summaryrefslogtreecommitdiffstats
path: root/comm/mail
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail')
-rw-r--r--comm/mail/Makefile.in28
-rw-r--r--comm/mail/actors/ChatActionChild.sys.mjs55
-rw-r--r--comm/mail/actors/ChatActionParent.sys.mjs32
-rw-r--r--comm/mail/actors/ContextMenuParent.sys.mjs45
-rw-r--r--comm/mail/actors/LinkClickHandlerChild.jsm178
-rw-r--r--comm/mail/actors/LinkClickHandlerParent.jsm30
-rw-r--r--comm/mail/actors/LinkHandlerParent.sys.mjs57
-rw-r--r--comm/mail/actors/MailLinkChild.jsm42
-rw-r--r--comm/mail/actors/MailLinkParent.jsm96
-rw-r--r--comm/mail/actors/PromptParent.jsm180
-rw-r--r--comm/mail/actors/VCardChild.jsm23
-rw-r--r--comm/mail/actors/VCardParent.jsm17
-rw-r--r--comm/mail/actors/moz.build24
-rw-r--r--comm/mail/app-system-headers.mozbuild18
-rw-r--r--comm/mail/app.mozbuild21
-rw-r--r--comm/mail/app/Makefile.in142
-rw-r--r--comm/mail/app/icons/gtk/calendar-alarm-dialog.pngbin0 -> 560 bytes
-rw-r--r--comm/mail/app/icons/gtk/calendar-general-dialog.pngbin0 -> 696 bytes
-rw-r--r--comm/mail/app/icons/gtk/msgcomposeWindow16.pngbin0 -> 453 bytes
-rw-r--r--comm/mail/app/icons/gtk/msgcomposeWindow24.pngbin0 -> 719 bytes
-rw-r--r--comm/mail/app/icons/gtk/msgcomposeWindow32.pngbin0 -> 872 bytes
-rw-r--r--comm/mail/app/icons/gtk/msgcomposeWindow48.pngbin0 -> 1408 bytes
-rw-r--r--comm/mail/app/icons/windows/calendar-alarm-dialog.icobin0 -> 17542 bytes
-rw-r--r--comm/mail/app/icons/windows/calendar-general-dialog.icobin0 -> 17542 bytes
-rw-r--r--comm/mail/app/icons/windows/msgcomposeWindow.icobin0 -> 17542 bytes
-rw-r--r--comm/mail/app/macbuild/Contents/Info.plist.in143
-rw-r--r--comm/mail/app/macbuild/Contents/MacOS-files-copy.in11
-rw-r--r--comm/mail/app/macbuild/Contents/MacOS-files.in18
-rw-r--r--comm/mail/app/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in5
-rw-r--r--comm/mail/app/macbuild/Contents/moz.build26
-rw-r--r--comm/mail/app/macversion.py44
-rw-r--r--comm/mail/app/module.ver11
-rw-r--r--comm/mail/app/moz.build132
-rw-r--r--comm/mail/app/no-pie/NoPie.c26
-rw-r--r--comm/mail/app/no-pie/moz.build24
-rw-r--r--comm/mail/app/nsMailApp.cpp402
-rw-r--r--comm/mail/app/permissions10
-rw-r--r--comm/mail/app/profile/all-thunderbird.js1428
-rw-r--r--comm/mail/app/profile/channel-prefs.js5
-rw-r--r--comm/mail/app/settings/dumps/moz.build7
-rw-r--r--comm/mail/app/settings/dumps/thunderbird/anti-tracking-url-decoration.json11
-rw-r--r--comm/mail/app/settings/dumps/thunderbird/hijack-blocklists.json59
-rw-r--r--comm/mail/app/settings/dumps/thunderbird/moz.build16
-rw-r--r--comm/mail/app/settings/dumps/thunderbird/password-recipes.json4
-rw-r--r--comm/mail/app/settings/dumps/thunderbird/records1
-rw-r--r--comm/mail/app/settings/dumps/thunderbird/search-config.json2152
-rw-r--r--comm/mail/app/settings/dumps/thunderbird/url-classifier-skip-urls.json4
-rw-r--r--comm/mail/app/settings/moz.build10
-rw-r--r--comm/mail/app/splash.rc24
-rw-r--r--comm/mail/app/thunderbird.exe.manifest47
-rw-r--r--comm/mail/base/content/FilterListDialog.js1162
-rw-r--r--comm/mail/base/content/FilterListDialog.xhtml168
-rw-r--r--comm/mail/base/content/SearchDialog.js650
-rw-r--r--comm/mail/base/content/SearchDialog.xhtml151
-rw-r--r--comm/mail/base/content/about3Pane.js7260
-rw-r--r--comm/mail/base/content/about3Pane.xhtml762
-rw-r--r--comm/mail/base/content/aboutAddonsExtra.js211
-rw-r--r--comm/mail/base/content/aboutDialog-appUpdater.js320
-rw-r--r--comm/mail/base/content/aboutDialog.css187
-rw-r--r--comm/mail/base/content/aboutDialog.js155
-rw-r--r--comm/mail/base/content/aboutDialog.xhtml184
-rw-r--r--comm/mail/base/content/aboutMessage.js617
-rw-r--r--comm/mail/base/content/aboutMessage.xhtml158
-rw-r--r--comm/mail/base/content/aboutRights.xhtml110
-rw-r--r--comm/mail/base/content/browserRequest.js145
-rw-r--r--comm/mail/base/content/browserRequest.xhtml58
-rw-r--r--comm/mail/base/content/buildconfig.html106
-rw-r--r--comm/mail/base/content/commonDialog.xhtml110
-rw-r--r--comm/mail/base/content/compactFoldersDialog.js54
-rw-r--r--comm/mail/base/content/compactFoldersDialog.xhtml54
-rw-r--r--comm/mail/base/content/contentAreaClick.js206
-rw-r--r--comm/mail/base/content/customElements.js35
-rw-r--r--comm/mail/base/content/customizeToolbar.js836
-rw-r--r--comm/mail/base/content/customizeToolbar.xhtml105
-rw-r--r--comm/mail/base/content/dialogShadowDom.js14
-rw-r--r--comm/mail/base/content/editContactPanel.inc.xhtml75
-rw-r--r--comm/mail/base/content/editContactPanel.js248
-rw-r--r--comm/mail/base/content/folderDisplay.js2649
-rw-r--r--comm/mail/base/content/globalOverlay.js122
-rw-r--r--comm/mail/base/content/glodaFacetTab.js111
-rw-r--r--comm/mail/base/content/glodaFacetView.js1114
-rw-r--r--comm/mail/base/content/glodaFacetView.xhtml123
-rw-r--r--comm/mail/base/content/glodaFacetViewWrapper.xhtml54
-rw-r--r--comm/mail/base/content/glodaFacetVis.js428
-rw-r--r--comm/mail/base/content/helpMenu.inc.xhtml43
-rw-r--r--comm/mail/base/content/hiddenWindowMac.js124
-rw-r--r--comm/mail/base/content/hiddenWindowMac.xhtml101
-rw-r--r--comm/mail/base/content/macMessengerMenu.js99
-rw-r--r--comm/mail/base/content/macWindowMenu.inc.xhtml22
-rw-r--r--comm/mail/base/content/mail-offline.js276
-rw-r--r--comm/mail/base/content/mail3PaneWindowCommands.js456
-rw-r--r--comm/mail/base/content/mailCommands.js667
-rw-r--r--comm/mail/base/content/mailCommon.js1126
-rw-r--r--comm/mail/base/content/mailContext.inc.xhtml324
-rw-r--r--comm/mail/base/content/mailContext.js822
-rw-r--r--comm/mail/base/content/mailCore.js1063
-rw-r--r--comm/mail/base/content/mailTabs.js390
-rw-r--r--comm/mail/base/content/mailWindow.js1153
-rw-r--r--comm/mail/base/content/mailWindowOverlay.js2177
-rw-r--r--comm/mail/base/content/mainCommandSet.inc.xhtml247
-rw-r--r--comm/mail/base/content/mainKeySet.inc.xhtml264
-rw-r--r--comm/mail/base/content/mainStatusbar.inc.xhtml19
-rw-r--r--comm/mail/base/content/messageWindow.js742
-rw-r--r--comm/mail/base/content/messageWindow.xhtml484
-rw-r--r--comm/mail/base/content/messenger-customization.js185
-rw-r--r--comm/mail/base/content/messenger-doctype.inc.dtd42
-rw-r--r--comm/mail/base/content/messenger-menubar.inc.xhtml1271
-rw-r--r--comm/mail/base/content/messenger-titlebar-items.inc.xhtml24
-rw-r--r--comm/mail/base/content/messenger.js1289
-rw-r--r--comm/mail/base/content/messenger.xhtml671
-rw-r--r--comm/mail/base/content/migrationProgress.js64
-rw-r--r--comm/mail/base/content/migrationProgress.xhtml39
-rw-r--r--comm/mail/base/content/minimizeToTray.js19
-rw-r--r--comm/mail/base/content/modules/thread-pane-columns.mjs385
-rw-r--r--comm/mail/base/content/msgAttachmentView.inc.xhtml102
-rw-r--r--comm/mail/base/content/msgHdrPopup.inc.xhtml224
-rw-r--r--comm/mail/base/content/msgHdrView.inc.xhtml559
-rw-r--r--comm/mail/base/content/msgHdrView.js4501
-rw-r--r--comm/mail/base/content/msgSecurityPane.inc.xhtml131
-rw-r--r--comm/mail/base/content/msgSecurityPane.js111
-rw-r--r--comm/mail/base/content/msgViewNavigation.js207
-rw-r--r--comm/mail/base/content/multimessageview.js844
-rw-r--r--comm/mail/base/content/multimessageview.xhtml79
-rw-r--r--comm/mail/base/content/newTagDialog.js108
-rw-r--r--comm/mail/base/content/newTagDialog.xhtml30
-rw-r--r--comm/mail/base/content/overrides/app-license-body.html1274
-rw-r--r--comm/mail/base/content/overrides/app-license-list.html33
-rw-r--r--comm/mail/base/content/overrides/app-license-name.html1
-rw-r--r--comm/mail/base/content/overrides/app-license.html9
-rw-r--r--comm/mail/base/content/printUtils.js428
-rw-r--r--comm/mail/base/content/profileDowngrade.js53
-rw-r--r--comm/mail/base/content/profileDowngrade.xhtml48
-rw-r--r--comm/mail/base/content/protovis-r2.6-modded.js5349
-rw-r--r--comm/mail/base/content/quickFilterBar.inc.xhtml118
-rw-r--r--comm/mail/base/content/quickFilterBar.js603
-rw-r--r--comm/mail/base/content/sanitize.js241
-rw-r--r--comm/mail/base/content/sanitize.xhtml92
-rw-r--r--comm/mail/base/content/sanitizeDialog.js206
-rw-r--r--comm/mail/base/content/searchBar.js45
-rw-r--r--comm/mail/base/content/selectionsummaries.js103
-rw-r--r--comm/mail/base/content/shortcutsOverlay.js123
-rw-r--r--comm/mail/base/content/spacesToolbar.inc.xhtml166
-rw-r--r--comm/mail/base/content/spacesToolbar.js1325
-rw-r--r--comm/mail/base/content/spacesToolbarPin.inc.xhtml46
-rw-r--r--comm/mail/base/content/specialTabs.js1320
-rw-r--r--comm/mail/base/content/sync.js157
-rw-r--r--comm/mail/base/content/systemIntegrationDialog.js188
-rw-r--r--comm/mail/base/content/systemIntegrationDialog.xhtml53
-rw-r--r--comm/mail/base/content/tabDialogs.inc.xhtml23
-rw-r--r--comm/mail/base/content/tabmail.js2048
-rw-r--r--comm/mail/base/content/tagDialog.inc.xhtml27
-rw-r--r--comm/mail/base/content/threadPane.js825
-rw-r--r--comm/mail/base/content/threadTree.inc.xhtml230
-rw-r--r--comm/mail/base/content/toolbarIconColor.js166
-rw-r--r--comm/mail/base/content/troubleshootMode.js74
-rw-r--r--comm/mail/base/content/troubleshootMode.xhtml54
-rw-r--r--comm/mail/base/content/utilityOverlay.js514
-rw-r--r--comm/mail/base/content/viewSource.js168
-rw-r--r--comm/mail/base/content/viewSource.xhtml245
-rw-r--r--comm/mail/base/content/viewZoomOverlay.js153
-rw-r--r--comm/mail/base/content/webextensions.css106
-rw-r--r--comm/mail/base/content/widgets/browserPopups.inc.xhtml192
-rw-r--r--comm/mail/base/content/widgets/browserPopups.js991
-rw-r--r--comm/mail/base/content/widgets/customizable-toolbar.js319
-rw-r--r--comm/mail/base/content/widgets/foldersummary.js295
-rw-r--r--comm/mail/base/content/widgets/gloda-autocomplete-input.js243
-rw-r--r--comm/mail/base/content/widgets/glodaFacet.js1823
-rw-r--r--comm/mail/base/content/widgets/header-fields.js973
-rw-r--r--comm/mail/base/content/widgets/mailWidgets.js2477
-rw-r--r--comm/mail/base/content/widgets/pane-splitter.js562
-rw-r--r--comm/mail/base/content/widgets/statuspanel.js78
-rw-r--r--comm/mail/base/content/widgets/tabmail-tab.js179
-rw-r--r--comm/mail/base/content/widgets/tabmail-tabs.js723
-rw-r--r--comm/mail/base/content/widgets/toolbarContext.inc.xhtml19
-rw-r--r--comm/mail/base/content/widgets/toolbarbutton-menu-button.js80
-rw-r--r--comm/mail/base/content/widgets/tree-listbox.js914
-rw-r--r--comm/mail/base/content/widgets/tree-selection.mjs744
-rw-r--r--comm/mail/base/content/widgets/tree-view.mjs2633
-rw-r--r--comm/mail/base/jar.mn144
-rw-r--r--comm/mail/base/moz.build54
-rw-r--r--comm/mail/base/test/browser/browser-detachedWindows.ini15
-rw-r--r--comm/mail/base/test/browser/browser-drawBelowTitlebar.ini17
-rw-r--r--comm/mail/base/test/browser/browser-drawInTitlebar.ini17
-rw-r--r--comm/mail/base/test/browser/browser.ini66
-rw-r--r--comm/mail/base/test/browser/browser_3paneTelemetry.js163
-rw-r--r--comm/mail/base/test/browser/browser_archive.js98
-rw-r--r--comm/mail/base/test/browser/browser_browserContext.js398
-rw-r--r--comm/mail/base/test/browser/browser_browserRequestWindow.js74
-rw-r--r--comm/mail/base/test/browser/browser_cardsView.js248
-rw-r--r--comm/mail/base/test/browser/browser_detachedWindows.js223
-rw-r--r--comm/mail/base/test/browser/browser_editMenu.js511
-rw-r--r--comm/mail/base/test/browser/browser_fileMenu.js137
-rw-r--r--comm/mail/base/test/browser/browser_folderPaneContext.js198
-rw-r--r--comm/mail/base/test/browser/browser_folderTreeProperties.js236
-rw-r--r--comm/mail/base/test/browser/browser_folderTreeQuirks.js1450
-rw-r--r--comm/mail/base/test/browser/browser_formPickers.js352
-rw-r--r--comm/mail/base/test/browser/browser_goMenu.js35
-rw-r--r--comm/mail/base/test/browser/browser_interactionTelemetry.js67
-rw-r--r--comm/mail/base/test/browser/browser_linkHandler.js294
-rw-r--r--comm/mail/base/test/browser/browser_mailContext.js950
-rw-r--r--comm/mail/base/test/browser/browser_mailTabsAndWindows.js355
-rw-r--r--comm/mail/base/test/browser/browser_markAsRead.js204
-rw-r--r--comm/mail/base/test/browser/browser_menulist.js183
-rw-r--r--comm/mail/base/test/browser/browser_messageMenu.js355
-rw-r--r--comm/mail/base/test/browser/browser_navigation.js1035
-rw-r--r--comm/mail/base/test/browser/browser_orderableTreeListbox.js481
-rw-r--r--comm/mail/base/test/browser/browser_paneFocus.js375
-rw-r--r--comm/mail/base/test/browser/browser_paneSplitter.js572
-rw-r--r--comm/mail/base/test/browser/browser_preferDisplayName.js456
-rw-r--r--comm/mail/base/test/browser/browser_searchMessages.js460
-rw-r--r--comm/mail/base/test/browser/browser_selectionWidgetController.js6196
-rw-r--r--comm/mail/base/test/browser/browser_smartFolderDelete.js75
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar.js1173
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbarCustomize.js119
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js24
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js24
-rw-r--r--comm/mail/base/test/browser/browser_statusFeedback.js71
-rw-r--r--comm/mail/base/test/browser/browser_tabIcon.js99
-rw-r--r--comm/mail/base/test/browser/browser_tagsMode.js214
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeDeleting.js572
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeQuirks.js669
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeSorting.js344
-rw-r--r--comm/mail/base/test/browser/browser_threads.js385
-rw-r--r--comm/mail/base/test/browser/browser_toolsMenu.js109
-rw-r--r--comm/mail/base/test/browser/browser_treeListbox.js1313
-rw-r--r--comm/mail/base/test/browser/browser_treeView.js1941
-rw-r--r--comm/mail/base/test/browser/browser_viewMenu.js218
-rw-r--r--comm/mail/base/test/browser/browser_webSearchTelemetry.js50
-rw-r--r--comm/mail/base/test/browser/browser_zoom.js110
-rw-r--r--comm/mail/base/test/browser/files/formContent.html36
-rw-r--r--comm/mail/base/test/browser/files/links.html38
-rw-r--r--comm/mail/base/test/browser/files/menulist.xhtml30
-rw-r--r--comm/mail/base/test/browser/files/orderableTreeListbox.xhtml171
-rw-r--r--comm/mail/base/test/browser/files/paneSplitter.xhtml122
-rw-r--r--comm/mail/base/test/browser/files/rss.xml16
-rw-r--r--comm/mail/base/test/browser/files/sampleContent.eml160
-rw-r--r--comm/mail/base/test/browser/files/sampleContent.html16
-rw-r--r--comm/mail/base/test/browser/files/selectionWidget.js225
-rw-r--r--comm/mail/base/test/browser/files/selectionWidget.xhtml57
-rw-r--r--comm/mail/base/test/browser/files/tb-logo.pngbin0 -> 6462 bytes
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-common.js73
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-header.js64
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-header.xhtml61
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-levels.js118
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-levels.xhtml65
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-no-header.js58
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml54
-rw-r--r--comm/mail/base/test/browser/files/treeListbox.xhtml390
-rw-r--r--comm/mail/base/test/browser/head.js371
-rw-r--r--comm/mail/base/test/browser/head_spacesToolbar.js34
-rw-r--r--comm/mail/base/test/moz.build22
-rw-r--r--comm/mail/base/test/performance/browser.ini23
-rw-r--r--comm/mail/base/test/performance/browser_preferences_usage.js177
-rw-r--r--comm/mail/base/test/performance/browser_startup.js277
-rw-r--r--comm/mail/base/test/unit/distribution.ini56
-rw-r--r--comm/mail/base/test/unit/head_mailbase.js20
-rw-r--r--comm/mail/base/test/unit/head_mailbase_maildir.js9
-rw-r--r--comm/mail/base/test/unit/resources/viewWrapperTestUtils.js534
-rw-r--r--comm/mail/base/test/unit/test_alertHook.js119
-rw-r--r--comm/mail/base/test/unit/test_attachmentChecker.js121
-rw-r--r--comm/mail/base/test/unit/test_devtools_url.js22
-rw-r--r--comm/mail/base/test/unit/test_emptyTrash_dbViewWrapper.js43
-rw-r--r--comm/mail/base/test/unit/test_mailGlue_distribution.js120
-rw-r--r--comm/mail/base/test/unit/test_oauth_migration.js319
-rw-r--r--comm/mail/base/test/unit/test_treeSelection.js581
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_imapFolder.js55
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_logic.js359
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_realFolder.js666
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_virtualFolder.js552
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_virtualFolderCustomTerm.js65
-rw-r--r--comm/mail/base/test/unit/xpcshell.ini25
-rw-r--r--comm/mail/base/test/unit/xpcshell_maildir.ini6
-rw-r--r--comm/mail/base/test/webextensions/.eslintrc.js13
-rw-r--r--comm/mail/base/test/webextensions/browser.ini41
-rw-r--r--comm/mail/base/test/webextensions/browser_extension_install_experiment.js82
-rw-r--r--comm/mail/base/test/webextensions/browser_extension_sideloading.js352
-rw-r--r--comm/mail/base/test/webextensions/browser_extension_update_background.js263
-rw-r--r--comm/mail/base/test/webextensions/browser_extension_update_background_noprompt.js116
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_installTrigger.js27
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_local_file.js46
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_mozAddonManager.js19
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_optional.js53
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_pointerevent.js65
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_unsigned.js49
-rw-r--r--comm/mail/base/test/webextensions/browser_update_checkForUpdates.js17
-rw-r--r--comm/mail/base/test/webextensions/browser_update_interactive_noprompt.js82
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_experiment.xpibin0 -> 2492 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpibin0 -> 2510 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_experiment_update1.xpibin0 -> 331 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpibin0 -> 2547 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_nopermissions.xpibin0 -> 4273 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_permissions.xpibin0 -> 16638 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_unsigned.xpibin0 -> 12606 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update.json82
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update1.xpibin0 -> 4311 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update2.xpibin0 -> 4331 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_icon1.xpibin0 -> 16585 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_icon2.xpibin0 -> 16604 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_origins1.xpibin0 -> 268 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_origins2.xpibin0 -> 275 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_perms1.xpibin0 -> 4273 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_perms2.xpibin0 -> 4282 bytes
-rw-r--r--comm/mail/base/test/webextensions/file_install_extensions.html19
-rw-r--r--comm/mail/base/test/webextensions/head.js632
-rw-r--r--comm/mail/branding/branding-common.mozbuild38
-rw-r--r--comm/mail/branding/nightly/TB-symbolic.svg6
-rw-r--r--comm/mail/branding/nightly/VisualElements_150.pngbin0 -> 27252 bytes
-rw-r--r--comm/mail/branding/nightly/VisualElements_70.pngbin0 -> 8325 bytes
-rw-r--r--comm/mail/branding/nightly/background.pngbin0 -> 127356 bytes
-rwxr-xr-xcomm/mail/branding/nightly/branding.nsi16
-rw-r--r--comm/mail/branding/nightly/configure.sh6
-rw-r--r--comm/mail/branding/nightly/content/about-background.pngbin0 -> 78649 bytes
-rw-r--r--comm/mail/branding/nightly/content/about-logo.svg144
-rw-r--r--comm/mail/branding/nightly/content/about-wordmark.svg8
-rw-r--r--comm/mail/branding/nightly/content/about.pngbin0 -> 38687 bytes
-rw-r--r--comm/mail/branding/nightly/content/aboutDialog.css38
-rw-r--r--comm/mail/branding/nightly/content/logo-gradient.svg139
-rw-r--r--comm/mail/branding/nightly/default128.pngbin0 -> 14123 bytes
-rw-r--r--comm/mail/branding/nightly/default16.pngbin0 -> 698 bytes
-rw-r--r--comm/mail/branding/nightly/default22.pngbin0 -> 1105 bytes
-rw-r--r--comm/mail/branding/nightly/default24.pngbin0 -> 1107 bytes
-rw-r--r--comm/mail/branding/nightly/default256.pngbin0 -> 33487 bytes
-rw-r--r--comm/mail/branding/nightly/default32.pngbin0 -> 1884 bytes
-rw-r--r--comm/mail/branding/nightly/default48.pngbin0 -> 3414 bytes
-rw-r--r--comm/mail/branding/nightly/default64.pngbin0 -> 5234 bytes
-rw-r--r--comm/mail/branding/nightly/disk.icnsbin0 -> 146449 bytes
-rwxr-xr-xcomm/mail/branding/nightly/dsstorebin0 -> 10244 bytes
-rw-r--r--comm/mail/branding/nightly/jar.mn19
-rwxr-xr-xcomm/mail/branding/nightly/locales/en-US/brand.dtd13
-rw-r--r--comm/mail/branding/nightly/locales/en-US/brand.ftl21
-rwxr-xr-xcomm/mail/branding/nightly/locales/en-US/brand.properties8
-rwxr-xr-xcomm/mail/branding/nightly/locales/jar.mn14
-rw-r--r--comm/mail/branding/nightly/locales/moz.build8
-rw-r--r--comm/mail/branding/nightly/messengerWindow.icobin0 -> 363246 bytes
-rw-r--r--comm/mail/branding/nightly/moz.build12
-rw-r--r--comm/mail/branding/nightly/msix/Assets/Calendar44x44.pngbin0 -> 2313 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/Email44x44.pngbin0 -> 2306 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/LargeTile.scale-200.pngbin0 -> 227049 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/News44x44.pngbin0 -> 1643 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/SmallTile.scale-200.pngbin0 -> 7607 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/Square150x150Logo.scale-200.pngbin0 -> 65154 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.pngbin0 -> 54235 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/Square44x44Logo.altform-unplated_targetsize-256.pngbin0 -> 54235 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/Square44x44Logo.scale-200.pngbin0 -> 6820 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/Square44x44Logo.targetsize-256.pngbin0 -> 37996 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/StoreLogo.scale-200.pngbin0 -> 12011 bytes
-rw-r--r--comm/mail/branding/nightly/msix/Assets/Wide310x150Logo.scale-200.pngbin0 -> 14963 bytes
-rw-r--r--comm/mail/branding/nightly/newmail.icobin0 -> 21238 bytes
-rw-r--r--comm/mail/branding/nightly/pref/thunderbird-branding.js37
-rw-r--r--comm/mail/branding/nightly/thunderbird.VisualElementsManifest.xml12
-rw-r--r--comm/mail/branding/nightly/thunderbird.icnsbin0 -> 197275 bytes
-rwxr-xr-xcomm/mail/branding/nightly/wizHeader.bmpbin0 -> 34254 bytes
-rwxr-xr-xcomm/mail/branding/nightly/wizHeaderRTL.bmpbin0 -> 34254 bytes
-rwxr-xr-xcomm/mail/branding/nightly/wizWatermark.bmpbin0 -> 206038 bytes
-rw-r--r--comm/mail/branding/nightly/writeMessage.icobin0 -> 337 bytes
-rw-r--r--comm/mail/branding/thunderbird/LICENSE10
-rw-r--r--comm/mail/branding/thunderbird/TB-symbolic.svg7
-rw-r--r--comm/mail/branding/thunderbird/VisualElements_150.pngbin0 -> 34117 bytes
-rw-r--r--comm/mail/branding/thunderbird/VisualElements_70.pngbin0 -> 10134 bytes
-rw-r--r--comm/mail/branding/thunderbird/background.pngbin0 -> 107557 bytes
-rw-r--r--comm/mail/branding/thunderbird/branding.nsi48
-rw-r--r--comm/mail/branding/thunderbird/configure.sh5
-rw-r--r--comm/mail/branding/thunderbird/content/about-logo.svg79
-rw-r--r--comm/mail/branding/thunderbird/content/about-wordmark.svg7
-rw-r--r--comm/mail/branding/thunderbird/content/about.pngbin0 -> 66797 bytes
-rw-r--r--comm/mail/branding/thunderbird/content/aboutDialog.css38
-rw-r--r--comm/mail/branding/thunderbird/content/logo-gradient.svg79
-rw-r--r--comm/mail/branding/thunderbird/default128.pngbin0 -> 13506 bytes
-rw-r--r--comm/mail/branding/thunderbird/default16.pngbin0 -> 819 bytes
-rw-r--r--comm/mail/branding/thunderbird/default22.pngbin0 -> 1220 bytes
-rw-r--r--comm/mail/branding/thunderbird/default24.pngbin0 -> 1337 bytes
-rw-r--r--comm/mail/branding/thunderbird/default256.pngbin0 -> 32928 bytes
-rw-r--r--comm/mail/branding/thunderbird/default32.pngbin0 -> 2017 bytes
-rw-r--r--comm/mail/branding/thunderbird/default48.pngbin0 -> 3525 bytes
-rw-r--r--comm/mail/branding/thunderbird/default64.pngbin0 -> 5286 bytes
-rw-r--r--comm/mail/branding/thunderbird/disk.icnsbin0 -> 1421821 bytes
-rwxr-xr-xcomm/mail/branding/thunderbird/dsstorebin0 -> 10244 bytes
-rw-r--r--comm/mail/branding/thunderbird/jar.mn18
-rw-r--r--comm/mail/branding/thunderbird/locales/Makefile.in8
-rw-r--r--comm/mail/branding/thunderbird/locales/en-US/brand.dtd13
-rw-r--r--comm/mail/branding/thunderbird/locales/en-US/brand.ftl21
-rw-r--r--comm/mail/branding/thunderbird/locales/en-US/brand.properties7
-rwxr-xr-xcomm/mail/branding/thunderbird/locales/jar.mn12
-rw-r--r--comm/mail/branding/thunderbird/locales/moz.build6
-rw-r--r--comm/mail/branding/thunderbird/messengerWindow.icobin0 -> 118835 bytes
-rw-r--r--comm/mail/branding/thunderbird/moz.build11
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/Calendar44x44.pngbin0 -> 2248 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/Email44x44.pngbin0 -> 2294 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/LargeTile.scale-200.pngbin0 -> 232128 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/News44x44.pngbin0 -> 1617 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/SmallTile.scale-200.pngbin0 -> 7580 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/Square150x150Logo.scale-200.pngbin0 -> 64428 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.pngbin0 -> 54081 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.altform-unplated_targetsize-256.pngbin0 -> 54081 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.scale-200.pngbin0 -> 6544 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.targetsize-256.pngbin0 -> 37370 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/StoreLogo.scale-200.pngbin0 -> 11450 bytes
-rw-r--r--comm/mail/branding/thunderbird/msix/Assets/Wide310x150Logo.scale-200.pngbin0 -> 14391 bytes
-rw-r--r--comm/mail/branding/thunderbird/net.thunderbird.Thunderbird.appdata.xml51
-rw-r--r--comm/mail/branding/thunderbird/newmail.icobin0 -> 21238 bytes
-rw-r--r--comm/mail/branding/thunderbird/pref/thunderbird-branding.js40
-rw-r--r--comm/mail/branding/thunderbird/thunderbird.VisualElementsManifest.xml8
-rw-r--r--comm/mail/branding/thunderbird/thunderbird.icnsbin0 -> 1017590 bytes
-rw-r--r--comm/mail/branding/thunderbird/wizHeader.bmpbin0 -> 25818 bytes
-rw-r--r--comm/mail/branding/thunderbird/wizHeaderRTL.bmpbin0 -> 25818 bytes
-rw-r--r--comm/mail/branding/thunderbird/wizWatermark.bmpbin0 -> 154542 bytes
-rw-r--r--comm/mail/branding/thunderbird/writeMessage.icobin0 -> 337 bytes
-rw-r--r--comm/mail/build.mk40
-rw-r--r--comm/mail/components/AboutRedirector.jsm124
-rw-r--r--comm/mail/components/AppIdleManager.jsm45
-rw-r--r--comm/mail/components/MailComponents.manifest9
-rw-r--r--comm/mail/components/MailGlue.jsm1380
-rw-r--r--comm/mail/components/MessengerContentHandler.jsm793
-rw-r--r--comm/mail/components/StartupRecorder.jsm229
-rw-r--r--comm/mail/components/about-support/AboutSupportMac.jsm16
-rw-r--r--comm/mail/components/about-support/AboutSupportUnix.jsm137
-rw-r--r--comm/mail/components/about-support/AboutSupportWin32.jsm77
-rw-r--r--comm/mail/components/about-support/content/aboutSupport.js1729
-rw-r--r--comm/mail/components/about-support/content/aboutSupport.xhtml956
-rw-r--r--comm/mail/components/about-support/content/accounts.js339
-rw-r--r--comm/mail/components/about-support/content/calendars.js77
-rw-r--r--comm/mail/components/about-support/content/chat.js73
-rw-r--r--comm/mail/components/about-support/content/export.js288
-rw-r--r--comm/mail/components/about-support/content/libs.js24
-rw-r--r--comm/mail/components/about-support/jar.mn13
-rw-r--r--comm/mail/components/about-support/moz.build13
-rw-r--r--comm/mail/components/accountcreation/AccountConfig.jsm463
-rw-r--r--comm/mail/components/accountcreation/AccountCreationUtils.jsm717
-rw-r--r--comm/mail/components/accountcreation/ConfigVerifier.jsm386
-rw-r--r--comm/mail/components/accountcreation/CreateInBackend.jsm459
-rw-r--r--comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm676
-rw-r--r--comm/mail/components/accountcreation/FetchConfig.jsm299
-rw-r--r--comm/mail/components/accountcreation/FetchHTTP.jsm401
-rw-r--r--comm/mail/components/accountcreation/GuessConfig.jsm1317
-rw-r--r--comm/mail/components/accountcreation/Sanitizer.jsm249
-rw-r--r--comm/mail/components/accountcreation/content/accountHub.js277
-rw-r--r--comm/mail/components/accountcreation/content/accountSetup.js3023
-rw-r--r--comm/mail/components/accountcreation/content/accountSetup.xhtml1333
-rw-r--r--comm/mail/components/accountcreation/jar.mn12
-rw-r--r--comm/mail/components/accountcreation/moz.build23
-rw-r--r--comm/mail/components/accountcreation/readFromXML.jsm352
-rw-r--r--comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml158
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml21
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js76
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js319
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js266
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini9
-rw-r--r--comm/mail/components/accountcreation/views/container.mjs50
-rw-r--r--comm/mail/components/accountcreation/views/email.mjs185
-rw-r--r--comm/mail/components/accountcreation/views/start.mjs163
-rw-r--r--comm/mail/components/activity/Activity.jsm322
-rw-r--r--comm/mail/components/activity/ActivityManager.jsm157
-rw-r--r--comm/mail/components/activity/ActivityManagerUI.jsm47
-rw-r--r--comm/mail/components/activity/components.conf38
-rw-r--r--comm/mail/components/activity/content/activity-widgets.js384
-rw-r--r--comm/mail/components/activity/content/activity.js239
-rw-r--r--comm/mail/components/activity/content/activity.xhtml61
-rw-r--r--comm/mail/components/activity/jar.mn8
-rw-r--r--comm/mail/components/activity/modules/activityModules.jsm33
-rw-r--r--comm/mail/components/activity/modules/alertHook.jsm101
-rw-r--r--comm/mail/components/activity/modules/autosync.jsm433
-rw-r--r--comm/mail/components/activity/modules/glodaIndexer.jsm251
-rw-r--r--comm/mail/components/activity/modules/moveCopy.jsm396
-rw-r--r--comm/mail/components/activity/modules/pop3Download.jsm154
-rw-r--r--comm/mail/components/activity/modules/sendLater.jsm298
-rw-r--r--comm/mail/components/activity/moz.build34
-rw-r--r--comm/mail/components/activity/nsIActivity.idl492
-rw-r--r--comm/mail/components/activity/nsIActivityManager.idl135
-rw-r--r--comm/mail/components/activity/nsIActivityManagerUI.idl50
-rw-r--r--comm/mail/components/addrbook/content/abCommon.js145
-rw-r--r--comm/mail/components/addrbook/content/abContactsPanel.js374
-rw-r--r--comm/mail/components/addrbook/content/abContactsPanel.xhtml234
-rw-r--r--comm/mail/components/addrbook/content/abEditListDialog.xhtml99
-rw-r--r--comm/mail/components/addrbook/content/abMailListDialog.xhtml116
-rw-r--r--comm/mail/components/addrbook/content/abSearchDialog.js408
-rw-r--r--comm/mail/components/addrbook/content/abSearchDialog.xhtml200
-rw-r--r--comm/mail/components/addrbook/content/abView-new.js577
-rw-r--r--comm/mail/components/addrbook/content/aboutAddressBook.js4445
-rw-r--r--comm/mail/components/addrbook/content/aboutAddressBook.xhtml460
-rw-r--r--comm/mail/components/addrbook/content/addressBookTab.js172
-rw-r--r--comm/mail/components/addrbook/content/menulist-addrbooks.js271
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/adr.mjs149
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/custom.mjs60
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/edit.mjs1094
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/email.mjs135
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/fn.mjs71
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs12
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/impp.mjs97
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/n.mjs186
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/nickname.mjs59
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/note.mjs82
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/org.mjs197
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/special-date.mjs269
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/tel.mjs83
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/tz.mjs86
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/url.mjs89
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml398
-rw-r--r--comm/mail/components/addrbook/jar.mn35
-rw-r--r--comm/mail/components/addrbook/moz.build10
-rw-r--r--comm/mail/components/addrbook/test/browser/browser.ini37
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js664
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js143
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js245
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js138
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js470
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_contact_tree.js1261
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_directory_tree.js982
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_display_card.js1020
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_display_multiple.js468
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_drag_drop.js417
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_async.js363
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_card.js3517
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_photo.js866
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_ldap_search.js180
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_mailing_lists.js474
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_open_actions.js157
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_search.js139
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_telemetry.js59
-rw-r--r--comm/mail/components/addrbook/test/browser/data/addressbook.sjs47
-rw-r--r--comm/mail/components/addrbook/test/browser/data/addressbooks.sjs62
-rw-r--r--comm/mail/components/addrbook/test/browser/data/auth_headers.sjs26
-rw-r--r--comm/mail/components/addrbook/test/browser/data/dns.sjs48
-rw-r--r--comm/mail/components/addrbook/test/browser/data/photo1.jpgbin0 -> 36775 bytes
-rw-r--r--comm/mail/components/addrbook/test/browser/data/photo2.jpgbin0 -> 38826 bytes
-rw-r--r--comm/mail/components/addrbook/test/browser/data/principal.sjs38
-rw-r--r--comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs21
-rw-r--r--comm/mail/components/addrbook/test/browser/data/token.sjs36
-rw-r--r--comm/mail/components/addrbook/test/browser/head.js445
-rw-r--r--comm/mail/components/cloudfile/cloudFileAccounts.jsm215
-rw-r--r--comm/mail/components/cloudfile/content/selectDialog.js17
-rw-r--r--comm/mail/components/cloudfile/content/selectDialog.xhtml32
-rw-r--r--comm/mail/components/cloudfile/jar.mn7
-rw-r--r--comm/mail/components/cloudfile/moz.build14
-rw-r--r--comm/mail/components/cloudfile/test/browser/browser.ini13
-rw-r--r--comm/mail/components/cloudfile/test/browser/browser_repeat_upload.js246
-rw-r--r--comm/mail/components/cloudfile/test/browser/files/green_eggs.txt1
-rw-r--r--comm/mail/components/cloudfile/test/browser/files/icon.svg7
-rw-r--r--comm/mail/components/cloudfile/test/browser/files/management.html10
-rw-r--r--comm/mail/components/cloudfile/test/browser/head.js48
-rw-r--r--comm/mail/components/components.conf76
-rw-r--r--comm/mail/components/compose/composer.js65
-rw-r--r--comm/mail/components/compose/content/ComposerCommands.js2261
-rw-r--r--comm/mail/components/compose/content/MsgComposeCommands.js11654
-rw-r--r--comm/mail/components/compose/content/addressingWidgetOverlay.js1336
-rw-r--r--comm/mail/components/compose/content/bigFileObserver.js368
-rw-r--r--comm/mail/components/compose/content/cloudAttachmentLinkManager.js758
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAEAttributes.js973
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js146
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js362
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js200
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js342
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml243
-rw-r--r--comm/mail/components/compose/content/dialogs/EdColorPicker.js290
-rw-r--r--comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml103
-rw-r--r--comm/mail/components/compose/content/dialogs/EdColorProps.js476
-rw-r--r--comm/mail/components/compose/content/dialogs/EdColorProps.xhtml211
-rw-r--r--comm/mail/components/compose/content/dialogs/EdConvertToTable.js325
-rw-r--r--comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml86
-rw-r--r--comm/mail/components/compose/content/dialogs/EdDialogCommon.js679
-rw-r--r--comm/mail/components/compose/content/dialogs/EdDictionary.js138
-rw-r--r--comm/mail/components/compose/content/dialogs/EdDictionary.xhtml88
-rw-r--r--comm/mail/components/compose/content/dialogs/EdHLineProps.js227
-rw-r--r--comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml131
-rw-r--r--comm/mail/components/compose/content/dialogs/EdImageDialog.js639
-rw-r--r--comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js144
-rw-r--r--comm/mail/components/compose/content/dialogs/EdImageProps.js293
-rw-r--r--comm/mail/components/compose/content/dialogs/EdImageProps.xhtml454
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsSrc.js162
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml67
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertChars.js412
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml92
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertMath.js317
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml73
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertTOC.js378
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml505
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertTable.js258
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml126
-rw-r--r--comm/mail/components/compose/content/dialogs/EdLinkProps.js323
-rw-r--r--comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml112
-rw-r--r--comm/mail/components/compose/content/dialogs/EdListProps.js455
-rw-r--r--comm/mail/components/compose/content/dialogs/EdListProps.xhtml101
-rw-r--r--comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js159
-rw-r--r--comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml67
-rw-r--r--comm/mail/components/compose/content/dialogs/EdReplace.js380
-rw-r--r--comm/mail/components/compose/content/dialogs/EdReplace.xhtml126
-rw-r--r--comm/mail/components/compose/content/dialogs/EdSpellCheck.js496
-rw-r--r--comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml209
-rw-r--r--comm/mail/components/compose/content/dialogs/EdTableProps.js1426
-rw-r--r--comm/mail/components/compose/content/dialogs/EdTableProps.xhtml472
-rw-r--r--comm/mail/components/compose/content/editFormatButtons.inc.xhtml282
-rw-r--r--comm/mail/components/compose/content/editor.js2392
-rw-r--r--comm/mail/components/compose/content/editorUtilities.js1015
-rw-r--r--comm/mail/components/compose/content/images/tag-anchor.gifbin0 -> 127 bytes
-rw-r--r--comm/mail/components/compose/content/messengercompose.xhtml2572
-rw-r--r--comm/mail/components/compose/jar.mn58
-rw-r--r--comm/mail/components/compose/moz.build8
-rw-r--r--comm/mail/components/compose/texzilla/TeXZilla.js339
-rw-r--r--comm/mail/components/customizableui/CustomizableUI.sys.mjs360
-rw-r--r--comm/mail/components/customizableui/PanelMultiView.sys.mjs1699
-rw-r--r--comm/mail/components/customizableui/content/customizeMode.inc.xhtml128
-rw-r--r--comm/mail/components/customizableui/content/jar.mn6
-rw-r--r--comm/mail/components/customizableui/content/moz.build7
-rw-r--r--comm/mail/components/customizableui/content/panelUI.inc.xhtml606
-rw-r--r--comm/mail/components/customizableui/content/panelUI.js882
-rw-r--r--comm/mail/components/customizableui/moz.build14
-rw-r--r--comm/mail/components/devtools/components.conf15
-rw-r--r--comm/mail/components/devtools/devtools-loader.jsm80
-rw-r--r--comm/mail/components/devtools/moz.build13
-rw-r--r--comm/mail/components/devtools/tb-root-actor.js104
-rw-r--r--comm/mail/components/downloads/content/aboutDownloads.js414
-rw-r--r--comm/mail/components/downloads/content/aboutDownloads.xhtml98
-rw-r--r--comm/mail/components/downloads/jar.mn7
-rw-r--r--comm/mail/components/downloads/moz.build5
-rw-r--r--comm/mail/components/enterprisepolicies/Policies.sys.mjs1758
-rw-r--r--comm/mail/components/enterprisepolicies/content/aboutPolicies.js410
-rw-r--r--comm/mail/components/enterprisepolicies/content/aboutPolicies.xhtml107
-rw-r--r--comm/mail/components/enterprisepolicies/content/policies-active.svg6
-rw-r--r--comm/mail/components/enterprisepolicies/content/policies-documentation.svg6
-rw-r--r--comm/mail/components/enterprisepolicies/content/policies-error.svg6
-rw-r--r--comm/mail/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs111
-rw-r--r--comm/mail/components/enterprisepolicies/helpers/moz.build12
-rw-r--r--comm/mail/components/enterprisepolicies/jar.mn10
-rw-r--r--comm/mail/components/enterprisepolicies/moz.build23
-rw-r--r--comm/mail/components/enterprisepolicies/schemas/configuration.json10
-rw-r--r--comm/mail/components/enterprisepolicies/schemas/moz.build12
-rw-r--r--comm/mail/components/enterprisepolicies/schemas/policies-schema.json634
-rw-r--r--comm/mail/components/enterprisepolicies/schemas/schema.sys.mjs16
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser.ini32
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js179
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js92
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_update.js41
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js104
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_block_about.js87
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js323
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js90
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js49
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js21
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_downloads.js147
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensions.js120
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js261
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js73
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_handlers.js183
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js104
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js27
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser.ini15
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js109
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json5
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.ini13
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js55
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json5
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/extensionsettings.html23
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.ini12
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js9
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json5
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/head.js103
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpibin0 -> 305 bytes
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.2.xpibin0 -> 297 bytes
-rw-r--r--comm/mail/components/enterprisepolicies/tests/moz.build16
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/head.js140
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js22
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js80
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js25
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js44
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js118
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js110
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js490
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_preferences.js255
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_proxy.js122
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js47
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js21
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js378
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js48
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/xpcshell.ini18
-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
-rw-r--r--comm/mail/components/im/IMIncomingServer.sys.mjs359
-rw-r--r--comm/mail/components/im/IMProtocolInfo.sys.mjs49
-rw-r--r--comm/mail/components/im/all-im.js14
-rw-r--r--comm/mail/components/im/components.conf20
-rw-r--r--comm/mail/components/im/content/.eslintrc.js22
-rw-r--r--comm/mail/components/im/content/addbuddy.js58
-rw-r--r--comm/mail/components/im/content/addbuddy.xhtml59
-rw-r--r--comm/mail/components/im/content/am-im.js291
-rw-r--r--comm/mail/components/im/content/am-im.xhtml235
-rw-r--r--comm/mail/components/im/content/chat-contact.js282
-rw-r--r--comm/mail/components/im/content/chat-conversation-info.js353
-rw-r--r--comm/mail/components/im/content/chat-conversation.js1760
-rw-r--r--comm/mail/components/im/content/chat-group.js255
-rw-r--r--comm/mail/components/im/content/chat-imconv.js366
-rw-r--r--comm/mail/components/im/content/chat-menu.inc.xhtml109
-rw-r--r--comm/mail/components/im/content/chat-messenger.inc.xhtml192
-rw-r--r--comm/mail/components/im/content/chat-messenger.js2162
-rw-r--r--comm/mail/components/im/content/imAccountWizard.js526
-rw-r--r--comm/mail/components/im/content/imAccountWizard.xhtml180
-rw-r--r--comm/mail/components/im/content/imAccounts.js663
-rw-r--r--comm/mail/components/im/content/imAccounts.xhtml250
-rw-r--r--comm/mail/components/im/content/imContextMenu.js276
-rw-r--r--comm/mail/components/im/content/imStatusSelector.js383
-rw-r--r--comm/mail/components/im/content/joinchat.js195
-rw-r--r--comm/mail/components/im/content/joinchat.xhtml58
-rw-r--r--comm/mail/components/im/content/toolbarbutton-badge-button.js70
-rw-r--r--comm/mail/components/im/content/verify.js53
-rw-r--r--comm/mail/components/im/content/verify.xhtml46
-rw-r--r--comm/mail/components/im/jar.mn199
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.pngbin0 -> 581 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.pngbin0 -> 658 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.pngbin0 -> 592 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.pngbin0 -> 596 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.pngbin0 -> 666 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.pngbin0 -> 600 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.pngbin0 -> 676 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.pngbin0 -> 589 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.pngbin0 -> 666 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.pngbin0 -> 602 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.pngbin0 -> 677 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.pngbin0 -> 597 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.pngbin0 -> 596 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.pngbin0 -> 682 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.pngbin0 -> 600 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.pngbin0 -> 593 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.pngbin0 -> 669 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.pngbin0 -> 562 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.pngbin0 -> 647 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.pngbin0 -> 588 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.pngbin0 -> 667 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.pngbin0 -> 593 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.pngbin0 -> 594 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.pngbin0 -> 672 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.pngbin0 -> 669 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.pngbin0 -> 590 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.pngbin0 -> 672 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.pngbin0 -> 591 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.pngbin0 -> 676 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.pngbin0 -> 588 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.pngbin0 -> 675 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.pngbin0 -> 578 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.pngbin0 -> 662 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.pngbin0 -> 590 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.pngbin0 -> 677 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.pngbin0 -> 593 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.pngbin0 -> 589 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.pngbin0 -> 673 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.pngbin0 -> 585 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.pngbin0 -> 670 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.pngbin0 -> 584 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.pngbin0 -> 679 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.pngbin0 -> 594 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.pngbin0 -> 561 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.pngbin0 -> 653 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.pngbin0 -> 674 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.pngbin0 -> 582 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.pngbin0 -> 674 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.pngbin0 -> 589 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.pngbin0 -> 672 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.pngbin0 -> 592 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.pngbin0 -> 591 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.pngbin0 -> 675 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.pngbin0 -> 592 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.pngbin0 -> 667 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.pngbin0 -> 599 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.pngbin0 -> 683 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.pngbin0 -> 593 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.pngbin0 -> 660 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.pngbin0 -> 525 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.pngbin0 -> 590 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.pngbin0 -> 596 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.pngbin0 -> 661 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.pngbin0 -> 594 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.pngbin0 -> 675 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.pngbin0 -> 596 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.pngbin0 -> 680 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.pngbin0 -> 608 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.pngbin0 -> 620 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/minus.pngbin0 -> 619 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.pngbin0 -> 615 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/plus.pngbin0 -> 614 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Footer.html5
-rw-r--r--comm/mail/components/im/messages/bubbles/Incoming/Content.html7
-rw-r--r--comm/mail/components/im/messages/bubbles/Incoming/Context.html7
-rw-r--r--comm/mail/components/im/messages/bubbles/Incoming/NextContent.html3
-rw-r--r--comm/mail/components/im/messages/bubbles/Info.plist41
-rw-r--r--comm/mail/components/im/messages/bubbles/NextStatus.html3
-rw-r--r--comm/mail/components/im/messages/bubbles/Status.html4
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/inline.js330
-rw-r--r--comm/mail/components/im/messages/bubbles/main.css210
-rw-r--r--comm/mail/components/im/messages/dark/Incoming/Content.html2
-rw-r--r--comm/mail/components/im/messages/dark/Incoming/Context.html2
-rw-r--r--comm/mail/components/im/messages/dark/Incoming/NextContent.html2
-rw-r--r--comm/mail/components/im/messages/dark/Incoming/NextContext.html2
-rw-r--r--comm/mail/components/im/messages/dark/Info.plist41
-rw-r--r--comm/mail/components/im/messages/dark/Status.html1
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Blue.css8
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Green.css8
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Purple.css8
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Red.css8
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Yellow.css8
-rw-r--r--comm/mail/components/im/messages/dark/inline.js60
-rw-r--r--comm/mail/components/im/messages/dark/main.css127
-rw-r--r--comm/mail/components/im/messages/mail/Footer.html0
-rw-r--r--comm/mail/components/im/messages/mail/Header.html0
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/Content.html1
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/Context.html1
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/NextContent.html1
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/NextContext.html0
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg6
-rw-r--r--comm/mail/components/im/messages/mail/Info.plist30
-rw-r--r--comm/mail/components/im/messages/mail/NextStatus.html1
-rw-r--r--comm/mail/components/im/messages/mail/Outgoing/Content.html0
-rw-r--r--comm/mail/components/im/messages/mail/Outgoing/Context.html0
-rw-r--r--comm/mail/components/im/messages/mail/Outgoing/NextContent.html0
-rw-r--r--comm/mail/components/im/messages/mail/Outgoing/NextContext.html0
-rw-r--r--comm/mail/components/im/messages/mail/Status.html1
-rw-r--r--comm/mail/components/im/messages/mail/Variants/Dark.css49
-rw-r--r--comm/mail/components/im/messages/mail/Variants/Light.css49
-rw-r--r--comm/mail/components/im/messages/mail/inline.js40
-rw-r--r--comm/mail/components/im/messages/mail/main.css155
-rw-r--r--comm/mail/components/im/messages/papersheets/Bitmaps/information.pngbin0 -> 740 bytes
-rw-r--r--comm/mail/components/im/messages/papersheets/Bitmaps/minus.pngbin0 -> 196 bytes
-rw-r--r--comm/mail/components/im/messages/papersheets/Bitmaps/plus.pngbin0 -> 196 bytes
-rw-r--r--comm/mail/components/im/messages/papersheets/Incoming/Content.html4
-rw-r--r--comm/mail/components/im/messages/papersheets/Incoming/Context.html4
-rw-r--r--comm/mail/components/im/messages/papersheets/Incoming/NextContent.html3
-rw-r--r--comm/mail/components/im/messages/papersheets/Info.plist38
-rw-r--r--comm/mail/components/im/messages/papersheets/NextStatus.html2
-rw-r--r--comm/mail/components/im/messages/papersheets/Status.html4
-rw-r--r--comm/mail/components/im/messages/papersheets/Variants/White.css22
-rw-r--r--comm/mail/components/im/messages/papersheets/inline.js81
-rw-r--r--comm/mail/components/im/messages/papersheets/main.css208
-rw-r--r--comm/mail/components/im/messages/simple/Incoming/Content.html1
-rw-r--r--comm/mail/components/im/messages/simple/Incoming/Context.html1
-rw-r--r--comm/mail/components/im/messages/simple/Incoming/NextContext.html1
-rw-r--r--comm/mail/components/im/messages/simple/Info.plist32
-rw-r--r--comm/mail/components/im/messages/simple/Status.html1
-rw-r--r--comm/mail/components/im/messages/simple/Variants/Dark.css23
-rw-r--r--comm/mail/components/im/messages/simple/Variants/Normal.css0
-rw-r--r--comm/mail/components/im/messages/simple/main.css90
-rw-r--r--comm/mail/components/im/modules/ChatEncryption.sys.mjs157
-rw-r--r--comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs352
-rw-r--r--comm/mail/components/im/modules/chatHandler.sys.mjs106
-rw-r--r--comm/mail/components/im/modules/chatIcons.sys.mjs106
-rw-r--r--comm/mail/components/im/modules/chatNotifications.sys.mjs262
-rw-r--r--comm/mail/components/im/modules/index_im.sys.mjs928
-rw-r--r--comm/mail/components/im/moz.build38
-rw-r--r--comm/mail/components/im/smileys/theme.json22
-rw-r--r--comm/mail/components/im/test/TestProtocol.sys.mjs308
-rw-r--r--comm/mail/components/im/test/browser/browser.ini26
-rw-r--r--comm/mail/components/im/test/browser/browser_browserRequest.js112
-rw-r--r--comm/mail/components/im/test/browser/browser_chatNotifications.js101
-rw-r--r--comm/mail/components/im/test/browser/browser_chatTelemetry.js52
-rw-r--r--comm/mail/components/im/test/browser/browser_contextMenu.js243
-rw-r--r--comm/mail/components/im/test/browser/browser_logs.js97
-rw-r--r--comm/mail/components/im/test/browser/browser_messagesMail.js235
-rw-r--r--comm/mail/components/im/test/browser/browser_readMessage.js49
-rw-r--r--comm/mail/components/im/test/browser/browser_removeMessage.js54
-rw-r--r--comm/mail/components/im/test/browser/browser_requestNotifications.js350
-rw-r--r--comm/mail/components/im/test/browser/browser_spacesToolbarChat.js255
-rw-r--r--comm/mail/components/im/test/browser/browser_tooltips.js194
-rw-r--r--comm/mail/components/im/test/browser/browser_updateMessage.js62
-rw-r--r--comm/mail/components/im/test/browser/head.js132
-rw-r--r--comm/mail/components/im/test/components.conf14
-rw-r--r--comm/mail/components/migration/content/migration.js464
-rw-r--r--comm/mail/components/migration/content/migration.xhtml89
-rw-r--r--comm/mail/components/migration/jar.mn7
-rw-r--r--comm/mail/components/migration/moz.build11
-rw-r--r--comm/mail/components/migration/public/moz.build12
-rw-r--r--comm/mail/components/migration/public/nsIMailProfileMigrator.idl70
-rw-r--r--comm/mail/components/migration/src/ThunderbirdProfileMigrator.jsm869
-rw-r--r--comm/mail/components/migration/src/components.conf38
-rw-r--r--comm/mail/components/migration/src/moz.build32
-rw-r--r--comm/mail/components/migration/src/nsMailProfileMigratorUtils.cpp86
-rw-r--r--comm/mail/components/migration/src/nsMailProfileMigratorUtils.h54
-rw-r--r--comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.cpp371
-rw-r--r--comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.h121
-rw-r--r--comm/mail/components/migration/src/nsOutlookProfileMigrator.cpp135
-rw-r--r--comm/mail/components/migration/src/nsOutlookProfileMigrator.h30
-rw-r--r--comm/mail/components/migration/src/nsProfileMigrator.cpp121
-rw-r--r--comm/mail/components/migration/src/nsProfileMigrator.h36
-rw-r--r--comm/mail/components/migration/src/nsProfileMigratorBase.cpp173
-rw-r--r--comm/mail/components/migration/src/nsProfileMigratorBase.h40
-rw-r--r--comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.cpp1175
-rw-r--r--comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h84
-rw-r--r--comm/mail/components/moz.build53
-rw-r--r--comm/mail/components/newmailaccount/content/accountProvisioner.js892
-rw-r--r--comm/mail/components/newmailaccount/content/accountProvisioner.xhtml226
-rw-r--r--comm/mail/components/newmailaccount/content/provisionerCheckout.js157
-rw-r--r--comm/mail/components/newmailaccount/content/uriListener.js281
-rw-r--r--comm/mail/components/newmailaccount/jar.mn9
-rw-r--r--comm/mail/components/newmailaccount/moz.build6
-rw-r--r--comm/mail/components/preferences/actionsshared.js23
-rw-r--r--comm/mail/components/preferences/applicationManager.js112
-rw-r--r--comm/mail/components/preferences/applicationManager.xhtml76
-rw-r--r--comm/mail/components/preferences/attachmentReminder.js100
-rw-r--r--comm/mail/components/preferences/attachmentReminder.xhtml54
-rw-r--r--comm/mail/components/preferences/chat.inc.xhtml198
-rw-r--r--comm/mail/components/preferences/chat.js193
-rw-r--r--comm/mail/components/preferences/colors.js15
-rw-r--r--comm/mail/components/preferences/colors.xhtml90
-rw-r--r--comm/mail/components/preferences/compose.inc.xhtml354
-rw-r--r--comm/mail/components/preferences/compose.js776
-rw-r--r--comm/mail/components/preferences/connection.js597
-rw-r--r--comm/mail/components/preferences/connection.xhtml264
-rw-r--r--comm/mail/components/preferences/cookies.js993
-rw-r--r--comm/mail/components/preferences/cookies.xhtml117
-rw-r--r--comm/mail/components/preferences/dockoptions.js11
-rw-r--r--comm/mail/components/preferences/dockoptions.xhtml59
-rw-r--r--comm/mail/components/preferences/downloads.js132
-rw-r--r--comm/mail/components/preferences/extensionControlled.js129
-rw-r--r--comm/mail/components/preferences/findInPage.js641
-rw-r--r--comm/mail/components/preferences/fonts.js196
-rw-r--r--comm/mail/components/preferences/fonts.xhtml337
-rw-r--r--comm/mail/components/preferences/general.inc.xhtml1096
-rw-r--r--comm/mail/components/preferences/general.js2962
-rw-r--r--comm/mail/components/preferences/jar.mn55
-rw-r--r--comm/mail/components/preferences/messagestyle.js259
-rw-r--r--comm/mail/components/preferences/messengerLanguages.js632
-rw-r--r--comm/mail/components/preferences/messengerLanguages.xhtml93
-rw-r--r--comm/mail/components/preferences/moz.build18
-rw-r--r--comm/mail/components/preferences/notifications.js25
-rw-r--r--comm/mail/components/preferences/notifications.xhtml71
-rw-r--r--comm/mail/components/preferences/offline.js31
-rw-r--r--comm/mail/components/preferences/offline.xhtml77
-rw-r--r--comm/mail/components/preferences/passwordManager.js819
-rw-r--r--comm/mail/components/preferences/passwordManager.xhtml186
-rw-r--r--comm/mail/components/preferences/permissions.js501
-rw-r--r--comm/mail/components/preferences/permissions.xhtml128
-rw-r--r--comm/mail/components/preferences/preferences.js453
-rw-r--r--comm/mail/components/preferences/preferences.xhtml256
-rw-r--r--comm/mail/components/preferences/preferencesTab.js162
-rw-r--r--comm/mail/components/preferences/privacy.inc.xhtml597
-rw-r--r--comm/mail/components/preferences/privacy.js562
-rw-r--r--comm/mail/components/preferences/receipts.js38
-rw-r--r--comm/mail/components/preferences/receipts.xhtml120
-rw-r--r--comm/mail/components/preferences/searchResults.inc.xhtml24
-rw-r--r--comm/mail/components/preferences/sync.inc.xhtml239
-rw-r--r--comm/mail/components/preferences/sync.js377
-rw-r--r--comm/mail/components/preferences/syncDialog.js38
-rw-r--r--comm/mail/components/preferences/syncDialog.xhtml210
-rw-r--r--comm/mail/components/preferences/tagDialog.xhtml26
-rw-r--r--comm/mail/components/preferences/test/browser/browser.ini20
-rw-r--r--comm/mail/components/preferences/test/browser/browser_chat.js74
-rw-r--r--comm/mail/components/preferences/test/browser/browser_cloudfile.js796
-rw-r--r--comm/mail/components/preferences/test/browser/browser_compose.js87
-rw-r--r--comm/mail/components/preferences/test/browser/browser_general.js380
-rw-r--r--comm/mail/components/preferences/test/browser/browser_openPreferences.js37
-rw-r--r--comm/mail/components/preferences/test/browser/browser_privacy.js454
-rw-r--r--comm/mail/components/preferences/test/browser/browser_sync.js419
-rw-r--r--comm/mail/components/preferences/test/browser/files/avatar.pngbin0 -> 11019 bytes
-rw-r--r--comm/mail/components/preferences/test/browser/files/icon.svg7
-rw-r--r--comm/mail/components/preferences/test/browser/files/management.html10
-rw-r--r--comm/mail/components/preferences/test/browser/head.js314
-rw-r--r--comm/mail/components/prompts/PromptCollection.jsm100
-rw-r--r--comm/mail/components/prompts/components.conf12
-rw-r--r--comm/mail/components/prompts/moz.build11
-rw-r--r--comm/mail/components/search/SearchIntegration.jsm871
-rw-r--r--comm/mail/components/search/components.conf16
-rw-r--r--comm/mail/components/search/content/SpotlightIntegration.js240
-rw-r--r--comm/mail/components/search/content/WinSearchIntegration.js346
-rw-r--r--comm/mail/components/search/extensions/allaannonser-sv-SE/favicon.icobin0 -> 668 bytes
-rw-r--r--comm/mail/components/search/extensions/allaannonser-sv-SE/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/allegro-pl/favicon.icobin0 -> 1150 bytes
-rw-r--r--comm/mail/components/search/extensions/allegro-pl/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/au/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/ca/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/de/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/en-GB/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/france/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/in/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/it/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/jp/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/mx/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/nl/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/favicon.icobin0 -> 1407 bytes
-rw-r--r--comm/mail/components/search/extensions/amazon/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/amazondotcn/favicon.icobin0 -> 1407 bytes
-rw-r--r--comm/mail/components/search/extensions/amazondotcn/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/amazondotcom/_locales/en/messages.json20
-rw-r--r--comm/mail/components/search/extensions/amazondotcom/favicon.icobin0 -> 1407 bytes
-rw-r--r--comm/mail/components/search/extensions/amazondotcom/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/atlas-sk/favicon.icobin0 -> 818 bytes
-rw-r--r--comm/mail/components/search/extensions/atlas-sk/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/azerdict/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/azerdict/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/azet-sk/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/azet-sk/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/baidu/favicon.icobin0 -> 5686 bytes
-rw-r--r--comm/mail/components/search/extensions/baidu/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/bbc-alba/favicon.icobin0 -> 958 bytes
-rw-r--r--comm/mail/components/search/extensions/bbc-alba/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/bing/favicon.icobin0 -> 3638 bytes
-rw-r--r--comm/mail/components/search/extensions/bing/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/bok-NO/favicon.pngbin0 -> 530 bytes
-rw-r--r--comm/mail/components/search/extensions/bok-NO/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/bolcom/_locales/fy-NL/messages.json14
-rw-r--r--comm/mail/components/search/extensions/bolcom/_locales/nl/messages.json14
-rw-r--r--comm/mail/components/search/extensions/bolcom/favicon.icobin0 -> 1406 bytes
-rw-r--r--comm/mail/components/search/extensions/bolcom/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/ceneji/favicon.pngbin0 -> 283 bytes
-rw-r--r--comm/mail/components/search/extensions/ceneji/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/chambers-en-GB/favicon.icobin0 -> 1425 bytes
-rw-r--r--comm/mail/components/search/extensions/chambers-en-GB/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/coccoc/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/coccoc/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/daum-kr/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/daum-kr/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/ddg/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/ddg/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/diec2/favicon.pngbin0 -> 4070 bytes
-rw-r--r--comm/mail/components/search/extensions/diec2/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/drae/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/drae/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/ecosia/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/ecosia/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/eki-ee/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/eki-ee/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/eudict/favicon.icobin0 -> 1785 bytes
-rw-r--r--comm/mail/components/search/extensions/eudict/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/faclair-beag/favicon.icobin0 -> 1091 bytes
-rw-r--r--comm/mail/components/search/extensions/faclair-beag/manifest.json23
-rw-r--r--comm/mail/components/search/extensions/flip/favicon.pngbin0 -> 342 bytes
-rw-r--r--comm/mail/components/search/extensions/flip/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/freelang/favicon.icobin0 -> 2280 bytes
-rw-r--r--comm/mail/components/search/extensions/freelang/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/google/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/google/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/gulesider-NO/favicon.icobin0 -> 1150 bytes
-rw-r--r--comm/mail/components/search/extensions/gulesider-NO/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/heureka-cz/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/heureka-cz/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/hotline-ua/favicon.icobin0 -> 1376 bytes
-rw-r--r--comm/mail/components/search/extensions/hotline-ua/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/kannadastore/favicon.pngbin0 -> 827 bytes
-rw-r--r--comm/mail/components/search/extensions/kannadastore/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/leo_ende_de/favicon.pngbin0 -> 749 bytes
-rw-r--r--comm/mail/components/search/extensions/leo_ende_de/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/list-am/favicon.gifbin0 -> 303 bytes
-rw-r--r--comm/mail/components/search/extensions/list-am/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/list.json1223
-rw-r--r--comm/mail/components/search/extensions/longdo/favicon.icobin0 -> 252 bytes
-rw-r--r--comm/mail/components/search/extensions/longdo/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/mailru/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/mailru/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/mapy-cz/favicon.icobin0 -> 1812 bytes
-rw-r--r--comm/mail/components/search/extensions/mapy-cz/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/marktplaats/_locales/fy-NL/messages.json17
-rw-r--r--comm/mail/components/search/extensions/marktplaats/_locales/nl/messages.json17
-rw-r--r--comm/mail/components/search/extensions/marktplaats/favicon.icobin0 -> 3054 bytes
-rw-r--r--comm/mail/components/search/extensions/marktplaats/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/_locales/ar/messages.json17
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/_locales/cl/messages.json17
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/_locales/mx/messages.json17
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/mercadolivre/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/mercadolivre/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/morfix-dic/favicon.icobin0 -> 2286 bytes
-rw-r--r--comm/mail/components/search/extensions/morfix-dic/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/najdi-si/favicon.pngbin0 -> 683 bytes
-rw-r--r--comm/mail/components/search/extensions/najdi-si/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/naver-kr/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/naver-kr/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/neti-ee/favicon.icobin0 -> 2519 bytes
-rw-r--r--comm/mail/components/search/extensions/neti-ee/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/odpiralni/favicon.pngbin0 -> 2639 bytes
-rw-r--r--comm/mail/components/search/extensions/odpiralni/manifest.json23
-rw-r--r--comm/mail/components/search/extensions/olx/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/olx/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/oshiete-goo/favicon.icobin0 -> 8348 bytes
-rw-r--r--comm/mail/components/search/extensions/oshiete-goo/manifest.json23
-rw-r--r--comm/mail/components/search/extensions/osta-ee/favicon.pngbin0 -> 328 bytes
-rw-r--r--comm/mail/components/search/extensions/osta-ee/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/ozonru/favicon.icobin0 -> 3638 bytes
-rw-r--r--comm/mail/components/search/extensions/ozonru/manifest.json27
-rw-r--r--comm/mail/components/search/extensions/palasprint/favicon.icobin0 -> 1406 bytes
-rw-r--r--comm/mail/components/search/extensions/palasprint/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/pazaruvaj/favicon.icobin0 -> 2584 bytes
-rw-r--r--comm/mail/components/search/extensions/pazaruvaj/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/pogodak/favicon.icobin0 -> 1150 bytes
-rw-r--r--comm/mail/components/search/extensions/pogodak/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/priberam/favicon.pngbin0 -> 790 bytes
-rw-r--r--comm/mail/components/search/extensions/priberam/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/priceru/favicon.icobin0 -> 468 bytes
-rw-r--r--comm/mail/components/search/extensions/priceru/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/prisjakt-sv-SE/favicon.icobin0 -> 1406 bytes
-rw-r--r--comm/mail/components/search/extensions/prisjakt-sv-SE/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/pwn-pl/favicon.pngbin0 -> 1055 bytes
-rw-r--r--comm/mail/components/search/extensions/pwn-pl/manifest.json23
-rw-r--r--comm/mail/components/search/extensions/qwant/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/qwant/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/qxl-NO/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/qxl-NO/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/rakuten/favicon.icobin0 -> 2053 bytes
-rw-r--r--comm/mail/components/search/extensions/rakuten/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/readmoo/favicon.icobin0 -> 2468 bytes
-rw-r--r--comm/mail/components/search/extensions/readmoo/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/salidzinilv/favicon.icobin0 -> 3638 bytes
-rw-r--r--comm/mail/components/search/extensions/salidzinilv/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/seznam-cz/favicon.icobin0 -> 1743 bytes
-rw-r--r--comm/mail/components/search/extensions/seznam-cz/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/sslv/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/sslv/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/tearma/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/tearma/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/tyda-sv-SE/favicon.icobin0 -> 379 bytes
-rw-r--r--comm/mail/components/search/extensions/tyda-sv-SE/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/vatera/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/vatera/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/NN/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/NO/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/af/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/an/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ar/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/as/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ast/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/az/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/be-tarask/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/be/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/bg/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/bn/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/br/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/bs/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ca/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/crh/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/cy/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/cz/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/da/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/de/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/dsb/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/el/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/en/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/eo/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/es/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/et/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/eu/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/fa/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/fi/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/fr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/fy-NL/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ga-IE/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/gd/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/gl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/gn/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/gu/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/he/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hi/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hsb/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hu/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hy/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ia/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/id/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/is/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/it/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ja/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ka/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/kab/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/kk/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/km/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/kn/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/kr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/lij/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/lo/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/lt/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ltg/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/lv/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/mk/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ml/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/mr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ms/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/my/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ne/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/nl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/oc/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/or/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/pa/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/pl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/pt/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/rm/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ro/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ru/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/si/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sk/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sq/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sv-SE/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ta/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/te/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/th/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/tl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/tr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/uk/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ur/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/uz/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/vi/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/wo/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/zh-CN/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/zh-TW/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/favicon.icobin0 -> 884 bytes
-rw-r--r--comm/mail/components/search/extensions/wikipedia/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/wiktionary/_locales/oc/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wiktionary/_locales/te/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wiktionary/favicon.icobin0 -> 318 bytes
-rw-r--r--comm/mail/components/search/extensions/wiktionary/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/wolnelektury-pl/favicon.pngbin0 -> 304 bytes
-rw-r--r--comm/mail/components/search/extensions/wolnelektury-pl/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/yahoo-jp-auctions/favicon.icobin0 -> 2672 bytes
-rw-r--r--comm/mail/components/search/extensions/yahoo-jp-auctions/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/yahoo-jp/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/yahoo-jp/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/az/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/by/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/en/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/kk/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/ru/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/tr/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/yandex/yandex-en.icobin0 -> 1691 bytes
-rw-r--r--comm/mail/components/search/extensions/yandex/yandex-ru.icobin0 -> 2034 bytes
-rw-r--r--comm/mail/components/search/extensions/zoznam-sk/favicon.pngbin0 -> 222 bytes
-rw-r--r--comm/mail/components/search/extensions/zoznam-sk/manifest.json25
-rw-r--r--comm/mail/components/search/jar.mn15
-rw-r--r--comm/mail/components/search/mdimporter/English.lproj/InfoPlist.stringsbin0 -> 456 bytes
-rw-r--r--comm/mail/components/search/mdimporter/English.lproj/schema.stringsbin0 -> 1276 bytes
-rw-r--r--comm/mail/components/search/mdimporter/GetMetadataForFile.c76
-rw-r--r--comm/mail/components/search/mdimporter/Info.plist53
-rw-r--r--comm/mail/components/search/mdimporter/Makefile.in26
-rw-r--r--comm/mail/components/search/mdimporter/main.c208
-rw-r--r--comm/mail/components/search/mdimporter/moz.build22
-rw-r--r--comm/mail/components/search/mdimporter/schema.xml32
-rw-r--r--comm/mail/components/search/moz.build23
-rw-r--r--comm/mail/components/search/nsMailWinSearchHelper.cpp254
-rw-r--r--comm/mail/components/search/nsMailWinSearchHelper.h34
-rw-r--r--comm/mail/components/search/public/moz.build10
-rw-r--r--comm/mail/components/search/public/nsIMailWinSearchHelper.idl58
-rw-r--r--comm/mail/components/search/wsenable/Makefile.in6
-rw-r--r--comm/mail/components/search/wsenable/WSEnable.cpp141
-rw-r--r--comm/mail/components/search/wsenable/WSEnable.exe.manifest37
-rw-r--r--comm/mail/components/search/wsenable/WSEnable.rc6
-rw-r--r--comm/mail/components/search/wsenable/module.ver1
-rw-r--r--comm/mail/components/search/wsenable/moz.build21
-rw-r--r--comm/mail/components/shell/components.conf37
-rw-r--r--comm/mail/components/shell/moz.build43
-rw-r--r--comm/mail/components/shell/nsGNOMEShellService.cpp341
-rw-r--r--comm/mail/components/shell/nsGNOMEShellService.h49
-rw-r--r--comm/mail/components/shell/nsIShellService.idl52
-rw-r--r--comm/mail/components/shell/nsMacShellService.cpp156
-rw-r--r--comm/mail/components/shell/nsMacShellService.h36
-rw-r--r--comm/mail/components/shell/nsToolkitShellService.h23
-rw-r--r--comm/mail/components/shell/nsWindowsShellService.cpp329
-rw-r--r--comm/mail/components/shell/nsWindowsShellService.h51
-rw-r--r--comm/mail/components/shell/test/unit/test_shellService.js22
-rw-r--r--comm/mail/components/shell/test/unit/xpcshell.ini2
-rw-r--r--comm/mail/components/storybook/.storybook/main.js47
-rw-r--r--comm/mail/components/storybook/.storybook/preview-head.html5
-rw-r--r--comm/mail/components/storybook/.storybook/preview.mjs43
-rw-r--r--comm/mail/components/storybook/README.md39
-rw-r--r--comm/mail/components/storybook/mach_commands.py42
-rw-r--r--comm/mail/components/storybook/package-lock.json37747
-rw-r--r--comm/mail/components/storybook/package.json28
-rw-r--r--comm/mail/components/storybook/stories/colors.stories.mjs89
-rw-r--r--comm/mail/components/storybook/stories/pane-splitter.stories.mjs60
-rw-r--r--comm/mail/components/storybook/stories/search-bar.stories.mjs37
-rw-r--r--comm/mail/components/telemetry/Events.yaml25
-rw-r--r--comm/mail/components/telemetry/Histograms.json40
-rw-r--r--comm/mail/components/telemetry/README.md175
-rw-r--r--comm/mail/components/telemetry/Scalars.yaml591
-rw-r--r--comm/mail/components/test/unit/head_mailcomponents.js20
-rw-r--r--comm/mail/components/test/unit/test_about_support.js219
-rw-r--r--comm/mail/components/test/unit/test_telemetry_buildconfig.js151
-rw-r--r--comm/mail/components/test/unit/xpcshell.ini6
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customizable-element.mjs299
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customization-palette.mjs243
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customization-target.mjs333
-rw-r--r--comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs148
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs38
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs19
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs40
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs44
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs81
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs223
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs183
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs32
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs15
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/space-button.mjs41
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs40
-rw-r--r--comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs549
-rw-r--r--comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs153
-rw-r--r--comm/mail/components/unifiedtoolbar/content/search-bar.mjs121
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs240
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs264
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs414
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs119
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs540
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml366
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml133
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml137
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css53
-rw-r--r--comm/mail/components/unifiedtoolbar/jar.mn29
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs23
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs134
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs445
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs55
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs419
-rw-r--r--comm/mail/components/unifiedtoolbar/moz.build22
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser.ini16
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js173
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js263
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js99
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js285
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml21
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml22
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js40
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js123
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js103
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js64
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js431
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini8
-rw-r--r--comm/mail/config/mozconfigs/common3
-rw-r--r--comm/mail/config/mozconfigs/l10n-common12
-rw-r--r--comm/mail/config/mozconfigs/linux32/common-linux3212
-rw-r--r--comm/mail/config/mozconfigs/linux32/debug4
-rw-r--r--comm/mail/config/mozconfigs/linux32/l10n-mozconfig6
-rw-r--r--comm/mail/config/mozconfigs/linux32/nightly2
-rw-r--r--comm/mail/config/mozconfigs/linux32/release3
-rw-r--r--comm/mail/config/mozconfigs/linux64-aarch64/common19
-rw-r--r--comm/mail/config/mozconfigs/linux64-aarch64/opt2
-rw-r--r--comm/mail/config/mozconfigs/linux64/code-coverage16
-rw-r--r--comm/mail/config/mozconfigs/linux64/code-coverage-debug9
-rw-r--r--comm/mail/config/mozconfigs/linux64/code-coverage-opt9
-rw-r--r--comm/mail/config/mozconfigs/linux64/common-linux6412
-rw-r--r--comm/mail/config/mozconfigs/linux64/debug4
-rw-r--r--comm/mail/config/mozconfigs/linux64/debug-searchfox-clang14
-rw-r--r--comm/mail/config/mozconfigs/linux64/l10n-mozconfig1
-rw-r--r--comm/mail/config/mozconfigs/linux64/nightly2
-rw-r--r--comm/mail/config/mozconfigs/linux64/nightly-asan23
-rw-r--r--comm/mail/config/mozconfigs/linux64/nightly-asan-reporter18
-rw-r--r--comm/mail/config/mozconfigs/linux64/release3
-rw-r--r--comm/mail/config/mozconfigs/linux64/source11
-rw-r--r--comm/mail/config/mozconfigs/linux64/tsan22
-rw-r--r--comm/mail/config/mozconfigs/macosx64-aarch64/common-opt21
-rw-r--r--comm/mail/config/mozconfigs/macosx64-aarch64/nightly12
-rw-r--r--comm/mail/config/mozconfigs/macosx64-aarch64/release7
-rw-r--r--comm/mail/config/mozconfigs/macosx64/common-opt9
-rw-r--r--comm/mail/config/mozconfigs/macosx64/debug12
-rw-r--r--comm/mail/config/mozconfigs/macosx64/debug-searchfox13
-rw-r--r--comm/mail/config/mozconfigs/macosx64/l10n-mozconfig9
-rw-r--r--comm/mail/config/mozconfigs/macosx64/nightly12
-rw-r--r--comm/mail/config/mozconfigs/macosx64/release7
-rw-r--r--comm/mail/config/mozconfigs/win32/common-win3219
-rw-r--r--comm/mail/config/mozconfigs/win32/debug18
-rw-r--r--comm/mail/config/mozconfigs/win32/l10n-mozconfig2
-rw-r--r--comm/mail/config/mozconfigs/win32/nightly19
-rw-r--r--comm/mail/config/mozconfigs/win32/release15
-rw-r--r--comm/mail/config/mozconfigs/win64/common-win6419
-rw-r--r--comm/mail/config/mozconfigs/win64/debug18
-rw-r--r--comm/mail/config/mozconfigs/win64/debug-searchfox16
-rw-r--r--comm/mail/config/mozconfigs/win64/l10n-mozconfig2
-rw-r--r--comm/mail/config/mozconfigs/win64/nightly20
-rw-r--r--comm/mail/config/mozconfigs/win64/nightly-asan17
-rw-r--r--comm/mail/config/mozconfigs/win64/nightly-asan-reporter20
-rw-r--r--comm/mail/config/mozconfigs/win64/plain-debug3
-rw-r--r--comm/mail/config/mozconfigs/win64/plain-opt6
-rw-r--r--comm/mail/config/mozconfigs/win64/release14
-rw-r--r--comm/mail/config/version.txt1
-rw-r--r--comm/mail/config/version_display.txt1
-rw-r--r--comm/mail/config/whats_new_page.yml28
-rwxr-xr-xcomm/mail/confvars.sh30
-rw-r--r--comm/mail/defs.mk9
-rw-r--r--comm/mail/extensions/am-e2e/AME2E.jsm24
-rw-r--r--comm/mail/extensions/am-e2e/am-e2e.inc.xhtml237
-rw-r--r--comm/mail/extensions/am-e2e/am-e2e.js1591
-rw-r--r--comm/mail/extensions/am-e2e/am-e2e.xhtml32
-rw-r--r--comm/mail/extensions/am-e2e/components.conf15
-rw-r--r--comm/mail/extensions/am-e2e/moz.build16
-rw-r--r--comm/mail/extensions/am-e2e/prefs/e2e-prefs.js285
-rw-r--r--comm/mail/extensions/jar.mn7
-rw-r--r--comm/mail/extensions/mailviews/content/mailViewList.js116
-rw-r--r--comm/mail/extensions/mailviews/content/mailViewList.xhtml63
-rw-r--r--comm/mail/extensions/mailviews/content/mailViewSetup.js108
-rw-r--r--comm/mail/extensions/mailviews/content/mailViewSetup.xhtml61
-rw-r--r--comm/mail/extensions/mailviews/content/msgViewPickerOverlay.js282
-rw-r--r--comm/mail/extensions/mailviews/jar.mn10
-rw-r--r--comm/mail/extensions/mailviews/moz.build6
-rw-r--r--comm/mail/extensions/moz.build16
-rw-r--r--comm/mail/extensions/openpgp/README.md22
-rw-r--r--comm/mail/extensions/openpgp/content/BondOpenPGP.jsm86
-rw-r--r--comm/mail/extensions/openpgp/content/modules/CollectedKeysDB.jsm355
-rw-r--r--comm/mail/extensions/openpgp/content/modules/GPGME.jsm338
-rw-r--r--comm/mail/extensions/openpgp/content/modules/GPGMELib.jsm584
-rw-r--r--comm/mail/extensions/openpgp/content/modules/OpenPGPAlias.jsm173
-rw-r--r--comm/mail/extensions/openpgp/content/modules/RNP.jsm4787
-rw-r--r--comm/mail/extensions/openpgp/content/modules/RNPLib.jsm2109
-rw-r--r--comm/mail/extensions/openpgp/content/modules/armor.jsm367
-rw-r--r--comm/mail/extensions/openpgp/content/modules/constants.jsm183
-rw-r--r--comm/mail/extensions/openpgp/content/modules/core.jsm189
-rw-r--r--comm/mail/extensions/openpgp/content/modules/cryptoAPI.jsm32
-rw-r--r--comm/mail/extensions/openpgp/content/modules/cryptoAPI/GnuPGCryptoAPI.jsm238
-rw-r--r--comm/mail/extensions/openpgp/content/modules/cryptoAPI/RNPCryptoAPI.jsm282
-rw-r--r--comm/mail/extensions/openpgp/content/modules/cryptoAPI/interface.js288
-rw-r--r--comm/mail/extensions/openpgp/content/modules/data.jsm156
-rw-r--r--comm/mail/extensions/openpgp/content/modules/decryption.jsm639
-rw-r--r--comm/mail/extensions/openpgp/content/modules/dialog.jsm481
-rw-r--r--comm/mail/extensions/openpgp/content/modules/encryption.jsm564
-rw-r--r--comm/mail/extensions/openpgp/content/modules/filters.jsm598
-rw-r--r--comm/mail/extensions/openpgp/content/modules/filtersWrapper.jsm186
-rw-r--r--comm/mail/extensions/openpgp/content/modules/fixExchangeMsg.jsm433
-rw-r--r--comm/mail/extensions/openpgp/content/modules/funcs.jsm561
-rw-r--r--comm/mail/extensions/openpgp/content/modules/key.jsm285
-rw-r--r--comm/mail/extensions/openpgp/content/modules/keyLookupHelper.jsm380
-rw-r--r--comm/mail/extensions/openpgp/content/modules/keyObj.jsm679
-rw-r--r--comm/mail/extensions/openpgp/content/modules/keyRing.jsm2202
-rw-r--r--comm/mail/extensions/openpgp/content/modules/keyserver.jsm1549
-rw-r--r--comm/mail/extensions/openpgp/content/modules/keyserverUris.jsm43
-rw-r--r--comm/mail/extensions/openpgp/content/modules/log.jsm151
-rw-r--r--comm/mail/extensions/openpgp/content/modules/masterpass.jsm332
-rw-r--r--comm/mail/extensions/openpgp/content/modules/mime.jsm571
-rw-r--r--comm/mail/extensions/openpgp/content/modules/mimeDecrypt.jsm933
-rw-r--r--comm/mail/extensions/openpgp/content/modules/mimeEncrypt.jsm760
-rw-r--r--comm/mail/extensions/openpgp/content/modules/mimeVerify.jsm716
-rw-r--r--comm/mail/extensions/openpgp/content/modules/msgRead.jsm289
-rw-r--r--comm/mail/extensions/openpgp/content/modules/persistentCrypto.jsm1338
-rw-r--r--comm/mail/extensions/openpgp/content/modules/pgpmimeHandler.jsm299
-rw-r--r--comm/mail/extensions/openpgp/content/modules/singletons.jsm54
-rw-r--r--comm/mail/extensions/openpgp/content/modules/sqliteDb.jsm477
-rw-r--r--comm/mail/extensions/openpgp/content/modules/streams.jsm155
-rw-r--r--comm/mail/extensions/openpgp/content/modules/trust.jsm94
-rw-r--r--comm/mail/extensions/openpgp/content/modules/uris.jsm124
-rw-r--r--comm/mail/extensions/openpgp/content/modules/webKey.jsm293
-rw-r--r--comm/mail/extensions/openpgp/content/modules/windows.jsm518
-rw-r--r--comm/mail/extensions/openpgp/content/modules/wkdLookup.jsm363
-rw-r--r--comm/mail/extensions/openpgp/content/modules/wksMimeHandler.jsm262
-rw-r--r--comm/mail/extensions/openpgp/content/modules/zbase32.jsm108
-rw-r--r--comm/mail/extensions/openpgp/content/strings/enigmail.properties348
-rw-r--r--comm/mail/extensions/openpgp/content/ui/attachmentItemContext.inc.xhtml17
-rw-r--r--comm/mail/extensions/openpgp/content/ui/backupKeyPassword.js123
-rw-r--r--comm/mail/extensions/openpgp/content/ui/backupKeyPassword.xhtml67
-rw-r--r--comm/mail/extensions/openpgp/content/ui/changeExpiryDlg.js152
-rw-r--r--comm/mail/extensions/openpgp/content/ui/changeExpiryDlg.xhtml73
-rw-r--r--comm/mail/extensions/openpgp/content/ui/commonWorkflows.js194
-rw-r--r--comm/mail/extensions/openpgp/content/ui/composeKeyStatus.js222
-rw-r--r--comm/mail/extensions/openpgp/content/ui/composeKeyStatus.xhtml94
-rw-r--r--comm/mail/extensions/openpgp/content/ui/confirmPubkeyImport.js102
-rw-r--r--comm/mail/extensions/openpgp/content/ui/confirmPubkeyImport.xhtml55
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailCommon.js69
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailKeyImportInfo.js172
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailKeyImportInfo.xhtml42
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailKeyManager.js1442
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailKeyManager.xhtml406
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailMessengerOverlay.js3460
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailMsgBox.js181
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailMsgBox.xhtml71
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailMsgComposeOverlay.js3034
-rw-r--r--comm/mail/extensions/openpgp/content/ui/enigmailMsgHdrViewOverlay.js1214
-rw-r--r--comm/mail/extensions/openpgp/content/ui/keyAssistant.inc.xhtml119
-rw-r--r--comm/mail/extensions/openpgp/content/ui/keyAssistant.js956
-rw-r--r--comm/mail/extensions/openpgp/content/ui/keyDetailsDlg.js1119
-rw-r--r--comm/mail/extensions/openpgp/content/ui/keyDetailsDlg.xhtml405
-rw-r--r--comm/mail/extensions/openpgp/content/ui/keyWizard.js1195
-rw-r--r--comm/mail/extensions/openpgp/content/ui/keyWizard.xhtml506
-rw-r--r--comm/mail/extensions/openpgp/content/ui/oneRecipientStatus.js177
-rw-r--r--comm/mail/extensions/openpgp/content/ui/oneRecipientStatus.xhtml86
-rw-r--r--comm/mail/extensions/openpgp/jar.mn14
-rw-r--r--comm/mail/extensions/openpgp/moz.build7
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpgbin0 -> 9727 bytes
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg.asc200
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg.gpgbin0 -> 9387 bytes
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/data/plaintext-with-key-and-windows-1252-encoded-eml-attachment.eml109
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/data/plaintext-with-windows-1252-encoded-eml-attachment.eml39
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/test_alias.js321
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/test_badKeys.js69
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/test_encryptAndOrSign.js278
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/test_secretKeys.js384
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/test_strip.js137
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/test_uid.js132
-rw-r--r--comm/mail/extensions/openpgp/test/unit/rnp/xpcshell.ini14
-rw-r--r--comm/mail/extensions/smime/content/msgHdrViewSMIMEOverlay.js483
-rw-r--r--comm/mail/extensions/smime/jar.mn14
-rw-r--r--comm/mail/extensions/smime/moz.build6
-rw-r--r--comm/mail/installer/Makefile.in214
-rw-r--r--comm/mail/installer/allowed-dupes.mn81
-rw-r--r--comm/mail/installer/moz.build4
-rw-r--r--comm/mail/installer/package-manifest.in516
-rw-r--r--comm/mail/installer/removed-files.in92
-rw-r--r--comm/mail/installer/windows/Makefile.in59
-rw-r--r--comm/mail/installer/windows/app.tag4
-rw-r--r--comm/mail/installer/windows/docs/MSIX.rst23
-rw-r--r--comm/mail/installer/windows/moz.build12
-rw-r--r--comm/mail/installer/windows/msi/installer.wxs104
-rw-r--r--comm/mail/installer/windows/msix/AppxManifest.xml.in150
-rw-r--r--comm/mail/installer/windows/msix/Resources.pribin0 -> 2280 bytes
-rw-r--r--comm/mail/installer/windows/msix/distribution/distribution.ini12
-rw-r--r--comm/mail/installer/windows/msix/msix-all-locales361
-rw-r--r--comm/mail/installer/windows/msix/priconfig.xml38
-rwxr-xr-xcomm/mail/installer/windows/nsis/defines.nsi.in86
-rwxr-xr-xcomm/mail/installer/windows/nsis/installer.nsi1316
-rw-r--r--comm/mail/installer/windows/nsis/maintenanceservice_installer.nsi343
-rwxr-xr-xcomm/mail/installer/windows/nsis/shared.nsh1617
-rwxr-xr-xcomm/mail/installer/windows/nsis/uninstaller.nsi683
-rw-r--r--comm/mail/installer/windows/nsis/updater_append.ini12
-rw-r--r--comm/mail/locales/Makefile.in107
-rw-r--r--comm/mail/locales/all-locales67
-rw-r--r--comm/mail/locales/en-US/all-l10n.js7
-rw-r--r--comm/mail/locales/en-US/browser/appExtensionFields.ftl15
-rw-r--r--comm/mail/locales/en-US/browser/branding/brandings.ftl17
-rw-r--r--comm/mail/locales/en-US/browser/components/mozSupportLink.ftl5
-rw-r--r--comm/mail/locales/en-US/chrome/communicator/utilityOverlay.dtd32
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-mapi/mapi.properties35
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-newsblog/am-newsblog.dtd14
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-newsblog/feed-subscriptions.dtd55
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-newsblog/newsblog.properties93
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-region/region.properties9
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-smime/certFetchingStatus.dtd9
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-smime/msgCompSMIMEOverlay.dtd10
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-smime/msgCompSecurityInfo.dtd18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-smime/msgCompSecurityInfo.properties13
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-smime/msgReadSMIMEOverlay.properties11
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-smime/msgReadSecurityInfo.dtd17
-rw-r--r--comm/mail/locales/en-US/chrome/messenger-smime/msgSecurityInfo.properties36
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/AccountManager.dtd23
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/AccountWizard.dtd50
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/CustomHeaders.dtd11
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/FilterEditor.dtd66
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/FilterListDialog.dtd40
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/SearchDialog.dtd37
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/aboutDownloads.dtd23
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/aboutRights.properties6
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/aboutSupportMail.properties15
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/accountCreationModel.properties20
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/accountCreationUtil.properties34
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/activity.dtd18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/activity.properties99
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addbuddy.dtd7
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/abAddressBookNameDialog.dtd7
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/abContactsPanel.dtd49
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/abMailListDialog.dtd21
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/abMainWindow.dtd14
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/abResultsPane.dtd38
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/addressBook.properties178
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/ldapAutoCompErrs.properties104
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/pref-directory-add.dtd45
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/pref-directory.dtd17
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/addressbook/replicationProgress.properties20
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-addressing.dtd47
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-advanced.dtd28
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-archiveoptions.dtd23
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-copies.dtd50
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-e2e.properties5
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-identities-list.dtd15
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-identity-edit.dtd12
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-im.dtd16
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-junk.dtd31
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-main.dtd47
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-mdn.dtd33
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-mdn.properties6
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-offline.dtd57
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-server-advanced.dtd31
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-server-top.dtd89
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-serverwithnoidentities.dtd6
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-smime.dtd46
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/am-smime.properties40
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/appUpdate.properties40
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/appleMailImportMsgs.properties20
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/baseMenuOverlay.dtd8
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/beckyImportMsgs.properties19
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/charsetTitles.properties80
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/chat.dtd44
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/chat.properties110
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/configEditorOverlay.dtd5
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/converterDialog.dtd10
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/converterDialog.properties41
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/custom.properties5
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/customizeToolbar.dtd17
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/customizeToolbar.properties11
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/devtools/dbgserver.dtd8
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/devtools/dbgserver.properties15
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/downloadheaders.dtd20
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/editContactOverlay.dtd20
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/editContactOverlay.properties14
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/fieldMapImport.dtd17
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/filter.properties107
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/folderProps.dtd70
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/folderWidgets.properties12
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/folderpane.dtd7
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/gloda.properties175
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/glodaComplete.properties19
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/glodaFacetView.dtd29
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/glodaFacetView.properties171
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/imAccountWizard.dtd32
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/imAccounts.properties63
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/imapMsgs.properties268
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/importDialog.dtd48
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/importMsgs.properties304
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/joinChat.dtd10
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/junkLog.dtd10
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/localMsgs.properties140
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/mailEditorOverlay.dtd6
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/mailOverlay.dtd11
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/mailViewList.dtd7
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/mailViewSetup.dtd10
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/mailviews.properties13
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/markByDate.dtd9
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messenger.dtd920
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messenger.properties758
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EdAdvancedEdit.dtd18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EdColorPicker.dtd22
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EdConvertToTable.dtd15
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EdDialogOverlay.dtd18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EdNamedAnchorProperties.dtd8
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorColorProperties.dtd29
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorHLineProperties.dtd27
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorImageProperties.dtd79
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertChars.dtd19
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertMath.dtd21
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertSource.dtd15
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertTOC.dtd16
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertTable.dtd18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorLinkProperties.dtd6
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorListProperties.dtd20
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorPersonalDictionary.dtd18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorReplace.dtd27
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorSpellCheck.dtd38
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorTableProperties.dtd75
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/composeMsgs.properties457
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/editor.properties208
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/editorOverlay.dtd304
-rwxr-xr-xcomm/mail/locales/en-US/chrome/messenger/messengercompose/mailComposeEditorOverlay.dtd9
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/messengercompose.dtd306
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/sendProgress.dtd8
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/messengercompose/sendProgress.properties21
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/migration/migration.dtd30
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/migration/migration.properties30
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/mime.properties154
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/mimeheader.properties35
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/morkImportMsgs.properties18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/msgAccountCentral.dtd26
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/msgHdrViewOverlay.dtd114
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/msgSynchronize.dtd23
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/msgViewPickerOverlay.dtd22
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/msgmdn.properties18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/multimessageview.dtd8
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/multimessageview.properties66
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/newFolderDialog.dtd16
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/news.properties55
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/newsError.dtd31
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/offline.properties28
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/offlineStartup.properties8
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/outlookImportMsgs.properties72
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/pgpmime.properties10
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/preferences/applicationManager.properties13
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/preferences/applications.properties13
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/preferences/messagestyle.properties13
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/preferences/preferences.properties100
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/prefs.properties89
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/removeAccount.dtd22
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/removeAccount.properties5
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/renameFolderDialog.dtd9
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/sanitize.dtd36
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/seamonkeyImportMsgs.properties18
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/search-attributes.properties45
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/search-operators.properties31
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/search.properties27
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/searchTermOverlay.dtd19
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/shutdownWindow.properties10
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/smime.properties11
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/smtpEditOverlay.dtd24
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/subscribe.dtd22
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/subscribe.properties13
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/tabmail.dtd9
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/taskbar.properties8
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/telemetry.properties13
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/templateUtils.properties7
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/textImportMsgs.properties43
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/vCardImportMsgs.properties26
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/viewLog.dtd12
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/viewSource.dtd84
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/viewSource.properties17
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/viewZoomOverlay.dtd30
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/virtualFolderListDialog.dtd8
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/virtualFolderProperties.dtd22
-rw-r--r--comm/mail/locales/en-US/chrome/messenger/wmImportMsgs.properties76
-rw-r--r--comm/mail/locales/en-US/chrome/mozldap/ldap.properties261
-rw-r--r--comm/mail/locales/en-US/chrome/overrides/profileDowngrade.dtd19
-rw-r--r--comm/mail/locales/en-US/crashreporter/crashreporter-override.ini9
-rwxr-xr-xcomm/mail/locales/en-US/installer/custom.properties85
-rwxr-xr-xcomm/mail/locales/en-US/installer/mui.properties60
-rwxr-xr-xcomm/mail/locales/en-US/installer/override.properties86
-rw-r--r--comm/mail/locales/en-US/langpack-metadata.ftl12
-rw-r--r--comm/mail/locales/en-US/messenger/about3Pane.ftl482
-rw-r--r--comm/mail/locales/en-US/messenger/aboutAddonsExtra.ftl11
-rw-r--r--comm/mail/locales/en-US/messenger/aboutDialog.ftl74
-rw-r--r--comm/mail/locales/en-US/messenger/aboutImport.ftl283
-rw-r--r--comm/mail/locales/en-US/messenger/aboutProfilesExtra.ftl5
-rw-r--r--comm/mail/locales/en-US/messenger/aboutRights.ftl122
-rw-r--r--comm/mail/locales/en-US/messenger/aboutSupportCalendar.ftl33
-rw-r--r--comm/mail/locales/en-US/messenger/aboutSupportChat.ftl11
-rw-r--r--comm/mail/locales/en-US/messenger/aboutSupportMail.ftl33
-rw-r--r--comm/mail/locales/en-US/messenger/accountCentral.ftl68
-rw-r--r--comm/mail/locales/en-US/messenger/accountManager.ftl18
-rw-r--r--comm/mail/locales/en-US/messenger/accountProvisioner.ftl80
-rw-r--r--comm/mail/locales/en-US/messenger/accountcreation/accountHub.ftl60
-rw-r--r--comm/mail/locales/en-US/messenger/accountcreation/accountSetup.ftl428
-rw-r--r--comm/mail/locales/en-US/messenger/addonNotifications.ftl130
-rw-r--r--comm/mail/locales/en-US/messenger/addressbook/abCardDAVDialog.ftl29
-rw-r--r--comm/mail/locales/en-US/messenger/addressbook/abCardDAVProperties.ftl31
-rw-r--r--comm/mail/locales/en-US/messenger/addressbook/aboutAddressBook.ftl299
-rw-r--r--comm/mail/locales/en-US/messenger/addressbook/fieldMapImport.ftl12
-rw-r--r--comm/mail/locales/en-US/messenger/addressbook/vcard.ftl189
-rw-r--r--comm/mail/locales/en-US/messenger/appmenu.ftl267
-rw-r--r--comm/mail/locales/en-US/messenger/chat-verifySession.ftl17
-rw-r--r--comm/mail/locales/en-US/messenger/chat.ftl47
-rw-r--r--comm/mail/locales/en-US/messenger/compactFoldersDialog.ftl22
-rw-r--r--comm/mail/locales/en-US/messenger/exportDialog.ftl22
-rw-r--r--comm/mail/locales/en-US/messenger/extensionPermissions.ftl23
-rw-r--r--comm/mail/locales/en-US/messenger/extensions/popup.ftl14
-rw-r--r--comm/mail/locales/en-US/messenger/extensionsUI.ftl11
-rw-r--r--comm/mail/locales/en-US/messenger/firefoxAccounts.ftl32
-rw-r--r--comm/mail/locales/en-US/messenger/flatpak.ftl28
-rw-r--r--comm/mail/locales/en-US/messenger/folderprops.ftl9
-rw-r--r--comm/mail/locales/en-US/messenger/importDialog.ftl30
-rw-r--r--comm/mail/locales/en-US/messenger/mailWidgets.ftl14
-rw-r--r--comm/mail/locales/en-US/messenger/menubar.ftl161
-rw-r--r--comm/mail/locales/en-US/messenger/messageheader/headerFields.ftl69
-rw-r--r--comm/mail/locales/en-US/messenger/messenger.ftl484
-rw-r--r--comm/mail/locales/en-US/messenger/messengercompose/messengercompose.ftl492
-rw-r--r--comm/mail/locales/en-US/messenger/migration.ftl10
-rw-r--r--comm/mail/locales/en-US/messenger/multimessageview.ftl17
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/backupKeyPassword.ftl17
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/changeExpiryDlg.ftl19
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/composeKeyStatus.ftl25
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/keyAssistant.ftl160
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/keyWizard.ftl198
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/msgReadStatus.ftl93
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/oneRecipientStatus.ftl60
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/openpgp-frontend.ftl71
-rw-r--r--comm/mail/locales/en-US/messenger/openpgp/openpgp.ftl856
-rw-r--r--comm/mail/locales/en-US/messenger/otr/add-finger.ftl16
-rw-r--r--comm/mail/locales/en-US/messenger/otr/am-im-otr.ftl25
-rw-r--r--comm/mail/locales/en-US/messenger/otr/auth.ftl56
-rw-r--r--comm/mail/locales/en-US/messenger/otr/chat.ftl19
-rw-r--r--comm/mail/locales/en-US/messenger/otr/finger-sync.ftl12
-rw-r--r--comm/mail/locales/en-US/messenger/otr/finger.ftl20
-rw-r--r--comm/mail/locales/en-US/messenger/otr/otr.ftl97
-rw-r--r--comm/mail/locales/en-US/messenger/otr/otrUI.ftl87
-rw-r--r--comm/mail/locales/en-US/messenger/policies/aboutPolicies.ftl17
-rw-r--r--comm/mail/locales/en-US/messenger/policies/policies-descriptions.ftl158
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/am-copies.ftl5
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/am-im.ftl25
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/application-manager.ftl10
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/attachment-reminder.ftl26
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/colors.ftl47
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/connection.ftl115
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/cookies.ftl54
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/dock-options.ftl29
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/fonts.ftl150
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/languages.ftl44
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/new-tag.ftl14
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/notifications.ftl33
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/offline.ftl56
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/passwordManager.ftl85
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/permissions.ftl55
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/preferences.ftl1026
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/receipts.ftl51
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/sync-dialog.ftl12
-rw-r--r--comm/mail/locales/en-US/messenger/preferences/system-integration.ftl44
-rw-r--r--comm/mail/locales/en-US/messenger/shortcuts.ftl114
-rw-r--r--comm/mail/locales/en-US/messenger/treeView.ftl71
-rw-r--r--comm/mail/locales/en-US/messenger/troubleshootMode.ftl39
-rw-r--r--comm/mail/locales/en-US/messenger/unifiedToolbar.ftl129
-rw-r--r--comm/mail/locales/en-US/messenger/unifiedToolbarItems.ftl234
-rw-r--r--comm/mail/locales/en-US/messenger/viewSource.ftl19
-rw-r--r--comm/mail/locales/en-US/updater/updater.ini8
-rw-r--r--comm/mail/locales/filter.py26
-rw-r--r--comm/mail/locales/generate_ini.py26
-rw-r--r--comm/mail/locales/jar.mn197
-rw-r--r--comm/mail/locales/l10n-beta.ini27
-rw-r--r--comm/mail/locales/l10n-central.ini27
-rw-r--r--comm/mail/locales/l10n-changesets.json723
-rw-r--r--comm/mail/locales/l10n-onchange-changesets.json77
-rw-r--r--comm/mail/locales/l10n.ini16
-rw-r--r--comm/mail/locales/l10n.toml112
-rw-r--r--comm/mail/locales/moz.build25
-rw-r--r--comm/mail/locales/onchange-locales13
-rw-r--r--comm/mail/locales/shipped-locales67
-rw-r--r--comm/mail/modules/AttachmentChecker.jsm118
-rw-r--r--comm/mail/modules/AttachmentInfo.sys.mjs626
-rw-r--r--comm/mail/modules/BrowserWindowTracker.jsm8
-rw-r--r--comm/mail/modules/ConversationOpener.jsm69
-rw-r--r--comm/mail/modules/DBViewWrapper.jsm2250
-rw-r--r--comm/mail/modules/DNS.jsm493
-rw-r--r--comm/mail/modules/DisplayNameUtils.jsm130
-rw-r--r--comm/mail/modules/ExtensionSupport.jsm240
-rw-r--r--comm/mail/modules/ExtensionsUI.jsm1461
-rw-r--r--comm/mail/modules/FolderTreeProperties.jsm84
-rw-r--r--comm/mail/modules/GlobalPopupNotifications.jsm1606
-rw-r--r--comm/mail/modules/MailConsts.jsm39
-rw-r--r--comm/mail/modules/MailE10SUtils.jsm98
-rw-r--r--comm/mail/modules/MailMigrator.jsm1200
-rw-r--r--comm/mail/modules/MailUsageTelemetry.jsm362
-rw-r--r--comm/mail/modules/MailUtils.jsm820
-rw-r--r--comm/mail/modules/MailViewManager.jsm169
-rw-r--r--comm/mail/modules/MessageArchiver.jsm392
-rw-r--r--comm/mail/modules/MsgHdrSyntheticView.jsm67
-rw-r--r--comm/mail/modules/PhishingDetector.jsm335
-rw-r--r--comm/mail/modules/QuickFilterManager.jsm1369
-rw-r--r--comm/mail/modules/SearchSpec.jsm562
-rw-r--r--comm/mail/modules/SelectionWidgetController.jsm1355
-rw-r--r--comm/mail/modules/SessionStore.jsm16
-rw-r--r--comm/mail/modules/SessionStoreManager.jsm302
-rw-r--r--comm/mail/modules/ShortcutsManager.jsm345
-rw-r--r--comm/mail/modules/SummaryFrameManager.jsm103
-rw-r--r--comm/mail/modules/TBDistCustomizer.jsm162
-rw-r--r--comm/mail/modules/TabStateFlusher.jsm8
-rw-r--r--comm/mail/modules/TagUtils.jsm152
-rw-r--r--comm/mail/modules/UIDensity.jsm76
-rw-r--r--comm/mail/modules/UIFontSize.jsm346
-rw-r--r--comm/mail/modules/WindowsJumpLists.jsm262
-rw-r--r--comm/mail/modules/moz.build47
-rw-r--r--comm/mail/moz.build40
-rw-r--r--comm/mail/moz.configure77
-rw-r--r--comm/mail/services/sync/modules/engines/accounts.sys.mjs392
-rw-r--r--comm/mail/services/sync/modules/engines/addressBooks.sys.mjs380
-rw-r--r--comm/mail/services/sync/modules/engines/calendars.sys.mjs349
-rw-r--r--comm/mail/services/sync/modules/engines/identities.sys.mjs394
-rw-r--r--comm/mail/services/sync/moz.build14
-rw-r--r--comm/mail/services/sync/test/unit/head.js7
-rw-r--r--comm/mail/services/sync/test/unit/test_account_store.js361
-rw-r--r--comm/mail/services/sync/test/unit/test_account_tracker.js155
-rw-r--r--comm/mail/services/sync/test/unit/test_addressBook_store.js130
-rw-r--r--comm/mail/services/sync/test/unit/test_addressBook_tracker.js167
-rw-r--r--comm/mail/services/sync/test/unit/test_calendar_store.js180
-rw-r--r--comm/mail/services/sync/test/unit/test_calendar_tracker.js154
-rw-r--r--comm/mail/services/sync/test/unit/test_identity_store.js222
-rw-r--r--comm/mail/services/sync/test/unit/test_identity_tracker.js238
-rw-r--r--comm/mail/services/sync/test/unit/xpcshell.ini11
-rw-r--r--comm/mail/test/browser/account/browser-clear.ini21
-rw-r--r--comm/mail/test/browser/account/browser.ini67
-rw-r--r--comm/mail/test/browser/account/browser_abWhitelist.js164
-rw-r--r--comm/mail/test/browser/account/browser_accountHub.js54
-rw-r--r--comm/mail/test/browser/account/browser_accountOrder.js91
-rw-r--r--comm/mail/test/browser/account/browser_accountSetupTab.js96
-rw-r--r--comm/mail/test/browser/account/browser_accountTelemetry.js278
-rw-r--r--comm/mail/test/browser/account/browser_actions.js226
-rw-r--r--comm/mail/test/browser/account/browser_archiveOptions.js200
-rw-r--r--comm/mail/test/browser/account/browser_deletion.js108
-rw-r--r--comm/mail/test/browser/account/browser_mailAccountSetupWizard.js931
-rw-r--r--comm/mail/test/browser/account/browser_manageIdentities.js325
-rw-r--r--comm/mail/test/browser/account/browser_portSetting.js87
-rw-r--r--comm/mail/test/browser/account/browser_retestConfig.js110
-rw-r--r--comm/mail/test/browser/account/browser_settingsInfrastructure.js488
-rw-r--r--comm/mail/test/browser/account/browser_tree.js208
-rw-r--r--comm/mail/test/browser/account/browser_values.js401
-rw-r--r--comm/mail/test/browser/account/head.js78
-rw-r--r--comm/mail/test/browser/account/xml/example-imap.com25
-rw-r--r--comm/mail/test/browser/account/xml/example-imap.com^headers^1
-rw-r--r--comm/mail/test/browser/account/xml/example.com24
-rw-r--r--comm/mail/test/browser/account/xml/example.com^headers^1
-rw-r--r--comm/mail/test/browser/account/xml/momo.invalid29
-rw-r--r--comm/mail/test/browser/account/xml/momo.invalid^headers^1
-rw-r--r--comm/mail/test/browser/attachment/browser.ini18
-rw-r--r--comm/mail/test/browser/attachment/browser_attachment.js764
-rw-r--r--comm/mail/test/browser/attachment/browser_attachmentEvents.js494
-rw-r--r--comm/mail/test/browser/attachment/browser_attachmentIcon.js254
-rw-r--r--comm/mail/test/browser/attachment/browser_attachmentInPlainMsg.js51
-rw-r--r--comm/mail/test/browser/attachment/browser_attachmentMenus.js565
-rw-r--r--comm/mail/test/browser/attachment/browser_attachmentSize.js421
-rw-r--r--comm/mail/test/browser/attachment/browser_openAttachment.js738
-rw-r--r--comm/mail/test/browser/attachment/data/attachment.txt1
-rw-r--r--comm/mail/test/browser/attachment/data/bug1358565.eml62
-rw-r--r--comm/mail/test/browser/cloudfile/browser.ini52
-rw-r--r--comm/mail/test/browser/cloudfile/browser_attachmentErrors.js440
-rw-r--r--comm/mail/test/browser/cloudfile/browser_attachmentItem.js451
-rw-r--r--comm/mail/test/browser/cloudfile/browser_attachmentUrls.js1554
-rw-r--r--comm/mail/test/browser/cloudfile/browser_filelinkTelemetry.js123
-rw-r--r--comm/mail/test/browser/cloudfile/browser_notifications.js565
-rw-r--r--comm/mail/test/browser/cloudfile/data/testFile11
-rw-r--r--comm/mail/test/browser/cloudfile/data/testFile23
-rw-r--r--comm/mail/test/browser/cloudfile/data/testFile33
-rw-r--r--comm/mail/test/browser/cloudfile/data/testFile41
-rw-r--r--comm/mail/test/browser/cloudfile/head.js7
-rw-r--r--comm/mail/test/browser/cloudfile/html/settings-with-link.xhtml9
-rw-r--r--comm/mail/test/browser/composition/browser.ini127
-rw-r--r--comm/mail/test/browser/composition/browser_addressWidgets.js773
-rw-r--r--comm/mail/test/browser/composition/browser_attachment.js995
-rw-r--r--comm/mail/test/browser/composition/browser_attachmentCloudDraft.js577
-rw-r--r--comm/mail/test/browser/composition/browser_attachmentDragDrop.js689
-rw-r--r--comm/mail/test/browser/composition/browser_attachmentReminder.js892
-rw-r--r--comm/mail/test/browser/composition/browser_base64Display.js48
-rw-r--r--comm/mail/test/browser/composition/browser_blockedContent.js149
-rw-r--r--comm/mail/test/browser/composition/browser_charsetEdit.js231
-rw-r--r--comm/mail/test/browser/composition/browser_checkRecipientKeys.js87
-rw-r--r--comm/mail/test/browser/composition/browser_cp932Display.js37
-rw-r--r--comm/mail/test/browser/composition/browser_customHeaders.js92
-rw-r--r--comm/mail/test/browser/composition/browser_draftIdentity.js313
-rw-r--r--comm/mail/test/browser/composition/browser_drafts.js457
-rw-r--r--comm/mail/test/browser/composition/browser_emlActions.js194
-rw-r--r--comm/mail/test/browser/composition/browser_encryptedBccRecipients.js283
-rw-r--r--comm/mail/test/browser/composition/browser_expandLists.js151
-rw-r--r--comm/mail/test/browser/composition/browser_focus.js523
-rw-r--r--comm/mail/test/browser/composition/browser_font_color.js114
-rw-r--r--comm/mail/test/browser/composition/browser_font_family.js146
-rw-r--r--comm/mail/test/browser/composition/browser_font_size.js333
-rw-r--r--comm/mail/test/browser/composition/browser_forwardDefectiveCharset.js109
-rw-r--r--comm/mail/test/browser/composition/browser_forwardHeaders.js175
-rw-r--r--comm/mail/test/browser/composition/browser_forwardRFC822Attach.js71
-rw-r--r--comm/mail/test/browser/composition/browser_forwardUTF8.js149
-rw-r--r--comm/mail/test/browser/composition/browser_forwardedContent.js70
-rw-r--r--comm/mail/test/browser/composition/browser_forwardedEmlActions.js171
-rw-r--r--comm/mail/test/browser/composition/browser_imageDisplay.js172
-rw-r--r--comm/mail/test/browser/composition/browser_imageInsertionDialog.js164
-rw-r--r--comm/mail/test/browser/composition/browser_inlineImage.js112
-rw-r--r--comm/mail/test/browser/composition/browser_linkPreviews.js39
-rw-r--r--comm/mail/test/browser/composition/browser_messageBody.js109
-rw-r--r--comm/mail/test/browser/composition/browser_multipartRelated.js143
-rw-r--r--comm/mail/test/browser/composition/browser_newmsgComposeIdentity.js273
-rw-r--r--comm/mail/test/browser/composition/browser_paragraph_state.js888
-rw-r--r--comm/mail/test/browser/composition/browser_publicRecipientsWarning.js734
-rw-r--r--comm/mail/test/browser/composition/browser_quoteMessage.js91
-rw-r--r--comm/mail/test/browser/composition/browser_recipientPillsSelection.js264
-rw-r--r--comm/mail/test/browser/composition/browser_redirect.js212
-rw-r--r--comm/mail/test/browser/composition/browser_remove_text_styling.js136
-rw-r--r--comm/mail/test/browser/composition/browser_replyAddresses.js1143
-rw-r--r--comm/mail/test/browser/composition/browser_replyCatchAll.js271
-rw-r--r--comm/mail/test/browser/composition/browser_replyFormatFlowed.js90
-rw-r--r--comm/mail/test/browser/composition/browser_replyMultipartCharset.js149
-rw-r--r--comm/mail/test/browser/composition/browser_replySelection.js64
-rw-r--r--comm/mail/test/browser/composition/browser_replySignature.js117
-rw-r--r--comm/mail/test/browser/composition/browser_saveChangesOnQuit.js420
-rw-r--r--comm/mail/test/browser/composition/browser_sendButton.js363
-rw-r--r--comm/mail/test/browser/composition/browser_sendFormat.js565
-rw-r--r--comm/mail/test/browser/composition/browser_signatureInit.js50
-rw-r--r--comm/mail/test/browser/composition/browser_signatureUpdating.js276
-rw-r--r--comm/mail/test/browser/composition/browser_spelling.js311
-rw-r--r--comm/mail/test/browser/composition/browser_subjectWas.js65
-rw-r--r--comm/mail/test/browser/composition/browser_text_styling.js609
-rw-r--r--comm/mail/test/browser/composition/browser_xUnsent.js45
-rw-r--r--comm/mail/test/browser/composition/data/attachment.txt4
-rw-r--r--comm/mail/test/browser/composition/data/base64-bug1586890.eml25
-rw-r--r--comm/mail/test/browser/composition/data/base64-encoded-msg.eml11
-rw-r--r--comm/mail/test/browser/composition/data/base64-with-whitespace.eml46
-rw-r--r--comm/mail/test/browser/composition/data/body-greek.eml9
-rw-r--r--comm/mail/test/browser/composition/data/body-utf16.eml10
-rw-r--r--comm/mail/test/browser/composition/data/charset-cp932.eml11
-rw-r--r--comm/mail/test/browser/composition/data/content-utf8-alt-rel.eml46
-rw-r--r--comm/mail/test/browser/composition/data/content-utf8-alt-rel2.eml46
-rw-r--r--comm/mail/test/browser/composition/data/content-utf8-rel-alt.eml40
-rw-r--r--comm/mail/test/browser/composition/data/content-utf8-rel-only.eml32
-rw-r--r--comm/mail/test/browser/composition/data/defective-charset.eml28
-rw-r--r--comm/mail/test/browser/composition/data/en_NZ/en_NZ.aff0
-rw-r--r--comm/mail/test/browser/composition/data/en_NZ/en_NZ.dic23
-rw-r--r--comm/mail/test/browser/composition/data/evil-meta-msg.eml11
-rw-r--r--comm/mail/test/browser/composition/data/feed-message.eml26
-rw-r--r--comm/mail/test/browser/composition/data/format-flowed.eml12
-rw-r--r--comm/mail/test/browser/composition/data/format1-altering.eml21
-rw-r--r--comm/mail/test/browser/composition/data/format1-plain.eml22
-rw-r--r--comm/mail/test/browser/composition/data/format2-style-attr.eml37
-rw-r--r--comm/mail/test/browser/composition/data/format3-style-tag.eml31
-rw-r--r--comm/mail/test/browser/composition/data/iso-2022-jp.eml12
-rw-r--r--comm/mail/test/browser/composition/data/long-html-line.eml16
-rw-r--r--comm/mail/test/browser/composition/data/mime-encoded-subject.eml16
-rw-r--r--comm/mail/test/browser/composition/data/multipart-charset.eml24
-rw-r--r--comm/mail/test/browser/composition/data/nest.pngbin0 -> 293451 bytes
-rw-r--r--comm/mail/test/browser/composition/data/non-flowed-plain.eml15
-rw-r--r--comm/mail/test/browser/composition/data/tb-logo.pngbin0 -> 6462 bytes
-rw-r--r--comm/mail/test/browser/composition/data/testmsg.eml16
-rw-r--r--comm/mail/test/browser/composition/data/xunsent.eml14
-rw-r--r--comm/mail/test/browser/composition/head.js65
-rw-r--r--comm/mail/test/browser/composition/html/linkpreview.html14
-rw-r--r--comm/mail/test/browser/content-policy/browser.ini19
-rw-r--r--comm/mail/test/browser/content-policy/browser_composeMailto.js119
-rw-r--r--comm/mail/test/browser/content-policy/browser_dnsPrefetch.js233
-rw-r--r--comm/mail/test/browser/content-policy/browser_exposedInContentTabs.js175
-rw-r--r--comm/mail/test/browser/content-policy/browser_generalContentPolicy.js908
-rw-r--r--comm/mail/test/browser/content-policy/browser_jsContentPolicy.js285
-rw-r--r--comm/mail/test/browser/content-policy/browser_pluginsPolicy.js245
-rw-r--r--comm/mail/test/browser/content-policy/html/401.sjs10
-rw-r--r--comm/mail/test/browser/content-policy/html/mailtolink.html8
-rw-r--r--comm/mail/test/browser/content-policy/html/pass.pngbin0 -> 732 bytes
-rw-r--r--comm/mail/test/browser/content-policy/html/plugin.html10
-rw-r--r--comm/mail/test/browser/content-policy/html/remote-noscript.html19
-rw-r--r--comm/mail/test/browser/content-policy/html/remoteimage.html9
-rw-r--r--comm/mail/test/browser/content-policy/html/remoteimagedata.html9
-rw-r--r--comm/mail/test/browser/content-policy/html/remotevideo.html9
-rw-r--r--comm/mail/test/browser/content-policy/html/video.ogvbin0 -> 40604 bytes
-rw-r--r--comm/mail/test/browser/content-tabs/browser.ini50
-rw-r--r--comm/mail/test/browser/content-tabs/browser_aboutSupport.js587
-rw-r--r--comm/mail/test/browser/content-tabs/browser_addonsMgr.js76
-rw-r--r--comm/mail/test/browser/content-tabs/browser_contentTab.js170
-rw-r--r--comm/mail/test/browser/content-tabs/browser_installXpi.js148
-rw-r--r--comm/mail/test/browser/content-tabs/html/blocklist.xml10
-rw-r--r--comm/mail/test/browser/content-tabs/html/blocklistHard.xml10
-rw-r--r--comm/mail/test/browser/content-tabs/html/blocklist_details.html8
-rw-r--r--comm/mail/test/browser/content-tabs/html/corrupt.xpibin0 -> 695 bytes
-rw-r--r--comm/mail/test/browser/content-tabs/html/dummy.xml4
-rw-r--r--comm/mail/test/browser/content-tabs/html/favicon.icobin0 -> 1394 bytes
-rw-r--r--comm/mail/test/browser/content-tabs/html/installxpi.html13
-rw-r--r--comm/mail/test/browser/content-tabs/html/installxpi.xpibin0 -> 701 bytes
-rw-r--r--comm/mail/test/browser/content-tabs/html/plugin.html9
-rw-r--r--comm/mail/test/browser/content-tabs/html/plugin_crashed_help.html8
-rw-r--r--comm/mail/test/browser/content-tabs/html/plugin_update.html8
-rw-r--r--comm/mail/test/browser/content-tabs/html/test-lwthemes.html44
-rw-r--r--comm/mail/test/browser/content-tabs/html/test.pngbin0 -> 6712 bytes
-rw-r--r--comm/mail/test/browser/content-tabs/html/webextension.xpibin0 -> 323 bytes
-rw-r--r--comm/mail/test/browser/content-tabs/html/whatsnew.html13
-rw-r--r--comm/mail/test/browser/content-tabs/html/whatsnew.pngbin0 -> 633 bytes
-rw-r--r--comm/mail/test/browser/content-tabs/html/whatsnew1.html8
-rw-r--r--comm/mail/test/browser/cookies/browser.ini12
-rw-r--r--comm/mail/test/browser/cookies/browser_cookies.js57
-rw-r--r--comm/mail/test/browser/cookies/html/cookietest1.html13
-rw-r--r--comm/mail/test/browser/cookies/html/cookietest2.html13
-rw-r--r--comm/mail/test/browser/downloads/browser.ini11
-rw-r--r--comm/mail/test/browser/downloads/browser_aboutDownloads.js384
-rw-r--r--comm/mail/test/browser/folder-display/browser.ini81
-rw-r--r--comm/mail/test/browser/folder-display/browser_applyView.js331
-rw-r--r--comm/mail/test/browser/folder-display/browser_archiveMessages.js132
-rw-r--r--comm/mail/test/browser/folder-display/browser_closeWindowOnDelete.js319
-rw-r--r--comm/mail/test/browser/folder-display/browser_columns.js955
-rw-r--r--comm/mail/test/browser/folder-display/browser_deletionFromVirtualFolders.js383
-rw-r--r--comm/mail/test/browser/folder-display/browser_deletionWithMultipleDisplays.js787
-rw-r--r--comm/mail/test/browser/folder-display/browser_displayName.js244
-rw-r--r--comm/mail/test/browser/folder-display/browser_folderPaneVisibility.js275
-rw-r--r--comm/mail/test/browser/folder-display/browser_folderToolbar.js147
-rw-r--r--comm/mail/test/browser/folder-display/browser_invalidDbFolderLoad.js61
-rw-r--r--comm/mail/test/browser/folder-display/browser_mailTelemetry.js135
-rw-r--r--comm/mail/test/browser/folder-display/browser_mailViews.js128
-rw-r--r--comm/mail/test/browser/folder-display/browser_messageCommands.js802
-rw-r--r--comm/mail/test/browser/folder-display/browser_messageCommandsOnMsgstore.js333
-rw-r--r--comm/mail/test/browser/folder-display/browser_messagePaneVisibility.js250
-rw-r--r--comm/mail/test/browser/folder-display/browser_messageReloads.js64
-rw-r--r--comm/mail/test/browser/folder-display/browser_messageSize.js80
-rw-r--r--comm/mail/test/browser/folder-display/browser_messageWindow.js153
-rw-r--r--comm/mail/test/browser/folder-display/browser_openingMessages.js186
-rw-r--r--comm/mail/test/browser/folder-display/browser_openingMessagesWithoutABackingView.js248
-rw-r--r--comm/mail/test/browser/folder-display/browser_readMsgs.js62
-rw-r--r--comm/mail/test/browser/folder-display/browser_recentMenu.js195
-rw-r--r--comm/mail/test/browser/folder-display/browser_rightClickMiddleClickFolders.js276
-rw-r--r--comm/mail/test/browser/folder-display/browser_rightClickMiddleClickMessages.js564
-rw-r--r--comm/mail/test/browser/folder-display/browser_savedsearchReloadAfterCompact.js105
-rw-r--r--comm/mail/test/browser/folder-display/browser_selection.js202
-rw-r--r--comm/mail/test/browser/folder-display/browser_summarization.js462
-rw-r--r--comm/mail/test/browser/folder-display/browser_syntheticViews.js292
-rw-r--r--comm/mail/test/browser/folder-display/browser_tabsSimple.js195
-rw-r--r--comm/mail/test/browser/folder-display/browser_viewSource.js222
-rw-r--r--comm/mail/test/browser/folder-display/browser_virtualFolderCommands.js83
-rw-r--r--comm/mail/test/browser/folder-display/browser_watchIgnoreThread.js150
-rw-r--r--comm/mail/test/browser/folder-display/data/test-invalid-vcard.eml25
-rw-r--r--comm/mail/test/browser/folder-display/head.js76
-rw-r--r--comm/mail/test/browser/folder-pane/browser.ini49
-rw-r--r--comm/mail/test/browser/folder-pane/browser_displayMessageWithFolderModes.js250
-rw-r--r--comm/mail/test/browser/folder-pane/browser_folderNamesInRecentMode.js117
-rw-r--r--comm/mail/test/browser/folder-pane/browser_folderPane.js136
-rw-r--r--comm/mail/test/browser/folder-pane/browser_folderPaneConsumers.js143
-rw-r--r--comm/mail/test/browser/folder-pane/browser_folderPaneHeader.js945
-rw-r--r--comm/mail/test/browser/folder-pane/browser_folderPaneModeContextMenu.js208
-rw-r--r--comm/mail/test/browser/folder-tree-modes/browser.ini51
-rw-r--r--comm/mail/test/browser/folder-tree-modes/browser_customFolderTreeMode.js142
-rw-r--r--comm/mail/test/browser/folder-tree-modes/browser_customSmartFolder.js211
-rw-r--r--comm/mail/test/browser/folder-tree-modes/browser_modeSwitching.js338
-rw-r--r--comm/mail/test/browser/folder-tree-modes/browser_smartFolders.js179
-rw-r--r--comm/mail/test/browser/folder-tree-modes/browser_unreadFolders.js91
-rw-r--r--comm/mail/test/browser/folder-widget/browser.ini44
-rw-r--r--comm/mail/test/browser/folder-widget/browser_messageFilters.js396
-rw-r--r--comm/mail/test/browser/global-search-bar/browser.ini12
-rw-r--r--comm/mail/test/browser/global-search-bar/browser_clickResultItem.js142
-rw-r--r--comm/mail/test/browser/global-search-bar/browser_globalSearchBar.js43
-rw-r--r--comm/mail/test/browser/im/browser.ini23
-rw-r--r--comm/mail/test/browser/im/browser_chatTabRestore.js102
-rw-r--r--comm/mail/test/browser/im/browser_toolbarButtons.js147
-rw-r--r--comm/mail/test/browser/import/browser.ini14
-rw-r--r--comm/mail/test/browser/import/browser_exportProfile.js97
-rw-r--r--comm/mail/test/browser/import/browser_importProfile.js323
-rw-r--r--comm/mail/test/browser/junk-commands/browser.ini11
-rw-r--r--comm/mail/test/browser/junk-commands/browser_junkCommands.js105
-rw-r--r--comm/mail/test/browser/keyboard/browser.ini11
-rw-r--r--comm/mail/test/browser/keyboard/browser_spacehit.js96
-rw-r--r--comm/mail/test/browser/message-header/browser.ini18
-rw-r--r--comm/mail/test/browser/message-header/browser_messageHeader.js1237
-rw-r--r--comm/mail/test/browser/message-header/browser_messageHeaderCustomize.js388
-rw-r--r--comm/mail/test/browser/message-header/browser_phishingBar.js307
-rw-r--r--comm/mail/test/browser/message-header/browser_replyIdentity.js231
-rw-r--r--comm/mail/test/browser/message-header/browser_replyToListFromAddressSelection.js121
-rw-r--r--comm/mail/test/browser/message-header/browser_returnReceipt.js208
-rw-r--r--comm/mail/test/browser/message-header/data/evil-attached.eml22
-rw-r--r--comm/mail/test/browser/message-header/data/evil.eml7
-rw-r--r--comm/mail/test/browser/message-header/head.js190
-rw-r--r--comm/mail/test/browser/message-reader/browser.ini18
-rw-r--r--comm/mail/test/browser/message-reader/browser_androidMMS.js75
-rw-r--r--comm/mail/test/browser/message-reader/browser_bug594646.js92
-rw-r--r--comm/mail/test/browser/message-reader/browser_convertToEventOrTask.js129
-rw-r--r--comm/mail/test/browser/message-reader/browser_detectCharset.js99
-rw-r--r--comm/mail/test/browser/message-reader/browser_printing.js114
-rw-r--r--comm/mail/test/browser/message-reader/data/bug1774805_android_mms.eml166
-rw-r--r--comm/mail/test/browser/message-reader/data/bug1843628_named_page.eml28
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_newline_charset_8bit.eml23
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_newline_charset_b64.eml16
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_newline_charset_qp.eml23
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_8bit.eml23
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_b64.eml16
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_qp.eml23
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_reference.eml22
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_reversed_order_8bit.eml22
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_reversed_order_b64.eml16
-rw-r--r--comm/mail/test/browser/message-reader/data/bug594646_reversed_order_qp.eml22
-rw-r--r--comm/mail/test/browser/message-reader/data/correctEncodingUTF8.eml11
-rw-r--r--comm/mail/test/browser/message-reader/data/multiparty.eml5966
-rw-r--r--comm/mail/test/browser/message-reader/data/noCharsetKOI8U.eml10
-rw-r--r--comm/mail/test/browser/message-reader/data/noCharsetWindows1252.eml22
-rw-r--r--comm/mail/test/browser/message-reader/data/wronglyDeclaredShift_JIS.eml11
-rw-r--r--comm/mail/test/browser/message-reader/data/wronglyDeclaredUTF8.eml11
-rw-r--r--comm/mail/test/browser/message-window/browser.ini18
-rw-r--r--comm/mail/test/browser/message-window/browser_autohideMenubar.js121
-rw-r--r--comm/mail/test/browser/message-window/browser_commands.js103
-rw-r--r--comm/mail/test/browser/message-window/browser_emlSubject.js48
-rw-r--r--comm/mail/test/browser/message-window/browser_vcardActions.js101
-rw-r--r--comm/mail/test/browser/message-window/browser_viewPlaintext.js138
-rw-r--r--comm/mail/test/browser/message-window/data/emptySubject.eml8
-rw-r--r--comm/mail/test/browser/message-window/data/evil.eml16
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt-HTML-missing.eml17
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt-plain-HTML-reversed.eml31
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt-plain-missing.eml24
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt-rel-text.eml50
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt-rel-with-attach.eml66
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt-rel.eml45
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt-rogue.eml42
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt-rogue2.eml36
-rw-r--r--comm/mail/test/browser/message-window/data/test-alt.eml30
-rw-r--r--comm/mail/test/browser/message-window/data/test-rel-alt.eml41
-rw-r--r--comm/mail/test/browser/message-window/data/test-triple-alt.eml36
-rw-r--r--comm/mail/test/browser/message-window/data/test-vcard-icon.eml800
-rw-r--r--comm/mail/test/browser/moz.build63
-rw-r--r--comm/mail/test/browser/multiple-identities/browser.ini11
-rw-r--r--comm/mail/test/browser/multiple-identities/browser_displayNames.js224
-rw-r--r--comm/mail/test/browser/multiple-identities/readme.txt4
-rw-r--r--comm/mail/test/browser/newmailaccount/browser.ini15
-rw-r--r--comm/mail/test/browser/newmailaccount/browser_newmailaccount.js682
-rw-r--r--comm/mail/test/browser/newmailaccount/html/badSuggestFromName4
-rw-r--r--comm/mail/test/browser/newmailaccount/html/config.xml33
-rw-r--r--comm/mail/test/browser/newmailaccount/html/configCorrupt.xml25
-rw-r--r--comm/mail/test/browser/newmailaccount/html/configError.xml6
-rw-r--r--comm/mail/test/browser/newmailaccount/html/emptySuggestFromName1
-rw-r--r--comm/mail/test/browser/newmailaccount/html/providerList63
-rw-r--r--comm/mail/test/browser/newmailaccount/html/providerListBad15
-rw-r--r--comm/mail/test/browser/newmailaccount/html/providerListIncomplete41
-rw-r--r--comm/mail/test/browser/newmailaccount/html/providerListNoOtherLangs28
-rw-r--r--comm/mail/test/browser/newmailaccount/html/providerListWildcard37
-rw-r--r--comm/mail/test/browser/newmailaccount/html/registration.html25
-rw-r--r--comm/mail/test/browser/newmailaccount/html/registrationCorrupt.html21
-rw-r--r--comm/mail/test/browser/newmailaccount/html/registrationError.html21
-rw-r--r--comm/mail/test/browser/newmailaccount/html/suggestFromName13
-rw-r--r--comm/mail/test/browser/newmailaccount/html/target.html10
-rw-r--r--comm/mail/test/browser/notification/browser.ini12
-rw-r--r--comm/mail/test/browser/notification/browser_notification.js720
-rw-r--r--comm/mail/test/browser/openpgp/browser.ini18
-rw-r--r--comm/mail/test/browser/openpgp/browser_collectKeys.js318
-rw-r--r--comm/mail/test/browser/openpgp/browser_keyWizard.js363
-rw-r--r--comm/mail/test/browser/openpgp/browser_openPGPDrafts.js162
-rw-r--r--comm/mail/test/browser/openpgp/browser_perm_decrypt.js156
-rw-r--r--comm/mail/test/browser/openpgp/browser_viewMessage.js931
-rw-r--r--comm/mail/test/browser/openpgp/browser_viewMessage2.js124
-rw-r--r--comm/mail/test/browser/openpgp/browser_viewMessageSecurity.js312
-rw-r--r--comm/mail/test/browser/openpgp/browser_viewPartialMessage.js239
-rw-r--r--comm/mail/test/browser/openpgp/composition/browser.ini23
-rw-r--r--comm/mail/test/browser/openpgp/composition/browser_composeEncrypted.js976
-rw-r--r--comm/mail/test/browser/openpgp/composition/browser_composeSigned.js423
-rw-r--r--comm/mail/test/browser/openpgp/composition/browser_composeSigned2.js213
-rw-r--r--comm/mail/test/browser/openpgp/composition/browser_composeSwitchIdentity.js821
-rw-r--r--comm/mail/test/browser/openpgp/composition/browser_editDraftTemplate.js221
-rw-r--r--comm/mail/test/browser/openpgp/composition/browser_expiredKey.js137
-rw-r--r--comm/mail/test/browser/openpgp/composition/head.js18
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/alice-broken-exchange.eml64
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/alice-utf.eml23
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/bob-enc-html-nbsp.eml38
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/bob-enc-inline-nbsp-qp.eml17
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/bob-to-alice-signed-damaged-signature.eml55
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/enc-to-carol@pgp.icu-revoked.eml73
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/encrypted-and-signed-alice-to-bob-nonascii.eml94
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/eve-duplicate.eml122
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/fwd-unsigned-encrypted.eml75
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc-sig.eml106
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc-with-mixed.eml92
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc.eml82
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-smime-enc-sig.eml113
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-smime-enc.eml55
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc-sig-with-mixed.eml135
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc-sig.eml125
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc.eml101
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-smime-enc-sig.eml132
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-smime-enc.eml74
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-alice-html.eml31
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-alice-plaintext.eml19
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-carol-html.eml41
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-carol-plaintext.eml29
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/partial-signed-from-bob-html.eml41
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/partial-signed-from-bob-plaintext.eml29
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/partial-signed-from-carol-html.eml44
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/partial-signed-from-carol-plaintext.eml32
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e-with-key.eml187
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e.eml78
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted-with-key.eml197
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted.eml57
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-multi-from.eml74
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-with-key.eml160
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml73
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted-with-key.eml167
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted.eml54
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-encrypted-autocrypt-gossip.eml174
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-inline-indented.eml23
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-mismatch-email-date.eml54
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/signed-with-mailman-footer.eml75
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/unrelated-and-fake-keys-attached.eml176
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f-with-key.eml163
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f.eml54
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330-with-key.eml139
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml52
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-0x3099ff1238852b9f-autocrypt.eml55
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-from-bob-to-alice.eml17
-rw-r--r--comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-key-0x1f10171bfb881b1c-attached.eml69
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-pub.asc15
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-rev.asc9
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret-with-pp.asc17
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc17
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc43
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-rev.asc16
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret-with-pp.asc83
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc83
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc51
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/carol@example.com-0x3099ff1238852b9f-secret.asc107
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/carol@pgp.icu-0xEF2FD01608AFD744-revoked-secret.asc90
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-pub.asc13
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-secret.asc15
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/encryption-subkey-bad.pgpbin0 -> 749 bytes
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/heisenberg-signed-by-pinkman.asc37
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/invalid-pubkey-nosigs.pgpbin0 -> 285 bytes
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/key-binary.gpgbin0 -> 414 bytes
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/key-with-utf8-comment.asc15
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/kylie-0x1AABD9FAD1E411DD-secret-subkeys.asc23
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/ofelia-public.asc68
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/ofelia-secret-subkeys.asc108
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/ofelia-secret.asc129
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/secret-for-preferred-sign-subkey-is-missing--a-without-second-sub--sec.asc129
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/secret-for-preferred-sign-subkey-is-missing--b-with-second-sub--pub.asc95
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/two-enc-subkeys-one-deleted.sec.asc35
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/two-enc-subkeys-still-both.pub.asc31
-rw-r--r--comm/mail/test/browser/openpgp/data/keys/untweaked-secret.asc15
-rw-r--r--comm/mail/test/browser/openpgp/data/smime/Bob.p12bin0 -> 3666 bytes
-rw-r--r--comm/mail/test/browser/openpgp/data/smime/alice.env.eml25
-rw-r--r--comm/mail/test/browser/override-main-menu-collapse/browser.ini13
-rw-r--r--comm/mail/test/browser/override-main-menu-collapse/browser_overrideMainmenuCollapse.js19
-rw-r--r--comm/mail/test/browser/pref-window/browser.ini12
-rw-r--r--comm/mail/test/browser/pref-window/browser_fontChooser.js375
-rw-r--r--comm/mail/test/browser/quick-filter-bar/browser.ini15
-rw-r--r--comm/mail/test/browser/quick-filter-bar/browser_filterLogic.js462
-rw-r--r--comm/mail/test/browser/quick-filter-bar/browser_keyboardInterface.js185
-rw-r--r--comm/mail/test/browser/quick-filter-bar/browser_stickyFilterLogic.js167
-rw-r--r--comm/mail/test/browser/quick-filter-bar/browser_toggleBar.js117
-rw-r--r--comm/mail/test/browser/quick-filter-bar/head.js56
-rw-r--r--comm/mail/test/browser/search-window/browser.ini15
-rw-r--r--comm/mail/test/browser/search-window/browser_multipleSearchWindows.js77
-rw-r--r--comm/mail/test/browser/search-window/browser_rightClickToOpenSearchWindow.js63
-rw-r--r--comm/mail/test/browser/search-window/browser_searchFromSyntheticView.js110
-rw-r--r--comm/mail/test/browser/search-window/browser_searchWindow.js358
-rw-r--r--comm/mail/test/browser/session-store/browser.ini11
-rw-r--r--comm/mail/test/browser/session-store/browser_sessionStore.js680
-rw-r--r--comm/mail/test/browser/shared-modules/.eslintrc.js7
-rw-r--r--comm/mail/test/browser/shared-modules/AccountManagerHelpers.jsm204
-rw-r--r--comm/mail/test/browser/shared-modules/AddressBookHelpers.jsm182
-rw-r--r--comm/mail/test/browser/shared-modules/AttachmentHelpers.jsm240
-rw-r--r--comm/mail/test/browser/shared-modules/CloudfileHelpers.jsm278
-rw-r--r--comm/mail/test/browser/shared-modules/ComposeHelpers.jsm2430
-rw-r--r--comm/mail/test/browser/shared-modules/ContentTabHelpers.jsm423
-rw-r--r--comm/mail/test/browser/shared-modules/CustomizationHelpers.jsm121
-rw-r--r--comm/mail/test/browser/shared-modules/DOMHelpers.jsm256
-rw-r--r--comm/mail/test/browser/shared-modules/EventUtils.jsm876
-rw-r--r--comm/mail/test/browser/shared-modules/FolderDisplayHelpers.jsm3243
-rw-r--r--comm/mail/test/browser/shared-modules/JunkHelpers.jsm97
-rw-r--r--comm/mail/test/browser/shared-modules/KeyboardHelpers.jsm58
-rw-r--r--comm/mail/test/browser/shared-modules/MockObjectHelpers.jsm161
-rw-r--r--comm/mail/test/browser/shared-modules/MouseEventHelpers.jsm226
-rw-r--r--comm/mail/test/browser/shared-modules/NNTPHelpers.jsm123
-rw-r--r--comm/mail/test/browser/shared-modules/NewMailAccountHelpers.jsm25
-rw-r--r--comm/mail/test/browser/shared-modules/NotificationBoxHelpers.jsm219
-rw-r--r--comm/mail/test/browser/shared-modules/OpenPGPTestUtils.jsm329
-rw-r--r--comm/mail/test/browser/shared-modules/PrefTabHelpers.jsm53
-rw-r--r--comm/mail/test/browser/shared-modules/PromptHelpers.jsm271
-rw-r--r--comm/mail/test/browser/shared-modules/QuickFilterBarHelpers.jsm391
-rw-r--r--comm/mail/test/browser/shared-modules/SearchWindowHelpers.jsm206
-rw-r--r--comm/mail/test/browser/shared-modules/SubscribeWindowHelpers.jsm82
-rw-r--r--comm/mail/test/browser/shared-modules/ViewHelpers.jsm85
-rw-r--r--comm/mail/test/browser/shared-modules/WindowHelpers.jsm1018
-rw-r--r--comm/mail/test/browser/shared-modules/controller.jsm60
-rw-r--r--comm/mail/test/browser/shared-modules/moz.build34
-rw-r--r--comm/mail/test/browser/shared-modules/utils.jsm130
-rw-r--r--comm/mail/test/browser/smime/browser.ini13
-rw-r--r--comm/mail/test/browser/smime/browser_multipartAlternative.js100
-rw-r--r--comm/mail/test/browser/smime/browser_nestedSMimeSigs.js49
-rw-r--r--comm/mail/test/browser/smime/data/Bob.p12bin0 -> 3666 bytes
-rw-r--r--comm/mail/test/browser/smime/data/README.md5
-rw-r--r--comm/mail/test/browser/smime/data/TestCA.pem21
-rw-r--r--comm/mail/test/browser/smime/data/multipart-alternative.eml42
-rw-r--r--comm/mail/test/browser/smime/data/nested-sigs.eml219
-rw-r--r--comm/mail/test/browser/startup-firstrun/browser.ini13
-rw-r--r--comm/mail/test/browser/startup-firstrun/browser_menubarCollapsed.js19
-rw-r--r--comm/mail/test/browser/subscribe/browser.ini11
-rw-r--r--comm/mail/test/browser/subscribe/browser_newsFilter.js67
-rw-r--r--comm/mail/test/browser/tabmail/browser.ini14
-rw-r--r--comm/mail/test/browser/tabmail/browser_closing.js407
-rw-r--r--comm/mail/test/browser/tabmail/browser_customize.js145
-rw-r--r--comm/mail/test/browser/tabmail/browser_dragndrop.js475
-rw-r--r--comm/mail/test/browser/tabmail/browser_tabSwitch.js344
-rw-r--r--comm/mail/test/browser/utils/browser.ini13
-rw-r--r--comm/mail/test/browser/utils/browser_extensionSupport.js171
-rw-r--r--comm/mail/test/browser/utils/html/collections.html26
-rw-r--r--comm/mail/test/marionette/manifest.ini1
-rw-r--r--comm/mail/test/marionette/moz.build6
-rw-r--r--comm/mail/test/marionette/test_empty.py10
-rw-r--r--comm/mail/test/static/.eslintrc.js11
-rw-r--r--comm/mail/test/static/browser.ini15
-rw-r--r--comm/mail/test/static/browser_parsable_css.js573
-rw-r--r--comm/mail/test/static/browser_parsable_script.js169
-rw-r--r--comm/mail/test/static/dummy_page.html9
-rw-r--r--comm/mail/test/static/head.js158
-rw-r--r--comm/mail/test/static/moz.build8
-rw-r--r--comm/mail/testsuite-targets.mk20
-rw-r--r--comm/mail/themes/BuiltInThemes.sys.mjs109
-rw-r--r--comm/mail/themes/ThemeVariableMap.sys.mjs194
-rw-r--r--comm/mail/themes/Windows8WindowFrameColor.sys.mjs55
-rw-r--r--comm/mail/themes/addons/dark/experiment.css6
-rw-r--r--comm/mail/themes/addons/dark/icon.svg13
-rw-r--r--comm/mail/themes/addons/dark/manifest.json73
-rw-r--r--comm/mail/themes/addons/dark/preview.svg18
-rw-r--r--comm/mail/themes/addons/jar.mn14
-rw-r--r--comm/mail/themes/addons/light/experiment.css6
-rw-r--r--comm/mail/themes/addons/light/icon.svg13
-rw-r--r--comm/mail/themes/addons/light/manifest.json68
-rw-r--r--comm/mail/themes/addons/light/preview.svg36
-rw-r--r--comm/mail/themes/addons/moz.build7
-rw-r--r--comm/mail/themes/icon.pngbin0 -> 1221 bytes
-rw-r--r--comm/mail/themes/icon64.pngbin0 -> 2284 bytes
-rw-r--r--comm/mail/themes/linux/editor/EditorDialog.css14
-rw-r--r--comm/mail/themes/linux/editor/img-align-bottom.gifbin0 -> 192 bytes
-rw-r--r--comm/mail/themes/linux/editor/img-align-left.gifbin0 -> 173 bytes
-rw-r--r--comm/mail/themes/linux/editor/img-align-middle.gifbin0 -> 184 bytes
-rw-r--r--comm/mail/themes/linux/editor/img-align-right.gifbin0 -> 169 bytes
-rw-r--r--comm/mail/themes/linux/editor/img-align-top.gifbin0 -> 186 bytes
-rw-r--r--comm/mail/themes/linux/jar.mn85
-rw-r--r--comm/mail/themes/linux/mail/accountCentral.css9
-rw-r--r--comm/mail/themes/linux/mail/accountManage.css5
-rw-r--r--comm/mail/themes/linux/mail/activity/activity.css5
-rw-r--r--comm/mail/themes/linux/mail/activity/addItemIcon.pngbin0 -> 601 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/compactMailIcon.pngbin0 -> 1668 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/copyMailIcon.pngbin0 -> 1162 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/defaultEventIcon.pngbin0 -> 1203 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/defaultProcessIcon.pngbin0 -> 2148 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/deleteMailIcon.pngbin0 -> 2324 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/indexMailIcon.pngbin0 -> 2070 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/moveMailIcon.pngbin0 -> 1250 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/removeItemIcon.pngbin0 -> 317 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/sendMailIcon.pngbin0 -> 1536 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/syncMailIcon.pngbin0 -> 2138 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/undoIcon.pngbin0 -> 1532 bytes
-rw-r--r--comm/mail/themes/linux/mail/activity/warning.pngbin0 -> 1447 bytes
-rw-r--r--comm/mail/themes/linux/mail/addrbook/abContactsPanel.css31
-rw-r--r--comm/mail/themes/linux/mail/addrbook/cardDialog.css14
-rw-r--r--comm/mail/themes/linux/mail/attachmentList.css7
-rw-r--r--comm/mail/themes/linux/mail/chat.css94
-rw-r--r--comm/mail/themes/linux/mail/common.css56
-rw-r--r--comm/mail/themes/linux/mail/compacttheme.css5
-rw-r--r--comm/mail/themes/linux/mail/compose/messengercompose.css157
-rw-r--r--comm/mail/themes/linux/mail/contextMenu.css19
-rw-r--r--comm/mail/themes/linux/mail/customizeToolbar.css13
-rw-r--r--comm/mail/themes/linux/mail/downloads/aboutDownloads.css9
-rw-r--r--comm/mail/themes/linux/mail/filterDialog.css47
-rw-r--r--comm/mail/themes/linux/mail/folderMenus.css22
-rw-r--r--comm/mail/themes/linux/mail/folderPane.css19
-rw-r--r--comm/mail/themes/linux/mail/glodaFacetView.css9
-rw-r--r--comm/mail/themes/linux/mail/icons/arrow/arrow-down-dim.pngbin0 -> 668 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/arrow/arrow-down.pngbin0 -> 394 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/arrow/arrow-left.pngbin0 -> 413 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/arrow/arrow-right-dim.pngbin0 -> 814 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/arrow/arrow-right.pngbin0 -> 391 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/arrow/arrow-up.pngbin0 -> 209 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/connecting.pngbin0 -> 8540 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/error.pngbin0 -> 666 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/identity.pngbin0 -> 7822 bytes
-rw-r--r--comm/mail/themes/linux/mail/icons/multicolor.pngbin0 -> 160 bytes
-rw-r--r--comm/mail/themes/linux/mail/imAccounts.css13
-rw-r--r--comm/mail/themes/linux/mail/input-fields.css19
-rw-r--r--comm/mail/themes/linux/mail/junkMail.css18
-rw-r--r--comm/mail/themes/linux/mail/mailWindow1.css67
-rw-r--r--comm/mail/themes/linux/mail/menulist.css29
-rw-r--r--comm/mail/themes/linux/mail/message-bar.css13
-rw-r--r--comm/mail/themes/linux/mail/messageBody.css10
-rw-r--r--comm/mail/themes/linux/mail/messageHeader.css16
-rw-r--r--comm/mail/themes/linux/mail/messageIcons.css57
-rw-r--r--comm/mail/themes/linux/mail/messageWindow.css25
-rw-r--r--comm/mail/themes/linux/mail/messenger.css421
-rw-r--r--comm/mail/themes/linux/mail/multimessageview.css11
-rw-r--r--comm/mail/themes/linux/mail/newsblog/feed-subscriptions.css7
-rw-r--r--comm/mail/themes/linux/mail/panelUI.css21
-rw-r--r--comm/mail/themes/linux/mail/popupPanel.css20
-rw-r--r--comm/mail/themes/linux/mail/preferences/alwaysAsk.pngbin0 -> 575 bytes
-rw-r--r--comm/mail/themes/linux/mail/preferences/applications.css25
-rw-r--r--comm/mail/themes/linux/mail/preferences/preferences.css23
-rw-r--r--comm/mail/themes/linux/mail/preferences/saveFile.pngbin0 -> 791 bytes
-rw-r--r--comm/mail/themes/linux/mail/primaryToolbar.css15
-rw-r--r--comm/mail/themes/linux/mail/searchBox.css24
-rw-r--r--comm/mail/themes/linux/mail/searchDialog.css41
-rw-r--r--comm/mail/themes/linux/mail/spacesToolbar.css11
-rw-r--r--comm/mail/themes/linux/mail/tabmail.css91
-rw-r--r--comm/mail/themes/linux/mail/themeableDialog.css80
-rw-r--r--comm/mail/themes/linux/mail/variables.css18
-rw-r--r--comm/mail/themes/linux/moz.build6
-rw-r--r--comm/mail/themes/moz.build25
-rw-r--r--comm/mail/themes/osx/editor/EditorDialog.css18
-rw-r--r--comm/mail/themes/osx/editor/img-align-bottom.gifbin0 -> 192 bytes
-rw-r--r--comm/mail/themes/osx/editor/img-align-left.gifbin0 -> 173 bytes
-rw-r--r--comm/mail/themes/osx/editor/img-align-middle.gifbin0 -> 184 bytes
-rw-r--r--comm/mail/themes/osx/editor/img-align-right.gifbin0 -> 169 bytes
-rw-r--r--comm/mail/themes/osx/editor/img-align-top.gifbin0 -> 186 bytes
-rw-r--r--comm/mail/themes/osx/jar.mn84
-rw-r--r--comm/mail/themes/osx/mail/accountCentral.css14
-rw-r--r--comm/mail/themes/osx/mail/accountManage.css13
-rw-r--r--comm/mail/themes/osx/mail/activity/activity.css5
-rw-r--r--comm/mail/themes/osx/mail/activity/addItemIcon.pngbin0 -> 717 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/compactMailIcon.pngbin0 -> 1700 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/copyMailIcon.pngbin0 -> 1199 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/defaultEventIcon.pngbin0 -> 1203 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/defaultProcessIcon.pngbin0 -> 1872 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/deleteMailIcon.pngbin0 -> 1895 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/error.pngbin0 -> 1235 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/indexMailIcon.pngbin0 -> 1360 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/moveMailIcon.pngbin0 -> 1047 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/removeItemIcon.pngbin0 -> 440 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/sendMailIcon.pngbin0 -> 1752 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/syncMailIcon.pngbin0 -> 2045 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/undoIcon.pngbin0 -> 1235 bytes
-rw-r--r--comm/mail/themes/osx/mail/activity/warning.pngbin0 -> 1446 bytes
-rw-r--r--comm/mail/themes/osx/mail/addrbook/abContactsPanel.css22
-rw-r--r--comm/mail/themes/osx/mail/addrbook/cardDialog.css13
-rw-r--r--comm/mail/themes/osx/mail/attachmentList.css7
-rw-r--r--comm/mail/themes/osx/mail/chat.css117
-rw-r--r--comm/mail/themes/osx/mail/common.css76
-rw-r--r--comm/mail/themes/osx/mail/compacttheme.css9
-rw-r--r--comm/mail/themes/osx/mail/compose/messengercompose.css217
-rw-r--r--comm/mail/themes/osx/mail/contextMenu.css54
-rw-r--r--comm/mail/themes/osx/mail/customizeToolbar.css25
-rw-r--r--comm/mail/themes/osx/mail/downloads/aboutDownloads.css9
-rw-r--r--comm/mail/themes/osx/mail/filterDialog.css58
-rw-r--r--comm/mail/themes/osx/mail/folderMenus.css21
-rw-r--r--comm/mail/themes/osx/mail/folderPane.css19
-rw-r--r--comm/mail/themes/osx/mail/glodaFacetView.css5
-rw-r--r--comm/mail/themes/osx/mail/icons/arrow/arrow-down-dim.pngbin0 -> 668 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/arrow/arrow-down.pngbin0 -> 394 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/arrow/arrow-left.pngbin0 -> 413 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/arrow/arrow-right-dim.pngbin0 -> 814 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/arrow/arrow-right.pngbin0 -> 391 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/arrow/arrow-up.pngbin0 -> 209 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/connecting.pngbin0 -> 8540 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/error.pngbin0 -> 666 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/identity.pngbin0 -> 7822 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/identity@2x.pngbin0 -> 12188 bytes
-rw-r--r--comm/mail/themes/osx/mail/icons/multicolor.pngbin0 -> 160 bytes
-rw-r--r--comm/mail/themes/osx/mail/imAccounts.css13
-rw-r--r--comm/mail/themes/osx/mail/input-fields.css18
-rw-r--r--comm/mail/themes/osx/mail/junkMail.css18
-rw-r--r--comm/mail/themes/osx/mail/mailWindow1.css76
-rw-r--r--comm/mail/themes/osx/mail/menulist.css29
-rw-r--r--comm/mail/themes/osx/mail/message-bar.css9
-rw-r--r--comm/mail/themes/osx/mail/messageBody.css28
-rw-r--r--comm/mail/themes/osx/mail/messageHeader.css38
-rw-r--r--comm/mail/themes/osx/mail/messageIcons.css70
-rw-r--r--comm/mail/themes/osx/mail/messageWindow.css53
-rw-r--r--comm/mail/themes/osx/mail/messenger.css460
-rw-r--r--comm/mail/themes/osx/mail/multimessageview.css11
-rw-r--r--comm/mail/themes/osx/mail/newsblog/feed-subscriptions.css11
-rw-r--r--comm/mail/themes/osx/mail/panelUI.css58
-rw-r--r--comm/mail/themes/osx/mail/popupPanel.css29
-rw-r--r--comm/mail/themes/osx/mail/preferences/alwaysAsk.pngbin0 -> 530 bytes
-rw-r--r--comm/mail/themes/osx/mail/preferences/application.pngbin0 -> 795 bytes
-rw-r--r--comm/mail/themes/osx/mail/preferences/applications.css32
-rw-r--r--comm/mail/themes/osx/mail/preferences/preferences.css33
-rw-r--r--comm/mail/themes/osx/mail/preferences/saveFile.pngbin0 -> 570 bytes
-rw-r--r--comm/mail/themes/osx/mail/primaryToolbar.css44
-rw-r--r--comm/mail/themes/osx/mail/searchBox.css44
-rw-r--r--comm/mail/themes/osx/mail/searchDialog.css56
-rw-r--r--comm/mail/themes/osx/mail/spacesToolbar.css9
-rw-r--r--comm/mail/themes/osx/mail/tabmail.css147
-rw-r--r--comm/mail/themes/osx/mail/themeableDialog.css91
-rw-r--r--comm/mail/themes/osx/mail/toolbar.css47
-rw-r--r--comm/mail/themes/osx/mail/variables.css26
-rw-r--r--comm/mail/themes/osx/moz.build6
-rw-r--r--comm/mail/themes/shared/jar.inc.mn731
-rw-r--r--comm/mail/themes/shared/mail/EdInsertChars.css27
-rw-r--r--comm/mail/themes/shared/mail/EditorDialog.css347
-rw-r--r--comm/mail/themes/shared/mail/abContactsPanel.css93
-rw-r--r--comm/mail/themes/shared/mail/abFormFields.css89
-rw-r--r--comm/mail/themes/shared/mail/abPrint.css88
-rw-r--r--comm/mail/themes/shared/mail/abResultsPane.css22
-rw-r--r--comm/mail/themes/shared/mail/abSearchDialog.css18
-rw-r--r--comm/mail/themes/shared/mail/about3Pane.css631
-rw-r--r--comm/mail/themes/shared/mail/aboutAddonsExtra.css174
-rw-r--r--comm/mail/themes/shared/mail/aboutAddressBook.css1023
-rw-r--r--comm/mail/themes/shared/mail/aboutDownloads.css131
-rw-r--r--comm/mail/themes/shared/mail/aboutImport.css431
-rw-r--r--comm/mail/themes/shared/mail/aboutPolicies.css159
-rw-r--r--comm/mail/themes/shared/mail/aboutSupport.css67
-rw-r--r--comm/mail/themes/shared/mail/accountCentral.css527
-rw-r--r--comm/mail/themes/shared/mail/accountHub.css349
-rw-r--r--comm/mail/themes/shared/mail/accountHubForms.css114
-rw-r--r--comm/mail/themes/shared/mail/accountManage.css606
-rw-r--r--comm/mail/themes/shared/mail/accountManager.css305
-rw-r--r--comm/mail/themes/shared/mail/accountSetup.css1021
-rw-r--r--comm/mail/themes/shared/mail/accountWizard.css27
-rw-r--r--comm/mail/themes/shared/mail/activity/activity.css117
-rw-r--r--comm/mail/themes/shared/mail/attachmentList.css168
-rw-r--r--comm/mail/themes/shared/mail/autocomplete.css23
-rw-r--r--comm/mail/themes/shared/mail/avatars.css71
-rw-r--r--comm/mail/themes/shared/mail/browserRequest.css14
-rw-r--r--comm/mail/themes/shared/mail/cardDAV.css94
-rw-r--r--comm/mail/themes/shared/mail/cardDialog.css160
-rw-r--r--comm/mail/themes/shared/mail/chat.css1006
-rw-r--r--comm/mail/themes/shared/mail/cloudfileSelectDialog.css20
-rw-r--r--comm/mail/themes/shared/mail/colors.css130
-rw-r--r--comm/mail/themes/shared/mail/common.css131
-rw-r--r--comm/mail/themes/shared/mail/compacttheme.css39
-rw-r--r--comm/mail/themes/shared/mail/composerOverlay.css16
-rw-r--r--comm/mail/themes/shared/mail/contextMenu.css210
-rw-r--r--comm/mail/themes/shared/mail/converterDialog.css57
-rw-r--r--comm/mail/themes/shared/mail/customizeToolbar.css110
-rw-r--r--comm/mail/themes/shared/mail/editorContent.css108
-rw-r--r--comm/mail/themes/shared/mail/extensionPopup.css14
-rw-r--r--comm/mail/themes/shared/mail/feedSubscribe.css43
-rw-r--r--comm/mail/themes/shared/mail/fieldMapImport.css23
-rw-r--r--comm/mail/themes/shared/mail/filterDialog.css139
-rw-r--r--comm/mail/themes/shared/mail/filterEditor.css27
-rw-r--r--comm/mail/themes/shared/mail/folderColors.css37
-rw-r--r--comm/mail/themes/shared/mail/folderMenus.css101
-rw-r--r--comm/mail/themes/shared/mail/folderPane.css312
-rw-r--r--comm/mail/themes/shared/mail/folderProps.css41
-rw-r--r--comm/mail/themes/shared/mail/glodaFacetView.css723
-rw-r--r--comm/mail/themes/shared/mail/glodacomplete.css51
-rw-r--r--comm/mail/themes/shared/mail/grid-layout.css84
-rw-r--r--comm/mail/themes/shared/mail/icons.css304
-rw-r--r--comm/mail/themes/shared/mail/icons/ablist.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/aboutdebugging-logo.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/account-sync.svg102
-rw-r--r--comm/mail/themes/shared/mail/icons/accounts.svg9
-rw-r--r--comm/mail/themes/shared/mail/icons/add-circle-fill.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/addcontact.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/addlist.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/addon-install-blocked.svg38
-rw-r--r--comm/mail/themes/shared/mail/icons/addon-install-confirm.svg19
-rw-r--r--comm/mail/themes/shared/mail/icons/addon-install-downloading.svg38
-rw-r--r--comm/mail/themes/shared/mail/icons/addon-install-error.svg38
-rw-r--r--comm/mail/themes/shared/mail/icons/addon-install-installed.svg38
-rw-r--r--comm/mail/themes/shared/mail/icons/addon-install-warning.svg38
-rw-r--r--comm/mail/themes/shared/mail/icons/address.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/anchor.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/app-update-badge.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/app-update.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/appbutton-badged.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/appbutton.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/archive.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/arrow-dropdown.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/attach.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/attachment-col.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/attachment-deleted-large.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/attachment-deleted.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/bold.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/browser-back.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/browser-forward.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/bullet-list.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/cancel.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/center-align.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/cert-error.svg31
-rw-r--r--comm/mail/themes/shared/mail/icons/chat.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/checkbox.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/collapse.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/compact.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/connection-insecure.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/connection-mixed.svg9
-rw-r--r--comm/mail/themes/shared/mail/icons/connection-secure.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/contact-generic.svg15
-rw-r--r--comm/mail/themes/shared/mail/icons/contact.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/conversation.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/correspondents-rtl.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/correspondents.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/cut.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/decrease.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/delete-col.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/delete.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/developer.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/download.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/empty-search-results.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/encryption-key.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/exclude.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/feeds-folder.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/feeds.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/file-item.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/file.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/filter.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/fingerprint.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/flag-col.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/flagged.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/folder-local.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/folder-new-indicator.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/folder.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/forget.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/format-dropmarker.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/forward-redirect.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/forward.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/founder.pngbin0 -> 658 bytes
-rw-r--r--comm/mail/themes/shared/mail/icons/get-all.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/getmsg.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/globe-secure.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/globe.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/goback.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/goforward.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/half-operator.pngbin0 -> 667 bytes
-rw-r--r--comm/mail/themes/shared/mail/icons/help.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/hidden.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/highlights.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/hline.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/hourglass.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/image.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/import.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/inbox.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/increase.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/indent.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/info.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/information.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/italics.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/join.svg9
-rw-r--r--comm/mail/themes/shared/mail/icons/junk-col.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/junk.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/justify.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/left-align.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/loading.svg98
-rw-r--r--comm/mail/themes/shared/mail/icons/login.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/mark.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/menu.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/message-encrypted-notok.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/message-encrypted-ok.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/message-secure.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/message-signed-mismatch.svg9
-rw-r--r--comm/mail/themes/shared/mail/icons/message-signed-ok.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/message-signed-unknown.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/message-signed-unverified.svg9
-rw-r--r--comm/mail/themes/shared/mail/icons/message-signed-verified.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/message.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/more.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/move-first.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/move-last.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/move-left.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/move-right.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/move-together.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/navigation.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new-addressbook.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new-calendar.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new-key.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new-window.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/addItemIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/compactMailIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/copyMailIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/defaultEventIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/defaultProcessIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/deleteMailIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/error.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/indexMailIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/info.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/moveMailIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/question.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/removeItemIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/sendMailIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/syncMailIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/undoIcon.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/activity/warning.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/new/address-book-indicator.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/attachment-sm.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/bell-disabled.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/bell-ring.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/bell.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/calendar-empty.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/chat-lock-finished.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/chat-lock-insecure.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/chat-lock-private.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/chat-lock-unverified.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/chat-lock.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/circle-sm.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/column-menu.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/account-settings.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/add-circle.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/add.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/address-book.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/app-menu-badged.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/app-menu.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/archive.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/attachment.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/calendar-invite.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/calendar-today.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/calendar.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/chat.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/check.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/checkbox.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/clock.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/close.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/cloud-download.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/collapse.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/compress.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/contact.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/conversation.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/copy.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/cut.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/density-compact.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/density-default.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/density-relaxed.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/display-options.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/download.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/draft.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/error-circle.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/event-status.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/export.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/extension.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/eye.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/features.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/file.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/filter.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/fingerprint.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/flexible-space.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/folder-filter.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/folder-rss.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/folder-save.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/folder.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/font.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/forward-col.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/forward-redirect-col.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/forward.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/get-mail.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/globe-secure.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/globe.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/handshake.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/heart.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/hidden.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/id.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/import.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/inbox.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/info.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/kebab.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/key.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/layout.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/link.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/lock-disabled.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/lock.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/low-priority.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/mail-secure.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/mail.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/more.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/nav-back.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/nav-down-unread.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/nav-down.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/nav-forward.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/nav-left.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/nav-right.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/nav-up-unread.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/nav-up.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/new-address-book.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/new-chat.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/new-contact.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/new-event.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/new-key.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/new-mail.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/new-task.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/new-user-list.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/newsletter.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/offline.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/online.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/outbox.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/overflow.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/paint-brush.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/paste.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/pencil.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/photo-ban.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/pin.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/print.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/priority.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/question.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/quit.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/quote.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/receipt.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/redirect-col.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/redirect.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/remove.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/reply-all.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/reply-col.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/reply-forward-col.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/reply-forward-redirect-col.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/reply-list.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/reply-redirect-col.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/reply.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/restore.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/ribbon.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/rss.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/search.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/sent.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/settings.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/shield.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/shortcut.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/sort.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/spaces-menu.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/spam.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/spelling.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/star.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/subthread-ignored.svg9
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/subtract-circle.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/sync.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/tag.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/tasks.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/template.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/tentative.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/thread-ignored.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/thread.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/tools.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/trash.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/unread.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/user-list-alt.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/user-list.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/user.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/compact/warning.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/event-continue.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/event-end.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/event-start.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/loading.svg98
-rw-r--r--comm/mail/themes/shared/mail/icons/new/mail-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/nav-down-sm.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/nav-left-sm.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/nav-right-sm.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/nav-today.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/nav-up-sm.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/add-circle.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/add.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/address-book.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/archive.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/calendar-invite.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/calendar.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/chat.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/cloud-download.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/collapse.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/download.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/draft.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/folder-filter.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/folder-rss.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/folder.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/globe-secure.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/globe.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/inbox.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/link.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/mail-secure.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/mail.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/more.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/newsletter.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/outbox.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/overflow.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/rss.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/sent.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/settings.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/spam.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/subtract-circle.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/tasks.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/template.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/normal/trash.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/notify.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/recurrence-exception.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/recurrence.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/spam-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/star-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/status-away-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/status-away.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/status-idle-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/status-idle.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/status-offline-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/status-offline.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/status-online-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/status-online.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/subtract-circle-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/supernova-logo.webpbin0 -> 1748 bytes
-rw-r--r--comm/mail/themes/shared/mail/icons/new/tag-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/thread-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/add-circle.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/address-book.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/calendar.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/chat.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/collapse.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/dictionary.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/export.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/extension-update-available.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/extension-update-recent.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/extension.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/features.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/globe.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/import.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/language.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/lock.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/mail.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/overflow.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/paint-brush.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/pencil.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/settings.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/subtract-circle.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/sync.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/new/touch/tasks.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/trash-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/unread-dot.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/new/unread-sm.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/newmail.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/newmsg.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/newsgroup.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/nextmsg.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/nextunread.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/notification-fill-12.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/notloading.pngbin0 -> 681 bytes
-rw-r--r--comm/mail/themes/shared/mail/icons/notloading@2x.pngbin0 -> 1924 bytes
-rw-r--r--comm/mail/themes/shared/mail/icons/number-list.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/offline.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/online.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/operator.pngbin0 -> 727 bytes
-rw-r--r--comm/mail/themes/shared/mail/icons/outbox.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/outdent.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/overflow-indicator.pngbin0 -> 794 bytes
-rw-r--r--comm/mail/themes/shared/mail/icons/overflow.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/paragraph.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/paste.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/phishing.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/pill-indicator.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/pluginBlocked.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/popular.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/previousmsg.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/previousunread.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/print.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/privacy-security.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/quit.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/quote.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/read.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/readcol.svg9
-rw-r--r--comm/mail/themes/shared/mail/icons/reader-mode.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/redirect.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/remote-blocked.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/remove-text-styling.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/reply-forward-redirect.svg8
-rw-r--r--comm/mail/themes/shared/mail/icons/reply-forward.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/reply-redirect.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/reply.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/replyall.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/replylist.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/restore.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/return-receipt.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/right-align.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/save-as.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/save.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/search-folder.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/search-not-found.svg11
-rw-r--r--comm/mail/themes/shared/mail/icons/search-spinner.svg9
-rw-r--r--comm/mail/themes/shared/mail/icons/send.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/sent.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/shield.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/sidebar-left.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/size.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/smiley.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/sort.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/spaces.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/spelling.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/spring.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/star.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/starred.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/status-away.svg14
-rw-r--r--comm/mail/themes/shared/mail/icons/status-idle.svg14
-rw-r--r--comm/mail/themes/shared/mail/icons/status-offline.svg14
-rw-r--r--comm/mail/themes/shared/mail/icons/status-online.svg14
-rw-r--r--comm/mail/themes/shared/mail/icons/sticky.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/stop.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/subthread-ignored.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/subtract-circle-fill.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/tab-drag-indicator.svg4
-rw-r--r--comm/mail/themes/shared/mail/icons/table.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/tag.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/template.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/thread-col.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/thread-ignored.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/thread.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/timeline.svg12
-rw-r--r--comm/mail/themes/shared/mail/icons/toolbarbutton-arrow.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/underline.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/userIcon.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/visible.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/voice.pngbin0 -> 733 bytes
-rw-r--r--comm/mail/themes/shared/mail/icons/waiting.svg6
-rw-r--r--comm/mail/themes/shared/mail/icons/warning-12.svg7
-rw-r--r--comm/mail/themes/shared/mail/icons/zoomout.svg6
-rw-r--r--comm/mail/themes/shared/mail/illustrations/accounts.svg62
-rw-r--r--comm/mail/themes/shared/mail/illustrations/connection-error.svg51
-rw-r--r--comm/mail/themes/shared/mail/illustrations/form.svg56
-rw-r--r--comm/mail/themes/shared/mail/illustrations/octopus-setup.svg75
-rw-r--r--comm/mail/themes/shared/mail/illustrations/sloth.svg88
-rw-r--r--comm/mail/themes/shared/mail/illustrations/sync-devices.svg49
-rw-r--r--comm/mail/themes/shared/mail/imAccounts.css203
-rw-r--r--comm/mail/themes/shared/mail/imMenulist.css13
-rw-r--r--comm/mail/themes/shared/mail/imRichlistbox.css15
-rw-r--r--comm/mail/themes/shared/mail/images/account-watermark-light.pngbin0 -> 21457 bytes
-rw-r--r--comm/mail/themes/shared/mail/images/account-watermark.pngbin0 -> 39280 bytes
-rw-r--r--comm/mail/themes/shared/mail/images/pendingpaint.pngbin0 -> 12955 bytes
-rw-r--r--comm/mail/themes/shared/mail/inContentDialog.css335
-rw-r--r--comm/mail/themes/shared/mail/input-fields.css51
-rw-r--r--comm/mail/themes/shared/mail/joinchat.css25
-rw-r--r--comm/mail/themes/shared/mail/layout.css81
-rw-r--r--comm/mail/themes/shared/mail/mailWindow1.css200
-rw-r--r--comm/mail/themes/shared/mail/menulist.css24
-rw-r--r--comm/mail/themes/shared/mail/message-bar.css432
-rw-r--r--comm/mail/themes/shared/mail/messageBody.css201
-rw-r--r--comm/mail/themes/shared/mail/messageHeader.css1002
-rw-r--r--comm/mail/themes/shared/mail/messageIcons.css360
-rw-r--r--comm/mail/themes/shared/mail/messageQuotes.css67
-rw-r--r--comm/mail/themes/shared/mail/messenger.css1506
-rw-r--r--comm/mail/themes/shared/mail/messengercompose.css1485
-rw-r--r--comm/mail/themes/shared/mail/migrationProgress.css111
-rw-r--r--comm/mail/themes/shared/mail/msgSelectOffline.css56
-rw-r--r--comm/mail/themes/shared/mail/multimessageview.css211
-rw-r--r--comm/mail/themes/shared/mail/newmailalert.css140
-rw-r--r--comm/mail/themes/shared/mail/overrides/add.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/arrow-down-12.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/arrow-down.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/arrow-left-12.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/arrow-left.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/arrow-right-12.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/arrow-right.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/arrow-up-12.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/arrow-up.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/blocked.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/category-available.svg7
-rw-r--r--comm/mail/themes/shared/mail/overrides/category-discover.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/category-extensions.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/category-recent.svg7
-rw-r--r--comm/mail/themes/shared/mail/overrides/category-themes.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/check.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/chevron.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/close.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/defaultFavicon.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/delete.svg7
-rw-r--r--comm/mail/themes/shared/mail/overrides/error.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/extension.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/find-next-arrow.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/find-previous-arrow.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/folder.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/help.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/info-filled.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/info.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/more.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/open-in-new.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/plugin-blocked.svg7
-rw-r--r--comm/mail/themes/shared/mail/overrides/plugin.svg7
-rw-r--r--comm/mail/themes/shared/mail/overrides/print.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/search-glass.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/security-broken.svg7
-rw-r--r--comm/mail/themes/shared/mail/overrides/security-warning.svg9
-rw-r--r--comm/mail/themes/shared/mail/overrides/security.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/settings.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/update-icon.svg6
-rw-r--r--comm/mail/themes/shared/mail/overrides/warning.svg6
-rw-r--r--comm/mail/themes/shared/mail/panelUI.css1199
-rw-r--r--comm/mail/themes/shared/mail/popupPanel.css213
-rw-r--r--comm/mail/themes/shared/mail/preferences/applications.css234
-rw-r--r--comm/mail/themes/shared/mail/preferences/calendar.svg6
-rw-r--r--comm/mail/themes/shared/mail/preferences/chat.svg6
-rw-r--r--comm/mail/themes/shared/mail/preferences/dialog.css174
-rw-r--r--comm/mail/themes/shared/mail/preferences/general.svg6
-rw-r--r--comm/mail/themes/shared/mail/preferences/passwordmgr.css44
-rw-r--r--comm/mail/themes/shared/mail/preferences/preferences.css1098
-rw-r--r--comm/mail/themes/shared/mail/preferences/privacy-security.svg6
-rw-r--r--comm/mail/themes/shared/mail/preferences/subdialog.css78
-rw-r--r--comm/mail/themes/shared/mail/primaryToolbar.css238
-rw-r--r--comm/mail/themes/shared/mail/profileDowngrade.css18
-rw-r--r--comm/mail/themes/shared/mail/quickFilterBar.css197
-rw-r--r--comm/mail/themes/shared/mail/sanitizeDialog.css49
-rw-r--r--comm/mail/themes/shared/mail/search-bar.css89
-rw-r--r--comm/mail/themes/shared/mail/searchBox.css129
-rw-r--r--comm/mail/themes/shared/mail/searchDialog.css126
-rw-r--r--comm/mail/themes/shared/mail/spacesToolbar.css371
-rw-r--r--comm/mail/themes/shared/mail/splitter.css162
-rw-r--r--comm/mail/themes/shared/mail/subscribe.css80
-rw-r--r--comm/mail/themes/shared/mail/tabmail.css484
-rw-r--r--comm/mail/themes/shared/mail/tagColors.css13
-rw-r--r--comm/mail/themes/shared/mail/themeableDialog.css608
-rw-r--r--comm/mail/themes/shared/mail/threadPane.css822
-rw-r--r--comm/mail/themes/shared/mail/tree-listbox.css528
-rw-r--r--comm/mail/themes/shared/mail/unifiedToolbar.css242
-rw-r--r--comm/mail/themes/shared/mail/unifiedToolbarCustomizableItems.css197
-rw-r--r--comm/mail/themes/shared/mail/unifiedToolbarCustomizationPane.css196
-rw-r--r--comm/mail/themes/shared/mail/unifiedToolbarShared.css408
-rw-r--r--comm/mail/themes/shared/mail/unifiedToolbarTab.css62
-rw-r--r--comm/mail/themes/shared/mail/variables.css291
-rw-r--r--comm/mail/themes/shared/mail/vcard.css476
-rw-r--r--comm/mail/themes/shared/mail/verifychat.css17
-rw-r--r--comm/mail/themes/shared/mail/widgets.css370
-rw-r--r--comm/mail/themes/shared/mail/wizard.css52
-rw-r--r--comm/mail/themes/shared/openpgp/backupKeyPassword.css37
-rw-r--r--comm/mail/themes/shared/openpgp/changeExpiryDlg.css19
-rw-r--r--comm/mail/themes/shared/openpgp/confirmPubkeyImport.css78
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncActiveConflict.pngbin0 -> 1017 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncActiveMinus.pngbin0 -> 715 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncActiveNone.pngbin0 -> 708 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncActivePlus.pngbin0 -> 729 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncForceNo.pngbin0 -> 675 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncForceYes.pngbin0 -> 757 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncInactive.pngbin0 -> 702 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncInactiveConflict.pngbin0 -> 819 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncInactiveMinus.pngbin0 -> 615 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncInactiveNone.pngbin0 -> 604 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncInactivePlus.pngbin0 -> 640 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigEncNotOk.pngbin0 -> 730 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignActiveConflict.pngbin0 -> 797 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignActiveMinus.pngbin0 -> 405 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignActiveNone.pngbin0 -> 379 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignActivePlus.pngbin0 -> 412 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignForceNo.pngbin0 -> 466 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignForceYes.pngbin0 -> 476 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignInactiveConflict.pngbin0 -> 646 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignInactiveMinus.pngbin0 -> 383 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignInactiveNone.pngbin0 -> 369 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignInactivePlus.pngbin0 -> 392 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignNotOk.pngbin0 -> 435 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigSignUnkown.pngbin0 -> 505 bytes
-rw-r--r--comm/mail/themes/shared/openpgp/enigmail-html.css101
-rw-r--r--comm/mail/themes/shared/openpgp/enigmail.css80
-rw-r--r--comm/mail/themes/shared/openpgp/inlineNotification.css59
-rw-r--r--comm/mail/themes/shared/openpgp/keyDetails.css72
-rw-r--r--comm/mail/themes/shared/openpgp/keyWizard.css152
-rw-r--r--comm/mail/themes/shared/openpgp/openPgpComposeStatus.css25
-rw-r--r--comm/mail/themes/shared/smime/msgCompSecurityInfo.css25
-rw-r--r--comm/mail/themes/windows/editor/EditorDialog.css12
-rw-r--r--comm/mail/themes/windows/editor/img-align-bottom.gifbin0 -> 192 bytes
-rw-r--r--comm/mail/themes/windows/editor/img-align-left.gifbin0 -> 173 bytes
-rw-r--r--comm/mail/themes/windows/editor/img-align-middle.gifbin0 -> 184 bytes
-rw-r--r--comm/mail/themes/windows/editor/img-align-right.gifbin0 -> 169 bytes
-rw-r--r--comm/mail/themes/windows/editor/img-align-top.gifbin0 -> 186 bytes
-rw-r--r--comm/mail/themes/windows/jar.mn94
-rw-r--r--comm/mail/themes/windows/mail/accountCentral.css29
-rw-r--r--comm/mail/themes/windows/mail/accountManage.css5
-rw-r--r--comm/mail/themes/windows/mail/activity/activity.css17
-rw-r--r--comm/mail/themes/windows/mail/activity/addItemIcon.pngbin0 -> 527 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/compactMailIcon.pngbin0 -> 1670 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/copyMailIcon.pngbin0 -> 1144 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/defaultEventIcon.pngbin0 -> 1203 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/defaultProcessIcon.pngbin0 -> 1286 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/deleteMailIcon.pngbin0 -> 1338 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/indexMailIcon.pngbin0 -> 1495 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/moveMailIcon.pngbin0 -> 866 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/removeItemIcon.pngbin0 -> 327 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/sendMailIcon.pngbin0 -> 1527 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/syncMailIcon.pngbin0 -> 2267 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/undoIcon.pngbin0 -> 1344 bytes
-rw-r--r--comm/mail/themes/windows/mail/activity/warning.pngbin0 -> 1536 bytes
-rw-r--r--comm/mail/themes/windows/mail/addrbook/abContactsPanel.css35
-rw-r--r--comm/mail/themes/windows/mail/addrbook/cardDialog.css13
-rw-r--r--comm/mail/themes/windows/mail/attachmentList.css22
-rw-r--r--comm/mail/themes/windows/mail/chat.css146
-rw-r--r--comm/mail/themes/windows/mail/common.css35
-rw-r--r--comm/mail/themes/windows/mail/compacttheme.css138
-rw-r--r--comm/mail/themes/windows/mail/compose/messengercompose.css210
-rw-r--r--comm/mail/themes/windows/mail/contextMenu.css74
-rw-r--r--comm/mail/themes/windows/mail/customizeToolbar.css16
-rw-r--r--comm/mail/themes/windows/mail/downloads/aboutDownloads.css47
-rw-r--r--comm/mail/themes/windows/mail/filterDialog.css46
-rw-r--r--comm/mail/themes/windows/mail/folderMenus.css17
-rw-r--r--comm/mail/themes/windows/mail/folderPane.css52
-rw-r--r--comm/mail/themes/windows/mail/glodaFacetView.css5
-rw-r--r--comm/mail/themes/windows/mail/icons/arrow/arrow-down-dim.pngbin0 -> 668 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/arrow/arrow-down.pngbin0 -> 394 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/arrow/arrow-left.pngbin0 -> 413 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/arrow/arrow-right-dim.pngbin0 -> 814 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/arrow/arrow-right.pngbin0 -> 391 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/arrow/arrow-up.pngbin0 -> 209 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/connecting.pngbin0 -> 8540 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/error.pngbin0 -> 666 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/identity.pngbin0 -> 7822 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/jumplist.pngbin0 -> 601 bytes
-rw-r--r--comm/mail/themes/windows/mail/icons/multicolor.pngbin0 -> 160 bytes
-rw-r--r--comm/mail/themes/windows/mail/imAccounts.css13
-rw-r--r--comm/mail/themes/windows/mail/input-fields.css20
-rw-r--r--comm/mail/themes/windows/mail/junkMail.css15
-rw-r--r--comm/mail/themes/windows/mail/mailWindow1.css461
-rw-r--r--comm/mail/themes/windows/mail/menulist.css57
-rw-r--r--comm/mail/themes/windows/mail/message-bar.css15
-rw-r--r--comm/mail/themes/windows/mail/messageBody.css10
-rw-r--r--comm/mail/themes/windows/mail/messageHeader.css19
-rw-r--r--comm/mail/themes/windows/mail/messageIcons.css49
-rw-r--r--comm/mail/themes/windows/mail/messageWindow.css37
-rw-r--r--comm/mail/themes/windows/mail/messenger.css573
-rw-r--r--comm/mail/themes/windows/mail/multimessageview.css25
-rw-r--r--comm/mail/themes/windows/mail/newsblog/feed-subscriptions.css7
-rw-r--r--comm/mail/themes/windows/mail/panelUI.css79
-rw-r--r--comm/mail/themes/windows/mail/popupPanel.css22
-rw-r--r--comm/mail/themes/windows/mail/preferences/alwaysAsk.pngbin0 -> 446 bytes
-rw-r--r--comm/mail/themes/windows/mail/preferences/application.pngbin0 -> 441 bytes
-rw-r--r--comm/mail/themes/windows/mail/preferences/applications.css24
-rw-r--r--comm/mail/themes/windows/mail/preferences/preferences.css15
-rw-r--r--comm/mail/themes/windows/mail/preferences/saveFile.pngbin0 -> 791 bytes
-rw-r--r--comm/mail/themes/windows/mail/primaryToolbar.css244
-rw-r--r--comm/mail/themes/windows/mail/searchBox.css44
-rw-r--r--comm/mail/themes/windows/mail/searchDialog.css34
-rw-r--r--comm/mail/themes/windows/mail/spacesToolbar.css77
-rw-r--r--comm/mail/themes/windows/mail/tabmail.css177
-rw-r--r--comm/mail/themes/windows/mail/themeableDialog.css90
-rw-r--r--comm/mail/themes/windows/mail/variables.css91
-rw-r--r--comm/mail/themes/windows/mail/window-controls/close-highcontrast.svg6
-rw-r--r--comm/mail/themes/windows/mail/window-controls/close-themes.svg7
-rw-r--r--comm/mail/themes/windows/mail/window-controls/close.svg6
-rw-r--r--comm/mail/themes/windows/mail/window-controls/maximize-highcontrast.svg6
-rw-r--r--comm/mail/themes/windows/mail/window-controls/maximize-themes.svg7
-rw-r--r--comm/mail/themes/windows/mail/window-controls/maximize.svg6
-rw-r--r--comm/mail/themes/windows/mail/window-controls/minimize-highcontrast.svg6
-rw-r--r--comm/mail/themes/windows/mail/window-controls/minimize-themes.svg7
-rw-r--r--comm/mail/themes/windows/mail/window-controls/minimize.svg6
-rw-r--r--comm/mail/themes/windows/mail/window-controls/restore-highcontrast.svg7
-rw-r--r--comm/mail/themes/windows/mail/window-controls/restore-themes.svg8
-rw-r--r--comm/mail/themes/windows/mail/window-controls/restore.svg7
-rw-r--r--comm/mail/themes/windows/moz.build6
3470 files changed, 593627 insertions, 0 deletions
diff --git a/comm/mail/Makefile.in b/comm/mail/Makefile.in
new file mode 100644
index 0000000000..895b1db672
--- /dev/null
+++ b/comm/mail/Makefile.in
@@ -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/.
+
+include $(topsrcdir)/config/config.mk
+include $(topsrcdir)/config/rules.mk
+
+ifdef MAKENSISU
+# For Windows build the uninstaller during the application build since the
+# uninstaller is included with the application for mar file generation.
+libs::
+ $(MAKE) -C installer/windows uninstaller
+ifdef MOZ_MAINTENANCE_SERVICE
+ $(MAKE) -C installer/windows maintenanceservice_installer
+endif
+endif
+
+
+# As fallout from bug 1247162, the sourcestamp in application.ini and
+# platform.ini are the same, which isn't a problem for Firefox, but
+# it's not right for anything else. So we correct platform.ini here.
+libs:: $(DIST)/bin/platform.ini
+ $(PYTHON3) $(commtopsrcdir)/build/source_repos.py gen_platformini \
+ $(DIST)/bin/platform.ini
+
+libs::
+ @echo Generating $(MOZ_BUILT_FROM_FILE) for Treeherder.
+ $(PYTHON3) $(commtopsrcdir)/build/source_repos.py gen_treeherder_build_links > $(DIST)/$(MOZ_BUILT_FROM_FILE)
diff --git a/comm/mail/actors/ChatActionChild.sys.mjs b/comm/mail/actors/ChatActionChild.sys.mjs
new file mode 100644
index 0000000000..92af66d3df
--- /dev/null
+++ b/comm/mail/actors/ChatActionChild.sys.mjs
@@ -0,0 +1,55 @@
+/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 sw=2 sts=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/. */
+
+export class ChatActionChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ this.messageActions = null;
+ }
+
+ receiveMessage(message) {
+ if (!this.messageActions) {
+ return;
+ }
+ if (message.name === "ChatAction:Run") {
+ this.messageActions[message.data.index].run();
+ } else if (message.name === "ChatAction:Hide") {
+ this.messageActions = null;
+ }
+ }
+
+ async handleEvent(event) {
+ let node = event.composedTarget;
+
+ // Set the node to containing <video>/<audio>/<embed>/<object> if the node
+ // is in the videocontrols UA Widget.
+ if (node.containingShadowRoot?.isUAWidget()) {
+ const host = node.containingShadowRoot.host;
+ if (
+ this.contentWindow.HTMLMediaElement.isInstance(host) ||
+ this.contentWindow.HTMLEmbedElement.isInstance(host) ||
+ this.contentWindow.HTMLObjectElement.isInstance(host)
+ ) {
+ node = host;
+ }
+ }
+
+ while (node) {
+ if (node._originalMsg) {
+ this.messageActions = node._originalMsg.getActions();
+ break;
+ }
+ node = node.parentNode;
+ }
+ if (!this.messageActions) {
+ return;
+ }
+ this.sendAsyncMessage("ChatAction:Actions", {
+ actions: this.messageActions.map(action => action.label),
+ });
+ }
+}
diff --git a/comm/mail/actors/ChatActionParent.sys.mjs b/comm/mail/actors/ChatActionParent.sys.mjs
new file mode 100644
index 0000000000..15b67419f5
--- /dev/null
+++ b/comm/mail/actors/ChatActionParent.sys.mjs
@@ -0,0 +1,32 @@
+/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 sw=2 sts=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/. */
+
+export class ChatActionParent extends JSWindowActorParent {
+ receiveMessage(message) {
+ if (message.name === "ChatAction:Actions") {
+ let browser = this.manager.rootFrameLoader.ownerElement;
+ if (browser.contentWindow?.gChatContextMenu) {
+ browser.contentWindow.gChatContextMenu.initActions(
+ message.data.actions
+ );
+ return;
+ }
+
+ // Otherwise, send them to the outer window.
+ let win = browser.ownerGlobal;
+ if (win.gChatContextMenu) {
+ win.gChatContextMenu.initActions(message.data.actions);
+ return;
+ }
+ this.actions = message.data.actions;
+ }
+ }
+
+ reportHide() {
+ this.sendAsyncMessage("ChatAction:Hide");
+ this.actions = null;
+ }
+}
diff --git a/comm/mail/actors/ContextMenuParent.sys.mjs b/comm/mail/actors/ContextMenuParent.sys.mjs
new file mode 100644
index 0000000000..5cf1d5dbe2
--- /dev/null
+++ b/comm/mail/actors/ContextMenuParent.sys.mjs
@@ -0,0 +1,45 @@
+/* vim: set ts=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/. */
+
+export class ContextMenuParent extends JSWindowActorParent {
+ receiveMessage(message) {
+ if (message.name != "contextmenu") {
+ return;
+ }
+
+ let browser = this.manager.rootFrameLoader.ownerElement;
+ let win = browser.ownerGlobal.top;
+
+ // Send events from a message display browser to about:3pane or
+ // about:message if possible.
+ let tabmail = win.document.getElementById("tabmail");
+ if (tabmail) {
+ let chromeBrowser = tabmail.currentTabInfo.chromeBrowser;
+ if (
+ chromeBrowser?.contentWindow.openContextMenu(message, browser, this)
+ ) {
+ return;
+ }
+ }
+ let messageBrowser = win.document.getElementById("messageBrowser");
+ if (messageBrowser?.contentWindow.openContextMenu(message, browser, this)) {
+ return;
+ }
+
+ // Otherwise, send them to the outer window.
+ if ("openContextMenu" in win) {
+ win.openContextMenu(message, browser, this);
+ }
+ }
+
+ hiding() {
+ try {
+ this.sendAsyncMessage("ContextMenu:Hiding", {});
+ } catch (e) {
+ // This will throw if the content goes away while the
+ // context menu is still open.
+ }
+ }
+}
diff --git a/comm/mail/actors/LinkClickHandlerChild.jsm b/comm/mail/actors/LinkClickHandlerChild.jsm
new file mode 100644
index 0000000000..8ad42a7a23
--- /dev/null
+++ b/comm/mail/actors/LinkClickHandlerChild.jsm
@@ -0,0 +1,178 @@
+/* vim: set ts=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 = [
+ "LinkClickHandlerChild",
+ "StrictLinkClickHandlerChild",
+];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "protocolSvc",
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ "nsIExternalProtocolService"
+);
+
+/**
+ * Extract the href from the link click event.
+ * We look for HTMLAnchorElement, HTMLAreaElement, HTMLLinkElement,
+ * HTMLInputElement.form.action, and nested anchor tags.
+ * If the clicked element was a HTMLInputElement or HTMLButtonElement
+ * we return the form action.
+ *
+ * @returns the url and the text for the link being clicked.
+ */
+function hRefForClickEvent(aEvent) {
+ let target = aEvent.target;
+
+ if (
+ HTMLImageElement.isInstance(target) &&
+ target.hasAttribute("overflowing")
+ ) {
+ // Click on zoomed image.
+ return [null, null];
+ }
+
+ let href = null;
+ if (
+ HTMLAnchorElement.isInstance(target) ||
+ HTMLAreaElement.isInstance(target) ||
+ HTMLLinkElement.isInstance(target)
+ ) {
+ if (target.hasAttribute("href") && !target.download) {
+ href = target.href;
+ }
+ } else {
+ // We may be nested inside of a link node.
+ let linkNode = aEvent.target;
+ while (linkNode && !HTMLAnchorElement.isInstance(linkNode)) {
+ linkNode = linkNode.parentNode;
+ }
+
+ if (linkNode && !linkNode.download) {
+ href = linkNode.href;
+ }
+ }
+ return href;
+}
+
+/**
+ * Listens for click events and, if the click would result in loading a page
+ * on a different base domain from the current page, cancels the click event,
+ * redirecting the URI to an external browser, effectively creating a
+ * single-site browser.
+ *
+ * This actor applies to browsers in the "single-site" message manager group.
+ */
+class LinkClickHandlerChild extends JSWindowActorChild {
+ handleEvent(event) {
+ // Don't handle events that:
+ // a) are in the parent process (handled by onclick),
+ // b) aren't trusted,
+ // c) have already been handled or
+ // d) aren't left-click.
+ if (
+ this.manager.isInProcess ||
+ !event.isTrusted ||
+ event.defaultPrevented ||
+ event.button
+ ) {
+ return;
+ }
+
+ let eventHRef = hRefForClickEvent(event);
+ if (!eventHRef) {
+ return;
+ }
+
+ let pageURI = Services.io.newURI(this.document.location.href);
+ let eventURI = Services.io.newURI(eventHRef);
+
+ try {
+ if (pageURI.host == eventURI.host) {
+ // Avoid using the eTLD service, and this also works for IP addresses.
+ return;
+ }
+
+ try {
+ if (
+ Services.eTLD.getBaseDomain(eventURI) ==
+ Services.eTLD.getBaseDomain(pageURI)
+ ) {
+ return;
+ }
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS) {
+ console.error(ex);
+ }
+ }
+ } catch (ex) {
+ // The page or link might be from a host-less URL scheme such as about,
+ // blob, or data. The host is never going to match, carry on.
+ }
+
+ if (
+ !lazy.protocolSvc.isExposedProtocol(eventURI.scheme) ||
+ eventURI.schemeIs("http") ||
+ eventURI.schemeIs("https")
+ ) {
+ event.preventDefault();
+ this.sendAsyncMessage("openLinkExternally", eventHRef);
+ }
+ }
+}
+
+/**
+ * Listens for click events and, if the click would result in loading a
+ * different page from the current page, cancels the click event, redirecting
+ * the URI to an external browser, effectively creating a single-page browser.
+ *
+ * This actor applies to browsers in the "single-page" message manager group.
+ */
+class StrictLinkClickHandlerChild extends JSWindowActorChild {
+ handleEvent(event) {
+ // Don't handle events that:
+ // a) are in the parent process (handled by onclick),
+ // b) aren't trusted,
+ // c) have already been handled or
+ // d) aren't left-click.
+ if (
+ this.manager.isInProcess ||
+ !event.isTrusted ||
+ event.defaultPrevented ||
+ event.button
+ ) {
+ return;
+ }
+
+ let eventHRef = hRefForClickEvent(event);
+ if (!eventHRef) {
+ return;
+ }
+
+ let pageURI = Services.io.newURI(this.document.location.href);
+ let eventURI = Services.io.newURI(eventHRef);
+ if (eventURI.specIgnoringRef == pageURI.specIgnoringRef) {
+ return;
+ }
+
+ if (
+ !lazy.protocolSvc.isExposedProtocol(eventURI.scheme) ||
+ eventURI.schemeIs("http") ||
+ eventURI.schemeIs("https")
+ ) {
+ event.preventDefault();
+ this.sendAsyncMessage("openLinkExternally", eventHRef);
+ }
+ }
+}
diff --git a/comm/mail/actors/LinkClickHandlerParent.jsm b/comm/mail/actors/LinkClickHandlerParent.jsm
new file mode 100644
index 0000000000..5fc0dd2b90
--- /dev/null
+++ b/comm/mail/actors/LinkClickHandlerParent.jsm
@@ -0,0 +1,30 @@
+/* vim: set ts=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 = [
+ "LinkClickHandlerParent",
+ "StrictLinkClickHandlerParent",
+];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ // eslint-disable-next-line mozilla/reject-global-this
+ this,
+ "openLinkExternally",
+ "chrome://browser/content/utilityOverlay.js"
+);
+
+class LinkClickHandlerParent extends JSWindowActorParent {
+ receiveMessage({ data }) {
+ openLinkExternally(data);
+ }
+}
+
+class StrictLinkClickHandlerParent extends LinkClickHandlerParent {}
diff --git a/comm/mail/actors/LinkHandlerParent.sys.mjs b/comm/mail/actors/LinkHandlerParent.sys.mjs
new file mode 100644
index 0000000000..269405c4ff
--- /dev/null
+++ b/comm/mail/actors/LinkHandlerParent.sys.mjs
@@ -0,0 +1,57 @@
+/* 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/. */
+
+export class LinkHandlerParent extends JSWindowActorParent {
+ receiveMessage(msg) {
+ let browser = this.browsingContext.top.embedderElement;
+ if (!browser) {
+ return;
+ }
+
+ switch (msg.name) {
+ case "Link:SetIcon":
+ this.setIconFromLink(browser, msg.data.iconURL, msg.data.canUseForTab);
+ break;
+ }
+ }
+
+ setIconFromLink(browser, iconURL, canUseForTab) {
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+ if (!tabmail) {
+ return;
+ }
+
+ let tab = tabmail.getTabForBrowser(browser);
+ if (tab?.mode?.type != "contentTab") {
+ return;
+ }
+
+ let iconURI;
+ try {
+ iconURI = Services.io.newURI(iconURL);
+ } catch (ex) {
+ console.error(ex);
+ return;
+ }
+ if (iconURI.scheme != "data") {
+ try {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ browser.contentPrincipal,
+ iconURI,
+ Services.scriptSecurityManager.ALLOW_CHROME
+ );
+ } catch (ex) {
+ return;
+ }
+ }
+
+ if (canUseForTab) {
+ tabmail.setTabFavIcon(
+ tab,
+ iconURL,
+ "chrome://messenger/skin/icons/new/compact/draft.svg"
+ );
+ }
+ }
+}
diff --git a/comm/mail/actors/MailLinkChild.jsm b/comm/mail/actors/MailLinkChild.jsm
new file mode 100644
index 0000000000..401b869fad
--- /dev/null
+++ b/comm/mail/actors/MailLinkChild.jsm
@@ -0,0 +1,42 @@
+/* vim: set ts=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 = ["MailLinkChild"];
+
+const PROTOCOLS = ["mailto:", "mid:", "news:", "snews:"];
+
+class MailLinkChild extends JSWindowActorChild {
+ handleEvent(event) {
+ let href = event.target.href;
+ let location = this.document.location;
+ if (
+ !href ||
+ // Do nothing if not the main button clicked.
+ event.button > 0 ||
+ // Do nothing if in the compose window.
+ location.href == "about:blank?compose"
+ ) {
+ return;
+ }
+
+ let url = new URL(href);
+ let protocol = url.protocol;
+ if (
+ PROTOCOLS.includes(protocol) ||
+ // A link to an attachment, e.g. cid: link.
+ (["imap:", "mailbox:"].includes(protocol) &&
+ url.searchParams.get("part") &&
+ // Prevent opening new tab for internal pdf link.
+ (!location.search.includes("part=") ||
+ url.origin != location.origin ||
+ url.pathname != location.pathname))
+ ) {
+ this.sendAsyncMessage(protocol, href);
+ event.preventDefault();
+ }
+ }
+}
diff --git a/comm/mail/actors/MailLinkParent.jsm b/comm/mail/actors/MailLinkParent.jsm
new file mode 100644
index 0000000000..6c371658f6
--- /dev/null
+++ b/comm/mail/actors/MailLinkParent.jsm
@@ -0,0 +1,96 @@
+/* vim: set ts=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 = ["MailLinkParent"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ MailServices: "resource:///modules/MailServices.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+});
+
+class MailLinkParent extends JSWindowActorParent {
+ receiveMessage(value) {
+ switch (value.name) {
+ case "imap:":
+ case "mailbox:":
+ this._handleMailboxLink(value);
+ break;
+ case "mailto:":
+ this._handleMailToLink(value);
+ break;
+ case "mid:":
+ this._handleMidLink(value);
+ break;
+ case "news:":
+ case "snews:":
+ this._handleNewsLink(value);
+ break;
+ default:
+ throw Components.Exception(
+ `Unsupported name=${value.name} url=${value.data}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+
+ _handleMailboxLink({ data, target }) {
+ // AttachmentInfo is defined in msgHdrView.js.
+ let url = new URL(data);
+ new lazy.AttachmentInfo({
+ contentType: "",
+ url: data,
+ name: url.searchParams.get("filename"),
+ uri: "",
+ isExternalAttachment: false,
+ }).open(target.browsingContext.topChromeWindow, target.browsingContext.id);
+ }
+
+ _handleMailToLink({ data, target }) {
+ let identity = null;
+
+ // If the document with the link is a message, try to get the identity
+ // from the message and use it when composing.
+ let documentURI = target.windowContext.documentURI;
+ if (documentURI instanceof Ci.nsIMsgMessageUrl) {
+ documentURI.QueryInterface(Ci.nsIMsgMessageUrl);
+ [identity] = lazy.MailUtils.getIdentityForHeader(
+ documentURI.messageHeader
+ );
+ }
+
+ lazy.MailServices.compose.OpenComposeWindowWithURI(
+ undefined,
+ Services.io.newURI(data),
+ identity
+ );
+ }
+
+ _handleMidLink({ data }) {
+ // data is the mid: url.
+ lazy.MailUtils.openMessageByMessageId(data.slice(4));
+ }
+
+ _handleNewsLink({ data }) {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ Services.io.newURI(data)
+ );
+ }
+}
diff --git a/comm/mail/actors/PromptParent.jsm b/comm/mail/actors/PromptParent.jsm
new file mode 100644
index 0000000000..5aedf5a1b9
--- /dev/null
+++ b/comm/mail/actors/PromptParent.jsm
@@ -0,0 +1,180 @@
+/* vim: set ts=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 EXPORTED_SYMBOLS = ["PromptParent"];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs",
+});
+
+/**
+ * @typedef {object} Prompt
+ * @property {Function} resolver
+ * The resolve function to be called with the data from the Prompt
+ * after the user closes it.
+ * @property {object} tabModalPrompt
+ * The TabModalPrompt being shown to the user.
+ */
+
+/**
+ * gBrowserPrompts weakly maps BrowsingContexts to a Map of their currently
+ * active Prompts.
+ *
+ * @type {WeakMap<BrowsingContext, Prompt>}
+ */
+let gBrowserPrompts = new WeakMap();
+
+class PromptParent extends JSWindowActorParent {
+ didDestroy() {
+ // In the event that the subframe or tab crashed, make sure that
+ // we close any active Prompts.
+ this.forceClosePrompts();
+ }
+
+ /**
+ * Registers a new Prompt to be tracked for a particular BrowsingContext.
+ * We need to track a Prompt so that we can, for example, force-close the
+ * TabModalPrompt if the originating subframe or tab unloads or crashes.
+ *
+ * @param {object} tabModalPrompt
+ * The TabModalPrompt that will be shown to the user.
+ * @param {string} id
+ * A unique ID to differentiate multiple Prompts coming from the same
+ * BrowsingContext.
+ * @returns {Promise}
+ * @resolves {object}
+ * Resolves with the arguments returned from the TabModalPrompt when it
+ * is dismissed.
+ */
+ registerPrompt(tabModalPrompt, id) {
+ let prompts = gBrowserPrompts.get(this.browsingContext);
+ if (!prompts) {
+ prompts = new Map();
+ gBrowserPrompts.set(this.browsingContext, prompts);
+ }
+
+ let promise = new Promise(resolve => {
+ prompts.set(id, {
+ tabModalPrompt,
+ resolver: resolve,
+ });
+ });
+
+ return promise;
+ }
+
+ /**
+ * Removes a Prompt for a BrowsingContext with a particular ID from the registry.
+ * This needs to be done to avoid leaking <xul:browser>'s.
+ *
+ * @param {string} id
+ * A unique ID to differentiate multiple Prompts coming from the same
+ * BrowsingContext.
+ */
+ unregisterPrompt(id) {
+ let prompts = gBrowserPrompts.get(this.browsingContext);
+ if (prompts) {
+ prompts.delete(id);
+ }
+ }
+
+ /**
+ * Programmatically closes all Prompts for the current BrowsingContext.
+ */
+ forceClosePrompts() {
+ let prompts = gBrowserPrompts.get(this.browsingContext) || [];
+
+ for (let [, prompt] of prompts) {
+ prompt.tabModalPrompt && prompt.tabModalPrompt.abortPrompt();
+ }
+ }
+
+ receiveMessage(message) {
+ let args = message.data;
+
+ switch (message.name) {
+ case "Prompt:Open": {
+ return this.openWindowPrompt(args);
+ }
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Opens a window prompt for a BrowsingContext, and puts the associated
+ * browser in the modal state until the prompt is closed.
+ *
+ * @param {object} args
+ * The arguments passed up from the BrowsingContext to be passed
+ * directly to the modal window.
+ * @returns {Promise}
+ * Resolves when the window prompt is dismissed.
+ * @resolves {object}
+ * The arguments returned from the window prompt.
+ */
+ async openWindowPrompt(args) {
+ const COMMON_DIALOG = "chrome://global/content/commonDialog.xhtml";
+ const SELECT_DIALOG = "chrome://global/content/selectDialog.xhtml";
+ let uri = args.promptType == "select" ? SELECT_DIALOG : COMMON_DIALOG;
+
+ let browsingContext = this.browsingContext.top;
+
+ let browser = browsingContext.embedderElement;
+ let win;
+
+ // If we are a chrome actor we can use the associated chrome win.
+ if (!browsingContext.isContent && browsingContext.window) {
+ win = browsingContext.window;
+ } else {
+ win = browser?.ownerGlobal;
+ if (!win?.isChromeWindow) {
+ win = browsingContext.topChromeWindow;
+ }
+ }
+
+ // There's a requirement for prompts to be blocked if a window is
+ // passed and that window is hidden (eg, auth prompts are suppressed if the
+ // passed window is the hidden window).
+ // See bug 875157 comment 30 for more..
+ if (win?.winUtils && !win.winUtils.isParentWindowMainWidgetVisible) {
+ throw new Error("Cannot call openModalWindow on a hidden window");
+ }
+
+ try {
+ if (browser) {
+ // The compose editor does not support enter/leaveModalState.
+ browser.enterModalState?.();
+ lazy.PromptUtils.fireDialogEvent(
+ win,
+ "DOMWillOpenModalDialog",
+ browser
+ );
+ }
+
+ let bag = lazy.PromptUtils.objectToPropBag(args);
+
+ Services.ww.openWindow(
+ win,
+ uri,
+ "_blank",
+ "centerscreen,chrome,modal,titlebar",
+ bag
+ );
+
+ lazy.PromptUtils.propBagToObject(bag, args);
+ } finally {
+ if (browser) {
+ browser.leaveModalState?.();
+ lazy.PromptUtils.fireDialogEvent(win, "DOMModalDialogClosed", browser);
+ }
+ }
+ return args;
+ }
+}
diff --git a/comm/mail/actors/VCardChild.jsm b/comm/mail/actors/VCardChild.jsm
new file mode 100644
index 0000000000..db43b6c145
--- /dev/null
+++ b/comm/mail/actors/VCardChild.jsm
@@ -0,0 +1,23 @@
+/* vim: set ts=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 = ["VCardChild"];
+
+class VCardChild extends JSWindowActorChild {
+ handleEvent(event) {
+ // This link comes from VCardMimeConverter.convertToHTML in VCardUtils.jsm.
+ if (event.target.classList.contains("moz-vcard-badge")) {
+ if (event.button == 0) {
+ // The href is a data:text/vcard URL.
+ let href = event.target.href;
+ href = href.substring(href.indexOf(",") + 1);
+ this.sendAsyncMessage("addVCard", href);
+ }
+ event.preventDefault();
+ }
+ }
+}
diff --git a/comm/mail/actors/VCardParent.jsm b/comm/mail/actors/VCardParent.jsm
new file mode 100644
index 0000000000..71454a4a93
--- /dev/null
+++ b/comm/mail/actors/VCardParent.jsm
@@ -0,0 +1,17 @@
+/* vim: set ts=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 = ["VCardParent"];
+
+class VCardParent extends JSWindowActorParent {
+ receiveMessage({ data, target }) {
+ target.browsingContext.topChromeWindow.toAddressBook({
+ action: "create",
+ vCard: decodeURIComponent(data),
+ });
+ }
+}
diff --git a/comm/mail/actors/moz.build b/comm/mail/actors/moz.build
new file mode 100644
index 0000000000..da87ba7f0b
--- /dev/null
+++ b/comm/mail/actors/moz.build
@@ -0,0 +1,24 @@
+# vim: set filetype=python:
+# 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_JS_MODULES += [
+ "../../../browser/modules/FaviconLoader.jsm",
+]
+
+FINAL_TARGET_FILES.actors += [
+ "../../../browser/actors/ContextMenuChild.sys.mjs",
+ "../../../browser/actors/LinkHandlerChild.sys.mjs",
+ "ChatActionChild.sys.mjs",
+ "ChatActionParent.sys.mjs",
+ "ContextMenuParent.sys.mjs",
+ "LinkClickHandlerChild.jsm",
+ "LinkClickHandlerParent.jsm",
+ "LinkHandlerParent.sys.mjs",
+ "MailLinkChild.jsm",
+ "MailLinkParent.jsm",
+ "PromptParent.jsm",
+ "VCardChild.jsm",
+ "VCardParent.jsm",
+]
diff --git a/comm/mail/app-system-headers.mozbuild b/comm/mail/app-system-headers.mozbuild
new file mode 100644
index 0000000000..78e3a19d51
--- /dev/null
+++ b/comm/mail/app-system-headers.mozbuild
@@ -0,0 +1,18 @@
+# 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 this file to define additional system header files that the
+# application requires. This is necessary to allow symbols defined in
+# those header files to be loaded from external library files.
+
+# system_headers += [
+# 'file1.h',
+# 'file2.h',
+# ]
+
+if CONFIG["MOZ_SYSTEM_ICU"]:
+ system_headers += [
+ "unicode/strenum.h",
+ "unicode/vtzone.h",
+ ]
diff --git a/comm/mail/app.mozbuild b/comm/mail/app.mozbuild
new file mode 100644
index 0000000000..7c6fc3240b
--- /dev/null
+++ b/comm/mail/app.mozbuild
@@ -0,0 +1,21 @@
+# vim: set filetype=python:
+# 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/.
+
+# Note that paths in this file are relative to the top directory (m-c)
+
+GENERATED_FILES["source-repo.h"].script = "comm/build/source_repos.py:source_repo_header"
+
+include("/toolkit/toolkit.mozbuild")
+
+# Never add dirs after /comm/mail because they apparently won't get
+# packaged properly on Mac.
+DIRS += [
+ "/comm/mailnews",
+ "/comm/mail/components",
+ "/%s" % CONFIG["MOZ_BRANDING_DIRECTORY"],
+ "/comm/calendar",
+ "/comm/chat",
+ "/comm/mail",
+]
diff --git a/comm/mail/app/Makefile.in b/comm/mail/app/Makefile.in
new file mode 100644
index 0000000000..9b9e86d1a2
--- /dev/null
+++ b/comm/mail/app/Makefile.in
@@ -0,0 +1,142 @@
+# 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/.
+
+dist_dest = $(DIST)/$(MOZ_MACBUNDLE_NAME)
+
+AB_CD = $(MOZ_UI_LOCALE)
+
+GRE_MILESTONE = $(shell $(PYTHON3) $(topsrcdir)/config/printconfigsetting.py $(DIST)/bin/platform.ini Build Milestone)
+MOZ_BUILDID = $(shell $(PYTHON3) $(topsrcdir)/config/printconfigsetting.py $(DIST)/bin/platform.ini Build BuildID)
+
+# Build a binary bootstrapping with XRE_main
+
+ifndef MOZ_WINCONSOLE
+ifdef MOZ_DEBUG
+MOZ_WINCONSOLE = 1
+else
+MOZ_WINCONSOLE = 0
+endif
+endif
+
+include $(topsrcdir)/config/config.mk
+
+# If we are trying to show an error dialog about the lack of SSE2 support,
+# make sure that code itself doesn't use SSE2.
+ifdef MOZ_LINUX_32_SSE2_STARTUP_ERROR
+CXX := $(filter-out -march=% -msse -msse2 -mfpmath=sse,$(CXX))
+CXX += -march=pentiumpro
+endif
+
+objdir = $(topobjdir)/comm/mail/app
+
+include $(topsrcdir)/config/rules.mk
+
+ifneq ($(OS_ARCH),WINNT)
+ifdef COMPILE_ENVIRONMENT
+ifndef MOZ_NO_PIE_COMPAT
+libs::
+ cp -p $(DIST)/bin/$(MOZ_APP_NAME)$(BIN_SUFFIX) $(DIST)/bin/$(MOZ_APP_NAME)-bin$(BIN_SUFFIX)
+endif
+endif
+
+GARBAGE += $(addprefix $(DIST)/bin/defaults/pref/, all.js all-thunderbird.js mailnews.js)
+endif # ! WinNT
+
+ifeq (gtk,$(MOZ_WIDGET_TOOLKIT))
+ICON_SUFFIX=.png
+DESKTOP_ICONS = \
+ msgcomposeWindow16 \
+ msgcomposeWindow24 \
+ msgcomposeWindow32 \
+ msgcomposeWindow48 \
+ calendar-alarm-dialog \
+ calendar-general-dialog \
+ $(NULL)
+
+DESKTOP_ICON_FILES = $(addsuffix $(ICON_SUFFIX), $(DESKTOP_ICONS))
+
+libs:: $(addprefix $(srcdir)/icons/$(MOZ_WIDGET_TOOLKIT)/,$(DESKTOP_ICON_FILES))
+ $(INSTALL) $(IFLAGS1) $^ $(DIST)/bin/chrome/icons/default
+endif
+
+ifneq (,$(filter windows,$(MOZ_WIDGET_TOOLKIT)))
+ICON_SUFFIX=.ico
+
+DESKTOP_ICONS = \
+ msgcomposeWindow \
+ calendar-alarm-dialog \
+ calendar-general-dialog \
+ $(NULL)
+
+BRANDED_ICONS = \
+ messengerWindow \
+ newmail \
+ $(NULL)
+
+DESKTOP_ICON_FILES = $(addsuffix $(ICON_SUFFIX), $(DESKTOP_ICONS))
+BRANDED_ICON_FILES = $(addsuffix $(ICON_SUFFIX), $(BRANDED_ICONS))
+
+libs:: $(addprefix $(srcdir)/icons/$(MOZ_WIDGET_TOOLKIT)/,$(DESKTOP_ICON_FILES))
+ $(INSTALL) $(IFLAGS1) $^ $(DIST)/bin/chrome/icons/default
+
+libs:: $(addprefix $(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/, $(BRANDED_ICON_FILES))
+ $(INSTALL) $(IFLAGS1) $^ $(DIST)/bin/chrome/icons/default
+endif
+
+# channel-prefs.js is handled separate from other prefs due to bug 756325
+libs:: $(srcdir)/profile/channel-prefs.js
+ $(NSINSTALL) -D $(DIST)/bin/defaults/pref
+ $(call py_action,preprocessor,-Fsubstitution $(PREF_PPFLAGS) $(ACDEFINES) $^ -o $(DIST)/bin/defaults/pref/channel-prefs.js)
+
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+
+MAC_APP_NAME = $(MOZ_APP_DISPLAYNAME)
+
+ifdef MOZ_DEBUG
+MAC_APP_NAME := $(MAC_APP_NAME)Debug
+endif
+
+AB_CD = $(MOZ_UI_LOCALE)
+
+ifeq (zh-TW,$(AB_CD))
+LPROJ_ROOT := $(subst -,_,$(AB_CD))
+else
+LPROJ_ROOT := $(firstword $(subst -, ,$(AB_CD)))
+endif
+LPROJ := Contents/Resources/$(LPROJ_ROOT).lproj
+
+clean clobber repackage::
+ $(RM) -r '$(dist_dest)'
+
+MAC_BUNDLE_VERSION = $(shell $(PYTHON3) $(srcdir)/macversion.py --version=$(MOZ_APP_VERSION) --buildid=$(DEPTH)/buildid.h)
+
+.PHONY: repackage
+tools repackage:: $(DIST)/bin/$(MOZ_APP_NAME) $(objdir)/macbuild/Contents/MacOS-files.txt
+ rm -rf $(dist_dest)
+ $(MKDIR) -p '$(dist_dest)/Contents/MacOS'
+ $(MKDIR) -p '$(dist_dest)/$(LPROJ)'
+ rsync -a --exclude '*.in' $(srcdir)/macbuild/Contents '$(dist_dest)' --exclude English.lproj
+ rsync -a --exclude '*.in' $(srcdir)/macbuild/Contents/Resources/English.lproj/ '$(dist_dest)/$(LPROJ)'
+ $(call py_action,preprocessor,-Fsubstitution -DAPP_VERSION='$(MOZ_APP_VERSION)' -DAPP_VERSION_DISPLAY='$(MOZ_APP_VERSION_DISPLAY)' -DMOZ_APP_NAME='$(MOZ_APP_NAME)' -DMAC_APP_NAME='$(MAC_APP_NAME)' -DMOZ_MACBUNDLE_ID='$(MOZ_MACBUNDLE_ID)' -DMAC_BUNDLE_VERSION='$(MAC_BUNDLE_VERSION)' -DMOZ_DEVELOPER_REPO_PATH='$(topsrcdir)' -DMOZ_DEVELOPER_OBJ_PATH='$(topobjdir)' $(srcdir)/macbuild/Contents/Info.plist.in -o '$(dist_dest)/Contents/Info.plist')
+ $(call py_action,preprocessor,-Fsubstitution --output-encoding utf-16 -DMAC_APP_NAME='$(MAC_APP_NAME)' $(srcdir)/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in -o '$(dist_dest)/$(LPROJ)/InfoPlist.strings')
+ rsync -a --exclude-from='$(objdir)/macbuild/Contents/MacOS-files.txt' $(DIST)/bin/ '$(dist_dest)/Contents/Resources'
+ rsync -a --include-from='$(objdir)/macbuild/Contents/MacOS-files.txt' --exclude '*' $(DIST)/bin/ '$(dist_dest)/Contents/MacOS'
+ # MacOS-files-copy.in is a list of files that should be copies rather
+ # than symlinks and placed in .app/Contents/MacOS.
+ rsync -aL --include-from='$(srcdir)/macbuild/Contents/MacOS-files-copy.in' --exclude '*' $(DIST)/bin/ '$(dist_dest)/Contents/MacOS'
+ $(RM) '$(dist_dest)/Contents/MacOS/$(MOZ_APP_NAME)'
+ rsync -aL $(DIST)/bin/$(MOZ_APP_NAME) '$(dist_dest)/Contents/MacOS'
+ $(MKDIR) -p '$(dist_dest)/Contents/Library/Spotlight'
+ rsync -a --copy-unsafe-links $(DIST)/package/thunderbird.mdimporter '$(dist_dest)/Contents/Library/Spotlight'
+ cp -RL $(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/thunderbird.icns '$(dist_dest)/Contents/Resources/thunderbird.icns'
+ $(MKDIR) -p '$(dist_dest)/Contents/Library/LaunchServices'
+ifdef MOZ_UPDATER
+ mv -f '$(dist_dest)/Contents/MacOS/updater.app/Contents/MacOS/org.mozilla.updater' '$(dist_dest)/Contents/Library/LaunchServices'
+ ln -s ../../../../Library/LaunchServices/org.mozilla.updater '$(dist_dest)/Contents/MacOS/updater.app/Contents/MacOS/org.mozilla.updater'
+endif
+ printf APPLMOZM > '$(dist_dest)/Contents/PkgInfo'
+endif
+
+# Note that anything you do to dist/ down here isn't going to make it into the
+# Mac build, since it's already been copied over to the .app, above.
diff --git a/comm/mail/app/icons/gtk/calendar-alarm-dialog.png b/comm/mail/app/icons/gtk/calendar-alarm-dialog.png
new file mode 100644
index 0000000000..a017151d0e
--- /dev/null
+++ b/comm/mail/app/icons/gtk/calendar-alarm-dialog.png
Binary files differ
diff --git a/comm/mail/app/icons/gtk/calendar-general-dialog.png b/comm/mail/app/icons/gtk/calendar-general-dialog.png
new file mode 100644
index 0000000000..83f327e76f
--- /dev/null
+++ b/comm/mail/app/icons/gtk/calendar-general-dialog.png
Binary files differ
diff --git a/comm/mail/app/icons/gtk/msgcomposeWindow16.png b/comm/mail/app/icons/gtk/msgcomposeWindow16.png
new file mode 100644
index 0000000000..a82a61f515
--- /dev/null
+++ b/comm/mail/app/icons/gtk/msgcomposeWindow16.png
Binary files differ
diff --git a/comm/mail/app/icons/gtk/msgcomposeWindow24.png b/comm/mail/app/icons/gtk/msgcomposeWindow24.png
new file mode 100644
index 0000000000..be3a293146
--- /dev/null
+++ b/comm/mail/app/icons/gtk/msgcomposeWindow24.png
Binary files differ
diff --git a/comm/mail/app/icons/gtk/msgcomposeWindow32.png b/comm/mail/app/icons/gtk/msgcomposeWindow32.png
new file mode 100644
index 0000000000..29dd4ee0b0
--- /dev/null
+++ b/comm/mail/app/icons/gtk/msgcomposeWindow32.png
Binary files differ
diff --git a/comm/mail/app/icons/gtk/msgcomposeWindow48.png b/comm/mail/app/icons/gtk/msgcomposeWindow48.png
new file mode 100644
index 0000000000..52bf9ac2a5
--- /dev/null
+++ b/comm/mail/app/icons/gtk/msgcomposeWindow48.png
Binary files differ
diff --git a/comm/mail/app/icons/windows/calendar-alarm-dialog.ico b/comm/mail/app/icons/windows/calendar-alarm-dialog.ico
new file mode 100644
index 0000000000..07ad8e6b6c
--- /dev/null
+++ b/comm/mail/app/icons/windows/calendar-alarm-dialog.ico
Binary files differ
diff --git a/comm/mail/app/icons/windows/calendar-general-dialog.ico b/comm/mail/app/icons/windows/calendar-general-dialog.ico
new file mode 100644
index 0000000000..81c02242cb
--- /dev/null
+++ b/comm/mail/app/icons/windows/calendar-general-dialog.ico
Binary files differ
diff --git a/comm/mail/app/icons/windows/msgcomposeWindow.ico b/comm/mail/app/icons/windows/msgcomposeWindow.ico
new file mode 100644
index 0000000000..51d09d9a79
--- /dev/null
+++ b/comm/mail/app/icons/windows/msgcomposeWindow.ico
Binary files differ
diff --git a/comm/mail/app/macbuild/Contents/Info.plist.in b/comm/mail/app/macbuild/Contents/Info.plist.in
new file mode 100644
index 0000000000..8c577cbeae
--- /dev/null
+++ b/comm/mail/app/macbuild/Contents/Info.plist.in
@@ -0,0 +1,143 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+ <key>CFBundleExecutable</key>
+ <string>@MOZ_APP_NAME@</string>
+ <key>CFBundleGetInfoString</key>
+ <string>@MAC_APP_NAME@ @APP_VERSION_DISPLAY@</string>
+ <key>CFBundleIconFile</key>
+ <string>thunderbird.icns</string>
+ <key>CFBundleIdentifier</key>
+ <string>@MOZ_MACBUNDLE_ID@</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>@MAC_APP_NAME@</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleShortVersionString</key>
+ <string>@APP_VERSION@</string>
+ <key>CFBundleSignature</key>
+ <string>MOZM</string>
+ <key>CFBundleVersion</key>
+ <string>@MAC_BUNDLE_VERSION@</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>Email Address URL</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>mailto</string>
+ </array>
+ </dict>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>News URL</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>news</string>
+ </array>
+ </dict>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>RSS / ATOM URL</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>feed</string>
+ </array>
+ </dict>
+ </array>
+ <key>NSAppleScriptEnabled</key>
+ <true/>
+ <key>LSApplicationCategoryType</key>
+ <string>public.app-category.productivity</string>
+ <key>LSFileQuarantineEnabled</key>
+ <true/>
+ <key>LSMinimumSystemVersion</key>
+ <string>10.12.0</string>
+ <key>NSSupportsAutomaticGraphicsSwitching</key>
+ <true/>
+ <key>NSRequiresAquaSystemAppearance</key>
+ <false/>
+ <key>NSDisablePersistence</key>
+ <true/>
+ <key>NSPrincipalClass</key>
+ <string>GeckoNSApplication</string>
+ <key>SMPrivilegedExecutables</key>
+ <dict>
+ <key>org.mozilla.updater</key>
+ <string>identifier "org.mozilla.updater" and ((anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.9]) or (anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "43AQ936H96"))</string>
+ </dict>
+ <key>MozillaDeveloperRepoPath</key>
+ <string>@MOZ_DEVELOPER_REPO_PATH@</string>
+ <key>MozillaDeveloperObjPath</key>
+ <string>@MOZ_DEVELOPER_OBJ_PATH@</string>
+ <key>UTExportedTypeDeclarations</key>
+ <array>
+ <dict>
+ <key>UTTypeIdentifier</key>
+ <string>com.mozilla.thunderbird.mozeml</string>
+ <key>UTTypeReferenceURL</key>
+ <string>https://thunderbird.net</string>
+ <key>UTTypeDescription</key>
+ <string>Thunderbird Mail Message</string>
+ <key>UTTypeConformsTo</key>
+ <array>
+ <string>public.data</string>
+ <string>public.content</string>
+ <string>public.email-message</string>
+ </array>
+ <key>UTTypeTagSpecification</key>
+ <dict>
+ <key>com.apple.ostype</key>
+ <string>TBMZ</string>
+ <key>public.filename-extension</key>
+ <array>
+ <string>mozeml</string>
+ </array>
+ </dict>
+ </dict>
+ </array>
+
+ <key>CFBundleDocumentTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>mozeml</string>
+ </array>
+ <key>CFBundleTypeOSTypes</key>
+ <array>
+ <string>TBMZ</string>
+ </array>
+
+ <key>CFBundleTypeName</key>
+ <string>Thunderbird Mail Message</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ <key>LSTypeIsPackage</key>
+ <false/>
+ <key>LSItemContentTypes</key>
+ <array>
+ <string>com.mozilla.thunderbird.mozeml</string>
+ </array>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>eml</string>
+ </array>
+ <key>CFBundleTypeName</key>
+ <string>Thunderbird Email</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ </array>
+ <key>NSContactsUsageDescription</key>
+ <string>Use your macOS contacts in @MAC_APP_NAME@.</string>
+</dict>
+</plist>
diff --git a/comm/mail/app/macbuild/Contents/MacOS-files-copy.in b/comm/mail/app/macbuild/Contents/MacOS-files-copy.in
new file mode 100644
index 0000000000..e9d0f0efb9
--- /dev/null
+++ b/comm/mail/app/macbuild/Contents/MacOS-files-copy.in
@@ -0,0 +1,11 @@
+# Specifies files that should be copied (via deep copy, resolving symlinks)
+# from dist/bin to the .app/Contents/MacOS directory. Linking is preferred to
+# reduce disk I/O during builds, so just include dylibs which need to be in the
+# same directory as returned by dladddr(3).
+#
+# Some of these dylibs load other dylibs which are assumed to be siblings in
+# the same directory obtained from dladdr(3). With macOS 10.15, dladdr returns
+# absolute resolved paths which breaks this assumption if symlinks are used
+# because the symlink targets are in different directories. Hence the need for
+# them to be copied to the same directory.
+/*.dylib
diff --git a/comm/mail/app/macbuild/Contents/MacOS-files.in b/comm/mail/app/macbuild/Contents/MacOS-files.in
new file mode 100644
index 0000000000..b0d3a788e0
--- /dev/null
+++ b/comm/mail/app/macbuild/Contents/MacOS-files.in
@@ -0,0 +1,18 @@
+#if 0
+; Specifies files that should be copied (preserving symlinks) from dist/bin
+; to the .app/Contents/MacOS directory.
+#endif
+#filter substitution
+/*.app/***
+/certutil
+/@MOZ_APP_NAME@-bin
+#if defined(MOZ_CRASHREPORTER)
+/minidump-analyzer
+#endif
+/pingsender
+/pk12util
+/rnp-cli
+/rnpkeys
+/ssltunnel
+/xpcshell
+/XUL
diff --git a/comm/mail/app/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in b/comm/mail/app/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in
new file mode 100644
index 0000000000..c84535de6a
--- /dev/null
+++ b/comm/mail/app/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in
@@ -0,0 +1,5 @@
+/* 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/. */
+
+CFBundleName = "@MAC_APP_NAME@";
diff --git a/comm/mail/app/macbuild/Contents/moz.build b/comm/mail/app/macbuild/Contents/moz.build
new file mode 100644
index 0000000000..87937e9967
--- /dev/null
+++ b/comm/mail/app/macbuild/Contents/moz.build
@@ -0,0 +1,26 @@
+# 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/.
+
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+defs = []
+
+for s in (
+ "MOZ_CRASHREPORTER",
+ "MOZ_APP_NAME",
+):
+ if CONFIG[s]:
+ defs.append("-D%s=%s" % (s, "1" if CONFIG[s] is True else CONFIG[s]))
+
+GeneratedFile(
+ "MacOS-files.txt",
+ script="/python/mozbuild/mozbuild/action/preprocessor.py",
+ entry_point="generate",
+ inputs=["MacOS-files.in"],
+ flags=defs,
+)
diff --git a/comm/mail/app/macversion.py b/comm/mail/app/macversion.py
new file mode 100644
index 0000000000..696b747f94
--- /dev/null
+++ b/comm/mail/app/macversion.py
@@ -0,0 +1,44 @@
+#!/usr/bin/python
+# 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 io
+import re
+import sys
+from optparse import OptionParser
+
+o = OptionParser()
+o.add_option("--buildid", dest="buildid")
+o.add_option("--version", dest="version")
+
+(options, args) = o.parse_args()
+
+if not options.buildid:
+ print("--buildid is required", file=sys.stderr)
+ sys.exit(1)
+
+if not options.version:
+ print("--version is required", file=sys.stderr)
+ sys.exit(1)
+
+# We want to build a version number that matches the format allowed for
+# CFBundleVersion (nnnnn[.nn[.nn]]). We'll incorporate both the version
+# number as well as the date, so that it changes at least daily (for nightly
+# builds), but also so that newly-built older versions (e.g. beta build) aren't
+# considered "newer" than previously-built newer versions (e.g. a trunk nightly)
+
+define, MOZ_BUILDID, buildid = io.open(options.buildid, "r", encoding="utf-8").read().split()
+
+# extract only the major version (i.e. "14" from "14.0b1")
+majorVersion = re.match(r"^(\d+)[^\d].*", options.version).group(1)
+# last two digits of the year
+twodigityear = buildid[2:4]
+month = buildid[4:6]
+if month[0] == "0":
+ month = month[1]
+day = buildid[6:8]
+if day[0] == "0":
+ day = day[1]
+
+print("%s.%s.%s" % (majorVersion + twodigityear, month, day))
diff --git a/comm/mail/app/module.ver b/comm/mail/app/module.ver
new file mode 100644
index 0000000000..456739fc6b
--- /dev/null
+++ b/comm/mail/app/module.ver
@@ -0,0 +1,11 @@
+WIN32_MODULE_DESCRIPTION=@MOZ_APP_DISPLAYNAME@
+WIN32_MODULE_PRODUCTNAME=@MOZ_APP_DISPLAYNAME@
+WIN32_MODULE_NAME=@MOZ_APP_DISPLAYNAME@
+WIN32_MODULE_FILEVERSION=@MOZ_APP_WINVERSION@
+WIN32_MODULE_FILEVERSION_STRING=@MOZ_APP_VERSION@
+WIN32_MODULE_PRODUCTVERSION=@MOZ_APP_WINVERSION@
+WIN32_MODULE_PRODUCTVERSION_STRING=@MOZ_APP_VERSION@
+WIN32_MODULE_COPYRIGHT=©Thunderbird and Mozilla Developers, according to the MPL 1.1/GPL 2.0/LGPL 2.1 licenses, as applicable.
+WIN32_MODULE_COMPANYNAME=Mozilla Corporation
+WIN32_MODULE_TRADEMARKS=Thunderbird is a Trademark of The Mozilla Foundation.
+WIN32_MODULE_COMMENT=Mozilla Thunderbird Mail and News Client
diff --git a/comm/mail/app/moz.build b/comm/mail/app/moz.build
new file mode 100644
index 0000000000..14c7789de7
--- /dev/null
+++ b/comm/mail/app/moz.build
@@ -0,0 +1,132 @@
+# vim: set filetype=python:
+# 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/.
+
+if CONFIG["MOZ_MACBUNDLE_NAME"]:
+ DIRS += ["macbuild/Contents"]
+
+if CONFIG["MOZ_NO_PIE_COMPAT"]:
+ GeckoProgram(CONFIG["MOZ_APP_NAME"] + "-bin")
+
+ DIRS += ["no-pie"]
+else:
+ GeckoProgram(CONFIG["MOZ_APP_NAME"])
+
+USE_LIBS += ["mozglue"]
+SOURCES += ["nsMailApp.cpp"]
+LOCAL_INCLUDES += [
+ "!/build",
+ "/ipc/contentproc/",
+ "/toolkit/xre",
+ "/xpcom/base",
+ "/xpcom/build",
+]
+
+if CONFIG["LIBFUZZER"]:
+ USE_LIBS += ["fuzzer"]
+ LOCAL_INCLUDES += [
+ "/tools/fuzzing/libfuzzer",
+ ]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ RCINCLUDE = "splash.rc"
+ DEFINES["MOZ_THUNDERBIRD"] = True
+
+ # Don't build thunderbird.exe with CETCOMPAT, because we need to be able to
+ # only enable it for processes that are not using JIT in xul.dll.
+
+ LINK_FLAGS["CETCOMPAT"] = []
+
+if CONFIG["OS_ARCH"] == "Darwin":
+ OS_LIBS += [
+ "-framework CoreFoundation",
+ ]
+
+if CONFIG["MOZ_SANDBOX"] and CONFIG["OS_ARCH"] == "WINNT":
+ # For sandbox includes and the include dependencies those have
+ LOCAL_INCLUDES += [
+ "/security/sandbox/chromium",
+ "/security/sandbox/chromium-shim",
+ ]
+
+ OS_LIBS += [
+ "version",
+ ]
+
+ USE_LIBS += [
+ "sandbox_s",
+ ]
+
+ OS_LIBS += [
+ "advapi32",
+ "winmm",
+ "user32",
+ ]
+
+ DELAYLOAD_DLLS += [
+ "winmm.dll",
+ "user32.dll",
+ ]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ OS_LIBS += [
+ "ntdll",
+ ]
+
+if CONFIG["CC_TYPE"] in ("msvc", "clang-cl"):
+ # Always enter a Windows program through wmain, whether or not we're
+ # a console application.
+ WIN32_EXE_LDFLAGS += ["-ENTRY:wmainCRTStartup"]
+
+# Control the default heap size.
+# This is the heap returned by GetProcessHeap().
+# As we use the CRT heap, the default size is too large and wastes VM.
+#
+# The default heap size is 1MB on Win32.
+# The heap will grow if need be.
+#
+# Set it to 256k. See bug 127069.
+if CONFIG["OS_ARCH"] == "WINNT" and CONFIG["CC_TYPE"] in ("msvc", "clang-cl"):
+ LDFLAGS += ["/HEAP:0x40000"]
+
+DisableStlWrapping()
+
+if CONFIG["MOZ_LINKER"]:
+ OS_LIBS += CONFIG["MOZ_ZLIB_LIBS"]
+
+if CONFIG["HAVE_CLOCK_MONOTONIC"]:
+ OS_LIBS += CONFIG["REALTIME_LIBS"]
+
+DEFINES["APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+
+if CONFIG["MOZILLA_OFFICIAL"]:
+ DEFINES["MOZILLA_OFFICIAL"] = True
+
+if CONFIG["MOZ_GPSD"]:
+ DEFINES["MOZ_GPSD"] = True
+
+if CONFIG["MOZ_LINUX_32_SSE2_STARTUP_ERROR"]:
+ DEFINES["MOZ_LINUX_32_SSE2_STARTUP_ERROR"] = True
+ COMPILE_FLAGS["OS_CXXFLAGS"] = [
+ f
+ for f in COMPILE_FLAGS.get("OS_CXXFLAGS", [])
+ if not f.startswith("-march=") and f not in ("-msse", "-msse2", "-mfpmath=sse")
+ ] + [
+ "-mno-sse",
+ "-mno-sse2",
+ "-mfpmath=387",
+ ]
+
+JS_PREFERENCE_PP_FILES += [
+ "profile/all-thunderbird.js",
+]
+
+DIRS += ["settings"]
+
+for icon in ("messengerWindow", "newmail", "writeMessage", "addressbook"):
+ DEFINES[icon.upper() + "_ICO"] = '"%s/%s/%s.ico"' % (
+ TOPSRCDIR,
+ CONFIG["MOZ_BRANDING_DIRECTORY"],
+ icon,
+ )
diff --git a/comm/mail/app/no-pie/NoPie.c b/comm/mail/app/no-pie/NoPie.c
new file mode 100644
index 0000000000..39b206e0af
--- /dev/null
+++ b/comm/mail/app/no-pie/NoPie.c
@@ -0,0 +1,26 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+int main(int argc, char* argv[]) {
+ // Ideally, we'd use mozilla::BinaryPath, but that pulls in stdc++compat,
+ // and further causes trouble linking with LTO.
+ char path[PATH_MAX + 4];
+ ssize_t len = readlink("/proc/self/exe", path, PATH_MAX - 1);
+ if (len < 0) {
+ fprintf(stderr, "Couldn't find the application directory.\n");
+ return 255;
+ }
+ strcpy(path + len, "-bin");
+ execv(path, argv);
+ // execv never returns. If it did, there was an error.
+ fprintf(stderr, "Exec failed with error: %s\n", strerror(errno));
+ return 255;
+}
diff --git a/comm/mail/app/no-pie/moz.build b/comm/mail/app/no-pie/moz.build
new file mode 100644
index 0000000000..74aa89409d
--- /dev/null
+++ b/comm/mail/app/no-pie/moz.build
@@ -0,0 +1,24 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Program(CONFIG["MOZ_APP_NAME"])
+
+SOURCES += [
+ "NoPie.c",
+]
+
+# For some reason, LTO messes things up. We don't care anyways.
+CFLAGS += [
+ "-fno-lto",
+]
+
+# Use OS_LIBS instead of LDFLAGS to "force" the flag to come after -pie
+# from MOZ_PROGRAM_LDFLAGS.
+if CONFIG["CC_TYPE"] == "clang":
+ # clang < 5.0 doesn't support -no-pie.
+ OS_LIBS += ["-nopie"]
+else:
+ OS_LIBS += ["-no-pie"]
diff --git a/comm/mail/app/nsMailApp.cpp b/comm/mail/app/nsMailApp.cpp
new file mode 100644
index 0000000000..842a378026
--- /dev/null
+++ b/comm/mail/app/nsMailApp.cpp
@@ -0,0 +1,402 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsXULAppAPI.h"
+#include "mozilla/CmdLineAndEnvUtils.h"
+#include "mozilla/XREAppData.h"
+#include "nsXPCOM.h"
+#include "nsISupports.h"
+#include "mozilla/Logging.h"
+#include "mozilla/XREAppData.h"
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/Assertions.h"
+#include "mozilla/TimeStamp.h"
+#include "XREChildData.h"
+#include "XREShellData.h"
+
+#include "application.ini.h"
+#include "mozilla/Bootstrap.h"
+#include "mozilla/ProcessType.h"
+#include "mozilla/RuntimeExceptionModule.h"
+#include "mozilla/ScopeExit.h"
+#if defined(XP_WIN)
+# include <windows.h>
+# include <stdlib.h>
+#elif defined(XP_UNIX)
+# include <sys/resource.h>
+# include <unistd.h>
+#endif
+
+#include <stdio.h>
+#include <stdarg.h>
+#include <time.h>
+
+#include "nsCOMPtr.h"
+#include "nsIFile.h"
+
+#ifdef XP_WIN
+# include "mozilla/mscom/ProcessRuntime.h"
+# include "mozilla/WindowsDllBlocklist.h"
+# include "mozilla/WindowsDpiInitialization.h"
+
+# define XRE_WANT_ENVIRON
+# define strcasecmp _stricmp
+# ifdef MOZ_SANDBOX
+# include "mozilla/sandboxing/SandboxInitialization.h"
+# endif
+#endif
+#include "BinaryPath.h"
+
+#include "nsXPCOMPrivate.h" // for MAXPATHLEN and XPCOM_DLL
+
+#include "mozilla/Sprintf.h"
+#include "mozilla/StartupTimeline.h"
+
+#ifdef LIBFUZZER
+# include "FuzzerDefs.h"
+#endif
+
+#ifdef MOZ_LINUX_32_SSE2_STARTUP_ERROR
+# include <cpuid.h>
+# include "mozilla/Unused.h"
+
+static bool IsSSE2Available() {
+ // The rest of the app has been compiled to assume that SSE2 is present
+ // unconditionally, so we can't use the normal copy of SSE.cpp here.
+ // Since SSE.cpp caches the results and we need them only transiently,
+ // instead of #including SSE.cpp here, let's just inline the specific check
+ // that's needed.
+ unsigned int level = 1u;
+ unsigned int eax, ebx, ecx, edx;
+ unsigned int bits = (1u << 26);
+ unsigned int max = __get_cpuid_max(0, nullptr);
+ if (level > max) {
+ return false;
+ }
+ __cpuid_count(level, 0, eax, ebx, ecx, edx);
+ return (edx & bits) == bits;
+}
+
+static const char sSSE2Message[] =
+ "This browser version requires a processor with the SSE2 instruction "
+ "set extension.\nYou may be able to obtain a version that does not "
+ "require SSE2 from your Linux distribution.\n";
+
+__attribute__((constructor)) static void SSE2Check() {
+ if (IsSSE2Available()) {
+ return;
+ }
+ // Using write() in order to avoid jemalloc-based buffering. Ignoring return
+ // values, since there isn't much we could do on failure and there is no
+ // point in trying to recover from errors.
+ MOZ_UNUSED(
+ write(STDERR_FILENO, sSSE2Message, MOZ_ARRAY_LENGTH(sSSE2Message) - 1));
+ // _exit() instead of exit() to avoid running the usual "at exit" code.
+ _exit(255);
+}
+#endif
+
+#if !defined(MOZ_WIDGET_COCOA) && !defined(MOZ_WIDGET_ANDROID)
+# define MOZ_BROWSER_CAN_BE_CONTENTPROC
+# include "plugin-container.cpp"
+#endif
+
+using namespace mozilla;
+
+#ifdef XP_MACOSX
+# define kOSXResourcesFolder "Resources"
+#endif
+#define kDesktopFolder ""
+
+static MOZ_FORMAT_PRINTF(1, 2) void Output(const char* fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+
+#ifndef XP_WIN
+ vfprintf(stderr, fmt, ap);
+#else
+ char msg[2048];
+ vsnprintf_s(msg, _countof(msg), _TRUNCATE, fmt, ap);
+
+ wchar_t wide_msg[2048];
+ MultiByteToWideChar(CP_UTF8, 0, msg, -1, wide_msg, _countof(wide_msg));
+# if MOZ_WINCONSOLE
+ fwprintf_s(stderr, wide_msg);
+# else
+ // Linking user32 at load-time interferes with the DLL blocklist (bug 932100).
+ // This is a rare codepath, so we can load user32 at run-time instead.
+ HMODULE user32 = LoadLibraryW(L"user32.dll");
+ if (user32) {
+ decltype(MessageBoxW)* messageBoxW =
+ (decltype(MessageBoxW)*)GetProcAddress(user32, "MessageBoxW");
+ if (messageBoxW) {
+ messageBoxW(nullptr, wide_msg, L"Thunderbird",
+ MB_OK | MB_ICONERROR | MB_SETFOREGROUND);
+ }
+ FreeLibrary(user32);
+ }
+# endif
+#endif
+
+ va_end(ap);
+}
+
+/**
+ * Return true if |arg| matches the given argument name.
+ */
+static bool IsArg(const char* arg, const char* s) {
+ if (*arg == '-') {
+ if (*++arg == '-') ++arg;
+ return !strcasecmp(arg, s);
+ }
+
+#if defined(XP_WIN)
+ if (*arg == '/') return !strcasecmp(++arg, s);
+#endif
+
+ return false;
+}
+
+Bootstrap::UniquePtr gBootstrap;
+
+static int do_main(int argc, char* argv[], char* envp[]) {
+ // Allow thunderbird.exe to launch XULRunner apps via -app <application.ini>
+ // Note that -app must be the *first* argument.
+ const char* appDataFile = getenv("XUL_APP_FILE");
+ if ((!appDataFile || !*appDataFile) && (argc > 1 && IsArg(argv[1], "app"))) {
+ if (argc == 2) {
+ Output("Incorrect number of arguments passed to -app");
+ return 255;
+ }
+ appDataFile = argv[2];
+
+ char appEnv[MAXPATHLEN];
+ SprintfLiteral(appEnv, "XUL_APP_FILE=%s", argv[2]);
+ if (putenv(strdup(appEnv))) {
+ Output("Couldn't set %s.\n", appEnv);
+ return 255;
+ }
+ argv[2] = argv[0];
+ argv += 2;
+ argc -= 2;
+ } else if (argc > 1 && IsArg(argv[1], "xpcshell")) {
+ for (int i = 1; i < argc; i++) {
+ argv[i] = argv[i + 1];
+ }
+
+ XREShellData shellData;
+#if defined(XP_WIN) && defined(MOZ_SANDBOX)
+ shellData.sandboxBrokerServices =
+ sandboxing::GetInitializedBrokerServices();
+#endif
+
+ return gBootstrap->XRE_XPCShellMain(--argc, argv, envp, &shellData);
+ }
+
+ BootstrapConfig config;
+
+ if (appDataFile && *appDataFile) {
+ config.appData = nullptr;
+ config.appDataPath = appDataFile;
+ } else {
+ // no -app flag so we use the compiled-in app data
+ config.appData = &sAppData;
+ config.appDataPath = kDesktopFolder;
+ }
+
+#if defined(XP_WIN) && defined(MOZ_SANDBOX)
+ sandbox::BrokerServices* brokerServices =
+ sandboxing::GetInitializedBrokerServices();
+ if (!brokerServices) {
+ Output("Couldn't initialize the broker services.\n");
+ return 255;
+ }
+ config.sandboxBrokerServices = brokerServices;
+#endif
+
+#ifdef LIBFUZZER
+ if (getenv("FUZZER"))
+ gBootstrap->XRE_LibFuzzerSetDriver(fuzzer::FuzzerDriver);
+#endif
+
+ // Note: FF needs to keep in sync with LauncherProcessWin,
+ // TB doesn't have that file.
+ const char* acceptableParams[] = {"compose", "mail", nullptr};
+ EnsureCommandlineSafe(argc, argv, acceptableParams);
+
+ return gBootstrap->XRE_main(argc, argv, config);
+}
+
+static nsresult InitXPCOMGlue(LibLoadingStrategy aLibLoadingStrategy) {
+ if (gBootstrap) {
+ return NS_OK;
+ }
+
+ UniqueFreePtr<char> exePath = BinaryPath::Get();
+ if (!exePath) {
+ Output("Couldn't find the application directory.\n");
+ return NS_ERROR_FAILURE;
+ }
+
+ auto bootstrapResult =
+ mozilla::GetBootstrap(exePath.get(), aLibLoadingStrategy);
+ if (bootstrapResult.isErr()) {
+ Output("Couldn't load XPCOM.\n");
+ return NS_ERROR_FAILURE;
+ }
+
+ gBootstrap = bootstrapResult.unwrap();
+
+ // This will set this thread as the main thread.
+ gBootstrap->NS_LogInit();
+
+ return NS_OK;
+}
+
+#ifdef HAS_DLL_BLOCKLIST
+// NB: This must be extern, as this value is checked elsewhere
+uint32_t gBlocklistInitFlags = eDllBlocklistInitFlagDefault;
+#endif
+
+int main(int argc, char* argv[], char* envp[]) {
+#if defined(MOZ_ENABLE_FORKSERVER)
+ if (strcmp(argv[argc - 1], "forkserver") == 0) {
+ nsresult rv = InitXPCOMGlue(LibLoadingStrategy::NoReadAhead);
+ if (NS_FAILED(rv)) {
+ return 255;
+ }
+
+ // Run a fork server in this process, single thread. When it
+ // returns, it means the fork server have been stopped or a new
+ // content process is created.
+ //
+ // For the later case, XRE_ForkServer() will return false, running
+ // in a content process just forked from the fork server process.
+ // argc & argv will be updated with the values passing from the
+ // chrome process. With the new values, this function
+ // continues the reset of the code acting as a content process.
+ if (gBootstrap->XRE_ForkServer(&argc, &argv)) {
+ // Return from the fork server in the fork server process.
+ // Stop the fork server.
+ gBootstrap->NS_LogTerm();
+ return 0;
+ }
+ // In a content process forked from the fork server.
+ // Start acting as a content process.
+ }
+#endif
+
+ mozilla::TimeStamp start = mozilla::TimeStamp::Now();
+
+ // Make sure we unregister the runtime exception module before returning.
+ // We do this here to cover both registers for child and main processes.
+ auto unregisterRuntimeExceptionModule =
+ MakeScopeExit([] { CrashReporter::UnregisterRuntimeExceptionModule(); });
+
+#ifdef MOZ_BROWSER_CAN_BE_CONTENTPROC
+ // We are launching as a content process, delegate to the appropriate
+ // main
+ if (argc > 1 && IsArg(argv[1], "contentproc")) {
+ // Set the process type. We don't remove the arg here as that will be done
+ // later in common code.
+ SetGeckoProcessType(argv[argc - 1]);
+
+ // Register an external module to report on otherwise uncatchable
+ // exceptions. Note that in child processes this must be called after Gecko
+ // process type has been set.
+ CrashReporter::RegisterRuntimeExceptionModule();
+
+# ifdef HAS_DLL_BLOCKLIST
+ DllBlocklist_Initialize(gBlocklistInitFlags |
+ eDllBlocklistInitFlagIsChildProcess);
+# endif
+# if defined(XP_WIN) && defined(MOZ_SANDBOX)
+ // We need to initialize the sandbox TargetServices before InitXPCOMGlue
+ // because we might need the sandbox broker to give access to some files.
+ if (IsSandboxedProcess() && !sandboxing::GetInitializedTargetServices()) {
+ Output("Failed to initialize the sandbox target services.");
+ return 255;
+ }
+# endif
+# if defined(XP_WIN)
+ // Ideally, we would be able to set our DPI awareness in
+ // thunderbird.exe.manifest Unfortunately, that would cause Win32k calls
+ // when user32.dll gets loaded, which would be incompatible with Win32k
+ // Lockdown. We need to call this after GetInitializedTargetServices
+ // because it can affect the detection of the win32k lockdown status.
+ //
+ // MSDN says that it's allowed-but-not-recommended to initialize DPI
+ // programmatically, as long as it's done before any HWNDs are created.
+ // Thus, we do it almost as soon as we possibly can
+ {
+ auto result = mozilla::WindowsDpiInitialization();
+ (void)result; // Ignore errors since some tools block DPI calls
+ }
+# endif
+
+ nsresult rv = InitXPCOMGlue(LibLoadingStrategy::NoReadAhead);
+ if (NS_FAILED(rv)) {
+ return 255;
+ }
+
+ int result = content_process_main(gBootstrap.get(), argc, argv);
+
+ // InitXPCOMGlue calls NS_LogInit, so we need to balance it here.
+ gBootstrap->NS_LogTerm();
+
+ return result;
+ }
+#endif
+
+ // Register an external module to report on otherwise uncatchable exceptions.
+ CrashReporter::RegisterRuntimeExceptionModule();
+
+#ifdef HAS_DLL_BLOCKLIST
+ DllBlocklist_Initialize(gBlocklistInitFlags);
+#endif
+
+#if defined(XP_WIN)
+
+ // Ideally, we would be able to set our DPI awareness in
+ // thunderbird.exe.manifest Unfortunately, that would cause Win32k calls when
+ // user32.dll gets loaded, which would be incompatible with Win32k Lockdown
+ //
+ // MSDN says that it's allowed-but-not-recommended to initialize DPI
+ // programmatically, as long as it's done before any HWNDs are created.
+ // Thus, we do it almost as soon as we possibly can
+ {
+ auto result = mozilla::WindowsDpiInitialization();
+ (void)result; // Ignore errors since some tools block DPI calls
+ }
+#endif
+
+ nsresult rv = InitXPCOMGlue(LibLoadingStrategy::NoReadAhead);
+ if (NS_FAILED(rv)) {
+ return 255;
+ }
+
+ gBootstrap->XRE_StartupTimelineRecord(mozilla::StartupTimeline::START, start);
+
+#ifdef MOZ_BROWSER_CAN_BE_CONTENTPROC
+ gBootstrap->XRE_EnableSameExecutableForContentProc();
+#endif
+
+ int result = do_main(argc, argv, envp);
+
+ gBootstrap->NS_LogTerm();
+
+#ifdef XP_MACOSX
+ // Allow writes again. While we would like to catch writes from static
+ // destructors to allow early exits to use _exit, we know that there is
+ // at least one such write that we don't control (see bug 826029). For
+ // now we enable writes again and early exits will have to use exit instead
+ // of _exit.
+ gBootstrap->XRE_StopLateWriteChecks();
+#endif
+
+ gBootstrap.reset();
+
+ return result;
+}
diff --git a/comm/mail/app/permissions b/comm/mail/app/permissions
new file mode 100644
index 0000000000..9131807e9a
--- /dev/null
+++ b/comm/mail/app/permissions
@@ -0,0 +1,10 @@
+# This file has default permissions for the permission manager.
+# The file-format is strict:
+# * matchtype \t type \t permission \t host
+# * "origin" should be used for matchtype, "host" is supported for legacy reasons
+# * type is a string that identifies the type of permission (e.g. "cookie")
+# * permission is an integer between 1 and 15
+# See nsPermissionManager.cpp for more...
+
+# XPInstall
+origin install 1 https://addons.thunderbird.net
diff --git a/comm/mail/app/profile/all-thunderbird.js b/comm/mail/app/profile/all-thunderbird.js
new file mode 100644
index 0000000000..cacc159aa9
--- /dev/null
+++ b/comm/mail/app/profile/all-thunderbird.js
@@ -0,0 +1,1428 @@
+#filter dumbComments emptyLines substitution
+
+// 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/.
+
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+#define UNIX_BUT_NOT_MAC
+#endif
+#endif
+
+pref("general.skins.selectedSkin", "classic/1.0");
+
+#ifdef XP_MACOSX
+pref("mail.biff.animate_dock_icon", false);
+#endif
+
+pref("mail.rights.version", 0);
+
+// Don't show the about:rights notification in debug or non-official builds.
+#ifdef DEBUG
+pref("mail.rights.override", true);
+#endif
+#ifndef MOZILLA_OFFICIAL
+pref("mail.rights.override", true);
+#endif
+
+// At startup, should we check to see if the installation
+// date is older than some threshold
+pref("app.update.checkInstallTime", true);
+
+// The number of days a binary is permitted to be old without checking is defined in
+// thunderbird-branding.js (app.update.checkInstallTime.days)
+
+// The minimum delay in seconds for the timer to fire between the notification
+// of each consumer of the timer manager.
+// minimum=30 seconds, default=120 seconds, and maximum=300 seconds
+pref("app.update.timerMinimumDelay", 120);
+
+// The minimum delay in milliseconds for the first firing after startup of the timer
+// to notify consumers of the timer manager.
+// minimum=10 seconds, default=30 seconds, and maximum=120 seconds
+pref("app.update.timerFirstInterval", 30000);
+
+// App-specific update preferences
+
+// The interval to check for updates (app.update.interval) is defined in
+// the branding files.
+
+// Enables some extra Application Update Logging (can reduce performance)
+pref("app.update.log", false);
+// Causes Application Update Logging to be sent to a file in the profile
+// directory. This preference is automatically disabled on application start to
+// prevent it from being left on accidentally. Turning this pref on enables
+// logging, even if app.update.log is false.
+pref("app.update.log.file", false);
+
+// The number of general background check failures to allow before notifying the
+// user of the failure. User initiated update checks always notify the user of
+// the failure.
+pref("app.update.backgroundMaxErrors", 10);
+
+// Ids of the links to the "What's new" update documentation
+pref("app.update.link.updateAvailableWhatsNew", "update-available-whats-new");
+pref("app.update.link.updateManualWhatsNew", "update-manual-whats-new");
+
+// How many times we should let downloads fail before prompting the user to
+// download a fresh installer.
+pref("app.update.download.promptMaxAttempts", 2);
+
+// How many times we should let an elevation prompt fail before prompting the user to
+// download a fresh installer.
+pref("app.update.elevation.promptMaxAttempts", 2);
+
+#ifdef NIGHTLY_BUILD
+ // Whether to delay popup notifications when an update is available and
+ // suppress them when an update is installed and waiting for user to restart.
+ // If set to true, these notifications will immediately be shown as banners in
+ // the app menu and as badges on the app menu button. Update available
+ // notifications will not create popup prompts until a week has passed without
+ // the user installing the update. Update restart notifications will not
+ // create popup prompts at all. This doesn't affect update notifications
+ // triggered by errors/failures or manual install prompts.
+ pref("app.update.suppressPrompts", false);
+#endif
+
+// If set to true, a message will be displayed in the hamburger menu while
+// an update is being downloaded.
+pref("app.update.notifyDuringDownload", false);
+
+// If set to true, the Update Service will automatically download updates when
+// user can apply updates. This pref is no longer used on Windows, except as the
+// default value to migrate to the new location that this data is now stored
+// (which is in a file in the update directory). Because of this, this pref
+// should no longer be used directly. Instead, getAppUpdateAutoEnabled and
+// getAppUpdateAutoEnabled from UpdateUtils.jsm should be used.
+#ifndef XP_WIN
+ pref("app.update.auto", true);
+#endif
+
+// If set to true, the Update Service will apply updates in the background
+// when it finishes downloading them.
+pref("app.update.staging.enabled", true);
+
+// Update service URL:
+// app.update.url was removed in Bug 1630041
+// app.update.url.manual is in branding section
+// app.update.url.details is in branding section
+// app.update.promptWaitTime is in branding section
+
+// Whether or not to attempt using the service for updates.
+#ifdef MOZ_MAINTENANCE_SERVICE
+pref("app.update.service.enabled", true);
+#endif
+
+#ifdef XP_WIN
+// This pref prevents BITS from being used by Thunderbird to download updates.
+pref("app.update.BITS.enabled", false);
+#endif
+
+// Release notes URL
+pref("app.releaseNotesURL", "https://live.thunderbird.net/%APP%/releasenotes?locale=%LOCALE%&version=%VERSION%&channel=%CHANNEL%&os=%OS%&buildid=%APPBUILDID%");
+
+#ifdef XP_MACOSX
+ // If set to true, Thunderbird will automatically restart if it is left
+ // running with no windows open.
+ pref("app.update.noWindowAutoRestart.enabled", true);
+ // How long to wait after all windows are closed before restarting,
+ // in milliseconds. 5 min = 300000 ms.
+ pref("app.update.noWindowAutoRestart.delayMs", 300000);
+#endif
+
+// URL for "Learn More" for DataCollection
+pref("toolkit.datacollection.infoURL",
+ "https://www.mozilla.org/thunderbird/legal/privacy/#telemetry");
+
+// URL for "Learn More" for Crash Reporter.
+pref("toolkit.crashreporter.infoURL",
+ "https://www.mozilla.org/thunderbird/legal/privacy/#crash-reporter");
+
+pref("datareporting.healthreport.uploadEnabled", true); // Required to enable telemetry pings.
+pref("datareporting.healthreport.infoURL", "https://www.mozilla.org/thunderbird/legal/privacy/#health-report");
+
+#ifdef MOZ_DATA_REPORTING
+pref("datareporting.policy.dataSubmissionEnabled", true);
+pref("datareporting.policy.dataSubmissionPolicyAcceptedVersion", 0);
+pref("datareporting.policy.dataSubmissionPolicyBypassNotification", false);
+pref("datareporting.policy.currentPolicyVersion", 2);
+pref("datareporting.policy.firstRunURL", "https://www.mozilla.org/thunderbird/legal/privacy/");
+#endif
+
+// Base URL for web-based support pages.
+pref("app.support.baseURL", "https://support.thunderbird.net/%APP%/%VERSION%/%OS%/%LOCALE%/");
+
+// Base url for web-based feedback pages.
+pref("app.feedback.baseURL", "https://connect.mozilla.org/");
+
+// Allows using Thundebird without a configured email account, blocking the
+// account setup prompt at startup.
+pref("app.use_without_mail_account", false);
+
+// Show error messages in error console.
+pref("javascript.options.showInConsole", true);
+
+#ifdef NIGHTLY_BUILD
+pref("signon.management.page.os-auth.enabled", true);
+#else
+pref("signon.management.page.os-auth.enabled", false);
+#endif
+
+// Controls enabling of the extension system logging (can reduce performance)
+pref("extensions.logging.enabled", false);
+pref("extensions.overlayloader.loglevel", "warn");
+
+pref("extensions.abuseReport.enabled", false);
+
+// Strict compatibility makes add-ons incompatible by default.
+#ifndef RELEASE_OR_BETA
+pref("extensions.strictCompatibility", false);
+#else
+pref("extensions.strictCompatibility", true);
+#endif
+
+pref("extensions.update.autoUpdateDefault", true);
+
+pref("extensions.systemAddon.update.enabled", true); // See bug 1462160.
+
+// Disable add-ons installed into the shared user and shared system areas by
+// default. This does not include the application directory. See the SCOPE
+// constants in AddonManager.jsm for values to use here
+pref("extensions.autoDisableScopes", 15);
+
+// Enable add-ons installed and owned by the application, like the default theme.
+pref("extensions.startupScanScopes", 4);
+
+// Gecko Profiler
+pref("extensions.geckoProfiler.acceptedExtensionIds", "geckoprofiler@mozilla.com,quantum-foxfooding@mozilla.com,raptor@mozilla.org");
+
+// Allow "legacy" XUL/XPCOM extensions.
+pref("extensions.legacy.enabled", true);
+
+// Preferences for AMO integration
+pref("extensions.getAddons.cache.enabled", true);
+pref("extensions.getAddons.maxResults", 15);
+pref("extensions.getAddons.get.url", "https://services.addons.thunderbird.net/api/v3/addons/search/?guid=%IDS%&lang=%LOCALE%");
+pref("extensions.getAddons.compatOverides.url", "https://services.addons.thunderbird.net/api/v3/addons/compat-override/?guid=%IDS%&lang=%LOCALE%");
+pref("extensions.getAddons.link.url", "https://addons.thunderbird.net/%LOCALE%/%APP%/");
+pref("browser.dictionaries.download.url", "https://addons.thunderbird.net/%LOCALE%/%APP%/language-tools/");
+pref("extensions.getAddons.recommended.url", "https://services.addons.thunderbird.net/%LOCALE%/%APP%/api/%API_VERSION%/list/recommended/all/%MAX_RESULTS%/%OS%/%VERSION%?src=thunderbird");
+pref("extensions.getAddons.search.browseURL", "https://addons.thunderbird.net/%LOCALE%/%APP%/search/?q=%TERMS%&appver=%VERSION%&platform=%OS%");
+pref("extensions.getAddons.search.url", "https://services.addons.thunderbird.net/%LOCALE%/%APP%/api/%API_VERSION%/search/%TERMS%/all/%MAX_RESULTS%/%OS%/%VERSION%/%COMPATIBILITY_MODE%?src=thunderbird");
+pref("extensions.webservice.discoverURL", "https://services.addons.thunderbird.net/%LOCALE%/%APP%/discovery/pane/%VERSION%/%OS%");
+pref("extensions.getAddons.langpacks.url", "https://services.addons.thunderbird.net/api/v3/addons/language-tools/?app=thunderbird&type=language&appversion=%VERSION%");
+pref("extensions.getAddons.discovery.api_url", "https://services.addons.thunderbird.net/api/v4/discovery/?lang=%LOCALE%&edition=%DISTRIBUTION%");
+
+// Blocklist preferences
+pref("extensions.blocklist.detailsURL", "https://blocked.cdn.mozilla.net/");
+pref("extensions.blocklist.itemURL", "https://blocked.cdn.mozilla.net/%blockID%.html");
+
+// Remote settings preferences
+pref("services.settings.server", "https://thunderbird-settings.thunderbird.net/v1");
+pref("services.settings.default_bucket", "thunderbird");
+pref("security.content.signature.root_hash", "[CONTENT SIGNING DISABLED - see bug 1612380]");
+
+// Show new install UI with permission lists
+pref("extensions.webextOptionalPermissionPrompts", true);
+
+// 1 = allow "Man In The Middle" (local proxy, web filter, etc.) for certificate
+// pinning checks.
+pref("security.cert_pinning.enforcement_level", 1);
+
+// Whether to use client certificates stored in OS certificate storage.
+// This does not work for S/MIME. See bug 1726442.
+pref("security.osclientcerts.autoload", false);
+
+// Symmetric (can be overridden by individual extensions) update preferences.
+// e.g.
+// extensions.{GUID}.update.enabled
+// extensions.{GUID}.update.url
+// extensions.{GUID}.update.interval
+// .. etc ..
+//
+pref("extensions.update.enabled", true);
+pref("extensions.update.url", "https://versioncheck.addons.thunderbird.net/update/VersionCheck.php?reqVersion=%REQ_VERSION%&id=%ITEM_ID%&version=%ITEM_VERSION%&maxAppVersion=%ITEM_MAXAPPVERSION%&status=%ITEM_STATUS%&appID=%APP_ID%&appVersion=%APP_VERSION%&appOS=%APP_OS%&appABI=%APP_ABI%&locale=%APP_LOCALE%&currentAppVersion=%CURRENT_APP_VERSION%&updateType=%UPDATE_TYPE%&compatMode=%COMPATIBILITY_MODE%");
+
+pref("extensions.update.background.url", "https://versioncheck-bg.addons.thunderbird.net/update/VersionCheck.php?reqVersion=%REQ_VERSION%&id=%ITEM_ID%&version=%ITEM_VERSION%&maxAppVersion=%ITEM_MAXAPPVERSION%&status=%ITEM_STATUS%&appID=%APP_ID%&appVersion=%APP_VERSION%&appOS=%APP_OS%&appABI=%APP_ABI%&locale=%APP_LOCALE%&currentAppVersion=%CURRENT_APP_VERSION%&updateType=%UPDATE_TYPE%&compatMode=%COMPATIBILITY_MODE%");
+
+pref("extensions.update.interval", 86400); // Check for updates to Extensions and
+ // Themes every day
+
+pref("extensions.dss.switchPending", false); // Non-dynamic switch pending after next
+
+// Don't show recommendations on the extension and theme list views.
+pref("extensions.htmlaboutaddons.recommendations.enabled", false);
+// Don't allow content scripts on these web sites
+pref("extensions.webextensions.restrictedDomains", "accounts-static.cdn.mozilla.net,accounts.firefox.com,addons.cdn.mozilla.net,addons.mozilla.org,api.accounts.firefox.com,content.cdn.mozilla.net,discovery.addons.mozilla.org,install.mozilla.org,oauth.accounts.firefox.com,profile.accounts.firefox.com,support.mozilla.org,sync.services.mozilla.com,addons.thunderbird.net");
+
+// Define Thunderbird specific add-on related URLs (not used in toolkit code).
+pref("extensions.canonicalAddonServer.url", "https://addons.thunderbird.net");
+pref("extensions.alternativeAddonSearch.url", "https://extension-finder.thunderbird.net");
+
+pref("lightweightThemes.update.enabled", true);
+
+// Use for in-content pages the theme's toolbar color scheme when none is set in the theme.
+pref("browser.theme.unified-color-scheme", true);
+
+// Built-in default permissions.
+pref("permissions.manager.defaultsUrl", "resource://app/defaults/permissions");
+
+#ifdef UNIX_BUT_NOT_MAC
+pref("general.autoScroll", false);
+#else
+pref("general.autoScroll", true);
+#endif
+
+pref("mail.shell.checkDefaultClient", true);
+pref("mail.spellcheck.inline", true);
+
+pref("mail.folder.views.version", 0);
+
+// Force the unit shown for the size of all folders. If empty, the unit
+// is determined automatically for each folder. Allowed values: KB/MB/<empty string>
+pref("mail.folderpane.sizeUnits", "");
+// Summarize messages count and size of subfolders into a collapsed parent?
+// Allowed values: true/false
+pref("mail.folderpane.sumSubfolders", true);
+
+// target folder URI used for the last move or copy
+pref("mail.last_msg_movecopy_target_uri", "");
+// last move or copy operation was a move
+pref("mail.last_msg_movecopy_was_move", true);
+
+//Set the font color for links to something lighter
+pref("browser.anchor_color", "#0B6CDA");
+
+#ifdef XP_MACOSX
+pref("browser.preferences.animateFadeIn", true);
+#else
+pref("browser.preferences.animateFadeIn", false);
+#endif
+pref("browser.preferences.search", true);
+
+// Whether the results panel should be kept open during IME composition.
+// The default value is false because some IME open a picker panel, and we end
+// up with two panels on top of each other. Since for now we can't detect that
+// we leave this choice to the user, hopefully in the future this can be flipped
+// for everyone.
+pref("browser.urlbar.keepPanelOpenDuringImeComposition", false);
+
+pref("accessibility.typeaheadfind", false);
+pref("accessibility.typeaheadfind.timeout", 5000);
+pref("accessibility.typeaheadfind.linksonly", false);
+pref("accessibility.typeaheadfind.flashBar", 1);
+
+pref("mail.close_message_window.on_delete", false);
+
+// Number of lines of To/CC/BCC address headers to show before "more"
+// truncates the list.
+pref("mailnews.headers.show_n_lines_before_more", 1);
+
+// We want to keep track of what items are appropriate in
+// XULStore.json. We use versioning to scrub out the things
+// that have become obsolete.
+// The value will always be set by startup code and must not be changed
+// here. A value of 0 means a new profile.
+pref("mail.ui-rdf.version", 0);
+
+/////////////////////////////////////////////////////////////////
+// Overrides of the core mailnews.js and composer.js prefs
+/////////////////////////////////////////////////////////////////
+pref("mail.showCondensedAddresses", true); // show the friendly display name for people I know
+
+pref("mailnews.attachments.display.start_expanded", false);
+// hidden pref for changing how we present attachments in the message pane
+pref("mail.pane_config.dynamic", 2);
+pref("mailnews.reuse_thread_window2", true);
+pref("editor.singleLine.pasteNewlines", 4); // substitute commas for new lines in single line text boxes
+pref("editor.CR_creates_new_p", true);
+pref("mail.compose.default_to_paragraph", true);
+
+// If true, when pasting a URL, paste the Open Graph / Twitter Card details
+// we can extract from the URL instead.
+pref("mail.compose.add_link_preview", false);
+
+// hidden pref to ensure a certain number of headers in the message pane
+// to avoid the height of the header area from changing when headers are present / not present
+pref("mailnews.headers.minNumHeaders", 0); // 0 means we ignore this pref
+
+// 0=no header, 1="<author> wrote:", 2="On <date> <author> wrote:"
+// 3="<author> wrote On <date>:", 4=user specified
+pref("mailnews.reply_header_type", 2);
+
+pref("mail.operate_on_msgs_in_collapsed_threads", true);
+pref("mail.warn_on_collapsed_thread_operation", true);
+pref("mail.warn_on_shift_delete", true);
+
+// When using commands like "next message" or "previous message", leave
+// at least this percentage of the thread pane visible above / below the
+// selected message.
+pref("mail.threadpane.padding.top_percent", 10);
+pref("mail.threadpane.padding.bottom_percent", 10);
+
+// Use correspondents column instead of from/recipient columns.
+pref("mail.threadpane.use_correspondents", true);
+
+// To allow images to be inserted into a composition with an auth prompt, we
+// need the following two.
+pref("network.auth.subresource-img-cross-origin-http-auth-allow", true);
+pref("network.auth.non-web-content-triggered-resources-http-auth-allow", true);
+
+// 0=as attachment 2=default forward as inline with attachments
+pref("mail.forward_message_mode", 2);
+
+pref("mailnews.send.loglevel", "Warn");
+
+pref("mail.import.in_new_tab", true);
+
+// End core mailnews.js pref overrides
+/////////////////////////////////////////////////////////////////
+
+/////////////////////////////////////////////////////////////////
+// Overrides for generic app behavior from the core all.js
+/////////////////////////////////////////////////////////////////
+
+pref("browser.hiddenWindowChromeURL", "chrome://messenger/content/hiddenWindowMac.xhtml");
+
+pref("offline.startup_state", 2);
+// 0 Ask before sending unsent messages when going online
+// 1 Always send unsent messages when going online
+// 2 Never send unsent messages when going online
+pref("offline.send.unsent_messages", 0);
+
+// 0 Ask before synchronizing the offline mail store when going offline
+// 1 Always synchronize the offline store when going offline
+// 2 Never synchronize the offline store when going offline
+pref("offline.download.download_messages", 0);
+
+// All platforms can automatically move the user offline or online based on
+// the network connection.
+pref("offline.autoDetect", true);
+
+// Disable preconnect and friends due to privacy concerns. They are not
+// sent through content policies.
+pref("network.http.speculative-parallel-limit", 0);
+
+// Expose only select protocol handlers. All others should go
+// through the external protocol handler route.
+// If you are changing this list, you may need to also consider changing the
+// list in nsMsgContentPolicy::IsExposedProtocol.
+pref("network.protocol-handler.expose-all", false);
+pref("network.protocol-handler.expose.mailto", true);
+pref("network.protocol-handler.expose.mid", true);
+pref("network.protocol-handler.expose.news", true);
+pref("network.protocol-handler.expose.snews", true);
+pref("network.protocol-handler.expose.nntp", true);
+pref("network.protocol-handler.expose.imap", true);
+pref("network.protocol-handler.expose.pop", true);
+pref("network.protocol-handler.expose.mailbox", true);
+// Although we allow these to be exposed internally, there are various places
+// (e.g. message pane) where we may divert them out to external applications.
+pref("network.protocol-handler.expose.about", true);
+pref("network.protocol-handler.expose.blob", true);
+pref("network.protocol-handler.expose.data", true);
+pref("network.protocol-handler.expose.file", true);
+pref("network.protocol-handler.expose.http", true);
+pref("network.protocol-handler.expose.https", true);
+pref("network.protocol-handler.expose.javascript", true);
+pref("network.protocol-handler.expose.moz-extension", true);
+
+// suppress external-load warning for standard browser schemes
+pref("network.protocol-handler.warn-external.http", false);
+pref("network.protocol-handler.warn-external.https", false);
+pref("network.protocol-handler.warn-external.ftp", false);
+
+// prevent web pages from registering mailnews protocol handlers
+pref("network.protocol-handler.external.cid", false);
+pref("network.protocol-handler.external.mid", false);
+pref("network.protocol-handler.external.mailto", false);
+pref("network.protocol-handler.external.imap", false);
+pref("network.protocol-handler.external.imap-message", false);
+pref("network.protocol-handler.external.pop", false);
+pref("network.protocol-handler.external.pop3", false);
+pref("network.protocol-handler.external.mailbox", false);
+pref("network.protocol-handler.external.mailbox-message", false);
+pref("network.protocol-handler.external.smtp", false);
+pref("network.protocol-handler.external.smtps", false);
+pref("network.protocol-handler.external.nntp", false);
+pref("network.protocol-handler.external.news", false);
+pref("network.protocol-handler.external.news-message", false);
+pref("network.protocol-handler.external.snews", false);
+pref("network.protocol-handler.external.ldap", false);
+pref("network.protocol-handler.external.ldaps", false);
+pref("network.protocol-handler.external.webcal", false);
+pref("network.protocol-handler.external.webcals", false);
+pref("network.protocol-handler.external.moz-cal-handle-itip", false);
+pref("network.protocol-handler.external.smile", false);
+
+pref("network.hosts.smtp_server", "mail");
+pref("network.hosts.pop_server", "mail");
+
+// For testing purposes only: Flipping this pref to true allows
+// to skip the assertion that every about page ships with a CSP.
+pref("dom.security.skip_about_page_has_csp_assert", true);
+
+pref("security.warn_entering_secure", false);
+pref("security.warn_entering_weak", false);
+pref("security.warn_leaving_secure", false);
+pref("security.warn_viewing_mixed", false);
+pref("security.aboutcertificate.enabled", true);
+
+// Don't automatically cleanup intermediate CA certificates that core
+// has on a preloaded list, it causes S/MIME failures. (Bug 1777336)
+pref("security.intermediate_preloading_healer.enabled", false);
+
+// Don't show a prompt for external applications.
+pref("security.external_protocol_requires_permission", false);
+
+// Prompt for the primary password prior to opening application windows,
+// to avoid the race that triggers multiple prompts (see bug 177175).
+pref("security.prompt_for_master_password_on_startup", true);
+
+pref("general.config.obscure_value", 0); // for MCD .cfg files
+
+pref("browser.display.auto_quality_min_font_size", 0);
+
+pref("view_source.syntax_highlight", false);
+
+pref("dom.serviceWorkers.enabled", true);
+
+/////////////////////////////////////////////////////////////////
+// End core all.js pref overrides
+/////////////////////////////////////////////////////////////////
+
+/////////////////////////////////////////////////////////////////
+// Generic browser related prefs.
+/////////////////////////////////////////////////////////////////
+pref("browser.send_pings", false);
+pref("browser.xul.error_pages.expert_bad_cert", false);
+
+// Attachment download manager settings
+pref("browser.download.useDownloadDir", false);
+pref("browser.download.folderList", 0);
+pref("browser.download.manager.showAlertOnComplete", false);
+pref("browser.download.manager.showAlertInterval", 2000);
+pref("browser.download.manager.retention", 1);
+pref("browser.download.manager.showWhenStarting", false);
+pref("browser.download.manager.closeWhenDone", true);
+pref("browser.download.manager.focusWhenStarting", false);
+pref("browser.download.manager.flashCount", 0);
+pref("browser.download.manager.addToRecentDocs", true);
+#ifndef XP_MACOSX
+pref("browser.helperApps.deleteTempFileOnExit", true);
+#endif
+
+// Not used in Thunderbird.
+pref("browser.startup.homepage.abouthome_cache.enabled", true);
+
+pref("spellchecker.dictionary", "");
+// Dictionary download preference
+pref("spellchecker.dictionaries.download.url", "https://addons.thunderbird.net/%LOCALE%/%APP%/dictionaries/");
+
+// profile.force.migration can be used to bypass the migration wizard, forcing migration from a particular
+// mail application without any user intervention. Possible values are:
+// seamonkey (mozilla suite) and outlook.
+pref("profile.force.migration", "");
+
+// prefs to control the mail alert notification
+#ifndef XP_MACOSX
+pref("alerts.totalOpenTime", 10000);
+#endif
+
+// Disable new windows notifications until they are fully supported by Thunderbird (bug 1838139).
+#ifdef XP_WIN
+pref("alerts.useSystemBackend", false);
+#endif
+
+// analyze urls in mail messages for scams
+pref("mail.phishing.detection.enabled", true);
+// If phishing detection is enabled, allow fine grained control
+// of the local, static tests
+pref("mail.phishing.detection.ipaddresses", true);
+pref("mail.phishing.detection.mismatched_hosts", true);
+pref("mail.phishing.detection.disallow_form_actions", true);
+
+pref("browser.safebrowsing.reportPhishURL", "https://%LOCALE%.phish-report.mozilla.com/?hl=%LOCALE%");
+
+// prevent status-bar spoofing even if people are foolish enough to turn on JS
+pref("dom.disable_window_status_change", true);
+
+// If a message is opened using Enter or a double click, what should we do?
+// 0 - open it in a new window
+// 1 - open it in an existing window
+// 2 - open it in a new tab
+pref("mail.openMessageBehavior", 2);
+pref("mail.openMessageBehavior.version", 0);
+// If messages or folders are opened using the context menu or a middle click,
+// should we open them in the foreground or in the background?
+pref("mail.tabs.loadInBackground", true);
+
+// Tabs
+pref("mail.tabs.tabMinWidth", 100);
+pref("mail.tabs.tabMaxWidth", 250);
+pref("mail.tabs.tabClipWidth", 140);
+pref("mail.tabs.autoHide", true);
+pref("mail.tabs.closeWindowWithLastTab", true);
+
+// Allow the tabs to be in the titlebar on supported systems
+pref("mail.tabs.drawInTitlebar", true);
+
+// The breakpad report server to link to in about:crashes
+pref("breakpad.reportURL", "https://crash-stats.mozilla.com/report/index/");
+
+// OS Integrated Search and Indexing
+#ifdef XP_WIN
+pref("mail.winsearch.enable", false);
+pref("mail.winsearch.firstRunDone", false);
+#else
+#ifdef XP_MACOSX
+pref("mail.spotlight.enable", false);
+pref("mail.spotlight.firstRunDone", false);
+#endif
+#endif
+
+// -- Windows Search/Spotlight logging options
+#ifdef XP_WIN
+pref("mail.winsearch.loglevel", "Warn");
+#else
+#ifdef XP_MACOSX
+pref("mail.spotlight.loglevel", "Warn");
+#endif
+#endif
+
+// Whether to use a panel that looks like an OS X sheet for customization
+#ifdef XP_MACOSX
+pref("toolbar.customization.usesheet", true);
+#else
+pref("toolbar.customization.usesheet", false);
+#endif
+
+// Start compositions with (empty) attachment pane showing
+pref("mail.compose.show_attachment_pane", false);
+// Check for missing attachments?
+pref("mail.compose.attachment_reminder", true);
+// Words that should trigger a missing attachments warning.
+pref("mail.compose.attachment_reminder_keywords", "chrome://messenger/locale/messengercompose/composeMsgs.properties");
+// When no action is taken on the inline missing attachment notification,
+// show an alert on send?
+pref("mail.compose.attachment_reminder_aggressive", true);
+
+// True if the user should be notified when attaching big files
+pref("mail.compose.big_attachments.notify", true);
+// Size (in kB) to automatically prompt for conversion of attachments to
+// cloud links
+pref("mail.compose.big_attachments.threshold_kb", 5120);
+// True if the user should be notified that links will be inserted into
+// their message when the upload is completed
+pref("mail.compose.big_attachments.insert_notification", true);
+
+// While false, display information about editing sending identity in compose.
+pref("mail.compose.warned_about_customize_from", false);
+
+pref("browser.formfill.enable", true);
+
+// Disable autoplay as we don't handle audio elements in emails very well.
+// See bug 515082.
+pref("media.autoplay.enabled", false);
+
+// whether to hide the timeline view by default in the faceted search display
+pref("gloda.facetview.hidetimeline", true);
+
+// Behavior of sort-by setting in search results:
+// 0 - default to "relevance", and don't remember user setting when it is changed (== old behavior)
+// 1 - default to "date", but don't remember user setting when it is changed
+// 2 - default to "relevance", but remember user preference when it is changed
+// 3 - default to "date", but remember user preference when it is changed
+pref("gloda.facetview.sortby", 2);
+
+// Enable gloda by default!
+pref("mailnews.database.global.indexer.enabled", true);
+// Limit the number of gloda message results
+pref("mailnews.database.global.search.msg.limit", 1000);
+
+// Serif fonts look dated. Switching those language families to sans-serif
+// where we think it makes sense. Worth investigating for other font families
+// as well, viz bug 520824. See all.js for the rest of the font families
+// preferences.
+pref("font.default", "sans-serif");
+pref("font.default.x-unicode", "sans-serif");
+pref("font.default.x-western", "sans-serif");
+pref("font.default.x-cyrillic", "sans-serif");
+pref("font.default.el", "sans-serif");
+
+#ifdef XP_WIN
+pref("font.name.monospace.x-unicode", "Consolas");
+pref("font.name.sans-serif.x-unicode", "Calibri");
+pref("font.name.serif.x-unicode", "Cambria");
+pref("font.size.monospace.x-unicode", 14);
+pref("font.size.variable.x-unicode", 17);
+
+pref("font.name.monospace.x-western", "Consolas");
+pref("font.name.sans-serif.x-western", "Calibri");
+pref("font.name.serif.x-western", "Cambria");
+pref("font.size.monospace.x-western", 14);
+pref("font.size.variable.x-western", 17);
+
+pref("font.name.monospace.x-cyrillic", "Consolas");
+pref("font.name.sans-serif.x-cyrillic", "Calibri");
+pref("font.name.serif.x-cyrillic", "Cambria");
+pref("font.size.monospace.x-cyrillic", 14);
+pref("font.size.variable.x-cyrillic", 17);
+
+pref("font.name.monospace.el", "Consolas");
+pref("font.name.sans-serif.el", "Calibri");
+pref("font.name.serif.el", "Cambria");
+pref("font.size.monospace.el", 14);
+pref("font.size.variable.el", 17);
+
+pref("mail.font.windows.version", 2);
+#endif
+
+#ifdef XP_MACOSX
+pref("font.name.sans-serif.x-unicode", "Lucida Grande");
+pref("font.name.monospace.x-unicode", "Menlo");
+pref("font.name-list.sans-serif.x-unicode", "Lucida Grande");
+pref("font.name-list.monospace.x-unicode", "Menlo, Monaco");
+pref("font.size.variable.x-unicode", 15);
+pref("font.size.monospace.x-unicode", 12);
+
+pref("font.name.sans-serif.x-western", "Lucida Grande");
+pref("font.name.monospace.x-western", "Menlo");
+pref("font.name-list.sans-serif.x-western", "Lucida Grande");
+pref("font.name-list.monospace.x-western", "Menlo, Monaco");
+pref("font.size.variable.x-western", 15);
+pref("font.size.monospace.x-western", 12);
+
+pref("font.name.sans-serif.x-cyrillic", "Lucida Grande");
+pref("font.name.monospace.x-cyrillic", "Menlo");
+pref("font.name-list.sans-serif.x-cyrillic", "Lucida Grande");
+pref("font.name-list.monospace.x-cyrillic", "Menlo, Monaco");
+pref("font.size.variable.x-cyrillic", 15);
+pref("font.size.monospace.x-cyrillic", 12);
+
+pref("font.name.sans-serif.el", "Lucida Grande");
+pref("font.name.monospace.el", "Menlo");
+pref("font.name-list.sans-serif.el", "Lucida Grande");
+pref("font.name-list.monospace.el", "Menlo, Monaco");
+pref("font.size.variable.el", 15);
+pref("font.size.monospace.el", 12);
+#endif
+
+// Since different versions of Windows need different settings, we'll handle
+// this in MailMigrator.jsm.
+
+// Linux, in other words. Other OSes may wish to override.
+#ifdef UNIX_BUT_NOT_MAC
+// The font.name-list fallback is defined in case font.name isn't
+// present -- e.g. in case a profile that's been used on Windows Vista or above
+// is used on Linux.
+pref("font.name-list.serif.x-unicode", "serif");
+pref("font.name-list.sans-serif.x-unicode", "sans-serif");
+pref("font.name-list.monospace.x-unicode", "monospace");
+
+pref("font.name-list.serif.x-western", "serif");
+pref("font.name-list.sans-serif.x-western", "sans-serif");
+pref("font.name-list.monospace.x-western", "monospace");
+
+pref("font.name-list.serif.x-cyrillic", "serif");
+pref("font.name-list.sans-serif.x-cyrillic", "sans-serif");
+pref("font.name-list.monospace.x-cyrillic", "monospace");
+
+pref("font.name-list.serif.el", "serif");
+pref("font.name-list.sans-serif.el", "sans-serif");
+pref("font.name-list.monospace.el", "monospace");
+#endif
+
+pref("mail.setup.loglevel", "Warn");
+
+// Handle links targeting new windows (from within content tabs)
+// These are the values that Firefox can be set to:
+// 0=default window, 1=current window/tab, 2=new window,
+// 3=new tab in most recent window
+//
+// Thunderbird only supports a value of 3. Other values can be set, but are
+// not implemented or supported.
+pref("browser.link.open_newwindow", 3);
+
+// These are the values that Firefox can be set to:
+// 0: no restrictions - divert everything
+// 1: don't divert window.open at all
+// 2: don't divert window.open with features
+//
+// Thunderbird only supports a value of 0. Other values can be set, but are
+// not implemented or supported.
+pref("browser.link.open_newwindow.restriction", 0);
+
+pref("browser.tabs.loadDivertedInBackground", false);
+
+// Enable multi-process.
+pref("browser.tabs.remote.autostart", true);
+pref("browser.tabs.remote.desktopbehavior", true);
+pref("extensions.webextensions.remote", true);
+
+pref("extensions.webextensions.background-delayed-startup", true);
+
+// Browser icon prefs
+pref("browser.chrome.site_icons", true);
+pref("browser.chrome.favicons", true);
+
+// Enable places by default as we want to store global history for visited links
+// Below we define reasonable defaults as copied from Firefox so that we have
+// something sensible.
+pref("places.history.enabled", true);
+
+// the (maximum) number of the recent visits to sample
+// when calculating frecency
+pref("places.frecency.numVisits", 10);
+
+// buckets (in days) for frecency calculation
+pref("places.frecency.firstBucketCutoff", 4);
+pref("places.frecency.secondBucketCutoff", 14);
+pref("places.frecency.thirdBucketCutoff", 31);
+pref("places.frecency.fourthBucketCutoff", 90);
+
+// weights for buckets for frecency calculations
+pref("places.frecency.firstBucketWeight", 100);
+pref("places.frecency.secondBucketWeight", 70);
+pref("places.frecency.thirdBucketWeight", 50);
+pref("places.frecency.fourthBucketWeight", 30);
+pref("places.frecency.defaultBucketWeight", 10);
+
+// bonus (in percent) for visit transition types for frecency calculations
+pref("places.frecency.embedVisitBonus", 0);
+pref("places.frecency.framedLinkVisitBonus", 0);
+pref("places.frecency.linkVisitBonus", 100);
+pref("places.frecency.typedVisitBonus", 2000);
+pref("places.frecency.bookmarkVisitBonus", 75);
+pref("places.frecency.downloadVisitBonus", 0);
+pref("places.frecency.permRedirectVisitBonus", 0);
+pref("places.frecency.tempRedirectVisitBonus", 0);
+pref("places.frecency.reloadVisitBonus", 0);
+pref("places.frecency.defaultVisitBonus", 0);
+
+// bonus (in percent) for place types for frecency calculations
+pref("places.frecency.unvisitedBookmarkBonus", 140);
+pref("places.frecency.unvisitedTypedBonus", 200);
+
+// Enables alternative frecency calculation for origins.
+pref("places.frecency.origins.alternative.featureGate", false);
+
+// The default Places log level.
+pref("places.loglevel", "Error");
+
+// Windows taskbar support
+#ifdef XP_WIN
+pref("mail.taskbar.lists.enabled", true);
+pref("mail.taskbar.lists.tasks.enabled", true);
+#endif
+
+// Account provisioner.
+pref("mail.provider.providerList", "https://broker.thunderbird.net/provider/list");
+pref("mail.provider.suggestFromName", "https://broker.thunderbird.net/provider/suggest");
+pref("mail.provider.enabled", true);
+
+pref("mail.chat.enabled", true);
+// Whether to show chat notifications or not.
+pref("mail.chat.show_desktop_notifications", true);
+// Decide how much information is to be shown in the notification.
+// 0 == Show all info (sender, chat message message preview),
+// 1 == Show sender's info only (not message preview),
+// 2 == No info (fill dummy values).
+pref("mail.chat.notification_info", 0);
+pref("mail.chat.play_sound", true);
+// 0 == default system sound, 1 == user specified wav
+pref("mail.chat.play_sound.type", 0);
+// if sound is user specified, this needs to be a file url
+pref("mail.chat.play_sound.url", "");
+// Enable/Disable support for OTR chat encryption.
+pref("chat.otr.enable", true);
+// Default values for chat account prefs.
+pref("chat.otr.default.requireEncryption", false);
+pref("chat.otr.default.verifyNudge", true);
+pref("chat.otr.default.allowMsgLog", true);
+
+// BigFiles
+pref("mail.cloud_files.enabled", true);
+pref("mail.cloud_files.learn_more_url", "https://support.thunderbird.net/kb/filelink-large-attachments");
+
+// Ignore threads
+pref("mail.ignore_thread.learn_more_url", "https://support.thunderbird.net/kb/ignore-threads");
+
+// Density control for the entire Thunderbird UI.
+// The possible values are 0=compact, 1=normal, 2=touch.
+pref("mail.uidensity", 1);
+
+// Font size control for the entire Thunderbird UI. The value represents the
+// pixel value which will be applied as inline style to the root element of the
+// page (e.g.: 14 = font-size: 14px)
+pref("mail.uifontsize", 0);
+
+// Sanitize dialog window
+pref("privacy.cpd.history", true);
+pref("privacy.cpd.cookies", true);
+pref("privacy.cpd.cache", true);
+
+// What default should we use for the time span in the sanitizer:
+// 0 - Clear everything
+// 1 - Last Hour
+// 2 - Last 2 Hours
+// 3 - Last 4 Hours
+// 4 - Today
+pref("privacy.sanitize.timeSpan", 1);
+
+// Enable Contextual Identity Containers
+pref("privacy.userContext.enabled", false);
+
+// Set to true to add toggles to the WebRTC indicator for globally
+// muting the camera and microphone.
+pref("privacy.webrtc.globalMuteToggles", false);
+
+// If set to true, Thunderbird will collapse the main menu for new profiles
+// (or, more precisely, profiles that start with no accounts created).
+pref("mail.main_menu.collapse_by_default", true);
+
+// If set to true, when saving a message to a file, use underscore
+// instead of space in the file name.
+pref("mail.save_msg_filename_underscores_for_space", false);
+
+#ifdef NIGHTLY_BUILD
+// See bug 1572568 for details. Disallow eval() with system principal.
+pref("security.allow_eval_with_system_principal", false);
+#endif
+
+// Enable FIDO U2F
+pref("security.webauth.u2f", true);
+
+// Use OS date and time settings by default.
+pref("intl.regional_prefs.use_os_locales", true);
+
+// Multi-lingual preferences:
+// *.enabled - Are langpacks available for the build of Firefox?
+// *.downloadEnabled - Langpacks are allowed to be downloaded from ATN. ATN only serves
+// langpacks for release and beta. There is no release-only define, so we also enable
+// it for beta.
+// *.liveReload - Switching a langpack will change the language without a restart.
+// *.liveReloadBidirectional - Allows switching when moving between LTR and RTL
+// languages without a full restart.
+pref("intl.multilingual.enabled", true);
+#if defined(RELEASE_OR_BETA)
+pref("intl.multilingual.downloadEnabled", true);
+pref("intl.multilingual.liveReload", false);
+pref("intl.multilingual.liveReloadBidirectional", false);
+#else
+pref("intl.multilingual.downloadEnabled", false);
+pref("intl.multilingual.liveReload", false);
+pref("intl.multilingual.liveReloadBidirectional", false);
+#endif
+
+// if true, use full page zoom instead of text zoom
+pref("browser.zoom.full", true);
+
+pref("toolkit.osKeyStore.loglevel", "Warn");
+
+// Developer Tools related preferences
+pref("devtools.chrome.enabled", true);
+pref("devtools.debugger.remote-enabled", true);
+pref("devtools.selfxss.count", 5);
+// Enable extensionStorage storage actor by default
+pref("devtools.storage.extensionStorage.enabled", true);
+
+// Toolbox preferences
+pref("devtools.toolbox.footer.height", 250);
+pref("devtools.toolbox.sidebar.width", 500);
+pref("devtools.toolbox.host", "bottom");
+pref("devtools.toolbox.previousHost", "right");
+pref("devtools.toolbox.selectedTool", "inspector");
+pref("devtools.toolbox.sideEnabled", true);
+pref("devtools.toolbox.zoomValue", "1");
+pref("devtools.toolbox.splitconsoleEnabled", false);
+pref("devtools.toolbox.splitconsoleHeight", 100);
+pref("devtools.toolbox.tabsOrder", "");
+pref("devtools.netmonitor.features.newEditAndResend", false);
+
+// The fission pref for enabling the "Multiprocess Browser Toolbox", which will
+// make it possible to debug anything in Firefox (See Bug 1570639 for more
+// information).
+#if defined(NIGHTLY_BUILD)
+pref("devtools.browsertoolbox.fission", true);
+#else
+pref("devtools.browsertoolbox.fission", false);
+#endif
+
+// When the Multiprocess Browser Toolbox is enabled, you can configure the scope of it:
+// - "everything" will enable debugging absolutely everything in the browser
+// All processes, all documents, all workers, all add-ons.
+// - "parent-process" will restrict debugging to the parent process
+// All privileged javascript, documents and workers running in the parent process.
+pref("devtools.browsertoolbox.scope", "everything");
+
+// Toolbox Button preferences
+pref("devtools.command-button-pick.enabled", true);
+pref("devtools.command-button-frames.enabled", true);
+pref("devtools.command-button-splitconsole.enabled", true);
+pref("devtools.command-button-responsive.enabled", true);
+pref("devtools.command-button-screenshot.enabled", false);
+pref("devtools.command-button-rulers.enabled", false);
+pref("devtools.command-button-measure.enabled", false);
+pref("devtools.command-button-noautohide.enabled", false);
+pref("devtools.command-button-errorcount.enabled", true);
+#ifndef MOZILLA_OFFICIAL
+ pref("devtools.command-button-experimental-prefs.enabled", true);
+#endif
+
+// Inspector preferences
+// Enable the Inspector
+pref("devtools.inspector.enabled", true);
+// What was the last active sidebar in the inspector
+pref("devtools.inspector.selectedSidebar", "layoutview");
+pref("devtools.inspector.activeSidebar", "layoutview");
+pref("devtools.inspector.remote", false);
+
+// Enable the 3 pane mode in the inspector
+pref("devtools.inspector.three-pane-enabled", true);
+// Enable the 3 pane mode in the chrome inspector
+pref("devtools.inspector.chrome.three-pane-enabled", false);
+// Collapse pseudo-elements by default in the rule-view
+pref("devtools.inspector.show_pseudo_elements", false);
+// The default size for image preview tooltips in the rule-view/computed-view/markup-view
+pref("devtools.inspector.imagePreviewTooltipSize", 300);
+// Enable user agent style inspection in rule-view
+pref("devtools.inspector.showUserAgentStyles", false);
+// Show native anonymous content and user agent shadow roots
+pref("devtools.inspector.showAllAnonymousContent", false);
+// Enable the inline CSS compatibility warning in inspector rule view
+pref("devtools.inspector.ruleview.inline-compatibility-warning.enabled", false);
+// Enable the compatibility tool in the inspector.
+pref("devtools.inspector.compatibility.enabled", true);
+// Enable color scheme simulation in the inspector.
+pref("devtools.inspector.color-scheme-simulation.enabled", true);
+
+// Grid highlighter preferences
+pref("devtools.gridinspector.gridOutlineMaxColumns", 50);
+pref("devtools.gridinspector.gridOutlineMaxRows", 50);
+pref("devtools.gridinspector.showGridAreas", false);
+pref("devtools.gridinspector.showGridLineNumbers", false);
+pref("devtools.gridinspector.showInfiniteLines", false);
+// Max number of grid highlighters that can be displayed
+pref("devtools.gridinspector.maxHighlighters", 3);
+
+// Whether or not simplified highlighters should be used when
+// prefers-reduced-motion is enabled.
+pref("devtools.inspector.simple-highlighters-reduced-motion", false);
+
+// Whether or not the box model panel is opened in the layout view
+pref("devtools.layout.boxmodel.opened", true);
+// Whether or not the flexbox panel is opened in the layout view
+pref("devtools.layout.flexbox.opened", true);
+// Whether or not the flexbox container panel is opened in the layout view
+pref("devtools.layout.flex-container.opened", true);
+// Whether or not the flexbox item panel is opened in the layout view
+pref("devtools.layout.flex-item.opened", true);
+// Whether or not the grid inspector panel is opened in the layout view
+pref("devtools.layout.grid.opened", true);
+
+// Enable hovering Box Model values and jumping to their source CSS rule in the
+// rule-view.
+#if defined(NIGHTLY_BUILD)
+ pref("devtools.layout.boxmodel.highlightProperty", true);
+#else
+ pref("devtools.layout.boxmodel.highlightProperty", false);
+#endif
+
+// By how many times eyedropper will magnify pixels
+pref("devtools.eyedropper.zoom", 6);
+
+// Enable to collapse attributes that are too long.
+pref("devtools.markup.collapseAttributes", true);
+// Length to collapse attributes
+pref("devtools.markup.collapseAttributeLength", 120);
+// Whether to auto-beautify the HTML on copy.
+pref("devtools.markup.beautifyOnCopy", false);
+// Whether or not the DOM mutation breakpoints context menu are enabled in the
+// markup view.
+pref("devtools.markup.mutationBreakpoints.enabled", true);
+
+// DevTools default color unit
+pref("devtools.defaultColorUnit", "authored");
+
+// Enable the Memory tools
+pref("devtools.memory.enabled", true);
+
+pref("devtools.memory.custom-census-displays", "{}");
+pref("devtools.memory.custom-label-displays", "{}");
+pref("devtools.memory.custom-tree-map-displays", "{}");
+
+pref("devtools.memory.max-individuals", 1000);
+pref("devtools.memory.max-retaining-paths", 10);
+
+// Enable the Performance tools
+pref("devtools.performance.enabled", true);
+// But not the pop-up.
+pref("devtools.performance.popup.feature-flag", false);
+// Override the default preset, which is "web-developer" on beta and release.
+pref("devtools.performance.recording.preset", "firefox-platform");
+pref("devtools.performance.recording.preset.remote", "firefox-platform");
+
+// The default cache UI setting
+pref("devtools.cache.disabled", false);
+
+// The default service workers UI setting
+pref("devtools.serviceWorkers.testing.enabled", false);
+
+// Enable the Network Monitor
+pref("devtools.netmonitor.enabled", true);
+
+pref("devtools.netmonitor.features.search", true);
+pref("devtools.netmonitor.features.requestBlocking", true);
+
+// Enable the Application panel
+pref("devtools.application.enabled", false);
+
+// Enable the custom formatters feature
+// TODO remove once the custom formatters feature is stable (see bug 1734614)
+pref("devtools.custom-formatters", false);
+// This preference represents the user's choice to enable the custom formatters feature.
+// While the preference above will be removed once the feature is stable, this one is menat to stay.
+pref("devtools.custom-formatters.enabled", false);
+
+// The default Network Monitor UI settings
+pref("devtools.netmonitor.panes-network-details-width", 550);
+pref("devtools.netmonitor.panes-network-details-height", 450);
+pref("devtools.netmonitor.panes-search-width", 550);
+pref("devtools.netmonitor.panes-search-height", 450);
+pref("devtools.netmonitor.filters", "[\"all\"]");
+pref("devtools.netmonitor.visibleColumns",
+ "[\"status\",\"method\",\"domain\",\"file\",\"initiator\",\"type\",\"transferred\",\"contentSize\",\"waterfall\"]"
+);
+pref("devtools.netmonitor.columnsData",
+ '[{"name":"status","minWidth":30,"width":5}, {"name":"method","minWidth":30,"width":5}, {"name":"domain","minWidth":30,"width":10}, {"name":"file","minWidth":30,"width":25}, {"name":"url","minWidth":30,"width":25},{"name":"initiator","minWidth":30,"width":10},{"name":"type","minWidth":30,"width":5},{"name":"transferred","minWidth":30,"width":10},{"name":"contentSize","minWidth":30,"width":5},{"name":"waterfall","minWidth":150,"width":15}]');
+pref("devtools.netmonitor.msg.payload-preview-height", 128);
+pref("devtools.netmonitor.msg.visibleColumns",
+ '["data", "time"]'
+);
+pref("devtools.netmonitor.msg.displayed-messages.limit", 500);
+
+pref("devtools.netmonitor.response.ui.limit", 10240);
+
+// Save request/response bodies yes/no.
+pref("devtools.netmonitor.saveRequestAndResponseBodies", true);
+
+// The default Network monitor HAR export setting
+pref("devtools.netmonitor.har.defaultLogDir", "");
+pref("devtools.netmonitor.har.defaultFileName", "%hostname_Archive [%date]");
+pref("devtools.netmonitor.har.jsonp", false);
+pref("devtools.netmonitor.har.jsonpCallback", "");
+pref("devtools.netmonitor.har.includeResponseBodies", true);
+pref("devtools.netmonitor.har.compress", false);
+pref("devtools.netmonitor.har.forceExport", false);
+pref("devtools.netmonitor.har.pageLoadedTimeout", 1500);
+pref("devtools.netmonitor.har.enableAutoExportToFile", false);
+
+pref("devtools.netmonitor.features.webSockets", true);
+
+// netmonitor audit
+pref("devtools.netmonitor.audits.slow", 500);
+
+// Disable the EventSource Inspector.
+pref("devtools.netmonitor.features.serverSentEvents", false);
+
+// Enable the Storage Inspector
+pref("devtools.storage.enabled", true);
+
+// Enable the Style Editor.
+pref("devtools.styleeditor.enabled", true);
+pref("devtools.styleeditor.autocompletion-enabled", true);
+pref("devtools.styleeditor.showMediaSidebar", true);
+pref("devtools.styleeditor.mediaSidebarWidth", 238);
+pref("devtools.styleeditor.navSidebarWidth", 245);
+pref("devtools.styleeditor.transitions", true);
+
+// Screenshot Option Settings.
+pref("devtools.screenshot.clipboard.enabled", false);
+pref("devtools.screenshot.audio.enabled", true);
+
+// Make sure the DOM panel is hidden by default
+pref("devtools.dom.enabled", false);
+
+// Enable the Accessibility panel.
+pref("devtools.accessibility.enabled", true);
+
+// Web console filters
+pref("devtools.webconsole.filter.error", true);
+pref("devtools.webconsole.filter.warn", true);
+pref("devtools.webconsole.filter.info", true);
+pref("devtools.webconsole.filter.log", true);
+pref("devtools.webconsole.filter.debug", true);
+pref("devtools.webconsole.filter.css", false);
+pref("devtools.webconsole.filter.net", false);
+pref("devtools.webconsole.filter.netxhr", false);
+
+// Webconsole autocomplete preference
+pref("devtools.webconsole.input.autocomplete",true);
+#ifdef NIGHTLY_BUILD
+ pref("devtools.webconsole.input.context", true);
+#else
+ pref("devtools.webconsole.input.context", false);
+#endif
+
+// Set to true to eagerly show the results of webconsole terminal evaluations
+// when they don't have side effects.
+pref("devtools.webconsole.input.eagerEvaluation", true);
+
+// Browser console filters
+pref("devtools.browserconsole.filter.error", true);
+pref("devtools.browserconsole.filter.warn", true);
+pref("devtools.browserconsole.filter.info", true);
+pref("devtools.browserconsole.filter.log", true);
+pref("devtools.browserconsole.filter.debug", true);
+pref("devtools.browserconsole.filter.css", false);
+pref("devtools.browserconsole.filter.net", false);
+pref("devtools.browserconsole.filter.netxhr", false);
+
+// Max number of inputs to store in web console history.
+pref("devtools.webconsole.inputHistoryCount", 300);
+
+// Persistent logging: |true| if you want the relevant tool to keep all of the
+// logged messages after reloading the page, |false| if you want the output to
+// be cleared each time page navigation happens.
+pref("devtools.webconsole.persistlog", false);
+pref("devtools.netmonitor.persistlog", false);
+
+// Web Console timestamp: |true| if you want the logs and instructions
+// in the Web Console to display a timestamp, or |false| to not display
+// any timestamps.
+pref("devtools.webconsole.timestampMessages", false);
+
+// Enable the webconsole sidebar toggle in Nightly builds.
+#if defined(NIGHTLY_BUILD)
+ pref("devtools.webconsole.sidebarToggle", true);
+#else
+ pref("devtools.webconsole.sidebarToggle", false);
+#endif
+
+// Saved editor mode state in the console.
+pref("devtools.webconsole.input.editor", false);
+pref("devtools.browserconsole.input.editor", false);
+
+// Editor width for webconsole and browserconsole.
+pref("devtools.webconsole.input.editorWidth", 0);
+pref("devtools.browserconsole.input.editorWidth", 0);
+
+// Display an onboarding UI for the Editor mode.
+pref("devtools.webconsole.input.editorOnboarding", true);
+
+// Enable message grouping in the console, true by default
+pref("devtools.webconsole.groupWarningMessages", true);
+
+// Saved state of the Display content messages checkbox in the browser console.
+pref("devtools.browserconsole.contentMessages", true);
+
+// Enable network monitoring the browser toolbox console/browser console.
+pref("devtools.browserconsole.enableNetworkMonitoring", false);
+
+// Enable client-side mapping service for source maps
+pref("devtools.source-map.client-service.enabled", true);
+
+// The number of lines that are displayed in the web console.
+pref("devtools.hud.loglimit", 10000);
+
+// The developer tools editor configuration:
+// - tabsize: how many spaces to use when a Tab character is displayed.
+// - expandtab: expand Tab characters to spaces.
+// - keymap: which keymap to use (can be 'default', 'emacs' or 'vim')
+// - autoclosebrackets: whether to permit automatic bracket/quote closing.
+// - detectindentation: whether to detect the indentation from the file
+// - enableCodeFolding: Whether to enable code folding or not.
+pref("devtools.editor.tabsize", 2);
+pref("devtools.editor.expandtab", true);
+pref("devtools.editor.keymap", "default");
+pref("devtools.editor.autoclosebrackets", true);
+pref("devtools.editor.detectindentation", true);
+pref("devtools.editor.enableCodeFolding", true);
+pref("devtools.editor.autocomplete", true);
+
+// The angle of the viewport.
+pref("devtools.responsive.viewport.angle", 0);
+// The width of the viewport.
+pref("devtools.responsive.viewport.width", 320);
+// The height of the viewport.
+pref("devtools.responsive.viewport.height", 480);
+// The pixel ratio of the viewport.
+pref("devtools.responsive.viewport.pixelRatio", 0);
+// Whether or not the viewports are left aligned.
+pref("devtools.responsive.leftAlignViewport.enabled", false);
+// Whether to reload when touch simulation is toggled
+pref("devtools.responsive.reloadConditions.touchSimulation", false);
+// Whether to reload when user agent is changed
+pref("devtools.responsive.reloadConditions.userAgent", false);
+// Whether to show the notification about reloading to apply emulation
+pref("devtools.responsive.reloadNotification.enabled", true);
+// Whether or not touch simulation is enabled.
+pref("devtools.responsive.touchSimulation.enabled", false);
+// The user agent of the viewport.
+pref("devtools.responsive.userAgent", "");
+
+// Show the custom user agent input in Nightly builds.
+#if defined(NIGHTLY_BUILD)
+ pref("devtools.responsive.showUserAgentInput", true);
+#else
+ pref("devtools.responsive.showUserAgentInput", false);
+#endif
+
+// Show tab debug targets for This Firefox (on by default for local builds).
+#ifdef MOZILLA_OFFICIAL
+ pref("devtools.aboutdebugging.local-tab-debugging", false);
+#else
+ pref("devtools.aboutdebugging.local-tab-debugging", true);
+#endif
+
+// Show process debug targets.
+pref("devtools.aboutdebugging.process-debugging", true);
+// Stringified array of network locations that users can connect to.
+pref("devtools.aboutdebugging.network-locations", "[]");
+// Debug target pane collapse/expand settings.
+pref("devtools.aboutdebugging.collapsibilities.installedExtension", false);
+pref("devtools.aboutdebugging.collapsibilities.otherWorker", false);
+pref("devtools.aboutdebugging.collapsibilities.serviceWorker", false);
+pref("devtools.aboutdebugging.collapsibilities.sharedWorker", false);
+pref("devtools.aboutdebugging.collapsibilities.tab", false);
+pref("devtools.aboutdebugging.collapsibilities.temporaryExtension", false);
+
+// about:debugging: only show system and hidden extensions in local builds by
+// default.
+#ifdef MOZILLA_OFFICIAL
+ pref("devtools.aboutdebugging.showHiddenAddons", false);
+#else
+ pref("devtools.aboutdebugging.showHiddenAddons", true);
+#endif
+
+// Map top-level await expressions in the console
+pref("devtools.debugger.features.map-await-expression", true);
+
+// This relies on javascript.options.asyncstack as well or it has no effect.
+pref("devtools.debugger.features.async-captured-stacks", true);
+pref("devtools.debugger.features.async-live-stacks", false);
+
+// Disable autohide for DevTools popups and tooltips.
+// This is currently not exposed by any UI to avoid making
+// about:devtools-toolbox tabs unusable by mistake.
+pref("devtools.popup.disable_autohide", false);
+
+// Enable overflow debugging in the inspector.
+pref("devtools.overflow.debugging.enabled", true);
+// Enable drag to edit properties in the inspector rule view.
+pref("devtools.inspector.draggable_properties", true);
+
+// Telemetry settings.
+
+// Server to submit telemetry pings to.
+pref("toolkit.telemetry.server", "https://incoming-telemetry.thunderbird.net");
+pref("toolkit.telemetry.server_owner", "Thunderbird");
+
+// Determines if Telemetry pings can be archived locally.
+pref("toolkit.telemetry.archive.enabled", true);
+// Enables sending the shutdown ping when Thunderbird shuts down.
+pref("toolkit.telemetry.shutdownPingSender.enabled", true);
+// Enables sending the shutdown ping using the pingsender from the first session.
+pref("toolkit.telemetry.shutdownPingSender.enabledFirstSession", false);
+// Enables sending a duplicate of the first shutdown ping from the first session.
+pref("toolkit.telemetry.firstShutdownPing.enabled", true);
+// Enables sending the 'new-profile' ping on new profiles.
+pref("toolkit.telemetry.newProfilePing.enabled", true);
+// Enables sending 'update' pings on Thunderbird updates.
+pref("toolkit.telemetry.updatePing.enabled", true);
+// Enables sending 'bhr' pings when the app hangs.
+pref("toolkit.telemetry.bhrPing.enabled", true);
+// Whether to enable Ecosystem Telemetry, requires a restart.
+#ifdef NIGHTLY_BUILD
+ pref("toolkit.telemetry.ecosystemtelemetry.enabled", true);
+#else
+ pref("toolkit.telemetry.ecosystemtelemetry.enabled", false);
+#endif
+
+#ifdef XP_WIN
+pref("mail.minimizeToTray", false);
+#endif
+
+pref("prompts.defaultModalType", 3);
+pref("prompts.contentPromptSubDialog", false);
+
+// The URL for the privacy policy related to recommended extensions.
+pref("extensions.recommendations.privacyPolicyUrl", "https://www.mozilla.org/en-US/privacy/thunderbird/#addons");
+
+// Used by pdf.js to know the first time Thunderbird is run with it installed
+// so it can become the default pdf viewer.
+pref("pdfjs.firstRun", true);
+// The values of preferredAction and alwaysAskBeforeHandling before pdf.js
+// became the default.
+pref("pdfjs.previousHandler.preferredAction", 0);
+pref("pdfjs.previousHandler.alwaysAskBeforeHandling", false);
+
+pref("mail.activity.loglevel", "Warn");
+
+// The number of public recipients before we offer BCC addressing.
+pref("mail.compose.warn_public_recipients.threshold", 15);
+
+// Indicates whether to show an alert before send if no action taken while the
+// too many public recipients notification is shown.
+pref("mail.compose.warn_public_recipients.aggressive", false);
+
+// The URL of most things that can be printed is useless information.
+// Hide it and move the title to the center.
+pref("print.print_headerleft", "");
+pref("print.print_headercenter", "&T");
+pref("print.print_headerright", "");
+
+// Enable Masonry Layout for AddressBook.
+pref("layout.css.grid-template-masonry-value.enabled", true);
+
+#ifdef NIGHTLY_BUILD
+// If set to false, FxAccounts and Sync will be unavailable.
+// A restart is mandatory after flipping that preference.
+pref("identity.fxaccounts.enabled", true);
+pref("identity.fxaccounts.log.sensitive", true);
+pref("services.sync.log.appender.console", "Info");
+// Auto-config URL for FxA self-hosters, makes an HTTP request to
+// [identity.fxaccounts.autoconfig.uri]/.well-known/fxa-client-configuration
+// This is now the prefered way of pointing to a custom FxA server, instead
+// of making changes to "identity.fxaccounts.*.uri".
+pref("identity.fxaccounts.autoconfig.uri", "https://accounts.stage.mozaws.net");
+// The remote FxA root content URL. Must use HTTPS.
+pref("identity.fxaccounts.remote.root", "https://accounts.stage.mozaws.net");
+// The value of the context query parameter passed in FxA requests.
+pref("identity.fxaccounts.contextParam", "fx_desktop_v3");
+// Token server used by the FxA Sync identity.
+pref("identity.sync.tokenserver.uri", "https://token.stage.mozaws.net/1.0/sync/1.5");
+// Adds stage server to the white list, because we need it.
+pref("webchannel.allowObject.urlWhitelist", "https://content.cdn.mozilla.net https://support.mozilla.org https://install.mozilla.org https://accounts.stage.mozaws.net");
+// Adds Firefox/10x.0 to the User-Agent string, because we need it.
+// TODO: Fix this.
+pref("general.useragent.compatMode.firefox", true);
+
+// Enable the sync engines we want, and disable the ones we don't want.
+pref("services.sync.engine.accounts", true);
+pref("services.sync.engine.addons", false);
+pref("services.sync.engine.addressbooks", true);
+pref("services.sync.engine.addresses", false);
+pref("services.sync.engine.calendars", true);
+pref("services.sync.engine.creditcards", false);
+pref("services.sync.engine.identities", true);
+pref("services.sync.engine.prefs", false);
+#endif
+
+// Donation appeal.
+pref("app.donation.eoy.version", 4);
+pref("app.donation.eoy.version.viewed", 0);
+pref("app.donation.eoy.url", "https://www.thunderbird.net/thunderbird/115.0/holidayeoy/");
+
+// IMAP-JS disabled, Bug 1707547.
+pref("mailnews.imap.jsmodule", false);
+
+// Unified toolbar
+
+// 0: icons beside text
+// 1: icons above text
+// 2: icons only
+// 3: text only
+pref("toolbar.unifiedtoolbar.buttonstyle", 0);
diff --git a/comm/mail/app/profile/channel-prefs.js b/comm/mail/app/profile/channel-prefs.js
new file mode 100644
index 0000000000..633c489f3c
--- /dev/null
+++ b/comm/mail/app/profile/channel-prefs.js
@@ -0,0 +1,5 @@
+/* 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/. */
+
+pref("app.update.channel", "@MOZ_UPDATE_CHANNEL@");
diff --git a/comm/mail/app/settings/dumps/moz.build b/comm/mail/app/settings/dumps/moz.build
new file mode 100644
index 0000000000..6d7b3b432c
--- /dev/null
+++ b/comm/mail/app/settings/dumps/moz.build
@@ -0,0 +1,7 @@
+# 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/.
+
+DIRS += [
+ "thunderbird",
+]
diff --git a/comm/mail/app/settings/dumps/thunderbird/anti-tracking-url-decoration.json b/comm/mail/app/settings/dumps/thunderbird/anti-tracking-url-decoration.json
new file mode 100644
index 0000000000..dd336cbd96
--- /dev/null
+++ b/comm/mail/app/settings/dumps/thunderbird/anti-tracking-url-decoration.json
@@ -0,0 +1,11 @@
+{
+ "data": [
+ {
+ "token": "fbclid",
+ "schema": 1564436129080,
+ "id": "60e82333-914d-4cfa-95b1-5f034b5a704b",
+ "last_modified": 1564511755134
+ }
+ ],
+ "timestamp": 1564511755134
+}
diff --git a/comm/mail/app/settings/dumps/thunderbird/hijack-blocklists.json b/comm/mail/app/settings/dumps/thunderbird/hijack-blocklists.json
new file mode 100644
index 0000000000..f00e73aebe
--- /dev/null
+++ b/comm/mail/app/settings/dumps/thunderbird/hijack-blocklists.json
@@ -0,0 +1,59 @@
+{
+ "data": [
+ {
+ "schema": 1605793537508,
+ "matches": [
+ "[https]opensearch.startpageweb.com/bing-search.xml",
+ "[https]opensearch.startwebsearch.com/bing-search.xml",
+ "[https]opensearch.webstartsearch.com/bing-search.xml",
+ "[https]opensearch.webofsearch.com/bing-search.xml",
+ "[profile]/searchplugins/Yahoo! Powered.xml",
+ "[profile]/searchplugins/yahoo! powered.xml",
+ "[profile]/searchplugins/bing-lavasoft-ff59.xml"
+ ],
+ "id": "load-paths",
+ "last_modified": 1626756455894
+ },
+ {
+ "schema": 1605793528951,
+ "matches": [
+ "hspart=lvs",
+ "pc=COS",
+ "clid=2308146",
+ "fr=mca",
+ "PC=MC0",
+ "lavasoft.gosearchresults",
+ "securedsearch.lavasoft",
+ "fr=mcsaoffblock",
+ "fr=jnazafzv",
+ "clid=2285101",
+ "pc=mc",
+ "//defaultsearch.co/",
+ "//searchdefault.co/"
+ ],
+ "id": "submission-urls",
+ "last_modified": 1626756429780
+ },
+ {
+ "schema": 1605728369453,
+ "matches": [
+ "hspart=lvs",
+ "pc=COS",
+ "clid=2308146",
+ "fr=mca",
+ "PC=MC0",
+ "lavasoft.gosearchresults",
+ "securedsearch.lavasoft",
+ "fr=mcsaoffblock",
+ "fr=jnazafzv",
+ "clid=2285101",
+ "pc=mc",
+ "//defaultsearch.co/",
+ "//searchdefault.co/"
+ ],
+ "id": "homepage-urls",
+ "last_modified": 1626756283026
+ }
+ ],
+ "timestamp": 1626756455894
+}
diff --git a/comm/mail/app/settings/dumps/thunderbird/moz.build b/comm/mail/app/settings/dumps/thunderbird/moz.build
new file mode 100644
index 0000000000..b3535c982e
--- /dev/null
+++ b/comm/mail/app/settings/dumps/thunderbird/moz.build
@@ -0,0 +1,16 @@
+# 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/.
+
+# Data obtained by
+# SERVER="https://thunderbird-settings.thunderbird.net/v1"
+# wget -qO- "$SERVER/buckets/thunderbird/collections/search-config/changeset?_expected=0" | \
+# jq '{"data": .changes, "timestamp": .timestamp}'
+
+FINAL_TARGET_FILES.defaults.settings.thunderbird += [
+ "anti-tracking-url-decoration.json",
+ "hijack-blocklists.json",
+ "password-recipes.json",
+ "search-config.json",
+ "url-classifier-skip-urls.json",
+]
diff --git a/comm/mail/app/settings/dumps/thunderbird/password-recipes.json b/comm/mail/app/settings/dumps/thunderbird/password-recipes.json
new file mode 100644
index 0000000000..6e31aa8c12
--- /dev/null
+++ b/comm/mail/app/settings/dumps/thunderbird/password-recipes.json
@@ -0,0 +1,4 @@
+{
+ "data": [],
+ "timestamp": 1674595048726
+}
diff --git a/comm/mail/app/settings/dumps/thunderbird/records b/comm/mail/app/settings/dumps/thunderbird/records
new file mode 100644
index 0000000000..cf94aab050
--- /dev/null
+++ b/comm/mail/app/settings/dumps/thunderbird/records
@@ -0,0 +1 @@
+{"data":[{"appliesTo":[{"included":{"everywhere":true}},{"included":{"locales":{"matches":["af","an","ar","as","ast","az","be","bg","br","bs","crh","cy","da","de","dsb","el","eo","et","eu","fa","fi","fy-NL","ga-IE","gd","gl","gn","he","hr","hsb","hu","ia","id","is","it","ka","kab","kk","km","kn","lij","lo","lt","ltg","lv","mk","ml","mr","ms","my","nl","oc","or","pl","rm","ro","ru","si","sk","sl","sq","sr","sv-SE","ta","te","th","tl","tr","uk","ur","uz","vi","wo","zh-CN","zh-TW"]}},"webExtension":{"locales":["$USER_LOCALE"]}},{"included":{"locales":{"matches":["be"]}},"webExtension":{"locales":["be","be-tarask"]}},{"included":{"locales":{"matches":["bn","bn-BD","bn-IN"]}},"webExtension":{"locales":["bn"]}},{"included":{"locales":{"matches":["ca","ca-valencia"]}},"webExtension":{"locales":["ca"]}},{"included":{"locales":{"matches":["cak","es-AR","es-CL","es-ES","es-MX","trs"]}},"webExtension":{"locales":["es"]}},{"included":{"locales":{"matches":["cs"]}},"webExtension":{"locales":["cz"]}},{"included":{"locales":{"matches":["ff","fr","son"]}},"webExtension":{"locales":["fr"]}},{"included":{"locales":{"matches":["gu-IN"]}},"webExtension":{"locales":["gu"]}},{"included":{"locales":{"matches":["hi-IN"]}},"webExtension":{"locales":["hi"]}},{"included":{"locales":{"matches":["hy-AM"]}},"webExtension":{"locales":["hy"]}},{"included":{"locales":{"matches":["ja-JP-macos","ja"]}},"webExtension":{"locales":["ja"]}},{"included":{"locales":{"matches":["ko"]}},"webExtension":{"locales":["kr"]}},{"included":{"locales":{"matches":["mai"]}},"webExtension":{"locales":["hi"]}},{"included":{"locales":{"matches":["ml"]}},"webExtension":{"locales":["en","ml"]}},{"included":{"locales":{"matches":["nb-NO"]}},"webExtension":{"locales":["NO"]}},{"included":{"locales":{"matches":["ne-NP"]}},"webExtension":{"locales":["ne"]}},{"included":{"locales":{"matches":["nn-NO"]}},"webExtension":{"locales":["NN"]}},{"included":{"locales":{"matches":["pa-IN"]}},"webExtension":{"locales":["pa"]}},{"included":{"locales":{"matches":["pt-BR","pt-PT"]}},"webExtension":{"locales":["pt"]}}],"webExtension":{"id":"wikipedia@search.mozilla.org","version":"1.0"},"id":"8563efb6-e9f5-4f83-88b4-5124e5a51885","last_modified":1597452131957},{"appliesTo":[{"included":{"locales":{"matches":["sk"]}}}],"webExtension":{"id":"zoznam-sk@search.mozilla.org","version":"1.2"},"id":"208e5b83-ea31-4ad3-b5b1-8c46fdb646d2","last_modified":1597452131934},{"appliesTo":[{"included":{"locales":{"matches":["ja-JP-macos","ja"]}}}],"webExtension":{"id":"yahoo-jp-auctions@search.mozilla.org","version":"1.2"},"id":"3d048cc7-0b49-49f1-8275-25562f997cd4","last_modified":1597452131911},{"appliesTo":[{"included":{"locales":{"matches":["ja-JP-macos","ja"]}}}],"webExtension":{"id":"yahoo-jp@search.mozilla.org","version":"1.0"},"id":"4a3c25e9-32fc-43ba-9f64-04f44f23edf0","last_modified":1597452131889},{"appliesTo":[{"included":{"locales":{"matches":["pl"]}}}],"webExtension":{"id":"wolnelektury-pl@search.mozilla.org","version":"1.0"},"id":"cebf41a8-bb48-4f5a-b05a-404e57c4e019","last_modified":1597452131866},{"appliesTo":[{"included":{"locales":{"matches":["te"]}},"webExtension":{"locales":["te"]}},{"included":{"locales":{"matches":["oc"]}},"webExtension":{"locales":["oc"]}}],"webExtension":{"id":"wiktionary@search.mozilla.org","version":"1.0"},"id":"676fad59-3280-4e5d-a3ae-74c918f6b8d3","last_modified":1597452131842},{"appliesTo":[{"included":{"locales":{"matches":["hu"]}}}],"webExtension":{"id":"vatera@search.mozilla.org","version":"1.2"},"id":"ff997f3f-e41c-44e2-96b2-419a1ed7e7c4","last_modified":1597452131820},{"appliesTo":[{"included":{"locales":{"matches":["sv-SE"]}}}],"webExtension":{"id":"tyda-sv-SE@search.mozilla.org","version":"1.0"},"id":"f3930927-4422-4cc1-9576-b70a80e528b8","last_modified":1597452131797},{"appliesTo":[{"included":{"locales":{"matches":["default","en-US","ach","an","bs","ca","ca-valencia","crh","en-CA","ga-IE","gn","hr","ia","ka","kk","km","lo","lt","mai","ms","my","ne-NP","oc","pt-BR","sl","tl","tr","ur","uz","wo"]}}},{"included":{"locales":{"matches":["ja-JP-macos","ja"]}},"webExtension":{"locales":["ja"]}}],"webExtension":{"id":"twitter@search.mozilla.org","version":"1.0"},"id":"bf7356d5-b520-4d94-a96a-eb832315a049","last_modified":1597452131769},{"appliesTo":[{"included":{"locales":{"matches":["ga-IE"]}}}],"webExtension":{"id":"tearma@search.mozilla.org","version":"1.0"},"id":"ada324f8-3081-4397-b78b-e03bd860375e","last_modified":1597452131747},{"appliesTo":[{"included":{"locales":{"matches":["ltg","lv"]}}}],"webExtension":{"id":"sslv@search.mozilla.org","version":"1.0"},"id":"3f907fb8-c074-444a-850f-b6ea3a651c56","last_modified":1597452131724},{"appliesTo":[{"included":{"locales":{"matches":["cs"]}}}],"webExtension":{"id":"seznam-cz@search.mozilla.org","version":"1.0"},"id":"38f61654-35a4-4c93-af84-7c215edaf434","last_modified":1597452131701},{"appliesTo":[{"included":{"locales":{"matches":["ltg","lv"]}}}],"webExtension":{"id":"salidzinilv@search.mozilla.org","version":"1.0"},"id":"6bec5c4a-0f12-4399-b4d5-2fe9d73c0e74","last_modified":1597452131679},{"appliesTo":[{"included":{"locales":{"matches":["zh-TW"]}}}],"webExtension":{"id":"readmoo@search.mozilla.org","version":"1.0"},"id":"4399d0ea-7540-43f6-adbd-4833b341ba9f","last_modified":1597452131655},{"appliesTo":[{"included":{"locales":{"matches":["ja-JP-macos","jp"]}}}],"webExtension":{"id":"rakuten@search.mozilla.org","version":"1.2"},"id":"fc2c153a-936c-4d7e-a72c-e0a7ce23b6d8","last_modified":1597452131632},{"appliesTo":[{"included":{"locales":{"matches":["nb-NO","nn-NO"]}}}],"webExtension":{"id":"qxl-NO@search.mozilla.org","version":"1.0"},"id":"dfc109bd-b4e5-4aa9-95a3-5cd81d67341d","last_modified":1597452131611},{"appliesTo":[{"included":{"locales":{"matches":["fr"]}}}],"webExtension":{"id":"qwant@search.mozilla.org","version":"1.0"},"id":"a0ce2b21-9204-486d-b0a1-07819e38b2e0","last_modified":1597452131588},{"appliesTo":[{"included":{"locales":{"matches":["pl"]}}}],"webExtension":{"id":"pwn-pl@search.mozilla.org","version":"1.0"},"id":"3f0b73cf-2645-427a-acbd-f9b9a7e86ca9","last_modified":1597452131564},{"appliesTo":[{"included":{"locales":{"matches":["sv-SE"]}}}],"webExtension":{"id":"prisjakt-sv-SE@search.mozilla.org","version":"1.0"},"id":"1a571c53-a05e-483f-ba76-944ce03e405c","last_modified":1597452131543},{"appliesTo":[{"included":{"locales":{"matches":["ru"]}}}],"webExtension":{"id":"priceru@search.mozilla.org","version":"1.0"},"id":"2ca250af-f2ac-4f63-af34-1b1b53fd3b92","last_modified":1597452131520},{"appliesTo":[{"included":{"locales":{"matches":["pt-PT"]}}}],"webExtension":{"id":"priberam@search.mozilla.org","version":"1.2"},"id":"44c5c666-422b-4fc2-b9e2-cddd0d3b7ccc","last_modified":1597452131497},{"appliesTo":[{"included":{"locales":{"matches":["sr"]}}}],"webExtension":{"id":"pogodak@search.mozilla.org","version":"1.0"},"id":"473fd16b-ed84-4ed1-822c-50ac5d9ee63b","last_modified":1597452131475},{"appliesTo":[{"included":{"locales":{"matches":["bg"]}}}],"webExtension":{"id":"pazaruvaj@search.mozilla.org","version":"1.0"},"id":"6752ecbe-99cc-4ce9-90db-31c8d9e7c506","last_modified":1597452131452},{"appliesTo":[{"included":{"locales":{"matches":["cy"]}}}],"webExtension":{"id":"palasprint@search.mozilla.org","version":"1.0"},"id":"1f5075fa-ed75-4561-919f-3fa6e1158bf7","last_modified":1597452131429},{"appliesTo":[{"included":{"locales":{"matches":["ru"]}}}],"webExtension":{"id":"ozonru@search.mozilla.org","version":"1.2"},"id":"954cc2fe-72f5-4360-b200-ab455c5ad920","last_modified":1597452131407},{"appliesTo":[{"included":{"locales":{"matches":["et"]}}}],"webExtension":{"id":"osta-ee@search.mozilla.org","version":"1.0"},"id":"e4e7b227-f6e6-49bf-840a-a7b7601e1a92","last_modified":1597452131384},{"appliesTo":[{"included":{"locales":{"matches":["ja-JP-macos","jp"]}}}],"webExtension":{"id":"oshiete-goo@search.mozilla.org","version":"1.0"},"id":"ba06b66e-b083-42f9-96ca-06beb7df003b","last_modified":1597452131360},{"appliesTo":[{"included":{"locales":{"matches":["bs"]}}}],"webExtension":{"id":"olx@search.mozilla.org","version":"1.0"},"id":"0a2b2d3b-18d7-425d-b852-319cafe49d22","last_modified":1597452131338},{"appliesTo":[{"included":{"locales":{"matches":["sl"]}}}],"webExtension":{"id":"odpiralni@search.mozilla.org","version":"1.0"},"id":"7e85688f-5f3e-4dce-a624-6a80ea792a39","last_modified":1597452131315},{"appliesTo":[{"included":{"locales":{"matches":["et"]}}}],"webExtension":{"id":"neti-ee@search.mozilla.org","version":"1.0"},"id":"c7cd702e-9049-4a75-823c-6ccd7ad2b86f","last_modified":1597452131292},{"appliesTo":[{"included":{"locales":{"matches":["ko"]}}}],"webExtension":{"id":"naver-kr@search.mozilla.org","version":"1.0"},"id":"af1943a3-75e1-4429-8a95-43675686786c","last_modified":1597452131271},{"appliesTo":[{"included":{"locales":{"matches":["sl"]}}}],"webExtension":{"id":"najdi-si@search.mozilla.org","version":"1.0"},"id":"fda60f56-ac34-410a-8e74-1b2ba0dbaeaa","last_modified":1597452131248},{"appliesTo":[{"included":{"locales":{"matches":["he"]}}}],"webExtension":{"id":"morfix-dic@search.mozilla.org","version":"1.1"},"id":"2950bf4a-450f-47cd-8988-1d9d73c10300","last_modified":1597452131225},{"appliesTo":[{"included":{"locales":{"matches":["pt-BR"]}}}],"webExtension":{"id":"mercadolivre@search.mozilla.org","version":"1.0"},"id":"393eed85-f54c-453c-9df3-d5351473a2d0","last_modified":1597452131204},{"appliesTo":[{"included":{"locales":{"matches":["es-AR"]}},"webExtension":{"locales":["ar"]}},{"included":{"locales":{"matches":["es-CL"]}},"webExtension":{"locales":["cl"]}},{"included":{"locales":{"matches":["es-MX"]}},"webExtension":{"locales":["mx"]}}],"webExtension":{"id":"mercadolibre@search.mozilla.org","version":"1.0"},"id":"58ccc938-9b28-43fa-99c8-8fb2afaf0531","last_modified":1597452131181},{"appliesTo":[{"included":{"locales":{"matches":["fy-NL"]}},"webExtension":{"locales":["fy-NL"]}},{"included":{"locales":{"matches":["nl"]}},"webExtension":{"locales":["nl"]}}],"webExtension":{"id":"marktplaats@search.mozilla.org","version":"1.0"},"id":"5e341152-6dc1-4357-80cb-8c2d615ec5dc","last_modified":1597452131158},{"appliesTo":[{"included":{"locales":{"matches":["cs"]}}}],"webExtension":{"id":"mapy-cz@search.mozilla.org","version":"1.0"},"id":"bcee8873-4644-44c1-babf-b35be366f4f7","last_modified":1597452131136},{"appliesTo":[{"included":{"locales":{"matches":["ru"]}}}],"webExtension":{"id":"mailru@search.mozilla.org","version":"1.0"},"id":"969a8778-1f6d-4af2-87cd-c08a9a4f80b8","last_modified":1597452131114},{"appliesTo":[{"included":{"locales":{"matches":["th"]}}}],"webExtension":{"id":"longdo@search.mozilla.org","version":"1.0"},"id":"c64fefb6-61b5-49fe-8148-1470fa586c21","last_modified":1597452131091},{"appliesTo":[{"included":{"locales":{"matches":["hy-AM"]}}}],"webExtension":{"id":"list-am@search.mozilla.org","version":"1.0"},"id":"2e099aaa-b949-4038-8595-69678b09c46d","last_modified":1597452131070},{"appliesTo":[{"included":{"locales":{"matches":["de","dsb","hsb","rm"]}}}],"webExtension":{"id":"leo_ende_de@search.mozilla.org","version":"1.0"},"id":"4db2ab80-c651-405b-88ba-1b30396a5d23","last_modified":1597452131047},{"appliesTo":[{"included":{"locales":{"matches":["kn"]}}}],"webExtension":{"id":"kannadastore@search.mozilla.org","version":"1.2"},"id":"8cb31936-c2da-47b6-8acc-15e2d48d2362","last_modified":1597452131024},{"appliesTo":[{"included":{"locales":{"matches":["uk"]}}}],"webExtension":{"id":"hotline-ua@search.mozilla.org","version":"1.0"},"id":"5b2895b4-afb4-452d-bc8f-4b3752448fba","last_modified":1597452131003},{"appliesTo":[{"included":{"locales":{"matches":["cs"]}}}],"webExtension":{"id":"heureka-cz@search.mozilla.org","version":"1.0"},"id":"dfc28b2a-40ac-4772-bca3-8b090ab8a05e","last_modified":1597452130980},{"appliesTo":[{"included":{"locales":{"matches":["nn-NO","nb-NO"]}}}],"webExtension":{"id":"gulesider-NO@search.mozilla.org","version":"1.0"},"id":"311b0b99-0619-446f-ad7c-695e531ec000","last_modified":1597452130957},{"appliesTo":[{"included":{"locales":{"matches":["br"]}}}],"webExtension":{"id":"freelang@search.mozilla.org","version":"1.0"},"id":"e72ce961-ea88-4296-ac41-745d75c14363","last_modified":1597452130936},{"appliesTo":[{"included":{"locales":{"matches":["kk"]}}}],"webExtension":{"id":"flip@search.mozilla.org","version":"1.0"},"id":"0b17f642-5519-4e5b-89bb-71e16e3e0763","last_modified":1597452130913},{"appliesTo":[{"included":{"locales":{"matches":["gd"]}}}],"webExtension":{"id":"faclair-beag@search.mozilla.org","version":"1.0"},"id":"2b046c0d-3cc6-4639-8454-2d2ea024705b","last_modified":1597452130891},{"appliesTo":[{"included":{"locales":{"matches":["hr"]}}}],"webExtension":{"id":"eudict@search.mozilla.org","version":"1.0"},"id":"1bf9f430-9b92-4c3b-a2c1-0a7edddc1f28","last_modified":1597452130869},{"appliesTo":[{"included":{"locales":{"matches":["et"]}}}],"webExtension":{"id":"eki-ee@search.mozilla.org","version":"1.0"},"id":"7c60a125-37c2-48f1-b541-86fe070b4388","last_modified":1597452130847},{"appliesTo":[{"included":{"locales":{"matches":["de"]}}}],"webExtension":{"id":"ecosia@search.mozilla.org","version":"1.0"},"id":"e7083870-11cf-4c2b-8843-67b3e098f474","last_modified":1597452130824},{"appliesTo":[{"included":{"locales":{"matches":["es-AR","es-CL","es-ES"]}}}],"webExtension":{"id":"drae@search.mozilla.org","version":"1.0"},"id":"fcf39b52-b59d-42a5-b1a8-4361159b428d","last_modified":1597452130803},{"appliesTo":[{"included":{"locales":{"matches":["ca","ca-valencia"]}}}],"webExtension":{"id":"diec2@search.mozilla.org","version":"1.2"},"id":"b88830cd-705d-492a-a28c-742abfa9334b","last_modified":1597452130780},{"appliesTo":[{"included":{"locales":{"matches":["ko"]}}}],"webExtension":{"id":"daum-kr@search.mozilla.org","version":"1.0"},"id":"2dc000ee-a78b-4e78-91af-ac76b79bac47","last_modified":1597452130756},{"appliesTo":[{"included":{"locales":{"matches":["vi"]}}}],"webExtension":{"id":"coccoc@search.mozilla.org","version":"1.0"},"id":"cd8d215b-6c81-439b-bc16-dab6bae8188b","last_modified":1597452130735},{"appliesTo":[{"included":{"locales":{"matches":["en-GB"]}}}],"webExtension":{"id":"chambers-en-GB@search.mozilla.org","version":"1.0"},"id":"7047c29e-7ad4-49db-adb9-6c135e6c59f8","last_modified":1597452130712},{"appliesTo":[{"included":{"locales":{"matches":["sl"]}}}],"webExtension":{"id":"ceneji@search.mozilla.org","version":"1.0"},"id":"c0fe16a1-6d66-48a2-bc54-ceb9a78754ce","last_modified":1597452130688},{"appliesTo":[{"included":{"locales":{"matches":["fy-NL"]}},"webExtension":{"locales":["fy-NL"]}},{"included":{"locales":{"matches":["nl"]}},"webExtension":{"locales":["nl"]}}],"webExtension":{"id":"bolcom@search.mozilla.org","version":"1.0"},"id":"adc06075-02c9-4d8d-88ab-bccd1b843184","last_modified":1597452130667},{"appliesTo":[{"included":{"locales":{"matches":["nb-NO","nn-NO"]}}}],"webExtension":{"id":"bok-NO@search.mozilla.org","version":"1.0"},"id":"dc33bc2b-758b-41fe-804c-e9894aea9cc1","last_modified":1597452130644},{"appliesTo":[{"included":{"locales":{"matches":["gd"]}}}],"webExtension":{"id":"bbc-alba@search.mozilla.org","version":"1.0"},"id":"614c27ea-1537-4db0-ac1d-b873732bde57","last_modified":1597452130621},{"appliesTo":[{"included":{"locales":{"matches":["sk"]}}}],"webExtension":{"id":"azet-sk@search.mozilla.org","version":"1.0"},"id":"17835df2-e2b8-4ca8-91bc-a45299bdcae5","last_modified":1597452130600},{"appliesTo":[{"included":{"locales":{"matches":["az"]}}}],"webExtension":{"id":"azerdict@search.mozilla.org","version":"1.0"},"id":"85d4f0c7-ace1-4e67-9e16-38db2aebf84b","last_modified":1597452130577},{"appliesTo":[{"included":{"locales":{"matches":["sk"]}}}],"webExtension":{"id":"atlas-sk@search.mozilla.org","version":"1.0"},"id":"bb87e317-4ba3-458e-9b64-d4459e61b001","last_modified":1597452130554},{"appliesTo":[{"included":{"locales":{"matches":["pl"]}}}],"webExtension":{"id":"allegro-pl@search.mozilla.org","version":"1.0"},"id":"9933195c-160e-41bb-984b-019137687d48","last_modified":1597452130533},{"appliesTo":[{"included":{"locales":{"matches":["sv-SE"]}}}],"webExtension":{"id":"allaannonser-sv-SE@search.mozilla.org","version":"1.2"},"id":"7011ae79-112a-494a-834b-857b66e444c0","last_modified":1597452130511},{"appliesTo":[{"default":"yes","included":{"locales":{"matches":["ru","tr","be","kk"],"startsWith":["en"]},"regions":["ru","tr","by","kz"]},"telemetryId":"yandex-en","webExtension":{"locales":["en"]}},{"included":{"locales":{"matches":["az"]}},"telemetryId":"yandex-az","webExtension":{"locales":["az"]}},{"included":{"locales":{"matches":["be"]}},"telemetryId":"yandex-by","webExtension":{"locales":["by"]}},{"included":{"locales":{"matches":["kk"]}},"telemetryId":"yandex-kk","webExtension":{"locales":["kk"]}},{"included":{"locales":{"matches":["ru"]}},"telemetryId":"yandex-ru","webExtension":{"locales":["ru"]}},{"included":{"locales":{"matches":["tr"]}},"telemetryId":"yandex-tr","webExtension":{"locales":["tr"]}}],"webExtension":{"id":"yandex@search.mozilla.org","version":"1.0"},"id":"cdfef3b9-59a5-4e58-998b-8f61e5f63279","last_modified":1597452130488},{"orderHint":500,"telemetryId":"ddg","webExtension":{"id":"ddg@search.mozilla.org","version":"1.0"},"id":"2c33bcaa-aae3-42e5-ad0d-915e2e822cc9","last_modified":1597452130466},{"params":{"searchUrlGetParams":{"ix":"sunray","Go.x":"0","Go.y":"0","keywords":"{searchTerms}","pageletid":"headsearch","searchType":"","bestSaleNum":"0"}},"appliesTo":[{"included":{"locales":{"matches":["zh-CN"]}}}],"orderHint":500,"telemetryId":"amazondotcn","webExtension":{"id":"amazondotcn@search.mozilla.org","version":"1.0"},"id":"965270fc-b4ee-4dcc-bdcb-fed87fe563a9","last_modified":1597452130444},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"}]},"appliesTo":[{"included":{"locales":{"matches":["as","bn","bn-IN","kn","gu-IN","mai","ml","mr","or","pa-IN","ta","te","ur"]}},"telemetryId":"amazon-in","webExtension":{"locales":["in"]}},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"},{"name":"sourceid","value":"Mozilla-search"}]},"included":{"locales":{"matches":["br","ff","fr","son","wo"]}},"telemetryId":"amazon-france","webExtension":{"locales":["france"]}},{"included":{"locales":{"matches":["br","ff","fr","son","wo"]},"regions":["ca"]},"telemetryId":"amazon-ca","webExtension":{"locales":["ca"]}},{"included":{"locales":{"matches":["en-CA"]}},"telemetryId":"amazon-ca","webExtension":{"locales":["ca"]}},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"},{"name":"sourceid","value":"Mozilla-search"}]},"included":{"locales":{"matches":["ja-JP-macos","ja"]}},"telemetryId":"amazon-jp","webExtension":{"locales":["jp"]}},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"},{"name":"sourceid","value":"Mozilla-search"}]},"included":{"locales":{"matches":["it","lij"]}},"telemetryId":"amazon-it","webExtension":{"locales":["it"]}},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"},{"name":"sourceid","value":"Mozilla-search"}]},"included":{"locales":{"matches":["de","dsb","hsb"]}},"telemetryId":"amazon-de","webExtension":{"locales":["de"]}},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"},{"name":"sourceid","value":"Mozilla-search"}]},"included":{"locales":{"matches":["cy","da","el","en-GB","eu","ga-IE","gd","gl","hr","nb-NO","nn-NO","pt-PT","sq","sr"]}},"telemetryId":"amazon-en-GB","webExtension":{"locales":["en-GB"]}},{"included":{"locales":{"matches":["cy","da","el","en-GB","eu","ga-IE","gd","gl","hr","nb-NO","nn-NO","pt-PT","sq","sr"]},"regions":["au"]},"telemetryId":"amazon-au","webExtension":{"locales":["au"]}}],"orderHint":500,"webExtension":{"id":"amazon@search.mozilla.org","version":"1.1"},"id":"968c66a0-0d32-4bdb-a132-66d0e7dead54","last_modified":1597452130421},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"},{"name":"sourceid","value":"Mozilla-search"}]},"appliesTo":[{"included":{"regions":["default"]}},{"included":{"locales":{"matches":["ach","af","ar","az","bg","cak","en-US","eo","es-AR","fa","gn","hy-AM","ia","is","ka","km","lt","mk","ms","my","ro","si","th","tl","trs","uz"]}}},{"included":{"locales":{"matches":["ach","af","ar","az","bg","cak","en-US","eo","es-AR","fa","gn","hy-AM","ia","is","ka","km","lt","mk","ms","my","ro","si","th","tl","trs","uz"]},"regions":["au"]},"telemetryId":"amazon-au","webExtension":{"id":"amazon@search.mozilla.org","locales":["au"],"version":"1.1"}},{"included":{"locales":{"matches":["ach","af","ar","az","bg","cak","en-US","eo","es-AR","fa","gn","hy-AM","ia","is","ka","km","lt","mk","ms","my","ro","si","th","tl","trs","uz"]},"regions":["ca"]},"telemetryId":"amazon-ca","webExtension":{"id":"amazon@search.mozilla.org","locales":["ca"],"version":"1.1"}},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"},{"name":"sourceid","value":"Mozilla-search"}]},"included":{"locales":{"matches":["ach","af","ar","az","bg","cak","en-US","eo","es-AR","fa","gn","hy-AM","ia","is","ka","km","lt","mk","ms","my","ro","si","th","tl","trs","uz"]},"regions":["fr"]},"telemetryId":"amazon-france","webExtension":{"id":"amazon@search.mozilla.org","locales":["france"],"version":"1.1"}},{"params":{"searchUrlGetParams":[{"name":"field-keywords","value":"{searchTerms}"},{"name":"ie","value":"{inputEncoding}"},{"name":"mode","value":"blended"},{"name":"sourceid","value":"Mozilla-search"}]},"included":{"locales":{"matches":["ach","af","ar","az","bg","cak","en-US","eo","es-AR","fa","gn","hy-AM","ia","is","ka","km","lt","mk","ms","my","ro","si","th","tl","trs","uz"]},"regions":["gb"]},"telemetryId":"amazon-en-GB","webExtension":{"id":"amazon@search.mozilla.org","locales":["en-GB"],"version":"1.1"}}],"orderHint":500,"telemetryId":"amazondotcom","webExtension":{"id":"amazondotcom@search.mozilla.org","version":"1.1"},"id":"5eb7e179-c11d-4029-99b7-bc7ea1467d46","last_modified":1597452130399},{"params":{"searchUrlGetParams":[{"name":"wd","value":"{searchTerms}"},{"name":"tn","value":"monline_7_dg"},{"name":"ie","value":"utf-8"}],"suggestUrlGetParams":[{"name":"wd","value":"{searchTerms}"},{"name":"tn","value":"monline_7_dg"},{"name":"ie","value":"utf-8"},{"name":"action","value":"opensearch"}]},"appliesTo":[{"included":{"locales":{"matches":["zh-CN"]}}},{"default":"yes","included":{"locales":{"matches":["zh-CN"]},"regions":["cn"]}}],"telemetryId":"baidu","webExtension":{"id":"baidu@search.mozilla.org","version":"1.0"},"id":"59f371ee-05cc-4c9e-8961-27fe1fa4cbc2","last_modified":1597452130376},{"params":{"searchUrlGetParams":[{"name":"q","value":"{searchTerms}"}]},"appliesTo":[{"included":{"locales":{"matches":["ach","af","an","ar","ast","az","ca","ca-valencia","cak","da","de","dsb","el","eo","es-CL","es-ES","es-MX","eu","fa","ff","fi","fr","fy-NL","gn","gu-IN","hi-IN","hr","hsb","ia","is","it","ja-JP-macos","ja","ka","kab","km","kn","lij","lo","lt","mai","mk","ml","ms","my","nb-NO","ne-NP","nl","nn-NO","oc","or","pa-IN","pt-BR","rm","ro","son","sq","sr","sv-SE","th","tl","trs","uk","ur","uz","wo","xh","zh-CN"],"startsWith":["bn","en"]}}},{"included":{"regions":["default"]}}],"webExtension":{"id":"bing@search.mozilla.org","version":"1.0"},"id":"d6b19d48-c263-49f9-9f5b-72fb5a3824bc","last_modified":1597452130353},{"params":{"searchUrlGetParams":[{"name":"q","value":"{searchTerms}"}]},"appliesTo":[{"default":"yes-if-no-other","included":{"everywhere":true}}],"orderHint":1000,"telemetryId":"google","webExtension":{"id":"google@search.mozilla.org","version":"1.0"},"id":"6158e467-c0d3-48e8-a5cf-a2102b7f9456","last_modified":1597452130330}]} \ No newline at end of file
diff --git a/comm/mail/app/settings/dumps/thunderbird/search-config.json b/comm/mail/app/settings/dumps/thunderbird/search-config.json
new file mode 100644
index 0000000000..9c6bcde4ef
--- /dev/null
+++ b/comm/mail/app/settings/dumps/thunderbird/search-config.json
@@ -0,0 +1,2152 @@
+{
+ "data": [
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "everywhere": true
+ }
+ }
+ ],
+ "orderHint": 500,
+ "telemetryId": "ddg",
+ "webExtension": {
+ "id": "ddg@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "2c33bcaa-aae3-42e5-ad0d-915e2e822cc9",
+ "last_modified": 1604535069307
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "everywhere": true
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": [
+ "af",
+ "an",
+ "ar",
+ "as",
+ "ast",
+ "az",
+ "be",
+ "bg",
+ "br",
+ "bs",
+ "crh",
+ "cy",
+ "da",
+ "de",
+ "dsb",
+ "el",
+ "eo",
+ "et",
+ "eu",
+ "fa",
+ "fi",
+ "fy-NL",
+ "ga-IE",
+ "gd",
+ "gl",
+ "gn",
+ "he",
+ "hr",
+ "hsb",
+ "hu",
+ "ia",
+ "id",
+ "is",
+ "it",
+ "ka",
+ "kab",
+ "kk",
+ "km",
+ "kn",
+ "lij",
+ "lo",
+ "lt",
+ "ltg",
+ "lv",
+ "mk",
+ "ml",
+ "mr",
+ "ms",
+ "my",
+ "nl",
+ "oc",
+ "or",
+ "pl",
+ "rm",
+ "ro",
+ "ru",
+ "si",
+ "sk",
+ "sl",
+ "sq",
+ "sr",
+ "sv-SE",
+ "ta",
+ "te",
+ "th",
+ "tl",
+ "tr",
+ "uk",
+ "ur",
+ "uz",
+ "vi",
+ "wo",
+ "zh-CN",
+ "zh-TW"
+ ]
+ }
+ },
+ "webExtension": {
+ "locales": ["$USER_LOCALE"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["be"]
+ }
+ },
+ "webExtension": {
+ "locales": ["be", "be-tarask"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["bn", "bn-BD", "bn-IN"]
+ }
+ },
+ "webExtension": {
+ "locales": ["bn"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["ca", "ca-valencia"]
+ }
+ },
+ "webExtension": {
+ "locales": ["ca"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["cak", "es-AR", "es-CL", "es-ES", "es-MX", "trs"]
+ }
+ },
+ "webExtension": {
+ "locales": ["es"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["cs"]
+ }
+ },
+ "webExtension": {
+ "locales": ["cz"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["ff", "fr", "son"]
+ }
+ },
+ "webExtension": {
+ "locales": ["fr"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["gu-IN"]
+ }
+ },
+ "webExtension": {
+ "locales": ["gu"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["hi-IN"]
+ }
+ },
+ "webExtension": {
+ "locales": ["hi"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["hy-AM"]
+ }
+ },
+ "webExtension": {
+ "locales": ["hy"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["ja-JP-macos", "ja"]
+ }
+ },
+ "webExtension": {
+ "locales": ["ja"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["ko"]
+ }
+ },
+ "webExtension": {
+ "locales": ["kr"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["mai"]
+ }
+ },
+ "webExtension": {
+ "locales": ["hi"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["ml"]
+ }
+ },
+ "webExtension": {
+ "locales": ["en", "ml"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["nb-NO"]
+ }
+ },
+ "webExtension": {
+ "locales": ["NO"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["ne-NP"]
+ }
+ },
+ "webExtension": {
+ "locales": ["ne"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["nn-NO"]
+ }
+ },
+ "webExtension": {
+ "locales": ["NN"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["pa-IN"]
+ }
+ },
+ "webExtension": {
+ "locales": ["pa"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["pt-BR", "pt-PT"]
+ }
+ },
+ "webExtension": {
+ "locales": ["pt"]
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "wikipedia@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "8563efb6-e9f5-4f83-88b4-5124e5a51885",
+ "last_modified": 1597452131957
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sk"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "zoznam-sk@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "208e5b83-ea31-4ad3-b5b1-8c46fdb646d2",
+ "last_modified": 1597452131934
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ja-JP-macos", "ja"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "yahoo-jp-auctions@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "3d048cc7-0b49-49f1-8275-25562f997cd4",
+ "last_modified": 1597452131911
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ja-JP-macos", "ja"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "yahoo-jp@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "4a3c25e9-32fc-43ba-9f64-04f44f23edf0",
+ "last_modified": 1597452131889
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["pl"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "wolnelektury-pl@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "cebf41a8-bb48-4f5a-b05a-404e57c4e019",
+ "last_modified": 1597452131866
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["te"]
+ }
+ },
+ "webExtension": {
+ "locales": ["te"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["oc"]
+ }
+ },
+ "webExtension": {
+ "locales": ["oc"]
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "wiktionary@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "676fad59-3280-4e5d-a3ae-74c918f6b8d3",
+ "last_modified": 1597452131842
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["hu"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "vatera@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "ff997f3f-e41c-44e2-96b2-419a1ed7e7c4",
+ "last_modified": 1597452131820
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sv-SE"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "tyda-sv-SE@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "f3930927-4422-4cc1-9576-b70a80e528b8",
+ "last_modified": 1597452131797
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ga-IE"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "tearma@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "ada324f8-3081-4397-b78b-e03bd860375e",
+ "last_modified": 1597452131747
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ltg", "lv"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "sslv@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "3f907fb8-c074-444a-850f-b6ea3a651c56",
+ "last_modified": 1597452131724
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["cs"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "seznam-cz@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "38f61654-35a4-4c93-af84-7c215edaf434",
+ "last_modified": 1597452131701
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ltg", "lv"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "salidzinilv@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "6bec5c4a-0f12-4399-b4d5-2fe9d73c0e74",
+ "last_modified": 1597452131679
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["zh-TW"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "readmoo@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "4399d0ea-7540-43f6-adbd-4833b341ba9f",
+ "last_modified": 1597452131655
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ja-JP-macos", "jp"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "rakuten@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "fc2c153a-936c-4d7e-a72c-e0a7ce23b6d8",
+ "last_modified": 1597452131632
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["nb-NO", "nn-NO"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "qxl-NO@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "dfc109bd-b4e5-4aa9-95a3-5cd81d67341d",
+ "last_modified": 1597452131611
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["fr"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "qwant@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "a0ce2b21-9204-486d-b0a1-07819e38b2e0",
+ "last_modified": 1597452131588
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["pl"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "pwn-pl@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "3f0b73cf-2645-427a-acbd-f9b9a7e86ca9",
+ "last_modified": 1597452131564
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sv-SE"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "prisjakt-sv-SE@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "1a571c53-a05e-483f-ba76-944ce03e405c",
+ "last_modified": 1597452131543
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ru"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "priceru@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "2ca250af-f2ac-4f63-af34-1b1b53fd3b92",
+ "last_modified": 1597452131520
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["pt-PT"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "priberam@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "44c5c666-422b-4fc2-b9e2-cddd0d3b7ccc",
+ "last_modified": 1597452131497
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sr"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "pogodak@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "473fd16b-ed84-4ed1-822c-50ac5d9ee63b",
+ "last_modified": 1597452131475
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["bg"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "pazaruvaj@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "6752ecbe-99cc-4ce9-90db-31c8d9e7c506",
+ "last_modified": 1597452131452
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["cy"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "palasprint@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "1f5075fa-ed75-4561-919f-3fa6e1158bf7",
+ "last_modified": 1597452131429
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ru"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "ozonru@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "954cc2fe-72f5-4360-b200-ab455c5ad920",
+ "last_modified": 1597452131407
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["et"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "osta-ee@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "e4e7b227-f6e6-49bf-840a-a7b7601e1a92",
+ "last_modified": 1597452131384
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ja-JP-macos", "jp"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "oshiete-goo@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "ba06b66e-b083-42f9-96ca-06beb7df003b",
+ "last_modified": 1597452131360
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["bs"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "olx@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "0a2b2d3b-18d7-425d-b852-319cafe49d22",
+ "last_modified": 1597452131338
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sl"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "odpiralni@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "7e85688f-5f3e-4dce-a624-6a80ea792a39",
+ "last_modified": 1597452131315
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["et"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "neti-ee@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "c7cd702e-9049-4a75-823c-6ccd7ad2b86f",
+ "last_modified": 1597452131292
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ko"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "naver-kr@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "af1943a3-75e1-4429-8a95-43675686786c",
+ "last_modified": 1597452131271
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sl"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "najdi-si@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "fda60f56-ac34-410a-8e74-1b2ba0dbaeaa",
+ "last_modified": 1597452131248
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["he"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "morfix-dic@search.mozilla.org",
+ "version": "1.1"
+ },
+ "id": "2950bf4a-450f-47cd-8988-1d9d73c10300",
+ "last_modified": 1597452131225
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["pt-BR"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "mercadolivre@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "393eed85-f54c-453c-9df3-d5351473a2d0",
+ "last_modified": 1597452131204
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["es-AR"]
+ }
+ },
+ "webExtension": {
+ "locales": ["ar"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["es-CL"]
+ }
+ },
+ "webExtension": {
+ "locales": ["cl"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["es-MX"]
+ }
+ },
+ "webExtension": {
+ "locales": ["mx"]
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "mercadolibre@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "58ccc938-9b28-43fa-99c8-8fb2afaf0531",
+ "last_modified": 1597452131181
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["fy-NL"]
+ }
+ },
+ "webExtension": {
+ "locales": ["fy-NL"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["nl"]
+ }
+ },
+ "webExtension": {
+ "locales": ["nl"]
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "marktplaats@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "5e341152-6dc1-4357-80cb-8c2d615ec5dc",
+ "last_modified": 1597452131158
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["cs"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "mapy-cz@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "bcee8873-4644-44c1-babf-b35be366f4f7",
+ "last_modified": 1597452131136
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["th"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "longdo@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "c64fefb6-61b5-49fe-8148-1470fa586c21",
+ "last_modified": 1597452131091
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["hy-AM"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "list-am@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "2e099aaa-b949-4038-8595-69678b09c46d",
+ "last_modified": 1597452131070
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["de", "dsb", "hsb", "rm"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "leo_ende_de@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "4db2ab80-c651-405b-88ba-1b30396a5d23",
+ "last_modified": 1597452131047
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["kn"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "kannadastore@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "8cb31936-c2da-47b6-8acc-15e2d48d2362",
+ "last_modified": 1597452131024
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["uk"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "hotline-ua@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "5b2895b4-afb4-452d-bc8f-4b3752448fba",
+ "last_modified": 1597452131003
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["cs"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "heureka-cz@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "dfc28b2a-40ac-4772-bca3-8b090ab8a05e",
+ "last_modified": 1597452130980
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["nn-NO", "nb-NO"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "gulesider-NO@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "311b0b99-0619-446f-ad7c-695e531ec000",
+ "last_modified": 1597452130957
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["br"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "freelang@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "e72ce961-ea88-4296-ac41-745d75c14363",
+ "last_modified": 1597452130936
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["kk"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "flip@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "0b17f642-5519-4e5b-89bb-71e16e3e0763",
+ "last_modified": 1597452130913
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["gd"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "faclair-beag@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "2b046c0d-3cc6-4639-8454-2d2ea024705b",
+ "last_modified": 1597452130891
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["hr"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "eudict@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "1bf9f430-9b92-4c3b-a2c1-0a7edddc1f28",
+ "last_modified": 1597452130869
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["et"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "eki-ee@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "7c60a125-37c2-48f1-b541-86fe070b4388",
+ "last_modified": 1597452130847
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["de"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "ecosia@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "e7083870-11cf-4c2b-8843-67b3e098f474",
+ "last_modified": 1597452130824
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["es-AR", "es-CL", "es-ES"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "drae@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "fcf39b52-b59d-42a5-b1a8-4361159b428d",
+ "last_modified": 1597452130803
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ca", "ca-valencia"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "diec2@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "b88830cd-705d-492a-a28c-742abfa9334b",
+ "last_modified": 1597452130780
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["ko"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "daum-kr@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "2dc000ee-a78b-4e78-91af-ac76b79bac47",
+ "last_modified": 1597452130756
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["vi"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "coccoc@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "cd8d215b-6c81-439b-bc16-dab6bae8188b",
+ "last_modified": 1597452130735
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["en-GB"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "chambers-en-GB@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "7047c29e-7ad4-49db-adb9-6c135e6c59f8",
+ "last_modified": 1597452130712
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sl"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "ceneji@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "c0fe16a1-6d66-48a2-bc54-ceb9a78754ce",
+ "last_modified": 1597452130688
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["fy-NL"]
+ }
+ },
+ "webExtension": {
+ "locales": ["fy-NL"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["nl"]
+ }
+ },
+ "webExtension": {
+ "locales": ["nl"]
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "bolcom@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "adc06075-02c9-4d8d-88ab-bccd1b843184",
+ "last_modified": 1597452130667
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["nb-NO", "nn-NO"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "bok-NO@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "dc33bc2b-758b-41fe-804c-e9894aea9cc1",
+ "last_modified": 1597452130644
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["gd"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "bbc-alba@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "614c27ea-1537-4db0-ac1d-b873732bde57",
+ "last_modified": 1597452130621
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sk"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "azet-sk@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "17835df2-e2b8-4ca8-91bc-a45299bdcae5",
+ "last_modified": 1597452130600
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["az"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "azerdict@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "85d4f0c7-ace1-4e67-9e16-38db2aebf84b",
+ "last_modified": 1597452130577
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sk"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "atlas-sk@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "bb87e317-4ba3-458e-9b64-d4459e61b001",
+ "last_modified": 1597452130554
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["pl"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "allegro-pl@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "9933195c-160e-41bb-984b-019137687d48",
+ "last_modified": 1597452130533
+ },
+ {
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["sv-SE"]
+ }
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "allaannonser-sv-SE@search.mozilla.org",
+ "version": "1.2"
+ },
+ "id": "7011ae79-112a-494a-834b-857b66e444c0",
+ "last_modified": 1597452130511
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ }
+ ]
+ },
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": [
+ "as",
+ "bn",
+ "bn-IN",
+ "kn",
+ "gu-IN",
+ "mai",
+ "ml",
+ "mr",
+ "or",
+ "pa-IN",
+ "ta",
+ "te",
+ "ur"
+ ]
+ }
+ },
+ "telemetryId": "amazon-in",
+ "webExtension": {
+ "locales": ["in"]
+ }
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ },
+ {
+ "name": "sourceid",
+ "value": "Mozilla-search"
+ }
+ ]
+ },
+ "included": {
+ "locales": {
+ "matches": ["br", "ff", "fr", "son", "wo"]
+ }
+ },
+ "telemetryId": "amazon-france",
+ "webExtension": {
+ "locales": ["france"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["br", "ff", "fr", "son", "wo"]
+ },
+ "regions": ["ca"]
+ },
+ "telemetryId": "amazon-ca",
+ "webExtension": {
+ "locales": ["ca"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": ["en-CA"]
+ }
+ },
+ "telemetryId": "amazon-ca",
+ "webExtension": {
+ "locales": ["ca"]
+ }
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ },
+ {
+ "name": "sourceid",
+ "value": "Mozilla-search"
+ }
+ ]
+ },
+ "included": {
+ "locales": {
+ "matches": ["ja-JP-macos", "ja"]
+ }
+ },
+ "telemetryId": "amazon-jp",
+ "webExtension": {
+ "locales": ["jp"]
+ }
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ },
+ {
+ "name": "sourceid",
+ "value": "Mozilla-search"
+ }
+ ]
+ },
+ "included": {
+ "locales": {
+ "matches": ["it", "lij"]
+ }
+ },
+ "telemetryId": "amazon-it",
+ "webExtension": {
+ "locales": ["it"]
+ }
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ },
+ {
+ "name": "sourceid",
+ "value": "Mozilla-search"
+ }
+ ]
+ },
+ "included": {
+ "locales": {
+ "matches": ["de", "dsb", "hsb"]
+ }
+ },
+ "telemetryId": "amazon-de",
+ "webExtension": {
+ "locales": ["de"]
+ }
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ },
+ {
+ "name": "sourceid",
+ "value": "Mozilla-search"
+ }
+ ]
+ },
+ "included": {
+ "locales": {
+ "matches": [
+ "cy",
+ "da",
+ "el",
+ "en-GB",
+ "eu",
+ "ga-IE",
+ "gd",
+ "gl",
+ "hr",
+ "nb-NO",
+ "nn-NO",
+ "pt-PT",
+ "sq",
+ "sr"
+ ]
+ }
+ },
+ "telemetryId": "amazon-en-GB",
+ "webExtension": {
+ "locales": ["en-GB"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": [
+ "cy",
+ "da",
+ "el",
+ "en-GB",
+ "eu",
+ "ga-IE",
+ "gd",
+ "gl",
+ "hr",
+ "nb-NO",
+ "nn-NO",
+ "pt-PT",
+ "sq",
+ "sr"
+ ]
+ },
+ "regions": ["au"]
+ },
+ "telemetryId": "amazon-au",
+ "webExtension": {
+ "locales": ["au"]
+ }
+ }
+ ],
+ "orderHint": 500,
+ "webExtension": {
+ "id": "amazon@search.mozilla.org",
+ "version": "1.1"
+ },
+ "id": "968c66a0-0d32-4bdb-a132-66d0e7dead54",
+ "last_modified": 1597452130421
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ },
+ {
+ "name": "sourceid",
+ "value": "Mozilla-search"
+ }
+ ]
+ },
+ "appliesTo": [
+ {
+ "included": {
+ "regions": ["default"]
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": [
+ "ach",
+ "af",
+ "ar",
+ "az",
+ "bg",
+ "cak",
+ "en-US",
+ "eo",
+ "es-AR",
+ "fa",
+ "gn",
+ "hy-AM",
+ "ia",
+ "is",
+ "ka",
+ "km",
+ "lt",
+ "mk",
+ "ms",
+ "my",
+ "ro",
+ "si",
+ "th",
+ "tl",
+ "trs",
+ "uz"
+ ]
+ }
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": [
+ "ach",
+ "af",
+ "ar",
+ "az",
+ "bg",
+ "cak",
+ "en-US",
+ "eo",
+ "es-AR",
+ "fa",
+ "gn",
+ "hy-AM",
+ "ia",
+ "is",
+ "ka",
+ "km",
+ "lt",
+ "mk",
+ "ms",
+ "my",
+ "ro",
+ "si",
+ "th",
+ "tl",
+ "trs",
+ "uz"
+ ]
+ },
+ "regions": ["au"]
+ },
+ "telemetryId": "amazon-au",
+ "webExtension": {
+ "id": "amazon@search.mozilla.org",
+ "locales": ["au"],
+ "version": "1.1"
+ }
+ },
+ {
+ "included": {
+ "locales": {
+ "matches": [
+ "ach",
+ "af",
+ "ar",
+ "az",
+ "bg",
+ "cak",
+ "en-US",
+ "eo",
+ "es-AR",
+ "fa",
+ "gn",
+ "hy-AM",
+ "ia",
+ "is",
+ "ka",
+ "km",
+ "lt",
+ "mk",
+ "ms",
+ "my",
+ "ro",
+ "si",
+ "th",
+ "tl",
+ "trs",
+ "uz"
+ ]
+ },
+ "regions": ["ca"]
+ },
+ "telemetryId": "amazon-ca",
+ "webExtension": {
+ "id": "amazon@search.mozilla.org",
+ "locales": ["ca"],
+ "version": "1.1"
+ }
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ },
+ {
+ "name": "sourceid",
+ "value": "Mozilla-search"
+ }
+ ]
+ },
+ "included": {
+ "locales": {
+ "matches": [
+ "ach",
+ "af",
+ "ar",
+ "az",
+ "bg",
+ "cak",
+ "en-US",
+ "eo",
+ "es-AR",
+ "fa",
+ "gn",
+ "hy-AM",
+ "ia",
+ "is",
+ "ka",
+ "km",
+ "lt",
+ "mk",
+ "ms",
+ "my",
+ "ro",
+ "si",
+ "th",
+ "tl",
+ "trs",
+ "uz"
+ ]
+ },
+ "regions": ["fr"]
+ },
+ "telemetryId": "amazon-france",
+ "webExtension": {
+ "id": "amazon@search.mozilla.org",
+ "locales": ["france"],
+ "version": "1.1"
+ }
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "field-keywords",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "ie",
+ "value": "{inputEncoding}"
+ },
+ {
+ "name": "mode",
+ "value": "blended"
+ },
+ {
+ "name": "sourceid",
+ "value": "Mozilla-search"
+ }
+ ]
+ },
+ "included": {
+ "locales": {
+ "matches": [
+ "ach",
+ "af",
+ "ar",
+ "az",
+ "bg",
+ "cak",
+ "en-US",
+ "eo",
+ "es-AR",
+ "fa",
+ "gn",
+ "hy-AM",
+ "ia",
+ "is",
+ "ka",
+ "km",
+ "lt",
+ "mk",
+ "ms",
+ "my",
+ "ro",
+ "si",
+ "th",
+ "tl",
+ "trs",
+ "uz"
+ ]
+ },
+ "regions": ["gb"]
+ },
+ "telemetryId": "amazon-en-GB",
+ "webExtension": {
+ "id": "amazon@search.mozilla.org",
+ "locales": ["en-GB"],
+ "version": "1.1"
+ }
+ }
+ ],
+ "orderHint": 500,
+ "telemetryId": "amazondotcom",
+ "webExtension": {
+ "id": "amazondotcom@search.mozilla.org",
+ "version": "1.1"
+ },
+ "id": "5eb7e179-c11d-4029-99b7-bc7ea1467d46",
+ "last_modified": 1597452130399
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "wd",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "tn",
+ "value": "monline_7_dg"
+ },
+ {
+ "name": "ie",
+ "value": "utf-8"
+ }
+ ],
+ "suggestUrlGetParams": [
+ {
+ "name": "wd",
+ "value": "{searchTerms}"
+ },
+ {
+ "name": "tn",
+ "value": "monline_7_dg"
+ },
+ {
+ "name": "ie",
+ "value": "utf-8"
+ },
+ {
+ "name": "action",
+ "value": "opensearch"
+ }
+ ]
+ },
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": ["zh-CN"]
+ }
+ }
+ },
+ {
+ "default": "yes",
+ "included": {
+ "locales": {
+ "matches": ["zh-CN"]
+ },
+ "regions": ["cn"]
+ }
+ }
+ ],
+ "telemetryId": "baidu",
+ "webExtension": {
+ "id": "baidu@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "59f371ee-05cc-4c9e-8961-27fe1fa4cbc2",
+ "last_modified": 1597452130376
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "q",
+ "value": "{searchTerms}"
+ }
+ ]
+ },
+ "appliesTo": [
+ {
+ "included": {
+ "locales": {
+ "matches": [
+ "ach",
+ "af",
+ "an",
+ "ar",
+ "ast",
+ "az",
+ "ca",
+ "ca-valencia",
+ "cak",
+ "da",
+ "de",
+ "dsb",
+ "el",
+ "eo",
+ "es-CL",
+ "es-ES",
+ "es-MX",
+ "eu",
+ "fa",
+ "ff",
+ "fi",
+ "fr",
+ "fy-NL",
+ "gn",
+ "gu-IN",
+ "hi-IN",
+ "hr",
+ "hsb",
+ "ia",
+ "is",
+ "it",
+ "ja-JP-macos",
+ "ja",
+ "ka",
+ "kab",
+ "km",
+ "kn",
+ "lij",
+ "lo",
+ "lt",
+ "mai",
+ "mk",
+ "ml",
+ "ms",
+ "my",
+ "nb-NO",
+ "ne-NP",
+ "nl",
+ "nn-NO",
+ "oc",
+ "or",
+ "pa-IN",
+ "pt-BR",
+ "rm",
+ "ro",
+ "son",
+ "sq",
+ "sr",
+ "sv-SE",
+ "th",
+ "tl",
+ "trs",
+ "uk",
+ "ur",
+ "uz",
+ "wo",
+ "xh",
+ "zh-CN"
+ ],
+ "startsWith": ["bn", "en"]
+ }
+ }
+ },
+ {
+ "included": {
+ "regions": ["default"]
+ }
+ }
+ ],
+ "webExtension": {
+ "id": "bing@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "d6b19d48-c263-49f9-9f5b-72fb5a3824bc",
+ "last_modified": 1597452130353
+ },
+ {
+ "params": {
+ "searchUrlGetParams": [
+ {
+ "name": "q",
+ "value": "{searchTerms}"
+ }
+ ]
+ },
+ "appliesTo": [
+ {
+ "default": "yes-if-no-other",
+ "included": {
+ "everywhere": true
+ }
+ }
+ ],
+ "orderHint": 1000,
+ "telemetryId": "google",
+ "webExtension": {
+ "id": "google@search.mozilla.org",
+ "version": "1.0"
+ },
+ "id": "6158e467-c0d3-48e8-a5cf-a2102b7f9456",
+ "last_modified": 1597452130330
+ }
+ ],
+ "timestamp": 1648185887941
+}
diff --git a/comm/mail/app/settings/dumps/thunderbird/url-classifier-skip-urls.json b/comm/mail/app/settings/dumps/thunderbird/url-classifier-skip-urls.json
new file mode 100644
index 0000000000..72d65b7215
--- /dev/null
+++ b/comm/mail/app/settings/dumps/thunderbird/url-classifier-skip-urls.json
@@ -0,0 +1,4 @@
+{
+ "data": [],
+ "timestamp": 1617259999367
+}
diff --git a/comm/mail/app/settings/moz.build b/comm/mail/app/settings/moz.build
new file mode 100644
index 0000000000..9b3acf08b9
--- /dev/null
+++ b/comm/mail/app/settings/moz.build
@@ -0,0 +1,10 @@
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Thunderbird", "General")
+
+DIRS += [
+ "dumps",
+]
diff --git a/comm/mail/app/splash.rc b/comm/mail/app/splash.rc
new file mode 100644
index 0000000000..4a3f209482
--- /dev/null
+++ b/comm/mail/app/splash.rc
@@ -0,0 +1,24 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+#include <windows.h>
+#include "nsNativeAppSupportWin.h"
+
+1 24 "thunderbird.exe.manifest"
+
+STRINGTABLE DISCARDABLE
+BEGIN
+ IDS_STARTMENU_APPNAME, "@MOZ_APP_DISPLAYNAME@"
+END
+
+// Program icon.
+IDI_APPLICATION ICON MESSENGERWINDOW_ICO
+
+// For some reason IDI_MAILBIFF needs to be larger than the value of IDI_APPLICATION for static builds
+#define IDI_MAILBIFF 32576
+IDI_MAILBIFF ICON NEWMAIL_ICO
+
+// Windows taskbar icons
+#define IDI_WRITE_MESSAGE 32577
+IDI_WRITE_MESSAGE ICON WRITEMESSAGE_ICO
diff --git a/comm/mail/app/thunderbird.exe.manifest b/comm/mail/app/thunderbird.exe.manifest
new file mode 100644
index 0000000000..f3ac19a291
--- /dev/null
+++ b/comm/mail/app/thunderbird.exe.manifest
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+<assemblyIdentity
+ version="1.0.0.0"
+ processorArchitecture="*"
+ name="Mozilla.Thunderbird"
+ type="win32"
+/>
+<description>Mozilla Thunderbird</description>
+<dependency>
+ <dependentAssembly>
+ <assemblyIdentity
+ type="win32"
+ name="Microsoft.Windows.Common-Controls"
+ version="6.0.0.0"
+ processorArchitecture="*"
+ publicKeyToken="6595b64144ccf1df"
+ language="*"
+ />
+ </dependentAssembly>
+</dependency>
+<dependency>
+ <dependentAssembly>
+ <assemblyIdentity
+ type="win32"
+ name="mozglue"
+ version="1.0.0.0"
+ language="*"
+ />
+ </dependentAssembly>
+</dependency>
+<ms_asmv3:trustInfo xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:security>
+ <ms_asmv3:requestedPrivileges>
+ <ms_asmv3:requestedExecutionLevel level="asInvoker" uiAccess="false" />
+ </ms_asmv3:requestedPrivileges>
+ </ms_asmv3:security>
+</ms_asmv3:trustInfo>
+ <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
+ <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
+ <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
+ <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
+ </application>
+ </compatibility>
+</assembly>
diff --git a/comm/mail/base/content/FilterListDialog.js b/comm/mail/base/content/FilterListDialog.js
new file mode 100644
index 0000000000..a802da6d78
--- /dev/null
+++ b/comm/mail/base/content/FilterListDialog.js
@@ -0,0 +1,1162 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+window.addEventListener("load", onLoad);
+window.addEventListener("unload", onFilterUnload);
+window.addEventListener("close", event => {
+ if (!onFilterClose()) {
+ event.preventDefault();
+ }
+});
+
+var gFilterListMsgWindow = null;
+var gCurrentFilterList;
+var gServerMenu = null;
+var gFilterListbox = null;
+var gEditButton = null;
+var gDeleteButton = null;
+var gCopyToNewButton = null;
+var gTopButton = null;
+var gUpButton = null;
+var gDownButton = null;
+var gBottomButton = null;
+var gSearchBox = null;
+var gRunFiltersFolder = null;
+var gRunFiltersButton = null;
+
+var gFilterBundle = null;
+
+var msgMoveMotion = {
+ Up: 0,
+ Down: 1,
+ Top: 2,
+ Bottom: 3,
+};
+
+var gStatusFeedback = {
+ progressMeterVisible: false,
+
+ showStatusString(status) {
+ document.getElementById("statusText").setAttribute("value", status);
+ },
+ startMeteors() {
+ // change run button to be a stop button
+ gRunFiltersButton.setAttribute(
+ "label",
+ gRunFiltersButton.getAttribute("stoplabel")
+ );
+ gRunFiltersButton.setAttribute(
+ "accesskey",
+ gRunFiltersButton.getAttribute("stopaccesskey")
+ );
+
+ if (!this.progressMeterVisible) {
+ document
+ .getElementById("statusbar-progresspanel")
+ .removeAttribute("collapsed");
+ this.progressMeterVisible = true;
+ }
+
+ document.getElementById("statusbar-icon").removeAttribute("value");
+ },
+ stopMeteors() {
+ try {
+ // change run button to be a stop button
+ gRunFiltersButton.setAttribute(
+ "label",
+ gRunFiltersButton.getAttribute("runlabel")
+ );
+ gRunFiltersButton.setAttribute(
+ "accesskey",
+ gRunFiltersButton.getAttribute("runaccesskey")
+ );
+
+ if (this.progressMeterVisible) {
+ document.getElementById("statusbar-progresspanel").collapsed = true;
+ this.progressMeterVisible = true;
+ }
+ } catch (ex) {
+ // can get here if closing window when running filters
+ }
+ },
+ showProgress(percentage) {},
+ closeWindow() {},
+};
+
+var filterEditorQuitObserver = {
+ observe(aSubject, aTopic, aData) {
+ // Check whether or not we want to veto the quit request (unless another
+ // observer already did.
+ if (
+ aTopic == "quit-application-requested" &&
+ aSubject instanceof Ci.nsISupportsPRBool &&
+ !aSubject.data
+ ) {
+ aSubject.data = !onFilterClose();
+ }
+ },
+};
+
+function onLoad() {
+ gFilterListMsgWindow = Cc[
+ "@mozilla.org/messenger/msgwindow;1"
+ ].createInstance(Ci.nsIMsgWindow);
+ gFilterListMsgWindow.domWindow = window;
+ gFilterListMsgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL;
+ gFilterListMsgWindow.statusFeedback = gStatusFeedback;
+
+ gServerMenu = document.getElementById("serverMenu");
+ gFilterListbox = document.getElementById("filterList");
+ gEditButton = document.getElementById("editButton");
+ gDeleteButton = document.getElementById("deleteButton");
+ gCopyToNewButton = document.getElementById("copyToNewButton");
+ gTopButton = document.getElementById("reorderTopButton");
+ gUpButton = document.getElementById("reorderUpButton");
+ gDownButton = document.getElementById("reorderDownButton");
+ gBottomButton = document.getElementById("reorderBottomButton");
+ gSearchBox = document.getElementById("searchBox");
+ gRunFiltersFolder = document.getElementById("runFiltersFolder");
+ gRunFiltersButton = document.getElementById("runFiltersButton");
+ gFilterBundle = document.getElementById("bundle_filter");
+
+ updateButtons();
+
+ initNewToolbarButtons(document.querySelector("#newButton toolbarbutton"));
+ initNewToolbarButtons(document.querySelector("#newButton dropmarker"));
+ document
+ .getElementById("filterActionButtons")
+ .addEventListener("keypress", event => onFilterActionButtonKeyPress(event));
+
+ processWindowArguments(window.arguments[0]);
+
+ // Don't change width after initial layout, so buttons stay within the dialog.
+ gRunFiltersFolder.style.maxWidth =
+ gRunFiltersFolder.getBoundingClientRect().width + "px";
+
+ Services.obs.addObserver(
+ filterEditorQuitObserver,
+ "quit-application-requested"
+ );
+}
+/**
+ * Set up the toolbarbutton to have an index and an EvenListener for proper
+ * keyboard navigation.
+ *
+ * @param {XULElement} newToolbarbutton - The toolbarbutton that needs to be
+ * initialized.
+ */
+function initNewToolbarButtons(newToolbarbutton) {
+ newToolbarbutton.setAttribute("tabindex", "0");
+ newToolbarbutton.setAttribute(
+ "id",
+ newToolbarbutton.parentNode.id + newToolbarbutton.tagName
+ );
+}
+
+/**
+ * Processes arguments sent to this dialog when opened or refreshed.
+ *
+ * @param aArguments An object having members representing the arguments.
+ * { arg1: value1, arg2: value2, ... }
+ */
+function processWindowArguments(aArguments) {
+ // If a specific folder was requested, try to select it
+ // if we don't already show its server.
+ if (
+ !gServerMenu._folder ||
+ ("folder" in aArguments &&
+ aArguments.folder != gServerMenu._folder &&
+ aArguments.folder.rootFolder != gServerMenu._folder)
+ ) {
+ let wantedFolder;
+ if ("folder" in aArguments) {
+ wantedFolder = aArguments.folder;
+ }
+
+ // Get the folder where filters should be defined, if that server
+ // can accept filters.
+ let firstItem = getFilterFolderForSelection(wantedFolder);
+
+ // If the selected server cannot have filters, get the default server
+ // If the default server cannot have filters, check all accounts
+ // and get a server that can have filters.
+ if (!firstItem) {
+ firstItem = getServerThatCanHaveFilters().rootFolder;
+ }
+
+ if (firstItem) {
+ setFilterFolder(firstItem);
+ }
+
+ if (wantedFolder) {
+ setRunFolder(wantedFolder);
+ }
+ } else {
+ // If we didn't change folder still redraw the list
+ // to show potential new filters if we were called for refresh.
+ rebuildFilterList();
+ }
+
+ // If a specific filter was requested, try to select it.
+ if ("filter" in aArguments) {
+ selectFilter(aArguments.filter);
+ }
+}
+
+/**
+ * This is called from OpenOrFocusWindow() if the dialog is already open.
+ * New filters could have been created by operations outside the dialog.
+ *
+ * @param aArguments An object of arguments having the same format
+ * as window.arguments[0].
+ */
+function refresh(aArguments) {
+ // As we really don't know what has changed, clear the search box
+ // undonditionally so that the changed/added filters are surely visible.
+ resetSearchBox();
+
+ processWindowArguments(aArguments);
+}
+
+function CanRunFiltersAfterTheFact(aServer) {
+ // filter after the fact is implement using search
+ // so if you can't search, you can't filter after the fact
+ return aServer.canSearchMessages;
+}
+
+/**
+ * Change the root server for which we are managing filters.
+ *
+ * @param msgFolder The nsIMsgFolder server containing filters
+ * (or a folder for NNTP server).
+ */
+function setFilterFolder(msgFolder) {
+ if (!msgFolder || msgFolder == gServerMenu._folder) {
+ return;
+ }
+
+ // Save the current filters to disk before switching because
+ // the dialog may be closed and we'll lose current filters.
+ if (gCurrentFilterList) {
+ gCurrentFilterList.saveToDefaultFile();
+ }
+
+ // Setting this attribute should go away in bug 473009.
+ gServerMenu._folder = msgFolder;
+ // Calling this should go away in bug 802609.
+ gServerMenu.menupopup.selectFolder(msgFolder);
+
+ // Calling getEditableFilterList will detect any errors in msgFilterRules.dat,
+ // backup the file, and alert the user.
+ gCurrentFilterList = msgFolder.getEditableFilterList(gFilterListMsgWindow);
+ rebuildFilterList();
+
+ // Select the first item in the list, if there is one.
+ if (gFilterListbox.itemCount > 0) {
+ gFilterListbox.selectItem(gFilterListbox.getItemAtIndex(0));
+ }
+
+ // This will get the deferred to account root folder, if server is deferred.
+ // We intentionally do this after setting the current server, as we want
+ // that to refer to the rootFolder for the actual server, not the
+ // deferred-to server, as current server is really a proxy for the
+ // server whose filters we are editing. But below here we are managing
+ // where the filters will get applied, which is on the deferred-to server.
+ msgFolder = msgFolder.server.rootMsgFolder;
+
+ // root the folder picker to this server
+ let runMenu = gRunFiltersFolder.menupopup;
+ runMenu._teardown();
+ runMenu._parentFolder = msgFolder;
+ runMenu._ensureInitialized();
+
+ let canFilterAfterTheFact = CanRunFiltersAfterTheFact(msgFolder.server);
+ gRunFiltersFolder.disabled = !canFilterAfterTheFact;
+ gRunFiltersButton.disabled = !canFilterAfterTheFact;
+ document.getElementById("folderPickerPrefix").disabled =
+ !canFilterAfterTheFact;
+
+ if (canFilterAfterTheFact) {
+ let wantedFolder = null;
+ // For a given server folder, get the default run target folder or show
+ // "Choose Folder".
+ if (!msgFolder.isServer) {
+ wantedFolder = msgFolder;
+ } else {
+ try {
+ switch (msgFolder.server.type) {
+ case "nntp":
+ // For NNTP select the subscribed newsgroup.
+ wantedFolder = gServerMenu._folder;
+ break;
+ case "rss":
+ // Show "Choose Folder" for feeds.
+ wantedFolder = null;
+ break;
+ case "imap":
+ case "pop3":
+ case "none":
+ // Find Inbox for IMAP and POP or Local Folders,
+ // show "Choose Folder" if not found.
+ wantedFolder = msgFolder.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ break;
+ default:
+ // For other account types we don't know what's good to select,
+ // so show "Choose Folder".
+ wantedFolder = null;
+ }
+ } catch (e) {
+ console.error(
+ "Failed to select a suitable folder to run filters on: " + e
+ );
+ wantedFolder = null;
+ }
+ }
+
+ // Select a useful first folder for the server.
+ setRunFolder(wantedFolder);
+ }
+}
+
+/**
+ * Select a folder on which filters are to be run.
+ *
+ * @param aFolder nsIMsgFolder folder to select.
+ */
+function setRunFolder(aFolder) {
+ // Setting this attribute should go away in bug 473009.
+ gRunFiltersFolder._folder = aFolder;
+ // Calling this should go away in bug 802609.
+ gRunFiltersFolder.menupopup.selectFolder(gRunFiltersFolder._folder);
+ updateButtons();
+}
+
+/**
+ * Toggle enabled state of a filter, in both the filter properties and the UI.
+ *
+ * @param aFilterItem an item (row) of the filter list to be toggled
+ */
+function toggleFilter(aFilterItem, aSetForEvent) {
+ let filter = aFilterItem._filter;
+ if (filter.unparseable && !filter.enabled) {
+ Services.prompt.alert(
+ window,
+ null,
+ gFilterBundle.getFormattedString("cannotEnableIncompatFilter", [
+ document.getElementById("bundle_brand").getString("brandShortName"),
+ ])
+ );
+ return;
+ }
+ filter.enabled = aSetForEvent === undefined ? !filter.enabled : aSetForEvent;
+
+ // Now update the checkbox
+ if (aSetForEvent === undefined) {
+ aFilterItem.firstElementChild.nextElementSibling.checked = filter.enabled;
+ }
+ // For accessibility set the checked state on listitem
+ aFilterItem.setAttribute("aria-checked", filter.enabled);
+}
+
+/**
+ * Selects a specific filter in the filter list.
+ * The listbox view is scrolled to the corresponding item.
+ *
+ * @param aFilter The nsIMsgFilter to select.
+ *
+ * @returns true/false indicating whether the filter was found and selected.
+ */
+function selectFilter(aFilter) {
+ if (currentFilter() == aFilter) {
+ return true;
+ }
+
+ resetSearchBox(aFilter);
+
+ let filterCount = gCurrentFilterList.filterCount;
+ for (let i = 0; i < filterCount; i++) {
+ if (gCurrentFilterList.getFilterAt(i) == aFilter) {
+ gFilterListbox.ensureIndexIsVisible(i);
+ gFilterListbox.selectedIndex = i;
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Returns the currently selected filter. If multiple filters are selected,
+ * returns the first one. If none are selected, returns null.
+ */
+function currentFilter() {
+ let currentItem = gFilterListbox.selectedItem;
+ return currentItem ? currentItem._filter : null;
+}
+
+function onEditFilter() {
+ if (gEditButton.disabled) {
+ return;
+ }
+
+ let selectedFilter = currentFilter();
+ if (!selectedFilter) {
+ return;
+ }
+
+ let args = { filter: selectedFilter, filterList: gCurrentFilterList };
+
+ window.openDialog(
+ "chrome://messenger/content/FilterEditor.xhtml",
+ "FilterEditor",
+ "chrome,modal,titlebar,resizable,centerscreen",
+ args
+ );
+
+ if ("refresh" in args && args.refresh) {
+ // reset search if edit was okay (name change might lead to hidden entry!)
+ resetSearchBox(selectedFilter);
+ rebuildFilterList();
+ }
+}
+
+/**
+ * Handler function for the 'New...' buttons.
+ * Opens the filter dialog for creating a new filter.
+ */
+function onNewFilter() {
+ calculatePositionAndShowCreateFilterDialog({});
+}
+
+/**
+ * Handler function for the 'Copy...' button.
+ * Opens the filter dialog for copying the selected filter.
+ */
+function onCopyToNewFilter() {
+ if (gCopyToNewButton.disabled) {
+ return;
+ }
+
+ let selectedFilter = currentFilter();
+ if (!selectedFilter) {
+ return;
+ }
+
+ let args = { copiedFilter: selectedFilter };
+
+ calculatePositionAndShowCreateFilterDialog(args);
+}
+
+/**
+ * Calculates the position for inserting the new filter,
+ * and then displays the create dialog.
+ *
+ * @param args The object containing the arguments for the dialog,
+ * passed to the filterEditorOnLoad() function.
+ * It will be augmented with the insertion position
+ * and global filters list properties by this function.
+ */
+function calculatePositionAndShowCreateFilterDialog(args) {
+ let selectedFilter = currentFilter();
+ // If no filter is selected use the first position.
+ let position = 0;
+ if (selectedFilter) {
+ // Get the position in the unfiltered list.
+ // - this is where the new filter should be inserted!
+ let filterCount = gCurrentFilterList.filterCount;
+ for (let i = 0; i < filterCount; i++) {
+ if (gCurrentFilterList.getFilterAt(i) == selectedFilter) {
+ position = i;
+ break;
+ }
+ }
+ }
+ args.filterPosition = position;
+
+ args.filterList = gCurrentFilterList;
+
+ window.openDialog(
+ "chrome://messenger/content/FilterEditor.xhtml",
+ "FilterEditor",
+ "chrome,modal,titlebar,resizable,centerscreen",
+ args
+ );
+
+ if ("refresh" in args && args.refresh) {
+ // On success: reset the search box if necessary!
+ resetSearchBox(args.newFilter);
+ rebuildFilterList();
+
+ // Select the new filter, it is at the position of previous selection.
+ gFilterListbox.selectItem(gFilterListbox.getItemAtIndex(position));
+ if (currentFilter() != args.newFilter) {
+ console.error("Filter created at an unexpected position!");
+ }
+ }
+}
+
+/**
+ * Delete selected filters.
+ * 'Selected' is not to be confused with active (checkbox checked)
+ */
+function onDeleteFilter() {
+ if (gDeleteButton.disabled) {
+ return;
+ }
+
+ let items = gFilterListbox.selectedItems;
+ if (!items.length) {
+ return;
+ }
+
+ let checkValue = { value: false };
+ if (
+ Services.prefs.getBoolPref("mailnews.filters.confirm_delete") &&
+ Services.prompt.confirmEx(
+ window,
+ null,
+ gFilterBundle.getString("deleteFilterConfirmation"),
+ Services.prompt.STD_YES_NO_BUTTONS,
+ "",
+ "",
+ "",
+ gFilterBundle.getString("dontWarnAboutDeleteCheckbox"),
+ checkValue
+ )
+ ) {
+ return;
+ }
+
+ if (checkValue.value) {
+ Services.prefs.setBoolPref("mailnews.filters.confirm_delete", false);
+ }
+
+ // Save filter position before the first selected one.
+ let newSelectionIndex = gFilterListbox.selectedIndex - 1;
+
+ // Must reverse the loop, as the items list shrinks when we delete.
+ for (let index = items.length - 1; index >= 0; --index) {
+ let item = items[index];
+ gCurrentFilterList.removeFilter(item._filter);
+ item.remove();
+ }
+ updateCountBox();
+
+ // Select filter above previously selected if one existed, otherwise the first one.
+ if (newSelectionIndex == -1 && gFilterListbox.itemCount > 0) {
+ newSelectionIndex = 0;
+ }
+ if (newSelectionIndex > -1) {
+ gFilterListbox.selectedIndex = newSelectionIndex;
+ updateViewPosition(-1);
+ }
+}
+
+/**
+ * Move filter one step up in visible list.
+ */
+function onUp(event) {
+ moveFilter(msgMoveMotion.Up);
+}
+
+/**
+ * Move filter one step down in visible list.
+ */
+function onDown(event) {
+ moveFilter(msgMoveMotion.Down);
+}
+
+/**
+ * Move filter to bottom for long filter lists.
+ */
+function onTop(evt) {
+ moveFilter(msgMoveMotion.Top);
+}
+
+/**
+ * Move filter to top for long filter lists.
+ */
+function onBottom(evt) {
+ moveFilter(msgMoveMotion.Bottom);
+}
+
+/**
+ * Moves a singular selected filter up or down either 1 increment or to the
+ * top/bottom. This acts on the visible filter list only which means that:
+ *
+ * - when moving up or down "1" the filter may skip one or more other
+ * filters (which are currently not visible) - this will also lead
+ * to the "related" filters (e.g search filters containing 'moz')
+ * being grouped more closely together
+ * - moveTop / moveBottom
+ * this is currently moving to the top/bottom of the absolute list
+ * but it would be better if it moved "just as far as necessary"
+ * which would further "compact" related filters
+ *
+ * @param motion
+ * msgMoveMotion.Up, msgMoveMotion.Down, msgMoveMotion.Top, msgMoveMotion.Bottom
+ */
+function moveFilter(motion) {
+ // At the moment, do not allow moving groups of filters.
+ let selectedFilter = currentFilter();
+ if (!selectedFilter) {
+ return;
+ }
+
+ var relativeStep = 0;
+ var moveFilterNative = null;
+
+ switch (motion) {
+ case msgMoveMotion.Top:
+ if (selectedFilter) {
+ gCurrentFilterList.removeFilter(selectedFilter);
+ gCurrentFilterList.insertFilterAt(0, selectedFilter);
+ rebuildFilterList();
+ }
+ return;
+ case msgMoveMotion.Bottom:
+ if (selectedFilter) {
+ gCurrentFilterList.removeFilter(selectedFilter);
+ gCurrentFilterList.insertFilterAt(
+ gCurrentFilterList.filterCount,
+ selectedFilter
+ );
+ rebuildFilterList();
+ }
+ return;
+ case msgMoveMotion.Up:
+ relativeStep = -1;
+ moveFilterNative = Ci.nsMsgFilterMotion.up;
+ break;
+ case msgMoveMotion.Down:
+ relativeStep = +1;
+ moveFilterNative = Ci.nsMsgFilterMotion.down;
+ break;
+ }
+
+ if (!gSearchBox.value) {
+ // use legacy move filter code: up, down; only if searchBox is empty
+ moveCurrentFilter(moveFilterNative);
+ return;
+ }
+
+ let nextIndex = gFilterListbox.selectedIndex + relativeStep;
+ let nextFilter = gFilterListbox.getItemAtIndex(nextIndex)._filter;
+
+ gCurrentFilterList.removeFilter(selectedFilter);
+
+ // Find the index of the filter we want to insert at.
+ let newIndex = -1;
+ let filterCount = gCurrentFilterList.filterCount;
+ for (let i = 0; i < filterCount; i++) {
+ if (gCurrentFilterList.getFilterAt(i) == nextFilter) {
+ newIndex = i;
+ break;
+ }
+ }
+
+ if (motion == msgMoveMotion.Down) {
+ newIndex += relativeStep;
+ }
+
+ gCurrentFilterList.insertFilterAt(newIndex, selectedFilter);
+
+ rebuildFilterList();
+}
+
+function viewLog() {
+ var args = { filterList: gCurrentFilterList };
+
+ window.openDialog(
+ "chrome://messenger/content/viewLog.xhtml",
+ "FilterLog",
+ "chrome,modal,titlebar,resizable,centerscreen",
+ args
+ );
+}
+
+function onFilterUnload() {
+ gCurrentFilterList.saveToDefaultFile();
+ Services.obs.removeObserver(
+ filterEditorQuitObserver,
+ "quit-application-requested"
+ );
+
+ gFilterListMsgWindow.closeWindow();
+}
+
+function onFilterClose() {
+ if (
+ gRunFiltersButton.getAttribute("label") ==
+ gRunFiltersButton.getAttribute("stoplabel")
+ ) {
+ let promptTitle = gFilterBundle.getString("promptTitle");
+ let promptMsg = gFilterBundle.getString("promptMsg");
+ let stopButtonLabel = gFilterBundle.getString("stopButtonLabel");
+ let continueButtonLabel = gFilterBundle.getString("continueButtonLabel");
+
+ let result = Services.prompt.confirmEx(
+ window,
+ promptTitle,
+ promptMsg,
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1,
+ continueButtonLabel,
+ stopButtonLabel,
+ null,
+ null,
+ { value: 0 }
+ );
+
+ if (result) {
+ gFilterListMsgWindow.StopUrls();
+ } else {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function runSelectedFilters() {
+ // if run button has "stop" label, do stop.
+ if (
+ gRunFiltersButton.getAttribute("label") ==
+ gRunFiltersButton.getAttribute("stoplabel")
+ ) {
+ gFilterListMsgWindow.StopUrls();
+ return;
+ }
+
+ let folder =
+ gRunFiltersFolder._folder || gRunFiltersFolder.selectedItem._folder;
+ if (!folder) {
+ return;
+ }
+
+ let filterList = MailServices.filters.getTempFilterList(folder);
+
+ // make sure the tmp filter list uses the real filter list log stream
+ filterList.loggingEnabled = gCurrentFilterList.loggingEnabled;
+ filterList.logStream = gCurrentFilterList.logStream;
+
+ let index = 0;
+ for (let item of gFilterListbox.selectedItems) {
+ filterList.insertFilterAt(index++, item._filter);
+ }
+
+ MailServices.filters.applyFiltersToFolders(
+ filterList,
+ [folder],
+ gFilterListMsgWindow
+ );
+}
+
+function moveCurrentFilter(motion) {
+ let filter = currentFilter();
+ if (!filter) {
+ return;
+ }
+
+ gCurrentFilterList.moveFilter(filter, motion);
+ rebuildFilterList();
+}
+
+/**
+ * Redraws the list of filters. Takes the search box value into account.
+ *
+ * This function should perform very fast even in case of high number of filters.
+ * Therefore there are some optimizations (e.g. listelement.itemChildren[] instead of
+ * list.getItemAtIndex()), that favour speed vs. semantical perfection.
+ */
+function rebuildFilterList() {
+ // Get filters that match the search box.
+ let aTempFilterList = onFindFilter();
+
+ let searchBoxFocus = false;
+ let activeElement = document.activeElement;
+
+ // Find if the currently focused element is a child inside the search box
+ // (probably html:input). Traverse up the parents until the first element
+ // with an ID is found. If it is not searchBox, return false.
+ while (activeElement != null) {
+ if (activeElement == gSearchBox) {
+ searchBoxFocus = true;
+ break;
+ } else if (activeElement.id) {
+ searchBoxFocus = false;
+ break;
+ }
+ activeElement = activeElement.parentNode;
+ }
+
+ // Make a note of which filters were previously selected
+ let selectedNames = [];
+ for (let i = 0; i < gFilterListbox.selectedItems.length; i++) {
+ selectedNames.push(gFilterListbox.selectedItems[i]._filter.filterName);
+ }
+
+ // Save scroll position so we can try to restore it later.
+ // Doesn't work when the list is rebuilt after search box condition changed.
+ let firstVisibleRowIndex = gFilterListbox.getIndexOfFirstVisibleRow();
+
+ // listbox.xml seems to cache the value of the first selected item in a
+ // range at _selectionStart. The old value though is now obsolete,
+ // since we will recreate all of the elements. We need to clear this,
+ // and one way to do this is with a call to clearSelection. This might be
+ // ugly from an accessibility perspective, since it fires an onSelect event.
+ gFilterListbox.clearSelection();
+
+ let listitem, nameCell, enabledCell, filter;
+ let filterCount = gCurrentFilterList.filterCount;
+ let listitemCount = gFilterListbox.itemCount;
+ let listitemIndex = 0;
+ let tempFilterListLength = aTempFilterList ? aTempFilterList.length - 1 : 0;
+ for (let i = 0; i < filterCount; i++) {
+ if (aTempFilterList && listitemIndex > tempFilterListLength) {
+ break;
+ }
+
+ filter = gCurrentFilterList.getFilterAt(i);
+ if (aTempFilterList && aTempFilterList[listitemIndex] != i) {
+ continue;
+ }
+
+ if (listitemCount > listitemIndex) {
+ // If there is a free existing listitem, reuse it.
+ // Use .itemChildren[] instead of .getItemAtIndex() as it is much faster.
+ listitem = gFilterListbox.itemChildren[listitemIndex];
+ nameCell = listitem.firstElementChild;
+ enabledCell = nameCell.nextElementSibling;
+ } else {
+ // If there are not enough listitems in the list, create a new one.
+ listitem = document.createXULElement("richlistitem");
+ listitem.setAttribute("align", "center");
+ listitem.setAttribute("role", "checkbox");
+ nameCell = document.createXULElement("label");
+ nameCell.setAttribute("flex", "1");
+ nameCell.setAttribute("crop", "end");
+ enabledCell = document.createXULElement("checkbox");
+ enabledCell.setAttribute("style", "padding-inline-start: 25px;");
+ enabledCell.addEventListener("CheckboxStateChange", onFilterClick, true);
+ listitem.appendChild(nameCell);
+ listitem.appendChild(enabledCell);
+ gFilterListbox.appendChild(listitem);
+ // We have to attach this listener to the listitem, even though we only care
+ // about clicks on the enabledCell. However, attaching to that item doesn't
+ // result in any events actually getting received.
+ listitem.addEventListener("dblclick", onFilterDoubleClick, true);
+ }
+ // For accessibility set the label on listitem.
+ listitem.setAttribute("label", filter.filterName);
+ // Set the listitem values to represent the current filter.
+ nameCell.setAttribute("value", filter.filterName);
+ if (filter.enabled) {
+ enabledCell.setAttribute("checked", "true");
+ } else {
+ enabledCell.removeAttribute("checked");
+ }
+ listitem.setAttribute("aria-checked", filter.enabled);
+ listitem._filter = filter;
+
+ if (selectedNames.includes(filter.filterName)) {
+ gFilterListbox.addItemToSelection(listitem);
+ }
+
+ listitemIndex++;
+ }
+ // Remove any superfluous listitems, if the number of filters shrunk.
+ for (let i = listitemCount - 1; i >= listitemIndex; i--) {
+ gFilterListbox.lastChild.remove();
+ }
+
+ updateViewPosition(firstVisibleRowIndex);
+ updateCountBox();
+
+ // If before rebuilding the list the searchbox was focused, focus it again.
+ // In any other case, focus the list.
+ if (searchBoxFocus) {
+ gSearchBox.focus();
+ } else {
+ gFilterListbox.focus();
+ }
+}
+
+function updateViewPosition(firstVisibleRowIndex) {
+ if (firstVisibleRowIndex == -1) {
+ firstVisibleRowIndex = gFilterListbox.getIndexOfFirstVisibleRow();
+ }
+
+ // Restore to the extent possible the scroll position.
+ if (firstVisibleRowIndex && gFilterListbox.itemCount) {
+ gFilterListbox.ensureElementIsVisible(
+ gFilterListbox.getItemAtIndex(
+ Math.min(firstVisibleRowIndex, gFilterListbox.itemCount - 1)
+ ),
+ true
+ );
+ }
+
+ if (gFilterListbox.selectedCount) {
+ // Make sure that at least the first selected item is visible.
+ gFilterListbox.ensureElementIsVisible(gFilterListbox.selectedItems[0]);
+
+ // The current item should be the first selected item, so that keyboard
+ // selection extension can work.
+ gFilterListbox.currentItem = gFilterListbox.selectedItems[0];
+ }
+
+ updateButtons();
+}
+
+/**
+ * Try to only enable buttons that make sense
+ * - moving filters is currently only enabled for single selection
+ * also movement is restricted by searchBox and current selection position
+ * - edit only for single filters
+ * - delete / run only for one or more selected filters
+ */
+function updateButtons() {
+ var numFiltersSelected = gFilterListbox.selectedItems.length;
+ var oneFilterSelected = numFiltersSelected == 1;
+
+ // "edit" is disabled when not exactly one filter is selected
+ // or if we couldn't parse that filter
+ let disabled = !oneFilterSelected || currentFilter().unparseable;
+ gEditButton.disabled = disabled;
+
+ // "copy" is the same as "edit"
+ gCopyToNewButton.disabled = disabled;
+
+ // "delete" only disabled when no filters are selected
+ gDeleteButton.disabled = !numFiltersSelected;
+
+ // we can run multiple filters on a folder
+ // so only disable this UI if no filters are selected
+ document.getElementById("folderPickerPrefix").disabled = !numFiltersSelected;
+ gRunFiltersFolder.disabled = !numFiltersSelected;
+ gRunFiltersButton.disabled =
+ !numFiltersSelected || !gRunFiltersFolder._folder;
+ // "up" and "top" enabled only if one filter is selected, and it's not the first
+ // don't use gFilterListbox.currentIndex here, it's buggy when we've just changed the
+ // children in the list (via rebuildFilterList)
+ disabled = !(
+ oneFilterSelected &&
+ gFilterListbox.getSelectedItem(0) != gFilterListbox.getItemAtIndex(0)
+ );
+ gUpButton.disabled = disabled;
+ gTopButton.disabled = disabled;
+
+ // "down" and "bottom" enabled only if one filter is selected,
+ // and it's not the last one
+ disabled = !(
+ oneFilterSelected &&
+ gFilterListbox.selectedIndex < gFilterListbox.itemCount - 1
+ );
+ gDownButton.disabled = disabled;
+ gBottomButton.disabled = disabled;
+}
+
+/**
+ * Given a selected folder, returns the folder where filters should
+ * be defined (the root folder except for news) if the server can
+ * accept filters.
+ *
+ * @param nsIMsgFolder aFolder - selected folder, from window args
+ * @returns an nsIMsgFolder where the filter is defined
+ */
+function getFilterFolderForSelection(aFolder) {
+ let rootFolder = aFolder && aFolder.server ? aFolder.server.rootFolder : null;
+ if (rootFolder && rootFolder.isServer && rootFolder.server.canHaveFilters) {
+ return aFolder.server.type == "nntp" ? aFolder : rootFolder;
+ }
+
+ return null;
+}
+
+/**
+ * If the selected server cannot have filters, get the default server.
+ * If the default server cannot have filters, check all accounts
+ * and get a server that can have filters.
+ *
+ * @returns an nsIMsgIncomingServer
+ */
+function getServerThatCanHaveFilters() {
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ let defaultIncomingServer = defaultAccount.incomingServer;
+ // Check to see if default server can have filters.
+ if (defaultIncomingServer.canHaveFilters) {
+ return defaultIncomingServer;
+ }
+ }
+
+ // If it cannot, check all accounts to find a server
+ // that can have filters.
+ return MailServices.accounts.allServers.find(server => server.canHaveFilters);
+}
+
+function onFilterClick(event) {
+ // This is called after the clicked checkbox changed state
+ // so this.checked is the right state we want to toggle to.
+ toggleFilter(this.parentNode, this.checked);
+}
+
+function onFilterDoubleClick(event) {
+ // we only care about button 0 (left click) events
+ if (event.button != 0) {
+ return;
+ }
+
+ onEditFilter();
+}
+
+/**
+ * Handles the keypress event on the filter list dialog.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+function onFilterActionButtonKeyPress(event) {
+ if (
+ event.key == "Enter" ||
+ (event.key == " " && event.target.hasAttribute("type"))
+ ) {
+ event.preventDefault();
+
+ if (
+ event.target.classList.contains("toolbarbutton-menubutton-dropmarker")
+ ) {
+ document
+ .getElementById("newFilterMenupopup")
+ .openPopup(event.target.parentNode, "after_end", {
+ triggerEvent: event,
+ });
+ return;
+ }
+ event.target.click();
+ }
+}
+
+function onFilterListKeyPress(aEvent) {
+ if (aEvent.keyCode) {
+ switch (aEvent.keyCode) {
+ case KeyEvent.DOM_VK_INSERT:
+ if (!document.getElementById("newButton").disabled) {
+ onNewFilter();
+ }
+ break;
+ case KeyEvent.DOM_VK_DELETE:
+ if (!document.getElementById("deleteButton").disabled) {
+ onDeleteFilter();
+ }
+ break;
+ case KeyEvent.DOM_VK_RETURN:
+ if (!document.getElementById("editButton").disabled) {
+ onEditFilter();
+ }
+ break;
+ }
+ } else if (!aEvent.ctrlKey && !aEvent.altKey && !aEvent.metaKey) {
+ switch (aEvent.charCode) {
+ case KeyEvent.DOM_VK_SPACE:
+ for (let item of gFilterListbox.selectedItems) {
+ toggleFilter(item);
+ }
+ break;
+ default:
+ gSearchBox.focus();
+ gSearchBox.value = String.fromCharCode(aEvent.charCode);
+ }
+ }
+}
+
+/**
+ * Decides if the given filter matches the given keyword.
+ *
+ * @param aFilter nsIMsgFilter to check
+ * @param aKeyword the string to find in the filter name
+ *
+ * @returns True if the filter name contains the searched keyword.
+ Otherwise false. In the future this may be extended to match
+ other filter attributes.
+ */
+function filterSearchMatch(aFilter, aKeyword) {
+ return aFilter.filterName.toLocaleLowerCase().includes(aKeyword);
+}
+
+/**
+ * Called from rebuildFilterList when the list needs to be redrawn.
+ *
+ * @returns Uses the search term in search box, to produce an array of
+ * row (filter) numbers (indexes) that match the search term.
+ */
+function onFindFilter() {
+ let keyWord = gSearchBox.value.toLocaleLowerCase();
+
+ // If searchbox is empty, just return and let rebuildFilterList
+ // create an unfiltered list.
+ if (!keyWord) {
+ return null;
+ }
+
+ // Rematch everything in the list, remove what doesn't match the search box.
+ let rows = gCurrentFilterList.filterCount;
+ let matchingFilterList = [];
+ // Use the full gCurrentFilterList, not the filterList listbox,
+ // which may already be filtered.
+ for (let i = 0; i < rows; i++) {
+ if (filterSearchMatch(gCurrentFilterList.getFilterAt(i), keyWord)) {
+ matchingFilterList.push(i);
+ }
+ }
+
+ return matchingFilterList;
+}
+
+/**
+ * Clear the search term in the search box if needed.
+ *
+ * @param aFilter If this nsIMsgFilter matches the search term,
+ * do not reset the box. If this is null,
+ * reset unconditionally.
+ */
+function resetSearchBox(aFilter) {
+ let keyword = gSearchBox.value.toLocaleLowerCase();
+ if (keyword && (!aFilter || !filterSearchMatch(aFilter, keyword))) {
+ gSearchBox.reset();
+ }
+}
+
+/**
+ * Display "1 item", "11 items" or "4 of 10" if list is filtered via search box.
+ */
+function updateCountBox() {
+ let countBox = document.getElementById("countBox");
+ let sum = gCurrentFilterList.filterCount;
+ let len = gFilterListbox.itemCount;
+
+ if (len == sum) {
+ // "N items"
+ countBox.value = PluralForm.get(
+ len,
+ gFilterBundle.getString("filterCountItems")
+ ).replace("#1", len);
+ return;
+ }
+
+ // "N of M"
+ countBox.value = gFilterBundle.getFormattedString(
+ "filterCountVisibleOfTotal",
+ [len, sum]
+ );
+}
diff --git a/comm/mail/base/content/FilterListDialog.xhtml b/comm/mail/base/content/FilterListDialog.xhtml
new file mode 100644
index 0000000000..397bdea0d8
--- /dev/null
+++ b/comm/mail/base/content/FilterListDialog.xhtml
@@ -0,0 +1,168 @@
+<?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://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/filterDialog.css" type="text/css"?>
+
+<!DOCTYPE html [
+ <!ENTITY % filtersDTD SYSTEM "chrome://messenger/locale/FilterListDialog.dtd">%filtersDTD;
+]>
+<html id="filterListDialog" 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="mailnews:filterlist"
+ lightweightthemes="true"
+ persist="width height screenX screenY"
+ scrolling="false"
+ style="min-width: 800px; min-height: 500px;">
+<head>
+ <title>&window.title;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/searchWidgets.js"></script>
+ <script defer="defer" src="chrome://messenger/content/FilterListDialog.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <stringbundle id="bundle_filter" src="chrome://messenger/locale/filter.properties"/>
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+
+ <keyset>
+ <key key="&closeCmd.key;" modifiers="accel" oncommand="if (onFilterClose()) { window.close(); }"/>
+ <key keycode="VK_ESCAPE" oncommand="if (onFilterClose()) { window.close(); }"/>
+ </keyset>
+
+ <hbox id="filterHeader" align="center">
+ <label value="&filtersForPrefix.label;"
+ accesskey="&filtersForPrefix.accesskey;" control="serverMenu"/>
+
+ <menulist id="serverMenu"
+ class="folderMenuItem" flex="1">
+ <menupopup is="folder-menupopup" id="serverMenuPopup"
+ mode="filters"
+ class="menulist-menupopup"
+ expandFolders="nntp"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ oncommand="setFilterFolder(event.target._folder);"/>
+ </menulist>
+ <search-textbox id="searchBox"
+ class="themeableSearchBox"
+ oncommand="rebuildFilterList();"
+ placeholder="&searchBox.emptyText;"
+ isempty="true"/>
+ </hbox>
+ <separator class="thin"/>
+ <hbox id="filterListGrid" flex="1">
+ <vbox id="filterListBox" flex="1">
+ <hbox>
+ <label id="filterListLabel" control="filterList" flex="1">
+ &filterHeader.label;
+ </label>
+ <label id="countBox"/>
+ </hbox>
+ <richlistbox id="filterList" flex="1" onselect="updateButtons();"
+ seltype="multiple"
+ onkeypress="onFilterListKeyPress(event);">
+ <treecols>
+ <treecol id="nameColumn" label="&nameColumn.label;" flex="1"/>
+ <treecol id="activeColumn" label="&activeColumn.label;" style="width: 100px;"/>
+ </treecols>
+ </richlistbox>
+ <vbox>
+ <separator class="thin"/>
+ <hbox align="center">
+ <label id="folderPickerPrefix" value="&folderPickerPrefix.label;"
+ accesskey="&folderPickerPrefix.accesskey;"
+ disabled="true" control="runFiltersFolder"/>
+ <menulist id="runFiltersFolder" disabled="true" flex="1"
+ class="folderMenuItem"
+ displayformat="verbose">
+ <menupopup is="folder-menupopup" id="runFiltersPopup"
+ class="menulist-menupopup"
+ showFileHereLabel="true"
+ showAccountsFileHere="false"
+ oncommand="setRunFolder(event.target._folder);"/>
+ </menulist>
+ <button id="runFiltersButton"
+ label="&runFilters.label;"
+ accesskey="&runFilters.accesskey;"
+ runlabel="&runFilters.label;"
+ runaccesskey="&runFilters.accesskey;"
+ stoplabel="&stopFilters.label;"
+ stopaccesskey="&stopFilters.accesskey;"
+ oncommand="runSelectedFilters();" disabled="true"/>
+ </hbox>
+ </vbox>
+ </vbox>
+ <vbox id="filterActionButtons">
+ <label value=""/>
+ <toolbarbutton is="toolbarbutton-menu-button" id="newButton"
+ type="menu"
+ label="&newButton.label;"
+ accesskey="&newButton.accesskey;"
+ oncommand="onNewFilter();">
+ <menupopup id="newFilterMenupopup">
+ <menuitem label="&newButton.label;"
+ accesskey="&newButton.accesskey;"/>
+ <menuitem id="copyToNewButton"
+ label="&newButton.popupCopy.label;"
+ accesskey="&newButton.popupCopy.accesskey;"
+ oncommand="onCopyToNewFilter(); event.stopPropagation();"/>
+ </menupopup>
+ </toolbarbutton>
+ <button id="editButton" label="&editButton.label;"
+ accesskey="&editButton.accesskey;"
+ oncommand="onEditFilter();"/>
+ <button id="deleteButton"
+ label="&deleteButton.label;"
+ accesskey="&deleteButton.accesskey;"
+ oncommand="onDeleteFilter();"/>
+ <separator class="thin"/>
+ <button id="reorderTopButton"
+ label="&reorderTopButton;"
+ accesskey="&reorderTopButton.accessKey;"
+ tooltiptext="&reorderTopButton.toolTip;"
+ oncommand="onTop(event);"/>
+ <button id="reorderUpButton"
+ label="&reorderUpButton.label;"
+ accesskey="&reorderUpButton.accesskey;"
+ class="up"
+ oncommand="onUp(event);"/>
+ <button id="reorderDownButton"
+ label="&reorderDownButton.label;"
+ accesskey="&reorderDownButton.accesskey;"
+ class="down"
+ oncommand="onDown(event);"/>
+ <button id="reorderBottomButton"
+ label="&reorderBottomButton;"
+ accesskey="&reorderBottomButton.accessKey;"
+ tooltiptext="&reorderBottomButton.toolTip;"
+ oncommand="onBottom(event);"/>
+ <vbox flex="1" pack="end">
+ <button id="filterLogButton"
+ label="&viewLogButton.label;"
+ accesskey="&viewLogButton.accesskey;"
+ oncommand="viewLog();"/>
+ </vbox>
+ </vbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox id="statusbar" role="status">
+ <label id="statusText" flex="1" crop="end"/>
+ <hbox id="statusbar-progresspanel" class="statusbarpanel-progress" collapsed="true">
+ <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/>
+ </hbox>
+ </hbox>
+
+</html:body>
+</html>
diff --git a/comm/mail/base/content/SearchDialog.js b/comm/mail/base/content/SearchDialog.js
new file mode 100644
index 0000000000..127370d7f2
--- /dev/null
+++ b/comm/mail/base/content/SearchDialog.js
@@ -0,0 +1,650 @@
+/* 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 ../../../mailnews/extensions/newsblog/newsblogOverlay.js */
+/* import-globals-from ../../../mailnews/search/content/searchTerm.js */
+/* import-globals-from folderDisplay.js */
+/* import-globals-from globalOverlay.js */
+/* import-globals-from threadPane.js */
+
+/* globals nsMsgStatusFeedback */ // From mailWindow.js
+
+"use strict";
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+
+var messenger;
+var msgWindow;
+
+var gCurrentFolder;
+
+var gFolderDisplay;
+
+var gFolderPicker;
+var gStatusFeedback;
+var gSearchBundle;
+
+// Datasource search listener -- made global as it has to be registered
+// and unregistered in different functions.
+var gDataSourceSearchListener;
+var gViewSearchListener;
+
+var gSearchStopButton;
+
+// Should we try to search online?
+var gSearchOnline = false;
+
+window.addEventListener("load", searchOnLoad);
+window.addEventListener("unload", event => {
+ onSearchStop();
+ searchOnUnload();
+});
+
+// Controller object for search results thread pane
+var nsSearchResultsController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ case "button_delete":
+ case "cmd_open":
+ case "file_message_button":
+ case "open_in_folder_button":
+ case "saveas_vf_button":
+ case "cmd_selectAll":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ // this controller only handles commands
+ // that rely on items being selected in
+ // the search results pane.
+ isCommandEnabled(command) {
+ var enabled = true;
+
+ switch (command) {
+ case "open_in_folder_button":
+ if (gFolderDisplay.selectedCount != 1) {
+ enabled = false;
+ }
+ break;
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ case "button_delete":
+ // this assumes that advanced searches don't cross accounts
+ if (gFolderDisplay.selectedCount <= 0) {
+ enabled = false;
+ }
+ break;
+ case "saveas_vf_button":
+ // need someway to see if there are any search criteria...
+ return true;
+ case "cmd_selectAll":
+ return true;
+ default:
+ if (gFolderDisplay.selectedCount <= 0) {
+ enabled = false;
+ }
+ break;
+ }
+
+ return enabled;
+ },
+
+ doCommand(command) {
+ switch (command) {
+ case "cmd_open":
+ MsgOpenSelectedMessages();
+ return true;
+
+ case "cmd_delete":
+ case "button_delete":
+ MsgDeleteSelectedMessages(Ci.nsMsgViewCommandType.deleteMsg);
+ return true;
+
+ case "cmd_shiftDelete":
+ MsgDeleteSelectedMessages(Ci.nsMsgViewCommandType.deleteNoTrash);
+ return true;
+
+ case "open_in_folder_button":
+ OpenInFolder();
+ return true;
+
+ case "saveas_vf_button":
+ saveAsVirtualFolder();
+ return true;
+
+ case "cmd_selectAll":
+ // move the focus to the search results pane
+ GetThreadTree().focus();
+ gFolderDisplay.doCommand(Ci.nsMsgViewCommandType.selectAll);
+ return true;
+
+ default:
+ return false;
+ }
+ },
+
+ onEvent(event) {},
+};
+
+function UpdateMailSearch(caller) {
+ document.commandDispatcher.updateCommands("mail-search");
+}
+
+function SetAdvancedSearchStatusText(aNumHits) {}
+
+/**
+ * Subclass the FolderDisplayWidget to deal with UI specific to the search
+ * window.
+ */
+function SearchFolderDisplayWidget() {
+ FolderDisplayWidget.call(this);
+}
+
+SearchFolderDisplayWidget.prototype = {
+ __proto__: FolderDisplayWidget.prototype,
+
+ // folder display will want to show the thread pane; we need do nothing
+ _showThreadPane() {},
+
+ onSearching(aIsSearching) {
+ if (aIsSearching) {
+ // Search button becomes the "stop" button
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForStopButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForStopButton.accesskey")
+ );
+
+ // update our toolbar equivalent
+ UpdateMailSearch("new-search");
+ // spin the meteors
+ gStatusFeedback._startMeteors();
+ // tell the user that we're searching
+ gStatusFeedback.showStatusString(
+ gSearchBundle.GetStringFromName("searchingMessage")
+ );
+ } else {
+ // Stop button resumes being the "search" button
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForSearchButton.accesskey")
+ );
+
+ // update our toolbar equivalent
+ UpdateMailSearch("done-search");
+ // stop spining the meteors
+ gStatusFeedback._stopMeteors();
+ // set the result test
+ this.updateStatusResultText();
+ }
+ },
+
+ /**
+ * If messages were removed, we might have lost some search results and so
+ * should update our search result text. Also, defer to our super-class.
+ */
+ onMessagesRemoved() {
+ // result text is only for when we are not searching
+ if (!this.view.searching) {
+ this.updateStatusResultText();
+ }
+ this.__proto__.__proto__.onMessagesRemoved.call(this);
+ },
+
+ updateStatusResultText() {
+ let rowCount = this.view.dbView.rowCount;
+ let statusMsg;
+
+ if (rowCount == 0) {
+ statusMsg = gSearchBundle.GetStringFromName("noMatchesFound");
+ } else {
+ statusMsg = PluralForm.get(
+ rowCount,
+ gSearchBundle.GetStringFromName("matchesFound")
+ );
+ statusMsg = statusMsg.replace("#1", rowCount);
+ }
+
+ gStatusFeedback.showStatusString(statusMsg);
+ },
+};
+
+function searchOnLoad() {
+ TagUtils.loadTagsIntoCSS(document);
+ initializeSearchWidgets();
+ initializeSearchWindowWidgets();
+ // eslint-disable-next-line no-global-assign
+ messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+ gSearchBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/search.properties"
+ );
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForSearchButton.accesskey")
+ );
+
+ // eslint-disable-next-line no-global-assign
+ gFolderDisplay = new SearchFolderDisplayWidget();
+ gFolderDisplay.messenger = messenger;
+ gFolderDisplay.msgWindow = msgWindow;
+ gFolderDisplay.tree = document.getElementById("threadTree");
+
+ // The view is initially unsorted; get the persisted sortDirection column
+ // and set up the user's desired sort. This synthetic view is not backed by
+ // a db, so secondary sorts and custom columns are not supported here.
+ let sortCol = gFolderDisplay.tree.querySelector("[sortDirection]");
+ let sortType, sortOrder;
+ if (sortCol) {
+ sortType = Ci.nsMsgViewSortType[gFolderDisplay.COLUMNS_MAP.get(sortCol.id)];
+ sortOrder =
+ sortCol.getAttribute("sortDirection") == "descending"
+ ? Ci.nsMsgViewSortOrder.descending
+ : Ci.nsMsgViewSortOrder.ascending;
+ }
+
+ gFolderDisplay.view.openSearchView();
+ gFolderDisplay.makeActive();
+
+ if (sortType) {
+ gFolderDisplay.view.sort(sortType, sortOrder);
+ }
+
+ if (window.arguments && window.arguments[0]) {
+ updateSearchFolderPicker(window.arguments[0].folder);
+ }
+
+ // Trigger searchTerm.js to create the first criterion.
+ onMore(null);
+ // Make sure all the buttons are configured.
+ UpdateMailSearch("onload");
+}
+
+function searchOnUnload() {
+ gFolderDisplay.close();
+ top.controllers.removeController(nsSearchResultsController);
+
+ msgWindow.closeWindow();
+}
+
+function initializeSearchWindowWidgets() {
+ gFolderPicker = document.getElementById("searchableFolders");
+ gSearchStopButton = document.getElementById("search-button");
+ hideMatchAllItem();
+
+ // eslint-disable-next-line no-global-assign
+ msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+ );
+ msgWindow.domWindow = window;
+ msgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL;
+
+ gStatusFeedback = new nsMsgStatusFeedback();
+ msgWindow.statusFeedback = gStatusFeedback;
+
+ // functionality to enable/disable buttons using nsSearchResultsController
+ // depending of whether items are selected in the search results thread pane.
+ top.controllers.insertControllerAt(0, nsSearchResultsController);
+}
+
+function onSearchStop() {
+ gFolderDisplay.view.search.session.interruptSearch();
+}
+
+function onResetSearch(event) {
+ onReset(event);
+ gFolderDisplay.view.search.clear();
+
+ gStatusFeedback.showStatusString("");
+}
+
+function updateSearchFolderPicker(folder) {
+ gCurrentFolder = folder;
+ gFolderPicker.menupopup.selectFolder(folder);
+
+ var searchOnline = document.getElementById("checkSearchOnline");
+ // We will hide and disable the search online checkbox if we are offline, or
+ // if the folder does not support online search.
+
+ // Any offlineSupportLevel > 0 is an online server like IMAP or news.
+ if (gCurrentFolder?.server.offlineSupportLevel && !Services.io.offline) {
+ searchOnline.hidden = false;
+ searchOnline.disabled = false;
+ } else {
+ searchOnline.hidden = true;
+ searchOnline.disabled = true;
+ }
+ if (gCurrentFolder) {
+ setSearchScope(GetScopeForFolder(gCurrentFolder));
+ }
+}
+
+function updateSearchLocalSystem() {
+ setSearchScope(GetScopeForFolder(gCurrentFolder));
+}
+
+function UpdateAfterCustomHeaderChange() {
+ updateSearchAttributes();
+}
+
+function onEnterInSearchTerm() {
+ // on enter
+ // if not searching, start the search
+ // if searching, stop and then start again
+ if (
+ gSearchStopButton.getAttribute("label") ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ onSearch();
+ }
+}
+
+function onSearch() {
+ let viewWrapper = gFolderDisplay.view;
+ let searchTerms = getSearchTerms();
+
+ viewWrapper.beginViewUpdate();
+ viewWrapper.search.userTerms = searchTerms.length ? searchTerms : null;
+ viewWrapper.search.onlineSearch = gSearchOnline;
+ viewWrapper.searchFolders = getSearchFolders();
+ viewWrapper.endViewUpdate();
+}
+
+/**
+ * Get the current set of search terms, returning them as a list. We filter out
+ * dangerous and insane predicates.
+ */
+function getSearchTerms() {
+ let termCreator = gFolderDisplay.view.search.session;
+
+ let searchTerms = [];
+ // searchTerm.js stores wrapper objects in its gSearchTerms array. Pluck
+ // them.
+ for (let iTerm = 0; iTerm < gSearchTerms.length; iTerm++) {
+ let termWrapper = gSearchTerms[iTerm].obj;
+ let realTerm = termCreator.createTerm();
+ termWrapper.saveTo(realTerm);
+ // A header search of "" is illegal for IMAP and will cause us to
+ // explode. You don't want that and I don't want that. So let's check
+ // if the bloody term is a subject search on a blank string, and if it
+ // is, let's secretly not add the term. Everyone wins!
+ if (
+ realTerm.attrib != Ci.nsMsgSearchAttrib.Subject ||
+ realTerm.value.str != ""
+ ) {
+ searchTerms.push(realTerm);
+ }
+ }
+
+ return searchTerms;
+}
+
+/**
+ * @returns the list of folders the search should cover.
+ */
+function getSearchFolders() {
+ let searchFolders = [];
+
+ if (!gCurrentFolder.isServer && !gCurrentFolder.noSelect) {
+ searchFolders.push(gCurrentFolder);
+ }
+
+ var searchSubfolders = document.getElementById(
+ "checkSearchSubFolders"
+ ).checked;
+ if (
+ gCurrentFolder &&
+ (searchSubfolders || gCurrentFolder.isServer || gCurrentFolder.noSelect)
+ ) {
+ AddSubFolders(gCurrentFolder, searchFolders);
+ }
+
+ return searchFolders;
+}
+
+function AddSubFolders(folder, outFolders) {
+ for (let nextFolder of folder.subFolders) {
+ if (!(nextFolder.flags & Ci.nsMsgFolderFlags.Virtual)) {
+ if (!nextFolder.noSelect) {
+ outFolders.push(nextFolder);
+ }
+
+ AddSubFolders(nextFolder, outFolders);
+ }
+ }
+}
+
+function AddSubFoldersToURI(folder) {
+ var returnString = "";
+
+ for (let nextFolder of folder.subFolders) {
+ if (!(nextFolder.flags & Ci.nsMsgFolderFlags.Virtual)) {
+ if (!nextFolder.noSelect && !nextFolder.isServer) {
+ if (returnString.length > 0) {
+ returnString += "|";
+ }
+ returnString += nextFolder.URI;
+ }
+ var subFoldersString = AddSubFoldersToURI(nextFolder);
+ if (subFoldersString.length > 0) {
+ if (returnString.length > 0) {
+ returnString += "|";
+ }
+ returnString += subFoldersString;
+ }
+ }
+ }
+ return returnString;
+}
+
+/**
+ * Determine the proper search scope to use for a folder, so that the user is
+ * presented with a correct list of search capabilities. The user may manually
+ * request on online search for certain server types. To determine if the
+ * folder body may be searched, we ignore whether autosync is enabled,
+ * figuring that after the user manually syncs, they would still expect that
+ * body searches would work.
+ *
+ * The available search capabilities also depend on whether the user is
+ * currently online or offline. Although that is also checked by the server,
+ * we do it ourselves because we have a more complex response to offline
+ * than the server's searchScope attribute provides.
+ *
+ * This method only works for real folders.
+ */
+function GetScopeForFolder(folder) {
+ let searchOnline = document.getElementById("checkSearchOnline");
+ if (!searchOnline.disabled && searchOnline.checked) {
+ gSearchOnline = true;
+ return folder.server.searchScope;
+ }
+ gSearchOnline = false;
+
+ // We are going to search offline. The proper search scope may depend on
+ // whether we have the body and/or junk available or not.
+ let localType;
+ try {
+ localType = folder.server.localStoreType;
+ } catch (e) {} // On error, we'll just assume the default mailbox type
+
+ let hasBody = folder.getFlag(Ci.nsMsgFolderFlags.Offline);
+ let nsMsgSearchScope = Ci.nsMsgSearchScope;
+ switch (localType) {
+ case "news":
+ // News has four offline scopes, depending on whether junk and body
+ // are available.
+ let hasJunk =
+ folder.getInheritedStringProperty(
+ "dobayes.mailnews@mozilla.org#junk"
+ ) == "true";
+ if (hasJunk && hasBody) {
+ return nsMsgSearchScope.localNewsJunkBody;
+ }
+ if (hasJunk) {
+ // and no body
+ return nsMsgSearchScope.localNewsJunk;
+ }
+ if (hasBody) {
+ // and no junk
+ return nsMsgSearchScope.localNewsBody;
+ }
+ // We don't have offline message bodies or junk processing.
+ return nsMsgSearchScope.localNews;
+
+ case "imap":
+ // Junk is always enabled for imap, so the offline scope only depends on
+ // whether the body is available.
+
+ // If we are the root folder, use the server property for body rather
+ // than the folder property.
+ if (folder.isServer) {
+ let imapServer = folder.server.QueryInterface(Ci.nsIImapIncomingServer);
+ if (imapServer && imapServer.offlineDownload) {
+ hasBody = true;
+ }
+ }
+
+ if (!hasBody) {
+ return nsMsgSearchScope.onlineManual;
+ }
+ // fall through to default
+ default:
+ return nsMsgSearchScope.offlineMail;
+ }
+}
+
+function goUpdateSearchItems(commandset) {
+ for (var i = 0; i < commandset.children.length; i++) {
+ var commandID = commandset.children[i].getAttribute("id");
+ if (commandID) {
+ goUpdateCommand(commandID);
+ }
+ }
+}
+
+// used to toggle functionality for Search/Stop button.
+function onSearchButton(event) {
+ if (
+ event.target.label ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ }
+}
+
+function MsgDeleteSelectedMessages(aCommandType) {
+ gFolderDisplay.hintAboutToDeleteMessages();
+ gFolderDisplay.doCommand(aCommandType);
+}
+
+/**
+ * Move selected messages to the destination folder
+ *
+ * @param destFolder {nsIMsgFolder} - destination folder
+ */
+function MoveMessageInSearch(destFolder) {
+ gFolderDisplay.hintAboutToDeleteMessages();
+ gFolderDisplay.doCommandWithFolder(
+ Ci.nsMsgViewCommandType.moveMessages,
+ destFolder
+ );
+}
+
+function OpenInFolder() {
+ MailUtils.displayMessageInFolderTab(gFolderDisplay.selectedMessage);
+}
+
+function saveAsVirtualFolder() {
+ var searchFolderURIs = gCurrentFolder.URI;
+
+ var searchSubfolders = document.getElementById(
+ "checkSearchSubFolders"
+ ).checked;
+ if (
+ gCurrentFolder &&
+ (searchSubfolders || gCurrentFolder.isServer || gCurrentFolder.noSelect)
+ ) {
+ var subFolderURIs = AddSubFoldersToURI(gCurrentFolder);
+ if (subFolderURIs.length > 0) {
+ searchFolderURIs += "|" + subFolderURIs;
+ }
+ }
+
+ var searchOnline = document.getElementById("checkSearchOnline");
+ var doOnlineSearch = searchOnline.checked && !searchOnline.disabled;
+
+ window.openDialog(
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ "",
+ "chrome,titlebar,modal,centerscreen,resizable=yes",
+ {
+ folder: window.arguments[0].folder,
+ searchTerms: getSearchTerms(),
+ searchFolderURIs,
+ searchOnline: doOnlineSearch,
+ }
+ );
+}
+
+function MsgOpenSelectedMessages() {
+ // Toggle message body (feed summary) and content-base url in message pane or
+ // load in browser, per pref, otherwise open summary or web page in new window
+ // or tab, per that pref.
+ if (
+ gFolderDisplay.treeSelection &&
+ gFolderDisplay.treeSelection.count == 1 &&
+ gFolderDisplay.selectedMessageIsFeed
+ ) {
+ let msgHdr = gFolderDisplay.selectedMessage;
+ if (
+ document.documentElement.getAttribute("windowtype") == "mail:3pane" &&
+ FeedMessageHandler.onOpenPref ==
+ FeedMessageHandler.kOpenToggleInMessagePane
+ ) {
+ let showSummary = FeedMessageHandler.shouldShowSummary(msgHdr, true);
+ FeedMessageHandler.setContent(msgHdr, showSummary);
+ return;
+ }
+ if (
+ FeedMessageHandler.onOpenPref == FeedMessageHandler.kOpenLoadInBrowser
+ ) {
+ setTimeout(FeedMessageHandler.loadWebPage, 20, msgHdr, { browser: true });
+ return;
+ }
+ }
+
+ // This is somewhat evil. If we're in a 3pane window, we'd have a tabmail
+ // element and would pass it in here, ensuring that if we open tabs, we use
+ // this tabmail to open them. If we aren't, then we wouldn't, so
+ // displayMessages would look for a 3pane window and open tabs there.
+ MailUtils.displayMessages(
+ gFolderDisplay.selectedMessages,
+ gFolderDisplay.view,
+ document.getElementById("tabmail")
+ );
+}
diff --git a/comm/mail/base/content/SearchDialog.xhtml b/comm/mail/base/content/SearchDialog.xhtml
new file mode 100644
index 0000000000..6d6ff82713
--- /dev/null
+++ b/comm/mail/base/content/SearchDialog.xhtml
@@ -0,0 +1,151 @@
+<?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/.
+
+#filter substitution
+#define SEARCH_WINDOW
+<?xml-stylesheet href="chrome://messenger/skin/searchDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderPane.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tagColors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE html [
+ <!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+ %messengerDTD;
+ <!ENTITY % SearchDialogDTD SYSTEM "chrome://messenger/locale/SearchDialog.dtd">
+ %SearchDialogDTD;
+ <!ENTITY % searchTermDTD SYSTEM "chrome://messenger/locale/searchTermOverlay.dtd">
+ %searchTermDTD;
+]>
+<html id="searchMailWindow" 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="mailnews:search"
+ scrolling="false"
+ style="min-width:52em; min-height:34em;"
+ lightweightthemes="true"
+ persist="screenX screenY width height sizemode">
+<head>
+ <title>&searchDialogTitle.label;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/searchWidgets.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script>
+ <script defer="defer" src="chrome://messenger/content/folderDisplay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/threadPane.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/searchTerm.js"></script>
+ <script defer="defer" src="chrome://messenger/content/dateFormat.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger.js"></script>
+ <script defer="defer" src="chrome://messenger/content/SearchDialog.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+
+ <commands id="commands">
+ <commandset id="mailSearchItems"
+ commandupdater="true"
+ events="mail-search"
+ oncommandupdate="goUpdateSearchItems(this)">
+ <command id="cmd_open" oncommand="goDoCommand('cmd_open')" disabled="true"/>
+ <command id="button_delete" oncommand="goDoCommand('button_delete')" disabled="true"/>
+ <command id="open_in_folder_button" oncommand="goDoCommand('open_in_folder_button')" disabled="true"/>
+ <command id="saveas_vf_button" oncommand="goDoCommand('saveas_vf_button')" disabled="false"/>
+ <command id="file_message_button" oncommand="MoveMessageInSearch(event.target._folder);" disabled="true"/>
+ <command id="cmd_delete" oncommand="goDoCommand('cmd_delete')" disabled="true"/>
+ <command id="cmd_shiftDelete" oncommand="goDoCommand('cmd_shiftDelete');"/>
+ </commandset>
+ </commands>
+
+ <keyset id="mailKeys">
+ <key key="&closeCmd.key;" modifiers="accel" oncommand="window.close();"/>
+ <key keycode="VK_ESCAPE" oncommand="window.close();"/>
+#ifdef XP_MACOSX
+ <key id="key_delete" keycode="VK_BACK" command="cmd_delete"/>
+ <key id="key_delete2" keycode="VK_DELETE" command="cmd_delete"/>
+ <key id="cmd_shiftDelete" keycode="VK_BACK"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+ <key id="cmd_shiftDelete2" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+#else
+ <key id="key_delete" keycode="VK_DELETE" command="cmd_delete"/>
+ <key id="cmd_shiftDelete" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+#endif
+ </keyset>
+
+ <vbox id="searchTerms" class="themeable-brighttext" persist="height">
+ <vbox>
+ <hbox align="center">
+ <label value="&searchHeading.label;" accesskey="&searchHeading.accesskey;"
+ control="searchableFolders"/>
+ <menulist id="searchableFolders" class="folderMenuItem"
+ displayformat="verbose">
+ <menupopup is="folder-menupopup" class="menulist-menupopup"
+ mode="search" showAccountsFileHere="true" showFileHereLabel="true"
+ oncommand="updateSearchFolderPicker(event.target._folder);"/>
+ </menulist>
+ <spacer style="flex: 10 10;"/>
+ <button id="search-button" oncommand="onSearchButton(event);" default="true"/>
+ </hbox>
+
+ <hbox align="center">
+ <checkbox id="checkSearchSubFolders"
+ label="&searchSubfolders.label;"
+ accesskey="&searchSubfolders.accesskey;"
+ checked="true"
+ persist="checked"/>
+ <spacer style="flex: 10 10;"/>
+ <button label="&resetButton.label;" oncommand="onResetSearch(event);" accesskey="&resetButton.accesskey;"/>
+ </hbox>
+ <hbox align="center">
+ <checkbox id="checkSearchOnline"
+ label="&searchOnServer.label;"
+ accesskey="&searchOnServer.accesskey;"
+ oncommand="updateSearchLocalSystem();"
+ persist="checked"/>
+ </hbox>
+ </vbox>
+
+ <hbox style="flex: 1 1 0; min-height: 0;">
+ <vbox id="searchTermListBox" flex="1">
+#include ../../../mailnews/search/content/searchTerm.inc.xhtml
+ </hbox>
+ </vbox>
+
+ <splitter id="gray_horizontal_splitter" persist="state" orient="vertical"/>
+
+ <vbox id="searchResults" persist="height">
+ <vbox id="searchResultListBox" flex="1">
+#include threadTree.inc.xhtml
+ </vbox>
+ <hbox align="start">
+ <button label="&openButton.label;" id="openButton" command="cmd_open" accesskey="&openButton.accesskey;"/>
+ <button id="fileMessageButton" type="menu" label="&moveButton.label;"
+ accesskey="&moveButton.accesskey;"
+ command="file_message_button">
+ <menupopup is="folder-menupopup" showFileHereLabel="true" mode="filing"/>
+ </button>
+
+ <button label="&deleteButton.label;" id="deleteButton" command="button_delete" accesskey="&deleteButton.accesskey;"/>
+ <button label="&openInFolder.label;" id="openInFolderButton" command="open_in_folder_button" accesskey="&openInFolder.accesskey;" />
+ <button label="&saveAsVFButton.label;" id="saveAsVFButton" command="saveas_vf_button" accesskey="&saveAsVFButton.accesskey;" />
+ <spacer flex="1" />
+ </hbox>
+ </vbox>
+
+ <hbox id="status-bar" class="statusbar chromeclass-status" role="status">
+ <label id="statusText" class="statusbarpanel" crop="end" flex="1"/>
+ <hbox id="statusbar-progresspanel" class="statusbarpanel statusbarpanel-progress" collapsed="true">
+ <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/>
+ </hbox>
+ </hbox>
+</html:body>
+</html>
diff --git a/comm/mail/base/content/about3Pane.js b/comm/mail/base/content/about3Pane.js
new file mode 100644
index 0000000000..43e09a0acc
--- /dev/null
+++ b/comm/mail/base/content/about3Pane.js
@@ -0,0 +1,7260 @@
+/* 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 MozElements */
+
+// mailCommon.js
+/* globals commandController, DBViewWrapper, dbViewWrapperListener,
+ nsMsgViewIndex_None, VirtualFolderHelper */
+/* globals gDBView: true, gFolder: true, gViewWrapper: true */
+
+// mailContext.js
+/* globals mailContextMenu */
+
+// globalOverlay.js
+/* globals goDoCommand, goUpdateCommand */
+
+// mail-offline.js
+/* globals MailOfflineMgr */
+
+// junkCommands.js
+/* globals analyzeMessagesForJunk deleteJunkInFolder filterFolderForJunk */
+
+// quickFilterBar.js
+/* globals quickFilterBar */
+
+// utilityOverlay.js
+/* globals validateFileName */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { FolderTreeProperties } = ChromeUtils.import(
+ "resource:///modules/FolderTreeProperties.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ FeedUtils: "resource:///modules/FeedUtils.jsm",
+ FolderUtils: "resource:///modules/FolderUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MailE10SUtils: "resource:///modules/MailE10SUtils.jsm",
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+const XULSTORE_URL = "chrome://messenger/content/messenger.xhtml";
+
+const messengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+);
+
+const { getDefaultColumns, getDefaultColumnsForCardsView, isOutgoing } =
+ ChromeUtils.importESModule(
+ "chrome://messenger/content/thread-pane-columns.mjs"
+ );
+
+// As defined in nsMsgDBView.h.
+const MSG_VIEW_FLAG_DUMMY = 0x20000000;
+
+/**
+ * The TreeListbox widget that displays folders.
+ */
+var folderTree;
+/**
+ * The TreeView widget that displays the message list.
+ */
+var threadTree;
+/**
+ * A XUL browser that displays web pages when required.
+ */
+var webBrowser;
+/**
+ * A XUL browser that displays single messages. This browser always has
+ * about:message loaded.
+ */
+var messageBrowser;
+/**
+ * A XUL browser that displays summaries of multiple messages or threads.
+ * This browser always has multimessageview.xhtml loaded.
+ */
+var multiMessageBrowser;
+/**
+ * A XUL browser that displays Account Central when an account's root folder
+ * is selected.
+ */
+var accountCentralBrowser;
+
+window.addEventListener("DOMContentLoaded", async event => {
+ if (event.target != document) {
+ return;
+ }
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+
+ folderTree = document.getElementById("folderTree");
+ accountCentralBrowser = document.getElementById("accountCentralBrowser");
+
+ paneLayout.init();
+ folderPaneContextMenu.init();
+ await folderPane.init();
+ await threadPane.init();
+ threadPaneHeader.init();
+ await messagePane.init();
+
+ // Set up the initial state using information which may have been provided
+ // by mailTabs.js, or the saved state from the XUL store, or the defaults.
+ try {
+ // Do this in a try so that errors (e.g. bad data) don't prevent doing the
+ // rest of the important 3pane initialization below.
+ restoreState(window.openingState);
+ } catch (e) {
+ console.warn(`Couldn't restore state: ${e.message}`, e);
+ }
+ delete window.openingState;
+
+ // Finally, add the folderTree listener and trigger it. Earlier events
+ // (triggered by `folderPane.init` and possibly `restoreState`) are ignored
+ // to avoid unnecessarily loading the thread tree or Account Central.
+ folderTree.addEventListener("select", folderPane);
+ folderTree.dispatchEvent(new CustomEvent("select"));
+
+ // Attach the progress listener for the webBrowser. For the messageBrowser this
+ // happens in the "aboutMessageLoaded" event from aboutMessage.js.
+ // For the webBrowser, we can do it here directly.
+ top.contentProgress.addProgressListenerToBrowser(webBrowser);
+
+ mailContextMenu.init();
+});
+
+window.addEventListener("unload", () => {
+ MailServices.mailSession.RemoveFolderListener(folderListener);
+ gViewWrapper?.close();
+ folderPane.uninit();
+ threadPane.uninit();
+ threadPaneHeader.uninit();
+});
+
+var paneLayout = {
+ init() {
+ this.folderPaneSplitter = document.getElementById("folderPaneSplitter");
+ this.messagePaneSplitter = document.getElementById("messagePaneSplitter");
+
+ for (let [splitter, properties, storeID] of [
+ [this.folderPaneSplitter, ["width"], "folderPaneBox"],
+ [this.messagePaneSplitter, ["height", "width"], "messagepaneboxwrapper"],
+ ]) {
+ for (let property of properties) {
+ let value = Services.xulStore.getValue(XULSTORE_URL, storeID, property);
+ if (value) {
+ splitter[property] = value;
+ }
+ }
+
+ splitter.storeAttr = function (attrName, attrValue) {
+ Services.xulStore.setValue(XULSTORE_URL, storeID, attrName, attrValue);
+ };
+
+ splitter.addEventListener("splitter-resized", () => {
+ if (splitter.resizeDirection == "vertical") {
+ splitter.storeAttr("height", splitter.height);
+ } else {
+ splitter.storeAttr("width", splitter.width);
+ }
+ });
+ }
+
+ this.messagePaneSplitter.addEventListener("splitter-collapsed", () => {
+ // Clear any loaded page or messages.
+ messagePane.clearAll();
+ this.messagePaneSplitter.storeAttr("collapsed", true);
+ });
+
+ this.messagePaneSplitter.addEventListener("splitter-expanded", () => {
+ // Load the selected messages.
+ threadTree.dispatchEvent(new CustomEvent("select"));
+ this.messagePaneSplitter.storeAttr("collapsed", false);
+ });
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "layoutPreference",
+ "mail.pane_config.dynamic",
+ null,
+ (name, oldValue, newValue) => this.setLayout(newValue)
+ );
+ this.setLayout(this.layoutPreference);
+ threadPane.updateThreadView(
+ Services.xulStore.getValue(XULSTORE_URL, "threadPane", "view")
+ );
+ },
+
+ setLayout(preference) {
+ document.body.classList.remove(
+ "layout-classic",
+ "layout-vertical",
+ "layout-wide"
+ );
+ switch (preference) {
+ case 1:
+ document.body.classList.add("layout-wide");
+ this.messagePaneSplitter.resizeDirection = "vertical";
+ break;
+ case 2:
+ document.body.classList.add("layout-vertical");
+ this.messagePaneSplitter.resizeDirection = "horizontal";
+ break;
+ default:
+ document.body.classList.add("layout-classic");
+ this.messagePaneSplitter.resizeDirection = "vertical";
+ break;
+ }
+ },
+
+ get accountCentralVisible() {
+ return document.body.classList.contains("account-central");
+ },
+ get folderPaneVisible() {
+ return !this.folderPaneSplitter.isCollapsed;
+ },
+ set folderPaneVisible(visible) {
+ this.folderPaneSplitter.isCollapsed = !visible;
+ },
+ get messagePaneVisible() {
+ return !this.messagePaneSplitter?.isCollapsed;
+ },
+ set messagePaneVisible(visible) {
+ this.messagePaneSplitter.isCollapsed = !visible;
+ },
+};
+
+var folderPaneContextMenu = {
+ /**
+ * @type {XULPopupElement}
+ */
+ _menupopup: null,
+
+ /**
+ * Commands handled by commandController.
+ *
+ * @type {Object.<string, string>}
+ */
+ _commands: {
+ "folderPaneContext-new": "cmd_newFolder",
+ "folderPaneContext-remove": "cmd_deleteFolder",
+ "folderPaneContext-rename": "cmd_renameFolder",
+ "folderPaneContext-compact": "cmd_compactFolder",
+ "folderPaneContext-properties": "cmd_properties",
+ "folderPaneContext-favoriteFolder": "cmd_toggleFavoriteFolder",
+ },
+
+ /**
+ * Current state of commandController commands. Set to null to invalidate
+ * the states.
+ *
+ * @type {Object.<string, boolean>|null}
+ */
+ _commandStates: null,
+
+ init() {
+ this._menupopup = document.getElementById("folderPaneContext");
+ this._menupopup.addEventListener("popupshowing", this);
+ this._menupopup.addEventListener("popuphidden", this);
+ this._menupopup.addEventListener("command", this);
+ folderTree.addEventListener("select", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshowing":
+ this.onPopupShowing(event);
+ break;
+ case "popuphidden":
+ this.onPopupHidden(event);
+ break;
+ case "command":
+ this.onCommand(event);
+ break;
+ case "select":
+ this._commandStates = null;
+ break;
+ }
+ },
+
+ /**
+ * The folder that this context menu is operating on. This will be `gFolder`
+ * unless the menu was opened by right-clicking on another folder.
+ *
+ * @type {nsIMsgFolder}
+ */
+ get activeFolder() {
+ return this._overrideFolder || gFolder;
+ },
+
+ /**
+ * Override the folder that this context menu should operate on. The effect
+ * lasts until `clearOverrideFolder` is called by `onPopupHidden`.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ setOverrideFolder(folder) {
+ this._overrideFolder = folder;
+ this._commandStates = null;
+ },
+
+ /**
+ * Clear the overriding folder, and go back to using `gFolder`.
+ */
+ clearOverrideFolder() {
+ this._overrideFolder = null;
+ this._commandStates = null;
+ },
+
+ /**
+ * Gets the enabled state of a command. If the state is unknown (because the
+ * selected folder has changed) the states of all the commands are worked
+ * out together to save unnecessary work.
+ *
+ * @param {string} command
+ */
+ getCommandState(command) {
+ let folder = this.activeFolder;
+ if (!folder || FolderUtils.isSmartTagsFolder(folder)) {
+ return false;
+ }
+ if (this._commandStates === null) {
+ let {
+ canCompact,
+ canCreateSubfolders,
+ canRename,
+ deletable,
+ flags,
+ isServer,
+ server,
+ URI,
+ } = folder;
+ let isJunk = flags & Ci.nsMsgFolderFlags.Junk;
+ let isVirtual = flags & Ci.nsMsgFolderFlags.Virtual;
+ let isNNTP = server.type == "nntp";
+ if (isNNTP && !isServer) {
+ // `folderPane.deleteFolder` has a special case for this.
+ deletable = true;
+ }
+ let isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder);
+ let showNewFolderItem =
+ (!isNNTP && canCreateSubfolders) || flags & Ci.nsMsgFolderFlags.Inbox;
+
+ this._commandStates = {
+ cmd_newFolder: showNewFolderItem,
+ cmd_deleteFolder: isJunk
+ ? FolderUtils.canRenameDeleteJunkMail(URI)
+ : deletable,
+ cmd_renameFolder:
+ (!isServer &&
+ canRename &&
+ !(flags & Ci.nsMsgFolderFlags.SpecialUse)) ||
+ isVirtual ||
+ (isJunk && FolderUtils.canRenameDeleteJunkMail(URI)),
+ cmd_compactFolder:
+ !isVirtual &&
+ (isServer || canCompact) &&
+ folder.isCommandEnabled("cmd_compactFolder"),
+ cmd_emptyTrash: !isNNTP,
+ cmd_properties: !isServer && !isSmartTagsFolder,
+ cmd_toggleFavoriteFolder: !isServer && !isSmartTagsFolder,
+ };
+ }
+ return this._commandStates[command];
+ },
+
+ onPopupShowing(event) {
+ if (event.target != this._menupopup) {
+ return;
+ }
+
+ function showItem(id, show) {
+ let item = document.getElementById(id);
+ if (item) {
+ item.hidden = !show;
+ }
+ }
+
+ function checkItem(id, checked) {
+ let item = document.getElementById(id);
+ if (item) {
+ // Always convert truthy/falsy to boolean before string.
+ item.setAttribute("checked", !!checked);
+ }
+ }
+
+ // Ask commandController about the commands it controls.
+ for (let [id, command] of Object.entries(this._commands)) {
+ showItem(id, commandController.isCommandEnabled(command));
+ }
+
+ let folder = this.activeFolder;
+ let { canCreateSubfolders, flags, isServer, isSpecialFolder, server } =
+ folder;
+ let isJunk = flags & Ci.nsMsgFolderFlags.Junk;
+ let isTrash = isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true);
+ let isVirtual = flags & Ci.nsMsgFolderFlags.Virtual;
+ let isRealFolder = !isServer && !isVirtual;
+ let isSmartVirtualFolder = FolderUtils.isSmartVirtualFolder(folder);
+ let isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder);
+ let serverType = server.type;
+
+ showItem(
+ "folderPaneContext-getMessages",
+ (isServer && serverType != "none") ||
+ (["nntp", "rss"].includes(serverType) && !isTrash && !isVirtual)
+ );
+ let showPauseAll = isServer && FeedUtils.isFeedFolder(folder);
+ showItem("folderPaneContext-pauseAllUpdates", showPauseAll);
+ if (showPauseAll) {
+ let optionsAcct = FeedUtils.getOptionsAcct(server);
+ checkItem("folderPaneContext-pauseAllUpdates", !optionsAcct.doBiff);
+ }
+ let showPaused = !isServer && FeedUtils.getFeedUrlsInFolder(folder);
+ showItem("folderPaneContext-pauseUpdates", showPaused);
+ if (showPaused) {
+ let properties = FeedUtils.getFolderProperties(folder);
+ checkItem(
+ "folderPaneContext-pauseUpdates",
+ properties.includes("isPaused")
+ );
+ }
+
+ showItem("folderPaneContext-searchMessages", !isVirtual);
+ if (isVirtual) {
+ showItem("folderPaneContext-subscribe", false);
+ } else if (serverType == "rss" && !isTrash) {
+ showItem("folderPaneContext-subscribe", true);
+ } else {
+ showItem(
+ "folderPaneContext-subscribe",
+ isServer && ["imap", "nntp"].includes(serverType)
+ );
+ }
+ showItem(
+ "folderPaneContext-newsUnsubscribe",
+ isRealFolder && serverType == "nntp"
+ );
+
+ let showNewFolderItem =
+ (serverType != "nntp" && canCreateSubfolders) ||
+ flags & Ci.nsMsgFolderFlags.Inbox;
+ if (showNewFolderItem) {
+ document
+ .getElementById("folderPaneContext-new")
+ .setAttribute(
+ "label",
+ messengerBundle.GetStringFromName(
+ isServer || flags & Ci.nsMsgFolderFlags.Inbox
+ ? "newFolder"
+ : "newSubfolder"
+ )
+ );
+ }
+
+ showItem(
+ "folderPaneContext-markMailFolderAllRead",
+ !isServer && !isSmartTagsFolder && serverType != "nntp"
+ );
+ showItem(
+ "folderPaneContext-markNewsgroupAllRead",
+ isRealFolder && serverType == "nntp"
+ );
+ showItem(
+ "folderPaneContext-emptyTrash",
+ isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true)
+ );
+ showItem("folderPaneContext-emptyJunk", isJunk);
+ showItem(
+ "folderPaneContext-sendUnsentMessages",
+ flags & Ci.nsMsgFolderFlags.Queue
+ );
+
+ checkItem(
+ "folderPaneContext-favoriteFolder",
+ flags & Ci.nsMsgFolderFlags.Favorite
+ );
+ showItem("folderPaneContext-markAllFoldersRead", isServer);
+
+ showItem("folderPaneContext-settings", isServer);
+
+ showItem("folderPaneContext-manageTags", isSmartTagsFolder);
+
+ // If source folder is virtual, allow only "move" within its own server.
+ // Don't show "copy" and "again" and don't show "recent" and "favorite".
+ // Also, check if this is a top-level smart folder, e.g., virtual "Inbox"
+ // in unified folder view or a Tags folder. If so, don't show "move".
+ let movePopup = document.getElementById("folderContext-movePopup");
+ if (isVirtual) {
+ showItem("folderPaneContext-copyMenu", false);
+ let showMove = true;
+ if (isSmartVirtualFolder || isSmartTagsFolder) {
+ showMove = false;
+ }
+ showItem("folderPaneContext-moveMenu", showMove);
+ if (showMove) {
+ let rootURI = MailUtils.getOrCreateFolder(
+ this.activeFolder.rootFolder.URI
+ );
+ movePopup.parentFolder = rootURI;
+ }
+ } else {
+ // Non-virtual. Don't allow move or copy of special use or root folder.
+ let okToMoveCopy = !(isServer || flags & Ci.nsMsgFolderFlags.SpecialUse);
+ if (okToMoveCopy) {
+ // Set the move menu to show all accounts.
+ movePopup.parentFolder = null;
+ }
+ showItem("folderPaneContext-moveMenu", okToMoveCopy);
+ showItem("folderPaneContext-copyMenu", okToMoveCopy);
+ }
+
+ let lastItem;
+ for (let child of document.getElementById("folderPaneContext").children) {
+ if (child.localName == "menuseparator") {
+ child.hidden = !lastItem || lastItem.localName == "menuseparator";
+ }
+ if (!child.hidden) {
+ lastItem = child;
+ }
+ }
+ if (lastItem.localName == "menuseparator") {
+ lastItem.hidden = true;
+ }
+ },
+
+ onPopupHidden(event) {
+ if (event.target != this._menupopup) {
+ return;
+ }
+
+ folderTree
+ .querySelector(".context-menu-target")
+ ?.classList.remove("context-menu-target");
+ this.clearOverrideFolder();
+ },
+
+ /**
+ * Check if the transfer mode selected from folder context menu is "copy".
+ * If "copy" (!isMove) is selected and the copy is within the same server,
+ * silently change to mode "move".
+ * Do the transfer and return true if moved, false if copied.
+ *
+ * @param {boolean} isMove
+ * @param {nsIMsgFolder} sourceFolder
+ * @param {nsIMsgFolder} targetFolder
+ */
+ transferFolder(isMove, sourceFolder, targetFolder) {
+ if (!isMove && sourceFolder.server == targetFolder.server) {
+ // Don't allow folder copy within the same server; only move allowed.
+ // Can't copy folder intra-server, change to move.
+ isMove = true;
+ }
+ // Do the transfer. A slight delay in calling copyFolder() helps the
+ // folder-menupopup chain of items get properly closed so the next folder
+ // context popup can occur.
+ setTimeout(() =>
+ MailServices.copy.copyFolder(
+ sourceFolder,
+ targetFolder,
+ isMove,
+ null,
+ top.msgWindow
+ )
+ );
+ return isMove;
+ },
+
+ onCommand(event) {
+ let folder = this.activeFolder;
+ // If commandController handles this command, ask it to do so.
+ if (event.target.id in this._commands) {
+ commandController.doCommand(this._commands[event.target.id], folder);
+ return;
+ }
+
+ let topChromeWindow = window.browsingContext.topChromeWindow;
+ switch (event.target.id) {
+ case "folderPaneContext-getMessages":
+ topChromeWindow.MsgGetMessage([folder]);
+ break;
+ case "folderPaneContext-pauseAllUpdates":
+ topChromeWindow.MsgPauseUpdates(
+ [folder],
+ event.target.getAttribute("checked") == "true"
+ );
+ break;
+ case "folderPaneContext-pauseUpdates":
+ topChromeWindow.MsgPauseUpdates(
+ [folder],
+ event.target.getAttribute("checked") == "true"
+ );
+ break;
+ case "folderPaneContext-openNewTab":
+ topChromeWindow.MsgOpenNewTabForFolders([folder], {
+ event,
+ folderPaneVisible: !paneLayout.folderPaneSplitter.isCollapsed,
+ messagePaneVisible: !paneLayout.messagePaneSplitter.isCollapsed,
+ });
+ break;
+ case "folderPaneContext-openNewWindow":
+ topChromeWindow.MsgOpenNewWindowForFolder(folder.URI, -1);
+ break;
+ case "folderPaneContext-searchMessages":
+ commandController.doCommand("cmd_searchMessages", folder);
+ break;
+ case "folderPaneContext-subscribe":
+ topChromeWindow.MsgSubscribe(folder);
+ break;
+ case "folderPaneContext-newsUnsubscribe":
+ topChromeWindow.MsgUnsubscribe([folder]);
+ break;
+ case "folderPaneContext-markMailFolderAllRead":
+ case "folderPaneContext-markNewsgroupAllRead":
+ if (folder.flags & Ci.nsMsgFolderFlags.Virtual) {
+ topChromeWindow.MsgMarkAllRead(
+ VirtualFolderHelper.wrapVirtualFolder(folder).searchFolders
+ );
+ } else {
+ topChromeWindow.MsgMarkAllRead([folder]);
+ }
+ break;
+ case "folderPaneContext-emptyTrash":
+ folderPane.emptyTrash(folder);
+ break;
+ case "folderPaneContext-emptyJunk":
+ folderPane.emptyJunk(folder);
+ break;
+ case "folderPaneContext-sendUnsentMessages":
+ topChromeWindow.SendUnsentMessages();
+ break;
+ case "folderPaneContext-properties":
+ folderPane.editFolder(folder);
+ break;
+ case "folderPaneContext-markAllFoldersRead":
+ topChromeWindow.MsgMarkAllFoldersRead([folder]);
+ break;
+ case "folderPaneContext-settings":
+ folderPane.editFolder(folder);
+ break;
+ case "folderPaneContext-manageTags":
+ goDoCommand("cmd_manageTags");
+ break;
+ default: {
+ // Handle folder context menu items move to, copy to.
+ let isMove = false;
+ let isCopy = false;
+ let targetFolder;
+ if (
+ document
+ .getElementById("folderPaneContext-moveMenu")
+ .contains(event.target)
+ ) {
+ // A move is requested via foldermenu-popup.
+ isMove = true;
+ } else if (
+ document
+ .getElementById("folderPaneContext-copyMenu")
+ .contains(event.target)
+ ) {
+ // A copy is requested via foldermenu-popup.
+ isCopy = true;
+ }
+ if (isMove || isCopy) {
+ if (!targetFolder) {
+ targetFolder = event.target._folder;
+ }
+ isMove = this.transferFolder(isMove, folder, targetFolder);
+ // Save in prefs the target folder URI and if this was a move or
+ // copy. This is to fill in the next folder or message context
+ // menu item "Move|Copy to <TargetFolderName> Again".
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ targetFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove);
+ }
+ break;
+ }
+ }
+ },
+};
+
+var folderPane = {
+ _initialized: false,
+
+ /**
+ * If the local folders should be hidden.
+ * @type {boolean}
+ */
+ _hideLocalFolders: false,
+
+ _modes: {
+ all: {
+ name: "all",
+ active: false,
+ canBeCompact: false,
+
+ initServer(server) {
+ let serverRow = folderPane._createServerRow(this.name, server);
+ folderPane._insertInServerOrder(this.containerList, serverRow);
+ folderPane._addSubFolders(server.rootFolder, serverRow, this.name);
+ },
+
+ addFolder(parentFolder, childFolder) {
+ FolderTreeProperties.setIsExpanded(childFolder.URI, this.name, true);
+ if (
+ childFolder.server.hidden ||
+ folderPane.getRowForFolder(childFolder, this.name)
+ ) {
+ // We're not displaying this server, or the folder already exists in
+ // the folder tree. Was `addFolder` called twice?
+ return;
+ }
+ if (!parentFolder) {
+ folderPane._insertInServerOrder(
+ this.containerList,
+ folderPane._createServerRow(this.name, childFolder.server)
+ );
+ return;
+ }
+
+ let parentRow = folderPane.getRowForFolder(parentFolder, this.name);
+ if (!parentRow) {
+ console.error("no parentRow for ", parentFolder.URI, childFolder.URI);
+ }
+ // To auto-expand non-root imap folders, imap URL "discoverchildren" is
+ // triggered -- but actually only occurs if server settings configured
+ // to ignore subscriptions. (This also occurs in _onExpanded() for
+ // manual folder expansion.)
+ if (parentFolder.server.type == "imap" && !parentFolder.isServer) {
+ parentFolder.QueryInterface(Ci.nsIMsgImapMailFolder);
+ parentFolder.performExpand(top.msgWindow);
+ }
+ folderTree.expandRow(parentRow);
+ let childRow = folderPane._createFolderRow(this.name, childFolder);
+ folderPane._addSubFolders(childFolder, childRow, "all");
+ parentRow.insertChildInOrder(childRow);
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ folderPane.getRowForFolder(childFolder, this.name)?.remove();
+ },
+
+ changeAccountOrder() {
+ folderPane._reapplyServerOrder(this.containerList);
+ },
+ },
+ smart: {
+ name: "smart",
+ active: false,
+ canBeCompact: false,
+
+ _folderTypes: [
+ { flag: Ci.nsMsgFolderFlags.Inbox, name: "Inbox" },
+ { flag: Ci.nsMsgFolderFlags.Drafts, name: "Drafts" },
+ { flag: Ci.nsMsgFolderFlags.Templates, name: "Templates" },
+ { flag: Ci.nsMsgFolderFlags.SentMail, name: "Sent" },
+ { flag: Ci.nsMsgFolderFlags.Archive, name: "Archives" },
+ { flag: Ci.nsMsgFolderFlags.Junk, name: "Junk" },
+ { flag: Ci.nsMsgFolderFlags.Trash, name: "Trash" },
+ // { flag: Ci.nsMsgFolderFlags.Queue, name: "Outbox" },
+ ],
+
+ init() {
+ this._smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ if (!this._smartServer) {
+ this._smartServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ // We don't want the "smart" server/account leaking out into the ui in
+ // other places, so set it as hidden.
+ this._smartServer.hidden = true;
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = this._smartServer;
+ }
+ this._smartServer.prettyName =
+ messengerBundle.GetStringFromName("unifiedAccountName");
+ let smartRoot = this._smartServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ let allFlags = 0;
+ this._folderTypes.forEach(folderType => (allFlags |= folderType.flag));
+
+ for (let folderType of this._folderTypes) {
+ let folder = smartRoot.getChildWithURI(
+ `${smartRoot.URI}/${folderType.name}`,
+ false,
+ true
+ );
+ if (!folder) {
+ try {
+ let searchFolders = [];
+
+ function recurse(folder) {
+ let subFolders;
+ try {
+ subFolders = folder.subFolders;
+ } catch (ex) {
+ console.error(
+ new Error(
+ `Unable to access the subfolders of ${folder.URI}`,
+ { cause: ex }
+ )
+ );
+ }
+ if (!subFolders?.length) {
+ return;
+ }
+
+ for (let sf of subFolders) {
+ // Add all of the subfolders except the ones that belong to
+ // a different folder type.
+ if (!(sf.flags & allFlags)) {
+ searchFolders.push(sf);
+ recurse(sf);
+ }
+ }
+ }
+
+ for (let server of MailServices.accounts.allServers) {
+ for (let f of server.rootFolder.getFoldersWithFlags(
+ folderType.flag
+ )) {
+ searchFolders.push(f);
+ recurse(f);
+ }
+ }
+
+ folder = smartRoot.createLocalSubfolder(folderType.name);
+ folder.flags |= Ci.nsMsgFolderFlags.Virtual | folderType.flag;
+
+ let msgDatabase = folder.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+
+ folderInfo.setCharProperty("searchStr", "ALL");
+ folderInfo.setCharProperty(
+ "searchFolderUri",
+ searchFolders.map(f => f.URI).join("|")
+ );
+ folderInfo.setUint32Property("searchFolderFlag", folderType.flag);
+ folderInfo.setBooleanProperty("searchOnline", true);
+ msgDatabase.summaryValid = true;
+ msgDatabase.close(true);
+
+ smartRoot.notifyFolderAdded(folder);
+ } catch (ex) {
+ console.error(ex);
+ continue;
+ }
+ }
+ let row = folderPane._createFolderRow(this.name, folder);
+ this.containerList.appendChild(row);
+ folderType.folderURI = folder.URI;
+ folderType.list = row.childList;
+
+ // Display the searched folders for this type.
+ let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder);
+ for (let searchFolder of wrappedFolder.searchFolders) {
+ if (searchFolder != folder) {
+ this._addSearchedFolder(
+ folderType,
+ folderPane._getNonGmailParent(searchFolder),
+ searchFolder
+ );
+ }
+ }
+ }
+ MailServices.accounts.saveVirtualFolders();
+ },
+
+ regenerateMode() {
+ if (this._smartServer) {
+ MailServices.accounts.removeIncomingServer(this._smartServer, true);
+ }
+ this.init();
+ },
+
+ _addSearchedFolder(folderType, parentFolder, childFolder) {
+ if (folderType.flag & childFolder.flags) {
+ // The folder has the flag for this type.
+ let folderRow = folderPane._createFolderRow(
+ this.name,
+ childFolder,
+ "server"
+ );
+ folderPane._insertInServerOrder(folderType.list, folderRow);
+ return;
+ }
+
+ if (!childFolder.isSpecialFolder(folderType.flag, true)) {
+ // This folder is searched by the virtual folder but it hasn't got
+ // the flag of this type and no ancestor has the flag of this type.
+ // We don't have a good way of displaying it.
+ return;
+ }
+
+ // The folder is a descendant of one which has the flag.
+ let parentRow = folderPane.getRowForFolder(parentFolder, this.name);
+ if (!parentRow) {
+ // This is awkward: `childFolder` is searched but `parentFolder` is
+ // not. Displaying the unsearched folder is probably the least
+ // confusing way to handle this situation.
+ this._addSearchedFolder(
+ folderType,
+ folderPane._getNonGmailParent(parentFolder),
+ parentFolder
+ );
+ parentRow = folderPane.getRowForFolder(parentFolder, this.name);
+ }
+ parentRow.insertChildInOrder(
+ folderPane._createFolderRow(this.name, childFolder)
+ );
+ },
+
+ changeSearchedFolders(smartFolder) {
+ let folderType = this._folderTypes.find(
+ ft => ft.folderURI == smartFolder.URI
+ );
+ if (!folderType) {
+ // This virtual folder isn't one of the smart folders. It's probably
+ // one of the tags virtual folders.
+ return;
+ }
+
+ let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(smartFolder);
+ let smartFolderRow = folderPane.getRowForFolder(smartFolder, this.name);
+ let searchFolderURIs = wrappedFolder.searchFolders.map(sf => sf.URI);
+ let serversToCheck = new Set();
+
+ // Remove any rows which may belong to folders that aren't searched.
+ for (let row of [...smartFolderRow.querySelectorAll("li")]) {
+ if (!searchFolderURIs.includes(row.uri)) {
+ row.remove();
+ let folder = MailServices.folderLookup.getFolderForURL(row.uri);
+ if (folder) {
+ serversToCheck.add(folder.server);
+ }
+ }
+ }
+
+ // Add missing rows for folders that are searched.
+ let existingRowURIs = Array.from(
+ smartFolderRow.querySelectorAll("li"),
+ row => row.uri
+ );
+ for (let searchFolder of wrappedFolder.searchFolders) {
+ if (
+ searchFolder == smartFolder ||
+ existingRowURIs.includes(searchFolder.URI)
+ ) {
+ continue;
+ }
+ let existingRow = folderPane.getRowForFolder(searchFolder, this.name);
+ if (existingRow) {
+ // A row for this folder exists, but not under the smart folder.
+ // Remove it and display under the smart folder.
+ folderPane._removeFolderAndAncestors(searchFolder, this.name, f =>
+ searchFolderURIs.includes(f.URI)
+ );
+ }
+ this._addSearchedFolder(
+ folderType,
+ folderPane._getNonGmailParent(searchFolder),
+ searchFolder
+ );
+ }
+
+ // For any rows we removed, check they are added back to the tree.
+ for (let server of serversToCheck) {
+ this.initServer(server);
+ }
+ },
+
+ initServer(server) {
+ // Find all folders in this server, and display the ones that aren't
+ // currently displayed.
+ let descendants = new Map(
+ server.rootFolder.descendants.map(d => [d.URI, d])
+ );
+ if (!descendants.size) {
+ return;
+ }
+ let remainingFolderURIs = Array.from(descendants.keys());
+
+ // Get a list of folders that already exist in the folder tree.
+ let existingRows = this.containerList.getElementsByTagName("li");
+ let existingURIs = Array.from(existingRows, li => li.uri);
+ do {
+ let folderURI = remainingFolderURIs.shift();
+ if (existingURIs.includes(folderURI)) {
+ continue;
+ }
+ let folder = descendants.get(folderURI);
+ if (folderPane._isGmailFolder(folder)) {
+ continue;
+ }
+ this.addFolder(folderPane._getNonGmailParent(folder), folder);
+ // Update the list of existing folders. `existingRows` is a live
+ // list, so we don't need to call `getElementsByTagName` again.
+ existingURIs = Array.from(existingRows, li => li.uri);
+ } while (remainingFolderURIs.length);
+ },
+
+ addFolder(parentFolder, childFolder) {
+ if (folderPane.getRowForFolder(childFolder, this.name)) {
+ // If a row for this folder exists, do nothing.
+ return;
+ }
+ if (!parentFolder) {
+ // If this folder is the root folder for a server, do nothing.
+ return;
+ }
+ if (childFolder.server.hidden) {
+ // If this folder is from a hidden server, do nothing.
+ return;
+ }
+
+ let folderType = this._folderTypes.find(ft =>
+ childFolder.isSpecialFolder(ft.flag, true)
+ );
+ if (folderType) {
+ let virtualFolder = VirtualFolderHelper.wrapVirtualFolder(
+ MailServices.folderLookup.getFolderForURL(folderType.folderURI)
+ );
+ let searchFolders = virtualFolder.searchFolders;
+ if (searchFolders.includes(childFolder)) {
+ // This folder is included in the virtual folder, do nothing.
+ return;
+ }
+
+ if (searchFolders.includes(parentFolder)) {
+ // This folder's parent is included in the virtual folder, but the
+ // folder itself isn't. Add it to the list of non-special folders.
+ // Note that `_addFolderAndAncestors` can't be used here, as that
+ // would add the row in the wrong place.
+ let serverRow = folderPane.getRowForFolder(
+ childFolder.rootFolder,
+ this.name
+ );
+ if (!serverRow) {
+ serverRow = folderPane._createServerRow(
+ this.name,
+ childFolder.server
+ );
+ folderPane._insertInServerOrder(this.containerList, serverRow);
+ }
+ let folderRow = folderPane._createFolderRow(this.name, childFolder);
+ serverRow.insertChildInOrder(folderRow);
+ folderPane._addSubFolders(childFolder, folderRow, this.name);
+ return;
+ }
+ }
+
+ // Nothing special about this folder. Add it to the end of the list.
+ let folderRow = folderPane._addFolderAndAncestors(
+ this.containerList,
+ childFolder,
+ this.name
+ );
+ folderPane._addSubFolders(childFolder, folderRow, this.name);
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ let childRow = folderPane.getRowForFolder(childFolder, this.name);
+ if (!childRow) {
+ return;
+ }
+ let parentRow = childRow.parentNode.closest("li");
+ childRow.remove();
+ if (
+ parentRow.parentNode == this.containerList &&
+ parentRow.dataset.serverType &&
+ !parentRow.querySelector("li")
+ ) {
+ parentRow.remove();
+ }
+ },
+
+ changeAccountOrder() {
+ folderPane._reapplyServerOrder(this.containerList);
+
+ for (let smartFolderRow of this.containerList.children) {
+ if (smartFolderRow.dataset.serverKey == this._smartServer.key) {
+ folderPane._reapplyServerOrder(smartFolderRow.childList);
+ }
+ }
+ },
+ },
+ unread: {
+ name: "unread",
+ active: false,
+ canBeCompact: true,
+
+ _unreadFilter(folder, includeSubFolders = true) {
+ return folder.getNumUnread(includeSubFolders) > 0;
+ },
+
+ initServer(server) {
+ this.addFolder(null, server.rootFolder);
+ },
+
+ _recurseSubFolders(parentFolder) {
+ let subFolders;
+ try {
+ subFolders = parentFolder.subFolders;
+ } catch (ex) {
+ console.error(
+ new Error(
+ `Unable to access the subfolders of ${parentFolder.URI}`,
+ { cause: ex }
+ )
+ );
+ }
+ if (!subFolders?.length) {
+ return;
+ }
+
+ for (let i = 0; i < subFolders.length; i++) {
+ let folder = subFolders[i];
+ if (folderPane._isGmailFolder(folder)) {
+ subFolders.splice(i, 1, ...folder.subFolders);
+ }
+ }
+
+ subFolders.sort((a, b) => a.compareSortKeys(b));
+
+ for (let folder of subFolders) {
+ if (!this._unreadFilter(folder)) {
+ continue;
+ }
+ if (this._unreadFilter(folder, false)) {
+ this._addFolder(folder);
+ }
+ this._recurseSubFolders(folder);
+ }
+ },
+
+ addFolder(unused, folder) {
+ if (!this._unreadFilter(folder)) {
+ return;
+ }
+ this._addFolder(folder);
+ this._recurseSubFolders(folder);
+ },
+
+ _addFolder(folder) {
+ if (folderPane.getRowForFolder(folder, this.name)) {
+ // Don't do anything. `folderPane.changeUnreadCount` already did it.
+ return;
+ }
+
+ if (!this._unreadFilter(folder, !folderPane._isCompact)) {
+ return;
+ }
+
+ if (folderPane._isCompact) {
+ let folderRow = folderPane._createFolderRow(
+ this.name,
+ folder,
+ "both"
+ );
+ folderPane._insertInServerOrder(this.containerList, folderRow);
+ return;
+ }
+
+ folderPane._addFolderAndAncestors(
+ this.containerList,
+ folder,
+ this.name
+ );
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ folderPane._removeFolderAndAncestors(
+ childFolder,
+ this.name,
+ this._unreadFilter
+ );
+
+ // If the folder is being moved, `childFolder.parent` is null so the
+ // above code won't remove ancestors. Do this now.
+ if (!childFolder.parent && parentFolder) {
+ folderPane._removeFolderAndAncestors(
+ parentFolder,
+ this.name,
+ this._unreadFilter,
+ true
+ );
+ }
+
+ // Remove any stray rows that might be descendants of `childFolder`.
+ for (let row of [...this.containerList.querySelectorAll("li")]) {
+ if (row.uri.startsWith(childFolder.URI + "/")) {
+ row.remove();
+ }
+ }
+ },
+
+ changeUnreadCount(folder, newValue) {
+ if (newValue > 0) {
+ this._addFolder(folder);
+ }
+ },
+
+ changeAccountOrder() {
+ folderPane._reapplyServerOrder(this.containerList);
+ },
+ },
+ favorite: {
+ name: "favorite",
+ active: false,
+ canBeCompact: true,
+
+ _favoriteFilter(folder) {
+ return folder.flags & Ci.nsMsgFolderFlags.Favorite;
+ },
+
+ initServer(server) {
+ this.addFolder(null, server.rootFolder);
+ },
+
+ addFolder(unused, folder) {
+ this._addFolder(folder);
+ for (let subFolder of folder.getFoldersWithFlags(
+ Ci.nsMsgFolderFlags.Favorite
+ )) {
+ this._addFolder(subFolder);
+ }
+ },
+
+ _addFolder(folder) {
+ if (
+ !this._favoriteFilter(folder) ||
+ folderPane.getRowForFolder(folder, this.name)
+ ) {
+ return;
+ }
+
+ if (folderPane._isCompact) {
+ folderPane._insertInServerOrder(
+ this.containerList,
+ folderPane._createFolderRow(this.name, folder, "both")
+ );
+ return;
+ }
+
+ folderPane._addFolderAndAncestors(
+ this.containerList,
+ folder,
+ this.name
+ );
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ folderPane._removeFolderAndAncestors(
+ childFolder,
+ this.name,
+ this._favoriteFilter
+ );
+
+ // If the folder is being moved, `childFolder.parent` is null so the
+ // above code won't remove ancestors. Do this now.
+ if (!childFolder.parent && parentFolder) {
+ folderPane._removeFolderAndAncestors(
+ parentFolder,
+ this.name,
+ this._favoriteFilter,
+ true
+ );
+ }
+
+ // Remove any stray rows that might be descendants of `childFolder`.
+ for (let row of [...this.containerList.querySelectorAll("li")]) {
+ if (row.uri.startsWith(childFolder.URI + "/")) {
+ row.remove();
+ }
+ }
+ },
+
+ changeFolderFlag(folder, oldValue, newValue) {
+ oldValue &= Ci.nsMsgFolderFlags.Favorite;
+ newValue &= Ci.nsMsgFolderFlags.Favorite;
+
+ if (oldValue == newValue) {
+ return;
+ }
+
+ if (oldValue) {
+ if (
+ folderPane._isCompact ||
+ !folder.getFolderWithFlags(Ci.nsMsgFolderFlags.Favorite)
+ ) {
+ folderPane._removeFolderAndAncestors(
+ folder,
+ this.name,
+ this._favoriteFilter
+ );
+ }
+ } else {
+ this._addFolder(folder);
+ }
+ },
+
+ changeAccountOrder() {
+ folderPane._reapplyServerOrder(this.containerList);
+ },
+ },
+ recent: {
+ name: "recent",
+ active: false,
+ canBeCompact: false,
+
+ init() {
+ let folders = FolderUtils.getMostRecentFolders(
+ MailServices.accounts.allFolders,
+ Services.prefs.getIntPref("mail.folder_widget.max_recent"),
+ "MRUTime"
+ );
+ for (let folder of folders) {
+ let folderRow = folderPane._createFolderRow(
+ this.name,
+ folder,
+ "both"
+ );
+ this.containerList.appendChild(folderRow);
+ }
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ folderPane.getRowForFolder(childFolder)?.remove();
+ },
+ },
+ tags: {
+ name: "tags",
+ active: false,
+ canBeCompact: false,
+
+ init() {
+ this._smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ if (!this._smartServer) {
+ this._smartServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ // We don't want the "smart" server/account leaking out into the ui in
+ // other places, so set it as hidden.
+ this._smartServer.hidden = true;
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = this._smartServer;
+ }
+ this._smartServer.prettyName =
+ messengerBundle.GetStringFromName("unifiedAccountName");
+ let smartRoot = this._smartServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+ this._tagsFolder =
+ smartRoot.getChildWithURI(`${smartRoot.URI}/tags`, false, false) ??
+ smartRoot.createLocalSubfolder("tags");
+ this._tagsFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ for (let tag of MailServices.tags.getAllTags()) {
+ try {
+ let folder = this._getVirtualFolder(tag);
+ this.containerList.appendChild(
+ folderPane._createTagRow(this.name, folder, tag)
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ MailServices.accounts.saveVirtualFolders();
+ },
+
+ /**
+ * Get or create a virtual folder searching messages for `tag`.
+ *
+ * @param {nsIMsgTag} tag
+ * @returns {nsIMsgFolder}
+ */
+ _getVirtualFolder(tag) {
+ let folder = this._tagsFolder.getChildWithURI(
+ `${this._tagsFolder.URI}/${encodeURIComponent(tag.key)}`,
+ false,
+ false
+ );
+ if (folder) {
+ return folder;
+ }
+
+ folder = this._tagsFolder.createLocalSubfolder(tag.key);
+ folder.flags |= Ci.nsMsgFolderFlags.Virtual;
+ folder.prettyName = tag.tag;
+
+ let msgDatabase = folder.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+
+ folderInfo.setCharProperty(
+ "searchStr",
+ `AND (tag,contains,${tag.key})`
+ );
+ folderInfo.setCharProperty("searchFolderUri", "*");
+ folderInfo.setUint32Property(
+ "searchFolderFlag",
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ folderInfo.setBooleanProperty("searchOnline", false);
+ msgDatabase.summaryValid = true;
+ msgDatabase.close(true);
+
+ this._tagsFolder.notifyFolderAdded(folder);
+ return folder;
+ },
+
+ /**
+ * Update the UI to match changes in a tag. If the tag is no longer
+ * valid (i.e. it's been deleted) the row representing it will be
+ * removed. If the tag is new, a row for it will be created.
+ *
+ * @param {string} prefName - The full name of the preference that
+ * changed causing this code to run.
+ */
+ changeTagFromPrefChange(prefName) {
+ let [, , key] = prefName.split(".");
+ if (!MailServices.tags.isValidKey(key)) {
+ let uri = `${this._tagsFolder.URI}/${encodeURIComponent(key)}`;
+ folderPane.getRowForFolder(uri)?.remove();
+ return;
+ }
+
+ let tag = MailServices.tags.getAllTags().find(t => t.key == key);
+ let folder = this._getVirtualFolder(tag);
+ let row = folderPane.getRowForFolder(folder);
+ folder.prettyName = tag.tag;
+ if (row) {
+ row.name = tag.tag;
+ row.icon.style.setProperty("--icon-color", tag.color);
+ } else {
+ this.containerList.appendChild(
+ folderPane._createTagRow(this.name, folder, tag)
+ );
+ }
+ },
+ },
+ },
+
+ /**
+ * Initialize the folder pane if needed.
+ * @returns {Promise<void>} when the folder pane is initialized.
+ */
+ async init() {
+ if (this._initialized) {
+ return;
+ }
+ if (window.openingState?.syntheticView) {
+ // Just avoid initialising the pane. We won't be using it. The folder
+ // listener is still required, because it does other things too.
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.all
+ );
+ return;
+ }
+
+ try {
+ // We could be here before `loadPostAccountWizard` loads the virtual
+ // folders, and we need them, so do it now.
+ MailServices.accounts.loadVirtualFolders();
+ } catch (e) {
+ console.error(e);
+ }
+
+ await FolderTreeProperties.ready;
+
+ this._modeTemplate = document.getElementById("modeTemplate");
+ this._folderTemplate = document.getElementById("folderTemplate");
+
+ this._isCompact =
+ Services.xulStore.getValue(XULSTORE_URL, "folderTree", "compact") ===
+ "true";
+ let activeModes = Services.xulStore.getValue(
+ XULSTORE_URL,
+ "folderTree",
+ "mode"
+ );
+ activeModes = activeModes.split(",");
+ this.activeModes = activeModes;
+
+ // Don't await anything between the active modes being initialised (the
+ // line above) and the listener being added. Otherwise folders may appear
+ // while we're not listening.
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.all
+ );
+
+ Services.prefs.addObserver("mail.accountmanager.accounts", this);
+ Services.prefs.addObserver("mailnews.tags.", this);
+
+ Services.obs.addObserver(this, "folder-color-changed");
+ Services.obs.addObserver(this, "folder-color-preview");
+ Services.obs.addObserver(this, "search-folders-changed");
+ Services.obs.addObserver(this, "folder-properties-changed");
+
+ folderTree.addEventListener("auxclick", this);
+ folderTree.addEventListener("contextmenu", this);
+ folderTree.addEventListener("collapsed", this);
+ folderTree.addEventListener("expanded", this);
+ folderTree.addEventListener("dragstart", this);
+ folderTree.addEventListener("dragover", this);
+ folderTree.addEventListener("dragleave", this);
+ folderTree.addEventListener("drop", this);
+
+ document.getElementById("folderPaneHeaderBar").hidden =
+ this.isFolderPaneHeaderHidden();
+ const folderPaneGetMessages = document.getElementById(
+ "folderPaneGetMessages"
+ );
+ folderPaneGetMessages.addEventListener("click", () => {
+ top.MsgGetMessagesForAccount();
+ });
+ folderPaneGetMessages.addEventListener("contextmenu", event => {
+ document
+ .getElementById("folderPaneGetMessagesContext")
+ .openPopup(event.target, { triggerEvent: event });
+ });
+ document
+ .getElementById("folderPaneWriteMessage")
+ .addEventListener("click", event => {
+ top.MsgNewMessage(event);
+ });
+ folderPaneGetMessages.hidden = this.isFolderPaneGetMsgsBtnHidden();
+ document.getElementById("folderPaneWriteMessage").hidden =
+ this.isFolderPaneNewMsgBtnHidden();
+ this.moreContext = document.getElementById("folderPaneMoreContext");
+ this.folderPaneModeContext = document.getElementById(
+ "folderPaneModeContext"
+ );
+
+ document
+ .getElementById("folderPaneMoreButton")
+ .addEventListener("click", event => {
+ this.moreContext.openPopup(event.target, { triggerEvent: event });
+ });
+ this.subFolderContext = document.getElementById(
+ "folderModesContextMenuPopup"
+ );
+ document
+ .getElementById("folderModesContextMenuPopup")
+ .addEventListener("click", event => {
+ this.subFolderContext.openPopup(event.target, { triggerEvent: event });
+ });
+ this.updateFolderRowUIElements();
+ this.updateWidgets();
+
+ this._initialized = true;
+ },
+
+ uninit() {
+ if (!this._initialized) {
+ return;
+ }
+ Services.prefs.removeObserver("mail.accountmanager.accounts", this);
+ Services.prefs.removeObserver("mailnews.tags.", this);
+ Services.obs.removeObserver(this, "folder-color-changed");
+ Services.obs.removeObserver(this, "folder-color-preview");
+ Services.obs.removeObserver(this, "search-folders-changed");
+ Services.obs.removeObserver(this, "folder-properties-changed");
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "select":
+ this._onSelect(event);
+ break;
+ case "auxclick":
+ if (event.button == 1) {
+ this._onMiddleClick(event);
+ }
+ break;
+ case "contextmenu":
+ this._onContextMenu(event);
+ break;
+ case "collapsed":
+ this._onCollapsed(event);
+ break;
+ case "expanded":
+ this._onExpanded(event);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "dragleave":
+ this._clearDropTarget(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ if (data == "mail.accountmanager.accounts") {
+ this._forAllActiveModes("changeAccountOrder");
+ } else if (
+ data.startsWith("mailnews.tags.") &&
+ this._modes.tags.active
+ ) {
+ // The tags service isn't updated until immediately after the
+ // preferences change, so go to the back of the event queue before
+ // updating the UI.
+ setTimeout(() => this._modes.tags.changeTagFromPrefChange(data));
+ }
+ break;
+ case "search-folders-changed":
+ if (this._modes.smart.active) {
+ subject.QueryInterface(Ci.nsIMsgFolder);
+ if (subject.server == this._modes.smart._smartServer) {
+ this._modes.smart.changeSearchedFolders(subject);
+ }
+ }
+ break;
+ case "folder-properties-changed":
+ this.updateFolderProperties(subject.QueryInterface(Ci.nsIMsgFolder));
+ break;
+ case "folder-color-changed":
+ case "folder-color-preview":
+ this._changeRows(subject, row => row.setIconColor(data));
+ break;
+ }
+ },
+
+ /**
+ * Whether the folder pane has been initialized.
+ *
+ * @type {boolean}
+ */
+ get isInitialized() {
+ return this._initialized;
+ },
+
+ /**
+ * If the local folders are currently hidden.
+ *
+ * @returns {boolean}
+ */
+ get hideLocalFolders() {
+ this._hideLocalFolders = this.isItemHidden("folderPaneLocalFolders");
+ return this._hideLocalFolders;
+ },
+
+ /**
+ * Reload the folder tree when the option changes.
+ *
+ * @param {boolean} - True if local folders should be hidden.
+ */
+ set hideLocalFolders(value) {
+ if (value == this._hideLocalFolders) {
+ return;
+ }
+
+ this._hideLocalFolders = value;
+ for (let mode of Object.values(this._modes)) {
+ if (!mode.active) {
+ continue;
+ }
+ mode.containerList.replaceChildren();
+ this._initMode(mode);
+ }
+ this.updateFolderRowUIElements();
+ },
+
+ /**
+ * Toggle the folder modes requested by the user.
+ *
+ * @param {Event} event - The DOMEvent.
+ */
+ toggleFolderMode(event) {
+ let currentModes = this.activeModes;
+ let mode = event.target.getAttribute("value");
+ let index = this.activeModes.indexOf(mode);
+
+ if (event.target.hasAttribute("checked")) {
+ if (index == -1) {
+ currentModes.push(mode);
+ }
+ } else if (index >= 0) {
+ currentModes.splice(index, 1);
+ }
+ this.activeModes = currentModes;
+ this.toggleCompactViewMenuItem();
+
+ if (this.activeModes.length == 1 && this.activeModes.at(0) == "all") {
+ this.updateContextCheckedFolderMode();
+ }
+ },
+
+ toggleCompactViewMenuItem() {
+ let subMenuCompactBtn = document.querySelector(
+ "#folderPaneMoreContextCompactToggle"
+ );
+ if (this.canBeCompact) {
+ subMenuCompactBtn.removeAttribute("disabled");
+ return;
+ }
+ subMenuCompactBtn.setAttribute("disabled", "true");
+ },
+
+ /**
+ * Ensure all the folder modes menuitems in the pane header context menu are
+ * checked to reflect the currently active modes.
+ */
+ updateContextCheckedFolderMode() {
+ for (let item of document.querySelectorAll(".folder-pane-mode")) {
+ if (this.activeModes.includes(item.value)) {
+ item.setAttribute("checked", true);
+ continue;
+ }
+ item.removeAttribute("checked");
+ }
+ },
+
+ /**
+ * Ensures all the folder pane mode context menuitems in the folder
+ * pane mode context menu are checked to reflect the current compact mode.
+ * @param {Event} event - The DOMEvent.
+ */
+ onFolderPaneModeContextOpening(event) {
+ this.mode = event.target.closest("[data-mode]")?.dataset.mode;
+
+ // If folder mode is at the top or the only one,
+ // it can't be moved up, so disable "Move Up".
+ const moveUpMenuItem = this.folderPaneModeContext.querySelector(
+ "#folderPaneModeMoveUp"
+ );
+ moveUpMenuItem.removeAttribute("disabled");
+ // Apply attribute mode to context menu option to allow
+ // for sorting later
+ if (this.activeModes.at(0) == this.mode) {
+ moveUpMenuItem.setAttribute("disabled", "true");
+ }
+
+ // If folder mode is at the bottom or the only one,
+ // it can't be moved down, so disable "Move Down".
+ const moveDownMenuItem = this.folderPaneModeContext.querySelector(
+ "#folderPaneModeMoveDown"
+ );
+ moveDownMenuItem.removeAttribute("disabled");
+ // Apply attribute mode to context menu option to allow
+ // for sorting later
+ if (this.activeModes.at(-1) == this.mode) {
+ moveDownMenuItem.setAttribute("disabled", "true");
+ }
+
+ let compactMenuItem = this.folderPaneModeContext.querySelector(
+ "#compactFolderButton"
+ );
+ compactMenuItem.removeAttribute("checked");
+ compactMenuItem.removeAttribute("disabled");
+ if (!this.canModeBeCompact(this.mode)) {
+ compactMenuItem.setAttribute("disabled", "true");
+ return;
+ }
+ if (this.isCompact) {
+ compactMenuItem.setAttribute("checked", true);
+ }
+ },
+
+ /**
+ * Toggles the compact mode of the active modes that allow it.
+ *
+ * @param {Event} event - The DOMEvent.
+ */
+ compactFolderToggle(event) {
+ this.isCompact = event.target.hasAttribute("checked");
+ },
+
+ /**
+ * Moves active folder mode up
+ *
+ * @param {Event} event - The DOMEvent.
+ */
+ moveFolderModeUp(event) {
+ let currentModes = this.activeModes;
+ const mode = this.mode;
+ const index = currentModes.indexOf(mode);
+
+ if (index > 0) {
+ const prev = currentModes[index - 1];
+ currentModes[index - 1] = currentModes[index];
+ currentModes[index] = prev;
+ }
+ this.activeModes = currentModes;
+ },
+
+ /**
+ * Moves active folder mode down
+ *
+ * @param {Event} event - The DOMEvent.
+ */
+ moveFolderModeDown(event) {
+ let currentModes = this.activeModes;
+ const mode = this.mode;
+ const index = currentModes.indexOf(mode);
+
+ if (index < currentModes.length - 1) {
+ const next = currentModes[index + 1];
+ currentModes[index + 1] = currentModes[index];
+ currentModes[index] = next;
+ }
+ this.activeModes = currentModes;
+ },
+
+ /**
+ * The names of all active modes.
+ *
+ * @type {string[]}
+ */
+ get activeModes() {
+ return Array.from(folderTree.children, li => li.dataset.mode);
+ },
+
+ set activeModes(modes) {
+ modes = modes.filter(m => m in this._modes);
+ if (modes.length == 0) {
+ modes = ["all"];
+ }
+ for (let name of Object.keys(this._modes)) {
+ this._toggleMode(name, modes.includes(name));
+ }
+ for (let name of modes) {
+ let { container, containerHeader } = this._modes[name];
+ containerHeader.hidden = modes.length == 1;
+ folderTree.appendChild(container);
+ }
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "folderTree",
+ "mode",
+ this.activeModes.join(",")
+ );
+ this.updateFolderRowUIElements();
+ },
+
+ /**
+ * Do any of the active modes have a compact variant?
+ *
+ * @type {boolean}
+ */
+ get canBeCompact() {
+ return Object.values(this._modes).some(
+ mode => mode.active && mode.canBeCompact
+ );
+ },
+
+ /**
+ * Do any of the active modes have a compact variant?
+ *
+ * @param {string} mode
+ * @type {boolean}
+ */
+ canModeBeCompact(mode) {
+ return Object.values(this._modes).some(
+ m => m.name == mode && m.active && m.canBeCompact
+ );
+ },
+
+ /**
+ * Are compact variants enabled?
+ *
+ * @type {boolean}
+ */
+ get isCompact() {
+ return this._isCompact;
+ },
+
+ set isCompact(value) {
+ if (this._isCompact == value) {
+ return;
+ }
+ this._isCompact = value;
+ for (let mode of Object.values(this._modes)) {
+ if (!mode.active || !mode.canBeCompact) {
+ continue;
+ }
+
+ mode.containerList.replaceChildren();
+ this._initMode(mode);
+ }
+ Services.xulStore.setValue(XULSTORE_URL, "folderTree", "compact", value);
+ },
+
+ /**
+ * Show or hide a folder tree mode.
+ *
+ * @param {string} modeName
+ * @param {boolean} active
+ */
+ _toggleMode(modeName, active) {
+ if (!(modeName in this._modes)) {
+ throw new Error(`Unknown folder tree mode: ${modeName}`);
+ }
+ let mode = this._modes[modeName];
+ if (mode.active == active) {
+ return;
+ }
+
+ if (!active) {
+ mode.container.remove();
+ delete mode.container;
+ mode.active = false;
+ return;
+ }
+
+ let container =
+ this._modeTemplate.content.firstElementChild.cloneNode(true);
+ container.dataset.mode = modeName;
+
+ mode.container = container;
+ mode.containerHeader = container.querySelector(".mode-container");
+ mode.containerHeader.querySelector(".mode-name").textContent =
+ messengerBundle.GetStringFromName(
+ modeName == "tags" ? "tag" : `folderPaneModeHeader_${modeName}`
+ );
+ mode.containerList = container.querySelector("ul");
+ this._initMode(mode);
+ mode.active = true;
+ container.querySelector(".mode-button").addEventListener("click", event => {
+ this.onFolderPaneModeContextOpening(event);
+ this.folderPaneModeContext.openPopup(event.target, {
+ triggerEvent: event,
+ });
+ });
+ },
+
+ /**
+ * Initialize a folder mode with all visible accounts.
+ *
+ * @param {object} mode - One of the folder modes from `folderPane._modes`.
+ */
+ _initMode(mode) {
+ if (typeof mode.init == "function") {
+ try {
+ mode.init();
+ } catch (e) {
+ console.warn(`Error intiating ${mode.name} mode.`, e);
+ if (typeof mode.regenerateMode != "function") {
+ return;
+ }
+ mode.containerList.replaceChildren();
+ mode.regenerateMode();
+ }
+ }
+ if (typeof mode.initServer != "function") {
+ return;
+ }
+
+ // `.accounts` is used here because it is ordered, `.allServers` isn't.
+ for (let account of MailServices.accounts.accounts) {
+ // Skip local folders if they're hidden.
+ if (
+ account.incomingServer.type == "none" &&
+ folderPane.hideLocalFolders
+ ) {
+ continue;
+ }
+ // Skip IM accounts.
+ if (account.incomingServer.type == "im") {
+ continue;
+ }
+ // Skip POP3 accounts that are deferred to another account.
+ if (
+ account.incomingServer instanceof Ci.nsIPop3IncomingServer &&
+ account.incomingServer.deferredToAccount
+ ) {
+ continue;
+ }
+ mode.initServer(account.incomingServer);
+ }
+ },
+
+ /**
+ * Create a FolderTreeRow representing a server.
+ *
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {nsIMsgIncomingServer} server - The server the row represents.
+ * @returns {FolderTreeRow}
+ */
+ _createServerRow(modeName, server) {
+ let row = document.createElement("li", { is: "folder-tree-row" });
+ row.modeName = modeName;
+ row.setServer(server);
+ return row;
+ },
+
+ /**
+ * Create a FolderTreeRow representing a folder.
+ *
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {nsIMsgFolder} folder - The folder the row represents.
+ * @param {"folder"|"server"|"both"} nameStyle
+ * @returns {FolderTreeRow}
+ */
+ _createFolderRow(modeName, folder, nameStyle) {
+ let row = document.createElement("li", { is: "folder-tree-row" });
+ row.modeName = modeName;
+ row.setFolder(folder, nameStyle);
+ return row;
+ },
+
+ /**
+ * Create a FolderTreeRow representing a virtual folder for a tag.
+ *
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {nsIMsgFolder} folder - The virtual folder the row represents.
+ * @param {nsIMsgTag} tag - The tag the virtual folder searches for.
+ * @returns {FolderTreeRow}
+ */
+ _createTagRow(modeName, folder, tag) {
+ let row = document.createElement("li", { is: "folder-tree-row" });
+ row.modeName = modeName;
+ row.setFolder(folder);
+ row.dataset.tagKey = tag.key;
+ row.icon.style.setProperty("--icon-color", tag.color);
+ return row;
+ },
+
+ /**
+ * Add a server row to the given list in the correct sort order.
+ *
+ * @param {HTMLUListElement} list
+ * @param {FolderTreeRow} serverRow
+ * @returns {FolderTreeRow}
+ */
+ _insertInServerOrder(list, serverRow) {
+ let serverKeys = MailServices.accounts.accounts.map(
+ a => a.incomingServer.key
+ );
+ let index = serverKeys.indexOf(serverRow.dataset.serverKey);
+ for (let row of list.children) {
+ let i = serverKeys.indexOf(row.dataset.serverKey);
+
+ if (i > index) {
+ return list.insertBefore(serverRow, row);
+ }
+ if (i < index) {
+ continue;
+ }
+
+ if (row.folderSortOrder > serverRow.folderSortOrder) {
+ return list.insertBefore(serverRow, row);
+ }
+ if (row.folderSortOrder < serverRow.folderSortOrder) {
+ continue;
+ }
+
+ if (FolderTreeRow.nameCollator.compare(row.name, serverRow.name) > 0) {
+ return list.insertBefore(serverRow, row);
+ }
+ }
+ return list.appendChild(serverRow);
+ },
+
+ _reapplyServerOrder(list) {
+ let selected = list.querySelector("li.selected");
+ let serverKeys = MailServices.accounts.accounts.map(
+ a => a.incomingServer.key
+ );
+ let serverRows = [...list.children];
+ serverRows.sort(
+ (a, b) =>
+ serverKeys.indexOf(a.dataset.serverKey) -
+ serverKeys.indexOf(b.dataset.serverKey)
+ );
+ list.replaceChildren(...serverRows);
+ if (selected) {
+ setTimeout(() => selected.classList.add("selected"));
+ }
+ },
+
+ /**
+ * Adds a row representing a folder and any missing rows for ancestors of
+ * the folder.
+ *
+ * @param {HTMLUListElement} containerList - The list to add folders to.
+ * @param {nsIMsgFolder} folder
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @returns {FolderTreeRow}
+ */
+ _addFolderAndAncestors(containerList, folder, modeName) {
+ let folderRow = folderPane.getRowForFolder(folder, modeName);
+ if (folderRow) {
+ return folderRow;
+ }
+
+ if (folder.isServer) {
+ let serverRow = folderPane._createServerRow(modeName, folder.server);
+ this._insertInServerOrder(containerList, serverRow);
+ return serverRow;
+ }
+
+ let parentRow = this._addFolderAndAncestors(
+ containerList,
+ folderPane._getNonGmailParent(folder),
+ modeName
+ );
+ folderRow = folderPane._createFolderRow(modeName, folder);
+ parentRow.insertChildInOrder(folderRow);
+ return folderRow;
+ },
+
+ /**
+ * @callback folderFilterCallback
+ * @param {FolderTreeRow} row
+ * @returns {boolean} - True if the folder should have a row in the tree.
+ */
+ /**
+ * Removes the row representing a folder and the rows for any ancestors of
+ * the folder, as long as they don't have other descendants or match
+ * `filterFunction`.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {folderFilterCallback} [filterFunction] - Optional callback to stop
+ * ascending.
+ * @param {boolean=false} childAlreadyGone - Is this function being called
+ * to remove the parent of a row that's already been removed?
+ */
+ _removeFolderAndAncestors(
+ folder,
+ modeName,
+ filterFunction,
+ childAlreadyGone = false
+ ) {
+ let folderRow = folderPane.getRowForFolder(folder, modeName);
+ if (folderPane._isCompact) {
+ folderRow?.remove();
+ return;
+ }
+
+ // If we get to a row for a folder that doesn't exist, or has children
+ // other than the one being removed, don't go any further.
+ if (
+ !folderRow ||
+ folderRow.childList.childElementCount > (childAlreadyGone ? 0 : 1)
+ ) {
+ return;
+ }
+
+ // Otherwise, move up the folder tree.
+ let parentFolder = folderPane._getNonGmailParent(folder);
+ if (
+ parentFolder &&
+ (typeof filterFunction != "function" || !filterFunction(parentFolder))
+ ) {
+ this._removeFolderAndAncestors(parentFolder, modeName, filterFunction);
+ }
+
+ // Remove the row for this folder.
+ folderRow.remove();
+ },
+
+ /**
+ * Add all subfolders to a row representing a folder. Called recursively,
+ * so all descendants are ultimately added.
+ *
+ * @param {nsIMsgFolder} parentFolder
+ * @param {FolderTreeRow} parentRow - The row representing `parentFolder`.
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {folderFilterCallback} [filterFunction] - Optional callback to add
+ * only some subfolders to the row.
+ */
+ _addSubFolders(parentFolder, parentRow, modeName, filterFunction) {
+ let subFolders;
+ try {
+ subFolders = parentFolder.subFolders;
+ } catch (ex) {
+ console.error(
+ new Error(`Unable to access the subfolders of ${parentFolder.URI}`, {
+ cause: ex,
+ })
+ );
+ }
+ if (!subFolders?.length) {
+ return;
+ }
+
+ for (let i = 0; i < subFolders.length; i++) {
+ let folder = subFolders[i];
+ if (this._isGmailFolder(folder)) {
+ subFolders.splice(i, 1, ...folder.subFolders);
+ }
+ }
+
+ subFolders.sort((a, b) => a.compareSortKeys(b));
+
+ for (let folder of subFolders) {
+ if (typeof filterFunction == "function" && !filterFunction(folder)) {
+ continue;
+ }
+ let folderRow = folderPane._createFolderRow(modeName, folder);
+ this._addSubFolders(folder, folderRow, modeName, filterFunction);
+ parentRow.childList.appendChild(folderRow);
+ }
+ },
+
+ /**
+ * Get the first row representing a folder, even if it is hidden.
+ *
+ * @param {nsIMsgFolder|string} folderOrURI - The folder to find, or its URI.
+ * @param {string?} modeName - If given, only look in the folders for this
+ * mode, otherwise look in the whole tree.
+ * @returns {FolderTreeRow}
+ */
+ getRowForFolder(folderOrURI, modeName) {
+ if (folderOrURI instanceof Ci.nsIMsgFolder) {
+ folderOrURI = folderOrURI.URI;
+ }
+
+ let modeNames = modeName ? [modeName] : this.activeModes;
+ for (let name of modeNames) {
+ let id = FolderTreeRow.makeRowID(name, folderOrURI);
+ // Look in the mode's container. The container may or may not be
+ // attached to the document at this point.
+ let row = this._modes[name].containerList.querySelector(
+ `#${CSS.escape(id)}`
+ );
+ if (row) {
+ return row;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Loop through all currently active modes and call the required function if
+ * it exists.
+ *
+ * @param {string} functionName - The name of the function to call.
+ * @param {...any} args - The list of arguments to pass to the function.
+ */
+ _forAllActiveModes(functionName, ...args) {
+ for (let mode of Object.values(this._modes)) {
+ if (!mode.active || typeof mode[functionName] != "function") {
+ continue;
+ }
+ try {
+ mode[functionName](...args);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ },
+
+ /**
+ * We deliberately hide the [Gmail] (or [Google Mail] in some cases) folder
+ * from the folder tree. This function determines if a folder is that folder.
+ *
+ * @param {nsIMsgFolder} folder
+ * @returns {boolean}
+ */
+ _isGmailFolder(folder) {
+ return (
+ folder?.parent?.isServer &&
+ folder.server instanceof Ci.nsIImapIncomingServer &&
+ folder.server.isGMailServer &&
+ folder.noSelect
+ );
+ },
+
+ /**
+ * If a folder is the [Gmail] folder, returns the parent folder, otherwise
+ * returns the given folder.
+ *
+ * @param {nsIMsgFolder} folder
+ * @returns {nsIMsgFolder}
+ */
+ _getNonGmailFolder(folder) {
+ return this._isGmailFolder(folder) ? folder.parent : folder;
+ },
+
+ /**
+ * Returns the parent folder of a given folder, or if that is the [Gmail]
+ * folder returns the grandparent of the given folder.
+ *
+ * @param {nsIMsgFolder} folder
+ * @returns {nsIMsgFolder}
+ */
+ _getNonGmailParent(folder) {
+ return this._getNonGmailFolder(folder.parent);
+ },
+
+ /**
+ * Update the folder pane UI and add rows for all newly created folders.
+ *
+ * @param {?nsIMsgFolder} parentFolder - The parent of the newly created
+ * folder.
+ * @param {nsIMsgFolder} childFolder - The newly created folder.
+ */
+ addFolder(parentFolder, childFolder) {
+ if (!parentFolder) {
+ // A server folder was added, so check if we need to update actions.
+ this.updateWidgets();
+ }
+
+ if (this._isGmailFolder(childFolder)) {
+ return;
+ }
+
+ parentFolder = this._getNonGmailFolder(parentFolder);
+ this._forAllActiveModes("addFolder", parentFolder, childFolder);
+ },
+
+ /**
+ * Update the folder pane UI and remove rows for all removed folders.
+ *
+ * @param {?nsIMsgFolder} parentFolder - The parent of the removed folder.
+ * @param {nsIMsgFolder} childFolder - The removed folder.
+ */
+ removeFolder(parentFolder, childFolder) {
+ if (!parentFolder) {
+ // A server folder was removed, so check if we need to update actions.
+ this.updateWidgets();
+ }
+
+ parentFolder = this._getNonGmailFolder(parentFolder);
+ this._forAllActiveModes("removeFolder", parentFolder, childFolder);
+ },
+
+ /**
+ * Update the list of folders if the current mode rely on specific flags.
+ *
+ * @param {nsIMsgFolder} item - The target folder.
+ * @param {nsMsgFolderFlags} oldValue - The old flag value.
+ * @param {nsMsgFolderFlags} newValue - The updated flag value.
+ */
+ changeFolderFlag(item, oldValue, newValue) {
+ this._forAllActiveModes("changeFolderFlag", item, oldValue, newValue);
+ this._changeRows(item, row => row.setFolderTypeFromFolder(item));
+ },
+
+ /**
+ * Update the list of folders to reflect current properties.
+ *
+ * @param {nsIMsgFolder} item - The folder whose data to use.
+ */
+ updateFolderProperties(item) {
+ this._forAllActiveModes("updateFolderProperties", item);
+ this._changeRows(item, row => row.setFolderPropertiesFromFolder(item));
+ },
+
+ /**
+ * @callback folderRowChangeCallback
+ * @param {FolderTreeRow} row
+ */
+ /**
+ * Perform a function on all rows representing a folder.
+ *
+ * @param {nsIMsgFolder|string} folderOrURI - The folder to change, or its URI.
+ * @param {folderRowChangeCallback} callback
+ */
+ _changeRows(folderOrURI, callback) {
+ if (folderOrURI instanceof Ci.nsIMsgFolder) {
+ folderOrURI = folderOrURI.URI;
+ }
+ for (let row of folderTree.querySelectorAll("li")) {
+ if (row.uri == folderOrURI) {
+ callback(row);
+ }
+ }
+ },
+
+ /**
+ * Get the folder from the URI by looping through the list of folders and
+ * finding a matching URI.
+ *
+ * @param {string} uri
+ * @returns {?FolderTreeRow}
+ */
+ getFolderFromUri(uri) {
+ for (let folder of folderTree.querySelectorAll("li")) {
+ if (folder.uri == uri) {
+ return folder;
+ }
+ }
+ return [...folderTree.querySelectorAll("li")]?.find(f => f.uri == uri);
+ },
+
+ /**
+ * Called when a folder's new messages state changes.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {boolean} hasNewMessages
+ */
+ changeNewMessages(folder, hasNewMessages) {
+ this._changeRows(folder, row => {
+ // Find the nearest visible ancestor and update it.
+ let collapsedAncestor = row.parentElement?.closest("li.collapsed");
+ while (collapsedAncestor) {
+ const next = collapsedAncestor.parentElement?.closest("li.collapsed");
+ if (!next) {
+ collapsedAncestor.updateNewMessages(hasNewMessages);
+ break;
+ }
+ collapsedAncestor = next;
+ }
+
+ // Update the row itself.
+ row.updateNewMessages(hasNewMessages);
+ });
+ },
+
+ /**
+ * Called when a folder's unread count changes, to update the UI.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {integer} newValue
+ */
+ changeUnreadCount(folder, newValue) {
+ this._changeRows(folder, row => {
+ // Find the nearest visible ancestor and update it.
+ let collapsedAncestor = row.parentElement?.closest("li.collapsed");
+ while (collapsedAncestor) {
+ const next = collapsedAncestor.parentElement?.closest("li.collapsed");
+ if (!next) {
+ collapsedAncestor.updateUnreadMessageCount();
+ break;
+ }
+ collapsedAncestor = next;
+ }
+
+ // Update the row itself.
+ row.updateUnreadMessageCount();
+ });
+
+ if (this._modes.unread.active && !folder.server.hidden) {
+ this._modes.unread.changeUnreadCount(folder, newValue);
+ }
+ },
+
+ /**
+ * Called when a folder's total count changes, to update the UI.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {integer} newValue
+ */
+ changeTotalCount(folder, newValue) {
+ this._changeRows(folder, row => {
+ // Find the nearest visible ancestor and update it.
+ let collapsedAncestor = row.parentElement?.closest("li.collapsed");
+ while (collapsedAncestor) {
+ const next = collapsedAncestor.parentElement?.closest("li.collapsed");
+ if (!next) {
+ collapsedAncestor.updateTotalMessageCount();
+ break;
+ }
+ collapsedAncestor = next;
+ }
+
+ // Update the row itself.
+ row.updateTotalMessageCount();
+ });
+ },
+
+ /**
+ * Called when a server's `prettyName` changes, to update the UI.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {string} name
+ */
+ changeServerName(folder, name) {
+ for (let row of folderTree.querySelectorAll(
+ `li[data-server-key="${folder.server.key}"]`
+ )) {
+ row.setServerName(name);
+ }
+ },
+
+ /**
+ * Update the UI widget to reflect the real folder size when the "FolderSize"
+ * property changes.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ changeFolderSize(folder) {
+ if (folderPane.isItemVisible("folderPaneFolderSize")) {
+ this._changeRows(folder, row => row.updateSizeCount(false, folder));
+ }
+ },
+
+ _onSelect(event) {
+ const isSynthetic = gViewWrapper?.isSynthetic;
+ threadPane.saveSelection();
+ threadPane.hideIgnoredMessageNotification();
+ if (!isSynthetic) {
+ // Don't clear the message pane for synthetic views, as a message may have
+ // already been selected in restoreState().
+ messagePane.clearAll();
+ }
+
+ let uri = folderTree.rows[folderTree.selectedIndex]?.uri;
+ if (!uri) {
+ gFolder = null;
+ return;
+ }
+ gFolder = MailServices.folderLookup.getFolderForURL(uri);
+
+ // Bail out if this is synthetic view, such as a gloda search.
+ if (isSynthetic) {
+ return;
+ }
+
+ document.head.querySelector(`link[rel="icon"]`).href =
+ FolderUtils.getFolderIcon(gFolder);
+
+ // Clean up any existing view wrapper. This will invalidate the thread tree.
+ gViewWrapper?.close();
+
+ if (gFolder.isServer) {
+ document.title = gFolder.server.prettyName;
+ gViewWrapper = gDBView = threadTree.view = null;
+
+ MailE10SUtils.loadURI(
+ accountCentralBrowser,
+ `chrome://messenger/content/msgAccountCentral.xhtml?folderURI=${encodeURIComponent(
+ gFolder.URI
+ )}`
+ );
+ document.body.classList.add("account-central");
+ accountCentralBrowser.hidden = false;
+ } else {
+ document.title = `${gFolder.name} - ${gFolder.server.prettyName}`;
+ document.body.classList.remove("account-central");
+ accountCentralBrowser.hidden = true;
+
+ quickFilterBar.activeElement = null;
+ threadPane.restoreColumns();
+
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+
+ threadPane.scrollToNewMessage =
+ !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) &&
+ gFolder.hasNewMessages &&
+ Services.prefs.getBoolPref("mailnews.scroll_to_new_message");
+ if (threadPane.scrollToNewMessage) {
+ threadPane.forgetSelection(uri);
+ }
+
+ gViewWrapper.open(gFolder);
+
+ // At this point `dbViewWrapperListener.onCreatedView` gets called,
+ // setting up gDBView and scrolling threadTree to the right end.
+
+ threadPane.updateListRole(
+ !gViewWrapper?.showThreaded && !gViewWrapper?.showGroupedBySort
+ );
+ threadPane.restoreSortIndicator();
+ threadPaneHeader.onFolderSelected();
+ }
+
+ this._updateStatusQuota();
+
+ window.dispatchEvent(
+ new CustomEvent("folderURIChanged", { bubbles: true, detail: uri })
+ );
+ },
+
+ /**
+ * Update the quotaPanel to reflect current folder quota status.
+ */
+ _updateStatusQuota() {
+ if (top.window.document.getElementById("status-bar").hidden) {
+ return;
+ }
+ const quotaPanel = top.window.document.getElementById("quotaPanel");
+ if (!(gFolder && gFolder instanceof Ci.nsIMsgImapMailFolder)) {
+ quotaPanel.hidden = true;
+ return;
+ }
+
+ let tabListener = event => {
+ // Hide the pane if the new tab ain't us.
+ quotaPanel.hidden =
+ top.window.document.getElementById("tabmail").currentAbout3Pane ==
+ this.window;
+ };
+ top.window.document.removeEventListener("TabSelect", tabListener);
+
+ // For display on main window panel only include quota names containing
+ // "STORAGE" or "MESSAGE". This will exclude unusual quota names containing
+ // items like "MAILBOX" and "LEVEL" from the panel bargraph. All quota names
+ // will still appear on the folder properties quota window.
+ // Note: Quota name is typically something like "User Quota / STORAGE".
+ let folderQuota = gFolder
+ .getQuota()
+ .filter(
+ quota =>
+ quota.name.toUpperCase().includes("STORAGE") ||
+ quota.name.toUpperCase().includes("MESSAGE")
+ );
+ if (!folderQuota.length) {
+ quotaPanel.hidden = true;
+ return;
+ }
+ // If folderQuota not empty, find the index of the element with highest
+ // percent usage and determine if it is above the panel display threshold.
+ let quotaUsagePercentage = q =>
+ Number((100n * BigInt(q.usage)) / BigInt(q.limit));
+ let highest = folderQuota.reduce((acc, current) =>
+ quotaUsagePercentage(acc) > quotaUsagePercentage(current) ? acc : current
+ );
+ let percent = quotaUsagePercentage(highest);
+ if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.show")
+ ) {
+ quotaPanel.hidden = true;
+ } else {
+ quotaPanel.hidden = false;
+ top.window.document.addEventListener("TabSelect", tabListener);
+
+ top.window.document
+ .getElementById("quotaMeter")
+ .setAttribute("value", percent);
+
+ let usage;
+ let limit;
+ if (/STORAGE/i.test(highest.name)) {
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ usage = messenger.formatFileSize(highest.usage * 1024);
+ limit = messenger.formatFileSize(highest.limit * 1024);
+ } else {
+ usage = highest.usage;
+ limit = highest.limit;
+ }
+
+ top.window.document.getElementById("quotaLabel").value = `${percent}%`;
+ top.window.document.l10n.setAttributes(
+ top.window.document.getElementById("quotaLabel"),
+ "quota-panel-percent-used",
+ { percent, usage, limit }
+ );
+ if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.warning")
+ ) {
+ quotaPanel.classList.remove("alert-warning", "alert-critical");
+ } else if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.critical")
+ ) {
+ quotaPanel.classList.remove("alert-critical");
+ quotaPanel.classList.add("alert-warning");
+ } else {
+ quotaPanel.classList.remove("alert-warning");
+ quotaPanel.classList.add("alert-critical");
+ }
+ }
+ },
+
+ _onMiddleClick(event) {
+ if (
+ event.target.closest(".mode-container") ||
+ folderTree.selectedIndex == -1
+ ) {
+ return;
+ }
+ const row = event.target.closest("li");
+ if (!row) {
+ return;
+ }
+
+ top.MsgOpenNewTabForFolders(
+ [MailServices.folderLookup.getFolderForURL(row.uri)],
+ {
+ event,
+ folderPaneVisible: !paneLayout.folderPaneSplitter.isCollapsed,
+ messagePaneVisible: !paneLayout.messagePaneSplitter.isCollapsed,
+ }
+ );
+ },
+
+ _onContextMenu(event) {
+ if (folderTree.selectedIndex == -1) {
+ return;
+ }
+
+ let popup = document.getElementById("folderPaneContext");
+
+ if (event.button == 2) {
+ // Mouse
+ if (event.target.closest(".mode-container")) {
+ return;
+ }
+ let row = event.target.closest("li");
+ if (!row) {
+ return;
+ }
+ if (row.uri != gFolder.URI) {
+ // The right-clicked-on folder is not `gFolder`. Tell the context menu
+ // to use it instead. This override lasts until the context menu fires
+ // a "popuphidden" event.
+ folderPaneContextMenu.setOverrideFolder(
+ MailServices.folderLookup.getFolderForURL(row.uri)
+ );
+ row.classList.add("context-menu-target");
+ }
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ // Keyboard
+ let row = folderTree.getRowAtIndex(folderTree.selectedIndex);
+ popup.openPopup(row, "after_end", 0, 0, true);
+ }
+
+ event.preventDefault();
+ },
+
+ _onCollapsed({ target }) {
+ if (target.uri) {
+ let mode = target.closest("[data-mode]").dataset.mode;
+ FolderTreeProperties.setIsExpanded(target.uri, mode, false);
+ }
+ target.updateUnreadMessageCount();
+ target.updateTotalMessageCount();
+ target.updateNewMessages();
+ },
+
+ _onExpanded({ target }) {
+ if (target.uri) {
+ let mode = target.closest("[data-mode]").dataset.mode;
+ FolderTreeProperties.setIsExpanded(target.uri, mode, true);
+ }
+
+ const updateRecursively = row => {
+ row.updateUnreadMessageCount();
+ row.updateTotalMessageCount();
+ row.updateNewMessages();
+ if (row.classList.contains("collapsed")) {
+ return;
+ }
+ for (const child of row.childList.children) {
+ updateRecursively(child);
+ }
+ };
+
+ updateRecursively(target);
+
+ // Get server type. IMAP is the only server type that does folder discovery.
+ let folder = MailServices.folderLookup.getFolderForURL(target.uri);
+ if (folder.server.type == "imap") {
+ if (folder.isServer) {
+ folder.server.performExpand(top.msgWindow);
+ } else {
+ folder.QueryInterface(Ci.nsIMsgImapMailFolder);
+ folder.performExpand(top.msgWindow);
+ }
+ }
+ },
+
+ _onDragStart(event) {
+ let row = event.target.closest(`li[is="folder-tree-row"]`);
+ if (!row) {
+ event.preventDefault();
+ return;
+ }
+
+ let folder = MailServices.folderLookup.getFolderForURL(row.uri);
+ if (!folder || folder.isServer) {
+ event.preventDefault();
+ return;
+ }
+ if (folder.server.type == "nntp") {
+ event.dataTransfer.mozSetDataAt("text/x-moz-newsfolder", folder, 0);
+ event.dataTransfer.effectAllowed = "move";
+ return;
+ }
+
+ event.dataTransfer.mozSetDataAt("text/x-moz-folder", folder, 0);
+ event.dataTransfer.effectAllowed = "copyMove";
+ },
+
+ _onDragOver(event) {
+ const copyKey =
+ AppConstants.platform == "macosx" ? event.altKey : event.ctrlKey;
+
+ event.dataTransfer.dropEffect = "none";
+ event.preventDefault();
+
+ let row = event.target.closest("li");
+ this._timedExpand(row);
+ if (!row) {
+ return;
+ }
+
+ let targetFolder = MailServices.folderLookup.getFolderForURL(row.uri);
+ if (!targetFolder) {
+ return;
+ }
+
+ let types = Array.from(event.dataTransfer.mozTypesAt(0));
+ if (types.includes("text/x-moz-message")) {
+ if (targetFolder.isServer || !targetFolder.canFileMessages) {
+ return;
+ }
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let msgHdr = top.messenger.msgHdrFromURI(
+ event.dataTransfer.mozGetDataAt("text/x-moz-message", i)
+ );
+ // Don't allow drop onto original folder.
+ if (msgHdr.folder == targetFolder) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = copyKey ? "copy" : "move";
+ } else if (types.includes("text/x-moz-folder")) {
+ // If cannot create subfolders then don't allow drop here.
+ if (!targetFolder.canCreateSubfolders) {
+ return;
+ }
+
+ let sourceFolder = event.dataTransfer
+ .mozGetDataAt("text/x-moz-folder", 0)
+ .QueryInterface(Ci.nsIMsgFolder);
+
+ // Don't allow to drop on itself.
+ if (targetFolder == sourceFolder) {
+ return;
+ }
+ // Don't copy within same server.
+ if (sourceFolder.server == targetFolder.server && copyKey) {
+ return;
+ }
+ // Don't allow immediate child to be dropped onto its parent.
+ if (targetFolder == sourceFolder.parent) {
+ return;
+ }
+ // Don't allow dragging of virtual folders across accounts.
+ if (
+ sourceFolder.getFlag(Ci.nsMsgFolderFlags.Virtual) &&
+ sourceFolder.server != targetFolder.server
+ ) {
+ return;
+ }
+ // Don't allow parent to be dropped on its ancestors.
+ if (sourceFolder.isAncestorOf(targetFolder)) {
+ return;
+ }
+ // If there is a folder that can't be renamed, don't allow it to be
+ // dropped if it is not to "Local Folders" or is to the same account.
+ if (
+ !sourceFolder.canRename &&
+ (targetFolder.server.type != "none" ||
+ sourceFolder.server == targetFolder.server)
+ ) {
+ return;
+ }
+ event.dataTransfer.dropEffect = copyKey ? "copy" : "move";
+ } else if (types.includes("application/x-moz-file")) {
+ if (targetFolder.isServer || !targetFolder.canFileMessages) {
+ return;
+ }
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let extFile = event.dataTransfer
+ .mozGetDataAt("application/x-moz-file", i)
+ .QueryInterface(Ci.nsIFile);
+ if (!extFile.isFile() || !/\.eml$/i.test(extFile.leafName)) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = "copy";
+ } else if (types.includes("text/x-moz-newsfolder")) {
+ let folder = event.dataTransfer
+ .mozGetDataAt("text/x-moz-newsfolder", 0)
+ .QueryInterface(Ci.nsIMsgFolder);
+ if (
+ targetFolder.isServer ||
+ targetFolder.server.type != "nntp" ||
+ folder == targetFolder ||
+ folder.server != targetFolder.server
+ ) {
+ return;
+ }
+ event.dataTransfer.dropEffect = "move";
+ } else if (
+ types.includes("text/x-moz-url-data") ||
+ types.includes("text/x-moz-url")
+ ) {
+ // Allow subscribing to feeds by dragging an url to a feed account.
+ if (
+ targetFolder.server.type == "rss" &&
+ !targetFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true) &&
+ event.dataTransfer.items.length == 1 &&
+ FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer)
+ ) {
+ return;
+ }
+ event.dataTransfer.dropEffect = "link";
+ } else {
+ return;
+ }
+
+ this._clearDropTarget();
+ row.classList.add("drop-target");
+ },
+
+ /**
+ * Set a timer to expand `row` in 500ms. If called again before the timer
+ * expires and with a different row, the timer is cleared and a new one
+ * started. If `row` is falsy or isn't collapsed the timer is cleared.
+ *
+ * @param {HTMLLIElement?} row
+ */
+ _timedExpand(row) {
+ if (this._expandRow == row) {
+ return;
+ }
+ if (this._expandTimer) {
+ clearTimeout(this._expandTimer);
+ }
+ if (!row?.classList.contains("collapsed")) {
+ return;
+ }
+ this._expandRow = row;
+ this._expandTimer = setTimeout(() => {
+ folderTree.expandRow(this._expandRow);
+ delete this._expandRow;
+ delete this._expandTimer;
+ }, 1000);
+ },
+
+ _clearDropTarget() {
+ folderTree.querySelector(".drop-target")?.classList.remove("drop-target");
+ },
+
+ _onDrop(event) {
+ this._timedExpand();
+ this._clearDropTarget();
+ if (event.dataTransfer.dropEffect == "none") {
+ // Somehow this is possible. It should not be possible.
+ return;
+ }
+
+ let row = event.target.closest("li");
+ if (!row) {
+ return;
+ }
+
+ let targetFolder = MailServices.folderLookup.getFolderForURL(row.uri);
+
+ let types = Array.from(event.dataTransfer.mozTypesAt(0));
+ if (types.includes("text/x-moz-message")) {
+ let array = [];
+ let sourceFolder;
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let msgHdr = top.messenger.msgHdrFromURI(
+ event.dataTransfer.mozGetDataAt("text/x-moz-message", i)
+ );
+ if (!i) {
+ sourceFolder = msgHdr.folder;
+ }
+ array.push(msgHdr);
+ }
+ let isMove = event.dataTransfer.dropEffect == "move";
+ let isNews = sourceFolder.flags & Ci.nsMsgFolderFlags.Newsgroup;
+ if (!sourceFolder.canDeleteMessages || isNews) {
+ isMove = false;
+ }
+
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ targetFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove);
+ // ### ugh, so this won't work with cross-folder views. We would
+ // really need to partition the messages by folder.
+ if (isMove) {
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ }
+ MailServices.copy.copyMessages(
+ sourceFolder,
+ array,
+ targetFolder,
+ isMove,
+ null,
+ top.msgWindow,
+ true
+ );
+ } else if (types.includes("text/x-moz-folder")) {
+ let sourceFolder = event.dataTransfer
+ .mozGetDataAt("text/x-moz-folder", 0)
+ .QueryInterface(Ci.nsIMsgFolder);
+ let isMove = event.dataTransfer.dropEffect == "move";
+ isMove = folderPaneContextMenu.transferFolder(
+ isMove,
+ sourceFolder,
+ targetFolder
+ );
+ // Save in prefs the target folder URI and if this was a move or copy.
+ // This is to fill in the next folder or message context menu item
+ // "Move|Copy to <TargetFolderName> Again".
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ targetFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove);
+ } else if (types.includes("application/x-moz-file")) {
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let extFile = event.dataTransfer
+ .mozGetDataAt("application/x-moz-file", i)
+ .QueryInterface(Ci.nsIFile);
+ if (extFile.isFile() && /\.eml$/i.test(extFile.leafName)) {
+ MailServices.copy.copyFileMessage(
+ extFile,
+ targetFolder,
+ null,
+ false,
+ 1,
+ "",
+ null,
+ top.msgWindow
+ );
+ }
+ }
+ } else if (types.includes("text/x-moz-newsfolder")) {
+ let folder = event.dataTransfer
+ .mozGetDataAt("text/x-moz-newsfolder", 0)
+ .QueryInterface(Ci.nsIMsgFolder);
+
+ let mode = row.closest("li[data-mode]").dataset.mode;
+ let newsRoot = targetFolder.rootFolder.QueryInterface(
+ Ci.nsIMsgNewsFolder
+ );
+ newsRoot.reorderGroup(folder, targetFolder);
+ setTimeout(
+ () => (folderTree.selectedRow = this.getRowForFolder(folder, mode))
+ );
+ } else if (
+ types.includes("text/x-moz-url-data") ||
+ types.includes("text/x-moz-url")
+ ) {
+ // This is a potential rss feed. A link image as well as link text url
+ // should be handled; try to extract a url from non moz apps as well.
+ let feedURI = FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer);
+ FeedUtils.subscribeToFeed(feedURI.spec, targetFolder);
+ }
+
+ event.preventDefault();
+ },
+
+ /**
+ * Opens the dialog to create a new sub-folder, and creates it if the user
+ * accepts.
+ *
+ * @param {?nsIMsgFolder} aParent - The parent for the new subfolder.
+ */
+ newFolder(aParent) {
+ let folder = aParent;
+
+ // Make sure we actually can create subfolders.
+ if (!folder?.canCreateSubfolders) {
+ // Check if we can create them at the root, otherwise use the default
+ // account as root folder.
+ let rootMsgFolder = folder.server.rootMsgFolder;
+ folder = rootMsgFolder.canCreateSubfolders
+ ? rootMsgFolder
+ : top.GetDefaultAccountRootFolder();
+ }
+
+ if (!folder) {
+ return;
+ }
+
+ let dualUseFolders = true;
+ if (folder.server instanceof Ci.nsIImapIncomingServer) {
+ dualUseFolders = folder.server.dualUseFolders;
+ }
+
+ function newFolderCallback(aName, aFolder) {
+ // createSubfolder can throw an exception, causing the newFolder dialog
+ // to not close and wait for another input.
+ // TODO: Rewrite this logic and also move the opening of alert dialogs from
+ // nsMsgLocalMailFolder::CreateSubfolderInternal to here (bug 831190#c16).
+ if (!aName) {
+ return;
+ }
+ aFolder.createSubfolder(aName, top.msgWindow);
+ // Don't call the rebuildAfterChange() here as we'll need to wait for the
+ // new folder to be properly created before rebuilding the tree.
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/newFolderDialog.xhtml",
+ "",
+ "chrome,modal,resizable=no,centerscreen",
+ { folder, dualUseFolders, okCallback: newFolderCallback }
+ );
+ },
+
+ /**
+ * Opens the dialog to edit the properties for a folder
+ *
+ * @param {nsIMsgFolder} [folder] - Folder to edit, if not the selected one.
+ * @param {string} [tabID] - Id of initial tab to select in the folder
+ * properties dialog.
+ */
+ editFolder(folder = gFolder, tabID) {
+ // If this is actually a server, send it off to that controller
+ if (folder.isServer) {
+ top.MsgAccountManager(null, folder.server);
+ return;
+ }
+
+ if (folder.getFlag(Ci.nsMsgFolderFlags.Virtual)) {
+ this.editVirtualFolder(folder);
+ return;
+ }
+ let title = messengerBundle.GetStringFromName("folderProperties");
+
+ function editFolderCallback(newName, oldName) {
+ if (newName != oldName) {
+ folder.rename(newName, top.msgWindow);
+ }
+ }
+
+ async function rebuildSummary() {
+ if (folder.locked) {
+ folder.throwAlertMsg("operationFailedFolderBusy", top.msgWindow);
+ return;
+ }
+ if (folder.supportsOffline) {
+ // Remove the offline store, if any.
+ await IOUtils.remove(folder.filePath.path, { recursive: true }).catch(
+ console.error
+ );
+ }
+
+ // We may be rebuilding a folder that is not the displayed one.
+ // TODO: Close any open views of this folder.
+
+ // Send a notification that we are triggering a database rebuild.
+ MailServices.mfn.notifyFolderReindexTriggered(folder);
+
+ folder.msgDatabase.summaryValid = false;
+
+ const msgDB = folder.msgDatabase;
+ msgDB.summaryValid = false;
+ try {
+ folder.closeAndBackupFolderDB("");
+ } catch (e) {
+ // In a failure, proceed anyway since we're dealing with problems
+ folder.ForceDBClosed();
+ }
+ if (gFolder == folder) {
+ gViewWrapper?.close();
+ folder.updateFolder(top.msgWindow);
+ folderTree.dispatchEvent(new CustomEvent("select"));
+ } else {
+ folder.updateFolder(top.msgWindow);
+ }
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/folderProps.xhtml",
+ "",
+ "chrome,modal,centerscreen",
+ {
+ folder,
+ serverType: folder.server.type,
+ msgWindow: top.msgWindow,
+ title,
+ okCallback: editFolderCallback,
+ tabID,
+ name: folder.prettyName,
+ rebuildSummaryCallback: rebuildSummary,
+ }
+ );
+ },
+
+ /**
+ * Opens the dialog to rename a particular folder, and does the renaming if
+ * the user clicks OK in that dialog
+ *
+ * @param [aFolder] - The folder to rename, if different than the currently
+ * selected one.
+ */
+ renameFolder(aFolder) {
+ let folder = aFolder;
+
+ function renameCallback(aName, aUri) {
+ if (aUri != folder.URI) {
+ console.error("got back a different folder to rename!");
+ }
+
+ // Actually do the rename.
+ folder.rename(aName, top.msgWindow);
+ }
+ window.openDialog(
+ "chrome://messenger/content/renameFolderDialog.xhtml",
+ "",
+ "chrome,modal,centerscreen",
+ {
+ preselectedURI: folder.URI,
+ okCallback: renameCallback,
+ name: folder.prettyName,
+ }
+ );
+ },
+
+ /**
+ * Deletes a folder from its parent. Also handles unsubscribe from newsgroups
+ * if the selected folder/s happen to be nntp.
+ *
+ * @param [folder] - The folder to delete, if not the selected one.
+ */
+ deleteFolder(folder) {
+ // For newsgroups, "delete" means "unsubscribe".
+ if (
+ folder.server.type == "nntp" &&
+ !folder.getFlag(Ci.nsMsgFolderFlags.Virtual)
+ ) {
+ top.MsgUnsubscribe([folder]);
+ return;
+ }
+
+ const canDelete = folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk, false)
+ ? FolderUtils.canRenameDeleteJunkMail(folder.URI)
+ : folder.deletable;
+
+ if (!canDelete) {
+ throw new Error("Can't delete folder: " + folder.name);
+ }
+
+ if (folder.getFlag(Ci.nsMsgFolderFlags.Virtual)) {
+ let confirmation = messengerBundle.GetStringFromName(
+ "confirmSavedSearchDeleteMessage"
+ );
+ let title = messengerBundle.GetStringFromName("confirmSavedSearchTitle");
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ confirmation,
+ Services.prompt.STD_YES_NO_BUTTONS +
+ Services.prompt.BUTTON_POS_1_DEFAULT,
+ "",
+ "",
+ "",
+ "",
+ {}
+ ) != 0
+ ) {
+ /* the yes button is in position 0 */
+ return;
+ }
+ }
+
+ try {
+ folder.deleteSelf(top.msgWindow);
+ } catch (ex) {
+ // Ignore known errors from canceled warning dialogs.
+ const NS_MSG_ERROR_COPY_FOLDER_ABORTED = 0x8055001a;
+ if (ex.result != NS_MSG_ERROR_COPY_FOLDER_ABORTED) {
+ throw ex;
+ }
+ }
+ },
+
+ /**
+ * Prompts the user to confirm and empties the trash for the selected folder.
+ * The folder and its children are only emptied if it has the proper Trash flag.
+ *
+ * @param [aFolder] - The trash folder to empty. If unspecified or not a trash
+ * folder, the currently selected server's trash folder is used.
+ */
+ emptyTrash(aFolder) {
+ let folder = aFolder;
+ if (!folder.getFlag(Ci.nsMsgFolderFlags.Trash)) {
+ folder = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ }
+ if (!folder) {
+ return;
+ }
+
+ if (!this._checkConfirmationPrompt("emptyTrash", folder)) {
+ return;
+ }
+
+ // Check if this is a top-level smart folder. If so, we're going
+ // to empty all the trash folders.
+ if (FolderUtils.isSmartVirtualFolder(folder)) {
+ for (let server of MailServices.accounts.allServers) {
+ for (let trash of server.rootFolder.getFoldersWithFlags(
+ Ci.nsMsgFolderFlags.Trash
+ )) {
+ trash.emptyTrash(null);
+ }
+ }
+ } else {
+ folder.emptyTrash(null);
+ }
+ },
+
+ /**
+ * Deletes everything (folders and messages) in the selected folder.
+ * The folder is only emptied if it has the proper Junk flag.
+ *
+ * @param {nsIMsgFolder} folder - The folder to empty.
+ * @param {boolean} [prompt=true] - If the user should be prompted.
+ */
+ emptyJunk(folder, prompt = true) {
+ if (!folder || !folder.getFlag(Ci.nsMsgFolderFlags.Junk)) {
+ return;
+ }
+
+ if (prompt && !this._checkConfirmationPrompt("emptyJunk", folder)) {
+ return;
+ }
+
+ if (FolderUtils.isSmartVirtualFolder(folder)) {
+ // This is the unified junk folder.
+ let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder);
+ for (let searchFolder of wrappedFolder.searchFolders) {
+ this.emptyJunk(searchFolder, false);
+ }
+ return;
+ }
+
+ // Delete any subfolders this folder might have
+ for (let subFolder of folder.subFolders) {
+ folder.propagateDelete(subFolder, true);
+ }
+
+ let messages = [...folder.messages];
+ if (!messages.length) {
+ return;
+ }
+
+ // Now delete the messages
+ folder.deleteMessages(messages, top.msgWindow, true, false, null, false);
+ },
+
+ /**
+ * Compacts the given folder.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ compactFolder(folder) {
+ // Can't compact folders that have just been compacted.
+ if (folder.server.type != "imap" && !folder.expungedBytes) {
+ return;
+ }
+
+ folder.compact(null, top.msgWindow);
+ },
+
+ /**
+ * Compacts all folders for the account that the given folder belongs to.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ compactAllFoldersForAccount(folder) {
+ folder.rootFolder.compactAll(null, top.msgWindow);
+ },
+
+ /**
+ * Opens the dialog to create a new virtual folder
+ *
+ * @param aName - The default name for the new folder.
+ * @param aSearchTerms - The search terms associated with the folder.
+ * @param aParent - The folder to run the search terms on.
+ */
+ newVirtualFolder(aName, aSearchTerms, aParent) {
+ let folder = aParent || top.GetDefaultAccountRootFolder();
+ if (!folder) {
+ return;
+ }
+
+ let name = folder.prettyName;
+ if (aName) {
+ name += "-" + aName;
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ "",
+ "chrome,modal,centerscreen,resizable=yes",
+ {
+ folder,
+ searchTerms: aSearchTerms,
+ newFolderName: name,
+ }
+ );
+ },
+
+ editVirtualFolder(aFolder) {
+ let folder = aFolder;
+
+ function editVirtualCallback() {
+ if (gFolder == folder) {
+ folderTree.dispatchEvent(new CustomEvent("select"));
+ }
+ }
+ window.openDialog(
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ "",
+ "chrome,modal,centerscreen,resizable=yes",
+ {
+ folder,
+ editExistingFolder: true,
+ onOKCallback: editVirtualCallback,
+ msgWindow: top.msgWindow,
+ }
+ );
+ },
+
+ /**
+ * Prompts for confirmation, if the user hasn't already chosen the "don't ask
+ * again" option.
+ *
+ * @param aCommand - The command to prompt for.
+ * @param aFolder - The folder for which the confirmation is requested.
+ */
+ _checkConfirmationPrompt(aCommand, aFolder) {
+ // If no folder was specified, reject the operation.
+ if (!aFolder) {
+ return false;
+ }
+
+ let showPrompt = !Services.prefs.getBoolPref(
+ "mailnews." + aCommand + ".dontAskAgain",
+ false
+ );
+
+ if (showPrompt) {
+ let checkbox = { value: false };
+ let title = messengerBundle.formatStringFromName(
+ aCommand + "FolderTitle",
+ [aFolder.prettyName]
+ );
+ let msg = messengerBundle.GetStringFromName(aCommand + "FolderMessage");
+ let ok =
+ Services.prompt.confirmEx(
+ window,
+ title,
+ msg,
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ messengerBundle.GetStringFromName(aCommand + "DontAsk"),
+ checkbox
+ ) == 0;
+ if (checkbox.value) {
+ Services.prefs.setBoolPref(
+ "mailnews." + aCommand + ".dontAskAgain",
+ true
+ );
+ }
+ if (!ok) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Update those UI elements that rely on the presence of a server to function.
+ */
+ updateWidgets() {
+ this._updateGetMessagesWidgets();
+ this._updateWriteMessageWidgets();
+ },
+
+ _updateGetMessagesWidgets() {
+ const canGetMessages = MailServices.accounts.allServers.some(
+ s => s.type != "none"
+ );
+ document.getElementById("folderPaneGetMessages").disabled = !canGetMessages;
+ },
+
+ _updateWriteMessageWidgets() {
+ const canWriteMessages = MailServices.accounts.allIdentities.length;
+ document.getElementById("folderPaneWriteMessage").disabled =
+ !canWriteMessages;
+ },
+
+ isFolderPaneGetMsgsBtnHidden() {
+ return this.isItemHidden("folderPaneGetMessages");
+ },
+
+ isFolderPaneNewMsgBtnHidden() {
+ return this.isItemHidden("folderPaneWriteMessage");
+ },
+
+ isFolderPaneHeaderHidden() {
+ return this.isItemHidden("folderPaneHeaderBar");
+ },
+
+ isItemHidden(item) {
+ return Services.xulStore.getValue(XULSTORE_URL, item, "hidden") == "true";
+ },
+
+ isItemVisible(item) {
+ return Services.xulStore.getValue(XULSTORE_URL, item, "visible") == "true";
+ },
+
+ /**
+ * Ensure the pane header context menu items are correctly checked.
+ */
+ updateContextMenuCheckedItems() {
+ for (let item of document.querySelectorAll(".folder-pane-option")) {
+ switch (item.id) {
+ case "folderPaneHeaderToggleGetMessages":
+ this.isFolderPaneGetMsgsBtnHidden()
+ ? item.removeAttribute("checked")
+ : item.setAttribute("checked", true);
+ break;
+ case "folderPaneHeaderToggleNewMessage":
+ this.isFolderPaneNewMsgBtnHidden()
+ ? item.removeAttribute("checked")
+ : item.setAttribute("checked", true);
+ break;
+ case "folderPaneHeaderToggleTotalCount":
+ this.isTotalMsgCountVisible()
+ ? item.setAttribute("checked", true)
+ : item.removeAttribute("checked");
+ break;
+ case "folderPaneMoreContextCompactToggle":
+ this.isCompact
+ ? item.setAttribute("checked", true)
+ : item.removeAttribute("checked");
+ this.toggleCompactViewMenuItem();
+ break;
+ case "folderPaneHeaderToggleFolderSize":
+ this.isItemVisible("folderPaneFolderSize")
+ ? item.setAttribute("checked", true)
+ : item.removeAttribute("checked");
+ break;
+ case "folderPaneHeaderToggleLocalFolders":
+ this.isItemHidden("folderPaneLocalFolders")
+ ? item.setAttribute("checked", true)
+ : item.removeAttribute("checked");
+ break;
+ default:
+ item.removeAttribute("checked");
+ break;
+ }
+ }
+ },
+
+ toggleGetMsgsBtn(event) {
+ let show = event.target.hasAttribute("checked");
+ document.getElementById("folderPaneGetMessages").hidden = !show;
+
+ this.updateXULStoreAttribute("folderPaneGetMessages", "hidden", show);
+ },
+
+ toggleNewMsgBtn(event) {
+ let show = event.target.hasAttribute("checked");
+ document.getElementById("folderPaneWriteMessage").hidden = !show;
+
+ this.updateXULStoreAttribute("folderPaneWriteMessage", "hidden", show);
+ },
+
+ toggleHeader(show) {
+ document.getElementById("folderPaneHeaderBar").hidden = !show;
+ this.updateXULStoreAttribute("folderPaneHeaderBar", "hidden", show);
+ },
+
+ updateXULStoreAttribute(element, attribute, value) {
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ element,
+ attribute,
+ value ? "false" : "true"
+ );
+ },
+
+ /**
+ * Ensure the folder rows UI elements reflect the state set by the user.
+ */
+ updateFolderRowUIElements() {
+ this.toggleTotalCountBadge();
+ this.toggleFolderSizes(this.isItemVisible("folderPaneFolderSize"));
+ },
+
+ /**
+ * Check XULStore to see if the total message count badges should be hidden.
+ */
+ isTotalMsgCountVisible() {
+ return this.isItemVisible("totalMsgCount");
+ },
+
+ /**
+ * Toggle the total message count badges and update the XULStore.
+ */
+ toggleTotal(event) {
+ let show = !event.target.hasAttribute("checked");
+ this.updateXULStoreAttribute("totalMsgCount", "visible", show);
+ this.toggleTotalCountBadge();
+ },
+
+ toggleTotalCountBadge() {
+ const isHidden = !this.isTotalMsgCountVisible();
+ for (let row of document.querySelectorAll(`li[is="folder-tree-row"]`)) {
+ row.toggleTotalCountBadgeVisibility(isHidden);
+ }
+ },
+
+ /**
+ * Toggle the folder size option and update the XULStore.
+ */
+ toggleFolderSize(event) {
+ let show = !event.target.hasAttribute("checked");
+ this.updateXULStoreAttribute("folderPaneFolderSize", "visible", show);
+ this.toggleFolderSizes(!show);
+ },
+
+ /**
+ * Toggle the folder size info on each folder.
+ */
+ toggleFolderSizes(visible) {
+ const isHidden = !visible;
+ for (let row of document.querySelectorAll(`li[is="folder-tree-row"]`)) {
+ row.updateSizeCount(isHidden);
+ }
+ },
+
+ /**
+ * Toggle the hiding of the local folders and update the XULStore.
+ */
+ toggleLocalFolders(event) {
+ let isHidden = event.target.hasAttribute("checked");
+ this.updateXULStoreAttribute("folderPaneLocalFolders", "hidden", !isHidden);
+ folderPane.hideLocalFolders = isHidden;
+ },
+
+ /**
+ * Populate the "Get Messages" context menu with all available servers that
+ * we can fetch data for.
+ */
+ updateGetMessagesContextMenu() {
+ const menupopup = document.getElementById("folderPaneGetMessagesContext");
+ while (menupopup.lastElementChild.classList.contains("server")) {
+ menupopup.lastElementChild.remove();
+ }
+
+ // Get all servers in the proper sorted order.
+ const servers = FolderUtils.allAccountsSorted(true)
+ .map(a => a.incomingServer)
+ .filter(s => s.rootFolder.isServer && s.type != "none");
+ for (let server of servers) {
+ const menuitem = document.createXULElement("menuitem");
+ menuitem.classList.add("menuitem-iconic", "server");
+ menuitem.dataset.serverType = server.type;
+ menuitem.dataset.serverSecure = server.isSecure;
+ menuitem.label = server.prettyName;
+ menuitem.addEventListener("command", () =>
+ top.MsgGetMessagesForAccount(server.rootFolder)
+ );
+ menupopup.appendChild(menuitem);
+ }
+ },
+};
+
+/**
+ * Represents a single row in the folder tree. The row can be for a server or
+ * a folder. Use `folderPane._createServerRow` or `folderPane._createFolderRow`
+ * to create rows.
+ */
+class FolderTreeRow extends HTMLLIElement {
+ /**
+ * Used for comparing folder names. This matches the collator used in
+ * `nsMsgDBFolder::createCollationKeyGenerator`.
+ * @type {Intl.Collator}
+ */
+ static nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
+
+ /**
+ * Creates an identifier unique for the given mode name and folder URI.
+ *
+ * @param {string} modeName
+ * @param {string} uri
+ * @returns {string}
+ */
+ static makeRowID(modeName, uri) {
+ return `${modeName}-${btoa(MailStringUtils.stringToByteString(uri))}`;
+ }
+
+ /**
+ * The name of the folder tree mode this row belongs to.
+ * @type {string}
+ */
+ modeName;
+ /**
+ * The URI of the folder represented by this row.
+ * @type {string}
+ */
+ uri;
+ /**
+ * How many times this row is nested. 1 or greater.
+ * @type {integer}
+ */
+ depth;
+ /**
+ * The sort order of this row's associated folder.
+ * @type {integer}
+ */
+ folderSortOrder;
+
+ /** @type {HTMLSpanElement} */
+ nameLabel;
+ /** @type {HTMLImageElement} */
+ icon;
+ /** @type {HTMLSpanElement} */
+ unreadCountLabel;
+ /** @type {HTMLUListElement} */
+ totalCountLabel;
+ /** @type {HTMLSpanElement} */
+ folderSizeLabel;
+ /** @type {HTMLUListElement} */
+ childList;
+
+ constructor() {
+ super();
+ this.setAttribute("is", "folder-tree-row");
+ this.append(folderPane._folderTemplate.content.cloneNode(true));
+ this.nameLabel = this.querySelector(".name");
+ this.icon = this.querySelector(".icon");
+ this.unreadCountLabel = this.querySelector(".unread-count");
+ this.totalCountLabel = this.querySelector(".total-count");
+ this.folderSizeLabel = this.querySelector(".folder-size");
+ this.childList = this.querySelector("ul");
+ }
+
+ connectedCallback() {
+ // Set the correct CSS `--depth` variable based on where this row was
+ // inserted into the tree.
+ let parent = this.parentNode.closest(`li[is="folder-tree-row"]`);
+ this.depth = parent ? parent.depth + 1 : 1;
+ this.childList.style.setProperty("--depth", this.depth);
+ }
+
+ /**
+ * The name to display for this folder or server.
+ *
+ * @type {string}
+ */
+ get name() {
+ return this.nameLabel.textContent;
+ }
+
+ set name(value) {
+ if (this.name != value) {
+ this.nameLabel.textContent = value;
+ this.#updateAriaLabel();
+ }
+ }
+
+ /**
+ * Format and set the name label of this row.
+ */
+ _setName() {
+ switch (this._nameStyle) {
+ case "server":
+ this.name = this._serverName;
+ break;
+ case "folder":
+ this.name = this._folderName;
+ break;
+ case "both":
+ this.name = `${this._folderName} - ${this._serverName}`;
+ break;
+ }
+ }
+
+ /**
+ * The number of unread messages for this folder.
+ *
+ * @type {integer}
+ */
+ get unreadCount() {
+ return parseInt(this.unreadCountLabel.textContent, 10) || 0;
+ }
+
+ set unreadCount(value) {
+ this.classList.toggle("unread", value > 0);
+ // Avoid setting `textContent` if possible, each change notifies the
+ // MutationObserver on `folderTree`, and there could be *many* changes.
+ let textNode = this.unreadCountLabel.firstChild;
+ if (textNode) {
+ textNode.nodeValue = value;
+ } else {
+ this.unreadCountLabel.textContent = value;
+ }
+ this.#updateAriaLabel();
+ }
+
+ /**
+ * The total number of messages for this folder.
+ *
+ * @type {integer}
+ */
+ get totalCount() {
+ return parseInt(this.totalCountLabel.textContent, 10) || 0;
+ }
+
+ set totalCount(value) {
+ this.classList.toggle("total", value > 0);
+ this.totalCountLabel.textContent = value;
+ this.#updateAriaLabel();
+ }
+
+ /**
+ * The folder size for this folder.
+ *
+ * @type {integer}
+ */
+ get folderSize() {
+ return this.folderSizeLabel.textContent;
+ }
+
+ set folderSize(value) {
+ this.folderSizeLabel.textContent = value;
+ this.#updateAriaLabel();
+ }
+
+ #updateAriaLabel() {
+ // Collect the various strings and fluent IDs to build the full string for
+ // the folder aria-label.
+ let ariaLabelPromises = [];
+ ariaLabelPromises.push(this.name);
+
+ // If unread messages.
+ const count = this.unreadCount;
+ if (count > 0) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("folder-pane-unread-aria-label", { count })
+ );
+ }
+
+ // If total messages is visible.
+ if (folderPane.isTotalMsgCountVisible()) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("folder-pane-total-aria-label", {
+ count: this.totalCount,
+ })
+ );
+ }
+
+ if (folderPane.isItemVisible("folderPaneFolderSize")) {
+ ariaLabelPromises.push(this.folderSize);
+ }
+
+ Promise.allSettled(ariaLabelPromises).then(results => {
+ const folderLabel = results
+ .map(settledPromise => settledPromise.value ?? "")
+ .filter(value => value.trim() != "")
+ .join(", ");
+ this.setAttribute("aria-label", folderLabel);
+ this.title = folderLabel;
+ });
+ }
+
+ /**
+ * Set some common properties based on the URI for this row.
+ * `this.modeName` must be set before calling this function.
+ *
+ * @param {string} uri
+ */
+ _setURI(uri) {
+ this.id = FolderTreeRow.makeRowID(this.modeName, uri);
+ this.uri = uri;
+ if (!FolderTreeProperties.getIsExpanded(uri, this.modeName)) {
+ this.classList.add("collapsed");
+ }
+ this.setIconColor();
+ }
+
+ /**
+ * Set the icon color to the given color, or if none is given the value from
+ * FolderTreeProperties, or the default.
+ *
+ * @param {string?} iconColor
+ */
+ setIconColor(iconColor) {
+ if (!iconColor) {
+ iconColor = FolderTreeProperties.getColor(this.uri);
+ }
+ this.icon.style.setProperty("--icon-color", iconColor ?? "");
+ }
+
+ /**
+ * Set some properties based on the server for this row.
+ *
+ * @param {nsIMsgIncomingServer} server
+ */
+ setServer(server) {
+ this._setURI(server.rootFolder.URI);
+ this.dataset.serverKey = server.key;
+ this.dataset.serverType = server.type;
+ this.dataset.serverSecure = server.isSecure;
+ this._nameStyle = "server";
+ this._serverName = server.prettyName;
+ this._setName();
+ const isCollapsed = this.classList.contains("collapsed");
+ if (isCollapsed) {
+ this.unreadCount = server.rootFolder.getNumUnread(isCollapsed);
+ this.totalCount = server.rootFolder.getTotalMessages(isCollapsed);
+ }
+ this.setFolderPropertiesFromFolder(server.rootFolder);
+ }
+
+ /**
+ * Set some properties based on the folder for this row.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {"folder"|"server"|"both"} nameStyle
+ */
+ setFolder(folder, nameStyle = "folder") {
+ this._setURI(folder.URI);
+ this.dataset.serverKey = folder.server.key;
+ this.setFolderTypeFromFolder(folder);
+ this.setFolderPropertiesFromFolder(folder);
+ this._nameStyle = nameStyle;
+ this._serverName = folder.server.prettyName;
+ this._folderName = folder.abbreviatedName;
+ this._setName();
+ const isCollapsed = this.classList.contains("collapsed");
+ this.unreadCount = folder.getNumUnread(isCollapsed);
+ this.totalCount = folder.getTotalMessages(isCollapsed);
+ if (folderPane.isItemVisible("folderPaneFolderSize")) {
+ this.folderSize = this.formatFolderSize(folder.sizeOnDisk);
+ }
+ this.folderSortOrder = folder.sortOrder;
+ if (folder.noSelect) {
+ this.classList.add("noselect-folder");
+ } else {
+ this.setAttribute("draggable", "true");
+ }
+ }
+
+ /**
+ * Update new message state of the row.
+ *
+ * @param {boolean} [notifiedOfNewMessages=false] - When true there are new
+ * messages on the server, but they may not yet be downloaded locally.
+ */
+ updateNewMessages(notifiedOfNewMessages = false) {
+ const folder = MailServices.folderLookup.getFolderForURL(this.uri);
+ const foldersHaveNewMessages = this.classList.contains("collapsed")
+ ? folder.hasFolderOrSubfolderNewMessages
+ : folder.hasNewMessages;
+ this.classList.toggle(
+ "new-messages",
+ notifiedOfNewMessages || foldersHaveNewMessages
+ );
+ }
+
+ updateUnreadMessageCount() {
+ this.unreadCount = MailServices.folderLookup
+ .getFolderForURL(this.uri)
+ .getNumUnread(this.classList.contains("collapsed"));
+ }
+
+ updateTotalMessageCount() {
+ const folder = MailServices.folderLookup.getFolderForURL(this.uri);
+ this.totalCount = folder.getTotalMessages(
+ this.classList.contains("collapsed")
+ );
+ if (folderPane.isItemVisible("folderPaneFolderSize")) {
+ this.updateSizeCount(false, folder);
+ }
+ }
+
+ updateSizeCount(isHidden, folder = null) {
+ this.folderSizeLabel.hidden = isHidden;
+ if (!isHidden) {
+ folder = folder ?? MailServices.folderLookup.getFolderForURL(this.uri);
+ this.folderSize = this.formatFolderSize(folder.sizeOnDisk);
+ }
+ }
+
+ /**
+ * Format the folder file size to display in the folder pane.
+ *
+ * @param {integer} size - The folder size on disk.
+ * @returns {string} - The formatted folder size.
+ */
+ formatFolderSize(size) {
+ return size / 1024 < 1 ? "" : top.messenger.formatFileSize(size, true);
+ }
+
+ /**
+ * Update the visibility of the total count badge.
+ *
+ * @param {boolean} isHidden
+ */
+ toggleTotalCountBadgeVisibility(isHidden) {
+ this.totalCountLabel.hidden = isHidden;
+ this.#updateAriaLabel();
+ }
+
+ /**
+ * Sets the folder type property based on the folder for the row.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ setFolderTypeFromFolder(folder) {
+ let folderType = FolderUtils.getSpecialFolderString(folder);
+ if (folderType != "none") {
+ this.dataset.folderType = folderType.toLowerCase();
+ }
+ }
+
+ /**
+ * Sets folder properties based on the folder for the row.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ setFolderPropertiesFromFolder(folder) {
+ if (folder.server.type != "rss") {
+ return;
+ }
+ let urls = !folder.isServer ? FeedUtils.getFeedUrlsInFolder(folder) : null;
+ if (urls?.length == 1) {
+ let url = urls[0];
+ this.icon.style = `content: url("page-icon:${url}"); background-image: none;`;
+ }
+ let props = FeedUtils.getFolderProperties(folder);
+ for (let name of ["hasError", "isBusy", "isPaused"]) {
+ if (props.includes(name)) {
+ this.dataset[name] = "true";
+ } else {
+ delete this.dataset[name];
+ }
+ }
+ }
+
+ /**
+ * Update this row's name label to match the new `prettyName` of the server.
+ *
+ * @param {string} name
+ */
+ setServerName(name) {
+ this._serverName = name;
+ if (this._nameStyle != "folder") {
+ this._setName();
+ }
+ }
+
+ /**
+ * Add a child row in the correct sort order.
+ *
+ * @param {FolderTreeRow} newChild
+ * @returns {FolderTreeRow}
+ */
+ insertChildInOrder(newChild) {
+ let { folderSortOrder, name } = newChild;
+ for (let child of this.childList.children) {
+ if (folderSortOrder < child.folderSortOrder) {
+ return this.childList.insertBefore(newChild, child);
+ }
+ if (
+ folderSortOrder == child.folderSortOrder &&
+ FolderTreeRow.nameCollator.compare(name, child.name) < 0
+ ) {
+ return this.childList.insertBefore(newChild, child);
+ }
+ }
+ return this.childList.appendChild(newChild);
+ }
+}
+customElements.define("folder-tree-row", FolderTreeRow, { extends: "li" });
+
+/**
+ * Header area of the message list pane.
+ */
+var threadPaneHeader = {
+ /**
+ * The header bar element.
+ * @type {?HTMLElement}
+ */
+ bar: null,
+ /**
+ * The h2 element receiving the folder name.
+ * @type {?HTMLHeadElement}
+ */
+ folderName: null,
+ /**
+ * The span element receiving the message count.
+ * @type {?HTMLSpanElement}
+ */
+ folderCount: null,
+ /**
+ * The quick filter toolbar toggle button.
+ * @type {?HTMLButtonElement}
+ */
+ filterButton: null,
+ /**
+ * The display options button opening the popup.
+ * @type {?HTMLButtonElement}
+ */
+ displayButton: null,
+ /**
+ * If the header area is hidden.
+ * @type {boolean}
+ */
+ isHidden: false,
+
+ init() {
+ this.isHidden =
+ Services.xulStore.getValue(XULSTORE_URL, "threadPaneHeader", "hidden") ===
+ "true";
+ this.bar = document.getElementById("threadPaneHeaderBar");
+ this.bar.hidden = this.isHidden;
+
+ this.folderName = document.getElementById("threadPaneFolderName");
+ this.folderCount = document.getElementById("threadPaneFolderCount");
+ this.selectedCount = document.getElementById("threadPaneSelectedCount");
+ this.filterButton = document.getElementById("threadPaneQuickFilterButton");
+ this.filterButton.addEventListener("click", () =>
+ goDoCommand("cmd_toggleQuickFilterBar")
+ );
+ window.addEventListener("qfbtoggle", this);
+ this.onQuickFilterToggle();
+
+ this.displayButton = document.getElementById("threadPaneDisplayButton");
+ this.displayContext = document.getElementById("threadPaneDisplayContext");
+ this.displayButton.addEventListener("click", event => {
+ this.displayContext.openPopup(event.target, { triggerEvent: event });
+ });
+ },
+
+ uninit() {
+ window.removeEventListener("qfbtoggle", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "qfbtoggle":
+ this.onQuickFilterToggle();
+ break;
+ }
+ },
+
+ /**
+ * Update the context menu to reflect the currently selected display options.
+ *
+ * @param {Event} event - The popupshowing DOMEvent.
+ */
+ updateDisplayContextMenu(event) {
+ if (event.target.id != "threadPaneDisplayContext") {
+ return;
+ }
+ const isTableLayout = document.body.classList.contains("layout-table");
+ document
+ .getElementById(
+ isTableLayout ? "threadPaneTableView" : "threadPaneCardsView"
+ )
+ .setAttribute("checked", "true");
+ },
+
+ /**
+ * Update the menuitems inside the thread pane sort menupopup.
+ *
+ * @param {Event} event - The popupshowing DOMEvent.
+ */
+ updateThreadPaneSortMenu(event) {
+ if (event.target.id != "menu_threadPaneSortPopup") {
+ return;
+ }
+
+ const hiddenColumns = threadPane.columns
+ .filter(c => c.hidden)
+ .map(c => c.sortKey);
+
+ // Update menuitem to reflect sort key.
+ for (const menuitem of event.target.querySelectorAll(`[name="sortby"]`)) {
+ const sortKey = menuitem.getAttribute("value");
+ menuitem.setAttribute(
+ "checked",
+ gViewWrapper.primarySortType == Ci.nsMsgViewSortType[sortKey]
+ );
+ if (hiddenColumns.includes(sortKey)) {
+ menuitem.setAttribute("disabled", "true");
+ } else {
+ menuitem.removeAttribute("disabled");
+ }
+ }
+
+ // Update sort direction menu items.
+ event.target
+ .querySelector(`[value="ascending"]`)
+ .setAttribute("checked", gViewWrapper.isSortedAscending);
+ event.target
+ .querySelector(`[value="descending"]`)
+ .setAttribute("checked", !gViewWrapper.isSortedAscending);
+
+ // Update the threaded and groupedBy menu items.
+ event.target
+ .querySelector(`[value="threaded"]`)
+ .setAttribute("checked", gViewWrapper.showThreaded);
+ event.target
+ .querySelector(`[value="unthreaded"]`)
+ .setAttribute("checked", gViewWrapper.showUnthreaded);
+ event.target
+ .querySelector(`[value="group"]`)
+ .setAttribute("checked", gViewWrapper.showGroupedBySort);
+ },
+
+ /**
+ * Change the display view of the message list pane.
+ *
+ * @param {DOMEvent} event - The click event.
+ */
+ changePaneView(event) {
+ const view = event.target.value;
+ Services.xulStore.setValue(XULSTORE_URL, "threadPane", "view", view);
+ threadPane.updateThreadView(view);
+ },
+
+ /**
+ * Update the quick filter button based on the quick filter bar state.
+ */
+ onQuickFilterToggle() {
+ const active = quickFilterBar.filterer.visible;
+ this.filterButton.setAttribute("aria-pressed", active.toString());
+ },
+
+ /**
+ * Toggle the visibility of the message list pane header.
+ */
+ toggleThreadPaneHeader() {
+ this.isHidden = !this.isHidden;
+ this.bar.hidden = this.isHidden;
+
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "threadPaneHeader",
+ "hidden",
+ this.isHidden
+ );
+ // Trigger a data refresh if we're revealing the header.
+ if (!this.isHidden) {
+ this.onFolderSelected();
+ }
+ },
+
+ /**
+ * Update the header data when the selected folder changes.
+ */
+ onFolderSelected() {
+ // Bail out if the pane is hidden as we don't need to update anything.
+ if (this.isHidden) {
+ return;
+ }
+
+ // Hide any potential stale data if we don't have a folder.
+ if (!gFolder && !gDBView && !gViewWrapper?.isSynthetic) {
+ this.folderName.hidden = true;
+ this.folderCount.hidden = true;
+ this.selectedCount.hidden = true;
+ return;
+ }
+
+ const folderName = gFolder?.abbreviatedName ?? document.title;
+ this.folderName.textContent = folderName;
+ this.folderName.title = folderName;
+ document.l10n.setAttributes(
+ this.folderCount,
+ "thread-pane-folder-message-count",
+ { count: gFolder?.getTotalMessages(false) || gDBView?.rowCount || 0 }
+ );
+
+ this.folderName.hidden = false;
+ this.folderCount.hidden = false;
+ },
+
+ /**
+ * Update the total message count in the header if the value changed for the
+ * currently selected folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder updating the count.
+ * @param {integer} newValue
+ */
+ updateFolderCount(folder, newValue) {
+ if (!gFolder || !folder || this.isHidden || folder.URI != gFolder.URI) {
+ return;
+ }
+
+ document.l10n.setAttributes(
+ this.folderCount,
+ "thread-pane-folder-message-count",
+ { count: newValue }
+ );
+ },
+
+ /**
+ * Count the number of currently selected messages and update the selected
+ * message count indicator.
+ */
+ updateSelectedCount() {
+ // Bail out if the pane is hidden as we don't need to update anything.
+ if (this.isHidden) {
+ return;
+ }
+
+ let count = gDBView?.getSelectedMsgHdrs().length;
+ if (count < 2) {
+ this.selectedCount.hidden = true;
+ return;
+ }
+ document.l10n.setAttributes(
+ this.selectedCount,
+ "thread-pane-folder-selected-count",
+ { count }
+ );
+ this.selectedCount.hidden = false;
+ },
+};
+
+var threadPane = {
+ /**
+ * Non-persistent storage of the last-selected items in each folder.
+ * Keys in this map are folder URIs. Values are objects containing an array
+ * of the selected messages and the current message. Messages are referenced
+ * by message key to account for possible changes in the folder.
+ *
+ * @type {Map<string, object>}
+ */
+ _savedSelections: new Map(),
+
+ /**
+ * This is set to true in folderPane._onSelect before opening the folder, if
+ * new messages have been received and the corresponding preference is set.
+ *
+ * @type {boolean}
+ */
+ scrollToNewMessage: false,
+
+ /**
+ * Set to true when a scrolling event (presumably by the user) is detected
+ * while messages are still loading in a newly created view.
+ *
+ * @type {boolean}
+ */
+ scrollDetected: false,
+
+ /**
+ * The first detected scrolling event is triggered by creating the view
+ * itself. This property is then set to false.
+ *
+ * @type {boolean}
+ */
+ isFirstScroll: true,
+
+ columns: getDefaultColumns(gFolder),
+
+ cardColumns: getDefaultColumnsForCardsView(gFolder),
+
+ async init() {
+ quickFilterBar.init();
+
+ this.setUpTagStyles();
+ Services.prefs.addObserver("mailnews.tags.", this);
+
+ Services.obs.addObserver(this, "addrbook-displayname-changed");
+
+ // Ensure TreeView and its classes are properly defined.
+ await customElements.whenDefined("tree-view-table-row");
+
+ threadTree = document.getElementById("threadTree");
+ this.treeTable = threadTree.table;
+ this.treeTable.editable = true;
+ this.treeTable.setPopupMenuTemplates([
+ "threadPaneApplyColumnMenu",
+ "threadPaneApplyViewMenu",
+ ]);
+ threadTree.setAttribute(
+ "rows",
+ !Services.xulStore.hasValue(XULSTORE_URL, "threadPane", "view") ||
+ Services.xulStore.getValue(XULSTORE_URL, "threadPane", "view") ==
+ "cards"
+ ? "thread-card"
+ : "thread-row"
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "selectDelay",
+ "mailnews.threadpane_select_delay",
+ null,
+ (name, oldValue, newValue) => (threadTree.dataset.selectDelay = newValue)
+ );
+ threadTree.dataset.selectDelay = this.selectDelay;
+
+ window.addEventListener("uidensitychange", () => {
+ this.densityChange();
+ threadTree.reset();
+ });
+ this.densityChange();
+
+ XPCOMUtils.defineLazyGetter(this, "notificationBox", () => {
+ let container = document.getElementById("threadPaneNotificationBox");
+ return new MozElements.NotificationBox(element =>
+ container.append(element)
+ );
+ });
+
+ this.treeTable.addEventListener("shift-column", event => {
+ this.onColumnShifted(event.detail);
+ });
+ this.treeTable.addEventListener("reorder-columns", event => {
+ this.onColumnsReordered(event.detail);
+ });
+ this.treeTable.addEventListener("column-resized", event => {
+ this.treeTable.setColumnsWidths(XULSTORE_URL, event);
+ });
+ this.treeTable.addEventListener("columns-changed", event => {
+ this.onColumnsVisibilityChanged(event.detail);
+ });
+ this.treeTable.addEventListener("sort-changed", event => {
+ this.onSortChanged(event.detail);
+ });
+ this.treeTable.addEventListener("restore-columns", () => {
+ this.restoreDefaultColumns();
+ });
+ this.treeTable.addEventListener("toggle-flag", event => {
+ gDBView.applyCommandToIndices(
+ event.detail.isFlagged
+ ? Ci.nsMsgViewCommandType.unflagMessages
+ : Ci.nsMsgViewCommandType.flagMessages,
+ [event.detail.index]
+ );
+ });
+ this.treeTable.addEventListener("toggle-unread", event => {
+ gDBView.applyCommandToIndices(
+ event.detail.isUnread
+ ? Ci.nsMsgViewCommandType.markMessagesRead
+ : Ci.nsMsgViewCommandType.markMessagesUnread,
+ [event.detail.index]
+ );
+ });
+ this.treeTable.addEventListener("toggle-spam", event => {
+ gDBView.applyCommandToIndices(
+ event.detail.isJunk
+ ? Ci.nsMsgViewCommandType.unjunk
+ : Ci.nsMsgViewCommandType.junk,
+ [event.detail.index]
+ );
+ });
+ this.treeTable.addEventListener("thread-changed", () => {
+ sortController.toggleThreaded();
+ });
+ this.treeTable.addEventListener("request-delete", event => {
+ gDBView.applyCommandToIndices(Ci.nsMsgViewCommandType.deleteMsg, [
+ event.detail.index,
+ ]);
+ });
+
+ this.updateClassList();
+
+ threadTree.addEventListener("contextmenu", this);
+ threadTree.addEventListener("dblclick", this);
+ threadTree.addEventListener("auxclick", this);
+ threadTree.addEventListener("keypress", this);
+ threadTree.addEventListener("select", this);
+ threadTree.table.body.addEventListener("dragstart", this);
+ threadTree.addEventListener("dragover", this);
+ threadTree.addEventListener("drop", this);
+ threadTree.addEventListener("expanded", this);
+ threadTree.addEventListener("collapsed", this);
+ threadTree.addEventListener("scroll", this);
+ },
+
+ uninit() {
+ Services.prefs.removeObserver("mailnews.tags.", this);
+ Services.obs.removeObserver(this, "addrbook-displayname-changed");
+ },
+
+ handleEvent(event) {
+ const notOnEmptySpace = event.target !== threadTree;
+ switch (event.type) {
+ case "contextmenu":
+ if (notOnEmptySpace) {
+ this._onContextMenu(event);
+ }
+ break;
+ case "dblclick":
+ if (notOnEmptySpace) {
+ this._onDoubleClick(event);
+ }
+ break;
+ case "auxclick":
+ if (event.button == 1 && notOnEmptySpace) {
+ this._onMiddleClick(event);
+ }
+ break;
+ case "keypress":
+ this._onKeyPress(event);
+ break;
+ case "select":
+ this._onSelect(event);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "expanded":
+ case "collapsed":
+ if (event.detail == threadTree.selectedIndex) {
+ // The selected index hasn't changed, but a collapsed row represents
+ // multiple messages, so for our purposes the selection has changed.
+ threadTree.dispatchEvent(new CustomEvent("select"));
+ }
+ break;
+ case "scroll":
+ if (this.isFirstScroll) {
+ this.isFirstScroll = false;
+ break;
+ }
+ this.scrollDetected = true;
+ break;
+ }
+ },
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ this.setUpTagStyles();
+ } else if (topic == "addrbook-displayname-changed") {
+ // This runs the when mail.displayname.version preference observer is
+ // notified/the mail.displayname.version number has been updated.
+ threadTree.invalidate();
+ }
+ },
+
+ /**
+ * Update the CSS classes of the thread tree based on the current folder.
+ */
+ updateClassList() {
+ if (!gFolder) {
+ threadTree.classList.remove("is-outgoing");
+ return;
+ }
+
+ threadTree.classList.toggle("is-outgoing", isOutgoing(gFolder));
+ },
+
+ /**
+ * Temporarily select a different index from the actual selection, without
+ * visually changing or losing the current selection.
+ *
+ * @param {integer} index - The index of the clicked row.
+ */
+ suppressSelect(index) {
+ this.saveSelection();
+ threadTree._selection.selectEventsSuppressed = true;
+ threadTree._selection.select(index);
+ },
+
+ /**
+ * Clear the selection suppression and restore the previous selection.
+ */
+ releaseSelection() {
+ threadTree._selection.selectEventsSuppressed = true;
+ this.restoreSelection({ notify: false });
+ threadTree._selection.selectEventsSuppressed = false;
+ },
+
+ _onDoubleClick(event) {
+ if (event.target.closest("button") || event.target.closest("menupopup")) {
+ // Prevent item activation if double click happens on a button inside the
+ // row. E.g.: Thread toggle, spam, favorite, etc. or in a menupopup like
+ // the column picker.
+ return;
+ }
+ this._onItemActivate(event);
+ },
+
+ _onKeyPress(event) {
+ if (event.target.closest("thead")) {
+ // Bail out if the keypress happens in the table header.
+ return;
+ }
+
+ if (event.key == "Enter") {
+ this._onItemActivate(event);
+ }
+ },
+
+ _onMiddleClick(event) {
+ const row =
+ event.target.closest(`tr[is^="thread-"]`) ||
+ threadTree.getRowAtIndex(threadTree.currentIndex);
+
+ const isSelected = gDBView.selection.isSelected(row.index);
+ if (!isSelected) {
+ // The middle-clicked row is not selected. Tell the activate item to use
+ // this instead.
+ this.suppressSelect(row.index);
+ }
+ this._onItemActivate(event);
+ if (!isSelected) {
+ this.releaseSelection();
+ }
+ },
+
+ _onItemActivate(event) {
+ if (
+ threadTree.selectedIndex < 0 ||
+ gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY
+ ) {
+ return;
+ }
+
+ let folder = gFolder || gDBView.hdrForFirstSelectedMessage.folder;
+ if (folder?.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)) {
+ commandController.doCommand("cmd_editDraftMsg", event);
+ } else if (folder?.isSpecialFolder(Ci.nsMsgFolderFlags.Templates, true)) {
+ commandController.doCommand("cmd_newMsgFromTemplate", event);
+ } else {
+ commandController.doCommand("cmd_openMessage", event);
+ }
+ },
+
+ /**
+ * Handle threadPane select events.
+ */
+ _onSelect(event) {
+ if (!paneLayout.messagePaneVisible.isCollapsed && gDBView) {
+ messagePane.clearWebPage();
+ switch (gDBView.numSelected) {
+ case 0:
+ messagePane.clearMessage();
+ messagePane.clearMessages();
+ threadPaneHeader.selectedCount.hidden = true;
+ break;
+ case 1:
+ if (
+ gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY
+ ) {
+ messagePane.clearMessage();
+ messagePane.clearMessages();
+ threadPaneHeader.selectedCount.hidden = true;
+ } else {
+ let uri = gDBView.getURIForViewIndex(threadTree.selectedIndex);
+ messagePane.displayMessage(uri);
+ threadPaneHeader.updateSelectedCount();
+ }
+ break;
+ default:
+ messagePane.displayMessages(gDBView.getSelectedMsgHdrs());
+ threadPaneHeader.updateSelectedCount();
+ break;
+ }
+ }
+
+ // Update the state of the zoom commands, since the view has changed.
+ const commandsToUpdate = [
+ "cmd_fullZoomReduce",
+ "cmd_fullZoomEnlarge",
+ "cmd_fullZoomReset",
+ "cmd_fullZoomToggle",
+ ];
+ for (const command of commandsToUpdate) {
+ top.goUpdateCommand(command);
+ }
+ },
+
+ /**
+ * Handle threadPane drag events.
+ */
+ _onDragStart(event) {
+ let row = event.target.closest(`tr[is^="thread-"]`);
+ if (!row) {
+ event.preventDefault();
+ return;
+ }
+
+ let messageURIs = gDBView.getURIsForSelection();
+ if (!threadTree.selectedIndices.includes(row.index)) {
+ messageURIs = [gDBView.getURIForViewIndex(row.index)];
+ }
+
+ let noSubjectString = messengerBundle.GetStringFromName(
+ "defaultSaveMessageAsFileName"
+ );
+ if (noSubjectString.endsWith(".eml")) {
+ noSubjectString = noSubjectString.slice(0, -4);
+ }
+ let longSubjectTruncator = messengerBundle.GetStringFromName(
+ "longMsgSubjectTruncator"
+ );
+ // Clip the subject string to 124 chars to avoid problems on Windows,
+ // see NS_MAX_FILEDESCRIPTOR in m-c/widget/windows/nsDataObj.cpp .
+ const maxUncutNameLength = 124;
+ let maxCutNameLength = maxUncutNameLength - longSubjectTruncator.length;
+ let messages = new Map();
+
+ for (let [index, uri] of Object.entries(messageURIs)) {
+ let msgService = MailServices.messageServiceFromURI(uri);
+ let msgHdr = msgService.messageURIToMsgHdr(uri);
+ let subject = msgHdr.mime2DecodedSubject || "";
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ subject = "Re: " + subject;
+ }
+
+ let uniqueFileName;
+ // If there is no subject, use a default name.
+ // If subject needs to be truncated, add a truncation character to indicate it.
+ if (!subject) {
+ uniqueFileName = noSubjectString;
+ } else {
+ uniqueFileName =
+ subject.length <= maxUncutNameLength
+ ? subject
+ : subject.substr(0, maxCutNameLength) + longSubjectTruncator;
+ }
+ let msgFileName = validateFileName(uniqueFileName);
+ let msgFileNameLowerCase = msgFileName.toLocaleLowerCase();
+
+ while (true) {
+ if (!messages.has(msgFileNameLowerCase)) {
+ messages.set(msgFileNameLowerCase, 1);
+ break;
+ } else {
+ let number = messages.get(msgFileNameLowerCase);
+ messages.set(msgFileNameLowerCase, number + 1);
+ let postfix = "-" + number;
+ msgFileName = msgFileName + postfix;
+ msgFileNameLowerCase = msgFileNameLowerCase + postfix;
+ }
+ }
+
+ msgFileName = msgFileName + ".eml";
+
+ // This type should be unnecessary, but getFlavorData can't get at
+ // text/x-moz-message for some reason.
+ event.dataTransfer.mozSetDataAt("text/plain", uri, index);
+ event.dataTransfer.mozSetDataAt("text/x-moz-message", uri, index);
+ event.dataTransfer.mozSetDataAt(
+ "text/x-moz-url",
+ msgService.getUrlForUri(uri).spec,
+ index
+ );
+ // When dragging messages to the filesystem:
+ // - Windows fetches this value and writes it to a file.
+ // - Linux does the same if there are multiple files, but for a single
+ // file it uses the flavor data provider below.
+ // - MacOS always uses the flavor data provider.
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-url",
+ msgService.getUrlForUri(uri).spec,
+ index
+ );
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise",
+ this._flavorDataProvider,
+ index
+ );
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-dest-filename",
+ msgFileName.replace(/(.{74}).*(.{10})$/u, "$1...$2"),
+ index
+ );
+ }
+
+ event.dataTransfer.effectAllowed = "copyMove";
+ let bcr = row.getBoundingClientRect();
+ event.dataTransfer.setDragImage(
+ row,
+ event.clientX - bcr.x,
+ event.clientY - bcr.y
+ );
+ },
+
+ /**
+ * Handle threadPane dragover events.
+ */
+ _onDragOver(event) {
+ if (event.target.closest("thead")) {
+ return; // Only allow dropping in the body.
+ }
+ // Must prevent default. Otherwise dropEffect gets cleared.
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "none";
+ let types = Array.from(event.dataTransfer.mozTypesAt(0));
+ let targetFolder = gFolder;
+ if (types.includes("application/x-moz-file")) {
+ if (targetFolder.isServer || !targetFolder.canFileMessages) {
+ return;
+ }
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let extFile = event.dataTransfer
+ .mozGetDataAt("application/x-moz-file", i)
+ .QueryInterface(Ci.nsIFile);
+ if (!extFile.isFile() || !/\.eml$/i.test(extFile.leafName)) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = "copy";
+ }
+ },
+
+ /**
+ * Handle threadPane drop events.
+ */
+ _onDrop(event) {
+ if (event.target.closest("thead")) {
+ return; // Only allow dropping in the body.
+ }
+ event.preventDefault();
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let extFile = event.dataTransfer
+ .mozGetDataAt("application/x-moz-file", i)
+ .QueryInterface(Ci.nsIFile);
+ if (extFile.isFile() && /\.eml$/i.test(extFile.leafName)) {
+ MailServices.copy.copyFileMessage(
+ extFile,
+ gFolder,
+ null,
+ false,
+ 1,
+ "",
+ null,
+ top.msgWindow
+ );
+ }
+ }
+ },
+
+ _onContextMenu(event, retry = false) {
+ let row =
+ event.target.closest(`tr[is^="thread-"]`) ||
+ threadTree.getRowAtIndex(threadTree.currentIndex);
+ const isMouse = event.button == 2;
+ if (!isMouse) {
+ if (threadTree.selectedIndex < 0) {
+ return;
+ }
+ // Scroll selected row we're triggering the context menu for into view.
+ threadTree.scrollToIndex(threadTree.currentIndex, true);
+ if (!row) {
+ row = threadTree.getRowAtIndex(threadTree.currentIndex);
+ // Try again once in the next frame.
+ if (!row && !retry) {
+ window.requestAnimationFrame(() => this._onContextMenu(event, true));
+ return;
+ }
+ }
+ }
+ if (!row || gDBView.getFlagsAt(row.index) & MSG_VIEW_FLAG_DUMMY) {
+ return;
+ }
+
+ mailContextMenu.setAsThreadPaneContextMenu();
+ let popup = document.getElementById("mailContext");
+
+ if (isMouse) {
+ if (!gDBView.selection.isSelected(row.index)) {
+ // The right-clicked-on row is not selected. Tell the context menu to
+ // use it instead. This override lasts until the context menu fires
+ // a "popuphidden" event.
+ mailContextMenu.setOverrideSelection(row.index);
+ row.classList.add("context-menu-target");
+ }
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ popup.openPopup(row, "after_end", 0, 0, true);
+ }
+
+ event.preventDefault();
+ },
+
+ _flavorDataProvider: {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(transferable, flavor, data) {
+ if (flavor !== "application/x-moz-file-promise") {
+ return;
+ }
+
+ let fileName = {};
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dest-filename",
+ fileName
+ );
+ fileName.value.QueryInterface(Ci.nsISupportsString);
+
+ let destDir = {};
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ destDir
+ );
+ destDir.value.QueryInterface(Ci.nsIFile);
+
+ let file = destDir.value.clone();
+ file.append(fileName.value.data);
+
+ let messageURI = {};
+ transferable.getTransferData("text/plain", messageURI);
+ messageURI.value.QueryInterface(Ci.nsISupportsString);
+
+ top.messenger.saveAs(messageURI.value.data, true, null, file.path, true);
+ },
+ },
+
+ _jsTree: {
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgJSTree"]),
+ _inBatch: false,
+ beginUpdateBatch() {
+ this._inBatch = true;
+ },
+ endUpdateBatch() {
+ this._inBatch = false;
+ },
+ ensureRowIsVisible(index) {
+ if (!this._inBatch) {
+ threadTree.scrollToIndex(index, true);
+ }
+ },
+ invalidate() {
+ if (!this._inBatch) {
+ threadTree.reset();
+ if (threadPane) {
+ threadPane.isFirstScroll = true;
+ threadPane.scrollDetected = false;
+ threadPane.scrollToLatestRowIfNoSelection();
+ }
+ }
+ },
+ invalidateRange(startIndex, endIndex) {
+ if (!this._inBatch) {
+ threadTree.invalidateRange(startIndex, endIndex);
+ }
+ },
+ rowCountChanged(index, count) {
+ if (!this._inBatch) {
+ threadTree.rowCountChanged(index, count);
+ }
+ },
+ get currentIndex() {
+ return threadTree.currentIndex;
+ },
+ set currentIndex(index) {
+ threadTree.currentIndex = index;
+ },
+ },
+
+ /**
+ * Tell the tree and the view about each other. `nsITreeView.setTree` can't
+ * be used because it needs a XULTreeElement and threadTree isn't one.
+ * (Strictly speaking the shim passed here isn't a tree either but it does
+ * implement the required methods.)
+ *
+ * @param {nsIMsgDBView} view
+ */
+ setTreeView(view) {
+ threadTree.view = gDBView = view;
+ // Clear the batch flag. Don't call `endUpdateBatch` as that may change in
+ // future leading to unintended consequences.
+ this._jsTree._inBatch = false;
+ view.setJSTree(this._jsTree);
+ },
+
+ setUpTagStyles() {
+ if (this.tagStyle) {
+ this.tagStyle.remove();
+ }
+ this.tagStyle = document.head.appendChild(document.createElement("style"));
+
+ for (let { color, key } of MailServices.tags.getAllTags()) {
+ if (!color) {
+ continue;
+ }
+ let selector = MailServices.tags.getSelectorForKey(key);
+ let contrast = TagUtils.isColorContrastEnough(color) ? "black" : "white";
+ this.tagStyle.sheet.insertRule(
+ `tr[data-properties~="${selector}"] {
+ --tag-color: ${color};
+ --tag-contrast-color: ${contrast};
+ }`
+ );
+ }
+ },
+
+ /**
+ * Make the list rows density aware.
+ */
+ densityChange() {
+ // The class ThreadRow can't be referenced because it's declared in a
+ // different scope. But we can get it from customElements.
+ let rowClass = customElements.get("thread-row");
+ let cardClass = customElements.get("thread-card");
+ switch (UIDensity.prefValue) {
+ case UIDensity.MODE_COMPACT:
+ rowClass.ROW_HEIGHT = 18;
+ cardClass.ROW_HEIGHT = 40;
+ break;
+ case UIDensity.MODE_TOUCH:
+ rowClass.ROW_HEIGHT = 32;
+ cardClass.ROW_HEIGHT = 52;
+ break;
+ default:
+ rowClass.ROW_HEIGHT = 26;
+ cardClass.ROW_HEIGHT = 46;
+ break;
+ }
+ },
+
+ /**
+ * Store the current thread tree selection.
+ */
+ saveSelection() {
+ // Identifying messages by key doesn't reliably work on on cross-folder views since
+ // the msgKey may not be unique.
+ if (gFolder && gDBView && !gViewWrapper?.isMultiFolder) {
+ this._savedSelections.set(gFolder.URI, {
+ currentKey: gDBView.getKeyAt(threadTree.currentIndex),
+ // In views which are "grouped by sort", getting the key for collapsed dummy rows
+ // returns the key of the first group member, so we would restore something that
+ // wasn't selected. So filter them out.
+ selectedKeys: threadTree.selectedIndices
+ .filter(i => !gViewWrapper.isGroupedByHeaderAtIndex(i))
+ .map(gDBView.getKeyAt),
+ });
+ }
+ },
+
+ /**
+ * Forget any saved selection of the given folder. This is useful if you're
+ * going to set the selection after switching to the folder.
+ *
+ * @param {string} folderURI
+ */
+ forgetSelection(folderURI) {
+ this._savedSelections.delete(folderURI);
+ },
+
+ /**
+ * Restore the previously saved thread tree selection.
+ *
+ * @param {boolean} [discard=true] - If false, the selection data is kept for
+ * another call of this function, unless all selections could already be
+ * restored in this run.
+ * @param {boolean} [notify=true] - Whether a change in "select" event
+ * should be fired.
+ * @param {boolean} [expand=true] - Try to expand threads containing selected
+ * messages.
+ */
+ restoreSelection({ discard = true, notify = true, expand = true } = {}) {
+ if (!this._savedSelections.has(gFolder?.URI) || !threadTree.view) {
+ return;
+ }
+
+ let { currentKey, selectedKeys } = this._savedSelections.get(gFolder.URI);
+ let currentIndex = nsMsgViewIndex_None;
+ let indices = new Set();
+ for (let key of selectedKeys) {
+ let index = gDBView.findIndexFromKey(key, expand);
+ // While the first message in a collapsed group returns the index of the
+ // dummy row, other messages return none. To be consistent, we don't
+ // select the dummy row in any case.
+ if (
+ index != nsMsgViewIndex_None &&
+ !gViewWrapper.isGroupedByHeaderAtIndex(index)
+ ) {
+ indices.add(index);
+ if (key == currentKey) {
+ currentIndex = index;
+ }
+ continue;
+ }
+ // Since it does not seem to be possible to reliably find the dummy row
+ // for a message in a group, we continue.
+ if (gViewWrapper.showGroupedBySort) {
+ continue;
+ }
+ // The message for this key can't be found. Perhaps the thread it's in
+ // has been collapsed? Select the root message in that case.
+ try {
+ const folder =
+ gViewWrapper.isVirtual && gViewWrapper.isSingleFolder
+ ? gViewWrapper._underlyingFolders[0]
+ : gFolder;
+ const msgHdr = folder.GetMessageHeader(key);
+ const thread = gDBView.getThreadContainingMsgHdr(msgHdr);
+ const rootMsgHdr = thread.getRootHdr();
+ index = gDBView.findIndexOfMsgHdr(rootMsgHdr, false);
+ if (index != nsMsgViewIndex_None) {
+ indices.add(index);
+ if (key == currentKey) {
+ currentIndex = index;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ threadTree.setSelectedIndices(indices.values(), !notify);
+
+ if (currentIndex != nsMsgViewIndex_None) {
+ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
+ threadTree.currentIndex = currentIndex;
+ threadTree.style.scrollBehavior = null;
+ }
+
+ // If all selections have already been restored, discard them as well.
+ if (discard || gDBView.selection.count == selectedKeys.length) {
+ this._savedSelections.delete(gFolder.URI);
+ }
+ },
+
+ /**
+ * Scroll to the most relevant end of the tree, but only if no rows are
+ * selected.
+ */
+ scrollToLatestRowIfNoSelection() {
+ if (!gDBView || gDBView.selection.count > 0 || gDBView.rowCount <= 0) {
+ return;
+ }
+ if (
+ gViewWrapper.sortImpliesTemporalOrdering &&
+ gViewWrapper.isSortedAscending
+ ) {
+ threadTree.scrollToIndex(gDBView.rowCount - 1, true);
+ } else {
+ threadTree.scrollToIndex(0, true);
+ }
+ },
+
+ /**
+ * Re-collapse threads expanded by nsMsgQuickSearchDBView if necessary.
+ */
+ ensureThreadStateForQuickSearchView() {
+ // nsMsgQuickSearchDBView::SortThreads leaves all threads expanded in any
+ // case.
+ if (
+ gViewWrapper.isSingleFolder &&
+ gViewWrapper.search.hasSearchTerms &&
+ gViewWrapper.showThreaded &&
+ !gViewWrapper._threadExpandAll
+ ) {
+ window.threadPane.saveSelection();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ window.threadPane.restoreSelection();
+ }
+ },
+
+ /**
+ * Restore the collapsed or expanded state of threads.
+ */
+ restoreThreadState() {
+ if (
+ gViewWrapper._threadExpandAll &&
+ !(gViewWrapper.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll)
+ ) {
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll);
+ }
+ if (
+ !gViewWrapper._threadExpandAll &&
+ gViewWrapper.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ ) {
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ }
+ },
+
+ /**
+ * Restore the chevron icon indicating the current sort order.
+ */
+ restoreSortIndicator() {
+ if (!gDBView) {
+ return;
+ }
+ this.updateSortIndicator(
+ sortController.convertSortTypeToColumnID(gViewWrapper.primarySortType)
+ );
+ },
+
+ /**
+ * Update the columns object and force the refresh of the thread pane to apply
+ * the updated state. This is usually called when changing folders.
+ */
+ restoreColumns() {
+ this.restoreColumnsState();
+ this.updateColumns();
+ },
+
+ /**
+ * Restore the visibility and order of the columns for the current folder.
+ */
+ restoreColumnsState() {
+ // Always fetch a fresh array of columns for the cards view even if we don't
+ // have a folder defined.
+ this.cardColumns = getDefaultColumnsForCardsView(gFolder);
+ this.updateClassList();
+
+ // Avoid doing anything if no folder has been loaded yet.
+ if (!gFolder) {
+ return;
+ }
+
+ // A missing folder database will throw an error so we need to handle that.
+ let msgDatabase;
+ try {
+ msgDatabase = gFolder.msgDatabase;
+ } catch {
+ return;
+ }
+
+ const stringState =
+ msgDatabase.dBFolderInfo.getCharProperty("columnStates");
+ if (!stringState) {
+ // If we don't have a previously saved state, make sure to enforce the
+ // default columns for the currently visible folder, otherwise the table
+ // layout will maintain whatever state is currently set from the previous
+ // folder, which it doesn't reflect reality.
+ this.columns = getDefaultColumns(gFolder);
+ return;
+ }
+
+ this.applyPersistedColumnsState(JSON.parse(stringState));
+ },
+
+ /**
+ * Update the current columns to match a previously saved state.
+ *
+ * @param {JSON} columnStates - The parsed JSON of a previously saved state.
+ */
+ applyPersistedColumnsState(columnStates) {
+ this.columns.forEach(c => {
+ c.hidden = !columnStates[c.id]?.visible;
+ c.ordinal = columnStates[c.id]?.ordinal ?? 0;
+ });
+ // Sort columns by ordinal.
+ this.columns.sort(function (a, b) {
+ return a.ordinal - b.ordinal;
+ });
+ },
+
+ /**
+ * Force an update of the thread tree to reflect the columns change.
+ *
+ * @param {boolean} isSimple - If the columns structure only requires a simple
+ * update and not a full reset of the entire table header.
+ */
+ updateColumns(isSimple = false) {
+ if (!this.rowTemplate) {
+ this.rowTemplate = document.getElementById("threadPaneRowTemplate");
+ }
+
+ // Update the row template to match the column properties.
+ for (let column of this.columns) {
+ let cell = this.rowTemplate.content.querySelector(
+ `.${column.id.toLowerCase()}-column`
+ );
+ cell.hidden = column.hidden;
+ this.rowTemplate.content.appendChild(cell);
+ }
+
+ if (isSimple) {
+ this.treeTable.updateColumns(this.columns);
+ } else {
+ // The order of the columns have changed, which warrants a rebuild of the
+ // full table header.
+ this.treeTable.setColumns(this.columns);
+ }
+ this.treeTable.restoreColumnsWidths(XULSTORE_URL);
+ },
+
+ /**
+ * Restore the default columns visibility and order and save the change.
+ */
+ restoreDefaultColumns() {
+ this.columns = getDefaultColumns(gFolder, gViewWrapper?.isSynthetic);
+ this.cardColumns = getDefaultColumnsForCardsView(gFolder);
+ this.updateClassList();
+ this.updateColumns();
+ threadTree.reset();
+ this.persistColumnStates();
+ },
+
+ /**
+ * Shift the ordinal of a column by one based on the visible columns.
+ *
+ * @param {object} data - The detail object of the bubbled event.
+ */
+ onColumnShifted(data) {
+ const column = data.column;
+ const forward = data.forward;
+
+ const columnToShift = this.columns.find(c => c.id == column);
+ const currentPosition = this.columns.indexOf(columnToShift);
+
+ let delta = forward ? 1 : -1;
+ let newPosition = currentPosition + delta;
+ // Account for hidden columns to find the correct new position.
+ while (this.columns.at(newPosition).hidden) {
+ newPosition += delta;
+ }
+
+ // Get the column in the current new position before shuffling the array.
+ const destinationTH = document.getElementById(
+ this.columns.at(newPosition).id
+ );
+
+ this.columns.splice(
+ newPosition,
+ 0,
+ this.columns.splice(currentPosition, 1)[0]
+ );
+
+ // Update the ordinal of the columns to reflect the new positions.
+ this.columns.forEach((column, index) => {
+ column.ordinal = index;
+ });
+
+ this.persistColumnStates();
+ this.updateColumns(true);
+ threadTree.reset();
+
+ // Swap the DOM elements.
+ const originalTH = document.getElementById(column);
+ if (forward) {
+ destinationTH.after(originalTH);
+ } else {
+ destinationTH.before(originalTH);
+ }
+ // Restore the focus so we can continue shifting if needed.
+ document.getElementById(`${column}Button`).focus();
+ },
+
+ onColumnsReordered(data) {
+ this.columns = data.columns;
+
+ this.persistColumnStates();
+ this.updateColumns(true);
+ threadTree.reset();
+ },
+
+ /**
+ * Update the list of visible columns based on the users' selection.
+ *
+ * @param {object} data - The detail object of the bubbled event.
+ */
+ onColumnsVisibilityChanged(data) {
+ let column = data.value;
+ let checked = data.target.hasAttribute("checked");
+
+ let changedColumn = this.columns.find(c => c.id == column);
+ changedColumn.hidden = !checked;
+
+ this.persistColumnStates();
+ this.updateColumns(true);
+ threadTree.reset();
+ },
+
+ /**
+ * Save the current visibility of the columns in the folder database.
+ */
+ persistColumnStates() {
+ let newState = {};
+ for (const column of this.columns) {
+ newState[column.id] = {
+ visible: !column.hidden,
+ ordinal: column.ordinal,
+ };
+ }
+
+ if (gViewWrapper.isSynthetic) {
+ let syntheticView = gViewWrapper._syntheticView;
+ if ("setPersistedSetting" in syntheticView) {
+ syntheticView.setPersistedSetting("columns", newState);
+ }
+ return;
+ }
+
+ if (!gFolder) {
+ return;
+ }
+
+ // A missing folder database will throw an error so we need to handle that.
+ let msgDatabase;
+ try {
+ msgDatabase = gFolder.msgDatabase;
+ } catch {
+ return;
+ }
+
+ msgDatabase.dBFolderInfo.setCharProperty(
+ "columnStates",
+ JSON.stringify(newState)
+ );
+ msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit);
+ },
+
+ /**
+ * Trigger a sort change when the user clicks on the table header.
+ *
+ * @param {object} data - The detail of the custom event.
+ */
+ onSortChanged(data) {
+ const sortColumn = sortController.convertSortTypeToColumnID(
+ gViewWrapper.primarySortType
+ );
+ const column = data.column;
+
+ // A click happened on the column that is already used to sort the list.
+ if (sortColumn == column) {
+ if (gViewWrapper.isSortedAscending) {
+ sortController.sortDescending();
+ } else {
+ sortController.sortAscending();
+ }
+ this.updateSortIndicator(column);
+ return;
+ }
+
+ const sortName = this.columns.find(c => c.id == data.column).sortKey;
+ sortController.sortThreadPane(sortName);
+ this.updateSortIndicator(column);
+ },
+
+ /**
+ * Update the classes on the table header to reflect the sorting order.
+ *
+ * @param {string} column - The ID of column affecting the sorting order.
+ */
+ updateSortIndicator(column) {
+ this.treeTable
+ .querySelector(".sorting")
+ ?.classList.remove("sorting", "ascending", "descending");
+ this.treeTable
+ .querySelector(`#${column} button`)
+ ?.classList.add(
+ "sorting",
+ gViewWrapper.isSortedAscending ? "ascending" : "descending"
+ );
+ },
+
+ /**
+ * Prompt the user to confirm applying the current columns state to the chosen
+ * folder and its children.
+ *
+ * @param {nsIMsgFolder} folder - The chosen message folder.
+ * @param {boolean} [useChildren=false] - If the requested action should be
+ * propagated to the child folders.
+ */
+ async confirmApplyColumns(folder, useChildren = false) {
+ const msgFluentID = useChildren
+ ? "apply-current-columns-to-folder-with-children-message"
+ : "apply-current-columns-to-folder-message";
+ let [title, message] = await document.l10n.formatValues([
+ "apply-changes-to-folder-title",
+ { id: msgFluentID, args: { name: folder.name } },
+ ]);
+ if (Services.prompt.confirm(null, title, message)) {
+ this._applyColumns(folder, useChildren);
+ }
+ },
+
+ /**
+ * Apply the current columns state to the chosen folder and its children,
+ * if specified.
+ *
+ * @param {nsIMsgFolder} destFolder - The chosen folder.
+ * @param {boolean} useChildren - True if the changes should affect the child
+ * folders of the chosen folder.
+ */
+ _applyColumns(destFolder, useChildren) {
+ // Avoid doing anything if no folder has been loaded yet.
+ if (!gFolder || !destFolder) {
+ return;
+ }
+
+ // Get the current state from the columns array, not the saved state in the
+ // database in order to make sure we're getting the currently visible state.
+ let columnState = {};
+ for (const column of this.columns) {
+ columnState[column.id] = {
+ visible: !column.hidden,
+ ordinal: column.ordinal,
+ };
+ }
+
+ // Swaps "From" and "Recipient" if only one is shown. This is useful for
+ // copying an incoming folder's columns to and from an outgoing folder.
+ let columStateString = JSON.stringify(columnState);
+ let swappedColumnStateString;
+ if (columnState.senderCol.visible != columnState.recipientCol.visible) {
+ const backedSenderColumn = columnState.senderCol;
+ columnState.senderCol = columnState.recipientCol;
+ columnState.recipientCol = backedSenderColumn;
+ swappedColumnStateString = JSON.stringify(columnState);
+ } else {
+ swappedColumnStateString = columStateString;
+ }
+
+ const currentFolderIsOutgoing = isOutgoing(gFolder);
+
+ /**
+ * Update the columnStates property of the folder database and forget the
+ * reference to prevent memory bloat.
+ *
+ * @param {nsIMsgFolder} folder - The message folder.
+ */
+ const commitColumnsState = folder => {
+ if (folder.isServer) {
+ return;
+ }
+ // Check if the destination folder we're trying to update matches the same
+ // special state of the folder we're getting the column state from.
+ const colStateString =
+ isOutgoing(folder) == currentFolderIsOutgoing
+ ? columStateString
+ : swappedColumnStateString;
+
+ folder.msgDatabase.dBFolderInfo.setCharProperty(
+ "columnStates",
+ colStateString
+ );
+ folder.msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit);
+ // Force the reference to be forgotten.
+ folder.msgDatabase = null;
+ };
+
+ if (!useChildren) {
+ commitColumnsState(destFolder);
+ return;
+ }
+
+ // Loop through all the child folders and apply the same column state.
+ MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ commitColumnsState
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gViewWrapper.displayedFolder,
+ "msg-folder-columns-propagated"
+ );
+ });
+ },
+
+ /**
+ * Prompt the user to confirm applying the current view sate to the chosen
+ * folder and its children.
+ *
+ * @param {nsIMsgFolder} folder - The chosen message folder.
+ * @param {boolean} [useChildren=false] - If the requested action should be
+ * propagated to the child folders.
+ */
+ async confirmApplyView(folder, useChildren = false) {
+ const msgFluentID = useChildren
+ ? "apply-current-view-to-folder-with-children-message"
+ : "apply-current-view-to-folder-message";
+ let [title, message] = await document.l10n.formatValues([
+ { id: "apply-changes-to-folder-title" },
+ { id: msgFluentID, args: { name: folder.name } },
+ ]);
+ if (Services.prompt.confirm(null, title, message)) {
+ this._applyView(folder, useChildren);
+ }
+ },
+
+ /**
+ * Apply the current view flags, sorting key, and sorting order to another
+ * folder and its children, if specified.
+ *
+ * @param {nsIMsgFolder} destFolder - The chosen folder.
+ * @param {boolean} useChildren - True if the changes should affect the child
+ * folders of the chosen folder.
+ */
+ _applyView(destFolder, useChildren) {
+ const viewFlags = gViewWrapper.dbView.viewFlags;
+ const sortType = gViewWrapper.dbView.sortType;
+ const sortOrder = gViewWrapper.dbView.sortOrder;
+
+ /**
+ * Update the view state flags of the folder database and forget the
+ * reference to prevent memory bloat.
+ *
+ * @param {nsIMsgFolder} folder - The message folder.
+ */
+ const commitViewState = folder => {
+ if (folder.isServer) {
+ return;
+ }
+ folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags;
+ folder.msgDatabase.dBFolderInfo.sortType = sortType;
+ folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder;
+ // Null out to avoid memory bloat.
+ folder.msgDatabase = null;
+ };
+
+ if (!useChildren) {
+ commitViewState(destFolder);
+ return;
+ }
+
+ MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ commitViewState
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gViewWrapper.displayedFolder,
+ "msg-folder-views-propagated"
+ );
+ });
+ },
+
+ /**
+ * Hide any notifications about ignored threads.
+ */
+ hideIgnoredMessageNotification() {
+ this.notificationBox.removeTransientNotifications();
+ },
+
+ /**
+ * Show a notification in the thread pane footer, allowing the user to learn
+ * more about the ignore thread feature, and also allowing undo ignore thread.
+ *
+ * @param {nsIMsgDBHdr[]} messages - The messages being ignored.
+ * @param {boolean} subthreadOnly - If true, ignoring only `messages` and
+ * their subthreads, otherwise ignoring the whole thread.
+ */
+ showIgnoredMessageNotification(messages, subthreadOnly) {
+ let threadIds = new Set();
+ messages.forEach(function (msg) {
+ if (!threadIds.has(msg.threadId)) {
+ threadIds.add(msg.threadId);
+ }
+ });
+
+ let buttons = [
+ {
+ label: messengerBundle.GetStringFromName("learnMoreAboutIgnoreThread"),
+ accessKey: messengerBundle.GetStringFromName(
+ "learnMoreAboutIgnoreThreadAccessKey"
+ ),
+ popup: null,
+ callback(aNotificationBar, aButton) {
+ let url = Services.prefs.getCharPref(
+ "mail.ignore_thread.learn_more_url"
+ );
+ top.openContentTab(url);
+ return true; // Keep notification open.
+ },
+ },
+ {
+ label: messengerBundle.GetStringFromName(
+ !subthreadOnly ? "undoIgnoreThread" : "undoIgnoreSubthread"
+ ),
+ accessKey: messengerBundle.GetStringFromName(
+ !subthreadOnly
+ ? "undoIgnoreThreadAccessKey"
+ : "undoIgnoreSubthreadAccessKey"
+ ),
+ isDefault: true,
+ popup: null,
+ callback(aNotificationBar, aButton) {
+ messages.forEach(function (msg) {
+ let msgDb = msg.folder.msgDatabase;
+ if (subthreadOnly) {
+ msgDb.markHeaderKilled(msg, false, null);
+ } else if (threadIds.has(msg.threadId)) {
+ let thread = msgDb.getThreadContainingMsgHdr(msg);
+ msgDb.markThreadIgnored(
+ thread,
+ thread.getChildKeyAt(0),
+ false,
+ null
+ );
+ threadIds.delete(msg.threadId);
+ }
+ });
+ // Invalidation should be unnecessary but the back end doesn't
+ // notify us properly and resists attempts to fix this.
+ threadTree.reset();
+ threadTree.table.body.focus();
+ return false; // Close notification.
+ },
+ },
+ ];
+
+ if (threadIds.size == 1) {
+ let ignoredThreadText = messengerBundle.GetStringFromName(
+ !subthreadOnly ? "ignoredThreadFeedback" : "ignoredSubthreadFeedback"
+ );
+ let subj = messages[0].mime2DecodedSubject || "";
+ if (subj.length > 45) {
+ subj = subj.substring(0, 45) + "…";
+ }
+ let text = ignoredThreadText.replace("#1", subj);
+
+ this.notificationBox.appendNotification(
+ "ignoreThreadInfo",
+ {
+ label: text,
+ priority: this.notificationBox.PRIORITY_INFO_MEDIUM,
+ },
+ buttons
+ );
+ } else {
+ let ignoredThreadText = messengerBundle.GetStringFromName(
+ !subthreadOnly ? "ignoredThreadsFeedback" : "ignoredSubthreadsFeedback"
+ );
+
+ const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+ );
+ let text = PluralForm.get(threadIds.size, ignoredThreadText).replace(
+ "#1",
+ threadIds.size
+ );
+ this.notificationBox.appendNotification(
+ "ignoreThreadsInfo",
+ {
+ label: text,
+ priority: this.notificationBox.PRIORITY_INFO_MEDIUM,
+ },
+ buttons
+ );
+ }
+ },
+
+ /**
+ * Update the display view of the message list. Current supported options are
+ * table and cards.
+ *
+ * @param {string} view - The view type.
+ */
+ updateThreadView(view) {
+ switch (view) {
+ case "table":
+ document.body.classList.add("layout-table");
+ threadTree?.setAttribute("rows", "thread-row");
+ break;
+ case "cards":
+ default:
+ document.body.classList.remove("layout-table");
+ threadTree?.setAttribute("rows", "thread-card");
+ break;
+ }
+ },
+
+ /**
+ * Update the ARIA Role of the tree view table body to properly communicate
+ * to assistive techonology the type of list we're rendering and toggles the
+ * threaded class on the tree table header.
+ *
+ * @param {boolean} isListbox - If the list should have a listbox role.
+ */
+ updateListRole(isListbox) {
+ threadTree.table.body.setAttribute("role", isListbox ? "listbox" : "tree");
+ if (isListbox) {
+ threadTree.table.header.classList.remove("threaded");
+ } else {
+ threadTree.table.header.classList.add("threaded");
+ }
+ },
+};
+
+var messagePane = {
+ async init() {
+ webBrowser = document.getElementById("webBrowser");
+ // Attach the progress listener for the webBrowser. For the messageBrowser this
+ // happens in the "aboutMessageLoaded" event from aboutMessage.js.
+ top.contentProgress.addProgressListenerToBrowser(webBrowser);
+
+ messageBrowser = document.getElementById("messageBrowser");
+ messageBrowser.docShell.allowDNSPrefetch = false;
+
+ multiMessageBrowser = document.getElementById("multiMessageBrowser");
+ multiMessageBrowser.docShell.allowDNSPrefetch = false;
+
+ if (messageBrowser.contentDocument.readyState != "complete") {
+ await new Promise(resolve => {
+ messageBrowser.addEventListener("load", () => resolve(), {
+ capture: true,
+ once: true,
+ });
+ });
+ }
+
+ if (multiMessageBrowser.contentDocument.readyState != "complete") {
+ await new Promise(resolve => {
+ multiMessageBrowser.addEventListener("load", () => resolve(), {
+ capture: true,
+ once: true,
+ });
+ });
+ }
+ },
+
+ /**
+ * Ensure all message pane browsers are blank.
+ */
+ clearAll() {
+ this.clearWebPage();
+ this.clearMessage();
+ this.clearMessages();
+ },
+
+ /**
+ * Ensure the web page browser is blank, unless the start page is shown.
+ */
+ clearWebPage() {
+ if (!this._keepStartPageOpen) {
+ webBrowser.hidden = true;
+ MailE10SUtils.loadAboutBlank(webBrowser);
+ }
+ },
+
+ /**
+ * Display a web page in the web page browser. If `url` is not given, or is
+ * "about:blank", the web page browser is cleared and hidden.
+ *
+ * @param {string} url - The URL to load.
+ * @param {object} [params] - Any params to pass to MailE10SUtils.loadURI.
+ */
+ displayWebPage(url, params) {
+ if (!paneLayout.messagePaneVisible) {
+ return;
+ }
+ if (!url || url == "about:blank") {
+ this._keepStartPageOpen = false;
+ this.clearWebPage();
+ return;
+ }
+
+ this.clearMessage();
+ this.clearMessages();
+
+ MailE10SUtils.loadURI(webBrowser, url, params);
+ webBrowser.hidden = false;
+ },
+
+ /**
+ * Ensure the message browser is not displaying a message.
+ */
+ clearMessage() {
+ messageBrowser.hidden = true;
+ messageBrowser.contentWindow.displayMessage();
+ },
+
+ /**
+ * Display a single message in the message browser. If `messageURI` is not
+ * given, the message browser is cleared and hidden.
+ *
+ * @param {string} messageURI
+ */
+ displayMessage(messageURI) {
+ if (!paneLayout.messagePaneVisible) {
+ return;
+ }
+ if (!messageURI) {
+ this.clearMessage();
+ return;
+ }
+
+ this._keepStartPageOpen = false;
+ messagePane.clearWebPage();
+ messagePane.clearMessages();
+
+ messageBrowser.contentWindow.displayMessage(messageURI, gViewWrapper);
+ messageBrowser.hidden = false;
+ },
+
+ /**
+ * Ensure the multi-message browser is not displaying messages.
+ */
+ clearMessages() {
+ multiMessageBrowser.hidden = true;
+ multiMessageBrowser.contentWindow.gMessageSummary.clear();
+ },
+
+ /**
+ * Display messages in the multi-message browser. For a single message, use
+ * `displayMessage` instead. If `messages` is not given, or an empty array,
+ * the multi-message browser is cleared and hidden.
+ *
+ * @param {nsIMsgDBHdr[]} messages
+ */
+ displayMessages(messages = []) {
+ if (!paneLayout.messagePaneVisible) {
+ return;
+ }
+ if (messages.length == 0) {
+ this.clearMessages();
+ return;
+ }
+
+ this._keepStartPageOpen = false;
+ messagePane.clearWebPage();
+ messagePane.clearMessage();
+
+ let getThreadId = function (message) {
+ return gDBView.getThreadContainingMsgHdr(message).getRootHdr().messageKey;
+ };
+
+ let oneThread = true;
+ let firstThreadId = getThreadId(messages[0]);
+ for (let i = 1; i < messages.length; i++) {
+ if (getThreadId(messages[i]) != firstThreadId) {
+ oneThread = false;
+ break;
+ }
+ }
+
+ multiMessageBrowser.contentWindow.gMessageSummary.summarize(
+ oneThread ? "thread" : "multipleselection",
+ messages,
+ gDBView,
+ function (messages) {
+ threadTree.selectedIndices = messages
+ .map(m => gDBView.findIndexOfMsgHdr(m, true))
+ .filter(i => i != nsMsgViewIndex_None);
+ }
+ );
+
+ multiMessageBrowser.hidden = false;
+ window.dispatchEvent(new CustomEvent("MsgsLoaded", { bubbles: true }));
+ },
+
+ /**
+ * Show the start page in the web page browser. The start page will remain
+ * shown until a message is displayed.
+ */
+ showStartPage() {
+ this._keepStartPageOpen = true;
+ let url = Services.urlFormatter.formatURLPref("mailnews.start_page.url");
+ if (/^mailbox:|^imap:|^pop:|^s?news:|^nntp:/i.test(url)) {
+ console.warn(`Can't use ${url} as mailnews.start_page.url`);
+ Services.prefs.clearUserPref("mailnews.start_page.url");
+ url = Services.urlFormatter.formatURLPref("mailnews.start_page.url");
+ }
+ messagePane.displayWebPage(url);
+ },
+};
+
+function restoreState({
+ folderPaneVisible,
+ messagePaneVisible,
+ folderURI,
+ syntheticView,
+ first = false,
+ title = null,
+} = {}) {
+ if (folderPaneVisible === undefined) {
+ folderPaneVisible = folderURI || !syntheticView;
+ }
+ paneLayout.folderPaneSplitter.isCollapsed = !folderPaneVisible;
+ paneLayout.folderPaneSplitter.isDisabled = syntheticView;
+
+ if (messagePaneVisible === undefined) {
+ messagePaneVisible =
+ Services.xulStore.getValue(
+ XULSTORE_URL,
+ "messagepaneboxwrapper",
+ "collapsed"
+ ) !== "true";
+ }
+ paneLayout.messagePaneSplitter.isCollapsed = !messagePaneVisible;
+
+ if (folderURI) {
+ displayFolder(folderURI);
+ } else if (syntheticView) {
+ // In a synthetic view check if we have a previously edited column layout to
+ // restore.
+ if ("getPersistedSetting" in syntheticView) {
+ let columnsState = syntheticView.getPersistedSetting("columns");
+ if (!columnsState) {
+ threadPane.restoreDefaultColumns();
+ return;
+ }
+
+ threadPane.applyPersistedColumnsState(columnsState);
+ threadPane.updateColumns();
+ } else {
+ // Otherwise restore the default synthetic columns.
+ threadPane.restoreDefaultColumns();
+ }
+
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+ gViewWrapper.openSynthetic(syntheticView);
+ gDBView = gViewWrapper.dbView;
+
+ if ("selectedMessage" in syntheticView) {
+ threadTree.selectedIndex = gDBView.findIndexOfMsgHdr(
+ syntheticView.selectedMessage,
+ true
+ );
+ } else {
+ // So that nsMsgSearchDBView::GetHdrForFirstSelectedMessage works from
+ // the beginning.
+ threadTree.currentIndex = 0;
+ }
+
+ document.title = title;
+ document.body.classList.remove("account-central");
+ accountCentralBrowser.hidden = true;
+ threadPaneHeader.onFolderSelected();
+ }
+
+ if (
+ first &&
+ messagePaneVisible &&
+ Services.prefs.getBoolPref("mailnews.start_page.enabled")
+ ) {
+ messagePane.showStartPage();
+ }
+}
+
+/**
+ * Set up the given folder to be selected in the folder pane.
+ * @param {nsIMsgFolder|string} folder - The folder to display, or its URI.
+ */
+function displayFolder(folder) {
+ let folderURI = folder instanceof Ci.nsIMsgFolder ? folder.URI : folder;
+ if (folderTree.selectedRow?.uri == folderURI) {
+ // Already set to display the right folder. Make sure not not to change
+ // to the same folder in a different folder mode.
+ return;
+ }
+
+ let row = folderPane.getRowForFolder(folderURI);
+ if (!row) {
+ return;
+ }
+
+ let collapsedAncestor = row.parentNode.closest("#folderTree li.collapsed");
+ while (collapsedAncestor) {
+ folderTree.expandRow(collapsedAncestor);
+ collapsedAncestor = collapsedAncestor.parentNode.closest(
+ "#folderTree li.collapsed"
+ );
+ }
+ folderTree.selectedRow = row;
+}
+
+/**
+ * Update the thread pane selection if it doesn't already match `msgHdr`.
+ * The selected folder will be changed if necessary. If the selection
+ * changes, the message pane will also be updated (via a "select" event).
+ *
+ * @param {nsIMsgDBHdr} msgHdr
+ */
+function selectMessage(msgHdr) {
+ if (
+ gDBView?.numSelected == 1 &&
+ gDBView.hdrForFirstSelectedMessage == msgHdr
+ ) {
+ return;
+ }
+
+ let index = threadTree.view?.findIndexOfMsgHdr(msgHdr, true);
+ // Change to correct folder if needed. We might not be in a folder, or the
+ // message might not be found in the current folder.
+ if (index === undefined || index === nsMsgViewIndex_None) {
+ threadPane.forgetSelection(msgHdr.folder.URI);
+ displayFolder(msgHdr.folder.URI);
+ index = threadTree.view.findIndexOfMsgHdr(msgHdr, true);
+ threadTree.scrollToIndex(index, true);
+ }
+ threadTree.selectedIndex = index;
+}
+
+var folderListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFolderListener"]),
+ onFolderAdded(parentFolder, childFolder) {
+ folderPane.addFolder(parentFolder, childFolder);
+ folderPane.updateFolderRowUIElements();
+ },
+ onMessageAdded(parentFolder, msg) {},
+ onFolderRemoved(parentFolder, childFolder) {
+ folderPane.removeFolder(parentFolder, childFolder);
+ if (childFolder == gFolder) {
+ gFolder = null;
+ gViewWrapper?.close(true);
+ }
+ },
+ onMessageRemoved(parentFolder, msg) {},
+ onFolderPropertyChanged(folder, property, oldValue, newValue) {},
+ onFolderIntPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "BiffState":
+ folderPane.changeNewMessages(
+ folder,
+ newValue === Ci.nsIMsgFolder.nsMsgBiffState_NewMail
+ );
+ break;
+ case "FolderFlag":
+ folderPane.changeFolderFlag(folder, oldValue, newValue);
+ break;
+ case "FolderSize":
+ folderPane.changeFolderSize(folder);
+ break;
+ case "TotalUnreadMessages":
+ if (oldValue == newValue) {
+ break;
+ }
+ folderPane.changeUnreadCount(folder, newValue);
+ break;
+ case "TotalMessages":
+ if (oldValue == newValue) {
+ break;
+ }
+ folderPane.changeTotalCount(folder, newValue);
+ threadPaneHeader.updateFolderCount(folder, newValue);
+ break;
+ }
+ },
+ onFolderBoolPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "isDeferred":
+ if (newValue) {
+ folderPane.removeFolder(null, folder);
+ } else {
+ folderPane.addFolder(null, folder);
+ for (let f of folder.descendants) {
+ folderPane.addFolder(f.parent, f);
+ }
+ }
+ break;
+ case "NewMessages":
+ folderPane.changeNewMessages(folder, newValue);
+ break;
+ }
+ },
+ onFolderUnicharPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "Name":
+ if (folder.isServer) {
+ folderPane.changeServerName(folder, newValue);
+ }
+ break;
+ }
+ },
+ onFolderPropertyFlagChanged(folder, property, oldFlag, newFlag) {},
+ onFolderEvent(folder, event) {
+ if (event == "RenameCompleted") {
+ // If a folder is renamed, we get an `onFolderAdded` notification for
+ // the folder but we are not notified about the descendants.
+ for (let f of folder.descendants) {
+ folderPane.addFolder(f.parent, f);
+ }
+ }
+ },
+};
+
+/**
+ * Custom element for rows in the thread tree.
+ */
+customElements.whenDefined("tree-view-table-row").then(() => {
+ class ThreadRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 22;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+ this.appendChild(threadPane.rowTemplate.content.cloneNode(true));
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+
+ let textColumns = [];
+ for (let column of threadPane.columns) {
+ // No need to update the text of this cell if it's hidden, the selection
+ // column, or an icon column that doesn't match a specific flag.
+ if (column.hidden || column.icon || column.select) {
+ continue;
+ }
+ textColumns.push(column.id);
+ }
+
+ // XPCOM calls here must be keep to a minimum. Collect all of the
+ // required data in one go.
+ let properties = {};
+ let threadLevel = {};
+ let cellTexts = this.view.cellDataForColumns(
+ index,
+ textColumns,
+ properties,
+ threadLevel
+ );
+
+ // Collect the various strings and fluent IDs to build the full string for
+ // the message row aria-label.
+ let ariaLabelPromises = [];
+
+ const propertiesSet = new Set(properties.value.split(" "));
+ const isDummyRow = propertiesSet.has("dummy");
+
+ this.dataset.properties = properties.value.trim();
+
+ for (let column of threadPane.columns) {
+ // Skip this column if it's hidden or it's the "select" column, since
+ // the selection state is communicated via the aria-activedescendant.
+ if (column.hidden || column.select) {
+ continue;
+ }
+ let cell = this.querySelector(`.${column.id.toLowerCase()}-column`);
+ let textIndex = textColumns.indexOf(column.id);
+
+ // Special case for the subject column.
+ if (column.id == "subjectCol") {
+ const div = cell.querySelector(".subject-line");
+
+ // Indent child message of this thread.
+ div.style.setProperty(
+ "--thread-level",
+ gViewWrapper.showGroupedBySort ? 0 : threadLevel.value
+ );
+
+ let imageFluentID = this.#getMessageIndicatorString(propertiesSet);
+ const image = div.querySelector("img");
+ if (imageFluentID && !isDummyRow) {
+ document.l10n.setAttributes(image, imageFluentID);
+ } else {
+ image.removeAttribute("data-l10n-id");
+ image.alt = "";
+ }
+
+ const span = div.querySelector("span");
+ cell.title = span.textContent = cellTexts[textIndex];
+ ariaLabelPromises.push(cellTexts[textIndex]);
+ continue;
+ }
+
+ if (column.id == "threadCol") {
+ let buttonL10nId, labelString;
+ if (propertiesSet.has("ignore")) {
+ buttonL10nId = "tree-list-view-row-ignored-thread-button";
+ labelString = "tree-list-view-row-ignored-thread";
+ } else if (propertiesSet.has("ignoreSubthread")) {
+ buttonL10nId = "tree-list-view-row-ignored-subthread-button";
+ labelString = "tree-list-view-row-ignored-subthread";
+ } else if (propertiesSet.has("watch")) {
+ buttonL10nId = "tree-list-view-row-watched-thread-button";
+ labelString = "tree-list-view-row-watched-thread";
+ } else if (this.classList.contains("children")) {
+ buttonL10nId = "tree-list-view-row-thread-button";
+ }
+
+ let button = cell.querySelector("button");
+ if (buttonL10nId) {
+ document.l10n.setAttributes(button, buttonL10nId);
+ }
+ if (labelString) {
+ ariaLabelPromises.push(document.l10n.formatValue(labelString));
+ }
+ continue;
+ }
+
+ if (column.id == "flaggedCol") {
+ let button = cell.querySelector("button");
+ if (propertiesSet.has("flagged")) {
+ document.l10n.setAttributes(button, "tree-list-view-row-flagged");
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-flagged-cell-label")
+ );
+ } else {
+ document.l10n.setAttributes(button, "tree-list-view-row-flag");
+ }
+ continue;
+ }
+
+ if (column.id == "junkStatusCol") {
+ let button = cell.querySelector("button");
+ if (propertiesSet.has("junk")) {
+ document.l10n.setAttributes(button, "tree-list-view-row-spam");
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-spam-cell-label")
+ );
+ } else {
+ document.l10n.setAttributes(button, "tree-list-view-row-not-spam");
+ }
+ continue;
+ }
+
+ if (column.id == "unreadButtonColHeader") {
+ let button = cell.querySelector("button");
+ if (propertiesSet.has("read")) {
+ document.l10n.setAttributes(button, "tree-list-view-row-read");
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-read-cell-label")
+ );
+ } else {
+ document.l10n.setAttributes(button, "tree-list-view-row-not-read");
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-unread-cell-label")
+ );
+ }
+ continue;
+ }
+
+ if (column.id == "attachmentCol" && propertiesSet.has("attach")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-attachments-cell-label")
+ );
+ continue;
+ }
+
+ if (textIndex >= 0) {
+ if (isDummyRow) {
+ cell.textContent = "";
+ continue;
+ }
+ cell.textContent = cellTexts[textIndex];
+ ariaLabelPromises.push(cellTexts[textIndex]);
+ }
+ }
+
+ Promise.allSettled(ariaLabelPromises).then(results => {
+ this.setAttribute(
+ "aria-label",
+ results
+ .map(settledPromise => settledPromise.value ?? "")
+ .filter(value => value.trim() != "")
+ .join(", ")
+ );
+ });
+ }
+
+ /**
+ * Find the fluent ID matching the current message state.
+ *
+ * @param {Set} propertiesSet - The Set() of properties for the row.
+ * @returns {?string} - The fluent ID string if we found one, otherwise null.
+ */
+ #getMessageIndicatorString(propertiesSet) {
+ // Bail out early if this is a new message since it can't be anything else.
+ if (propertiesSet.has("new")) {
+ return "threadpane-message-new";
+ }
+
+ const isReplied = propertiesSet.has("replied");
+ const isForwarded = propertiesSet.has("forwarded");
+ const isRedirected = propertiesSet.has("redirected");
+
+ if (isReplied && !isForwarded && !isRedirected) {
+ return "threadpane-message-replied";
+ }
+
+ if (isRedirected && !isForwarded && !isReplied) {
+ return "threadpane-message-redirected";
+ }
+
+ if (isForwarded && !isReplied && !isRedirected) {
+ return "threadpane-message-forwarded";
+ }
+
+ if (isReplied && isForwarded && !isRedirected) {
+ return "threadpane-message-replied-forwarded";
+ }
+
+ if (isReplied && isRedirected && !isForwarded) {
+ return "threadpane-message-replied-redirected";
+ }
+
+ if (isForwarded && isRedirected && !isReplied) {
+ return "threadpane-message-forwarded-redirected";
+ }
+
+ if (isReplied && isForwarded && isRedirected) {
+ return "threadpane-message-replied-forwarded-redirected";
+ }
+
+ return null;
+ }
+ }
+ customElements.define("thread-row", ThreadRow, { extends: "tr" });
+
+ class ThreadCard extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 46;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+
+ this.appendChild(
+ document
+ .getElementById("threadPaneCardTemplate")
+ .content.cloneNode(true)
+ );
+
+ this.senderLine = this.querySelector(".sender");
+ this.subjectLine = this.querySelector(".subject");
+ this.dateLine = this.querySelector(".date");
+ this.starButton = this.querySelector(".button-star");
+ this.tagIcon = this.querySelector(".tag-icon");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+
+ // XPCOM calls here must be keep to a minimum. Collect all of the
+ // required data in one go.
+ let properties = {};
+ let threadLevel = {};
+
+ let cellTexts = this.view.cellDataForColumns(
+ index,
+ threadPane.cardColumns,
+ properties,
+ threadLevel
+ );
+
+ // Collect the various strings and fluent IDs to build the full string for
+ // the message row aria-label.
+ let ariaLabelPromises = [];
+
+ if (threadLevel.value) {
+ properties.value += " thread-children";
+ }
+ const propertiesSet = new Set(properties.value.split(" "));
+ this.dataset.properties = properties.value.trim();
+
+ this.subjectLine.textContent = cellTexts[0];
+ this.subjectLine.title = cellTexts[0];
+ this.senderLine.textContent = cellTexts[1];
+ this.dateLine.textContent = cellTexts[2];
+ this.tagIcon.title = cellTexts[3];
+
+ // Follow the layout order.
+ ariaLabelPromises.push(cellTexts[1]);
+ ariaLabelPromises.push(cellTexts[2]);
+ ariaLabelPromises.push(cellTexts[0]);
+ ariaLabelPromises.push(cellTexts[3]);
+
+ if (propertiesSet.has("flagged")) {
+ document.l10n.setAttributes(
+ this.starButton,
+ "tree-list-view-row-flagged"
+ );
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-flagged-cell-label")
+ );
+ } else {
+ document.l10n.setAttributes(this.starButton, "tree-list-view-row-flag");
+ }
+
+ if (propertiesSet.has("junk")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-spam-cell-label")
+ );
+ }
+
+ if (propertiesSet.has("read")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-read-cell-label")
+ );
+ }
+
+ if (propertiesSet.has("unread")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-unread-cell-label")
+ );
+ }
+
+ if (propertiesSet.has("attach")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-attachments-cell-label")
+ );
+ }
+
+ Promise.allSettled(ariaLabelPromises).then(results => {
+ this.setAttribute(
+ "aria-label",
+ results
+ .map(settledPromise => settledPromise.value ?? "")
+ .filter(value => value.trim() != "")
+ .join(", ")
+ );
+ });
+ }
+ }
+ customElements.define("thread-card", ThreadCard, {
+ extends: "tr",
+ });
+});
+
+commandController.registerCallback(
+ "cmd_newFolder",
+ (folder = gFolder) => folderPane.newFolder(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_newFolder")
+);
+commandController.registerCallback("cmd_newVirtualFolder", (folder = gFolder) =>
+ folderPane.newVirtualFolder(undefined, undefined, folder)
+);
+commandController.registerCallback(
+ "cmd_deleteFolder",
+ (folder = gFolder) => folderPane.deleteFolder(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_deleteFolder")
+);
+commandController.registerCallback(
+ "cmd_renameFolder",
+ (folder = gFolder) => folderPane.renameFolder(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_renameFolder")
+);
+commandController.registerCallback(
+ "cmd_compactFolder",
+ (folder = gFolder) => {
+ if (folder.isServer) {
+ folderPane.compactAllFoldersForAccount(folder);
+ } else {
+ folderPane.compactFolder(folder);
+ }
+ },
+ () => folderPaneContextMenu.getCommandState("cmd_compactFolder")
+);
+commandController.registerCallback(
+ "cmd_emptyTrash",
+ (folder = gFolder) => folderPane.emptyTrash(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_emptyTrash")
+);
+commandController.registerCallback(
+ "cmd_properties",
+ (folder = gFolder) => folderPane.editFolder(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_properties")
+);
+commandController.registerCallback(
+ "cmd_toggleFavoriteFolder",
+ (folder = gFolder) => folder.toggleFlag(Ci.nsMsgFolderFlags.Favorite),
+ () => folderPaneContextMenu.getCommandState("cmd_toggleFavoriteFolder")
+);
+
+// Delete commands, which change behaviour based on the active element.
+// Note that `document.activeElement` refers to the active element in *this*
+// document regardless of whether this document is the active one.
+commandController.registerCallback(
+ "cmd_delete",
+ () => {
+ if (document.activeElement == folderTree) {
+ commandController.doCommand("cmd_deleteFolder");
+ } else if (!quickFilterBar.domNode.contains(document.activeElement)) {
+ commandController.doCommand("cmd_deleteMessage");
+ }
+ },
+ () => {
+ if (document.activeElement == folderTree) {
+ return commandController.isCommandEnabled("cmd_deleteFolder");
+ }
+ if (
+ !quickFilterBar?.domNode ||
+ quickFilterBar.domNode.contains(document.activeElement)
+ ) {
+ return false;
+ }
+ return commandController.isCommandEnabled("cmd_deleteMessage");
+ }
+);
+commandController.registerCallback(
+ "cmd_shiftDelete",
+ () => {
+ commandController.doCommand("cmd_shiftDeleteMessage");
+ },
+ () => {
+ if (
+ document.activeElement == folderTree ||
+ !quickFilterBar?.domNode ||
+ quickFilterBar.domNode.contains(document.activeElement)
+ ) {
+ return false;
+ }
+ return commandController.isCommandEnabled("cmd_shiftDeleteMessage");
+ }
+);
+
+commandController.registerCallback("cmd_viewClassicMailLayout", () =>
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 0)
+);
+commandController.registerCallback("cmd_viewWideMailLayout", () =>
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 1)
+);
+commandController.registerCallback("cmd_viewVerticalMailLayout", () =>
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 2)
+);
+commandController.registerCallback(
+ "cmd_toggleThreadPaneHeader",
+ () => threadPaneHeader.toggleThreadPaneHeader(),
+ () => gFolder && !gFolder.isServer
+);
+commandController.registerCallback(
+ "cmd_toggleFolderPane",
+ () => paneLayout.folderPaneSplitter.toggleCollapsed(),
+ () => !!gFolder
+);
+commandController.registerCallback("cmd_toggleMessagePane", () => {
+ paneLayout.messagePaneSplitter.toggleCollapsed();
+});
+
+commandController.registerCallback(
+ "cmd_selectAll",
+ () => {
+ threadTree.selectAll();
+ threadTree.table.body.focus();
+ },
+ () => !!gViewWrapper?.dbView
+);
+commandController.registerCallback(
+ "cmd_selectThread",
+ () => gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.selectThread),
+ () => !!gViewWrapper?.dbView
+);
+commandController.registerCallback(
+ "cmd_selectFlagged",
+ () => gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.selectFlagged),
+ () => !!gViewWrapper?.dbView
+);
+commandController.registerCallback(
+ "cmd_downloadFlagged",
+ () =>
+ gViewWrapper.dbView.doCommand(
+ Ci.nsMsgViewCommandType.downloadFlaggedForOffline
+ ),
+ () => gFolder && !gFolder.isServer && MailOfflineMgr.isOnline()
+);
+commandController.registerCallback(
+ "cmd_downloadSelected",
+ () =>
+ gViewWrapper.dbView.doCommand(
+ Ci.nsMsgViewCommandType.downloadSelectedForOffline
+ ),
+ () =>
+ gFolder &&
+ !gFolder.isServer &&
+ MailOfflineMgr.isOnline() &&
+ gViewWrapper.dbView.selectedCount > 0
+);
+
+var sortController = {
+ handleCommand(event) {
+ switch (event.target.value) {
+ case "ascending":
+ this.sortAscending();
+ threadPane.restoreSortIndicator();
+ break;
+ case "descending":
+ this.sortDescending();
+ threadPane.restoreSortIndicator();
+ break;
+ case "threaded":
+ this.sortThreaded();
+ break;
+ case "unthreaded":
+ this.sortUnthreaded();
+ break;
+ case "group":
+ this.groupBySort();
+ break;
+ default:
+ if (event.target.value in Ci.nsMsgViewSortType) {
+ this.sortThreadPane(event.target.value);
+ threadPane.restoreSortIndicator();
+ }
+ break;
+ }
+ },
+ sortByThread() {
+ threadPane.updateListRole(false);
+ gViewWrapper.showThreaded = true;
+ this.sortThreadPane("byDate");
+ },
+ sortThreadPane(sortName) {
+ let sortType = Ci.nsMsgViewSortType[sortName];
+ let grouped = gViewWrapper.showGroupedBySort;
+ gViewWrapper._threadExpandAll = Boolean(
+ gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ if (!grouped) {
+ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
+ gViewWrapper.sort(sortType, Ci.nsMsgViewSortOrder.ascending);
+ threadTree.style.scrollBehavior = null;
+ // Respect user's last expandAll/collapseAll choice, post sort direction change.
+ threadPane.restoreThreadState();
+ return;
+ }
+
+ // legacy behavior dictates we un-group-by-sort if we were. this probably
+ // deserves a UX call...
+
+ // For non virtual folders, do not ungroup (which sorts by the going away
+ // sort) and then sort, as it's a double sort.
+ // For virtual folders, which are rebuilt in the backend in a grouped
+ // change, create a new view upfront rather than applying viewFlags. There
+ // are oddities just applying viewFlags, for example changing out of a
+ // custom column grouped xfvf view with the threads collapsed works (doesn't)
+ // differently than other variations.
+ // So, first set the desired sortType and sortOrder, then set viewFlags in
+ // batch mode, then apply it all (open a new view) with endViewUpdate().
+ gViewWrapper.beginViewUpdate();
+ gViewWrapper._sort = [[sortType, Ci.nsMsgViewSortOrder.ascending]];
+ gViewWrapper.showGroupedBySort = false;
+ gViewWrapper.endViewUpdate();
+
+ // Virtual folders don't persist viewFlags well in the back end,
+ // due to a virtual folder being either 'real' or synthetic, so make
+ // sure it's done here.
+ if (gViewWrapper.isVirtual) {
+ gViewWrapper.dbView.viewFlags = gViewWrapper.viewFlags;
+ }
+ },
+ reverseSortThreadPane() {
+ let grouped = gViewWrapper.showGroupedBySort;
+ gViewWrapper._threadExpandAll = Boolean(
+ gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ // Grouped By view is special for column click sort direction changes.
+ if (grouped) {
+ if (gDBView.selection.count) {
+ threadPane.saveSelection();
+ }
+
+ if (gViewWrapper.isSingleFolder) {
+ if (gViewWrapper.isVirtual) {
+ gViewWrapper.showGroupedBySort = false;
+ } else {
+ // Must ensure rows are collapsed and kExpandAll is unset.
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ }
+ }
+ }
+
+ if (gViewWrapper.isSortedAscending) {
+ gViewWrapper.sortDescending();
+ } else {
+ gViewWrapper.sortAscending();
+ }
+
+ // Restore Grouped By state post sort direction change.
+ if (grouped) {
+ if (gViewWrapper.isVirtual && gViewWrapper.isSingleFolder) {
+ this.groupBySort();
+ }
+ // Restore Grouped By selection post sort direction change.
+ threadPane.restoreSelection();
+ // Refresh dummy rows in case of collapseAll.
+ threadTree.invalidate();
+ }
+ threadPane.restoreThreadState();
+ },
+ toggleThreaded() {
+ if (gViewWrapper.showThreaded) {
+ threadPane.updateListRole(true);
+ gViewWrapper.showUnthreaded = true;
+ } else {
+ threadPane.updateListRole(false);
+ gViewWrapper.showThreaded = true;
+ }
+ },
+ sortThreaded() {
+ threadPane.updateListRole(false);
+ gViewWrapper.showThreaded = true;
+ },
+ groupBySort() {
+ threadPane.updateListRole(false);
+ gViewWrapper.showGroupedBySort = true;
+ },
+ sortUnthreaded() {
+ threadPane.updateListRole(true);
+ gViewWrapper.showUnthreaded = true;
+ },
+ sortAscending() {
+ if (gViewWrapper.showGroupedBySort && gViewWrapper.isSingleFolder) {
+ if (gViewWrapper.isSortedDescending) {
+ this.reverseSortThreadPane();
+ }
+ return;
+ }
+
+ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
+ gViewWrapper.sortAscending();
+ threadPane.ensureThreadStateForQuickSearchView();
+ threadTree.style.scrollBehavior = null;
+ },
+ sortDescending() {
+ if (gViewWrapper.showGroupedBySort && gViewWrapper.isSingleFolder) {
+ if (gViewWrapper.isSortedAscending) {
+ this.reverseSortThreadPane();
+ }
+ return;
+ }
+
+ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
+ gViewWrapper.sortDescending();
+ threadPane.ensureThreadStateForQuickSearchView();
+ threadTree.style.scrollBehavior = null;
+ },
+ convertSortTypeToColumnID(sortKey) {
+ let columnID;
+
+ // Hack to turn this into an integer, if it was a string.
+ // It would be a string if it came from XULStore.json.
+ sortKey = sortKey - 0;
+
+ switch (sortKey) {
+ // In the case of None, we default to the date column. This appears to be
+ // the case in such instances as Global search, so don't complain about
+ // it.
+ case Ci.nsMsgViewSortType.byNone:
+ case Ci.nsMsgViewSortType.byDate:
+ columnID = "dateCol";
+ break;
+ case Ci.nsMsgViewSortType.byReceived:
+ columnID = "receivedCol";
+ break;
+ case Ci.nsMsgViewSortType.byAuthor:
+ columnID = "senderCol";
+ break;
+ case Ci.nsMsgViewSortType.byRecipient:
+ columnID = "recipientCol";
+ break;
+ case Ci.nsMsgViewSortType.bySubject:
+ columnID = "subjectCol";
+ break;
+ case Ci.nsMsgViewSortType.byLocation:
+ columnID = "locationCol";
+ break;
+ case Ci.nsMsgViewSortType.byAccount:
+ columnID = "accountCol";
+ break;
+ case Ci.nsMsgViewSortType.byUnread:
+ columnID = "unreadButtonColHeader";
+ break;
+ case Ci.nsMsgViewSortType.byStatus:
+ columnID = "statusCol";
+ break;
+ case Ci.nsMsgViewSortType.byTags:
+ columnID = "tagsCol";
+ break;
+ case Ci.nsMsgViewSortType.bySize:
+ columnID = "sizeCol";
+ break;
+ case Ci.nsMsgViewSortType.byPriority:
+ columnID = "priorityCol";
+ break;
+ case Ci.nsMsgViewSortType.byFlagged:
+ columnID = "flaggedCol";
+ break;
+ case Ci.nsMsgViewSortType.byThread:
+ columnID = "threadCol";
+ break;
+ case Ci.nsMsgViewSortType.byId:
+ columnID = "idCol";
+ break;
+ case Ci.nsMsgViewSortType.byJunkStatus:
+ columnID = "junkStatusCol";
+ break;
+ case Ci.nsMsgViewSortType.byAttachments:
+ columnID = "attachmentCol";
+ break;
+ case Ci.nsMsgViewSortType.byCustom:
+ // TODO: either change try() catch to if (property exists) or restore
+ // the getColumnHandler() check.
+ try {
+ // getColumnHandler throws an error when the ID is not handled
+ columnID = gDBView.curCustomColumn;
+ } catch (e) {
+ // error - means no handler
+ dump(
+ "ConvertSortTypeToColumnID: custom sort key but no handler for column '" +
+ columnID +
+ "'\n"
+ );
+ columnID = "dateCol";
+ }
+ break;
+ case Ci.nsMsgViewSortType.byCorrespondent:
+ columnID = "correspondentCol";
+ break;
+ default:
+ dump("unsupported sort key: " + sortKey + "\n");
+ columnID = "dateCol";
+ break;
+ }
+ return columnID;
+ },
+};
+
+commandController.registerCallback(
+ "cmd_sort",
+ event => sortController.handleCommand(event),
+ () => !!gViewWrapper?.dbView
+);
+
+commandController.registerCallback(
+ "cmd_expandAllThreads",
+ () => {
+ threadPane.saveSelection();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll);
+ gViewWrapper._threadExpandAll = true;
+ threadPane.restoreSelection();
+ },
+ () => !!gViewWrapper?.dbView
+);
+commandController.registerCallback(
+ "cmd_collapseAllThreads",
+ () => {
+ threadPane.saveSelection();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ gViewWrapper._threadExpandAll = false;
+ threadPane.restoreSelection({ expand: false });
+ },
+ () => !!gViewWrapper?.dbView
+);
+
+function SwitchView(command) {
+ // when switching thread views, we might be coming out of quick search
+ // or a message view.
+ // first set view picker to all
+ if (gViewWrapper.mailViewIndex != 0) {
+ // MailViewConstants.kViewItemAll
+ gViewWrapper.setMailView(0);
+ }
+
+ switch (command) {
+ // "All" threads and "Unread" threads don't change threading state
+ case "cmd_viewAllMsgs":
+ gViewWrapper.showUnreadOnly = false;
+ break;
+ case "cmd_viewUnreadMsgs":
+ gViewWrapper.showUnreadOnly = true;
+ break;
+ // "Threads with Unread" and "Watched Threads with Unread" force threading
+ case "cmd_viewWatchedThreadsWithUnread":
+ gViewWrapper.specialViewWatchedThreadsWithUnread = true;
+ break;
+ case "cmd_viewThreadsWithUnread":
+ gViewWrapper.specialViewThreadsWithUnread = true;
+ break;
+ // "Ignored Threads" toggles 'ignored' inclusion --
+ // but it also resets 'With Unread' views to 'All'
+ case "cmd_viewIgnoredThreads":
+ gViewWrapper.showIgnored = !gViewWrapper.showIgnored;
+ break;
+ }
+}
+
+commandController.registerCallback(
+ "cmd_viewAllMsgs",
+ () => SwitchView("cmd_viewAllMsgs"),
+ () => !!gDBView
+);
+commandController.registerCallback(
+ "cmd_viewThreadsWithUnread",
+ () => SwitchView("cmd_viewThreadsWithUnread"),
+ () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual)
+);
+commandController.registerCallback(
+ "cmd_viewWatchedThreadsWithUnread",
+ () => SwitchView("cmd_viewWatchedThreadsWithUnread"),
+ () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual)
+);
+commandController.registerCallback(
+ "cmd_viewUnreadMsgs",
+ () => SwitchView("cmd_viewUnreadMsgs"),
+ () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual)
+);
+commandController.registerCallback(
+ "cmd_viewIgnoredThreads",
+ () => SwitchView("cmd_viewIgnoredThreads"),
+ () => !!gDBView
+);
+
+commandController.registerCallback("cmd_goStartPage", () => {
+ // This is a user-triggered command, they must want to see the page, so show
+ // the message pane if it's hidden.
+ paneLayout.messagePaneSplitter.expand();
+ messagePane.showStartPage();
+});
+commandController.registerCallback(
+ "cmd_print",
+ async () => {
+ let PrintUtils = top.PrintUtils;
+ if (!webBrowser.hidden) {
+ PrintUtils.startPrintWindow(webBrowser.browsingContext);
+ return;
+ }
+ let uris = gViewWrapper.dbView.getURIsForSelection();
+ if (uris.length == 1) {
+ if (messageBrowser.hidden) {
+ // Load the only message in a hidden browser, then use the print preview UI.
+ let messageService = MailServices.messageServiceFromURI(uris[0]);
+ await PrintUtils.loadPrintBrowser(
+ messageService.getUrlForUri(uris[0]).spec
+ );
+ PrintUtils.startPrintWindow(
+ PrintUtils.printBrowser.browsingContext,
+ {}
+ );
+ } else {
+ PrintUtils.startPrintWindow(
+ messageBrowser.contentWindow.getMessagePaneBrowser().browsingContext,
+ {}
+ );
+ }
+ return;
+ }
+
+ // Multiple messages. Get the printer settings, then load the messages into
+ // a hidden browser and print them one at a time.
+ let ps = PrintUtils.getPrintSettings();
+ Cc["@mozilla.org/widget/printdialog-service;1"]
+ .getService(Ci.nsIPrintDialogService)
+ .showPrintDialog(window, false, ps);
+ if (ps.isCancelled) {
+ return;
+ }
+ ps.printSilent = true;
+
+ for (let uri of uris) {
+ let messageService = MailServices.messageServiceFromURI(uri);
+ await PrintUtils.loadPrintBrowser(messageService.getUrlForUri(uri).spec);
+ await PrintUtils.printBrowser.browsingContext.print(ps);
+ }
+ },
+ () => {
+ if (!accountCentralBrowser?.hidden) {
+ return false;
+ }
+ if (webBrowser && !webBrowser.hidden) {
+ return true;
+ }
+ return gDBView && gDBView.numSelected > 0;
+ }
+);
+commandController.registerCallback(
+ "cmd_recalculateJunkScore",
+ () => analyzeMessagesForJunk(),
+ () => {
+ // We're going to take a conservative position here, because we really
+ // don't want people running junk controls on folders that are not
+ // enabled for junk. The junk type picks up possible dummy message headers,
+ // while the runJunkControls will prevent running on XF virtual folders.
+ return (
+ commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.junk) &&
+ commandController._getViewCommandStatus(
+ Ci.nsMsgViewCommandType.runJunkControls
+ )
+ );
+ }
+);
+commandController.registerCallback(
+ "cmd_runJunkControls",
+ () => filterFolderForJunk(gFolder),
+ () =>
+ commandController._getViewCommandStatus(
+ Ci.nsMsgViewCommandType.runJunkControls
+ )
+);
+commandController.registerCallback(
+ "cmd_deleteJunk",
+ () => deleteJunkInFolder(gFolder),
+ () =>
+ commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.deleteJunk)
+);
+
+commandController.registerCallback(
+ "cmd_killThread",
+ () => {
+ threadPane.hideIgnoredMessageNotification();
+ if (!gFolder.msgDatabase.isIgnored(gDBView.keyForFirstSelectedMessage)) {
+ threadPane.showIgnoredMessageNotification(
+ gDBView.getSelectedMsgHdrs(),
+ false
+ );
+ }
+ commandController._navigate(Ci.nsMsgNavigationType.toggleThreadKilled);
+ // Invalidation should be unnecessary but the back end doesn't notify us
+ // properly and resists attempts to fix this.
+ threadTree.reset();
+ },
+ () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic)
+);
+commandController.registerCallback(
+ "cmd_killSubthread",
+ () => {
+ threadPane.hideIgnoredMessageNotification();
+ if (!gDBView.hdrForFirstSelectedMessage.isKilled) {
+ threadPane.showIgnoredMessageNotification(
+ gDBView.getSelectedMsgHdrs(),
+ true
+ );
+ }
+ commandController._navigate(Ci.nsMsgNavigationType.toggleSubthreadKilled);
+ // Invalidation should be unnecessary but the back end doesn't notify us
+ // properly and resists attempts to fix this.
+ threadTree.reset();
+ },
+ () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic)
+);
+
+// Forward these commands directly to about:message.
+commandController.registerCallback(
+ "cmd_find",
+ () =>
+ this.messageBrowser.contentWindow.commandController.doCommand("cmd_find"),
+ () => this.messageBrowser && !this.messageBrowser.hidden
+);
+commandController.registerCallback(
+ "cmd_findAgain",
+ () =>
+ this.messageBrowser.contentWindow.commandController.doCommand(
+ "cmd_findAgain"
+ ),
+ () => this.messageBrowser && !this.messageBrowser.hidden
+);
+commandController.registerCallback(
+ "cmd_findPrevious",
+ () =>
+ this.messageBrowser.contentWindow.commandController.doCommand(
+ "cmd_findPrevious"
+ ),
+ () => this.messageBrowser && !this.messageBrowser.hidden
+);
+
+/**
+ * Helper function for the zoom commands, which returns the browser that is
+ * currently visible in the message pane or null if no browser is visible.
+ *
+ * @returns {?XULElement} - A XUL browser or null.
+ */
+function visibleMessagePaneBrowser() {
+ if (webBrowser && !webBrowser.hidden) {
+ return webBrowser;
+ }
+
+ if (messageBrowser && !messageBrowser.hidden) {
+ // If the message browser is the one visible, actually return the
+ // element showing the message's content, since that's the one zoom
+ // commands should apply to.
+ return messageBrowser.contentDocument.getElementById("messagepane");
+ }
+
+ if (multiMessageBrowser && !multiMessageBrowser.hidden) {
+ return multiMessageBrowser;
+ }
+
+ return null;
+}
+
+// Zoom.
+commandController.registerCallback(
+ "cmd_fullZoomReduce",
+ () => top.ZoomManager.reduce(visibleMessagePaneBrowser()),
+ () => visibleMessagePaneBrowser() != null
+);
+commandController.registerCallback(
+ "cmd_fullZoomEnlarge",
+ () => top.ZoomManager.enlarge(visibleMessagePaneBrowser()),
+ () => visibleMessagePaneBrowser() != null
+);
+commandController.registerCallback(
+ "cmd_fullZoomReset",
+ () => top.ZoomManager.reset(visibleMessagePaneBrowser()),
+ () => visibleMessagePaneBrowser() != null
+);
+commandController.registerCallback(
+ "cmd_fullZoomToggle",
+ () => top.ZoomManager.toggleZoom(visibleMessagePaneBrowser()),
+ () => visibleMessagePaneBrowser() != null
+);
+
+// Browser commands.
+commandController.registerCallback(
+ "Browser:Back",
+ () => webBrowser.goBack(),
+ () => webBrowser?.canGoBack
+);
+commandController.registerCallback(
+ "Browser:Forward",
+ () => webBrowser.goForward(),
+ () => webBrowser?.canGoForward
+);
+commandController.registerCallback(
+ "cmd_reload",
+ () => webBrowser.reload(),
+ () => webBrowser && !webBrowser.busy
+);
+commandController.registerCallback(
+ "cmd_stop",
+ () => webBrowser.stop(),
+ () => webBrowser && webBrowser.busy
+);
+
+// Attachments commands.
+for (let command of [
+ "cmd_openAllAttachments",
+ "cmd_saveAllAttachments",
+ "cmd_detachAllAttachments",
+ "cmd_deleteAllAttachments",
+]) {
+ commandController.registerCallback(
+ command,
+ () => messageBrowser.contentWindow.commandController.doCommand(command),
+ () =>
+ messageBrowser &&
+ !messageBrowser.hidden &&
+ messageBrowser.contentWindow.commandController.isCommandEnabled(command)
+ );
+}
diff --git a/comm/mail/base/content/about3Pane.xhtml b/comm/mail/base/content/about3Pane.xhtml
new file mode 100644
index 0000000000..084a5ac7ad
--- /dev/null
+++ b/comm/mail/base/content/about3Pane.xhtml
@@ -0,0 +1,762 @@
+<?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/. -->
+
+#filter substitution
+
+<!DOCTYPE html [
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%messengerDTD;
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd">
+%calendarDTD;
+]>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ lightweightthemes="true">
+<head>
+ <meta charset="utf-8" />
+ <title></title>
+
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/folder.svg" />
+
+ <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/icons.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/colors.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/folderColors.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/folderMenus.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/quickFilterBar.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/searchBox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/about3Pane.css" />
+
+ <link rel="localization" href="messenger/about3Pane.ftl" />
+ <link rel="localization" href="messenger/treeView.ftl" />
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/junkCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgViewNavigation.js"></script>
+ <script defer="defer" src="chrome://messenger/content/quickFilterBar.js"></script>
+ <script defer="defer" src="chrome://messenger/content/pane-splitter.js"></script>
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+ <script defer="defer" type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailContext.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCommon.js"></script>
+ <script defer="defer" src="chrome://messenger/content/about3Pane.js"></script>
+</head>
+<body class="layout-classic">
+ <div id="folderPane" class="collapsed-by-splitter no-overscroll" tabindex="-1">
+ <div id="folderPaneHeaderBar" hidden="hidden">
+# Force a reverse tabindex to work alongside the `flex-direction: row-reverse`
+# in order to guarantee a consistent end alignment of the `#folderPaneMoreButton`.
+ <button id="folderPaneMoreButton"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="folder-pane-more-menu-button"
+ type="button"
+ tabindex="3"></button>
+ <button id="folderPaneWriteMessage"
+ class="button button-primary icon-button"
+ data-l10n-id="folder-pane-write-message-button"
+ type="button"
+ tabindex="2"
+ disabled="disabled"></button>
+ <button id="folderPaneGetMessages"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="folder-pane-get-messages-button"
+ type="button"
+ tabindex="1"
+ disabled="disabled"></button>
+ </div>
+ <ul id="folderTree" is="tree-listbox" role="tree"></ul>
+ <template id="modeTemplate">
+ <li class="unselectable">
+ <div class="mode-container">
+ <div class="mode-name"></div>
+ <button class="mode-button button button-flat icon-button icon-only"
+ type="button"
+ data-l10n-id="folder-pane-mode-context-button"
+ tabindex="-1"></button>
+ </div>
+ <ul></ul>
+ </li>
+ </template>
+ <template id="folderTemplate">
+ <div class="container">
+ <div class="twisty">
+ <img class="twisty-icon" src="chrome://global/skin/icons/arrow-down-12.svg" alt="" />
+ </div>
+ <div class="icon"></div>
+ <span class="name" tabindex="-1"></span>
+ <span class="folder-count-badge unread-count"></span>
+ <span class="folder-count-badge total-count" hidden="hidden"></span>
+ <span class="folder-size" hidden="hidden"></span>
+ </div>
+ <ul></ul>
+ </template>
+ </div>
+ <hr is="pane-splitter" id="folderPaneSplitter"
+ resize-direction="horizontal"
+ resize-id="folderPane"
+ collapse-width="100" />
+ <div id="threadPane">
+ <div id="threadPaneHeaderBar" class="list-header-bar">
+ <div class="list-header-bar-container-start"
+ role="region"
+ aria-live="off">
+ <h2 id="threadPaneFolderName" class="list-header-title"></h2>
+ <div id="threadPaneFolderCountContainer">
+ <span id="threadPaneFolderCount"
+ class="thread-pane-count-info"
+ hidden="hidden"></span>
+ <span id="threadPaneSelectedCount"
+ class="thread-pane-count-info"
+ hidden="hidden"></span>
+ </div>
+ </div>
+ <div class="list-header-bar-container-end">
+ <button id="threadPaneQuickFilterButton"
+ class="button icon-button check-button unified-toolbar-button"
+ data-l10n-id="quick-filter-button"
+ oncommand="cmd_toggleQuickFilterBar">
+ <span data-l10n-id="quick-filter-button-label"></span>
+ </button>
+ <button id="threadPaneDisplayButton"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="thread-pane-header-display-button"
+ type="button">
+ </button>
+ </div>
+ </div>
+#include quickFilterBar.inc.xhtml
+ <tree-view id="threadTree" data-label-id="threadPaneFolderName"/>
+ <!-- Thread pane templates -->
+ <template id="threadPaneApplyColumnMenu">
+ <xul:menu class="applyTo-menu"
+ data-l10n-id="apply-columns-to-menu"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <menupopup>
+ <menu class="applyToFolder-menu"
+ data-l10n-id="apply-current-view-to-folder"
+ oncommand="threadPane.confirmApplyColumns(event.target._folder);">
+ <menupopup is="folder-menupopup"
+ class="applyToFolder"
+ showFileHereLabel="false"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyToFolderAndChildren-menu"
+ data-l10n-id="apply-current-view-to-folder-children"
+ oncommand="threadPane.confirmApplyColumns(event.target._folder, true);">
+ <menupopup is="folder-menupopup"
+ class="applyToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </xul:menu>
+ </template>
+ <template id="threadPaneApplyViewMenu">
+ <xul:menu class="applyViewTo-menu"
+ data-l10n-id="apply-current-view-to-menu"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <menupopup>
+ <menu class="applyViewToFolder-menu"
+ data-l10n-id="apply-current-view-to-folder"
+ oncommand="threadPane.confirmApplyView(event.target._folder);">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolder"
+ showFileHereLabel="true"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyViewToFolderAndChildren-menu"
+ data-l10n-id="apply-current-view-to-folder-children"
+ oncommand="threadPane.confirmApplyView(event.target._folder, true);">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </xul:menu>
+ </template>
+ <template id="threadPaneRowTemplate">
+ <!-- This template must be kept in sync with thread-pane-columns.mjs. -->
+ <td class="selectcol-column" data-l10n-id="threadpane-cell-select"></td>
+ <td class="tree-view-row-thread threadcol-column button-column" data-l10n-id="threadpane-cell-thread">
+ <button type="button"
+ class="button-flat tree-button-thread"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </td>
+ <td class="tree-view-row-flag flaggedcol-column button-column" data-l10n-id="threadpane-cell-flagged">
+ <button type="button"
+ class="button-flat tree-button-flag"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </td>
+ <td class="attachmentcol-column button-column" data-l10n-id="threadpane-cell-attachments">
+ <img src="" data-l10n-id="tree-list-view-row-attach" />
+ </td>
+ <td class="subjectcol-column" data-l10n-id="threadpane-cell-subject">
+ <div class="thread-container">
+ <button type="button"
+ class="button button-flat button-reset twisty"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" class="twisty-icon" />
+ </button>
+ <div class="subject-line" tabindex="-1">
+ <img src="" alt="" /><span></span>
+ </div>
+ </div>
+ </td>
+ <td class="tree-view-row-unread unreadbuttoncolheader-column button-column" data-l10n-id="threadpane-cell-read-status">
+ <button type="button"
+ class="button-flat tree-button-unread"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </td>
+ <td class="sendercol-column" data-l10n-id="threadpane-cell-sender"></td>
+ <td class="recipientcol-column" data-l10n-id="threadpane-cell-recipient"></td>
+ <td class="correspondentcol-column" data-l10n-id="threadpane-cell-correspondents"></td>
+ <td class="tree-view-row-spam junkstatuscol-column button-column" data-l10n-id="threadpane-cell-spam">
+ <button type="button"
+ class="button-flat tree-button-spam"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </td>
+ <td class="datecol-column" data-l10n-id="threadpane-cell-date"></td>
+ <td class="receivedcol-column" data-l10n-id="threadpane-cell-received"></td>
+ <td class="statuscol-column" data-l10n-id="threadpane-cell-status"></td>
+ <td class="sizecol-column" data-l10n-id="threadpane-cell-size"></td>
+ <td class="tagscol-column" data-l10n-id="threadpane-cell-tags"></td>
+ <td class="accountcol-column" data-l10n-id="threadpane-cell-account"></td>
+ <td class="prioritycol-column" data-l10n-id="threadpane-cell-priority"></td>
+ <td class="unreadcol-column" data-l10n-id="threadpane-cell-unread"></td>
+ <td class="totalcol-column" data-l10n-id="threadpane-cell-total"></td>
+ <td class="locationcol-column" data-l10n-id="threadpane-cell-location"></td>
+ <td class="idcol-column" data-l10n-id="threadpane-cell-id"></td>
+ <td class="tree-view-row-delete deletecol-column button-column" data-l10n-id="threadpane-cell-delete">
+ <button type="button"
+ class="button-flat tree-button-delete tree-button-request-delete"
+ tabindex="-1"
+ aria-hidden="true"
+ data-l10n-id="tree-list-view-row-delete">
+ <img src="" alt="" />
+ </button>
+ <button type="button"
+ class="button-flat tree-button-restore tree-button-request-delete"
+ tabindex="-1"
+ aria-hidden="true"
+ data-l10n-id="tree-list-view-row-restore">
+ <img src="" alt="" />
+ </button>
+ </td>
+ </template>
+ <template id="threadPaneCardTemplate">
+ <td>
+ <div class="thread-card-container">
+ <div class="thread-card-row">
+ <span class="sender"></span>
+ <img class="state replied" src="" data-l10n-id="threadpane-message-replied" />
+ <img class="state forwarded" src="" data-l10n-id="threadpane-message-forwarded" />
+ <img class="state redirected" src="" data-l10n-id="threadpane-message-redirected" />
+ <button class="button-spam tree-button-spam"
+ data-l10n-id="tree-list-view-row-spam"
+ aria-hidden="true"
+ tabindex="-1">
+ </button>
+ <span class="date"></span>
+ </div>
+ <div class="thread-card-row">
+ <div class="thread-card-subject-container">
+ <button type="button"
+ class="button button-flat button-reset twisty"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" class="twisty-icon" />
+ </button>
+ <span class="subject"></span>
+ </div>
+ <img class="attachment-icon" src="" data-l10n-id="tree-list-view-row-attach" />
+ <img class="tag-icon" src="" alt="" hidden="hidden" />
+ <button class="button-star tree-button-flag"
+ aria-hidden="true"
+ tabindex="-1">
+ </button>
+ </div>
+ </div>
+ </td>
+ </template>
+ <div id="threadPaneNotificationBox">
+ <!-- notificationbox will be added here lazily. -->
+ </div>
+ </div>
+ <hr is="pane-splitter" id="messagePaneSplitter"
+ resize-id="messagePane"
+ collapse-width="300"
+ collapse-height="100" />
+ <div id="messagePane" class="collapsed-by-splitter">
+ <xul:browser id="webBrowser"
+ type="content"
+ hidden="true"
+ nodefaultsrc="true"
+ context="browserContext"
+ autocompletepopup="PopupAutoComplete"
+ forcemessagemanager="true"
+ messagemanagergroup="single-page"
+ maychangeremoteness="true" />
+ <xul:browser id="messageBrowser"
+ hidden="true"
+ src="about:message" />
+ <xul:browser id="multiMessageBrowser"
+ type="content"
+ hidden="true"
+ context="aboutPagesContext"
+ src="chrome://messenger/content/multimessageview.xhtml" />
+ </div>
+ <xul:browser id="accountCentralBrowser" hidden="true"/>
+</body>
+<popupset xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <menupopup id="folderPaneContext">
+ <menuitem id="folderPaneContext-getMessages"
+ class="menuitem-iconic"
+ label="&folderContextGetMessages.label;"
+ accesskey="&folderContextGetMessages.accesskey;"/>
+ <menuitem id="folderPaneContext-pauseAllUpdates"
+ type="checkbox"
+ label="&folderContextPauseAllUpdates.label;"
+ accesskey="&folderContextPauseUpdates.accesskey;"/>
+ <menuitem id="folderPaneContext-pauseUpdates"
+ type="checkbox"
+ label="&folderContextPauseUpdates.label;"
+ accesskey="&folderContextPauseUpdates.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-openNewTab"
+ class="menuitem-iconic"
+ label="&folderContextOpenNewTab.label;"
+ accesskey="&folderContextOpenNewTab.accesskey;"/>
+ <menuitem id="folderPaneContext-openNewWindow"
+ class="menuitem-iconic"
+ label="&folderContextOpenInNewWindow.label;"
+ accesskey="&folderContextOpenInNewWindow.accesskey;"/>
+ <menuitem id="folderPaneContext-searchMessages"
+ class="menuitem-iconic"
+ label="&folderContextSearchForMessages.label;"
+ accesskey="&folderContextSearchForMessages.accesskey;"/>
+ <menuitem id="folderPaneContext-subscribe"
+ class="menuitem-iconic"
+ label="&folderContextSubscribe.label;"
+ accesskey="&folderContextSubscribe.accesskey;"/>
+ <menuitem id="folderPaneContext-newsUnsubscribe"
+ class="menuitem-iconic"
+ label="&folderContextUnsubscribe.label;"
+ accesskey="&folderContextUnsubscribe.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-new"
+ class="menuitem-iconic"
+ label="&folderContextNew.label;"
+ accesskey="&folderContextNew.accesskey;"/>
+ <menuitem id="folderPaneContext-remove"
+ class="menuitem-iconic"
+ label="&folderContextRemove.label;"
+ accesskey="&folderContextRemove.accesskey;"/>
+ <menuitem id="folderPaneContext-rename"
+ class="menuitem-iconic"
+ label="&folderContextRename.label;"
+ accesskey="&folderContextRename.accesskey;"/>
+ <menuseparator/>
+ <menu id="folderPaneContext-moveMenu"
+ class="menu-iconic"
+ label="&moveMsgToMenu.label;"
+ accesskey="&moveMsgToMenu.accesskey;">
+ <menupopup is="folder-menupopup" id="folderContext-movePopup"
+ mode="filing"
+ showAccountsFileHere="true"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"
+ showLast="true"/>
+ </menu>
+ <menu id="folderPaneContext-copyMenu"
+ class="menu-iconic"
+ label="&copyMsgToMenu.label;"
+ accesskey="&copyMsgToMenu.accesskey;">
+ <menupopup is="folder-menupopup" id="folderContext-copyPopup"
+ mode="filing"
+ showAccountsFileHere="true"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"
+ showLast="true"/>
+ </menu>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-compact"
+ class="menuitem-iconic"
+ label="&folderContextCompact.label;"
+ accesskey="&folderContextCompact.accesskey;"/>
+ <menuitem id="folderPaneContext-markMailFolderAllRead"
+ class="menuitem-iconic"
+ label="&folderContextMarkMailFolderRead.label;"
+ accesskey="&folderContextMarkMailFolderRead.accesskey;"/>
+ <menuitem id="folderPaneContext-markNewsgroupAllRead"
+ class="menuitem-iconic"
+ label="&folderContextMarkNewsgroupRead.label;"
+ accesskey="&folderContextMarkNewsgroupRead.accesskey;"/>
+ <menuitem id="folderPaneContext-emptyTrash"
+ class="menuitem-iconic"
+ label="&folderContextEmptyTrash.label;"
+ accesskey="&folderContextEmptyTrash.accesskey;"/>
+ <menuitem id="folderPaneContext-emptyJunk"
+ class="menuitem-iconic"
+ label="&folderContextEmptyJunk.label;"
+ accesskey="&folderContextEmptyJunk.accesskey;"/>
+ <menuitem id="folderPaneContext-sendUnsentMessages"
+ class="menuitem-iconic"
+ label="&folderContextSendUnsentMessages.label;"
+ accesskey="&folderContextSendUnsentMessages.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-favoriteFolder"
+ type="checkbox"
+ label="&folderContextFavoriteFolder.label;"
+ accesskey="&folderContextFavoriteFolder.accesskey;"/>
+ <menuitem id="folderPaneContext-properties"
+ class="menuitem-iconic"
+ label="&folderContextProperties2.label;"
+ accesskey="&folderContextProperties2.accesskey;"/>
+ <menuitem id="folderPaneContext-markAllFoldersRead"
+ class="menuitem-iconic"
+ label="&folderContextMarkAllFoldersRead.label;"/>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-settings"
+ class="menuitem-iconic"
+ label="&folderContextSettings2.label;"
+ accesskey="&folderContextSettings2.accesskey;"/>
+ <menuitem id="folderPaneContext-manageTags"
+ class="menuitem-iconic"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"/>
+ <menuseparator/>
+ </menupopup>
+ <tooltip id="qfb-text-search-upsell">
+ <div id="qfb-upsell-line-one"
+ data-l10n-id="quick-filter-bar-gloda-upsell-line1"></div>
+ <div id="qfb-upsell-line-two"></div>
+ </tooltip>
+ <menupopup id="folderPaneMoreContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft"
+ onpopupshowing="folderPane.updateContextMenuCheckedItems();">
+ <menu id="folderModesContextMenu"
+ data-l10n-id="folder-pane-header-folder-modes"
+ position="bottomleft topleft">
+ <menupopup id="folderModesContextMenuPopup"
+ onpopupshowing="folderPane.updateContextCheckedFolderMode();">
+ <menuitem id="folderPaneMoreContextAllFolders"
+ class="folder-pane-mode"
+ value="all"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-all-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuitem id="folderPaneMoreContextUnifiedFolders"
+ class="folder-pane-mode"
+ value="smart"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-smart-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuitem id="folderPaneMoreContextUnreadFolders"
+ class="folder-pane-mode"
+ value="unread"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ data-l10n-id="show-unread-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuitem id="folderPaneMoreContextFavoriteFolders"
+ class="folder-pane-mode"
+ value="favorite"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-favorite-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuitem id="folderPaneMoreContextRecentFolders"
+ class="folder-pane-mode"
+ value="recent"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-recent-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuseparator/>
+ <menuitem id="folderPaneMoreContextTags"
+ class="folder-pane-mode"
+ value="tags"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-tags-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuseparator id="separatorAfterFolderModes"/>
+ <menuitem id="folderPaneMoreContextCompactToggle"
+ class="compact-folder-button folder-pane-option"
+ value="compact"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-mode-context-toggle-compact-mode"
+ oncommand="folderPane.compactFolderToggle(event);"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="separatorAfterFolderViewOptions"/>
+ <menuitem id="folderPaneHeaderToggleGetMessages"
+ class="folder-pane-option"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-header-context-toggle-get-messages"
+ oncommand="folderPane.toggleGetMsgsBtn(event);"/>
+ <menuitem id="folderPaneHeaderToggleNewMessage"
+ class="folder-pane-option"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-header-context-toggle-new-message"
+ oncommand="folderPane.toggleNewMsgBtn(event);"/>
+ <menuseparator id="separatorAfterToggleButtons"/>
+ <menuitem id="folderPaneHeaderToggleTotalCount"
+ class="folder-pane-option"
+ value="total"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-show-total-toggle"
+ oncommand="folderPane.toggleTotal(event);"/>
+ <menuitem id="folderPaneHeaderToggleFolderSize"
+ class="folder-pane-option"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-header-toggle-folder-size"
+ oncommand="folderPane.toggleFolderSize(event);"/>
+ <menuitem id="folderPaneHeaderToggleLocalFolders"
+ class="folder-pane-option"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-header-hide-local-folders"
+ oncommand="folderPane.toggleLocalFolders(event);"/>
+ <menuseparator id="separatorBeforeHideFolderPaneHeaderOption"/>
+ <menuitem id="folderPaneHeaderHideMenuItem"
+ data-l10n-id="folder-pane-header-context-hide"
+ oncommand="folderPane.toggleHeader(false);"/>
+ </menupopup>
+ <menupopup id="folderPaneModeContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft">
+ <menuitem id="folderPaneModeMoveUp"
+ class="folder-pane-mode"
+ value="moveup"
+ data-l10n-id="folder-pane-mode-move-up"
+ oncommand="folderPane.moveFolderModeUp(event);"/>
+ <menuitem id="folderPaneModeMoveDown"
+ class="folder-pane-mode"
+ value="movedown"
+ data-l10n-id="folder-pane-mode-move-down"
+ oncommand="folderPane.moveFolderModeDown(event);"/>
+ <menuseparator id="separatorBeforeCompactFolderOption"/>
+ <menuitem id="compactFolderButton"
+ class="compact-folder-button folder-pane-mode"
+ value="compact"
+ type="checkbox"
+ data-l10n-id="folder-pane-mode-context-toggle-compact-mode"
+ oncommand="folderPane.compactFolderToggle(event);"/>
+ </menupopup>
+ <menupopup id="threadPaneDisplayContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft"
+ onpopupshowing="threadPaneHeader.updateDisplayContextMenu(event);">
+ <menuitem id="threadPaneTableView"
+ class="thread-view-option"
+ type="radio"
+ name="threadview"
+ value="table"
+ closemenu="none"
+ data-l10n-id="thread-pane-header-context-table-view"
+ oncommand="threadPaneHeader.changePaneView(event);"/>
+ <menuitem id="threadPaneCardsView"
+ class="thread-view-option"
+ type="radio"
+ name="threadview"
+ value="cards"
+ closemenu="none"
+ data-l10n-id="thread-pane-header-context-cards-view"
+ oncommand="threadPaneHeader.changePaneView(event);"/>
+ <menuseparator id="separatorBeforeHideThreadHeaderOption"/>
+ <menu id="threadPaneSortMenu"
+ accesskey="&sortMenu.accesskey;"
+ label="&sortMenu.label;">
+ <menupopup id="menu_threadPaneSortPopup"
+ oncommand="goDoCommand('cmd_sort', event);"
+ onpopupshowing="threadPaneHeader.updateThreadPaneSortMenu(event);">
+ <menuitem id="threadPaneSortByDateMenuitem"
+ type="radio"
+ name="sortby"
+ value="byDate"
+ label="&sortByDateCmd.label;"
+ accesskey="&sortByDateCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByReceivedMenuitem"
+ type="radio"
+ name="sortby"
+ value="byReceived"
+ label="&sortByReceivedCmd.label;"
+ accesskey="&sortByReceivedCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByFlagMenuitem"
+ type="radio"
+ name="sortby"
+ value="byFlagged"
+ label="&sortByStarCmd.label;"
+ accesskey="&sortByStarCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByOrderReceivedMenuitem"
+ type="radio"
+ name="sortby"
+ value="byId"
+ label="&sortByOrderReceivedCmd.label;"
+ accesskey="&sortByOrderReceivedCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByPriorityMenuitem"
+ type="radio"
+ name="sortby"
+ value="byPriority"
+ label="&sortByPriorityCmd.label;"
+ accesskey="&sortByPriorityCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByFromMenuitem"
+ type="radio"
+ name="sortby"
+ value="byAuthor"
+ label="&sortByFromCmd.label;"
+ accesskey="&sortByFromCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByRecipientMenuitem"
+ type="radio"
+ name="sortby"
+ value="byRecipient"
+ label="&sortByRecipientCmd.label;"
+ accesskey="&sortByRecipientCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByCorrespondentMenuitem"
+ type="radio"
+ name="sortby"
+ value="byCorrespondent"
+ label="&sortByCorrespondentCmd.label;"
+ accesskey="&sortByCorrespondentCmd.accesskey;"/>
+ <menuitem id="threadPaneSortBySizeMenuitem"
+ type="radio"
+ name="sortby"
+ value="bySize"
+ label="&sortBySizeCmd.label;"
+ accesskey="&sortBySizeCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByStatusMenuitem"
+ type="radio"
+ name="sortby"
+ value="byStatus"
+ label="&sortByStatusCmd.label;"
+ accesskey="&sortByStatusCmd.accesskey;"/>
+ <menuitem id="threadPaneSortBySubjectMenuitem"
+ type="radio"
+ name="sortby"
+ value="bySubject"
+ label="&sortBySubjectCmd.label;"
+ accesskey="&sortBySubjectCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByUnreadMenuitem"
+ type="radio"
+ name="sortby"
+ value="byUnread"
+ label="&sortByUnreadCmd.label;"
+ accesskey="&sortByUnreadCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByTagsMenuitem"
+ type="radio"
+ name="sortby"
+ value="byTags"
+ label="&sortByTagsCmd.label;"
+ accesskey="&sortByTagsCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByJunkStatusMenuitem"
+ type="radio"
+ name="sortby"
+ value="byJunkStatus"
+ label="&sortByJunkStatusCmd.label;"
+ accesskey="&sortByJunkStatusCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByAttachmentsMenuitem"
+ type="radio"
+ name="sortby"
+ value="byAttachments"
+ label="&sortByAttachmentsCmd.label;"
+ accesskey="&sortByAttachmentsCmd.accesskey;"/>
+ <menuseparator id="threadPaneSortAfterAttachmentSeparator"/>
+ <menuitem id="threadPaneSortAscending"
+ type="radio"
+ name="sortdirection"
+ value="ascending"
+ label="&sortAscending.label;"
+ accesskey="&sortAscending.accesskey;"/>
+ <menuitem id="threadPaneSortDescending"
+ type="radio"
+ name="sortdirection"
+ value="descending"
+ label="&sortDescending.label;"
+ accesskey="&sortDescending.accesskey;"/>
+ <menuseparator id="threadPaneSortAfterDescendingSeparator"/>
+ <menuitem id="threadPaneSortThreaded"
+ type="radio"
+ name="threaded"
+ value="threaded"
+ label="&sortThreaded.label;"
+ accesskey="&sortThreaded.accesskey;"/>
+ <menuitem id="threadPaneSortUnthreaded"
+ type="radio"
+ name="threaded"
+ value="unthreaded"
+ label="&sortUnthreaded.label;"
+ accesskey="&sortUnthreaded.accesskey;"/>
+ <menuitem id="threadPaneGroupBySort"
+ type="checkbox"
+ name="group"
+ value="group"
+ label="&groupBySort.label;"
+ accesskey="&groupBySort.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="separatorAfterSortOptions"/>
+ <menuitem data-l10n-id="thread-pane-header-context-hide"
+ oncommand="threadPaneHeader.toggleThreadPaneHeader();"/>
+ </menupopup>
+ <menupopup id="folderPaneGetMessagesContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft"
+ onpopupshowing="folderPane.updateGetMessagesContextMenu();">
+ <menuitem id="itemGetAllNewMessages"
+ class="menuitem-iconic"
+ data-l10n-id="folder-pane-get-all-messages-menuitem"
+ oncommand="top.MsgGetMessagesForAllAuthenticatedAccounts();"/>
+ <menuseparator id="separatorAfterItemGetAllNewMessages"/>
+ </menupopup>
+#include mailContext.inc.xhtml
+ <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete"
+ type="autocomplete"
+ role="group"
+ noautofocus="true"/>
+</popupset>
+</html>
diff --git a/comm/mail/base/content/aboutAddonsExtra.js b/comm/mail/base/content/aboutAddonsExtra.js
new file mode 100644
index 0000000000..1499d3927b
--- /dev/null
+++ b/comm/mail/base/content/aboutAddonsExtra.js
@@ -0,0 +1,211 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/mozapps/extensions/content/aboutaddons.js */
+
+const THUNDERBIRD_THEME_PREVIEWS = new Map([
+ [
+ "thunderbird-compact-light@mozilla.org",
+ "resource://builtin-themes/light/preview.svg",
+ ],
+ [
+ "thunderbird-compact-dark@mozilla.org",
+ "resource://builtin-themes/dark/preview.svg",
+ ],
+]);
+
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "alternativeAddonSearchUrl",
+ "extensions.alternativeAddonSearch.url"
+);
+
+(async function () {
+ window.MozXULElement.insertFTLIfNeeded("messenger/aboutAddonsExtra.ftl");
+ // Needed for webext-perms-description-experiment.
+ window.MozXULElement.insertFTLIfNeeded("messenger/extensionPermissions.ftl");
+ UIFontSize.registerWindow(window);
+
+ // Consume clicks on a-tags and let openTrustedLinkIn() decide how to open them.
+ window.addEventListener("click", event => {
+ if (event.target.matches("a[href]") && event.target.href) {
+ let uri = Services.io.newURI(event.target.href);
+ if (uri.scheme == "http" || uri.scheme == "https") {
+ event.preventDefault();
+ event.stopPropagation();
+ windowRoot.ownerGlobal.openTrustedLinkIn(event.target.href, "tab");
+ }
+ }
+ });
+
+ // Fix the "Search on addons.mozilla.org" placeholder text in the searchbox.
+ let textbox = document.querySelector("search-addons > search-textbox");
+ document.l10n.setAttributes(textbox, "atn-addons-heading-search-input");
+
+ // Add our stylesheet.
+ let contentStylesheet = document.createElement("link");
+ contentStylesheet.rel = "stylesheet";
+ contentStylesheet.href = "chrome://messenger/skin/aboutAddonsExtra.css";
+ document.head.appendChild(contentStylesheet);
+
+ // Override logic for detecting unsigned add-ons.
+ window.isCorrectlySigned = function () {
+ return true;
+ };
+
+ // Load our theme screenshots.
+ let _getScreenshotUrlForAddon = getScreenshotUrlForAddon;
+ getScreenshotUrlForAddon = function (addon) {
+ if (THUNDERBIRD_THEME_PREVIEWS.has(addon.id)) {
+ return THUNDERBIRD_THEME_PREVIEWS.get(addon.id);
+ }
+ return _getScreenshotUrlForAddon(addon);
+ };
+
+ // Add logic to detect add-ons using the unsupported legacy API.
+ let getMozillaAddonMessageInfo = window.getAddonMessageInfo;
+ window.getAddonMessageInfo = async function (addon) {
+ const { name } = addon;
+ const { STATE_SOFTBLOCKED } = Ci.nsIBlocklistService;
+
+ let data = new ExtensionData(addon.getResourceURI());
+ await data.loadManifest();
+ if (
+ addon.type == "extension" &&
+ (data.manifest.legacy ||
+ (!addon.isCompatible &&
+ (AddonManager.checkCompatibility ||
+ addon.blocklistState !== STATE_SOFTBLOCKED)))
+ ) {
+ return {
+ linkText: await document.l10n.formatValue(
+ "add-on-search-alternative-button-label"
+ ),
+ linkUrl: `${alternativeAddonSearchUrl}?id=${encodeURIComponent(
+ addon.id
+ )}&q=${encodeURIComponent(name)}`,
+ messageId: "details-notification-incompatible",
+ messageArgs: { name, version: Services.appinfo.version },
+ type: "warning",
+ };
+ }
+ return getMozillaAddonMessageInfo(addon);
+ };
+ document.querySelectorAll("addon-card").forEach(card => card.updateMessage());
+
+ // Override parts of the addon-card customElement to be able
+ // to add a dedicated button for extension preferences.
+ await customElements.whenDefined("addon-card");
+ AddonCard.prototype.addOptionsButton = async function () {
+ let { addon, optionsButton } = this;
+ if (addon.type != "extension") {
+ return;
+ }
+
+ let addonOptionsButton = this.querySelector(".extension-options-button");
+ if (!addonOptionsButton) {
+ addonOptionsButton = document.createElement("button");
+ addonOptionsButton.classList.add("extension-options-button");
+ addonOptionsButton.setAttribute("action", "preferences");
+ document.l10n.setAttributes(addonOptionsButton, "add-on-options-button");
+ addonOptionsButton.disabled = true;
+ optionsButton.parentNode.insertBefore(addonOptionsButton, optionsButton);
+ }
+
+ // Upon fresh install the manifest has not been parsed and optionsType
+ // is not known, manually trigger parsing.
+ if (addon.isActive && !addon.optionsType) {
+ let data = new ExtensionData(addon.getResourceURI());
+ await data.loadManifest();
+ }
+
+ addonOptionsButton.disabled = !(addon.isActive && addon.optionsType);
+ };
+ AddonCard.prototype._update = AddonCard.prototype.update;
+ AddonCard.prototype.update = function () {
+ this._update();
+ this.addOptionsButton();
+ };
+
+ // Override parts of the addon-permission-list customElement to be able
+ // to show the usage of Experiments in the permission list.
+ await customElements.whenDefined("addon-permissions-list");
+ AddonPermissionsList.prototype.renderExperimentOnly = function () {
+ this.textContent = "";
+ let frag = importTemplate("addon-permissions-list");
+ let section = frag.querySelector(".addon-permissions-required");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+
+ let item = document.createElement("li");
+ document.l10n.setAttributes(item, "webext-perms-description-experiment");
+ item.classList.add("permission-info", "permission-checked");
+ list.appendChild(item);
+
+ this.appendChild(frag);
+ };
+ // We change this function from sync to async, which does not matter.
+ // It calls this.render() which is async without awaiting it anyway.
+ AddonPermissionsList.prototype.setAddon = async function (addon) {
+ this.addon = addon;
+ let data = new ExtensionData(addon.getResourceURI());
+ await data.loadManifest();
+ if (data.manifest.experiment_apis) {
+ this.renderExperimentOnly();
+ } else {
+ this.render();
+ }
+ };
+
+ await customElements.whenDefined("recommended-addon-card");
+ RecommendedAddonCard.prototype._setCardContent =
+ RecommendedAddonCard.prototype.setCardContent;
+ RecommendedAddonCard.prototype.setCardContent = function (card, addon) {
+ this._setCardContent(card, addon);
+ card.addEventListener("click", event => {
+ if (event.target.matches("a[href]") || event.target.matches("button")) {
+ return;
+ }
+ windowRoot.ownerGlobal.openTrustedLinkIn(
+ card.querySelector(".disco-addon-author a").href,
+ "tab"
+ );
+ });
+ };
+
+ await customElements.whenDefined("search-addons");
+ SearchAddons.prototype.searchAddons = function (query) {
+ if (query.length === 0) {
+ return;
+ }
+
+ let url = new URL(
+ formatUTMParams(
+ "addons-manager-search",
+ AddonRepository.getSearchURL(query)
+ )
+ );
+
+ // Limit search to themes, if the themes section is currently active.
+ if (
+ document.getElementById("page-header").getAttribute("type") == "theme"
+ ) {
+ url.searchParams.set("cat", "themes");
+ }
+
+ let browser = getBrowserElement();
+ let chromewin = browser.ownerGlobal;
+ chromewin.openLinkIn(url.href, "tab", {
+ fromChrome: true,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ });
+ };
+})();
diff --git a/comm/mail/base/content/aboutDialog-appUpdater.js b/comm/mail/base/content/aboutDialog-appUpdater.js
new file mode 100644
index 0000000000..36bbc6a3e6
--- /dev/null
+++ b/comm/mail/base/content/aboutDialog-appUpdater.js
@@ -0,0 +1,320 @@
+/* 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/. */
+
+// Note: this file is included in aboutDialog.xhtml and preferences/advanced.xhtml
+// if MOZ_UPDATER is defined.
+
+/* import-globals-from aboutDialog.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "AUS",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+
+var UPDATING_MIN_DISPLAY_TIME_MS = 1500;
+
+var gAppUpdater;
+
+function onUnload(aEvent) {
+ if (gAppUpdater) {
+ gAppUpdater.destroy();
+ gAppUpdater = null;
+ }
+}
+
+function appUpdater(options = {}) {
+ this._appUpdater = new AppUpdater();
+
+ this._appUpdateListener = (status, ...args) => {
+ this._onAppUpdateStatus(status, ...args);
+ };
+ this._appUpdater.addListener(this._appUpdateListener);
+
+ this.options = options;
+ this.updatingMinDisplayTimerId = null;
+ this.updateDeck = document.getElementById("updateDeck");
+
+ this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+
+ try {
+ let manualURL = new URL(
+ Services.urlFormatter.formatURLPref("app.update.url.manual")
+ );
+
+ for (const manualLink of document.getElementsByClassName("manualLink")) {
+ // Strip hash and search parameters for display text.
+ manualLink.textContent = manualURL.origin + manualURL.pathname;
+ manualLink.href = manualURL.href;
+ }
+
+ document.getElementById("failedLink").href = manualURL.href;
+ } catch (e) {
+ console.error("Invalid manual update url.", e);
+ }
+
+ this._appUpdater.check();
+}
+
+appUpdater.prototype = {
+ destroy() {
+ this.stopCurrentCheck();
+ if (this.updatingMinDisplayTimerId) {
+ clearTimeout(this.updatingMinDisplayTimerId);
+ }
+ },
+
+ stopCurrentCheck() {
+ this._appUpdater.removeListener(this._appUpdateListener);
+ this._appUpdater.stop();
+ },
+
+ get update() {
+ return this._appUpdater.update;
+ },
+
+ get selectedPanel() {
+ return this.updateDeck.selectedPanel;
+ },
+
+ _onAppUpdateStatus(status, ...args) {
+ switch (status) {
+ case AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY:
+ this.selectPanel("policyDisabled");
+ break;
+ case AppUpdater.STATUS.READY_FOR_RESTART:
+ this.selectPanel("apply");
+ break;
+ case AppUpdater.STATUS.OTHER_INSTANCE_HANDLING_UPDATES:
+ this.selectPanel("otherInstanceHandlingUpdates");
+ break;
+ case AppUpdater.STATUS.DOWNLOADING: {
+ let downloadStatus = document.getElementById("downloadStatus");
+ if (!args.length) {
+ // Very early in the DOWNLOADING state, `selectedPatch` may not be
+ // available yet. But this function will be called again when it is
+ // available. A `maxSize < 0` indicates that the max size is not yet
+ // available.
+ let maxSize = -1;
+ if (this.update.selectedPatch) {
+ maxSize = this.update.selectedPatch.size;
+ }
+ downloadStatus.textContent = DownloadUtils.getTransferTotal(
+ 0,
+ maxSize
+ );
+ this.selectPanel("downloading");
+ } else {
+ let [progress, max] = args;
+ downloadStatus.textContent = DownloadUtils.getTransferTotal(
+ progress,
+ max
+ );
+ }
+ break;
+ }
+ case AppUpdater.STATUS.STAGING:
+ this.selectPanel("applying");
+ break;
+ case AppUpdater.STATUS.CHECKING: {
+ this.checkingForUpdatesDelayPromise = new Promise(resolve => {
+ this.updatingMinDisplayTimerId = setTimeout(
+ resolve,
+ UPDATING_MIN_DISPLAY_TIME_MS
+ );
+ });
+ if (Services.policies.isAllowed("appUpdate")) {
+ this.selectPanel("checkingForUpdates");
+ } else {
+ this.selectPanel("policyDisabled");
+ }
+ break;
+ }
+ case AppUpdater.STATUS.CHECKING_FAILED:
+ this.selectPanel("checkingFailed");
+ break;
+ case AppUpdater.STATUS.NO_UPDATES_FOUND:
+ this.checkingForUpdatesDelayPromise.then(() => {
+ if (Services.policies.isAllowed("appUpdate")) {
+ this.selectPanel("noUpdatesFound");
+ } else {
+ this.selectPanel("policyDisabled");
+ }
+ });
+ break;
+ case AppUpdater.STATUS.UNSUPPORTED_SYSTEM:
+ if (this.update.detailsURL) {
+ let unsupportedLink = document.getElementById("unsupportedLink");
+ unsupportedLink.href = this.update.detailsURL;
+ }
+ this.selectPanel("unsupportedSystem");
+ break;
+ case AppUpdater.STATUS.MANUAL_UPDATE:
+ this.selectPanel("manualUpdate");
+ break;
+ case AppUpdater.STATUS.DOWNLOAD_AND_INSTALL:
+ this.selectPanel("downloadAndInstall");
+ break;
+ case AppUpdater.STATUS.DOWNLOAD_FAILED:
+ this.selectPanel("downloadFailed");
+ break;
+ case AppUpdater.STATUS.INTERNAL_ERROR:
+ this.selectPanel("internalError");
+ break;
+ case AppUpdater.STATUS.NEVER_CHECKED:
+ this.selectPanel("checkForUpdates");
+ break;
+ case AppUpdater.STATUS.NO_UPDATER:
+ default:
+ this.selectPanel("noUpdater");
+ document.getElementById("updateBox").style.maxHeight = "0";
+ break;
+ }
+ },
+
+ /**
+ * Sets the panel of the updateDeck and the visibility of icons
+ * in the #icons element.
+ *
+ * @param aChildID
+ * The id of the deck's child to select, e.g. "apply".
+ */
+ selectPanel(aChildID) {
+ let panel = document.getElementById(aChildID);
+ let icons = document.getElementById("icons");
+ if (icons) {
+ icons.className = aChildID;
+ }
+
+ // Make sure to select the panel before potentially auto-focusing the button.
+ this.updateDeck.selectedPanel = panel;
+
+ let button = panel.querySelector("button");
+ if (button) {
+ if (aChildID == "downloadAndInstall") {
+ let updateVersion = gAppUpdater.update.displayVersion;
+ // Include the build ID if this is an "a#" (nightly or aurora) build
+ if (/a\d+$/.test(updateVersion)) {
+ let buildID = gAppUpdater.update.buildID;
+ let year = buildID.slice(0, 4);
+ let month = buildID.slice(4, 6);
+ let day = buildID.slice(6, 8);
+ updateVersion += ` (${year}-${month}-${day})`;
+ } else {
+ let updateNotesLink = document.getElementById("updateNotes");
+ if (updateNotesLink) {
+ updateNotesLink.href = gAppUpdater.update.detailsURL;
+ updateNotesLink.hidden = false;
+ }
+ }
+ button.textContent = this.bundle.formatStringFromName(
+ "update.downloadAndInstallButton.label",
+ [updateVersion]
+ );
+ button.accessKey = this.bundle.GetStringFromName(
+ "update.downloadAndInstallButton.accesskey"
+ );
+ }
+ if (this.options.buttonAutoFocus) {
+ let promise = Promise.resolve();
+ if (document.readyState != "complete") {
+ promise = new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ promise.then(() => {
+ if (
+ !document.commandDispatcher.focusedElement || // don't steal the focus
+ // except from the other buttons
+ document.commandDispatcher.focusedElement.localName == "button"
+ ) {
+ button.focus();
+ }
+ });
+ }
+ }
+ },
+
+ /**
+ * Check for updates
+ */
+ checkForUpdates() {
+ this._appUpdater.check();
+ },
+
+ /**
+ * Handles oncommand for the "Restart to Update" button
+ * which is presented after the download has been downloaded.
+ */
+ buttonRestartAfterDownload() {
+ if (AUS.currentState != Ci.nsIApplicationUpdateService.STATE_PENDING) {
+ return;
+ }
+
+ gAppUpdater.selectPanel("restarting");
+
+ // Notify all windows that an application quit has been requested.
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ // Something aborted the quit process.
+ if (cancelQuit.data) {
+ gAppUpdater.selectPanel("apply");
+ return;
+ }
+
+ // If already in safe mode restart in safe mode (bug 327119)
+ if (Services.appinfo.inSafeMode) {
+ Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit);
+ return;
+ }
+
+ if (
+ !Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ )
+ ) {
+ // Either the user or the hidden window aborted the quit process.
+ gAppUpdater.selectPanel("apply");
+ }
+ },
+
+ /**
+ * Starts the download of an update mar.
+ */
+ startDownload() {
+ this._appUpdater.allowUpdateDownload();
+ },
+};
+
+window.addEventListener("load", () => {
+ let protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ for (let link of document.querySelectorAll(".download-link")) {
+ link.addEventListener("click", event => {
+ event.preventDefault();
+ protocolSvc.loadURI(Services.io.newURI(event.target.href));
+ });
+ }
+});
diff --git a/comm/mail/base/content/aboutDialog.css b/comm/mail/base/content/aboutDialog.css
new file mode 100644
index 0000000000..36a6332030
--- /dev/null
+++ b/comm/mail/base/content/aboutDialog.css
@@ -0,0 +1,187 @@
+/* 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/. */
+
+:root {
+ --dialog-background: #fefefe;
+ --dialog-box-color: #222;
+ --client-box-background: #f7f7f7;
+ --link-color: -moz-nativehyperlinktext;
+ --link-decoration: inherit;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --dialog-background: #222;
+ --dialog-box-color: #f7f7f7;
+ --client-box-background: #444;
+ --link-color: #f7f7f7;
+ --link-decoration: underline;
+ }
+}
+
+@media (prefers-contrast) {
+ :root {
+ --dialog-background: -moz-Dialog;
+ --dialog-box-color: -moz-DialogText;
+ --client-box-background: -moz-Dialog;
+ --link-color: -moz-nativehyperlinktext;
+ --link-decoration: inherit;
+ }
+}
+
+body {
+ margin: 0;
+ overflow: hidden;
+}
+
+#aboutDialog {
+ /* Set an explicit line-height to avoid discrepancies in 'auto' spacing
+ across screens with different device DPI, which may cause font metrics
+ to round differently. */
+ line-height: 1.5;
+}
+
+#aboutDialogContainer {
+ width: 670px;
+ color: var(--dialog-box-color);
+ background-color: var(--dialog-background);
+}
+
+#rightBox {
+ background-image: url("chrome://branding/content/about-wordmark.svg");
+ background-repeat: no-repeat;
+ /* padding-top creates room for the wordmark */
+ padding-top: 38px;
+ margin-top: 20px;
+ margin-inline: 30px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#detailsBox {
+ padding-top: 10px;
+}
+
+#updateDeck {
+ align-items: center;
+ min-height: 33px;
+}
+
+.update-throbber {
+ width: 16px;
+ min-height: 16px;
+ margin-inline-end: 3px;
+ content: image-set(url("chrome://global/skin/icons/loading.png"),
+ url("chrome://global/skin/icons/loading@2x.png") 2x);
+ vertical-align: middle;
+}
+
+#rightBox:-moz-locale-dir(rtl) {
+ background-position: 100% 0;
+}
+
+#bottomBox {
+ padding: 15px 10px 0;
+}
+
+#version {
+ font-weight: bold;
+ margin-top: 10px;
+ margin-inline-start: 0;
+ user-select: text;
+ -moz-user-focus: normal;
+ cursor: text;
+}
+
+#releasenotes {
+ margin-inline-start: 0.5em;
+}
+
+#distribution,
+#distributionId {
+ display: none;
+ margin-block: 0;
+}
+
+.text-blurb {
+ margin-bottom: 10px;
+ margin-inline-start: 0;
+ padding-inline-start: 0;
+}
+
+.update-deck-container {
+ display: flex;
+ align-items: center;
+ position: fixed;
+}
+
+.update-deck-container > * {
+ flex: 0 0 fit-content;
+}
+
+.update-deck-container.deck-selected {
+ visibility: visible;
+}
+
+.update-deck-container span {
+ font-style: italic;
+}
+
+.update-deck-container span > a {
+ font-style: normal;
+}
+
+.update-throbber {
+ width: 16px;
+ height: 16px;
+ margin-inline-end: 3px;
+}
+
+.trademark-label,
+.text-link,
+.text-link:focus {
+ margin: 0;
+ padding: 0;
+}
+
+.bottom-link,
+.bottom-link:focus {
+ text-align: center;
+ margin: 0 40px;
+}
+
+.text-link {
+ color: var(--link-color);
+}
+
+.text-link:not(:hover) {
+ text-decoration: var(--link-decoration);
+}
+
+#updateNotes {
+ margin-inline-start: 5px;
+}
+
+#currentChannel {
+ margin: 0;
+ padding: 0;
+ font-weight: bold;
+}
+
+#icons > .icon {
+ -moz-context-properties: fill;
+ margin: 10px 5px;
+ width: 16px;
+ height: 16px;
+}
+
+#icons:not(.checkingForUpdates, .downloading, .applying, .restarting) > .update-throbber,
+#icons:not(.noUpdatesFound) > .noUpdatesFound,
+#icons:not(.apply) > .apply {
+ display: none;
+}
+
+#icons > .noUpdatesFound {
+ fill: #16a34a;
+}
diff --git a/comm/mail/base/content/aboutDialog.js b/comm/mail/base/content/aboutDialog.js
new file mode 100644
index 0000000000..74af77866f
--- /dev/null
+++ b/comm/mail/base/content/aboutDialog.js
@@ -0,0 +1,155 @@
+/* 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 aboutDialog-appUpdater.js */
+
+"use strict";
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+if (AppConstants.MOZ_UPDATER) {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/aboutDialog-appUpdater.js",
+ this
+ );
+}
+
+window.addEventListener("DOMContentLoaded", onLoad);
+if (AppConstants.MOZ_UPDATER) {
+ // This method is in the aboutDialog-appUpdater.js file.
+ window.addEventListener("unload", onUnload);
+}
+
+function onLoad(event) {
+ if (event.target !== document) {
+ return;
+ }
+
+ let defaults = Services.prefs.getDefaultBranch(null);
+ let distroId = defaults.getCharPref("distribution.id", "");
+ if (distroId) {
+ let distroAbout = defaults.getStringPref("distribution.about", "");
+ // If there is about text, we always show it.
+ if (distroAbout) {
+ let distroField = document.getElementById("distribution");
+ distroField.innerText = distroAbout;
+ distroField.style.display = "block";
+ }
+ // If it's not a mozilla distribution, show the rest,
+ // unless about text exists, then we always show.
+ if (!distroId.startsWith("mozilla-") || distroAbout) {
+ let distroVersion = defaults.getCharPref("distribution.version", "");
+ if (distroVersion) {
+ distroId += " - " + distroVersion;
+ }
+
+ let distroIdField = document.getElementById("distributionId");
+ distroIdField.innerText = distroId;
+ distroIdField.style.display = "block";
+ }
+ }
+
+ // Include the build ID and display warning if this is an "a#" (nightly or aurora) build
+ let versionId = "aboutDialog-version";
+ let versionAttributes = {
+ version: AppConstants.MOZ_APP_VERSION_DISPLAY,
+ bits: Services.appinfo.is64Bit ? 64 : 32,
+ };
+
+ let version = Services.appinfo.version;
+ if (/a\d+$/.test(version)) {
+ versionId = "aboutDialog-version-nightly";
+ let buildID = Services.appinfo.appBuildID;
+ let year = buildID.slice(0, 4);
+ let month = buildID.slice(4, 6);
+ let day = buildID.slice(6, 8);
+ versionAttributes.isodate = `${year}-${month}-${day}`;
+
+ document.getElementById("experimental").hidden = false;
+ document.getElementById("communityDesc").hidden = true;
+ }
+
+ // Use Fluent arguments for append version and the architecture of the build
+ let versionField = document.getElementById("version");
+
+ document.l10n.setAttributes(versionField, versionId, versionAttributes);
+
+ if (!AppConstants.NIGHTLY_BUILD) {
+ // Show a release notes link if we have a URL.
+ let relNotesLink = document.getElementById("releasenotes");
+ let relNotesPrefType = Services.prefs.getPrefType("app.releaseNotesURL");
+ if (relNotesPrefType != Services.prefs.PREF_INVALID) {
+ let relNotesURL = Services.urlFormatter.formatURLPref(
+ "app.releaseNotesURL"
+ );
+ if (relNotesURL != "about:blank") {
+ relNotesLink.href = relNotesURL;
+ relNotesLink.hidden = false;
+ }
+ }
+ }
+
+ if (AppConstants.MOZ_UPDATER) {
+ gAppUpdater = new appUpdater({ buttonAutoFocus: true });
+
+ let channelLabel = document.getElementById("currentChannelText");
+ let channelAttrs = document.l10n.getAttributes(channelLabel);
+ let channel = UpdateUtils.UpdateChannel;
+ document.l10n.setAttributes(channelLabel, channelAttrs.id, { channel });
+ if (
+ /^release($|\-)/.test(channel) ||
+ Services.sysinfo.getProperty("isPackagedApp")
+ ) {
+ channelLabel.hidden = true;
+ }
+ }
+
+ // Open external links in browser
+ for (const link of document.getElementsByClassName("browser-link")) {
+ link.onclick = event => {
+ event.preventDefault();
+ openLink(event.target.href);
+ };
+ }
+ // Open internal (about:) links open in Thunderbird tab
+ for (const link of document.getElementsByClassName("tab-link")) {
+ link.onclick = event => {
+ event.preventDefault();
+ openAboutTab(event.target.href);
+ };
+ }
+}
+
+// This function is used to open about: tabs. The caller should ensure the url
+// is only an about: url.
+function openAboutTab(url) {
+ // Check existing windows
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mailWindow) {
+ mailWindow.focus();
+ mailWindow.document
+ .getElementById("tabmail")
+ .openTab("contentTab", { url });
+ return;
+ }
+
+ // No existing windows.
+ window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType: "contentTab",
+ tabParams: { url },
+ }
+ );
+}
+
+function openLink(url) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(url));
+}
diff --git a/comm/mail/base/content/aboutDialog.xhtml b/comm/mail/base/content/aboutDialog.xhtml
new file mode 100644
index 0000000000..55c7330f03
--- /dev/null
+++ b/comm/mail/base/content/aboutDialog.xhtml
@@ -0,0 +1,184 @@
+<?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/. -->
+
+#filter substitution
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/content/aboutDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://branding/content/aboutDialog.css" type="text/css"?>
+
+<!DOCTYPE html>
+<html id="aboutDialog" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ role="dialog"
+ windowtype="mail:about">
+
+<head>
+ <title data-l10n-id="about-dialog-title"></title>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="messenger/aboutDialog.ftl"/>
+ <script defer="true" src="chrome://messenger/content/aboutDialog.js"></script>
+</head>
+<body aria-describedby="version distribution distributionId currentChannelText communityDesc contributeDesc trademark">
+ <xul:keyset id="mainKeyset">
+ <xul:key keycode="VK_ESCAPE" oncommand="window.close();"/>
+#ifdef XP_MACOSX
+ <xul:key id="key_close" modifiers="accel" data-l10n-id="cmd-close-mac-command-key"
+ oncommand="window.close();"/>
+#endif
+ </xul:keyset>
+ <div id="aboutDialogContainer">
+ <xul:hbox id="clientBox">
+ <xul:vbox id="leftBox" flex="1"/>
+ <xul:vbox id="rightBox">
+ <xul:hbox align="baseline">
+ <span id="version"></span>
+ <a id="releasenotes" class="text-link browser-link" hidden="hidden"
+ data-l10n-id="release-notes-link"></a>
+ </xul:hbox>
+
+ <img src="chrome://messenger/skin/icons/new/supernova-logo.webp" alt="" id="supernova-logo"/>
+
+ <span id="distribution" class="text-blurb"></span>
+ <span id="distributionId" class="text-blurb"></span>
+
+ <xul:vbox id="detailsBox">
+ <xul:hbox id="updateBox">
+#ifdef MOZ_UPDATER
+ <div id="icons">
+ <img class="icon update-throbber" role="presentation"/>
+ <img class="icon noUpdatesFound" src="chrome://global/skin/icons/check.svg" role="presentation"/>
+ <img class="icon apply" src="chrome://global/skin/icons/reload.svg" role="presentation"/>
+ </div>
+ <xul:vbox>
+ <xul:deck id="updateDeck" orient="vertical">
+ <div id="checkForUpdates" class="update-deck-container">
+ <button id="checkForUpdatesButton"
+ data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </button>
+ </div>
+ <div id="downloadAndInstall" class="update-deck-container">
+ <button id="downloadAndInstallButton"
+ onclick="gAppUpdater.startDownload();">
+ </button>
+ <a id="updateNotes" class="text-link browser-link" hidden="hidden"
+ data-l10n-id="about-update-whats-new"></a>
+ </div>
+ <div id="apply" class="update-deck-container">
+ <button id="updateButton"
+ data-l10n-id="update-update-button"
+ onclick="gAppUpdater.buttonRestartAfterDownload();">
+ </button>
+ </div>
+ <div id="checkingForUpdates" class="update-deck-container">
+ <span data-l10n-id="update-checking-for-updates"></span>
+ </div>
+ <div id="downloading" class="update-deck-container" data-l10n-id="update-downloading-message">
+ <span id="downloadStatus" data-l10n-name="download-status"></span>
+ </div>
+ <div id="applying" class="update-deck-container">
+ <span data-l10n-id="update-applying"></span>
+ </div>
+ <div id="downloadFailed" class="update-deck-container">
+ <!-- Outer span ensures whitespace between the plain text and
+ - the link. Otherwise, this would be suppressed by the
+ - update-deck-container's display: flex. -->
+ <span data-l10n-id="update-failed">
+ <a id="failedLink" data-l10n-name="failed-link"
+ class="text-link browser-link"></a>
+ </span>
+ </div>
+ <div id="policyDisabled" class="update-deck-container">
+ <span data-l10n-id="update-admin-disabled"></span>
+ </div>
+ <div id="noUpdatesFound" class="update-deck-container">
+ <span data-l10n-id="update-no-updates-found"></span>
+ </div>
+ <div id="checkingFailed" class="update-deck-container">
+ <span data-l10n-id="aboutdialog-update-checking-failed"></span>
+ </div>
+ <div id="otherInstanceHandlingUpdates" class="update-deck-container">
+ <span data-l10n-id="update-other-instance-handling-updates"></span>
+ </div>
+ <div id="manualUpdate" class="update-deck-container">
+ <span data-l10n-id="update-manual">
+ <a id="manualLink" data-l10n-name="manual-link"
+ class="manualLink text-link browser-link"></a>
+ </span>
+ </div>
+ <div id="unsupportedSystem" class="update-deck-container">
+ <span data-l10n-id="update-unsupported">
+ <a id="unsupportedLink" data-l10n-name="unsupported-link"
+ class="manualLink text-link browser-link"></a>
+ </span>
+ </div>
+ <div id="restarting" class="update-deck-container">
+ <span data-l10n-id="update-restarting"></span>
+ </div>
+ <div id="internalError" class="update-deck-container">
+ <span data-l10n-id="update-internal-error">
+ <a id="internalErrorLink" data-l10n-name="manual-link"
+ class="manualLink text-link browser-link"></a>
+ </span>
+ </div>
+ <div id="noUpdater" class="update-deck-container"></div>
+ </xul:deck>
+ <!-- This HBOX is duplicated above without class="update" -->
+ <xul:hbox align="baseline">
+ <span id="version" class="update"></span>
+ <a id="releasenotes" class="text-link browser-link" hidden="hidden"
+ data-l10n-id="release-notes-link"></a>
+ </xul:hbox>
+ </xul:vbox>
+#endif
+ </xul:hbox>
+
+#ifdef MOZ_UPDATER
+ <div class="text-blurb" id="currentChannelText" data-l10n-id="channel-description"
+ data-l10n-args='{"channel": ""}'
+ data-l10n-attrs="{&quot;channel&quot;: &quot;&quot;}">
+ <span id="currentChannel" data-l10n-name="current-channel"></span>
+ </div>
+#endif
+ <xul:vbox id="experimental" hidden="true">
+ <div class="text-blurb" id="warningDesc">
+ <span data-l10n-id="warning-desc-version"></span>
+#ifdef MOZ_TELEMETRY_ON_BY_DEFAULT
+ <span data-l10n-id="warning-desc-telemetry"></span>
+#endif
+ </div>
+ <div class="text-blurb" id="communityExperimentalDesc" data-l10n-id="community-experimental">
+ <a class="text-link browser-link" href="https://www.mozilla.org/"
+ data-l10n-name="community-exp-mozilla-link"></a>
+ <a class="text-link tab-link" href="about:credits"
+ data-l10n-name="community-exp-credits-link"></a>
+ </div>
+ </xul:vbox>
+ <div class="text-blurb" id="communityDesc" data-l10n-id="community-desc">
+ <a class="text-link browser-link" href="https://www.mozilla.org/" data-l10n-name="community-mozilla-link"></a>
+ <a class="text-link tab-link" href="about:credits" data-l10n-name="community-credits-link"></a>
+ </div>
+ <div class="text-blurb" id="contributeDesc" data-l10n-id="about-donation">
+ <a class="text-link browser-link" href="https://give.thunderbird.net/?utm_source=thunderbird-client&amp;utm_medium=referral&amp;utm_content=about-dialog"
+ data-l10n-name="helpus-donate-link"></a>
+ <a class="text-link browser-link" href="https://www.thunderbird.net/get-involved/"
+ data-l10n-name="helpus-get-involved-link"></a>
+ </div>
+ </xul:vbox>
+ </xul:vbox>
+ </xul:hbox>
+ <xul:vbox id="bottomBox">
+ <xul:hbox pack="center">
+ <a class="text-link bottom-link tab-link" href="about:license" data-l10n-id="bottom-links-license"></a>
+ <a class="text-link bottom-link tab-link" href="about:rights" data-l10n-id="bottom-links-rights"></a>
+ <a class="text-link bottom-link browser-link" href="https://www.mozilla.org/privacy/thunderbird/"
+ data-l10n-id="bottom-links-privacy"></a>
+ </xul:hbox>
+ <span id="trademark" data-l10n-id="trademarkInfo"></span>
+ </xul:vbox>
+ </div>
+</body>
+</html>
diff --git a/comm/mail/base/content/aboutMessage.js b/comm/mail/base/content/aboutMessage.js
new file mode 100644
index 0000000000..29ce47ba4d
--- /dev/null
+++ b/comm/mail/base/content/aboutMessage.js
@@ -0,0 +1,617 @@
+/* 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 Enigmail, MailE10SUtils */
+
+// mailCommon.js
+/* globals commandController, DBViewWrapper, dbViewWrapperListener,
+ nsMsgViewIndex_None, TreeSelection */
+/* globals gDBView: true, gFolder: true, gViewWrapper: true */
+
+// mailContext.js
+/* globals mailContextMenu */
+
+// msgHdrView.js
+/* globals AdjustHeaderView ClearCurrentHeaders ClearPendingReadTimer
+ HideMessageHeaderPane OnLoadMsgHeaderPane OnTagsChange
+ OnUnloadMsgHeaderPane HandleAllAttachments AttachmentMenuController */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+const messengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+);
+
+var gMessage, gMessageURI;
+var autodetectCharset;
+
+function getMessagePaneBrowser() {
+ return document.getElementById("messagepane");
+}
+
+function messagePaneOnResize() {
+ const doc = getMessagePaneBrowser().contentDocument;
+ // Bail out if it's http content or we don't have images.
+ if (doc?.URL.startsWith("http") || !doc?.images) {
+ return;
+ }
+
+ for (let img of doc.images) {
+ img.toggleAttribute(
+ "overflowing",
+ img.clientWidth - doc.body.offsetWidth >= 0 &&
+ (img.clientWidth <= img.naturalWidth || !img.naturalWidth)
+ );
+ }
+}
+
+function ReloadMessage() {
+ if (!gMessageURI) {
+ return;
+ }
+ displayMessage(gMessageURI, gViewWrapper);
+}
+
+function MailSetCharacterSet() {
+ let messageService = MailServices.messageServiceFromURI(gMessageURI);
+ gMessage = messageService.messageURIToMsgHdr(gMessageURI);
+ messageService.loadMessage(
+ gMessageURI,
+ getMessagePaneBrowser().docShell,
+ top.msgWindow,
+ null,
+ true
+ );
+ autodetectCharset = true;
+}
+
+window.addEventListener("DOMContentLoaded", event => {
+ if (event.target != document) {
+ return;
+ }
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+
+ OnLoadMsgHeaderPane();
+
+ Enigmail.msg.messengerStartup();
+ Enigmail.hdrView.hdrViewLoad();
+
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.removed
+ );
+
+ preferenceObserver.init();
+ Services.obs.addObserver(msgObserver, "message-content-updated");
+
+ const browser = getMessagePaneBrowser();
+
+ if (parent == top) {
+ // Standalone message display? Focus the message pane.
+ browser.focus();
+ }
+
+ if (window.parent == window.top) {
+ mailContextMenu.init();
+ }
+
+ // There might not be a msgWindow variable on the top window
+ // if we're e.g. showing a message in a dedicated window.
+ if (top.msgWindow) {
+ // Necessary plumbing to communicate status updates back to
+ // the user.
+ browser.docShell
+ ?.QueryInterface(Ci.nsIWebProgress)
+ .addProgressListener(
+ top.msgWindow.statusFeedback,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ }
+
+ window.dispatchEvent(
+ new CustomEvent("aboutMessageLoaded", { bubbles: true })
+ );
+});
+
+window.addEventListener("unload", () => {
+ ClearPendingReadTimer();
+ OnUnloadMsgHeaderPane();
+ MailServices.mailSession.RemoveFolderListener(folderListener);
+ preferenceObserver.cleanUp();
+ Services.obs.removeObserver(msgObserver, "message-content-updated");
+ gViewWrapper?.close();
+});
+
+function displayMessage(uri, viewWrapper) {
+ // Clear the state flags, if this window is re-used.
+ window.msgLoaded = false;
+ window.msgLoading = false;
+
+ // Clean up existing objects before starting again.
+ ClearPendingReadTimer();
+ gMessage = null;
+ if (gViewWrapper && viewWrapper != gViewWrapper) {
+ // Don't clean up gViewWrapper if we're going to reuse it. If we're inside
+ // about:3pane, close the view wrapper, but don't call `onLeavingFolder`,
+ // because about:3pane will do that if we're actually leaving the folder.
+ gViewWrapper?.close(parent != top);
+ gViewWrapper = null;
+ }
+ gDBView = null;
+
+ gMessageURI = uri;
+ ClearCurrentHeaders();
+
+ if (!uri) {
+ HideMessageHeaderPane();
+ MailE10SUtils.loadAboutBlank(getMessagePaneBrowser());
+ window.msgLoaded = true;
+ window.dispatchEvent(
+ new CustomEvent("messageURIChanged", { bubbles: true, detail: uri })
+ );
+ return;
+ }
+
+ let messageService = MailServices.messageServiceFromURI(uri);
+ gMessage = messageService.messageURIToMsgHdr(uri);
+ gFolder = gMessage.folder;
+
+ messageHistory.push(uri);
+
+ if (gFolder) {
+ if (viewWrapper) {
+ if (viewWrapper != gViewWrapper) {
+ gViewWrapper = viewWrapper.clone(dbViewWrapperListener);
+ }
+ } else {
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+ gViewWrapper._viewFlags = Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ gViewWrapper.open(gFolder);
+ }
+ } else {
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+ gViewWrapper.openSearchView();
+ }
+ gDBView = gViewWrapper.dbView;
+ let selection = (gDBView.selection = new TreeSelection());
+ selection.view = gDBView;
+ let index = gDBView.findIndexOfMsgHdr(gMessage, true);
+ selection.select(index == nsMsgViewIndex_None ? -1 : index);
+ gDBView?.setJSTree({
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgJSTree"]),
+ _inBatch: false,
+ beginUpdateBatch() {
+ this._inBatch = true;
+ },
+ endUpdateBatch() {
+ this._inBatch = false;
+ },
+ ensureRowIsVisible(index) {},
+ invalidate() {},
+ invalidateRange(startIndex, endIndex) {},
+ rowCountChanged(index, count) {
+ let wasSuppressed = gDBView.selection.selectEventsSuppressed;
+ gDBView.selection.selectEventsSuppressed = true;
+ gDBView.selection.adjustSelection(index, count);
+ gDBView.selection.selectEventsSuppressed = wasSuppressed;
+ },
+ currentIndex: null,
+ });
+
+ if (gMessage.flags & Ci.nsMsgMessageFlags.HasRe) {
+ document.title = `Re: ${gMessage.mime2DecodedSubject || ""}`;
+ } else {
+ document.title = gMessage.mime2DecodedSubject;
+ }
+
+ let browser = getMessagePaneBrowser();
+ const browserChanged = MailE10SUtils.changeRemoteness(browser, null);
+ // The message pane browser should inherit `docShellIsActive` from the
+ // about:message browser, but changing remoteness causes that to not happen.
+ browser.docShellIsActive = !document.hidden;
+ browser.docShell.allowAuth = false;
+ browser.docShell.allowDNSPrefetch = false;
+
+ if (browserChanged) {
+ browser.docShell
+ ?.QueryInterface(Ci.nsIWebProgress)
+ .addProgressListener(
+ top.msgWindow.statusFeedback,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ }
+
+ if (gMessage.flags & Ci.nsMsgMessageFlags.Partial) {
+ document.body.classList.add("partial-message");
+ } else if (document.body.classList.contains("partial-message")) {
+ document.body.classList.remove("partial-message");
+ document.body.classList.add("completed-message");
+ }
+
+ // @implements {nsIUrlListener}
+ let urlListener = {
+ OnStartRunningUrl(url) {},
+ OnStopRunningUrl(url, status) {
+ window.msgLoading = true;
+ window.dispatchEvent(
+ new CustomEvent("messageURIChanged", { bubbles: true, detail: uri })
+ );
+ if (url instanceof Ci.nsIMsgMailNewsUrl && url.seeOtherURI) {
+ // Show error page if needed.
+ HideMessageHeaderPane();
+ MailE10SUtils.loadURI(getMessagePaneBrowser(), url.seeOtherURI);
+ }
+ },
+ };
+ try {
+ messageService.loadMessage(
+ uri,
+ browser.docShell,
+ top.msgWindow,
+ urlListener,
+ false
+ );
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_OFFLINE) {
+ throw ex;
+ }
+
+ // TODO: This should be replaced with a real page, and made not ugly.
+ let title = messengerBundle.GetStringFromName("nocachedbodytitle");
+ // This string includes some HTML! Get rid of it.
+ title = title.replace(/<\/?title>/gi, "");
+ let body = messengerBundle.GetStringFromName("nocachedbodybody2");
+ HideMessageHeaderPane();
+ MailE10SUtils.loadURI(
+ getMessagePaneBrowser(),
+ "data:text/html;base64," +
+ btoa(
+ `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8" />
+ <title>${title}</title>
+ </head>
+ <body>
+ <h1>${title}</h1>
+ <p>${body}</p>
+ </body>
+ </html>`
+ )
+ );
+ }
+ autodetectCharset = false;
+}
+
+var folderListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFolderListener"]),
+
+ onFolderRemoved(parentFolder, childFolder) {},
+ onMessageRemoved(parentFolder, msg) {
+ messageHistory.onMessageRemoved(parentFolder, msg);
+ },
+};
+
+var msgObserver = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ observe(subject, topic, data) {
+ if (
+ topic == "message-content-updated" &&
+ gMessageURI == subject.QueryInterface(Ci.nsISupportsString).data
+ ) {
+ // This notification is triggered after a partial pop3 message was
+ // fully downloaded. The old message URI is now gone. To reload the
+ // message, we display it with its new URI.
+ displayMessage(data, gViewWrapper);
+ }
+ },
+};
+
+var preferenceObserver = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ _topics: [
+ "mail.inline_attachments",
+ "mail.show_headers",
+ "mail.showCondensedAddresses",
+ "mailnews.display.disallow_mime_handlers",
+ "mailnews.display.html_as",
+ "mailnews.display.prefer_plaintext",
+ "mailnews.headers.showReferences",
+ "rss.show.summary",
+ ],
+
+ _reloadTimeout: null,
+
+ init() {
+ for (let topic of this._topics) {
+ Services.prefs.addObserver(topic, this);
+ }
+ },
+
+ cleanUp() {
+ for (let topic of this._topics) {
+ Services.prefs.removeObserver(topic, this);
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (data == "mail.show_headers") {
+ AdjustHeaderView(Services.prefs.getIntPref(data));
+ }
+ if (!this._reloadTimeout) {
+ // Clear the event queue before reloading the message. Several prefs may
+ // be changed at once.
+ this._reloadTimeout = setTimeout(() => {
+ this._reloadTimeout = null;
+ ReloadMessage();
+ });
+ }
+ },
+};
+
+var messageHistory = {
+ MAX_HISTORY_SIZE: 20,
+ /**
+ * @typedef {object} MessageHistoryEntry
+ * @property {string} messageURI - URI of the message for this entry.
+ * @property {string} folderURI - URI of the folder for this entry.
+ */
+ /**
+ * @type {MessageHistoryEntry[]}
+ */
+ _history: [],
+ _currentIndex: -1,
+ /**
+ * Remove the message from the history, cleaning up the state as needed in
+ * the process.
+ *
+ * @param {nsIMsgFolder} parentFolder
+ * @param {nsIMsgDBHdr} message
+ */
+ onMessageRemoved(parentFolder, message) {
+ if (!this._history.length) {
+ return;
+ }
+ const messageURI = parentFolder.generateMessageURI(message.messageKey);
+ const folderURI = parentFolder.URI;
+ const oldLength = this._history.length;
+ let removedEntriesBeforeFuture = 0;
+ this._history = this._history.filter((entry, index) => {
+ const keepEntry =
+ entry.messageURI !== messageURI || entry.folderURI !== folderURI;
+ if (!keepEntry && index <= this._currentIndex) {
+ ++removedEntriesBeforeFuture;
+ }
+ return keepEntry;
+ });
+ this._currentIndex -= removedEntriesBeforeFuture;
+ // Correct for first entry getting removed while it's the current entry.
+ if (this._history.length && this._currentIndex == -1) {
+ this._currentIndex = 0;
+ }
+ if (oldLength === this._history.length) {
+ return;
+ }
+ window.top.goUpdateCommand("cmd_goBack");
+ window.top.goUpdateCommand("cmd_goForward");
+ },
+ /**
+ * Get the actual index in the history based on a delta from the current
+ * index.
+ *
+ * @param {number} delta - Relative delta from the current index. Forward is
+ * positive, backward is negative.
+ * @returns {number} Absolute index in the history, bounded to the history
+ * size.
+ */
+ _getAbsoluteIndex(delta) {
+ return Math.min(
+ Math.max(this._currentIndex + delta, 0),
+ this._history.length - 1
+ );
+ },
+ /**
+ * Add a message to the end of the history. Does nothing if the message is
+ * already the current item. Moves the history forward by one step if the next
+ * item already matches the given message. Else removes any "future" history
+ * if the current position isn't the newest entry in the history.
+ *
+ * If the history is growing larger than what we want to keep, it is trimmed.
+ *
+ * Assumes the view is currently in the folder that should be comitted to
+ * history.
+ *
+ * @param {string} messageURI - Message to add to the history.
+ */
+ push(messageURI) {
+ if (!messageURI) {
+ return;
+ }
+ let currentItem = this._history[this._currentIndex];
+ let currentFolder = gFolder?.URI;
+ if (
+ currentItem &&
+ messageURI === currentItem.messageURI &&
+ currentFolder === currentItem.folderURI
+ ) {
+ return;
+ }
+ let nextMessageIndex = this._currentIndex + 1;
+ let erasedFuture = false;
+ if (nextMessageIndex < this._history.length) {
+ let nextMessage = this._history[nextMessageIndex];
+ if (
+ nextMessage &&
+ messageURI === nextMessage.messageURI &&
+ currentFolder === nextMessage.folderURI
+ ) {
+ this._currentIndex = nextMessageIndex;
+ if (this._currentIndex === 1) {
+ window.top.goUpdateCommand("cmd_goBack");
+ }
+ if (this._currentIndex + 1 === this._history.length) {
+ window.top.goUpdateCommand("cmd_goForward");
+ }
+ return;
+ }
+ this._history.splice(nextMessageIndex, Infinity);
+ erasedFuture = true;
+ }
+ this._history.push({ messageURI, folderURI: currentFolder });
+ this._currentIndex = nextMessageIndex;
+ if (this._history.length > this.MAX_HISTORY_SIZE) {
+ let amountOfItemsToRemove = this._history.length - this.MAX_HISTORY_SIZE;
+ this._history.splice(0, amountOfItemsToRemove);
+ this._currentIndex -= amountOfItemsToRemove;
+ }
+ if (!currentItem || this._currentIndex === 0) {
+ window.top.goUpdateCommand("cmd_goBack");
+ }
+ if (erasedFuture) {
+ window.top.goUpdateCommand("cmd_goForward");
+ }
+ },
+ /**
+ * Go forward or back in history relative to the current position.
+ *
+ * @param {number} delta
+ * @returns {?MessageHistoryEntry} The message and folder URI that are now at
+ * the active position in the history. If null is returned, no action was
+ * taken.
+ */
+ pop(delta) {
+ let targetIndex = this._getAbsoluteIndex(delta);
+ if (this._currentIndex == targetIndex && gMessage) {
+ return null;
+ }
+ this._currentIndex = targetIndex;
+ window.top.goUpdateCommand("cmd_goBack");
+ window.top.goUpdateCommand("cmd_goForward");
+ return this._history[targetIndex];
+ },
+ /**
+ * Get the current state of the message history.
+ *
+ * @returns {{entries: MessageHistoryEntry[], currentIndex: number}}
+ * A list of message and folder URIs as strings and the current index in the
+ * entries.
+ */
+ getHistory() {
+ return { entries: this._history.slice(), currentIndex: this._currentIndex };
+ },
+ /**
+ * Get a specific history entry relative to the current positon.
+ *
+ * @param {number} delta - Relative index to get the value of.
+ * @returns {?MessageHistoryEntry} If found, the message and
+ * folder URI at the given position.
+ */
+ getMessageAt(delta) {
+ if (!this._history.length) {
+ return null;
+ }
+ return this._history[this._getAbsoluteIndex(delta)];
+ },
+ /**
+ * Check if going forward or back in the history by the given steps is
+ * possible. A special case is when no message is currently selected, going
+ * back to relative position 0 (so the current index) is possible.
+ *
+ * @param {number} delta - Relative position to go to from the current index.
+ * @returns {boolean} If there is a target available at that position in the
+ * current history.
+ */
+ canPop(delta) {
+ let resultIndex = this._currentIndex + delta;
+ return (
+ resultIndex >= 0 &&
+ resultIndex < this._history.length &&
+ (resultIndex !== this._currentIndex || !gMessage)
+ );
+ },
+ /**
+ * Clear the message history, resetting it to its initial empty state.
+ */
+ clear() {
+ this._history.length = 0;
+ this._currentIndex = -1;
+ window.top.goUpdateCommand("cmd_goBack");
+ window.top.goUpdateCommand("cmd_goForward");
+ },
+};
+
+commandController.registerCallback(
+ "cmd_delete",
+ () => commandController.doCommand("cmd_deleteMessage"),
+ () => commandController.isCommandEnabled("cmd_deleteMessage")
+);
+commandController.registerCallback(
+ "cmd_shiftDelete",
+ () => commandController.doCommand("cmd_shiftDeleteMessage"),
+ () => commandController.isCommandEnabled("cmd_shiftDeleteMessage")
+);
+commandController.registerCallback("cmd_find", () =>
+ document.getElementById("FindToolbar").onFindCommand()
+);
+commandController.registerCallback("cmd_findAgain", () =>
+ document.getElementById("FindToolbar").onFindAgainCommand(false)
+);
+commandController.registerCallback("cmd_findPrevious", () =>
+ document.getElementById("FindToolbar").onFindAgainCommand(true)
+);
+commandController.registerCallback("cmd_print", () => {
+ top.PrintUtils.startPrintWindow(getMessagePaneBrowser().browsingContext, {});
+});
+commandController.registerCallback("cmd_fullZoomReduce", () => {
+ top.ZoomManager.reduce();
+});
+commandController.registerCallback("cmd_fullZoomEnlarge", () => {
+ top.ZoomManager.enlarge();
+});
+commandController.registerCallback("cmd_fullZoomReset", () => {
+ top.ZoomManager.reset();
+});
+commandController.registerCallback("cmd_fullZoomToggle", () => {
+ top.ZoomManager.toggleZoom();
+});
+
+// Attachments commands.
+commandController.registerCallback(
+ "cmd_openAllAttachments",
+ () => HandleAllAttachments("open"),
+ () => AttachmentMenuController.someFilesAvailable()
+);
+
+commandController.registerCallback(
+ "cmd_saveAllAttachments",
+ () => HandleAllAttachments("save"),
+ () => AttachmentMenuController.someFilesAvailable()
+);
+
+commandController.registerCallback(
+ "cmd_detachAllAttachments",
+ () => HandleAllAttachments("detach"),
+ () => AttachmentMenuController.canDetachFiles()
+);
+
+commandController.registerCallback(
+ "cmd_deleteAllAttachments",
+ () => HandleAllAttachments("delete"),
+ () => AttachmentMenuController.canDetachFiles()
+);
diff --git a/comm/mail/base/content/aboutMessage.xhtml b/comm/mail/base/content/aboutMessage.xhtml
new file mode 100644
index 0000000000..36571563db
--- /dev/null
+++ b/comm/mail/base/content/aboutMessage.xhtml
@@ -0,0 +1,158 @@
+<?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/. -->
+
+#filter substitution
+
+<!DOCTYPE html [
+<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd">
+%msgHdrViewOverlayDTD;
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%messengerDTD;
+<!ENTITY % editContactOverlayDTD SYSTEM "chrome://messenger/locale/editContactOverlay.dtd">
+%editContactOverlayDTD;
+<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd">
+%lightningDTD;
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" >
+%calendarDTD;
+<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd">
+%smimeDTD;
+]>
+<html 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">
+<head>
+ <meta charset="utf-8" />
+ <title></title>
+
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/draft.svg" />
+
+ <link rel="stylesheet" href="chrome://calendar/skin/calendar.css" />
+ <link rel="stylesheet" href="chrome://calendar/skin/shared/calendar-invitation-display.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/messageWindow.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/popupPanel.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/messageHeader.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/icons.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/colors.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/folderMenus.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/attachmentList.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/searchBox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/openpgp/inlineNotification.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/autocomplete.css" />
+
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="toolkit/main-window/findbar.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="calendar/calendar-invitation-panel.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" />
+ <link rel="localization" href="messenger/openpgp/msgReadStatus.ftl" />
+ <link rel="localization" href="messenger/messageheader/headerFields.ftl" />
+
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgViewNavigation.js"></script>
+ <script defer="defer" src="chrome://messenger/content/editContactPanel.js"></script>
+ <script defer="defer" src="chrome://messenger/content/header-fields.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script>
+ <script defer="defer" src="chrome://messenger-smime/content/msgHdrViewSMIMEOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger-smime/content/msgReadSMIMEOverlay.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/enigmailMessengerOverlay.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/enigmailMsgHdrViewOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgSecurityPane.js"></script>
+ <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script>
+ <script defer="defer" src="chrome://calendar/content/imip-bar.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-invitation-display.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-invitation-panel.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCore.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailContext.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCommon.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgHdrView.js"></script>
+ <script defer="defer" src="chrome://messenger/content/aboutMessage.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <commandset id="attachmentCommands">
+ <command id="cmd_openAllAttachments"
+ oncommand="goDoCommand('cmd_openAllAttachments');"/>
+ <command id="cmd_saveAllAttachments"
+ oncommand="goDoCommand('cmd_saveAllAttachments');"/>
+ <command id="cmd_detachAllAttachments"
+ oncommand="goDoCommand('cmd_detachAllAttachments');"/>
+ <command id="cmd_deleteAllAttachments"
+ oncommand="goDoCommand('cmd_deleteAllAttachments');"/>
+ </commandset>
+
+ <popupset id="mainPopupSet">
+#include mailContext.inc.xhtml
+#include msgHdrPopup.inc.xhtml
+#include editContactPanel.inc.xhtml
+ <tooltip id="aHTMLTooltip" page="true"/>
+ </popupset>
+
+ <!-- msg header view -->
+ <!-- a convenience box for ease of extension overlaying -->
+ <hbox id="messagepaneboxwrapper" flex="1">
+ <vbox id="messagepanebox">
+ <vbox id="singleMessage">
+ <hbox id="msgHeaderView" collapsed="true" class="main-header-area">
+#include msgHdrView.inc.xhtml
+ </hbox>
+#include ../../../calendar/base/content/imip-bar-overlay.inc.xhtml
+ </vbox>
+ <!-- The msgNotificationBar appears on top of the message and displays
+ information like: junk, mdn, remote content and phishing warnings -->
+ <vbox id="mail-notification-top">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+
+#include ../../../calendar/base/content/widgets/calendar-invitation-panel.xhtml
+#include ../../../calendar/base/content/widgets/calendar-minidate.xhtml
+
+ <vbox id="calendarInvitationDisplayContainer"
+ flex="1"
+ hidden="true">
+ <html:div id="calendarInvitationDisplay">
+ <!-- The calendar invitation panel is displayed here. -->
+ </html:div>
+ </vbox>
+
+ <!-- message view -->
+ <browser id="messagepane"
+ context="mailContext"
+ tooltip="aHTMLTooltip"
+ style="height: 0px; min-height: 1px; background-color: field;"
+ flex="1"
+ name="messagepane"
+ disablesecurity="true"
+ disablehistory="true"
+ type="content"
+ primary="true"
+ autofind="false"
+ nodefaultsrc="true"
+ forcemessagemanager="true"
+ maychangeremoteness="true"
+ messagemanagergroup="single-page"
+ onclick="return contentAreaClick(event);"
+ onresize="messagePaneOnResize();"/>
+ <splitter id="attachment-splitter" orient="vertical"
+ resizebefore="closest" resizeafter="closest"
+ collapse="after" collapsed="true"/>
+ <vbox id="attachmentView" collapsed="true">
+#include msgAttachmentView.inc.xhtml
+ </vbox>
+ <findbar id="FindToolbar" browserid="messagepane"/>
+ </vbox>
+#include msgSecurityPane.inc.xhtml
+ </hbox>
+</html:body>
+</html>
diff --git a/comm/mail/base/content/aboutRights.xhtml b/comm/mail/base/content/aboutRights.xhtml
new file mode 100644
index 0000000000..51997dc87d
--- /dev/null
+++ b/comm/mail/base/content/aboutRights.xhtml
@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd">
+%htmlDTD; ]>
+
+<!-- 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/. -->
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'"
+ />
+ <title data-l10n-id="rights-title"></title>
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/info-pages.css"
+ type="text/css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/aboutRights.css"
+ type="text/css"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/aboutRights.ftl" />
+ </head>
+
+ <body id="your-rights">
+ <div class="container">
+ <div class="rights-header">
+ <div>
+ <h1 data-l10n-id="rights-title"></h1>
+
+ <p data-l10n-id="rights-intro"></p>
+ </div>
+ </div>
+
+ <ul>
+ <li data-l10n-id="rights-intro-point-1">
+ <a
+ href="http://www.mozilla.org/MPL/"
+ data-l10n-name="mozilla-public-license-link"
+ ></a>
+ </li>
+ <!-- Point 2 discusses Mozilla trademarks, and isn't needed when the build is unbranded.
+ - Point 4 discusses privacy policy, unbranded builds get a placeholder (for the vendor to replace)
+ - Point 5 discusses web service terms, unbranded builds gets a placeholder (for the vendor to replace) -->
+ <li data-l10n-id="rights-intro-point-2">
+ <a
+ href="http://www.mozilla.org/foundation/trademarks/policy.html"
+ data-l10n-name="mozilla-trademarks-link"
+ ></a>
+ </li>
+ <li data-l10n-id="rights-intro-point-3"></li>
+ <li data-l10n-id="rights-intro-point-4">
+ <a
+ href="https://www.mozilla.org/legal/privacy/firefox.html"
+ data-l10n-name="mozilla-privacy-policy-link"
+ ></a>
+ </li>
+ <li data-l10n-id="rights-intro-point-5">
+ <a
+ href="about:rights#webservices"
+ id="showWebServices"
+ data-l10n-name="mozilla-service-terms-link"
+ ></a>
+ </li>
+ <li data-l10n-id="rights-intro-point-6"></li>
+ </ul>
+
+ <div id="webservices-container">
+ <a name="webservices" />
+ <h3 data-l10n-id="rights-webservices-header"></h3>
+
+ <p data-l10n-id="rights-webservices2">
+ <a
+ href="about:rights#disabling-webservices"
+ id="showDisablingWebServices"
+ data-l10n-name="mozilla-disable-service-link"
+ ></a>
+ </p>
+
+ <div id="disabling-webservices-container" style="margin-left: 40px">
+ <a name="disabling-webservices" />
+ <p data-l10n-id="rights-locationawarebrowsing"></p>
+ <ul>
+ <li data-l10n-id="rights-locationawarebrowsing-term-1"></li>
+ <li data-l10n-id="rights-locationawarebrowsing-term-2"></li>
+ <li data-l10n-id="rights-locationawarebrowsing-term-3"></li>
+ <li data-l10n-id="rights-locationawarebrowsing-term-4"></li>
+ </ul>
+ </div>
+
+ <ol>
+ <!-- Terms only apply to official builds, unbranded builds get a placeholder. -->
+ <li data-l10n-id="rights-webservices-term-1"></li>
+ <li data-l10n-id="rights-webservices-term-2"></li>
+ <li data-l10n-id="rights-webservices-term-3"></li>
+ <li data-l10n-id="rights-webservices-term-4"></li>
+ <li data-l10n-id="rights-webservices-term-5"></li>
+ <li data-l10n-id="rights-webservices-term-6"></li>
+ <li data-l10n-id="rights-webservices-term-7"></li>
+ </ol>
+ </div>
+ </div>
+ </body>
+ <script src="chrome://global/content/aboutRights.js" />
+</html>
diff --git a/comm/mail/base/content/browserRequest.js b/comm/mail/base/content/browserRequest.js
new file mode 100644
index 0000000000..3cf695f94a
--- /dev/null
+++ b/comm/mail/base/content/browserRequest.js
@@ -0,0 +1,145 @@
+/* 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 { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+/* Magic global things the <browser> and its entourage of logic expect. */
+var PopupNotifications = {
+ show(browser, id, message) {
+ console.warn(
+ "Not showing popup notification",
+ id,
+ "with the message",
+ message
+ );
+ },
+};
+
+var gBrowser = {
+ get selectedBrowser() {
+ return document.getElementById("requestFrame");
+ },
+ _getAndMaybeCreateDateTimePickerPanel() {
+ return this.selectedBrowser.dateTimePicker;
+ },
+ get webNavigation() {
+ return this.selectedBrowser.webNavigation;
+ },
+};
+
+function getBrowser() {
+ return gBrowser.selectedBrowser;
+}
+
+/* Logic to actually run the login process and window contents */
+var reporterListener = {
+ _isBusy: false,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ onStateChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in unsigned long*/ aStateFlags,
+ /* in nsresult*/ aStatus
+ ) {},
+
+ onProgressChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in long*/ aCurSelfProgress,
+ /* in long */ aMaxSelfProgress,
+ /* in long */ aCurTotalProgress,
+ /* in long */ aMaxTotalProgress
+ ) {},
+
+ onLocationChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in nsIURI*/ aLocation
+ ) {
+ document.getElementById("headerMessage").value = aLocation.spec;
+ },
+
+ onStatusChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in nsresult*/ aStatus,
+ /* in wstring*/ aMessage
+ ) {},
+
+ onSecurityChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in unsigned long*/ aState
+ ) {
+ const wpl_security_bits =
+ Ci.nsIWebProgressListener.STATE_IS_SECURE |
+ Ci.nsIWebProgressListener.STATE_IS_BROKEN |
+ Ci.nsIWebProgressListener.STATE_IS_INSECURE;
+
+ let icon = document.getElementById("security-icon");
+ switch (aState & wpl_security_bits) {
+ case Ci.nsIWebProgressListener.STATE_IS_SECURE:
+ icon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/connection-secure.svg"
+ );
+ // Set alt.
+ document.l10n.setAttributes(icon, "content-tab-security-high-icon");
+ icon.classList.add("secure-connection-icon");
+ break;
+ case Ci.nsIWebProgressListener.STATE_IS_BROKEN:
+ icon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/connection-insecure.svg"
+ );
+ document.l10n.setAttributes(icon, "content-tab-security-broken-icon");
+ icon.classList.remove("secure-connection-icon");
+ break;
+ default:
+ icon.removeAttribute("src");
+ icon.removeAttribute("data-l10n-id");
+ icon.removeAttribute("alt");
+ icon.classList.remove("secure-connection-icon");
+ break;
+ }
+ },
+
+ onContentBlockingEvent(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in unsigned long*/ aEvent
+ ) {},
+};
+
+function cancelRequest() {
+ reportUserClosed();
+ window.close();
+}
+
+function reportUserClosed() {
+ let request = window.arguments[0].wrappedJSObject;
+ request.cancelled();
+}
+
+function loadRequestedUrl() {
+ let request = window.arguments[0].wrappedJSObject;
+
+ var browser = document.getElementById("requestFrame");
+ browser.addProgressListener(reporterListener, Ci.nsIWebProgress.NOTIFY_ALL);
+ var url = request.url;
+ if (url == "") {
+ document.getElementById("headerMessage").value = request.promptText;
+ } else {
+ MailE10SUtils.loadURI(browser, url);
+ document.getElementById("headerMessage").value = url;
+ }
+ request.loaded(window, browser.webProgress);
+}
diff --git a/comm/mail/base/content/browserRequest.xhtml b/comm/mail/base/content/browserRequest.xhtml
new file mode 100644
index 0000000000..b9a77766a9
--- /dev/null
+++ b/comm/mail/base/content/browserRequest.xhtml
@@ -0,0 +1,58 @@
+<?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://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"?>
+
+<!DOCTYPE window [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd">
+%messengerDTD;
+]>
+<window id="browserRequest"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml"
+ buttons=","
+ onload="loadRequestedUrl()"
+ onclose="reportUserClosed()"
+ title=""
+ width="800"
+ height="500"
+ orient="vertical">
+ <popupset id="mainPopupSet">
+#define NO_BROWSERCONTEXT
+#include widgets/browserPopups.inc.xhtml
+ </popupset>
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://messenger/content/viewZoomOverlay.js"/>
+ <script src="chrome://messenger/content/browserRequest.js"/>
+
+ <html:link rel="localization" href="messenger/messenger.ftl"/>
+
+ <keyset id="mainKeyset">
+ <key id="key_close" key="w" modifiers="accel" oncommand="cancelRequest()"/>
+ <key id="key_close2" keycode="VK_ESCAPE" oncommand="cancelRequest()"/>
+ </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:input>
+ </html:div>
+ <browser id="requestFrame"
+ type="content"
+ nodefaultsrc="true"
+ maychangeremoteness="true"
+ flex="1"
+ autocompletepopup="PopupAutoComplete"/>
+</window>
diff --git a/comm/mail/base/content/buildconfig.html b/comm/mail/base/content/buildconfig.html
new file mode 100644
index 0000000000..1a699cbfed
--- /dev/null
+++ b/comm/mail/base/content/buildconfig.html
@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+# 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/.
+#
+#filter substitution
+#include @TOPOBJDIR@/source-repo.h
+#include @TOPOBJDIR@/buildid.h
+<html>
+ <head>
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src chrome:; object-src 'none'" />
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width; user-scalable=false;">
+ <title>Build Configuration</title>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" type="text/css">
+ <link rel="stylesheet" href="chrome://global/content/buildconfig.css" type="text/css">
+ </head>
+ <body>
+ <div>
+ <h1>Build Configuration</h1>
+ <h2>@MOZ_APP_DISPLAYNAME@ @MOZ_APP_VERSION_DISPLAY@ - @MOZ_BUILDID@</h2>
+ <table>
+ <tbody>
+ #ifdef MOZ_COMM_SOURCE_URL
+ <tr>
+ <th>
+ @MOZ_APP_DISPLAYNAME@ source
+ </th>
+ </tr>
+ <tr>
+ <td>
+ <a href="@MOZ_COMM_SOURCE_URL@">@MOZ_COMM_SOURCE_URL@</a>
+ </td>
+ </tr>
+ #endif
+ #ifdef MOZ_GECKO_SOURCE_URL
+ <tr>
+ <th>
+ Platform source
+ </th>
+ </tr>
+ <tr>
+ <td>
+ <a href="@MOZ_GECKO_SOURCE_URL@">@MOZ_GECKO_SOURCE_URL@</a>
+ </td>
+ </tr>
+ #endif
+ </tbody>
+ </table>
+
+ <p>
+ The latest information on building @MOZ_APP_DISPLAYNAME@ can be found at
+ <a href="@THUNDERBIRD_DEVELOPER_WWW@">@THUNDERBIRD_DEVELOPER_WWW@</a>.
+ </p>
+
+ <h2>Build platform</h2>
+ <table>
+ <tbody>
+ <tr>
+ <th>Target</th>
+ </tr>
+ <tr>
+ <td>@target@</td>
+ </tr>
+ </tbody>
+ </table>
+ #if defined(CC) && defined(CXX) && defined(RUSTC)
+ <h2>Build tools</h2>
+ <table>
+ <tbody>
+ <tr>
+ <th>Compiler</th>
+ <th>Version</th>
+ <th>Compiler flags</th>
+ </tr>
+ <tr>
+ <td><code>@CC@</code></td>
+ <td><code>@CC_VERSION@</code></td>
+ <td><code>@CFLAGS@</code></td>
+ </tr>
+ <tr>
+ <td><code>@CXX@</code></td>
+ <td><code>@CC_VERSION@</code></td>
+ <td><code>@CXXFLAGS@</code></td>
+ </tr>
+ <tr>
+ <td><code>@RUSTC@</code></td>
+ <td><code>@RUSTC_VERSION@</code></td>
+ <td><code>@RUSTFLAGS@</code></td>
+ </tr>
+ </tbody>
+ </table>
+ #endif
+ <h2>Configure options</h2>
+ <table>
+ <tbody>
+ <tr>
+ <td>
+ <code>@MOZ_CONFIGURE_OPTIONS@</code>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/base/content/commonDialog.xhtml b/comm/mail/base/content/commonDialog.xhtml
new file mode 100644
index 0000000000..4072ff52f6
--- /dev/null
+++ b/comm/mail/base/content/commonDialog.xhtml
@@ -0,0 +1,110 @@
+<?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/content/commonDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://global/skin/commonDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window
+ id="commonDialogWindow"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ aria-describedby="infoBody"
+ headerparent="dialogGrid"
+ onunload="commonDialogOnUnload();"
+>
+ <dialog id="commonDialog" buttonpack="end">
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="toolkit/global/commonDialog.ftl" />
+ </linkset>
+ <script src="chrome://global/content/adjustableTitle.js" />
+ <script src="chrome://global/content/commonDialog.js" />
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://global/content/customElements.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+ <script>
+ /* eslint-disable no-undef */
+ document.addEventListener("DOMContentLoaded", function () {
+ commonDialogOnLoad();
+ });
+ </script>
+
+ <commandset id="selectEditMenuItems">
+ <command
+ id="cmd_copy"
+ oncommand="goDoCommand('cmd_copy')"
+ disabled="true"
+ />
+ <command id="cmd_selectAll" oncommand="goDoCommand('cmd_selectAll')" />
+ </commandset>
+
+ <popupset id="contentAreaContextSet">
+ <menupopup
+ id="contentAreaContextMenu"
+ onpopupshowing="goUpdateCommand('cmd_copy')"
+ >
+ <menuitem
+ id="context-copy"
+ data-l10n-id="common-dialog-copy-cmd"
+ command="cmd_copy"
+ disabled="true"
+ />
+ <menuitem
+ id="context-selectall"
+ data-l10n-id="common-dialog-select-all-cmd"
+ command="cmd_selectAll"
+ />
+ </menupopup>
+ </popupset>
+
+ <!-- The <div> was added in bug 1606617 to workaround bug 1614447 -->
+ <div xmlns="http://www.w3.org/1999/xhtml">
+ <div id="dialogGrid">
+ <div class="dialogRow" id="infoRow" hidden="hidden">
+ <div id="iconContainer">
+ <img id="infoIcon" src="" alt="" role="presentation" />
+ </div>
+ <div id="infoContainer">
+ <xul:description id="infoTitle" />
+ <xul:description
+ id="infoBody"
+ context="contentAreaContextMenu"
+ noinitialfocus="true"
+ />
+ </div>
+ </div>
+ <div id="loginContainer" class="dialogRow" hidden="hidden">
+ <xul:label
+ id="loginLabel"
+ data-l10n-id="common-dialog-username"
+ control="loginTextbox"
+ />
+ <input type="text" id="loginTextbox" dir="ltr" />
+ </div>
+ <div id="password1Container" class="dialogRow" hidden="hidden">
+ <xul:label
+ id="password1Label"
+ data-l10n-id="common-dialog-password"
+ control="password1Textbox"
+ />
+ <input type="password" id="password1Textbox" dir="ltr" />
+ </div>
+ <div id="checkboxContainer" class="dialogRow" hidden="hidden">
+ <div />
+ <!-- spacer -->
+ <xul:checkbox id="checkbox" oncommand="Dialog.onCheckbox()" />
+ </div>
+ </div>
+ </div>
+ </dialog>
+</window>
diff --git a/comm/mail/base/content/compactFoldersDialog.js b/comm/mail/base/content/compactFoldersDialog.js
new file mode 100644
index 0000000000..b0ac027266
--- /dev/null
+++ b/comm/mail/base/content/compactFoldersDialog.js
@@ -0,0 +1,54 @@
+/* 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 propBag, args;
+
+document.addEventListener("DOMContentLoaded", compactDialogOnDOMContentLoaded);
+// Bug 1720540: Call sizeToContent only after the entire window has been loaded,
+// including the shadow DOM and the updated fluent strings.
+window.addEventListener("load", window.sizeToContent);
+
+function compactDialogOnDOMContentLoaded() {
+ propBag = window.arguments[0]
+ .QueryInterface(Ci.nsIWritablePropertyBag2)
+ .QueryInterface(Ci.nsIWritablePropertyBag);
+
+ // Convert to a JS object.
+ args = {};
+ for (let prop of propBag.enumerator) {
+ args[prop.name] = prop.value;
+ }
+
+ // We're deliberately adding the data-l10n-args attribute synchronously to
+ // avoid race issues for window.sizeToContent later on.
+ document
+ .getElementById("compactFoldersText")
+ .setAttribute("data-l10n-args", JSON.stringify({ data: args.compactSize }));
+
+ document.addEventListener("dialogaccept", function () {
+ args.buttonNumClicked = 0;
+ args.checked = document.getElementById("neverAskCheckbox").checked;
+ });
+
+ document.addEventListener("dialogcancel", function () {
+ args.buttonNumClicked = 1;
+ });
+
+ document.addEventListener("dialogextra1", function () {
+ // Open the support article URL and leave the dialog open.
+ let uri = Services.io.newURI(
+ "https://support.mozilla.org/kb/compacting-folders"
+ );
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ });
+}
+
+function compactDialogOnUnload() {
+ // Convert args back into property bag.
+ for (let propName in args) {
+ propBag.setProperty(propName, args[propName]);
+ }
+}
diff --git a/comm/mail/base/content/compactFoldersDialog.xhtml b/comm/mail/base/content/compactFoldersDialog.xhtml
new file mode 100644
index 0000000000..427c70848c
--- /dev/null
+++ b/comm/mail/base/content/compactFoldersDialog.xhtml
@@ -0,0 +1,54 @@
+<?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://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="compact-dialog-window-title"
+ role="alert"
+ lightweightthemes="true"
+ onunload="compactDialogOnUnload();"
+>
+ <dialog
+ id="folderCompactDialog"
+ buttons="accept,cancel,extra1"
+ data-l10n-id="compact-dialog"
+ data-l10n-attrs="buttonlabelaccept, buttonlabelcancel, buttonlabelextra1, buttonaccesskeyaccept, buttonaccesskeycancel, buttonaccesskeyextra1"
+ >
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="messenger/compactFoldersDialog.ftl" />
+ </linkset>
+
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/compactFoldersDialog.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <hbox>
+ <vbox class="image-container">
+ <html:img src="chrome://global/skin/icons/help.svg" alt="" />
+ </vbox>
+
+ <vbox flex="1" class="text-container">
+ <description
+ id="compactFoldersText"
+ data-l10n-id="compact-dialog-message"
+ data-l10n-args='{"data" : ""}'
+ />
+ <checkbox
+ id="neverAskCheckbox"
+ data-l10n-id="compact-dialog-never-ask-checkbox"
+ />
+ </vbox>
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/base/content/contentAreaClick.js b/comm/mail/base/content/contentAreaClick.js
new file mode 100644
index 0000000000..647c4e7551
--- /dev/null
+++ b/comm/mail/base/content/contentAreaClick.js
@@ -0,0 +1,206 @@
+/**
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/contentAreaUtils.js */
+/* import-globals-from utilityOverlay.js */
+
+/* globals getMessagePaneBrowser */ // From aboutMessage.js
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ PhishingDetector: "resource:///modules/PhishingDetector.jsm",
+});
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "alternativeAddonSearchUrl",
+ "extensions.alternativeAddonSearch.url"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "canonicalAddonServerUrl",
+ "extensions.canonicalAddonServer.url"
+);
+/**
+ * Extract the href from the link click event.
+ * We look for HTMLAnchorElement, HTMLAreaElement, HTMLLinkElement,
+ * HTMLInputElement.form.action, and nested anchor tags.
+ * If the clicked element was a HTMLInputElement or HTMLButtonElement
+ * we return the form action.
+ *
+ * @returns [href, linkText] the url and the text for the link being clicked.
+ */
+function hRefForClickEvent(aEvent, aDontCheckInputElement) {
+ let target =
+ aEvent.type == "command"
+ ? document.commandDispatcher.focusedElement
+ : aEvent.target;
+
+ if (
+ HTMLImageElement.isInstance(target) &&
+ target.hasAttribute("overflowing")
+ ) {
+ // Click on zoomed image.
+ return [null, null];
+ }
+
+ let href = null;
+ let linkText = null;
+ if (
+ HTMLAnchorElement.isInstance(target) ||
+ HTMLAreaElement.isInstance(target) ||
+ HTMLLinkElement.isInstance(target)
+ ) {
+ if (target.hasAttribute("href")) {
+ href = target.href;
+ linkText = gatherTextUnder(target);
+ }
+ } else if (
+ !aDontCheckInputElement &&
+ (HTMLInputElement.isInstance(target) ||
+ HTMLButtonElement.isInstance(target))
+ ) {
+ if (target.form && target.form.action) {
+ href = target.form.action;
+ }
+ } else {
+ // We may be nested inside of a link node.
+ let linkNode = aEvent.target;
+ while (linkNode && !HTMLAnchorElement.isInstance(linkNode)) {
+ linkNode = linkNode.parentNode;
+ }
+
+ if (linkNode) {
+ href = linkNode.href;
+ linkText = gatherTextUnder(linkNode);
+ }
+ }
+ return [href, linkText];
+}
+
+/**
+ * Check whether the click target's or its ancestor's href
+ * points to an anchor on the page.
+ *
+ * @param HTMLElement aTargetNode - the element node.
+ * @returns - true if link pointing to anchor.
+ */
+function isLinkToAnchorOnPage(aTargetNode) {
+ let url = aTargetNode.ownerDocument.URL;
+ if (!url.startsWith("http")) {
+ return false;
+ }
+
+ let linkNode = aTargetNode;
+ while (linkNode && !HTMLAnchorElement.isInstance(linkNode)) {
+ linkNode = linkNode.parentNode;
+ }
+
+ // It's not a link with an anchor.
+ if (!linkNode || !linkNode.href || !linkNode.hash) {
+ return false;
+ }
+
+ // The link's href must match the document URL.
+ if (makeURI(linkNode.href).specIgnoringRef != makeURI(url).specIgnoringRef) {
+ return false;
+ }
+
+ return true;
+}
+
+// Called whenever the user clicks in the content area,
+// should always return true for click to go through.
+function contentAreaClick(aEvent) {
+ let target = aEvent.target;
+ if (target.localName == "browser") {
+ // This is a remote browser. Nothing useful can happen in this process.
+ return true;
+ }
+
+ // If we've loaded a web page url, and the element's or its ancestor's href
+ // points to an anchor on the page, let the click go through.
+ // Otherwise fall through and open externally.
+ if (isLinkToAnchorOnPage(target)) {
+ return true;
+ }
+
+ let [href, linkText] = hRefForClickEvent(aEvent);
+
+ if (!href && !aEvent.button) {
+ // Is this an image that we might want to scale?
+
+ if (HTMLImageElement.isInstance(target)) {
+ // Make sure it loaded successfully. No action if not or a broken link.
+ var req = target.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST);
+ if (!req || req.imageStatus & Ci.imgIRequest.STATUS_ERROR) {
+ return false;
+ }
+
+ // Is it an image?
+ if (target.localName == "img" && target.hasAttribute("overflowing")) {
+ if (target.hasAttribute("shrinktofit")) {
+ // Currently shrunk to fit, so unshrink it.
+ target.removeAttribute("shrinktofit");
+ } else {
+ // User wants to shrink now.
+ target.setAttribute("shrinktofit", true);
+ }
+
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if (!href || aEvent.button == 2) {
+ return true;
+ }
+
+ // We want all about, http and https links in the message pane to be loaded
+ // externally in a browser, therefore we need to detect that here and redirect
+ // as necessary.
+ let uri = makeURI(href);
+ if (
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .isExposedProtocol(uri.scheme) &&
+ !uri.schemeIs("http") &&
+ !uri.schemeIs("https")
+ ) {
+ return true;
+ }
+
+ // Add-on names in the Add-On Manager are links, but we don't want to do
+ // anything with them.
+ if (uri.schemeIs("addons")) {
+ return true;
+ }
+
+ // Now we're here, we know this should be loaded in an external browser, so
+ // prevent the default action so we don't try and load it here.
+ aEvent.preventDefault();
+
+ // Let the phishing detector check the link.
+ let urlPhishCheckResult = PhishingDetector.warnOnSuspiciousLinkClick(
+ window,
+ href,
+ linkText
+ );
+ if (urlPhishCheckResult === 1) {
+ return false; // Block request
+ }
+
+ if (urlPhishCheckResult === 0) {
+ // Use linkText instead.
+ openLinkExternally(linkText);
+ return true;
+ }
+
+ openLinkExternally(href);
+ return true;
+}
diff --git a/comm/mail/base/content/customElements.js b/comm/mail/base/content/customElements.js
new file mode 100644
index 0000000000..f5bec376a9
--- /dev/null
+++ b/comm/mail/base/content/customElements.js
@@ -0,0 +1,35 @@
+/* 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 is loaded into chrome windows with the subscript loader. Wrap in
+// a block to prevent accidentally leaking globals onto `window`.
+(() => {
+ // If toolkit customElements weren't already loaded, do it now.
+ if (!window.MozXULElement) {
+ Services.scriptloader.loadSubScript(
+ "chrome://global/content/customElements.js",
+ window
+ );
+ }
+
+ const isDummyDocument =
+ document.documentURI == "chrome://extensions/content/dummy.xhtml";
+ if (!isDummyDocument) {
+ for (let script of [
+ "chrome://chat/content/conversation-browser.js",
+ "chrome://messenger/content/gloda-autocomplete-input.js",
+ "chrome://chat/content/chat-tooltip.js",
+ "chrome://messenger/content/mailWidgets.js",
+ "chrome://messenger/content/statuspanel.js",
+ "chrome://messenger/content/foldersummary.js",
+ "chrome://messenger/content/addressbook/menulist-addrbooks.js",
+ "chrome://messenger/content/folder-menupopup.js",
+ "chrome://messenger/content/toolbarbutton-menu-button.js",
+ ]) {
+ Services.scriptloader.loadSubScript(script, window);
+ }
+ }
+})();
diff --git a/comm/mail/base/content/customizeToolbar.js b/comm/mail/base/content/customizeToolbar.js
new file mode 100644
index 0000000000..dd2b28bdab
--- /dev/null
+++ b/comm/mail/base/content/customizeToolbar.js
@@ -0,0 +1,836 @@
+/* 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 gToolboxDocument = null;
+var gToolbox = null;
+var gCurrentDragOverItem = null;
+var gToolboxChanged = false;
+var gToolboxSheet = false;
+var gPaletteBox = null;
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+function onLoad() {
+ if ("arguments" in window && window.arguments[0]) {
+ InitWithToolbox(window.arguments[0]);
+ repositionDialog(window);
+ } else if (window.frameElement && "toolbox" in window.frameElement) {
+ gToolboxSheet = true;
+ InitWithToolbox(window.frameElement.toolbox);
+ repositionDialog(window.frameElement.panel);
+ }
+}
+
+function InitWithToolbox(aToolbox) {
+ gToolbox = aToolbox;
+ dispatchCustomizationEvent("beforecustomization");
+ gToolboxDocument = gToolbox.ownerDocument;
+ gToolbox.customizing = true;
+ forEachCustomizableToolbar(function (toolbar) {
+ toolbar.setAttribute("customizing", "true");
+ });
+ gPaletteBox = document.getElementById("palette-box");
+
+ var elts = getRootElements();
+ for (let i = 0; i < elts.length; i++) {
+ elts[i].addEventListener("dragstart", onToolbarDragStart, true);
+ elts[i].addEventListener("dragover", onToolbarDragOver, true);
+ elts[i].addEventListener("dragleave", onToolbarDragLeave, true);
+ elts[i].addEventListener("drop", onToolbarDrop, true);
+ }
+
+ initDialog();
+}
+
+function onClose() {
+ if (!gToolboxSheet) {
+ window.close();
+ } else {
+ finishToolbarCustomization();
+ }
+}
+
+function onUnload() {
+ if (!gToolboxSheet) {
+ finishToolbarCustomization();
+ }
+}
+
+function finishToolbarCustomization() {
+ removeToolboxListeners();
+ unwrapToolbarItems();
+ persistCurrentSets();
+ gToolbox.customizing = false;
+ forEachCustomizableToolbar(function (toolbar) {
+ toolbar.removeAttribute("customizing");
+ });
+
+ notifyParentComplete();
+}
+
+function initDialog() {
+ var mode = gToolbox.getAttribute("mode");
+ document.getElementById("modelist").value = mode;
+ var smallIconsCheckbox = document.getElementById("smallicons");
+ smallIconsCheckbox.checked = gToolbox.getAttribute("iconsize") == "small";
+ if (mode == "text") {
+ smallIconsCheckbox.disabled = true;
+ }
+
+ if (AppConstants.MOZ_APP_NAME == "thunderbird") {
+ document.getElementById("showTitlebar").checked =
+ !Services.prefs.getBoolPref("mail.tabs.drawInTitlebar");
+ if (
+ window.opener &&
+ window.opener.document.documentElement.getAttribute("windowtype") ==
+ "mail:3pane"
+ ) {
+ document.getElementById("titlebarSettings").hidden = false;
+ }
+ }
+
+ // Build up the palette of other items.
+ buildPalette();
+
+ // Wrap all the items on the toolbar in toolbarpaletteitems.
+ wrapToolbarItems();
+}
+
+function repositionDialog(aWindow) {
+ // Position the dialog touching the bottom of the toolbox and centered with
+ // it.
+ if (!aWindow) {
+ return;
+ }
+
+ var width;
+ if (aWindow != window) {
+ width = aWindow.getBoundingClientRect().width;
+ } else if (document.documentElement.hasAttribute("width")) {
+ width = document.documentElement.getAttribute("width");
+ } else {
+ width = parseInt(document.documentElement.style.width);
+ }
+ var boundingRect = gToolbox.getBoundingClientRect();
+ var screenX = gToolbox.screenX + (boundingRect.width - width) / 2;
+ var screenY = gToolbox.screenY + boundingRect.height;
+
+ aWindow.moveTo(screenX, screenY);
+}
+
+function removeToolboxListeners() {
+ var elts = getRootElements();
+ for (let i = 0; i < elts.length; i++) {
+ elts[i].removeEventListener("dragstart", onToolbarDragStart, true);
+ elts[i].removeEventListener("dragover", onToolbarDragOver, true);
+ elts[i].removeEventListener("dragleave", onToolbarDragLeave, true);
+ elts[i].removeEventListener("drop", onToolbarDrop, true);
+ }
+}
+
+/**
+ * Invoke a callback on the toolbox to notify it that the dialog is done
+ * and going away.
+ */
+function notifyParentComplete() {
+ if ("customizeDone" in gToolbox) {
+ gToolbox.customizeDone(gToolboxChanged);
+ }
+ dispatchCustomizationEvent("aftercustomization");
+}
+
+function toolboxChanged(aType) {
+ gToolboxChanged = true;
+ if ("customizeChange" in gToolbox) {
+ gToolbox.customizeChange(aType);
+ }
+ dispatchCustomizationEvent("customizationchange");
+}
+
+function dispatchCustomizationEvent(aEventName) {
+ var evt = document.createEvent("Events");
+ evt.initEvent(aEventName, true, true);
+ gToolbox.dispatchEvent(evt);
+}
+
+/**
+ * Persist the current set of buttons in all customizable toolbars to
+ * localstore.
+ */
+function persistCurrentSets() {
+ if (!gToolboxChanged || gToolboxDocument.defaultView.closed) {
+ return;
+ }
+
+ forEachCustomizableToolbar(function (toolbar) {
+ // Calculate currentset and store it in the attribute.
+ var currentSet = toolbar.currentSet;
+ toolbar.setAttribute("currentset", currentSet);
+ Services.xulStore.persist(toolbar, "currentset");
+ });
+}
+
+/**
+ * Wraps all items in all customizable toolbars in a toolbox.
+ */
+function wrapToolbarItems() {
+ forEachCustomizableToolbar(function (toolbar) {
+ for (let item of toolbar.children) {
+ if (AppConstants.platform == "macosx") {
+ if (
+ item.firstElementChild &&
+ item.firstElementChild.localName == "menubar"
+ ) {
+ return;
+ }
+ }
+ if (isToolbarItem(item)) {
+ let wrapper = wrapToolbarItem(item);
+ cleanupItemForToolbar(item, wrapper);
+ }
+ }
+ });
+}
+
+function getRootElements() {
+ if (window.frameElement && "externalToolbars" in window.frameElement) {
+ return [gToolbox].concat(window.frameElement.externalToolbars);
+ }
+ if ("arguments" in window && window.arguments[1].length > 0) {
+ return [gToolbox].concat(window.arguments[1]);
+ }
+ return [gToolbox];
+}
+
+/**
+ * Unwraps all items in all customizable toolbars in a toolbox.
+ */
+function unwrapToolbarItems() {
+ let elts = getRootElements();
+ for (let i = 0; i < elts.length; i++) {
+ let paletteItems = elts[i].getElementsByTagName("toolbarpaletteitem");
+ let paletteItem;
+ while ((paletteItem = paletteItems.item(0)) != null) {
+ let toolbarItem = paletteItem.firstElementChild;
+ restoreItemForToolbar(toolbarItem, paletteItem);
+ paletteItem.parentNode.replaceChild(toolbarItem, paletteItem);
+ }
+ }
+}
+
+/**
+ * Creates a wrapper that can be used to contain a toolbaritem and prevent
+ * it from receiving UI events.
+ */
+function createWrapper(aId, aDocument) {
+ let wrapper = aDocument.createXULElement("toolbarpaletteitem");
+
+ wrapper.id = "wrapper-" + aId;
+ return wrapper;
+}
+
+/**
+ * Wraps an item that has been cloned from a template and adds
+ * it to the end of the palette.
+ */
+function wrapPaletteItem(aPaletteItem) {
+ var wrapper = createWrapper(aPaletteItem.id, document);
+
+ wrapper.appendChild(aPaletteItem);
+
+ // XXX We need to call this AFTER the palette item has been appended
+ // to the wrapper or else we crash dropping certain buttons on the
+ // palette due to removal of the command and disabled attributes - JRH
+ cleanUpItemForPalette(aPaletteItem, wrapper);
+
+ gPaletteBox.appendChild(wrapper);
+}
+
+/**
+ * Wraps an item that is currently on a toolbar and replaces the item
+ * with the wrapper. This is not used when dropping items from the palette,
+ * only when first starting the dialog and wrapping everything on the toolbars.
+ */
+function wrapToolbarItem(aToolbarItem) {
+ var wrapper = createWrapper(aToolbarItem.id, gToolboxDocument);
+
+ wrapper.flex = aToolbarItem.flex;
+
+ aToolbarItem.parentNode.replaceChild(wrapper, aToolbarItem);
+
+ wrapper.appendChild(aToolbarItem);
+
+ return wrapper;
+}
+
+/**
+ * Get the list of ids for the current set of items on each toolbar.
+ */
+function getCurrentItemIds() {
+ var currentItems = {};
+ forEachCustomizableToolbar(function (toolbar) {
+ var child = toolbar.firstElementChild;
+ while (child) {
+ if (isToolbarItem(child)) {
+ currentItems[child.id] = 1;
+ }
+ child = child.nextElementSibling;
+ }
+ });
+ return currentItems;
+}
+
+/**
+ * Builds the palette of draggable items that are not yet in a toolbar.
+ */
+function buildPalette() {
+ // Empty the palette first.
+ while (gPaletteBox.lastElementChild) {
+ gPaletteBox.lastChild.remove();
+ }
+
+ // Add the toolbar separator item.
+ var templateNode = document.createXULElement("toolbarseparator");
+ templateNode.id = "separator";
+ wrapPaletteItem(templateNode);
+
+ // Add the toolbar spring item.
+ templateNode = document.createXULElement("toolbarspring");
+ templateNode.id = "spring";
+ templateNode.flex = 1;
+ wrapPaletteItem(templateNode);
+
+ // Add the toolbar spacer item.
+ templateNode = document.createXULElement("toolbarspacer");
+ templateNode.id = "spacer";
+ templateNode.flex = 1;
+ wrapPaletteItem(templateNode);
+
+ var currentItems = getCurrentItemIds();
+ templateNode = gToolbox.palette.firstElementChild;
+ while (templateNode) {
+ // Check if the item is already in a toolbar before adding it to the
+ // palette, but do not add back separators, springs and spacers - we do
+ // not want them duplicated.
+ if (!isSpecialItem(templateNode) && !(templateNode.id in currentItems)) {
+ var paletteItem = document.importNode(templateNode, true);
+ wrapPaletteItem(paletteItem);
+ }
+
+ templateNode = templateNode.nextElementSibling;
+ }
+}
+
+/**
+ * Makes sure that an item that has been cloned from a template
+ * is stripped of any attributes that may adversely affect its
+ * appearance in the palette.
+ */
+function cleanUpItemForPalette(aItem, aWrapper) {
+ aWrapper.setAttribute("place", "palette");
+ setWrapperType(aItem, aWrapper);
+
+ if (aItem.hasAttribute("title")) {
+ aWrapper.setAttribute("title", aItem.getAttribute("title"));
+ } else if (aItem.hasAttribute("label")) {
+ aWrapper.setAttribute("title", aItem.getAttribute("label"));
+ } else if (isSpecialItem(aItem)) {
+ var stringBundle = document.getElementById("stringBundle");
+ // Remove the common "toolbar" prefix to generate the string name.
+ var title = stringBundle.getString(aItem.localName.slice(7) + "Title");
+ aWrapper.setAttribute("title", title);
+ }
+ aWrapper.setAttribute("tooltiptext", aWrapper.getAttribute("title"));
+
+ // Remove attributes that screw up our appearance.
+ aItem.removeAttribute("command");
+ aItem.removeAttribute("observes");
+ aItem.removeAttribute("type");
+ aItem.removeAttribute("width");
+ aItem.removeAttribute("checked");
+ aItem.removeAttribute("collapsed");
+
+ aWrapper.querySelectorAll("[disabled]").forEach(function (aNode) {
+ aNode.removeAttribute("disabled");
+ });
+}
+
+/**
+ * Makes sure that an item that has been cloned from a template
+ * is stripped of all properties that may adversely affect its
+ * appearance in the toolbar. Store critical properties on the
+ * wrapper so they can be put back on the item when we're done.
+ */
+function cleanupItemForToolbar(aItem, aWrapper) {
+ setWrapperType(aItem, aWrapper);
+ aWrapper.setAttribute("place", "toolbar");
+
+ if (aItem.hasAttribute("command")) {
+ aWrapper.setAttribute("itemcommand", aItem.getAttribute("command"));
+ aItem.removeAttribute("command");
+ }
+
+ if (aItem.hasAttribute("collapsed")) {
+ aWrapper.setAttribute("itemcollapsed", aItem.getAttribute("collapsed"));
+ aItem.removeAttribute("collapsed");
+ }
+
+ if (aItem.checked) {
+ aWrapper.setAttribute("itemchecked", "true");
+ aItem.checked = false;
+ }
+
+ if (aItem.disabled) {
+ aWrapper.setAttribute("itemdisabled", "true");
+ aItem.disabled = false;
+ }
+}
+
+/**
+ * Restore all the properties that we stripped off above.
+ */
+function restoreItemForToolbar(aItem, aWrapper) {
+ if (aWrapper.hasAttribute("itemdisabled")) {
+ aItem.disabled = true;
+ }
+
+ if (aWrapper.hasAttribute("itemchecked")) {
+ aItem.checked = true;
+ }
+
+ if (aWrapper.hasAttribute("itemcollapsed")) {
+ let collapsed = aWrapper.getAttribute("itemcollapsed");
+ aItem.setAttribute("collapsed", collapsed);
+ }
+
+ if (aWrapper.hasAttribute("itemcommand")) {
+ let commandID = aWrapper.getAttribute("itemcommand");
+ aItem.setAttribute("command", commandID);
+
+ // XXX Bug 309953 - toolbarbuttons aren't in sync with their commands after customizing
+ let command = gToolboxDocument.getElementById(commandID);
+ if (command && command.hasAttribute("disabled")) {
+ aItem.setAttribute("disabled", command.getAttribute("disabled"));
+ }
+ }
+}
+
+function setWrapperType(aItem, aWrapper) {
+ if (aItem.localName == "toolbarseparator") {
+ aWrapper.setAttribute("type", "separator");
+ } else if (aItem.localName == "toolbarspring") {
+ aWrapper.setAttribute("type", "spring");
+ } else if (aItem.localName == "toolbarspacer") {
+ aWrapper.setAttribute("type", "spacer");
+ } else if (aItem.localName == "toolbaritem" && aItem.firstElementChild) {
+ aWrapper.setAttribute("type", aItem.firstElementChild.localName);
+ }
+}
+
+function setDragActive(aItem, aValue) {
+ var node = aItem;
+ var direction = window.getComputedStyle(aItem).direction;
+ var value = direction == "ltr" ? "left" : "right";
+ if (aItem.localName == "toolbar") {
+ node = aItem.lastElementChild;
+ value = direction == "ltr" ? "right" : "left";
+ }
+
+ if (!node) {
+ return;
+ }
+
+ if (aValue) {
+ if (!node.hasAttribute("dragover")) {
+ node.setAttribute("dragover", value);
+ }
+ } else {
+ node.removeAttribute("dragover");
+ }
+}
+
+/**
+ * Restore the default set of buttons to fixed toolbars,
+ * remove all custom toolbars, and rebuild the palette.
+ */
+function restoreDefaultSet() {
+ // Unwrap the items on the toolbar.
+ unwrapToolbarItems();
+
+ // Remove all of the customized toolbars.
+ var child = gToolbox.lastElementChild;
+ while (child) {
+ if (child.hasAttribute("customindex")) {
+ var thisChild = child;
+ child = child.previousElementSibling;
+ thisChild.currentSet = "__empty";
+ gToolbox.removeChild(thisChild);
+ } else {
+ child = child.previousElementSibling;
+ }
+ }
+
+ // Restore the defaultset for fixed toolbars.
+ forEachCustomizableToolbar(function (toolbar) {
+ var defaultSet = toolbar.getAttribute("defaultset");
+ if (defaultSet) {
+ toolbar.currentSet = defaultSet;
+ }
+ });
+
+ // Restore the default icon size and mode.
+ document.getElementById("smallicons").checked = updateIconSize() == "small";
+ document.getElementById("modelist").value = updateToolbarMode();
+
+ // Now rebuild the palette.
+ buildPalette();
+
+ // Now re-wrap the items on the toolbar.
+ wrapToolbarItems();
+
+ toolboxChanged("reset");
+}
+
+function updateIconSize(aSize) {
+ return updateToolboxProperty("iconsize", aSize, "large");
+}
+
+function updateTitlebar() {
+ let titlebarCheckbox = document.getElementById("showTitlebar");
+ Services.prefs.setBoolPref(
+ "mail.tabs.drawInTitlebar",
+ !titlebarCheckbox.checked
+ );
+
+ // Bring the customizeToolbar window to front (on linux it's behind the main
+ // window). Otherwise the customization window gets left in the background.
+ setTimeout(() => window.focus(), 100);
+}
+
+function updateToolbarMode(aModeValue) {
+ var mode = updateToolboxProperty("mode", aModeValue, "icons");
+
+ var iconSizeCheckbox = document.getElementById("smallicons");
+ iconSizeCheckbox.disabled = mode == "text";
+
+ return mode;
+}
+
+function updateToolboxProperty(aProp, aValue, aToolkitDefault) {
+ var toolboxDefault =
+ gToolbox.getAttribute("default" + aProp) || aToolkitDefault;
+
+ gToolbox.setAttribute(aProp, aValue || toolboxDefault);
+ Services.xulStore.persist(gToolbox, aProp);
+
+ forEachCustomizableToolbar(function (toolbar) {
+ var toolbarDefault =
+ toolbar.getAttribute("default" + aProp) || toolboxDefault;
+ if (
+ toolbar.getAttribute("lock" + aProp) == "true" &&
+ toolbar.getAttribute(aProp) == toolbarDefault
+ ) {
+ return;
+ }
+
+ toolbar.setAttribute(aProp, aValue || toolbarDefault);
+ Services.xulStore.persist(toolbar, aProp);
+ });
+
+ toolboxChanged(aProp);
+
+ return aValue || toolboxDefault;
+}
+
+function forEachCustomizableToolbar(callback) {
+ if (window.frameElement && "externalToolbars" in window.frameElement) {
+ Array.from(window.frameElement.externalToolbars)
+ .filter(isCustomizableToolbar)
+ .forEach(callback);
+ } else if ("arguments" in window && window.arguments[1].length > 0) {
+ Array.from(window.arguments[1])
+ .filter(isCustomizableToolbar)
+ .forEach(callback);
+ }
+ Array.from(gToolbox.children).filter(isCustomizableToolbar).forEach(callback);
+}
+
+function isCustomizableToolbar(aElt) {
+ return (
+ aElt.localName == "toolbar" && aElt.getAttribute("customizable") == "true"
+ );
+}
+
+function isSpecialItem(aElt) {
+ return (
+ aElt.localName == "toolbarseparator" ||
+ aElt.localName == "toolbarspring" ||
+ aElt.localName == "toolbarspacer"
+ );
+}
+
+function isToolbarItem(aElt) {
+ return (
+ aElt.localName == "toolbarbutton" ||
+ aElt.localName == "toolbaritem" ||
+ aElt.localName == "toolbarseparator" ||
+ aElt.localName == "toolbarspring" ||
+ aElt.localName == "toolbarspacer"
+ );
+}
+
+// Drag and Drop observers
+
+function onToolbarDragLeave(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+
+ if (gCurrentDragOverItem) {
+ setDragActive(gCurrentDragOverItem, false);
+ }
+}
+
+function onToolbarDragStart(aEvent) {
+ var item = aEvent.target;
+ while (item && item.localName != "toolbarpaletteitem") {
+ if (item.localName == "toolbar") {
+ return;
+ }
+ item = item.parentNode;
+ }
+
+ item.setAttribute("dragactive", "true");
+
+ var dt = aEvent.dataTransfer;
+ var documentId = gToolboxDocument.documentElement.id;
+ dt.setData("text/toolbarwrapper-id/" + documentId, item.firstElementChild.id);
+ dt.effectAllowed = "move";
+}
+
+function onToolbarDragOver(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+
+ var documentId = gToolboxDocument.documentElement.id;
+ if (
+ !aEvent.dataTransfer.types.includes(
+ "text/toolbarwrapper-id/" + documentId.toLowerCase()
+ )
+ ) {
+ return;
+ }
+
+ var toolbar = aEvent.target;
+ var dropTarget = aEvent.target;
+ while (toolbar && toolbar.localName != "toolbar") {
+ dropTarget = toolbar;
+ toolbar = toolbar.parentNode;
+ }
+
+ // Make sure we are dragging over a customizable toolbar.
+ if (!toolbar || !isCustomizableToolbar(toolbar)) {
+ gCurrentDragOverItem = null;
+ return;
+ }
+
+ var previousDragItem = gCurrentDragOverItem;
+
+ if (dropTarget.localName == "toolbar") {
+ gCurrentDragOverItem = dropTarget;
+ } else {
+ gCurrentDragOverItem = null;
+
+ var direction = window.getComputedStyle(dropTarget.parentNode).direction;
+ var boundingRect = dropTarget.getBoundingClientRect();
+ var dropTargetCenter = boundingRect.x + boundingRect.width / 2;
+ var dragAfter;
+ if (direction == "ltr") {
+ dragAfter = aEvent.clientX > dropTargetCenter;
+ } else {
+ dragAfter = aEvent.clientX < dropTargetCenter;
+ }
+
+ if (dragAfter) {
+ gCurrentDragOverItem = dropTarget.nextElementSibling;
+ if (!gCurrentDragOverItem) {
+ gCurrentDragOverItem = toolbar;
+ }
+ } else {
+ gCurrentDragOverItem = dropTarget;
+ }
+ }
+
+ if (previousDragItem && gCurrentDragOverItem != previousDragItem) {
+ setDragActive(previousDragItem, false);
+ }
+
+ setDragActive(gCurrentDragOverItem, true);
+
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+}
+
+function onToolbarDrop(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+
+ if (!gCurrentDragOverItem) {
+ return;
+ }
+
+ setDragActive(gCurrentDragOverItem, false);
+
+ var documentId = gToolboxDocument.documentElement.id;
+ var draggedItemId = aEvent.dataTransfer.getData(
+ "text/toolbarwrapper-id/" + documentId
+ );
+ if (gCurrentDragOverItem.id == draggedItemId) {
+ return;
+ }
+
+ var toolbar = aEvent.target;
+ while (toolbar.localName != "toolbar") {
+ toolbar = toolbar.parentNode;
+ }
+
+ var draggedPaletteWrapper = document.getElementById(
+ "wrapper-" + draggedItemId
+ );
+ if (!draggedPaletteWrapper) {
+ // The wrapper has been dragged from the toolbar.
+ // Get the wrapper from the toolbar document and make sure that
+ // it isn't being dropped on itself.
+ let wrapper = gToolboxDocument.getElementById("wrapper-" + draggedItemId);
+ if (wrapper == gCurrentDragOverItem) {
+ return;
+ }
+
+ // Don't allow non-removable kids (e.g., the menubar) to move.
+ if (wrapper.firstElementChild.getAttribute("removable") != "true") {
+ return;
+ }
+
+ // Remove the item from its place in the toolbar.
+ wrapper.remove();
+
+ // Determine which toolbar we are dropping on.
+ var dropToolbar = null;
+ if (gCurrentDragOverItem.localName == "toolbar") {
+ dropToolbar = gCurrentDragOverItem;
+ } else {
+ dropToolbar = gCurrentDragOverItem.parentNode;
+ }
+
+ // Insert the item into the toolbar.
+ if (gCurrentDragOverItem != dropToolbar) {
+ dropToolbar.insertBefore(wrapper, gCurrentDragOverItem);
+ } else {
+ dropToolbar.appendChild(wrapper);
+ }
+ } else {
+ // The item has been dragged from the palette
+
+ // Create a new wrapper for the item. We don't know the id yet.
+ let wrapper = createWrapper("", gToolboxDocument);
+
+ // Ask the toolbar to clone the item's template, place it inside the wrapper, and insert it in the toolbar.
+ var newItem = toolbar.insertItem(
+ draggedItemId,
+ gCurrentDragOverItem == toolbar ? null : gCurrentDragOverItem,
+ wrapper
+ );
+
+ // Prepare the item and wrapper to look good on the toolbar.
+ cleanupItemForToolbar(newItem, wrapper);
+ wrapper.id = "wrapper-" + newItem.id;
+ wrapper.flex = newItem.flex;
+
+ // Remove the wrapper from the palette.
+ if (
+ draggedItemId != "separator" &&
+ draggedItemId != "spring" &&
+ draggedItemId != "spacer"
+ ) {
+ gPaletteBox.removeChild(draggedPaletteWrapper);
+ }
+ }
+
+ gCurrentDragOverItem = null;
+
+ toolboxChanged();
+}
+
+function onPaletteDragOver(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+ var documentId = gToolboxDocument.documentElement.id;
+ if (
+ aEvent.dataTransfer.types.includes(
+ "text/toolbarwrapper-id/" + documentId.toLowerCase()
+ )
+ ) {
+ aEvent.preventDefault();
+ }
+}
+
+function onPaletteDrop(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+ var documentId = gToolboxDocument.documentElement.id;
+ var itemId = aEvent.dataTransfer.getData(
+ "text/toolbarwrapper-id/" + documentId
+ );
+
+ var wrapper = gToolboxDocument.getElementById("wrapper-" + itemId);
+ if (wrapper) {
+ // Don't allow non-removable kids (e.g., the menubar) to move.
+ if (wrapper.firstElementChild.getAttribute("removable") != "true") {
+ return;
+ }
+
+ var wrapperType = wrapper.getAttribute("type");
+ if (
+ wrapperType != "separator" &&
+ wrapperType != "spacer" &&
+ wrapperType != "spring"
+ ) {
+ restoreItemForToolbar(wrapper.firstElementChild, wrapper);
+ wrapPaletteItem(document.importNode(wrapper.firstElementChild, true));
+ gToolbox.palette.appendChild(wrapper.firstElementChild);
+ }
+
+ // The item was dragged out of the toolbar.
+ wrapper.remove();
+ }
+
+ toolboxChanged();
+}
+
+function isUnwantedDragEvent(aEvent) {
+ try {
+ if (
+ Services.prefs.getBoolPref("toolkit.customization.unsafe_drag_events")
+ ) {
+ return false;
+ }
+ } catch (ex) {}
+
+ // Discard drag events that originated from a separate window to
+ // prevent content->chrome privilege escalations.
+ let mozSourceNode = aEvent.dataTransfer.mozSourceNode;
+ // mozSourceNode is null in the dragStart event handler or if
+ // the drag event originated in an external application.
+ if (!mozSourceNode) {
+ return true;
+ }
+ let sourceWindow = mozSourceNode.ownerGlobal;
+ return sourceWindow != window && sourceWindow != gToolboxDocument.defaultView;
+}
diff --git a/comm/mail/base/content/customizeToolbar.xhtml b/comm/mail/base/content/customizeToolbar.xhtml
new file mode 100644
index 0000000000..8810a44ee9
--- /dev/null
+++ b/comm/mail/base/content/customizeToolbar.xhtml
@@ -0,0 +1,105 @@
+<?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/. -->
+
+<!DOCTYPE dialog [ <!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd">
+%customizeToolbarDTD; ]>
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/customizeToolbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/primaryToolbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messengercompose/messengercompose.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar-task-view.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-toolbar.css" type="text/css"?>
+
+<window
+ id="CustomizeToolbarWindow"
+ title="&dialog.title;"
+ lightweightthemes="true"
+ windowtype="mailnews:customizeToolbar"
+ onload="overlayOnLoad();"
+ onunload="onUnload();"
+ style="max-width: 92ch; min-height: 36em"
+ persist="width height"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <script src="chrome://messenger/content/customizeToolbar.js" />
+ <script src="chrome://messenger/content/mailCore.js" />
+ <stringbundle
+ id="stringBundle"
+ src="chrome://messenger/locale/customizeToolbar.properties"
+ />
+
+ <keyset id="CustomizeToolbarKeyset">
+ <key id="cmd_close1" keycode="VK_ESCAPE" oncommand="onClose();" />
+ <key id="cmd_close2" keycode="VK_RETURN" oncommand="onClose();" />
+ </keyset>
+
+ <vbox id="main-box" flex="1">
+ <description id="instructions"> &instructions.description; </description>
+
+ <vbox
+ flex="1"
+ id="palette-box"
+ ondragstart="onToolbarDragStart(event)"
+ ondragover="onPaletteDragOver(event)"
+ ondrop="onPaletteDrop(event)"
+ />
+
+ <hbox id="buttonBox" align="center">
+ <hbox id="titlebarSettings" hidden="true">
+ <checkbox
+ id="showTitlebar"
+ oncommand="updateTitlebar();"
+ label="&showTitlebar2.label;"
+ />
+ </hbox>
+ <label id="modelistLabel" value="&show.label;" control="modelist" />
+ <menulist
+ id="modelist"
+ value="icons"
+ oncommand="overlayUpdateToolbarMode(this.value, 'mail-toolbox');"
+ >
+ <menupopup id="modelistpopup">
+ <menuitem id="modefull" value="full" label="&iconsAndText.label;" />
+ <menuitem id="modeicons" value="icons" label="&icons.label;" />
+ <menuitem id="modetext" value="text" label="&text.label;" />
+ <menuitem
+ id="textbesideiconItem"
+ value="textbesideicon"
+ label="&iconsBesideText.label;"
+ />
+ </menupopup>
+ </menulist>
+ <checkbox
+ id="smallicons"
+ oncommand="updateIconSize(this.checked ? 'small' : 'large');"
+ label="&useSmallIcons.label;"
+ />
+ </hbox>
+ <hbox align="center">
+ <button
+ id="restoreDefault"
+ label="&restoreDefaultSet.label;"
+ oncommand="restoreDefaultSet();"
+ />
+ <spacer flex="1" />
+ <button
+ id="donebutton"
+ label="&saveChanges.label;"
+ oncommand="onClose();"
+ default="true"
+ />
+ </hbox>
+ </vbox>
+</window>
diff --git a/comm/mail/base/content/dialogShadowDom.js b/comm/mail/base/content/dialogShadowDom.js
new file mode 100644
index 0000000000..447aa87603
--- /dev/null
+++ b/comm/mail/base/content/dialogShadowDom.js
@@ -0,0 +1,14 @@
+/* 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/. */
+
+/**
+ * When the dialog window loads, add a stylesheet to the shadow DOM of the
+ * dialog to style the accept and cancel buttons, etc.
+ */
+window.addEventListener("load", () => {
+ let link = document.createElement("link");
+ link.setAttribute("rel", "stylesheet");
+ link.setAttribute("href", "chrome://messenger/skin/themeableDialog.css");
+ document.querySelector("dialog").shadowRoot.appendChild(link);
+});
diff --git a/comm/mail/base/content/editContactPanel.inc.xhtml b/comm/mail/base/content/editContactPanel.inc.xhtml
new file mode 100644
index 0000000000..4580648eca
--- /dev/null
+++ b/comm/mail/base/content/editContactPanel.inc.xhtml
@@ -0,0 +1,75 @@
+# 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/.
+
+<html:template id="editContactPanelTemplate">
+<panel id="editContactPanel"
+ type="arrow"
+ orient="vertical"
+ class="cui-widget-panel popup-panel panel-no-padding"
+ ignorekeys="true"
+ aria-labelledby="editContactPanelTitle"
+ onpopuphidden="editContactInlineUI.onPopupHidden(event);"
+ onpopupshown="editContactInlineUI.onPopupShown(event);"
+ onkeypress="editContactInlineUI.onKeyPress(event, true);">
+ <html:div class="popup-panel-body">
+ <html:div id="editContactHeader">
+ <html:img id="editContactPanelIcon"
+ src="chrome://messenger/skin/icons/new/normal/address-book.svg"
+ alt="" />
+ <html:h3 id="editContactPanelTitle" flex="1"></html:h3>
+ </html:div>
+
+ <box id="editContactContent">
+ <hbox pack="end">
+ <label value="&editContactName.label;"
+ class="editContactPanel_rowLabel"
+ accesskey="&editContactName.accesskey;"
+ control="editContactName"/>
+ </hbox>
+ <html:input id="editContactName" class="editContactTextbox" type="text"
+ onkeypress="editContactInlineUI.onKeyPress(event, true);"/>
+ <hbox pack="end">
+ <label value="&editContactEmail.label;"
+ class="editContactPanel_rowLabel"
+ accesskey="&editContactEmail.accesskey;"
+ control="editContactEmail"/>
+ </hbox>
+ <html:input id="editContactEmail" readonly="readonly"
+ class="editContactTextbox" type="email"
+ onkeypress="editContactInlineUI.onKeyPress(event, true);"/>
+ <hbox pack="end">
+ <label id="editContactAddressBook"
+ class="editContactPanel_rowLabel"
+ value="&editContactAddressBook.label;"
+ accesskey="&editContactAddressBook.accesskey;"
+ control="editContactAddressBookList"/>
+ </hbox>
+ <menulist is="menulist-addrbooks"
+ id="editContactAddressBookList"
+ flex="1"/>
+ <label value="" collapsed="true"/>
+ <description id="contactMoveDisabledText" hidden="true">
+ &contactMoveDisabledWarning.description;
+ </description>
+ </box>
+
+ <html:div class="popup-panel-buttons-container">
+ <button id="editContactPanelEditDetailsButton"
+ oncommand="editContactInlineUI.editDetails();"
+ onkeypress="editContactInlineUI.onKeyPress(event, false);"/>
+ <button id="editContactPanelDeleteContactButton"
+ label="&editContactPanelDeleteContact.label;"
+ accesskey="&editContactPanelDeleteContact.accesskey;"
+ oncommand="editContactInlineUI.deleteContact();"
+ onkeypress="editContactInlineUI.onKeyPress(event, false);"/>
+ <button id="editContactPanelDoneButton"
+ class="primary"
+ label="&editContactPanelDone.label;"
+ accesskey="&editContactPanelDone.accesskey;"
+ oncommand="editContactInlineUI.saveChanges();"
+ onkeypress="editContactInlineUI.onKeyPress(event, false);"/>
+ </html:div>
+ </html:div>
+</panel>
+</html:template>
diff --git a/comm/mail/base/content/editContactPanel.js b/comm/mail/base/content/editContactPanel.js
new file mode 100644
index 0000000000..40e1061167
--- /dev/null
+++ b/comm/mail/base/content/editContactPanel.js
@@ -0,0 +1,248 @@
+/* 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 editContactInlineUI = {
+ _overlayLoaded: false,
+ _overlayLoading: false,
+ _cardDetails: null,
+ _writeable: true,
+ _blockedCommands: ["cmd_close"],
+
+ _blockCommands() {
+ for (var i = 0; i < this._blockedCommands; ++i) {
+ var elt = document.getElementById(this._blockedCommands[i]);
+ // make sure not to permanetly disable this item
+ if (elt.hasAttribute("wasDisabled")) {
+ continue;
+ }
+
+ if (elt.getAttribute("disabled") == "true") {
+ elt.setAttribute("wasDisabled", "true");
+ } else {
+ elt.setAttribute("wasDisabled", "false");
+ elt.setAttribute("disabled", "true");
+ }
+ }
+ },
+
+ _restoreCommandsState() {
+ for (var i = 0; i < this._blockedCommands; ++i) {
+ var elt = document.getElementById(this._blockedCommands[i]);
+ if (elt.getAttribute("wasDisabled") != "true") {
+ elt.removeAttribute("disabled");
+ }
+ elt.removeAttribute("wasDisabled");
+ }
+ document.getElementById("editContactAddressBookList").disabled = false;
+ document.getElementById("contactMoveDisabledText").hidden = true;
+ },
+
+ onPopupHidden(aEvent) {
+ if (aEvent.target == this.panel) {
+ this._restoreCommandsState();
+ }
+ },
+
+ onPopupShown(aEvent) {
+ if (aEvent.target == this.panel) {
+ document.getElementById("editContactName").focus();
+ }
+ },
+
+ onKeyPress(aEvent, aHandleOnlyReadOnly) {
+ // Escape should just close this panel
+ if (aEvent.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ this.panel.hidePopup();
+ return;
+ }
+
+ // Return does the default button (done)
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ if (!aEvent.target.hasAttribute("oncommand")) {
+ this.saveChanges();
+ }
+ return;
+ }
+
+ // Only handle the read-only cases here.
+ if (aHandleOnlyReadOnly && this._writeable && !aEvent.target.readOnly) {
+ return;
+ }
+
+ // Any other character and we prevent the default, this stops us doing
+ // things in the main message window.
+ if (aEvent.charCode) {
+ aEvent.preventDefault();
+ }
+ },
+
+ get panel() {
+ // The panel is initially stored in a template for performance reasons.
+ // Load it into the DOM now.
+ delete this.panel;
+ let template = document.getElementById("editContactPanelTemplate");
+ template.replaceWith(template.content);
+ let element = document.getElementById("editContactPanel");
+ return (this.panel = element);
+ },
+
+ showEditContactPanel(aCardDetails, aAnchorElement) {
+ this._cardDetails = aCardDetails;
+ let position = "after_start";
+ this._doShowEditContactPanel(aAnchorElement, position);
+ },
+
+ _doShowEditContactPanel(aAnchorElement, aPosition) {
+ this._blockCommands(); // un-done in the popuphiding handler.
+ var bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/editContactOverlay.properties"
+ );
+
+ // Is this address book writeable?
+ this._writeable = !this._cardDetails.book.readOnly;
+ var type = this._writeable ? "edit" : "view";
+
+ // Force the panel to be created from the template, if necessary.
+ this.panel;
+
+ // Update the labels accordingly.
+ document.getElementById("editContactPanelTitle").textContent =
+ bundle.GetStringFromName(type + "Title");
+ document.getElementById("editContactPanelEditDetailsButton").label =
+ bundle.GetStringFromName(type + "DetailsLabel");
+ document.getElementById("editContactPanelEditDetailsButton").accessKey =
+ bundle.GetStringFromName(type + "DetailsAccessKey");
+
+ // We don't need a delete button for a read only card.
+ document.getElementById("editContactPanelDeleteContactButton").hidden =
+ !this._writeable;
+
+ var nameElement = document.getElementById("editContactName");
+
+ // Set these to read only if we can't write to the directory.
+ if (this._writeable) {
+ nameElement.removeAttribute("readonly");
+ nameElement.class = "editContactTextbox";
+ } else {
+ nameElement.setAttribute("readonly", "readonly");
+ nameElement.class = "plain";
+ }
+
+ // Fill in the card details
+ nameElement.value = this._cardDetails.card.displayName;
+ document.getElementById("editContactEmail").value =
+ aAnchorElement.getAttribute("emailAddress") ||
+ aAnchorElement.emailAddress;
+
+ document.getElementById("editContactAddressBookList").value =
+ this._cardDetails.book.URI;
+
+ // Is this card contained within mailing lists?
+ let inMailList = false;
+ if (this._cardDetails.book.supportsMailingLists) {
+ // We only have to look in one book here, because cards currently have
+ // to be in the address book they belong to.
+ for (let list of this._cardDetails.book.childNodes) {
+ if (!list.isMailList) {
+ continue;
+ }
+
+ for (let card of list.childCards) {
+ if (card.primaryEmail == this._cardDetails.card.primaryEmail) {
+ inMailList = true;
+ break;
+ }
+ }
+ if (inMailList) {
+ break;
+ }
+ }
+ }
+
+ if (!this._writeable || inMailList) {
+ document.getElementById("editContactAddressBookList").disabled = true;
+ }
+
+ if (inMailList) {
+ document.getElementById("contactMoveDisabledText").hidden = false;
+ }
+
+ this.panel.openPopup(aAnchorElement, aPosition, -1, -1);
+ },
+
+ editDetails() {
+ this.saveChanges();
+ top.toAddressBook({ action: "edit", card: this._cardDetails.card });
+ },
+
+ deleteContact() {
+ if (this._cardDetails.book.readOnly) {
+ // Double check we can delete this.
+ return;
+ }
+
+ // Hide before the dialog or the panel takes the first click.
+ this.panel.hidePopup();
+
+ var bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/editContactOverlay.properties"
+ );
+ if (
+ !Services.prompt.confirm(
+ window,
+ bundle.GetStringFromName("deleteContactTitle"),
+ bundle.GetStringFromName("deleteContactMessage")
+ )
+ ) {
+ // XXX Would be nice to bring the popup back up here.
+ return;
+ }
+
+ MailServices.ab
+ .getDirectory(this._cardDetails.book.URI)
+ .deleteCards([this._cardDetails.card]);
+ },
+
+ saveChanges() {
+ // If we're a popup dialog, just hide the popup and return
+ if (!this._writeable) {
+ this.panel.hidePopup();
+ return;
+ }
+
+ let originalBook = this._cardDetails.book;
+
+ let abURI = document.getElementById("editContactAddressBookList").value;
+ if (abURI != originalBook.URI) {
+ this._cardDetails.book = MailServices.ab.getDirectory(abURI);
+ }
+
+ // We can assume the email address stays the same, so just update the name
+ var newName = document.getElementById("editContactName").value;
+ if (newName != this._cardDetails.card.displayName) {
+ this._cardDetails.card.displayName = newName;
+ this._cardDetails.card.setProperty("PreferDisplayName", true);
+ }
+
+ // Save the card
+ if (this._cardDetails.book.hasCard(this._cardDetails.card)) {
+ // Address book wasn't changed.
+ this._cardDetails.book.modifyCard(this._cardDetails.card);
+ } else {
+ // We changed address books for the card.
+
+ // Add it to the chosen address book...
+ this._cardDetails.book.addCard(this._cardDetails.card);
+
+ // ...and delete it from the old place.
+ originalBook.deleteCards([this._cardDetails.card]);
+ }
+
+ this.panel.hidePopup();
+ },
+};
diff --git a/comm/mail/base/content/folderDisplay.js b/comm/mail/base/content/folderDisplay.js
new file mode 100644
index 0000000000..dfd5824c6b
--- /dev/null
+++ b/comm/mail/base/content/folderDisplay.js
@@ -0,0 +1,2649 @@
+/* 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 SearchDialog.js */
+
+/* globals ViewPickerBinding */ // From msgViewPickerOverlay.js
+
+/* TODO: Now used exclusively in SearchDialog.xhtml. Needs dead code removal. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ TreeSelection: "chrome://messenger/content/tree-selection.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ DBViewWrapper: "resource:///modules/DBViewWrapper.jsm",
+});
+
+var gDBView;
+var nsMsgKey_None = 0xffffffff;
+var nsMsgViewIndex_None = 0xffffffff;
+
+/**
+ * Maintains a list of listeners for all FolderDisplayWidget instances in this
+ * window. The assumption is that because of our multiplexed tab
+ * implementation all consumers are effectively going to care about all such
+ * tabs.
+ *
+ * We are not just a global list so that we can add brains about efficiently
+ * building lists, provide try-wrapper convenience, etc.
+ */
+var FolderDisplayListenerManager = {
+ _listeners: [],
+
+ /**
+ * Register a listener that implements one or more of the methods defined on
+ * |IDBViewWrapperListener|. Note that a change from those interface
+ * signatures is that the first argument is always a reference to the
+ * FolderDisplayWidget generating the notification.
+ *
+ * We additionally support the following notifications:
+ * - onMakeActive. Invoked when makeActive is called on the
+ * FolderDisplayWidget. The second argument (after the folder display) is
+ * aWasInactive.
+ *
+ * - onActiveCreatedView. onCreatedView deferred to when the tab is actually
+ * made active.
+ *
+ * - onActiveMessagesLoaded. onMessagesLoaded deferred to when the
+ * tab is actually made active. Use this if the actions you need to take
+ * are based on the folder display actually being visible, such as updating
+ * some UI widget, etc. Not all messages may have been loaded, but some.
+ *
+ */
+ registerListener(aListener) {
+ this._listeners.push(aListener);
+ },
+
+ /**
+ * Unregister a previously registered event listener.
+ */
+ unregisterListener(aListener) {
+ let idx = this._listeners.indexOf(aListener);
+ if (idx >= 0) {
+ this._listeners.splice(idx, 1);
+ }
+ },
+
+ /**
+ * For use by FolderDisplayWidget to trigger listener invocation.
+ */
+ _fireListeners(aEventName, aArgs) {
+ for (let listener of this._listeners) {
+ if (aEventName in listener) {
+ try {
+ listener[aEventName].apply(listener, aArgs);
+ } catch (e) {
+ console.error(
+ aEventName + " event listener FAILED; " + e + " at: " + e.stack
+ );
+ }
+ }
+ }
+ },
+};
+
+/**
+ * Abstraction for a widget that (roughly speaking) displays the contents of
+ * folders. The widget belongs to a tab and has a lifetime as long as the tab
+ * that contains it. This class is strictly concerned with the UI aspects of
+ * this; the DBViewWrapper class handles the view details (and is exposed on
+ * the 'view' attribute.)
+ *
+ * The search window subclasses this into the SearchFolderDisplayWidget rather
+ * than us attempting to generalize everything excessively. This is because
+ * we hate the search window and don't want to clutter up this code for it.
+ * The standalone message display window also subclasses us; we do not hate it,
+ * but it's not invited to our birthday party either.
+ * For reasons of simplicity and the original order of implementation, this
+ * class does alter its behavior slightly for the benefit of the standalone
+ * message window. If no tab info is provided, we avoid touching tabmail
+ * (which is good, because it won't exist!) And now we guard against treeBox
+ * manipulations...
+ */
+function FolderDisplayWidget() {
+ // If the folder does not get handled by the DBViewWrapper, stash it here.
+ // ex: when isServer is true.
+ this._nonViewFolder = null;
+
+ this.view = new DBViewWrapper(this);
+
+ /**
+ * The XUL tree node, as retrieved by getDocumentElementById. The caller is
+ * responsible for setting this.
+ */
+ this.tree = null;
+
+ /**
+ * The nsIMsgWindow corresponding to the window that holds us. There is only
+ * one of these per tab. The caller is responsible for setting this.
+ */
+ this.msgWindow = null;
+ /**
+ * The nsIMessenger instance that corresponds to our tab/window. We do not
+ * use this ourselves, but are responsible for using it to update the
+ * global |messenger| object so that our tab maintains its own undo and
+ * navigation history. At some point we might touch it for those reasons.
+ */
+ this.messenger = null;
+ this.threadPaneCommandUpdater = this;
+
+ /**
+ * Flag to expose whether all messages are loaded or not. Set by
+ * onMessagesLoaded() when aAll is true.
+ */
+ this._allMessagesLoaded = false;
+
+ /**
+ * Save the top row displayed when we go inactive, restore when we go active,
+ * nuke it when we destroy the view.
+ */
+ this._savedFirstVisibleRow = null;
+ /** the next view index to select once the delete completes */
+ this._nextViewIndexAfterDelete = null;
+ /**
+ * Track when a mass move is in effect (we get told by hintMassMoveStarting,
+ * and hintMassMoveCompleted) so that we can avoid deletion-triggered
+ * moving to _nextViewIndexAfterDelete until the mass move completes.
+ */
+ this._massMoveActive = false;
+ /**
+ * Track when a message is being deleted so we can respond appropriately.
+ */
+ this._deleteInProgress = false;
+
+ /**
+ * Used by pushNavigation to queue a navigation request for when we enter the
+ * next folder; onMessagesLoaded(true) is the one that processes it.
+ */
+ this._pendingNavigation = null;
+
+ this._active = false;
+ /**
+ * A list of methods to call on 'this' object when we are next made active.
+ * This list is populated by calls to |_notifyWhenActive| when we are
+ * not active at the moment.
+ */
+ this._notificationsPendingActivation = [];
+
+ this._mostRecentSelectionCounts = [];
+ this._mostRecentCurrentIndices = [];
+}
+FolderDisplayWidget.prototype = {
+ /**
+ * @returns the currently displayed folder. This is just proxied from the
+ * view wrapper.
+ * @groupName Displayed
+ */
+ get displayedFolder() {
+ return this._nonViewFolder || this.view.displayedFolder;
+ },
+
+ /**
+ * @returns true if the selection should be summarized for this folder. This
+ * is based on the mail.operate_on_msgs_in_collapsed_threads pref and
+ * if we are in a newsgroup folder. XXX When bug 478167 is fixed, this
+ * should be limited to being disabled for newsgroups that are not stored
+ * offline.
+ */
+ get summarizeSelectionInFolder() {
+ return (
+ Services.prefs.getBoolPref("mail.operate_on_msgs_in_collapsed_threads") &&
+ !(this.displayedFolder instanceof Ci.nsIMsgNewsFolder)
+ );
+ },
+
+ /**
+ * @returns the nsITreeSelection object for our tree view. This exists for
+ * the benefit of message tabs that haven't been switched to yet.
+ * We provide a fake tree selection in those cases.
+ * @protected
+ */
+ get treeSelection() {
+ // If we haven't switched to this tab yet, dbView will exist but
+ // dbView.selection won't, so use the fake tree selection instead.
+ if (this.view.dbView) {
+ return this.view.dbView.selection;
+ }
+ return null;
+ },
+
+ /**
+ * Determine which pane currently has focus (one of the folder pane, thread
+ * pane, or message pane). The message pane node is the common ancestor of
+ * the single- and multi-message content windows. When changing focus to the
+ * message pane, be sure to focus the appropriate content window in addition
+ * to the messagepanebox (doing both is required in order to blur the
+ * previously-focused chrome element).
+ *
+ * @returns the focused pane
+ */
+ get focusedPane() {
+ let panes = ["threadTree", "folderTree", "messagepanebox"].map(id =>
+ document.getElementById(id)
+ );
+
+ let currentNode = top.document.activeElement;
+
+ while (currentNode) {
+ if (panes.includes(currentNode)) {
+ return currentNode;
+ }
+
+ currentNode = currentNode.parentNode;
+ }
+ return null;
+ },
+
+ /**
+ * Number of headers to tell the message database to cache when we enter a
+ * folder. This value is being propagated from legacy code which provided
+ * no explanation for its choice.
+ *
+ * We definitely want the header cache size to be larger than the number of
+ * rows that can be displayed on screen simultaneously.
+ *
+ * @private
+ */
+ PERF_HEADER_CACHE_SIZE: 100,
+
+ /**
+ * @name Selection Persistence
+ * @private
+ */
+ // @{
+
+ /**
+ * An optional object, with the following properties:
+ * - messages: This is a list where each item is an object with the following
+ * attributes sufficient to re-establish the selected items even in the
+ * face of folder renaming.
+ * - messageId: The value of the message's message-id header.
+ *
+ * That's right, we only save the message-id header value. This is arguably
+ * overkill and ambiguous in the face of duplicate messages, but it's the
+ * most persistent/reliable thing we have without gloda.
+ * Using the view index was ruled out because it is hardly stable. Using the
+ * message key alone is insufficient for cross-folder searches. Using a
+ * folder identifier and message key is insufficient for local folders in the
+ * face of compaction, let alone complexities where the folder name may
+ * change due to renaming/moving. Which means we eventually need to fall
+ * back to message-id anyways. Feel free to add in lots of complexity if
+ * you actually write unit tests for all the many possible cases.
+ * Additional justification is that selection saving/restoration should not
+ * happen all that frequently. A nice freebie is that message-id is
+ * definitely persistable.
+ *
+ * - forceSelect: Whether we are allowed to drop all filters in our quest to
+ * select messages.
+ */
+ _savedSelection: null,
+
+ /**
+ * Save the current view selection for when we the view is getting destroyed
+ * or otherwise re-ordered in such a way that the nsITreeSelection will lose
+ * track of things (because it just has a naive view-index 'view' of the
+ * world.) We just save each message's message-id header. This is overkill
+ * and ambiguous in the face of duplicate messages (and expensive to
+ * restore), but is also the most reliable option for this use case.
+ */
+ _saveSelection() {
+ this._savedSelection = {
+ messages: this.selectedMessages.map(msgHdr => ({
+ messageId: msgHdr.messageId,
+ })),
+ forceSelect: false,
+ };
+ },
+
+ /**
+ * Clear the saved selection.
+ */
+ _clearSavedSelection() {
+ this._savedSelection = null;
+ },
+
+ /**
+ * Restore the view selection if we have a saved selection. We must be
+ * active!
+ *
+ * @returns true if we were able to restore the selection and there was
+ * a selection, false if there was no selection (anymore).
+ */
+ _restoreSelection() {
+ if (!this._savedSelection || !this._active) {
+ return false;
+ }
+
+ // translate message IDs back to messages. this is O(s(m+n)) where:
+ // - s is the number of messages saved in the selection
+ // - m is the number of messages in the view (from findIndexOfMsgHdr)
+ // - n is the number of messages in the underlying folders (from
+ // DBViewWrapper.getMsgHdrForMessageID).
+ // which ends up being O(sn)
+ let messages = this._savedSelection.messages
+ .map(savedInfo => this.view.getMsgHdrForMessageID(savedInfo.messageId))
+ .filter(msgHdr => !!msgHdr);
+
+ this.selectMessages(messages, this._savedSelection.forceSelect, true);
+ this._savedSelection = null;
+
+ return this.selectedCount != 0;
+ },
+
+ /**
+ * Restore the last expandAll/collapseAll state, for both grouped and threaded
+ * views. Not all views respect viewFlags, ie single folder non-virtual.
+ */
+ restoreThreadState() {
+ if (!this._active || !this.tree || !this.view.dbView.viewFolder) {
+ return;
+ }
+
+ if (
+ this.view._threadExpandAll &&
+ !(this.view.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll)
+ ) {
+ this.view.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll);
+ }
+ if (
+ !this.view._threadExpandAll &&
+ this.view.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ ) {
+ this.view.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ }
+ },
+ // @}
+
+ /**
+ * @name Columns
+ * @protected
+ */
+ // @{
+
+ /**
+ * The map of all stock sortable columns and their sortType. The key must
+ * match the column's xul <treecol> id.
+ */
+ COLUMNS_MAP: new Map([
+ ["accountCol", "byAccount"],
+ ["attachmentCol", "byAttachments"],
+ ["senderCol", "byAuthor"],
+ ["correspondentCol", "byCorrespondent"],
+ ["dateCol", "byDate"],
+ ["flaggedCol", "byFlagged"],
+ ["idCol", "byId"],
+ ["junkStatusCol", "byJunkStatus"],
+ ["locationCol", "byLocation"],
+ ["priorityCol", "byPriority"],
+ ["receivedCol", "byReceived"],
+ ["recipientCol", "byRecipient"],
+ ["sizeCol", "bySize"],
+ ["statusCol", "byStatus"],
+ ["subjectCol", "bySubject"],
+ ["tagsCol", "byTags"],
+ ["threadCol", "byThread"],
+ ["unreadButtonColHeader", "byUnread"],
+ ]),
+
+ /**
+ * The map of stock non-sortable columns. The key must match the column's
+ * xul <treecol> id.
+ */
+ COLUMNS_MAP_NOSORT: new Set([
+ "selectCol",
+ "totalCol",
+ "unreadCol",
+ "deleteCol",
+ ]),
+
+ /**
+ * The set of potential default columns in their default display order. Each
+ * column in this list is checked against |COLUMN_DEFAULT_TESTERS| to see if
+ * it is actually an appropriate default for the folder type.
+ */
+ DEFAULT_COLUMNS: [
+ "threadCol",
+ "attachmentCol",
+ "flaggedCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "senderCol", // news folders or incoming folders when correspondents not in use
+ "recipientCol", // outgoing folders when correspondents not in use
+ "correspondentCol", // mail folders
+ "junkStatusCol",
+ "dateCol",
+ "locationCol", // multiple-folder backed folders
+ ],
+
+ /**
+ * Maps column ids to functions that test whether the column is a good default
+ * for display for the folder. Each function should expect a DBViewWrapper
+ * instance as its argument. The intent is that the various helper
+ * properties like isMailFolder/isIncomingFolder/isOutgoingFolder allow the
+ * constraint to be expressed concisely. If a helper does not exist, add
+ * one! (If doing so is out of reach, than access viewWrapper.displayedFolder
+ * to get at the nsIMsgFolder.)
+ * If a column does not have a function, it is assumed that it should be
+ * displayed by default.
+ */
+ COLUMN_DEFAULT_TESTERS: {
+ correspondentCol(viewWrapper) {
+ if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) {
+ // Don't show the correspondent for news or RSS where it doesn't make sense.
+ return viewWrapper.isMailFolder && !viewWrapper.isFeedFolder;
+ }
+ return false;
+ },
+ senderCol(viewWrapper) {
+ if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) {
+ // Show the sender even if correspondent is enabled for news and feeds.
+ return viewWrapper.isNewsFolder || viewWrapper.isFeedFolder;
+ }
+ // senderCol = From. You only care in incoming folders.
+ return viewWrapper.isIncomingFolder;
+ },
+ recipientCol(viewWrapper) {
+ if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) {
+ // No recipient column if we use correspondent.
+ return false;
+ }
+ // recipientCol = To. You only care in outgoing folders.
+ return viewWrapper.isOutgoingFolder;
+ },
+ // Only show the location column for non-single-folder results
+ locationCol(viewWrapper) {
+ return !viewWrapper.isSingleFolder;
+ },
+ // core UI does not provide an ability to mark newsgroup messages as spam
+ junkStatusCol(viewWrapper) {
+ return !viewWrapper.isNewsFolder;
+ },
+ },
+
+ /**
+ * The property name we use to store the column states on the
+ * dbFolderInfo.
+ */
+ PERSISTED_COLUMN_PROPERTY_NAME: "columnStates",
+
+ /**
+ * Given a dbFolderInfo, extract the persisted state from it if there is any.
+ *
+ * @returns null if there was no persisted state, the persisted state in object
+ * form otherwise. (Ideally the state conforms to the documentation on
+ * |_savedColumnStates| but we can't stop people from doing bad things.)
+ */
+ _depersistColumnStatesFromDbFolderInfo(aDbFolderInfo) {
+ let columnJsonString = aDbFolderInfo.getCharProperty(
+ this.PERSISTED_COLUMN_PROPERTY_NAME
+ );
+ if (!columnJsonString) {
+ return null;
+ }
+
+ return JSON.parse(columnJsonString);
+ },
+
+ /**
+ * Persist the column state for the currently displayed folder. We are
+ * assuming that the message database is already open when we are called and
+ * therefore that we do not need to worry about cleaning up after the message
+ * database.
+ * The caller should only call this when they have reason to suspect that the
+ * column state has been changed. This could be because there was no
+ * persisted state so we figured out a default one and want to save it.
+ * Otherwise this should be because the user explicitly changed up the column
+ * configurations. You should not call this willy-nilly.
+ *
+ * @param aState State to persist.
+ */
+ _persistColumnStates(aState) {
+ if (this.view.isSynthetic) {
+ let syntheticView = this.view._syntheticView;
+ if ("setPersistedSetting" in syntheticView) {
+ syntheticView.setPersistedSetting("columns", aState);
+ }
+ return;
+ }
+
+ if (!this.view.displayedFolder || !this.view.displayedFolder.msgDatabase) {
+ return;
+ }
+
+ let msgDatabase = this.view.displayedFolder.msgDatabase;
+ let dbFolderInfo = msgDatabase.dBFolderInfo;
+ dbFolderInfo.setCharProperty(
+ this.PERSISTED_COLUMN_PROPERTY_NAME,
+ JSON.stringify(aState)
+ );
+ msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit);
+ },
+
+ /**
+ * Let us know that the state of the columns has changed. This is either due
+ * to a re-ordering or hidden-ness being toggled.
+ *
+ * This method should only be called on (the active) gFolderDisplay.
+ */
+ hintColumnsChanged() {
+ // ignore this if we are the ones doing things
+ if (this._touchingColumns) {
+ return;
+ }
+ this._persistColumnStates(this.getColumnStates());
+ },
+
+ /**
+ * Either inherit the column state of another folder or use heuristics to
+ * figure out the best column state for the current folder.
+ */
+ _getDefaultColumnsForCurrentFolder(aDoNotInherit) {
+ // If the view is synthetic, try asking it for its default columns. If it
+ // fails, just return nothing, since most synthetic views don't care about
+ // columns anyway.
+ if (this.view.isSynthetic) {
+ if ("getDefaultSetting" in this.view._syntheticView) {
+ return this.view._syntheticView.getDefaultSetting("columns");
+ }
+ return {};
+ }
+
+ // do not inherit from the inbox if:
+ // - It's an outgoing folder; these have a different use-case and there
+ // should be a small number of these, so it's okay to have no defaults.
+ // - It's a virtual folder (single or multi-folder backed). Who knows what
+ // the intent of the user is in this case. This should also be bounded
+ // in number and our default heuristics should be pretty good.
+ // - It's a multiple folder; this is either a search view (which has no
+ // displayed folder) or a virtual folder (which we eliminated above).
+ // - News folders. There is no inbox so there's nothing to inherit from.
+ // (Although we could try and see if they have opened any other news
+ // folders in the same account. But it's not all that important to us.)
+ // - It's an inbox!
+ let doNotInherit =
+ aDoNotInherit ||
+ this.view.isOutgoingFolder ||
+ this.view.isVirtual ||
+ this.view.isMultiFolder ||
+ this.view.isNewsFolder ||
+ this.displayedFolder.getFlag(Ci.nsMsgFolderFlags.Inbox);
+
+ // Try and grab the inbox for this account's settings. we may not be able
+ // to, in which case we just won't inherit. (It ends up the same since the
+ // inbox is obviously not customized in this case.)
+ if (!doNotInherit) {
+ let inboxFolder = this.displayedFolder.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ if (inboxFolder) {
+ let state = this._depersistColumnStatesFromDbFolderInfo(
+ inboxFolder.msgDatabase.dBFolderInfo
+ );
+ // inbox message databases don't get closed as a matter of policy.
+
+ if (state) {
+ return state;
+ }
+ }
+ }
+
+ // if we are still here, use the defaults and helper functions
+ let state = {};
+ for (let colId of this.DEFAULT_COLUMNS) {
+ let shouldShowColumn = true;
+ if (colId in this.COLUMN_DEFAULT_TESTERS) {
+ // This is potentially going to be used by extensions; avoid them
+ // killing us.
+ try {
+ shouldShowColumn = this.COLUMN_DEFAULT_TESTERS[colId](this.view);
+ } catch (ex) {
+ shouldShowColumn = false;
+ console.error(ex);
+ }
+ }
+ state[colId] = { visible: shouldShowColumn };
+ }
+ return state;
+ },
+
+ /**
+ * Is setColumnStates messing with the columns' DOM? This is used by
+ * hintColumnsChanged to avoid wasteful state persistence.
+ */
+ _touchingColumns: false,
+
+ /**
+ * Set the column states of this FolderDisplay to the provided state.
+ *
+ * @param aColumnStates an object of the form described on
+ * |_savedColumnStates|. If ordinal attributes are omitted then no
+ * re-ordering will be performed. This is intentional, but potentially a
+ * bad idea. (Right now only gloda search underspecifies ordinals.)
+ * @param [aPersistChanges=false] Should we persist the changes to the view?
+ * This only has an effect if we are active.
+ *
+ * @public
+ */
+ setColumnStates(aColumnStates, aPersistChanges) {
+ // If we are not active, just overwrite our current state with the provided
+ // state and bail.
+ if (!this._active) {
+ this._savedColumnStates = aColumnStates;
+ return;
+ }
+
+ this._touchingColumns = true;
+
+ try {
+ let cols = document.getElementById("threadCols");
+ let colChildren = cols.children;
+
+ for (let iKid = 0; iKid < colChildren.length; iKid++) {
+ let colChild = colChildren[iKid];
+ if (colChild == null) {
+ continue;
+ }
+
+ // We only care about treecols. The splitters do not need to be marked
+ // hidden or un-hidden.
+ if (colChild.tagName == "treecol") {
+ // if it doesn't have preserved state it should be hidden
+ let shouldBeHidden = true;
+ // restore state
+ if (colChild.id in aColumnStates) {
+ let colState = aColumnStates[colChild.id];
+ if ("visible" in colState) {
+ shouldBeHidden = !colState.visible;
+ }
+ if ("ordinal" in colState && colChild.ordinal != colState.ordinal) {
+ colChild.ordinal = colState.ordinal;
+ }
+ }
+ let isHidden = colChild.hidden;
+ if (isHidden != shouldBeHidden) {
+ if (shouldBeHidden) {
+ colChild.setAttribute("hidden", "true");
+ } else {
+ colChild.removeAttribute("hidden");
+ }
+ }
+ }
+ }
+ } finally {
+ this._touchingColumns = false;
+ }
+
+ if (aPersistChanges) {
+ this.hintColumnsChanged();
+ }
+ },
+
+ /**
+ * A dictionary that maps column ids to dictionaries where each dictionary
+ * has the following fields:
+ * - visible: Is the column visible.
+ * - ordinal: The 1-based XUL 'ordinal' value assigned to the column. This
+ * corresponds to the position but is not something you want to manipulate.
+ * See the documentation in _saveColumnStates for more information.
+ */
+ _savedColumnStates: null,
+
+ /**
+ * Return a dictionary in the form of |_savedColumnStates| representing the
+ * current column states.
+ *
+ * @public
+ */
+ getColumnStates() {
+ if (!this._active) {
+ return this._savedColumnStates;
+ }
+
+ let columnStates = {};
+
+ let cols = document.getElementById("threadCols");
+ let colChildren = cols.children;
+ for (let iKid = 0; iKid < colChildren.length; iKid++) {
+ let colChild = colChildren[iKid];
+ if (colChild.tagName != "treecol") {
+ continue;
+ }
+ columnStates[colChild.id] = {
+ visible: !colChild.hidden,
+ ordinal: colChild.ordinal,
+ };
+ }
+
+ return columnStates;
+ },
+
+ /**
+ * For now, just save the visible columns into a dictionary for use in a
+ * subsequent call to |setColumnStates|.
+ */
+ _saveColumnStates() {
+ // In the actual TreeColumn, the index property indicates the column
+ // number. This column number is a 0-based index with no gaps; it only
+ // increments the number each time it sees a column.
+ // However, this is subservient to the 'ordinal' property which
+ // defines the _apparent content sequence_ provided by GetNextSibling.
+ // The underlying content ordering is still the same, which is how
+ // _ensureColumnOrder() can reset things to their XUL definition sequence.
+ // The 'ordinal' stuff works because nsBoxFrame::RelayoutChildAtOrdinal
+ // messes with the sibling relationship.
+ // Ordinals are 1-based. _ensureColumnOrder() apparently is dumb and does
+ // not know this, although the ordering is relative so it doesn't actually
+ // matter. The annoying splitters do have ordinals, and live between
+ // tree columns. The splitters adjacent to a tree column do not need to
+ // have any 'ordinal' relationship, although it would appear user activity
+ // tends to move them around in a predictable fashion with oddness involved
+ // at the edges.
+ // Changes to the ordinal attribute should take immediate effect in terms of
+ // sibling relationship, but will merely invalidate the columns rather than
+ // cause a re-computation of column relationships every time.
+ // _ensureColumnOrder() invalidates the tree when it is done re-ordering;
+ // I'm not sure that's entirely necessary...
+ this._savedColumnStates = this.getColumnStates();
+ },
+
+ /**
+ * Restores the visible columns saved by |_saveColumnStates|.
+ */
+ _restoreColumnStates() {
+ if (this._savedColumnStates) {
+ this.setColumnStates(this._savedColumnStates);
+ this._savedColumnStates = null;
+ }
+ },
+ // @}
+
+ /**
+ * @name What To Display
+ * @protected
+ */
+ // @{
+ showFolderUri(aFolderURI) {
+ return this.show(MailUtils.getExistingFolder(aFolderURI));
+ },
+
+ /**
+ * Invoked by showFolder when it turns out the folder is in fact a server.
+ *
+ * @private
+ */
+ _showServer() {
+ // currently nothing to do. makeActive handles everything for us (because
+ // what is displayed needs to be re-asserted each time we are activated
+ // too.)
+ },
+
+ /**
+ * Select a folder for display.
+ *
+ * @param aFolder The nsIMsgDBFolder to display.
+ */
+ show(aFolder) {
+ if (aFolder == null) {
+ this._nonViewFolder = null;
+ this.view.close();
+ } else if (aFolder instanceof Ci.nsIMsgFolder) {
+ if (aFolder.isServer) {
+ this._nonViewFolder = aFolder;
+ this._showServer();
+ this.view.close();
+ // A server is fully loaded immediately, for now. (When we have the
+ // account summary, we might want to change this to wait for the page
+ // load to complete.)
+ this._allMessagesLoaded = true;
+ } else {
+ this._nonViewFolder = null;
+ this.view.open(aFolder);
+ }
+ } else {
+ // it must be a synthetic view
+ this.view.openSynthetic(aFolder);
+ }
+ if (this._active) {
+ this.makeActive();
+ }
+ },
+
+ /**
+ * Clone an existing view wrapper as the basis for our display.
+ */
+ cloneView(aViewWrapper) {
+ this.view = aViewWrapper.clone(this);
+ // generate a view created notification; this will cause us to do the right
+ // thing in terms of associating the view with the tree and such.
+ this.onCreatedView();
+ if (this._active) {
+ this.makeActive();
+ }
+ },
+
+ /**
+ * Close resources associated with the currently displayed folder because you
+ * no longer care about this FolderDisplayWidget.
+ */
+ close() {
+ // Mark ourselves as inactive without doing any of the hard work of becoming
+ // inactive. This saves us from trying to update things as they go away.
+ this._active = false;
+
+ this.view.close();
+ this.messenger.setWindow(null, null);
+ this.messenger = null;
+ },
+ // @}
+
+ /* =============================== */
+ /* ===== IDBViewWrapper Listener ===== */
+ /* =============================== */
+
+ /**
+ * @name IDBViewWrapperListener Interface
+ * @private
+ */
+ // @{
+
+ /**
+ * @returns true if the mail view picker is visible. This affects whether the
+ * DBViewWrapper will actually use the persisted mail view or not.
+ */
+ get shouldUseMailViews() {
+ return ViewPickerBinding.isVisible;
+ },
+
+ /**
+ * Let the viewWrapper know if we should defer message display because we
+ * want the user to connect to the server first so password authentication
+ * can occur.
+ *
+ * @returns true if the folder should be shown immediately, false if we should
+ * wait for updateFolder to complete.
+ */
+ get shouldDeferMessageDisplayUntilAfterServerConnect() {
+ let passwordPromptRequired = false;
+
+ if (Services.prefs.getBoolPref("mail.password_protect_local_cache")) {
+ passwordPromptRequired =
+ this.view.displayedFolder.server.passwordPromptRequired;
+ }
+
+ return passwordPromptRequired;
+ },
+
+ /**
+ * Let the viewWrapper know if it should mark the messages read when leaving
+ * the provided folder.
+ *
+ * @returns true if the preference is set for the folder's server type.
+ */
+ shouldMarkMessagesReadOnLeavingFolder(aMsgFolder) {
+ return Services.prefs.getBoolPref(
+ "mailnews.mark_message_read." + aMsgFolder.server.type
+ );
+ },
+
+ /**
+ * The view wrapper tells us when it starts loading a folder, and we set the
+ * cursor busy. Setting the cursor busy on a per-tab basis is us being
+ * nice to the future. Loading a folder is a blocking operation that is going
+ * to make us unresponsive and accordingly make it very hard for the user to
+ * change tabs.
+ */
+ onFolderLoading(aFolderLoading) {
+ FolderDisplayListenerManager._fireListeners("onFolderLoading", [
+ this,
+ aFolderLoading,
+ ]);
+ },
+
+ /**
+ * The view wrapper tells us when a search is active, and we mark the tab as
+ * thinking so the user knows something is happening. 'Searching' in this
+ * case is more than just a user-initiated search. Virtual folders / saved
+ * searches, mail views, plus the more obvious quick search are all based off
+ * of searches and we will receive a notification for them.
+ */
+ onSearching(aIsSearching) {
+ FolderDisplayListenerManager._fireListeners("onSearching", [
+ this,
+ aIsSearching,
+ ]);
+ },
+
+ /**
+ * Things we do on creating a view:
+ * - notify the observer service so that custom column handler providers can
+ * add their custom columns to our view.
+ */
+ onCreatedView() {
+ // All of our messages are not displayed if the view was just created. We
+ // will get an onMessagesLoaded(true) nearly immediately if this is a local
+ // folder where view creation is synonymous with having all messages.
+ this._allMessagesLoaded = false;
+
+ FolderDisplayListenerManager._fireListeners("onCreatedView", [this]);
+
+ this._notifyWhenActive(this._activeCreatedView);
+ },
+ _activeCreatedView() {
+ gDBView = this.view.dbView; // eslint-disable-line no-global-assign
+
+ // A change in view may result in changes to sorts, the view menu, etc.
+ // Do this before we 'reroot' the dbview.
+ this._updateThreadDisplay();
+
+ // this creates a new selection object for the view.
+ if (this.tree) {
+ this.tree.view = this.view.dbView;
+ }
+
+ FolderDisplayListenerManager._fireListeners("onActiveCreatedView", [this]);
+
+ // The data payload used to be viewType + ":" + viewFlags. We no longer
+ // do this because we already have the implied contract that gDBView is
+ // valid at the time we generate the notification. In such a case, you
+ // can easily get that information from the gDBView. (The documentation
+ // on creating a custom column assumes gDBView.)
+ Services.obs.notifyObservers(this.displayedFolder, "MsgCreateDBView");
+ },
+
+ /**
+ * If our view is being destroyed and it is coming back, we want to save the
+ * current selection so we can restore it when the view comes back.
+ */
+ onDestroyingView(aFolderIsComingBack) {
+ // try and persist the selection's content if we can
+ if (this._active) {
+ // If saving the selection throws an exception, we still want continue
+ // destroying the view. Saving the selection can fail if an underlying
+ // local folder has been compacted, invalidating the message keys.
+ // See bug 536676 for more info.
+ try {
+ // If a new selection is coming up, there's no point in trying to
+ // persist any selections.
+ if (aFolderIsComingBack && !this._aboutToSelectMessage) {
+ this._saveSelection();
+ } else {
+ this._clearSavedSelection();
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ gDBView = null; // eslint-disable-line no-global-assign
+ }
+
+ FolderDisplayListenerManager._fireListeners("onDestroyingView", [
+ this,
+ aFolderIsComingBack,
+ ]);
+
+ // if we have no view, no messages could be loaded.
+ this._allMessagesLoaded = false;
+
+ // but the actual tree view selection (based on view indices) is a goner no
+ // matter what, make everyone forget.
+ this.view.dbView.selection = null;
+ this._savedFirstVisibleRow = null;
+ this._nextViewIndexAfterDelete = null;
+ // although the move may still be active, its relation to the view is moot.
+ this._massMoveActive = false;
+
+ // Anything pending needs to get cleared out; the new view and its related
+ // events will re-schedule anything required or simply run it when it
+ // has its initial call to makeActive compelled.
+ this._notificationsPendingActivation = [];
+ },
+
+ /**
+ * Restore persisted information about what columns to display for the folder.
+ * If we have no persisted information, we leave/set _savedColumnStates null.
+ * The column states will be set to default values in onDisplayingFolder in
+ * that case.
+ */
+ onLoadingFolder(aDbFolderInfo) {
+ this._savedColumnStates =
+ this._depersistColumnStatesFromDbFolderInfo(aDbFolderInfo);
+
+ FolderDisplayListenerManager._fireListeners("onLoadingFolder", [
+ this,
+ aDbFolderInfo,
+ ]);
+ },
+
+ /**
+ * We are entering the folder for display:
+ * - set the header cache size.
+ * - Setup the columns if we did not already depersist in |onLoadingFolder|.
+ */
+ onDisplayingFolder() {
+ let displayedFolder = this.view.displayedFolder;
+ let msgDatabase = displayedFolder && displayedFolder.msgDatabase;
+ if (msgDatabase) {
+ msgDatabase.resetHdrCacheSize(this.PERF_HEADER_CACHE_SIZE);
+ }
+
+ // makeActive will restore the folder state
+ if (!this._savedColumnStates) {
+ if (
+ this.view.isSynthetic &&
+ "getPersistedSetting" in this.view._syntheticView
+ ) {
+ let columns = this.view._syntheticView.getPersistedSetting("columns");
+ this._savedColumnStates = columns;
+ } else {
+ // get the default for this folder
+ this._savedColumnStates = this._getDefaultColumnsForCurrentFolder();
+ // and save it so it doesn't wiggle if the inbox/prototype changes
+ this._persistColumnStates(this._savedColumnStates);
+ }
+ }
+
+ FolderDisplayListenerManager._fireListeners("onDisplayingFolder", [this]);
+
+ if (this.active) {
+ this.makeActive();
+ }
+ },
+
+ /**
+ * Notification from DBViewWrapper that it is closing the folder. This can
+ * happen for reasons other than our own 'close' method closing the view.
+ * For example, user deletion of the folder or underlying folder closes it.
+ */
+ onLeavingFolder() {
+ FolderDisplayListenerManager._fireListeners("onLeavingFolder", [this]);
+
+ // Keep the msgWindow's openFolder up-to-date; it powers nsMessenger's
+ // concept of history so that it can bring you back to the actual folder
+ // you were looking at, rather than just the underlying folder.
+ if (this._active) {
+ msgWindow.openFolder = null;
+ }
+ },
+
+ /**
+ * Indicates whether we are done loading the messages that should be in this
+ * folder. This is being surfaced for testing purposes, but could be useful
+ * to other code as well. But don't poll this property; ask for an event
+ * that you can hook.
+ */
+ get allMessagesLoaded() {
+ return this._allMessagesLoaded;
+ },
+
+ /**
+ * Things to do once some or all the messages that should show up in a folder
+ * have shown up. For a real folder, this happens when the folder is
+ * entered. For a virtual folder, this happens when the search completes.
+ *
+ * What we do:
+ * - Any scrolling required!
+ */
+ onMessagesLoaded(aAll) {
+ this._allMessagesLoaded = aAll;
+
+ FolderDisplayListenerManager._fireListeners("onMessagesLoaded", [
+ this,
+ aAll,
+ ]);
+
+ this._notifyWhenActive(this._activeMessagesLoaded);
+ },
+ _activeMessagesLoaded() {
+ FolderDisplayListenerManager._fireListeners("onActiveMessagesLoaded", [
+ this,
+ ]);
+
+ // - if a selectMessage's coming up, get out of here
+ if (this._aboutToSelectMessage) {
+ return;
+ }
+
+ // - restore user's last expand/collapse choice.
+ this.restoreThreadState();
+
+ // - restore selection
+ // Attempt to restore the selection (if we saved it because the view was
+ // being destroyed or otherwise manipulated in a fashion that the normal
+ // nsTreeSelection would be unable to handle.)
+ if (this._restoreSelection()) {
+ this.ensureRowIsVisible(this.view.dbView.viewIndexForFirstSelectedMsg);
+ return;
+ }
+
+ // - pending navigation from pushNavigation (probably spacebar triggered)
+ // Need to have all messages loaded first.
+ if (this._pendingNavigation) {
+ // Move it to a local and clear the state in case something bad happens.
+ // (We don't want to swallow the exception.)
+ let pendingNavigation = this._pendingNavigation;
+ this._pendingNavigation = null;
+ this.navigate.apply(this, pendingNavigation);
+ return;
+ }
+
+ // - if something's already selected (e.g. in a message tab), scroll to the
+ // first selected message and get out
+ if (this.view.dbView.numSelected > 0) {
+ this.ensureRowIsVisible(this.view.dbView.viewIndexForFirstSelectedMsg);
+ return;
+ }
+
+ // - new messages
+ // if configured to scroll to new messages, try that
+ if (
+ Services.prefs.getBoolPref("mailnews.scroll_to_new_message") &&
+ this.navigate(Ci.nsMsgNavigationType.firstNew, /* select */ false)
+ ) {
+ return;
+ }
+
+ // - last selected message
+ // if configured to load the last selected message (this is currently more
+ // persistent than our saveSelection/restoreSelection stuff), and the view
+ // is backed by a single underlying folder (the only way having just a
+ // message key works out), try that
+ if (
+ Services.prefs.getBoolPref("mailnews.remember_selected_message") &&
+ this.view.isSingleFolder &&
+ this.view.displayedFolder
+ ) {
+ // use the displayed folder; nsMsgDBView goes to the effort to save the
+ // state to the viewFolder, so this is the correct course of action.
+ let lastLoadedMessageKey = this.view.displayedFolder.lastMessageLoaded;
+ if (lastLoadedMessageKey != nsMsgKey_None) {
+ this.view.dbView.selectMsgByKey(lastLoadedMessageKey);
+ // The message key may not be present in the view for a variety of
+ // reasons. Beyond message deletion, it simply may not match the
+ // active mail view or quick search, for example.
+ if (this.view.dbView.numSelected > 0) {
+ this.ensureRowIsVisible(
+ this.view.dbView.viewIndexForFirstSelectedMsg
+ );
+ return;
+ }
+ }
+ }
+
+ // - towards the newest messages, but don't select
+ if (
+ this.view.isSortedAscending &&
+ this.view.sortImpliesTemporalOrdering &&
+ this.navigate(Ci.nsMsgNavigationType.lastMessage, /* select */ false)
+ ) {
+ return;
+ }
+
+ // - to the top, the coliseum
+ this.ensureRowIsVisible(0);
+ },
+
+ /**
+ * The DBViewWrapper tells us when someone (possibly the wrapper itself)
+ * changes the active mail view so that we can kick the UI to update.
+ */
+ onMailViewChanged() {
+ // only do this if we're currently active. no need to queue it because we
+ // always update the mail view whenever we are made active.
+ if (this.active) {
+ // you cannot cancel a view change!
+ window.dispatchEvent(
+ new Event("MailViewChanged", { bubbles: false, cancelable: false })
+ );
+ }
+ },
+
+ /**
+ * Just the sort or threading was changed, without changing other things. We
+ * will not get this notification if the view was re-created, for example.
+ */
+ onSortChanged() {
+ if (this.active) {
+ UpdateSortIndicators(
+ this.view.primarySortType,
+ this.view.primarySortOrder
+ );
+ }
+
+ FolderDisplayListenerManager._fireListeners("onSortChanged", [this]);
+ },
+
+ /**
+ * Messages (that may have been displayed) have been removed; this may impact
+ * our message selection. We might know it's coming; if we do then
+ * this._nextViewIndexAfterDelete should know what view index to select next.
+ * For the imap mark-as-deleted we won't know beforehand.
+ */
+ onMessagesRemoved() {
+ FolderDisplayListenerManager._fireListeners("onMessagesRemoved", [this]);
+
+ this._deleteInProgress = false;
+
+ // - we saw this coming
+ let rowCount = this.view.dbView.rowCount;
+ if (!this._massMoveActive && this._nextViewIndexAfterDelete != null) {
+ // adjust the index if it is after the last row...
+ // (this can happen if the "mail.delete_matches_sort_order" pref is not
+ // set and the message is the last message in the view.)
+ if (this._nextViewIndexAfterDelete >= rowCount) {
+ this._nextViewIndexAfterDelete = rowCount - 1;
+ }
+ // just select the index and get on with our lives
+ this.selectViewIndex(this._nextViewIndexAfterDelete);
+ this._nextViewIndexAfterDelete = null;
+ return;
+ }
+
+ // - we didn't see it coming
+
+ // A deletion happened to our folder.
+ let treeSelection = this.treeSelection;
+ // we can't fix the selection if we have no selection
+ if (!treeSelection) {
+ return;
+ }
+
+ // For reasons unknown (but theoretically knowable), sometimes the selection
+ // object will be invalid. At least, I've reliably seen a selection of
+ // [0, 0] with 0 rows. If that happens, we need to fix up the selection
+ // here.
+ if (rowCount == 0 && treeSelection.count) {
+ // nsTreeSelection doesn't generate an event if we use clearRange, so use
+ // that to avoid spurious events, given that we are going to definitely
+ // trigger a change notification below.
+ treeSelection.clearRange(0, 0);
+ }
+
+ // Check if we now no longer have a selection, but we had exactly one
+ // message selected previously. If we did, then try and do some
+ // 'persistence of having a thing selected'.
+ if (
+ treeSelection.count == 0 &&
+ this._mostRecentSelectionCounts.length > 1 &&
+ this._mostRecentSelectionCounts[1] == 1 &&
+ this._mostRecentCurrentIndices[1] != -1
+ ) {
+ let targetIndex = this._mostRecentCurrentIndices[1];
+ if (targetIndex >= rowCount) {
+ targetIndex = rowCount - 1;
+ }
+ this.selectViewIndex(targetIndex);
+ return;
+ }
+
+ // Otherwise, just tell the view that things have changed so it can update
+ // itself to the new state of things.
+ // tell the view that things have changed so it can update itself suitably.
+ if (this.view.dbView) {
+ this.view.dbView.selectionChanged();
+ }
+ },
+
+ /**
+ * Messages were not actually removed, but we were expecting that they would
+ * be. Clean-up what onMessagesRemoved would have cleaned up, namely the
+ * next view index to select.
+ */
+ onMessageRemovalFailed() {
+ this._nextViewIndexAfterDelete = null;
+ FolderDisplayListenerManager._fireListeners("onMessagesRemovalFailed", [
+ this,
+ ]);
+ },
+
+ /**
+ * Update the status bar to reflect our exciting message counts.
+ */
+ onMessageCountsChanged() {},
+ // @}
+ /* ===== End IDBViewWrapperListener ===== */
+
+ /* ================================== */
+ /* ===== nsIMsgDBViewCommandUpdater ===== */
+ /* ================================== */
+
+ /**
+ * @name nsIMsgDBViewCommandUpdater Interface
+ * @private
+ */
+ // @{
+
+ /**
+ * This gets called when the selection changes AND !suppressCommandUpdating
+ * AND (we're not removing a row OR we are now out of rows).
+ * In response, we update the toolbar.
+ */
+ updateCommandStatus() {},
+
+ /**
+ * This gets called by nsMsgDBView::UpdateDisplayMessage following a call
+ * to nsIMessenger.OpenURL to kick off message display OR (UDM gets called)
+ * by nsMsgDBView::SelectionChanged in lieu of loading the message because
+ * mSupressMsgDisplay.
+ * In other words, we get notified immediately after the process of displaying
+ * a message triggered by the nsMsgDBView happens. We get some arguments
+ * that are display optimizations for historical reasons (as usual).
+ *
+ * Things this makes us want to do:
+ * - Set the tab title, perhaps. (If we are a message display.)
+ * - Update message counts, because things might have changed, why not.
+ * - Update some toolbar buttons, why not.
+ *
+ * @param aFolder The display/view folder, as opposed to the backing folder.
+ * @param aSubject The subject with "Re: " if it's got one, which makes it
+ * notably different from just directly accessing the message header's
+ * subject.
+ * @param aKeywords The keywords, which roughly translates to message tags.
+ */
+ displayMessageChanged(aFolder, aSubject, aKeywords) {},
+
+ /**
+ * This gets called as a hint that the currently selected message is junk and
+ * said junked message is going to be moved out of the current folder, or
+ * right before a header is removed from the db view. The legacy behaviour
+ * is to retrieve the msgToSelectAfterDelete attribute off the db view,
+ * stashing it for benefit of the code that gets called when a message
+ * move/deletion is completed so that we can trigger its display.
+ */
+ updateNextMessageAfterDelete() {
+ this.hintAboutToDeleteMessages();
+ },
+
+ /**
+ * The most recent currentIndexes on the selection (from the last time
+ * summarizeSelection got called). We use this in onMessagesRemoved if
+ * we get an unexpected notification.
+ * We keep a maximum of 2 entries in this list.
+ */
+ _mostRecentCurrentIndices: undefined, // initialized in constructor
+ /**
+ * The most recent counts on the selection (from the last time
+ * summarizeSelection got called). We use this in onMessagesRemoved if
+ * we get an unexpected notification.
+ * We keep a maximum of 2 entries in this list.
+ */
+ _mostRecentSelectionCounts: undefined, // initialized in constructor
+
+ /**
+ * Always called by the db view when the selection changes in
+ * SelectionChanged. This event will come after the notification to
+ * displayMessageChanged (if one happens), and before the notification to
+ * updateCommandStatus (if one happens).
+ */
+ summarizeSelection() {
+ // save the current index off in case the selection gets deleted out from
+ // under us and we want to have persistence of actually-having-something
+ // selected.
+ let treeSelection = this.treeSelection;
+ if (treeSelection) {
+ this._mostRecentCurrentIndices.unshift(treeSelection.currentIndex);
+ this._mostRecentCurrentIndices.splice(2);
+ this._mostRecentSelectionCounts.unshift(treeSelection.count);
+ this._mostRecentSelectionCounts.splice(2);
+ }
+ },
+ // @}
+ /* ===== End nsIMsgDBViewCommandUpdater ===== */
+
+ /* ===== Hints from the command infrastructure ===== */
+ /**
+ * @name Command Infrastructure Hints
+ * @protected
+ */
+ // @{
+
+ /**
+ * doCommand helps us out by telling us when it is telling the view to delete
+ * some messages. Ideally it should go through us / the DB View Wrapper to
+ * kick off the delete in the first place, but that's a thread I don't want
+ * to pull on right now.
+ * We use this hint to figure out the next message to display once the
+ * deletion completes. We do this before the deletion happens because the
+ * selection is probably going away (except in the IMAP delete model), and it
+ * might be too late to figure this out after the deletion happens.
+ * Our automated complement (that calls us) is updateNextMessageAfterDelete.
+ */
+ hintAboutToDeleteMessages() {
+ this._deleteInProgress = true;
+ // save the value, even if it is nsMsgViewIndex_None.
+ this._nextViewIndexAfterDelete = this.view.dbView.msgToSelectAfterDelete;
+ },
+
+ /**
+ * The archive code tells us when it is starting to archive messages. This
+ * is different from hinting about deletion because it will also tell us
+ * when it has completed its mass move.
+ * The UI goal is that we do not immediately jump beyond the selected messages
+ * to the next message until all of the selected messages have been
+ * processed (moved). Ideally we would also do this when deleting messages
+ * from a multiple-folder backed message view, but we don't know when the
+ * last job completes in that case (whereas in this case we do because of the
+ * call to hintMassMoveCompleted.)
+ */
+ hintMassMoveStarting() {
+ this.hintAboutToDeleteMessages();
+ this._massMoveActive = true;
+ },
+
+ /**
+ * The archival has completed, we can finally let onMessagseRemoved run to
+ * completion.
+ */
+ hintMassMoveCompleted() {
+ this._massMoveActive = false;
+ this.onMessagesRemoved();
+ },
+
+ /**
+ * When a right-click on the thread pane is going to alter our selection, we
+ * get this notification (currently from |ChangeSelectionWithoutContentLoad|
+ * in threadPane.js), which lets us save our state.
+ * This ends one of two ways: we get made inactive because a new tab popped up
+ * or we get a call to |hintRightClickSelectionPerturbationDone|.
+ *
+ * Ideally, we could just save off our current nsITreeSelection and restore it
+ * when this is all over. This assumption would rely on the underlying view
+ * not having any changes to its rows before we restore the selection. I am
+ * not confident we can rule out background processes making changes, plus
+ * the right-click itself may mutate the view (although we could try and get
+ * it to restore the selection before it gets to the mutation part). Our
+ * only way to resolve this would be to create a 'tee' like fake selection
+ * that would proxy view change notifications to both sets of selections.
+ * That is hard.
+ * So we just use the existing _saveSelection/_restoreSelection mechanism
+ * which is potentially very costly.
+ */
+ hintRightClickPerturbingSelection() {
+ this._saveSelection();
+ },
+
+ /**
+ * When a right-click on the thread pane altered our selection (which we
+ * should have received a call to |hintRightClickPerturbingSelection| for),
+ * we should receive this notification from
+ * |RestoreSelectionWithoutContentLoad| when it wants to put things back.
+ */
+ hintRightClickSelectionPerturbationDone() {
+ this._restoreSelection();
+ },
+ // @}
+ /* ===== End hints from the command infrastructure ==== */
+
+ _updateThreadDisplay() {
+ if (this.active) {
+ if (this.view.dbView) {
+ UpdateSortIndicators(
+ this.view.dbView.sortType,
+ this.view.dbView.sortOrder
+ );
+ SetNewsFolderColumns();
+ UpdateSelectCol();
+ }
+ }
+ },
+
+ /**
+ * Update the UI display apart from the thread tree because the folder being
+ * displayed has changed. This can be the result of changing the folder in
+ * this FolderDisplayWidget, or because this FolderDisplayWidget is being
+ * made active. _updateThreadDisplay handles the parts of the thread tree
+ * that need updating.
+ */
+ _updateContextDisplay() {
+ if (this.active) {
+ UpdateStatusQuota(this.displayedFolder);
+
+ // - mail view combo-box.
+ this.onMailViewChanged();
+ }
+ },
+
+ /**
+ * @name Activation Control
+ * @protected
+ */
+ // @{
+
+ /**
+ * Run the provided notification function right now if we are 'active' (the
+ * currently displayed tab), otherwise queue it to be run when we become
+ * active. We do this because our tabbing model uses multiplexed (reused)
+ * widgets, and extensions likewise depend on these global/singleton things.
+ * If the requested notification function is already queued, it will not be
+ * added a second time, and the original call ordering will be maintained.
+ * If a new call ordering is required, the list of notifications should
+ * probably be reset by the 'big bang' event (new view creation?).
+ */
+ _notifyWhenActive(aNotificationFunc) {
+ if (this._active) {
+ aNotificationFunc.call(this);
+ } else if (
+ !this._notificationsPendingActivation.includes(aNotificationFunc)
+ ) {
+ this._notificationsPendingActivation.push(aNotificationFunc);
+ }
+ },
+
+ /**
+ * Some notifications cannot run while the FolderDisplayWidget is inactive
+ * (presumbly because it is in a background tab). We accumulate those in
+ * _notificationsPendingActivation and then this method runs them when we
+ * become active again.
+ */
+ _runNotificationsPendingActivation() {
+ if (!this._notificationsPendingActivation.length) {
+ return;
+ }
+
+ let pendingNotifications = this._notificationsPendingActivation;
+ this._notificationsPendingActivation = [];
+ for (let notif of pendingNotifications) {
+ notif.call(this);
+ }
+ },
+
+ // This is not guaranteed to be up to date if the folder display is active
+ _folderPaneVisible: null,
+
+ /**
+ * Whether the folder pane is visible. When we're inactive, we stash the value
+ * in |this._folderPaneVisible|.
+ */
+ get folderPaneVisible() {
+ // Early return if the user wants to use Thunderbird without an email
+ // account and no account is configured.
+ if (
+ Services.prefs.getBoolPref("app.use_without_mail_account", false) &&
+ !MailServices.accounts.accounts.length
+ ) {
+ return false;
+ }
+
+ if (this._active) {
+ let folderPaneBox = document.getElementById("folderPaneBox");
+ if (folderPaneBox) {
+ return !folderPaneBox.collapsed;
+ }
+ } else {
+ return this._folderPaneVisible;
+ }
+
+ return null;
+ },
+
+ /**
+ * Sets the visibility of the folder pane. This should reflect reality and
+ * not define it (for active tabs at least).
+ */
+ set folderPaneVisible(aVisible) {
+ this._folderPaneVisible = aVisible;
+ },
+
+ get active() {
+ return this._active;
+ },
+
+ /**
+ * Make this FolderDisplayWidget the 'active' widget by updating globals and
+ * linking us up to the UI widgets. This is intended for use by the tabbing
+ * logic.
+ */
+ makeActive(aWasInactive) {
+ let wasInactive = !this._active;
+
+ // -- globals
+ // update per-tab globals that we own
+ gFolderDisplay = this; // eslint-disable-line no-global-assign
+ gDBView = this.view.dbView; // eslint-disable-line no-global-assign
+ messenger = this.messenger; // eslint-disable-line no-global-assign
+
+ // update singleton globals' state
+ msgWindow.openFolder = this.view.displayedFolder;
+
+ this._active = true;
+ this._runNotificationsPendingActivation();
+
+ FolderDisplayListenerManager._fireListeners("onMakeActive", [
+ this,
+ aWasInactive,
+ ]);
+
+ // -- UI
+
+ // thread pane if we have a db view
+ if (this.view.dbView) {
+ // Make sure said thread pane is visible. If we do this after we re-root
+ // the tree, the thread pane may not actually replace the account central
+ // pane. Concerning...
+ this._showThreadPane();
+
+ // some things only need to happen if we are transitioning from inactive
+ // to active
+ if (wasInactive) {
+ if (this.tree) {
+ // Setting the 'view' attribute on treeBox results in the following
+ // effective calls, noting that in makeInactive we made sure to null
+ // out its view so that it won't try and clean up any views or their
+ // selections. (The actual actions happen in
+ // nsTreeBodyFrame::SetView)
+ // - this.view.dbView.selection.tree = this.tree
+ // - this.view.dbView.setTree(this.tree)
+ // - this.tree.view = this.view.dbView (in
+ // nsTreeBodyObject::SetView)
+ this.tree.view = this.view.dbView;
+
+ if (this._savedFirstVisibleRow != null) {
+ this.tree.scrollToRow(this._savedFirstVisibleRow);
+ }
+ }
+ }
+
+ // Always restore the column state if we have persisted state. We restore
+ // state on folder entry, in which case we were probably not inactive.
+ this._restoreColumnStates();
+
+ // update the columns and such that live inside the thread pane
+ this._updateThreadDisplay();
+ }
+
+ this._updateContextDisplay();
+ },
+
+ /**
+ * Cause the displayBox to display the thread pane.
+ */
+ _showThreadPane() {
+ document.getElementById("accountCentralBox").collapsed = true;
+ document.getElementById("threadPaneBox").collapsed = false;
+ },
+
+ /**
+ * Cause the displayBox to display the (preference configurable) account
+ * central page.
+ */
+ _showAccountCentral() {
+ if (!this.displayedFolder && MailServices.accounts.accounts.length > 0) {
+ // If we have any accounts set up, but no folder is selected yet,
+ // we expect another selection event to come when session restore finishes.
+ // Until then, do nothing.
+ return;
+ }
+ document.getElementById("accountCentralBox").collapsed = false;
+ document.getElementById("threadPaneBox").collapsed = true;
+
+ // Prevent a second load if necessary.
+ let loadURL =
+ "chrome://messenger/content/msgAccountCentral.xhtml" +
+ (this.displayedFolder
+ ? "?folderURI=" + encodeURIComponent(this.displayedFolder.URI)
+ : "");
+ if (window.frames.accountCentralPane.location.href != loadURL) {
+ window.frames.accountCentralPane.location.href = loadURL;
+ }
+ },
+
+ /**
+ * Call this when the tab using us is being hidden.
+ */
+ makeInactive() {
+ // - things to do before we mark ourselves inactive (because they depend on
+ // us being active)
+
+ // getColumnStates returns _savedColumnStates when we are inactive (and is
+ // used by _saveColumnStates) so we must do this before marking inactive.
+ this._saveColumnStates();
+
+ // - mark us inactive
+ this._active = false;
+
+ // - (everything after this point doesn't care that we are marked inactive)
+ // save the folder pane's state always
+ this._folderPaneVisible =
+ !document.getElementById("folderPaneBox").collapsed;
+
+ if (this.view.dbView) {
+ if (this.tree) {
+ this._savedFirstVisibleRow = this.tree.getFirstVisibleRow();
+ }
+
+ // save the message pane's state only when it is potentially visible
+ this.messagePaneCollapsed = document.getElementById(
+ "messagepaneboxwrapper"
+ ).collapsed;
+ }
+ },
+ // @}
+
+ /**
+ * @name Command Support
+ */
+ // @{
+
+ /**
+ * @returns true if there is a db view and the command is enabled on the view.
+ * This function hides some of the XPCOM-odditities of the getCommandStatus
+ * call.
+ */
+ getCommandStatus(aCommandType, aEnabledObj, aCheckStatusObj) {
+ // no view means not enabled
+ if (!this.view.dbView) {
+ return false;
+ }
+ let enabledObj = {},
+ checkStatusObj = {};
+ this.view.dbView.getCommandStatus(aCommandType, enabledObj, checkStatusObj);
+ return enabledObj.value;
+ },
+
+ /**
+ * Make code cleaner by allowing peoples to call doCommand on us rather than
+ * having to do folderDisplayWidget.view.dbView.doCommand.
+ *
+ * @param aCommandName The command name to invoke.
+ */
+ doCommand(aCommandName) {
+ return this.view.dbView && this.view.dbView.doCommand(aCommandName);
+ },
+
+ /**
+ * Make code cleaner by allowing peoples to call doCommandWithFolder on us
+ * rather than having to do:
+ * folderDisplayWidget.view.dbView.doCommandWithFolder.
+ *
+ * @param aCommandName The command name to invoke.
+ * @param aFolder The folder context for the command.
+ */
+ doCommandWithFolder(aCommandName, aFolder) {
+ return (
+ this.view.dbView &&
+ this.view.dbView.doCommandWithFolder(aCommandName, aFolder)
+ );
+ },
+ // @}
+
+ /**
+ * @returns true when account central is being displayed.
+ * @groupName Displayed
+ */
+ get isAccountCentralDisplayed() {
+ return this.view.dbView == null;
+ },
+
+ /**
+ * @name Navigation
+ * @protected
+ */
+ // @{
+
+ /**
+ * Navigate using nsMsgNavigationType rules and ensuring the resulting row is
+ * visible. This is trickier than it used to be because we now support
+ * treating collapsed threads as the set of all the messages in the collapsed
+ * thread rather than just the root message in that thread.
+ *
+ * @param {nsMsgNavigationType} aNavType navigation command.
+ * @param {boolean} [aSelect=true] should we select the message if we find
+ * one?
+ *
+ * @returns true if the navigation constraint matched anything, false if not.
+ * We will have navigated if true, we will have done nothing if false.
+ */
+ navigate(aNavType, aSelect) {
+ if (aSelect === undefined) {
+ aSelect = true;
+ }
+ let resultKeyObj = {},
+ resultIndexObj = {},
+ threadIndexObj = {};
+
+ let summarizeSelection = this.summarizeSelectionInFolder;
+
+ let treeSelection = this.treeSelection; // potentially magic getter
+ let currentIndex = treeSelection ? treeSelection.currentIndex : 0;
+
+ let viewIndex;
+ // if we're doing next unread, and a collapsed thread is selected, and
+ // the top level message is unread, just set the result manually to
+ // the top level message, without using viewNavigate.
+ if (
+ summarizeSelection &&
+ aNavType == Ci.nsMsgNavigationType.nextUnreadMessage &&
+ currentIndex != -1 &&
+ this.view.isCollapsedThreadAtIndex(currentIndex) &&
+ !(this.view.dbView.getFlagsAt(currentIndex) & Ci.nsMsgMessageFlags.Read)
+ ) {
+ viewIndex = currentIndex;
+ } else {
+ // always 'wrap' because the start index is relative to the selection.
+ // (keep in mind that many forms of navigation do not care about the
+ // starting position or 'wrap' at all; for example, firstNew just finds
+ // the first new message.)
+ // allegedly this does tree-expansion for us.
+ this.view.dbView.viewNavigate(
+ aNavType,
+ resultKeyObj,
+ resultIndexObj,
+ threadIndexObj,
+ true
+ );
+ viewIndex = resultIndexObj.value;
+ }
+
+ if (viewIndex == nsMsgViewIndex_None) {
+ return false;
+ }
+
+ // - Expand if required.
+ // (The nsMsgDBView isn't really aware of the varying semantics of
+ // collapsed threads, so viewNavigate might tell us about the root message
+ // and leave it collapsed, not realizing that it needs to be expanded.)
+ if (summarizeSelection && this.view.isCollapsedThreadAtIndex(viewIndex)) {
+ this.view.dbView.toggleOpenState(viewIndex);
+ }
+
+ if (aSelect) {
+ this.selectViewIndex(viewIndex);
+ } else {
+ this.ensureRowIsVisible(viewIndex);
+ }
+ return true;
+ },
+
+ /**
+ * Push a call to |navigate| to be what we do once we successfully open the
+ * next folder. This is intended to be used by cross-folder navigation
+ * code. It should call this method before triggering the folder change.
+ */
+ pushNavigation(aNavType, aSelect) {
+ this._pendingNavigation = [aNavType, aSelect];
+ },
+ // @}
+
+ /**
+ * @name Selection
+ */
+ // @{
+
+ /**
+ * @returns the message header for the first selected message, or null if
+ * there is no selected message.
+ *
+ * If the user has right-clicked on a message, this method will return that
+ * message and not the 'current index' (the dude with the dotted selection
+ * rectangle around him.) If you instead always want the currently
+ * displayed message (which is not impacted by right-clicking), then you
+ * would want to access the displayedMessage property on the
+ * MessageDisplayWidget. You can get to that via the messageDisplay
+ * attribute on this object or (potentially) via the gMessageDisplay object.
+ */
+ get selectedMessage() {
+ // there are inconsistencies in hdrForFirstSelectedMessage between
+ // nsMsgDBView and nsMsgSearchDBView in whether they use currentIndex,
+ // do it ourselves. (nsMsgDBView does not use currentIndex, search does.)
+ let treeSelection = this.treeSelection;
+ if (!treeSelection || !treeSelection.count) {
+ return null;
+ }
+ let minObj = {},
+ maxObj = {};
+ treeSelection.getRangeAt(0, minObj, maxObj);
+ return this.view.dbView.getMsgHdrAt(minObj.value);
+ },
+
+ /**
+ * @returns true if there is a selected message and it's an RSS feed message;
+ * a feed message does not have to be in an rss account folder if stored in
+ * Tb15 and later.
+ */
+ get selectedMessageIsFeed() {
+ return FeedUtils.isFeedMessage(this.selectedMessage);
+ },
+
+ /**
+ * @returns true if there is a selected message and it's an IMAP message.
+ */
+ get selectedMessageIsImap() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message &&
+ message.folder &&
+ message.folder.flags & Ci.nsMsgFolderFlags.ImapBox
+ );
+ },
+
+ /**
+ * @returns true if there is a selected message and it's a news message. It
+ * would be great if messages knew this about themselves, but they don't.
+ */
+ get selectedMessageIsNews() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message &&
+ message.folder &&
+ message.folder.flags & Ci.nsMsgFolderFlags.Newsgroup
+ );
+ },
+
+ /**
+ * @returns true if there is a selected message and it's an external message,
+ * meaning it is loaded from an .eml file on disk or is an rfc822 attachment
+ * on a message.
+ */
+ get selectedMessageIsExternal() {
+ let message = this.selectedMessage;
+ // Dummy messages currently lack a folder. This is not a great heuristic.
+ // I have annotated msgHdrView.js which provides the dummy header to
+ // express this implementation dependency.
+ // (Currently, since external mails can only be opened in standalone windows
+ // which subclass us, we could always return false, and have the subclass
+ // return true using its own heuristics. But since we are moving to a tab
+ // model more heavily, at some point the 3-pane will need this.)
+ return Boolean(message && !message.folder);
+ },
+
+ /**
+ * @returns true if there is a selected message and the message belongs to an
+ * ignored thread.
+ */
+ get selectedMessageThreadIgnored() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message &&
+ message.folder &&
+ message.folder.msgDatabase.isIgnored(message.messageKey)
+ );
+ },
+
+ /**
+ * @returns true if there is a selected message and the message is the base
+ * message for an ignored subthread.
+ */
+ get selectedMessageSubthreadIgnored() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message && message.folder && message.flags & Ci.nsMsgMessageFlags.Ignored
+ );
+ },
+
+ /**
+ * @returns true if there is a selected message and the message belongs to a
+ * watched thread.
+ */
+ get selectedMessageThreadWatched() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message &&
+ message.folder &&
+ message.folder.msgDatabase.isWatched(message.messageKey)
+ );
+ },
+
+ /**
+ * @returns the number of selected messages. If summarizeSelectionInFolder is
+ * true, then any collapsed thread roots that are selected will also
+ * conceptually have all of the messages in that thread selected.
+ */
+ get selectedCount() {
+ return this.selectedMessages.length;
+ },
+
+ /**
+ * Provides a list of the view indices that are selected which is *not* the
+ * same as the rows of the selected messages. When
+ * summarizeSelectionInFolder is true, messages may be selected but not
+ * visible (because the thread root is selected.)
+ * You probably want to use the |selectedMessages| attribute instead of this
+ * one. (Or selectedMessageUris in some rare cases.)
+ *
+ * If the user has right-clicked on a message, this will return that message
+ * and not the selection prior to the right-click.
+ *
+ * @returns a list of the view indices that are currently selected
+ */
+ get selectedIndices() {
+ if (!this.view.dbView) {
+ return [];
+ }
+
+ return this.view.dbView.getIndicesForSelection();
+ },
+
+ /**
+ * Provides a list of the message headers for the currently selected messages.
+ * If summarizeSelectionInFolder is true, then any collapsed thread roots
+ * that are selected will also (conceptually) have all of the messages in
+ * that thread selected and they will be included in the returned list.
+ *
+ * If the user has right-clicked on a message, this will return that message
+ * (and any collapsed children if so enabled) and not the selection prior to
+ * the right-click.
+ *
+ * @returns a list of the message headers for the currently selected messages.
+ * If there are no selected messages, the result is an empty list.
+ */
+ get selectedMessages() {
+ if (!this._active && this._savedSelection?.messages) {
+ return this._savedSelection.messages
+ .map(savedInfo => this.view.getMsgHdrForMessageID(savedInfo.messageId))
+ .filter(msgHdr => !!msgHdr);
+ }
+ if (!this.view.dbView) {
+ return [];
+ }
+ return this.view.dbView.getSelectedMsgHdrs();
+ },
+
+ /**
+ * @returns a list of the URIs for the currently selected messages or null
+ * (instead of a list) if there are no selected messages. Do not
+ * pass around URIs unless you have a good reason. Legacy code is an
+ * ok reason.
+ *
+ * If the user has right-clicked on a message, this will return that message's
+ * URI and not the selection prior to the right-click.
+ */
+ get selectedMessageUris() {
+ if (!this.view.dbView) {
+ return null;
+ }
+
+ let messageArray = this.view.dbView.getURIsForSelection();
+ return messageArray.length ? messageArray : null;
+ },
+
+ /**
+ * @returns true if all the selected messages can be archived, false otherwise.
+ */
+ get canArchiveSelectedMessages() {
+ return false;
+ },
+
+ /**
+ * The maximum number of messages canMarkThreadAsRead will look through.
+ * If the number exceeds this limit, as a performance measure, we return
+ * true rather than looking through the messages and possible
+ * submessages.
+ */
+ MAX_COUNT_FOR_MARK_THREAD: 1000,
+
+ /**
+ * Check if the thread for the currently selected message can be marked as
+ * read. A thread can be marked as read if and only if it has at least one
+ * unread message.
+ */
+ get canMarkThreadAsRead() {
+ if (
+ (this.displayedFolder && this.displayedFolder.getNumUnread(false) > 0) ||
+ this.view._underlyingData === this.view.kUnderlyingSynthetic
+ ) {
+ // If the messages limit is exceeded we bail out early and return true.
+ if (this.selectedIndices.length > this.MAX_COUNT_FOR_MARK_THREAD) {
+ return true;
+ }
+
+ for (let i of this.selectedIndices) {
+ if (
+ this.view.dbView.getThreadContainingIndex(i).numUnreadChildren > 0
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+
+ /**
+ * @returns true if all the selected messages can be deleted from their
+ * folders, false otherwise.
+ */
+ get canDeleteSelectedMessages() {
+ if (!this.view.dbView) {
+ return false;
+ }
+
+ let selectedMessages = this.selectedMessages;
+ for (let i = 0; i < selectedMessages.length; ++i) {
+ if (
+ selectedMessages[i].folder &&
+ (!selectedMessages[i].folder.canDeleteMessages ||
+ selectedMessages[i].folder.flags & Ci.nsMsgFolderFlags.Newsgroup)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Clear the tree selection, making sure the message pane is cleared and
+ * the context display (toolbars, etc.) are updated.
+ */
+ clearSelection() {
+ let treeSelection = this.treeSelection; // potentially magic getter
+ if (!treeSelection) {
+ return;
+ }
+ treeSelection.clearSelection();
+ this._updateContextDisplay();
+ },
+
+ // Whether we're about to select a message
+ _aboutToSelectMessage: false,
+
+ /**
+ * This needs to be called to let us know that a selectMessage or equivalent
+ * is coming up right after a show() call, so that we know that a double
+ * message load won't be happening.
+ *
+ * This can be assumed to be idempotent.
+ */
+ selectMessageComingUp() {
+ this._aboutToSelectMessage = true;
+ },
+
+ /**
+ * Select a message for display by header. Attempt to select the message
+ * right now. If we were unable to find it, update our saved selection
+ * to want to display the message. Threads are expanded to find the header.
+ *
+ * @param aMsgHdr The message header to select for display.
+ * @param [aForceSelect] If the message is not in the view and this is true,
+ * we will drop any applied view filters to look for the
+ * message. The dropping of view filters is persistent,
+ * so use with care. Defaults to false.
+ */
+ selectMessage(aMsgHdr, aForceSelect) {
+ let viewIndex = this.view.getViewIndexForMsgHdr(aMsgHdr, aForceSelect);
+ if (viewIndex != nsMsgViewIndex_None) {
+ this._savedSelection = null;
+ this.selectViewIndex(viewIndex);
+ } else {
+ this._savedSelection = {
+ messages: [{ messageId: aMsgHdr.messageId }],
+ forceSelect: aForceSelect,
+ };
+ // queue the selection to be restored once we become active if we are not
+ // active.
+ if (!this.active) {
+ this._notifyWhenActive(this._restoreSelection);
+ }
+ }
+
+ // Do this here instead of at the beginning to prevent reentrancy issues
+ this._aboutToSelectMessage = false;
+ },
+
+ /**
+ * Select all of the provided nsIMsgDBHdrs in the aMessages array, expanding
+ * threads as required. If we were not able to find all of the messages,
+ * update our saved selection to want to display the messages. The messages
+ * will then be selected when we are made active or all messages in the
+ * folder complete loading. This is to accommodate the use-case where we
+ * are backed by an in-progress search and no
+ *
+ * @param aMessages An array of nsIMsgDBHdr instances.
+ * @param [aForceSelect] If a message is not in the view and this is true,
+ * we will drop any applied view filters to look for the
+ * message. The dropping of view filters is persistent,
+ * so use with care. Defaults to false.
+ * @param aDoNotNeedToFindAll If true (can be omitted and left undefined), we
+ * do not attempt to save the selection for future use. This is intended
+ * for use by the _restoreSelection call which is the end-of-the-line for
+ * restoring the selection. (Once it gets called all of our messages
+ * should have already been loaded.)
+ */
+ selectMessages(aMessages, aForceSelect, aDoNotNeedToFindAll) {
+ let treeSelection = this.treeSelection; // potentially magic getter
+ let foundAll = true;
+ if (treeSelection) {
+ let minRow = null,
+ maxRow = null;
+
+ treeSelection.selectEventsSuppressed = true;
+ treeSelection.clearSelection();
+
+ for (let msgHdr of aMessages) {
+ let viewIndex = this.view.getViewIndexForMsgHdr(msgHdr, aForceSelect);
+
+ if (viewIndex != nsMsgViewIndex_None) {
+ if (minRow == null || viewIndex < minRow) {
+ minRow = viewIndex;
+ }
+ if (maxRow == null || viewIndex > maxRow) {
+ maxRow = viewIndex;
+ }
+ // nsTreeSelection is actually very clever about doing this
+ // efficiently.
+ treeSelection.rangedSelect(viewIndex, viewIndex, true);
+ } else {
+ foundAll = false;
+ }
+
+ // make sure the selection is as visible as possible
+ if (minRow != null) {
+ this.ensureRowRangeIsVisible(minRow, maxRow);
+ }
+ }
+
+ treeSelection.selectEventsSuppressed = false;
+
+ // If we haven't selected every message, we'll set |this._savedSelection|
+ // below, so it's fine to null it out at this point.
+ this._savedSelection = null;
+ }
+
+ // Do this here instead of at the beginning to prevent reentrancy issues
+ this._aboutToSelectMessage = false;
+
+ // Two cases.
+ // 1. The tree selection isn't there at all.
+ // 2. The tree selection is there, and we needed to find all messages, but
+ // we didn't.
+ if (!treeSelection || (!aDoNotNeedToFindAll && !foundAll)) {
+ this._savedSelection = {
+ messages: aMessages.map(msgHdr => ({ messageId: msgHdr.messageId })),
+ forceSelect: aForceSelect,
+ };
+ if (!this.active) {
+ this._notifyWhenActive(this._restoreSelection);
+ }
+ }
+ },
+
+ /**
+ * Select the message at view index.
+ *
+ * @param aViewIndex The view index to select. This will be bounds-checked
+ * and if it is outside the bounds, we will clear the selection and
+ * bail.
+ */
+ selectViewIndex(aViewIndex) {
+ let treeSelection = this.treeSelection;
+ // if we have no selection, we can't select something
+ if (!treeSelection) {
+ return;
+ }
+ let rowCount = this.view.dbView.rowCount;
+ if (
+ aViewIndex == nsMsgViewIndex_None ||
+ aViewIndex < 0 ||
+ aViewIndex >= rowCount
+ ) {
+ this.clearSelection();
+ return;
+ }
+
+ // Check whether the index is already selected/current. This can be the
+ // case when we are here as the result of a deletion. Assuming
+ // nsMsgDBView::NoteChange ran and was not suppressing change
+ // notifications, then it's very possible the selection is already where
+ // we want it to go. However, in that case, nsMsgDBView::SelectionChanged
+ // bailed without doing anything because m_deletingRows...
+ // So we want to generate a change notification if that is the case. (And
+ // we still want to call ensureRowIsVisible, as there may be padding
+ // required.)
+ if (
+ treeSelection.count == 1 &&
+ (treeSelection.currentIndex == aViewIndex ||
+ treeSelection.isSelected(aViewIndex))
+ ) {
+ // Make sure the index we just selected is also the current index.
+ // This can happen when the tree selection adjusts itself as a result of
+ // changes to the tree as a result of deletion. This will not trigger
+ // a notification.
+ treeSelection.select(aViewIndex);
+ this.view.dbView.selectionChanged();
+ } else {
+ // Previous code was concerned about avoiding updating commands on the
+ // assumption that only the selection count mattered. We no longer
+ // make this assumption.
+ // Things that may surprise you about the call to treeSelection.select:
+ // 1) This ends up calling the onselect method defined on the XUL 'tree'
+ // tag. For the 3pane this is the ThreadPaneSelectionChanged method in
+ // threadPane.js. That code checks a global to see if it is dealing
+ // with a right-click, and ignores it if so.
+ treeSelection.select(aViewIndex);
+ }
+
+ if (this._active) {
+ this.ensureRowIsVisible(aViewIndex);
+ }
+
+ // The saved selection is invalidated, since we've got something newer
+ this._savedSelection = null;
+
+ // Do this here instead of at the beginning to prevent reentrancy issues
+ this._aboutToSelectMessage = false;
+ },
+
+ /**
+ * For every selected message in the display that is part of a (displayed)
+ * thread and is not the root message, de-select it and ensure that the
+ * root message of the thread is selected.
+ * This is primarily intended to be used when collapsing visible threads.
+ *
+ * We do nothing if we are not in a threaded display mode.
+ */
+ selectSelectedThreadRoots() {
+ if (!this.view.showThreaded) {
+ return;
+ }
+
+ // There are basically two implementation strategies available to us:
+ // 1) For each selected view index with a level > 0, keep walking 'up'
+ // (numerically smaller) until we find a message with level 0.
+ // The inefficiency here is the potentially large number of JS calls
+ // into XPCOM space that will be required.
+ // 2) Ask for the thread that each view index belongs to, use that to
+ // efficiently retrieve the thread root, then find the root using
+ // the message header. The inefficiency here is that the view
+ // currently does a linear scan, albeit a relatively efficient one.
+ // And the winner is... option 2, because the code is simpler because we
+ // can reuse selectMessages to do most of the work.
+ let selectedIndices = this.selectedIndices;
+ let newSelectedMessages = [];
+ let dbView = this.view.dbView;
+ for (let index of selectedIndices) {
+ let thread = dbView.getThreadContainingIndex(index);
+ newSelectedMessages.push(thread.getRootHdr());
+ }
+ this.selectMessages(newSelectedMessages);
+ },
+
+ // @}
+
+ /**
+ * @name Ensure Visibility
+ */
+ // @{
+
+ /**
+ * Minimum number of lines to display between the 'focused' message and the
+ * top / bottom of the thread pane.
+ */
+ get visibleRowPadding() {
+ let topPadding, bottomPadding;
+
+ // If we can get the height of the folder pane, treat the values as
+ // percentages of that.
+ if (this.tree) {
+ let topPercentPadding = Services.prefs.getIntPref(
+ "mail.threadpane.padding.top_percent"
+ );
+ let bottomPercentPadding = Services.prefs.getIntPref(
+ "mail.threadpane.padding.bottom_percent"
+ );
+
+ // Assume the bottom row is half-visible and should generally be ignored.
+ // (We could actually do the legwork to see if there is a partial one...)
+ let paneHeight = this.tree.getPageLength() - 1;
+
+ // Convert from percentages to absolute row counts.
+ topPadding = Math.ceil((topPercentPadding / 100) * paneHeight);
+ bottomPadding = Math.ceil((bottomPercentPadding / 100) * paneHeight);
+
+ // We need one visible row not counted in either padding, for the actual
+ // target message. Also helps correct for rounding errors.
+ if (topPadding + bottomPadding > paneHeight) {
+ if (topPadding > bottomPadding) {
+ topPadding--;
+ } else {
+ bottomPadding--;
+ }
+ }
+ } else {
+ // Something's gone wrong elsewhere, and we likely have bigger problems.
+ topPadding = 0;
+ bottomPadding = 0;
+ console.error("Unable to get height of folder pane (treeBox is null)");
+ }
+
+ return [topPadding, bottomPadding];
+ },
+
+ /**
+ * Ensure the given view index is visible, optionally with some padding.
+ * By padding, we mean that the index will not be the first or last message
+ * displayed, but rather have messages on either side.
+ * We have the concept of a 'lip' when we are at the end of the message
+ * display. If we are near the end of the display, we want to show an
+ * empty row (at the bottom) so the user knows they are at the end. Also,
+ * if a message shows up that is new and things are sorted ascending, this
+ * turns out to be useful.
+ */
+ ensureRowIsVisible(aViewIndex, aBounced) {
+ // Dealing with the tree view layout is a nightmare, let's just always make
+ // sure we re-schedule ourselves. The most particular rationale here is
+ // that the message pane may be toggling its state and it's much simpler
+ // and reliable if we ensure that all of FolderDisplayWidget's state
+ // change logic gets to run to completion before we run ourselves.
+ if (!aBounced) {
+ let dis = this;
+ window.setTimeout(function () {
+ dis.ensureRowIsVisible(aViewIndex, true);
+ }, 0);
+ }
+
+ let tree = this.tree;
+ if (!tree || !tree.view) {
+ return;
+ }
+
+ // try and trigger a reflow...
+ tree.getBoundingClientRect();
+
+ let maxIndex = tree.view.rowCount - 1;
+
+ let first = tree.getFirstVisibleRow();
+ // Assume the bottom row is half-visible and should generally be ignored.
+ // (We could actually do the legwork to see if there is a partial one...)
+ const halfVisible = 1;
+ let last = tree.getLastVisibleRow() - halfVisible;
+ let span = tree.getPageLength() - halfVisible;
+ let [topPadding, bottomPadding] = this.visibleRowPadding;
+
+ let target;
+ if (aViewIndex >= last - bottomPadding) {
+ // The index is after the last visible guy (with padding),
+ // move down so that the target index is padded in 1 from the bottom.
+ target = Math.min(maxIndex, aViewIndex + bottomPadding) - span;
+ } else if (aViewIndex <= first + topPadding) {
+ // The index is before the first visible guy (with padding), move up.
+ target = Math.max(0, aViewIndex - topPadding);
+ } else {
+ // It is already visible.
+ return;
+ }
+
+ // this sets the first visible row
+ tree.scrollToRow(target);
+ },
+
+ /**
+ * Ensure that the given range of rows is maximally visible in the thread
+ * pane. If the range is larger than the number of rows that can be
+ * displayed in the thread pane, we bias towards showing the min row (with
+ * padding).
+ *
+ * @param aMinRow The numerically smallest row index defining the start of
+ * the inclusive range.
+ * @param aMaxRow The numberically largest row index defining the end of the
+ * inclusive range.
+ */
+ ensureRowRangeIsVisible(aMinRow, aMaxRow, aBounced) {
+ // Dealing with the tree view layout is a nightmare, let's just always make
+ // sure we re-schedule ourselves. The most particular rationale here is
+ // that the message pane may be toggling its state and it's much simpler
+ // and reliable if we ensure that all of FolderDisplayWidget's state
+ // change logic gets to run to completion before we run ourselves.
+ if (!aBounced) {
+ let dis = this;
+ window.setTimeout(function () {
+ dis.ensureRowRangeIsVisible(aMinRow, aMaxRow, true);
+ }, 0);
+ }
+
+ let tree = this.tree;
+ if (!tree) {
+ return;
+ }
+ let first = tree.getFirstVisibleRow();
+ const halfVisible = 1;
+ let last = tree.getLastVisibleRow() - halfVisible;
+ let span = tree.getPageLength() - halfVisible;
+ let [topPadding, bottomPadding] = this.visibleRowPadding;
+
+ // bail if the range is already visible with padding constraints handled
+ if (first + topPadding <= aMinRow && last - bottomPadding >= aMaxRow) {
+ return;
+ }
+
+ let target;
+ // if the range is bigger than we can fit, optimize position for the min row
+ // with padding to make it obvious the range doesn't extend above the row.
+ if (aMaxRow - aMinRow > span) {
+ target = Math.max(0, aMinRow - topPadding);
+ } else {
+ // So the range must fit, and it's a question of how we want to position
+ // it. For now, the answer is we try and center it, why not.
+ let rowSpan = aMaxRow - aMinRow + 1;
+ let halfSpare = Math.floor(
+ (span - rowSpan - topPadding - bottomPadding) / 2
+ );
+ target = aMinRow - halfSpare - topPadding;
+ }
+ tree.scrollToRow(target);
+ },
+
+ /**
+ * Ensure that the selection is visible to the extent possible.
+ */
+ ensureSelectionIsVisible() {
+ let treeSelection = this.treeSelection; // potentially magic getter
+ if (!treeSelection || !treeSelection.count) {
+ return;
+ }
+
+ let minRow = null,
+ maxRow = null;
+
+ let rangeCount = treeSelection.getRangeCount();
+ for (let iRange = 0; iRange < rangeCount; iRange++) {
+ let rangeMinObj = {},
+ rangeMaxObj = {};
+ treeSelection.getRangeAt(iRange, rangeMinObj, rangeMaxObj);
+ let rangeMin = rangeMinObj.value,
+ rangeMax = rangeMaxObj.value;
+ if (minRow == null || rangeMin < minRow) {
+ minRow = rangeMin;
+ }
+ if (maxRow == null || rangeMax > maxRow) {
+ maxRow = rangeMax;
+ }
+ }
+
+ this.ensureRowRangeIsVisible(minRow, maxRow);
+ },
+ // @}
+};
+
+function SetNewsFolderColumns() {
+ var sizeColumn = document.getElementById("sizeCol");
+ var bundle = document.getElementById("bundle_messenger");
+
+ if (gDBView.usingLines) {
+ sizeColumn.setAttribute("label", bundle.getString("linesColumnHeader"));
+ sizeColumn.setAttribute(
+ "tooltiptext",
+ bundle.getString("linesColumnTooltip2")
+ );
+ } else {
+ sizeColumn.setAttribute("label", bundle.getString("sizeColumnHeader"));
+ sizeColumn.setAttribute(
+ "tooltiptext",
+ bundle.getString("sizeColumnTooltip2")
+ );
+ }
+}
+
+function UpdateStatusQuota(folder) {
+ if (!document.getElementById("quotaPanel")) {
+ // No quotaPanel in here, like for the search window.
+ return;
+ }
+
+ if (!(folder && folder instanceof Ci.nsIMsgImapMailFolder)) {
+ document.getElementById("quotaPanel").hidden = true;
+ return;
+ }
+
+ let quotaUsagePercentage = q =>
+ Number((100n * BigInt(q.usage)) / BigInt(q.limit));
+
+ // For display on main window panel only include quota names containing
+ // "STORAGE" or "MESSAGE". This will exclude unusual quota names containing
+ // items like "MAILBOX" and "LEVEL" from the panel bargraph. All quota names
+ // will still appear on the folder properties quota window.
+ // Note: Quota name is typically something like "User Quota / STORAGE".
+ let folderQuota = folder
+ .getQuota()
+ .filter(
+ quota =>
+ quota.name.toUpperCase().includes("STORAGE") ||
+ quota.name.toUpperCase().includes("MESSAGE")
+ );
+ // If folderQuota not empty, find the index of the element with highest
+ // percent usage and determine if it is above the panel display threshold.
+ if (folderQuota.length > 0) {
+ let highest = folderQuota.reduce((acc, current) =>
+ quotaUsagePercentage(acc) > quotaUsagePercentage(current) ? acc : current
+ );
+ let percent = quotaUsagePercentage(highest);
+ if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.show")
+ ) {
+ document.getElementById("quotaPanel").hidden = true;
+ } else {
+ document.getElementById("quotaPanel").hidden = false;
+ document.getElementById("quotaMeter").setAttribute("value", percent);
+ var bundle = document.getElementById("bundle_messenger");
+ document.getElementById("quotaLabel").value = bundle.getFormattedString(
+ "percent",
+ [percent]
+ );
+ document.getElementById("quotaLabel").tooltipText =
+ bundle.getFormattedString("quotaTooltip2", [
+ highest.usage,
+ highest.limit,
+ ]);
+ let quotaPanel = document.getElementById("quotaPanel");
+ if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.warning")
+ ) {
+ quotaPanel.classList.remove("alert-warning", "alert-critical");
+ } else if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.critical")
+ ) {
+ quotaPanel.classList.remove("alert-critical");
+ quotaPanel.classList.add("alert-warning");
+ } else {
+ quotaPanel.classList.remove("alert-warning");
+ quotaPanel.classList.add("alert-critical");
+ }
+ }
+ } else {
+ document.getElementById("quotaPanel").hidden = true;
+ }
+}
diff --git a/comm/mail/base/content/globalOverlay.js b/comm/mail/base/content/globalOverlay.js
new file mode 100644
index 0000000000..b31751a490
--- /dev/null
+++ b/comm/mail/base/content/globalOverlay.js
@@ -0,0 +1,122 @@
+/* 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/. */
+
+/**
+ * Notifies observers that quitting has been requested.
+ *
+ * @returns {boolean} - True if an observer prevented quitting, false otherwise.
+ */
+function canQuitApplication() {
+ try {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+
+ // Something aborted the quit process.
+ if (cancelQuit.data) {
+ return false;
+ }
+ } catch (ex) {}
+ return true;
+}
+
+/**
+ * Quit the application if no `quit-application-requested` observer prevents it.
+ */
+function goQuitApplication() {
+ if (!canQuitApplication()) {
+ return false;
+ }
+
+ Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit);
+ return true;
+}
+
+/**
+ * Gets the first registered controller that returns true for both
+ * `supportsCommand` and `isCommandEnabled`, or null if no controllers
+ * return true for both.
+ *
+ * @param {string} command - The command name to pass to controllers.
+ * @returns {nsIController|null}
+ */
+function getEnabledControllerForCommand(command) {
+ // The first controller for which `supportsCommand` returns true.
+ let controllerA =
+ top.document.commandDispatcher.getControllerForCommand(command);
+ if (controllerA?.isCommandEnabled(command)) {
+ return controllerA;
+ }
+
+ // Didn't find a controller, or `isCommandEnabled` returned false?
+ // Try the other controllers. Note this isn't exactly the same set
+ // of controllers as `commandDispatcher` has.
+ for (let i = 0; i < top.controllers.getControllerCount(); i++) {
+ let controllerB = top.controllers.getControllerAt(i);
+ if (
+ controllerB !== controllerA &&
+ controllerB.supportsCommand(command) &&
+ controllerB.isCommandEnabled(command)
+ ) {
+ return controllerB;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Updates the enabled state of the element with the ID `command`. The command
+ * is considered enabled if at least one controller returns true for both
+ * `supportsCommand` and `isCommandEnabled`.
+ *
+ * @param {string} command - The command name to pass to controllers.
+ */
+function goUpdateCommand(command) {
+ try {
+ goSetCommandEnabled(command, !!getEnabledControllerForCommand(command));
+ } catch (e) {
+ console.error(`An error occurred updating the ${command} command: ${e}`);
+ }
+}
+
+/**
+ * Calls `doCommand` on the first controller that returns true for both
+ * `supportsCommand` and `isCommandEnabled`.
+ *
+ * @param {string} command - The command name to pass to controllers.
+ * @param {any[]} args - Any number of arguments to pass to the chosen
+ * controller. Note that passing arguments is not part of the `nsIController`
+ * interface and only possible for JS controllers.
+ */
+function goDoCommand(command, ...args) {
+ try {
+ let controller = getEnabledControllerForCommand(command);
+ if (controller) {
+ controller = controller.wrappedJSObject ?? controller;
+ controller.doCommand(command, ...args);
+ }
+ } catch (e) {
+ console.error(`An error occurred executing the ${command} command: ${e}`);
+ }
+}
+
+/**
+ * Updates the enabled state of the element with the ID `id`.
+ *
+ * @param {string} id
+ * @param {boolean} enabled
+ */
+function goSetCommandEnabled(id, enabled) {
+ let node = document.getElementById(id);
+
+ if (node) {
+ if (enabled) {
+ node.removeAttribute("disabled");
+ } else {
+ node.setAttribute("disabled", "true");
+ }
+ }
+}
diff --git a/comm/mail/base/content/glodaFacetTab.js b/comm/mail/base/content/glodaFacetTab.js
new file mode 100644
index 0000000000..f194b2c24b
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetTab.js
@@ -0,0 +1,111 @@
+/* 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,
+ "GlodaMsgSearcher",
+ "resource:///modules/gloda/GlodaMsgSearcher.jsm"
+);
+
+var glodaFacetTabType = {
+ name: "glodaFacet",
+ perTabPanel: "vbox",
+ lastTabId: 0,
+ strings: Services.strings.createBundle(
+ "chrome://messenger/locale/glodaFacetView.properties"
+ ),
+ modes: {
+ glodaFacet: {
+ // this is what get exposed on the tab for icon purposes
+ type: "glodaSearch",
+ },
+ },
+ openTab(aTab, aArgs) {
+ // If aArgs is empty, default to a blank user search.
+ if (!Object.keys(aArgs).length) {
+ aArgs = { searcher: new GlodaMsgSearcher(null, "") };
+ }
+ // we have no browser until our XUL document loads
+ aTab.browser = null;
+
+ aTab.tabNode.setIcon(
+ "chrome://messenger/skin/icons/new/compact/search.svg"
+ );
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("glodaTab")
+ .firstElementChild.cloneNode(true);
+
+ aTab.panel.setAttribute("id", "glodaTab" + this.lastTabId);
+ aTab.panel.appendChild(clone);
+ aTab.iframe = aTab.panel.querySelector("iframe");
+
+ if ("query" in aArgs) {
+ aTab.query = aArgs.query;
+ aTab.collection = aTab.query.getCollection();
+
+ aTab.title = this.strings.GetStringFromName(
+ "glodaFacetView.tab.query.label"
+ );
+ aTab.searchString = null;
+ } else if ("searcher" in aArgs) {
+ aTab.searcher = aArgs.searcher;
+ aTab.collection = aTab.searcher.getCollection();
+ aTab.query = aTab.searcher.query;
+ if ("IMSearcher" in aArgs) {
+ aTab.IMSearcher = aArgs.IMSearcher;
+ aTab.IMCollection = aArgs.IMSearcher.getCollection();
+ aTab.IMQuery = aTab.IMSearcher.query;
+ }
+
+ let searchString = aTab.searcher.searchString;
+ aTab.searchInputValue = aTab.searchString = searchString;
+ aTab.title = searchString
+ ? searchString
+ : this.strings.GetStringFromName("glodaFacetView.tab.search.label");
+ } else if ("collection" in aArgs) {
+ aTab.collection = aArgs.collection;
+
+ aTab.title = this.strings.GetStringFromName(
+ "glodaFacetView.tab.query.label"
+ );
+ aTab.searchString = null;
+ }
+
+ function xulLoadHandler() {
+ aTab.iframe.contentWindow.tab = aTab;
+ aTab.browser = aTab.iframe.contentDocument.getElementById("browser");
+ aTab.browser.setAttribute(
+ "src",
+ "chrome://messenger/content/glodaFacetView.xhtml"
+ );
+
+ // Wire up the search input icon click event
+ let searchInput = aTab.panel.querySelector(".remote-gloda-search");
+ searchInput.focus();
+ }
+
+ aTab.iframe.contentWindow.addEventListener("load", xulLoadHandler, {
+ capture: false,
+ once: true,
+ });
+ aTab.iframe.setAttribute(
+ "src",
+ "chrome://messenger/content/glodaFacetViewWrapper.xhtml"
+ );
+
+ this.lastTabId++;
+ },
+ closeTab(aTab) {},
+ saveTabState(aTab) {
+ // nothing to do; we are not multiplexed
+ },
+ showTab(aTab) {
+ // nothing to do; we are not multiplexed
+ },
+ getBrowser(aTab) {
+ return aTab.browser;
+ },
+};
diff --git a/comm/mail/base/content/glodaFacetView.js b/comm/mail/base/content/glodaFacetView.js
new file mode 100644
index 0000000000..db8bce8150
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetView.js
@@ -0,0 +1,1114 @@
+/* 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 provides the global context for the faceting environment. In the
+ * Model View Controller (paradigm), we are the view and the XBL widgets are
+ * the the view and controller.
+ *
+ * Because much of the work related to faceting is not UI-specific, we try and
+ * push as much of it into mailnews/db/gloda/Facet.jsm. In some cases we may
+ * get it wrong and it may eventually want to migrate.
+ */
+
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+var { Gloda } = ChromeUtils.import("resource:///modules/gloda/GlodaPublic.jsm");
+var { GlodaConstants } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+var { GlodaSyntheticView } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaSyntheticView.jsm"
+);
+var { FacetDriver, FacetUtils } = ChromeUtils.import(
+ "resource:///modules/gloda/Facet.jsm"
+);
+
+var glodaFacetStrings = Services.strings.createBundle(
+ "chrome://messenger/locale/glodaFacetView.properties"
+);
+
+/**
+ * Object containing query-explanantion binding methods.
+ */
+const QueryExplanation = {
+ get node() {
+ return document.getElementById("query-explanation");
+ },
+ /**
+ * Indicate that we are based on a fulltext search
+ */
+ setFulltext(aMsgSearcher) {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+
+ const spanify = (text, classNames) => {
+ const span = document.createElement("span");
+ span.setAttribute("class", classNames);
+ span.textContent = text;
+ this.node.appendChild(span);
+ return span;
+ };
+
+ const searchLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.search.label2"
+ );
+ spanify(searchLabel, "explanation-fulltext-label");
+
+ const criteriaText = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.fulltext." +
+ (aMsgSearcher.andTerms ? "and" : "or") +
+ "JoinWord"
+ );
+ for (let [iTerm, term] of aMsgSearcher.fulltextTerms.entries()) {
+ if (iTerm) {
+ spanify(criteriaText, "explanation-fulltext-criteria");
+ }
+ spanify(term, "explanation-fulltext-term");
+ }
+ },
+ setQuery(msgQuery) {
+ try {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+
+ const spanify = (text, classNames) => {
+ const span = document.createElement("span");
+ span.setAttribute("class", classNames);
+ span.textContent = text;
+ this.node.appendChild(span);
+ return span;
+ };
+
+ let label = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.search.label2"
+ );
+ spanify(label, "explanation-query-label");
+
+ let constraintStrings = [];
+ for (let constraint of msgQuery._constraints) {
+ if (constraint[0] != 1) {
+ // No idea what this is about.
+ return;
+ }
+ if (constraint[1].attributeName == "involves") {
+ let involvesLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.involves.label"
+ );
+ involvesLabel = involvesLabel.replace("#1", constraint[2].value);
+ spanify(involvesLabel, "explanation-query-involves");
+ } else if (constraint[1].attributeName == "tag") {
+ const tagLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.tagged.label"
+ );
+ const tag = constraint[2];
+ const tagNode = document.createElement("span");
+ const color = MailServices.tags.getColorForKey(tag.key);
+ tagNode.setAttribute("class", "message-tag");
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.textContent = tag.tag;
+ spanify(tagLabel, "explanation-query-tagged");
+ this.node.appendChild(tagNode);
+ }
+ }
+ label = label + constraintStrings.join(", "); // XXX l10n?
+ } catch (e) {
+ console.error(e);
+ }
+ },
+};
+
+/**
+ * Object containing facets binding methods.
+ */
+const UIFacets = {
+ get node() {
+ return document.getElementById("facets");
+ },
+ clearFacets() {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+ },
+ addFacet(type, attrDef, args) {
+ let facet;
+
+ if (type === "boolean") {
+ facet = document.createElement("facet-boolean");
+ } else if (type === "boolean-filtered") {
+ facet = document.createElement("facet-boolean-filtered");
+ } else if (type === "discrete") {
+ facet = document.createElement("facet-discrete");
+ } else {
+ facet = document.createElement("div");
+ facet.setAttribute("class", "facetious");
+ }
+
+ facet.attrDef = attrDef;
+ facet.nounDef = attrDef.objectNounDef;
+ facet.setAttribute("type", type);
+
+ for (let key in args) {
+ facet[key] = args[key];
+ }
+
+ facet.setAttribute("name", attrDef.attributeName);
+ this.node.appendChild(facet);
+
+ return facet;
+ },
+};
+
+/**
+ * Represents the active constraints on a singular facet. Singular facets can
+ * only have an inclusive set or an exclusive set, but not both. Non-singular
+ * facets can have both. Because they are different worlds, non-singular gets
+ * its own class, |ActiveNonSingularConstraint|.
+ */
+function ActiveSingularConstraint(aFaceter, aRanged) {
+ this.faceter = aFaceter;
+ this.attrDef = aFaceter.attrDef;
+ this.facetDef = aFaceter.facetDef;
+ this.ranged = Boolean(aRanged);
+ this.clear();
+}
+ActiveSingularConstraint.prototype = {
+ _makeQuery() {
+ // have the faceter make the query and the invert decision for us if it
+ // implements the makeQuery method.
+ if ("makeQuery" in this.faceter) {
+ [this.query, this.invertQuery] = this.faceter.makeQuery(
+ this.groupValues,
+ this.inclusive
+ );
+ return;
+ }
+
+ let query = (this.query = Gloda.newQuery(GlodaConstants.NOUN_MESSAGE));
+ let constraintFunc;
+ // If the facet definition references a queryHelper defined by the noun
+ // type, use that instead of the standard constraint function.
+ if ("queryHelper" in this.facetDef) {
+ constraintFunc =
+ query[this.attrDef.boundName + this.facetDef.queryHelper];
+ } else {
+ constraintFunc =
+ query[
+ this.ranged
+ ? this.attrDef.boundName + "Range"
+ : this.attrDef.boundName
+ ];
+ }
+ constraintFunc.apply(query, this.groupValues);
+
+ this.invertQuery = !this.inclusive;
+ },
+ /**
+ * Adjust the constraint given the incoming faceting constraint desired.
+ * Mainly, if the inclusive flag is the same as what we already have, we
+ * just append the new values to the existing set of values. If it is not
+ * the same, we replace them.
+ *
+ * @returns true if the caller needs to revalidate their understanding of the
+ * constraint because we have flipped whether we are inclusive or
+ * exclusive and have thrown away some constraints as a result.
+ */
+ constrain(aInclusive, aGroupValues) {
+ if (aInclusive == this.inclusive) {
+ this.groupValues = this.groupValues.concat(aGroupValues);
+ this._makeQuery();
+ return false;
+ }
+
+ let needToRevalidate = this.inclusive != null;
+ this.inclusive = aInclusive;
+ this.groupValues = aGroupValues;
+ this._makeQuery();
+
+ return needToRevalidate;
+ },
+ /**
+ * Relax something we previously constrained. Remove it, some might say. It
+ * is possible after relaxing that we will no longer be an active constraint.
+ *
+ * @returns true if we are no longer constrained at all.
+ */
+ relax(aInclusive, aGroupValues) {
+ if (aInclusive != this.inclusive) {
+ throw new Error("You can't relax a constraint that isn't possible.");
+ }
+
+ for (let groupValue of aGroupValues) {
+ let index = this.groupValues.indexOf(groupValue);
+ if (index == -1) {
+ throw new Error("Tried to relax a constraint that was not in force.");
+ }
+ this.groupValues.splice(index, 1);
+ }
+ if (this.groupValues.length == 0) {
+ this.clear();
+ return true;
+ }
+ this._makeQuery();
+
+ return false;
+ },
+ /**
+ * Indicate whether this constraint is actually doing anything anymore.
+ */
+ get isConstrained() {
+ return this.inclusive != null;
+ },
+ /**
+ * Clear the constraint so that the next call to adjust initializes it.
+ */
+ clear() {
+ this.inclusive = null;
+ this.groupValues = null;
+ this.query = null;
+ this.invertQuery = null;
+ },
+ /**
+ * Filter the items against our constraint.
+ */
+ sieve(aItems) {
+ let query = this.query;
+ let expectedResult = !this.invertQuery;
+ return aItems.filter(item => query.test(item) == expectedResult);
+ },
+ isIncludedGroup(aGroupValue) {
+ if (!this.inclusive) {
+ return false;
+ }
+ return this.groupValues.includes(aGroupValue);
+ },
+ isExcludedGroup(aGroupValue) {
+ if (this.inclusive) {
+ return false;
+ }
+ return this.groupValues.includes(aGroupValue);
+ },
+};
+
+function ActiveNonSingularConstraint(aFaceter, aRanged) {
+ this.faceter = aFaceter;
+ this.attrDef = aFaceter.attrDef;
+ this.facetDef = aFaceter.facetDef;
+ this.ranged = Boolean(aRanged);
+
+ this.clear();
+}
+ActiveNonSingularConstraint.prototype = {
+ _makeQuery(aInclusive, aGroupValues) {
+ // have the faceter make the query and the invert decision for us if it
+ // implements the makeQuery method.
+ if ("makeQuery" in this.faceter) {
+ // returns [query, invertQuery] directly
+ return this.faceter.makeQuery(aGroupValues, aInclusive);
+ }
+
+ let query = Gloda.newQuery(GlodaConstants.NOUN_MESSAGE);
+ let constraintFunc;
+ // If the facet definition references a queryHelper defined by the noun
+ // type, use that instead of the standard constraint function.
+ if ("queryHelper" in this.facetDef) {
+ constraintFunc =
+ query[this.attrDef.boundName + this.facetDef.queryHelper];
+ } else {
+ constraintFunc =
+ query[
+ this.ranged
+ ? this.attrDef.boundName + "Range"
+ : this.attrDef.boundName
+ ];
+ }
+ constraintFunc.apply(query, aGroupValues);
+
+ return [query, false];
+ },
+
+ /**
+ * Adjust the constraint given the incoming faceting constraint desired.
+ * Mainly, if the inclusive flag is the same as what we already have, we
+ * just append the new values to the existing set of values. If it is not
+ * the same, we replace them.
+ */
+ constrain(aInclusive, aGroupValues) {
+ let groupIdAttr = this.attrDef.objectNounDef.isPrimitive
+ ? null
+ : this.facetDef.groupIdAttr;
+ let idMap = aInclusive ? this.includedGroupIds : this.excludedGroupIds;
+ let valList = aInclusive
+ ? this.includedGroupValues
+ : this.excludedGroupValues;
+ for (let groupValue of aGroupValues) {
+ let valId =
+ groupIdAttr !== null && groupValue != null
+ ? groupValue[groupIdAttr]
+ : groupValue;
+ idMap[valId] = true;
+ valList.push(groupValue);
+ }
+
+ let [query, invertQuery] = this._makeQuery(aInclusive, valList);
+ if (aInclusive && !invertQuery) {
+ this.includeQuery = query;
+ } else {
+ this.excludeQuery = query;
+ }
+
+ return false;
+ },
+ /**
+ * Relax something we previously constrained. Remove it, some might say. It
+ * is possible after relaxing that we will no longer be an active constraint.
+ *
+ * @returns true if we are no longer constrained at all.
+ */
+ relax(aInclusive, aGroupValues) {
+ let groupIdAttr = this.attrDef.objectNounDef.isPrimitive
+ ? null
+ : this.facetDef.groupIdAttr;
+ let idMap = aInclusive ? this.includedGroupIds : this.excludedGroupIds;
+ let valList = aInclusive
+ ? this.includedGroupValues
+ : this.excludedGroupValues;
+ for (let groupValue of aGroupValues) {
+ let valId =
+ groupIdAttr !== null && groupValue != null
+ ? groupValue[groupIdAttr]
+ : groupValue;
+ if (!(valId in idMap)) {
+ throw new Error("Tried to relax a constraint that was not in force.");
+ }
+ delete idMap[valId];
+
+ let index = valList.indexOf(groupValue);
+ valList.splice(index, 1);
+ }
+
+ if (valList.length == 0) {
+ if (aInclusive) {
+ this.includeQuery = null;
+ } else {
+ this.excludeQuery = null;
+ }
+ } else {
+ let [query, invertQuery] = this._makeQuery(aInclusive, valList);
+ if (aInclusive && !invertQuery) {
+ this.includeQuery = query;
+ } else {
+ this.excludeQuery = query;
+ }
+ }
+
+ return this.includeQuery == null && this.excludeQuery == null;
+ },
+ /**
+ * Indicate whether this constraint is actually doing anything anymore.
+ */
+ get isConstrained() {
+ return this.includeQuery == null && this.excludeQuery == null;
+ },
+ /**
+ * Clear the constraint so that the next call to adjust initializes it.
+ */
+ clear() {
+ this.includeQuery = null;
+ this.includedGroupIds = {};
+ this.includedGroupValues = [];
+
+ this.excludeQuery = null;
+ this.excludedGroupIds = {};
+ this.excludedGroupValues = [];
+ },
+ /**
+ * Filter the items against our constraint.
+ */
+ sieve(aItems) {
+ let includeQuery = this.includeQuery;
+ let excludeQuery = this.excludeQuery;
+ return aItems.filter(
+ item =>
+ (!includeQuery || includeQuery.test(item)) &&
+ (!excludeQuery || !excludeQuery.test(item))
+ );
+ },
+ isIncludedGroup(aGroupValue) {
+ let valId = aGroupValue[this.facetDef.groupIdAttr];
+ return valId in this.includedGroupIds;
+ },
+ isExcludedGroup(aGroupValue) {
+ let valId = aGroupValue[this.facetDef.groupIdAttr];
+ return valId in this.excludedGroupIds;
+ },
+};
+
+var FacetContext = {
+ facetDriver: new FacetDriver(Gloda.lookupNounDef("message"), window),
+
+ /**
+ * The root collection which our active set is a subset of. We hold onto this
+ * for garbage collection reasons, although the tab that owns us should also
+ * be holding on.
+ */
+ _collection: null,
+ set collection(aCollection) {
+ this._collection = aCollection;
+ },
+ get collection() {
+ return this._collection;
+ },
+
+ _sortBy: null,
+ get sortBy() {
+ return this._sortBy;
+ },
+ set sortBy(val) {
+ try {
+ if (val == this._sortBy) {
+ return;
+ }
+ this._sortBy = val;
+ this.build(this._sieveAll());
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ /**
+ * List of the current working set
+ */
+ _activeSet: null,
+ get activeSet() {
+ return this._activeSet;
+ },
+
+ /**
+ * fullSet is a special attribute which is passed a set of items that we're
+ * displaying, but the order of which is determined by the sortBy property.
+ * On setting the fullSet, we compute both sorted lists, and then on getting,
+ * we return the appropriate one.
+ */
+ get fullSet() {
+ return this._sortBy == "-dascore"
+ ? this._relevantSortedItems
+ : this._dateSortedItems;
+ },
+
+ set fullSet(items) {
+ let scores;
+ if (this.searcher && this.searcher.scores) {
+ scores = this.searcher.scores;
+ } else {
+ scores = Gloda.scoreNounItems(items);
+ }
+ let scoredItems = items.map(function (item, index) {
+ return [scores[index], item];
+ });
+ scoredItems.sort((a, b) => b[0] - a[0]);
+ this._relevantSortedItems = scoredItems.map(scoredItem => scoredItem[1]);
+
+ this._dateSortedItems = this._relevantSortedItems
+ .concat()
+ .sort((a, b) => b.date - a.date);
+ },
+
+ initialBuild() {
+ if (this.searcher) {
+ QueryExplanation.setFulltext(this.searcher);
+ } else {
+ QueryExplanation.setQuery(this.collection.query);
+ }
+ // we like to sort them so should clone the list
+ this.faceters = this.facetDriver.faceters.concat();
+
+ this._timelineShown = !Services.prefs.getBoolPref(
+ "gloda.facetview.hidetimeline"
+ );
+
+ this.everFaceted = false;
+ this._activeConstraints = {};
+ if (this.searcher) {
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+ this._sortBy = sortByPref == 0 || sortByPref == 2 ? "-dascore" : "-date";
+ } else {
+ this._sortBy = "-date";
+ }
+ this.fullSet = this._removeDupes(this._collection.items.concat());
+ if ("IMCollection" in this) {
+ this.fullSet = this.fullSet.concat(this.IMCollection.items);
+ }
+ this.build(this.fullSet);
+ },
+
+ /**
+ * Remove duplicate messages from search results.
+ *
+ * @param aItems the initial set of messages to deduplicate
+ * @returns the subset of those, with duplicates removed.
+ *
+ * Some IMAP servers (here's looking at you, Gmail) will create message
+ * duplicates unbeknownst to the user. We'd like to deal with them earlier
+ * in the pipeline, but that's a bit hard right now. So as a workaround
+ * we'd rather not show them in the Search Results UI. The simplest way
+ * of doing that is just to cull (from the display) messages with have the
+ * Message-ID of a message already displayed.
+ */
+ _removeDupes(aItems) {
+ let deduped = [];
+ let msgIdsSeen = {};
+ for (let item of aItems) {
+ if (item.headerMessageID in msgIdsSeen) {
+ continue;
+ }
+ deduped.push(item);
+ msgIdsSeen[item.headerMessageID] = true;
+ }
+ return deduped;
+ },
+
+ /**
+ * Kick-off a new faceting pass.
+ *
+ * @param aNewSet the set of items to facet.
+ * @param aCallback the callback to invoke when faceting is completed.
+ */
+ build(aNewSet, aCallback) {
+ this._activeSet = aNewSet;
+ this._callbackOnFacetComplete = aCallback;
+ this.facetDriver.go(this._activeSet, this.facetingCompleted, this);
+ },
+
+ /**
+ * Attempt to figure out a reasonable number of rows to limit each facet to
+ * display. While the number will ordinarily be dominated by the maximum
+ * number of rows we believe the user can easily scan, this may also be
+ * impacted by layout concerns (since we want to avoid scrolling).
+ */
+ planLayout() {
+ // XXX arbitrary!
+ this.maxDisplayRows = 8;
+ this.maxMessagesToShow = 10;
+ },
+
+ /**
+ * Clean up the UI in preparation for a new query to come in.
+ */
+ _resetUI() {
+ for (let faceter of this.faceters) {
+ if (faceter.xblNode && !faceter.xblNode.explicit) {
+ faceter.xblNode.remove();
+ }
+ faceter.xblNode = null;
+ faceter.constraint = null;
+ }
+ },
+
+ _groupCountComparator(a, b) {
+ return b.groupCount - a.groupCount;
+ },
+ /**
+ * Tells the UI about all the facets when notified by the |facetDriver| when
+ * it is done faceting everything.
+ */
+ facetingCompleted() {
+ this.planLayout();
+
+ if (!this.everFaceted) {
+ this.everFaceted = true;
+ this.faceters.sort(this._groupCountComparator);
+ for (let faceter of this.faceters) {
+ let attrName = faceter.attrDef.attributeName;
+ let explicitBinding = document.getElementById("facet-" + attrName);
+
+ if (explicitBinding) {
+ explicitBinding.explicit = true;
+ explicitBinding.faceter = faceter;
+ explicitBinding.attrDef = faceter.attrDef;
+ explicitBinding.facetDef = faceter.facetDef;
+ explicitBinding.nounDef = faceter.attrDef.objectNounDef;
+ explicitBinding.orderedGroups = faceter.orderedGroups;
+ // explicit booleans should always be displayed for consistency
+ if (
+ faceter.groupCount >= 1 ||
+ explicitBinding.getAttribute("type").includes("boolean")
+ ) {
+ try {
+ explicitBinding.build(true);
+ } catch (e) {
+ console.error(e);
+ }
+ explicitBinding.removeAttribute("uninitialized");
+ }
+ faceter.xblNode = explicitBinding;
+ continue;
+ }
+
+ // ignore facets that do not vary!
+ if (faceter.groupCount <= 1) {
+ faceter.xblNode = null;
+ continue;
+ }
+
+ faceter.xblNode = UIFacets.addFacet(faceter.type, faceter.attrDef, {
+ faceter,
+ facetDef: faceter.facetDef,
+ orderedGroups: faceter.orderedGroups,
+ maxDisplayRows: this.maxDisplayRows,
+ explicit: false,
+ });
+ }
+ } else {
+ for (let faceter of this.faceters) {
+ // Do not bother with un-displayed facets, or that are locked by a
+ // constraint. But do bother if the widget can be updated without
+ // losing important data.
+ if (
+ !faceter.xblNode ||
+ (faceter.constraint && !faceter.xblNode.canUpdate)
+ ) {
+ continue;
+ }
+
+ // hide things that have 0/1 groups now and are not constrained and not
+ // explicit
+ if (
+ faceter.groupCount <= 1 &&
+ !faceter.constraint &&
+ (!faceter.xblNode.explicit || faceter.type == "date")
+ ) {
+ faceter.xblNode.style.display = "none";
+ } else {
+ // otherwise, update
+ faceter.xblNode.orderedGroups = faceter.orderedGroups;
+ faceter.xblNode.build(false);
+ faceter.xblNode.removeAttribute("style");
+ }
+ }
+ }
+
+ if (!this._timelineShown) {
+ this._hideTimeline(true);
+ }
+
+ this._showResults();
+
+ if (this._callbackOnFacetComplete) {
+ let callback = this._callbackOnFacetComplete;
+ this._callbackOnFacetComplete = null;
+ callback();
+ }
+ },
+
+ _showResults() {
+ let results = document.getElementById("results");
+ let numMessageToShow = Math.min(
+ this.maxMessagesToShow * this._numPages,
+ this._activeSet.length
+ );
+ results.setMessages(this._activeSet.slice(0, numMessageToShow));
+
+ let showLoading = document.getElementById("showLoading");
+ showLoading.style.display = "none"; // Hide spinner, we're done thinking.
+
+ let showEmpty = document.getElementById("showEmpty");
+ let showAll = document.getElementById("gloda-showall");
+ // Check for no messages at all.
+ if (this._activeSet.length == 0) {
+ showEmpty.style.display = "block";
+ showAll.style.display = "none";
+ } else {
+ showEmpty.style.display = "none";
+ showAll.style.display = "block";
+ }
+
+ let showMore = document.getElementById("showMore");
+ showMore.style.display =
+ this._activeSet.length > numMessageToShow ? "block" : "none";
+ },
+
+ showMore() {
+ this._numPages += 1;
+ this._showResults();
+ },
+
+ zoomOut() {
+ let facetDate = document.getElementById("facet-date");
+ this.removeFacetConstraint(
+ facetDate.faceter,
+ true,
+ facetDate.vis.constraints
+ );
+ facetDate.setAttribute("zoomedout", "true");
+ },
+
+ toggleTimeline() {
+ try {
+ this._timelineShown = !this._timelineShown;
+ if (this._timelineShown) {
+ this._showTimeline();
+ } else {
+ this._hideTimeline(false);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ _showTimeline() {
+ let facetDate = document.getElementById("facet-date");
+ if (facetDate.style.display == "none") {
+ facetDate.style.display = "inherit";
+ // Force binding attachment so the transition to the
+ // visible state actually happens.
+ facetDate.getBoundingClientRect();
+ }
+ let listener = () => {
+ // Need to set overflow to visible so that the zoom button
+ // is not cut off at the top, and overflow=hidden causes
+ // the transition to not work as intended.
+ facetDate.removeAttribute("style");
+ };
+ facetDate.addEventListener("transitionend", listener, { once: true });
+ facetDate.removeAttribute("hide");
+ document.getElementById("date-toggle").setAttribute("checked", "true");
+ Services.prefs.setBoolPref("gloda.facetview.hidetimeline", false);
+ },
+
+ _hideTimeline(immediate) {
+ let facetDate = document.getElementById("facet-date");
+ if (immediate) {
+ facetDate.style.display = "none";
+ }
+ facetDate.style.overflow = "hidden";
+ facetDate.setAttribute("hide", "true");
+ document.getElementById("date-toggle").removeAttribute("checked");
+ Services.prefs.setBoolPref("gloda.facetview.hidetimeline", true);
+ },
+
+ _timelineShown: true,
+
+ /** For use in hovering specific results. */
+ fakeResultFaceter: {},
+ /** For use in hovering specific results. */
+ fakeResultAttr: {},
+
+ _numPages: 1,
+ _HOVER_STABILITY_DURATION_MS: 100,
+ _brushedFacet: null,
+ _brushedGroup: null,
+ _brushedItems: null,
+ _brushTimeout: null,
+ hoverFacet(aFaceter, aAttrDef, aGroupValue, aGroupItems) {
+ // bail if we are already brushing this item
+ if (this._brushedFacet == aFaceter && this._brushedGroup == aGroupValue) {
+ return;
+ }
+
+ this._brushedFacet = aFaceter;
+ this._brushedGroup = aGroupValue;
+ this._brushedItems = aGroupItems;
+
+ if (this._brushTimeout != null) {
+ clearTimeout(this._brushTimeout);
+ }
+ this._brushTimeout = setTimeout(
+ this._timeoutHoverWrapper,
+ this._HOVER_STABILITY_DURATION_MS,
+ this
+ );
+ },
+ _timeoutHover() {
+ this._brushTimeout = null;
+ for (let faceter of this.faceters) {
+ if (faceter == this._brushedFacet || !faceter.xblNode) {
+ continue;
+ }
+
+ if (this._brushedItems != null) {
+ faceter.xblNode.brushItems(this._brushedItems);
+ } else {
+ faceter.xblNode.clearBrushedItems();
+ }
+ }
+ },
+ _timeoutHoverWrapper(aThis) {
+ aThis._timeoutHover();
+ },
+ unhoverFacet(aFaceter, aAttrDef, aGroupValue, aGroupItems) {
+ // have we already brushed from some other source already? ignore then.
+ if (this._brushedFacet != aFaceter || this._brushedGroup != aGroupValue) {
+ return;
+ }
+
+ // reuse hover facet to null everyone out
+ this.hoverFacet(null, null, null, null);
+ },
+
+ /**
+ * Maps attribute names to their corresponding |ActiveConstraint|, if they
+ * have one.
+ */
+ _activeConstraints: null,
+ /**
+ * Called by facet bindings when the user does some clicking and wants to
+ * impose a new constraint.
+ *
+ * @param aFaceter The faceter that is the source of this constraint. We
+ * need to know this because once a facet has a constraint attached,
+ * the UI stops updating it.
+ * @param {boolean} aInclusive Is this an inclusive (true) or exclusive
+ * (false) constraint? The constraint instance is the one that deals with
+ * the nuances resulting from this.
+ * @param aGroupValues A list of the group values this constraint covers. In
+ * general, we expect that only one group value will be present in the
+ * list since this method should get called each time the user clicks
+ * something. Previously, we provided support for an "other" case which
+ * covered multiple groupValues so a single click needed to be able to
+ * pass in a list. The "other" case is gone now, but semantically it's
+ * okay for us to support a list.
+ * @param [aRanged] Is it a ranged constraint? (Currently only for dates)
+ * @param [aNukeExisting] Do we need to replace the existing constraint and
+ * re-sieve everything? This currently only happens for dates, where
+ * our display allows a click to actually make our range more generic
+ * than it currently is. (But this only matters if we already have
+ * a date constraint applied.)
+ * @param [aCallback] The callback to call once (re-)faceting has completed.
+ *
+ * @returns true if the caller needs to revalidate because the constraint has
+ * changed in a way other than explicitly requested. This can occur if
+ * a singular constraint flips its inclusive state and throws away
+ * constraints.
+ */
+ addFacetConstraint(
+ aFaceter,
+ aInclusive,
+ aGroupValues,
+ aRanged,
+ aNukeExisting,
+ aCallback
+ ) {
+ let attrName = aFaceter.attrDef.attributeName;
+
+ let constraint;
+ let needToSieveAll = false;
+ if (attrName in this._activeConstraints) {
+ constraint = this._activeConstraints[attrName];
+
+ needToSieveAll = true;
+ if (aNukeExisting) {
+ constraint.clear();
+ }
+ } else {
+ let constraintClass = aFaceter.attrDef.singular
+ ? ActiveSingularConstraint
+ : ActiveNonSingularConstraint;
+ constraint = this._activeConstraints[attrName] = new constraintClass(
+ aFaceter,
+ aRanged
+ );
+ aFaceter.constraint = constraint;
+ }
+ let needToRevalidate = constraint.constrain(aInclusive, aGroupValues);
+
+ // Given our current implementation, we can only be further constraining our
+ // active set, so we can just sieve the existing active set with the
+ // (potentially updated) constraint. In some cases, it would be much
+ // cheaper to use the facet's knowledge about the items in the groups, but
+ // for now let's keep a single code-path for how we refine the active set.
+ this.build(
+ needToSieveAll ? this._sieveAll() : constraint.sieve(this.activeSet),
+ aCallback
+ );
+
+ return needToRevalidate;
+ },
+
+ /**
+ * Remove a constraint previously imposed by addFacetConstraint. The
+ * constraint must still be active, which means you need to pay attention
+ * when |addFacetConstraint| returns true indicating that you need to
+ * revalidate.
+ *
+ * @param aFaceter
+ * @param aInclusive Whether the group values were previously included /
+ * excluded. If you want to remove some values that were included and
+ * some that were excluded then you need to call us once for each case.
+ * @param aGroupValues The list of group values to remove.
+ * @param aCallback The callback to call once all facets have been updated.
+ *
+ * @returns true if the constraint has been completely removed. Under the
+ * current regime, this will likely cause the binding that is calling us
+ * to be rebuilt, so be aware if you are trying to do any cool animation
+ * that might no longer make sense.
+ */
+ removeFacetConstraint(aFaceter, aInclusive, aGroupValues, aCallback) {
+ let attrName = aFaceter.attrDef.attributeName;
+ let constraint = this._activeConstraints[attrName];
+
+ let constraintGone = false;
+
+ if (constraint.relax(aInclusive, aGroupValues)) {
+ delete this._activeConstraints[attrName];
+ aFaceter.constraint = null;
+ constraintGone = true;
+ }
+
+ // we definitely need to re-sieve everybody in this case...
+ this.build(this._sieveAll(), aCallback);
+
+ return constraintGone;
+ },
+
+ /**
+ * Sieve the items from the underlying collection against all constraints,
+ * returning the value.
+ */
+ _sieveAll() {
+ let items = this.fullSet;
+
+ for (let elem in this._activeConstraints) {
+ items = this._activeConstraints[elem].sieve(items);
+ }
+
+ return items;
+ },
+
+ toggleFulltextCriteria() {
+ this.tab.searcher.andTerms = !this.tab.searcher.andTerms;
+ this._resetUI();
+ this.collection = this.tab.searcher.getCollection(this);
+ },
+
+ /**
+ * Show the active message set in a 3-pane tab.
+ */
+ showActiveSetInTab() {
+ let tabmail = this.rootWin.document.getElementById("tabmail");
+ tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ collection: Gloda.explicitCollection(
+ GlodaConstants.NOUN_MESSAGE,
+ this.activeSet
+ ),
+ }),
+ title: this.tab.title,
+ });
+ },
+
+ /**
+ * Show the conversation in a new 3-pane tab.
+ *
+ * @param {glodaFacetBindings.xml#result-message} aResultMessage The
+ * result the user wants to see in more details.
+ * @param {boolean} [aBackground] Whether it should be in the background.
+ */
+ showConversationInTab(aResultMessage, aBackground) {
+ let tabmail = this.rootWin.document.getElementById("tabmail");
+ let message = aResultMessage.message;
+ if (
+ "IMCollection" in this &&
+ message instanceof Gloda.lookupNounDef("im-conversation").clazz
+ ) {
+ tabmail.openTab("chat", {
+ convType: "log",
+ conv: message,
+ searchTerm: aResultMessage.firstMatchText,
+ background: aBackground,
+ });
+ return;
+ }
+ tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ conversation: message.conversation,
+ message,
+ }),
+ title: message.conversation.subject,
+ background: aBackground,
+ });
+ },
+
+ onItemsAdded(aItems, aCollection) {},
+ onItemsModified(aItems, aCollection) {},
+ onItemsRemoved(aItems, aCollection) {},
+ onQueryCompleted(aCollection) {
+ if (
+ this.tab.query.completed &&
+ (!("IMQuery" in this.tab) || this.tab.IMQuery.completed)
+ ) {
+ this.initialBuild();
+ }
+ },
+};
+
+/**
+ * addEventListener betrayals compel us to establish our link with the
+ * outside world from inside. NeilAway suggests the problem might have
+ * been the registration of the listener prior to initiating the load. Which
+ * is odd considering it works for the XUL case, but I could see how that might
+ * differ. Anywho, this works for now and is a delightful reference to boot.
+ */
+function reachOutAndTouchFrame() {
+ let us = window
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShellTreeItem);
+
+ FacetContext.rootWin = us.rootTreeItem.domWindow;
+
+ let parentWin = us.parent.domWindow;
+ let aTab = (FacetContext.tab = parentWin.tab);
+ parentWin.tab = null;
+ window.addEventListener("resize", function () {
+ document.getElementById("facet-date").build(true);
+ });
+ // we need to hook the context up as a listener in all cases since
+ // removal notifications are required.
+ if ("searcher" in aTab) {
+ FacetContext.searcher = aTab.searcher;
+ aTab.searcher.listener = FacetContext;
+ if ("IMSearcher" in aTab) {
+ FacetContext.IMSearcher = aTab.IMSearcher;
+ aTab.IMSearcher.listener = FacetContext;
+ }
+ } else {
+ FacetContext.searcher = null;
+ aTab.collection.listener = FacetContext;
+ }
+ FacetContext.collection = aTab.collection;
+ if ("IMCollection" in aTab) {
+ FacetContext.IMCollection = aTab.IMCollection;
+ }
+
+ // if it has already completed, we need to prod things
+ if (
+ aTab.query.completed &&
+ (!("IMQuery" in aTab) || aTab.IMQuery.completed)
+ ) {
+ FacetContext.initialBuild();
+ }
+}
+
+function clickOnBody(event) {
+ if (event.bubbles) {
+ document.querySelector("facet-popup-menu").hide();
+ }
+ return 0;
+}
diff --git a/comm/mail/base/content/glodaFacetView.xhtml b/comm/mail/base/content/glodaFacetView.xhtml
new file mode 100644
index 0000000000..2b53355e0e
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetView.xhtml
@@ -0,0 +1,123 @@
+<?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/. -->
+
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd" [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % facetViewDTD SYSTEM "chrome://messenger/locale/glodaFacetView.dtd">
+%facetViewDTD; ]>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+>
+ <head>
+ <!-- Themes -->
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/glodaFacetView.css"
+ type="text/css"
+ />
+ <!-- Custom elements -->
+ <script src="chrome://messenger/content/glodaFacet.js"></script>
+ <!-- Global Context -->
+ <script src="chrome://messenger/content/glodaFacetView.js"></script>
+ <!-- Libs -->
+ <script src="chrome://messenger/content/protovis-r2.6-modded.js"></script>
+ <!-- Facet Binding Stuff that doesn't belong in XBL -->
+ <script src="chrome://messenger/content/glodaFacetVis.js"></script>
+ </head>
+ <body
+ id="body"
+ onload="reachOutAndTouchFrame()"
+ onmouseup="return clickOnBody(event)"
+ >
+ <facet-popup-menu class="popup-menu" variety="invisible" />
+ <div id="gloda-facet-view">
+ <div class="facets facets-sidebar" id="facets">
+ <h1 id="filter-header-label">&glodaFacetView.filters.label;</h1>
+ <div>
+ <facet-boolean
+ id="facet-fromMe"
+ type="boolean"
+ attr="fromMe"
+ uninitialized="true"
+ />
+ <facet-boolean
+ id="facet-toMe"
+ type="boolean"
+ attr="toMe"
+ uninitialized="true"
+ />
+ <facet-boolean
+ id="facet-star"
+ type="boolean"
+ attr="star"
+ uninitialized="true"
+ /><br />
+ <facet-boolean-filtered
+ id="facet-attachmentTypes"
+ type="boolean-filtered"
+ attr="attachmentTypes"
+ groupDisplayProperty="categoryLabel"
+ uninitialized="true"
+ />
+ </div>
+ </div>
+
+ <div id="main-column">
+ <div id="header">
+ <div id="query-explanation" />
+ <a
+ id="gloda-showall"
+ class="results-message-showall-button"
+ title="&glodaFacetView.openEmailAsList.tooltip;"
+ onclick="FacetContext.showActiveSetInTab();"
+ onkeypress="if (event.charCode == KeyEvent.DOM_VK_SPACE) { FacetContext.showActiveSetInTab(); event.preventDefault(); }"
+ tabindex="0"
+ >
+ &glodaFacetView.openEmailAsList.label;
+ </a>
+ </div>
+ <div id="data-column">
+ <!-- facet-results-message is put before facet-date here so that it gets upgraded first.
+ facet-date uses width of facet-results-message for the visualization. Using order property
+ we can show facet-date before facet-results-message -->
+ <facet-results-message id="results" class="results" />
+ <facet-date id="facet-date" class="facetious" type="date" />
+ <div class="loading" id="showLoading">
+ <span class="loading">
+ <img
+ class="loading"
+ src="chrome://global/skin/icons/loading.png"
+ alt=""
+ />
+ &glodaFacetView.loading.label;
+ </span>
+ </div>
+ <div id="showEmpty" class="empty">
+ <span class="empty">
+ <img
+ class="empty"
+ src="chrome://messenger/skin/icons/empty-search-results.svg"
+ alt=""
+ />
+ <br />
+ &glodaFacetView.empty.label;
+ </span>
+ </div>
+ <button
+ id="showMore"
+ class="show-more"
+ tabindex="0"
+ onclick="FacetContext.showMore()"
+ onkeypress="if (event.charCode == KeyEvent.DOM_VK_SPACE) { FacetContext.showMore(); event.preventDefault() }"
+ >
+ &glodaFacetView.pageMore.label;
+ </button>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/base/content/glodaFacetViewWrapper.xhtml b/comm/mail/base/content/glodaFacetViewWrapper.xhtml
new file mode 100644
index 0000000000..8b2ea1e8bc
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetViewWrapper.xhtml
@@ -0,0 +1,54 @@
+<?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" type="text/css"?>
+<window
+ id="window"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <script src="chrome://messenger/content/viewZoomOverlay.js" />
+ <script>
+ <![CDATA[
+ function getBrowser() {
+ return document.getElementById('browser');
+ }
+ ]]>
+ </script>
+ <commandset id="selectEditMenuItems">
+ <command id="cmd_fullZoomReduce" oncommand="ZoomManager.reduce();" />
+ <command id="cmd_fullZoomEnlarge" oncommand="ZoomManager.enlarge();" />
+ <command id="cmd_fullZoomReset" oncommand="ZoomManager.reset();" />
+ </commandset>
+ <keyset>
+ <!--move to locale-->
+ <key
+ id="key_fullZoomEnlarge"
+ key="+"
+ command="cmd_fullZoomEnlarge"
+ modifiers="accel"
+ />
+ <key
+ id="key_fullZoomEnlarge2"
+ key="="
+ command="cmd_fullZoomEnlarge"
+ modifiers="accel"
+ />
+ <key
+ id="key_fullZoomReduce"
+ key="-"
+ command="cmd_fullZoomReduce"
+ modifiers="accel"
+ />
+ <key
+ id="key_fullZoomReset"
+ key="0"
+ command="cmd_fullZoomReset"
+ modifiers="accel"
+ />
+ </keyset>
+ <tooltip id="aHTMLTooltip" page="true" />
+ <browser id="browser" flex="1" disablehistory="true" tooltip="aHTMLTooltip" />
+</window>
diff --git a/comm/mail/base/content/glodaFacetVis.js b/comm/mail/base/content/glodaFacetVis.js
new file mode 100644
index 0000000000..0060f67d92
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetVis.js
@@ -0,0 +1,428 @@
+/* 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/. */
+
+/*
+ * Facet visualizations that would be awkward in XBL. Allegedly because the
+ * interaciton idiom of a protovis-based visualization is entirely different
+ * from XBL, but also a lot because of the lack of good syntax highlighting.
+ */
+
+/* import-globals-from glodaFacetView.js */
+/* import-globals-from protovis-r2.6-modded.js */
+
+/**
+ * A date facet visualization abstraction.
+ */
+function DateFacetVis(aBinding, aCanvasNode) {
+ this.binding = aBinding;
+ this.canvasNode = aCanvasNode;
+
+ this.faceter = aBinding.faceter;
+ this.attrDef = this.faceter.attrDef;
+}
+DateFacetVis.prototype = {
+ build() {
+ let resultsBarRect = document
+ .getElementById("results")
+ .getBoundingClientRect();
+ this.allowedSpace = resultsBarRect.right - resultsBarRect.left;
+ this.render();
+ },
+ rebuild() {
+ this.render();
+ },
+
+ _MIN_BAR_SIZE_PX: 9,
+ _BAR_SPACING_PX: 1,
+
+ _MAX_BAR_SIZE_PX: 44,
+
+ _AXIS_FONT: "10px sans-serif",
+ _AXIS_HEIGHT_NO_LABEL_PX: 6,
+ _AXIS_HEIGHT_WITH_LABEL_PX: 14,
+ _AXIS_VERT_SPACING_PX: 1,
+ _AXIS_HORIZ_MIN_SPACING_PX: 4,
+
+ _MAX_DAY_COUNT_LABEL_DISPLAY: 10,
+
+ /**
+ * Figure out how to chunk things given the linear space in pixels. In an
+ * ideal world we would not use pixels, avoiding tying ourselves to assumed
+ * pixel densities, but we do not live there. Reality wants crisp graphics
+ * and does not have enough pixels that you can ignore the pixel coordinate
+ * space and have things still look sharp (and good).
+ *
+ * Because of our love of sharpness, we will potentially under-use the space
+ * allocated to us.
+ *
+ * @param aPixels The number of linear content pixels we have to work with.
+ * You are in charge of the borders and such, so you subtract that off
+ * before you pass it in.
+ * @returns An object with attributes:
+ */
+ makeIdealScaleGivenSpace(aPixels) {
+ let facet = this.faceter;
+ // build a scale and have it grow the edges based on the span
+ let scale = pv.Scales.dateTime(facet.oldest, facet.newest);
+
+ const Span = pv.Scales.DateTimeScale.Span;
+ const MS_MIN = 60 * 1000,
+ MS_HOUR = 60 * MS_MIN,
+ MS_DAY = 24 * MS_HOUR,
+ MS_WEEK = 7 * MS_DAY,
+ MS_MONTHISH = 31 * MS_DAY,
+ MS_YEARISH = 366 * MS_DAY;
+ const roughMap = {};
+ roughMap[Span.DAYS] = MS_DAY;
+ roughMap[Span.WEEKS] = MS_WEEK;
+ // we overestimate since we want to slightly underestimate pixel usage
+ // in enoughPix's rough estimate
+ roughMap[Span.MONTHS] = MS_MONTHISH;
+ roughMap[Span.YEARS] = MS_YEARISH;
+
+ const minBarPix = this._MIN_BAR_SIZE_PX + this._BAR_SPACING_PX;
+
+ let delta = facet.newest.valueOf() - facet.oldest.valueOf();
+ let span, rules, barPixBudget;
+ // evil side-effect land
+ function enoughPix(aSpan) {
+ span = aSpan;
+ // do a rough guestimate before doing something potentially expensive...
+ barPixBudget = Math.floor(aPixels / (delta / roughMap[span]));
+ if (barPixBudget < minBarPix + 1) {
+ return false;
+ }
+
+ rules = scale.ruleValues(span);
+ // + 0 because we want to over-estimate slightly for niceness rounding
+ // reasons
+ barPixBudget = Math.floor(aPixels / (rules.length + 0));
+ delta = scale.max().valueOf() - scale.min().valueOf();
+ return barPixBudget > minBarPix;
+ }
+
+ // day is our smallest unit
+ const ALLOWED_SPANS = [Span.DAYS, Span.WEEKS, Span.MONTHS, Span.YEARS];
+ for (let trySpan of ALLOWED_SPANS) {
+ if (enoughPix(trySpan)) {
+ // do the equivalent of nice() for our chosen span
+ scale.min(scale.round(scale.min(), trySpan, false));
+ scale.max(scale.round(scale.max(), trySpan, true));
+ // try again for paranoia, but mainly for the side-effect...
+ if (enoughPix(trySpan)) {
+ break;
+ }
+ }
+ }
+
+ // - Figure out our labeling strategy
+ // normalize the symbols into an explicit ordering
+ let spandex = ALLOWED_SPANS.indexOf(span);
+ // from least-specific to most-specific
+ let labelTiers = [];
+ // add year spans in all cases, although whether we draw bars depends on if
+ // we are in year mode or not
+ labelTiers.push({
+ rules: span == Span.YEARS ? rules : scale.ruleValues(Span.YEARS, true),
+ // We should not hit the null member of the array...
+ label: [{ year: "numeric" }, { year: "2-digit" }, null],
+ boost: span == Span.YEARS,
+ noFringe: span == Span.YEARS,
+ });
+ // add month spans if we are days or weeks...
+ if (spandex < 2) {
+ labelTiers.push({
+ rules: scale.ruleValues(Span.MONTHS, true),
+ // try to use the full month, falling back to the short month
+ label: [{ month: "long" }, { month: "short" }, null],
+ boost: false,
+ });
+ }
+ // add week spans if our granularity is days...
+ if (span == Span.DAYS) {
+ let numDays = delta / MS_DAY;
+
+ // find out how many days we are talking about and add days if it's small
+ // enough, display both the date and the day of the week
+ if (numDays <= this._MAX_DAY_COUNT_LABEL_DISPLAY) {
+ labelTiers.push({
+ rules,
+ label: [{ day: "numeric" }, null],
+ boost: true,
+ noFringe: true,
+ });
+ labelTiers.push({
+ rules,
+ label: [{ weekday: "short" }, null],
+ boost: true,
+ noFringe: true,
+ });
+ } else {
+ // show the weeks since we're at greater than a day time-scale
+ labelTiers.push({
+ rules: scale.ruleValues(Span.WEEKS, true),
+ // labeling weeks is nonsensical; no one understands ISO weeks
+ // numbers.
+ label: [null],
+ boost: false,
+ });
+ }
+ }
+
+ return { scale, span, rules, barPixBudget, labelTiers };
+ },
+
+ render() {
+ let { scale, span, rules, barPixBudget, labelTiers } =
+ this.makeIdealScaleGivenSpace(this.allowedSpace);
+
+ barPixBudget = Math.floor(barPixBudget);
+
+ let minBarPix = this._MIN_BAR_SIZE_PX + this._BAR_SPACING_PX;
+ let maxBarPix = this._MAX_BAR_SIZE_PX + this._BAR_SPACING_PX;
+
+ let barPix = Math.max(minBarPix, Math.min(maxBarPix, barPixBudget));
+ let width = barPix * (rules.length - 1);
+
+ let totalAxisLabelHeight = 0;
+ let isRTL = window.getComputedStyle(this.binding).direction == "rtl";
+
+ // we need to do some font-metric calculations, so create a canvas...
+ let fontMetricCanvas = document.createElement("canvas");
+ let ctx = fontMetricCanvas.getContext("2d");
+
+ // do the labeling logic,
+ for (let labelTier of labelTiers) {
+ let labelRules = labelTier.rules;
+ let perLabelBudget = width / (labelRules.length - 1);
+ for (let labelFormat of labelTier.label) {
+ let maxWidth = 0;
+ let displayValues = [];
+ for (let iRule = 0; iRule < labelRules.length - 1; iRule++) {
+ // is this at the either edge of the display? in that case, it might
+ // be partial...
+ let fringe =
+ labelRules.length > 2 &&
+ (iRule == 0 || iRule == labelRules.length - 2);
+ let labelStartDate = labelRules[iRule];
+ let labelEndDate = labelRules[iRule + 1];
+ let labelText = labelFormat
+ ? labelStartDate.toLocaleDateString(undefined, labelFormat)
+ : null;
+ let labelStartNorm = Math.max(0, scale.normalize(labelStartDate));
+ let labelEndNorm = Math.min(1, scale.normalize(labelEndDate));
+ let labelBudget = (labelEndNorm - labelStartNorm) * width;
+ if (labelText) {
+ let labelWidth = ctx.measureText(labelText).width;
+ // discard labels at the fringe who don't fit in our budget
+ if (fringe && !labelTier.noFringe && labelWidth > labelBudget) {
+ labelText = null;
+ } else {
+ maxWidth = Math.max(labelWidth, maxWidth);
+ }
+ }
+
+ displayValues.push([
+ labelStartNorm,
+ labelEndNorm,
+ labelText,
+ labelStartDate,
+ labelEndDate,
+ ]);
+ }
+ // there needs to be space between the labels. (we may be over-padding
+ // here if there is only one label with the maximum width...)
+ maxWidth += this._AXIS_HORIZ_MIN_SPACING_PX;
+
+ if (labelTier.boost && maxWidth > perLabelBudget) {
+ // we only boost labels that are the same span as the bins, so rules
+ // === labelRules at this point. (and barPix === perLabelBudget)
+ barPix = perLabelBudget = maxWidth;
+ width = barPix * (labelRules.length - 1);
+ }
+ if (maxWidth <= perLabelBudget) {
+ labelTier.displayValues = displayValues;
+ labelTier.displayLabel = labelFormat != null;
+ labelTier.vertHeight = labelFormat
+ ? this._AXIS_HEIGHT_WITH_LABEL_PX
+ : this._AXIS_HEIGHT_NO_LABEL_PX;
+ labelTier.vertOffset = totalAxisLabelHeight;
+ totalAxisLabelHeight +=
+ labelTier.vertHeight + this._AXIS_VERT_SPACING_PX;
+
+ break;
+ }
+ }
+ }
+
+ let barWidth = barPix - this._BAR_SPACING_PX;
+
+ width = barPix * (rules.length - 1);
+ // we ideally want this to be the same size as the max rows translates to...
+ let height = 100;
+ let ch = height - totalAxisLabelHeight;
+
+ let [bins, maxBinSize] = this.binBySpan(scale, span, rules);
+
+ // build empty bins for our hot bins
+ this.emptyBins = bins.map(bin => 0);
+
+ let binScale = maxBinSize ? ch / maxBinSize : 1;
+
+ let vis = (this.vis = new pv.Panel()
+ .canvas(this.canvasNode)
+ // dimensions
+ .width(width)
+ .height(ch)
+ // margins
+ .bottom(totalAxisLabelHeight));
+
+ let faceter = this.faceter;
+ let dis = this;
+ // bin bars...
+ vis
+ .add(pv.Bar)
+ .data(bins)
+ .bottom(0)
+ .height(d => Math.floor(d.items.length * binScale))
+ .width(() => barWidth)
+ .left(function () {
+ return isRTL ? null : this.index * barPix;
+ })
+ .right(function () {
+ return isRTL ? this.index * barPix : null;
+ })
+ .fillStyle("var(--barColor)")
+ .event("mouseover", function (d) {
+ return this.fillStyle("var(--barHlColor)");
+ })
+ .event("mouseout", function (d) {
+ return this.fillStyle("var(--barColor)");
+ })
+ .event("click", function (d) {
+ dis.constraints = [[d.startDate, d.endDate]];
+ dis.binding.setAttribute("zoomedout", "false");
+ FacetContext.addFacetConstraint(
+ faceter,
+ true,
+ dis.constraints,
+ true,
+ true
+ );
+ });
+
+ this.hotBars = vis
+ .add(pv.Bar)
+ .data(this.emptyBins)
+ .bottom(0)
+ .height(d => Math.floor(d * binScale))
+ .width(() => barWidth)
+ .left(function () {
+ return this.index * barPix;
+ })
+ .fillStyle("var(--barHlColor)");
+
+ for (let labelTier of labelTiers) {
+ let labelBar = vis
+ .add(pv.Bar)
+ .data(labelTier.displayValues)
+ .bottom(-totalAxisLabelHeight + labelTier.vertOffset)
+ .height(labelTier.vertHeight)
+ .left(d => (isRTL ? null : Math.floor(width * d[0])))
+ .right(d => (isRTL ? Math.floor(width * d[0]) : null))
+ .width(d => Math.floor(width * d[1]) - Math.floor(width * d[0]) - 1)
+ .fillStyle("var(--dateColor)")
+ .event("mouseover", function (d) {
+ return this.fillStyle("var(--dateHLColor)");
+ })
+ .event("mouseout", function (d) {
+ return this.fillStyle("var(--dateColor)");
+ })
+ .event("click", function (d) {
+ dis.constraints = [[d[3], d[4]]];
+ dis.binding.setAttribute("zoomedout", "false");
+ FacetContext.addFacetConstraint(
+ faceter,
+ true,
+ dis.constraints,
+ true,
+ true
+ );
+ });
+
+ if (labelTier.displayLabel) {
+ labelBar
+ .anchor("top")
+ .add(pv.Label)
+ .font(this._AXIS_FONT)
+ .textAlign("center")
+ .textBaseline("top")
+ .textStyle("var(--dateTextColor)")
+ .text(d => d[2]);
+ }
+ }
+
+ vis.render();
+ },
+
+ hoverItems(aItems) {
+ let itemToBin = this.itemToBin;
+ let bins = this.emptyBins.concat();
+ for (let item of aItems) {
+ if (item.id in itemToBin) {
+ bins[itemToBin[item.id]]++;
+ }
+ }
+ this.hotBars.data(bins);
+ this.vis.render();
+ },
+
+ clearHover() {
+ this.hotBars.data(this.emptyBins);
+ this.vis.render();
+ },
+
+ /**
+ * Bin items at the given span granularity with the set of rules generated
+ * for the given span. This could equally as well be done as a pre-built
+ * array of buckets with a linear scan of items and a calculation of what
+ * bucket they should be placed in.
+ */
+ binBySpan(aScale, aSpan, aRules, aItems) {
+ let bins = [];
+ let maxBinSize = 0;
+ let binCount = aRules.length - 1;
+ let itemToBin = (this.itemToBin = {});
+
+ // We used to break this out by case, but that was a lot of code, and it was
+ // somewhat ridiculous. So now we just do the simple, if somewhat more
+ // expensive thing. Reviewer, feel free to thank me.
+ // We do a pass through the rules, mapping each rounded rule to a bin. We
+ // then do a pass through all of the items, rounding them down and using
+ // that to perform a lookup against the map. We could special-case the
+ // rounding, but I doubt it's worth it.
+ let binMap = {};
+ for (let iRule = 0; iRule < binCount; iRule++) {
+ let binStartDate = aRules[iRule],
+ binEndDate = aRules[iRule + 1];
+ binMap[binStartDate.valueOf().toString()] = iRule;
+ bins.push({ items: [], startDate: binStartDate, endDate: binEndDate });
+ }
+ let attrKey = this.attrDef.boundName;
+ for (let item of this.faceter.validItems) {
+ let val = item[attrKey];
+ // round it to the rule...
+ val = aScale.round(val, aSpan, false);
+ // which we can then map...
+ let itemBin = binMap[val.valueOf().toString()];
+ itemToBin[item.id] = itemBin;
+ bins[itemBin].items.push(item);
+ }
+ for (let bin of bins) {
+ maxBinSize = Math.max(bin.items.length, maxBinSize);
+ }
+
+ return [bins, maxBinSize];
+ },
+};
diff --git a/comm/mail/base/content/helpMenu.inc.xhtml b/comm/mail/base/content/helpMenu.inc.xhtml
new file mode 100644
index 0000000000..c69aa7ea6d
--- /dev/null
+++ b/comm/mail/base/content/helpMenu.inc.xhtml
@@ -0,0 +1,43 @@
+# 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/.
+
+<menu id="helpMenu"
+ data-l10n-id="menu-help-help-title"
+ onpopupshowing="buildHelpMenu();">
+ <menupopup id="menu_HelpPopup">
+ <menuitem id="menu_openHelp"
+ data-l10n-id="menu-help-get-help"
+ key="key_openHelp"
+ oncommand="openSupportURL();"/>
+ <menuitem id="menu_openTour"
+ data-l10n-id="menu-help-explore-features"
+ oncommand="openLinkText(event, 'tourURL');"/>
+ <menuitem id="menu_keyboardShortcuts"
+ data-l10n-id="menu-help-shortcuts"
+ oncommand="openLinkText(event, 'keyboardShortcutsURL');"/>
+ <menuseparator/>
+ <menuitem id="getInvolved"
+ data-l10n-id="menu-help-get-involved"
+ oncommand="openLinkText(event, 'getInvolvedURL');"/>
+ <menuitem id="donationsPage"
+ data-l10n-id="menu-help-donation"
+ oncommand="openLinkText(event, 'donateURL');"/>
+ <menuitem id="feedbackPage"
+ data-l10n-id="menu-help-share-feedback"
+ oncommand="openLinkText(event, 'feedbackURL');"/>
+ <menuseparator id="functionsSeparator"/>
+ <menuitem id="helpTroubleshootMode"
+ data-l10n-id="menu-help-enter-troubleshoot-mode"
+ oncommand="safeModeRestart();"/>
+ <menuitem id="aboutsupport_open"
+ data-l10n-id="menu-help-troubleshooting-info"
+ oncommand="openAboutSupport();"/>
+#ifndef XP_MACOSX
+ <menuseparator id="aboutSeparator"/>
+#endif
+ <menuitem id="aboutName"
+ data-l10n-id="menu-help-about-product"
+ oncommand="openAboutDialog();"/>
+ </menupopup>
+ </menu>
diff --git a/comm/mail/base/content/hiddenWindowMac.js b/comm/mail/base/content/hiddenWindowMac.js
new file mode 100644
index 0000000000..f6a8ffccc8
--- /dev/null
+++ b/comm/mail/base/content/hiddenWindowMac.js
@@ -0,0 +1,124 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+function hiddenWindowStartup() {
+ // Disable menus which are not appropriate
+ let disabledItems = [
+ "menu_newFolder",
+ "newMailAccountMenuItem",
+ "newNewsgroupAccountMenuItem",
+ "menu_close",
+ "menu_saveAs",
+ "menu_saveAsFile",
+ "menu_newVirtualFolder",
+ "menu_find",
+ "menu_findCmd",
+ "menu_findAgainCmd",
+ "menu_sendunsentmsgs",
+ "menu_subscribe",
+ "menu_deleteFolder",
+ "menu_renameFolder",
+ "menu_select",
+ "menu_selectAll",
+ "menu_selectThread",
+ "menu_favoriteFolder",
+ "menu_properties",
+ "menu_Toolbars",
+ "menu_MessagePaneLayout",
+ "menu_showMessage",
+ "menu_toggleThreadPaneHeader",
+ "menu_showFolderPane",
+ "menu_FolderViews",
+ "viewSortMenu",
+ "groupBySort",
+ "viewMessageViewMenu",
+ "viewMessagesMenu",
+ "menu_expandAllThreads",
+ "collapseAllThreads",
+ "viewheadersmenu",
+ "viewBodyMenu",
+ "viewAttachmentsInlineMenuitem",
+ "viewFullZoomMenu",
+ "goNextMenu",
+ "menu_nextMsg",
+ "menu_nextUnreadMsg",
+ "menu_nextUnreadThread",
+ "goPreviousMenu",
+ "menu_prevMsg",
+ "menu_prevUnreadMsg",
+ "menu_goForward",
+ "menu_goBack",
+ "goStartPage",
+ "newMsgCmd",
+ "replyMainMenu",
+ "replySenderMainMenu",
+ "replyNewsgroupMainMenu",
+ "menu_replyToAll",
+ "menu_replyToList",
+ "menu_forwardMsg",
+ "forwardAsMenu",
+ "menu_editMsgAsNew",
+ "openMessageWindowMenuitem",
+ "openConversationMenuitem",
+ "moveMenu",
+ "copyMenu",
+ "moveToFolderAgain",
+ "tagMenu",
+ "markMenu",
+ "markReadMenuItem",
+ "menu_markThreadAsRead",
+ "menu_markReadByDate",
+ "menu_markAllRead",
+ "markFlaggedMenuItem",
+ "menu_markAsJunk",
+ "menu_markAsNotJunk",
+ "createFilter",
+ "killThread",
+ "killSubthread",
+ "watchThread",
+ "applyFilters",
+ "runJunkControls",
+ "deleteJunk",
+ "menu_import",
+ "searchMailCmd",
+ "searchAddressesCmd",
+ "filtersCmd",
+ "cmd_close",
+ "minimizeWindow",
+ "zoomWindow",
+ "appmenu_newFolder",
+ "appmenu_newMailAccountMenuItem",
+ "appmenu_newNewsgroupAccountMenuItem",
+ "appmenu_saveAs",
+ "appmenu_saveAsFile",
+ "appmenu_newVirtualFolder",
+ "appmenu_findAgainCmd",
+ "appmenu_favoriteFolder",
+ "appmenu_properties",
+ "appmenu_MessagePaneLayout",
+ "appmenu_showMessage",
+ "appmenu_toggleThreadPaneHeader",
+ "appmenu_showFolderPane",
+ "appmenu_FolderViews",
+ "appmenu_groupBySort",
+ "appmenu_findCmd",
+ "appmenu_find",
+ "appmenu_openMessageWindowMenuitem",
+ ];
+
+ let element;
+ for (let id of disabledItems) {
+ element = document.getElementById(id);
+ if (element) {
+ element.setAttribute("disabled", "true");
+ }
+ }
+
+ // Also hide the window-list separator if it exists.
+ element = document.getElementById("sep-window-list");
+ if (element) {
+ element.setAttribute("hidden", "true");
+ }
+}
diff --git a/comm/mail/base/content/hiddenWindowMac.xhtml b/comm/mail/base/content/hiddenWindowMac.xhtml
new file mode 100644
index 0000000000..3f0e493e32
--- /dev/null
+++ b/comm/mail/base/content/hiddenWindowMac.xhtml
@@ -0,0 +1,101 @@
+<?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/.
+
+<!DOCTYPE window [
+#include messenger-doctype.inc.dtd
+]>
+
+<window id="hidden-window"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="hiddenWindowStartup();">
+
+<script src="chrome://messenger/content/globalOverlay.js"/>
+<script src="chrome://messenger/content/mailWindow.js"/>
+<script src="chrome://messenger/content/messenger.js"/>
+<script src="chrome://messenger/content/mail3PaneWindowCommands.js"/>
+<script src="chrome://messenger/content/searchBar.js"/>
+<script src="chrome://messenger/content/hiddenWindowMac.js"/>
+<script src="chrome://messenger/content/mailCommands.js"/>
+<script src="chrome://messenger/content/mailWindowOverlay.js"/>
+<script src="chrome://messenger/content/mailTabs.js"/>
+<script src="chrome://messenger-newsblog/content/newsblogOverlay.js"/>
+<script src="chrome://messenger/content/accountUtils.js"/>
+<script src="chrome://messenger/content/mail-offline.js"/>
+<script src="chrome://messenger/content/msgViewPickerOverlay.js"/>
+<script src="chrome://messenger/content/viewZoomOverlay.js"/>
+<script src="chrome://communicator/content/utilityOverlay.js"/>
+<script src="chrome://messenger/content/mailCore.js"/>
+<script src="chrome://messenger/content/newmailaccount/uriListener.js"/>
+<script src="chrome://global/content/macWindowMenu.js"/>
+
+<stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+<stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+
+<linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="messenger/messenger.ftl"/>
+ <html:link rel="localization" href="messenger/menubar.ftl"/>
+ <html:link rel="localization" href="messenger/appmenu.ftl"/>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl"/>
+</linkset>
+
+<!-- keys are appended from the overlay -->
+<keyset id="mailKeys">
+#include mainKeySet.inc.xhtml
+ <keyset id="tasksKeys">
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel,shift"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+ </keyset>
+</keyset>
+
+<commandset id="mailCommands">
+#include mainCommandSet.inc.xhtml
+ <commandset id="mailSearchMenuItems"/>
+ <commandset id="globalEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateGlobalEditMenuItems()"/>
+ <commandset id="selectEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateSelectEditMenuItems()"/>
+ <commandset id="clipboardEditMenuItems"
+ commandupdater="true"
+ events="clipboard"
+ oncommandupdate="goUpdatePasteMenuItems()"/>
+ <commandset id="tasksCommands">
+ <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/>
+ <command id="cmd_newCard" oncommand="openNewCardDialog()"/>
+ </commandset>
+</commandset>
+
+ <!-- it's the whole mailWindowOverlay.xhtml menubar! hidden windows need to
+ have a menubar for situations where they're the only window remaining
+ on a platform that wants to leave the app running, like the Mac.
+ -->
+ <box id="navigation-toolbox-background">
+ <toolbox id="navigation-toolbox" flex="1" labelalign="end" defaultlabelalign="end">
+
+ <vbox id="titlebar">
+ <!-- Menu -->
+ <toolbar id="toolbar-menubar"
+ class="chromeclass-menubar themeable-full"
+ type="menubar"
+ context="toolbar-context-menu">
+# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it
+# can be shared with other top level windows.
+#include messenger-menubar.inc.xhtml
+ </toolbar>
+ </vbox>
+ </toolbox>
+ </box>
+
+<browser id="hiddenBrowser" disablehistory="true"/>
+
+</window>
diff --git a/comm/mail/base/content/macMessengerMenu.js b/comm/mail/base/content/macMessengerMenu.js
new file mode 100644
index 0000000000..3ee6ba4872
--- /dev/null
+++ b/comm/mail/base/content/macMessengerMenu.js
@@ -0,0 +1,99 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 mailCore.js */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+// Load and add the menu item to the OS X Dock icon menu.
+addEventListener(
+ "load",
+ function () {
+ let dockMenuElement = document.getElementById("menu_mac_dockmenu");
+ let nativeMenu = Cc[
+ "@mozilla.org/widget/standalonenativemenu;1"
+ ].createInstance(Ci.nsIStandaloneNativeMenu);
+
+ nativeMenu.init(dockMenuElement);
+
+ let dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService(
+ Ci.nsIMacDockSupport
+ );
+ dockSupport.dockMenu = nativeMenu;
+ },
+ false
+);
+
+/**
+ * When the Preferences window is actually loaded, this Listener is called.
+ * Not doing this way could make DOM elements not available.
+ */
+function loadListener(event) {
+ setTimeout(function () {
+ let prefWin = Services.wm.getMostRecentWindow("Mail:Preferences");
+ prefWin.gSubDialog.open(
+ "chrome://messenger/content/preferences/dockoptions.xhtml"
+ );
+ });
+}
+
+/**
+ * When the Preferences window is opened/closed, this observer will be called.
+ * This is done so subdialog opens as a child of it.
+ */
+function PrefWindowObserver() {
+ this.observe = function (aSubject, aTopic, aData) {
+ if (aTopic == "domwindowopened") {
+ aSubject.addEventListener("load", loadListener, {
+ capture: false,
+ once: true,
+ });
+ }
+ Services.ww.unregisterNotification(this);
+ };
+}
+
+/**
+ * Show the Dock Options sub-dialog hanging from the Preferences window.
+ * If Preference window was already opened, this will select General pane before
+ * opening Dock Options sub-dialog.
+ */
+function openDockOptions() {
+ let win = Services.wm.getMostRecentWindow("Mail:Preferences");
+
+ if (win) {
+ openOptionsDialog("paneGeneral");
+ win.gSubDialog("chrome://messenger/content/preferences/dockoptions.xhtml");
+ } else {
+ Services.ww.registerNotification(new PrefWindowObserver());
+ openOptionsDialog("paneGeneral");
+ }
+}
+
+/**
+ * Open a new window for writing a new message
+ */
+function writeNewMessageDock() {
+ // Default identity will be used as sender for the new message.
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ null,
+ Ci.nsIMsgCompType.New,
+ Ci.nsIMsgCompFormat.Default,
+ null,
+ null,
+ null
+ );
+}
+
+/**
+ * Open the address book window
+ */
+function openAddressBookDock() {
+ toAddressBook();
+}
diff --git a/comm/mail/base/content/macWindowMenu.inc.xhtml b/comm/mail/base/content/macWindowMenu.inc.xhtml
new file mode 100644
index 0000000000..e75a68f51d
--- /dev/null
+++ b/comm/mail/base/content/macWindowMenu.inc.xhtml
@@ -0,0 +1,22 @@
+# 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/.
+
+<!-- Mac window menu -->
+ <menu id="windowMenu"
+ label="&windowMenu.label;">
+ <menupopup id="windowPopup">
+ <menuseparator/>
+ <menuitem id="minimizeWindow"
+ label="&minimizeWindow.label;"
+ oncommand="window.minimize();"
+ key="key_minimizeWindow"/>
+ <menuitem id="zoomWindow"
+ label="&zoomWindow.label;"
+ oncommand="zoomWindow();"/>
+ <!-- decomment when "BringAllToFront" is implemented
+ <menuseparator/>
+ <menuitem label="&bringAllToFront.label;" disabled="true"/> -->
+ <menuseparator id="sep-window-list"/>
+ </menupopup>
+ </menu>
diff --git a/comm/mail/base/content/mail-offline.js b/comm/mail/base/content/mail-offline.js
new file mode 100644
index 0000000000..13024b874a
--- /dev/null
+++ b/comm/mail/base/content/mail-offline.js
@@ -0,0 +1,276 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 msgWindow */ // From mailWindow.js
+
+var MailOfflineMgr = {
+ offlineManager: null,
+ offlineBundle: null,
+
+ init() {
+ Services.obs.addObserver(this, "network:offline-status-changed");
+
+ this.offlineManager = Cc[
+ "@mozilla.org/messenger/offline-manager;1"
+ ].getService(Ci.nsIMsgOfflineManager);
+ this.offlineBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/offline.properties"
+ );
+
+ // initialize our offline state UI
+ this.updateOfflineUI(!this.isOnline());
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "network:offline-status-changed");
+ },
+
+ /**
+ * @returns true if we are online
+ */
+ isOnline() {
+ return !Services.io.offline;
+ },
+
+ /**
+ * Toggles the online / offline state, initiated by the user. Depending on user settings
+ * we may prompt the user to send unsent messages when going online or to download messages for
+ * offline use when going offline.
+ */
+ toggleOfflineStatus() {
+ // the offline manager(goOnline and synchronizeForOffline) actually does the dirty work of
+ // changing the offline state with the networking service.
+ if (!this.isOnline()) {
+ // We do the go online stuff in our listener for the online state change.
+ Services.io.offline = false;
+ // resume managing offline status now that we are going back online.
+ Services.io.manageOfflineStatus =
+ Services.prefs.getBoolPref("offline.autoDetect");
+ } else {
+ // going offline
+ // Stop automatic management of the offline status since the user has
+ // decided to go offline.
+ Services.io.manageOfflineStatus = false;
+ var prefDownloadMessages = Services.prefs.getIntPref(
+ "offline.download.download_messages"
+ );
+ // 0 == Ask, 1 == Always Download, 2 == Never Download
+ var downloadForOfflineUse =
+ (prefDownloadMessages == 0 &&
+ this.confirmDownloadMessagesForOfflineUse()) ||
+ prefDownloadMessages == 1;
+ this.offlineManager.synchronizeForOffline(
+ downloadForOfflineUse,
+ downloadForOfflineUse,
+ false,
+ true,
+ msgWindow
+ );
+ }
+ },
+
+ observe(aSubject, aTopic, aState) {
+ if (aTopic == "network:offline-status-changed") {
+ this.mailOfflineStateChanged(aState == "offline");
+ }
+ },
+
+ /**
+ * @returns true if there are unsent messages
+ */
+ haveUnsentMessages() {
+ return Cc["@mozilla.org/messengercompose/sendlater;1"]
+ .getService(Ci.nsIMsgSendLater)
+ .hasUnsentMessages();
+ },
+
+ /**
+ * open the offline panel in the account manager for the currently loaded
+ * account.
+ */
+ openOfflineAccountSettings() {
+ window.parent.MsgAccountManager("am-offline.xhtml");
+ },
+
+ /**
+ * Prompt the user about going online to send unsent messages, and then send them
+ * if appropriate. Puts the app back into online mode.
+ *
+ * @param aMsgWindow the msg window to be used when going online
+ */
+ goOnlineToSendMessages(aMsgWindow) {
+ let goOnlineToSendMsgs = Services.prompt.confirm(
+ window,
+ this.offlineBundle.GetStringFromName("sendMessagesOfflineWindowTitle1"),
+ this.offlineBundle.GetStringFromName("sendMessagesOfflineLabel1")
+ );
+
+ if (goOnlineToSendMsgs) {
+ this.offlineManager.goOnline(
+ true /* send unsent messages*/,
+ false,
+ aMsgWindow
+ );
+ }
+ },
+
+ /**
+ * Prompts the user to confirm sending of unsent messages. This is different from
+ * goOnlineToSendMessages which involves going online to send unsent messages.
+ *
+ * @returns true if the user wants to send unsent messages
+ */
+ confirmSendUnsentMessages() {
+ let alwaysAsk = { value: true };
+ let sendUnsentMessages =
+ Services.prompt.confirmEx(
+ window,
+ this.offlineBundle.GetStringFromName("sendMessagesWindowTitle1"),
+ this.offlineBundle.GetStringFromName("sendMessagesLabel2"),
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1,
+ this.offlineBundle.GetStringFromName("sendMessagesNow2"),
+ this.offlineBundle.GetStringFromName("processMessagesLater2"),
+ null,
+ this.offlineBundle.GetStringFromName("sendMessagesCheckboxLabel1"),
+ alwaysAsk
+ ) == 0;
+
+ // if the user changed the ask me setting then update the global pref based on their yes / no answer
+ if (!alwaysAsk.value) {
+ Services.prefs.setIntPref(
+ "offline.send.unsent_messages",
+ sendUnsentMessages ? 1 : 2
+ );
+ }
+
+ return sendUnsentMessages;
+ },
+
+ /**
+ * Should we send unsent messages? Based on the value of
+ * offline.send.unsent_messages, this method may prompt the user.
+ *
+ * @returns true if we should send unsent messages
+ */
+ shouldSendUnsentMessages() {
+ var sendUnsentWhenGoingOnlinePref = Services.prefs.getIntPref(
+ "offline.send.unsent_messages"
+ );
+ if (sendUnsentWhenGoingOnlinePref == 2) {
+ // never send
+ return false;
+ } else if (this.haveUnsentMessages()) {
+ // if we we have unsent messages, then honor the offline.send.unsent_messages pref.
+ if (
+ (sendUnsentWhenGoingOnlinePref == 0 &&
+ this.confirmSendUnsentMessages()) ||
+ sendUnsentWhenGoingOnlinePref == 1
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Prompts the user to download messages for offline use before going offline.
+ * May update the value of offline.download.download_messages
+ *
+ * @returns true if the user wants to download messages for offline use.
+ */
+ confirmDownloadMessagesForOfflineUse() {
+ let alwaysAsk = { value: true };
+ let downloadMessages =
+ Services.prompt.confirmEx(
+ window,
+ this.offlineBundle.GetStringFromName("downloadMessagesWindowTitle1"),
+ this.offlineBundle.GetStringFromName("downloadMessagesLabel1"),
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1,
+ this.offlineBundle.GetStringFromName("downloadMessagesNow2"),
+ this.offlineBundle.GetStringFromName("processMessagesLater2"),
+ null,
+ this.offlineBundle.GetStringFromName("downloadMessagesCheckboxLabel1"),
+ alwaysAsk
+ ) == 0;
+
+ // if the user changed the ask me setting then update the global pref based on their yes / no answer
+ if (!alwaysAsk.value) {
+ Services.prefs.setIntPref(
+ "offline.download.download_messages",
+ downloadMessages ? 1 : 2
+ );
+ }
+ return downloadMessages;
+ },
+
+ /**
+ * Get New Mail When Offline
+ * Prompts the user about going online in order to download new messages.
+ * Based on the response, will move us back to online mode.
+ *
+ * @returns true if the user confirms going online.
+ */
+ getNewMail() {
+ let goOnline = Services.prompt.confirm(
+ window,
+ this.offlineBundle.GetStringFromName("getMessagesOfflineWindowTitle1"),
+ this.offlineBundle.GetStringFromName("getMessagesOfflineLabel1")
+ );
+
+ if (goOnline) {
+ this.offlineManager.goOnline(
+ this.shouldSendUnsentMessages(),
+ false /* playbackOfflineImapOperations */,
+ msgWindow
+ );
+ }
+ return goOnline;
+ },
+
+ /**
+ * Private helper method to update the state of the Offline menu item
+ * and the offline status bar indicator
+ */
+ updateOfflineUI(aIsOffline) {
+ document
+ .getElementById("goOfflineMenuItem")
+ .setAttribute("checked", aIsOffline);
+ var statusBarPanel = document.getElementById("offline-status");
+ if (aIsOffline) {
+ statusBarPanel.setAttribute("offline", "true");
+ statusBarPanel.setAttribute(
+ "tooltiptext",
+ this.offlineBundle.GetStringFromName("offlineTooltip")
+ );
+ } else {
+ statusBarPanel.removeAttribute("offline");
+ statusBarPanel.setAttribute(
+ "tooltiptext",
+ this.offlineBundle.GetStringFromName("onlineTooltip")
+ );
+ }
+ },
+
+ /**
+ * private helper method called whenever we detect a change to the offline state
+ */
+ mailOfflineStateChanged(aGoingOffline) {
+ this.updateOfflineUI(aGoingOffline);
+ if (!aGoingOffline) {
+ let prefSendUnsentMessages = Services.prefs.getIntPref(
+ "offline.send.unsent_messages"
+ );
+ // 0 == Ask, 1 == Always Send, 2 == Never Send
+ let sendUnsentMessages =
+ (prefSendUnsentMessages == 0 &&
+ this.haveUnsentMessages() &&
+ this.confirmSendUnsentMessages()) ||
+ prefSendUnsentMessages == 1;
+ this.offlineManager.goOnline(sendUnsentMessages, true, msgWindow);
+ }
+ },
+};
diff --git a/comm/mail/base/content/mail3PaneWindowCommands.js b/comm/mail/base/content/mail3PaneWindowCommands.js
new file mode 100644
index 0000000000..8042022dcb
--- /dev/null
+++ b/comm/mail/base/content/mail3PaneWindowCommands.js
@@ -0,0 +1,456 @@
+/* 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/. */
+
+/**
+ * Functionality for the main application window (aka the 3pane) usually
+ * consisting of folder pane, thread pane and message pane.
+ */
+
+/* global MozElements */
+
+/* import-globals-from ../../components/im/content/chat-messenger.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from mailWindow.js */ // msgWindow and a loooot more
+/* import-globals-from utilityOverlay.js */
+
+/* globals MailOfflineMgr */ // From mail-offline.js
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+);
+
+// DefaultController object (handles commands when one of the trees does not have focus)
+var DefaultController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_newMessage":
+ case "cmd_undoCloseTab":
+ case "cmd_undo":
+ case "cmd_redo":
+ case "cmd_sendUnsentMsgs":
+ case "cmd_subscribe":
+ case "cmd_getNewMessages":
+ case "cmd_getMsgsForAuthAccounts":
+ case "cmd_getNextNMessages":
+ case "cmd_settingsOffline":
+ case "cmd_viewAllHeader":
+ case "cmd_viewNormalHeader":
+ case "cmd_stop":
+ case "cmd_chat":
+ case "cmd_goFolder":
+ return true;
+ case "cmd_synchronizeOffline":
+ return MailOfflineMgr.isOnline();
+ case "cmd_joinChat":
+ case "cmd_addChatBuddy":
+ case "cmd_chatStatus":
+ return !!chatHandler;
+
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(command) {
+ if (document.getElementById("tabmail").globalOverlay) {
+ return false;
+ }
+ switch (command) {
+ case "cmd_newMessage":
+ return MailServices.accounts.allIdentities.length > 0;
+ case "cmd_viewAllHeader":
+ case "cmd_viewNormalHeader":
+ return true;
+ case "cmd_undoCloseTab":
+ return document.getElementById("tabmail").recentlyClosedTabs.length > 0;
+ case "cmd_stop":
+ return window.MsgStatusFeedback?._meteorsSpinning;
+ case "cmd_undo":
+ case "cmd_redo":
+ return SetupUndoRedoCommand(command);
+ case "cmd_sendUnsentMsgs":
+ return IsSendUnsentMsgsEnabled(null);
+ case "cmd_subscribe":
+ return IsSubscribeEnabled();
+ case "cmd_getNewMessages":
+ case "cmd_getMsgsForAuthAccounts":
+ return IsGetNewMessagesEnabled();
+ case "cmd_getNextNMessages":
+ return IsGetNextNMessagesEnabled();
+ case "cmd_synchronizeOffline":
+ return MailOfflineMgr.isOnline();
+ case "cmd_settingsOffline":
+ return IsAccountOfflineEnabled();
+ case "cmd_goFolder":
+ return isFolderPaneInitialized();
+ case "cmd_chat":
+ return true;
+ case "cmd_joinChat":
+ case "cmd_addChatBuddy":
+ case "cmd_chatStatus":
+ return !!chatHandler;
+ }
+ return false;
+ },
+
+ doCommand(command, event) {
+ // If the user invoked a key short cut then it is possible that we got here
+ // for a command which is really disabled. Kick out if the command should be disabled.
+ if (!this.isCommandEnabled(command)) {
+ return;
+ }
+
+ switch (command) {
+ case "cmd_getNewMessages":
+ MsgGetMessage();
+ break;
+ case "cmd_getMsgsForAuthAccounts":
+ MsgGetMessagesForAllAuthenticatedAccounts();
+ break;
+ case "cmd_getNextNMessages":
+ MsgGetNextNMessages();
+ break;
+ case "cmd_newMessage":
+ MsgNewMessage(event);
+ break;
+ case "cmd_undoCloseTab":
+ document.getElementById("tabmail").undoCloseTab();
+ break;
+ case "cmd_undo":
+ messenger.undo(msgWindow);
+ break;
+ case "cmd_redo":
+ messenger.redo(msgWindow);
+ break;
+ case "cmd_sendUnsentMsgs":
+ // if offline, prompt for sendUnsentMessages
+ if (MailOfflineMgr.isOnline()) {
+ SendUnsentMessages();
+ } else {
+ MailOfflineMgr.goOnlineToSendMessages(msgWindow);
+ }
+ return;
+ case "cmd_subscribe":
+ MsgSubscribe();
+ return;
+ case "cmd_stop":
+ msgWindow.StopUrls();
+ return;
+ case "cmd_viewAllHeader":
+ MsgViewAllHeaders();
+ return;
+ case "cmd_viewNormalHeader":
+ MsgViewNormalHeaders();
+ return;
+ case "cmd_synchronizeOffline":
+ MsgSynchronizeOffline();
+ break;
+ case "cmd_settingsOffline":
+ MailOfflineMgr.openOfflineAccountSettings();
+ break;
+ case "cmd_goFolder":
+ document
+ .getElementById("tabmail")
+ .currentAbout3Pane.displayFolder(event.target._folder);
+ break;
+ case "cmd_chat":
+ showChatTab();
+ break;
+ }
+ },
+
+ onEvent(event) {
+ // on blur events set the menu item texts back to the normal values
+ if (event == "blur") {
+ goSetMenuValue("cmd_undo", "valueDefault");
+ goSetMenuValue("cmd_redo", "valueDefault");
+ }
+ },
+};
+// This is the highest priority controller. It's followed by
+// tabmail.tabController and calendarController, then whatever Gecko adds.
+window.controllers.insertControllerAt(0, DefaultController);
+
+function CloseTabOrWindow() {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.globalOverlay) {
+ return;
+ }
+ if (tabmail.tabInfo.length == 1) {
+ if (Services.prefs.getBoolPref("mail.tabs.closeWindowWithLastTab")) {
+ window.close();
+ }
+ } else {
+ tabmail.removeCurrentTab();
+ }
+}
+
+function IsSendUnsentMsgsEnabled(unsentMsgsFolder) {
+ // If no account has been configured, there are no messages for sending.
+ if (MailServices.accounts.accounts.length == 0) {
+ return false;
+ }
+
+ let msgSendlater;
+ try {
+ msgSendlater = Cc["@mozilla.org/messengercompose/sendlater;1"].getService(
+ Ci.nsIMsgSendLater
+ );
+ } catch (error) {}
+
+ // If we're currently sending unsent msgs, disable this cmd.
+ if (msgSendlater?.sendingMessages) {
+ return false;
+ }
+
+ if (unsentMsgsFolder) {
+ // If unsentMsgsFolder is non-null, it is the "Unsent Messages" folder.
+ // We're here because we've done a right click on the "Unsent Messages"
+ // folder (context menu), so we can use the folder and return true/false
+ // straight away.
+ return unsentMsgsFolder.getTotalMessages(false) > 0;
+ }
+
+ // Otherwise, we don't know where we are, so use the current identity and
+ // find out if we have messages or not via that.
+ let identity;
+ let folders = GetSelectedMsgFolders();
+ if (folders.length > 0) {
+ [identity] = MailUtils.getIdentityForServer(folders[0].server);
+ }
+
+ if (!identity) {
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ identity = defaultAccount.defaultIdentity;
+ }
+
+ if (!identity) {
+ return false;
+ }
+ }
+
+ let hasUnsentMessages = false;
+ try {
+ hasUnsentMessages = msgSendlater?.hasUnsentMessages(identity);
+ } catch (error) {}
+ return hasUnsentMessages;
+}
+
+/**
+ * Determine whether there exists any server for which to show the Subscribe dialog.
+ */
+function IsSubscribeEnabled() {
+ // If there are any IMAP or News servers, we can show the dialog any time and
+ // it will properly show those.
+ for (let server of MailServices.accounts.allServers) {
+ if (server.type == "imap" || server.type == "nntp") {
+ return true;
+ }
+ }
+
+ // RSS accounts use a separate Subscribe dialog that we can only show when
+ // such an account is selected.
+ let preselectedFolder = GetFirstSelectedMsgFolder();
+ if (preselectedFolder && preselectedFolder.server.type == "rss") {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Cycle through the various panes in the 3pane window.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+function SwitchPaneFocus(event) {
+ let tabmail = document.getElementById("tabmail");
+ // Should not move the focus around when the entire window is covered with
+ // something else.
+ if (tabmail.globalOverlay) {
+ return;
+ }
+ // First, build an array of panes to cycle through based on our current state.
+ // This will usually be something like [folderTree, threadTree, messageBrowser].
+ let panes = [];
+ // The logically focused element. If the actually focused element is not one
+ // of the panes, the code below can change this variable to point to one of
+ // the panes.
+ let focusedElement = document.activeElement;
+ // If the actually focused element is between two of the panes, set this to
+ // -1, 0, or 1 (depending on the direction and where the focus is relative to
+ // `focusedElement`) so that the element to focus is correctly chosen.
+ let adjustment = 0;
+
+ let spacesElement = !gSpacesToolbar.isHidden
+ ? gSpacesToolbar.focusButton
+ : document.getElementById("spacesPinnedButton");
+ panes.push(spacesElement);
+
+ let toolbar = document.getElementById("unifiedToolbar");
+ if (!toolbar.hidden) {
+ // Prioritise the search bar, otherwise use the first available button.
+ let toolbarElement =
+ toolbar.querySelector("global-search-bar") ||
+ toolbar.querySelector("li:not([hidden]) button, #button-appmenu");
+ if (toolbarElement) {
+ panes.push(toolbarElement);
+ if (toolbar.matches(":focus-within") && focusedElement != spacesElement) {
+ focusedElement = toolbarElement;
+ }
+ }
+ }
+
+ let { currentTabInfo } = tabmail;
+ switch (currentTabInfo.mode.name) {
+ case "mail3PaneTab": {
+ let { contentWindow, contentDocument } = currentTabInfo.chromeBrowser;
+ let {
+ paneLayout,
+ folderTree,
+ threadTree,
+ webBrowser,
+ messageBrowser,
+ multiMessageBrowser,
+ accountCentralBrowser,
+ } = contentWindow;
+
+ if (paneLayout.folderPaneVisible) {
+ panes.push(folderTree);
+ }
+
+ if (accountCentralBrowser.hidden) {
+ panes.push(threadTree.table.body);
+ } else {
+ panes.push(accountCentralBrowser);
+ }
+
+ if (paneLayout.messagePaneVisible) {
+ if (!webBrowser.hidden) {
+ panes.push(webBrowser);
+ } else if (!messageBrowser.hidden) {
+ panes.push(messageBrowser.contentWindow.getMessagePaneBrowser());
+ } else if (!multiMessageBrowser.hidden) {
+ panes.push(multiMessageBrowser);
+ }
+ }
+
+ if (focusedElement == currentTabInfo.chromeBrowser) {
+ focusedElement = contentDocument.activeElement;
+ if (
+ focusedElement != folderTree &&
+ contentDocument.getElementById("folderPane").contains(focusedElement)
+ ) {
+ focusedElement = folderTree;
+ adjustment = event.shiftKey ? 0 : -1;
+ } else if (
+ contentDocument
+ .getElementById("threadPaneNotificationBox")
+ .contains(focusedElement)
+ ) {
+ focusedElement = threadTree.table.body;
+ adjustment = event.shiftKey ? 1 : 0;
+ } else if (
+ focusedElement != threadTree.table.body &&
+ contentDocument.getElementById("threadPane").contains(focusedElement)
+ ) {
+ focusedElement = threadTree.table.body;
+ adjustment = event.shiftKey ? 0 : -1;
+ } else if (focusedElement == messageBrowser) {
+ focusedElement = messageBrowser.contentWindow.getMessagePaneBrowser();
+ }
+ }
+ break;
+ }
+ case "mailMessageTab": {
+ let { content } = currentTabInfo.chromeBrowser.contentWindow;
+ panes.push(content);
+ if (focusedElement == currentTabInfo.chromeBrowser) {
+ focusedElement = content;
+ }
+ break;
+ }
+ case "addressBookTab": {
+ let { booksList, cardsPane, detailsPane } =
+ currentTabInfo.browser.contentWindow;
+
+ if (detailsPane.isEditing) {
+ panes.push(currentTabInfo.browser);
+ } else {
+ let targets = [
+ booksList,
+ cardsPane.searchInput,
+ cardsPane.cardsList.table.body,
+ ];
+ if (!detailsPane.node.hidden && !detailsPane.editButton.hidden) {
+ targets.push(detailsPane.editButton);
+ }
+
+ if (focusedElement == currentTabInfo.browser) {
+ focusedElement = targets.find(t => t.matches(":focus-within"));
+ }
+ panes.push(...targets);
+ }
+ break;
+ }
+ default:
+ if (currentTabInfo.browser) {
+ panes.push(currentTabInfo.browser);
+ }
+ break;
+ }
+
+ // Find our focused element in the array.
+ let focusedElementIndex = panes.indexOf(focusedElement) + adjustment;
+ if (event.shiftKey) {
+ focusedElementIndex--;
+ if (focusedElementIndex < 0) {
+ focusedElementIndex = panes.length - 1;
+ }
+ } else if (focusedElementIndex == -1) {
+ focusedElementIndex = 0;
+ } else {
+ focusedElementIndex++;
+ if (focusedElementIndex == panes.length) {
+ focusedElementIndex = 0;
+ }
+ }
+
+ if (panes[focusedElementIndex]) {
+ panes[focusedElementIndex].focus();
+ }
+}
+
+// Override F6 handling for remote browsers, and use our own logic to
+// determine the element to focus.
+addEventListener(
+ "keypress",
+ function (event) {
+ if (event.key == "F6" && Services.focus.focusedElement?.isRemoteBrowser) {
+ event.preventDefault();
+ SwitchPaneFocus(event);
+ }
+ },
+ true
+);
+
+/**
+ * Check the status of the folder pane, if available.
+ *
+ * @returns {boolean|undefined} The initialization state of the folder pane,
+ * or undefined if we can't access the document.
+ */
+function isFolderPaneInitialized() {
+ return document.getElementById("tabmail")?.currentAbout3Pane?.folderPane
+ .isInitialized;
+}
diff --git a/comm/mail/base/content/mailCommands.js b/comm/mail/base/content/mailCommands.js
new file mode 100644
index 0000000000..9c974202e5
--- /dev/null
+++ b/comm/mail/base/content/mailCommands.js
@@ -0,0 +1,667 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 utilityOverlay.js */
+
+/* globals msgWindow, messenger */ // From mailWindow.js
+/* globals openComposeWindowForRSSArticle */ // From newsblogOverlay.js
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FeedUtils",
+ "resource:///modules/FeedUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MsgHdrToMimeMessage",
+ "resource:///modules/gloda/MimeMessage.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "EnigmailMime",
+ "chrome://openpgp/content/modules/mime.jsm"
+);
+
+function GetNextNMessages(folder) {
+ if (folder) {
+ var newsFolder = folder.QueryInterface(Ci.nsIMsgNewsFolder);
+ if (newsFolder) {
+ newsFolder.getNextNMessages(msgWindow);
+ }
+ }
+}
+
+/**
+ * Figure out the message key from the message uri.
+ *
+ * @param uri string defining internal storage
+ */
+function GetMsgKeyFromURI(uri) {
+ // Format of 'uri' : protocol://email/folder#key?params
+ // '?params' are optional
+ // ex : mailbox-message://john%2Edoe@pop.isp.invalid/Drafts#12345
+ // We keep only the part after '#' and before an optional '?'.
+ // The regexp expects 'key' to be an integer (a series of digits) : '\d+'.
+ let match = /.+#(\d+)/.exec(uri);
+ return match ? match[1] : null;
+}
+
+/* eslint-disable complexity */
+/**
+ * Compose a message.
+ *
+ * @param {nsIMsgCompType} type - Type of composition (new message, reply, draft, etc.)
+ * @param {nsIMsgCompFormat} format - Requested format (plain text, html, default)
+ * @param {nsIMsgFolder} folder - Folder where the original message is stored
+ * @param {string[]} messageArray - Array of message URIs to process, often only
+ * holding one element.
+ * @param {Selection} [selection=null] - A DOM selection to be quoted, or null
+ * to quote the whole message, if quoting is appropriate (e.g. in a reply).
+ * @param {boolean} [autodetectCharset=false] - If quoting the whole message,
+ * whether automatic character set detection should be used.
+ */
+async function ComposeMessage(
+ type,
+ format,
+ folder,
+ messageArray,
+ selection = null,
+ autodetectCharset = false
+) {
+ let aboutMessage =
+ document.getElementById("tabmail")?.currentAboutMessage ||
+ document.getElementById("messageBrowser")?.contentWindow;
+ let currentHeaderData = aboutMessage?.currentHeaderData;
+
+ function isCurrentlyDisplayed(hdr) {
+ return (
+ currentHeaderData && // ignoring enclosing brackets:
+ currentHeaderData["message-id"]?.headerValue.includes(hdr.messageId)
+ );
+ }
+
+ function findDeliveredToIdentityEmail(hdr) {
+ // This function reads from currentHeaderData, which is only useful if we're
+ // looking at the currently-displayed message. Otherwise, just return
+ // immediately so we don't waste time.
+ if (!isCurrentlyDisplayed(hdr)) {
+ return "";
+ }
+
+ // Get the delivered-to headers.
+ let key = "delivered-to";
+ let deliveredTos = [];
+ let index = 0;
+ let header = "";
+ while ((header = currentHeaderData[key])) {
+ deliveredTos.push(header.headerValue.toLowerCase().trim());
+ key = "delivered-to" + index++;
+ }
+
+ // Reverse the array so that the last delivered-to header will show at front.
+ deliveredTos.reverse();
+
+ for (let i = 0; i < deliveredTos.length; i++) {
+ for (let identity of MailServices.accounts.allIdentities) {
+ if (!identity.email) {
+ continue;
+ }
+ // If the deliver-to header contains the defined identity, that's it.
+ if (
+ deliveredTos[i] == identity.email.toLowerCase() ||
+ deliveredTos[i].includes("<" + identity.email.toLowerCase() + ">")
+ ) {
+ return identity.email;
+ }
+ }
+ }
+ return "";
+ }
+
+ let msgKey;
+ if (messageArray && messageArray.length == 1) {
+ msgKey = GetMsgKeyFromURI(messageArray[0]);
+ }
+
+ // Check if the draft is already open in another window. If it is, just focus the window.
+ if (type == Ci.nsIMsgCompType.Draft && messageArray.length == 1) {
+ // We'll search this uri in the opened windows.
+ for (let win of Services.wm.getEnumerator("")) {
+ // Check if it is a compose window.
+ if (
+ win.document.defaultView.gMsgCompose &&
+ win.document.defaultView.gMsgCompose.compFields.draftId
+ ) {
+ let wKey = GetMsgKeyFromURI(
+ win.document.defaultView.gMsgCompose.compFields.draftId
+ );
+ if (wKey == msgKey) {
+ // Found ! just focus it...
+ win.focus();
+ // ...and nothing to do anymore.
+ return;
+ }
+ }
+ }
+ }
+ var identity = null;
+ var newsgroup = null;
+ var hdr;
+
+ // dump("ComposeMessage folder=" + folder + "\n");
+ try {
+ if (folder) {
+ // Get the incoming server associated with this uri.
+ var server = folder.server;
+
+ // If they hit new or reply and they are reading a newsgroup,
+ // turn this into a new post or a reply to group.
+ if (
+ !folder.isServer &&
+ server.type == "nntp" &&
+ type == Ci.nsIMsgCompType.New
+ ) {
+ type = Ci.nsIMsgCompType.NewsPost;
+ newsgroup = folder.folderURL;
+ }
+
+ identity = folder.customIdentity;
+ if (!identity) {
+ [identity] = MailUtils.getIdentityForServer(server);
+ }
+ // dump("identity = " + identity + "\n");
+ }
+ } catch (ex) {
+ dump("failed to get an identity to pre-select: " + ex + "\n");
+ }
+
+ // dump("\nComposeMessage from XUL: " + identity + "\n");
+
+ switch (type) {
+ case Ci.nsIMsgCompType.New: // new message
+ // dump("OpenComposeWindow with " + identity + "\n");
+
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ null,
+ type,
+ format,
+ identity,
+ null,
+ msgWindow
+ );
+ return;
+ case Ci.nsIMsgCompType.NewsPost:
+ // dump("OpenComposeWindow with " + identity + " and " + newsgroup + "\n");
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ newsgroup,
+ type,
+ format,
+ identity,
+ null,
+ msgWindow
+ );
+ return;
+ case Ci.nsIMsgCompType.ForwardAsAttachment:
+ if (messageArray && messageArray.length) {
+ // If we have more than one ForwardAsAttachment then pass null instead
+ // of the header to tell the compose service to work out the attachment
+ // subjects from the URIs.
+ hdr =
+ messageArray.length > 1
+ ? null
+ : messenger.msgHdrFromURI(messageArray[0]);
+ MailServices.compose.OpenComposeWindow(
+ null,
+ hdr,
+ messageArray.join(","),
+ type,
+ format,
+ identity,
+ null,
+ msgWindow
+ );
+ }
+ return;
+ default:
+ if (!messageArray) {
+ return;
+ }
+
+ // Limit the number of new compose windows to 8. Why 8 ?
+ // I like that number :-)
+ if (messageArray.length > 8) {
+ messageArray.length = 8;
+ }
+
+ for (var i = 0; i < messageArray.length; ++i) {
+ var messageUri = messageArray[i];
+ hdr = messenger.msgHdrFromURI(messageUri);
+
+ if (
+ [
+ Ci.nsIMsgCompType.Reply,
+ Ci.nsIMsgCompType.ReplyAll,
+ Ci.nsIMsgCompType.ReplyToSender,
+ // Author's address doesn't matter for followup to a newsgroup.
+ // Ci.nsIMsgCompType.ReplyToGroup,
+ Ci.nsIMsgCompType.ReplyToSenderAndGroup,
+ Ci.nsIMsgCompType.ReplyWithTemplate,
+ Ci.nsIMsgCompType.ReplyToList,
+ ].includes(type)
+ ) {
+ let replyTo = hdr.getStringProperty("replyTo");
+ let from = replyTo || hdr.author;
+ let fromAddrs = MailServices.headerParser.parseEncodedHeader(
+ from,
+ null
+ );
+ let email = fromAddrs[0]?.email;
+ if (
+ type == Ci.nsIMsgCompType.ReplyToList &&
+ isCurrentlyDisplayed(hdr)
+ ) {
+ // ReplyToList is only enabled for current message (if at all), so
+ // using currentHeaderData is ok.
+ // List-Post value is of the format <mailto:list@example.com>
+ let listPost = currentHeaderData["list-post"]?.headerValue;
+ if (listPost) {
+ email = listPost.replace(/.*<mailto:(.+)>.*/, "$1");
+ }
+ }
+
+ if (
+ /^(.*[._-])?(do[._-]?not|no)[._-]?reply([._-].*)?@/i.test(email)
+ ) {
+ let [title, message, replyAnywayButton] =
+ await document.l10n.formatValues([
+ { id: "no-reply-title" },
+ { id: "no-reply-message", args: { email } },
+ { id: "no-reply-reply-anyway-button" },
+ ]);
+
+ let buttonFlags =
+ Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 +
+ Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1 +
+ Ci.nsIPrompt.BUTTON_POS_1_DEFAULT;
+
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ buttonFlags,
+ replyAnywayButton,
+ null, // cancel
+ null,
+ null,
+ {}
+ )
+ ) {
+ continue;
+ }
+ }
+ }
+
+ if (FeedUtils.isFeedMessage(hdr)) {
+ // Do not use the header derived identity for feeds, pass on only a
+ // possible server identity from above.
+ openComposeWindowForRSSArticle(
+ null,
+ hdr,
+ messageUri,
+ type,
+ format,
+ identity,
+ msgWindow
+ );
+ } else {
+ // Replies come here.
+
+ let useCatchAll = false;
+ // Check if we are using catchAll on any identity. If current
+ // folder has some customIdentity set, ignore catchAll settings.
+ // CatchAll is not applicable to news (and doesn't work, bug 545365).
+ if (
+ hdr.folder &&
+ hdr.folder.server.type != "nntp" &&
+ !hdr.folder.customIdentity
+ ) {
+ useCatchAll = MailServices.accounts.allIdentities.some(
+ identity => identity.catchAll
+ );
+ }
+
+ if (useCatchAll) {
+ // If we use catchAll, we need to get all headers.
+ // MsgHdr retrieval is asynchronous, do everything in the callback.
+ MsgHdrToMimeMessage(
+ hdr,
+ null,
+ function (hdr, mimeMsg) {
+ let catchAllHeaders = Services.prefs
+ .getStringPref("mail.compose.catchAllHeaders")
+ .split(",")
+ .map(header => header.toLowerCase().trim());
+ // Collect catchAll hints from given headers.
+ let collectedHeaderAddresses = "";
+ for (let header of catchAllHeaders) {
+ if (mimeMsg.has(header)) {
+ for (let mimeMsgHeader of mimeMsg.headers[header]) {
+ collectedHeaderAddresses +=
+ MailServices.headerParser
+ .parseEncodedHeaderW(mimeMsgHeader)
+ .toString() + ",";
+ }
+ }
+ }
+
+ let [identity, matchingHint] = MailUtils.getIdentityForHeader(
+ hdr,
+ type,
+ collectedHeaderAddresses
+ );
+
+ // The found identity might have no catchAll enabled.
+ if (identity.catchAll && matchingHint) {
+ // If name is not set in matchingHint, search trough other hints.
+ if (matchingHint.email && !matchingHint.name) {
+ let hints =
+ MailServices.headerParser.makeFromDisplayAddress(
+ hdr.recipients +
+ "," +
+ hdr.ccList +
+ "," +
+ collectedHeaderAddresses
+ );
+ for (let hint of hints) {
+ if (
+ hint.name &&
+ hint.email.toLowerCase() ==
+ matchingHint.email.toLowerCase()
+ ) {
+ matchingHint =
+ MailServices.headerParser.makeMailboxObject(
+ hint.name,
+ matchingHint.email
+ );
+ break;
+ }
+ }
+ }
+ } else {
+ matchingHint = MailServices.headerParser.makeMailboxObject(
+ "",
+ ""
+ );
+ }
+
+ // Now open compose window and use matching hint as reply sender.
+ MailServices.compose.OpenComposeWindow(
+ null,
+ hdr,
+ messageUri,
+ type,
+ format,
+ identity,
+ matchingHint.toString(),
+ msgWindow,
+ selection,
+ autodetectCharset
+ );
+ },
+ true,
+ { saneBodySize: true }
+ );
+ } else {
+ // Fall back to traditional behavior.
+ let [hdrIdentity] = MailUtils.getIdentityForHeader(
+ hdr,
+ type,
+ findDeliveredToIdentityEmail(hdr)
+ );
+ MailServices.compose.OpenComposeWindow(
+ null,
+ hdr,
+ messageUri,
+ type,
+ format,
+ hdrIdentity,
+ null,
+ msgWindow,
+ selection,
+ autodetectCharset
+ );
+ }
+ }
+ }
+ }
+}
+/* eslint-enable complexity */
+
+function Subscribe(preselectedMsgFolder) {
+ window.openDialog(
+ "chrome://messenger/content/subscribe.xhtml",
+ "subscribe",
+ "chrome,modal,titlebar,resizable=yes",
+ {
+ folder: preselectedMsgFolder,
+ okCallback: SubscribeOKCallback,
+ }
+ );
+}
+
+function SubscribeOKCallback(changeTable) {
+ for (var serverURI in changeTable) {
+ var folder = MailUtils.getExistingFolder(serverURI);
+ var server = folder.server;
+ var subscribableServer = server.QueryInterface(Ci.nsISubscribableServer);
+
+ for (var name in changeTable[serverURI]) {
+ if (changeTable[serverURI][name]) {
+ try {
+ subscribableServer.subscribe(name);
+ } catch (ex) {
+ dump("failed to subscribe to " + name + ": " + ex + "\n");
+ }
+ } else if (!changeTable[serverURI][name]) {
+ try {
+ subscribableServer.unsubscribe(name);
+ } catch (ex) {
+ dump("failed to unsubscribe to " + name + ": " + ex + "\n");
+ }
+ }
+ }
+
+ try {
+ subscribableServer.commitSubscribeChanges();
+ } catch (ex) {
+ dump("failed to commit the changes: " + ex + "\n");
+ }
+ }
+}
+
+function SaveAsFile(uris) {
+ let filenames = [];
+
+ for (let uri of uris) {
+ let msgHdr =
+ MailServices.messageServiceFromURI(uri).messageURIToMsgHdr(uri);
+ let nameBase = GenerateFilenameFromMsgHdr(msgHdr);
+ let name = GenerateValidFilename(nameBase, ".eml");
+
+ let number = 2;
+ while (filenames.includes(name)) {
+ // should be unlikely
+ name = GenerateValidFilename(nameBase + "-" + number, ".eml");
+ number++;
+ }
+ filenames.push(name);
+ }
+
+ if (uris.length == 1) {
+ messenger.saveAs(uris[0], true, null, filenames[0]);
+ } else {
+ messenger.saveMessages(filenames, uris);
+ }
+}
+
+function GenerateFilenameFromMsgHdr(msgHdr) {
+ function MakeIS8601ODateString(date) {
+ function pad(n) {
+ return n < 10 ? "0" + n : n;
+ }
+ return (
+ date.getFullYear() +
+ "-" +
+ pad(date.getMonth() + 1) +
+ "-" +
+ pad(date.getDate()) +
+ " " +
+ pad(date.getHours()) +
+ "" +
+ pad(date.getMinutes()) +
+ ""
+ );
+ }
+
+ let filename;
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ filename = msgHdr.mime2DecodedSubject
+ ? "Re: " + msgHdr.mime2DecodedSubject
+ : "Re: ";
+ } else {
+ filename = msgHdr.mime2DecodedSubject;
+ }
+
+ filename += " - ";
+ filename += msgHdr.mime2DecodedAuthor + " - ";
+ filename += MakeIS8601ODateString(new Date(msgHdr.date / 1000));
+
+ return filename;
+}
+
+function saveAsUrlListener(aUri, aIdentity) {
+ this.uri = aUri;
+ this.identity = aIdentity;
+}
+
+saveAsUrlListener.prototype = {
+ OnStartRunningUrl(aUrl) {},
+ OnStopRunningUrl(aUrl, aExitCode) {
+ messenger.saveAs(this.uri, false, this.identity, null);
+ },
+};
+
+function SaveAsTemplate(uri) {
+ if (uri) {
+ let hdr = messenger.msgHdrFromURI(uri);
+ let [identity] = MailUtils.getIdentityForHeader(
+ hdr,
+ Ci.nsIMsgCompType.Template
+ );
+ let templates = MailUtils.getOrCreateFolder(identity.stationeryFolder);
+ if (!templates.parent) {
+ templates.setFlag(Ci.nsMsgFolderFlags.Templates);
+ let isAsync = templates.server.protocolInfo.foldersCreatedAsync;
+ templates.createStorageIfMissing(new saveAsUrlListener(uri, identity));
+ if (isAsync) {
+ return;
+ }
+ }
+ messenger.saveAs(uri, false, identity, null);
+ }
+}
+
+function viewEncryptedPart(message) {
+ let url;
+ try {
+ url = MailServices.mailSession.ConvertMsgURIToMsgURL(message, msgWindow);
+ } catch (e) {
+ console.debug(e);
+ // Couldn't get mail session
+ return false;
+ }
+
+ // Strip out the message-display parameter to ensure that attached emails
+ // display the message source, not the processed HTML.
+ url = url.replace(/type=application\/x-message-display&/, "");
+
+ /**
+ * Save the given string to a file, then open it as an .eml file.
+ *
+ * @param {string} data - The message data.
+ */
+ let msgOpenMessageFromString = function (data) {
+ let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempFile.append("subPart.eml");
+ tempFile.createUnique(0, 0o600);
+
+ let outputStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ outputStream.init(tempFile, 2, 0x200, false); // open as "write only"
+ outputStream.write(data, data.length);
+ outputStream.close();
+
+ // Delete file on exit, because Windows locks the file
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+
+ let url = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .newFileURI(tempFile);
+
+ MailUtils.openEMLFile(window, tempFile, url);
+ };
+
+ function recursiveEmitEncryptedParts(mimeTree) {
+ for (let part of mimeTree.subParts) {
+ const ct = part.headers.contentType.type;
+ if (ct == "multipart/encrypted") {
+ const boundary = part.headers.contentType.get("boundary");
+ let full = `${part.headers.rawHeaderText}\n\n`;
+ for (let subPart of part.subParts) {
+ full += `${boundary}\n${subPart.headers.rawHeaderText}\n\n${subPart.body}\n`;
+ }
+ full += `${boundary}--\n`;
+ msgOpenMessageFromString(full);
+ continue;
+ }
+ recursiveEmitEncryptedParts(part);
+ }
+ }
+
+ EnigmailMime.getMimeTreeFromUrl(url, true, recursiveEmitEncryptedParts);
+ return true;
+}
+
+function viewEncryptedParts(messages) {
+ if (!messages?.length) {
+ dump("viewEncryptedParts(): No messages selected.\n");
+ return false;
+ }
+
+ if (messages.length > 1) {
+ dump("viewEncryptedParts(): Too many messages selected.\n");
+ return false;
+ }
+
+ return viewEncryptedPart(messages[0]);
+}
diff --git a/comm/mail/base/content/mailCommon.js b/comm/mail/base/content/mailCommon.js
new file mode 100644
index 0000000000..b74a3d8fba
--- /dev/null
+++ b/comm/mail/base/content/mailCommon.js
@@ -0,0 +1,1126 @@
+/* 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/. */
+
+// mailContext.js
+/* globals mailContextMenu */
+
+// msgViewNavigation.js
+/* globals CrossFolderNavigation */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ TreeSelection: "chrome://messenger/content/tree-selection.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ConversationOpener: "resource:///modules/ConversationOpener.jsm",
+ DBViewWrapper: "resource:///modules/DBViewWrapper.jsm",
+ EnigmailPersistentCrypto:
+ "chrome://openpgp/content/modules/persistentCrypto.jsm",
+ EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MessageArchiver: "resource:///modules/MessageArchiver.jsm",
+ VirtualFolderHelper: "resource:///modules/VirtualFolderWrapper.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gEncryptedURIService",
+ "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1",
+ "nsIEncryptedSMIMEURIsService"
+);
+
+const nsMsgViewIndex_None = 0xffffffff;
+const nsMsgKey_None = 0xffffffff;
+
+var gDBView, gFolder, gViewWrapper;
+
+var commandController = {
+ _composeCommands: {
+ cmd_editDraftMsg: Ci.nsIMsgCompType.Draft,
+ cmd_newMsgFromTemplate: Ci.nsIMsgCompType.Template,
+ cmd_editTemplateMsg: Ci.nsIMsgCompType.EditTemplate,
+ cmd_newMessage: Ci.nsIMsgCompType.New,
+ cmd_replyGroup: Ci.nsIMsgCompType.ReplyToGroup,
+ cmd_replySender: Ci.nsIMsgCompType.ReplyToSender,
+ cmd_replyall: Ci.nsIMsgCompType.ReplyAll,
+ cmd_replylist: Ci.nsIMsgCompType.ReplyToList,
+ cmd_forwardInline: Ci.nsIMsgCompType.ForwardInline,
+ cmd_forwardAttachment: Ci.nsIMsgCompType.ForwardAsAttachment,
+ cmd_redirect: Ci.nsIMsgCompType.Redirect,
+ cmd_editAsNew: Ci.nsIMsgCompType.EditAsNew,
+ },
+ _navigationCommands: {
+ cmd_goForward: Ci.nsMsgNavigationType.forward,
+ cmd_goBack: Ci.nsMsgNavigationType.back,
+ cmd_nextUnreadMsg: Ci.nsMsgNavigationType.nextUnreadMessage,
+ cmd_nextUnreadThread: Ci.nsMsgNavigationType.nextUnreadThread,
+ cmd_nextMsg: Ci.nsMsgNavigationType.nextMessage,
+ cmd_nextFlaggedMsg: Ci.nsMsgNavigationType.nextFlagged,
+ cmd_previousMsg: Ci.nsMsgNavigationType.previousMessage,
+ cmd_previousUnreadMsg: Ci.nsMsgNavigationType.previousUnreadMessage,
+ cmd_previousFlaggedMsg: Ci.nsMsgNavigationType.previousFlagged,
+ },
+ _viewCommands: {
+ cmd_toggleRead: Ci.nsMsgViewCommandType.toggleMessageRead,
+ cmd_markAsRead: Ci.nsMsgViewCommandType.markMessagesRead,
+ cmd_markAsUnread: Ci.nsMsgViewCommandType.markMessagesUnread,
+ cmd_markThreadAsRead: Ci.nsMsgViewCommandType.markThreadRead,
+ cmd_markAsNotJunk: Ci.nsMsgViewCommandType.unjunk,
+ cmd_watchThread: Ci.nsMsgViewCommandType.toggleThreadWatched,
+ },
+ _callbackCommands: {
+ cmd_cancel() {
+ gFolder
+ .QueryInterface(Ci.nsIMsgNewsFolder)
+ .cancelMessage(gDBView.hdrForFirstSelectedMessage, top.msgWindow);
+ },
+ cmd_openConversation() {
+ new ConversationOpener(window).openConversationForMessages(
+ gDBView.getSelectedMsgHdrs()
+ );
+ },
+ cmd_reply(event) {
+ if (gFolder?.flags & Ci.nsMsgFolderFlags.Newsgroup) {
+ commandController.doCommand("cmd_replyGroup", event);
+ } else {
+ commandController.doCommand("cmd_replySender", event);
+ }
+ },
+ cmd_forward(event) {
+ if (Services.prefs.getIntPref("mail.forward_message_mode", 0) == 0) {
+ commandController.doCommand("cmd_forwardAttachment", event);
+ } else {
+ commandController.doCommand("cmd_forwardInline", event);
+ }
+ },
+ cmd_openMessage(event) {
+ MailUtils.displayMessages(
+ gDBView.getSelectedMsgHdrs(),
+ gViewWrapper,
+ top.document.getElementById("tabmail"),
+ event?.type == "auxclick" && !event?.shiftKey
+ );
+ },
+ cmd_tag() {
+ // Does nothing, just here to enable/disable the tags sub-menu.
+ },
+ cmd_tag1: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 1),
+ cmd_tag2: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 2),
+ cmd_tag3: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 3),
+ cmd_tag4: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 4),
+ cmd_tag5: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 5),
+ cmd_tag6: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 6),
+ cmd_tag7: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 7),
+ cmd_tag8: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 8),
+ cmd_tag9: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 9),
+ cmd_addTag() {
+ mailContextMenu.addTag();
+ },
+ cmd_manageTags() {
+ window.browsingContext.topChromeWindow.openOptionsDialog(
+ "paneGeneral",
+ "tagsCategory"
+ );
+ },
+ cmd_removeTags() {
+ mailContextMenu.removeAllMessageTags();
+ },
+ cmd_toggleTag(event) {
+ mailContextMenu._toggleMessageTag(
+ event.target.value,
+ event.target.getAttribute("checked") == "true"
+ );
+ },
+ cmd_markReadByDate() {
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://messenger/content/markByDate.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen",
+ gFolder
+ );
+ },
+ cmd_markAsFlagged() {
+ gViewWrapper.dbView.doCommand(
+ gDBView.hdrForFirstSelectedMessage.isFlagged
+ ? Ci.nsMsgViewCommandType.unflagMessages
+ : Ci.nsMsgViewCommandType.flagMessages
+ );
+ },
+ cmd_markAsJunk() {
+ if (
+ Services.prefs.getBoolPref("mailnews.ui.junk.manualMarkAsJunkMarksRead")
+ ) {
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.markMessagesRead);
+ }
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.junk);
+ },
+ cmd_markAllRead() {
+ if (gFolder.flags & Ci.nsMsgFolderFlags.Virtual) {
+ top.MsgMarkAllRead(
+ VirtualFolderHelper.wrapVirtualFolder(gFolder).searchFolders
+ );
+ } else {
+ top.MsgMarkAllRead([gFolder]);
+ }
+ },
+ /**
+ * Moves the selected messages to the destination folder.
+ *
+ * @param {nsIMsgFolder} destFolder - the destination folder
+ */
+ cmd_moveMessage(destFolder) {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_moveMessage", destFolder);
+ return;
+ }
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ gViewWrapper.dbView.doCommandWithFolder(
+ Ci.nsMsgViewCommandType.moveMessages,
+ destFolder
+ );
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ destFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", true);
+ },
+ async cmd_copyDecryptedTo(destFolder) {
+ let msgHdrs = gDBView.getSelectedMsgHdrs();
+ if (!msgHdrs || msgHdrs.length === 0) {
+ return;
+ }
+
+ let total = msgHdrs.length;
+ let failures = 0;
+ for (let msgHdr of msgHdrs) {
+ await EnigmailPersistentCrypto.cryptMessage(
+ msgHdr,
+ destFolder.URI,
+ false, // not moving
+ false
+ ).catch(err => {
+ failures++;
+ });
+ }
+
+ if (failures) {
+ let info = await document.l10n.formatValue(
+ "decrypt-and-copy-failures-multiple",
+ {
+ failures,
+ total,
+ }
+ );
+ Services.prompt.alert(null, document.title, info);
+ }
+ },
+ /**
+ * Copies the selected messages to the destination folder.
+ *
+ * @param {nsIMsgFolder} destFolder - the destination folder
+ */
+ cmd_copyMessage(destFolder) {
+ if (window.gMessageURI?.startsWith("file:")) {
+ let file = Services.io
+ .newURI(window.gMessageURI)
+ .QueryInterface(Ci.nsIFileURL).file;
+ MailServices.copy.copyFileMessage(
+ file,
+ destFolder,
+ null,
+ false,
+ Ci.nsMsgMessageFlags.Read,
+ "",
+ null,
+ top.msgWindow
+ );
+ } else {
+ gViewWrapper.dbView.doCommandWithFolder(
+ Ci.nsMsgViewCommandType.copyMessages,
+ destFolder
+ );
+ }
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ destFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", false);
+ },
+ cmd_archive() {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_archive");
+ return;
+ }
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ let archiver = new MessageArchiver();
+ // The instance of nsITransactionManager to use here is tied to msgWindow. Set
+ // this property so the operation can be undone if requested.
+ archiver.msgWindow = top.msgWindow;
+ // Archive the selected message(s).
+ archiver.archiveMessages(gViewWrapper.dbView.getSelectedMsgHdrs());
+ },
+ cmd_moveToFolderAgain() {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_moveToFolderAgain");
+ return;
+ }
+ let folder = MailUtils.getOrCreateFolder(
+ Services.prefs.getStringPref("mail.last_msg_movecopy_target_uri")
+ );
+ if (Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move")) {
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ commandController.doCommand("cmd_moveMessage", folder);
+ } else {
+ commandController.doCommand("cmd_copyMessage", folder);
+ }
+ },
+ cmd_deleteMessage() {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_deleteMessage");
+ return;
+ }
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.deleteMsg);
+ },
+ cmd_shiftDeleteMessage() {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_shiftDeleteMessage");
+ return;
+ }
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.deleteNoTrash);
+ },
+ cmd_createFilterFromMenu() {
+ let msgHdr = gDBView.hdrForFirstSelectedMessage;
+ let emailAddress =
+ MailServices.headerParser.extractHeaderAddressMailboxes(msgHdr.author);
+ if (emailAddress) {
+ top.MsgFilters(emailAddress, msgHdr.folder);
+ }
+ },
+ cmd_viewPageSource() {
+ let uris = window.gMessageURI
+ ? [window.gMessageURI]
+ : gDBView.getURIsForSelection();
+ for (let uri of uris) {
+ // Now, we need to get a URL from a URI
+ let url = MailServices.mailSession.ConvertMsgURIToMsgURL(
+ uri,
+ top.msgWindow
+ );
+
+ // Strip out the message-display parameter to ensure that attached emails
+ // display the message source, not the processed HTML.
+ url = url.replace(/type=application\/x-message-display&/, "");
+ window.openDialog(
+ "chrome://messenger/content/viewSource.xhtml",
+ "_blank",
+ "all,dialog=no",
+ { URL: url }
+ );
+ }
+ },
+ cmd_saveAsFile() {
+ let uris = window.gMessageURI
+ ? [window.gMessageURI]
+ : gDBView.getURIsForSelection();
+ top.SaveAsFile(uris);
+ },
+ cmd_saveAsTemplate() {
+ top.SaveAsTemplate(gDBView.getURIsForSelection()[0]);
+ },
+ cmd_applyFilters() {
+ let curFilterList = gFolder.getFilterList(top.msgWindow);
+ // Create a new filter list and copy over the enabled filters to it.
+ // We do this instead of having the filter after the fact code ignore
+ // disabled filters because the Filter Dialog filter after the fact
+ // code would have to clone filters to allow disabled filters to run,
+ // and we don't support cloning filters currently.
+ let tempFilterList = MailServices.filters.getTempFilterList(gFolder);
+ let numFilters = curFilterList.filterCount;
+ // Make sure the temp filter list uses the same log stream.
+ tempFilterList.loggingEnabled = curFilterList.loggingEnabled;
+ tempFilterList.logStream = curFilterList.logStream;
+ let newFilterIndex = 0;
+ for (let i = 0; i < numFilters; i++) {
+ let curFilter = curFilterList.getFilterAt(i);
+ // Only add enabled, UI visible filters that are in the manual context.
+ if (
+ curFilter.enabled &&
+ !curFilter.temporary &&
+ curFilter.filterType & Ci.nsMsgFilterType.Manual
+ ) {
+ tempFilterList.insertFilterAt(newFilterIndex, curFilter);
+ newFilterIndex++;
+ }
+ }
+ MailServices.filters.applyFiltersToFolders(
+ tempFilterList,
+ [gFolder],
+ top.msgWindow
+ );
+ },
+ cmd_applyFiltersToSelection() {
+ let selectedMessages = gDBView.getSelectedMsgHdrs();
+ if (selectedMessages.length) {
+ MailServices.filters.applyFilters(
+ Ci.nsMsgFilterType.Manual,
+ selectedMessages,
+ gFolder,
+ top.msgWindow
+ );
+ }
+ },
+ cmd_space(event) {
+ let messagePaneBrowser;
+ if (window.messageBrowser) {
+ messagePaneBrowser =
+ window.messageBrowser.contentWindow.getMessagePaneBrowser();
+ } else {
+ messagePaneBrowser = window.getMessagePaneBrowser();
+ }
+ let contentWindow = messagePaneBrowser.contentWindow;
+
+ if (event?.shiftKey) {
+ // If at the start of the message, go to the previous one.
+ if (contentWindow?.scrollY > 0) {
+ contentWindow.scrollByPages(-1);
+ } else if (Services.prefs.getBoolPref("mail.advance_on_spacebar")) {
+ top.goDoCommand("cmd_previousUnreadMsg");
+ }
+ } else if (
+ Math.ceil(contentWindow?.scrollY) < contentWindow?.scrollMaxY
+ ) {
+ // If at the end of the message, go to the next one.
+ contentWindow.scrollByPages(1);
+ } else if (Services.prefs.getBoolPref("mail.advance_on_spacebar")) {
+ top.goDoCommand("cmd_nextUnreadMsg");
+ }
+ },
+ cmd_searchMessages(folder = gFolder) {
+ // We always open a new search dialog for each search command.
+ top.openDialog(
+ "chrome://messenger/content/SearchDialog.xhtml",
+ "_blank",
+ "chrome,resizable,status,centerscreen,dialog=no",
+ { folder }
+ );
+ },
+ },
+ _isCallbackEnabled: {},
+
+ registerCallback(name, callback, isEnabled = true) {
+ this._callbackCommands[name] = callback;
+ this._isCallbackEnabled[name] = isEnabled;
+ },
+
+ supportsCommand(command) {
+ return (
+ command in this._composeCommands ||
+ command in this._navigationCommands ||
+ command in this._viewCommands ||
+ command in this._callbackCommands
+ );
+ },
+ // eslint-disable-next-line complexity
+ isCommandEnabled(command) {
+ let type = typeof this._isCallbackEnabled[command];
+ if (type == "function") {
+ return this._isCallbackEnabled[command]();
+ } else if (type == "boolean") {
+ return this._isCallbackEnabled[command];
+ }
+
+ const hasIdentities = MailServices.accounts.allIdentities.length;
+ switch (command) {
+ case "cmd_newMessage":
+ return hasIdentities;
+ case "cmd_searchMessages":
+ // TODO: This shouldn't be here, or should return false if there are no accounts.
+ return true;
+ case "cmd_space":
+ case "cmd_manageTags":
+ return true;
+ }
+
+ if (!gViewWrapper?.dbView) {
+ return false;
+ }
+
+ let isDummyMessage = !gViewWrapper.isSynthetic && !gFolder;
+
+ if (["cmd_goBack", "cmd_goForward"].includes(command)) {
+ let activeMessageHistory = (
+ window.messageBrowser?.contentWindow ?? window
+ ).messageHistory;
+ let relPos = command === "cmd_goBack" ? -1 : 1;
+ if (relPos === -1 && activeMessageHistory.canPop(0)) {
+ return !isDummyMessage;
+ }
+ return !isDummyMessage && activeMessageHistory.canPop(relPos);
+ }
+
+ if (command in this._navigationCommands) {
+ return !isDummyMessage;
+ }
+
+ let numSelectedMessages = isDummyMessage ? 1 : gDBView.numSelected;
+
+ // Evaluate these properties only if needed, not once for each command.
+ let folder = () => {
+ if (gFolder) {
+ return gFolder;
+ }
+ if (gDBView.numSelected >= 1) {
+ return gDBView.hdrForFirstSelectedMessage?.folder;
+ }
+ return null;
+ };
+ let isNewsgroup = () =>
+ folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, true);
+ let canMove = () =>
+ numSelectedMessages >= 1 &&
+ (folder()?.canDeleteMessages || gViewWrapper.isSynthetic);
+
+ switch (command) {
+ case "cmd_cancel":
+ if (numSelectedMessages == 1 && isNewsgroup()) {
+ // Ensure author of message matches own identity
+ let author = gDBView.hdrForFirstSelectedMessage.mime2DecodedAuthor;
+ return MailServices.accounts
+ .getIdentitiesForServer(folder().server)
+ .some(id => id.fullAddress == author);
+ }
+ return false;
+ case "cmd_openConversation":
+ return (
+ // This (instead of numSelectedMessages) is necessary to be able to
+ // also open a collapsed thread in conversation.
+ gDBView.selection.count == 1 &&
+ ConversationOpener.isMessageIndexed(
+ gDBView.hdrForFirstSelectedMessage
+ )
+ );
+ case "cmd_replylist":
+ if (
+ !mailContextMenu.selectionIsOverridden &&
+ hasIdentities &&
+ numSelectedMessages == 1
+ ) {
+ return (window.messageBrowser?.contentWindow ?? window)
+ .currentHeaderData?.["list-post"];
+ }
+ return false;
+ case "cmd_viewPageSource":
+ case "cmd_saveAsTemplate":
+ return numSelectedMessages == 1;
+ case "cmd_reply":
+ case "cmd_replySender":
+ case "cmd_replyall":
+ case "cmd_forward":
+ case "cmd_forwardInline":
+ case "cmd_forwardAttachment":
+ case "cmd_redirect":
+ case "cmd_editAsNew":
+ return (
+ hasIdentities &&
+ (numSelectedMessages == 1 ||
+ (numSelectedMessages > 1 &&
+ // Exclude collapsed threads.
+ numSelectedMessages == gDBView.selection.count))
+ );
+ case "cmd_copyMessage":
+ case "cmd_saveAsFile":
+ return numSelectedMessages >= 1;
+ case "cmd_openMessage":
+ return (
+ (location.href == "about:3pane" ||
+ parent.location.href == "about:3pane") &&
+ numSelectedMessages >= 1 &&
+ !isDummyMessage
+ );
+ case "cmd_tag":
+ case "cmd_tag1":
+ case "cmd_tag2":
+ case "cmd_tag3":
+ case "cmd_tag4":
+ case "cmd_tag5":
+ case "cmd_tag6":
+ case "cmd_tag7":
+ case "cmd_tag8":
+ case "cmd_tag9":
+ case "cmd_addTag":
+ case "cmd_removeTags":
+ case "cmd_toggleTag":
+ case "cmd_toggleRead":
+ case "cmd_markReadByDate":
+ case "cmd_markAsFlagged":
+ case "cmd_applyFiltersToSelection":
+ return numSelectedMessages >= 1 && !isDummyMessage;
+ case "cmd_copyDecryptedTo": {
+ let showDecrypt = numSelectedMessages > 1;
+ if (numSelectedMessages == 1 && !isDummyMessage) {
+ let msgURI = gDBView.URIForFirstSelectedMessage;
+ if (msgURI) {
+ showDecrypt =
+ EnigmailURIs.isEncryptedUri(msgURI) ||
+ gEncryptedURIService.isEncrypted(msgURI);
+ }
+ }
+ return showDecrypt;
+ }
+ case "cmd_editDraftMsg":
+ return (
+ numSelectedMessages >= 1 &&
+ folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)
+ );
+ case "cmd_newMsgFromTemplate":
+ case "cmd_editTemplateMsg":
+ return (
+ numSelectedMessages >= 1 &&
+ folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Templates, true)
+ );
+ case "cmd_replyGroup":
+ return isNewsgroup();
+ case "cmd_markAsRead":
+ return (
+ numSelectedMessages >= 1 &&
+ !isDummyMessage &&
+ gViewWrapper.dbView.getSelectedMsgHdrs().some(msg => !msg.isRead)
+ );
+ case "cmd_markAsUnread":
+ return (
+ numSelectedMessages >= 1 &&
+ !isDummyMessage &&
+ gViewWrapper.dbView.getSelectedMsgHdrs().some(msg => msg.isRead)
+ );
+ case "cmd_markThreadAsRead": {
+ if (numSelectedMessages == 0 || isDummyMessage) {
+ return false;
+ }
+ let sel = gViewWrapper.dbView.selection;
+ for (let i = 0; i < sel.getRangeCount(); i++) {
+ let start = {};
+ let end = {};
+ sel.getRangeAt(i, start, end);
+ for (let j = start.value; j <= end.value; j++) {
+ if (
+ gViewWrapper.dbView.getThreadContainingIndex(j)
+ .numUnreadChildren > 0
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+ case "cmd_markAllRead":
+ return gDBView?.msgFolder?.getNumUnread(false) > 0;
+ case "cmd_markAsJunk":
+ case "cmd_markAsNotJunk":
+ return this._getViewCommandStatus(Ci.nsMsgViewCommandType.junk);
+ case "cmd_archive":
+ return (
+ !isDummyMessage &&
+ MessageArchiver.canArchive(
+ gDBView.getSelectedMsgHdrs(),
+ gViewWrapper.isSingleFolder
+ )
+ );
+ case "cmd_moveMessage": {
+ return canMove();
+ }
+ case "cmd_moveToFolderAgain": {
+ // Disable "Move to <folder> Again" for news and other read only
+ // folders since we can't really move messages from there - only copy.
+ let canMoveAgain = numSelectedMessages >= 1;
+ if (Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move")) {
+ canMoveAgain = canMove() && !isNewsgroup();
+ }
+ if (canMoveAgain) {
+ let targetURI = Services.prefs.getStringPref(
+ "mail.last_msg_movecopy_target_uri"
+ );
+ canMoveAgain = targetURI && MailUtils.getExistingFolder(targetURI);
+ }
+ return !!canMoveAgain;
+ }
+ case "cmd_deleteMessage":
+ return canMove();
+ case "cmd_shiftDeleteMessage":
+ return this._getViewCommandStatus(
+ Ci.nsMsgViewCommandType.deleteNoTrash
+ );
+ case "cmd_createFilterFromMenu":
+ return (
+ numSelectedMessages == 1 &&
+ !isDummyMessage &&
+ folder()?.server.canHaveFilters
+ );
+ case "cmd_watchThread": {
+ let enabledObj = {};
+ let checkStatusObj = {};
+ gViewWrapper.dbView.getCommandStatus(
+ Ci.nsMsgViewCommandType.toggleThreadWatched,
+ enabledObj,
+ checkStatusObj
+ );
+ return enabledObj.value;
+ }
+ case "cmd_applyFilters": {
+ return this._getViewCommandStatus(Ci.nsMsgViewCommandType.applyFilters);
+ }
+ }
+
+ return false;
+ },
+ doCommand(command, ...args) {
+ if (!this.isCommandEnabled(command)) {
+ return;
+ }
+
+ if (command in this._composeCommands) {
+ this._composeMsgByType(this._composeCommands[command], ...args);
+ return;
+ }
+
+ if (command in this._navigationCommands) {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand(command, ...args);
+ } else {
+ this._navigate(this._navigationCommands[command]);
+ }
+ return;
+ }
+
+ if (command in this._viewCommands) {
+ if (command.endsWith("Read") || command.endsWith("Unread")) {
+ if (window.ClearPendingReadTimer) {
+ window.ClearPendingReadTimer();
+ } else {
+ window.messageBrowser.contentWindow.ClearPendingReadTimer();
+ }
+ }
+ gViewWrapper.dbView.doCommand(this._viewCommands[command]);
+ return;
+ }
+
+ if (command in this._callbackCommands) {
+ this._callbackCommands[command](...args);
+ }
+ },
+
+ _getViewCommandStatus(commandType) {
+ if (!gViewWrapper?.dbView) {
+ return false;
+ }
+
+ let enabledObj = {};
+ let checkStatusObj = {};
+ gViewWrapper.dbView.getCommandStatus(
+ commandType,
+ enabledObj,
+ checkStatusObj
+ );
+ return enabledObj.value;
+ },
+
+ /**
+ * Calls the ComposeMessage function with the desired type, and proper default
+ * based on the event that fired it.
+ *
+ * @param composeType the nsIMsgCompType to pass to the function
+ * @param event (optional) the event that triggered the call
+ */
+ _composeMsgByType(composeType, event) {
+ // If we're the hidden window, then we're not going to have a gFolderDisplay
+ // to work out existing folders, so just use null.
+ let msgFolder = gFolder;
+ let msgUris =
+ gFolder || gViewWrapper.isSynthetic
+ ? gDBView?.getURIsForSelection()
+ : [window.gMessageURI];
+
+ let messagePaneBrowser;
+ let autodetectCharset;
+ let selection;
+ if (!mailContextMenu.selectionIsOverridden) {
+ if (window.messageBrowser) {
+ if (!window.messageBrowser.hidden) {
+ messagePaneBrowser =
+ window.messageBrowser.contentWindow.getMessagePaneBrowser();
+ autodetectCharset =
+ window.messageBrowser.contentWindow.autodetectCharset;
+ }
+ } else {
+ messagePaneBrowser = window.getMessagePaneBrowser();
+ autodetectCharset = window.autodetectCharset;
+ }
+ selection = messagePaneBrowser?.contentWindow?.getSelection();
+ }
+
+ if (event && event.shiftKey) {
+ window.browsingContext.topChromeWindow.ComposeMessage(
+ composeType,
+ Ci.nsIMsgCompFormat.OppositeOfDefault,
+ msgFolder,
+ msgUris,
+ selection,
+ autodetectCharset
+ );
+ } else {
+ window.browsingContext.topChromeWindow.ComposeMessage(
+ composeType,
+ Ci.nsIMsgCompFormat.Default,
+ msgFolder,
+ msgUris,
+ selection,
+ autodetectCharset
+ );
+ }
+ },
+
+ _navigate(navigationType) {
+ if (
+ [Ci.nsMsgNavigationType.back, Ci.nsMsgNavigationType.forward].includes(
+ navigationType
+ )
+ ) {
+ const { messageHistory } = window.messageBrowser?.contentWindow ?? window;
+ const noCurrentMessage = messageHistory.canPop(0);
+ let relativePosition = -1;
+ if (navigationType === Ci.nsMsgNavigationType.forward) {
+ relativePosition = 1;
+ } else if (noCurrentMessage) {
+ relativePosition = 0;
+ }
+ let newMessageURI = messageHistory.pop(relativePosition)?.messageURI;
+ if (!newMessageURI) {
+ return;
+ }
+ let msgHdr =
+ MailServices.messageServiceFromURI(newMessageURI).messageURIToMsgHdr(
+ newMessageURI
+ );
+ if (msgHdr) {
+ if (window.threadPane) {
+ window.selectMessage(msgHdr);
+ } else {
+ window.displayMessage(newMessageURI);
+ }
+ }
+ return;
+ }
+
+ let resultKey = { value: nsMsgKey_None };
+ let resultIndex = { value: nsMsgViewIndex_None };
+ let threadIndex = {};
+
+ let expandCurrentThread = false;
+ let currentIndex = window.threadTree ? window.threadTree.currentIndex : -1;
+
+ // If we're doing next unread, and a collapsed thread is selected, and
+ // the top level message is unread, just set the result manually to
+ // the top level message, without using viewNavigate.
+ if (
+ navigationType == Ci.nsMsgNavigationType.nextUnreadMessage &&
+ currentIndex != -1 &&
+ gViewWrapper.isCollapsedThreadAtIndex(currentIndex) &&
+ !(
+ gViewWrapper.dbView.getFlagsAt(currentIndex) & Ci.nsMsgMessageFlags.Read
+ )
+ ) {
+ expandCurrentThread = true;
+ resultIndex.value = currentIndex;
+ resultKey.value = gViewWrapper.dbView.getKeyAt(currentIndex);
+ } else {
+ gViewWrapper.dbView.viewNavigate(
+ navigationType,
+ resultKey,
+ resultIndex,
+ threadIndex,
+ true
+ );
+ if (resultIndex.value == nsMsgViewIndex_None) {
+ if (CrossFolderNavigation(navigationType)) {
+ this._navigate(navigationType);
+ }
+ return;
+ }
+ if (resultKey.value == nsMsgKey_None) {
+ return;
+ }
+ }
+
+ if (window.threadTree) {
+ if (
+ gDBView.selection.count == 1 &&
+ window.threadTree.selectedIndex == resultIndex.value &&
+ !expandCurrentThread
+ ) {
+ return;
+ }
+
+ window.threadTree.expandRowAtIndex(resultIndex.value);
+ // Do an instant scroll before setting the index to avoid animation.
+ window.threadTree.scrollToIndex(resultIndex.value, true);
+ window.threadTree.selectedIndex = resultIndex.value;
+ // Focus the thread tree, unless the message pane has focus.
+ if (
+ Services.focus.focusedWindow !=
+ window.messageBrowser.contentWindow?.getMessagePaneBrowser()
+ .contentWindow
+ ) {
+ // There's something strange going on here – calling `focus`
+ // immediately can cause the scroll position to return to where it was
+ // before changing folders, which starts a cascade of "scroll" events
+ // until the tree scrolls to the top.
+ setTimeout(() => window.threadTree.table.body.focus());
+ }
+ } else {
+ if (window.gMessage.messageKey == resultKey.value) {
+ return;
+ }
+
+ gViewWrapper.dbView.selection.select(resultIndex.value);
+ window.displayMessage(
+ gViewWrapper.dbView.URIForFirstSelectedMessage,
+ gViewWrapper
+ );
+ }
+ },
+};
+// Add the controller to this window's controllers, so that built-in commands
+// such as cmd_selectAll run our code instead of the default code.
+window.controllers.insertControllerAt(0, commandController);
+
+var dbViewWrapperListener = {
+ _nextViewIndexAfterDelete: null,
+
+ messenger: null,
+ msgWindow: top.msgWindow,
+ threadPaneCommandUpdater: {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMsgDBViewCommandUpdater",
+ "nsISupportsWeakReference",
+ ]),
+ updateCommandStatus() {},
+ displayMessageChanged(folder, subject, keywords) {},
+ updateNextMessageAfterDelete() {
+ dbViewWrapperListener._nextViewIndexAfterDelete = gDBView
+ ? gDBView.msgToSelectAfterDelete
+ : null;
+ },
+ summarizeSelection() {
+ return true;
+ },
+ selectedMessageRemoved() {
+ // We need to invalidate the tree, but this method could get called
+ // multiple times, so we won't invalidate until we get to the end of the
+ // event loop.
+ if (this._timeout) {
+ return;
+ }
+ this._timeout = setTimeout(() => {
+ dbViewWrapperListener.onMessagesRemoved();
+ window.threadTree?.invalidate();
+ delete this._timeout;
+ });
+ },
+ },
+
+ get shouldUseMailViews() {
+ return !!top.ViewPickerBinding?.isVisible;
+ },
+ get shouldDeferMessageDisplayUntilAfterServerConnect() {
+ return false;
+ },
+ shouldMarkMessagesReadOnLeavingFolder(msgFolder) {
+ return false;
+ },
+ onFolderLoading(isFolderLoading) {},
+ onSearching(isSearching) {},
+ onCreatedView() {
+ if (window.threadTree) {
+ window.threadPane.setTreeView(gViewWrapper.dbView);
+ // There is no persisted thread last expanded state for synthetic views.
+ if (!gViewWrapper.isSynthetic) {
+ window.threadPane.restoreThreadState();
+ }
+ window.threadPane.isFirstScroll = true;
+ window.threadPane.scrollDetected = false;
+ window.threadPane.scrollToLatestRowIfNoSelection();
+ }
+ },
+ onDestroyingView(folderIsComingBack) {
+ if (!window.threadTree) {
+ return;
+ }
+
+ if (folderIsComingBack) {
+ // We'll get a new view of the same folder (e.g. with a quick filter) -
+ // try to preserve the selection.
+ window.threadPane.saveSelection();
+ } else {
+ if (gDBView) {
+ gDBView.setJSTree(null);
+ }
+ window.threadTree.view = gDBView = null;
+ }
+ },
+ onLoadingFolder(dbFolderInfo) {
+ window.quickFilterBar?.onFolderChanged();
+ },
+ onDisplayingFolder() {},
+ onLeavingFolder() {},
+ onMessagesLoaded(all) {
+ if (!window.threadPane) {
+ return;
+ }
+ // Try to restore what was selected. Keep the saved selection (if there is
+ // one) until we have all of the messages. This will also reveal selected
+ // messages in collapsed threads.
+ window.threadPane.restoreSelection({ discard: all });
+
+ if (all || gViewWrapper.search.hasSearchTerms) {
+ window.threadPane.ensureThreadStateForQuickSearchView();
+ let newMessageFound = false;
+ if (window.threadPane.scrollToNewMessage) {
+ try {
+ let index = gDBView.findIndexOfMsgHdr(gFolder.firstNewMessage, true);
+ if (index != nsMsgViewIndex_None) {
+ window.threadTree.scrollToIndex(index, true);
+ newMessageFound = true;
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ window.threadPane.scrollToNewMessage = false;
+ }
+ window.threadTree.reset();
+ if (!newMessageFound && !window.threadPane.scrollDetected) {
+ window.threadPane.scrollToLatestRowIfNoSelection();
+ }
+ }
+ window.quickFilterBar?.onMessagesChanged();
+ },
+ onMailViewChanged() {
+ window.dispatchEvent(new CustomEvent("MailViewChanged"));
+ },
+ onSortChanged() {
+ // If there is no selection, scroll to the most relevant end.
+ window.threadPane?.scrollToLatestRowIfNoSelection();
+ },
+ onMessagesRemoved() {
+ window.quickFilterBar?.onMessagesChanged();
+
+ if (!gDBView || (!gFolder && !gViewWrapper?.isSynthetic)) {
+ // This can't be a notification about the message currently displayed.
+ return;
+ }
+
+ let rowCount = gDBView.rowCount;
+
+ // There's no messages left.
+ if (rowCount == 0) {
+ if (location.href == "about:3pane") {
+ // In a 3-pane tab, clear the message pane and selection.
+ window.threadTree.selectedIndex = -1;
+ } else if (parent?.location != "about:3pane") {
+ // In a standalone message tab or window, close the tab or window.
+ let tabmail = top.document.getElementById("tabmail");
+ if (tabmail) {
+ tabmail.closeTab(window.tabOrWindow);
+ } else {
+ top.close();
+ }
+ }
+ this._nextViewIndexAfterDelete = null;
+ return;
+ }
+
+ if (
+ this._nextViewIndexAfterDelete != null &&
+ this._nextViewIndexAfterDelete != nsMsgViewIndex_None
+ ) {
+ // Select the next message in the view, based on what we were told in
+ // updateNextMessageAfterDelete.
+ if (this._nextViewIndexAfterDelete >= rowCount) {
+ this._nextViewIndexAfterDelete = rowCount - 1;
+ }
+ if (
+ this._nextViewIndexAfterDelete > -1 &&
+ !mailContextMenu.selectionIsOverridden
+ ) {
+ if (location.href == "about:3pane") {
+ // A "select" event should fire here, but setting the selected index
+ // might not fire it. OTOH, we want it to fire only once, so see if
+ // the event is fired, and if not, fire it.
+ let eventFired = false;
+ let onSelect = () => (eventFired = true);
+
+ window.threadTree.addEventListener("select", onSelect, {
+ once: true,
+ });
+ window.threadTree.selectedIndex = this._nextViewIndexAfterDelete;
+ window.threadTree.removeEventListener("select", onSelect);
+
+ if (!eventFired) {
+ window.threadTree.dispatchEvent(new CustomEvent("select"));
+ }
+ } else if (parent?.location != "about:3pane") {
+ if (
+ Services.prefs.getBoolPref("mail.close_message_window.on_delete")
+ ) {
+ // Bail out early if this is about a partial POP3 message that has
+ // just been completed and reloaded.
+ if (document.body.classList.contains("completed-message")) {
+ document.body.classList.remove("completed-message");
+ return;
+ }
+ // Close the tab or window if the displayed message is deleted.
+ let tabmail = top.document.getElementById("tabmail");
+ if (tabmail) {
+ tabmail.closeTab(window.tabOrWindow);
+ } else {
+ top.close();
+ }
+ return;
+ }
+ gDBView.selection.select(this._nextViewIndexAfterDelete);
+ window.displayMessage(
+ gDBView.getURIForViewIndex(this._nextViewIndexAfterDelete),
+ gViewWrapper
+ );
+ }
+ }
+ this._nextViewIndexAfterDelete = null;
+ }
+ },
+ onMessageRemovalFailed() {
+ this._nextViewIndexAfterDelete = null;
+ },
+ onMessageCountsChanged() {
+ window.quickFilterBar?.onMessagesChanged();
+ },
+};
diff --git a/comm/mail/base/content/mailContext.inc.xhtml b/comm/mail/base/content/mailContext.inc.xhtml
new file mode 100644
index 0000000000..989cdd710e
--- /dev/null
+++ b/comm/mail/base/content/mailContext.inc.xhtml
@@ -0,0 +1,324 @@
+# 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/.
+
+ <menupopup id="mailContext" needsgutter="true">
+ <!-- Links -->
+ <menuitem id="mailContext-openInBrowser"
+ class="menuitem-iconic"
+ label="&openInBrowser.label;"
+ accesskey="&openInBrowser.accesskey;"/>
+ <menuitem id="mailContext-openLinkInBrowser"
+ class="menuitem-iconic"
+ label="&openLinkInBrowser.label;"
+ accesskey="&openLinkInBrowser.accesskey;"/>
+ <menuitem id="mailContext-copylink"
+ class="menuitem-iconic"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"/>
+ <menuitem id="mailContext-savelink"
+ class="menuitem-iconic"
+ label="&saveLinkAsCmd.label;"
+ accesskey="&saveLinkAsCmd.accesskey;"/>
+ <menuitem id="mailContext-reportPhishingURL"
+ class="menuitem-iconic"
+ label="&reportPhishingURL.label;"
+ accesskey="&reportPhishingURL.accesskey;"/>
+ <menuitem id="mailContext-addemail"
+ class="menuitem-iconic"
+ label="&AddToAddressBook.label;"
+ accesskey="&AddToAddressBook.accesskey;"/>
+ <menuitem id="mailContext-composeemailto"
+ class="menuitem-iconic"
+ label="&SendMessageTo.label;"
+ accesskey="&SendMessageTo.accesskey;"/>
+ <menuitem id="mailContext-copyemail"
+ class="menuitem-iconic"
+ label="&copyEmailCmd.label;"
+ accesskey="&copyEmailCmd.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Images -->
+ <menuitem id="mailContext-copyimage"
+ class="menuitem-iconic"
+ label="&copyImageAllCmd.label;"
+ accesskey="&copyImageAllCmd.accesskey;"/>
+ <menuitem id="mailContext-saveimage"
+ class="menuitem-iconic"
+ label="&saveImageAsCmd.label;"
+ accesskey="&saveImageAsCmd.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Edit -->
+ <menuitem id="mailContext-copy"
+ class="menuitem-iconic"
+ data-l10n-id="text-action-copy"/>
+ <menuitem id="mailContext-selectall"
+ class="menuitem-iconic"
+ data-l10n-id="text-action-select-all"/>
+ <menuseparator/>
+
+ <!-- Search -->
+ <menuitem id="mailContext-searchTheWeb"
+ class="menuitem-iconic"/>
+ <menuseparator/>
+
+ <!-- Drafts/templates -->
+ <menuitem id="mailContext-editDraftMsg"
+ class="menuitem-iconic"
+ label="&contextEditDraftMsg.label;"
+ default="true"/>
+ <menuitem id="mailContext-newMsgFromTemplate"
+ class="menuitem-iconic"
+ label="&contextNewMsgFromTemplate.label;"
+ default="true"/>
+ <menuitem id="mailContext-editTemplateMsg"
+ class="menuitem-iconic"
+ label="&contextEditTemplate.label;"
+ accesskey="&contextEditTemplate.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Open messages -->
+ <menuitem id="mailContext-openNewTab"
+ class="menuitem-iconic"
+ label="&contextOpenNewTab.label;"
+ accesskey="&contextOpenNewTab.accesskey;"/>
+ <menuitem id="mailContext-openNewWindow"
+ class="menuitem-iconic"
+ label="&contextOpenNewWindow.label;"
+ accesskey="&contextOpenNewWindow.accesskey;"/>
+ <menuitem id="mailContext-openConversation"
+ class="menuitem-iconic"
+ label="&contextOpenConversation.label;"
+ accesskey="&contextOpenConversation.accesskey;"/>
+ <menuitem id="mailContext-openContainingFolder"
+ class="menuitem-iconic"
+ label="&contextOpenContainingFolder.label;"
+ accesskey="&contextOpenContainingFolder.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Reply/forward/redirect -->
+ <menuitem id="mailContext-replyNewsgroup"
+ class="menuitem-iconic"
+ label="&contextReplyNewsgroup2.label;"
+ accesskey="&contextReplyNewsgroup2.accesskey;"/>
+ <menuitem id="mailContext-replySender"
+ class="menuitem-iconic"
+ label="&contextReplySender.label;"
+ accesskey="&contextReplySender.accesskey;"/>
+ <menuitem id="mailContext-replyAll"
+ class="menuitem-iconic"
+ label="&contextReplyAll.label;"
+ accesskey="&contextReplyAll.accesskey;"/>
+ <menuitem id="mailContext-replyList"
+ class="menuitem-iconic"
+ label="&contextReplyList.label;"
+ accesskey="&contextReplyList.accesskey;"/>
+ <menuitem id="mailContext-forward"
+ class="menuitem-iconic"
+ label="&contextForward.label;"
+ accesskey="&contextForward.accesskey;"/>
+ <menu id="mailContext-forwardAsMenu"
+ class="menu-iconic"
+ label="&contextForwardAsMenu.label;"
+ accesskey="&contextForwardAsMenu.accesskey;">
+ <menupopup id="mailContext-forwardAsPopup">
+ <menuitem id="mailContext-forwardAsInline"
+ label="&contextForwardAsInline.label;"
+ accesskey="&contextForwardAsInline.accesskey;"/>
+ <menuitem id="mailContext-forwardAsAttachment"
+ label="&contextForwardAsAttachmentItem.label;"
+ accesskey="&contextForwardAsAttachmentItem.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menuitem id="mailContext-multiForwardAsAttachment"
+ class="menuitem-iconic"
+ label="&contextMultiForwardAsAttachment.label;"
+ accesskey="&contextMultiForwardAsAttachment.accesskey;"/>
+ <menuitem id="mailContext-redirect"
+ class="menuitem-iconic"
+ data-l10n-id="context-menu-redirect-msg"/>
+ <menuitem id="mailContext-cancel"
+ class="menuitem-iconic"
+ data-l10n-id="context-menu-cancel-msg"/>
+ <menuitem id="mailContext-editAsNew"
+ class="menuitem-iconic"
+ label="&contextEditMsgAsNew.label;"
+ accesskey="&contextEditMsgAsNew.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Tags/mark sub-menus -->
+ <menu id="mailContext-tags"
+ class="menu-iconic"
+ label="&tagMenu.label;"
+ accesskey="&tagMenu.accesskey;">
+ <menupopup id="mailContext-tagpopup" needsgutter="true">
+ <menuitem id="mailContext-addNewTag"
+ class="menuitem-iconic"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"/>
+ <menuitem id="mailContext-manageTags"
+ class="menuitem-iconic"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="mailContext-tagRemoveAll"
+ class="menuitem-iconic"
+ accesskey="&tagCmd0.key;"/>
+ <menuseparator/>
+ </menupopup>
+ </menu>
+ <menu id="mailContext-mark"
+ class="menu-iconic"
+ label="&markMenu.label;"
+ accesskey="&markMenu.accesskey;">
+ <menupopup id="mailContext-markPopup">
+ <menuitem id="mailContext-markRead"
+ class="menuitem-iconic"
+ label="&markAsReadCmd.label;"
+ accesskey="&markAsReadCmd.accesskey;"/>
+ <menuitem id="mailContext-markUnread"
+ class="menuitem-iconic"
+ label="&markAsUnreadCmd.label;"
+ accesskey="&markAsUnreadCmd.accesskey;"/>
+ <menuitem id="mailContext-markThreadAsRead"
+ class="menuitem-iconic"
+ label="&markThreadAsReadCmd.label;"
+ accesskey="&markThreadAsReadCmd.accesskey;"/>
+ <menuitem id="mailContext-markReadByDate"
+ class="menuitem-iconic"
+ label="&markReadByDateCmd.label;"
+ accesskey="&markReadByDateCmd.accesskey;"/>
+ <menuitem id="mailContext-markAllRead"
+ class="menuitem-iconic"
+ label="&markAllReadCmd.label;"
+ accesskey="&markAllReadCmd.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="mailContext-markFlagged"
+ type="checkbox"
+ label="&markStarredCmd.label;"
+ accesskey="&markStarredCmd.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="mailContext-markAsJunk"
+ class="menuitem-iconic"
+ label="&markAsJunkCmd.label;"
+ accesskey="&markAsJunkCmd.accesskey;"/>
+ <menuitem id="mailContext-markAsNotJunk"
+ class="menuitem-iconic"
+ label="&markAsNotJunkCmd.label;"
+ accesskey="&markAsNotJunkCmd.accesskey;"/>
+ <menuitem id="mailContext-recalculateJunkScore"
+ class="menuitem-iconic"
+ label="&recalculateJunkScoreCmd.label;"
+ accesskey="&recalculateJunkScoreCmd.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+
+ <!-- Move/copy/archive/convert/delete -->
+ <menuitem id="mailContext-copyMessageUrl"
+ class="menuitem-iconic"
+ label="&copyMessageLocation.label;"
+ accesskey="&copyMessageLocation.accesskey;"/>
+ <menuitem id="mailContext-archive"
+ class="menuitem-iconic"
+ label="&contextArchive.label;"
+ accesskey="&contextArchive.accesskey;"/>
+ <menu id="mailContext-moveMenu"
+ class="menu-iconic"
+ label="&contextMoveMsgMenu.label;"
+ accesskey="&contextMoveMsgMenu.accesskey;">
+ <menupopup is="folder-menupopup" id="mailContext-fileHereMenu"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menu id="mailContext-copyMenu"
+ class="menu-iconic"
+ label="&contextCopyMsgMenu.label;"
+ accesskey="&contextCopyMsgMenu.accesskey;">
+ <menupopup is="folder-menupopup" id="mailContext-copyHereMenu"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menuitem id="mailContext-moveToFolderAgain"
+ class="menuitem-iconic"
+ label="&moveToFolderAgain.label;"
+ accesskey="&moveToFolderAgain.accesskey;"/>
+
+ <menu id="mailContext-decryptToFolder"
+ class="menu-iconic"
+ data-l10n-id="context-menu-decrypt-to-folder2"
+ data-l10n-attrs="accesskey">
+ <menupopup is="folder-menupopup"
+ id="mailContext-decryptToTargetFolder"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"
+ hasbeenopened="false" />
+ </menu>
+
+ <menu id="mailContext-calendar-convert-menu"
+ class="menu-iconic hide-when-calendar-deactivated"
+ label="&calendar.context.convertmenu.label;"
+ accesskey="&calendar.context.convertmenu.accesskey.mail;">
+ <menupopup id="mailContext-calendar-convert-menupopup">
+ <menuitem id="mailContext-calendar-convert-event-menuitem"
+ label="&calendar.context.convertmenu.event.label;"
+ accesskey="&calendar.context.convertmenu.event.accesskey;"/>
+ <menuitem id="mailContext-calendar-convert-task-menuitem"
+ label="&calendar.context.convertmenu.task.label;"
+ accesskey="&calendar.context.convertmenu.task.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menuitem id="mailContext-delete"
+ class="menuitem-iconic"/>
+ <menuseparator/>
+
+ <!-- Threads -->
+ <menuitem id="mailContext-ignoreThread"
+ type="checkbox"
+ label="&contextKillThreadMenu.label;"
+ accesskey="&contextKillThreadMenu.accesskey;"/>
+ <menuitem id="mailContext-ignoreSubthread"
+ type="checkbox"
+ label="&contextKillSubthreadMenu.label;"
+ accesskey="&contextKillSubthreadMenu.accesskey;"/>
+ <menuitem id="mailContext-watchThread"
+ type="checkbox"
+ label="&contextWatchThreadMenu.label;"
+ accesskey="&contextWatchThreadMenu.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Save/print/download -->
+ <menuitem id="mailContext-saveAs"
+ class="menuitem-iconic"
+ label="&contextSaveAs.label;"
+ accesskey="&contextSaveAs.accesskey;"/>
+ <menuitem id="mailContext-print"
+ class="menuitem-iconic"
+ label="&contextPrint.label;"
+ accesskey="&contextPrint.accesskey;"
+ observes="cmd_print"/>
+ <menuitem id="mailContext-downloadSelected"
+ class="menuitem-iconic"
+ label="&downloadSelectedCmd.label;"
+ accesskey="&downloadSelectedCmd.accesskey;"/>
+ </menupopup>
diff --git a/comm/mail/base/content/mailContext.js b/comm/mail/base/content/mailContext.js
new file mode 100644
index 0000000000..7b2a050b1e
--- /dev/null
+++ b/comm/mail/base/content/mailContext.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/. */
+
+// mailCommon.js
+/* globals commandController */
+
+// about:3pane and about:message must BOTH provide these:
+
+/* globals goDoCommand */ // globalOverlay.js
+/* globals gDBView, gFolder, gViewWrapper, messengerBundle */
+
+/* globals gEncryptedURIService */ // mailCommon.js
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ calendarDeactivator:
+ "resource:///modules/calendar/calCalendarDeactivator.jsm",
+ EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ PhishingDetector: "resource:///modules/PhishingDetector.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+/**
+ * Called by ContextMenuParent if this window is about:3pane, or is
+ * about:message but not contained by about:3pane.
+ *
+ * @returns {boolean} true if this function opened the context menu
+ */
+function openContextMenu({ data, target }, browser) {
+ if (window.browsingContext.parent != window.browsingContext.top) {
+ // Not sure how we'd get here, but let's not continue if we do.
+ return false;
+ }
+
+ if (browser.getAttribute("context") != "mailContext") {
+ return false;
+ }
+
+ mailContextMenu.setAsMessagePaneContextMenu(data, target.browsingContext);
+ let screenX = data.context.screenXDevPx / window.devicePixelRatio;
+ let screenY = data.context.screenYDevPx / window.devicePixelRatio;
+ let popup = document.getElementById("mailContext");
+ popup.openPopupAtScreen(screenX, screenY, true);
+
+ return true;
+}
+
+var mailContextMenu = {
+ /**
+ * @type {XULPopupElement}
+ */
+ _menupopup: null,
+
+ // Commands handled by commandController.
+ _commands: {
+ "mailContext-editDraftMsg": "cmd_editDraftMsg",
+ "mailContext-newMsgFromTemplate": "cmd_newMsgFromTemplate",
+ "mailContext-editTemplateMsg": "cmd_editTemplateMsg",
+ "mailContext-openConversation": "cmd_openConversation",
+ "mailContext-replyNewsgroup": "cmd_replyGroup",
+ "mailContext-replySender": "cmd_replySender",
+ "mailContext-replyAll": "cmd_replyall",
+ "mailContext-replyList": "cmd_replylist",
+ "mailContext-forward": "cmd_forward",
+ "mailContext-forwardAsInline": "cmd_forwardInline",
+ "mailContext-forwardAsAttachment": "cmd_forwardAttachment",
+ "mailContext-multiForwardAsAttachment": "cmd_forwardAttachment",
+ "mailContext-redirect": "cmd_redirect",
+ "mailContext-cancel": "cmd_cancel",
+ "mailContext-editAsNew": "cmd_editAsNew",
+ "mailContext-addNewTag": "cmd_addTag",
+ "mailContext-manageTags": "cmd_manageTags",
+ "mailContext-tagRemoveAll": "cmd_removeTags",
+ "mailContext-markReadByDate": "cmd_markReadByDate",
+ "mailContext-markFlagged": "cmd_markAsFlagged",
+ "mailContext-archive": "cmd_archive",
+ "mailContext-moveToFolderAgain": "cmd_moveToFolderAgain",
+ "mailContext-decryptToFolder": "cmd_copyDecryptedTo",
+ "mailContext-delete": "cmd_deleteMessage",
+ "mailContext-ignoreThread": "cmd_killThread",
+ "mailContext-ignoreSubthread": "cmd_killSubthread",
+ "mailContext-watchThread": "cmd_watchThread",
+ "mailContext-saveAs": "cmd_saveAsFile",
+ "mailContext-print": "cmd_print",
+ "mailContext-downloadSelected": "cmd_downloadSelected",
+ },
+
+ // More commands handled by commandController, except these ones get
+ // disabled instead of hidden.
+ _alwaysVisibleCommands: {
+ "mailContext-markRead": "cmd_markAsRead",
+ "mailContext-markUnread": "cmd_markAsUnread",
+ "mailContext-markThreadAsRead": "cmd_markThreadAsRead",
+ "mailContext-markAllRead": "cmd_markAllRead",
+ "mailContext-markAsJunk": "cmd_markAsJunk",
+ "mailContext-markAsNotJunk": "cmd_markAsNotJunk",
+ "mailContext-recalculateJunkScore": "cmd_recalculateJunkScore",
+ },
+
+ /**
+ * If we have overridden the selection for the context menu.
+ *
+ * @see `setOverrideSelection`
+ * @type {boolean}
+ */
+ _selectionIsOverridden: false,
+
+ init() {
+ this._menupopup = document.getElementById("mailContext");
+ this._menupopup.addEventListener("popupshowing", this);
+ this._menupopup.addEventListener("popuphidden", this);
+ this._menupopup.addEventListener("command", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshowing":
+ this.onPopupShowing(event);
+ break;
+ case "popuphidden":
+ this.onPopupHidden(event);
+ break;
+ case "command":
+ this.onCommand(event);
+ break;
+ }
+ },
+
+ onPopupShowing(event) {
+ if (event.target == this._menupopup) {
+ this.fillMailContextMenu(event);
+ }
+ },
+
+ onPopupHidden(event) {
+ if (event.target == this._menupopup) {
+ this.clearOverrideSelection();
+ }
+ },
+
+ onCommand(event) {
+ this.onMailContextMenuCommand(event);
+ },
+
+ /**
+ * Override the selection that this context menu should operate on. The
+ * effect lasts until `clearOverrideSelection` is called by `onPopupHidden`.
+ *
+ * @param {integer} index - The index of the row to use as selection.
+ */
+ setOverrideSelection(index) {
+ this._selectionIsOverridden = true;
+ window.threadPane.saveSelection();
+ window.threadTree._selection.selectEventsSuppressed = true;
+ window.threadTree._selection.select(index);
+ },
+
+ /**
+ * Has the real selection been overridden by a right-click on a message that
+ * wasn't selected?
+ *
+ * @type {boolean}
+ */
+ get selectionIsOverridden() {
+ return this._selectionIsOverridden;
+ },
+
+ /**
+ * Clear the overriding selection, and go back to the previous selection.
+ */
+ clearOverrideSelection() {
+ if (!window.threadTree) {
+ return;
+ }
+ if (this._selectionIsOverridden) {
+ window.threadTree._selection.selectEventsSuppressed = true;
+ window.threadPane.restoreSelection({ notify: false });
+ this._selectionIsOverridden = false;
+ window.threadTree.invalidate();
+ }
+ window.threadTree
+ .querySelector(".context-menu-target")
+ ?.classList.remove("context-menu-target");
+ window.threadTree._selection.selectEventsSuppressed = false;
+ window.threadTree.table.body.focus();
+ },
+
+ setAsThreadPaneContextMenu() {
+ delete this.browsingContext;
+ delete this.context;
+ delete this.selectionInfo;
+ this.inThreadTree = true;
+
+ for (let id of [
+ "mailContext-openInBrowser",
+ "mailContext-openLinkInBrowser",
+ "mailContext-copylink",
+ "mailContext-savelink",
+ "mailContext-reportPhishingURL",
+ "mailContext-addemail",
+ "mailContext-composeemailto",
+ "mailContext-copyemail",
+ "mailContext-copyimage",
+ "mailContext-saveimage",
+ "mailContext-copy",
+ "mailContext-selectall",
+ "mailContext-searchTheWeb",
+ ]) {
+ document.getElementById(id).hidden = true;
+ }
+ },
+
+ setAsMessagePaneContextMenu({ context, selectionInfo }, browsingContext) {
+ function showItem(id, show) {
+ let item = document.getElementById(id);
+ if (item) {
+ item.hidden = !show;
+ }
+ }
+
+ delete this.inThreadTree;
+ this.browsingContext = browsingContext;
+ this.context = context;
+ this.selectionInfo = selectionInfo;
+
+ // showItem("mailContext-openInBrowser", false);
+ showItem(
+ "mailContext-openLinkInBrowser",
+ context.onLink && !context.onMailtoLink
+ );
+ showItem("mailContext-copylink", context.onLink && !context.onMailtoLink);
+ showItem("mailContext-savelink", context.onLink);
+ showItem(
+ "mailContext-reportPhishingURL",
+ context.onLink && !context.onMailtoLink
+ );
+ showItem("mailContext-addemail", context.onMailtoLink);
+ showItem("mailContext-composeemailto", context.onMailtoLink);
+ showItem("mailContext-copyemail", context.onMailtoLink);
+ showItem("mailContext-copyimage", context.onImage);
+ showItem("mailContext-saveimage", context.onLoadedImage);
+ showItem(
+ "mailContext-copy",
+ selectionInfo && !selectionInfo.docSelectionIsCollapsed
+ );
+ showItem("mailContext-selectall", true);
+ showItem(
+ "mailContext-searchTheWeb",
+ selectionInfo && !selectionInfo.docSelectionIsCollapsed
+ );
+
+ let searchTheWeb = document.getElementById("mailContext-searchTheWeb");
+ if (!searchTheWeb.hidden) {
+ let key = "openSearch.label";
+ let abbrSelection;
+ if (selectionInfo.text.length > 15) {
+ key += ".truncated";
+ abbrSelection = selectionInfo.text.slice(0, 15);
+ } else {
+ abbrSelection = selectionInfo.text;
+ }
+
+ searchTheWeb.label = messengerBundle.formatStringFromName(key, [
+ Services.search.defaultEngine.name,
+ abbrSelection,
+ ]);
+ }
+ },
+
+ fillMailContextMenu(event) {
+ function showItem(id, show) {
+ let item = document.getElementById(id);
+ if (item) {
+ item.hidden = !show;
+ }
+ }
+
+ function enableItem(id, enabled) {
+ let item = document.getElementById(id);
+ item.disabled = !enabled;
+ }
+
+ function checkItem(id, checked) {
+ let item = document.getElementById(id);
+ if (item) {
+ // Convert truthy/falsy to boolean before string.
+ item.setAttribute("checked", !!checked);
+ }
+ }
+
+ function setSingleSelection(id, show = true) {
+ showItem(id, numSelectedMessages == 1 && show);
+ enableItem(id, numSelectedMessages == 1);
+ }
+
+ // Hide things that don't work yet.
+ for (let id of [
+ "mailContext-openInBrowser",
+ "mailContext-recalculateJunkScore",
+ ]) {
+ showItem(id, false);
+ }
+
+ let onSpecialItem =
+ this.context?.isContentSelected ||
+ this.context?.onCanvas ||
+ this.context?.onLink ||
+ this.context?.onImage ||
+ this.context?.onAudio ||
+ this.context?.onVideo ||
+ this.context?.onTextInput;
+
+ for (let id of ["mailContext-tags", "mailContext-mark"]) {
+ showItem(id, !onSpecialItem);
+ }
+
+ // Ask commandController about the commands it controls.
+ for (let [id, command] of Object.entries(this._commands)) {
+ showItem(
+ id,
+ !onSpecialItem && commandController.isCommandEnabled(command)
+ );
+ }
+ for (let [id, command] of Object.entries(this._alwaysVisibleCommands)) {
+ showItem(id, !onSpecialItem);
+ enableItem(id, commandController.isCommandEnabled(command));
+ }
+
+ let inAbout3Pane = !!window.threadTree;
+ let inThreadTree = !!this.inThreadTree;
+
+ let message =
+ gFolder || gViewWrapper.isSynthetic
+ ? gDBView?.hdrForFirstSelectedMessage
+ : top.messenger.msgHdrFromURI(window.gMessageURI);
+ let folder = message?.folder;
+ let isDummyMessage = !gViewWrapper.isSynthetic && !folder;
+
+ let numSelectedMessages = isDummyMessage ? 1 : gDBView.numSelected;
+ let isNewsgroup = folder?.isSpecialFolder(
+ Ci.nsMsgFolderFlags.Newsgroup,
+ true
+ );
+ let canMove =
+ numSelectedMessages >= 1 && !isNewsgroup && folder?.canDeleteMessages;
+ let canCopy = numSelectedMessages >= 1;
+
+ setSingleSelection("mailContext-openNewTab", inThreadTree);
+ setSingleSelection("mailContext-openNewWindow", inThreadTree);
+ setSingleSelection(
+ "mailContext-openContainingFolder",
+ (!isDummyMessage && !inAbout3Pane) || gViewWrapper.isSynthetic
+ );
+ setSingleSelection("mailContext-forward", !onSpecialItem);
+ setSingleSelection("mailContext-forwardAsMenu", !onSpecialItem);
+ showItem(
+ "mailContext-multiForwardAsAttachment",
+ numSelectedMessages > 1 &&
+ commandController.isCommandEnabled("cmd_forwardAttachment")
+ );
+
+ if (isDummyMessage) {
+ showItem("mailContext-tags", false);
+ } else {
+ showItem("mailContext-tags", true);
+ this._initMessageTags();
+ }
+
+ showItem("mailContext-mark", !isDummyMessage);
+ checkItem("mailContext-markFlagged", message?.isFlagged);
+
+ setSingleSelection("mailContext-copyMessageUrl", !!isNewsgroup);
+ // Disable move if we can't delete message(s) from this folder.
+ showItem("mailContext-moveMenu", canMove && !onSpecialItem);
+ showItem("mailContext-copyMenu", canCopy && !onSpecialItem);
+
+ top.initMoveToFolderAgainMenu(
+ document.getElementById("mailContext-moveToFolderAgain")
+ );
+
+ // Show only if a message is actively selected in the DOM.
+ // extractFromEmail can't work on dummy messages.
+ showItem(
+ "mailContext-calendar-convert-menu",
+ numSelectedMessages == 1 &&
+ !isDummyMessage &&
+ calendarDeactivator.isCalendarActivated
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("mailContext-delete"),
+ message.flags & Ci.nsMsgMessageFlags.IMAPDeleted
+ ? "mail-context-undelete-messages"
+ : "mail-context-delete-messages",
+ {
+ count: numSelectedMessages,
+ }
+ );
+
+ checkItem(
+ "mailContext-ignoreThread",
+ folder?.msgDatabase.isIgnored(message?.messageKey)
+ );
+ checkItem(
+ "mailContext-ignoreSubthread",
+ folder && message.flags & Ci.nsMsgMessageFlags.Ignored
+ );
+ checkItem(
+ "mailContext-watchThread",
+ folder?.msgDatabase.isWatched(message?.messageKey)
+ );
+
+ showItem(
+ "mailContext-downloadSelected",
+ window.threadTree && numSelectedMessages > 1
+ );
+
+ let lastItem;
+ for (let child of document.getElementById("mailContext").children) {
+ if (child.localName == "menuseparator") {
+ child.hidden = !lastItem || lastItem.localName == "menuseparator";
+ }
+ if (!child.hidden) {
+ lastItem = child;
+ }
+ }
+ if (lastItem.localName == "menuseparator") {
+ lastItem.hidden = true;
+ }
+
+ // The rest of this block sends menu information to WebExtensions.
+
+ let selectionInfo = this.selectionInfo;
+ let isContentSelected = selectionInfo
+ ? !selectionInfo.docSelectionIsCollapsed
+ : false;
+ let textSelected = selectionInfo ? selectionInfo.text : "";
+ let isTextSelected = !!textSelected.length;
+
+ let tabmail = top.document.getElementById("tabmail");
+ let subject = {
+ menu: event.target,
+ tab: tabmail ? tabmail.currentTabInfo : top,
+ isContentSelected,
+ isTextSelected,
+ onTextInput: this.context?.onTextInput,
+ onLink: this.context?.onLink,
+ onImage: this.context?.onImage,
+ onEditable: this.context?.onEditable,
+ srcUrl: this.context?.mediaURL,
+ linkText: this.context?.linkTextStr,
+ linkUrl: this.context?.linkURL,
+ selectionText: isTextSelected ? selectionInfo.fullText : undefined,
+ pageUrl: this.browsingContext?.currentURI?.spec,
+ };
+
+ if (inThreadTree) {
+ subject.displayedFolder = folder;
+ subject.selectedMessages = gDBView.getSelectedMsgHdrs();
+ }
+
+ subject.context = subject;
+ subject.wrappedJSObject = subject;
+
+ Services.obs.notifyObservers(subject, "on-prepare-contextmenu");
+ Services.obs.notifyObservers(subject, "on-build-contextmenu");
+ },
+
+ onMailContextMenuCommand(event) {
+ // If commandController handles this command, ask it to do so.
+ if (event.target.id in this._commands) {
+ commandController.doCommand(this._commands[event.target.id], event);
+ return;
+ }
+ if (event.target.id in this._alwaysVisibleCommands) {
+ commandController.doCommand(
+ this._alwaysVisibleCommands[event.target.id],
+ event
+ );
+ return;
+ }
+
+ switch (event.target.id) {
+ // Links
+ // case "mailContext-openInBrowser":
+ // this._openInBrowser();
+ // break;
+ case "mailContext-openLinkInBrowser":
+ // Only called in about:message.
+ top.openLinkExternally(this.context.linkURL);
+ break;
+ case "mailContext-copylink":
+ goDoCommand("cmd_copyLink");
+ break;
+ case "mailContext-savelink":
+ top.saveURL(
+ this.context.linkURL, // URL
+ null, // originalURL
+ this.context.linkTextStr, // fileName
+ null, // filePickerTitleKey
+ true, // shouldBypassCache
+ false, // skipPrompt
+ null, // referrerInfo
+ null, // cookieJarSettings
+ this.browsingContext.window.document, // sourceDocument
+ null, // isContentWindowPrivate,
+ Services.scriptSecurityManager.getSystemPrincipal() // principal
+ );
+ break;
+ case "mailContext-reportPhishingURL":
+ PhishingDetector.reportPhishingURL(this.context.linkURL);
+ break;
+ case "mailContext-addemail":
+ top.addEmail(this.context.linkURL);
+ break;
+ case "mailContext-composeemailto":
+ top.composeEmailTo(
+ this.context.linkURL,
+ gFolder
+ ? MailServices.accounts.getFirstIdentityForServer(gFolder.server)
+ : null
+ );
+ break;
+ case "mailContext-copyemail": {
+ let addresses = top.getEmail(this.context.linkURL);
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(addresses);
+ break;
+ }
+
+ // Images
+ case "mailContext-copyimage":
+ goDoCommand("cmd_copyImageContents");
+ break;
+ case "mailContext-saveimage":
+ top.saveURL(
+ this.context.imageInfo.currentSrc, // URL
+ null, // originalURL
+ this.context.linkTextStr, // fileName
+ "SaveImageTitle", // filePickerTitleKey
+ true, // shouldBypassCache
+ false, // skipPrompt
+ null, // referrerInfo
+ null, // cookieJarSettings
+ this.browsingContext.window?.document, // sourceDocument
+ null, // isContentWindowPrivate,
+ Services.scriptSecurityManager.getSystemPrincipal() // principal
+ );
+ break;
+
+ // Edit
+ case "mailContext-copy":
+ goDoCommand("cmd_copy");
+ break;
+ case "mailContext-selectall":
+ goDoCommand("cmd_selectAll");
+ break;
+
+ // Search
+ case "mailContext-searchTheWeb":
+ top.openWebSearch(this.selectionInfo.text);
+ break;
+
+ // Open messages
+ case "mailContext-openNewTab":
+ top.OpenMessageInNewTab(gDBView.hdrForFirstSelectedMessage, {
+ event,
+ viewWrapper: gViewWrapper,
+ });
+ break;
+ case "mailContext-openNewWindow":
+ top.MsgOpenNewWindowForMessage(
+ gDBView.hdrForFirstSelectedMessage,
+ gViewWrapper
+ );
+ break;
+ case "mailContext-openContainingFolder":
+ MailUtils.displayMessageInFolderTab(gDBView.hdrForFirstSelectedMessage);
+ break;
+
+ // Move/copy/archive/convert/delete
+ // (Move and Copy sub-menus are handled in the default case.)
+ case "mailContext-copyMessageUrl": {
+ let message = gDBView.hdrForFirstSelectedMessage;
+ let server = message?.folder?.server;
+
+ if (!server) {
+ return;
+ }
+
+ // TODO let backend construct URL and return as attribute
+ let url =
+ server.socketType == Ci.nsMsgSocketType.SSL ? "snews://" : "news://";
+ url += server.hostName + ":" + server.port + "/" + message.messageId;
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(url);
+ break;
+ }
+
+ // Calendar Convert sub-menu
+ case "mailContext-calendar-convert-event-menuitem":
+ top.calendarExtract.extractFromEmail(
+ gDBView.hdrForFirstSelectedMessage,
+ true
+ );
+ break;
+ case "mailContext-calendar-convert-task-menuitem":
+ top.calendarExtract.extractFromEmail(
+ gDBView.hdrForFirstSelectedMessage,
+ false
+ );
+ break;
+
+ // Save/print/download
+ default: {
+ if (
+ document.getElementById("mailContext-moveMenu").contains(event.target)
+ ) {
+ commandController.doCommand("cmd_moveMessage", event.target._folder);
+ } else if (
+ document.getElementById("mailContext-copyMenu").contains(event.target)
+ ) {
+ commandController.doCommand("cmd_copyMessage", event.target._folder);
+ } else if (
+ document
+ .getElementById("mailContext-decryptToFolder")
+ .contains(event.target)
+ ) {
+ commandController.doCommand(
+ "cmd_copyDecryptedTo",
+ event.target._folder
+ );
+ }
+ break;
+ }
+ }
+ },
+
+ // Tags sub-menu
+
+ /**
+ * Refresh the contents of the tag popup menu/panel.
+ * Used for example for appmenu/Message/Tag panel.
+ *
+ * @param {Element} parent - Parent element that will contain the menu items.
+ * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton".
+ * @param {string} [classes] - Classes to set on the menu items.
+ */
+ _initMessageTags() {
+ let parent = document.getElementById("mailContext-tagpopup");
+ // Remove any existing non-static items (clear tags list before rebuilding it).
+ // There is a separator element above the dynamically added tag elements, so
+ // remove dynamically added elements below the separator.
+ while (parent.lastElementChild.localName == "menuitem") {
+ parent.lastElementChild.remove();
+ }
+
+ // Create label and accesskey for the static "remove all tags" item.
+ let removeItem = document.getElementById("mailContext-tagRemoveAll");
+ removeItem.label = messengerBundle.GetStringFromName(
+ "mailnews.tags.remove"
+ );
+
+ // Rebuild the list.
+ let message = gDBView.hdrForFirstSelectedMessage;
+ let currentTags = message
+ ? message.getStringProperty("keywords").split(" ")
+ : [];
+ let index = 1;
+
+ for (let tagInfo of MailServices.tags.getAllTags()) {
+ let msgHasTag = currentTags.includes(tagInfo.key);
+ if (tagInfo.ordinal.includes("~AUTOTAG") && !msgHasTag) {
+ return;
+ }
+
+ let item = document.createXULElement("menuitem");
+ item.accessKey = index < 10 ? index : "";
+ item.label = messengerBundle.formatStringFromName(
+ "mailnews.tags.format",
+ [item.accessKey, tagInfo.tag]
+ );
+ item.setAttribute("type", "checkbox");
+ if (msgHasTag) {
+ item.setAttribute("checked", "true");
+ }
+ item.value = tagInfo.key;
+ item.addEventListener("command", event =>
+ this._toggleMessageTag(
+ tagInfo.key,
+ item.getAttribute("checked") == "true"
+ )
+ );
+ if (tagInfo.color) {
+ item.style.color = tagInfo.color;
+ }
+ parent.appendChild(item);
+
+ index++;
+ }
+ },
+
+ removeAllMessageTags() {
+ let selectedMessages = gDBView.getSelectedMsgHdrs();
+ if (!selectedMessages.length) {
+ return;
+ }
+
+ let messages = [];
+ let allKeys = MailServices.tags
+ .getAllTags()
+ .map(t => t.key)
+ .join(" ");
+ let prevHdrFolder = null;
+
+ // This crudely handles cross-folder virtual folders with selected
+ // messages that spans folders, by coalescing consecutive messages in the
+ // selection that happen to be in the same folder. nsMsgSearchDBView does
+ // this better, but nsIMsgDBView doesn't handle commands with arguments,
+ // and untag takes a key argument. Furthermore, we only delete known tags,
+ // keeping other keywords like (non)junk intact.
+ for (let i = 0; i < selectedMessages.length; ++i) {
+ let msgHdr = selectedMessages[i];
+ if (prevHdrFolder != msgHdr.folder) {
+ if (prevHdrFolder) {
+ prevHdrFolder.removeKeywordsFromMessages(messages, allKeys);
+ }
+ messages = [];
+ prevHdrFolder = msgHdr.folder;
+ }
+ messages.push(msgHdr);
+ }
+ if (prevHdrFolder) {
+ prevHdrFolder.removeKeywordsFromMessages(messages, allKeys);
+ }
+ },
+
+ _toggleMessageTag(key, addKey) {
+ let messages = [];
+ let selectedMessages = gDBView.getSelectedMsgHdrs();
+ let toggler = addKey
+ ? "addKeywordsToMessages"
+ : "removeKeywordsFromMessages";
+ let prevHdrFolder = null;
+ // this crudely handles cross-folder virtual folders with selected messages
+ // that spans folders, by coalescing consecutive msgs in the selection
+ // that happen to be in the same folder. nsMsgSearchDBView does this
+ // better, but nsIMsgDBView doesn't handle commands with arguments,
+ // and (un)tag takes a key argument.
+ for (let i = 0; i < selectedMessages.length; ++i) {
+ let msgHdr = selectedMessages[i];
+ if (prevHdrFolder != msgHdr.folder) {
+ if (prevHdrFolder) {
+ prevHdrFolder[toggler](messages, key);
+ }
+ messages = [];
+ prevHdrFolder = msgHdr.folder;
+ }
+ messages.push(msgHdr);
+ }
+ if (prevHdrFolder) {
+ prevHdrFolder[toggler](messages, key);
+ }
+ },
+
+ /**
+ * Toggle the state of a message tag on the selected messages (based on the
+ * state of the first selected message, like for starring).
+ *
+ * @param {number} keyNumber - The number (1 through 9) associated with the tag.
+ */
+ _toggleMessageTagKey(keyNumber) {
+ let msgHdr = gDBView.hdrForFirstSelectedMessage;
+ if (!msgHdr) {
+ return;
+ }
+
+ let tagArray = MailServices.tags.getAllTags();
+ if (keyNumber > tagArray.length) {
+ return;
+ }
+
+ let key = tagArray[keyNumber - 1].key;
+ let curKeys = msgHdr.getStringProperty("keywords").split(" ");
+ if (msgHdr.label) {
+ curKeys.push("$label" + msgHdr.label);
+ }
+ let addKey = !curKeys.includes(key);
+
+ this._toggleMessageTag(key, addKey);
+ },
+
+ addTag() {
+ top.openDialog(
+ "chrome://messenger/content/newTagDialog.xhtml",
+ "",
+ "chrome,titlebar,modal,centerscreen",
+ {
+ result: "",
+ okCallback: (name, color) => {
+ MailServices.tags.addTag(name, color, "");
+ let key = MailServices.tags.getKeyForTag(name);
+ TagUtils.addTagToAllDocumentSheets(key, color);
+
+ this._toggleMessageTag(key, true);
+ return true;
+ },
+ }
+ );
+ },
+};
diff --git a/comm/mail/base/content/mailCore.js b/comm/mail/base/content/mailCore.js
new file mode 100644
index 0000000000..781d5449d6
--- /dev/null
+++ b/comm/mail/base/content/mailCore.js
@@ -0,0 +1,1063 @@
+/* -*- Mode: JS; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+/*
+ * Core mail routines used by all of the major mail windows (address book,
+ * 3-pane, compose and stand alone message window).
+ * Routines to support custom toolbars in mail windows, opening up a new window
+ * of a particular type all live here.
+ * Before adding to this file, ask yourself, is this a JS routine that is going
+ * to be used by all of the main mail windows?
+ */
+
+/* import-globals-from ../../extensions/mailviews/content/msgViewPickerOverlay.js */
+/* import-globals-from customizeToolbar.js */
+/* import-globals-from utilityOverlay.js */
+
+/* globals gChatTab */ // From globals chat-messenger.js
+/* globals currentAttachments */ // From msgHdrView.js
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "gViewSourceUtils", function () {
+ let scope = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://global/content/viewSourceUtils.js",
+ scope
+ );
+ scope.gViewSourceUtils.viewSource = async function (aArgs) {
+ // Check if external view source is enabled. If so, try it. If it fails,
+ // fallback to internal view source.
+ if (Services.prefs.getBoolPref("view_source.editor.external")) {
+ try {
+ await this.openInExternalEditor(aArgs);
+ return;
+ } catch (ex) {}
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/viewSource.xhtml",
+ "_blank",
+ "all,dialog=no",
+ aArgs
+ );
+ };
+ return scope.gViewSourceUtils;
+});
+
+Object.defineProperty(this, "BrowserConsoleManager", {
+ get() {
+ let { loader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ return loader.require("devtools/client/webconsole/browser-console-manager")
+ .BrowserConsoleManager;
+ },
+ configurable: true,
+ enumerable: true,
+});
+
+var gCustomizeSheet = false;
+
+function overlayRestoreDefaultSet() {
+ let toolbox = null;
+ if ("arguments" in window && window.arguments[0]) {
+ toolbox = window.arguments[0];
+ } else if (window.frameElement && "toolbox" in window.frameElement) {
+ toolbox = window.frameElement.toolbox;
+ }
+
+ let mode = toolbox.getAttribute("defaultmode");
+ let align = toolbox.getAttribute("defaultlabelalign");
+ let menulist = document.getElementById("modelist");
+
+ if (mode == "full" && align == "end") {
+ toolbox.setAttribute("mode", "textbesideicon");
+ toolbox.setAttribute("labelalign", align);
+ overlayUpdateToolbarMode("textbesideicon");
+ } else if (mode == "full" && align == "") {
+ toolbox.setAttribute("mode", "full");
+ toolbox.removeAttribute("labelalign");
+ overlayUpdateToolbarMode(mode);
+ }
+
+ restoreDefaultSet();
+
+ if (mode == "full" && align == "end") {
+ menulist.value = "textbesideicon";
+ }
+}
+
+function overlayUpdateToolbarMode(aModeValue) {
+ let toolbox = null;
+ if ("arguments" in window && window.arguments[0]) {
+ toolbox = window.arguments[0];
+ } else if (window.frameElement && "toolbox" in window.frameElement) {
+ toolbox = window.frameElement.toolbox;
+ }
+
+ // If they chose a mode of textbesideicon or full,
+ // then map that to a mode of full, and a labelalign of true or false.
+ if (aModeValue == "textbesideicon" || aModeValue == "full") {
+ var align = aModeValue == "textbesideicon" ? "end" : "bottom";
+ toolbox.setAttribute("labelalign", align);
+ Services.xulStore.persist(toolbox, "labelalign");
+ aModeValue = "full";
+ }
+ updateToolbarMode(aModeValue);
+}
+
+function overlayOnLoad() {
+ let restoreButton = document
+ .getElementById("main-box")
+ .querySelector("[oncommand*='restore']");
+ restoreButton.setAttribute("oncommand", "overlayRestoreDefaultSet();");
+
+ // Add the textBesideIcon menu item if it's not already there.
+ let menuitem = document.getElementById("textbesideiconItem");
+ if (!menuitem) {
+ let menulist = document.getElementById("modelist");
+ let label = document
+ .getElementById("iconsBesideText.label")
+ .getAttribute("value");
+ menuitem = menulist.appendItem(label, "textbesideicon");
+ menuitem.id = "textbesideiconItem";
+ }
+
+ // If they have a mode of full and a labelalign of true,
+ // then pretend the mode is textbesideicon when populating the popup.
+ let toolbox = null;
+ if ("arguments" in window && window.arguments[0]) {
+ toolbox = window.arguments[0];
+ } else if (window.frameElement && "toolbox" in window.frameElement) {
+ toolbox = window.frameElement.toolbox;
+ }
+
+ let toolbarWindow = document.getElementById("CustomizeToolbarWindow");
+ toolbarWindow.setAttribute("toolboxId", toolbox.id);
+ toolbox.setAttribute("doCustomization", "true");
+
+ let mode = toolbox.getAttribute("mode");
+ let align = toolbox.getAttribute("labelalign");
+ if (mode == "full" && align == "end") {
+ toolbox.setAttribute("mode", "textbesideicon");
+ }
+
+ onLoad();
+ overlayRepositionDialog();
+
+ // Re-set and re-persist the mode, if we changed it above.
+ if (mode == "full" && align == "end") {
+ toolbox.setAttribute("mode", mode);
+ Services.xulStore.persist(toolbox, "mode");
+ }
+}
+
+function overlayRepositionDialog() {
+ // Position the dialog so it is fully visible on the screen
+ // (if possible)
+
+ // Seems to be necessary to get the correct dialog height/width
+ window.sizeToContent();
+ var wH = window.outerHeight;
+ var wW = window.outerWidth;
+ var sH = window.screen.height;
+ var sW = window.screen.width;
+ var sX = window.screenX;
+ var sY = window.screenY;
+ var sAL = window.screen.availLeft;
+ var sAT = window.screen.availTop;
+
+ var nX = Math.max(Math.min(sX, sW - wW), sAL);
+ var nY = Math.max(Math.min(sY, sH - wH), sAT);
+ window.moveTo(nX, nY);
+}
+
+function CustomizeMailToolbar(toolboxId, customizePopupId) {
+ if (toolboxId === "mail-toolbox" && window.tabmail) {
+ // Open the unified toolbar customization panel only for mail.
+ document.querySelector("unified-toolbar").showCustomization();
+ return;
+ }
+
+ // Disable the toolbar context menu items
+ var menubar = document.getElementById("mail-menubar");
+ for (var i = 0; i < menubar.children.length; ++i) {
+ menubar.children[i].setAttribute("disabled", true);
+ }
+
+ var customizePopup = document.getElementById(customizePopupId);
+ customizePopup.setAttribute("disabled", "true");
+
+ var toolbox = document.getElementById(toolboxId);
+
+ var customizeURL = "chrome://messenger/content/customizeToolbar.xhtml";
+ gCustomizeSheet = Services.prefs.getBoolPref(
+ "toolbar.customization.usesheet"
+ );
+
+ let externalToolbars = [];
+ if (toolbox.getAttribute("id") == "mail-toolbox") {
+ if (
+ AppConstants.platform != "macosx" &&
+ document.getElementById("toolbar-menubar")
+ ) {
+ externalToolbars.push(document.getElementById("toolbar-menubar"));
+ }
+ }
+
+ if (gCustomizeSheet) {
+ var sheetFrame = document.getElementById("customizeToolbarSheetIFrame");
+ var panel = document.getElementById("customizeToolbarSheetPopup");
+ sheetFrame.hidden = false;
+ sheetFrame.toolbox = toolbox;
+ sheetFrame.panel = panel;
+ if (externalToolbars.length > 0) {
+ sheetFrame.externalToolbars = externalToolbars;
+ }
+
+ // The document might not have been loaded yet, if this is the first time.
+ // If it is already loaded, reload it so that the onload initialization code
+ // re-runs.
+ if (sheetFrame.getAttribute("src") == customizeURL) {
+ sheetFrame.contentWindow.location.reload();
+ } else {
+ sheetFrame.setAttribute("src", customizeURL);
+ }
+
+ // Open the panel, but make it invisible until the iframe has loaded so
+ // that the user doesn't see a white flash.
+ panel.style.visibility = "hidden";
+ toolbox.addEventListener(
+ "beforecustomization",
+ function () {
+ panel.style.removeProperty("visibility");
+ },
+ { capture: false, once: true }
+ );
+ panel.openPopup(toolbox, "after_start", 0, 0);
+ } else {
+ var wintype = document.documentElement.getAttribute("windowtype");
+ wintype = wintype.replace(/:/g, "");
+
+ window.openDialog(
+ customizeURL,
+ "CustomizeToolbar" + wintype,
+ "chrome,all,dependent",
+ toolbox,
+ externalToolbars
+ );
+ }
+}
+
+function MailToolboxCustomizeDone(aEvent, customizePopupId) {
+ if (gCustomizeSheet) {
+ document.getElementById("customizeToolbarSheetIFrame").hidden = true;
+ document.getElementById("customizeToolbarSheetPopup").hidePopup();
+ }
+
+ // Update global UI elements that may have been added or removed
+
+ // Re-enable parts of the UI we disabled during the dialog
+ var menubar = document.getElementById("mail-menubar");
+ for (var i = 0; i < menubar.children.length; ++i) {
+ menubar.children[i].setAttribute("disabled", false);
+ }
+
+ var customizePopup = document.getElementById(customizePopupId);
+ customizePopup.removeAttribute("disabled");
+
+ let toolbox = document.querySelector('[doCustomization="true"]');
+ if (toolbox) {
+ toolbox.removeAttribute("doCustomization");
+
+ // The GetMail button is stuck in a strange state right now, since the
+ // customization wrapping preserves its children, but not its initialized
+ // state. Fix that here.
+ // That is also true for the File -> "Get new messages for" menuitems in both
+ // menus (old and new App menu). And also Go -> Folder.
+ // TODO bug 904223: try to fix folderWidgets.xml to not do this.
+ // See Bug 520457 and Bug 534448 and Bug 709733.
+ // Fix Bug 565045: Only treat "Get Message Button" if it is in our toolbox
+ for (let popup of [
+ toolbox.querySelector("#button-getMsgPopup"),
+ document.getElementById("menu_getAllNewMsgPopup"),
+ document.getElementById("appmenu_getAllNewMsgPopup"),
+ document.getElementById("menu_GoFolderPopup"),
+ document.getElementById("appmenu_GoFolderPopup"),
+ ]) {
+ if (!popup) {
+ continue;
+ }
+
+ // .teardown() is only available here if the menu has its frame
+ // otherwise the folderWidgets.xml::folder-menupopup binding is not
+ // attached to the popup. So if it is not available, remove the items
+ // explicitly. Only remove elements that were generated by the binding.
+ if ("_teardown" in popup) {
+ popup._teardown();
+ } else {
+ for (let i = popup.children.length - 1; i >= 0; i--) {
+ let child = popup.children[i];
+ if (child.getAttribute("generated") != "true") {
+ continue;
+ }
+ if ("_teardown" in child) {
+ child._teardown();
+ }
+ child.remove();
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Sets up the menu popup that lets the user hide or display toolbars. For
+ * example, in the appmenu / Preferences view. Adds toolbar items to the popup
+ * and sets their attributes.
+ *
+ * @param {Event} event - Event causing the menu popup to appear.
+ * @param {string|string[]} toolboxIds - IDs of toolboxes that contain toolbars.
+ * @param {Element} insertPoint - Where to insert menu items.
+ * @param {string} elementName - What kind of menu item element to use. E.g.
+ * "toolbarbutton" for the appmenu.
+ * @param {string} classes - Classes to set on menu items.
+ * @param {boolean} keepOpen - If to force the menu to stay open when clicking
+ * on this element.
+ */
+function onViewToolbarsPopupShowing(
+ event,
+ toolboxIds,
+ insertPoint,
+ elementName = "menuitem",
+ classes,
+ keepOpen = false
+) {
+ if (!Array.isArray(toolboxIds)) {
+ toolboxIds = [toolboxIds];
+ }
+
+ let popup = event.target.querySelector(".panel-subview-body") || event.target;
+ // Limit the toolbar menu entries to the first level of context menus.
+ if (
+ popup != event.currentTarget &&
+ event.currentTarget.tagName == "menupopup"
+ ) {
+ return;
+ }
+
+ // Remove all collapsible nodes from the menu.
+ for (let i = popup.children.length - 1; i >= 0; --i) {
+ let deadItem = popup.children[i];
+
+ if (deadItem.hasAttribute("iscollapsible")) {
+ deadItem.remove();
+ }
+ }
+
+ // We insert menuitems before the first child if no insert point is given.
+ let firstMenuItem = insertPoint || popup.firstElementChild;
+
+ for (let toolboxId of toolboxIds) {
+ let toolbars = [];
+ let toolbox = document.getElementById(toolboxId);
+
+ if (toolbox) {
+ // We consider child nodes that have a toolbarname attribute.
+ toolbars = toolbars.concat(
+ Array.from(toolbox.querySelectorAll("[toolbarname]"))
+ );
+ }
+
+ if (
+ toolboxId == "mail-toolbox" &&
+ toolbars.every(
+ toolbar => toolbar.getAttribute("id") !== "toolbar-menubar"
+ )
+ ) {
+ if (
+ AppConstants.platform != "macosx" &&
+ document.getElementById("toolbar-menubar")
+ ) {
+ toolbars.push(document.getElementById("toolbar-menubar"));
+ }
+ }
+
+ for (let toolbar of toolbars) {
+ let toolbarName = toolbar.getAttribute("toolbarname");
+ if (!toolbarName) {
+ continue;
+ }
+
+ let menuItem = document.createXULElement(elementName);
+ let hidingAttribute =
+ toolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed";
+
+ menuItem.setAttribute("type", "checkbox");
+ // Mark this menuitem with an iscollapsible attribute, so we
+ // know we can wipe it out later on.
+ menuItem.setAttribute("iscollapsible", true);
+ menuItem.setAttribute("toolbarid", toolbar.id);
+ menuItem.setAttribute("label", toolbarName);
+ menuItem.setAttribute("accesskey", toolbar.getAttribute("accesskey"));
+ menuItem.setAttribute(
+ "checked",
+ toolbar.getAttribute(hidingAttribute) != "true"
+ );
+ if (classes) {
+ menuItem.setAttribute("class", classes);
+ }
+ if (keepOpen) {
+ menuItem.setAttribute("closemenu", "none");
+ }
+ popup.insertBefore(menuItem, firstMenuItem);
+
+ menuItem.addEventListener("command", () => {
+ if (toolbar.getAttribute(hidingAttribute) != "true") {
+ toolbar.setAttribute(hidingAttribute, "true");
+ menuItem.removeAttribute("checked");
+ } else {
+ menuItem.setAttribute("checked", true);
+ toolbar.removeAttribute(hidingAttribute);
+ }
+ Services.xulStore.persist(toolbar, hidingAttribute);
+ });
+ }
+ }
+}
+
+function toJavaScriptConsole() {
+ BrowserConsoleManager.openBrowserConsoleOrFocus();
+}
+
+function openAboutDebugging(hash) {
+ let url = "about:debugging" + (hash ? "#" + hash : "");
+ document.getElementById("tabmail").openTab("contentTab", { url });
+}
+
+function toOpenWindowByType(inType, uri) {
+ var topWindow = Services.wm.getMostRecentWindow(inType);
+ if (topWindow) {
+ topWindow.focus();
+ return topWindow;
+ }
+ return window.open(
+ uri,
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar"
+ );
+}
+
+function toMessengerWindow() {
+ return toOpenWindowByType(
+ "mail:3pane",
+ "chrome://messenger/content/messenger.xhtml"
+ );
+}
+
+function focusOnMail(tabNo, event) {
+ // this is invoked by accel-<number>
+ var topWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (topWindow) {
+ topWindow.focus();
+ const tabmail = document.getElementById("tabmail");
+ if (tabmail.globalOverlay) {
+ return;
+ }
+ tabmail.selectTabByIndex(event, tabNo);
+ } else {
+ window.open(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar"
+ );
+ }
+}
+
+/**
+ * Open the address book and optionally display/edit a card.
+ *
+ * @param {?object} openArgs - Arguments to pass to the address book.
+ * See `externalAction` in aboutAddressBook.js for details.
+ * @returns {?Window} The address book's window global, if the address book was
+ * opened.
+ */
+async function toAddressBook(openArgs) {
+ let messengerWindow = toMessengerWindow();
+ if (messengerWindow.document.readyState != "complete") {
+ await new Promise(resolve => {
+ Services.obs.addObserver(
+ {
+ observe(subject) {
+ if (subject == messengerWindow) {
+ Services.obs.removeObserver(this, "mail-tabs-session-restored");
+ resolve();
+ }
+ },
+ },
+ "mail-tabs-session-restored"
+ );
+ });
+ }
+
+ if (messengerWindow.tabmail.globalOverlay) {
+ return null;
+ }
+
+ return new Promise(resolve => {
+ messengerWindow.tabmail.openTab("addressBookTab", {
+ onLoad(event, browser) {
+ if (openArgs) {
+ browser.contentWindow.externalAction(openArgs);
+ }
+ resolve(browser.contentWindow);
+ },
+ });
+ messengerWindow.focus();
+ });
+}
+
+/**
+ * Open the calendar.
+ */
+async function toCalendar() {
+ let messengerWindow = toMessengerWindow();
+ if (messengerWindow.document.readyState != "complete") {
+ await new Promise(resolve => {
+ Services.obs.addObserver(
+ {
+ observe(subject) {
+ if (subject == messengerWindow) {
+ Services.obs.removeObserver(this, "mail-tabs-session-restored");
+ resolve();
+ }
+ },
+ },
+ "mail-tabs-session-restored"
+ );
+ });
+ }
+
+ return new Promise(resolve => {
+ messengerWindow.tabmail.openTab("calendar", {
+ onLoad(event, browser) {
+ resolve(browser.contentWindow);
+ },
+ });
+ messengerWindow.focus();
+ });
+}
+
+function showChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ if (gChatTab) {
+ tabmail.switchToTab(gChatTab);
+ } else {
+ tabmail.openTab("chat", {});
+ }
+}
+
+/**
+ * Open about:import or importDialog.xhtml.
+ *
+ * @param {"start"|"app"|"addressBook"|"calendar"|"export"} [tabId] - The tab
+ * to open in about:import.
+ */
+function toImport(tabId = "start") {
+ if (Services.prefs.getBoolPref("mail.import.in_new_tab")) {
+ let tab = toMessengerWindow().openTab("contentTab", {
+ url: "about:import",
+ onLoad(event, browser) {
+ if (tabId) {
+ browser.contentWindow.showTab(`tab-${tabId}`, true);
+ }
+ },
+ });
+ // Somehow DOMContentLoaded is called even when about:import is already
+ // open, which resets the active tab. Use setTimeout here as a workaround.
+ setTimeout(
+ () => tab.browser.contentWindow.showTab(`tab-${tabId}`, true),
+ 100
+ );
+ return;
+ }
+ window.openDialog(
+ "chrome://messenger/content/importDialog.xhtml",
+ "importDialog",
+ "chrome,modal,titlebar,centerscreen"
+ );
+}
+
+function toExport() {
+ if (Services.prefs.getBoolPref("mail.import.in_new_tab")) {
+ toImport("export");
+ return;
+ }
+ window.openDialog(
+ "chrome://messenger/content/exportDialog.xhtml",
+ "exportDialog",
+ "chrome,modal,titlebar,centerscreen"
+ );
+}
+
+function toSanitize() {
+ let sanitizerScope = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/sanitize.js",
+ sanitizerScope
+ );
+ sanitizerScope.Sanitizer.sanitize(window);
+}
+
+/**
+ * Opens the Preferences (Options) dialog.
+ *
+ * @param aPaneID ID of prefpane to select automatically.
+ * @param aScrollPaneTo ID of the element to scroll into view.
+ * @param aOtherArgs other prefpane specific arguments
+ */
+function openOptionsDialog(aPaneID, aScrollPaneTo, aOtherArgs) {
+ openPreferencesTab(aPaneID, aScrollPaneTo, aOtherArgs);
+}
+
+function openAddonsMgr(aView) {
+ return new Promise(resolve => {
+ let emWindow;
+ let browserWindow;
+
+ let receivePong = function (aSubject, aTopic, aData) {
+ let browserWin = aSubject.browsingContext.topChromeWindow;
+ if (!emWindow || browserWin == window /* favor the current window */) {
+ emWindow = aSubject;
+ browserWindow = browserWin;
+ }
+ };
+ Services.obs.addObserver(receivePong, "EM-pong");
+ Services.obs.notifyObservers(null, "EM-ping");
+ Services.obs.removeObserver(receivePong, "EM-pong");
+
+ if (emWindow) {
+ if (aView) {
+ emWindow.loadView(aView);
+ }
+ let tabmail = browserWindow.document.getElementById("tabmail");
+ tabmail.switchToTab(tabmail.getBrowserForDocument(emWindow));
+ emWindow.focus();
+ resolve(emWindow);
+ return;
+ }
+
+ // This must be a new load, else the ping/pong would have
+ // found the window above.
+ let tab = openContentTab("about:addons");
+ // Also in `contentTabType.restoreTab` in specialTabs.js.
+ tab.browser.droppedLinkHandler = event =>
+ tab.browser.contentWindow.gDragDrop.onDrop(event);
+
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observer, aTopic);
+ if (aView) {
+ aSubject.loadView(aView);
+ }
+ aSubject.focus();
+ resolve(aSubject);
+ }, "EM-loaded");
+ });
+}
+
+function openActivityMgr() {
+ Cc["@mozilla.org/activity-manager-ui;1"]
+ .getService(Ci.nsIActivityManagerUI)
+ .show(window);
+}
+
+/**
+ * Open the folder properties of current folder with the quota tab selected.
+ */
+function openFolderQuota() {
+ document
+ .getElementById("tabmail")
+ .currentAbout3Pane?.folderPane.editFolder("QuotaTab");
+}
+
+function openIMAccountMgr() {
+ var win = Services.wm.getMostRecentWindow("Messenger:Accounts");
+ if (win) {
+ win.focus();
+ } else {
+ win = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/chat/imAccounts.xhtml",
+ "Accounts",
+ "chrome,resizable,centerscreen",
+ null
+ );
+ }
+ return win;
+}
+
+function openIMAccountWizard() {
+ const kFeatures = "chrome,centerscreen,modal,titlebar";
+ const kUrl = "chrome://messenger/content/chat/imAccountWizard.xhtml";
+ const kName = "IMAccountWizard";
+
+ if (AppConstants.platform == "macosx") {
+ // On Mac, avoid using the hidden window as a parent as that would
+ // make it visible.
+ let hiddenWindowUrl = Services.prefs.getCharPref(
+ "browser.hiddenWindowChromeURL"
+ );
+ if (window.location.href == hiddenWindowUrl) {
+ Services.ww.openWindow(null, kUrl, kName, kFeatures, null);
+ return;
+ }
+ }
+
+ window.openDialog(kUrl, kName, kFeatures);
+}
+
+function openSavedFilesWnd() {
+ if (window.tabmail?.globalOverlay) {
+ return Promise.resolve();
+ }
+ return openContentTab("about:downloads");
+}
+
+function SetBusyCursor(window, enable) {
+ // setCursor() is only available for chrome windows.
+ // However one of our frames is the start page which
+ // is a non-chrome window, so check if this window has a
+ // setCursor method
+ if ("setCursor" in window) {
+ if (enable) {
+ window.setCursor("progress");
+ } else {
+ window.setCursor("auto");
+ }
+ }
+
+ var numFrames = window.frames.length;
+ for (var i = 0; i < numFrames; i++) {
+ SetBusyCursor(window.frames[i], enable);
+ }
+}
+
+function openAboutDialog() {
+ for (let win of Services.wm.getEnumerator("Mail:About")) {
+ // Only open one about window
+ win.focus();
+ return;
+ }
+
+ let features = "chrome,centerscreen,";
+ if (AppConstants.platform == "win") {
+ features += "dependent";
+ } else if (AppConstants.platform == "macosx") {
+ features += "resizable=no,minimizable=no";
+ } else {
+ features += "dependent,dialog=no";
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/aboutDialog.xhtml",
+ "About",
+ features
+ );
+}
+
+/**
+ * Opens the support page based on the app.support.baseURL pref.
+ */
+function openSupportURL() {
+ openFormattedURL("app.support.baseURL");
+}
+
+/**
+ * Fetches the url for the passed in pref name, formats it and then loads it in the default
+ * browser.
+ *
+ * @param aPrefName - name of the pref that holds the url we want to format and open
+ */
+function openFormattedURL(aPrefName) {
+ var urlToOpen = Services.urlFormatter.formatURLPref(aPrefName);
+
+ var uri = Services.io.newURI(urlToOpen);
+
+ var protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ protocolSvc.loadURI(uri);
+}
+
+/**
+ * Opens the Troubleshooting page in a new tab.
+ */
+function openAboutSupport() {
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mailWindow) {
+ mailWindow.focus();
+ mailWindow.document.getElementById("tabmail").openTab("contentTab", {
+ url: "about:support",
+ });
+ return;
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType: "contentTab",
+ tabParams: { url: "about:support" },
+ }
+ );
+}
+
+/**
+ * Prompt the user to restart the browser in safe mode.
+ */
+function safeModeRestart() {
+ // Is TB in safe mode?
+ if (Services.appinfo.inSafeMode) {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ if (cancelQuit.data) {
+ return;
+ }
+
+ Services.startup.quit(
+ Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit
+ );
+ return;
+ }
+ // prompt the user to confirm
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let promptTitle = bundle.GetStringFromName(
+ "troubleshootModeRestartPromptTitle"
+ );
+ let promptMessage = bundle.GetStringFromName(
+ "troubleshootModeRestartPromptMessage"
+ );
+ let restartText = bundle.GetStringFromName("troubleshootModeRestartButton");
+ let buttonFlags =
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL +
+ Services.prompt.BUTTON_POS_0_DEFAULT;
+
+ let rv = Services.prompt.confirmEx(
+ window,
+ promptTitle,
+ promptMessage,
+ buttonFlags,
+ restartText,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (rv == 0) {
+ Services.env.set("MOZ_SAFE_MODE_RESTART", "1");
+ let { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+ MailUtils.restartApplication();
+ }
+}
+
+function getMostRecentMailWindow() {
+ let win = null;
+
+ win = Services.wm.getMostRecentWindow("mail:3pane", true);
+
+ // If we're lucky, this isn't a popup, and we can just return this.
+ if (win && win.document.documentElement.getAttribute("chromehidden")) {
+ win = null;
+ // This is oldest to newest, so this gets a bit ugly.
+ for (let nextWin of Services.wm.getEnumerator("mail:3pane", true)) {
+ if (!nextWin.document.documentElement.getAttribute("chromehidden")) {
+ win = nextWin;
+ }
+ }
+ }
+
+ return win;
+}
+
+/**
+ * Create a sanitized display name for an attachment in order to help prevent
+ * people from hiding malicious extensions behind a run of spaces, etc. To do
+ * this, we strip leading/trailing whitespace and collapse long runs of either
+ * whitespace or identical characters. Windows especially will drop trailing
+ * dots and whitespace from filename extensions.
+ *
+ * @param aAttachment the AttachmentInfo object
+ * @returns a sanitized display name for the attachment
+ */
+function SanitizeAttachmentDisplayName(aAttachment) {
+ let displayName = aAttachment.name.trim().replace(/\s+/g, " ");
+ if (AppConstants.platform == "win") {
+ displayName = displayName.replace(/[ \.]+$/, "");
+ }
+ return displayName.replace(/(.)\1{9,}/g, "$1…$1");
+}
+
+/**
+ * Appends a dataTransferItem to the associated event for message attachments,
+ * either from the message reader or the composer.
+ *
+ * @param {Event} event - The associated event.
+ * @param {nsIMsgAttachment[]} attachments - The attachments to setup
+ */
+function setupDataTransfer(event, attachments) {
+ let index = 0;
+ for (let attachment of attachments) {
+ if (attachment.contentType == "text/x-moz-deleted") {
+ return;
+ }
+
+ let name = attachment.name || attachment.displayName;
+
+ if (!attachment.url || !name) {
+ continue;
+ }
+
+ // Only add type/filename info for non-file URLs that don't already
+ // have it.
+ let info = [];
+ if (/(^file:|&filename=)/.test(attachment.url)) {
+ info.push(attachment.url);
+ } else {
+ info.push(
+ attachment.url +
+ "&type=" +
+ attachment.contentType +
+ "&filename=" +
+ encodeURIComponent(name)
+ );
+ }
+ info.push(name, attachment.size, attachment.contentType, attachment.uri);
+ if (attachment.sendViaCloud) {
+ info.push(attachment.cloudFileAccountKey, attachment.cloudPartHeaderData);
+ }
+
+ event.dataTransfer.mozSetDataAt("text/x-moz-url", info.join("\n"), index);
+ event.dataTransfer.mozSetDataAt(
+ "text/x-moz-url-data",
+ attachment.url,
+ index
+ );
+ event.dataTransfer.mozSetDataAt("text/x-moz-url-desc", name, index);
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-url",
+ attachment.url,
+ index
+ );
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise",
+ new nsFlavorDataProvider(),
+ index
+ );
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-dest-filename",
+ name.replace(/(.{74}).*(.{10})$/u, "$1...$2"),
+ index
+ );
+ index++;
+ }
+}
+
+/**
+ * Checks if Thunderbird was launched in safe mode and updates the menu items.
+ */
+function updateTroubleshootMenuItem() {
+ if (Services.appinfo.inSafeMode) {
+ let safeMode = document.getElementById("helpTroubleshootMode");
+ document.l10n.setAttributes(safeMode, "menu-help-exit-troubleshoot-mode");
+
+ let appSafeMode = document.getElementById("appmenu_troubleshootMode");
+ if (appSafeMode) {
+ document.l10n.setAttributes(
+ appSafeMode,
+ "appmenu-help-exit-troubleshoot-mode2"
+ );
+ }
+ }
+}
+
+function nsFlavorDataProvider() {}
+
+nsFlavorDataProvider.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(aTransferable, aFlavor, aData) {
+ // get the url for the attachment
+ if (aFlavor == "application/x-moz-file-promise") {
+ var urlPrimitive = {};
+ aTransferable.getTransferData(
+ "application/x-moz-file-promise-url",
+ urlPrimitive
+ );
+
+ var srcUrlPrimitive = urlPrimitive.value.QueryInterface(
+ Ci.nsISupportsString
+ );
+
+ // now get the destination file location from kFilePromiseDirectoryMime
+ var dirPrimitive = {};
+ aTransferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ dirPrimitive
+ );
+ var destDirectory = dirPrimitive.value.QueryInterface(Ci.nsIFile);
+
+ // now save the attachment to the specified location
+ // XXX: we need more information than just the attachment url to save it,
+ // fortunately, we have an array of all the current attachments so we can
+ // cheat and scan through them
+
+ var attachment = null;
+ for (let index of currentAttachments.keys()) {
+ attachment = currentAttachments[index];
+ if (attachment.url == srcUrlPrimitive) {
+ break;
+ }
+ }
+
+ // call our code for saving attachments
+ if (attachment) {
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ let name = attachment.name || attachment.displayName;
+ let destFilePath = messenger.saveAttachmentToFolder(
+ attachment.contentType,
+ attachment.url,
+ name.replace(/(.{74}).*(.{10})$/u, "$1...$2"),
+ attachment.uri,
+ destDirectory
+ );
+ aData.value = destFilePath.QueryInterface(Ci.nsISupports);
+ }
+ if (AppConstants.platform == "macosx") {
+ // Workaround dnd of multiple attachments creating duplicates. See bug 1494588.
+ aTransferable.removeDataFlavor("application/x-moz-file-promise");
+ }
+ }
+ },
+};
diff --git a/comm/mail/base/content/mailTabs.js b/comm/mail/base/content/mailTabs.js
new file mode 100644
index 0000000000..e805eb8afb
--- /dev/null
+++ b/comm/mail/base/content/mailTabs.js
@@ -0,0 +1,390 @@
+/* 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 mail3PaneWindowCommands.js */
+/* import-globals-from mailWindowOverlay.js */
+/* import-globals-from messenger.js */
+
+/* globals contentProgress, statusFeedback */ // From mailWindow.js
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ FolderUtils: "resource:///modules/FolderUtils.jsm",
+ GlodaSyntheticView: "resource:///modules/gloda/GlodaSyntheticView.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MsgHdrSyntheticView: "resource:///modules/MsgHdrSyntheticView.jsm",
+ MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm",
+});
+
+/**
+ * Tabs for displaying mail folders and messages.
+ */
+var mailTabType = {
+ name: "mailTab",
+ perTabPanel: "vbox",
+ _cloneTemplate(template, tab, onDOMContentLoaded, onLoad) {
+ let tabmail = document.getElementById("tabmail");
+
+ let clone = document.getElementById(template).content.cloneNode(true);
+ let browser = clone.querySelector("browser");
+ browser.id = `${tab.mode.name}Browser${tab.mode._nextId}`;
+ browser.addEventListener(
+ "DOMTitleChanged",
+ () => {
+ tab.title = browser.contentTitle;
+ tabmail.setTabTitle(tab);
+ },
+ true
+ );
+ let linkRelIconHandler = event => {
+ if (event.target.rel != "icon") {
+ return;
+ }
+ // Allow 3pane and message tab to set a tab favicon. Mail content should
+ // not be allowed to do that.
+ if (event.target.ownerGlobal.frameElement == browser) {
+ tabmail.setTabFavIcon(tab, event.target.href);
+ }
+ };
+ browser.addEventListener("DOMLinkAdded", linkRelIconHandler);
+ browser.addEventListener("DOMLinkChanged", linkRelIconHandler);
+ if (onDOMContentLoaded) {
+ browser.addEventListener(
+ "DOMContentLoaded",
+ event => {
+ if (!tab.closed) {
+ onDOMContentLoaded(event.target.ownerGlobal);
+ }
+ },
+ { capture: true, once: true }
+ );
+ }
+ browser.addEventListener(
+ "load",
+ event => {
+ if (!tab.closed) {
+ onLoad(event.target.ownerGlobal);
+ }
+ },
+ { capture: true, once: true }
+ );
+
+ tab.title = "";
+ tab.panel.id = `${tab.mode.name}${tab.mode._nextId}`;
+ tab.panel.appendChild(clone);
+ // `chromeBrowser` refers to the outermost browser in the tab, i.e. the
+ // browser displaying about:3pane or about:message.
+ tab.chromeBrowser = browser;
+ tab.mode._nextId++;
+ },
+
+ closeTab(tab) {},
+ saveTabState(tab) {},
+
+ modes: {
+ mail3PaneTab: {
+ _nextId: 1,
+ isDefault: true,
+
+ openTab(tab, args = {}) {
+ mailTabType._cloneTemplate(
+ "mail3PaneTabTemplate",
+ tab,
+ win => {
+ // Send the state to the page so it can restore immediately.
+ win.openingState = args;
+ },
+ async win => {
+ win.tabOrWindow = tab;
+ // onLoad has happened. async activities of scripts running of
+ // that may not have finished. Let's go back to the end of the
+ // event queue giving win.messageBrowser time to get defined.
+ await new Promise(resolve => win.setTimeout(resolve));
+ win.messageBrowser.contentWindow.tabOrWindow = tab;
+ if (!args.background) {
+ // Update telemetry once the tab has loaded and decided if the
+ // panes are visible.
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.pane_visibility",
+ "folderPane",
+ win.paneLayout.folderPaneVisible
+ );
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.pane_visibility",
+ "messagePane",
+ win.paneLayout.messagePaneVisible
+ );
+ }
+
+ // The first tab has loaded and ready for the user to interact with
+ // it. We can let the rest of the start-up happen now without
+ // appearing to slow the program down.
+ if (tab.first) {
+ Services.obs.notifyObservers(window, "mail-startup-done");
+ requestIdleCallback(function () {
+ if (!window.closed) {
+ Services.obs.notifyObservers(
+ window,
+ "mail-idle-startup-tasks-finished"
+ );
+ }
+ });
+ }
+ }
+ );
+
+ // `browser` and `linkedBrowser` refer to the message display browser
+ // within this tab. They may be null if the browser isn't visible.
+ // Extension APIs refer to these properties.
+ Object.defineProperty(tab, "browser", {
+ get() {
+ if (!tab.chromeBrowser.contentWindow) {
+ return null;
+ }
+
+ const { messageBrowser, webBrowser } =
+ tab.chromeBrowser.contentWindow;
+ if (messageBrowser && !messageBrowser.hidden) {
+ return messageBrowser.contentDocument.getElementById(
+ "messagepane"
+ );
+ }
+ if (webBrowser && !webBrowser.hidden) {
+ return webBrowser;
+ }
+
+ return null;
+ },
+ });
+ Object.defineProperty(tab, "linkedBrowser", {
+ get() {
+ return tab.browser;
+ },
+ });
+
+ // Content properties.
+ Object.defineProperty(tab, "message", {
+ get() {
+ let dbView = tab.chromeBrowser.contentWindow.gDBView;
+ if (dbView?.selection?.count) {
+ return dbView.hdrForFirstSelectedMessage;
+ }
+ return null;
+ },
+ });
+ Object.defineProperty(tab, "folder", {
+ get() {
+ return tab.chromeBrowser.contentWindow.gFolder;
+ },
+ set(folder) {
+ tab.chromeBrowser.contentWindow.displayFolder(folder.URI);
+ },
+ });
+
+ tab.canClose = !tab.first;
+ return tab;
+ },
+ persistTab(tab) {
+ if (!tab.folder) {
+ return null;
+ }
+ return {
+ firstTab: tab.first,
+ folderPaneVisible:
+ tab.chromeBrowser.contentWindow.paneLayout.folderPaneVisible,
+ folderURI: tab.folder.URI,
+ messagePaneVisible:
+ tab.chromeBrowser.contentWindow.paneLayout.messagePaneVisible,
+ };
+ },
+ restoreTab(tabmail, persistedState) {
+ if (!persistedState.firstTab) {
+ tabmail.openTab("mail3PaneTab", persistedState);
+ return;
+ }
+
+ // Manually call onTabRestored, since it is usually called by openTab(),
+ // which is skipped for the first tab.
+ let restoreState = tabmail._restoringTabState;
+ if (restoreState) {
+ for (let tabMonitor of tabmail.tabMonitors) {
+ try {
+ if (
+ "onTabRestored" in tabMonitor &&
+ restoreState &&
+ tabMonitor.monitorName in restoreState.ext
+ ) {
+ tabMonitor.onTabRestored(
+ tabmail.tabInfo[0],
+ restoreState.ext[tabMonitor.monitorName],
+ false
+ );
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ let { chromeBrowser, closed } = tabmail.tabInfo[0];
+ if (
+ chromeBrowser.contentDocument.readyState == "complete" &&
+ chromeBrowser.currentURI.spec == "about:3pane"
+ ) {
+ chromeBrowser.contentWindow.restoreState(persistedState);
+ return;
+ }
+
+ // Send the state to the page so it can restore immediately. Don't
+ // overwrite any existing state properties from `openTab` (especially
+ // `first`), unless there is a newer value.
+ let sawDOMContentLoaded = false;
+ chromeBrowser.addEventListener(
+ "DOMContentLoaded",
+ event => {
+ if (!closed && event.target == chromeBrowser.contentDocument) {
+ let about3Pane = event.target.ownerGlobal;
+ about3Pane.openingState = {
+ ...about3Pane.openingState,
+ ...persistedState,
+ };
+ sawDOMContentLoaded = true;
+ }
+ },
+ { capture: true, once: true }
+ );
+ // Didn't see DOMContentLoaded? Restore the state on load. The state
+ // from `openTab` has been used by now.
+ chromeBrowser.addEventListener(
+ "load",
+ event => {
+ if (
+ !closed &&
+ !sawDOMContentLoaded &&
+ event.target == chromeBrowser.contentDocument
+ ) {
+ chromeBrowser.contentWindow.restoreState(persistedState);
+ }
+ },
+ { capture: true, once: true }
+ );
+ },
+ showTab(tab) {
+ if (
+ tab.chromeBrowser.currentURI.spec != "about:3pane" ||
+ tab.chromeBrowser.contentDocument.readyState != "complete"
+ ) {
+ return;
+ }
+
+ // Update telemetry when switching to a 3-pane tab. The telemetry
+ // reflects the state of the last 3-pane tab that was shown, but not
+ // if the state changed since it was shown.
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.pane_visibility",
+ "folderPane",
+ tab.chromeBrowser.contentWindow.paneLayout.folderPaneVisible
+ );
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.pane_visibility",
+ "messagePane",
+ tab.chromeBrowser.contentWindow.paneLayout.messagePaneVisible
+ );
+ },
+ supportsCommand(command, tab) {
+ return tab.chromeBrowser?.contentWindow.commandController?.supportsCommand(
+ command
+ );
+ },
+ isCommandEnabled(command, tab) {
+ return tab.chromeBrowser?.contentWindow.commandController?.isCommandEnabled(
+ command
+ );
+ },
+ doCommand(command, tab, ...args) {
+ tab.chromeBrowser?.contentWindow.commandController?.doCommand(
+ command,
+ ...args
+ );
+ },
+ getBrowser(tab) {
+ return tab.browser;
+ },
+ },
+ mailMessageTab: {
+ _nextId: 1,
+ openTab(tab, { messageURI, viewWrapper } = {}) {
+ mailTabType._cloneTemplate(
+ "mailMessageTabTemplate",
+ tab,
+ win => {
+ // Make tabmail give the message pane focus when this tab becomes
+ // the active tab.
+ tab.lastActiveElement = tab.browser;
+ },
+ win => {
+ win.tabOrWindow = tab;
+ win.displayMessage(messageURI, viewWrapper);
+ }
+ );
+
+ // `browser` and `linkedBrowser` refer to the message display browser
+ // within this tab. They may be null if the browser isn't visible.
+ // Extension APIs refer to these properties.
+ Object.defineProperty(tab, "browser", {
+ get() {
+ return tab.chromeBrowser.contentDocument?.getElementById(
+ "messagepane"
+ );
+ },
+ });
+ Object.defineProperty(tab, "linkedBrowser", {
+ get() {
+ return tab.browser;
+ },
+ });
+
+ // Content properties.
+ Object.defineProperty(tab, "message", {
+ get() {
+ return tab.chromeBrowser.contentWindow.gMessage;
+ },
+ });
+ Object.defineProperty(tab, "folder", {
+ get() {
+ return tab.chromeBrowser.contentWindow.gViewWrapper
+ ?.displayedFolder;
+ },
+ });
+
+ return tab;
+ },
+ persistTab(tab) {
+ return { messageURI: tab.chromeBrowser.contentWindow.gMessageURI };
+ },
+ restoreTab(tabmail, persistedState) {
+ tabmail.openTab("mailMessageTab", persistedState);
+ },
+ showTab(tab) {},
+ supportsCommand(command, tab) {
+ return tab.chromeBrowser?.contentWindow.commandController?.supportsCommand(
+ command
+ );
+ },
+ isCommandEnabled(command, tab) {
+ return tab.chromeBrowser.contentWindow.commandController?.isCommandEnabled(
+ command
+ );
+ },
+ doCommand(command, tab, ...args) {
+ tab.chromeBrowser?.contentWindow.commandController?.doCommand(
+ command,
+ ...args
+ );
+ },
+ getBrowser(tab) {
+ return tab.browser;
+ },
+ },
+ },
+};
diff --git a/comm/mail/base/content/mailWindow.js b/comm/mail/base/content/mailWindow.js
new file mode 100644
index 0000000000..106678ec56
--- /dev/null
+++ b/comm/mail/base/content/mailWindow.js
@@ -0,0 +1,1153 @@
+/**
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/contentAreaUtils.js */
+/* import-globals-from ../../../../toolkit/content/viewZoomOverlay.js */
+/* import-globals-from globalOverlay.js */
+/* import-globals-from mail-offline.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from mailWindowOverlay.js */
+/* import-globals-from messenger.js */
+/* import-globals-from utilityOverlay.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ appIdleManager: "resource:///modules/AppIdleManager.jsm",
+ Gloda: "resource:///modules/gloda/GlodaPublic.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+});
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://messenger/content/printUtils.js"
+);
+
+// This file stores variables common to mail windows
+var messenger;
+var statusFeedback;
+var msgWindow;
+
+UIDensity.registerWindow(window);
+
+/**
+ * Called by messageWindow.xhtml:onunload, the 'single message display window'.
+ *
+ * Also called by messenger.xhtml:onunload's (the 3-pane window inside of tabs
+ * window) unload function, OnUnloadMessenger.
+ */
+function OnMailWindowUnload() {
+ MailOfflineMgr.uninit();
+
+ // all dbview closing is handled by OnUnloadMessenger for the 3-pane (it closes
+ // the tabs which close their views) and OnUnloadMessageWindow for the
+ // standalone message window.
+
+ MailServices.mailSession.RemoveMsgWindow(msgWindow);
+ // the tabs have the FolderDisplayWidget close their 'messenger' instances for us
+
+ window.browserDOMWindow = null;
+
+ msgWindow.closeWindow();
+
+ msgWindow.notificationCallbacks = null;
+ window.MsgStatusFeedback.unload();
+ Cc["@mozilla.org/activity-manager;1"]
+ .getService(Ci.nsIActivityManager)
+ .removeListener(window.MsgStatusFeedback);
+}
+
+/**
+ * When copying/dragging, convert imap/mailbox URLs of images into data URLs so
+ * that the images can be accessed in a paste elsewhere.
+ */
+function onCopyOrDragStart(e) {
+ let browser = getBrowser();
+ if (!browser) {
+ return;
+ }
+
+ // We're only interested if this is in the message content.
+ let sourceDoc = browser.contentDocument;
+ if (e.target.ownerDocument != sourceDoc) {
+ return;
+ }
+ let sourceURL = sourceDoc.URL;
+ let protocol = sourceURL.substr(0, sourceURL.indexOf(":")).toLowerCase();
+ if (
+ !(
+ Services.io.getProtocolHandler(protocol) instanceof
+ Ci.nsIMsgMessageFetchPartService
+ )
+ ) {
+ // Can't fetch parts, not a message protocol, don't process.
+ return;
+ }
+
+ let imgMap = new Map(); // Mapping img.src -> dataURL.
+
+ // For copy, the data of what is to be copied is not accessible at this point.
+ // Figure out what images are a) part of the selection and b) visible in
+ // the current document. If their source isn't http or data already, convert
+ // them to data URLs.
+
+ let selection = sourceDoc.getSelection();
+ let draggedImg = selection.isCollapsed ? e.target : null;
+ for (let img of sourceDoc.images) {
+ if (/^(https?|data):/.test(img.src)) {
+ continue;
+ }
+
+ if (img.naturalWidth == 0) {
+ // Broken/inaccessible image then...
+ continue;
+ }
+
+ if (!draggedImg && !selection.containsNode(img, true)) {
+ continue;
+ }
+
+ let style = window.getComputedStyle(img);
+ if (style.display == "none" || style.visibility == "hidden") {
+ continue;
+ }
+
+ // Do not convert if the image is specifically flagged to not snarf.
+ if (img.getAttribute("moz-do-not-send") == "true") {
+ continue;
+ }
+
+ // We don't need to wait for the image to load. If it isn't already loaded
+ // in the source document, we wouldn't want it anyway.
+ let canvas = sourceDoc.createElement("canvas");
+ canvas.width = img.width;
+ canvas.height = img.height;
+ canvas.getContext("2d").drawImage(img, 0, 0, img.width, img.height);
+
+ let type = /\.jpe?g$/i.test(img.src) ? "image/jpg" : "image/png";
+ imgMap.set(img.src, canvas.toDataURL(type));
+ }
+
+ if (imgMap.size == 0) {
+ // Nothing that needs converting!
+ return;
+ }
+
+ let clonedSelection = draggedImg
+ ? draggedImg.cloneNode(false)
+ : selection.getRangeAt(0).cloneContents();
+ let div = sourceDoc.createElement("div");
+ div.appendChild(clonedSelection);
+
+ let images = div.querySelectorAll("img");
+ for (let img of images) {
+ if (!imgMap.has(img.src)) {
+ continue;
+ }
+ img.src = imgMap.get(img.src);
+ }
+
+ let html = div.innerHTML;
+ let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+ );
+ let plain = parserUtils.convertToPlainText(
+ html,
+ Ci.nsIDocumentEncoder.OutputForPlainTextClipboardCopy,
+ 0
+ );
+ if ("clipboardData" in e) {
+ // copy
+ e.clipboardData.setData("text/html", html);
+ e.clipboardData.setData("text/plain", plain);
+ e.preventDefault();
+ } else if ("dataTransfer" in e) {
+ // drag
+ e.dataTransfer.setData("text/html", html);
+ e.dataTransfer.setData("text/plain", plain);
+ }
+}
+
+function CreateMailWindowGlobals() {
+ // Create message window object
+ // eslint-disable-next-line no-global-assign
+ msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+ );
+ // get the messenger instance
+ // eslint-disable-next-line no-global-assign
+ messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+ messenger.setWindow(window, msgWindow);
+
+ window.addEventListener("blur", appIdleManager.onBlur);
+ window.addEventListener("focus", appIdleManager.onFocus);
+
+ // Create windows status feedback
+ // set the JS implementation of status feedback before creating the c++ one..
+ window.MsgStatusFeedback = new nsMsgStatusFeedback();
+ // double register the status feedback object as the xul browser window implementation
+ window
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShellTreeItem)
+ .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.MsgStatusFeedback;
+
+ window.browserDOMWindow = new nsBrowserAccess();
+
+ // eslint-disable-next-line no-global-assign
+ statusFeedback = Cc["@mozilla.org/messenger/statusfeedback;1"].createInstance(
+ Ci.nsIMsgStatusFeedback
+ );
+ statusFeedback.setWrappedStatusFeedback(window.MsgStatusFeedback);
+
+ Cc["@mozilla.org/activity-manager;1"]
+ .getService(Ci.nsIActivityManager)
+ .addListener(window.MsgStatusFeedback);
+}
+
+function toggleCaretBrowsing() {
+ const enabledPref = "accessibility.browsewithcaret_shortcut.enabled";
+ const warnPref = "accessibility.warn_on_browsewithcaret";
+ const caretPref = "accessibility.browsewithcaret";
+
+ if (!Services.prefs.getBoolPref(enabledPref)) {
+ return;
+ }
+
+ let useCaret = Services.prefs.getBoolPref(caretPref, false);
+ let warn = Services.prefs.getBoolPref(warnPref, true);
+ if (!warn || useCaret) {
+ // Toggle immediately.
+ try {
+ Services.prefs.setBoolPref(caretPref, !useCaret);
+ } catch (ex) {}
+ return;
+ }
+
+ // Async prompt.
+ document.l10n
+ .formatValues([
+ { id: "caret-browsing-prompt-title" },
+ { id: "caret-browsing-prompt-text" },
+ { id: "caret-browsing-prompt-check-text" },
+ ])
+ .then(([title, promptText, checkText]) => {
+ let checkValue = { value: false };
+
+ useCaret =
+ 0 ===
+ Services.prompt.confirmEx(
+ window,
+ title,
+ promptText,
+ Services.prompt.STD_YES_NO_BUTTONS |
+ Services.prompt.BUTTON_POS_1_DEFAULT,
+ null,
+ null,
+ null,
+ checkText,
+ checkValue
+ );
+
+ if (checkValue.value) {
+ if (useCaret) {
+ try {
+ Services.prefs.setBoolPref(warnPref, false);
+ } catch (ex) {}
+ } else {
+ try {
+ Services.prefs.setBoolPref(enabledPref, false);
+ } catch (ex) {}
+ }
+ }
+ try {
+ Services.prefs.setBoolPref(caretPref, useCaret);
+ } catch (ex) {}
+ });
+}
+
+function InitMsgWindow() {
+ // Set the domWindow before setting the status feedback object.
+ msgWindow.domWindow = window;
+ msgWindow.statusFeedback = statusFeedback;
+ MailServices.mailSession.AddMsgWindow(msgWindow);
+ msgWindow.rootDocShell.allowAuth = true;
+ msgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL;
+ // Ensure we don't load xul error pages into the main window
+ msgWindow.rootDocShell.useErrorPages = false;
+
+ document.addEventListener("dragstart", onCopyOrDragStart, true);
+
+ let keypressListener = {
+ handleEvent: event => {
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ switch (event.code) {
+ case "F7":
+ // shift + F7 is the default DevTools shortcut for the Style Editor.
+ if (!event.shiftKey) {
+ toggleCaretBrowsing();
+ }
+ break;
+ }
+ },
+ };
+ Services.els.addSystemEventListener(
+ document,
+ "keypress",
+ keypressListener,
+ false
+ );
+}
+
+// We're going to implement our status feedback for the mail window in JS now.
+// the following contains the implementation of our status feedback object
+
+function nsMsgStatusFeedback() {
+ this._statusText = document.getElementById("statusText");
+ this._statusPanel = document.getElementById("statusbar-display");
+ this._progressBar = document.getElementById("statusbar-icon");
+ this._progressBarContainer = document.getElementById(
+ "statusbar-progresspanel"
+ );
+ this._throbber = document.getElementById("throbber-box");
+ this._activeProcesses = [];
+
+ // make sure the stop button is accurate from the get-go
+ goUpdateCommand("cmd_stop");
+}
+
+/**
+ * @implements {nsIMsgStatusFeedback}
+ * @implements {nsIXULBrowserWindow}
+ * @implements {nsIActivityMgrListener}
+ * @implements {nsIActivityListener}
+ * @implements {nsISupportsWeakReference}
+ */
+nsMsgStatusFeedback.prototype = {
+ // Document elements.
+ _statusText: null,
+ _statusPanel: null,
+ _progressBar: null,
+ _progressBarContainer: null,
+ _throbber: null,
+
+ // Member variables.
+ _startTimeoutID: null,
+ _stopTimeoutID: null,
+ // How many start meteors have been requested.
+ _startRequests: 0,
+ _meteorsSpinning: false,
+ _defaultStatusText: "",
+ _progressBarVisible: false,
+ _activeProcesses: null,
+ _statusFeedbackProgress: -1,
+ _statusLastShown: 0,
+ _lastStatusText: null,
+
+ // unload - call to remove links to listeners etc.
+ unload() {
+ // Remove listeners for any active processes we have hooked ourselves into.
+ this._activeProcesses.forEach(function (element) {
+ element.removeListener(this);
+ }, this);
+ },
+
+ // nsIXULBrowserWindow implementation.
+ setJSStatus(status) {
+ if (status.length > 0) {
+ this.showStatusString(status);
+ }
+ },
+
+ /*
+ * Set the statusbar display for hovered links, from browser.js.
+ *
+ * @param {String} url - The href to display.
+ * @param {Element} anchorElt - Element.
+ */
+ setOverLink(url, anchorElt) {
+ if (url) {
+ url = Services.textToSubURI.unEscapeURIForUI(url);
+
+ // Encode bidirectional formatting characters.
+ // (RFC 3987 sections 3.2 and 4.1 paragraph 6)
+ url = url.replace(
+ /[\u200e\u200f\u202a\u202b\u202c\u202d\u202e]/g,
+ encodeURIComponent
+ );
+ }
+
+ if (!document.getElementById("status-bar").hidden) {
+ this._statusText.value = url;
+ } else {
+ // Statusbar invisible: Show link in statuspanel instead.
+ // TODO: consider porting the Firefox implementation of LinkTargetDisplay.
+ this._statusPanel.label = url;
+ }
+ },
+
+ // 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, needed for tooltips in content tabs.
+ 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, needed for tooltips in content tabs.
+ hideTooltip() {
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.hidePopup();
+ },
+
+ getTabCount() {
+ let tabmail = document.getElementById("tabmail");
+ // messageWindow.xhtml does not have multiple tabs.
+ return tabmail ? tabmail.tabs.length : 1;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMsgStatusFeedback",
+ "nsIXULBrowserWindow",
+ "nsIActivityMgrListener",
+ "nsIActivityListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ // nsIMsgStatusFeedback implementation.
+ showStatusString(statusText) {
+ if (!statusText) {
+ statusText = this._defaultStatusText;
+ } else {
+ this._defaultStatusText = "";
+ }
+ // Let's make sure the display doesn't flicker.
+ const timeBetweenDisplay = 500;
+ const now = Date.now();
+ if (now - this._statusLastShown > timeBetweenDisplay) {
+ // Cancel any pending status message. The timeout is not guaranteed
+ // to run within timeBetweenDisplay milliseconds.
+ this._lastStatusText = null;
+
+ this._statusLastShown = now;
+ if (this._statusText.value != statusText) {
+ this._statusText.value = statusText;
+ }
+ } else {
+ if (this._lastStatusText !== null) {
+ // There's already a pending display. Replace it.
+ this._lastStatusText = statusText;
+ return;
+ }
+ // Arrange for this to be shown in timeBetweenDisplay milliseconds.
+ this._lastStatusText = statusText;
+ setTimeout(() => {
+ if (this._lastStatusText !== null) {
+ this._statusLastShown = Date.now();
+ if (this._statusText.value != this._lastStatusText) {
+ this._statusText.value = this._lastStatusText;
+ }
+ this._lastStatusText = null;
+ }
+ }, timeBetweenDisplay);
+ }
+ },
+
+ setStatusString(status) {
+ if (status.length > 0) {
+ this._defaultStatusText = status;
+ this._statusText.value = status;
+ }
+ },
+
+ _startMeteors() {
+ this._meteorsSpinning = true;
+ this._startTimeoutID = null;
+
+ // Turn progress meter on.
+ this.updateProgress();
+
+ // Start the throbber.
+ if (this._throbber) {
+ this._throbber.setAttribute("busy", true);
+ }
+
+ document.querySelector(".throbber")?.classList.add("busy");
+
+ // Update the stop button
+ goUpdateCommand("cmd_stop");
+ },
+
+ startMeteors() {
+ this._startRequests++;
+ // If we don't already have a start meteor timeout pending
+ // and the meteors aren't spinning, then kick off a start.
+ if (
+ !this._startTimeoutID &&
+ !this._meteorsSpinning &&
+ "MsgStatusFeedback" in window
+ ) {
+ this._startTimeoutID = setTimeout(
+ () => window.MsgStatusFeedback._startMeteors(),
+ 500
+ );
+ }
+
+ // Since we are going to start up the throbber no sense in processing
+ // a stop timeout...
+ if (this._stopTimeoutID) {
+ clearTimeout(this._stopTimeoutID);
+ this._stopTimeoutID = null;
+ }
+ },
+
+ _stopMeteors() {
+ this.showStatusString(this._defaultStatusText);
+
+ // stop the throbber
+ if (this._throbber) {
+ this._throbber.setAttribute("busy", false);
+ }
+
+ document.querySelector(".throbber")?.classList.remove("busy");
+
+ this._meteorsSpinning = false;
+ this._stopTimeoutID = null;
+
+ // Turn progress meter off.
+ this._statusFeedbackProgress = -1;
+ this.updateProgress();
+
+ // Update the stop button
+ goUpdateCommand("cmd_stop");
+ },
+
+ stopMeteors() {
+ if (this._startRequests > 0) {
+ this._startRequests--;
+ }
+
+ // If we are going to be starting the meteors, cancel the start.
+ if (this._startRequests == 0 && this._startTimeoutID) {
+ clearTimeout(this._startTimeoutID);
+ this._startTimeoutID = null;
+ }
+
+ // If we have no more pending starts and we don't have a stop timeout
+ // already in progress AND the meteors are currently running then fire a
+ // stop timeout to shut them down.
+ if (
+ this._startRequests == 0 &&
+ !this._stopTimeoutID &&
+ this._meteorsSpinning &&
+ "MsgStatusFeedback" in window
+ ) {
+ this._stopTimeoutID = setTimeout(
+ () => window.MsgStatusFeedback._stopMeteors(),
+ 500
+ );
+ }
+ },
+
+ showProgress(percentage) {
+ this._statusFeedbackProgress = percentage;
+ this.updateProgress();
+ },
+
+ updateProgress() {
+ if (this._meteorsSpinning) {
+ // In this function, we expect that the maximum for each progress is 100,
+ // i.e. we are dealing with percentages. Hence we can combine several
+ // processes running at the same time.
+ let currentProgress = 0;
+ let progressCount = 0;
+
+ // For each activity that is in progress, get its status.
+
+ this._activeProcesses.forEach(function (element) {
+ if (
+ element.state == Ci.nsIActivityProcess.STATE_INPROGRESS &&
+ element.percentComplete != -1
+ ) {
+ currentProgress += element.percentComplete;
+ ++progressCount;
+ }
+ });
+
+ // Add the generic progress that's fed to the status feedback object if
+ // we've got one.
+ if (this._statusFeedbackProgress != -1) {
+ currentProgress += this._statusFeedbackProgress;
+ ++progressCount;
+ }
+
+ let percentage = 0;
+ if (progressCount) {
+ percentage = currentProgress / progressCount;
+ }
+
+ if (!percentage) {
+ this._progressBar.removeAttribute("value");
+ } else {
+ this._progressBar.value = percentage;
+ this._progressBar.label = Math.round(percentage) + "%";
+ }
+ if (!this._progressBarVisible) {
+ this._progressBarContainer.removeAttribute("collapsed");
+ this._progressBarVisible = true;
+ }
+ } else {
+ // Stop the bar spinning as we're not doing anything now.
+ this._progressBar.value = 0;
+ this._progressBar.label = "";
+
+ if (this._progressBarVisible) {
+ this._progressBarContainer.collapsed = true;
+ this._progressBarVisible = false;
+ }
+ }
+ },
+
+ // nsIActivityMgrListener
+ onAddedActivity(aID, aActivity) {
+ // ignore Gloda activity for status bar purposes
+ if (aActivity.initiator == Gloda) {
+ return;
+ }
+ if (aActivity instanceof Ci.nsIActivityEvent) {
+ this.showStatusString(aActivity.displayText);
+ } else if (aActivity instanceof Ci.nsIActivityProcess) {
+ this._activeProcesses.push(aActivity);
+ aActivity.addListener(this);
+ this.startMeteors();
+ }
+ },
+
+ onRemovedActivity(aID) {
+ this._activeProcesses = this._activeProcesses.filter(function (element) {
+ if (element.id == aID) {
+ element.removeListener(this);
+ this.stopMeteors();
+ return false;
+ }
+ return true;
+ }, this);
+ },
+
+ // nsIActivityListener
+ onStateChanged(aActivity, aOldState) {},
+
+ onProgressChanged(
+ aActivity,
+ aStatusText,
+ aWorkUnitsCompleted,
+ aTotalWorkUnits
+ ) {
+ let index = this._activeProcesses.indexOf(aActivity);
+
+ // Iterate through the list trying to find the first active process, but
+ // only go as far as our process.
+ for (var i = 0; i < index; ++i) {
+ if (
+ this._activeProcesses[i].status ==
+ Ci.nsIActivityProcess.STATE_INPROGRESS
+ ) {
+ break;
+ }
+ }
+
+ // If the found activity was the same as our activity, update the status
+ // text.
+ if (i == index) {
+ // Use the display text if we haven't got any status text. I'm assuming
+ // that the status text will be generally what we want to see on the
+ // status bar.
+ this.showStatusString(aStatusText ? aStatusText : aActivity.displayText);
+ }
+
+ this.updateProgress();
+ },
+
+ onHandlerChanged(aActivity) {},
+};
+
+/**
+ * Returns the browser element of the current tab.
+ * The zoom manager, view source and possibly some other functions still rely
+ * on the getBrowser function.
+ */
+function getBrowser() {
+ let tabmail = document.getElementById("tabmail");
+ return tabmail ? tabmail.getBrowserForSelectedTab() : null;
+}
+
+// Given the server, open the twisty and the set the selection
+// on inbox of that server.
+// prompt if offline.
+function OpenInboxForServer(server) {
+ // TODO: Reimplement this or fix the caller?
+}
+
+/** Update state of zoom type (text vs. full) menu item. */
+function UpdateFullZoomMenu() {
+ let cmdItem = document.getElementById("cmd_fullZoomToggle");
+ cmdItem.setAttribute("checked", !ZoomManager.useFullZoom);
+}
+
+window.addEventListener("DoZoomEnlargeBy10", event =>
+ ZoomManager.scrollZoomEnlarge(event.target)
+);
+
+window.addEventListener("DoZoomReduceBy10", event =>
+ ZoomManager.scrollReduceEnlarge(event.target)
+);
+
+function nsBrowserAccess() {}
+
+nsBrowserAccess.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIBrowserDOMWindow"]),
+
+ _openURIInNewTab(
+ aURI,
+ aReferrerInfo,
+ aIsExternal,
+ aOpenWindowInfo = null,
+ aTriggeringPrincipal = null,
+ aCsp = null,
+ aSkipLoad = false,
+ aMessageManagerGroup = null
+ ) {
+ let win, needToFocusWin;
+
+ // Try the current window. If we're in a popup, fall back on the most
+ // recent browser window.
+ if (!window.document.documentElement.getAttribute("chromehidden")) {
+ win = window;
+ } else {
+ win = getMostRecentMailWindow();
+ needToFocusWin = 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",
+ });
+
+ if (needToFocusWin || (!loadInBackground && aIsExternal)) {
+ win.focus();
+ }
+
+ return newTab.browser;
+ },
+
+ createContentWindow(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp
+ ) {
+ return this.getContentWindowOrOpenURI(
+ null,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ true
+ );
+ },
+
+ 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) {
+ if (!aURI) {
+ throw Components.Exception(
+ "openURI should only be called with a valid URI",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ return this.getContentWindowOrOpenURI(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ false
+ );
+ },
+
+ openURIInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ return this.getContentWindowOrOpenURIInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ false
+ );
+ },
+
+ getContentWindowOrOpenURI(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ aSkipLoad
+ ) {
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ let browser =
+ PrintUtils.handleStaticCloneCreatedForPrint(aOpenWindowInfo);
+ return browser ? browser.browsingContext : null;
+ }
+
+ let isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL);
+
+ if (aOpenWindowInfo && isExternal) {
+ throw Components.Exception(
+ "nsBrowserAccess.openURI did not expect aOpenWindowInfo to be " +
+ "passed if the context is OPEN_EXTERNAL.",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (isExternal && aURI && aURI.schemeIs("chrome")) {
+ Services.console.logStringMessage(
+ "use -chrome command-line option to load external chrome urls\n"
+ );
+ return null;
+ }
+
+ const ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ );
+
+ let referrerInfo;
+ if (aFlags & Ci.nsIBrowserDOMWindow.OPEN_NO_REFERRER) {
+ referrerInfo = new ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, false, null);
+ } else if (
+ aOpenWindowInfo &&
+ aOpenWindowInfo.parent &&
+ aOpenWindowInfo.parent.window
+ ) {
+ referrerInfo = new ReferrerInfo(
+ aOpenWindowInfo.parent.window.document.referrerInfo.referrerPolicy,
+ true,
+ makeURI(aOpenWindowInfo.parent.window.location.href)
+ );
+ } else {
+ referrerInfo = new ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, true, null);
+ }
+
+ if (aWhere != Ci.nsIBrowserDOMWindow.OPEN_NEWTAB) {
+ Services.console.logStringMessage(
+ "Opening a URI in something other than a new tab is not supported, opening in new tab instead"
+ );
+ }
+
+ let browser = this._openURIInNewTab(
+ aURI,
+ referrerInfo,
+ isExternal,
+ aOpenWindowInfo,
+ aTriggeringPrincipal,
+ aCsp,
+ aSkipLoad,
+ aOpenWindowInfo?.openerBrowser?.getAttribute("messagemanagergroup")
+ );
+
+ return browser ? browser.browsingContext : null;
+ },
+
+ 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() {
+ let tabmail = document.getElementById("tabmail");
+ // messageWindow.xhtml does not have multiple tabs.
+ return tabmail ? tabmail.tabInfo.length : 1;
+ },
+};
+
+/**
+ * Called from the extensions manager to open an add-on options XUL document.
+ * Only the "open in tab" option is supported, so that's what we'll do here.
+ */
+function switchToTabHavingURI(aURI, aOpenNew, aOpenParams = {}) {
+ let tabmail = document.getElementById("tabmail");
+ let matchingIndex = -1;
+ if (tabmail) {
+ // about:preferences should be opened through openPreferencesTab().
+ if (aURI == "about:preferences") {
+ openPreferencesTab();
+ return true;
+ }
+
+ let openURI = makeURI(aURI);
+ let tabInfo = tabmail.tabInfo;
+
+ // Check if we already have the same URL open in a content tab.
+ for (let tabIndex = 0; tabIndex < tabInfo.length; tabIndex++) {
+ if (tabInfo[tabIndex].mode.name == "contentTab") {
+ let browserFunc =
+ tabInfo[tabIndex].mode.getBrowser ||
+ tabInfo[tabIndex].mode.tabType.getBrowser;
+ if (browserFunc) {
+ let browser = browserFunc.call(
+ tabInfo[tabIndex].mode.tabType,
+ tabInfo[tabIndex]
+ );
+ if (browser.currentURI.equals(openURI)) {
+ matchingIndex = tabIndex;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Open the found matching tab.
+ if (tabmail && matchingIndex > -1) {
+ tabmail.switchToTab(matchingIndex);
+ return true;
+ }
+
+ if (aOpenNew) {
+ tabmail.openTab("contentTab", { ...aOpenParams, url: aURI });
+ }
+
+ return false;
+}
+
+/**
+ * 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(),
+
+ 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) {
+ if (this.browser.hidden) {
+ // Ignore events from hidden browsers. This should avoid confusion in
+ // about:3pane, where multiple browsers could send events.
+ return;
+ }
+ 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);
+ }
+ },
+};
+
+window.addEventListener("aboutMessageLoaded", event => {
+ // Add a progress listener to any about:message content browser that comes
+ // along. This often happens after the tab is opened so the usual mechanism
+ // doesn't work. It also works for standalone message windows.
+ contentProgress.addProgressListenerToBrowser(
+ event.target.getMessagePaneBrowser()
+ );
+ // Also add a copy listener so we can process images.
+ event.target.document.addEventListener("copy", onCopyOrDragStart, true);
+});
+
+// Listener to correctly set the busy flag on the webBrowser in about:3pane. All
+// other content tabs are handled by tabmail.js.
+contentProgress.addListener({
+ onStateChange(browser, webProgress, request, stateFlags, statusCode) {
+ // Skip if this is not the webBrowser in about:3pane.
+ if (browser.id != "webBrowser") {
+ 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";
+ }
+ browser.busy = status == "loading";
+ },
+});
diff --git a/comm/mail/base/content/mailWindowOverlay.js b/comm/mail/base/content/mailWindowOverlay.js
new file mode 100644
index 0000000000..449a92de07
--- /dev/null
+++ b/comm/mail/base/content/mailWindowOverlay.js
@@ -0,0 +1,2177 @@
+/* -*- indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 gSpacesToolbar */
+
+/* import-globals-from ../../../mailnews/extensions/newsblog/newsblogOverlay.js */
+/* import-globals-from contentAreaClick.js */
+/* import-globals-from mail3PaneWindowCommands.js */
+/* import-globals-from mailCommands.js */
+/* import-globals-from mailCore.js */
+
+/* import-globals-from utilityOverlay.js */
+
+/* globals messenger */ // From messageWindow.js
+/* globals GetSelectedMsgFolders */ // From messenger.js
+/* globals MailOfflineMgr */ // From mail-offline.js
+
+/* globals OnTagsChange, currentHeaderData */ // TODO: these aren't real.
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+
+ BrowserToolboxLauncher:
+ "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs",
+});
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MimeParser: "resource:///modules/mimeParser.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+});
+
+Object.defineProperty(this, "BrowserConsoleManager", {
+ get() {
+ let { loader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ return loader.require("devtools/client/webconsole/browser-console-manager")
+ .BrowserConsoleManager;
+ },
+ configurable: true,
+ enumerable: true,
+});
+
+// the user preference,
+// if HTML is not allowed. I assume, that the user could have set this to a
+// value > 1 in his prefs.js or user.js, but that the value will not
+// change during runtime other than through the MsgBody*() functions below.
+var gDisallow_classes_no_html = 1;
+
+/**
+ * Disable the new account menu item if the account preference is locked.
+ * The other affected areas are the account central, the account manager
+ * dialog, and the account provisioner window.
+ */
+function menu_new_init() {
+ // If the account provisioner is pref'd off, we shouldn't display the menu
+ // item.
+ ShowMenuItem(
+ "newCreateEmailAccountMenuItem",
+ Services.prefs.getBoolPref("mail.provider.enabled")
+ );
+
+ // If we don't have a folder, just get out of here and leave the menu as it is.
+ let folder = document.getElementById("tabmail")?.currentTabInfo.folder;
+ if (!folder) {
+ return;
+ }
+
+ if (Services.prefs.prefIsLocked("mail.disable_new_account_addition")) {
+ document
+ .getElementById("newNewsgroupAccountMenuItem")
+ .setAttribute("disabled", "true");
+ document
+ .getElementById("appmenu_newNewsgroupAccountMenuItem")
+ .setAttribute("disabled", "true");
+ }
+
+ var isInbox = folder.isSpecialFolder(Ci.nsMsgFolderFlags.Inbox);
+ var showNew =
+ (folder.canCreateSubfolders ||
+ (isInbox && !folder.getFlag(Ci.nsMsgFolderFlags.Virtual))) &&
+ document.getElementById("cmd_newFolder").getAttribute("disabled") != "true";
+ ShowMenuItem("menu_newFolder", showNew);
+ ShowMenuItem("menu_newVirtualFolder", showNew);
+ ShowMenuItem("newAccountPopupMenuSeparator", showNew);
+
+ EnableMenuItem(
+ "menu_newFolder",
+ folder.server.type != "imap" || MailOfflineMgr.isOnline()
+ );
+ if (showNew) {
+ var bundle = document.getElementById("bundle_messenger");
+ // Change "New Folder..." menu according to the context.
+ SetMenuItemLabel(
+ "menu_newFolder",
+ bundle.getString(
+ folder.isServer || isInbox
+ ? "newFolderMenuItem"
+ : "newSubfolderMenuItem"
+ )
+ );
+ }
+
+ goUpdateCommand("cmd_newMessage");
+}
+
+function goUpdateMailMenuItems(commandset) {
+ for (var i = 0; i < commandset.children.length; i++) {
+ var commandID = commandset.children[i].getAttribute("id");
+ if (commandID) {
+ goUpdateCommand(commandID);
+ }
+ }
+
+ updateCheckedStateForIgnoreAndWatchThreadCmds();
+}
+
+/**
+ * Update the ignore (sub)thread, and watch thread commands so the menus
+ * using them get the checked state set up properly.
+ */
+function updateCheckedStateForIgnoreAndWatchThreadCmds() {
+ let message;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ message = tab.message;
+ }
+
+ let folder = message?.folder;
+
+ let killThreadItem = document.getElementById("cmd_killThread");
+ if (folder?.msgDatabase.isIgnored(message.messageKey)) {
+ killThreadItem.setAttribute("checked", "true");
+ } else {
+ killThreadItem.removeAttribute("checked");
+ }
+ let killSubthreadItem = document.getElementById("cmd_killSubthread");
+ if (folder && message.flags & Ci.nsMsgMessageFlags.Ignored) {
+ killSubthreadItem.setAttribute("checked", "true");
+ } else {
+ killSubthreadItem.removeAttribute("checked");
+ }
+ let watchThreadItem = document.getElementById("cmd_watchThread");
+ if (folder?.msgDatabase.isWatched(message.messageKey)) {
+ watchThreadItem.setAttribute("checked", "true");
+ } else {
+ watchThreadItem.removeAttribute("checked");
+ }
+}
+
+function file_init() {
+ document.commandDispatcher.updateCommands("create-menu-file");
+}
+
+/**
+ * Update the menu items visibility in the Edit submenu.
+ */
+function InitEditMessagesMenu() {
+ document.commandDispatcher.updateCommands("create-menu-edit");
+
+ let chromeBrowser, folderTreeActive, folder, folderIsNewsgroup;
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (tab?.mode.name == "mail3PaneTab") {
+ chromeBrowser = tab.chromeBrowser;
+ folderTreeActive =
+ chromeBrowser.contentDocument.activeElement.id == "folderTree";
+ folder = chromeBrowser.contentWindow.gFolder;
+ folderIsNewsgroup = folder?.server.type == "nntp";
+ } else if (tab?.mode.name == "mailMessageTab") {
+ chromeBrowser = tab.chromeBrowser;
+ } else {
+ chromeBrowser = document.getElementById("messageBrowser");
+ }
+
+ let deleteController = getEnabledControllerForCommand("cmd_delete");
+ // If the controller is a JS object, it must be one we've implemented,
+ // not the built-in controller for textboxes.
+
+ let dbView = chromeBrowser?.contentWindow.gDBView;
+ let numSelected = dbView?.numSelected;
+
+ let deleteMenuItem = document.getElementById("menu_delete");
+ if (deleteController?.wrappedJSObject && folderTreeActive) {
+ let value = folderIsNewsgroup
+ ? "menu-edit-unsubscribe-newsgroup"
+ : "menu-edit-delete-folder";
+ document.l10n.setAttributes(deleteMenuItem, value);
+ } else if (deleteController?.wrappedJSObject && numSelected) {
+ let message = dbView?.hdrForFirstSelectedMessage;
+ let value;
+ if (message && message.flags & Ci.nsMsgMessageFlags.IMAPDeleted) {
+ value = "menu-edit-undelete-messages";
+ } else {
+ value = "menu-edit-delete-messages";
+ }
+ document.l10n.setAttributes(deleteMenuItem, value, { count: numSelected });
+ } else {
+ document.l10n.setAttributes(deleteMenuItem, "text-action-delete");
+ }
+
+ // Initialize the Favorite Folder checkbox in the Edit menu.
+ let favoriteFolderMenu = document.getElementById("menu_favoriteFolder");
+ if (folder?.getFlag(Ci.nsMsgFolderFlags.Favorite)) {
+ favoriteFolderMenu.setAttribute("checked", "true");
+ } else {
+ favoriteFolderMenu.removeAttribute("checked");
+ }
+
+ let propertiesController = getEnabledControllerForCommand("cmd_properties");
+ let propertiesMenuItem = document.getElementById("menu_properties");
+ if (tab?.mode.name == "mail3PaneTab" && propertiesController) {
+ let value = folderIsNewsgroup
+ ? "menu-edit-newsgroup-properties"
+ : "menu-edit-folder-properties";
+ document.l10n.setAttributes(propertiesMenuItem, value);
+ } else {
+ document.l10n.setAttributes(propertiesMenuItem, "menu-edit-properties");
+ }
+}
+
+/**
+ * Update the menu items visibility in the Find submenu.
+ */
+function initSearchMessagesMenu() {
+ // Show 'Global Search' menu item only when global search is enabled.
+ let glodaEnabled = Services.prefs.getBoolPref(
+ "mailnews.database.global.indexer.enabled"
+ );
+ document.getElementById("glodaSearchCmd").hidden = !glodaEnabled;
+}
+
+function InitGoMessagesMenu() {
+ document.commandDispatcher.updateCommands("create-menu-go");
+}
+
+/**
+ * This is called every time the view menu popup is displayed (in the main menu
+ * bar or in the appmenu). It is responsible for updating the menu items'
+ * state to reflect reality.
+ */
+function view_init(event) {
+ if (event && event.target.id != "menu_View_Popup") {
+ return;
+ }
+
+ let accountCentralVisible;
+ let folderPaneVisible;
+ let message;
+ let messagePaneVisible;
+ let quickFilterBarVisible;
+ let threadPaneHeaderVisible;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (tab?.mode.name == "mail3PaneTab") {
+ let chromeBrowser;
+ ({ chromeBrowser, message } = tab);
+ let { paneLayout, quickFilterBar } = chromeBrowser.contentWindow;
+ ({ accountCentralVisible, folderPaneVisible, messagePaneVisible } =
+ paneLayout);
+ quickFilterBarVisible = quickFilterBar.filterer.visible;
+ threadPaneHeaderVisible = true;
+ } else if (tab?.mode.name == "mailMessageTab") {
+ message = tab.message;
+ messagePaneVisible = true;
+ threadPaneHeaderVisible = false;
+ }
+
+ let isFeed = FeedUtils.isFeedMessage(message);
+
+ let qfbMenuItem = document.getElementById(
+ "view_toolbars_popup_quickFilterBar"
+ );
+ if (qfbMenuItem) {
+ qfbMenuItem.setAttribute("checked", quickFilterBarVisible);
+ }
+
+ let qfbAppMenuItem = document.getElementById("appmenu_quickFilterBar");
+ if (qfbAppMenuItem) {
+ if (quickFilterBarVisible) {
+ qfbAppMenuItem.setAttribute("checked", "true");
+ } else {
+ qfbAppMenuItem.removeAttribute("checked");
+ }
+ }
+
+ let messagePaneMenuItem = document.getElementById("menu_showMessage");
+ if (!messagePaneMenuItem.hidden) {
+ // Hidden in the standalone msg window.
+ messagePaneMenuItem.setAttribute(
+ "checked",
+ accountCentralVisible ? false : messagePaneVisible
+ );
+ messagePaneMenuItem.disabled = accountCentralVisible;
+ }
+
+ let messagePaneAppMenuItem = document.getElementById("appmenu_showMessage");
+ if (messagePaneAppMenuItem && !messagePaneAppMenuItem.hidden) {
+ // Hidden in the standalone msg window.
+ messagePaneAppMenuItem.setAttribute(
+ "checked",
+ accountCentralVisible ? false : messagePaneVisible
+ );
+ messagePaneAppMenuItem.disabled = accountCentralVisible;
+ }
+
+ let folderPaneMenuItem = document.getElementById("menu_showFolderPane");
+ if (!folderPaneMenuItem.hidden) {
+ // Hidden in the standalone msg window.
+ folderPaneMenuItem.setAttribute("checked", folderPaneVisible);
+ }
+
+ let folderPaneAppMenuItem = document.getElementById("appmenu_showFolderPane");
+ if (!folderPaneAppMenuItem.hidden) {
+ // Hidden in the standalone msg window.
+ folderPaneAppMenuItem.setAttribute("checked", folderPaneVisible);
+ }
+
+ let threadPaneMenuItem = document.getElementById(
+ "menu_toggleThreadPaneHeader"
+ );
+ threadPaneMenuItem.setAttribute("disabled", !threadPaneHeaderVisible);
+
+ let threadPaneAppMenuItem = document.getElementById(
+ "appmenu_toggleThreadPaneHeader"
+ );
+ threadPaneAppMenuItem.toggleAttribute("disabled", !threadPaneHeaderVisible);
+
+ // Disable some menus if account manager is showing
+ document.getElementById("viewSortMenu").disabled = accountCentralVisible;
+
+ document.getElementById("viewMessageViewMenu").disabled =
+ accountCentralVisible;
+
+ document.getElementById("viewMessagesMenu").disabled = accountCentralVisible;
+
+ // Hide the "View > Messages" menu item if the user doesn't have the "Views"
+ // (aka "Mail Views") toolbar button in the main toolbar. (See bug 1563789.)
+ var viewsToolbarButton = window.ViewPickerBinding?.isVisible;
+ document.getElementById("viewMessageViewMenu").hidden = !viewsToolbarButton;
+
+ // Initialize the Message Body menuitem
+ document.getElementById("viewBodyMenu").hidden = isFeed;
+
+ // Initialize the Show Feed Summary menu
+ let viewFeedSummary = document.getElementById("viewFeedSummary");
+ viewFeedSummary.hidden = !isFeed;
+
+ let viewRssMenuItemIds = [
+ "bodyFeedGlobalWebPage",
+ "bodyFeedGlobalSummary",
+ "bodyFeedPerFolderPref",
+ ];
+ let checked = FeedMessageHandler.onSelectPref;
+ for (let [index, id] of viewRssMenuItemIds.entries()) {
+ document.getElementById(id).setAttribute("checked", index == checked);
+ }
+
+ // Initialize the View Attachment Inline menu
+ var viewAttachmentInline = Services.prefs.getBoolPref(
+ "mail.inline_attachments"
+ );
+ document
+ .getElementById("viewAttachmentsInlineMenuitem")
+ .setAttribute("checked", viewAttachmentInline);
+
+ document.commandDispatcher.updateCommands("create-menu-view");
+
+ // No need to do anything if we don't have a spaces toolbar like in standalone
+ // windows or another non tabmail window.
+ let spacesToolbarMenu = document.getElementById("appmenu_spacesToolbar");
+ if (spacesToolbarMenu) {
+ // Update the spaces toolbar menu items.
+ let isSpacesVisible = !gSpacesToolbar.isHidden;
+ spacesToolbarMenu.checked = isSpacesVisible;
+ document
+ .getElementById("viewToolbarsPopupSpacesToolbar")
+ .setAttribute("checked", isSpacesVisible);
+ }
+}
+
+function initUiDensityMenu(event) {
+ // Prevent submenus from unnecessarily triggering onViewToolbarsPopupShowing
+ // via bubbling of events.
+ event.stopImmediatePropagation();
+
+ // Apply the correct mode attribute to the various items.
+ document.getElementById("uiDensityCompact").mode = UIDensity.MODE_COMPACT;
+ document.getElementById("uiDensityNormal").mode = UIDensity.MODE_NORMAL;
+ document.getElementById("uiDensityTouch").mode = UIDensity.MODE_TOUCH;
+
+ // Fetch the currently active identity.
+ let currentDensity = UIDensity.prefValue;
+
+ for (let item of event.target.querySelectorAll("menuitem")) {
+ if (item.mode == currentDensity) {
+ item.setAttribute("checked", "true");
+ break;
+ }
+ }
+}
+
+/**
+ * Assign the proper mode to the UI density controls in the App Menu and set
+ * the correct checked state based on the current density.
+ */
+function initUiDensityAppMenu() {
+ // Apply the correct mode attribute to the various items.
+ document.getElementById("appmenu_uiDensityCompact").mode =
+ UIDensity.MODE_COMPACT;
+ document.getElementById("appmenu_uiDensityNormal").mode =
+ UIDensity.MODE_NORMAL;
+ document.getElementById("appmenu_uiDensityTouch").mode = UIDensity.MODE_TOUCH;
+
+ // Fetch the currently active identity.
+ let currentDensity = UIDensity.prefValue;
+
+ for (let item of document.querySelectorAll(
+ "#appMenu-uiDensity-controls > toolbarbutton"
+ )) {
+ if (item.mode == currentDensity) {
+ item.setAttribute("checked", "true");
+ } else {
+ item.removeAttribute("checked");
+ }
+ }
+}
+
+function InitViewLayoutStyleMenu(event, appmenu) {
+ // Prevent submenus from unnecessarily triggering onViewToolbarsPopupShowing
+ // via bubbling of events.
+ event.stopImmediatePropagation();
+ let paneConfig = Services.prefs.getIntPref("mail.pane_config.dynamic");
+
+ let parent = appmenu
+ ? event.target.querySelector(".panel-subview-body")
+ : event.target;
+
+ let layoutStyleMenuitem = parent.children[paneConfig];
+ if (layoutStyleMenuitem) {
+ layoutStyleMenuitem.setAttribute("checked", "true");
+ }
+
+ if (
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPaneHeader",
+ "hidden"
+ ) !== "true"
+ ) {
+ parent
+ .querySelector(`[name="threadheader"]`)
+ .setAttribute("checked", "true");
+ } else {
+ parent.querySelector(`[name="threadheader"]`).removeAttribute("checked");
+ }
+}
+
+/**
+ * Called when showing the menu_viewSortPopup menupopup, so it should always
+ * be up-to-date.
+ */
+function InitViewSortByMenu() {
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (tab?.mode.name != "mail3PaneTab") {
+ return;
+ }
+
+ let { gViewWrapper, threadPane } = tab.chromeBrowser.contentWindow;
+ if (!gViewWrapper?.dbView) {
+ return;
+ }
+
+ let { primarySortType, primarySortOrder, showGroupedBySort, showThreaded } =
+ gViewWrapper;
+ let hiddenColumns = threadPane.columns
+ .filter(c => c.hidden)
+ .map(c => c.sortKey);
+
+ let isSortTypeValidForGrouping = [
+ Ci.nsMsgViewSortType.byAccount,
+ Ci.nsMsgViewSortType.byAttachments,
+ Ci.nsMsgViewSortType.byAuthor,
+ Ci.nsMsgViewSortType.byCorrespondent,
+ Ci.nsMsgViewSortType.byDate,
+ Ci.nsMsgViewSortType.byFlagged,
+ Ci.nsMsgViewSortType.byLocation,
+ Ci.nsMsgViewSortType.byPriority,
+ Ci.nsMsgViewSortType.byReceived,
+ Ci.nsMsgViewSortType.byRecipient,
+ Ci.nsMsgViewSortType.byStatus,
+ Ci.nsMsgViewSortType.bySubject,
+ Ci.nsMsgViewSortType.byTags,
+ Ci.nsMsgViewSortType.byCustom,
+ ].includes(primarySortType);
+
+ let setSortItemAttrs = function (id, sortKey) {
+ let menuItem = document.getElementById(id);
+ menuItem.setAttribute(
+ "checked",
+ primarySortType == Ci.nsMsgViewSortType[sortKey]
+ );
+ if (hiddenColumns.includes(sortKey)) {
+ menuItem.setAttribute("disabled", "true");
+ } else {
+ menuItem.removeAttribute("disabled");
+ }
+ };
+
+ setSortItemAttrs("sortByDateMenuitem", "byDate");
+ setSortItemAttrs("sortByReceivedMenuitem", "byReceived");
+ setSortItemAttrs("sortByFlagMenuitem", "byFlagged");
+ setSortItemAttrs("sortByOrderReceivedMenuitem", "byId");
+ setSortItemAttrs("sortByPriorityMenuitem", "byPriority");
+ setSortItemAttrs("sortBySizeMenuitem", "bySize");
+ setSortItemAttrs("sortByStatusMenuitem", "byStatus");
+ setSortItemAttrs("sortBySubjectMenuitem", "bySubject");
+ setSortItemAttrs("sortByUnreadMenuitem", "byUnread");
+ setSortItemAttrs("sortByTagsMenuitem", "byTags");
+ setSortItemAttrs("sortByJunkStatusMenuitem", "byJunkStatus");
+ setSortItemAttrs("sortByFromMenuitem", "byAuthor");
+ setSortItemAttrs("sortByRecipientMenuitem", "byRecipient");
+ setSortItemAttrs("sortByAttachmentsMenuitem", "byAttachments");
+ setSortItemAttrs("sortByCorrespondentMenuitem", "byCorrespondent");
+
+ document
+ .getElementById("sortAscending")
+ .setAttribute(
+ "checked",
+ primarySortOrder == Ci.nsMsgViewSortOrder.ascending
+ );
+ document
+ .getElementById("sortDescending")
+ .setAttribute(
+ "checked",
+ primarySortOrder == Ci.nsMsgViewSortOrder.descending
+ );
+
+ document.getElementById("sortThreaded").setAttribute("checked", showThreaded);
+ document
+ .getElementById("sortUnthreaded")
+ .setAttribute("checked", !showThreaded && !showGroupedBySort);
+
+ let groupBySortOrderMenuItem = document.getElementById("groupBySort");
+ groupBySortOrderMenuItem.setAttribute(
+ "disabled",
+ !isSortTypeValidForGrouping
+ );
+ groupBySortOrderMenuItem.setAttribute("checked", showGroupedBySort);
+}
+
+function InitViewMessagesMenu() {
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (!["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ return;
+ }
+
+ let viewWrapper = tab.chromeBrowser.contentWindow.gViewWrapper;
+
+ document
+ .getElementById("viewAllMessagesMenuItem")
+ .setAttribute(
+ "checked",
+ !viewWrapper || (!viewWrapper.showUnreadOnly && !viewWrapper.specialView)
+ );
+
+ document
+ .getElementById("viewUnreadMessagesMenuItem")
+ .setAttribute("checked", !!viewWrapper?.showUnreadOnly);
+
+ document
+ .getElementById("viewThreadsWithUnreadMenuItem")
+ .setAttribute("checked", !!viewWrapper?.specialViewThreadsWithUnread);
+
+ document
+ .getElementById("viewWatchedThreadsWithUnreadMenuItem")
+ .setAttribute(
+ "checked",
+ !!viewWrapper?.specialViewWatchedThreadsWithUnread
+ );
+
+ document
+ .getElementById("viewIgnoredThreadsMenuItem")
+ .setAttribute("checked", !!viewWrapper?.showIgnored);
+}
+
+function InitMessageMenu() {
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ let message, folder;
+ let isDummy;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ ({ message, folder } = tab);
+ isDummy = message && !folder;
+ } else {
+ message = document.getElementById("messageBrowser")?.contentWindow.gMessage;
+ isDummy = !message?.folder;
+ }
+
+ let isNews = message?.folder?.flags & Ci.nsMsgFolderFlags.Newsgroup;
+ let isFeed = message && FeedUtils.isFeedMessage(message);
+
+ // We show reply to Newsgroups only for news messages.
+ document.getElementById("replyNewsgroupMainMenu").hidden = !isNews;
+
+ // For mail messages we say reply. For news we say ReplyToSender.
+ document.getElementById("replyMainMenu").hidden = isNews;
+ document.getElementById("replySenderMainMenu").hidden = !isNews;
+
+ document.getElementById("menu_cancel").hidden =
+ !isNews || !getEnabledControllerForCommand("cmd_cancel");
+
+ // Disable the move menu if there are no messages selected or if
+ // the message is a dummy - e.g. opening a message in the standalone window.
+ let messageStoredInternally = message && !isDummy;
+ // Disable the move menu if we can't delete msgs from the folder.
+ let canMove =
+ messageStoredInternally && !isNews && message.folder.canDeleteMessages;
+
+ document.getElementById("moveMenu").disabled = !canMove;
+
+ document.getElementById("copyMenu").disabled = !message;
+
+ initMoveToFolderAgainMenu(document.getElementById("moveToFolderAgain"));
+
+ // Disable the Forward As menu item if no message is selected.
+ document.getElementById("forwardAsMenu").disabled = !message;
+
+ // Disable the Attachments menu if no message is selected and we don't have
+ // any attachment.
+ let aboutMessage =
+ document.getElementById("tabmail")?.currentAboutMessage ||
+ document.getElementById("messageBrowser")?.contentWindow;
+ document.getElementById("msgAttachmentMenu").disabled =
+ !message || !aboutMessage?.currentAttachments.length;
+
+ // Disable the Tag menu item if no message is selected or when we're
+ // not in a folder.
+ document.getElementById("tagMenu").disabled = !messageStoredInternally;
+
+ // Show "Edit Draft Message" menus only in a drafts folder; otherwise hide them.
+ showCommandInSpecialFolder("cmd_editDraftMsg", Ci.nsMsgFolderFlags.Drafts);
+ // Show "New Message from Template" and "Edit Template" menus only in a
+ // templates folder; otherwise hide them.
+ showCommandInSpecialFolder(
+ ["cmd_newMsgFromTemplate", "cmd_editTemplateMsg"],
+ Ci.nsMsgFolderFlags.Templates
+ );
+
+ // Initialize the Open Message menuitem
+ var winType = document.documentElement.getAttribute("windowtype");
+ if (winType == "mail:3pane") {
+ document.getElementById("openMessageWindowMenuitem").hidden = isFeed;
+ }
+
+ // Initialize the Open Feed Message handler menu
+ let index = FeedMessageHandler.onOpenPref;
+ document
+ .getElementById("menu_openFeedMessage")
+ .children[index].setAttribute("checked", true);
+
+ let openRssMenu = document.getElementById("openFeedMessage");
+ openRssMenu.hidden = !isFeed;
+ if (winType != "mail:3pane") {
+ openRssMenu.hidden = true;
+ }
+
+ // Disable mark menu when we're not in a folder.
+ document.getElementById("markMenu").disabled = !folder || folder.isServer;
+
+ document.commandDispatcher.updateCommands("create-menu-message");
+
+ for (let id of ["killThread", "killSubthread", "watchThread"]) {
+ let item = document.getElementById(id);
+ let command = document.getElementById(item.getAttribute("command"));
+ if (command.hasAttribute("checked")) {
+ item.setAttribute("checked", command.getAttribute("checked"));
+ } else {
+ item.removeAttribute("checked");
+ }
+ }
+}
+
+/**
+ * Show folder-specific menu items only for messages in special folders, e.g.
+ * show 'cmd_editDraftMsg' in Drafts folder, or
+ * show 'cmd_newMsgFromTemplate' in Templates folder.
+ *
+ * aCommandIds single ID string of command or array of IDs of commands
+ * to be shown in folders having aFolderFlag
+ * aFolderFlag the nsMsgFolderFlag that the folder must have to show the command
+ */
+function showCommandInSpecialFolder(aCommandIds, aFolderFlag) {
+ let folder, message;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ ({ message, folder } = tab);
+ } else if (tab?.mode.tabType.name == "mail") {
+ ({ displayedFolder: folder, selectedMessage: message } = tab.folderDisplay);
+ }
+
+ let inSpecialFolder =
+ message?.folder?.isSpecialFolder(aFolderFlag, true) ||
+ (folder && folder.getFlag(aFolderFlag));
+ if (typeof aCommandIds === "string") {
+ aCommandIds = [aCommandIds];
+ }
+
+ aCommandIds.forEach(cmdId =>
+ document.getElementById(cmdId).setAttribute("hidden", !inSpecialFolder)
+ );
+}
+
+/**
+ * Initializes the menu item aMenuItem to show either "Move" or "Copy" to
+ * folder again, based on the value of mail.last_msg_movecopy_target_uri.
+ * The menu item label and accesskey are adjusted to include the folder name.
+ *
+ * @param aMenuItem the menu item to adjust
+ */
+function initMoveToFolderAgainMenu(aMenuItem) {
+ let lastFolderURI = Services.prefs.getStringPref(
+ "mail.last_msg_movecopy_target_uri"
+ );
+
+ if (!lastFolderURI) {
+ return;
+ }
+ let destMsgFolder = MailUtils.getExistingFolder(lastFolderURI);
+ if (!destMsgFolder) {
+ return;
+ }
+ let bundle = document.getElementById("bundle_messenger");
+ let isMove = Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move");
+ let stringName = isMove ? "moveToFolderAgain" : "copyToFolderAgain";
+ aMenuItem.label = bundle.getFormattedString(
+ stringName,
+ [destMsgFolder.prettyName],
+ 1
+ );
+ // This gives us moveToFolderAgainAccessKey and copyToFolderAgainAccessKey.
+ aMenuItem.accesskey = bundle.getString(stringName + "AccessKey");
+}
+
+/**
+ * Update the "Show Header" menu items to reflect the current pref.
+ */
+function InitViewHeadersMenu() {
+ let dt = Ci.nsMimeHeaderDisplayTypes;
+ let headerchoice = Services.prefs.getIntPref("mail.show_headers");
+ document
+ .getElementById("cmd_viewAllHeader")
+ .setAttribute("checked", headerchoice == dt.AllHeaders);
+ document
+ .getElementById("cmd_viewNormalHeader")
+ .setAttribute("checked", headerchoice == dt.NormalHeaders);
+ document.commandDispatcher.updateCommands("create-menu-mark");
+}
+
+function InitViewBodyMenu() {
+ let message;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ message = tab.message;
+ }
+
+ // Separate render prefs not implemented for feeds, bug 458606. Show the
+ // checked item for feeds as for the regular pref.
+ // let html_as = Services.prefs.getIntPref("rss.display.html_as");
+ // let prefer_plaintext = Services.prefs.getBoolPref("rss.display.prefer_plaintext");
+ // let disallow_classes = Services.prefs.getIntPref("rss.display.disallow_mime_handlers");
+ let html_as = Services.prefs.getIntPref("mailnews.display.html_as");
+ let prefer_plaintext = Services.prefs.getBoolPref(
+ "mailnews.display.prefer_plaintext"
+ );
+ let disallow_classes = Services.prefs.getIntPref(
+ "mailnews.display.disallow_mime_handlers"
+ );
+ let isFeed = FeedUtils.isFeedMessage(message);
+ const defaultIDs = [
+ "bodyAllowHTML",
+ "bodySanitized",
+ "bodyAsPlaintext",
+ "bodyAllParts",
+ ];
+ const rssIDs = [
+ "bodyFeedSummaryAllowHTML",
+ "bodyFeedSummarySanitized",
+ "bodyFeedSummaryAsPlaintext",
+ ];
+ let menuIDs = isFeed ? rssIDs : defaultIDs;
+
+ if (disallow_classes > 0) {
+ gDisallow_classes_no_html = disallow_classes;
+ }
+ // else gDisallow_classes_no_html keeps its initial value (see top)
+
+ let AllowHTML_menuitem = document.getElementById(menuIDs[0]);
+ let Sanitized_menuitem = document.getElementById(menuIDs[1]);
+ let AsPlaintext_menuitem = document.getElementById(menuIDs[2]);
+ let AllBodyParts_menuitem = menuIDs[3]
+ ? document.getElementById(menuIDs[3])
+ : null;
+
+ document.getElementById("bodyAllParts").hidden = !Services.prefs.getBoolPref(
+ "mailnews.display.show_all_body_parts_menu"
+ );
+
+ if (
+ !prefer_plaintext &&
+ !html_as &&
+ !disallow_classes &&
+ AllowHTML_menuitem
+ ) {
+ AllowHTML_menuitem.setAttribute("checked", true);
+ } else if (
+ !prefer_plaintext &&
+ html_as == 3 &&
+ disallow_classes > 0 &&
+ Sanitized_menuitem
+ ) {
+ Sanitized_menuitem.setAttribute("checked", true);
+ } else if (
+ prefer_plaintext &&
+ html_as == 1 &&
+ disallow_classes > 0 &&
+ AsPlaintext_menuitem
+ ) {
+ AsPlaintext_menuitem.setAttribute("checked", true);
+ } else if (
+ !prefer_plaintext &&
+ html_as == 4 &&
+ !disallow_classes &&
+ AllBodyParts_menuitem
+ ) {
+ AllBodyParts_menuitem.setAttribute("checked", true);
+ }
+ // else (the user edited prefs/user.js) check none of the radio menu items
+
+ if (isFeed) {
+ AllowHTML_menuitem.hidden = !FeedMessageHandler.gShowSummary;
+ Sanitized_menuitem.hidden = !FeedMessageHandler.gShowSummary;
+ AsPlaintext_menuitem.hidden = !FeedMessageHandler.gShowSummary;
+ document.getElementById("viewFeedSummarySeparator").hidden =
+ !gShowFeedSummary;
+ }
+}
+
+function ShowMenuItem(id, showItem) {
+ document.getElementById(id).hidden = !showItem;
+}
+
+function EnableMenuItem(id, enableItem) {
+ document.getElementById(id).disabled = !enableItem;
+}
+
+function SetMenuItemLabel(menuItemId, customLabel) {
+ var menuItem = document.getElementById(menuItemId);
+ if (menuItem) {
+ menuItem.setAttribute("label", customLabel);
+ }
+}
+
+/**
+ * Refresh the contents of the tag popup menu/panel.
+ * Used for example for appmenu/Message/Tag panel.
+ *
+ * @param {Element} parent - Parent element that will contain the menu items.
+ * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton".
+ * @param {string} [classes] - Classes to set on the menu items.
+ */
+function InitMessageTags(parent, elementName = "menuitem", classes) {
+ function SetMessageTagLabel(menuitem, index, name) {
+ // if a <key> is defined for this tag, use its key as the accesskey
+ // (the key for the tag at index n needs to have the id key_tag<n>)
+ let shortcutkey = document.getElementById("key_tag" + index);
+ let accesskey = shortcutkey ? shortcutkey.getAttribute("key") : " ";
+ if (accesskey != " ") {
+ menuitem.setAttribute("accesskey", accesskey);
+ menuitem.setAttribute("acceltext", accesskey);
+ }
+ let label = document
+ .getElementById("bundle_messenger")
+ .getFormattedString("mailnews.tags.format", [accesskey, name]);
+ menuitem.setAttribute("label", label);
+ }
+
+ let message;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ message = tab.message;
+ } else {
+ message = document.getElementById("messageBrowser")?.contentWindow.gMessage;
+ }
+
+ const tagArray = MailServices.tags.getAllTags();
+ const elementNameUpperCase = elementName.toUpperCase();
+
+ // Remove any existing non-static items (clear tags list before rebuilding it).
+ // There is a separator element above the dynamically added tag elements, so
+ // remove dynamically added elements below the separator.
+ while (
+ parent.lastElementChild.tagName.toUpperCase() == elementNameUpperCase
+ ) {
+ parent.lastChild.remove();
+ }
+
+ // Create label and accesskey for the static "remove all tags" item.
+ const tagRemoveLabel = document
+ .getElementById("bundle_messenger")
+ .getString("mailnews.tags.remove");
+ SetMessageTagLabel(
+ parent.lastElementChild.previousElementSibling,
+ 0,
+ tagRemoveLabel
+ );
+
+ // Rebuild the list.
+ const curKeys = message.getStringProperty("keywords");
+
+ tagArray.forEach((tagInfo, index) => {
+ const removeKey = ` ${curKeys} `.includes(` ${tagInfo.key} `);
+
+ if (tagInfo.ordinal.includes("~AUTOTAG") && !removeKey) {
+ return;
+ }
+ // TODO We want to either remove or "check" the tags that already exist.
+ let item = parent.ownerDocument.createXULElement(elementName);
+ SetMessageTagLabel(item, index + 1, tagInfo.tag);
+
+ if (removeKey) {
+ item.setAttribute("checked", "true");
+ }
+ item.setAttribute("value", tagInfo.key);
+ item.setAttribute("type", "checkbox");
+ item.addEventListener("command", function (event) {
+ goDoCommand("cmd_toggleTag", event);
+ });
+
+ if (tagInfo.color) {
+ item.setAttribute("style", `color: ${tagInfo.color};`);
+ }
+ if (classes) {
+ item.setAttribute("class", classes);
+ }
+ parent.appendChild(item);
+ });
+}
+
+function getMsgToolbarMenu_init() {
+ document.commandDispatcher.updateCommands("create-menu-getMsgToolbar");
+}
+
+function InitMessageMark() {
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ let flaggedItem = document.getElementById("markFlaggedMenuItem");
+ if (tab?.message?.isFlagged) {
+ flaggedItem.setAttribute("checked", "true");
+ } else {
+ flaggedItem.removeAttribute("checked");
+ }
+
+ document.commandDispatcher.updateCommands("create-menu-mark");
+}
+
+function GetFirstSelectedMsgFolder() {
+ try {
+ var selectedFolders = GetSelectedMsgFolders();
+ } catch (e) {
+ console.error(e);
+ }
+ return selectedFolders.length > 0 ? selectedFolders[0] : null;
+}
+
+function GetMessagesForInboxOnServer(server) {
+ var inboxFolder = MailUtils.getInboxFolder(server);
+
+ // If the server doesn't support an inbox it could be an RSS server or some
+ // other server type. Just use the root folder and the server implementation
+ // can figure out what to do.
+ if (!inboxFolder) {
+ inboxFolder = server.rootFolder;
+ }
+
+ GetNewMsgs(server, inboxFolder);
+}
+
+function MsgGetMessage(folders) {
+ // if offline, prompt for getting messages
+ if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) {
+ GetFolderMessages(folders);
+ }
+}
+
+function MsgPauseUpdates(selectedFolders = GetSelectedMsgFolders(), pause) {
+ // Pause single feed folder subscription updates, or all account updates if
+ // folder is the account folder.
+ let folder = selectedFolders.length ? selectedFolders[0] : null;
+ if (!FeedUtils.isFeedFolder(folder)) {
+ return;
+ }
+
+ FeedUtils.pauseFeedFolderUpdates(folder, pause, true);
+ Services.obs.notifyObservers(folder, "folder-properties-changed");
+}
+
+function MsgGetMessagesForAllServers(defaultServer) {
+ // now log into any server
+ try {
+ // Array of arrays of servers for a particular folder.
+ var pop3DownloadServersArray = [];
+ // Parallel array of folders to download to...
+ var localFoldersToDownloadTo = [];
+ var pop3Server;
+ for (let server of MailServices.accounts.allServers) {
+ if (server.protocolInfo.canLoginAtStartUp && server.loginAtStartUp) {
+ if (
+ defaultServer &&
+ defaultServer.equals(server) &&
+ !defaultServer.isDeferredTo &&
+ defaultServer.rootFolder == defaultServer.rootMsgFolder
+ ) {
+ // skip, already opened
+ } else if (server.type == "pop3" && server.downloadOnBiff) {
+ CoalesceGetMsgsForPop3ServersByDestFolder(
+ server,
+ pop3DownloadServersArray,
+ localFoldersToDownloadTo
+ );
+ pop3Server = server.QueryInterface(Ci.nsIPop3IncomingServer);
+ } else {
+ // Check to see if there are new messages on the server
+ server.performBiff(msgWindow);
+ }
+ }
+ }
+ for (let i = 0; i < pop3DownloadServersArray.length; ++i) {
+ // Any ol' pop3Server will do - the serversArray specifies which servers
+ // to download from.
+ pop3Server.downloadMailFromServers(
+ pop3DownloadServersArray[i],
+ msgWindow,
+ localFoldersToDownloadTo[i],
+ null
+ );
+ }
+ } catch (ex) {
+ dump(ex + "\n");
+ }
+}
+
+/**
+ * Get messages for all those accounts which have the capability
+ * of getting messages and have session password available i.e.,
+ * currently logged in accounts.
+ * if offline, prompt for getting messages.
+ */
+function MsgGetMessagesForAllAuthenticatedAccounts() {
+ if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) {
+ GetMessagesForAllAuthenticatedAccounts();
+ }
+}
+
+/**
+ * Get messages for the account selected from Menu dropdowns.
+ * if offline, prompt for getting messages.
+ *
+ * @param aFolder (optional) a folder in the account for which messages should
+ * be retrieved. If null, all accounts will be used.
+ */
+function MsgGetMessagesForAccount(aFolder) {
+ if (!aFolder) {
+ goDoCommand("cmd_getNewMessages");
+ return;
+ }
+
+ if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) {
+ var server = aFolder.server;
+ GetMessagesForInboxOnServer(server);
+ }
+}
+
+// if offline, prompt for getNextNMessages
+function MsgGetNextNMessages() {
+ if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) {
+ GetNextNMessages(GetFirstSelectedMsgFolder());
+ }
+}
+
+function MsgNewMessage(event) {
+ let msgFolder = document.getElementById("tabmail")?.currentTabInfo.folder;
+
+ if (event?.shiftKey) {
+ ComposeMessage(
+ Ci.nsIMsgCompType.New,
+ Ci.nsIMsgCompFormat.OppositeOfDefault,
+ msgFolder,
+ []
+ );
+ } else {
+ ComposeMessage(
+ Ci.nsIMsgCompType.New,
+ Ci.nsIMsgCompFormat.Default,
+ msgFolder,
+ []
+ );
+ }
+}
+
+/** Open subscribe window. */
+function MsgSubscribe(folder) {
+ var preselectedFolder = folder || GetFirstSelectedMsgFolder();
+
+ if (FeedUtils.isFeedFolder(preselectedFolder)) {
+ // Open feed subscription dialog.
+ openSubscriptionsDialog(preselectedFolder);
+ } else {
+ // Open IMAP/NNTP subscription dialog.
+ Subscribe(preselectedFolder);
+ }
+}
+
+/**
+ * Show a confirmation dialog - check if the user really want to unsubscribe
+ * from the given newsgroup/s.
+ *
+ * @folders an array of newsgroup folders to unsubscribe from
+ * @returns true if the user said it's ok to unsubscribe
+ */
+function ConfirmUnsubscribe(folders) {
+ var bundle = document.getElementById("bundle_messenger");
+ var titleMsg = bundle.getString("confirmUnsubscribeTitle");
+ var dialogMsg =
+ folders.length == 1
+ ? bundle.getFormattedString(
+ "confirmUnsubscribeText",
+ [folders[0].name],
+ 1
+ )
+ : bundle.getString("confirmUnsubscribeManyText");
+
+ return Services.prompt.confirm(window, titleMsg, dialogMsg);
+}
+
+/**
+ * Unsubscribe from selected or passed in newsgroup/s.
+ * @param {nsIMsgFolder[]} selectedFolders - The folders to unsubscribe.
+ */
+function MsgUnsubscribe(folders) {
+ if (!ConfirmUnsubscribe(folders)) {
+ return;
+ }
+
+ for (let i = 0; i < folders.length; i++) {
+ let subscribableServer = folders[i].server.QueryInterface(
+ Ci.nsISubscribableServer
+ );
+ subscribableServer.unsubscribe(folders[i].name);
+ subscribableServer.commitSubscribeChanges();
+ }
+}
+
+function MsgOpenNewWindowForFolder(folderURI, msgKeyToSelect) {
+ window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,all,dialog=no",
+ folderURI,
+ msgKeyToSelect
+ );
+}
+
+/**
+ * UI-triggered command to open the currently selected folder(s) in new tabs.
+ *
+ * @param {nsIMsgFolder[]} folders - Folders to open in new tabs.
+ * @param {object} [tabParams] - Parameters to pass to the new tabs.
+ */
+function MsgOpenNewTabForFolders(folders, tabParams = {}) {
+ if (tabParams.background === undefined) {
+ tabParams.background = Services.prefs.getBoolPref(
+ "mail.tabs.loadInBackground"
+ );
+ if (tabParams.event?.shiftKey) {
+ tabParams.background = !tabParams.background;
+ }
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ for (let i = 0; i < folders.length; i++) {
+ tabmail.openTab("mail3PaneTab", {
+ ...tabParams,
+ folderURI: folders[i].URI,
+ });
+ }
+}
+
+function MsgOpenFromFile() {
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ var bundle = document.getElementById("bundle_messenger");
+ var filterLabel = bundle.getString("EMLFiles");
+ var windowTitle = bundle.getString("OpenEMLFiles");
+
+ fp.init(window, windowTitle, Ci.nsIFilePicker.modeOpen);
+ fp.appendFilter(filterLabel, "*.eml");
+
+ // Default or last filter is "All Files".
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ return;
+ }
+ MailUtils.openEMLFile(window, fp.file, fp.fileURL);
+ });
+}
+
+function MsgOpenNewWindowForMessage(aMsgHdr, aView) {
+ // We need to tell the window about our current view so that it can clone it.
+ // This enables advancing through the messages, etc.
+ return window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ aMsgHdr,
+ aView
+ );
+}
+
+/**
+ * Display the given message in an existing folder tab.
+ *
+ * @param aMsgHdr The message header to display.
+ */
+function MsgDisplayMessageInFolderTab(aMsgHdr) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.switchToTab(0);
+ tabmail.currentAbout3Pane.selectMessage(aMsgHdr);
+}
+
+function MsgMarkAllRead(folders) {
+ for (let i = 0; i < folders.length; i++) {
+ folders[i].markAllMessagesRead(msgWindow);
+ }
+}
+
+/**
+ * Go through each selected server and mark all its folders read.
+ *
+ * @param {nsIMsgFolder[]} selectedFolders - Folders in the servers to be
+ * marked as read.
+ */
+function MsgMarkAllFoldersRead(selectedFolders) {
+ let selectedServers = selectedFolders.filter(folder => folder.isServer);
+ if (!selectedServers.length) {
+ return;
+ }
+
+ let bundle = document.getElementById("bundle_messenger");
+ if (
+ !Services.prompt.confirm(
+ window,
+ bundle.getString("confirmMarkAllFoldersReadTitle"),
+ bundle.getString("confirmMarkAllFoldersReadMessage")
+ )
+ ) {
+ return;
+ }
+
+ selectedServers.forEach(function (server) {
+ for (let folder of server.rootFolder.descendants) {
+ folder.markAllMessagesRead(msgWindow);
+ }
+ });
+}
+
+/**
+ * Opens the filter list.
+ * If an email address was passed, first a new filter is offered for creation
+ * with the data prefilled.
+ *
+ * @param {?string} emailAddress - An email address to use as value in the first
+ * search term.
+ * @param {?nsIMsgFolder} folder - The filter will be created in this folder's
+ * filter list.
+ * @param {?string} fieldName - Search field string, from
+ * nsMsgSearchTerm.cpp::SearchAttribEntryTable.
+ */
+function MsgFilters(emailAddress, folder, fieldName) {
+ // Don't trigger anything if there are no accounts configured. This is to
+ // disable potential triggers via shortcuts.
+ if (MailServices.accounts.accounts.length == 0) {
+ return;
+ }
+
+ if (!folder) {
+ let chromeBrowser =
+ document.getElementById("tabmail")?.currentTabInfo.chromeBrowser ||
+ document.getElementById("messageBrowser");
+ let dbView = chromeBrowser?.contentWindow?.gDBView;
+ // Try to determine the folder from the selected message.
+ if (dbView?.numSelected) {
+ // Here we face a decision. If the message has been moved to a different
+ // account, then a single filter cannot work for both manual and incoming
+ // scope. So we will create the filter based on its existing location,
+ // which will make it work properly in manual scope. This is the best
+ // solution for POP3 with global inbox (as then both manual and incoming
+ // filters work correctly), but may not be what IMAP users who filter to a
+ // local folder really want.
+ folder = dbView.hdrForFirstSelectedMessage.folder;
+ }
+ if (!folder) {
+ folder = GetFirstSelectedMsgFolder();
+ }
+ }
+ let args;
+ if (emailAddress) {
+ // We have to do prefill filter so we are going to launch the filterEditor
+ // dialog and prefill that with the emailAddress.
+ args = {
+ filterList: folder.getEditableFilterList(msgWindow),
+ filterName: emailAddress,
+ };
+ // Set the field name to prefill in the filter, if one was specified.
+ if (fieldName) {
+ args.fieldName = fieldName;
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/FilterEditor.xhtml",
+ "",
+ "chrome, modal, resizable,centerscreen,dialog=yes",
+ args
+ );
+
+ // If the user hits OK in the filterEditor dialog we set args.refresh=true
+ // there and we check this here in args to show filterList dialog.
+ // We also received the filter created via args.newFilter.
+ if ("refresh" in args && args.refresh) {
+ args = { refresh: true, folder, filter: args.newFilter };
+ MsgFilterList(args);
+ }
+ } else {
+ // Just launch filterList dialog.
+ args = { refresh: false, folder };
+ MsgFilterList(args);
+ }
+}
+
+function MsgViewAllHeaders() {
+ Services.prefs.setIntPref(
+ "mail.show_headers",
+ Ci.nsMimeHeaderDisplayTypes.AllHeaders
+ );
+}
+
+function MsgViewNormalHeaders() {
+ Services.prefs.setIntPref(
+ "mail.show_headers",
+ Ci.nsMimeHeaderDisplayTypes.NormalHeaders
+ );
+}
+
+function MsgBodyAllowHTML() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false);
+ Services.prefs.setIntPref("mailnews.display.html_as", 0);
+ Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", 0);
+}
+
+function MsgBodySanitized() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false);
+ Services.prefs.setIntPref("mailnews.display.html_as", 3);
+ Services.prefs.setIntPref(
+ "mailnews.display.disallow_mime_handlers",
+ gDisallow_classes_no_html
+ );
+}
+
+function MsgBodyAsPlaintext() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", true);
+ Services.prefs.setIntPref("mailnews.display.html_as", 1);
+ Services.prefs.setIntPref(
+ "mailnews.display.disallow_mime_handlers",
+ gDisallow_classes_no_html
+ );
+}
+
+function MsgBodyAllParts() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false);
+ Services.prefs.setIntPref("mailnews.display.html_as", 4);
+ Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", 0);
+}
+
+function MsgFeedBodyRenderPrefs(plaintext, html, mime) {
+ // Separate render prefs not implemented for feeds, bug 458606.
+ // Services.prefs.setBoolPref("rss.display.prefer_plaintext", plaintext);
+ // Services.prefs.setIntPref("rss.display.html_as", html);
+ // Services.prefs.setIntPref("rss.display.disallow_mime_handlers", mime);
+
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", plaintext);
+ Services.prefs.setIntPref("mailnews.display.html_as", html);
+ Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", mime);
+ // Reload only if showing rss summary; menuitem hidden if web page..
+}
+
+function ToggleInlineAttachment(target) {
+ var viewAttachmentInline = !Services.prefs.getBoolPref(
+ "mail.inline_attachments"
+ );
+ Services.prefs.setBoolPref("mail.inline_attachments", viewAttachmentInline);
+ target.setAttribute("checked", viewAttachmentInline ? "true" : "false");
+}
+
+function IsGetNewMessagesEnabled() {
+ for (let server of MailServices.accounts.allServers) {
+ if (server.type == "none") {
+ continue;
+ }
+ return true;
+ }
+ return false;
+}
+
+function IsGetNextNMessagesEnabled() {
+ let selectedFolders = GetSelectedMsgFolders();
+ let folder = selectedFolders.length ? selectedFolders[0] : null;
+
+ let menuItem = document.getElementById("menu_getnextnmsg");
+ if (
+ folder &&
+ !folder.isServer &&
+ folder.server instanceof Ci.nsINntpIncomingServer
+ ) {
+ menuItem.label = PluralForm.get(
+ folder.server.maxArticles,
+ document
+ .getElementById("bundle_messenger")
+ .getString("getNextNewsMessages")
+ ).replace("#1", folder.server.maxArticles);
+ menuItem.removeAttribute("hidden");
+ return true;
+ }
+
+ menuItem.setAttribute("hidden", "true");
+ return false;
+}
+
+function MsgSynchronizeOffline() {
+ window.openDialog(
+ "chrome://messenger/content/msgSynchronize.xhtml",
+ "",
+ "centerscreen,chrome,modal,titlebar,resizable=yes",
+ { msgWindow }
+ );
+}
+
+function IsAccountOfflineEnabled() {
+ var selectedFolders = GetSelectedMsgFolders();
+
+ if (selectedFolders && selectedFolders.length == 1) {
+ return selectedFolders[0].supportsOffline;
+ }
+ return false;
+}
+
+function GetDefaultAccountRootFolder() {
+ var account = MailServices.accounts.defaultAccount;
+ if (account) {
+ return account.incomingServer.rootMsgFolder;
+ }
+
+ return null;
+}
+
+/**
+ * Check for new messages for all selected folders, or for the default account
+ * in case no folders are selected.
+ */
+function GetFolderMessages(selectedFolders = GetSelectedMsgFolders()) {
+ var defaultAccountRootFolder = GetDefaultAccountRootFolder();
+
+ // if nothing selected, use the default
+ var folders = selectedFolders.length
+ ? selectedFolders
+ : [defaultAccountRootFolder];
+
+ if (!folders[0]) {
+ return;
+ }
+
+ for (var i = 0; i < folders.length; i++) {
+ var serverType = folders[i].server.type;
+ if (folders[i].isServer && serverType == "nntp") {
+ // If we're doing "get msgs" on a news server.
+ // Update unread counts on this server.
+ folders[i].server.performExpand(msgWindow);
+ } else if (folders[i].isServer && serverType == "imap") {
+ GetMessagesForInboxOnServer(folders[i].server);
+ } else if (serverType == "none") {
+ // If "Local Folders" is selected and the user does "Get Msgs" and
+ // LocalFolders is not deferred to, get new mail for the default account
+ //
+ // XXX TODO
+ // Should shift click get mail for all (authenticated) accounts?
+ // see bug #125885.
+ if (!folders[i].server.isDeferredTo) {
+ if (!defaultAccountRootFolder) {
+ continue;
+ }
+ GetNewMsgs(defaultAccountRootFolder.server, defaultAccountRootFolder);
+ } else {
+ GetNewMsgs(folders[i].server, folders[i]);
+ }
+ } else {
+ GetNewMsgs(folders[i].server, folders[i]);
+ }
+ }
+}
+
+/**
+ * Gets new messages for the given server, for the given folder.
+ *
+ * @param server which nsIMsgIncomingServer to check for new messages
+ * @param folder which nsIMsgFolder folder to check for new messages
+ */
+function GetNewMsgs(server, folder) {
+ // Note that for Global Inbox folder.server != server when we want to get
+ // messages for a specific account.
+
+ // Whenever we do get new messages, clear the old new messages.
+ folder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail;
+ folder.clearNewMessages();
+ server.getNewMessages(folder, msgWindow, new TransportErrorUrlListener());
+}
+
+function InformUserOfCertError(secInfo, targetSite) {
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location: targetSite,
+ };
+ window.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+}
+
+/**
+ * A listener to be passed to the url object of the server request being issued
+ * to detect the bad server certificates.
+ *
+ * @implements {nsIUrlListener}
+ */
+function TransportErrorUrlListener() {}
+
+TransportErrorUrlListener.prototype = {
+ OnStartRunningUrl(url) {},
+
+ OnStopRunningUrl(url, exitCode) {
+ if (Components.isSuccessCode(exitCode)) {
+ return;
+ }
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ try {
+ let errorClass = nssErrorsService.getErrorClass(exitCode);
+ if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ let mailNewsUrl = url.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ let secInfo = mailNewsUrl.failedSecInfo;
+ InformUserOfCertError(secInfo, url.asciiHostPort);
+ }
+ } catch (e) {
+ // It's not an NSS error.
+ }
+ },
+
+ // nsISupports
+ QueryInterface: ChromeUtils.generateQI(["nsIUrlListener"]),
+};
+
+function SendUnsentMessages() {
+ let msgSendlater = Cc["@mozilla.org/messengercompose/sendlater;1"].getService(
+ Ci.nsIMsgSendLater
+ );
+
+ for (let identity of MailServices.accounts.allIdentities) {
+ let msgFolder = msgSendlater.getUnsentMessagesFolder(identity);
+ if (msgFolder) {
+ let numMessages = msgFolder.getTotalMessages(
+ false /* include subfolders */
+ );
+ if (numMessages > 0) {
+ msgSendlater.sendUnsentMessages(identity);
+ // Right now, all identities point to the same unsent messages
+ // folder, so to avoid sending multiple copies of the
+ // unsent messages, we only call messenger.SendUnsentMessages() once.
+ // See bug #89150 for details.
+ break;
+ }
+ }
+ }
+}
+
+function CoalesceGetMsgsForPop3ServersByDestFolder(
+ currentServer,
+ pop3DownloadServersArray,
+ localFoldersToDownloadTo
+) {
+ var inboxFolder = currentServer.rootMsgFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ // coalesce the servers that download into the same folder...
+ var index = localFoldersToDownloadTo.indexOf(inboxFolder);
+ if (index == -1) {
+ if (inboxFolder) {
+ inboxFolder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail;
+ inboxFolder.clearNewMessages();
+ }
+ localFoldersToDownloadTo.push(inboxFolder);
+ index = pop3DownloadServersArray.length;
+ pop3DownloadServersArray.push([]);
+ }
+ pop3DownloadServersArray[index].push(currentServer);
+}
+
+function GetMessagesForAllAuthenticatedAccounts() {
+ // now log into any server
+ try {
+ // Array of arrays of servers for a particular folder.
+ var pop3DownloadServersArray = [];
+ // parallel array of folders to download to...
+ var localFoldersToDownloadTo = [];
+ var pop3Server;
+
+ for (let server of MailServices.accounts.allServers) {
+ if (
+ server.protocolInfo.canGetMessages &&
+ !server.passwordPromptRequired
+ ) {
+ if (server.type == "pop3") {
+ CoalesceGetMsgsForPop3ServersByDestFolder(
+ server,
+ pop3DownloadServersArray,
+ localFoldersToDownloadTo
+ );
+ pop3Server = server.QueryInterface(Ci.nsIPop3IncomingServer);
+ } else {
+ // get new messages on the server for imap or rss
+ GetMessagesForInboxOnServer(server);
+ }
+ }
+ }
+ for (let i = 0; i < pop3DownloadServersArray.length; ++i) {
+ // any ol' pop3Server will do - the serversArray specifies which servers to download from
+ pop3Server.downloadMailFromServers(
+ pop3DownloadServersArray[i],
+ msgWindow,
+ localFoldersToDownloadTo[i],
+ null
+ );
+ }
+ } catch (ex) {
+ dump(ex + "\n");
+ }
+}
+
+function CommandUpdate_UndoRedo() {
+ EnableMenuItem("menu_undo", SetupUndoRedoCommand("cmd_undo"));
+ EnableMenuItem("menu_redo", SetupUndoRedoCommand("cmd_redo"));
+}
+
+function SetupUndoRedoCommand(command) {
+ let folder = document.getElementById("tabmail")?.currentTabInfo.folder;
+ if (!folder?.server.canUndoDeleteOnServer) {
+ return false;
+ }
+
+ let canUndoOrRedo = false;
+ let txnType;
+ try {
+ if (command == "cmd_undo") {
+ canUndoOrRedo = messenger.canUndo();
+ txnType = messenger.getUndoTransactionType();
+ } else {
+ canUndoOrRedo = messenger.canRedo();
+ txnType = messenger.getRedoTransactionType();
+ }
+ } catch (ex) {
+ // If this fails, assume we can't undo or redo.
+ console.error(ex);
+ }
+
+ if (canUndoOrRedo) {
+ let commands = {
+ [Ci.nsIMessenger.eUnknown]: "valueDefault",
+ [Ci.nsIMessenger.eDeleteMsg]: "valueDeleteMsg",
+ [Ci.nsIMessenger.eMoveMsg]: "valueMoveMsg",
+ [Ci.nsIMessenger.eCopyMsg]: "valueCopyMsg",
+ [Ci.nsIMessenger.eMarkAllMsg]: "valueUnmarkAllMsgs",
+ };
+ goSetMenuValue(command, commands[txnType]);
+ } else {
+ goSetMenuValue(command, "valueDefault");
+ }
+
+ return canUndoOrRedo;
+}
+
+/**
+ * Focus the gloda global search input box on current tab, or,
+ * if the search box is not available, open a new gloda search tab
+ * (with its search box focused).
+ */
+function QuickSearchFocus() {
+ // Default to focusing the search box on the current tab
+ let newTab = false;
+ let searchInput;
+ let tabmail = document.getElementById("tabmail");
+ // Tabmail should never be undefined.
+ if (!tabmail || tabmail.globalOverlay) {
+ return;
+ }
+
+ switch (tabmail.currentTabInfo.mode.name) {
+ case "glodaFacet":
+ // If we're currently viewing a Gloda tab, drill down to find the
+ // built-in search input, and select that.
+ searchInput = tabmail.currentTabInfo.panel.querySelector(
+ ".remote-gloda-search"
+ );
+ break;
+ default:
+ searchInput = document.querySelector(
+ "#unifiedToolbarContent .search-bar global-search-bar"
+ );
+ break;
+ }
+
+ if (!searchInput) {
+ // If searchInput is not found on current tab (e.g. removed by user),
+ // use a new tab.
+ newTab = true;
+ } else {
+ // The searchInput element exists on current tab.
+ // However, via toolbar customization, it can be in different places:
+ // Toolbars, tab bar, menu bar, etc. If the containing elements are hidden,
+ // searchInput will also be hidden, so clientHeight and clientWidth of the
+ // searchbox or one of its parents will typically be zero and we can test
+ // for that. If searchInput is hidden, use a new tab.
+ let element = searchInput;
+ while (element) {
+ if (element.clientHeight == 0 || element.clientWidth == 0) {
+ newTab = true;
+ }
+ element = element.parentElement;
+ }
+ }
+
+ if (!newTab) {
+ // Focus and select global search box on current tab.
+ if (searchInput.select) {
+ searchInput.select();
+ } else {
+ searchInput.focus();
+ }
+ } else {
+ // Open a new global search tab (with focus on its global search box)
+ tabmail.openTab("glodaFacet");
+ }
+}
+
+/**
+ * Open a new gloda search tab, with its search box focused.
+ */
+function openGlodaSearchTab() {
+ document.getElementById("tabmail").openTab("glodaFacet");
+}
+
+function MsgSearchAddresses() {
+ var args = { directory: null };
+ OpenOrFocusWindow(
+ args,
+ "mailnews:absearch",
+ "chrome://messenger/content/addressbook/abSearchDialog.xhtml"
+ );
+}
+
+function MsgFilterList(args) {
+ OpenOrFocusWindow(
+ args,
+ "mailnews:filterlist",
+ "chrome://messenger/content/FilterListDialog.xhtml"
+ );
+}
+
+function OpenOrFocusWindow(args, windowType, chromeURL) {
+ var desiredWindow = Services.wm.getMostRecentWindow(windowType);
+
+ if (desiredWindow) {
+ desiredWindow.focus();
+ if ("refresh" in args && args.refresh) {
+ desiredWindow.refresh(args);
+ }
+ } else {
+ window.openDialog(
+ chromeURL,
+ "",
+ "chrome,resizable,status,centerscreen,dialog=no",
+ args
+ );
+ }
+}
+
+function initAppMenuPopup() {
+ file_init();
+ view_init();
+ InitGoMessagesMenu();
+ menu_new_init();
+ CommandUpdate_UndoRedo();
+ document.commandDispatcher.updateCommands("create-menu-tasks");
+ UIFontSize.updateAppMenuButton(window);
+ initUiDensityAppMenu();
+
+ document.getElementById("appmenu_FolderViews").disabled =
+ document.getElementById("tabmail").currentTabInfo.mode.name !=
+ "mail3PaneTab";
+}
+
+/**
+ * Generate menu items that open a preferences dialog/tab for an installed addon,
+ * and add them to a menu popup. E.g. in the appmenu or Tools menu > addon prefs.
+ *
+ * @param {Element} parent - The element (e.g. menupopup) to populate.
+ * @param {string} [elementName] - The kind of menu item elements to create (e.g. "toolbarbutton").
+ * @param {string} [classes] - Classes for menu item elements with no icon.
+ * @param {string} [iconClasses] - Classes for menu item elements with an icon.
+ */
+async function initAddonPrefsMenu(
+ parent,
+ elementName = "menuitem",
+ classes,
+ iconClasses = "menuitem-iconic"
+) {
+ // Starting at the bottom, clear all menu items until we hit
+ // "no add-on prefs", which is the only disabled element. Above this element
+ // there may be further items that we want to preserve.
+ let noPrefsElem = parent.querySelector('[disabled="true"]');
+ while (parent.lastChild != noPrefsElem) {
+ parent.lastChild.remove();
+ }
+
+ // Enumerate all enabled addons with URL to XUL document with prefs.
+ let addonsFound = [];
+ for (let addon of await AddonManager.getAddonsByTypes(["extension"])) {
+ if (addon.userDisabled || addon.appDisabled || addon.softDisabled) {
+ continue;
+ }
+ if (addon.optionsURL) {
+ if (addon.optionsType == 5) {
+ addonsFound.push({
+ addon,
+ optionsURL: `addons://detail/${encodeURIComponent(
+ addon.id
+ )}/preferences`,
+ optionsOpenInAddons: true,
+ });
+ } else if (addon.optionsType === null || addon.optionsType == 3) {
+ addonsFound.push({
+ addon,
+ optionsURL: addon.optionsURL,
+ optionsOpenInTab: addon.optionsType == 3,
+ });
+ }
+ }
+ }
+
+ // Populate the menu with addon names and icons.
+ // Note: Having the following code in the getAddonsByTypes() async callback
+ // above works on Windows and Linux but doesn't work on Mac, see bug 1419145.
+ if (addonsFound.length > 0) {
+ addonsFound.sort((a, b) => a.addon.name.localeCompare(b.addon.name));
+ for (let {
+ addon,
+ optionsURL,
+ optionsOpenInTab,
+ optionsOpenInAddons,
+ } of addonsFound) {
+ let newItem = document.createXULElement(elementName);
+ newItem.setAttribute("label", addon.name);
+ newItem.setAttribute("value", optionsURL);
+ if (optionsOpenInTab) {
+ newItem.setAttribute("optionsType", "tab");
+ } else if (optionsOpenInAddons) {
+ newItem.setAttribute("optionsType", "addons");
+ }
+ let iconURL = addon.iconURL || addon.icon64URL;
+ if (iconURL) {
+ newItem.setAttribute("class", iconClasses);
+ newItem.setAttribute("image", iconURL);
+ } else if (classes) {
+ newItem.setAttribute("class", classes);
+ }
+ parent.appendChild(newItem);
+ }
+ noPrefsElem.setAttribute("collapsed", "true");
+ } else {
+ // Only show message that there are no addons with prefs.
+ noPrefsElem.setAttribute("collapsed", "false");
+ }
+}
+
+function openNewCardDialog() {
+ toAddressBook({ action: "create" });
+}
+
+/**
+ * Opens Address Book tab and triggers address book creation dialog defined
+ * type.
+ *
+ * @param {?string}[type = "JS"] type - The address book type needing creation.
+ */
+function openNewABDialog(type = "JS") {
+ toAddressBook({ action: `create_ab_${type}` });
+}
+
+/**
+ * Verifies we have the attachments in order to populate the menupopup.
+ * Resets the popup to be populated.
+ *
+ * @param {DOMEvent} event - The popupshowing event.
+ */
+function fillAttachmentListPopup(event) {
+ if (event.target.id != "attachmentMenuList") {
+ return;
+ }
+
+ const popup = event.target;
+
+ // Clear out the old menupopup.
+ while (popup.firstElementChild?.localName == "menu") {
+ popup.firstElementChild?.remove();
+ }
+
+ let aboutMessage =
+ document.getElementById("tabmail")?.currentAboutMessage ||
+ document.getElementById("messageBrowser")?.contentWindow;
+ if (!aboutMessage) {
+ return;
+ }
+
+ let attachments = aboutMessage.currentAttachments;
+ for (let [index, attachment] of attachments.entries()) {
+ addAttachmentToPopup(aboutMessage, popup, attachment, index);
+ }
+ aboutMessage.goUpdateAttachmentCommands();
+}
+
+/**
+ * Add each attachment to the menupop up before the menuseparator and create
+ * a submenu with the attachments' options (open, save, detach and delete).
+ *
+ * @param {?Window} aboutMessage - The current message on the message pane.
+ * @param {XULPopupElement} popup - #attachmentMenuList menupopup.
+ * @param {AttachmentInfo} attachment - The file attached to the email.
+ * @param {integer} attachmentIndex - The attachment's index.
+ */
+function addAttachmentToPopup(
+ aboutMessage,
+ popup,
+ attachment,
+ attachmentIndex
+) {
+ let item = document.createXULElement("menu");
+
+ function getString(aName) {
+ return document.getElementById("bundle_messenger").getString(aName);
+ }
+
+ // Insert the item just before the separator. The separator is the 2nd to
+ // last element in the popup.
+ item.classList.add("menu-iconic");
+ item.setAttribute("image", getIconForAttachment(attachment));
+
+ const separator = popup.querySelector("menuseparator");
+
+ // We increment the attachmentIndex here since we only use it for the
+ // label and accesskey attributes, and we want the accesskeys for the
+ // attachments list in the menu to be 1-indexed.
+ attachmentIndex++;
+
+ let displayName = SanitizeAttachmentDisplayName(attachment);
+ let label = document
+ .getElementById("bundle_messenger")
+ .getFormattedString("attachmentDisplayNameFormat", [
+ attachmentIndex,
+ displayName,
+ ]);
+ item.setAttribute("crop", "center");
+ item.setAttribute("label", label);
+ item.setAttribute("accesskey", attachmentIndex % 10);
+
+ // Each attachment in the list gets its own menupopup with options for
+ // saving, deleting, detaching, etc.
+ let menupopup = document.createXULElement("menupopup");
+ menupopup = item.appendChild(menupopup);
+
+ item = popup.insertBefore(item, separator);
+
+ if (attachment.isExternalAttachment) {
+ if (!attachment.hasFile) {
+ item.classList.add("notfound");
+ } else {
+ // The text-link class must be added to the <label> and have a <menu>
+ // hover rule. Adding to <menu> makes hover overflow the underline to
+ // the popup items.
+ let label = item.children[1];
+ label.classList.add("text-link");
+ }
+ }
+
+ if (attachment.isDeleted) {
+ item.classList.add("notfound");
+ }
+
+ let detached = attachment.isExternalAttachment;
+ let deleted = !attachment.hasFile;
+ let canDetach = aboutMessage?.CanDetachAttachments() && !deleted && !detached;
+
+ if (deleted) {
+ // We can't do anything with a deleted attachment, so just return.
+ item.disabled = true;
+ return;
+ }
+
+ // Create the "open" menu item
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.addEventListener("command", () =>
+ attachment.open(aboutMessage.browsingContext)
+ );
+ menuitem.setAttribute("label", getString("openLabel"));
+ menuitem.setAttribute("accesskey", getString("openLabelAccesskey"));
+ menuitem.setAttribute("disabled", deleted);
+ menuitem = menupopup.appendChild(menuitem);
+
+ // Create the "save" menu item
+ menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.addEventListener("command", () => attachment.save(messenger));
+ menuitem.setAttribute("label", getString("saveLabel"));
+ menuitem.setAttribute("accesskey", getString("saveLabelAccesskey"));
+ menuitem.setAttribute("disabled", deleted);
+ menuitem = menupopup.appendChild(menuitem);
+
+ // Create the "detach" menu item
+ menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.addEventListener("command", () =>
+ attachment.detach(messenger, true)
+ );
+ menuitem.setAttribute("label", getString("detachLabel"));
+ menuitem.setAttribute("accesskey", getString("detachLabelAccesskey"));
+ menuitem.setAttribute("disabled", !canDetach);
+ menuitem = menupopup.appendChild(menuitem);
+
+ // Create the "delete" menu item
+ menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.addEventListener("command", () =>
+ attachment.detach(messenger, false)
+ );
+ menuitem.setAttribute("label", getString("deleteLabel"));
+ menuitem.setAttribute("accesskey", getString("deleteLabelAccesskey"));
+ menuitem.setAttribute("disabled", !canDetach);
+ menuitem = menupopup.appendChild(menuitem);
+
+ // Create the "open containing folder" menu item, for existing detached only.
+ if (attachment.isFileAttachment) {
+ let menuseparator = document.createXULElement("menuseparator");
+ menupopup.appendChild(menuseparator);
+ menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.setAttribute("oncommand", "this.attachment.openFolder();");
+ menuitem.setAttribute("label", getString("openFolderLabel"));
+ menuitem.setAttribute("accesskey", getString("openFolderLabelAccesskey"));
+ menuitem.setAttribute("disabled", !attachment.hasFile);
+ menuitem = menupopup.appendChild(menuitem);
+ }
+}
+
+/**
+ * Return the string of the corresponding type of attachment's icon.
+ *
+ * @param {AttachmentInfo} attachment - The file attached to the email.
+ * @returns {string}
+ */
+function getIconForAttachment(attachment) {
+ return attachment.isDeleted
+ ? "chrome://messenger/skin/icons/attachment-deleted.svg"
+ : `moz-icon://${attachment.name}?size=16&amp;contentType=${attachment.contentType}`;
+}
+
+/**
+ * Opens the Address Book to add the email address from the given mailto: URL.
+ *
+ * @param {string} url
+ */
+function addEmail(url) {
+ let addresses = getEmail(url);
+ toAddressBook({
+ action: "create",
+ address: addresses,
+ });
+}
+
+/**
+ * Extracts email address(es) from the given mailto: URL.
+ *
+ * @param {string} url
+ * @returns {string}
+ */
+function getEmail(url) {
+ let mailtolength = 7;
+ let qmark = url.indexOf("?");
+ let addresses;
+
+ if (qmark > mailtolength) {
+ addresses = url.substring(mailtolength, qmark);
+ } else {
+ addresses = url.substr(mailtolength);
+ }
+ // Let's try to unescape it using a character set
+ try {
+ addresses = Services.textToSubURI.unEscapeURIForUI(addresses);
+ } catch (ex) {
+ // Do nothing.
+ }
+ return addresses;
+}
+
+/**
+ * Begins composing an email to the address from the given mailto: URL.
+ *
+ * @param {string} linkURL
+ * @param {nsIMsgIdentity} [identity] - The identity to use, otherwise the
+ * default identity is used.
+ */
+function composeEmailTo(linkURL, identity) {
+ let fields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ fields.to = getEmail(linkURL);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.Default;
+ if (identity) {
+ params.identity = identity;
+ }
+ params.composeFields = fields;
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+}
diff --git a/comm/mail/base/content/mainCommandSet.inc.xhtml b/comm/mail/base/content/mainCommandSet.inc.xhtml
new file mode 100644
index 0000000000..ddc4fb6b66
--- /dev/null
+++ b/comm/mail/base/content/mainCommandSet.inc.xhtml
@@ -0,0 +1,247 @@
+# 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/.
+
+ <command id="cmd_quitApplication" oncommand="goQuitApplication(event)"/>
+ <command id="cmd_CustomizeMailToolbar"
+ oncommand="CustomizeMailToolbar('mail-toolbox', 'CustomizeMailToolbar')"/>
+
+ <commandset id="mailFileMenuItems"
+ commandupdater="true"
+ events="create-menu-file"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+ <command id="cmd_newFolder" oncommand="goDoCommand('cmd_newFolder')" disabled="true"/>
+ <command id="cmd_newVirtualFolder" oncommand="goDoCommand('cmd_newVirtualFolder')" disabled="true"/>
+ <command id="cmd_getNewMessages" oncommand="goDoCommand('cmd_getNewMessages')" disabled="true"/>
+ <command id="cmd_open" oncommand="goDoCommand('cmd_open')"/>
+
+ <command id="cmd_emptyTrash" oncommand="goDoCommand('cmd_emptyTrash')" disabled="true"/>
+ <command id="cmd_compactFolder" oncommand="goDoCommand('cmd_compactFolder')" disabled="true"/>
+ <command id="cmd_print" oncommand="goDoCommand('cmd_print')" disabled="true"/>
+ <command id="cmd_saveAsFile" oncommand="goDoCommand('cmd_saveAsFile')" disabled="true"/>
+ <command id="cmd_saveAsTemplate" oncommand="goDoCommand('cmd_saveAsTemplate')" disabled="true"/>
+ <command id="cmd_getNextNMessages" oncommand="goDoCommand('cmd_getNextNMessages')" disabled="true"/>
+ <command id="cmd_deleteFolder" oncommand="goDoCommand('cmd_deleteFolder')"/>
+ <command id="cmd_renameFolder" oncommand="goDoCommand('cmd_renameFolder')" />
+ <command id="cmd_sendUnsentMsgs" oncommand="goDoCommand('cmd_sendUnsentMsgs')" />
+ <command id="cmd_subscribe" oncommand="goDoCommand('cmd_subscribe')" disabled="true"/>
+ <command id="cmd_synchronizeOffline" oncommand="goDoCommand('cmd_synchronizeOffline')" disabled="true"/>
+ <command id="cmd_downloadFlagged" oncommand="goDoCommand('cmd_downloadFlagged')" disabled="true"/>
+ <command id="cmd_downloadSelected" oncommand="goDoCommand('cmd_downloadSelected')" disabled="true"/>
+ <command id="cmd_settingsOffline" oncommand="goDoCommand('cmd_settingsOffline')" disabled="true"/>
+ </commandset>
+
+ <commandset id="mailViewMenuItems"
+ commandupdater="true"
+ events="create-menu-view"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+ <command id="cmd_showQuickFilterBar"
+ oncommand="goDoCommand('cmd_showQuickFilterBar');"/>
+ <command id="cmd_toggleQuickFilterBar"
+ oncommand="goDoCommand('cmd_toggleQuickFilterBar');"/>
+ <command id="cmd_viewPageSource" oncommand="goDoCommand('cmd_viewPageSource')" disabled="true"/>
+ <command id="cmd_setFolderCharset" oncommand="goDoCommand('cmd_setFolderCharset')" />
+
+ <command id="cmd_expandAllThreads" oncommand="goDoCommand('cmd_expandAllThreads')" disabled="true"/>
+ <command id="cmd_collapseAllThreads" oncommand="goDoCommand('cmd_collapseAllThreads')" disabled="true"/>
+ <command id="cmd_viewClassicMailLayout" oncommand="goDoCommand('cmd_viewClassicMailLayout')" disabled="true"/>
+ <command id="cmd_viewWideMailLayout" oncommand="goDoCommand('cmd_viewWideMailLayout')" disabled="true"/>
+ <command id="cmd_viewVerticalMailLayout" oncommand="goDoCommand('cmd_viewVerticalMailLayout')" disabled="true"/>
+ <command id="cmd_toggleFolderPane" oncommand="goDoCommand('cmd_toggleFolderPane')" disabled="true"/>
+ <command id="cmd_toggleThreadPaneHeader" oncommand="goDoCommand('cmd_toggleThreadPaneHeader')" disabled="true"/>
+ <command id="cmd_toggleMessagePane" oncommand="goDoCommand('cmd_toggleMessagePane')" disabled="true"/>
+ <command id="cmd_viewAllMsgs" oncommand="goDoCommand('cmd_viewAllMsgs')" disabled="true"/>
+ <command id="cmd_viewUnreadMsgs" oncommand="goDoCommand('cmd_viewUnreadMsgs')" disabled="true"/>
+ <command id="cmd_viewThreadsWithUnread" oncommand="goDoCommand('cmd_viewThreadsWithUnread')" disabled="true"/>
+ <command id="cmd_viewWatchedThreadsWithUnread" oncommand="goDoCommand('cmd_viewWatchedThreadsWithUnread')" disabled="true"/>
+ <command id="cmd_viewIgnoredThreads" oncommand="goDoCommand('cmd_viewIgnoredThreads')" disabled="true"/>
+ <commandset id="viewZoomCommands"
+ commandupdater="true"
+ events="create-menu-view"
+ oncommandupdate="goUpdateMailMenuItems(this);">
+ <command id="cmd_fullZoomReduce"
+ oncommand="goDoCommand('cmd_fullZoomReduce');"/>
+ <command id="cmd_fullZoomEnlarge"
+ oncommand="goDoCommand('cmd_fullZoomEnlarge');"/>
+ <command id="cmd_fullZoomReset"
+ oncommand="goDoCommand('cmd_fullZoomReset');"/>
+ <command id="cmd_fullZoomToggle"
+ oncommand="goDoCommand('cmd_fullZoomToggle');"
+ checked="false"/>
+ </commandset>
+ </commandset>
+
+ <commandset id="mailEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+
+ <command id="cmd_undo"
+ oncommand="goDoCommand('cmd_undo')"
+ disabled="true"
+ valueDeleteMsg="&undoDeleteMsgCmd.label;"
+ valueMoveMsg="&undoMoveMsgCmd.label;"
+ valueCopyMsg="&undoCopyMsgCmd.label;"
+ valueUnmarkAllMsgs="&undoMarkAllCmd.label;"
+ valueDefault="&undoDefaultCmd.label;"/>
+ <command id="cmd_redo"
+ oncommand="goDoCommand('cmd_redo')"
+ disabled="true"
+ valueDeleteMsg="&redoDeleteMsgCmd.label;"
+ valueMoveMsg="&redoMoveMsgCmd.label;"
+ valueCopyMsg="&redoCopyMsgCmd.label;"
+ valueUnmarkAllMsgs="&redoMarkAllCmd.label;"
+ valueDefault="&redoDefaultCmd.label;"/>
+ <command id="cmd_cut"
+ oncommand="goDoCommand('cmd_cut')"
+ disabled="true"/>
+ <command id="cmd_copy"
+ oncommand="goDoCommand('cmd_copy')"
+ disabled="true"/>
+ <command id="cmd_paste"
+ oncommand="goDoCommand('cmd_paste')"
+ disabled="true"/>
+ <command id="cmd_delete"
+ oncommand="goDoCommand('cmd_delete')"
+ disabled="true"/>
+ <command id="cmd_cancel" oncommand="goDoCommand('cmd_cancel')"/>
+ <command id="cmd_selectThread" oncommand="goDoCommand('cmd_selectThread')"/>
+ <command id="cmd_selectFlagged" oncommand="goDoCommand('cmd_selectFlagged')"/>
+ <command id="cmd_toggleFavoriteFolder" oncommand="goDoCommand('cmd_toggleFavoriteFolder')"/>
+ <command id="cmd_properties" oncommand="goDoCommand('cmd_properties')"/>
+ <command id="cmd_find" oncommand="goDoCommand('cmd_find')" disabled="true"/>
+ <command id="cmd_findAgain" oncommand="goDoCommand('cmd_findAgain')" disabled="true"/>
+ <command id="cmd_findPrevious" oncommand="goDoCommand('cmd_findPrevious')"
+ disabled="true"/>
+ <command id="cmd_searchMessages" oncommand="goDoCommand('cmd_searchMessages');"/>
+ <!-- Stop/abort current network activities. -->
+ <command id="cmd_stop" oncommand="goDoCommand('cmd_stop')"/>
+ <command id="cmd_reload" oncommand="goDoCommand('cmd_reload')"/>
+ </commandset>
+
+ <commandset id="mailEditContextMenuItems">
+ <command id="cmd_copyLink" oncommand="goDoCommand('cmd_copyLink')" disabled="false"/>
+ <command id="cmd_copyImage" oncommand="goDoCommand('cmd_copyImageContents')" disabled="false"/>
+ </commandset>
+
+ <commandset id="mailGoMenuItems"
+ commandupdater="true"
+ events="create-menu-go"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+
+ <command id="cmd_nextMsg" oncommand="goDoCommand('cmd_nextMsg')" disabled="true"/>
+ <command id="cmd_nextUnreadMsg" oncommand="goDoCommand('cmd_nextUnreadMsg')" disabled="true"/>
+ <command id="cmd_nextFlaggedMsg" oncommand="goDoCommand('cmd_nextFlaggedMsg')" disabled="true"/>
+ <command id="cmd_nextUnreadThread" oncommand="goDoCommand('cmd_nextUnreadThread')" disabled="true"/>
+ <command id="cmd_previousMsg" oncommand="goDoCommand('cmd_previousMsg')" disabled="true"/>
+ <command id="cmd_previousUnreadMsg" oncommand="goDoCommand('cmd_previousUnreadMsg')" disabled="true"/>
+ <command id="cmd_previousFlaggedMsg" oncommand="goDoCommand('cmd_previousFlaggedMsg')" disabled="true"/>
+ <command id="cmd_goStartPage" oncommand="goDoCommand('cmd_goStartPage');"/>
+ <command id="cmd_undoCloseTab" oncommand="goDoCommand('cmd_undoCloseTab');"/>
+ <command id="cmd_goForward" oncommand="goDoCommand('cmd_goForward')" disabled="true"/>
+ <command id="cmd_goBack" oncommand="goDoCommand('cmd_goBack')" disabled="true"/>
+ <command id="cmd_goFolder" oncommand="goDoCommand('cmd_goFolder', event)" disabled="true"/>
+ <command id="cmd_chat" oncommand="goDoCommand('cmd_chat')" disabled="true"/>
+ </commandset>
+
+ <commandset id="mailMessageMenuItems"
+ commandupdater="true"
+ events="create-menu-message"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+ <command id="cmd_archive" oncommand="goDoCommand('cmd_archive')"/>
+ <command id="cmd_newMessage" oncommand="goDoCommand('cmd_newMessage', event)"/>
+ <command id="cmd_reply" oncommand="goDoCommand('cmd_reply', event)"/>
+ <command id="cmd_replySender" oncommand="goDoCommand('cmd_replySender', event)"/>
+ <command id="cmd_replyGroup" oncommand="goDoCommand('cmd_replyGroup', event)"/>
+ <command id="cmd_replyall" oncommand="goDoCommand('cmd_replyall', event)"/>
+ <command id="cmd_replylist" oncommand="goDoCommand('cmd_replylist', event)"/>
+ <command id="cmd_forward" oncommand="goDoCommand('cmd_forward', event);"/>
+ <command id="cmd_forwardInline" oncommand="goDoCommand('cmd_forwardInline', event)"/>
+ <command id="cmd_forwardAttachment" oncommand="goDoCommand('cmd_forwardAttachment', event);"/>
+ <command id="cmd_redirect" oncommand="goDoCommand('cmd_redirect', event)"/>
+ <command id="cmd_editAsNew" oncommand="goDoCommand('cmd_editAsNew', event)"/>
+ <command id="cmd_editDraftMsg" oncommand="goDoCommand('cmd_editDraftMsg', event)"/>
+ <command id="cmd_newMsgFromTemplate" oncommand="goDoCommand('cmd_newMsgFromTemplate', event)"/>
+ <command id="cmd_editTemplateMsg" oncommand="goDoCommand('cmd_editTemplateMsg', event)"/>
+ <command id="cmd_openMessage" oncommand="goDoCommand('cmd_openMessage')"/>
+ <command id="cmd_openConversation" oncommand="goDoCommand('cmd_openConversation')"/>
+ <command id="cmd_moveToFolderAgain" oncommand="goDoCommand('cmd_moveToFolderAgain')"/>
+ <command id="cmd_createFilterFromMenu" oncommand="goDoCommand('cmd_createFilterFromMenu')"/>
+ <command id="cmd_killThread" oncommand="goDoCommand('cmd_killThread')"/>
+ <command id="cmd_killSubthread" oncommand="goDoCommand('cmd_killSubthread')"/>
+ <command id="cmd_watchThread" oncommand="goDoCommand('cmd_watchThread')"/>
+ </commandset>
+
+ <commandset id="mailGetMsgMenuItems"
+ commandupdater="true"
+ events="create-menu-getMsgToolbar,create-menu-file"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+
+ <command id="cmd_getMsgsForAuthAccounts"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts'); event.stopPropagation()"
+ disabled="true"/>
+ </commandset>
+
+ <commandset id="mailMarkMenuItems"
+ commandupdater="true"
+ events="create-menu-mark"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+ <command id="cmd_mark"/>
+ <command id="cmd_tag"/>
+ <command id="cmd_toggleRead" oncommand="goDoCommand('cmd_toggleRead'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAsRead" oncommand="goDoCommand('cmd_markAsRead'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAsUnread" oncommand="goDoCommand('cmd_markAsUnread'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAllRead" oncommand="goDoCommand('cmd_markAllRead'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markThreadAsRead" oncommand="goDoCommand('cmd_markThreadAsRead'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markReadByDate" oncommand="goDoCommand('cmd_markReadByDate');" disabled="true"/>
+ <command id="cmd_markAsFlagged" oncommand="goDoCommand('cmd_markAsFlagged'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAsJunk" oncommand="goDoCommand('cmd_markAsJunk'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAsNotJunk" oncommand="goDoCommand('cmd_markAsNotJunk'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_recalculateJunkScore" oncommand="goDoCommand('cmd_recalculateJunkScore');" disabled="true"/>
+ <command id="cmd_viewAllHeader" oncommand="goDoCommand('cmd_viewAllHeader');" disabled="true"/>
+ <command id="cmd_viewNormalHeader" oncommand="goDoCommand('cmd_viewNormalHeader');" disabled="true"/>
+ </commandset>
+
+ <commandset id="mailTagMenuItems"
+ commandupdater="true"
+ events="create-menu-tag"
+ oncommandupdate="goUpdateMailMenuItems(this);">
+
+ <command id="cmd_addTag" oncommand="goDoCommand('cmd_addTag'); event.stopPropagation();"/>
+ <command id="cmd_manageTags" oncommand="goDoCommand('cmd_manageTags'); event.stopPropagation();"/>
+ <command id="cmd_removeTags" oncommand="goDoCommand('cmd_removeTags'); event.stopPropagation();"/>
+ </commandset>
+
+ <commandset id="mailToolsMenuItems"
+ commandupdater="true"
+ events="create-menu-tasks"
+ oncommandupdate="goUpdateMailMenuItems(this);">
+ <command id="cmd_applyFilters" oncommand="goDoCommand('cmd_applyFilters');" disabled="true"/>
+ <command id="cmd_applyFiltersToSelection"
+ oncommand="goDoCommand('cmd_applyFiltersToSelection');"
+ disabled="true"
+ valueSelection="&filtersApplyToSelection.label;"
+ valueSelectionAccessKey="&filtersApplyToSelection.accesskey;"
+ valueMessage="&filtersApplyToMessage.label;"
+ valueMessageAccessKey="&filtersApplyToMessage.accesskey;"/>
+ <command id="cmd_runJunkControls" oncommand="goDoCommand('cmd_runJunkControls');" disabled="true"/>
+ <command id="cmd_deleteJunk" oncommand="goDoCommand('cmd_deleteJunk');" disabled="true"/>
+ </commandset>
+
+ <commandset id="mailChatMenuItems"
+ commandupdater="true"
+ events="create-menu-tag">
+ <command id="cmd_joinChat" oncommand="chatHandler.joinChat();" disabled="true"/>
+ <command id="cmd_chatStatus" oncommand="chatHandler.setStatusMenupopupCommand(event);" disabled="true"/>
+ <command id="cmd_addChatBuddy" oncommand="chatHandler.addBuddy();" disabled="true"/>
+ </commandset>
+
+#ifdef XP_MACOSX
+ <commandset id="macWindowMenuItems">
+ <!-- Mac Window menu -->
+ <command id="minimizeWindow" label="&minimizeWindow.label;" oncommand="window.minimize();"/>
+ <command id="zoomWindow" label="&zoomWindow.label;" oncommand="zoomWindow();"/>
+ <command id="Tasks:Mail" oncommand="focusOnMail(1);"/>
+ <command id="Tasks:AddressBook" oncommand="toAddressBook();"/>
+ </commandset>
+#endif
diff --git a/comm/mail/base/content/mainKeySet.inc.xhtml b/comm/mail/base/content/mainKeySet.inc.xhtml
new file mode 100644
index 0000000000..6d79ad8c0c
--- /dev/null
+++ b/comm/mail/base/content/mainKeySet.inc.xhtml
@@ -0,0 +1,264 @@
+# 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/.
+
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+#define XP_GNOME 1
+#endif
+#endif
+
+ <key id="space" key=" " modifiers="shift any" oncommand="goDoCommand('cmd_space', event);"/>
+
+ <!-- File Menu -->
+ <key id="key_close" key="&closeCmd.key;" command="cmd_close" modifiers="accel"/>
+#ifndef XP_MACOSX
+ <key id="key_close2" keycode="VK_F4" modifiers="accel" command="cmd_close"/>
+ <key id="key_renameFolder" keycode="&renameFolder.key;" oncommand="goDoCommand('cmd_renameFolder')"/>
+#endif
+ <key id="key_quitApplication" data-l10n-id="quit-app-shortcut"
+#ifdef XP_WIN
+ modifiers="accel,shift"
+#else
+ modifiers="accel"
+#endif
+#ifdef XP_MACOSX
+ internal="true"
+#else
+ command="cmd_quitApplication"
+#endif
+ reserved="true"/>
+ <!-- Edit Menu -->
+ <key id="key_undo" data-l10n-id="text-action-undo-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_redo"
+#ifdef XP_UNIX
+ data-l10n-id="text-action-undo-shortcut" modifiers="shift, accel"
+#else
+ data-l10n-id="text-action-redo-shortcut" modifiers="accel"
+#endif
+ internal="true"/>
+ <key id="key_cut" data-l10n-id="text-action-cut-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_paste" data-l10n-id="text-action-paste-shortcut" modifiers="accel" internal="true"/>
+#ifdef XP_MACOSX
+ <key id="key_delete" keycode="VK_BACK"
+ oncommand="goDoCommand('cmd_delete');"/>
+ <key id="key_delete2" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_delete');"/>
+ <key id="cmd_shiftDelete" keycode="VK_BACK"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+ <key id="cmd_shiftDelete2" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+#else
+ <key id="key_delete" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_delete');"/>
+ <key id="cmd_shiftDelete" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+#endif
+ <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_selectThread" key="&selectThreadCmd.key;" oncommand="goDoCommand('cmd_selectThread');" modifiers="accel, shift"/>
+
+ <key id="key_toggleRead" key="&toggleReadCmd.key;" oncommand="goDoCommand('cmd_toggleRead');"/>
+ <key id="key_toggleFlagged" key="&markStarredCmd.key;" oncommand="goDoCommand('cmd_markAsFlagged');"/>
+ <key id="key_markJunk" key="&markAsJunkCmd.key;" oncommand="goDoCommand('cmd_markAsJunk');"/>
+ <key id="key_markNotJunk" key="&markAsNotJunkCmd.key;" oncommand="goDoCommand('cmd_markAsNotJunk');"
+ modifiers="shift"/>
+
+ <key id="key_markAllRead" key="&markAllReadCmd.key;"
+ oncommand="goDoCommand('cmd_markAllRead');" modifiers="shift"/>
+
+ <key id="key_markThreadAsRead" key="&markThreadAsReadCmd.key;" oncommand="goDoCommand('cmd_markThreadAsRead')"/>
+ <key id="key_markReadByDate" key="&markReadByDateCmd.key;" oncommand="goDoCommand('cmd_markReadByDate')"/>
+ <key id="key_nextMsg" key="&nextMsgCmd.key;" oncommand="goDoCommand('cmd_nextMsg')"/>
+ <key id="key_nextUnreadMsg" key="&nextUnreadMsgCmd.key;" oncommand="goDoCommand('cmd_nextUnreadMsg')"/>
+ <key id="key_expandAllThreads" key="&expandAllThreadsCmd.key;" oncommand="goDoCommand('cmd_expandAllThreads')"/>
+ <key key="&expandAllThreadsCmd.key;" modifiers="shift" oncommand="goDoCommand('cmd_expandAllThreads')"/>
+ <key id="key_collapseAllThreads" key="&collapseAllThreadsCmd.key;" oncommand="goDoCommand('cmd_collapseAllThreads')"/>
+ <key key="&collapseAllThreadsCmd.key;" modifiers="shift" oncommand="goDoCommand('cmd_collapseAllThreads')"/>
+ <key id="key_nextUnreadThread" key="&nextUnreadThread.key;" oncommand="goDoCommand('cmd_nextUnreadThread')"/>
+ <key id="key_previousMsg" key="&prevMsgCmd.key;" oncommand="goDoCommand('cmd_previousMsg')"/>
+ <key id="key_previousUnreadMsg" key="&prevUnreadMsgCmd.key;" oncommand="goDoCommand('cmd_previousUnreadMsg')"/>
+ <key id="key_archive" key="&archiveMsgCmd.key;" oncommand="goDoCommand('cmd_archive')"/>
+ <key id="key_goForward" key="&goForwardCmd.commandKey;" oncommand="goDoCommand('cmd_goForward')"/>
+ <key id="key_goBack" key="&goBackCmd.commandKey;" oncommand="goDoCommand('cmd_goBack')"/>
+ <key id="key_goStartPage" keycode="VK_HOME" oncommand="goDoCommand('cmd_goStartPage')" modifiers="alt"/>
+ <key id="key_undoCloseTab" key="&undoCloseTabCmd.commandkey;" oncommand="goDoCommand('cmd_undoCloseTab')" modifiers="accel, shift"/>
+ <key id="key_reply" key="&replyMsgCmd.key;" oncommand="goDoCommand('cmd_reply')" modifiers="accel"/>
+ <key id="key_replyall" key="&replyToAllMsgCmd.key;" oncommand="goDoCommand('cmd_replyall')" modifiers="accel, shift"/>
+ <key id="key_replylist" key="&replyToListMsgCmd.key;" oncommand="goDoCommand('cmd_replylist')" modifiers="accel, shift"/>
+ <key id="key_forward" key="&forwardMsgCmd.key;" oncommand="goDoCommand('cmd_forward')" modifiers="accel"/>
+ <key id="key_editAsNew" key="&editAsNewMsgCmd.key;" oncommand="goDoCommand('cmd_editAsNew')" modifiers="accel"/>
+ <key id="key_newMsgFromTemplate" keycode="&newMsgFromTemplateCmd.keycode;" internal="true"/><!-- for display on menus only -->
+ <key id="key_watchThread" key="&watchThreadMenu.key;" oncommand="goDoCommand('cmd_watchThread')" />
+ <key id="key_killThread" key="&killThreadMenu.key;" oncommand="goDoCommand('cmd_killThread')" />
+ <key id="key_killSubthread" key="&killSubthreadMenu.key;" oncommand="goDoCommand('cmd_killSubthread')" modifiers="shift" />
+ <key id="key_openMessage" key="&openMessageWindowCmd.key;" oncommand="goDoCommand('cmd_openMessage')" modifiers="accel"/>
+ <key id="key_openConversation" key="&openInConversationCmd.key;" oncommand="goDoCommand('cmd_openConversation')" modifiers="accel, shift"/>
+#ifdef XP_MACOSX
+ <key id="key_moveToFolderAgain" key="&moveToFolderAgainCmd.key;" oncommand="goDoCommand('cmd_moveToFolderAgain')" modifiers="alt, accel"/>
+#else
+ <key id="key_moveToFolderAgain" key="&moveToFolderAgainCmd.key;" oncommand="goDoCommand('cmd_moveToFolderAgain')" modifiers="accel, shift"/>
+#endif
+ <key id="key_print" key="&printCmd.key;" oncommand="goDoCommand('cmd_print')" modifiers="accel"/>
+ <key id="key_saveAsFile" key="&saveAsFileCmd.key;" oncommand="goDoCommand('cmd_saveAsFile')" modifiers="accel"/>
+ <key id="key_viewPageSource" key="&pageSourceCmd.key;" oncommand="goDoCommand('cmd_viewPageSource')" modifiers="accel"/>
+#ifdef XP_MACOSX
+ <key id="key_getNewMessagesAlt" keycode="VK_F5"
+ oncommand="goDoCommand('cmd_getNewMessages');"/>
+ <key id="key_getAllNewMessagesAlt" keycode="VK_F5" modifiers="shift"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/>
+ <key id="key_getNewMessages" key="&getNewMessagesCmd.key;" modifiers="accel"
+ oncommand="goDoCommand('cmd_getNewMessages');"/>
+ <key id="key_getAllNewMessages" key="&getAllNewMessagesCmd.key;" modifiers="accel, shift"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/>
+#else
+ <key id="key_getNewMessages" keycode="VK_F5"
+ oncommand="goDoCommand('cmd_getNewMessages');"/>
+ <key id="key_getAllNewMessages" keycode="VK_F5" modifiers="shift"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/>
+#endif
+#ifdef XP_GNOME
+ <key id="key_getNewMessages2" keycode="VK_F9"
+ oncommand="goDoCommand('cmd_getNewMessages');"/>
+ <key id="key_getAllNewMessages2" keycode="VK_F9" modifiers="shift"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/>
+#endif
+ <key id="key_find" key="&findCmd.key;" oncommand="goDoCommand('cmd_find')" modifiers="accel"/>
+ <key id="key_findAgain" key="&findAgainCmd.key;" oncommand="goDoCommand('cmd_findAgain')" modifiers="accel"/>
+ <key id="key_findPrev" key="&findPrevCmd.key;" oncommand="goDoCommand('cmd_findPrevious')" modifiers="accel, shift"/>
+ <key keycode="&findAgainCmd.key2;" oncommand="goDoCommand('cmd_findAgain')"/>
+ <key keycode="&findPrevCmd.key2;" oncommand="goDoCommand('cmd_findPrevious')" modifiers="shift"/>
+ <key id="key_quickSearchFocus" key="&quickSearchCmd.key;" oncommand="QuickSearchFocus()" modifiers="accel"/>
+
+ <keyset id="viewZoomKeys">
+ <key id="key_fullZoomReduce" key="&fullZoomReduceCmd.commandkey;"
+ command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key key="&fullZoomReduceCmd.commandkey2;"
+ command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key id="key_fullZoomEnlarge" key="&fullZoomEnlargeCmd.commandkey;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key key="&fullZoomEnlargeCmd.commandkey2;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key key="&fullZoomEnlargeCmd.commandkey3;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key id="key_fullZoomReset" key="&fullZoomResetCmd.commandkey;"
+ command="cmd_fullZoomReset" modifiers="accel"/>
+ <key key="&fullZoomResetCmd.commandkey2;"
+ command="cmd_fullZoomReset" modifiers="accel"/>
+ </keyset>
+
+ <!-- View Toggle Keys (F8) -->
+ <key id="key_toggleMessagePane" keycode="VK_F8" oncommand="goDoCommand('cmd_toggleMessagePane');"/>
+
+ <!-- Tag Keys -->
+ <!-- Includes both shifted and not, for Azerty and other layouts where the
+ numeric keys are shifted. -->
+ <key id="key_tag0" key="&tagCmd0.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_removeTags');"/>
+ <key id="key_tag1" key="&tagCmd1.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag1');"/>
+ <key id="key_tag2" key="&tagCmd2.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag2');"/>
+ <key id="key_tag3" key="&tagCmd3.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag3');"/>
+ <key id="key_tag4" key="&tagCmd4.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag4');"/>
+ <key id="key_tag5" key="&tagCmd5.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag5');"/>
+ <key id="key_tag6" key="&tagCmd6.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag6');"/>
+ <key id="key_tag7" key="&tagCmd7.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag7');"/>
+ <key id="key_tag8" key="&tagCmd8.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag8');"/>
+ <key id="key_tag9" key="&tagCmd9.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag9');"/>
+
+ <!-- Tools Keys -->
+ <key id="key_searchMail"
+ key="&searchMailCmd.key;"
+ modifiers="accel,shift"
+ command="cmd_searchMessages"/>
+ <key id="key_errorConsole"
+ key="&errorConsoleCmd.commandkey;"
+ oncommand="toJavaScriptConsole();"
+ modifiers="accel,shift"/>
+ <key id="key_devtoolsToolbox"
+ key="&devToolboxCmd.commandkey;"
+#ifdef XP_MACOSX
+ modifiers="accel,alt"
+#else
+ modifiers="accel,shift"
+#endif
+ oncommand="BrowserToolboxLauncher.init();"/>
+ <key id="key_sanitizeHistory"
+ keycode="VK_DELETE"
+ oncommand="toSanitize();"
+ modifiers="accel,shift"/>
+#ifdef XP_MACOSX
+ <key id="key_sanitizeHistory_mac"
+ keycode="VK_BACK"
+ oncommand="toSanitize();"
+ modifiers="accel,shift"/>
+#endif
+ <key id="key_addressbook"
+ key="&addressBookCmd.key;"
+ modifiers="accel, shift"
+ oncommand="toAddressBook();"/>
+ <key id="key_savedFiles"
+ key="&savedFiles.key;"
+ modifiers="accel"
+ oncommand="openSavedFilesWnd();"/>
+
+ <key id="key_qfb_show"
+ modifiers="accel,shift"
+ data-l10n-id="quick-filter-bar-show"
+ command="cmd_showQuickFilterBar"/>
+#ifdef XP_GNOME
+#define NUM_SELECT_TAB_MODIFIER alt
+#else
+#define NUM_SELECT_TAB_MODIFIER accel
+#endif
+
+#expand <key id="key_mail" oncommand="focusOnMail(0, event);" key="1" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab2" oncommand="focusOnMail(1, event);" key="2" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab3" oncommand="focusOnMail(2, event);" key="3" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab4" oncommand="focusOnMail(3, event);" key="4" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab5" oncommand="focusOnMail(4, event);" key="5" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab6" oncommand="focusOnMail(5, event);" key="6" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab7" oncommand="focusOnMail(6, event);" key="7" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab8" oncommand="focusOnMail(7, event);" key="8" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectLastTab" oncommand="focusOnMail(-1, event);" key="9" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+
+#ifdef XP_MACOSX
+ <!-- Mac Window menu keys -->
+ <key id="key_minimizeWindow"
+ command="minimizeWindow"
+ key="&minimizeWindow.key;"
+ modifiers="accel"/>
+ <key id="key_addressbook"
+ oncommand="toAddressBook();"
+ key="&addressBookCmd.key;"
+ modifiers="accel, shift"/>
+ <!-- the following 3 keys are used in the application menu on Mac OS X Cocoa widgets -->
+ <key id="key_preferencesCmdMac"
+ key="&preferencesCmdMac.commandkey;"
+ modifiers="&preferencesCmdMac.modifiers;"
+ internal="true"/>
+ <key id="key_hideThisAppCmdMac"
+ key="&hideThisAppCmdMac.commandkey;"
+ modifiers="&hideThisAppCmdMac.modifiers;"
+ internal="true"/>
+ <key id="key_hideOtherAppsCmdMac"
+ key="&hideOtherAppsCmdMac.commandkey;"
+ modifiers="&hideOtherAppsCmdMac.modifiers;"
+ internal="true"/>
+ <key id="key_openHelp"
+ oncommand="openSupportURL();"
+ key="&productHelpMac.commandkey;"
+ modifiers="&productHelpMac.modifiers;"/>
+#else
+ <key id="key_openHelp"
+ oncommand="openSupportURL();"
+ keycode="&productHelp.commandkey;"/>
+#endif
diff --git a/comm/mail/base/content/mainStatusbar.inc.xhtml b/comm/mail/base/content/mainStatusbar.inc.xhtml
new file mode 100644
index 0000000000..149d06fea7
--- /dev/null
+++ b/comm/mail/base/content/mainStatusbar.inc.xhtml
@@ -0,0 +1,19 @@
+# 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/.
+
+ <hbox id="statusTextBox" ondblclick="openActivityMgr();" flex="1">
+ <hbox class="statusbarpanel">
+ <toolbarbutton id="offline-status" oncommand="MailOfflineMgr.toggleOfflineStatus();"/>
+ </hbox>
+ <label id="statusText" class="statusbarpanel" value="&statusText.label;" flex="1" crop="end"/>
+ <hbox id="statusbar-progresspanel" class="statusbarpanel statusbarpanel-progress" collapsed="true">
+ <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/>
+ </hbox>
+ <hbox id="quotaPanel" class="statusbarpanel statusbarpanel-progress" hidden="true">
+ <stack>
+ <html:progress id="quotaMeter" class="progressmeter-statusbar" value="0" max="100"/>
+ <html:a id="quotaLabel" onclick="openFolderQuota();"></html:a>
+ </stack>
+ </hbox>
+ </hbox>
diff --git a/comm/mail/base/content/messageWindow.js b/comm/mail/base/content/messageWindow.js
new file mode 100644
index 0000000000..2b93de1fc9
--- /dev/null
+++ b/comm/mail/base/content/messageWindow.js
@@ -0,0 +1,742 @@
+/**
+ * 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 is where functions related to the standalone message window are kept */
+
+/* import-globals-from ../../../../toolkit/content/viewZoomOverlay.js */
+/* import-globals-from ../../../mailnews/base/prefs/content/accountUtils.js */
+/* import-globals-from ../../components/customizableui/content/panelUI.js */
+/* import-globals-from mail-offline.js */
+/* import-globals-from mailCommands.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from mailWindowOverlay.js */
+/* import-globals-from messenger-customization.js */
+/* import-globals-from toolbarIconColor.js */
+
+/* globals messenger, CreateMailWindowGlobals, InitMsgWindow, OnMailWindowUnload */ // From mailWindow.js
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+});
+
+var messageBrowser;
+
+function getBrowser() {
+ return document
+ .getElementById("messageBrowser")
+ .contentDocument.getElementById("messagepane");
+}
+
+this.__defineGetter__("browser", getBrowser);
+
+window.addEventListener("DOMContentLoaded", event => {
+ if (event.target != document) {
+ return;
+ }
+
+ messageBrowser = document.getElementById("messageBrowser");
+ messageBrowser.addEventListener("messageURIChanged", () => {
+ // Update toolbar buttons.
+ goUpdateCommand("cmd_getNewMessages");
+ goUpdateCommand("cmd_print");
+ goUpdateCommand("cmd_delete");
+ document.commandDispatcher.updateCommands("create-menu-go");
+ document.commandDispatcher.updateCommands("create-menu-message");
+ });
+ messageBrowser.addEventListener(
+ "load",
+ event => (messageBrowser.contentWindow.tabOrWindow = window),
+ true
+ );
+});
+window.addEventListener("load", OnLoadMessageWindow);
+window.addEventListener("unload", OnUnloadMessageWindow);
+
+// we won't show the window until the onload() handler is finished
+// so we do this trick (suggested by hyatt / blaker)
+function OnLoadMessageWindow() {
+ // Set a sane starting width/height for all resolutions on new profiles.
+ // Do this before the window loads.
+ if (!document.documentElement.hasAttribute("width")) {
+ // Prefer 860xfull height.
+ let defaultHeight = screen.availHeight;
+ let defaultWidth = screen.availWidth >= 860 ? 860 : screen.availWidth;
+
+ // On small screens, default to maximized state.
+ if (defaultHeight <= 600) {
+ document.documentElement.setAttribute("sizemode", "maximized");
+ }
+
+ document.documentElement.setAttribute("width", defaultWidth);
+ document.documentElement.setAttribute("height", defaultHeight);
+ // Make sure we're safe at the left/top edge of screen
+ document.documentElement.setAttribute("screenX", screen.availLeft);
+ document.documentElement.setAttribute("screenY", screen.availTop);
+ }
+
+ updateTroubleshootMenuItem();
+ ToolbarIconColor.init();
+ BondOpenPGP.init();
+ PanelUI.init();
+ gExtensionsNotifications.init();
+
+ setTimeout(delayedOnLoadMessageWindow, 0); // when debugging, set this to 5000, so you can see what happens after the window comes up.
+
+ messageBrowser.addEventListener("DOMTitleChanged", () => {
+ if (messageBrowser.contentTitle) {
+ if (AppConstants.platform == "macosx") {
+ document.title = messageBrowser.contentTitle;
+ } else {
+ document.title =
+ messageBrowser.contentTitle +
+ document.documentElement.getAttribute("titlemenuseparator") +
+ document.documentElement.getAttribute("titlemodifier");
+ }
+ } else {
+ document.title = document.documentElement.getAttribute("titlemodifier");
+ }
+ });
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+}
+
+function delayedOnLoadMessageWindow() {
+ HideMenus();
+ ShowMenus();
+ MailOfflineMgr.init();
+ CreateMailWindowGlobals();
+
+ // Run menubar initialization first, to avoid TabsInTitlebar code picking
+ // up mutations from it and causing a reflow.
+ if (AppConstants.platform != "macosx") {
+ AutoHideMenubar.init();
+ }
+
+ InitMsgWindow();
+
+ // initialize the customizeDone method on the customizeable toolbar
+ var toolbox = document.getElementById("mail-toolbox");
+ toolbox.customizeDone = function (aEvent) {
+ MailToolboxCustomizeDone(aEvent, "CustomizeMailToolbar");
+ };
+
+ SetupCommandUpdateHandlers();
+
+ setTimeout(actuallyLoadMessage, 0);
+}
+
+function actuallyLoadMessage() {
+ /*
+ * Our actual use cases that drive the arguments we take are:
+ * 1) Displaying a message from disk or that was an attachment on a message.
+ * Such messages have no (real) message header and must come in the form of
+ * a URI. (The message display code creates a 'dummy' header.)
+ * 2) Displaying a message that has a header available, either as a result of
+ * the user selecting a message in another window to spawn us or through
+ * some indirection like displaying a message by message-id. (The
+ * newsgroup UI exposes this, as well as the spotlight/vista indexers.)
+ *
+ * We clone views when possible for:
+ * - Consistency of navigation within the message display. Users would find
+ * it odd if they showed a message from a cross-folder view but ended up
+ * navigating around the message's actual folder.
+ * - Efficiency. It's faster to clone a view than open a new one.
+ *
+ * Our argument idioms for the use cases are thus:
+ * 1) [{msgHdr: A message header, viewWrapperToClone: (optional) a view
+ * wrapper to clone}]
+ * 2) [A Message header, (optional) the origin DBViewWraper]
+ * 3) [A Message URI] where the URI is an nsIURL corresponding to a message
+ * on disk or that is an attachment part on another message.
+ *
+ * Our original set of arguments, in case these get passed in and you're
+ * wondering why we explode, was:
+ * 0: A message URI, string or nsIURI.
+ * 1: A folder URI. If arg 0 was an nsIURI, it may have had a folder attribute.
+ * 2: The nsIMsgDBView used to open us.
+ */
+ if (window.arguments && window.arguments.length) {
+ let contentWindow = messageBrowser.contentWindow;
+ if (window.arguments[0] instanceof Ci.nsIURI) {
+ contentWindow.displayMessage(window.arguments[0].spec);
+ return;
+ }
+
+ let msgHdr, viewWrapperToClone;
+ // message header as an object?
+ if ("wrappedJSObject" in window.arguments[0]) {
+ let hdrObject = window.arguments[0].wrappedJSObject;
+ ({ msgHdr, viewWrapperToClone } = hdrObject);
+ } else if (window.arguments[0] instanceof Ci.nsIMsgDBHdr) {
+ // message header as a separate param?
+ msgHdr = window.arguments[0];
+ viewWrapperToClone = window.arguments[1];
+ }
+
+ contentWindow.displayMessage(
+ msgHdr.folder.getUriForMsg(msgHdr),
+ viewWrapperToClone
+ );
+ }
+
+ // set focus to the message pane
+ window.content.focus();
+}
+
+/**
+ * Load the given message into this window, and bring it to the front. This is
+ * supposed to be called whenever a message is supposed to be displayed in this
+ * window.
+ *
+ * @param aMsgHdr the message to display
+ * @param aViewWrapperToClone [optional] a DB view wrapper to clone for the
+ * message window
+ */
+function displayMessage(aMsgHdr, aViewWrapperToClone) {
+ let contentWindow = messageBrowser.contentWindow;
+ contentWindow.displayMessage(
+ aMsgHdr.folder.getUriForMsg(aMsgHdr),
+ aViewWrapperToClone
+ );
+
+ // bring this window to the front
+ window.focus();
+}
+
+function ShowMenus() {
+ var openMail3Pane_menuitem = document.getElementById("tasksMenuMail");
+ if (openMail3Pane_menuitem) {
+ openMail3Pane_menuitem.removeAttribute("hidden");
+ }
+}
+
+/* eslint-disable complexity */
+function HideMenus() {
+ // TODO: Seems to be a lot of repetitive code.
+ // Can we just fold this into an array of element IDs and loop over them?
+ var message_menuitem = document.getElementById("menu_showMessage");
+ if (message_menuitem) {
+ message_menuitem.setAttribute("hidden", "true");
+ }
+
+ message_menuitem = document.getElementById("appmenu_showMessage");
+ if (message_menuitem) {
+ message_menuitem.setAttribute("hidden", "true");
+ }
+
+ var folderPane_menuitem = document.getElementById("menu_showFolderPane");
+ if (folderPane_menuitem) {
+ folderPane_menuitem.setAttribute("hidden", "true");
+ }
+
+ folderPane_menuitem = document.getElementById("appmenu_showFolderPane");
+ if (folderPane_menuitem) {
+ folderPane_menuitem.setAttribute("hidden", "true");
+ }
+
+ var showSearch_showMessage_Separator = document.getElementById(
+ "menu_showSearch_showMessage_Separator"
+ );
+ if (showSearch_showMessage_Separator) {
+ showSearch_showMessage_Separator.setAttribute("hidden", "true");
+ }
+
+ var expandOrCollapseMenu = document.getElementById("menu_expandOrCollapse");
+ if (expandOrCollapseMenu) {
+ expandOrCollapseMenu.setAttribute("hidden", "true");
+ }
+
+ var menuDeleteFolder = document.getElementById("menu_deleteFolder");
+ if (menuDeleteFolder) {
+ menuDeleteFolder.hidden = true;
+ }
+
+ var renameFolderMenu = document.getElementById("menu_renameFolder");
+ if (renameFolderMenu) {
+ renameFolderMenu.setAttribute("hidden", "true");
+ }
+
+ var viewLayoutMenu = document.getElementById("menu_MessagePaneLayout");
+ if (viewLayoutMenu) {
+ viewLayoutMenu.setAttribute("hidden", "true");
+ }
+
+ viewLayoutMenu = document.getElementById("appmenu_MessagePaneLayout");
+ if (viewLayoutMenu) {
+ viewLayoutMenu.setAttribute("hidden", "true");
+ }
+
+ let paneViewSeparator = document.getElementById("appmenu_paneViewSeparator");
+ if (paneViewSeparator) {
+ paneViewSeparator.setAttribute("hidden", "true");
+ }
+
+ var viewFolderMenu = document.getElementById("menu_FolderViews");
+ if (viewFolderMenu) {
+ viewFolderMenu.setAttribute("hidden", "true");
+ }
+
+ viewFolderMenu = document.getElementById("appmenu_FolderViews");
+ if (viewFolderMenu) {
+ viewFolderMenu.setAttribute("hidden", "true");
+ }
+
+ var viewMessagesMenu = document.getElementById("viewMessagesMenu");
+ if (viewMessagesMenu) {
+ viewMessagesMenu.setAttribute("hidden", "true");
+ }
+
+ viewMessagesMenu = document.getElementById("appmenu_viewMessagesMenu");
+ if (viewMessagesMenu) {
+ viewMessagesMenu.setAttribute("hidden", "true");
+ }
+
+ var viewMessageViewMenu = document.getElementById("viewMessageViewMenu");
+ if (viewMessageViewMenu) {
+ viewMessageViewMenu.setAttribute("hidden", "true");
+ }
+
+ var viewMessagesMenuSeparator = document.getElementById(
+ "viewMessagesMenuSeparator"
+ );
+ if (viewMessagesMenuSeparator) {
+ viewMessagesMenuSeparator.setAttribute("hidden", "true");
+ }
+
+ var openMessageMenu = document.getElementById("openMessageWindowMenuitem");
+ if (openMessageMenu) {
+ openMessageMenu.setAttribute("hidden", "true");
+ }
+
+ openMessageMenu = document.getElementById(
+ "appmenu_openMessageWindowMenuitem"
+ );
+ if (openMessageMenu) {
+ openMessageMenu.setAttribute("hidden", "true");
+ }
+
+ var viewSortMenuSeparator = document.getElementById("viewSortMenuSeparator");
+ if (viewSortMenuSeparator) {
+ viewSortMenuSeparator.setAttribute("hidden", "true");
+ }
+
+ viewSortMenuSeparator = document.getElementById(
+ "appmenu_viewAfterThreadsSeparator"
+ );
+ if (viewSortMenuSeparator) {
+ viewSortMenuSeparator.setAttribute("hidden", "true");
+ }
+
+ var viewSortMenu = document.getElementById("viewSortMenu");
+ if (viewSortMenu) {
+ viewSortMenu.setAttribute("hidden", "true");
+ }
+
+ var emptryTrashMenu = document.getElementById("menu_emptyTrash");
+ if (emptryTrashMenu) {
+ emptryTrashMenu.setAttribute("hidden", "true");
+ }
+
+ emptryTrashMenu = document.getElementById("appmenu_emptyTrash");
+ if (emptryTrashMenu) {
+ emptryTrashMenu.setAttribute("hidden", "true");
+ }
+
+ var menuPropertiesSeparator = document.getElementById(
+ "editPropertiesSeparator"
+ );
+ if (menuPropertiesSeparator) {
+ menuPropertiesSeparator.setAttribute("hidden", "true");
+ }
+
+ menuPropertiesSeparator = document.getElementById(
+ "appmenu_editPropertiesSeparator"
+ );
+ if (menuPropertiesSeparator) {
+ menuPropertiesSeparator.setAttribute("hidden", "true");
+ }
+
+ var menuProperties = document.getElementById("menu_properties");
+ if (menuProperties) {
+ menuProperties.setAttribute("hidden", "true");
+ }
+
+ menuProperties = document.getElementById("appmenu_properties");
+ if (menuProperties) {
+ menuProperties.setAttribute("hidden", "true");
+ }
+
+ var favoriteFolder = document.getElementById("menu_favoriteFolder");
+ if (favoriteFolder) {
+ favoriteFolder.setAttribute("disabled", "true");
+ favoriteFolder.setAttribute("hidden", "true");
+ }
+
+ favoriteFolder = document.getElementById("appmenu_favoriteFolder");
+ if (favoriteFolder) {
+ favoriteFolder.setAttribute("disabled", "true");
+ favoriteFolder.setAttribute("hidden", "true");
+ }
+
+ var compactFolderMenu = document.getElementById("menu_compactFolder");
+ if (compactFolderMenu) {
+ compactFolderMenu.setAttribute("hidden", "true");
+ }
+
+ let trashSeparator = document.getElementById("trashMenuSeparator");
+ if (trashSeparator) {
+ trashSeparator.setAttribute("hidden", "true");
+ }
+
+ let goStartPageSeparator = document.getElementById("goNextSeparator");
+ if (goStartPageSeparator) {
+ goStartPageSeparator.hidden = true;
+ }
+
+ let goRecentlyClosedTabsSeparator = document.getElementById(
+ "goRecentlyClosedTabsSeparator"
+ );
+ if (goRecentlyClosedTabsSeparator) {
+ goRecentlyClosedTabsSeparator.setAttribute("hidden", "true");
+ }
+
+ let goFolder = document.getElementById("goFolderMenu");
+ if (goFolder) {
+ goFolder.hidden = true;
+ }
+
+ goFolder = document.getElementById("goFolderSeparator");
+ if (goFolder) {
+ goFolder.hidden = true;
+ }
+
+ let goStartPage = document.getElementById("goStartPage");
+ if (goStartPage) {
+ goStartPage.hidden = true;
+ }
+
+ let quickFilterBar = document.getElementById("appmenu_quickFilterBar");
+ if (quickFilterBar) {
+ quickFilterBar.hidden = true;
+ }
+
+ var menuFileClose = document.getElementById("menu_close");
+ var menuFileQuit = document.getElementById("menu_FileQuitItem");
+ if (menuFileClose && menuFileQuit) {
+ menuFileQuit.parentNode.replaceChild(menuFileClose, menuFileQuit);
+ }
+}
+/* eslint-enable complexity */
+
+function OnUnloadMessageWindow() {
+ UnloadCommandUpdateHandlers();
+ ToolbarIconColor.uninit();
+ PanelUI.uninit();
+ OnMailWindowUnload();
+}
+
+// MessageWindowController object (handles commands when one of the trees does not have focus)
+var MessageWindowController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_undo":
+ case "cmd_redo":
+ case "cmd_getMsgsForAuthAccounts":
+ case "cmd_newMessage":
+ case "cmd_getNextNMessages":
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ case "cmd_reload":
+ case "cmd_getNewMessages":
+ case "cmd_settingsOffline":
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_viewAllHeader":
+ case "cmd_viewNormalHeader":
+ case "cmd_stop":
+ case "cmd_chat":
+ return true;
+ case "cmd_synchronizeOffline":
+ return MailOfflineMgr.isOnline();
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(command) {
+ switch (command) {
+ case "cmd_newMessage":
+ return MailServices.accounts.allIdentities.length > 0;
+ case "cmd_reload":
+ case "cmd_find":
+ case "cmd_stop":
+ return false;
+ case "cmd_getNewMessages":
+ case "cmd_getMsgsForAuthAccounts":
+ return IsGetNewMessagesEnabled();
+ case "cmd_getNextNMessages":
+ return IsGetNextNMessagesEnabled();
+ case "cmd_synchronizeOffline":
+ return MailOfflineMgr.isOnline();
+ case "cmd_settingsOffline":
+ return IsAccountOfflineEnabled();
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_viewAllHeader":
+ case "cmd_viewNormalHeader":
+ return true;
+ case "cmd_undo":
+ case "cmd_redo":
+ return SetupUndoRedoCommand(command);
+ case "cmd_chat":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ doCommand(command) {
+ // If the user invoked a key short cut then it is possible that we got here
+ // for a command which is really disabled. Kick out if the command should be disabled.
+ if (!this.isCommandEnabled(command)) {
+ return;
+ }
+
+ switch (command) {
+ case "cmd_getNewMessages":
+ MsgGetMessage();
+ break;
+ case "cmd_undo":
+ messenger.undo(msgWindow);
+ break;
+ case "cmd_redo":
+ messenger.redo(msgWindow);
+ break;
+ case "cmd_getMsgsForAuthAccounts":
+ MsgGetMessagesForAllAuthenticatedAccounts();
+ break;
+ case "cmd_getNextNMessages":
+ MsgGetNextNMessages();
+ break;
+ case "cmd_newMessage":
+ MsgNewMessage(null);
+ break;
+ case "cmd_reload":
+ ReloadMessage();
+ break;
+ case "cmd_find":
+ document.getElementById("FindToolbar").onFindCommand();
+ break;
+ case "cmd_findAgain":
+ document.getElementById("FindToolbar").onFindAgainCommand(false);
+ break;
+ case "cmd_findPrevious":
+ document.getElementById("FindToolbar").onFindAgainCommand(true);
+ break;
+ case "cmd_viewAllHeader":
+ MsgViewAllHeaders();
+ return;
+ case "cmd_viewNormalHeader":
+ MsgViewNormalHeaders();
+ return;
+ case "cmd_synchronizeOffline":
+ MsgSynchronizeOffline();
+ return;
+ case "cmd_settingsOffline":
+ MailOfflineMgr.openOfflineAccountSettings();
+ return;
+ case "cmd_fullZoomReduce":
+ ZoomManager.reduce();
+ break;
+ case "cmd_fullZoomEnlarge":
+ ZoomManager.enlarge();
+ break;
+ case "cmd_fullZoomReset":
+ ZoomManager.reset();
+ break;
+ case "cmd_fullZoomToggle":
+ ZoomManager.toggleZoom();
+ break;
+ case "cmd_stop":
+ msgWindow.StopUrls();
+ break;
+ case "cmd_chat":
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (win) {
+ win.focus();
+ win.showChatTab();
+ } else {
+ window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar",
+ null,
+ { tabType: "chat", tabParams: {} }
+ );
+ }
+ break;
+ }
+ },
+
+ onEvent(event) {},
+};
+
+function SetupCommandUpdateHandlers() {
+ top.controllers.insertControllerAt(0, MessageWindowController);
+ top.controllers.insertControllerAt(
+ 0,
+ messageBrowser.contentWindow.commandController
+ );
+}
+
+function UnloadCommandUpdateHandlers() {
+ top.controllers.removeController(MessageWindowController);
+ top.controllers.removeController(
+ messageBrowser.contentWindow.commandController
+ );
+}
+
+/**
+ * Message history popup implementation from mail-go-button ported for the old
+ * mail toolbar.
+ *
+ * @param {XULPopupElement} popup
+ */
+function messageHistoryMenu_init(popup) {
+ const { messageHistory } = messageBrowser.contentWindow;
+ const { entries, currentIndex } = messageHistory.getHistory();
+
+ // For populating the back menu, we want the most recently visited
+ // messages first in the menu. So we go backward from curPos to 0.
+ // For the forward menu, we want to go forward from curPos to the end.
+ const items = [];
+ const relativePositionBase = entries.length - 1 - currentIndex;
+ for (const [index, entry] of entries.reverse().entries()) {
+ const folder = MailServices.folderLookup.getFolderForURL(entry.folderURI);
+ if (!folder) {
+ // Where did the folder go?
+ continue;
+ }
+
+ let menuText = "";
+ let msgHdr;
+ try {
+ msgHdr = MailServices.messageServiceFromURI(
+ entry.messageURI
+ ).messageURIToMsgHdr(entry.messageURI);
+ } catch (ex) {
+ // Let's just ignore this history entry.
+ continue;
+ }
+ const messageSubject = msgHdr.mime2DecodedSubject;
+ const messageAuthor = msgHdr.mime2DecodedAuthor;
+
+ if (!messageAuthor && !messageSubject) {
+ // Avoid empty entries in the menu. The message was most likely (re)moved.
+ continue;
+ }
+
+ // If the message was not being displayed via the current folder, prepend
+ // the folder name. We do not need to check underlying folders for
+ // virtual folders because 'folder' is the display folder, not the
+ // underlying one.
+ if (folder != messageBrowser.contentWindow.gFolder) {
+ menuText = folder.prettyName + " - ";
+ }
+
+ let subject = "";
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ subject = "Re: ";
+ }
+ if (messageSubject) {
+ subject += messageSubject;
+ }
+ if (subject) {
+ menuText += subject + " - ";
+ }
+
+ menuText += messageAuthor;
+ const newMenuItem = document.createXULElement("menuitem");
+ newMenuItem.setAttribute("label", menuText);
+ const relativePosition = relativePositionBase - index;
+ newMenuItem.setAttribute("value", relativePosition);
+ newMenuItem.addEventListener("command", commandEvent => {
+ navigateToUri(commandEvent.target);
+ commandEvent.stopPropagation();
+ });
+ if (relativePosition === 0 && !messageHistory.canPop(0)) {
+ newMenuItem.setAttribute("checked", true);
+ newMenuItem.setAttribute("type", "radio");
+ }
+ items.push(newMenuItem);
+ }
+ popup.replaceChildren(...items);
+}
+
+/**
+ * Select the message in the appropriate folder for the history popup entry.
+ * Finds the message based on the value of the item, which is the relative
+ * index of the item in the message history.
+ *
+ * @param {Element} target
+ */
+function navigateToUri(target) {
+ const nsMsgViewIndex_None = 0xffffffff;
+ const historyIndex = Number.parseInt(target.getAttribute("value"), 10);
+ const currentWindow = messageBrowser.contentWindow;
+ const { messageHistory } = currentWindow;
+ if (!messageHistory || !messageHistory.canPop(historyIndex)) {
+ return;
+ }
+ const item = messageHistory.pop(historyIndex);
+
+ if (
+ currentWindow.displayFolder &&
+ currentWindow.gFolder?.URI !== item.folderURI
+ ) {
+ const folder = MailServices.folderLookup.getFolderForURL(item.folderURI);
+ currentWindow.displayFolder(folder);
+ }
+ const msgHdr = MailServices.messageServiceFromURI(
+ item.messageURI
+ ).messageURIToMsgHdr(item.messageURI);
+ const index = currentWindow.gDBView.findIndexOfMsgHdr(msgHdr, true);
+ if (index != nsMsgViewIndex_None) {
+ currentWindow.gViewWrapper.dbView.selection.select(index);
+ currentWindow.displayMessage(
+ currentWindow.gViewWrapper.dbView.URIForFirstSelectedMessage,
+ currentWindow.gViewWrapper
+ );
+ }
+}
+
+function backToolbarMenu_init(popup) {
+ messageHistoryMenu_init(popup);
+}
+
+function forwardToolbarMenu_init(popup) {
+ messageHistoryMenu_init(popup);
+}
+
+function GetSelectedMsgFolders() {
+ return [messageBrowser.contentWindow.gFolder];
+}
diff --git a/comm/mail/base/content/messageWindow.xhtml b/comm/mail/base/content/messageWindow.xhtml
new file mode 100644
index 0000000000..55b11f9c5d
--- /dev/null
+++ b/comm/mail/base/content/messageWindow.xhtml
@@ -0,0 +1,484 @@
+<?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/.
+
+#filter substitution
+
+<?xml-stylesheet href="chrome://messenger/skin/messageWindow.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/popupPanel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/panelUI.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-toolbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+
+<!DOCTYPE html [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+%brandDTD;
+<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd">
+%msgHdrViewOverlayDTD;
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%messengerDTD;
+<!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd">
+%customizeToolbarDTD;
+<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd">
+%utilityDTD;
+<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" >
+%msgViewPickerDTD;
+<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd">
+%baseMenuOverlayDTD;
+<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd">
+%utilityDTD;
+<!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd">
+%viewZoomOverlayDTD;
+<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" >
+%msgViewPickerDTD;
+<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd">
+%lightningDTD;
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" >
+%calendarDTD;
+<!ENTITY % calendarMenuOverlayDTD SYSTEM "chrome://calendar/locale/menuOverlay.dtd" >
+%calendarMenuOverlayDTD;
+<!ENTITY % toolbarDTD SYSTEM "chrome://lightning/locale/lightning-toolbar.dtd">
+%toolbarDTD;
+<!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" >
+%eventDialogDTD;
+<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd">
+%smimeDTD;
+]>
+
+<!--
+ - This window displays a single message.
+ -->
+<html id="messengerWindow" 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"
+ icon="messengerWindow"
+ scrolling="false"
+ titlemodifier="&titledefault.label;@PRE_RELEASE_SUFFIX@"
+ titlemenuseparator="&titleSeparator.label;"
+ persist="width height screenX screenY sizemode"
+ toggletoolbar="true"
+ windowtype="mail:messageWindow"
+#ifdef XP_MACOSX
+ macanimationtype="document"
+ chromemargin="0,-1,-1,-1"
+#endif
+ lightweightthemes="true"
+ fullscreenbutton="true">
+<head>
+ <title>&titledefault.label;@PRE_RELEASE_SUFFIX@</title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="toolkit/main-window/findbar.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="toolkit/printing/printUI.ftl" />
+ <link rel="localization" href="messenger/menubar.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" />
+
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messageWindow.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script>
+ <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindowOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script>
+ <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCore.js"></script>
+#ifdef NIGHTLY_BUILD
+ <script defer="defer" src="chrome://messenger/content/sync.js"></script>
+#endif
+ <script defer="defer" src="chrome://messenger/content/panelUI.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/toolbarbutton-badge-button.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script>
+#ifdef XP_MACOSX
+ <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script>
+#endif
+ <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-chrome-startup.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-management.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-extract.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <commandset id="mailCommands">
+#include mainCommandSet.inc.xhtml
+ <commandset id="mailSearchMenuItems"/>
+ <commandset id="attachmentCommands">
+ <command id="cmd_openAllAttachments"
+ oncommand="goDoCommand('cmd_openAllAttachments');"/>
+ <command id="cmd_saveAllAttachments"
+ oncommand="goDoCommand('cmd_saveAllAttachments');"/>
+ <command id="cmd_detachAllAttachments"
+ oncommand="goDoCommand('cmd_detachAllAttachments');"/>
+ <command id="cmd_deleteAllAttachments"
+ oncommand="goDoCommand('cmd_deleteAllAttachments');"/>
+ </commandset>
+ <commandset id="tasksCommands">
+ <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/>
+ <command id="cmd_newCard" oncommand="openNewCardDialog()"/>
+ </commandset>
+ <commandset id="commandKeys"/>
+ <command id="cmd_close" oncommand="window.close();"/>
+ </commandset>
+
+ <keyset id="mailKeys">
+ <key keycode="VK_ESCAPE" oncommand="window.close();"/>
+#include mainKeySet.inc.xhtml
+ <keyset id="tasksKeys">
+#ifdef XP_MACOSX
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel,shift"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+#else
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+#endif
+ </keyset>
+ </keyset>
+
+ <popupset id="mainPopupSet">
+#include widgets/browserPopups.inc.xhtml
+#include widgets/toolbarContext.inc.xhtml
+<!-- The panelUI is for the appmenu. -->
+#include ../../components/customizableui/content/panelUI.inc.xhtml
+ </popupset>
+
+ <toolbox id="mail-toolbox"
+ class="mail-toolbox"
+ mode="full"
+ defaultmode="full"
+#ifdef XP_MACOSX
+ iconsize="small"
+ defaulticonsize="small"
+#endif
+ labelalign="end"
+ defaultlabelalign="end">
+#ifdef XP_MACOSX
+ <hbox id="titlebar">
+ <hbox id="titlebar-title" align="center" flex="1">
+ <label id="titlebar-title-label" value="&titledefault.label;" flex="1" crop="end"/>
+ </hbox>
+#include messenger-titlebar-items.inc.xhtml
+ </hbox>
+#endif
+ <!-- Menu -->
+ <toolbar is="customizable-toolbar" id="toolbar-menubar"
+ class="chromeclass-menubar themeable-full"
+ type="menubar"
+ customizable="true"
+ toolboxid="mail-toolbox"
+#ifdef XP_MACOSX
+ defaultset="menubar-items"
+ autohide="true"
+#else
+ defaultset="menubar-items,spring"
+#endif
+#ifndef XP_MACOSX
+ data-l10n-id="toolbar-context-menu-menu-bar"
+ data-l10n-attrs="toolbarname"
+#endif
+ context="toolbar-context-menu"
+ mode="icons"
+ insertbefore="tabs-toolbar"
+ prependmenuitem="true">
+ <toolbaritem id="menubar-items" align="center">
+# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it
+# can be shared with other top level windows.
+#include messenger-menubar.inc.xhtml
+ </toolbaritem>
+ </toolbar>
+ <!-- mail-toolbox with the main toolbarbuttons -->
+ <toolbarpalette id="MailToolbarPalette">
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-getmsg"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&getMsgButton1.label;"
+ tooltiptext="&getMsgButton.tooltip;"
+ command="cmd_getNewMessages">
+ <menupopup is="folder-menupopup" id="button-getMsgPopup"
+ onpopupshowing="getMsgToolbarMenu_init();"
+ oncommand="MsgGetMessagesForAccount(event.target._folder); event.stopPropagation();"
+ expandFolders="false"
+ mode="getMail">
+ <menuitem id="button-getAllNewMsg"
+ class="menuitem-iconic folderMenuItem"
+ label="&getAllNewMsgCmd.label;"
+ accesskey="&getAllNewMsgCmd.accesskey;"
+ command="cmd_getMsgsForAuthAccounts"/>
+ <menuseparator id="button-getAllNewMsgSeparator"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-newmsg"
+ class="toolbarbutton-1"
+ label="&newMsgButton.label;"
+ tooltiptext="&newMsgButton.tooltip;"
+ command="cmd_newMessage"/>
+ <toolbarbutton id="button-file"
+ type="menu"
+ wantdropmarker="true"
+ class="toolbarbutton-1"
+ label="&fileButton.label;"
+ tooltiptext="&fileButton.tooltip;"
+ oncommand="goDoCommand('cmd_moveMessage', event.target._folder)">
+ <menupopup is="folder-menupopup" id="button-filePopup"
+ mode="filing"
+ showRecent="true"
+ showFileHereLabel="true"
+ recentLabel="&moveCopyMsgRecentMenu.label;"
+ recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"/>
+ </toolbarbutton>
+ <toolbarbutton id="button-showconversation"
+ class="toolbarbutton-1"
+ label="&openConversationButton.label;"
+ tooltiptext="&openMsgConversationButton.tooltip;"
+ command="cmd_openConversation"/>
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-goback"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&backButton1.label;"
+ command="cmd_goBack"
+ tooltiptext="&goBackButton.tooltip;">
+ <menupopup id="button-goBackPopup" onpopupshowing="backToolbarMenu_init(this)">
+ <menuitem id="button-goBack" label="&goBackCmd.label;" command="cmd_goBack"/>
+ <menuseparator id="button-goBackSeparator"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-goforward"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&goForwardButton1.label;"
+ command="cmd_goForward"
+ tooltiptext="&goForwardButton.tooltip;">
+ <menupopup id="button-goForwardPopup" onpopupshowing="forwardToolbarMenu_init(this)">
+ <menuitem id="button-goForward"
+ label="&goForwardCmd.label;"
+ command="cmd_goForward"/>
+ <menuseparator id="button-goForwardSeparator"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbaritem id="button-previous"
+ title="&previousButtonToolbarItem.label;"
+ align="center"
+ class="chromeclass-toolbar-additional">
+ <toolbarbutton id="button-previousUnread"
+ class="toolbarbutton-1"
+ label="&previousButton.label;"
+ command="cmd_previousUnreadMsg"
+ tooltiptext="&previousButton.tooltip;"/>
+ </toolbaritem>
+ <toolbarbutton id="button-previousMsg"
+ class="toolbarbutton-1"
+ label="&previousMsgButton.label;"
+ command="cmd_previousMsg"
+ tooltiptext="&previousMsgButton.tooltip;"/>
+ <toolbaritem id="button-next"
+ title="&nextButtonToolbarItem.label;"
+ align="center"
+ class="chromeclass-toolbar-additional">
+ <toolbarbutton id="button-nextUnread"
+ class="toolbarbutton-1"
+ label="&nextButton.label;"
+ command="cmd_nextUnreadMsg"
+ tooltiptext="&nextButton.tooltip;"/>
+ </toolbaritem>
+ <toolbarbutton id="button-nextMsg"
+ class="toolbarbutton-1"
+ label="&nextMsgButton.label;"
+ command="cmd_nextMsg"
+ tooltiptext="&nextMsgButton.tooltip;"/>
+ <toolbarbutton id="button-print"
+ class="toolbarbutton-1"
+ label="&printButton.label;"
+ command="cmd_print"
+ tooltiptext="&printButton.tooltip;"/>
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-mark"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&markButton.label;"
+ tooltiptext="&markButton.tooltip;">
+ <menupopup id="button-markPopup" onpopupshowing="InitMessageMark()">
+ <menuitem id="markReadToolbarItem"
+ label="&markAsReadCmd.label;"
+ accesskey="&markAsReadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsRead"/>
+ <menuitem id="markUnreadToolbarItem"
+ label="&markAsUnreadCmd.label;"
+ accesskey="&markAsUnreadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsUnread"/>
+ <menuitem id="button-markThreadAsRead"
+ label="&markThreadAsReadCmd.label;"
+ key="key_markThreadAsRead"
+ accesskey="&markThreadAsReadCmd.accesskey;"
+ command="cmd_markThreadAsRead"/>
+ <menuitem id="button-markReadByDate"
+ label="&markReadByDateCmd.label;"
+ key="key_markReadByDate"
+ accesskey="&markReadByDateCmd.accesskey;"
+ command="cmd_markReadByDate"/>
+ <menuitem id="button-markAllRead"
+ label="&markAllReadCmd.label;"
+ key="key_markAllRead"
+ accesskey="&markAllReadCmd.accesskey;"
+ command="cmd_markAllRead"/>
+ <menuseparator id="button-markAllReadSeparator"/>
+ <menuitem id="markFlaggedToolbarItem"
+ type="checkbox"
+ label="&markStarredCmd.label;"
+ accesskey="&markStarredCmd.accesskey;"
+ key="key_toggleFlagged"
+ command="cmd_markAsFlagged"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-tag"
+ type="menu"
+ wantdropmarker="true"
+ class="toolbarbutton-1"
+ label="&tagButton.label;"
+ tooltiptext="&tagButton.tooltip;"
+ command="cmd_tag">
+ <menupopup id="button-tagpopup"
+ onpopupshowing="InitMessageTags(this);">
+ <menuitem id="button-addNewTag"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"
+ command="cmd_addTag"/>
+ <menuitem id="button-manageTags"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"
+ command="cmd_manageTags"/>
+ <menuseparator id="button-tagpopup-sep-afterTagAddNew"/>
+ <menuitem id="button-tagRemoveAll"
+ command="cmd_removeTags"/>
+ <menuseparator id="button-afterTagRemoveAllSeparator"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-address"
+ class="toolbarbutton-1"
+ label="&addressBookButton.label;"
+ oncommand="toAddressBook();"
+ tooltiptext="&addressBookButton.tooltip;"/>
+ <toolbarbutton is="toolbarbutton-badge-button" id="button-chat"
+ image="chrome://messenger/skin/icons/new/compact/chat.svg"
+ class="toolbarbutton-1"
+ label="&chatButton.label;"
+ command="cmd_chat"
+ observes="cmd_chat"
+ tooltiptext="&chatButton.tooltip;"/>
+ <toolbaritem id="throbber-box" title="&throbberItem.title;">
+ <!-- NOTE: We only display up to one of these images at any given time.
+ - Only show the static icon when customizing the toolbar.
+ - Only show the animated icon when we are not customizing the toolbar
+ - and there is some activity.
+ - Once loading animation is handled by CSS, we can use a single image
+ - here instead. -->
+ <html:img class="animated-throbber-icon"
+ src="chrome://global/skin/icons/loading.png"
+ srcset="chrome://global/skin/icons/loading@2x.png 2x"
+ alt="" />
+ <html:img class="static-throbber-icon"
+ src="chrome://messenger/skin/icons/notloading.png"
+ srcset="chrome://messenger/skin/icons/notloading@2x.png 2x"
+ alt="" />
+ </toolbaritem>
+
+ <toolbarbutton id="button-addons" class="toolbarbutton-1"
+ data-l10n-id="addons-and-themes-toolbarbutton"
+ oncommand="openAddonsMgr();"/>
+
+ <toolbarbutton id="lightning-button-calendar"
+ class="toolbarbutton-1"
+ label="&lightning.toolbar.calendar.label;"
+ tooltiptext="&lightning.toolbar.calendar.tooltip;"
+ command="new_calendar_tab"/>
+ <toolbarbutton id="lightning-button-tasks"
+ class="toolbarbutton-1"
+ label="&lightning.toolbar.task.label;"
+ tooltiptext="&lightning.toolbar.task.tooltip;"
+ command="new_task_tab"/>
+ <toolbarbutton is="toolbarbutton-menu-button" id="extractEventButton"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&calendar.extract.event.button;"
+ tooltiptext="&calendar.extract.event.button.tooltip;"
+ oncommand="calendarExtract.extractFromEmail(document.getElementById('messageBrowser').contentWindow.gMessage, true);">
+ <menupopup id="extractEventLocaleList"
+ oncommand="calendarExtract.extractWithLocale(event, true);"
+ onpopupshowing="calendarExtract.onShowLocaleMenu(event.target);"/>
+ </toolbarbutton>
+ <toolbarbutton is="toolbarbutton-menu-button" id="extractTaskButton"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&calendar.extract.task.button;"
+ tooltiptext="&calendar.extract.task.button.tooltip;"
+ oncommand="calendarExtract.extractFromEmail(document.getElementById('messageBrowser').contentWindow.gMessage, false);">
+ <menupopup id="extractTaskLocaleList"
+ oncommand="calendarExtract.extractWithLocale(event, false);"
+ onpopupshowing="calendarExtract.onShowLocaleMenu(event.target);"/>
+ </toolbarbutton>
+ </toolbarpalette>
+
+ <!-- If changes are made to the default set of toolbar buttons, you may need to rev the id
+ of mail-bar in order to force the new default items to show up for users who customized their toolbar
+ in earlier versions. Bumping the id means users will have to re-customize their toolbar!
+ -->
+
+ <toolbar is="customizable-toolbar" id="mail-bar3"
+ class="inline-toolbar chromeclass-toolbar themeable-full"
+ toolbarname="&showMessengerToolbarCmd.label;"
+ accesskey="&showMessengerToolbarCmd.accesskey;"
+ fullscreentoolbar="true" mode="full"
+ customizable="true"
+ context="toolbar-context-menu"
+#ifdef XP_MACOSX
+ iconsize="small"
+ defaultset="button-getmsg,button-newmsg,spacer,button-tag,qfb-show-filter-bar,spring">
+#else
+ defaultset="button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring">
+#endif
+ </toolbar>
+ </toolbox>
+
+ <stack flex="1" class="printPreviewStack">
+ <browser id="messageBrowser"
+ flex="1"
+ src="about:message"
+ autocompletepopup="PopupAutoComplete"
+ messagemanagergroup="single-page"/>
+ </stack>
+
+ <panel id="customizeToolbarSheetPopup" noautohide="true">
+ <iframe id="customizeToolbarSheetIFrame"
+ style="&dialog.dimensions;"
+ hidden="true"/>
+ </panel>
+
+ <hbox id="status-bar" class="statusbar chromeclass-status" role="status">
+#include mainStatusbar.inc.xhtml
+ </hbox>
+
+#include tabDialogs.inc.xhtml
+</html:body>
+</html>
diff --git a/comm/mail/base/content/messenger-customization.js b/comm/mail/base/content/messenger-customization.js
new file mode 100644
index 0000000000..bf6fc46834
--- /dev/null
+++ b/comm/mail/base/content/messenger-customization.js
@@ -0,0 +1,185 @@
+/**
+ * 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 { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var AutoHideMenubar = {
+ get _node() {
+ delete this._node;
+ return (this._node =
+ document.getElementById("toolbar-menubar") ||
+ document.getElementById("compose-toolbar-menubar2") ||
+ document.getElementById("addrbook-toolbar-menubar2"));
+ },
+
+ _contextMenuListener: {
+ contextMenu: null,
+
+ get active() {
+ return !!this.contextMenu;
+ },
+
+ init(event) {
+ // Ignore mousedowns in <menupopup>s.
+ if (event.target.closest("menupopup")) {
+ return;
+ }
+
+ let contextMenuId = AutoHideMenubar._node.getAttribute("context");
+ this.contextMenu = document.getElementById(contextMenuId);
+ this.contextMenu.addEventListener("popupshown", this);
+ this.contextMenu.addEventListener("popuphiding", this);
+ AutoHideMenubar._node.addEventListener("mousemove", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshown":
+ AutoHideMenubar._node.removeEventListener("mousemove", this);
+ break;
+ case "popuphiding":
+ case "mousemove":
+ AutoHideMenubar._setInactiveAsync();
+ AutoHideMenubar._node.removeEventListener("mousemove", this);
+ this.contextMenu.removeEventListener("popuphiding", this);
+ this.contextMenu.removeEventListener("popupshown", this);
+ this.contextMenu = null;
+ break;
+ }
+ },
+ },
+
+ init() {
+ this._node.addEventListener("toolbarvisibilitychange", this);
+ this._enable();
+ },
+
+ _updateState() {
+ if (this._node.getAttribute("autohide") == "true") {
+ this._enable();
+ } else {
+ this._disable();
+ }
+ },
+
+ _events: [
+ "DOMMenuBarInactive",
+ "DOMMenuBarActive",
+ "popupshowing",
+ "mousedown",
+ ],
+ _enable() {
+ this._node.setAttribute("inactive", "true");
+ for (let event of this._events) {
+ this._node.addEventListener(event, this);
+ }
+ },
+
+ _disable() {
+ this._setActive();
+ for (let event of this._events) {
+ this._node.removeEventListener(event, this);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "toolbarvisibilitychange":
+ this._updateState();
+ break;
+ case "popupshowing":
+ // fall through
+ case "DOMMenuBarActive":
+ this._setActive();
+ break;
+ case "mousedown":
+ if (event.button == 2) {
+ this._contextMenuListener.init(event);
+ }
+ break;
+ case "DOMMenuBarInactive":
+ if (!this._contextMenuListener.active) {
+ this._setInactiveAsync();
+ }
+ break;
+ }
+ },
+
+ _setInactiveAsync() {
+ this._inactiveTimeout = setTimeout(() => {
+ if (this._node.getAttribute("autohide") == "true") {
+ this._inactiveTimeout = null;
+ this._node.setAttribute("inactive", "true");
+ }
+ }, 0);
+ },
+
+ _setActive() {
+ if (this._inactiveTimeout) {
+ clearTimeout(this._inactiveTimeout);
+ this._inactiveTimeout = null;
+ }
+ this._node.removeAttribute("inactive");
+ },
+};
+
+var ToolbarContextMenu = {
+ _getExtensionId(popup) {
+ let node = popup.triggerNode;
+ if (!node) {
+ return null;
+ }
+ if (node.hasAttribute("data-extensionid")) {
+ return node.getAttribute("data-extensionid");
+ }
+ const extensionButton = node.closest('[item-id^="ext-"]');
+ return extensionButton?.getAttribute("item-id").slice(4);
+ },
+
+ async updateExtension(popup) {
+ let removeExtension = popup.querySelector(
+ ".customize-context-removeExtension"
+ );
+ let manageExtension = popup.querySelector(
+ ".customize-context-manageExtension"
+ );
+ let separator = popup.querySelector("#extensionsMailToolbarMenuSeparator");
+ let id = this._getExtensionId(popup);
+ let addon = id && (await AddonManager.getAddonByID(id));
+
+ for (let element of [removeExtension, manageExtension, separator]) {
+ if (!element) {
+ continue;
+ }
+
+ element.hidden = !addon;
+ }
+
+ if (addon) {
+ removeExtension.disabled = !(
+ addon.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+ }
+ },
+
+ async removeExtensionForContextAction(popup) {
+ let id = this._getExtensionId(popup);
+
+ // This can be called from a composeAction button, where
+ // popup.ownerGlobal.BrowserAddonUI is undefined.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ await win.BrowserAddonUI.removeAddon(id);
+ },
+
+ openAboutAddonsForContextAction(popup) {
+ let id = this._getExtensionId(popup);
+ if (id) {
+ let viewID = "addons://detail/" + encodeURIComponent(id);
+ popup.ownerGlobal.openAddonsMgr(viewID);
+ }
+ },
+};
diff --git a/comm/mail/base/content/messenger-doctype.inc.dtd b/comm/mail/base/content/messenger-doctype.inc.dtd
new file mode 100644
index 0000000000..0d42d56feb
--- /dev/null
+++ b/comm/mail/base/content/messenger-doctype.inc.dtd
@@ -0,0 +1,42 @@
+# 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/.
+
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+%brandDTD;
+<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd">
+%msgHdrViewOverlayDTD;
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%messengerDTD;
+<!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd">
+%chatDTD;
+<!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd">
+%customizeToolbarDTD;
+<!ENTITY % tabMailDTD SYSTEM "chrome://messenger/locale/tabmail.dtd" >
+%tabMailDTD;
+<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd">
+%utilityDTD;
+<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" >
+%msgViewPickerDTD;
+<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd">
+%baseMenuOverlayDTD;
+<!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd">
+%viewZoomOverlayDTD;
+<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" >
+%msgViewPickerDTD;
+<!ENTITY % calendarGlobalDTD SYSTEM "chrome://calendar/locale/global.dtd">
+%calendarGlobalDTD;
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd">
+%calendarDTD;
+<!ENTITY % calendarMenuOverlayDTD SYSTEM "chrome://calendar/locale/menuOverlay.dtd" >
+%calendarMenuOverlayDTD;
+<!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd">
+%eventDialogDTD;
+<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd">
+%lightningDTD;
+<!ENTITY % lightningToolbarDTD SYSTEM "chrome://lightning/locale/lightning-toolbar.dtd" >
+%lightningToolbarDTD;
+<!ENTITY % mailOverlayDTD SYSTEM "chrome://messenger/locale/mailOverlay.dtd">
+%mailOverlayDTD;
+<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd">
+%smimeDTD;
diff --git a/comm/mail/base/content/messenger-menubar.inc.xhtml b/comm/mail/base/content/messenger-menubar.inc.xhtml
new file mode 100644
index 0000000000..1a0859998e
--- /dev/null
+++ b/comm/mail/base/content/messenger-menubar.inc.xhtml
@@ -0,0 +1,1271 @@
+# 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/.
+
+<menubar id="mail-menubar">
+ <!-- File -->
+ <menu id="menu_File"
+ label="&fileMenu.label;"
+ accesskey="&fileMenu.accesskey;">
+ <menupopup id="menu_FilePopup" onpopupshowing="file_init();">
+ <menu id="menu_New"
+ label="&newMenu.label;"
+ accesskey="&newMenu.accesskey;">
+ <menupopup id="menu_NewPopup" onpopupshowing="menu_new_init();">
+ <menuitem id="menu_newNewMsgCmd" label="&newNewMsgCmd.label;"
+ accesskey="&newNewMsgCmd.accesskey;"
+ key="key_newMessage2"
+ command="cmd_newMessage"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-new-event-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&lightning.menupopup.new.event.label;"
+ accesskey="&lightning.menupopup.new.event.accesskey;"
+ key="calendar-new-event-key"
+ command="calendar_new_event_command"/>
+ <menuitem id="calendar-new-task-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&lightning.menupopup.new.task.label;"
+ accesskey="&lightning.menupopup.new.task.accesskey;"
+ key="calendar-new-todo-key"
+ command="calendar_new_todo_command"/>
+ <menuseparator id="calendar-after-new-task-menuseparator"
+ class="hide-when-calendar-deactivated"
+ observes="menu_newFolder"/>
+#endif
+ <menuitem id="menu_newFolder" label="&newFolderCmd.label;"
+ command="cmd_newFolder"
+ accesskey="&newFolderCmd.accesskey;"/>
+ <menuitem id="menu_newVirtualFolder" label="&newVirtualFolderCmd.label;"
+ command="cmd_newVirtualFolder"
+ accesskey="&newVirtualFolderCmd.accesskey;"/>
+ <menuseparator id="newAccountPopupMenuSeparator"/>
+ <menuitem id="newCreateEmailAccountMenuItem"
+ label="&newCreateEmailAccountCmd.label;"
+ accesskey="&newCreateEmailAccountCmd.accesskey;"
+ oncommand="openAccountProvisionerTab();"/>
+ <menuitem id="newMailAccountMenuItem"
+ label="&newExistingEmailAccountCmd.label;"
+ accesskey="&newExistingEmailAccountCmd.accesskey;"
+ oncommand="openAccountSetupTab();"/>
+ <menuitem id="newIMAccountMenuItem"
+ label="&newIMAccountCmd.label;"
+ accesskey="&newIMAccountCmd.accesskey;"
+ oncommand="openIMAccountWizard();"/>
+ <menuitem id="newFeedAccountMenuItem"
+ label="&newFeedAccountCmd.label;"
+ accesskey="&newFeedAccountCmd.accesskey;"
+ oncommand="AddFeedAccount();"/>
+ <menuitem id="newNewsgroupAccountMenuItem"
+ data-l10n-id="file-new-newsgroup-account"
+ oncommand="openNewsgroupAccountWizard();"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-new-calendar-menuitem"
+ label="&lightning.menupopup.new.calendar.label;"
+ command="calendar_new_calendar_command"
+ accesskey="&lightning.menupopup.new.calendar.accesskey;"/>
+#endif
+ <menuseparator id="newPopupMenuSeparator"/>
+ <menuitem id="menu_newCard"
+ label="&newContactCmd.label;"
+ accesskey="&newContactCmd.accesskey;"
+ command="cmd_newCard"/>
+ <menuitem id="newIMContactMenuItem"
+ label="&newIMContactCmd.label;"
+ accesskey="&newIMContactCmd.accesskey;"
+ command="cmd_addChatBuddy"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_Open"
+ mode="calendar"
+ label="&openMenuCmd.label;"
+ accesskey="&openMenuCmd.accesskey;">
+ <menupopup id="menu_OpenPopup">
+ <menuitem id="openMessageFileMenuitem"
+ label="&openMessageFileCmd.label;"
+ accesskey="&openMessageFileCmd.accesskey;"
+ oncommand="MsgOpenFromFile();"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-open-calendar-file-menuitem"
+ label="&lightning.menupopup.open.calendar.label;"
+ accesskey="&lightning.menupopup.open.calendar.accesskey;"
+ oncommand="openLocalCalendar();"/>
+#endif
+ </menupopup>
+ </menu>
+ <menuitem id="menu_close"
+ label="&closeCmd.label;"
+ key="key_close"
+ accesskey="&closeCmd.accesskey;"
+ command="cmd_close"/>
+ <menuseparator id="fileMenuAfterCloseSeparator"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-save-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&event.menu.item.save.label;"
+ accesskey="&event.menu.item.save.tab.accesskey;"
+ key="save-key"
+ command="cmd_save"/>
+ <menuitem id="calendar-save-and-close-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&event.menu.item.saveandclose.label;"
+ accesskey="&event.menu.item.saveandclose.tab.accesskey;"
+ command="cmd_accept"/>
+#endif
+ <menu id="menu_saveAs"
+ label="&saveAsMenu.label;" accesskey="&saveAsMenu.accesskey;">
+ <menupopup id="menu_SavePopup">
+ <menuitem id="menu_saveAsFile"
+ data-l10n-id="menu-file-save-as-file"
+ key="key_saveAsFile"
+ command="cmd_saveAsFile"/>
+ <menuitem id="menu_saveAsTemplate"
+ label="&saveAsTemplateCmd.label;"
+ accesskey="&saveAsTemplateCmd.accesskey;"
+ command="cmd_saveAsTemplate"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="fileMenuAfterSaveSeparator"/>
+ <menu label="&getNewMsgForCmd.label;" accesskey="&getNewMsgForCmd.accesskey;"
+ id="menu_getAllNewMsg"
+ oncommand="MsgGetMessagesForAccount();">
+ <menupopup is="folder-menupopup" id="menu_getAllNewMsgPopup"
+ expandFolders="false"
+ oncommand="MsgGetMessagesForAccount(event.target._folder); event.stopPropagation();">
+ <menuitem id="menu_getnewmsgs_all_accounts"
+ label="&getAllNewMsgCmdPopupMenu.label;"
+ accesskey="&getAllNewMsgCmdPopupMenu.accesskey;"
+ key="key_getAllNewMessages"
+ command="cmd_getMsgsForAuthAccounts"/>
+ <menuitem id="menu_getnewmsgs_current_account"
+ label="&getNewMsgCurrentAccountCmdPopupMenu.label;"
+ accesskey="&getNewMsgCurrentAccountCmdPopupMenu.accesskey;"
+ key="key_getNewMessages"
+ command="cmd_getNewMessages"/>
+ <menuseparator/>
+ </menupopup>
+ </menu>
+ <menuitem id="menu_getnextnmsg" label="&getNextNMsgCmd2.label;"
+ accesskey="&getNextNMsgCmd2.accesskey;"
+ command="cmd_getNextNMessages"/>
+ <menuitem id="menu_sendunsentmsgs" label="&sendUnsentCmd.label;"
+ accesskey="&sendUnsentCmd.accesskey;" command="cmd_sendUnsentMsgs"/>
+ <menuitem id="menu_subscribe" label="&subscribeCmd.label;"
+ accesskey="&subscribeCmd.accesskey;" command="cmd_subscribe"/>
+ <menuseparator id="fileMenuAfterSubscribeSeparator"/>
+ <menuitem id="menu_deleteFolder"
+ data-l10n-id="menu-edit-delete-folder"
+ command="cmd_deleteFolder"/>
+ <menuitem id="menu_renameFolder" label="&renameFolder.label;"
+ accesskey="&renameFolder.accesskey;"
+#ifndef XP_MACOSX
+ key="key_renameFolder"
+#endif
+ command="cmd_renameFolder"/>
+ <menuitem id="menu_compactFolder"
+ label="&compactFolders.label;"
+ accesskey="&compactFolders.accesskey;"
+ command="cmd_compactFolder"/>
+ <menuitem id="menu_emptyTrash" label="&emptyTrashCmd.label;"
+ accesskey="&emptyTrashCmd.accesskey;"
+ command="cmd_emptyTrash"/>
+ <menuseparator id="trashMenuSeparator"/>
+ <menu id="offlineMenuItem" label="&offlineMenu.label;" accesskey="&offlineMenu.accesskey;">
+ <menupopup id="menu_OfflinePopup">
+ <menuitem id="goOfflineMenuItem" type="checkbox" label="&offlineGoOfflineCmd.label;"
+ accesskey="&offlineGoOfflineCmd.accesskey;" oncommand="MailOfflineMgr.toggleOfflineStatus();"/>
+ <menuseparator id="offlineMenuAfterGoSeparator"/>
+ <menuitem id="menu_synchronizeOffline"
+ label="&synchronizeOfflineCmd.label;"
+ accesskey="&synchronizeOfflineCmd.accesskey;"
+ command="cmd_synchronizeOffline"/>
+ <menuitem id="menu_settingsOffline"
+ label="&settingsOfflineCmd2.label;"
+ accesskey="&settingsOfflineCmd2.accesskey;"
+ command="cmd_settingsOffline"/>
+ <menuseparator id="offlineMenuAfterSettingsSeparator"/>
+ <menuitem id="menu_downloadFlagged"
+ label="&downloadStarredCmd.label;"
+ accesskey="&downloadStarredCmd.accesskey;"
+ command="cmd_downloadFlagged"/>
+ <menuitem id="menu_downloadSelected"
+ label="&downloadSelectedCmd.label;"
+ accesskey="&downloadSelectedCmd.accesskey;"
+ command="cmd_downloadSelected"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="fileMenuAfterOfflineSeparator"/>
+ <menuitem id="printMenuItem"
+ key="key_print"
+ label="&printCmd.label;"
+ accesskey="&printCmd.accesskey;"
+ command="cmd_print"/>
+ <menuseparator id="menu_FileQuitSeparator"/>
+ <menuitem id="menu_FileQuitItem"
+#ifdef XP_MACOSX
+ data-l10n-id="menu-quit-mac"
+#else
+ data-l10n-id="menu-quit"
+#endif
+ key="key_quitApplication"
+ command="cmd_quitApplication"/>
+ </menupopup>
+ </menu>
+
+<!-- Edit -->
+<menu id="menu_Edit"
+ label="&editMenu.label;"
+ accesskey="&editMenu.accesskey;"
+ oncommand="CommandUpdate_UndoRedo();">
+ <menupopup id="menu_EditPopup" onpopupshowing="InitEditMessagesMenu()">
+ <menuitem id="menu_undo"
+ label="&undoDefaultCmd.label;"
+ accesskey="&undoDefaultCmd.accesskey;"
+ key="key_undo"
+ command="cmd_undo"/>
+ <menuitem id="menu_redo"
+ label="&redoDefaultCmd.label;"
+ accesskey="&redoDefaultCmd.accesskey;"
+ key="key_redo"
+ command="cmd_redo"/>
+ <menuseparator id="editMenuAfterRedoSeparator"/>
+ <menuitem id="menu_cut"
+ data-l10n-id="text-action-cut"
+ key="key_cut"
+ command="cmd_cut"/>
+ <menuitem id="menu_copy"
+ data-l10n-id="text-action-copy"
+ key="key_copy"
+ command="cmd_copy"/>
+ <menuitem id="menu_paste"
+ data-l10n-id="text-action-paste"
+ key="key_paste"
+ command="cmd_paste"/>
+ <menuitem id="menu_delete"
+ key="key_delete"
+ command="cmd_delete"/>
+ <menuseparator id="editMenuAfterDeleteSeparator"/>
+ <menu id="menu_select" label="&selectMenu.label;" accesskey="&selectMenu.accesskey;">
+ <menupopup id="menu_SelectPopup">
+ <menuitem id="menu_SelectAll" label="&all.label;"
+ accesskey="&all.accesskey;" key="key_selectAll"
+ command="cmd_selectAll"/>
+ <menuseparator id="selectMenuSeparator"/>
+ <menuitem id="menu_selectThread" label="&selectThreadCmd.label;"
+ accesskey="&selectThreadCmd.accesskey;"
+ key="key_selectThread"
+ command="cmd_selectThread"/>
+ <menuitem id="menu_selectFlagged"
+ label="&selectFlaggedCmd.label;"
+ accesskey="&selectFlaggedCmd.accesskey;"
+ command="cmd_selectFlagged"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="editMenuAfterSelectSeparator"/>
+ <menu id="menu_find"
+ label="&findMenu.label;" accesskey="&findMenu.accesskey;">
+ <menupopup id="menu_FindPopup"
+ onpopupshowing="initSearchMessagesMenu()">
+ <menuitem id="menu_findCmd"
+ label="&findCmd.label;"
+ key="key_find"
+ accesskey="&findCmd.accesskey;"
+ command="cmd_find"/>
+ <menuitem id="menu_findAgainCmd"
+ label="&findAgainCmd.label;"
+ key="key_findAgain"
+ accesskey="&findAgainCmd.accesskey;"
+ command="cmd_findAgain"/>
+ <menuseparator id="editMenuAfterFindSeparator"/>
+ <menuitem id="searchMailCmd" label="&searchMailCmd.label;"
+ key="key_searchMail"
+ accesskey="&searchMailCmd.accesskey;"
+ command="cmd_searchMessages"/>
+ <menuitem id="glodaSearchCmd"
+ label="&glodaSearchCmd.label;"
+ accesskey="&glodaSearchCmd.accesskey;"
+ oncommand="openGlodaSearchTab()"/>
+ <menuitem id="searchAddressesCmd" label="&searchAddressesCmd.label;"
+ accesskey="&searchAddressesCmd.accesskey;"
+ oncommand="MsgSearchAddresses()"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="editPropertiesSeparator"/>
+ <menuitem id="menu_favoriteFolder"
+ type="checkbox"
+ label="&menuFavoriteFolder.label;"
+ accesskey="&menuFavoriteFolder.accesskey;"
+ checked="false"
+ command="cmd_toggleFavoriteFolder"/>
+ <menuitem id="menu_properties"
+ command="cmd_properties"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-properties-menuitem"
+ label="&calendar.properties.label;"
+ accesskey="&calendar.properties.accesskey;"
+ command="calendar_edit_calendar_command"/>
+#endif
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_preferences"
+ oncommand="openOptionsDialog()"
+ data-l10n-id="menu-tools-settings"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmdUnix2.accesskey;"
+ oncommand="MsgAccountManager(null);"/>
+#endif
+#endif
+ </menupopup>
+</menu>
+
+<!-- View -->
+<menu id="menu_View"
+ label="&viewMenu.label;"
+ accesskey="&viewMenu.accesskey;">
+ <menupopup id="menu_View_Popup" onpopupshowing="view_init(event);">
+ <menu id="menu_Toolbars"
+ label="&viewToolbarsMenu.label;"
+ accesskey="&viewToolbarsMenu.accesskey;"
+ onpopupshowing="calendarOnToolbarsPopupShowing(event);">
+ <menupopup id="view_toolbars_popup">
+#ifdef MAIN_WINDOW
+ <menuitem id="view_toolbars_popup_quickFilterBar"
+ type="checkbox"
+ command="cmd_toggleQuickFilterBar"
+ data-l10n-id="quick-filter-bar-toggle"/>
+ <menuitem id="viewToolbarsPopupSpacesToolbar"
+ type="checkbox"
+ data-l10n-id="menu-spaces-toolbar-button"
+ oncommand="gSpacesToolbar.toggleToolbarFromMenu();"/>
+#endif
+ <menuitem id="menu_showTaskbar"
+ type="checkbox"
+ label="&showTaskbarCmd.label;"
+ accesskey="&showTaskbarCmd.accesskey;"
+ oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')"
+ checked="true"/>
+ <menuseparator id="viewMenuBeforeCustomizeMailToolbarsSeparator"/>
+ <menuitem id="customizeMailToolbars"
+ command="cmd_CustomizeMailToolbar"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_MessagePaneLayout" label="&messagePaneLayoutStyle.label;" accesskey="&messagePaneLayoutStyle.accesskey;">
+ <menupopup id="view_layout_popup" onpopupshowing="InitViewLayoutStyleMenu(event)">
+ <menuitem id="messagePaneClassic" type="radio" label="&messagePaneClassic.label;" name="viewlayoutgroup"
+ accesskey="&messagePaneClassic.accesskey;" command="cmd_viewClassicMailLayout"/>
+ <menuitem id="messagePaneWide" type="radio" label="&messagePaneWide.label;" name="viewlayoutgroup"
+ accesskey="&messagePaneWide.accesskey;" command="cmd_viewWideMailLayout"/>
+ <menuitem id="messagePaneVertical" type="radio" label="&messagePaneVertical.label;" name="viewlayoutgroup"
+ accesskey="&messagePaneVertical.accesskey;" command="cmd_viewVerticalMailLayout"/>
+ <menuseparator id="viewMenuAfterPaneVerticalSeparator"/>
+ <menuitem id="menu_showFolderPane" type="checkbox" label="&showFolderPaneCmd.label;"
+ accesskey="&showFolderPaneCmd.accesskey;" command="cmd_toggleFolderPane"/>
+ <menuitem id="menu_toggleThreadPaneHeader"
+ type="checkbox"
+ name="threadheader"
+ data-l10n-id="menu-view-toggle-thread-pane-header"
+ command="cmd_toggleThreadPaneHeader"/>
+ <menuitem id="menu_showMessage" type="checkbox" label="&showMessageCmd.label;" key="key_toggleMessagePane"
+ accesskey="&showMessageCmd.accesskey;" command="cmd_toggleMessagePane"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_FolderViews" label="&folderView.label;" accesskey="&folderView.accesskey;">
+ <menupopup id="menu_FolderViewsPopup"
+ onpopupshowing="PanelUI._onFoldersViewShow(event)">
+ <menuitem id="menu_toggleFolderHeader"
+ name="paneheader"
+ value="toggle-header"
+ data-l10n-id="menu-view-folders-toggle-header"
+ type="checkbox"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuseparator id="folderViewsHeaderSeparator"/>
+ <menuitem id="menu_allFolders" value="all"
+ data-l10n-id="show-all-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuitem id="menu_smartFolders" value="smart"
+ data-l10n-id="show-smart-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuitem id="menu_unreadFolders" value="unread"
+ data-l10n-id="show-unread-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuitem id="menu_favoriteFolders" value="favorite"
+ data-l10n-id="show-favorite-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuitem id="menu_recentFolders" value="recent"
+ data-l10n-id="show-recent-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuseparator/>
+ <menuitem id="menu_tags" value="tags"
+ data-l10n-id="show-tags-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuseparator/>
+ <menuitem id="menu_compactMode" value="compact"
+ data-l10n-id="folder-toolbar-toggle-folder-compact-view"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderCompactMenuOnCommand(event);"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="viewUIZoomMenuSeparator"/>
+ <menu id="menu_uiDensity"
+ data-l10n-id="mail-uidensity-label">
+ <menupopup id="view_density_popup" onpopupshowing="initUiDensityMenu(event);">
+ <menuitem id="uiDensityCompact"
+ data-l10n-id="mail-uidensity-compact"
+ type="radio"
+ name="uidensity"
+ closemenu="none"
+ oncommand="UIDensity.setMode(this.mode);"/>
+ <menuitem id="uiDensityNormal"
+ data-l10n-id="mail-uidensity-default"
+ type="radio"
+ name="uidensity"
+ closemenu="none"
+ oncommand="UIDensity.setMode(this.mode);"/>
+ <menuitem id="uiDensityTouch"
+ data-l10n-id="mail-uidensity-relaxed"
+ type="radio"
+ name="uidensity"
+ closemenu="none"
+ oncommand="UIDensity.setMode(this.mode);"/>
+ </menupopup>
+ </menu>
+ <menu id="viewFullZoomMenu" label="&fullZoom.label;" accesskey="&fullZoom.accesskey;"
+ onpopupshowing="UpdateFullZoomMenu()">
+ <menupopup id="viewFullZoomPopupMenu">
+ <menuitem id="menu_fullZoomEnlarge" key="key_fullZoomEnlarge"
+ label="&fullZoomEnlargeCmd.label;"
+ accesskey="&fullZoomEnlargeCmd.accesskey;"
+ command="cmd_fullZoomEnlarge"/>
+ <menuitem id="menu_fullZoomReduce" key="key_fullZoomReduce"
+ label="&fullZoomReduceCmd.label;"
+ accesskey="&fullZoomReduceCmd.accesskey;"
+ command="cmd_fullZoomReduce"/>
+ <menuseparator id="fullZoomAfterReduceSeparator"/>
+ <menuitem id="menu_fullZoomReset" key="key_fullZoomReset"
+ label="&fullZoomResetCmd.label;"
+ accesskey="&fullZoomResetCmd.accesskey;"
+ command="cmd_fullZoomReset"/>
+ <menuseparator id="fullZoomAfterResetSeparator"/>
+ <menuitem id="menu_fullZoomToggle" label="&fullZoomToggleCmd.label;"
+ accesskey="&fullZoomToggleCmd.accesskey;"
+ type="checkbox" command="cmd_fullZoomToggle" checked="false"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_uiFontSize"
+ data-l10n-id="menu-font-size-label">
+ <menupopup id="view_font_size_popup">
+ <menuitem id="menu_fontSizeEnlarge"
+ data-l10n-id="menuitem-font-size-enlarge"
+ oncommand="UIFontSize.increaseSize();"
+ closemenu="none"/>
+ <menuitem id="menu_fontSizeReduce"
+ data-l10n-id="menuitem-font-size-reduce"
+ oncommand="UIFontSize.reduceSize();"
+ closemenu="none"/>
+ <menuseparator id="fontSizeAfterReduceSeparator"/>
+ <menuitem id="menu_fontSizeReset"
+ data-l10n-id="menuitem-font-size-reset"
+ oncommand="UIFontSize.resetSize();"
+ closemenu="none"/>
+ </menupopup>
+ </menu>
+
+#ifdef MAIN_WINDOW
+#include ../../../calendar/base/content/calendar-view-menu.inc.xhtml
+#endif
+
+ <menuseparator id="viewSortMenuSeparator"/>
+ <menu id="viewSortMenu" accesskey="&sortMenu.accesskey;" label="&sortMenu.label;">
+ <menupopup id="menu_viewSortPopup" oncommand="goDoCommand('cmd_sort', event);" onpopupshowing="InitViewSortByMenu()">
+ <menuitem id="sortByDateMenuitem"
+ type="radio"
+ name="sortby"
+ value="byDate"
+ label="&sortByDateCmd.label;"
+ accesskey="&sortByDateCmd.accesskey;"/>
+ <menuitem id="sortByReceivedMenuitem"
+ type="radio"
+ name="sortby"
+ value="byReceived"
+ label="&sortByReceivedCmd.label;"
+ accesskey="&sortByReceivedCmd.accesskey;"/>
+ <menuitem id="sortByFlagMenuitem"
+ type="radio"
+ name="sortby"
+ value="byFlagged"
+ label="&sortByStarCmd.label;"
+ accesskey="&sortByStarCmd.accesskey;"/>
+ <menuitem id="sortByOrderReceivedMenuitem"
+ type="radio"
+ name="sortby"
+ value="byId"
+ label="&sortByOrderReceivedCmd.label;"
+ accesskey="&sortByOrderReceivedCmd.accesskey;"/>
+ <menuitem id="sortByPriorityMenuitem"
+ type="radio"
+ name="sortby"
+ value="byPriority"
+ label="&sortByPriorityCmd.label;"
+ accesskey="&sortByPriorityCmd.accesskey;"/>
+ <menuitem id="sortByFromMenuitem"
+ type="radio"
+ name="sortby"
+ value="byAuthor"
+ label="&sortByFromCmd.label;"
+ accesskey="&sortByFromCmd.accesskey;"/>
+ <menuitem id="sortByRecipientMenuitem"
+ type="radio"
+ name="sortby"
+ value="byRecipient"
+ label="&sortByRecipientCmd.label;"
+ accesskey="&sortByRecipientCmd.accesskey;"/>
+ <menuitem id="sortByCorrespondentMenuitem"
+ type="radio"
+ name="sortby"
+ value="byCorrespondent"
+ label="&sortByCorrespondentCmd.label;"
+ accesskey="&sortByCorrespondentCmd.accesskey;"/>
+ <menuitem id="sortBySizeMenuitem"
+ type="radio"
+ name="sortby"
+ value="bySize"
+ label="&sortBySizeCmd.label;"
+ accesskey="&sortBySizeCmd.accesskey;"/>
+ <menuitem id="sortByStatusMenuitem"
+ type="radio"
+ name="sortby"
+ value="byStatus"
+ label="&sortByStatusCmd.label;"
+ accesskey="&sortByStatusCmd.accesskey;"/>
+ <menuitem id="sortBySubjectMenuitem"
+ type="radio"
+ name="sortby"
+ value="bySubject"
+ label="&sortBySubjectCmd.label;"
+ accesskey="&sortBySubjectCmd.accesskey;"/>
+ <menuitem id="sortByUnreadMenuitem"
+ type="radio"
+ name="sortby"
+ value="byUnread"
+ label="&sortByUnreadCmd.label;"
+ accesskey="&sortByUnreadCmd.accesskey;"/>
+ <menuitem id="sortByTagsMenuitem"
+ type="radio"
+ name="sortby"
+ value="byTags"
+ label="&sortByTagsCmd.label;"
+ accesskey="&sortByTagsCmd.accesskey;"/>
+ <menuitem id="sortByJunkStatusMenuitem"
+ type="radio"
+ name="sortby"
+ value="byJunkStatus"
+ label="&sortByJunkStatusCmd.label;"
+ accesskey="&sortByJunkStatusCmd.accesskey;"/>
+ <menuitem id="sortByAttachmentsMenuitem"
+ type="radio"
+ name="sortby"
+ value="byAttachments"
+ label="&sortByAttachmentsCmd.label;"
+ accesskey="&sortByAttachmentsCmd.accesskey;"/>
+ <menuseparator id="sortAfterAttachmentSeparator"/>
+ <menuitem id="sortAscending"
+ type="radio"
+ name="sortdirection"
+ value="ascending"
+ label="&sortAscending.label;"
+ accesskey="&sortAscending.accesskey;"/>
+ <menuitem id="sortDescending"
+ type="radio"
+ name="sortdirection"
+ value="descending"
+ label="&sortDescending.label;"
+ accesskey="&sortDescending.accesskey;"/>
+ <menuseparator id="sortAfterDescendingSeparator"/>
+ <menuitem id="sortThreaded"
+ type="radio"
+ name="threaded"
+ value="threaded"
+ label="&sortThreaded.label;"
+ accesskey="&sortThreaded.accesskey;"/>
+ <menuitem id="sortUnthreaded"
+ type="radio"
+ name="threaded"
+ value="unthreaded"
+ label="&sortUnthreaded.label;"
+ accesskey="&sortUnthreaded.accesskey;"/>
+ <menuitem id="groupBySort"
+ type="checkbox"
+ name="group"
+ value="group"
+ label="&groupBySort.label;"
+ accesskey="&groupBySort.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menu id="viewMessageViewMenu" label="&msgsMenu.label;" accesskey="&msgsMenu.accesskey;"
+ command="mailHideMenus" oncommand="ViewChangeByMenuitem(event.target);">
+ <menupopup id="viewMessagePopup" onpopupshowing="RefreshViewPopup(this);">
+ <menuitem id="viewMessageAll" value="0" type="radio" label="&viewAll.label;" accesskey="&viewAll.accesskey;"/>
+ <menuitem id="viewMessageUnread" value="1" type="radio" label="&viewUnread.label;" accesskey="&viewUnread.accesskey;"/>
+ <menuitem id="viewMessageNotDeleted" value="3" type="radio" label="&viewNotDeleted.label;" accesskey="&viewNotDeleted.accesskey;"/>
+ <menuseparator id="messageViewAfterUnreadSeparator"/>
+ <menu id="viewMessageTags" label="&viewTags.label;" accesskey="&viewTags.accesskey;">
+ <menupopup id="viewMessageTagsPopup" onpopupshowing="RefreshTagsPopup(this);"/>
+ </menu>
+ <menu id="viewMessageCustomViews" label="&viewCustomViews.label;" accesskey="&viewCustomViews.accesskey;">
+ <menupopup id="viewMessageCustomViewsPopup" onpopupshowing="RefreshCustomViewsPopup(this);"/>
+ </menu>
+ <menuseparator id="messageViewAfterCustomSeparator"/>
+ <menuitem id="viewMessageVirtualFolder" value="7" label="&viewVirtualFolder.label;" accesskey="&viewVirtualFolder.accesskey;"/>
+ <menuitem id="viewMessageCustomize" value="8" label="&viewCustomizeView.label;" accesskey="&viewCustomizeView.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menu label="&threads.label;" id="viewMessagesMenu" accesskey="&threads.accesskey;">
+ <menupopup id="menu_ThreadsPopup" onpopupshowing="InitViewMessagesMenu()">
+ <menuitem id="viewAllMessagesMenuItem" type="radio" name="viewmessages" label="&allMsgsCmd.label;" accesskey="&allMsgsCmd.accesskey;" disabled="true" command="cmd_viewAllMsgs"/>
+ <menuitem id="viewUnreadMessagesMenuItem" type="radio" name="viewmessages" label="&unreadMsgsCmd.label;" accesskey="&unreadMsgsCmd.accesskey;" disabled="true" command="cmd_viewUnreadMsgs"/>
+ <menuitem id="viewThreadsWithUnreadMenuItem" type="radio" name="viewmessages" label="&threadsWithUnreadCmd.label;" accesskey="&threadsWithUnreadCmd.accesskey;" disabled="true" command="cmd_viewThreadsWithUnread"/>
+ <menuitem id="viewWatchedThreadsWithUnreadMenuItem" type="radio" name="viewmessages" label="&watchedThreadsWithUnreadCmd.label;" accesskey="&watchedThreadsWithUnreadCmd.accesskey;" disabled="true" command="cmd_viewWatchedThreadsWithUnread"/>
+ <menuseparator id="threadsAfterWatchedSeparator"/>
+ <menuitem id="viewIgnoredThreadsMenuItem" type="checkbox" label="&ignoredThreadsCmd.label;" disabled="true" command="cmd_viewIgnoredThreads" accesskey="&ignoredThreadsCmd.accesskey;"/>
+ <menuseparator id="threadsAfterIgnoredSeparator"/>
+ <menuitem id="menu_expandAllThreads" label="&expandAllThreadsCmd.label;" accesskey="&expandAllThreadsCmd.accesskey;" key="key_expandAllThreads" disabled="true" command="cmd_expandAllThreads"/>
+ <menuitem id="collapseAllThreads" label="&collapseAllThreadsCmd.label;" accesskey="&collapseAllThreadsCmd.accesskey;" key="key_collapseAllThreads" disabled="true" command="cmd_collapseAllThreads"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="viewAfterThreadsSeparator"/>
+ <menu id="viewheadersmenu" label="&headersMenu.label;" accesskey="&headersMenu.accesskey;">
+ <menupopup id="menu_HeadersPopup" onpopupshowing="InitViewHeadersMenu();">
+ <menuitem id="viewallheaders"
+ type="radio"
+ name="viewheadergroup"
+ label="&headersAllCmd.label;"
+ accesskey="&headersAllCmd.accesskey;"
+ command="cmd_viewAllHeader"/>
+ <menuitem id="viewnormalheaders"
+ type="radio"
+ name="viewheadergroup"
+ label="&headersNormalCmd.label;"
+ accesskey="&headersNormalCmd.accesskey;"
+ command="cmd_viewNormalHeader"/>
+ </menupopup>
+ </menu>
+ <menu id="viewBodyMenu" accesskey="&bodyMenu.accesskey;" label="&bodyMenu.label;">
+ <menupopup id="viewBodyPopMenu" onpopupshowing="InitViewBodyMenu()">
+ <menuitem id="bodyAllowHTML" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAllowHTML.label;"
+ accesskey="&bodyAllowHTML.accesskey;" oncommand="MsgBodyAllowHTML()"/>
+ <menuitem id="bodySanitized" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodySanitized.label;"
+ accesskey="&bodySanitized.accesskey;"
+ oncommand="MsgBodySanitized()"/>
+ <menuitem id="bodyAsPlaintext" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAsPlaintext.label;"
+ accesskey="&bodyAsPlaintext.accesskey;" oncommand="MsgBodyAsPlaintext()"/>
+ <menuitem id="bodyAllParts" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAllParts.label;"
+ accesskey="&bodyAllParts.accesskey;" oncommand="MsgBodyAllParts()"/>
+ </menupopup>
+ </menu>
+ <menu id="viewFeedSummary"
+ label="&bodyMenuFeed.label;"
+ accesskey="&bodyMenuFeed.accesskey;">
+ <menupopup id="viewFeedSummaryPopupMenu"
+ onpopupshowing="InitViewBodyMenu()">
+ <menuitem id="bodyFeedGlobalWebPage"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedWebPage.label;"
+ accesskey="&viewFeedWebPage.accesskey;"
+ oncommand="FeedMessageHandler.onSelectPref = 0"/>
+ <menuitem id="bodyFeedGlobalSummary"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedSummary.label;"
+ accesskey="&viewFeedSummary.accesskey;"
+ oncommand="FeedMessageHandler.onSelectPref = 1"/>
+ <menuitem id="bodyFeedPerFolderPref"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedSummaryFeedPropsPref.label;"
+ accesskey="&viewFeedSummaryFeedPropsPref.accesskey;"
+ oncommand="FeedMessageHandler.onSelectPref = 2"/>
+ <menuseparator id="viewFeedSummarySeparator"/>
+ <menuitem id="bodyFeedSummaryAllowHTML"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodyAllowHTML.label;"
+ accesskey="&bodyAllowHTML.accesskey;"
+ oncommand="MsgFeedBodyRenderPrefs(false, 0, 0)"/>
+ <menuitem id="bodyFeedSummarySanitized"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodySanitized.label;"
+ accesskey="&bodySanitized.accesskey;"
+ oncommand="MsgFeedBodyRenderPrefs(false, 3, gDisallow_classes_no_html)"/>
+ <menuitem id="bodyFeedSummaryAsPlaintext"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodyAsPlaintext.label;"
+ accesskey="&bodyAsPlaintext.accesskey;"
+ oncommand="MsgFeedBodyRenderPrefs(true, 1, gDisallow_classes_no_html)"/>
+ </menupopup>
+ </menu>
+ <menuitem id="viewAttachmentsInlineMenuitem" label="&viewAttachmentsInlineCmd.label;" accesskey="&viewAttachmentsInlineCmd.accesskey;"
+ oncommand="ToggleInlineAttachment(event.target)" type="checkbox" checked="true"/>
+ <menuseparator id="viewAfterAttachmentsSeparator"/>
+ <menuitem id="pageSourceMenuItem"
+ label="&pageSourceCmd.label;"
+ key="key_viewPageSource"
+ accesskey="&pageSourceCmd.accesskey;"
+ command="cmd_viewPageSource"/>
+ </menupopup>
+ </menu>
+
+ <!-- Go -->
+ <menu id="menu_Go" label="&goMenu.label;" accesskey="&goMenu.accesskey;">
+ <menupopup id="menu_GoPopup" onpopupshowing="InitGoMessagesMenu();">
+ <menu id="goNextMenu" label="&nextMenu.label;" accesskey="&nextMenu.accesskey;">
+ <menupopup id="menu_GoNextPopup">
+ <menuitem id="menu_nextMsg"
+ label="&nextMsgCmd.label;"
+ accesskey="&nextMsgCmd.accesskey;"
+ command="cmd_nextMsg"
+ key="key_nextMsg"/>
+ <menuitem id="menu_nextUnreadMsg"
+ label="&nextUnreadMsgCmd.label;"
+ accesskey="&nextUnreadMsgCmd.accesskey;"
+ command="cmd_nextUnreadMsg"
+ key="key_nextUnreadMsg"/>
+ <menuitem id="menu_nextFlaggedMsg"
+ label="&nextStarredMsgCmd.label;"
+ accesskey="&nextStarredMsgCmd.accesskey;"
+ command="cmd_nextFlaggedMsg"/>
+ <menuseparator id="goNextAfterFlaggedSeparator"/>
+ <menuitem id="menu_nextUnreadThread"
+ label="&nextUnreadThread.label;"
+ accesskey="&nextUnreadThread.accesskey;"
+ command="cmd_nextUnreadThread"
+ key="key_nextUnreadThread"/>
+#ifdef MAIN_WINDOW
+ <menuseparator id="goNextAfterUnreadThreadSeparator"
+ class="hide-before-calendar-loaded hide-when-calendar-deactivated"
+ hidden="true"/>
+ <!-- Label is set up automatically using the view id. When writing
+ a view extension, add a `label-<myviewtype>` attribute with
+ the correct label. -->
+ <menuitem id="calendar-go-menu-next"
+ class="hide-before-calendar-loaded hide-when-calendar-deactivated"
+ label=""
+ label-day="&lightning.toolbar.day.label;"
+ label-week="&lightning.toolbar.week.label;"
+ label-multiweek="&lightning.toolbar.week.label;"
+ label-month="&lightning.toolbar.month.label;"
+ accesskey-day="&lightning.toolbar.day.accesskey;"
+ accesskey-week="&lightning.toolbar.week.accesskey;"
+ accesskey-multiweek="&lightning.toolbar.week.accesskey;"
+ accesskey-month="&lightning.toolbar.month.accesskey;"
+ command="calendar_view_next_command"
+ hidden="true"/>
+#endif
+ </menupopup>
+ </menu>
+ <menu id="goPreviousMenu" label="&prevMenu.label;" accesskey="&prevMenu.accesskey;">
+ <menupopup id="menu_GoPreviousPopup">
+ <menuitem id="menu_prevMsg"
+ label="&prevMsgCmd.label;"
+ accesskey="&prevMsgCmd.accesskey;"
+ command="cmd_previousMsg"
+ key="key_previousMsg"/>
+ <menuitem id="menu_prevUnreadMsg"
+ label="&prevUnreadMsgCmd.label;"
+ accesskey="&prevUnreadMsgCmd.accesskey;"
+ command="cmd_previousUnreadMsg"
+ key="key_previousUnreadMsg"/>
+ <menuitem id="menu_prevFlaggedMsg"
+ label="&prevStarredMsgCmd.label;"
+ accesskey="&prevStarredMsgCmd.accesskey;"
+ command="cmd_previousFlaggedMsg"/>
+#ifdef MAIN_WINDOW
+ <menuseparator id="goPreviousAfterFlaggedSeparator"
+ class="hide-before-calendar-loaded hide-when-calendar-deactivated"
+ hidden="true"/>
+ <!-- Label is set up automatically using the view id. When writing
+ a view extension, add a `label-<myviewtype>` attribute with
+ the correct label. -->
+ <menuitem id="calendar-go-menu-previous"
+ class="hide-before-calendar-loaded hide-when-calendar-deactivated"
+ label=""
+ label-day="&lightning.toolbar.day.label;"
+ label-week="&lightning.toolbar.week.label;"
+ label-multiweek="&lightning.toolbar.week.label;"
+ label-month="&lightning.toolbar.month.label;"
+ accesskey-day="&lightning.toolbar.day.accesskey;"
+ accesskey-week="&lightning.toolbar.week.accesskey;"
+ accesskey-multiweek="&lightning.toolbar.week.accesskey;"
+ accesskey-month="&lightning.toolbar.month.accesskey;"
+ command="calendar_view_prev_command"
+ hidden="true"/>
+#endif
+ </menupopup>
+ </menu>
+ <menuitem id="menu_goForward" label="&goForwardCmd.label;"
+ accesskey="&goForwardCmd.accesskey;" command="cmd_goForward"
+ key="key_goForward"/>
+ <menuitem id="menu_goBack" label="&goBackCmd.label;"
+ accesskey="&goBackCmd.accesskey;" command="cmd_goBack"
+ key="key_goBack"/>
+ <menuseparator id="goNextSeparator"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-go-to-today-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&goTodayCmd.label;"
+ accesskey="&goTodayCmd.accesskey;"
+ command="calendar_go_to_today_command"
+ key="calendar-go-to-today-key"/>
+#endif
+ <menuitem id="menu_goChat" label="&goChatCmd.label;"
+ accesskey="&goChatCmd.accesskey;"
+ command="cmd_chat"
+ data-l10n-attrs="acceltext"/>
+ <menuseparator id="goChatSeparator"/>
+ <menu id="goFolderMenu"
+ label="&folderMenu.label;"
+ accesskey="&folderMenu.accesskey;"
+ command="cmd_goFolder">
+ <menupopup is="folder-menupopup" id="menu_GoFolderPopup"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menuseparator id="goFolderSeparator"/>
+
+ <menu id="goRecentlyClosedTabs"
+ label="&goRecentlyClosedTabs.label;"
+ accesskey="&goRecentlyClosedTabs.accesskey;"
+ observes="cmd_undoCloseTab">
+ <menupopup id="menu_GoRecentlyClosedTabsPopup"
+ onpopupshowing="return InitRecentlyClosedTabsPopup(this)" />
+ </menu>
+ <menuseparator id="goRecentlyClosedTabsSeparator"/>
+
+ <menuitem id="goStartPage"
+ label="&startPageCmd.label;"
+ accesskey="&startPageCmd.accesskey;"
+ command="cmd_goStartPage"
+ key="key_goStartPage"/>
+ </menupopup>
+ </menu>
+
+ <!-- Message -->
+ <menu id="messageMenu" label="&msgMenu.label;" accesskey="&msgMenu.accesskey;">
+ <menupopup id="messageMenuPopup" onpopupshowing="InitMessageMenu();">
+ <menuitem id="newMsgCmd" label="&newMsgCmd.label;"
+ accesskey="&newMsgCmd.accesskey;"
+ key="key_newMessage2"
+ command="cmd_newMessage"/>
+ <menuitem id="replyMainMenu" label="&replyMsgCmd.label;"
+ accesskey="&replyMsgCmd.accesskey;"
+ key="key_reply"
+ command="cmd_reply"/>
+ <menuitem id="replyNewsgroupMainMenu" label="&replyNewsgroupCmd2.label;"
+ accesskey="&replyNewsgroupCmd2.accesskey;"
+ key="key_reply"
+ command="cmd_replyGroup"/>
+ <menuitem id="replySenderMainMenu" label="&replySenderCmd.label;"
+ accesskey="&replySenderCmd.accesskey;"
+ command="cmd_replySender"/>
+ <menuitem id="menu_replyToAll" label="&replyToAllMsgCmd.label;"
+ accesskey="&replyToAllMsgCmd.accesskey;"
+ key="key_replyall"
+ command="cmd_replyall"/>
+ <menuitem id="menu_replyToList" label="&replyToListMsgCmd.label;"
+ accesskey="&replyToListMsgCmd.accesskey;"
+ key="key_replylist"
+ command="cmd_replylist"/>
+ <menuitem id="menu_forwardMsg" label="&forwardMsgCmd.label;"
+ accesskey="&forwardMsgCmd.accesskey;"
+ key="key_forward"
+ command="cmd_forward"/>
+ <menu id="forwardAsMenu" label="&forwardAsMenu.label;" accesskey="&forwardAsMenu.accesskey;">
+ <menupopup id="menu_forwardAsPopup">
+ <menuitem id="menu_forwardAsInline"
+ label="&forwardAsInline.label;"
+ accesskey="&forwardAsInline.accesskey;"
+ command="cmd_forwardInline"/>
+ <menuitem id="menu_forwardAsAttachment"
+ label="&forwardAsAttachmentCmd.label;"
+ accesskey="&forwardAsAttachmentCmd.accesskey;"
+ command="cmd_forwardAttachment"/>
+ </menupopup>
+ </menu>
+ <menuitem id="menu_redirectMsg"
+ data-l10n-id="redirect-msg-menuitem"
+ command="cmd_redirect"/>
+ <menuitem id="menu_editMsgAsNew" label="&editAsNewMsgCmd.label;"
+ accesskey="&editAsNewMsgCmd.accesskey;"
+ key="key_editAsNew"
+ command="cmd_editAsNew"/>
+ <menuitem id="menu_editDraftMsg"
+ label="&editDraftMsgCmd.label;"
+ accesskey="&editDraftMsgCmd.accesskey;"
+ command="cmd_editDraftMsg"/>
+ <menuitem id="menu_newMsgFromTemplate"
+ label="&newMsgFromTemplateCmd.label;"
+ key="key_newMsgFromTemplate"
+ command="cmd_newMsgFromTemplate"/>
+ <menuitem id="menu_editTemplate"
+ label="&editTemplateMsgCmd.label;"
+ accesskey="&editTemplateMsgCmd.accesskey;"
+ command="cmd_editTemplateMsg"/>
+ <menuseparator id="messageMenuAfterCompositionCommandsSeparator"/>
+ <menuitem id="openMessageWindowMenuitem" label="&openMessageWindowCmd.label;"
+ command="cmd_openMessage"
+ accesskey="&openMessageWindowCmd.accesskey;"
+ key="key_openMessage"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="openConversationMenuitem" label="&openInConversationCmd.label;"
+ command="cmd_openConversation"
+ accesskey="&openInConversationCmd.accesskey;"
+ key="key_openConversation"/>
+#endif
+ <menu id="openFeedMessage"
+ label="&openFeedMessage1.label;"
+ accesskey="&openFeedMessage1.accesskey;">
+ <menupopup id="menu_openFeedMessage">
+ <menuitem id="menu_openFeedWebPage"
+ type="radio"
+ name="openFeedGroup"
+ label="&openFeedWebPage.label;"
+ accesskey="&openFeedWebPage.accesskey;"
+ oncommand="FeedMessageHandler.onOpenPref = 0"/>
+ <menuitem id="menu_openFeedSummary"
+ type="radio"
+ name="openFeedGroup"
+ label="&openFeedSummary.label;"
+ accesskey="&openFeedSummary.accesskey;"
+ oncommand="FeedMessageHandler.onOpenPref = 1"/>
+ <menuitem id="menu_openFeedWebPageInMessagePane"
+ type="radio"
+ name="openFeedGroup"
+ label="&openFeedWebPageInMP.label;"
+ accesskey="&openFeedWebPageInMP.accesskey;"
+ oncommand="FeedMessageHandler.onOpenPref = 2"/>
+ </menupopup>
+ </menu>
+#ifdef MAIN_WINDOW
+ <menuseparator id="messageAfterOpenMsgSeparator"/>
+#endif
+ <menu id="msgAttachmentMenu"
+ label="&openAttachmentListCmd.label;"
+ accesskey="&openAttachmentListCmd.accesskey;"
+ disabled="true">
+ <menupopup id="attachmentMenuList"
+ onpopupshowing="fillAttachmentListPopup(event);">
+ <menuseparator/>
+ <menuitem id="menu-openAllAttachments"
+ label="&openAllAttachmentsCmd.label;"
+ accesskey="&openAllAttachmentsCmd.accesskey;"
+ command="cmd_openAllAttachments"/>
+ <menuitem id="menu-saveAllAttachments"
+ label="&saveAllAttachmentsCmd.label;"
+ accesskey="&saveAllAttachmentsCmd.accesskey;"
+ command="cmd_saveAllAttachments"/>
+ <menuitem id="menu-detachAllAttachments"
+ label="&detachAllAttachmentsCmd.label;"
+ accesskey="&detachAllAttachmentsCmd.accesskey;"
+ command="cmd_detachAllAttachments"/>
+ <menuitem id="menu-deleteAllAttachments"
+ label="&deleteAllAttachmentsCmd.label;"
+ accesskey="&deleteAllAttachmentsCmd.accesskey;"
+ command="cmd_deleteAllAttachments"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="messageAfterAttachmentMenuSeparator"/>
+ <menu id="tagMenu" label="&tagMenu.label;" accesskey="&tagMenu.accesskey;" command="cmd_tag">
+ <menupopup id="tagMenu-tagpopup"
+ onpopupshowing="InitMessageTags(this);">
+ <menuitem id="tagMenu-addNewTag"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"
+ command="cmd_addTag"/>
+ <menuitem id="tagMenu-manageTags"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"
+ command="cmd_manageTags"/>
+ <menuseparator id="tagMenu-sep-afterTagAddNew"/>
+ <menuitem id="tagMenu-tagRemoveAll"
+ command="cmd_removeTags"/>
+ <menuseparator id="tagMenuAfterRemoveSeparator"/>
+ </menupopup>
+ </menu>
+ <menu id="markMenu" label="&markMenu.label;" accesskey="&markMenu.accesskey;">
+ <menupopup id="menu_MarkPopup" onpopupshowing="InitMessageMark()">
+ <menuitem id="markReadMenuItem" label="&markAsReadCmd.label;"
+ accesskey="&markAsReadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsRead"/>
+ <menuitem id="markUnreadMenuItem" label="&markAsUnreadCmd.label;"
+ accesskey="&markAsUnreadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsUnread"/>
+ <menuitem id="menu_markThreadAsRead"
+ label="&markThreadAsReadCmd.label;"
+ accesskey="&markThreadAsReadCmd.accesskey;"
+ command="cmd_markThreadAsRead"
+ key="key_markThreadAsRead"/>
+ <menuitem id="menu_markReadByDate"
+ label="&markReadByDateCmd.label;"
+ accesskey="&markReadByDateCmd.accesskey;"
+ command="cmd_markReadByDate"
+ key="key_markReadByDate"/>
+ <menuitem id="menu_markAllRead"
+ label="&markAllReadCmd.label;"
+ key="key_markAllRead"
+ accesskey="&markAllReadCmd.accesskey;"
+ command="cmd_markAllRead"/>
+ <menuseparator id="markMenuAfterAllReadSeparator"/>
+ <menuitem id="markFlaggedMenuItem"
+ type="checkbox"
+ label="&markStarredCmd.label;"
+ accesskey="&markStarredCmd.accesskey;"
+ command="cmd_markAsFlagged"
+ key="key_toggleFlagged"/>
+ <menuseparator id="markMenuAfterFlaggedSeparator"/>
+ <menuitem id="menu_markAsJunk" label="&markAsJunkCmd.label;"
+ accesskey="&markAsJunkCmd.accesskey;"
+ command="cmd_markAsJunk"
+ key="key_markJunk"/>
+ <menuitem id="menu_markAsNotJunk" label="&markAsNotJunkCmd.label;"
+ key="key_markNotJunk"
+ accesskey="&markAsNotJunkCmd.accesskey;"
+ command="cmd_markAsNotJunk"/>
+ <menuitem id="menu_recalculateJunkScore"
+ label="&recalculateJunkScoreCmd.label;"
+ accesskey="&recalculateJunkScoreCmd.accesskey;"
+ command="cmd_recalculateJunkScore"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="messageMenuAfterMarkSeparator"/>
+ <menuitem id="archiveMainMenu" label="&archiveMsgCmd.label;"
+ accesskey="&archiveMsgCmd.accesskey;"
+ key="key_archive"
+ command="cmd_archive"/>
+ <menuitem id="menu_cancel" command="cmd_cancel"
+ label="&cancelNewsMsgCmd.label;"
+ accesskey="&cancelNewsMsgCmd.accesskey;"/>
+ <menu id="moveMenu"
+ label="&moveMsgToMenu.label;"
+ accesskey="&moveMsgToMenu.accesskey;"
+ oncommand="goDoCommand('cmd_moveMessage', event.target._folder)">
+ <menupopup is="folder-menupopup"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&moveCopyMsgRecentMenu.label;"
+ recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menu id="copyMenu"
+ label="&copyMsgToMenu.label;"
+ accesskey="&copyMsgToMenu.accesskey;"
+ oncommand="goDoCommand('cmd_copyMessage', event.target._folder)">
+ <menupopup is="folder-menupopup"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&moveCopyMsgRecentMenu.label;"
+ recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menuitem id="moveToFolderAgain" key="key_moveToFolderAgain" command="cmd_moveToFolderAgain"
+ label="&moveToFolderAgain.label;" accesskey="&moveToFolderAgain.accesskey;"/>
+ <menuseparator id="messageMenuAfterMoveCommandsSeparator"/>
+ <menuitem id="createFilter" label="&createFilter.label;"
+ accesskey="&createFilter.accesskey;"
+ command="cmd_createFilterFromMenu"/>
+ <menuseparator id="threadItemsSeparator"/>
+ <menuitem id="killThread"
+ label="&killThreadMenu.label;"
+ accesskey="&killThreadMenu.accesskey;"
+ command="cmd_killThread"
+ type="checkbox"
+ key="key_killThread"/>
+ <menuitem id="killSubthread"
+ label="&killSubthreadMenu.label;"
+ accesskey="&killSubthreadMenu.accesskey;"
+ type="checkbox"
+ command="cmd_killSubthread"
+ key="key_killSubthread"/>
+ <menuitem id="watchThread"
+ label="&watchThreadMenu.label;"
+ accesskey="&watchThreadMenu.accesskey;"
+ type="checkbox"
+ command="cmd_watchThread"
+ key="key_watchThread"/>
+ </menupopup>
+</menu>
+
+#ifdef MAIN_WINDOW
+#include ../../../calendar/base/content/calendar-menu-events-tasks.inc.xhtml
+#endif
+
+<!-- Tools -->
+<menu id="tasksMenu" label="&tasksMenu.label;" accesskey="&tasksMenu.accesskey;">
+ <menupopup id="taskPopup" onpopupshowing="document.commandDispatcher.updateCommands('create-menu-tasks')">
+#ifndef XP_MACOSX
+ <menuitem hidden="true" accesskey="&messengerCmd.accesskey;" label="&messengerCmd.label;"
+ key="key_mail" oncommand="toMessengerWindow();" id="tasksMenuMail"/>
+ <menuitem id="addressBook"
+ label="&addressBookCmd.label;"
+ accesskey="&addressBookCmd.accesskey;"
+ key="key_addressbook"
+ oncommand="toAddressBook();"/>
+ <menuseparator id="devToolsSeparator"/>
+#endif
+ <menuitem id="menu_openSavedFilesWnd" label="&savedFiles.label;"
+ accesskey="&savedFiles.accesskey;"
+ key="key_savedFiles"
+ oncommand="openSavedFilesWnd();"/>
+ <menuitem id="addonsManager"
+ data-l10n-id="menu-addons-and-themes"
+ oncommand="openAddonsMgr();"/>
+ <menuitem id="activityManager" label="&activitymanager.label;"
+ accesskey="&activitymanager.accesskey;"
+ oncommand="openActivityMgr();"/>
+ <menu id="imAccountsStatus" label="&imAccountsStatus.label;"
+ accesskey="&imAccountsStatus.accesskey;"
+ command="cmd_chatStatus">
+ <menupopup id="imStatusMenupopup">
+ <menuitem id="imStatusAvailable" status="available" label="&imStatus.available;" class="menuitem-iconic"/>
+ <menuitem id="imStatusUnavailable" status="unavailable" label="&imStatus.unavailable;" class="menuitem-iconic"/>
+ <menuseparator id="imStatusOfflineSeparator"/>
+ <menuitem id="imStatusOffline" status="offline" label="&imStatus.offline;" class="menuitem-iconic"/>
+ <menuseparator id="imStatusShowAccountsSeparator"/>
+ <menuitem id="imStatusShowAccounts" label="&imStatus.showAccounts;"/>
+ </menupopup>
+ </menu>
+ <menuitem id="joinChatMenuItem"
+ label="&joinChatCmd.label;"
+ accesskey="&joinChatCmd.accesskey;"
+ command="cmd_joinChat"/>
+
+ <menuseparator id="devToolsSeparator"/>
+ <menuitem id="filtersCmd" label="&filtersCmd2.label;"
+ accesskey="&filtersCmd2.accesskey;"
+ oncommand="MsgFilters();"/>
+ <menuitem id="applyFilters"
+ label="&filtersApply.label;"
+ accesskey="&filtersApply.accesskey;"
+ command="cmd_applyFilters"/>
+ <menuitem id="applyFiltersToSelection"
+ label="&filtersApplyToMessage.label;"
+ accesskey="&filtersApplyToMessage.accesskey;"
+ command="cmd_applyFiltersToSelection"/>
+ <menuseparator id="tasksMenuAfterApplySeparator"/>
+ <menuitem id="runJunkControls"
+ label="&runJunkControls.label;"
+ accesskey="&runJunkControls.accesskey;"
+ command="cmd_runJunkControls"/>
+ <menuitem id="deleteJunk"
+ label="&deleteJunk.label;"
+ accesskey="&deleteJunk.accesskey;"
+ command="cmd_deleteJunk"/>
+ <menuseparator id="tasksMenuAfterDeleteSeparator"/>
+ <menuitem id="menu_import" label="&importCmd.label;"
+ accesskey="&importCmd.accesskey;"
+ oncommand="toImport();"/>
+ <menuitem id="menu_export" label="&exportCmd.label;"
+ accesskey="&exportCmd.accesskey;"
+ oncommand="toExport();"/>
+ <menuitem id="manageKeysOpenPGP"
+ data-l10n-id="openpgp-manage-keys-openpgp-cmd"
+ oncommand="openKeyManager()"/>
+ <menu id="devtoolsMenu" label="&devtoolsMenu.label;" accesskey="&devtoolsMenu.accesskey;">
+ <menupopup id="devtoolsPopup">
+ <menuitem id="devtoolsToolbox"
+ label="&devToolboxCmd.label;"
+ accesskey="&devToolboxCmd.accesskey;"
+ key="key_devtoolsToolbox"
+ oncommand="BrowserToolboxLauncher.init();"/>
+ <menuitem id="addonDebugging"
+ label="&debugAddonsCmd.label;"
+ accesskey="&debugAddonsCmd.accesskey;"
+ oncommand="openAboutDebugging('addons')"/>
+ <menuseparator id="debuggingSeparator"/>
+ <menuitem id="javascriptConsole"
+ label="&errorConsoleCmd.label;"
+ accesskey="&errorConsoleCmd.accesskey;"
+ key="key_errorConsole"
+ oncommand="toJavaScriptConsole();"/>
+ </menupopup>
+ </menu>
+ <menuitem id="sanitizeHistory"
+ label="&clearRecentHistory.label;"
+ accesskey="&clearRecentHistory.accesskey;"
+ key="key_sanitizeHistory"
+ oncommand="toSanitize();"/>
+#ifndef XP_UNIX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_preferences"
+ oncommand="openOptionsDialog()"
+ data-l10n-id="menu-tools-settings"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmd2.accesskey;"
+ oncommand="MsgAccountManager(null);"/>
+#else
+#ifdef XP_MACOSX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_preferences"
+ data-l10n-id="menu-tools-settings"
+ key="key_preferencesCmdMac"
+ oncommand="openOptionsDialog()"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmd2.accesskey;"
+ oncommand="MsgAccountManager(null);"/>
+ <menuitem id="menu_mac_services"
+ label="&servicesMenuMac.label;"/>
+ <menuitem id="menu_mac_hide_app"
+ label="&hideThisAppCmdMac.label;"
+ key="key_hideThisAppCmdMac"/>
+ <menuitem id="menu_mac_hide_others"
+ label="&hideOtherAppsCmdMac.label;"
+ key="key_hideOtherAppsCmdMac"/>
+ <menuitem id="menu_mac_show_all"
+ label="&showAllAppsCmdMac.label;"/>
+#endif
+#endif
+ </menupopup>
+ </menu>
+
+#ifdef XP_MACOSX
+#include macWindowMenu.inc.xhtml
+#endif
+
+ <!-- Help -->
+#include helpMenu.inc.xhtml
+</menubar>
diff --git a/comm/mail/base/content/messenger-titlebar-items.inc.xhtml b/comm/mail/base/content/messenger-titlebar-items.inc.xhtml
new file mode 100644
index 0000000000..999ad00830
--- /dev/null
+++ b/comm/mail/base/content/messenger-titlebar-items.inc.xhtml
@@ -0,0 +1,24 @@
+# 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/.
+
+<hbox class="titlebar-buttonbox-container" skipintoolbarset="true">
+ <hbox class="titlebar-buttonbox titlebar-color">
+ <toolbarbutton class="titlebar-button titlebar-min"
+ titlebar-btn="min"
+ oncommand="window.minimize();"
+ data-l10n-id="messenger-window-minimize-button"/>
+ <toolbarbutton class="titlebar-button titlebar-max"
+ titlebar-btn="max"
+ oncommand="window.maximize();"
+ data-l10n-id="messenger-window-maximize-button"/>
+ <toolbarbutton class="titlebar-button titlebar-restore"
+ titlebar-btn="max"
+ oncommand="window.restore();"
+ data-l10n-id="messenger-window-restore-down-button"/>
+ <toolbarbutton class="titlebar-button titlebar-close"
+ titlebar-btn="close"
+ oncommand="window.close()"
+ data-l10n-id="messenger-window-close-button"/>
+ </hbox>
+</hbox>
diff --git a/comm/mail/base/content/messenger.js b/comm/mail/base/content/messenger.js
new file mode 100644
index 0000000000..6e11a3b538
--- /dev/null
+++ b/comm/mail/base/content/messenger.js
@@ -0,0 +1,1289 @@
+/**
+ * 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 ../../../mailnews/base/prefs/content/accountUtils.js */
+/* import-globals-from ../../components/addrbook/content/addressBookTab.js */
+/* import-globals-from ../../components/customizableui/content/panelUI.js */
+/* import-globals-from ../../components/newmailaccount/content/provisionerCheckout.js */
+/* import-globals-from ../../components/preferences/preferencesTab.js */
+/* import-globals-from glodaFacetTab.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from mail-offline.js */
+/* import-globals-from mailTabs.js */
+/* import-globals-from mailWindowOverlay.js */
+/* import-globals-from messenger-customization.js */
+/* import-globals-from searchBar.js */
+/* import-globals-from spacesToolbar.js */
+/* import-globals-from specialTabs.js */
+/* import-globals-from toolbarIconColor.js */
+
+/* globals CreateMailWindowGlobals, InitMsgWindow, OnMailWindowUnload */ // From mailWindow.js
+
+/* globals loadCalendarComponent */
+
+ChromeUtils.import("resource:///modules/activity/activityModules.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ Color: "resource://gre/modules/Color.sys.mjs",
+ ctypes: "resource://gre/modules/ctypes.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm",
+ MailConsts: "resource:///modules/MailConsts.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ msgDBCacheManager: "resource:///modules/MsgDBCacheManager.jsm",
+ PeriodicFilterManager: "resource:///modules/PeriodicFilterManager.jsm",
+ SessionStoreManager: "resource:///modules/SessionStoreManager.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "PopupNotifications", function () {
+ let { PopupNotifications } = ChromeUtils.import(
+ "resource:///modules/GlobalPopupNotifications.jsm"
+ );
+ try {
+ // Hide all notifications while the URL is being edited and the address bar
+ // has focus, including the virtual focus in the results popup.
+ // We also have to hide notifications explicitly when the window is
+ // minimized because of the effects of the "noautohide" attribute on Linux.
+ // This can be removed once bug 545265 and bug 1320361 are fixed.
+ let shouldSuppress = () => window.windowState == window.STATE_MINIMIZED;
+ return new PopupNotifications(
+ document.getElementById("tabmail"),
+ document.getElementById("notification-popup"),
+ document.getElementById("notification-popup-box"),
+ { shouldSuppress }
+ );
+ } catch (ex) {
+ console.error(ex);
+ return null;
+ }
+});
+
+/**
+ * Gets the service pack and build information on Windows platforms. The initial version
+ * was copied from nsUpdateService.js.
+ *
+ * @returns An object containing the service pack major and minor versions, along with the
+ * build number.
+ */
+function getWindowsVersionInfo() {
+ const UNKNOWN_VERSION_INFO = {
+ servicePackMajor: null,
+ servicePackMinor: null,
+ buildNumber: null,
+ };
+
+ if (AppConstants.platform !== "win") {
+ return UNKNOWN_VERSION_INFO;
+ }
+
+ const BYTE = ctypes.uint8_t;
+ const WORD = ctypes.uint16_t;
+ const DWORD = ctypes.uint32_t;
+ const WCHAR = ctypes.char16_t;
+ const BOOL = ctypes.int;
+
+ // This structure is described at:
+ // http://msdn.microsoft.com/en-us/library/ms724833%28v=vs.85%29.aspx
+ const SZCSDVERSIONLENGTH = 128;
+ const OSVERSIONINFOEXW = new ctypes.StructType("OSVERSIONINFOEXW", [
+ { dwOSVersionInfoSize: DWORD },
+ { dwMajorVersion: DWORD },
+ { dwMinorVersion: DWORD },
+ { dwBuildNumber: DWORD },
+ { dwPlatformId: DWORD },
+ { szCSDVersion: ctypes.ArrayType(WCHAR, SZCSDVERSIONLENGTH) },
+ { wServicePackMajor: WORD },
+ { wServicePackMinor: WORD },
+ { wSuiteMask: WORD },
+ { wProductType: BYTE },
+ { wReserved: BYTE },
+ ]);
+
+ let kernel32 = ctypes.open("kernel32");
+ try {
+ let GetVersionEx = kernel32.declare(
+ "GetVersionExW",
+ ctypes.winapi_abi,
+ BOOL,
+ OSVERSIONINFOEXW.ptr
+ );
+ let winVer = OSVERSIONINFOEXW();
+ winVer.dwOSVersionInfoSize = OSVERSIONINFOEXW.size;
+
+ if (0 === GetVersionEx(winVer.address())) {
+ throw new Error("Failure in GetVersionEx (returned 0)");
+ }
+
+ return {
+ servicePackMajor: winVer.wServicePackMajor,
+ servicePackMinor: winVer.wServicePackMinor,
+ buildNumber: winVer.dwBuildNumber,
+ };
+ } catch (e) {
+ return UNKNOWN_VERSION_INFO;
+ } finally {
+ kernel32.close();
+ }
+}
+
+/* This is where functions related to the 3 pane window are kept */
+
+// from MailNewsTypes.h
+var kMailCheckOncePrefName = "mail.startup.enabledMailCheckOnce";
+
+/**
+ * Tracks whether the right mouse button changed the selection or not. If the
+ * user right clicks on the selection, it stays the same. If they click outside
+ * of it, we alter the selection (but not the current index) to be the row they
+ * clicked on.
+ *
+ * The value of this variable is an object with "view" and "selection" keys
+ * and values. The view value is the view whose selection we saved off, and
+ * the selection value is the selection object we saved off.
+ */
+var gRightMouseButtonSavedSelection = null;
+var gNewAccountToLoad = null;
+
+// The object in charge of managing the mail summary pane
+var gSummaryFrameManager;
+
+/**
+ * Called on startup if there are no accounts.
+ */
+function verifyOpenAccountHubTab() {
+ let suppressDialogs = Services.prefs.getBoolPref(
+ "mail.provider.suppress_dialog_on_startup",
+ false
+ );
+
+ if (suppressDialogs) {
+ // Looks like we were in the middle of filling out an account form. We
+ // won't display the dialogs in that case.
+ Services.prefs.clearUserPref("mail.provider.suppress_dialog_on_startup");
+ loadPostAccountWizard();
+ return;
+ }
+
+ openAccountSetupTab();
+}
+
+let _resolveDelayedStartup;
+var delayedStartupPromise = new Promise(resolve => {
+ _resolveDelayedStartup = resolve;
+});
+
+var gMailInit = {
+ onBeforeInitialXULLayout() {
+ // Set a sane starting width/height for all resolutions on new profiles.
+ // Do this before the window loads.
+ if (!document.documentElement.hasAttribute("width")) {
+ const TARGET_WIDTH = 1280;
+ let defaultWidth = Math.min(screen.availWidth * 0.9, TARGET_WIDTH);
+ let defaultHeight = screen.availHeight;
+
+ document.documentElement.setAttribute("width", defaultWidth);
+ document.documentElement.setAttribute("height", defaultHeight);
+
+ // On small screens, default to maximized state.
+ if (defaultWidth < TARGET_WIDTH) {
+ document.documentElement.setAttribute("sizemode", "maximized");
+ }
+ // Make sure we're safe at the left/top edge of screen
+ document.documentElement.setAttribute("screenX", screen.availLeft);
+ document.documentElement.setAttribute("screenY", screen.availTop);
+ }
+
+ // Run menubar initialization first, to avoid TabsInTitlebar code picking
+ // up mutations from it and causing a reflow.
+ AutoHideMenubar.init();
+ TabsInTitlebar.init();
+
+ if (AppConstants.platform == "win") {
+ // On Win8 set an attribute when the window frame color is too dark for black text.
+ if (
+ window.matchMedia("(-moz-platform: windows-win8)").matches &&
+ window.matchMedia("(-moz-windows-default-theme)").matches
+ ) {
+ let { Windows8WindowFrameColor } = ChromeUtils.importESModule(
+ "resource:///modules/Windows8WindowFrameColor.sys.mjs"
+ );
+ let windowFrameColor = new Color(...Windows8WindowFrameColor.get());
+ // Default to black for foreground text.
+ if (!windowFrameColor.isContrastRatioAcceptable(new Color(0, 0, 0))) {
+ document.documentElement.setAttribute("darkwindowframe", "true");
+ }
+ } else if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ // 17763 is the build number of Windows 10 version 1809
+ if (getWindowsVersionInfo().buildNumber < 17763) {
+ document.documentElement.setAttribute(
+ "always-use-accent-color-for-window-border",
+ ""
+ );
+ }
+ }
+ }
+
+ // Call this after we set attributes that might change toolbars' computed
+ // text color.
+ ToolbarIconColor.init();
+ },
+
+ /**
+ * Called on startup to initialize various parts of the main window.
+ * Most of this should be moved out into _delayedStartup or only
+ * initialized when needed.
+ */
+ onLoad() {
+ CreateMailWindowGlobals();
+
+ if (!Services.policies.isAllowed("devtools")) {
+ let devtoolsMenu = document.getElementById("devtoolsMenu");
+ if (devtoolsMenu) {
+ devtoolsMenu.hidden = true;
+ }
+ }
+
+ // - initialize tabmail system
+ // Do this before loadPostAccountWizard since that code selects the first
+ // folder for display, and we want gFolderDisplay setup and ready to handle
+ // that event chain.
+ // Also, we definitely need to register the tab type prior to the call to
+ // specialTabs.openSpecialTabsOnStartup below.
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail) {
+ // mailTabType is defined in mailTabs.js
+ tabmail.registerTabType(mailTabType);
+ // glodaFacetTab* in glodaFacetTab.js
+ tabmail.registerTabType(glodaFacetTabType);
+ tabmail.registerTabMonitor(GlodaSearchBoxTabMonitor);
+ tabmail.openFirstTab();
+ }
+
+ // This also registers the contentTabType ("contentTab")
+ specialTabs.openSpecialTabsOnStartup();
+ tabmail.registerTabType(addressBookTabType);
+ tabmail.registerTabType(preferencesTabType);
+ // provisionerCheckoutTabType is defined in provisionerCheckout.js
+ tabmail.registerTabType(provisionerCheckoutTabType);
+
+ // Depending on the pref, hide/show the gloda toolbar search widgets.
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gGlodaEnabled",
+ "mailnews.database.global.indexer.enabled",
+ true,
+ (pref, oldVal, newVal) => {
+ for (let widget of document.querySelectorAll(".gloda-search-widget")) {
+ widget.hidden = !newVal;
+ }
+ }
+ );
+ for (let widget of document.querySelectorAll(".gloda-search-widget")) {
+ widget.hidden = !this.gGlodaEnabled;
+ }
+
+ window.addEventListener("AppCommand", HandleAppCommandEvent, true);
+
+ this._boundDelayedStartup = this._delayedStartup.bind(this);
+ window.addEventListener("MozAfterPaint", this._boundDelayedStartup);
+
+ // Listen for the messages sent to the main 3 pane window.
+ window.addEventListener("message", this._onMessageReceived);
+ },
+
+ _cancelDelayedStartup() {
+ window.removeEventListener("MozAfterPaint", this._boundDelayedStartup);
+ this._boundDelayedStartup = null;
+ },
+
+ /**
+ * Handle the messages sent via postMessage() method to the main 3 pane
+ * window.
+ *
+ * @param {Event} event - The message event.
+ */
+ _onMessageReceived(event) {
+ switch (event.data) {
+ case "account-created":
+ case "account-created-in-backend":
+ case "account-created-from-provisioner":
+ // Set the pref to false in case it was previously changed.
+ Services.prefs.setBoolPref("app.use_without_mail_account", false);
+ loadPostAccountWizard();
+
+ // Always update the mail UI to guarantee all the panes are visible even
+ // if the mail tab is not the currently active tab.
+ updateMailPaneUI();
+ break;
+
+ case "account-setup-closed":
+ // The user closed the account setup after a successful run. Make sure
+ // to focus on the primary mail tab.
+ switchToMailTab();
+ gSpacesToolbar.onLoad();
+ // Trigger the integration dialog if necessary.
+ showSystemIntegrationDialog();
+ break;
+
+ case "account-setup-dismissed":
+ // The user closed the account setup before completing it. Be sure to
+ // initialize the few important areas we need.
+ if (!gSpacesToolbar.isLoaded) {
+ loadPostAccountWizard();
+ }
+ break;
+
+ case "open-account-setup-tab":
+ openAccountSetupTab();
+ break;
+ default:
+ break;
+ }
+ },
+
+ /**
+ * Delayed startup happens after the first paint of the window. Anything
+ * that can be delayed until after paint, should be to help give the
+ * illusion that Thunderbird is starting faster.
+ *
+ * Note: this only runs for the main 3 pane window.
+ */
+ _delayedStartup() {
+ this._cancelDelayedStartup();
+
+ MailOfflineMgr.init();
+
+ BondOpenPGP.init();
+
+ PanelUI.init();
+ gExtensionsNotifications.init();
+
+ Services.search.init();
+
+ PeriodicFilterManager.setupFiltering();
+ msgDBCacheManager.init();
+
+ this.delayedStartupFinished = true;
+ _resolveDelayedStartup(window);
+ Services.obs.notifyObservers(window, "browser-delayed-startup-finished");
+
+ // Notify observer to resolve the browserStartupPromise, which is used for the
+ // delayed background startup of WebExtensions.
+ Services.obs.notifyObservers(window, "extensions-late-startup");
+
+ this._loadComponentsAtStartup();
+ },
+
+ /**
+ * Load all the necessary components to make Thunderbird usable before
+ * checking for existing accounts.
+ */
+ async _loadComponentsAtStartup() {
+ updateTroubleshootMenuItem();
+ // The calendar component needs to be loaded before restoring any tabs.
+ await loadCalendarComponent();
+
+ // Don't trigger the existing account verification if the user wants to use
+ // Thunderbird without an email account.
+ if (!Services.prefs.getBoolPref("app.use_without_mail_account", false)) {
+ // Load the Mail UI only if we already have at least one account configured
+ // otherwise the verifyExistingAccounts will trigger the account wizard.
+ if (verifyExistingAccounts()) {
+ switchToMailTab();
+ await loadPostAccountWizard();
+ }
+ } else {
+ // Run the tabs restore method here since we're skipping the loading of
+ // the Mail UI which would have taken care of this to properly handle
+ // opened folders or messages in tabs.
+ await atStartupRestoreTabs(false);
+ gSpacesToolbar.onLoad();
+ }
+
+ // Show the end of year donation appeal page.
+ if (this.shouldShowEOYDonationAppeal()) {
+ // Add a timeout to prevent opening the browser immediately at startup.
+ setTimeout(this.showEOYDonationAppeal, 2000);
+ }
+ },
+
+ /**
+ * Called by messenger.xhtml:onunload, the 3-pane window inside of tabs window.
+ * It's being unloaded! Right now!
+ */
+ onUnload() {
+ Services.obs.notifyObservers(window, "mail-unloading-messenger");
+
+ if (gRightMouseButtonSavedSelection) {
+ // Avoid possible cycle leaks.
+ gRightMouseButtonSavedSelection.view = null;
+ gRightMouseButtonSavedSelection = null;
+ }
+
+ SessionStoreManager.unloadingWindow(window);
+ TabsInTitlebar.uninit();
+ ToolbarIconColor.uninit();
+ gSpacesToolbar.onUnload();
+
+ document.getElementById("tabmail")._teardown();
+
+ OnMailWindowUnload();
+ },
+
+ /**
+ * Check if we can trigger the opening of the donation appeal page.
+ *
+ * @returns {boolean} - True if the donation appeal page should be opened.
+ */
+ shouldShowEOYDonationAppeal() {
+ let currentEOY = Services.prefs.getIntPref("app.donation.eoy.version", 1);
+ let viewedEOY = Services.prefs.getIntPref(
+ "app.donation.eoy.version.viewed",
+ 0
+ );
+
+ // True if the user never saw the donation appeal, this is not a new
+ // profile (since users are already prompted to donate after downloading),
+ // and we're not running tests.
+ return (
+ viewedEOY < currentEOY &&
+ !specialTabs.shouldShowPolicyNotification() &&
+ !Cu.isInAutomation
+ );
+ },
+
+ /**
+ * Open the end of year appeal in a new web browser page. We don't open this
+ * in a tab due to the complexity of the donation site, and we don't want to
+ * handle that inside Thunderbird.
+ */
+ showEOYDonationAppeal() {
+ let url = Services.prefs.getStringPref("app.donation.eoy.url");
+ let protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ protocolSvc.loadURI(Services.io.newURI(url));
+
+ let currentEOY = Services.prefs.getIntPref("app.donation.eoy.version", 1);
+ Services.prefs.setIntPref("app.donation.eoy.version.viewed", currentEOY);
+ },
+};
+
+/**
+ * Called at startup to verify if we have ny existing account, even if invalid,
+ * and if not, it will trigger the Account Hub in a tab.
+ *
+ * @returns {boolean} - True if we have at least one existing account.
+ */
+function verifyExistingAccounts() {
+ try {
+ // Migrate quoting preferences from global to per account. This function
+ // returns true if it had to migrate, which we will use to mean this is a
+ // just migrated or new profile.
+ let newProfile = migrateGlobalQuotingPrefs(
+ MailServices.accounts.allIdentities
+ );
+
+ // If there are no accounts, or all accounts are "invalid" then kick off the
+ // account migration. Or if this is a new (to Mozilla) profile. MCD can set
+ // up accounts without the profile being used yet.
+ if (newProfile) {
+ // Check if MCD is configured. If not, say this is not a new profile so
+ // that we don't accidentally remigrate non MCD profiles.
+ var adminUrl = Services.prefs.getCharPref(
+ "autoadmin.global_config_url",
+ ""
+ );
+ if (!adminUrl) {
+ newProfile = false;
+ }
+ }
+
+ let accounts = MailServices.accounts.accounts;
+ let invalidAccounts = getInvalidAccounts(accounts);
+ // Trigger the new account configuration wizard only if we don't have any
+ // existing account, not even if we have at least one invalid account.
+ if (
+ (newProfile && !accounts.length) ||
+ accounts.length == invalidAccounts.length ||
+ (invalidAccounts.length > 0 &&
+ invalidAccounts.length == accounts.length &&
+ invalidAccounts[0])
+ ) {
+ verifyOpenAccountHubTab();
+ return false;
+ }
+
+ let localFoldersExists;
+ try {
+ localFoldersExists = MailServices.accounts.localFoldersServer;
+ } catch (ex) {
+ localFoldersExists = false;
+ }
+
+ // We didn't trigger the account configuration wizard, so we need to verify
+ // that local folders exists.
+ if (!localFoldersExists && requireLocalFoldersAccount()) {
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ return true;
+ } catch (ex) {
+ dump(`Error verifying accounts: ${ex}`);
+ return false;
+ }
+}
+
+/**
+ * Switch the view to the first Mail tab if the currently selected tab is not
+ * the first Mail tab.
+ */
+function switchToMailTab() {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail?.selectedTab.mode.name != "folder") {
+ tabmail.switchToTab(0);
+ }
+}
+
+/**
+ * Trigger the initialization of the entire UI. Called after the okCallback of
+ * the emailWizard during a first run, or directly from the accountProvisioner
+ * in case a user configures a new email account on first run.
+ */
+async function loadPostAccountWizard() {
+ InitMsgWindow();
+
+ MigrateJunkMailSettings();
+ MigrateFolderViews();
+ MigrateOpenMessageBehavior();
+
+ MailServices.accounts.setSpecialFolders();
+
+ try {
+ MailServices.accounts.loadVirtualFolders();
+ } catch (e) {
+ console.error(e);
+ }
+
+ // Init the mozINewMailListener service (MailNotificationManager) before
+ // any new mails are fetched.
+ // MailNotificationManager triggers mozINewMailNotificationService
+ // init as well.
+ Cc["@mozilla.org/mail/notification-manager;1"].getService(
+ Ci.mozINewMailListener
+ );
+
+ // Restore the previous folder selection before shutdown, or select the first
+ // inbox folder of a newly created account.
+ await selectFirstFolder();
+
+ gSpacesToolbar.onLoad();
+}
+
+/**
+ * Check if we need to show the system integration dialog before notifying the
+ * application that the startup process is completed.
+ */
+function showSystemIntegrationDialog() {
+ // Check the shell service.
+ let shellService;
+ try {
+ shellService = Cc["@mozilla.org/mail/shell-service;1"].getService(
+ Ci.nsIShellService
+ );
+ } catch (ex) {}
+ let defaultAccount = MailServices.accounts.defaultAccount;
+
+ // Load the search integration module.
+ let { SearchIntegration } = ChromeUtils.import(
+ "resource:///modules/SearchIntegration.jsm"
+ );
+
+ // Show the default client dialog only if
+ // EITHER: we have at least one account, and we aren't already the default
+ // for mail,
+ // OR: we have the search integration module, the OS version is suitable,
+ // and the first run hasn't already been completed.
+ // Needs to be shown outside the he normal load sequence so it doesn't appear
+ // before any other displays, in the wrong place of the screen.
+ if (
+ (shellService &&
+ defaultAccount &&
+ shellService.shouldCheckDefaultClient &&
+ !shellService.isDefaultClient(true, Ci.nsIShellService.MAIL)) ||
+ (SearchIntegration &&
+ !SearchIntegration.osVersionTooLow &&
+ !SearchIntegration.osComponentsNotRunning &&
+ !SearchIntegration.firstRunDone)
+ ) {
+ window.openDialog(
+ "chrome://messenger/content/systemIntegrationDialog.xhtml",
+ "SystemIntegration",
+ "modal,centerscreen,chrome,resizable=no"
+ );
+ // On Windows, there seems to be a delay between setting TB as the
+ // default client, and the isDefaultClient check succeeding.
+ if (shellService.isDefaultClient(true, Ci.nsIShellService.MAIL)) {
+ Services.obs.notifyObservers(window, "mail:setAsDefault");
+ }
+ }
+}
+
+/**
+ * Properly select the starting folder or message header if we have one.
+ */
+async function selectFirstFolder() {
+ let startFolderURI = null;
+ let startMsgHdr = null;
+
+ if ("arguments" in window && window.arguments.length > 0) {
+ let arg0 = window.arguments[0];
+ // If the argument is a string, it is folder URI.
+ if (typeof arg0 == "string") {
+ startFolderURI = arg0;
+ } else if (arg0) {
+ // arg0 is an object
+ if ("wrappedJSObject" in arg0 && arg0.wrappedJSObject) {
+ arg0 = arg0.wrappedJSObject;
+ }
+ startMsgHdr = "msgHdr" in arg0 ? arg0.msgHdr : null;
+ }
+ }
+
+ // Don't try to be smart with this because we need the loadStartFolder()
+ // method to run even if startFolderURI is null otherwise our UI won't
+ // properly restore.
+ if (startMsgHdr) {
+ await loadStartMsgHdr(startMsgHdr);
+ } else {
+ await loadStartFolder(startFolderURI);
+ }
+}
+
+function HandleAppCommandEvent(evt) {
+ evt.stopPropagation();
+ switch (evt.command) {
+ case "Back":
+ goDoCommand("cmd_goBack");
+ break;
+ case "Forward":
+ goDoCommand("cmd_goForward");
+ break;
+ case "Stop":
+ msgWindow.StopUrls();
+ break;
+ case "Bookmarks":
+ toAddressBook();
+ break;
+ case "Home":
+ case "Reload":
+ default:
+ break;
+ }
+}
+
+/**
+ * Called by the session store manager periodically and at shutdown to get
+ * the state of this window for persistence.
+ */
+function getWindowStateForSessionPersistence() {
+ let tabmail = document.getElementById("tabmail");
+ let tabsState = tabmail.persistTabs();
+ return { type: "3pane", tabs: tabsState };
+}
+
+/**
+ * Attempt to restore the previous tab states.
+ *
+ * @param {boolean} aDontRestoreFirstTab - If this is true, the first tab will
+ * not be restored, and will continue to retain focus at the end. This is
+ * needed if the window was opened with a folder or a message as an argument.
+ * @returns true if the restoration was successful, false otherwise.
+ */
+async function atStartupRestoreTabs(aDontRestoreFirstTab) {
+ let state = await SessionStoreManager.loadingWindow(window);
+ if (state) {
+ let tabsState = state.tabs;
+ let tabmail = document.getElementById("tabmail");
+ try {
+ tabmail.restoreTabs(tabsState, aDontRestoreFirstTab);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ // It's now safe to load extra Tabs.
+ loadExtraTabs();
+
+ // Note: The tabs have not finished loading at this point.
+ SessionStoreManager._restored = true;
+ Services.obs.notifyObservers(window, "mail-tabs-session-restored");
+
+ return !!state;
+}
+
+/**
+ * Loads and restores tabs upon opening a window by evaluating window.arguments[1].
+ *
+ * The type of the object is specified by it's action property. It can be
+ * either "restore" or "open". "restore" invokes tabmail.restoreTab() for each
+ * item in the tabs array. While "open" invokes tabmail.openTab() for each item.
+ *
+ * In case a tab can't be restored it will fail silently
+ *
+ * the object need at least the following properties:
+ *
+ * {
+ * action = "restore" | "open"
+ * tabs = [];
+ * }
+ *
+ */
+function loadExtraTabs() {
+ if (!("arguments" in window) || window.arguments.length < 2) {
+ return;
+ }
+
+ let tab = window.arguments[1];
+ if (!tab || typeof tab != "object") {
+ return;
+ }
+
+ if ("wrappedJSObject" in tab) {
+ tab = tab.wrappedJSObject;
+ }
+
+ let tabmail = document.getElementById("tabmail");
+
+ // we got no action, so suppose its "legacy" code
+ if (!("action" in tab)) {
+ if ("tabType" in tab) {
+ tabmail.openTab(tab.tabType, tab.tabParams);
+ }
+ return;
+ }
+
+ if (!("tabs" in tab)) {
+ return;
+ }
+
+ // this is used if a tab is detached to a new window.
+ if (tab.action == "restore") {
+ for (let i = 0; i < tab.tabs.length; i++) {
+ tabmail.restoreTab(tab.tabs[i]);
+ }
+
+ // we currently do not support opening in background or opening a
+ // special position. So select the last tab opened.
+ tabmail.switchToTab(tabmail.tabInfo[tabmail.tabInfo.length - 1]);
+ return;
+ }
+
+ if (tab.action == "open") {
+ for (let i = 0; i < tab.tabs.length; i++) {
+ if ("tabType" in tab.tabs[i]) {
+ tabmail.openTab(tab.tabs[i].tabType, tab.tabs[i].tabParams);
+ }
+ }
+ }
+}
+
+/**
+ * Loads the given message header at window open. Exactly one out of this and
+ * |loadStartFolder| should be called.
+ *
+ * @param aStartMsgHdr The message header to load at window open
+ */
+async function loadStartMsgHdr(aStartMsgHdr) {
+ // We'll just clobber the default tab
+ await atStartupRestoreTabs(true);
+
+ MsgDisplayMessageInFolderTab(aStartMsgHdr);
+}
+
+async function loadStartFolder(initialUri) {
+ var defaultServer = null;
+ var startFolder;
+ var isLoginAtStartUpEnabled = false;
+
+ // If a URI was explicitly specified, we'll just clobber the default tab
+ let loadFolder = !(await atStartupRestoreTabs(!!initialUri));
+
+ if (initialUri) {
+ loadFolder = true;
+ }
+
+ // First get default account
+ try {
+ if (initialUri) {
+ startFolder = MailUtils.getOrCreateFolder(initialUri);
+ } else {
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (!defaultAccount) {
+ return;
+ }
+
+ defaultServer = defaultAccount.incomingServer;
+ var rootMsgFolder = defaultServer.rootMsgFolder;
+
+ startFolder = rootMsgFolder;
+
+ // Enable check new mail once by turning checkmail pref 'on' to bring
+ // all users to one plane. This allows all users to go to Inbox. User can
+ // always go to server settings panel and turn off "Check for new mail at startup"
+ if (!Services.prefs.getBoolPref(kMailCheckOncePrefName)) {
+ Services.prefs.setBoolPref(kMailCheckOncePrefName, true);
+ defaultServer.loginAtStartUp = true;
+ }
+
+ // Get the user pref to see if the login at startup is enabled for default account
+ isLoginAtStartUpEnabled = defaultServer.loginAtStartUp;
+
+ // Get Inbox only if login at startup is enabled.
+ if (isLoginAtStartUpEnabled) {
+ // now find Inbox
+ var inboxFolder = rootMsgFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ if (!inboxFolder) {
+ return;
+ }
+
+ startFolder = inboxFolder;
+ }
+ }
+
+ // it is possible we were given an initial uri and we need to subscribe or try to add
+ // the folder. i.e. the user just clicked on a news folder they aren't subscribed to from a browser
+ // the news url comes in here.
+
+ // Perform biff on the server to check for new mail, except for imap
+ // or a pop3 account that is deferred or deferred to,
+ // or the case where initialUri is non-null (non-startup)
+ if (
+ !initialUri &&
+ isLoginAtStartUpEnabled &&
+ !defaultServer.isDeferredTo &&
+ defaultServer.rootFolder == defaultServer.rootMsgFolder
+ ) {
+ defaultServer.performBiff(msgWindow);
+ }
+ if (loadFolder) {
+ let tab = document.getElementById("tabmail")?.tabInfo[0];
+ tab.chromeBrowser.addEventListener(
+ "load",
+ () => (tab.folder = startFolder),
+ true
+ );
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+
+ MsgGetMessagesForAllServers(defaultServer);
+
+ if (MailOfflineMgr.isOnline()) {
+ // Check if we shut down offline, and restarted online, in which case
+ // we may have offline events to playback. Since this is not a pref
+ // the user should set, it's not in mailnews.js, so we need a try catch.
+ let playbackOfflineEvents = Services.prefs.getBoolPref(
+ "mailnews.playback_offline",
+ false
+ );
+ if (playbackOfflineEvents) {
+ Services.prefs.setBoolPref("mailnews.playback_offline", false);
+ MailOfflineMgr.offlineManager.goOnline(false, true, msgWindow);
+ }
+
+ // If appropriate, send unsent messages. This may end up prompting the user,
+ // so we need to get it out of the flow of the normal load sequence.
+ setTimeout(function () {
+ if (MailOfflineMgr.shouldSendUnsentMessages()) {
+ SendUnsentMessages();
+ }
+ }, 0);
+ }
+}
+
+function OpenMessageInNewTab(msgHdr, tabParams = {}) {
+ if (!msgHdr) {
+ return;
+ }
+
+ if (tabParams.background === undefined) {
+ tabParams.background = Services.prefs.getBoolPref(
+ "mail.tabs.loadInBackground"
+ );
+ if (tabParams.event?.shiftKey) {
+ tabParams.background = !tabParams.background;
+ }
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("mailMessageTab", {
+ ...tabParams,
+ messageURI: msgHdr.folder.getUriForMsg(msgHdr),
+ });
+}
+
+function GetSelectedMsgFolders() {
+ let tabInfo = document.getElementById("tabmail").currentTabInfo;
+ if (tabInfo.mode.name == "mail3PaneTab") {
+ let folder = tabInfo.folder;
+ if (folder) {
+ return [folder];
+ }
+ }
+ return [];
+}
+
+function SelectFolder(folderUri) {
+ // TODO: Replace this.
+}
+
+function ReloadMessage() {}
+
+// Some of the per account junk mail settings have been
+// converted to global prefs. Let's try to migrate some
+// of those settings from the default account.
+function MigrateJunkMailSettings() {
+ var junkMailSettingsVersion = Services.prefs.getIntPref("mail.spam.version");
+ if (!junkMailSettingsVersion) {
+ // Get the default account, check to see if we have values for our
+ // globally migrated prefs.
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ // we only care about
+ var prefix = "mail.server." + defaultAccount.incomingServer.key + ".";
+ if (Services.prefs.prefHasUserValue(prefix + "manualMark")) {
+ Services.prefs.setBoolPref(
+ "mail.spam.manualMark",
+ Services.prefs.getBoolPref(prefix + "manualMark")
+ );
+ }
+ if (Services.prefs.prefHasUserValue(prefix + "manualMarkMode")) {
+ Services.prefs.setIntPref(
+ "mail.spam.manualMarkMode",
+ Services.prefs.getIntPref(prefix + "manualMarkMode")
+ );
+ }
+ if (Services.prefs.prefHasUserValue(prefix + "spamLoggingEnabled")) {
+ Services.prefs.setBoolPref(
+ "mail.spam.logging.enabled",
+ Services.prefs.getBoolPref(prefix + "spamLoggingEnabled")
+ );
+ }
+ if (Services.prefs.prefHasUserValue(prefix + "markAsReadOnSpam")) {
+ Services.prefs.setBoolPref(
+ "mail.spam.markAsReadOnSpam",
+ Services.prefs.getBoolPref(prefix + "markAsReadOnSpam")
+ );
+ }
+ }
+ // bump the version so we don't bother doing this again.
+ Services.prefs.setIntPref("mail.spam.version", 1);
+ }
+}
+
+// The first time a user runs a build that supports folder views, pre-populate the favorite folders list
+// with the existing INBOX folders.
+function MigrateFolderViews() {
+ var folderViewsVersion = Services.prefs.getIntPref(
+ "mail.folder.views.version"
+ );
+ if (!folderViewsVersion) {
+ for (let server of MailServices.accounts.allServers) {
+ if (server) {
+ let inbox = MailUtils.getInboxFolder(server);
+ if (inbox) {
+ inbox.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ }
+ }
+ }
+ Services.prefs.setIntPref("mail.folder.views.version", 1);
+ }
+}
+
+// Do a one-time migration of the old mailnews.reuse_message_window pref to the
+// newer mail.openMessageBehavior. This does the migration only if the old pref
+// is defined.
+function MigrateOpenMessageBehavior() {
+ let openMessageBehaviorVersion = Services.prefs.getIntPref(
+ "mail.openMessageBehavior.version"
+ );
+ if (!openMessageBehaviorVersion) {
+ // Don't touch this if it isn't defined
+ if (
+ Services.prefs.getPrefType("mailnews.reuse_message_window") ==
+ Ci.nsIPrefBranch.PREF_BOOL
+ ) {
+ if (Services.prefs.getBoolPref("mailnews.reuse_message_window")) {
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.EXISTING_WINDOW
+ );
+ } else {
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_TAB
+ );
+ }
+ }
+
+ Services.prefs.setIntPref("mail.openMessageBehavior.version", 1);
+ }
+}
+
+function messageFlavorDataProvider() {}
+
+messageFlavorDataProvider.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(aTransferable, aFlavor, aData) {
+ if (aFlavor !== "application/x-moz-file-promise") {
+ return;
+ }
+ let fileUriPrimitive = {};
+ aTransferable.getTransferData(
+ "application/x-moz-file-promise-url",
+ fileUriPrimitive
+ );
+
+ let fileUriStr = fileUriPrimitive.value.QueryInterface(
+ Ci.nsISupportsString
+ );
+ let fileUri = Services.io.newURI(fileUriStr.data);
+ let fileUrl = fileUri.QueryInterface(Ci.nsIURL);
+ let fileName = fileUrl.fileName.replace(/(.{74}).*(.{10})$/u, "$1...$2");
+
+ let destDirPrimitive = {};
+ aTransferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ destDirPrimitive
+ );
+ let destDirectory = destDirPrimitive.value.QueryInterface(Ci.nsIFile);
+ let file = destDirectory.clone();
+ file.append(fileName);
+
+ let messageUriPrimitive = {};
+ aTransferable.getTransferData("text/x-moz-message", messageUriPrimitive);
+ let messageUri = messageUriPrimitive.value.QueryInterface(
+ Ci.nsISupportsString
+ );
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ messenger.saveAs(
+ messageUri.data,
+ true,
+ null,
+ decodeURIComponent(file.path),
+ true
+ );
+ },
+};
+
+var TabsInTitlebar = {
+ init() {
+ this._readPref();
+ Services.prefs.addObserver(this._drawInTitlePref, this);
+
+ window.addEventListener("resolutionchange", this);
+ window.addEventListener("resize", this);
+
+ this._initialized = true;
+ this.update();
+ },
+
+ allowedBy(condition, allow) {
+ if (allow) {
+ if (condition in this._disallowed) {
+ delete this._disallowed[condition];
+ this.update();
+ }
+ } else if (!(condition in this._disallowed)) {
+ this._disallowed[condition] = null;
+ this.update();
+ }
+ },
+
+ get systemSupported() {
+ let isSupported = false;
+ switch (AppConstants.MOZ_WIDGET_TOOLKIT) {
+ case "windows":
+ case "cocoa":
+ isSupported = true;
+ break;
+ case "gtk":
+ isSupported = window.matchMedia("(-moz-gtk-csd-available)");
+ break;
+ }
+ delete this.systemSupported;
+ return (this.systemSupported = isSupported);
+ },
+
+ get enabled() {
+ return document.documentElement.getAttribute("tabsintitlebar") == "true";
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ this._readPref();
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "resolutionchange":
+ if (aEvent.target == window) {
+ this.update();
+ }
+ break;
+ case "resize":
+ // The spaces toolbar needs special styling for the fullscreen mode.
+ gSpacesToolbar.onWindowResize();
+ if (window.fullScreen || aEvent.target != window) {
+ break;
+ }
+ // We use resize events because the window is not ready after
+ // sizemodechange events. However, we only care about the event when
+ // the sizemode is different from the last time we updated the
+ // appearance of the tabs in the titlebar.
+ let sizemode = document.documentElement.getAttribute("sizemode");
+ if (this._lastSizeMode == sizemode) {
+ break;
+ }
+ let oldSizeMode = this._lastSizeMode;
+ this._lastSizeMode = sizemode;
+ // Don't update right now if we are leaving fullscreen, since the UI is
+ // still changing in the consequent "fullscreen" event. Code there will
+ // call this function again when everything is ready.
+ // See browser-fullScreen.js: FullScreen.toggle and bug 1173768.
+ if (oldSizeMode == "fullscreen") {
+ break;
+ }
+ this.update();
+ break;
+ }
+ },
+
+ _initialized: false,
+ _disallowed: {},
+ _drawInTitlePref: "mail.tabs.drawInTitlebar",
+ _lastSizeMode: null,
+
+ _readPref() {
+ // check is only true when drawInTitlebar=true
+ let check = Services.prefs.getBoolPref(this._drawInTitlePref);
+ this.allowedBy("pref", check);
+ },
+
+ update() {
+ if (!this._initialized || window.fullScreen) {
+ return;
+ }
+
+ let allowed =
+ this.systemSupported && Object.keys(this._disallowed).length == 0;
+
+ if (
+ document.documentElement.getAttribute("chromehidden")?.includes("toolbar")
+ ) {
+ // Don't draw in titlebar in case of a popup window.
+ allowed = false;
+ }
+
+ if (allowed) {
+ document.documentElement.setAttribute("tabsintitlebar", "true");
+ if (AppConstants.platform == "macosx") {
+ document.documentElement.setAttribute("chromemargin", "0,-1,-1,-1");
+ document.documentElement.removeAttribute("drawtitle");
+ } else {
+ document.documentElement.setAttribute("chromemargin", "0,2,2,2");
+ }
+ } else {
+ document.documentElement.removeAttribute("tabsintitlebar");
+ document.documentElement.removeAttribute("chromemargin");
+ if (AppConstants.platform == "macosx") {
+ document.documentElement.setAttribute("drawtitle", "true");
+ }
+ }
+ },
+
+ uninit() {
+ this._initialized = false;
+ Services.prefs.removeObserver(this._drawInTitlePref, this);
+ },
+};
+
+var BrowserAddonUI = {
+ async promptRemoveExtension(addon) {
+ let { name } = addon;
+ let [title, btnTitle] = await document.l10n.formatValues([
+ {
+ id: "addon-removal-title",
+ args: { name },
+ },
+ {
+ id: "addon-removal-confirmation-button",
+ },
+ ]);
+ let {
+ BUTTON_TITLE_IS_STRING: titleString,
+ BUTTON_TITLE_CANCEL: titleCancel,
+ BUTTON_POS_0,
+ BUTTON_POS_1,
+ confirmEx,
+ } = Services.prompt;
+ let btnFlags = BUTTON_POS_0 * titleString + BUTTON_POS_1 * titleCancel;
+ let message = null;
+
+ if (!Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) {
+ message = await document.l10n.formatValue(
+ "addon-removal-confirmation-message",
+ {
+ name,
+ }
+ );
+ }
+
+ let checkboxState = { value: false };
+ let result = confirmEx(
+ window,
+ title,
+ message,
+ btnFlags,
+ btnTitle,
+ /* button1 */ null,
+ /* button2 */ null,
+ /* checkboxMessage */ null,
+ checkboxState
+ );
+
+ return { remove: result === 0, report: false };
+ },
+
+ async removeAddon(addonId) {
+ let addon = addonId && (await AddonManager.getAddonByID(addonId));
+ if (!addon || !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) {
+ return;
+ }
+
+ let { remove, report } = await this.promptRemoveExtension(addon);
+
+ if (remove) {
+ await addon.uninstall(report);
+ }
+ },
+};
diff --git a/comm/mail/base/content/messenger.xhtml b/comm/mail/base/content/messenger.xhtml
new file mode 100644
index 0000000000..cf58784374
--- /dev/null
+++ b/comm/mail/base/content/messenger.xhtml
@@ -0,0 +1,671 @@
+<?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/.
+
+#filter substitution
+#define MAIN_WINDOW
+<?xml-stylesheet href="chrome://messenger/skin/mailWindow1.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tagColors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/glodacomplete.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tabmail.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/popupPanel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?>
+<?xml-stylesheet href="chrome://chat/skin/imtooltip.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/panelUI.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/spacesToolbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/unifiedToolbar.css" type="text/css"?>
+
+<!-- Calendar CSS -->
+<?xml-stylesheet href="chrome://calendar/skin/calendar.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/calendar-event-dialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/today-pane.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/calendar-unifinder.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/calendar-task-tree.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar-task-view.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/calendar-views.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-alarms.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/widgets/minimonth.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/widgets/calendar-widgets.css" type="text/css"?>
+
+# All DTD information is stored in a separate file so that it can be shared by
+# hiddenWindowMac.xhtml.
+<!DOCTYPE html [
+#include messenger-doctype.inc.dtd
+]>
+
+<!--
+ - The 'what you think of when you think of thunderbird' window;
+ - 3-pane view inside of tabs.
+ -->
+<html id="messengerWindow" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ icon="messengerWindow"
+ titlemodifier="&titledefault.label;@PRE_RELEASE_SUFFIX@"
+ titlemenuseparator="&titleSeparator.label;"
+ defaultTabTitle="&defaultTabTitle.label;"
+ windowtype="mail:3pane"
+ macanimationtype="document"
+ screenX="10" screenY="10"
+ scrolling="false"
+ persist="screenX screenY width height sizemode"
+ toggletoolbar="true"
+ lightweightthemes="true"
+ fullscreenbutton="true"
+ calendar-deactivated="">
+<head>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/shortcuts.ftl" />
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="toolkit/main-window/findbar.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="toolkit/printing/printUI.ftl" />
+ <link rel="localization" href="messenger/menubar.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" />
+ <link rel="localization" href="messenger/openpgp/msgReadStatus.ftl"/>
+ <link rel="localization" href="calendar/calendar-widgets.ftl" />
+ <link rel="localization" href="calendar/calendar-context-menus.ftl" />
+ <link rel="localization" href="calendar/calendar-editable-item.ftl" />
+ <link rel="localization" href="messenger/chat.ftl" />
+ <link rel="localization" href="messenger/messageheader/headerFields.ftl" />
+ <link rel="localization" href="messenger/mailWidgets.ftl" />
+ <link rel="localization" href="messenger/unifiedToolbar.ftl" />
+ <link rel="localization" href="messenger/unifiedToolbarItems.ftl" />
+#ifdef NIGHTLY_BUILD
+ <link rel="localization" href="messenger/firefoxAccounts.ftl" />
+#endif
+
+ <title>&titledefault.label;@PRE_RELEASE_SUFFIX@</title>
+
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script>
+ <script defer="defer" src="chrome://messenger/content/selectionsummaries.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger.js"></script>
+ <script defer="defer" src="chrome://messenger/content/specialTabs.js"></script>
+ <script defer="defer" src="chrome://messenger/content/spacesToolbar.js"></script>
+ <script defer="defer" src="chrome://messenger/content/newmailaccount/provisionerCheckout.js"></script>
+ <script defer="defer" src="chrome://messenger/content/glodaFacetTab.js"></script>
+ <script defer="defer" src="chrome://messenger/content/searchBar.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail3PaneWindowCommands.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script>
+ <script defer="defer" src="chrome://messenger/content/accountUtils.js"></script>
+ <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script>
+ <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script>
+ <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-messenger.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/imStatusSelector.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/imContextMenu.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-conversation.js"></script>
+ <script defer="defer" src="chrome://messenger/content/addressbook/addressBookTab.js"></script>
+ <script defer="defer" src="chrome://messenger/content/preferences/preferencesTab.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCore.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindowOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailTabs.js"></script>
+ <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgViewPickerOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/newmailaccount/uriListener.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-conversation-info.js"></script>
+ <script defer="defer" src="chrome://gloda/content/autocomplete-richlistitem.js"></script>
+ <script defer="defer" src="chrome://gloda/content/glodacomplete.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-contact.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-group.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-imconv.js"></script>
+ <script defer="defer" src="chrome://messenger/content/tabmail-tab.js"></script>
+ <script defer="defer" src="chrome://messenger/content/tabmail-tabs.js"></script>
+ <script defer="defer" src="chrome://messenger/content/tabmail.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script>
+ <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script>
+#ifdef NIGHTLY_BUILD
+ <script defer="defer" src="chrome://messenger/content/sync.js"></script>
+#endif
+ <!-- panelUI.js is for the appmenus. -->
+ <script defer="defer" src="chrome://messenger/content/panelUI.js"></script>
+#ifdef XP_MACOSX
+ <script defer="defer" src="chrome://messenger/content/macMessengerMenu.js"></script>
+ <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script>
+#endif
+#ifdef XP_WIN
+ <script defer="defer" src="chrome://messenger/content/minimizeToTray.js"></script>
+#endif
+ <!-- calendar-management.js also needed for multiple calendar support and today pane -->
+ <script defer="defer" src="chrome://calendar/content/calendar-management.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-tabs.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-modes.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-day-label.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-clipboard.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/import-export.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/publish.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-chrome-startup.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/mouseoverPreviews.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-views-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-filter.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-base-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-dnd-widgets.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-editable-item.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-month-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-multiday-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-views.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-dnd-listener.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-statusbar.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-invitation-panel.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-minidate.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-modebox.js"></script>
+
+ <!-- NEEDED FOR TASK VIEW/LIST SUPPORT -->
+ <script defer="defer" src="chrome://calendar/content/calendar-task-editing.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-extract.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-invitations-manager.js"></script>
+
+ <!-- NEEDED FOR EVENT/TASK IN A TAB -->
+ <script defer="defer" src="chrome://calendar/content/calendar-item-panel.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-command-controller.js"></script>
+
+ <!-- NEEDED FOR EVENTS VIEW (UNIFINDER) -->
+ <script defer="defer" src="chrome://calendar/content/calendar-unifinder.js"></script>
+
+ <!-- NEEDED FOR TODAY PANE AND TASKS VIEW -->
+
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-task-tree-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/today-pane-agenda.js"></script>
+ <script defer="defer" src="chrome://calendar/content/today-pane.js"></script>
+
+ <!-- NEEDED FOR TASK VIEW -->
+ <script defer="defer" src="chrome://calendar/content/calendar-task-tree-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-task-tree.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-task-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-menus.js"></script>
+
+ <!-- NEEDED FOR MIGRATION CHECK AT INSTALL -->
+ <script defer="defer" src="chrome://calendar/content/calendar-migration.js"></script>
+ <script defer="defer" src="chrome://messenger/content/shortcutsOverlay.js"></script>
+
+ <script defer="defer" src="chrome://messenger/content/accountcreation/accountHub.js"></script>
+
+ <!-- Unified toolbar -->
+ <script type="module" defer="defer" src="chrome://messenger/content/unifiedtoolbar/unified-toolbar.mjs"></script>
+
+ <script>
+ window.onload = gMailInit.onLoad.bind(gMailInit);
+ window.onunload = gMailInit.onUnload.bind(gMailInit);
+
+ window.addEventListener("MozBeforeInitialXULLayout",
+ gMailInit.onBeforeInitialXULLayout.bind(gMailInit), { once: true });
+ </script>
+
+ <!-- Color customization for the folder pane. -->
+ <style id="folderColorsStyle"></style>
+ <style id="folderColorsStylePreview"></style>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+<stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+<stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+<commandset id="mailCommands">
+#include mainCommandSet.inc.xhtml
+ <commandset id="mailSearchMenuItems"/>
+ <commandset id="globalEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateGlobalEditMenuItems()"/>
+ <commandset id="selectEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateSelectEditMenuItems()"/>
+ <commandset id="undoEditMenuItems"
+ commandupdater="true"
+ events="undo"
+ oncommandupdate="goUpdateUndoEditMenuItems()"/>
+ <commandset id="clipboardEditMenuItems"
+ commandupdater="true"
+ events="clipboard"
+ oncommandupdate="goUpdatePasteMenuItems()"/>
+ <commandset id="webSearchItems"/>
+ <commandset id="browserCommands">
+ <!-- Browsing back and forth inside the add-on manager and on content tabs -->
+ <command id="Browser:Back"
+ oncommand="goDoCommand('Browser:Back');"/>
+ <command id="Browser:Forward"
+ oncommand="goDoCommand('Browser:Forward');"/>
+ </commandset>
+ <commandset id="attachmentCommands">
+ <command id="cmd_openAllAttachments"
+ oncommand="goDoCommand('cmd_openAllAttachments');"/>
+ <command id="cmd_saveAllAttachments"
+ oncommand="goDoCommand('cmd_saveAllAttachments');"/>
+ <command id="cmd_detachAllAttachments"
+ oncommand="goDoCommand('cmd_detachAllAttachments');"/>
+ <command id="cmd_deleteAllAttachments"
+ oncommand="goDoCommand('cmd_deleteAllAttachments');"/>
+ </commandset>
+ <commandset id="tasksCommands">
+ <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/>
+ <command id="cmd_newCard" oncommand="openNewCardDialog()"/>
+ </commandset>
+ <command id="cmd_close" oncommand="CloseTabOrWindow();"/>
+ <command id="cmd_CustomizeMailToolbar"
+ oncommand="customizeMailToolbarForTabType()"/>
+</commandset>
+
+#include ../../../calendar/base/content/calendar-commands.inc.xhtml
+
+<keyset id="browserKeys">
+#ifdef XP_MACOSX
+ <key id="key_goBackKb" keycode="VK_LEFT" oncommand="goDoCommand('Browser:Back');" modifiers="accel"/>
+ <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="goDoCommand('Browser:Forward');" modifiers="accel"/>
+#else
+ <key id="key_goBackKb" keycode="VK_LEFT" oncommand="goDoCommand('Browser:Back');" modifiers="alt" />
+ <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="goDoCommand('Browser:Forward');" modifiers="alt" />
+#endif
+</keyset>
+<keyset id="mailKeys">
+ <!-- Tab/F6 Keys -->
+ <key keycode="VK_TAB" oncommand="SwitchPaneFocus(event);" modifiers="control,shift"/>
+ <key keycode="VK_TAB" oncommand="SwitchPaneFocus(event);" modifiers="control"/>
+ <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="control,shift"/>
+ <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="control"/>
+ <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="shift"/>
+ <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);"/>
+#include mainKeySet.inc.xhtml
+ <keyset id="tasksKeys">
+#ifdef XP_MACOSX
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel,shift"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+#else
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+#endif
+ </keyset>
+</keyset>
+
+#include ../../../calendar/base/content/calendar-keys.inc.xhtml
+
+<popupset id="mainPopupSet">
+#include widgets/browserPopups.inc.xhtml
+#include widgets/toolbarContext.inc.xhtml
+ <menupopup id="aboutPagesContext"
+ onpopupshowing="goUpdateCommand('cmd_copy'); goUpdateCommand('cmd_paste'); goUpdateCommand('cmd_selectAll');">
+ <menuitem id="aboutPagesContext-copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuitem id="aboutPagesContext-paste"
+ data-l10n-id="text-action-paste"
+ command="cmd_paste"/>
+ <menuitem id="aboutPagesContext-selectall"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ </menupopup>
+
+<!-- The panelUI is for the appmenu. -->
+#include ../../components/customizableui/content/panelUI.inc.xhtml
+#include ../../components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml
+ <panel is="glodacomplete-rich-result-popup"
+ id="PopupGlodaAutocomplete"
+ noautofocus="true"/>
+
+ <tooltip id="attachmentListTooltip"/>
+
+ <!-- We want to be able to do the following:
+
+ 1) Open the tabContextMenu by right-clicking on individual tab selectors
+ 2) Open the mail-toolbox customize context menu when right-clicking on
+ the empty space of the tab selector.
+
+ In order to do that, we make the tabContextMenu available in the main
+ document, and refer to it via the context attributes of each newly spawned
+ tab selector. We also make the context attribute of the tab strip default
+ to the mail-toolbox customization popup.
+
+ So, when right-clicking on a tab, the tabContextMenu opens up, and stops
+ the click event from propagating - but when the strip is right-clicked
+ outside of any tabs, the mail-toolbox context menu opens, as desired.
+ -->
+
+ <menupopup id="tabContextMenu">
+ <menuitem id="tabContextMenuOpenInWindow"
+ label="&moveToNewWindow.label;"
+ accesskey="&moveToNewWindow.accesskey;"/>
+ <menuseparator />
+ <menuitem id="tabContextMenuCloseOtherTabs"
+ label="&closeOtherTabsCmd2.label;"
+ accesskey="&closeOtherTabsCmd2.accesskey;"/>
+ <menuseparator />
+ <menu id="tabContextMenuRecentlyClosed"
+ label="&recentlyClosedTabsCmd.label;"
+ accesskey="&recentlyClosedTabsCmd.accesskey;">
+ <menupopup />
+ </menu>
+ <menuitem id="tabContextMenuClose"
+ label="&closeTabCmd2.label;"
+ accesskey="&closeTabCmd2.accesskey;"/>
+ </menupopup>
+
+ <tooltip id="aHTMLTooltip" page="true"/>
+
+ <panel id="notification-popup"
+ position="after_end"
+ orient="vertical"
+ noautofocus="true"
+ role="alert"/>
+
+ <popupnotification id="addon-progress-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent orient="vertical">
+ <html:progress id="addon-progress-notification-progressmeter" max="100"/>
+ <label id="addon-progress-notification-progresstext" crop="end"/>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-install-confirmation-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/>
+ </popupnotification>
+
+ <popupnotification id="addon-webext-permissions-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent class="addon-webext-perm-notification-content" orient="vertical">
+ <description id="addon-webext-perm-text" class="addon-webext-perm-text"/>
+ <label id="addon-webext-perm-intro" class="addon-webext-perm-text"/>
+ <label id="addon-webext-perm-single-entry" class="addon-webext-perm-single-entry"/>
+ <html:ul id="addon-webext-perm-list" class="addon-webext-perm-list"/>
+ <description id="addon-webext-experiment-warning" class="addon-webext-experiment-warning"/>
+ <hbox>
+ <label id="addon-webext-perm-info" is="text-link" class="popup-notification-learnmore-link"/>
+ </hbox>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-installed-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent class="addon-installed-notification-content" orient="vertical">
+ <html:ul id="addon-installed-list" class="addon-installed-list"/>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-install-blocked-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent id="addon-install-blocked-content" orient="vertical">
+ <description id="addon-install-blocked-message" class="popup-notification-description"></description>
+ <hbox>
+ <label id="addon-install-blocked-info" class="popup-notification-learnmore-link" is="text-link"/>
+ </hbox>
+ </popupnotificationcontent>
+ </popupnotification>
+
+#include ../../components/im/content/chat-menu.inc.xhtml
+</popupset>
+#ifdef XP_MACOSX
+<popupset>
+ <menupopup id="menu_mac_dockmenu">
+ <menuitem label="&writeNewMessageDock.label;" id="tasksWriteNewMessage"
+ oncommand="writeNewMessageDock();"/>
+ <menuitem label="&openAddressBookDock.label;" id="tasksOpenAddressBook"
+ oncommand="openAddressBookDock();"/>
+ <menuitem label="&dockOptions.label;" id="tasksMenuDockOptions"
+ oncommand="openDockOptions();"/>
+ </menupopup>
+</popupset>
+#endif
+
+#include ../../../calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml
+#include ../../components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml
+
+#include spacesToolbar.inc.xhtml
+
+<!--
+ GTK needs to draw behind the lightweight theme toolbox backgrounds, thus the
+ extra box. Also this box allows a negative margin-top to slide the toolbox off
+ screen in fullscreen layout.
+-->
+<box id="navigation-toolbox-background">
+ <toolbox id="navigation-toolbox" flex="1" labelalign="end" defaultlabelalign="end">
+
+ <vbox id="titlebar">
+ <html:unified-toolbar></html:unified-toolbar>
+ <!-- Menu -->
+ <toolbar id="toolbar-menubar"
+ class="chromeclass-menubar themeable-full"
+ type="menubar"
+#ifdef XP_MACOSX
+ autohide="true"
+#endif
+#ifndef XP_MACOSX
+ data-l10n-id="toolbar-context-menu-menu-bar"
+ data-l10n-attrs="toolbarname"
+#endif
+ context="toolbar-context-menu"
+ mode="icons"
+ insertbefore="tabs-toolbar"
+ prependmenuitem="true">
+# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it
+# can be shared with other top level windows.
+#include messenger-menubar.inc.xhtml
+ </toolbar>
+
+ <toolbar id="tabs-toolbar" class="chromeclass-toolbar">
+ <tabs is="tabmail-tabs" id="tabmail-tabs"
+ flex="1"
+ align="end"
+ setfocus="false"
+ alltabsbutton="alltabs-button"
+ context="toolbar-context-menu"
+ collapsetoolbar="tabs-toolbar">
+ <html:img class="tab-drop-indicator"
+ src="chrome://messenger/skin/icons/tab-drag-indicator.svg"
+ alt=""
+ hidden="hidden" />
+ <arrowscrollbox id="tabmail-arrowscrollbox"
+ orient="horizontal"
+ flex="1"
+ clicktoscroll="true"
+ style="min-width: 1px;">
+ <tab is="tabmail-tab" selected="true"
+ class="tabmail-tab" crop="end"/>
+ </arrowscrollbox>
+ </tabs>
+
+ <toolbarbutton class="toolbarbutton-1 tabs-alltabs-button"
+ id="alltabs-button"
+ type="menu"
+ hidden="true"
+ tooltiptext="&listAllTabs.label;">
+ <menupopup is="tabmail-alltabs-menupopup" id="alltabs-popup"
+ position="after_end"
+ tabcontainer="tabmail-tabs"/>
+ </toolbarbutton>
+
+ </toolbar>
+
+ </vbox>
+
+ </toolbox>
+</box>
+
+<vbox id="messengerBody">
+ <!-- XXX This extension point (tabmail-container) is only temporary!
+ Horizontal space shouldn't be wasted if it isn't absolutely critical.
+ A mechanism for adding sidebar panes will be added in bug 476154. -->
+ <hbox id="tabmail-container" flex="1">
+ <!-- Beware! Do NOT use overlays to append nodes directly to tabmail (children
+ of tabmail is OK though). This will break Ctrl-tab switching because
+ the Custom Element will choke when it finds a child of tabmail that is
+ not a tabpanels node. -->
+ <tabmail id="tabmail"
+ class="printPreviewStack"
+ flex="1"
+ panelcontainer="tabpanelcontainer"
+ tabcontainer="tabmail-tabs">
+ <tabbox id="tabmail-tabbox" flex="1" eventnode="document" tabcontainer="tabmail-tabs">
+ <tabpanels id="tabpanelcontainer" flex="1" class="plain" selectedIndex="0">
+#include ../../components/im/content/chat-messenger.inc.xhtml
+#include ../../../calendar/base/content/calendar-tab-panels.inc.xhtml
+#include ../../../calendar/base/content/item-editing/calendar-item-panel.inc.xhtml
+ </tabpanels>
+ </tabbox>
+ <html:template id="mail3PaneTabTemplate">
+ <stack flex="1">
+ <browser flex="1"
+ src="about:3pane"
+ autocompletepopup="PopupAutoComplete"
+ messagemanagergroup="single-page"/>
+ </stack>
+ </html:template>
+ <html:template id="mailMessageTabTemplate">
+ <stack flex="1">
+ <browser flex="1"
+ src="about:message"
+ autocompletepopup="PopupAutoComplete"
+ messagemanagergroup="single-page"/>
+ </stack>
+ </html:template>
+#include ../../../calendar/base/content/widgets/calendar-invitation-panel.xhtml
+#include ../../../calendar/base/content/widgets/calendar-minidate.xhtml
+ <!-- Hidden browser used for printing documents without displaying them. -->
+ <browser id="hiddenPrintContent"
+ type="content"
+ nodefaultsrc="true"
+ maychangeremoteness="true"
+ hidden="true"/>
+ </tabmail>
+#include ../../../calendar/base/content/calendar-today-pane.inc.xhtml
+ <vbox id="contentTab" collapsed="true">
+ <vbox flex="1" class="contentTabInstance">
+ <vbox id="dummycontenttoolbox" class="contentTabToolbox themeable-full">
+ <hbox id="dummycontenttoolbar" class="contentTabToolbar">
+ <toolbarbutton class="back-btn nav-button"
+ tooltiptext="&browseBackButton.tooltip;"
+ disabled="true"/>
+ <toolbarbutton class="forward-btn nav-button"
+ tooltiptext="&browseForwardButton.tooltip;"
+ disabled="true"/>
+ <toolbaritem class="contentTabAddress" flex="1">
+ <html:img class="contentTabSecurity" />
+ <html:input class="contentTabUrlInput themeableSearchBox"
+ readonly="readonly" />
+ </toolbaritem>
+ </hbox>
+ </vbox>
+ <stack flex="1"><!-- Insert browser here. --></stack>
+ </vbox>
+ </vbox>
+ <vbox id="glodaTab" collapsed="true">
+ <vbox flex="1" class="chromeTabInstance">
+ <vbox class="contentTabToolbox themeable-full">
+ <hbox class="glodaTabToolbar inline-toolbar chromeclass-toolbar" flex="1">
+ <spacer flex="1" />
+ <spacer flex="1" />
+ <hbox flex="1" class="remote-gloda-search-container">
+ <html:img class="search-icon" alt=""
+ src="chrome://global/skin/icons/search-textbox.svg" />
+ <html:input is="gloda-autocomplete-input"
+ type="text"
+ class="remote-gloda-search searchBox gloda-search"
+ searchbutton="true"
+ autocompletesearch="gloda"
+ autocompletepopup="PopupGlodaAutocomplete"
+ autocompletesearchparam="global"
+ timeout="200"
+ maxlength="192"
+ placeholder=""
+ emptytextbase="&search.label.base1;"
+ keyLabelNonMac="&search.keyLabel.nonmac;"
+ keyLabelMac="&search.keyLabel.mac;"/>
+ </hbox>
+ </hbox>
+ </vbox>
+ <iframe flex="1"/>
+ </vbox>
+ </vbox>
+ <vbox id="preferencesTab" collapsed="true">
+ <vbox flex="1">
+ <hbox flex="1">
+ <browser id="preferencesbrowser"
+ type="content"
+ flex="1"
+ disablehistory="true"
+ autocompletepopup="PopupAutoComplete"
+ messagemanagergroup="single-site"
+ onclick="return contentAreaClick(event);"/>
+ </hbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ <panel id="customizeToolbarSheetPopup" noautohide="true">
+ <iframe id="customizeToolbarSheetIFrame"
+ style="&dialog.dimensions;"
+ hidden="true"/>
+ </panel>
+
+ <vbox id="messenger-notification-bottom">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+ <statuspanel id="statusbar-display"/>
+ <hbox id="status-bar" class="statusbar chromeclass-status">
+ <html:button type="button" id="spacesToolbarReveal"
+ onclick="gSpacesToolbar.toggleToolbar(false);"
+ data-l10n-id="spaces-toolbar-button-show"
+ class="plain spaces-toolbar-statusbar-button"
+ hidden="hidden">
+ <html:img src="chrome://messenger/skin/icons/new/compact/collapse.svg" alt="" />
+ </html:button>
+ <!-- We put the role="status" only around the information that is actually
+ - status information for the mail tabs. Specifically, we exclude the
+ - Spaces toolbar button and the calendar status bar (which is used when
+ - editing events in a tab and for the today pane button). -->
+ <hbox role="status" aria-live="off" flex="1">
+#include mainStatusbar.inc.xhtml
+ <hbox id="calendar-invitations-panel" class="statusbarpanel" hidden="true">
+ <label id="calendar-invitations-label"
+ class="text-link"
+ onclick="openInvitationsDialog()"
+ onkeypress="if (event.key == 'Enter') { openInvitationsDialog(); }"/>
+ </hbox>
+ </hbox>
+#include ../../../calendar/base/content/calendar-status-bar.inc.xhtml
+ </hbox>
+</vbox><!-- Closing #messengerBody. -->
+
+#include tabDialogs.inc.xhtml
+#include ../../components/accountcreation/templates/accountHubTemplate.inc.xhtml
+</html:body>
+</html>
diff --git a/comm/mail/base/content/migrationProgress.js b/comm/mail/base/content/migrationProgress.js
new file mode 100644
index 0000000000..d75536a137
--- /dev/null
+++ b/comm/mail/base/content/migrationProgress.js
@@ -0,0 +1,64 @@
+/* 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 { MigrationTasks } = ChromeUtils.import(
+ "resource:///modules/MailMigrator.jsm"
+);
+
+window.addEventListener("load", async function () {
+ let list = document.getElementById("tasks");
+ let itemTemplate = document.getElementById("taskItem");
+ let progress = document.querySelector("progress");
+ let l10nElements = [];
+
+ for (let task of MigrationTasks.tasks) {
+ if (!task.fluentID) {
+ continue;
+ }
+
+ let item = itemTemplate.content.firstElementChild.cloneNode(true);
+ item.classList.add(task.status);
+
+ let name = item.querySelector(".task-name");
+ document.l10n.setAttributes(name, task.fluentID);
+ l10nElements.push(name);
+
+ if (task.status == "running") {
+ if (task.subTasks.length) {
+ progress.value = task.subTasks.filter(
+ t => t.status == "finished"
+ ).length;
+ progress.max = task.subTasks.length;
+ progress.style.visibility = null;
+ } else {
+ progress.style.visibility = "hidden";
+ }
+ }
+
+ list.appendChild(item);
+
+ task.on("status-change", (event, status) => {
+ item.classList.remove("pending", "running", "finished");
+ item.classList.add(status);
+
+ if (status == "running") {
+ // Always hide the progress bar when starting a task. If there are
+ // sub-tasks, it will be shown by a progress event.
+ progress.style.visibility = "hidden";
+ }
+ });
+ task.on("progress", (event, value, max) => {
+ progress.value = value;
+ progress.max = max;
+ progress.style.visibility = null;
+ });
+ }
+
+ await document.l10n.translateElements(l10nElements);
+ window.sizeToContent();
+ window.moveTo(
+ (screen.width - window.outerWidth) / 2,
+ (screen.height - window.outerHeight) / 2
+ );
+});
diff --git a/comm/mail/base/content/migrationProgress.xhtml b/comm/mail/base/content/migrationProgress.xhtml
new file mode 100644
index 0000000000..c196d92668
--- /dev/null
+++ b/comm/mail/base/content/migrationProgress.xhtml
@@ -0,0 +1,39 @@
+<?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/. -->
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta charset="utf-8" />
+ <title data-l10n-id="migration-progress-header"></title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/migration.ftl" />
+ <link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/migrationProgress.css"
+ />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/migrationProgress.js"
+ ></script>
+ </head>
+ <body>
+ <img
+ src="chrome://branding/content/icon256.png"
+ width="256"
+ height="256"
+ alt=""
+ />
+ <h1 data-l10n-id="migration-progress-header"></h1>
+ <ol id="tasks"></ol>
+ <progress value="0"></progress>
+ <template id="taskItem">
+ <li>
+ <div class="task-icon"></div>
+ <span class="task-name"></span>
+ </li>
+ </template>
+ </body>
+</html>
diff --git a/comm/mail/base/content/minimizeToTray.js b/comm/mail/base/content/minimizeToTray.js
new file mode 100644
index 0000000000..f65fcc7b43
--- /dev/null
+++ b/comm/mail/base/content/minimizeToTray.js
@@ -0,0 +1,19 @@
+/* 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 docShell, Services, windowState */
+
+addEventListener("sizemodechange", () => {
+ if (
+ windowState == window.STATE_MINIMIZED &&
+ Services.prefs.getBoolPref("mail.minimizeToTray", false)
+ ) {
+ setTimeout(() => {
+ var bw = docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow);
+ Cc["@mozilla.org/messenger/osintegration;1"]
+ .getService(Ci.nsIMessengerWindowsIntegration)
+ .hideWindow(bw);
+ });
+ }
+});
diff --git a/comm/mail/base/content/modules/thread-pane-columns.mjs b/comm/mail/base/content/modules/thread-pane-columns.mjs
new file mode 100644
index 0000000000..2361379509
--- /dev/null
+++ b/comm/mail/base/content/modules/thread-pane-columns.mjs
@@ -0,0 +1,385 @@
+/* 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "USE_CORRESPONDENTS",
+ "mail.threadpane.use_correspondents",
+ true
+);
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ FeedUtils: "resource:///modules/FeedUtils.jsm",
+ DBViewWrapper: "resource:///modules/DBViewWrapper.jsm",
+});
+
+/**
+ * The array of columns for the table layout. This must be kept in sync with
+ * the row template #threadPaneRowTemplate in about3Pane.xhtml.
+ *
+ * @type {Array}
+ */
+const DEFAULT_COLUMNS = [
+ {
+ id: "selectCol",
+ l10n: {
+ header: "threadpane-column-header-select",
+ menuitem: "threadpane-column-label-select",
+ },
+ ordinal: 1,
+ select: true,
+ icon: true,
+ resizable: false,
+ sortable: false,
+ hidden: true,
+ },
+ {
+ id: "threadCol",
+ l10n: {
+ header: "threadpane-column-header-thread",
+ menuitem: "threadpane-column-label-thread",
+ },
+ ordinal: 2,
+ thread: true,
+ icon: true,
+ resizable: false,
+ sortable: false,
+ },
+ {
+ id: "flaggedCol",
+ l10n: {
+ header: "threadpane-column-header-flagged",
+ menuitem: "threadpane-column-label-flagged",
+ },
+ ordinal: 3,
+ sortKey: "byFlagged",
+ star: true,
+ icon: true,
+ resizable: false,
+ },
+ {
+ id: "attachmentCol",
+ l10n: {
+ header: "threadpane-column-header-attachments",
+ menuitem: "threadpane-column-label-attachments",
+ },
+ ordinal: 4,
+ sortKey: "byAttachments",
+ icon: true,
+ resizable: false,
+ },
+ {
+ id: "subjectCol",
+ l10n: {
+ header: "threadpane-column-header-subject",
+ menuitem: "threadpane-column-label-subject",
+ },
+ ordinal: 5,
+ picker: false,
+ sortKey: "bySubject",
+ },
+ {
+ id: "unreadButtonColHeader",
+ l10n: {
+ header: "threadpane-column-header-unread-button",
+ menuitem: "threadpane-column-label-unread-button",
+ },
+ ordinal: 6,
+ sortKey: "byUnread",
+ icon: true,
+ resizable: false,
+ unread: true,
+ },
+ {
+ id: "senderCol",
+ l10n: {
+ header: "threadpane-column-header-sender",
+ menuitem: "threadpane-column-label-sender",
+ },
+ ordinal: 7,
+ sortKey: "byAuthor",
+ hidden: true,
+ },
+ {
+ id: "recipientCol",
+ l10n: {
+ header: "threadpane-column-header-recipient",
+ menuitem: "threadpane-column-label-recipient",
+ },
+ ordinal: 8,
+ sortKey: "byRecipient",
+ hidden: true,
+ },
+ {
+ id: "correspondentCol",
+ l10n: {
+ header: "threadpane-column-header-correspondents",
+ menuitem: "threadpane-column-label-correspondents",
+ },
+ ordinal: 9,
+ sortKey: "byCorrespondent",
+ },
+ {
+ id: "junkStatusCol",
+ l10n: {
+ header: "threadpane-column-header-spam",
+ menuitem: "threadpane-column-label-spam",
+ },
+ ordinal: 10,
+ sortKey: "byJunkStatus",
+ spam: true,
+ icon: true,
+ resizable: false,
+ },
+ {
+ id: "dateCol",
+ l10n: {
+ header: "threadpane-column-header-date",
+ menuitem: "threadpane-column-label-date",
+ },
+ ordinal: 11,
+ sortKey: "byDate",
+ },
+ {
+ id: "receivedCol",
+ l10n: {
+ header: "threadpane-column-header-received",
+ menuitem: "threadpane-column-label-received",
+ },
+ ordinal: 12,
+ sortKey: "byReceived",
+ hidden: true,
+ },
+ {
+ id: "statusCol",
+ l10n: {
+ header: "threadpane-column-header-status",
+ menuitem: "threadpane-column-label-status",
+ },
+ ordinal: 13,
+ sortKey: "byStatus",
+ hidden: true,
+ },
+ {
+ id: "sizeCol",
+ l10n: {
+ header: "threadpane-column-header-size",
+ menuitem: "threadpane-column-label-size",
+ },
+ ordinal: 14,
+ sortKey: "bySize",
+ hidden: true,
+ },
+ {
+ id: "tagsCol",
+ l10n: {
+ header: "threadpane-column-header-tags",
+ menuitem: "threadpane-column-label-tags",
+ },
+ ordinal: 15,
+ sortKey: "byTags",
+ hidden: true,
+ },
+ {
+ id: "accountCol",
+ l10n: {
+ header: "threadpane-column-header-account",
+ menuitem: "threadpane-column-label-account",
+ },
+ ordinal: 16,
+ sortKey: "byAccount",
+ hidden: true,
+ },
+ {
+ id: "priorityCol",
+ l10n: {
+ header: "threadpane-column-header-priority",
+ menuitem: "threadpane-column-label-priority",
+ },
+ ordinal: 17,
+ sortKey: "byPriority",
+ hidden: true,
+ },
+ {
+ id: "unreadCol",
+ l10n: {
+ header: "threadpane-column-header-unread",
+ menuitem: "threadpane-column-label-unread",
+ },
+ ordinal: 18,
+ sortable: false,
+ hidden: true,
+ },
+ {
+ id: "totalCol",
+ l10n: {
+ header: "threadpane-column-header-total",
+ menuitem: "threadpane-column-label-total",
+ },
+ ordinal: 19,
+ sortable: false,
+ hidden: true,
+ },
+ {
+ id: "locationCol",
+ l10n: {
+ header: "threadpane-column-header-location",
+ menuitem: "threadpane-column-label-location",
+ },
+ ordinal: 20,
+ sortKey: "byLocation",
+ hidden: true,
+ },
+ {
+ id: "idCol",
+ l10n: {
+ header: "threadpane-column-header-id",
+ menuitem: "threadpane-column-label-id",
+ },
+ ordinal: 21,
+ sortKey: "byId",
+ hidden: true,
+ },
+ {
+ id: "deleteCol",
+ l10n: {
+ header: "threadpane-column-header-delete",
+ menuitem: "threadpane-column-label-delete",
+ },
+ ordinal: 22,
+ delete: true,
+ icon: true,
+ resizable: false,
+ sortable: false,
+ hidden: true,
+ },
+];
+
+/**
+ * Check if the current folder is a special Outgoing folder.
+ *
+ * @param {nsIMsgFolder} folder - The message folder.
+ * @returns {boolean} True if the folder is Outgoing.
+ */
+export const isOutgoing = folder => {
+ return folder.isSpecialFolder(
+ lazy.DBViewWrapper.prototype.OUTGOING_FOLDER_FLAGS,
+ true
+ );
+};
+
+/**
+ * Generate the correct default array of columns, accounting for different views
+ * and folder states.
+ *
+ * @param {?nsIMsgFolder} folder - The currently viewed folder if available.
+ * @param {boolean} [isSynthetic=false] - If the current view is synthetic,
+ * meaning we are not visualizing a real folder, but rather
+ * the gloda results list.
+ * @returns {object[]}
+ */
+export function getDefaultColumns(folder, isSynthetic = false) {
+ // Create a clone we can edit.
+ let updatedColumns = DEFAULT_COLUMNS.map(column => ({ ...column }));
+
+ if (isSynthetic) {
+ // Synthetic views usually can contain messages from multiple folders.
+ // Folder for the selected message will still be set.
+ for (let c of updatedColumns) {
+ switch (c.id) {
+ case "correspondentCol":
+ // Don't show the correspondent if is not wanted.
+ c.hidden = !lazy.USE_CORRESPONDENTS;
+ break;
+ case "senderCol":
+ // Hide the sender if correspondent is enabled.
+ c.hidden = lazy.USE_CORRESPONDENTS;
+ break;
+ case "attachmentCol":
+ case "unreadButtonColHeader":
+ case "junkStatusCol":
+ // Hide all the columns we don't want in a default gloda view.
+ c.hidden = true;
+ break;
+ case "locationCol":
+ // Always show the location by default in a gloda view.
+ c.hidden = false;
+ break;
+ }
+ }
+ return updatedColumns;
+ }
+
+ if (!folder) {
+ // We don't have a folder yet. Use defaults.
+ return updatedColumns;
+ }
+
+ for (let c of updatedColumns) {
+ switch (c.id) {
+ case "correspondentCol":
+ // Don't show the correspondent for news or RSS.
+ c.hidden = lazy.USE_CORRESPONDENTS
+ ? !folder.getFlag(Ci.nsMsgFolderFlags.Mail) ||
+ lazy.FeedUtils.isFeedFolder(folder)
+ : true;
+ break;
+ case "senderCol":
+ // Show the sender even if correspondent is enabled for news and feeds.
+ c.hidden = lazy.USE_CORRESPONDENTS
+ ? !folder.getFlag(Ci.nsMsgFolderFlags.Newsgroup) &&
+ !lazy.FeedUtils.isFeedFolder(folder)
+ : isOutgoing(folder);
+ break;
+ case "recipientCol":
+ // No recipient column if we use correspondent. Otherwise hide it if is
+ // not an outgoing folder.
+ c.hidden = lazy.USE_CORRESPONDENTS ? true : !isOutgoing(folder);
+ break;
+ case "junkStatusCol":
+ // No ability to mark newsgroup or feed messages as spam.
+ c.hidden =
+ folder.getFlag(Ci.nsMsgFolderFlags.Newsgroup) ||
+ lazy.FeedUtils.isFeedFolder(folder);
+ break;
+ }
+ }
+ return updatedColumns;
+}
+
+/**
+ * Find the proper column to use as sender field for the cards view.
+ *
+ * @param {?nsIMsgFolder} folder - The currently viewed folder if available.
+ * @returns {string} - The name of the column to use as sender field.
+ */
+function getProperSenderForCardsView(folder) {
+ // Default to correspondent as it's the safest choice most of the times.
+ if (!folder) {
+ return "correspondentCol";
+ }
+
+ // Show the recipient for outgoing folders.
+ if (isOutgoing(folder)) {
+ return "recipientCol";
+ }
+
+ // Show the sender for any other scenario, including news and feeds folders.
+ return "senderCol";
+}
+
+/**
+ * Get the default array of columns to fetch data for the cards view.
+ *
+ * @param {?nsIMsgFolder} folder - The currently viewed folder if available.
+ * @returns {string[]}
+ */
+export function getDefaultColumnsForCardsView(folder) {
+ const sender = getProperSenderForCardsView(folder);
+ return ["subjectCol", sender, "dateCol", "tagsCol"];
+}
diff --git a/comm/mail/base/content/msgAttachmentView.inc.xhtml b/comm/mail/base/content/msgAttachmentView.inc.xhtml
new file mode 100644
index 0000000000..934ff7bc18
--- /dev/null
+++ b/comm/mail/base/content/msgAttachmentView.inc.xhtml
@@ -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/.
+
+ <!-- the message pane consists of 4 'boxes'. Box #4 is the attachment
+ box which can be toggled into a slim or an expanded view -->
+ <hbox align="center" id="attachmentBar"
+ context="attachment-toolbar-context-menu"
+ onclick="if (event.button == 0) { toggleAttachmentList(undefined, true); }">
+ <button type="checkbox" id="attachmentToggle"
+ onmousedown="event.preventDefault();"
+ onclick="event.stopPropagation();"
+ oncommand="toggleAttachmentList(this.checked, true);"/>
+ <hbox align="center" id="attachmentInfo">
+ <html:img id="attachmentIcon"
+ src="chrome://messenger/skin/icons/attach.svg"
+ alt="" />
+ <label id="attachmentCount"/>
+ <label id="attachmentName" crop="center"
+ role="button"
+ tooltiptext="&openAttachment.tooltip;"
+ tooltiptextopen="&openAttachment.tooltip;"
+ onclick="OpenAttachmentFromBar(event);"
+ ondragstart="attachmentNameDNDObserver.onDragStart(event);"/>
+ <label id="attachmentSize"/>
+ </hbox>
+ <spacer flex="1"/>
+
+ <vbox id="attachment-view-toolbox" class="inline-toolbox">
+ <hbox id="attachment-view-toolbar"
+ class="toolbar themeable-brighttext"
+ context="attachment-toolbar-context-menu">
+ <toolbaritem id="attachmentSaveAll"
+ title="&saveAllAttachmentsButton1.label;">
+ <toolbarbutton is="toolbarbutton-menu-button" id="attachmentSaveAllSingle"
+ type="menu"
+ class="toolbarbutton-1 message-header-view-button"
+ label="&saveAttachmentButton1.label;"
+ tooltiptext="&saveAttachmentButton1.tooltip;"
+ onclick="event.stopPropagation();"
+ oncommand="TryHandleAllAttachments('saveAs');"
+ hidden="true">
+ <menupopup id="attachmentSaveAllSingleMenu"
+ onpopupshowing="onShowSaveAttachmentMenuSingle();">
+ <menuitem id="button-openAttachment"
+ oncommand="TryHandleAllAttachments('open'); event.stopPropagation();"
+ label="&openAttachmentCmd.label;"
+ accesskey="&openAttachmentCmd.accesskey;"/>
+ <menuitem id="button-saveAttachment"
+ oncommand="TryHandleAllAttachments('saveAs'); event.stopPropagation();"
+ label="&saveAsAttachmentCmd.label;"
+ accesskey="&saveAsAttachmentCmd.accesskey;"/>
+ <menuseparator id="button-menu-separator"/>
+ <menuitem id="button-detachAttachment"
+ oncommand="TryHandleAllAttachments('detach'); event.stopPropagation();"
+ label="&detachAttachmentCmd.label;"
+ accesskey="&detachAttachmentCmd.accesskey;"/>
+ <menuitem id="button-deleteAttachment"
+ oncommand="TryHandleAllAttachments('delete'); event.stopPropagation();"
+ label="&deleteAttachmentCmd.label;"
+ accesskey="&deleteAttachmentCmd.accesskey;"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton is="toolbarbutton-menu-button" id="attachmentSaveAllMultiple"
+ type="menu"
+ class="toolbarbutton-1 message-header-view-button"
+ label="&saveAllAttachmentsButton1.label;"
+ tooltiptext="&saveAllAttachmentsButton1.tooltip;"
+ onclick="event.stopPropagation();"
+ oncommand="TryHandleAllAttachments('save');">
+ <menupopup id="attachmentSaveAllMultipleMenu"
+ onpopupshowing="onShowSaveAttachmentMenuMultiple();">
+ <menuitem id="button-openAllAttachments"
+ oncommand="TryHandleAllAttachments('open'); event.stopPropagation();"
+ label="&openAllAttachmentsCmd.label;"
+ accesskey="&openAllAttachmentsCmd.accesskey;"/>
+ <menuitem id="button-saveAllAttachments"
+ oncommand="TryHandleAllAttachments('save'); event.stopPropagation();"
+ label="&saveAllAttachmentsCmd.label;"
+ accesskey="&saveAllAttachmentsCmd.accesskey;"/>
+ <menuseparator id="button-menu-separator-all"/>
+ <menuitem id="button-detachAllAttachments"
+ oncommand="TryHandleAllAttachments('detach'); event.stopPropagation();"
+ label="&detachAllAttachmentsCmd.label;"
+ accesskey="&detachAllAttachmentsCmd.accesskey;"/>
+ <menuitem id="button-deleteAllAttachments"
+ oncommand="TryHandleAllAttachments('delete'); event.stopPropagation();"
+ label="&deleteAllAttachmentsCmd.label;"
+ accesskey="&deleteAllAttachmentsCmd.accesskey;"/>
+ </menupopup>
+ </toolbarbutton>
+ </toolbaritem>
+ </hbox>
+ </vbox>
+ </hbox>
+ <richlistbox is="attachment-list" id="attachmentList"
+ class="attachmentList"
+ seltype="multiple"
+ context="attachmentListContext"
+ itemcontext="attachmentItemContext"
+ role="listbox"
+ ondragstart="attachmentListDNDObserver.onDragStart(event);"/>
diff --git a/comm/mail/base/content/msgHdrPopup.inc.xhtml b/comm/mail/base/content/msgHdrPopup.inc.xhtml
new file mode 100644
index 0000000000..3c0b9826bb
--- /dev/null
+++ b/comm/mail/base/content/msgHdrPopup.inc.xhtml
@@ -0,0 +1,224 @@
+# 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/.
+
+ <menupopup id="messageIdContext">
+ <menuitem id="messageIdContext-messageIdTarget" disabled="true"/>
+ <menuseparator id="messageIdContext-separator"/>
+ <menuitem id="messageIdContext-openMessageForMsgId"
+ label="&OpenMessageForMsgId.label;"
+ accesskey="&OpenMessageForMsgId.accesskey;"
+ oncommand="gMessageHeader.openMessage(event);"/>
+ <menuitem id="messageIdContext-openBrowserWithMsgId"
+ label="&OpenBrowserWithMsgId.label;"
+ accesskey="&OpenBrowserWithMsgId.accesskey;"
+ oncommand="gMessageHeader.openBrowser(event);"/>
+ <menuitem id="messageIdContext-copyMessageId"
+ label="&CopyMessageId.label;"
+ accesskey="&CopyMessageId.accesskey;"
+ oncommand="gMessageHeader.copyMessageId(event);"/>
+ </menupopup>
+
+ <menupopup id="attachmentItemContext"
+ onpopupshowing="return onShowAttachmentItemContextMenu();"
+ onpopuphiding="return onHideAttachmentItemContextMenu();">
+ <menuitem id="context-openAttachment"
+ label="&openAttachmentCmd.label;"
+ accesskey="&openAttachmentCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'open');"/>
+ <menuitem id="context-saveAttachment"
+ label="&saveAsAttachmentCmd.label;"
+ accesskey="&saveAsAttachmentCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'saveAs');"/>
+ <menuseparator id="context-menu-separator"/>
+ <menuitem id="context-detachAttachment"
+ label="&detachAttachmentCmd.label;"
+ accesskey="&detachAttachmentCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'detach');"/>
+ <menuitem id="context-deleteAttachment"
+ label="&deleteAttachmentCmd.label;"
+ accesskey="&deleteAttachmentCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'delete');"/>
+ <menuseparator id="context-menu-copyurl-separator"/>
+ <menuitem id="context-copyAttachmentUrl"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'copyUrl');"/>
+ <menuitem id="context-openFolder"
+#ifdef XP_MACOSX
+ label="&detachedAttachmentFolder.showMac.label;"
+ accesskey="&detachedAttachmentFolder.showMac.accesskey;"
+#else
+ label="&detachedAttachmentFolder.show.label;"
+ accesskey="&detachedAttachmentFolder.show.accesskey;"
+#endif
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'openFolder');"/>
+#include ../../extensions/openpgp/content/ui/attachmentItemContext.inc.xhtml
+ </menupopup>
+
+ <menupopup id="attachmentListContext"
+ onpopupshowing="goUpdateAttachmentCommands();">
+ <menuitem id="context-openAllAttachments"
+ label="&openAllAttachmentsCmd.label;"
+ accesskey="&openAllAttachmentsCmd.accesskey;"
+ command="cmd_openAllAttachments"/>
+ <menuitem id="context-saveAllAttachments"
+ label="&saveAllAttachmentsCmd.label;"
+ accesskey="&saveAllAttachmentsCmd.accesskey;"
+ command="cmd_saveAllAttachments"/>
+ <menuseparator id="context-menu-separator-all"/>
+ <menuitem id="context-detachAllAttachments"
+ label="&detachAllAttachmentsCmd.label;"
+ accesskey="&detachAllAttachmentsCmd.accesskey;"
+ command="cmd_detachAllAttachments"/>
+ <menuitem id="context-deleteAllAttachments"
+ label="&deleteAllAttachmentsCmd.label;"
+ accesskey="&deleteAllAttachmentsCmd.accesskey;"
+ command="cmd_deleteAllAttachments"/>
+ </menupopup>
+
+ <menupopup id="attachment-toolbar-context-menu"
+ onpopupshowing="return onShowAttachmentToolbarContextMenu(event);">
+ <menuitem id="context-expandAttachmentBar"
+ type="checkbox"
+ label="&startExpandedCmd.label;"
+ accesskey="&startExpandedCmd.accesskey;"
+ oncommand="Services.prefs.setBoolPref('mailnews.attachments.display.start_expanded', this.getAttribute('checked'));"/>
+ </menupopup>
+
+ <menupopup id="emailAddressPopup"
+ position="after_start"
+ class="no-icon-menupopup">
+ <menuitem id="emailAddressPlaceHolder"
+ class="menuitem-iconic"
+ disabled="true"/>
+ <menuseparator/>
+ <menuitem id="addToAddressBookItem"
+ label="&AddDirectlyToAddressBook.label;"
+ accesskey="&AddDirectlyToAddressBook.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.addContact(event);"/>
+ <menuitem id="editContactItem" label="&EditContact1.label;" hidden="true"
+ accesskey="&EditContact1.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.showContactEdit(event);"/>
+ <menuitem id="viewContactItem" label="&ViewContact.label;" hidden="true"
+ accesskey="&ViewContact.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.showContactEdit(event);"/>
+ <menuitem id="sendMailToItem" label="&SendMessageTo.label;"
+ accesskey="&SendMessageTo.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.composeMessage(event);"/>
+ <menuitem id="copyEmailAddressItem" label="&CopyEmailAddress.label;"
+ accesskey="&CopyEmailAddress.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyAddress(event)"/>
+ <menuitem id="copyNameAndEmailAddressItem" label="&CopyNameAndEmailAddress.label;"
+ accesskey="&CopyNameAndEmailAddress.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyAddress(event, true);"/>
+ <menuseparator/>
+ <menuitem id="searchKeysOpenPGP" data-l10n-id="openpgp-search-keys-openpgp"
+ class="menuitem-iconic"
+ oncommand="Enigmail.msg.searchKeysOnInternet(event)"/>
+ <menuseparator/>
+ <menuitem id="createFilterFrom" label="&CreateFilterFrom.label;"
+ accesskey="&CreateFilterFrom.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.createFilter(event);"/>
+ </menupopup>
+
+ <menupopup id="copyPopup" class="no-icon-menupopup">
+ <menuitem id="copyMenuitem"
+ data-l10n-id="text-action-copy"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyString(event);"/>
+ <menuitem id="copyCreateFilterFrom"
+ label="&CreateFilterFrom.label;"
+ accesskey="&CreateFilterFrom.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.createFilter(event);"/>
+ </menupopup>
+
+ <menupopup id="copyUrlPopup"
+ popupanchor="bottomleft">
+ <menuitem label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"
+ oncommand="gMessageHeader.copyWebsiteUrl(event);"/>
+ </menupopup>
+
+ <menupopup id="simpleCopyPopup" class="no-icon-menupopup">
+ <menuitem id="copyMenuitem"
+ data-l10n-id="text-action-copy"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyString(event);"/>
+ </menupopup>
+
+ <menupopup id="newsgroupPopup"
+ position="after_start"
+ class="newsgroupPopup no-icon-menupopup">
+ <menuitem id="newsgroupPlaceHolder"
+ class="menuitem-iconic"
+ disabled="true"/>
+ <menuseparator/>
+ <menuitem id="sendMessageToNewsgroupItem"
+ label="&SendMessageTo.label;"
+ accesskey="&SendMessageTo.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.composeMessage(event);"/>
+ <menuitem id="copyNewsgroupNameItem"
+ label="&CopyNewsgroupName.label;"
+ accesskey="&CopyNewsgroupName.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyAddress(event);"/>
+ <menuitem id="copyNewsgroupURLItem"
+ label="&CopyNewsgroupURL.label;"
+ accesskey="&CopyNewsgroupURL.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyNewsgroupURL(event);"/>
+ <menuseparator id="subscribeToNewsgroupSeparator"/>
+ <menuitem id="subscribeToNewsgroupItem"
+ label="&SubscribeToNewsgroup.label;"
+ accesskey="&SubscribeToNewsgroup.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.subscribeToNewsgroup(event)"/>
+ </menupopup>
+
+ <menupopup id="remoteContentOptions" value=""
+ onpopupshowing="onRemoteContentOptionsShowing(event);">
+ <menuitem id="remoteContentOptionAllowForMsg"
+ label="&remoteContentOptionsAllowForMsg.label;"
+ accesskey="&remoteContentOptionsAllowForMsg.accesskey;"
+ oncommand="LoadMsgWithRemoteContent();"/>
+ <menuseparator id="remoteContentSettingsMenuSeparator"/>
+ <menuitem id="editRemoteContentSettings"
+#ifdef XP_WIN
+ label="&editRemoteContentSettings.label;"
+ accesskey="&editRemoteContentSettings.accesskey;"
+#else
+ label="&editRemoteContentSettingsUnix.label;"
+ accesskey="&editRemoteContentSettingsUnix.accesskey;"
+#endif
+ oncommand="editRemoteContentSettings();"/>
+ <menuseparator id="remoteContentOriginsMenuSeparator"/>
+ <menuseparator id="remoteContentAllMenuSeparator"/>
+ <menuitem id="remoteContentOptionAllowAll"
+ oncommand="allowRemoteContentForAll(this.parentNode);"/>
+ </menupopup>
+
+ <menupopup id="phishingOptions">
+ <menuitem id="phishingOptionIgnore"
+ label="&phishingOptionIgnore.label;"
+ accesskey="&phishingOptionIgnore.accesskey;"
+ oncommand="IgnorePhishingWarning();"/>
+ <menuitem id="phishingOptionSettings"
+#ifdef XP_WIN
+ label="&phishingOptionSettings.label;"
+ accesskey="&phishingOptionSettings.accesskey;"
+#else
+ label="&phishingOptionSettingsUnix.label;"
+ accesskey="&phishingOptionSettingsUnix.accesskey;"
+#endif
+ oncommand="OpenPhishingSettings();"/>
+ </menupopup>
diff --git a/comm/mail/base/content/msgHdrView.inc.xhtml b/comm/mail/base/content/msgHdrView.inc.xhtml
new file mode 100644
index 0000000000..fa9565d7cc
--- /dev/null
+++ b/comm/mail/base/content/msgHdrView.inc.xhtml
@@ -0,0 +1,559 @@
+# 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/.
+
+<menupopup id="header-toolbar-context-menu"
+ onpopupshowing="ToolbarContextMenu.updateExtension(this);">
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
+
+<!-- Header container -->
+<html:header id="messageHeader"
+ class="message-header-container message-header-large-subject message-header-hide-label-column message-header-show-recipient-avatar message-header-show-sender-full-address">
+ <!-- From + buttons -->
+ <html:div id="headerSenderToolbarContainer"
+ class="message-header-row header-row-reverse message-header-wrap items-center">
+ <html:div id="header-view-toolbox" role="toolbar" class="header-buttons-container">
+ <!-- NOTE: Temporarily keep the hbox to allow extensions to add custom sets of
+ toolbar buttons. This can be later removed once a new API that doesn't rely
+ on XUL currentset is in place. -->
+ <hbox id="header-view-toolbar" class="header-buttons-container themeable-brighttext">
+ <toolbarbutton id="hdrReplyToSenderButton" label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event);"
+ class="toolbarbutton-1 message-header-view-button hdrReplyToSenderButton"/>
+
+ <toolbaritem id="hdrSmartReplyButton" label="&hdrSmartReplyButton1.label;">
+ <!-- This button is a dummy and should only be shown when customizing
+ the toolbar to distinguish the smart reply button from the reply
+ to sender button. -->
+ <toolbarbutton id="hdrDummyReplyButton" label="&hdrSmartReplyButton1.label;"
+ hidden="true"
+ class="toolbarbutton-1 message-header-view-button hdrDummyReplyButton"/>
+
+ <toolbarbutton id="hdrReplyButton" label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event);"
+ class="toolbarbutton-1 message-header-view-button hdrReplyButton"/>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="hdrReplyAllButton"
+ type="menu"
+ label="&hdrReplyAllButton1.label;"
+ tooltiptext="&hdrReplyAllButton1.tooltip;"
+ oncommand="MsgReplyToAllMessage(event);"
+ class="toolbarbutton-1 message-header-view-button hdrReplyButton hdrReplyAllButton"
+ hidden="true">
+ <menupopup id="hdrReplyAllDropdown"
+ class="no-icon-menupopup">
+ <menuitem id="hdrReplyAll_ReplyAllSubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyAllButton1.label;"
+ tooltiptext="&hdrReplyAllButton1.tooltip;"/>
+ <toolbarseparator id="hdrReplyAllSubSeparator"/>
+ <menuitem id="hdrReplySubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event); event.stopPropagation();"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="hdrReplyListButton"
+ type="menu"
+ label="&hdrReplyListButton1.label;"
+ tooltiptext="&hdrReplyListButton1.tooltip;"
+ oncommand="MsgReplyToListMessage(event);"
+ class="toolbarbutton-1 message-header-view-button hdrReplyButton hdrReplyListButton"
+ hidden="true">
+ <menupopup id="hdrReplyListDropdown"
+ class="no-icon-menupopup">
+ <menuitem id="hdrReplyList_ReplyListSubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyListButton1.label;"
+ tooltiptext="&hdrReplyListButton1.tooltip;"/>
+ <toolbarseparator id="hdrReplyListSubSeparator"/>
+ <menuitem id="hdrRelplyList_ReplyAllSubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyAllButton1.label;"
+ tooltiptext="&hdrReplyAllButton1.tooltip;"
+ oncommand="MsgReplyToAllMessage(event); event.stopPropagation();"/>
+ <menuitem id="hdrReplyList_ReplySubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event); event.stopPropagation();"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="hdrFollowupButton"
+ label="&hdrFollowupButton1.label;"
+ type="menu"
+ tooltiptext="&hdrFollowupButton1.tooltip;"
+ oncommand="MsgReplyGroup(event);"
+ class="toolbarbutton-1 message-header-view-button hdrFollowupButton">
+ <menupopup id="hdrFollowupDropdown"
+ class="no-icon-menupopup">
+ <menuitem id="hdrFollowup_FollowupSubButton"
+ class="menuitem-iconic"
+ label="&hdrFollowupButton1.label;"
+ tooltiptext="&hdrFollowupButton1.tooltip;"/>
+ <toolbarseparator id="hdrFollowupSubSeparator"/>
+ <menuitem id="hdrFollowup_ReplyAllSubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyAllButton1.label;"
+ tooltiptext="&hdrReplyAllButton1.tooltip;"
+ oncommand="MsgReplyToAllMessage(event); event.stopPropagation();"/>
+ <menuitem id="hdrFollowup_ReplySubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event); event.stopPropagation();"/>
+ </menupopup>
+ </toolbarbutton>
+ </toolbaritem>
+
+ <toolbarbutton id="hdrForwardButton"
+ label="&hdrForwardButton1.label;"
+ tooltiptext="&hdrForwardButton1.tooltip;"
+ oncommand="MsgForwardMessage(event);"
+ class="toolbarbutton-1 message-header-view-button hdrForwardButton"/>
+ <toolbarbutton id="hdrArchiveButton"
+ label="&hdrArchiveButton1.label;"
+ tooltiptext="&hdrArchiveButton1.tooltip;"
+ oncommand="goDoCommand('cmd_archive');"
+ class="toolbarbutton-1 message-header-view-button hdrArchiveButton"/>
+ <toolbarbutton id="hdrJunkButton" label="&hdrJunkButton1.label;"
+ tooltiptext="&hdrJunkButton1.tooltip;"
+ observes="cmd_markAsJunk"
+ class="toolbarbutton-1 message-header-view-button hdrJunkButton"
+ oncommand="goDoCommand('cmd_markAsJunk');"/>
+ <toolbarbutton id="hdrTrashButton"
+ label="&hdrTrashButton1.label;"
+ tooltiptext="&hdrTrashButton1.tooltip;"
+ observes="cmd_delete"
+ class="toolbarbutton-1 message-header-view-button hdrTrashButton"
+ oncommand="goDoCommand(event.shiftKey ? 'cmd_shiftDelete' : 'cmd_delete');"/>
+ <toolbarbutton id="otherActionsButton"
+ type="menu"
+ wantdropmarker="true"
+ label="&otherActionsButton2.label;"
+ tooltiptext="&otherActionsButton.tooltip;"
+ class="toolbarbutton-1 message-header-view-button">
+ <menupopup id="otherActionsPopup"
+ class="no-icon-menupopup"
+ onpopupshowing="onShowOtherActionsPopup();">
+ <menuitem id="otherActionsRedirect"
+ class="menuitem-iconic"
+ data-l10n-id="other-action-redirect-msg"
+ oncommand="MsgRedirectMessage();"/>
+#ifdef MAIN_WINDOW
+ <menuseparator id="otherActionsRedirectSeparator"/>
+ <menuitem id="otherActionsOpenConversation"
+ class="menuitem-iconic"
+ label="&otherActionsOpenConversation1.label;"
+ accesskey="&otherActionsOpenConversation1.accesskey;"
+ oncommand="new ConversationOpener(window).openConversationForMessages(gFolderDisplay.selectedMessages);"/>
+ <menuitem id="otherActionsOpenInNewWindow"
+ class="menuitem-iconic"
+ label="&otherActionsOpenInNewWindow1.label;"
+ accesskey="&otherActionsOpenInNewWindow1.accesskey;"
+ oncommand="MsgOpenNewWindowForMessage();"/>
+ <menuitem id="otherActionsOpenInNewTab"
+ class="menuitem-iconic"
+ label="&otherActionsOpenInNewTab1.label;"
+ accesskey="&otherActionsOpenInNewTab1.accesskey;"
+ oncommand="OpenMessageInNewTab(gFolderDisplay.selectedMessage, { event });"/>
+#endif
+ <menuseparator id="otherActionsSeparator"/>
+ <menu id="otherActionsTag"
+ class="menu-iconic"
+ label="&tagMenu.label;"
+ accesskey="&tagMenu.accesskey;">
+ <menupopup id="hdrTagDropdown"
+ onpopupshowing="window.top.InitMessageTags(this);">
+ <menuitem id="hdrTagDropdown-addNewTag"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"
+ oncommand="goDoCommand('cmd_addTag');"/>
+ <menuitem id="manageTags"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"
+ oncommand="goDoCommand('cmd_manageTags');"/>
+ <menuseparator id="hdrTagDropdown-sep-afterAddNewTag"/>
+ <menuitem id="hdrTagDropdown-tagRemoveAll"
+ oncommand="goDoCommand('cmd_removeTags');"/>
+ <menuseparator id="hdrTagDropdown-sep-afterTagRemoveAll"/>
+ </menupopup>
+ </menu>
+ <menuitem id="markAsReadMenuItem"
+ class="menuitem-iconic"
+ label="&markAsReadMenuItem1.label;"
+ accesskey="&markAsReadMenuItem1.accesskey;"
+ oncommand="MsgMarkMsgAsRead();"/>
+ <menuitem id="markAsUnreadMenuItem"
+ class="menuitem-iconic"
+ label="&markAsUnreadMenuItem1.label;"
+ accesskey="&markAsUnreadMenuItem1.accesskey;"
+ oncommand="MsgMarkMsgAsRead();"/>
+ <menuitem id="saveAsMenuItem"
+ class="menuitem-iconic"
+ label="&saveAsMenuItem1.label;"
+ accesskey="&saveAsMenuItem1.accesskey;"
+ oncommand="goDoCommand('cmd_saveAsFile')"/>
+ <menuitem id="otherActionsPrint"
+ class="menuitem-iconic"
+ label="&otherActionsPrint1.label;"
+ accesskey="&otherActionsPrint1.accesskey;"
+ oncommand="goDoCommand('cmd_print');"/>
+ <menu id="otherActions-calendar-convert-menu"
+ class="menu-iconic"
+ label="&calendar.context.convertmenu.label;"
+ accesskey="&calendar.context.convertmenu.accesskey.mail;">
+ <menupopup id="otherActions-calendar-convert-menupopup"
+ class="no-icon-menupopup">
+ <menuitem id="otherActions-calendar-convert-event-menuitem"
+ class="menuitem-iconic"
+ label="&calendar.context.convertmenu.event.label;"
+ accesskey="&calendar.context.convertmenu.event.accesskey;"
+ oncommand="convertToEventOrTask(true);"/>
+ <menuitem id="otherActions-calendar-convert-task-menuitem"
+ class="menuitem-iconic"
+ label="&calendar.context.convertmenu.task.label;"
+ accesskey="&calendar.context.convertmenu.task.accesskey;"
+ oncommand="convertToEventOrTask(false);"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="otherActionsComposeSeparator"/>
+ <menuitem id="viewSourceMenuItem"
+ class="menuitem-iconic"
+ label="&viewSourceMenuItem1.label;"
+ accesskey="&viewSourceMenuItem1.accesskey;"
+ oncommand="goDoCommand('cmd_viewPageSource');"/>
+ <menuitem id="charsetRepairMenuitem"
+ class="menuitem-iconic"
+ data-l10n-id="repair-text-encoding-button"
+ oncommand="MailSetCharacterSet()"/>
+ <menu id="otherActionsMessageBodyAs"
+ class="menu-iconic"
+ label="&bodyMenu.label;">
+ <menupopup id="hdrMessageBodyAsDropdown"
+ class="menu-iconic"
+ onpopupshowing="InitOtherActionsViewBodyMenu();">
+ <menuitem id="otherActionsMenu_bodyAllowHTML"
+ class="menuitem-iconic"
+ type="radio"
+ name="bodyPlaintextVsHTMLPref"
+ label="&bodyAllowHTML.label;"
+ oncommand="top.MsgBodyAllowHTML()"/>
+ <menuitem id="otherActionsMenu_bodySanitized"
+ class="menuitem-iconic"
+ type="radio"
+ name="bodyPlaintextVsHTMLPref"
+ label="&bodySanitized.label;"
+ oncommand="top.MsgBodySanitized()"/>
+ <menuitem id="otherActionsMenu_bodyAsPlaintext"
+ class="menuitem-iconic"
+ type="radio"
+ name="bodyPlaintextVsHTMLPref"
+ label="&bodyAsPlaintext.label;"
+ oncommand="top.MsgBodyAsPlaintext()"/>
+ <menuitem id="otherActionsMenu_bodyAllParts"
+ class="menuitem-iconic"
+ type="radio"
+ name="bodyPlaintextVsHTMLPref"
+ label="&bodyAllParts.label;"
+ oncommand="top.MsgBodyAllParts()"/>
+ </menupopup>
+ </menu>
+ <menu id="otherActionsFeedBodyAs"
+ class="menu-iconic"
+ label="&bodyMenu.label;">
+ <menupopup id="hdrFeedBodyAsDropdown"
+ class="menu-iconic"
+ onpopupshowing="InitOtherActionsViewBodyMenu();">
+ <menuitem id="otherActionsMenu_bodyFeedGlobalWebPage"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedWebPage.label;"
+ observes="bodyFeedGlobalWebPage"
+ oncommand="FeedMessageHandler.onSelectPref = 0"/>
+ <menuitem id="otherActionsMenu_bodyFeedGlobalSummary"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedSummary.label;"
+ observes="bodyFeedGlobalSummary"
+ oncommand="FeedMessageHandler.onSelectPref = 1"/>
+ <menuitem id="otherActionsMenu_bodyFeedPerFolderPref"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedSummaryFeedPropsPref.label;"
+ observes="bodyFeedPerFolderPref"
+ oncommand="FeedMessageHandler.onSelectPref = 2"/>
+ <menuseparator id="otherActionsMenu_viewFeedSummarySeparator"/>
+ <menuitem id="otherActionsMenu_bodyFeedSummaryAllowHTML"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodyAllowHTML.label;"
+ oncommand="top.MsgFeedBodyRenderPrefs(false, 0, 0)"/>
+ <menuitem id="otherActionsMenu_bodyFeedSummarySanitized"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodySanitized.label;"
+ oncommand="top.MsgFeedBodyRenderPrefs(false, 3, gDisallow_classes_no_html)"/>
+ <menuitem id="otherActionsMenu_bodyFeedSummaryAsPlaintext"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodyAsPlaintext.label;"
+ oncommand="top.MsgFeedBodyRenderPrefs(true, 1, gDisallow_classes_no_html)"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menuitem id="messageHeaderMoreMenuCustomize"
+ class="menuitem-iconic"
+ data-l10n-id="menuitem-customize-label"
+ oncommand="gHeaderCustomize.showPanel()"/>
+ </menupopup>
+ </toolbarbutton>
+ <html:button id="starMessageButton" role="checkbox" aria-checked="false"
+ class="plain-button email-action-button email-action-flagged"
+ data-l10n-id="message-header-msg-flagged">
+ <html:img src="chrome://messenger/skin/icons/new/compact/star.svg" alt="" />
+ </html:button>
+ </hbox>
+ </html:div>
+
+ <html:div id="expandedfromRow" class="header-row-grow">
+ <label id="expandedfromLabel" class="message-header-label header-pill-label"
+ value="&fromField4.label;"
+ valueFrom="&fromField4.label;" valueAuthor="&author.label;"/>
+ <html:div id="expandedfromBox" is="multi-recipient-row"
+ data-header-name="from" data-show-all="true"></html:div>
+ </html:div>
+ </html:div>
+
+ <!-- To recipients + date -->
+ <html:div id="expandedtoRow" class="message-header-row">
+ <html:div class="header-row-grow">
+ <label id="expandedtoLabel" class="message-header-label header-pill-label"
+ value="&toField4.label;"/>
+ <html:div id="expandedtoBox" is="multi-recipient-row"
+ data-header-name="to"></html:div>
+ </html:div>
+ <html:time id="dateLabel"
+ class="message-header-datetime"
+ aria-readonly="true"></html:time>
+ </html:div>
+
+ <!-- Cc recipients -->
+ <html:div id="expandedccRow" class="message-header-row" hidden="hidden">
+ <label id="expandedccLabel" class="message-header-label header-pill-label"
+ value="&ccField4.label;"/>
+ <html:div id="expandedccBox" is="multi-recipient-row"
+ data-header-name="cc"></html:div>
+ </html:div>
+
+ <!-- Bcc recipients -->
+ <html:div id="expandedbccRow" class="message-header-row" hidden="hidden">
+ <label id="expandedbccLabel" class="message-header-label header-pill-label"
+ value="&bccField4.label;"/>
+ <html:div id="expandedbccBox" is="multi-recipient-row"
+ data-header-name="bcc"></html:div>
+ </html:div>
+
+ <!-- Reply-to -->
+ <html:div id="expandedreply-toRow" class="message-header-row" hidden="hidden">
+ <label id="expandedreply-toLabel" class="message-header-label header-pill-label"
+ value="&replyToField4.label;"/>
+ <html:div id="expandedreply-toBox" is="multi-recipient-row"
+ data-header-name="reply-to"></html:div>
+ </html:div>
+
+ <!-- Organization -->
+ <html:div id="expandedorganizationRow" class="message-header-row" hidden="hidden">
+ <label id="expandedorganizationLabel" class="message-header-label"
+ value="&organizationField4.label;"/>
+ <html:div id="expandedorganizationBox" is="simple-header-row"
+ data-header-name="organization"></html:div>
+ </html:div>
+
+ <!-- Sender -->
+ <html:div id="expandedsenderRow" class="message-header-row" hidden="hidden">
+ <label id="expandedsenderLabel" class="message-header-label header-pill-label"
+ value="&senderField4.label;"/>
+ <html:div id="expandedsenderBox" is="multi-recipient-row"
+ data-header-name="sender"></html:div>
+ </html:div>
+
+ <!-- Newsgroups -->
+ <html:div id="expandednewsgroupsRow" class="message-header-row" hidden="hidden">
+ <label id="expandednewsgroupsLabel" class="message-header-label header-pill-label"
+ value="&newsgroupsField4.label;"/>
+ <html:div id="expandednewsgroupsBox" is="header-newsgroups-row"
+ data-header-name="newsgroups"/>
+ </html:div>
+
+ <!-- Follow up -->
+ <html:div id="expandedfollowup-toRow" class="message-header-row" hidden="hidden">
+ <label id="expandedfollowup-toLabel" class="message-header-label header-pill-label"
+ value="&followupToField4.label;"/>
+ <html:div id="expandedfollowup-toBox"
+ is="header-newsgroups-row"
+ data-header-name="followup-to"/>
+ </html:div>
+
+ <!-- Subject + security info + extra date label for hidden To variation -->
+ <html:div id="headerSubjectSecurityContainer" class="message-header-row">
+ <html:div id="expandedsubjectRow" class="header-row-grow">
+ <label id="expandedsubjectLabel" class="message-header-label"
+ value="&subjectField4.label;"/>
+ <html:div id="expandedsubjectBox" is="simple-header-row"
+ data-header-name="subject"></html:div>
+ </html:div>
+ <html:div id="cryptoBox" hidden="hidden">
+ <html:button id="encryptionTechBtn"
+ class="toolbarbutton-1 crypto-button themeable-brighttext button-focusable"
+ data-l10n-id="message-security-button">
+ <html:span class="crypto-label"></html:span>
+ <html:img id="encryptedHdrIcon" hidden="hidden" alt="" />
+ <html:img id="signedHdrIcon" hidden="hidden" alt="" />
+ </html:button>
+ </html:div>
+ <html:time id="dateLabelSubject"
+ class="message-header-datetime"
+ aria-readonly="true"
+ hidden="hidden"></html:time>
+ </html:div>
+
+ <!-- Tags -->
+ <html:div id="expandedtagsRow" class="message-header-row" hidden="hidden">
+ <label id="expandedtagsLabel" class="message-header-label"
+ value="&tagsHdr4.label;"/>
+ <html:div id="expandedtagsBox" is="header-tags-row"
+ data-header-name="tags"></html:div>
+ </html:div>
+
+ <!-- Date -->
+ <html:div id="expandeddateRow" class="message-header-row" hidden="hidden">
+ <label id="expandeddateLabel" class="message-header-label"
+ value="&dateField4.label;"/>
+ <html:div id="expandeddateBox" is="simple-header-row"
+ data-header-name="date"></html:div>
+ </html:div>
+
+ <!-- Message ID -->
+ <html:div id="expandedmessage-idRow" class="message-header-row" hidden="hidden">
+ <label id="expandedmessage-idLabel" class="message-header-label header-pill-label"
+ value="&messageIdField4.label;"/>
+ <html:div id="expandedmessage-idBox" is="multi-message-ids-row"
+ data-header-name="message-id"></html:div>
+ </html:div>
+
+ <!-- In reply to -->
+ <html:div id="expandedin-reply-toRow" class="message-header-row" hidden="hidden">
+ <label id="expandedin-reply-toLabel" class="message-header-label header-pill-label"
+ value="&inReplyToField4.label;"/>
+ <html:div id="expandedin-reply-toBox" is="multi-message-ids-row"
+ data-header-name="in-reply-to"></html:div>
+ </html:div>
+
+ <!-- Reference -->
+ <html:div id="expandedreferencesRow" class="message-header-row" hidden="hidden">
+ <label id="expandedreferencesLabel" class="message-header-label header-pill-label"
+ value="&referencesField4.label;"/>
+ <html:div id="expandedreferencesBox" is="multi-message-ids-row"
+ data-header-name="references"></html:div>
+ </html:div>
+
+ <!-- Content base -->
+ <html:div id="expandedcontent-baseRow" class="message-header-row" hidden="hidden">
+ <label id="expandedcontent-baseLabel" class="message-header-label"
+ value="&originalWebsite4.label;"/>
+ <html:div id="expandedcontent-baseBox" is="url-header-row"
+ data-header-name="content-base"></html:div>
+ </html:div>
+
+ <!-- User agent -->
+ <html:div id="expandeduser-agentRow" class="message-header-row" hidden="hidden">
+ <label id="expandeduser-agentLabel" class="message-header-label"
+ value="&userAgentField4.label;"/>
+ <html:div id="expandeduser-agentBox" is="simple-header-row"
+ data-header-name="user-agent"></html:div>
+ </html:div>
+
+ <!-- All extra headers will be dynamically added here. -->
+ <html:div id="extraHeadersArea" class="message-header-extra-container"></html:div>
+
+</html:header>
+<!-- END Header container -->
+
+<panel id="messageHeaderCustomizationPanel"
+ type="arrow"
+ orient="vertical"
+ class="cui-widget-panel popup-panel panel-no-padding"
+ onpopupshowing="gHeaderCustomize.onPanelShowing();"
+ onpopuphidden="gHeaderCustomize.onPanelHidden();">
+ <html:div class="popup-panel-body">
+ <html:h3 data-l10n-id="message-header-customize-panel-title"></html:h3>
+
+ <html:div class="popup-panel-options-grid">
+ <label data-l10n-id="message-header-customize-button-style"
+ control="headerButtonStyle"/>
+ <menulist id="headerButtonStyle"
+ oncommand="gHeaderCustomize.updateButtonStyle(event);"
+ crop="none">
+ <menupopup>
+ <menuitem value="default" data-l10n-id="message-header-button-style-default"/>
+ <menuitem value="only-text" data-l10n-id="message-header-button-style-text"/>
+ <menuitem value="only-icons" data-l10n-id="message-header-button-style-icons"/>
+ </menupopup>
+ </menulist>
+
+ <html:div class="popup-panel-column-container">
+ <checkbox id="headerShowAvatar"
+ data-l10n-id="message-header-show-recipient-avatar"
+ oncommand="gHeaderCustomize.toggleAvatar(event);"/>
+
+ <checkbox id="headerShowBigAvatar" class="indent"
+ data-l10n-id="message-header-show-big-avatar"
+ oncommand="gHeaderCustomize.toggleBigAvatar(event);"/>
+
+ <checkbox id="headerShowFullAddress"
+ data-l10n-id="message-header-show-sender-full-address"
+ oncommand="gHeaderCustomize.toggleSenderAddress(event);"/>
+ <html:span class="checkbox-description"
+ data-l10n-id="message-header-show-sender-full-address-description"></html:span>
+
+ <checkbox id="headerHideLabels"
+ data-l10n-id="message-header-hide-label-column"
+ oncommand="gHeaderCustomize.toggleLabelColumn(event);"/>
+
+ <checkbox id="headerSubjectLarge"
+ data-l10n-id="message-header-large-subject"
+ oncommand="gHeaderCustomize.updateSubjectStyle(event);"/>
+
+ <checkbox id="headerViewAllHeaders"
+ data-l10n-id="message-header-all-headers"
+ oncommand="gHeaderCustomize.toggleAllHeaders(event);"/>
+ </html:div>
+ </html:div>
+
+ <html:div class="popup-panel-buttons-container">
+ <html:button type="button"
+ data-l10n-id="customize-panel-button-save"
+ onclick="gHeaderCustomize.closePanel()"
+ class="primary">
+ </html:button>
+ </html:div>
+ </html:div>
+</panel>
diff --git a/comm/mail/base/content/msgHdrView.js b/comm/mail/base/content/msgHdrView.js
new file mode 100644
index 0000000000..1579cddbfe
--- /dev/null
+++ b/comm/mail/base/content/msgHdrView.js
@@ -0,0 +1,4501 @@
+/* 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 related to displaying the headers for a selected message in the
+ * message pane.
+ */
+
+/* import-globals-from ../../../../toolkit/content/contentAreaUtils.js */
+/* import-globals-from ../../../calendar/base/content/imip-bar.js */
+/* import-globals-from ../../../mailnews/extensions/newsblog/newsblogOverlay.js */
+/* import-globals-from ../../extensions/smime/content/msgHdrViewSMIMEOverlay.js */
+/* import-globals-from aboutMessage.js */
+/* import-globals-from editContactPanel.js */
+/* import-globals-from globalOverlay.js */
+/* import-globals-from mailContext.js */
+/* import-globals-from mail-offline.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from msgSecurityPane.js */
+
+/* globals MozElements */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ calendarDeactivator:
+ "resource:///modules/calendar/calCalendarDeactivator.jsm",
+ Gloda: "resource:///modules/gloda/GlodaPublic.jsm",
+ GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MessageArchiver: "resource:///modules/MessageArchiver.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gDbService",
+ "@mozilla.org/msgDatabase/msgDBService;1",
+ "nsIMsgDBService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gHandlerService",
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gEncryptedSMIMEURIsService",
+ "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1",
+ Ci.nsIEncryptedSMIMEURIsService
+);
+
+// Warning: It's critical that the code in here for displaying the message
+// headers for a selected message remain as fast as possible. In particular,
+// right now, we only introduce one reflow per message. i.e. if you click on
+// a message in the thread pane, we batch up all the changes for displaying
+// the header pane (to, cc, attachments button, etc.) and we make a single
+// pass to display them. It's critical that we maintain this one reflow per
+// message view in the message header pane.
+
+var gViewAllHeaders = false;
+var gMinNumberOfHeaders = 0;
+var gDummyHeaderIdIndex = 0;
+var gBuildAttachmentsForCurrentMsg = false;
+var gBuiltExpandedView = false;
+var gHeadersShowReferences = false;
+
+/**
+ * Show the friendly display names for people I know,
+ * instead of the name + email address.
+ */
+var gShowCondensedEmailAddresses;
+
+/**
+ * Other components may listen to on start header & on end header notifications
+ * for each message we display: to do that you need to add yourself to our
+ * gMessageListeners array with an object that supports the three properties:
+ * onStartHeaders, onEndHeaders and onEndAttachments.
+ *
+ * Additionally, if your object has an onBeforeShowHeaderPane() method, it will
+ * be called at the appropriate time. This is designed to give add-ons a
+ * chance to examine and modify the currentHeaderData array before it gets
+ * displayed.
+ */
+var gMessageListeners = [];
+
+/**
+ * List fo common headers that need to be populated.
+ *
+ * For every possible "view" in the message pane, you need to define the header
+ * names you want to see in that view. In addition, include information
+ * describing how you want that header field to be presented. We'll then use
+ * this static table to dynamically generate header view entries which
+ * manipulate the UI.
+ *
+ * @param {string} name - The name of the header. i.e. "to", "subject". This
+ * must be in lower case and the name of the header is used to help
+ * dynamically generate ids for objects in the document.
+ * @param {Function} outputFunction - This is a method which takes a headerEntry
+ * (see the definition below) and a header value. This allows to provide a
+ * unique methods for determining how the header value is displayed. Defaults
+ * to updateHeaderValue which just sets the header value on the text node.
+ */
+const gExpandedHeaderList = [
+ { name: "subject" },
+ { name: "from", outputFunction: outputEmailAddresses },
+ { name: "reply-to", outputFunction: outputEmailAddresses },
+ { name: "to", outputFunction: outputEmailAddresses },
+ { name: "cc", outputFunction: outputEmailAddresses },
+ { name: "bcc", outputFunction: outputEmailAddresses },
+ { name: "newsgroups", outputFunction: outputNewsgroups },
+ { name: "references", outputFunction: outputMessageIds },
+ { name: "followup-to", outputFunction: outputNewsgroups },
+ { name: "content-base" },
+ { name: "tags", outputFunction: outputTags },
+];
+
+/**
+ * These are all the items that use a multi-recipient-row widget and
+ * therefore may require updating if the address book changes.
+ */
+var gEmailAddressHeaderNames = [
+ "from",
+ "reply-to",
+ "to",
+ "cc",
+ "bcc",
+ "toCcBcc",
+];
+
+/**
+ * Now, for each view the message pane can generate, we need a global table of
+ * headerEntries. These header entry objects are generated dynamically based on
+ * the static data in the header lists (see above) and elements we find in the
+ * DOM based on properties in the header lists.
+ */
+var gExpandedHeaderView = {};
+
+/**
+ * This is an array of header name and value pairs for the currently displayed
+ * message. It's purely a data object and has no view information. View
+ * information is contained in the view objects.
+ * For a given entry in this array you can ask for:
+ * .headerName name of the header (i.e. 'to'). Always stored in lower case
+ * .headerValue value of the header "johndoe@example.com"
+ */
+var currentHeaderData = {};
+
+/**
+ * CurrentAttachments is an array of AttachmentInfo objects.
+ */
+var currentAttachments = [];
+
+/**
+ * The character set of the message, according to the MIME parser.
+ */
+var currentCharacterSet = "";
+
+/**
+ * Folder database listener object. This is used alongside the
+ * nsIDBChangeListener implementation in order to listen for the changes of the
+ * messages' flags that don't trigger a messageHeaderSink.processHeaders().
+ * For now, it's used only for the flagged/marked/starred flag, but it could be
+ * extended to handle other flags changes and remove the full header reload.
+ */
+var gFolderDBListener = null;
+
+// Timer to mark read, if the user has configured the app to mark a message as
+// read if it is viewed for more than n seconds.
+var gMarkViewedMessageAsReadTimer = null;
+
+// Per message header flags to keep track of whether the user is allowing remote
+// content for a particular message.
+// if you change or add more values to these constants, be sure to modify
+// the corresponding definitions in nsMsgContentPolicy.cpp
+var kNoRemoteContentPolicy = 0;
+var kBlockRemoteContent = 1;
+var kAllowRemoteContent = 2;
+
+class FolderDBListener {
+ constructor(folder) {
+ // Keep a record of the currently selected folder to check when the
+ // selection changes to avoid initializing the DBListener in case the same
+ // folder is selected.
+ this.selectedFolder = folder;
+ this.isRegistered = false;
+ }
+
+ register() {
+ gDbService.registerPendingListener(this.selectedFolder, this);
+ this.isRegistered = true;
+ }
+
+ unregister() {
+ gDbService.unregisterPendingListener(this);
+ this.isRegistered = false;
+ }
+
+ /** @implements {nsIDBChangeListener} */
+ onHdrFlagsChanged(hdrChanged, oldFlags, newFlags, instigator) {
+ // Bail out if the changed message isn't the one currently displayed.
+ if (hdrChanged != gMessage) {
+ return;
+ }
+
+ // Check if the flagged/marked/starred state was changed.
+ if (
+ newFlags & Ci.nsMsgMessageFlags.Marked ||
+ oldFlags & Ci.nsMsgMessageFlags.Marked
+ ) {
+ updateStarButton();
+ }
+ }
+ onHdrDeleted(hdrChanged, parentKey, flags, instigator) {}
+ onHdrAdded(hdrChanged, parentKey, flags, instigator) {}
+ onParentChanged(keyChanged, oldParent, newParent, instigator) {}
+ onAnnouncerGoingAway(instigator) {}
+ onReadChanged(instigator) {}
+ onJunkScoreChanged(instigator) {}
+ onHdrPropertyChanged(hdrToChange, property, preChange, status, instigator) {
+ // Not interested before a change, or if the message isn't the one displayed,
+ // or an .eml file from disk or an attachment.
+ if (preChange || gMessage != hdrToChange) {
+ return;
+ }
+ switch (property) {
+ case "keywords":
+ OnTagsChange();
+ break;
+ case "junkscore":
+ HandleJunkStatusChanged(hdrToChange);
+ break;
+ }
+ }
+ onEvent(db, event) {}
+}
+
+/**
+ * Initialize the nsIDBChangeListener when a new folder is selected in order to
+ * listen for any flags change happening in the currently displayed messages.
+ */
+function initFolderDBListener() {
+ // Bail out if we don't have a selected message, or we already have a
+ // DBListener initialized and the folder didn't change.
+ if (
+ !gFolder ||
+ (gFolderDBListener?.isRegistered &&
+ gFolderDBListener.selectedFolder == gFolder)
+ ) {
+ return;
+ }
+
+ // Clearly we are viewing a different message in a different folder, so clear
+ // any remaining of the old DBListener.
+ clearFolderDBListener();
+
+ gFolderDBListener = new FolderDBListener(gFolder);
+ gFolderDBListener.register();
+}
+
+/**
+ * Unregister the listener and clear the object if we already have one, meaning
+ * the user just changed folder or deselected all messages.
+ */
+function clearFolderDBListener() {
+ if (gFolderDBListener?.isRegistered) {
+ gFolderDBListener.unregister();
+ gFolderDBListener = null;
+ }
+}
+
+/**
+ * Our class constructor method which creates a header Entry based on an entry
+ * in one of the header lists. A header entry is different from a header list.
+ * A header list just describes how you want a particular header to be
+ * presented. The header entry actually has knowledge about the DOM
+ * and the actual DOM elements associated with the header.
+ *
+ * @param prefix the name of the view (e.g. "expanded")
+ * @param headerListInfo entry from a header list.
+ */
+class MsgHeaderEntry {
+ constructor(prefix, headerListInfo) {
+ this.enclosingBox = document.getElementById(
+ `${prefix}${headerListInfo.name}Box`
+ );
+ this.enclosingRow = this.enclosingBox.closest(".message-header-row");
+ this.isNewHeader = false;
+ this.valid = false;
+ this.outputFunction = headerListInfo.outputFunction || updateHeaderValue;
+ }
+}
+
+function initializeHeaderViewTables() {
+ // Iterate over each header in our header list arrays and create header entries
+ // for each one. These header entries are then stored in the appropriate header
+ // table.
+ for (let header of gExpandedHeaderList) {
+ gExpandedHeaderView[header.name] = new MsgHeaderEntry("expanded", header);
+ }
+
+ let extraHeaders = Services.prefs
+ .getCharPref("mailnews.headers.extraExpandedHeaders")
+ .split(" ");
+ for (let extraHeaderName of extraHeaders) {
+ if (!extraHeaderName.trim()) {
+ continue;
+ }
+ gExpandedHeaderView[extraHeaderName.toLowerCase()] = new HeaderView(
+ extraHeaderName,
+ extraHeaderName
+ );
+ }
+
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+
+ for (let otherHeaderName of otherHeaders) {
+ gExpandedHeaderView[otherHeaderName.toLowerCase()] = new HeaderView(
+ otherHeaderName,
+ otherHeaderName
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showOrganization")) {
+ var organizationEntry = {
+ name: "organization",
+ outputFunction: updateHeaderValue,
+ };
+ gExpandedHeaderView[organizationEntry.name] = new MsgHeaderEntry(
+ "expanded",
+ organizationEntry
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showUserAgent")) {
+ var userAgentEntry = {
+ name: "user-agent",
+ outputFunction: updateHeaderValue,
+ };
+ gExpandedHeaderView[userAgentEntry.name] = new MsgHeaderEntry(
+ "expanded",
+ userAgentEntry
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showMessageId")) {
+ var messageIdEntry = {
+ name: "message-id",
+ outputFunction: outputMessageIds,
+ };
+ gExpandedHeaderView[messageIdEntry.name] = new MsgHeaderEntry(
+ "expanded",
+ messageIdEntry
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showSender")) {
+ let senderEntry = {
+ name: "sender",
+ outputFunction: outputEmailAddresses,
+ };
+ gExpandedHeaderView[senderEntry.name] = new MsgHeaderEntry(
+ "expanded",
+ senderEntry
+ );
+ }
+}
+
+async function OnLoadMsgHeaderPane() {
+ // Load any preferences that at are global with regards to
+ // displaying a message...
+ gMinNumberOfHeaders = Services.prefs.getIntPref(
+ "mailnews.headers.minNumHeaders"
+ );
+ gShowCondensedEmailAddresses = Services.prefs.getBoolPref(
+ "mail.showCondensedAddresses"
+ );
+ gHeadersShowReferences = Services.prefs.getBoolPref(
+ "mailnews.headers.showReferences"
+ );
+
+ Services.obs.addObserver(MsgHdrViewObserver, "remote-content-blocked");
+ Services.prefs.addObserver("mail.showCondensedAddresses", MsgHdrViewObserver);
+ Services.prefs.addObserver(
+ "mailnews.headers.showReferences",
+ MsgHdrViewObserver
+ );
+
+ initializeHeaderViewTables();
+
+ // Add the keyboard shortcut event listener for the message header.
+ // Ctrl+Alt+S / Cmd+Control+S. We don't use the Alt/Option key on macOS
+ // because it alters the pressed key to an ASCII character. See bug 1692263.
+ let shortcut = await document.l10n.formatValue(
+ "message-header-show-security-info-key"
+ );
+ document.addEventListener("keypress", event => {
+ if (
+ event.ctrlKey &&
+ (event.altKey || event.metaKey) &&
+ event.key.toLowerCase() == shortcut.toLowerCase()
+ ) {
+ showMessageReadSecurityInfo();
+ }
+ });
+
+ headerToolbarNavigation.init();
+
+ // Set up event listeners for the encryption technology button and panel.
+ document
+ .getElementById("encryptionTechBtn")
+ .addEventListener("click", showMessageReadSecurityInfo);
+ let panel = document.getElementById("messageSecurityPanel");
+ panel.addEventListener("popuphidden", onMessageSecurityPopupHidden);
+
+ // Set the flag/star button on click listener.
+ document
+ .getElementById("starMessageButton")
+ .addEventListener("click", MsgMarkAsFlagged);
+
+ // Dispatch an event letting any listeners know that we have loaded
+ // the message pane.
+ let headerViewElement = document.getElementById("msgHeaderView");
+ headerViewElement.loaded = true;
+ headerViewElement.dispatchEvent(
+ new Event("messagepane-loaded", { bubbles: false, cancelable: true })
+ );
+
+ getMessagePaneBrowser().addProgressListener(
+ messageProgressListener,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+
+ gHeaderCustomize.init();
+}
+
+function OnUnloadMsgHeaderPane() {
+ let headerViewElement = document.getElementById("msgHeaderView");
+ if (!headerViewElement.loaded) {
+ // We're unloading, but we never loaded.
+ return;
+ }
+
+ Services.obs.removeObserver(MsgHdrViewObserver, "remote-content-blocked");
+ Services.prefs.removeObserver(
+ "mail.showCondensedAddresses",
+ MsgHdrViewObserver
+ );
+ Services.prefs.removeObserver(
+ "mailnews.headers.showReferences",
+ MsgHdrViewObserver
+ );
+
+ clearFolderDBListener();
+
+ // Dispatch an event letting any listeners know that we have unloaded
+ // the message pane.
+ headerViewElement.dispatchEvent(
+ new Event("messagepane-unloaded", { bubbles: false, cancelable: true })
+ );
+}
+
+var MsgHdrViewObserver = {
+ observe(subject, topic, data) {
+ // verify that we're changing the mail pane config pref
+ if (topic == "nsPref:changed") {
+ // We don't need to call ReloadMessage() in either of these conditions
+ // because a preference observer for these preferences already does it.
+ if (data == "mail.showCondensedAddresses") {
+ gShowCondensedEmailAddresses = Services.prefs.getBoolPref(
+ "mail.showCondensedAddresses"
+ );
+ } else if (data == "mailnews.headers.showReferences") {
+ gHeadersShowReferences = Services.prefs.getBoolPref(
+ "mailnews.headers.showReferences"
+ );
+ }
+ } else if (topic == "remote-content-blocked") {
+ let browser = getMessagePaneBrowser();
+ if (
+ browser.browsingContext.id == data ||
+ browser.browsingContext == BrowsingContext.get(data)?.top
+ ) {
+ gMessageNotificationBar.setRemoteContentMsg(
+ null,
+ subject,
+ !gEncryptedSMIMEURIsService.isEncrypted(browser.currentURI.spec)
+ );
+ }
+ }
+ },
+};
+
+/**
+ * Receives a message's headers as we display the message through our mime converter.
+ *
+ * @see {nsIMailChannel}
+ * @implements {nsIMailProgressListener}
+ * @implements {nsIWebProgressListener}
+ * @implements {nsISupportsWeakReference}
+ */
+var messageProgressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMailProgressListener",
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ /**
+ * Step 1: A message has started loading (if the flags include STATE_START).
+ *
+ * @param {nsIWebProgress} webProgress
+ * @param {nsIRequest} request
+ * @param {integer} stateFlags
+ * @param {nsresult} status
+ * @see {nsIWebProgressListener}
+ */
+ onStateChange(webProgress, request, stateFlags, status) {
+ if (
+ !(request instanceof Ci.nsIMailChannel) ||
+ !(stateFlags & Ci.nsIWebProgressListener.STATE_START)
+ ) {
+ return;
+ }
+
+ // Clear the previously displayed message.
+ const previousDocElement =
+ getMessagePaneBrowser().contentDocument?.documentElement;
+ if (previousDocElement) {
+ previousDocElement.style.display = "none";
+ }
+ ClearAttachmentList();
+ gMessageNotificationBar.clearMsgNotifications();
+
+ request.listener = this;
+ request.smimeHeaderSink = smimeHeaderSink;
+ this.onStartHeaders();
+ },
+
+ /**
+ * Step 2: The message headers are available on the channel.
+ *
+ * @param {nsIMailChannel} mailChannel
+ * @see {nsIMailProgressListener}
+ */
+ onHeadersComplete(mailChannel) {
+ const domWindow = getMessagePaneBrowser().docShell.DOMWindow;
+ domWindow.addEventListener(
+ "DOMContentLoaded",
+ event => this.onDOMContentLoaded(event),
+ { once: true }
+ );
+ this.processHeaders(mailChannel.headerNames, mailChannel.headerValues);
+ },
+
+ /**
+ * Step 3: The parser has finished reading the body of the message.
+ *
+ * @param {nsIMailChannel} mailChannel
+ * @see {nsIMailProgressListener}
+ */
+ onBodyComplete(mailChannel) {
+ autoMarkAsRead();
+ },
+
+ /**
+ * Step 4: The attachment information is available on the channel.
+ *
+ * @param {nsIMailChannel} mailChannel
+ * @see {nsIMailProgressListener}
+ */
+ onAttachmentsComplete(mailChannel) {
+ for (const attachment of mailChannel.attachments) {
+ this.handleAttachment(
+ attachment.getProperty("contentType"),
+ attachment.getProperty("url"),
+ attachment.getProperty("displayName"),
+ attachment.getProperty("uri"),
+ attachment.getProperty("notDownloaded")
+ );
+ for (const key of [
+ "X-Mozilla-PartURL",
+ "X-Mozilla-PartSize",
+ "X-Mozilla-PartDownloaded",
+ "Content-Description",
+ "Content-Type",
+ "Content-Encoding",
+ ]) {
+ if (attachment.hasKey(key)) {
+ this.addAttachmentField(key, attachment.getProperty(key));
+ }
+ }
+ }
+ },
+
+ /**
+ * Step 5: The message HTML is complete, but external resources such as may
+ * not have loaded yet. The docShell will handle them – for our purposes,
+ * message loading has finished.
+ */
+ onDOMContentLoaded(event) {
+ const { docShell } = event.target.ownerGlobal;
+ if (!docShell.isTopLevelContentDocShell) {
+ return;
+ }
+
+ const channel = docShell.currentDocumentChannel;
+ channel.QueryInterface(Ci.nsIMailChannel);
+ currentCharacterSet = channel.mailCharacterSet;
+ channel.smimeHeaderSink = null;
+ if (channel.imipItem) {
+ calImipBar.showImipBar(channel.imipItem, channel.imipMethod);
+ }
+ this.onEndAllAttachments();
+ const uri = channel.URI.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ this.onEndMsgHeaders(uri);
+ this.onEndMsgDownload(uri);
+ },
+
+ onStartHeaders() {
+ // Every time we start to redisplay a message, check the view all headers
+ // pref...
+ let showAllHeadersPref = Services.prefs.getIntPref("mail.show_headers");
+ if (showAllHeadersPref == 2) {
+ // eslint-disable-next-line no-global-assign
+ gViewAllHeaders = true;
+ } else {
+ if (gViewAllHeaders) {
+ // If we currently are in view all header mode, rebuild our header
+ // view so we remove most of the header data.
+ hideHeaderView(gExpandedHeaderView);
+ RemoveNewHeaderViews(gExpandedHeaderView);
+ gDummyHeaderIdIndex = 0;
+ // eslint-disable-next-line no-global-assign
+ gExpandedHeaderView = {};
+ initializeHeaderViewTables();
+ }
+
+ // eslint-disable-next-line no-global-assign
+ gViewAllHeaders = false;
+ }
+
+ document.title = "";
+ ClearCurrentHeaders();
+ gBuiltExpandedView = false;
+ gBuildAttachmentsForCurrentMsg = false;
+ ClearAttachmentList();
+ gMessageNotificationBar.clearMsgNotifications();
+
+ // Reset the blocked hosts so we can populate it again for this message.
+ document.getElementById("remoteContentOptions").value = "";
+
+ for (let listener of gMessageListeners) {
+ listener.onStartHeaders();
+ }
+ },
+
+ onEndHeaders() {
+ if (!gViewWrapper || !gMessage) {
+ // The view wrapper and/or message went away before we finished loading
+ // the message. Bail out.
+ return;
+ }
+
+ // Give add-ons a chance to modify currentHeaderData before it actually
+ // gets displayed.
+ for (let listener of gMessageListeners) {
+ if ("onBeforeShowHeaderPane" in listener) {
+ listener.onBeforeShowHeaderPane();
+ }
+ }
+
+ // Load feed web page if so configured. This entry point works for
+ // messagepane loads in 3pane folder tab, 3pane message tab, and the
+ // standalone message window.
+ if (!FeedMessageHandler.shouldShowSummary(gMessage, false)) {
+ FeedMessageHandler.setContent(gMessage, false);
+ }
+
+ ShowMessageHeaderPane();
+ // WARNING: This is the ONLY routine inside of the message Header Sink
+ // that should trigger a reflow!
+ ClearHeaderView(gExpandedHeaderView);
+
+ // Make sure there is a subject even if it's empty so we'll show the
+ // subject and the twisty.
+ EnsureSubjectValue();
+
+ // Make sure there is a from value even if empty so the header toolbar
+ // will show up.
+ EnsureFromValue();
+
+ // Only update the expanded view if it's actually selected and needs updating.
+ if (!gBuiltExpandedView) {
+ UpdateExpandedMessageHeaders();
+ }
+
+ gMessageNotificationBar.setDraftEditMessage();
+ updateHeaderToolbarButtons();
+
+ for (let listener of gMessageListeners) {
+ listener.onEndHeaders();
+ }
+ },
+
+ processHeaders(headerNames, headerValues) {
+ const kMailboxSeparator = ", ";
+ var index = 0;
+ for (let i = 0; i < headerNames.length; i++) {
+ let header = {
+ headerName: headerNames[i],
+ headerValue: headerValues[i],
+ };
+
+ // For consistency's sake, let us force all header names to be lower
+ // case so we don't have to worry about looking for: Cc and CC, etc.
+ var lowerCaseHeaderName = header.headerName.toLowerCase();
+
+ // If we have an x-mailer, x-mimeole, or x-newsreader string,
+ // put it in the user-agent slot which we know how to handle already.
+ if (/^x-(mailer|mimeole|newsreader)$/.test(lowerCaseHeaderName)) {
+ lowerCaseHeaderName = "user-agent";
+ }
+
+ // According to RFC 2822, certain headers can occur "unlimited" times.
+ if (lowerCaseHeaderName in currentHeaderData) {
+ // Sometimes, you can have multiple To or Cc lines....
+ // In this case, we want to append these headers into one.
+ if (lowerCaseHeaderName == "to" || lowerCaseHeaderName == "cc") {
+ currentHeaderData[lowerCaseHeaderName].headerValue =
+ currentHeaderData[lowerCaseHeaderName].headerValue +
+ "," +
+ header.headerValue;
+ } else {
+ // Use the index to create a unique header name like:
+ // received5, received6, etc
+ currentHeaderData[lowerCaseHeaderName + index++] = header;
+ }
+ } else {
+ currentHeaderData[lowerCaseHeaderName] = header;
+ }
+
+ // See RFC 5322 section 3.6 for min-max number for given header.
+ // If multiple headers exist we need to make sure to use the first one.
+ if (lowerCaseHeaderName == "subject" && !document.title) {
+ let fullSubject = "";
+ // Use the subject from the database, which may have been put there in
+ // decrypted form.
+ if (gMessage?.subject) {
+ if (gMessage.flags & Ci.nsMsgMessageFlags.HasRe) {
+ fullSubject = "Re: ";
+ }
+ fullSubject += gMessage.mime2DecodedSubject;
+ }
+ document.title = fullSubject || header.headerValue;
+ currentHeaderData.subject.headerValue = document.title;
+ }
+ } // while we have more headers to parse
+
+ // Process message tags as if they were headers in the message.
+ gMessageHeader.setTags();
+ updateStarButton();
+
+ if ("from" in currentHeaderData && "sender" in currentHeaderData) {
+ let senderMailbox =
+ kMailboxSeparator +
+ MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData.sender.headerValue
+ ) +
+ kMailboxSeparator;
+ let fromMailboxes =
+ kMailboxSeparator +
+ MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData.from.headerValue
+ ) +
+ kMailboxSeparator;
+ if (fromMailboxes.includes(senderMailbox)) {
+ delete currentHeaderData.sender;
+ }
+ }
+
+ // We don't need to show the reply-to header if its value is either
+ // the From field (totally pointless) or the To field (common for
+ // mailing lists, but not that useful).
+ if (
+ "from" in currentHeaderData &&
+ "to" in currentHeaderData &&
+ "reply-to" in currentHeaderData
+ ) {
+ let replyToMailbox =
+ MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData["reply-to"].headerValue
+ );
+ let fromMailboxes =
+ MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData.from.headerValue
+ );
+ let toMailboxes = MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData.to.headerValue
+ );
+
+ if (replyToMailbox == fromMailboxes || replyToMailbox == toMailboxes) {
+ delete currentHeaderData["reply-to"];
+ }
+ }
+
+ // For content-base urls stored uri encoded, we want to decode for
+ // display (and encode for external link open).
+ if ("content-base" in currentHeaderData) {
+ currentHeaderData["content-base"].headerValue = decodeURI(
+ currentHeaderData["content-base"].headerValue
+ );
+ }
+
+ let expandedfromLabel = document.getElementById("expandedfromLabel");
+ if (FeedUtils.isFeedMessage(gMessage)) {
+ expandedfromLabel.value = expandedfromLabel.getAttribute("valueAuthor");
+ } else {
+ expandedfromLabel.value = expandedfromLabel.getAttribute("valueFrom");
+ }
+
+ this.onEndHeaders();
+ },
+
+ handleAttachment(contentType, url, displayName, uri, isExternalAttachment) {
+ let newAttachment = new AttachmentInfo({
+ contentType,
+ url,
+ name: displayName,
+ uri,
+ isExternalAttachment,
+ message: gMessage,
+ updateAttachmentsDisplayFn: updateAttachmentsDisplay,
+ });
+ currentAttachments.push(newAttachment);
+
+ if (contentType == "application/pgp-keys" || displayName.endsWith(".asc")) {
+ Enigmail.msg.autoProcessPgpKeyAttachment(newAttachment);
+ }
+ },
+
+ addAttachmentField(field, value) {
+ let last = currentAttachments[currentAttachments.length - 1];
+ if (
+ field == "X-Mozilla-PartSize" &&
+ !last.isFileAttachment &&
+ !last.isDeleted
+ ) {
+ let size = parseInt(value);
+
+ if (last.isLinkAttachment) {
+ // Check if an external link attachment's reported size is sane.
+ // A size of < 2 isn't sensical so ignore such placeholder values.
+ // Don't accept a size with any non numerics. Also cap the number.
+ // We want the size to be checked again, upon user action, to make
+ // sure size is updated with an accurate value, so |sizeResolved|
+ // remains false.
+ if (isNaN(size) || size.toString().length != value.length || size < 2) {
+ last.size = -1;
+ } else if (size > Number.MAX_SAFE_INTEGER) {
+ last.size = Number.MAX_SAFE_INTEGER;
+ } else {
+ last.size = size;
+ }
+ } else {
+ // For internal or file (detached) attachments, save the size.
+ last.size = size;
+ // For external file attachments, we won't have a valid size.
+ if (!last.isFileAttachment && size > -1) {
+ last.sizeResolved = true;
+ }
+ }
+ } else if (field == "X-Mozilla-PartDownloaded" && value == "0") {
+ // We haven't downloaded the attachment, so any size we get from
+ // libmime is almost certainly inaccurate. Just get rid of it. (Note:
+ // this relies on the fact that PartDownloaded comes after PartSize from
+ // the MIME emitter.)
+ // Note: for imap parts_on_demand, a small size consisting of the part
+ // headers would have been returned above.
+ last.size = -1;
+ last.sizeResolved = false;
+ }
+ },
+
+ onEndAllAttachments() {
+ Enigmail.msg.notifyEndAllAttachments();
+
+ displayAttachmentsForExpandedView();
+
+ for (let listener of gMessageListeners) {
+ if ("onEndAttachments" in listener) {
+ listener.onEndAttachments();
+ }
+ }
+ },
+
+ /**
+ * This event is generated by nsMsgStatusFeedback when it gets an
+ * OnStateChange event for STATE_STOP. This is the same event that
+ * generates the "msgLoaded" property flag change event. This best
+ * corresponds to the end of the streaming process.
+ */
+ onEndMsgDownload(url) {
+ let browser = getMessagePaneBrowser();
+
+ // If we have no attachments, we hide the attachment icon in the message
+ // tree.
+ // PGP key attachments do not count as attachments for the purposes of the
+ // message tree, even though we still show them in the attachment list.
+ // Otherwise the attachment icon becomes less useful when someone receives
+ // lots of signed messages.
+ // We do the same if we only have text/vcard attachments because we
+ // *assume* the vcard attachment is a personal vcard (rather than an
+ // addressbook, or a shared contact) that is attached to every message.
+ // NOTE: There would be some obvious give-aways in the vcard content that
+ // this personal vcard assumption is incorrect (multiple contacts, or a
+ // contact with an address that is different from the sender address) but we
+ // do not have easy access to the attachment content here, so we just stick
+ // to the assumption.
+ // NOTE: If the message contains two vcard attachments (or more) then this
+ // would hint that one of the vcards is not personal, but we won't make an
+ // exception here to keep the implementation simple.
+ gMessage?.markHasAttachments(
+ currentAttachments.some(
+ att =>
+ att.contentType != "text/vcard" &&
+ att.contentType != "text/x-vcard" &&
+ att.contentType != "application/pgp-keys"
+ )
+ );
+
+ if (
+ currentAttachments.length &&
+ Services.prefs.getBoolPref("mail.inline_attachments") &&
+ FeedUtils.isFeedMessage(gMessage) &&
+ browser &&
+ browser.contentDocument &&
+ browser.contentDocument.body
+ ) {
+ for (let img of browser.contentDocument.body.getElementsByClassName(
+ "moz-attached-image"
+ )) {
+ for (let attachment of currentAttachments) {
+ let partID = img.src.split("&part=")[1];
+ partID = partID ? partID.split("&")[0] : null;
+ if (attachment.partID && partID == attachment.partID) {
+ img.src = attachment.url;
+ break;
+ }
+ }
+
+ img.addEventListener("load", function (event) {
+ if (this.clientWidth > this.parentNode.clientWidth) {
+ img.setAttribute("overflowing", "true");
+ img.setAttribute("shrinktofit", "true");
+ }
+ });
+ }
+ }
+
+ OnMsgParsed(url);
+ },
+
+ onEndMsgHeaders(url) {
+ if (!url.errorCode) {
+ // Should not mark a message as read if failed to load.
+ OnMsgLoaded(url);
+ }
+ },
+};
+
+/**
+ * Update the flagged (starred) state of the currently selected message.
+ */
+function updateStarButton() {
+ if (!gMessage || !gFolder) {
+ // No msgHdr to update, or we're dealing with an .eml.
+ document.getElementById("starMessageButton").hidden = true;
+ return;
+ }
+
+ let flagButton = document.getElementById("starMessageButton");
+ flagButton.hidden = false;
+
+ let isFlagged = gMessage.isFlagged;
+ flagButton.classList.toggle("flagged", isFlagged);
+ flagButton.setAttribute("aria-checked", isFlagged);
+}
+
+function EnsureSubjectValue() {
+ if (!("subject" in currentHeaderData)) {
+ let foo = {};
+ foo.headerValue = "";
+ foo.headerName = "subject";
+ currentHeaderData[foo.headerName] = foo;
+ }
+}
+
+function EnsureFromValue() {
+ if (!("from" in currentHeaderData)) {
+ let foo = {};
+ foo.headerValue = "";
+ foo.headerName = "from";
+ currentHeaderData[foo.headerName] = foo;
+ }
+}
+
+function OnTagsChange() {
+ // rebuild the tag headers
+ gMessageHeader.setTags();
+
+ // Now update the expanded header view to rebuild the tags,
+ // and then show or hide the tag header box.
+ if (gBuiltExpandedView) {
+ let headerEntry = gExpandedHeaderView.tags;
+ if (headerEntry) {
+ headerEntry.valid = "tags" in currentHeaderData;
+ if (headerEntry.valid) {
+ headerEntry.outputFunction(
+ headerEntry,
+ currentHeaderData.tags.headerValue
+ );
+ }
+
+ // we may need to collapse or show the tag header row...
+ headerEntry.enclosingRow.hidden = !headerEntry.valid;
+ // ... and ensure that all headers remain correctly aligned
+ gMessageHeader.syncLabelsColumnWidths();
+ }
+ }
+}
+
+/**
+ * Flush out any local state being held by a header entry for a given table.
+ *
+ * @param aHeaderTable Table of header entries
+ */
+function ClearHeaderView(aHeaderTable) {
+ for (let name in aHeaderTable) {
+ let headerEntry = aHeaderTable[name];
+ headerEntry.enclosingBox.clearHeaderValues?.();
+ headerEntry.enclosingBox.clear?.();
+
+ headerEntry.valid = false;
+ }
+}
+
+/**
+ * Make sure that any valid header entry in the table is collapsed.
+ *
+ * @param aHeaderTable Table of header entries
+ */
+function hideHeaderView(aHeaderTable) {
+ for (let name in aHeaderTable) {
+ let headerEntry = aHeaderTable[name];
+ headerEntry.enclosingRow.hidden = true;
+ }
+}
+
+/**
+ * Make sure that any valid header entry in the table specified is visible.
+ *
+ * @param aHeaderTable Table of header entries
+ */
+function showHeaderView(aHeaderTable) {
+ for (let name in aHeaderTable) {
+ let headerEntry = aHeaderTable[name];
+ headerEntry.enclosingRow.hidden = !headerEntry.valid;
+
+ // If we're hiding the To field, we need to hide the date inline and show
+ // the duplicate on the subject line.
+ if (headerEntry.enclosingRow.id == "expandedtoRow") {
+ let dateLabel = document.getElementById("dateLabel");
+ let dateLabelSubject = document.getElementById("dateLabelSubject");
+ if (!headerEntry.valid) {
+ dateLabelSubject.setAttribute(
+ "datetime",
+ dateLabel.getAttribute("datetime")
+ );
+ dateLabelSubject.textContent = dateLabel.textContent;
+ dateLabelSubject.hidden = false;
+ } else {
+ dateLabelSubject.removeAttribute("datetime");
+ dateLabelSubject.textContent = "";
+ dateLabelSubject.hidden = true;
+ }
+ }
+ }
+}
+
+/**
+ * Enumerate through the list of headers and find the number that are visible
+ * add empty entries if we don't have the minimum number of rows.
+ */
+function EnsureMinimumNumberOfHeaders(headerTable) {
+ // 0 means we don't have a minimum... do nothing special
+ if (!gMinNumberOfHeaders) {
+ return;
+ }
+
+ var numVisibleHeaders = 0;
+ for (let name in headerTable) {
+ let headerEntry = headerTable[name];
+ if (headerEntry.valid) {
+ numVisibleHeaders++;
+ }
+ }
+
+ if (numVisibleHeaders < gMinNumberOfHeaders) {
+ // How many empty headers do we need to add?
+ var numEmptyHeaders = gMinNumberOfHeaders - numVisibleHeaders;
+
+ // We may have already dynamically created our empty rows and we just need
+ // to make them visible.
+ for (let index in headerTable) {
+ let headerEntry = headerTable[index];
+ if (index.startsWith("Dummy-Header") && numEmptyHeaders) {
+ headerEntry.valid = true;
+ numEmptyHeaders--;
+ }
+ }
+
+ // Ok, now if we have any extra dummy headers we need to add, create a new
+ // header widget for them.
+ while (numEmptyHeaders) {
+ var dummyHeaderId = "Dummy-Header" + gDummyHeaderIdIndex;
+ gExpandedHeaderView[dummyHeaderId] = new HeaderView(dummyHeaderId, "");
+ gExpandedHeaderView[dummyHeaderId].valid = true;
+
+ gDummyHeaderIdIndex++;
+ numEmptyHeaders--;
+ }
+ }
+}
+
+/**
+ * Make sure the appropriate fields in the expanded header view are collapsed
+ * or visible...
+ */
+function updateExpandedView() {
+ if (gMinNumberOfHeaders) {
+ EnsureMinimumNumberOfHeaders(gExpandedHeaderView);
+ }
+ showHeaderView(gExpandedHeaderView);
+
+ // Now that we have all the headers, ensure that the name columns of both
+ // grids are the same size so that they don't look weird.
+ gMessageHeader.syncLabelsColumnWidths();
+
+ UpdateReplyButtons();
+ updateHeaderToolbarButtons();
+ updateComposeButtons();
+ displayAttachmentsForExpandedView();
+
+ try {
+ AdjustHeaderView(Services.prefs.getIntPref("mail.show_headers"));
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+/**
+ * Default method for updating a header value into a header entry
+ *
+ * @param aHeaderEntry A single header from currentHeaderData
+ * @param aHeaderValue The new value for headerEntry
+ */
+function updateHeaderValue(aHeaderEntry, aHeaderValue) {
+ aHeaderEntry.enclosingBox.headerValue = aHeaderValue;
+}
+
+/**
+ * Create the DOM nodes (aka "View") for a non-standard header and insert them
+ * into the grid. Create and return the corresponding headerEntry object.
+ *
+ * @param {string} headerName - name of the header we're adding, used to
+ * construct the element IDs (in lower case)
+ * @param {string} label - name of the header as displayed in the UI
+ */
+class HeaderView {
+ constructor(headerName, label) {
+ headerName = headerName.toLowerCase();
+ let rowId = "expanded" + headerName + "Row";
+ let idName = "expanded" + headerName + "Box";
+ let newHeaderNode;
+ // If a row for this header already exists, do not create another one.
+ let newRowNode = document.getElementById(rowId);
+ if (!newRowNode) {
+ // Create new collapsed row.
+ newRowNode = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ newRowNode.setAttribute("id", rowId);
+ newRowNode.classList.add("message-header-row");
+ newRowNode.hidden = true;
+
+ // Create and append the label which contains the header name.
+ let newLabelNode = document.createXULElement("label");
+ newLabelNode.setAttribute("id", "expanded" + headerName + "Label");
+ newLabelNode.setAttribute("value", label);
+ newLabelNode.setAttribute("class", "message-header-label");
+
+ newRowNode.appendChild(newLabelNode);
+
+ // Create and append the new header value.
+ newHeaderNode = document.createElement("div", {
+ is: "simple-header-row",
+ });
+ newHeaderNode.setAttribute("id", idName);
+ newHeaderNode.dataset.prettyHeaderName = label;
+ newHeaderNode.dataset.headerName = headerName;
+ newRowNode.appendChild(newHeaderNode);
+
+ // Add the new row to the extra headers container.
+ document.getElementById("extraHeadersArea").appendChild(newRowNode);
+ this.isNewHeader = true;
+ } else {
+ newRowNode.hidden = true;
+ newHeaderNode = document.getElementById(idName);
+ this.isNewHeader = false;
+ }
+
+ this.enclosingBox = newHeaderNode;
+ this.enclosingRow = newRowNode;
+ this.valid = false;
+ this.outputFunction = updateHeaderValue;
+ }
+}
+
+/**
+ * Removes all non-predefined header nodes from the view.
+ *
+ * @param aHeaderTable Table of header entries.
+ */
+function RemoveNewHeaderViews(aHeaderTable) {
+ for (let name in aHeaderTable) {
+ let headerEntry = aHeaderTable[name];
+ if (headerEntry.isNewHeader) {
+ headerEntry.enclosingRow.remove();
+ }
+ }
+}
+
+/**
+ * UpdateExpandedMessageHeaders: Iterate through all the current header data
+ * we received from mime for this message for the expanded header entry table,
+ * and see if we have a corresponding entry for that header (i.e.
+ * whether the expanded header view cares about this header value)
+ * If so, then call updateHeaderEntry
+ */
+function UpdateExpandedMessageHeaders() {
+ // Iterate over each header we received and see if we have a matching entry
+ // in each header view table...
+ var headerName;
+
+ // Remove the height attr so that it redraws correctly. Works around a problem
+ // that attachment-splitter causes if it's moved high enough to affect
+ // the header box:
+ document.getElementById("msgHeaderView").removeAttribute("height");
+ // This height attribute may be set by toggleWrap() if the user clicked
+ // the "more" button" in the header.
+ // Remove it so that the height is determined automatically.
+
+ for (headerName in currentHeaderData) {
+ var headerField = currentHeaderData[headerName];
+ var headerEntry = null;
+
+ if (headerName in gExpandedHeaderView) {
+ headerEntry = gExpandedHeaderView[headerName];
+ }
+
+ if (!headerEntry && gViewAllHeaders) {
+ // For view all headers, if we don't have a header field for this
+ // value, cheat and create one then fill in a headerEntry.
+ if (headerName == "message-id" || headerName == "in-reply-to") {
+ var messageIdEntry = {
+ name: headerName,
+ outputFunction: outputMessageIds,
+ };
+ gExpandedHeaderView[headerName] = new MsgHeaderEntry(
+ "expanded",
+ messageIdEntry
+ );
+ } else if (headerName != "x-mozilla-localizeddate") {
+ // Don't bother showing X-Mozilla-LocalizedDate, since that value is
+ // displayed below the message header toolbar.
+ gExpandedHeaderView[headerName] = new HeaderView(
+ headerName,
+ currentHeaderData[headerName].headerName
+ );
+ }
+
+ headerEntry = gExpandedHeaderView[headerName];
+ }
+
+ if (headerEntry) {
+ if (
+ headerName == "references" &&
+ !(
+ gViewAllHeaders ||
+ gHeadersShowReferences ||
+ gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)
+ )
+ ) {
+ // Hide references header if view all headers mode isn't selected, the
+ // pref show references is deactivated and the currently displayed
+ // message isn't a newsgroup posting.
+ headerEntry.valid = false;
+ } else {
+ // Set the row element visible before populating the field with addresses.
+ headerEntry.enclosingRow.hidden = false;
+ headerEntry.outputFunction(headerEntry, headerField.headerValue);
+ headerEntry.valid = true;
+ }
+ }
+ }
+
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+
+ for (let otherHeaderName of otherHeaders) {
+ let toLowerCaseHeaderName = otherHeaderName.toLowerCase();
+ let headerEntry = gExpandedHeaderView[toLowerCaseHeaderName];
+ let headerData = currentHeaderData[toLowerCaseHeaderName];
+
+ if (headerEntry && headerData) {
+ headerEntry.outputFunction(headerEntry, headerData.headerValue);
+ headerEntry.valid = true;
+ }
+ }
+
+ let dateLabel = document.getElementById("dateLabel");
+ dateLabel.hidden = true;
+ if (
+ "x-mozilla-localizeddate" in currentHeaderData &&
+ currentHeaderData["x-mozilla-localizeddate"].headerValue
+ ) {
+ dateLabel.textContent =
+ currentHeaderData["x-mozilla-localizeddate"].headerValue;
+ let date = new Date(currentHeaderData.date.headerValue);
+ if (!isNaN(date)) {
+ dateLabel.setAttribute("datetime", date.toISOString());
+ dateLabel.hidden = false;
+ }
+ }
+
+ gBuiltExpandedView = true;
+
+ // Now update the view to make sure the right elements are visible.
+ updateExpandedView();
+}
+
+function ClearCurrentHeaders() {
+ gSecureMsgProbe = {};
+ // eslint-disable-next-line no-global-assign
+ currentHeaderData = {};
+ // eslint-disable-next-line no-global-assign
+ currentAttachments = [];
+ currentCharacterSet = "";
+}
+
+function ShowMessageHeaderPane() {
+ document.getElementById("msgHeaderView").collapsed = false;
+ document.getElementById("mail-notification-top").collapsed = false;
+
+ // Initialize the DBListener if we don't have one. This might happen when the
+ // message pane is hidden or no message was selected before, which caused the
+ // clearing of the the DBListener.
+ initFolderDBListener();
+}
+
+function HideMessageHeaderPane() {
+ let header = document.getElementById("msgHeaderView");
+ header.collapsed = true;
+ document.getElementById("mail-notification-top").collapsed = true;
+
+ // Disable the attachment box.
+ document.getElementById("attachmentView").collapsed = true;
+ document.getElementById("attachment-splitter").collapsed = true;
+
+ gMessageNotificationBar.clearMsgNotifications();
+ // Clear the DBListener since we don't have any visible UI to update.
+ clearFolderDBListener();
+
+ // Now let interested listeners know the pane has been hidden.
+ header.dispatchEvent(new Event("message-header-pane-hidden"));
+}
+
+/**
+ * Take a string of newsgroups separated by commas, split it into newsgroups and
+ * add them to the corresponding header-newsgroups-row element.
+ *
+ * @param {MsgHeaderEntry} headerEntry - The data structure for this header.
+ * @param {string} headerValue - The string of newsgroups from the message.
+ */
+function outputNewsgroups(headerEntry, headerValue) {
+ headerValue
+ .split(",")
+ .forEach(newsgroup => headerEntry.enclosingBox.addNewsgroup(newsgroup));
+ headerEntry.enclosingBox.buildView();
+}
+
+/**
+ * Take a string of tags separated by space, split them and add them to the
+ * corresponding header-tags-row element.
+ *
+ * @param {MsgHeaderEntry} headerEntry - The data structure for this header.
+ * @param {string} headerValue - The string of tags from the message.
+ */
+function outputTags(headerEntry, headerValue) {
+ headerEntry.enclosingBox.buildTags(headerValue.split(" "));
+}
+
+/**
+ * Take a string of message-ids separated by whitespace, split it and send them
+ * to the corresponding header-message-ids-row element.
+ *
+ * @param {MsgHeaderEntry} headerEntry - The data structure for this header.
+ * @param {string} headerValue - The string of message IDs from the message.
+ */
+function outputMessageIds(headerEntry, headerValue) {
+ headerEntry.enclosingBox.clear();
+
+ for (let id of headerValue.split(/\s+/)) {
+ headerEntry.enclosingBox.addId(id);
+ }
+
+ headerEntry.enclosingBox.buildView();
+}
+
+/**
+ * Take a string of addresses separated by commas, split it into separated
+ * recipient objects and add them to the related parent container row.
+ *
+ * @param {MsgHeaderEntry} headerEntry - The data structure for this header.
+ * @param {string} emailAddresses - The string of addresses from the message.
+ */
+function outputEmailAddresses(headerEntry, emailAddresses) {
+ if (!emailAddresses) {
+ return;
+ }
+
+ // The email addresses are still RFC2047 encoded but libmime has already
+ // converted from "raw UTF-8" to "wide" (UTF-16) characters.
+ let addresses = MailServices.headerParser.parseEncodedHeaderW(emailAddresses);
+
+ // Make sure we start clean.
+ headerEntry.enclosingBox.clear();
+
+ // No addresses and a colon, so an empty group like "undisclosed-recipients: ;".
+ // Add group name so at least something displays.
+ if (!addresses.length && emailAddresses.includes(":")) {
+ let address = { displayName: emailAddresses };
+ headerEntry.enclosingBox.addRecipient(address);
+ }
+
+ for (let addr of addresses) {
+ // If we want to include short/long toggle views and we have a long view,
+ // always add it. If we aren't including a short/long view OR if we are and
+ // we haven't parsed enough addresses to reach the cutoff valve yet then add
+ // it to the default (short) div.
+ let address = {};
+ address.emailAddress = addr.email;
+ address.fullAddress = addr.toString();
+ address.displayName = addr.name;
+ headerEntry.enclosingBox.addRecipient(address);
+ }
+
+ headerEntry.enclosingBox.buildView();
+}
+
+/**
+ * Return true if possible attachments in the currently loaded message can be
+ * deleted/detached.
+ */
+function CanDetachAttachments() {
+ var canDetach =
+ !gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false) &&
+ (!gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.ImapBox, false) ||
+ MailOfflineMgr.isOnline()) &&
+ gFolder; // We can't detach from loaded eml files yet.
+ if (canDetach && "content-type" in currentHeaderData) {
+ canDetach = !ContentTypeIsSMIME(
+ currentHeaderData["content-type"].headerValue
+ );
+ }
+ if (canDetach) {
+ canDetach = Enigmail.hdrView.enigCanDetachAttachments();
+ }
+
+ return canDetach;
+}
+
+/**
+ * Return true if the content type is an S/MIME one.
+ */
+function ContentTypeIsSMIME(contentType) {
+ // S/MIME is application/pkcs7-mime and application/pkcs7-signature
+ // - also match application/x-pkcs7-mime and application/x-pkcs7-signature.
+ return /application\/(x-)?pkcs7-(mime|signature)/.test(contentType);
+}
+
+function onShowAttachmentToolbarContextMenu() {
+ let expandBar = document.getElementById("context-expandAttachmentBar");
+ let expanded = Services.prefs.getBoolPref(
+ "mailnews.attachments.display.start_expanded"
+ );
+ expandBar.setAttribute("checked", expanded);
+}
+
+/**
+ * Set up the attachment item context menu, showing or hiding the appropriate
+ * menu items.
+ */
+function onShowAttachmentItemContextMenu() {
+ let attachmentList = document.getElementById("attachmentList");
+ let attachmentInfo = document.getElementById("attachmentInfo");
+ let attachmentName = document.getElementById("attachmentName");
+ let contextMenu = document.getElementById("attachmentItemContext");
+ let openMenu = document.getElementById("context-openAttachment");
+ let saveMenu = document.getElementById("context-saveAttachment");
+ let detachMenu = document.getElementById("context-detachAttachment");
+ let deleteMenu = document.getElementById("context-deleteAttachment");
+ let copyUrlMenuSep = document.getElementById(
+ "context-menu-copyurl-separator"
+ );
+ let copyUrlMenu = document.getElementById("context-copyAttachmentUrl");
+ let openFolderMenu = document.getElementById("context-openFolder");
+
+ // 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.
+ var selectedAttachments;
+ if (
+ contextMenu.triggerNode == attachmentInfo ||
+ contextMenu.triggerNode.parentNode == attachmentInfo
+ ) {
+ selectedAttachments = [attachmentList.getItemAtIndex(0).attachment];
+ if (contextMenu.triggerNode == attachmentName) {
+ attachmentName.setAttribute("selected", true);
+ }
+ } else {
+ selectedAttachments = [...attachmentList.selectedItems].map(
+ item => item.attachment
+ );
+ }
+ contextMenu.attachments = selectedAttachments;
+
+ var allSelectedDetached = selectedAttachments.every(function (attachment) {
+ return attachment.isExternalAttachment;
+ });
+ var allSelectedDeleted = selectedAttachments.every(function (attachment) {
+ return !attachment.hasFile;
+ });
+ var canDetachSelected =
+ CanDetachAttachments() && !allSelectedDetached && !allSelectedDeleted;
+ let allSelectedHttp = selectedAttachments.every(function (attachment) {
+ return attachment.isLinkAttachment;
+ });
+ let allSelectedFile = selectedAttachments.every(function (attachment) {
+ return attachment.isFileAttachment;
+ });
+
+ openMenu.disabled = allSelectedDeleted;
+ saveMenu.disabled = allSelectedDeleted;
+ detachMenu.disabled = !canDetachSelected;
+ deleteMenu.disabled = !canDetachSelected;
+ copyUrlMenuSep.hidden = copyUrlMenu.hidden = !(
+ allSelectedHttp || allSelectedFile
+ );
+ openFolderMenu.hidden = !allSelectedFile;
+ openFolderMenu.disabled = allSelectedDeleted;
+
+ Enigmail.hdrView.onShowAttachmentContextMenu();
+}
+
+/**
+ * Close the attachment item context menu, performing any cleanup as necessary.
+ */
+function onHideAttachmentItemContextMenu() {
+ let attachmentName = document.getElementById("attachmentName");
+ let contextMenu = document.getElementById("attachmentItemContext");
+
+ // If we opened the context menu from the attachmentName label, we need to
+ // get rid of the "selected" attribute.
+ if (contextMenu.triggerNode == attachmentName) {
+ attachmentName.removeAttribute("selected");
+ }
+}
+
+/**
+ * Enable/disable menu items as appropriate for the single-attachment save all
+ * toolbar button.
+ */
+function onShowSaveAttachmentMenuSingle() {
+ let openItem = document.getElementById("button-openAttachment");
+ let saveItem = document.getElementById("button-saveAttachment");
+ let detachItem = document.getElementById("button-detachAttachment");
+ let deleteItem = document.getElementById("button-deleteAttachment");
+
+ let detached = currentAttachments[0].isExternalAttachment;
+ let deleted = !currentAttachments[0].hasFile;
+ let canDetach = CanDetachAttachments() && !deleted && !detached;
+
+ openItem.disabled = deleted;
+ saveItem.disabled = deleted;
+ detachItem.disabled = !canDetach;
+ deleteItem.disabled = !canDetach;
+}
+
+/**
+ * Enable/disable menu items as appropriate for the multiple-attachment save all
+ * toolbar button.
+ */
+function onShowSaveAttachmentMenuMultiple() {
+ let openAllItem = document.getElementById("button-openAllAttachments");
+ let saveAllItem = document.getElementById("button-saveAllAttachments");
+ let detachAllItem = document.getElementById("button-detachAllAttachments");
+ let deleteAllItem = document.getElementById("button-deleteAllAttachments");
+
+ let allDetached = currentAttachments.every(function (attachment) {
+ return attachment.isExternalAttachment;
+ });
+ let allDeleted = currentAttachments.every(function (attachment) {
+ return !attachment.hasFile;
+ });
+ let canDetach = CanDetachAttachments() && !allDeleted && !allDetached;
+
+ openAllItem.disabled = allDeleted;
+ saveAllItem.disabled = allDeleted;
+ detachAllItem.disabled = !canDetach;
+ deleteAllItem.disabled = !canDetach;
+}
+
+/**
+ * This is our oncommand handler for the attachment list items. A double click
+ * or enter press in an attachmentitem simulates "opening" the attachment.
+ *
+ * @param event the event object
+ */
+function attachmentItemCommand(event) {
+ HandleSelectedAttachments("open");
+}
+
+var AttachmentListController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_selectAll":
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ case "cmd_saveAsFile":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(command) {
+ switch (command) {
+ case "cmd_selectAll":
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ case "cmd_saveAsFile":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ doCommand(command) {
+ // If the user invoked a key short cut then it is possible that we got here
+ // for a command which is really disabled. kick out if the command should
+ // be disabled.
+ if (!this.isCommandEnabled(command)) {
+ return;
+ }
+
+ var attachmentList = document.getElementById("attachmentList");
+
+ switch (command) {
+ case "cmd_selectAll":
+ attachmentList.selectAll();
+ return;
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ HandleSelectedAttachments("delete");
+ return;
+ case "cmd_saveAsFile":
+ HandleSelectedAttachments("saveAs");
+ }
+ },
+
+ onEvent(event) {},
+};
+
+var AttachmentMenuController = {
+ canDetachFiles() {
+ let someNotDetached = currentAttachments.some(function (aAttachment) {
+ return !aAttachment.isExternalAttachment;
+ });
+
+ return (
+ CanDetachAttachments() && someNotDetached && this.someFilesAvailable()
+ );
+ },
+
+ someFilesAvailable() {
+ return currentAttachments.some(function (aAttachment) {
+ return aAttachment.hasFile;
+ });
+ },
+
+ supportsCommand(aCommand) {
+ return aCommand in this.commands;
+ },
+};
+
+function goUpdateAttachmentCommands() {
+ for (let action of ["open", "save", "detach", "delete"]) {
+ goUpdateCommand(`cmd_${action}AllAttachments`);
+ }
+}
+
+async function displayAttachmentsForExpandedView() {
+ var bundle = document.getElementById("bundle_messenger");
+ var numAttachments = currentAttachments.length;
+ var attachmentView = document.getElementById("attachmentView");
+ var attachmentSplitter = document.getElementById("attachment-splitter");
+ document
+ .getElementById("attachmentIcon")
+ .setAttribute("src", "chrome://messenger/skin/icons/attach.svg");
+
+ if (numAttachments <= 0) {
+ attachmentView.collapsed = true;
+ attachmentSplitter.collapsed = true;
+ } else if (!gBuildAttachmentsForCurrentMsg) {
+ attachmentView.collapsed = false;
+
+ var attachmentList = document.getElementById("attachmentList");
+
+ attachmentList.controllers.appendController(AttachmentListController);
+
+ toggleAttachmentList(false);
+
+ for (let attachment of currentAttachments) {
+ // Create a new attachment widget
+ var displayName = SanitizeAttachmentDisplayName(attachment);
+ var item = attachmentList.appendItem(attachment, displayName);
+ item.setAttribute("tooltiptext", attachment.name);
+ item.addEventListener("command", attachmentItemCommand);
+
+ // Get a detached file's size. For link attachments, the user must always
+ // initiate the fetch for privacy reasons.
+ if (attachment.isFileAttachment) {
+ await attachment.isEmpty();
+ }
+ }
+
+ if (
+ Services.prefs.getBoolPref("mailnews.attachments.display.start_expanded")
+ ) {
+ toggleAttachmentList(true);
+ }
+
+ let attachmentInfo = document.getElementById("attachmentInfo");
+ let attachmentCount = document.getElementById("attachmentCount");
+ let attachmentName = document.getElementById("attachmentName");
+ let attachmentSize = document.getElementById("attachmentSize");
+
+ if (numAttachments == 1) {
+ let count = bundle.getString("attachmentCountSingle");
+ let name = SanitizeAttachmentDisplayName(currentAttachments[0]);
+
+ attachmentInfo.setAttribute("contextmenu", "attachmentItemContext");
+ attachmentCount.setAttribute("value", count);
+ attachmentName.hidden = false;
+ attachmentName.setAttribute("value", name);
+ } else {
+ let words = bundle.getString("attachmentCount");
+ let count = PluralForm.get(currentAttachments.length, words).replace(
+ "#1",
+ currentAttachments.length
+ );
+
+ attachmentInfo.setAttribute("contextmenu", "attachmentListContext");
+ attachmentCount.setAttribute("value", count);
+ attachmentName.hidden = true;
+ }
+
+ attachmentSize.value = getAttachmentsTotalSizeStr();
+
+ // Extra candy for external attachments.
+ displayAttachmentsForExpandedViewExternal();
+
+ // Show the appropriate toolbar button and label based on the number of
+ // attachments.
+ updateSaveAllAttachmentsButton();
+
+ gBuildAttachmentsForCurrentMsg = true;
+ }
+}
+
+function displayAttachmentsForExpandedViewExternal() {
+ let bundleMessenger = document.getElementById("bundle_messenger");
+ let attachmentName = document.getElementById("attachmentName");
+ let attachmentList = document.getElementById("attachmentList");
+
+ // Attachment bar single.
+ let firstAttachment = attachmentList.firstElementChild.attachment;
+ let isExternalAttachment = firstAttachment.isExternalAttachment;
+ let displayUrl = isExternalAttachment ? firstAttachment.displayUrl : "";
+ let tooltiptext =
+ isExternalAttachment || firstAttachment.isDeleted
+ ? ""
+ : attachmentName.getAttribute("tooltiptextopen");
+ let externalAttachmentNotFound = bundleMessenger.getString(
+ "externalAttachmentNotFound"
+ );
+
+ attachmentName.textContent = displayUrl;
+ attachmentName.tooltipText = tooltiptext;
+ attachmentName.setAttribute(
+ "tooltiptextexternalnotfound",
+ externalAttachmentNotFound
+ );
+ attachmentName.addEventListener("mouseover", () =>
+ top.MsgStatusFeedback.setOverLink(displayUrl)
+ );
+ attachmentName.addEventListener("mouseout", () =>
+ top.MsgStatusFeedback.setOverLink("")
+ );
+ attachmentName.addEventListener("focus", () =>
+ top.MsgStatusFeedback.setOverLink(displayUrl)
+ );
+ attachmentName.addEventListener("blur", () =>
+ top.MsgStatusFeedback.setOverLink("")
+ );
+ attachmentName.classList.remove("text-link");
+ attachmentName.classList.remove("notfound");
+
+ if (firstAttachment.isDeleted) {
+ attachmentName.classList.add("notfound");
+ }
+
+ if (isExternalAttachment) {
+ attachmentName.classList.add("text-link");
+
+ if (!firstAttachment.hasFile) {
+ attachmentName.setAttribute("tooltiptext", externalAttachmentNotFound);
+ attachmentName.classList.add("notfound");
+ }
+ }
+
+ // Expanded attachment list.
+ let index = 0;
+ for (let attachmentitem of attachmentList.children) {
+ let attachment = attachmentitem.attachment;
+ if (attachment.isDeleted) {
+ attachmentitem.classList.add("notfound");
+ }
+
+ if (attachment.isExternalAttachment) {
+ displayUrl = attachment.displayUrl;
+ attachmentitem.setAttribute("tooltiptext", "");
+ attachmentitem.addEventListener("mouseover", () =>
+ top.MsgStatusFeedback.setOverLink(displayUrl)
+ );
+ attachmentitem.addEventListener("mouseout", () =>
+ top.MsgStatusFeedback.setOverLink("")
+ );
+ attachmentitem.addEventListener("focus", () =>
+ top.MsgStatusFeedback.setOverLink(displayUrl)
+ );
+ attachmentitem.addEventListener("blur", () =>
+ top.MsgStatusFeedback.setOverLink("")
+ );
+
+ attachmentitem
+ .querySelector(".attachmentcell-name")
+ .classList.add("text-link");
+ attachmentitem
+ .querySelector(".attachmentcell-extension")
+ .classList.add("text-link");
+
+ if (attachment.isLinkAttachment) {
+ if (index == 0) {
+ attachment.size = currentAttachments[index].size;
+ }
+ }
+
+ if (!attachment.hasFile) {
+ attachmentitem.setAttribute("tooltiptext", externalAttachmentNotFound);
+ attachmentitem.classList.add("notfound");
+ }
+ }
+
+ index++;
+ }
+}
+
+/**
+ * Update the "save all attachments" button in the attachment pane, showing
+ * the proper button and enabling/disabling it as appropriate.
+ */
+function updateSaveAllAttachmentsButton() {
+ let saveAllSingle = document.getElementById("attachmentSaveAllSingle");
+ let saveAllMultiple = document.getElementById("attachmentSaveAllMultiple");
+
+ // If we can't find the buttons, they're not on the toolbar, so bail out!
+ if (!saveAllSingle || !saveAllMultiple) {
+ return;
+ }
+
+ let allDeleted = currentAttachments.every(function (attachment) {
+ return !attachment.hasFile;
+ });
+ let single = currentAttachments.length == 1;
+
+ saveAllSingle.hidden = !single;
+ saveAllMultiple.hidden = single;
+ saveAllSingle.disabled = saveAllMultiple.disabled = allDeleted;
+}
+
+/**
+ * Update the attachments display info after a particular attachment's
+ * existence has been verified.
+ *
+ * @param {AttachmentInfo} attachmentInfo
+ * @param {boolean} isFetching
+ */
+function updateAttachmentsDisplay(attachmentInfo, isFetching) {
+ if (attachmentInfo.isExternalAttachment) {
+ let attachmentList = document.getElementById("attachmentList");
+ let attachmentIcon = document.getElementById("attachmentIcon");
+ let attachmentName = document.getElementById("attachmentName");
+ let attachmentSize = document.getElementById("attachmentSize");
+ let attachmentItem = attachmentList.findItemForAttachment(attachmentInfo);
+ let index = attachmentList.getIndexOfItem(attachmentItem);
+
+ if (isFetching) {
+ // Set elements busy to show the user this is potentially a long network
+ // fetch for the link attachment.
+ attachmentList.setAttachmentLoaded(attachmentItem, false);
+ return;
+ }
+
+ if (attachmentInfo.message != gMessage) {
+ // The user changed messages while fetching, reset the bar and exit;
+ // the listitems are torn down/rebuilt on each message load.
+ attachmentIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/attach.svg"
+ );
+ return;
+ }
+
+ if (index == -1) {
+ // The user changed messages while fetching, then came back to the same
+ // message. The reset of busy state has already happened and anyway the
+ // item has already been torn down so the index will be invalid; exit.
+ return;
+ }
+
+ currentAttachments[index].size = attachmentInfo.size;
+ let tooltiptextExternalNotFound = attachmentName.getAttribute(
+ "tooltiptextexternalnotfound"
+ );
+
+ let sizeStr;
+ let bundle = document.getElementById("bundle_messenger");
+ if (attachmentInfo.size < 1) {
+ sizeStr = bundle.getString("attachmentSizeUnknown");
+ } else {
+ sizeStr = top.messenger.formatFileSize(attachmentInfo.size);
+ }
+
+ // The attachment listitem.
+ attachmentList.setAttachmentLoaded(attachmentItem, true);
+ attachmentList.setAttachmentSize(
+ attachmentItem,
+ attachmentInfo.hasFile ? sizeStr : ""
+ );
+
+ // FIXME: The UI logic for this should be moved to the attachment list or
+ // item itself.
+ if (attachmentInfo.hasFile) {
+ attachmentItem.removeAttribute("tooltiptext");
+ attachmentItem.classList.remove("notfound");
+ } else {
+ attachmentItem.setAttribute("tooltiptext", tooltiptextExternalNotFound);
+ attachmentItem.classList.add("notfound");
+ }
+
+ // The attachmentbar.
+ updateSaveAllAttachmentsButton();
+ attachmentSize.value = getAttachmentsTotalSizeStr();
+ if (attachmentList.isLoaded()) {
+ attachmentIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/attach.svg"
+ );
+ }
+
+ // If it's the first one (and there's only one).
+ if (index == 0) {
+ if (attachmentInfo.hasFile) {
+ attachmentName.removeAttribute("tooltiptext");
+ attachmentName.classList.remove("notfound");
+ } else {
+ attachmentName.setAttribute("tooltiptext", tooltiptextExternalNotFound);
+ attachmentName.classList.add("notfound");
+ }
+ }
+
+ // Reset widths since size may have changed; ensure no false cropping of
+ // the attachment item name.
+ attachmentList.setOptimumWidth();
+ }
+}
+
+/**
+ * Calculate the total size of all attachments in the message as emitted to
+ * |currentAttachments| and return a pretty string.
+ *
+ * @returns {string} - Description of the attachment size (e.g. 123 KB or 3.1MB)
+ */
+function getAttachmentsTotalSizeStr() {
+ let bundle = document.getElementById("bundle_messenger");
+ let totalSize = 0;
+ let lastPartID;
+ let unknownSize = false;
+ for (let attachment of currentAttachments) {
+ // Check if this attachment's part ID is a child of the last attachment
+ // we counted. If so, skip it, since we already accounted for its size
+ // from its parent.
+ if (!lastPartID || attachment.partID.indexOf(lastPartID) != 0) {
+ lastPartID = attachment.partID;
+ if (attachment.size != -1) {
+ totalSize += Number(attachment.size);
+ } else if (!attachment.isDeleted) {
+ unknownSize = true;
+ }
+ }
+ }
+
+ let sizeStr = top.messenger.formatFileSize(totalSize);
+ if (unknownSize) {
+ if (totalSize == 0) {
+ sizeStr = bundle.getString("attachmentSizeUnknown");
+ } else {
+ sizeStr = bundle.getFormattedString("attachmentSizeAtLeast", [sizeStr]);
+ }
+ }
+
+ return sizeStr;
+}
+
+/**
+ * Expand/collapse the attachment list. When expanding it, automatically resize
+ * it to an appropriate height (1/4 the message pane or smaller).
+ *
+ * @param expanded True if the attachment list should be expanded, false
+ * otherwise. If |expanded| is not specified, toggle the state.
+ * @param updateFocus (optional) True if the focus should be updated, focusing
+ * on the attachmentList when expanding, or the messagepane
+ * when collapsing (but only when the attachmentList was
+ * originally focused).
+ */
+function toggleAttachmentList(expanded, updateFocus) {
+ var attachmentView = document.getElementById("attachmentView");
+ var attachmentBar = document.getElementById("attachmentBar");
+ var attachmentToggle = document.getElementById("attachmentToggle");
+ var attachmentList = document.getElementById("attachmentList");
+ var attachmentSplitter = document.getElementById("attachment-splitter");
+ var bundle = document.getElementById("bundle_messenger");
+
+ if (expanded === undefined) {
+ expanded = !attachmentToggle.checked;
+ }
+
+ attachmentToggle.checked = expanded;
+
+ if (expanded) {
+ attachmentList.collapsed = false;
+ if (!attachmentView.collapsed) {
+ attachmentSplitter.collapsed = false;
+ }
+ attachmentBar.setAttribute(
+ "tooltiptext",
+ bundle.getString("collapseAttachmentPaneTooltip")
+ );
+
+ attachmentList.setOptimumWidth();
+
+ // By design, attachmentView should not take up more than 1/4 of the message
+ // pane space
+ attachmentView.setAttribute(
+ "height",
+ Math.min(
+ attachmentList.preferredHeight,
+ document.getElementById("messagepanebox").getBoundingClientRect()
+ .height / 4
+ )
+ );
+
+ if (updateFocus) {
+ attachmentList.focus();
+ }
+ } else {
+ attachmentList.collapsed = true;
+ attachmentSplitter.collapsed = true;
+ attachmentBar.setAttribute(
+ "tooltiptext",
+ bundle.getString("expandAttachmentPaneTooltip")
+ );
+ attachmentView.removeAttribute("height");
+
+ if (updateFocus && document.activeElement == attachmentList) {
+ // TODO
+ }
+ }
+}
+
+/**
+ * Open an attachment from the attachment bar.
+ *
+ * @param event the event that triggered this action
+ */
+function OpenAttachmentFromBar(event) {
+ if (event.button == 0) {
+ // Only open on the first click; ignore double-clicks so that the user
+ // doesn't end up with the attachment opened multiple times.
+ if (event.detail == 1) {
+ TryHandleAllAttachments("open");
+ }
+ event.stopPropagation();
+ }
+}
+
+/**
+ * Handle all the attachments in this message (save them, open them, etc).
+ *
+ * @param action one of "open", "save", "saveAs", "detach", or "delete"
+ */
+function HandleAllAttachments(action) {
+ HandleMultipleAttachments(currentAttachments, action);
+}
+
+/**
+ * Try to handle all the attachments in this message (save them, open them,
+ * etc). If the action fails for whatever reason, catch the error and report it.
+ *
+ * @param action one of "open", "save", "saveAs", "detach", or "delete"
+ */
+function TryHandleAllAttachments(action) {
+ try {
+ HandleAllAttachments(action);
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+/**
+ * Handle the currently-selected attachments in this message (save them, open
+ * them, etc).
+ *
+ * @param action one of "open", "save", "saveAs", "detach", or "delete"
+ */
+function HandleSelectedAttachments(action) {
+ let attachmentList = document.getElementById("attachmentList");
+ let selectedAttachments = [];
+ for (let item of attachmentList.selectedItems) {
+ selectedAttachments.push(item.attachment);
+ }
+
+ HandleMultipleAttachments(selectedAttachments, action);
+}
+
+/**
+ * Perform an action on multiple attachments (e.g. open or save)
+ *
+ * @param attachments an array of AttachmentInfo objects to work with
+ * @param action one of "open", "save", "saveAs", "detach", or "delete"
+ */
+function HandleMultipleAttachments(attachments, action) {
+ // Feed message link attachments save handling.
+ if (
+ FeedUtils.isFeedMessage(gMessage) &&
+ (action == "save" || action == "saveAs")
+ ) {
+ saveLinkAttachmentsToFile(attachments);
+ return;
+ }
+
+ // convert our attachment data into some c++ friendly structs
+ var attachmentContentTypeArray = [];
+ var attachmentUrlArray = [];
+ var attachmentDisplayUrlArray = [];
+ var attachmentDisplayNameArray = [];
+ var attachmentMessageUriArray = [];
+
+ // populate these arrays..
+ var actionIndex = 0;
+ for (let attachment of attachments) {
+ // Exclude attachment which are 1) deleted, or 2) detached with missing
+ // external files, unless copying urls.
+ if (!attachment.hasFile && action != "copyUrl") {
+ continue;
+ }
+
+ attachmentContentTypeArray[actionIndex] = attachment.contentType;
+ attachmentUrlArray[actionIndex] = attachment.url;
+ attachmentDisplayUrlArray[actionIndex] = attachment.displayUrl;
+ attachmentDisplayNameArray[actionIndex] = encodeURI(attachment.name);
+ attachmentMessageUriArray[actionIndex] = attachment.uri;
+ ++actionIndex;
+ }
+
+ // The list has been built. Now call our action code...
+ switch (action) {
+ case "save":
+ top.messenger.saveAllAttachments(
+ attachmentContentTypeArray,
+ attachmentUrlArray,
+ attachmentDisplayNameArray,
+ attachmentMessageUriArray
+ );
+ return;
+ case "detach":
+ // "detach" on a multiple selection of attachments is so far not really
+ // supported. As a workaround, resort to normal detach-"all". See also
+ // the comment on 'detaching a multiple selection of attachments' below.
+ if (attachments.length == 1) {
+ attachments[0].detach(top.messenger, true);
+ } else {
+ top.messenger.detachAllAttachments(
+ attachmentContentTypeArray,
+ attachmentUrlArray,
+ attachmentDisplayNameArray,
+ attachmentMessageUriArray,
+ true // save
+ );
+ }
+ return;
+ case "delete":
+ top.messenger.detachAllAttachments(
+ attachmentContentTypeArray,
+ attachmentUrlArray,
+ attachmentDisplayNameArray,
+ attachmentMessageUriArray,
+ false // don't save
+ );
+ return;
+ case "open":
+ // XXX hack alert. If we sit in tight loop and open multiple
+ // attachments, we get chrome errors in layout as we start loading the
+ // first helper app dialog then before it loads, we kick off the next
+ // one and the next one. Subsequent helper app dialogs were failing
+ // because we were still loading the chrome files for the first attempt
+ // (error about the xul cache being empty). For now, work around this by
+ // doing the first helper app dialog right away, then waiting a bit
+ // before we launch the rest.
+ let actionFunction = function (aAttachment) {
+ aAttachment.open(getMessagePaneBrowser().browsingContext);
+ };
+
+ for (let i = 0; i < attachments.length; i++) {
+ if (i == 0) {
+ actionFunction(attachments[i]);
+ } else {
+ setTimeout(actionFunction, 100, attachments[i]);
+ }
+ }
+ return;
+ case "saveAs":
+ // Show one save dialog at a time, which allows to adjust the file name
+ // and folder path for each attachment. For added convenience, we remember
+ // the folder path of each file for the save dialog of the next one.
+ let saveAttachments = function (attachments) {
+ if (attachments.length > 0) {
+ attachments[0].save(top.messenger).then(function () {
+ saveAttachments(attachments.slice(1));
+ });
+ }
+ };
+
+ saveAttachments(attachments);
+ return;
+ case "copyUrl":
+ // Copy external http url(s) to clipboard. The menuitem is hidden unless
+ // all selected attachment urls are http.
+ navigator.clipboard.writeText(attachmentDisplayUrlArray.join("\n"));
+ return;
+ case "openFolder":
+ for (let attachment of attachments) {
+ setTimeout(() => attachment.openFolder());
+ }
+ return;
+ default:
+ throw new Error("unknown HandleMultipleAttachments action: " + action);
+ }
+}
+
+/**
+ * Link attachments are passed as an array of AttachmentInfo objects. This
+ * is meant to download http link content using the browser method.
+ *
+ * @param {AttachmentInfo[]} aAttachmentInfoArray - Array of attachmentInfo.
+ */
+async function saveLinkAttachmentsToFile(aAttachmentInfoArray) {
+ for (let attachment of aAttachmentInfoArray) {
+ if (!attachment.hasFile || attachment.message != gMessage) {
+ continue;
+ }
+
+ let empty = await attachment.isEmpty();
+ if (empty) {
+ continue;
+ }
+
+ // internalSave() is part of saveURL() internals...
+ internalSave(
+ attachment.url, // aURL,
+ null, // aOriginalUrl,
+ undefined, // aDocument,
+ attachment.name, // aDefaultFileName,
+ undefined, // aContentDisposition,
+ undefined, // aContentType,
+ undefined, // aShouldBypassCache,
+ undefined, // aFilePickerTitleKey,
+ undefined, // aChosenData,
+ undefined, // aReferrer,
+ undefined, // aCookieJarSettings,
+ document, // aInitiatingDocument,
+ undefined, // aSkipPrompt,
+ undefined, // aCacheKey,
+ undefined // aIsContentWindowPrivate
+ );
+ }
+}
+
+function ClearAttachmentList() {
+ // clear selection
+ var list = document.getElementById("attachmentList");
+ list.clearSelection();
+
+ while (list.hasChildNodes()) {
+ list.lastChild.remove();
+ }
+}
+
+// See attachmentBucketDNDObserver, which should have the same logic.
+let attachmentListDNDObserver = {
+ onDragStart(event) {
+ // NOTE: Starting a drag on an attachment item will normally also select
+ // the attachment item before this method is called. But this is not
+ // necessarily the case. E.g. holding Shift when starting the drag
+ // operation. When it isn't selected, we just don't transfer.
+ if (event.target.matches(".attachmentItem[selected]")) {
+ // Also transfer other selected attachment items.
+ let attachments = Array.from(
+ document.querySelectorAll("#attachmentList .attachmentItem[selected]"),
+ item => item.attachment
+ );
+ setupDataTransfer(event, attachments);
+ }
+ event.stopPropagation();
+ },
+};
+
+let attachmentNameDNDObserver = {
+ onDragStart(event) {
+ let attachmentList = document.getElementById("attachmentList");
+ setupDataTransfer(event, [attachmentList.getItemAtIndex(0).attachment]);
+ event.stopPropagation();
+ },
+};
+
+function onShowOtherActionsPopup() {
+ // Enable/disable the Open Conversation button.
+ let glodaEnabled = Services.prefs.getBoolPref(
+ "mailnews.database.global.indexer.enabled"
+ );
+
+ let openConversation = document.getElementById(
+ "otherActionsOpenConversation"
+ );
+ // Check because this menuitem element is not present in messageWindow.xhtml.
+ if (openConversation) {
+ openConversation.disabled = !(
+ glodaEnabled && Gloda.isMessageIndexed(gMessage)
+ );
+ }
+
+ let isDummyMessage = !gViewWrapper.isSynthetic && !gMessage.folder;
+ let tagsItem = document.getElementById("otherActionsTag");
+ let markAsReadItem = document.getElementById("markAsReadMenuItem");
+ let markAsUnreadItem = document.getElementById("markAsUnreadMenuItem");
+
+ if (isDummyMessage) {
+ tagsItem.disabled = true;
+ markAsReadItem.disabled = true;
+ markAsReadItem.removeAttribute("hidden");
+ markAsUnreadItem.setAttribute("hidden", true);
+ } else {
+ tagsItem.disabled = false;
+ markAsReadItem.disabled = false;
+ if (SelectedMessagesAreRead()) {
+ markAsReadItem.setAttribute("hidden", true);
+ markAsUnreadItem.removeAttribute("hidden");
+ } else {
+ markAsReadItem.removeAttribute("hidden");
+ markAsUnreadItem.setAttribute("hidden", true);
+ }
+ }
+
+ document.getElementById("otherActions-calendar-convert-menu").hidden =
+ isDummyMessage || !calendarDeactivator.isCalendarActivated;
+
+ // Check if the current message is feed or not.
+ let isFeed = FeedUtils.isFeedMessage(gMessage);
+ document.getElementById("otherActionsMessageBodyAs").hidden = isFeed;
+ document.getElementById("otherActionsFeedBodyAs").hidden = !isFeed;
+}
+
+function InitOtherActionsViewBodyMenu() {
+ let html_as = Services.prefs.getIntPref("mailnews.display.html_as");
+ let prefer_plaintext = Services.prefs.getBoolPref(
+ "mailnews.display.prefer_plaintext"
+ );
+ let disallow_classes = Services.prefs.getIntPref(
+ "mailnews.display.disallow_mime_handlers"
+ );
+ let isFeed = false; // TODO
+ const kDefaultIDs = [
+ "otherActionsMenu_bodyAllowHTML",
+ "otherActionsMenu_bodySanitized",
+ "otherActionsMenu_bodyAsPlaintext",
+ "otherActionsMenu_bodyAllParts",
+ ];
+ const kRssIDs = [
+ "otherActionsMenu_bodyFeedSummaryAllowHTML",
+ "otherActionsMenu_bodyFeedSummarySanitized",
+ "otherActionsMenu_bodyFeedSummaryAsPlaintext",
+ ];
+ let menuIDs = isFeed ? kRssIDs : kDefaultIDs;
+
+ if (disallow_classes > 0) {
+ window.top.gDisallow_classes_no_html = disallow_classes;
+ }
+ // else gDisallow_classes_no_html keeps its initial value (see top)
+
+ let AllowHTML_menuitem = document.getElementById(menuIDs[0]);
+ let Sanitized_menuitem = document.getElementById(menuIDs[1]);
+ let AsPlaintext_menuitem = document.getElementById(menuIDs[2]);
+ let AllBodyParts_menuitem = menuIDs[3]
+ ? document.getElementById(menuIDs[3])
+ : null;
+
+ document.getElementById("otherActionsMenu_bodyAllParts").hidden =
+ !Services.prefs.getBoolPref("mailnews.display.show_all_body_parts_menu");
+
+ // Clear all checkmarks.
+ AllowHTML_menuitem.removeAttribute("checked");
+ Sanitized_menuitem.removeAttribute("checked");
+ AsPlaintext_menuitem.removeAttribute("checked");
+ if (AllBodyParts_menuitem) {
+ AllBodyParts_menuitem.removeAttribute("checked");
+ }
+
+ if (
+ !prefer_plaintext &&
+ !html_as &&
+ !disallow_classes &&
+ AllowHTML_menuitem
+ ) {
+ AllowHTML_menuitem.setAttribute("checked", true);
+ } else if (
+ !prefer_plaintext &&
+ html_as == 3 &&
+ disallow_classes > 0 &&
+ Sanitized_menuitem
+ ) {
+ Sanitized_menuitem.setAttribute("checked", true);
+ } else if (
+ prefer_plaintext &&
+ html_as == 1 &&
+ disallow_classes > 0 &&
+ AsPlaintext_menuitem
+ ) {
+ AsPlaintext_menuitem.setAttribute("checked", true);
+ } else if (
+ !prefer_plaintext &&
+ html_as == 4 &&
+ !disallow_classes &&
+ AllBodyParts_menuitem
+ ) {
+ AllBodyParts_menuitem.setAttribute("checked", true);
+ }
+ // else (the user edited prefs/user.js) check none of the radio menu items
+
+ if (isFeed) {
+ AllowHTML_menuitem.hidden = !gShowFeedSummary;
+ Sanitized_menuitem.hidden = !gShowFeedSummary;
+ AsPlaintext_menuitem.hidden = !gShowFeedSummary;
+ document.getElementById(
+ "otherActionsMenu_viewFeedSummarySeparator"
+ ).hidden = !gShowFeedSummary;
+ }
+}
+
+/**
+ * Object literal to handle a few simple customization options for the message
+ * header.
+ */
+const gHeaderCustomize = {
+ docURL: "chrome://messenger/content/messenger.xhtml",
+ /**
+ * The DOM element panel collecting all customization options.
+ *
+ * @type {XULElement}
+ */
+ customizePanel: null,
+ /**
+ * The object storing all saved customization options.
+ *
+ * @note Any keys added to this object should also be added to the telemetry
+ * scalar tb.ui.configuration.message_header.
+ *
+ * @type {object}
+ * @property {boolean} showAvatar - If the profile picture of the sender
+ * should be shown.
+ * @property {boolean} showBigAvatar - If a big profile picture of the sender
+ * should be shown.
+ * @property {boolean} showFullAddress - If the sender should always be
+ * shown with the full name and email address.
+ * @property {boolean} hideLabels - If the labels column should be hidden.
+ * @property {boolean} subjectLarge - If the font size of the subject line
+ * should be increased.
+ * @property {string} buttonStyle - The style in which the buttons should be
+ * rendered:
+ * - "default" = icons+text
+ * - "only-icons" = only icons
+ * - "only-text" = only text
+ */
+ customizeData: {
+ showAvatar: true,
+ showBigAvatar: false,
+ showFullAddress: true,
+ hideLabels: true,
+ subjectLarge: true,
+ buttonStyle: "default",
+ },
+
+ /**
+ * Initialize the customizer.
+ */
+ init() {
+ this.customizePanel = document.getElementById(
+ "messageHeaderCustomizationPanel"
+ );
+
+ if (Services.xulStore.hasValue(this.docURL, "messageHeader", "layout")) {
+ this.customizeData = JSON.parse(
+ Services.xulStore.getValue(this.docURL, "messageHeader", "layout")
+ );
+ this.updateLayout();
+ }
+ },
+
+ /**
+ * Reset and update the customized style of the message header.
+ */
+ updateLayout() {
+ let header = document.getElementById("messageHeader");
+ // Always clear existing styles to avoid visual issues.
+ header.classList.remove(
+ "message-header-large-subject",
+ "message-header-buttons-only-icons",
+ "message-header-buttons-only-text",
+ "message-header-hide-label-column"
+ );
+
+ // Bail out if we don't have anything to customize.
+ if (!Object.keys(this.customizeData).length) {
+ header.classList.add(
+ "message-header-large-subject",
+ "message-header-show-recipient-avatar",
+ "message-header-show-sender-full-address",
+ "message-header-hide-label-column"
+ );
+ return;
+ }
+
+ header.classList.toggle(
+ "message-header-large-subject",
+ this.customizeData.subjectLarge || false
+ );
+
+ header.classList.toggle(
+ "message-header-hide-label-column",
+ this.customizeData.hideLabels || false
+ );
+
+ header.classList.toggle(
+ "message-header-show-recipient-avatar",
+ this.customizeData.showAvatar || false
+ );
+
+ header.classList.toggle(
+ "message-header-show-big-avatar",
+ this.customizeData.showBigAvatar || false
+ );
+
+ header.classList.toggle(
+ "message-header-show-sender-full-address",
+ this.customizeData.showFullAddress || false
+ );
+
+ switch (this.customizeData.buttonStyle) {
+ case "only-icons":
+ case "only-text":
+ header.classList.add(
+ `message-header-buttons-${this.customizeData.buttonStyle}`
+ );
+ break;
+
+ case "default":
+ default:
+ header.classList.remove(
+ "message-header-buttons-only-icons",
+ "message-header-buttons-only-text"
+ );
+ break;
+ }
+
+ gMessageHeader.syncLabelsColumnWidths();
+ },
+
+ /**
+ * Show the customization panel for the message header.
+ */
+ showPanel() {
+ this.customizePanel.openPopup(
+ document.getElementById("otherActionsButton"),
+ "after_end",
+ 6,
+ 6,
+ false
+ );
+ },
+
+ /**
+ * Update the panel's elements to reflect the users' customization.
+ */
+ onPanelShowing() {
+ document.getElementById("headerButtonStyle").value =
+ this.customizeData.buttonStyle || "default";
+
+ document.getElementById("headerShowAvatar").checked =
+ this.customizeData.showAvatar || false;
+
+ document.getElementById("headerShowBigAvatar").checked =
+ this.customizeData.showBigAvatar || false;
+
+ document.getElementById("headerShowFullAddress").checked =
+ this.customizeData.showFullAddress || false;
+
+ document.getElementById("headerHideLabels").checked =
+ this.customizeData.hideLabels || false;
+
+ document.getElementById("headerSubjectLarge").checked =
+ this.customizeData.subjectLarge || false;
+
+ let type = Ci.nsMimeHeaderDisplayTypes;
+ let pref = Services.prefs.getIntPref("mail.show_headers");
+
+ document.getElementById("headerViewAllHeaders").checked =
+ type.AllHeaders == pref;
+ },
+
+ /**
+ * Update the buttons style when the menuitem value is changed.
+ *
+ * @param {Event} event - The menuitem command event.
+ */
+ updateButtonStyle(event) {
+ this.customizeData.buttonStyle = event.target.value;
+ this.updateLayout();
+ },
+
+ /**
+ * Show or hide the profile picture of the sender recipient.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleAvatar(event) {
+ const isChecked = event.target.checked;
+ this.customizeData.showAvatar = isChecked;
+ document.getElementById("headerShowBigAvatar").disabled = !isChecked;
+ this.updateLayout();
+ },
+
+ /**
+ * Show big or small profile picture of the sender recipient.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleBigAvatar(event) {
+ this.customizeData.showBigAvatar = event.target.checked;
+ this.updateLayout();
+ },
+
+ /**
+ * Show or hide the sender's full address, which will show the display name
+ * and the email address on two different lines.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleSenderAddress(event) {
+ this.customizeData.showFullAddress = event.target.checked;
+ this.updateLayout();
+ },
+
+ /**
+ * Show or hide the labels column.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleLabelColumn(event) {
+ this.customizeData.hideLabels = event.target.checked;
+ this.updateLayout();
+ },
+
+ /**
+ * Update the subject style when the checkbox is clicked.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ updateSubjectStyle(event) {
+ this.customizeData.subjectLarge = event.target.checked;
+ this.updateLayout();
+ },
+
+ /**
+ * Show or hide all the headers of a message.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleAllHeaders(event) {
+ let mode = event.target.checked
+ ? Ci.nsMimeHeaderDisplayTypes.AllHeaders
+ : Ci.nsMimeHeaderDisplayTypes.NormalHeaders;
+ Services.prefs.setIntPref("mail.show_headers", mode);
+ AdjustHeaderView(mode);
+ ReloadMessage();
+ },
+
+ /**
+ * Close the customize panel.
+ */
+ closePanel() {
+ this.customizePanel.hidePopup();
+ },
+
+ /**
+ * Update the xulStore only when the panel is closed.
+ */
+ onPanelHidden() {
+ Services.xulStore.setValue(
+ this.docURL,
+ "messageHeader",
+ "layout",
+ JSON.stringify(this.customizeData)
+ );
+ },
+};
+
+/**
+ * Object to handle the creation, destruction, and update of all recipient
+ * fields that will be showed in the message header.
+ */
+const gMessageHeader = {
+ /**
+ * Get the newsgroup server corresponding to the currently selected message.
+ *
+ * @returns {?nsISubscribableServer} The server for the newsgroup, or null.
+ */
+ get newsgroupServer() {
+ if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) {
+ return gFolder.server?.QueryInterface(Ci.nsISubscribableServer);
+ }
+
+ return null;
+ },
+
+ /**
+ * Toggle the scrollable style of the message header area.
+ *
+ * @param {boolean} showAllHeaders - True if we need to show all header fields
+ * and ignore the space limit for multi recipients row.
+ */
+ toggleScrollableHeader(showAllHeaders) {
+ document
+ .getElementById("messageHeader")
+ .classList.toggle("scrollable", showAllHeaders);
+ },
+
+ /**
+ * Ensure that the all visible labels have the same size.
+ */
+ syncLabelsColumnWidths() {
+ let allHeaderLabels = document.querySelectorAll(
+ ".message-header-row:not([hidden]) .message-header-label"
+ );
+
+ // Clear existing style.
+ for (let label of allHeaderLabels) {
+ label.style.minWidth = null;
+ }
+
+ let minWidth = Math.max(...Array.from(allHeaderLabels, i => i.clientWidth));
+ for (let label of allHeaderLabels) {
+ label.style.minWidth = `${minWidth}px`;
+ }
+ },
+
+ openCopyPopup(event, element) {
+ document.getElementById("copyCreateFilterFrom").disabled =
+ !gFolder?.server.canHaveFilters;
+
+ let popup = document.getElementById(
+ element.matches(`:scope[is="url-header-row"]`)
+ ? "copyUrlPopup"
+ : "copyPopup"
+ );
+ popup.headerField = element;
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ async openEmailAddressPopup(event, element) {
+ // Bail out if we don't have an email address.
+ if (!element.emailAddress) {
+ return;
+ }
+
+ document
+ .getElementById("emailAddressPlaceHolder")
+ .setAttribute("label", element.emailAddress);
+
+ document.getElementById("addToAddressBookItem").hidden =
+ element.cardDetails.card;
+ document.getElementById("editContactItem").hidden =
+ !element.cardDetails.card || element.cardDetails.book?.readOnly;
+ document.getElementById("viewContactItem").hidden =
+ !element.cardDetails.card || !element.cardDetails.book?.readOnly;
+
+ let discoverKeyMenuItem = document.getElementById("searchKeysOpenPGP");
+ if (discoverKeyMenuItem) {
+ let hidden = await PgpSqliteDb2.hasAnyPositivelyAcceptedKeyForEmail(
+ element.emailAddress
+ );
+ discoverKeyMenuItem.hidden = hidden;
+ discoverKeyMenuItem.nextElementSibling.hidden = hidden; // Hide separator.
+ }
+
+ document.getElementById("createFilterFrom").disabled =
+ !gFolder?.server.canHaveFilters;
+
+ let popup = document.getElementById("emailAddressPopup");
+ popup.headerField = element;
+
+ if (!event.screenX) {
+ popup.openPopup(event.target, "after_start", 0, 0, true);
+ return;
+ }
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ openNewsgroupPopup(event, element) {
+ document
+ .getElementById("newsgroupPlaceHolder")
+ .setAttribute("label", element.textContent);
+
+ let subscribed = this.newsgroupServer
+ ?.QueryInterface(Ci.nsINntpIncomingServer)
+ .containsNewsgroup(element.textContent);
+ document.getElementById("subscribeToNewsgroupItem").hidden = subscribed;
+ document.getElementById("subscribeToNewsgroupSeparator").hidden =
+ subscribed;
+
+ let popup = document.getElementById("newsgroupPopup");
+ popup.headerField = element;
+
+ if (!event.screenX) {
+ popup.openPopup(event.target, "after_start", 0, 0, true);
+ return;
+ }
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ openMessageIdPopup(event, element) {
+ document
+ .getElementById("messageIdContext-messageIdTarget")
+ .setAttribute("label", element.id);
+
+ // We don't want to show "Open Message For ID" for the same message
+ // we're viewing.
+ document.getElementById("messageIdContext-openMessageForMsgId").hidden =
+ `<${gMessage.messageId}>` == element.id;
+
+ // We don't want to show "Open Browser With Message-ID" for non-nntp
+ // messages.
+ document.getElementById("messageIdContext-openBrowserWithMsgId").hidden =
+ !gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false);
+
+ let popup = document.getElementById("messageIdContext");
+ popup.headerField = element;
+
+ if (!event.screenX) {
+ popup.openPopup(event.target, "after_start", 0, 0, true);
+ return;
+ }
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ /**
+ * Add a contact to the address book.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ addContact(event) {
+ event.currentTarget.parentNode.headerField.addToAddressBook();
+ },
+
+ /**
+ * Show the edit card popup panel.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ showContactEdit(event) {
+ this.editContact(event.currentTarget.parentNode.headerField);
+ },
+
+ /**
+ * Trigger a new message compose window.
+ *
+ * @param {Event} event - The click DOMEvent.
+ */
+ composeMessage(event) {
+ let recipient = event.currentTarget.parentNode.headerField;
+
+ let fields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (recipient.classList.contains("header-newsgroup")) {
+ fields.newsgroups = recipient.textContent;
+ }
+
+ if (recipient.fullAddress) {
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(
+ recipient.fullAddress
+ );
+ if (addresses.length) {
+ fields.to = MailServices.headerParser.makeMimeHeader([addresses[0]]);
+ }
+ }
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+
+ // If the Shift key was pressed toggle the composition format
+ // (HTML vs. plaintext).
+ params.format = event.shiftKey
+ ? Ci.nsIMsgCompFormat.OppositeOfDefault
+ : Ci.nsIMsgCompFormat.Default;
+
+ if (gFolder) {
+ params.identity = MailServices.accounts.getFirstIdentityForServer(
+ gFolder.server
+ );
+ }
+ params.composeFields = fields;
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ },
+
+ /**
+ * Copy the email address, as well as the name if wanted, in the clipboard.
+ *
+ * @param {Event} event - The DOM Event.
+ * @param {boolean} withName - True if we need to copy also the name.
+ */
+ copyAddress(event, withName = false) {
+ let recipient = event.currentTarget.parentNode.headerField;
+ let address;
+ if (recipient.classList.contains("header-newsgroup")) {
+ address = recipient.textContent;
+ } else {
+ address = withName ? recipient.fullAddress : recipient.emailAddress;
+ }
+ navigator.clipboard.writeText(address);
+ },
+
+ copyNewsgroupURL(event) {
+ let server = this.newsgroupServer;
+ if (!server) {
+ return;
+ }
+
+ let newsgroup = event.currentTarget.parentNode.headerField.textContent;
+
+ let url;
+ if (server.socketType != Ci.nsMsgSocketType.SSL) {
+ url = "news://" + server.hostName;
+ if (server.port != Ci.nsINntpUrl.DEFAULT_NNTP_PORT) {
+ url += ":" + server.port;
+ }
+ url += "/" + newsgroup;
+ } else {
+ url = "snews://" + server.hostName;
+ if (server.port != Ci.nsINntpUrl.DEFAULT_NNTPS_PORT) {
+ url += ":" + server.port;
+ }
+ url += "/" + newsgroup;
+ }
+
+ try {
+ let uri = Services.io.newURI(url);
+ navigator.clipboard.writeText(decodeURI(uri.spec));
+ } catch (e) {
+ console.error("Invalid URL: " + url);
+ }
+ },
+
+ /**
+ * Subscribe to a newsgroup.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ subscribeToNewsgroup(event) {
+ let server = this.newsgroupServer;
+ if (server) {
+ let newsgroup = event.currentTarget.parentNode.headerField.textContent;
+ server.subscribe(newsgroup);
+ server.commitSubscribeChanges();
+ }
+ },
+
+ /**
+ * Copy the text value of an header field.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ copyString(event) {
+ // This method is used inside the copyPopup menupopup, which is triggered by
+ // both HTML headers fields and XUL labels. We need to account for those
+ // different widgets in order to properly copy the text.
+ let target =
+ event.currentTarget.parentNode.triggerNode ||
+ event.currentTarget.parentNode.headerField;
+ navigator.clipboard.writeText(
+ window.getSelection().isCollapsed
+ ? target.textContent
+ : window.getSelection().toString()
+ );
+ },
+
+ /**
+ * Open the message filter dialog prefilled with available data.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ createFilter(event) {
+ let element = event.currentTarget.parentNode.headerField;
+ top.MsgFilters(
+ element.emailAddress || element.value.textContent,
+ gFolder,
+ element.dataset.headerName
+ );
+ },
+
+ /**
+ * Show the edit contact popup panel.
+ *
+ * @param {HTMLLIElement} element - The recipient element.
+ */
+ editContact(element) {
+ editContactInlineUI.showEditContactPanel(element.cardDetails, element);
+ },
+
+ /**
+ * Set the tags to the message header tag element.
+ */
+ setTags() {
+ // Bail out if we don't have a message selected.
+ if (!gMessage || !gFolder) {
+ return;
+ }
+
+ // Extract the tag keys from the message header.
+ let msgKeyArray = gMessage.getStringProperty("keywords").split(" ");
+
+ // Get the list of known tags.
+ let tagsArray = MailServices.tags.getAllTags().filter(t => t.tag);
+ let tagKeys = {};
+ for (let tagInfo of tagsArray) {
+ tagKeys[tagInfo.key] = true;
+ }
+ // Only use tags that match our saved tags.
+ let msgKeys = msgKeyArray.filter(k => k in tagKeys);
+
+ if (msgKeys.length) {
+ currentHeaderData.tags = {
+ headerName: "tags",
+ headerValue: msgKeys.join(" "),
+ };
+ return;
+ }
+
+ // No more tags, so clear out the header field.
+ delete currentHeaderData.tags;
+ },
+
+ onMessageIdClick(event) {
+ let id = event.currentTarget.closest(".header-message-id").id;
+ if (event.button == 0) {
+ // Remove the < and > symbols.
+ OpenMessageForMessageId(id.substring(1, id.length - 1));
+ }
+ },
+
+ openMessage(event) {
+ let id = event.currentTarget.parentNode.headerField.id;
+ // Remove the < and > symbols.
+ OpenMessageForMessageId(id.substring(1, id.length - 1));
+ },
+
+ openBrowser(event) {
+ let id = event.currentTarget.parentNode.headerField.id;
+ // Remove the < and > symbols.
+ OpenBrowserWithMessageId(id.substring(1, id.length - 1));
+ },
+
+ copyMessageId(event) {
+ navigator.clipboard.writeText(
+ event.currentTarget.parentNode.headerField.id
+ );
+ },
+
+ copyWebsiteUrl(event) {
+ navigator.clipboard.writeText(
+ event.currentTarget.parentNode.headerField.value.textContent
+ );
+ },
+};
+
+function MarkSelectedMessagesRead(markRead) {
+ ClearPendingReadTimer();
+ gDBView.doCommand(
+ markRead
+ ? Ci.nsMsgViewCommandType.markMessagesRead
+ : Ci.nsMsgViewCommandType.markMessagesUnread
+ );
+ if (markRead) {
+ reportMsgRead({ isNewRead: true });
+ }
+}
+
+function MarkSelectedMessagesFlagged(markFlagged) {
+ gDBView.doCommand(
+ markFlagged
+ ? Ci.nsMsgViewCommandType.flagMessages
+ : Ci.nsMsgViewCommandType.unflagMessages
+ );
+}
+
+/**
+ * Take the message id from the messageIdNode and use the url defined in the
+ * hidden pref "mailnews.messageid_browser.url" to open it in a browser window
+ * (%mid is replaced by the message id).
+ * @param {string} messageId - The message id to open.
+ */
+function OpenBrowserWithMessageId(messageId) {
+ var browserURL = Services.prefs.getComplexValue(
+ "mailnews.messageid_browser.url",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ browserURL = browserURL.replace(/%mid/, messageId);
+ try {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(browserURL));
+ } catch (ex) {
+ console.error(
+ "Failed to open message-id in browser; browserURL=" + browserURL
+ );
+ }
+}
+
+/**
+ * Take the message id from the messageIdNode, search for the corresponding
+ * message in all folders starting with the current selected folder, then the
+ * current account followed by the other accounts and open corresponding
+ * message if found.
+ * @param {string} messageId - The message id to open.
+ */
+function OpenMessageForMessageId(messageId) {
+ let startServer = gFolder?.server;
+
+ window.setCursor("wait");
+ let msgHdr = MailUtils.getMsgHdrForMsgId(messageId, startServer);
+ window.setCursor("auto");
+
+ // If message was found open corresponding message.
+ if (msgHdr) {
+ if (parent.location == "about:3pane") {
+ // Message in 3pane.
+ parent.selectMessage(msgHdr);
+ } else {
+ // Message in tab, standalone message window.
+ let uri = msgHdr.folder.getUriForMsg(msgHdr);
+ window.displayMessage(uri);
+ }
+ return;
+ }
+ let messageIdStr = "<" + messageId + ">";
+ let bundle = document.getElementById("bundle_messenger");
+ let errorTitle = bundle.getString("errorOpenMessageForMessageIdTitle");
+ let errorMessage = bundle.getFormattedString(
+ "errorOpenMessageForMessageIdMessage",
+ [messageIdStr]
+ );
+ Services.prompt.alert(window, errorTitle, errorMessage);
+}
+
+/**
+ * @param headermode {Ci.nsMimeHeaderDisplayTypes}
+ */
+function AdjustHeaderView(headermode) {
+ const all = Ci.nsMimeHeaderDisplayTypes.AllHeaders;
+ document
+ .getElementById("messageHeader")
+ .setAttribute("show_header_mode", headermode == all ? "all" : "normal");
+}
+
+/**
+ * Should the reply command/button be enabled?
+ *
+ * @return whether the reply command/button should be enabled.
+ */
+function IsReplyEnabled() {
+ // If we're in an rss item, we never want to Reply, because there's
+ // usually no-one useful to reply to.
+ return !FeedUtils.isFeedMessage(gMessage);
+}
+
+/**
+ * Should the reply-all command/button be enabled?
+ *
+ * @return whether the reply-all command/button should be enabled.
+ */
+function IsReplyAllEnabled() {
+ if (gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) {
+ // If we're in a news item, we always want ReplyAll, because we can
+ // reply to the sender and the newsgroup.
+ return true;
+ }
+ if (FeedUtils.isFeedMessage(gMessage)) {
+ // If we're in an rss item, we never want to ReplyAll, because there's
+ // usually no-one useful to reply to.
+ return false;
+ }
+
+ let addresses =
+ gMessage.author + "," + gMessage.recipients + "," + gMessage.ccList;
+
+ // If we've got any BCCed addresses (because we sent the message), add
+ // them as well.
+ if ("bcc" in currentHeaderData) {
+ addresses += currentHeaderData.bcc.headerValue;
+ }
+
+ // Check to see if my email address is in the list of addresses.
+ let [myIdentity] = MailUtils.getIdentityForHeader(gMessage);
+ let myEmail = myIdentity ? myIdentity.email : null;
+ // We aren't guaranteed to have an email address, so guard against that.
+ let imInAddresses =
+ myEmail && addresses.toLowerCase().includes(myEmail.toLowerCase());
+
+ // Now, let's get the number of unique addresses.
+ let uniqueAddresses = MailServices.headerParser.removeDuplicateAddresses(
+ addresses,
+ ""
+ );
+ let numAddresses =
+ MailServices.headerParser.parseEncodedHeader(uniqueAddresses).length;
+
+ // I don't want to count my address in the number of addresses to reply
+ // to, since I won't be emailing myself.
+ if (imInAddresses) {
+ numAddresses--;
+ }
+
+ // ReplyAll is enabled if there is more than 1 person to reply to.
+ return numAddresses > 1;
+}
+
+/**
+ * Should the reply-list command/button be enabled?
+ *
+ * @return whether the reply-list command/button should be enabled.
+ */
+function IsReplyListEnabled() {
+ // ReplyToList is enabled if there is a List-Post header
+ // with the correct format.
+ let listPost = currentHeaderData["list-post"];
+ if (!listPost) {
+ return false;
+ }
+
+ // XXX: Once Bug 496914 provides a parser, we should use that instead.
+ // Until then, we need to keep the following regex in sync with the
+ // listPost parsing in nsMsgCompose.cpp's
+ // QuotingOutputStreamListener::OnStopRequest.
+ return /<mailto:.+>/.test(listPost.headerValue);
+}
+
+/**
+ * Update the enabled/disabled states of the Reply, Reply-All, and
+ * Reply-List buttons. (After this function runs, one of the buttons
+ * should be shown, and the others should be hidden.)
+ */
+function UpdateReplyButtons() {
+ // If we have no message, because we're being called from
+ // MailToolboxCustomizeDone before someone selected a message, then just
+ // return.
+ if (!gMessage) {
+ return;
+ }
+
+ let buttonToShow;
+ if (gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) {
+ // News messages always default to the "followup" dual-button.
+ buttonToShow = "followup";
+ } else if (FeedUtils.isFeedMessage(gMessage)) {
+ // RSS items hide all the reply buttons.
+ buttonToShow = null;
+ } else if (IsReplyListEnabled()) {
+ // Mail messages show the "reply" button (not the dual-button) and
+ // possibly the "reply all" and "reply list" buttons.
+ buttonToShow = "replyList";
+ } else if (IsReplyAllEnabled()) {
+ buttonToShow = "replyAll";
+ } else {
+ buttonToShow = "reply";
+ }
+
+ let smartReplyButton = document.getElementById("hdrSmartReplyButton");
+ if (smartReplyButton) {
+ let replyButton = document.getElementById("hdrReplyButton");
+ let replyAllButton = document.getElementById("hdrReplyAllButton");
+ let replyListButton = document.getElementById("hdrReplyListButton");
+ let followupButton = document.getElementById("hdrFollowupButton");
+
+ replyButton.hidden = buttonToShow != "reply";
+ replyAllButton.hidden = buttonToShow != "replyAll";
+ replyListButton.hidden = buttonToShow != "replyList";
+ followupButton.hidden = buttonToShow != "followup";
+ }
+
+ let replyToSenderButton = document.getElementById("hdrReplyToSenderButton");
+ if (replyToSenderButton) {
+ if (FeedUtils.isFeedMessage(gMessage)) {
+ replyToSenderButton.hidden = true;
+ } else if (smartReplyButton) {
+ replyToSenderButton.hidden = buttonToShow == "reply";
+ } else {
+ replyToSenderButton.hidden = false;
+ }
+ }
+
+ // Run this method only after all the header toolbar buttons have been updated
+ // so we deal with the actual state.
+ headerToolbarNavigation.updateRovingTab();
+}
+
+/**
+ * Update the enabled/disabled states of the Reply, Reply-All, Reply-List,
+ * Followup, and Forward buttons based on the number of identities.
+ * If there are no identities, all of these buttons should be disabled.
+ */
+function updateComposeButtons() {
+ const hasIdentities = MailServices.accounts.allIdentities.length;
+ for (let id of [
+ "hdrReplyButton",
+ "hdrReplyAllButton",
+ "hdrReplyListButton",
+ "hdrFollowupButton",
+ "hdrForwardButton",
+ "hdrReplyToSenderButton",
+ ]) {
+ document.getElementById(id).disabled = !hasIdentities;
+ }
+}
+
+function SelectedMessagesAreJunk() {
+ try {
+ let junkScore = gMessage.getStringProperty("junkscore");
+ return junkScore != "" && junkScore != "0";
+ } catch (ex) {
+ return false;
+ }
+}
+
+function SelectedMessagesAreRead() {
+ return gMessage?.isRead;
+}
+
+function SelectedMessagesAreFlagged() {
+ return gMessage?.isFlagged;
+}
+
+function MsgReplyMessage(event) {
+ if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) {
+ MsgReplyGroup(event);
+ } else {
+ MsgReplySender(event);
+ }
+}
+
+function MsgReplySender(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToSender, event);
+}
+
+function MsgReplyGroup(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToGroup, event);
+}
+
+function MsgReplyToAllMessage(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyAll, event);
+}
+
+function MsgReplyToListMessage(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToList, event);
+}
+
+function MsgForwardMessage(event) {
+ var forwardType = Services.prefs.getIntPref("mail.forward_message_mode", 0);
+
+ // mail.forward_message_mode could be 1, if the user migrated from 4.x
+ // 1 (forward as quoted) is obsolete, so we treat is as forward inline
+ // since that is more like forward as quoted then forward as attachment
+ if (forwardType == 0) {
+ MsgForwardAsAttachment(event);
+ } else {
+ MsgForwardAsInline(event);
+ }
+}
+
+function MsgForwardAsAttachment(event) {
+ commandController._composeMsgByType(
+ Ci.nsIMsgCompType.ForwardAsAttachment,
+ event
+ );
+}
+
+function MsgForwardAsInline(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ForwardInline, event);
+}
+
+function MsgRedirectMessage(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.Redirect, event);
+}
+
+function MsgEditMessageAsNew(aEvent) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.EditAsNew, aEvent);
+}
+
+function MsgEditDraftMessage(aEvent) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.Draft, aEvent);
+}
+
+function MsgNewMessageFromTemplate(aEvent) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.Template, aEvent);
+}
+
+function MsgEditTemplateMessage(aEvent) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.EditTemplate, aEvent);
+}
+
+function MsgComposeDraftMessage() {
+ top.ComposeMessage(
+ Ci.nsIMsgCompType.Draft,
+ Ci.nsIMsgCompFormat.Default,
+ gFolder,
+ [gMessageURI]
+ );
+}
+
+/**
+ * Update the "archive", "junk" and "delete" buttons in the message header area.
+ */
+function updateHeaderToolbarButtons() {
+ let isDummyMessage = !gViewWrapper.isSynthetic && !gMessage.folder;
+ let archiveButton = document.getElementById("hdrArchiveButton");
+ let junkButton = document.getElementById("hdrJunkButton");
+ let trashButton = document.getElementById("hdrTrashButton");
+
+ if (isDummyMessage) {
+ archiveButton.disabled = true;
+ junkButton.disabled = true;
+ trashButton.disabled = true;
+ return;
+ }
+
+ archiveButton.disabled = !MessageArchiver.canArchive([gMessage]);
+ let junkScore = gMessage.getStringProperty("junkscore");
+ let hideJunk = junkScore == Ci.nsIJunkMailPlugin.IS_SPAM_SCORE;
+ if (!commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.junk)) {
+ hideJunk = true;
+ }
+ junkButton.disabled = hideJunk;
+ trashButton.disabled = false;
+}
+
+/**
+ * Checks if the selected messages can be marked as read or unread
+ *
+ * @param markingRead true if trying to mark messages as read, false otherwise
+ * @return true if the chosen operation can be performed
+ */
+function CanMarkMsgAsRead(markingRead) {
+ return gMessage && SelectedMessagesAreRead() != markingRead;
+}
+
+/**
+ * Marks the selected messages as read or unread
+ *
+ * @param read true if trying to mark messages as read, false if marking unread,
+ * undefined if toggling the read status
+ */
+function MsgMarkMsgAsRead(read) {
+ if (read == undefined) {
+ read = !gMessage.isRead;
+ }
+ MarkSelectedMessagesRead(read);
+}
+
+function MsgMarkAsFlagged() {
+ MarkSelectedMessagesFlagged(!SelectedMessagesAreFlagged());
+}
+
+/**
+ * Extract email data and prefill the event/task dialog with that data.
+ */
+function convertToEventOrTask(isTask = false) {
+ window.top.calendarExtract.extractFromEmail(gMessage, isTask);
+}
+
+/**
+ * Triggered by the onHdrPropertyChanged notification for a single message being
+ * displayed. We handle updating the message display if our displayed message
+ * might have had its junk status change. This primarily entails updating the
+ * notification bar (that thing that appears above the message and says "this
+ * message might be junk") and (potentially) reloading the message because junk
+ * status affects the form of HTML display used (sanitized vs not).
+ * When our tab implementation is no longer multiplexed (reusing the same
+ * display widget), this must be moved into the MessageDisplayWidget or
+ * otherwise be scoped to the tab.
+ *
+ * @param {nsIMsgHdr} msgHdr - The nsIMsgHdr of the message with a junk status change.
+ */
+function HandleJunkStatusChanged(msgHdr) {
+ if (!msgHdr || !msgHdr.folder) {
+ return;
+ }
+
+ let junkBarStatus = gMessageNotificationBar.checkJunkMsgStatus(msgHdr);
+
+ // Only reload message if junk bar display state is changing and only if the
+ // reload is really needed.
+ if (junkBarStatus != 0) {
+ // We may be forcing junk mail to be rendered with sanitized html.
+ // In that scenario, we want to reload the message if the status has just
+ // changed to not junk.
+ var sanitizeJunkMail = Services.prefs.getBoolPref(
+ "mail.spam.display.sanitize"
+ );
+
+ // Only bother doing this if we are modifying the html for junk mail....
+ if (sanitizeJunkMail) {
+ let junkScore = msgHdr.getStringProperty("junkscore");
+ let isJunk = junkScore == Ci.nsIJunkMailPlugin.IS_SPAM_SCORE;
+
+ // If the current row isn't going to change, reload to show sanitized or
+ // unsanitized. Otherwise we wouldn't see the reloaded version anyway.
+ // 1) When marking as non-junk from the Junk folder, the msg would move
+ // back to the Inbox -> no reload needed
+ // When marking as non-junk from a folder other than the Junk folder,
+ // the message isn't moved back to Inbox -> reload needed
+ // (see nsMsgDBView::DetermineActionsForJunkChange)
+ // 2) When marking as junk, the msg will move or delete, if manualMark is set.
+ // 3) Marking as junk in the junk folder just changes the junk status.
+ if (
+ (!isJunk && !msgHdr.folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk)) ||
+ (isJunk && !msgHdr.folder.server.spamSettings.manualMark) ||
+ (isJunk && msgHdr.folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk))
+ ) {
+ ReloadMessage();
+ return;
+ }
+ }
+ }
+
+ gMessageNotificationBar.setJunkMsg(msgHdr);
+}
+
+/**
+ * Object to handle message related notifications that are showing in a
+ * notificationbox above the message content.
+ */
+var gMessageNotificationBar = {
+ get stringBundle() {
+ delete this.stringBundle;
+ return (this.stringBundle = document.getElementById("bundle_messenger"));
+ },
+
+ get brandBundle() {
+ delete this.brandBundle;
+ return (this.brandBundle = document.getElementById("bundle_brand"));
+ },
+
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ document.getElementById("mail-notification-top").append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ /**
+ * Check if the current status of the junk notification is correct or not.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - Information about the message
+ * @returns {integer} Tri-state status information.
+ * 1: notification is missing
+ * 0: notification is correct
+ * -1: notification must be removed
+ */
+ checkJunkMsgStatus(aMsgHdr) {
+ let junkScore = aMsgHdr ? aMsgHdr.getStringProperty("junkscore") : "";
+ let junkStatus = this.isShowingJunkNotification();
+
+ if (junkScore == "" || junkScore == Ci.nsIJunkMailPlugin.IS_HAM_SCORE) {
+ // This is not junk. The notification should not be shown.
+ return junkStatus ? -1 : 0;
+ }
+
+ // This is junk. The notification should be shown.
+ return junkStatus ? 0 : 1;
+ },
+
+ setJunkMsg(aMsgHdr) {
+ goUpdateCommand("cmd_junk");
+
+ let junkBarStatus = this.checkJunkMsgStatus(aMsgHdr);
+ if (junkBarStatus == -1) {
+ this.msgNotificationBar.removeNotification(
+ this.msgNotificationBar.getNotificationWithValue("junkContent"),
+ true
+ );
+ } else if (junkBarStatus == 1) {
+ let brandName = this.brandBundle.getString("brandShortName");
+ let junkBarMsg = this.stringBundle.getFormattedString("junkBarMessage", [
+ brandName,
+ ]);
+
+ let buttons = [
+ {
+ label: this.stringBundle.getString("junkBarInfoButton"),
+ accessKey: this.stringBundle.getString("junkBarInfoButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ // TODO: This doesn't work in a message window.
+ top.openContentTab(
+ "https://support.mozilla.org/kb/thunderbird-and-junk-spam-messages"
+ );
+ return true; // keep notification open
+ },
+ },
+ {
+ label: this.stringBundle.getString("junkBarButton"),
+ accessKey: this.stringBundle.getString("junkBarButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ commandController.doCommand("cmd_markAsNotJunk");
+ // Return true (=don't close) since changing junk status will fire a
+ // JunkStatusChanged notification which will make the junk bar go away
+ // for this message -> no notification to close anymore -> trying to
+ // close would just fail.
+ return true;
+ },
+ },
+ ];
+
+ this.msgNotificationBar.appendNotification(
+ "junkContent",
+ {
+ label: junkBarMsg,
+ image: "chrome://messenger/skin/icons/junk.svg",
+ priority: this.msgNotificationBar.PRIORITY_WARNING_HIGH,
+ },
+ buttons
+ );
+ }
+ },
+
+ isShowingJunkNotification() {
+ return !!this.msgNotificationBar.getNotificationWithValue("junkContent");
+ },
+
+ setRemoteContentMsg(aMsgHdr, aContentURI, aCanOverride) {
+ // update the allow remote content for sender string
+ let brandName = this.brandBundle.getString("brandShortName");
+ let remoteContentMsg = this.stringBundle.getFormattedString(
+ "remoteContentBarMessage",
+ [brandName]
+ );
+
+ let buttonLabel = this.stringBundle.getString(
+ AppConstants.platform == "win"
+ ? "remoteContentPrefLabel"
+ : "remoteContentPrefLabelUnix"
+ );
+ let buttonAccesskey = this.stringBundle.getString(
+ AppConstants.platform == "win"
+ ? "remoteContentPrefAccesskey"
+ : "remoteContentPrefAccesskeyUnix"
+ );
+
+ let buttons = [
+ {
+ label: buttonLabel,
+ accessKey: buttonAccesskey,
+ popup: "remoteContentOptions",
+ callback() {},
+ },
+ ];
+
+ // The popup value is a space separated list of all the blocked origins.
+ let popup = document.getElementById("remoteContentOptions");
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ aContentURI,
+ {}
+ );
+ let origins = popup.value ? popup.value.split(" ") : [];
+ if (!origins.includes(principal.origin)) {
+ origins.push(principal.origin);
+ }
+ popup.value = origins.join(" ");
+
+ if (!this.isShowingRemoteContentNotification()) {
+ let notification = this.msgNotificationBar.appendNotification(
+ "remoteContent",
+ {
+ label: remoteContentMsg,
+ image: "chrome://messenger/skin/icons/remote-blocked.svg",
+ priority: this.msgNotificationBar.PRIORITY_WARNING_MEDIUM,
+ },
+ aCanOverride ? buttons : []
+ );
+
+ notification.buttonContainer.firstElementChild.classList.add(
+ "button-menu-list"
+ );
+ }
+ },
+
+ isShowingRemoteContentNotification() {
+ return !!this.msgNotificationBar.getNotificationWithValue("remoteContent");
+ },
+
+ setPhishingMsg() {
+ let phishingMsgNote = this.stringBundle.getString("phishingBarMessage");
+
+ let buttonLabel = this.stringBundle.getString(
+ AppConstants.platform == "win"
+ ? "phishingBarPrefLabel"
+ : "phishingBarPrefLabelUnix"
+ );
+ let buttonAccesskey = this.stringBundle.getString(
+ AppConstants.platform == "win"
+ ? "phishingBarPrefAccesskey"
+ : "phishingBarPrefAccesskeyUnix"
+ );
+
+ let buttons = [
+ {
+ label: buttonLabel,
+ accessKey: buttonAccesskey,
+ popup: "phishingOptions",
+ callback(aNotification, aButton) {},
+ },
+ ];
+
+ if (!this.isShowingPhishingNotification()) {
+ let notification = this.msgNotificationBar.appendNotification(
+ "maybeScam",
+ {
+ label: phishingMsgNote,
+ image: "chrome://messenger/skin/icons/phishing.svg",
+ priority: this.msgNotificationBar.PRIORITY_CRITICAL_MEDIUM,
+ },
+ buttons
+ );
+
+ notification.buttonContainer.firstElementChild.classList.add(
+ "button-menu-list"
+ );
+ }
+ },
+
+ isShowingPhishingNotification() {
+ return !!this.msgNotificationBar.getNotificationWithValue("maybeScam");
+ },
+
+ setMDNMsg(aMdnGenerator, aMsgHeader, aMimeHdr) {
+ this.mdnGenerator = aMdnGenerator;
+ // Return receipts can be RFC 3798 or not.
+ let mdnHdr =
+ aMimeHdr.extractHeader("Disposition-Notification-To", false) ||
+ aMimeHdr.extractHeader("Return-Receipt-To", false); // not
+ let fromHdr = aMimeHdr.extractHeader("From", false);
+
+ let mdnAddr =
+ MailServices.headerParser.extractHeaderAddressMailboxes(mdnHdr);
+ let fromAddr =
+ MailServices.headerParser.extractHeaderAddressMailboxes(fromHdr);
+
+ let authorName =
+ MailServices.headerParser.extractFirstName(
+ aMsgHeader.mime2DecodedAuthor
+ ) || aMsgHeader.author;
+
+ // If the return receipt doesn't go to the sender address, note that in the
+ // notification.
+ let mdnBarMsg =
+ mdnAddr != fromAddr
+ ? this.stringBundle.getFormattedString("mdnBarMessageAddressDiffers", [
+ authorName,
+ mdnAddr,
+ ])
+ : this.stringBundle.getFormattedString("mdnBarMessageNormal", [
+ authorName,
+ ]);
+
+ let buttons = [
+ {
+ label: this.stringBundle.getString("mdnBarSendReqButton"),
+ accessKey: this.stringBundle.getString("mdnBarSendReqButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ SendMDNResponse();
+ return false; // close notification
+ },
+ },
+ {
+ label: this.stringBundle.getString("mdnBarIgnoreButton"),
+ accessKey: this.stringBundle.getString("mdnBarIgnoreButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ IgnoreMDNResponse();
+ return false; // close notification
+ },
+ },
+ ];
+
+ this.msgNotificationBar.appendNotification(
+ "mdnRequested",
+ {
+ label: mdnBarMsg,
+ priority: this.msgNotificationBar.PRIORITY_INFO_MEDIUM,
+ },
+ buttons
+ );
+ },
+
+ setDraftEditMessage() {
+ if (!gMessage || !gFolder) {
+ return;
+ }
+
+ if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)) {
+ let draftMsgNote = this.stringBundle.getString("draftMessageMsg");
+
+ let buttons = [
+ {
+ label: this.stringBundle.getString("draftMessageButton"),
+ accessKey: this.stringBundle.getString("draftMessageButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ MsgComposeDraftMessage();
+ return true; // keep notification open
+ },
+ },
+ ];
+
+ this.msgNotificationBar.appendNotification(
+ "draftMsgContent",
+ {
+ label: draftMsgNote,
+ priority: this.msgNotificationBar.PRIORITY_INFO_HIGH,
+ },
+ buttons
+ );
+ }
+ },
+
+ clearMsgNotifications() {
+ this.msgNotificationBar.removeAllNotifications(true);
+ },
+};
+
+/**
+ * LoadMsgWithRemoteContent
+ * Reload the current message, allowing remote content
+ */
+function LoadMsgWithRemoteContent() {
+ // we want to get the msg hdr for the currently selected message
+ // change the "remoteContentBar" property on it
+ // then reload the message
+
+ setMsgHdrPropertyAndReload("remoteContentPolicy", kAllowRemoteContent);
+ window.content?.focus();
+}
+
+/**
+ * Populate the remote content options for the current message.
+ */
+function onRemoteContentOptionsShowing(aEvent) {
+ let origins = aEvent.target.value ? aEvent.target.value.split(" ") : [];
+
+ let addresses = MailServices.headerParser.parseEncodedHeader(gMessage.author);
+ addresses = addresses.slice(0, 1);
+ // If there is an author's email, put it also in the menu.
+ let adrCount = addresses.length;
+ if (adrCount > 0) {
+ let authorEmailAddress = addresses[0].email;
+ let authorEmailAddressURI = Services.io.newURI(
+ "chrome://messenger/content/email=" + authorEmailAddress
+ );
+ let mailPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ authorEmailAddressURI,
+ {}
+ );
+ origins.push(mailPrincipal.origin);
+ }
+
+ let messengerBundle = document.getElementById("bundle_messenger");
+
+ // Out with the old...
+ let children = aEvent.target.children;
+ for (let i = children.length - 1; i >= 0; i--) {
+ if (children[i].getAttribute("class") == "allow-remote-uri") {
+ children[i].remove();
+ }
+ }
+
+ let urlSepar = document.getElementById("remoteContentAllMenuSeparator");
+
+ // ... and in with the new.
+ for (let origin of origins) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute(
+ "label",
+ messengerBundle.getFormattedString("remoteAllowResource", [
+ origin.replace("chrome://messenger/content/email=", ""),
+ ])
+ );
+ menuitem.setAttribute("value", origin);
+ menuitem.setAttribute("class", "allow-remote-uri");
+ menuitem.setAttribute("oncommand", "allowRemoteContentForURI(this.value);");
+ if (origin.startsWith("chrome://messenger/content/email=")) {
+ aEvent.target.appendChild(menuitem);
+ } else {
+ aEvent.target.insertBefore(menuitem, urlSepar);
+ }
+ }
+
+ let URLcount = origins.length - adrCount;
+ let allowAllItem = document.getElementById("remoteContentOptionAllowAll");
+ let allURLLabel = messengerBundle.getString("remoteAllowAll");
+ allowAllItem.label = PluralForm.get(URLcount, allURLLabel).replace(
+ "#1",
+ URLcount
+ );
+
+ allowAllItem.collapsed = URLcount < 2;
+ document.getElementById("remoteContentOriginsMenuSeparator").collapsed =
+ urlSepar.collapsed = allowAllItem.collapsed && adrCount == 0;
+}
+
+/**
+ * Add privileges to display remote content for the given uri.
+ *
+ * @param aUriSpec |String| uri for the site to add permissions for.
+ * @param aReload Reload the message display after allowing the URI.
+ */
+function allowRemoteContentForURI(aUriSpec, aReload = true) {
+ let uri = Services.io.newURI(aUriSpec);
+ Services.perms.addFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipal(uri, {}),
+ "image",
+ Services.perms.ALLOW_ACTION
+ );
+ if (aReload) {
+ ReloadMessage();
+ }
+}
+
+/**
+ * Add privileges to display remote content for the given uri.
+ *
+ * @param aListNode The menulist element containing the URIs to allow.
+ */
+function allowRemoteContentForAll(aListNode) {
+ let uriNodes = aListNode.querySelectorAll(".allow-remote-uri");
+ for (let uriNode of uriNodes) {
+ if (!uriNode.value.startsWith("chrome://messenger/content/email=")) {
+ allowRemoteContentForURI(uriNode.value, false);
+ }
+ }
+ ReloadMessage();
+}
+
+/**
+ * Displays fine-grained, per-site preferences for remote content.
+ */
+function editRemoteContentSettings() {
+ top.openOptionsDialog("panePrivacy", "privacyCategory");
+}
+
+/**
+ * Set the msg hdr flag to ignore the phishing warning and reload the message.
+ */
+function IgnorePhishingWarning() {
+ // This property should really be called skipPhishingWarning or something
+ // like that, but it's too late to change that now.
+ // This property is used to suppress the phishing bar for the message.
+ setMsgHdrPropertyAndReload("notAPhishMessage", 1);
+}
+
+/**
+ * Open the preferences dialog to allow disabling the scam feature.
+ */
+function OpenPhishingSettings() {
+ top.openOptionsDialog("panePrivacy", "privacySecurityCategory");
+}
+
+function setMsgHdrPropertyAndReload(aProperty, aValue) {
+ // we want to get the msg hdr for the currently selected message
+ // change the appropriate property on it then reload the message
+ if (gMessage) {
+ gMessage.setUint32Property(aProperty, aValue);
+ ReloadMessage();
+ }
+}
+
+/**
+ * Mark a specified message as read.
+ * @param msgHdr header (nsIMsgDBHdr) of the message to mark as read
+ */
+function MarkMessageAsRead(msgHdr) {
+ ClearPendingReadTimer();
+ msgHdr.folder.markMessagesRead([msgHdr], true);
+ reportMsgRead({ isNewRead: true });
+}
+
+function ClearPendingReadTimer() {
+ if (gMarkViewedMessageAsReadTimer) {
+ clearTimeout(gMarkViewedMessageAsReadTimer);
+ gMarkViewedMessageAsReadTimer = null;
+ }
+}
+
+// this is called when layout is actually finished rendering a
+// mail message. OnMsgLoaded is called when libmime is done parsing the message
+function OnMsgParsed(aUrl) {
+ // browser doesn't do this, but I thought it could be a useful thing to test out...
+ // If the find bar is visible and we just loaded a new message, re-run
+ // the find command. This means the new message will get highlighted and
+ // we'll scroll to the first word in the message that matches the find text.
+ var findBar = document.getElementById("FindToolbar");
+ if (!findBar.hidden) {
+ findBar.onFindAgainCommand(false);
+ }
+
+ let browser = getMessagePaneBrowser();
+ // Run the phishing detector on the message if it hasn't been marked as not
+ // a scam already.
+ if (
+ gMessage &&
+ !gMessage.getUint32Property("notAPhishMessage") &&
+ PhishingDetector.analyzeMsgForPhishingURLs(aUrl, browser)
+ ) {
+ gMessageNotificationBar.setPhishingMsg();
+ }
+
+ // Notify anyone (e.g., extensions) who's interested in when a message is loaded.
+ Services.obs.notifyObservers(null, "MsgMsgDisplayed", gMessageURI);
+
+ let doc = browser && browser.contentDocument ? browser.contentDocument : null;
+
+ // Rewrite any anchor elements' href attribute to reflect that the loaded
+ // document is a mailnews url. This will cause docShell to scroll to the
+ // element in the document rather than opening the link externally.
+ let links = doc && doc.links ? doc.links : [];
+ for (let linkNode of links) {
+ if (!linkNode.hash) {
+ continue;
+ }
+
+ // We have a ref fragment which may reference a node in this document.
+ // Ensure html in mail anchors work as expected.
+ let anchorId = linkNode.hash.replace("#", "");
+ // Continue if an id (html5) or name attribute value for the ref is not
+ // found in this document.
+ let selector = "#" + anchorId + ", [name='" + anchorId + "']";
+ try {
+ if (!linkNode.ownerDocument.querySelector(selector)) {
+ continue;
+ }
+ } catch (ex) {
+ continue;
+ }
+
+ // Then check if the href url matches the document baseURL.
+ if (
+ makeURI(linkNode.href).specIgnoringRef !=
+ makeURI(linkNode.baseURI).specIgnoringRef
+ ) {
+ continue;
+ }
+
+ // Finally, if the document url is a message url, and the anchor href is
+ // http, it needs to be adjusted so docShell finds the node.
+ let messageURI = makeURI(linkNode.ownerDocument.URL);
+ if (
+ messageURI instanceof Ci.nsIMsgMailNewsUrl &&
+ linkNode.href.startsWith("http")
+ ) {
+ linkNode.href = messageURI.specIgnoringRef + linkNode.hash;
+ }
+ }
+
+ // Scale any overflowing images, exclude http content.
+ let imgs = doc && !doc.URL.startsWith("http") ? doc.images : [];
+ for (let img of imgs) {
+ if (
+ img.clientWidth - doc.body.offsetWidth >= 0 &&
+ (img.clientWidth <= img.naturalWidth || !img.naturalWidth)
+ ) {
+ img.setAttribute("overflowing", "true");
+ }
+
+ // This is the default case for images when a message is loaded.
+ img.setAttribute("shrinktofit", "true");
+ }
+}
+
+function OnMsgLoaded(aUrl) {
+ if (!aUrl) {
+ return;
+ }
+
+ window.msgLoaded = true;
+ window.dispatchEvent(
+ new CustomEvent("MsgLoaded", { detail: gMessage, bubbles: true })
+ );
+ window.dispatchEvent(
+ new CustomEvent("MsgsLoaded", { detail: [gMessage], bubbles: true })
+ );
+
+ if (!gFolder) {
+ return;
+ }
+
+ gMessageNotificationBar.setJunkMsg(gMessage);
+
+ // See if MDN was requested but has not been sent.
+ HandleMDNResponse(aUrl);
+}
+
+/**
+ * Marks the message as read, optionally after a delay, if the preferences say
+ * we should do so.
+ */
+function autoMarkAsRead() {
+ if (!gMessage?.folder) {
+ // The message can't be marked read or unread.
+ return;
+ }
+
+ if (document.hidden) {
+ // We're in an inactive docShell (probably a background tab). Wait until
+ // it becomes active before marking the message as read.
+ document.addEventListener("visibilitychange", () => autoMarkAsRead(), {
+ once: true,
+ });
+ return;
+ }
+
+ let markReadAutoMode = Services.prefs.getBoolPref(
+ "mailnews.mark_message_read.auto"
+ );
+
+ // We just finished loading a message. If messages are to be marked as read
+ // automatically, set a timer to mark the message is read after n seconds
+ // where n can be configured by the user.
+ if (!gMessage.isRead && markReadAutoMode) {
+ let markReadOnADelay = Services.prefs.getBoolPref(
+ "mailnews.mark_message_read.delay"
+ );
+
+ let winType = top.document.documentElement.getAttribute("windowtype");
+ // Only use the timer if viewing using the 3-pane preview pane and the
+ // user has set the pref.
+ if (markReadOnADelay && winType == "mail:3pane") {
+ // 3-pane window
+ ClearPendingReadTimer();
+ let markReadDelayTime = Services.prefs.getIntPref(
+ "mailnews.mark_message_read.delay.interval"
+ );
+ if (markReadDelayTime == 0) {
+ MarkMessageAsRead(gMessage);
+ } else {
+ gMarkViewedMessageAsReadTimer = setTimeout(
+ MarkMessageAsRead,
+ markReadDelayTime * 1000,
+ gMessage
+ );
+ }
+ } else {
+ // standalone msg window
+ MarkMessageAsRead(gMessage);
+ }
+ }
+}
+
+/**
+ * This function handles all mdn response generation (ie, imap and pop).
+ * For pop the msg uid can be 0 (ie, 1st msg in a local folder) so no
+ * need to check uid here. No one seems to set mimeHeaders to null so
+ * no need to check it either.
+ */
+function HandleMDNResponse(aUrl) {
+ if (!aUrl) {
+ return;
+ }
+
+ var msgFolder = aUrl.folder;
+ if (
+ !msgFolder ||
+ !gMessage ||
+ gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)
+ ) {
+ return;
+ }
+
+ // if the message is marked as junk, do NOT attempt to process a return receipt
+ // in order to better protect the user
+ if (SelectedMessagesAreJunk()) {
+ return;
+ }
+
+ var mimeHdr;
+
+ try {
+ mimeHdr = aUrl.mimeHeaders;
+ } catch (ex) {
+ return;
+ }
+
+ // If we didn't get the message id when we downloaded the message header,
+ // we cons up an md5: message id. If we've done that, we'll try to extract
+ // the message id out of the mime headers for the whole message.
+ let msgId = gMessage.messageId;
+ if (msgId.startsWith("md5:")) {
+ var mimeMsgId = mimeHdr.extractHeader("Message-Id", false);
+ if (mimeMsgId) {
+ gMessage.messageId = mimeMsgId;
+ }
+ }
+
+ // After a msg is downloaded it's already marked READ at this point so we must check if
+ // the msg has a "Disposition-Notification-To" header and no MDN report has been sent yet.
+ if (gMessage.flags & Ci.nsMsgMessageFlags.MDNReportSent) {
+ return;
+ }
+
+ var DNTHeader = mimeHdr.extractHeader("Disposition-Notification-To", false);
+ var oldDNTHeader = mimeHdr.extractHeader("Return-Receipt-To", false);
+ if (!DNTHeader && !oldDNTHeader) {
+ return;
+ }
+
+ // Everything looks good so far, let's generate the MDN response.
+ var mdnGenerator = Cc[
+ "@mozilla.org/messenger-mdn/generator;1"
+ ].createInstance(Ci.nsIMsgMdnGenerator);
+ const MDN_DISPOSE_TYPE_DISPLAYED = 0;
+ let askUser = mdnGenerator.process(
+ MDN_DISPOSE_TYPE_DISPLAYED,
+ top.msgWindow,
+ msgFolder,
+ gMessage.messageKey,
+ mimeHdr,
+ false
+ );
+ if (askUser) {
+ gMessageNotificationBar.setMDNMsg(mdnGenerator, gMessage, mimeHdr);
+ }
+}
+
+function SendMDNResponse() {
+ gMessageNotificationBar.mdnGenerator.userAgreed();
+}
+
+function IgnoreMDNResponse() {
+ gMessageNotificationBar.mdnGenerator.userDeclined();
+}
+
+// An object to help collecting reading statistics of secure emails.
+var gSecureMsgProbe = {};
+
+/**
+ * Update gSecureMsgProbe and report to telemetry if necessary.
+ */
+function reportMsgRead({ isNewRead = false, key = null }) {
+ if (isNewRead) {
+ gSecureMsgProbe.isNewRead = true;
+ }
+ if (key) {
+ gSecureMsgProbe.key = key;
+ }
+ if (gSecureMsgProbe.key && gSecureMsgProbe.isNewRead) {
+ Services.telemetry.keyedScalarAdd(
+ "tb.mails.read_secure",
+ gSecureMsgProbe.key,
+ 1
+ );
+ }
+}
+
+window.addEventListener("secureMsgLoaded", event => {
+ reportMsgRead({ key: event.detail.key });
+});
+
+/**
+ * Roving tab navigation for the header buttons.
+ */
+var headerToolbarNavigation = {
+ /**
+ * Get all currently visible buttons of the message header toolbar.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get headerButtons() {
+ return this.headerToolbar.querySelectorAll(
+ `toolbarbutton:not([hidden="true"],[is="toolbarbutton-menu-button"]),toolbaritem[id="hdrSmartReplyButton"]>toolbarbutton:not([hidden="true"])>dropmarker, button:not([hidden])`
+ );
+ },
+
+ init() {
+ this.headerToolbar = document.getElementById("header-view-toolbar");
+ this.headerToolbar.addEventListener("keypress", event => {
+ this.triggerMessageHeaderRovingTab(event);
+ });
+ },
+
+ /**
+ * Update the `tabindex` attribute of the currently visible buttons.
+ */
+ updateRovingTab() {
+ for (let button of this.headerButtons) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ // We use `setAttribute` to guarantee compatibility with XUL toolbarbuttons.
+ this.headerButtons[0].setAttribute("tabindex", "0");
+ },
+
+ /**
+ * Handles the keypress event on the message header toolbar.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerMessageHeaderRovingTab(event) {
+ // Expected keyboard actions are Left, Right, Home, End, Space, and Enter.
+ if (
+ !["ArrowRight", "ArrowLeft", "Home", "End", " ", "Enter"].includes(
+ event.key
+ )
+ ) {
+ return;
+ }
+
+ const headerButtons = [...this.headerButtons];
+ let focusableButton = headerButtons.find(b => b.tabIndex != -1);
+ let elementIndex = headerButtons.indexOf(focusableButton);
+
+ // TODO: Remove once the buttons are updated to not be XUL
+ // NOTE: Normally a button click handler would cover Enter and Space key
+ // events. However, we need to prevent the default behavior and explicitly
+ // trigger the button click because the XUL toolbarbuttons do not work when
+ // the Enter key is pressed. They do work when the Space key is pressed.
+ // However, if the toolbarbutton is a dropdown menu, the Space key
+ // does not open the menu.
+ if (
+ event.key == "Enter" ||
+ (event.key == " " && event.target.hasAttribute("type"))
+ ) {
+ if (
+ event.target.getAttribute("class") ==
+ "toolbarbutton-menubutton-dropmarker"
+ ) {
+ event.preventDefault();
+ event.target.parentNode
+ .querySelector("menupopup")
+ .openPopup(event.target.parentNode, "after_end", {
+ triggerEvent: event,
+ });
+ } else {
+ event.preventDefault();
+ event.target.click();
+ return;
+ }
+ }
+
+ // Find the adjacent focusable element based on the pressed key.
+ if (
+ (document.dir == "rtl" && event.key == "ArrowLeft") ||
+ (document.dir == "ltr" && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > headerButtons.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (document.dir == "ltr" && event.key == "ArrowLeft") ||
+ (document.dir == "rtl" && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = headerButtons.length - 1;
+ }
+ }
+
+ // Move the focus to a new toolbar button and update the tabindex attribute.
+ let newFocusableButton = headerButtons[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.setAttribute("tabindex", "0");
+ newFocusableButton.focus();
+ }
+ },
+};
diff --git a/comm/mail/base/content/msgSecurityPane.inc.xhtml b/comm/mail/base/content/msgSecurityPane.inc.xhtml
new file mode 100644
index 0000000000..a6a35e4b76
--- /dev/null
+++ b/comm/mail/base/content/msgSecurityPane.inc.xhtml
@@ -0,0 +1,131 @@
+# 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/.
+ <panel id="messageSecurityPanel"
+ type="arrow"
+ orient="vertical"
+ class="cui-widget-panel"
+ position="bottomright topright"
+ tabindex="-1">
+ <vbox class="security-panel-body">
+ <html:header class="message-security-header">
+ <html:h3>&status.label; <html:span id="techLabel"></html:span></html:h3>
+ </html:header>
+
+ <vbox class="message-security-body">
+ <!-- Notification container -->
+ <vbox>
+ <!-- Import OpenPGP key -->
+ <hbox id="openpgpKeyBox" hidden="true"
+ class="inline-notification-container info-container">
+ <hbox class="inline-notification-wrapper align-center">
+ <html:img class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt="" />
+ <description>
+ <html:span data-l10n-id="openpgp-has-sender-key"></html:span>
+ </description>
+ <button id="openpgpImportButton"
+ class="button-focusable"
+ data-l10n-id="openpgp-import-sender-key"
+ oncommand="Enigmail.msg.importAttachedSenderKey();"/>
+ </hbox>
+ </hbox>
+
+ <!-- Missing signature -->
+ <hbox id="signatureKeyBox" hidden="true"
+ class="inline-notification-container info-container">
+ <hbox class="inline-notification-wrapper align-center">
+ <html:img class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt="" />
+ <description data-l10n-id="openpgp-missing-signature-key"/>
+ <button data-l10n-id="openpgp-search-signature-key"
+ class="button-focusable"
+ oncommand="Enigmail.msg.searchSignatureKey();"/>
+ </hbox>
+ </hbox>
+ </vbox>
+
+ <label id="signatureLabel" class="message-security-label"/>
+ <label id="signatureHeader" collapsed="true" />
+ <description id="signatureExplanation"/>
+
+ <vbox id="signatureCert" class="message-security-container"
+ collapsed="true">
+ <hbox>
+ <label id="signedByLabel" class="cert-label">&signer.name;</label>
+ <description id="signedBy" />
+ </hbox>
+ <hbox>
+ <label id="signerEmailLabel"
+ class="cert-label">&email.address;</label>
+ <description id="signerEmail" />
+ </hbox>
+ <hbox>
+ <label id="sigCertIssuedByLabel"
+ class="cert-label">&issuer.name;</label>
+ <description id="sigCertIssuedBy" />
+ </hbox>
+ <hbox pack="end">
+ <button id="signatureCertView" label="&signatureCert.label;"
+ class="button-focusable"
+ oncommand="viewSignatureCert()" />
+ </hbox>
+ </vbox>
+
+ <hbox id="signatureKey" class="message-security-container"
+ collapsed="true" align="center">
+ <label id="signatureKeyId" flex="1" context="simpleCopyPopup"/>
+ <button id="viewSignatureKey" data-l10n-id="openpgp-view-signer-key"
+ class="button-focusable"
+ oncommand="viewSignatureKey()" collapsed="true"/>
+ </hbox>
+
+ <label id="encryptionLabel" class="message-security-label"/>
+ <label id="encryptionHeader" collapsed="true" />
+ <description id="encryptionExplanation"/>
+
+ <vbox id="encryptionCert" class="message-security-container"
+ collapsed="true">
+ <hbox>
+ <label id="encryptedForLabel"
+ class="cert-label">&recipient.name;</label>
+ <description id="encryptedFor" />
+ </hbox>
+ <hbox>
+ <label id="recipientEmailLabel"
+ class="cert-label">&email.address;</label>
+ <description id="recipientEmail" />
+ </hbox>
+ <hbox>
+ <label id="encCertIssuedByLabel"
+ class="cert-label">&issuer.name;</label>
+ <description id="encCertIssuedBy" />
+ </hbox>
+ <hbox pack="end">
+ <button id="encryptionCertView" label="&encryptionCert.label;"
+ class="button-focusable"
+ oncommand="viewEncryptionCert()" />
+ </hbox>
+ </vbox>
+
+ <vbox id="encryptionKey" class="message-security-container"
+ collapsed="true">
+ <label id="encryptionKeyId" context="simpleCopyPopup"/>
+ <hbox pack="end">
+ <button id="viewEncryptionKey"
+ data-l10n-id="openpgp-view-your-encryption-key"
+ class="button-focusable"
+ oncommand="viewEncryptionKey()"
+ collapsed="true"/>
+ </hbox>
+ </vbox>
+
+ <vbox id="otherEncryptionKeys" collapsed="true">
+ <label id="otherLabel" class="message-security-label none"/>
+ <vbox id="otherEncryptionKeysList"/>
+ </vbox>
+ </vbox>
+ </vbox>
+ </panel>
diff --git a/comm/mail/base/content/msgSecurityPane.js b/comm/mail/base/content/msgSecurityPane.js
new file mode 100644
index 0000000000..e8887363ce
--- /dev/null
+++ b/comm/mail/base/content/msgSecurityPane.js
@@ -0,0 +1,111 @@
+/* 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 related to the msgSecurityPane.inc.xhtml file, used in the message
+ * header to display S/MIME and OpenPGP encryption and signature info.
+ */
+
+/* import-globals-from ../../../mailnews/extensions/smime/msgReadSMIMEOverlay.js */
+/* import-globals-from ../../extensions/openpgp/content/ui/enigmailMessengerOverlay.js */
+/* import-globals-from aboutMessage.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+});
+
+var gSigKeyId = null;
+var gEncKeyId = null;
+
+/**
+ * Reveal message security popup panel with updated OpenPGP or S/MIME info.
+ */
+function showMessageReadSecurityInfo() {
+ // Interrupt if no message is selected or no encryption technology was used.
+ if (!gMessage || document.getElementById("cryptoBox").hidden) {
+ return;
+ }
+
+ // OpenPGP.
+ if (document.getElementById("cryptoBox").getAttribute("tech") === "OpenPGP") {
+ Enigmail.msg.loadOpenPgpMessageSecurityInfo();
+ showMessageSecurityPanel();
+ return;
+ }
+
+ // S/MIME.
+ if (gSignatureStatus === Ci.nsICMSMessageErrors.VERIFY_NOT_YET_ATTEMPTED) {
+ showImapSignatureUnknown();
+ return;
+ }
+
+ loadSmimeMessageSecurityInfo();
+ showMessageSecurityPanel();
+}
+
+/**
+ * Reveal the popup panel with the populated message security info.
+ */
+function showMessageSecurityPanel() {
+ document
+ .getElementById("messageSecurityPanel")
+ .openPopup(
+ document.getElementById("encryptionTechBtn"),
+ "bottomright topright",
+ 0,
+ 0,
+ false
+ );
+}
+
+/**
+ * Reset all values and clear the text of the message security popup panel.
+ */
+function onMessageSecurityPopupHidden() {
+ // Clear the variables for signature and encryption.
+ gSigKeyId = null;
+ gEncKeyId = null;
+
+ // Hide the UI elements.
+ document.getElementById("signatureHeader").collapsed = true;
+ document.getElementById("encryptionHeader").collapsed = true;
+ document.getElementById("signatureCert").collapsed = true;
+ document.getElementById("signatureKey").collapsed = true;
+ document.getElementById("viewSignatureKey").collapsed = true;
+ document.getElementById("encryptionKey").collapsed = true;
+ document.getElementById("encryptionCert").collapsed = true;
+ document.getElementById("viewEncryptionKey").collapsed = true;
+ document.getElementById("otherEncryptionKeys").collapsed = true;
+
+ let keyList = document.getElementById("otherEncryptionKeysList");
+ // Clear any possible existing key previously appended to the DOM.
+ for (let node of keyList.children) {
+ keyList.removeChild(node);
+ }
+}
+
+async function viewSignatureKey() {
+ if (!gSigKeyId) {
+ return;
+ }
+
+ // If the signature acceptance was edited, reload the current message.
+ if (await EnigmailWindows.openKeyDetails(window, gSigKeyId, false)) {
+ ReloadMessage();
+ }
+}
+
+function viewEncryptionKey() {
+ if (!gEncKeyId) {
+ return;
+ }
+
+ EnigmailWindows.openKeyDetails(window, gEncKeyId, false);
+}
diff --git a/comm/mail/base/content/msgViewNavigation.js b/comm/mail/base/content/msgViewNavigation.js
new file mode 100644
index 0000000000..6ff860a717
--- /dev/null
+++ b/comm/mail/base/content/msgViewNavigation.js
@@ -0,0 +1,207 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 contains the js functions necessary to implement view navigation within the 3 pane. */
+
+/* globals DBViewWrapper, dbViewWrapperListener, TreeSelection */
+/* globals gDBView: true, gFolder: true, gViewWrapper: true */ // mailCommon.js
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "FolderUtils",
+ "resource:///modules/FolderUtils.jsm"
+);
+
+function GetSubFoldersInFolderPaneOrder(folder) {
+ function compareFolderSortKey(folder1, folder2) {
+ return folder1.compareSortKeys(folder2);
+ }
+ // sort the subfolders
+ return folder.subFolders.sort(compareFolderSortKey);
+}
+
+function FindNextChildFolder(aParent, aAfter) {
+ // Search the child folders of aParent for unread messages
+ // but in the case that we are working up from the current folder
+ // we need to skip up to and including the current folder
+ // we skip the current folder in case a mail view is hiding unread messages
+ if (aParent.getNumUnread(true) > 0) {
+ var subFolders = GetSubFoldersInFolderPaneOrder(aParent);
+ var i = 0;
+ var folder = null;
+
+ // Skip folders until after the specified child
+ while (folder != aAfter) {
+ folder = subFolders[i++];
+ }
+
+ let ignoreFlags =
+ Ci.nsMsgFolderFlags.Trash |
+ Ci.nsMsgFolderFlags.SentMail |
+ Ci.nsMsgFolderFlags.Drafts |
+ Ci.nsMsgFolderFlags.Queue |
+ Ci.nsMsgFolderFlags.Templates |
+ Ci.nsMsgFolderFlags.Junk;
+ while (i < subFolders.length) {
+ folder = subFolders[i++];
+ // If there is unread mail in the trash, sent, drafts, unsent messages
+ // templates or junk special folder,
+ // we ignore it when doing cross folder "next" navigation.
+ if (!folder.isSpecialFolder(ignoreFlags, true)) {
+ if (folder.getNumUnread(false) > 0) {
+ return folder;
+ }
+
+ folder = FindNextChildFolder(folder, null);
+ if (folder) {
+ return folder;
+ }
+ }
+ }
+ }
+
+ return null;
+}
+
+function FindNextFolder() {
+ // look for the next folder, this will only look on the current account
+ // and below us, in the folder pane
+ // note use of gDBView restricts this function to message folders
+ // otherwise you could go next unread from a server
+ var folder = FindNextChildFolder(gDBView.msgFolder, null);
+ if (folder) {
+ return folder;
+ }
+
+ // didn't find folder in children
+ // go up to the parent, and start at the folder after the current one
+ // unless we are at a server, in which case bail out.
+ folder = gDBView.msgFolder;
+ while (!folder.isServer) {
+ var parent = folder.parent;
+ folder = FindNextChildFolder(parent, folder);
+ if (folder) {
+ return folder;
+ }
+
+ // none at this level after the current folder. go up.
+ folder = parent;
+ }
+
+ // nothing in the current account, start with the next account (below)
+ // and try until we hit the bottom of the folder pane
+
+ // start at the account after the current account
+ var rootFolders = GetRootFoldersInFolderPaneOrder();
+ for (var i = 0; i < rootFolders.length; i++) {
+ if (rootFolders[i].URI == gDBView.msgFolder.server.serverURI) {
+ break;
+ }
+ }
+
+ for (var j = i + 1; j < rootFolders.length; j++) {
+ folder = FindNextChildFolder(rootFolders[j], null);
+ if (folder) {
+ return folder;
+ }
+ }
+
+ // if nothing from the current account down to the bottom
+ // (of the folder pane), start again at the top.
+ for (j = 0; j <= i; j++) {
+ folder = FindNextChildFolder(rootFolders[j], null);
+ if (folder) {
+ return folder;
+ }
+ }
+ return null;
+}
+
+function GetRootFoldersInFolderPaneOrder() {
+ let accounts = FolderUtils.allAccountsSorted(false);
+
+ let serversMsgFolders = [];
+ for (let account of accounts) {
+ serversMsgFolders.push(account.incomingServer.rootMsgFolder);
+ }
+
+ return serversMsgFolders;
+}
+
+/**
+ * Handle switching the folder if required for the given kind of navigation.
+ * Only used in about:3pane.
+ *
+ * @param {nsMsgNavigationType} type - The type of navigation.
+ * @returns {boolean} If the folder was changed for the navigation.
+ */
+function CrossFolderNavigation(type) {
+ // do cross folder navigation for next unread message/thread and message history
+ if (
+ type != Ci.nsMsgNavigationType.nextUnreadMessage &&
+ type != Ci.nsMsgNavigationType.nextUnreadThread
+ ) {
+ return false;
+ }
+
+ let nextMode = Services.prefs.getIntPref("mailnews.nav_crosses_folders");
+ // 0: "next" goes to the next folder, without prompting
+ // 1: "next" goes to the next folder, and prompts (the default)
+ // 2: "next" does nothing when there are no unread messages
+
+ // not crossing folders, don't find next
+ if (nextMode == 2) {
+ return false;
+ }
+
+ let folder = FindNextFolder();
+ if (!folder || gDBView.msgFolder.URI == folder.URI) {
+ return false;
+ }
+
+ if (nextMode == 1) {
+ let messengerBundle =
+ window.messengerBundle ||
+ Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let promptText = messengerBundle.formatStringFromName("advanceNextPrompt", [
+ folder.name,
+ ]);
+ if (
+ Services.prompt.confirmEx(
+ window,
+ null,
+ promptText,
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ )
+ ) {
+ return false;
+ }
+ }
+
+ if (window.threadPane) {
+ // In about:3pane.
+ window.threadPane.forgetSelection(folder.URI);
+ window.displayFolder(folder.URI);
+ } else {
+ // In standalone about:message. Do just enough to call
+ // `commandController._navigate` again.
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+ gViewWrapper._viewFlags = Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ gViewWrapper.open(folder);
+ gDBView = gViewWrapper.dbView;
+ let selection = (gDBView.selection = new TreeSelection());
+ selection.view = gDBView;
+ // We're now in a bit of a weird state until `displayMessage` is called,
+ // but being here means we have everything we need for that to happen.
+ }
+ return true;
+}
diff --git a/comm/mail/base/content/multimessageview.js b/comm/mail/base/content/multimessageview.js
new file mode 100644
index 0000000000..9b4c593fde
--- /dev/null
+++ b/comm/mail/base/content/multimessageview.js
@@ -0,0 +1,844 @@
+/* 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"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ DisplayNameUtils: "resource:///modules/DisplayNameUtils.jsm",
+ Gloda: "resource:///modules/gloda/Gloda.jsm",
+ makeFriendlyDateAgo: "resource:///modules/TemplateUtils.jsm",
+ MessageArchiver: "resource:///modules/MessageArchiver.jsm",
+ mimeMsgToContentSnippetAndMeta: "resource:///modules/gloda/GlodaContent.jsm",
+ MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm",
+ PluralStringFormatter: "resource:///modules/TemplateUtils.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+var gMessenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+// Set up our string formatter for localizing strings.
+XPCOMUtils.defineLazyGetter(this, "formatString", function () {
+ let formatter = new PluralStringFormatter(
+ "chrome://messenger/locale/multimessageview.properties"
+ );
+ return function (...args) {
+ return formatter.get(...args);
+ };
+});
+
+/**
+ * A LimitIterator is a utility class that allows limiting the maximum number
+ * of items to iterate over.
+ *
+ * @param aArray The array to iterate over (can be anything with a .length
+ * property and a subscript operator.
+ * @param aMaxLength The maximum number of items to iterate over.
+ */
+function LimitIterator(aArray, aMaxLength) {
+ this._array = aArray;
+ this._maxLength = aMaxLength;
+}
+
+LimitIterator.prototype = {
+ /**
+ * Returns true if the iterator won't actually iterate over everything in the
+ * array.
+ */
+ get limited() {
+ return this._array.length > this._maxLength;
+ },
+
+ /**
+ * Returns the number of elements that will actually be iterated over.
+ */
+ get length() {
+ return Math.min(this._array.length, this._maxLength);
+ },
+
+ /**
+ * Returns the real number of elements in the array.
+ */
+ get trueLength() {
+ return this._array.length;
+ },
+};
+
+var JS_HAS_SYMBOLS = typeof Symbol === "function";
+var ITERATOR_SYMBOL = JS_HAS_SYMBOLS ? Symbol.iterator : "@@iterator";
+
+/**
+ * Iterate over the array until we hit the end or the maximum length,
+ * whichever comes first.
+ */
+LimitIterator.prototype[ITERATOR_SYMBOL] = function* () {
+ let length = this.length;
+ for (let i = 0; i < length; i++) {
+ yield this._array[i];
+ }
+};
+
+/**
+ * The MultiMessageSummary class is responsible for populating the message pane
+ * with a reasonable summary of a set of messages.
+ */
+function MultiMessageSummary() {
+ this._summarizers = {};
+}
+
+MultiMessageSummary.prototype = {
+ /**
+ * The maximum number of messages to examine in any way.
+ */
+ kMaxMessages: 10000,
+
+ /**
+ * Register a summarizer for a particular type of message summary.
+ *
+ * @param aSummarizer The summarizer object.
+ */
+ registerSummarizer(aSummarizer) {
+ this._summarizers[aSummarizer.name] = aSummarizer;
+ aSummarizer.onregistered(this);
+ },
+
+ /**
+ * Store a mapping from a message header to the summary node in the DOM. We
+ * use this to update things when Gloda tells us to.
+ *
+ * @param aMsgHdr The nsIMsgDBHdr.
+ * @param aNode The related DOM node.
+ */
+ mapMsgToNode(aMsgHdr, aNode) {
+ let key = aMsgHdr.messageKey + aMsgHdr.folder.URI;
+ this._msgNodes[key] = aNode;
+ },
+
+ /**
+ * Clear all the content from the summary.
+ */
+ clear() {
+ this._selectCallback = null;
+ this._listener = null;
+ this._glodaQuery = null;
+ this._msgNodes = {};
+
+ // Clear the messages list.
+ let messageList = document.getElementById("message_list");
+ while (messageList.hasChildNodes()) {
+ messageList.lastChild.remove();
+ }
+
+ // Clear the notice.
+ document.getElementById("notice").textContent = "";
+ },
+
+ /**
+ * Fill in the summary pane describing the selected messages.
+ *
+ * @param aType The type of summary to perform (e.g. 'multimessage').
+ * @param aMessages The messages to summarize.
+ * @param aDBView The current DB view.
+ * @param aSelectCallback Called with an array of nsIMsgHdrs when one of
+ * a summarized message is clicked on.
+ * @param [aListener] A listener to be notified when the summary starts and
+ * finishes.
+ */
+ summarize(aType, aMessages, aDBView, aSelectCallback, aListener) {
+ this.clear();
+
+ this._selectCallback = aSelectCallback;
+ this._listener = aListener;
+ if (this._listener) {
+ this._listener.onLoadStarted();
+ }
+
+ // Enable/disable the archive button as appropriate.
+ let archiveBtn = document.getElementById("hdrArchiveButton");
+ archiveBtn.hidden = !MessageArchiver.canArchive(aMessages);
+
+ // Set archive and delete button listeners.
+ let topChromeWindow = window.browsingContext.topChromeWindow;
+ archiveBtn.onclick = event => {
+ if (event.button == 0) {
+ topChromeWindow.goDoCommand("cmd_archive");
+ }
+ };
+ document.getElementById("hdrTrashButton").onclick = event => {
+ if (event.button == 0) {
+ topChromeWindow.goDoCommand("cmd_delete");
+ }
+ };
+
+ headerToolbarNavigation.init();
+
+ let summarizer = this._summarizers[aType];
+ if (!summarizer) {
+ throw new Error('Unknown summarizer "' + aType + '"');
+ }
+
+ let messages = new LimitIterator(aMessages, this.kMaxMessages);
+ let summarizedMessages = summarizer.summarize(messages, aDBView);
+
+ // Stash somewhere so it doesn't get GC'ed.
+ this._glodaQuery = Gloda.getMessageCollectionForHeaders(
+ summarizedMessages,
+ this
+ );
+ this._computeSize(messages);
+ },
+
+ /**
+ * Set the heading for the summary.
+ *
+ * @param title The title for the heading.
+ * @param subtitle A smaller subtitle for the heading.
+ */
+ setHeading(title, subtitle) {
+ let titleNode = document.getElementById("summary_title");
+ let subtitleNode = document.getElementById("summary_subtitle");
+ titleNode.textContent = title || "";
+ subtitleNode.textContent = subtitle || "";
+ },
+
+ /**
+ * Create a summary item for a message or thread.
+ *
+ * @param aMsgOrThread An nsIMsgDBHdr or an array thereof
+ * @param [aOptions] An optional object to customize the output:
+ * showSubject: true if the subject of the message
+ * should be shown; defaults to false
+ * snippetLength: the length in bytes of the message
+ * snippet; defaults to undefined (let Gloda decide)
+ * @returns A DOM node for the summary item.
+ */
+ makeSummaryItem(aMsgOrThread, aOptions) {
+ let message, thread, numUnread, isStarred, tags;
+ if (aMsgOrThread instanceof Ci.nsIMsgDBHdr) {
+ thread = null;
+ message = aMsgOrThread;
+
+ numUnread = message.isRead ? 0 : 1;
+ isStarred = message.isFlagged;
+
+ tags = this._getTagsForMsg(message);
+ } else {
+ thread = aMsgOrThread;
+ message = thread[0];
+
+ numUnread = thread.reduce(function (x, hdr) {
+ return x + (hdr.isRead ? 0 : 1);
+ }, 0);
+ isStarred = thread.some(function (hdr) {
+ return hdr.isFlagged;
+ });
+
+ tags = new Set();
+ for (let message of thread) {
+ for (let tag of this._getTagsForMsg(message)) {
+ tags.add(tag);
+ }
+ }
+ }
+
+ let row = document.createElement("li");
+ row.dataset.messageId = message.messageId;
+ row.classList.toggle("thread", thread && thread.length > 1);
+ row.classList.toggle("unread", numUnread > 0);
+ row.classList.toggle("starred", isStarred);
+
+ row.appendChild(document.createElement("div")).classList.add("star");
+
+ let summary = document.createElement("div");
+ summary.classList.add("item_summary");
+ summary
+ .appendChild(document.createElement("div"))
+ .classList.add("item_header");
+ summary.appendChild(document.createElement("div")).classList.add("snippet");
+ row.appendChild(summary);
+
+ let itemHeaderNode = row.querySelector(".item_header");
+
+ let authorNode = document.createElement("span");
+ authorNode.classList.add("author");
+ authorNode.textContent = DisplayNameUtils.formatDisplayNameList(
+ message.mime2DecodedAuthor,
+ "from"
+ );
+
+ if (aOptions && aOptions.showSubject) {
+ authorNode.classList.add("right");
+ itemHeaderNode.appendChild(authorNode);
+
+ let subjectNode = document.createElement("span");
+ subjectNode.classList.add("subject", "primary_header", "link");
+ subjectNode.textContent =
+ message.mime2DecodedSubject || formatString("noSubject");
+ subjectNode.addEventListener("click", () => this._selectCallback(thread));
+ itemHeaderNode.appendChild(subjectNode);
+
+ if (thread && thread.length > 1) {
+ let numUnreadStr = "";
+ if (numUnread) {
+ numUnreadStr = formatString(
+ "numUnread",
+ [numUnread.toLocaleString()],
+ numUnread
+ );
+ }
+ let countStr =
+ "(" +
+ formatString(
+ "numMessages",
+ [thread.length.toLocaleString()],
+ thread.length
+ ) +
+ numUnreadStr +
+ ")";
+
+ let countNode = document.createElement("span");
+ countNode.classList.add("count");
+ countNode.textContent = countStr;
+ itemHeaderNode.appendChild(countNode);
+ }
+ } else {
+ let dateNode = document.createElement("span");
+ dateNode.classList.add("date", "right");
+ dateNode.textContent = makeFriendlyDateAgo(new Date(message.date / 1000));
+ itemHeaderNode.appendChild(dateNode);
+
+ authorNode.classList.add("primary_header", "link");
+ authorNode.addEventListener("click", () => {
+ this._selectCallback([message]);
+ });
+ itemHeaderNode.appendChild(authorNode);
+ }
+
+ let tagNode = document.createElement("span");
+ tagNode.classList.add("tags");
+ this._addTagNodes(tags, tagNode);
+ itemHeaderNode.appendChild(tagNode);
+
+ let snippetNode = row.querySelector(".snippet");
+ try {
+ const kSnippetLength = aOptions && aOptions.snippetLength;
+ MsgHdrToMimeMessage(
+ message,
+ null,
+ function (aMsgHdr, aMimeMsg) {
+ if (aMimeMsg == null) {
+ // Shouldn't happen, but sometimes does?
+ return;
+ }
+ let [text, meta] = mimeMsgToContentSnippetAndMeta(
+ aMimeMsg,
+ aMsgHdr.folder,
+ kSnippetLength
+ );
+ snippetNode.textContent = text;
+ if (meta.author) {
+ authorNode.textContent = meta.author;
+ }
+ },
+ false,
+ { saneBodySize: true }
+ );
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FAILURE) {
+ // Offline messages generate exceptions, which is unfortunate. When
+ // that's fixed, this code should adapt. XXX
+ snippetNode.textContent = "...";
+ } else {
+ throw e;
+ }
+ }
+ return row;
+ },
+
+ /**
+ * Show an informative notice about the summarized messages (e.g. if we only
+ * summarized some of them).
+ *
+ * @param aNoticeText The text to show in the notice.
+ */
+ showNotice(aNoticeText) {
+ let notice = document.getElementById("notice");
+ notice.textContent = aNoticeText;
+ },
+
+ /**
+ * Given a msgHdr, return a list of tag objects. This function just does the
+ * messy work of understanding how tags are stored in nsIMsgDBHdrs. It would
+ * be a good candidate for a utility library.
+ *
+ * @param aMsgHdr The msgHdr whose tags we want.
+ * @returns An array of nsIMsgTag objects.
+ */
+ _getTagsForMsg(aMsgHdr) {
+ let keywords = new Set(aMsgHdr.getStringProperty("keywords").split(" "));
+ let allTags = MailServices.tags.getAllTags();
+
+ return allTags.filter(function (tag) {
+ return keywords.has(tag.key);
+ });
+ },
+
+ /**
+ * Add a list of tags to a DOM node.
+ *
+ * @param aTags An array (or any iterable) of nsIMsgTag objects.
+ * @param aTagsNode The DOM node to contain the list of tags.
+ */
+ _addTagNodes(aTags, aTagsNode) {
+ // Make sure the tags are sorted in their natural order.
+ let sortedTags = [...aTags];
+ sortedTags.sort(function (a, b) {
+ return a.key.localeCompare(b.key) || a.ordinal.localeCompare(b.ordinal);
+ });
+
+ for (let tag of sortedTags) {
+ let tagNode = document.createElement("span");
+
+ tagNode.className = "tag";
+ let color = MailServices.tags.getColorForKey(tag.key);
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.dataset.tag = tag.tag;
+ tagNode.textContent = tag.tag;
+ aTagsNode.appendChild(tagNode);
+ }
+ },
+
+ /**
+ * Compute the size of the messages in the selection and display it in the
+ * element of id "size".
+ *
+ * @param aMessages A LimitIterator of the messages to calculate the size of.
+ */
+ _computeSize(aMessages) {
+ let numBytes = 0;
+ for (let msgHdr of aMessages) {
+ numBytes += msgHdr.messageSize;
+ // XXX do something about news?
+ }
+
+ let format = aMessages.limited
+ ? "messagesTotalSizeMoreThan"
+ : "messagesTotalSize";
+ document.getElementById("size").textContent = formatString(format, [
+ gMessenger.formatFileSize(numBytes),
+ ]);
+ },
+
+ // These are listeners for the gloda collections.
+ onItemsAdded(aItems) {},
+ onItemsModified(aItems) {
+ this._processItems(aItems);
+ },
+ onItemsRemoved(aItems) {},
+
+ /**
+ * Given a set of items from a gloda collection, process them and update
+ * the display accordingly.
+ *
+ * @param aItems Contents of a gloda collection.
+ */
+ _processItems(aItems) {
+ let knownMessageNodes = new Map();
+
+ for (let glodaMsg of aItems) {
+ // Unread and starred will get set if any of the messages in a collapsed
+ // thread qualify. The trick here is that we may get multiple items
+ // corresponding to the same thread (and hence DOM node), so we need to
+ // detect when we get the first item for a particular DOM node, stash the
+ // preexisting status of that DOM node, an only do transitions if the
+ // items warrant it.
+ let key = glodaMsg.messageKey + glodaMsg.folder.uri;
+ let headerNode = this._msgNodes[key];
+ if (!headerNode) {
+ continue;
+ }
+ if (!knownMessageNodes.has(headerNode)) {
+ knownMessageNodes.set(headerNode, {
+ read: true,
+ starred: false,
+ tags: new Set(),
+ });
+ }
+
+ let flags = knownMessageNodes.get(headerNode);
+
+ // Count as read if *all* the messages are read.
+ flags.read &= glodaMsg.read;
+ // Count as starred if *any* of the messages are starred.
+ flags.starred |= glodaMsg.starred;
+ // Count as tagged with a tag if *any* of the messages have that tag.
+ for (let tag of this._getTagsForMsg(glodaMsg.folderMessage)) {
+ flags.tags.add(tag);
+ }
+ }
+
+ for (let [headerNode, flags] of knownMessageNodes) {
+ headerNode.classList.toggle("unread", !flags.read);
+ headerNode.classList.toggle("starred", flags.starred);
+
+ // Clear out all the tags and start fresh, just to make sure we don't get
+ // out of sync.
+ let tagsNode = headerNode.querySelector(".tags");
+ while (tagsNode.hasChildNodes()) {
+ tagsNode.lastChild.remove();
+ }
+ this._addTagNodes(flags.tags, tagsNode);
+ }
+ },
+
+ onQueryCompleted(aCollection) {
+ // If we need something that's just available from GlodaMessages, this is
+ // where we'll get it initially.
+ if (this._listener) {
+ this._listener.onLoadCompleted();
+ }
+ },
+};
+
+/**
+ * A summarizer to use for a single thread.
+ */
+function ThreadSummarizer() {}
+
+ThreadSummarizer.prototype = {
+ /**
+ * The maximum number of messages to summarize.
+ */
+ kMaxSummarizedMessages: 100,
+
+ /**
+ * The length of message snippets to fetch from Gloda.
+ */
+ kSnippetLength: 300,
+
+ /**
+ * Returns a canonical name for this summarizer.
+ */
+ get name() {
+ return "thread";
+ },
+
+ /**
+ * A function to be called once the summarizer has been registered with the
+ * main summary object.
+ *
+ * @param aContext The MultiMessageSummary object holding this summarizer.
+ */
+ onregistered(aContext) {
+ this.context = aContext;
+ },
+
+ /**
+ * Summarize a list of messages.
+ *
+ * @param aMessages A LimitIterator of the messages to summarize.
+ * @returns An array of the messages actually summarized.
+ */
+ summarize(aMessages, aDBView) {
+ let messageList = document.getElementById("message_list");
+
+ // Remove all ignored messages from summarization.
+ let summarizedMessages = [];
+ for (let message of aMessages) {
+ if (!message.isKilled) {
+ summarizedMessages.push(message);
+ }
+ }
+ let ignoredCount = aMessages.trueLength - summarizedMessages.length;
+
+ // Summarize the selected messages.
+ let subject = null;
+ let maxCountExceeded = false;
+ for (let [i, msgHdr] of summarizedMessages.entries()) {
+ if (i == this.kMaxSummarizedMessages) {
+ summarizedMessages.length = i;
+ maxCountExceeded = true;
+ break;
+ }
+
+ if (subject == null) {
+ subject = msgHdr.mime2DecodedSubject;
+ }
+
+ let msgNode = this.context.makeSummaryItem(msgHdr, {
+ snippetLength: this.kSnippetLength,
+ });
+ messageList.appendChild(msgNode);
+
+ this.context.mapMsgToNode(msgHdr, msgNode);
+ }
+
+ // Set the heading based on the subject and number of messages.
+ let countInfo = formatString(
+ "numMessages",
+ [aMessages.length.toLocaleString()],
+ aMessages.length
+ );
+ if (ignoredCount != 0) {
+ let format = aMessages.limited ? "atLeastNumIgnored" : "numIgnored";
+ countInfo += formatString(
+ format,
+ [ignoredCount.toLocaleString()],
+ ignoredCount
+ );
+ }
+
+ this.context.setHeading(subject || formatString("noSubject"), countInfo);
+
+ if (maxCountExceeded) {
+ this.context.showNotice(
+ formatString("maxCountExceeded", [
+ aMessages.trueLength.toLocaleString(),
+ this.kMaxSummarizedMessages.toLocaleString(),
+ ])
+ );
+ }
+ return summarizedMessages;
+ },
+};
+
+/**
+ * A summarizer to use when multiple threads are selected.
+ */
+function MultipleSelectionSummarizer() {}
+
+MultipleSelectionSummarizer.prototype = {
+ /**
+ * The maximum number of threads to summarize.
+ */
+ kMaxSummarizedThreads: 100,
+
+ /**
+ * The length of message snippets to fetch from Gloda.
+ */
+ kSnippetLength: 300,
+
+ /**
+ * Returns a canonical name for this summarizer.
+ */
+ get name() {
+ return "multipleselection";
+ },
+
+ /**
+ * A function to be called once the summarizer has been registered with the
+ * main summary object.
+ *
+ * @param aContext The MultiMessageSummary object holding this summarizer.
+ */
+ onregistered(aContext) {
+ this.context = aContext;
+ },
+
+ /**
+ * Summarize a list of messages.
+ *
+ * @param aMessages The messages to summarize.
+ */
+ summarize(aMessages, aDBView) {
+ let messageList = document.getElementById("message_list");
+
+ let threads = this._buildThreads(aMessages, aDBView);
+ let threadsCount = threads.length;
+
+ // Set the heading based on the number of messages & threads.
+ let format = aMessages.limited
+ ? "atLeastNumConversations"
+ : "numConversations";
+ this.context.setHeading(
+ formatString(format, [threads.length.toLocaleString()], threads.length)
+ );
+
+ // Summarize the selected messages by thread.
+ let maxCountExceeded = false;
+ for (let [i, msgs] of threads.entries()) {
+ if (i == this.kMaxSummarizedThreads) {
+ threads.length = i;
+ maxCountExceeded = true;
+ break;
+ }
+
+ let msgNode = this.context.makeSummaryItem(msgs, {
+ showSubject: true,
+ snippetLength: this.kSnippetLength,
+ });
+ messageList.appendChild(msgNode);
+
+ for (let msgHdr of msgs) {
+ this.context.mapMsgToNode(msgHdr, msgNode);
+ }
+ }
+
+ if (maxCountExceeded) {
+ this.context.showNotice(
+ formatString("maxThreadCountExceeded", [
+ threadsCount.toLocaleString(),
+ this.kMaxSummarizedThreads.toLocaleString(),
+ ])
+ );
+
+ // Return only the messages for the threads we're actually showing. We
+ // need to collapse our array-of-arrays into a flat array.
+ return threads.reduce(function (accum, curr) {
+ accum.push(...curr);
+ return accum;
+ }, []);
+ }
+
+ // Return everything, since we're showing all the threads. Don't forget to
+ // turn it into an array, though!
+ return [...aMessages];
+ },
+
+ /**
+ * Group all the messages to be summarized into threads.
+ *
+ * @param aMessages The messages to group.
+ * @returns An array of arrays of messages, grouped by thread.
+ */
+ _buildThreads(aMessages, aDBView) {
+ // First, we group the messages in threads and count the threads.
+ let threads = [];
+ let threadMap = {};
+ for (let msgHdr of aMessages) {
+ let viewThreadId = aDBView.getThreadContainingMsgHdr(msgHdr).threadKey;
+ if (!(viewThreadId in threadMap)) {
+ threadMap[viewThreadId] = threads.length;
+ threads.push([msgHdr]);
+ } else {
+ threads[threadMap[viewThreadId]].push(msgHdr);
+ }
+ }
+ return threads;
+ },
+};
+
+var gMessageSummary = new MultiMessageSummary();
+
+gMessageSummary.registerSummarizer(new ThreadSummarizer());
+gMessageSummary.registerSummarizer(new MultipleSelectionSummarizer());
+
+/**
+ * Roving tab navigation for the header buttons.
+ */
+const headerToolbarNavigation = {
+ /**
+ * If the roving tab has already been loaded.
+ *
+ * @type {boolean}
+ */
+ isLoaded: false,
+ /**
+ * Get all currently visible buttons of the message header toolbar.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get headerButtons() {
+ return this.headerToolbar.querySelectorAll(
+ `toolbarbutton:not([hidden="true"])`
+ );
+ },
+
+ init() {
+ // Bail out if we already initialized this.
+ if (this.isLoaded) {
+ return;
+ }
+ this.headerToolbar = document.getElementById("header-view-toolbar");
+ this.headerToolbar.addEventListener("keypress", event => {
+ this.triggerMessageHeaderRovingTab(event);
+ });
+ this.updateRovingTab();
+ this.isLoaded = true;
+ },
+
+ /**
+ * Update the `tabindex` attribute of the currently visible buttons.
+ */
+ updateRovingTab() {
+ for (const button of this.headerButtons) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ // We use `setAttribute` to guarantee compatibility with XUL toolbarbuttons.
+ this.headerButtons[0].setAttribute("tabindex", "0");
+ },
+
+ /**
+ * Handles the keypress event on the message header toolbar.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerMessageHeaderRovingTab(event) {
+ // Expected keyboard actions are Left, Right, Home, End, Space, and Enter.
+ if (!["ArrowRight", "ArrowLeft", " ", "Enter"].includes(event.key)) {
+ return;
+ }
+
+ const headerButtons = [...this.headerButtons];
+ const focusableButton = headerButtons.find(b => b.tabIndex != -1);
+ let elementIndex = headerButtons.indexOf(focusableButton);
+
+ // TODO: Remove once the buttons are updated to not be XUL
+ // NOTE: Normally a button click handler would cover Enter and Space key
+ // events. However, we need to prevent the default behavior and explicitly
+ // trigger the button click because the XUL toolbarbuttons do not work when
+ // the Enter key is pressed. They do work when the Space key is pressed.
+ // However, if the toolbarbutton is a dropdown menu, the Space key
+ // does not open the menu.
+ if (
+ event.key == "Enter" ||
+ (event.key == " " && event.target.hasAttribute("type"))
+ ) {
+ event.preventDefault();
+ event.target.click();
+ return;
+ }
+
+ // Find the adjacent focusable element based on the pressed key.
+ const isRTL = document.dir == "rtl";
+ if (
+ (isRTL && event.key == "ArrowLeft") ||
+ (!isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > headerButtons.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (!isRTL && event.key == "ArrowLeft") ||
+ (isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = headerButtons.length - 1;
+ }
+ }
+
+ // Move the focus to a new toolbar button and update the tabindex attribute.
+ const newFocusableButton = headerButtons[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.setAttribute("tabindex", "0");
+ newFocusableButton.focus();
+ }
+ },
+};
diff --git a/comm/mail/base/content/multimessageview.xhtml b/comm/mail/base/content/multimessageview.xhtml
new file mode 100644
index 0000000000..17241ce537
--- /dev/null
+++ b/comm/mail/base/content/multimessageview.xhtml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!-- 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/. -->
+
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd" [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % startDTD SYSTEM "chrome://messenger/locale/multimessageview.dtd">
+%startDTD; ]>
+
+<html
+ 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"
+>
+ <head>
+ <link
+ rel="stylesheet"
+ media="screen"
+ type="text/css"
+ href="chrome://messenger/skin/messenger.css"
+ />
+ <link
+ rel="stylesheet"
+ media="screen"
+ type="text/css"
+ href="chrome://messenger/skin/primaryToolbar.css"
+ />
+ <link
+ rel="stylesheet"
+ media="screen"
+ type="text/css"
+ href="chrome://messenger/skin/messageHeader.css"
+ />
+ <link
+ rel="stylesheet"
+ media="screen, print"
+ type="text/css"
+ href="chrome://messenger/skin/multimessageview.css"
+ />
+ <link rel="localization" href="messenger/multimessageview.ftl" />
+ <title data-l10n-id="multi-message-window-title"></title>
+ <script src="chrome://messenger/content/multimessageview.js" />
+ </head>
+ <body>
+ <div id="headingWrapper">
+ <vbox
+ id="header-view-toolbox"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <hbox id="header-view-toolbar" class="header-buttons-container">
+ <toolbarbutton
+ id="hdrArchiveButton"
+ class="toolbarbutton-1 message-header-view-button hdrArchiveButton"
+ data-l10n-id="multi-message-archive-button"
+ />
+ <toolbarbutton
+ id="hdrTrashButton"
+ class="toolbarbutton-1 message-header-view-button hdrTrashButton"
+ data-l10n-id="multi-message-delete-button"
+ />
+ </hbox>
+ </vbox>
+ <h1 id="heading">
+ <span id="summary_title" data-l10n-id="selected-messages-label"></span
+ >&#x200B;
+ <span id="summary_subtitle" />
+ </h1>
+ </div>
+ <div id="content">
+ <ul id="message_list" />
+ <div id="footer">
+ <span class="info" id="size" /> <span class="info" id="notice" />
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/base/content/newTagDialog.js b/comm/mail/base/content/newTagDialog.js
new file mode 100644
index 0000000000..01af8892cc
--- /dev/null
+++ b/comm/mail/base/content/newTagDialog.js
@@ -0,0 +1,108 @@
+/* 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 dialog;
+
+/**
+ * Pass in keyToEdit as a window argument to turn this dialog into an edit
+ * tag dialog.
+ */
+function onLoad() {
+ let windowArgs = window.arguments[0];
+
+ dialog = {};
+
+ dialog.OKButton = document.querySelector("dialog").getButton("accept");
+ dialog.nameField = document.getElementById("name");
+ dialog.nameField.focus();
+
+ // call this when OK is pressed
+ dialog.okCallback = windowArgs.okCallback;
+ if (windowArgs.keyToEdit) {
+ initializeForEditing(windowArgs.keyToEdit);
+ document.addEventListener("dialogaccept", onOKEditTag);
+ } else {
+ document.addEventListener("dialogaccept", onOKNewTag);
+ }
+
+ doEnabling();
+}
+
+/**
+ * Turn the new tag dialog into an edit existing tag dialog
+ */
+function initializeForEditing(aTagKey) {
+ dialog.editTagKey = aTagKey;
+
+ // Change the title of the dialog
+ var messengerBundle = document.getElementById("bundle_messenger");
+ document.title = messengerBundle.getString("editTagTitle");
+
+ // extract the color and name for the current tag
+ document.getElementById("tagColorPicker").value =
+ MailServices.tags.getColorForKey(aTagKey);
+ dialog.nameField.value = MailServices.tags.getTagForKey(aTagKey);
+}
+
+/**
+ * on OK handler for editing a new tag.
+ */
+function onOKEditTag(event) {
+ // get the tag name of the current key we are editing
+ let existingTagName = MailServices.tags.getTagForKey(dialog.editTagKey);
+
+ // it's ok if the name didn't change
+ if (existingTagName != dialog.nameField.value) {
+ // don't let the user edit a tag to the name of another existing tag
+ if (MailServices.tags.getKeyForTag(dialog.nameField.value)) {
+ alertForExistingTag();
+ event.preventDefault();
+ return;
+ }
+
+ MailServices.tags.setTagForKey(dialog.editTagKey, dialog.nameField.value);
+ }
+
+ MailServices.tags.setColorForKey(
+ dialog.editTagKey,
+ document.getElementById("tagColorPicker").value
+ );
+}
+
+/**
+ * on OK handler for creating a new tag. Alerts the user if a tag with
+ * the name already exists.
+ */
+function onOKNewTag(event) {
+ var name = dialog.nameField.value;
+
+ if (MailServices.tags.getKeyForTag(name)) {
+ alertForExistingTag();
+ event.preventDefault();
+ return;
+ }
+ if (
+ !dialog.okCallback(name, document.getElementById("tagColorPicker").value)
+ ) {
+ event.preventDefault();
+ }
+}
+
+/**
+ * Alerts the user that they are trying to create a tag with a name that
+ * already exists.
+ */
+function alertForExistingTag() {
+ var messengerBundle = document.getElementById("bundle_messenger");
+ var alertText = messengerBundle.getString("tagExists");
+ Services.prompt.alert(window, document.title, alertText);
+}
+
+function doEnabling() {
+ dialog.OKButton.disabled = !dialog.nameField.value;
+}
diff --git a/comm/mail/base/content/newTagDialog.xhtml b/comm/mail/base/content/newTagDialog.xhtml
new file mode 100644
index 0000000000..38eccafec7
--- /dev/null
+++ b/comm/mail/base/content/newTagDialog.xhtml
@@ -0,0 +1,30 @@
+<?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://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="tag-dialog-window"
+ lightweightthemes="true"
+ style="min-width: 25em;"
+ onload="onLoad();">
+<dialog>
+
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://messenger/content/newTagDialog.js"/>
+ <script src="chrome://messenger/content/dialogShadowDom.js"/>
+#include tagDialog.inc.xhtml
+</dialog>
+</window>
diff --git a/comm/mail/base/content/overrides/app-license-body.html b/comm/mail/base/content/overrides/app-license-body.html
new file mode 100644
index 0000000000..4c4669bc32
--- /dev/null
+++ b/comm/mail/base/content/overrides/app-license-body.html
@@ -0,0 +1,1274 @@
+<!-- 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/. -->
+
+<hr />
+
+<h1><a id="tb-apache"></a>Apache License 2.0</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/@matrix-org/olm</code></li>
+ <li><code>chat/protocols/matrix/lib/another-json</code></li>
+ <li><code>chat/protocols/matrix/lib/matrix-events-sdk</code></li>
+ <li><code>chat/protocols/matrix/lib/matrix-sdk</code></li>
+ <li><code>chat/protocols/matrix/lib/matrix-widget-api</code></li>
+</ul>
+
+<pre>
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+</pre>
+
+<h1><a id="tb-bsd3clause"></a>BSD-3-Clause License</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/cipher/sha256-avx-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha256-avx2-bmi2-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha256-ssse3-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha512-avx-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha512-avx2-bmi2-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha512-ssse3-amd64.S</code></li>
+</ul>
+
+See the individual LICENSE files for copyright owners.
+
+<pre>
+ 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 the Intel Corporation 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 INTEL CORPORATION "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 INTEL CORPORATION 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.
+</pre>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/random/jitterentropy-base.c</code></li>
+ <li><code>third_party/libgcrypt/random/jitterentropy.h</code></li>
+ <li>
+ <code
+ >third_party/libgcrypt/random/rndjent.c (plus common Libgcrypt copyright
+ holders)</code
+ >
+ </li>
+</ul>
+
+<pre>
+ * Copyright Stephan Mueller &lt;smueller@chronox.de&gt;, 2013
+ *
+ * License
+ * =======
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, and the entire permission notice in its entirety,
+ * including the disclaimer of warranties.
+ * 2. 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.
+ * 3. The name of the author may not be used to endorse or promote
+ * products derived from this software without specific prior
+ * written permission.
+ *
+ * ALTERNATIVELY, this product may be distributed under the terms of
+ * the GNU General Public License, in which case the provisions of the GPL are
+ * required INSTEAD OF the above restrictions. (This clause is
+ * necessary due to a potential bad interaction between the GPL and
+ * the restrictions contained in a BSD-style copyright.)
+ *
+ * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED
+ * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ALL OF
+ * WHICH ARE HEREBY DISCLAIMED. IN NO EVENT SHALL THE AUTHOR 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 NOT ADVISED OF THE POSSIBILITY OF SUCH
+ * DAMAGE.
+</pre>
+
+<h1><a id="tb-xlicense"></a>X License</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/install.sh</code></li>
+</ul>
+
+<pre>
+ Copyright (C) 1994 X Consortium
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNEC-
+ TION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+ Except as contained in this notice, the name of the X Consortium shall not
+ be used in advertising or otherwise to promote the sale, use or other deal-
+ ings in this Software without prior written authorization from the X Consor-
+ tium.
+</pre>
+
+<h1><a id="tb-publicdomain"></a>Public domain</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/cipher/arcfour-amd64.S</code></li>
+</ul>
+
+<pre>
+ Author: Marc Bevand &lt;bevand_m (at) epita.fr&gt;
+ Licence: I hereby disclaim the copyright on this code and place it
+ in the public domain.
+</pre>
+
+<h1><a id="tb-ocblicense1"></a>OCB license 1</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/cipher/cipher-ocb.c</code></li>
+</ul>
+
+<pre>
+ OCB is covered by several patents but may be used freely by most
+ software. See http://web.cs.ucdavis.edu/~rogaway/ocb/license.htm .
+ In particular license 1 is suitable for Libgcrypt: See
+ http://web.cs.ucdavis.edu/~rogaway/ocb/license1.pdf for the full
+ license document; it basically says:
+
+ License 1 — License for Open-Source Software Implementations of OCB
+ (Jan 9, 2013)
+
+ Under this license, you are authorized to make, use, and
+ distribute open-source software implementations of OCB. This
+ license terminates for you if you sue someone over their
+ open-source software implementation of OCB claiming that you have
+ a patent covering their implementation.
+
+
+
+ License for Open Source Software Implementations of OCB
+ January 9, 2013
+
+ 1 Definitions
+
+ 1.1 “Licensor†means Phillip Rogaway.
+
+ 1.2 “Licensed Patents†means any patent that claims priority to United
+ States Patent Application No. 09/918,615 entitled “Method and Apparatus
+ for Facilitating Efficient Authenticated Encryption,†and any utility,
+ divisional, provisional, continuation, continuations-in-part, reexamination,
+ reissue, or foreign counterpart patents that may issue with respect to the
+ aforesaid patent application. This includes, but is not limited to, United
+ States Patent No. 7,046,802; United States Patent No. 7,200,227; United
+ States Patent No. 7,949,129; United States Patent No. 8,321,675 ; and any
+ patent that issues out of United States Patent Application No. 13/669,114.
+
+ 1.3 “Use†means any practice of any invention claimed in the Licensed Patents.
+
+ 1.4 “Software Implementation†means any practice of any invention
+ claimed in the Licensed Patents that takes the form of software executing on
+ a user-programmable, general-purpose computer or that takes the form of a
+ computer-readable medium storing such software. Software Implementation does
+ not include, for example, application-specific integrated circuits (ASICs),
+ field-programmable gate arrays (FPGAs), embedded systems, or IP cores.
+
+ 1.5 “Open Source Software†means software whose source code is published
+ and made available for inspection and use by anyone because either (a) the
+ source code is subject to a license that permits recipients to copy, modify,
+ and distribute the source code without payment of fees or royalties, or
+ (b) the source code is in the public domain, including code released for
+ public use through a CC0 waiver. All licenses certified by the Open Source
+ Initiative at opensource.org as of January 9, 2013 and all Creative Commons
+ licenses identified on the creativecommons.org website as of January 9,
+ 2013, including the Public License Fallback of the CC0 waiver, satisfy these
+ requirements for the purposes of this license.
+
+ 1.6 “Open Source Software Implementation†means a Software
+ Implementation in which the software implicating the Licensed Patents is
+ Open Source Software. Open Source Software Implementation does not include
+ any Software Implementation in which the software implicating the Licensed
+ Patents is combined, so as to form a larger program, with software that is
+ not Open Source Software.
+
+ 2 License Grant
+
+ 2.1 License. Subject to your compliance with the term s of this license,
+ including the restriction set forth in Section 2.2, Licensor hereby
+ grants to you a perpetual, worldwide, non-exclusive, non-transferable,
+ non-sublicenseable, no-charge, royalty-free, irrevocable license to practice
+ any invention claimed in the Licensed Patents in any Open Source Software
+ Implementation.
+
+ 2.2 Restriction. If you or your affiliates institute patent litigation
+ (including, but not limited to, a cross-claim or counterclaim in a lawsuit)
+ against any entity alleging that any Use authorized by this license
+ infringes another patent, then any rights granted to you under this license
+ automatically terminate as of the date such litigation is filed.
+
+ 3 Disclaimer
+ YOUR USE OF THE LICENSED PATENTS IS AT YOUR OWN RISK AND UNLESS REQUIRED
+ BY APPLICABLE LAW, LICENSOR MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
+ KIND CONCERNING THE LICENSED PATENTS OR ANY PRODUCT EMBODYING ANY LICENSED
+ PATENT, EXPRESS OR IMPLIED, STATUT ORY OR OTHERWISE, INCLUDING, WITHOUT
+ LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, OR NONINFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
+ ARISING FROM OR RELATED TO ANY USE OF THE LICENSED PATENTS, INCLUDING,
+ WITHOUT LIMITATION, DIRECT, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE
+ OR SPECIAL DAMAGES, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF
+ SUCH DAMAGES PRIOR TO SUCH AN OCCURRENCE.
+</pre>
+
+<h1><a id="tb-bzip2license"></a>Bzip2 License</h1>
+
+<p>This license applies to files in <code>third_party/bzip2</code>.</p>
+
+<pre>
+This program, "bzip2", the associated library "libbzip2", and all
+documentation, are copyright (C) 1996-2019 Julian R Seward. All
+rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+2. The origin of this software must not be misrepresented; you must
+ not claim that you wrote the original software. If you use this
+ software in a product, an acknowledgment in the product
+ documentation would be appreciated but is not required.
+
+3. Altered source versions must be plainly marked as such, and must
+ not be misrepresented as being the original software.
+
+4. The name of the author may not be used to endorse or promote
+ products derived from this software without specific prior written
+ permission.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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.
+
+Julian Seward, jseward@acm.org
+bzip2/libbzip2 version 1.0.8 of 13 July 2019
+ </pre
+>
+
+<h1><a id="tb-jsonclicense"></a>Json-C License</h1>
+
+<p>This license applies to files in <code>third_party/json-c</code>.</p>
+
+<pre>
+
+Copyright (c) 2009-2012 Eric Haszlakiewicz
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----------------------------------------------------------------
+
+Copyright (c) 2004, 2005 Metaparadigm Pte Ltd
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-botanlicense"></a>Botan License</h1>
+
+<p>This license applies to files in <code>third_party/botan</code>.</p>
+
+<pre>
+Copyright (C) 1999-2020 The Botan 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:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions, and the following disclaimer.
+
+2. 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.
+
+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 HOLDER 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.
+ </pre
+>
+
+<h1><a id="tb-rnplicense"></a>RNP Licenses</h1>
+
+<p>These licenses apply to files in <code>third_party/rnp</code>.</p>
+
+<h2>Ribose's BSD 2-Clause License</h2>
+
+<pre>
+Copyright (c) 2017, <a href="https://www.ribose.com">Ribose Inc</a>.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+2. 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.
+
+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.
+ </pre>
+
+<h2>NetBSD's BSD 2-Clause License</h2>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/rnpinclude/rekey/rnp_key_store.h</code></li>
+ <li><code>third_party/rnpinclude/repgp/repgp_def.h</code></li>
+ <li><code>third_party/rnpinclude/rnp.h</code></li>
+ <li><code>third_party/rnpinclude/rnp/rnp_sdk.h</code></li>
+ <li><code>third_party/rnpsrc/librekey/key_store_pgp.h</code></li>
+ <li><code>third_party/rnpsrc/librekey/key_store_pgp.cpp</code></li>
+ <li><code>third_party/rnpsrc/librekey/rnp_key_store.cpp</code></li>
+ <li><code>third_party/rnpsrc/rnpkeys/main.cpp</code></li>
+ <li><code>third_party/rnpsrc/rnpkeys/rnpkeys.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/pgp-key.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto.h</code></li>
+ <li><code>third_party/rnpsrc/lib/types.h</code></li>
+ <li><code>third_party/rnpsrc/lib/misc.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/pgp-key.h</code></li>
+ <li><code>third_party/rnpsrc/lib/fingerprint.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/ec.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/s2k.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/hash.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/symmetric.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/s2k.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/bn.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/rng.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/rng.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/dsa.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/symmetric.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/elgamal.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/bn.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/hash.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/dsa.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/rsa.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/elgamal.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/eddsa.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/rsa.cpp</code></li>
+ <li><code>third_party/rnpsrc/rnp/rnp.cpp</code></li>
+</ul>
+
+<pre>
+This software contains source code originating from NetPGP, which
+carries the following copyright notice and license.
+
+Copyright (c) 2009-2016, <a href="https://www.netbsd.org">The NetBSD Foundation, Inc</a>.
+All rights reserved.
+
+This code is derived from software contributed to The NetBSD Foundation
+by Alistair Crooks &lt;agc at NetBSD.org&gt;
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+2. 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.
+
+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 HOLDER 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.
+</pre>
+
+<h2>Nominet UK's Apache 2.0 Licence</h2>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/rnp/src/librekey/key_store_pgp.cpp</code></li>
+ <li><code>third_party/rnp/src/librekey/key_store_pgp.h</code></li>
+ <li><code>third_party/rnp/src/lib/crypto.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto.h</code></li>
+ <li><code>third_party/rnp/src/lib/misc.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/pgp-key.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/pgp-key.h</code></li>
+ <li><code>third_party/rnp/src/lib/types.h</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/dsa.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/elgamal.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/hash.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/rsa.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/symmetric.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/symmetric.h</code></li>
+</ul>
+
+<pre>
+This software contains source code originating from NetPGP, which
+carries the following copyright notice and license.
+
+Copyright (c) 2005-2008 <a href="http://www.nic.uk">Nominet UK</a>
+All rights reserved.
+
+Contributors: Ben Laurie, Rachel Willmer. The Contributors have asserted
+their moral rights under the UK Copyright Design and Patents Act 1988 to
+be recorded as the authors of this copyright work.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+ </pre>
+
+<h2>Nominet UK's BSD 3-Clause License</h2>
+
+<pre>
+This software contains source code originating from NetPGP, which
+carries the following copyright notice and license.
+
+Copyright (c) 2005 <a href="http://www.nic.uk">Nominet UK</a>
+All rights reserved.
+
+Contributors: Ben Laurie, Rachel Willmer. The Contributors have asserted
+their moral rights under the UK Copyright Design and Patents Act 1988 to
+be recorded as the authors of this copyright work.
+
+This is a BSD-style Open Source licence.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+2. 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.
+
+3. The name of Nominet UK or the contributors may not be used to
+ endorse or promote products derived from this software without specific
+ prior written permission;
+
+and provided that the user accepts the terms of the following disclaimer:
+
+THIS SOFTWARE IS PROVIDED BY NOMINET UK 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 NOMINET UK 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.
+ </pre>
+
+<h2>OCB Patent License for Ribose Inc.</h2>
+
+<pre>
+This license has been graciously granted by Professor Phillip Rogaway to allow
+users of "rnp" to utilize the patented OCB blockcipher mode of operation,
+which simultaneously provides privacy and authenticity.
+
+The license text is presented below in plain text form purely for referencial
+purposes. The original signed license is available on request from Ribose Inc.,
+reachable at open.source@ribose.com.
+
+1. Definitions
+
+1.1 "Licensor" means Phillip Rogaway, of 1212 Purdue Dr., Davis, California, USA.
+
+1.2 "Licensed Patents" means any patent that claims priority to United States
+Patent Application No. 09/918,615 entitled "Method and Apparatus for
+Facilitating Efficient Authenticated Encryption," and any utility, divisional,
+provisional, continuation, continuations in part, reexamination, reissue, or
+foreign counterpart patents that may issue with respect to the aforesaid patent
+application. This includes, but is not limited to, United States Patent No.
+7,046,802; United States Patent No. 7,200,227; United States Patent No.
+7,949,129; United States Patent No. 8,321,675; and any patent that issues out
+or United States Patent Application No. 13/669,114.
+
+1.3 "Licensee" means Ribose Inc., at Suite 1, 8/F, 10 Ice House Street,
+Central, Hong Kong, its affiliates, assignees, or successors in interest, or
+anyone using, making, copying, modifying, distributing, having made, importing,
+or having imported any program, software, or computer system including or based
+upon Open Source Software published by Ribose Inc., or their customers,
+suppliers, importers, manufacturers, distributors, or insurers.
+
+1.4 "Use in Licensee Products" means using, making, copying, modifying,
+distributing, having made, importing or having imported any program, software,
+or computer system published by Licensee, which contains or is based upon Open
+Source Software which may include any implementation of the Licensed Patents.
+
+1.5 "Open Source Software" means software whose source code is published and
+made available for inspection and use by anyone because either (a) the source
+code is subject to a license that permits recipients to copy, modify, and
+distribute the source code without payment of fees or royalties, or (b) the
+source code is in the public domain, including code released for public use
+through a CC0 waiver. All licenses certified by the Open Source Initiative at
+opensource.org as of January 1, 2017 and all Creative Commons licenses
+identified on the creativecommons.org website as of January 1, 2017, including
+the Public License Fallback of the CC0 waiver, satisfy these requirements for
+the purposes of this license.
+
+2. Grant of License
+
+2.1 Licensor hereby grants to Licensee a perpetual, worldwide, non-exclusive,
+nontransferable, non-sublicenseable, no-charge, royalty-free, irrevocable
+license to Use in Licensee Products any invention claimed in the Licensed
+Patents in any Open Source Software Implementation and in hardware as long as
+the Open Source Software incorporated in such hardware is freely licensed for
+hardware embodiment.
+
+3. Disclaimer
+
+3.1 LICENSEE'S USE OF THE LICENSED PATENTS IS AT LICENSEE'S OWN RISK AND UNLESS
+REQUIRED BY APPLICABLE LAW, LICENSOR MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ANY KIND CONCERNING THE LICENSED PATENTS OR ANY PRODUCT EMBODYING ANY LICENSED
+PATENT, EXPRESS OR IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT
+LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR
+PURPOSE, OR NONINFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING
+FROM OR RELATED TO ANY USE OF THE LICENSED PATENTS, INCLUDING, WITHOUT
+LIMITATION, DIRECT, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR SPECIAL
+DAMAGES, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES
+PRIOR TO SUCH AN OCCURRENCE.
+
+[SIGNATURE by Phillip Rogaway]
+
+Date: August 28, 2017
+
+ </pre
+>
+
+<h1><a id="tb-direntlicense"></a>Dirent License</h1>
+
+<p>This license applies to <code>third_party/niwcompat/dirent.h</code>.</p>
+
+<pre>
+The MIT License (MIT)
+
+Copyright (c) 1998-2019 Toni Ronkko
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-mapiheaders"></a>MAPI Headers License</h1>
+
+<p>This license applies to <code>mailnews/mapi/include/</code>.</p>
+
+<pre>
+MIT License
+
+Copyright (c) 2018 Microsoft
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-getopt">getopt.c License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/niwcompat/getopt.c</code></li>
+ <li><code>third_party/niwcompat/getopt.h</code></li>
+</ul>
+<pre>
+Copyright (c) 1987, 1993, 1994, 1996
+ The Regents of the University of California. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. 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.
+3. Neither the names of the copyright holders 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 REGENTS 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.
+ </pre
+>
+
+<h1><a id="tb-asn1js">ASN1.js License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/asn1js/</code></li>
+</ul>
+<pre>
+Copyright (c) 2014, GMO GlobalSign
+Copyright (c) 2015-2022, Peculiar Ventures
+All rights reserved.
+
+Author 2014-2019, Yury Strozhevsky
+
+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 the copyright holder 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 HOLDER 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.
+ </pre
+>
+
+<h1><a id="tb-base-x">base-x License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/base-x</code></li>
+</ul>
+<pre>
+The MIT License (MIT)
+
+Copyright (c) 2018 base-x contributors
+Copyright (c) 2014-2018 The Bitcoin Core developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-bs58">bs58 License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/bs58</code></li>
+</ul>
+<pre>
+MIT License
+
+Copyright (c) 2018 cryptocoinjs
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-content-type">content-type License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/content-type</code></li>
+</ul>
+<pre>
+(The MIT License)
+
+Copyright (c) 2015 Douglas Christopher Wilson
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-events">events License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/events</code></li>
+</ul>
+<pre>
+MIT
+
+Copyright Joyent, Inc. and other Node contributors.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-p-retry">p-retry License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/p-retry</code></li>
+</ul>
+<pre>
+MIT License
+
+Copyright (c) Sindre Sorhus &lt;sindresorhus@gmail.com&gt; (sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-retry">retry License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/retry</code></li>
+</ul>
+<pre>
+Copyright (c) 2011:
+Tim Koschützki (tim@debuggable.com)
+Felix Geisendörfer (felix@debuggable.com)
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-unhomoglyph">unhomoglyph License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/unhomoglyph</code></li>
+</ul>
+<pre>
+Copyright (c) 2016 Vitaly Puzrin.
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-sax">sax License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/xmpp/lib/sax</code></li>
+</ul>
+<pre>
+The ISC License
+
+Copyright (c) Isaac Z. Schlueter and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+====
+
+`String.fromCodePoint` by Mathias Bynens used according to terms of MIT
+License, as follows:
+
+ Copyright Mathias Bynens &lt;https://mathiasbynens.be/&gt;
+
+ Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-lgpl">GNU Lesser General Public License 2.1</a></h1>
+<p>This product contains code from the following LGPLed libraries:</p>
+<ul>
+ <li><a href="https://www.gnupg.org/ftp/gcrypt/libgcrypt/">libgcrypt</a></li>
+ <li>
+ <a href="https://www.gnupg.org/ftp/gcrypt/libgpg-error/">gpg-error</a>
+ </li>
+ <li><a href="https://otr.cypherpunks.ca/">libotr</a></li>
+</ul>
+
+(These libraries only ship in some versions of this product.)
+<a href="#lgpl">Read the license above.</a>
+
+<h1><a id="tb-sdp-transform">sdp-transform License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/sdp-transform</code></li>
+</ul>
+<pre>
+(The MIT License)
+
+Copyright (c) 2013 Eirik Albrigtsen
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
diff --git a/comm/mail/base/content/overrides/app-license-list.html b/comm/mail/base/content/overrides/app-license-list.html
new file mode 100644
index 0000000000..19cbec0b01
--- /dev/null
+++ b/comm/mail/base/content/overrides/app-license-list.html
@@ -0,0 +1,33 @@
+<!-- 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/. -->
+
+<br />
+
+<ul>
+ <li><a href="about:license#tb-apache">Apache License 2.0</a></li>
+ <li><a href="about:license#tb-bsd3clause">BSD-3-Clause License</a></li>
+ <li><a href="about:license#tb-xlicense">X License</a></li>
+ <li><a href="about:license#tb-publicdomain">Public domain</a></li>
+ <li><a href="about:license#tb-ocblicense1">OCB license 1</a></li>
+ <li><a href="about:license#tb-bzip2license">Bzip2 License</a></li>
+ <li><a href="about:license#tb-jsonclicense">Json-C License</a></li>
+ <li><a href="about:license#tb-botanlicense">Botan License</a></li>
+ <li><a href="about:license#tb-rnplicense">RNP Licenses</a></li>
+ <li><a href="about:license#tb-direntlicense">Dirent License</a></li>
+ <li><a href="about:license#tb-mapiheaders">MAPI Headers License</a></li>
+ <li><a href="about:license#tb-getopt">Getopt.c License</a></li>
+ <li><a href="about:license#tb-asn1js">ASN1.js License</a></li>
+ <li><a href="about:license#tb-base-x">base-x License</a></li>
+ <li><a href="about:license#tb-bs58">bs58 License</a></li>
+ <li><a href="about:license#tb-content-type">content-type License</a></li>
+ <li><a href="about:license#tb-events">events License</a></li>
+ <li><a href="about:license#tb-p-retry">p-retry License</a></li>
+ <li><a href="about:license#tb-retry">retry License</a></li>
+ <li><a href="about:license#tb-unhomoglyph">unhomoglyph License</a></li>
+ <li><a href="about:license#tb-sax">sax License</a></li>
+ <li>
+ <a href="about:license#tb-lgpl">GNU Lesser General Public License 2.1</a>
+ </li>
+ <li><a href="about:license#tb-sdp-transform">sdp-transform License</a></li>
+</ul>
diff --git a/comm/mail/base/content/overrides/app-license-name.html b/comm/mail/base/content/overrides/app-license-name.html
new file mode 100644
index 0000000000..2bcdf72c40
--- /dev/null
+++ b/comm/mail/base/content/overrides/app-license-name.html
@@ -0,0 +1 @@
+Thunderbird
diff --git a/comm/mail/base/content/overrides/app-license.html b/comm/mail/base/content/overrides/app-license.html
new file mode 100644
index 0000000000..4a66b5bc53
--- /dev/null
+++ b/comm/mail/base/content/overrides/app-license.html
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+
+<p>
+ <b>Binaries</b> of this product have been made available to you by the
+ <a href="https://www.thunderbird.net/">Thunderbird Project</a> under the
+ Mozilla Public License. <a href="about:rights">Know your rights</a>.
+</p>
diff --git a/comm/mail/base/content/printUtils.js b/comm/mail/base/content/printUtils.js
new file mode 100644
index 0000000000..129cc6a41a
--- /dev/null
+++ b/comm/mail/base/content/printUtils.js
@@ -0,0 +1,428 @@
+/* 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"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ SubDialogManager: "resource://gre/modules/SubDialog.sys.mjs",
+});
+
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+// Load PrintUtils lazily and modify it to suit.
+XPCOMUtils.defineLazyGetter(this, "PrintUtils", () => {
+ let scope = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://global/content/printUtils.js",
+ scope
+ );
+ scope.PrintUtils.getTabDialogBox = function (browser) {
+ if (!browser.tabDialogBox) {
+ browser.tabDialogBox = new TabDialogBox(browser);
+ }
+ return browser.tabDialogBox;
+ };
+ scope.PrintUtils.createBrowser = function ({
+ remoteType,
+ initialBrowsingContextGroupId,
+ userContextId,
+ skipLoad,
+ initiallyActive,
+ } = {}) {
+ let b = document.createXULElement("browser");
+ // Use the JSM global to create the permanentKey, so that if the
+ // permanentKey is held by something after this window closes, it
+ // doesn't keep the window alive.
+ b.permanentKey = new (Cu.getGlobalForObject(Services).Object)();
+
+ const defaultBrowserAttributes = {
+ maychangeremoteness: "true",
+ messagemanagergroup: "browsers",
+ type: "content",
+ };
+ for (let attribute in defaultBrowserAttributes) {
+ b.setAttribute(attribute, defaultBrowserAttributes[attribute]);
+ }
+
+ if (userContextId) {
+ b.setAttribute("usercontextid", userContextId);
+ }
+
+ if (remoteType) {
+ b.setAttribute("remoteType", remoteType);
+ b.setAttribute("remote", "true");
+ }
+
+ // Ensure that the browser will be created in a specific initial
+ // BrowsingContextGroup. This may change the process selection behaviour
+ // of the newly created browser, and is often used in combination with
+ // "remoteType" to ensure that the initial about:blank load occurs
+ // within the same process as another window.
+ if (initialBrowsingContextGroupId) {
+ b.setAttribute(
+ "initialBrowsingContextGroupId",
+ initialBrowsingContextGroupId
+ );
+ }
+
+ // We set large flex on both containers to allow the devtools toolbox to
+ // set a flex attribute. We don't want the toolbox to actually take up free
+ // space, but we do want it to collapse when the window shrinks, and with
+ // flex=0 it can't. When the toolbox is on the bottom it's a sibling of
+ // browserStack, and when it's on the side it's a sibling of
+ // browserContainer.
+ let stack = document.createXULElement("stack");
+ stack.className = "browserStack";
+ stack.appendChild(b);
+
+ let browserContainer = document.createXULElement("vbox");
+ browserContainer.className = "browserContainer";
+ browserContainer.appendChild(stack);
+
+ let browserSidebarContainer = document.createXULElement("hbox");
+ browserSidebarContainer.className = "browserSidebarContainer";
+ browserSidebarContainer.appendChild(browserContainer);
+
+ // Prevent the superfluous initial load of a blank document
+ // if we're going to load something other than about:blank.
+ if (skipLoad) {
+ b.setAttribute("nodefaultsrc", "true");
+ }
+
+ return b;
+ };
+
+ scope.PrintUtils.__defineGetter__("printBrowser", () =>
+ document.getElementById("hiddenPrintContent")
+ );
+ scope.PrintUtils.loadPrintBrowser = async function (url) {
+ let printBrowser = this.printBrowser;
+ if (printBrowser.currentURI?.spec == url) {
+ return;
+ }
+
+ // The template page hasn't been loaded yet. Do that now.
+ await new Promise(resolve => {
+ // Store a strong reference to this progress listener.
+ printBrowser.progressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ /** nsIWebProgressListener */
+ onStateChange(webProgress, request, stateFlags, status) {
+ if (
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ printBrowser.currentURI.spec != "about:blank"
+ ) {
+ printBrowser.webProgress.removeProgressListener(this);
+ delete printBrowser.progressListener;
+ resolve();
+ }
+ },
+ };
+
+ printBrowser.webProgress.addProgressListener(
+ printBrowser.progressListener,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+ MailE10SUtils.loadURI(printBrowser, url);
+ });
+ };
+ return scope.PrintUtils;
+});
+
+/**
+ * The TabDialogBox supports opening window dialogs as SubDialogs on the tab and content
+ * level. Both tab and content dialogs have their own separate managers.
+ * Dialogs will be queued FIFO and cover the web content.
+ * Dialogs are closed when the user reloads or leaves the page.
+ * While a dialog is open PopupNotifications, such as permission prompts, are
+ * suppressed.
+ */
+class TabDialogBox {
+ constructor(browser) {
+ this._weakBrowserRef = Cu.getWeakReference(browser);
+
+ // Create parent element for tab dialogs
+ let template = document.getElementById("dialogStackTemplate");
+ this.dialogStack = template.content.cloneNode(true).firstElementChild;
+ this.dialogStack.classList.add("tab-prompt-dialog");
+
+ while (browser.ownerDocument != document) {
+ // Find an ancestor <browser> in this document so that we can locate the
+ // print preview appropriately.
+ browser = browser.ownerGlobal.browsingContext.embedderElement;
+ }
+
+ // This differs from Firefox by using a specific ancestor <stack> rather
+ // than the parent of the <browser>, so that a larger area of the screen
+ // is used for the preview.
+ this.printPreviewStack = document.querySelector(".printPreviewStack");
+ if (this.printPreviewStack && this.printPreviewStack.contains(browser)) {
+ this.printPreviewStack.appendChild(this.dialogStack);
+ } else {
+ this.printPreviewStack = this.browser.parentNode;
+ this.browser.parentNode.insertBefore(
+ this.dialogStack,
+ this.browser.nextElementSibling
+ );
+ }
+
+ // Initially the stack only contains the template
+ let dialogTemplate = this.dialogStack.firstElementChild;
+
+ // Create dialog manager for prompts at the tab level.
+ this._tabDialogManager = new SubDialogManager({
+ dialogStack: this.dialogStack,
+ dialogTemplate,
+ orderType: SubDialogManager.ORDER_QUEUE,
+ allowDuplicateDialogs: true,
+ dialogOptions: {
+ consumeOutsideClicks: false,
+ },
+ });
+ }
+
+ /**
+ * Open a dialog on tab or content level.
+ *
+ * @param {string} aURL - URL of the dialog to load in the tab box.
+ * @param {object} [aOptions]
+ * @param {string} [aOptions.features] - Comma separated list of window
+ * features.
+ * @param {boolean} [aOptions.allowDuplicateDialogs] - Whether to allow
+ * showing multiple dialogs with aURL at the same time. If false calls for
+ * duplicate dialogs will be dropped.
+ * @param {string} [aOptions.sizeTo] - Pass "available" to stretch dialog to
+ * roughly content size.
+ * @param {boolean} [aOptions.keepOpenSameOriginNav] - By default dialogs are
+ * aborted on any navigation.
+ * Set to true to keep the dialog open for same origin navigation.
+ * @param {number} [aOptions.modalType] - The modal type to create the dialog for.
+ * By default, we show the dialog for tab prompts.
+ * @returns {object} [result] Returns an object { closedPromise, dialog }.
+ * @returns {Promise} [result.closedPromise] Resolves once the dialog has been closed.
+ * @returns {SubDialog} [result.dialog] A reference to the opened SubDialog.
+ */
+ open(
+ aURL,
+ {
+ features = null,
+ allowDuplicateDialogs = true,
+ sizeTo,
+ keepOpenSameOriginNav,
+ modalType = null,
+ allowFocusCheckbox = false,
+ } = {},
+ ...aParams
+ ) {
+ let resolveClosed;
+ let closedPromise = new Promise(resolve => (resolveClosed = resolve));
+ // Get the dialog manager to open the prompt with.
+ let dialogManager =
+ modalType === Ci.nsIPrompt.MODAL_TYPE_CONTENT
+ ? this.getContentDialogManager()
+ : this._tabDialogManager;
+ let hasDialogs =
+ this._tabDialogManager.hasDialogs ||
+ this._contentDialogManager?.hasDialogs;
+
+ if (!hasDialogs) {
+ this._onFirstDialogOpen();
+ }
+
+ let closingCallback = event => {
+ if (!hasDialogs) {
+ this._onLastDialogClose();
+ }
+
+ if (allowFocusCheckbox && !event.detail?.abort) {
+ this.maybeSetAllowTabSwitchPermission(event.target);
+ }
+ };
+
+ if (modalType == Ci.nsIPrompt.MODAL_TYPE_CONTENT) {
+ sizeTo = "limitheight";
+ }
+
+ // Open dialog and resolve once it has been closed
+ let dialog = dialogManager.open(
+ aURL,
+ {
+ features,
+ allowDuplicateDialogs,
+ sizeTo,
+ closingCallback,
+ closedCallback: resolveClosed,
+ },
+ ...aParams
+ );
+
+ // Marking the dialog externally, instead of passing it as an option.
+ // The SubDialog(Manager) does not care about navigation.
+ // dialog can be null here if allowDuplicateDialogs = false.
+ if (dialog) {
+ dialog._keepOpenSameOriginNav = keepOpenSameOriginNav;
+ }
+ return { closedPromise, dialog };
+ }
+
+ _onFirstDialogOpen() {
+ for (let element of this.printPreviewStack.children) {
+ if (element != this.dialogStack) {
+ element.setAttribute("tabDialogShowing", true);
+ }
+ }
+
+ // Register listeners
+ this._lastPrincipal = this.browser.contentPrincipal;
+ if ("addProgressListener" in this.browser) {
+ this.browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
+ }
+ }
+
+ _onLastDialogClose() {
+ for (let element of this.printPreviewStack.children) {
+ if (element != this.dialogStack) {
+ element.removeAttribute("tabDialogShowing");
+ }
+ }
+
+ // Clean up listeners
+ if ("removeProgressListener" in this.browser) {
+ this.browser.removeProgressListener(this);
+ }
+ this._lastPrincipal = null;
+ }
+
+ _buildContentPromptDialog() {
+ let template = document.getElementById("dialogStackTemplate");
+ let contentDialogStack = template.content.cloneNode(true).firstElementChild;
+ contentDialogStack.classList.add("content-prompt-dialog");
+
+ // Create a dialog manager for content prompts.
+ let tabPromptDialog =
+ this.browser.parentNode.querySelector(".tab-prompt-dialog");
+ this.browser.parentNode.insertBefore(contentDialogStack, tabPromptDialog);
+
+ let contentDialogTemplate = contentDialogStack.firstElementChild;
+ this._contentDialogManager = new SubDialogManager({
+ dialogStack: contentDialogStack,
+ dialogTemplate: contentDialogTemplate,
+ orderType: SubDialogManager.ORDER_QUEUE,
+ allowDuplicateDialogs: true,
+ dialogOptions: {
+ consumeOutsideClicks: false,
+ },
+ });
+ }
+
+ handleEvent(event) {
+ if (event.type !== "TabClose") {
+ return;
+ }
+ this.abortAllDialogs();
+ }
+
+ abortAllDialogs() {
+ this._tabDialogManager.abortDialogs();
+ this._contentDialogManager?.abortDialogs();
+ }
+
+ focus() {
+ // Prioritize focusing the dialog manager for tab prompts
+ if (this._tabDialogManager._dialogs.length) {
+ this._tabDialogManager.focusTopDialog();
+ return;
+ }
+ this._contentDialogManager?.focusTopDialog();
+ }
+
+ /**
+ * If the user navigates away or refreshes the page, close all dialogs for
+ * the current browser.
+ */
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ if (
+ !aWebProgress.isTopLevel ||
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
+ ) {
+ return;
+ }
+
+ // Dialogs can be exempt from closing on same origin location change.
+ let filterFn;
+
+ // Test for same origin location change
+ if (
+ this._lastPrincipal?.isSameOrigin(
+ aLocation,
+ this.browser.browsingContext.usePrivateBrowsing
+ )
+ ) {
+ filterFn = dialog => !dialog._keepOpenSameOriginNav;
+ }
+
+ this._lastPrincipal = this.browser.contentPrincipal;
+
+ this._tabDialogManager.abortDialogs(filterFn);
+ this._contentDialogManager?.abortDialogs(filterFn);
+ }
+
+ get tab() {
+ return document.getElementById("tabmail").getTabForBrowser(this.browser);
+ }
+
+ get browser() {
+ let browser = this._weakBrowserRef.get();
+ if (!browser) {
+ throw new Error("Stale dialog box! The associated browser is gone.");
+ }
+ return browser;
+ }
+
+ getTabDialogManager() {
+ return this._tabDialogManager;
+ }
+
+ getContentDialogManager() {
+ if (!this._contentDialogManager) {
+ this._buildContentPromptDialog();
+ }
+ return this._contentDialogManager;
+ }
+
+ onNextPromptShowAllowFocusCheckboxFor(principal) {
+ this._allowTabFocusByPromptPrincipal = principal;
+ }
+
+ /**
+ * Sets the "focus-tab-by-prompt" permission for the dialog.
+ */
+ maybeSetAllowTabSwitchPermission(dialog) {
+ let checkbox = dialog.querySelector("checkbox");
+
+ if (checkbox.checked) {
+ Services.perms.addFromPrincipal(
+ this._allowTabFocusByPromptPrincipal,
+ "focus-tab-by-prompt",
+ Services.perms.ALLOW_ACTION
+ );
+ }
+
+ // Don't show the "allow tab switch checkbox" for subsequent prompts.
+ this._allowTabFocusByPromptPrincipal = null;
+ }
+}
+
+TabDialogBox.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+]);
diff --git a/comm/mail/base/content/profileDowngrade.js b/comm/mail/base/content/profileDowngrade.js
new file mode 100644
index 0000000000..3a9038e8e5
--- /dev/null
+++ b/comm/mail/base/content/profileDowngrade.js
@@ -0,0 +1,53 @@
+/* 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 gParams;
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+window.addEventListener("load", event => {
+ init();
+});
+
+function init() {
+ /*
+ * The C++ code passes a dialog param block using its integers as in and out
+ * arguments for this UI. The following are the uses of the integers:
+ *
+ * 0: A set of flags from nsIToolkitProfileService.downgradeUIFlags.
+ * 1: A return argument, one of nsIToolkitProfileService.downgradeUIChoice.
+ */
+ gParams = window.arguments[0].QueryInterface(Ci.nsIDialogParamBlock);
+
+ document.addEventListener("dialogextra1", createProfile);
+ document.addEventListener("dialogaccept", quit);
+ document.addEventListener("dialogcancel", quit);
+
+ document.querySelector("dialog").getButton("accept").focus();
+}
+
+function quit() {
+ gParams.SetInt(1, Ci.nsIToolkitProfileService.quit);
+}
+
+function createProfile() {
+ gParams.SetInt(1, Ci.nsIToolkitProfileService.createNewProfile);
+ window.close();
+}
+
+function moreInfo(event) {
+ if (event.type == "keypress" && event.key != "Enter") {
+ return;
+ }
+ event.preventDefault();
+
+ let uri = Services.io.newURI(
+ "https://support.mozilla.org/kb/unable-launch-older-version-profile"
+ );
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+}
diff --git a/comm/mail/base/content/profileDowngrade.xhtml b/comm/mail/base/content/profileDowngrade.xhtml
new file mode 100644
index 0000000000..4768f930fb
--- /dev/null
+++ b/comm/mail/base/content/profileDowngrade.xhtml
@@ -0,0 +1,48 @@
+<?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://messenger/skin/profileDowngrade.css" type="text/css"?>
+
+<!DOCTYPE html [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % profileDTD SYSTEM "chrome://messenger/locale/profileDowngrade.dtd">
+%profileDTD;
+]>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ scrolling="false">
+<head>
+ <title>&window.title;</title>
+ <script defer="defer" src="chrome://global/content/customElements.js"></script>
+ <script defer="defer" src="profileDowngrade.js"></script>
+</head>
+<body>
+<xul:dialog buttonlabelextra1="&window.create;"
+#ifdef XP_WIN
+ buttonlabelaccept="&window.quit-win;"
+#else
+ buttonlabelaccept="&window.quit-nonwin;"
+#endif
+ buttons="accept,extra1" buttonpack="end"
+ nobuttonspacer="true">
+ <div id="contentWrapper">
+ <img src="chrome://messenger/skin/icons/new/activity/info.svg"
+ alt=""
+ role="presentation" />
+ <div>
+ <p id="nosync">&window.nosync2;</p>
+ <p>
+ <a class="text-link"
+ onclick="moreInfo(event);"
+ onkeypress="moreInfo(event);">&window.moreinfo;</a>
+ </p>
+ </div>
+ </div>
+</xul:dialog>
+</body>
+</html>
diff --git a/comm/mail/base/content/protovis-r2.6-modded.js b/comm/mail/base/content/protovis-r2.6-modded.js
new file mode 100644
index 0000000000..547dedd17a
--- /dev/null
+++ b/comm/mail/base/content/protovis-r2.6-modded.js
@@ -0,0 +1,5349 @@
+/* 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 pv = function () {
+/**
+ * @namespace The Protovis namespace, <tt>pv</tt>. All public methods and fields
+ * should be registered on this object. Note that core Protovis source is
+ * surrounded by an anonymous function, so any other declared globals will not
+ * be visible outside of core methods. This also allows multiple versions of
+ * Protovis to coexist, since each version will see their own <tt>pv</tt>
+ * namespace.
+ */
+var pv = {};
+
+/**
+ * Returns a prototype object suitable for extending the given class
+ * <tt>f</tt>. Rather than constructing a new instance of <tt>f</tt> to serve as
+ * the prototype (which unnecessarily runs the constructor on the created
+ * prototype object, potentially polluting it), an anonymous function is
+ * generated internally that shares the same prototype:
+ *
+ * <pre>function g() {}
+ * g.prototype = f.prototype;
+ * return new g();</pre>
+ *
+ * For more details, see Douglas Crockford's essay on prototypal inheritance.
+ *
+ * @param {function} f a constructor.
+ * @returns a suitable prototype object.
+ * @see Douglas Crockford's essay on <a
+ * href="http://javascript.crockford.com/prototypal.html">prototypal
+ * inheritance</a>.
+ */
+pv.extend = function(f) {
+ function g() {}
+ g.prototype = f.prototype;
+ return new g();
+};
+
+/**
+ * Returns the passed-in argument, <tt>x</tt>; the identity function. This method
+ * is provided for convenience since it is used as the default behavior for a
+ * number of property functions.
+ *
+ * @param x a value.
+ * @returns the value <tt>x</tt>.
+ */
+pv.identity = function(x) { return x; };
+
+/**
+ * Returns an array of numbers, starting at <tt>start</tt>, incrementing by
+ * <tt>step</tt>, until <tt>stop</tt> is reached. The stop value is exclusive. If
+ * only a single argument is specified, this value is interpreted as the
+ * <i>stop</i> value, with the <i>start</i> value as zero. If only two arguments
+ * are specified, the step value is implied to be one.
+ *
+ * <p>The method is modeled after the built-in <tt>range</tt> method from
+ * Python. See the Python documentation for more details.
+ *
+ * @see <a href="http://docs.python.org/library/functions.html#range">Python range</a>.
+ * @param {number} [start] the start value.
+ * @param {number} stop the stop value.
+ * @param {number} [step] the step value.
+ * @returns {number[]} an array of numbers.
+ */
+pv.range = function(start, stop, step) {
+ if (arguments.length == 1) {
+ stop = start;
+ start = 0;
+ }
+ if (step == undefined) step = 1;
+ else if (!step) throw new Error("step must be non-zero");
+ var array = [], i = 0, j;
+ if (step < 0) {
+ while ((j = start + step * i++) > stop) {
+ array.push(j);
+ }
+ } else {
+ while ((j = start + step * i++) < stop) {
+ array.push(j);
+ }
+ }
+ return array;
+};
+
+/**
+ * Given two arrays <tt>a</tt> and <tt>b</tt>, returns an array of all possible
+ * pairs of elements [a<sub>i</sub>, b<sub>j</sub>]. The outer loop is on array
+ * <i>a</i>, while the inner loop is on <i>b</i>, such that the order of
+ * returned elements is [a<sub>0</sub>, b<sub>0</sub>], [a<sub>0</sub>,
+ * b<sub>1</sub>], ... [a<sub>0</sub>, b<sub>m</sub>], [a<sub>1</sub>,
+ * b<sub>0</sub>], [a<sub>1</sub>, b<sub>1</sub>], ... [a<sub>1</sub>,
+ * b<sub>m</sub>], ... [a<sub>n</sub>, b<sub>m</sub>]. If either array is empty,
+ * an empty array is returned.
+ *
+ * @param {array} a an array.
+ * @param {array} b an array.
+ * @returns {array} an array of pairs of elements in <tt>a</tt> and <tt>b</tt>.
+ */
+pv.cross = function(a, b) {
+ var array = [];
+ for (var i = 0, n = a.length, m = b.length; i < n; i++) {
+ for (var j = 0, x = a[i]; j < m; j++) {
+ array.push([x, b[j]]);
+ }
+ }
+ return array;
+};
+
+/**
+ * Given the specified array of <tt>arrays</tt>, concatenates the arrays into a
+ * single array. If the individual arrays are explicitly known, an alternative
+ * to blend is to use JavaScript's <tt>concat</tt> method directly. These two
+ * equivalent expressions:<ul>
+ *
+ * <li><tt>pv.blend([[1, 2, 3], ["a", "b", "c"]])</tt>
+ * <li><tt>[1, 2, 3].concat(["a", "b", "c"])</tt>
+ *
+ * </ul>return [1, 2, 3, "a", "b", "c"].
+ *
+ * @param {array[]} arrays an array of arrays.
+ * @returns {array} an array containing all the elements of each array in
+ * <tt>arrays</tt>.
+ */
+pv.blend = function(arrays) {
+ return Array.prototype.concat.apply([], arrays);
+};
+
+/**
+ * Returns all of the property names (keys) of the specified object (a map). The
+ * order of the returned array is not defined.
+ *
+ * @param map an object.
+ * @returns {string[]} an array of strings corresponding to the keys.
+ * @see #entries
+ */
+pv.keys = function(map) {
+ var array = [];
+ for (var key in map) {
+ array.push(key);
+ }
+ return array;
+};
+
+/**
+ * Returns all of the entries (key-value pairs) of the specified object (a
+ * map). The order of the returned array is not defined. Each key-value pair is
+ * represented as an object with <tt>key</tt> and <tt>value</tt> attributes,
+ * e.g., <tt>{key: "foo", value: 42}</tt>.
+ *
+ * @param map an object.
+ * @returns {array} an array of key-value pairs corresponding to the keys.
+ */
+pv.entries = function(map) {
+ var array = [];
+ for (var key in map) {
+ array.push({ key: key, value: map[key] });
+ }
+ return array;
+};
+
+/**
+ * Returns all of the values (attribute values) of the specified object (a
+ * map). The order of the returned array is not defined.
+ *
+ * @param map an object.
+ * @returns {array} an array of objects corresponding to the values.
+ * @see #entries
+ */
+pv.values = function(map) {
+ var array = [];
+ for (var key in map) {
+ array.push(map[key]);
+ }
+ return array;
+};
+
+/**
+ * Returns a normalized copy of the specified array, such that the sum of the
+ * returned elements sum to one. If the specified array is not an array of
+ * numbers, an optional accessor function <tt>f</tt> can be specified to map the
+ * elements to numbers. For example, if <tt>array</tt> is an array of objects,
+ * and each object has a numeric property "foo", the expression
+ *
+ * <pre>pv.normalize(array, function(d) d.foo)</pre>
+ *
+ * returns a normalized array on the "foo" property. If an accessor function is
+ * not specified, the identity function is used.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number[]} an array of numbers that sums to one.
+ */
+pv.normalize = function(array, f) {
+ if (!f) f = pv.identity;
+ var sum = pv.sum(array, f);
+ return array.map(function(d) { return f(d) / sum; });
+};
+
+/**
+ * Returns the sum of the specified array. If the specified array is not an
+ * array of numbers, an optional accessor function <tt>f</tt> can be specified
+ * to map the elements to numbers. See {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the sum of the specified array.
+ */
+pv.sum = function(array, f) {
+ if (!f) f = pv.identity;
+ return pv.reduce(array, function(p, d) { return p + f(d); }, 0);
+};
+
+/**
+ * Returns the maximum value of the specified array. If the specified array is
+ * not an array of numbers, an optional accessor function <tt>f</tt> can be
+ * specified to map the elements to numbers. See {@link #normalize} for an
+ * example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the maximum value of the specified array.
+ */
+pv.max = function(array, f) {
+ if (!f) f = pv.identity;
+ return pv.reduce(array, function(p, d) { return Math.max(p, f(d)); }, -Infinity);
+};
+
+/**
+ * Returns the index of the maximum value of the specified array. If the
+ * specified array is not an array of numbers, an optional accessor function
+ * <tt>f</tt> can be specified to map the elements to numbers. See
+ * {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the index of the maximum value of the specified array.
+ */
+pv.max.index = function(array, f) {
+ if (!f) f = pv.identity;
+ var maxi = -1, maxx = -Infinity;
+ for (var i = 0; i < array.length; i++) {
+ var x = f(array[i]);
+ if (x > maxx) {
+ maxx = x;
+ maxi = i;
+ }
+ }
+ return maxi;
+}
+
+/**
+ * Returns the minimum value of the specified array of numbers. If the specified
+ * array is not an array of numbers, an optional accessor function <tt>f</tt>
+ * can be specified to map the elements to numbers. See {@link #normalize} for
+ * an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the minimum value of the specified array.
+ */
+pv.min = function(array, f) {
+ if (!f) f = pv.identity;
+ return pv.reduce(array, function(p, d) { return Math.min(p, f(d)); }, Infinity);
+};
+
+/**
+ * Returns the index of the minimum value of the specified array. If the
+ * specified array is not an array of numbers, an optional accessor function
+ * <tt>f</tt> can be specified to map the elements to numbers. See
+ * {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the index of the minimum value of the specified array.
+ */
+pv.min.index = function(array, f) {
+ if (!f) f = pv.identity;
+ var mini = -1, minx = Infinity;
+ for (var i = 0; i < array.length; i++) {
+ var x = f(array[i]);
+ if (x < minx) {
+ minx = x;
+ mini = i;
+ }
+ }
+ return mini;
+}
+
+/**
+ * Returns the arithmetic mean, or average, of the specified array. If the
+ * specified array is not an array of numbers, an optional accessor function
+ * <tt>f</tt> can be specified to map the elements to numbers. See
+ * {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the mean of the specified array.
+ */
+pv.mean = function(array, f) {
+ return pv.sum(array, f) / array.length;
+};
+
+/**
+ * Returns the median of the specified array. If the specified array is not an
+ * array of numbers, an optional accessor function <tt>f</tt> can be specified
+ * to map the elements to numbers. See {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the median of the specified array.
+ */
+pv.median = function(array, f) {
+ if (!f) f = pv.identity;
+ array = array.map(f).sort(function(a, b) { return a - b; });
+ if (array.length % 2) return array[Math.floor(array.length / 2)];
+ var i = array.length / 2;
+ return (array[i - 1] + array[i]) / 2;
+};
+
+if (/\[native code\]/.test(Array.prototype.reduce)) {
+/**
+ * Applies the specified function <tt>f</tt> against an accumulator and each
+ * value of the specified array (from left-ot-right) so as to reduce it to a
+ * single value.
+ *
+ * <p>Array reduce was added in JavaScript 1.8. This implementation uses the native
+ * method if provided; otherwise we use our own implementation derived from the
+ * JavaScript documentation. Note that we don't want to add it to the Array
+ * prototype directly because this breaks certain (bad) for loop idioms.
+ *
+ * @see <a
+ * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/reduce">Array.reduce</a>.
+ * @param {array} array an array.
+ * @param {function} [f] a callback function to execute on each value in the array.
+ * @param [v] the object to use as the first argument to the first callback.
+ * @returns the reduced value.
+ */
+ pv.reduce = function(array, f, v) {
+ var p = Array.prototype;
+ return p.reduce.apply(array, p.slice.call(arguments, 1));
+ };
+} else {
+ pv.reduce = function(array, f, v) {
+ var len = array.length;
+ if (!len && (arguments.length == 2)) {
+ throw new Error();
+ }
+
+ var i = 0;
+ if (arguments.length < 3) {
+ while (true) {
+ if (i in array) {
+ v = array[i++];
+ break;
+ }
+ if (++i >= len) {
+ throw new Error();
+ }
+ }
+ }
+
+ for (; i < len; i++) {
+ if (i in array) {
+ v = f.call(null, v, array[i], i, array);
+ }
+ }
+ return v;
+ };
+};
+
+/**
+ * Returns a map constructed from the specified <tt>keys</tt>, using the function
+ * <tt>f</tt> to compute the value for each key. The arguments to the value
+ * function are the same as those used in the built-in array <tt>map</tt>
+ * function: the key, the index, and the array itself. The callback is invoked
+ * only for indexes of the array which have assigned values; it is not invoked
+ * for indexes which have been deleted or which have never been assigned values.
+ *
+ * <p>For example, this expression creates a map from strings to string length:
+ *
+ * <pre>pv.dict(["one", "three", "seventeen"], function(s) s.length)</pre>
+ *
+ * The returned value is <tt>{one: 3, three: 5, seventeen: 9}</tt>.
+ *
+ * @see <a
+ * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Array/map">Array.map</a>.
+ * @param {array} keys an array.
+ * @param {function} f a value function.
+ * @returns a map from keys to values.
+ */
+pv.dict = function(keys, f) {
+ var m = {};
+ for (var i = 0; i < keys.length; i++) {
+ if (i in keys) {
+ var k = keys[i];
+ m[k] = f.call(null, k, i, keys);
+ }
+ }
+ return m;
+};
+
+/**
+ * Returns a permutation of the specified array, using the specified array of
+ * indexes. The returned array contains the corresponding element in
+ * <tt>array</tt> for each index in <tt>indexes</tt>, in order. For example,
+ *
+ * <pre>pv.permute(["a", "b", "c"], [1, 2, 0])</pre>
+ *
+ * returns <tt>["b", "c", "a"]</tt>. It is acceptable for the array of indexes
+ * to be a different length from the array of elements, and for indexes to be
+ * duplicated or omitted. The optional accessor function <tt>f</tt> can be used
+ * to perform a simultaneous mapping of the array elements.
+ *
+ * @param {array} array an array.
+ * @param {number[]} indexes an array of indexes into <tt>array</tt>.
+ * @param {function} [f] an optional accessor function.
+ * @returns {array} an array of elements from <tt>array</tt>; a permutation.
+ */
+pv.permute = function(array, indexes, f) {
+ if (!f) f = pv.identity;
+ var p = new Array(indexes.length);
+ indexes.forEach(function(j, i) { p[i] = f(array[j]); });
+ return p;
+};
+
+/**
+ * Returns a map from key to index for the specified <tt>keys</tt> array. For
+ * example,
+ *
+ * <pre>pv.numerate(["a", "b", "c"])</pre>
+ *
+ * returns <tt>{a: 0, b: 1, c: 2}</tt>. Note that since JavaScript maps only
+ * support string keys, <tt>keys</tt> must contain strings, or other values that
+ * naturally map to distinct string values. Alternatively, an optional accessor
+ * function <tt>f</tt> can be specified to compute the string key for the given
+ * element.
+ *
+ * @param {array} keys an array, usually of string keys.
+ * @param {function} [f] an optional key function.
+ * @returns a map from key to index.
+ */
+pv.numerate = function(keys, f) {
+ if (!f) f = pv.identity;
+ var map = {};
+ keys.forEach(function(x, i) { map[f(x)] = i; });
+ return map;
+};
+
+/**
+ * The comparator function for natural order. This can be used in conjunction with
+ * the built-in array <tt>sort</tt> method to sort elements by their natural
+ * order, ascending. Note that if no comparator function is specified to the
+ * built-in <tt>sort</tt> method, the default order is lexicographic, <i>not</i>
+ * natural!
+ *
+ * @see <a
+ * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Array/sort">Array.sort</a>.
+ * @param a an element to compare.
+ * @param b an element to compare.
+ * @returns {number} negative if a &lt; b; positive if a &gt; b; otherwise 0.
+ */
+pv.naturalOrder = function(a, b) {
+ return (a < b) ? -1 : ((a > b) ? 1 : 0);
+};
+
+/**
+ * The comparator function for reverse natural order. This can be used in
+ * conjunction with the built-in array <tt>sort</tt> method to sort elements by
+ * their natural order, descending. Note that if no comparator function is
+ * specified to the built-in <tt>sort</tt> method, the default order is
+ * lexicographic, <i>not</i> natural!
+ *
+ * @see #naturalOrder
+ * @param a an element to compare.
+ * @param b an element to compare.
+ * @returns {number} negative if a &lt; b; positive if a &gt; b; otherwise 0.
+ */
+pv.reverseOrder = function(b, a) {
+ return (a < b) ? -1 : ((a > b) ? 1 : 0);
+};
+
+/** @namespace Namespace constants for SVG, XMLNS, and XLINK. */
+pv.ns = {
+ /**
+ * The SVG namespace, "http://www.w3.org/2000/svg".
+ *
+ * @type string
+ */
+ svg: "http://www.w3.org/2000/svg",
+
+ /**
+ * The XMLNS namespace, "http://www.w3.org/2000/xmlns".
+ *
+ * @type string
+ */
+ xmlns: "http://www.w3.org/2000/xmlns",
+
+ /**
+ * The XLINK namespace, "http://www.w3.org/1999/xlink".
+ *
+ * @type string
+ */
+ xlink: "http://www.w3.org/1999/xlink"
+};
+
+/** @namespace Protovis major and minor version numbers. */
+pv.version = {
+ /**
+ * The major version number.
+ *
+ * @type number
+ */
+ major: 2,
+
+ /**
+ * The minor version number.
+ *
+ * @type number
+ */
+ minor: 6
+};
+/**
+ * Returns the {@link pv.Color} for the specified color format string. Colors
+ * may have an associated opacity, or alpha channel. Color formats are specified
+ * by CSS Color Modular Level 3, using either in RGB or HSL color space. For
+ * example:<ul>
+ *
+ * <li>#f00 // #rgb
+ * <li>#ff0000 // #rrggbb
+ * <li>rgb(255, 0, 0)
+ * <li>rgb(100%, 0%, 0%)
+ * <li>hsl(0, 100%, 50%)
+ * <li>rgba(0, 0, 255, 0.5)
+ * <li>hsla(120, 100%, 50%, 1)
+ *
+ * </ul>The SVG 1.0 color keywords names are also supported, such as "aliceblue"
+ * and yellowgreen". The "transparent" keyword is also supported for a
+ * fully-transparent color.
+ *
+ * <p>If the <tt>format</tt> argument is already an instance of <tt>Color</tt>,
+ * the argument is returned with no further processing.
+ *
+ * @param {string} format the color specification string, e.g., "#f00".
+ * @returns {pv.Color} the corresponding <tt>Color</tt>.
+ * @see <a href="http://www.w3.org/TR/SVG/types.html#ColorKeywords">SVG color keywords</a>.
+ * @see <a href="http://www.w3.org/TR/css3-color/">CSS3 color module</a>.
+ */
+pv.color = function(format) {
+ if (!format || (format == "transparent")) {
+ return new pv.Color.Rgb(0, 0, 0, 0);
+ }
+ if (format instanceof pv.Color) {
+ return format;
+ }
+
+ /* Handle hsl, rgb. */
+ var m1 = /([a-z]+)\((.*)\)/i.exec(format);
+ if (m1) {
+ var m2 = m1[2].split(","), a = 1;
+ switch (m1[1]) {
+ case "hsla":
+ case "rgba": {
+ a = parseFloat(m2[3]);
+ break;
+ }
+ }
+ switch (m1[1]) {
+ case "hsla":
+ case "hsl": {
+ var h = parseFloat(m2[0]), // degrees
+ s = parseFloat(m2[1]) / 100, // percentage
+ l = parseFloat(m2[2]) / 100; // percentage
+ return (new pv.Color.Hsl(h, s, l, a)).rgb();
+ }
+ case "rgba":
+ case "rgb": {
+ let parse = function(c) { // either integer or percentage
+ let f = parseFloat(c);
+ return (c[c.length - 1] == '%') ? Math.round(f * 2.55) : f;
+ };
+ let r = parse(m2[0]), g = parse(m2[1]), b = parse(m2[2]);
+ return new pv.Color.Rgb(r, g, b, a);
+ }
+ }
+ }
+
+ /* Otherwise, assume named colors. TODO allow lazy conversion to RGB. */
+ return new pv.Color(format, 1);
+};
+
+/**
+ * Constructs a color with the specified color format string and opacity. This
+ * constructor should not be invoked directly; use {@link pv.color} instead.
+ *
+ * @class Represents an abstract (possibly translucent) color. The color is
+ * divided into two parts: the <tt>color</tt> attribute, an opaque color format
+ * string, and the <tt>opacity</tt> attribute, a float in [0, 1]. The color
+ * space is dependent on the implementing class; all colors support the
+ * {@link #rgb} method to convert to RGB color space for interpolation.
+ *
+ * <p>See also the <a href="../../api/Color.html">Color guide</a>.
+ *
+ * @param {string} color an opaque color format string, such as "#f00".
+ * @param {number} opacity the opacity, in [0,1].
+ * @see pv.color
+ */
+pv.Color = function(color, opacity) {
+ /**
+ * An opaque color format string, such as "#f00".
+ *
+ * @type string
+ * @see <a href="http://www.w3.org/TR/SVG/types.html#ColorKeywords">SVG color keywords</a>.
+ * @see <a href="http://www.w3.org/TR/css3-color/">CSS3 color module</a>.
+ */
+ this.color = color;
+
+ /**
+ * The opacity, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.opacity = opacity;
+};
+
+/**
+ * Constructs a new RGB color with the specified channel values.
+ *
+ * @class Represents a color in RGB space.
+ *
+ * @param {number} r the red channel, an integer in [0,255].
+ * @param {number} g the green channel, an integer in [0,255].
+ * @param {number} b the blue channel, an integer in [0,255].
+ * @param {number} a the alpha channel, a float in [0,1].
+ * @extends pv.Color
+ */
+pv.Color.Rgb = function(r, g, b, a) {
+ pv.Color.call(this, a ? ("rgb(" + r + "," + g + "," + b + ")") : "none", a);
+
+ /**
+ * The red channel, an integer in [0, 255].
+ *
+ * @type number
+ */
+ this.r = r;
+
+ /**
+ * The green channel, an integer in [0, 255].
+ *
+ * @type number
+ */
+ this.g = g;
+
+ /**
+ * The blue channel, an integer in [0, 255].
+ *
+ * @type number
+ */
+ this.b = b;
+
+ /**
+ * The alpha channel, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.a = a;
+};
+pv.Color.Rgb.prototype = pv.extend(pv.Color);
+
+/**
+ * Returns the RGB color equivalent to this color. This method is abstract and
+ * must be implemented by subclasses.
+ *
+ * @returns {pv.Color.Rgb} an RGB color.
+ * @function
+ * @name pv.Color.prototype.rgb
+ */
+
+/**
+ * Returns this.
+ *
+ * @returns {pv.Color.Rgb} this.
+ */
+pv.Color.Rgb.prototype.rgb = function() { return this; };
+
+/**
+ * Constructs a new HSL color with the specified values.
+ *
+ * @class Represents a color in HSL space.
+ *
+ * @param {number} h the hue, an integer in [0, 360].
+ * @param {number} s the saturation, a float in [0, 1].
+ * @param {number} l the lightness, a float in [0, 1].
+ * @param {number} a the opacity, a float in [0, 1].
+ * @extends pv.Color
+ */
+pv.Color.Hsl = function(h, s, l, a) {
+ pv.Color.call(this, "hsl(" + h + "," + (s * 100) + "%," + (l * 100) + "%)", a);
+
+ /**
+ * The hue, an integer in [0, 360].
+ *
+ * @type number
+ */
+ this.h = h;
+
+ /**
+ * The saturation, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.s = s;
+
+ /**
+ * The lightness, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.l = l;
+
+ /**
+ * The opacity, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.a = a;
+};
+pv.Color.Hsl.prototype = pv.extend(pv.Color);
+
+/**
+ * Returns the RGB color equivalent to this HSL color.
+ *
+ * @returns {pv.Color.Rgb} an RGB color.
+ */
+pv.Color.Hsl.prototype.rgb = function() {
+ var h = this.h, s = this.s, l = this.l;
+
+ /* Some simple corrections for h, s and l. */
+ h = h % 360; if (h < 0) h += 360;
+ s = Math.max(0, Math.min(s, 1));
+ l = Math.max(0, Math.min(l, 1));
+
+ /* From FvD 13.37 */
+ var m2 = (l < .5) ? (l * (l + s)) : (l + s - l * s);
+ var m1 = 2 * l - m2;
+ if (s == 0) {
+ return new rgb(l, l, l);
+ }
+ function v(h) {
+ if (h > 360) h -= 360;
+ else if (h < 0) h += 360;
+ if (h < 60) return m1 + (m2 - m1) * h / 60;
+ else if (h < 180) return m2;
+ else if (h < 240) return m1 + (m2 - m1) * (240 - h) / 60;
+ return m1;
+ }
+ function vv(h) {
+ return Math.round(v(h) * 255);
+ }
+
+ return new pv.Color.Rgb(vv(h + 120), vv(h), vv(h - 120), this.a);
+};
+/**
+ * Returns a new categorical color encoding using the specified colors. The
+ * arguments to this method are an array of colors; see {@link pv.color}. For
+ * example, to create a categorical color encoding using the <tt>species</tt>
+ * attribute:
+ *
+ * <pre>pv.colors("red", "green", "blue").by(function(d) d.species)</pre>
+ *
+ * The result of this expression can be used as a fill- or stroke-style
+ * property. This assumes that the data's <tt>species</tt> attribute is a
+ * string.
+ *
+ * @returns {pv.Colors} a new categorical color encoding.
+ * @param {string} colors... categorical colors.
+ * @see pv.Colors
+ */
+pv.colors = function() {
+ return pv.Colors(arguments);
+};
+
+/**
+ * Returns a new categorical color encoding using the specified colors. This
+ * constructor is typically not used directly; use {@link pv.colors} instead.
+ *
+ * @class Represents a categorical color encoding using the specified colors.
+ * The returned object can be used as a property function; the appropriate
+ * categorical color will be returned by evaluating the current datum, or
+ * through whatever other means the encoding uses to determine uniqueness, per
+ * the {@link #by} method. The default implementation allocates a distinct color
+ * per {@link pv.Mark#childIndex}.
+ *
+ * @param {string[]} values an array of colors; see {@link pv.color}.
+ * @returns {pv.Colors} a new categorical color encoding.
+ * @see pv.colors
+ */
+pv.Colors = function(values) {
+
+ /**
+ * @ignore Each set of colors has an associated (numeric) ID that is used to
+ * store a cache of assigned colors on the root scene. As unique keys are
+ * discovered, a new color is allocated and assigned to the given key.
+ *
+ * The key function determines how uniqueness is determined. By default,
+ * colors are assigned using the mark's childIndex, such that each new mark
+ * added is given a new color. Note that derived marks will not inherit the
+ * exact color of the prototype, but instead inherit the set of colors.
+ */
+ function colors(keyf) {
+ var id = pv.Colors.count++;
+
+ function color() {
+ var key = keyf.apply(this, this.root.scene.data);
+ var state = this.root.scene.colors;
+ if (!state) this.root.scene.colors = state = {};
+ if (!state[id]) state[id] = { count: 0 };
+ var color = state[id][key];
+ if (color == undefined) {
+ color = state[id][key] = values[state[id].count++ % values.length];
+ }
+ return color;
+ }
+ return color;
+ }
+
+ var c = colors(function() { return this.childIndex; });
+
+ /**
+ * Allows a new set of colors to be derived from the current set using a
+ * different key function. For instance, to color marks using the value of the
+ * field "foo", say:
+ *
+ * <pre>pv.Colors.category10.by(function(d) d.foo)</pre>
+ *
+ * For convenience, "index" and "parent.index" keys are predefined.
+ *
+ * @param {function} v the new key function.
+ * @name pv.Colors.prototype.by
+ * @function
+ * @returns {pv.Colors} a new color scheme
+ */
+ c.by = colors;
+
+ /**
+ * A derivative color encoding using the same colors, but allocating unique
+ * colors based on the mark index.
+ *
+ * @name pv.Colors.prototype.unique
+ * @type pv.Colors
+ */
+ c.unique = c.by(function() { return this.index; });
+
+ /**
+ * A derivative color encoding using the same colors, but allocating unique
+ * colors based on the parent index.
+ *
+ * @name pv.Colors.prototype.parent
+ * @type pv.Colors
+ */
+ c.parent = c.by(function() { return this.parent.index; });
+
+ /**
+ * The underlying array of colors.
+ *
+ * @type string[]
+ * @name pv.Colors.prototype.values
+ */
+ c.values = values;
+
+ return c;
+};
+
+/** @private */
+pv.Colors.count = 0;
+
+/* From Flare. */
+
+/**
+ * A 10-color scheme.
+ *
+ * @type pv.Colors
+ */
+pv.Colors.category10 = pv.colors(
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"
+);
+
+/**
+ * A 20-color scheme.
+ *
+ * @type pv.Colors
+ */
+pv.Colors.category20 = pv.colors(
+ "#1f77b4", "#aec7e8", "#ff7f0e", "#ffbb78", "#2ca02c",
+ "#98df8a", "#d62728", "#ff9896", "#9467bd", "#c5b0d5",
+ "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", "#7f7f7f",
+ "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"
+);
+
+/**
+ * An alternative 19-color scheme.
+ *
+ * @type pv.Colors
+ */
+pv.Colors.category19 = pv.colors(
+ "#9c9ede", "#7375b5", "#4a5584", "#cedb9c", "#b5cf6b",
+ "#8ca252", "#637939", "#e7cb94", "#e7ba52", "#bd9e39",
+ "#8c6d31", "#e7969c", "#d6616b", "#ad494a", "#843c39",
+ "#de9ed6", "#ce6dbd", "#a55194", "#7b4173"
+);
+// TODO support arbitrary color stops
+
+/**
+ * Returns a linear color ramp from the specified <tt>start</tt> color to the
+ * specified <tt>end</tt> color. The color arguments may be specified either as
+ * <tt>string</tt>s or as {@link pv.Color}s.
+ *
+ * @param {string} start the start color; may be a <tt>pv.Color</tt>.
+ * @param {string} end the end color; may be a <tt>pv.Color</tt>.
+ * @returns {pv.Ramp} a color ramp from <tt>start</tt> to <tt>end</tt>.
+ */
+pv.ramp = function(start, end) {
+ return pv.Ramp(pv.color(start), pv.color(end));
+};
+
+/**
+ * Constructs a ramp from the specified start color to the specified end
+ * color. This constructor should not be invoked directly; use {@link pv.ramp}
+ * instead.
+ *
+ * @class Represents a linear color ramp from the specified <tt>start</tt> color
+ * to the specified <tt>end</tt> color. Ramps can be used as property functions;
+ * their behavior is equivalent to calling {@link #value}, passing in the
+ * current datum as the sample point. If the data is <i>not</i> a float in [0,
+ * 1], the {@link #by} method can be used to map the datum to a suitable sample
+ * point.
+ *
+ * @extends Function
+ * @param {pv.Color} start the start color.
+ * @param {pv.Color} end the end color.
+ * @see pv.ramp
+ */
+pv.Ramp = function(start, end) {
+ var s = start.rgb(), e = end.rgb(), f = pv.identity;
+
+ /** @ignore Property function. */
+ function ramp() {
+ return value(f.apply(this, this.root.scene.data));
+ }
+
+ /** @ignore Interpolates between start and end at value aT in [0,1]. */
+ function value(aT) {
+ var t = Math.max(0, Math.min(1, aT));
+ var a = s.a * (1 - t) + e.a * t;
+ if (a < 1e-5) a = 0; // avoid scientific notation
+ return (s.a == 0) ? new pv.Color.Rgb(e.r, e.g, e.b, a)
+ : ((e.a == 0) ? new pv.Color.Rgb(s.r, s.g, s.b, a)
+ : new pv.Color.Rgb(
+ Math.round(s.r * (1 - t) + e.r * t),
+ Math.round(s.g * (1 - t) + e.g * t),
+ Math.round(s.b * (1 - t) + e.b * t), a));
+ }
+
+ /**
+ * Sets the sample function to be the specified function <tt>v</tt>.
+ *
+ * @param {function} v the new sample function.
+ * @name pv.Ramp.prototype.by
+ * @function
+ * @returns {pv.Ramp} this.
+ */
+ ramp.by = function(v) { f = v; return this; };
+
+ /**
+ * Returns the interpolated color at the specified sample point.
+ *
+ * @param {number} t the sample point in [0, 1].
+ * @name pv.Ramp.prototype.value
+ * @function
+ * @returns {pv.Color.Rgb} the interpolated color.
+ */
+ ramp.value = value;
+
+ return ramp;
+};
+/**
+ * Constructs a new mark with default properties. Marks, with the exception of
+ * the root panel, are not typically constructed directly; instead, they are
+ * added to a panel or an existing mark via {@link pv.Mark#add}.
+ *
+ * @class Represents a data-driven graphical mark. The <tt>Mark</tt> class is
+ * the base class for all graphical marks in Protovis; it does not provide any
+ * specific rendering functionality, but together with {@link Panel} establishes
+ * the core framework.
+ *
+ * <p>Concrete mark types include familiar visual elements such as bars, lines
+ * and labels. Although a bar mark may be used to construct a bar chart, marks
+ * know nothing about charts; it is only through their specification and
+ * composition that charts are produced. These building blocks permit many
+ * combinatorial possibilities.
+ *
+ * <p>Marks are associated with <b>data</b>: a mark is generated once per
+ * associated datum, mapping the datum to visual <b>properties</b> such as
+ * position and color. Thus, a single mark specification represents a set of
+ * visual elements that share the same data and visual encoding. The type of
+ * mark defines the names of properties and their meaning. A property may be
+ * static, ignoring the associated datum and returning a constant; or, it may be
+ * dynamic, derived from the associated datum or index. Such dynamic encodings
+ * can be specified succinctly using anonymous functions. Special properties
+ * called event handlers can be registered to add interactivity.
+ *
+ * <p>While most properties are <i>variable</i>, some mark types, such as lines
+ * and areas, generate a single visual element rather than a distinct visual
+ * element per datum. With these marks, some properties may be <b>fixed</b>.
+ * Fixed properties can vary per mark, but not <i>per datum</i>! These
+ * properties are evaluated solely for the first (0-index) datum, and typically
+ * are specified as a constant. However, it is valid to use a function if the
+ * property varies between panels or is dynamically generated.
+ *
+ * <p>Protovis uses <b>inheritance</b> to simplify the specification of related
+ * marks: a new mark can be derived from an existing mark, inheriting its
+ * properties. The new mark can then override properties to specify new
+ * behavior, potentially in terms of the old behavior. In this way, the old mark
+ * serves as the <b>prototype</b> for the new mark. Most mark types share the
+ * same basic properties for consistency and to facilitate inheritance.
+ *
+ * <p>See also the <a href="../../api/">Protovis guide</a>.
+ */
+pv.Mark = function() {};
+
+/**
+ * Returns the mark type name. Names should be lower case, with words separated
+ * by hyphens. For example, the mark class <tt>FooBar</tt> should return
+ * "foo-bar".
+ *
+ * <p>Note that this method is defined on the constructor, not on the prototype,
+ * and thus is a static method. The constructor is accessible through the
+ * {@link #type} field.
+ *
+ * @returns {string} the mark type name, such as "mark".
+ */
+pv.Mark.toString = function() { return "mark"; };
+
+/**
+ * Defines and registers a property method for the property with the given name.
+ * This method should be called on a mark class prototype to define each exposed
+ * property. (Note this refers to the JavaScript <tt>prototype</tt>, not the
+ * Protovis mark prototype, which is the {@link #proto} field.)
+ *
+ * <p>The created property method supports several modes of invocation: <ol>
+ *
+ * <li>If invoked with a <tt>Function</tt> argument, this function is evaluated
+ * for each associated datum. The return value of the function is used as the
+ * computed property value. The context of the function (<tt>this</tt>) is this
+ * mark. The arguments to the function are the associated data of this mark and
+ * any enclosing panels. For example, a linear encoding of numerical data to
+ * height is specified as
+ *
+ * <pre>m.height(function(d) d * 100);</pre>
+ *
+ * The expression <tt>d * 100</tt> will be evaluated for the height property of
+ * each mark instance. This function is stored in the <tt>$height</tt> field. The
+ * return value of the property method (e.g., <tt>m.height</tt>) is this mark
+ * (<tt>m</tt>)).<p>
+ *
+ * <li>If invoked with a non-function argument, the property is treated as a
+ * constant, and wrapped with an accessor function. This wrapper function is
+ * stored in the equivalent internal (<tt>$</tt>-prefixed) field. The return
+ * value of the property method (e.g., <tt>m.height</tt>) is this mark.<p>
+ *
+ * <li>If invoked from an event handler, the property is set to the specified
+ * value on the current instance (i.e., the instance that triggered the event,
+ * such as a mouse click). In this case, the value should be a constant and not
+ * a function. The return value is this mark. For example, saying
+ *
+ * <pre>this.fillStyle("red").strokeStyle("black");</pre>
+ *
+ * from a "click" event handler will set the fill color to red, and the stroke
+ * color to black, for any marks that are clicked.<p>
+ *
+ * <li>If invoked with no arguments, the computed property value for the current
+ * mark instance in the scene graph is returned. This facilitates <i>property
+ * chaining</i>, where one mark's properties are defined in terms of another's.
+ * For example, to offset a mark's location from its prototype, you might say
+ *
+ * <pre>m.top(function() this.proto.top() + 10);</pre>
+ *
+ * Note that the index of the mark being evaluated (in the above example,
+ * <tt>this.proto</tt>) is inherited from the <tt>Mark</tt> class and set by
+ * this mark. So, if the fifth element's top property is being evaluated, the
+ * fifth instance of <tt>this.proto</tt> will similarly be queried for the value
+ * of its top property. If the mark being evaluated has a different number of
+ * instances, or its data is unrelated, the behavior of this method is
+ * undefined. In these cases it may be better to index the <tt>scene</tt>
+ * explicitly to specify the exact instance.
+ *
+ * </ol><p>Property names should follow standard JavaScript method naming
+ * conventions, using lowerCamel-style capitalization.
+ *
+ * <p>In addition to creating the property method, every property is registered
+ * in the {@link #properties} array on the <tt>prototype</tt>. Although this
+ * array is an instance field, it is considered immutable and shared by all
+ * instances of a given mark type. The <tt>properties</tt> array can be queried
+ * to see if a mark type defines a particular property, such as width or height.
+ *
+ * @param {string} name the property name.
+ */
+pv.Mark.prototype.defineProperty = function(name) {
+ if (!this.hasOwnProperty("properties")) {
+ this.properties = (this.properties || []).concat();
+ }
+ this.properties.push(name);
+ this[name] = function(v) {
+ if (arguments.length) {
+ if (this.scene) {
+ this.scene[this.index][name] = v;
+ } else {
+ this["$" + name] = (v instanceof Function) ? v : function() { return v; };
+ }
+ return this;
+ }
+ return this.scene[this.index][name];
+ };
+};
+
+/**
+ * The constructor; the mark type. This mark type may define default property
+ * functions (see {@link #defaults}) that are used if the property is not
+ * overridden by the mark or any of its prototypes.
+ *
+ * @type function
+ */
+pv.Mark.prototype.type = pv.Mark;
+
+/**
+ * The mark prototype, possibly null, from which to inherit property
+ * functions. The mark prototype is not necessarily of the same type as this
+ * mark. Any properties defined on this mark will override properties inherited
+ * either from the prototype or from the type-specific defaults.
+ *
+ * @type pv.Mark
+ */
+pv.Mark.prototype.proto = null;
+
+/**
+ * The enclosing parent panel. The parent panel is generally null only for the
+ * root panel; however, it is possible to create "offscreen" marks that are used
+ * only for inheritance purposes.
+ *
+ * @type pv.Panel
+ */
+pv.Mark.prototype.parent = null;
+
+/**
+ * The child index. -1 if the enclosing parent panel is null; otherwise, the
+ * zero-based index of this mark into the parent panel's <tt>children</tt> array.
+ *
+ * @type number
+ */
+pv.Mark.prototype.childIndex = -1;
+
+/**
+ * The mark index. The value of this field depends on which instance (i.e.,
+ * which element of the data array) is currently being evaluated. During the
+ * build phase, the index is incremented over each datum; when handling events,
+ * the index is set to the instance that triggered the event.
+ *
+ * @type number
+ */
+pv.Mark.prototype.index = -1;
+
+/**
+ * The scene graph. The scene graph is an array of objects; each object (or
+ * "node") corresponds to an instance of this mark and an element in the data
+ * array. The scene graph can be traversed to lookup previously-evaluated
+ * properties.
+ *
+ * <p>For instance, consider a stacked area chart. The bottom property of the
+ * area can be defined using the <i>cousin</i> instance, which is the current
+ * area instance in the previous instantiation of the parent panel. In this
+ * sample code,
+ *
+ * <pre>new pv.Panel()
+ * .width(150).height(150)
+ * .add(pv.Panel)
+ * .data([[1, 1.2, 1.7, 1.5, 1.7],
+ * [.5, 1, .8, 1.1, 1.3],
+ * [.2, .5, .8, .9, 1]])
+ * .add(pv.Area)
+ * .data(function(d) d)
+ * .bottom(function() {
+ * var c = this.cousin();
+ * return c ? (c.bottom + c.height) : 0;
+ * })
+ * .height(function(d) d * 40)
+ * .left(function() this.index * 35)
+ * .root.render();</pre>
+ *
+ * the bottom property is computed based on the upper edge of the corresponding
+ * datum in the previous series. The area's parent panel is instantiated once
+ * per series, so the cousin refers to the previous (below) area mark. (Note
+ * that the position of the upper edge is not the same as the top property,
+ * which refers to the top margin: the distance from the top edge of the panel
+ * to the top edge of the mark.)
+ *
+ * @see #first
+ * @see #last
+ * @see #sibling
+ * @see #cousin
+ */
+pv.Mark.prototype.scene = null;
+
+/**
+ * The root parent panel. This may be null for "offscreen" marks that are
+ * created for inheritance purposes only.
+ *
+ * @type pv.Panel
+ */
+pv.Mark.prototype.root = null;
+
+/**
+ * The data property; an array of objects. The size of the array determines the
+ * number of marks that will be instantiated; each element in the array will be
+ * passed to property functions to compute the property values. Typically, the
+ * data property is specified as a constant array, such as
+ *
+ * <pre>m.data([1, 2, 3, 4, 5]);</pre>
+ *
+ * However, it is perfectly acceptable to define the data property as a
+ * function. This function might compute the data dynamically, allowing
+ * different data to be used per enclosing panel. For instance, in the stacked
+ * area graph example (see {@link #scene}), the data function on the area mark
+ * dereferences each series.
+ *
+ * @type array
+ * @name pv.Mark.prototype.data
+ */
+pv.Mark.prototype.defineProperty("data");
+
+/**
+ * The visible property; a boolean determining whether or not the mark instance
+ * is visible. If a mark instance is not visible, its other properties will not
+ * be evaluated. Similarly, for panels no child marks will be rendered.
+ *
+ * @type boolean
+ * @name pv.Mark.prototype.visible
+ */
+pv.Mark.prototype.defineProperty("visible");
+
+/**
+ * The left margin; the distance, in pixels, between the left edge of the
+ * enclosing panel and the left edge of this mark. Note that in some cases this
+ * property may be redundant with the right property, or with the conjunction of
+ * right and width.
+ *
+ * @type number
+ * @name pv.Mark.prototype.left
+ */
+pv.Mark.prototype.defineProperty("left");
+
+/**
+ * The right margin; the distance, in pixels, between the right edge of the
+ * enclosing panel and the right edge of this mark. Note that in some cases this
+ * property may be redundant with the left property, or with the conjunction of
+ * left and width.
+ *
+ * @type number
+ * @name pv.Mark.prototype.right
+ */
+pv.Mark.prototype.defineProperty("right");
+
+/**
+ * The top margin; the distance, in pixels, between the top edge of the
+ * enclosing panel and the top edge of this mark. Note that in some cases this
+ * property may be redundant with the bottom property, or with the conjunction
+ * of bottom and height.
+ *
+ * @type number
+ * @name pv.Mark.prototype.top
+ */
+pv.Mark.prototype.defineProperty("top");
+
+/**
+ * The bottom margin; the distance, in pixels, between the bottom edge of the
+ * enclosing panel and the bottom edge of this mark. Note that in some cases
+ * this property may be redundant with the top property, or with the conjunction
+ * of top and height.
+ *
+ * @type number
+ * @name pv.Mark.prototype.bottom
+ */
+pv.Mark.prototype.defineProperty("bottom");
+
+/**
+ * The cursor property; corresponds to the CSS cursor property. This is
+ * typically used in conjunction with event handlers to indicate interactivity.
+ *
+ * @type string
+ * @name pv.Mark.prototype.cursor
+ * @see <a href="http://www.w3.org/TR/CSS2/ui.html#propdef-cursor">CSS2 cursor</a>.
+ */
+pv.Mark.prototype.defineProperty("cursor");
+
+/**
+ * The title property; corresponds to the HTML/SVG title property, allowing the
+ * general of simple plain text tooltips.
+ *
+ * @type string
+ * @name pv.Mark.prototype.title
+ */
+pv.Mark.prototype.defineProperty("title");
+
+/**
+ * Default properties for all mark types. By default, the data array is a single
+ * null element; if the data property is not specified, this causes each mark to
+ * be instantiated as a singleton. The visible property is true by default.
+ *
+ * @type pv.Mark
+ */
+pv.Mark.defaults = new pv.Mark()
+ .data([null])
+ .visible(true);
+
+/**
+ * Sets the prototype of this mark to the specified mark. Any properties not
+ * defined on this mark may be inherited from the specified prototype mark, or
+ * its prototype, and so on. The prototype mark need not be the same type of
+ * mark as this mark. (Note that for inheritance to be useful, properties with
+ * the same name on different mark types should have equivalent meaning.)
+ *
+ * @param {pv.Mark} proto the new prototype.
+ * @return {pv.Mark} this mark.
+ */
+pv.Mark.prototype.extend = function(proto) {
+ this.proto = proto;
+ return this;
+};
+
+/**
+ * Adds a new mark of the specified type to the enclosing parent panel, whilst
+ * simultaneously setting the prototype of the new mark to be this mark.
+ *
+ * @param {function} type the type of mark to add; a constructor, such as
+ * <tt>pv.Bar</tt>.
+ * @return {pv.Mark} the new mark.
+ */
+pv.Mark.prototype.add = function(type) {
+ return this.parent.add(type).extend(this);
+};
+
+/**
+ * Constructs a new mark anchor with default properties.
+ *
+ * @class Represents an anchor on a given mark. An anchor is itself a mark, but
+ * without a visual representation. It serves only to provide useful default
+ * properties that can be inherited by other marks. Each type of mark can define
+ * any number of named anchors for convenience. If the concrete mark type does
+ * not define an anchor implementation specifically, one will be inherited from
+ * the mark's parent class.
+ *
+ * <p>For example, the bar mark provides anchors for its four sides: left,
+ * right, top and bottom. Adding a label to the top anchor of a bar,
+ *
+ * <pre>bar.anchor("top").add(pv.Label);</pre>
+ *
+ * will render a text label on the top edge of the bar; the top anchor defines
+ * the appropriate position properties (top and left), as well as text-rendering
+ * properties for convenience (textAlign and textBaseline).
+ *
+ * @extends pv.Mark
+ */
+pv.Mark.Anchor = function() {
+ pv.Mark.call(this);
+};
+pv.Mark.Anchor.prototype = pv.extend(pv.Mark);
+
+/**
+ * The anchor name. The set of supported anchor names is dependent on the
+ * concrete mark type; see the mark type for details. For example, bars support
+ * left, right, top and bottom anchors.
+ *
+ * <p>While anchor names are typically constants, the anchor name is a true
+ * property, which means you can specify a function to compute the anchor name
+ * dynamically. For instance, if you wanted to alternate top and bottom anchors,
+ * saying
+ *
+ * <pre>m.anchor(function() (this.index % 2) ? "top" : "bottom").add(pv.Dot);</pre>
+ *
+ * would have the desired effect.
+ *
+ * @type string
+ * @name pv.Mark.Anchor.prototype.name
+ */
+pv.Mark.Anchor.prototype.defineProperty("name");
+
+/**
+ * Returns an anchor with the specified name. While anchor names are typically
+ * constants, the anchor name is a true property, which means you can specify a
+ * function to compute the anchor name dynamically. See the
+ * {@link pv.Mark.Anchor#name} property for details.
+ *
+ * @param {string} name the anchor name; either a string or a property function.
+ * @returns {pv.Mark.Anchor} the new anchor.
+ */
+pv.Mark.prototype.anchor = function(name) {
+ var anchorType = this.type;
+ while (!anchorType.Anchor) {
+ anchorType = anchorType.defaults.proto.type;
+ }
+ var anchor = new anchorType.Anchor().extend(this).name(name);
+ anchor.parent = this.parent;
+ anchor.type = this.type;
+ return anchor;
+};
+
+/**
+ * Returns the anchor target of this mark, if it is derived from an anchor;
+ * otherwise returns null. For example, if a label is derived from a bar anchor,
+ *
+ * <pre>bar.anchor("top").add(pv.Label);</pre>
+ *
+ * then property functions on the label can refer to the bar via the
+ * <tt>anchorTarget</tt> method. This method is also useful for mark types
+ * defining properties on custom anchors.
+ *
+ * @returns {pv.Mark} the anchor target of this mark; possibly null.
+ */
+pv.Mark.prototype.anchorTarget = function() {
+ var target = this;
+ while (!(target instanceof pv.Mark.Anchor)) {
+ target = target.proto;
+ if (!target) return null;
+ }
+ return target.proto;
+};
+
+/**
+ * Returns the first instance of this mark in the scene graph. This method can
+ * only be called when the mark is bound to the scene graph (for example, from
+ * an event handler, or within a property function).
+ *
+ * @returns a node in the scene graph.
+ */
+pv.Mark.prototype.first = function() {
+ return this.scene[0];
+};
+
+/**
+ * Returns the last instance of this mark in the scene graph. This method can
+ * only be called when the mark is bound to the scene graph (for example, from
+ * an event handler, or within a property function). In addition, note that mark
+ * instances are built sequentially, so the last instance of this mark may not
+ * yet be constructed.
+ *
+ * @returns a node in the scene graph.
+ */
+pv.Mark.prototype.last = function() {
+ return this.scene[this.scene.length - 1];
+};
+
+/**
+ * Returns the previous instance of this mark in the scene graph, or null if
+ * this is the first instance.
+ *
+ * @returns a node in the scene graph, or null.
+ */
+pv.Mark.prototype.sibling = function() {
+ return (this.index == 0) ? null : this.scene[this.index - 1];
+};
+
+/**
+ * Returns the current instance in the scene graph of this mark, in the previous
+ * instance of the enclsoing parent panel. May return null if this instance
+ * could not be found.
+ *
+ * @returns a node in the scene graph, or null.
+ */
+pv.Mark.prototype.cousin = function() {
+ var p = this.parent, s = p && p.sibling();
+ return (s && s.children) ? s.children[this.childIndex][this.index] : null;
+};
+
+/**
+ * Renders this mark, including recursively rendering all child marks if this is
+ * a panel. Rendering consists of two phases: <b>build</b> and <b>update</b>. In
+ * the future, the update phase could conceivably be decoupled to allow
+ * different rendering engines. Similarly, future work is needed to allow
+ * dynamic rebuilding based on interaction. (For example, dynamic expansion of a
+ * tree visualization.)
+ *
+ * <p>In the build phase (see {@link #build}), all properties are evaluated, and
+ * the scene graph is generated. However, nothing is rendered.
+ *
+ * <p>In the update phase (see {@link #update}), the mark is rendered by
+ * creating and updating elements and attributes in the SVG image. No properties
+ * are evaluated during the update phase; instead the values computed previously
+ * in the build phase are simply translated into SVG.
+ */
+pv.Mark.prototype.render = function() {
+ this.build();
+ this.update();
+};
+
+/**
+ * Evaluates properties and computes implied properties. Properties are stored
+ * in the {@link #scene} array for each instance of this mark.
+ *
+ * <p>As marks are built recursively, the {@link #index} property is updated to
+ * match the current index into the data array for each mark. Note that the
+ * index property is only set for the mark currently being built and its
+ * enclosing parent panels. The index property for other marks is unset, but is
+ * inherited from the global <tt>Mark</tt> class prototype. This allows mark
+ * properties to refer to properties on other marks <i>in the same panel</i>
+ * conveniently; however, in general it is better to reference mark instances
+ * specifically through the scene graph rather than depending on the magical
+ * behavior of {@link #index}.
+ *
+ * <p>The root scene array has a special property, <tt>data</tt>, which stores
+ * the current data stack. The first element in this stack is the current datum,
+ * followed by the datum of the enclosing parent panel, and so on. The data
+ * stack should not be accessed directly; instead, property functions are passed
+ * the current data stack as arguments.
+ *
+ * <p>The evaluation of the <tt>data</tt> and <tt>visible</tt> properties is
+ * special. The <tt>data</tt> property is evaluated first; unlike the other
+ * properties, the data stack is from the parent panel, rather than the current
+ * mark, since the data is not defined until the data property is evaluated.
+ * The <tt>visible</tt> property is subsequently evaluated for each instance;
+ * only if true will the {@link #buildInstance} method be called, evaluating
+ * other properties and recursively building the scene graph.
+ *
+ * <p>If this mark is being re-built, any old instances of this mark that no
+ * longer exist (because the new data array contains fewer elements) will be
+ * cleared using {@link #clearInstance}.
+ *
+ * @param parent the instance of the parent panel from the scene graph.
+ */
+pv.Mark.prototype.build = function(parent) {
+ if (!this.scene) {
+ this.scene = [];
+ if (!this.parent) {
+ this.scene.data = [];
+ }
+ }
+
+ var data = this.get("data");
+ var stack = this.root.scene.data;
+ stack.unshift(null);
+ this.index = -1;
+
+ this.$$data = data; // XXX
+
+ for (var i = 0, d; i < data.length; i++) {
+ pv.Mark.prototype.index = ++this.index;
+ var s = {};
+
+ /*
+ * This is a bit confusing and could be cleaned up. This "scene" stores the
+ * previous scene graph; we want to reuse SVG elements that were created
+ * previously rather than recreating them, so we extract them. We also want
+ * to reuse SVG child elements as well.
+ */
+ if (this.scene[this.index]) {
+ s.svg = this.scene[this.index].svg;
+ s.children = this.scene[this.index].children;
+ }
+ this.scene[this.index] = s;
+
+ s.index = i;
+ s.data = stack[0] = data[i];
+ s.parent = parent;
+ s.visible = this.get("visible");
+ if (s.visible) {
+ this.buildInstance(s);
+ }
+ }
+ stack.shift();
+ delete this.index;
+ pv.Mark.prototype.index = -1;
+
+ /* Clear any old instances from the scene. */
+ for (var i = data.length; i < this.scene.length; i++) {
+ this.clearInstance(this.scene[i]);
+ }
+ this.scene.length = data.length;
+
+ return this;
+};
+
+/**
+ * Removes the specified mark instance from the SVG image. This method depends
+ * on the <tt>svg</tt> property of the scene graph node. If the specified mark
+ * instance was not present in the SVG image (for example, because it was not
+ * visible), this method has no effect.
+ *
+ * @param s a node in the scene graph; the instance of the mark to clear.
+ */
+pv.Mark.prototype.clearInstance = function(s) {
+ if (s.svg) {
+ s.parent.svg.removeChild(s.svg);
+ }
+};
+
+/**
+ * Evaluates all of the properties for this mark for the specified instance
+ * <tt>s</tt> in the scene graph. The set of properties to evaluate is retrieved
+ * from the {@link #properties} array for this mark type (see {@link #type}).
+ * After these properties are evaluated, any <b>implied</b> properties may be
+ * computed by the mark and set on the scene graph; see {@link #buildImplied}.
+ *
+ * <p>For panels, this method recursively builds the scene graph for all child
+ * marks as well. In general, this method should not need to be overridden by
+ * concrete mark types.
+ *
+ * @param s a node in the scene graph; the instance of the mark to build.
+ */
+pv.Mark.prototype.buildInstance = function(s) {
+ var p = this.type.prototype;
+ for (var i = 0; i < p.properties.length; i++) {
+ var name = p.properties[i];
+ if (!(name in s)) {
+ s[name] = this.get(name);
+ }
+ }
+ this.buildImplied(s);
+};
+
+/**
+ * Computes the implied properties for this mark for the specified instance
+ * <tt>s</tt> in the scene graph. Implied properties are those with dependencies
+ * on multiple other properties; for example, the width property may be implied
+ * if the left and right properties are set. This method can be overridden by
+ * concrete mark types to define new implied properties, if necessary.
+ *
+ * <p>The default implementation computes the implied CSS box model properties.
+ * The prioritization of redundant properties is as follows:<ol>
+ *
+ * <li>If the <tt>width</tt> property is not specified (i.e., null), its value is
+ * the width of the parent panel, minus this mark's left and right margins; the
+ * left and right margins are zero if not specified.
+ *
+ * <li>Otherwise, if the <tt>right</tt> margin is not specified, its value is the
+ * width of the parent panel, minus this mark's width and left margin; the left
+ * margin is zero if not specified.
+ *
+ * <li>Otherwise, if the <tt>left</tt> property is not specified, its value is
+ * the width of the parent panel, minus this mark's width and the right margin.
+ *
+ * </ol>This prioritization is then duplicated for the <tt>height</tt>,
+ * <tt>bottom</tt> and <tt>top</tt> properties, respectively.
+ *
+ * @param s a node in the scene graph; the instance of the mark to build.
+ */
+pv.Mark.prototype.buildImplied = function(s) {
+ var l = s.left;
+ var r = s.right;
+ var t = s.top;
+ var b = s.bottom;
+
+ /* Assume width and height are zero if not supported by this mark type. */
+ var p = this.type.prototype;
+ var w = p.width ? s.width : 0;
+ var h = p.height ? s.height : 0;
+
+ /* Compute implied width, right and left. */
+ var width = s.parent ? s.parent.width : 0;
+ if (w == null) {
+ w = width - (r = r || 0) - (l = l || 0);
+ } else if (r == null) {
+ r = width - w - (l = l || 0);
+ } else if (l == null) {
+ l = width - w - (r = r || 0);
+ }
+
+ /* Compute implied height, bottom and top. */
+ var height = s.parent ? s.parent.height : 0;
+ if (h == null) {
+ h = height - (t = t || 0) - (b = b || 0);
+ } else if (b == null) {
+ b = height - h - (t = t || 0);
+ } else if (t == null) {
+ t = height - h - (b = b || 0);
+ }
+
+ s.left = l;
+ s.right = r;
+ s.top = t;
+ s.bottom = b;
+
+ /* Only set width and height if they are supported by this mark type. */
+ if (p.width) s.width = w;
+ if (p.height) s.height = h;
+};
+
+var property; // XXX
+
+/**
+ * Evaluates the property function with the specified name for the current data
+ * stack. The data stack, <tt>this.root.scene.data</tt>, contains the current
+ * datum, followed by the datum for the enclosing panel, and so on.
+ *
+ * <p>This method first finds the implementing property function by querying the
+ * current mark. If the current mark does not define the property function, the
+ * prototype mark is queried, and so on. If none of the mark prototypes define a
+ * property function with the given name, the type default function is used. If
+ * no default function is provided, this method returns null.
+ *
+ * <p>The context of the property function is <tt>this</tt> instance (i.e., the
+ * leaf-level mark), rather than whatever mark defined the property function.
+ * Because of this behavior, a property function may be called on an object of a
+ * different "class" (e.g., a Dot inheriting the fill style from a Line). Also
+ * note that properties are not inherited statically; inheritance happens at the
+ * property function / mark level, not per property value / mark instance. Thus,
+ * even if a Dot extends from a Line, if the Line's fill style is defined using
+ * a function that generates a random color, the Dot may get a different color.
+ *
+ * @param {string} name the property name.
+ * @returns the evaluated property value.
+ */
+pv.Mark.prototype.get = function(name) {
+ var mark = this;
+ while (!mark["$" + name]) {
+ mark = mark.proto;
+ if (!mark) {
+ mark = this.type.defaults;
+ while (!mark["$" + name]) {
+ mark = mark.proto;
+ if (!mark) {
+ return null;
+ }
+ }
+ break;
+ }
+ }
+ property = name; // XXX
+ return mark["$" + name].apply(this, this.root.scene.data);
+};
+
+/**
+ * Updates the display, propagating property values computed in the build phase
+ * to the SVG image. This method is typically invoked by {@link #render}, but is
+ * also invoked after an event handler is triggered to update the display of a
+ * specific mark.
+ *
+ * @see #event
+ */
+pv.Mark.prototype.update = function() {
+ for (var i = 0; i < this.scene.length; i++) {
+ this.updateInstance(this.scene[i]);
+ }
+};
+
+/**
+ * Updates the display for the specified mark instance <tt>s</tt> in the scene
+ * graph. This implementation handles basic properties for all mark types, such
+ * as visibility, cursor and title tooltip. Concrete mark types should override
+ * this method to specify how marks are rendered.
+ *
+ * @param s a node in the scene graph; the instance of the mark to update.
+ */
+pv.Mark.prototype.updateInstance = function(s) {
+ var that = this, v = s.svg;
+
+ /* visible */
+ if (!s.visible) {
+ if (v) v.setAttribute("display", "none");
+ return;
+ }
+ v.removeAttribute("display");
+
+ /* cursor */
+ if (s.cursor) v.style.cursor = s.cursor;
+
+ /* title (Safari only supports xlink:title on anchor elements) */
+ var p = v.parentNode;
+ if (s.title) {
+ if (!v.$title) {
+ v.$title = document.createElementNS(pv.ns.svg, "a");
+ p.insertBefore(v.$title, v);
+ v.$title.appendChild(v);
+ }
+ v.$title.setAttributeNS(pv.ns.xlink, "title", s.title);
+ } else if (v.$title) {
+ p.insertBefore(v, v.$title);
+ p.removeChild(v.$title);
+ delete v.$title;
+ }
+
+ /* event */
+ function dispatch(type) {
+ return function(e) {
+ /* TODO set full scene stack. */
+ var data = [s.data], p = s;
+ while ((p = p.parent)) {
+ data.push(p.data);
+ }
+ that.index = s.index;
+ that.scene = s.parent.children[that.childIndex];
+ that.events[type].apply(that, data);
+ that.updateInstance(s); // XXX updateInstance, bah!
+ delete that.index;
+ delete that.scene;
+ e.preventDefault();
+ };
+ };
+
+ /* TODO inherit event handlers. */
+ if (!this.events)
+ return;
+ for (var type in this.events) {
+ v["on" + type] = dispatch(type);
+ }
+};
+
+/**
+ * Registers an event handler for the specified event type with this mark. When
+ * an event of the specified type is triggered, the specified handler will be
+ * invoked. The handler is invoked in a similar method to property functions:
+ * the context is <tt>this</tt> mark instance, and the arguments are the full
+ * data stack. Event handlers can use property methods to manipulate the display
+ * properties of the mark:
+ *
+ * <pre>m.event("click", function() this.fillStyle("red"));</pre>
+ *
+ * Alternatively, the external data can be manipulated and the visualization
+ * redrawn:
+ *
+ * <pre>m.event("click", function(d) {
+ * data = all.filter(function(k) k.name == d);
+ * vis.render();
+ * });</pre>
+ *
+ * TODO In the current event handler implementation, only the mark instance that
+ * triggered the event is updated, even if the event handler dirties the rest of
+ * the scene. While this can be ameliorated by explicitly re-rendering, it would
+ * be better and more efficient for the event dispatcher to handle dirtying and
+ * redraw automatically.
+ *
+ * <p>The complete set of event types is defined by SVG; see the reference
+ * below. The set of supported event types is:<ul>
+ *
+ * <li>click
+ * <li>mousedown
+ * <li>mouseup
+ * <li>mouseover
+ * <li>mousemove
+ * <li>mouseout
+ *
+ * </ul>Since Protovis does not specify any concept of focus, it does not
+ * support key events; these should be handled outside the visualization using
+ * standard JavaScript. In the future, support for interaction may be extended
+ * to support additional event types, particularly those most relevant to
+ * interactive visualization, such as selection.
+ *
+ * <p>TODO In the current implementation, event handlers are not inherited from
+ * prototype marks. They must be defined explicitly on each interactive mark. In
+ * addition, only one event handler for a given event type can be defined; when
+ * specifying multiple event handlers for the same type, only the last one will
+ * be used.
+ *
+ * @see <a href="http://www.w3.org/TR/SVGTiny12/interact.html#SVGEvents">SVG events</a>.
+ * @param {string} type the event type.
+ * @param {function} handler the event handler.
+ * @returns {pv.Mark} this.
+ */
+pv.Mark.prototype.event = function(type, handler) {
+ if (!this.events) this.events = {};
+ this.events[type] = handler;
+ return this;
+};
+/**
+ * Constructs a new area mark with default properties. Areas are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents an area mark: the solid area between two series of
+ * connected line segments. Unsurprisingly, areas are used most frequently for
+ * area charts.
+ *
+ * <p>Just as a line represents a polyline, the <tt>Area</tt> mark type
+ * represents a <i>polygon</i>. However, an area is not an arbitrary polygon;
+ * vertices are paired either horizontally or vertically into parallel
+ * <i>spans</i>, and each span corresponds to an associated datum. Either the
+ * width or the height must be specified, but not both; this determines whether
+ * the area is horizontally-oriented or vertically-oriented. Like lines, areas
+ * can be stroked and filled with arbitrary colors.
+ *
+ * <p>See also the <a href="../../api/Area.html">Area guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Area = function() {
+ pv.Mark.call(this);
+};
+pv.Area.prototype = pv.extend(pv.Mark);
+pv.Area.prototype.type = pv.Area;
+
+/**
+ * Returns "area".
+ *
+ * @returns {string} "area".
+ */
+pv.Area.toString = function() { return "area"; };
+
+/**
+ * The width of a given span, in pixels; used for horizontal spans. If the width
+ * is specified, the height property should be 0 (the default). Either the top
+ * or bottom property should be used to space the spans vertically, typically as
+ * a multiple of the index.
+ *
+ * @type number
+ * @name pv.Area.prototype.width
+ */
+pv.Area.prototype.defineProperty("width");
+
+/**
+ * The height of a given span, in pixels; used for vertical spans. If the height
+ * is specified, the width property should be 0 (the default). Either the left
+ * or right property should be used to space the spans horizontally, typically
+ * as a multiple of the index.
+ *
+ * @type number
+ * @name pv.Area.prototype.height
+ */
+pv.Area.prototype.defineProperty("height");
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the perimeter of the area. Unlike the
+ * {@link Line} mark type, the entire perimeter is stroked, rather than just one
+ * edge. The default value of this property is 1.5, but since the default stroke
+ * style is null, area marks are not stroked by default.
+ *
+ * <p>This property is <i>fixed</i>. See {@link pv.Mark}.
+ *
+ * @type number
+ * @name pv.Area.prototype.lineWidth
+ */
+pv.Area.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the perimeter of the area. Unlike the {@link Line} mark type, the
+ * entire perimeter is stroked, rather than just one edge. The default value of
+ * this property is null, meaning areas are not stroked by default.
+ *
+ * <p>This property is <i>fixed</i>. See {@link pv.Mark}.
+ *
+ * @type string
+ * @name pv.Area.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Area.prototype.defineProperty("strokeStyle");
+
+/**
+ * The area fill style; if non-null, the interior of the polygon forming the
+ * area is filled with the specified color. The default value of this property
+ * is a categorical color.
+ *
+ * <p>This property is <i>fixed</i>. See {@link pv.Mark}.
+ *
+ * @type string
+ * @name pv.Area.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Area.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for areas. By default, there is no stroke and the fill
+ * style is a categorical color.
+ *
+ * @type pv.Area
+ */
+pv.Area.defaults = new pv.Area().extend(pv.Mark.defaults)
+ .lineWidth(1.5)
+ .fillStyle(pv.Colors.category20);
+
+/**
+ * Constructs a new area anchor with default properties.
+ *
+ * @class Represents an anchor for an area mark. Areas support five different
+ * anchors:<ul>
+ *
+ * <li>top
+ * <li>left
+ * <li>center
+ * <li>bottom
+ * <li>right
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline). Text is
+ * rendered to appear inside the area polygon.
+ *
+ * <p>To facilitate stacking of areas, the anchors are defined in terms of their
+ * opposite edge. For example, the top anchor defines the bottom property, such
+ * that the area grows upwards; the bottom anchor instead defines the top
+ * property, such that the area grows downwards. Of course, in general it is
+ * more robust to use panels and the cousin accessor to define stacked area
+ * marks; see {@link pv.Mark#scene} for an example.
+ *
+ * @extends pv.Mark.Anchor
+ */
+pv.Area.Anchor = function() {
+ pv.Mark.Anchor.call(this);
+};
+pv.Area.Anchor.prototype = pv.extend(pv.Mark.Anchor);
+pv.Area.Anchor.prototype.type = pv.Area;
+
+/**
+ * The left property; null for "left" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Area.Anchor.prototype.left
+ */ /** @private */
+pv.Area.Anchor.prototype.$left = function() {
+ var area = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return area.left() + area.width() / 2;
+ case "right": return area.left() + area.width();
+ }
+ return null;
+};
+
+/**
+ * The right property; null for "right" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Area.Anchor.prototype.right
+ */ /** @private */
+pv.Area.Anchor.prototype.$right = function() {
+ var area = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return area.right() + area.width() / 2;
+ case "left": return area.right() + area.width();
+ }
+ return null;
+};
+
+/**
+ * The top property; null for "top" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Area.Anchor.prototype.top
+ */ /** @private */
+pv.Area.Anchor.prototype.$top = function() {
+ var area = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return area.top() + area.height() / 2;
+ case "bottom": return area.top() + area.height();
+ }
+ return null;
+};
+
+/**
+ * The bottom property; null for "bottom" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Area.Anchor.prototype.bottom
+ */ /** @private */
+pv.Area.Anchor.prototype.$bottom = function() {
+ var area = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return area.bottom() + area.height() / 2;
+ case "top": return area.bottom() + area.height();
+ }
+ return null;
+};
+
+/**
+ * The text-align property, for horizontal alignment inside the area.
+ *
+ * @type string
+ * @name pv.Area.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Area.Anchor.prototype.$textAlign = function() {
+ switch (this.get("name")) {
+ case "left": return "left";
+ case "bottom":
+ case "top":
+ case "center": return "center";
+ case "right": return "right";
+ }
+ return null;
+};
+
+/**
+ * The text-baseline property, for vertical alignment inside the area.
+ *
+ * @type string
+ * @name pv.Area.Anchor.prototype.textBasline
+ */ /** @private */
+pv.Area.Anchor.prototype.$textBaseline = function() {
+ switch (this.get("name")) {
+ case "right":
+ case "left":
+ case "center": return "middle";
+ case "top": return "top";
+ case "bottom": return "bottom";
+ }
+ return null;
+};
+
+/**
+ * Overrides the default behavior of {@link pv.Mark#buildImplied} such that the
+ * width and height are set to zero if null.
+ *
+ * @param s a node in the scene graph; the instance of the mark to build.
+ */
+pv.Area.prototype.buildImplied = function(s) {
+ if (s.height == null) s.height = 0;
+ if (s.width == null) s.width = 0;
+ pv.Mark.prototype.buildImplied.call(this, s);
+};
+
+/**
+ * Override the default update implementation, since the area mark generates a
+ * single graphical element rather than multiple distinct elements.
+ */
+pv.Area.prototype.update = function() {
+ if (!this.scene.length) return;
+
+ var s = this.scene[0], v = s.svg;
+ if (s.visible) {
+
+ /* Create the <svg:polygon> element, if necessary. */
+ if (!v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "polygon");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* points */
+ var p = "";
+ for (var i = 0; i < this.scene.length; i++) {
+ var si = this.scene[i];
+ p += si.left + "," + si.top + " ";
+ }
+ for (var i = this.scene.length - 1; i >= 0; i--) {
+ var si = this.scene[i];
+ p += (si.left + si.width) + "," + (si.top + si.height) + " ";
+ }
+ v.setAttribute("points", p);
+ }
+
+ this.updateInstance(s);
+};
+
+/**
+ * Updates the display for the (singleton) area instance. The area mark
+ * generates a single graphical element rather than multiple distinct elements.
+ *
+ * <p>TODO Recompute points? For efficiency, the points (the span positions) are
+ * not recomputed, and therefore cannot be updated automatically from event
+ * handlers without an explicit call to rebuild the area.
+ *
+ * @param s a node in the scene graph; the area to update.
+ */
+pv.Area.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+/**
+ * Constructs a new bar mark with default properties. Bars are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a bar: an axis-aligned rectangle that can be stroked and
+ * filled. Bars are used for many chart types, including bar charts, histograms
+ * and Gantt charts. Bars can also be used as decorations, for example to draw a
+ * frame border around a panel; in fact, a panel is a special type (a subclass)
+ * of bar.
+ *
+ * <p>Bars can be positioned in several ways. Most commonly, one of the four
+ * corners is fixed using two margins, and then the width and height properties
+ * determine the extent of the bar relative to this fixed location. For example,
+ * using the bottom and left properties fixes the bottom-left corner; the width
+ * then extends to the right, while the height extends to the top. As an
+ * alternative to the four corners, a bar can be positioned exclusively using
+ * margins; this is convenient as an inset from the containing panel, for
+ * example. See {@link pv.Mark#buildImplied} for details on the prioritization
+ * of redundant positioning properties.
+ *
+ * <p>See also the <a href="../../api/Bar.html">Bar guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Bar = function() {
+ pv.Mark.call(this);
+};
+pv.Bar.prototype = pv.extend(pv.Mark);
+pv.Bar.prototype.type = pv.Bar;
+
+/**
+ * Returns "bar".
+ *
+ * @returns {string} "bar".
+ */
+pv.Bar.toString = function() { return "bar"; };
+
+/**
+ * The width of the bar, in pixels. If the left position is specified, the bar
+ * extends rightward from the left edge; if the right position is specified, the
+ * bar extends leftward from the right edge.
+ *
+ * @type number
+ * @name pv.Bar.prototype.width
+ */
+pv.Bar.prototype.defineProperty("width");
+
+/**
+ * The height of the bar, in pixels. If the bottom position is specified, the
+ * bar extends upward from the bottom edge; if the top position is specified,
+ * the bar extends downward from the top edge.
+ *
+ * @type number
+ * @name pv.Bar.prototype.height
+ */
+pv.Bar.prototype.defineProperty("height");
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the bar's border.
+ *
+ * @type number
+ * @name pv.Bar.prototype.lineWidth
+ */
+pv.Bar.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the bar's border. The default value of this property is null, meaning
+ * bars are not stroked by default.
+ *
+ * @type string
+ * @name pv.Bar.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Bar.prototype.defineProperty("strokeStyle");
+
+/**
+ * The bar fill style; if non-null, the interior of the bar is filled with the
+ * specified color. The default value of this property is a categorical color.
+ *
+ * @type string
+ * @name pv.Bar.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Bar.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for bars. By default, there is no stroke and the fill
+ * style is a categorical color.
+ *
+ * @type pv.Bar
+ */
+pv.Bar.defaults = new pv.Bar().extend(pv.Mark.defaults)
+ .lineWidth(1.5)
+ .fillStyle(pv.Colors.category20);
+
+/**
+ * Constructs a new bar anchor with default properties.
+ *
+ * @class Represents an anchor for a bar mark. Bars support five different
+ * anchors:<ul>
+ *
+ * <li>top
+ * <li>left
+ * <li>center
+ * <li>bottom
+ * <li>right
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline). Text
+ * is rendered to appear inside the bar.
+ *
+ * <p>To facilitate stacking of bars, the anchors are defined in terms of their
+ * opposite edge. For example, the top anchor defines the bottom property, such
+ * that the bar grows upwards; the bottom anchor instead defines the top
+ * property, such that the bar grows downwards. Of course, in general it is more
+ * robust to use panels and the cousin accessor to define stacked bars; see
+ * {@link pv.Mark#scene} for an example.
+ *
+ * <p>Bar anchors also "smartly" specify position properties based on whether
+ * the derived mark type supports the width and height properties. If the
+ * derived mark type does not support these properties (e.g., dots), the
+ * position will be centered on the corresponding edge. Otherwise (e.g., bars),
+ * the position will be in the opposite side.
+ *
+ * @extends pv.Mark.Anchor
+ */
+pv.Bar.Anchor = function() {
+ pv.Mark.Anchor.call(this);
+};
+pv.Bar.Anchor.prototype = pv.extend(pv.Mark.Anchor);
+pv.Bar.Anchor.prototype.type = pv.Bar;
+
+/**
+ * The left property; null for "left" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Bar.Anchor.prototype.left
+ */ /** @private */
+pv.Bar.Anchor.prototype.$left = function() {
+ var bar = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return bar.left() + (this.type.prototype.width ? 0 : (bar.width() / 2));
+ case "right": return bar.left() + bar.width();
+ }
+ return null;
+};
+
+/**
+ * The right property; null for "right" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Bar.Anchor.prototype.right
+ */ /** @private */
+pv.Bar.Anchor.prototype.$right = function() {
+ var bar = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return bar.right() + (this.type.prototype.width ? 0 : (bar.width() / 2));
+ case "left": return bar.right() + bar.width();
+ }
+ return null;
+};
+
+/**
+ * The top property; null for "top" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Bar.Anchor.prototype.top
+ */ /** @private */
+pv.Bar.Anchor.prototype.$top = function() {
+ var bar = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return bar.top() + (this.type.prototype.height ? 0 : (bar.height() / 2));
+ case "bottom": return bar.top() + bar.height();
+ }
+ return null;
+};
+
+/**
+ * The bottom property; null for "bottom" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Bar.Anchor.prototype.bottom
+ */ /** @private */
+pv.Bar.Anchor.prototype.$bottom = function() {
+ var bar = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return bar.bottom() + (this.type.prototype.height ? 0 : (bar.height() / 2));
+ case "top": return bar.bottom() + bar.height();
+ }
+ return null;
+};
+
+/**
+ * The text-align property, for horizontal alignment inside the bar.
+ *
+ * @type string
+ * @name pv.Bar.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Bar.Anchor.prototype.$textAlign = function() {
+ switch (this.get("name")) {
+ case "left": return "left";
+ case "bottom":
+ case "top":
+ case "center": return "center";
+ case "right": return "right";
+ }
+ return null;
+};
+
+/**
+ * The text-baseline property, for vertical alignment inside the bar.
+ *
+ * @type string
+ * @name pv.Bar.Anchor.prototype.textBaseline
+ */ /** @private */
+pv.Bar.Anchor.prototype.$textBaseline = function() {
+ switch (this.get("name")) {
+ case "right":
+ case "left":
+ case "center": return "middle";
+ case "top": return "top";
+ case "bottom": return "bottom";
+ }
+ return null;
+};
+
+/**
+ * Updates the display for the specified bar instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the bar, as
+ * well as positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the bar to update.
+ */
+pv.Bar.prototype.updateInstance = function(s) {
+ var v = s.svg;
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "rect");
+ s.parent.svg.appendChild(v);
+ }
+
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top */
+ v.setAttribute("x", s.left);
+ v.setAttribute("y", s.top);
+
+ /* If width and height are exactly zero, the rect is not stroked! */
+ v.setAttribute("width", Math.max(1E-10, s.width));
+ v.setAttribute("height", Math.max(1E-10, s.height));
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+/**
+ * Constructs a new dot mark with default properties. Dots are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a dot; a dot is simply a sized glyph centered at a given
+ * point that can also be stroked and filled. The <tt>size</tt> property is
+ * proportional to the area of the rendered glyph to encourage meaningful visual
+ * encodings. Dots can visually encode up to eight dimensions of data, though
+ * this may be unwise due to integrality. See {@link pv.Mark#buildImplied} for
+ * details on the prioritization of redundant positioning properties.
+ *
+ * <p>See also the <a href="../../api/Dot.html">Dot guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Dot = function() {
+ pv.Mark.call(this);
+};
+pv.Dot.prototype = pv.extend(pv.Mark);
+pv.Dot.prototype.type = pv.Dot;
+
+/**
+ * Returns "dot".
+ *
+ * @returns {string} "dot".
+ */
+pv.Dot.toString = function() { return "dot"; };
+
+/**
+ * The size of the dot, in square pixels. Square pixels are used such that the
+ * area of the dot is linearly proportional to the value of the size property,
+ * facilitating representative encodings.
+ *
+ * @see #radius
+ * @type number
+ * @name pv.Dot.prototype.size
+ */
+pv.Dot.prototype.defineProperty("size");
+
+/**
+ * The shape name. Several shapes are supported:<ul>
+ *
+ * <li>cross
+ * <li>triangle
+ * <li>diamond
+ * <li>square
+ * <li>tick
+ * <li>circle
+ *
+ * </ul>These shapes can be further changed using the {@link #angle} property;
+ * for instance, a cross can be turned into a plus by rotating. Similarly, the
+ * tick, which is vertical by default, can be rotated horizontally. Note that
+ * some shapes (cross and tick) do not have interior areas, and thus do not
+ * support fill style meaningfully.
+ *
+ * <p>TODO It's probably better to use the Rule mark type rather than a
+ * tick-shaped Dot. However, the Rule mark doesn't support the width and height
+ * properties, so it's a bit clumsy to use. It should be possible to add support
+ * for width and height to rule, and then remove the tick shape.
+ *
+ * @type string
+ * @name pv.Dot.prototype.shape
+ */
+pv.Dot.prototype.defineProperty("shape");
+
+/**
+ * The rotation angle, in radians. Used to rotate shapes, such as to turn a
+ * cross into a plus.
+ *
+ * @type number
+ * @name pv.Dot.prototype.angle
+ */
+pv.Dot.prototype.defineProperty("angle");
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the dot's shape.
+ *
+ * @type number
+ * @name pv.Dot.prototype.lineWidth
+ */
+pv.Dot.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the dot's shape. The default value of this property is a categorical
+ * color.
+ *
+ * @type string
+ * @name pv.Dot.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Dot.prototype.defineProperty("strokeStyle");
+
+/**
+ * The fill style; if non-null, the interior of the dot is filled with the
+ * specified color. The default value of this property is null, meaning dots are
+ * not filled by default.
+ *
+ * @type string
+ * @name pv.Dot.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Dot.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for dots. By default, there is no fill and the stroke
+ * style is a categorical color. The default shape is "circle" with size 20.
+ *
+ * @type pv.Dot
+ */
+pv.Dot.defaults = new pv.Dot().extend(pv.Mark.defaults)
+ .size(20)
+ .shape("circle")
+ .lineWidth(1.5)
+ .strokeStyle(pv.Colors.category10);
+
+/**
+ * Constructs a new dot anchor with default properties.
+ *
+ * @class Represents an anchor for a dot mark. Dots support five different
+ * anchors:<ul>
+ *
+ * <li>top
+ * <li>left
+ * <li>center
+ * <li>bottom
+ * <li>right
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline). Text is
+ * rendered to appear outside the dot. Note that this behavior is different from
+ * other mark anchors, which default to rendering text <i>inside</i> the mark.
+ *
+ * <p>For consistency with the other mark types, the anchor positions are
+ * defined in terms of their opposite edge. For example, the top anchor defines
+ * the bottom property, such that a bar added to the top anchor grows upward.
+ *
+ * @extends pv.Mark.Anchor
+ */
+pv.Dot.Anchor = function() {
+ pv.Mark.Anchor.call(this);
+};
+pv.Dot.Anchor.prototype = pv.extend(pv.Mark.Anchor);
+pv.Dot.Anchor.prototype.type = pv.Dot;
+
+/**
+ * The left property; null for "left" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Dot.Anchor.prototype.left
+ */ /** @private */
+pv.Dot.Anchor.prototype.$left = function(d) {
+ var dot = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return dot.left();
+ case "right": return dot.left() + dot.radius();
+ }
+ return null;
+};
+
+/**
+ * The right property; null for "right" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Dot.Anchor.prototype.right
+ */ /** @private */
+pv.Dot.Anchor.prototype.$right = function(d) {
+ var dot = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return dot.right();
+ case "left": return dot.right() + dot.radius();
+ }
+ return null;
+};
+
+/**
+ * The top property; null for "top" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Dot.Anchor.prototype.top
+ */ /** @private */
+pv.Dot.Anchor.prototype.$top = function(d) {
+ var dot = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return dot.top();
+ case "bottom": return dot.top() + dot.radius();
+ }
+ return null;
+};
+
+/**
+ * The bottom property; null for "bottom" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Dot.Anchor.prototype.bottom
+ */ /** @private */
+pv.Dot.Anchor.prototype.$bottom = function(d) {
+ var dot = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return dot.bottom();
+ case "top": return dot.bottom() + dot.radius();
+ }
+ return null;
+};
+
+/**
+ * The text-align property, for horizontal alignment outside the dot.
+ *
+ * @type string
+ * @name pv.Dot.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Dot.Anchor.prototype.$textAlign = function(d) {
+ switch (this.get("name")) {
+ case "left": return "right";
+ case "bottom":
+ case "top":
+ case "center": return "center";
+ case "right": return "left";
+ }
+ return null;
+};
+
+/**
+ * The text-baseline property, for vertical alignment outside the dot.
+ *
+ * @type string
+ * @name pv.Dot.Anchor.prototype.textBasline
+ */ /** @private */
+pv.Dot.Anchor.prototype.$textBaseline = function(d) {
+ switch (this.get("name")) {
+ case "right":
+ case "left":
+ case "center": return "middle";
+ case "top": return "bottom";
+ case "bottom": return "top";
+ }
+ return null;
+};
+
+/**
+ * Returns the radius of the dot, which is defined to be the square root of the
+ * {@link #size} property.
+ *
+ * @returns {number} the radius.
+ */
+pv.Dot.prototype.radius = function() {
+ return Math.sqrt(this.size());
+};
+
+/**
+ * Updates the display for the specified dot instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the dot, as
+ * well as positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the dot to update.
+ */
+pv.Dot.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the <svg:path> element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "path");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* visible, cursor, title, event, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top */
+ v.setAttribute("transform", "translate(" + s.left + "," + s.top +")"
+ + (s.angle ? " rotate(" + 180 * s.angle / Math.PI + ")" : ""));
+
+ /* fill, stroke TODO gradient, patterns? */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+
+ /* shape, size */
+ var radius = Math.sqrt(s.size);
+ var d;
+ switch (s.shape) {
+ case "cross": {
+ d = "M" + -radius + "," + -radius
+ + "L" + radius + "," + radius
+ + "M" + radius + "," + -radius
+ + "L" + -radius + "," + radius;
+ break;
+ }
+ case "triangle": {
+ var h = radius, w = radius * 2 / Math.sqrt(3);
+ d = "M0," + h
+ + "L" + w +"," + -h
+ + " " + -w + "," + -h
+ + "Z";
+ break;
+ }
+ case "diamond": {
+ radius *= Math.sqrt(2);
+ d = "M0," + -radius
+ + "L" + radius + ",0"
+ + " 0," + radius
+ + " " + -radius + ",0"
+ + "Z";
+ break;
+ }
+ case "square": {
+ d = "M" + -radius + "," + -radius
+ + "L" + radius + "," + -radius
+ + " " + radius + "," + radius
+ + " " + -radius + "," + radius
+ + "Z";
+ break;
+ }
+ case "tick": {
+ d = "M0,0L0," + -s.size;
+ break;
+ }
+ default: { // circle
+ d = "M0," + radius
+ + "A" + radius + "," + radius + " 0 1,1 0," + (-radius)
+ + "A" + radius + "," + radius + " 0 1,1 0," + radius
+ + "Z";
+ break;
+ }
+ }
+ v.setAttribute("d", d);
+};
+/**
+ * Constructs a new dot mark with default properties. Images are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents an image. Images share the same layout and style properties as
+ * bars, in conjunction with an external image such as PNG or JPEG. The image is
+ * specified via the {@link #url} property. The fill, if specified, appears
+ * beneath the image, while the optional stroke appears above the image.
+ *
+ * <p>TODO Restore support for dynamic images (such as heatmaps). These were
+ * supported in the canvas implementation using the pixel buffer API; although
+ * SVG does not support pixel manipulation, it is possible to embed a canvas
+ * element in SVG using foreign objects.
+ *
+ * <p>TODO Allow different modes of image placement: "scale" -- scale and
+ * preserve aspect ratio, "tile" -- repeat the image, "center" -- center the
+ * image, "fill" -- scale without preserving aspect ratio.
+ *
+ * <p>See {@link pv.Bar} for details on positioning properties.
+ *
+ * @extends pv.Bar
+ */
+pv.Image = function() {
+ pv.Bar.call(this);
+};
+pv.Image.prototype = pv.extend(pv.Bar);
+pv.Image.prototype.type = pv.Image;
+
+/**
+ * Returns "image".
+ *
+ * @returns {string} "image".
+ */
+pv.Image.toString = function() { return "image"; };
+
+/**
+ * The URL of the image to display. The set of supported image types is
+ * browser-dependent; PNG and JPEG are recommended.
+ *
+ * @type string
+ * @name pv.Image.prototype.url
+ */
+pv.Image.prototype.defineProperty("url");
+
+/**
+ * Default properties for images. By default, there is no stroke or fill style.
+ *
+ * @type pv.Image
+ */
+pv.Image.defaults = new pv.Image().extend(pv.Bar.defaults)
+ .fillStyle(null);
+
+/**
+ * Updates the display for the specified image instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the image,
+ * as well as positional properties.
+ *
+ * <p>Image rendering is a bit more complicated than most marks because it can
+ * entail up to four SVG elements: three for the fill, image and stroke, and the
+ * fourth an anchor element for the title tooltip. The anchor element is placed
+ * around the stroke rect element, if present, and otherwise the image element.
+ * Similarly the event handlers and cursor style is placed on the stroke
+ * element, if present, and otherwise the image element. Note that since the
+ * stroke element is transparent, the <tt>pointer-events</tt> attribute is used
+ * to capture events.
+ *
+ * @param s a node in the scene graph; the instance of the image to update.
+ */
+pv.Image.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the svg:image element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "image");
+ v.setAttribute("preserveAspectRatio", "none");
+ s.parent.svg.appendChild(v);
+ }
+
+ /*
+ * If no stroke is specified, then the event handlers and title anchor element
+ * can be placed on the image element. However, if there was previously a
+ * title anchor element around the stroke element, we must be careful to
+ * remove it. This logic could likely be simplified.
+ */
+ if (!s.strokeStyle) {
+ if (v.$stroke) {
+ v.parentNode.removeChild(v.$stroke.$title || v.$stroke);
+ delete v.$stroke;
+ }
+
+ /* cursor, title, events, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ }
+
+ /* visible */
+ function display(v) {
+ s.visible ? v.removeAttribute("display") : v.setAttribute("display", "none");
+ }
+ if (v) {
+ display(v);
+ if (v.$stroke) display(v.$stroke);
+ if (v.$fill) display(v.$fill);
+ }
+ if (!s.visible) return;
+
+ /* left, top, width, height */
+ function position(v) {
+ v.setAttribute("x", s.left);
+ v.setAttribute("y", s.top);
+ v.setAttribute("width", s.width);
+ v.setAttribute("height", s.height);
+ }
+ position(v);
+
+ /* fill (via an underlaid svg:rect element) */
+ if (s.fillStyle) {
+ var f = v.$fill;
+ if (!f) {
+ f = v.$fill = document.createElementNS(pv.ns.svg, "rect");
+ (v.$title || v).parentNode.insertBefore(f, (v.$title || v));
+ }
+ position(f);
+ var fill = pv.color(s.fillStyle);
+ f.setAttribute("fill", fill.color);
+ f.setAttribute("fill-opacity", fill.opacity);
+ } else if (v.$fill) {
+ v.$fill.parentNode.removeChild(v.$fill);
+ delete v.$fill;
+ }
+
+ /* stroke (via an overlaid svg:rect element) */
+ if (s.strokeStyle) {
+ var f = v.$stroke;
+
+ /*
+ * If the $title attribute is set, that means the title anchor element was
+ * previously on the image element; now that the stroke style is set, we
+ * must delete the old title element to make room for the new one.
+ */
+ if (v.$title) {
+ var p = v.$title.parentNode;
+ p.insertBefore(v, v.$title);
+ p.removeChild(v.$title);
+ delete v.$title;
+ }
+
+ /* Create the stroke svg:rect element, if necessary. */
+ if (!f) {
+ f = v.$stroke = document.createElementNS(pv.ns.svg, "rect");
+ f.setAttribute("fill", "none");
+ f.setAttribute("pointer-events", "all");
+ v.parentNode.insertBefore(f, v.nextElementSibling);
+ }
+ position(f);
+ var stroke = pv.color(s.strokeStyle);
+ f.setAttribute("stroke", stroke.color);
+ f.setAttribute("stroke-opacity", stroke.opacity);
+ f.setAttribute("stroke-width", s.lineWidth);
+
+ /* cursor, title, events, etc. */
+ try {
+ s.svg = f;
+ pv.Mark.prototype.updateInstance.call(this, s);
+ } finally {
+ s.svg = v;
+ }
+ }
+
+ /* url */
+ v.setAttributeNS(pv.ns.xlink, "href", s.url);
+};
+/**
+ * Constructs a new label mark with default properties. Labels are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a text label, allowing textual annotation of other marks or
+ * arbitrary text within the visualization. The character data must be plain
+ * text (unicode), though the text can be styled using the {@link #font}
+ * property. If rich text is needed, external HTML elements can be overlaid on
+ * the canvas by hand.
+ *
+ * <p>Labels are positioned using the box model, similarly to {@link Dot}. Thus,
+ * a label has no width or height, but merely a text anchor location. The text
+ * is positioned relative to this anchor location based on the
+ * {@link #textAlign}, {@link #textBaseline} and {@link #textMargin} properties.
+ * Furthermore, the text may be rotated using {@link #textAngle}.
+ *
+ * <p>Labels ignore events, so as to not interfere with event handlers on
+ * underlying marks, such as bars. In the future, we may support event handlers
+ * on labels.
+ *
+ * <p>See also the <a href="../../api/Label.html">Label guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Label = function() {
+ pv.Mark.call(this);
+};
+pv.Label.prototype = pv.extend(pv.Mark);
+pv.Label.prototype.type = pv.Label;
+
+/**
+ * Returns "label".
+ *
+ * @returns {string} "label".
+ */
+pv.Label.toString = function() { return "label"; };
+
+/**
+ * The character data to render; a string. The default value of the text
+ * property is the identity function, meaning the label's associated datum will
+ * be rendered using its <tt>toString</tt>.
+ *
+ * @type string
+ * @name pv.Label.prototype.text
+ */
+pv.Label.prototype.defineProperty("text");
+
+/**
+ * The font format, per the CSS Level 2 specification. The default font is "10px
+ * sans-serif", for consistency with the HTML 5 canvas element specification.
+ * Note that since text is not wrapped, any line-height property will be
+ * ignored. The other font-style, font-variant, font-weight, font-size and
+ * font-family properties are supported.
+ *
+ * @see <a href="http://www.w3.org/TR/CSS2/fonts.html#font-shorthand">CSS2 fonts</a>.
+ * @type string
+ * @name pv.Label.prototype.font
+ */
+pv.Label.prototype.defineProperty("font");
+
+/**
+ * The rotation angle, in radians. Text is rotated clockwise relative to the
+ * anchor location. For example, with the default left alignment, an angle of
+ * Math.PI / 2 causes text to proceed downwards. The default angle is zero.
+ *
+ * @type number
+ * @name pv.Label.prototype.textAngle
+ */
+pv.Label.prototype.defineProperty("textAngle");
+
+/**
+ * The text color. The name "textStyle" is used for consistency with "fillStyle"
+ * and "strokeStyle", although it might be better to rename this property (and
+ * perhaps use the same name as "strokeStyle"). The default color is black.
+ *
+ * @type string
+ * @name pv.Label.prototype.textStyle
+ * @see pv.color
+ */
+pv.Label.prototype.defineProperty("textStyle");
+
+/**
+ * The horizontal text alignment. One of:<ul>
+ *
+ * <li>left
+ * <li>center
+ * <li>right
+ *
+ * </ul>The default horizontal alignment is left.
+ *
+ * @type string
+ * @name pv.Label.prototype.textAlign
+ */
+pv.Label.prototype.defineProperty("textAlign");
+
+/**
+ * The vertical text alignment. One of:<ul>
+ *
+ * <li>top
+ * <li>middle
+ * <li>bottom
+ *
+ * </ul>The default vertical alignment is bottom.
+ *
+ * @type string
+ * @name pv.Label.prototype.textBaseline
+ */
+pv.Label.prototype.defineProperty("textBaseline");
+
+/**
+ * The text margin; may be specified in pixels, or in font-dependent units
+ * (e.g., ".1ex"). The margin can be used to pad text away from its anchor
+ * location, in a direction dependent on the horizontal and vertical alignment
+ * properties. For example, if the text is left- and middle-aligned, the margin
+ * shifts the text to the right. The default margin is 3 pixels.
+ *
+ * @type number
+ * @name pv.Label.prototype.textMargin
+ */
+pv.Label.prototype.defineProperty("textMargin");
+
+/**
+ * A list of shadow effects to be applied to text, per the CSS Text Level 3
+ * text-shadow property. An example specification is "0.1em 0.1em 0.1em
+ * rgba(0,0,0,.5)"; the first length is the horizontal offset, the second the
+ * vertical offset, and the third the blur radius.
+ *
+ * @see <a href="http://www.w3.org/TR/css3-text/#text-shadow">CSS3 text</a>.
+ * @type string
+ * @name pv.Label.prototype.textShadow
+ */
+pv.Label.prototype.defineProperty("textShadow");
+
+/**
+ * Default properties for labels. See the individual properties for the default
+ * values.
+ *
+ * @type pv.Label
+ */
+pv.Label.defaults = new pv.Label().extend(pv.Mark.defaults)
+ .text(pv.identity)
+ .font("10px sans-serif")
+ .textAngle(0)
+ .textStyle("black")
+ .textAlign("left")
+ .textBaseline("bottom")
+ .textMargin(3);
+
+/**
+ * Updates the display for the specified label instance <tt>s</tt> in the scene
+ * graph. This implementation handles the text formatting for the label, as well
+ * as positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the dot to update.
+ */
+pv.Label.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the svg:text element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "text");
+ v.$text = document.createTextNode("");
+ v.appendChild(v.$text);
+ s.parent.svg.appendChild(v);
+ }
+
+ /* cursor, title, events, visible, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top, angle */
+ v.setAttribute("transform", "translate(" + s.left + "," + s.top + ")"
+ + (s.textAngle ? " rotate(" + 180 * s.textAngle / Math.PI + ")" : ""));
+
+ /* text-baseline */
+ switch (s.textBaseline) {
+ case "middle": {
+ v.removeAttribute("y");
+ v.setAttribute("dy", ".35em");
+ break;
+ }
+ case "top": {
+ v.setAttribute("y", s.textMargin);
+ v.setAttribute("dy", ".71em");
+ break;
+ }
+ case "bottom": {
+ v.setAttribute("y", "-" + s.textMargin);
+ v.removeAttribute("dy");
+ break;
+ }
+ }
+
+ /* text-align */
+ switch (s.textAlign) {
+ case "right": {
+ v.setAttribute("text-anchor", "end");
+ v.setAttribute("x", "-" + s.textMargin);
+ break;
+ }
+ case "center": {
+ v.setAttribute("text-anchor", "middle");
+ v.removeAttribute("x");
+ break;
+ }
+ case "left": {
+ v.setAttribute("text-anchor", "start");
+ v.setAttribute("x", s.textMargin);
+ break;
+ }
+ }
+
+ /* font, text-shadow TODO centralize font definition? */
+ v.$text.nodeValue = s.text;
+ var style = "font:" + s.font + ";";
+ if (s.textShadow) {
+ style += "text-shadow:" + s.textShadow +";";
+ }
+ v.setAttribute("style", style);
+
+ /* fill */
+ var fill = pv.color(s.textStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+
+ /* TODO enable interaction on labels? centralize this definition? */
+ v.setAttribute("pointer-events", "none");
+};
+/**
+ * Constructs a new line mark with default properties. Lines are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a series of connected line segments, or <i>polyline</i>,
+ * that can be stroked with a configurable color and thickness. Each
+ * articulation point in the line corresponds to a datum; for <i>n</i> points,
+ * <i>n</i>-1 connected line segments are drawn. The point is positioned using
+ * the box model. Arbitrary paths are also possible, allowing radar plots and
+ * other custom visualizations.
+ *
+ * <p>Like areas, lines can be stroked and filled with arbitrary colors. In most
+ * cases, lines are only stroked, but the fill style can be used to construct
+ * arbitrary polygons.
+ *
+ * <p>See also the <a href="../../api/Line.html">Line guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Line = function() {
+ pv.Mark.call(this);
+};
+pv.Line.prototype = pv.extend(pv.Mark);
+pv.Line.prototype.type = pv.Line;
+
+/**
+ * Returns "line".
+ *
+ * @returns {string} "line".
+ */
+pv.Line.toString = function() { return "line"; };
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the line.
+ *
+ * @type number
+ * @name pv.Line.prototype.lineWidth
+ */
+pv.Line.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the line. The default value of this property is a categorical color.
+ *
+ * @type string
+ * @name pv.Line.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Line.prototype.defineProperty("strokeStyle");
+
+/**
+ * The line fill style; if non-null, the interior of the line is closed and
+ * filled with the specified color. The default value of this property is a
+ * null, meaning that lines are not filled by default.
+ *
+ * @type string
+ * @name pv.Line.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Line.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for lines. By default, there is no fill and the stroke
+ * style is a categorical color.
+ *
+ * @type pv.Line
+ */
+pv.Line.defaults = new pv.Line().extend(pv.Mark.defaults)
+ .lineWidth(1.5)
+ .strokeStyle(pv.Colors.category10);
+
+/**
+ * Override the default update implementation, since the line mark generates a
+ * single graphical element rather than multiple distinct elements.
+ */
+pv.Line.prototype.update = function() {
+ if (!this.scene.length) return;
+
+ /* visible */
+ var s = this.scene[0], v = s.svg;
+ if (s.visible) {
+
+ /* Create the svg:polyline element, if necessary. */
+ if (!v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "polyline");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* left, top TODO allow points to be changed on events? */
+ var p = "";
+ for (var i = 0; i < this.scene.length; i++) {
+ var si = this.scene[i];
+ if (isNaN(si.left)) si.left = 0;
+ if (isNaN(si.top)) si.top = 0;
+ p += si.left + "," + si.top + " ";
+ }
+ v.setAttribute("points", p);
+
+ /* cursor, title, events, etc. */
+ this.updateInstance(s);
+ v.removeAttribute("display");
+ } else if (v) {
+ v.setAttribute("display", "none");
+ }
+};
+
+/**
+ * Updates the display for the (singleton) line instance. The line mark
+ * generates a single graphical element rather than multiple distinct elements.
+ *
+ * <p>TODO Recompute points? For efficiency, the points are not recomputed, and
+ * therefore cannot be updated automatically from event handlers without an
+ * explicit call to rebuild the line.
+ *
+ * @param s a node in the scene graph; the instance of the mark to update.
+ */
+pv.Line.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+/**
+ * Constructs a new, empty panel with default properties. Panels, with the
+ * exception of the root panel, are not typically constructed directly; instead,
+ * they are added to an existing panel or mark via {@link pv.Mark#add}.
+ *
+ * @class Represents a container mark. Panels allow repeated or nested
+ * structures, commonly used in small multiple displays where a small
+ * visualization is tiled to facilitate comparison across one or more
+ * dimensions. Other types of visualizations may benefit from repeated and
+ * possibly overlapping structure as well, such as stacked area charts. Panels
+ * can also offset the position of marks to provide padding from surrounding
+ * content.
+ *
+ * <p>All Protovis displays have at least one panel; this is the root panel to
+ * which marks are rendered. The box model properties (four margins, width and
+ * height) are used to offset the positions of contained marks. The data
+ * property determines the panel count: a panel is generated once per associated
+ * datum. When nested panels are used, property functions can declare additional
+ * arguments to access the data associated with enclosing panels.
+ *
+ * <p>Panels can be rendered inline, facilitating the creation of sparklines.
+ * This allows designers to reuse browser layout features, such as text flow and
+ * tables; designers can also overlay HTML elements such as rich text and
+ * images.
+ *
+ * <p>All panels have a <tt>children</tt> array (possibly empty) containing the
+ * child marks in the order they were added. Panels also have a <tt>root</tt>
+ * field which points to the root (outermost) panel; the root panel's root field
+ * points to itself.
+ *
+ * <p>See also the <a href="../../api/">Protovis guide</a>.
+ *
+ * @extends pv.Bar
+ */
+pv.Panel = function() {
+ pv.Bar.call(this);
+
+ /**
+ * The child marks; zero or more {@link pv.Mark}s in the order they were
+ * added.
+ *
+ * @see #add
+ * @type pv.Mark[]
+ */
+ this.children = [];
+ this.root = this;
+
+ /**
+ * The internal $dom field is set by the Protovis loader; see lang/init.js. It
+ * refers to the script element that contains the Protovis specification, so
+ * that the panel knows where in the DOM to insert the generated SVG element.
+ *
+ * @private
+ */
+ this.$dom = pv.Panel.$dom;
+};
+pv.Panel.prototype = pv.extend(pv.Bar);
+pv.Panel.prototype.type = pv.Panel;
+
+/**
+ * Returns "panel".
+ *
+ * @returns {string} "panel".
+ */
+pv.Panel.toString = function() { return "panel"; };
+
+/**
+ * The canvas element; either the string ID of the canvas element in the current
+ * document, or a reference to the canvas element itself. If null, a canvas
+ * element will be created and inserted into the document at the location of the
+ * script element containing the current Protovis specification. This property
+ * only applies to root panels and is ignored on nested panels.
+ *
+ * <p>Note: the "canvas" element here refers to a <tt>div</tt> (or other suitable
+ * HTML container element), <i>not</i> a <tt>canvas</tt> element. The name of
+ * this property is a historical anachronism from the first implementation that
+ * used HTML 5 canvas, rather than SVG.
+ *
+ * @type string
+ * @name pv.Panel.prototype.canvas
+ */
+pv.Panel.prototype.defineProperty("canvas");
+
+/**
+ * The reverse property; a boolean determining whether child marks are ordered
+ * from front-to-back or back-to-front. SVG does not support explicit
+ * z-ordering; shapes are rendered in the order they appear. Thus, by default,
+ * child marks are rendered in the order they are added to the panel. Setting
+ * the reverse property to false reverses the order in which they are added to
+ * the SVG element; however, the properties are still evaluated (i.e., built) in
+ * forward order.
+ *
+ * @type boolean
+ * @name pv.Panel.prototype.reverse
+ */
+pv.Panel.prototype.defineProperty("reverse");
+
+/**
+ * Default properties for panels. By default, the margins are zero, the fill
+ * style is transparent, and the reverse property is false.
+ *
+ * @type pv.Panel
+ */
+pv.Panel.defaults = new pv.Panel().extend(pv.Bar.defaults)
+ .top(0).left(0).bottom(0).right(0)
+ .fillStyle(null)
+ .reverse(false);
+
+/**
+ * Adds a new mark of the specified type to this panel. Unlike the normal
+ * {@link Mark#add} behavior, adding a mark to a panel does not cause the mark
+ * to inherit from the panel. Since the contained marks are offset by the panel
+ * margins already, inheriting properties is generally undesirable; of course,
+ * it is always possible to change this behavior by calling {@link Mark#extend}
+ * explicitly.
+ *
+ * @param {function} type the type of the new mark to add.
+ * @returns {pv.Mark} the new mark.
+ */
+pv.Panel.prototype.add = function(type) {
+ var child = new type();
+ child.parent = this;
+ child.root = this.root;
+ child.childIndex = this.children.length;
+ this.children.push(child);
+ return child;
+};
+
+/**
+ * Creates a new canvas (SVG) element with the specified width and height, and
+ * inserts it into the current document. If the <tt>$dom</tt> field is set, as
+ * for text/javascript+protovis scripts, the SVG element is inserted into the
+ * DOM before the script element. Otherwise, the SVG element is inserted into
+ * the last child element of the document, as for text/javascript scripts.
+ *
+ * @param w the width of the canvas to create, in pixels.
+ * @param h the height of the canvas to create, in pixels.
+ * @return the new canvas (SVG) element.
+ */
+pv.Panel.prototype.createCanvas = function(w, h) {
+
+ /**
+ * Returns the last element in the current document's body. The canvas element
+ * is appended to this last element if another DOM element has not already
+ * been specified via the <tt>$dom</tt> field.
+ */
+ function lastElement() {
+ var node = document.body;
+ while (node.lastElementChild && node.lastElementChild.tagName) {
+ node = node.lastElementChild;
+ }
+ return (node == document.body) ? node : node.parentNode;
+ }
+
+ /* Create the SVG element. */
+ var c = document.createElementNS(pv.ns.svg, "svg");
+ c.setAttribute("width", w);
+ c.setAttribute("height", h);
+
+ /* Insert it into the DOM at the appropriate location. */
+ this.$dom // script element for text/javascript+protovis
+ ? this.$dom.parentNode.insertBefore(c, this.$dom)
+ : lastElement().appendChild(c);
+
+ return c;
+};
+
+/**
+ * Evaluates all of the properties for this panel for the specified instance
+ * <tt>s</tt> in the scene graph, including recursively building the scene graph
+ * for child marks.
+ *
+ * @param s a node in the scene graph; the instance of the panel to build.
+ * @see Mark#scene
+ */
+pv.Panel.prototype.buildInstance = function(s) {
+ pv.Bar.prototype.buildInstance.call(this, s);
+
+ /*
+ * Build each child, passing in the parent (this panel) scene graph node. The
+ * child mark's scene is initialized from the corresponding entry in the
+ * existing scene graph, such that properties from the previous build can be
+ * reused; this is largely to facilitate the recycling of SVG elements.
+ */
+ for (var i = 0; i < this.children.length; i++) {
+ this.children[i].scene = s.children[i] || [];
+ this.children[i].build(s);
+ }
+
+ /*
+ * Once the child marks have been built, the new scene graph nodes are removed
+ * from the child marks and placed into the scene graph. The nodes cannot
+ * remain on the child nodes because this panel (or a parent panel) may be
+ * instantiated multiple times!
+ */
+ for (var i = 0; i < this.children.length; i++) {
+ s.children[i] = this.children[i].scene;
+ delete this.children[i].scene;
+ }
+
+ /* Delete any expired child scenes, should child marks have been removed. */
+ s.children.length = this.children.length;
+};
+
+/**
+ * Computes the implied properties for this panel for the specified instance
+ * <tt>s</tt> in the scene graph. Panels have two implied properties:<ul>
+ *
+ * <li>The <tt>canvas</tt> property references the DOM element, typically a DIV,
+ * that contains the SVG element that is used to display the visualization. This
+ * property may be specified as a string, referring to the unique ID of the
+ * element in the DOM. The string is converted to a reference to the DOM
+ * element. The width and height of the SVG element is inferred from this DOM
+ * element. If no canvas property is specified, a new SVG element is created and
+ * inserted into the document, using the panel dimensions; see
+ * {@link #createCanvas}.
+ *
+ * <li>The <tt>children</tt> array, while not a property per se, contains the
+ * scene graph for each child mark. This array is initialized to be empty, and
+ * is populated above in {@link #buildInstance}.
+ *
+ * </ul>The current implementation creates the SVG element, if necessary, during
+ * the build phase; in the future, it may be preferable to move this to the
+ * update phase, although then the canvas property would be undefined. In
+ * addition, DOM inspection is necessary to define the implied width and height
+ * properties that may be inferred from the DOM.
+ *
+ * @param s a node in the scene graph; the instance of the panel to build.
+ */
+pv.Panel.prototype.buildImplied = function(s) {
+ if (!s.children) s.children = [];
+ if (!s.parent) {
+ var c = s.canvas;
+ if (c) {
+ var d = (typeof c == "string") ? document.getElementById(c) : c;
+
+ /* Clear the container if it's not already associated with this panel. */
+ if (!d.$panel || d.$panel != this) {
+ d.$panel = this;
+ delete d.$canvas;
+ while (d.lastChild) {
+ d.lastChild.remove();
+ }
+ }
+
+ /* Construct the canvas if not already present. */
+ if (!(c = d.$canvas)) {
+ d.$canvas = c = document.createElementNS(pv.ns.svg, "svg");
+ d.appendChild(c);
+ }
+
+ /** Returns the computed style for the given element and property. */
+ let css = function(e, p) {
+ return parseFloat(self.getComputedStyle(e, null).getPropertyValue(p));
+ };
+
+ /* If width and height weren't specified, inspect the container. */
+ var w, h;
+ if (s.width == null) {
+ w = css(d, "width");
+ s.width = w - s.left - s.right;
+ } else {
+ w = s.width + s.left + s.right;
+ }
+ if (s.height == null) {
+ h = css(d, "height");
+ s.height = h - s.top - s.bottom;
+ } else {
+ h = s.height + s.top + s.bottom;
+ }
+
+ c.setAttribute("width", w);
+ c.setAttribute("height", h);
+ s.canvas = c;
+ } else if (s.svg) {
+ s.canvas = s.svg.parentNode;
+ } else {
+ s.canvas = this.createCanvas(
+ s.width + s.left + s.right,
+ s.height + s.top + s.bottom);
+ }
+ }
+ pv.Bar.prototype.buildImplied.call(this, s);
+};
+
+/**
+ * Updates the display, propagating property values computed in the build phase
+ * to the SVG image. In addition to the SVG element that serves as the canvas,
+ * each panel instance has a corresponding <tt>g</tt> (container) element. The
+ * <tt>g</tt> element uses the <tt>transform</tt> attribute to offset the location
+ * of contained graphical elements.
+ */
+pv.Panel.prototype.update = function() {
+ var appends = [];
+ for (var i = 0; i < this.scene.length; i++) {
+ var s = this.scene[i];
+
+ /* Create the <svg:g> element, if necessary. */
+ var v = s.svg;
+ if (!v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "g");
+ appends.push(s);
+ }
+
+ /* Update this instance, recursively including child marks. */
+ this.updateInstance(s);
+ if (s.children) { // check visibility
+ for (var j = 0; j < this.children.length; j++) {
+ var c = this.children[j];
+ c.scene = s.children[j];
+ c.update();
+ delete c.scene;
+ }
+ }
+ }
+
+ /*
+ * WebKit appears has a bug where images are not rendered if the <g> element
+ * is appended before it contained any elements. Creating the child elements
+ * first and then appending them solves the problem and is likely more
+ * efficient. Also, it means we can reverse the order easily.
+ *
+ * TODO It would be nice to support arbitrary z-order here, at least within
+ * panel. Of course, the order of children may need to be updated not just on
+ * append.
+ */
+ if (appends.length) {
+ if (appends[0].reverse) appends.reverse();
+ for (var i = 0; i < appends.length; i++) {
+ var s = appends[i];
+ (s.parent ? s.parent.svg : s.canvas).appendChild(s.svg);
+ }
+ }
+};
+
+/**
+ * Updates the display for the specified panel instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the panel,
+ * as well as any necessary transform to offset the location of contained marks.
+ *
+ * <p>TODO As a performance optimization, it may also be possible to assign
+ * constant property values (or even the most common value for each property) as
+ * attributes on the <g> element so they can be inherited.
+ *
+ * @param s a node in the scene graph; the instance of the panel to update.
+ */
+pv.Panel.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* visible */
+ if (!s.visible) {
+ if (v) v.setAttribute("display", "none");
+ return;
+ }
+ v.removeAttribute("display");
+
+ /* fillStyle, strokeStyle */
+ var r = v.$rect;
+ if (s.fillStyle || s.strokeStyle) {
+ if (!r) {
+ r = v.$rect = document.createElementNS(pv.ns.svg, "rect");
+ v.insertBefore(r, v.firstElementChild);
+ }
+
+ /* If width and height are exactly zero, the rect is not stroked! */
+ r.setAttribute("width", Math.max(1E-10, s.width));
+ r.setAttribute("height", Math.max(1E-10, s.height));
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ r.setAttribute("fill", fill.color);
+ r.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ r.setAttribute("stroke", stroke.color);
+ r.setAttribute("stroke-opacity", stroke.opacity);
+ r.setAttribute("stroke-width", s.lineWidth);
+ } else if (r) {
+ v.removeChild(r);
+ delete v.$rect;
+ r = null;
+ }
+
+ /* cursor, title, event, etc. */
+ if (r) {
+ try {
+ s.svg = r;
+ pv.Mark.prototype.updateInstance.call(this, s);
+ } finally {
+ s.svg = v;
+ }
+ }
+
+ /* left, top */
+ if (s.left || s.top) {
+ v.setAttribute("transform", "translate(" + s.left + "," + s.top +")");
+ } else {
+ v.removeAttribute("transform");
+ }
+};
+/**
+ * Constructs a new rule with default properties. Rules are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a horizontal or vertical rule. Rules are frequently used
+ * for axes and grid lines. For example, specifying only the bottom property
+ * draws horizontal rules, while specifying only the left draws vertical
+ * rules. Rules can also be used as thin bars. The visual style is controlled in
+ * the same manner as lines.
+ *
+ * <p>Rules are positioned exclusively using the four margins. The following
+ * combinations of properties are supported:<ul>
+ *
+ * <li>left (vertical)
+ * <li>right (vertical)
+ * <li>left, bottom, top (vertical)
+ * <li>right, bottom, top (vertical)
+ * <li>top (horizontal)
+ * <li>bottom (horizontal)
+ * <li>top, left, right (horizontal)
+ * <li>bottom, left, right (horizontal)
+ *
+ * </ul>TODO If rules supported width (for horizontal) and height (for vertical)
+ * properties, it might be easier to place them. Small rules can be used as tick
+ * marks; alternatively, a {@link Dot} with the "tick" shape can be used.
+ *
+ * <p>See also the <a href="../../api/Rule.html">Rule guide</a>.
+ *
+ * @see pv.Line
+ * @extends pv.Mark
+ */
+pv.Rule = function() {
+ pv.Mark.call(this);
+};
+pv.Rule.prototype = pv.extend(pv.Mark);
+pv.Rule.prototype.type = pv.Rule;
+
+/**
+ * Returns "rule".
+ *
+ * @returns {string} "rule".
+ */
+pv.Rule.toString = function() { return "rule"; };
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the rule. The default value is 1 pixel.
+ *
+ * @type number
+ * @name pv.Rule.prototype.lineWidth
+ */
+pv.Rule.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the rule. The default value of this property is black.
+ *
+ * @type string
+ * @name pv.Rule.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Rule.prototype.defineProperty("strokeStyle");
+
+/**
+ * Default properties for rules. By default, a single-pixel black line is
+ * stroked.
+ *
+ * @type pv.Rule
+ */
+pv.Rule.defaults = new pv.Rule().extend(pv.Mark.defaults)
+ .lineWidth(1)
+ .strokeStyle("black");
+
+/**
+ * Constructs a new rule anchor with default properties.
+ *
+ * @class Represents an anchor for a rule mark. Rules support five different
+ * anchors:<ul>
+ *
+ * <li>top
+ * <li>left
+ * <li>center
+ * <li>bottom
+ * <li>right
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline). Text is
+ * rendered to appear outside the rule. Note that this behavior is different
+ * from other mark anchors, which default to rendering text <i>inside</i> the
+ * mark.
+ *
+ * <p>For consistency with the other mark types, the anchor positions are
+ * defined in terms of their opposite edge. For example, the top anchor defines
+ * the bottom property, such that a bar added to the top anchor grows upward.
+ *
+ * @extends pv.Bar.Anchor
+ */
+pv.Rule.Anchor = function() {
+ pv.Bar.Anchor.call(this);
+};
+pv.Rule.Anchor.prototype = pv.extend(pv.Bar.Anchor);
+pv.Rule.Anchor.prototype.type = pv.Rule;
+
+/**
+ * The text-align property, for horizontal alignment outside the rule.
+ *
+ * @type string
+ * @name pv.Rule.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Rule.Anchor.prototype.$textAlign = function(d) {
+ switch (this.get("name")) {
+ case "left": return "right";
+ case "bottom":
+ case "top":
+ case "center": return "center";
+ case "right": return "left";
+ }
+ return null;
+};
+
+/**
+ * The text-baseline property, for vertical alignment outside the rule.
+ *
+ * @type string
+ * @name pv.Rule.Anchor.prototype.textBaseline
+ */ /** @private */
+pv.Rule.Anchor.prototype.$textBaseline = function(d) {
+ switch (this.get("name")) {
+ case "right":
+ case "left":
+ case "center": return "middle";
+ case "top": return "bottom";
+ case "bottom": return "top";
+ }
+ return null;
+};
+
+/**
+ * Returns the pseudo-width of the rule in pixels; read-only.
+ *
+ * @returns {number} the pseudo-width, in pixels.
+ */
+pv.Rule.prototype.width = function() {
+ return this.scene[this.index].width;
+};
+
+/**
+ * Returns the pseudo-height of the rule in pixels; read-only.
+ *
+ * @returns {number} the pseudo-height, in pixels.
+ */
+pv.Rule.prototype.height = function() {
+ return this.scene[this.index].height;
+};
+
+/**
+ * Overrides the default behavior of {@link Mark#buildImplied} to determine the
+ * orientation (vertical or horizontal) of the rule.
+ *
+ * @param s a node in the scene graph; the instance of the rule to build.
+ */
+pv.Rule.prototype.buildImplied = function(s) {
+ s.width = s.height = 0;
+
+ /* Determine horizontal or vertical orientation. */
+ var l = s.left, r = s.right, t = s.top, b = s.bottom;
+ if (((l == null) && (r == null)) || ((r != null) && (l != null))) {
+ s.width = s.parent.width - (l = l || 0) - (r = r || 0);
+ } else {
+ s.height = s.parent.height - (t = t || 0) - (b = b || 0);
+ }
+
+ s.left = l;
+ s.right = r;
+ s.top = t;
+ s.bottom = b;
+
+ pv.Mark.prototype.buildImplied.call(this, s);
+};
+
+/**
+ * Updates the display for the specified rule instance <tt>s</tt> in the scene
+ * graph. This implementation handles the stroke style for the rule, as well as
+ * positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the rule to update.
+ */
+pv.Rule.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the svg:line element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "line");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* visible, cursor, title, events, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top */
+ v.setAttribute("x1", s.left);
+ v.setAttribute("y1", s.top);
+ v.setAttribute("x2", s.left + s.width);
+ v.setAttribute("y2", s.top + s.height);
+
+ /* stroke TODO gradient, patterns, dashes */
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+/**
+ * Constructs a new wedge with default properties. Wedges are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a wedge, or pie slice. Specified in terms of start and end
+ * angle, inner and outer radius, wedges can be used to construct donut charts
+ * and polar bar charts as well. If the {@link #angle} property is used, the end
+ * angle is implied by adding this value to start angle. By default, the start
+ * angle is the previously-generated wedge's end angle. This design allows
+ * explicit control over the wedge placement if desired, while offering
+ * convenient defaults for the construction of radial graphs.
+ *
+ * <p>The center point of the circle is positioned using the standard box model.
+ * The wedge can be stroked and filled, similar to {link Bar}.
+ *
+ * <p>See also the <a href="../../api/Wedge.html">Wedge guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Wedge = function() {
+ pv.Mark.call(this);
+};
+pv.Wedge.prototype = pv.extend(pv.Mark);
+pv.Wedge.prototype.type = pv.Wedge;
+
+/**
+ * Returns "wedge".
+ *
+ * @returns {string} "wedge".
+ */
+pv.Wedge.toString = function() { return "wedge"; };
+
+/**
+ * The start angle of the wedge, in radians. The start angle is measured
+ * clockwise from the 3 o'clock position. The default value of this property is
+ * the end angle of the previous instance (the {@link Mark#sibling}), or -PI / 2
+ * for the first wedge; for pie and donut charts, typically only the
+ * {@link #angle} property needs to be specified.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.startAngle
+ */
+pv.Wedge.prototype.defineProperty("startAngle");
+
+/**
+ * The end angle of the wedge, in radians. If not specified, the end angle is
+ * implied as the start angle plus the {@link #angle}.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.endAngle
+ */
+pv.Wedge.prototype.defineProperty("endAngle");
+
+/**
+ * The angular span of the wedge, in radians. This property is used if end angle
+ * is not specified.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.angle
+ */
+pv.Wedge.prototype.defineProperty("angle");
+
+/**
+ * The inner radius of the wedge, in pixels. The default value of this property
+ * is zero; a positive value will produce a donut slice rather than a pie slice.
+ * The inner radius can vary per-wedge.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.innerRadius
+ */
+pv.Wedge.prototype.defineProperty("innerRadius");
+
+/**
+ * The outer radius of the wedge, in pixels. This property is required. For
+ * pies, only this radius is required; for donuts, the inner radius must be
+ * specified as well. The outer radius can vary per-wedge.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.outerRadius
+ */
+pv.Wedge.prototype.defineProperty("outerRadius");
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the wedge's border.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.lineWidth
+ */
+pv.Wedge.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the wedge's border. The default value of this property is null,
+ * meaning wedges are not stroked by default.
+ *
+ * @type string
+ * @name pv.Wedge.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Wedge.prototype.defineProperty("strokeStyle");
+
+/**
+ * The wedge fill style; if non-null, the interior of the wedge is filled with
+ * the specified color. The default value of this property is a categorical
+ * color.
+ *
+ * @type string
+ * @name pv.Wedge.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Wedge.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for wedges. By default, there is no stroke and the fill
+ * style is a categorical color.
+ *
+ * @type pv.Wedge
+ */
+pv.Wedge.defaults = new pv.Wedge().extend(pv.Mark.defaults)
+ .startAngle(function() {
+ var s = this.sibling();
+ return s ? s.endAngle : -Math.PI / 2;
+ })
+ .innerRadius(0)
+ .lineWidth(1.5)
+ .strokeStyle(null)
+ .fillStyle(pv.Colors.category20.unique);
+
+/**
+ * Returns the mid-radius of the wedge, which is defined as half-way between the
+ * inner and outer radii.
+ *
+ * @see #innerRadius
+ * @see #outerRadius
+ * @returns {number} the mid-radius, in pixels.
+ */
+pv.Wedge.prototype.midRadius = function() {
+ return (this.innerRadius() + this.outerRadius()) / 2;
+};
+
+/**
+ * Returns the mid-angle of the wedge, which is defined as half-way between the
+ * start and end angles.
+ *
+ * @see #startAngle
+ * @see #endAngle
+ * @returns {number} the mid-angle, in radians.
+ */
+pv.Wedge.prototype.midAngle = function() {
+ return (this.startAngle() + this.endAngle()) / 2;
+};
+
+/**
+ * Constructs a new wedge anchor with default properties.
+ *
+ * @class Represents an anchor for a wedge mark. Wedges support five different
+ * anchors:<ul>
+ *
+ * <li>outer
+ * <li>inner
+ * <li>center
+ * <li>start
+ * <li>end
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline,
+ * textAngle). Text is rendered to appear inside the wedge.
+ *
+ * @extends pv.Mark.Anchor
+ */
+pv.Wedge.Anchor = function() {
+ pv.Mark.Anchor.call(this);
+};
+pv.Wedge.Anchor.prototype = pv.extend(pv.Mark.Anchor);
+pv.Wedge.Anchor.prototype.type = pv.Wedge;
+
+/**
+ * The left property; non-null.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.left
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$left = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return w.left() + w.outerRadius() * Math.cos(w.midAngle());
+ case "inner": return w.left() + w.innerRadius() * Math.cos(w.midAngle());
+ case "start": return w.left() + w.midRadius() * Math.cos(w.startAngle());
+ case "center": return w.left() + w.midRadius() * Math.cos(w.midAngle());
+ case "end": return w.left() + w.midRadius() * Math.cos(w.endAngle());
+ }
+ return null;
+};
+
+/**
+ * The right property; non-null.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.right
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$right = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return w.right() + w.outerRadius() * Math.cos(w.midAngle());
+ case "inner": return w.right() + w.innerRadius() * Math.cos(w.midAngle());
+ case "start": return w.right() + w.midRadius() * Math.cos(w.startAngle());
+ case "center": return w.right() + w.midRadius() * Math.cos(w.midAngle());
+ case "end": return w.right() + w.midRadius() * Math.cos(w.endAngle());
+ }
+ return null;
+};
+
+/**
+ * The top property; non-null.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.top
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$top = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return w.top() + w.outerRadius() * Math.sin(w.midAngle());
+ case "inner": return w.top() + w.innerRadius() * Math.sin(w.midAngle());
+ case "start": return w.top() + w.midRadius() * Math.sin(w.startAngle());
+ case "center": return w.top() + w.midRadius() * Math.sin(w.midAngle());
+ case "end": return w.top() + w.midRadius() * Math.sin(w.endAngle());
+ }
+ return null;
+};
+
+/**
+ * The bottom property; non-null.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.bottom
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$bottom = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return w.bottom() + w.outerRadius() * Math.sin(w.midAngle());
+ case "inner": return w.bottom() + w.innerRadius() * Math.sin(w.midAngle());
+ case "start": return w.bottom() + w.midRadius() * Math.sin(w.startAngle());
+ case "center": return w.bottom() + w.midRadius() * Math.sin(w.midAngle());
+ case "end": return w.bottom() + w.midRadius() * Math.sin(w.endAngle());
+ }
+ return null;
+};
+
+/**
+ * The text-align property, for horizontal alignment inside the wedge.
+ *
+ * @type string
+ * @name pv.Wedge.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$textAlign = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return pv.Wedge.upright(w.midAngle()) ? "right" : "left";
+ case "inner": return pv.Wedge.upright(w.midAngle()) ? "left" : "right";
+ default: return "center";
+ }
+};
+
+/**
+ * The text-baseline property, for vertical alignment inside the wedge.
+ *
+ * @type string
+ * @name pv.Wedge.Anchor.prototype.textBaseline
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$textBaseline = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "start": return pv.Wedge.upright(w.startAngle()) ? "top" : "bottom";
+ case "end": return pv.Wedge.upright(w.endAngle()) ? "bottom" : "top";
+ default: return "middle";
+ }
+};
+
+/**
+ * The text-angle property, for text rotation inside the wedge.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.textAngle
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$textAngle = function() {
+ var w = this.anchorTarget();
+ var a = 0;
+ switch (this.get("name")) {
+ case "center":
+ case "inner":
+ case "outer": a = w.midAngle(); break;
+ case "start": a = w.startAngle(); break;
+ case "end": a = w.endAngle(); break;
+ }
+ return pv.Wedge.upright(a) ? a : (a + Math.PI);
+};
+
+/**
+ * Returns true if the specified angle is considered "upright", as in, text
+ * rendered at that angle would appear upright. If the angle is not upright,
+ * text is rotated 180 degrees to be upright, and the text alignment properties
+ * are correspondingly changed.
+ *
+ * @param {number} angle an angle, in radius.
+ * @returns {boolean} true if the specified angle is upright.
+ */
+pv.Wedge.upright = function(angle) {
+ angle = angle % (2 * Math.PI);
+ angle = (angle < 0) ? (2 * Math.PI + angle) : angle;
+ return (angle < Math.PI / 2) || (angle > 3 * Math.PI / 2);
+};
+
+/**
+ * Overrides the default behavior of {@link Mark#buildImplied} such that the end
+ * angle is computed from the start angle and angle (angular span) if not
+ * specified.
+ *
+ * @param s a node in the scene graph; the instance of the wedge to build.
+ */
+pv.Wedge.prototype.buildImplied = function(s) {
+ pv.Mark.prototype.buildImplied.call(this, s);
+ if (s.endAngle == null) {
+ s.endAngle = s.startAngle + s.angle;
+ }
+};
+
+/**
+ * Updates the display for the specified wedge instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the wedge,
+ * as well as positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the bar to update.
+ */
+pv.Wedge.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the <svg:path> element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "path");
+ v.setAttribute("fill-rule", "evenodd");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* visible, cursor, title, events, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top */
+ v.setAttribute("transform", "translate(" + s.left + "," + s.top +")");
+
+ /*
+ * TODO If the angle or endAngle is updated by an event handler, the implied
+ * properties won't recompute correctly, so this will lead to potentially
+ * buggy redraw. How to re-evaluate implied properties on update?
+ */
+
+ /* innerRadius, outerRadius, startAngle, endAngle */
+ var r1 = s.innerRadius, r2 = s.outerRadius;
+ if (s.angle >= 2 * Math.PI) {
+ if (r1) {
+ v.setAttribute("d", "M0," + r2
+ + "A" + r2 + "," + r2 + " 0 1,1 0," + (-r2)
+ + "A" + r2 + "," + r2 + " 0 1,1 0," + r2
+ + "M0," + r1
+ + "A" + r1 + "," + r1 + " 0 1,1 0," + (-r1)
+ + "A" + r1 + "," + r1 + " 0 1,1 0," + r1
+ + "Z");
+ } else {
+ v.setAttribute("d", "M0," + r2
+ + "A" + r2 + "," + r2 + " 0 1,1 0," + (-r2)
+ + "A" + r2 + "," + r2 + " 0 1,1 0," + r2
+ + "Z");
+ }
+ } else {
+ var c1 = Math.cos(s.startAngle), c2 = Math.cos(s.endAngle),
+ s1 = Math.sin(s.startAngle), s2 = Math.sin(s.endAngle);
+ if (r1) {
+ v.setAttribute("d", "M" + r2 * c1 + "," + r2 * s1
+ + "A" + r2 + "," + r2 + " 0 "
+ + ((s.angle < Math.PI) ? "0" : "1") + ",1 "
+ + r2 * c2 + "," + r2 * s2
+ + "L" + r1 * c2 + "," + r1 * s2
+ + "A" + r1 + "," + r1 + " 0 "
+ + ((s.angle < Math.PI) ? "0" : "1") + ",0 "
+ + r1 * c1 + "," + r1 * s1 + "Z");
+ } else {
+ v.setAttribute("d", "M" + r2 * c1 + "," + r2 * s1
+ + "A" + r2 + "," + r2 + " 0 "
+ + ((s.angle < Math.PI) ? "0" : "1") + ",1 "
+ + r2 * c2 + "," + r2 * s2 + "L0,0Z");
+ }
+ }
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+pv.Scales = {};
+pv.Scales.epsilon = 1e-30;
+pv.Scales.defaultBase = 10;
+
+/**
+ * Scale is a base class for scale objects. Scale objects are used to scale the
+ * data to a given range. The Scale object initially scales the value to the
+ * interval [0, 1]. The values are then mapped to a given range by the range()
+ * method.
+ */
+pv.Scales.Scale = function() {
+ // Pixel coordinate minimum
+ this._rMin = 0;
+ // Pixel coordinate maximum
+ this._rMax = 100;
+ // Round value?
+ this._round = true;
+};
+
+/**
+ * Sets the range to map the data to.
+ */
+pv.Scales.Scale.prototype.range = function(a, b) {
+ if (a == undefined) {
+ // use default values
+ // TODO: [0, 100] may not be the best default values.
+ // Find better default values, which may be different for each scale type.
+ } else if (b == undefined) {
+ this._rMin = 0;
+ this._rMax = a;
+ } else {
+ this._rMin = a;
+ this._rMax = b;
+ }
+
+ return this;
+};
+
+// Accessor method for range min
+pv.Scales.Scale.prototype.rangeMin = function(x) {
+ if (x == undefined) {
+ return this._rMin;
+ } else {
+ this._rMin = x;
+ return this;
+ }
+};
+
+// Accessor method for range max
+pv.Scales.Scale.prototype.rangeMax = function(x) {
+ if (x == undefined) {
+ return this._rMax;
+ } else {
+ this._rMax = x;
+ return this;
+ }
+};
+
+// Accessor method for round
+pv.Scales.Scale.prototype.round = function(x) {
+ if (x == undefined) {
+ return this._round;
+ } else {
+ this._round = x;
+ return this;
+ }
+};
+
+//Scales the input to the set range
+pv.Scales.Scale.prototype.scale = function(x) {
+ var v = this._rMin + (this._rMax-this._rMin) * this.normalize(x);
+ return this._round ? Math.round(v) : v;
+};
+
+// Returns the inverse scaled value.
+pv.Scales.Scale.prototype.invert = function(y) {
+ var n = (y - this._rMin) / (this._rMax - this._rMin);
+ return this.unnormalize(n);
+};
+pv.Scale = {};
+
+pv.Scale.linear = function() {
+ var min, max, nice = false, s, f = pv.identity;
+
+ /* Property function. */
+ function scale() {
+ if (s == undefined) {
+ if (min == undefined) min = pv.min(this.$$data, f);
+ if (max == undefined) max = pv.max(this.$$data, f);
+ if (nice) { // TODO Only "nice" bounds set automatically.
+ var step = Math.pow(10, Math.round(Math.log(max - min) / Math.log(10)) - 1);
+ min = Math.floor(min / step) * step;
+ max = Math.ceil(max / step) * step;
+ }
+ s = range.call(this) / (max - min);
+ }
+ return (f.apply(this, arguments) - min) * s;
+ }
+
+ function range() {
+ switch (property) {
+ case "height":
+ case "top":
+ case "bottom": return this.parent.height();
+ case "width":
+ case "left":
+ case "right": return this.parent.width();
+ default: return 1;
+ }
+ }
+
+ scale.by = function(v) { f = v; return this; };
+ scale.min = function(v) { min = v; return this; };
+ scale.max = function(v) { max = v; return this; };
+
+ scale.nice = function(v) {
+ nice = (arguments.length == 0) ? true : v;
+ return this;
+ };
+
+ scale.range = function() {
+ if (arguments.length == 1) {
+ o = 0;
+ s = arguments[0];
+ } else {
+ o = arguments[0];
+ s = arguments[1] - arguments[0];
+ }
+ return this;
+ };
+
+ return scale;
+};
+/**
+ * QuantitativeScale is a base class for representing quantitative numerical data
+ * scales.
+ */
+pv.Scales.QuantitativeScale = function(min, max, base) {
+ pv.Scales.Scale.call(this);
+
+ this._min = min;
+ this._max = max;
+ this._base = base==undefined ? pv.Scales.defaultBase : base;
+};
+
+pv.Scales.QuantitativeScale.prototype = pv.extend(pv.Scales.Scale);
+
+// Accessor method for min
+pv.Scales.QuantitativeScale.prototype.min = function(x) {
+ if (x == undefined) {
+ return this._min;
+ } else {
+ this._min = x;
+ return this;
+ }
+};
+
+// Accessor method for max
+pv.Scales.QuantitativeScale.prototype.max = function(x) {
+ if (x == undefined) {
+ return this._max;
+ } else {
+ this._max = x;
+ return this;
+ }
+};
+
+// Accessor method for base
+pv.Scales.QuantitativeScale.prototype.base = function(x) {
+ if (x == undefined) {
+ return this._base;
+ } else {
+ this._base = x;
+ return this;
+ }
+};
+
+// Checks if the mapped interval contains x
+pv.Scales.QuantitativeScale.prototype.contains = function(x) {
+ return (x >= this._min && x <= this._max);
+};
+
+// Returns the step for the scale
+pv.Scales.QuantitativeScale.prototype.step = function(min, max, base) {
+ if (!base) base = pv.Scales.defaultBase;
+ var exp = Math.round(Math.log(max-min)/Math.log(base)) - 1;
+
+ return Math.pow(base, exp);
+};
+pv.Scales.dateTime = function(min, max) {
+ return new pv.Scales.DateTimeScale(min, max);
+}
+
+/**
+ * DateTimeScale DateTimeScale scales time data.
+ */
+pv.Scales.DateTimeScale = function(min, max) {
+ pv.Scales.Scale.call(this);
+
+ this._min = min;
+ this._max = max;
+};
+
+pv.Scales.DateTimeScale.prototype = pv.extend(pv.Scales.Scale);
+
+// Accessor method for min
+pv.Scales.DateTimeScale.prototype.min = function(x) {
+ if (x == undefined) {
+ return this._min;
+ } else {
+ this._min = x;
+ return this;
+ }
+};
+
+// Accessor method for max
+pv.Scales.DateTimeScale.prototype.max = function(x) {
+ if (x == undefined) {
+ return this._max;
+ } else {
+ this._max = x;
+ return this;
+ }
+};
+
+// Normalizes DateTimeScale value
+pv.Scales.DateTimeScale.prototype.normalize = function(x) {
+ var eps = pv.Scales.epsilon;
+ var range = this._max - this._min;
+
+ return (range < eps && range > -eps) ? 0 : (x - this._min) / range;
+};
+
+// Un-normalizes the value
+pv.Scales.DateTimeScale.prototype.unnormalize = function(n) {
+ return n * (this._max - this._min) + this._min;
+};
+
+// Checks if the mapped interval contains x
+pv.Scales.DateTimeScale.prototype.contains = function(x) {
+ var t = x.valueOf();
+ return (t >= this._min.valueOf() && t <= this._max.valueOf());
+};
+
+// Sets min/max values to "nice" values
+pv.Scales.DateTimeScale.prototype.nice = function() {
+ var span = this.span(this._min, this._max);
+ this._min = this.round(this._min, span, false);
+ this._max = this.round(this._max, span, true);
+};
+
+/**
+ * Calculate a list of rule values covering the time range spaced at a
+ * configurable span.
+ *
+ * @param [forceSpan] If you want to force rule-generation from a span other
+ * than the default calculated by span, pass the value here.
+ * @param [beNice] Round the min and max values based on the span in use. If
+ * you are passing a value for forceSpan, you may also want to pass true
+ * for this argument.
+ *
+ * @return a list of rule values
+ */
+pv.Scales.DateTimeScale.prototype.ruleValues = function(forceSpan, beNice) {
+ var min = this._min.valueOf(), max = this._max.valueOf();
+ var span = (forceSpan == null) ? this.span(this._min, this._max) : forceSpan;
+ // We need to boost the step in order to avoid an infinite loop in the first
+ // case where we round. DST can cause a case where just one step is not
+ // enough to push round far enough.
+ var step = Math.floor(this.step(this._min, this._max, span) * 1.5);
+ var list = [];
+
+ var d = this._min;
+ if (beNice) {
+ d = this.round(d, span, false);
+ max = this.round(this._max, span, true).valueOf();
+ }
+ if (span < pv.Scales.DateTimeScale.Span.MONTHS) {
+ while (d.valueOf() <= max) {
+ list.push(d);
+ // we need to round to compensate for daylight savings time...
+ d = this.round(new Date(d.valueOf()+step), span, false);
+ }
+ } else if (span == pv.Scales.DateTimeScale.Span.MONTHS) {
+ // TODO: Handle quarters
+ step = 1;
+ while (d.valueOf() <= max) {
+ list.push(d);
+ d = new Date(d);
+ d.setMonth(d.getMonth() + step);
+ }
+ } else { // Span.YEARS
+ step = 1;
+ while (d.valueOf() <= max) {
+ list.push(d);
+ d = new Date(d);
+ d.setFullYear(d.getFullYear() + step);
+ }
+ }
+
+ return list;
+};
+
+// Time Span Constants
+pv.Scales.DateTimeScale.Span = {};
+pv.Scales.DateTimeScale.Span.YEARS = 0;
+pv.Scales.DateTimeScale.Span.MONTHS = -1;
+pv.Scales.DateTimeScale.Span.DAYS = -2;
+pv.Scales.DateTimeScale.Span.HOURS = -3;
+pv.Scales.DateTimeScale.Span.MINUTES = -4;
+pv.Scales.DateTimeScale.Span.SECONDS = -5;
+pv.Scales.DateTimeScale.Span.MILLISECONDS = -6;
+pv.Scales.DateTimeScale.Span.WEEKS = -10;
+pv.Scales.DateTimeScale.Span.QUARTERS = -11;
+
+// Rounds the date
+pv.Scales.DateTimeScale.prototype.round = function(t, span, roundUp) {
+ var Span = pv.Scales.DateTimeScale.Span;
+ var d = t, bias = roundUp ? 1 : 0;
+
+ if (span >= Span.YEARS) {
+ d = new Date(t.getFullYear() + bias, 0);
+ } else if (span == Span.MONTHS) {
+ d = new Date(t.getFullYear(), t.getMonth() + bias);
+ } else if (span == Span.DAYS) {
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate() + bias);
+ } else if (span == Span.HOURS) {
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours() + bias);
+ } else if (span == Span.MINUTES) {
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours(), t.getMinutes() + bias);
+ } else if (span == Span.SECONDS) {
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours(), t.getMinutes(), t.getSeconds() + bias);
+ } else if (span == Span.MILLISECONDS) {
+ d = new Date(d.time + (roundUp ? 1 : -1));
+ } else if (span == Span.WEEKS) {
+ bias = roundUp ? 7 - d.getDay() : -d.getDay();
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate() + bias);
+ }
+ return d;
+};
+
+// Returns the span of the given min/max values
+pv.Scales.DateTimeScale.prototype.span = function(min, max) {
+ var MS_MIN = 60*1000, MS_HOUR = 60*MS_MIN, MS_DAY = 24*MS_HOUR, MS_WEEK = 7*MS_DAY;
+ var Span = pv.Scales.DateTimeScale.Span;
+ var span = max.valueOf() - min.valueOf();
+ var days = span / MS_DAY;
+
+ // TODO: handle Weeks/Quarters
+ if (days >= 365*2) return (1 + max.getFullYear()-min.getFullYear());
+ else if (days >= 60) return Span.MONTHS;
+ else if (span/MS_WEEK > 1) return Span.WEEKS;
+ else if (span/MS_DAY > 1) return Span.DAYS;
+ else if (span/MS_HOUR > 1) return Span.HOURS;
+ else if (span/MS_MIN > 1) return Span.MINUTES;
+ else if (span/1000.0 > 1) return Span.SECONDS;
+ else return Span.MILLISECONDS;
+}
+
+// Returns the step for the scale
+pv.Scales.DateTimeScale.prototype.step = function(min, max, span) {
+ var Span = pv.Scales.DateTimeScale.Span;
+
+ if (span > Span.YEARS) {
+ var exp = Math.round(Math.log(Math.max(1,span-1)/Math.log(10))) - 1;
+ return Math.pow(10, exp);
+ } else if (span == Span.MONTHS) {
+ return 0;
+ } else if (span == Span.WEEKS) {
+ return 7*24*60*60*1000;
+ } else if (span == Span.DAYS) {
+ return 24*60*60*1000;
+ } else if (span == Span.HOURS) {
+ return 60*60*1000;
+ } else if (span == Span.MINUTES) {
+ return 60*1000;
+ } else if (span == Span.SECONDS) {
+ return 1000;
+ } else {
+ return 1;
+ }
+};
+pv.Scales.linear = function(min, max, base) {
+ return new pv.Scales.LinearScale(min, max, base);
+};
+
+pv.Scales.linear.fromData = function(data, f, base) {
+ return new pv.Scales.LinearScale(pv.min(data, f), pv.max(data, f), base);
+}
+
+/**
+ * LinearScale is a QuantativeScale that spaces values linearly along the scale
+ * range. This is the default scale for numeric types.
+ */
+pv.Scales.LinearScale = function(min, max, base) {
+ pv.Scales.QuantitativeScale.call(this, min, max, base);
+};
+
+pv.Scales.LinearScale.prototype = pv.extend(pv.Scales.QuantitativeScale);
+
+// Normalizes the value
+pv.Scales.LinearScale.prototype.normalize = function(x) {
+ var eps = pv.Scales.epsilon;
+ var range = this._max - this._min;
+
+ return (range < eps && range > -eps) ? 0 : (x - this._min) / range;
+};
+
+// Un-normalizes the value
+pv.Scales.LinearScale.prototype.unnormalize = function(n) {
+ return n * (this._max - this._min) + this._min;
+};
+
+// Sets min/max values to "nice numbers"
+pv.Scales.LinearScale.prototype.nice = function() {
+ var step = this.step(this._min, this._max, this._base);
+
+ this._min = Math.floor(this._min / step) * step;
+ this._max = Math.ceil(this._max / step) * step;
+
+ return this;
+};
+
+// Returns a list of rule values
+pv.Scales.LinearScale.prototype.ruleValues = function() {
+ var step = this.step(this._min, this._max, this._base);
+
+ var start = Math.floor(this._min / step) * step;
+ var end = Math.ceil(this._max / step) * step;
+
+ var list = pv.range(start, end+step, step);
+
+ // Remove precision problems
+ // TODO move to tick rendering, not scales
+ if (step < 1) {
+ var exp = Math.round(Math.log(step)/Math.log(this._base));
+
+ for (var i = 0; i < list.length; i++) {
+ list[i] = list[i].toFixed(-exp);
+ }
+ }
+
+ // check end points
+ if (list[0] < this._min) list.splice(0, 1);
+ if (list[list.length-1] > this._max) list.splice(list.length-1, 1);
+
+ return list;
+};
+pv.Scales.log = function(min, max, base) {
+ return new pv.Scales.LogScale(min, max, base);
+};
+
+pv.Scales.log.fromData = function(data, f, base) {
+ return new pv.Scales.LogScale(pv.min(data, f), pv.max(data, f), base);
+}
+
+/*
+ * LogScale is a QuantativeScale that performs a log transformation of the
+ * data. The base of the logarithm is determined by the base property.
+ */
+pv.Scales.LogScale = function(min, max, base) {
+ pv.Scales.QuantitativeScale.call(this, min, max, base);
+
+ this.update();
+};
+
+// Zero-symmetric log function
+pv.Scales.LogScale.log = function(x, b) {
+ return x==0 ? 0 : x>0 ? Math.log(x)/Math.log(b) : -Math.log(-x)/Math.log(b);
+};
+
+// Adjusted zero-symmetric log function
+pv.Scales.LogScale.zlog = function(x, b) {
+ var s = (x < 0) ? -1 : 1;
+ x = s*x;
+ if (x < b) x += (b-x)/b;
+ return s * Math.log(x) / Math.log(b);
+};
+
+pv.Scales.LogScale.prototype = pv.extend(pv.Scales.QuantitativeScale);
+
+// Accessor method for min
+pv.Scales.LogScale.prototype.min = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.min.call(this, x);
+
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Accessor method for max
+pv.Scales.LogScale.prototype.max = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.max.call(this, x);
+
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Accessor method for base
+pv.Scales.LogScale.prototype.base = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.base.call(this, x);
+
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Normalizes the value
+pv.Scales.LogScale.prototype.normalize = function(x) {
+ var eps = pv.Scales.epsilon;
+ var range = this._lmax - this._lmin;
+
+ return (range < eps && range > -eps) ? 0 : (this._log(x, this._base) - this._lmin) / range;
+};
+
+// Un-normalizes the value
+pv.Scales.LogScale.prototype.unnormalize = function(n) {
+ // TODO: handle case where _log = zlog
+ return Math.pow(this._base, n * (this._lmax - this._lmin) + this._lmin);
+};
+
+/**
+ * Sets min/max values to "nice numbers" For LogScale, we compute "nice" min/max
+ * values for the log scale(_lmin, _lmax) first, then calculate the data min/max
+ * values from the log min/max values.
+ */
+pv.Scales.LogScale.prototype.nice = function() {
+ var step = 1; //this.step(this._lmin, this._lmax);
+
+ this._lmin = Math.floor(this._lmin / step) * step;
+ this._lmax = Math.ceil(this._lmax / step) * step;
+
+ // TODO: handle case where _log = zlog
+ this._min = Math.pow(this._base, this._lmin);
+ this._max = Math.pow(this._base, this._lmax);
+
+ return this;
+};
+
+// Returns a list of rule values
+pv.Scales.LogScale.prototype.ruleValues = function() {
+ var step = this.step(this._lmin, this._lmax);
+ if (step < 1) step = 1; // bound to 1
+
+ var start = Math.floor(this._lmin);
+ var end = Math.ceil(this._lmax);
+
+ var list =[];
+ var i, j, b;
+ for (i = start; i < end; i++) { // for each step
+ // add each rule value
+ // TODO: handle case where _log = zlog
+ b = Math.pow(this._base, i);
+ for (j = 1; j < this._base; j++) {
+ if (i >= 0) list.push(b*j);
+ else list.push((b*j).toFixed(-i));
+ }
+ }
+ list.push(b*this._base); // add max value
+
+ // check end points
+ if (list[0] < this._min) list.splice(0, 1);
+ if (list[list.length-1] > this._max) list.splice(list.length-1, 1);
+
+ return list;
+};
+
+// Update log scale values
+pv.Scales.LogScale.prototype.update = function() {
+ this._log = (this._min < 0 && this._max > 0) ? pv.Scales.LogScale.zlog : pv.Scales.LogScale.log;
+ this._lmin = this._log(this._min, this._base);
+ this._lmax = this._log(this._max, this._base);
+};
+/**
+ * Returns a {@link pv.Nest} operator for the specified array. This is a
+ * convenience factory method, equivalent to <tt>new pv.Nest(array)</tt>.
+ *
+ * @see pv.Nest
+ * @param {array} array an array of elements to nest.
+ * @returns {pv.Nest} a nest operator for the specified array.
+ */
+pv.nest = function(array) {
+ return new pv.Nest(array);
+};
+
+/**
+ * Constructs a nest operator for the specified array.
+ *
+ * @class Represents a {@link Nest} operator for the specified array. Nesting
+ * allows elements in an array to be grouped into a hierarchical tree
+ * structure. The levels in the tree are specified by <i>key</i> functions. The
+ * leaf nodes of the tree can be sorted by value, while the internal nodes can
+ * be sorted by key. Finally, the tree can be returned either has a
+ * multidimensional array via {@link #entries}, or as a hierarchical map via
+ * {@link #map}. The {@link #rollup} routine similarly returns a map, collapsing
+ * the elements in each leaf node using a summary function.
+ *
+ * <p>For example, consider the following tabular data structure of Barley
+ * yields, from various sites in Minnesota during 1931-2:
+ *
+ * <pre>{ yield: 27.00, variety: "Manchuria", year: 1931, site: "University Farm" },
+ * { yield: 48.87, variety: "Manchuria", year: 1931, site: "Waseca" },
+ * { yield: 27.43, variety: "Manchuria", year: 1931, site: "Morris" }, ...</pre>
+ *
+ * To facilitate visualization, it may be useful to nest the elements first by
+ * year, and then by variety, as follows:
+ *
+ * <pre>var nest = pv.nest(yields)
+ * .key(function(d) d.year)
+ * .key(function(d) d.variety)
+ * .entries();</pre>
+ *
+ * This returns a nested array. Each element of the outer array is a key-values
+ * pair, listing the values for each distinct key:
+ *
+ * <pre>{ key: 1931, values: [
+ * { key: "Manchuria", values: [
+ * { yield: 27.00, variety: "Manchuria", year: 1931, site: "University Farm" },
+ * { yield: 48.87, variety: "Manchuria", year: 1931, site: "Waseca" },
+ * { yield: 27.43, variety: "Manchuria", year: 1931, site: "Morris" },
+ * ...
+ * ]},
+ * { key: "Glabron", values: [
+ * { yield: 43.07, variety: "Glabron", year: 1931, site: "University Farm" },
+ * { yield: 55.20, variety: "Glabron", year: 1931, site: "Waseca" },
+ * ...
+ * ]},
+ * ]},
+ * { key: 1932, values: ... }</pre>
+ *
+ * Further details, including sorting and rollup, is provided below on the
+ * corresponding methods.
+ *
+ * @param {array} array an array of elements to nest.
+ */
+pv.Nest = function(array) {
+ this.array = array;
+ this.keys = [];
+};
+
+/**
+ * Nests using the specified key function. Multiple keys may be added to the
+ * nest; the array elements will be nested in the order keys are specified.
+ *
+ * @param {function} key a key function; must return a string or suitable map
+ * key.
+ * @return {pv.Nest} this.
+ */
+pv.Nest.prototype.key = function(key) {
+ this.keys.push(key);
+ return this;
+};
+
+/**
+ * Sorts the previously-added keys. The natural sort order is used by default
+ * (see {@link pv.naturalOrder}); if an alternative order is desired,
+ * <tt>order</tt> should be a comparator function. If this method is not called
+ * (i.e., keys are <i>unsorted</i>), keys will appear in the order they appear
+ * in the underlying elements array. For example,
+ *
+ * <pre>pv.nest(yields)
+ * .key(function(d) d.year)
+ * .key(function(d) d.variety)
+ * .sortKeys()
+ * .entries()</pre>
+ *
+ * groups yield data by year, then variety, and sorts the variety groups
+ * lexicographically (since the variety attribute is a string).
+ *
+ * <p>Key sort order is only used in conjunction with {@link #entries}, which
+ * returns an array of key-values pairs. If the nest is used to construct a
+ * {@link #map} instead, keys are unsorted.
+ *
+ * @param {function} [order] an optional comparator function.
+ * @returns {pv.Nest} this.
+ */
+pv.Nest.prototype.sortKeys = function(order) {
+ this.keys[this.keys.length - 1].order = order || pv.naturalOrder;
+ return this;
+};
+
+/**
+ * Sorts the leaf values. The natural sort order is used by default (see
+ * {@link pv.naturalOrder}); if an alternative order is desired, <tt>order</tt>
+ * should be a comparator function. If this method is not called (i.e., values
+ * are <i>unsorted</i>), values will appear in the order they appear in the
+ * underlying elements array. For example,
+ *
+ * <pre>pv.nest(yields)
+ * .key(function(d) d.year)
+ * .key(function(d) d.variety)
+ * .sortValues(function(a, b) a.yield - b.yield)
+ * .entries()</pre>
+ *
+ * groups yield data by year, then variety, and sorts the values for each
+ * variety group by yield.
+ *
+ * <p>Value sort order, unlike keys, applies to both {@link #entries} and
+ * {@link #map}. It has no effect on {@link #rollup}.
+ *
+ * @param {function} [order] an optional comparator function.
+ * @return {pv.Nest} this.
+ */
+pv.Nest.prototype.sortValues = function(order) {
+ this.order = order || pv.naturalOrder;
+ return this;
+};
+
+/**
+ * Returns a hierarchical map of values. Each key adds one level to the
+ * hierarchy. With only a single key, the returned map will have a key for each
+ * distinct value of the key function; the correspond value with be an array of
+ * elements with that key value. If a second key is added, this will be a nested
+ * map. For example:
+ *
+ * <pre>pv.nest(yields)
+ * .key(function(d) d.variety)
+ * .key(function(d) d.site)
+ * .map()</pre>
+ *
+ * returns a map <tt>m</tt> such that <tt>m[variety][site]</tt> is an array, a subset of
+ * <tt>yields</tt>, with each element having the given variety and site.
+ *
+ * @returns a hierarchical map of values.
+ */
+pv.Nest.prototype.map = function() {
+ var map = {}, values = [];
+
+ /* Build the map. */
+ for (var i, j = 0; j < this.array.length; j++) {
+ var x = this.array[j];
+ var m = map;
+ for (i = 0; i < this.keys.length - 1; i++) {
+ var k = this.keys[i](x);
+ if (!m[k]) m[k] = {};
+ m = m[k];
+ }
+ k = this.keys[i](x);
+ if (!m[k]) {
+ var a = [];
+ values.push(a);
+ m[k] = a;
+ }
+ m[k].push(x);
+ }
+
+ /* Sort each leaf array. */
+ if (this.order) {
+ for (var i = 0; i < values.length; i++) {
+ values[i].sort(this.order);
+ }
+ }
+
+ return map;
+};
+
+/**
+ * Returns a hierarchical nested array. This method is similar to
+ * {@link pv#entries}, but works recursively on the entire hierarchy. Rather
+ * than returning a map like {@link #map}, this method returns a nested
+ * array. Each element of the array has a <tt>key</tt> and <tt>values</tt>
+ * field. For leaf nodes, the <tt>values</tt> array will be a subset of the
+ * underlying elements array; for non-leaf nodes, the <tt>values</tt> array will
+ * contain more key-values pairs.
+ *
+ * <p>For an example usage, see the {@link Nest} constructor.
+ *
+ * @returns a hierarchical nested array.
+ */
+pv.Nest.prototype.entries = function() {
+
+ /** Recursively extracts the entries for the given map. */
+ function entries(map) {
+ var array = [];
+ for (var k in map) {
+ var v = map[k];
+ array.push({ key: k, values: (v instanceof Array) ? v : entries(v) });
+ };
+ return array;
+ }
+
+ /** Recursively sorts the values for the given key-values array. */
+ function sort(array, i) {
+ var o = this.keys[i].order;
+ if (o) array.sort(function(a, b) { return o(a.key, b.key); });
+ if (++i < this.keys.length) {
+ for (var j = 0; j < array.length; j++) {
+ sort.call(this, array[j].values, i);
+ }
+ }
+ return array;
+ }
+
+ return sort.call(this, entries(this.map()), 0);
+};
+
+/**
+ * Returns a rollup map. The behavior of this method is the same as
+ * {@link #map}, except that the leaf values are replaced with the return value
+ * of the specified rollup function <tt>f</tt>. For example,
+ *
+ * <pre>pv.nest(yields)
+ * .key(function(d) d.site)
+ * .rollup(function(v) pv.median(v, function(d) d.yield))</pre>
+ *
+ * first groups yield data by site, and then returns a map from site to median
+ * yield for the given site.
+ *
+ * @see #map
+ * @param {function} f a rollup function.
+ * @returns a hierarhical map, with the leaf values computed by <tt>f</tt>.
+ */
+pv.Nest.prototype.rollup = function(f) {
+
+ /** Recursively descends to the leaf nodes (arrays) and does rollup. */
+ function rollup(map) {
+ for (var key in map) {
+ var value = map[key];
+ if (value instanceof Array) {
+ map[key] = f(value);
+ } else {
+ rollup(value);
+ }
+ }
+ return map;
+ }
+
+ return rollup(this.map());
+};
+pv.Scales.ordinal = function(ordinals) {
+ return new pv.Scales.OrdinalScale(ordinals);
+};
+
+/**
+ * OrdinalScale is a Scale for ordered sequential data. This supports both
+ * numeric and non-numeric data, and simply places each element in sequence
+ * using the ordering found in the input data array.
+ */
+pv.Scales.OrdinalScale = function(ordinals) {
+ pv.Scales.Scale.call(this);
+
+ /* Filter the specified ordinals to their unique values. */
+ var seen = {};
+ this._ordinals = [];
+ for (var i = 0; i < ordinals.length; i++) {
+ var o = ordinals[i];
+ if (seen[o] == undefined) {
+ seen[o] = true;
+ this._ordinals.push(o);
+ }
+ }
+
+ this._map = pv.numerate(this._ordinals);
+};
+
+pv.Scales.OrdinalScale.prototype = pv.extend(pv.Scales.Scale);
+
+// Accessor method for ordinals
+pv.Scales.OrdinalScale.prototype.ordinals = function(ordinals) {
+ if (ordinals == undefined) {
+ return this._ordinals;
+ } else {
+ this._ordinals = ordinals;
+ this._map = pv.numerate(ordinals);
+ return this;
+ }
+};
+
+// Normalizes the value
+pv.Scales.OrdinalScale.prototype.normalize = function(x) {
+ var i = this._map[x];
+
+ // if x not an ordinal value(assume x is an index value)
+ if (i == undefined) i = x;
+
+ // Not sure if the value should be shifted
+ return (i == undefined) ? -1 : (i + 0.5) / this._ordinals.length;
+};
+
+// Returns the ordinal values for i
+pv.Scales.OrdinalScale.prototype.unnormalize = function(n) {
+ var i = Math.floor(n * this._ordinals.length - 0.5);
+ return this._ordinals[i];
+};
+
+// Returns a list of rule values
+pv.Scales.OrdinalScale.prototype.ruleValues = function() {
+ return pv.range(0.5, this._ordinals.length-0.5);
+};
+
+// Returns the width between rules
+pv.Scales.OrdinalScale.prototype.ruleWidth = function() {
+ return this.scale(1/this._ordinals.length);
+};
+pv.Scales.root = function(min, max, base) {
+ return new pv.Scales.RootScale(min, max, base);
+};
+
+pv.Scales.root.fromData = function(data, f, base) {
+ return new pv.Scales.RootScale(pv.min(data, f), pv.max(data, f), base);
+}
+
+/**
+ * RootScale is a QuantativeScale that performs a root transformation of the
+ * data. This could be a square root or any arbitrary power. A root scale may
+ * be a many-to-one mapping where the reverse mapping will not be correct.
+ */
+pv.Scales.RootScale = function(min, max, base) {
+ if (min instanceof Array) {
+ if (max == undefined) max = 2; // default base for root is 2.
+ } else {
+ if (base == undefined) base = 2; // default base for root is 2.
+ }
+
+ pv.Scales.QuantitativeScale.call(this, min, max, base);
+
+ this.update();
+};
+
+// Returns the root value with base b
+pv.Scales.RootScale.root = function (x, b) {
+ var s = (x < 0) ? -1 : 1;
+ return s * Math.pow(s * x, 1 / b);
+};
+
+pv.Scales.RootScale.prototype = pv.extend(pv.Scales.QuantitativeScale);
+
+// Accessor method for min
+pv.Scales.RootScale.prototype.min = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.min.call(this, x);
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Accessor method for max
+pv.Scales.RootScale.prototype.max = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.max.call(this, x);
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Accessor method for base
+pv.Scales.RootScale.prototype.base = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.base.call(this, x);
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Normalizes the value
+pv.Scales.RootScale.prototype.normalize = function(x) {
+ var eps = pv.Scales.epsilon;
+ var range = this._rmax - this._rmin;
+
+ return (range < eps && range > -eps) ? 0
+ : (pv.Scales.RootScale.root(x, this._base) - this._rmin)
+ / (this._rmax - this._rmin);
+};
+
+// Un-normalizes the value
+pv.Scales.RootScale.prototype.unnormalize = function(n) {
+ return Math.pow(n * (this._rmax - this._rmin) + this._rmin, this._base);
+};
+
+// Sets min/max values to "nice numbers"
+pv.Scales.RootScale.prototype.nice = function() {
+ var step = this.step(this._rmin, this._rmax);
+
+ this._rmin = Math.floor(this._rmin / step) * step;
+ this._rmax = Math.ceil(this._rmax / step) * step;
+
+ this._min = Math.pow(this._rmin, this._base);
+ this._max = Math.pow(this._rmax, this._base);
+
+ return this;
+};
+
+// Returns a list of rule values
+// The rule values of a root scale should be the powers
+// of integers, e.g. 1, 4, 9, ... for base = 2
+// TODO: This function needs further testing
+pv.Scales.RootScale.prototype.ruleValues = function() {
+ var step = this.step(this._rmin, this._rmax);
+// if (step < 1) step = 1; // bound to 1
+ // TODO: handle decimal values
+
+ var s;
+ var list = pv.range(Math.floor(this._rmin), Math.ceil(this._rmax), step);
+ for (var i = 0; i < list.length; i++) {
+ s = (list[i] < 0) ? -1 : 1;
+ list[i] = s*Math.pow(list[i], this._base);
+ }
+
+ // check end points
+ if (list[0] < this._min) list.splice(0, 1);
+ if (list[list.length-1] > this._max) list.splice(list.length-1, 1);
+
+ return list;
+};
+
+// Update root scale values
+pv.Scales.RootScale.prototype.update = function() {
+ var rt = pv.Scales.RootScale.root;
+ this._rmin = rt(this._min, this._base);
+ this._rmax = rt(this._max, this._base);
+};
+ return pv;
+}();
diff --git a/comm/mail/base/content/quickFilterBar.inc.xhtml b/comm/mail/base/content/quickFilterBar.inc.xhtml
new file mode 100644
index 0000000000..d7ddee8ef6
--- /dev/null
+++ b/comm/mail/base/content/quickFilterBar.inc.xhtml
@@ -0,0 +1,118 @@
+# 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/.
+
+ <div id="quick-filter-bar" class="themeable-brighttext" hidden="hidden">
+ <div id="quickFilterBarContainer">
+ <button is="toggle-button" id="qfb-sticky"
+ class="button icon-button icon-only check-button"
+ data-l10n-id="quick-filter-bar-sticky">
+ </button>
+ <xul:search-textbox id="qfb-qs-textbox"
+ class="themeableSearchBox"
+ timeout="500"
+ maxlength="192"
+ data-l10n-id="quick-filter-bar-textbox"
+ data-l10n-attrs="placeholder" />
+ <button id="qfd-dropdown"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="quick-filter-bar-dropdown">
+ </button>
+ <div class="roving-group button-group quickFilterButtons">
+ <button is="toggle-button" id="qfb-unread"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-unread">
+ <span data-l10n-id="quick-filter-bar-unread-label"></span>
+ </button>
+ <button is="toggle-button" id="qfb-starred"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-starred">
+ <span data-l10n-id="quick-filter-bar-starred-label"></span>
+ </button>
+ <button is="toggle-button" id="qfb-inaddrbook"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-inaddrbook">
+ <span data-l10n-id="quick-filter-bar-inaddrbook-label"></span>
+ </button>
+ <button is="toggle-button" id="qfb-tags"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-tags">
+ <span data-l10n-id="quick-filter-bar-tags-label"></span>
+ </button>
+ <button is="toggle-button" id="qfb-attachment"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-attachment">
+ <span data-l10n-id="quick-filter-bar-attachment-label"></span>
+ </button>
+ </div>
+ <span id="qfb-results-label"></span>
+ </div>
+ <div id="quickFilterBarSecondFilters">
+ <div id="quick-filter-bar-filter-text-bar" hidden="hidden">
+ <span id="qfb-qs-label"
+ data-l10n-id="quick-filter-bar-text-filter-explanation"></span>
+ <div class="roving-group button-group">
+ <button is="toggle-button" id="qfb-qs-sender"
+ class="button check-button"
+ data-l10n-id="quick-filter-bar-text-filter-sender"></button>
+ <button is="toggle-button" id="qfb-qs-recipients"
+ class="button check-button"
+ data-l10n-id="quick-filter-bar-text-filter-recipients"></button>
+ <button is="toggle-button" id="qfb-qs-subject"
+ class="button check-button"
+ data-l10n-id="quick-filter-bar-text-filter-subject"></button>
+ <button is="toggle-button" id="qfb-qs-body"
+ class="button check-button"
+ data-l10n-id="quick-filter-bar-text-filter-body"></button>
+ </div>
+ </div>
+ <div id="quickFilterBarTagsContainer" hidden="hidden">
+ <xul:menulist id="qfb-boolean-mode" value="OR">
+ <xul:menupopup>
+ <xul:menuitem id="qfb-boolean-mode-or"
+ value="OR"
+ data-l10n-id="quick-filter-bar-boolean-mode-any"
+ default="default"/>
+ <xul:menuitem id="qfb-boolean-mode-and"
+ value="AND"
+ data-l10n-id="quick-filter-bar-boolean-mode-all"/>
+ </xul:menupopup>
+ </xul:menulist>
+ </div>
+ </div>
+ </div>
+ <xul:menupopup id="quickFilterButtonsContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft"
+ onpopupshowing="quickFilterBar.updateCheckedStateQuickFilterButtons();">
+ <xul:menuitem id="quickFilterButtonsContextUnreadToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="unread"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-unread"/>
+ <xul:menuitem id="quickFilterButtonsContextStarredToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="starred"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-starred"/>
+ <xul:menuitem id="quickFilterButtonsContextInaddrbookToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="addrBook"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-inaddrbook"/>
+ <xul:menuitem id="quickFilterButtonsContextTagsToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="tags"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-tags"/>
+ <xul:menuitem id="quickFilterButtonsContextAttachmentToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="attachment"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-attachment"/>
+ </xul:menupopup>
diff --git a/comm/mail/base/content/quickFilterBar.js b/comm/mail/base/content/quickFilterBar.js
new file mode 100644
index 0000000000..e254b91416
--- /dev/null
+++ b/comm/mail/base/content/quickFilterBar.js
@@ -0,0 +1,603 @@
+/* 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 about3Pane.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MessageTextFilter: "resource:///modules/QuickFilterManager.jsm",
+ SearchSpec: "resource:///modules/SearchSpec.jsm",
+ QuickFilterManager: "resource:///modules/QuickFilterManager.jsm",
+ QuickFilterSearchListener: "resource:///modules/QuickFilterManager.jsm",
+ QuickFilterState: "resource:///modules/QuickFilterManager.jsm",
+});
+
+class ToggleButton extends HTMLButtonElement {
+ constructor() {
+ super();
+ this.addEventListener("click", () => {
+ this.pressed = !this.pressed;
+ });
+ }
+
+ connectedCallback() {
+ this.setAttribute("is", "toggle-button");
+ if (!this.hasAttribute("aria-pressed")) {
+ this.pressed = false;
+ }
+ }
+
+ get pressed() {
+ return this.getAttribute("aria-pressed") === "true";
+ }
+
+ set pressed(value) {
+ this.setAttribute("aria-pressed", value ? "true" : "false");
+ }
+}
+customElements.define("toggle-button", ToggleButton, { extends: "button" });
+
+var quickFilterBar = {
+ _filterer: null,
+ activeTopLevelFilters: new Set(),
+ topLevelFilters: ["unread", "starred", "addrBook", "attachment"],
+
+ /**
+ * The UI element that last triggered a search. This can be used to avoid
+ * updating the element when a search returns - in particular the text box,
+ * which the user may still be typing into.
+ *
+ * @type {Element}
+ */
+ activeElement: null,
+
+ init() {
+ this._bindUI();
+ this.updateRovingTab();
+
+ // Enable any filters set by the user.
+ // If keep filters applied/sticky setting is enabled, enable sticky.
+ let xulStickyVal = Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilterBarSticky",
+ "enabled"
+ );
+ if (xulStickyVal) {
+ this.filterer.setFilterValue("sticky", xulStickyVal == "true");
+
+ // If sticky is set, show saved filters.
+ // Otherwise do not display saved filters on load.
+ if (xulStickyVal == "true") {
+ // If any filter settings are enabled, retrieve the enabled filters.
+ let enabledTopFiltersVal = Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilter",
+ "enabledTopFilters"
+ );
+
+ // Set any enabled filters to enabled in the UI.
+ if (enabledTopFiltersVal) {
+ let enabledTopFilters = JSON.parse(enabledTopFiltersVal);
+ for (let filterName of enabledTopFilters) {
+ this.activeTopLevelFilters.add(filterName);
+ this.filterer.setFilterValue(filterName, true);
+ }
+ }
+ }
+ }
+
+ // Hide the toolbar, unless it has been previously shown.
+ if (
+ Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilterBar",
+ "collapsed"
+ ) === "false"
+ ) {
+ this._showFilterBar(true, true);
+ } else {
+ this._showFilterBar(false, true);
+ }
+
+ commandController.registerCallback("cmd_showQuickFilterBar", () => {
+ if (!this.filterer.visible) {
+ this._showFilterBar(true);
+ }
+ document.getElementById(QuickFilterManager.textBoxDomId).select();
+ });
+ commandController.registerCallback("cmd_toggleQuickFilterBar", () => {
+ let show = !this.filterer.visible;
+ this._showFilterBar(show);
+ if (show) {
+ document.getElementById(QuickFilterManager.textBoxDomId).select();
+ }
+ });
+ window.addEventListener("keypress", event => {
+ if (event.keyCode != KeyEvent.DOM_VK_ESCAPE || !this.filterer.visible) {
+ // The filter bar isn't visible, do nothing.
+ return;
+ }
+ if (this.filterer.userHitEscape()) {
+ // User hit the escape key; do our undo-ish thing.
+ this.updateSearch();
+ this.reflectFiltererState();
+ } else {
+ // Close the filter since there was nothing left to relax.
+ this._showFilterBar(false);
+ }
+ });
+
+ document.getElementById("qfd-dropdown").addEventListener("click", event => {
+ document
+ .getElementById("quickFilterButtonsContext")
+ .openPopup(event.target, { triggerEvent: event });
+ });
+
+ for (let buttonGroup of this.rovingGroups) {
+ buttonGroup.addEventListener("keypress", event => {
+ this.triggerQFTRovingTab(event);
+ });
+ }
+
+ document.getElementById("qfb-sticky").addEventListener("click", event => {
+ let stickyValue = event.target.pressed ? "true" : "false";
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilterBarSticky",
+ "enabled",
+ stickyValue
+ );
+ });
+ },
+
+ /**
+ * Get all button groups with the roving-group class.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get rovingGroups() {
+ return document.querySelectorAll("#quick-filter-bar .roving-group");
+ },
+
+ /**
+ * Update the `tabindex` attribute of the buttons.
+ */
+ updateRovingTab() {
+ for (let buttonGroup of this.rovingGroups) {
+ for (let button of buttonGroup.querySelectorAll("button")) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ buttonGroup.querySelector("button").tabIndex = 0;
+ }
+ },
+
+ /**
+ * Handles the keypress event on the button group.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerQFTRovingTab(event) {
+ if (!["ArrowRight", "ArrowLeft"].includes(event.key)) {
+ return;
+ }
+
+ let buttonGroup = [
+ ...event.target
+ .closest(".roving-group")
+ .querySelectorAll(`[is="toggle-button"]`),
+ ];
+ let focusableButton = buttonGroup.find(b => b.tabIndex != -1);
+ let elementIndex = buttonGroup.indexOf(focusableButton);
+
+ // Find the adjacent focusable element based on the pressed key.
+ let isRTL = document.dir == "rtl";
+ if (
+ (isRTL && event.key == "ArrowLeft") ||
+ (!isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > buttonGroup.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (!isRTL && event.key == "ArrowLeft") ||
+ (isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = buttonGroup.length - 1;
+ }
+ }
+
+ // Move the focus to a button and update the tabindex attribute.
+ let newFocusableButton = buttonGroup[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.tabIndex = 0;
+ newFocusableButton.focus();
+ }
+ },
+
+ get filterer() {
+ if (!this._filterer) {
+ this._filterer = new QuickFilterState();
+ this._filterer.visible = false;
+ }
+ return this._filterer;
+ },
+
+ set filterer(value) {
+ this._filterer = value;
+ },
+
+ // ---------------------
+ // UI State Manipulation
+
+ /**
+ * Add appropriate event handlers to the DOM elements. We do this rather
+ * than requiring lots of boilerplate "oncommand" junk on the nodes.
+ *
+ * We hook up the following:
+ * - "command" event listener.
+ * - reflect filter state
+ */
+ _bindUI() {
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ let domNode = document.getElementById(filterDef.domId);
+ let menuItemNode = document.getElementById(filterDef.menuItemID);
+
+ let handlerDomId, handlerMenuItems;
+
+ if (!("onCommand" in filterDef)) {
+ handlerDomId = event => {
+ try {
+ let postValue = domNode.pressed ? true : null;
+ this.filterer.setFilterValue(filterDef.name, postValue);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ this.deferredUpdateSearch(domNode);
+ } catch (ex) {
+ console.error(ex);
+ }
+ };
+ handlerMenuItems = event => {
+ try {
+ let postValue = menuItemNode.hasAttribute("checked") ? true : null;
+ this.filterer.setFilterValue(filterDef.name, postValue);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ this.deferredUpdateSearch();
+ } catch (ex) {
+ console.error(ex);
+ }
+ };
+ } else {
+ handlerDomId = event => {
+ if (filterDef.name == "tags") {
+ filterDef.callID = "button";
+ }
+ let filterValues = this.filterer.filterValues;
+ let preValue =
+ filterDef.name in filterValues
+ ? filterValues[filterDef.name]
+ : null;
+ let [postValue, update] = filterDef.onCommand(
+ preValue,
+ domNode,
+ event,
+ document
+ );
+ this.filterer.setFilterValue(filterDef.name, postValue, !update);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ if (update) {
+ this.deferredUpdateSearch(domNode);
+ }
+ };
+ handlerMenuItems = event => {
+ if (filterDef.name == "tags") {
+ filterDef.callID = "menuItem";
+ }
+ let filterValues = this.filterer.filterValues;
+ let preValue =
+ filterDef.name in filterValues
+ ? filterValues[filterDef.name]
+ : null;
+ let [postValue, update] = filterDef.onCommand(
+ preValue,
+ menuItemNode,
+ event,
+ document
+ );
+ this.filterer.setFilterValue(filterDef.name, postValue, !update);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ if (update) {
+ this.deferredUpdateSearch();
+ }
+ };
+ }
+
+ if (domNode.namespaceURI == document.documentElement.namespaceURI) {
+ domNode.addEventListener("click", handlerDomId);
+ } else {
+ domNode.addEventListener("command", handlerDomId);
+ }
+ if (menuItemNode !== null) {
+ menuItemNode.addEventListener("command", handlerMenuItems);
+ }
+
+ if ("domBindExtra" in filterDef) {
+ filterDef.domBindExtra(document, this, domNode);
+ }
+ }
+ },
+
+ /**
+ * Update enabled filters in XULStore.
+ */
+ updateFiltersSettings(filterName, filterValue) {
+ if (this.topLevelFilters.includes(filterName)) {
+ this.updateTopLevelFilters(filterName, filterValue);
+ }
+ },
+
+ /**
+ * Update enabled top level filters in XULStore.
+ */
+ updateTopLevelFilters(filterName, filterValue) {
+ if (filterValue) {
+ this.activeTopLevelFilters.add(filterName);
+ } else {
+ this.activeTopLevelFilters.delete(filterName);
+ }
+
+ // Save enabled filter settings to XULStore.
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilter",
+ "enabledTopFilters",
+ JSON.stringify(Array.from(this.activeTopLevelFilters))
+ );
+ },
+
+ /**
+ * Ensure all the quick filter menuitems in the quick filter dropdown menu are
+ * checked to reflect their current state.
+ */
+ updateCheckedStateQuickFilterButtons() {
+ for (let item of document.querySelectorAll(".quick-filter-menuitem")) {
+ if (Object.hasOwn(this.filterer.filterValues, `${item.value}`)) {
+ item.setAttribute("checked", true);
+ continue;
+ }
+ item.removeAttribute("checked");
+ }
+ },
+
+ /**
+ * Update the UI to reflect the state of the filterer constraints.
+ *
+ * @param [aFilterName] If only a single filter needs to be updated, name it.
+ */
+ reflectFiltererState(aFilterName) {
+ // If we aren't visible then there is no need to update the widgets.
+ if (this.filterer.visible) {
+ let filterValues = this.filterer.filterValues;
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ // If we only need to update one state, check and skip as appropriate.
+ if (aFilterName && filterDef.name != aFilterName) {
+ continue;
+ }
+
+ let domNode = document.getElementById(filterDef.domId);
+
+ let value =
+ filterDef.name in filterValues ? filterValues[filterDef.name] : null;
+ if (!("reflectInDOM" in filterDef)) {
+ domNode.pressed = value;
+ } else {
+ filterDef.reflectInDOM(domNode, value, document, this);
+ }
+ }
+ }
+
+ this.reflectFiltererResults();
+
+ this.domNode.hidden = !this.filterer.visible;
+ },
+
+ /**
+ * Update the UI to reflect the state of the folderDisplay in terms of
+ * filtering. This is expected to be called by |reflectFiltererState| and
+ * when something happens event-wise in terms of search.
+ *
+ * We can have one of two states:
+ * - No filter is active; no attributes exposed for CSS to do anything.
+ * - A filter is active and we are still searching; filterActive=searching.
+ */
+ reflectFiltererResults() {
+ let threadPane = document.getElementById("threadTree");
+
+ // bail early if the view is in the process of being created
+ if (!gDBView) {
+ return;
+ }
+
+ // no filter active
+ if (!gViewWrapper.search || !gViewWrapper.search.userTerms) {
+ threadPane.removeAttribute("filterActive");
+ this.domNode.removeAttribute("filterActive");
+ } else if (gViewWrapper.searching) {
+ // filter active, still searching
+ // Do not set this immediately; wait a bit and then only set this if we
+ // still are in this same state (and we are still the active tab...)
+ setTimeout(() => {
+ threadPane.setAttribute("filterActive", "searching");
+ this.domNode.setAttribute("filterActive", "searching");
+ }, 500);
+ }
+ },
+
+ // ----------------------
+ // Event Handling Support
+
+ /**
+ * Retrieve the current filter state value (presumably an object) for mutation
+ * purposes. This causes the filter to be the last touched filter for escape
+ * undo-ish purposes.
+ */
+ getFilterValueForMutation(aName) {
+ return this.filterer.getFilterValue(aName);
+ },
+
+ /**
+ * Set the filter state for the given named filter to the given value. This
+ * causes the filter to be the last touched filter for escape undo-ish
+ * purposes.
+ *
+ * @param aName Filter name.
+ * @param aValue The new filter state.
+ */
+ setFilterValue(aName, aValue) {
+ this.filterer.setFilterValue(aName, aValue);
+ },
+
+ /**
+ * For UI responsiveness purposes, defer the actual initiation of the search
+ * until after the button click handling has completed and had the ability
+ * to paint such.
+ *
+ * @param {Element} activeElement - The element that triggered a call to
+ * this function, if any.
+ */
+ deferredUpdateSearch(activeElement) {
+ setTimeout(() => this.updateSearch(activeElement), 10);
+ },
+
+ /**
+ * Update the user terms part of the search definition to reflect the active
+ * filterer's current state.
+ *
+ * @param {Element?} activeElement - The element that triggered a call to
+ * this function, if any.
+ */
+ updateSearch(activeElement) {
+ if (!this._filterer || !gViewWrapper?.search) {
+ return;
+ }
+
+ this.activeElement = activeElement;
+ this.filterer.displayedFolder = gFolder;
+
+ let [terms, listeners] = this.filterer.createSearchTerms(
+ gViewWrapper.search.session
+ );
+
+ for (let [listener, filterDef] of listeners) {
+ // it registers itself with the search session.
+ new QuickFilterSearchListener(
+ gViewWrapper,
+ this.filterer,
+ filterDef,
+ listener,
+ quickFilterBar
+ );
+ }
+
+ gViewWrapper.search.userTerms = terms;
+ // Uncomment to know what the search state is when we (try and) update it.
+ // dump(tab.folderDisplay.view.search.prettyString());
+ },
+
+ /**
+ * Shows and hides quick filter bar, and sets the XUL Store value for the
+ * quick filter bar status.
+ *
+ * @param {boolean} show - Filter Status.
+ * @param {boolean} [init=false] - Initial Function Call.
+ */
+ _showFilterBar(show, init = false) {
+ this.filterer.visible = show;
+ if (!show) {
+ this.filterer.clear();
+ this.updateSearch();
+ // Cannot call the below function when threadTree hasn't been initialized yet.
+ if (!init) {
+ threadTree.table.body.focus();
+ }
+ }
+ this.reflectFiltererState();
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilterBar",
+ "collapsed",
+ !show
+ );
+
+ window.dispatchEvent(new Event("qfbtoggle"));
+ },
+
+ /**
+ * Called by the view wrapper so we can update the results count.
+ */
+ onMessagesChanged() {
+ let filtering = gViewWrapper.search?.userTerms != null;
+ let newCount = filtering ? gDBView.numMsgsInView : null;
+ this.filterer.setFilterValue("results", newCount, true);
+
+ // - postFilterProcess everyone who cares
+ // This may need to be converted into an asynchronous process at some point.
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ if ("postFilterProcess" in filterDef) {
+ let preState =
+ filterDef.name in this.filterer.filterValues
+ ? this.filterer.filterValues[filterDef.name]
+ : null;
+ let [newState, update, treatAsUserAction] = filterDef.postFilterProcess(
+ preState,
+ gViewWrapper,
+ filtering
+ );
+ this.filterer.setFilterValue(
+ filterDef.name,
+ newState,
+ !treatAsUserAction
+ );
+ if (update) {
+ let domNode = document.getElementById(filterDef.domId);
+ // We are passing update as a super-secret data propagation channel
+ // exclusively for one-off cases like the text filter gloda upsell.
+ filterDef.reflectInDOM(domNode, newState, document, this, update);
+ }
+ }
+ }
+
+ // - Update match status.
+ this.reflectFiltererState();
+ },
+
+ /**
+ * The displayed folder changed. Reset or reapply the filter, depending on
+ * the sticky state.
+ */
+ onFolderChanged() {
+ this.filterer = new QuickFilterState(this.filterer);
+ this.reflectFiltererState();
+ if (this._filterer?.filterValues.sticky) {
+ this.updateSearch();
+ }
+ },
+
+ _testHelperResetFilterState() {
+ if (!this._filterer) {
+ return;
+ }
+ this._filterer = new QuickFilterState();
+ this.updateSearch();
+ this.reflectFiltererState();
+ },
+};
+XPCOMUtils.defineLazyGetter(quickFilterBar, "domNode", () =>
+ document.getElementById("quick-filter-bar")
+);
diff --git a/comm/mail/base/content/sanitize.js b/comm/mail/base/content/sanitize.js
new file mode 100644
index 0000000000..68510faeac
--- /dev/null
+++ b/comm/mail/base/content/sanitize.js
@@ -0,0 +1,241 @@
+/* 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 { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+function Sanitizer() {}
+Sanitizer.prototype = {
+ // warning to the caller: this one may raise an exception (e.g. bug #265028)
+ clearItem(aItemName) {
+ if (this.items[aItemName].canClear) {
+ this.items[aItemName].clear();
+ }
+ },
+
+ canClearItem(aItemName) {
+ return this.items[aItemName].canClear;
+ },
+
+ prefDomain: "",
+
+ getNameFromPreference(aPreferenceName) {
+ return aPreferenceName.substr(this.prefDomain.length);
+ },
+
+ /**
+ * Deletes privacy sensitive data in a batch, according to user preferences
+ *
+ * @returns null if everything's fine; an object in the form
+ * { itemName: error, ... } on (partial) failure
+ */
+ sanitize() {
+ var branch = Services.prefs.getBranch(this.prefDomain);
+ var errors = null;
+
+ // Cache the range of times to clear
+ if (this.ignoreTimespan) {
+ // If we ignore timespan, clear everything.
+ var range = null;
+ } else {
+ range = this.range || Sanitizer.getClearRange();
+ }
+
+ for (var itemName in this.items) {
+ var item = this.items[itemName];
+ item.range = range;
+ if ("clear" in item && item.canClear && branch.getBoolPref(itemName)) {
+ // Some of these clear() may raise exceptions (see bug #265028)
+ // to sanitize as much as possible, we catch and store them,
+ // rather than fail fast.
+ // Callers should check returned errors and give user feedback
+ // about items that could not be sanitized
+ try {
+ item.clear();
+ } catch (er) {
+ if (!errors) {
+ errors = {};
+ }
+ errors[itemName] = er;
+ dump("Error sanitizing " + itemName + ": " + er + "\n");
+ }
+ }
+ }
+ return errors;
+ },
+
+ // Time span only makes sense in certain cases. Consumers who want
+ // to only clear some private data can opt in by setting this to false,
+ // and can optionally specify a specific range. If timespan is not ignored,
+ // and range is not set, sanitize() will use the value of the timespan
+ // pref to determine a range
+ ignoreTimespan: true,
+ range: null,
+
+ items: {
+ cache: {
+ clear() {
+ try {
+ // Cache doesn't consult timespan, nor does it have the
+ // facility for timespan-based eviction. Wipe it.
+ Services.cache2.clear();
+ } catch (ex) {}
+ },
+
+ get canClear() {
+ return true;
+ },
+ },
+
+ cookies: {
+ clear() {
+ if (this.range) {
+ // Iterate through the cookies and delete any created after our cutoff.
+ for (let cookie of Services.cookies.cookies) {
+ if (cookie.creationTime > this.range[0]) {
+ // This cookie was created after our cutoff, clear it
+ Services.cookies.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ cookie.originAttributes
+ );
+ }
+ }
+ } else {
+ // Remove everything
+ Services.cookies.removeAll();
+ }
+ },
+
+ get canClear() {
+ return true;
+ },
+ },
+
+ history: {
+ clear() {
+ if (this.range) {
+ PlacesUtils.history.removeVisitsByFilter({
+ beginDate: new Date(this.range[0]),
+ endDate: new Date(this.range[1]),
+ });
+ } else {
+ PlacesUtils.history.clear();
+ }
+
+ try {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ } catch (e) {}
+
+ try {
+ var predictor = Cc["@mozilla.org/network/predictor;1"].getService(
+ Ci.nsINetworkPredictor
+ );
+ predictor.reset();
+ } catch (e) {}
+ },
+
+ get canClear() {
+ // bug 347231: Always allow clearing history due to dependencies on
+ // the browser:purge-session-history notification. (like error console)
+ return true;
+ },
+ },
+ },
+};
+
+// "Static" members
+Sanitizer.prefDomain = "privacy.sanitize.";
+Sanitizer.prefShutdown = "sanitizeOnShutdown";
+Sanitizer.prefDidShutdown = "didShutdownSanitize";
+
+// Time span constants corresponding to values of the privacy.sanitize.timeSpan
+// pref. Used to determine how much history to clear, for various items
+Sanitizer.TIMESPAN_EVERYTHING = 0;
+Sanitizer.TIMESPAN_HOUR = 1;
+Sanitizer.TIMESPAN_2HOURS = 2;
+Sanitizer.TIMESPAN_4HOURS = 3;
+Sanitizer.TIMESPAN_TODAY = 4;
+
+// Return a 2 element array representing the start and end times,
+// in the uSec-since-epoch format that PRTime likes. If we should
+// clear everything, return null. Use ts if it is defined; otherwise
+// use the timeSpan pref.
+Sanitizer.getClearRange = function (ts) {
+ if (ts === undefined) {
+ ts = Sanitizer.prefs.getIntPref("timeSpan");
+ }
+ if (ts === Sanitizer.TIMESPAN_EVERYTHING) {
+ return null;
+ }
+
+ // PRTime is microseconds while JS time is milliseconds
+ var endDate = Date.now() * 1000;
+ switch (ts) {
+ case Sanitizer.TIMESPAN_HOUR:
+ var startDate = endDate - 3600000000; // 1*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_2HOURS:
+ startDate = endDate - 7200000000; // 2*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_4HOURS:
+ startDate = endDate - 14400000000; // 4*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_TODAY:
+ var d = new Date(); // Start with today
+ d.setHours(0); // zero us back to midnight...
+ d.setMinutes(0);
+ d.setSeconds(0);
+ startDate = d.valueOf() * 1000; // convert to epoch usec
+ break;
+ default:
+ throw new Error("Invalid time span for clear private data: " + ts);
+ }
+ return [startDate, endDate];
+};
+
+Sanitizer._prefs = null;
+Sanitizer.__defineGetter__("prefs", function () {
+ return Sanitizer._prefs
+ ? Sanitizer._prefs
+ : (Sanitizer._prefs = Services.prefs.getBranch(Sanitizer.prefDomain));
+});
+
+// Shows sanitization UI
+Sanitizer.showUI = function (aParentWindow) {
+ Services.ww.openWindow(
+ AppConstants.platform == "macosx" ? null : aParentWindow,
+ "chrome://messenger/content/sanitize.xhtml",
+ "Sanitize",
+ "chrome,titlebar,dialog,centerscreen,modal",
+ null
+ );
+};
+
+/**
+ * Deletes privacy sensitive data in a batch, optionally showing the
+ * sanitize UI, according to user preferences
+ */
+Sanitizer.sanitize = function (aParentWindow) {
+ Sanitizer.showUI(aParentWindow);
+};
+
+// this is called on startup and shutdown, to perform pending sanitizations
+Sanitizer._checkAndSanitize = function () {
+ const prefs = Sanitizer.prefs;
+ if (
+ prefs.getBoolPref(Sanitizer.prefShutdown) &&
+ !prefs.prefHasUserValue(Sanitizer.prefDidShutdown)
+ ) {
+ // this is a shutdown or a startup after an unclean exit
+ var s = new Sanitizer();
+ s.prefDomain = "privacy.clearOnShutdown.";
+ s.sanitize() || prefs.setBoolPref(Sanitizer.prefDidShutdown, true); // sanitize() returns null on full success
+ }
+};
diff --git a/comm/mail/base/content/sanitize.xhtml b/comm/mail/base/content/sanitize.xhtml
new file mode 100644
index 0000000000..b9186841ab
--- /dev/null
+++ b/comm/mail/base/content/sanitize.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"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/sanitizeDialog.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE window [
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+ <!ENTITY % sanitizeDTD SYSTEM "chrome://messenger/locale/sanitize.dtd">
+ %brandDTD;
+ %sanitizeDTD;
+]>
+
+<window type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&sanitizeDialog2.title;"
+ noneverythingtitle="&sanitizeDialog2.title;"
+ style="min-width: &dialog.width;"
+ lightweightthemes="true"
+ onload="gSanitizePromptDialog.init();">
+<dialog id="SanitizeDialog"
+ dlgbuttons="accept,cancel">
+
+ <vbox id="SanitizeDialogPane">
+ <stringbundle id="bundleBrowser"
+ src="chrome://messenger/locale/messenger.properties"/>
+
+ <script src="chrome://messenger/content/sanitize.js"/>
+ <script src="chrome://messenger/content/sanitizeDialog.js"/>
+ <script src="chrome://messenger/content/dialogShadowDom.js"/>
+
+ <hbox id="SanitizeDurationBox" align="center">
+ <label value="&clearTimeDuration.label;"
+ accesskey="&clearTimeDuration.accesskey;"
+ control="sanitizeDurationChoice"
+ id="sanitizeDurationLabel"/>
+ <menulist id="sanitizeDurationChoice"
+ onselect="gSanitizePromptDialog.selectByTimespan();"
+ flex="1">
+ <menupopup id="sanitizeDurationPopup">
+ <menuitem label="&clearTimeDuration.lastHour;" value="1"/>
+ <menuitem label="&clearTimeDuration.last2Hours;" value="2"/>
+ <menuitem label="&clearTimeDuration.last4Hours;" value="3"/>
+ <menuitem label="&clearTimeDuration.today;" value="4"/>
+ <menuseparator/>
+ <menuitem label="&clearTimeDuration.everything;" value="0"/>
+ </menupopup>
+ </menulist>
+ <label id="sanitizeDurationSuffixLabel"
+ value="&clearTimeDuration.suffix;"/>
+ </hbox>
+
+ <vbox id="sanitizeEverythingWarningBox">
+ <spacer flex="1"/>
+ <hbox align="center">
+ <html:img id="sanitizeEverythingWarningIcon" alt="" />
+ <vbox id="sanitizeEverythingWarningDescBox" flex="1">
+ <description id="sanitizeEverythingWarning"/>
+ <description id="sanitizeEverythingUndoWarning">&sanitizeEverythingUndoWarning;</description>
+ </vbox>
+ </hbox>
+ <spacer flex="1"/>
+ </vbox>
+
+ <label id="historyGroupLabel" value="&historyGroup.label;"/>
+ <vbox id="historyGroup">
+ <checkbox label="&itemHistory.label;"
+ accesskey="&itemHistory.accesskey;"
+ preference="privacy.cpd.history"
+ oncommand="gSanitizePromptDialog.onReadGeneric();"/>
+ <checkbox label="&itemCookies.label;"
+ accesskey="&itemCookies.accesskey;"
+ preference="privacy.cpd.cookies"
+ oncommand="gSanitizePromptDialog.onReadGeneric();"/>
+ <checkbox label="&itemCache.label;"
+ accesskey="&itemCache.accesskey;"
+ preference="privacy.cpd.cache"
+ oncommand="gSanitizePromptDialog.onReadGeneric();"/>
+ </vbox>
+ <separator class="thin"/>
+
+ </vbox>
+</dialog>
+</window>
diff --git a/comm/mail/base/content/sanitizeDialog.js b/comm/mail/base/content/sanitizeDialog.js
new file mode 100644
index 0000000000..457cd4dc45
--- /dev/null
+++ b/comm/mail/base/content/sanitizeDialog.js
@@ -0,0 +1,206 @@
+/* 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 sanitize.js */
+
+var gSanitizePromptDialog = {
+ get bundleBrowser() {
+ if (!this._bundleBrowser) {
+ this._bundleBrowser = document.getElementById("bundleBrowser");
+ }
+ return this._bundleBrowser;
+ },
+
+ get selectedTimespan() {
+ var durList = document.getElementById("sanitizeDurationChoice");
+ return parseInt(durList.value);
+ },
+
+ get warningBox() {
+ return document.getElementById("sanitizeEverythingWarningBox");
+ },
+
+ init() {
+ // This is used by selectByTimespan() to determine if the window has loaded.
+ this._inited = true;
+
+ var s = new Sanitizer();
+ s.prefDomain = "privacy.cpd.";
+
+ document.getElementById("sanitizeDurationChoice").value =
+ Services.prefs.getIntPref("privacy.sanitize.timeSpan");
+
+ let sanitizeItemList = document.querySelectorAll(
+ "#historyGroup > [preference]"
+ );
+ for (let prefItem of sanitizeItemList) {
+ let name = s.getNameFromPreference(prefItem.getAttribute("preference"));
+ if (!s.canClearItem(name)) {
+ prefItem.preference = null;
+ prefItem.checked = false;
+ prefItem.disabled = true;
+ } else {
+ prefItem.checked = Services.prefs.getBoolPref(
+ prefItem.getAttribute("preference")
+ );
+ }
+ }
+
+ this.onReadGeneric();
+
+ document.querySelector("dialog").getButton("accept").label =
+ this.bundleBrowser.getString("sanitizeButtonOK");
+
+ let warningIcon = document.getElementById("sanitizeEverythingWarningIcon");
+ warningIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/activity/warning.svg"
+ );
+
+ if (this.selectedTimespan === Sanitizer.TIMESPAN_EVERYTHING) {
+ this.prepareWarning();
+ this.warningBox.hidden = false;
+ document.title = this.bundleBrowser.getString(
+ "sanitizeDialog2.everything.title"
+ );
+ } else {
+ this.warningBox.hidden = true;
+ }
+ },
+
+ selectByTimespan() {
+ // This method is the onselect handler for the duration dropdown. As a
+ // result it's called a couple of times before onload calls init().
+ if (!this._inited) {
+ return;
+ }
+
+ var warningBox = this.warningBox;
+
+ // If clearing everything
+ if (this.selectedTimespan === Sanitizer.TIMESPAN_EVERYTHING) {
+ this.prepareWarning();
+ if (warningBox.hidden) {
+ warningBox.hidden = false;
+ }
+ window.sizeToContent();
+ window.document.title = this.bundleBrowser.getString(
+ "sanitizeDialog2.everything.title"
+ );
+ return;
+ }
+
+ // If clearing a specific time range
+ if (!warningBox.hidden) {
+ window.resizeBy(0, -warningBox.getBoundingClientRect().height);
+ warningBox.hidden = true;
+ }
+ window.document.title =
+ window.document.documentElement.getAttribute("noneverythingtitle");
+ },
+
+ sanitize() {
+ // Update pref values before handing off to the sanitizer (bug 453440)
+ this.updatePrefs();
+ var s = new Sanitizer();
+ s.prefDomain = "privacy.cpd.";
+
+ s.range = Sanitizer.getClearRange(this.selectedTimespan);
+ s.ignoreTimespan = !s.range;
+
+ try {
+ s.sanitize();
+ } catch (er) {
+ console.error("Exception during sanitize: " + er);
+ }
+ },
+
+ /**
+ * If the panel that displays a warning when the duration is "Everything" is
+ * not set up, sets it up. Otherwise does nothing.
+ */
+ prepareWarning() {
+ // If the date and time-aware locale warning string is ever used again,
+ // initialize it here. Currently we use the no-visits warning string,
+ // which does not include date and time. See bug 480169 comment 48.
+
+ var warningStringID;
+ if (this.hasNonSelectedItems()) {
+ warningStringID = "sanitizeSelectedWarning";
+ } else {
+ warningStringID = "sanitizeEverythingWarning2";
+ }
+
+ var warningDesc = document.getElementById("sanitizeEverythingWarning");
+ warningDesc.textContent = this.bundleBrowser.getString(warningStringID);
+ },
+
+ /**
+ * Called when the value of a preference element is synced from the actual
+ * pref. Enables or disables the OK button appropriately.
+ */
+ onReadGeneric() {
+ var found = false;
+
+ // Find any other pref that's checked and enabled.
+ let sanitizeItemList = document.querySelectorAll(
+ "#historyGroup > [preference]"
+ );
+ for (let prefItem of sanitizeItemList) {
+ found = !prefItem.disabled && prefItem.checked;
+ if (found) {
+ break;
+ }
+ }
+
+ try {
+ document.querySelector("dialog").getButton("accept").disabled = !found;
+ } catch (e) {}
+
+ // Update the warning prompt if needed
+ this.prepareWarning();
+
+ return undefined;
+ },
+
+ /**
+ * Sanitizer.prototype.sanitize() requires the prefs to be up-to-date.
+ * Because the type of this prefwindow is "child" -- and that's needed because
+ * without it the dialog has no OK and Cancel buttons -- the prefs are not
+ * updated on dialogaccept on platforms that don't support instant-apply
+ * (i.e., Windows). We must therefore manually set the prefs from their
+ * corresponding preference elements.
+ */
+ updatePrefs() {
+ Sanitizer.prefs.setIntPref("timeSpan", this.selectedTimespan);
+
+ // Now manually set the prefs from their corresponding preference elements.
+ let sanitizeItemList = document.querySelectorAll(
+ "#historyGroup > [preference]"
+ );
+ for (let prefItem of sanitizeItemList) {
+ let prefName = prefItem.getAttribute("preference");
+ Services.prefs.setBoolPref(prefName, prefItem.checked);
+ }
+ },
+
+ /**
+ * Check if all of the history items have been selected like the default status.
+ */
+ hasNonSelectedItems() {
+ let sanitizeItemList = document.querySelectorAll(
+ "#historyGroup > [preference]"
+ );
+ for (let prefItem of sanitizeItemList) {
+ if (!prefItem.checked) {
+ return true;
+ }
+ }
+ return false;
+ },
+};
+
+document.addEventListener("dialogaccept", () =>
+ gSanitizePromptDialog.sanitize()
+);
diff --git a/comm/mail/base/content/searchBar.js b/comm/mail/base/content/searchBar.js
new file mode 100644
index 0000000000..48e066a05b
--- /dev/null
+++ b/comm/mail/base/content/searchBar.js
@@ -0,0 +1,45 @@
+/* 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 gStatusBar = document.getElementById("statusbar-icon");
+
+/**
+ * The glodasearch widget is a UI widget (the #searchInput textbox) which is
+ * outside of the mailTabType's display panel, but acts as though it were within
+ * it.. This means we need to use a tab monitor so that we can appropriately
+ * update the contents of the textbox.
+ *
+ * Every time a tab is changed, we save the state of the text box and restore
+ * its previous value for the tab we are switching to, as well as whether this
+ * value is a change to the currently-used value (if it is a faceted search) tab.
+ * The behaviour rationale for this is that the searchInput is like the
+ * URL bar. When you are on a glodaSearch tab, we need to show you your
+ * current value, including any "uncommitted" (you haven't hit enter yet)
+ * changes.
+ *
+ * In addition, we want to disable the quick-search modes when a tab is
+ * being displayed that lacks quick search abilities (but we'll leave the
+ * faceted search as it's always available).
+ */
+
+var GlodaSearchBoxTabMonitor = {
+ monitorName: "glodaSearchBox",
+
+ onTabSwitched(aTab, aOldTab) {},
+
+ onTabTitleChanged() {},
+
+ onTabOpened(aTab, aFirstTab, aOldTab) {
+ aTab._ext.glodaSearchBox = {
+ value: aTab.mode.name === "glodaFacet" ? aTab.searchString : "",
+ };
+
+ if (aTab.mode.name === "glodaFacet") {
+ let searchInput = aTab.panel.querySelector(".remote-gloda-search");
+ if (searchInput) {
+ searchInput.value = aTab.searchString;
+ }
+ }
+ },
+};
diff --git a/comm/mail/base/content/selectionsummaries.js b/comm/mail/base/content/selectionsummaries.js
new file mode 100644
index 0000000000..75461e3a54
--- /dev/null
+++ b/comm/mail/base/content/selectionsummaries.js
@@ -0,0 +1,103 @@
+/* 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 gSummaryFrameManager */ // From messenger.js
+
+/**
+ * Summarize a set of selected messages. This can either be a single thread or
+ * multiple threads.
+ *
+ * @param aMessageDisplay The MessageDisplayWidget object responsible for
+ * showing messages.
+ */
+function summarizeSelection(aMessageDisplay) {
+ // Figure out if we're looking at one thread or more than one thread. We want
+ // the view's version of threading, not the database's version, in order to
+ // thread together cross-folder messages. XXX: This falls apart for group by
+ // sort; what we really want is a way to specify only the cross-folder view.
+ let folderDisplay = aMessageDisplay.folderDisplay;
+ let selectedIndices = folderDisplay.selectedIndices;
+ let dbView = folderDisplay.view.dbView;
+
+ let getThreadId = function (index) {
+ return dbView.getThreadContainingIndex(index).getRootHdr().messageKey;
+ };
+
+ let firstThreadId = getThreadId(selectedIndices[0]);
+ let oneThread = true;
+ for (let i = 1; i < selectedIndices.length; i++) {
+ if (getThreadId(selectedIndices[i]) != firstThreadId) {
+ oneThread = false;
+ break;
+ }
+ }
+
+ let selectedMessages = folderDisplay.selectedMessages;
+ if (oneThread) {
+ summarizeThread(selectedMessages, aMessageDisplay);
+ } else {
+ summarizeMultipleSelection(selectedMessages, aMessageDisplay);
+ }
+}
+
+/**
+ * Given an array of messages which are all in the same thread, summarize them.
+ *
+ * @param aSelectedMessages Array of message headers.
+ * @param aMessageDisplay The MessageDisplayWidget object responsible for
+ * showing messages.
+ */
+function summarizeThread(aSelectedMessages, aMessageDisplay) {
+ const kSummaryURL = "chrome://messenger/content/multimessageview.xhtml";
+
+ aMessageDisplay.singleMessageDisplay = false;
+ gSummaryFrameManager.loadAndCallback(kSummaryURL, function () {
+ let childWindow = gSummaryFrameManager.iframe.contentWindow;
+ try {
+ childWindow.gMessageSummary.summarize(
+ "thread",
+ aSelectedMessages,
+ aMessageDisplay.folderDisplay.view.dbView,
+ aMessageDisplay.folderDisplay.selectMessages.bind(
+ aMessageDisplay.folderDisplay
+ ),
+ aMessageDisplay
+ );
+ } catch (e) {
+ console.error(e);
+ throw e;
+ }
+ });
+}
+
+/**
+ * Given an array of message URIs, cause the message panel to display a summary
+ * of them.
+ *
+ * @param aSelectedMessages Array of message headers.
+ * @param aMessageDisplay The MessageDisplayWidget object responsible for
+ * showing messages.
+ */
+function summarizeMultipleSelection(aSelectedMessages, aMessageDisplay) {
+ const kSummaryURL = "chrome://messenger/content/multimessageview.xhtml";
+
+ aMessageDisplay.singleMessageDisplay = false;
+ gSummaryFrameManager.loadAndCallback(kSummaryURL, function () {
+ let childWindow = gSummaryFrameManager.iframe.contentWindow;
+ try {
+ childWindow.gMessageSummary.summarize(
+ "multipleselection",
+ aSelectedMessages,
+ aMessageDisplay.folderDisplay.view.dbView,
+ aMessageDisplay.folderDisplay.selectMessages.bind(
+ aMessageDisplay.folderDisplay
+ ),
+ aMessageDisplay
+ );
+ } catch (e) {
+ console.error(e);
+ throw e;
+ }
+ });
+}
diff --git a/comm/mail/base/content/shortcutsOverlay.js b/comm/mail/base/content/shortcutsOverlay.js
new file mode 100644
index 0000000000..85bf20b5ec
--- /dev/null
+++ b/comm/mail/base/content/shortcutsOverlay.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/. */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ );
+
+ XPCOMUtils.defineLazyModuleGetters(this, {
+ ShortcutsManager: "resource:///modules/ShortcutsManager.jsm",
+ });
+
+ function setupShortcuts() {
+ // Set up all dedicated shortcuts.
+ setupSpacesShortcuts();
+
+ // Set up the event listener.
+ setupEventListener();
+ }
+
+ /**
+ * Use the ShortcutManager to set up all keyboard shortcuts for the spaces
+ * toolbar buttons.
+ */
+ async function setupSpacesShortcuts() {
+ // Set up all shortcut strings for the various spaces buttons.
+ let buttons = {
+ "space-toggle": ["collapseButton", "spacesToolbarReveal"],
+ "space-mail": ["mailButton"],
+ "space-addressbook": ["addressBookButton"],
+ "space-calendar": ["calendarButton"],
+ "space-tasks": ["tasksButton"],
+ "space-chat": ["chatButton"],
+ };
+ for (let [string, ids] of Object.entries(buttons)) {
+ let shortcut = await ShortcutsManager.getShortcutStrings(string);
+ if (!shortcut) {
+ continue;
+ }
+
+ for (let id of ids) {
+ let button = document.getElementById(id);
+ button.setAttribute("aria-label", button.title);
+ document.l10n.setAttributes(button, "button-shortcut-string", {
+ title: button.title,
+ shortcut: shortcut.localizedShortcut,
+ });
+ button.setAttribute("aria-keyshortcuts", shortcut.ariaKeyShortcuts);
+ }
+ }
+
+ // Set up all shortcut strings for the various spaces menuitems.
+ let menuitems = {
+ "space-toggle": ["spacesPopupButtonReveal"],
+ "space-mail": ["spacesPopupButtonMail"],
+ "space-addressbook": ["spacesPopupButtonAddressBook"],
+ "space-calendar": [
+ "spacesPopupButtonCalendar",
+ "calMenuSwitchToCalendar",
+ ],
+ "space-tasks": ["spacesPopupButtonTasks", "calMenuSwitchToTask"],
+ "space-chat": ["spacesPopupButtonChat", "menu_goChat"],
+ };
+ for (let [string, ids] of Object.entries(menuitems)) {
+ let shortcut = await ShortcutsManager.getShortcutStrings(string);
+ if (!shortcut) {
+ continue;
+ }
+
+ for (let id of ids) {
+ let menuitem = document.getElementById(id);
+ if (!menuitem.label) {
+ await document.l10n.translateElements([menuitem]);
+ }
+ document.l10n.setAttributes(menuitem, "menuitem-shortcut-string", {
+ label: menuitem.label,
+ shortcut: shortcut.localizedShortcut,
+ });
+ }
+ }
+ }
+
+ /**
+ * Set up the keydown event to intercept shortcuts.
+ */
+ function setupEventListener() {
+ let tabmail = document.getElementById("tabmail");
+
+ window.addEventListener("keydown", event => {
+ let shortcut = ShortcutsManager.matches(event);
+ // FIXME: Temporarily ignore numbers coming from the Numpad to prevent
+ // hijacking Alt characters typing in Windows. This can be removed once
+ // we implement customizable shortcuts.
+ if (!shortcut || event.location == 3) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+
+ switch (shortcut.id) {
+ case "space-toggle":
+ window.gSpacesToolbar.toggleToolbar(!window.gSpacesToolbar.isHidden);
+ break;
+ case "space-mail":
+ case "space-addressbook":
+ case "space-calendar":
+ case "space-tasks":
+ case "space-chat":
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == shortcut.id.replace("space-", "")
+ );
+ window.gSpacesToolbar.openSpace(tabmail, space);
+ break;
+ }
+ });
+ }
+
+ window.addEventListener("load", setupShortcuts);
+}
diff --git a/comm/mail/base/content/spacesToolbar.inc.xhtml b/comm/mail/base/content/spacesToolbar.inc.xhtml
new file mode 100644
index 0000000000..440cfe9ee6
--- /dev/null
+++ b/comm/mail/base/content/spacesToolbar.inc.xhtml
@@ -0,0 +1,166 @@
+# 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/.
+
+<popupset id="spacesToolbarPopupSet">
+ <menupopup id="spacesContextMenu"
+ class="no-icon-menupopup no-accel-menupopup">
+ <menuitem id="spacesContextNewTabItem"
+ class="menuitem-iconic"
+ data-l10n-id="spaces-context-new-tab-item"/>
+ <menuitem id="spacesContextNewWindowItem"
+ class="menuitem-iconic"
+ data-l10n-id="spaces-context-new-window-item"/>
+ <menuseparator id="spacesContextMenuSeparator" hidden="true"/>
+ </menupopup>
+
+ <menupopup id="settingsContextMenu"
+ class="no-icon-menupopup no-accel-menupopup">
+ <menuitem id="settingsContextOpenSettingsItem"
+ class="menuitem-iconic"
+ data-l10n-id="settings-context-open-settings-item2"/>
+ <menuitem id="settingsContextOpenAccountSettingsItem"
+ class="menuitem-iconic"
+ data-l10n-id="settings-context-open-account-settings-item2"/>
+ <menuitem id="settingsContextOpenAddonsItem"
+ class="menuitem-iconic"
+ data-l10n-id="settings-context-open-addons-item2"/>
+ <menuseparator/>
+ <menuitem id="settingsContextOpenCustomizeItem"
+ class="menuitem-iconic"
+ data-l10n-id="menuitem-customize-label"/>
+ </menupopup>
+
+ <menupopup id="spacesToolbarContextMenu"
+ class="no-icon-menupopup no-accel-menupopup">
+ <menuitem id="spacesToolbarContextCustomize"
+ class="menuitem-iconic"
+ data-l10n-id="menuitem-customize-label"
+ oncommand="gSpacesToolbar.showCustomize();"/>
+ </menupopup>
+
+ <menupopup id="spacesToolbarAddonsPopup" type="arrow"
+ class="no-accel-menupopup"
+ onpopuphidden="gSpacesToolbar.spacesToolbarAddonsPopupClosed();">
+ </menupopup>
+</popupset>
+
+<html:div role="toolbar" id="spacesToolbar" xmlns="http://www.w3.org/1999/xhtml"
+ class="spaces-toolbar"
+ data-l10n-id="spaces-toolbar-element"
+ data-l10n-attrs="toolbarname"
+ aria-orientation="vertical"
+ hidden="hidden">
+ <!-- Hidden element used to fetch the style of an "active" button. -->
+ <span id="spacesAccentPlaceholder" hidden="hidden"></span>
+
+ <!-- Primary tabs. -->
+ <div class="spaces-toolbar-container spaces-toolbar-top-container">
+ <button id="mailButton"
+ data-l10n-id="spaces-toolbar-button-mail2"
+ class="spaces-toolbar-button"
+ tabindex="0">
+ <img src="" alt="" />
+ </button>
+ <button id="addressBookButton"
+ data-l10n-id="spaces-toolbar-button-address-book2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ <button id="calendarButton"
+ data-l10n-id="spaces-toolbar-button-calendar2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ <button id="tasksButton"
+ data-l10n-id="spaces-toolbar-button-tasks2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ <button id="chatButton"
+ data-l10n-id="spaces-toolbar-button-chat2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <span class="spaces-badge-container"></span>
+ <img src="" alt="" />
+ </button>
+ </div>
+
+ <div id="spacesToolbarAddonsContainer" class="spaces-toolbar-container">
+ <!-- Special container to allow add-ons to add buttons. -->
+ </div>
+ <button id="spacesToolbarAddonsOverflowButton"
+ data-l10n-id="spaces-toolbar-button-overflow"
+ class="spaces-toolbar-button"
+ aria-expanded="false"
+ aria-haspopup="menu"
+ aria-controls="spacesToolbarAddonsPopup"
+ hidden="hidden"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+
+ <div class="spaces-toolbar-container spaces-toolbar-bottom-container">
+ <!-- Settings button. -->
+ <button id="settingsButton"
+ data-l10n-id="spaces-toolbar-button-settings2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+
+ <!-- Collapse button. -->
+ <button id="collapseButton"
+ data-l10n-id="spaces-toolbar-button-hide"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </div>
+</html:div>
+
+<panel id="spacesToolbarCustomizationPanel"
+ type="arrow"
+ orient="vertical"
+ class="cui-widget-panel popup-panel panel-no-padding"
+ noautohide="true"
+ onpopuphidden="gSpacesToolbar.onCustomizePopupHidden();">
+ <!-- Keep the noautohide attribute to true to prevent the panel from closing
+ when the color picker appears. -->
+ <html:div class="popup-panel-body" xmlns="http://www.w3.org/1999/xhtml">
+ <h3 data-l10n-id="spaces-customize-panel-title"></h3>
+
+ <div class="popup-panel-options-grid">
+ <label for="spacesBackgroundColor"
+ data-l10n-id="spaces-customize-background-color"></label>
+ <input type="color" id="spacesBackgroundColor" />
+
+ <label for="spacesIconsColor"
+ data-l10n-id="spaces-customize-icon-color"></label>
+ <input type="color" id="spacesIconsColor" />
+
+ <label for="spacesAccentBgColor"
+ data-l10n-id="spaces-customize-accent-background-color"></label>
+ <input type="color" id="spacesAccentBgColor" />
+
+ <label for="spacesAccentTextColor"
+ data-l10n-id="spaces-customize-accent-text-color"></label>
+ <input type="color" id="spacesAccentTextColor" />
+ </div>
+
+ <div class="popup-panel-buttons-container">
+ <button type="button"
+ data-l10n-id="spaces-customize-button-restore"
+ onclick="gSpacesToolbar.resetColorCustomization();">
+ </button>
+ <button type="button"
+ data-l10n-id="customize-panel-button-save"
+ onclick="gSpacesToolbar.closeCustomize();"
+ class="primary">
+ </button>
+ </div>
+ </html:div>
+</panel>
diff --git a/comm/mail/base/content/spacesToolbar.js b/comm/mail/base/content/spacesToolbar.js
new file mode 100644
index 0000000000..4155d7d0dc
--- /dev/null
+++ b/comm/mail/base/content/spacesToolbar.js
@@ -0,0 +1,1325 @@
+/* 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";
+
+/* import-globals-from mailCore.js */
+/* import-globals-from utilityOverlay.js */
+
+/**
+ * Special vertical toolbar to organize all the buttons opening a tab.
+ */
+var gSpacesToolbar = {
+ SUPPORTED_BADGE_STYLES: ["--spaces-button-badge-bg-color"],
+ SUPPORTED_ICON_STYLES: [
+ "--webextension-toolbar-image",
+ "--webextension-toolbar-image-dark",
+ "--webextension-toolbar-image-light",
+ "--webextension-toolbar-image-2x",
+ "--webextension-toolbar-image-2x-dark",
+ "--webextension-toolbar-image-2x-light",
+ ],
+ docURL: "chrome://messenger/content/messenger.xhtml",
+ /**
+ * The spaces toolbar DOM element.
+ *
+ * @type {?HTMLElement}
+ */
+ element: null,
+ /**
+ * If the spaces toolbar has already been loaded.
+ *
+ * @type {boolean}
+ */
+ isLoaded: false,
+ /**
+ * If the spaces toolbar is hidden or visible.
+ *
+ * @type {boolean}
+ */
+ isHidden: false,
+ /**
+ * If the spaces toolbar is currently being customized.
+ *
+ * @type {boolean}
+ */
+ isCustomizing: false,
+ /**
+ * The DOM element panel collecting all customization options.
+ */
+ customizePanel: null,
+ /**
+ * The object storing all saved customization options:
+ * - background: The toolbar background color.
+ * - color: The default icon color of the buttons.
+ * - accentColor: The background color of an active/current button.
+ * - accentBackground: The icon color of an active/current button.
+ */
+ customizeData: {},
+
+ /**
+ * @callback TabInSpace
+ * @param {object} tabInfo - The tabInfo object (a member of tabmail.tabInfo)
+ * for the tab.
+ * @returns {0|1|2} - The relation between the tab and the space. 0 means it
+ * does not belong to the space. 1 means it is a primary tab of the space.
+ * 2 means it is a secondary tab of the space.
+ */
+ /**
+ * @callback OpenSpace
+ * @param {"tab"|"window"} where - Where to open the space: in a new tab or in
+ * a new window.
+ * @returns {?object | Window} - The tabInfo for the newly opened tab, or the
+ * newly opened messenger window, or null if neither was created.
+ */
+ /**
+ * Data and methods for a space.
+ *
+ * @typedef {object} SpaceInfo
+ * @property {string} name - The name for this space.
+ * @property {boolean} allowMultipleTabs - Whether to allow the user to open
+ * multiple tabs in this space.
+ * @property {HTMLButtonElement} button - The toolbar button for this space.
+ * @property {?XULMenuItem} menuitem - The menuitem for this space, if
+ * available.
+ * @property {TabInSpace} tabInSpace - A callback that determines whether an
+ * existing tab is considered outside this space, a primary tab of this
+ * space (a tab that is similar to the tab created by the open method) or
+ * a secondary tab of this space (a related tab that still belongs to this
+ * space).
+ * @property {OpenSpace} open - A callback to open this space.
+ */
+ /**
+ * The main spaces in this toolbar. This will be constructed on load.
+ *
+ * @type {?SpaceInfo[]}
+ */
+ spaces: null,
+ /**
+ * The current space the window is in, or undefined if it is not in any of the
+ * known spaces.
+ *
+ * @type {SpaceInfo|undefined}
+ */
+ currentSpace: undefined,
+ /**
+ * The number of buttons created by add-ons.
+ *
+ * @returns {integer}
+ */
+ get addonButtonCount() {
+ return document.querySelectorAll(".spaces-addon-button").length;
+ },
+ /**
+ * The number of pixel spacing to add to the add-ons button height calculation
+ * based on the current UI density.
+ *
+ * @type {integer}
+ */
+ densitySpacing: 0,
+ /**
+ * The button that can receive focus. Used for managing focus with a roving
+ * tabindex.
+ *
+ * @type {?HTMLElement}
+ */
+ focusButton: null,
+
+ tabMonitor: {
+ monitorName: "spacesToolbarMonitor",
+
+ onTabTitleChanged() {},
+ onTabOpened() {},
+ onTabPersist() {},
+ onTabRestored() {},
+ onTabClosing() {},
+
+ onTabSwitched(newTabInfo, oldTabInfo) {
+ // Bail out if for whatever reason something went wrong.
+ if (!newTabInfo) {
+ console.error(
+ "Spaces Toolbar: Missing new tab on monitored tab switching"
+ );
+ return;
+ }
+
+ let tabSpace = gSpacesToolbar.spaces.find(space =>
+ space.tabInSpace(newTabInfo)
+ );
+ if (gSpacesToolbar.currentSpace != tabSpace) {
+ gSpacesToolbar.currentSpace?.button.classList.remove("current");
+ gSpacesToolbar.currentSpace?.menuitem?.classList.remove("current");
+ gSpacesToolbar.currentSpace = tabSpace;
+ if (gSpacesToolbar.currentSpace) {
+ gSpacesToolbar.currentSpace.button.classList.add("current");
+ gSpacesToolbar.currentSpace.menuitem?.classList.add("current");
+ gSpacesToolbar.setFocusButton(gSpacesToolbar.currentSpace.button);
+ }
+
+ const spaceChangeEvent = new CustomEvent("spacechange", {
+ detail: tabSpace,
+ });
+ gSpacesToolbar.element.dispatchEvent(spaceChangeEvent);
+ }
+ },
+ },
+
+ /**
+ * Convert an rgb() string to an hexadecimal color string.
+ *
+ * @param {string} color - The RGBA color string that needs conversion.
+ * @returns {string} - The converted hexadecimal color.
+ */
+ _rgbToHex(color) {
+ let rgb = color.split("(")[1].split(")")[0].split(",");
+
+ // For each array element convert ot a base16 string and add zero if we get
+ // only one character.
+ let hash = rgb.map(x => parseInt(x).toString(16).padStart(2, "0"));
+
+ return `#${hash.join("")}`;
+ },
+
+ onLoad() {
+ if (this.isLoaded) {
+ return;
+ }
+
+ this.element = document.getElementById("spacesToolbar");
+ this.focusButton = document.getElementById("mailButton");
+ let tabmail = document.getElementById("tabmail");
+
+ this.spaces = [
+ {
+ name: "mail",
+ button: document.getElementById("mailButton"),
+ menuitem: document.getElementById("spacesPopupButtonMail"),
+ tabInSpace(tabInfo) {
+ switch (tabInfo.mode.name) {
+ case "folder":
+ case "mail3PaneTab":
+ case "mailMessageTab":
+ return 1;
+ default:
+ return 0;
+ }
+ },
+ open(where) {
+ // Prefer the current tab, else the earliest tab.
+ let existingTab = [tabmail.currentTabInfo, ...tabmail.tabInfo].find(
+ tabInfo => this.tabInSpace(tabInfo) == 1
+ );
+ let folderURI = null;
+ switch (existingTab?.mode.name) {
+ case "folder":
+ folderURI =
+ existingTab.folderDisplay.displayedFolder?.URI || null;
+ break;
+ case "mail3PaneTab":
+ folderURI = existingTab.folder.URI || null;
+ break;
+ }
+ if (where == "window") {
+ return window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ folderURI,
+ -1
+ );
+ }
+ return openTab("mail3PaneTab", { folderURI }, "tab");
+ },
+ allowMultipleTabs: true,
+ },
+ {
+ name: "addressbook",
+ button: document.getElementById("addressBookButton"),
+ menuitem: document.getElementById("spacesPopupButtonAddressBook"),
+ tabInSpace(tabInfo) {
+ if (tabInfo.mode.name == "addressBookTab") {
+ return 1;
+ }
+ return 0;
+ },
+ open(where) {
+ return openTab("addressBookTab", {}, where);
+ },
+ },
+ {
+ name: "calendar",
+ button: document.getElementById("calendarButton"),
+ menuitem: document.getElementById("spacesPopupButtonCalendar"),
+ tabInSpace(tabInfo) {
+ return tabInfo.mode.name == "calendar" ? 1 : 0;
+ },
+ open(where) {
+ return openTab("calendar", {}, where);
+ },
+ },
+ {
+ name: "tasks",
+ button: document.getElementById("tasksButton"),
+ menuitem: document.getElementById("spacesPopupButtonTasks"),
+ tabInSpace(tabInfo) {
+ return tabInfo.mode.name == "tasks" ? 1 : 0;
+ },
+ open(where) {
+ return openTab("tasks", {}, where);
+ },
+ },
+ {
+ name: "chat",
+ button: document.getElementById("chatButton"),
+ menuitem: document.getElementById("spacesPopupButtonChat"),
+ tabInSpace(tabInfo) {
+ return tabInfo.mode.name == "chat" ? 1 : 0;
+ },
+ open(where) {
+ return openTab("chat", {}, where);
+ },
+ },
+ {
+ name: "settings",
+ button: document.getElementById("settingsButton"),
+ menuitem: document.getElementById("spacesPopupButtonSettings"),
+ 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;
+ },
+ open(where) {
+ return openTab("preferencesTab", {}, where);
+ },
+ },
+ ];
+
+ this.setupEventListeners();
+ this.toggleToolbar(
+ Services.xulStore.getValue(this.docURL, "spacesToolbar", "hidden") ==
+ "true"
+ );
+
+ // The tab monitor will inform us when a different tab is selected.
+ tabmail.registerTabMonitor(this.tabMonitor);
+
+ this.customizePanel = document.getElementById(
+ "spacesToolbarCustomizationPanel"
+ );
+ this.loadCustomization();
+
+ this.isLoaded = true;
+ window.dispatchEvent(new CustomEvent("spaces-toolbar-ready"));
+ // Update the window UI after the spaces toolbar has been loaded.
+ this.updateUI();
+ },
+
+ setupEventListeners() {
+ this.element.addEventListener("contextmenu", event =>
+ this._showContextMenu(event)
+ );
+ this.element.addEventListener("keydown", event => {
+ this._onSpacesToolbarKeyDown(event);
+ });
+
+ // Prevent buttons from stealing the focus on click since the focus is
+ // handled when a specific tab is opened or switched to.
+ for (let button of document.querySelectorAll(".spaces-toolbar-button")) {
+ button.onmousedown = event => event.preventDefault();
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ let contextMenu = document.getElementById("spacesContextMenu");
+ let newTabItem = document.getElementById("spacesContextNewTabItem");
+ let newWindowItem = document.getElementById("spacesContextNewWindowItem");
+ let separator = document.getElementById("spacesContextMenuSeparator");
+
+ // The space that we (last) opened the context menu for, which we share
+ // between methods.
+ let contextSpace;
+ newTabItem.addEventListener("command", () => contextSpace.open("tab"));
+ newWindowItem.addEventListener("command", () =>
+ contextSpace.open("window")
+ );
+
+ let settingsContextMenu = document.getElementById("settingsContextMenu");
+ document
+ .getElementById("settingsContextOpenSettingsItem")
+ .addEventListener("command", () => openTab("preferencesTab", {}));
+ document
+ .getElementById("settingsContextOpenAccountSettingsItem")
+ .addEventListener("command", () =>
+ openTab("contentTab", { url: "about:accountsettings" })
+ );
+ document
+ .getElementById("settingsContextOpenAddonsItem")
+ .addEventListener("command", () =>
+ openTab("contentTab", { url: "about:addons" })
+ );
+ document
+ .getElementById("settingsContextOpenCustomizeItem")
+ .addEventListener("command", () => this.showCustomize());
+
+ for (let space of this.spaces) {
+ this._addButtonClickListener(space.button, () => {
+ this.openSpace(tabmail, space);
+ });
+ space.menuitem?.addEventListener("command", () => {
+ this.openSpace(tabmail, space);
+ });
+ if (space.name == "settings") {
+ space.button.addEventListener("contextmenu", event => {
+ event.stopPropagation();
+ settingsContextMenu.openPopupAtScreen(
+ event.screenX,
+ event.screenY,
+ true,
+ event
+ );
+ });
+ continue;
+ }
+ space.button.addEventListener("contextmenu", event => {
+ event.stopPropagation();
+ contextSpace = space;
+ // Clean up old items.
+ for (let menuitem of contextMenu.querySelectorAll(".switch-to-tab")) {
+ menuitem.remove();
+ }
+
+ let existingTabs = tabmail.tabInfo.filter(space.tabInSpace);
+ // Show opening in new tab if no existing tabs or can open multiple.
+ // NOTE: We always show at least one item: either the switch to tab
+ // items, or the new tab item.
+ newTabItem.hidden = !!existingTabs.length && !space.allowMultipleTabs;
+ newWindowItem.hidden = !space.allowMultipleTabs;
+
+ for (let tabInfo of existingTabs) {
+ let menuitem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(
+ menuitem,
+ "spaces-context-switch-tab-item",
+ { tabName: tabInfo.title }
+ );
+ menuitem.classList.add("switch-to-tab", "menuitem-iconic");
+ menuitem.addEventListener("command", () =>
+ tabmail.switchToTab(tabInfo)
+ );
+ contextMenu.appendChild(menuitem);
+ }
+ // The separator splits the "Open in new tab" and "Open in new window"
+ // items from the switch-to-tab items. Only show separator if there
+ // are non-hidden items on both sides.
+ separator.hidden = !existingTabs.length || !space.allowMultipleTabs;
+
+ contextMenu.openPopupAtScreen(
+ event.screenX,
+ event.screenY,
+ true,
+ event
+ );
+ });
+ }
+
+ this._addButtonClickListener(
+ document.getElementById("collapseButton"),
+ () => this.toggleToolbar(true)
+ );
+
+ document
+ .getElementById("spacesPopupButtonReveal")
+ .addEventListener("command", () => {
+ this.toggleToolbar(false);
+ });
+ this._addButtonClickListener(
+ document.getElementById("spacesToolbarAddonsOverflowButton"),
+ event => this.openSpacesToolbarAddonsPopup(event)
+ );
+
+ // Allow opening the pinned menu with Space or Enter keypress.
+ document
+ .getElementById("spacesPinnedButton")
+ .addEventListener("keypress", event => {
+ // Don't show the panel if the window is in customization mode.
+ if (
+ document.getElementById("toolbar-menubar").hasAttribute("customizing")
+ ) {
+ return;
+ }
+
+ if (event.key == " " || event.key == "Enter") {
+ let panel = document.getElementById("spacesButtonMenuPopup");
+ if (panel.state == "open") {
+ panel.hidePopup();
+ } else if (panel.state == "closed") {
+ panel.openPopup(event.target, "after_start");
+ }
+ }
+ });
+ },
+
+ /**
+ * Handle the keypress event on the spaces toolbar.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ _onSpacesToolbarKeyDown(event) {
+ if (
+ !["ArrowUp", "ArrowDown", "Home", "End", " ", "Enter"].includes(event.key)
+ ) {
+ return;
+ }
+
+ // NOTE: Normally a button click handler would cover Enter and Space key
+ // events, however we need to prevent the default behavior and explicitly
+ // trigger the button click because in some tabs XUL keys or Window event
+ // listeners are attached to this keys triggering specific actions.
+ // TODO: Remove once we have a properly mapped global shortcut object not
+ // relying on XUL keys.
+ if (event.key == " " || event.key == "Enter") {
+ event.preventDefault();
+ event.target.click();
+ return;
+ }
+
+ // Collect all currently visible buttons of the spaces toolbar.
+ let buttons = [
+ ...document.querySelectorAll(".spaces-toolbar-button:not([hidden])"),
+ ];
+ let elementIndex = buttons.indexOf(this.focusButton);
+
+ // Find the adjacent focusable element based on the pressed key.
+ switch (event.key) {
+ case "ArrowUp":
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = buttons.length - 1;
+ }
+ break;
+
+ case "ArrowDown":
+ elementIndex++;
+ if (elementIndex > buttons.length - 1) {
+ elementIndex = 0;
+ }
+ break;
+
+ case "Home":
+ elementIndex = 0;
+ break;
+
+ case "End":
+ elementIndex = buttons.length - 1;
+ break;
+ }
+
+ this.setFocusButton(buttons[elementIndex], true);
+ },
+
+ /**
+ * Move the focus to a new toolbar button and update the tabindex attribute.
+ *
+ * @param {HTMLElement} buttonToFocus - The new button to receive focus.
+ * @param {boolean} [forceFocus=false] - Whether to force the focus to move
+ * onto the new button, otherwise focus will only move if the previous
+ * focusButton had focus.
+ */
+ setFocusButton(buttonToFocus, forceFocus = false) {
+ let prevHadFocus = false;
+ if (buttonToFocus != this.focusButton) {
+ prevHadFocus = document.activeElement == this.focusButton;
+ this.focusButton.tabIndex = -1;
+ this.focusButton = buttonToFocus;
+ buttonToFocus.tabIndex = 0;
+ }
+ // Only move the focus if the currently focused button was the active
+ // element.
+ if (forceFocus || prevHadFocus) {
+ buttonToFocus.focus();
+ }
+ },
+
+ /**
+ * Add a click event listener to a spaces toolbar button.
+ *
+ * This method will insert focus controls for when the button is clicked.
+ *
+ * @param {HTMLButtonElement} button - A button that belongs to the spaces
+ * toolbar.
+ * @param {Function} listener - An event listener to call when the button's
+ * click event is fired.
+ */
+ _addButtonClickListener(button, listener) {
+ button.addEventListener("click", event => {
+ // Since the button may have tabIndex = -1, we must manually move the
+ // focus into the button and set it as the focusButton.
+ // NOTE: We do *not* force the focus to move onto the button if it is not
+ // currently already within the spaces toolbar. This is mainly to avoid
+ // changing the document.activeElement before the tab's lastActiveElement
+ // is set in tabmail.js.
+ // NOTE: We do this before activating the button, which may move the focus
+ // elsewhere, such as into a space.
+ this.setFocusButton(button);
+ listener(event);
+ });
+ },
+
+ /**
+ * Open a space by creating a new tab or switching to an existing tab.
+ *
+ * @param {XULElement} tabmail - The tabmail element.
+ * @param {SpaceInfo} space - The space to open.
+ */
+ openSpace(tabmail, space) {
+ // Find the earliest primary tab that belongs to this space.
+ let existing = tabmail.tabInfo.find(
+ tabInfo => space.tabInSpace(tabInfo) == 1
+ );
+ if (!existing) {
+ return space.open("tab");
+ } else if (this.currentSpace != space) {
+ // Only switch to the tab if it is in a different space to the
+ // current one. In particular, if we are in a later tab we won't
+ // switch to the earliest tab.
+ tabmail.switchToTab(existing);
+ return existing;
+ }
+ return tabmail.currentTabInfo;
+ },
+
+ /**
+ * Open a popup context menu at the location of the right on the toolbar.
+ *
+ * @param {DOMEvent} event - The click event.
+ */
+ _showContextMenu(event) {
+ document
+ .getElementById("spacesToolbarContextMenu")
+ .openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ /**
+ * Load the saved customization from the xulStore, if we have any.
+ */
+ async loadCustomization() {
+ let xulStore = Services.xulStore;
+ if (xulStore.hasValue(this.docURL, "spacesToolbar", "colors")) {
+ this.customizeData = JSON.parse(
+ xulStore.getValue(this.docURL, "spacesToolbar", "colors")
+ );
+ this.updateCustomization();
+ }
+ },
+
+ /**
+ * Reset the colors shown on the button colors to the default state to remove
+ * any previously applied custom color.
+ */
+ _resetColorInputs() {
+ // Update colors with the current values. If we don't have any customization
+ // data, we fetch the current colors from the DOM elements.
+ // IMPORTANT! Always clear the onchange method before setting a new value
+ // since this method might be called after the popup is already opened.
+ let bgButton = document.getElementById("spacesBackgroundColor");
+ bgButton.onchange = null;
+ bgButton.value =
+ this.customizeData.background ||
+ this._rgbToHex(getComputedStyle(this.element).backgroundColor);
+ bgButton.onchange = event => {
+ this.customizeData.background = event.target.value;
+ this.updateCustomization();
+ };
+
+ let iconButton = document.getElementById("spacesIconsColor");
+ iconButton.onchange = null;
+ iconButton.value =
+ this.customizeData.color ||
+ this._rgbToHex(
+ getComputedStyle(
+ document.querySelector(".spaces-toolbar-button:not(.current)")
+ ).color
+ );
+ iconButton.onchange = event => {
+ this.customizeData.color = event.target.value;
+ this.updateCustomization();
+ };
+
+ let accentStyle = getComputedStyle(
+ document.getElementById("spacesAccentPlaceholder")
+ );
+ let accentBgButton = document.getElementById("spacesAccentBgColor");
+ accentBgButton.onchange = null;
+ accentBgButton.value =
+ this.customizeData.accentBackground ||
+ this._rgbToHex(accentStyle.backgroundColor);
+ accentBgButton.onchange = event => {
+ this.customizeData.accentBackground = event.target.value;
+ this.updateCustomization();
+ };
+
+ let accentFgButton = document.getElementById("spacesAccentTextColor");
+ accentFgButton.onchange = null;
+ accentFgButton.value =
+ this.customizeData.accentColor || this._rgbToHex(accentStyle.color);
+ accentFgButton.onchange = event => {
+ this.customizeData.accentColor = event.target.value;
+ this.updateCustomization();
+ };
+ },
+
+ /**
+ * Update the color buttons to reflect the current state of the toolbar UI,
+ * then open the customization panel.
+ */
+ showCustomize() {
+ this.isCustomizing = true;
+ // Reset the color inputs to be sure we're showing the correct colors.
+ this._resetColorInputs();
+
+ // Since we're forcing the panel to stay open with noautohide, we need to
+ // listen for the Escape keypress to maintain that usability exit point.
+ window.addEventListener("keypress", this.onWindowKeypress);
+ this.customizePanel.openPopup(
+ document.getElementById("collapseButton"),
+ "end_after",
+ 6,
+ 0,
+ false
+ );
+ },
+
+ /**
+ * Listen for the keypress event on the window after the customize panel was
+ * opened to enable the closing on Escape.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ onWindowKeypress(event) {
+ if (event.key == "Escape") {
+ gSpacesToolbar.customizePanel.hidePopup();
+ }
+ },
+
+ /**
+ * Close the customization panel.
+ */
+ closeCustomize() {
+ this.customizePanel.hidePopup();
+ },
+
+ /**
+ * Reset all event listeners and store the custom colors.
+ */
+ onCustomizePopupHidden() {
+ this.isCustomizing = false;
+ // Always remove the keypress event listener set on opening.
+ window.removeEventListener("keypress", this.onWindowKeypress);
+
+ // Save the custom colors, or delete it if we don't have any.
+ if (!Object.keys(this.customizeData).length) {
+ Services.xulStore.removeValue(this.docURL, "spacesToolbar", "colors");
+ return;
+ }
+
+ Services.xulStore.setValue(
+ this.docURL,
+ "spacesToolbar",
+ "colors",
+ JSON.stringify(this.customizeData)
+ );
+ },
+
+ /**
+ * Apply the customization to the CSS file.
+ */
+ updateCustomization() {
+ let data = this.customizeData;
+ let style = document.documentElement.style;
+
+ // Toolbar background color.
+ style.setProperty("--spaces-bg-color", data.background ?? null);
+ // Icons color.
+ style.setProperty("--spaces-button-text-color", data.color ?? null);
+ // Icons color for current/active buttons.
+ style.setProperty(
+ "--spaces-button-active-text-color",
+ data.accentColor ?? null
+ );
+ // Background color for current/active buttons.
+ style.setProperty(
+ "--spaces-button-active-bg-color",
+ data.accentBackground ?? null
+ );
+ },
+
+ /**
+ * Reset all color customizations to show the user the default UI.
+ */
+ resetColorCustomization() {
+ if (!matchMedia("(prefers-reduced-motion)").matches) {
+ // We set an event listener for the transition of any element inside the
+ // toolbar so we can reset the color for the buttons only after the
+ // toolbar and its elements reverted to their original colors.
+ this.element.addEventListener(
+ "transitionend",
+ () => {
+ this._resetColorInputs();
+ },
+ {
+ once: true,
+ }
+ );
+ }
+
+ this.customizeData = {};
+ this.updateCustomization();
+
+ // If the user required reduced motion, the transitionend listener will not
+ // work.
+ if (matchMedia("(prefers-reduced-motion)").matches) {
+ this._resetColorInputs();
+ }
+ },
+
+ /**
+ * Toggle the spaces toolbar and toolbar buttons visibility.
+ *
+ * @param {boolean} state - The visibility state to update the elements.
+ */
+ toggleToolbar(state) {
+ // Prevent the visibility change state of the spaces toolbar if we're
+ // currently customizing it, in order to avoid weird positioning outcomes
+ // with the customize popup panel.
+ if (this.isCustomizing) {
+ return;
+ }
+
+ this.isHidden = state;
+
+ // The focused element, prior to toggling.
+ let activeElement = document.activeElement;
+
+ let pinnedButton = document.getElementById("spacesPinnedButton");
+ pinnedButton.hidden = !state;
+ let revealButton = document.getElementById("spacesToolbarReveal");
+ revealButton.hidden = !state;
+ this.element.hidden = state;
+
+ if (state && this.element.contains(activeElement)) {
+ // If the toolbar is being hidden and one of its child element was
+ // focused, move the focus to the pinned button without changing the
+ // focusButton attribute of this object.
+ pinnedButton.focus();
+ } else if (
+ !state &&
+ (activeElement == pinnedButton || activeElement == revealButton)
+ ) {
+ // If the the toolbar is being shown and the focus is on the pinned or
+ // reveal button, move the focus to the previously focused button.
+ this.focusButton?.focus();
+ }
+
+ // Update the window UI after the visibility state of the spaces toolbar
+ // has changed.
+ this.updateUI();
+ },
+
+ /**
+ * Toggle the spaces toolbar from a menuitem.
+ */
+ toggleToolbarFromMenu() {
+ this.toggleToolbar(!this.isHidden);
+ },
+
+ /**
+ * Update the addons buttons and propagate toolbar visibility to a global
+ * attribute.
+ */
+ updateUI() {
+ // Interrupt if the spaces toolbar isn't loaded yet.
+ if (!this.isLoaded) {
+ return;
+ }
+
+ let density = Services.prefs.getIntPref("mail.uidensity", 1);
+ switch (density) {
+ case 0:
+ this.densitySpacing = 10;
+ break;
+ case 1:
+ this.densitySpacing = 15;
+ break;
+ case 2:
+ this.densitySpacing = 20;
+ break;
+ }
+
+ // Toggle the window attribute for those CSS selectors that need it.
+ if (this.isHidden) {
+ document.documentElement.removeAttribute("spacestoolbar");
+ } else {
+ document.documentElement.setAttribute("spacestoolbar", "true");
+ this.updateAddonButtonsUI();
+ }
+ },
+
+ /**
+ * Reset the inline style of the various titlebars and toolbars that interact
+ * with the spaces toolbar.
+ */
+ resetInlineStyle() {
+ document.getElementById("tabmail-tabs").removeAttribute("style");
+ },
+
+ /**
+ * Update the UI based on the window sizing.
+ */
+ onWindowResize() {
+ if (!this.isLoaded) {
+ return;
+ }
+
+ this.updateUImacOS();
+ this.updateAddonButtonsUI();
+ },
+
+ /**
+ * Update the location of buttons added by addons based on the space available
+ * in the toolbar. If the number of buttons is greater than the height of the
+ * visible container, move those buttons inside an overflow popup.
+ */
+ updateAddonButtonsUI() {
+ if (this.isHidden) {
+ return;
+ }
+
+ let overflowButton = document.getElementById(
+ "spacesToolbarAddonsOverflowButton"
+ );
+ let separator = document.getElementById("spacesPopupAddonsSeparator");
+ let popup = document.getElementById("spacesToolbarAddonsPopup");
+ // Bail out if we don't have any add-ons button.
+ if (!this.addonButtonCount) {
+ if (this.focusButton == overflowButton) {
+ this.setFocusButton(
+ this.element.querySelector(".spaces-toolbar-button:not([hidden])")
+ );
+ }
+ overflowButton.hidden = true;
+ separator.collapsed = true;
+ popup.hidePopup();
+ return;
+ }
+
+ separator.collapsed = false;
+ // Use the first available button's height as reference, and include the gap
+ // defined by the UIDensity pref.
+ let buttonHeight =
+ document.querySelector(".spaces-toolbar-button").getBoundingClientRect()
+ .height + this.densitySpacing;
+
+ let containerHeight = document
+ .getElementById("spacesToolbarAddonsContainer")
+ .getBoundingClientRect().height;
+
+ // Calculate the visible threshold of add-on buttons by:
+ // - Multiplying the space occupied by one button for the number of the
+ // add-on buttons currently present.
+ // - Subtracting the height of the add-ons container from the height
+ // occupied by all add-on buttons.
+ // - Dividing the returned value by the height of a single button.
+ // Doing so we will get an integer representing how many buttons might or
+ // might not fit in the available area.
+ let threshold = Math.ceil(
+ (buttonHeight * this.addonButtonCount - containerHeight) / buttonHeight
+ );
+
+ // Always reset the visibility of all buttons to avoid unnecessary
+ // calculations when needing to reveal hidden buttons.
+ for (let btn of document.querySelectorAll(".spaces-addon-button[hidden]")) {
+ btn.hidden = false;
+ }
+
+ // If we get a negative threshold, it means we have plenty of empty space
+ // so we don't need to do anything.
+ if (threshold <= 0) {
+ // If the overflow button was the currently focused button, move the focus
+ // to an arbitrary first available button.
+ if (this.focusButton == overflowButton) {
+ this.setFocusButton(
+ this.element.querySelector(".spaces-toolbar-button:not([hidden])")
+ );
+ }
+ overflowButton.hidden = true;
+ popup.hidePopup();
+ return;
+ }
+
+ overflowButton.hidden = false;
+ // Hide as many buttons as needed based on the threshold value.
+ for (let i = 0; i <= threshold; i++) {
+ let btn = document.querySelector(
+ `.spaces-addon-button:nth-last-child(${i})`
+ );
+ if (btn) {
+ // If one of the hidden add-on buttons was the focused one, move the
+ // focus to the overflow button.
+ if (btn == this.focusButton) {
+ this.setFocusButton(overflowButton);
+ }
+ btn.hidden = true;
+ }
+ }
+ },
+
+ /**
+ * Update the spacesToolbar UI and adjacent tabs exclusively for macOS. This
+ * is necessary mostly to tackle the changes when switching fullscreen mode.
+ */
+ updateUImacOS() {
+ // No need to to anything if we're not on macOS.
+ if (AppConstants.platform != "macosx") {
+ return;
+ }
+
+ // Add inline styling to the tabmail tabs only if we're on macOS and the
+ // app is in full screen mode.
+ if (window.fullScreen) {
+ let size = this.element.getBoundingClientRect().width;
+ let style = `margin-inline-start: ${size}px;`;
+ document.getElementById("tabmail-tabs").setAttribute("style", style);
+ return;
+ }
+
+ // Reset the style if we exited full screen mode.
+ this.resetInlineStyle();
+ },
+
+ /**
+ * @typedef NativeButtonProperties
+ * @property {string} title - The text of the button tooltip and menuitem value.
+ * @property {string} url - The URL of the content tab to open.
+ * @property {Map} iconStyles - The icon styles Map.
+ * @property {?string} badgeText - The optional badge text.
+ * @property {?Map} badgeStyles - The optional badge styles Map.
+ */
+
+ /**
+ * Helper function for extensions in order to add buttons to the spaces
+ * toolbar.
+ *
+ * @param {string} id - The ID of the newly created button.
+ * @param {NativeButtonProperties} properties - The properties of the new button.
+ *
+ * @returns {Promise} - A Promise that resolves when the button is created.
+ */
+ async createToolbarButton(id, properties = {}) {
+ return new Promise((resolve, reject) => {
+ if (!this.isLoaded) {
+ return reject("Unable to add spaces toolbar button! Toolbar not ready");
+ }
+ if (
+ !id ||
+ !properties.title ||
+ !properties.url ||
+ !properties.iconStyles
+ ) {
+ return reject(
+ "Unable to add spaces toolbar button! Missing ID, Title, IconStyles, or space URL"
+ );
+ }
+
+ // Create the button.
+ let button = document.createElement("button");
+ button.classList.add("spaces-toolbar-button", "spaces-addon-button");
+ button.id = id;
+ button.title = properties.title;
+ button.tabIndex = -1;
+
+ let badge = document.createElement("span");
+ badge.classList.add("spaces-badge-container");
+ button.appendChild(badge);
+
+ let img = document.createElement("img");
+ img.setAttribute("alt", "");
+ button.appendChild(img);
+ document
+ .getElementById("spacesToolbarAddonsContainer")
+ .appendChild(button);
+
+ // Create the menuitem.
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.classList.add(
+ "spaces-addon-menuitem",
+ "menuitem-iconic",
+ "spaces-popup-menuitem"
+ );
+ menuitem.id = `${id}-menuitem`;
+ menuitem.label = properties.title;
+ document
+ .getElementById("spacesButtonMenuPopup")
+ .insertBefore(
+ menuitem,
+ document.getElementById("spacesPopupRevealSeparator")
+ );
+
+ // Set icons. The unified toolbar customization also relies on the CSS
+ // variables of the img.
+ for (let style of this.SUPPORTED_ICON_STYLES) {
+ if (properties.iconStyles.has(style)) {
+ img.style.setProperty(style, properties.iconStyles.get(style));
+ menuitem.style.setProperty(style, properties.iconStyles.get(style));
+ }
+ }
+
+ // Add space.
+ gSpacesToolbar.spaces.push({
+ name: id,
+ button,
+ menuitem,
+ url: properties.url,
+ isExtensionSpace: true,
+ tabInSpace(tabInfo) {
+ // TODO: Store the spaceButtonId in the XULStore (or somewhere), so the
+ // space is recognized after a restart. Or force closing of all spaces
+ // on shutdown.
+ return tabInfo.spaceButtonId == this.name ? 1 : 0;
+ },
+ open(where) {
+ // The check if we should switch to an existing tab in this space was
+ // done in openSpace() and this function here should always open a new
+ // tab and not switch to a tab which might have loaded the same url,
+ // but belongs to a different space.
+ let tab = openTab(
+ "contentTab",
+ { url: this.url, duplicate: true },
+ where
+ );
+ tab.spaceButtonId = this.name;
+ // TODO: Make sure the spaceButtonId is set during load, and not here,
+ // where it might be too late.
+ gSpacesToolbar.currentSpace = this;
+ button.classList.add("current");
+ return tab;
+ },
+ });
+
+ // Set click actions.
+ let tabmail = document.getElementById("tabmail");
+ this._addButtonClickListener(button, () => {
+ let space = gSpacesToolbar.spaces.find(space => space.name == id);
+ this.openSpace(tabmail, space);
+ });
+ menuitem.addEventListener("command", () => {
+ let space = gSpacesToolbar.spaces.find(space => space.name == id);
+ this.openSpace(tabmail, space);
+ });
+
+ // Set badge.
+ if (properties.badgeText) {
+ button.classList.add("has-badge");
+ badge.textContent = properties.badgeText;
+ }
+
+ if (properties.badgeStyles) {
+ for (let style of this.SUPPORTED_BADGE_STYLES) {
+ if (properties.badgeStyles.has(style)) {
+ badge.style.setProperty(style, properties.badgeStyles.get(style));
+ }
+ }
+ }
+
+ this.updateAddonButtonsUI();
+ return resolve();
+ });
+ },
+
+ /**
+ * Helper function for extensions in order to update buttons previously added
+ * to the spaces toolbar.
+ *
+ * @param {string} id - The ID of the button that needs to be updated.
+ * @param {NativeButtonProperties} properties - The new properties of the button.
+ * Not specifying the optional badgeText or badgeStyles will remove them.
+ *
+ * @returns {Promise} - A promise that resolves when the button is updated.
+ */
+ async updateToolbarButton(id, properties = {}) {
+ return new Promise((resolve, reject) => {
+ if (
+ !id ||
+ !properties.title ||
+ !properties.url ||
+ !properties.iconStyles
+ ) {
+ return reject(
+ "Unable to update spaces toolbar button! Missing ID, Title, IconsStyles, or space URL"
+ );
+ }
+
+ let button = document.getElementById(`${id}`);
+ let menuitem = document.getElementById(`${id}-menuitem`);
+ if (!button || !menuitem) {
+ return reject(
+ "Unable to update spaces toolbar button! Button or menuitem don't exist"
+ );
+ }
+
+ button.title = properties.title;
+ menuitem.label = properties.title;
+
+ // Update icons.
+ let img = button.querySelector("img");
+ for (let style of this.SUPPORTED_ICON_STYLES) {
+ let value = properties.iconStyles.get(style);
+ img.style.setProperty(style, value ?? null);
+ menuitem.style.setProperty(style, value ?? null);
+ }
+
+ // Update url.
+ let space = gSpacesToolbar.spaces.find(space => space.name == id);
+ if (space.url != properties.url) {
+ // TODO: Reload the space, when the url is changed (or close and re-open
+ // the tab).
+ space.url = properties.url;
+ }
+
+ // Update badge.
+ let badge = button.querySelector(".spaces-badge-container");
+ if (properties.badgeText) {
+ button.classList.add("has-badge");
+ badge.textContent = properties.badgeText;
+ } else {
+ button.classList.remove("has-badge");
+ badge.textContent = "";
+ }
+
+ for (let style of this.SUPPORTED_BADGE_STYLES) {
+ badge.style.setProperty(
+ style,
+ properties.badgeStyles?.get(style) ?? null
+ );
+ }
+
+ return resolve();
+ });
+ },
+
+ /**
+ * Helper function for extensions allowing the removal of previously created
+ * buttons.
+ *
+ * @param {string} id - The ID of the button that needs to be removed.
+ * @returns {Promise} - A promise that resolves when the button is removed.
+ */
+ async removeToolbarButton(id) {
+ return new Promise((resolve, reject) => {
+ if (!this.isLoaded) {
+ return reject(
+ "Unable to remove spaces toolbar button! Toolbar not ready"
+ );
+ }
+ if (!id) {
+ return reject("Unable to remove spaces toolbar button! Missing ID");
+ }
+
+ let button = document.getElementById(`${id}`);
+ // If the button being removed is the currently focused one, move the
+ // focus on an arbitrary first available spaces button.
+ if (this.focusButton == button) {
+ this.setFocusButton(
+ this.element.querySelector(".spaces-toolbar-button:not([hidden])")
+ );
+ }
+
+ button?.remove();
+ document.getElementById(`${id}-menuitem`)?.remove();
+
+ let space = gSpacesToolbar.spaces.find(space => space.name == id);
+ let tabmail = document.getElementById("tabmail");
+ let existing = tabmail.tabInfo.find(
+ tabInfo => space.tabInSpace(tabInfo) == 1
+ );
+ if (existing) {
+ tabmail.closeTab(existing);
+ }
+
+ gSpacesToolbar.spaces = gSpacesToolbar.spaces.filter(e => e.name != id);
+ this.updateAddonButtonsUI();
+
+ return resolve();
+ });
+ },
+
+ /**
+ * Populate the overflow container with a copy of all the currently hidden
+ * buttons generated by add-ons.
+ *
+ * @param {DOMEvent} event - The DOM click event.
+ */
+ openSpacesToolbarAddonsPopup(event) {
+ let popup = document.getElementById("spacesToolbarAddonsPopup");
+
+ for (let button of document.querySelectorAll(
+ ".spaces-addon-button[hidden]"
+ )) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.classList.add("menuitem-iconic", "spaces-popup-menuitem");
+ menuitem.label = button.title;
+
+ let img = button.querySelector("img");
+ for (let style of this.SUPPORTED_ICON_STYLES) {
+ menuitem.style.setProperty(
+ style,
+ img.style.getPropertyValue(style) ?? null
+ );
+ }
+
+ menuitem.addEventListener("command", () => button.click());
+ popup.appendChild(menuitem);
+ }
+
+ popup.openPopup(event.target, "after_start", 0, 0);
+ },
+
+ /**
+ * Empty the overflow container.
+ */
+ spacesToolbarAddonsPopupClosed() {
+ document.getElementById("spacesToolbarAddonsPopup").replaceChildren();
+ },
+
+ /**
+ * Copy the badges from the contained menu items to the pinned button.
+ * Should be called whenever one of the menu item's badge state changes.
+ */
+ updatePinnedBadgeState() {
+ let hasBadge = Boolean(
+ document.querySelector("#spacesButtonMenuPopup .has-badge")
+ );
+ let spacesPinnedButton = document.getElementById("spacesPinnedButton");
+ spacesPinnedButton.classList.toggle("has-badge", hasBadge);
+ },
+
+ /**
+ * Save the preferred state when the app is closed.
+ */
+ onUnload() {
+ Services.xulStore.setValue(
+ this.docURL,
+ "spacesToolbar",
+ "hidden",
+ this.isHidden
+ );
+ },
+};
diff --git a/comm/mail/base/content/spacesToolbarPin.inc.xhtml b/comm/mail/base/content/spacesToolbarPin.inc.xhtml
new file mode 100644
index 0000000000..95b0389561
--- /dev/null
+++ b/comm/mail/base/content/spacesToolbarPin.inc.xhtml
@@ -0,0 +1,46 @@
+# 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/.
+
+<toolbarbutton id="spacesPinnedButton"
+ type="menu"
+ wantdropmarker="false"
+ class="button toolbar-button"
+ data-l10n-id="spaces-toolbar-pinned-tab-button"
+ hidden="true"
+ badged="true"
+ tabindex="0">
+ <menupopup id="spacesButtonMenuPopup">
+ <menuitem id="spacesPopupButtonMail"
+ data-l10n-id="spaces-pinned-button-menuitem-mail2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuitem id="spacesPopupButtonAddressBook"
+ data-l10n-id="spaces-pinned-button-menuitem-address-book2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuitem id="spacesPopupButtonCalendar"
+ data-l10n-id="spaces-pinned-button-menuitem-calendar2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuitem id="spacesPopupButtonTasks"
+ data-l10n-id="spaces-pinned-button-menuitem-tasks2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuitem id="spacesPopupButtonChat"
+ data-l10n-id="spaces-pinned-button-menuitem-chat2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuseparator id="spacesPopupSettingsSeparator"/>
+ <menuitem id="spacesPopupButtonSettings"
+ data-l10n-id="spaces-pinned-button-menuitem-settings2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuseparator id="spacesPopupAddonsSeparator" collapsed="true"/>
+ <menuseparator id="spacesPopupRevealSeparator"/>
+ <menuitem id="spacesPopupButtonReveal"
+ data-l10n-id="spaces-pinned-button-menuitem-show"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ </menupopup>
+</toolbarbutton>
diff --git a/comm/mail/base/content/specialTabs.js b/comm/mail/base/content/specialTabs.js
new file mode 100644
index 0000000000..15a163c981
--- /dev/null
+++ b/comm/mail/base/content/specialTabs.js
@@ -0,0 +1,1320 @@
+/* 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 MozElements, openOptionsDialog */
+
+/* import-globals-from utilityOverlay.js */
+
+/* globals ZoomManager */ // From viewZoomOverlay.js
+/* globals PrintUtils */ // From printUtils.js
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+function tabProgressListener(aTab, aStartsBlank) {
+ this.mTab = aTab;
+ this.mBrowser = aTab.browser;
+ this.mBlank = aStartsBlank;
+ this.mProgressListener = null;
+}
+
+tabProgressListener.prototype = {
+ mTab: null,
+ mBrowser: null,
+ mBlank: null,
+ mProgressListener: null,
+
+ // cache flags for correct status bar update after tab switching
+ mStateFlags: 0,
+ mStatus: 0,
+ mMessage: "",
+
+ // count of open requests (should always be 0 or 1)
+ mRequestCount: 0,
+
+ addProgressListener(aProgressListener) {
+ this.mProgressListener = aProgressListener;
+ },
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ );
+ }
+ },
+ onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ );
+ }
+ },
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onLocationChange(
+ aWebProgress,
+ aRequest,
+ aLocationURI,
+ aFlags
+ );
+ }
+ // onLocationChange is called for both the top-level content
+ // and the subframes.
+ if (aWebProgress.isTopLevel) {
+ // Don't clear the favicon if this onLocationChange was triggered
+ // by a pushState or a replaceState. See bug 550565.
+ if (
+ aWebProgress.isLoadingDocument &&
+ !(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) &&
+ !(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
+ ) {
+ this.mTab.favIconUrl = null;
+ }
+
+ var location = aLocationURI ? aLocationURI.spec : "";
+ if (aLocationURI && !aLocationURI.schemeIs("about")) {
+ this.mTab.backButton.disabled = !this.mBrowser.canGoBack;
+ this.mTab.forwardButton.disabled = !this.mBrowser.canGoForward;
+ this.mTab.urlbar.value = location;
+ this.mTab.root.removeAttribute("collapsed");
+ } else {
+ this.mTab.root.setAttribute("collapsed", "false");
+ }
+
+ // Although we're unlikely to be loading about:blank, we'll check it
+ // anyway just in case. The second condition is for new tabs, otherwise
+ // the reload function is enabled until tab is refreshed.
+ this.mTab.reloadEnabled = !(
+ (location == "about:blank" && !this.mBrowser.browsingContext.opener) ||
+ location == ""
+ );
+ }
+ },
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onStateChange(
+ aWebProgress,
+ aRequest,
+ aStateFlags,
+ aStatus
+ );
+ }
+
+ if (!aRequest) {
+ return;
+ }
+
+ let tabmail = document.getElementById("tabmail");
+
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ this.mRequestCount++;
+ } else if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ // Since we (try to) only handle STATE_STOP of the last request,
+ // the count of open requests should now be 0.
+ this.mRequestCount = 0;
+ }
+
+ if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_START &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ if (!this.mBlank) {
+ this.mTab.title = specialTabs.contentTabType.loadingTabString;
+ this.mTab.securityIcon.setLoading(true);
+ tabmail.setTabBusy(this.mTab, true);
+ tabmail.setTabTitle(this.mTab);
+ }
+
+ // Set our unit testing variables accordingly
+ this.mTab.pageLoading = true;
+ this.mTab.pageLoaded = false;
+ } else if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ this.mBlank = false;
+ this.mTab.securityIcon.setLoading(false);
+ tabmail.setTabBusy(this.mTab, false);
+ this.mTab.title = this.mTab.browser.contentTitle;
+ tabmail.setTabTitle(this.mTab);
+
+ // Set our unit testing variables accordingly
+ this.mTab.pageLoading = false;
+ this.mTab.pageLoaded = true;
+
+ // If we've finished loading, and we've not had an icon loaded from a
+ // link element, then we try using the default icon for the site.
+ if (aWebProgress.isTopLevel && !this.mTab.favIconUrl) {
+ specialTabs.useDefaultFavIcon(this.mTab);
+ }
+ }
+ },
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onStatusChange(
+ aWebProgress,
+ aRequest,
+ aStatus,
+ aMessage
+ );
+ }
+ },
+ onSecurityChange(aWebProgress, aRequest, aState) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onSecurityChange(aWebProgress, aRequest, aState);
+ }
+
+ const wpl = Ci.nsIWebProgressListener;
+ const wpl_security_bits =
+ wpl.STATE_IS_SECURE | wpl.STATE_IS_BROKEN | wpl.STATE_IS_INSECURE;
+ let level = "";
+ switch (aState & wpl_security_bits) {
+ case wpl.STATE_IS_SECURE:
+ level = "high";
+ break;
+ case wpl.STATE_IS_BROKEN:
+ level = "broken";
+ break;
+ }
+ this.mTab.securityIcon.setSecurityLevel(level);
+ },
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onContentBlockingEvent(
+ aWebProgress,
+ aRequest,
+ aEvent
+ );
+ }
+ },
+ onRefreshAttempted(aWebProgress, aURI, aDelay, aSameURI) {
+ if (this.mProgressListener) {
+ return this.mProgressListener.onRefreshAttempted(
+ aWebProgress,
+ aURI,
+ aDelay,
+ aSameURI
+ );
+ }
+ return true;
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+/**
+ * Handles tab icons for parent process browsers. The DOMLinkAdded event won't
+ * fire for child process browsers, that is handled by LinkHandlerParent.
+ */
+var DOMLinkHandler = {
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMLinkAdded":
+ case "DOMLinkChanged":
+ this.onLinkAdded(event);
+ break;
+ }
+ },
+ onLinkAdded(event) {
+ let link = event.target;
+ let rel = link.rel && link.rel.toLowerCase();
+ if (!link || !link.ownerDocument || !rel || !link.href) {
+ return;
+ }
+
+ if (rel.split(/\s+/).includes("icon")) {
+ if (!Services.prefs.getBoolPref("browser.chrome.site_icons")) {
+ return;
+ }
+
+ let targetDoc = link.ownerDocument;
+
+ let uri = Services.io.newURI(link.href, targetDoc.characterSet);
+
+ // Verify that the load of this icon is legal.
+ // Some error or special pages can load their favicon.
+ // To be on the safe side, only allow chrome:// favicons.
+ let isAllowedPage =
+ targetDoc.documentURI == "about:home" ||
+ ["about:neterror?", "about:blocked?", "about:certerror?"].some(
+ function (aStart) {
+ targetDoc.documentURI.startsWith(aStart);
+ }
+ );
+
+ if (!isAllowedPage || !uri.schemeIs("chrome")) {
+ // Be extra paraniod and just make sure we're not going to load
+ // something we shouldn't. Firefox does this, so we're doing the same.
+ try {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ targetDoc.nodePrincipal,
+ uri,
+ Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT
+ );
+ } catch (ex) {
+ return;
+ }
+ }
+
+ try {
+ var contentPolicy = Cc[
+ "@mozilla.org/layout/content-policy;1"
+ ].getService(Ci.nsIContentPolicy);
+ } catch (e) {
+ // Refuse to load if we can't do a security check.
+ return;
+ }
+
+ // Security says okay, now ask content policy. This is probably trying to
+ // ensure that the image loaded always obeys the content policy. There
+ // may have been a chance that it was cached and we're trying to load it
+ // direct from the cache and not the normal route.
+ let { NetUtil } = ChromeUtils.import(
+ "resource://gre/modules/NetUtil.jsm"
+ );
+ let tmpChannel = NetUtil.newChannel({
+ uri,
+ loadingNode: targetDoc,
+ securityFlags: Ci.nsILoadInfo.SEC_ONLY_FOR_EXPLICIT_CONTENTSEC_CHECK,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_IMAGE,
+ });
+ let tmpLoadInfo = tmpChannel.loadInfo;
+ if (
+ contentPolicy.shouldLoad(uri, tmpLoadInfo, link.type) !=
+ Ci.nsIContentPolicy.ACCEPT
+ ) {
+ return;
+ }
+
+ let tab = document
+ .getElementById("tabmail")
+ .getBrowserForDocument(targetDoc.defaultView);
+
+ // If we don't have a browser/tab, then don't load the icon.
+ if (!tab) {
+ return;
+ }
+
+ // Just set the url on the browser and we'll display the actual icon
+ // when we finish loading the page.
+ specialTabs.setFavIcon(tab, link.href);
+ }
+ },
+};
+
+var contentTabBaseType = {
+ // List of URLs that will receive special treatment when opened in a tab.
+ // Note that about:preferences is loaded via a different mechanism.
+ inContentWhitelist: [
+ "about:addons",
+ "about:addressbook",
+ "about:blank",
+ "about:profiles",
+ "about:*",
+ ],
+
+ // Code to run if a particular document is loaded in a tab.
+ // The array members (functions) are for the respective document URLs
+ // as specified in inContentWhitelist.
+ inContentOverlays: [
+ // about:addons
+ function (aDocument, aTab) {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/aboutAddonsExtra.js",
+ aDocument.defaultView
+ );
+ },
+
+ // about:addressbook provides its own context menu.
+ function (aDocument, aTab) {
+ aTab.browser.removeAttribute("context");
+ },
+
+ // Let's not mess with about:blank.
+ null,
+
+ // about:profiles
+ function (aDocument, aTab) {
+ let win = aDocument.defaultView;
+ // Need a timeout to let the script run to create the needed buttons.
+ win.setTimeout(() => {
+ win.MozXULElement.insertFTLIfNeeded("messenger/aboutProfilesExtra.ftl");
+ for (let button of aDocument.querySelectorAll(
+ `[data-l10n-id="profiles-launch-profile"]`
+ )) {
+ win.document.l10n.setAttributes(
+ button,
+ "profiles-launch-profile-plain"
+ );
+ }
+ }, 500);
+ },
+
+ // Other about:* pages.
+ function (aDocument, aTab) {
+ // Provide context menu for about:* pages.
+ aTab.browser.setAttribute("context", "aboutPagesContext");
+ },
+ ],
+
+ shouldSwitchTo({ url, duplicate }) {
+ if (duplicate) {
+ return -1;
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ let tabInfo = tabmail.tabInfo;
+ let uri;
+
+ try {
+ uri = Services.io.newURI(url);
+ } catch (ex) {
+ return -1;
+ }
+
+ for (
+ let selectedIndex = 0;
+ selectedIndex < tabInfo.length;
+ ++selectedIndex
+ ) {
+ // Reuse the same tab, if only the anchors differ - especially for the
+ // about: pages, we just want to re-use the same tab.
+ if (
+ tabInfo[selectedIndex].mode.name == this.name &&
+ tabInfo[selectedIndex].browser.currentURI?.specIgnoringRef ==
+ uri.specIgnoringRef
+ ) {
+ // Go to the correct location on the page, but only if it's not the
+ // current location. This should NOT cause the page to reload.
+ if (tabInfo[selectedIndex].browser.currentURI.spec != uri.spec) {
+ MailE10SUtils.loadURI(tabInfo[selectedIndex].browser, uri.spec);
+ }
+ return selectedIndex;
+ }
+ }
+ return -1;
+ },
+
+ closeTab(aTab) {
+ aTab.browser.removeEventListener(
+ "pagetitlechanged",
+ aTab.titleListener,
+ true
+ );
+ aTab.browser.removeEventListener(
+ "DOMWindowClose",
+ aTab.closeListener,
+ true
+ );
+ aTab.browser.removeEventListener("DOMLinkAdded", DOMLinkHandler);
+ aTab.browser.removeEventListener("DOMLinkChanged", DOMLinkHandler);
+ aTab.browser.webProgress.removeProgressListener(aTab.filter);
+ aTab.filter.removeProgressListener(aTab.progressListener);
+ aTab.browser.destroy();
+ },
+
+ saveTabState(aTab) {
+ aTab.browser.setAttribute("type", "content");
+ aTab.browser.removeAttribute("primary");
+ },
+
+ showTab(aTab) {
+ aTab.browser.setAttribute("type", "content");
+ aTab.browser.setAttribute("primary", "true");
+ if (aTab.browser.currentURI.spec.startsWith("about:preferences")) {
+ aTab.browser.contentDocument.documentElement.focus();
+ }
+ },
+
+ getBrowser(aTab) {
+ return aTab.browser;
+ },
+
+ _setUpLoadListener(aTab) {
+ let self = this;
+
+ function onLoad(aEvent) {
+ let doc = aEvent.target;
+ let url = doc.defaultView.location.href;
+
+ // If this document has an overlay defined, run it now.
+ let ind = self.inContentWhitelist.indexOf(url);
+ if (ind < 0) {
+ // Try a wildcard.
+ ind = self.inContentWhitelist.indexOf(url.replace(/:.*/, ":*"));
+ }
+ if (ind >= 0) {
+ let overlayFunction = self.inContentOverlays[ind];
+ if (overlayFunction) {
+ overlayFunction(doc, aTab);
+ }
+ }
+ }
+
+ aTab.loadListener = onLoad;
+ aTab.browser.addEventListener("load", aTab.loadListener, true);
+ },
+
+ // Internal function used to set up the title listener on a content tab.
+ _setUpTitleListener(aTab) {
+ function onDOMTitleChanged(aEvent) {
+ aTab.title = aTab.browser.contentTitle;
+ document.getElementById("tabmail").setTabTitle(aTab);
+ }
+ // Save the function we'll use as listener so we can remove it later.
+ aTab.titleListener = onDOMTitleChanged;
+ // Add the listener.
+ aTab.browser.addEventListener("pagetitlechanged", aTab.titleListener, true);
+ },
+
+ /**
+ * Internal function used to set up the close window listener on a content
+ * tab.
+ */
+ _setUpCloseWindowListener(aTab) {
+ function onDOMWindowClose(aEvent) {
+ if (!aEvent.isTrusted) {
+ return;
+ }
+
+ // Redirect any window.close events to closing the tab. As a 3-pane tab
+ // must be open, we don't need to worry about being the last tab open.
+ document.getElementById("tabmail").closeTab(aTab);
+ aEvent.preventDefault();
+ }
+ // Save the function we'll use as listener so we can remove it later.
+ aTab.closeListener = onDOMWindowClose;
+ // Add the listener.
+ aTab.browser.addEventListener("DOMWindowClose", aTab.closeListener, true);
+ },
+
+ supportsCommand(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ case "cmd_print":
+ case "button_print":
+ case "cmd_stop":
+ case "cmd_reload":
+ case "Browser:Back":
+ case "Browser:Forward":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ return true;
+ case "cmd_print":
+ case "button_print": {
+ let uri = aTab.browser?.currentURI;
+ if (!uri || !uri.schemeIs("about")) {
+ return true;
+ }
+ return [
+ "addressbook",
+ "certificate",
+ "crashes",
+ "credits",
+ "license",
+ "profiles",
+ "support",
+ "telemetry",
+ ].includes(uri.filePath);
+ }
+ case "cmd_reload":
+ return aTab.reloadEnabled;
+ case "cmd_stop":
+ return aTab.busy;
+ case "Browser:Back":
+ return aTab.browser?.canGoBack;
+ case "Browser:Forward":
+ return aTab.browser?.canGoForward;
+ default:
+ return false;
+ }
+ },
+
+ doCommand(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ ZoomManager.reduce();
+ break;
+ case "cmd_fullZoomEnlarge":
+ ZoomManager.enlarge();
+ break;
+ case "cmd_fullZoomReset":
+ ZoomManager.reset();
+ break;
+ case "cmd_fullZoomToggle":
+ ZoomManager.toggleZoom();
+ break;
+ case "cmd_find":
+ aTab.findbar.onFindCommand();
+ break;
+ case "cmd_findAgain":
+ aTab.findbar.onFindAgainCommand(false);
+ break;
+ case "cmd_findPrevious":
+ aTab.findbar.onFindAgainCommand(true);
+ break;
+ case "cmd_print":
+ PrintUtils.startPrintWindow(this.getBrowser(aTab).browsingContext, {});
+ break;
+ case "cmd_stop":
+ aTab.browser.stop();
+ break;
+ case "cmd_reload":
+ aTab.browser.reload();
+ break;
+ case "Browser:Back":
+ specialTabs.browserBack();
+ break;
+ case "Browser:Forward":
+ specialTabs.browserForward();
+ break;
+ }
+ },
+};
+
+/**
+ * Class that wraps the content page loading/security icon.
+ */
+// Ideally, this could be moved into a sub-class for content tabs.
+class SecurityIcon {
+ constructor(icon) {
+ this.icon = icon;
+ this.loading = false;
+ this.securityLevel = "";
+ this.updateIcon();
+ }
+
+ /**
+ * Set whether the page is loading.
+ *
+ * @param {boolean} loading - Whether the page is loading.
+ */
+ setLoading(loading) {
+ if (this.loading !== loading) {
+ this.loading = loading;
+ this.updateIcon();
+ }
+ }
+
+ /**
+ * Set the security level of the page.
+ *
+ * @param {"high"|"broken"|""} - The security level for the page, or empty if
+ * it is to be ignored.
+ */
+ setSecurityLevel(securityLevel) {
+ if (this.securityLevel !== securityLevel) {
+ this.securityLevel = securityLevel;
+ this.updateIcon();
+ }
+ }
+
+ updateIcon() {
+ let src;
+ let srcSet;
+ let l10nId;
+ let secure = false;
+ if (this.loading) {
+ src = "chrome://global/skin/icons/loading.png";
+ srcSet = "chrome://global/skin/icons/loading@2x.png 2x";
+ l10nId = "content-tab-page-loading-icon";
+ } else {
+ switch (this.securityLevel) {
+ case "high":
+ secure = true;
+ src = "chrome://messenger/skin/icons/connection-secure.svg";
+ l10nId = "content-tab-security-high-icon";
+ break;
+ case "broken":
+ src = "chrome://messenger/skin/icons/connection-insecure.svg";
+ l10nId = "content-tab-security-broken-icon";
+ break;
+ }
+ }
+ if (srcSet) {
+ this.icon.setAttribute("srcset", srcSet);
+ } else {
+ this.icon.removeAttribute("srcset");
+ }
+ if (src) {
+ this.icon.setAttribute("src", src);
+ // Set alt.
+ document.l10n.setAttributes(this.icon, l10nId);
+ } else {
+ this.icon.removeAttribute("src");
+ this.icon.removeAttribute("data-l10n-id");
+ this.icon.removeAttribute("alt");
+ }
+ this.icon.classList.toggle("secure-connection-icon", secure);
+ }
+}
+
+var specialTabs = {
+ _kAboutRightsVersion: 1,
+ get _protocolSvc() {
+ delete this._protocolSvc;
+ return (this._protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService));
+ },
+
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document
+ .getElementById("messenger-notification-bottom")
+ .append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ // This will open any special tabs if necessary on startup.
+ openSpecialTabsOnStartup() {
+ let tabmail = document.getElementById("tabmail");
+
+ tabmail.registerTabType(this.contentTabType);
+
+ this.showWhatsNewPage();
+
+ // Show the about rights notification if we need to.
+ if (this.shouldShowAboutRightsNotification()) {
+ this.showAboutRightsNotification();
+ }
+ if (this.shouldShowPolicyNotification()) {
+ // Do it on a timeout to workaround that open in background do not work when called too early.
+ setTimeout(this.showPolicyNotification, 10000);
+ }
+ },
+
+ /**
+ * A tab to show content pages.
+ */
+ contentTabType: {
+ __proto__: contentTabBaseType,
+ name: "contentTab",
+ perTabPanel: "vbox",
+ lastBrowserId: 0,
+ get loadingTabString() {
+ delete this.loadingTabString;
+ return (this.loadingTabString = document
+ .getElementById("bundle_messenger")
+ .getString("loadingTab"));
+ },
+
+ modes: {
+ contentTab: {
+ type: "contentTab",
+ },
+ },
+
+ /**
+ * This is the internal function used by content tabs to open a new tab. To
+ * open a contentTab, use specialTabs.openTab("contentTab", aArgs)
+ *
+ * @param {object} aArgs - The options that content tabs accept.
+ * @param {string} aArgs.url - The URL that is to be opened
+ * @param {nsIOpenWindowInfo} [aArgs.openWindowInfo] - The opener window
+ * @param {"single-site"|"single-page"|null} [aArgs.linkHandler="single-site"]
+ * Restricts navigation in the browser to be opened:
+ * - "single-site" allows only URLs in the same domain as
+ * aArgs.url (including subdomains).
+ * - "single-page" allows only URLs matching aArgs.url.
+ * - `null` applies no such restrictions.
+ * All other links are sent to an external browser.
+ * @param {Function} [aArgs.onLoad] - A function that takes an Event and a
+ * DOMNode. It is called when the content page is done loading. The
+ * first argument is the load event, and the second argument is the
+ * xul:browser that holds the page. You can access the inner tab's
+ * window object by accessing the second parameter's contentWindow
+ * property.
+ */
+ openTab(aTab, aArgs) {
+ if (!("url" in aArgs)) {
+ throw new Error("url must be specified");
+ }
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("contentTab")
+ .firstElementChild.cloneNode(true);
+
+ clone.setAttribute("id", "contentTab" + this.lastBrowserId);
+ clone.setAttribute("collapsed", false);
+
+ let toolbox = clone.firstElementChild;
+ toolbox.setAttribute("id", "contentTabToolbox" + this.lastBrowserId);
+ toolbox.firstElementChild.setAttribute(
+ "id",
+ "contentTabToolbar" + this.lastBrowserId
+ );
+
+ aTab.linkedBrowser = aTab.browser = document.createXULElement("browser");
+ aTab.browser.setAttribute("id", "contentTabBrowser" + this.lastBrowserId);
+ aTab.browser.setAttribute("type", "content");
+ aTab.browser.setAttribute("flex", "1");
+ aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ aTab.browser.setAttribute("context", "browserContext");
+ aTab.browser.setAttribute("maychangeremoteness", "true");
+ aTab.browser.setAttribute("onclick", "return contentAreaClick(event);");
+ aTab.browser.openWindowInfo = aArgs.openWindowInfo || null;
+ clone.querySelector("stack").appendChild(aTab.browser);
+
+ if (aArgs.skipLoad) {
+ clone.querySelector("browser").setAttribute("nodefaultsrc", "true");
+ }
+ if (aArgs.userContextId) {
+ aTab.browser.setAttribute("usercontextid", aArgs.userContextId);
+ }
+ aTab.panel.setAttribute("id", "contentTabWrapper" + this.lastBrowserId);
+ aTab.panel.appendChild(clone);
+ aTab.root = clone;
+
+ ExtensionParent.apiManager.emit(
+ "extension-browser-inserted",
+ aTab.browser
+ );
+
+ // For pdf.js use the aboutPagesContext context menu.
+ if (aArgs.url.includes("type=application/pdf")) {
+ aTab.browser.setAttribute("context", "aboutPagesContext");
+ }
+
+ // Start setting up the browser.
+ aTab.toolbar = aTab.panel.querySelector(".contentTabToolbar");
+ aTab.backButton = aTab.toolbar.querySelector(".back-btn");
+ aTab.backButton.addEventListener("command", () => aTab.browser.goBack());
+ aTab.forwardButton = aTab.toolbar.querySelector(".forward-btn");
+ aTab.forwardButton.addEventListener("command", () =>
+ aTab.browser.goForward()
+ );
+ aTab.securityIcon = new SecurityIcon(
+ aTab.toolbar.querySelector(".contentTabSecurity")
+ );
+ aTab.urlbar = aTab.toolbar.querySelector(".contentTabUrlInput");
+ aTab.urlbar.value = aArgs.url;
+
+ // As we're opening this tab, showTab may not get called, so set
+ // the type according to if we're opening in background or not.
+ let background = "background" in aArgs && aArgs.background;
+ if (background) {
+ aTab.browser.removeAttribute("primary");
+ } else {
+ aTab.browser.setAttribute("primary", "true");
+ }
+
+ if (aArgs.linkHandler == "single-page") {
+ aTab.browser.setAttribute("messagemanagergroup", "single-page");
+ } else if (aArgs.linkHandler === null) {
+ aTab.browser.setAttribute("messagemanagergroup", "browsers");
+ } else {
+ aTab.browser.setAttribute("messagemanagergroup", "single-site");
+ }
+
+ aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler);
+ aTab.browser.addEventListener("DOMLinkChanged", DOMLinkHandler);
+
+ // Now initialise the find bar.
+ aTab.findbar = document.createXULElement("findbar");
+ aTab.findbar.setAttribute(
+ "browserid",
+ "contentTabBrowser" + this.lastBrowserId
+ );
+ clone.appendChild(aTab.findbar);
+
+ // Default to reload being disabled.
+ aTab.reloadEnabled = false;
+
+ // Now set up the listeners.
+ this._setUpLoadListener(aTab);
+ this._setUpTitleListener(aTab);
+ this._setUpCloseWindowListener(aTab);
+
+ /**
+ * Override the browser custom element's version, which returns gBrowser.
+ */
+ aTab.browser.getTabBrowser = function () {
+ return document.getElementById("tabmail");
+ };
+
+ if ("onLoad" in aArgs) {
+ aTab.browser.addEventListener(
+ "load",
+ function _contentTab_onLoad(event) {
+ aArgs.onLoad(event, aTab.browser);
+ aTab.browser.removeEventListener("load", _contentTab_onLoad, true);
+ },
+ true
+ );
+ }
+
+ // Create a filter and hook it up to our browser
+ let filter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ aTab.filter = filter;
+ aTab.browser.webProgress.addProgressListener(
+ filter,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+
+ // Wire up a progress listener to the filter for this browser
+ aTab.progressListener = new tabProgressListener(aTab, false);
+
+ filter.addProgressListener(
+ aTab.progressListener,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+
+ if ("onListener" in aArgs) {
+ aArgs.onListener(aTab.browser, aTab.progressListener);
+ }
+
+ // Initialize our unit testing variables.
+ aTab.pageLoading = false;
+ aTab.pageLoaded = false;
+
+ // Now start loading the content.
+ aTab.title = this.loadingTabString;
+
+ if (!aArgs.skipLoad) {
+ MailE10SUtils.loadURI(aTab.browser, aArgs.url, {
+ csp: aArgs.csp,
+ referrerInfo: aArgs.referrerInfo,
+ triggeringPrincipal: aArgs.triggeringPrincipal,
+ });
+ }
+
+ this.lastBrowserId++;
+ },
+ tryCloseTab(aTab) {
+ return aTab.browser.permitUnload();
+ },
+ persistTab(aTab) {
+ if (aTab.browser.currentURI.spec == "about:blank") {
+ return null;
+ }
+
+ // Extension pages of temporarily installed extensions cannot be restored.
+ if (
+ aTab.browser.currentURI.scheme == "moz-extension" &&
+ WebExtensionPolicy.getByHostname(aTab.browser.currentURI.host)
+ ?.temporarilyInstalled
+ ) {
+ return null;
+ }
+
+ return {
+ tabURI: aTab.browser.currentURI.spec,
+ linkHandler: aTab.browser.getAttribute("messagemanagergroup"),
+ userContextId: `${
+ aTab.browser.getAttribute("usercontextid") ||
+ Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID
+ }`,
+ };
+ },
+ restoreTab(aTabmail, aPersistedState) {
+ let tab = aTabmail.openTab("contentTab", {
+ background: true,
+ duplicate: aPersistedState.duplicate,
+ linkHandler: aPersistedState.linkHandler,
+ url: aPersistedState.tabURI,
+ userContextId: aPersistedState.userContextId,
+ });
+
+ if (aPersistedState.tabURI == "about:addons") {
+ // Also in `openAddonsMgr` in mailCore.js.
+ tab.browser.droppedLinkHandler = event =>
+ tab.browser.contentWindow.gDragDrop.onDrop(event);
+ }
+ },
+ },
+
+ /**
+ * Shows the what's new page in the system browser if we should.
+ * Will update the mstone pref to a new version if needed.
+ *
+ * @see {BrowserContentHandler.needHomepageOverride}
+ */
+ showWhatsNewPage() {
+ let old_mstone = Services.prefs.getCharPref(
+ "mailnews.start_page_override.mstone",
+ ""
+ );
+
+ let mstone = Services.appinfo.version;
+ if (mstone != old_mstone) {
+ Services.prefs.setCharPref("mailnews.start_page_override.mstone", mstone);
+ }
+
+ if (AppConstants.MOZ_UPDATER) {
+ let update = Cc["@mozilla.org/updates/update-manager;1"].getService(
+ Ci.nsIUpdateManager
+ ).readyUpdate;
+
+ if (update && Services.vc.compare(update.appVersion, old_mstone) > 0) {
+ let overridePage = Services.urlFormatter.formatURLPref(
+ "mailnews.start_page.override_url"
+ );
+ overridePage = this.getPostUpdateOverridePage(update, overridePage);
+ overridePage = overridePage.replace("%OLD_VERSION%", old_mstone);
+ if (overridePage) {
+ openLinkExternally(overridePage);
+ }
+ }
+ }
+ },
+
+ /**
+ * Gets the override page for the first run after the application has been
+ * updated.
+ *
+ * @param {nsIUpdate} update - The nsIUpdate for the update that has been applied.
+ * @param {string} defaultOverridePage - The default override page.
+ * @returns {string} The override page.
+ */
+ getPostUpdateOverridePage(update, defaultOverridePage) {
+ update = update.QueryInterface(Ci.nsIWritablePropertyBag);
+ let actions = update.getProperty("actions");
+ // When the update doesn't specify actions fallback to the original behavior
+ // of displaying the default override page.
+ if (!actions) {
+ return defaultOverridePage;
+ }
+
+ // The existence of silent or the non-existence of showURL in the actions both
+ // mean that an override page should not be displayed.
+ if (actions.includes("silent") || !actions.includes("showURL")) {
+ return "";
+ }
+
+ // If a policy was set to not allow the update.xml-provided
+ // URL to be used, use the default fallback (which will also
+ // be provided by the policy).
+ if (!Services.policies.isAllowed("postUpdateCustomPage")) {
+ return defaultOverridePage;
+ }
+
+ return update.getProperty("openURL") || defaultOverridePage;
+ },
+
+ /**
+ * Looks at the existing prefs and determines if we should show the policy or not.
+ */
+ shouldShowPolicyNotification() {
+ let dataSubmissionEnabled = Services.prefs.getBoolPref(
+ "datareporting.policy.dataSubmissionEnabled",
+ true
+ );
+ let dataSubmissionPolicyBypassNotification = Services.prefs.getBoolPref(
+ "datareporting.policy.dataSubmissionPolicyBypassNotification",
+ false
+ );
+ let dataSubmissionPolicyAcceptedVersion = Services.prefs.getIntPref(
+ "datareporting.policy.dataSubmissionPolicyAcceptedVersion",
+ 0
+ );
+ let currentPolicyVersion = Services.prefs.getIntPref(
+ "datareporting.policy.currentPolicyVersion",
+ 1
+ );
+ if (
+ !AppConstants.MOZ_DATA_REPORTING ||
+ !dataSubmissionEnabled ||
+ dataSubmissionPolicyBypassNotification
+ ) {
+ return false;
+ }
+ if (dataSubmissionPolicyAcceptedVersion >= currentPolicyVersion) {
+ return false;
+ }
+ return true;
+ },
+
+ showPolicyNotification() {
+ try {
+ let firstRunURL = Services.prefs.getStringPref(
+ "datareporting.policy.firstRunURL"
+ );
+ document.getElementById("tabmail").openTab("contentTab", {
+ background: true,
+ url: firstRunURL,
+ });
+ } catch (e) {
+ // Show the infobar if it fails to show the privacy policy in the new tab.
+ this.showTelemetryNotification();
+ }
+ let currentPolicyVersion = Services.prefs.getIntPref(
+ "datareporting.policy.currentPolicyVersion",
+ 1
+ );
+ Services.prefs.setIntPref(
+ "datareporting.policy.dataSubmissionPolicyAcceptedVersion",
+ currentPolicyVersion
+ );
+ Services.prefs.setStringPref(
+ "datareporting.policy.dataSubmissionPolicyNotifiedTime",
+ new Date().getTime().toString()
+ );
+ },
+
+ showTelemetryNotification() {
+ let brandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let telemetryBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/telemetry.properties"
+ );
+
+ let productName = brandBundle.GetStringFromName("brandFullName");
+ let serverOwner = Services.prefs.getCharPref(
+ "toolkit.telemetry.server_owner"
+ );
+ let telemetryText = telemetryBundle.formatStringFromName("telemetryText", [
+ productName,
+ serverOwner,
+ ]);
+
+ // TODO: sync up this bar with Firefox:
+ // https://searchfox.org/mozilla-central/rev/227f22acef5c4865503bde9f835452bf38332c8e/browser/locales/en-US/chrome/browser/browser.properties#697-698
+ let buttons = [
+ {
+ label: telemetryBundle.GetStringFromName("telemetryLinkLabel"),
+ popup: null,
+ callback: () => {
+ openOptionsDialog("panePrivacy", "privacyDataCollectionCategory");
+ },
+ },
+ ];
+
+ let notification = this.msgNotificationBar.appendNotification(
+ "telemetry",
+ {
+ label: telemetryText,
+ priority: this.msgNotificationBar.PRIORITY_INFO_LOW,
+ },
+ buttons
+ );
+ // Arbitrary number, just so bar sticks around for a bit.
+ notification.persistence = 3;
+ },
+
+ /**
+ * Looks at the existing prefs and determines if we should show about:rights
+ * or not.
+ *
+ * This is controlled by two prefs:
+ *
+ * mail.rights.override
+ * If this pref is set to false, always show the about:rights
+ * notification.
+ * If this pref is set to true, never show the about:rights notification.
+ * If the pref doesn't exist, then we fallback to checking
+ * mail.rights.version.
+ *
+ * mail.rights.version
+ * If this pref isn't set or the value is less than the current version
+ * then we show the about:rights notification.
+ */
+ shouldShowAboutRightsNotification() {
+ try {
+ return !Services.prefs.getBoolPref("mail.rights.override");
+ } catch (e) {}
+
+ return (
+ Services.prefs.getIntPref("mail.rights.version") <
+ this._kAboutRightsVersion
+ );
+ },
+
+ async showAboutRightsNotification() {
+ var rightsBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/aboutRights.properties"
+ );
+
+ var buttons = [
+ {
+ label: rightsBundle.GetStringFromName("buttonLabel"),
+ accessKey: rightsBundle.GetStringFromName("buttonAccessKey"),
+ popup: null,
+ callback(aNotificationBar, aButton) {
+ // Show the about:rights tab
+ document.getElementById("tabmail").openTab("contentTab", {
+ url: "about:rights",
+ });
+ },
+ },
+ ];
+
+ let notifyRightsText = await document.l10n.formatValue(
+ "about-rights-notification-text"
+ );
+ let notification = this.msgNotificationBar.appendNotification(
+ "about-rights",
+ {
+ label: notifyRightsText,
+ priority: this.msgNotificationBar.PRIORITY_INFO_LOW,
+ },
+ buttons
+ );
+ // Arbitrary number, just so bar sticks around for a bit.
+ notification.persistence = 3;
+
+ // Set the pref to say we've displayed the notification.
+ Services.prefs.setIntPref("mail.rights.version", this._kAboutRightsVersion);
+ },
+
+ /**
+ * Determine if we should load fav icons or not.
+ *
+ * @param aURI An nsIURI containing the current url.
+ */
+ _shouldLoadFavIcon(aURI) {
+ return (
+ aURI &&
+ Services.prefs.getBoolPref("browser.chrome.site_icons") &&
+ Services.prefs.getBoolPref("browser.chrome.favicons") &&
+ "schemeIs" in aURI &&
+ (aURI.schemeIs("http") || aURI.schemeIs("https"))
+ );
+ },
+
+ /**
+ * Tries to use the default favicon for a webpage for the specified tab.
+ * We'll use the site's favicon.ico if prefs allow us to.
+ */
+ useDefaultFavIcon(aTab) {
+ // Use documentURI in the check for shouldLoadFavIcon so that we do the
+ // right thing with about:-style error pages.
+ let docURIObject = aTab.browser.documentURI;
+ let icon = null;
+ if (this._shouldLoadFavIcon(docURIObject)) {
+ icon = docURIObject.prePath + "/favicon.ico";
+ }
+
+ this.setFavIcon(aTab, icon);
+ },
+
+ /**
+ * This sets the specified tab to load and display the given icon for the
+ * page shown in the browser. It is assumed that the preferences have already
+ * been checked before calling this function appropriately.
+ *
+ * @param aTab The tab to set the icon for.
+ * @param aIcon A string based URL of the icon to try and load.
+ */
+ setFavIcon(aTab, aIcon) {
+ if (aIcon) {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ aTab.browser.currentURI,
+ Services.io.newURI(aIcon),
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ aTab.browser.contentPrincipal
+ );
+ }
+ document
+ .getElementById("tabmail")
+ .setTabFavIcon(
+ aTab,
+ aIcon,
+ "chrome://messenger/skin/icons/new/compact/draft.svg"
+ );
+ },
+
+ browserForward() {
+ let tabmail = document.getElementById("tabmail");
+ if (
+ !["contentTab", "mail3PaneTab"].includes(
+ tabmail?.currentTabInfo.mode.name
+ )
+ ) {
+ return;
+ }
+ let browser = tabmail.getBrowserForSelectedTab();
+ if (!browser) {
+ return;
+ }
+ if (browser.webNavigation) {
+ browser.webNavigation.goForward();
+ }
+ },
+
+ browserBack() {
+ let tabmail = document.getElementById("tabmail");
+ if (
+ !["contentTab", "mail3PaneTab"].includes(
+ tabmail?.currentTabInfo.mode.name
+ )
+ ) {
+ return;
+ }
+ let browser = tabmail.getBrowserForSelectedTab();
+ if (!browser) {
+ return;
+ }
+ if (browser.webNavigation) {
+ browser.webNavigation.goBack();
+ }
+ },
+};
diff --git a/comm/mail/base/content/sync.js b/comm/mail/base/content/sync.js
new file mode 100644
index 0000000000..2a53e10856
--- /dev/null
+++ b/comm/mail/base/content/sync.js
@@ -0,0 +1,157 @@
+/* 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/. */
+
+/**
+ * AppMenu UI for Sync. This file is only loaded if NIGHTLY_BUILD is set.
+ */
+
+/* import-globals-from utilityOverlay.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ EnsureFxAccountsWebChannel:
+ "resource://gre/modules/FxAccountsWebChannel.sys.mjs",
+ FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
+ UIState: "resource://services-sync/UIState.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+});
+
+var gSync = {
+ handleEvent(event) {
+ if (event.type == "load") {
+ this.updateFxAPanel();
+ Services.obs.addObserver(this, UIState.ON_UPDATE);
+ window.addEventListener("unload", this, { once: true });
+ } else if (event.type == "unload") {
+ Services.obs.removeObserver(this, UIState.ON_UPDATE);
+ }
+ },
+
+ observe(subject, topic, data) {
+ this.updateFxAPanel();
+ },
+
+ /**
+ * Update the app menu items to match the current state.
+ */
+ updateFxAPanel() {
+ let state = UIState.get();
+ let isSignedIn = state.status == UIState.STATUS_SIGNED_IN;
+ document.getElementById("appmenu_signin").hidden = isSignedIn;
+ document.getElementById("appmenu_sync").hidden = !isSignedIn;
+ document.getElementById("syncSeparator").hidden = false;
+ document.querySelectorAll(".appmenu-sync-account-email").forEach(el => {
+ el.value = state.email;
+ el.removeAttribute("data-l10n-id");
+ });
+ let button = document.getElementById("appmenu-submenu-sync-now");
+ if (button) {
+ if (state.syncing) {
+ button.setAttribute("syncstatus", "active");
+ } else {
+ button.removeAttribute("syncstatus");
+ }
+ }
+ },
+
+ /**
+ * Opens the FxA log-in page in a tab.
+ *
+ * @param {string = ""} entryPoint
+ */
+ async initFxA() {
+ EnsureFxAccountsWebChannel();
+ let url = await FxAccounts.config.promiseConnectAccountURI("");
+ openContentTab(url);
+ },
+
+ /**
+ * Opens the FxA account management page in a tab.
+ *
+ * @param {string = ""} entryPoint
+ */
+ async openFxAManagePage(entryPoint = "") {
+ EnsureFxAccountsWebChannel();
+ const url = await FxAccounts.config.promiseManageURI(entryPoint);
+ openContentTab(url);
+ },
+
+ /**
+ * Opens the FxA avatar management page in a tab.
+ *
+ * @param {string = ""} entryPoint
+ */
+ async openFxAAvatarPage(entryPoint = "") {
+ EnsureFxAccountsWebChannel();
+ const url = await FxAccounts.config.promiseChangeAvatarURI(entryPoint);
+ openContentTab(url);
+ },
+
+ /**
+ * Disconnect from sync, and optionally disconnect from the FxA account.
+ *
+ * @param {boolean} confirm - Should the user be asked to confirm the
+ * disconnection?
+ * @param {boolean} disconnectAccount - If true, disconnect from FxA as well
+ * as Sync. If false, just disconnect from Sync.
+ * @returns {boolean} - true if the disconnection happened (ie, if the user
+ * didn't decline when asked to confirm)
+ */
+ async disconnect({ confirm = false, disconnectAccount = true }) {
+ if (confirm) {
+ let title, body, button;
+ if (disconnectAccount) {
+ [title, body, button] = await document.l10n.formatValues([
+ "fxa-signout-dialog-title",
+ "fxa-signout-dialog-body",
+ "fxa-signout-dialog-button",
+ ]);
+ } else {
+ [title, body, button] = await document.l10n.formatValues([
+ "sync-disconnect-dialog-title",
+ "sync-disconnect-dialog-body",
+ "sync-disconnect-dialog-button",
+ ]);
+ }
+
+ let flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
+
+ // buttonPressed will be 0 for disconnect, 1 for cancel.
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ title,
+ body,
+ flags,
+ button,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (buttonPressed != 0) {
+ return false;
+ }
+ }
+
+ let fxAccounts = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+
+ if (disconnectAccount) {
+ const { SyncDisconnect } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncDisconnect.sys.mjs"
+ );
+ await fxAccounts.telemetry.recordDisconnection(null, "ui");
+ await SyncDisconnect.disconnect(false);
+ return true;
+ }
+
+ await fxAccounts.telemetry.recordDisconnection("sync", "ui");
+ await Weave.Service.promiseInitialized;
+ await Weave.Service.startOver();
+ return true;
+ },
+};
+window.addEventListener("load", gSync, { once: true });
diff --git a/comm/mail/base/content/systemIntegrationDialog.js b/comm/mail/base/content/systemIntegrationDialog.js
new file mode 100644
index 0000000000..41455b3db5
--- /dev/null
+++ b/comm/mail/base/content/systemIntegrationDialog.js
@@ -0,0 +1,188 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 dialog can only be opened if we have a shell service.
+
+var { SearchIntegration } = ChromeUtils.import(
+ "resource:///modules/SearchIntegration.jsm"
+);
+
+var gSystemIntegrationDialog = {
+ _shellSvc: Cc["@mozilla.org/mail/shell-service;1"].getService(
+ Ci.nsIShellService
+ ),
+
+ _mailCheckbox: null,
+
+ _newsCheckbox: null,
+
+ _rssCheckbox: null,
+
+ _startupCheckbox: null,
+
+ _searchCheckbox: null,
+
+ onLoad() {
+ // initialize elements
+ this._mailCheckbox = document.getElementById("checkMail");
+ this._newsCheckbox = document.getElementById("checkNews");
+ this._rssCheckbox = document.getElementById("checkRSS");
+ this._calendarCheckbox = document.getElementById("checkCalendar");
+ this._startupCheckbox = document.getElementById("checkOnStartup");
+ this._searchCheckbox = document.getElementById("searchIntegration");
+
+ // Initialize the check boxes based on the default app states.
+ this._mailCheckbox.disabled = this._shellSvc.isDefaultClient(
+ false,
+ this._shellSvc.MAIL
+ );
+
+ let calledFromPrefs =
+ "arguments" in window && window.arguments[0] == "calledFromPrefs";
+
+ if (!calledFromPrefs) {
+ // As an optimization, if we aren't already the default mail client,
+ // then pre-check that option for the user. We'll leave News and RSS alone.
+ // Do this only if we are not called from the Preferences (Options) dialog.
+ // In that case, the user may want to just check what the current state is.
+ this._mailCheckbox.checked = true;
+ } else {
+ this._mailCheckbox.checked = this._mailCheckbox.disabled;
+
+ // If called from preferences, use only a simpler "Cancel" label on the
+ // cancel button.
+ document.querySelector("dialog").getButton("cancel").label = document
+ .querySelector("dialog")
+ .getAttribute("buttonlabelcancel2");
+ }
+
+ if (!this._mailCheckbox.disabled) {
+ this._mailCheckbox.removeAttribute("tooltiptext");
+ }
+
+ this._newsCheckbox.checked = this._newsCheckbox.disabled =
+ this._shellSvc.isDefaultClient(false, this._shellSvc.NEWS);
+ if (!this._newsCheckbox.disabled) {
+ this._newsCheckbox.removeAttribute("tooltiptext");
+ }
+
+ this._rssCheckbox.checked = this._rssCheckbox.disabled =
+ this._shellSvc.isDefaultClient(false, this._shellSvc.RSS);
+ if (!this._rssCheckbox.disabled) {
+ this._rssCheckbox.removeAttribute("tooltiptext");
+ }
+
+ this._calendarCheckbox.checked = this._calendarCheckbox.disabled =
+ this._shellSvc.isDefaultClient(false, this._shellSvc.CALENDAR);
+
+ // read the raw pref value and not shellSvc.shouldCheckDefaultMail
+ this._startupCheckbox.checked = Services.prefs.getBoolPref(
+ "mail.shell.checkDefaultClient"
+ );
+
+ // Search integration - check whether we should show/disable integration options
+ if (SearchIntegration) {
+ this._searchCheckbox.checked = SearchIntegration.prefEnabled;
+ // On Windows, do not offer the option on startup as it does not perform well.
+ if (
+ Services.appinfo.OS == "WINNT" &&
+ !calledFromPrefs &&
+ !this._searchCheckbox.checked
+ ) {
+ this._searchCheckbox.hidden = true;
+ // Even if the user wasn't presented the choice,
+ // we do not want to ask again automatically.
+ SearchIntegration.firstRunDone = true;
+ } else if (!SearchIntegration.osVersionTooLow) {
+ // Hide/disable the options if the OS does not support them.
+ this._searchCheckbox.hidden = false;
+ if (SearchIntegration.osComponentsNotRunning) {
+ this._searchCheckbox.checked = false;
+ this._searchCheckbox.disabled = true;
+ }
+ }
+ }
+ },
+
+ /**
+ * Called when the dialog is closed by any button.
+ *
+ * @param aSetAsDefault If true, set TB as the default application for the
+ * checked actions (mail/news/rss). Otherwise do nothing.
+ */
+ onDialogClose(aSetAsDefault) {
+ // In all cases, save the user's decision for "always check at startup".
+ this._shellSvc.shouldCheckDefaultClient = this._startupCheckbox.checked;
+
+ // If the search checkbox is exposed, the user had the chance to make his choice.
+ // So do not ask next time.
+ let searchIntegPossible = !this._searchCheckbox.hidden;
+ if (searchIntegPossible) {
+ SearchIntegration.firstRunDone = true;
+ }
+
+ // If the "skip integration" button was used do not set any defaults
+ // and close the dialog.
+ if (!aSetAsDefault) {
+ // Disable search integration in this case.
+ if (searchIntegPossible) {
+ SearchIntegration.prefEnabled = false;
+ }
+
+ return true;
+ }
+
+ // For each checked item, if we aren't already the default client,
+ // make us the default.
+ let appTypes = 0;
+
+ if (
+ this._mailCheckbox.checked &&
+ !this._shellSvc.isDefaultClient(false, this._shellSvc.MAIL)
+ ) {
+ appTypes |= this._shellSvc.MAIL;
+ }
+
+ if (
+ this._newsCheckbox.checked &&
+ !this._shellSvc.isDefaultClient(false, this._shellSvc.NEWS)
+ ) {
+ appTypes |= this._shellSvc.NEWS;
+ }
+
+ if (
+ this._rssCheckbox.checked &&
+ !this._shellSvc.isDefaultClient(false, this._shellSvc.RSS)
+ ) {
+ appTypes |= this._shellSvc.RSS;
+ }
+
+ if (
+ this._calendarCheckbox.checked &&
+ !this._shellSvc.isDefaultClient(false, this._shellSvc.CALENDAR)
+ ) {
+ appTypes |= this._shellSvc.CALENDAR;
+ }
+
+ if (appTypes) {
+ this._shellSvc.setDefaultClient(false, appTypes);
+ }
+
+ // Set the search integration pref if it is changed.
+ // The integration will handle the rest.
+ if (searchIntegPossible) {
+ SearchIntegration.prefEnabled = this._searchCheckbox.checked;
+ }
+
+ return true;
+ },
+};
+
+document.addEventListener("dialogaccept", () =>
+ gSystemIntegrationDialog.onDialogClose(true)
+);
+document.addEventListener("dialogcancel", () =>
+ gSystemIntegrationDialog.onDialogClose(false)
+);
diff --git a/comm/mail/base/content/systemIntegrationDialog.xhtml b/comm/mail/base/content/systemIntegrationDialog.xhtml
new file mode 100644
index 0000000000..6ce932aed5
--- /dev/null
+++ b/comm/mail/base/content/systemIntegrationDialog.xhtml
@@ -0,0 +1,53 @@
+<?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"?>
+
+<!DOCTYPE window>
+
+<window
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="gSystemIntegrationDialog.onLoad();"
+ data-l10n-id="system-integration-title"
+ style="min-width: 33em"
+>
+ <dialog
+ id="systemIntegrationDialog"
+ buttons="accept,cancel"
+ data-l10n-id="system-integration-dialog"
+ data-l10n-attrs="buttonlabelaccept, buttonlabelcancel, buttonlabelcancel2"
+ >
+ <script src="chrome://messenger/content/systemIntegrationDialog.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/system-integration.ftl"
+ />
+ </linkset>
+
+ <label control="defaultClientList" data-l10n-id="default-client-intro" />
+ <vbox id="defaultClientList" role="group">
+ <checkbox id="checkMail" data-l10n-id="checkbox-email-label" />
+ <checkbox id="checkNews" data-l10n-id="checkbox-newsgroups-label" />
+ <checkbox id="checkRSS" data-l10n-id="checkbox-feeds-label" />
+ <checkbox id="checkCalendar" data-l10n-id="checkbox-calendar-label" />
+ </vbox>
+
+ <separator class="groove" />
+
+ <checkbox
+ id="searchIntegration"
+ hidden="true"
+ data-l10n-id="system-search-integration-label"
+ />
+
+ <separator class="thin" />
+
+ <checkbox id="checkOnStartup" data-l10n-id="check-on-startup-label" />
+ </dialog>
+</window>
diff --git a/comm/mail/base/content/tabDialogs.inc.xhtml b/comm/mail/base/content/tabDialogs.inc.xhtml
new file mode 100644
index 0000000000..956db429a4
--- /dev/null
+++ b/comm/mail/base/content/tabDialogs.inc.xhtml
@@ -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/.
+
+<html:template id="dialogStackTemplate">
+ <stack class="dialogStack tab-dialog-box" hidden="true">
+ <vbox class="dialogTemplate dialogOverlay" align="center" topmost="true" hidden="true">
+ <hbox class="dialogBox">
+ <browser class="dialogFrame"
+ autoscroll="false"
+ disablehistory="true"/>
+ </hbox>
+ </vbox>
+ </stack>
+</html:template>
+
+<html:template id="printPreviewStackTemplate">
+ <stack class="previewStack" rendering="true" flex="1" previewtype="primary">
+ <vbox class="previewRendering" flex="1">
+ <h1 class="print-pending-label" data-l10n-id="printui-loading"></h1>
+ </vbox>
+ </stack>
+</html:template>
diff --git a/comm/mail/base/content/tabmail.js b/comm/mail/base/content/tabmail.js
new file mode 100644
index 0000000000..c2ab652ef9
--- /dev/null
+++ b/comm/mail/base/content/tabmail.js
@@ -0,0 +1,2048 @@
+/* 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"; // from mailWindow.js
+
+/* global MozElements, MozXULElement */
+
+/* import-globals-from mailCore.js */
+/* globals contentProgress, statusFeedback */
+
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozTabmailAlltabsMenuPopup widget is used as a menupopup to list all the
+ * currently opened tabs.
+ *
+ * @augments {MozElements.MozMenuPopup}
+ * @implements {EventListener}
+ */
+ class MozTabmailAlltabsMenuPopup extends MozElements.MozMenuPopup {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.tabmail = document.getElementById("tabmail");
+
+ this._mutationObserver = new MutationObserver((records, observer) => {
+ records.forEach(mutation => {
+ let menuItem = mutation.target.mCorrespondingMenuitem;
+ if (menuItem) {
+ this._setMenuitemAttributes(menuItem, mutation.target);
+ }
+ });
+ });
+
+ this.addEventListener("popupshowing", event => {
+ // Set up the menu popup.
+ let tabcontainer = this.tabmail.tabContainer;
+ let tabs = tabcontainer.allTabs;
+
+ // Listen for changes in the tab bar.
+ this._mutationObserver.observe(tabcontainer, {
+ attributes: true,
+ subtree: true,
+ attributeFilter: ["label", "crop", "busy", "image", "selected"],
+ });
+
+ this.tabmail.addEventListener("TabOpen", this);
+ tabcontainer.arrowScrollbox.addEventListener("scroll", this);
+
+ // If an animation is in progress and the user
+ // clicks on the "all tabs" button, stop the animation.
+ tabcontainer._stopAnimation();
+
+ for (let i = 0; i < tabs.length; i++) {
+ this._createTabMenuItem(tabs[i]);
+ }
+ this._updateTabsVisibilityStatus();
+ });
+
+ this.addEventListener("popuphiding", event => {
+ // Clear out the menu popup and remove the listeners.
+ while (this.hasChildNodes()) {
+ let menuItem = this.lastElementChild;
+ menuItem.removeEventListener("command", this);
+ menuItem.tab.removeEventListener("TabClose", this);
+ menuItem.tab.mCorrespondingMenuitem = null;
+ menuItem.remove();
+ }
+ this._mutationObserver.disconnect();
+
+ this.tabmail.tabContainer.arrowScrollbox.removeEventListener(
+ "scroll",
+ this
+ );
+ this.tabmail.removeEventListener("TabOpen", this);
+ });
+ }
+
+ _menuItemOnCommand(aEvent) {
+ this.tabmail.tabContainer.selectedItem = aEvent.target.tab;
+ }
+
+ _tabOnTabClose(aEvent) {
+ let menuItem = aEvent.target.mCorrespondingMenuitem;
+ if (menuItem) {
+ menuItem.remove();
+ }
+ }
+
+ handleEvent(aEvent) {
+ if (!aEvent.isTrusted) {
+ return;
+ }
+
+ switch (aEvent.type) {
+ case "command":
+ this._menuItemOnCommand(aEvent);
+ break;
+ case "TabClose":
+ this._tabOnTabClose(aEvent);
+ break;
+ case "TabOpen":
+ this._createTabMenuItem(aEvent.target);
+ break;
+ case "scroll":
+ this._updateTabsVisibilityStatus();
+ break;
+ }
+ }
+
+ _updateTabsVisibilityStatus() {
+ let tabStrip = this.tabmail.tabContainer.arrowScrollbox;
+ // We don't want menu item decoration unless there is overflow.
+ if (tabStrip.getAttribute("overflow") != "true") {
+ return;
+ }
+
+ let tabStripBox = tabStrip.getBoundingClientRect();
+
+ for (let i = 0; i < this.children.length; i++) {
+ let currentTabBox = this.children[i].tab.getBoundingClientRect();
+
+ if (
+ currentTabBox.left >= tabStripBox.left &&
+ currentTabBox.right <= tabStripBox.right
+ ) {
+ this.children[i].setAttribute("tabIsVisible", "true");
+ } else {
+ this.children[i].removeAttribute("tabIsVisible");
+ }
+ }
+ }
+
+ _createTabMenuItem(aTab) {
+ let menuItem = document.createXULElement("menuitem");
+
+ menuItem.setAttribute(
+ "class",
+ "menuitem-iconic alltabs-item menuitem-with-favicon"
+ );
+
+ this._setMenuitemAttributes(menuItem, aTab);
+
+ // Keep some attributes of the menuitem in sync with its
+ // corresponding tab (e.g. the tab label).
+ aTab.mCorrespondingMenuitem = menuItem;
+ aTab.addEventListener("TabClose", this);
+ menuItem.tab = aTab;
+ menuItem.addEventListener("command", this);
+
+ this.appendChild(menuItem);
+ return menuItem;
+ }
+
+ _setMenuitemAttributes(aMenuitem, aTab) {
+ aMenuitem.setAttribute("label", aTab.label);
+ aMenuitem.setAttribute("crop", "end");
+
+ if (aTab.hasAttribute("busy")) {
+ aMenuitem.setAttribute("busy", aTab.getAttribute("busy"));
+ aMenuitem.removeAttribute("image");
+ } else {
+ aMenuitem.setAttribute("image", aTab.getAttribute("image"));
+ aMenuitem.removeAttribute("busy");
+ }
+
+ // Change the tab icon accordingly.
+ let style = window.getComputedStyle(aTab);
+ aMenuitem.style.listStyleImage = style.listStyleImage;
+ aMenuitem.style.MozImageRegion = style.MozImageRegion;
+
+ if (aTab.hasAttribute("pending")) {
+ aMenuitem.setAttribute("pending", aTab.getAttribute("pending"));
+ } else {
+ aMenuitem.removeAttribute("pending");
+ }
+
+ if (aTab.selected) {
+ aMenuitem.setAttribute("selected", "true");
+ } else {
+ aMenuitem.removeAttribute("selected");
+ }
+ }
+ }
+
+ customElements.define(
+ "tabmail-alltabs-menupopup",
+ MozTabmailAlltabsMenuPopup,
+ { extends: "menupopup" }
+ );
+
+ /**
+ * Thunderbird's tab UI mechanism.
+ *
+ * We expect to be instantiated with the following children:
+ * One "tabpanels" child element whose id must be placed in the
+ * "panelcontainer" attribute on the element we are being bound to. We do
+ * this because it is important to allow overlays to contribute panels.
+ * When we attempted to have the immediate children of the bound element
+ * be propagated through use of the "children" tag, we found that children
+ * contributed by overlays did not propagate.
+ * Any children you want added to the right side of the tab bar. This is
+ * primarily intended to allow for "open a BLANK tab" buttons, namely
+ * calendar and tasks. For reasons similar to the tabpanels case, we
+ * expect the instantiating element to provide a child hbox for overlays
+ * to contribute buttons to.
+ *
+ * From a javascript perspective, there are three types of code that we
+ * expect to interact with:
+ * 1) Code that wants to open new tabs.
+ * 2) Code that wants to contribute one or more varieties of tabs.
+ * 3) Code that wants to monitor to know when the active tab changes.
+ *
+ * Consumer code should use the following methods:
+ * openTab(aTabModeName, aArgs)
+ * Open a tab of the given "mode", passing the provided arguments as an
+ * object. The tab type author should tell you the modes they implement
+ * and the required/optional arguments.
+ *
+ * Each tab type can define the set of arguments that it expects, but
+ * there are also a few common ones that all should obey, including:
+ *
+ * "background": if this is true, the tab will be loaded in the
+ * background.
+ * "disregardOpener": if this is true, then the tab opener will not
+ * be switched to automatically by tabmail if the new tab is immediately
+ * closed.
+ *
+ * closeTab(aOptionalTabIndexInfoOrTabNode, aNoUndo):
+ * If no argument is provided, the current tab is closed. The first
+ * argument specifies a specific tab to be closed. It can be a tab index,
+ * a tab info object, or a tab's DOM element. In case the second
+ * argument is true, the closed tab can't be restored by calling
+ * undoCloseTab().
+ * Please note, some tabs cannot be closed. Trying to close such tab,
+ * will fail silently.
+ * undoCloseTab():
+ * Restores the most recent tab closed by the user.
+ * switchToTab(aTabIndexInfoOrTabNode):
+ * Switch to the tab by providing a tab index, tab info object, or tab
+ * node (tabmail-tab bound element.) Instead of calling this method,
+ * you can also just poke at tabmail.tabContainer and its selectedIndex
+ * and selectedItem properties.
+ * replaceTabWithWindow(aTab):
+ * Detaches a tab from this tabbar to new window. The argument "aTab" is
+ * required and can be a tab index, a tab info object or a tabs's
+ * DOM element. Calling this method works only for tabs implementing
+ * session restore.
+ * moveTabTo(aTab, aIndex):
+ * moves the given tab to the given Index. The first argument can be
+ * a tab index, a tab info object or a tab's DOM element. The second
+ * argument specifies the tabs new absolute position within the tabbar.
+ *
+ * Less-friendly consumer methods:
+ * * persistTab(tab):
+ * serializes a tab into an object, by passing a tab info object as
+ * argument. It is used for session restore and moving tabs between
+ * windows. Returns null in case persist fails.
+ * * removeCurrentTab():
+ * Close the current tab.
+ * * removeTabByNode(aTabElement):
+ * Close the tab whose tabmail-tab bound element is passed in.
+ * Changing the currently displayed tab is accomplished by changing
+ * tabmail.tabContainer's selectedIndex or selectedItem property.
+ *
+ * Code that lives in a tab should use the following methods:
+ * * setTabTitle([aOptionalTabInfo]): Tells us that the title of the current
+ * tab (if no argument is provided) or provided tab needs to be updated.
+ * This will result in a call to the tab mode's logic to update the title.
+ * In the event this is not for the current tab, the caller is responsible
+ * for ensuring that the underlying tab mode is capable of providing a tab
+ * title when it is in the background. (The is currently not the case for
+ * "folder" and "mail" modes because of their implementation.)
+ * * setTabBusy(aTabNode, aBusyState): Tells us that the tab in question
+ * is now busy or not busy. "Busy" means that it is occupied and
+ * will not be able to respond to you until it is no longer busy.
+ * This impacts the cursor display, as well as potentially
+ * providing tab display hints.
+ * * setTabThinking(aTabNode, aThinkingState): Tells us that the
+ * tab in question is now thinking or not thinking. "Thinking" means
+ * that the tab is involved in some ongoing process but you can still
+ * interact with the tab while it is thinking. A search would be an
+ * example of thinking. This impacts spinny-thing feedback as well as
+ * potential providing tab display hints. aThinkingState may be a
+ * boolean or a localized string explaining what you are thinking about.
+ *
+ * Tab contributing code should define a tab type object and register it
+ * with us by calling registerTabType. You can remove a registered tab
+ * type (eg when unloading a restartless addon) by calling unregisterTabType.
+ * Each tab type can provide multiple tab modes. The rationale behind this
+ * organization is that Thunderbird historically/currently uses a single
+ * 3-pane view to display both three-pane folder browsing and single message
+ * browsing across multiple tabs. Each tab type has the ability to use a
+ * single tab panel for all of its display needs. So Thunderbird's "mail"
+ * tab type covers both the "folder" (3-pane folder-based browsing) and
+ * "message" (just a single message) tab modes. Likewise, calendar/lightning
+ * currently displays both its calendar and tasks in the same panel. A tab
+ * type can also create a new tabpanel for each tab as it is created. In
+ * that case, the tab type should probably only have a single mode unless
+ * there are a number of similar modes that can gain from code sharing.
+ *
+ * If you're adding a new tab type, please update TabmailTab.type in
+ * mail/components/extensions/parent/ext-mail.js.
+ *
+ * The tab type definition should include the following attributes:
+ * * name: The name of the tab-type, mainly to aid in debugging.
+ * * panelId or perTabPanel: If using a single tab panel, the id of the
+ * panel must be provided in panelId. If using one tab panel per tab,
+ * perTabPanel should be either the XUL element name that should be
+ * created for each tab, or a helper function to create and return the
+ * element.
+ * * modes: An object whose attributes are mode names (which are
+ * automatically propagated to a 'name' attribute for debugging) and
+ * values are objects with the following attributes...
+ * * any of the openTab/closeTab/saveTabState/showTab/onTitleChanged
+ * functions as described on the mode definitions. These will only be
+ * called if the mode does not provide the functions. Note that because
+ * the 'this' variable passed to the functions will always reference the
+ * tab type definition (rather than the mode definition), the mode
+ * functions can defer to the tab type functions by calling
+ * this.functionName(). (This should prove convenient.)
+ * Mode definition attributes:
+ * * type: The "type" attribute to set on the displayed tab for CSS purposes.
+ * Generally, this would be the same as the mode name, but you can do as
+ * you please.
+ * * isDefault: This should only be present and should be true for the tab
+ * mode that is the tab displayed automatically on startup.
+ * * maxTabs: The maximum number of this mode that can be opened at a time.
+ * If this limit is reached, any additional calls to openTab for this
+ * mode will simply result in the first existing tab of this mode being
+ * displayed.
+ * * shouldSwitchTo(aArgs): Optional function. Called when openTab is called
+ * on the top-level tabmail binding. It is used to decide if the openTab
+ * function should switch to an existing tab or actually open a new tab.
+ * If the openTab function should switch to an existing tab, return the
+ * index of that tab; otherwise return -1.
+ * aArgs is a set of named parameters (the ones that are later passed to
+ * openTab).
+ * * openTab(aTab, aArgs): Called when a tab of the given mode is in the
+ * process of being opened. aTab will have its "mode" attribute
+ * set to the mode definition of the tab mode being opened. You should
+ * set the "title" attribute on it, and may set any other attributes
+ * you wish for your own use in subsequent functions. Note that 'this'
+ * points to the tab type definition, not the mode definition as you
+ * might expect. This allows you to place common logic code on the
+ * tab type for use by multiple modes and to defer to it. Any arguments
+ * provided to the caller of tabmail.openTab will be passed to your
+ * function as well, including background.
+ * * closeTab(aTab): Called when aTab is being closed. The tab need not be
+ * currently displayed. You are responsible for properly cleaning up
+ * any state you preserved in aTab.
+ * * saveTabState(aTab): Called when aTab is being switched away from so that
+ * you can preserve its state on aTab. This is primarily for single
+ * tab panel implementations; you may not have much state to save if your
+ * tab has its own tab panel.
+ * * showTab(aTab): Called when aTab is being displayed and you should
+ * restore its state (if required).
+ * * persistTab(aTab): Called when we want to persist the tab because we are
+ * saving the session state. You should return an object suitable for
+ * JSON serialization. The object will be provided to your restoreTab
+ * method when we attempt to restore the session. If your code is
+ * unable or unwilling to persist the tab (some of the time), you should
+ * return null in that case. If your code never wants to persist the tab
+ * you should not implement this method. You must implement restoreTab
+ * if you implement this method.
+ * * restoreTab(aTabmail, aPersistedState): Called when we are restoring a
+ * tab session and a tab with your mode was previously persisted via a
+ * call to your persistTab implementation. You are provided with a
+ * reference to this tabmail instance and the (deserialized) state object
+ * you returned from your persistTab implementation. It is your
+ * function's job to determine if you can restore the tab, and if so,
+ * you should invoke aTabmail.openTab to actually cause your tab to be
+ * opened. This may seem odd, but it should help keep your code simple
+ * while letting you do whatever you want. Since openTab is synchronous
+ * and returns the tabInfo structure built for the tab, you can perform
+ * any additional work you need after the call to openTab.
+ * * onTitleChanged(aTab): Called when someone calls tabmail.setTabTitle() to
+ * hint that the tab's title needs to be updated. This function should
+ * update aTab.title if it can.
+ * Mode definition functions to do with menu/toolbar commands:
+ * * supportsCommand(aCommand, aTab): Called when a menu or toolbar needs to
+ * be updated. Return true if you support that command in
+ * isCommandEnabled and doCommand, return false otherwise.
+ * * isCommandEnabled(aCommand, aTab): Called when a menu or toolbar needs
+ * to be updated. Return true if the command can be executed at the
+ * current time, false otherwise.
+ * * doCommand(aCommand, aTab): Called when a menu or toolbar command is to
+ * be executed. Perform the action appropriate to the command.
+ * * onEvent(aEvent, aTab): This can be used to handle different events on
+ * the window.
+ * * getBrowser(aTab): This function should return the browser element for
+ * your tab if there is one (return null or don't define this function
+ * otherwise). It is used for some toolkit functions that require a
+ * global "getBrowser" function, e.g. ZoomManager.
+ *
+ * Tab monitoring code is expected to be used for widgets on the screen
+ * outside of the tab box that need to update themselves as the active tab
+ * changes.
+ * Tab monitoring code (un)registers itself via (un)registerTabMonitor.
+ * The following attributes should be provided on the monitor object:
+ * * monitorName: A string value naming the tab monitor/extension. This is
+ * the canonical name for the tab monitor for all persistence purposes.
+ * If the tab monitor wants to store data in the tab info object and its
+ * name is FOO it should store it in 'tabInfo._ext.FOO'. This is the
+ * only place the tab monitor should store information on the tab info
+ * object. The FOO attribute will not be automatically created; it is
+ * up to the code. The _ext attribute will be there, reliably, however.
+ * The name is also used when persisting state, but the tab monitor
+ * does not need to do anything in that case; the name is automatically
+ * used in the course of wrapping the object.
+ * The following functions should be provided on the monitor object:
+ * * onTabTitleChanged(aTab): Called when the tab's title changes.
+ * * onTabSwitched(aTab, aOldTab): Called when a new tab is made active.
+ * Also called when the monitor is registered if one or more tabs exist.
+ * If this is the first call, aOldTab will be null, otherwise aOldTab
+ * will be the previously active tab.
+ * * onTabOpened(aTab, aIsFirstTab, aWasCurrentTab): Called when a new tab is
+ * opened. This method is invoked after the tab mode's openTab method
+ * is invoked. This method is invoked before the tab monitor
+ * onTabSwitched method in the case where it will be invoked. (It is
+ * not invoked if the tab is opened in the background.)
+ * * onTabClosing(aTab): Called when a tab is being closed. This method is
+ * is invoked before the call to the tab mode's closeTab function.
+ * * onTabPersist(aTab): Return a JSON-representable object to persist for
+ * the tab. Return null if you do not have anything to persist.
+ * * onTabRestored(aTab, aState, aIsFirstTab): Called when a tab is being
+ * restored and there is data previously persisted by the tab monitor.
+ * This method is called instead of invoking onTabOpened. This is done
+ * because the restoreTab method (potentially) uses the tabmail openTab
+ * API to effect restoration. (Note: the first opened tab is special;
+ * it will produce an onTabOpened notification potentially followed by
+ * an onTabRestored notification.)
+ * Tab monitor code is also allowed to hook into the command processing
+ * logic. We support the standard supportsCommand/isCommandEnabled/
+ * doCommand functions but with a twist to indicate when other tab monitors
+ * and the actual tab itself should get a chance to process: supportsCommand
+ * and isCommandEnabled should return null when they are not handling the
+ * case. doCommand should return true if it handled the case, null
+ * otherwise.
+ */
+
+ /**
+ * The MozTabmail widget handles the Tab UI mechanism.
+ *
+ * @augments {MozXULElement}
+ */
+ class MozTabmail extends MozXULElement {
+ /**
+ * Flag indicating that the UI is currently covered by an overlay.
+ *
+ * @type {boolean}
+ */
+ globalOverlay = false;
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.tabbox = this.getElementsByTagName("tabbox").item(0);
+ this.currentTabInfo = null;
+
+ /**
+ * Temporary field that only has a non-null value during a call to
+ * openTab, and whose value is the currentTabInfo of the tab that was
+ * open when we received the call to openTab.
+ */
+ this._mostRecentTabInfo = null;
+ /**
+ * Tab id, incremented on each openTab() and set on the browser.
+ */
+ this.tabId = 0;
+ this.tabTypes = {};
+ this.tabModes = {};
+ this.defaultTabMode = null;
+ this.tabInfo = [];
+ this.tabContainer = document.getElementById(
+ this.getAttribute("tabcontainer")
+ );
+ this.panelContainer = document.getElementById(
+ this.getAttribute("panelcontainer")
+ );
+ this.tabMonitors = [];
+ this.recentlyClosedTabs = [];
+ this.mLastTabOpener = null;
+ this.unrestoredTabs = [];
+
+ // @implements {nsIController}
+ this.tabController = {
+ supportsCommand: aCommand => {
+ let tab = this.currentTabInfo;
+ // This can happen if we're starting up and haven't got a tab
+ // loaded yet.
+ if (!tab) {
+ return false;
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("supportsCommand" in tabMonitor) {
+ let result = tabMonitor.supportsCommand(aCommand, tab);
+ if (result !== null) {
+ return result;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ let supportsCommandFunc =
+ tab.mode.supportsCommand || tab.mode.tabType.supportsCommand;
+ if (supportsCommandFunc) {
+ return supportsCommandFunc.call(tab.mode.tabType, aCommand, tab);
+ }
+
+ return false;
+ },
+
+ isCommandEnabled: aCommand => {
+ let tab = this.currentTabInfo;
+ // This can happen if we're starting up and haven't got a tab
+ // loaded yet.
+ if (!tab || this.globalOverlay) {
+ return false;
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("isCommandEnabled" in tabMonitor) {
+ let result = tabMonitor.isCommandEnabled(aCommand, tab);
+ if (result !== null) {
+ return result;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ let isCommandEnabledFunc =
+ tab.mode.isCommandEnabled || tab.mode.tabType.isCommandEnabled;
+ if (isCommandEnabledFunc) {
+ return isCommandEnabledFunc.call(tab.mode.tabType, aCommand, tab);
+ }
+
+ return false;
+ },
+
+ doCommand: (aCommand, ...args) => {
+ let tab = this.currentTabInfo;
+ // This can happen if we're starting up and haven't got a tab
+ // loaded yet.
+ if (!tab) {
+ return;
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("doCommand" in tabMonitor) {
+ let result = tabMonitor.doCommand(aCommand, tab);
+ if (result === true) {
+ return;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ let doCommandFunc = tab.mode.doCommand || tab.mode.tabType.doCommand;
+ if (doCommandFunc) {
+ doCommandFunc.call(tab.mode.tabType, aCommand, tab, ...args);
+ }
+ },
+
+ onEvent: aEvent => {
+ let tab = this.currentTabInfo;
+ // This can happen if we're starting up and haven't got a tab
+ // loaded yet.
+ if (!tab) {
+ return null;
+ }
+
+ let onEventFunc = tab.mode.onEvent || tab.mode.tabType.onEvent;
+ if (onEventFunc) {
+ return onEventFunc.call(tab.mode.tabType, aEvent, tab);
+ }
+
+ return false;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIController"]),
+ };
+
+ // This is the second-highest priority controller. It's preceded by
+ // DefaultController and followed by calendarController, then whatever
+ // Gecko adds.
+ window.controllers.insertControllerAt(1, this.tabController);
+ this._restoringTabState = null;
+ }
+
+ set selectedTab(val) {
+ this.switchToTab(val);
+ }
+
+ get selectedTab() {
+ if (!this.currentTabInfo) {
+ this.currentTabInfo = this.tabInfo[0];
+ }
+
+ return this.currentTabInfo;
+ }
+
+ get tabs() {
+ return this.tabContainer.allTabs;
+ }
+
+ get selectedBrowser() {
+ return this.getBrowserForSelectedTab();
+ }
+
+ registerTabType(aTabType) {
+ if (aTabType.name in this.tabTypes) {
+ return;
+ }
+
+ this.tabTypes[aTabType.name] = aTabType;
+ for (let [modeName, modeDetails] of Object.entries(aTabType.modes)) {
+ modeDetails.name = modeName;
+ modeDetails.tabType = aTabType;
+ modeDetails.tabs = [];
+ this.tabModes[modeName] = modeDetails;
+ if (modeDetails.isDefault) {
+ this.defaultTabMode = modeDetails;
+ }
+ }
+
+ if (aTabType.panelId) {
+ aTabType.panel = document.getElementById(aTabType.panelId);
+ } else if (!aTabType.perTabPanel) {
+ throw new Error(
+ "Trying to register a tab type with neither panelId " +
+ "nor perTabPanel attributes."
+ );
+ }
+
+ setTimeout(() => {
+ for (let modeName of Object.keys(aTabType.modes)) {
+ let i = 0;
+ while (i < this.unrestoredTabs.length) {
+ let state = this.unrestoredTabs[i];
+ if (state.mode == modeName) {
+ this.restoreTab(state);
+ this.unrestoredTabs.splice(i, 1);
+ } else {
+ i++;
+ }
+ }
+ }
+ }, 0);
+ }
+
+ unregisterTabType(aTabType) {
+ // we can skip if the tab type was never registered...
+ if (!(aTabType.name in this.tabTypes)) {
+ return;
+ }
+
+ // ... if the tab type is still in use, we can not remove it without
+ // breaking the UI. So we throw an exception.
+ for (let modeName of Object.keys(aTabType.modes)) {
+ if (this.tabModes[modeName].tabs.length) {
+ throw new Error("Tab mode " + modeName + " still in use. Close tabs");
+ }
+ }
+ // ... finally get rid of the tab type
+ for (let modeName of Object.keys(aTabType.modes)) {
+ delete this.tabModes[modeName];
+ }
+
+ delete this.tabTypes[aTabType.name];
+ }
+
+ registerTabMonitor(aTabMonitor) {
+ if (!this.tabMonitors.includes(aTabMonitor)) {
+ this.tabMonitors.push(aTabMonitor);
+ if (this.tabInfo.length) {
+ aTabMonitor.onTabSwitched(this.currentTabInfo, null);
+ }
+ }
+ }
+
+ unregisterTabMonitor(aTabMonitor) {
+ if (this.tabMonitors.includes(aTabMonitor)) {
+ this.tabMonitors.splice(this.tabMonitors.indexOf(aTabMonitor), 1);
+ }
+ }
+
+ /**
+ * Given an index, tab node or tab info object, return a tuple of
+ * [iTab, tab info dictionary, tab DOM node]. If
+ * aTabIndexNodeOrInfo is not specified and aDefaultToCurrent is
+ * true, the current tab will be returned. Otherwise, an
+ * exception will be thrown.
+ */
+ _getTabContextForTabbyThing(aTabIndexNodeOrInfo, aDefaultToCurrent) {
+ let iTab;
+ let tab;
+ let tabNode;
+ if (aTabIndexNodeOrInfo == null) {
+ if (!aDefaultToCurrent) {
+ throw new Error("You need to specify a tab!");
+ }
+ iTab = this.tabContainer.selectedIndex;
+ return [iTab, this.tabInfo[iTab], this.tabContainer.allTabs[iTab]];
+ }
+ if (typeof aTabIndexNodeOrInfo == "number") {
+ iTab = aTabIndexNodeOrInfo;
+ tabNode = this.tabContainer.allTabs[iTab];
+ tab = this.tabInfo[iTab];
+ } else if (
+ aTabIndexNodeOrInfo.tagName &&
+ aTabIndexNodeOrInfo.tagName == "tab"
+ ) {
+ tabNode = aTabIndexNodeOrInfo;
+ iTab = this.tabContainer.getIndexOfItem(tabNode);
+ tab = this.tabInfo[iTab];
+ } else {
+ tab = aTabIndexNodeOrInfo;
+ iTab = this.tabInfo.indexOf(tab);
+ tabNode = iTab >= 0 ? this.tabContainer.allTabs[iTab] : null;
+ }
+ return [iTab, tab, tabNode];
+ }
+
+ openFirstTab() {
+ // From the moment of creation, our customElement already has a visible
+ // tab. We need to create a tab information structure for this tab.
+ // In the process we also generate a synthetic tab title changed
+ // event to ensure we have an accurate title. We assume the tab
+ // contents will set themselves up correctly.
+ if (this.tabInfo.length == 0) {
+ let tab = this.openTab("mail3PaneTab", { first: true });
+ this.tabs[0].linkedPanel = tab.panel.id;
+ }
+ }
+
+ // eslint-disable-next-line complexity
+ openTab(aTabModeName, aArgs = {}) {
+ try {
+ if (!(aTabModeName in this.tabModes)) {
+ throw new Error("No such tab mode: " + aTabModeName);
+ }
+
+ let tabMode = this.tabModes[aTabModeName];
+ // if we are already at our limit for this mode, show an existing one
+ if (tabMode.tabs.length == tabMode.maxTabs) {
+ let desiredTab = tabMode.tabs[0];
+ this.tabContainer.selectedIndex = this.tabInfo.indexOf(desiredTab);
+ return null;
+ }
+
+ // Do this so that we don't generate strict warnings
+ let background = aArgs.background;
+ // If the mode wants us to, we should switch to an existing tab
+ // rather than open a new one. We shouldn't switch to the tab if
+ // we're opening it in the background, though.
+ let shouldSwitchToFunc =
+ tabMode.shouldSwitchTo || tabMode.tabType.shouldSwitchTo;
+ if (shouldSwitchToFunc) {
+ let tabIndex = shouldSwitchToFunc.apply(tabMode.tabType, [aArgs]);
+ if (tabIndex >= 0) {
+ if (!background) {
+ this.selectTabByIndex(null, tabIndex);
+ }
+ return this.tabInfo[tabIndex];
+ }
+ }
+
+ if (!aArgs.first && !background) {
+ // we need to save the state before it gets corrupted
+ this.saveCurrentTabState();
+ }
+
+ let tab = {
+ first: !!aArgs.first,
+ mode: tabMode,
+ busy: false,
+ canClose: true,
+ thinking: false,
+ beforeTabOpen: true,
+ favIconUrl: null,
+ _ext: {},
+ };
+
+ tab.tabId = this.tabId++;
+ tabMode.tabs.push(tab);
+
+ let t;
+ if (aArgs.first) {
+ t = this.tabContainer.querySelector(`tab[is="tabmail-tab"]`);
+ } else {
+ t = document.createXULElement("tab", { is: "tabmail-tab" });
+ t.className = "tabmail-tab";
+ t.setAttribute("validate", "never");
+ this.tabContainer.appendChild(t);
+ }
+ tab.tabNode = t;
+
+ if (
+ this.tabContainer.mCollapseToolbar.collapsed &&
+ (!this.tabContainer.mAutoHide || this.tabContainer.allTabs.length > 1)
+ ) {
+ this.tabContainer.mCollapseToolbar.collapsed = false;
+ this.tabContainer._updateCloseButtons();
+ document.documentElement.removeAttribute("tabbarhidden");
+ }
+
+ let oldTab = (this._mostRecentTabInfo = this.currentTabInfo);
+ // If we're not disregarding the opening, hold a reference to opener
+ // so that if the new tab is closed without switching, we can switch
+ // back to the opener tab.
+ if (aArgs.disregardOpener) {
+ this.mLastTabOpener = null;
+ } else {
+ this.mLastTabOpener = oldTab;
+ }
+
+ // the order of the following statements is important
+ this.tabInfo[this.tabContainer.allTabs.length - 1] = tab;
+ if (!background) {
+ this.currentTabInfo = tab;
+ // this has a side effect of calling updateCurrentTab, but our
+ // setting currentTabInfo above will cause it to take no action.
+ this.tabContainer.selectedIndex =
+ this.tabContainer.allTabs.length - 1;
+ }
+
+ // make sure we are on the right panel
+ if (tab.mode.tabType.perTabPanel) {
+ // should we create the element for them, or will they do it?
+ if (typeof tab.mode.tabType.perTabPanel == "string") {
+ tab.panel = document.createXULElement(tab.mode.tabType.perTabPanel);
+ } else {
+ tab.panel = tab.mode.tabType.perTabPanel(tab);
+ }
+
+ this.panelContainer.appendChild(tab.panel);
+
+ if (!background) {
+ this.panelContainer.selectedPanel = tab.panel;
+ }
+ } else {
+ if (!background) {
+ this.panelContainer.selectedPanel = tab.mode.tabType.panel;
+ }
+ t.linkedPanel = tab.mode.tabType.panelId;
+ }
+
+ // Make sure the new panel is marked selected.
+ let oldPanel = [...this.panelContainer.children].find(p =>
+ p.hasAttribute("selected")
+ );
+ // Blur the currently focused element only if we're actually switching
+ // to the newly opened tab.
+ if (oldPanel && !background) {
+ this.rememberLastActiveElement(oldTab);
+ oldPanel.removeAttribute("selected");
+ if (oldTab.chromeBrowser) {
+ oldTab.chromeBrowser.docShellIsActive = false;
+ }
+ }
+
+ this.panelContainer.selectedPanel.setAttribute("selected", "true");
+ let tabOpenFunc = tab.mode.openTab || tab.mode.tabType.openTab;
+ tabOpenFunc.apply(tab.mode.tabType, [tab, aArgs]);
+ if (tab.chromeBrowser) {
+ tab.chromeBrowser.docShellIsActive = !background;
+ }
+
+ if (!t.linkedPanel) {
+ if (!tab.panel.id) {
+ // No id set. Create our own.
+ tab.panel.id = "unnamedTab" + Math.random().toString().substring(2);
+ console.warn(`Tab mode ${aTabModeName} should set an id
+ on the first argument of openTab.`);
+ }
+ t.linkedPanel = tab.panel.id;
+ }
+
+ // Set the tabId after defining a <browser> and before notifications.
+ let browser = this.getBrowserForTab(tab);
+ if (browser && !tab.browser) {
+ tab.browser = browser;
+ if (!tab.linkedBrowser) {
+ tab.linkedBrowser = browser;
+ }
+ }
+
+ let restoreState = this._restoringTabState;
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if (
+ "onTabRestored" in tabMonitor &&
+ restoreState &&
+ tabMonitor.monitorName in restoreState.ext
+ ) {
+ tabMonitor.onTabRestored(
+ tab,
+ restoreState.ext[tabMonitor.monitorName],
+ false
+ );
+ } else if ("onTabOpened" in tabMonitor) {
+ tabMonitor.onTabOpened(tab, false, oldTab);
+ }
+ if (!background) {
+ tabMonitor.onTabSwitched(tab, oldTab);
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ // clear _mostRecentTabInfo; we only needed it during the call to
+ // openTab.
+ this._mostRecentTabInfo = null;
+ t.setAttribute("label", tab.title);
+ // For styling purposes, apply the type to the tab.
+ t.setAttribute("type", tab.mode.type);
+
+ if (!background) {
+ this.setDocumentTitle(tab);
+ // Move the focus on the newly selected tab.
+ this.panelContainer.selectedPanel.focus();
+ }
+
+ let moving = restoreState ? restoreState.moving : null;
+ // Dispatch tab opening event
+ let evt = new CustomEvent("TabOpen", {
+ bubbles: true,
+ detail: { tabInfo: tab, moving },
+ });
+ t.dispatchEvent(evt);
+ delete tab.beforeTabOpen;
+
+ contentProgress.addProgressListenerToBrowser(browser);
+
+ return tab;
+ } catch (e) {
+ console.error(e);
+ return null;
+ }
+ }
+
+ selectTabByMode(aTabModeName) {
+ let tabMode = this.tabModes[aTabModeName];
+ if (tabMode.tabs.length) {
+ let desiredTab = tabMode.tabs[0];
+ this.tabContainer.selectedIndex = this.tabInfo.indexOf(desiredTab);
+ }
+ }
+
+ selectTabByIndex(aEvent, aIndex) {
+ // count backwards for aIndex < 0
+ if (aIndex < 0) {
+ aIndex += this.tabInfo.length;
+ }
+
+ if (
+ aIndex >= 0 &&
+ aIndex < this.tabInfo.length &&
+ aIndex != this.tabContainer.selectedIndex
+ ) {
+ this.tabContainer.selectedIndex = aIndex;
+ }
+
+ if (aEvent) {
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+ }
+ }
+
+ /**
+ * If the current/most recent tab is of mode aTabModeName, return its
+ * tab info, otherwise return the tab info for the first tab of the
+ * given mode.
+ * You would want to use this method when you would like to mimic the
+ * settings of an existing instance of your mode. In such a case,
+ * it is reasonable to assume that if the 'current' tab was of the
+ * same mode that its settings should be used. Otherwise, we must
+ * fall back to another tab. We currently choose the first tab of
+ * the instance, because for the "folder" tab, it is the canonical tab.
+ * In other cases, having an MRU order and choosing the MRU tab might
+ * be more appropriate.
+ *
+ * @returns the tab info object for the tab meeting the above criteria,
+ * or null if no such tab exists.
+ */
+ getTabInfoForCurrentOrFirstModeInstance(aTabMode) {
+ // If we're in the middle of opening a new tab
+ // (this._mostRecentTabInfo is non-null), we shouldn't consider the
+ // current tab
+ let tabToConsider = this._mostRecentTabInfo || this.currentTabInfo;
+ if (tabToConsider && tabToConsider.mode == aTabMode) {
+ return tabToConsider;
+ } else if (aTabMode.tabs.length) {
+ return aTabMode.tabs[0];
+ }
+
+ return null;
+ }
+
+ undoCloseTab(aIdx) {
+ if (!this.recentlyClosedTabs.length) {
+ return;
+ }
+ if (aIdx >= this.recentlyClosedTabs.length) {
+ aIdx = this.recentlyClosedTabs.length - 1;
+ }
+ // splice always returns an array
+ let history = this.recentlyClosedTabs.splice(aIdx, 1)[0];
+ if (!history.tab) {
+ return;
+ }
+
+ if (!this.restoreTab(JSON.parse(history.tab))) {
+ return;
+ }
+
+ let idx = Math.min(history.idx, this.tabInfo.length);
+ let tab = this.tabContainer.allTabs[this.tabInfo.length - 1];
+ this.moveTabTo(tab, idx);
+ this.switchToTab(tab);
+ }
+
+ closeTab(aOptTabIndexNodeOrInfo, aNoUndo) {
+ let [iTab, tab, tabNode] = this._getTabContextForTabbyThing(
+ aOptTabIndexNodeOrInfo,
+ true
+ );
+ if (!tab.canClose) {
+ return;
+ }
+
+ // Give the tab type a chance to make its own decisions about
+ // whether its tabs can be closed or not. For instance, contentTabs
+ // and chromeTabs run onbeforeunload event handlers that may
+ // exercise their right to prompt the user for confirmation before
+ // closing.
+ let tryCloseFunc = tab.mode.tryCloseTab || tab.mode.tabType.tryCloseTab;
+ if (tryCloseFunc && !tryCloseFunc.call(tab.mode.tabType, tab)) {
+ return;
+ }
+
+ let evt = new CustomEvent("TabClose", {
+ bubbles: true,
+ detail: { tabInfo: tab, moving: tab.moving },
+ });
+
+ tabNode.dispatchEvent(evt);
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("onTabClosing" in tabMonitor) {
+ tabMonitor.onTabClosing(tab);
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ if (!aNoUndo) {
+ // Allow user to undo accidentally closed tabs
+ let session = this.persistTab(tab);
+ if (session) {
+ this.recentlyClosedTabs.unshift({
+ tab: JSON.stringify(session),
+ idx: iTab,
+ title: tab.title,
+ });
+ if (this.recentlyClosedTabs.length > 10) {
+ this.recentlyClosedTabs.pop();
+ }
+ }
+ }
+
+ tab.closed = true;
+ let closeFunc = tab.mode.closeTab || tab.mode.tabType.closeTab;
+ closeFunc.call(tab.mode.tabType, tab);
+ this.tabInfo.splice(iTab, 1);
+ tab.mode.tabs.splice(tab.mode.tabs.indexOf(tab), 1);
+ tabNode.remove();
+
+ if (this.tabContainer.selectedIndex == -1) {
+ if (this.mLastTabOpener && this.tabInfo.includes(this.mLastTabOpener)) {
+ this.tabContainer.selectedIndex = this.tabInfo.indexOf(
+ this.mLastTabOpener
+ );
+ } else {
+ this.tabContainer.selectedIndex =
+ iTab == this.tabContainer.allTabs.length ? iTab - 1 : iTab;
+ }
+ }
+
+ // Clear the last tab opener - we don't need this anymore.
+ this.mLastTabOpener = null;
+ if (this.currentTabInfo == tab) {
+ this.updateCurrentTab();
+ }
+
+ if (tab.panel) {
+ tab.panel.remove();
+ delete tab.panel;
+ // Ensure current tab is still selected and displayed in the
+ // panelContainer.
+ this.panelContainer.selectedPanel =
+ this.currentTabInfo.panel || this.currentTabInfo.mode.tabType.panel;
+ }
+
+ if (
+ this.tabContainer.allTabs.length == 1 &&
+ this.tabContainer.mAutoHide
+ ) {
+ this.tabContainer.mCollapseToolbar.collapsed = true;
+ document.documentElement.setAttribute("tabbarhidden", "true");
+ }
+ }
+
+ removeTabByNode(aTabNode) {
+ this.closeTab(aTabNode);
+ }
+
+ /**
+ * Given a tabNode (or tabby thing), close all of the other tabs
+ * that are closeable.
+ */
+ closeOtherTabs(aTabNode, aNoUndo) {
+ let [, thisTab] = this._getTabContextForTabbyThing(aTabNode, false);
+ // closeTab mutates the tabInfo array, so start from the end.
+ for (let i = this.tabInfo.length - 1; i >= 0; i--) {
+ let tab = this.tabInfo[i];
+ if (tab != thisTab && tab.canClose) {
+ this.closeTab(tab, aNoUndo);
+ }
+ }
+ }
+
+ replaceTabWithWindow(aTab, aTargetWindow, aTargetPosition) {
+ if (this.tabInfo.length <= 1) {
+ return null;
+ }
+
+ let tab = this._getTabContextForTabbyThing(aTab, false)[1];
+ if (!tab.canClose) {
+ return null;
+ }
+
+ // We use JSON and session restore transfer the tab to the new window.
+ tab = this.persistTab(tab);
+ if (!tab) {
+ return null;
+ }
+
+ // Converting to JSON and back again creates clean javascript
+ // object with absolutely no references to our current window.
+ tab = JSON.parse(JSON.stringify(tab));
+ // Set up an identifier for the move, consumers may want to correlate TabClose and
+ // TabOpen events.
+ let moveSession = Services.uuid.generateUUID().toString();
+ tab.moving = moveSession;
+ aTab.moving = moveSession;
+ this.closeTab(aTab, true);
+
+ if (aTargetWindow && aTargetWindow !== "popup") {
+ let targetTabmail = aTargetWindow.document.getElementById("tabmail");
+ targetTabmail.restoreTab(tab);
+ if (aTargetPosition) {
+ let droppedTab =
+ targetTabmail.tabInfo[targetTabmail.tabInfo.length - 1];
+ targetTabmail.moveTabTo(droppedTab, aTargetPosition);
+ }
+ return aTargetWindow;
+ }
+
+ let features = ["chrome"];
+ if (aTargetWindow === "popup") {
+ features.push(
+ "dialog",
+ "resizable",
+ "minimizable",
+ "centerscreen",
+ "titlebar",
+ "close"
+ );
+ } else {
+ features.push("dialog=no", "all", "status", "toolbar");
+ }
+
+ return window
+ .openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ features.join(","),
+ null,
+ {
+ action: "restore",
+ tabs: [tab],
+ }
+ )
+ .focus();
+ }
+
+ moveTabTo(aTabIndexNodeOrInfo, aIndex) {
+ let [oldIdx, tab, tabNode] = this._getTabContextForTabbyThing(
+ aTabIndexNodeOrInfo,
+ false
+ );
+ if (
+ !tab ||
+ !tabNode ||
+ tabNode.tagName != "tab" ||
+ oldIdx < 0 ||
+ oldIdx == aIndex
+ ) {
+ return -1;
+ }
+
+ // remove the entries from tabInfo, tabMode and the tabContainer
+ this.tabInfo.splice(oldIdx, 1);
+ tab.mode.tabs.splice(tab.mode.tabs.indexOf(tab), 1);
+ tabNode.remove();
+ // as we removed items, we might need to update indices
+ if (oldIdx < aIndex) {
+ aIndex--;
+ }
+
+ // Read it into tabInfo and the tabContainer
+ this.tabInfo.splice(aIndex, 0, tab);
+ this.tabContainer.insertBefore(
+ tabNode,
+ this.tabContainer.allTabs[aIndex]
+ );
+ // Now it's getting a bit ugly, as tabModes stores redundant
+ // information we need to get it in sync with tabInfo.
+ //
+ // As tabModes.tabs is a subset of tabInfo, every tab can be mapped
+ // to a tabInfo index. So we check for each tab in tabModes if it is
+ // directly in front of our moved tab. We do this by looking up the
+ // index in tabInfo and compare it with the moved tab's index. If we
+ // found our tab, we insert the moved tab directly behind into tabModes
+ // In case find no tab we simply append it
+ let modeIdx = tab.mode.tabs.length + 1;
+ for (let i = 0; i < tab.mode.tabs.length; i++) {
+ if (this.tabInfo.indexOf(tab.mode.tabs[i]) < aIndex) {
+ continue;
+ }
+ modeIdx = i;
+ break;
+ }
+
+ tab.mode.tabs.splice(modeIdx, 0, tab);
+ let evt = new CustomEvent("TabMove", {
+ bubbles: true,
+ view: window,
+ detail: { idx: oldIdx, tabInfo: tab },
+ });
+ tabNode.dispatchEvent(evt);
+
+ return aIndex;
+ }
+
+ // Returns null in case persist fails.
+ persistTab(tab) {
+ let persistFunc = tab.mode.persistTab || tab.mode.tabType.persistTab;
+ // if we can't restore the tab we can't move it
+ if (!persistFunc) {
+ return null;
+ }
+
+ // If there is a non-null tab-state, then persisting succeeded and
+ // we should store it. We store the tab's persisted state in its
+ // own distinct object rather than mixing things up in a dictionary
+ // to avoid bugs and because we may eventually let extensions store
+ // per-tab information in the persisted state.
+ let tabState;
+ // Wrap this in an exception handler so that if the persistence
+ // logic fails, things like tab closure still run to completion.
+ try {
+ tabState = persistFunc.call(tab.mode.tabType, tab);
+ } catch (ex) {
+ // Report this so that our unit testing framework sees this
+ // error and (extension) developers likewise can see when their
+ // extensions are ill-behaved.
+ console.error(ex);
+ }
+
+ if (!tabState) {
+ return null;
+ }
+
+ let ext = {};
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("onTabPersist" in tabMonitor) {
+ let monState = tabMonitor.onTabPersist(tab);
+ if (monState !== null) {
+ ext[tabMonitor.monitorName] = monState;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ return { mode: tab.mode.name, state: tabState, ext };
+ }
+
+ /**
+ * Persist the state of all tab modes implementing persistTab methods
+ * to a JSON-serializable object representation and return it. Call
+ * restoreTabs with the result to restore the tab state.
+ * Calling this method should have no side effects; tabs will not be
+ * closed, displays will not change, etc. This means the method is
+ * safe to use in an auto-save style so that if we crash we can
+ * restore the (approximate) state at the time of the crash.
+ *
+ * @returns {object} The persisted tab states.
+ */
+ persistTabs() {
+ let state = {
+ // Explicitly specify a revision so we don't wish we had later.
+ rev: 0,
+ // If our currently selected tab gets persisted, we will update this
+ selectedIndex: null,
+ };
+
+ let tabs = (state.tabs = []);
+ for (let [iTab, tab] of this.tabInfo.entries()) {
+ let persistTab = this.persistTab(tab);
+ if (!persistTab) {
+ continue;
+ }
+ tabs.push(persistTab);
+ // Mark this persisted tab as selected
+ if (iTab == this.tabContainer.selectedIndex) {
+ state.selectedIndex = tabs.length - 1;
+ }
+ }
+
+ return state;
+ }
+
+ restoreTab(aState) {
+ // Migrate old mail tabs to new mail tabs. This can be removed after ESR 115.
+ if (aState.mode == "folder") {
+ aState.mode = "mail3PaneTab";
+ } else if (aState.mode == "message") {
+ aState.mode = "mailMessageTab";
+ }
+
+ // if we no longer know about the mode, we can't restore the tab
+ let mode = this.tabModes[aState.mode];
+ if (!mode) {
+ this.unrestoredTabs.push(aState);
+ return false;
+ }
+
+ let restoreFunc = mode.restoreTab || mode.tabType.restoreTab;
+ if (!restoreFunc) {
+ return false;
+ }
+
+ // normalize the state to have an ext attribute if it does not.
+ if (!("ext" in aState)) {
+ aState.ext = {};
+ }
+
+ this._restoringTabState = aState;
+ restoreFunc.call(mode.tabType, this, aState.state);
+ this._restoringTabState = null;
+
+ return true;
+ }
+
+ /**
+ * Attempts to restore tabs persisted from a prior call to
+ * |persistTabs|. This is currently a synchronous operation, but in
+ * the future this may kick off an asynchronous mechanism to restore
+ * the tabs one-by-one.
+ */
+ restoreTabs(aPersistedState, aDontRestoreFirstTab) {
+ let tabs = aPersistedState.tabs;
+ let indexToSelect = null;
+
+ for (let [iTab, tabState] of tabs.entries()) {
+ if (tabState.state.firstTab && aDontRestoreFirstTab) {
+ tabState.state.dontRestoreFirstTab = aDontRestoreFirstTab;
+ }
+
+ if (!this.restoreTab(tabState)) {
+ continue;
+ }
+
+ // If this persisted tab was the selected one, then mark the newest
+ // tab as the guy to select.
+ if (iTab == aPersistedState.selectedIndex) {
+ indexToSelect = this.tabInfo.length - 1;
+ }
+ }
+
+ if (indexToSelect != null && !aDontRestoreFirstTab) {
+ this.tabContainer.selectedIndex = indexToSelect;
+ } else {
+ this.tabContainer.selectedIndex = 0;
+ }
+
+ if (
+ this.tabContainer.allTabs.length == 1 &&
+ this.tabContainer.mAutoHide
+ ) {
+ this.tabContainer.mCollapseToolbar.collapsed = true;
+ document.documentElement.setAttribute("tabbarhidden", "true");
+ }
+ }
+
+ clearRecentlyClosedTabs() {
+ this.recentlyClosedTabs.length = 0;
+ }
+ /**
+ * Called when the window is being unloaded, this calls the close
+ * function for every tab.
+ */
+ _teardown() {
+ for (var i = 0; i < this.tabInfo.length; i++) {
+ let tab = this.tabInfo[i];
+ let tabCloseFunc = tab.mode.closeTab || tab.mode.tabType.closeTab;
+ tabCloseFunc.call(tab.mode.tabType, tab);
+ }
+ }
+
+ /**
+ * The content window of the current tab, if it is a 3-pane tab.
+ *
+ * @type {?Window}
+ */
+ get currentAbout3Pane() {
+ if (this.currentTabInfo.mode.name == "mail3PaneTab") {
+ return this.currentTabInfo.chromeBrowser.contentWindow;
+ }
+ return null;
+ }
+
+ /**
+ * The content window of the current tab, if it is a message tab, OR if it
+ * is a 3-pane tab, the content window of the message browser within.
+ *
+ * @type {?Window}
+ */
+ get currentAboutMessage() {
+ switch (this.currentTabInfo.mode.name) {
+ case "mail3PaneTab": {
+ let messageBrowser = this.currentAbout3Pane.messageBrowser;
+ return messageBrowser && !messageBrowser.hidden
+ ? messageBrowser.contentWindow
+ : null;
+ }
+ case "mailMessageTab":
+ return this.currentTabInfo.chromeBrowser.contentWindow;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * getBrowserForSelectedTab is required as some toolkit functions
+ * require a getBrowser() function.
+ */
+ getBrowserForSelectedTab() {
+ if (!this.tabInfo) {
+ return null;
+ }
+
+ if (!this.currentTabInfo) {
+ this.currentTabInfo = this.tabInfo[0];
+ }
+
+ if (this.currentTabInfo) {
+ return this.getBrowserForTab(this.currentTabInfo);
+ }
+
+ return null;
+ }
+
+ getBrowserForTab(aTab) {
+ let browserFunc = aTab
+ ? aTab.mode.getBrowser || aTab.mode.tabType.getBrowser
+ : null;
+ return browserFunc ? browserFunc.call(aTab.mode.tabType, aTab) : null;
+ }
+
+ /**
+ * getBrowserForDocument is used to find the browser for a specific
+ * document that's been loaded
+ */
+ getBrowserForDocument(aDocument) {
+ for (let i = 0; i < this.tabInfo.length; ++i) {
+ let browserFunc =
+ this.tabInfo[i].mode.getBrowser ||
+ this.tabInfo[i].mode.tabType.getBrowser;
+
+ if (browserFunc) {
+ let possBrowser = browserFunc.call(
+ this.tabInfo[i].mode.tabType,
+ this.tabInfo[i]
+ );
+ if (possBrowser && possBrowser.contentWindow == aDocument) {
+ return this.tabInfo[i];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * getBrowserForDocumentId is used to find the browser for a specific
+ * document via its id attribute.
+ */
+ getBrowserForDocumentId(aDocumentId) {
+ for (let i = 0; i < this.tabInfo.length; ++i) {
+ let browserFunc =
+ this.tabInfo[i].mode.getBrowser ||
+ this.tabInfo[i].mode.tabType.getBrowser;
+ if (browserFunc) {
+ let possBrowser = browserFunc.call(
+ this.tabInfo[i].mode.tabType,
+ this.tabInfo[i]
+ );
+ if (
+ possBrowser &&
+ possBrowser.contentDocument.documentElement.id == aDocumentId
+ ) {
+ return this.tabInfo[i];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ getTabForBrowser(aBrowser) {
+ // Check the selected browser first, since that's the most likely.
+ if (this.getBrowserForSelectedTab() == aBrowser) {
+ return this.currentTabInfo;
+ }
+ for (let tabInfo of this.tabInfo) {
+ if (this.getBrowserForTab(tabInfo) == aBrowser) {
+ return tabInfo;
+ }
+ }
+ return null;
+ }
+
+ removeCurrentTab() {
+ this.removeTabByNode(
+ this.tabContainer.allTabs[this.tabContainer.selectedIndex]
+ );
+ }
+
+ switchToTab(aTabIndexNodeOrInfo) {
+ let [iTab] = this._getTabContextForTabbyThing(aTabIndexNodeOrInfo, false);
+ this.tabContainer.selectedIndex = iTab;
+ }
+
+ /**
+ * Finds the active element and stores it on `tabInfo` for restoring focus
+ * when this tab next becomes active.
+ *
+ * @param {object} tabInfo
+ */
+ rememberLastActiveElement(tabInfo) {
+ // Check for anything inside tabmail-container rather than the panel
+ // because focus could be in the Today Pane.
+ let activeElement = document.activeElement;
+ let container = document.getElementById("tabmail-container");
+ if (container.contains(activeElement)) {
+ while (activeElement.localName == "browser") {
+ let next = activeElement.contentDocument?.activeElement;
+ if (!next || next.localName == "body") {
+ break;
+ }
+ activeElement = next;
+ }
+ // If the active element is inside a container, store the container
+ // instead of the element, so that `.focus()` returns focus to the
+ // right place.
+ tabInfo.lastActiveElement =
+ activeElement.closest("[aria-activedescendant]") ?? activeElement;
+ Services.focus.clearFocus(window);
+ } else {
+ delete tabInfo.lastActiveElement;
+ }
+ }
+
+ /**
+ * UpdateCurrentTab - called in response to changing the current tab.
+ */
+ updateCurrentTab() {
+ if (
+ this.currentTabInfo != this.tabInfo[this.tabContainer.selectedIndex]
+ ) {
+ if (this.currentTabInfo) {
+ this.saveCurrentTabState();
+ }
+
+ let oldTab = this.currentTabInfo;
+ let oldPanel = [...this.panelContainer.children].find(p =>
+ p.hasAttribute("selected")
+ );
+ let tab = (this.currentTabInfo =
+ this.tabInfo[this.tabContainer.selectedIndex]);
+ // Update the selected attribute on the current and old tab panel.
+ if (oldPanel) {
+ this.rememberLastActiveElement(oldTab);
+ oldPanel.removeAttribute("selected");
+ if (oldTab.chromeBrowser) {
+ oldTab.chromeBrowser.docShellIsActive = false;
+ }
+ }
+
+ this.panelContainer.selectedPanel.setAttribute("selected", "true");
+ let showTabFunc = tab.mode.showTab || tab.mode.tabType.showTab;
+ showTabFunc.call(tab.mode.tabType, tab);
+ if (tab.chromeBrowser) {
+ tab.chromeBrowser.docShellIsActive = true;
+ }
+
+ let browser = this.getBrowserForTab(tab);
+ if (browser && !tab.browser) {
+ tab.browser = browser;
+ if (!tab.linkedBrowser) {
+ tab.linkedBrowser = browser;
+ }
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ tabMonitor.onTabSwitched(tab, oldTab);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ // always update the cursor status when we switch tabs
+ SetBusyCursor(window, tab.busy);
+ // active tabs should not have the wasBusy attribute
+ this.tabContainer.selectedItem.removeAttribute("wasBusy");
+ // update the thinking status when we switch tabs
+ this._setActiveThinkingState(tab.thinking);
+ // active tabs should not have the wasThinking attribute
+ this.tabContainer.selectedItem.removeAttribute("wasThinking");
+ this.setDocumentTitle(tab);
+
+ // We switched tabs, so we don't need to know the last tab
+ // opener anymore.
+ this.mLastTabOpener = null;
+
+ // Try to set focus where it was when the tab was last selected.
+ this.panelContainer.selectedPanel.focus();
+ if (tab.lastActiveElement) {
+ tab.lastActiveElement.focus();
+ delete tab.lastActiveElement;
+ }
+
+ let evt = new CustomEvent("TabSelect", {
+ bubbles: true,
+ detail: {
+ tabInfo: tab,
+ previousTabInfo: oldTab,
+ },
+ });
+ this.tabContainer.selectedItem.dispatchEvent(evt);
+ }
+ }
+
+ saveCurrentTabState() {
+ if (!this.currentTabInfo) {
+ this.currentTabInfo = this.tabInfo[0];
+ }
+
+ let tab = this.currentTabInfo;
+ // save the old tab state before we change the current tab
+ let saveTabFunc = tab.mode.saveTabState || tab.mode.tabType.saveTabState;
+ saveTabFunc.call(tab.mode.tabType, tab);
+ }
+
+ setTabTitle(aTabNodeOrInfo) {
+ let [iTab, tab] = this._getTabContextForTabbyThing(aTabNodeOrInfo, true);
+ if (tab) {
+ let tabNode = this.tabContainer.allTabs[iTab];
+ let titleChangeFunc =
+ tab.mode.onTitleChanged || tab.mode.tabType.onTitleChanged;
+ if (titleChangeFunc) {
+ titleChangeFunc.call(tab.mode.tabType, tab, tabNode);
+ }
+
+ let defaultTabTitle =
+ document.documentElement.getAttribute("defaultTabTitle");
+ let oldLabel = tabNode.getAttribute("label");
+ let newLabel = aTabNodeOrInfo ? tab.title : defaultTabTitle;
+ if (oldLabel == newLabel) {
+ return;
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ tabMonitor.onTabTitleChanged(tab);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ // If the displayed tab is the one at the moment of creation
+ // (aTabNodeOrInfo is null), set the default title as its title.
+ tabNode.setAttribute("label", newLabel);
+ // Update the window title if we're the displayed tab.
+ if (iTab == this.tabContainer.selectedIndex) {
+ this.setDocumentTitle(tab);
+ }
+
+ // Notify tab title change
+ if (!tab.beforeTabOpen) {
+ let evt = new CustomEvent("TabAttrModified", {
+ bubbles: true,
+ cancelable: false,
+ detail: { changed: ["label"], tabInfo: tab },
+ });
+ tabNode.dispatchEvent(evt);
+ }
+ }
+ }
+
+ /**
+ * Set the favIconUrl for the given tab and display it as the tab's icon.
+ * If the given favicon is missing or loads with an error, a fallback icon
+ * will be displayed instead.
+ *
+ * Note that the new favIconUrl is reported to the extension API's
+ * tabs.onUpdated.
+ *
+ * @param {object} tabInfo - The tabInfo object for the tab.
+ * @param {string|null} favIconUrl - The favIconUrl to set for the given
+ * tab.
+ * @param {string} fallbackSrc - The fallback icon src to display in case
+ * of missing or broken favicons.
+ */
+ setTabFavIcon(tabInfo, favIconUrl, fallbackSrc) {
+ let prevUrl = tabInfo.favIconUrl;
+ // The favIconUrl value is used by the TabmailTab _favIconUrl getter,
+ // which is used by the tab wrapper in the TabAttrModified callback.
+ tabInfo.favIconUrl = favIconUrl;
+ // NOTE: we always report the given favIconUrl, rather than the icon that
+ // is used in the tab. In particular, if the favIconUrl is null, we pass
+ // null rather than the fallbackIcon that is displayed.
+ if (favIconUrl != prevUrl && !tabInfo.beforeTabOpen) {
+ let evt = new CustomEvent("TabAttrModified", {
+ bubbles: true,
+ cancelable: false,
+ detail: { changed: ["favIconUrl"], tabInfo },
+ });
+ tabInfo.tabNode.dispatchEvent(evt);
+ }
+
+ tabInfo.tabNode.setIcon(favIconUrl, fallbackSrc);
+ }
+
+ /**
+ * Updates the global state to reflect the active tab's thinking
+ * state (which the caller provides).
+ */
+ _setActiveThinkingState(aThinkingState) {
+ if (aThinkingState) {
+ statusFeedback.showProgress(0);
+ if (typeof aThinkingState == "string") {
+ statusFeedback.showStatusString(aThinkingState);
+ }
+ } else {
+ statusFeedback.showProgress(0);
+ }
+ }
+
+ setTabThinking(aTabNodeOrInfo, aThinking) {
+ let [iTab, tab, tabNode] = this._getTabContextForTabbyThing(
+ aTabNodeOrInfo,
+ false
+ );
+ let isSelected = iTab == this.tabContainer.selectedIndex;
+ // if we are the current tab, update the cursor
+ if (isSelected) {
+ this._setActiveThinkingState(aThinking);
+ }
+
+ // if we are busy, hint our tab
+ if (aThinking) {
+ tabNode.setAttribute("thinking", "true");
+ } else {
+ // if we were thinking and are not selected, set the
+ // "wasThinking" attribute.
+ if (tab.thinking && !isSelected) {
+ tabNode.setAttribute("wasThinking", "true");
+ }
+ tabNode.removeAttribute("thinking");
+ }
+
+ // update the tab info to store the busy state.
+ tab.thinking = aThinking;
+ }
+
+ setTabBusy(aTabNodeOrInfo, aBusy) {
+ let [iTab, tab, tabNode] = this._getTabContextForTabbyThing(
+ aTabNodeOrInfo,
+ false
+ );
+ let isSelected = iTab == this.tabContainer.selectedIndex;
+
+ // if we are the current tab, update the cursor
+ if (isSelected) {
+ SetBusyCursor(window, aBusy);
+ }
+
+ // if we are busy, hint our tab
+ if (aBusy) {
+ tabNode.setAttribute("busy", "true");
+ } else {
+ // if we were busy and are not selected, set the
+ // "wasBusy" attribute.
+ if (tab.busy && !isSelected) {
+ tabNode.setAttribute("wasBusy", "true");
+ }
+ tabNode.removeAttribute("busy");
+ }
+
+ // update the tab info to store the busy state.
+ tab.busy = aBusy;
+ }
+
+ /**
+ * Set the document title based on the tab title
+ */
+ setDocumentTitle(aTab = this.selectedTab) {
+ let docTitle = aTab.title ? aTab.title.trim() : "";
+ let docElement = document.documentElement;
+ // If the document title is blank, add the default title.
+ if (!docTitle) {
+ docTitle = docElement.getAttribute("defaultTabTitle");
+ }
+
+ if (docElement.hasAttribute("titlepreface")) {
+ docTitle = docElement.getAttribute("titlepreface") + docTitle;
+ }
+
+ // If we're on Mac, don't display the separator and the modifier.
+ if (AppConstants.platform != "macosx") {
+ docTitle +=
+ docElement.getAttribute("titlemenuseparator") +
+ docElement.getAttribute("titlemodifier");
+ }
+
+ document.title = docTitle;
+ }
+
+ // Called by <browser>, unused by tabmail.
+ finishBrowserRemotenessChange(browser, loadSwitchId) {}
+
+ /**
+ * Returns the find bar for a tab.
+ */
+ getCachedFindBar(tab = this.selectedTab) {
+ return tab.findbar ?? null;
+ }
+
+ /**
+ * Implementation of gBrowser's lazy-loaded find bar. We don't lazily load
+ * the find bar, and some of our tabs don't have a find bar.
+ */
+ async getFindBar(tab = this.selectedTab) {
+ return tab.findbar ?? null;
+ }
+
+ disconnectedCallback() {
+ window.controllers.removeController(this.tabController);
+ }
+ }
+
+ customElements.define("tabmail", MozTabmail);
+}
+
+/**
+ * Refresh the contents of the recently closed tags popup menu/panel.
+ * Used for example for appmenu/Go/Recently_Closed_Tabs panel.
+ *
+ * @param {Element} parent - Parent element that will contain the menu items.
+ * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton".
+ * @param {string} [classes] - Classes to set on the menu items.
+ * @param {string} [separatorName] - Type of separator, e.g. "menuseparator", "toolbarseparator".
+ */
+function InitRecentlyClosedTabsPopup(
+ parent,
+ elementName = "menuitem",
+ classes,
+ separatorName = "menuseparator"
+) {
+ const tabs = document.getElementById("tabmail").recentlyClosedTabs;
+
+ // Show Popup only when there are restorable tabs.
+ if (!tabs.length) {
+ return false;
+ }
+
+ // Clear the list.
+ while (parent.hasChildNodes()) {
+ parent.lastChild.remove();
+ }
+
+ // Insert menu items to rebuild the recently closed tab list.
+ tabs.forEach((tab, index) => {
+ const item = document.createXULElement(elementName);
+ item.setAttribute("label", tab.title);
+ item.setAttribute(
+ "oncommand",
+ `document.getElementById("tabmail").undoCloseTab(${index});`
+ );
+ if (classes) {
+ item.setAttribute("class", classes);
+ }
+
+ if (index == 0) {
+ item.setAttribute("key", "key_undoCloseTab");
+ }
+ parent.appendChild(item);
+ });
+
+ // Only show "Restore All Tabs" if there is more than one tab to restore.
+ if (tabs.length > 1) {
+ parent.appendChild(document.createXULElement(separatorName));
+
+ const item = document.createXULElement(elementName);
+ item.setAttribute(
+ "label",
+ document.getElementById("bundle_messenger").getString("restoreAllTabs")
+ );
+
+ item.addEventListener("command", () => {
+ let tabmail = document.getElementById("tabmail");
+ let len = tabmail.recentlyClosedTabs.length;
+ while (len--) {
+ document.getElementById("tabmail").undoCloseTab();
+ }
+ });
+
+ if (classes) {
+ item.setAttribute("class", classes);
+ }
+ parent.appendChild(item);
+ }
+
+ return true;
+}
+
+// Set up the tabContextMenu, which is used as the context menu for all tabmail
+// tabs.
+window.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ let tabmail = document.getElementById("tabmail");
+ let tabMenu = document.getElementById("tabContextMenu");
+
+ let openInWindowItem = document.getElementById(
+ "tabContextMenuOpenInWindow"
+ );
+ let closeOtherTabsItem = document.getElementById(
+ "tabContextMenuCloseOtherTabs"
+ );
+ let recentlyClosedMenu = document.getElementById(
+ "tabContextMenuRecentlyClosed"
+ );
+ let closeItem = document.getElementById("tabContextMenuClose");
+
+ // Shared variable: the tabNode that was activated to open the context menu.
+ let currentTabInfo = null;
+
+ tabMenu.addEventListener("popupshowing", () => {
+ let tabNode = tabMenu.triggerNode?.closest("tab");
+
+ // this happens when the user did not actually-click on a tab but
+ // instead on the strip behind it.
+ if (!tabNode) {
+ currentTabInfo = null;
+ return false;
+ }
+
+ currentTabInfo = tabmail.tabInfo.find(info => info.tabNode == tabNode);
+ openInWindowItem.setAttribute(
+ "disabled",
+ currentTabInfo.canClose && tabmail.persistTab(currentTabInfo)
+ );
+ closeOtherTabsItem.setAttribute(
+ "disabled",
+ tabmail.tabInfo.every(info => info == currentTabInfo || !info.canClose)
+ );
+ recentlyClosedMenu.setAttribute(
+ "disabled",
+ !tabmail.recentlyClosedTabs.length
+ );
+ closeItem.setAttribute("disabled", !currentTabInfo.canClose);
+ return true;
+ });
+
+ // Tidy up.
+ tabMenu.addEventListener("popuphidden", () => {
+ currentTabInfo = null;
+ });
+
+ openInWindowItem.addEventListener("command", () => {
+ tabmail.replaceTabWithWindow(currentTabInfo);
+ });
+ closeOtherTabsItem.addEventListener("command", () => {
+ tabmail.closeOtherTabs(currentTabInfo);
+ });
+ closeItem.addEventListener("command", () => {
+ tabmail.closeTab(currentTabInfo);
+ });
+
+ let recentlyClosedPopup = recentlyClosedMenu.querySelector("menupopup");
+ recentlyClosedPopup.addEventListener("popupshowing", () =>
+ InitRecentlyClosedTabsPopup(recentlyClosedPopup)
+ );
+
+ // Register the tabmail window font size only after everything else loaded.
+ UIFontSize.registerWindow(window);
+ },
+ { once: true }
+);
diff --git a/comm/mail/base/content/tagDialog.inc.xhtml b/comm/mail/base/content/tagDialog.inc.xhtml
new file mode 100644
index 0000000000..100efefe60
--- /dev/null
+++ b/comm/mail/base/content/tagDialog.inc.xhtml
@@ -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/.
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/new-tag.ftl"/>
+ </linkset>
+
+ <box style="display: grid; grid-template-columns: auto 1fr; align-items: center;">
+ <label id="nameLabel"
+ data-l10n-id="tag-name-label"
+ control="name"/>
+ <hbox class="input-container">
+ <html:input id="name"
+ type="text"
+ oninput="doEnabling();"
+ class="input-inline"
+ aria-labelledby="nameLabel"/>
+ </hbox>
+ <hbox align="center">
+ <label id="colorLabel"
+ data-l10n-id="tag-color-label"
+ control="tagColorPicker"/>
+ </hbox>
+ <html:input type="color" id="tagColorPicker"/>
+ </box>
+ <separator/>
diff --git a/comm/mail/base/content/threadPane.js b/comm/mail/base/content/threadPane.js
new file mode 100644
index 0000000000..88af9c28b0
--- /dev/null
+++ b/comm/mail/base/content/threadPane.js
@@ -0,0 +1,825 @@
+/* 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/. */
+
+/* TODO: Now used exclusively in SearchDialog.xhtml. Needs dead code removal. */
+
+/* import-globals-from folderDisplay.js */
+/* import-globals-from SearchDialog.js */
+
+/* globals validateFileName */ // From utilityOverlay.js
+/* globals messageFlavorDataProvider */ // From messenger.js
+
+ChromeUtils.defineESModuleGetters(this, {
+ TreeSelection: "chrome://messenger/content/tree-selection.mjs",
+});
+
+var gLastMessageUriToLoad = null;
+var gThreadPaneCommandUpdater = null;
+/**
+ * Tracks whether the right mouse button changed the selection or not. If the
+ * user right clicks on the selection, it stays the same. If they click outside
+ * of it, we alter the selection (but not the current index) to be the row they
+ * clicked on.
+ *
+ * The value of this variable is an object with "view" and "selection" keys
+ * and values. The view value is the view whose selection we saved off, and
+ * the selection value is the selection object we saved off.
+ */
+var gRightMouseButtonSavedSelection = null;
+
+/**
+ * When right-clicks happen, we do not want to corrupt the underlying
+ * selection. The right-click is a transient selection. So, unless the
+ * user is right-clicking on the current selection, we create a new
+ * selection object (thanks to TreeSelection) and set that as the
+ * current/transient selection.
+ *
+ * @param aSingleSelect Should the selection we create be a single selection?
+ * This is relevant if the row being clicked on is already part of the
+ * selection. If it is part of the selection and !aSingleSelect, then we
+ * leave the selection as is. If it is part of the selection and
+ * aSingleSelect then we create a transient single-row selection.
+ */
+function ChangeSelectionWithoutContentLoad(event, tree, aSingleSelect) {
+ var treeSelection = tree.view.selection;
+
+ var row = tree.getRowAt(event.clientX, event.clientY);
+ // Only do something if:
+ // - the row is valid
+ // - it's not already selected (or we want a single selection)
+ if (row >= 0 && (aSingleSelect || !treeSelection.isSelected(row))) {
+ // Check if the row is exactly the existing selection. In that case
+ // there is no need to create a bogus selection.
+ if (treeSelection.count == 1) {
+ let minObj = {};
+ treeSelection.getRangeAt(0, minObj, {});
+ if (minObj.value == row) {
+ event.stopPropagation();
+ return;
+ }
+ }
+
+ let transientSelection = new TreeSelection(tree);
+ transientSelection.logAdjustSelectionForReplay();
+
+ gRightMouseButtonSavedSelection = {
+ // Need to clear out this reference later.
+ view: tree.view,
+ realSelection: treeSelection,
+ transientSelection,
+ };
+
+ var saveCurrentIndex = treeSelection.currentIndex;
+
+ // tell it to log calls to adjustSelection
+ // attach it to the view
+ tree.view.selection = transientSelection;
+ // Don't generate any selection events! (we never set this to false, because
+ // that would generate an event, and we never need one of those from this
+ // selection object.
+ transientSelection.selectEventsSuppressed = true;
+ transientSelection.select(row);
+ transientSelection.currentIndex = saveCurrentIndex;
+ tree.ensureRowIsVisible(row);
+ }
+ event.stopPropagation();
+}
+
+function ThreadPaneOnDragStart(aEvent) {
+ if (aEvent.target.localName != "treechildren") {
+ return;
+ }
+
+ let messageUris = gFolderDisplay.selectedMessageUris;
+ if (!messageUris) {
+ return;
+ }
+
+ gFolderDisplay.hintAboutToDeleteMessages();
+ let messengerBundle = document.getElementById("bundle_messenger");
+ let noSubjectString = messengerBundle.getString(
+ "defaultSaveMessageAsFileName"
+ );
+ if (noSubjectString.endsWith(".eml")) {
+ noSubjectString = noSubjectString.slice(0, -4);
+ }
+ let longSubjectTruncator = messengerBundle.getString(
+ "longMsgSubjectTruncator"
+ );
+ // Clip the subject string to 124 chars to avoid problems on Windows,
+ // see NS_MAX_FILEDESCRIPTOR in m-c/widget/windows/nsDataObj.cpp .
+ const maxUncutNameLength = 124;
+ let maxCutNameLength = maxUncutNameLength - longSubjectTruncator.length;
+ let messages = new Map();
+ for (let [index, msgUri] of messageUris.entries()) {
+ let msgService = MailServices.messageServiceFromURI(msgUri);
+ let msgHdr = msgService.messageURIToMsgHdr(msgUri);
+ let subject = msgHdr.mime2DecodedSubject || "";
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ subject = "Re: " + subject;
+ }
+
+ let uniqueFileName;
+ // If there is no subject, use a default name.
+ // If subject needs to be truncated, add a truncation character to indicate it.
+ if (!subject) {
+ uniqueFileName = noSubjectString;
+ } else {
+ uniqueFileName =
+ subject.length <= maxUncutNameLength
+ ? subject
+ : subject.substr(0, maxCutNameLength) + longSubjectTruncator;
+ }
+ let msgFileName = validateFileName(uniqueFileName);
+ let msgFileNameLowerCase = msgFileName.toLocaleLowerCase();
+
+ while (true) {
+ if (!messages[msgFileNameLowerCase]) {
+ messages[msgFileNameLowerCase] = 1;
+ break;
+ } else {
+ let postfix = "-" + messages[msgFileNameLowerCase];
+ messages[msgFileNameLowerCase]++;
+ msgFileName = msgFileName + postfix;
+ msgFileNameLowerCase = msgFileNameLowerCase + postfix;
+ }
+ }
+
+ msgFileName = msgFileName + ".eml";
+
+ let msgUrl = msgService.getUrlForUri(msgUri);
+ let separator = msgUrl.spec.includes("?") ? "&" : "?";
+
+ aEvent.dataTransfer.mozSetDataAt("text/x-moz-message", msgUri, index);
+ aEvent.dataTransfer.mozSetDataAt("text/x-moz-url", msgUrl.spec, index);
+ aEvent.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-url",
+ msgUrl.spec + separator + "fileName=" + encodeURIComponent(msgFileName),
+ index
+ );
+ aEvent.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise",
+ new messageFlavorDataProvider(),
+ index
+ );
+ aEvent.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-dest-filename",
+ msgFileName.replace(/(.{74}).*(.{10})$/u, "$1...$2"),
+ index
+ );
+ }
+ aEvent.dataTransfer.effectAllowed = "copyMove";
+ aEvent.dataTransfer.addElement(aEvent.target);
+}
+
+function ThreadPaneOnDragOver(aEvent) {
+ let ds = Cc["@mozilla.org/widget/dragservice;1"]
+ .getService(Ci.nsIDragService)
+ .getCurrentSession();
+ ds.canDrop = false;
+ if (!gFolderDisplay.displayedFolder.canFileMessages) {
+ return;
+ }
+
+ let dt = aEvent.dataTransfer;
+ if (Array.from(dt.mozTypesAt(0)).includes("application/x-moz-file")) {
+ let extFile = dt.mozGetDataAt("application/x-moz-file", 0);
+ if (!extFile) {
+ return;
+ }
+
+ extFile = extFile.QueryInterface(Ci.nsIFile);
+ if (extFile.isFile()) {
+ let len = extFile.leafName.length;
+ if (len > 4 && extFile.leafName.toLowerCase().endsWith(".eml")) {
+ ds.canDrop = true;
+ }
+ }
+ }
+}
+
+function ThreadPaneOnDrop(aEvent) {
+ let dt = aEvent.dataTransfer;
+ for (let i = 0; i < dt.mozItemCount; i++) {
+ let extFile = dt.mozGetDataAt("application/x-moz-file", i);
+ if (!extFile) {
+ continue;
+ }
+
+ extFile = extFile.QueryInterface(Ci.nsIFile);
+ if (extFile.isFile()) {
+ let len = extFile.leafName.length;
+ if (len > 4 && extFile.leafName.toLowerCase().endsWith(".eml")) {
+ MailServices.copy.copyFileMessage(
+ extFile,
+ gFolderDisplay.displayedFolder,
+ null,
+ false,
+ 1,
+ "",
+ null,
+ msgWindow
+ );
+ }
+ }
+ }
+}
+
+function TreeOnMouseDown(event) {
+ // Detect right mouse click and change the highlight to the row
+ // where the click happened without loading the message headers in
+ // the Folder or Thread Pane.
+ // Same for middle click, which will open the folder/message in a tab.
+ if (event.button == 2 || event.button == 1) {
+ // We want a single selection if this is a middle-click (button 1)
+ ChangeSelectionWithoutContentLoad(
+ event,
+ event.target.parentNode,
+ event.button == 1
+ );
+ }
+}
+
+function ThreadPaneOnClick(event) {
+ // We only care about button 0 (left click) events.
+ if (event.button != 0) {
+ event.stopPropagation();
+ return;
+ }
+
+ // We already handle marking as read/flagged/junk cyclers in nsMsgDBView.cpp
+ // so all we need to worry about here is doubleclicks and column header. We
+ // get here for clicks on the "treecol" (headers) and the "scrollbarbutton"
+ // (scrollbar buttons) and don't want those events to cause a doubleclick.
+
+ let t = event.target;
+ if (t.localName == "treecol") {
+ HandleColumnClick(t.id);
+ return;
+ }
+
+ if (t.localName != "treechildren") {
+ return;
+ }
+
+ let tree = GetThreadTree();
+ // Figure out what cell the click was in.
+ let treeCellInfo = tree.getCellAt(event.clientX, event.clientY);
+ if (treeCellInfo.row == -1) {
+ return;
+ }
+
+ if (treeCellInfo.col.id == "selectCol") {
+ HandleSelectColClick(event, treeCellInfo.row);
+ return;
+ }
+
+ if (treeCellInfo.col.id == "deleteCol") {
+ handleDeleteColClick(event);
+ return;
+ }
+
+ // Grouped By Sort dummy header row non cycler column doubleclick toggles the
+ // thread's open/closed state; tree.js handles it. Cyclers are not currently
+ // implemented in group header rows, a click/doubleclick there should
+ // select/toggle thread state.
+ if (gFolderDisplay.view.isGroupedByHeaderAtIndex(treeCellInfo.row)) {
+ if (!treeCellInfo.col.cycler) {
+ return;
+ }
+ if (event.detail == 1) {
+ gFolderDisplay.selectViewIndex(treeCellInfo.row);
+ }
+ if (event.detail == 2) {
+ gFolderDisplay.view.dbView.toggleOpenState(treeCellInfo.row);
+ }
+ event.stopPropagation();
+ return;
+ }
+
+ // Cyclers and twisties respond to single clicks, not double clicks.
+ if (
+ event.detail == 2 &&
+ !treeCellInfo.col.cycler &&
+ treeCellInfo.childElt != "twisty"
+ ) {
+ ThreadPaneDoubleClick();
+ } else if (
+ treeCellInfo.col.id == "threadCol" &&
+ !event.shiftKey &&
+ (event.ctrlKey || event.metaKey)
+ ) {
+ gDBView.ExpandAndSelectThreadByIndex(treeCellInfo.row, true);
+ event.stopPropagation();
+ }
+}
+
+function HandleColumnClick(columnID) {
+ if (columnID == "selectCol") {
+ let treeView = gFolderDisplay.tree.view;
+ let selection = treeView.selection;
+ if (!selection) {
+ return;
+ }
+ if (selection.count > 0) {
+ selection.clearSelection();
+ } else {
+ selection.selectAll();
+ }
+ return;
+ }
+
+ if (gFolderDisplay.COLUMNS_MAP_NOSORT.has(columnID)) {
+ return;
+ }
+
+ let sortType = gFolderDisplay.COLUMNS_MAP.get(columnID);
+ let curCustomColumn = gDBView.curCustomColumn;
+ if (!sortType) {
+ // If the column isn't in the map, check if it's a custom column.
+ try {
+ // Test for the columnHandler (an error is thrown if it does not exist).
+ gDBView.getColumnHandler(columnID);
+
+ // Handler is registered - set column to be the current custom column.
+ gDBView.curCustomColumn = columnID;
+ sortType = "byCustom";
+ } catch (ex) {
+ dump(
+ "HandleColumnClick: No custom column handler registered for " +
+ "columnID: " +
+ columnID +
+ " - " +
+ ex +
+ "\n"
+ );
+ return;
+ }
+ }
+
+ let viewWrapper = gFolderDisplay.view;
+ let simpleColumns = false;
+ try {
+ simpleColumns = !Services.prefs.getBoolPref(
+ "mailnews.thread_pane_column_unthreads"
+ );
+ } catch (ex) {}
+
+ if (sortType == "byThread") {
+ if (simpleColumns) {
+ MsgToggleThreaded();
+ } else if (viewWrapper.showThreaded) {
+ MsgReverseSortThreadPane();
+ } else {
+ MsgSortByThread();
+ }
+
+ return;
+ }
+
+ if (!simpleColumns && viewWrapper.showThreaded) {
+ viewWrapper.showUnthreaded = true;
+ MsgSortThreadPane(sortType);
+ return;
+ }
+
+ if (
+ viewWrapper.primarySortType == Ci.nsMsgViewSortType[sortType] &&
+ (viewWrapper.primarySortType != Ci.nsMsgViewSortType.byCustom ||
+ curCustomColumn == columnID)
+ ) {
+ MsgReverseSortThreadPane();
+ } else {
+ MsgSortThreadPane(sortType);
+ }
+}
+
+function HandleSelectColClick(event, row) {
+ // User wants to multiselect using the old way.
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+ let tree = gFolderDisplay.tree;
+ let selection = tree.view.selection;
+ if (event.detail == 1) {
+ selection.toggleSelect(row);
+ }
+
+ // In the selectCol, we want a double click on a thread parent to select
+ // and deselect all children, in threaded and grouped views.
+ if (
+ event.detail == 2 &&
+ tree.view.isContainerOpen(row) &&
+ !tree.view.isContainerEmpty(row)
+ ) {
+ // On doubleclick of an open thread, select/deselect all the children.
+ let startRow = row + 1;
+ let endRow = startRow;
+ while (endRow < tree.view.rowCount && tree.view.getLevel(endRow) > 0) {
+ endRow++;
+ }
+ endRow--;
+ if (selection.isSelected(row)) {
+ selection.rangedSelect(startRow, endRow, true);
+ } else {
+ selection.clearRange(startRow, endRow);
+ ThreadPaneSelectionChanged();
+ }
+ }
+
+ // There is no longer any selection, clean up for correct state of things.
+ if (selection.count == 0) {
+ if (gFolderDisplay.displayedFolder) {
+ gFolderDisplay.displayedFolder.lastMessageLoaded = nsMsgKey_None;
+ }
+ gFolderDisplay._mostRecentSelectionCounts[1] = 0;
+ }
+}
+
+/**
+ * Delete a message without selecting it or loading its content.
+ *
+ * @param {DOMEvent} event - The DOM Event.
+ */
+function handleDeleteColClick(event) {
+ // Prevent deletion if any of the modifier keys was pressed.
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ // Simulate a right click on the message row to inherit all the validations
+ // and alerts coming from the "cmd_delete" command.
+ ChangeSelectionWithoutContentLoad(
+ event,
+ event.target.parentNode,
+ event.button == 1
+ );
+
+ // Trigger the message deletion.
+ goDoCommand("cmd_delete");
+}
+
+function ThreadPaneDoubleClick() {
+ MsgOpenSelectedMessages();
+}
+
+function ThreadPaneKeyDown(event) {
+ if (event.keyCode != KeyEvent.DOM_VK_RETURN) {
+ return;
+ }
+
+ // Grouped By Sort dummy header row <enter> toggles the thread's open/closed
+ // state. Let tree.js handle it.
+ if (
+ gFolderDisplay.view.showGroupedBySort &&
+ gFolderDisplay.treeSelection &&
+ gFolderDisplay.treeSelection.count == 1 &&
+ gFolderDisplay.view.isGroupedByHeaderAtIndex(
+ gFolderDisplay.treeSelection.currentIndex
+ )
+ ) {
+ return;
+ }
+
+ // Prevent any thread that happens to be last selected (currentIndex) in a
+ // single or multi selection from toggling in tree.js.
+ event.stopImmediatePropagation();
+
+ ThreadPaneDoubleClick();
+}
+
+function MsgSortByThread() {
+ gFolderDisplay.view.showThreaded = true;
+ MsgSortThreadPane("byDate");
+}
+
+function MsgSortThreadPane(sortName) {
+ let sortType = Ci.nsMsgViewSortType[sortName];
+ let grouped = gFolderDisplay.view.showGroupedBySort;
+ gFolderDisplay.view._threadExpandAll = Boolean(
+ gFolderDisplay.view._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ if (!grouped) {
+ gFolderDisplay.view.sort(sortType, Ci.nsMsgViewSortOrder.ascending);
+ // Respect user's last expandAll/collapseAll choice, post sort direction change.
+ gFolderDisplay.restoreThreadState();
+ return;
+ }
+
+ // legacy behavior dictates we un-group-by-sort if we were. this probably
+ // deserves a UX call...
+
+ // For non virtual folders, do not ungroup (which sorts by the going away
+ // sort) and then sort, as it's a double sort.
+ // For virtual folders, which are rebuilt in the backend in a grouped
+ // change, create a new view upfront rather than applying viewFlags. There
+ // are oddities just applying viewFlags, for example changing out of a
+ // custom column grouped xfvf view with the threads collapsed works (doesn't)
+ // differently than other variations.
+ // So, first set the desired sortType and sortOrder, then set viewFlags in
+ // batch mode, then apply it all (open a new view) with endViewUpdate().
+ gFolderDisplay.view.beginViewUpdate();
+ gFolderDisplay.view._sort = [[sortType, Ci.nsMsgViewSortOrder.ascending]];
+ gFolderDisplay.view.showGroupedBySort = false;
+ gFolderDisplay.view.endViewUpdate();
+
+ // Virtual folders don't persist viewFlags well in the back end,
+ // due to a virtual folder being either 'real' or synthetic, so make
+ // sure it's done here.
+ if (gFolderDisplay.view.isVirtual) {
+ gFolderDisplay.view.dbView.viewFlags = gFolderDisplay.view.viewFlags;
+ }
+}
+
+function MsgReverseSortThreadPane() {
+ let grouped = gFolderDisplay.view.showGroupedBySort;
+ gFolderDisplay.view._threadExpandAll = Boolean(
+ gFolderDisplay.view._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ // Grouped By view is special for column click sort direction changes.
+ if (grouped) {
+ if (gDBView.selection.count) {
+ gFolderDisplay._saveSelection();
+ }
+
+ if (gFolderDisplay.view.isSingleFolder) {
+ if (gFolderDisplay.view.isVirtual) {
+ gFolderDisplay.view.showGroupedBySort = false;
+ } else {
+ // Must ensure rows are collapsed and kExpandAll is unset.
+ gFolderDisplay.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ }
+ }
+ }
+
+ if (gFolderDisplay.view.isSortedAscending) {
+ gFolderDisplay.view.sortDescending();
+ } else {
+ gFolderDisplay.view.sortAscending();
+ }
+
+ // Restore Grouped By state post sort direction change.
+ if (grouped) {
+ if (gFolderDisplay.view.isVirtual && gFolderDisplay.view.isSingleFolder) {
+ MsgGroupBySort();
+ }
+
+ // Restore Grouped By selection post sort direction change.
+ gFolderDisplay._restoreSelection();
+ }
+
+ // Respect user's last expandAll/collapseAll choice, for both threaded and grouped
+ // views, post sort direction change.
+ gFolderDisplay.restoreThreadState();
+}
+
+function MsgToggleThreaded() {
+ if (gFolderDisplay.view.showThreaded) {
+ gFolderDisplay.view.showUnthreaded = true;
+ } else {
+ gFolderDisplay.view.showThreaded = true;
+ }
+}
+
+function MsgSortThreaded() {
+ gFolderDisplay.view.showThreaded = true;
+}
+
+function MsgGroupBySort() {
+ gFolderDisplay.view.showGroupedBySort = true;
+}
+
+function MsgSortUnthreaded() {
+ gFolderDisplay.view.showUnthreaded = true;
+}
+
+function MsgSortAscending() {
+ if (
+ gFolderDisplay.view.showGroupedBySort &&
+ gFolderDisplay.view.isSingleFolder
+ ) {
+ if (gFolderDisplay.view.isSortedDescending) {
+ MsgReverseSortThreadPane();
+ }
+
+ return;
+ }
+
+ gFolderDisplay.view.sortAscending();
+}
+
+function MsgSortDescending() {
+ if (
+ gFolderDisplay.view.showGroupedBySort &&
+ gFolderDisplay.view.isSingleFolder
+ ) {
+ if (gFolderDisplay.view.isSortedAscending) {
+ MsgReverseSortThreadPane();
+ }
+
+ return;
+ }
+
+ gFolderDisplay.view.sortDescending();
+}
+
+// XXX this should probably migrate into FolderDisplayWidget, or whatever
+// FolderDisplayWidget ends up using if it refactors column management out.
+function UpdateSortIndicators(sortType, sortOrder) {
+ // Remove the sort indicator from all the columns
+ let treeColumns = document.getElementById("threadCols").children;
+ for (let i = 0; i < treeColumns.length; i++) {
+ treeColumns[i].removeAttribute("sortDirection");
+ }
+
+ // show the twisties if the view is threaded
+ let threadCol = document.getElementById("threadCol");
+ let subjectCol = document.getElementById("subjectCol");
+ let sortedColumn;
+ // set the sort indicator on the column we are sorted by
+ let colID = ConvertSortTypeToColumnID(sortType);
+ if (colID) {
+ sortedColumn = document.getElementById(colID);
+ }
+
+ let viewWrapper = gFolderDisplay.view;
+
+ // the thread column is not visible when we are grouped by sort
+ threadCol.collapsed = viewWrapper.showGroupedBySort;
+
+ // show twisties only when grouping or threading
+ if (viewWrapper.showGroupedBySort || viewWrapper.showThreaded) {
+ subjectCol.setAttribute("primary", "true");
+ } else {
+ subjectCol.removeAttribute("primary");
+ }
+
+ if (sortedColumn) {
+ sortedColumn.setAttribute(
+ "sortDirection",
+ sortOrder == Ci.nsMsgViewSortOrder.ascending ? "ascending" : "descending"
+ );
+ }
+
+ // Prevent threadCol from showing the sort direction chevron.
+ if (viewWrapper.showThreaded) {
+ threadCol.removeAttribute("sortDirection");
+ }
+}
+
+function GetThreadTree() {
+ return document.getElementById("threadTree");
+}
+
+function ThreadPaneOnLoad() {
+ var tree = GetThreadTree();
+ // We won't have the tree if we're in a message window, so exit silently
+ if (!tree) {
+ return;
+ }
+ tree.addEventListener("click", ThreadPaneOnClick, true);
+ tree.addEventListener(
+ "dblclick",
+ event => {
+ // The tree.js dblclick event handler is handling editing and toggling
+ // open state of the cell. We don't use editing, and we want to handle
+ // the toggling through the click handler (also for double click), so
+ // capture the dblclick event before it bubbles up and causes the
+ // tree.js dblclick handler to toggle open state.
+ event.stopPropagation();
+ },
+ true
+ );
+
+ // The mousedown event listener below should only be added in the thread
+ // pane of the mailnews 3pane window, not in the advanced search window.
+ if (tree.parentNode.id == "searchResultListBox") {
+ return;
+ }
+
+ tree.addEventListener("mousedown", TreeOnMouseDown, true);
+ let delay = Services.prefs.getIntPref("mailnews.threadpane_select_delay");
+ document.getElementById("threadTree")._selectDelay = delay;
+}
+
+function ThreadPaneSelectionChanged() {
+ GetThreadTree().view.selectionChanged();
+ UpdateSelectCol();
+ UpdateMailSearch();
+}
+
+function UpdateSelectCol() {
+ let selectCol = document.getElementById("selectCol");
+ if (!selectCol) {
+ return;
+ }
+ let treeView = gFolderDisplay.tree.view;
+ let selection = treeView.selection;
+ if (selection && selection.count > 0) {
+ if (treeView.rowCount == selection.count) {
+ selectCol.classList.remove("someselected");
+ selectCol.classList.add("allselected");
+ } else {
+ selectCol.classList.remove("allselected");
+ selectCol.classList.add("someselected");
+ }
+ } else {
+ selectCol.classList.remove("allselected");
+ selectCol.classList.remove("someselected");
+ }
+}
+
+function ConvertSortTypeToColumnID(sortKey) {
+ var columnID;
+
+ // Hack to turn this into an integer, if it was a string.
+ // It would be a string if it came from XULStore.json.
+ sortKey = sortKey - 0;
+
+ switch (sortKey) {
+ // In the case of None, we default to the date column
+ // This appears to be the case in such instances as
+ // Global search, so don't complain about it.
+ case Ci.nsMsgViewSortType.byNone:
+ case Ci.nsMsgViewSortType.byDate:
+ columnID = "dateCol";
+ break;
+ case Ci.nsMsgViewSortType.byReceived:
+ columnID = "receivedCol";
+ break;
+ case Ci.nsMsgViewSortType.byAuthor:
+ columnID = "senderCol";
+ break;
+ case Ci.nsMsgViewSortType.byRecipient:
+ columnID = "recipientCol";
+ break;
+ case Ci.nsMsgViewSortType.bySubject:
+ columnID = "subjectCol";
+ break;
+ case Ci.nsMsgViewSortType.byLocation:
+ columnID = "locationCol";
+ break;
+ case Ci.nsMsgViewSortType.byAccount:
+ columnID = "accountCol";
+ break;
+ case Ci.nsMsgViewSortType.byUnread:
+ columnID = "unreadButtonColHeader";
+ break;
+ case Ci.nsMsgViewSortType.byStatus:
+ columnID = "statusCol";
+ break;
+ case Ci.nsMsgViewSortType.byTags:
+ columnID = "tagsCol";
+ break;
+ case Ci.nsMsgViewSortType.bySize:
+ columnID = "sizeCol";
+ break;
+ case Ci.nsMsgViewSortType.byPriority:
+ columnID = "priorityCol";
+ break;
+ case Ci.nsMsgViewSortType.byFlagged:
+ columnID = "flaggedCol";
+ break;
+ case Ci.nsMsgViewSortType.byThread:
+ columnID = "threadCol";
+ break;
+ case Ci.nsMsgViewSortType.byId:
+ columnID = "idCol";
+ break;
+ case Ci.nsMsgViewSortType.byJunkStatus:
+ columnID = "junkStatusCol";
+ break;
+ case Ci.nsMsgViewSortType.byAttachments:
+ columnID = "attachmentCol";
+ break;
+ case Ci.nsMsgViewSortType.byCustom:
+ // TODO: either change try() catch to if (property exists) or restore the getColumnHandler() check
+ try {
+ // getColumnHandler throws an error when the ID is not handled
+ columnID = window.gDBView.curCustomColumn;
+ } catch (err) {
+ // error - means no handler
+ dump(
+ "ConvertSortTypeToColumnID: custom sort key but no handler for column '" +
+ columnID +
+ "'\n"
+ );
+ columnID = "dateCol";
+ }
+
+ break;
+ case Ci.nsMsgViewSortType.byCorrespondent:
+ columnID = "correspondentCol";
+ break;
+ default:
+ dump("unsupported sort key: " + sortKey + "\n");
+ columnID = "dateCol";
+ break;
+ }
+ return columnID;
+}
+
+addEventListener("load", ThreadPaneOnLoad, true);
diff --git a/comm/mail/base/content/threadTree.inc.xhtml b/comm/mail/base/content/threadTree.inc.xhtml
new file mode 100644
index 0000000000..6151847887
--- /dev/null
+++ b/comm/mail/base/content/threadTree.inc.xhtml
@@ -0,0 +1,230 @@
+# 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 threadTree is shared with messenger.xhtml (MAIN_WINDOW)
+ and SearchDialog.xhtml (SEARCH_WINDOW). -->
+ <tree id="threadTree"
+ class="plain"
+ persist="lastfoldersent width"
+ treelines="true"
+ enableColumnDrag="true"
+ _selectDelay="250"
+ lastfoldersent="false"
+ keepcurrentinview="true"
+ disableKeyNavigation="true"
+ onkeydown="ThreadPaneKeyDown(event);"
+ onselect="ThreadPaneSelectionChanged();">
+#ifdef MAIN_WINDOW
+ <treecols is="thread-pane-treecols" id="threadCols"
+#else
+ <treecols id="threadCols"
+#endif
+ pickertooltiptext="&columnChooser2.tooltip;">
+
+ <!--
+ The below code may suggest that 'ordinal' is still a supported XUL
+ XUL attribute. It is not. This is a crutch so that we can
+ continue persisting the CSS -moz-box-ordinal-group attribute,
+ which is the appropriate replacement for the ordinal attribute
+ but cannot yet be easily persisted. The code that synchronizes
+ the attribute with the CSS lives in
+ toolkit/content/widget/tree.js and is specific to tree elements.
+ -->
+ <treecol is="treecol-image" id="selectCol"
+ class="thread-tree-icon-header selectColumnHeader"
+ persist="hidden ordinal"
+ fixed="true"
+ cycler="true"
+ currentView="unthreaded"
+ hidden="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/compact/checkbox.svg"
+ label="&selectColumn.label;"
+ tooltiptext="&selectColumn.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="threadCol"
+ class="thread-tree-icon-header threadColumnHeader"
+ persist="hidden ordinal"
+ fixed="true"
+ cycler="true"
+ currentView="unthreaded"
+#ifdef SEARCH_WINDOW
+ ignoreincolumnpicker="true"
+ hidden="true"
+#endif
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/thread-sm.svg"
+ label="&threadColumn.label;"
+ tooltiptext="&threadColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="flaggedCol"
+ class="thread-tree-icon-header flagColumnHeader"
+ persist="hidden ordinal sortDirection"
+ fixed="true"
+ cycler="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/star-sm.svg"
+ label="&starredColumn.label;"
+ tooltiptext="&starredColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="attachmentCol"
+ class="thread-tree-icon-header attachmentColumnHeader"
+ persist="hidden ordinal sortDirection"
+ fixed="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/attachment-sm.svg"
+ label="&attachmentColumn.label;"
+ tooltiptext="&attachmentColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="subjectCol"
+ persist="ordinal width sortDirection"
+ ignoreincolumnpicker="true"
+ closemenu="none"
+ label="&subjectColumn.label;"
+ tooltiptext="&subjectColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="unreadButtonColHeader"
+ class="thread-tree-icon-header readColumnHeader"
+ persist="hidden ordinal sortDirection"
+ fixed="true"
+ cycler="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/unread-sm.svg"
+ label="&readColumn.label;"
+ tooltiptext="&readColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="senderCol"
+ persist="hidden ordinal sortDirection width"
+ hidden="true"
+ closemenu="none"
+ label="&fromColumn.label;"
+ tooltiptext="&fromColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="recipientCol"
+ persist="hidden ordinal sortDirection width"
+ hidden="true"
+ closemenu="none"
+ label="&recipientColumn.label;"
+ tooltiptext="&recipientColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="correspondentCol"
+ persist="hidden ordinal sortDirection width"
+ closemenu="none"
+ label="&correspondentColumn.label;"
+ tooltiptext="&correspondentColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="junkStatusCol"
+ class="thread-tree-icon-header junkStatusHeader"
+ persist="hidden ordinal sortDirection"
+ fixed="true"
+ cycler="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/spam-sm.svg"
+ label="&junkStatusColumn.label;"
+ tooltiptext="&junkStatusColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="receivedCol"
+ persist="hidden ordinal sortDirection width"
+ hidden="true"
+ closemenu="none"
+ label="&receivedColumn.label;"
+ tooltiptext="&receivedColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="dateCol"
+ persist="hidden ordinal sortDirection width"
+ closemenu="none"
+ label="&dateColumn.label;"
+ tooltiptext="&dateColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="statusCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&statusColumn.label;"
+ tooltiptext="&statusColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="sizeCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&sizeColumn.label;"
+ tooltiptext="&sizeColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="tagsCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&tagsColumn.label;"
+ tooltiptext="&tagsColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="accountCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&accountColumn.label;"
+ tooltiptext="&accountColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="priorityCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&priorityColumn.label;"
+ tooltiptext="&priorityColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="unreadCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&unreadColumn.label;"
+ tooltiptext="&unreadColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="totalCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&totalColumn.label;"
+ tooltiptext="&totalColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="locationCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&locationColumn.label;"
+ tooltiptext="&locationColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="idCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&idColumn.label;"
+ tooltiptext="&idColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="deleteCol"
+ class="thread-tree-icon-header deleteColumnHeader"
+ persist="hidden ordinal"
+ fixed="true"
+ cycler="true"
+ currentView="unthreaded"
+ hidden="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/trash-sm.svg"
+ label="&deleteColumn.label;"
+ tooltiptext="&deleteColumn.tooltip;"/>
+ </treecols>
+#ifdef MAIN_WINDOW
+ <treechildren ondragstart="ThreadPaneOnDragStart(event);"
+ ondragover="ThreadPaneOnDragOver(event);"
+ ondrop="ThreadPaneOnDrop(event);"/>
+#else
+ <treechildren ondragstart="ThreadPaneOnDragStart(event);"/>
+#endif
+ </tree>
diff --git a/comm/mail/base/content/toolbarIconColor.js b/comm/mail/base/content/toolbarIconColor.js
new file mode 100644
index 0000000000..591c86096d
--- /dev/null
+++ b/comm/mail/base/content/toolbarIconColor.js
@@ -0,0 +1,166 @@
+/**
+ * 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 ToolbarIconColor = {
+ _windowState: {
+ active: false,
+ fullscreen: false,
+ tabsintitlebar: false,
+ },
+
+ init() {
+ this._initialized = true;
+
+ window.addEventListener("activate", this);
+ window.addEventListener("deactivate", this);
+ window.addEventListener("toolbarvisibilitychange", this);
+ window.addEventListener("windowlwthemeupdate", this);
+
+ // If the window isn't active now, we assume that it has never been active
+ // before and will soon become active such that inferFromText will be
+ // called from the initial activate event.
+ if (Services.focus.activeWindow == window) {
+ this.inferFromText("activate");
+ }
+ },
+
+ uninit() {
+ this._initialized = false;
+
+ window.removeEventListener("activate", this);
+ window.removeEventListener("deactivate", this);
+ window.removeEventListener("toolbarvisibilitychange", this);
+ window.removeEventListener("windowlwthemeupdate", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "activate":
+ case "deactivate":
+ case "windowlwthemeupdate":
+ this.inferFromText(event.type);
+ break;
+ case "toolbarvisibilitychange":
+ this.inferFromText(event.type, event.visible);
+ break;
+ }
+ },
+
+ // A cache of luminance values for each toolbar to avoid unnecessary calls to
+ // getComputedStyle().
+ _toolbarLuminanceCache: new Map(),
+
+ // A cache of the current sidebar color to avoid unnecessary conditions and
+ // luminance calculations.
+ _sidebarColorCache: null,
+
+ inferFromText(reason, reasonValue) {
+ if (!this._initialized) {
+ return;
+ }
+
+ function parseRGB(aColorString) {
+ let rgb = aColorString.match(/^rgba?\((\d+), (\d+), (\d+)/);
+ rgb.shift();
+ return rgb.map(x => parseInt(x));
+ }
+
+ switch (reason) {
+ case "activate": // falls through.
+ case "deactivate":
+ this._windowState.active = reason === "activate";
+ break;
+ case "fullscreen":
+ this._windowState.fullscreen = reasonValue;
+ break;
+ case "windowlwthemeupdate":
+ // Theme change, we'll need to recalculate all color values.
+ this._toolbarLuminanceCache.clear();
+ this._sidebarColorCache = null;
+ break;
+ case "toolbarvisibilitychange":
+ // Toolbar changes dont require reset of the cached color values.
+ break;
+ case "tabsintitlebar":
+ this._windowState.tabsintitlebar = reasonValue;
+ break;
+ }
+
+ let toolbarSelector = "toolbox > toolbar:not([collapsed=true])";
+ if (AppConstants.platform == "macosx") {
+ toolbarSelector += ":not([type=menubar])";
+ }
+ toolbarSelector += ", .toolbar";
+
+ // The getComputedStyle calls and setting the brighttext are separated in
+ // two loops to avoid flushing layout and making it dirty repeatedly.
+ let cachedLuminances = this._toolbarLuminanceCache;
+ let luminances = new Map();
+ for (let toolbar of document.querySelectorAll(toolbarSelector)) {
+ // Toolbars *should* all have ids, but guard anyway to avoid blowing up.
+ let cacheKey =
+ toolbar.id && toolbar.id + JSON.stringify(this._windowState);
+ // Lookup cached luminance value for this toolbar in this window state.
+ let luminance = cacheKey && cachedLuminances.get(cacheKey);
+ if (isNaN(luminance)) {
+ let [r, g, b] = parseRGB(getComputedStyle(toolbar).color);
+ luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b;
+ if (cacheKey) {
+ cachedLuminances.set(cacheKey, luminance);
+ }
+ }
+ luminances.set(toolbar, luminance);
+ }
+
+ const luminanceThreshold = 127; // In between 0 and 255
+ for (let [toolbar, luminance] of luminances) {
+ if (luminance <= luminanceThreshold) {
+ toolbar.removeAttribute("brighttext");
+ } else {
+ toolbar.setAttribute("brighttext", "true");
+ }
+ }
+
+ // On Linux, we need to detect if the OS theme caused a text color change in
+ // the sidebar icons and properly update the brighttext attribute.
+ if (
+ reason == "activate" &&
+ AppConstants.platform == "linux" &&
+ Services.prefs.getCharPref("extensions.activeThemeID", "") ==
+ "default-theme@mozilla.org"
+ ) {
+ let folderTree = document.getElementById("folderTree");
+ if (!folderTree) {
+ return;
+ }
+
+ let sidebarColor = getComputedStyle(folderTree).color;
+ // Interrupt if the sidebar color didn't change.
+ if (sidebarColor == this._sidebarColorCache) {
+ return;
+ }
+
+ this._sidebarColorCache = sidebarColor;
+
+ let mainWindow = document.getElementById("messengerWindow");
+ if (!mainWindow) {
+ return;
+ }
+
+ let [r, g, b] = parseRGB(sidebarColor);
+ let luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b;
+
+ if (luminance <= 110) {
+ mainWindow.removeAttribute("lwt-tree-brighttext");
+ } else {
+ mainWindow.setAttribute("lwt-tree-brighttext", "true");
+ }
+ }
+ },
+};
diff --git a/comm/mail/base/content/troubleshootMode.js b/comm/mail/base/content/troubleshootMode.js
new file mode 100644
index 0000000000..79343f3004
--- /dev/null
+++ b/comm/mail/base/content/troubleshootMode.js
@@ -0,0 +1,74 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 { XPIDatabase } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+);
+
+function restartApp() {
+ Services.startup.quit(
+ Services.startup.eForceQuit | Services.startup.eRestart
+ );
+}
+
+function deleteLocalstore() {
+ // Delete the xulstore file.
+ let xulstoreFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ xulstoreFile.append("xulstore.json");
+ if (xulstoreFile.exists()) {
+ xulstoreFile.remove(false);
+ }
+}
+
+async function disableAddons() {
+ XPIDatabase.syncLoadDB(false);
+ let addons = XPIDatabase.getAddons();
+ for (let addon of addons) {
+ if (addon.type == "theme") {
+ // Setting userDisabled to false on the default theme activates it,
+ // disables all other themes and deactivates the applied persona, if
+ // any.
+ const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+ if (addon.id == DEFAULT_THEME_ID) {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ userDisabled: false,
+ });
+ }
+ } else {
+ await XPIDatabase.updateAddonDisabledState(addon, { userDisabled: true });
+ }
+ }
+}
+
+async function onOK(event) {
+ event.preventDefault();
+ if (document.getElementById("resetToolbars").checked) {
+ deleteLocalstore();
+ }
+ if (document.getElementById("disableAddons").checked) {
+ await disableAddons();
+ }
+ restartApp();
+}
+
+function onCancel() {
+ Services.startup.quit(Services.startup.eForceQuit);
+}
+
+function onLoad() {
+ document
+ .getElementById("tasks")
+ .addEventListener("CheckboxStateChange", updateOKButtonState);
+
+ document.addEventListener("dialogaccept", onOK);
+ document.addEventListener("dialogcancel", onCancel);
+ document.addEventListener("dialogextra1", () => window.close());
+}
+
+function updateOKButtonState() {
+ document.querySelector("dialog").getButton("accept").disabled =
+ !document.getElementById("resetToolbars").checked &&
+ !document.getElementById("disableAddons").checked;
+}
diff --git a/comm/mail/base/content/troubleshootMode.xhtml b/comm/mail/base/content/troubleshootMode.xhtml
new file mode 100644
index 0000000000..767adced2c
--- /dev/null
+++ b/comm/mail/base/content/troubleshootMode.xhtml
@@ -0,0 +1,54 @@
+<?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/. -->
+
+<!DOCTYPE window [ <!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd">
+%utilityDTD; ]>
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="troubleshoot-mode-window"
+ data-l10n-attrs="title,style"
+ onload="onLoad();"
+>
+ <dialog
+ id="safeModeDialog"
+ style="width: inherit"
+ buttons="accept,cancel,extra1"
+ buttonidaccept="troubleshoot-mode-change-and-restart"
+ buttonidcancel="troubleshoot-mode-quit"
+ buttonidextra1="troubleshoot-mode-continue"
+ buttondisabledaccept="true"
+ >
+ <script src="chrome://messenger/content/troubleshootMode.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="messenger/troubleshootMode.ftl" />
+ </linkset>
+
+ <vbox>
+ <description data-l10n-id="troubleshoot-mode-description" />
+
+ <separator class="thin" />
+
+ <label data-l10n-id="troubleshoot-mode-description2" />
+ <vbox id="tasks">
+ <checkbox
+ id="disableAddons"
+ data-l10n-id="troubleshoot-mode-disable-addons"
+ />
+ <checkbox
+ id="resetToolbars"
+ data-l10n-id="troubleshoot-mode-reset-toolbars"
+ />
+ </vbox>
+ </vbox>
+
+ <separator class="thin" />
+ </dialog>
+</window>
diff --git a/comm/mail/base/content/utilityOverlay.js b/comm/mail/base/content/utilityOverlay.js
new file mode 100644
index 0000000000..b0593647e1
--- /dev/null
+++ b/comm/mail/base/content/utilityOverlay.js
@@ -0,0 +1,514 @@
+/* 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 goUpdateCommand */ // From globalOverlay.js
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+
+var gShowBiDi = false;
+
+function getBrowserURL() {
+ return AppConstants.BROWSER_CHROME_URL;
+}
+
+// update menu items that rely on focus
+function goUpdateGlobalEditMenuItems() {
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_redo");
+ goUpdateCommand("cmd_cut");
+ goUpdateCommand("cmd_copy");
+ goUpdateCommand("cmd_paste");
+ goUpdateCommand("cmd_selectAll");
+ goUpdateCommand("cmd_delete");
+ if (gShowBiDi) {
+ goUpdateCommand("cmd_switchTextDirection");
+ }
+}
+
+// update menu items that rely on the current selection
+function goUpdateSelectEditMenuItems() {
+ goUpdateCommand("cmd_cut");
+ goUpdateCommand("cmd_copy");
+ goUpdateCommand("cmd_delete");
+ goUpdateCommand("cmd_selectAll");
+}
+
+// update menu items that relate to undo/redo
+function goUpdateUndoEditMenuItems() {
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_redo");
+}
+
+// update menu items that depend on clipboard contents
+function goUpdatePasteMenuItems() {
+ goUpdateCommand("cmd_paste");
+}
+
+// update Find As You Type menu items, they rely on focus
+function goUpdateFindTypeMenuItems() {
+ goUpdateCommand("cmd_findTypeText");
+ goUpdateCommand("cmd_findTypeLinks");
+}
+
+/**
+ * Gather all descendent text under given node.
+ *
+ * @param {Node} root - The root node to gather text from.
+ * @returns {string} The text data under the node.
+ */
+function gatherTextUnder(root) {
+ var text = "";
+ var node = root.firstChild;
+ var depth = 1;
+ while (node && depth > 0) {
+ // See if this node is text.
+ if (node.nodeType == Node.TEXT_NODE) {
+ // Add this text to our collection.
+ text += " " + node.data;
+ } else if (HTMLImageElement.isInstance(node)) {
+ // If it has an alt= attribute, add that.
+ var altText = node.getAttribute("alt");
+ if (altText && altText != "") {
+ text += " " + altText;
+ }
+ }
+ // Find next node to test.
+ if (node.firstChild) {
+ // If it has children, go to first child.
+ node = node.firstChild;
+ depth++;
+ } else if (node.nextSibling) {
+ // No children, try next sibling.
+ node = node.nextSibling;
+ } else {
+ // Last resort is a sibling of an ancestor.
+ while (node && depth > 0) {
+ node = node.parentNode;
+ depth--;
+ if (node.nextSibling) {
+ node = node.nextSibling;
+ break;
+ }
+ }
+ }
+ }
+ // Strip leading and trailing whitespace.
+ text = text.trim();
+ // Compress remaining whitespace.
+ text = text.replace(/\s+/g, " ");
+ return text;
+}
+
+function GenerateValidFilename(filename, extension) {
+ if (filename) {
+ // we have a title; let's see if it's usable
+ // clean up the filename to make it usable and
+ // then trim whitespace from beginning and end
+ filename = validateFileName(filename).trim();
+ if (filename.length > 0) {
+ return filename + extension;
+ }
+ }
+ return null;
+}
+
+function validateFileName(aFileName) {
+ var re = /[\/]+/g;
+ if (navigator.appVersion.includes("Windows")) {
+ re = /[\\\/\|]+/g;
+ aFileName = aFileName.replace(/[\"]+/g, "'");
+ aFileName = aFileName.replace(/[\*\:\?]+/g, " ");
+ aFileName = aFileName.replace(/[\<]+/g, "(");
+ aFileName = aFileName.replace(/[\>]+/g, ")");
+ } else if (navigator.appVersion.includes("Macintosh")) {
+ re = /[\:\/]+/g;
+ }
+
+ if (
+ Services.prefs.getBoolPref("mail.save_msg_filename_underscores_for_space")
+ ) {
+ aFileName = aFileName.replace(/ /g, "_");
+ }
+
+ return aFileName.replace(re, "_");
+}
+
+function goToggleToolbar(id, elementID) {
+ var toolbar = document.getElementById(id);
+ var element = document.getElementById(elementID);
+ if (toolbar) {
+ const isHidden = toolbar.getAttribute("hidden") === "true";
+ toolbar.setAttribute("hidden", !isHidden);
+ Services.xulStore.persist(toolbar, "hidden");
+ if (element) {
+ element.setAttribute("checked", isHidden);
+ Services.xulStore.persist(element, "checked");
+ }
+ }
+}
+
+/**
+ * Toggle a splitter to show or hide some piece of UI (e.g. the message preview
+ * pane).
+ *
+ * @param splitterId the splliter that should be toggled
+ */
+function togglePaneSplitter(splitterId) {
+ var splitter = document.getElementById(splitterId);
+ var state = splitter.getAttribute("state");
+ if (state == "collapsed") {
+ splitter.setAttribute("state", "open");
+ } else {
+ splitter.setAttribute("state", "collapsed");
+ }
+}
+
+// openUILink handles clicks on UI elements that cause URLs to load.
+// We currently only react to left click in Thunderbird.
+function openUILink(url, event) {
+ if (!event.button) {
+ PlacesUtils.history
+ .insert({
+ url,
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ })
+ .catch(console.error);
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(url));
+ }
+}
+
+function openLinkText(event, what) {
+ switch (what) {
+ case "getInvolvedURL":
+ openUILink("https://www.thunderbird.net/get-involved/", event);
+ break;
+ case "keyboardShortcutsURL":
+ openUILink("https://support.mozilla.org/kb/keyboard-shortcuts/", event);
+ break;
+ case "donateURL":
+ openUILink(
+ "https://give.thunderbird.net/?utm_source=thunderbird-client&utm_medium=referral&utm_content=help-menu",
+ event
+ );
+ break;
+ case "tourURL":
+ openUILink("https://www.thunderbird.net/features/", event);
+ break;
+ case "feedbackURL":
+ openUILink("https://connect.mozilla.org/", event);
+ break;
+ }
+}
+
+/**
+ * Open a web search in the default browser for a given query.
+ *
+ * @param query the string to search for
+ * @param engine (optional) the search engine to use
+ */
+function openWebSearch(query, engine) {
+ return Services.search.init().then(async () => {
+ if (!engine) {
+ engine = await Services.search.getDefault();
+ openLinkExternally(engine.getSubmission(query).uri.spec);
+
+ Services.telemetry.keyedScalarAdd(
+ "tb.websearch.usage",
+ engine.name.toLowerCase(),
+ 1
+ );
+ }
+ });
+}
+
+/**
+ * Open the specified tab type (possibly in a new window)
+ *
+ * @param tabType the tab type to open (e.g. "contentTab")
+ * @param tabParams the parameters to pass to the tab
+ * @param where 'tab' to open in a new tab (default) or 'window' to open in a
+ * new window
+ */
+function openTab(tabType, tabParams, where) {
+ if (where != "window") {
+ let tabmail = document.getElementById("tabmail");
+ if (!tabmail) {
+ // Try opening new tabs in an existing 3pane window
+ let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mail3PaneWindow) {
+ tabmail = mail3PaneWindow.document.getElementById("tabmail");
+ mail3PaneWindow.focus();
+ }
+ }
+
+ if (tabmail) {
+ return tabmail.openTab(tabType, tabParams);
+ }
+ }
+
+ // Either we explicitly wanted to open in a new window, or we fell through to
+ // here because there's no 3pane.
+ return window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType,
+ tabParams,
+ }
+ );
+}
+
+/**
+ * Open the specified URL as a content tab (or window)
+ *
+ * @param {string} url - The location to open.
+ * @param {string} [where="tab"] - 'tab' to open in a new tab or 'window' to
+ * open in a new window
+ * @param {string} [linkHandler] - See specialTabs.contentTabType.openTab.
+ */
+function openContentTab(url, where, linkHandler) {
+ return openTab("contentTab", { url, linkHandler }, where);
+}
+
+/**
+ * Open the preferences page for the specified query in a new tab.
+ *
+ * @param paneID ID of prefpane to select automatically.
+ * @param scrollPaneTo ID of the element to scroll into view.
+ * @param otherArgs other prefpane specific arguments.
+ */
+function openPreferencesTab(paneID, scrollPaneTo, otherArgs) {
+ openTab("preferencesTab", {
+ paneID,
+ scrollPaneTo,
+ otherArgs,
+ onLoad(aEvent, aBrowser) {
+ aBrowser.contentWindow.selectPrefPane(paneID, scrollPaneTo, otherArgs);
+ },
+ });
+}
+
+/**
+ * Open the dictionary list in a new content tab, if possible in an available
+ * mail:3pane window, otherwise by opening a new mail:3pane.
+ *
+ * @param where the context to open the dictionary list in (e.g. 'tab',
+ * 'window'). See openContentTab for more details.
+ */
+function openDictionaryList(where) {
+ let dictUrl = Services.urlFormatter.formatURLPref(
+ "spellchecker.dictionaries.download.url"
+ );
+
+ openContentTab(dictUrl, where);
+}
+
+/**
+ * Open the privacy policy in a new content tab, if possible in an available
+ * mail:3pane window, otherwise by opening a new mail:3pane.
+ *
+ * @param where the context to open the privacy policy in (e.g. 'tab',
+ * 'window'). See openContentTab for more details.
+ */
+function openPrivacyPolicy(where) {
+ const kTelemetryInfoUrl = "toolkit.telemetry.infoURL";
+ let url = Services.prefs.getCharPref(kTelemetryInfoUrl);
+ openContentTab(url, where);
+}
+
+/**
+ * Used by the developer tools (in the toolbox process) and a few toolkit pages
+ * for opening URLs.
+ *
+ * Thunderbird code should avoid using this function.
+ *
+ * This is similar, but not identical, to the same function in Firefox.
+ *
+ * @param {string} url - The URL to load.
+ * @param {string} [where] - Ignored, only here for compatibility.
+ * @param {object} [openParams] - Optional parameters for changing behaviour.
+ */
+function openTrustedLinkIn(url, where, params = {}) {
+ if (!params.triggeringPrincipal) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ }
+
+ openLinkIn(url, where, params);
+}
+
+/**
+ * Used by the developer tools (in the toolbox process) for opening URLs.
+ * MDN URLs get send to a browser, all others are displayed in a new window.
+ *
+ * Thunderbird code should avoid using this function.
+ *
+ * This is similar, but not identical, to the same function in Firefox.
+ *
+ * @param {string} url - The URL to load.
+ * @param {string} [where] - Ignored, only here for compatibility.
+ * @param {object} [openParams] - Optional parameters for changing behaviour.
+ */
+function openWebLinkIn(url, where, params = {}) {
+ if (url.startsWith("https://developer.mozilla.org/")) {
+ openLinkExternally(url);
+ return;
+ }
+
+ if (!params.triggeringPrincipal) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.createNullPrincipal({});
+ }
+ if (params.triggeringPrincipal.isSystemPrincipal) {
+ throw new Error(
+ "System principal should never be passed into openWebLinkIn()"
+ );
+ }
+
+ openLinkIn(url, where, params);
+}
+
+// Thunderbird itself is not using this function. It is however called for the
+// "contribute" button for add-ons in the add-on manager. We ignore all additional
+// parameters including "where" and always open the link externally. We don't
+// want to open donation pages in a tab due to their complexity, and we don't
+// want to handle them inside Thunderbird.
+function openUILinkIn(
+ url,
+ where,
+ aAllowThirdPartyFixup,
+ aPostData,
+ aReferrerInfo
+) {
+ openLinkExternally(url);
+}
+
+/**
+ * Loads a URL in Thunderbird. If this is a mail:3pane window, the URL opens
+ * in a content tab, otherwise a new window is opened.
+ *
+ * This is similar, but not identical, to the same function in Firefox.
+ *
+ * @param {string} url - The URL to load.
+ * @param {string} [where] - Ignored, only here for compatibility.
+ * @param {object} [openParams] - Optional parameters for changing behaviour.
+ */
+function openLinkIn(url, where, openParams) {
+ if (!url) {
+ return;
+ }
+
+ if ("switchToTabHavingURI" in window) {
+ window.switchToTabHavingURI(url, true);
+ return;
+ }
+
+ // If we get here, this isn't a mail:3pane window, which means it's probably
+ // the developer tools window and therefore a completely separate program
+ // from the rest of Thunderbird. Be careful what you do here.
+
+ let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ let uri = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ uri.data = url;
+ args.appendElement(uri);
+
+ let win = Services.ww.openWindow(
+ window,
+ AppConstants.BROWSER_CHROME_URL,
+ null,
+ "chrome,dialog=no,all",
+ args
+ );
+
+ if (openParams.resolveOnContentBrowserCreated) {
+ win.addEventListener("load", () =>
+ openParams.resolveOnContentBrowserCreated(win.gBrowser.selectedBrowser)
+ );
+ }
+}
+
+/**
+ * Forces a url to open in an external application according to the protocol
+ * service settings.
+ *
+ * @param url A url string or an nsIURI containing the url to open.
+ */
+function openLinkExternally(url) {
+ let uri = url;
+ if (!(uri instanceof Ci.nsIURI)) {
+ uri = Services.io.newURI(url);
+ }
+
+ // This can fail if there is a problem with the places database.
+ PlacesUtils.history
+ .insert({
+ url, // accepts both string and nsIURI
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ })
+ .catch(console.error);
+
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+}
+
+/**
+ * Moved from toolkit/content/globalOverlay.js.
+ * For details see bug 1422720 and bug 1422721.
+ */
+function goSetMenuValue(aCommand, aLabelAttribute) {
+ var commandNode = top.document.getElementById(aCommand);
+ if (commandNode) {
+ var label = commandNode.getAttribute(aLabelAttribute);
+ if (label) {
+ commandNode.setAttribute("label", label);
+ }
+ }
+}
+
+function goSetAccessKey(aCommand, aAccessKeyAttribute) {
+ var commandNode = top.document.getElementById(aCommand);
+ if (commandNode) {
+ var value = commandNode.getAttribute(aAccessKeyAttribute);
+ if (value) {
+ commandNode.setAttribute("accesskey", value);
+ }
+ }
+}
+
+function buildHelpMenu() {
+ let helpTroubleshootModeItem = document.getElementById(
+ "helpTroubleshootMode"
+ );
+ if (helpTroubleshootModeItem) {
+ helpTroubleshootModeItem.disabled =
+ !Services.policies.isAllowed("safeMode");
+ }
+ let appmenu_troubleshootModeItem = document.getElementById(
+ "appmenu_troubleshootMode"
+ );
+ if (appmenu_troubleshootModeItem) {
+ appmenu_troubleshootModeItem.disabled =
+ !Services.policies.isAllowed("safeMode");
+ }
+}
diff --git a/comm/mail/base/content/viewSource.js b/comm/mail/base/content/viewSource.js
new file mode 100644
index 0000000000..8fd3a9ccde
--- /dev/null
+++ b/comm/mail/base/content/viewSource.js
@@ -0,0 +1,168 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+
+/* 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 gViewSourceUtils, internalSave, ZoomManager */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://messenger/content/printUtils.js"
+);
+
+// Needed for printing.
+window.browserDOMWindow = window.opener.browserDOMWindow;
+
+var gBrowser;
+addEventListener("load", () => {
+ gBrowser = document.getElementById("content");
+ gBrowser.getTabForBrowser = () => {
+ return null;
+ };
+ gBrowser.addEventListener("pagetitlechanged", () => {
+ document.title =
+ document.documentElement.getAttribute("titlepreface") +
+ gBrowser.contentTitle +
+ document.documentElement.getAttribute("titlemenuseparator") +
+ document.documentElement.getAttribute("titlemodifier");
+ });
+
+ if (Services.prefs.getBoolPref("view_source.wrap_long_lines", false)) {
+ document
+ .getElementById("cmd_wrapLongLines")
+ .setAttribute("checked", "true");
+ }
+
+ gViewSourceUtils.viewSourceInBrowser({
+ ...window.arguments[0],
+ viewSourceBrowser: gBrowser,
+ });
+ gBrowser.contentWindow.focus();
+
+ document
+ .getElementById("repair-text-encoding")
+ .setAttribute("disabled", !gBrowser.mayEnableCharacterEncodingMenu);
+ gBrowser.addEventListener(
+ "load",
+ () => {
+ document
+ .getElementById("repair-text-encoding")
+ .setAttribute("disabled", !gBrowser.mayEnableCharacterEncodingMenu);
+ },
+ true
+ );
+
+ gBrowser.addEventListener(
+ "DoZoomEnlargeBy10",
+ () => {
+ ZoomManager.scrollZoomEnlarge(gBrowser);
+ },
+ true
+ );
+ gBrowser.addEventListener(
+ "DoZoomReduceBy10",
+ () => {
+ ZoomManager.scrollReduceEnlarge(gBrowser);
+ },
+ true
+ );
+});
+
+var viewSourceChrome = {
+ promptAndGoToLine() {
+ let actor = gViewSourceUtils.getViewSourceActor(gBrowser.browsingContext);
+ actor.manager.getActor("ViewSourcePage").promptAndGoToLine();
+ },
+
+ toggleWrapping() {
+ let state = gBrowser.contentDocument.body.classList.toggle("wrap");
+ if (state) {
+ document
+ .getElementById("cmd_wrapLongLines")
+ .setAttribute("checked", "true");
+ } else {
+ document.getElementById("cmd_wrapLongLines").removeAttribute("checked");
+ }
+ Services.prefs.setBoolPref("view_source.wrap_long_lines", state);
+ },
+
+ /**
+ * Called by clicks on a menuitem to force the character set detection.
+ */
+ onForceCharacterSet() {
+ gBrowser.forceEncodingDetection();
+ gBrowser.reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE);
+ },
+
+ /**
+ * Reloads the browser, bypassing the network cache.
+ */
+ reload() {
+ gBrowser.reloadWithFlags(
+ Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY |
+ Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE
+ );
+ },
+};
+
+// viewZoomOverlay.js uses this
+function getBrowser() {
+ return gBrowser;
+}
+
+// Strips the |view-source:| for internalSave()
+function ViewSourceSavePage() {
+ internalSave(
+ gBrowser.currentURI.spec.replace(/^view-source:/i, ""),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ "SaveLinkTitle",
+ null,
+ null,
+ gBrowser.cookieJarSettings,
+ gBrowser.contentDocument,
+ null,
+ gBrowser.webNavigation.QueryInterface(Ci.nsIWebPageDescriptor),
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+}
+
+/** Called by ContextMenuParent.sys.mjs */
+function openContextMenu({ data }, browser, actor) {
+ let popup = browser.ownerDocument.getElementById("viewSourceContextMenu");
+
+ let newEvent = document.createEvent("MouseEvent");
+ let screenX = data.context.screenXDevPx / window.devicePixelRatio;
+ let screenY = data.context.screenYDevPx / window.devicePixelRatio;
+ newEvent.initNSMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ null,
+ 0,
+ screenX,
+ screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 2,
+ null,
+ 0,
+ data.context.mozInputSource
+ );
+ popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
+}
diff --git a/comm/mail/base/content/viewSource.xhtml b/comm/mail/base/content/viewSource.xhtml
new file mode 100644
index 0000000000..046ad41937
--- /dev/null
+++ b/comm/mail/base/content/viewSource.xhtml
@@ -0,0 +1,245 @@
+<?xml version="1.0"?>
+# -*- Mode: HTML -*-
+# 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://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE window [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+%brandDTD;
+<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd">
+%baseMenuOverlayDTD;
+<!ENTITY % sourceDTD SYSTEM "chrome://messenger/locale/viewSource.dtd" >
+%sourceDTD;
+]>
+
+<window id="viewSource"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ contenttitlesetting="true"
+ title="&mainWindow.title;"
+ titlemodifier="&mainWindow.titlemodifier;"
+ titlepreface="&mainWindow.preface;"
+ titlemenuseparator ="&mainWindow.titlemodifierseparator;"
+ windowtype="navigator:view-source"
+ width="640" height="480"
+ screenX="10" screenY="10"
+ persist="screenX screenY width height sizemode">
+
+<linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="messenger/messenger.ftl"/>
+ <html:link rel="localization" href="messenger/menubar.ftl"/>
+ <html:link rel="localization" href="messenger/appmenu.ftl"/>
+ <html:link rel="localization" href="messenger/viewSource.ftl"/>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="toolkit/printing/printUI.ftl" />
+</linkset>
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://messenger/content/mailCore.js"/>
+ <script src="chrome://messenger/content/viewSource.js"/>
+ <script src="chrome://messenger/content/viewZoomOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+ <stringbundle id="viewSourceBundle" src="chrome://messenger/locale/viewSource.properties"/>
+
+ <command id="cmd_savePage" oncommand="ViewSourceSavePage();"/>
+ <command id="cmd_print" oncommand="PrintUtils.startPrintWindow(gBrowser.browsingContext, {});"/>
+ <command id="cmd_close" oncommand="window.close();"/>
+ <command id="cmd_find"
+ oncommand="document.getElementById('FindToolbar').onFindCommand();"/>
+ <command id="cmd_findAgain"
+ oncommand="document.getElementById('FindToolbar').onFindAgainCommand(false);"/>
+ <command id="cmd_findPrevious"
+ oncommand="document.getElementById('FindToolbar').onFindAgainCommand(true);"/>
+#ifdef XP_MACOSX
+ <command id="cmd_findSelection"
+ oncommand="document.getElementById('FindToolbar').onFindSelectionCommand();"/>
+#endif
+ <command id="cmd_reload" oncommand="viewSourceChrome.reload();"/>
+ <command id="cmd_goToLine" oncommand="viewSourceChrome.promptAndGoToLine();"/>
+ <command id="cmd_wrapLongLines" oncommand="viewSourceChrome.toggleWrapping();"/>
+ <command id="cmd_textZoomReduce" oncommand="ZoomManager.reduce();"/>
+ <command id="cmd_textZoomEnlarge" oncommand="ZoomManager.enlarge();"/>
+ <command id="cmd_textZoomReset" oncommand="ZoomManager.reset();"/>
+
+ <keyset id="viewSourceKeys">
+ <key id="key_savePage" key="&savePageCmd.commandkey;" modifiers="accel" command="cmd_savePage"/>
+ <key id="key_print" key="&printCmd.commandkey;" modifiers="accel" command="cmd_print"/>
+ <key id="key_close" key="&closeCmd.commandkey;" modifiers="accel" command="cmd_close"/>
+ <key id="key_goToLine" key="&goToLineCmd.commandkey;" command="cmd_goToLine" modifiers="accel"/>
+
+ <key id="key_textZoomEnlarge" key="&textEnlarge.commandkey;" command="cmd_textZoomEnlarge" modifiers="accel"/>
+ <key id="key_textZoomEnlarge2" key="&textEnlarge.commandkey2;" command="cmd_textZoomEnlarge" modifiers="accel"/>
+ <key id="key_textZoomEnlarge3" key="&textEnlarge.commandkey3;" command="cmd_textZoomEnlarge" modifiers="accel"/>
+ <key id="key_textZoomReduce" key="&textReduce.commandkey;" command="cmd_textZoomReduce" modifiers="accel"/>
+ <key id="key_textZoomReduce2" key="&textReduce.commandkey2;" command="cmd_textZoomReduce" modifiers="accel"/>
+ <key id="key_textZoomReset" key="&textReset.commandkey;" command="cmd_textZoomReset" modifiers="accel"/>
+ <key id="key_textZoomReset2" key="&textReset.commandkey2;" command="cmd_textZoomReset" modifiers="accel"/>
+
+ <key id="key_reload" key="&reloadCmd.commandkey;" command="cmd_reload" modifiers="accel"/>
+ <key key="&reloadCmd.commandkey;" command="cmd_reload" modifiers="accel,shift"/>
+ <key keycode="VK_F5" command="cmd_reload"/>
+ <key keycode="VK_F5" command="cmd_reload" modifiers="accel"/>
+
+ <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" command="cmd_copy"/>
+ <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" command="cmd_selectAll"/>
+ <key id="key_find" key="&findOnCmd.commandkey;" command="cmd_find" modifiers="accel"/>
+ <key id="key_findAgain" key="&findAgainCmd.commandkey;" command="cmd_findAgain" modifiers="accel"/>
+ <key id="key_findPrevious" key="&findAgainCmd.commandkey;" command="cmd_findPrevious" modifiers="accel,shift"/>
+#ifdef XP_MACOSX
+ <key id="key_findSelection" key="&findSelectionCmd.commandkey;" command="cmd_findSelection" modifiers="accel"/>
+#endif
+ <key keycode="&findAgainCmd.commandkey2;" command="cmd_findAgain"/>
+ <key keycode="&findAgainCmd.commandkey2;" command="cmd_findPrevious" modifiers="shift"/>
+
+ <key keycode="VK_BACK" command="Browser:Back"/>
+ <key keycode="VK_BACK" command="Browser:Forward" modifiers="shift"/>
+#ifndef XP_MACOSX
+ <key id="goBackKb" keycode="VK_LEFT" command="Browser:Back" modifiers="alt"/>
+ <key id="goForwardKb" keycode="VK_RIGHT" command="Browser:Forward" modifiers="alt"/>
+#else
+ <key id="goBackKb" keycode="VK_LEFT" command="Browser:Back" modifiers="accel" />
+ <key id="goForwardKb" keycode="VK_RIGHT" command="Browser:Forward" modifiers="accel" />
+#endif
+#ifdef XP_UNIX
+ <key id="goBackKb2" key="&goBackCmd.commandKey;" command="Browser:Back" modifiers="accel"/>
+ <key id="goForwardKb2" key="&goForwardCmd.commandKey;" command="Browser:Forward" modifiers="accel"/>
+#endif
+ <key id="key_openHelp"
+ oncommand="openSupportURL();"
+#ifdef XP_MACOSX
+ key="&productHelpMac.commandkey;"
+ modifiers="&productHelpMac.modifiers;"/>
+#else
+ keycode="&productHelp.commandkey;"/>
+#endif
+ </keyset>
+
+ <tooltip id="aHTMLTooltip" page="true"/>
+
+ <menupopup id="viewSourceContextMenu">
+ <menuitem id="cMenu_copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuseparator/>
+ <menuitem id="cMenu_selectAll"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem id="cMenu_find"
+ data-l10n-id="context-text-action-find"
+ command="cmd_find"/>
+ <menuitem id="cMenu_findAgain"
+ data-l10n-id="context-text-action-find-again"
+ command="cmd_findAgain"/>
+ </menupopup>
+
+ <!-- Menu -->
+ <toolbox id="viewSource-toolbox">
+ <toolbar type="menubar">
+ <menubar id="viewSource-main-menubar">
+
+ <menu id="menu_file" label="&fileMenu.label;" accesskey="&fileMenu.accesskey;">
+ <menupopup id="menu_FilePopup">
+ <menuitem key="key_savePage" command="cmd_savePage" id="menu_savePage"
+ label="&savePageCmd.label;" accesskey="&savePageCmd.accesskey;"/>
+ <menuseparator/>
+ <menuitem key="key_print" command="cmd_print" id="menu_print"
+ label="&printCmd.label;" accesskey="&printCmd.accesskey;"/>
+ <menuseparator/>
+ <menuitem key="key_close" command="cmd_close" id="menu_close"
+ label="&closeCmd.label;" accesskey="&closeCmd.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menu id="menu_edit" label="&editMenu.label;"
+ accesskey="&editMenu.accesskey;">
+ <menupopup id="editmenu-popup">
+ <menuitem id="menu_copy"
+ data-l10n-id="text-action-copy"
+ key="key_copy"
+ command="cmd_copy"/>
+ <menuseparator/>
+ <menuitem id="menu_selectAll"
+ data-l10n-id="text-action-select-all"
+ key="key_selectAll"
+ command="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem id="menu_find"
+ data-l10n-id="text-action-find"
+ key="key_find"
+ command="cmd_find"/>
+ <menuitem id="menu_findAgain"
+ data-l10n-id="text-action-find-again"
+ key="key_findAgain"
+ command="cmd_findAgain"/>
+ <menuseparator/>
+ <menuitem id="menu_goToLine" key="key_goToLine" command="cmd_goToLine"
+ label="&goToLineCmd.label;" accesskey="&goToLineCmd.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menu id="menu_view" label="&viewMenu.label;" accesskey="&viewMenu.accesskey;">
+ <menupopup id="viewmenu-popup">
+ <menuitem id="menu_reload" command="cmd_reload" accesskey="&reloadCmd.accesskey;"
+ label="&reloadCmd.label;" key="key_reload"/>
+ <menuseparator />
+ <menu id="viewTextZoomMenu" label="&menu_textSize.label;" accesskey="&menu_textSize.accesskey;">
+ <menupopup>
+ <menuitem id="menu_textEnlarge" command="cmd_textZoomEnlarge"
+ label="&menu_textEnlarge.label;" accesskey="&menu_textEnlarge.accesskey;"
+ key="key_textZoomEnlarge"/>
+ <menuitem id="menu_textReduce" command="cmd_textZoomReduce"
+ label="&menu_textReduce.label;" accesskey="&menu_textReduce.accesskey;"
+ key="key_textZoomReduce"/>
+ <menuseparator/>
+ <menuitem id="menu_textReset" command="cmd_textZoomReset"
+ label="&menu_textReset.label;" accesskey="&menu_textReset.accesskey;"
+ key="key_textZoomReset"/>
+ </menupopup>
+ </menu>
+
+ <!-- Charset Menu -->
+ <menuitem id="repair-text-encoding"
+ data-l10n-id="menu-view-repair-text-encoding"
+ oncommand="viewSourceChrome.onForceCharacterSet();"/>
+ <menuseparator/>
+ <menuitem id="menu_wrapLongLines" type="checkbox" command="cmd_wrapLongLines"
+ label="&menu_wrapLongLines.title;" accesskey="&menu_wrapLongLines.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menu id="helpMenu"
+ data-l10n-id="menu-help-help-title">
+ <menupopup id="menu_HelpPopup">
+ <menuitem id="menu_openHelp"
+ data-l10n-id="appmenu-help-get-help"
+ key="key_openHelp"
+ oncommand="openSupportURL();"/>
+ </menupopup>
+ </menu>
+ </menubar>
+ </toolbar>
+ </toolbox>
+ <vbox class="printPreviewStack" flex="1">
+ <browser id="content"
+ type="content"
+ name="content"
+ src="about:blank"
+ flex="1"
+ primary="true"
+ disableglobalhistory="true"
+ showcaret="true"
+ tooltip="aHTMLTooltip"
+ maychangeremoteness="true"
+ messagemanagergroup="browsers"/>
+ <findbar id="FindToolbar" browserid="content"/>
+ </vbox>
+
+#include tabDialogs.inc.xhtml
+</window>
diff --git a/comm/mail/base/content/viewZoomOverlay.js b/comm/mail/base/content/viewZoomOverlay.js
new file mode 100644
index 0000000000..523386f82e
--- /dev/null
+++ b/comm/mail/base/content/viewZoomOverlay.js
@@ -0,0 +1,153 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+
+/* 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 getBrowser */
+
+/** Document Zoom Management Code
+ *
+ * Forked from M-C since we don't provide a global gBrowser variable.
+ *
+ * TODO: Move to dedicated js module - see bug 1841768.
+ */
+
+var ZoomManager = {
+ get MIN() {
+ delete this.MIN;
+ return (this.MIN = Services.prefs.getIntPref("zoom.minPercent") / 100);
+ },
+
+ get MAX() {
+ delete this.MAX;
+ return (this.MAX = Services.prefs.getIntPref("zoom.maxPercent") / 100);
+ },
+
+ get useFullZoom() {
+ return Services.prefs.getBoolPref("browser.zoom.full");
+ },
+
+ set useFullZoom(aVal) {
+ Services.prefs.setBoolPref("browser.zoom.full", aVal);
+ },
+
+ get zoom() {
+ return this.getZoomForBrowser(getBrowser());
+ },
+
+ useFullZoomForBrowser(aBrowser) {
+ return this.useFullZoom || aBrowser.isSyntheticDocument;
+ },
+
+ getFullZoomForBrowser(aBrowser) {
+ if (!this.useFullZoomForBrowser(aBrowser)) {
+ return 1.0;
+ }
+ return this.getZoomForBrowser(aBrowser);
+ },
+
+ getZoomForBrowser(aBrowser) {
+ let zoom = this.useFullZoomForBrowser(aBrowser)
+ ? aBrowser.fullZoom
+ : aBrowser.textZoom;
+ // Round to remove any floating-point error.
+ return Number(zoom ? zoom.toFixed(2) : 1);
+ },
+
+ set zoom(aVal) {
+ this.setZoomForBrowser(getBrowser(), aVal);
+ },
+
+ setZoomForBrowser(browser, val) {
+ if (val < this.MIN || val > this.MAX) {
+ throw Components.Exception(
+ `invalid zoom value: ${val}`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let fullZoom = this.useFullZoomForBrowser(browser);
+ browser.textZoom = fullZoom ? 1 : val;
+ browser.fullZoom = fullZoom ? val : 1;
+ },
+
+ get zoomValues() {
+ var zoomValues = Services.prefs
+ .getCharPref("toolkit.zoomManager.zoomValues")
+ .split(",")
+ .map(parseFloat);
+ zoomValues.sort((a, b) => a - b);
+
+ while (zoomValues[0] < this.MIN) {
+ zoomValues.shift();
+ }
+
+ while (zoomValues[zoomValues.length - 1] > this.MAX) {
+ zoomValues.pop();
+ }
+
+ delete this.zoomValues;
+ return (this.zoomValues = zoomValues);
+ },
+
+ enlarge(browser = getBrowser()) {
+ const i =
+ this.zoomValues.indexOf(this.snap(this.getZoomForBrowser(browser))) + 1;
+ if (i < this.zoomValues.length) {
+ this.setZoomForBrowser(browser, this.zoomValues[i]);
+ }
+ },
+
+ reduce(browser = getBrowser()) {
+ const i =
+ this.zoomValues.indexOf(this.snap(this.getZoomForBrowser(browser))) - 1;
+ if (i >= 0) {
+ this.setZoomForBrowser(browser, this.zoomValues[i]);
+ }
+ },
+
+ reset(browser = getBrowser()) {
+ this.setZoomForBrowser(browser, 1);
+ },
+
+ toggleZoom(browser = getBrowser()) {
+ const zoomLevel = this.getZoomForBrowser();
+
+ this.useFullZoom = !this.useFullZoom;
+ this.setZoomForBrowser(browser, zoomLevel);
+ },
+
+ snap(aVal) {
+ var values = this.zoomValues;
+ for (var i = 0; i < values.length; i++) {
+ if (values[i] >= aVal) {
+ if (i > 0 && aVal - values[i - 1] < values[i] - aVal) {
+ i--;
+ }
+ return values[i];
+ }
+ }
+ return values[i - 1];
+ },
+
+ scrollZoomEnlarge(messagePaneBrowser) {
+ let zoom = messagePaneBrowser.fullZoom;
+ zoom += 0.1;
+ let zoomMax = Services.prefs.getIntPref("zoom.maxPercent") / 100;
+ if (zoom > zoomMax) {
+ zoom = zoomMax;
+ }
+ messagePaneBrowser.fullZoom = zoom;
+ },
+
+ scrollReduceEnlarge(messagePaneBrowser) {
+ let zoom = messagePaneBrowser.fullZoom;
+ zoom -= 0.1;
+ let zoomMin = Services.prefs.getIntPref("zoom.minPercent") / 100;
+ if (zoom < zoomMin) {
+ zoom = zoomMin;
+ }
+ messagePaneBrowser.fullZoom = zoom;
+ },
+};
diff --git a/comm/mail/base/content/webextensions.css b/comm/mail/base/content/webextensions.css
new file mode 100644
index 0000000000..8245fb5183
--- /dev/null
+++ b/comm/mail/base/content/webextensions.css
@@ -0,0 +1,106 @@
+/* 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 xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+/* Rules to help integrate WebExtension buttons */
+
+.webextension-action > .toolbarbutton-badge-stack > .toolbarbutton-icon {
+ height: 18px;
+ width: 18px;
+}
+
+@media not all and (min-resolution: 1.1dppx) {
+ /* for browserAction, composeAction and messageAction */
+ :root .spaces-addon-menuitem,
+ .webextension-action {
+ list-style-image: var(--webextension-toolbar-image, inherit);
+ }
+
+ /* for buttons in sidebar or sidebar menu */
+ :root .spaces-addon-button img {
+ content: var(--webextension-toolbar-image, inherit);
+ }
+
+ :root .spaces-addon-menuitem:-moz-lwtheme,
+ .webextension-action:-moz-lwtheme {
+ list-style-image: var(--webextension-toolbar-image-dark, inherit);
+ }
+
+ :root .spaces-addon-button:-moz-lwtheme img {
+ content: var(--webextension-toolbar-image-dark, inherit);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root .spaces-addon-menuitem,
+ .webextension-action {
+ list-style-image: var(--webextension-toolbar-image-light, inherit) !important;
+ }
+
+ :root .spaces-addon-button img {
+ content: var(--webextension-toolbar-image-light, inherit) !important;
+ }
+ }
+
+ .webextension-action[cui-areatype="menu-panel"] {
+ list-style-image: var(--webextension-menupanel-image, inherit);
+ }
+ :root[lwt-popup-brighttext] .webextension-action[cui-areatype="menu-panel"] {
+ list-style-image: var(--webextension-menupanel-image-light, inherit);
+ }
+ :root:not([lwt-popup-brighttext]) .webextension-action[cui-areatype="menu-panel"]:-moz-lwtheme {
+ list-style-image: var(--webextension-menupanel-image-dark, inherit);
+ }
+
+ .webextension-menuitem {
+ list-style-image: var(--webextension-menuitem-image, inherit) !important;
+ }
+}
+
+/* for displays, like Retina > 1.1dppx */
+@media (min-resolution: 1.1dppx) {
+ :root .spaces-addon-menuitem,
+ .webextension-action {
+ list-style-image: var(--webextension-toolbar-image-2x, inherit);
+ }
+
+ :root .spaces-addon-button img {
+ content: var(--webextension-toolbar-image-2x, inherit);
+ }
+
+ :root .spaces-addon-menuitem:-moz-lwtheme,
+ .webextension-action:-moz-lwtheme {
+ list-style-image: var(--webextension-toolbar-image-2x-dark, inherit);
+ }
+
+ :root .spaces-addon-button:-moz-lwtheme img {
+ content: var(--webextension-toolbar-image-2x-dark, inherit);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root .spaces-addon-menuitem,
+ .webextension-action {
+ list-style-image: var(--webextension-toolbar-image-2x-light, inherit) !important;
+ }
+
+ :root .spaces-addon-button img {
+ content: var(--webextension-toolbar-image-2x-light, inherit) !important;
+ }
+ }
+
+ .webextension-action[cui-areatype="menu-panel"] {
+ list-style-image: var(--webextension-menupanel-image-2x, inherit);
+ }
+ :root[lwt-popup-brighttext] .webextension-action[cui-areatype="menu-panel"] {
+ list-style-image: var(--webextension-menupanel-image-2x-light, inherit);
+ }
+ :root:not([lwt-popup-brighttext]) .webextension-action[cui-areatype="menu-panel"]:-moz-lwtheme {
+ list-style-image: var(--webextension-menupanel-image-2x-dark, inherit);
+ }
+
+ .webextension-menuitem {
+ list-style-image: var(--webextension-menuitem-image-2x, inherit) !important;
+ }
+}
diff --git a/comm/mail/base/content/widgets/browserPopups.inc.xhtml b/comm/mail/base/content/widgets/browserPopups.inc.xhtml
new file mode 100644
index 0000000000..468c2eb3eb
--- /dev/null
+++ b/comm/mail/base/content/widgets/browserPopups.inc.xhtml
@@ -0,0 +1,192 @@
+# 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/.
+
+#ifndef NO_BROWSERCONTEXT
+ <menupopup id="browserContext"
+ onpopupshowing="return browserContextOnShowing(event);"
+ onpopuphiding="browserContextOnHiding(event);">
+ <!-- Browser navigation -->
+#ifdef XP_MACOSX
+ <menuitem id="browserContext-back"
+ data-l10n-id="content-tab-menu-back-mac"
+ command="Browser:Back"/>
+ <menuitem id="browserContext-forward"
+ data-l10n-id="content-tab-menu-forward-mac"
+ command="Browser:Forward"/>
+ <menuitem id="browserContext-reload"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="content-tab-menu-reload-mac"
+ command="cmd_reload"/>
+ <menuitem id="browserContext-stop"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="content-tab-menu-stop-mac"
+ command="cmd_stop"/>
+#else
+ <menugroup id="context-navigation">
+ <menuitem id="browserContext-back"
+ data-l10n-id="content-tab-menu-back"
+ data-l10n-args='{"shortcut":""}'
+ class="menuitem-iconic"
+ command="Browser:Back"/>
+ <menuitem id="browserContext-forward"
+ data-l10n-id="content-tab-menu-forward"
+ data-l10n-args='{"shortcut":""}'
+ class="menuitem-iconic"
+ command="Browser:Forward"/>
+ <menuitem id="browserContext-reload"
+ class="menuitem-iconic"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="content-tab-menu-reload"
+ command="cmd_reload"/>
+ <menuitem id="browserContext-stop"
+ class="menuitem-iconic"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="content-tab-menu-stop"
+ command="cmd_stop"/>
+ </menugroup>
+#endif
+ <menuseparator id="browserContext-sep-navigation"/>
+ <!-- Spellchecking suggestions -->
+ <menuitem id="browserContext-spell-no-suggestions"
+ disabled="true"
+ data-l10n-id="text-action-spell-no-suggestions"/>
+ <menuitem id="browserContext-spell-add-to-dictionary"
+ data-l10n-id="text-action-spell-add-to-dictionary"
+ oncommand="gSpellChecker.addToDictionary();"/>
+ <menuitem id="browserContext-spell-undo-add-to-dictionary"
+ data-l10n-id="text-action-spell-undo-add-to-dictionary"
+ oncommand="gSpellChecker.undoAddToDictionary();" />
+ <menuseparator id="browserContext-spell-suggestions-separator"/>
+
+ <menuitem id="browserContext-openInBrowser"
+ label="&openInBrowser.label;"
+ accesskey="&openInBrowser.accesskey;"
+ oncommand="gContextMenu.openInBrowser();"/>
+ <menuitem id="browserContext-openLinkInBrowser"
+ label="&openLinkInBrowser.label;"
+ accesskey="&openLinkInBrowser.accesskey;"
+ oncommand="gContextMenu.openLinkInBrowser();"/>
+ <menuseparator id="browserContext-sep-open-browser"/>
+ <menuitem id="browserContext-undo"
+ label="&undoDefaultCmd.label;"
+ accesskey="&undoDefaultCmd.accesskey;"
+ command="cmd_undo"/>
+ <menuseparator id="browserContext-sep-undo"/>
+ <menuitem id="browserContext-cut"
+ data-l10n-id="text-action-cut"
+ command="cmd_copy"/>
+ <menuitem id="browserContext-copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuitem id="browserContext-paste"
+ data-l10n-id="text-action-paste"
+ command="cmd_paste"/>
+ <menuitem id="browserContext-selectall"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ <menuseparator id="browserContext-sep-clipboard"/>
+
+ <menuitem id="browserContext-searchTheWeb"
+ label="[glodaComplete.webSearch1.label]"
+ oncommand="openWebSearch(event.target.value)"/>
+
+ <!-- Spellchecking general menu items (enable, add dictionaries...) -->
+ <menuseparator id="browserContext-spell-separator"/>
+ <menuitem id="browserContext-spell-check-enabled"
+ data-l10n-id="text-action-spell-check-toggle"
+ type="checkbox"
+ oncommand="gSpellChecker.toggleEnabled();"/>
+ <menuitem id="browserContext-spell-add-dictionaries-main"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="gContextMenu.addDictionaries();"/>
+ <menu id="browserContext-spell-dictionaries"
+ data-l10n-id="text-action-spell-dictionaries">
+ <menupopup id="browserContext-spell-dictionaries-menu">
+ <menuseparator id="browserContext-spell-language-separator"/>
+ <menuitem id="browserContext-spell-add-dictionaries"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="gContextMenu.addDictionaries();"/>
+ </menupopup>
+ </menu>
+
+ <menuitem id="browserContext-media-play"
+ label="&contextPlay.label;"
+ accesskey="&contextPlay.accesskey;"
+ oncommand="gContextMenu.mediaCommand('play');"/>
+ <menuitem id="browserContext-media-pause"
+ label="&contextPause.label;"
+ accesskey="&contextPause.accesskey;"
+ oncommand="gContextMenu.mediaCommand('pause');"/>
+ <menuitem id="browserContext-media-mute"
+ label="&contextMute.label;"
+ accesskey="&contextMute.accesskey;"
+ oncommand="gContextMenu.mediaCommand('mute');"/>
+ <menuitem id="browserContext-media-unmute"
+ label="&contextUnmute.label;"
+ accesskey="&contextUnmute.accesskey;"
+ oncommand="gContextMenu.mediaCommand('unmute');"/>
+ <menuseparator id="browserContext-sep-edit"/>
+ <menuitem id="browserContext-copylink"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"
+ command="cmd_copyLink"/>
+ <menuitem id="browserContext-copyimage"
+ label="&copyImageAllCmd.label;"
+ accesskey="&copyImageAllCmd.accesskey;"
+ command="cmd_copyImage"/>
+ <menuitem id="browserContext-addemail"
+ label="&AddToAddressBook.label;"
+ accesskey="&AddToAddressBook.accesskey;"
+ oncommand="addEmail(gContextMenu.linkURL);"/>
+ <menuitem id="browserContext-composeemailto"
+ label="&SendMessageTo.label;"
+ accesskey="&SendMessageTo.accesskey;"
+ oncommand="composeEmailTo(gContextMenu.linkURL);"/>
+ <menuitem id="browserContext-copyemail"
+ label="&copyEmailCmd.label;"
+ accesskey="&copyEmailCmd.accesskey;"
+ oncommand="gContextMenu.copyEmail();"/>
+ <menuseparator id="browserContext-sep-copy"/>
+ <menuitem id="browserContext-savelink"
+ label="&saveLinkAsCmd.label;"
+ accesskey="&saveLinkAsCmd.accesskey;"
+ oncommand="gContextMenu.saveLink();"/>
+ <menuitem id="browserContext-saveimage"
+ label="&saveImageAsCmd.label;"
+ accesskey="&saveImageAsCmd.accesskey;"
+ oncommand="gContextMenu.saveImage();"/>
+ </menupopup>
+#endif
+ <panel id="DateTimePickerPanel"
+ type="arrow"
+ orient="vertical"
+ noautofocus="true"
+ norolluponanchor="true"
+ consumeoutsideclicks="never"
+ level="top"
+ tabspecific="true">
+ </panel>
+
+ <!-- For select dropdowns. The menupopup is what shows the list of options,
+ and the popuponly menulist makes things like the menuactive attributes
+ work correctly on the menupopup. ContentSelectDropdown expects the
+ popuponly menulist to be its immediate parent. -->
+ <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
+ <menupopup rolluponmousewheel="true"
+ activateontab="true" position="after_start"
+ level="parent"
+#ifdef XP_WIN
+ consumeoutsideclicks="false" ignorekeys="shortcuts"
+#endif
+ />
+ </menulist>
+
+ <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete"
+ type="autocomplete"
+ role="group"
+ noautofocus="true"/>
+
+ <tooltip id="remoteBrowserTooltip"/>
diff --git a/comm/mail/base/content/widgets/browserPopups.js b/comm/mail/base/content/widgets/browserPopups.js
new file mode 100644
index 0000000000..f6d2a2139f
--- /dev/null
+++ b/comm/mail/base/content/widgets/browserPopups.js
@@ -0,0 +1,991 @@
+/* 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 ../utilityOverlay.js */
+
+/* globals saveURL */ // From contentAreaUtils.js
+/* globals goUpdateCommand */ // From globalOverlay.js
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { InlineSpellChecker, SpellCheckHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+);
+var { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+var { ShortcutUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ShortcutUtils.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+);
+var { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+);
+
+var gContextMenu;
+var gSpellChecker = new InlineSpellChecker();
+
+/** Called by ContextMenuParent.sys.mjs */
+function openContextMenu({ data }, browser, actor) {
+ if (!browser.hasAttribute("context")) {
+ return;
+ }
+
+ let wgp = actor.manager;
+
+ if (!wgp.isCurrentGlobal) {
+ // Don't display context menus for unloaded documents
+ return;
+ }
+
+ // NOTE: We don't use `wgp.documentURI` here as we want to use the failed
+ // channel URI in the case we have loaded an error page.
+ let documentURIObject = wgp.browsingContext.currentURI;
+
+ let frameReferrerInfo = data.frameReferrerInfo;
+ if (frameReferrerInfo) {
+ frameReferrerInfo = E10SUtils.deserializeReferrerInfo(frameReferrerInfo);
+ }
+
+ let linkReferrerInfo = data.linkReferrerInfo;
+ if (linkReferrerInfo) {
+ linkReferrerInfo = E10SUtils.deserializeReferrerInfo(linkReferrerInfo);
+ }
+
+ let frameID = nsContextMenu.WebNavigationFrames.getFrameId(
+ wgp.browsingContext
+ );
+
+ nsContextMenu.contentData = {
+ context: data.context,
+ browser,
+ actor,
+ editFlags: data.editFlags,
+ spellInfo: data.spellInfo,
+ principal: wgp.documentPrincipal,
+ storagePrincipal: wgp.documentStoragePrincipal,
+ documentURIObject,
+ docLocation: data.docLocation,
+ charSet: data.charSet,
+ referrerInfo: E10SUtils.deserializeReferrerInfo(data.referrerInfo),
+ frameReferrerInfo,
+ linkReferrerInfo,
+ contentType: data.contentType,
+ contentDisposition: data.contentDisposition,
+ frameID,
+ frameOuterWindowID: frameID,
+ frameBrowsingContext: wgp.browsingContext,
+ selectionInfo: data.selectionInfo,
+ disableSetDesktopBackground: data.disableSetDesktopBackground,
+ loginFillInfo: data.loginFillInfo,
+ parentAllowsMixedContent: data.parentAllowsMixedContent,
+ userContextId: wgp.browsingContext.originAttributes.userContextId,
+ webExtContextData: data.webExtContextData,
+ cookieJarSettings: wgp.cookieJarSettings,
+ };
+
+ // Note: `popup` must be in `document`, but `browser` might be in a
+ // different document, such as about:3pane.
+ let popup = document.getElementById(browser.getAttribute("context"));
+ let context = nsContextMenu.contentData.context;
+
+ // Fill in some values in the context from the WindowGlobalParent actor.
+ context.principal = wgp.documentPrincipal;
+ context.storagePrincipal = wgp.documentStoragePrincipal;
+ context.frameID = frameID;
+ context.frameOuterWindowID = wgp.outerWindowId;
+ context.frameBrowsingContextID = wgp.browsingContext.id;
+
+ // We don't have access to the original event here, as that happened in
+ // another process. Therefore we synthesize a new MouseEvent to propagate the
+ // inputSource to the subsequently triggered popupshowing event.
+ let newEvent = document.createEvent("MouseEvent");
+ let screenX = context.screenXDevPx / window.devicePixelRatio;
+ let screenY = context.screenYDevPx / window.devicePixelRatio;
+ newEvent.initNSMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ null,
+ 0,
+ screenX,
+ screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 2,
+ null,
+ 0,
+ context.mozInputSource
+ );
+ popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
+}
+
+/**
+ * Function to set the global nsContextMenu. Called by popupshowing on browserContext.
+ *
+ * @param {Event} event - The onpopupshowing event.
+ * @returns {boolean}
+ */
+function browserContextOnShowing(event) {
+ if (event.target.id != "browserContext") {
+ return true;
+ }
+
+ gContextMenu = new nsContextMenu(event.target, event.shiftKey);
+ return gContextMenu.shouldDisplay;
+}
+
+/**
+ * Function to clear out the global nsContextMenu.
+ *
+ * @param {Event} event - The onpopuphiding event.
+ */
+function browserContextOnHiding(event) {
+ if (event.target.id != "browserContext") {
+ return;
+ }
+
+ gContextMenu.hiding();
+ gContextMenu = null;
+}
+
+class nsContextMenu {
+ constructor(aXulMenu, aIsShift) {
+ this.xulMenu = aXulMenu;
+
+ // Get contextual info.
+ this.setContext();
+
+ if (!this.shouldDisplay) {
+ return;
+ }
+
+ this.isContentSelected =
+ !this.selectionInfo || !this.selectionInfo.docSelectionIsCollapsed;
+
+ if (!aIsShift) {
+ // The rest of this block sends menu information to WebExtensions.
+ let subject = {
+ menu: aXulMenu,
+ tab: document.getElementById("tabmail")
+ ? document.getElementById("tabmail").currentTabInfo
+ : window,
+ timeStamp: this.timeStamp,
+ isContentSelected: this.isContentSelected,
+ inFrame: this.inFrame,
+ isTextSelected: this.isTextSelected,
+ onTextInput: this.onTextInput,
+ onLink: this.onLink,
+ onImage: this.onImage,
+ onVideo: this.onVideo,
+ onAudio: this.onAudio,
+ onCanvas: this.onCanvas,
+ onEditable: this.onEditable,
+ onSpellcheckable: this.onSpellcheckable,
+ onPassword: this.onPassword,
+ srcUrl: this.mediaURL,
+ frameUrl: this.contentData ? this.contentData.docLocation : undefined,
+ pageUrl: this.browser ? this.browser.currentURI.spec : undefined,
+ linkText: this.linkTextStr,
+ linkUrl: this.linkURL,
+ selectionText: this.isTextSelected
+ ? this.selectionInfo.fullText
+ : undefined,
+ frameId: this.frameID,
+ webExtBrowserType: this.webExtBrowserType,
+ webExtContextData: this.contentData
+ ? this.contentData.webExtContextData
+ : undefined,
+ };
+
+ subject.wrappedJSObject = subject;
+ Services.obs.notifyObservers(subject, "on-build-contextmenu");
+ }
+
+ // Reset after "on-build-contextmenu" notification in case selection was
+ // changed during the notification.
+ this.isContentSelected =
+ !this.selectionInfo || !this.selectionInfo.docSelectionIsCollapsed;
+ this.initItems();
+
+ // If all items in the menu are hidden, set this.shouldDisplay to false
+ // so that the callers know to not even display the empty menu.
+ let contextPopup = document.getElementById("browserContext");
+ for (let item of contextPopup.children) {
+ if (!item.hidden) {
+ return;
+ }
+ }
+
+ // All items must have been hidden.
+ this.shouldDisplay = false;
+ }
+
+ setContext() {
+ let context = Object.create(null);
+
+ if (nsContextMenu.contentData) {
+ this.contentData = nsContextMenu.contentData;
+ context = this.contentData.context;
+ nsContextMenu.contentData = null;
+ }
+
+ this.shouldDisplay = !this.contentData || context.shouldDisplay;
+ this.timeStamp = context.timeStamp;
+
+ // Assign what's _possibly_ needed from `context` sent by ContextMenuChild.sys.mjs
+ // Keep this consistent with the similar code in ContextMenu's _setContext
+ this.bgImageURL = context.bgImageURL;
+ this.imageDescURL = context.imageDescURL;
+ this.imageInfo = context.imageInfo;
+ this.mediaURL = context.mediaURL;
+
+ this.canSpellCheck = context.canSpellCheck;
+ this.hasBGImage = context.hasBGImage;
+ this.hasMultipleBGImages = context.hasMultipleBGImages;
+ this.isDesignMode = context.isDesignMode;
+ this.inFrame = context.inFrame;
+ this.inPDFViewer = context.inPDFViewer;
+ this.inSrcdocFrame = context.inSrcdocFrame;
+ this.inSyntheticDoc = context.inSyntheticDoc;
+
+ this.link = context.link;
+ this.linkDownload = context.linkDownload;
+ this.linkProtocol = context.linkProtocol;
+ this.linkTextStr = context.linkTextStr;
+ this.linkURL = context.linkURL;
+ this.linkURI = this.getLinkURI(); // can't send; regenerate
+
+ this.onAudio = context.onAudio;
+ this.onCanvas = context.onCanvas;
+ this.onCompletedImage = context.onCompletedImage;
+ this.onDRMMedia = context.onDRMMedia;
+ this.onPiPVideo = context.onPiPVideo;
+ this.onEditable = context.onEditable;
+ this.onImage = context.onImage;
+ this.onKeywordField = context.onKeywordField;
+ this.onLink = context.onLink;
+ this.onLoadedImage = context.onLoadedImage;
+ this.onMailtoLink = context.onMailtoLink;
+ this.onMozExtLink = context.onMozExtLink;
+ this.onNumeric = context.onNumeric;
+ this.onPassword = context.onPassword;
+ this.onSaveableLink = context.onSaveableLink;
+ this.onSpellcheckable = context.onSpellcheckable;
+ this.onTextInput = context.onTextInput;
+ this.onVideo = context.onVideo;
+
+ this.target = context.target;
+ this.targetIdentifier = context.targetIdentifier;
+
+ this.principal = context.principal;
+ this.storagePrincipal = context.storagePrincipal;
+ this.frameID = context.frameID;
+ this.frameOuterWindowID = context.frameOuterWindowID;
+ this.frameBrowsingContext = BrowsingContext.get(
+ context.frameBrowsingContextID
+ );
+
+ this.inSyntheticDoc = context.inSyntheticDoc;
+ this.inAboutDevtoolsToolbox = context.inAboutDevtoolsToolbox;
+
+ // Everything after this isn't sent directly from ContextMenu
+ if (this.target) {
+ this.ownerDoc = this.target.ownerDocument;
+ }
+
+ this.csp = E10SUtils.deserializeCSP(context.csp);
+
+ if (!this.contentData) {
+ return;
+ }
+
+ this.browser = this.contentData.browser;
+ if (this.browser && this.browser.currentURI.spec == "about:blank") {
+ this.shouldDisplay = false;
+ return;
+ }
+ this.selectionInfo = this.contentData.selectionInfo;
+ this.actor = this.contentData.actor;
+
+ this.textSelected = this.selectionInfo?.text;
+ this.isTextSelected = !!this.textSelected?.length;
+
+ this.webExtBrowserType = this.browser.getAttribute(
+ "webextension-view-type"
+ );
+
+ if (context.shouldInitInlineSpellCheckerUINoChildren) {
+ gSpellChecker.initFromRemote(
+ this.contentData.spellInfo,
+ this.actor.manager
+ );
+ }
+
+ if (this.contentData.spellInfo) {
+ this.spellSuggestions = this.contentData.spellInfo.spellSuggestions;
+ }
+
+ if (context.shouldInitInlineSpellCheckerUIWithChildren) {
+ gSpellChecker.initFromRemote(
+ this.contentData.spellInfo,
+ this.actor.manager
+ );
+ let canSpell = gSpellChecker.canSpellCheck && this.canSpellCheck;
+ this.showItem("browserContext-spell-check-enabled", canSpell);
+ this.showItem("browserContext-spell-separator", canSpell);
+ }
+ }
+
+ hiding() {
+ if (this.actor) {
+ this.actor.hiding();
+ }
+
+ this.contentData = null;
+ gSpellChecker.clearSuggestionsFromMenu();
+ gSpellChecker.clearDictionaryListFromMenu();
+ gSpellChecker.uninit();
+ }
+
+ initItems() {
+ this.initSaveItems();
+ this.initClipboardItems();
+ this.initMediaPlayerItems();
+ this.initBrowserItems();
+ this.initSpellingItems();
+ this.initSeparators();
+ }
+ addDictionaries() {
+ openDictionaryList();
+ }
+ initSpellingItems() {
+ let canSpell =
+ gSpellChecker.canSpellCheck &&
+ !gSpellChecker.initialSpellCheckPending &&
+ this.canSpellCheck;
+ let showDictionaries = canSpell && gSpellChecker.enabled;
+ let onMisspelling = gSpellChecker.overMisspelling;
+ let showUndo = canSpell && gSpellChecker.canUndo();
+ this.showItem("browserContext-spell-check-enabled", canSpell);
+ this.showItem("browserContext-spell-separator", canSpell);
+ document
+ .getElementById("browserContext-spell-check-enabled")
+ .setAttribute("checked", canSpell && gSpellChecker.enabled);
+
+ this.showItem("browserContext-spell-add-to-dictionary", onMisspelling);
+ this.showItem("browserContext-spell-undo-add-to-dictionary", showUndo);
+
+ // suggestion list
+ this.showItem(
+ "browserContext-spell-suggestions-separator",
+ onMisspelling || showUndo
+ );
+ if (onMisspelling) {
+ let addMenuItem = document.getElementById(
+ "browserContext-spell-add-to-dictionary"
+ );
+ let suggestionCount = gSpellChecker.addSuggestionsToMenu(
+ addMenuItem.parentNode,
+ addMenuItem,
+ this.spellSuggestions
+ );
+ this.showItem(
+ "browserContext-spell-no-suggestions",
+ suggestionCount == 0
+ );
+ } else {
+ this.showItem("browserContext-spell-no-suggestions", false);
+ }
+
+ // dictionary list
+ this.showItem("browserContext-spell-dictionaries", showDictionaries);
+ if (canSpell) {
+ let dictMenu = document.getElementById(
+ "browserContext-spell-dictionaries-menu"
+ );
+ let dictSep = document.getElementById(
+ "browserContext-spell-language-separator"
+ );
+ let count = gSpellChecker.addDictionaryListToMenu(dictMenu, dictSep);
+ this.showItem(dictSep, count > 0);
+ this.showItem("browserContext-spell-add-dictionaries-main", false);
+ } else if (this.onSpellcheckable) {
+ // when there is no spellchecker but we might be able to spellcheck
+ // add the add to dictionaries item. This will ensure that people
+ // with no dictionaries will be able to download them
+ this.showItem(
+ "browserContext-spell-language-separator",
+ showDictionaries
+ );
+ this.showItem(
+ "browserContext-spell-add-dictionaries-main",
+ showDictionaries
+ );
+ } else {
+ this.showItem("browserContext-spell-add-dictionaries-main", false);
+ }
+ }
+ initSaveItems() {
+ this.showItem("browserContext-savelink", this.onSaveableLink);
+ this.showItem("browserContext-saveimage", this.onLoadedImage);
+ }
+ initClipboardItems() {
+ // Copy depends on whether there is selected text.
+ // Enabling this context menu item is now done through the global
+ // command updating system.
+
+ goUpdateGlobalEditMenuItems();
+
+ this.showItem("browserContext-cut", this.onTextInput);
+ this.showItem(
+ "browserContext-copy",
+ !this.onPlayableMedia && (this.isContentSelected || this.onTextInput)
+ );
+ this.showItem("browserContext-paste", this.onTextInput);
+
+ this.showItem("browserContext-undo", this.onTextInput);
+ // Select all not available in the thread pane or on playable media.
+ this.showItem("browserContext-selectall", !this.onPlayableMedia);
+ this.showItem("browserContext-copyemail", this.onMailtoLink);
+ this.showItem("browserContext-copylink", this.onLink && !this.onMailtoLink);
+ this.showItem("browserContext-copyimage", this.onImage);
+
+ this.showItem("browserContext-composeemailto", this.onMailtoLink);
+ this.showItem("browserContext-addemail", this.onMailtoLink);
+
+ let searchTheWeb = document.getElementById("browserContext-searchTheWeb");
+ this.showItem(
+ searchTheWeb,
+ !this.onPlayableMedia && this.isContentSelected
+ );
+
+ if (!searchTheWeb.hidden) {
+ let selection = this.textSelected;
+
+ let bundle = document.getElementById("bundle_messenger");
+ let key = "openSearch.label";
+ let abbrSelection;
+ if (selection.length > 15) {
+ key += ".truncated";
+ abbrSelection = selection.slice(0, 15);
+ } else {
+ abbrSelection = selection;
+ }
+
+ searchTheWeb.label = bundle.getFormattedString(key, [
+ Services.search.defaultEngine.name,
+ abbrSelection,
+ ]);
+ searchTheWeb.value = selection;
+ }
+ }
+ initMediaPlayerItems() {
+ let onMedia = this.onVideo || this.onAudio;
+ // Several mutually exclusive items.... play/pause, mute/unmute, show/hide
+ this.showItem("browserContext-media-play", onMedia && this.target.paused);
+ this.showItem("browserContext-media-pause", onMedia && !this.target.paused);
+ this.showItem("browserContext-media-mute", onMedia && !this.target.muted);
+ this.showItem("browserContext-media-unmute", onMedia && this.target.muted);
+ if (onMedia) {
+ let hasError =
+ this.target.error != null ||
+ this.target.networkState == this.target.NETWORK_NO_SOURCE;
+ this.setItemAttr("browserContext-media-play", "disabled", hasError);
+ this.setItemAttr("browserContext-media-pause", "disabled", hasError);
+ this.setItemAttr("browserContext-media-mute", "disabled", hasError);
+ this.setItemAttr("browserContext-media-unmute", "disabled", hasError);
+ }
+ }
+ initBackForwardMenuItemTooltip(menuItemId, l10nId, shortcutId) {
+ // On macOS regular menuitems are used and the shortcut isn't added.
+ if (AppConstants.platform == "macosx") {
+ return;
+ }
+
+ let shortcut = document.getElementById(shortcutId);
+ if (shortcut) {
+ shortcut = ShortcutUtils.prettifyShortcut(shortcut);
+ } else {
+ // Sidebar doesn't have navigation buttons or shortcuts, but we still
+ // want to format the menu item tooltip to remove "$shortcut" string.
+ shortcut = "";
+ }
+ let menuItem = document.getElementById(menuItemId);
+ document.l10n.setAttributes(menuItem, l10nId, { shortcut });
+ }
+ initBrowserItems() {
+ // Work out if we are a context menu on a special item e.g. an image, link
+ // etc.
+ let onSpecialItem =
+ this.isContentSelected ||
+ this.onCanvas ||
+ this.onLink ||
+ this.onImage ||
+ this.onAudio ||
+ this.onVideo ||
+ this.onTextInput;
+
+ // Internal about:* pages should not show nav items.
+ let shouldShowNavItems =
+ !onSpecialItem && this.browser.currentURI.scheme != "about";
+
+ // Ensure these commands are updated with their current status.
+ if (shouldShowNavItems) {
+ goUpdateCommand("Browser:Back");
+ goUpdateCommand("Browser:Forward");
+ goUpdateCommand("cmd_stop");
+ goUpdateCommand("cmd_reload");
+ }
+
+ let stopped = document.getElementById("cmd_stop").hasAttribute("disabled");
+ this.showItem("browserContext-reload", shouldShowNavItems && stopped);
+ this.showItem("browserContext-stop", shouldShowNavItems && !stopped);
+ this.showItem("browserContext-sep-navigation", shouldShowNavItems);
+
+ if (AppConstants.platform == "macosx") {
+ this.showItem("browserContext-back", shouldShowNavItems);
+ this.showItem("browserContext-forward", shouldShowNavItems);
+ } else {
+ this.showItem("context-navigation", shouldShowNavItems);
+
+ this.initBackForwardMenuItemTooltip(
+ "browserContext-back",
+ "content-tab-menu-back",
+ "key_goBackKb"
+ );
+ this.initBackForwardMenuItemTooltip(
+ "browserContext-forward",
+ "content-tab-menu-forward",
+ "key_goForwardKb"
+ );
+ }
+
+ // Only show open in browser if we're not on a special item and we're not
+ // on an about: or chrome: protocol - for these protocols the browser is
+ // unlikely to show the same thing as we do (if at all), so therefore don't
+ // offer the option.
+ this.showItem(
+ "browserContext-openInBrowser",
+ !onSpecialItem &&
+ ["http", "https"].includes(this.contentData?.documentURIObject?.scheme)
+ );
+
+ // Only show browserContext-openLinkInBrowser if we're on a link and it isn't
+ // a mailto link.
+ this.showItem(
+ "browserContext-openLinkInBrowser",
+ this.onLink && ["http", "https"].includes(this.linkProtocol)
+ );
+ }
+ initSeparators() {
+ let separators = Array.from(
+ this.xulMenu.querySelectorAll(":scope > menuseparator")
+ );
+ let lastShownSeparator = null;
+ for (let separator of separators) {
+ let shouldShow = this.shouldShowSeparator(separator);
+ if (
+ !shouldShow &&
+ lastShownSeparator &&
+ separator.classList.contains("webextension-group-separator")
+ ) {
+ // The separator for the WebExtension elements group must be shown, hide
+ // the last shown menu separator instead.
+ lastShownSeparator.hidden = true;
+ shouldShow = true;
+ }
+ if (shouldShow) {
+ lastShownSeparator = separator;
+ }
+ separator.hidden = !shouldShow;
+ }
+ this.checkLastSeparator(this.xulMenu);
+ }
+
+ /**
+ * Get a computed style property for an element.
+ *
+ * @param aElem
+ * A DOM node
+ * @param aProp
+ * The desired CSS property
+ * @returns the value of the property
+ */
+ getComputedStyle(aElem, aProp) {
+ return aElem.ownerGlobal.getComputedStyle(aElem).getPropertyValue(aProp);
+ }
+
+ /**
+ * Determine whether the clicked-on link can be saved, and whether it
+ * may be saved according to the ScriptSecurityManager.
+ *
+ * @returns true if the protocol can be persisted and if the target has
+ * permission to link to the URL, false if not
+ */
+ isLinkSaveable() {
+ try {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ this.target.nodePrincipal,
+ this.linkURI,
+ Ci.nsIScriptSecurityManager.STANDARD
+ );
+ } catch (e) {
+ // Don't save things we can't link to.
+ return false;
+ }
+
+ // We don't do the Right Thing for news/snews yet, so turn them off
+ // until we do.
+ return (
+ this.linkProtocol &&
+ !(
+ this.linkProtocol == "mailto" ||
+ this.linkProtocol == "javascript" ||
+ this.linkProtocol == "news" ||
+ this.linkProtocol == "snews"
+ )
+ );
+ }
+
+ /**
+ * Save URL of clicked-on link.
+ */
+ saveLink() {
+ saveURL(
+ this.linkURL,
+ null,
+ this.linkTextStr,
+ null,
+ true,
+ null,
+ null,
+ null,
+ document
+ );
+ }
+
+ /**
+ * Save a clicked-on image.
+ */
+ saveImage() {
+ saveURL(
+ this.imageInfo.currentSrc,
+ null,
+ null,
+ "SaveImageTitle",
+ false,
+ null,
+ null,
+ null,
+ document
+ );
+ }
+
+ /**
+ * Extract email addresses from a mailto: link and put them on the
+ * clipboard.
+ */
+ copyEmail() {
+ // Copy the comma-separated list of email addresses only.
+ // There are other ways of embedding email addresses in a mailto:
+ // link, but such complex parsing is beyond us.
+
+ const kMailToLength = 7; // length of "mailto:"
+
+ var url = this.linkURL;
+ var qmark = url.indexOf("?");
+ var addresses;
+
+ if (qmark > kMailToLength) {
+ addresses = url.substring(kMailToLength, qmark);
+ } else {
+ addresses = url.substr(kMailToLength);
+ }
+
+ // Let's try to unescape it using a character set.
+ try {
+ addresses = Services.textToSubURI.unEscapeURIForUI(addresses);
+ } catch (ex) {
+ // Do nothing.
+ }
+
+ var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(addresses);
+ }
+
+ // ---------
+ // Utilities
+
+ /**
+ * Set a DOM node's hidden property by passing in the node's id or the
+ * element itself.
+ *
+ * @param aItemOrId
+ * a DOM node or the id of a DOM node
+ * @param aShow
+ * true to show, false to hide
+ */
+ showItem(aItemOrId, aShow) {
+ var item =
+ aItemOrId.constructor == String
+ ? document.getElementById(aItemOrId)
+ : aItemOrId;
+ if (item) {
+ item.hidden = !aShow;
+ }
+ }
+
+ /**
+ * Set a DOM node's disabled property by passing in the node's id or the
+ * element itself.
+ *
+ * @param aItemOrId A DOM node or the id of a DOM node
+ * @param aEnabled True to enable the element, false to disable.
+ */
+ enableItem(aItemOrId, aEnabled) {
+ var item =
+ aItemOrId.constructor == String
+ ? document.getElementById(aItemOrId)
+ : aItemOrId;
+ item.disabled = !aEnabled;
+ }
+
+ /**
+ * Set given attribute of specified context-menu item. If the
+ * value is null, then it removes the attribute (which works
+ * nicely for the disabled attribute).
+ *
+ * @param aId
+ * The id of an element
+ * @param aAttr
+ * The attribute name
+ * @param aVal
+ * The value to set the attribute to, or null to remove the attribute
+ */
+ setItemAttr(aId, aAttr, aVal) {
+ var elem = document.getElementById(aId);
+ if (elem) {
+ if (aVal == null) {
+ // null indicates attr should be removed.
+ elem.removeAttribute(aAttr);
+ } else {
+ // Set attr=val.
+ elem.setAttribute(aAttr, aVal);
+ }
+ }
+ }
+
+ /**
+ * Get an absolute URL for clicked-on link, from the href property or by
+ * resolving an XLink URL by hand.
+ *
+ * @returns the string absolute URL for the clicked-on link
+ */
+ getLinkURL() {
+ if (this.link.href) {
+ return this.link.href;
+ }
+ var href = this.link.getAttributeNS("http://www.w3.org/1999/xlink", "href");
+ if (!href || href.trim() == "") {
+ // Without this we try to save as the current doc,
+ // for example, HTML case also throws if empty.
+ throw new Error("Empty href");
+ }
+ href = this.makeURLAbsolute(this.link.baseURI, href);
+ return href;
+ }
+
+ /**
+ * Generate a URI object from the linkURL spec
+ *
+ * @returns an nsIURI if possible, or null if not
+ */
+ getLinkURI() {
+ try {
+ return Services.io.newURI(this.linkURL);
+ } catch (ex) {
+ // e.g. empty URL string
+ }
+ return null;
+ }
+
+ /**
+ * Get the scheme for the clicked-on linkURI, if present.
+ *
+ * @returns a scheme, possibly undefined, or null if there's no linkURI
+ */
+ getLinkProtocol() {
+ if (this.linkURI) {
+ return this.linkURI.scheme; // Can be |undefined|.
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the text of the clicked-on link.
+ *
+ * @returns {string}
+ */
+ linkText() {
+ return this.linkTextStr;
+ }
+
+ /**
+ * Determines whether the focused window has something selected.
+ *
+ * @returns true if there is a selection, false if not
+ */
+ isContentSelection() {
+ return !document.commandDispatcher.focusedWindow.getSelection().isCollapsed;
+ }
+
+ /**
+ * Convert relative URL to absolute, using a provided <base>.
+ *
+ * @param aBase
+ * The URL string to use as the base
+ * @param aUrl
+ * The possibly-relative URL string
+ * @returns The string absolute URL
+ */
+ makeURLAbsolute(aBase, aUrl) {
+ // Construct nsIURL.
+ var baseURI = Services.io.newURI(aBase);
+
+ return Services.io.newURI(baseURI.resolve(aUrl)).spec;
+ }
+
+ /**
+ * Determine whether a DOM node is a text or password input, or a textarea.
+ *
+ * @param aNode
+ * The DOM node to check
+ * @returns true for textboxes, false for other elements
+ */
+ isTargetATextBox(aNode) {
+ if (HTMLInputElement.isInstance(aNode)) {
+ return aNode.type == "text" || aNode.type == "password";
+ }
+
+ return HTMLTextAreaElement.isInstance(aNode);
+ }
+
+ /**
+ * Determine whether a separator should be shown based on whether
+ * there are any non-hidden items between it and the previous separator.
+ *
+ * @param {DomElement} element - The separator element.
+ * @returns {boolean} True if the separator should be shown, false if not.
+ */
+ shouldShowSeparator(element) {
+ if (element) {
+ let sibling = element.previousElementSibling;
+ while (sibling && sibling.localName != "menuseparator") {
+ if (!sibling.hidden) {
+ return true;
+ }
+ sibling = sibling.previousElementSibling;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Ensures that there isn't a separator shown at the bottom of the menu.
+ *
+ * @param aPopup The menu to check.
+ */
+ checkLastSeparator(aPopup) {
+ let sibling = aPopup.lastElementChild;
+ while (sibling) {
+ if (!sibling.hidden) {
+ if (sibling.localName == "menuseparator") {
+ // If we got here then the item is a menuseparator and everything
+ // below it hidden.
+ sibling.setAttribute("hidden", true);
+ return;
+ }
+ return;
+ }
+ sibling = sibling.previousElementSibling;
+ }
+ }
+
+ openInBrowser() {
+ let url = this.contentData?.documentURIObject?.spec;
+ if (!url) {
+ return;
+ }
+ PlacesUtils.history
+ .insert({
+ url,
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ })
+ .catch(console.error);
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(url));
+ }
+
+ openLinkInBrowser() {
+ PlacesUtils.history
+ .insert({
+ url: this.linkURL,
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ })
+ .catch(console.error);
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(this.linkURI);
+ }
+
+ mediaCommand(command) {
+ var media = this.target;
+
+ switch (command) {
+ case "play":
+ media.play();
+ break;
+ case "pause":
+ media.pause();
+ break;
+ case "mute":
+ media.muted = true;
+ break;
+ case "unmute":
+ media.muted = false;
+ break;
+ // XXX hide controls & show controls don't work in emails as Javascript is
+ // disabled. May want to consider later for RSS feeds.
+ }
+ }
+}
+
+ChromeUtils.defineESModuleGetters(nsContextMenu, {
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
+});
diff --git a/comm/mail/base/content/widgets/customizable-toolbar.js b/comm/mail/base/content/widgets/customizable-toolbar.js
new file mode 100644
index 0000000000..350e814716
--- /dev/null
+++ b/comm/mail/base/content/widgets/customizable-toolbar.js
@@ -0,0 +1,319 @@
+/* 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";
+
+/* globals MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * Extends the built-in `toolbar` element to allow it to be customized.
+ *
+ * @augments {MozXULElement}
+ */
+ class CustomizableToolbar extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this._hasConnected) {
+ return;
+ }
+ this._hasConnected = true;
+
+ this._toolbox = null;
+ this._newElementCount = 0;
+
+ // Search for the toolbox palette in the toolbar binding because
+ // toolbars are constructed first.
+ let toolbox = this.toolbox;
+ if (!toolbox) {
+ return;
+ }
+
+ if (!toolbox.palette) {
+ // Look to see if there is a toolbarpalette.
+ let node = toolbox.firstElementChild;
+ while (node) {
+ if (node.localName == "toolbarpalette") {
+ break;
+ }
+ node = node.nextElementSibling;
+ }
+
+ if (!node) {
+ return;
+ }
+
+ // Hold on to the palette but remove it from the document.
+ toolbox.palette = node;
+ toolbox.removeChild(node);
+ }
+
+ // Build up our contents from the palette.
+ let currentSet =
+ this.getAttribute("currentset") || this.getAttribute("defaultset");
+
+ if (currentSet) {
+ this.currentSet = currentSet;
+ }
+ }
+
+ /**
+ * Get the toolbox element connected to this toolbar.
+ *
+ * @returns {Element?} The toolbox element or null.
+ */
+ get toolbox() {
+ if (this._toolbox) {
+ return this._toolbox;
+ }
+
+ let toolboxId = this.getAttribute("toolboxid");
+ if (toolboxId) {
+ let toolbox = document.getElementById(toolboxId);
+ if (!toolbox) {
+ let tbName = this.hasAttribute("toolbarname")
+ ? ` (${this.getAttribute("toolbarname")})`
+ : "";
+
+ throw new Error(
+ `toolbar ID ${this.id}${tbName}: toolboxid attribute '${toolboxId}' points to a toolbox that doesn't exist`
+ );
+ }
+ this._toolbox = toolbox;
+ return this._toolbox;
+ }
+
+ this._toolbox =
+ this.parentNode && this.parentNode.localName == "toolbox"
+ ? this.parentNode
+ : null;
+
+ return this._toolbox;
+ }
+
+ /**
+ * Sets the current set of items in the toolbar.
+ *
+ * @param {string} val - Comma-separated list of IDs or "__empty".
+ * @returns {string} Comma-separated list of IDs or "__empty".
+ */
+ set currentSet(val) {
+ if (val == this.currentSet) {
+ return;
+ }
+
+ // Build a cache of items in the toolbarpalette.
+ let palette = this.toolbox ? this.toolbox.palette : null;
+ let paletteChildren = palette ? palette.children : [];
+
+ let paletteItems = {};
+
+ for (let item of paletteChildren) {
+ paletteItems[item.id] = item;
+ }
+
+ let ids = val == "__empty" ? [] : val.split(",");
+ let children = this.children;
+ let nodeidx = 0;
+ let added = {};
+
+ // Iterate over the ids to use on the toolbar.
+ for (let id of ids) {
+ // Iterate over the existing nodes on the toolbar. nodeidx is the
+ // spot where we want to insert items.
+ let found = false;
+ for (let i = nodeidx; i < children.length; i++) {
+ let curNode = children[i];
+ if (this._idFromNode(curNode) == id) {
+ // The node already exists. If i equals nodeidx, we haven't
+ // iterated yet, so the item is already in the right position.
+ // Otherwise, insert it here.
+ if (i != nodeidx) {
+ this.insertBefore(curNode, children[nodeidx]);
+ }
+
+ added[curNode.id] = true;
+ nodeidx++;
+ found = true;
+ break;
+ }
+ }
+ if (found) {
+ // Move on to the next id.
+ continue;
+ }
+
+ // The node isn't already on the toolbar, so add a new one.
+ let nodeToAdd = paletteItems[id] || this._getToolbarItem(id);
+ if (nodeToAdd && !(nodeToAdd.id in added)) {
+ added[nodeToAdd.id] = true;
+ this.insertBefore(nodeToAdd, children[nodeidx] || null);
+ nodeToAdd.setAttribute("removable", "true");
+ nodeidx++;
+ }
+ }
+
+ // Remove any leftover removable nodes.
+ for (let i = children.length - 1; i >= nodeidx; i--) {
+ let curNode = children[i];
+
+ let curNodeId = this._idFromNode(curNode);
+ // Skip over fixed items.
+ if (curNodeId && curNode.getAttribute("removable") == "true") {
+ if (palette) {
+ palette.appendChild(curNode);
+ } else {
+ this.removeChild(curNode);
+ }
+ }
+ }
+ }
+
+ /**
+ * Gets the current set of items in the toolbar.
+ *
+ * @returns {string} Comma-separated list of IDs or "__empty".
+ */
+ get currentSet() {
+ let node = this.firstElementChild;
+ let currentSet = [];
+ while (node) {
+ let id = this._idFromNode(node);
+ if (id) {
+ currentSet.push(id);
+ }
+ node = node.nextElementSibling;
+ }
+
+ return currentSet.join(",") || "__empty";
+ }
+
+ /**
+ * Return the ID for a given toolbar item node, with special handling for
+ * some cases.
+ *
+ * @param {Element} node - Return the ID of this node.
+ * @returns {string} The ID of the node.
+ */
+ _idFromNode(node) {
+ if (node.getAttribute("skipintoolbarset") == "true") {
+ return "";
+ }
+ const specialItems = {
+ toolbarseparator: "separator",
+ toolbarspring: "spring",
+ toolbarspacer: "spacer",
+ };
+ return specialItems[node.localName] || node.id;
+ }
+
+ /**
+ * Returns a toolbar item based on the given ID.
+ *
+ * @param {string} id - The ID for the new toolbar item.
+ * @returns {Element?} The toolbar item corresponding to the ID, or null.
+ */
+ _getToolbarItem(id) {
+ // Handle special cases.
+ if (["separator", "spring", "spacer"].includes(id)) {
+ let newItem = document.createXULElement("toolbar" + id);
+ // Due to timers resolution Date.now() can be the same for
+ // elements created in small timeframes. So ids are
+ // differentiated through a unique count suffix.
+ newItem.id = id + Date.now() + ++this._newElementCount;
+ if (id == "spring") {
+ newItem.flex = 1;
+ }
+ return newItem;
+ }
+
+ let toolbox = this.toolbox;
+ if (!toolbox) {
+ return null;
+ }
+
+ // Look for an item with the same id, as the item may be
+ // in a different toolbar.
+ let item = document.getElementById(id);
+ if (
+ item &&
+ item.parentNode &&
+ item.parentNode.localName == "toolbar" &&
+ item.parentNode.toolbox == toolbox
+ ) {
+ return item;
+ }
+
+ if (toolbox.palette) {
+ // Attempt to locate an item with a matching ID within the palette.
+ let paletteItem = toolbox.palette.firstElementChild;
+ while (paletteItem) {
+ if (paletteItem.id == id) {
+ return paletteItem;
+ }
+ paletteItem = paletteItem.nextElementSibling;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Insert an item into the toolbar.
+ *
+ * @param {string} id - The ID of the item to insert.
+ * @param {Element?} beforeElt - Optional element to insert the item before.
+ * @param {Element?} wrapper - Optional wrapper element.
+ * @returns {Element} The inserted item.
+ */
+ insertItem(id, beforeElt, wrapper) {
+ let newItem = this._getToolbarItem(id);
+ if (!newItem) {
+ return null;
+ }
+
+ let insertItem = newItem;
+ // Make sure added items are removable.
+ newItem.setAttribute("removable", "true");
+
+ // Wrap the item in another node if so inclined.
+ if (wrapper) {
+ wrapper.appendChild(newItem);
+ insertItem = wrapper;
+ }
+
+ // Insert the palette item into the toolbar.
+ if (beforeElt) {
+ this.insertBefore(insertItem, beforeElt);
+ } else {
+ this.appendChild(insertItem);
+ }
+ return newItem;
+ }
+
+ /**
+ * Determine whether the current set of toolbar items has custom
+ * interactive items or not.
+ *
+ * @param {string} currentSet - Comma-separated list of IDs or "__empty".
+ * @returns {boolean} Whether the current set has custom interactive items.
+ */
+ hasCustomInteractiveItems(currentSet) {
+ if (currentSet == "__empty") {
+ return false;
+ }
+
+ let defaultOrNoninteractive = (this.getAttribute("defaultset") || "")
+ .split(",")
+ .concat(["separator", "spacer", "spring"]);
+
+ return currentSet
+ .split(",")
+ .some(item => !defaultOrNoninteractive.includes(item));
+ }
+ }
+
+ customElements.define("customizable-toolbar", CustomizableToolbar, {
+ extends: "toolbar",
+ });
+}
diff --git a/comm/mail/base/content/widgets/foldersummary.js b/comm/mail/base/content/widgets/foldersummary.js
new file mode 100644
index 0000000000..48bcb34d26
--- /dev/null
+++ b/comm/mail/base/content/widgets/foldersummary.js
@@ -0,0 +1,295 @@
+/**
+ * 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 MozElements */
+/* global MozXULElement */
+/* import-globals-from ../../../../mailnews/base/content/newmailalert.js */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+
+ /**
+ * MozFolderSummary displays a listing of NEW mails for the folder in question.
+ * For each mail the subject, sender and a message preview can be included.
+ *
+ * @augments {MozXULElement}
+ */
+ class MozFolderSummary extends MozXULElement {
+ constructor() {
+ super();
+ this.maxMsgHdrsInPopup = 8;
+
+ this.showSubject = Services.prefs.getBoolPref(
+ "mail.biff.alert.show_subject"
+ );
+ this.showSender = Services.prefs.getBoolPref(
+ "mail.biff.alert.show_sender"
+ );
+ this.showPreview = Services.prefs.getBoolPref(
+ "mail.biff.alert.show_preview"
+ );
+ this.messengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+
+ ChromeUtils.defineModuleGetter(
+ this,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+ );
+ }
+
+ hasMessages() {
+ return this.lastElementChild;
+ }
+
+ static createFolderSummaryMessage() {
+ let vbox = document.createXULElement("vbox");
+ vbox.setAttribute("class", "folderSummaryMessage");
+
+ let hbox = document.createXULElement("hbox");
+ hbox.setAttribute("class", "folderSummary-message-row");
+
+ let subject = document.createXULElement("label");
+ subject.setAttribute("class", "folderSummary-subject");
+
+ let sender = document.createXULElement("label");
+ sender.setAttribute("class", "folderSummary-sender");
+ sender.setAttribute("crop", "end");
+
+ hbox.appendChild(subject);
+ hbox.appendChild(sender);
+
+ let preview = document.createXULElement("description");
+ preview.setAttribute(
+ "class",
+ "folderSummary-message-row folderSummary-previewText"
+ );
+ preview.setAttribute("crop", "end");
+
+ vbox.appendChild(hbox);
+ vbox.appendChild(preview);
+ return vbox;
+ }
+
+ /**
+ * Check the given folder for NEW messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to examine.
+ * @param {nsIUrlListener} urlListener - Listener to notify if we run urls
+ * to fetch msgs.
+ * @param Object outAsync - Object with value property set to true if there
+ * are async fetches pending (a message preview will be available later).
+ * @returns true if the folder knows about messages that should be shown.
+ */
+ parseFolder(folder, urlListener, outAsync) {
+ // Skip servers, Trash, Junk folders and newsgroups.
+ if (
+ !folder ||
+ folder.isServer ||
+ !folder.hasNewMessages ||
+ folder.getFlag(Ci.nsMsgFolderFlags.Junk) ||
+ folder.getFlag(Ci.nsMsgFolderFlags.Trash) ||
+ folder.server instanceof Ci.nsINntpIncomingServer
+ ) {
+ return false;
+ }
+
+ let folderArray = [];
+ let msgDatabase;
+ try {
+ msgDatabase = folder.msgDatabase;
+ } catch (e) {
+ // The database for this folder may be missing (e.g. outdated/missing .msf),
+ // so just skip this folder.
+ return false;
+ }
+
+ if (folder.flags & Ci.nsMsgFolderFlags.Virtual) {
+ let srchFolderUri =
+ msgDatabase.dBFolderInfo.getCharProperty("searchFolderUri");
+ let folderUris = srchFolderUri.split("|");
+ for (let uri of folderUris) {
+ let realFolder = this.MailUtils.getOrCreateFolder(uri);
+ if (!realFolder.isServer) {
+ folderArray.push(realFolder);
+ }
+ }
+ } else {
+ folderArray.push(folder);
+ }
+
+ let haveMsgsToShow = false;
+ for (let folder of folderArray) {
+ // now get the database
+ try {
+ msgDatabase = folder.msgDatabase;
+ } catch (e) {
+ // The database for this folder may be missing (e.g. outdated/missing .msf),
+ // then just skip this folder.
+ continue;
+ }
+
+ folder.msgDatabase = null;
+ let msgKeys = msgDatabase.getNewList();
+
+ let numNewMessages = folder.getNumNewMessages(false);
+ if (!numNewMessages) {
+ continue;
+ }
+ // NOTE: getNewlist returns all nsMsgMessageFlagType::New messages,
+ // while getNumNewMessages returns count of new messages since the last
+ // biff. Only show newly received messages since last biff in
+ // notification.
+ msgKeys = msgKeys.slice(-numNewMessages);
+ if (!msgKeys.length) {
+ continue;
+ }
+
+ if (this.showPreview) {
+ // fetchMsgPreviewText forces the previewText property to get generated
+ // for each of the message keys.
+ try {
+ outAsync.value = folder.fetchMsgPreviewText(msgKeys, urlListener);
+ folder.msgDatabase = null;
+ } catch (ex) {
+ // fetchMsgPreviewText throws an error when we call it on a news
+ // folder
+ folder.msgDatabase = null;
+ continue;
+ }
+ }
+
+ // If fetching the preview text is going to be an asynch operation and the
+ // caller is set up to handle that fact, then don't bother filling in any
+ // of the fields since we'll have to do this all over again when the fetch
+ // for the preview text completes.
+ // We don't expect to get called with a urlListener if we're doing a
+ // virtual folder.
+ if (outAsync.value && urlListener) {
+ return false;
+ }
+
+ // In the case of async fetching for more than one folder, we may
+ // already have got enough to show (added by another urllistener).
+ let curHdrsInPopup = this.children.length;
+ if (curHdrsInPopup >= this.maxMsgHdrsInPopup) {
+ return false;
+ }
+
+ for (
+ let i = 0;
+ i + curHdrsInPopup < this.maxMsgHdrsInPopup && i < msgKeys.length;
+ i++
+ ) {
+ let msgBox = MozFolderSummary.createFolderSummaryMessage();
+ let msgHdr = msgDatabase.getMsgHdrForKey(msgKeys[i]);
+ msgBox.addEventListener("click", event => {
+ if (event.button !== 0) {
+ return;
+ }
+ this.MailUtils.displayMessageInFolderTab(msgHdr, true);
+ });
+
+ if (this.showSubject) {
+ let msgSubject = msgHdr.mime2DecodedSubject;
+ const kMsgFlagHasRe = 0x0010; // MSG_FLAG_HAS_RE
+ if (msgHdr.flags & kMsgFlagHasRe) {
+ msgSubject = msgSubject ? "Re: " + msgSubject : "Re: ";
+ }
+ msgBox.querySelector(".folderSummary-subject").textContent =
+ msgSubject;
+ }
+
+ if (this.showSender) {
+ let addrs = MailServices.headerParser.parseEncodedHeader(
+ msgHdr.author,
+ msgHdr.effectiveCharset,
+ false
+ );
+ let folderSummarySender = msgBox.querySelector(
+ ".folderSummary-sender"
+ );
+ // Set the label value instead of textContent to avoid wrapping.
+ folderSummarySender.value =
+ addrs.length > 0 ? addrs[0].name || addrs[0].email : "";
+ if (addrs.length > 1) {
+ let andOthersStr =
+ this.messengerBundle.GetStringFromName("andOthers");
+ folderSummarySender.value += " " + andOthersStr;
+ }
+ }
+
+ if (this.showPreview) {
+ msgBox.querySelector(".folderSummary-previewText").textContent =
+ msgHdr.getStringProperty("preview") || "";
+ }
+ this.appendChild(msgBox);
+ haveMsgsToShow = true;
+ }
+ }
+ return haveMsgsToShow;
+ }
+
+ /**
+ * Render NEW messages in a folder.
+ *
+ * @param {nsIMsgFolder} folder - A real folder containing new messages.
+ * @param {number[]} msgKeys - The keys of new messages.
+ */
+ render(folder, msgKeys) {
+ let msgDatabase = folder.msgDatabase;
+ for (let msgKey of msgKeys.slice(0, this.maxMsgHdrsInPopup)) {
+ let msgBox = MozFolderSummary.createFolderSummaryMessage();
+ let msgHdr = msgDatabase.getMsgHdrForKey(msgKey);
+ msgBox.addEventListener("click", event => {
+ if (event.button !== 0) {
+ return;
+ }
+ this.MailUtils.displayMessageInFolderTab(msgHdr, true);
+ });
+
+ if (this.showSubject) {
+ let msgSubject = msgHdr.mime2DecodedSubject;
+ const kMsgFlagHasRe = 0x0010; // MSG_FLAG_HAS_RE
+ if (msgHdr.flags & kMsgFlagHasRe) {
+ msgSubject = msgSubject ? "Re: " + msgSubject : "Re: ";
+ }
+ msgBox.querySelector(".folderSummary-subject").textContent =
+ msgSubject;
+ }
+
+ if (this.showSender) {
+ let addrs = MailServices.headerParser.parseEncodedHeader(
+ msgHdr.author,
+ msgHdr.effectiveCharset,
+ false
+ );
+ let folderSummarySender = msgBox.querySelector(
+ ".folderSummary-sender"
+ );
+ // Set the label value instead of textContent to avoid wrapping.
+ folderSummarySender.value =
+ addrs.length > 0 ? addrs[0].name || addrs[0].email : "";
+ if (addrs.length > 1) {
+ let andOthersStr =
+ this.messengerBundle.GetStringFromName("andOthers");
+ folderSummarySender.value += " " + andOthersStr;
+ }
+ }
+
+ if (this.showPreview) {
+ msgBox.querySelector(".folderSummary-previewText").textContent =
+ msgHdr.getStringProperty("preview") || "";
+ }
+ this.appendChild(msgBox);
+ }
+ }
+ }
+ customElements.define("folder-summary", MozFolderSummary);
+}
diff --git a/comm/mail/base/content/widgets/gloda-autocomplete-input.js b/comm/mail/base/content/widgets/gloda-autocomplete-input.js
new file mode 100644
index 0000000000..59f71ba6ae
--- /dev/null
+++ b/comm/mail/base/content/widgets/gloda-autocomplete-input.js
@@ -0,0 +1,243 @@
+/**
+ * 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 MozXULElement */
+
+"use strict";
+
+// The autocomplete CE is defined lazily. Create one now to get
+// autocomplete-input defined, allowing us to inherit from it.
+if (!customElements.get("autocomplete-input")) {
+ delete document.createXULElement("input", { is: "autocomplete-input" });
+}
+
+customElements.whenDefined("autocomplete-input").then(() => {
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ );
+
+ const lazy = {};
+ ChromeUtils.defineESModuleGetters(lazy, {
+ GlodaIMSearcher: "resource:///modules/GlodaIMSearcher.sys.mjs",
+ });
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "Gloda",
+ "resource:///modules/gloda/GlodaPublic.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaMsgSearcher",
+ "resource:///modules/gloda/GlodaMsgSearcher.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaConstants",
+ "resource:///modules/gloda/GlodaConstants.jsm"
+ );
+
+ XPCOMUtils.defineLazyGetter(
+ lazy,
+ "glodaCompleter",
+ () =>
+ Cc["@mozilla.org/autocomplete/search;1?name=gloda"].getService(
+ Ci.nsIAutoCompleteSearch
+ ).wrappedJSObject
+ );
+
+ /**
+ * The MozGlodaAutocompleteInput widget is used to display the autocomplete search bar.
+ *
+ * @augments {AutocompleteInput}
+ */
+ class MozGlodaAutocompleteInput extends customElements.get(
+ "autocomplete-input"
+ ) {
+ constructor() {
+ super();
+
+ this.addEventListener(
+ "drop",
+ event => {
+ this.searchInputDNDObserver.onDrop(event);
+ },
+ true
+ );
+
+ this.addEventListener("keypress", event => {
+ if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
+ // Trigger the click event if a popup result is currently selected.
+ if (this.popup.richlistbox.selectedIndex != -1) {
+ this.popup.onPopupClick(event);
+ } else {
+ this.doSearch();
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ this.clearSearch();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ });
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ this.hasConnected = true;
+ super.connectedCallback();
+
+ this.setAttribute("is", "gloda-autocomplete-input");
+
+ // @implements {nsIObserver}
+ this.searchInputDNDObserver = {
+ onDrop: event => {
+ if (event.dataTransfer.types.includes("text/x-moz-address")) {
+ this.focus();
+ this.value = event.dataTransfer.getData("text/plain");
+ // XXX for some reason the input field is _cleared_ even though
+ // the search works.
+ this.doSearch();
+ }
+ event.stopPropagation();
+ },
+ };
+
+ // @implements {nsIObserver}
+ this.textObserver = {
+ observe: (subject, topic, data) => {
+ try {
+ // Some autocomplete controllers throw NS_ERROR_NOT_IMPLEMENTED.
+ subject.popupElement;
+ } catch (ex) {
+ return;
+ }
+ if (
+ topic == "autocomplete-did-enter-text" &&
+ document.activeElement == this
+ ) {
+ let selectedIndex = this.popup.selectedIndex;
+ let curResult = lazy.glodaCompleter.curResult;
+ if (!curResult) {
+ // autocomplete didn't even finish.
+ return;
+ }
+ let row = curResult.getObjectAt(selectedIndex);
+ if (row == null) {
+ return;
+ }
+ if (row.fullText) {
+ // The autocomplete-did-enter-text notification is synchronously
+ // generated by nsAutoCompleteController which will attempt to
+ // call ClosePopup after we return and then tell the searchbox
+ // about the text entered. Since doSearch may close the current
+ // tab (and thus destroy the XUL document that owns the popup and
+ // the input field), the search box may no longer have its
+ // binding attached when we return and telling it about the
+ // entered text could fail.
+ // To avoid this, we defer the doSearch call to the next turn of
+ // the event loop by using setTimeout.
+ setTimeout(this.doSearch.bind(this), 0);
+ } else if (row.nounDef) {
+ let theQuery = lazy.Gloda.newQuery(
+ lazy.GlodaConstants.NOUN_MESSAGE
+ );
+ if (row.nounDef.name == "tag") {
+ theQuery = theQuery.tags(row.item);
+ } else if (row.nounDef.name == "identity") {
+ theQuery = theQuery.involves(row.item);
+ }
+ theQuery.orderBy("-date");
+ document.getElementById("tabmail").openTab("glodaFacet", {
+ query: theQuery,
+ });
+ }
+ }
+ },
+ };
+
+ let keyLabel =
+ AppConstants.platform == "macosx" ? "keyLabelMac" : "keyLabelNonMac";
+ let placeholder = this.getAttribute("emptytextbase").replace(
+ "#1",
+ this.getAttribute(keyLabel)
+ );
+
+ this.setAttribute("placeholder", placeholder);
+
+ Services.obs.addObserver(
+ this.textObserver,
+ "autocomplete-did-enter-text"
+ );
+
+ // make sure we set our emptytext here from the get-go
+ if (this.hasAttribute("placeholder")) {
+ this.placeholder = this.getAttribute("placeholder");
+ }
+ }
+
+ set state(val) {
+ this.value = val.string;
+ }
+
+ get state() {
+ return { string: this.value };
+ }
+
+ doSearch() {
+ if (this.value) {
+ let tabmail = document.getElementById("tabmail");
+ // If the current tab is a gloda search tab, reset the value
+ // to the initial search value. Otherwise, clear it. This
+ // is the value that is going to be saved with the current
+ // tab when we switch back to it next.
+ let searchString = this.value;
+
+ if (tabmail.currentTabInfo.mode.name == "glodaFacet") {
+ // We'd rather reuse the existing tab (and somehow do something
+ // smart with any preexisting facet choices, but that's a
+ // bit hard right now, so doing the cheap thing and closing
+ // this tab and starting over.
+ tabmail.closeTab();
+ }
+ this.value = ""; // clear our value, to avoid persistence
+ let args = {
+ searcher: new lazy.GlodaMsgSearcher(null, searchString),
+ };
+ if (Services.prefs.getBoolPref("mail.chat.enabled")) {
+ args.IMSearcher = new lazy.GlodaIMSearcher(null, searchString);
+ }
+ tabmail.openTab("glodaFacet", args);
+ }
+ }
+
+ clearSearch() {
+ this.value = "";
+ }
+
+ disconnectedCallback() {
+ Services.obs.removeObserver(
+ this.textObserver,
+ "autocomplete-did-enter-text"
+ );
+ this.hasConnected = false;
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozGlodaAutocompleteInput, [
+ Ci.nsIObserver,
+ ]);
+ customElements.define("gloda-autocomplete-input", MozGlodaAutocompleteInput, {
+ extends: "input",
+ });
+});
diff --git a/comm/mail/base/content/widgets/glodaFacet.js b/comm/mail/base/content/widgets/glodaFacet.js
new file mode 100644
index 0000000000..c8d1e78dd8
--- /dev/null
+++ b/comm/mail/base/content/widgets/glodaFacet.js
@@ -0,0 +1,1823 @@
+/* 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 DateFacetVis, FacetContext */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ const { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+ const { FacetUtils } = ChromeUtils.import(
+ "resource:///modules/gloda/Facet.jsm"
+ );
+ const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+ );
+ const { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm");
+
+ var glodaFacetStrings = Services.strings.createBundle(
+ "chrome://messenger/locale/glodaFacetView.properties"
+ );
+
+ class MozFacetDate extends HTMLElement {
+ get build() {
+ return this.buildFunc;
+ }
+
+ get brushItems() {
+ return items => this.vis.hoverItems(items);
+ }
+
+ get clearBrushedItems() {
+ return () => this.vis.clearHover();
+ }
+
+ connectedCallback() {
+ const wrapper = document.createElement("div");
+ wrapper.classList.add("facet", "date-wrapper");
+
+ const h2 = document.createElement("h2");
+
+ const canvas = document.createElement("div");
+ canvas.classList.add("date-vis-frame");
+
+ const zoomOut = document.createElement("div");
+ zoomOut.classList.add("facet-date-zoom-out");
+ zoomOut.setAttribute("role", "image");
+ zoomOut.addEventListener("click", () => FacetContext.zoomOut());
+
+ wrapper.appendChild(h2);
+ wrapper.appendChild(canvas);
+ wrapper.appendChild(zoomOut);
+ this.appendChild(wrapper);
+
+ this.canUpdate = true;
+ this.canvasNode = canvas;
+ this.vis = null;
+ if ("faceter" in this) {
+ this.buildFunc(true);
+ }
+ }
+
+ buildFunc(aDoSize) {
+ if (!this.vis) {
+ this.vis = new DateFacetVis(this, this.canvasNode);
+ this.vis.build();
+ } else {
+ while (this.canvasNode.hasChildNodes()) {
+ this.canvasNode.lastChild.remove();
+ }
+ if (aDoSize) {
+ this.vis.build();
+ } else {
+ this.vis.rebuild();
+ }
+ }
+ }
+ }
+
+ customElements.define("facet-date", MozFacetDate);
+
+ /**
+ * MozFacetResultsMessage shows the search results for the string entered in gloda-searchbox.
+ *
+ * @augments {HTMLElement}
+ */
+ class MozFacetResultsMessage extends HTMLElement {
+ connectedCallback() {
+ const header = document.createElement("div");
+ header.classList.add("results-message-header");
+
+ this.countNode = document.createElement("h2");
+ this.countNode.classList.add("results-message-count");
+
+ this.toggleTimeline = document.createElement("button");
+ this.toggleTimeline.setAttribute("id", "date-toggle");
+ this.toggleTimeline.setAttribute("tabindex", 0);
+ this.toggleTimeline.classList.add("gloda-timeline-button");
+ this.toggleTimeline.addEventListener("click", () => {
+ FacetContext.toggleTimeline();
+ });
+
+ const timelineImage = document.createElement("img");
+ timelineImage.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/popular.svg"
+ );
+ timelineImage.setAttribute("alt", "");
+ this.toggleTimeline.appendChild(timelineImage);
+
+ this.toggleText = document.createElement("span");
+ this.toggleTimeline.appendChild(this.toggleText);
+
+ const sortDiv = document.createElement("div");
+ sortDiv.classList.add("results-message-sort-bar");
+
+ this.sortSelect = document.createElement("select");
+ this.sortSelect.setAttribute("id", "sortby");
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+
+ let relevanceItem = document.createElement("option");
+ relevanceItem.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.sort.relevance2"
+ );
+ relevanceItem.setAttribute("value", "-dascore");
+ relevanceItem.toggleAttribute(
+ "selected",
+ sortByPref <= 0 || sortByPref == 2 || sortByPref > 3
+ );
+ this.sortSelect.appendChild(relevanceItem);
+
+ let dateItem = document.createElement("option");
+ dateItem.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.sort.date2"
+ );
+ dateItem.setAttribute("value", "-date");
+ dateItem.toggleAttribute("selected", sortByPref == 1 || sortByPref == 3);
+ this.sortSelect.appendChild(dateItem);
+
+ this.messagesNode = document.createElement("div");
+ this.messagesNode.classList.add("messages");
+
+ header.appendChild(this.countNode);
+ header.appendChild(this.toggleTimeline);
+ header.appendChild(sortDiv);
+
+ sortDiv.appendChild(this.sortSelect);
+
+ this.appendChild(header);
+ this.appendChild(this.messagesNode);
+ }
+
+ setMessages(messages) {
+ let topMessagesPluralFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.NMessages"
+ );
+ let outOfPluralFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.ofN"
+ );
+ let groupingFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.grouping"
+ );
+
+ let displayCount = messages.length;
+ let totalCount = FacetContext.activeSet.length;
+
+ // set the count so CSS selectors can know what the results look like
+ this.setAttribute("state", totalCount <= 0 ? "empty" : "some");
+
+ let topMessagesStr = PluralForm.get(
+ displayCount,
+ topMessagesPluralFormat
+ ).replace("#1", displayCount.toLocaleString());
+ let outOfStr = PluralForm.get(totalCount, outOfPluralFormat).replace(
+ "#1",
+ totalCount.toLocaleString()
+ );
+
+ this.countNode.textContent = groupingFormat
+ .replace("#1", topMessagesStr)
+ .replace("#2", outOfStr);
+
+ this.toggleText.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.timeline.label"
+ );
+
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+ this.sortSelect.addEventListener("change", () => {
+ if (sortByPref >= 2) {
+ Services.prefs.setIntPref(
+ "gloda.facetview.sortby",
+ this.sortSelect.value == "-dascore" ? 2 : 3
+ );
+ }
+
+ FacetContext.sortBy = this.sortSelect.value;
+ });
+
+ while (this.messagesNode.hasChildNodes()) {
+ this.messagesNode.lastChild.remove();
+ }
+ try {
+ // -- Messages
+ for (let message of messages) {
+ let msgNode = document.createElement("facet-result-message");
+ msgNode.message = message;
+ msgNode.setAttribute("class", "message");
+ this.messagesNode.appendChild(msgNode);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ customElements.define("facet-results-message", MozFacetResultsMessage);
+
+ class MozFacetBoolean extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("mouseover", event => {
+ FacetContext.hoverFacet(
+ this.faceter,
+ this.faceter.attrDef,
+ true,
+ this.trueValues
+ );
+ });
+
+ this.addEventListener("mouseout", event => {
+ FacetContext.unhoverFacet(
+ this.faceter,
+ this.faceter.attrDef,
+ true,
+ this.trueValues
+ );
+ });
+ }
+
+ connectedCallback() {
+ this.addChildren();
+
+ this.canUpdate = true;
+ this.bubble.addEventListener("click", event => {
+ return this.bubbleClicked(event);
+ });
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+ }
+
+ addChildren() {
+ this.bubble = document.createElement("span");
+ this.bubble.classList.add("facet-checkbox-bubble");
+
+ this.checkbox = document.createElement("input");
+ this.checkbox.setAttribute("type", "checkbox");
+
+ this.labelNode = document.createElement("span");
+ this.labelNode.classList.add("facet-checkbox-label");
+
+ this.countNode = document.createElement("span");
+ this.countNode.classList.add("facet-checkbox-count");
+
+ this.bubble.appendChild(this.checkbox);
+ this.bubble.appendChild(this.labelNode);
+ this.bubble.appendChild(this.countNode);
+
+ this.appendChild(this.bubble);
+ }
+
+ set disabled(val) {
+ if (val) {
+ this.setAttribute("disabled", "true");
+ this.checkbox.setAttribute("disabled", "true");
+ } else {
+ this.removeAttribute("disabled");
+ this.checkbox.removeAttribute("disabled");
+ }
+ }
+
+ get disabled() {
+ return this.getAttribute("disabled") == "true";
+ }
+
+ set checked(val) {
+ if (this.checked == val) {
+ return;
+ }
+ this.checkbox.checked = val;
+ if (val) {
+ this.setAttribute("checked", "true");
+ if (!this.disabled) {
+ FacetContext.addFacetConstraint(this.faceter, true, this.trueGroups);
+ }
+ } else {
+ this.removeAttribute("checked");
+ this.checkbox.removeAttribute("checked");
+ if (!this.disabled) {
+ FacetContext.removeFacetConstraint(
+ this.faceter,
+ true,
+ this.trueGroups
+ );
+ }
+ }
+ this.checkStateChanged();
+ }
+
+ get checked() {
+ return this.getAttribute("checked") == "true";
+ }
+
+ extraSetup() {}
+
+ checkStateChanged() {}
+
+ brushItems() {}
+
+ clearBrushedItems() {}
+
+ build(firstTime) {
+ if (firstTime) {
+ this.labelNode.textContent = this.facetDef.strings.facetNameLabel;
+ this.checkbox.setAttribute(
+ "aria-label",
+ this.facetDef.strings.facetNameLabel
+ );
+ this.trueValues = [];
+ }
+
+ // If we do not currently have a constraint applied and there is only
+ // one (or no) group, then: disable us, but reflect the underlying
+ // state of the data (checked or non-checked)
+ if (!this.faceter.constraint && this.orderedGroups.length <= 1) {
+ this.disabled = true;
+ let count = 0;
+ if (this.orderedGroups.length) {
+ // true case?
+ if (this.orderedGroups[0][0]) {
+ count = this.orderedGroups[0][1].length;
+ this.checked = true;
+ } else {
+ this.checked = false;
+ }
+ }
+ this.countNode.textContent = count.toLocaleString();
+ return;
+ }
+ // if we were disabled checked before, clear ourselves out
+ if (this.disabled && this.checked) {
+ this.checked = false;
+ }
+ this.disabled = false;
+
+ // if we are here, we have our 2 groups, find true...
+ // (note: it is possible to get jerked around by null values
+ // currently, so leave a reasonable failure case)
+ this.trueValues = [];
+ this.trueGroups = [true];
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0]) {
+ this.trueValues = groupPair[1];
+ }
+ }
+
+ this.countNode.textContent = this.trueValues.length.toLocaleString();
+ }
+
+ bubbleClicked(event) {
+ if (!this.disabled) {
+ this.checked = !this.checked;
+ }
+ event.stopPropagation();
+ }
+ }
+
+ customElements.define("facet-boolean", MozFacetBoolean);
+
+ class MozFacetBooleanFiltered extends MozFacetBoolean {
+ static get observedAttributes() {
+ return ["checked", "disabled"];
+ }
+
+ connectedCallback() {
+ super.addChildren();
+
+ this.filterNode = document.createElement("select");
+ this.filterNode.classList.add("facet-filter-list");
+ this.appendChild(this.filterNode);
+
+ this.canUpdate = true;
+ this.bubble.addEventListener("click", event => {
+ return super.bubbleClicked(event);
+ });
+
+ this.extraSetup();
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.checkbox) {
+ return;
+ }
+
+ if (this.hasAttribute("checked")) {
+ this.checkbox.setAttribute("checked", this.getAttribute("checked"));
+ } else {
+ this.checkbox.removeAttribute("checked");
+ }
+
+ if (this.hasAttribute("disabled")) {
+ this.checkbox.setAttribute("disabled", this.getAttribute("disabled"));
+ } else {
+ this.checkbox.removeAttribute("disabled");
+ }
+ }
+
+ extraSetup() {
+ this.groupDisplayProperty = this.getAttribute("groupDisplayProperty");
+
+ this.filterNode.addEventListener("change", event =>
+ this.filterChanged(event)
+ );
+
+ this.selectedValue = "all";
+ }
+
+ build(firstTime) {
+ if (firstTime) {
+ this.labelNode.textContent = this.facetDef.strings.facetNameLabel;
+ this.checkbox.setAttribute(
+ "aria-label",
+ this.facetDef.strings.facetNameLabel
+ );
+ this.trueValues = [];
+ }
+
+ // Only update count if anything other than "all" is selected.
+ // Otherwise we lose the set of attachment types in our select box,
+ // and that makes us sad. We do want to update on "all" though
+ // because other facets may further reduce the number of attachments
+ // we see. (Or if this is not just being used for attachments, it
+ // still holds.)
+ if (this.selectedValue != "all") {
+ let count = 0;
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0] != null) {
+ count += groupPair[1].length;
+ }
+ }
+ this.countNode.textContent = count.toLocaleString();
+ return;
+ }
+
+ while (this.filterNode.hasChildNodes()) {
+ this.filterNode.lastChild.remove();
+ }
+
+ let allNode = document.createElement("option");
+ allNode.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.filter." +
+ this.attrDef.attributeName +
+ ".allLabel"
+ );
+ allNode.setAttribute("value", "all");
+ if (this.selectedValue == "all") {
+ allNode.setAttribute("selected", "selected");
+ }
+ this.filterNode.appendChild(allNode);
+
+ // if we are here, we have our 2 groups, find true...
+ // (note: it is possible to get jerked around by null values
+ // currently, so leave a reasonable failure case)
+ // empty true groups is for the checkbox
+ this.trueGroups = [];
+ // the real true groups is the actual true values for our explicit
+ // filtering
+ this.realTrueGroups = [];
+ this.trueValues = [];
+ this.falseValues = [];
+ let selectNodes = [];
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0] === null) {
+ this.falseValues.push.apply(this.falseValues, groupPair[1]);
+ } else {
+ this.trueValues.push.apply(this.trueValues, groupPair[1]);
+
+ let groupValue = groupPair[0];
+ let selNode = document.createElement("option");
+ selNode.textContent = groupValue[this.groupDisplayProperty];
+ selNode.setAttribute("value", this.realTrueGroups.length);
+ if (this.selectedValue == groupValue.category) {
+ selNode.setAttribute("selected", "selected");
+ }
+ selectNodes.push(selNode);
+
+ this.realTrueGroups.push(groupValue);
+ }
+ }
+ selectNodes.sort((a, b) => {
+ return a.textContent.localeCompare(b.textContent);
+ });
+ selectNodes.forEach(selNode => {
+ this.filterNode.appendChild(selNode);
+ });
+
+ this.disabled = !this.trueValues.length;
+
+ this.countNode.textContent = this.trueValues.length.toLocaleString();
+ }
+
+ checkStateChanged() {
+ // if they un-check us, revert our value to all.
+ if (!this.checked) {
+ this.selectedValue = "all";
+ }
+ }
+
+ filterChanged(event) {
+ if (!this.checked) {
+ return;
+ }
+ if (this.filterNode.value == "all") {
+ this.selectedValue = "all";
+ FacetContext.addFacetConstraint(
+ this.faceter,
+ true,
+ this.trueGroups,
+ false,
+ true
+ );
+ } else {
+ let groupValue = this.realTrueGroups[parseInt(this.filterNode.value)];
+ this.selectedValue = groupValue.category;
+ FacetContext.addFacetConstraint(
+ this.faceter,
+ true,
+ [groupValue],
+ false,
+ true
+ );
+ }
+ }
+ }
+
+ customElements.define("facet-boolean-filtered", MozFacetBooleanFiltered);
+
+ class MozFacetDiscrete extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("click", event => {
+ this.showPopup(event);
+ });
+
+ this.addEventListener("keypress", event => {
+ if (event.keyCode != KeyEvent.DOM_VK_RETURN) {
+ return;
+ }
+ this.showPopup(event);
+ });
+
+ this.addEventListener("keypress", event => {
+ this.activateLink(event);
+ });
+
+ this.addEventListener("mouseover", event => {
+ // we dispatch based on the class of the thing we clicked on.
+ // there are other ways we could accomplish this, but they all sorta suck.
+ if (
+ event.target.hasAttribute("class") &&
+ event.target.classList.contains("bar-link")
+ ) {
+ this.barHovered(event.target.parentNode, true);
+ }
+ });
+
+ this.addEventListener("mouseout", event => {
+ // we dispatch based on the class of the thing we clicked on.
+ // there are other ways we could accomplish this, but they all sorta suck.
+ if (
+ event.target.hasAttribute("class") &&
+ event.target.classList.contains("bar-link")
+ ) {
+ this.barHoverGone(event.target.parentNode, true);
+ }
+ });
+ }
+
+ connectedCallback() {
+ const facet = document.createElement("div");
+ facet.classList.add("facet");
+
+ this.nameNode = document.createElement("h2");
+
+ this.contentBox = document.createElement("div");
+ this.contentBox.classList.add("facet-content");
+
+ this.includeLabel = document.createElement("h3");
+ this.includeLabel.classList.add("facet-included-header");
+
+ this.includeList = document.createElement("ul");
+ this.includeList.classList.add("facet-included", "barry");
+
+ this.remainderLabel = document.createElement("h3");
+ this.remainderLabel.classList.add("facet-remaindered-header");
+
+ this.remainderList = document.createElement("ul");
+ this.remainderList.classList.add("facet-remaindered", "barry");
+
+ this.excludeLabel = document.createElement("h3");
+ this.excludeLabel.classList.add("facet-excluded-header");
+
+ this.excludeList = document.createElement("ul");
+ this.excludeList.classList.add("facet-excluded", "barry");
+
+ this.moreButton = document.createElement("button");
+ this.moreButton.classList.add("facet-more");
+ this.moreButton.setAttribute("needed", "false");
+ this.moreButton.setAttribute("tabindex", "0");
+
+ this.contentBox.appendChild(this.includeLabel);
+ this.contentBox.appendChild(this.includeList);
+ this.contentBox.appendChild(this.remainderLabel);
+ this.contentBox.appendChild(this.remainderList);
+ this.contentBox.appendChild(this.excludeLabel);
+ this.contentBox.appendChild(this.excludeList);
+ this.contentBox.appendChild(this.moreButton);
+
+ facet.appendChild(this.nameNode);
+ facet.appendChild(this.contentBox);
+
+ this.appendChild(facet);
+
+ this.canUpdate = false;
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+ }
+
+ build(firstTime) {
+ // -- Header Building
+ this.nameNode.textContent = this.facetDef.strings.facetNameLabel;
+
+ // - include
+ // setup the include label
+ if ("includeLabel" in this.facetDef.strings) {
+ this.includeLabel.textContent = this.facetDef.strings.includeLabel;
+ } else {
+ this.includeLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.included.fallbackLabel"
+ );
+ }
+ this.includeLabel.setAttribute("state", "empty");
+
+ // - exclude
+ // setup the exclude label
+ if ("excludeLabel" in this.facetDef.strings) {
+ this.excludeLabel.textContent = this.facetDef.strings.excludeLabel;
+ } else {
+ this.excludeLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.excluded.fallbackLabel"
+ );
+ }
+ this.excludeLabel.setAttribute("state", "empty");
+
+ // - remainder
+ // setup the remainder label
+ if ("remainderLabel" in this.facetDef.strings) {
+ this.remainderLabel.textContent = this.facetDef.strings.remainderLabel;
+ } else {
+ this.remainderLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.remainder.fallbackLabel"
+ );
+ }
+
+ // -- House-cleaning
+ // -- All/Top mode decision
+ this.modes = ["all"];
+ if (this.maxDisplayRows >= this.orderedGroups.length) {
+ this.mode = "all";
+ } else {
+ // top mode must be used
+ this.modes.push("top");
+ this.mode = "top";
+ this.topGroups = FacetUtils.makeTopGroups(
+ this.attrDef,
+ this.orderedGroups,
+ this.maxDisplayRows
+ );
+ // setup the more button string
+ let groupCount = this.orderedGroups.length;
+ this.moreButton.textContent = PluralForm.get(
+ groupCount,
+ glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.mode.top.listAllLabel"
+ )
+ ).replace("#1", groupCount);
+ }
+
+ // -- Row Building
+ this.buildRows();
+ }
+
+ changeMode(newMode) {
+ this.mode = newMode;
+ this.setAttribute("mode", newMode);
+ this.buildRows();
+ }
+
+ buildRows() {
+ let nounDef = this.nounDef;
+ let useGroups = this.mode == "all" ? this.orderedGroups : this.topGroups;
+
+ // should we just rely on automatic string coercion?
+ this.moreButton.setAttribute(
+ "needed",
+ this.mode == "top" ? "true" : "false"
+ );
+
+ let constraint = this.faceter.constraint;
+
+ // -- empty all of our display buckets...
+ let remainderList = this.remainderList;
+ while (remainderList.hasChildNodes()) {
+ remainderList.lastChild.remove();
+ }
+ let includeList = this.includeList;
+ let excludeList = this.excludeList;
+ while (includeList.hasChildNodes()) {
+ includeList.lastChild.remove();
+ }
+ while (excludeList.hasChildNodes()) {
+ excludeList.lastChild.remove();
+ }
+
+ // -- first pass, check for ambiguous labels
+ // It's possible that multiple groups are identified by the same short
+ // string, in which case we want to use the longer string to
+ // disambiguate. For example, un-merged contacts can result in
+ // multiple identities having contacts with the same name. In that
+ // case we want to display both the contact name and the identity
+ // name.
+ // This is generically addressed by using the userVisibleString function
+ // defined on the noun type if it is defined. It takes an argument
+ // indicating whether it should be a short string or a long string.
+ // Our algorithm is somewhat dumb. We get the short strings, put them
+ // in a dictionary that maps to whether they are ambiguous or not. We
+ // do not attempt to map based on their id, so then when it comes time
+ // to actually build the labels, we must build the short string and
+ // then re-call for the long name. We could be smarter by building
+ // a list of the input values that resulted in the output string and
+ // then using that to back-update the id map, but it's more compelx and
+ // the performance difference is unlikely to be meaningful.
+ let ambiguousKeyValues;
+ if ("userVisibleString" in nounDef) {
+ ambiguousKeyValues = {};
+ for (let groupPair of useGroups) {
+ let [groupValue] = groupPair;
+
+ // skip null values, they are handled by the none special-case
+ if (groupValue == null) {
+ continue;
+ }
+
+ let groupStr = nounDef.userVisibleString(groupValue, false);
+ // We use hasOwnProperty because it is possible that groupStr could
+ // be the same as the name of one of the attributes on
+ // Object.prototype.
+ if (ambiguousKeyValues.hasOwnProperty(groupStr)) {
+ ambiguousKeyValues[groupStr] = true;
+ } else {
+ ambiguousKeyValues[groupStr] = false;
+ }
+ }
+ }
+
+ // -- create the items, assigning them to the right list based on
+ // existing constraint values
+ for (let groupPair of useGroups) {
+ let [groupValue, groupItems] = groupPair;
+ let li = document.createElement("li");
+ li.setAttribute("class", "bar");
+ li.setAttribute("tabindex", "0");
+ li.setAttribute("role", "link");
+ li.setAttribute("aria-haspopup", "true");
+ li.groupValue = groupValue;
+ li.setAttribute("groupValue", groupValue);
+ li.groupItems = groupItems;
+
+ let countSpan = document.createElement("span");
+ countSpan.setAttribute("class", "bar-count");
+ countSpan.textContent = groupItems.length.toLocaleString();
+ li.appendChild(countSpan);
+
+ let label = document.createElement("span");
+ label.setAttribute("class", "bar-link");
+
+ // The null value is a special indicator for 'none'
+ if (groupValue == null) {
+ if ("noneLabel" in this.facetDef.strings) {
+ label.textContent = this.facetDef.strings.noneLabel;
+ } else {
+ label.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.noneLabel"
+ );
+ }
+ } else {
+ // Otherwise stringify the group object
+ let labelStr;
+ if (ambiguousKeyValues) {
+ labelStr = nounDef.userVisibleString(groupValue, false);
+ if (ambiguousKeyValues[labelStr]) {
+ labelStr = nounDef.userVisibleString(groupValue, true);
+ }
+ } else if ("labelFunc" in this.facetDef) {
+ labelStr = this.facetDef.labelFunc(groupValue);
+ } else {
+ labelStr = groupValue.toLocaleString().substring(0, 80);
+ }
+ label.textContent = labelStr;
+ label.setAttribute("title", labelStr);
+ }
+ li.appendChild(label);
+
+ // root it under the appropriate list
+ if (constraint) {
+ if (constraint.isIncludedGroup(groupValue)) {
+ li.setAttribute("variety", "include");
+ includeList.appendChild(li);
+ } else if (constraint.isExcludedGroup(groupValue)) {
+ li.setAttribute("variety", "exclude");
+ excludeList.appendChild(li);
+ } else {
+ li.setAttribute("variety", "remainder");
+ remainderList.appendChild(li);
+ }
+ } else {
+ li.setAttribute("variety", "remainder");
+ remainderList.appendChild(li);
+ }
+ }
+
+ this.updateHeaderStates();
+ }
+
+ /**
+ * - Mark the include/exclude headers as "some" if there is anything in their
+ * - lists, mark the remainder header as "needed" if either of include /
+ * - exclude exist so we need that label.
+ */
+ updateHeaderStates(items) {
+ this.includeLabel.setAttribute(
+ "state",
+ this.includeList.childElementCount ? "some" : "empty"
+ );
+ this.excludeLabel.setAttribute(
+ "state",
+ this.excludeList.childElementCount ? "some" : "empty"
+ );
+ this.remainderLabel.setAttribute(
+ "needed",
+ (this.includeList.childElementCount ||
+ this.excludeList.childElementCount) &&
+ this.remainderList.childElementCount
+ ? "true"
+ : "false"
+ );
+
+ // nuke the style attributes.
+ this.includeLabel.removeAttribute("style");
+ this.excludeLabel.removeAttribute("style");
+ this.remainderLabel.removeAttribute("style");
+ }
+
+ brushItems(items) {}
+
+ clearBrushedItems() {}
+
+ afterListVisible(variety, callback) {
+ let labelNode = this[variety + "Label"];
+ let listNode = this[variety + "List"];
+
+ // if there are already things displayed, no need
+ if (listNode.childElementCount) {
+ callback();
+ return;
+ }
+
+ let remListVisible = this.remainderLabel.getAttribute("needed") == "true";
+ let remListShouldBeVisible = this.remainderList.childElementCount > 1;
+
+ labelNode.setAttribute("state", "some");
+
+ let showNodes = [labelNode];
+ if (remListVisible != remListShouldBeVisible) {
+ showNodes = [labelNode, this.remainderLabel];
+ }
+
+ showNodes.forEach(node => (node.style.display = "block"));
+
+ callback();
+ }
+
+ _flyBarAway(barNode, variety, callback) {
+ function getRect(aElement) {
+ let box = aElement.getBoundingClientRect();
+ let documentElement = aElement.ownerDocument.documentElement;
+ return {
+ top: box.top + window.pageYOffset - documentElement.clientTop,
+ left: box.left + window.pageXOffset - documentElement.clientLeft,
+ width: box.width,
+ height: box.height,
+ };
+ }
+ // figure out our origin location prior to adding the target or it
+ // will shift us down.
+ let origin = getRect(barNode);
+
+ // clone the node into its target location
+ let targetNode = barNode.cloneNode(true);
+ targetNode.groupValue = barNode.groupValue;
+ targetNode.groupItems = barNode.groupItems;
+ targetNode.setAttribute("variety", variety);
+
+ let targetParent = this[variety + "List"];
+ targetParent.appendChild(targetNode);
+
+ // create a flying clone
+ let flyingNode = barNode.cloneNode(true);
+
+ let dest = getRect(targetNode);
+
+ // if the flying box wants to go higher than the content box goes, just
+ // send it to the top of the content box instead.
+ let contentRect = getRect(this.contentBox);
+ if (dest.top < contentRect.top) {
+ dest.top = contentRect.top;
+ }
+
+ // likewise if it wants to go further south than the content box, stop
+ // that
+ if (dest.top > contentRect.top + contentRect.height) {
+ dest.top = contentRect.top + contentRect.height - dest.height;
+ }
+
+ flyingNode.style.position = "absolute";
+ flyingNode.style.width = origin.width + "px";
+ flyingNode.style.height = origin.height + "px";
+ flyingNode.style.top = origin.top + "px";
+ flyingNode.style.left = origin.left + "px";
+ flyingNode.style.zIndex = 1000;
+
+ flyingNode.style.transitionDuration =
+ Math.abs(dest.top - origin.top) * 2 + "ms";
+ flyingNode.style.transitionProperty = "top, left";
+
+ flyingNode.addEventListener("transitionend", () => {
+ barNode.remove();
+ targetNode.style.display = "block";
+ flyingNode.remove();
+
+ if (callback) {
+ setTimeout(callback, 50);
+ }
+ });
+
+ document.body.appendChild(flyingNode);
+
+ // Adding setTimeout to improve the facet-discrete animation.
+ // See Bug 1439323 for more detail.
+ setTimeout(() => {
+ // animate the flying clone... flying!
+ window.requestAnimationFrame(() => {
+ flyingNode.style.top = dest.top + "px";
+ flyingNode.style.left = dest.left + "px";
+ });
+
+ // hide the target (cloned) node
+ targetNode.style.display = "none";
+
+ // hide the original node and remove its JS properties
+ barNode.style.visibility = "hidden";
+ delete barNode.groupValue;
+ delete barNode.groupItems;
+ }, 100);
+ }
+
+ barClicked(barNode, variety) {
+ let groupValue = barNode.groupValue;
+ // These determine what goAnimate actually does.
+ // flyAway allows us to cancel flying in the case the constraint is
+ // being fully dropped and so the facet is just going to get rebuilt
+ let flyAway = true;
+
+ const goAnimate = () => {
+ setTimeout(() => {
+ if (flyAway) {
+ this.afterListVisible(variety, () => {
+ this._flyBarAway(barNode, variety, () => {
+ this.updateHeaderStates();
+ });
+ });
+ }
+ }, 0);
+ };
+
+ // Immediately apply the facet change, triggering the animation after
+ // the faceting completes.
+ if (variety == "remainder") {
+ let currentVariety = barNode.getAttribute("variety");
+ let constraintGone = FacetContext.removeFacetConstraint(
+ this.faceter,
+ currentVariety == "include",
+ [groupValue],
+ goAnimate
+ );
+
+ // we will automatically rebuild if the constraint is gone, so
+ // just make the animation a no-op.
+ if (constraintGone) {
+ flyAway = false;
+ }
+ } else {
+ // include/exclude
+ let revalidate = FacetContext.addFacetConstraint(
+ this.faceter,
+ variety == "include",
+ [groupValue],
+ false,
+ false,
+ goAnimate
+ );
+
+ // revalidate means we need to blow away the other dudes, in which
+ // case it makes the most sense to just trigger a rebuild of ourself
+ if (revalidate) {
+ flyAway = false;
+ this.build(false);
+ }
+ }
+ }
+
+ barHovered(barNode, aInclude) {
+ let groupValue = barNode.groupValue;
+ let groupItems = barNode.groupItems;
+
+ FacetContext.hoverFacet(
+ this.faceter,
+ this.attrDef,
+ groupValue,
+ groupItems
+ );
+ }
+
+ /**
+ * HoverGone! HoverGone!
+ * We know it's gone, but where has it gone?
+ */
+ barHoverGone(barNode, include) {
+ let groupValue = barNode.groupValue;
+ let groupItems = barNode.groupItems;
+
+ FacetContext.unhoverFacet(
+ this.faceter,
+ this.attrDef,
+ groupValue,
+ groupItems
+ );
+ }
+
+ includeFacet(node) {
+ this.barClicked(
+ node,
+ node.getAttribute("variety") == "remainder" ? "include" : "remainder"
+ );
+ }
+
+ undoFacet(node) {
+ this.barClicked(
+ node,
+ node.getAttribute("variety") == "remainder" ? "include" : "remainder"
+ );
+ }
+
+ excludeFacet(node) {
+ this.barClicked(node, "exclude");
+ }
+
+ showPopup(event) {
+ try {
+ // event.target could be the <li> node, or a span inside
+ // of it, or perhaps the facet-more button, or maybe something
+ // else that we'll handle in the next version. We walk up its
+ // parent chain until we get to the right level of the DOM
+ // hierarchy, or the facet-content which seems to be the root.
+ if (this.currentNode) {
+ this.currentNode.removeAttribute("selected");
+ }
+
+ let node = event.target;
+
+ while (
+ !(node && node.hasAttribute && node.hasAttribute("class")) ||
+ (!node.classList.contains("bar") &&
+ !node.classList.contains("facet-more") &&
+ !node.classList.contains("facet-content"))
+ ) {
+ node = node.parentNode;
+ }
+
+ if (!(node && node.hasAttribute && node.hasAttribute("class"))) {
+ return false;
+ }
+
+ this.currentNode = node;
+ node.setAttribute("selected", "true");
+
+ if (node.classList.contains("bar")) {
+ document.querySelector("facet-popup-menu").show(event, this, node);
+ } else if (node.classList.contains("facet-more")) {
+ this.changeMode("all");
+ }
+
+ return false;
+ } catch (e) {
+ return console.error(e);
+ }
+ }
+
+ activateLink(event) {
+ try {
+ let node = event.target;
+
+ while (
+ !node.hasAttribute("class") ||
+ (!node.classList.contains("facet-more") &&
+ !node.classList.contains("facet-content"))
+ ) {
+ node = node.parentNode;
+ }
+
+ if (node.classList.contains("facet-more")) {
+ this.changeMode("all");
+ }
+
+ return false;
+ } catch (e) {
+ return console.error(e);
+ }
+ }
+ }
+
+ customElements.define("facet-discrete", MozFacetDiscrete);
+
+ class MozFacetPopupMenu extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("keypress", event => {
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_ESCAPE:
+ this.hide();
+ break;
+
+ case KeyEvent.DOM_VK_DOWN:
+ this.moveFocus(event, 1);
+ break;
+
+ case KeyEvent.DOM_VK_TAB:
+ if (event.shiftKey) {
+ this.moveFocus(event, -1);
+ break;
+ }
+
+ this.moveFocus(event, 1);
+ break;
+
+ case KeyEvent.DOM_VK_UP:
+ this.moveFocus(event, -1);
+ break;
+
+ default:
+ break;
+ }
+ });
+ }
+
+ connectedCallback() {
+ const parentDiv = document.createElement("div");
+ parentDiv.classList.add("parent");
+ parentDiv.setAttribute("tabIndex", "0");
+
+ this.includeNode = document.createElement("div");
+ this.includeNode.classList.add("popup-menuitem", "top");
+ this.includeNode.setAttribute("tabindex", "0");
+ this.includeNode.onmouseover = () => {
+ this.focus();
+ };
+ this.includeNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doInclude();
+ }
+ };
+ this.includeNode.onmouseup = () => {
+ this.doInclude();
+ };
+
+ this.excludeNode = document.createElement("div");
+ this.excludeNode.classList.add("popup-menuitem", "bottom");
+ this.excludeNode.setAttribute("tabindex", "0");
+ this.excludeNode.onmouseover = () => {
+ this.focus();
+ };
+ this.excludeNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doExclude();
+ }
+ };
+ this.excludeNode.onmouseup = () => {
+ this.doExclude();
+ };
+
+ this.undoNode = document.createElement("div");
+ this.undoNode.classList.add("popup-menuitem", "undo");
+ this.undoNode.setAttribute("tabindex", "0");
+ this.undoNode.onmouseover = () => {
+ this.focus();
+ };
+ this.undoNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doUndo();
+ }
+ };
+ this.undoNode.onmouseup = () => {
+ this.doUndo();
+ };
+
+ parentDiv.appendChild(this.includeNode);
+ parentDiv.appendChild(this.excludeNode);
+ parentDiv.appendChild(this.undoNode);
+
+ this.appendChild(parentDiv);
+ }
+
+ _getLabel(facetDef, facetValue, groupValue, stringName) {
+ let labelFormat;
+ if (stringName in facetDef.strings) {
+ labelFormat = facetDef.strings[stringName];
+ } else {
+ labelFormat = glodaFacetStrings.GetStringFromName(
+ `glodaFacetView.facets.${stringName}.fallbackLabel`
+ );
+ }
+
+ if (!labelFormat.includes("#1")) {
+ return labelFormat;
+ }
+
+ return labelFormat.replace("#1", facetValue);
+ }
+
+ build(facetDef, facetValue, groupValue) {
+ try {
+ if (groupValue) {
+ this.includeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchLabel"
+ );
+ this.excludeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "cantMatchLabel"
+ );
+ this.undoNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mayMatchLabel"
+ );
+ } else {
+ this.includeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchNoneLabel"
+ );
+ this.excludeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchSomeLabel"
+ );
+ this.undoNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mayMatchAnyLabel"
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ moveFocus(event, delta) {
+ try {
+ // We probably want something quite generic in the long term, but that
+ // is way too much for now (needs to skip over invisible items, etc)
+ let focused = document.activeElement;
+ if (focused == this.includeNode) {
+ this.excludeNode.focus();
+ } else if (focused == this.excludeNode) {
+ this.includeNode.focus();
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ selectItem(event) {
+ try {
+ let focused = document.activeElement;
+ if (focused == this.includeNode) {
+ this.doInclude();
+ } else if (focused == this.excludeNode) {
+ this.doExclude();
+ } else {
+ this.doUndo();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ show(event, facetNode, barNode) {
+ try {
+ this.node = barNode;
+ this.facetNode = facetNode;
+ let facetDef = facetNode.facetDef;
+ let groupValue = barNode.groupValue;
+ let variety = barNode.getAttribute("variety");
+ let label = barNode.querySelector(".bar-link").textContent;
+ this.build(facetDef, label, groupValue);
+ this.node.setAttribute("selected", "true");
+ const rtl = window.getComputedStyle(this).direction == "rtl";
+ /* We show different menus if we're on an "unselected" facet value,
+ or if we're on a preselected facet value, whether included or
+ excluded. The variety attribute handles that through CSS */
+ this.setAttribute("variety", variety);
+ let rect = barNode.getBoundingClientRect();
+ let x, y;
+ if (event.type == "click") {
+ // center the menu on the mouse click
+ if (rtl) {
+ x = event.pageX + 10;
+ } else {
+ x = event.pageX - 10;
+ }
+ y = Math.max(20, event.pageY - 15);
+ } else {
+ if (rtl) {
+ x = rect.left + rect.width / 2 + 20;
+ } else {
+ x = rect.left + rect.width / 2 - 20;
+ }
+ y = rect.top - 10;
+ }
+ if (rtl) {
+ this.style.left = x - this.getBoundingClientRect().width + "px";
+ } else {
+ this.style.left = x + "px";
+ }
+ this.style.top = y + "px";
+
+ if (variety == "remainder") {
+ // include
+ this.includeNode.focus();
+ } else {
+ // undo
+ this.undoNode.focus();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ hide() {
+ try {
+ this.setAttribute("variety", "invisible");
+ if (this.node) {
+ this.node.removeAttribute("selected");
+ this.node.focus();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ doInclude() {
+ try {
+ this.facetNode.includeFacet(this.node);
+ this.hide();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ doExclude() {
+ this.facetNode.excludeFacet(this.node);
+ this.hide();
+ }
+
+ doUndo() {
+ this.facetNode.undoFacet(this.node);
+ this.hide();
+ }
+ }
+
+ customElements.define("facet-popup-menu", MozFacetPopupMenu);
+
+ /**
+ * MozResultMessage displays an excerpt of a message. Typically these are used in the gloda
+ * results listing, showing the messages that matched.
+ */
+ class MozFacetResultMessage extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("mouseover", event => {
+ FacetContext.hoverFacet(
+ FacetContext.fakeResultFaceter,
+ FacetContext.fakeResultAttr,
+ this.message,
+ [this.message]
+ );
+ });
+
+ this.addEventListener("mouseout", event => {
+ FacetContext.unhoverFacet(
+ FacetContext.fakeResultFaceter,
+ FacetContext.fakeResultAttr,
+ this.message,
+ [this.message]
+ );
+ });
+ }
+
+ connectedCallback() {
+ const messageHeader = document.createElement("div");
+
+ const messageLine = document.createElement("div");
+ messageLine.classList.add("message-line");
+
+ const messageMeta = document.createElement("div");
+ messageMeta.classList.add("message-meta");
+
+ this.addressesGroup = document.createElement("div");
+ this.addressesGroup.classList.add("message-addresses-group");
+
+ this.authorGroup = document.createElement("div");
+ this.authorGroup.classList.add("message-author-group");
+
+ this.author = document.createElement("span");
+ this.author.classList.add("message-author");
+
+ this.date = document.createElement("div");
+ this.date.classList.add("message-date");
+
+ this.authorGroup.appendChild(this.author);
+ this.authorGroup.appendChild(this.date);
+ this.addressesGroup.appendChild(this.authorGroup);
+ messageMeta.appendChild(this.addressesGroup);
+ messageLine.appendChild(messageMeta);
+
+ const messageSubjectGroup = document.createElement("div");
+ messageSubjectGroup.classList.add("message-subject-group");
+
+ this.star = document.createElement("span");
+ this.star.classList.add("message-star");
+
+ this.subject = document.createElement("span");
+ this.subject.classList.add("message-subject");
+ this.subject.setAttribute("tabindex", "0");
+ this.subject.setAttribute("role", "link");
+
+ this.tags = document.createElement("span");
+ this.tags.classList.add("message-tags");
+
+ this.recipientsGroup = document.createElement("div");
+ this.recipientsGroup.classList.add("message-recipients-group");
+
+ this.to = document.createElement("span");
+ this.to.classList.add("message-to-label");
+
+ this.recipients = document.createElement("div");
+ this.recipients.classList.add("message-recipients");
+
+ this.recipientsGroup.appendChild(this.to);
+ this.recipientsGroup.appendChild(this.recipients);
+ messageSubjectGroup.appendChild(this.star);
+ messageSubjectGroup.appendChild(this.subject);
+ messageSubjectGroup.appendChild(this.tags);
+ messageSubjectGroup.appendChild(this.recipientsGroup);
+ messageLine.appendChild(messageSubjectGroup);
+ messageHeader.appendChild(messageLine);
+ this.appendChild(messageHeader);
+
+ this.snippet = document.createElement("pre");
+ this.snippet.classList.add("message-body");
+
+ this.attachments = document.createElement("div");
+ this.attachments.classList.add("message-attachments");
+
+ this.appendChild(this.snippet);
+ this.appendChild(this.attachments);
+
+ this.build();
+ }
+
+ /* eslint-disable complexity */
+ build() {
+ let message = this.message;
+
+ let subject = this.subject;
+ // -- eventify
+ subject.onclick = event => {
+ FacetContext.showConversationInTab(this, event.button == 1);
+ };
+ subject.onkeypress = event => {
+ if (Event.keyCode == event.DOM_VK_RETURN) {
+ FacetContext.showConversationInTab(this, event.shiftKey);
+ }
+ };
+
+ // -- Content Poking
+ if (message.subject.trim() == "") {
+ subject.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.result.message.noSubject"
+ );
+ } else {
+ subject.textContent = message.subject;
+ }
+ let authorNode = this.author;
+ authorNode.setAttribute("title", message.from.value);
+ authorNode.textContent = message.from.contact.name;
+ let toNode = this.to;
+ toNode.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.result.message.toLabel"
+ );
+
+ // this.author.textContent = ;
+ let { makeFriendlyDateAgo } = ChromeUtils.import(
+ "resource:///modules/TemplateUtils.jsm"
+ );
+ this.date.textContent = makeFriendlyDateAgo(message.date);
+
+ // - Recipients
+ try {
+ let recipientsNode = this.recipients;
+ if (message.recipients) {
+ let recipientCount = 0;
+ const MAX_RECIPIENTS = 3;
+ let totalRecipientCount = message.recipients.length;
+ let recipientSeparator = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.recipientSeparator"
+ );
+ for (let index in message.recipients) {
+ let recipNode = document.createElement("span");
+ recipNode.setAttribute("class", "message-recipient");
+ recipNode.textContent = message.recipients[index].contact.name;
+ recipientsNode.appendChild(recipNode);
+ recipientCount++;
+ if (recipientCount == MAX_RECIPIENTS) {
+ break;
+ }
+ if (index != totalRecipientCount - 1) {
+ // add separators (usually commas)
+ let sepNode = document.createElement("span");
+ sepNode.setAttribute("class", "message-recipient-separator");
+ sepNode.textContent = recipientSeparator;
+ recipientsNode.appendChild(sepNode);
+ }
+ }
+ if (totalRecipientCount > MAX_RECIPIENTS) {
+ let nOthers = totalRecipientCount - recipientCount;
+ let andNOthers = document.createElement("span");
+ andNOthers.setAttribute("class", "message-recipients-andothers");
+
+ let andOthersLabel = PluralForm.get(
+ nOthers,
+ glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.andOthers"
+ )
+ ).replace("#1", nOthers);
+
+ andNOthers.textContent = andOthersLabel;
+ recipientsNode.appendChild(andNOthers);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // - Starred
+ let starNode = this.star;
+ if (message.starred) {
+ starNode.setAttribute("starred", "true");
+ }
+
+ // - Attachments
+ if (message.attachmentNames) {
+ let attachmentsNode = this.attachments;
+ let imgNode = document.createElement("div");
+ imgNode.setAttribute("class", "message-attachment-icon");
+ attachmentsNode.appendChild(imgNode);
+ for (let attach of message.attachmentNames) {
+ let attachNode = document.createElement("div");
+ attachNode.setAttribute("class", "message-attachment");
+ if (attach.length >= 28) {
+ attach = attach.substring(0, 24) + "…";
+ }
+ attachNode.textContent = attach;
+ attachmentsNode.appendChild(attachNode);
+ }
+ }
+
+ // - Tags
+ let tagsNode = this.tags;
+ if ("tags" in message && message.tags.length) {
+ for (let tag of message.tags) {
+ let tagNode = document.createElement("span");
+ tagNode.setAttribute("class", "message-tag");
+ let color = MailServices.tags.getColorForKey(tag.key);
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.textContent = tag.tag;
+ tagsNode.appendChild(tagNode);
+ }
+ }
+
+ // - Body
+ if (message.indexedBodyText) {
+ let bodyText = message.indexedBodyText;
+
+ let matches = [];
+ if ("stashedColumns" in FacetContext.collection) {
+ let collection;
+ if (
+ "IMCollection" in FacetContext &&
+ message instanceof Gloda.lookupNounDef("im-conversation").clazz
+ ) {
+ collection = FacetContext.IMCollection;
+ } else {
+ collection = FacetContext.collection;
+ }
+ let offsets = collection.stashedColumns[message.id][0];
+ let offsetNums = offsets.split(" ").map(x => parseInt(x));
+ for (let i = 0; i < offsetNums.length; i += 4) {
+ // i is the column index. The indexedBodyText is in the column 0.
+ // Ignore matches for other columns.
+ if (offsetNums[i] != 0) {
+ continue;
+ }
+
+ // i+1 is the term index, indicating which queried term was found.
+ // We can ignore for now...
+
+ // i+2 is the *byte* offset at which the term is in the string.
+ // i+3 is the term's length.
+ matches.push([offsetNums[i + 2], offsetNums[i + 3]]);
+ }
+
+ // Sort the matches by index, just to be sure.
+ // They are probably already sorted, but if they aren't it could
+ // mess things up at the next step.
+ matches.sort((a, b) => a[0] - b[0]);
+
+ // Convert the byte offsets and lengths into character indexes.
+ let charCodeToByteCount = c => {
+ // UTF-8 stores:
+ // - code points below U+0080 on 1 byte,
+ // - code points below U+0800 on 2 bytes,
+ // - code points U+D800 through U+DFFF are UTF-16 surrogate halves
+ // (they indicate that JS has split a 4 bytes UTF-8 character
+ // in two halves of 2 bytes each),
+ // - other code points on 3 bytes.
+ if (c < 0x80) {
+ return 1;
+ }
+ if (c < 0x800 || (c >= 0xd800 && c <= 0xdfff)) {
+ return 2;
+ }
+ return 3;
+ };
+ let byteOffset = 0;
+ let offset = 0;
+ for (let match of matches) {
+ while (byteOffset < match[0]) {
+ byteOffset += charCodeToByteCount(bodyText.charCodeAt(offset++));
+ }
+ match[0] = offset;
+ for (let i = offset; i < offset + match[1]; ++i) {
+ let size = charCodeToByteCount(bodyText.charCodeAt(i));
+ if (size > 1) {
+ match[1] -= size - 1;
+ }
+ }
+ }
+ }
+
+ // how many lines of context we want before the first match:
+ const kContextLines = 2;
+
+ let startIndex = 0;
+ if (matches.length > 0) {
+ // Find where the snippet should begin to show at least the
+ // first match and kContextLines of context before the match.
+ startIndex = matches[0][0];
+ for (let context = kContextLines; context >= 0; --context) {
+ startIndex = bodyText.lastIndexOf("\n", startIndex - 1);
+ if (startIndex == -1) {
+ startIndex = 0;
+ break;
+ }
+ }
+ }
+
+ // start assuming it's just one line that we want to show
+ let idxNewline = -1;
+ let ellipses = "…";
+
+ let maxLineCount = 5;
+ if (startIndex != 0) {
+ // Avoid displaying an ellipses followed by an empty line.
+ while (bodyText[startIndex + 1] == "\n") {
+ ++startIndex;
+ }
+ bodyText = ellipses + bodyText.substring(startIndex);
+ // The first line will only contain the ellipsis as the character
+ // at startIndex is always \n, so we show an additional line.
+ ++maxLineCount;
+ }
+
+ for (
+ let newlineCount = 0;
+ newlineCount < maxLineCount;
+ newlineCount++
+ ) {
+ idxNewline = bodyText.indexOf("\n", idxNewline + 1);
+ if (idxNewline == -1) {
+ ellipses = "";
+ break;
+ }
+ }
+ let snippet = "";
+ if (idxNewline > -1) {
+ snippet = bodyText.substring(0, idxNewline);
+ } else {
+ snippet = bodyText;
+ }
+ if (ellipses) {
+ snippet = snippet.trimRight() + ellipses;
+ }
+
+ let parent = this.snippet;
+ let node = document.createTextNode(snippet);
+ parent.appendChild(node);
+
+ let offset = startIndex ? startIndex - 1 : 0; // The ellipsis takes 1 character.
+ for (let match of matches) {
+ if (idxNewline > -1 && match[0] > startIndex + idxNewline) {
+ break;
+ }
+ let secondNode = node.splitText(match[0] - offset);
+ node = secondNode.splitText(match[1]);
+ offset += match[0] + match[1] - offset;
+ let span = document.createElement("span");
+ span.textContent = secondNode.data;
+ if (!this.firstMatchText) {
+ this.firstMatchText = secondNode.data;
+ }
+ span.setAttribute("class", "message-body-fulltext-match");
+ parent.replaceChild(span, secondNode);
+ }
+ }
+
+ // - Misc attributes
+ if (!message.read) {
+ this.setAttribute("unread", "true");
+ }
+ }
+ }
+
+ customElements.define("facet-result-message", MozFacetResultMessage);
+}
diff --git a/comm/mail/base/content/widgets/header-fields.js b/comm/mail/base/content/widgets/header-fields.js
new file mode 100644
index 0000000000..10ec83b45c
--- /dev/null
+++ b/comm/mail/base/content/widgets/header-fields.js
@@ -0,0 +1,973 @@
+/* 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 gMessageHeader, gShowCondensedEmailAddresses, openUILink */
+
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+
+ const lazy = {};
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "DisplayNameUtils",
+ "resource:///modules/DisplayNameUtils.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "TagUtils",
+ "resource:///modules/TagUtils.jsm"
+ );
+
+ class MultiRecipientRow extends HTMLDivElement {
+ /**
+ * The number of lines of recipients to display before adding a <more>
+ * indicator to the widget. This can be increased using the preference
+ * mailnews.headers.show_n_lines_before_more.
+ *
+ * @type {integer}
+ */
+ #maxLinesBeforeMore = 1;
+
+ /**
+ * The array of all the recipients that need to be shown in this widget.
+ *
+ * @type {Array<object>}
+ */
+ #recipients = [];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "multi-recipient-row");
+ this.classList.add("multi-recipient-row");
+
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ // message-header-to-list-name
+ // message-header-from-list-name
+ // message-header-cc-list-name
+ // message-header-bcc-list-name
+ // message-header-sender-list-name
+ // message-header-reply-to-list-name
+ document.l10n.setAttributes(
+ this.heading,
+ `message-header-${this.dataset.headerName}-list-name`
+ );
+ this.appendChild(this.heading);
+
+ this.recipientsList = document.createElement("ol");
+ this.recipientsList.classList.add("recipients-list");
+ this.recipientsList.setAttribute("aria-labelledby", this.heading.id);
+ this.appendChild(this.recipientsList);
+
+ this.moreButton = document.createElement("button");
+ this.moreButton.setAttribute("type", "button");
+ this.moreButton.classList.add("show-more-recipients", "plain");
+ this.moreButton.addEventListener(
+ "mousedown",
+ // Prevent focus being transferred to the button before it is removed.
+ event => event.preventDefault()
+ );
+ this.moreButton.addEventListener("click", () => this.showAllRecipients());
+
+ document.l10n.setAttributes(
+ this.moreButton,
+ "message-header-field-show-more"
+ );
+
+ // @implements {nsIObserver}
+ this.ABObserver = {
+ /**
+ * Array list of all observable notifications.
+ *
+ * @type {Array<string>}
+ */
+ _notifications: [
+ "addrbook-directory-created",
+ "addrbook-directory-deleted",
+ "addrbook-contact-created",
+ "addrbook-contact-updated",
+ "addrbook-contact-deleted",
+ ],
+
+ addObservers() {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ this._added = true;
+ window.addEventListener("unload", this);
+ },
+
+ removeObservers() {
+ if (!this._added) {
+ return;
+ }
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ this._added = false;
+ window.removeEventListener("unload", this);
+ },
+
+ handleEvent() {
+ this.removeObservers();
+ },
+
+ observe: (subject, topic, data) => {
+ switch (topic) {
+ case "addrbook-directory-created":
+ case "addrbook-directory-deleted":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ this.directoryChanged(subject);
+ break;
+ case "addrbook-contact-created":
+ case "addrbook-contact-updated":
+ case "addrbook-contact-deleted":
+ subject.QueryInterface(Ci.nsIAbCard);
+ this.contactUpdated(subject);
+ break;
+ }
+ },
+ };
+
+ this.ABObserver.addObservers();
+ }
+
+ /**
+ * Clear things out when the element is removed from the DOM.
+ */
+ disconnectedCallback() {
+ this.ABObserver.removeObservers();
+ }
+
+ /**
+ * Loop through all available recipients and check if any of those belonged
+ * to the created or removed address book.
+ *
+ * @param {nsIAbDirectory} subject - The created or removed Address Book.
+ */
+ directoryChanged(subject) {
+ if (!(subject instanceof Ci.nsIAbDirectory)) {
+ return;
+ }
+
+ for (let recipient of [...this.recipientsList.childNodes].filter(
+ r => r.cardDetails?.book?.dirPrefId == subject.dirPrefId
+ )) {
+ recipient.updateRecipient();
+ }
+ }
+
+ /**
+ * Loop through all available recipients and update the UI to reflect if
+ * they were saved, updated, or removed as contacts in an address book.
+ *
+ * @param {nsIAbCard} subject - The changed contact card.
+ */
+ contactUpdated(subject) {
+ if (!(subject instanceof Ci.nsIAbCard)) {
+ // Bail out if this is not a valid Address Book Card object.
+ return;
+ }
+
+ if (!subject.isMailList && !subject.emailAddresses.length) {
+ // Bail out if we don't have any addresses to match against.
+ return;
+ }
+
+ let addresses = subject.emailAddresses;
+ for (let recipient of [...this.recipientsList.childNodes].filter(
+ r => r.emailAddress && addresses.includes(r.emailAddress)
+ )) {
+ recipient.updateRecipient();
+ }
+ }
+
+ /**
+ * Add a recipient to be shown in this widget. The recipient won't be shown
+ * until the row view is built.
+ *
+ * @param {object} recipient - The recipient element.
+ * @param {string} recipient.displayName - The recipient display name.
+ * @param {string} [recipient.emailAddress] - The recipient email address.
+ * @param {string} [recipient.fullAddress] - The recipient full address.
+ */
+ addRecipient(recipient) {
+ this.#recipients.push(recipient);
+ }
+
+ buildView() {
+ this.#maxLinesBeforeMore = Services.prefs.getIntPref(
+ "mailnews.headers.show_n_lines_before_more"
+ );
+ let showAllHeaders =
+ this.#maxLinesBeforeMore < 1 ||
+ Services.prefs.getIntPref("mail.show_headers") ==
+ Ci.nsMimeHeaderDisplayTypes.AllHeaders ||
+ this.dataset.showAll == "true";
+ this.buildRecipients(showAllHeaders);
+ }
+
+ buildRecipients(showAllHeaders) {
+ // Determine focus before clearing the children.
+ let focusIndex = [...this.recipientsList.childNodes].findIndex(node =>
+ node.contains(document.activeElement)
+ );
+ this.recipientsList.replaceChildren();
+ gMessageHeader.toggleScrollableHeader(showAllHeaders);
+
+ // Store the available width of the entire row.
+ // FIXME! The size of the rows can variate depending on when adjacent
+ // elements are generated (e.g.: TO row + date row), therefore this size
+ // is not always accurate when viewing the first email. We should defer
+ // the generation of the multi recipient rows only after all the other
+ // headers have been populated.
+ let availableWidth = !showAllHeaders
+ ? this.recipientsList.getBoundingClientRect().width
+ : 0;
+
+ // Track the space occupied by recipients per row. Every time we exceed
+ // the available space of a single row, we reset this value.
+ let currentRowWidth = 0;
+ // Track how many rows are being populated by recipients.
+ let rows = 1;
+ for (let [count, recipient] of this.#recipients.entries()) {
+ let li = document.createElement("li", { is: "header-recipient" });
+ // Set an id before connected callback is called on the element.
+ li.id = `${this.dataset.headerName}Recipient${count}`;
+ // Append the element to the DOM to trigger the connectedCallback.
+ this.recipientsList.appendChild(li);
+ li.dataset.headerName = this.dataset.headerName;
+ li.recipient = recipient;
+
+ // Bail out if we need to show all elements.
+ if (showAllHeaders) {
+ continue;
+ }
+
+ // Keep track of how much space our recipients are occupying.
+ let width = li.getBoundingClientRect().width;
+ // FIXME! If we have more than one recipient, we add a comma as pseudo
+ // element after the previous element. Account for that by adding an
+ // arbitrary 30px size to simulate extra characters space. This is a bit
+ // of an extreme sizing as it's almost as large as the more button, but
+ // it's necessary to make sure we never encounter that scenario.
+ if (count > 0) {
+ width += 30;
+ }
+ currentRowWidth += width;
+
+ if (currentRowWidth <= availableWidth) {
+ continue;
+ }
+
+ // If the recipients available in the current row exceed the
+ // available space, increase the row count and set the value of the
+ // last added list item to the next row width counter.
+ if (rows < this.#maxLinesBeforeMore) {
+ rows++;
+ currentRowWidth = width;
+ continue;
+ }
+
+ // Append the "more" button inside a list item to be properly handled
+ // as an inline element of the recipients list UI.
+ let buttonLi = document.createElement("li");
+ buttonLi.appendChild(this.moreButton);
+ this.recipientsList.appendChild(buttonLi);
+ currentRowWidth += buttonLi.getBoundingClientRect().width;
+
+ // Reverse loop through the added list item and remove them until
+ // they all fit in the current row alongside the "more" button.
+ for (; count && currentRowWidth > availableWidth; count--) {
+ let toRemove = this.recipientsList.childNodes[count];
+ currentRowWidth -= toRemove.getBoundingClientRect().width;
+ toRemove.remove();
+ }
+
+ // Skip the "more" button, which is present if we reached this stage.
+ let lastRecipientIndex = this.recipientsList.childNodes.length - 2;
+ // Add a unique class to the last visible recipient to remove the
+ // comma separator added via pseudo element.
+ this.recipientsList.childNodes[lastRecipientIndex].classList.add(
+ "last-before-button"
+ );
+
+ break;
+ }
+
+ if (focusIndex >= 0) {
+ // If we had focus before, restore focus to the same index, or the last node.
+ let focusNode =
+ this.recipientsList.childNodes[
+ Math.min(focusIndex, this.recipientsList.childNodes.length - 1)
+ ];
+ if (focusNode.contains(this.moreButton)) {
+ // The button is focusable.
+ this.moreButton.focus();
+ } else {
+ // The item is focusable.
+ focusNode.focus();
+ }
+ }
+ }
+
+ /**
+ * Show all recipients available in this widget.
+ */
+ showAllRecipients() {
+ this.buildRecipients(true);
+ }
+
+ /**
+ * Empty the widget.
+ */
+ clear() {
+ this.#recipients = [];
+ this.recipientsList.replaceChildren();
+ }
+ }
+ customElements.define("multi-recipient-row", MultiRecipientRow, {
+ extends: "div",
+ });
+
+ class HeaderRecipient extends HTMLLIElement {
+ /**
+ * The object holding the recipient information.
+ *
+ * @type {object}
+ * @property {string} displayName - The recipient display name.
+ * @property {string} [emailAddress] - The recipient email address.
+ * @property {string} [fullAddress] - The recipient full address.
+ */
+ #recipient = {};
+
+ /**
+ * The Card object if the recipients is saved in the address book.
+ *
+ * @type {object}
+ * @property {?object} book - The address book in which the contact is
+ * saved, if we have a card.
+ * @property {?object} card - The saved contact card, if present.
+ */
+ cardDetails = {};
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-recipient");
+ this.classList.add("header-recipient");
+ this.tabIndex = 0;
+
+ this.avatar = document.createElement("div");
+ this.avatar.classList.add("recipient-avatar");
+ this.appendChild(this.avatar);
+
+ this.email = document.createElement("span");
+ this.email.classList.add("recipient-single-line");
+ this.email.id = `${this.id}Display`;
+ this.appendChild(this.email);
+
+ this.multiLine = document.createElement("span");
+ this.multiLine.classList.add("recipient-multi-line");
+
+ this.nameLine = document.createElement("span");
+ this.nameLine.classList.add("recipient-multi-line-name");
+ this.multiLine.appendChild(this.nameLine);
+
+ this.addressLine = document.createElement("span");
+ this.addressLine.classList.add("recipient-multi-line-address");
+ this.multiLine.appendChild(this.addressLine);
+
+ this.appendChild(this.multiLine);
+
+ this.abIndicator = document.createElement("button");
+ this.abIndicator.classList.add(
+ "recipient-address-book-button",
+ "plain-button"
+ );
+ // We make the button non-focusable since its functionality is equivalent
+ // to the first item in the popup menu, so we can save a tab-stop.
+ this.abIndicator.tabIndex = -1;
+ this.abIndicator.addEventListener("click", event => {
+ event.stopPropagation();
+ if (this.cardDetails.card) {
+ gMessageHeader.editContact(this);
+ return;
+ }
+
+ this.addToAddressBook();
+ });
+
+ let img = document.createElement("img");
+ img.id = `${this.id}AbIcon`;
+ img.src = "chrome://messenger/skin/icons/new/address-book-indicator.svg";
+ document.l10n.setAttributes(
+ img,
+ "message-header-address-not-in-address-book-icon2"
+ );
+
+ this.abIndicator.appendChild(img);
+ this.appendChild(this.abIndicator);
+
+ // Use the email and icon as the accessible name. We do this to stop the
+ // button title from contributing to the accessible name.
+ // TODO: If the button or its title is removed, or the title replaces the
+ // image alt text, then remove this aria-labelledby attribute. The id's
+ // will no longer be necessary either.
+ this.setAttribute("aria-labelledby", `${this.email.id} ${img.id}`);
+
+ this.addEventListener("contextmenu", event => {
+ gMessageHeader.openEmailAddressPopup(event, this);
+ });
+ this.addEventListener("click", event => {
+ gMessageHeader.openEmailAddressPopup(event, this);
+ });
+ this.addEventListener("keypress", event => {
+ if (event.key == "Enter") {
+ gMessageHeader.openEmailAddressPopup(event, this);
+ }
+ });
+ }
+
+ set recipient(recipient) {
+ this.#recipient = recipient;
+ this.updateRecipient();
+ }
+
+ get displayName() {
+ return this.#recipient.displayName;
+ }
+
+ get emailAddress() {
+ return this.#recipient.emailAddress;
+ }
+
+ get fullAddress() {
+ return this.#recipient.fullAddress;
+ }
+
+ updateRecipient() {
+ if (!this.emailAddress) {
+ this.abIndicator.hidden = true;
+ this.email.textContent = this.displayName;
+ if (this.dataset.headerName == "from") {
+ this.nameLine.textContent = this.displayName;
+ this.addressLine.textContent = "";
+ this.avatar.replaceChildren();
+ this.avatar.classList.remove("has-avatar");
+ }
+ this.cardDetails = {};
+ return;
+ }
+
+ this.abIndicator.hidden = false;
+ let card = MailServices.ab.cardForEmailAddress(
+ this.#recipient.emailAddress
+ );
+ this.cardDetails = {
+ card,
+ book: card
+ ? MailServices.ab.getDirectoryFromUID(card.directoryUID)
+ : null,
+ };
+
+ let displayName = lazy.DisplayNameUtils.formatDisplayName(
+ this.emailAddress,
+ this.displayName,
+ this.dataset.headerName,
+ this.cardDetails.card
+ );
+
+ // Show only the display name if we have a valid card and the user wants
+ // to show a condensed header (without the full email address) for saved
+ // contacts.
+ if (gShowCondensedEmailAddresses && displayName) {
+ this.email.textContent = displayName;
+ this.email.setAttribute("title", this.#recipient.fullAddress);
+ } else {
+ this.email.textContent = this.#recipient.fullAddress;
+ this.email.removeAttribute("title");
+ }
+
+ if (this.dataset.headerName == "from") {
+ if (gShowCondensedEmailAddresses) {
+ this.nameLine.textContent =
+ displayName || this.displayName || this.fullAddress;
+ } else {
+ this.nameLine.textContent = this.fullAddress;
+ }
+ this.addressLine.textContent = this.emailAddress;
+ }
+
+ let hasCard = this.cardDetails.card;
+ // Update the style of the indicator button.
+ this.abIndicator.classList.toggle("in-address-book", hasCard);
+ document.l10n.setAttributes(
+ this.abIndicator,
+ hasCard
+ ? "message-header-address-in-address-book-button"
+ : "message-header-address-not-in-address-book-button"
+ );
+ document.l10n.setAttributes(
+ this.abIndicator.querySelector("img"),
+ hasCard
+ ? "message-header-address-in-address-book-icon2"
+ : "message-header-address-not-in-address-book-icon2"
+ );
+
+ if (this.dataset.headerName == "from") {
+ this._updateAvatar();
+ }
+ }
+
+ _updateAvatar() {
+ this.avatar.replaceChildren();
+
+ if (!this.cardDetails.card) {
+ this._createAvatarPlaceholder();
+ return;
+ }
+
+ // We have a card, so let's try to fetch the image.
+ let card = this.cardDetails.card;
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ document.l10n.setAttributes(img, "message-header-recipient-avatar", {
+ address: this.emailAddress,
+ });
+ // TODO: We should fetch a dynamically generated smaller version of the
+ // uploaded picture to avoid loading large images that will only be used
+ // in smaller format.
+ img.src = photoURL;
+ this.avatar.appendChild(img);
+ this.avatar.classList.add("has-avatar");
+ } else {
+ this._createAvatarPlaceholder();
+ }
+ }
+
+ _createAvatarPlaceholder() {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(
+ this.nameLine.textContent || this.displayName || this.fullAddress
+ )[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ this.avatar.appendChild(letter);
+ this.avatar.classList.remove("has-avatar");
+ }
+
+ addToAddressBook() {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = this.#recipient.displayName;
+ card.primaryEmail = this.#recipient.emailAddress;
+
+ let addressBook = MailServices.ab.getDirectory(
+ "jsaddrbook://abook.sqlite"
+ );
+ addressBook.addCard(card);
+ }
+ }
+ customElements.define("header-recipient", HeaderRecipient, {
+ extends: "li",
+ });
+
+ class SimpleHeaderRow extends HTMLDivElement {
+ constructor() {
+ super();
+
+ this.addEventListener("contextmenu", event => {
+ gMessageHeader.openCopyPopup(event, this);
+ });
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "simple-header-row");
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ let sep = document.createElement("span");
+ sep.classList.add("screen-reader-only");
+ sep.setAttribute("data-l10n-name", "field-separator");
+ this.heading.appendChild(sep);
+
+ if (
+ ["organization", "subject", "date", "user-agent"].includes(
+ this.dataset.headerName
+ )
+ ) {
+ // message-header-organization-field
+ // message-header-subject-field
+ // message-header-date-field
+ // message-header-user-agent-field
+ document.l10n.setAttributes(
+ this.heading,
+ `message-header-${this.dataset.headerName}-field`
+ );
+ } else {
+ // If this simple row is used by an autogenerated custom header,
+ // use directly that header value as label.
+ document.l10n.setAttributes(
+ this.heading,
+ "message-header-custom-field",
+ {
+ fieldName: this.dataset.prettyHeaderName,
+ }
+ );
+ }
+ this.appendChild(this.heading);
+
+ this.classList.add("header-row");
+ this.tabIndex = 0;
+
+ this.value = document.createElement("span");
+ this.appendChild(this.value);
+ }
+
+ /**
+ * Set the text content for this row.
+ *
+ * @param {string} val - The content string to be added to this row.
+ */
+ set headerValue(val) {
+ this.value.textContent = val;
+ // NOTE: In principle, we could use aria-labelledby and point to the
+ // heading and value elements. However, for some reason the expected
+ // accessible name is not read out when focused whilst using Orca screen
+ // reader. Instead, only the content of the value element is read out.
+ // This may be because this element has no proper ARIA role since we are
+ // extending a div, which is not a best approach, so we can't expect
+ // proper support.
+ // TODO: This area needs some proper semantics to associate the fieldname
+ // with the field value, whilst being focusable to allow the user to open
+ // a context menu on the row.
+ this.setAttribute(
+ "aria-label",
+ `${this.heading.textContent} ${this.value.textContent}`
+ );
+ }
+ }
+ customElements.define("simple-header-row", SimpleHeaderRow, {
+ extends: "div",
+ });
+
+ class UrlHeaderRow extends SimpleHeaderRow {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ super.connectedCallback();
+
+ this.setAttribute("is", "url-header-row");
+ document.l10n.setAttributes(this.heading, "message-header-website-field");
+
+ this.value.classList.add("text-link");
+ this.addEventListener("click", event => {
+ if (event.button != 2) {
+ openUILink(encodeURI(this.value.textContent), event);
+ }
+ });
+ this.addEventListener("keydown", event => {
+ if (event.key == "Enter") {
+ openUILink(encodeURI(this.value.textContent), event);
+ }
+ });
+ }
+ }
+ customElements.define("url-header-row", UrlHeaderRow, {
+ extends: "div",
+ });
+
+ class HeaderNewsgroupsRow extends HTMLDivElement {
+ /**
+ * The array of all the newsgroups that need to be shown in this row.
+ *
+ * @type {Array<object>}
+ */
+ #newsgroups = [];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-newsgroups-row");
+ this.classList.add("header-newsgroups-row");
+
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ // message-header-newsgroups-list-name
+ // message-header-followup-to-list-name
+ document.l10n.setAttributes(
+ this.heading,
+ `message-header-${this.dataset.headerName}-list-name`
+ );
+ this.appendChild(this.heading);
+
+ this.newsgroupsList = document.createElement("ol");
+ this.newsgroupsList.classList.add("newsgroups-list");
+ this.newsgroupsList.setAttribute("aria-labelledby", this.heading.id);
+ this.appendChild(this.newsgroupsList);
+ }
+
+ addNewsgroup(newsgroup) {
+ this.#newsgroups.push(newsgroup);
+ }
+
+ buildView() {
+ this.newsgroupsList.replaceChildren();
+ for (let newsgroup of this.#newsgroups) {
+ let li = document.createElement("li", { is: "header-newsgroup" });
+ this.newsgroupsList.appendChild(li);
+ li.textContent = newsgroup;
+ }
+ }
+
+ clear() {
+ this.#newsgroups = [];
+ this.newsgroupsList.replaceChildren();
+ }
+ }
+ customElements.define("header-newsgroups-row", HeaderNewsgroupsRow, {
+ extends: "div",
+ });
+
+ class HeaderNewsgroup extends HTMLLIElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-newsgroup");
+ this.classList.add("header-newsgroup");
+ this.tabIndex = 0;
+
+ this.addEventListener("contextmenu", event => {
+ gMessageHeader.openNewsgroupPopup(event, this);
+ });
+ this.addEventListener("click", event => {
+ gMessageHeader.openNewsgroupPopup(event, this);
+ });
+ this.addEventListener("keypress", event => {
+ if (event.key == "Enter") {
+ gMessageHeader.openNewsgroupPopup(event, this);
+ }
+ });
+ }
+ }
+ customElements.define("header-newsgroup", HeaderNewsgroup, {
+ extends: "li",
+ });
+
+ class HeaderTagsRow extends HTMLDivElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-tags-row");
+ this.classList.add("header-tags-row");
+
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ document.l10n.setAttributes(
+ this.heading,
+ "message-header-tags-list-name"
+ );
+ this.appendChild(this.heading);
+
+ this.tagsList = document.createElement("ol");
+ this.tagsList.classList.add("tags-list");
+ this.tagsList.setAttribute("aria-labelledby", this.heading.id);
+ this.appendChild(this.tagsList);
+ }
+
+ buildTags(tags) {
+ // Clear old tags.
+ this.tagsList.replaceChildren();
+
+ for (let tag of tags) {
+ // For each tag, create a label, give it the font color that corresponds to the
+ // color of the tag and append it.
+ let tagName;
+ try {
+ // if we got a bad tag name, getTagForKey will throw an exception, skip it
+ // and go to the next one.
+ tagName = MailServices.tags.getTagForKey(tag);
+ } catch (ex) {
+ continue;
+ }
+
+ // Create a label for the tag name and set the color.
+ let li = document.createElement("li");
+ li.tabIndex = 0;
+ li.classList.add("tag");
+ li.textContent = tagName;
+
+ let color = MailServices.tags.getColorForKey(tag);
+ if (color) {
+ let textColor = !lazy.TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ li.setAttribute(
+ "style",
+ `color: ${textColor}; background-color: ${color};`
+ );
+ }
+
+ this.tagsList.appendChild(li);
+ }
+ }
+
+ clear() {
+ this.tagsList.replaceChildren();
+ }
+ }
+ customElements.define("header-tags-row", HeaderTagsRow, {
+ extends: "div",
+ });
+
+ class MultiMessageIdsRow extends HTMLDivElement {
+ /**
+ * The array of all the IDs that need to be shown in this row.
+ *
+ * @type {Array<object>}
+ */
+ #ids = [];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "multi-message-ids-row");
+ this.classList.add("multi-message-ids-row");
+
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ let sep = document.createElement("span");
+ sep.classList.add("screen-reader-only");
+ sep.setAttribute("data-l10n-name", "field-separator");
+ this.heading.appendChild(sep);
+
+ // message-header-references-field
+ // message-header-message-id-field
+ // message-header-in-reply-to-field
+ document.l10n.setAttributes(
+ this.heading,
+ `message-header-${this.dataset.headerName}-field`
+ );
+ this.appendChild(this.heading);
+
+ this.idsList = document.createElement("ol");
+ this.idsList.classList.add("ids-list");
+ this.appendChild(this.idsList);
+
+ this.toggleButton = document.createElement("button");
+ this.toggleButton.setAttribute("type", "button");
+ this.toggleButton.classList.add("show-more-ids", "plain");
+ this.toggleButton.addEventListener(
+ "mousedown",
+ // Prevent focus being transferred to the button before it is removed.
+ event => event.preventDefault()
+ );
+ this.toggleButton.addEventListener("click", () => this.buildView(true));
+
+ document.l10n.setAttributes(
+ this.toggleButton,
+ "message-ids-field-show-all"
+ );
+ }
+
+ addId(id) {
+ this.#ids.push(id);
+ }
+
+ buildView(showAll = false) {
+ this.idsList.replaceChildren();
+ for (let [count, id] of this.#ids.entries()) {
+ let li = document.createElement("li", { is: "header-message-id" });
+ li.id = id;
+ this.idsList.appendChild(li);
+ if (!showAll && count < this.#ids.length - 1 && this.#ids.length > 1) {
+ li.messageId.textContent = count + 1;
+ li.messageId.title = id;
+ } else {
+ li.messageId.textContent = id;
+ }
+ }
+
+ if (!showAll && this.#ids.length > 1) {
+ this.idsList.lastElementChild.classList.add("last-before-button");
+ let liButton = document.createElement("li");
+ liButton.appendChild(this.toggleButton);
+ this.idsList.appendChild(liButton);
+ }
+ }
+
+ clear() {
+ this.#ids = [];
+ this.idsList.replaceChildren();
+ }
+ }
+ customElements.define("multi-message-ids-row", MultiMessageIdsRow, {
+ extends: "div",
+ });
+
+ class HeaderMessageId extends HTMLLIElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-message-id");
+ this.classList.add("header-message-id");
+
+ this.messageId = document.createElement("span");
+ this.messageId.classList.add("text-link");
+ this.messageId.tabIndex = 0;
+ this.appendChild(this.messageId);
+
+ this.messageId.addEventListener("contextmenu", event => {
+ gMessageHeader.openMessageIdPopup(event, this);
+ });
+ this.messageId.addEventListener("click", event => {
+ gMessageHeader.onMessageIdClick(event);
+ });
+ this.messageId.addEventListener("keypress", event => {
+ if (event.key == "Enter") {
+ gMessageHeader.onMessageIdClick(event);
+ }
+ });
+ }
+ }
+ customElements.define("header-message-id", HeaderMessageId, {
+ extends: "li",
+ });
+}
diff --git a/comm/mail/base/content/widgets/mailWidgets.js b/comm/mail/base/content/widgets/mailWidgets.js
new file mode 100644
index 0000000000..6ad566b742
--- /dev/null
+++ b/comm/mail/base/content/widgets/mailWidgets.js
@@ -0,0 +1,2477 @@
+/**
+ * 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 ../../../components/compose/content/addressingWidgetOverlay.js */
+/* import-globals-from ../../../components/compose/content/MsgComposeCommands.js */
+
+/* global MozElements */
+/* global MozXULElement */
+/* global gFolderDisplay */
+/* global PluralForm */
+/* global onRecipientsChanged */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ const LazyModules = {};
+
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "DBViewWrapper",
+ "resource:///modules/DBViewWrapper.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "MimeParser",
+ "resource:///modules/mimeParser.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "TagUtils",
+ "resource:///modules/TagUtils.jsm"
+ );
+
+ // NOTE: Icon column headers should have their "label" attribute set to
+ // describe the icon for the accessibility tree.
+ //
+ // NOTE: Ideally we could listen for the "alt" attribute and pass it on to the
+ // contained <img>, but the accessibility tree only seems to read the "label"
+ // for a <treecol>, and ignores the alt text.
+ class MozTreecolImage extends customElements.get("treecol") {
+ static get observedAttributes() {
+ return ["src"];
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+ this.image = document.createElement("img");
+ this.image.classList.add("treecol-icon");
+
+ this.appendChild(this.image);
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.image) {
+ return;
+ }
+
+ const src = this.getAttribute("src");
+
+ if (src != null) {
+ this.image.setAttribute("src", src);
+ } else {
+ this.image.removeAttribute("src");
+ }
+ }
+ }
+ customElements.define("treecol-image", MozTreecolImage, {
+ extends: "treecol",
+ });
+
+ /**
+ * Class extending treecols. This features a customized treecolpicker that
+ * features a menupopup with more items than the standard one.
+ *
+ * @augments {MozTreecols}
+ */
+ class MozThreadPaneTreecols extends customElements.get("treecols") {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ let treecolpicker = this.querySelector("treecolpicker:not([is]");
+
+ // Can't change the super treecolpicker by setting
+ // is="thread-pane-treecolpicker" since that needs to be there at the
+ // parsing stage to take effect.
+ // So, remove the existing treecolpicker, and add a new one.
+ if (treecolpicker) {
+ treecolpicker.remove();
+ }
+ if (!this.querySelector("treecolpicker[is=thread-pane-treecolpicker]")) {
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <treecolpicker is="thread-pane-treecolpicker"
+ class="thread-tree-col-picker"
+ tooltiptext="&columnChooser2.tooltip;"
+ fixed="true">
+ </treecolpicker>
+ `,
+ ["chrome://messenger/locale/messenger.dtd"]
+ )
+ );
+ }
+ // Exceptionally apply super late, so we get the other goodness from there
+ // now that the treecolpicker is corrected.
+ super.connectedCallback();
+ }
+ }
+ customElements.define("thread-pane-treecols", MozThreadPaneTreecols, {
+ extends: "treecols",
+ });
+
+ /**
+ * Class extending treecolpicker. This implements UI to apply column settings
+ * of the current thread pane to other mail folders too.
+ *
+ * @augments {MozTreecolPicker}
+ */
+ class MozThreadPaneTreeColpicker extends customElements.get("treecolpicker") {
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ MozXULElement.insertFTLIfNeeded("messenger/mailWidgets.ftl");
+ let popup = this.querySelector(`menupopup[anonid="popup"]`);
+
+ // We'll add an "Apply columns to..." menu
+ popup.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <menu class="applyTo-menu" label="&columnPicker.applyTo.label;">
+ <menupopup>
+ <menu class="applyToFolder-menu"
+ label="&columnPicker.applyToFolder.label;">
+ <menupopup is="folder-menupopup"
+ class="applyToFolder"
+ showFileHereLabel="true"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyToFolderAndChildren-menu"
+ label="&columnPicker.applyToFolderAndChildren.label;">
+ <menupopup is="folder-menupopup"
+ class="applyToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </menu>
+ <menu class="applyViewTo-menu" data-l10n-id="apply-current-view-to-menu">
+ <menupopup>
+ <menu class="applyViewToFolder-menu"
+ label="&columnPicker.applyToFolder.label;">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolder"
+ showFileHereLabel="true"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyViewToFolderAndChildren-menu"
+ label="&columnPicker.applyToFolderAndChildren.label;">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </menu>
+ `,
+ ["chrome://messenger/locale/messenger.dtd"]
+ )
+ );
+
+ let confirmApplyCols = (destFolder, useChildren) => {
+ // Confirm the action with the user.
+ let bundle = document.getElementById("bundle_messenger");
+ let title = useChildren
+ ? "threadPane.columnPicker.confirmFolder.withChildren.title"
+ : "threadPane.columnPicker.confirmFolder.noChildren.title";
+ let message = useChildren
+ ? "threadPane.columnPicker.confirmFolder.withChildren.message"
+ : "threadPane.columnPicker.confirmFolder.noChildren.message";
+ let confirmed = Services.prompt.confirm(
+ null,
+ bundle.getString(title),
+ bundle.getFormattedString(message, [destFolder.prettyName])
+ );
+ if (confirmed) {
+ this._applyColumns(destFolder, useChildren);
+ }
+ };
+
+ this.querySelector(".applyToFolder-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyCols(event.target._folder, false);
+ }
+ );
+
+ this.querySelector(".applyToFolderAndChildren-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyCols(event.target._folder, true);
+ }
+ );
+
+ let confirmApplyView = async (destFolder, useChildren) => {
+ let msgId = useChildren
+ ? "threadpane-apply-changes-prompt-with-children-text"
+ : "threadpane-apply-changes-prompt-no-children-text";
+ let [title, message] = await document.l10n.formatValues([
+ { id: "threadpane-apply-changes-prompt-title" },
+ { id: msgId, args: { name: destFolder.prettyName } },
+ ]);
+ if (Services.prompt.confirm(null, title, message)) {
+ this._applyView(destFolder, useChildren);
+ }
+ };
+
+ this.querySelector(".applyViewToFolder-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyView(event.target._folder, false);
+ }
+ );
+
+ this.querySelector(".applyViewToFolderAndChildren-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyView(event.target._folder, true);
+ }
+ );
+ }
+
+ _applyColumns(destFolder, useChildren) {
+ // Get the current folder's column state, plus the "swapped" column
+ // state, which swaps "From" and "Recipient" if only one is shown.
+ // This is useful for copying an incoming folder's columns to an
+ // outgoing folder, or vice versa.
+ let colState = gFolderDisplay.getColumnStates();
+
+ let myColStateString = JSON.stringify(colState);
+ let swappedColStateString;
+ if (colState.senderCol.visible != colState.recipientCol.visible) {
+ let tmp = colState.senderCol;
+ colState.senderCol = colState.recipientCol;
+ colState.recipientCol = tmp;
+ swappedColStateString = JSON.stringify(colState);
+ } else {
+ swappedColStateString = myColStateString;
+ }
+
+ let isOutgoing = function (folder) {
+ return folder.isSpecialFolder(
+ LazyModules.DBViewWrapper.prototype.OUTGOING_FOLDER_FLAGS,
+ true
+ );
+ };
+
+ let amIOutgoing = isOutgoing(gFolderDisplay.displayedFolder);
+
+ let colStateString = function (folder) {
+ return isOutgoing(folder) == amIOutgoing
+ ? myColStateString
+ : swappedColStateString;
+ };
+
+ // Now propagate appropriately...
+ const propName = gFolderDisplay.PERSISTED_COLUMN_PROPERTY_NAME;
+ if (useChildren) {
+ LazyModules.MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ folder => {
+ folder.setStringProperty(propName, colStateString(folder));
+ // Force the reference to be forgotten.
+ folder.msgDatabase = null;
+ }
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gFolderDisplay.displayedFolder,
+ "msg-folder-columns-propagated"
+ );
+ });
+ } else {
+ destFolder.setStringProperty(propName, colStateString(destFolder));
+ // null out to avoid memory bloat.
+ destFolder.msgDatabase = null;
+ }
+ }
+
+ _applyView(destFolder, useChildren) {
+ let viewFlags =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.viewFlags;
+ let sortType =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortType;
+ let sortOrder =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortOrder;
+ if (useChildren) {
+ LazyModules.MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ folder => {
+ folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags;
+ folder.msgDatabase.dBFolderInfo.sortType = sortType;
+ folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder;
+ folder.msgDatabase = null;
+ }
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gFolderDisplay.displayedFolder,
+ "msg-folder-views-propagated"
+ );
+ });
+ } else {
+ destFolder.msgDatabase.dBFolderInfo.viewFlags = viewFlags;
+ destFolder.msgDatabase.dBFolderInfo.sortType = sortType;
+ destFolder.msgDatabase.dBFolderInfo.sortOrder = sortOrder;
+ // null out to avoid memory bloat
+ destFolder.msgDatabase = null;
+ }
+ }
+ }
+ customElements.define(
+ "thread-pane-treecolpicker",
+ MozThreadPaneTreeColpicker,
+ { extends: "treecolpicker" }
+ );
+
+ // The menulist CE is defined lazily. Create one now to get menulist defined,
+ // allowing us to inherit from it.
+ if (!customElements.get("menulist")) {
+ delete document.createXULElement("menulist");
+ }
+ {
+ /**
+ * MozMenulistEditable is a menulist widget that can be made editable by setting editable="true".
+ * With an additional type="description" the list also contains an additional label that can hold
+ * for instance, a description of a menu item.
+ * It is typically used e.g. for the "Custom From Address..." feature to let the user chose and
+ * edit the address to send from.
+ *
+ * @augments {MozMenuList}
+ */
+ class MozMenulistEditable extends customElements.get("menulist") {
+ static get markup() {
+ // Accessibility information of these nodes will be
+ // presented on XULComboboxAccessible generated from <menulist>;
+ // hide these nodes from the accessibility tree.
+ return `
+ <html:link rel="stylesheet" href="chrome://global/skin/menulist.css"/>
+ <html:input part="text-input" type="text" allowevents="true"/>
+ <hbox id="label-box" part="label-box" flex="1" role="none">
+ <label id="label" part="label" crop="end" flex="1" role="none"/>
+ <label id="highlightable-label" part="label" crop="end" flex="1" role="none"/>
+ </hbox>
+ <dropmarker part="dropmarker" exportparts="icon: dropmarker-icon" type="menu" role="none"/>
+ <html:slot/>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.shadowRoot.appendChild(this.constructor.fragment);
+ this._inputField = this.shadowRoot.querySelector("input");
+ this._labelBox = this.shadowRoot.getElementById("label-box");
+ this._dropmarker = this.shadowRoot.querySelector("dropmarker");
+
+ if (this.getAttribute("type") == "description") {
+ this._description = document.createXULElement("label");
+ this._description.id = this._description.part = "description";
+ this._description.setAttribute("crop", "end");
+ this._description.setAttribute("role", "none");
+ this.shadowRoot.getElementById("label").after(this._description);
+ }
+
+ this.initializeAttributeInheritance();
+
+ this.mSelectedInternal = null;
+ this.setInitialSelection();
+
+ this._handleMutation = mutations => {
+ this.editable = this.getAttribute("editable") == "true";
+ };
+ this.mAttributeObserver = new MutationObserver(this._handleMutation);
+ this.mAttributeObserver.observe(this, {
+ attributes: true,
+ attributeFilter: ["editable"],
+ });
+
+ this._keypress = event => {
+ if (event.key == "ArrowDown") {
+ this.open = true;
+ }
+ };
+ this._inputField.addEventListener("keypress", this._keypress);
+ this._change = event => {
+ event.stopPropagation();
+ this.selectedItem = null;
+ this.setAttribute("value", this._inputField.value);
+ // Start the event again, but this time with the menulist as target.
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ };
+ this._inputField.addEventListener("change", this._change);
+
+ this._popupHiding = event => {
+ // layerX is 0 if the user clicked outside the popup.
+ if (this.editable && event.layerX > 0) {
+ this._inputField.select();
+ }
+ };
+ if (!this.menupopup) {
+ this.appendChild(MozXULElement.parseXULToFragment(`<menupopup />`));
+ }
+ this.menupopup.addEventListener("popuphiding", this._popupHiding);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this.mAttributeObserver.disconnect();
+ this._inputField.removeEventListener("keypress", this._keypress);
+ this._inputField.removeEventListener("change", this._change);
+ this.menupopup.removeEventListener("popuphiding", this._popupHiding);
+
+ for (let prop of [
+ "_inputField",
+ "_labelBox",
+ "_dropmarker",
+ "_description",
+ ]) {
+ if (this[prop]) {
+ this[prop].remove();
+ this[prop] = null;
+ }
+ }
+ }
+
+ static get inheritedAttributes() {
+ let attrs = super.inheritedAttributes;
+ attrs.input = "value,disabled";
+ attrs["#description"] = "value=description";
+ return attrs;
+ }
+
+ set editable(val) {
+ if (val == this.editable) {
+ return;
+ }
+
+ if (!val) {
+ // If we were focused and transition from editable to not editable,
+ // focus the parent menulist so that the focus does not get stuck.
+ if (this._inputField == document.activeElement) {
+ window.setTimeout(() => this.focus(), 0);
+ }
+ }
+
+ this.setAttribute("editable", val);
+ }
+
+ get editable() {
+ return this.getAttribute("editable") == "true";
+ }
+
+ set value(val) {
+ this._inputField.value = val;
+ this.setAttribute("value", val);
+ this.setAttribute("label", val);
+ }
+
+ get value() {
+ if (this.editable) {
+ return this._inputField.value;
+ }
+ return super.value;
+ }
+
+ get label() {
+ if (this.editable) {
+ return this._inputField.value;
+ }
+ return super.label;
+ }
+
+ set placeholder(val) {
+ this._inputField.placeholder = val;
+ }
+
+ get placeholder() {
+ return this._inputField.placeholder;
+ }
+
+ set selectedItem(val) {
+ if (val) {
+ this._inputField.value = val.getAttribute("value");
+ }
+ super.selectedItem = val;
+ }
+
+ get selectedItem() {
+ return super.selectedItem;
+ }
+
+ focus() {
+ if (this.editable) {
+ this._inputField.focus();
+ } else {
+ super.focus();
+ }
+ }
+
+ select() {
+ if (this.editable) {
+ this._inputField.select();
+ }
+ }
+ }
+
+ const MenuBaseControl = MozElements.BaseControlMixin(
+ MozElements.MozElementMixin(XULMenuElement)
+ );
+ MenuBaseControl.implementCustomInterface(MozMenulistEditable, [
+ Ci.nsIDOMXULMenuListElement,
+ Ci.nsIDOMXULSelectControlElement,
+ ]);
+
+ customElements.define("menulist-editable", MozMenulistEditable, {
+ extends: "menulist",
+ });
+ }
+
+ /**
+ * The MozAttachmentlist widget lists attachments for a mail. This is typically used to show
+ * attachments while writing a new mail as well as when reading mails.
+ *
+ * @augments {MozElements.RichListBox}
+ */
+ class MozAttachmentlist extends MozElements.RichListBox {
+ constructor() {
+ super();
+
+ this.messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ this.addEventListener("keypress", event => {
+ switch (event.key) {
+ case " ":
+ // Allow plain spacebar to select the focused item.
+ if (!event.shiftKey && !event.ctrlKey) {
+ this.addItemToSelection(this.currentItem);
+ }
+ // Prevent inbuilt scrolling.
+ event.preventDefault();
+ break;
+
+ case "Enter":
+ if (this.currentItem && !event.ctrlKey && !event.shiftKey) {
+ this.addItemToSelection(this.currentItem);
+ let evt = document.createEvent("XULCommandEvent");
+ evt.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ event.ctrlKey,
+ event.altKey,
+ event.shiftKey,
+ event.metaKey,
+ null
+ );
+ this.currentItem.dispatchEvent(evt);
+ }
+ break;
+ }
+ });
+
+ // Make sure we keep the focus.
+ this.addEventListener("mousedown", event => {
+ if (event.button != 0) {
+ return;
+ }
+
+ if (document.commandDispatcher.focusedElement != this) {
+ this.focus();
+ }
+ });
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ let children = Array.from(this._childNodes);
+
+ children
+ .filter(child => child.getAttribute("selected") == "true")
+ .forEach(this.selectedItems.append, this.selectedItems);
+
+ children
+ .filter(child => !child.hasAttribute("context"))
+ .forEach(child =>
+ child.setAttribute("context", this.getAttribute("itemcontext"))
+ );
+ }
+
+ get itemCount() {
+ return this._childNodes.length;
+ }
+
+ /**
+ * Get the preferred height (the height that would allow us to fit
+ * everything without scrollbars) of the attachmentlist's bounding
+ * rectangle. Add 3px to account for item's margin.
+ */
+ get preferredHeight() {
+ return this.scrollHeight + this.getBoundingClientRect().height + 3;
+ }
+
+ get _childNodes() {
+ return this.querySelectorAll("richlistitem.attachmentItem");
+ }
+
+ getIndexOfItem(item) {
+ for (let i = 0; i < this._childNodes.length; i++) {
+ if (this._childNodes[i] === item) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ getItemAtIndex(index) {
+ if (index >= 0 && index < this._childNodes.length) {
+ return this._childNodes[index];
+ }
+ return null;
+ }
+
+ getRowCount() {
+ return this._childNodes.length;
+ }
+
+ getIndexOfFirstVisibleRow() {
+ if (this._childNodes.length == 0) {
+ return -1;
+ }
+
+ // First try to estimate which row is visible, assuming they're all the same height.
+ let box = this;
+ let estimatedRow = Math.floor(
+ box.scrollTop / this._childNodes[0].getBoundingClientRect().height
+ );
+ let estimatedIndex = estimatedRow * this._itemsPerRow();
+ let offset = this._childNodes[estimatedIndex].screenY - box.screenY;
+
+ if (offset > 0) {
+ // We went too far! Go back until we find an item totally off-screen, then return the one
+ // after that.
+ for (let i = estimatedIndex - 1; i >= 0; i--) {
+ let childBoxObj = this._childNodes[i].getBoundingClientRect();
+ if (childBoxObj.screenY + childBoxObj.height <= box.screenY) {
+ return i + 1;
+ }
+ }
+
+ // If we get here, we must have gone back to the beginning of the list, so just return 0.
+ return 0;
+ }
+
+ // We didn't go far enough! Keep going until we find an item at least partially on-screen.
+ for (let i = estimatedIndex; i < this._childNodes.length; i++) {
+ let childBoxObj = this._childNodes[i].getBoundingClientRect();
+ if (childBoxObj.screenY + childBoxObj.height > box.screenY > 0) {
+ return i;
+ }
+ }
+
+ return null;
+ }
+
+ ensureIndexIsVisible(index) {
+ this.ensureElementIsVisible(this.getItemAtIndex(index));
+ }
+
+ ensureElementIsVisible(item) {
+ let box = this;
+
+ // Are we too far down?
+ if (item.screenY < box.screenY) {
+ box.scrollTop =
+ item.getBoundingClientRect().y - box.getBoundingClientRect().y;
+ } else if (
+ item.screenY + item.getBoundingClientRect().height >
+ box.screenY + box.getBoundingClientRect().height
+ ) {
+ // ... or not far enough?
+ box.scrollTop =
+ item.getBoundingClientRect().y +
+ item.getBoundingClientRect().height -
+ box.getBoundingClientRect().y -
+ box.getBoundingClientRect().height;
+ }
+ }
+
+ scrollToIndex(index) {
+ let box = this;
+ let item = this.getItemAtIndex(index);
+ if (!item) {
+ return;
+ }
+ box.scrollTop =
+ item.getBoundingClientRect().y - box.getBoundingClientRect().y;
+ }
+
+ appendItem(attachment, name) {
+ // -1 appends due to the way getItemAtIndex is implemented.
+ return this.insertItemAt(-1, attachment, name);
+ }
+
+ insertItemAt(index, attachment, name) {
+ let item = this.ownerDocument.createXULElement("richlistitem");
+ item.classList.add("attachmentItem");
+ item.setAttribute("role", "option");
+
+ item.addEventListener("dblclick", event => {
+ let evt = document.createEvent("XULCommandEvent");
+ evt.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ event.ctrlKey,
+ event.altKey,
+ event.shiftKey,
+ event.metaKey,
+ null
+ );
+ item.dispatchEvent(evt);
+ });
+
+ let makeDropIndicator = placementClass => {
+ let img = document.createElement("img");
+ img.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/tab-drag-indicator.svg"
+ );
+ img.setAttribute("alt", "");
+ img.classList.add("attach-drop-indicator", placementClass);
+ return img;
+ };
+
+ item.appendChild(makeDropIndicator("before"));
+
+ let icon = this.ownerDocument.createElement("img");
+ icon.setAttribute("alt", "");
+ icon.setAttribute("draggable", "false");
+ // Allow the src to be invalid.
+ icon.classList.add("attachmentcell-icon", "invisible-on-broken");
+ item.appendChild(icon);
+
+ let textLabel = this.ownerDocument.createElement("span");
+ textLabel.classList.add("attachmentcell-name");
+ item.appendChild(textLabel);
+
+ let extensionLabel = this.ownerDocument.createElement("span");
+ extensionLabel.classList.add("attachmentcell-extension");
+ item.appendChild(extensionLabel);
+
+ let sizeLabel = this.ownerDocument.createElement("span");
+ sizeLabel.setAttribute("role", "note");
+ sizeLabel.classList.add("attachmentcell-size");
+ item.appendChild(sizeLabel);
+
+ item.appendChild(makeDropIndicator("after"));
+
+ item.setAttribute("context", this.getAttribute("itemcontext"));
+
+ item.attachment = attachment;
+ this.invalidateItem(item, name);
+ this.insertBefore(item, this.getItemAtIndex(index));
+ return item;
+ }
+
+ /**
+ * Set the attachment icon source.
+ *
+ * @param {MozRichlistitem} item - The attachment item to set the icon of.
+ * @param {string|null} src - The src to set.
+ */
+ setAttachmentIconSrc(item, src) {
+ let icon = item.querySelector(".attachmentcell-icon");
+ icon.setAttribute("src", src);
+ }
+
+ /**
+ * Refresh the attachment icon using the attachment details.
+ *
+ * @param {MozRichlistitem} item - The attachment item to refresh the icon
+ * for.
+ */
+ refreshAttachmentIcon(item) {
+ let src;
+ let attachment = item.attachment;
+ let type = attachment.contentType;
+ if (type == "text/x-moz-deleted") {
+ src = "chrome://messenger/skin/icons/attachment-deleted.svg";
+ } else if (!item.loaded || item.uploading) {
+ src = "chrome://global/skin/icons/loading.png";
+ } else if (item.cloudIcon) {
+ src = item.cloudIcon;
+ } else {
+ let iconName = attachment.name;
+ if (iconName.toLowerCase().endsWith(".eml")) {
+ // Discard file names derived from subject headers with special
+ // characters.
+ iconName = "message.eml";
+ } else if (attachment.url) {
+ // For local file urls, we are better off using the full file url
+ // because moz-icon will actually resolve the file url and get the
+ // right icon from the file url. All other urls, we should try to
+ // extract the file name from them. This fixes issues where an icon
+ // wasn't showing up if you dragged a web url that had a query or
+ // reference string after the file name and for mailnews urls where
+ // the filename is hidden in the url as a &filename= part.
+ let url = Services.io.newURI(attachment.url);
+ if (
+ url instanceof Ci.nsIURL &&
+ url.fileName &&
+ !url.schemeIs("file")
+ ) {
+ iconName = url.fileName;
+ }
+ }
+ src = `moz-icon://${iconName}?size=16&contentType=${type}`;
+ }
+
+ this.setAttachmentIconSrc(item, src);
+ }
+
+ /**
+ * Get whether the attachment list is fully loaded.
+ *
+ * @returns {boolean} - Whether all the attachments in the list are fully
+ * loaded.
+ */
+ isLoaded() {
+ // Not loaded if at least one loading.
+ for (let item of this.querySelectorAll(".attachmentItem")) {
+ if (!item.loaded) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Set the attachment item's loaded state.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {boolean} loaded - Whether the attachment is fully loaded.
+ */
+ setAttachmentLoaded(item, loaded) {
+ item.loaded = loaded;
+ this.refreshAttachmentIcon(item);
+ }
+
+ /**
+ * Set the attachment item's cloud icon, if any.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {?string} cloudIcon - The icon of the cloud provider where the
+ * attachment was uploaded. Will be used as file type icon in the list of
+ * attachments, if specified.
+ */
+ setCloudIcon(item, cloudIcon) {
+ item.cloudIcon = cloudIcon;
+ this.refreshAttachmentIcon(item);
+ }
+
+ /**
+ * Set the attachment item's displayed name.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {string} name - The name to display for the attachment.
+ */
+ setAttachmentName(item, name) {
+ item.setAttribute("name", name);
+ // Extract what looks like the file extension so we can always show it,
+ // even if the full name would overflow.
+ // NOTE: This is a convenience feature rather than a security feature
+ // since the content type of an attachment need not match the extension.
+ let found = name.match(/^(.+)(\.[a-zA-Z0-9_#$!~+-]{1,16})$/);
+ item.querySelector(".attachmentcell-name").textContent =
+ found?.[1] || name;
+ item.querySelector(".attachmentcell-extension").textContent =
+ found?.[2] || "";
+ }
+
+ /**
+ * Set the attachment item's displayed size.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {string} size - The size to display for the attachment.
+ */
+ setAttachmentSize(item, size) {
+ item.setAttribute("size", size);
+ let sizeEl = item.querySelector(".attachmentcell-size");
+ sizeEl.textContent = size;
+ sizeEl.hidden = !size;
+ }
+
+ invalidateItem(item, name) {
+ let attachment = item.attachment;
+
+ this.setAttachmentName(item, name || attachment.name);
+ let size =
+ attachment.size == null || attachment.size == -1
+ ? ""
+ : this.messenger.formatFileSize(attachment.size);
+ if (size && item.cloudHtmlFileSize > 0) {
+ size = `${this.messenger.formatFileSize(
+ item.cloudHtmlFileSize
+ )} (${size})`;
+ }
+ this.setAttachmentSize(item, size);
+
+ // By default, items are considered loaded.
+ item.loaded = true;
+ this.refreshAttachmentIcon(item);
+ return item;
+ }
+
+ /**
+ * Find the attachmentitem node for the specified nsIMsgAttachment.
+ */
+ findItemForAttachment(aAttachment) {
+ for (let i = 0; i < this.itemCount; i++) {
+ let item = this.getItemAtIndex(i);
+ if (item.attachment == aAttachment) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ _fireOnSelect() {
+ if (!this._suppressOnSelect && !this.suppressOnSelect) {
+ this.dispatchEvent(
+ new Event("select", { bubbles: false, cancelable: true })
+ );
+ }
+ }
+
+ _itemsPerRow() {
+ // For 0 or 1 children, we can assume that they all fit in one row.
+ if (this._childNodes.length < 2) {
+ return this._childNodes.length;
+ }
+
+ let itemWidth =
+ this._childNodes[1].getBoundingClientRect().x -
+ this._childNodes[0].getBoundingClientRect().x;
+
+ // Each item takes up a full row
+ if (itemWidth == 0) {
+ return 1;
+ }
+ return Math.floor(this.clientWidth / itemWidth);
+ }
+
+ _itemsPerCol(aItemsPerRow) {
+ let itemsPerRow = aItemsPerRow || this._itemsPerRow();
+
+ if (this._childNodes.length == 0) {
+ return 0;
+ }
+
+ if (this._childNodes.length <= itemsPerRow) {
+ return 1;
+ }
+
+ let itemHeight =
+ this._childNodes[itemsPerRow].getBoundingClientRect().y -
+ this._childNodes[0].getBoundingClientRect().y;
+
+ return Math.floor(this.clientHeight / itemHeight);
+ }
+
+ /**
+ * Set the width of each child to the largest width child to create a
+ * grid-like effect for the flex-wrapped attachment list.
+ */
+ setOptimumWidth() {
+ if (this._childNodes.length == 0) {
+ return;
+ }
+
+ let width = 0;
+ for (let child of this._childNodes) {
+ // Unset the width, then the child will expand or shrink to its
+ // "natural" size in the flex-wrapped container. I.e. its preferred
+ // width bounded by the width of the container's content space.
+ child.style.width = null;
+ width = Math.max(width, child.getBoundingClientRect().width);
+ }
+ for (let child of this._childNodes) {
+ child.style.width = `${width}px`;
+ }
+ }
+ }
+
+ customElements.define("attachment-list", MozAttachmentlist, {
+ extends: "richlistbox",
+ });
+
+ /**
+ * The MailAddressPill widget is used to display the email addresses in the
+ * messengercompose.xhtml window.
+ *
+ * @augments {MozXULElement}
+ */
+ class MailAddressPill extends MozXULElement {
+ static get inheritedAttributes() {
+ return {
+ ".pill-label": "crop,value=label",
+ };
+ }
+
+ /**
+ * Indicates whether the address of this pill is for a mail list.
+ *
+ * @type {boolean}
+ */
+ isMailList = false;
+
+ /**
+ * If this pill is for a mail list, this provides the URI.
+ *
+ * @type {?string}
+ */
+ listURI = null;
+
+ /**
+ * If this pill is for a mail list, this provides the total count of
+ * its addresses.
+ *
+ * @type {number}
+ */
+ listAddressCount = 0;
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.classList.add("address-pill");
+ this.setAttribute("context", "emailAddressPillPopup");
+ this.setAttribute("allowevents", "true");
+
+ this.labelView = document.createXULElement("hbox");
+ this.labelView.setAttribute("flex", "1");
+
+ this.pillLabel = document.createXULElement("label");
+ this.pillLabel.classList.add("pill-label");
+ this.pillLabel.setAttribute("crop", "center");
+
+ this.pillIndicator = document.createElement("img");
+ this.pillIndicator.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/pill-indicator.svg"
+ );
+ this.pillIndicator.setAttribute("alt", "");
+ this.pillIndicator.classList.add("pill-indicator");
+ this.pillIndicator.hidden = true;
+
+ this.labelView.appendChild(this.pillLabel);
+ this.labelView.appendChild(this.pillIndicator);
+
+ this.appendChild(this.labelView);
+ this._setupEmailInput();
+
+ this._setupEventListeners();
+ this.initializeAttributeInheritance();
+
+ // @implements {nsIObserver}
+ this.inputObserver = {
+ observe: (subject, topic, data) => {
+ if (topic == "autocomplete-did-enter-text" && this.isEditing) {
+ this.updatePill();
+ }
+ },
+ };
+
+ Services.obs.addObserver(
+ this.inputObserver,
+ "autocomplete-did-enter-text"
+ );
+
+ // Remove the observer on window unload as the disconnectedCallback()
+ // will never be called when closing a window, so we might therefore
+ // leak if XPCOM isn't smart enough.
+ window.addEventListener(
+ "unload",
+ () => {
+ this.removeObserver();
+ },
+ { once: true }
+ );
+ }
+
+ get emailAddress() {
+ return this.getAttribute("emailAddress");
+ }
+
+ set emailAddress(val) {
+ this.setAttribute("emailAddress", val);
+ }
+
+ get label() {
+ return this.getAttribute("label");
+ }
+
+ set label(val) {
+ this.setAttribute("label", val);
+ }
+
+ get fullAddress() {
+ return this.getAttribute("fullAddress");
+ }
+
+ set fullAddress(val) {
+ this.setAttribute("fullAddress", val);
+ }
+
+ get displayName() {
+ return this.getAttribute("displayName");
+ }
+
+ set displayName(val) {
+ this.setAttribute("displayName", val);
+ }
+
+ get emailInput() {
+ return this.querySelector(`input[is="autocomplete-input"]`);
+ }
+
+ /**
+ * Get the main addressing input field the pill belongs to.
+ */
+ get rowInput() {
+ return this.closest(".address-container").querySelector(
+ ".address-row-input"
+ );
+ }
+
+ /**
+ * Check if the pill is currently in "Edit Mode", meaning the label is
+ * hidden and the html:input field is visible.
+ *
+ * @returns {boolean} true if the pill is currently being edited.
+ */
+ get isEditing() {
+ return !this.emailInput.hasAttribute("hidden");
+ }
+
+ get fragment() {
+ if (!this.constructor.hasOwnProperty("_fragment")) {
+ this.constructor._fragment = MozXULElement.parseXULToFragment(`
+ <html:input is="autocomplete-input"
+ type="text"
+ class="input-pill"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ hidden="hidden"/>
+ `);
+ }
+ return document.importNode(this.constructor._fragment, true);
+ }
+
+ _setupEmailInput() {
+ this.appendChild(this.fragment);
+ this.emailInput.value = this.fullAddress;
+ }
+
+ _setupEventListeners() {
+ this.addEventListener("blur", event => {
+ // Prevent deselecting a pill on blur if:
+ // - The related target is null (context menu was opened, bug 1729741).
+ // - The related target is another pill (multi selection and deslection
+ // are handled by the click event listener added on pill creation).
+ if (
+ !event.relatedTarget ||
+ event.relatedTarget.tagName == "mail-address-pill"
+ ) {
+ return;
+ }
+
+ this.closest("mail-recipients-area").deselectAllPills();
+ });
+
+ this.emailInput.addEventListener("keypress", event => {
+ if (this.hasAttribute("disabled")) {
+ return;
+ }
+ this.onEmailInputKeyPress(event);
+ });
+
+ // Disable the inbuilt autocomplete on blur as we handle it here.
+ this.emailInput._dontBlur = true;
+
+ this.emailInput.addEventListener("blur", () => {
+ // If the input is still the active element after blur (when switching
+ // to another window), return to prevent autocompletion and
+ // pillification and let the user continue editing the address later.
+ if (document.activeElement == this.emailInput) {
+ return;
+ }
+
+ if (
+ this.emailInput.forceComplete &&
+ this.emailInput.mController.matchCount >= 1
+ ) {
+ // If input.forceComplete is true and there are autocomplete matches,
+ // we need to call the inbuilt Enter handler to force the input text
+ // to the best autocomplete match because we've set input._dontBlur.
+ this.emailInput.mController.handleEnter(true);
+ return;
+ }
+
+ this.updatePill();
+ });
+ }
+
+ /**
+ * Simple email address validation.
+ *
+ * @param {string} address - An email address.
+ */
+ isValidAddress(address) {
+ return /^[^\s@]+@[^\s@]+$/.test(address);
+ }
+
+ /**
+ * Convert the pill into "Edit Mode" by hiding the label and showing the
+ * html:input element.
+ */
+ startEditing() {
+ // Record the intention of editing a pill as a change in the recipient
+ // even if the text is not actually changed in order to prevent accidental
+ // data loss.
+ onRecipientsChanged();
+
+ // We need to set the min and max width before hiding and showing the
+ // child nodes in order to prevent unwanted jumps in the resizing of the
+ // edited pill. Both properties are necessary to handle flexbox.
+ this.style.setProperty("max-width", `${this.clientWidth}px`);
+ this.style.setProperty("min-width", `${this.clientWidth}px`);
+
+ this.classList.add("editing");
+ this.labelView.setAttribute("hidden", "true");
+ this.emailInput.removeAttribute("hidden");
+ this.emailInput.focus();
+
+ // Account for pill padding.
+ let inputWidth = this.emailInput.clientWidth + 15;
+
+ // In case the original address is shorter than the input field child node
+ // force resize the pill container to prevent overflows.
+ if (inputWidth > this.clientWidth) {
+ this.style.setProperty("max-width", `${inputWidth}px`);
+ this.style.setProperty("min-width", `${inputWidth}px`);
+ }
+ }
+
+ /**
+ * Revert the pill UI to a regular selectable element, meaning the label is
+ * visible and the html:input field is hidden.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ onEmailInputKeyPress(event) {
+ switch (event.key) {
+ case "Escape":
+ this.emailInput.value = this.fullAddress;
+ this.resetPill();
+ break;
+ case "Delete":
+ case "Backspace":
+ if (!this.emailInput.value.trim() && !event.repeat) {
+ this.rowInput.focus();
+ this.remove();
+ }
+ break;
+ }
+ }
+
+ async updatePill() {
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(
+ this.emailInput.value
+ );
+ let row = this.closest(".address-row");
+
+ if (!addresses[0]) {
+ this.rowInput.focus();
+ this.remove();
+ // Update aria labels of all pills in the row, as pill count changed.
+ updateAriaLabelsOfAddressRow(row);
+ onRecipientsChanged();
+ return;
+ }
+
+ this.label = addresses[0].toString();
+ this.emailAddress = addresses[0].email || "";
+ this.fullAddress = addresses[0].toString();
+ this.displayName = addresses[0].name || "";
+ // We need to detach the autocomplete Controller to prevent the input
+ // to be filled with the previously selected address when the "blur"
+ // event gets triggered.
+ this.emailInput.detachController();
+ // Attach it again to enable autocomplete.
+ this.emailInput.attachController();
+
+ this.resetPill();
+
+ // Update the aria label of edited pill only, as pill count didn't change.
+ // Unfortunately, we still need to get the row's pills for counting once.
+ let pills = row.querySelectorAll("mail-address-pill");
+ this.setAttribute(
+ "aria-label",
+ await document.l10n.formatValue("pill-aria-label", {
+ email: this.fullAddress,
+ count: pills.length,
+ })
+ );
+
+ onRecipientsChanged();
+ }
+
+ resetPill() {
+ this.updatePillStatus();
+ this.style.removeProperty("max-width");
+ this.style.removeProperty("min-width");
+ this.classList.remove("editing");
+ this.labelView.removeAttribute("hidden");
+ this.emailInput.setAttribute("hidden", "hidden");
+ let textLength = this.emailInput.value.length;
+ this.emailInput.setSelectionRange(textLength, textLength);
+ this.rowInput.focus();
+ }
+
+ /**
+ * Check if an address is valid or it exists in the address book and update
+ * the helper icons accordingly.
+ */
+ async updatePillStatus() {
+ let isValid = this.isValidAddress(this.emailAddress);
+ let listNames = LazyModules.MimeParser.parseHeaderField(
+ this.fullAddress,
+ LazyModules.MimeParser.HEADER_ADDRESS
+ );
+
+ if (listNames.length > 0) {
+ let mailList = MailServices.ab.getMailListFromName(listNames[0].name);
+ this.isMailList = !!mailList;
+ if (this.isMailList) {
+ this.listURI = mailList.URI;
+ this.listAddressCount = mailList.childCards.length;
+ } else {
+ this.listURI = "";
+ this.listAddressCount = 0;
+ }
+ }
+
+ let isNewsgroup = this.emailInput.classList.contains("news-input");
+
+ if (!isValid && !this.isMailList && !isNewsgroup) {
+ this.classList.add("invalid-address");
+ this.setAttribute(
+ "tooltiptext",
+ await document.l10n.formatValue("pill-tooltip-invalid-address", {
+ email: this.fullAddress,
+ })
+ );
+ this.pillIndicator.hidden = true;
+
+ // Interrupt if the address is not valid as we don't need to check for
+ // other conditions.
+ return;
+ }
+
+ this.classList.remove("invalid-address");
+ this.removeAttribute("tooltiptext");
+ this.pillIndicator.hidden = true;
+
+ // Check if the address is not in the Address Book only if it's not a
+ // mail list or a newsgroup.
+ if (
+ !isNewsgroup &&
+ !this.isMailList &&
+ !MailServices.ab.cardForEmailAddress(this.emailAddress)
+ ) {
+ this.setAttribute(
+ "tooltiptext",
+ await document.l10n.formatValue("pill-tooltip-not-in-address-book", {
+ email: this.fullAddress,
+ })
+ );
+ this.pillIndicator.hidden = false;
+ }
+ }
+
+ /**
+ * Get the nearest sibling pill which is not selected.
+ *
+ * @param {("next"|"previous")} [siblingsType="next"] - Iterate next or
+ * previous siblings.
+ * @returns {HTMLElement} - The nearest unselected sibling element, or null.
+ */
+ getUnselectedSiblingPill(siblingsType = "next") {
+ if (siblingsType == "next") {
+ // Check for next siblings.
+ let element = this.nextElementSibling;
+ while (element) {
+ if (!element.hasAttribute("selected")) {
+ return element;
+ }
+ element = element.nextElementSibling;
+ }
+
+ return null;
+ }
+
+ // Check for previous siblings.
+ let element = this.previousElementSibling;
+ while (element) {
+ if (!element.hasAttribute("selected")) {
+ return element;
+ }
+ element = element.previousElementSibling;
+ }
+
+ return null;
+ }
+
+ removeObserver() {
+ Services.obs.removeObserver(
+ this.inputObserver,
+ "autocomplete-did-enter-text"
+ );
+ }
+ }
+
+ customElements.define("mail-address-pill", MailAddressPill);
+
+ /**
+ * The MailRecipientsArea widget is used to display the recipient rows in the
+ * header area of the messengercompose.xul window.
+ *
+ * @augments {MozXULElement}
+ */
+ class MailRecipientsArea extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ for (let input of this.querySelectorAll(".mail-input,.news-input")) {
+ // Disable inbuilt autocomplete on blur to handle it with our handlers.
+ input._dontBlur = true;
+
+ setupAutocompleteInput(input);
+
+ input.addEventListener("keypress", event => {
+ // Ctrl+Shift+Tab is handled by moveFocusToNeighbouringArea.
+ if (event.key != "Tab" || !event.shiftKey || event.ctrlKey) {
+ return;
+ }
+ event.preventDefault();
+ this.moveFocusToPreviousElement(input);
+ });
+
+ input.addEventListener("input", event => {
+ addressInputOnInput(event, false);
+ });
+ }
+
+ // Force the focus on the first available input field if Tab is
+ // pressed on the extraAddressRowsMenuButton label.
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .addEventListener("keypress", event => {
+ if (event.key == "Tab" && !event.shiftKey) {
+ event.preventDefault();
+ let row = this.querySelector(".address-row:not(.hidden)");
+ let removeFieldButton = row.querySelector(".remove-field-button");
+ // If the close button is hidden, focus on the input field.
+ if (removeFieldButton.hidden) {
+ row.querySelector(".address-row-input").focus();
+ return;
+ }
+ // Focus on the close button.
+ removeFieldButton.focus();
+ }
+ });
+
+ this.addEventListener("dragstart", event => {
+ // Check if we're dragging a pill, as the drag target might be another
+ // element like row or pill <input> when dragging selected plain text.
+ let targetPill = event.target.closest(
+ "mail-address-pill:not(.editing)"
+ );
+ if (!targetPill) {
+ return;
+ }
+ if (!targetPill.hasAttribute("selected")) {
+ // If the drag action starts from a non-selected pill,
+ // deselect all selected pills and select only the target pill.
+ for (let pill of this.getAllSelectedPills()) {
+ pill.removeAttribute("selected");
+ }
+ targetPill.toggleAttribute("selected");
+ }
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.dropEffect = "move";
+ event.dataTransfer.setData("text/pills", "pills");
+ event.dataTransfer.setDragImage(targetPill, 50, 12);
+ });
+
+ this.addEventListener("dragover", event => {
+ event.preventDefault();
+ });
+
+ this.addEventListener("dragenter", event => {
+ if (!event.dataTransfer.getData("text/pills")) {
+ return;
+ }
+
+ // If the current drop target is a pill, add drop indicator style to it.
+ event.target
+ .closest("mail-address-pill")
+ ?.classList.add("drop-indicator");
+
+ // If the current drop target is inside an address row, add the
+ // indicator style for the row's address container.
+ event.target
+ .closest(".address-row")
+ ?.querySelector(".address-container")
+ .classList.add("drag-address-container");
+ });
+
+ this.addEventListener("dragleave", event => {
+ if (!event.dataTransfer.getData("text/pills")) {
+ return;
+ }
+ // If dragleave from pill, remove its drop indicator style.
+ event.target
+ .closest("mail-address-pill")
+ ?.classList.remove("drop-indicator");
+
+ // If dragleave from address row, remove the indicator style of its
+ // address container.
+ event.target
+ .closest(".address-row")
+ ?.querySelector(".address-container")
+ .classList.remove("drag-address-container");
+ });
+
+ this.addEventListener("drop", event => {
+ // First handle cases where the dropped data is not pills.
+ if (!event.dataTransfer.getData("text/pills")) {
+ // Bail out if the dropped data comes from the contacts sidebar.
+ // Those addresses will be added immediately as pills without going
+ // through the input field as plain text.
+ if (event.dataTransfer.types.includes("moz/abcard")) {
+ return;
+ }
+
+ // Dropped data should be plain text (images are handled elsewhere).
+ // We currently only support dropping text directly into the row input
+ // (Bug 1706187), which is inbuilt: no further handling required here.
+ // Input element resizing is automatically handled by its input event.
+ return;
+ }
+
+ // Pills have been dropped ("text/pills").
+ let targetAddressRow = event.target.closest(".address-row");
+ // Return if pills have been dropped outside an address row.
+ if (
+ !targetAddressRow ||
+ targetAddressRow.classList.contains("address-row-raw")
+ ) {
+ return;
+ }
+
+ // Pills have been dropped somewhere inside an address row.
+ // If they have been dropped directly on an address container, use that.
+ // Otherwise ensure having an addressContainer for drop targets inside
+ // the row, but outside the address container (e.g. the row label).
+ let targetAddressContainer = event.target.closest(".address-container");
+ let addressContainer =
+ targetAddressContainer ||
+ targetAddressRow.querySelector(".address-container");
+
+ // Recreate pills in the target address container.
+ // If dropped on a pill, append pills before that pill. Otherwise if
+ // dropped into an address container, append pills after existing pills.
+ // Otherwise if dropped elsewhere on the row (e.g. on the row label),
+ // append pills before existing pills.
+ let targetPill = event.target.closest("mail-address-pill");
+ this.createDNDPills(
+ addressContainer,
+ targetPill || !targetAddressContainer,
+ targetPill ? targetPill.fullAddress : null
+ );
+ addressContainer.classList.remove("drag-address-container");
+ });
+ }
+
+ /**
+ * Check if the current size of the recipient input field doesn't exceed its
+ * container width. This might happen if the user pastes a very long string
+ * with multiple addresses when pills are already present.
+ *
+ * @param {Element} input - The HTML input field.
+ * @param {integer} length - The amount of characters in the input field.
+ */
+ resizeInputField(input, length) {
+ // Set a minimum size of 1 in case no characters were written in the field
+ // in order to force the smallest size possible and avoid blank rows when
+ // multiple pills fill the entire recipient row.
+ input.setAttribute("size", length || 1);
+
+ // If the previously set size causes the input field to grow beyond 80% of
+ // its parent container, we remove the size attribute to let the CSS flex
+ // attribute let it grow naturally to fill the available space.
+ if (
+ input.clientWidth >
+ input.closest(".address-container").clientWidth * 0.8
+ ) {
+ input.removeAttribute("size");
+ }
+ }
+
+ /**
+ * Move the dragged pills to another address row.
+ *
+ * @param {string} addressContainer - The address container on which pills
+ * have been dropped.
+ * @param {boolean} [appendStart] - If the selected addresses should be
+ * appended at the start or at the end of existing addresses.
+ * Specifying targetAddress will override this.
+ * @param {string} [targetAddress] - The existing address before which all
+ * selected addresses should be appended.
+ */
+ createDNDPills(addressContainer, appendStart, targetAddress) {
+ let existingPills =
+ addressContainer.querySelectorAll("mail-address-pill");
+ let existingAddresses = [...existingPills].map(pill => pill.fullAddress);
+ let selectedAddresses = [...this.getAllSelectedPills()].map(
+ pill => pill.fullAddress
+ );
+ let originalTargetIndex = existingAddresses.indexOf(targetAddress);
+
+ // Remove all the duplicate existing addresses.
+ for (let address of selectedAddresses) {
+ let index = existingAddresses.indexOf(address);
+ if (index > -1) {
+ existingAddresses.splice(index, 1);
+ }
+ }
+
+ let combinedAddresses;
+ // If selected pills have been dropped on another pill, they should be
+ // inserted before that pill, otherwise use appendStart.
+ if (targetAddress) {
+ // Merge the two arrays in the right order. If the target address has
+ // been removed by deduplication above, use its original index.
+ existingAddresses.splice(
+ existingAddresses.includes(targetAddress)
+ ? existingAddresses.indexOf(targetAddress)
+ : originalTargetIndex,
+ 0,
+ ...selectedAddresses
+ );
+ combinedAddresses = existingAddresses;
+ } else {
+ combinedAddresses = appendStart
+ ? selectedAddresses.concat(existingAddresses)
+ : existingAddresses.concat(selectedAddresses);
+ }
+
+ // Remove all selected pills.
+ for (let pill of this.getAllSelectedPills()) {
+ pill.remove();
+ }
+
+ // Existing pills are removed before creating new ones in the right order.
+ for (let pill of existingPills) {
+ pill.remove();
+ }
+
+ // Create pills for all the combined addresses.
+ let row = addressContainer.closest(".address-row");
+ for (let address of combinedAddresses) {
+ addressRowAddRecipientsArray(
+ row,
+ [address],
+ selectedAddresses.includes(address)
+ );
+ }
+
+ // Move the focus to the first selected pill.
+ this.getAllSelectedPills()[0].focus();
+ }
+
+ /**
+ * Create a new address row and a menuitem for revealing it.
+ *
+ * @param {object} recipient - An object for various element attributes.
+ * @param {boolean} rawInput - A flag to disable pills and autocompletion.
+ * @returns {object} - The newly created elements.
+ * @property {Element} row - The address row.
+ * @property {Element} showRowMenuItem - The menu item that shows the row.
+ */
+ // NOTE: This is currently never called with rawInput = false, so it may be
+ // out of date if used.
+ buildRecipientRow(recipient, rawInput = false) {
+ let row = document.createXULElement("hbox");
+ row.setAttribute("id", recipient.rowId);
+ row.classList.add("address-row");
+ row.dataset.recipienttype = recipient.type;
+
+ let firstCol = document.createXULElement("hbox");
+ firstCol.classList.add("aw-firstColBox");
+
+ row.classList.add("hidden");
+
+ let closeButton = document.createElement("button");
+ closeButton.classList.add("remove-field-button", "plain-button");
+ document.l10n.setAttributes(closeButton, "remove-address-row-button", {
+ type: recipient.type,
+ });
+ let closeIcon = document.createElement("img");
+ closeIcon.setAttribute("src", "chrome://global/skin/icons/close.svg");
+ // Button's title is the accessible name.
+ closeIcon.setAttribute("alt", "");
+ closeButton.appendChild(closeIcon);
+
+ closeButton.addEventListener("click", event => {
+ closeLabelOnClick(event);
+ });
+ firstCol.appendChild(closeButton);
+ row.appendChild(firstCol);
+
+ let labelContainer = document.createXULElement("hbox");
+ labelContainer.setAttribute("align", "top");
+ labelContainer.setAttribute("pack", "end");
+ labelContainer.setAttribute("flex", 1);
+ labelContainer.classList.add("address-label-container");
+ labelContainer.setAttribute(
+ "style",
+ getComposeBundle().getString("headersSpaceStyle")
+ );
+
+ let label = document.createXULElement("label");
+ label.setAttribute("id", recipient.labelId);
+ label.setAttribute("value", recipient.type);
+ label.setAttribute("control", recipient.inputId);
+ label.setAttribute("flex", 1);
+ label.setAttribute("crop", "end");
+ labelContainer.appendChild(label);
+ row.appendChild(labelContainer);
+
+ let inputContainer = document.createXULElement("hbox");
+ inputContainer.setAttribute("id", recipient.containerId);
+ inputContainer.setAttribute("flex", 1);
+ inputContainer.setAttribute("align", "center");
+ inputContainer.classList.add(
+ "input-container",
+ "wrap-container",
+ "address-container"
+ );
+ inputContainer.addEventListener("click", focusAddressInputOnClick);
+
+ // Set up the row input for the row.
+ let input = document.createElement(
+ "input",
+ rawInput
+ ? undefined
+ : {
+ is: "autocomplete-input",
+ }
+ );
+ input.setAttribute("id", recipient.inputId);
+ input.setAttribute("size", 1);
+ input.setAttribute("type", "text");
+ input.setAttribute("disableonsend", true);
+ input.classList.add("plain", "address-input", "address-row-input");
+
+ if (!rawInput) {
+ // Regular autocomplete address input, not other header with raw input.
+ // Set various attributes for autocomplete.
+ input.setAttribute("autocompletesearch", "mydomain addrbook ldap news");
+ input.setAttribute("autocompletesearchparam", "{}");
+ input.setAttribute("timeout", 200);
+ input.setAttribute("maxrows", 6);
+ input.setAttribute("completedefaultindex", true);
+ input.setAttribute("forcecomplete", true);
+ input.setAttribute("completeselectedindex", true);
+ input.setAttribute("minresultsforpopup", 2);
+ input.setAttribute("ignoreblurwhilesearching", true);
+ // Disable the inbuilt autocomplete on blur as we handle it below.
+ input._dontBlur = true;
+
+ setupAutocompleteInput(input);
+
+ // Handle keydown event in autocomplete address input of row with pills.
+ // input.onBeforeHandleKeyDown() gets called by the toolkit autocomplete
+ // before going into autocompletion.
+ input.onBeforeHandleKeyDown = event => {
+ addressInputOnBeforeHandleKeyDown(event);
+ };
+ } else {
+ // Handle keydown event in other header input (rawInput), which does not
+ // have autocomplete and its associated keydown handling.
+ row.classList.add("address-row-raw");
+ input.addEventListener("keydown", otherHeaderInputOnKeyDown);
+ input.addEventListener("input", event => {
+ addressInputOnInput(event, true);
+ });
+ }
+
+ input.addEventListener("blur", () => {
+ addressInputOnBlur(input);
+ });
+ input.addEventListener("focus", () => {
+ addressInputOnFocus(input);
+ });
+
+ inputContainer.appendChild(input);
+ row.appendChild(inputContainer);
+
+ // Create the menuitem that shows the row on selection.
+ let showRowMenuItem = document.createXULElement("menuitem");
+ showRowMenuItem.classList.add("subviewbutton", "menuitem-iconic");
+ showRowMenuItem.setAttribute("id", recipient.showRowMenuItemId);
+ showRowMenuItem.setAttribute("disableonsend", true);
+ showRowMenuItem.setAttribute("label", recipient.type);
+
+ showRowMenuItem.addEventListener("command", () =>
+ showAndFocusAddressRow(row.id)
+ );
+
+ row.dataset.showSelfMenuitem = showRowMenuItem.id;
+
+ return { row, showRowMenuItem };
+ }
+
+ /**
+ * Create a new recipient pill.
+ *
+ * @param {HTMLElement} element - The original autocomplete input that
+ * generated the pill.
+ * @param {Array} address - The array containing the recipient's info.
+ * @returns {Element} The newly created pill.
+ */
+ createRecipientPill(element, address) {
+ let pill = document.createXULElement("mail-address-pill");
+
+ pill.label = address.toString();
+ pill.emailAddress = address.email || "";
+ pill.fullAddress = address.toString();
+ pill.displayName = address.name || "";
+
+ pill.addEventListener("click", event => {
+ if (pill.hasAttribute("disabled")) {
+ return;
+ }
+ // Remove pills on middle mouse button click, but not with selection
+ // modifier keys.
+ if (
+ event.button == 1 &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey
+ ) {
+ if (!pill.hasAttribute("selected")) {
+ this.deselectAllPills();
+ pill.setAttribute("selected", "selected");
+ }
+ this.removeSelectedPills();
+ return;
+ }
+
+ // Edit pill on unmodified single left-click on single selected pill,
+ // which also fires for unmodified double-click ("dblclick") on a pill.
+ if (
+ event.button == 0 &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey &&
+ !pill.isEditing &&
+ pill.hasAttribute("selected") &&
+ this.getAllSelectedPills().length == 1
+ ) {
+ this.startEditing(pill, event);
+ return;
+ }
+
+ // Handle selection, especially with Ctrl/Cmd and/or Shift modifiers.
+ this.checkSelected(pill, event);
+ });
+
+ pill.addEventListener("keydown", event => {
+ if (!pill.isEditing || pill.hasAttribute("disabled")) {
+ return;
+ }
+ this.handleKeyDown(pill, event);
+ });
+
+ pill.addEventListener("keypress", event => {
+ if (pill.hasAttribute("disabled")) {
+ return;
+ }
+ this.handleKeyPress(pill, event);
+ });
+
+ element.closest(".address-container").insertBefore(pill, element);
+
+ // The emailInput attribute is accessible only after the pill has been
+ // appended to the DOM.
+ let excludedClasses = [
+ "mail-primary-input",
+ "news-primary-input",
+ "address-row-input",
+ ];
+ for (let cssClass of element.classList) {
+ if (excludedClasses.includes(cssClass)) {
+ continue;
+ }
+ pill.emailInput.classList.add(cssClass);
+ }
+ pill.emailInput.setAttribute(
+ "aria-labelledby",
+ element.getAttribute("aria-labelledby")
+ );
+ element.removeAttribute("aria-labelledby");
+
+ let params = JSON.parse(
+ pill.emailInput.getAttribute("autocompletesearchparam")
+ );
+ params.type = element.closest(".address-row").dataset.recipienttype;
+ pill.emailInput.setAttribute(
+ "autocompletesearchparam",
+ JSON.stringify(params)
+ );
+
+ pill.updatePillStatus();
+
+ return pill;
+ }
+
+ /**
+ * Handle keydown event on a pill in the mail-recipients-area.
+ *
+ * @param {Element} pill - The mail-address-pill element where Event fired.
+ * @param {Event} event - The DOM Event.
+ */
+ handleKeyDown(pill, event) {
+ switch (event.key) {
+ case " ":
+ case ",":
+ // Behaviour consistent with row input:
+ // If keydown would normally replace all of the current trimmed input,
+ // including if the current input is empty, then suppress the key and
+ // clear the input instead.
+ let input = pill.emailInput;
+ let selection = input.value.substring(
+ input.selectionStart,
+ input.selectionEnd
+ );
+ if (selection.includes(input.value.trim())) {
+ event.preventDefault();
+ input.value = "";
+ }
+ break;
+ }
+ }
+
+ /**
+ * Handle keypress event on a pill in the mail-recipients-area.
+ *
+ * @param {Element} pill - The mail-address-pill element where Event fired.
+ * @param {Event} event - The DOM Event.
+ */
+ handleKeyPress(pill, event) {
+ if (pill.isEditing) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Enter":
+ case "F2": // For Windows users
+ this.startEditing(pill, event);
+ break;
+
+ case "Delete":
+ case "Backspace":
+ // We must never delete a focused pill which is not selected.
+ // If no pills selected, just select the focused pill.
+ // For rapid repeated deletions (esp. from holding BACKSPACE),
+ // stop before selecting another focused pill for deletion.
+ if (!this.hasSelectedPills() && !event.repeat) {
+ pill.setAttribute("selected", "selected");
+ break;
+ }
+ // Delete selected pills, handle focus and select another pill
+ // where applicable.
+ let focusType = event.key == "Delete" ? "next" : "previous";
+ this.removeSelectedPills(focusType, true);
+ break;
+
+ case "ArrowLeft":
+ if (pill.previousElementSibling) {
+ this.checkKeyboardSelected(event, pill.previousElementSibling);
+ }
+ break;
+
+ case "ArrowRight":
+ this.checkKeyboardSelected(event, pill.nextElementSibling);
+ break;
+
+ case " ":
+ this.checkSelected(pill, event);
+ break;
+
+ case "Home":
+ let firstPill = pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill");
+ if (!event.ctrlKey) {
+ // Unmodified navigation: select only first pill and focus it below.
+ // ### Todo: We can't handle Shift+Home yet, so it ends up here.
+ this.deselectAllPills();
+ firstPill.setAttribute("selected", "selected");
+ }
+ firstPill.focus();
+ break;
+
+ case "End":
+ if (!event.ctrlKey) {
+ // Unmodified navigation: focus row input.
+ // ### Todo: We can't handle Shift+End yet, so it ends up here.
+ pill.rowInput.focus();
+ break;
+ }
+ // Navigation with Ctrl modifier key: focus last pill.
+ pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill:last-of-type")
+ .focus();
+ break;
+
+ case "Tab":
+ for (let item of this.getSiblingPills(pill)) {
+ item.removeAttribute("selected");
+ }
+ // Ctrl+Tab is handled by moveFocusToNeighbouringArea.
+ if (event.ctrlKey) {
+ return;
+ }
+ event.preventDefault();
+ if (event.shiftKey) {
+ this.moveFocusToPreviousElement(pill);
+ return;
+ }
+ pill.rowInput.focus();
+ break;
+
+ case "a":
+ if (
+ !(event.ctrlKey || event.metaKey) ||
+ event.repeat ||
+ event.shiftKey
+ ) {
+ // Bail out if it's not Ctrl+A or Cmd+A, if the Shift key is
+ // pressed, or if repeated keypress.
+ break;
+ }
+ if (
+ pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill:not([selected])")
+ ) {
+ // For non-repeated Ctrl+A, if there's at least one unselected pill,
+ // first select all pills of the same .address-container.
+ this.selectSiblingPills(pill);
+ break;
+ }
+ // For non-repeated Ctrl+A, if pills in same container are already
+ // selected, select all pills of the entire <mail-recipients-area>.
+ this.selectAllPills();
+ break;
+
+ case "c":
+ if (event.ctrlKey || event.metaKey) {
+ this.copySelectedPills();
+ }
+ break;
+
+ case "x":
+ if (event.ctrlKey || event.metaKey) {
+ this.cutSelectedPills();
+ }
+ break;
+ }
+ }
+
+ /**
+ * Handle the selection and focus of recipient pill elements on mouse click
+ * and spacebar keypress events.
+ *
+ * @param {HTMLElement} pill - The <mail-address-pill> element, event target.
+ * @param {Event} event - A DOM click or keypress Event.
+ */
+ checkSelected(pill, event) {
+ // Interrupt if the pill is in edit mode or a right click was detected.
+ // Selecting pills on right click will be handled by the opening of the
+ // context menu.
+ if (pill.isEditing || event.button == 2) {
+ return;
+ }
+
+ if (!event.ctrlKey && !event.metaKey && event.key != " ") {
+ this.deselectAllPills();
+ }
+
+ pill.toggleAttribute("selected");
+
+ // We need to force the focus on a pill that receives a click event
+ // (or a spacebar keypress), as macOS doesn't automatically move the focus
+ // on this custom element (bug 1645643, bug 1645916).
+ pill.focus();
+ }
+
+ /**
+ * Handle the selection and focus of the pill elements on keyboard
+ * navigation.
+ *
+ * @param {Event} event - A DOM keyboard event.
+ * @param {HTMLElement} targetElement - A mail-address-pill or address input
+ * element navigated to.
+ */
+ checkKeyboardSelected(event, targetElement) {
+ let sourcePill =
+ event.target.tagName == "mail-address-pill" ? event.target : null;
+ let targetPill =
+ targetElement.tagName == "mail-address-pill" ? targetElement : null;
+
+ if (event.shiftKey) {
+ if (sourcePill) {
+ sourcePill.setAttribute("selected", "selected");
+ }
+ if (event.key == "Home" && !sourcePill) {
+ // Shift+Home from address input.
+ this.selectSiblingPills(targetPill);
+ }
+ if (targetPill) {
+ targetPill.setAttribute("selected", "selected");
+ }
+ } else if (!event.ctrlKey) {
+ // Non-modified navigation keys must select the target pill and deselect
+ // all others. Also some other keys like Backspace from rowInput.
+ this.deselectAllPills();
+ if (targetPill) {
+ targetPill.setAttribute("selected", "selected");
+ } else {
+ // Focus the input navigated to.
+ targetElement.focus();
+ }
+ }
+
+ // If targetElement is a pill, focus it.
+ if (targetPill) {
+ targetPill.focus();
+ }
+ }
+
+ /**
+ * Trigger the pill.startEditing() method.
+ *
+ * @param {XULElement} pill - The mail-address-pill element.
+ * @param {Event} event - The DOM Event.
+ */
+ startEditing(pill, event) {
+ if (pill.isEditing) {
+ event.stopPropagation();
+ return;
+ }
+
+ pill.startEditing();
+ }
+
+ /**
+ * Copy the selected pills to clipboard.
+ */
+ copySelectedPills() {
+ let selectedAddresses = [
+ ...document.getElementById("recipientsContainer").getAllSelectedPills(),
+ ].map(pill => pill.fullAddress);
+
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(selectedAddresses.join(", "));
+ }
+
+ /**
+ * Cut the selected pills to clipboard.
+ */
+ cutSelectedPills() {
+ this.copySelectedPills();
+ this.removeSelectedPills();
+ }
+
+ /**
+ * Move the selected email address pills to another address row.
+ *
+ * @param {Element} row - The address row to move the pills to.
+ */
+ moveSelectedPills(row) {
+ // Store all the selected addresses inside an array.
+ let selectedAddresses = [...this.getAllSelectedPills()].map(
+ pill => pill.fullAddress
+ );
+
+ // Return if no pills selected.
+ if (!selectedAddresses.length) {
+ return;
+ }
+
+ // Remove the selected pills.
+ this.removeSelectedPills("next", false, true);
+
+ // Create new address pills inside the target address row and
+ // maintain the current selection.
+ addressRowAddRecipientsArray(row, selectedAddresses, true);
+
+ // Move focus to the last selected pill.
+ let selectedPills = this.getAllSelectedPills();
+ selectedPills[selectedPills.length - 1].focus();
+ }
+
+ /**
+ * Delete all selected pills and handle focus and selection smartly as needed.
+ *
+ * @param {("next"|"previous")} [focusType="next"] - How to move focus after
+ * removing pills: try to focus one of the next siblings (for DEL etc.)
+ * or one of the previous siblings (for BACKSPACE).
+ * @param {boolean} [select=false] - After deletion, whether to select the
+ * focused pill where applicable.
+ * @param {boolean} [moved=false] - Whether the method was originally called
+ * from moveSelectedPills().
+ */
+ removeSelectedPills(focusType = "next", select = false, moved = false) {
+ // Return if no pills selected.
+ let firstSelectedPill = this.querySelector("mail-address-pill[selected]");
+ if (!firstSelectedPill) {
+ return;
+ }
+ // Get the pill which has focus before we start removing selected pills,
+ // which may or may not include the focused pill. If no pill has focus,
+ // consider the first selected pill as focused pill for our purposes.
+ let pill =
+ this.querySelector("mail-address-pill:focus") || firstSelectedPill;
+
+ // We'll look hard for an appropriate element to focus after the removal.
+ let focusElement = null;
+ // Get addressContainer and rowInput now as pill might be deleted later.
+ let addressContainer = pill.closest(".address-container");
+ let rowInput = pill.rowInput;
+ let unselectedSourcePill = false;
+
+ if (pill.hasAttribute("selected")) {
+ // Find focus (1): Focused pill is selected and will be deleted;
+ // try nearest sibling, observing focusType direction.
+ focusElement = pill.getUnselectedSiblingPill(focusType);
+ } else {
+ // The source pill isn't selected; keep it focused ("satellite focus").
+ unselectedSourcePill = true;
+ focusElement = pill;
+ }
+
+ // Remove selected pills.
+ let selectedPills = this.getAllSelectedPills();
+ for (let sPill of selectedPills) {
+ sPill.remove();
+ }
+
+ // Find focus (2): When deleting backwards, if no previous sibling found,
+ // this means that the first pill was deleted. Try the first remaining pill,
+ // but don't auto-select it because it's in the opposite direction.
+ if (!focusElement && focusType == "previous") {
+ focusElement = addressContainer.querySelector("mail-address-pill");
+ } else if (
+ select &&
+ focusElement &&
+ selectedPills.length == 1 &&
+ !unselectedSourcePill
+ ) {
+ // If select = true (DEL or BACKSPACE), and we found a pill to focus in
+ // round (1), and we have removed a single pill only, and it's not a
+ // case of "satellite focus" (see above):
+ // Conveniently select the nearest pill for rapid consecutive deletions.
+ focusElement.setAttribute("selected", "selected");
+ }
+ // Find focus (3): If all else fails (no pills left in addressContainer,
+ // or last pill deleted forwards): Focus rowInput.
+ if (!focusElement) {
+ focusElement = rowInput;
+ }
+ focusElement.focus();
+
+ // Update aria labels for all rows as we allow cross-row pill removal.
+ // This may not yet be micro-performance optimized; see bug 1671261.
+ updateAriaLabelsAndTooltipsOfAllAddressRows();
+
+ // Don't trigger some methods if the pills were removed automatically
+ // during the move to another addressing widget.
+ if (!moved) {
+ onRecipientsChanged();
+ }
+ }
+
+ /**
+ * Select all pills of the same address row (.address-container).
+ *
+ * @param {Element} pill - A <mail-address-pill> element. All pills in the
+ * same .address-container will be selected.
+ */
+ selectSiblingPills(pill) {
+ for (let sPill of this.getSiblingPills(pill)) {
+ sPill.setAttribute("selected", "selected");
+ }
+ }
+
+ /**
+ * Select all pills of the <mail-recipients-area> element.
+ */
+ selectAllPills() {
+ for (let pill of this.getAllPills()) {
+ pill.setAttribute("selected", "selected");
+ }
+ }
+
+ /**
+ * Deselect all the pills of the <mail-recipients-area> element.
+ */
+ deselectAllPills() {
+ for (let pill of this.querySelectorAll(`mail-address-pill[selected]`)) {
+ pill.removeAttribute("selected");
+ }
+ }
+
+ /**
+ * Return all pills of the same address row (.address-container).
+ *
+ * @param {Element} pill - A <mail-address-pill> element. All pills in the
+ * same .address-container will be returned.
+ * @returns {NodeList} NodeList of <mail-address-pill> elements in same field.
+ */
+ getSiblingPills(pill) {
+ return pill
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill");
+ }
+
+ /**
+ * Return all pills of the <mail-recipients-area> element.
+ *
+ * @returns {NodeList} NodeList of all <mail-address-pill> elements.
+ */
+ getAllPills() {
+ return this.querySelectorAll("mail-address-pill");
+ }
+
+ /**
+ * Return all currently selected pills in the <mail-recipients-area>.
+ *
+ * @returns {NodeList} NodeList of all selected <mail-address-pill> elements.
+ */
+ getAllSelectedPills() {
+ return this.querySelectorAll("mail-address-pill[selected]");
+ }
+
+ /**
+ * Check if any pill in the <mail-recipients-area> is selected.
+ *
+ * @returns {boolean} true if any pill is selected.
+ */
+ hasSelectedPills() {
+ return Boolean(this.querySelector("mail-address-pill[selected]"));
+ }
+
+ /**
+ * Move the focus to the previous focusable element.
+ *
+ * @param {Element} element - The element where the event was triggered.
+ */
+ moveFocusToPreviousElement(element) {
+ let row = element.closest(".address-row");
+ // Move focus on the close label if not collapsed.
+ if (!row.querySelector(".remove-field-button").hidden) {
+ row.querySelector(".remove-field-button").focus();
+ return;
+ }
+ // If a previous address row is available and not hidden,
+ // focus on the autocomplete input field.
+ let previousRow = row.previousElementSibling;
+ while (previousRow) {
+ if (!previousRow.classList.contains("hidden")) {
+ previousRow.querySelector(".address-row-input").focus();
+ return;
+ }
+ previousRow = previousRow.previousElementSibling;
+ }
+ // Move the focus on the previous button: either the
+ // extraAddressRowsMenuButton, or one of "<type>ShowAddressRowButton".
+ let buttons = document.querySelectorAll(
+ "#extraAddressRowsArea button:not([hidden])"
+ );
+ if (buttons.length) {
+ // Select the last available label.
+ buttons[buttons.length - 1].focus();
+ return;
+ }
+ // Move the focus on the msgIdentity if no extra recipients are available.
+ document.getElementById("msgIdentity").focus();
+ }
+ }
+
+ customElements.define("mail-recipients-area", MailRecipientsArea);
+}
diff --git a/comm/mail/base/content/widgets/pane-splitter.js b/comm/mail/base/content/widgets/pane-splitter.js
new file mode 100644
index 0000000000..d201f3286f
--- /dev/null
+++ b/comm/mail/base/content/widgets/pane-splitter.js
@@ -0,0 +1,562 @@
+/* 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/. */
+
+{
+ /**
+ * A widget for adjusting the size of its {@link PaneSplitter#resizeElement}.
+ * By default, the splitter will resize the height of the resizeElement, but
+ * this can be changed using the "resize-direction" attribute.
+ *
+ * If dragged, the splitter will set a CSS variable on the parent element,
+ * which is named from the id of the element plus "width" or "height" as
+ * appropriate (e.g. --splitter-width). The variable should be used to set the
+ * border-area width or height of the resizeElement.
+ *
+ * Often, you will want to naturally limit the size of the resizeElement to
+ * prevent it exceeding its min or max size bounds, and to remain within the
+ * available space of its container. One way to do this is to use a grid
+ * layout on the container and size the resizeElement's row with
+ * "minmax(auto, --splitter-height)", or similar for the column when adjusting
+ * the width.
+ *
+ * This splitter element fires a "splitter-resizing" event as dragging begins,
+ * and "splitter-resized" when it ends.
+ *
+ * The resizeElement can be collapsed and expanded. Whilst collapsed, the
+ * "collapsed-by-splitter" class will be added to the resizeElement and the
+ * "--<id>-width" or "--<id>-height" CSS variable, will be be set to "0px".
+ * The "splitter-collapsed" and "splitter-expanded" events are fired as
+ * appropriate. If the splitter has a "collapse-width" or "collapse-height"
+ * attribute, collapsing and expanding happens automatically when below the
+ * given size.
+ */
+ class PaneSplitter extends HTMLHRElement {
+ static observedAttributes = ["resize-direction", "resize-id", "id"];
+
+ connectedCallback() {
+ this.addEventListener("mousedown", this);
+ // Try and find the _resizeElement from the resize-id attribute.
+ this._updateResizeElement();
+ this._updateStyling();
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ switch (name) {
+ case "resize-direction":
+ this._updateResizeDirection();
+ break;
+ case "resize-id":
+ this._updateResizeElement();
+ break;
+ case "id":
+ this._updateStyling();
+ break;
+ }
+ }
+
+ /**
+ * The direction the splitter resizes the controlled element. Resizing
+ * horizontally changes its width, whilst resizing vertically changes its
+ * height.
+ *
+ * This corresponds to the "resize-direction" attribute and defaults to
+ * "vertical" when none is given.
+ *
+ * @type {"vertical"|"horizontal"}
+ */
+ get resizeDirection() {
+ return this.getAttribute("resize-direction") ?? "vertical";
+ }
+
+ set resizeDirection(val) {
+ this.setAttribute("resize-direction", val);
+ }
+
+ _updateResizeDirection() {
+ // The resize direction has changed. To be safe, make sure we're no longer
+ // resizing.
+ this.endResize();
+ this._updateStyling();
+ }
+
+ _resizeElement = null;
+
+ /**
+ * The element that is being sized by the splitter. It must have a set id.
+ *
+ * If the "resize-id" attribute is set, it will be used to choose this
+ * element by its id.
+ *
+ * @type {?HTMLElement}
+ */
+ get resizeElement() {
+ // Make sure the resizeElement is up to date.
+ this._updateResizeElement();
+ return this._resizeElement;
+ }
+
+ set resizeElement(element) {
+ if (!element?.id) {
+ element = null;
+ }
+ this._updateResizeElement(element);
+ // Set the resize-id attribute.
+ // NOTE: This will trigger a second call to _updateResizeElement, but it
+ // should end early because the resize-id matches the just set
+ // _resizeElement.
+ if (element) {
+ this.setAttribute("resize-id", element.id);
+ } else {
+ this.removeAttribute("resize-id");
+ }
+ }
+
+ /**
+ * Update the _resizeElement property.
+ *
+ * @param {?HTMLElement} [element] - The resizeElement to set, or leave
+ * undefined to use the resize-id attribute to find the element.
+ */
+ _updateResizeElement(element) {
+ if (element == undefined) {
+ // Use the resize-id to find the element.
+ let resizeId = this.getAttribute("resize-id");
+ if (resizeId) {
+ if (this._resizeElement?.id == resizeId) {
+ // Avoid looking up the element since we already have it.
+ return;
+ }
+ // Try and find the element.
+ // NOTE: If we don't find the element now, then we still keep the same
+ // resize-id attribute and we'll try again the next time this method
+ // is called.
+ element = this.ownerDocument.getElementById(resizeId);
+ } else {
+ element = null;
+ }
+ }
+ if (element == this._resizeElement) {
+ return;
+ }
+
+ // Make sure we stop resizing the current _resizeElement.
+ this.endResize();
+ if (this._resizeElement) {
+ // Clean up previous element.
+ this._resizeElement.classList.remove("collapsed-by-splitter");
+ }
+ this._resizeElement = element;
+ this._beforeElement =
+ element &&
+ !!(
+ this.compareDocumentPosition(element) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ );
+ // Are we already collapsed?
+ this._isCollapsed = this._resizeElement?.classList.contains(
+ "collapsed-by-splitter"
+ );
+ this._updateStyling();
+ }
+
+ _width = null;
+
+ /**
+ * The desired width of the resizeElement. This is used to set the
+ * --<id>-width CSS variable on the parent when the resizeDirection is
+ * "horizontal" and the resizeElement is not collapsed. If its value is
+ * null, the same CSS variable is removed from the parent instead.
+ *
+ * Note, this value is persistent across collapse states, so the width
+ * before collapsing can be returned to on expansion.
+ *
+ * Use this value in persistent storage.
+ *
+ * @type {?number}
+ */
+ get width() {
+ return this._width;
+ }
+
+ set width(width) {
+ if (width == this._width) {
+ return;
+ }
+ this._width = width;
+ this._updateStyling();
+ }
+
+ _height = null;
+
+ /**
+ * The desired height of the resizeElement. This is used to set the
+ * -<id>-height CSS variable on the parent when the resizeDirection is
+ * "vertical" and the resizeElement is not collapsed. If its value is null,
+ * the same CSS variable is removed from the parent instead.
+ *
+ * Note, this value is persistent across collapse states, so the height
+ * before collapsing can be returned to on expansion.
+ *
+ * Use this value in persistent storage.
+ *
+ * @type {?number}
+ */
+ get height() {
+ return this._height;
+ }
+
+ set height(height) {
+ if (height == this._height) {
+ return;
+ }
+ this._height = height;
+ this._updateStyling();
+ }
+
+ /**
+ * Update the width or height of the splitter, depending on its
+ * resizeDirection.
+ *
+ * If a trySize is given, the width or height of the splitter will be set to
+ * the given value, before being set to the actual size of the
+ * resizeElement. This acts as an automatic bounding process, without
+ * knowing the details of the layout and its constraints.
+ *
+ * If no trySize is given, then the width and height will be set to the
+ * actual size of the resizeElement.
+ *
+ * @param {?number} [trySize] - The size to try and achieve.
+ */
+ _updateSize(trySize) {
+ let vertical = this.resizeDirection == "vertical";
+ if (trySize != undefined) {
+ if (vertical) {
+ this.height = trySize;
+ } else {
+ this.width = trySize;
+ }
+ }
+ // Now that the width and height are updated, we fetch the size the
+ // element actually took.
+ let actual = this._getActualResizeSize();
+ if (vertical) {
+ this.height = actual;
+ } else {
+ this.width = actual;
+ }
+ }
+
+ /**
+ * Get the actual size of the resizeElement, regardless of the current
+ * width or height property values. This causes a reflow, and it gets
+ * called on every mousemove event while dragging, so it's very expensive
+ * but practically unavoidable.
+ *
+ * @returns {number} - The border area size of the resizeElement.
+ */
+ _getActualResizeSize() {
+ let resizeRect = this.resizeElement.getBoundingClientRect();
+ if (this.resizeDirection == "vertical") {
+ return resizeRect.height;
+ }
+ return resizeRect.width;
+ }
+
+ /**
+ * Collapses the controlled pane. A collapsed pane does not affect the
+ * `width` or `height` properties. Fires a "splitter-collapsed" event.
+ */
+ collapse() {
+ if (this._isCollapsed) {
+ return;
+ }
+ this._isCollapsed = true;
+ this._updateStyling();
+ this._updateDragCursor();
+ this.dispatchEvent(
+ new CustomEvent("splitter-collapsed", { bubbles: true })
+ );
+ }
+
+ /**
+ * Expands the controlled pane. It returns to the width or height it had
+ * when collapsed. Fires a "splitter-expanded" event.
+ */
+ expand() {
+ if (!this._isCollapsed) {
+ return;
+ }
+ this._isCollapsed = false;
+ this._updateStyling();
+ this._updateDragCursor();
+ this.dispatchEvent(
+ new CustomEvent("splitter-expanded", { bubbles: true })
+ );
+ }
+
+ _isCollapsed = false;
+
+ /**
+ * If the controlled pane is collapsed.
+ *
+ * @type {boolean}
+ */
+ get isCollapsed() {
+ return this._isCollapsed;
+ }
+
+ set isCollapsed(collapsed) {
+ if (collapsed) {
+ this.collapse();
+ } else {
+ this.expand();
+ }
+ }
+
+ /**
+ * Collapse the splitter if it is expanded, or expand it if collapsed.
+ */
+ toggleCollapsed() {
+ this.isCollapsed = !this._isCollapsed;
+ }
+
+ /**
+ * If the splitter is disabled.
+ *
+ * @type {boolean}
+ */
+ get isDisabled() {
+ return this.hasAttribute("disabled");
+ }
+
+ set isDisabled(disabled) {
+ if (disabled) {
+ this.setAttribute("disabled", true);
+ return;
+ }
+ this.removeAttribute("disabled");
+ }
+
+ /**
+ * Update styling to reflect the current state.
+ */
+ _updateStyling() {
+ if (!this.resizeElement || !this.parentNode || !this.id) {
+ // Wait until we have a resizeElement, a parent and an id.
+ return;
+ }
+
+ if (this.id != this._cssName?.basis) {
+ // Clear the old names.
+ if (this._cssName) {
+ this.parentNode.style.removeProperty(this._cssName.width);
+ this.parentNode.style.removeProperty(this._cssName.height);
+ }
+ this._cssName = {
+ basis: this.id,
+ height: `--${this.id}-height`,
+ width: `--${this.id}-width`,
+ };
+ }
+
+ let vertical = this.resizeDirection == "vertical";
+ let height = this.isCollapsed ? 0 : this.height;
+ if (!vertical || height == null) {
+ // If we are resizing horizontally or the "height" property is set to
+ // null, we remove the CSS height variable. The height of the element
+ // is left to be determined by the CSS stylesheet rules.
+ this.parentNode.style.removeProperty(this._cssName.height);
+ } else {
+ this.parentNode.style.setProperty(this._cssName.height, `${height}px`);
+ }
+ let width = this.isCollapsed ? 0 : this.width;
+ if (vertical || width == null) {
+ // If we are resizing vertically or the "width" property is set to
+ // null, we remove the CSS width variable. The width of the element
+ // is left to be determined by the CSS stylesheet rules.
+ this.parentNode.style.removeProperty(this._cssName.width);
+ } else {
+ this.parentNode.style.setProperty(this._cssName.width, `${width}px`);
+ }
+ this.resizeElement.classList.toggle(
+ "collapsed-by-splitter",
+ this.isCollapsed
+ );
+ this.classList.toggle("splitter-collapsed", this.isCollapsed);
+ this.classList.toggle("splitter-before", this._beforeElement);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "mousedown":
+ this._onMouseDown(event);
+ break;
+ case "mousemove":
+ this._onMouseMove(event);
+ break;
+ case "mouseup":
+ this._onMouseUp(event);
+ break;
+ }
+ }
+
+ _onMouseDown(event) {
+ if (!this.resizeElement || this.isDisabled) {
+ return;
+ }
+ if (event.buttons != 1) {
+ return;
+ }
+
+ let vertical = this.resizeDirection == "vertical";
+ let collapseSize =
+ Number(
+ this.getAttribute(vertical ? "collapse-height" : "collapse-width")
+ ) || 0;
+ let ltrDir = this.parentNode.matches(":dir(ltr)");
+
+ this._dragStartInfo = {
+ wasCollapsed: this.isCollapsed,
+ // Whether this will resize vertically.
+ vertical,
+ pos: vertical ? event.clientY : event.clientX,
+ // Whether decreasing X/Y should increase the size.
+ negative: vertical
+ ? this._beforeElement
+ : this._beforeElement == ltrDir,
+ size: this._getActualResizeSize(),
+ collapseSize,
+ };
+
+ event.preventDefault();
+ window.addEventListener("mousemove", this);
+ window.addEventListener("mouseup", this);
+ // Block all other pointer events whilst resizing. This ensures we don't
+ // trigger any styling or other effects whilst resizing. This also ensures
+ // that the MouseEvent's clientX and clientY will always be relative to
+ // the current window, rather than some ancestor xul:browser's window.
+ document.documentElement.style.pointerEvents = "none";
+ this._updateDragCursor();
+ this.classList.add("splitter-resizing");
+ }
+
+ _updateDragCursor() {
+ if (!this._dragStartInfo) {
+ return;
+ }
+ let cursor;
+ let { vertical, negative } = this._dragStartInfo;
+ if (this.isCollapsed) {
+ if (vertical) {
+ cursor = negative ? "n-resize" : "s-resize";
+ } else {
+ cursor = negative ? "w-resize" : "e-resize";
+ }
+ } else {
+ cursor = vertical ? "ns-resize" : "ew-resize";
+ }
+ document.documentElement.style.cursor = cursor;
+ }
+
+ /**
+ * If `mousemove` events will be ignored because the screen hasn't been
+ * updated since the last one.
+ *
+ * @type {boolean}
+ */
+ _mouseMoveBlocked = false;
+
+ _onMouseMove(event) {
+ if (event.buttons != 1) {
+ // The button was released and we didn't get a mouseup event (e.g.
+ // releasing the mouse above a disabled html:button), or the
+ // button(s) pressed changed. Either way, stop dragging.
+ this.endResize();
+ return;
+ }
+
+ event.preventDefault();
+
+ // Ensure the expensive part of this function runs no more than once
+ // per frame. Doing it more frequently is just wasting CPU time.
+ if (this._mouseMoveBlocked) {
+ return;
+ }
+ this._mouseMoveBlocked = true;
+ requestAnimationFrame(() => (this._mouseMoveBlocked = false));
+
+ let { wasCollapsed, vertical, negative, pos, size, collapseSize } =
+ this._dragStartInfo;
+
+ let delta = (vertical ? event.clientY : event.clientX) - pos;
+ if (negative) {
+ delta *= -1;
+ }
+
+ if (!this._started) {
+ if (Math.abs(delta) < 3) {
+ return;
+ }
+ this._started = true;
+ this.dispatchEvent(
+ new CustomEvent("splitter-resizing", { bubbles: true })
+ );
+ }
+
+ size += delta;
+ if (collapseSize) {
+ let pastCollapseThreshold = size < collapseSize - 20;
+ if (wasCollapsed) {
+ if (!pastCollapseThreshold) {
+ this._dragStartInfo.wasCollapsed = false;
+ }
+ pastCollapseThreshold = size < 20;
+ }
+
+ if (pastCollapseThreshold) {
+ this.collapse();
+ return;
+ }
+
+ this.expand();
+ size = Math.max(size, collapseSize);
+ }
+ this._updateSize(Math.max(0, size));
+ }
+
+ _onMouseUp(event) {
+ event.preventDefault();
+ this.endResize();
+ }
+
+ /**
+ * Stop the resizing operation if it is currently active.
+ */
+ endResize() {
+ if (!this._dragStartInfo) {
+ return;
+ }
+ let didStart = this._started;
+
+ delete this._dragStartInfo;
+ delete this._started;
+
+ window.removeEventListener("mousemove", this);
+ window.removeEventListener("mouseup", this);
+ document.documentElement.style.pointerEvents = null;
+ document.documentElement.style.cursor = null;
+ this.classList.remove("splitter-resizing");
+
+ // Make sure our property corresponds to the actual final size.
+ this._updateSize();
+
+ if (didStart) {
+ this.dispatchEvent(
+ new CustomEvent("splitter-resized", { bubbles: true })
+ );
+ }
+ }
+ }
+ customElements.define("pane-splitter", PaneSplitter, { extends: "hr" });
+}
diff --git a/comm/mail/base/content/widgets/statuspanel.js b/comm/mail/base/content/widgets/statuspanel.js
new file mode 100644
index 0000000000..8d30ea4697
--- /dev/null
+++ b/comm/mail/base/content/widgets/statuspanel.js
@@ -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/. */
+
+/* global MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ class MozStatuspanel extends MozXULElement {
+ static get observedAttributes() {
+ return ["label", "mirror"];
+ }
+
+ connectedCallback() {
+ const hbox = document.createXULElement("hbox");
+ hbox.classList.add("statuspanel-inner");
+
+ const label = document.createXULElement("label");
+ label.classList.add("statuspanel-label");
+ label.setAttribute("flex", "1");
+ label.setAttribute("crop", "end");
+
+ hbox.appendChild(label);
+ this.appendChild(hbox);
+
+ this._labelElement = label;
+
+ this._updateAttributes();
+ this._setupEventListeners();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ set label(val) {
+ if (!this.label) {
+ this.removeAttribute("mirror");
+ }
+ this.setAttribute("label", val);
+ }
+
+ get label() {
+ return this.getAttribute("label");
+ }
+
+ _updateAttributes() {
+ if (!this._labelElement) {
+ return;
+ }
+
+ if (this.hasAttribute("label")) {
+ this._labelElement.setAttribute("value", this.getAttribute("label"));
+ } else {
+ this._labelElement.removeAttribute("value");
+ }
+
+ if (this.hasAttribute("mirror")) {
+ this._labelElement.setAttribute("mirror", this.getAttribute("mirror"));
+ } else {
+ this._labelElement.removeAttribute("mirror");
+ }
+ }
+
+ _setupEventListeners() {
+ this.addEventListener("mouseover", event => {
+ if (this.hasAttribute("mirror")) {
+ this.removeAttribute("mirror");
+ } else {
+ this.setAttribute("mirror", "true");
+ }
+ });
+ }
+ }
+
+ customElements.define("statuspanel", MozStatuspanel);
+}
diff --git a/comm/mail/base/content/widgets/tabmail-tab.js b/comm/mail/base/content/widgets/tabmail-tab.js
new file mode 100644
index 0000000000..7de115149b
--- /dev/null
+++ b/comm/mail/base/content/widgets/tabmail-tab.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/. */
+
+"use strict";
+
+/* global MozElements, MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozTabmailTab widget behaves as a tab in the messenger window.
+ * It is used to navigate between different views. It displays information
+ * about the view: i.e. name and icon.
+ *
+ * @augments {MozElements.MozTab}
+ */
+ class MozTabmailTab extends MozElements.MozTab {
+ static get inheritedAttributes() {
+ return {
+ ".tab-background": "pinned,selected,titlechanged",
+ ".tab-line": "selected=visuallyselected",
+ ".tab-content": "pinned,selected,titlechanged,title=label",
+ ".tab-throbber": "fadein,pinned,busy,progress,selected",
+ ".tab-icon-image": "fadein,pinned,selected",
+ ".tab-label-container": "pinned,selected=visuallyselected",
+ ".tab-text": "text=label,accesskey,fadein,pinned,selected",
+ ".tab-close-button": "fadein,pinned,selected=visuallyselected",
+ };
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "tabmail-tab");
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <stack class="tab-stack" flex="1">
+ <vbox class="tab-background">
+ <hbox class="tab-line"></hbox>
+ </vbox>
+ <html:div class="tab-content">
+ <hbox class="tab-throbber" role="presentation"></hbox>
+ <html:img class="tab-icon-image" alt="" role="presentation" />
+ <hbox class="tab-label-container"
+ onoverflow="this.setAttribute('textoverflow', 'true');"
+ onunderflow="this.removeAttribute('textoverflow');"
+ flex="1">
+ <label class="tab-text tab-label" role="presentation"></label>
+ </hbox>
+ <!-- We make the button non-focusable, otherwise each close
+ - button creates a new tab stop. See bug 1754097 -->
+ <html:button class="plain-button tab-close-button"
+ tabindex="-1"
+ title="&closeTab.label;">
+ <!-- Button title should provide the accessible context. -->
+ <html:img class="tab-close-icon" alt=""
+ src="chrome://global/skin/icons/close.svg" />
+ </html:button>
+ </html:div>
+ </stack>
+ `,
+ ["chrome://messenger/locale/tabmail.dtd"]
+ )
+ );
+
+ this.addEventListener(
+ "dragstart",
+ event => {
+ document.dragTab = this;
+ },
+ true
+ );
+
+ this.addEventListener(
+ "dragover",
+ event => {
+ document.dragTab = null;
+ },
+ true
+ );
+
+ let closeButton = this.querySelector(".tab-close-button");
+
+ // Prevent switching to the tab before closing it by stopping the
+ // mousedown event.
+ closeButton.addEventListener("mousedown", event => {
+ if (event.button != 0) {
+ return;
+ }
+ event.stopPropagation();
+ });
+
+ closeButton.addEventListener("click", () =>
+ document.getElementById("tabmail").removeTabByNode(this)
+ );
+
+ // Middle mouse button click on the tab also closes it.
+ this.addEventListener("click", event => {
+ if (event.button != 1) {
+ return;
+ }
+ document.getElementById("tabmail").removeTabByNode(this);
+ });
+
+ this.setAttribute("context", "tabContextMenu");
+
+ this.mCorrespondingMenuitem = null;
+
+ this.initializeAttributeInheritance();
+ }
+
+ get linkedBrowser() {
+ let tabmail = document.getElementById("tabmail");
+ let tab = tabmail._getTabContextForTabbyThing(this, false)[1];
+ return tabmail.getBrowserForTab(tab);
+ }
+
+ get mode() {
+ let tabmail = document.getElementById("tabmail");
+ let tab = tabmail._getTabContextForTabbyThing(this, false)[1];
+ return tab.mode;
+ }
+
+ /**
+ * Set the displayed icon for the tab.
+ *
+ * If a fallback source if given, it will be used instead if the given icon
+ * source is missing or loads with an error.
+ *
+ * If both sources are null, then the icon will become invisible.
+ *
+ * @param {string|null} iconSrc - The icon source to display in the tab, or
+ * null to just use the fallback source.
+ * @param {?string} [fallbackSrc] - The fallback source to display if the
+ * iconSrc is missing or broken.
+ */
+ setIcon(iconSrc, fallbackSrc) {
+ let icon = this.querySelector(".tab-icon-image");
+ if (!fallbackSrc) {
+ if (iconSrc) {
+ icon.setAttribute("src", iconSrc);
+ } else {
+ icon.removeAttribute("src");
+ }
+ return;
+ }
+ if (!iconSrc) {
+ icon.setAttribute("src", fallbackSrc);
+ return;
+ }
+ if (iconSrc == icon.getAttribute("src")) {
+ return;
+ }
+
+ // Set the tab image, and use the fallback if an error occurs.
+ // Set up a one time listener for either error or load.
+ let listener = event => {
+ icon.removeEventListener("error", listener);
+ icon.removeEventListener("load", listener);
+ if (event.type == "error") {
+ icon.setAttribute("src", fallbackSrc);
+ }
+ };
+ icon.addEventListener("error", listener);
+ icon.addEventListener("load", listener);
+ icon.setAttribute("src", iconSrc);
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozTabmailTab, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define("tabmail-tab", MozTabmailTab, { extends: "tab" });
+}
diff --git a/comm/mail/base/content/widgets/tabmail-tabs.js b/comm/mail/base/content/widgets/tabmail-tabs.js
new file mode 100644
index 0000000000..004a60122d
--- /dev/null
+++ b/comm/mail/base/content/widgets/tabmail-tabs.js
@@ -0,0 +1,723 @@
+/* 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 MozElements, MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ );
+
+ /**
+ * The MozTabs widget holds all the tabs for the main tab UI.
+ *
+ * @augments {MozTabs}
+ */
+ class MozTabmailTabs extends customElements.get("tabs") {
+ constructor() {
+ super();
+
+ this.addEventListener("dragstart", event => {
+ let draggedTab = this._getDragTargetTab(event);
+
+ if (!draggedTab) {
+ return;
+ }
+
+ let tab = this.tabmail.selectedTab;
+
+ if (!tab || !tab.canClose) {
+ return;
+ }
+
+ let dt = event.dataTransfer;
+
+ // If we drag within the same window, we use the tab directly
+ dt.mozSetDataAt("application/x-moz-tabmail-tab", draggedTab, 0);
+
+ // Otherwise we use session restore & JSON to migrate the tab.
+ let uri = this.tabmail.persistTab(tab);
+
+ // In case the tab implements session restore, we use JSON to convert
+ // it into a string.
+ //
+ // If a tab does not support session restore it returns null. We can't
+ // moved such tabs to a new window. However moving them within the same
+ // window works perfectly fine.
+ if (uri) {
+ uri = JSON.stringify(uri);
+ }
+
+ dt.mozSetDataAt("application/x-moz-tabmail-json", uri, 0);
+
+ dt.mozCursor = "default";
+
+ // Create Drag Image.
+ let panel = document.getElementById("tabpanelcontainer");
+
+ let thumbnail = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ thumbnail.width = Math.ceil(screen.availWidth / 5.75);
+ thumbnail.height = Math.round(thumbnail.width * 0.5625);
+
+ let snippetWidth = panel.getBoundingClientRect().width * 0.6;
+ let scale = thumbnail.width / snippetWidth;
+
+ let ctx = thumbnail.getContext("2d");
+
+ ctx.scale(scale, scale);
+
+ ctx.drawWindow(
+ window,
+ panel.screenX - window.mozInnerScreenX,
+ panel.screenY - window.mozInnerScreenY,
+ snippetWidth,
+ snippetWidth * 0.5625,
+ "rgb(255,255,255)"
+ );
+
+ dt = event.dataTransfer;
+ dt.setDragImage(thumbnail, 0, 0);
+
+ event.stopPropagation();
+ });
+
+ this.addEventListener("dragover", event => {
+ let dt = event.dataTransfer;
+
+ if (dt.mozItemCount == 0) {
+ return;
+ }
+
+ // Bug 516247:
+ // in case the user is dragging something else than a tab, and
+ // keeps hovering over a tab, we assume he wants to switch to this tab.
+ if (
+ dt.mozTypesAt(0)[0] != "application/x-moz-tabmail-tab" &&
+ dt.mozTypesAt(0)[1] != "application/x-moz-tabmail-json"
+ ) {
+ let tab = this._getDragTargetTab(event);
+
+ if (!tab) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!this._dragTime) {
+ this._dragTime = Date.now();
+ return;
+ }
+
+ if (Date.now() <= this._dragTime + this._dragOverDelay) {
+ return;
+ }
+
+ if (this.tabmail.tabContainer.selectedItem == tab) {
+ return;
+ }
+
+ this.tabmail.tabContainer.selectedItem = tab;
+
+ return;
+ }
+
+ // As some tabs do not support session restore they can't be
+ // moved to a different or new window. We should not show
+ // a dropmarker in such a case.
+ if (!dt.mozGetDataAt("application/x-moz-tabmail-json", 0)) {
+ let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0);
+
+ if (!draggedTab) {
+ return;
+ }
+
+ if (this.tabmail.tabContainer.getIndexOfItem(draggedTab) == -1) {
+ return;
+ }
+ }
+
+ dt.effectAllowed = "copyMove";
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ let ltr = window.getComputedStyle(this).direction == "ltr";
+ let ind = this._tabDropIndicator;
+ let arrowScrollbox = this.arrowScrollbox;
+
+ // Let's scroll
+ let pixelsToScroll = 0;
+ if (arrowScrollbox.getAttribute("overflow") == "true") {
+ switch (event.target) {
+ case arrowScrollbox._scrollButtonDown:
+ pixelsToScroll = arrowScrollbox.scrollIncrement * -1;
+ break;
+ case arrowScrollbox._scrollButtonUp:
+ pixelsToScroll = arrowScrollbox.scrollIncrement;
+ break;
+ }
+
+ if (ltr) {
+ pixelsToScroll = pixelsToScroll * -1;
+ }
+
+ if (pixelsToScroll) {
+ // Hide Indicator while Scrolling
+ ind.hidden = true;
+ arrowScrollbox.scrollByPixels(pixelsToScroll);
+ return;
+ }
+ }
+
+ let newIndex = this._getDropIndex(event);
+
+ // Fix the DropIndex in case it points to tab that can't be closed.
+ let tabInfo = this.tabmail.tabInfo;
+
+ while (newIndex < tabInfo.length && !tabInfo[newIndex].canClose) {
+ newIndex++;
+ }
+
+ let scrollRect = this.arrowScrollbox.scrollClientRect;
+ let rect = this.getBoundingClientRect();
+ let minMargin = scrollRect.left - rect.left;
+ let maxMargin = Math.min(
+ minMargin + scrollRect.width,
+ scrollRect.right
+ );
+
+ if (!ltr) {
+ [minMargin, maxMargin] = [
+ this.clientWidth - maxMargin,
+ this.clientWidth - minMargin,
+ ];
+ }
+
+ let newMargin;
+ let tabs = this.allTabs;
+
+ if (newIndex == tabs.length) {
+ let tabRect = tabs[newIndex - 1].getBoundingClientRect();
+
+ if (ltr) {
+ newMargin = tabRect.right - rect.left;
+ } else {
+ newMargin = rect.right - tabRect.left;
+ }
+ } else {
+ let tabRect = tabs[newIndex].getBoundingClientRect();
+
+ if (ltr) {
+ newMargin = tabRect.left - rect.left;
+ } else {
+ newMargin = rect.right - tabRect.right;
+ }
+ }
+
+ ind.hidden = false;
+
+ newMargin -= ind.clientWidth / 2;
+
+ ind.style.insetInlineStart = `${Math.round(newMargin)}px`;
+ });
+
+ this.addEventListener("drop", event => {
+ let dt = event.dataTransfer;
+
+ if (dt.mozItemCount != 1) {
+ return;
+ }
+
+ let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0);
+
+ if (!draggedTab) {
+ return;
+ }
+
+ event.stopPropagation();
+ this._tabDropIndicator.hidden = true;
+
+ // Is the tab one of our children?
+ if (this.tabmail.tabContainer.getIndexOfItem(draggedTab) == -1) {
+ // It's a tab from an other window, so we have to trigger session
+ // restore to get our tab
+
+ let tabmail2 = draggedTab.ownerDocument.getElementById("tabmail");
+ if (!tabmail2) {
+ return;
+ }
+
+ let draggedJson = dt.mozGetDataAt(
+ "application/x-moz-tabmail-json",
+ 0
+ );
+ if (!draggedJson) {
+ return;
+ }
+
+ draggedJson = JSON.parse(draggedJson);
+
+ // Some tab exist only once, so we have to gamble a bit. We close
+ // the tab and try to reopen it. If something fails the tab is gone.
+
+ tabmail2.closeTab(draggedTab, true);
+
+ if (!this.tabmail.restoreTab(draggedJson)) {
+ return;
+ }
+
+ draggedTab =
+ this.tabmail.tabContainer.allTabs[
+ this.tabmail.tabContainer.allTabs.length - 1
+ ];
+ }
+
+ let idx = this._getDropIndex(event);
+
+ // Fix the DropIndex in case it points to tab that can't be closed
+ let tabInfo = this.tabmail.tabInfo;
+ while (idx < tabInfo.length && !tabInfo[idx].canClose) {
+ idx++;
+ }
+
+ this.tabmail.moveTabTo(draggedTab, idx);
+
+ this.tabmail.switchToTab(draggedTab);
+ this.tabmail.updateCurrentTab();
+ });
+
+ this.addEventListener("dragend", event => {
+ // Note: while this case is correctly handled here, this event
+ // isn't dispatched when the tab is moved within the tabstrip,
+ // see bug 460801.
+
+ // The user pressed ESC to cancel the drag, or the drag succeeded.
+ let dt = event.dataTransfer;
+ if (dt.mozUserCancelled || dt.dropEffect != "none") {
+ return;
+ }
+
+ // Disable detach within the browser toolbox.
+ let eX = event.screenX;
+ let wX = window.screenX;
+
+ // Check if the drop point is horizontally within the window.
+ if (eX > wX && eX < wX + window.outerWidth) {
+ let bo = this.arrowScrollbox;
+ // Also avoid detaching if the the tab was dropped too close to
+ // the tabbar (half a tab).
+ let endScreenY = bo.screenY + 1.5 * bo.getBoundingClientRect().height;
+ let eY = event.screenY;
+
+ if (eY < endScreenY && eY > window.screenY) {
+ return;
+ }
+ }
+
+ // User wants to deatach tab from window...
+ if (dt.mozItemCount != 1) {
+ return;
+ }
+
+ let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0);
+
+ if (!draggedTab) {
+ return;
+ }
+
+ this.tabmail.replaceTabWithWindow(draggedTab);
+ });
+
+ this.addEventListener("dragleave", event => {
+ this._dragTime = 0;
+
+ this._tabDropIndicator.hidden = true;
+ event.stopPropagation();
+ });
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ super.connectedCallback();
+
+ this.tabmail = document.getElementById("tabmail");
+
+ this.arrowScrollboxWidth = 0;
+
+ this.arrowScrollbox = this.querySelector("arrowscrollbox");
+
+ this.mCollapseToolbar = document.getElementById(
+ this.getAttribute("collapsetoolbar")
+ );
+
+ // @implements {nsIObserver}
+ this._prefObserver = (subject, topic, data) => {
+ if (topic == "nsPref:changed") {
+ subject.QueryInterface(Ci.nsIPrefBranch);
+ if (data == "mail.tabs.autoHide") {
+ this.mAutoHide = subject.getBoolPref("mail.tabs.autoHide");
+ }
+ }
+ };
+
+ this._tabDropIndicator = this.querySelector(".tab-drop-indicator");
+
+ this._dragOverDelay = 350;
+
+ this._dragTime = 0;
+
+ this._mAutoHide = false;
+
+ this.mAllTabsButton = document.getElementById(
+ this.getAttribute("alltabsbutton")
+ );
+ this.mAllTabsPopup = this.mAllTabsButton.menu;
+
+ this.mDownBoxAnimate = this.arrowScrollbox;
+
+ this._animateTimer = null;
+
+ this._animateStep = -1;
+
+ this._animateDelay = 25;
+
+ this._animatePercents = [
+ 1.0, 0.85, 0.8, 0.75, 0.71, 0.68, 0.65, 0.62, 0.59, 0.57, 0.54, 0.52,
+ 0.5, 0.47, 0.45, 0.44, 0.42, 0.4, 0.38, 0.37, 0.35, 0.34, 0.32, 0.31,
+ 0.3, 0.29, 0.28, 0.27, 0.26, 0.25, 0.24, 0.23, 0.23, 0.22, 0.22, 0.21,
+ 0.21, 0.21, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.19, 0.19, 0.19,
+ 0.18, 0.18, 0.17, 0.17, 0.16, 0.15, 0.14, 0.13, 0.11, 0.09, 0.06,
+ ];
+
+ this.mTabMinWidth = Services.prefs.getIntPref("mail.tabs.tabMinWidth");
+ this.mTabMaxWidth = Services.prefs.getIntPref("mail.tabs.tabMaxWidth");
+ this.mTabClipWidth = Services.prefs.getIntPref("mail.tabs.tabClipWidth");
+ this.mAutoHide = Services.prefs.getBoolPref("mail.tabs.autoHide");
+
+ if (this.mAutoHide) {
+ this.mCollapseToolbar.collapsed = true;
+ document.documentElement.setAttribute("tabbarhidden", "true");
+ }
+
+ this._updateCloseButtons();
+
+ Services.prefs.addObserver("mail.tabs.", this._prefObserver);
+
+ window.addEventListener("resize", this);
+
+ // Listen to overflow/underflow events on the tabstrip,
+ // we cannot put these as xbl handlers on the entire binding because
+ // they would also get called for the all-tabs popup scrollbox.
+ // Also, we can't rely on event.target because these are all
+ // anonymous nodes.
+ this.arrowScrollbox.shadowRoot.addEventListener("overflow", this);
+ this.arrowScrollbox.shadowRoot.addEventListener("underflow", this);
+
+ this.addEventListener("select", event => {
+ this._handleTabSelect();
+
+ if (
+ !("updateCurrentTab" in this.tabmail) ||
+ event.target.localName != "tabs"
+ ) {
+ return;
+ }
+
+ this.tabmail.updateCurrentTab();
+ });
+
+ this.addEventListener("TabSelect", event => {
+ this._handleTabSelect();
+ });
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_tabMinWidthPref",
+ "mail.tabs.tabMinWidth",
+ null,
+ (pref, prevValue, newValue) => (this._tabMinWidth = newValue),
+ newValue => {
+ const LIMIT = 50;
+ return Math.max(newValue, LIMIT);
+ }
+ );
+ this._tabMinWidth = this._tabMinWidthPref;
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_tabMaxWidthPref",
+ "mail.tabs.tabMaxWidth",
+ null,
+ (pref, prevValue, newValue) => (this._tabMaxWidth = newValue)
+ );
+ this._tabMaxWidth = this._tabMaxWidthPref;
+ }
+
+ get tabbox() {
+ return document.getElementById("tabmail-tabbox");
+ }
+
+ // Accessor for tabs.
+ get allTabs() {
+ if (!this.arrowScrollbox) {
+ return [];
+ }
+
+ return Array.from(this.arrowScrollbox.children);
+ }
+
+ appendChild(tab) {
+ return this.insertBefore(tab, null);
+ }
+
+ insertBefore(tab, node) {
+ if (!this.arrowScrollbox) {
+ return;
+ }
+
+ if (node == null) {
+ this.arrowScrollbox.appendChild(tab);
+ return;
+ }
+
+ this.arrowScrollbox.insertBefore(tab, node);
+ }
+
+ set mAutoHide(val) {
+ if (val != this._mAutoHide) {
+ if (this.allTabs.length == 1) {
+ this.mCollapseToolbar.collapsed = val;
+ }
+ this._mAutoHide = val;
+ }
+ }
+
+ get mAutoHide() {
+ return this._mAutoHide;
+ }
+
+ set selectedIndex(val) {
+ let tab = this.getItemAtIndex(val);
+ let alreadySelected = tab && tab.selected;
+
+ this.__proto__.__proto__
+ .__lookupSetter__("selectedIndex")
+ .call(this, val);
+
+ if (!alreadySelected) {
+ // Fire an onselect event for the tabs element.
+ let event = document.createEvent("Events");
+ event.initEvent("select", true, true);
+ this.dispatchEvent(event);
+ }
+ }
+
+ get selectedIndex() {
+ return this.__proto__.__proto__
+ .__lookupGetter__("selectedIndex")
+ .call(this);
+ }
+
+ _updateCloseButtons() {
+ let width =
+ this.arrowScrollbox.firstElementChild.getBoundingClientRect().width;
+ // 0 width is an invalid value and indicates
+ // an item without display, so ignore.
+ if (width > this.mTabClipWidth || width == 0) {
+ this.setAttribute("closebuttons", "alltabs");
+ } else {
+ this.setAttribute("closebuttons", "activetab");
+ }
+ }
+
+ _handleTabSelect() {
+ this.arrowScrollbox.ensureElementIsVisible(this.selectedItem);
+ }
+
+ handleEvent(aEvent) {
+ let alltabsButton = document.getElementById("alltabs-button");
+
+ switch (aEvent.type) {
+ case "overflow":
+ this.arrowScrollbox.ensureElementIsVisible(this.selectedItem);
+
+ // filter overflow events which were dispatched on nested scrollboxes
+ // and ignore vertical events.
+ if (
+ aEvent.target != this.arrowScrollbox.scrollbox ||
+ aEvent.detail == 0
+ ) {
+ return;
+ }
+
+ this.arrowScrollbox.setAttribute("overflow", "true");
+ alltabsButton.removeAttribute("hidden");
+ break;
+ case "underflow":
+ // filter underflow events which were dispatched on nested scrollboxes
+ // and ignore vertical events.
+ if (
+ aEvent.target != this.arrowScrollbox.scrollbox ||
+ aEvent.detail == 0
+ ) {
+ return;
+ }
+
+ this.arrowScrollbox.removeAttribute("overflow");
+ alltabsButton.setAttribute("hidden", "true");
+ break;
+ case "resize":
+ let width = this.arrowScrollbox.getBoundingClientRect().width;
+ if (width != this.arrowScrollboxWidth) {
+ this._updateCloseButtons();
+ // XXX without this line the tab bar won't budge
+ this.arrowScrollbox.scrollByPixels(1);
+ this._handleTabSelect();
+ this.arrowScrollboxWidth = width;
+ }
+ break;
+ }
+ }
+
+ _stopAnimation() {
+ if (this._animateStep != -1) {
+ if (this._animateTimer) {
+ this._animateTimer.cancel();
+ }
+
+ this._animateStep = -1;
+ this.mAllTabsBoxAnimate.style.opacity = 0.0;
+ this.mDownBoxAnimate.style.opacity = 0.0;
+ }
+ }
+
+ _notifyBackgroundTab(aTab) {
+ let tsbo = this.arrowScrollbox;
+ let tsboStart = tsbo.screenX;
+ let tsboEnd = tsboStart + tsbo.getBoundingClientRect().width;
+
+ let ctboStart = aTab.screenX;
+ let ctboEnd = ctboStart + aTab.getBoundingClientRect().width;
+
+ // only start the flash timer if the new tab (which was loaded in
+ // the background) is not completely visible
+ if (tsboStart > ctboStart || ctboEnd > tsboEnd) {
+ this._animateStep = 0;
+
+ if (!this._animateTimer) {
+ this._animateTimer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ );
+ } else {
+ this._animateTimer.cancel();
+ }
+
+ this._animateTimer.initWithCallback(
+ this,
+ this._animateDelay,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ }
+ }
+
+ notify(aTimer) {
+ if (!document) {
+ aTimer.cancel();
+ }
+
+ let percent = this._animatePercents[this._animateStep];
+ this.mAllTabsBoxAnimate.style.opacity = percent;
+ this.mDownBoxAnimate.style.opacity = percent;
+
+ if (this._animateStep < this._animatePercents.length - 1) {
+ this._animateStep++;
+ } else {
+ this._stopAnimation();
+ }
+ }
+
+ _getDragTargetTab(event) {
+ let tab = event.target;
+ while (tab && tab.localName != "tab") {
+ tab = tab.parentNode;
+ }
+
+ if (!tab) {
+ return null;
+ }
+
+ if (event.type != "drop" && event.type != "dragover") {
+ return tab;
+ }
+
+ let tabRect = tab.getBoundingClientRect();
+ if (event.screenX < tab.screenX + tabRect.width * 0.25) {
+ return null;
+ }
+
+ if (event.screenX > tab.screenX + tabRect.width * 0.75) {
+ return null;
+ }
+
+ return tab;
+ }
+
+ _getDropIndex(event) {
+ let tabs = this.allTabs;
+
+ if (window.getComputedStyle(this).direction == "ltr") {
+ for (let i = 0; i < tabs.length; i++) {
+ if (
+ event.screenX <
+ tabs[i].screenX + tabs[i].getBoundingClientRect().width / 2
+ ) {
+ return i;
+ }
+ }
+ } else {
+ for (let i = 0; i < tabs.length; i++) {
+ if (
+ event.screenX >
+ tabs[i].screenX + tabs[i].getBoundingClientRect().width / 2
+ ) {
+ return i;
+ }
+ }
+ }
+
+ return tabs.length;
+ }
+
+ set _tabMinWidth(val) {
+ this.arrowScrollbox.style.setProperty("--tab-min-width", `${val}px`);
+ }
+ set _tabMaxWidth(val) {
+ this.arrowScrollbox.style.setProperty("--tab-max-width", `${val}px`);
+ }
+
+ disconnectedCallback() {
+ Services.prefs.removeObserver("mail.tabs.", this._prefObserver);
+
+ // Release timer to avoid reference cycles.
+ if (this._animateTimer) {
+ this._animateTimer.cancel();
+ this._animateTimer = null;
+ }
+
+ this.arrowScrollbox.shadowRoot.removeEventListener("overflow", this);
+ this.arrowScrollbox.shadowRoot.removeEventListener("underflow", this);
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozTabmailTabs, [Ci.nsITimerCallback]);
+ customElements.define("tabmail-tabs", MozTabmailTabs, { extends: "tabs" });
+}
diff --git a/comm/mail/base/content/widgets/toolbarContext.inc.xhtml b/comm/mail/base/content/widgets/toolbarContext.inc.xhtml
new file mode 100644
index 0000000000..c6a1c415a8
--- /dev/null
+++ b/comm/mail/base/content/widgets/toolbarContext.inc.xhtml
@@ -0,0 +1,19 @@
+# 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/.
+
+<menupopup id="toolbar-context-menu"
+ onpopupshowing="calendarOnToolbarsPopupShowing(event); ToolbarContextMenu.updateExtension(this);">
+ <menuseparator id="customizeMailToolbarMenuSeparator"/>
+ <menuitem id="CustomizeMailToolbar"
+ command="cmd_CustomizeMailToolbar"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"/>
+ <menuseparator id="extensionsMailToolbarMenuSeparator"/>
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
diff --git a/comm/mail/base/content/widgets/toolbarbutton-menu-button.js b/comm/mail/base/content/widgets/toolbarbutton-menu-button.js
new file mode 100644
index 0000000000..c514aa7357
--- /dev/null
+++ b/comm/mail/base/content/widgets/toolbarbutton-menu-button.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/. */
+
+"use strict";
+
+/* global MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozToolbarButtonMenuButton widget is a toolbarbutton with
+ * type="menu". Place a menupopup element inside the button to create
+ * the menu popup. When the dropmarker in the toobarbutton is pressed the
+ * menupopup will open. When clicking the main area of the button it works
+ * like a normal toolbarbutton.
+ *
+ * @augments MozToolbarbutton
+ */
+ class MozToolbarButtonMenuButton extends customElements.get("toolbarbutton") {
+ static get inheritedAttributes() {
+ return {
+ ...super.inheritedAttributes,
+ ".toolbarbutton-menubutton-button":
+ "command,hidden,disabled,align,dir,pack,orient,label,wrap,tooltiptext=buttontooltiptext",
+ ".toolbarbutton-menubutton-dropmarker": "open,disabled",
+ };
+ }
+ static get menubuttonFragment() {
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <toolbarbutton class="box-inherit toolbarbutton-menubutton-button"
+ flex="1"
+ allowevents="true"></toolbarbutton>
+ <dropmarker type="menu"
+ class="toolbarbutton-menubutton-dropmarker"></dropmarker>
+ `),
+ true
+ );
+ Object.defineProperty(this, "menubuttonFragment", { value: frag });
+ return frag;
+ }
+
+ /** @override */
+ get _hasConnected() {
+ return (
+ this.querySelector(":scope > toolbarbutton > .toolbarbutton-text") !=
+ null
+ );
+ }
+
+ /** @override */
+ render() {
+ this.appendChild(this.constructor.menubuttonFragment.cloneNode(true));
+ this.initializeAttributeInheritance();
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this._hasConnected) {
+ return;
+ }
+
+ // Defer creating DOM elements for content inside popups.
+ // These will be added in the popupshown handler above.
+ let panel = this.closest("panel");
+ if (panel && !panel.hasAttribute("hasbeenopened")) {
+ return;
+ }
+ this.setAttribute("is", "toolbarbutton-menu-button");
+ this.setAttribute("type", "menu");
+
+ this.render();
+ }
+ }
+ customElements.define(
+ "toolbarbutton-menu-button",
+ MozToolbarButtonMenuButton,
+ { extends: "toolbarbutton" }
+ );
+}
diff --git a/comm/mail/base/content/widgets/tree-listbox.js b/comm/mail/base/content/widgets/tree-listbox.js
new file mode 100644
index 0000000000..81d42ca72b
--- /dev/null
+++ b/comm/mail/base/content/widgets/tree-listbox.js
@@ -0,0 +1,914 @@
+/* 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/. */
+
+{
+ // Animation variables for expanding and collapsing child lists.
+ const ANIMATION_DURATION_MS = 200;
+ const ANIMATION_EASING = "ease";
+ let reducedMotionMedia = matchMedia("(prefers-reduced-motion)");
+
+ /**
+ * Provides keyboard and mouse interaction to a (possibly nested) list.
+ * It is intended for lists with a small number (up to 1000?) of items.
+ * Only one item can be selected at a time. Maintenance of the items in the
+ * list is not managed here. Styling of the list is not managed here.
+ *
+ * The following class names apply to list items:
+ * - selected: Indicates the currently selected list item.
+ * - children: If the list item has descendants.
+ * - collapsed: If the list item's descendants are hidden.
+ *
+ * List items can provide their own twisty element, which will operate when
+ * clicked on if given the class name "twisty".
+ *
+ * This class fires "collapsed", "expanded" and "select" events.
+ */
+ let TreeListboxMixin = Base =>
+ class extends Base {
+ /**
+ * The selected and focused item, or null if there is none.
+ *
+ * @type {?HTMLLIElement}
+ */
+ _selectedRow = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-listbox");
+ switch (this.getAttribute("role")) {
+ case "tree":
+ this.isTree = true;
+ break;
+ case "listbox":
+ this.isTree = false;
+ break;
+ default:
+ throw new RangeError(
+ `Unsupported role ${this.getAttribute("role")}`
+ );
+ }
+ this.tabIndex = 0;
+
+ this.domChanged();
+ this._initRows();
+ let rows = this.rows;
+ if (!this.selectedRow && rows.length) {
+ // TODO: This should only really happen on "focus".
+ this.selectedRow = rows[0];
+ }
+
+ this.addEventListener("click", this);
+ this.addEventListener("keydown", this);
+ this._mutationObserver.observe(this, {
+ subtree: true,
+ childList: true,
+ });
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ this._onClick(event);
+ break;
+ case "keydown":
+ this._onKeyDown(event);
+ break;
+ }
+ }
+
+ _onClick(event) {
+ if (event.button !== 0) {
+ return;
+ }
+
+ let row = event.target.closest("li:not(.unselectable)");
+ if (!row) {
+ return;
+ }
+
+ if (
+ row.classList.contains("children") &&
+ (event.target.closest(".twisty") || event.detail == 2)
+ ) {
+ if (row.classList.contains("collapsed")) {
+ this.expandRow(row);
+ } else {
+ this.collapseRow(row);
+ }
+ return;
+ }
+
+ this.selectedRow = row;
+ if (document.activeElement != this) {
+ // Overflowing elements with tabindex=-1 steal focus. Grab it back.
+ this.focus();
+ }
+ }
+
+ _onKeyDown(event) {
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ switch (event.key) {
+ case "ArrowUp":
+ this.selectedIndex = this._clampIndex(this.selectedIndex - 1);
+ break;
+ case "ArrowDown":
+ this.selectedIndex = this._clampIndex(this.selectedIndex + 1);
+ break;
+ case "Home":
+ this.selectedIndex = 0;
+ break;
+ case "End":
+ this.selectedIndex = this.rowCount - 1;
+ break;
+ case "PageUp": {
+ if (!this.selectedRow) {
+ break;
+ }
+ // Get the top of the selected row, and remove the page height.
+ let selectedBox = this.selectedRow.getBoundingClientRect();
+ let y = selectedBox.top - this.clientHeight;
+
+ // Find the last row below there.
+ let rows = this.rows;
+ let i = this.selectedIndex - 1;
+ while (i > 0 && rows[i].getBoundingClientRect().top >= y) {
+ i--;
+ }
+ this.selectedIndex = i;
+ break;
+ }
+ case "PageDown": {
+ if (!this.selectedRow) {
+ break;
+ }
+ // Get the top of the selected row, and add the page height.
+ let selectedBox = this.selectedRow.getBoundingClientRect();
+ let y = selectedBox.top + this.clientHeight;
+
+ // Find the last row below there.
+ let rows = this.rows;
+ let i = rows.length - 1;
+ while (
+ i > this.selectedIndex &&
+ rows[i].getBoundingClientRect().top >= y
+ ) {
+ i--;
+ }
+ this.selectedIndex = i;
+ break;
+ }
+ case "ArrowLeft":
+ case "ArrowRight": {
+ let selected = this.selectedRow;
+ if (!selected) {
+ break;
+ }
+
+ let isArrowRight = event.key == "ArrowRight";
+ let isRTL = this.matches(":dir(rtl)");
+ if (isArrowRight == isRTL) {
+ let parent = selected.parentNode.closest(
+ ".children:not(.unselectable)"
+ );
+ if (
+ parent &&
+ (!selected.classList.contains("children") ||
+ selected.classList.contains("collapsed"))
+ ) {
+ this.selectedRow = parent;
+ break;
+ }
+ if (selected.classList.contains("children")) {
+ this.collapseRow(selected);
+ }
+ } else if (selected.classList.contains("children")) {
+ if (selected.classList.contains("collapsed")) {
+ this.expandRow(selected);
+ } else {
+ this.selectedRow = selected.querySelector("li");
+ }
+ }
+ break;
+ }
+ case "Enter": {
+ const selected = this.selectedRow;
+ if (!selected?.classList.contains("children")) {
+ return;
+ }
+ if (selected.classList.contains("collapsed")) {
+ this.expandRow(selected);
+ } else {
+ this.collapseRow(selected);
+ }
+ break;
+ }
+ default:
+ return;
+ }
+
+ event.preventDefault();
+ }
+
+ /**
+ * Data for the rows in the DOM.
+ *
+ * @typedef {object} TreeRowData
+ * @property {HTMLLIElement} row - The row item.
+ * @property {HTMLLIElement[]} ancestors - The ancestors of the row,
+ * ordered closest to furthest away.
+ */
+
+ /**
+ * Data for all items beneath this node, including collapsed items,
+ * ordered as they are in the DOM.
+ *
+ * @type {TreeRowData[]}
+ */
+ _rowsData = [];
+
+ /**
+ * Call whenever the tree nodes or ordering changes. This should only be
+ * called externally if the mutation observer has been dis-connected and
+ * re-connected.
+ */
+ domChanged() {
+ this._rowsData = Array.from(this.querySelectorAll("li"), row => {
+ let ancestors = [];
+ for (
+ let parentRow = row.parentNode.closest("li");
+ this.contains(parentRow);
+ parentRow = parentRow.parentNode.closest("li")
+ ) {
+ ancestors.push(parentRow);
+ }
+ return { row, ancestors };
+ });
+ }
+
+ _mutationObserver = new MutationObserver(mutations => {
+ for (let mutation of mutations) {
+ for (let node of mutation.addedNodes) {
+ if (node.nodeType != Node.ELEMENT_NODE || !node.matches("li")) {
+ continue;
+ }
+ // No item can already be selected on addition.
+ node.classList.remove("selected");
+ }
+ }
+ let oldRowsData = this._rowsData;
+ this.domChanged();
+ this._initRows();
+ let newRows = this.rows;
+ if (!newRows.length) {
+ this.selectedRow = null;
+ return;
+ }
+ if (!this.selectedRow) {
+ // TODO: This should only really happen on "focus".
+ this.selectedRow = newRows[0];
+ return;
+ }
+ if (newRows.includes(this.selectedRow)) {
+ // Selected row is still visible.
+ return;
+ }
+ let oldSelectedIndex = oldRowsData.findIndex(
+ entry => entry.row == this.selectedRow
+ );
+ if (oldSelectedIndex < 0) {
+ // Unexpected, the selectedRow was not in our _rowsData list.
+ this.selectedRow = newRows[0];
+ return;
+ }
+ // Find the closest ancestor that is still shown.
+ let existingAncestor = oldRowsData[oldSelectedIndex].ancestors.find(
+ row => newRows.includes(row)
+ );
+ if (existingAncestor) {
+ // We search as if the existingAncestor is the full list. This keeps
+ // the selection within the ancestor, or moves it to the ancestor if
+ // no child is found.
+ // NOTE: Includes existingAncestor itself, so should be non-empty.
+ newRows = newRows.filter(row => existingAncestor.contains(row));
+ }
+ // We have lost the selectedRow, so we select a new row. We want to try
+ // and find the element that exists both in the new rows and in the old
+ // rows, that directly preceded the previously selected row. We then
+ // want to select the next visible row that follows this found element
+ // in the new rows.
+ // If rows were replaced with new rows, this will select the first of
+ // the new rows.
+ // If rows were simply removed, this will select the next row that was
+ // not removed.
+ let beforeIndex = -1;
+ for (let i = oldSelectedIndex; i >= 0; i--) {
+ beforeIndex = this._rowsData.findIndex(
+ entry => entry.row == oldRowsData[i].row
+ );
+ if (beforeIndex >= 0) {
+ break;
+ }
+ }
+ // Start from just after the found item, or 0 if none were found
+ // (beforeIndex == -1), find the next visible item. Otherwise we default
+ // to selecting the last row.
+ let selectRow = newRows[newRows.length - 1];
+ for (let i = beforeIndex + 1; i < this._rowsData.length; i++) {
+ if (newRows.includes(this._rowsData[i].row)) {
+ selectRow = this._rowsData[i].row;
+ break;
+ }
+ }
+ this.selectedRow = selectRow;
+ });
+
+ /**
+ * Set the role attribute and classes for all descendants of the widget.
+ */
+ _initRows() {
+ let descendantItems = this.querySelectorAll("li");
+ let descendantLists = this.querySelectorAll("ol, ul");
+
+ for (let i = 0; i < descendantItems.length; i++) {
+ let row = descendantItems[i];
+ row.setAttribute("role", this.isTree ? "treeitem" : "option");
+ if (
+ i + 1 < descendantItems.length &&
+ row.contains(descendantItems[i + 1])
+ ) {
+ row.classList.add("children");
+ if (this.isTree) {
+ row.setAttribute(
+ "aria-expanded",
+ !row.classList.contains("collapsed")
+ );
+ }
+ } else {
+ row.classList.remove("children");
+ row.classList.remove("collapsed");
+ row.removeAttribute("aria-expanded");
+ }
+ row.setAttribute("aria-selected", row.classList.contains("selected"));
+ }
+
+ if (this.isTree) {
+ for (let list of descendantLists) {
+ list.setAttribute("role", "group");
+ }
+ }
+
+ for (let childList of this.querySelectorAll(
+ "li.collapsed > :is(ol, ul)"
+ )) {
+ childList.style.height = "0";
+ }
+ }
+
+ /**
+ * Every visible row. Rows with collapsed ancestors are not included.
+ *
+ * @type {HTMLLIElement[]}
+ */
+ get rows() {
+ return [...this.querySelectorAll("li:not(.unselectable)")].filter(
+ row => {
+ let collapsed = row.parentNode.closest("li.collapsed");
+ if (collapsed && this.contains(collapsed)) {
+ return false;
+ }
+ return true;
+ }
+ );
+ }
+
+ /**
+ * The number of visible rows.
+ *
+ * @type {integer}
+ */
+ get rowCount() {
+ return this.rows.length;
+ }
+
+ /**
+ * Clamps `index` to a value between 0 and `rowCount - 1`.
+ *
+ * @param {integer} index
+ * @returns {integer}
+ */
+ _clampIndex(index) {
+ if (index >= this.rowCount) {
+ return this.rowCount - 1;
+ }
+ if (index < 0) {
+ return 0;
+ }
+ return index;
+ }
+
+ /**
+ * Ensures that the row at `index` is on the screen.
+ *
+ * @param {integer} index
+ */
+ scrollToIndex(index) {
+ this.getRowAtIndex(index)?.scrollIntoView({ block: "nearest" });
+ }
+
+ /**
+ * Returns the row element at `index` or null if `index` is out of range.
+ *
+ * @param {integer} index
+ * @returns {HTMLLIElement?}
+ */
+ getRowAtIndex(index) {
+ return this.rows[index];
+ }
+
+ /**
+ * The index of the selected row. If there are no rows, the value is -1.
+ * Otherwise, should always have a value between 0 and `rowCount - 1`.
+ * It is set to 0 in `connectedCallback` if there are rows.
+ *
+ * @type {integer}
+ */
+ get selectedIndex() {
+ return this.rows.findIndex(row => row == this.selectedRow);
+ }
+
+ set selectedIndex(index) {
+ index = this._clampIndex(index);
+ this.selectedRow = this.getRowAtIndex(index);
+ }
+
+ /**
+ * The selected and focused item, or null if there is none.
+ *
+ * @type {?HTMLLIElement}
+ */
+ get selectedRow() {
+ return this._selectedRow;
+ }
+
+ set selectedRow(row) {
+ if (row == this._selectedRow) {
+ return;
+ }
+
+ if (this._selectedRow) {
+ this._selectedRow.classList.remove("selected");
+ this._selectedRow.setAttribute("aria-selected", "false");
+ }
+
+ this._selectedRow = row ?? null;
+ if (row) {
+ row.classList.add("selected");
+ row.setAttribute("aria-selected", "true");
+ this.setAttribute("aria-activedescendant", row.id);
+ row.firstElementChild.scrollIntoView({ block: "nearest" });
+ } else {
+ this.removeAttribute("aria-activedescendant");
+ }
+
+ this.dispatchEvent(new CustomEvent("select"));
+ }
+
+ /**
+ * Collapses the row at `index` if it can be collapsed. If the selected
+ * row is a descendant of the collapsing row, selection is moved to the
+ * collapsing row.
+ *
+ * @param {integer} index
+ */
+ collapseRowAtIndex(index) {
+ this.collapseRow(this.getRowAtIndex(index));
+ }
+
+ /**
+ * Expands the row at `index` if it can be expanded.
+ *
+ * @param {integer} index
+ */
+ expandRowAtIndex(index) {
+ this.expandRow(this.getRowAtIndex(index));
+ }
+
+ /**
+ * Collapses the row if it can be collapsed. If the selected row is a
+ * descendant of the collapsing row, selection is moved to the collapsing
+ * row.
+ *
+ * @param {HTMLLIElement} row - The row to collapse.
+ */
+ collapseRow(row) {
+ if (
+ row.classList.contains("children") &&
+ !row.classList.contains("collapsed")
+ ) {
+ if (row.contains(this.selectedRow)) {
+ this.selectedRow = row;
+ }
+ row.classList.add("collapsed");
+ if (this.isTree) {
+ row.setAttribute("aria-expanded", "false");
+ }
+ row.dispatchEvent(new CustomEvent("collapsed", { bubbles: true }));
+ this._animateCollapseRow(row);
+ }
+ }
+
+ /**
+ * Expands the row if it can be expanded.
+ *
+ * @param {HTMLLIElement} row - The row to expand.
+ */
+ expandRow(row) {
+ if (
+ row.classList.contains("children") &&
+ row.classList.contains("collapsed")
+ ) {
+ row.classList.remove("collapsed");
+ if (this.isTree) {
+ row.setAttribute("aria-expanded", "true");
+ }
+ row.dispatchEvent(new CustomEvent("expanded", { bubbles: true }));
+ this._animateExpandRow(row);
+ }
+ }
+
+ /**
+ * Animate the collapsing of a row containing child items.
+ *
+ * @param {HTMLLIElement} row - The parent row element.
+ */
+ _animateCollapseRow(row) {
+ let childList = row.querySelector("ol, ul");
+
+ if (reducedMotionMedia.matches) {
+ if (childList) {
+ childList.style.height = "0";
+ }
+ return;
+ }
+
+ let childListHeight = childList.scrollHeight;
+
+ let animation = childList.animate(
+ [{ height: `${childListHeight}px` }, { height: "0" }],
+ {
+ duration: ANIMATION_DURATION_MS,
+ easing: ANIMATION_EASING,
+ fill: "both",
+ }
+ );
+ animation.onfinish = () => {
+ childList.style.height = "0";
+ animation.cancel();
+ };
+ }
+
+ /**
+ * Animate the revealing of a row containing child items.
+ *
+ * @param {HTMLLIElement} row - The parent row element.
+ */
+ _animateExpandRow(row) {
+ let childList = row.querySelector("ol, ul");
+
+ if (reducedMotionMedia.matches) {
+ if (childList) {
+ childList.style.height = null;
+ }
+ return;
+ }
+
+ let childListHeight = childList.scrollHeight;
+
+ let animation = childList.animate(
+ [{ height: "0" }, { height: `${childListHeight}px` }],
+ {
+ duration: ANIMATION_DURATION_MS,
+ easing: ANIMATION_EASING,
+ fill: "both",
+ }
+ );
+ animation.onfinish = () => {
+ childList.style.height = null;
+ animation.cancel();
+ };
+ }
+ };
+
+ /**
+ * An unordered list with the functionality of TreeListboxMixin.
+ */
+ class TreeListbox extends TreeListboxMixin(HTMLUListElement) {}
+ customElements.define("tree-listbox", TreeListbox, { extends: "ul" });
+
+ /**
+ * An ordered list with the functionality of TreeListboxMixin, plus the
+ * ability to re-order the top-level list by drag-and-drop/Alt+Up/Alt+Down.
+ *
+ * This class fires an "ordered" event when the list is re-ordered.
+ *
+ * @note All children of this element should be HTML. If there are XUL
+ * elements, you're gonna have a bad time.
+ */
+ class OrderableTreeListbox extends TreeListboxMixin(HTMLOListElement) {
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("is", "orderable-tree-listbox");
+
+ this.addEventListener("dragstart", this);
+ window.addEventListener("dragover", this);
+ window.addEventListener("drop", this);
+ window.addEventListener("dragend", this);
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+
+ switch (event.type) {
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "dragend":
+ this._onDragEnd(event);
+ break;
+ }
+ }
+
+ /**
+ * An array of all top-level rows that can be reordered. Override this
+ * getter to prevent reordering of one or more rows.
+ *
+ * @note So far this has only been used to prevent the last row being
+ * moved. Any other use is untested. It likely also works for rows at
+ * the top of the list.
+ *
+ * @returns {HTMLLIElement[]}
+ */
+ get _orderableChildren() {
+ return [...this.children];
+ }
+
+ _onKeyDown(event) {
+ super._onKeyDown(event);
+
+ if (
+ !event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey ||
+ !["ArrowUp", "ArrowDown"].includes(event.key)
+ ) {
+ return;
+ }
+
+ let row = this.selectedRow;
+ if (!row || row.parentElement != this) {
+ return;
+ }
+
+ let otherRow;
+ if (event.key == "ArrowUp") {
+ otherRow = row.previousElementSibling;
+ } else {
+ otherRow = row.nextElementSibling;
+ }
+ if (!otherRow) {
+ return;
+ }
+
+ // Check we can move these rows.
+ let orderable = this._orderableChildren;
+ if (!orderable.includes(row) || !orderable.includes(otherRow)) {
+ return;
+ }
+
+ let reducedMotion = reducedMotionMedia.matches;
+
+ this.scrollToIndex(this.rows.indexOf(otherRow));
+
+ // Temporarily disconnect the mutation observer to stop it changing things.
+ this._mutationObserver.disconnect();
+ if (event.key == "ArrowUp") {
+ if (!reducedMotion) {
+ let { top: otherTop } = otherRow.getBoundingClientRect();
+ let { top: rowTop, height: rowHeight } = row.getBoundingClientRect();
+ OrderableTreeListbox._animateTranslation(otherRow, 0 - rowHeight);
+ OrderableTreeListbox._animateTranslation(row, rowTop - otherTop);
+ }
+ this.insertBefore(row, otherRow);
+ } else {
+ if (!reducedMotion) {
+ let { top: otherTop, height: otherHeight } =
+ otherRow.getBoundingClientRect();
+ let { top: rowTop, height: rowHeight } = row.getBoundingClientRect();
+ OrderableTreeListbox._animateTranslation(otherRow, rowHeight);
+ OrderableTreeListbox._animateTranslation(
+ row,
+ rowTop - otherTop - otherHeight + rowHeight
+ );
+ }
+ this.insertBefore(row, otherRow.nextElementSibling);
+ }
+ this._mutationObserver.observe(this, { subtree: true, childList: true });
+
+ // Rows moved.
+ this.domChanged();
+ this.dispatchEvent(new CustomEvent("ordered", { detail: row }));
+ }
+
+ _onDragStart(event) {
+ if (!event.target.closest("[draggable]")) {
+ // This shouldn't be necessary, but is?!
+ event.preventDefault();
+ return;
+ }
+
+ let orderable = this._orderableChildren;
+ if (orderable.length < 2) {
+ return;
+ }
+
+ for (let topLevelRow of orderable) {
+ if (topLevelRow.contains(event.target)) {
+ let rect = topLevelRow.getBoundingClientRect();
+ this._dragInfo = {
+ row: topLevelRow,
+ // How far can we move `topLevelRow` upwards?
+ min: orderable[0].getBoundingClientRect().top - rect.top,
+ // How far can we move `topLevelRow` downwards?
+ max:
+ orderable[orderable.length - 1].getBoundingClientRect().bottom -
+ rect.bottom,
+ // Where is the pointer relative to the scroll box of the list?
+ // (Not quite, the Y position of `this` is not removed, but we'd
+ // only have to do the same where this value is used.)
+ scrollY: event.clientY + this.scrollTop,
+ // Where is the pointer relative to `topLevelRow`?
+ offsetY: event.clientY - rect.top,
+ };
+ topLevelRow.classList.add("dragging");
+
+ // Prevent `topLevelRow` being used as the drag image. We don't
+ // really want any drag image, but there's no way to not have one.
+ event.dataTransfer.setDragImage(document.createElement("img"), 0, 0);
+ return;
+ }
+ }
+ }
+
+ _onDragOver(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ let { row, min, max, scrollY, offsetY } = this._dragInfo;
+
+ // Move `row` with the mouse pointer.
+ let dragY = Math.min(
+ max,
+ Math.max(min, event.clientY + this.scrollTop - scrollY)
+ );
+ row.style.transform = `translateY(${dragY}px)`;
+
+ let thisRect = this.getBoundingClientRect();
+ // How much space is there above `row`? We'll see how many rows fit in
+ // the space and put `row` in after them.
+ let spaceAbove = Math.max(
+ 0,
+ event.clientY + this.scrollTop - offsetY - thisRect.top
+ );
+ // The height of all rows seen in the loop so far.
+ let totalHeight = 0;
+ // If we've looped past the row being dragged.
+ let afterDraggedRow = false;
+ // The row before where a drop would take place. If null, drop would
+ // happen at the start of the list.
+ let targetRow = null;
+
+ for (let topLevelRow of this._orderableChildren) {
+ if (topLevelRow == row) {
+ afterDraggedRow = true;
+ continue;
+ }
+
+ let rect = topLevelRow.getBoundingClientRect();
+ let enoughSpace = spaceAbove > totalHeight + rect.height / 2;
+
+ let multiplier = 0;
+ if (enoughSpace) {
+ if (afterDraggedRow) {
+ multiplier = -1;
+ }
+ targetRow = topLevelRow;
+ } else if (!afterDraggedRow) {
+ multiplier = 1;
+ }
+ OrderableTreeListbox._transitionTranslation(
+ topLevelRow,
+ multiplier * row.clientHeight
+ );
+
+ totalHeight += rect.height;
+ }
+
+ this._dragInfo.dropTarget = targetRow;
+ event.preventDefault();
+ }
+
+ _onDrop(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ let { row, dropTarget } = this._dragInfo;
+
+ let targetRow;
+ if (dropTarget) {
+ targetRow = dropTarget.nextElementSibling;
+ } else {
+ targetRow = this.firstElementChild;
+ }
+
+ event.preventDefault();
+ // Temporarily disconnect the mutation observer to stop it changing things.
+ this._mutationObserver.disconnect();
+ this.insertBefore(row, targetRow);
+ this._mutationObserver.observe(this, { subtree: true, childList: true });
+ // Rows moved.
+ this.domChanged();
+ this.dispatchEvent(new CustomEvent("ordered", { detail: row }));
+ }
+
+ _onDragEnd(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ this._dragInfo.row.classList.remove("dragging");
+ delete this._dragInfo;
+
+ for (let topLevelRow of this.children) {
+ topLevelRow.style.transition = null;
+ topLevelRow.style.transform = null;
+ }
+ }
+
+ /**
+ * Used to animate a real change in the order. The element is moved in the
+ * DOM, then the animation makes it appear to move from the original
+ * position to the new position
+ *
+ * @param {HTMLLIElement} element - The row to animate.
+ * @param {number} from - Original Y position of the element relative to
+ * its current position.
+ */
+ static _animateTranslation(element, from) {
+ let animation = element.animate(
+ [
+ { transform: `translateY(${from}px)` },
+ { transform: "translateY(0px)" },
+ ],
+ {
+ duration: ANIMATION_DURATION_MS,
+ fill: "both",
+ }
+ );
+ animation.onfinish = () => animation.cancel();
+ }
+
+ /**
+ * Used to simulate a change in the order. The element remains in the same
+ * DOM position.
+ *
+ * @param {HTMLLIElement} element - The row to animate.
+ * @param {number} to - The new Y position of the element after animation.
+ */
+ static _transitionTranslation(element, to) {
+ if (!reducedMotionMedia.matches) {
+ element.style.transition = `transform ${ANIMATION_DURATION_MS}ms`;
+ }
+ element.style.transform = to ? `translateY(${to}px)` : null;
+ }
+ }
+ customElements.define("orderable-tree-listbox", OrderableTreeListbox, {
+ extends: "ol",
+ });
+}
diff --git a/comm/mail/base/content/widgets/tree-selection.mjs b/comm/mail/base/content/widgets/tree-selection.mjs
new file mode 100644
index 0000000000..022af7316e
--- /dev/null
+++ b/comm/mail/base/content/widgets/tree-selection.mjs
@@ -0,0 +1,744 @@
+/* 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 implementation attempts to mimic the behavior of nsTreeSelection. In
+ * a few cases, this leads to potentially confusing actions. I attempt to note
+ * when we are doing this and why we do it.
+ *
+ * Unit test is in mail/base/test/unit/test_treeSelection.js
+ */
+export class TreeSelection {
+ QueryInterface = ChromeUtils.generateQI(["nsITreeSelection"]);
+
+ /**
+ * The current XULTreeElement, appropriately QueryInterfaced. May be null.
+ */
+ _tree;
+
+ /**
+ * Where the focus rectangle (that little dotted thing) shows up. Just
+ * because something is focused does not mean it is actually selected.
+ */
+ _currentIndex;
+ /**
+ * The view index where the shift is anchored when it is not (conceptually)
+ * the same as _currentIndex. This only happens when you perform a ranged
+ * selection. In that case, the start index of the ranged selection becomes
+ * the shift pivot (and the _currentIndex becomes the end of the ranged
+ * selection.)
+ * It gets cleared whenever the selection changes and it's not the result of
+ * a call to rangedSelect.
+ */
+ _shiftSelectPivot;
+ /**
+ * A list of [lowIndexInclusive, highIndexInclusive] non-overlapping,
+ * non-adjacent 'tuples' sort in ascending order.
+ */
+ _ranges;
+ /**
+ * The number of currently selected rows.
+ */
+ _count;
+
+ // In the case of the stand-alone message window, there's no tree, but
+ // there's a view.
+ _view;
+
+ /**
+ * A set of indices we think is invalid.
+ */
+ _invalidIndices;
+
+ constructor(tree) {
+ this._tree = tree;
+
+ this._currentIndex = null;
+ this._shiftSelectPivot = null;
+ this._ranges = [];
+ this._count = 0;
+ this._invalidIndices = new Set();
+
+ this._selectEventsSuppressed = false;
+ }
+
+ /**
+ * Mark the currently selected rows as invalid.
+ */
+ _invalidateSelection() {
+ for (let [low, high] of this._ranges) {
+ for (let i = low; i <= high; i++) {
+ this._invalidIndices.add(i);
+ }
+ }
+ }
+
+ /**
+ * Call `invalidateRow` on the tree for each row we think is invalid.
+ */
+ _doInvalidateRows() {
+ if (this.selectEventsSuppressed) {
+ return;
+ }
+ if (this._tree) {
+ for (let i of this._invalidIndices) {
+ this._tree.invalidateRow(i);
+ }
+ }
+ this._invalidIndices.clear();
+ }
+
+ /**
+ * Call `invalidateRange` on the tree.
+ *
+ * @param {number} startIndex - The first index to invalidate.
+ * @param {number?} endIndex - The last index to invalidate. If not given,
+ * defaults to the index of the last row.
+ */
+ _doInvalidateRange(startIndex, endIndex) {
+ let noEndIndex = endIndex === undefined;
+ if (noEndIndex) {
+ if (!this._view || this.view.rowCount == 0) {
+ this._doInvalidateAll();
+ return;
+ }
+ endIndex = this._view.rowCount - 1;
+ }
+ if (this._tree) {
+ this._tree.invalidateRange(startIndex, endIndex);
+ }
+ for (let i of this._invalidIndices) {
+ if (i >= startIndex && (noEndIndex || i <= endIndex)) {
+ this._invalidIndices.delete(i);
+ }
+ }
+ }
+
+ /**
+ * Call `invalidate` on the tree.
+ */
+ _doInvalidateAll() {
+ if (this._tree) {
+ this._tree.invalidate();
+ }
+ this._invalidIndices.clear();
+ }
+
+ get tree() {
+ return this._tree;
+ }
+ set tree(tree) {
+ this._tree = tree;
+ }
+
+ get view() {
+ return this._view;
+ }
+ set view(view) {
+ this._view = view;
+ }
+ /**
+ * Although the nsITreeSelection documentation doesn't say, what this method
+ * is supposed to do is check if the seltype attribute on the XUL tree is any
+ * of the following: "single" (only a single row may be selected at a time,
+ * "cell" (a single cell may be selected), or "text" (the row gets selected
+ * but only the primary column shows up as selected.)
+ *
+ * @returns false because we don't support single-selection.
+ */
+ get single() {
+ return false;
+ }
+
+ _updateCount() {
+ this._count = 0;
+ for (let [low, high] of this._ranges) {
+ this._count += high - low + 1;
+ }
+ }
+
+ get count() {
+ return this._count;
+ }
+
+ isSelected(viewIndex) {
+ for (let [low, high] of this._ranges) {
+ if (viewIndex >= low && viewIndex <= high) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Select the given row. It does nothing if that row was already selected.
+ */
+ select(viewIndex) {
+ this._invalidateSelection();
+ // current index will provide our effective shift pivot
+ this._shiftSelectPivot = null;
+ this._currentIndex = viewIndex != -1 ? viewIndex : null;
+
+ if (this._count == 1 && this._ranges[0][0] == viewIndex) {
+ return;
+ }
+
+ if (viewIndex >= 0) {
+ this._count = 1;
+ this._ranges = [[viewIndex, viewIndex]];
+ this._invalidIndices.add(viewIndex);
+ } else {
+ this._count = 0;
+ this._ranges = [];
+ }
+
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ }
+
+ timedSelect(index, delay) {
+ throw new Error("We do not implement timed selection.");
+ }
+
+ toggleSelect(index) {
+ this._currentIndex = index;
+ // If nothing's selected, select index
+ if (this._count == 0) {
+ this._count = 1;
+ this._ranges = [[index, index]];
+ } else {
+ let added = false;
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ // below the range? add it to the existing range or create a new one
+ if (index < low) {
+ this._count++;
+ // is it just below an existing range? (range fusion only happens in the
+ // high case, not here.)
+ if (index == low - 1) {
+ this._ranges[iTupe][0] = index;
+ added = true;
+ break;
+ }
+ // then it gets its own range
+ this._ranges.splice(iTupe, 0, [index, index]);
+ added = true;
+ break;
+ }
+ // in the range? will need to either nuke, shrink, or split the range to
+ // remove it
+ if (index >= low && index <= high) {
+ this._count--;
+ if (index == low && index == high) {
+ // nuke
+ this._ranges.splice(iTupe, 1);
+ } else if (index == low) {
+ // lower shrink
+ this._ranges[iTupe][0] = index + 1;
+ } else if (index == high) {
+ // upper shrink
+ this._ranges[iTupe][1] = index - 1;
+ } else {
+ // split
+ this._ranges.splice(iTupe, 1, [low, index - 1], [index + 1, high]);
+ }
+ added = true;
+ break;
+ }
+ // just above the range? fuse into the range, and possibly the next
+ // range up.
+ if (index == high + 1) {
+ this._count++;
+ // see if there is another range and there was just a gap of one between
+ // the two ranges.
+ if (
+ iTupe + 1 < this._ranges.length &&
+ this._ranges[iTupe + 1][0] == index + 1
+ ) {
+ // yes, merge the ranges
+ this._ranges.splice(iTupe, 2, [low, this._ranges[iTupe + 1][1]]);
+ added = true;
+ break;
+ }
+ // nope, no merge required, just update the range
+ this._ranges[iTupe][1] = index;
+ added = true;
+ break;
+ }
+ // otherwise we need to keep going
+ }
+
+ if (!added) {
+ this._count++;
+ this._ranges.push([index, index]);
+ }
+ }
+
+ this._invalidIndices.add(index);
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ }
+
+ /**
+ * @param rangeStart If omitted, it implies a shift-selection is happening,
+ * in which case we use _shiftSelectPivot as the start if we have it,
+ * _currentIndex if we don't, and if we somehow didn't have a
+ * _currentIndex, we use the range end.
+ * @param rangeEnd Just the inclusive end of the range.
+ * @param augment Does this set a new selection or should it be merged with
+ * the existing selection?
+ */
+ rangedSelect(rangeStart, rangeEnd, augment) {
+ if (rangeStart == -1) {
+ if (this._shiftSelectPivot != null) {
+ rangeStart = this._shiftSelectPivot;
+ } else if (this._currentIndex != null) {
+ rangeStart = this._currentIndex;
+ } else {
+ rangeStart = rangeEnd;
+ }
+ }
+
+ this._shiftSelectPivot = rangeStart;
+ this._currentIndex = rangeEnd;
+
+ // enforce our ordering constraint for our ranges
+ if (rangeStart > rangeEnd) {
+ [rangeStart, rangeEnd] = [rangeEnd, rangeStart];
+ }
+
+ // if we're not augmenting, then this is really easy.
+ if (!augment) {
+ this._invalidateSelection();
+
+ this._count = rangeEnd - rangeStart + 1;
+ this._ranges = [[rangeStart, rangeEnd]];
+
+ for (let i = rangeStart; i <= rangeEnd; i++) {
+ this._invalidIndices.add(i);
+ }
+
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ return;
+ }
+
+ // Iterate over our existing set of ranges, finding the 'range' of ranges
+ // that our new range overlaps or simply obviates.
+ // Overlap variables track blocks we need to keep some part of, Nuke
+ // variables are for blocks that get spliced out. For our purposes, all
+ // overlap blocks are also nuke blocks.
+ let lowOverlap, lowNuke, highNuke, highOverlap;
+ // in case there is no overlap, also figure an insertionPoint
+ let insertionPoint = this._ranges.length; // default to the end
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ // If it's completely include the range, it should be nuked
+ if (rangeStart <= low && rangeEnd >= high) {
+ if (lowNuke == null) {
+ // only the first one we see is the low one
+ lowNuke = iTupe;
+ }
+ highNuke = iTupe;
+ }
+ // If our new range start is inside a range or is adjacent, it's overlap
+ if (
+ rangeStart >= low - 1 &&
+ rangeStart <= high + 1 &&
+ lowOverlap == null
+ ) {
+ lowOverlap = lowNuke = highNuke = iTupe;
+ }
+ // If our new range ends inside a range or is adjacent, it's overlap
+ if (rangeEnd >= low - 1 && rangeEnd <= high + 1) {
+ highOverlap = highNuke = iTupe;
+ if (lowNuke == null) {
+ lowNuke = iTupe;
+ }
+ }
+
+ // we're done when no more overlap is possible
+ if (rangeEnd < low) {
+ insertionPoint = iTupe;
+ break;
+ }
+ }
+
+ if (lowOverlap != null) {
+ rangeStart = Math.min(rangeStart, this._ranges[lowOverlap][0]);
+ }
+ if (highOverlap != null) {
+ rangeEnd = Math.max(rangeEnd, this._ranges[highOverlap][1]);
+ }
+ if (lowNuke != null) {
+ this._ranges.splice(lowNuke, highNuke - lowNuke + 1, [
+ rangeStart,
+ rangeEnd,
+ ]);
+ } else {
+ this._ranges.splice(insertionPoint, 0, [rangeStart, rangeEnd]);
+ }
+ for (let i = rangeStart; i <= rangeEnd; i++) {
+ this._invalidIndices.add(i);
+ }
+
+ this._updateCount();
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ }
+
+ /**
+ * This is basically RangedSelect but without insertion of a new range and we
+ * don't need to worry about adjacency.
+ * Oddly, nsTreeSelection doesn't fire a selection changed event here...
+ */
+ clearRange(rangeStart, rangeEnd) {
+ // Iterate over our existing set of ranges, finding the 'range' of ranges
+ // that our clear range overlaps or simply obviates.
+ // Overlap variables track blocks we need to keep some part of, Nuke
+ // variables are for blocks that get spliced out. For our purposes, all
+ // overlap blocks are also nuke blocks.
+ let lowOverlap, lowNuke, highNuke, highOverlap;
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ // If we completely include the range, it should be nuked
+ if (rangeStart <= low && rangeEnd >= high) {
+ if (lowNuke == null) {
+ // only the first one we see is the low one
+ lowNuke = iTupe;
+ }
+ highNuke = iTupe;
+ }
+ // If our new range start is inside a range, it's nuke and maybe overlap
+ if (rangeStart >= low && rangeStart <= high && lowNuke == null) {
+ lowNuke = highNuke = iTupe;
+ // it's only overlap if we don't match at the low end
+ if (rangeStart > low) {
+ lowOverlap = iTupe;
+ }
+ }
+ // If our new range ends inside a range, it's nuke and maybe overlap
+ if (rangeEnd >= low && rangeEnd <= high) {
+ highNuke = iTupe;
+ // it's only overlap if we don't match at the high end
+ if (rangeEnd < high) {
+ highOverlap = iTupe;
+ }
+ if (lowNuke == null) {
+ lowNuke = iTupe;
+ }
+ }
+
+ // we're done when no more overlap is possible
+ if (rangeEnd < low) {
+ break;
+ }
+ }
+ // nothing to do since there's nothing to nuke
+ if (lowNuke == null) {
+ return;
+ }
+ let args = [lowNuke, highNuke - lowNuke + 1];
+ if (lowOverlap != null) {
+ args.push([this._ranges[lowOverlap][0], rangeStart - 1]);
+ }
+ if (highOverlap != null) {
+ args.push([rangeEnd + 1, this._ranges[highOverlap][1]]);
+ }
+ this._ranges.splice.apply(this._ranges, args);
+
+ for (let i = rangeStart; i <= rangeEnd; i++) {
+ this._invalidIndices.add(i);
+ }
+
+ this._updateCount();
+ this._doInvalidateRows();
+ // note! nsTreeSelection doesn't fire a selection changed event, so neither
+ // do we, but it seems like we should
+ }
+
+ /**
+ * nsTreeSelection always fires a select notification when the range is
+ * cleared, even if there is no effective chance in selection.
+ */
+ clearSelection() {
+ this._invalidateSelection();
+ this._shiftSelectPivot = null;
+ this._count = 0;
+ this._ranges = [];
+
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ }
+
+ /**
+ * Select all with no rows is a no-op, otherwise we select all and notify.
+ */
+ selectAll() {
+ if (!this._view) {
+ return;
+ }
+
+ let view = this._view;
+ let rowCount = view.rowCount;
+
+ // no-ops-ville
+ if (!rowCount) {
+ return;
+ }
+
+ this._count = rowCount;
+ this._ranges = [[0, rowCount - 1]];
+
+ this._doInvalidateAll();
+ this._fireSelectionChanged();
+ }
+
+ getRangeCount() {
+ return this._ranges.length;
+ }
+ getRangeAt(rangeIndex, minObj, maxObj) {
+ if (rangeIndex < 0 || rangeIndex >= this._ranges.length) {
+ throw new Error("Try a real range index next time.");
+ }
+ [minObj.value, maxObj.value] = this._ranges[rangeIndex];
+ }
+
+ /**
+ * Helper method to adjust points in the face of row additions/removal.
+ *
+ * @param point The point, null if there isn't one, or an index otherwise.
+ * @param deltaAt The row at which the change is happening.
+ * @param delta The number of rows added if positive, or the (negative)
+ * number of rows removed.
+ */
+ _adjustPoint(point, deltaAt, delta) {
+ // if there is no point, no change
+ if (point == null) {
+ return point;
+ }
+ // if the point is before the change, no change
+ if (point < deltaAt) {
+ return point;
+ }
+ // if it's a deletion and it includes the point, clear it
+ if (delta < 0 && point >= deltaAt && point + delta < deltaAt) {
+ return null;
+ }
+ // (else) the point is at/after the change, compensate
+ return point + delta;
+ }
+ /**
+ * Find the index of the range, if any, that contains the given index, and
+ * the index at which to insert a range if one does not exist.
+ *
+ * @returns A tuple containing: 1) the index if there is one, null otherwise,
+ * 2) the index at which to insert a range that would contain the point.
+ */
+ _findRangeContainingRow(index) {
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ if (index >= low && index <= high) {
+ return [iTupe, iTupe];
+ }
+ if (index < low) {
+ return [null, iTupe];
+ }
+ }
+ return [null, this._ranges.length];
+ }
+
+ /**
+ * When present, a list of calls made to adjustSelection. See
+ * |logAdjustSelectionForReplay| and |replayAdjustSelectionLog|.
+ */
+ _adjustSelectionLog = null;
+ /**
+ * Start logging calls to adjustSelection made against this instance. You
+ * would do this because you are replacing an existing selection object
+ * with this instance for the purposes of creating a transient selection.
+ * Of course, you want the original selection object to be up-to-date when
+ * you go to put it back, so then you can call replayAdjustSelectionLog
+ * with that selection object and everything will be peachy.
+ */
+ logAdjustSelectionForReplay() {
+ this._adjustSelectionLog = [];
+ }
+ /**
+ * Stop logging calls to adjustSelection and replay the existing log against
+ * selection.
+ *
+ * @param selection {nsITreeSelection}.
+ */
+ replayAdjustSelectionLog(selection) {
+ if (this._adjustSelectionLog.length) {
+ // Temporarily disable selection events because adjustSelection is going
+ // to generate an event each time otherwise, and better 1 event than
+ // many.
+ selection.selectEventsSuppressed = true;
+ for (let [index, count] of this._adjustSelectionLog) {
+ selection.adjustSelection(index, count);
+ }
+ selection.selectEventsSuppressed = false;
+ }
+ this._adjustSelectionLog = null;
+ }
+
+ adjustSelection(index, count) {
+ // nothing to do if there is no actual change
+ if (!count) {
+ return;
+ }
+
+ if (this._adjustSelectionLog) {
+ this._adjustSelectionLog.push([index, count]);
+ }
+
+ // adjust our points
+ this._shiftSelectPivot = this._adjustPoint(
+ this._shiftSelectPivot,
+ index,
+ count
+ );
+ this._currentIndex = this._adjustPoint(this._currentIndex, index, count);
+
+ // If we are adding rows, we want to split any range at index and then
+ // translate all of the ranges above that point up.
+ if (count > 0) {
+ let [iContain, iInsert] = this._findRangeContainingRow(index);
+ if (iContain != null) {
+ let [low, high] = this._ranges[iContain];
+ // if it is the low value, we just want to shift the range entirely, so
+ // do nothing (and keep iInsert pointing at it for translation)
+ // if it is not the low value, then there must be at least two values so
+ // we should split it and only translate the new/upper block
+ if (index != low) {
+ this._ranges.splice(iContain, 1, [low, index - 1], [index, high]);
+ iInsert++;
+ }
+ }
+ // now translate everything from iInsert on up
+ for (let iTrans = iInsert; iTrans < this._ranges.length; iTrans++) {
+ let [low, high] = this._ranges[iTrans];
+ this._ranges[iTrans] = [low + count, high + count];
+ }
+ // invalidate and fire selection change notice
+ this._doInvalidateRange(index);
+ this._fireSelectionChanged();
+ return;
+ }
+
+ // If we are removing rows, we are basically clearing the range that is
+ // getting deleted and translating everyone above the remaining point
+ // downwards. The one trick is we may have to merge the lowest translated
+ // block.
+ let saveSuppress = this.selectEventsSuppressed;
+ this.selectEventsSuppressed = true;
+ this.clearRange(index, index - count - 1);
+ // translate
+ let iTrans = this._findRangeContainingRow(index)[1];
+ for (; iTrans < this._ranges.length; iTrans++) {
+ let [low, high] = this._ranges[iTrans];
+ // for the first range, low may be below the index, in which case it
+ // should not get translated
+ this._ranges[iTrans] = [low >= index ? low + count : low, high + count];
+ }
+ // we may have to merge the lowest translated block because it may now be
+ // adjacent to the previous block
+ if (
+ iTrans > 0 &&
+ iTrans < this._ranges.length &&
+ this._ranges[iTrans - 1][1] == this._ranges[iTrans][0]
+ ) {
+ this._ranges[iTrans - 1][1] = this._ranges[iTrans][1];
+ this._ranges.splice(iTrans, 1);
+ }
+
+ this._doInvalidateRange(index);
+ this.selectEventsSuppressed = saveSuppress;
+ }
+
+ get selectEventsSuppressed() {
+ return this._selectEventsSuppressed;
+ }
+ /**
+ * Control whether selection events are suppressed. For consistency with
+ * nsTreeSelection, we always generate a selection event when a value of
+ * false is assigned, even if the value was already false.
+ */
+ set selectEventsSuppressed(suppress) {
+ if (this._selectEventsSuppressed == suppress) {
+ return;
+ }
+
+ this._selectEventsSuppressed = suppress;
+ if (!suppress) {
+ this._fireSelectionChanged();
+ }
+ }
+
+ /**
+ * Note that we bypass any XUL "onselect" handler that may exist and go
+ * straight to the view. If you have a tree, you shouldn't be using us,
+ * so this seems aboot right.
+ */
+ _fireSelectionChanged() {
+ // don't fire if we are suppressed; we will fire when un-suppressed
+ if (this.selectEventsSuppressed) {
+ return;
+ }
+ let view = this._tree?.view ?? this._view;
+
+ // We might not have a view if we're in the middle of setting up things
+ view?.selectionChanged();
+ }
+
+ get currentIndex() {
+ if (this._currentIndex == null) {
+ return -1;
+ }
+ return this._currentIndex;
+ }
+ /**
+ * Sets the current index. Other than updating the variable, this just
+ * invalidates the tree row if we have a tree.
+ * The real selection object would send a DOM event we don't care about.
+ */
+ set currentIndex(index) {
+ if (index == this.currentIndex) {
+ return;
+ }
+
+ this._invalidateSelection();
+ this._currentIndex = index != -1 ? index : null;
+ this._invalidIndices.add(index);
+ this._doInvalidateRows();
+ }
+
+ get shiftSelectPivot() {
+ return this._shiftSelectPivot != null ? this._shiftSelectPivot : -1;
+ }
+
+ /*
+ * Functions after this aren't part of the nsITreeSelection interface.
+ */
+
+ /**
+ * Duplicate this selection on another nsITreeSelection. This is useful
+ * when you would like to discard this selection for a real tree selection.
+ * We assume that both selections are for the same tree.
+ *
+ * @note We don't transfer the correct shiftSelectPivot over.
+ * @note This will fire a selectionChanged event on the tree view.
+ *
+ * @param selection an nsITreeSelection to duplicate this selection onto
+ */
+ duplicateSelection(selection) {
+ selection.selectEventsSuppressed = true;
+ selection.clearSelection();
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ selection.rangedSelect(low, high, iTupe > 0);
+ }
+
+ selection.currentIndex = this.currentIndex;
+ // This will fire a selectionChanged event
+ selection.selectEventsSuppressed = false;
+ }
+}
diff --git a/comm/mail/base/content/widgets/tree-view.mjs b/comm/mail/base/content/widgets/tree-view.mjs
new file mode 100644
index 0000000000..aef622fa27
--- /dev/null
+++ b/comm/mail/base/content/widgets/tree-view.mjs
@@ -0,0 +1,2633 @@
+/* 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 { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+import { TreeSelection } from "chrome://messenger/content/tree-selection.mjs";
+
+// Account for the mac OS accelerator key variation.
+// Use these strings to check keyboard event properties.
+const accelKeyName = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey";
+const otherKeyName = AppConstants.platform == "macosx" ? "ctrlKey" : "metaKey";
+
+const ANIMATION_DURATION_MS = 200;
+const reducedMotionMedia = matchMedia("(prefers-reduced-motion)");
+
+/**
+ * Main tree view container that takes care of generating the main scrollable
+ * DIV and the tree table.
+ */
+class TreeView extends HTMLElement {
+ static observedAttributes = ["rows"];
+
+ /**
+ * The number of rows on either side to keep of the visible area to keep in
+ * memory in order to avoid visible blank spaces while the user scrolls.
+ *
+ * This member is visible for testing and should not be used outside of this
+ * class in production code.
+ *
+ * @type {integer}
+ */
+ _toleranceSize = 0;
+
+ /**
+ * Set the size of the tolerance buffer based on the number of rows which can
+ * be visible at once.
+ */
+ #calculateToleranceBufferSize() {
+ this._toleranceSize = this.#calculateVisibleRowCount() * 2;
+ }
+
+ /**
+ * Index of the first row that exists in the DOM. Includes rows in the
+ * tolerance buffer if they have been added.
+ *
+ * @type {integer}
+ */
+ #firstBufferRowIndex = 0;
+
+ /**
+ * Index of the last row that exists in the DOM. Includes rows in the
+ * tolerance buffer if they have been added.
+ *
+ * @type {integer}
+ */
+ #lastBufferRowIndex = 0;
+
+ /**
+ * Index of the first visible row.
+ *
+ * @type {integer}
+ */
+ #firstVisibleRowIndex = 0;
+
+ /**
+ * Index of the last visible row.
+ *
+ * @type {integer}
+ */
+ #lastVisibleRowIndex = 0;
+
+ /**
+ * Row indices mapped to the row elements that exist in the DOM.
+ *
+ * @type {Map<integer, HTMLTableRowElement>}
+ */
+ _rows = new Map();
+
+ /**
+ * The current view.
+ *
+ * @type {nsITreeView}
+ */
+ _view = null;
+
+ /**
+ * The current selection.
+ *
+ * @type {nsITreeSelection}
+ */
+ _selection = null;
+
+ /**
+ * The function storing the timeout callback for the delayed select feature in
+ * order to clear it when not needed.
+ *
+ * @type {integer}
+ */
+ _selectTimeout = null;
+
+ /**
+ * A handle to the callback to fill the buffer when we aren't busy painting.
+ *
+ * @type {number}
+ */
+ #bufferFillIdleCallbackHandle = null;
+
+ /**
+ * The virtualized table containing our rows.
+ *
+ * @type {TreeViewTable}
+ */
+ table = null;
+
+ /**
+ * An event to fire to indicate the work of filling the buffer is complete.
+ * This will fire once both visible and tolerance rows are ready. It will also
+ * fire if no change to the buffer is required.
+ *
+ * This member is visible in order to provide a reliable indicator to tests
+ * that all expected rows should be in place. It should not be used in
+ * production code.
+ *
+ * @type {Event}
+ */
+ _rowBufferReadyEvent = null;
+
+ /**
+ * Fire the provided event, if any, in order to indicate that any necessary
+ * buffer modification work is complete, including if no work is necessary.
+ */
+ #dispatchRowBufferReadyEvent() {
+ // Don't fire if we're currently waiting on buffer fills; let the callback
+ // do that when it's finished.
+ if (this._rowBufferReadyEvent && !this.#bufferFillIdleCallbackHandle) {
+ this.dispatchEvent(this._rowBufferReadyEvent);
+ }
+ }
+
+ /**
+ * Determine the height of the visible row area, excluding any chrome which
+ * covers elements.
+ *
+ * WARNING: This may cause synchronous reflow if used after modifying the DOM.
+ *
+ * @returns {integer} - The height of the area into which visible rows are
+ * rendered.
+ */
+ #calculateVisibleHeight() {
+ // Account for the table header height in a sticky position above the body.
+ return this.clientHeight - this.table.header.clientHeight;
+ }
+
+ /**
+ * Determine how many rows are visible in the client presently.
+ *
+ * WARNING: This may cause synchronous reflow if used after modifying the DOM.
+ *
+ * @returns {integer} - The number of visible or partly-visible rows.
+ */
+ #calculateVisibleRowCount() {
+ return Math.ceil(
+ this.#calculateVisibleHeight() / this._rowElementClass.ROW_HEIGHT
+ );
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ // Prevent this element from being part of the roving tab focus since we
+ // handle that independently for the TreeViewTableBody and we don't want any
+ // interference from this.
+ this.tabIndex = -1;
+ this.classList.add("tree-view-scrollable-container");
+
+ this.table = document.createElement("table", { is: "tree-view-table" });
+ this.appendChild(this.table);
+
+ this.placeholder = this.querySelector(`slot[name="placeholders"]`);
+
+ this.addEventListener("scroll", this);
+
+ let lastHeight = 0;
+ this.resizeObserver = new ResizeObserver(entries => {
+ // The width of the table isn't important to virtualizing the table. Skip
+ // updating if the height hasn't changed.
+ if (this.clientHeight == lastHeight) {
+ this.#dispatchRowBufferReadyEvent();
+ return;
+ }
+
+ if (!this._rowElementClass) {
+ this.#dispatchRowBufferReadyEvent();
+ return;
+ }
+
+ // The number of rows in the tolerance buffer is based on the number of
+ // rows which can be visible. Update it.
+ this.#calculateToleranceBufferSize();
+
+ // There's not much point in reducing the number of rows on resize. Scroll
+ // height remains the same and we can retain the extra rows in the buffer.
+ if (this.clientHeight > lastHeight) {
+ this._ensureVisibleRowsAreDisplayed();
+ } else {
+ this.#dispatchRowBufferReadyEvent();
+ }
+
+ lastHeight = this.clientHeight;
+ });
+ this.resizeObserver.observe(this);
+ }
+
+ disconnectedCallback() {
+ this.#resetRowBuffer();
+ this.resizeObserver.disconnect();
+ }
+
+ attributeChangedCallback(attrName, oldValue, newValue) {
+ this._rowElementName = newValue || "tree-view-table-row";
+ this._rowElementClass = customElements.get(this._rowElementName);
+
+ this.#calculateToleranceBufferSize();
+
+ if (this._view) {
+ this.reset();
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keyup": {
+ if (
+ ["Tab", "F6"].includes(event.key) &&
+ this.currentIndex == -1 &&
+ this._view?.rowCount
+ ) {
+ let selectionChanged = false;
+ if (this.selectedIndex == -1) {
+ this._selection.select(0);
+ selectionChanged = true;
+ }
+ this.currentIndex = this.selectedIndex;
+ if (selectionChanged) {
+ this.onSelectionChanged();
+ }
+ }
+ break;
+ }
+ case "click": {
+ if (event.button !== 0) {
+ return;
+ }
+
+ let row = event.target.closest(`tr[is="${this._rowElementName}"]`);
+ if (!row) {
+ return;
+ }
+
+ let index = row.index;
+
+ if (event.target.classList.contains("tree-button-thread")) {
+ if (this._view.isContainerOpen(index)) {
+ let children = 0;
+ for (
+ let i = index + 1;
+ i < this._view.rowCount && this._view.getLevel(i) > 0;
+ i++
+ ) {
+ children++;
+ }
+ this._selectRange(index, index + children, event[accelKeyName]);
+ } else {
+ let addedRows = this.expandRowAtIndex(index);
+ this._selectRange(index, index + addedRows, event[accelKeyName]);
+ }
+ this.table.body.focus();
+ return;
+ }
+
+ if (this._view.isContainer(index) && event.target.closest(".twisty")) {
+ if (this._view.isContainerOpen(index)) {
+ this.collapseRowAtIndex(index);
+ } else {
+ let addedRows = this.expandRowAtIndex(index);
+ this.scrollToIndex(
+ index + Math.min(addedRows, this.#calculateVisibleRowCount() - 1)
+ );
+ }
+ this.table.body.focus();
+ return;
+ }
+
+ // Handle the click as a CTRL extension if it happens on the checkbox
+ // image inside the selection column.
+ if (event.target.classList.contains("tree-view-row-select-checkbox")) {
+ if (event.shiftKey) {
+ this._selectRange(-1, index, event[accelKeyName]);
+ } else {
+ this._toggleSelected(index);
+ }
+ this.table.body.focus();
+ return;
+ }
+
+ if (event.target.classList.contains("tree-button-request-delete")) {
+ this.table.body.dispatchEvent(
+ new CustomEvent("request-delete", {
+ bubbles: true,
+ detail: {
+ index,
+ },
+ })
+ );
+ this.table.body.focus();
+ return;
+ }
+
+ if (event.target.classList.contains("tree-button-flag")) {
+ this.table.body.dispatchEvent(
+ new CustomEvent("toggle-flag", {
+ bubbles: true,
+ detail: {
+ isFlagged: row.dataset.properties.includes("flagged"),
+ index,
+ },
+ })
+ );
+ this.table.body.focus();
+ return;
+ }
+
+ if (event.target.classList.contains("tree-button-unread")) {
+ this.table.body.dispatchEvent(
+ new CustomEvent("toggle-unread", {
+ bubbles: true,
+ detail: {
+ isUnread: row.dataset.properties.includes("unread"),
+ index,
+ },
+ })
+ );
+ this.table.body.focus();
+ return;
+ }
+
+ if (event.target.classList.contains("tree-button-spam")) {
+ this.table.body.dispatchEvent(
+ new CustomEvent("toggle-spam", {
+ bubbles: true,
+ detail: {
+ isJunk: row.dataset.properties.split(" ").includes("junk"),
+ index,
+ },
+ })
+ );
+ this.table.body.focus();
+ return;
+ }
+
+ if (event[accelKeyName] && !event.shiftKey) {
+ this._toggleSelected(index);
+ } else if (event.shiftKey) {
+ this._selectRange(-1, index, event[accelKeyName]);
+ } else {
+ this._selectSingle(index);
+ }
+
+ this.table.body.focus();
+ break;
+ }
+ case "keydown": {
+ if (event.altKey || event[otherKeyName]) {
+ return;
+ }
+
+ let currentIndex = this.currentIndex == -1 ? 0 : this.currentIndex;
+ let newIndex;
+ switch (event.key) {
+ case "ArrowUp":
+ newIndex = currentIndex - 1;
+ break;
+ case "ArrowDown":
+ newIndex = currentIndex + 1;
+ break;
+ case "ArrowLeft":
+ case "ArrowRight": {
+ event.preventDefault();
+ if (this.currentIndex == -1) {
+ return;
+ }
+ let isArrowRight = event.key == "ArrowRight";
+ let isRTL = this.matches(":dir(rtl)");
+ if (isArrowRight == isRTL) {
+ // Collapse action.
+ let currentLevel = this._view.getLevel(this.currentIndex);
+ if (this._view.isContainerOpen(this.currentIndex)) {
+ this.collapseRowAtIndex(this.currentIndex);
+ return;
+ } else if (currentLevel == 0) {
+ return;
+ }
+
+ let parentIndex = this._view.getParentIndex(this.currentIndex);
+ if (parentIndex != -1) {
+ newIndex = parentIndex;
+ }
+ } else if (this._view.isContainer(this.currentIndex)) {
+ // Expand action.
+ if (!this._view.isContainerOpen(this.currentIndex)) {
+ let addedRows = this.expandRowAtIndex(this.currentIndex);
+ this.scrollToIndex(
+ this.currentIndex +
+ Math.min(addedRows, this.#calculateVisibleRowCount() - 1)
+ );
+ } else {
+ newIndex = this.currentIndex + 1;
+ }
+ }
+ if (newIndex != undefined) {
+ this._selectSingle(newIndex);
+ }
+ return;
+ }
+ case "Home":
+ newIndex = 0;
+ break;
+ case "End":
+ newIndex = this._view.rowCount - 1;
+ break;
+ case "PageUp":
+ newIndex = Math.max(
+ 0,
+ currentIndex - this.#calculateVisibleRowCount()
+ );
+ break;
+ case "PageDown":
+ newIndex = Math.min(
+ this._view.rowCount - 1,
+ currentIndex + this.#calculateVisibleRowCount()
+ );
+ break;
+ }
+
+ if (newIndex != undefined) {
+ newIndex = this._clampIndex(newIndex);
+ if (newIndex != null) {
+ if (event[accelKeyName] && !event.shiftKey) {
+ // Change focus, but not selection.
+ this.currentIndex = newIndex;
+ } else if (event.shiftKey) {
+ this._selectRange(-1, newIndex, event[accelKeyName]);
+ } else {
+ this._selectSingle(newIndex, true);
+ }
+ }
+ event.preventDefault();
+ return;
+ }
+
+ // Space bar keystroke selection toggling.
+ if (event.key == " " && this.currentIndex != -1) {
+ // Don't do anything if we're on macOS and the target row is already
+ // selected.
+ if (
+ AppConstants.platform == "macosx" &&
+ this._selection.isSelected(this.currentIndex)
+ ) {
+ return;
+ }
+
+ // Handle the macOS exception of toggling the selection with only
+ // the space bar since CMD+Space is captured by the OS.
+ if (event[accelKeyName] || AppConstants.platform == "macosx") {
+ this._toggleSelected(this.currentIndex);
+ event.preventDefault();
+ } else if (!this._selection.isSelected(this.currentIndex)) {
+ // The target row is not currently selected.
+ this._selectSingle(this.currentIndex, true);
+ event.preventDefault();
+ }
+ }
+ break;
+ }
+ case "scroll":
+ this._ensureVisibleRowsAreDisplayed();
+ break;
+ }
+ }
+
+ /**
+ * The current view for this list.
+ *
+ * @type {nsITreeView}
+ */
+ get view() {
+ return this._view;
+ }
+
+ set view(view) {
+ this._selection = null;
+ if (this._view) {
+ this._view.setTree(null);
+ this._view.selection = null;
+ }
+ if (this._selection) {
+ this._selection.view = null;
+ }
+
+ this._view = view;
+ if (view) {
+ try {
+ this._selection = new TreeSelection();
+ this._selection.tree = this;
+ this._selection.view = view;
+
+ view.selection = this._selection;
+ view.setTree(this);
+ } catch (ex) {
+ // This isn't a XULTreeElement, and we can't make it one, so if the
+ // `setTree` call crosses XPCOM, an exception will be thrown.
+ if (ex.result != Cr.NS_ERROR_XPC_BAD_CONVERT_JS) {
+ throw ex;
+ }
+ }
+ }
+
+ // Clear the height of the top spacer to avoid confusing
+ // `_ensureVisibleRowsAreDisplayed`.
+ this.table.spacerTop.setHeight(0);
+ this.reset();
+
+ this.dispatchEvent(new CustomEvent("viewchange"));
+ }
+
+ /**
+ * Set the colspan of the spacer row cells.
+ *
+ * @param {int} count - The amount of visible columns.
+ */
+ setSpacersColspan(count) {
+ // Add an extra column if the table is editable to account for the column
+ // picker column.
+ if (this.parentNode.editable) {
+ count++;
+ }
+ this.table.spacerTop.setColspan(count);
+ this.table.spacerBottom.setColspan(count);
+ }
+
+ /**
+ * Clear all rows from the buffer, empty the table body, and reset spacers.
+ */
+ #resetRowBuffer() {
+ this.#cancelToleranceFillCallback();
+ this.table.body.replaceChildren();
+ this._rows.clear();
+ this.#firstBufferRowIndex = 0;
+ this.#lastBufferRowIndex = 0;
+ this.#firstVisibleRowIndex = 0;
+
+ // Set the height of the bottom spacer to account for the now-missing rows.
+ // We want to ensure that the overall scroll height does not decrease.
+ // Otherwise, we may lose our scroll position and cause unnecessary
+ // scrolling. However, we don't always want to change the height of the top
+ // spacer for the same reason.
+ let rowCount = this._view?.rowCount ?? 0;
+ this.table.spacerBottom.setHeight(
+ rowCount * this._rowElementClass.ROW_HEIGHT
+ );
+ }
+
+ /**
+ * Clear all rows from the list and create them again.
+ */
+ reset() {
+ this.#resetRowBuffer();
+ this._ensureVisibleRowsAreDisplayed();
+ }
+
+ /**
+ * Updates all existing rows in place, without removing all the rows and
+ * starting again. This can be used if the row element class hasn't changed
+ * and its `index` setter is capable of handling any modifications required.
+ */
+ invalidate() {
+ this.invalidateRange(this.#firstBufferRowIndex, this.#lastBufferRowIndex);
+ }
+
+ /**
+ * Perform the actions necessary to invalidate the specified row. Implemented
+ * separately to allow {@link invalidateRange} to handle testing event fires
+ * on its own.
+ *
+ * @param {integer} index
+ */
+ #doInvalidateRow(index) {
+ const rowCount = this._view?.rowCount ?? 0;
+ let row = this.getRowAtIndex(index);
+ if (row) {
+ if (index >= rowCount) {
+ this._removeRowAtIndex(index);
+ } else {
+ row.index = index;
+ row.selected = this._selection.isSelected(index);
+ }
+ } else if (
+ index >= this.#firstBufferRowIndex &&
+ index <= Math.min(rowCount - 1, this.#lastBufferRowIndex)
+ ) {
+ this._addRowAtIndex(index);
+ }
+ }
+
+ /**
+ * Invalidate the rows between `startIndex` and `endIndex`.
+ *
+ * @param {integer} startIndex
+ * @param {integer} endIndex
+ */
+ invalidateRange(startIndex, endIndex) {
+ for (
+ let index = Math.max(startIndex, this.#firstBufferRowIndex),
+ last = Math.min(endIndex, this.#lastBufferRowIndex);
+ index <= last;
+ index++
+ ) {
+ this.#doInvalidateRow(index);
+ }
+ this._ensureVisibleRowsAreDisplayed();
+ }
+
+ /**
+ * Invalidate the row at `index` in place. If `index` refers to a row that
+ * should exist but doesn't (because the row count increased), adds a row.
+ * If `index` refers to a row that does exist but shouldn't (because the
+ * row count decreased), removes it.
+ *
+ * @param {integer} index
+ */
+ invalidateRow(index) {
+ this.#doInvalidateRow(index);
+ this.#dispatchRowBufferReadyEvent();
+ }
+
+ /**
+ * A contiguous range, inclusive of both extremes.
+ *
+ * @typedef InclusiveRange
+ * @property {integer} first - The inclusive start of the range.
+ * @property {integer} last - The inclusive end of the range.
+ */
+
+ /**
+ * Calculate the range of rows we wish to have in a filled tolerance buffer
+ * based on a given range of visible rows.
+ *
+ * @param {integer} firstVisibleRow - The first visible row in the range.
+ * @param {integer} lastVisibleRow - The last visible row in the range.
+ * @param {integer} dataRowCount - The total number of available rows in the
+ * source data.
+ * @returns {InclusiveRange} - The full range of the desired buffer.
+ */
+ #calculateDesiredBufferRange(firstVisibleRow, lastVisibleRow, dataRowCount) {
+ const desiredRowRange = {};
+
+ desiredRowRange.first = Math.max(firstVisibleRow - this._toleranceSize, 0);
+ desiredRowRange.last = Math.min(
+ lastVisibleRow + this._toleranceSize,
+ dataRowCount - 1
+ );
+
+ return desiredRowRange;
+ }
+
+ #createToleranceFillCallback() {
+ // Don't schedule a new buffer fill callback if we already have one.
+ if (!this.#bufferFillIdleCallbackHandle) {
+ this.#bufferFillIdleCallbackHandle = requestIdleCallback(deadline =>
+ this.#fillToleranceBuffer(deadline)
+ );
+ }
+ }
+
+ #cancelToleranceFillCallback() {
+ cancelIdleCallback(this.#bufferFillIdleCallbackHandle);
+ this.#bufferFillIdleCallbackHandle = null;
+ }
+
+ /**
+ * Fill the buffer with tolerance rows above and below the visible rows.
+ *
+ * As fetching data and modifying the DOM is expensive, this is intended to be
+ * run within an idle callback and includes management of the idle callback
+ * handle and creation of further callbacks if work is not completed.
+ *
+ * @param {IdleDeadline} deadline - A deadline object for fetching the
+ * remaining time in the idle tick.
+ */
+ #fillToleranceBuffer(deadline) {
+ this.#bufferFillIdleCallbackHandle = null;
+
+ const rowCount = this._view?.rowCount ?? 0;
+ if (!rowCount) {
+ return;
+ }
+
+ const bufferRange = this.#calculateDesiredBufferRange(
+ this.#firstVisibleRowIndex,
+ this.#lastVisibleRowIndex,
+ rowCount
+ );
+
+ // Set the amount of time to leave in the deadline to fill another row. In
+ // order to cooperatively schedule work, we shouldn't overrun the time
+ // allotted for the idle tick. This value should be set such that it leaves
+ // enough time to perform another row fill and adjust the relevant spacer
+ // while doing the maximal amount of work per callback.
+ const MS_TO_LEAVE_PER_FILL = 1.25;
+
+ // Fill in the beginning of the buffer.
+ if (bufferRange.first < this.#firstBufferRowIndex) {
+ for (
+ let i = this.#firstBufferRowIndex - 1;
+ i >= bufferRange.first &&
+ deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL;
+ i--
+ ) {
+ this._addRowAtIndex(i, this.table.body.firstElementChild);
+
+ // Update as we go in case we need to wait for the next idle.
+ this.#firstBufferRowIndex = i;
+ }
+
+ // Adjust the height of the top spacer to account for the new rows we've
+ // added.
+ this.table.spacerTop.setHeight(
+ this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT
+ );
+
+ // If we haven't completed the work of filling the tolerance buffer,
+ // schedule a new job to do so.
+ if (this.#firstBufferRowIndex != bufferRange.first) {
+ this.#createToleranceFillCallback();
+ return;
+ }
+ }
+
+ // Fill in the end of the buffer.
+ if (bufferRange.last > this.#lastBufferRowIndex) {
+ for (
+ let i = this.#lastBufferRowIndex + 1;
+ i <= bufferRange.last &&
+ deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL;
+ i++
+ ) {
+ this._addRowAtIndex(i);
+
+ // Update as we go in case we need to wait for the next idle.
+ this.#lastBufferRowIndex = i;
+ }
+
+ // Adjust the height of the bottom spacer to account for the new rows
+ // we've added.
+ this.table.spacerBottom.setHeight(
+ (rowCount - 1 - this.#lastBufferRowIndex) *
+ this._rowElementClass.ROW_HEIGHT
+ );
+
+ // If we haven't completed the work of filling the tolerance buffer,
+ // schedule a new job to do so.
+ if (this.#lastBufferRowIndex != bufferRange.last) {
+ this.#createToleranceFillCallback();
+ return;
+ }
+ }
+
+ // Notify tests that we have finished work.
+ this.#dispatchRowBufferReadyEvent();
+ }
+
+ /**
+ * The calculated ranges which determine the shape of the row buffer at
+ * various stages of processing.
+ *
+ * @typedef RowBufferRanges
+ * @property {InclusiveRange} visibleRows - The range of rows which should be
+ * displayed to the user.
+ * @property {integer?} pruneBefore - The index of the row before which any
+ * additional rows should be discarded.
+ * @property {integer?} pruneAfter - The index of the row after which any
+ * additional rows should be discarded.
+ * @property {InclusiveRange} finalizedRows - The range of rows which should
+ * exist in the row buffer after any additions and removals have been made.
+ */
+
+ /**
+ * Calculate the values necessary for building the list of visible rows and
+ * retaining any rows in the buffer which fall inside the desired tolerance
+ * and form a contiguous range with the visible rows.
+ *
+ * WARNING: This function makes calculations based on existing DOM dimensions.
+ * Do not use it after you have modified the DOM.
+ *
+ * @returns {RowBufferRanges}
+ */
+ #calculateRowBufferRanges(dataRowCount) {
+ /** @type {RowBufferRanges} */
+ const ranges = {
+ visibleRows: {},
+ pruneBefore: null,
+ pruneAfter: null,
+ finalizedRows: {},
+ };
+
+ // We adjust the row buffer in several stages. First, we'll use the new
+ // scroll position to determine the boundaries of the buffer. Then, we'll
+ // create and add any new rows which are necessary to fit the new
+ // boundaries. Next, we prune rows added in previous scrolls which now fall
+ // outside the boundaries. Finally, we recalculate the height of the spacers
+ // which position the visible rows within the rendered area.
+ ranges.visibleRows.first = Math.max(
+ Math.floor(this.scrollTop / this._rowElementClass.ROW_HEIGHT),
+ 0
+ );
+
+ const lastPossibleVisibleRow = Math.ceil(
+ (this.scrollTop + this.#calculateVisibleHeight()) /
+ this._rowElementClass.ROW_HEIGHT
+ );
+
+ ranges.visibleRows.last =
+ Math.min(lastPossibleVisibleRow, dataRowCount) - 1;
+
+ // Determine the number of rows desired in the tolerance buffer in order to
+ // determine whether there are any that we can save.
+ const desiredRowRange = this.#calculateDesiredBufferRange(
+ ranges.visibleRows.first,
+ ranges.visibleRows.last,
+ dataRowCount
+ );
+
+ // Determine which rows are no longer wanted in the buffer. If we've
+ // scrolled past the previous visible rows, it's possible that the tolerance
+ // buffer will still contain some rows we'd like to have in the buffer. Note
+ // that we insist on a contiguous range of rows in the buffer to simplify
+ // determining which rows exist and appropriately spacing the viewport.
+ if (this.#lastBufferRowIndex < ranges.visibleRows.first) {
+ // There is a discontiguity between the visible rows and anything that's
+ // in the buffer. Prune everything before the visible rows.
+ ranges.pruneBefore = ranges.visibleRows.first;
+ ranges.finalizedRows.first = ranges.visibleRows.first;
+ } else if (this.#firstBufferRowIndex < desiredRowRange.first) {
+ // The range of rows in the buffer overlaps the start of the visible rows,
+ // but there are rows outside of the desired buffer as well. Prune them.
+ ranges.pruneBefore = desiredRowRange.first;
+ ranges.finalizedRows.first = desiredRowRange.first;
+ } else {
+ // Determine the beginning of the finalized buffer based on whether the
+ // buffer contains rows before the start of the visible rows.
+ ranges.finalizedRows.first = Math.min(
+ ranges.visibleRows.first,
+ this.#firstBufferRowIndex
+ );
+ }
+
+ if (this.#firstBufferRowIndex > ranges.visibleRows.last) {
+ // There is a discontiguity between the visible rows and anything that's
+ // in the buffer. Prune everything after the visible rows.
+ ranges.pruneAfter = ranges.visibleRows.last;
+ ranges.finalizedRows.last = ranges.visibleRows.last;
+ } else if (this.#lastBufferRowIndex > desiredRowRange.last) {
+ // The range of rows in the buffer overlaps the end of the visible rows,
+ // but there are rows outside of the desired buffer as well. Prune them.
+ ranges.pruneAfter = desiredRowRange.last;
+ ranges.finalizedRows.last = desiredRowRange.last;
+ } else {
+ // Determine the end of the finalized buffer based on whether the buffer
+ // contains rows after the end of the visible rows.
+ ranges.finalizedRows.last = Math.max(
+ ranges.visibleRows.last,
+ this.#lastBufferRowIndex
+ );
+ }
+
+ return ranges;
+ }
+
+ /**
+ * Display the table rows which should be shown in the visible area and
+ * request filling of the tolerance buffer when idle.
+ */
+ _ensureVisibleRowsAreDisplayed() {
+ this.#cancelToleranceFillCallback();
+
+ let rowCount = this._view?.rowCount ?? 0;
+ this.placeholder?.classList.toggle("show", !rowCount);
+
+ if (!rowCount || this.#calculateVisibleRowCount() == 0) {
+ return;
+ }
+
+ if (this.scrollTop > rowCount * this._rowElementClass.ROW_HEIGHT) {
+ // Beyond the end of the list. We're about to scroll anyway, so clear
+ // everything out and wait for it to happen. Don't call `invalidate` here,
+ // or you'll end up in an infinite loop.
+ this.table.spacerTop.setHeight(0);
+ this.#resetRowBuffer();
+ return;
+ }
+
+ const ranges = this.#calculateRowBufferRanges(rowCount);
+
+ // *WARNING: Do not request any DOM dimensions after this point. Modifying
+ // the DOM will invalidate existing calculations and any additional requests
+ // will cause synchronous reflow.
+
+ // Add a row if the table is empty. Either we're initializing or have
+ // invalidated the tree, and the next two steps pass over row zero if there
+ // are no rows already in the buffer.
+ if (
+ this.#lastBufferRowIndex == 0 &&
+ this.table.body.childElementCount == 0 &&
+ ranges.visibleRows.first == 0
+ ) {
+ this._addRowAtIndex(0);
+ }
+
+ // Expand the row buffer to include newly-visible rows which weren't already
+ // visible or preloaded in the tolerance buffer.
+
+ const earliestMissingEndRowIdx = Math.max(
+ this.#lastBufferRowIndex + 1,
+ ranges.visibleRows.first
+ );
+ for (let i = earliestMissingEndRowIdx; i <= ranges.visibleRows.last; i++) {
+ // We are missing rows at the end of the buffer. Either the last row of
+ // the existing buffer lies within the range of visible rows and we begin
+ // there, or the entire range of visible rows occurs after the end of the
+ // buffer and we fill in from the start.
+ this._addRowAtIndex(i);
+ }
+
+ const latestMissingStartRowIdx = Math.min(
+ this.#firstBufferRowIndex - 1,
+ ranges.visibleRows.last
+ );
+ for (let i = latestMissingStartRowIdx; i >= ranges.visibleRows.first; i--) {
+ // We are missing rows at the start of the buffer. We'll add them working
+ // backwards so that we can prepend. Either the first row of the existing
+ // buffer lies within the range of visible rows and we begin there, or the
+ // entire range of visible rows occurs before the end of the buffer and we
+ // fill in from the end.
+ this._addRowAtIndex(i, this.table.body.firstElementChild);
+ }
+
+ // Prune the buffer of any rows outside of our desired buffer range.
+ if (ranges.pruneBefore !== null) {
+ const pruneBeforeRow = this.getRowAtIndex(ranges.pruneBefore);
+ let rowToPrune = pruneBeforeRow.previousElementSibling;
+ while (rowToPrune) {
+ this._removeRowAtIndex(rowToPrune.index);
+ rowToPrune = pruneBeforeRow.previousElementSibling;
+ }
+ }
+
+ if (ranges.pruneAfter !== null) {
+ const pruneAfterRow = this.getRowAtIndex(ranges.pruneAfter);
+ let rowToPrune = pruneAfterRow.nextElementSibling;
+ while (rowToPrune) {
+ this._removeRowAtIndex(rowToPrune.index);
+ rowToPrune = pruneAfterRow.nextElementSibling;
+ }
+ }
+
+ // Set the indices of the new first and last rows in the DOM. They may come
+ // from the tolerance buffer if we haven't exhausted it.
+ this.#firstBufferRowIndex = ranges.finalizedRows.first;
+ this.#lastBufferRowIndex = ranges.finalizedRows.last;
+
+ this.#firstVisibleRowIndex = ranges.visibleRows.first;
+ this.#lastVisibleRowIndex = ranges.visibleRows.last;
+
+ // Adjust the height of the spacers to ensure that visible rows fall within
+ // the visible space and the overall scroll height is correct.
+ this.table.spacerTop.setHeight(
+ this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT
+ );
+
+ this.table.spacerBottom.setHeight(
+ (rowCount - this.#lastBufferRowIndex - 1) *
+ this._rowElementClass.ROW_HEIGHT
+ );
+
+ // The row buffer ideally contains some tolerance on either end to avoid
+ // creating rows and fetching data for them during short scrolls. However,
+ // actually creating those rows can be expensive, and during a long scroll
+ // we may throw them away very quickly. To save the expense, only fill the
+ // buffer while idle.
+
+ this.#createToleranceFillCallback();
+ }
+
+ /**
+ * Index of the first visible or partly visible row.
+ *
+ * @returns {integer}
+ */
+ getFirstVisibleIndex() {
+ return this.#firstVisibleRowIndex;
+ }
+
+ /**
+ * Index of the last visible or partly visible row.
+ *
+ * @returns {integer}
+ */
+ getLastVisibleIndex() {
+ return this.#lastVisibleRowIndex;
+ }
+
+ /**
+ * Ensures that the row at `index` is on the screen.
+ *
+ * @param {integer} index
+ */
+ scrollToIndex(index, instant = false) {
+ const rowCount = this._view.rowCount;
+ if (rowCount == 0) {
+ // If there are no rows, make sure we're scrolled to the top.
+ this.scrollTo({ top: 0, behavior: "instant" });
+ return;
+ }
+ if (index < 0 || index >= rowCount) {
+ // Bad index. Report, and do nothing.
+ console.error(
+ `<${this.localName} id="${this.id}"> tried to scroll to a row that doesn't exist: ${index}`
+ );
+ return;
+ }
+
+ const topOfRow = this._rowElementClass.ROW_HEIGHT * index;
+ let scrollTop = this.scrollTop;
+ const visibleHeight = this.#calculateVisibleHeight();
+ const behavior = instant ? "instant" : "auto";
+
+ // Scroll up to the row.
+ if (topOfRow < scrollTop) {
+ this.scrollTo({ top: topOfRow, behavior });
+ return;
+ }
+
+ // Scroll down to the row.
+ const bottomOfRow = topOfRow + this._rowElementClass.ROW_HEIGHT;
+ if (bottomOfRow > scrollTop + visibleHeight) {
+ this.scrollTo({ top: bottomOfRow - visibleHeight, behavior });
+ return;
+ }
+
+ // Call `scrollTo` even if the row is in view, to stop any earlier smooth
+ // scrolling that might be happening.
+ this.scrollTo({ top: this.scrollTop, behavior });
+ }
+
+ /**
+ * Updates the list to reflect added or removed rows.
+ *
+ * @param {integer} index - The position in the existing list where rows were
+ * added or removed.
+ * @param {integer} delta - The change in number of rows; positive if rows
+ * were added and negative if rows were removed.
+ */
+ rowCountChanged(index, delta) {
+ if (!this._selection) {
+ return;
+ }
+
+ this._selection.adjustSelection(index, delta);
+ this._updateCurrentIndexClasses();
+ this.dispatchEvent(new CustomEvent("rowcountchange"));
+ }
+
+ /**
+ * Clamps `index` to a value between 0 and `rowCount - 1`.
+ *
+ * @param {integer} index
+ * @returns {integer}
+ */
+ _clampIndex(index) {
+ if (!this._view.rowCount) {
+ return null;
+ }
+ if (index < 0) {
+ return 0;
+ }
+ if (index >= this._view.rowCount) {
+ return this._view.rowCount - 1;
+ }
+ return index;
+ }
+
+ /**
+ * Creates a new row element and adds it to the DOM.
+ *
+ * @param {integer} index
+ */
+ _addRowAtIndex(index, before = null) {
+ let row = document.createElement("tr", { is: this._rowElementName });
+ row.setAttribute("is", this._rowElementName);
+ this.table.body.insertBefore(row, before);
+ row.setAttribute("aria-setsize", this._view.rowCount);
+ row.style.height = `${this._rowElementClass.ROW_HEIGHT}px`;
+ row.index = index;
+ if (this._selection?.isSelected(index)) {
+ row.selected = true;
+ }
+ if (this.currentIndex === index) {
+ row.classList.add("current");
+ this.table.body.setAttribute("aria-activedescendant", row.id);
+ }
+ this._rows.set(index, row);
+ }
+
+ /**
+ * Removes the row element at `index` from the DOM and map of rows.
+ *
+ * @param {integer} index
+ */
+ _removeRowAtIndex(index) {
+ const row = this._rows.get(index);
+ row?.remove();
+ this._rows.delete(index);
+ }
+
+ /**
+ * Returns the row element at `index` or null if `index` is out of range.
+ *
+ * @param {integer} index
+ * @returns {HTMLTableRowElement}
+ */
+ getRowAtIndex(index) {
+ return this._rows.get(index) ?? null;
+ }
+
+ /**
+ * Collapses the row at `index` if it can be collapsed. If the selected
+ * row is a descendant of the collapsing row, selection is moved to the
+ * collapsing row.
+ *
+ * @param {integer} index
+ */
+ collapseRowAtIndex(index) {
+ if (!this._view.isContainerOpen(index)) {
+ return;
+ }
+
+ // If the selected row is going to be collapsed, move the selection.
+ // Even if the row to be collapsed is already selected, set
+ // selectIndex to ensure currentIndex also points to the correct row.
+ let selectedIndex = this.selectedIndex;
+ while (selectedIndex >= index) {
+ if (selectedIndex == index) {
+ this.selectedIndex = index;
+ break;
+ }
+ selectedIndex = this._view.getParentIndex(selectedIndex);
+ }
+
+ // Check if the view calls rowCountChanged. If it didn't, we'll have to
+ // call it. This can happen if the view has no reference to the tree.
+ let rowCountDidChange = false;
+ let rowCountChangeListener = () => {
+ rowCountDidChange = true;
+ };
+
+ let countBefore = this._view.rowCount;
+ this.addEventListener("rowcountchange", rowCountChangeListener);
+ this._view.toggleOpenState(index);
+ this.removeEventListener("rowcountchange", rowCountChangeListener);
+ let countAdded = this._view.rowCount - countBefore;
+
+ // Call rowCountChanged, if it hasn't already happened.
+ if (countAdded && !rowCountDidChange) {
+ this.invalidateRow(index);
+ this.rowCountChanged(index + 1, countAdded);
+ }
+
+ this.dispatchEvent(
+ new CustomEvent("collapsed", { bubbles: true, detail: index })
+ );
+ }
+
+ /**
+ * Expands the row at `index` if it can be expanded.
+ *
+ * @param {integer} index
+ * @returns {integer} - the number of rows that were added
+ */
+ expandRowAtIndex(index) {
+ if (!this._view.isContainer(index) || this._view.isContainerOpen(index)) {
+ return 0;
+ }
+
+ // Check if the view calls rowCountChanged. If it didn't, we'll have to
+ // call it. This can happen if the view has no reference to the tree.
+ let rowCountDidChange = false;
+ let rowCountChangeListener = () => {
+ rowCountDidChange = true;
+ };
+
+ let countBefore = this._view.rowCount;
+ this.addEventListener("rowcountchange", rowCountChangeListener);
+ this._view.toggleOpenState(index);
+ this.removeEventListener("rowcountchange", rowCountChangeListener);
+ let countAdded = this._view.rowCount - countBefore;
+
+ // Call rowCountChanged, if it hasn't already happened.
+ if (countAdded && !rowCountDidChange) {
+ this.invalidateRow(index);
+ this.rowCountChanged(index + 1, countAdded);
+ }
+
+ this.dispatchEvent(
+ new CustomEvent("expanded", { bubbles: true, detail: index })
+ );
+
+ return countAdded;
+ }
+
+ /**
+ * In a selection, index of the most-recently-selected row.
+ *
+ * @type {integer}
+ */
+ get currentIndex() {
+ return this._selection ? this._selection.currentIndex : -1;
+ }
+
+ set currentIndex(index) {
+ if (!this._view) {
+ return;
+ }
+
+ this._selection.currentIndex = index;
+ this._updateCurrentIndexClasses();
+ if (index >= 0 && index < this._view.rowCount) {
+ this.scrollToIndex(index);
+ }
+ }
+
+ /**
+ * Set the "current" class on the right row, and remove it from all other rows.
+ */
+ _updateCurrentIndexClasses() {
+ let index = this.currentIndex;
+
+ for (let row of this.querySelectorAll(
+ `tr[is="${this._rowElementName}"].current`
+ )) {
+ row.classList.remove("current");
+ }
+
+ if (!this._view || index < 0 || index > this._view.rowCount - 1) {
+ this.table.body.removeAttribute("aria-activedescendant");
+ return;
+ }
+
+ let row = this.getRowAtIndex(index);
+ if (row) {
+ // We need to clear the attribute in order to let screen readers know that
+ // a new message has been selected even if the ID is identical. For
+ // example when we delete the first message with ID 0, the next message
+ // becomes ID 0 itself. Therefore the attribute wouldn't trigger the screen
+ // reader to announce the new message without being cleared first.
+ this.table.body.removeAttribute("aria-activedescendant");
+ row.classList.add("current");
+ this.table.body.setAttribute("aria-activedescendant", row.id);
+ }
+ }
+
+ /**
+ * Select and focus the given index.
+ *
+ * @param {integer} index - The index to select.
+ * @param {boolean} [delaySelect=false] - If the selection should be delayed.
+ */
+ _selectSingle(index, delaySelect = false) {
+ let changeSelection =
+ this._selection.count != 1 || !this._selection.isSelected(index);
+ // Update the TreeSelection selection to trigger a tree reset().
+ if (changeSelection) {
+ this._selection.select(index);
+ }
+ this.currentIndex = index;
+ if (changeSelection) {
+ this.onSelectionChanged(delaySelect);
+ }
+ }
+
+ /**
+ * Start or extend a range selection to the given index and focus it.
+ *
+ * @param {number} start - Start index of selection. -1 for current index.
+ * @param {number} end - End index of selection.
+ * @param {boolean} extend[false] - If the new selection range should extend
+ * the current selection.
+ */
+ _selectRange(start, end, extend = false) {
+ this._selection.rangedSelect(start, end, extend);
+ this.currentIndex = start == -1 ? end : start;
+ this.onSelectionChanged();
+ }
+
+ /**
+ * Toggle the selection state at the given index and focus it.
+ *
+ * @param {integer} index - The index to toggle.
+ */
+ _toggleSelected(index) {
+ this._selection.toggleSelect(index);
+ // We hack the internals of the TreeSelection to clear the
+ // shiftSelectPivot.
+ this._selection._shiftSelectPivot = null;
+ this.currentIndex = index;
+ this.onSelectionChanged();
+ }
+
+ /**
+ * Select all rows.
+ */
+ selectAll() {
+ this._selection.selectAll();
+ this.onSelectionChanged();
+ }
+
+ /**
+ * Toggle between selecting all rows or none, depending on the current
+ * selection state.
+ */
+ toggleSelectAll() {
+ if (!this.selectedIndices.length) {
+ const index = this._view.rowCount - 1;
+ this._selection.selectAll();
+ this.currentIndex = index;
+ } else {
+ this._selection.clearSelection();
+ }
+ // Make sure the body is focused when the selection is changed as
+ // clicking on the "select all" header button steals the focus.
+ this.focus();
+
+ this.onSelectionChanged();
+ }
+
+ /**
+ * In a selection, index of the most-recently-selected row.
+ *
+ * @type {integer}
+ */
+ get selectedIndex() {
+ if (!this._selection?.count) {
+ return -1;
+ }
+
+ let min = {};
+ this._selection.getRangeAt(0, min, {});
+ return min.value;
+ }
+
+ set selectedIndex(index) {
+ this._selectSingle(index);
+ }
+
+ /**
+ * An array of the indices of all selected rows.
+ *
+ * @type {integer[]}
+ */
+ get selectedIndices() {
+ let indices = [];
+ let rangeCount = this._selection.getRangeCount();
+
+ for (let range = 0; range < rangeCount; range++) {
+ let min = {};
+ let max = {};
+ this._selection.getRangeAt(range, min, max);
+
+ if (min.value == -1) {
+ continue;
+ }
+
+ for (let index = min.value; index <= max.value; index++) {
+ indices.push(index);
+ }
+ }
+
+ return indices;
+ }
+
+ set selectedIndices(indices) {
+ this.setSelectedIndices(indices);
+ }
+
+ /**
+ * An array of the indices of all selected rows.
+ *
+ * @param {integer[]} indices
+ * @param {boolean} suppressEvent - Prevent a "select" event firing.
+ */
+ setSelectedIndices(indices, suppressEvent) {
+ this._selection.clearSelection();
+ for (let index of indices) {
+ this._selection.toggleSelect(index);
+ }
+ this.onSelectionChanged(false, suppressEvent);
+ }
+
+ /**
+ * Changes the selection state of the row at `index`.
+ *
+ * @param {integer} index
+ * @param {boolean?} selected - if set, set the selection state to this
+ * value, otherwise toggle the current state
+ * @param {boolean?} suppressEvent - prevent a "select" event firing
+ * @returns {boolean} - if the index is now selected
+ */
+ toggleSelectionAtIndex(index, selected, suppressEvent) {
+ let wasSelected = this._selection.isSelected(index);
+ if (selected === undefined) {
+ selected = !wasSelected;
+ }
+
+ if (selected != wasSelected) {
+ this._selection.toggleSelect(index);
+ this.onSelectionChanged(false, suppressEvent);
+ }
+
+ return selected;
+ }
+
+ /**
+ * Loop through all available child elements of the placeholder slot and
+ * show those that are needed.
+ * @param {array} idsToShow - Array of ids to show.
+ */
+ updatePlaceholders(idsToShow) {
+ for (let element of this.placeholder.children) {
+ element.hidden = !idsToShow.includes(element.id);
+ }
+ }
+
+ /**
+ * Update the classes on the table element to reflect the current selection
+ * state, and dispatch an event to allow implementations to handle the
+ * change in the selection state.
+ *
+ * @param {boolean} [delaySelect=false] - If the selection should be delayed.
+ * @param {boolean} [suppressEvent=false] - Prevent a "select" event firing.
+ */
+ onSelectionChanged(delaySelect = false, suppressEvent = false) {
+ const selectedCount = this._selection.count;
+ const allSelected = selectedCount == this._view.rowCount;
+
+ this.table.classList.toggle("all-selected", allSelected);
+ this.table.classList.toggle("some-selected", !allSelected && selectedCount);
+ this.table.classList.toggle("multi-selected", selectedCount > 1);
+
+ const selectButton = this.table.querySelector(".tree-view-header-select");
+ // Some implementations might not use a select header.
+ if (selectButton) {
+ // Only mark the `select` button as "checked" if all rows are selected.
+ selectButton.toggleAttribute("aria-checked", allSelected);
+ // The default action for the header button is to deselect all messages
+ // if even one message is currently selected.
+ document.l10n.setAttributes(
+ selectButton,
+ selectedCount
+ ? "threadpane-column-header-deselect-all"
+ : "threadpane-column-header-select-all"
+ );
+ }
+
+ if (suppressEvent) {
+ return;
+ }
+
+ // No need to handle a delayed select if not required.
+ if (!delaySelect) {
+ // Clear the timeout in case something was still running.
+ if (this._selectTimeout) {
+ window.clearTimeout(this._selectTimeout);
+ }
+ this.dispatchEvent(new CustomEvent("select", { bubbles: true }));
+ return;
+ }
+
+ let delay = this.dataset.selectDelay || 50;
+ if (delay != -1) {
+ if (this._selectTimeout) {
+ window.clearTimeout(this._selectTimeout);
+ }
+ this._selectTimeout = window.setTimeout(() => {
+ this.dispatchEvent(new CustomEvent("select", { bubbles: true }));
+ this._selectTimeout = null;
+ }, delay);
+ }
+ }
+}
+customElements.define("tree-view", TreeView);
+
+/**
+ * The main <table> element containing the thead and the TreeViewTableBody
+ * tbody. This class is used to expose all those methods and custom events
+ * needed at the implementation level.
+ */
+class TreeViewTable extends HTMLTableElement {
+ /**
+ * The array of objects containing the data to generate the needed columns.
+ * Keep this public so child elements can access it if needed.
+ * @type {Array}
+ */
+ columns;
+
+ /**
+ * The header row for the table.
+ *
+ * @type {TreeViewTableHeader}
+ */
+ header;
+
+ /**
+ * Array containing the IDs of templates holding menu items to dynamically add
+ * to the menupopup of the column picker.
+ * @type {Array}
+ */
+ popupMenuTemplates = [];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-view-table");
+ this.classList.add("tree-table");
+
+ // Use a fragment to append child elements to later add them all at once
+ // to the DOM. Performance is important.
+ const fragment = new DocumentFragment();
+
+ this.header = document.createElement("thead", {
+ is: "tree-view-table-header",
+ });
+ fragment.append(this.header);
+
+ this.spacerTop = document.createElement("tbody", {
+ is: "tree-view-table-spacer",
+ });
+ fragment.append(this.spacerTop);
+
+ this.body = document.createElement("tbody", {
+ is: "tree-view-table-body",
+ });
+ fragment.append(this.body);
+
+ this.spacerBottom = document.createElement("tbody", {
+ is: "tree-view-table-spacer",
+ });
+ fragment.append(this.spacerBottom);
+
+ this.append(fragment);
+ }
+
+ /**
+ * If set to TRUE before generating the columns, the table will
+ * automatically create a column picker in the table header.
+ *
+ * @type {boolean}
+ */
+ set editable(val) {
+ this.dataset.editable = val;
+ }
+
+ get editable() {
+ return this.dataset.editable === "true";
+ }
+
+ /**
+ * Set the id attribute of the TreeViewTableBody for selection and styling
+ * purpose.
+ *
+ * @param {string} id - The string ID to set.
+ */
+ setBodyID(id) {
+ this.body.id = id;
+ }
+
+ setPopupMenuTemplates(array) {
+ this.popupMenuTemplates = array;
+ }
+
+ /**
+ * Set the columns array of the table. This should only be used during
+ * initialization and any following change to the columns visibility should
+ * be handled via the updateColumns() method.
+ *
+ * @param {Array} columns - The array of columns to generate.
+ */
+ setColumns(columns) {
+ this.columns = columns;
+ this.header.setColumns();
+ this.#updateView();
+ }
+
+ /**
+ * Update the currently visible columns.
+ *
+ * @param {Array} columns - The array of columns to update. It should match
+ * the original array set via the setColumn() method since this method will
+ * only update the column visibility without generating new elements.
+ */
+ updateColumns(columns) {
+ this.columns = columns;
+ this.#updateView();
+ }
+
+ /**
+ * Store the newly resized column values in the xul store.
+ *
+ * @param {string} url - The document URL used to store the values.
+ * @param {DOMEvent} event - The dom event bubbling from the resized action.
+ */
+ setColumnsWidths(url, event) {
+ const width = event.detail.splitter.width;
+ const column = event.detail.column;
+ const newValue = `${column}:${width}`;
+ let newWidths;
+
+ // Check if we already have stored values and update it if so.
+ let columnsWidths = Services.xulStore.getValue(url, "columns", "widths");
+ if (columnsWidths) {
+ let updated = false;
+ columnsWidths = columnsWidths.split(",");
+ for (let index = 0; index < columnsWidths.length; index++) {
+ const cw = columnsWidths[index].split(":");
+ if (cw[0] == column) {
+ cw[1] = width;
+ updated = true;
+ columnsWidths[index] = newValue;
+ break;
+ }
+ }
+ // Push the new value into the array if we didn't have an existing one.
+ if (!updated) {
+ columnsWidths.push(newValue);
+ }
+ newWidths = columnsWidths.join(",");
+ } else {
+ newWidths = newValue;
+ }
+
+ // Store the values as a plain string with the current format:
+ // columnID:width,columnID:width,...
+ Services.xulStore.setValue(url, "columns", "widths", newWidths);
+ }
+
+ /**
+ * Restore the previously saved widths of the various columns if we have
+ * any.
+ *
+ * @param {string} url - The document URL used to store the values.
+ */
+ restoreColumnsWidths(url) {
+ let columnsWidths = Services.xulStore.getValue(url, "columns", "widths");
+ if (!columnsWidths) {
+ return;
+ }
+
+ for (let column of columnsWidths.split(",")) {
+ column = column.split(":");
+ this.querySelector(`#${column[0]}`)?.style.setProperty(
+ `--${column[0]}Splitter-width`,
+ `${column[1]}px`
+ );
+ }
+ }
+
+ /**
+ * Update the visibility of the currently available columns.
+ */
+ #updateView() {
+ let lastResizableColumn = this.columns.findLast(
+ c => !c.hidden && (c.resizable ?? true)
+ );
+
+ for (let column of this.columns) {
+ document.getElementById(column.id).hidden = column.hidden;
+
+ // No need to update the splitter visibility if the column is
+ // specifically not resizable.
+ if (column.resizable === false) {
+ continue;
+ }
+
+ document.getElementById(column.id).resizable =
+ column != lastResizableColumn;
+ }
+ }
+}
+customElements.define("tree-view-table", TreeViewTable, { extends: "table" });
+
+/**
+ * Class used to generate the thead of the TreeViewTable. This class will take
+ * care of handling columns sizing and sorting order, with bubbling events to
+ * allow listening for those changes on the implementation level.
+ */
+class TreeViewTableHeader extends HTMLTableSectionElement {
+ /**
+ * An array of all table header cells that can be reordered.
+ *
+ * @returns {HTMLTableCellElement[]}
+ */
+ get #orderableChildren() {
+ return [...this.querySelectorAll("th[draggable]:not([hidden])")];
+ }
+
+ /**
+ * Used to simulate a change in the order. The element remains in the same
+ * DOM position.
+ *
+ * @param {HTMLTableRowElement} element - The row to animate.
+ * @param {number} to - The new Y position of the element after animation.
+ */
+ static _transitionTranslation(element, to) {
+ if (!reducedMotionMedia.matches) {
+ element.style.transition = `transform ${ANIMATION_DURATION_MS}ms ease`;
+ }
+ element.style.transform = to ? `translateX(${to}px)` : null;
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-view-table-header");
+ this.classList.add("tree-table-header");
+ this.row = document.createElement("tr");
+ this.appendChild(this.row);
+
+ this.addEventListener("keypress", this);
+ this.addEventListener("dragstart", this);
+ this.addEventListener("dragover", this);
+ this.addEventListener("dragend", this);
+ this.addEventListener("drop", this);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keypress":
+ this.#onKeyPress(event);
+ break;
+ case "dragstart":
+ this.#onDragStart(event);
+ break;
+ case "dragover":
+ this.#onDragOver(event);
+ break;
+ case "dragend":
+ this.#onDragEnd();
+ break;
+ case "drop":
+ this.#onDrop(event);
+ break;
+ }
+ }
+
+ #onKeyPress(event) {
+ if (!event.altKey || !["ArrowRight", "ArrowLeft"].includes(event.key)) {
+ this.triggerTableHeaderRovingTab(event);
+ return;
+ }
+
+ let column = event.target.closest(`th[is="tree-view-table-header-cell"]`);
+ if (!column) {
+ return;
+ }
+
+ let visibleColumns = this.parentNode.columns.filter(c => !c.hidden);
+ let forward =
+ event.key == (document.dir === "rtl" ? "ArrowLeft" : "ArrowRight");
+
+ // Bail out if the user is trying to shift backward the first column, or
+ // shift forward the last column.
+ if (
+ (!forward && visibleColumns.at(0)?.id == column.id) ||
+ (forward && visibleColumns.at(-1)?.id == column.id)
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent("shift-column", {
+ bubbles: true,
+ detail: {
+ column: column.id,
+ forward,
+ },
+ })
+ );
+ }
+
+ #onDragStart(event) {
+ if (!event.target.closest("th[draggable]")) {
+ // This shouldn't be necessary, but is?!
+ event.preventDefault();
+ return;
+ }
+
+ const orderable = this.#orderableChildren;
+ if (orderable.length < 2) {
+ return;
+ }
+
+ const headerCell = orderable.find(th => th.contains(event.target));
+ const rect = headerCell.getBoundingClientRect();
+
+ this._dragInfo = {
+ cell: headerCell,
+ // How far can we move `headerCell` horizontally.
+ min: orderable.at(0).getBoundingClientRect().left - rect.left,
+ max: orderable.at(-1).getBoundingClientRect().right - rect.right,
+ // Where is the drag event starting.
+ startX: event.clientX,
+ offsetX: event.clientX - rect.left,
+ };
+
+ headerCell.classList.add("column-dragging");
+ // Prevent `headerCell` being used as the drag image. We don't
+ // really want any drag image, but there's no way to not have one.
+ event.dataTransfer.setDragImage(document.createElement("img"), 0, 0);
+ }
+
+ #onDragOver(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ const { cell, min, max, startX, offsetX } = this._dragInfo;
+ // Move `cell` with the mouse pointer.
+ let dragX = Math.min(max, Math.max(min, event.clientX - startX));
+ cell.style.transform = `translateX(${dragX}px)`;
+
+ let thisRect = this.getBoundingClientRect();
+
+ // How much space is there before the `cell`? We'll see how many cells fit
+ // in the space and put the `cell` in after them.
+ let spaceBefore = Math.max(
+ 0,
+ event.clientX + this.scrollLeft - offsetX - thisRect.left
+ );
+ // The width of all cells seen in the loop so far.
+ let totalWidth = 0;
+ // If we've looped past the cell being dragged.
+ let afterDraggedTh = false;
+ // The cell before where a drop would take place. If null, drop would
+ // happen at the start of the table header.
+ let header = null;
+
+ for (let headerCell of this.#orderableChildren) {
+ if (headerCell == cell) {
+ afterDraggedTh = true;
+ continue;
+ }
+
+ let rect = headerCell.getBoundingClientRect();
+ let enoughSpace = spaceBefore > totalWidth + rect.width / 2;
+
+ let multiplier = 0;
+ if (enoughSpace) {
+ if (afterDraggedTh) {
+ multiplier = -1;
+ }
+ header = headerCell;
+ } else if (!afterDraggedTh) {
+ multiplier = 1;
+ }
+ TreeViewTableHeader._transitionTranslation(
+ headerCell,
+ multiplier * cell.clientWidth
+ );
+
+ totalWidth += rect.width;
+ }
+
+ this._dragInfo.dropTarget = header;
+
+ event.preventDefault();
+ }
+
+ #onDragEnd() {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ this._dragInfo.cell.classList.remove("column-dragging");
+ delete this._dragInfo;
+
+ for (let headerCell of this.#orderableChildren) {
+ headerCell.style.transform = null;
+ headerCell.style.transition = null;
+ }
+ }
+
+ #onDrop(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ let { cell, startX, dropTarget } = this._dragInfo;
+
+ let newColumns = this.parentNode.columns.map(column => ({ ...column }));
+
+ const draggedColumn = newColumns.find(c => c.id == cell.id);
+ const initialPosition = newColumns.indexOf(draggedColumn);
+
+ let targetCell;
+ let newPosition;
+ if (!dropTarget) {
+ // Get the first visible cell.
+ targetCell = this.querySelector("th:not([hidden])");
+ newPosition = newColumns.indexOf(
+ newColumns.find(c => c.id == targetCell.id)
+ );
+ } else {
+ // Get the next non hidden sibling.
+ targetCell = dropTarget.nextElementSibling;
+ while (targetCell.hidden) {
+ targetCell = targetCell.nextElementSibling;
+ }
+ newPosition = newColumns.indexOf(
+ newColumns.find(c => c.id == targetCell.id)
+ );
+ }
+
+ // Reduce the new position index if we're moving forward in order to get the
+ // accurate index position of the column we're taking the position of.
+ if (event.clientX > startX) {
+ newPosition -= 1;
+ }
+
+ newColumns.splice(newPosition, 0, newColumns.splice(initialPosition, 1)[0]);
+
+ // Update the ordinal of the columns to reflect the new positions.
+ newColumns.forEach((column, index) => {
+ column.ordinal = index;
+ });
+
+ this.querySelector("tr").insertBefore(cell, targetCell);
+
+ this.dispatchEvent(
+ new CustomEvent("reorder-columns", {
+ bubbles: true,
+ detail: {
+ columns: newColumns,
+ },
+ })
+ );
+ event.preventDefault();
+ }
+
+ /**
+ * Create all the table header cells based on the currently set columns.
+ */
+ setColumns() {
+ this.row.replaceChildren();
+
+ for (let column of this.parentNode.columns) {
+ /** @type {TreeViewTableHeaderCell} */
+ let cell = document.createElement("th", {
+ is: "tree-view-table-header-cell",
+ });
+ this.row.appendChild(cell);
+ cell.setColumn(column);
+ }
+
+ // Create a column picker if the table is editable.
+ if (this.parentNode.editable) {
+ const picker = document.createElement("th", {
+ is: "tree-view-table-column-picker",
+ });
+ this.row.appendChild(picker);
+ }
+
+ this.updateRovingTab();
+ }
+
+ /**
+ * Get all currently visible columns of the table header.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get headerColumns() {
+ return this.row.querySelectorAll(`th:not([hidden]) button`);
+ }
+
+ /**
+ * Update the `tabindex` attribute of the currently visible columns.
+ */
+ updateRovingTab() {
+ for (let button of this.headerColumns) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ this.headerColumns[0].tabIndex = 0;
+ }
+
+ /**
+ * Handles the keypress event on the table header.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerTableHeaderRovingTab(event) {
+ if (!["ArrowRight", "ArrowLeft"].includes(event.key)) {
+ return;
+ }
+
+ const headerColumns = [...this.headerColumns];
+ let focusableButton = headerColumns.find(b => b.tabIndex != -1);
+ let elementIndex = headerColumns.indexOf(focusableButton);
+
+ // Find the adjacent focusable element based on the pressed key.
+ let isRTL = document.dir == "rtl";
+ if (
+ (isRTL && event.key == "ArrowLeft") ||
+ (!isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > headerColumns.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (!isRTL && event.key == "ArrowLeft") ||
+ (isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = headerColumns.length - 1;
+ }
+ }
+
+ // Move the focus to a new column and update the tabindex attribute.
+ let newFocusableButton = headerColumns[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.tabIndex = 0;
+ newFocusableButton.focus();
+ }
+ }
+}
+customElements.define("tree-view-table-header", TreeViewTableHeader, {
+ extends: "thead",
+});
+
+/**
+ * Class to generated the TH elements for the TreeViewTableHeader.
+ */
+class TreeViewTableHeaderCell extends HTMLTableCellElement {
+ /**
+ * The div needed to handle the header button in an absolute position.
+ * @type {HTMLElement}
+ */
+ #container;
+
+ /**
+ * The clickable button to change the sorting of the table.
+ * @type {HTMLButtonElement}
+ */
+ #button;
+
+ /**
+ * If this cell is resizable.
+ * @type {boolean}
+ */
+ #resizable = true;
+
+ /**
+ * If this cell can be clicked to affect the sorting order of the tree.
+ * @type {boolean}
+ */
+ #sortable = true;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-view-table-header-cell");
+ this.draggable = true;
+
+ this.#container = document.createElement("div");
+ this.#container.classList.add(
+ "tree-table-cell",
+ "tree-table-cell-container"
+ );
+
+ this.#button = document.createElement("button");
+ this.#container.appendChild(this.#button);
+ this.appendChild(this.#container);
+ }
+
+ /**
+ * Set the proper data to the newly generated table header cell and create
+ * the needed child elements.
+ *
+ * @param {object} column - The column object with all the data to generate
+ * the correct header cell.
+ */
+ setColumn(column) {
+ // Set a public ID so parent elements can loop through the available
+ // columns after they're created.
+ this.id = column.id;
+ this.#button.id = `${column.id}Button`;
+
+ // Add custom classes if needed.
+ if (column.classes) {
+ this.#button.classList.add(...column.classes);
+ }
+
+ if (column.l10n?.header) {
+ document.l10n.setAttributes(this.#button, column.l10n.header);
+ }
+
+ // Add an image if this is a table header that needs to display an icon,
+ // and set the column as icon.
+ if (column.icon) {
+ this.dataset.type = "icon";
+ const img = document.createElement("img");
+ img.src = "";
+ img.alt = "";
+ this.#button.appendChild(img);
+ }
+
+ this.resizable = column.resizable ?? true;
+
+ this.hidden = column.hidden;
+
+ this.#sortable = column.sortable ?? true;
+ // Make the button clickable if the column can trigger a sorting of rows.
+ if (this.#sortable) {
+ this.#button.addEventListener("click", () => {
+ this.dispatchEvent(
+ new CustomEvent("sort-changed", {
+ bubbles: true,
+ detail: {
+ column: column.id,
+ },
+ })
+ );
+ });
+ }
+
+ this.#button.addEventListener("contextmenu", event => {
+ event.stopPropagation();
+ const table = this.closest("table");
+ if (table.editable) {
+ table
+ .querySelector("#columnPickerMenuPopup")
+ .openPopup(event.target, { triggerEvent: event });
+ }
+ });
+
+ // This is the column handling the thread toggling.
+ if (column.thread) {
+ this.#button.classList.add("tree-view-header-thread");
+ this.#button.addEventListener("click", () => {
+ this.dispatchEvent(
+ new CustomEvent("thread-changed", {
+ bubbles: true,
+ })
+ );
+ });
+ }
+
+ // This is the column handling bulk selection.
+ if (column.select) {
+ this.#button.classList.add("tree-view-header-select");
+ this.#button.addEventListener("click", () => {
+ this.closest("tree-view").toggleSelectAll();
+ });
+ }
+
+ // This is the column handling delete actions.
+ if (column.delete) {
+ this.#button.classList.add("tree-view-header-delete");
+ }
+ }
+
+ /**
+ * Set this table header as responsible for the sorting of rows.
+ *
+ * @param {string["ascending"|"descending"]} direction - The new sorting
+ * direction.
+ */
+ setSorting(direction) {
+ this.#button.classList.add("sorting", direction);
+ }
+
+ /**
+ * If this current column can be resized.
+ *
+ * @type {boolean}
+ */
+ set resizable(val) {
+ this.#resizable = val;
+ this.dataset.resizable = val;
+
+ let splitter = this.querySelector("hr");
+
+ // Add a splitter if we don't have one already.
+ if (!splitter) {
+ splitter = document.createElement("hr", { is: "pane-splitter" });
+ splitter.setAttribute("is", "pane-splitter");
+ this.appendChild(splitter);
+ splitter.resizeDirection = "horizontal";
+ splitter.resizeElement = this;
+ splitter.id = `${this.id}Splitter`;
+ // Emit a custom event after a resize action. Methods at implementation
+ // level should listen to this event if the edited column size needs to
+ // be stored or used.
+ splitter.addEventListener("splitter-resized", () => {
+ this.dispatchEvent(
+ new CustomEvent("column-resized", {
+ bubbles: true,
+ detail: {
+ splitter,
+ column: this.id,
+ },
+ })
+ );
+ });
+ }
+
+ this.style.setProperty("width", val ? `var(--${splitter.id}-width)` : null);
+ // Disable the splitter if this is not a resizable column.
+ splitter.isDisabled = !val;
+ }
+
+ get resizable() {
+ return this.#resizable;
+ }
+
+ /**
+ * If the current column can trigger a sorting of rows.
+ *
+ * @type {boolean}
+ */
+ set sortable(val) {
+ this.#sortable = val;
+ this.#button.disabled = !val;
+ }
+
+ get sortable() {
+ return this.#sortable;
+ }
+}
+customElements.define("tree-view-table-header-cell", TreeViewTableHeaderCell, {
+ extends: "th",
+});
+
+/**
+ * Class used to generate a column picker used for the TreeViewTableHeader in
+ * case the visibility of the columns of a table can be changed.
+ *
+ * Include treeView.ftl for strings.
+ */
+class TreeViewTableColumnPicker extends HTMLTableCellElement {
+ /**
+ * The clickable button triggering the picker context menu.
+ * @type {HTMLButtonElement}
+ */
+ #button;
+
+ /**
+ * The menupopup allowing users to show and hide columns.
+ * @type {XULElement}
+ */
+ #context;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-view-table-column-picker");
+ this.classList.add("tree-table-cell-container");
+
+ this.#button = document.createElement("button");
+ document.l10n.setAttributes(this.#button, "tree-list-view-column-picker");
+ this.#button.classList.add("button-flat", "button-column-picker");
+ this.appendChild(this.#button);
+
+ const img = document.createElement("img");
+ img.src = "";
+ img.alt = "";
+ this.#button.appendChild(img);
+
+ this.#context = document.createXULElement("menupopup");
+ this.#context.id = "columnPickerMenuPopup";
+ this.#context.setAttribute("position", "bottomleft topleft");
+ this.appendChild(this.#context);
+ this.#context.addEventListener("popupshowing", event => {
+ // Bail out if we're opening a submenu.
+ if (event.target.id != this.#context.id) {
+ return;
+ }
+
+ if (!this.#context.hasChildNodes()) {
+ this.#initPopup();
+ }
+
+ let columns = this.closest("table").columns;
+ for (let column of columns) {
+ let item = this.#context.querySelector(`[value="${column.id}"]`);
+ if (!item) {
+ continue;
+ }
+
+ if (!column.hidden) {
+ item.setAttribute("checked", "true");
+ continue;
+ }
+
+ item.removeAttribute("checked");
+ }
+ });
+
+ this.#button.addEventListener("click", event => {
+ this.#context.openPopup(event.target, { triggerEvent: event });
+ });
+ }
+
+ /**
+ * Add all toggable columns to the context menu popup of the picker button.
+ */
+ #initPopup() {
+ let table = this.closest("table");
+ let columns = table.columns;
+ let items = new DocumentFragment();
+ for (let column of columns) {
+ // Skip those columns we don't want to allow hiding.
+ if (column.picker === false) {
+ continue;
+ }
+
+ let menuitem = document.createXULElement("menuitem");
+ items.append(menuitem);
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("name", "toggle");
+ menuitem.setAttribute("value", column.id);
+ menuitem.setAttribute("closemenu", "none");
+ if (column.l10n?.menuitem) {
+ document.l10n.setAttributes(menuitem, column.l10n.menuitem);
+ }
+
+ menuitem.addEventListener("command", () => {
+ this.dispatchEvent(
+ new CustomEvent("columns-changed", {
+ bubbles: true,
+ detail: {
+ target: menuitem,
+ value: column.id,
+ },
+ })
+ );
+ });
+ }
+
+ items.append(document.createXULElement("menuseparator"));
+ let restoreItem = document.createXULElement("menuitem");
+ restoreItem.id = "restoreColumnOrder";
+ restoreItem.addEventListener("command", () => {
+ this.dispatchEvent(
+ new CustomEvent("restore-columns", {
+ bubbles: true,
+ })
+ );
+ });
+ document.l10n.setAttributes(
+ restoreItem,
+ "tree-list-view-column-picker-restore"
+ );
+ items.append(restoreItem);
+
+ for (const templateID of table.popupMenuTemplates) {
+ items.append(document.getElementById(templateID).content.cloneNode(true));
+ }
+
+ this.#context.replaceChildren(items);
+ }
+}
+customElements.define(
+ "tree-view-table-column-picker",
+ TreeViewTableColumnPicker,
+ { extends: "th" }
+);
+
+/**
+ * A more powerful list designed to be used with a view (nsITreeView or
+ * whatever replaces it in time) and be scalable to a very large number of
+ * items if necessary. Multiple selections are possible and changes in the
+ * connected view are cause updates to the list (provided `rowCountChanged`/
+ * `invalidate` are called as appropriate).
+ *
+ * Rows are provided by a custom element that inherits from
+ * TreeViewTableRow below. Set the name of the custom element as the "rows"
+ * attribute.
+ *
+ * Include tree-listbox.css for appropriate styling.
+ */
+class TreeViewTableBody extends HTMLTableSectionElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.tabIndex = 0;
+ this.setAttribute("is", "tree-view-table-body");
+ this.setAttribute("role", "tree");
+ this.setAttribute("aria-multiselectable", "true");
+
+ let treeView = this.closest("tree-view");
+ this.addEventListener("keyup", treeView);
+ this.addEventListener("click", treeView);
+ this.addEventListener("keydown", treeView);
+
+ if (treeView.dataset.labelId) {
+ this.setAttribute("aria-labelledby", treeView.dataset.labelId);
+ }
+ }
+}
+customElements.define("tree-view-table-body", TreeViewTableBody, {
+ extends: "tbody",
+});
+
+/**
+ * Base class for rows in a TreeViewTableBody. Rows have a fixed height and
+ * their position on screen is managed by the owning list.
+ *
+ * Sub-classes should override ROW_HEIGHT, styles, and fragment to suit the
+ * intended layout. The index getter/setter should be overridden to fill the
+ * layout with values.
+ */
+class TreeViewTableRow extends HTMLTableRowElement {
+ /**
+ * Fixed height of this row. Rows in the list will be spaced this far
+ * apart. This value must not change at runtime.
+ *
+ * @type {integer}
+ */
+ static ROW_HEIGHT = 50;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.tabIndex = -1;
+ this.list = this.closest("tree-view");
+ this.view = this.list.view;
+ this.setAttribute("aria-selected", !!this.selected);
+ }
+
+ /**
+ * The 0-based position of this row in the list. Override this setter to
+ * fill layout based on values from the list's view. Always call back to
+ * this class's getter/setter when inheriting.
+ *
+ * @note Don't short-circuit the setter if the given index is equal to the
+ * existing index. Rows can be reused to display new data at the same index.
+ *
+ * @type {integer}
+ */
+ get index() {
+ return this._index;
+ }
+
+ set index(index) {
+ this.setAttribute(
+ "role",
+ this.list.table.body.getAttribute("role") === "tree"
+ ? "treeitem"
+ : "option"
+ );
+ this.setAttribute("aria-posinset", index + 1);
+ this.id = `${this.list.id}-row${index}`;
+
+ const isGroup = this.view.isContainer(index);
+ this.classList.toggle("children", isGroup);
+
+ const isGroupOpen = this.view.isContainerOpen(index);
+ if (isGroup) {
+ this.setAttribute("aria-expanded", isGroupOpen);
+ } else {
+ this.removeAttribute("aria-expanded");
+ }
+ this.classList.toggle("collapsed", !isGroupOpen);
+ this._index = index;
+
+ let table = this.closest("table");
+ for (let column of table.columns) {
+ let cell = this.querySelector(`.${column.id.toLowerCase()}-column`);
+ // No need to do anything if this cell doesn't exist. This can happen
+ // for non-table layouts.
+ if (!cell) {
+ continue;
+ }
+
+ // Always clear the colspan when updating the columns.
+ cell.removeAttribute("colspan");
+
+ // No need to do anything if this column is hidden.
+ if (cell.hidden) {
+ continue;
+ }
+
+ // Handle the special case for the selectable checkbox column.
+ if (column.select) {
+ let img = cell.firstElementChild;
+ if (!img) {
+ cell.classList.add("tree-view-row-select");
+ img = document.createElement("img");
+ img.src = "";
+ img.tabIndex = -1;
+ img.classList.add("tree-view-row-select-checkbox");
+ cell.replaceChildren(img);
+ }
+ document.l10n.setAttributes(
+ img,
+ this.list._selection.isSelected(index)
+ ? "tree-list-view-row-deselect"
+ : "tree-list-view-row-select"
+ );
+ continue;
+ }
+
+ // No need to do anything if an earlier call to this function already
+ // added the cell contents.
+ if (cell.firstElementChild) {
+ continue;
+ }
+ }
+
+ // Account for the column picker in the last visible column if the table
+ // if editable.
+ if (table.editable) {
+ let last = table.columns.filter(c => !c.hidden).pop();
+ this.querySelector(`.${last.id.toLowerCase()}-column`)?.setAttribute(
+ "colspan",
+ "2"
+ );
+ }
+ }
+
+ /**
+ * Tracks the selection state of the current row.
+ *
+ * @type {boolean}
+ */
+ get selected() {
+ return this.classList.contains("selected");
+ }
+
+ set selected(selected) {
+ this.setAttribute("aria-selected", !!selected);
+ this.classList.toggle("selected", !!selected);
+ }
+}
+customElements.define("tree-view-table-row", TreeViewTableRow, {
+ extends: "tr",
+});
+
+/**
+ * Simple tbody spacer used above and below the main tbody for space
+ * allocation and ensuring the correct scrollable height.
+ */
+class TreeViewTableSpacer extends HTMLTableSectionElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.cell = document.createElement("td");
+ const row = document.createElement("tr");
+ row.appendChild(this.cell);
+ this.appendChild(row);
+ }
+
+ /**
+ * Set the cell colspan to reflect the number of visible columns in order
+ * to generate a correct HTML markup.
+ *
+ * @param {int} count - The columns count.
+ */
+ setColspan(count) {
+ this.cell.setAttribute("colspan", count);
+ }
+
+ /**
+ * Set the height of the cell in order to occupy the empty area that will
+ * be filled by new rows on demand when needed.
+ *
+ * @param {int} val - The pixel height the row should occupy.
+ */
+ setHeight(val) {
+ this.cell.style.height = `${val}px`;
+ }
+}
+customElements.define("tree-view-table-spacer", TreeViewTableSpacer, {
+ extends: "tbody",
+});
diff --git a/comm/mail/base/jar.mn b/comm/mail/base/jar.mn
new file mode 100644
index 0000000000..53a295719e
--- /dev/null
+++ b/comm/mail/base/jar.mn
@@ -0,0 +1,144 @@
+#filter substitution
+
+messenger.jar:
+% content messagebody %content/messagebody/ contentaccessible=yes
+% content messenger %content/messenger/ contentaccessible=yes
+% override chrome://messagebody/skin/messageBody.css chrome://messenger/skin/messageBody.css
+% override chrome://messagebody/skin/abPrint.css chrome://messenger/skin/shared/abPrint.css
+% override chrome://global/content/commonDialog.xhtml chrome://messenger/content/commonDialog.xhtml
+# Override utilityOverlay.js from browser because it is used directly in devtools
+% override chrome://browser/content/utilityOverlay.js chrome://communicator/content/utilityOverlay.js
+ content/messenger/about3Pane.js (content/about3Pane.js)
+* content/messenger/about3Pane.xhtml (content/about3Pane.xhtml)
+ content/messenger/aboutAddonsExtra.js (content/aboutAddonsExtra.js)
+ content/messenger/aboutDialog-appUpdater.js (content/aboutDialog-appUpdater.js)
+ content/messenger/aboutDialog.css (content/aboutDialog.css)
+ content/messenger/aboutDialog.js (content/aboutDialog.js)
+* content/messenger/aboutDialog.xhtml (content/aboutDialog.xhtml)
+ content/messenger/aboutMessage.js (content/aboutMessage.js)
+* content/messenger/aboutMessage.xhtml (content/aboutMessage.xhtml)
+ content/messenger/aboutRights.xhtml (content/aboutRights.xhtml)
+ content/messenger/browserRequest.js (content/browserRequest.js)
+* content/messenger/browserRequest.xhtml (content/browserRequest.xhtml)
+ content/messenger/commonDialog.xhtml (content/commonDialog.xhtml)
+ content/messenger/compactFoldersDialog.js (content/compactFoldersDialog.js)
+ content/messenger/compactFoldersDialog.xhtml (content/compactFoldersDialog.xhtml)
+ content/messenger/customElements.js (content/customElements.js)
+ content/messenger/customizeToolbar.js (content/customizeToolbar.js)
+ content/messenger/customizeToolbar.xhtml (content/customizeToolbar.xhtml)
+ content/messenger/dialogShadowDom.js (content/dialogShadowDom.js)
+ content/messenger/editContactPanel.js (content/editContactPanel.js)
+ content/messenger/FilterListDialog.js (content/FilterListDialog.js)
+ content/messenger/FilterListDialog.xhtml (content/FilterListDialog.xhtml)
+ content/messenger/folderDisplay.js (content/folderDisplay.js)
+ content/messenger/globalOverlay.js (content/globalOverlay.js)
+ content/messenger/glodaFacetTab.js (content/glodaFacetTab.js)
+ content/messenger/glodaFacetView.js (content/glodaFacetView.js)
+ content/messenger/glodaFacetView.xhtml (content/glodaFacetView.xhtml)
+ content/messenger/glodaFacetViewWrapper.xhtml (content/glodaFacetViewWrapper.xhtml)
+ content/messenger/glodaFacetVis.js (content/glodaFacetVis.js)
+#ifdef XP_MACOSX
+ content/messenger/hiddenWindowMac.js (content/hiddenWindowMac.js)
+* content/messenger/hiddenWindowMac.xhtml (content/hiddenWindowMac.xhtml)
+ content/messenger/macMessengerMenu.js (content/macMessengerMenu.js)
+#endif
+ content/messenger/mail-offline.js (content/mail-offline.js)
+ content/messenger/mail3PaneWindowCommands.js (content/mail3PaneWindowCommands.js)
+ content/messenger/mailCommands.js (content/mailCommands.js)
+ content/messenger/mailCommon.js (content/mailCommon.js)
+ content/messenger/mailContext.js (content/mailContext.js)
+ content/messenger/mailCore.js (content/mailCore.js)
+ content/messenger/mailTabs.js (content/mailTabs.js)
+ content/messenger/mailWindow.js (content/mailWindow.js)
+ content/messenger/mailWindowOverlay.js (content/mailWindowOverlay.js)
+ content/messenger/messageWindow.js (content/messageWindow.js)
+* content/messenger/messageWindow.xhtml (content/messageWindow.xhtml)
+ content/messenger/messenger-customization.js (content/messenger-customization.js)
+ content/messenger/messenger.js (content/messenger.js)
+* content/messenger/messenger.xhtml (content/messenger.xhtml)
+ content/messenger/webextensions.css (content/webextensions.css)
+#ifdef XP_WIN
+ content/messenger/minimizeToTray.js (content/minimizeToTray.js)
+#endif
+ content/messenger/migrationProgress.js (content/migrationProgress.js)
+ content/messenger/migrationProgress.xhtml (content/migrationProgress.xhtml)
+ content/messenger/msgHdrView.js (content/msgHdrView.js)
+ content/messenger/msgSecurityPane.js (content/msgSecurityPane.js)
+ content/messenger/msgViewNavigation.js (content/msgViewNavigation.js)
+ content/messenger/multimessageview.js (content/multimessageview.js)
+ content/messenger/multimessageview.xhtml (content/multimessageview.xhtml)
+ content/messenger/newTagDialog.js (content/newTagDialog.js)
+* content/messenger/newTagDialog.xhtml (content/newTagDialog.xhtml)
+ content/messenger/printUtils.js (content/printUtils.js)
+ content/messenger/protovis-r2.6-modded.js (content/protovis-r2.6-modded.js)
+ content/messenger/quickFilterBar.js (content/quickFilterBar.js)
+ content/messenger/sanitize.js (content/sanitize.js)
+ content/messenger/sanitize.xhtml (content/sanitize.xhtml)
+ content/messenger/sanitizeDialog.js (content/sanitizeDialog.js)
+ content/messenger/searchBar.js (content/searchBar.js)
+ content/messenger/SearchDialog.js (content/SearchDialog.js)
+* content/messenger/SearchDialog.xhtml (content/SearchDialog.xhtml)
+ content/messenger/selectionsummaries.js (content/selectionsummaries.js)
+ content/messenger/shortcutsOverlay.js (content/shortcutsOverlay.js)
+ content/messenger/spacesToolbar.js (content/spacesToolbar.js)
+ content/messenger/specialTabs.js (content/specialTabs.js)
+#ifdef NIGHTLY_BUILD
+ content/messenger/sync.js (content/sync.js)
+#endif
+ content/messenger/systemIntegrationDialog.js (content/systemIntegrationDialog.js)
+ content/messenger/systemIntegrationDialog.xhtml (content/systemIntegrationDialog.xhtml)
+ content/messenger/tabmail.js (content/tabmail.js)
+ content/messenger/threadPane.js (content/threadPane.js)
+ content/messenger/toolbarIconColor.js (content/toolbarIconColor.js)
+ content/messenger/troubleshootMode.js (content/troubleshootMode.js)
+ content/messenger/troubleshootMode.xhtml (content/troubleshootMode.xhtml)
+ content/messenger/viewSource.js (content/viewSource.js)
+* content/messenger/viewSource.xhtml (content/viewSource.xhtml)
+ content/messenger/viewZoomOverlay.js (content/viewZoomOverlay.js)
+ content/messenger/browserPopups.js (content/widgets/browserPopups.js)
+ content/messenger/customizable-toolbar.js (content/widgets/customizable-toolbar.js)
+ content/messenger/foldersummary.js (content/widgets/foldersummary.js)
+ content/messenger/gloda-autocomplete-input.js (content/widgets/gloda-autocomplete-input.js)
+ content/messenger/glodaFacet.js (content/widgets/glodaFacet.js)
+ content/messenger/header-fields.js (content/widgets/header-fields.js)
+ content/messenger/mailWidgets.js (content/widgets/mailWidgets.js)
+ content/messenger/pane-splitter.js (content/widgets/pane-splitter.js)
+ content/messenger/statuspanel.js (content/widgets/statuspanel.js)
+ content/messenger/tabmail-tab.js (content/widgets/tabmail-tab.js)
+ content/messenger/tabmail-tabs.js (content/widgets/tabmail-tabs.js)
+ content/messenger/toolbarbutton-menu-button.js (content/widgets/toolbarbutton-menu-button.js)
+ content/messenger/tree-listbox.js (content/widgets/tree-listbox.js)
+ content/messenger/tree-selection.mjs (content/widgets/tree-selection.mjs)
+ content/messenger/tree-view.mjs (content/widgets/tree-view.mjs)
+ content/messenger/thread-pane-columns.mjs (content/modules/thread-pane-columns.mjs)
+
+# the following files are mail-specific overrides
+* content/messenger/license.html (/toolkit/content/license.html)
+% override chrome://global/content/license.html chrome://messenger/content/license.html
+ content/messenger/profileDowngrade.js (content/profileDowngrade.js)
+* content/messenger/profileDowngrade.xhtml (content/profileDowngrade.xhtml)
+% override chrome://mozapps/content/profile/profileDowngrade.js chrome://messenger/content/profileDowngrade.js
+% override chrome://mozapps/content/profile/profileDowngrade.xhtml chrome://messenger/content/profileDowngrade.xhtml
+
+* content/messenger/buildconfig.html (content/buildconfig.html)
+% override chrome://global/content/buildconfig.html chrome://messenger/content/buildconfig.html
+
+comm.jar:
+% content communicator %content/communicator/
+ content/communicator/contentAreaClick.js (content/contentAreaClick.js)
+ content/communicator/utilityOverlay.js (content/utilityOverlay.js)
+
+browser.jar:
+# Needed for built_in_addons.json
+% content browser %content/
+ content/extension.css (/browser/components/extensions/extension.css)
+#ifdef XP_LINUX
+ content/extension-linux-panel.css (/browser/components/extensions/extension-linux-panel.css)
+#endif
+#ifdef XP_MACOSX
+ content/extension-mac.css (/browser/components/extensions/extension-mac.css)
+ content/extension-mac-panel.css (/browser/components/extensions/extension-mac-panel.css)
+#endif
+#ifdef XP_WIN
+ content/extension-win-panel.css (/browser/components/extensions/extension-win-panel.css)
+#endif
diff --git a/comm/mail/base/moz.build b/comm/mail/base/moz.build
new file mode 100644
index 0000000000..15558e8d3c
--- /dev/null
+++ b/comm/mail/base/moz.build
@@ -0,0 +1,54 @@
+# vim: set filetype=python:
+# 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_DIRS += ["test"]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+DEFINES["PRE_RELEASE_SUFFIX"] = ""
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["MOZ_APP_VERSION_DISPLAY"] = CONFIG["MOZ_APP_VERSION_DISPLAY"]
+DEFINES["APP_LICENSE_BLOCK"] = "%s/content/overrides/app-license.html" % SRCDIR
+DEFINES["APP_LICENSE_PRODUCT_NAME"] = "%s/content/overrides/app-license-name.html" % SRCDIR
+DEFINES["APP_LICENSE_LIST_BLOCK"] = "%s/content/overrides/app-license-list.html" % SRCDIR
+DEFINES["APP_LICENSE_BODY_BLOCK"] = "%s/content/overrides/app-license-body.html" % SRCDIR
+
+if CONFIG["MOZILLA_OFFICIAL"]:
+ DEFINES["OFFICIAL_BUILD"] = 1
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] in ("windows", "gtk", "cocoa"):
+ DEFINES["HAVE_SHELL_SERVICE"] = 1
+
+if CONFIG["MOZ_UPDATER"]:
+ DEFINES["MOZ_UPDATER"] = 1
+
+# For customized buildconfig
+DEFINES["TOPOBJDIR"] = TOPOBJDIR
+
+DEFINES["MOZ_APP_DISPLAYNAME"] = CONFIG["MOZ_APP_DISPLAYNAME"]
+DEFINES["MOZ_APP_VERSION_DISPLAY"] = CONFIG["MOZ_APP_VERSION_DISPLAY"]
+DEFINES["THUNDERBIRD_DEVELOPER_WWW"] = "https://developer.thunderbird.net/"
+
+for var in ("CC", "CC_VERSION", "CXX", "RUSTC", "RUSTC_VERSION"):
+ if CONFIG[var]:
+ DEFINES[var] = CONFIG[var]
+
+for var in ("MOZ_CONFIGURE_OPTIONS",):
+ DEFINES[var] = CONFIG[var]
+
+ DEFINES["target"] = CONFIG["target"]
+
+DEFINES["CFLAGS"] = " ".join(CONFIG["OS_CFLAGS"])
+
+rustflags = CONFIG["RUSTFLAGS"]
+if not rustflags:
+ rustflags = []
+DEFINES["RUSTFLAGS"] = " ".join(rustflags)
+
+cxx_flags = []
+for var in ("OS_CPPFLAGS", "OS_CXXFLAGS", "DEBUG", "OPTIMIZE", "FRAMEPTR"):
+ cxx_flags += COMPILE_FLAGS[var] or []
+
+DEFINES["CXXFLAGS"] = " ".join(cxx_flags)
diff --git a/comm/mail/base/test/browser/browser-detachedWindows.ini b/comm/mail/base/test/browser/browser-detachedWindows.ini
new file mode 100644
index 0000000000..5932a9b682
--- /dev/null
+++ b/comm/mail/base/test/browser/browser-detachedWindows.ini
@@ -0,0 +1,15 @@
+[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.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = files/**
+
+[browser_detachedWindows.js]
+skip-if = debug
diff --git a/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini b/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini
new file mode 100644
index 0000000000..63963b8145
--- /dev/null
+++ b/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini
@@ -0,0 +1,17 @@
+[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.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ mail.tabs.drawInTitlebar=false
+subsuite = thunderbird
+support-files =
+ head_spacesToolbar.js
+skip-if = os == 'mac'
+
+[browser_spacesToolbar_drawBelowTitlebar.js]
diff --git a/comm/mail/base/test/browser/browser-drawInTitlebar.ini b/comm/mail/base/test/browser/browser-drawInTitlebar.ini
new file mode 100644
index 0000000000..0b73400c5a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser-drawInTitlebar.ini
@@ -0,0 +1,17 @@
+[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.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ mail.tabs.drawInTitlebar=true
+subsuite = thunderbird
+support-files =
+ head_spacesToolbar.js
+skip-if = os == 'mac'
+
+[browser_spacesToolbar_drawInTitlebar.js]
diff --git a/comm/mail/base/test/browser/browser.ini b/comm/mail/base/test/browser/browser.ini
new file mode 100644
index 0000000000..4ff3a5d866
--- /dev/null
+++ b/comm/mail/base/test/browser/browser.ini
@@ -0,0 +1,66 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.biff.show_alert=false
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = files/**
+
+[browser_3paneTelemetry.js]
+[browser_archive.js]
+[browser_browserContext.js]
+tags = contextmenu webextensions
+[browser_browserRequestWindow.js]
+[browser_cardsView.js]
+[browser_editMenu.js]
+skip-if = os == 'mac'
+[browser_fileMenu.js]
+skip-if = os == 'mac'
+[browser_folderPaneContext.js]
+tags = contextmenu
+[browser_folderTreeProperties.js]
+[browser_folderTreeQuirks.js]
+[browser_formPickers.js]
+tags = webextensions
+[browser_goMenu.js]
+skip-if = os == 'mac'
+[browser_interactionTelemetry.js]
+[browser_linkHandler.js]
+[browser_mailContext.js]
+tags = contextmenu
+[browser_mailTabsAndWindows.js]
+[browser_markAsRead.js]
+[browser_menulist.js]
+skip-if = os == 'mac'
+[browser_messageMenu.js]
+skip-if = os == 'mac'
+[browser_navigation.js]
+[browser_orderableTreeListbox.js]
+[browser_paneFocus.js]
+[browser_paneSplitter.js]
+[browser_preferDisplayName.js]
+[browser_searchMessages.js]
+[browser_smartFolderDelete.js]
+[browser_spacesToolbar.js]
+[browser_spacesToolbarCustomize.js]
+[browser_selectionWidgetController.js]
+[browser_statusFeedback.js]
+[browser_tabIcon.js]
+[browser_tagsMode.js]
+[browser_threads.js]
+[browser_threadTreeDeleting.js]
+[browser_threadTreeQuirks.js]
+[browser_threadTreeSorting.js]
+[browser_toolsMenu.js]
+skip-if = os == 'mac'
+[browser_treeListbox.js]
+[browser_treeView.js]
+[browser_viewMenu.js]
+skip-if = os == 'mac'
+[browser_webSearchTelemetry.js]
+[browser_zoom.js]
diff --git a/comm/mail/base/test/browser/browser_3paneTelemetry.js b/comm/mail/base/test/browser/browser_3paneTelemetry.js
new file mode 100644
index 0000000000..84af064e84
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_3paneTelemetry.js
@@ -0,0 +1,163 @@
+/* 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 { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+var tabmail = document.getElementById("tabmail");
+var folders = {};
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ let rootFolder = account.incomingServer.rootFolder;
+
+ for (let type of ["Drafts", "SentMail", "Templates", "Junk", "Archive"]) {
+ rootFolder.createSubfolder(`telemetry${type}`, null);
+ folders[type] = rootFolder.getChildNamed(`telemetry${type}`);
+ folders[type].setFlag(Ci.nsMsgFolderFlags[type]);
+ }
+ rootFolder.createSubfolder("telemetryPlain", null);
+ folders.Other = rootFolder.getChildNamed("telemetryPlain");
+
+ let { paneLayout } = tabmail.currentAbout3Pane;
+ let folderPaneVisibleAtStart = paneLayout.folderPaneVisible;
+ let messagePaneVisibleAtStart = paneLayout.messagePaneVisible;
+
+ registerCleanupFunction(function () {
+ MailServices.accounts.removeAccount(account, false);
+ tabmail.closeOtherTabs(0);
+ if (paneLayout.folderPaneVisible != folderPaneVisibleAtStart) {
+ goDoCommand("cmd_toggleFolderPane");
+ }
+ if (paneLayout.messagePaneVisible != messagePaneVisibleAtStart) {
+ goDoCommand("cmd_toggleMessagePane");
+ }
+ });
+});
+
+add_task(async function testFolderOpen() {
+ Services.telemetry.clearScalars();
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.displayFolder(folders.Other.URI);
+
+ let scalarName = "tb.mails.folder_opened";
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 1);
+
+ about3Pane.displayFolder(folders.Templates.URI);
+ about3Pane.displayFolder(folders.Other.URI);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 2);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 1);
+
+ about3Pane.displayFolder(folders.Junk.URI);
+ about3Pane.displayFolder(folders.Other.URI);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 3);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 1);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Junk", 1);
+
+ about3Pane.displayFolder(folders.Junk.URI);
+ about3Pane.displayFolder(folders.Templates.URI);
+ about3Pane.displayFolder(folders.Archive.URI);
+ about3Pane.displayFolder(folders.Other.URI);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 4);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 2);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Archive", 1);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Junk", 2);
+});
+
+add_task(async function testPaneVisibility() {
+ let { paneLayout, displayFolder } = tabmail.currentAbout3Pane;
+ displayFolder(folders.Other.URI);
+ // Make the folder pane and message pane visible initially.
+ if (!paneLayout.folderPaneVisible) {
+ goDoCommand("cmd_toggleFolderPane");
+ }
+ if (!paneLayout.messagePaneVisible) {
+ goDoCommand("cmd_toggleMessagePane");
+ }
+ // The scalar is updated by switching to the folder tab, so open another tab.
+ window.openContentTab("about:mozilla");
+
+ Services.telemetry.clearScalars();
+
+ tabmail.switchToTab(0);
+
+ let scalarName = "tb.ui.configuration.pane_visibility";
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "folderPane", true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "messagePane",
+ true
+ );
+
+ // Hide the folder pane.
+ goDoCommand("cmd_toggleFolderPane");
+ tabmail.switchToTab(1);
+ tabmail.switchToTab(0);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "folderPane",
+ false
+ );
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "messagePane",
+ true
+ );
+
+ // Hide the message pane.
+ goDoCommand("cmd_toggleMessagePane");
+ tabmail.switchToTab(1);
+ tabmail.switchToTab(0);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "folderPane",
+ false
+ );
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "messagePane",
+ false
+ );
+
+ // Show both panes again.
+ goDoCommand("cmd_toggleFolderPane");
+ goDoCommand("cmd_toggleMessagePane");
+ tabmail.switchToTab(1);
+ tabmail.switchToTab(0);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "folderPane", true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "messagePane",
+ true
+ );
+
+ // Close the extra tab.
+ tabmail.closeOtherTabs(0);
+});
diff --git a/comm/mail/base/test/browser/browser_archive.js b/comm/mail/base/test/browser/browser_archive.js
new file mode 100644
index 0000000000..6a84aff45a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_archive.js
@@ -0,0 +1,98 @@
+/* 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 messenger */
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const tabmail = document.getElementById("tabmail");
+const about3Pane = tabmail.currentAbout3Pane;
+const { threadTree } = about3Pane;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false);
+ // Create an account for the test.
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ // Remove test account on cleanup.
+ registerCleanupFunction(() => {
+ // This test should create mailbox://nobody@Local%20Folders/Archives/2000.
+ // Tests following this one may attempt to create a folder at the same URI
+ // and will fail because our folder lookup code is a mess. Renaming should
+ // prevent that.
+ let archiveFolder = rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Archive
+ );
+ archiveFolder?.subFolders[0]?.rename("archive2000", null);
+ archiveFolder?.rename("archiveArchives", null);
+
+ MailServices.accounts.removeAccount(account, false);
+ // Clear the undo and redo stacks to avoid side-effects on
+ // tests expecting them to start in a cleared state.
+ messenger.transactionManager.clear();
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true);
+ });
+
+ // Create a folder for the account to store test messages.
+ const rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test-archive", null);
+ const testFolder = rootFolder
+ .getChildNamed("test-archive")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ // Generate test messages.
+ const generator = new MessageGenerator();
+ testFolder.addMessageBatch(
+ generator
+ .makeMessages({ count: 2, msgsPerThread: 2 })
+ .map(message => message.toMboxString())
+ );
+
+ // Use the test folder.
+ about3Pane.displayFolder(testFolder.URI);
+});
+
+/**
+ * Tests undoing after archiving a thread.
+ */
+add_task(async function testArchiveUndo() {
+ let row = threadTree.getRowAtIndex(0);
+
+ // Simulate a click on the row's subject line to select the row.
+ const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".thread-card-subject-container"),
+ { clickCount: 1 },
+ about3Pane
+ );
+ await selectPromise;
+
+ // Make sure the thread is selected
+ Assert.ok(
+ row.classList.contains("selected"),
+ "The thread row should be selected"
+ );
+
+ // Archive the message.
+ EventUtils.synthesizeKey("a");
+
+ // Make sure the thread was removed from the thread tree.
+ await TestUtils.waitForCondition(
+ () => threadTree.getRowAtIndex(0) === null,
+ "The thread tree should not have any row"
+ );
+
+ // Undo the operation.
+ EventUtils.synthesizeKey("z", { accelKey: true });
+
+ // Make sure the thread makes it back to the thread tree.
+ await TestUtils.waitForCondition(
+ () => threadTree.getRowAtIndex(0) !== null,
+ "The thread should have returned back from the archive"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_browserContext.js b/comm/mail/base/test/browser/browser_browserContext.js
new file mode 100644
index 0000000000..2691eba80e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_browserContext.js
@@ -0,0 +1,398 @@
+/* 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/. */
+
+/* eslint-env webextensions */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const TEST_DOCUMENT_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html";
+const TEST_MESSAGE_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.eml";
+const TEST_IMAGE_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png";
+
+let about3Pane, testFolder;
+
+async function getImageArrayBuffer() {
+ let response = await fetch(TEST_IMAGE_URL);
+ let blob = await response.blob();
+
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.addEventListener("loadend", event => {
+ resolve(event.target.result);
+ });
+ reader.readAsArrayBuffer(blob);
+ });
+}
+
+function checkMenuitems(menu, ...expectedItems) {
+ if (expectedItems.length == 0) {
+ // Menu should not be shown.
+ Assert.equal(menu.state, "closed");
+ return;
+ }
+
+ Assert.notEqual(menu.state, "closed");
+
+ let actualItems = [];
+ for (let item of menu.children) {
+ if (
+ ["menu", "menuitem", "menugroup"].includes(item.localName) &&
+ !item.hidden
+ ) {
+ actualItems.push(item.id);
+ }
+ }
+ Assert.deepEqual(actualItems, expectedItems);
+}
+
+async function checkABrowser(browser, doc = browser.ownerDocument) {
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ !browser.currentURI ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ undefined,
+ url => url != "about:blank"
+ );
+ }
+
+ let browserContext = doc.getElementById("browserContext");
+ let isMac = AppConstants.platform == "macosx";
+ let isWebPage =
+ browser.currentURI.schemeIs("http") || browser.currentURI.schemeIs("https");
+ let isExtensionPage = browser.currentURI.schemeIs("moz-extension");
+
+ // Just some text.
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ browserContext,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "p",
+ { type: "contextmenu" },
+ browser
+ );
+ await shownPromise;
+
+ let expectedContextItems = [];
+ if (isWebPage || isExtensionPage) {
+ if (isMac) {
+ // Mac has the nav items directly in the context menu and not in the horizontal
+ // context-navigation menugroup.
+ expectedContextItems.push(
+ "browserContext-back",
+ "browserContext-forward",
+ "browserContext-reload"
+ );
+ } else {
+ expectedContextItems.push("context-navigation");
+ checkMenuitems(
+ doc.getElementById("context-navigation"),
+ "browserContext-back",
+ "browserContext-forward",
+ "browserContext-reload"
+ );
+ }
+ }
+ if (isWebPage) {
+ expectedContextItems.push("browserContext-openInBrowser");
+ }
+ expectedContextItems.push("browserContext-selectall");
+ checkMenuitems(browserContext, ...expectedContextItems);
+ browserContext.hidePopup();
+
+ // A link.
+
+ shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a",
+ { type: "contextmenu" },
+ browser
+ );
+ await shownPromise;
+ checkMenuitems(
+ browserContext,
+ "browserContext-openLinkInBrowser",
+ "browserContext-selectall",
+ "browserContext-copylink",
+ "browserContext-savelink"
+ );
+ browserContext.hidePopup();
+
+ // A text input widget.
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("input", {}, browser);
+ shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "input",
+ { type: "contextmenu" },
+ browser
+ );
+ await shownPromise;
+ checkMenuitems(
+ browserContext,
+ "browserContext-undo",
+ "browserContext-cut",
+ "browserContext-copy",
+ "browserContext-paste",
+ "browserContext-selectall",
+ "browserContext-spell-check-enabled"
+ );
+ browserContext.hidePopup();
+
+ // An image. Also checks Save Image As works.
+
+ shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "img",
+ { type: "contextmenu" },
+ browser
+ );
+ await shownPromise;
+ checkMenuitems(
+ browserContext,
+ "browserContext-selectall",
+ "browserContext-copyimage",
+ "browserContext-saveimage"
+ );
+
+ let pickerPromise = new Promise(resolve => {
+ SpecialPowers.MockFilePicker.init(window);
+ SpecialPowers.MockFilePicker.showCallback = picker => {
+ resolve(picker.defaultString);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ });
+ browserContext.activateItem(doc.getElementById("browserContext-saveimage"));
+ Assert.equal(await pickerPromise, "tb-logo.png");
+ SpecialPowers.MockFilePicker.cleanup();
+}
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("browserContextFolder", null);
+ testFolder = rootFolder
+ .getChildNamed("browserContextFolder")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ let message = await fetch(TEST_MESSAGE_URL).then(r => r.text());
+ testFolder.addMessageBatch([message]);
+ let messages = new MessageGenerator().makeMessages({ count: 5 });
+ let messageStrings = messages.map(message => message.toMboxString());
+ testFolder.addMessageBatch(messageStrings);
+
+ about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderURI: testFolder.URI,
+ messagePaneVisible: true,
+ });
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testMessagePane() {
+ about3Pane.messagePane.displayWebPage(TEST_DOCUMENT_URL);
+ await checkABrowser(about3Pane.webBrowser, document);
+ about3Pane.messagePane.clearWebPage();
+});
+
+add_task(async function testContentTab() {
+ let tab = window.openContentTab(TEST_DOCUMENT_URL);
+ await checkABrowser(tab.browser);
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
+
+add_task(async function testExtensionTab() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ await browser.tabs.create({ url: "sampleContent.html" });
+ browser.test.notifyPass("ready");
+ },
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("ready");
+
+ let tabmail = document.getElementById("tabmail");
+ await checkABrowser(tabmail.tabInfo[1].browser);
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionPopupWindow() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ await browser.windows.create({
+ url: "sampleContent.html",
+ type: "popup",
+ width: 800,
+ height: 500,
+ });
+ browser.test.notifyPass("ready");
+ },
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("ready");
+
+ let extensionPopup = Services.wm.getMostRecentWindow("mail:extensionPopup");
+ // extensionPopup.xhtml needs time to initialise properly.
+ await new Promise(resolve => extensionPopup.setTimeout(resolve, 500));
+ await checkABrowser(extensionPopup.document.getElementById("requestFrame"));
+ await BrowserTestUtils.closeWindow(extensionPopup);
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionBrowserAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browsercontext@mochi.test",
+ },
+ },
+ browser_action: {
+ default_popup: "sampleContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let { panel, browser } = await openExtensionPopup(
+ window,
+ "ext-browsercontext\\@mochi.test"
+ );
+ await TestUtils.waitForCondition(
+ () => browser.clientWidth > 100,
+ "waiting for browser to resize"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionComposeAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browsercontext@mochi.test",
+ },
+ },
+ compose_action: {
+ default_popup: "sampleContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "load");
+
+ let { panel, browser } = await openExtensionPopup(
+ composeWindow,
+ "browsercontext_mochi_test-composeAction-toolbarbutton"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(composeWindow);
+});
+
+add_task(async function testExtensionMessageDisplayAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browsercontext@mochi.test",
+ },
+ },
+ message_display_action: {
+ default_popup: "sampleContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let messageWindowPromise = BrowserTestUtils.domWindowOpened();
+ window.MsgOpenNewWindowForMessage([...testFolder.messages][0]);
+ let messageWindow = await messageWindowPromise;
+ let { target: aboutMessage } = await BrowserTestUtils.waitForEvent(
+ messageWindow,
+ "aboutMessageLoaded"
+ );
+
+ let { panel, browser } = await openExtensionPopup(
+ aboutMessage,
+ "browsercontext_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(messageWindow);
+});
diff --git a/comm/mail/base/test/browser/browser_browserRequestWindow.js b/comm/mail/base/test/browser/browser_browserRequestWindow.js
new file mode 100644
index 0000000000..47309e5fc6
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_browserRequestWindow.js
@@ -0,0 +1,74 @@
+/* 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 ../../content/browserRequest.js */
+
+/**
+ * Open the browserRequest window.
+ *
+ * @returns {{cancelledPromise: Promise, requestWindow: DOMWindow}}
+ */
+async function openBrowserRequestWindow() {
+ let onCancelled;
+ let cancelledPromise = new Promise(resolve => {
+ onCancelled = resolve;
+ });
+ let requestWindow = await new Promise(resolve => {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/browserRequest.xhtml",
+ null,
+ "chrome,private,centerscreen,width=980,height=750",
+ {
+ url: "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html",
+ cancelled() {
+ onCancelled();
+ },
+ loaded(window, webProgress) {
+ resolve(window);
+ },
+ }
+ );
+ });
+ return { cancelledPromise, requestWindow };
+}
+
+add_task(async function test_urlBar() {
+ let { requestWindow, cancelledPromise } = await openBrowserRequestWindow();
+
+ let browser = requestWindow.getBrowser();
+ await BrowserTestUtils.browserLoaded(browser);
+ ok(browser, "Got a browser from global getBrowser function");
+
+ let urlBar = requestWindow.document.getElementById("headerMessage");
+ is(urlBar.value, browser.currentURI.spec, "Initial page is shown in URL bar");
+
+ let redirect = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, "about:blank");
+ await redirect;
+ is(urlBar.value, "about:blank", "URL bar value follows browser");
+
+ const closeEvent = new Event("close");
+ requestWindow.dispatchEvent(closeEvent);
+ await BrowserTestUtils.closeWindow(requestWindow);
+ await cancelledPromise;
+});
+
+add_task(async function test_cancelWithEsc() {
+ let { requestWindow, cancelledPromise } = await openBrowserRequestWindow();
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, requestWindow);
+ await cancelledPromise;
+});
+
+add_task(async function test_cancelWithAccelW() {
+ let { requestWindow, cancelledPromise } = await openBrowserRequestWindow();
+
+ EventUtils.synthesizeKey(
+ "w",
+ { [AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"]: true },
+ requestWindow
+ );
+ await cancelledPromise;
+});
diff --git a/comm/mail/base/test/browser/browser_cardsView.js b/comm/mail/base/test/browser/browser_cardsView.js
new file mode 100644
index 0000000000..462e21fba3
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_cardsView.js
@@ -0,0 +1,248 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { click_through_appmenu } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let { threadPane, threadTree } = about3Pane;
+let rootFolder, testFolder, testMessages, displayContext, displayButton;
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("cardsView", null);
+ testFolder = rootFolder
+ .getChildNamed("cardsView")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ let generator = new MessageGenerator();
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ about3Pane.displayFolder(testFolder.URI);
+ about3Pane.paneLayout.messagePaneVisible = false;
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ about3Pane.paneLayout.messagePaneVisible = true;
+ about3Pane.folderTree.focus();
+ });
+});
+
+add_task(async function testSwitchToCardsView() {
+ Assert.ok(
+ threadTree.getAttribute("rows") == "thread-card",
+ "The tree view should have a card layout"
+ );
+
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }],
+ { id: "appmenu_messagePaneClassic" },
+ window
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-card",
+ "The tree view should not switch to a table layout"
+ );
+
+ displayContext = about3Pane.document.getElementById(
+ "threadPaneDisplayContext"
+ );
+ displayButton = about3Pane.document.getElementById("threadPaneDisplayButton");
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ displayContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane);
+ await shownPromise;
+
+ Assert.ok(
+ displayContext
+ .querySelector("#threadPaneCardsView")
+ .getAttribute("checked"),
+ "The cards view menuitem should be checked"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ displayContext,
+ "popuphidden"
+ );
+ displayContext.activateItem(
+ displayContext.querySelector("#threadPaneTableView")
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view switched to a table layout"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {});
+ await hiddenPromise;
+
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }],
+ { id: "appmenu_messagePaneVertical" },
+ window
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view should not switch to a card layout"
+ );
+
+ Assert.equal(
+ threadTree.table.body.getAttribute("role"),
+ "tree",
+ "The message list table should be presented as Tree View"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "treeitem",
+ "The message row should be presented as Tree Item"
+ );
+
+ displayContext = about3Pane.document.getElementById(
+ "threadPaneDisplayContext"
+ );
+ displayButton = about3Pane.document.getElementById("threadPaneDisplayButton");
+ shownPromise = BrowserTestUtils.waitForEvent(displayContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane);
+ await shownPromise;
+
+ Assert.ok(
+ displayContext
+ .querySelector("#threadPaneTableView")
+ .getAttribute("checked"),
+ "The table view menuitem should be checked"
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(displayContext, "popuphidden");
+ displayContext.activateItem(
+ displayContext.querySelector("#threadPaneCardsView")
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-card",
+ "The tree view switched to a card layout"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {});
+ await hiddenPromise;
+
+ Assert.equal(
+ threadTree.getAttribute("rows"),
+ "thread-card",
+ "tree view in cards layout"
+ );
+ Assert.equal(
+ threadTree.table.body.getAttribute("role"),
+ "tree",
+ "The message list table should remain as Tree View"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "treeitem",
+ "The message row should remain as Tree Item"
+ );
+
+ let row = threadTree.getRowAtIndex(0);
+ let star = row.querySelector(".button-star");
+ Assert.ok(BrowserTestUtils.is_visible(star), "star icon should be visible");
+ let tag = row.querySelector(".tag-icon");
+ Assert.ok(BrowserTestUtils.is_hidden(tag), "tag icon should be hidden");
+ let attachment = row.querySelector(".attachment-icon");
+ Assert.ok(
+ BrowserTestUtils.is_hidden(attachment),
+ "attachment icon should be hidden"
+ );
+
+ // Switching to horizontal view shouldn't affect the list layout.
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }],
+ { id: "appmenu_messagePaneClassic" },
+ window
+ );
+
+ Assert.equal(
+ threadTree.getAttribute("rows"),
+ "thread-card",
+ "tree view in cards layout"
+ );
+ about3Pane.folderTree.focus();
+});
+
+add_task(async function testTagsInVerticalView() {
+ let row = threadTree.getRowAtIndex(1);
+ EventUtils.synthesizeMouseAtCenter(row, {}, about3Pane);
+ Assert.ok(row.classList.contains("selected"), "the row should be selected");
+
+ let tag = row.querySelector(".tag-icon");
+ Assert.ok(BrowserTestUtils.is_hidden(tag), "tag icon should be hidden");
+
+ // Set the important tag.
+ EventUtils.synthesizeKey("1", {});
+ Assert.ok(BrowserTestUtils.is_visible(tag), "tag icon should be visible");
+ Assert.deepEqual(tag.title, "Important", "The important tag should be set");
+
+ let row2 = threadTree.getRowAtIndex(2);
+ EventUtils.synthesizeMouseAtCenter(row2, {}, about3Pane);
+ Assert.ok(
+ row2.classList.contains("selected"),
+ "the third row should be selected"
+ );
+
+ let tag2 = row2.querySelector(".tag-icon");
+ Assert.ok(BrowserTestUtils.is_hidden(tag2), "tag icon should be hidden");
+
+ // Set the work tag.
+ EventUtils.synthesizeKey("2", {});
+ Assert.ok(BrowserTestUtils.is_visible(tag2), "tag icon should be visible");
+ Assert.deepEqual(tag2.title, "Work", "The work tag should be set");
+
+ // Switch back to a table layout and horizontal view.
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ displayContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane);
+ await shownPromise;
+
+ Assert.ok(
+ displayContext
+ .querySelector("#threadPaneCardsView")
+ .getAttribute("checked"),
+ "The cards view menuitem should be checked"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ displayContext,
+ "popuphidden"
+ );
+ displayContext.activateItem(
+ displayContext.querySelector("#threadPaneTableView")
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view switched to a table layout"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {});
+ await hiddenPromise;
+
+ Assert.equal(
+ threadTree.getAttribute("rows"),
+ "thread-row",
+ "tree view in table layout"
+ );
+
+ await ensure_cards_view();
+ about3Pane.folderTree.focus();
+});
diff --git a/comm/mail/base/test/browser/browser_detachedWindows.js b/comm/mail/base/test/browser/browser_detachedWindows.js
new file mode 100644
index 0000000000..a523f4a799
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_detachedWindows.js
@@ -0,0 +1,223 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let manager = Cc["@mozilla.org/memory-reporter-manager;1"].getService(
+ Ci.nsIMemoryReporterManager
+);
+
+let tabmail = document.getElementById("tabmail");
+let testFolder;
+let testMessages;
+
+add_setup(async function () {
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("detachedWindows", null);
+ testFolder = rootFolder
+ .getChildNamed("detachedWindows")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+
+ info("Initial state:");
+ await getWindows();
+});
+
+add_task(async function test3PaneTab() {
+ info("Opening a new 3-pane tab");
+ window.MsgOpenNewTabForFolders([testFolder], {
+ background: false,
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ });
+ let tab = tabmail.tabInfo[1];
+ await BrowserTestUtils.waitForEvent(
+ tab.chromeBrowser,
+ "aboutMessageLoaded",
+ true
+ );
+ await new Promise(resolve =>
+ tab.chromeBrowser.contentWindow.setTimeout(resolve, 500)
+ );
+
+ tab.chromeBrowser.contentWindow.threadTree.selectedIndex = 0;
+ await BrowserTestUtils.waitForEvent(tab.chromeBrowser, "MsgLoaded");
+ await new Promise(resolve =>
+ tab.chromeBrowser.contentWindow.setTimeout(resolve, 500)
+ );
+
+ info("Closing the tab");
+ tabmail.closeOtherTabs(0);
+ tab = null;
+
+ await assertNoDetachedWindows();
+});
+
+add_task(async function testMessageTab() {
+ info("Opening a new message tab");
+ window.OpenMessageInNewTab(testMessages[0], { background: false });
+ let tab = tabmail.tabInfo[1];
+ await BrowserTestUtils.waitForEvent(
+ tab.chromeBrowser,
+ "aboutMessageLoaded",
+ true
+ );
+ await new Promise(resolve =>
+ tab.chromeBrowser.contentWindow.setTimeout(resolve, 500)
+ );
+
+ info("Closing the tab");
+ tabmail.closeOtherTabs(0);
+ tab = null;
+
+ await assertNoDetachedWindows();
+});
+
+add_task(async function testMessageWindow() {
+ info("Opening a standalone message window");
+ let win = await openMessageFromFile(
+ new FileUtils.File(getTestFilePath("files/sampleContent.eml"))
+ );
+ await new Promise(resolve => win.setTimeout(resolve, 500));
+
+ info("Closing the window");
+ await BrowserTestUtils.closeWindow(win);
+ win = null;
+
+ await assertNoDetachedWindows();
+});
+
+add_task(async function testSearchMessagesDialog() {
+ info("Opening the search messages dialog");
+ let about3Pane = tabmail.currentAbout3Pane;
+ let context = about3Pane.document.getElementById("folderPaneContext");
+ let searchMessagesItem = about3Pane.document.getElementById(
+ "folderPaneContext-searchMessages"
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.folderPane.getRowForFolder(testFolder).querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ let searchWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ w =>
+ w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ context.activateItem(searchMessagesItem);
+ let searchWindow = await searchWindowPromise;
+
+ await new Promise(resolve => searchWindow.setTimeout(resolve, 500));
+
+ info("Closing the dialog");
+ await BrowserTestUtils.closeWindow(searchWindow);
+ searchWindowPromise = null;
+ searchWindow = null;
+
+ await assertNoDetachedWindows();
+});
+
+add_task(async function testAddressBookTab() {
+ info("Opening the Address Book");
+ window.toAddressBook();
+ let tab = tabmail.tabInfo[1];
+ await BrowserTestUtils.waitForEvent(
+ tab.browser,
+ "about-addressbook-ready",
+ true
+ );
+ await new Promise(resolve =>
+ tab.browser.contentWindow.setTimeout(resolve, 500)
+ );
+
+ info("Closing the tab");
+ tabmail.closeOtherTabs(0);
+ tab = null;
+
+ await assertNoDetachedWindows();
+});
+
+async function getWindows() {
+ await new Promise(resolve => manager.minimizeMemoryUsage(resolve));
+
+ let windows = new Set();
+ await new Promise(resolve =>
+ manager.getReports(
+ (process, path, kind, units, amount, description) => {
+ if (path.startsWith("explicit/window-objects/top")) {
+ path = path.replace("top(none)", "top");
+ path = path.substring(0, path.indexOf(")") + 1);
+ path = path.replace(/\\/g, "/");
+ windows.add(path);
+ }
+ },
+ null,
+ resolve,
+ null,
+ false
+ )
+ );
+
+ for (let win of windows) {
+ info(win);
+ }
+
+ return [...windows];
+}
+
+async function assertNoDetachedWindows() {
+ info("Remaining windows:");
+ let windows = await getWindows();
+
+ let noDetachedWindows = true;
+ for (let win of windows) {
+ if (win.includes("detached")) {
+ noDetachedWindows = false;
+ let url = win.substring(win.indexOf("(") + 1, win.indexOf(")"));
+ Assert.report(true, undefined, undefined, `detached window: ${url}`);
+ }
+ }
+
+ if (noDetachedWindows) {
+ Assert.report(false, undefined, undefined, "no detached windows");
+ }
+}
+
+async function openMessageFromFile(file) {
+ let fileURL = Services.io
+ .newFileURI(file)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ );
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+ return win;
+}
diff --git a/comm/mail/base/test/browser/browser_editMenu.js b/comm/mail/base/test/browser/browser_editMenu.js
new file mode 100644
index 0000000000..d320a12e36
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_editMenu.js
@@ -0,0 +1,511 @@
+/* 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 { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+/** @type MenuData */
+const editMenuData = {
+ menu_undo: { disabled: true },
+ menu_redo: { disabled: true },
+ menu_cut: { disabled: true },
+ menu_copy: { disabled: true },
+ menu_paste: { disabled: true },
+ menu_delete: { disabled: true, l10nID: "text-action-delete" },
+ menu_select: {},
+ menu_SelectAll: {},
+ menu_selectThread: { disabled: true },
+ menu_selectFlagged: { disabled: true },
+ menu_find: {},
+ menu_findCmd: { disabled: true },
+ menu_findAgainCmd: { disabled: true },
+ searchMailCmd: {},
+ glodaSearchCmd: {},
+ searchAddressesCmd: {},
+ menu_favoriteFolder: { disabled: true },
+ menu_properties: { disabled: true },
+ "calendar-properties-menuitem": { disabled: true },
+};
+if (AppConstants.platform == "linux") {
+ editMenuData.menu_preferences = {};
+ editMenuData.menu_accountmgr = {};
+}
+let helper = new MenuTestHelper("menu_Edit", editMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, testMessages, virtualFolder;
+let nntpRootFolder, nntpFolder;
+let imapRootFolder, imapFolder;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+ window.messenger.transactionManager.clear();
+
+ const generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("edit menu", null);
+ testFolder = rootFolder
+ .getChildNamed("edit menu")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({}).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ rootFolder.createSubfolder("edit menu virtual", null);
+ virtualFolder = rootFolder.getChildNamed("edit menu virtual");
+ virtualFolder.setFlag(Ci.nsMsgFolderFlags.Virtual);
+ let msgDatabase = virtualFolder.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+ folderInfo.setCharProperty("searchStr", "ALL");
+ folderInfo.setCharProperty("searchFolderUri", testFolder.URI);
+
+ NNTPServer.open();
+ NNTPServer.addGroup("edit.menu.newsgroup");
+ let nntpAccount = MailServices.accounts.createAccount();
+ nntpAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ `${nntpAccount.key}user`,
+ "localhost",
+ "nntp"
+ );
+ nntpAccount.incomingServer.port = NNTPServer.port;
+ nntpRootFolder = nntpAccount.incomingServer.rootFolder;
+ nntpRootFolder.createSubfolder("edit.menu.newsgroup", null);
+ nntpFolder = nntpRootFolder.getChildNamed("edit.menu.newsgroup");
+
+ IMAPServer.open();
+ let imapAccount = MailServices.accounts.createAccount();
+ imapAccount.addIdentity(MailServices.accounts.createIdentity());
+ imapAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ `${imapAccount.key}user`,
+ "localhost",
+ "imap"
+ );
+ imapAccount.incomingServer.port = IMAPServer.port;
+ imapAccount.incomingServer.username = "user";
+ imapAccount.incomingServer.password = "password";
+ imapAccount.incomingServer.deleteModel = Ci.nsMsgImapDeleteModels.IMAPDelete;
+ imapRootFolder = imapAccount.incomingServer.rootFolder;
+ imapFolder = imapRootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox);
+ IMAPServer.addMessages(imapFolder, generator.makeMessages({}));
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ MailServices.accounts.removeAccount(nntpAccount, false);
+ MailServices.accounts.removeAccount(imapAccount, false);
+ NNTPServer.close();
+ IMAPServer.close();
+ });
+});
+
+add_task(async function test3PaneTab() {
+ await helper.testAllItems("mail3PaneTab");
+});
+
+/**
+ * Tests the "Delete" item in the menu. This item calls cmd_delete, which does
+ * various things depending on the current context.
+ */
+add_task(async function testDeleteItem() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ let { displayFolder, folderTree, paneLayout, threadTree } = about3Pane;
+ paneLayout.messagePaneVisible = true;
+
+ // Focus on the folder tree and check that an NNTP account shows
+ // "Unsubscribe Folder". The account can't be deleted this way so the menu
+ // item should be disabled.
+
+ folderTree.focus();
+ displayFolder(nntpRootFolder);
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // Check that an NNTP folder shows "Unsubscribe Folder". Then check that
+ // calling cmd_delete actually attempts to unsubscribe the folder.
+
+ displayFolder(nntpFolder);
+ await Promise.all([
+ BrowserTestUtils.promiseAlertDialog("cancel"),
+ helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-unsubscribe-newsgroup",
+ }),
+ ]);
+
+ // Check that a mail account shows "Delete Folder". The account can't be
+ // deleted this way so the menu item should be disabled.
+
+ displayFolder(rootFolder);
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // Check that focus on the folder tree and a mail folder shows "Delete
+ // Folder". Then check that calling cmd_delete actually attempts to delete
+ // the folder.
+
+ displayFolder(testFolder);
+ await Promise.all([
+ BrowserTestUtils.promiseAlertDialog("cancel"),
+ helper.activateItem("menu_delete", { l10nID: "menu-edit-delete-folder" }),
+ ]);
+ await new Promise(resolve => setTimeout(resolve));
+
+ // Focus the Quick Filter bar text box and check the menu item shows "Delete".
+
+ goDoCommand("cmd_showQuickFilterBar");
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // Focus on the thread tree with no messages selected and check the menu
+ // item shows "Delete".
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = -1;
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // With one message selected check the menu item shows "Delete Message".
+
+ threadTree.selectedIndex = 0;
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 1 },
+ },
+ });
+
+ // Focus the Quick Filter bar text box and check the menu item shows "Delete".
+ // It should *not* show "Delete Message" even though one is selected.
+
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // Focus on about:message and check the menu item shows "Delete Message".
+
+ about3Pane.messageBrowser.focus();
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 1 },
+ },
+ });
+
+ // With multiple messages selected and check the menu item shows "Delete
+ // Messages". Then check that calling cmd_delete actually deletes the messages.
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndices = [0, 1, 3];
+ await Promise.all([
+ new PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ ),
+ helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 3 },
+ }),
+ ]);
+
+ // Load an IMAP folder with the "just mark deleted" model. With no messages
+ // selected check the menu item shows "Delete".
+
+ // Note that for each flag change, we wait for a second for the change to
+ // be sent to the IMAP server.
+
+ displayFolder(imapFolder);
+ await TestUtils.waitForCondition(() => threadTree.view.rowCount == 10);
+ let dbView = about3Pane.gDBView;
+ threadTree.selectedIndex = -1;
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // With one message selected check the menu item shows "Delete Message".
+ // Then check that calling cmd_delete sets the flag on the message.
+
+ threadTree.selectedIndex = 0;
+ let message = dbView.getMsgHdrAt(0);
+ await helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 1 },
+ });
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ message.flags & Ci.nsMsgMessageFlags.IMAPDeleted,
+ "IMAPDeleted flag should be set"
+ );
+
+ // Check the menu item now shows "Undelete Message" and that calling
+ // cmd_delete clears the flag on the message.
+
+ // The delete operation moved the selection, go back.
+ threadTree.selectedIndex = 0;
+ await helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-undelete-messages",
+ l10nArgs: { count: 1 },
+ });
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !(message.flags & Ci.nsMsgMessageFlags.IMAPDeleted),
+ "IMAPDeleted flag should be cleared on message 0"
+ );
+
+ // Check the menu item again shows "Delete Message".
+
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 1 },
+ },
+ });
+
+ // With multiple messages selected and check the menu item shows "Delete
+ // Messages". Check that calling cmd_delete sets the flag on the messages.
+
+ threadTree.selectedIndices = [1, 3, 5];
+ let messages = dbView.getSelectedMsgHdrs();
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 3 },
+ },
+ });
+ await helper.activateItem("menu_delete");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ messages.every(m => m.flags & Ci.nsMsgMessageFlags.IMAPDeleted),
+ "IMAPDeleted flags should be set"
+ );
+
+ // Check the menu item now shows "Undelete Messages" and that calling
+ // cmd_delete clears the flag on the messages.
+
+ threadTree.selectedIndices = [1, 3, 5];
+ await helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-undelete-messages",
+ l10nArgs: { count: 3 },
+ });
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+ Assert.ok(
+ messages.every(m => !(m.flags & Ci.nsMsgMessageFlags.IMAPDeleted)),
+ "IMAPDeleted flags should be cleared"
+ );
+
+ // Check the menu item again shows "Delete Messages".
+
+ threadTree.selectedIndices = [1, 3, 5];
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 3 },
+ },
+ });
+
+ Services.focus.focusedWindow = window;
+}).__skipMe = AppConstants.DEBUG; // Too unreliable.
+
+/**
+ * Tests the "Favorite Folder" item in the menu is checked/unchecked as expected.
+ */
+add_task(async function testFavoriteFolderItem() {
+ let { displayFolder } = tabmail.currentAbout3Pane;
+
+ testFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ displayFolder(testFolder);
+ await helper.testItems({ menu_favoriteFolder: {} });
+
+ testFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await helper.activateItem("menu_favoriteFolder", { checked: true });
+ Assert.ok(
+ !testFolder.getFlag(Ci.nsMsgFolderFlags.Favorite),
+ "favorite flag should be cleared"
+ );
+
+ await helper.activateItem("menu_favoriteFolder", {});
+ Assert.ok(
+ testFolder.getFlag(Ci.nsMsgFolderFlags.Favorite),
+ "favorite flag should be set"
+ );
+
+ testFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+});
+
+/**
+ * Tests the "Properties" item in the menu is enabled/disabled as expected,
+ * and has the correct label.
+ */
+add_task(async function testPropertiesItem() {
+ async function testDialog(folder, data, which = "folderProps.xhtml") {
+ await Promise.all([
+ BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ `chrome://messenger/content/${which}`,
+ {
+ callback(win) {
+ Assert.ok(true, "folder properties dialog opened");
+ Assert.equal(
+ win.gMsgFolder.URI,
+ folder.URI,
+ "dialog has correct folder"
+ );
+ win.document.querySelector("dialog").getButton("cancel").click();
+ },
+ }
+ ),
+ helper.activateItem("menu_properties", data),
+ ]);
+ await SimpleTest.promiseFocus(window);
+ }
+
+ let { displayFolder } = tabmail.currentAbout3Pane;
+
+ displayFolder(rootFolder);
+ await helper.testItems({
+ menu_properties: { disabled: true, l10nID: "menu-edit-properties" },
+ });
+
+ displayFolder(testFolder);
+ await testDialog(testFolder, { l10nID: "menu-edit-folder-properties" });
+
+ displayFolder(virtualFolder);
+ await testDialog(
+ virtualFolder,
+ { l10nID: "menu-edit-folder-properties" },
+ "virtualFolderProperties.xhtml"
+ );
+
+ displayFolder(imapRootFolder);
+ await helper.testItems({
+ menu_properties: { disabled: true, l10nID: "menu-edit-properties" },
+ });
+
+ displayFolder(imapFolder);
+ await testDialog(imapFolder, { l10nID: "menu-edit-folder-properties" });
+
+ displayFolder(nntpRootFolder);
+ await helper.testItems({
+ menu_properties: { disabled: true, l10nID: "menu-edit-properties" },
+ });
+
+ displayFolder(nntpFolder);
+ await testDialog(nntpFolder, { l10nID: "menu-edit-newsgroup-properties" });
+});
+
+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);
+ },
+};
+
+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)
+ );
+ },
+};
diff --git a/comm/mail/base/test/browser/browser_fileMenu.js b/comm/mail/base/test/browser/browser_fileMenu.js
new file mode 100644
index 0000000000..927628eb93
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_fileMenu.js
@@ -0,0 +1,137 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+/** @type MenuData */
+const fileMenuData = {
+ menu_New: {},
+ menu_newNewMsgCmd: {},
+ "calendar-new-event-menuitem": { hidden: true },
+ "calendar-new-task-menuitem": { hidden: true },
+ menu_newFolder: { hidden: ["mailMessageTab", "contentTab"] },
+ menu_newVirtualFolder: { hidden: ["mailMessageTab", "contentTab"] },
+ newCreateEmailAccountMenuItem: {},
+ newMailAccountMenuItem: {},
+ newIMAccountMenuItem: {},
+ newFeedAccountMenuItem: {},
+ newNewsgroupAccountMenuItem: {},
+ "calendar-new-calendar-menuitem": {},
+ menu_newCard: {},
+ newIMContactMenuItem: { disabled: true },
+ menu_Open: {},
+ openMessageFileMenuitem: {},
+ "calendar-open-calendar-file-menuitem": {},
+ menu_close: {},
+ "calendar-save-menuitem": { hidden: true },
+ "calendar-save-and-close-menuitem": { hidden: true },
+ menu_saveAs: {},
+ menu_saveAsFile: { disabled: ["mail3PaneTab", "contentTab"] },
+ menu_saveAsTemplate: { disabled: ["mail3PaneTab", "contentTab"] },
+ menu_getAllNewMsg: {},
+ menu_getnewmsgs_all_accounts: { disabled: true },
+ menu_getnewmsgs_current_account: { disabled: true },
+ menu_getnextnmsg: { hidden: true },
+ menu_sendunsentmsgs: { disabled: true },
+ menu_subscribe: { disabled: true },
+ menu_deleteFolder: { disabled: true },
+ menu_renameFolder: { disabled: true },
+ menu_compactFolder: { disabled: ["mailMessageTab", "contentTab"] },
+ menu_emptyTrash: { disabled: ["mailMessageTab", "contentTab"] },
+ offlineMenuItem: {},
+ goOfflineMenuItem: {},
+ menu_synchronizeOffline: {},
+ menu_settingsOffline: { disabled: true },
+ menu_downloadFlagged: { disabled: true },
+ menu_downloadSelected: { disabled: true },
+ printMenuItem: { disabled: ["mail3PaneTab"] },
+ menu_FileQuitItem: {},
+};
+let helper = new MenuTestHelper("menu_File", fileMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let inboxFolder, plainFolder, rootFolder, testMessages, trashFolder;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("file menu inbox", null);
+ inboxFolder = rootFolder
+ .getChildNamed("file menu inbox")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox);
+ inboxFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...inboxFolder.messages];
+
+ rootFolder.createSubfolder("file menu plain", null);
+ plainFolder = rootFolder.getChildNamed("file menu plain");
+
+ trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+
+ window.OpenMessageInNewTab(testMessages[0], { background: true });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+
+ window.openTab("contentTab", {
+ url: "https://example.com/",
+ background: true,
+ });
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function test3PaneTab() {
+ tabmail.currentAbout3Pane.displayFolder(rootFolder);
+ await helper.testAllItems("mail3PaneTab");
+
+ tabmail.currentAbout3Pane.displayFolder(inboxFolder);
+ await helper.testItems({
+ menu_deleteFolder: { disabled: true },
+ menu_renameFolder: { disabled: true },
+ menu_compactFolder: { disabled: false },
+ menu_emptyTrash: {},
+ });
+
+ tabmail.currentAbout3Pane.displayFolder(plainFolder);
+ await helper.testItems({
+ menu_deleteFolder: { disabled: false },
+ menu_renameFolder: { disabled: false },
+ menu_compactFolder: { disabled: false },
+ menu_emptyTrash: {},
+ });
+
+ tabmail.currentAbout3Pane.displayFolder(trashFolder);
+ await helper.testItems({
+ menu_deleteFolder: { disabled: true },
+ menu_renameFolder: { disabled: true },
+ menu_compactFolder: { disabled: false },
+ menu_emptyTrash: {},
+ });
+});
+
+add_task(async function testMessageTab() {
+ tabmail.switchToTab(1);
+ await helper.testAllItems("mailMessageTab");
+});
+
+add_task(async function testContentTab() {
+ tabmail.switchToTab(2);
+ await helper.testAllItems("contentTab");
+});
diff --git a/comm/mail/base/test/browser/browser_folderPaneContext.js b/comm/mail/base/test/browser/browser_folderPaneContext.js
new file mode 100644
index 0000000000..843965c966
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_folderPaneContext.js
@@ -0,0 +1,198 @@
+/* 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 { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+
+let servers = ["server", "rssRoot"];
+let realFolders = ["plain", "inbox", "junk", "trash", "rssFeed"];
+
+const folderPaneContextData = {
+ "folderPaneContext-getMessages": [...servers, "rssFeed"],
+ "folderPaneContext-pauseAllUpdates": ["rssRoot"],
+ "folderPaneContext-pauseUpdates": ["rssFeed"],
+ "folderPaneContext-openNewTab": true,
+ "folderPaneContext-openNewWindow": true,
+ "folderPaneContext-searchMessages": [...servers, ...realFolders],
+ "folderPaneContext-subscribe": ["rssRoot", "rssFeed"],
+ "folderPaneContext-newsUnsubscribe": [],
+ "folderPaneContext-new": [...servers, ...realFolders],
+ "folderPaneContext-remove": ["plain", "junk", "virtual", "rssFeed"],
+ "folderPaneContext-rename": ["plain", "junk", "virtual", "rssFeed"],
+ "folderPaneContext-compact": [...servers, ...realFolders],
+ "folderPaneContext-markMailFolderAllRead": [...realFolders, "virtual"],
+ "folderPaneContext-markNewsgroupAllRead": [],
+ "folderPaneContext-emptyTrash": ["trash"],
+ "folderPaneContext-emptyJunk": ["junk"],
+ "folderPaneContext-sendUnsentMessages": [],
+ "folderPaneContext-favoriteFolder": [...realFolders, "virtual"],
+ "folderPaneContext-properties": [...realFolders, "virtual"],
+ "folderPaneContext-markAllFoldersRead": [...servers],
+ "folderPaneContext-settings": [...servers],
+ "folderPaneContext-manageTags": ["tags"],
+ "folderPaneContext-moveMenu": ["plain", "virtual", "rssFeed"],
+ "folderPaneContext-copyMenu": ["plain", "rssFeed"],
+};
+
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+let context = about3Pane.document.getElementById("folderPaneContext");
+let rootFolder,
+ plainFolder,
+ inboxFolder,
+ junkFolder,
+ trashFolder,
+ virtualFolder;
+let rssRootFolder, rssFeedFolder;
+let tagsFolder;
+
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ "pop3"
+ );
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ plainFolder = rootFolder.createLocalSubfolder("folderPaneContextFolder");
+ inboxFolder = rootFolder.createLocalSubfolder("folderPaneContextInbox");
+ inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox);
+ junkFolder = rootFolder.createLocalSubfolder("folderPaneContextJunk");
+ junkFolder.setFlag(Ci.nsMsgFolderFlags.Junk);
+ trashFolder = rootFolder.createLocalSubfolder("folderPaneContextTrash");
+ trashFolder.setFlag(Ci.nsMsgFolderFlags.Trash);
+
+ virtualFolder = rootFolder.createLocalSubfolder("folderPaneContextVirtual");
+ virtualFolder.setFlag(Ci.nsMsgFolderFlags.Virtual);
+ let msgDatabase = virtualFolder.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+ folderInfo.setCharProperty("searchStr", "ALL");
+ folderInfo.setCharProperty("searchFolderUri", plainFolder.URI);
+
+ let rssAccount = FeedUtils.createRssAccount("rss");
+ rssRootFolder = rssAccount.incomingServer.rootFolder;
+ FeedUtils.subscribeToFeed(
+ "https://example.org/browser/comm/mail/base/test/browser/files/rss.xml?folderPaneContext",
+ rssRootFolder,
+ null
+ );
+ await TestUtils.waitForCondition(() => rssRootFolder.subFolders.length == 2);
+ rssFeedFolder = rssRootFolder.getChildNamed("Test Feed");
+
+ about3Pane.folderPane.activeModes = ["all", "tags"];
+ tagsFolder = about3Pane.folderPane._modes.tags._tagsFolder.subFolders[0];
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ MailServices.accounts.removeAccount(rssAccount, false);
+ about3Pane.folderPane.activeModes = ["all"];
+ });
+});
+
+add_task(async function () {
+ // Check the menu has the right items for the selected folder.
+ leftClickOn(rootFolder);
+ await rightClickOn(rootFolder, "server");
+ leftClickOn(plainFolder);
+ await rightClickOn(plainFolder, "plain");
+ leftClickOn(inboxFolder);
+ await rightClickOn(inboxFolder, "inbox");
+ leftClickOn(junkFolder);
+ await rightClickOn(junkFolder, "junk");
+ leftClickOn(trashFolder);
+ await rightClickOn(trashFolder, "trash");
+ leftClickOn(virtualFolder);
+ await rightClickOn(virtualFolder, "virtual");
+ leftClickOn(rssRootFolder);
+ await rightClickOn(rssRootFolder, "rssRoot");
+ leftClickOn(rssFeedFolder);
+ await rightClickOn(rssFeedFolder, "rssFeed");
+ leftClickOn(tagsFolder);
+ await rightClickOn(tagsFolder, "tags");
+
+ // Check the menu has the right items when the selected folder is not the
+ // folder that was right-clicked on.
+ await rightClickOn(rootFolder, "server");
+ leftClickOn(rootFolder);
+ await rightClickOn(plainFolder, "plain");
+ await rightClickOn(inboxFolder, "inbox");
+ await rightClickOn(junkFolder, "junk");
+ await rightClickOn(trashFolder, "trash");
+ await rightClickOn(virtualFolder, "virtual");
+ await rightClickOn(rssRootFolder, "rssRoot");
+ await rightClickOn(rssFeedFolder, "rssFeed");
+ await rightClickOn(tagsFolder, "tags");
+});
+
+function leftClickOn(folder) {
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.folderPane.getRowForFolder(folder).querySelector(".name"),
+ {},
+ about3Pane
+ );
+}
+
+async function rightClickOn(folder, mode) {
+ let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.folderPane.getRowForFolder(folder).querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(context, mode);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(context, "popuphidden");
+ context.hidePopup();
+ await hiddenPromise;
+}
+
+function checkMenuitems(menu, mode) {
+ if (!mode) {
+ // Menu should not be shown.
+ Assert.equal(menu.state, "closed");
+ return;
+ }
+
+ Assert.notEqual(menu.state, "closed");
+
+ let expectedItems = [];
+ for (let [id, modes] of Object.entries(folderPaneContextData)) {
+ if (modes === true || modes.includes(mode)) {
+ expectedItems.push(id);
+ }
+ }
+
+ let actualItems = [];
+ for (let item of menu.children) {
+ if (["menu", "menuitem"].includes(item.localName) && !item.hidden) {
+ actualItems.push(item.id);
+ }
+ }
+
+ let notFoundItems = expectedItems.filter(i => !actualItems.includes(i));
+ if (notFoundItems.length) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "items expected but not found: " + notFoundItems.join(", ")
+ );
+ }
+
+ let unexpectedItems = actualItems.filter(i => !expectedItems.includes(i));
+ if (unexpectedItems.length) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "items found but not expected: " + unexpectedItems.join(", ")
+ );
+ }
+
+ if (notFoundItems.length + unexpectedItems.length == 0) {
+ Assert.report(false, undefined, undefined, `all ${mode} items are correct`);
+ }
+}
diff --git a/comm/mail/base/test/browser/browser_folderTreeProperties.js b/comm/mail/base/test/browser/browser_folderTreeProperties.js
new file mode 100644
index 0000000000..6fdfa4f2ad
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_folderTreeProperties.js
@@ -0,0 +1,236 @@
+/* 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 { FolderTreeProperties } = ChromeUtils.import(
+ "resource:///modules/FolderTreeProperties.jsm"
+);
+
+const TRASH_COLOR_HEX = "#52507c";
+const TRASH_COLOR_RGB = "rgb(82, 80, 124)";
+const VIRTUAL_COLOR_HEX = "#cd26a5";
+const VIRTUAL_COLOR_RGB = "rgb(205, 38, 165)";
+
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+let { folderPane, folderTree, threadTree } = about3Pane;
+let rootFolder, trashFolder, trashFolderRows, virtualFolder, virtualFolderRows;
+
+add_setup(async function () {
+ Services.prefs.setIntPref("ui.prefersReducedMotion", 1);
+ FolderTreeProperties.resetColors();
+
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ "none"
+ );
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ trashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ rootFolder.createSubfolder("folderTreePropsVirtual", null);
+ virtualFolder = rootFolder.getChildNamed("folderTreePropsVirtual");
+ virtualFolder.flags |=
+ Ci.nsMsgFolderFlags.Virtual | Ci.nsMsgFolderFlags.Favorite;
+ let virtualFolderInfo = virtualFolder.msgDatabase.dBFolderInfo;
+ virtualFolderInfo.setCharProperty("searchStr", "ALL");
+ virtualFolderInfo.setCharProperty("searchFolderUri", trashFolder.URI);
+
+ // Test the colours change in all folder modes, not just the current one.
+ folderPane.activeModes = ["all", "favorite"];
+ await new Promise(resolve => setTimeout(resolve));
+ for (let row of folderTree.querySelectorAll(".collapsed")) {
+ folderTree.expandRow(row);
+ }
+
+ trashFolderRows = {
+ all: folderPane.getRowForFolder(trashFolder, "all"),
+ favorite: folderPane.getRowForFolder(trashFolder, "favorite"),
+ };
+ virtualFolderRows = {
+ all: folderPane.getRowForFolder(virtualFolder, "all"),
+ favorite: folderPane.getRowForFolder(virtualFolder, "favorite"),
+ };
+
+ registerCleanupFunction(async () => {
+ folderPane.activeModes = ["all"];
+ MailServices.accounts.removeAccount(account, false);
+ FolderTreeProperties.resetColors();
+ Services.prefs.clearUserPref("ui.prefersReducedMotion");
+ });
+});
+
+add_task(async function testNormalFolderColors() {
+ await subtestColors(trashFolderRows, TRASH_COLOR_HEX, TRASH_COLOR_RGB);
+});
+
+add_task(async function testVirtualFolderColors() {
+ await subtestColors(virtualFolderRows, VIRTUAL_COLOR_HEX, VIRTUAL_COLOR_RGB);
+});
+
+async function subtestColors(rows, defaultHex, defaultRGB) {
+ assertRowColors(rows, defaultRGB);
+
+ // Accept the dialog without changing anything.
+ let dialog = await openFolderProperties(rows.all);
+ dialog.assertColor(defaultHex);
+ await dialog.accept();
+ assertRowColors(rows, defaultRGB);
+
+ // Cancel the dialog without changing anything.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor(defaultHex);
+ await dialog.cancel();
+ assertRowColors(rows, defaultRGB);
+
+ // Set a non-default color.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor(defaultHex);
+ await dialog.setColor("#ff6600");
+ assertRowColors(rows, "rgb(255, 102, 0)");
+ await dialog.accept();
+ assertRowColors(rows, "rgb(255, 102, 0)");
+
+ // Reset to the default color.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor("#ff6600");
+ dialog.resetColor();
+ dialog.assertColor(defaultHex);
+ assertRowColors(rows, defaultRGB);
+ await dialog.accept();
+ assertRowColors(rows, defaultRGB);
+
+ // Set a color, but cancel the dialog.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor(defaultHex);
+ await dialog.setColor("#ffcc00");
+ assertRowColors(rows, "rgb(255, 204, 0)");
+ await dialog.cancel();
+ assertRowColors(rows, defaultRGB);
+
+ // Set a color, but reset it and accept the dialog.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor(defaultHex);
+ await dialog.setColor("#00cc00");
+ assertRowColors(rows, "rgb(0, 204, 0)");
+ dialog.resetColor();
+ dialog.assertColor(defaultHex);
+ assertRowColors(rows, defaultRGB);
+ await dialog.accept();
+ assertRowColors(rows, defaultRGB);
+
+ // Set a non-default color.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor(defaultHex);
+ await dialog.setColor("#0000cc");
+ assertRowColors(rows, "rgb(0, 0, 204)");
+ await dialog.accept();
+ assertRowColors(rows, "rgb(0, 0, 204)");
+
+ // Accept the dialog without changing anything.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor("#0000cc");
+ await dialog.accept();
+ assertRowColors(rows, "rgb(0, 0, 204)");
+
+ // Cancel the dialog without changing anything.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor("#0000cc");
+ await dialog.cancel();
+ assertRowColors(rows, "rgb(0, 0, 204)");
+
+ // Reset the color and cancel the dialog.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor("#0000cc");
+ dialog.resetColor();
+ dialog.assertColor(defaultHex);
+ assertRowColors(rows, defaultRGB);
+ await dialog.cancel();
+ assertRowColors(rows, "rgb(0, 0, 204)");
+
+ // Reset the color, pick a new one, and accept the dialog.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor("#0000cc");
+ dialog.resetColor();
+ dialog.assertColor(defaultHex);
+ assertRowColors(rows, defaultRGB);
+ await dialog.setColor("#0066cc");
+ assertRowColors(rows, "rgb(0, 102, 204)");
+ await dialog.accept();
+ assertRowColors(rows, "rgb(0, 102, 204)");
+}
+
+async function openFolderProperties(row) {
+ let folderPaneContext =
+ about3Pane.document.getElementById("folderPaneContext");
+ let folderPaneContextProperties = about3Pane.document.getElementById(
+ "folderPaneContext-properties"
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ folderPaneContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ let windowOpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ folderPaneContext.activateItem(folderPaneContextProperties);
+ let dialogWindow = await windowOpenedPromise;
+ let dialogDocument = dialogWindow.document;
+
+ let colorButton = dialogDocument.getElementById("color");
+ let resetColorButton = dialogDocument.getElementById("resetColor");
+ let folderPropertiesDialog = dialogDocument.querySelector("dialog");
+
+ return {
+ assertColor(hex) {
+ Assert.equal(colorButton.value, hex);
+ },
+ async setColor(hex) {
+ SpecialPowers.MockColorPicker.init(dialogWindow);
+ SpecialPowers.MockColorPicker.returnColor = hex;
+ let inputPromise = BrowserTestUtils.waitForEvent(colorButton, "input");
+ EventUtils.synthesizeMouseAtCenter(colorButton, {}, dialogWindow);
+ await inputPromise;
+ SpecialPowers.MockColorPicker.cleanup();
+ },
+ resetColor() {
+ EventUtils.synthesizeMouseAtCenter(resetColorButton, {}, dialogWindow);
+ },
+ async accept() {
+ let windowClosedPromise = BrowserTestUtils.domWindowClosed(dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ folderPropertiesDialog.getButton("accept"),
+ {},
+ dialogWindow
+ );
+ await windowClosedPromise;
+ },
+ async cancel() {
+ let windowClosedPromise = BrowserTestUtils.domWindowClosed(dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ folderPropertiesDialog.getButton("cancel"),
+ {},
+ dialogWindow
+ );
+ await windowClosedPromise;
+ },
+ };
+}
+
+function assertRowColors(rows, rgb) {
+ // Always move the focus away from the row otherwise we might get the selected
+ // state which turns the icon white.
+ threadTree.table.body.focus();
+ for (let row of Object.values(rows)) {
+ Assert.equal(getComputedStyle(row.querySelector(".icon")).stroke, rgb);
+ }
+}
diff --git a/comm/mail/base/test/browser/browser_folderTreeQuirks.js b/comm/mail/base/test/browser/browser_folderTreeQuirks.js
new file mode 100644
index 0000000000..885be9242c
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_folderTreeQuirks.js
@@ -0,0 +1,1450 @@
+/* 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 { MessageGenerator, SyntheticMessageSet } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { MessageInjection } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageInjection.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+const { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+let { folderPane, folderTree, threadTree } = about3Pane;
+let account,
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ moreButton,
+ moreContext;
+let generator = new MessageGenerator();
+let messageInjection = new MessageInjection(
+ {
+ mode: "local",
+ },
+ generator
+);
+
+add_setup(async function () {
+ account = MailServices.accounts.accounts[0];
+ rootFolder = account.incomingServer.rootFolder;
+ inboxFolder = rootFolder.getChildNamed("Inbox");
+ trashFolder = rootFolder.getChildNamed("Trash");
+ outboxFolder = rootFolder.getChildNamed("Outbox");
+ moreButton = about3Pane.document.querySelector("#folderPaneMoreButton");
+ moreContext = about3Pane.document.getElementById("folderPaneMoreContext");
+
+ rootFolder.createSubfolder("folderTreeQuirksA", null);
+ folderA = rootFolder
+ .getChildNamed("folderTreeQuirksA")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ folderA.createSubfolder("folderTreeQuirksB", null);
+ folderB = folderA
+ .getChildNamed("folderTreeQuirksB")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ folderB.createSubfolder("folderTreeQuirksC", null);
+ folderC = folderB
+ .getChildNamed("folderTreeQuirksC")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ messageInjection.addSetsToFolders(
+ [folderA, folderB, folderC],
+ [
+ new SyntheticMessageSet(generator.makeMessages({ read: true })),
+ new SyntheticMessageSet(generator.makeMessages({ read: true })),
+ new SyntheticMessageSet(generator.makeMessages({ read: true })),
+ ]
+ );
+
+ Services.prefs.setIntPref("ui.prefersReducedMotion", 1);
+ about3Pane.paneLayout.messagePaneVisible = false;
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("ui.prefersReducedMotion");
+ folderPane.activeModes = ["all"];
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ about3Pane.paneLayout.messagePaneVisible = true;
+ });
+});
+
+/**
+ * Tests the Favorite Folders mode.
+ */
+add_task(async function testFavoriteFolders() {
+ folderPane.activeModes = ["all", "favorite"];
+ await checkModeListItems("favorite", []);
+
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [rootFolder, folderA]);
+
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ folderB.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [rootFolder, folderA, folderB]);
+
+ folderB.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]);
+
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]);
+
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+});
+
+/**
+ * Tests the compact Favorite Folders mode.
+ */
+add_task(async function testCompactFavoriteFolders() {
+ folderPane.activeModes = ["all", "favorite"];
+ folderPane.isCompact = true;
+ await checkModeListItems("favorite", []);
+
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderA]);
+
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ folderB.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderB]);
+
+ folderB.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderA, folderC]); // c, a
+
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderC]);
+
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ // Test with multiple accounts.
+
+ let foo = MailServices.accounts.createAccount();
+ foo.incomingServer = MailServices.accounts.createIncomingServer(
+ `${foo.key}user`,
+ "localhost",
+ "none"
+ );
+ let fooRootFolder = foo.incomingServer.rootFolder;
+ let fooTrashFolder = fooRootFolder.getChildNamed("Trash");
+
+ fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [fooTrashFolder]);
+
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [fooTrashFolder, folderC]);
+
+ MailServices.accounts.reorderAccounts([account.key, foo.key]);
+ await checkModeListItems("favorite", [folderC, fooTrashFolder]);
+
+ fooTrashFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderC]);
+
+ fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderC, fooTrashFolder]);
+
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [fooTrashFolder]);
+
+ // Clean up.
+
+ MailServices.accounts.removeAccount(foo, false);
+ await checkModeListItems("favorite", []);
+ folderPane.isCompact = false;
+});
+
+/**
+ * Tests the Unread Folders mode.
+ */
+add_task(async function testUnreadFolders() {
+ let folderAMessages = [...folderA.messages];
+ let folderBMessages = [...folderB.messages];
+ let folderCMessages = [...folderC.messages];
+
+ folderPane.activeModes = ["all", "unread"];
+ await checkModeListItems("unread", []);
+
+ folderAMessages[0].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+
+ folderAMessages[1].markRead(false);
+ folderAMessages[2].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+
+ window.MsgMarkAllRead([folderA]);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+
+ folderAMessages[0].markRead(false);
+ folderBMessages[0].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderBMessages[0].markRead(true);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderAMessages[0].markRead(true);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderCMessages[0].markRead(true);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderCMessages[1].markRead(false);
+ folderCMessages[2].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ window.MsgMarkAllRead([folderC]);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+});
+
+/**
+ * Tests the compact Unread Folders mode.
+ */
+add_task(async function testCompactUnreadFolders() {
+ let folderAMessages = [...folderA.messages];
+ let folderBMessages = [...folderB.messages];
+ let folderCMessages = [...folderC.messages];
+
+ folderPane.activeModes = ["all", "unread"];
+ folderPane.isCompact = true;
+ await checkModeListItems("unread", []);
+
+ folderAMessages[0].markRead(false);
+ await checkModeListItems("unread", [folderA]);
+
+ folderAMessages[1].markRead(false);
+ folderAMessages[2].markRead(false);
+ await checkModeListItems("unread", [folderA]);
+
+ window.MsgMarkAllRead([folderA]);
+ await checkModeListItems("unread", [folderA]);
+
+ folderAMessages[0].markRead(false);
+ folderBMessages[0].markRead(false);
+ await checkModeListItems("unread", [folderA, folderB]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderBMessages[0].markRead(true);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderAMessages[0].markRead(true);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderCMessages[0].markRead(true);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderCMessages[1].markRead(false);
+ folderCMessages[2].markRead(false);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ window.MsgMarkAllRead([folderC]);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ // Test with multiple accounts.
+
+ let foo = MailServices.accounts.createAccount();
+ foo.incomingServer = MailServices.accounts.createIncomingServer(
+ `${foo.key}user`,
+ "localhost",
+ "none"
+ );
+ let fooRootFolder = foo.incomingServer.rootFolder;
+ let fooTrashFolder = fooRootFolder.getChildNamed("Trash");
+
+ let generator = new MessageGenerator();
+ fooTrashFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .addMessage(generator.makeMessages({}).map(m => m.toMboxString()));
+ let fooMessages = [...fooTrashFolder.messages];
+
+ fooMessages[0].markRead(false);
+ await checkModeListItems("unread", [
+ fooTrashFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [
+ fooTrashFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ MailServices.accounts.reorderAccounts([account.key, foo.key]);
+ await checkModeListItems("unread", [
+ folderA,
+ folderB,
+ folderC,
+ fooTrashFolder,
+ ]);
+
+ fooMessages[0].markRead(true);
+ await checkModeListItems("unread", [
+ folderA,
+ folderB,
+ folderC,
+ fooTrashFolder,
+ ]);
+
+ fooMessages[0].markRead(false);
+ await checkModeListItems("unread", [
+ folderA,
+ folderB,
+ folderC,
+ fooTrashFolder,
+ ]);
+
+ folderCMessages[0].markRead(true);
+ await checkModeListItems("unread", [
+ folderA,
+ folderB,
+ folderC,
+ fooTrashFolder,
+ ]);
+
+ // Clean up.
+
+ MailServices.accounts.removeAccount(foo, false);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+ folderPane.isCompact = false;
+});
+
+/**
+ * Tests the Smart Folders mode.
+ */
+add_task(async function testSmartFolders() {
+ folderPane.activeModes = ["smart"];
+
+ // Check the mode is set up correctly.
+ let localExtraFolders = [rootFolder, outboxFolder, folderA, folderB, folderC];
+ let smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ let smartInbox = smartServer.rootFolder.getChildNamed("Inbox");
+ let smartInboxFolders = [smartInbox, inboxFolder];
+ let otherSmartFolders = [
+ smartServer.rootFolder.getChildNamed("Drafts"),
+ smartServer.rootFolder.getChildNamed("Templates"),
+ smartServer.rootFolder.getChildNamed("Sent"),
+ smartServer.rootFolder.getChildNamed("Archives"),
+ smartServer.rootFolder.getChildNamed("Junk"),
+ smartServer.rootFolder.getChildNamed("Trash"),
+ ];
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ ]);
+
+ // Add some subfolders of existing folders.
+ rootFolder.createSubfolder("folderTreeQuirksX", null);
+ let folderX = rootFolder.getChildNamed("folderTreeQuirksX");
+ inboxFolder.createSubfolder("folderTreeQuirksY", null);
+ let folderY = inboxFolder.getChildNamed("folderTreeQuirksY");
+ folderY.createSubfolder("folderTreeQuirksYY", null);
+ let folderYY = folderY.getChildNamed("folderTreeQuirksYY");
+ folderB.createSubfolder("folderTreeQuirksZ", null);
+ let folderZ = folderB.getChildNamed("folderTreeQuirksZ");
+
+ // Check the folders are listed in the right order.
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ folderY,
+ folderYY,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ folderZ,
+ folderX,
+ ]);
+
+ // Check the hierarchy.
+ let rootRow = folderPane.getRowForFolder(rootFolder);
+ let inboxRow = folderPane.getRowForFolder(inboxFolder);
+ let trashRow = folderPane.getRowForFolder(trashFolder);
+ let rowB = folderPane.getRowForFolder(folderB);
+ let rowX = folderPane.getRowForFolder(folderX);
+ let rowY = folderPane.getRowForFolder(folderY);
+ let rowYY = folderPane.getRowForFolder(folderYY);
+ let rowZ = folderPane.getRowForFolder(folderZ);
+ Assert.equal(
+ rowX.parentNode.parentNode,
+ rootRow,
+ "folderX should be displayed as a child of rootFolder"
+ );
+ Assert.equal(
+ rowY.parentNode.parentNode,
+ inboxRow,
+ "folderY should be displayed as a child of inboxFolder"
+ );
+ Assert.equal(
+ rowYY.parentNode.parentNode,
+ rowY,
+ "folderYY should be displayed as a child of folderY"
+ );
+ Assert.equal(
+ rowZ.parentNode.parentNode,
+ rowB,
+ "folderZ should be displayed as a child of folderB"
+ );
+
+ // Stop searching folderY and folderYY in the smart inbox. They should stop
+ // being listed under the inbox and instead appear under the root folder.
+ let wrappedInbox = VirtualFolderHelper.wrapVirtualFolder(smartInbox);
+ Assert.deepEqual(wrappedInbox.searchFolders, [
+ inboxFolder,
+ folderY,
+ folderYY,
+ ]);
+ wrappedInbox.searchFolders = [inboxFolder];
+
+ // Check the folders are listed in the right order.
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ folderZ,
+ folderX,
+ folderY,
+ folderYY,
+ ]);
+
+ // Check the hierarchy.
+ rowY = folderPane.getRowForFolder(folderY);
+ rowYY = folderPane.getRowForFolder(folderYY);
+ Assert.equal(
+ rowY.parentNode.parentNode,
+ rootRow,
+ "folderY should be displayed as a child of the rootFolder"
+ );
+ Assert.equal(
+ rowYY.parentNode.parentNode,
+ rowY,
+ "folderYY should be displayed as a child of folderY"
+ );
+
+ // Search them again. They should move back to the smart inbox section.
+ wrappedInbox.searchFolders = [inboxFolder, folderY, folderYY];
+
+ // Check the folders are listed in the right order.
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ folderY,
+ folderYY,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ folderZ,
+ folderX,
+ ]);
+
+ // Check the hierarchy.
+ rowY = folderPane.getRowForFolder(folderY);
+ rowYY = folderPane.getRowForFolder(folderYY);
+ Assert.equal(
+ rowY.parentNode.parentNode,
+ inboxRow,
+ "folderY should be displayed as a child of inboxFolder"
+ );
+ Assert.equal(
+ rowYY.parentNode.parentNode,
+ rowY,
+ "folderYY should be displayed as a child of folderY"
+ );
+
+ // Delete the added folders.
+ folderX.deleteSelf(null);
+ folderY.deleteSelf(null);
+ folderZ.deleteSelf(null);
+ folderX = trashFolder.getChildNamed("folderTreeQuirksX");
+ folderY = trashFolder.getChildNamed("folderTreeQuirksY");
+ folderYY = folderY.getChildNamed("folderTreeQuirksYY");
+ folderZ = trashFolder.getChildNamed("folderTreeQuirksZ");
+
+ // Check they appear in the trash.
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ ...otherSmartFolders,
+ trashFolder,
+ folderX,
+ folderY,
+ folderYY,
+ folderZ,
+ ...localExtraFolders,
+ ]);
+
+ // Check the hierarchy.
+ rowX = folderPane.getRowForFolder(folderX);
+ rowY = folderPane.getRowForFolder(folderY);
+ rowYY = folderPane.getRowForFolder(folderYY);
+ rowZ = folderPane.getRowForFolder(folderZ);
+ Assert.equal(
+ rowX.parentNode.parentNode,
+ trashRow,
+ "folderX should be displayed as a child of trashFolder"
+ );
+ Assert.equal(
+ rowY.parentNode.parentNode,
+ trashRow,
+ "folderY should be displayed as a child of trashFolder"
+ );
+ Assert.equal(
+ rowYY.parentNode.parentNode,
+ rowY,
+ "folderYY should be displayed as a child of folderY"
+ );
+ Assert.equal(
+ rowZ.parentNode.parentNode,
+ trashRow,
+ "folderZ should be displayed as a child of trashFolder"
+ );
+
+ // Empty the trash and check everything is back to normal.
+ rootFolder.emptyTrash(null, null);
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ ]);
+});
+
+/**
+ * Tests that after moving a folder it is in the right place in the tree,
+ * with any subfolders if they should be shown.
+ */
+add_task(async function testFolderMove() {
+ rootFolder.createSubfolder("new parent", null);
+ let newParentFolder = rootFolder.getChildNamed("new parent");
+ [...folderC.messages][6].markRead(false);
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ // Set up and check initial state.
+
+ folderPane.activeModes = ["all", "unread", "favorite"];
+ folderPane.isCompact = false;
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ newParentFolder,
+ ]);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+ await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]);
+
+ // Move `folderB` from `folderA` to `newParentFolder`.
+
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFolder(
+ folderB,
+ newParentFolder,
+ true,
+ copyListener,
+ window.msgWindow
+ );
+ await copyListener.promise;
+
+ let movedFolderB = newParentFolder.getChildNamed("folderTreeQuirksB");
+ let movedFolderC = movedFolderB.getChildNamed("folderTreeQuirksC");
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ newParentFolder,
+ movedFolderB,
+ movedFolderC,
+ ]);
+ await checkModeListItems("unread", [
+ rootFolder,
+ newParentFolder,
+ movedFolderB,
+ movedFolderC,
+ ]);
+ await checkModeListItems("favorite", [
+ rootFolder,
+ newParentFolder,
+ movedFolderB,
+ movedFolderC,
+ ]);
+
+ // Switch to compact mode for the return move.
+
+ folderPane.isCompact = true;
+ await checkModeListItems("unread", [movedFolderC]);
+ await checkModeListItems("favorite", [movedFolderC]);
+
+ // Move `movedFolderB` from `newParentFolder` back to `folderA`.
+
+ copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFolder(
+ movedFolderB,
+ folderA,
+ true,
+ copyListener,
+ window.msgWindow
+ );
+ await copyListener.promise;
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ newParentFolder,
+ ]);
+ await checkModeListItems("unread", [folderC]);
+ await checkModeListItems("favorite", [folderC]);
+
+ // Clean up.
+
+ newParentFolder.deleteSelf(null);
+ rootFolder.emptyTrash(null, null);
+ folderC.markAllMessagesRead(null);
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderPane.isCompact = false;
+});
+
+/**
+ * Tests that after renaming a folder it is in the right place in the tree,
+ * with any subfolders if they should be shown.
+ */
+add_task(async function testFolderRename() {
+ let extraFolders = {};
+ for (let name of ["aaa", "ggg", "zzz"]) {
+ rootFolder.createSubfolder(name, null);
+ extraFolders[name] = rootFolder
+ .getChildNamed(name)
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ extraFolders[name].addMessage(generator.makeMessage({}).toMboxString());
+ extraFolders[name].setFlag(Ci.nsMsgFolderFlags.Favorite);
+ }
+ [...folderC.messages][4].markRead(false);
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ // Set up and check initial state.
+
+ folderPane.activeModes = ["all", "unread", "favorite"];
+ folderPane.isCompact = false;
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ extraFolders.aaa,
+ folderA,
+ folderB,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("unread", [
+ rootFolder,
+ extraFolders.aaa,
+ folderA,
+ folderB,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("favorite", [
+ rootFolder,
+ extraFolders.aaa,
+ folderA,
+ folderB,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+
+ // Rename `folderA`.
+
+ folderA.rename("renamedA", window.msgWindow);
+ let renamedFolderA = rootFolder.getChildNamed("renamedA");
+ let renamedFolderB = renamedFolderA.getChildNamed("folderTreeQuirksB");
+ let renamedFolderC = renamedFolderB.getChildNamed("folderTreeQuirksC");
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ extraFolders.aaa,
+ extraFolders.ggg,
+ renamedFolderA,
+ renamedFolderB,
+ renamedFolderC,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("unread", [
+ rootFolder,
+ extraFolders.aaa,
+ extraFolders.ggg,
+ renamedFolderA,
+ renamedFolderB,
+ renamedFolderC,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("favorite", [
+ rootFolder,
+ extraFolders.aaa,
+ extraFolders.ggg,
+ renamedFolderA,
+ renamedFolderB,
+ renamedFolderC,
+ extraFolders.zzz,
+ ]);
+
+ // Switch to compact mode.
+
+ folderPane.isCompact = true;
+ await checkModeListItems("unread", [
+ extraFolders.aaa,
+ renamedFolderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("favorite", [
+ extraFolders.aaa,
+ renamedFolderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+
+ // Rename the folder back to its original name.
+
+ renamedFolderA.rename("folderTreeQuirksA", window.msgWindow);
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ extraFolders.aaa,
+ folderA,
+ folderB,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("unread", [
+ extraFolders.aaa,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("favorite", [
+ extraFolders.aaa,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+
+ // Clean up.
+
+ extraFolders.aaa.deleteSelf(null);
+ extraFolders.ggg.deleteSelf(null);
+ extraFolders.zzz.deleteSelf(null);
+ rootFolder.emptyTrash(null, null);
+ folderC.markAllMessagesRead(null);
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderPane.isCompact = false;
+});
+
+/**
+ * The creation of a virtual folder involves two "folderAdded" notifications.
+ * Check that only one entry in the folder tree is created.
+ */
+add_task(async function testSearchFolderAddedOnlyOnce() {
+ let context = about3Pane.document.getElementById("folderPaneContext");
+ let searchMessagesItem = about3Pane.document.getElementById(
+ "folderPaneContext-searchMessages"
+ );
+ let removeItem = about3Pane.document.getElementById(
+ "folderPaneContext-remove"
+ );
+
+ // Start searching for messages.
+
+ let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ folderPane.getRowForFolder(rootFolder).querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ let searchWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ w =>
+ w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ context.activateItem(searchMessagesItem);
+ let searchWindow = await searchWindowPromise;
+
+ EventUtils.synthesizeMouseAtCenter(
+ searchWindow.document.getElementById("searchVal0"),
+ {},
+ searchWindow
+ );
+ EventUtils.sendString("hovercraft", searchWindow);
+
+ // Create a virtual folder for the search.
+
+ let vfWindowPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ {
+ async callback(vfWindow) {
+ EventUtils.synthesizeMouseAtCenter(
+ vfWindow.document.getElementById("name"),
+ {},
+ vfWindow
+ );
+ EventUtils.sendString("virtual folder", vfWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ vfWindow.document.querySelector("dialog").getButton("accept"),
+ {},
+ vfWindow
+ );
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ searchWindow.document.getElementById("saveAsVFButton"),
+ {},
+ searchWindow
+ );
+ await vfWindowPromise;
+
+ await BrowserTestUtils.closeWindow(searchWindow);
+
+ // Find the folder and the row for it in the tree.
+
+ let virtualFolder = rootFolder.getChildNamed("virtual folder");
+ let row = await TestUtils.waitForCondition(() =>
+ folderPane.getRowForFolder(virtualFolder)
+ );
+
+ // Check it exists only once.
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ virtualFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ // Delete the virtual folder.
+
+ shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept");
+ context.activateItem(removeItem);
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ // Check it went away.
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+});
+
+/**
+ * Tests deferred POP3 accounts are not displayed in All Folders mode, and
+ * that a change in their deferred status updates the folder tree.
+ */
+add_task(async function testDeferredAccount() {
+ let pop3Account = MailServices.accounts.createAccount();
+ let pop3Server = MailServices.accounts.createIncomingServer(
+ `${pop3Account.key}user`,
+ "localhost",
+ "pop3"
+ );
+ pop3Server.QueryInterface(Ci.nsIPop3IncomingServer);
+ pop3Account.incomingServer = pop3Server.QueryInterface(
+ Ci.nsIPop3IncomingServer
+ );
+
+ let pop3RootFolder = pop3Server.rootFolder;
+ let pop3Folders = [
+ pop3RootFolder,
+ pop3RootFolder.getChildNamed("Inbox"),
+ pop3RootFolder.getChildNamed("Trash"),
+ ];
+ let localFolders = [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ];
+
+ folderPane.activeModes = ["all"];
+ await checkModeListItems("all", [...pop3Folders, ...localFolders]);
+
+ // Defer the account to Local Folders.
+ pop3Server.deferredToAccount = account.key;
+ await checkModeListItems("all", localFolders);
+
+ // Remove and add the All mode again to check that an existing deferred
+ // folder is not shown when the mode initialises.
+ folderPane.activeModes = ["recent"];
+ folderPane.activeModes = ["all"];
+ await checkModeListItems("all", localFolders);
+
+ // Stop deferring the account.
+ pop3Server.deferredToAccount = null;
+ await checkModeListItems("all", [...pop3Folders, ...localFolders]);
+
+ MailServices.accounts.removeAccount(pop3Account, false);
+});
+
+/**
+ * We deliberately hide the special [Gmail] folder from the folder tree.
+ * Check that it doesn't appear when for a new or existing account.
+ */
+add_task(async function testGmailFolders() {
+ IMAPServer.open();
+ // Set up a fake Gmail account.
+ let gmailAccount = MailServices.accounts.createAccount();
+ let gmailServer = MailServices.accounts.createIncomingServer(
+ "user",
+ "localhost",
+ "imap"
+ );
+ gmailServer.port = IMAPServer.port;
+ gmailServer.password = "password";
+ gmailAccount.incomingServer = gmailServer;
+
+ let gmailIdentity = MailServices.accounts.createIdentity();
+ gmailIdentity.email = "imap@invalid";
+ gmailAccount.addIdentity(gmailIdentity);
+ gmailAccount.defaultIdentity = gmailIdentity;
+
+ let gmailRootFolder = gmailServer.rootFolder;
+ gmailServer.performExpand(window.msgWindow);
+ await TestUtils.waitForCondition(
+ () => gmailRootFolder.subFolders.length == 3,
+ "waiting for folders to be created"
+ );
+
+ let gmailInboxFolder = gmailRootFolder.getChildNamed("INBOX");
+ let gmailTrashFolder = gmailRootFolder.getChildNamed("Trash");
+ let gmailGmailFolder = gmailRootFolder.getChildNamed("[Gmail]");
+ await TestUtils.waitForCondition(
+ () => gmailGmailFolder.subFolders.length == 1,
+ "waiting for All Mail folder to be created"
+ );
+ let gmailAllMailFolder = gmailGmailFolder.getChildNamed("All Mail");
+
+ Assert.ok(
+ !folderPane._isGmailFolder(gmailRootFolder),
+ "_isGmailFolder should be false for the root folder"
+ );
+ Assert.ok(
+ folderPane._isGmailFolder(gmailGmailFolder),
+ "_isGmailFolder should be true for the [Gmail] folder"
+ );
+ Assert.ok(
+ !folderPane._isGmailFolder(gmailAllMailFolder),
+ "_isGmailFolder should be false for the All Mail folder"
+ );
+
+ Assert.equal(
+ folderPane._getNonGmailFolder(gmailRootFolder),
+ gmailRootFolder,
+ "_getNonGmailFolder should return the same folder for the root folder"
+ );
+ Assert.equal(
+ folderPane._getNonGmailFolder(gmailGmailFolder),
+ gmailRootFolder,
+ "_getNonGmailFolder should return the root folder for the [Gmail] folder"
+ );
+ Assert.equal(
+ folderPane._getNonGmailFolder(gmailAllMailFolder),
+ gmailAllMailFolder,
+ "_getNonGmailFolder should return the same folder for the All Mail folder"
+ );
+
+ Assert.equal(
+ folderPane._getNonGmailParent(gmailRootFolder),
+ null,
+ "_getNonGmailParent should return null for the root folder"
+ );
+ Assert.equal(
+ folderPane._getNonGmailParent(gmailGmailFolder),
+ gmailRootFolder,
+ "_getNonGmailParent should return the root folder for the [Gmail] folder"
+ );
+ Assert.equal(
+ folderPane._getNonGmailParent(gmailAllMailFolder),
+ gmailRootFolder,
+ "_getNonGmailParent should return the root folder for the All Mail folder"
+ );
+
+ await checkModeListItems("all", [
+ gmailRootFolder,
+ gmailInboxFolder,
+ gmailAllMailFolder,
+ gmailTrashFolder,
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ // The accounts didn't exist when about:3pane loaded, but we can simulate
+ // that by removing the mode and then re-adding it.
+ folderPane.activeModes = ["favorite"];
+ folderPane.activeModes = ["all"];
+
+ await checkModeListItems("all", [
+ gmailRootFolder,
+ gmailInboxFolder,
+ gmailAllMailFolder,
+ gmailTrashFolder,
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ MailServices.accounts.removeAccount(gmailAccount, false);
+});
+
+add_task(async function testAccountOrder() {
+ // Make some changes to the main account so that it appears in all modes.
+
+ [...folderA.messages][0].markRead(false);
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderPane.activeModes = ["all", "smart", "unread", "favorite"];
+
+ let localFolders = [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ];
+ let localExtraFolders = [rootFolder, outboxFolder, folderA, folderB, folderC];
+ let smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ let smartFolders = [
+ smartServer.rootFolder.getChildNamed("Inbox"),
+ inboxFolder,
+ smartServer.rootFolder.getChildNamed("Drafts"),
+ smartServer.rootFolder.getChildNamed("Templates"),
+ smartServer.rootFolder.getChildNamed("Sent"),
+ smartServer.rootFolder.getChildNamed("Archives"),
+ smartServer.rootFolder.getChildNamed("Junk"),
+ smartServer.rootFolder.getChildNamed("Trash"),
+ // There are trash folders in each account, they go here.
+ ];
+
+ // Check the initial items in the folder tree.
+
+ await checkModeListItems("all", localFolders);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ ]);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+ await checkModeListItems("favorite", [rootFolder, folderA]);
+
+ // Create two new "none" accounts, foo and bar.
+
+ let foo = MailServices.accounts.createAccount();
+ foo.incomingServer = MailServices.accounts.createIncomingServer(
+ `${foo.key}user`,
+ "localhost",
+ "none"
+ );
+ let fooRootFolder = foo.incomingServer.rootFolder;
+ let fooTrashFolder = fooRootFolder.getChildNamed("Trash");
+ let fooOutboxFolder = fooRootFolder.getChildNamed("Outbox");
+ let fooFolders = [fooRootFolder, fooTrashFolder, fooOutboxFolder];
+ let fooExtraFolders = [fooRootFolder, fooOutboxFolder];
+
+ let bar = MailServices.accounts.createAccount();
+ bar.incomingServer = MailServices.accounts.createIncomingServer(
+ `${bar.key}user`,
+ "localhost",
+ "none"
+ );
+ let barRootFolder = bar.incomingServer.rootFolder;
+ let barTrashFolder = barRootFolder.getChildNamed("Trash");
+ let barOutboxFolder = barRootFolder.getChildNamed("Outbox");
+ let barFolders = [barRootFolder, barTrashFolder, barOutboxFolder];
+ let barExtraFolders = [barRootFolder, barOutboxFolder];
+
+ let generator = new MessageGenerator();
+ fooTrashFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .addMessage(generator.makeMessage({}).toMboxString());
+ fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ barTrashFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .addMessage(generator.makeMessage({}).toMboxString());
+ barTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ // Check the addition of accounts has put them in the right order.
+
+ Assert.deepEqual(
+ MailServices.accounts.accounts.map(a => a.key),
+ [foo.key, bar.key, account.key]
+ );
+ await checkModeListItems("all", [
+ ...fooFolders,
+ ...barFolders,
+ ...localFolders,
+ ]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ fooTrashFolder,
+ barTrashFolder,
+ trashFolder,
+ ...fooExtraFolders,
+ ...barExtraFolders,
+ ...localExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ fooRootFolder,
+ fooTrashFolder,
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ ]);
+ await checkModeListItems("favorite", [
+ fooRootFolder,
+ fooTrashFolder,
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ ]);
+
+ // Remove and add the modes again. This should reinitialise them.
+
+ folderPane.activeModes = ["recent"];
+ folderPane.activeModes = ["all", "smart", "unread", "favorite"];
+ await checkModeListItems("all", [
+ ...fooFolders,
+ ...barFolders,
+ ...localFolders,
+ ]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ fooTrashFolder,
+ barTrashFolder,
+ trashFolder,
+ ...fooExtraFolders,
+ ...barExtraFolders,
+ ...localExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ fooRootFolder,
+ fooTrashFolder,
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ ]);
+ await checkModeListItems("favorite", [
+ fooRootFolder,
+ fooTrashFolder,
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ ]);
+
+ // Reorder the accounts.
+
+ MailServices.accounts.reorderAccounts([bar.key, account.key, foo.key]);
+ await checkModeListItems("all", [
+ ...barFolders,
+ ...localFolders,
+ ...fooFolders,
+ ]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ barTrashFolder,
+ trashFolder,
+ fooTrashFolder,
+ ...barExtraFolders,
+ ...localExtraFolders,
+ ...fooExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ fooRootFolder,
+ fooTrashFolder,
+ ]);
+ await checkModeListItems("favorite", [
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ fooRootFolder,
+ fooTrashFolder,
+ ]);
+
+ // Reorder the accounts again.
+
+ MailServices.accounts.reorderAccounts([foo.key, account.key, bar.key]);
+ await checkModeListItems("all", [
+ ...fooFolders,
+ ...localFolders,
+ ...barFolders,
+ ]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ fooTrashFolder,
+ trashFolder,
+ barTrashFolder,
+ ...fooExtraFolders,
+ ...localExtraFolders,
+ ...barExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ fooRootFolder,
+ fooTrashFolder,
+ rootFolder,
+ folderA,
+ barRootFolder,
+ barTrashFolder,
+ ]);
+ await checkModeListItems("favorite", [
+ fooRootFolder,
+ fooTrashFolder,
+ rootFolder,
+ folderA,
+ barRootFolder,
+ barTrashFolder,
+ ]);
+
+ // Remove one of the added accounts.
+
+ MailServices.accounts.removeAccount(foo, false);
+ await checkModeListItems("all", [...localFolders, ...barFolders]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ trashFolder,
+ barTrashFolder,
+ ...localExtraFolders,
+ ...barExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ rootFolder,
+ folderA,
+ barRootFolder,
+ barTrashFolder,
+ ]);
+ await checkModeListItems("favorite", [
+ rootFolder,
+ folderA,
+ barRootFolder,
+ barTrashFolder,
+ ]);
+
+ // Remove the other added account, folder flags, and the added folder.
+
+ MailServices.accounts.removeAccount(bar, false);
+ folderA.markAllMessagesRead(null);
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ rootFolder.emptyTrash(null);
+
+ await checkModeListItems("all", localFolders);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ ]);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+ await checkModeListItems("favorite", []);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleLocalFolders")
+ );
+ // Force a 500ms timeout due to a weird intermittent macOS issue that prevents
+ // the Escape key press from closing the menupopup.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ // All instances of local folders shouldn't be present.
+ await checkModeListItems("all", []);
+ await checkModeListItems("smart", [...smartFolders, trashFolder]);
+ await checkModeListItems("unread", []);
+ await checkModeListItems("favorite", []);
+});
+
+async function checkModeListItems(modeName, folders) {
+ // Jump to the end of the event queue so that any code listening for changes
+ // can run first.
+ await new Promise(resolve => setTimeout(resolve));
+ expandAll(modeName);
+
+ Assert.deepEqual(
+ Array.from(
+ folderPane._modes[modeName].containerList.querySelectorAll("li"),
+ folderTreeRow => folderTreeRow.uri
+ ),
+ folders.map(folder => folder.URI)
+ );
+}
+
+function expandAll(modeName) {
+ for (let folderTreeRow of folderPane._modes[
+ modeName
+ ].containerList.querySelectorAll("li")) {
+ folderTree.expandRow(folderTreeRow);
+ }
+}
+
+var IMAPServer = {
+ open() {
+ const {
+ ImapDaemon,
+ ImapMessage,
+ IMAP_GMAIL_extension,
+ IMAP_RFC3348_extension,
+ IMAP_RFC3501_handler,
+ mixinExtension,
+ } = ChromeUtils.import("resource://testing-common/mailnews/Imapd.jsm");
+ const { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+ );
+ IMAPServer.ImapMessage = ImapMessage;
+
+ this.daemon = new ImapDaemon();
+ this.daemon.getMailbox("INBOX").specialUseFlag = "\\Inbox";
+ this.daemon.getMailbox("INBOX").subscribed = true;
+ this.daemon.createMailbox("Trash", {
+ flags: ["\\Trash"],
+ subscribed: true,
+ });
+ this.daemon.createMailbox("[Gmail]", {
+ flags: ["\\NoSelect"],
+ subscribed: true,
+ });
+ this.daemon.createMailbox("[Gmail]/All Mail", {
+ flags: ["\\Archive"],
+ subscribed: true,
+ specialUseFlag: "\\AllMail",
+ });
+ this.server = new nsMailServer(daemon => {
+ let handler = new IMAP_RFC3501_handler(daemon);
+ mixinExtension(handler, IMAP_GMAIL_extension);
+ mixinExtension(handler, IMAP_RFC3348_extension);
+ return handler;
+ }, this.daemon);
+ this.server.start();
+
+ registerCleanupFunction(() => this.close());
+ },
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+};
diff --git a/comm/mail/base/test/browser/browser_formPickers.js b/comm/mail/base/test/browser/browser_formPickers.js
new file mode 100644
index 0000000000..1b98a0584a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_formPickers.js
@@ -0,0 +1,352 @@
+/* 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/. */
+
+/* eslint-env webextensions */
+
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const TEST_DOCUMENT_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/formContent.html";
+
+let testFolder;
+
+async function checkABrowser(browser) {
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ undefined,
+ url => url != "about:blank"
+ );
+ }
+
+ let win = browser.ownerGlobal;
+ let doc = browser.ownerDocument;
+
+ // Date picker
+
+ let picker = win.top.document.getElementById("DateTimePickerPanel");
+ Assert.ok(picker, "date/time picker exists");
+
+ // Open the popup.
+ let shownPromise = BrowserTestUtils.waitForEvent(picker, "popupshown");
+ await SpecialPowers.spawn(browser, [], function () {
+ content.document.notifyUserGestureActivation();
+ content.document.querySelector(`input[type="date"]`).showPicker();
+ });
+ await shownPromise;
+
+ // Allow the picker time to initialise.
+ await new Promise(r => win.setTimeout(r, 500));
+
+ // Click in the middle of the picker. This should always land on a date and
+ // close the picker.
+ let hiddenPromise = BrowserTestUtils.waitForEvent(picker, "popuphidden");
+ let frame = picker.querySelector("#dateTimePopupFrame");
+ EventUtils.synthesizeMouseAtCenter(
+ frame.contentDocument.querySelector(".days-view td"),
+ {},
+ frame.contentWindow
+ );
+ await hiddenPromise;
+
+ // Check the date was assigned to the input.
+ await SpecialPowers.spawn(browser, [], () => {
+ Assert.ok(content.document.querySelector(`input[type="date"]`).value);
+ });
+
+ // Select drop-down
+
+ let menulist = win.top.document.getElementById("ContentSelectDropdown");
+ Assert.ok(menulist, "select menulist exists");
+ let menupopup = menulist.menupopup;
+
+ // Click on the select control to open the popup.
+ shownPromise = BrowserTestUtils.waitForEvent(menulist, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter("select", {}, browser);
+ await shownPromise;
+
+ // Allow the menulist time to initialise.
+ await new Promise(r => win.setTimeout(r, 500));
+
+ Assert.equal(menulist.value, "0");
+ Assert.equal(menupopup.childElementCount, 3);
+ // Item values do not match the content document, but are 0-indexed.
+ Assert.equal(menupopup.children[0].label, "");
+ Assert.equal(menupopup.children[0].value, "0");
+ Assert.equal(menupopup.children[1].label, "Ï€");
+ Assert.equal(menupopup.children[1].value, "1");
+ Assert.equal(menupopup.children[2].label, "Ï„");
+ Assert.equal(menupopup.children[2].value, "2");
+
+ // Click the second option. This sets the value and closes the menulist.
+ hiddenPromise = BrowserTestUtils.waitForEvent(menulist, "popuphidden");
+ menupopup.activateItem(menupopup.children[1]);
+ await hiddenPromise;
+
+ // Sometimes the next change doesn't happen soon enough.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ // Check the value was assigned to the control.
+ await SpecialPowers.spawn(browser, [], () => {
+ Assert.equal(content.document.querySelector("select").value, "3.141592654");
+ });
+
+ // Input auto-complete
+
+ browser.focus();
+
+ let popup = doc.getElementById(browser.getAttribute("autocompletepopup"));
+ Assert.ok(popup, "auto-complete popup exists");
+
+ // Click on the input box and type some letters to open the popup.
+ shownPromise = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `input[list="letters"]`,
+ {},
+ browser
+ );
+ await BrowserTestUtils.synthesizeKey("e", {}, browser);
+ await BrowserTestUtils.synthesizeKey("t", {}, browser);
+ await BrowserTestUtils.synthesizeKey("a", {}, browser);
+ await shownPromise;
+
+ // Allow the popup time to initialise.
+ await new Promise(r => win.setTimeout(r, 500));
+
+ let list = popup.querySelector("richlistbox");
+ Assert.ok(list, "list added to popup");
+ Assert.equal(list.itemCount, 4);
+ Assert.equal(list.itemChildren[0].getAttribute("title"), "beta");
+ Assert.equal(list.itemChildren[1].getAttribute("title"), "zeta");
+ Assert.equal(list.itemChildren[2].getAttribute("title"), "eta");
+ Assert.equal(list.itemChildren[3].getAttribute("title"), "theta");
+
+ // Click the second option. This sets the value and closes the popup.
+ hiddenPromise = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(list.itemChildren[1], {}, win);
+ await hiddenPromise;
+
+ // Check the value was assigned to the input.
+ await SpecialPowers.spawn(browser, [], () => {
+ Assert.equal(
+ content.document.querySelector(`input[list="letters"]`).value,
+ "zeta"
+ );
+ });
+}
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("formPickerFolder", null);
+ testFolder = rootFolder
+ .getChildNamed("formPickerFolder")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ let messages = new MessageGenerator().makeMessages({ count: 5 });
+ let messageStrings = messages.map(message => message.toMboxString());
+ testFolder.addMessageBatch(messageStrings);
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testMessagePane() {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderURI: testFolder.URI,
+ messagePaneVisible: true,
+ });
+
+ about3Pane.messagePane.displayWebPage(TEST_DOCUMENT_URL);
+ await checkABrowser(about3Pane.webBrowser);
+});
+
+add_task(async function testContentTab() {
+ let tab = window.openContentTab(TEST_DOCUMENT_URL);
+ await checkABrowser(tab.browser);
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
+
+add_task(async function testExtensionPopupWindow() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ await browser.windows.create({
+ url: "formContent.html",
+ type: "popup",
+ width: 800,
+ height: 500,
+ });
+ browser.test.notifyPass("ready");
+ },
+ files: {
+ "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("ready");
+
+ let extensionPopup = Services.wm.getMostRecentWindow("mail:extensionPopup");
+ // extensionPopup.xhtml needs time to initialise properly.
+ await new Promise(resolve => extensionPopup.setTimeout(resolve, 500));
+ await checkABrowser(extensionPopup.document.getElementById("requestFrame"));
+ await BrowserTestUtils.closeWindow(extensionPopup);
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionBrowserAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "formpickers@mochi.test",
+ },
+ },
+ browser_action: {
+ default_popup: "formContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let { panel, browser } = await openExtensionPopup(
+ window,
+ "ext-formpickers\\@mochi.test"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionComposeAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "formpickers@mochi.test",
+ },
+ },
+ compose_action: {
+ default_popup: "formContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "load");
+
+ let { panel, browser } = await openExtensionPopup(
+ composeWindow,
+ "formpickers_mochi_test-composeAction-toolbarbutton"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(composeWindow);
+});
+
+add_task(async function testExtensionMessageDisplayAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "formpickers@mochi.test",
+ },
+ },
+ message_display_action: {
+ default_popup: "formContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let messageWindowPromise = BrowserTestUtils.domWindowOpened();
+ window.MsgOpenNewWindowForMessage([...testFolder.messages][0]);
+ let messageWindow = await messageWindowPromise;
+ let { target: aboutMessage } = await BrowserTestUtils.waitForEvent(
+ messageWindow,
+ "aboutMessageLoaded"
+ );
+
+ let { panel, browser } = await openExtensionPopup(
+ aboutMessage,
+ "formpickers_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(messageWindow);
+});
+
+add_task(async function testBrowserRequestWindow() {
+ let requestWindow = await new Promise(resolve => {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/browserRequest.xhtml",
+ null,
+ "chrome,private,centerscreen,width=980,height=750",
+ {
+ url: TEST_DOCUMENT_URL,
+ cancelled() {},
+ loaded(window, webProgress) {
+ resolve(window);
+ },
+ }
+ );
+ });
+
+ await checkABrowser(requestWindow.document.getElementById("requestFrame"));
+ await BrowserTestUtils.closeWindow(requestWindow);
+});
diff --git a/comm/mail/base/test/browser/browser_goMenu.js b/comm/mail/base/test/browser/browser_goMenu.js
new file mode 100644
index 0000000000..6a15a5e1d2
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_goMenu.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+/** @type MenuData */
+const goMenuData = {
+ goNextMenu: {},
+ menu_nextMsg: { disabled: true },
+ menu_nextUnreadMsg: { disabled: true },
+ menu_nextFlaggedMsg: { disabled: true },
+ menu_nextUnreadThread: { disabled: true },
+ "calendar-go-menu-next": { hidden: true },
+ goPreviousMenu: {},
+ menu_prevMsg: { disabled: true },
+ menu_prevUnreadMsg: { disabled: true },
+ menu_prevFlaggedMsg: { disabled: true },
+ "calendar-go-menu-previous": { hidden: true },
+ menu_goForward: { disabled: true },
+ menu_goBack: { disabled: true },
+ "calendar-go-to-today-menuitem": { hidden: true },
+ menu_goChat: {},
+ goFolderMenu: {},
+ goRecentlyClosedTabs: { disabled: true },
+ goStartPage: {},
+};
+let helper = new MenuTestHelper("menu_Go", goMenuData);
+
+add_setup(async function () {
+ document.getElementById("tabmail").clearRecentlyClosedTabs();
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+});
+
+add_task(async function test3PaneTab() {
+ await helper.testAllItems("mail3PaneTab");
+});
diff --git a/comm/mail/base/test/browser/browser_interactionTelemetry.js b/comm/mail/base/test/browser/browser_interactionTelemetry.js
new file mode 100644
index 0000000000..a958a24bdf
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_interactionTelemetry.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const AREAS = ["keyboard", "calendar", "chat", "message_display", "toolbox"];
+
+// Checks that the correct number of clicks are registered against the correct
+// keys in the scalars.
+function assertInteractionScalars(expectedAreas) {
+ let processScalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent ?? {};
+
+ for (let source of AREAS) {
+ let scalars = processScalars?.[`tb.ui.interaction.${source}`] ?? {};
+
+ let expected = expectedAreas[source] ?? {};
+
+ let expectedKeys = new Set(
+ Object.keys(scalars).concat(Object.keys(expected))
+ );
+ for (let key of expectedKeys) {
+ Assert.equal(
+ scalars[key],
+ expected[key],
+ `Expected to see the correct value for ${key} in ${source}.`
+ );
+ }
+ }
+}
+
+add_task(async function () {
+ Services.telemetry.clearScalars();
+
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendarButton"),
+ {},
+ window
+ );
+
+ let calendarWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ "cancel",
+ "chrome://calendar/content/calendar-creation.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.querySelector("#newCalendarSidebarButton"),
+ {},
+ window
+ );
+ await calendarWindowPromise;
+
+ EventUtils.synthesizeMouseAtCenter(
+ document.querySelector("#tabmail-tabs tab:nth-child(2) .tab-close-button"),
+ {},
+ window
+ );
+
+ assertInteractionScalars({
+ calendar: {
+ newCalendarSidebarButton: 1,
+ },
+ toolbox: {
+ calendarButton: 1,
+ "tab-close-button": 1,
+ },
+ });
+});
diff --git a/comm/mail/base/test/browser/browser_linkHandler.js b/comm/mail/base/test/browser/browser_linkHandler.js
new file mode 100644
index 0000000000..38cb8e5b05
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_linkHandler.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/. */
+
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+const TEST_DOMAIN = "http://example.org";
+const TEST_IP = "http://127.0.0.1:8888";
+const TEST_PATH = "/browser/comm/mail/base/test/browser/files/links.html";
+
+let links = new Map([
+ ["#this-hash", `${TEST_PATH}#hash`],
+ ["#this-nohash", `${TEST_PATH}`],
+ [
+ "#local-here",
+ "/browser/comm/mail/base/test/browser/files/sampleContent.html",
+ ],
+ [
+ "#local-elsewhere",
+ "/browser/comm/mail/components/extensions/test/browser/data/content.html",
+ ],
+ ["#other-https", `https://example.org${TEST_PATH}`],
+ ["#other-port", `http://example.org:8000${TEST_PATH}`],
+ ["#other-subdomain", `http://test1.example.org${TEST_PATH}`],
+ ["#other-subsubdomain", `http://sub1.test1.example.org${TEST_PATH}`],
+ ["#other-domain", `http://mochi.test:8888${TEST_PATH}`],
+]);
+
+/** @implements {nsIWebProgressListener} */
+let webProgressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ _browser: null,
+ _deferred: null,
+
+ onStateChange(webProgress, request, stateFlags, status) {
+ if (
+ !(stateFlags & Ci.nsIWebProgressListener.STATE_STOP) ||
+ this._browser?.currentURI.spec == "about:blank"
+ ) {
+ return;
+ }
+
+ if (this._deferred) {
+ let deferred = this._deferred;
+ let url = this._browser.currentURI.spec;
+ this.cancelPromise();
+
+ deferred.resolve(url);
+ } else {
+ this.cancelPromise();
+ Assert.ok(false, "unexpected state change");
+ }
+ },
+
+ onLocationChange(webProgress, request, location, flags) {
+ if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_HASHCHANGE)) {
+ return;
+ }
+
+ if (this._deferred) {
+ let deferred = this._deferred;
+ let url = this._browser.currentURI.spec;
+ this.cancelPromise();
+
+ deferred.resolve(url);
+ } else {
+ this.cancelPromise();
+ Assert.ok(false, "unexpected location change");
+ }
+ },
+
+ promiseEvent(browser) {
+ this._browser = browser;
+ browser.webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL | Ci.nsIWebProgress.NOTIFY_LOCATION
+ );
+
+ this._deferred = PromiseUtils.defer();
+ return this._deferred.promise;
+ },
+
+ cancelPromise() {
+ this._deferred = null;
+ this._browser.removeProgressListener(this);
+ this._browser = null;
+ },
+};
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+
+ _deferred: null,
+
+ loadURI(aURI, aWindowContext) {
+ if (this._deferred) {
+ let deferred = this._deferred;
+ this._deferred = null;
+
+ deferred.resolve(aURI.spec);
+ } else {
+ this.cancelPromise();
+ Assert.ok(false, "unexpected call to external protocol service");
+ }
+ },
+
+ promiseEvent() {
+ this._deferred = PromiseUtils.defer();
+ return this._deferred.promise;
+ },
+
+ cancelPromise() {
+ this._deferred = null;
+ },
+};
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(tabmail.tabInfo.length, 1);
+
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(tabmail.tabInfo[1]);
+ }
+
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+async function clickOnLink(
+ browser,
+ selector,
+ url,
+ pageURL,
+ shouldLoadInternally
+) {
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // Clear the event queue.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ }
+ Assert.equal(
+ browser.currentURI?.spec,
+ pageURL,
+ "original URL should be loaded"
+ );
+
+ let webProgressPromise = webProgressListener.promiseEvent(browser);
+ let externalProtocolPromise = mockExternalProtocolService.promiseEvent();
+
+ info(`clicking on ${selector}`);
+ await BrowserTestUtils.synthesizeMouseAtCenter(selector, {}, browser);
+
+ await Promise.any([webProgressPromise, externalProtocolPromise]);
+
+ if (selector == "#this-hash") {
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let target = doc.querySelector("#hash");
+ let targetRect = target.getBoundingClientRect();
+ Assert.less(
+ targetRect.bottom,
+ doc.documentElement.clientHeight,
+ "page did scroll"
+ );
+ });
+ }
+
+ if (shouldLoadInternally) {
+ Assert.equal(
+ await webProgressPromise,
+ url,
+ `${url} should load internally`
+ );
+ mockExternalProtocolService.cancelPromise();
+ } else {
+ Assert.equal(
+ await externalProtocolPromise,
+ url,
+ `${url} should load externally`
+ );
+ webProgressListener.cancelPromise();
+ }
+
+ if (browser.currentURI?.spec != pageURL) {
+ let promise = webProgressListener.promiseEvent(browser);
+ browser.browsingContext.goBack();
+ await promise;
+ Assert.equal(browser.currentURI?.spec, pageURL, "should have gone back");
+ }
+}
+
+async function subtest(pagePrePath, group, shouldLoadCB) {
+ let tabmail = document.getElementById("tabmail");
+ let tab = window.openContentTab(
+ `${pagePrePath}${TEST_PATH}`,
+ undefined,
+ group
+ );
+
+ let expectedGroup = group;
+ if (group === null) {
+ expectedGroup = "browsers";
+ } else if (group === undefined) {
+ expectedGroup = "single-site";
+ }
+ Assert.equal(tab.browser.getAttribute("messagemanagergroup"), expectedGroup);
+
+ try {
+ for (let [selector, url] of links) {
+ if (url.startsWith("/")) {
+ url = `${pagePrePath}${url}`;
+ }
+ await clickOnLink(
+ tab.browser,
+ selector,
+ url,
+ `${pagePrePath}${TEST_PATH}`,
+ shouldLoadCB(selector)
+ );
+ }
+ } finally {
+ tabmail.closeTab(tab);
+ }
+}
+
+add_task(function testNoGroup() {
+ return subtest(
+ TEST_DOMAIN,
+ undefined,
+ selector => selector != "#other-domain"
+ );
+});
+
+add_task(function testBrowsersGroup() {
+ return subtest(TEST_DOMAIN, null, selector => true);
+});
+
+add_task(function testSingleSiteGroup() {
+ return subtest(
+ TEST_DOMAIN,
+ "single-site",
+ selector => selector != "#other-domain"
+ );
+});
+
+add_task(function testSinglePageGroup() {
+ return subtest(TEST_DOMAIN, "single-page", selector =>
+ selector.startsWith("#this")
+ );
+});
+
+add_task(function testNoGroupWithIP() {
+ return subtest(
+ TEST_IP,
+ undefined,
+ selector => selector.startsWith("#this") || selector.startsWith("#local")
+ );
+});
+
+add_task(function testBrowsersGroupWithIP() {
+ return subtest(TEST_IP, null, selector => true);
+});
+
+add_task(function testSingleSiteGroupWithIP() {
+ return subtest(
+ TEST_IP,
+ "single-site",
+ selector => selector.startsWith("#this") || selector.startsWith("#local")
+ );
+});
+
+add_task(function testSinglePageGroupWithIP() {
+ return subtest(TEST_IP, "single-page", selector =>
+ selector.startsWith("#this")
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_mailContext.js b/comm/mail/base/test/browser/browser_mailContext.js
new file mode 100644
index 0000000000..3b75b2827e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_mailContext.js
@@ -0,0 +1,950 @@
+/* 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 { ConversationOpener } = ChromeUtils.import(
+ "resource:///modules/ConversationOpener.jsm"
+);
+var { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm");
+var { GlodaSyntheticView } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaSyntheticView.jsm"
+);
+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 { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const TEST_MESSAGE_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.eml";
+
+let tabmail = document.getElementById("tabmail");
+let testFolder, testMessages;
+let draftsFolder, draftsMessages;
+let templatesFolder, templatesMessages;
+let listFolder, listMessages;
+
+let singleSelectionMessagePane = [
+ "singleMessage",
+ "draftsFolder",
+ "templatesFolder",
+ "listFolder",
+ "syntheticFolderDraft",
+ "syntheticFolder",
+];
+let singleSelectionThreadPane = [
+ "singleMessageTree",
+ "draftsFolderTree",
+ "templatesFolderTree",
+ "listFolderTree",
+ "syntheticFolderDraftTree",
+ "syntheticFolderTree",
+];
+let onePane = ["messageTab", "messageWindow"];
+let external = ["externalMessageTab", "externalMessageWindow"];
+let allSingleSelection = [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ ...onePane,
+ ...external,
+];
+let allThreePane = [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ "multipleMessagesTree",
+ "collapsedThreadTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+];
+const noCollapsedThreads = [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ "multipleMessagesTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+ ...onePane,
+ ...external,
+];
+let notExternal = [...allThreePane, ...onePane];
+let singleNotExternal = [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ ...onePane,
+];
+
+const mailContextData = {
+ "mailContext-selectall": [
+ ...singleSelectionMessagePane,
+ ...onePane,
+ ...external,
+ ],
+ "mailContext-editDraftMsg": [
+ "draftsFolder",
+ "draftsFolderTree",
+ "multipleDraftsFolderTree",
+ "syntheticFolderDraft",
+ "syntheticFolderDraftTree",
+ ],
+ "mailContext-newMsgFromTemplate": [
+ "templatesFolder",
+ "templatesFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+ "mailContext-editTemplateMsg": [
+ "templatesFolder",
+ "templatesFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+ "mailContext-openNewTab": singleSelectionThreadPane,
+ "mailContext-openNewWindow": singleSelectionThreadPane,
+ "mailContext-openConversation": [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ ...onePane,
+ "collapsedThreadTree",
+ ],
+ "mailContext-openContainingFolder": [
+ "syntheticFolderDraft",
+ "syntheticFolderDraftTree",
+ "syntheticFolder",
+ "syntheticFolderTree",
+ ...onePane,
+ ],
+ "mailContext-replySender": noCollapsedThreads,
+ "mailContext-replyAll": noCollapsedThreads,
+ "mailContext-replyList": ["listFolder", "listFolderTree"],
+ "mailContext-forward": allSingleSelection,
+ "mailContext-forwardAsMenu": allSingleSelection,
+ "mailContext-multiForwardAsAttachment": [
+ "multipleMessagesTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+ "mailContext-redirect": noCollapsedThreads,
+ "mailContext-editAsNew": noCollapsedThreads,
+ "mailContext-tags": notExternal,
+ "mailContext-mark": notExternal,
+ "mailContext-archive": notExternal,
+ "mailContext-moveMenu": notExternal,
+ "mailContext-copyMenu": true,
+ "mailContext-decryptToFolder": [
+ "multipleMessagesTree",
+ "collapsedThreadTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+ "mailContext-calendar-convert-menu": singleNotExternal,
+ "mailContext-delete": notExternal,
+ "mailContext-ignoreThread": allThreePane,
+ "mailContext-ignoreSubthread": allThreePane,
+ "mailContext-watchThread": notExternal,
+ "mailContext-saveAs": true,
+ "mailContext-print": true,
+ "mailContext-downloadSelected": [
+ "multipleMessagesTree",
+ "collapsedThreadTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+};
+
+function checkMenuitems(menu, mode) {
+ if (!mode) {
+ // Menu should not be shown.
+ Assert.equal(menu.state, "closed");
+ return;
+ }
+
+ info(`Checking menus for ${mode} ...`);
+
+ Assert.notEqual(menu.state, "closed", "Menu should be closed");
+
+ let expectedItems = [];
+ for (let [id, modes] of Object.entries(mailContextData)) {
+ if (modes === true || modes.includes(mode)) {
+ expectedItems.push(id);
+ }
+ }
+
+ let actualItems = [];
+ for (let item of menu.children) {
+ if (["menu", "menuitem"].includes(item.localName) && !item.hidden) {
+ actualItems.push(item.id);
+ }
+ }
+
+ let notFoundItems = expectedItems.filter(i => !actualItems.includes(i));
+ if (notFoundItems.length) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "items expected but not found: " + notFoundItems.join(", ")
+ );
+ }
+
+ let unexpectedItems = actualItems.filter(i => !expectedItems.includes(i));
+ if (unexpectedItems.length) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "items found but not expected: " + unexpectedItems.join(", ")
+ );
+ }
+
+ Assert.deepEqual(actualItems, expectedItems);
+
+ menu.hidePopup();
+}
+
+add_setup(async function () {
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("mailContextFolder", null);
+ testFolder = rootFolder
+ .getChildNamed("mailContextFolder")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ let message = await fetch(TEST_MESSAGE_URL).then(r => r.text());
+ testFolder.addMessageBatch([message]);
+ let messages = [
+ ...generator.makeMessages({ count: 5 }),
+ ...generator.makeMessages({ count: 5, msgsPerThread: 5 }),
+ ...generator.makeMessages({ count: 200 }),
+ ];
+ let messageStrings = messages.map(message => message.toMboxString());
+ testFolder.addMessageBatch(messageStrings);
+ testMessages = [...testFolder.messages];
+ rootFolder.createSubfolder("mailContextDrafts", null);
+ draftsFolder = rootFolder
+ .getChildNamed("mailContextDrafts")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ draftsFolder.setFlag(Ci.nsMsgFolderFlags.Drafts);
+ draftsFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ draftsMessages = [...draftsFolder.messages];
+ rootFolder.createSubfolder("mailContextTemplates", null);
+ templatesFolder = rootFolder
+ .getChildNamed("mailContextTemplates")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ templatesFolder.setFlag(Ci.nsMsgFolderFlags.Templates);
+ templatesFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ templatesMessages = [...templatesFolder.messages];
+ rootFolder.createSubfolder("mailContextMailingList", null);
+ listFolder = rootFolder
+ .getChildNamed("mailContextMailingList")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ listFolder.addMessage(
+ "From - Mon Jan 01 00:00:00 2001\n" +
+ "To: Mailing List <list@example.com>\n" +
+ "Date: Mon, 01 Jan 2001 00:00:00 +0100\n" +
+ "List-Help: <https://list.example.com>\n" +
+ "List-Post: <mailto:list@example.com>\n" +
+ "List-Software: Mailing List Software\n" +
+ "List-Subscribe: <https://subscribe.example.com>\n" +
+ "Precedence: list\n" +
+ "Subject: Mailing List Test Mail\n" +
+ `Message-ID: <${Date.now()}@example.com>\n` +
+ "From: Mailing List <list@example.com>\n" +
+ "List-Unsubscribe: <https://unsubscribe.example.com>,\n" +
+ " <mailto:unsubscribe@example.com?subject=Unsubscribe Test>\n" +
+ "MIME-Version: 1.0\n" +
+ "Content-Type: text/plain; charset=UTF-8\n" +
+ "Content-Transfer-Encoding: quoted-printable\n" +
+ "\n" +
+ "Mailing List Message Body\n"
+ );
+ listMessages = [...listFolder.messages];
+
+ tabmail.currentAbout3Pane.restoreState({
+ folderURI: testFolder.URI,
+ messagePaneVisible: true,
+ });
+
+ // Enable home calendar.
+ cal.manager.getCalendars()[0].setProperty("disabled", false);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("mail.openMessageBehavior");
+ cal.manager.getCalendars()[0].setProperty("disabled", true);
+ });
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane when no
+ * messages are selected.
+ */
+add_task(async function testNoMessages() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { messageBrowser, messagePane, threadTree } = about3Pane;
+ messagePane.clearAll();
+
+ // The message pane browser isn't visible.
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(messageBrowser),
+ "message browser should be hidden"
+ );
+ Assert.equal(messageBrowser.currentURI.spec, "about:message");
+ Assert.equal(
+ messageBrowser.contentWindow.getMessagePaneBrowser().currentURI.spec,
+ "about:blank"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.document.getElementById("messagePane"),
+ { type: "contextmenu" }
+ );
+ checkMenuitems(mailContext);
+
+ // Open the menu from an empty part of the thread pane.
+
+ let treeRect = threadTree.getBoundingClientRect();
+ EventUtils.synthesizeMouse(
+ threadTree,
+ treeRect.x + treeRect.width / 2,
+ treeRect.bottom - 10,
+ { type: "contextmenu" },
+ about3Pane
+ );
+ checkMenuitems(mailContext);
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane when one
+ * message is selected.
+ */
+add_task(async function testSingleMessage() {
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(testMessages[0]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ threadTree.scrollToIndex(0, true);
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "singleMessage");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "singleMessageTree");
+
+ // Open the menu through the keyboard.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ const row = threadTree.getRowAtIndex(0);
+ row.focus();
+ EventUtils.synthesizeMouseAtCenter(
+ row,
+ { type: "contextmenu", button: 0 },
+ about3Pane
+ );
+ await shownPromise;
+ Assert.ok(
+ BrowserTestUtils.is_visible(mailContext),
+ "Context menu is shown through keyboard action"
+ );
+ mailContext.hidePopup();
+
+ // Open the menu through the keyboard on a message that is scrolled slightly
+ // out of view.
+
+ threadTree.selectedIndex = 5;
+ threadTree.scrollToIndex(threadTree.getLastVisibleIndex() + 7, true);
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ Assert.equal(threadTree.currentIndex, 5, "Row 5 is the current row");
+ Assert.ok(row.parentNode, "Row element should still be attached");
+ Assert.greater(
+ threadTree.getFirstVisibleIndex(),
+ 5,
+ "Selected row should no longer be visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree,
+ { type: "contextmenu", button: 0 },
+ about3Pane
+ );
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ await BrowserTestUtils.waitForPopupEvent(mailContext, "shown");
+ Assert.greaterOrEqual(
+ 5,
+ threadTree.getFirstVisibleIndex(),
+ "Current row is greater than or equal to first visible index"
+ );
+ Assert.lessOrEqual(
+ 5,
+ threadTree.getLastVisibleIndex(),
+ "Current row is less than or equal to last visible index"
+ );
+ mailContext.hidePopup();
+
+ // Open the menu on a message that is scrolled out of view.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ threadTree.scrollToIndex(200, true);
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ Assert.ok(!row.parentNode, "Row element should no longer be attached");
+ Assert.equal(threadTree.currentIndex, 5, "Row 5 is the current row");
+ Assert.ok(
+ !threadTree.getRowAtIndex(threadTree.currentIndex),
+ "Current row is scrolled out of view"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree,
+ { type: "contextmenu", button: 0 },
+ about3Pane
+ );
+ await shownPromise;
+ Assert.ok(
+ threadTree.getRowAtIndex(threadTree.currentIndex),
+ "Current row is scrolled into view when showing context menu"
+ );
+ Assert.greaterOrEqual(
+ 5,
+ threadTree.getFirstVisibleIndex(),
+ "Current row is greater than or equal to first visible index"
+ );
+ Assert.lessOrEqual(
+ 5,
+ threadTree.getLastVisibleIndex(),
+ "Current row is less than or equal to last visible index"
+ );
+ mailContext.hidePopup();
+
+ Assert.ok(BrowserTestUtils.is_hidden(mailContext), "Context menu is hidden");
+});
+
+/**
+ * Tests the mailContext menu on the thread tree when more than one message is
+ * selected.
+ */
+add_task(async function testMultipleMessages() {
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(testMessages[6]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { messageBrowser, multiMessageBrowser, threadTree } = about3Pane;
+ threadTree.scrollToIndex(1, true);
+ threadTree.selectedIndices = [1, 2, 3];
+ await TestUtils.waitForTick(); // Wait for rows to be added.
+
+ // The message pane browser isn't visible.
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(messageBrowser),
+ "message browser should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(multiMessageBrowser),
+ "multimessage browser should be visible"
+ );
+
+ // Open the menu from the thread pane.
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(2),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "multipleMessagesTree");
+
+ // Select a collapsed thread and open the menu.
+
+ threadTree.scrollToIndex(6, true);
+ threadTree.selectedIndices = [6];
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(6),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "collapsedThreadTree");
+
+ // Open the menu in the thread pane on a message scrolled out of view.
+
+ threadTree.selectAll();
+ threadTree.currentIndex = 200;
+ await TestUtils.waitForTick();
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ threadTree.scrollToIndex(0, true);
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ Assert.ok(
+ !threadTree.getRowAtIndex(threadTree.currentIndex),
+ "Current row is scrolled out of view"
+ );
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree,
+ { type: "contextmenu", button: 0 },
+ about3Pane
+ );
+ await shownPromise;
+ Assert.ok(
+ threadTree.getRowAtIndex(threadTree.currentIndex),
+ "Current row is scrolled into view when popup is shown"
+ );
+ mailContext.hidePopup();
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane of a Drafts
+ * folder.
+ */
+add_task(async function testDraftsFolder() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({ folderURI: draftsFolder.URI });
+
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(draftsMessages[1]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "draftsFolder");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "draftsFolderTree");
+
+ threadTree.scrollToIndex(1, true);
+ threadTree.selectedIndices = [1, 2, 3];
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(2),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "multipleDraftsFolderTree");
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane of a Templates
+ * folder.
+ */
+add_task(async function testTemplatesFolder() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({ folderURI: templatesFolder.URI });
+
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(templatesMessages[1]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "templatesFolder");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "templatesFolderTree");
+
+ threadTree.scrollToIndex(1, true);
+ threadTree.selectedIndices = [1, 2, 3];
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(2),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "multipleTemplatesFolderTree");
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane of a
+ * mailing list message.
+ */
+
+add_task(async function testListMessage() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({ folderURI: listFolder.URI });
+
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(listMessages[0]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "listFolder");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "listFolderTree");
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane of a Gloda
+ * synthetic view (in this case a conversation, but a list of search results
+ * should be the same).
+ */
+add_task(async function testSyntheticFolder() {
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(testMessages[9]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(draftsMessages[4]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "aboutMessageLoaded");
+ tabmail.openTab("mail3PaneTab", {
+ syntheticView: new GlodaSyntheticView({
+ collection: Gloda.getMessageCollectionForHeaders([
+ ...draftsMessages,
+ ...testMessages.slice(6),
+ ]),
+ }),
+ title: "Test gloda results",
+ });
+ await tabPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "syntheticFolderDraft");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "syntheticFolderDraftTree");
+
+ loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(5))
+ );
+ threadTree.selectedIndex = 5;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "syntheticFolder");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(5),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "syntheticFolderTree");
+
+ tabmail.closeOtherTabs(0);
+});
+
+/**
+ * Tests the mailContext menu on the message pane of a message in a tab.
+ */
+add_task(async function testMessageTab() {
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ window.OpenMessageInNewTab(testMessages[0], { background: false });
+ await tabPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let mailContext = aboutMessage.document.getElementById("mailContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ aboutMessage.getMessagePaneBrowser()
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "messageTab");
+
+ tabmail.closeOtherTabs(0);
+});
+
+/**
+ * Tests the mailContext menu on the message pane of a file message in a tab.
+ */
+add_task(async function testExternalMessageTab() {
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ let messageFile = new FileUtils.File(
+ getTestFilePath("files/sampleContent.eml")
+ );
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_TAB
+ );
+ MailUtils.openEMLFile(
+ window,
+ messageFile,
+ Services.io.newFileURI(messageFile)
+ );
+ await tabPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let mailContext = aboutMessage.document.getElementById("mailContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ aboutMessage.getMessagePaneBrowser()
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "externalMessageTab");
+
+ tabmail.closeOtherTabs(0);
+});
+
+/**
+ * Tests the mailContext menu on the message pane of a message in a window.
+ */
+add_task(async function testMessageWindow() {
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.MsgOpenNewWindowForMessage(testMessages[0]);
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+
+ let aboutMessage = win.messageBrowser.contentWindow;
+ let mailContext = aboutMessage.document.getElementById("mailContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ aboutMessage.getMessagePaneBrowser()
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "messageWindow");
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests the mailContext menu on the message pane of a file message in a window.
+ */
+add_task(async function testExternalMessageWindow() {
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let messageFile = new FileUtils.File(
+ getTestFilePath("files/sampleContent.eml")
+ );
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_WINDOW
+ );
+ MailUtils.openEMLFile(
+ window,
+ messageFile,
+ Services.io.newFileURI(messageFile)
+ );
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+
+ let aboutMessage = win.messageBrowser.contentWindow;
+ let mailContext = aboutMessage.document.getElementById("mailContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ aboutMessage.getMessagePaneBrowser()
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "externalMessageWindow");
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/mail/base/test/browser/browser_mailTabsAndWindows.js b/comm/mail/base/test/browser/browser_mailTabsAndWindows.js
new file mode 100644
index 0000000000..3da815edd9
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_mailTabsAndWindows.js
@@ -0,0 +1,355 @@
+/* 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 { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let folderA, messagesA, folderB, messagesB;
+
+add_setup(async function () {
+ 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);
+ }
+ }
+ Assert.equal(tabmail.tabInfo.length, 1, "should be set up with one tab");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ let rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("mailTabsA", null);
+ folderA = rootFolder
+ .getChildNamed("mailTabsA")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderA.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ messagesA = [...folderA.messages];
+
+ rootFolder.createSubfolder("mailTabsB", null);
+ folderB = rootFolder
+ .getChildNamed("mailTabsB")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderB.addMessageBatch(
+ generator.makeMessages({ count: 2 }).map(message => message.toMboxString())
+ );
+ messagesB = [...folderB.messages];
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testTabs() {
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(tabmail.tabInfo.length, 1, "should start off with one tab open");
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[0], "should show tab0");
+
+ // Check the first tab.
+
+ let firstTab = tabmail.currentTabInfo;
+ Assert.equal(firstTab.mode.name, "mail3PaneTab");
+ Assert.equal(firstTab.mode.tabType.name, "mailTab");
+
+ let firstChromeBrowser = firstTab.chromeBrowser;
+ Assert.equal(firstChromeBrowser.currentURI.spec, "about:3pane");
+ Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow);
+
+ let firstMessageBrowser =
+ firstChromeBrowser.contentDocument.getElementById("messageBrowser");
+ Assert.equal(firstMessageBrowser.currentURI.spec, "about:message");
+
+ let firstMessagePane =
+ firstMessageBrowser.contentDocument.getElementById("messagepane");
+ Assert.equal(firstMessagePane.currentURI.spec, "about:blank");
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with no message selected"
+ );
+ Assert.equal(firstTab.browser, null);
+ Assert.equal(firstTab.linkedBrowser, null);
+
+ let { folderTree, threadTree, messagePane, paneLayout } =
+ firstChromeBrowser.contentWindow;
+
+ firstTab.folder = folderA;
+ Assert.equal(firstTab.folder, folderA);
+ Assert.equal(
+ folderTree.querySelector(".selected .name").textContent,
+ "mailTabsA"
+ );
+ Assert.equal(threadTree.view.rowCount, 5);
+ Assert.equal(threadTree.selectedIndex, -1);
+
+ Assert.equal(firstTab.message, null);
+ threadTree.selectedIndex = 0;
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ firstMessageBrowser.contentWindow,
+ "currentAboutMessage should have a value with a message selected"
+ );
+ Assert.equal(firstTab.message, messagesA[0]);
+ Assert.equal(firstTab.browser, firstMessagePane);
+ Assert.equal(firstTab.linkedBrowser, firstMessagePane);
+
+ Assert.ok(BrowserTestUtils.is_visible(folderTree));
+ Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser));
+
+ paneLayout.folderPaneVisible = false;
+ Assert.ok(BrowserTestUtils.is_hidden(folderTree));
+ Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser));
+
+ paneLayout.messagePaneVisible = false;
+ Assert.ok(BrowserTestUtils.is_hidden(folderTree));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser));
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with the message pane hidden"
+ );
+ Assert.equal(firstTab.browser, null);
+ Assert.equal(firstTab.linkedBrowser, null);
+
+ paneLayout.folderPaneVisible = true;
+ Assert.ok(BrowserTestUtils.is_visible(folderTree));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser));
+
+ paneLayout.messagePaneVisible = true;
+ Assert.ok(BrowserTestUtils.is_visible(folderTree));
+ Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser));
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ firstMessageBrowser.contentWindow,
+ "currentAboutMessage should have a value with the message pane shown"
+ );
+ Assert.equal(firstTab.browser, firstMessagePane);
+ Assert.equal(firstTab.linkedBrowser, firstMessagePane);
+
+ Assert.equal(firstChromeBrowser.contentWindow.tabOrWindow, firstTab);
+ Assert.equal(firstMessageBrowser.contentWindow.tabOrWindow, firstTab);
+
+ // Select multiple messages.
+
+ let firstMultiMessageBrowser =
+ firstChromeBrowser.contentDocument.getElementById("multiMessageBrowser");
+ let firstWebBrowser =
+ firstChromeBrowser.contentDocument.getElementById("webBrowser");
+
+ threadTree.selectedIndices = [1, 2];
+ Assert.ok(BrowserTestUtils.is_hidden(firstWebBrowser));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser));
+ Assert.ok(BrowserTestUtils.is_visible(firstMultiMessageBrowser));
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with multiple messages selected"
+ );
+ Assert.equal(firstTab.browser, null);
+ Assert.equal(firstTab.linkedBrowser, null);
+
+ // Load a web page.
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ firstWebBrowser,
+ false,
+ "http://mochi.test:8888/"
+ );
+ messagePane.displayWebPage("http://mochi.test:8888/");
+ await loadedPromise;
+ Assert.ok(BrowserTestUtils.is_visible(firstWebBrowser));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMultiMessageBrowser));
+ Assert.equal(firstWebBrowser.currentURI.spec, "http://mochi.test:8888/");
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with a web page loaded"
+ );
+ Assert.equal(firstTab.browser, firstWebBrowser);
+ Assert.equal(firstTab.linkedBrowser, firstWebBrowser);
+
+ // Go back to a single selection.
+
+ threadTree.selectedIndex = 0;
+ Assert.ok(BrowserTestUtils.is_hidden(firstWebBrowser));
+ Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMultiMessageBrowser));
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ firstMessageBrowser.contentWindow,
+ "currentAboutMessage should have a value with a single message selected"
+ );
+ Assert.equal(firstTab.browser, firstMessagePane);
+ Assert.equal(firstTab.linkedBrowser, firstMessagePane);
+
+ // Open some more tabs. These should open in the background.
+
+ window.MsgOpenNewTabForFolders([folderB], {
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ });
+
+ for (let message of messagesB) {
+ window.OpenMessageInNewTab(message, {});
+ }
+
+ Assert.equal(tabmail.tabInfo.length, 4);
+ Assert.equal(tabmail.currentTabInfo, firstTab);
+ Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow);
+ Assert.equal(tabmail.currentAboutMessage, firstMessageBrowser.contentWindow);
+
+ // Check the second tab.
+
+ tabmail.switchToTab(1);
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[1]);
+
+ let secondTab = tabmail.currentTabInfo;
+ Assert.equal(secondTab.mode.name, "mail3PaneTab");
+ Assert.equal(secondTab.mode.tabType.name, "mailTab");
+
+ let secondChromeBrowser = secondTab.chromeBrowser;
+ await ensureBrowserLoaded(secondChromeBrowser);
+ Assert.equal(secondChromeBrowser.currentURI.spec, "about:3pane");
+ Assert.equal(tabmail.currentAbout3Pane, secondChromeBrowser.contentWindow);
+
+ let secondMessageBrowser =
+ secondChromeBrowser.contentDocument.getElementById("messageBrowser");
+ await ensureBrowserLoaded(secondMessageBrowser);
+ Assert.equal(secondMessageBrowser.currentURI.spec, "about:message");
+
+ let secondMessagePane =
+ secondMessageBrowser.contentDocument.getElementById("messagepane");
+ Assert.equal(secondMessagePane.currentURI.spec, "about:blank");
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with no message selected"
+ );
+ Assert.equal(secondTab.browser, null);
+ Assert.equal(secondTab.linkedBrowser, null);
+
+ Assert.equal(secondTab.folder, folderB);
+
+ secondChromeBrowser.contentWindow.threadTree.selectedIndex = 0;
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ secondMessageBrowser.contentWindow,
+ "currentAboutMessage should have a value with a message selected"
+ );
+
+ Assert.equal(secondChromeBrowser.contentWindow.tabOrWindow, secondTab);
+ Assert.equal(secondMessageBrowser.contentWindow.tabOrWindow, secondTab);
+
+ // Check the third tab.
+
+ tabmail.switchToTab(2);
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[2]);
+
+ let thirdTab = tabmail.currentTabInfo;
+ Assert.equal(thirdTab.mode.name, "mailMessageTab");
+ Assert.equal(thirdTab.mode.tabType.name, "mailTab");
+
+ let thirdChromeBrowser = thirdTab.chromeBrowser;
+ await ensureBrowserLoaded(thirdChromeBrowser);
+ Assert.equal(thirdChromeBrowser.currentURI.spec, "about:message");
+ Assert.equal(tabmail.currentAbout3Pane, null);
+ Assert.equal(tabmail.currentAboutMessage, thirdChromeBrowser.contentWindow);
+
+ let thirdMessagePane =
+ thirdChromeBrowser.contentDocument.getElementById("messagepane");
+ Assert.equal(thirdMessagePane.currentURI.spec, messageToURL(messagesB[0]));
+ Assert.equal(thirdTab.browser, thirdMessagePane);
+ Assert.equal(thirdTab.linkedBrowser, thirdMessagePane);
+
+ Assert.equal(thirdTab.folder, folderB);
+ Assert.equal(thirdTab.message, messagesB[0]);
+
+ Assert.equal(thirdChromeBrowser.contentWindow.tabOrWindow, thirdTab);
+
+ // Check the fourth tab.
+
+ tabmail.switchToTab(3);
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[3]);
+
+ let fourthTab = tabmail.currentTabInfo;
+ Assert.equal(fourthTab.mode.name, "mailMessageTab");
+ Assert.equal(fourthTab.mode.tabType.name, "mailTab");
+
+ let fourthChromeBrowser = fourthTab.chromeBrowser;
+ await ensureBrowserLoaded(fourthChromeBrowser);
+ Assert.equal(fourthChromeBrowser.currentURI.spec, "about:message");
+ Assert.equal(tabmail.currentAbout3Pane, null);
+ Assert.equal(tabmail.currentAboutMessage, fourthChromeBrowser.contentWindow);
+
+ let fourthMessagePane =
+ fourthChromeBrowser.contentDocument.getElementById("messagepane");
+ Assert.equal(fourthMessagePane.currentURI.spec, messageToURL(messagesB[1]));
+ Assert.equal(fourthTab.browser, fourthMessagePane);
+ Assert.equal(fourthTab.linkedBrowser, fourthMessagePane);
+
+ Assert.equal(fourthTab.folder, folderB);
+ Assert.equal(fourthTab.message, messagesB[1]);
+
+ Assert.equal(fourthChromeBrowser.contentWindow.tabOrWindow, fourthTab);
+
+ // Close tabs.
+
+ tabmail.closeTab(3);
+ Assert.equal(tabmail.currentTabInfo, thirdTab);
+ Assert.equal(tabmail.currentAbout3Pane, null);
+ Assert.equal(tabmail.currentAboutMessage, thirdChromeBrowser.contentWindow);
+
+ tabmail.closeTab(2);
+ Assert.equal(tabmail.currentTabInfo, secondTab);
+ Assert.equal(tabmail.currentAbout3Pane, secondChromeBrowser.contentWindow);
+ Assert.equal(tabmail.currentAboutMessage, secondMessageBrowser.contentWindow);
+
+ tabmail.closeTab(1);
+ Assert.equal(tabmail.currentTabInfo, firstTab);
+ Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow);
+ Assert.equal(tabmail.currentAboutMessage, firstMessageBrowser.contentWindow);
+});
+
+add_task(async function testMessageWindow() {
+ let messageWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ async win =>
+ win.document.documentURI ==
+ "chrome://messenger/content/messageWindow.xhtml"
+ );
+ MailUtils.openMessageInNewWindow(messagesB[0]);
+
+ let messageWindow = await messageWindowPromise;
+ let messageBrowser = messageWindow.messageBrowser;
+ await ensureBrowserLoaded(messageBrowser);
+ Assert.equal(messageBrowser.contentWindow.tabOrWindow, messageWindow);
+
+ await BrowserTestUtils.closeWindow(messageWindow);
+});
+
+async function ensureBrowserLoaded(browser) {
+ await TestUtils.waitForCondition(
+ () =>
+ browser.currentURI.spec != "about:blank" &&
+ browser.contentDocument.readyState == "complete",
+ "waiting for browser to finish loading"
+ );
+}
+
+function messageToURL(message) {
+ let messageService = MailServices.messageServiceFromURI("mailbox-message://");
+ let uri = message.folder.getUriForMsg(message);
+ return messageService.getUrlForUri(uri).spec;
+}
diff --git a/comm/mail/base/test/browser/browser_markAsRead.js b/comm/mail/base/test/browser/browser_markAsRead.js
new file mode 100644
index 0000000000..62b19d5b4e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_markAsRead.js
@@ -0,0 +1,204 @@
+/* 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/. */
+
+/**
+ * Tests that a message does not get marked as read if it is opened in a
+ * background tab.
+ */
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let localTestFolder;
+
+add_setup(async function () {
+ // We need to get messages directly from the server when displaying them,
+ // or this test isn't really testing what it should.
+ Services.prefs.setBoolPref("mail.server.default.offline_download", false);
+
+ const generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ const rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+ localTestFolder = rootFolder
+ .createLocalSubfolder("markAsRead")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ localTestFolder.addMessageBatch(
+ generator.makeMessages({}).map(message => message.toMboxString())
+ );
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("mail.server.default.offline_download");
+ Services.prefs.clearUserPref("mailnews.mark_message_read.auto");
+ Services.prefs.clearUserPref("mailnews.mark_message_read.delay");
+ Services.prefs.clearUserPref("mailnews.mark_message_read.delay.interval");
+ });
+});
+
+add_task(async function testLocal() {
+ await subtest(localTestFolder);
+});
+
+async function subtest(testFolder) {
+ const tabmail = document.getElementById("tabmail");
+ const firstAbout3Pane = tabmail.currentAbout3Pane;
+ firstAbout3Pane.displayFolder(testFolder);
+ const testMessages = testFolder.messages;
+
+ // Open a message in the first tab. It should get marked as read immediately.
+
+ let message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 0 should not be read before load");
+ firstAbout3Pane.threadTree.selectedIndex =
+ firstAbout3Pane.gDBView.findIndexOfMsgHdr(message, false);
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ await TestUtils.waitForCondition(
+ () => message.isRead,
+ "waiting for message 0 to be marked as read"
+ );
+
+ firstAbout3Pane.threadTree.selectedIndex = -1; // Unload the message.
+
+ // Open a message in a background tab. It should not get marked as read.
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 1 should not be read before load");
+ window.OpenMessageInNewTab(message, { background: true });
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !message.isRead,
+ "message 1 should not be read after opening in a background tab"
+ );
+
+ // Switch to the tab. The message should get marked as read immediately.
+
+ tabmail.switchToTab(1);
+ await TestUtils.waitForTick();
+ Assert.ok(
+ message.isRead,
+ "message 1 should be read after switching to the background tab"
+ );
+ tabmail.closeTab(1);
+
+ // With the marking delayed by preferences, open a message in a background tab.
+ // It should not get marked as read.
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.delay", true);
+ Services.prefs.setIntPref("mailnews.mark_message_read.delay.interval", 2);
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 2 should not be read before load");
+ window.OpenMessageInNewTab(message, { background: true });
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 3000));
+ Assert.ok(
+ !message.isRead,
+ "message 2 should not be read after opening in a background tab"
+ );
+
+ // Switch to the tab. The message should get marked as read after the delay.
+
+ const timeBeforeSwitchingTab = Date.now();
+ tabmail.switchToTab(1);
+ Assert.ok(
+ !message.isRead,
+ "message 2 should not be read immediately after switching to the background tab"
+ );
+ await TestUtils.waitForCondition(
+ () => message.isRead,
+ "waiting for message 2 to be marked as read"
+ );
+ Assert.greaterOrEqual(
+ Date.now() - timeBeforeSwitchingTab,
+ 2000,
+ "message 2 should be read after switching to the background tab and the 2s delay"
+ );
+ tabmail.closeTab(1);
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.delay", false);
+
+ // With the marking disabled by preferences, open a message in a background
+ // tab. It should not get marked as read.
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 3 should not be read before load");
+ window.OpenMessageInNewTab(message, { background: true });
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !message.isRead,
+ "message 3 should not be read after opening in a background tab"
+ );
+
+ // Switch to the tab. The message should not get marked as read.
+
+ tabmail.switchToTab(1);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !message.isRead,
+ "message 3 should not be read after switching to the background tab"
+ );
+ tabmail.closeTab(1);
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", true);
+
+ // Open a new 3-pane tab in the background and load a message in it. The
+ // message should not get marked as read.
+
+ window.MsgOpenNewTabForFolders([testFolder], {
+ background: true,
+ messagePaneVisible: true,
+ });
+ const secondAbout3Pane = tabmail.tabInfo[1].chromeBrowser.contentWindow;
+ await TestUtils.waitForCondition(
+ () => secondAbout3Pane.gDBView,
+ "waiting for view to load"
+ );
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 4 should not be read before load");
+ secondAbout3Pane.threadTree.selectedIndex =
+ secondAbout3Pane.gDBView.findIndexOfMsgHdr(message, false);
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !message.isRead,
+ "message 4 should not be read after opening in a background tab"
+ );
+
+ tabmail.switchToTab(1);
+ await TestUtils.waitForTick();
+ Assert.ok(
+ message.isRead,
+ "message 4 should be read after switching to the background tab"
+ );
+ tabmail.closeTab(1);
+
+ // Open a message in a new foreground tab. It should get marked as read
+ // immediately.
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 5 should not be read before load");
+ window.OpenMessageInNewTab(message, { background: false });
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ Assert.ok(
+ message.isRead,
+ "message 5 should be read after opening the foreground tab"
+ );
+ tabmail.closeTab(1);
+}
diff --git a/comm/mail/base/test/browser/browser_menulist.js b/comm/mail/base/test/browser/browser_menulist.js
new file mode 100644
index 0000000000..39a6a840d2
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_menulist.js
@@ -0,0 +1,183 @@
+/* import-globals-from ../../content/utilityOverlay.js */
+
+add_task(async () => {
+ let TEST_DOCUMENT_URL = getRootDirectory(gTestPath) + "files/menulist.xhtml";
+ let testDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL == TEST_DOCUMENT_URL) {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ resolve(subject);
+ }
+ }, "chrome-document-loaded");
+ openContentTab(TEST_DOCUMENT_URL);
+ });
+ ok(testDocument.URL == TEST_DOCUMENT_URL);
+ let testWindow = testDocument.ownerGlobal;
+ let MENULIST_CLASS = testWindow.customElements.get("menulist");
+ let MENULIST_EDITABLE_CLASS =
+ testWindow.customElements.get("menulist-editable");
+
+ let menulists = testDocument.querySelectorAll("menulist");
+ is(menulists.length, 3);
+
+ // Menulist 0 is an ordinary, non-editable menulist.
+ ok(menulists[0] instanceof MENULIST_CLASS);
+ ok(!(menulists[0] instanceof MENULIST_EDITABLE_CLASS));
+ ok(!("editable" in menulists[0]));
+
+ // Menulist 1 is an editable menulist, but not in editing mode.
+ ok(menulists[1] instanceof MENULIST_CLASS);
+ ok(menulists[1] instanceof MENULIST_EDITABLE_CLASS);
+ ok("editable" in menulists[1]);
+ ok(!menulists[1].editable);
+
+ // Menulist 2 is an editable menulist, in editing mode.
+ ok(menulists[2] instanceof MENULIST_CLASS);
+ ok(menulists[2] instanceof MENULIST_EDITABLE_CLASS);
+ ok("editable" in menulists[2]);
+ ok(menulists[2].editable);
+
+ // Okay, let's check the focus order.
+ let testBrowser = document.getElementById("tabmail").currentTabInfo.browser;
+ EventUtils.synthesizeMouseAtCenter(testBrowser, { clickCount: 1 });
+ await new Promise(resolve => setTimeout(resolve));
+
+ let beforeButton = testDocument.querySelector("button#before");
+ beforeButton.focus();
+ is(testDocument.activeElement, beforeButton);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, menulists[0]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, menulists[1]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, menulists[2]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, testDocument.querySelector("button#after"));
+
+ // Now go back again.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, menulists[2]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, menulists[1]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, menulists[0]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, beforeButton, "focus back to the start");
+
+ let popup = menulists[2].menupopup;
+ // The dropmarker should open and close the popup.
+ let openEvent = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ menulists[2]._dropmarker,
+ { clickCount: 1 },
+ testWindow
+ );
+ await openEvent;
+ ok(menulists[2].hasAttribute("open"), "popup open");
+
+ let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ popup.hidePopup();
+ await hiddenEvent;
+ ok(!menulists[2].hasAttribute("open"), "closed again");
+
+ // Open the popup and choose an item.
+ EventUtils.synthesizeMouseAtCenter(
+ menulists[2]._dropmarker,
+ { clickCount: 1 },
+ testWindow
+ );
+ await BrowserTestUtils.waitForEvent(popup, "popupshown");
+ ok(menulists[2].hasAttribute("open"), "open for item click");
+
+ await new Promise(resolve => {
+ menulists[2].addEventListener("select", () => setTimeout(resolve), {
+ once: true,
+ });
+ popup.activateItem(menulists[2].querySelectorAll("menuitem")[0]);
+ });
+ ok(!menulists[2].hasAttribute("open"));
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+ is(menulists[2]._inputField.value, "foo");
+ is(menulists[2].value, "foo");
+ is(menulists[2].getAttribute("value"), "foo");
+
+ // Again.
+ EventUtils.synthesizeMouseAtCenter(
+ menulists[2]._dropmarker,
+ { clickCount: 1 },
+ testWindow
+ );
+ await BrowserTestUtils.waitForEvent(popup, "popupshown");
+ ok(menulists[2].hasAttribute("open"));
+
+ await new Promise(resolve => {
+ menulists[2].addEventListener("select", () => setTimeout(resolve), {
+ once: true,
+ });
+ popup.activateItem(menulists[2].querySelectorAll("menuitem")[1]);
+ });
+ ok(!menulists[2].hasAttribute("open"));
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+ is(menulists[2]._inputField.value, "bar");
+ is(menulists[2].value, "bar");
+ is(menulists[2].getAttribute("value"), "bar");
+
+ // Type in a value.
+ is(menulists[2]._inputField.selectionStart, 0);
+ is(menulists[2]._inputField.selectionEnd, 3);
+ EventUtils.sendString("quux", testWindow);
+ await new Promise(resolve => {
+ menulists[2].addEventListener(
+ "change",
+ event => {
+ is(event.target, menulists[2]);
+ resolve();
+ },
+ { once: true }
+ );
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ });
+ is(menulists[2].value, "quux");
+ is(menulists[2].getAttribute("value"), "quux");
+
+ // Open the popup and choose an item.
+ EventUtils.synthesizeMouseAtCenter(
+ menulists[2]._dropmarker,
+ { clickCount: 1 },
+ testWindow
+ );
+ await BrowserTestUtils.waitForEvent(popup, "popupshown");
+ ok(menulists[2].hasAttribute("open"));
+
+ await new Promise(resolve => {
+ menulists[2].addEventListener("select", () => setTimeout(resolve), {
+ once: true,
+ });
+ popup.activateItem(menulists[2].querySelectorAll("menuitem")[0]);
+ });
+ ok(!menulists[2].hasAttribute("open"));
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+ is(menulists[2]._inputField.value, "foo");
+ is(menulists[2].value, "foo");
+ is(menulists[2].getAttribute("value"), "foo");
+
+ document.getElementById("tabmail").closeOtherTabs(0);
+});
diff --git a/comm/mail/base/test/browser/browser_messageMenu.js b/comm/mail/base/test/browser/browser_messageMenu.js
new file mode 100644
index 0000000000..7b397f922e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_messageMenu.js
@@ -0,0 +1,355 @@
+/* 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 { GlodaIndexer } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaIndexer.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const nothingSelected = ["rootFolder", "noSelection", "contentTab"];
+const nothingOrMultiSelected = [...nothingSelected, "multiSelection"];
+
+/** @type MenuData */
+const messageMenuData = {
+ newMsgCmd: {},
+ replyMainMenu: { disabled: nothingSelected },
+ replyNewsgroupMainMenu: { hidden: true },
+ replySenderMainMenu: { hidden: true },
+ menu_replyToAll: { disabled: nothingSelected },
+ menu_replyToList: { disabled: true },
+ menu_forwardMsg: { disabled: nothingSelected },
+ forwardAsMenu: { disabled: nothingSelected },
+ menu_forwardAsInline: { disabled: nothingSelected },
+ menu_forwardAsAttachment: { disabled: nothingSelected },
+ menu_redirectMsg: { disabled: nothingSelected },
+ menu_editMsgAsNew: { disabled: nothingSelected },
+ menu_editDraftMsg: { hidden: true },
+ menu_newMsgFromTemplate: { hidden: true },
+ menu_editTemplate: { hidden: true },
+ openMessageWindowMenuitem: {
+ disabled: [...nothingSelected, "message", "externalMessage"],
+ },
+ openConversationMenuitem: {
+ disabled: [...nothingOrMultiSelected, "externalMessage"],
+ },
+ openFeedMessage: { hidden: true },
+ menu_openFeedWebPage: { disabled: nothingSelected },
+ menu_openFeedSummary: { disabled: nothingSelected },
+ menu_openFeedWebPageInMessagePane: {
+ disabled: nothingSelected,
+ },
+ msgAttachmentMenu: { disabled: true },
+ tagMenu: { disabled: [...nothingSelected, "externalMessage"] },
+ "tagMenu-addNewTag": { disabled: nothingSelected },
+ "tagMenu-manageTags": { disabled: nothingSelected },
+ "tagMenu-tagRemoveAll": { disabled: nothingSelected },
+ markMenu: { disabled: ["rootFolder", "externalMessage", "contentTab"] },
+ markReadMenuItem: { disabled: nothingSelected },
+ markUnreadMenuItem: { disabled: true },
+ menu_markThreadAsRead: { disabled: nothingSelected },
+ menu_markReadByDate: { disabled: nothingSelected },
+ menu_markAllRead: { disabled: ["rootFolder"] },
+ markFlaggedMenuItem: { disabled: nothingSelected },
+ menu_markAsJunk: { disabled: nothingSelected },
+ menu_markAsNotJunk: { disabled: nothingSelected },
+ menu_recalculateJunkScore: {
+ disabled: [...nothingSelected, "message"],
+ },
+ archiveMainMenu: { disabled: [...nothingSelected, "externalMessage"] },
+ menu_cancel: { hidden: true },
+ moveMenu: { disabled: [...nothingSelected, "externalMessage"] },
+ copyMenu: { disabled: nothingSelected },
+ moveToFolderAgain: { disabled: true },
+ createFilter: { disabled: [...nothingOrMultiSelected, "externalMessage"] },
+ killThread: { disabled: [...nothingSelected, "message", "externalMessage"] },
+ killSubthread: {
+ disabled: [...nothingSelected, "message", "externalMessage"],
+ },
+ watchThread: { disabled: [...nothingSelected, "externalMessage"] },
+};
+let helper = new MenuTestHelper("messageMenu", messageMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, testMessages;
+let draftsFolder, draftsMessages, templatesFolder, templatesMessages;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("messageMenu", null);
+ testFolder = rootFolder
+ .getChildNamed("messageMenu")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ const messages = [
+ ...generator.makeMessages({ count: 5 }),
+ ...generator.makeMessages({ count: 5, msgsPerThread: 5 }),
+ ];
+ testFolder.addMessageBatch(messages.map(message => message.toMboxString()));
+ testFolder.addMessage(
+ generator
+ .makeMessage({
+ attachments: [
+ {
+ body: "an attachment",
+ contentType: "text/plain",
+ filename: "attachment.txt",
+ },
+ ],
+ })
+ .toMboxString()
+ );
+ testFolder.addMessage(
+ "From - Mon Jan 01 00:00:00 2001\n" +
+ "To: Mailing List <list@example.com>\n" +
+ "Date: Mon, 01 Jan 2001 00:00:00 +0100\n" +
+ "List-Help: <https://list.example.com>\n" +
+ "List-Post: <mailto:list@example.com>\n" +
+ "List-Software: Mailing List Software\n" +
+ "List-Subscribe: <https://subscribe.example.com>\n" +
+ "Precedence: list\n" +
+ "Subject: Mailing List Test Mail\n" +
+ `Message-ID: <${Date.now()}@example.com>\n` +
+ "From: Mailing List <list@example.com>\n" +
+ "List-Unsubscribe: <https://unsubscribe.example.com>,\n" +
+ " <mailto:unsubscribe@example.com?subject=Unsubscribe Test>\n" +
+ "MIME-Version: 1.0\n" +
+ "Content-Type: text/plain; charset=UTF-8\n" +
+ "Content-Transfer-Encoding: quoted-printable\n" +
+ "\n" +
+ "Mailing List Message Body\n"
+ );
+ testMessages = [...testFolder.messages];
+
+ rootFolder.createSubfolder("messageMenuDrafts", null);
+ draftsFolder = rootFolder
+ .getChildNamed("messageMenuDrafts")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ draftsFolder.setFlag(Ci.nsMsgFolderFlags.Drafts);
+ draftsFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ draftsMessages = [...draftsFolder.messages];
+ rootFolder.createSubfolder("messageMenuTemplates", null);
+ templatesFolder = rootFolder
+ .getChildNamed("messageMenuTemplates")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ templatesFolder.setFlag(Ci.nsMsgFolderFlags.Templates);
+ templatesFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ templatesMessages = [...templatesFolder.messages];
+
+ window.OpenMessageInNewTab(testMessages[0], { background: true });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+
+ let messageFile = new FileUtils.File(
+ getTestFilePath("files/sampleContent.eml")
+ );
+ let messageURI =
+ Services.io.newFileURI(messageFile).spec +
+ "?type=application/x-message-display";
+ tabmail.openTab("mailMessageTab", { background: true, messageURI });
+
+ window.openTab("contentTab", {
+ url: "https://example.com/",
+ background: true,
+ });
+
+ await TestUtils.waitForCondition(
+ () => !GlodaIndexer.indexing,
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("mailnews.mark_message_read.auto");
+ });
+});
+
+add_task(async function testRootFolder() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: rootFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+ await helper.testAllItems("rootFolder");
+});
+
+add_task(async function testNoSelection() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: testFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+ await helper.testAllItems("noSelection");
+});
+
+add_task(async function testSingleSelection() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: testFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+
+ // This message is not marked as read.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 1;
+ await helper.testAllItems("singleSelection");
+
+ // Mark it as read.
+ testMessages[1].markRead(true);
+ await helper.testItems({
+ markMenu: {},
+ markReadMenuItem: { disabled: true },
+ markUnreadMenuItem: {},
+ menu_markThreadAsRead: { disabled: true },
+ });
+
+ // Mark it as starred.
+ testMessages[1].markFlagged(true);
+ await helper.testItems({
+ markMenu: {},
+ markFlaggedMenuItem: { checked: true },
+ });
+
+ testFolder.addKeywordsToMessages([testMessages[1]], "$label1");
+ await helper.testItems({
+ tagMenu: {},
+ "tagMenu-tagRemoveAll": {},
+ });
+
+ // This message has an attachment.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 6;
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentAboutMessage.getMessagePaneBrowser()
+ );
+
+ await helper.testItems({
+ msgAttachmentMenu: {},
+ "menu-openAllAttachments": {},
+ "menu-saveAllAttachments": {},
+ "menu-detachAllAttachments": {},
+ "menu-deleteAllAttachments": {},
+ });
+
+ // This message is from a mailing list.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 7;
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentAboutMessage.getMessagePaneBrowser()
+ );
+ await helper.testItems({
+ menu_replyToList: { disabled: false },
+ });
+
+ // FIXME: Select another message and wait for it load in order to properly
+ // clear about:message.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 1;
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentAboutMessage.getMessagePaneBrowser()
+ );
+});
+
+add_task(async function testMultiSelection() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: testFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+
+ // These messages aren't marked as read or flagged, or have a tag.
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [2, 4];
+ await helper.testAllItems("multiSelection");
+
+ // ONE of these messages IS marked as read and flagged, and it has a tag.
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4];
+ await helper.testItems({
+ markMenu: {},
+ markReadMenuItem: {},
+ markUnreadMenuItem: {},
+ menu_markThreadAsRead: { disabled: false },
+ markFlaggedMenuItem: { checked: true },
+ tagMenu: {},
+ "tagMenu-tagRemoveAll": {},
+ });
+
+ // Messages in a collapsed thread.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 5;
+ await helper.testItems({
+ replyMainMenu: { disabled: true },
+ menu_replyToAll: { disabled: true },
+ menu_redirectMsg: { disabled: true },
+ menu_editMsgAsNew: { disabled: true },
+ });
+});
+
+add_task(async function testDraftsFolder() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: draftsFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4];
+ await helper.testItems({
+ menu_editDraftMsg: { hidden: false },
+ });
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [3];
+ await helper.testItems({
+ menu_editDraftMsg: { hidden: false },
+ });
+});
+
+add_task(async function testTemplatesFolder() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: templatesFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4];
+ await helper.testItems({
+ menu_newMsgFromTemplate: { hidden: false },
+ menu_editTemplate: { hidden: false },
+ });
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [3];
+ await helper.testItems({
+ menu_newMsgFromTemplate: { hidden: false },
+ menu_editTemplate: { hidden: false },
+ });
+});
+
+add_task(async function testMessageTab() {
+ tabmail.switchToTab(1);
+ await helper.testAllItems("message");
+});
+
+add_task(async function testExternalMessageTab() {
+ tabmail.switchToTab(2);
+ await helper.testAllItems("externalMessage");
+});
+
+add_task(async function testContentTab() {
+ tabmail.switchToTab(3);
+ await helper.testAllItems("contentTab");
+});
diff --git a/comm/mail/base/test/browser/browser_navigation.js b/comm/mail/base/test/browser/browser_navigation.js
new file mode 100644
index 0000000000..baa7bc6142
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_navigation.js
@@ -0,0 +1,1035 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let { messageBrowser, multiMessageBrowser, threadTree } = about3Pane;
+let mailboxService = MailServices.messageServiceFromURI("mailbox:");
+let folderA,
+ folderAMessages,
+ folderB,
+ folderBMessages,
+ folderC,
+ folderCMessages,
+ folderD,
+ folderDMessages;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("Navigation A", null);
+ folderA = rootFolder
+ .getChildNamed("Navigation A")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderA.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ folderAMessages = [...folderA.messages];
+ folderA.markAllMessagesRead(null);
+
+ rootFolder.createSubfolder("Navigation B", null);
+ folderB = rootFolder
+ .getChildNamed("Navigation B")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderB.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ folderBMessages = [...folderB.messages];
+ folderB.markAllMessagesRead(null);
+
+ rootFolder.createSubfolder("Navigation C", null);
+ folderC = rootFolder
+ .getChildNamed("Navigation C")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ // Add a lot of messages so scrolling can be tested.
+ folderC.addMessageBatch(
+ generator
+ .makeMessages({ count: 500 })
+ .map(message => message.toMboxString())
+ );
+ folderC.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ folderCMessages = [...folderC.messages];
+ folderC.markAllMessagesRead(null);
+
+ rootFolder.createSubfolder("Navigation D", null);
+ folderD = rootFolder
+ .getChildNamed("Navigation D")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderD.addMessageBatch(
+ generator
+ .makeMessages({
+ count: 12,
+ msgsPerThread: 3,
+ })
+ .map(message => message.toMboxString())
+ );
+ folderDMessages = [...folderD.messages];
+ folderD.markAllMessagesRead(null);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("mailnews.mark_message_read.auto");
+ });
+});
+
+/** Tests the next message/previous message commands. */
+add_task(async function testNextPreviousMessageInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ about3Pane.displayFolder(folderA.URI);
+ assertSelectedMessage();
+ await assertNoDisplayedMessage(aboutMessage);
+
+ for (let i = 0; i < 5; i++) {
+ goDoCommand("cmd_nextMsg");
+ assertSelectedMessage(folderAMessages[i]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[i]);
+ }
+
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ goDoCommand("cmd_nextMsg");
+ assertSelectedMessage(
+ folderAMessages[4],
+ "the selected message should not change"
+ );
+ await assertDisplayedMessage(aboutMessage, folderAMessages[4]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ for (let i = 3; i >= 0; i--) {
+ goDoCommand("cmd_previousMsg");
+ assertSelectedMessage(folderAMessages[i]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[i]);
+ }
+
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ goDoCommand("cmd_previousMsg");
+ assertSelectedMessage(
+ folderAMessages[0],
+ "the selected message should not change"
+ );
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+});
+
+async function subtestNextPreviousMessage(win, aboutMessage) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ await assertDisplayedMessage(aboutMessage, folderAMessages[2]);
+
+ for (let i = 3; i < 5; i++) {
+ win.goDoCommand("cmd_nextMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[i]);
+ }
+
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ win.goDoCommand("cmd_nextMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[4]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ for (let i = 3; i >= 0; i--) {
+ win.goDoCommand("cmd_previousMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[i]);
+ }
+
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ win.goDoCommand("cmd_previousMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+}
+
+/** Tests the next message/previous message commands in a message tab. */
+add_task(async function testNextPreviousMessageInATab() {
+ await withMessageInATab(folderAMessages[2], subtestNextPreviousMessage);
+});
+
+/** Tests the next message/previous message commands in a message window. */
+add_task(async function testNextPreviousMessageInAWindow() {
+ await withMessageInAWindow(folderAMessages[2], subtestNextPreviousMessage);
+});
+
+/** Tests the next unread message command. */
+add_task(async function testNextUnreadMessageInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false);
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+ folderD.markMessagesRead(
+ [
+ folderDMessages[3],
+ folderDMessages[4],
+ folderDMessages[6],
+ folderDMessages[7],
+ ],
+ false
+ );
+
+ about3Pane.displayFolder(folderA.URI);
+ threadTree.selectedIndex = -1;
+ assertSelectedMessage();
+ await assertNoDisplayedMessage(aboutMessage);
+
+ // Select the first unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderAMessages[3]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Loops to start of folder.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Mark the message as read.
+ goDoCommand("cmd_markAsRead");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderAMessages[3]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Changes to the next folder.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+ assertSelectedFolder(folderC);
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderCMessages[501]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderCMessages[504]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ // Select the first message in folder D and make sure all threads are
+ // collapsed.
+ about3Pane.displayFolder(folderD.URI);
+ threadTree.selectedIndex = 0;
+ let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_collapseAllThreads");
+ await selectPromise;
+ assertSelectedMessage(folderDMessages[0]);
+
+ // Go to the next thread without expanding it.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ assertSelectedMessage(folderDMessages[3]);
+
+ // The next displayed message should be the root message of the now expanded
+ // thread.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderDMessages[3]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[3]);
+
+ // Select the next unread message in the thread.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderDMessages[4]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[4]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderDMessages[6]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[6]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderDMessages[7]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[7]);
+
+ // Mark folder D read again.
+ folderD.markAllMessagesRead(null);
+
+ // Go back to the first folder. The previous selection should be restored.
+ about3Pane.displayFolder(folderA.URI);
+ assertSelectedMessage(folderAMessages[3]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Changes to the next folder.
+ // The previous selection should NOT be restored.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+ assertSelectedFolder(folderC);
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ folderC.markAllMessagesRead(null);
+ // No more unread messages, prompt to move to the next folder.
+ // Cancel the prompt.
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+ assertSelectedFolder(folderC);
+ assertSelectedMessage(folderCMessages[500]);
+
+ folderA.markAllMessagesRead(null);
+ // No unread messages anywhere, do nothing.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedFolder(folderC);
+ assertSelectedMessage(folderCMessages[500]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+});
+
+async function subtestNextUnreadMessage(win, aboutMessage) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false);
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+ Assert.equal(folderC.getNumUnread(false), 3);
+
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ // Select the first unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Select the next unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Loops to start of folder.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Mark the message as read.
+ win.goDoCommand("cmd_markAsRead");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Select the next unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Changes to the next folder.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ // Select the next unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ // Select the next unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ folderC.markAllMessagesRead(null);
+ // No more unread messages, prompt to move to the next folder.
+ // Cancel the prompt.
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+
+ folderA.markAllMessagesRead(null);
+ // No unread messages anywhere, do nothing.
+ win.goDoCommand("cmd_nextUnreadMsg");
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+}
+
+/** Tests the next unread message command in a message tab. */
+add_task(async function testNextUnreadMessageInATab() {
+ await withMessageInATab(folderAMessages[0], subtestNextUnreadMessage);
+});
+
+/** Tests the next unread message command in a message window. */
+add_task(async function testNextUnreadMessageInAWindow() {
+ await withMessageInAWindow(folderAMessages[0], subtestNextUnreadMessage);
+});
+
+/** Tests the previous unread message command. This doesn't cross folders. */
+add_task(async function testPreviousUnreadMessageInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false);
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+
+ about3Pane.displayFolder(folderC.URI);
+ threadTree.scrollToIndex(504, true);
+ // Ensure the scrolling from the previous line happens.
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ threadTree.selectedIndex = 504;
+ assertSelectedMessage(folderCMessages[504]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ goDoCommand("cmd_previousUnreadMsg");
+ assertSelectedMessage(folderCMessages[501]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ goDoCommand("cmd_previousUnreadMsg");
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ goDoCommand("cmd_previousUnreadMsg");
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+});
+
+async function subtestPreviousUnreadMessage(win, aboutMessage) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false);
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ win.goDoCommand("cmd_previousUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ win.goDoCommand("cmd_previousUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ win.goDoCommand("cmd_previousUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+}
+
+/** Tests the previous unread message command in a message tab. */
+add_task(async function testPreviousUnreadMessageInATab() {
+ await withMessageInATab(folderCMessages[504], subtestPreviousUnreadMessage);
+});
+
+/** Tests the previous unread message command in a message window. */
+add_task(async function testPreviousUnreadMessageInAWindow() {
+ await withMessageInAWindow(
+ folderCMessages[504],
+ subtestPreviousUnreadMessage
+ );
+});
+
+/**
+ * Tests the next unread thread command. This command depends on marking the
+ * thread as read, despite mailnews.mark_message_read.auto being false in this
+ * test. Seems wrong, but it does make this test less complicated!
+ */
+add_task(async function testNextUnreadThreadInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+ folderD.markMessagesRead(
+ [
+ folderDMessages[0],
+ folderDMessages[1],
+ folderDMessages[2],
+ folderDMessages[8],
+ folderDMessages[9],
+ folderDMessages[10],
+ folderDMessages[11],
+ ],
+ false
+ );
+
+ // In folder C, there are no threads. Going to the next unread thread is the
+ // same as going to the next unread message. But as stated above, it does
+ // mark the current message as read.
+ about3Pane.displayFolder(folderC.URI);
+ threadTree.scrollToIndex(504, true);
+ // Ensure the scrolling from the previous line happens.
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ threadTree.selectedIndex = 500;
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ goDoCommand("cmd_nextUnreadThread");
+ assertSelectedMessage(folderCMessages[501]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ goDoCommand("cmd_nextUnreadThread");
+ assertSelectedMessage(folderCMessages[504]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ // No more unread messages, we'll move to folder D.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ goDoCommand("cmd_nextUnreadThread");
+ await dialogPromise;
+ assertSelectedFolder(folderD);
+ assertSelectedMessage(folderDMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[0]);
+
+ goDoCommand("cmd_nextUnreadThread");
+ // The root message is read, we're looking at a single message in the thread.
+ assertSelectedMessage(folderDMessages[8]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[8]);
+
+ goDoCommand("cmd_nextUnreadThread");
+ // The root message is unread.
+ assertSelectedMessage(folderDMessages[9]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[9]);
+
+ // No more unread messages, prompt to move to the next folder.
+ // Cancel the prompt.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ goDoCommand("cmd_nextUnreadThread");
+ await dialogPromise;
+ assertSelectedMessage(folderDMessages[9]);
+
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+});
+
+async function subtestNextUnreadThread(win, aboutMessage) {
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+ folderD.markMessagesRead(
+ [
+ folderDMessages[0],
+ folderDMessages[1],
+ folderDMessages[2],
+ folderDMessages[8],
+ folderDMessages[9],
+ folderDMessages[10],
+ folderDMessages[11],
+ ],
+ false
+ );
+
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ win.goDoCommand("cmd_nextUnreadThread");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ win.goDoCommand("cmd_nextUnreadThread");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ // No more unread messages, we'll move to folder D.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ win.goDoCommand("cmd_nextUnreadThread");
+ await dialogPromise;
+ await assertDisplayedMessage(aboutMessage, folderDMessages[0]);
+
+ win.goDoCommand("cmd_nextUnreadThread");
+ // The root message is read, we're looking at a single message in the thread.
+ await assertDisplayedMessage(aboutMessage, folderDMessages[8]);
+
+ win.goDoCommand("cmd_nextUnreadThread");
+ // The root message is unread.
+ await assertDisplayedMessage(aboutMessage, folderDMessages[9]);
+
+ // No more unread messages, prompt to move to the next folder.
+ // Cancel the prompt.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ win.goDoCommand("cmd_nextUnreadThread");
+ await dialogPromise;
+}
+
+/** Tests the next unread thread command in a message tab. */
+add_task(async function testNextUnreadThreadInATab() {
+ await withMessageInATab(folderCMessages[500], subtestNextUnreadThread);
+});
+
+/** Tests the next unread thread command in a message window. */
+add_task(async function testNextUnreadThreadInAWindow() {
+ await withMessageInAWindow(folderCMessages[500], subtestNextUnreadThread);
+});
+
+/** Tests that navigation with a closed message pane does not load messages. */
+add_task(async function testHiddenMessagePaneInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ about3Pane.paneLayout.messagePaneVisible = false;
+ about3Pane.displayFolder(folderA.URI);
+ threadTree.selectedIndex = 0;
+ assertSelectedMessage(folderAMessages[0]);
+ await assertNoDisplayedMessage(aboutMessage);
+
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ goDoCommand("cmd_nextMsg");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertNoDisplayedMessage(aboutMessage);
+
+ goDoCommand("cmd_previousMsg");
+ assertSelectedMessage(folderAMessages[0]);
+ await assertNoDisplayedMessage(aboutMessage);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ threadTree.selectedIndex = -1;
+ about3Pane.paneLayout.messagePaneVisible = true;
+});
+
+/** Tests the go back/forward commands. */
+add_task(async function testMessageHistoryInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const { messageHistory } = aboutMessage;
+ messageHistory.clear();
+ about3Pane.displayFolder(folderA.URI);
+ threadTree.selectedIndex = 0;
+ assertSelectedMessage(folderAMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ goDoCommand("cmd_nextMsg");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Going back should be available");
+ Assert.ok(
+ !messageHistory.canPop(0),
+ "Should not be able to go back to the current message"
+ );
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should not have any message to go forward to"
+ );
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ goDoCommand("cmd_goBack");
+ assertSelectedMessage(folderAMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be disabled"
+ );
+
+ goDoCommand("cmd_goForward");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+
+ // Switching folder to test going back/forward between folders.
+ about3Pane.displayFolder(folderB.URI);
+ threadTree.selectedIndex = 0;
+ assertSelectedMessage(folderBMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderBMessages[0]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ goDoCommand("cmd_goBack");
+
+ assertSelectedFolder(folderA);
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+
+ goDoCommand("cmd_goForward");
+
+ assertSelectedFolder(folderB);
+ assertSelectedMessage(folderBMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderBMessages[0]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ goDoCommand("cmd_goBack");
+
+ assertSelectedFolder(folderA);
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+
+ // Select a different message while going forward is possible, clearing the
+ // previous forward history.
+
+ goDoCommand("cmd_nextMsg");
+
+ assertSelectedMessage(folderAMessages[2]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[2]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ goDoCommand("cmd_goBack");
+
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+
+ // Remove the previous message in the history from the folder it was
+ // displayed in.
+
+ let movedMessage = folderAMessages[0];
+ await moveMessage(folderA, movedMessage, folderB);
+
+ Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to");
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be disabled"
+ );
+
+ // Display no message, so going back goes to the previously displayed message,
+ // which is also the current history entry.
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+
+ Assert.ok(
+ messageHistory.canPop(0),
+ "Can go back to current history entry without selected message"
+ );
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be enabled"
+ );
+
+ goDoCommand("cmd_goBack");
+
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ threadTree.selectedIndex = -1;
+ let currentFolderBMessages = [...folderB.messages];
+ movedMessage = currentFolderBMessages.find(
+ message => !folderBMessages.includes(message)
+ );
+ await moveMessage(folderB, movedMessage, folderA);
+ folderAMessages = [...folderA.messages];
+});
+
+async function subtestMessageHistory(win, aboutMessage) {
+ const { messageHistory } = aboutMessage;
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ Assert.ok(win.getEnabledControllerForCommand("cmd_nextMsg"));
+ win.goDoCommand("cmd_nextMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Going back should be available");
+ Assert.ok(
+ !messageHistory.canPop(0),
+ "Should not be able to go back to the current message"
+ );
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should not have any message to go forward to"
+ );
+ Assert.ok(
+ !win.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ Assert.ok(win.getEnabledControllerForCommand("cmd_goBack"));
+ win.goDoCommand("cmd_goBack");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ !win.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be disabled"
+ );
+
+ Assert.ok(win.getEnabledControllerForCommand("cmd_goForward"));
+ win.goDoCommand("cmd_goForward");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ win.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+}
+
+/** Tests the go back/forward commands in a message tab. */
+add_task(async function testMessageHistoryInATab() {
+ await withMessageInATab(folderAMessages[0], subtestMessageHistory);
+});
+
+/** Tests the go back/forward commands in a message window. */
+add_task(async function testMessageHistoryInAWindow() {
+ await withMessageInAWindow(folderAMessages[0], subtestMessageHistory);
+});
+
+function assertSelectedFolder(expected) {
+ Assert.equal(about3Pane.gFolder.URI, expected.URI, "selected folder");
+}
+
+function assertSelectedMessage(expected, comment) {
+ if (expected) {
+ Assert.notEqual(
+ threadTree.selectedIndex,
+ -1,
+ "a message should be selected"
+ );
+ Assert.ok(
+ threadTree.getRowAtIndex(threadTree.selectedIndex),
+ "row for selected message should exist and be in view"
+ );
+ Assert.equal(
+ about3Pane.gDBView.getMsgHdrAt(threadTree.selectedIndex).messageId,
+ expected.messageId,
+ comment ?? "selected message"
+ );
+ } else {
+ Assert.equal(threadTree.selectedIndex, -1, "no message should be selected");
+ }
+}
+
+async function assertDisplayedMessage(aboutMessage, expected) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+ let mailboxURL = expected.folder.getUriForMsg(expected);
+ let messageURI = mailboxService.getUrlForUri(mailboxURL);
+
+ if (
+ messagePaneBrowser.webProgess?.isLoadingDocument ||
+ !messagePaneBrowser.currentURI.equals(messageURI)
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ messageURI.spec
+ );
+ }
+ Assert.equal(
+ aboutMessage.gMessage.messageId,
+ expected.messageId,
+ "correct message loaded"
+ );
+ Assert.equal(
+ messagePaneBrowser.currentURI.spec,
+ messageURI.spec,
+ "correct message displayed"
+ );
+}
+
+async function assertDisplayedThread(firstMessage) {
+ let items = multiMessageBrowser.contentDocument.querySelectorAll("li");
+ Assert.equal(
+ items[0].dataset.messageId,
+ firstMessage.messageId,
+ "correct thread displayed"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(multiMessageBrowser),
+ "multimessageview visible"
+ );
+}
+
+async function assertNoDisplayedMessage(aboutMessage) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+ if (
+ messagePaneBrowser.webProgess?.isLoadingDocument ||
+ messagePaneBrowser.currentURI.spec != "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ "about:blank"
+ );
+ }
+
+ Assert.equal(aboutMessage.gMessage, null, "no message loaded");
+ Assert.equal(
+ messagePaneBrowser.currentURI.spec,
+ "about:blank",
+ "no message displayed"
+ );
+ Assert.ok(BrowserTestUtils.is_hidden(messageBrowser), "about:message hidden");
+}
+
+function reportBadSelectEvent() {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "should not have fired a select event"
+ );
+}
+
+function reportBadLoad() {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "should not have reloaded the message"
+ );
+}
+
+function moveMessage(sourceFolder, message, targetFolder) {
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyMessages(
+ sourceFolder,
+ [message],
+ targetFolder,
+ true,
+ copyListener,
+ window.msgWindow,
+ true
+ );
+ return copyListener.promise;
+}
+
+async function withMessageInATab(message, subtest) {
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ window.OpenMessageInNewTab(message, { background: false });
+ await tabPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ await subtest(window, tabmail.currentAboutMessage);
+
+ tabmail.closeOtherTabs(0);
+}
+
+async function withMessageInAWindow(message, subtest) {
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.MsgOpenNewWindowForMessage(message);
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+
+ await subtest(win, win.messageBrowser.contentWindow);
+
+ await BrowserTestUtils.closeWindow(win);
+}
diff --git a/comm/mail/base/test/browser/browser_orderableTreeListbox.js b/comm/mail/base/test/browser/browser_orderableTreeListbox.js
new file mode 100644
index 0000000000..54634cbd88
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_orderableTreeListbox.js
@@ -0,0 +1,481 @@
+/* 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/. */
+
+/* eslint mozilla/no-arbitrary-setTimeout: off */
+
+let dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+let WAIT_TIME = 0;
+
+let tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+ Services.prefs.clearUserPref("ui.prefersReducedMotion");
+ Services.prefs.clearUserPref("mailnews.default_view_flags");
+});
+
+async function withMotion(subtest) {
+ Services.prefs.setIntPref("ui.prefersReducedMotion", 0);
+ WAIT_TIME = 300;
+ await TestUtils.waitForCondition(
+ () => !matchMedia("(prefers-reduced-motion)").matches
+ );
+ return subtest();
+}
+
+async function withoutMotion(subtest) {
+ Services.prefs.setIntPref("ui.prefersReducedMotion", 1);
+ WAIT_TIME = 0;
+ await TestUtils.waitForCondition(
+ () => matchMedia("(prefers-reduced-motion)").matches
+ );
+ await subtest();
+}
+
+let win, doc, list, dataTransfer;
+
+async function orderWithKeys(key) {
+ selectHandler.reset();
+ orderedHandler.reset();
+
+ list.addEventListener("select", selectHandler);
+ list.addEventListener("ordered", orderedHandler);
+ EventUtils.synthesizeKey(key, { altKey: true }, win);
+ await new Promise(resolve => win.setTimeout(resolve, WAIT_TIME));
+ list.removeEventListener("select", selectHandler);
+ list.removeEventListener("ordered", orderedHandler);
+
+ await checkNoTransformations();
+}
+
+async function startDrag(index) {
+ let listRect = list.getBoundingClientRect();
+ let clientY = listRect.top + index * 32 + 4;
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+ [, dataTransfer] = EventUtils.synthesizeDragOver(
+ list.rows[index],
+ list,
+ null,
+ null,
+ win,
+ win,
+ {
+ clientY,
+ _domDispatchOnly: true,
+ }
+ );
+
+ await new Promise(resolve => setTimeout(resolve, WAIT_TIME));
+}
+
+async function continueDrag(index) {
+ let listRect = list.getBoundingClientRect();
+ let destClientX = listRect.left + listRect.width / 2;
+ let destClientY = listRect.top + index * 32 + 4;
+ let destScreenX = win.mozInnerScreenX + destClientX;
+ let destScreenY = win.mozInnerScreenY + destClientY;
+
+ let result = EventUtils.sendDragEvent(
+ {
+ type: "dragover",
+ screenX: destScreenX,
+ screenY: destScreenY,
+ clientX: destClientX,
+ clientY: destClientY,
+ dataTransfer,
+ _domDispatchOnly: true,
+ },
+ list,
+ win
+ );
+
+ await new Promise(resolve => setTimeout(resolve, WAIT_TIME));
+ return result;
+}
+
+async function endDrag(index) {
+ let listRect = list.getBoundingClientRect();
+ let clientY = listRect.top + index * 32 + 4;
+
+ EventUtils.synthesizeDropAfterDragOver(false, dataTransfer, list, win, {
+ clientY,
+ _domDispatchOnly: true,
+ });
+ list.dispatchEvent(new CustomEvent("dragend", { bubbles: true }));
+ dragService.endDragSession(true);
+
+ await new Promise(resolve => setTimeout(resolve, WAIT_TIME));
+}
+
+function checkRowOrder(expectedOrder) {
+ expectedOrder = expectedOrder.split(" ").map(i => `row-${i}`);
+ Assert.equal(list.rowCount, expectedOrder.length, "rowCount is correct");
+ Assert.deepEqual(
+ list.rows.map(row => row.id),
+ expectedOrder,
+ "order in DOM is correct"
+ );
+
+ let apparentOrder = list.rows.sort(
+ (a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top
+ );
+ Assert.deepEqual(
+ apparentOrder.map(row => row.id),
+ expectedOrder,
+ "order on screen is correct"
+ );
+
+ if (orderedHandler.orderAtEvent) {
+ Assert.deepEqual(
+ orderedHandler.orderAtEvent,
+ expectedOrder,
+ "order at the last 'ordered' event was correct"
+ );
+ }
+}
+
+function checkYPositions(...expectedPositions) {
+ let offset = list.getBoundingClientRect().top;
+
+ for (let i = 0; i < 5; i++) {
+ let id = `row-${i + 1}`;
+ let row = doc.getElementById(id);
+ Assert.equal(
+ row.getBoundingClientRect().top - offset,
+ expectedPositions[i],
+ id
+ );
+ }
+}
+
+async function checkNoTransformations() {
+ for (let row of list.children) {
+ await TestUtils.waitForCondition(
+ () => win.getComputedStyle(row).transform == "none",
+ `${row.id} has no transforms`
+ );
+ Assert.equal(
+ row
+ .getAnimations()
+ .filter(animation => animation.transitionProperty != "opacity").length,
+ 0,
+ `${row.id} has no animations`
+ );
+ }
+}
+
+let selectHandler = {
+ seenEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ },
+};
+
+let orderedHandler = {
+ seenEvent: null,
+ orderAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.orderAtEvent = null;
+ },
+ handleEvent(event) {
+ if (this.seenEvent) {
+ throw new Error("we already have an 'ordered' event");
+ }
+ this.seenEvent = event;
+ this.orderAtEvent = list.rows.map(row => row.id);
+ },
+};
+
+/** Test Alt+Up and Alt+Down. */
+async function subtestKeyReorder() {
+ list.focus();
+ list.selectedIndex = 0;
+
+ // Move row 1 down the list to the bottom.
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 1 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ // Some additional checks to prove the right row is selected.
+
+ Assert.ok(!selectHandler.seenEvent);
+ Assert.equal(list.selectedIndex, 3, "correct index is selected");
+ Assert.equal(
+ list.querySelector(".selected").id,
+ "row-1",
+ "correct row is selected"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, win);
+ Assert.equal(
+ list.querySelector(".selected").id,
+ "row-2-2",
+ "key press moved to the correct row"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, win);
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2 1");
+
+ // Move row 1 back to the top.
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 1 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ // Move row 3 around. Row 3 has children, so we're checking they move with it.
+
+ list.selectedIndex = 4;
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 3 3-1 3-2 3-3 2 2-1 2-2 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("3 3-1 3-2 3-3 1 2 2-1 2-2 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 3 3-1 3-2 3-3 2 2-1 2-2 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 4 3 3-1 3-2 3-3 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 4 5 5-1 5-2 3 3-1 3-2 3-3");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 4 3 3-1 3-2 3-3 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+}
+
+/** Drag the first item to the end. */
+async function subtestDragReorder1() {
+ orderedHandler.reset();
+ list.addEventListener("ordered", orderedHandler);
+
+ checkYPositions(1, 33, 129, 257, 289);
+
+ await startDrag(0);
+ checkYPositions(1, 33, 129, 257, 289);
+
+ await continueDrag(2);
+ checkYPositions(52, 1, 129, 257, 289);
+ await continueDrag(3);
+ checkYPositions(84, 1, 129, 257, 289);
+ await continueDrag(4);
+ checkYPositions(116, 1, 129, 257, 289);
+ await continueDrag(5);
+ checkYPositions(148, 1, 129, 257, 289);
+ await continueDrag(6);
+ checkYPositions(180, 1, 97, 257, 289);
+ await continueDrag(7);
+ checkYPositions(212, 1, 97, 257, 289);
+ await continueDrag(8);
+ checkYPositions(244, 1, 97, 225, 289);
+ await continueDrag(9);
+ checkYPositions(276, 1, 97, 225, 289);
+ await continueDrag(10);
+ checkYPositions(308, 1, 97, 225, 257);
+ await continueDrag(11);
+ checkYPositions(340, 1, 97, 225, 257);
+ await continueDrag(12);
+ checkYPositions(353, 1, 97, 225, 257);
+
+ await endDrag(12);
+ list.removeEventListener("ordered", orderedHandler);
+
+ Assert.ok(orderedHandler.seenEvent);
+ checkYPositions(353, 1, 97, 225, 257);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2 1");
+ await checkNoTransformations();
+}
+
+/** Drag the (now) last item back to the start. */
+async function subtestDragReorder2() {
+ orderedHandler.reset();
+ list.addEventListener("ordered", orderedHandler);
+
+ await startDrag(11);
+ checkYPositions(340, 1, 97, 225, 257);
+
+ await continueDrag(9);
+ checkYPositions(276, 1, 97, 225, 289);
+
+ await continueDrag(7);
+ checkYPositions(212, 1, 97, 257, 289);
+
+ await continueDrag(4);
+ checkYPositions(116, 1, 129, 257, 289);
+
+ await continueDrag(1);
+ checkYPositions(20, 33, 129, 257, 289);
+
+ await endDrag(0);
+ list.removeEventListener("ordered", orderedHandler);
+
+ Assert.ok(orderedHandler.seenEvent);
+ checkYPositions(1, 33, 129, 257, 289);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+ await checkNoTransformations();
+}
+
+/**
+ * Listen for the 'ordering' event and prevent dropping on some rows.
+ *
+ * In this test, we'll prevent dragging an item below the last one - row-5 and
+ * its descendants. Other use cases may be possible but haven't been needed
+ * yet, so they are untested.
+ */
+async function subtestDragUndroppable() {
+ let originalGetter = list.__lookupGetter__("_orderableChildren");
+ list.__defineGetter__("_orderableChildren", function () {
+ let rows = [...this.children];
+ rows.pop();
+ return rows;
+ });
+
+ orderedHandler.reset();
+ list.addEventListener("ordered", orderedHandler);
+
+ checkYPositions(1, 33, 129, 257, 289);
+
+ await startDrag(0);
+ checkYPositions(1, 33, 129, 257, 289);
+
+ await continueDrag(8);
+ checkYPositions(244, 1, 97, 225, 289);
+ await continueDrag(9);
+ checkYPositions(257, 1, 97, 225, 289);
+ await continueDrag(10);
+ checkYPositions(257, 1, 97, 225, 289);
+ await continueDrag(11);
+ checkYPositions(257, 1, 97, 225, 289);
+ await continueDrag(12);
+ checkYPositions(257, 1, 97, 225, 289);
+
+ await endDrag(12);
+ list.removeEventListener("ordered", orderedHandler);
+
+ Assert.ok(orderedHandler.seenEvent);
+ checkYPositions(257, 1, 97, 225, 289);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2");
+ await checkNoTransformations();
+
+ // Move row-3 down with the keyboard.
+
+ list.selectedIndex = 7;
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2");
+
+ // It should not move further down.
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(!orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2");
+
+ // Reset the order.
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2");
+
+ orderedHandler.reset();
+ await startDrag(8);
+ await continueDrag(1);
+ await endDrag(1);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ list.__defineGetter__("_orderableChildren", originalGetter);
+}
+
+add_setup(async function () {
+ // Make sure the whole test runs with an unthreaded view in all folders.
+ Services.prefs.setIntPref("mailnews.default_view_flags", 0);
+
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml",
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+
+ win = tab.browser.contentWindow;
+ doc = win.document;
+
+ list = doc.querySelector(`ol[is="orderable-tree-listbox"]`);
+ Assert.ok(!!list, "the list exists");
+
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+ Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0");
+});
+
+add_task(async function testKeyReorder() {
+ await withMotion(subtestKeyReorder);
+});
+add_task(async function testDragReorder1() {
+ await withMotion(subtestDragReorder1);
+});
+add_task(async function testDragReorder2() {
+ await withMotion(subtestDragReorder2);
+});
+add_task(async function testDragUndroppable() {
+ await withMotion(subtestDragUndroppable);
+});
+
+add_task(async function testKeyReorderReducedMotion() {
+ await withoutMotion(subtestKeyReorder);
+});
+add_task(async function testDragReorder1ReducedMotion() {
+ await withoutMotion(subtestDragReorder1);
+});
+add_task(async function testDragReorder2ReducedMotion() {
+ await withoutMotion(subtestDragReorder2);
+});
+add_task(async function testDragUndroppableReducedMotion() {
+ await withoutMotion(subtestDragUndroppable);
+});
diff --git a/comm/mail/base/test/browser/browser_paneFocus.js b/comm/mail/base/test/browser/browser_paneFocus.js
new file mode 100644
index 0000000000..9b85b4afe3
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_paneFocus.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/. */
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let mailButton = document.getElementById("mailButton");
+let globalSearch = document.querySelector("#unifiedToolbar global-search-bar");
+let addressBookButton = document.getElementById("addressBookButton");
+let calendarButton = document.getElementById("calendarButton");
+let tasksButton = document.getElementById("tasksButton");
+let tabmail = document.getElementById("tabmail");
+
+let rootFolder, testFolder, testMessages, addressBook;
+
+add_setup(async function () {
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ // Quick Filter Bar needs to be toggled on for F6 focus shift to be accurate.
+ goDoCommand("cmd_showQuickFilterBar");
+
+ rootFolder.createSubfolder("paneFocus", null);
+ testFolder = rootFolder
+ .getChildNamed("paneFocus")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ let prefName = MailServices.ab.newAddressBook(
+ "paneFocus",
+ null,
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ addressBook = MailServices.ab.getDirectoryFromId(prefName);
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.displayName = "contact 1";
+ contact.firstName = "contact";
+ contact.lastName = "1";
+ contact.primaryEmail = "contact.1@invalid";
+ addressBook.addCard(contact);
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, false);
+ let removePromise = TestUtils.topicObserved("addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(addressBook.URI);
+ await removePromise;
+ });
+});
+
+add_task(async function testMail3PaneTab() {
+ document.body.focus();
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ });
+ let {
+ folderTree,
+ threadTree,
+ webBrowser,
+ messageBrowser,
+ multiMessageBrowser,
+ accountCentralBrowser,
+ } = about3Pane;
+
+ // Reset focus to accountCentralBrowser because QFB was toggled on.
+ accountCentralBrowser.focus();
+ info("Displaying the root folder");
+ about3Pane.displayFolder(rootFolder.URI);
+ cycle(
+ mailButton,
+ globalSearch,
+ folderTree,
+ accountCentralBrowser,
+ mailButton
+ );
+
+ info("Displaying the test folder");
+ about3Pane.displayFolder(testFolder.URI);
+ threadTree.selectedIndex = 0;
+ cycle(
+ globalSearch,
+ folderTree,
+ threadTree.table.body,
+ messageBrowser.contentWindow.getMessagePaneBrowser(),
+ mailButton,
+ globalSearch
+ );
+
+ info("Hiding the folder pane");
+ about3Pane.restoreState({ folderPaneVisible: false });
+ cycle(
+ threadTree.table.body,
+ messageBrowser.contentWindow.getMessagePaneBrowser(),
+ mailButton,
+ globalSearch,
+ threadTree.table.body
+ );
+
+ info("Showing the folder pane, hiding the message pane");
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: false,
+ });
+ cycle(
+ mailButton,
+ globalSearch,
+ folderTree,
+ threadTree.table.body,
+ mailButton
+ );
+
+ info("Showing the message pane, selecting multiple messages");
+ about3Pane.restoreState({ messagePaneVisible: true });
+ threadTree.selectedIndices = [1, 2];
+ cycle(
+ globalSearch,
+ folderTree,
+ threadTree.table.body,
+ multiMessageBrowser,
+ mailButton,
+ globalSearch
+ );
+
+ info("Showing a web page");
+ about3Pane.messagePane.displayWebPage("https://example.com/");
+ cycle(
+ folderTree,
+ threadTree.table.body,
+ webBrowser,
+ mailButton,
+ globalSearch,
+ folderTree
+ );
+
+ info("Testing focus from secondary focus targets");
+ about3Pane.document.getElementById("folderPaneMoreButton").focus();
+ EventUtils.synthesizeKey("KEY_F6", {}, about3Pane);
+ Assert.equal(
+ getActiveElement(),
+ folderTree,
+ "F6 moved the focus to the folder tree"
+ );
+
+ about3Pane.document.getElementById("folderPaneMoreButton").focus();
+ EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, about3Pane);
+ Assert.equal(
+ getActiveElement().id,
+ globalSearch.id,
+ "Shift+F6 moved the focus to the toolbar"
+ );
+
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ EventUtils.synthesizeKey("KEY_F6", {}, about3Pane);
+ Assert.equal(
+ getActiveElement(),
+ threadTree.table.body,
+ "F6 moved the focus to the threadTree"
+ );
+
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, about3Pane);
+ Assert.equal(
+ getActiveElement(),
+ folderTree,
+ "Shift+F6 moved the focus to the folder tree"
+ );
+});
+
+add_task(async function testMailMessageTab() {
+ document.body.focus();
+
+ window.OpenMessageInNewTab(testMessages[0], { background: false });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+ cycle(mailButton, globalSearch, tabmail.tabInfo[1].browser, mailButton);
+
+ tabmail.closeOtherTabs(0);
+});
+
+add_task(async function testAddressBookTab() {
+ EventUtils.synthesizeMouseAtCenter(addressBookButton, {});
+ await BrowserTestUtils.browserLoaded(tabmail.currentTabInfo.browser);
+
+ let abWindow = tabmail.currentTabInfo.browser.contentWindow;
+ let abDocument = abWindow.document;
+ let booksList = abDocument.getElementById("books");
+ let searchInput = abDocument.getElementById("searchInput");
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+
+ // Switch to the table view so the edit button isn't falling off the window.
+ abWindow.cardsPane.toggleLayout(true);
+
+ // Check what happens with a contact selected.
+ let row = booksList.getRowForUID(addressBook.UID);
+ EventUtils.synthesizeMouseAtCenter(row.querySelector("span"), {}, abWindow);
+
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ // NOTE: When the "cards" element first receives focus it will select the
+ // first item, which causes the panel to be displayed.
+ cycle(
+ searchInput,
+ cardsList.table.body,
+ editButton,
+ addressBookButton,
+ globalSearch,
+ booksList,
+ searchInput
+ );
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ // Check with no selection.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(0),
+ { accelKey: true },
+ abWindow
+ );
+ Assert.equal(getActiveElement(), cardsList.table.body);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ cycle(
+ addressBookButton,
+ globalSearch,
+ booksList,
+ searchInput,
+ cardsList.table.body,
+ addressBookButton
+ );
+ // Still hidden.
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // Check what happens while editing. It should be nothing.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ Assert.equal(getActiveElement(), cardsList.table.body);
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ editButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ Assert.equal(abDocument.activeElement.id, "vcard-n-firstname");
+ EventUtils.synthesizeKey("KEY_F6", {}, abWindow);
+ Assert.equal(
+ abDocument.activeElement.id,
+ "vcard-n-firstname",
+ "F6 did nothing"
+ );
+ EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, abWindow);
+ Assert.equal(
+ abDocument.activeElement.id,
+ "vcard-n-firstname",
+ "Shift+F6 did nothing"
+ );
+
+ tabmail.closeOtherTabs(0);
+});
+
+add_task(async function testCalendarTab() {
+ EventUtils.synthesizeMouseAtCenter(calendarButton, {});
+
+ cycle(calendarButton, globalSearch, calendarButton);
+
+ tabmail.closeOtherTabs(0);
+});
+
+add_task(async function testTasksTab() {
+ EventUtils.synthesizeMouseAtCenter(tasksButton, {});
+
+ cycle(tasksButton, globalSearch, tasksButton);
+
+ tabmail.closeOtherTabs(0);
+});
+
+add_task(async function testContentTab() {
+ document.body.focus();
+
+ window.openTab("contentTab", {
+ url: "https://example.com/",
+ background: false,
+ });
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentTabInfo.browser,
+ undefined,
+ "https://example.com/"
+ );
+ cycle(mailButton, globalSearch, tabmail.currentTabInfo.browser, mailButton);
+
+ document.body.focus();
+
+ window.openTab("contentTab", { url: "about:mozilla", background: false });
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentTabInfo.browser,
+ undefined,
+ "about:mozilla"
+ );
+ cycle(
+ globalSearch,
+ tabmail.currentTabInfo.browser.contentDocument.body,
+ mailButton,
+ globalSearch
+ );
+
+ tabmail.closeOtherTabs(0);
+});
+
+/**
+ * Gets the active element. If it is a browser, returns the browser in some
+ * special cases we're interested in, or the browser's active element.
+ *
+ * @returns {Element}
+ */
+function getActiveElement() {
+ let activeElement = document.activeElement;
+ if (globalSearch.contains(activeElement)) {
+ return globalSearch;
+ }
+ if (activeElement.localName == "browser" && !activeElement.isRemoteBrowser) {
+ activeElement = activeElement.contentDocument.activeElement;
+ }
+ if (
+ activeElement.localName == "browser" &&
+ activeElement.id == "messageBrowser"
+ ) {
+ activeElement = activeElement.contentDocument.activeElement;
+ }
+ return activeElement;
+}
+
+/**
+ * Presses F6 for each element in `elements`, and checks the element has focus.
+ * Then presses Shift+F6 to go back through the elements.
+ * Note that the currently selected element should *not* be the first element.
+ *
+ * @param {Element[]}
+ */
+function cycle(...elements) {
+ let activeElement = getActiveElement();
+
+ for (let i = 0; i < elements.length; i++) {
+ EventUtils.synthesizeKey("KEY_F6", {}, activeElement.ownerGlobal);
+ activeElement = getActiveElement();
+ Assert.equal(
+ activeElement.id || activeElement.localName,
+ elements[i].id || elements[i].localName,
+ "F6 moved the focus"
+ );
+ }
+
+ for (let i = elements.length - 2; i >= 0; i--) {
+ EventUtils.synthesizeKey(
+ "KEY_F6",
+ { shiftKey: true },
+ activeElement.ownerGlobal
+ );
+ activeElement = getActiveElement();
+ Assert.equal(
+ activeElement.id || activeElement.localName,
+ elements[i].id || elements[i].localName,
+ "Shift+F6 moved the focus"
+ );
+ }
+}
diff --git a/comm/mail/base/test/browser/browser_paneSplitter.js b/comm/mail/base/test/browser/browser_paneSplitter.js
new file mode 100644
index 0000000000..1646703b96
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_paneSplitter.js
@@ -0,0 +1,572 @@
+/* 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 tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+// Increase this value to slow the test down if you want to see what it is doing.
+let MOUSE_DELAY = 0;
+
+let win, doc;
+
+let resizingEvents = 0;
+let resizedEvents = 0;
+let collapsedEvents = 0;
+let expandedEvents = 0;
+
+// This object keeps the test simple by removing the differences between
+// horizontal and vertical, and which pane is controlled by the splitter.
+let testRunner = {
+ outer: null, // The container for the splitter and panes.
+ splitter: null, // The splitter.
+ resizedIsBefore: null, // Whether resized is before the splitter.
+ resized: null, // The pane the splitter controls the size of.
+ fill: null, // The pane that splitter doesn't control.
+ dimension: null, // Which dimension the splitter resizes.
+
+ getSize(element) {
+ return element.getBoundingClientRect()[this.dimension];
+ },
+
+ assertElementSizes(size, msg = "") {
+ Assert.equal(
+ this.getSize(this.resized),
+ size,
+ `Resized element should take up the expected ${this.dimension}: ${msg}`
+ );
+ Assert.equal(
+ this.getSize(this.fill),
+ 500 - size,
+ `Fill element should take up the rest of the ${this.dimension}: ${msg}`
+ );
+ },
+
+ assertSplitterSize(size, msg = "") {
+ Assert.equal(
+ this.splitter[this.dimension],
+ size,
+ `Splitter ${this.dimension} should match expected ${size}: ${msg}`
+ );
+ },
+
+ get minSizeProperty() {
+ return this.dimension == "width" ? "minWidth" : "minHeight";
+ },
+
+ get maxSizeProperty() {
+ return this.dimension == "width" ? "maxWidth" : "maxHeight";
+ },
+
+ get collapseSizeAttribute() {
+ return this.dimension == "width" ? "collapse-width" : "collapse-height";
+ },
+
+ setCollapseSize(size) {
+ this.splitter.setAttribute(this.collapseSizeAttribute, size);
+ },
+
+ clearCollapseSize() {
+ this.splitter.removeAttribute(this.collapseSizeAttribute);
+ },
+
+ async synthMouse(position, type = "mousemove", otherPosition = 50) {
+ let x, y;
+ if (!this.resizedIsBefore) {
+ position = 500 - position;
+ }
+ if (this.dimension == "width") {
+ [x, y] = [position, otherPosition];
+ } else {
+ [x, y] = [otherPosition, position];
+ }
+ EventUtils.synthesizeMouse(
+ this.splitter.parentNode,
+ x,
+ y,
+ { type, buttons: 1 },
+ win
+ );
+
+ if (MOUSE_DELAY) {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, MOUSE_DELAY));
+ }
+
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ },
+};
+
+add_setup(async function () {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/paneSplitter.xhtml",
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+
+ win = tab.browser.contentWindow;
+ doc = win.document;
+
+ win.addEventListener("splitter-resizing", event => resizingEvents++);
+ win.addEventListener("splitter-resized", event => resizedEvents++);
+ win.addEventListener("splitter-collapsed", event => collapsedEvents++);
+ win.addEventListener("splitter-expanded", event => expandedEvents++);
+});
+
+add_task(async function testHorizontalBefore() {
+ let outer = doc.getElementById("horizontal-before");
+ let resized = outer.querySelector(".resized");
+ let splitter = outer.querySelector(`hr[is="pane-splitter"]`);
+ let fill = outer.querySelector(".fill");
+
+ Assert.equal(resized.clientWidth, 200);
+ Assert.equal(fill.clientWidth, 300);
+ Assert.equal(win.getComputedStyle(splitter).cursor, "ew-resize");
+
+ testRunner.outer = outer;
+ testRunner.splitter = splitter;
+ testRunner.resizedIsBefore = true;
+ testRunner.resized = resized;
+ testRunner.fill = fill;
+ testRunner.dimension = "width";
+
+ await subtestDrag();
+ await subtestDragSizeBounds();
+ await subtestDragAutoCollapse();
+ await subtestCollapseExpand();
+});
+
+add_task(async function testHorizontalAfter() {
+ let outer = doc.getElementById("horizontal-after");
+ let fill = outer.querySelector(".fill");
+ let splitter = outer.querySelector(`hr[is="pane-splitter"]`);
+ let resized = outer.querySelector(".resized");
+
+ Assert.equal(fill.clientWidth, 300);
+ Assert.equal(resized.clientWidth, 200);
+ Assert.equal(win.getComputedStyle(splitter).cursor, "ew-resize");
+
+ testRunner.outer = outer;
+ testRunner.splitter = splitter;
+ testRunner.resizedIsBefore = false;
+ testRunner.resized = resized;
+ testRunner.fill = fill;
+ testRunner.dimension = "width";
+
+ await subtestDrag();
+ await subtestDragSizeBounds();
+ await subtestDragAutoCollapse();
+ await subtestCollapseExpand();
+});
+
+add_task(async function testVerticalBefore() {
+ let outer = doc.getElementById("vertical-before");
+ let resized = outer.querySelector(".resized");
+ let splitter = outer.querySelector(`hr[is="pane-splitter"]`);
+ let fill = outer.querySelector(".fill");
+
+ Assert.equal(resized.clientHeight, 200);
+ Assert.equal(fill.clientHeight, 300);
+ Assert.equal(win.getComputedStyle(splitter).cursor, "ns-resize");
+
+ testRunner.outer = outer;
+ testRunner.splitter = splitter;
+ testRunner.resizedIsBefore = true;
+ testRunner.resized = resized;
+ testRunner.fill = fill;
+ testRunner.dimension = "height";
+
+ await subtestDrag();
+ await subtestDragSizeBounds();
+ await subtestDragAutoCollapse();
+ await subtestCollapseExpand();
+});
+
+add_task(async function testVerticalAfter() {
+ let outer = doc.getElementById("vertical-after");
+ let fill = outer.querySelector(".fill");
+ let splitter = outer.querySelector(`hr[is="pane-splitter"]`);
+ let resized = outer.querySelector(".resized");
+
+ testRunner.outer = outer;
+ testRunner.splitter = splitter;
+ testRunner.resizedIsBefore = false;
+ testRunner.resized = resized;
+ testRunner.fill = fill;
+ testRunner.dimension = "height";
+
+ Assert.equal(fill.clientHeight, 300);
+ Assert.equal(resized.clientHeight, 200);
+ Assert.equal(win.getComputedStyle(splitter).cursor, "ns-resize");
+
+ await subtestDrag();
+ await subtestDragSizeBounds();
+ await subtestDragAutoCollapse();
+ await subtestCollapseExpand();
+});
+
+async function subtestDrag() {
+ info("subtestDrag");
+ resizingEvents = 0;
+ resizedEvents = 0;
+
+ let originalPosition = testRunner.getSize(testRunner.resized);
+ let position = 200;
+
+ await testRunner.synthMouse(position, "mousedown");
+
+ await testRunner.synthMouse(position, "mousemove", 25);
+ Assert.equal(resizingEvents, 0, "moving up the splitter does nothing");
+ await testRunner.synthMouse(position, "mousemove", 75);
+ Assert.equal(resizingEvents, 0, "moving down the splitter does nothing");
+
+ position--;
+ await testRunner.synthMouse(position);
+ Assert.equal(resizingEvents, 0, "moving 1px does nothing");
+
+ position--;
+ await testRunner.synthMouse(position);
+ Assert.equal(resizingEvents, 0, "moving 2px does nothing");
+
+ position--;
+ await testRunner.synthMouse(position);
+ Assert.equal(resizingEvents, 1, "a resizing event fired");
+
+ // Drag in steps to the left-hand/top end.
+ for (; position >= 0; position -= 50) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(position);
+ }
+
+ // Drag beyond the left-hand/top end.
+ position = -50;
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(0);
+
+ // Drag in steps to the right-hand/bottom end.
+ for (let position = 0; position <= 500; position += 50) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(position);
+ }
+
+ // Drag beyond the right-hand/bottom end.
+ position = 550;
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(500);
+
+ // Drop.
+ position = 400;
+ Assert.equal(resizingEvents, 1, "no more resizing events fired");
+ Assert.equal(resizedEvents, 0, "no resized events fired");
+ await testRunner.synthMouse(position);
+ await testRunner.synthMouse(position, "mouseup");
+ testRunner.assertElementSizes(400);
+ Assert.equal(resizingEvents, 1, "no more resizing events fired");
+ Assert.equal(resizedEvents, 1, "a resized event fired");
+
+ // Pick up again.
+ await testRunner.synthMouse(position, "mousedown");
+
+ // Move.
+ for (; position >= originalPosition; position -= 50) {
+ await testRunner.synthMouse(position);
+ }
+
+ // Drop.
+ Assert.equal(resizingEvents, 2, "a resizing event fired");
+ Assert.equal(resizedEvents, 1, "no more resized events fired");
+ await testRunner.synthMouse(position, "mouseup");
+ testRunner.assertElementSizes(originalPosition);
+ Assert.equal(resizingEvents, 2, "no more resizing events fired");
+ Assert.equal(resizedEvents, 2, "a resized event fired");
+}
+
+async function subtestDragSizeBounds() {
+ info("subtestDragSizeBounds");
+
+ let { splitter, resized, fill, minSizeProperty, maxSizeProperty } =
+ testRunner;
+
+ // Various min or max sizes to set on the resized and fill elements.
+ // NOTE: the sum of the max sizes is greater than 500px.
+ // Moreover, the resized element's min size is below 200px, and the max size
+ // above it. Similarly, the fill element's min size is below 300px. This
+ // ensures that the initial sizes of 200px and 300px are within their
+ // respective min-max bounds.
+ // NOTE: We do not set a max size on the fill element. The grid layout does
+ // not handle this. Nor is it an expected usage of the splitter.
+ for (let [minResized, min] of [
+ [null, 0],
+ ["100.5px", 100.5],
+ ]) {
+ for (let [maxResized, expectMax1] of [
+ [null, 500],
+ ["360px", 360],
+ ]) {
+ for (let [minFill, expectMax2] of [
+ [null, 500],
+ ["148px", 352],
+ ]) {
+ info(`Bounds [${minResized}, ${maxResized}] and [${minFill}, none]`);
+ let max = Math.min(expectMax1, expectMax2);
+ info(`Overall bound [${min}px, ${max}px]`);
+
+ // Construct a set of positions we are interested in.
+ let roundMin = Math.floor(min);
+ let roundMax = Math.ceil(max);
+ let positionSet = [-50, 150, 350, 550];
+ // Include specific positions around the minimum and maximum points.
+ positionSet.push(roundMin - 1, roundMin, roundMin + 1);
+ positionSet.push(roundMax - 1, roundMax, roundMax + 1);
+ positionSet.sort();
+
+ // Reset the splitter.
+ splitter.width = null;
+ splitter.height = null;
+
+ resized.style[minSizeProperty] = minResized;
+ resized.style[maxSizeProperty] = maxResized;
+ fill.style[minSizeProperty] = minFill;
+
+ testRunner.assertElementSizes(200, "initial position");
+ await testRunner.synthMouse(200, "mousedown");
+
+ for (let position of positionSet) {
+ await testRunner.synthMouse(position);
+ let size = Math.min(Math.max(position, min), max);
+ testRunner.assertElementSizes(size, `Moved forward to ${position}`);
+ testRunner.assertSplitterSize(size, `Moved forward to ${position}`);
+ }
+
+ await testRunner.synthMouse(500);
+ await testRunner.synthMouse(500, "mouseup");
+ testRunner.assertElementSizes(max, "positioned at max");
+ testRunner.assertSplitterSize(max, "positioned at max");
+
+ // Reverse.
+ await testRunner.synthMouse(max, "mousedown");
+
+ for (let position of positionSet.reverse()) {
+ await testRunner.synthMouse(position);
+ let size = Math.min(Math.max(position, min), max);
+ testRunner.assertElementSizes(size, `Moved backward to ${position}`);
+ testRunner.assertSplitterSize(size, `Moved backward to ${position}`);
+ }
+
+ await testRunner.synthMouse(0);
+ await testRunner.synthMouse(0, "mouseup");
+ testRunner.assertElementSizes(min, "positioned at min");
+ testRunner.assertSplitterSize(min, "positioned at min");
+ }
+ }
+ }
+
+ // Reset.
+ splitter.width = null;
+ splitter.height = null;
+ resized.style[minSizeProperty] = null;
+ resized.style[maxSizeProperty] = null;
+ fill.style[minSizeProperty] = null;
+}
+
+async function subtestDragAutoCollapse() {
+ info("subtestDragAutoCollapse");
+ testRunner.setCollapseSize(78);
+
+ collapsedEvents = 0;
+ expandedEvents = 0;
+
+ let { splitter } = testRunner;
+
+ let originalPosition = 200;
+
+ // Drag in steps toward the left-hand/top end.
+ await testRunner.synthMouse(200, "mousedown");
+ for (let position of [180, 160, 140, 120, 100, 80, 78]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ position,
+ `Should have ${position} size at ${position}`
+ );
+ Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
+ }
+
+ // For the first 20 pixels inside the minimum size, nothing happens.
+ for (let position of [74, 68, 64, 60, 58]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ 78,
+ `Should be at collapse-size at ${position}`
+ );
+ Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
+ }
+
+ // Then the pane collapses.
+ await testRunner.synthMouse(57);
+ Assert.equal(collapsedEvents, 1, "collapsed event fired");
+ for (let position of [57, 55, 51, 40, 20, 0, -20]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(0, `Should have no size at ${position}`);
+ Assert.ok(splitter.isCollapsed, `Should be collapsed at ${position}`);
+ }
+
+ await testRunner.synthMouse(-20, "mouseup");
+ testRunner.assertElementSizes(
+ 0,
+ "Should be at min size after releasing mouse"
+ );
+ Assert.ok(splitter.isCollapsed, "Should be collapsed after releasing mouse");
+
+ // Drag it from the collapsed state.
+ await testRunner.synthMouse(0, "mousedown");
+ for (let position of [0, 8, 16, 19]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ 0,
+ `Should still have no size at ${position}`
+ );
+ Assert.ok(splitter.isCollapsed, `Should still be collapsed at ${position}`);
+ }
+
+ // Then the pane expands. For the first 20 pixels, nothing happens.
+ await testRunner.synthMouse(20);
+ Assert.equal(expandedEvents, 1, "expanded event fired");
+ for (let position of [40, 60, 78]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ 78,
+ `Should expand to collapse-size at ${position}`
+ );
+ Assert.ok(
+ !splitter.isCollapsed,
+ `Should no longer be collapsed at ${position}`
+ );
+ }
+
+ for (let position of [79, 100, 120, 200, 250, 300, 400, 450]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ position,
+ `Should have ${position} size at ${position}`
+ );
+ Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
+ }
+
+ await testRunner.synthMouse(450, "mouseup");
+ testRunner.assertElementSizes(
+ 450,
+ "Should be at final size after releasing mouse"
+ );
+ Assert.ok(
+ !splitter.isCollapsed,
+ "Should not be collapsed after releasing mouse"
+ );
+
+ // Test that collapse and expand can happen in the same drag.
+ await testRunner.synthMouse(450, "mousedown");
+ let position;
+ let expectedSize;
+ for ([position, expectedSize] of [
+ [58, 78],
+ [57, 0],
+ [58, 78],
+ [57, 0],
+ ]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ expectedSize,
+ `Should have ${expectedSize} size at ${position}`
+ );
+ }
+ Assert.equal(collapsedEvents, 3, "collapsed events fired");
+ Assert.equal(expandedEvents, 2, "expanded events fired");
+ await testRunner.synthMouse(position, "mouseup");
+
+ // Test that expansion from collapsed reverts to normal behaviour after
+ // dragging out to the minimum size.
+ await testRunner.synthMouse(0, "mousedown");
+ for ([position, expectedSize] of [
+ [0, 0],
+ [10, 0],
+ [20, 78],
+ [40, 78],
+ [60, 78],
+ [40, 0],
+ [60, 78],
+ [40, 0],
+ [80, 80],
+ [100, 100],
+ ]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ expectedSize,
+ `Should have ${expectedSize} size at ${position}`
+ );
+ }
+ Assert.equal(collapsedEvents, 5, "collapsed events fired");
+ Assert.equal(expandedEvents, 5, "expanded events fired");
+ await testRunner.synthMouse(position, "mouseup");
+
+ // Restore the original position.
+ await testRunner.synthMouse(position, "mousedown");
+ position = originalPosition;
+ await testRunner.synthMouse(position);
+ await testRunner.synthMouse(position, "mouseup");
+ testRunner.assertElementSizes(originalPosition);
+
+ testRunner.clearCollapseSize();
+}
+
+async function subtestCollapseExpand() {
+ info("subtestCollapseExpand");
+ collapsedEvents = 0;
+ expandedEvents = 0;
+
+ let { splitter } = testRunner;
+
+ let originalSize = testRunner.getSize(testRunner.resized);
+
+ // Collapse.
+ Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
+ Assert.equal(collapsedEvents, 0, "no collapsed events have fired");
+
+ splitter.collapse();
+ testRunner.assertElementSizes(0);
+ Assert.ok(splitter.isCollapsed, "splitter is collapsed");
+ Assert.equal(collapsedEvents, 1, "a collapsed event fired");
+
+ splitter.collapse();
+ Assert.ok(splitter.isCollapsed, "splitter is collapsed");
+ Assert.equal(collapsedEvents, 1, "no more collapsed events have fired");
+
+ // Expand.
+ splitter.expand();
+ testRunner.assertElementSizes(originalSize);
+ Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
+ Assert.equal(expandedEvents, 1, "an expanded event fired");
+
+ splitter.expand();
+ Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
+ Assert.equal(expandedEvents, 1, "no more expanded events have fired");
+
+ collapsedEvents = 0;
+ expandedEvents = 0;
+
+ // Collapse again. Then drag to expand.
+ splitter.collapse();
+ Assert.equal(collapsedEvents, 1, "a collapsed event fired");
+
+ testRunner.setCollapseSize(78);
+
+ await testRunner.synthMouse(0, "mousedown");
+ await testRunner.synthMouse(200);
+ await testRunner.synthMouse(200, "mouseup");
+ testRunner.assertElementSizes(200);
+ Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
+ Assert.equal(expandedEvents, 1, "an expanded event fired");
+
+ testRunner.clearCollapseSize();
+}
diff --git a/comm/mail/base/test/browser/browser_preferDisplayName.js b/comm/mail/base/test/browser/browser_preferDisplayName.js
new file mode 100644
index 0000000000..d041b3bd37
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_preferDisplayName.js
@@ -0,0 +1,456 @@
+/* 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 { AddrBookCard } = ChromeUtils.import(
+ "resource:///modules/AddrBookCard.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let book, emily, felix, testFolder;
+
+add_setup(async function () {
+ book = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+
+ emily = new AddrBookCard();
+ emily.displayName = "This is Emily!";
+ emily.primaryEmail = "emily@ekberg.invalid";
+ book.addCard(emily);
+
+ felix = new AddrBookCard();
+ felix.displayName = "Felix's Flower Co.";
+ felix.primaryEmail = "felix@flowers.invalid";
+ felix.setPropertyAsBool("PreferDisplayName", false);
+ book.addCard(felix);
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ book.deleteCards(book.childCards);
+ MailServices.accounts.removeAccount(account, false);
+ });
+
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("preferDisplayName", null);
+ testFolder = rootFolder
+ .getChildNamed("preferDisplayName")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+});
+
+add_task(async function () {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let { threadPane, threadTree, messageBrowser } = about3Pane;
+ // Not `currentAboutMessage` as that's null right now.
+ let aboutMessage = messageBrowser.contentWindow;
+ let messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ // Set up the UI.
+
+ about3Pane.restoreState({
+ folderURI: testFolder.URI,
+ messagePaneVisible: true,
+ });
+ threadPane.onColumnsVisibilityChanged({
+ value: "senderCol",
+ target: { hasAttribute: () => true },
+ });
+ threadPane.onColumnsVisibilityChanged({
+ value: "recipientCol",
+ target: { hasAttribute: () => true },
+ });
+
+ // Switch to classic view and table layout as the test requires this state.
+ await ensure_table_view();
+
+ // It's important that we don't cause the thread tree to invalidate the row
+ // in question, and selecting it would do that, so select it first.
+ threadTree.selectedIndex = 2;
+ await BrowserTestUtils.browserLoaded(messagePaneBrowser);
+
+ // Check the initial state of everything.
+
+ let fromLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="from"]`
+ );
+ let fromSingleLine = fromLabel.querySelector(".recipient-single-line");
+ let fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name");
+ let fromMultiLineAddress = fromLabel.querySelector(
+ ".recipient-multi-line-address"
+ );
+
+ let toLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="to"]`
+ );
+ let toSingleLine = toLabel.querySelector(".recipient-single-line");
+
+ let row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "This is Emily!",
+ "initial state of Correspondent column"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "This is Emily!",
+ "initial state of Sender column"
+ );
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix Flowers",
+ "initial state of Recipient column"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "This is Emily!",
+ "initial state of From single-line label"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "initial state of From single-line title"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "This is Emily!",
+ "initial state of From multi-line name"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "initial state of From multi-line address"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix Flowers <felix@flowers.invalid>",
+ "initial state of To single-line label"
+ );
+ Assert.equal(toSingleLine.title, "", "initial state of To single-line title");
+
+ // Change Emily's display name.
+
+ emily.displayName = "I'm Emily!";
+ book.modifyCard(emily);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "I'm Emily!",
+ "Correspondent column should be the new display name"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "I'm Emily!",
+ "Sender column should be the new display name"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "I'm Emily!",
+ "From single-line label should be the new display name"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line title should not change"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "I'm Emily!",
+ "From multi-line name should be the new display name"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+
+ // Stop preferring Emily's display name.
+
+ emily.setPropertyAsBool("PreferDisplayName", false);
+ book.modifyCard(emily);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "Emily Ekberg",
+ "Correspondent column should be the name from the header"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "Emily Ekberg",
+ "Sender column should be the name from the header"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line label should match the header"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "",
+ "From single-line title should be cleared"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "Emily Ekberg",
+ "From multi-line name should be the name from the header"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+
+ // Prefer Emily's display name.
+
+ emily.setPropertyAsBool("PreferDisplayName", true);
+ book.modifyCard(emily);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "I'm Emily!",
+ "Correspondent column should be the display name"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "I'm Emily!",
+ "Sender column should be the display name"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "I'm Emily!",
+ "From single-line label should be the display name"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line title should match the header"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "I'm Emily!",
+ "From multi-line name should be the display name"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+
+ // Prefer Felix's display name.
+
+ felix.setPropertyAsBool("PreferDisplayName", true);
+ book.modifyCard(felix);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix's Flower Co.",
+ "Recipient column should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix's Flower Co.",
+ "To single-line label should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line title should match the header"
+ );
+
+ // Stop preferring Felix's display name.
+
+ felix.setPropertyAsBool("PreferDisplayName", false);
+ book.modifyCard(felix);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix Flowers",
+ "Recipient column should be the name from the header"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line label should match the header"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "",
+ "To single-line title should be cleared"
+ );
+
+ // Prefer Felix's display name.
+
+ felix.setPropertyAsBool("PreferDisplayName", true);
+ book.modifyCard(felix);
+
+ // Set global prefer display name preference to false.
+
+ Services.prefs.setBoolPref("mail.showCondensedAddresses", false);
+ await TestUtils.waitForCondition(
+ () => !toLabel.parentNode,
+ "Waiting for the header labels to reload."
+ );
+ fromLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="from"]`
+ );
+ toLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="to"]`
+ );
+
+ fromSingleLine = fromLabel.querySelector(".recipient-single-line");
+ fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name");
+ fromMultiLineAddress = fromLabel.querySelector(
+ ".recipient-multi-line-address"
+ );
+ toSingleLine = toLabel.querySelector(".recipient-single-line");
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "Emily Ekberg",
+ "Correspondent column should be the name from the header"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "Emily Ekberg",
+ "Sender column should be the name from the header"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line label should match the header"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "",
+ "From single-line title should be cleared"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From multi-line name should be the name from the header"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix Flowers",
+ "Recipient column should be the name from the header"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line label should match the header"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "",
+ "To single-line title should be cleared"
+ );
+
+ // Reset prefer display name global preference to true.
+
+ Services.prefs.setBoolPref("mail.showCondensedAddresses", true);
+ await TestUtils.waitForCondition(
+ () => !toLabel.parentNode,
+ "Waiting for the header labels to reload."
+ );
+ fromLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="from"]`
+ );
+ toLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="to"]`
+ );
+
+ fromSingleLine = fromLabel.querySelector(".recipient-single-line");
+ fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name");
+ fromMultiLineAddress = fromLabel.querySelector(
+ ".recipient-multi-line-address"
+ );
+ toSingleLine = toLabel.querySelector(".recipient-single-line");
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "I'm Emily!",
+ "Correspondent column should be the new display name"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "I'm Emily!",
+ "Sender column should be the new display name"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "I'm Emily!",
+ "From single-line label should be the new display name"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line title should not change"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "I'm Emily!",
+ "From multi-line name should be the new display name"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix's Flower Co.",
+ "Recipient column should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix's Flower Co.",
+ "To single-line label should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line title should match the header"
+ );
+
+ // Restore the default for Felix.
+
+ felix.deleteProperty("PreferDisplayName");
+ book.modifyCard(felix);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix's Flower Co.",
+ "Recipient column should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix's Flower Co.",
+ "To single-line label should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line title should match the header"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_searchMessages.js b/comm/mail/base/test/browser/browser_searchMessages.js
new file mode 100644
index 0000000000..4207b0019c
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_searchMessages.js
@@ -0,0 +1,460 @@
+/* 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 { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+const tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, otherFolder;
+
+add_setup(async function () {
+ const generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+ rootFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder = rootFolder.createLocalSubfolder("searchMessagesFolder");
+ testFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ const messageStrings = generator
+ .makeMessages({ count: 20 })
+ .map(message => message.toMboxString());
+ testFolder.addMessageBatch(messageStrings);
+ otherFolder = rootFolder.createLocalSubfolder("searchMessagesOtherFolder");
+
+ tabmail.currentAbout3Pane.paneLayout.messagePaneVisible = true;
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/SearchDialog.xhtml"
+ );
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function () {
+ const windowOpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ w =>
+ w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ goDoCommand("cmd_searchMessages");
+ const win = await windowOpenedPromise;
+ const doc = win.document;
+
+ await SimpleTest.promiseFocus(win);
+
+ const searchButton = doc.getElementById("search-button");
+ const clearButton = doc.querySelector(
+ "#searchTerms > vbox > hbox:nth-child(2) > button"
+ );
+ const searchTermList = doc.getElementById("searchTermList");
+ const threadTree = doc.getElementById("threadTree");
+ const columns = threadTree.columns;
+ const picker = threadTree.querySelector("treecolpicker");
+ const popup = picker.querySelector("menupopup");
+ const openButton = doc.getElementById("openButton");
+ const deleteButton = doc.getElementById("deleteButton");
+ const fileMessageButton = doc.getElementById("fileMessageButton");
+ const fileMessagePopup = fileMessageButton.querySelector("menupopup");
+ const openInFolderButton = doc.getElementById("openInFolderButton");
+ const saveAsVFButton = doc.getElementById("saveAsVFButton");
+ const statusText = doc.getElementById("statusText");
+
+ const treeClick = mailTestUtils.treeClick.bind(
+ null,
+ EventUtils,
+ win,
+ threadTree
+ );
+
+ // Test search criteria. The search results are deterministic unless
+ // MessageGenerator is changed.
+
+ await TestUtils.waitForCondition(
+ () => searchTermList.itemCount == 1,
+ "waiting for a search term to exist"
+ );
+ const searchTerm0 = searchTermList.getItemAtIndex(0);
+ const input0 = searchTerm0.querySelector("search-value input");
+ const button0 = searchTerm0.querySelector("button.small-button:first-child");
+
+ // Row 0 will look for subjects including "hovercraft".
+ Assert.equal(input0.value, "");
+ input0.focus();
+ EventUtils.sendString("hovercraft", win);
+
+ // Add another row.
+ EventUtils.synthesizeMouseAtCenter(button0, {}, win);
+ await TestUtils.waitForCondition(
+ () => searchTermList.itemCount == 2,
+ "waiting for a second search term to exist"
+ );
+
+ const searchTerm1 = searchTermList.getItemAtIndex(1);
+ const menulist = searchTerm1.querySelector("search-attribute menulist");
+ const menuitem = menulist.querySelector(`menuitem[value="1"]`);
+ const input1 = searchTerm1.querySelector("search-value input");
+
+ // Change row 1's search attribute.
+ EventUtils.synthesizeMouseAtCenter(menulist, {}, win);
+ await BrowserTestUtils.waitForPopupEvent(menulist, "shown");
+ menulist.menupopup.activateItem(menuitem);
+ await BrowserTestUtils.waitForPopupEvent(menulist, "hidden");
+
+ // Row 1 will look for the sender Emily Ekberg.
+ Assert.equal(input1.value, "");
+ EventUtils.synthesizeMouseAtCenter(input1, {}, win);
+ EventUtils.sendString("emily@ekberg.invalid", win);
+
+ // Search. Emily didn't send a message about hovercraft, so no results.
+ EventUtils.synthesizeMouseAtCenter(searchButton, {}, win);
+ // Allows 5 seconds for expected statusText to appear.
+ await TestUtils.waitForCondition(
+ () => statusText.value == "No matches found",
+ "waiting for status text to update"
+ );
+
+ // Change the search from AND to OR.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector(`#booleanAndGroup > radio[value="or"]`),
+ {},
+ win
+ );
+ // Change the subject search to something more common.
+ input0.select();
+ EventUtils.sendString("in", win);
+
+ // Search. 10 messages should be found.
+ EventUtils.synthesizeMouseAtCenter(searchButton, {}, win);
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 10,
+ "waiting for tree view to be filled"
+ );
+ // statusText changes on 500 ms time base.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ await TestUtils.waitForCondition(
+ () => statusText.value == "10 matches found",
+ "waiting for status text to update"
+ );
+
+ // Test tree sort column and direction.
+
+ EventUtils.synthesizeMouseAtCenter(columns.subjectCol.element, {}, win);
+ Assert.equal(
+ columns.subjectCol.element.getAttribute("sortDirection"),
+ "ascending"
+ );
+ EventUtils.synthesizeMouseAtCenter(columns.dateCol.element, {}, win);
+ Assert.equal(
+ columns.dateCol.element.getAttribute("sortDirection"),
+ "ascending"
+ );
+ EventUtils.synthesizeMouseAtCenter(columns.dateCol.element, {}, win);
+ Assert.equal(
+ columns.dateCol.element.getAttribute("sortDirection"),
+ "descending"
+ );
+
+ // Test tree column visibility and order.
+
+ checkTreeColumnsInOrder(threadTree, [
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "correspondentCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ ]);
+ EventUtils.synthesizeMouseAtCenter(picker, {}, win);
+ await BrowserTestUtils.waitForPopupEvent(popup, "shown");
+ popup.activateItem(
+ popup.querySelector(`[colindex="${columns.selectCol.index}"]`)
+ );
+ popup.activateItem(
+ popup.querySelector(`[colindex="${columns.deleteCol.index}"]`)
+ );
+ popup.hidePopup();
+ await BrowserTestUtils.waitForPopupEvent(popup, "hidden");
+ // Wait for macOS to catch up.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ checkTreeColumnsInOrder(threadTree, [
+ "selectCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "correspondentCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ "deleteCol",
+ ]);
+
+ threadTree._reorderColumn(
+ columns.deleteCol.element,
+ columns.selectCol.element,
+ false
+ );
+ threadTree.invalidate();
+ // Wait for macOS to catch up.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ checkTreeColumnsInOrder(threadTree, [
+ "selectCol",
+ "deleteCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "correspondentCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ ]);
+
+ // Test message selection with the select column.
+
+ treeClick(0, "subjectCol", {});
+ await TestUtils.waitForCondition(
+ () => threadTree.view.selection.count == 1,
+ "waiting for first message to be selected"
+ );
+ Assert.ok(!openButton.disabled);
+ Assert.ok(!deleteButton.disabled);
+ Assert.ok(!fileMessageButton.disabled);
+ Assert.ok(!openInFolderButton.disabled);
+ treeClick(1, "selectCol", {});
+ await TestUtils.waitForCondition(
+ () => threadTree.view.selection.count == 2,
+ "waiting for second message to be selected"
+ );
+ Assert.ok(!openButton.disabled);
+ Assert.ok(!deleteButton.disabled);
+ Assert.ok(!fileMessageButton.disabled);
+ Assert.ok(openInFolderButton.disabled);
+ treeClick(1, "selectCol", {});
+ await TestUtils.waitForCondition(
+ () => threadTree.view.selection.count == 1,
+ "waiting for second message to be unselected"
+ );
+ Assert.ok(!openButton.disabled);
+ Assert.ok(!deleteButton.disabled);
+ Assert.ok(!fileMessageButton.disabled);
+ Assert.ok(!openInFolderButton.disabled);
+ treeClick(0, "selectCol", {});
+ await TestUtils.waitForCondition(
+ () => threadTree.view.selection.count == 0,
+ "waiting for first message to be selected"
+ );
+ Assert.ok(openButton.disabled);
+ Assert.ok(deleteButton.disabled);
+ Assert.ok(fileMessageButton.disabled);
+ Assert.ok(openInFolderButton.disabled);
+
+ // Opening messages.
+
+ // Test opening a message with the "Open" button.
+ treeClick(0, "subjectCol", {});
+ let tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ EventUtils.synthesizeMouseAtCenter(openButton, {}, win);
+ const {
+ detail: { tabInfo: tab1 },
+ } = await tabOpenPromise;
+ await BrowserTestUtils.waitForEvent(tab1.chromeBrowser, "MsgLoaded");
+ Assert.equal(tab1.mode.name, "mailMessageTab");
+
+ await SimpleTest.promiseFocus(win);
+
+ // Test opening a message with a double click.
+ tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ treeClick(0, "subjectCol", { clickCount: 2 });
+ const {
+ detail: { tabInfo: tab2 },
+ } = await tabOpenPromise;
+ await BrowserTestUtils.waitForEvent(tab2.chromeBrowser, "MsgLoaded");
+ Assert.equal(tab2.mode.name, "mailMessageTab");
+
+ await SimpleTest.promiseFocus(win);
+
+ // Test opening a message with the keyboard.
+ tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ threadTree.focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ const {
+ detail: { tabInfo: tab3 },
+ } = await tabOpenPromise;
+ await BrowserTestUtils.waitForEvent(tab3.chromeBrowser, "MsgLoaded");
+ Assert.equal(tab3.mode.name, "mailMessageTab");
+
+ await SimpleTest.promiseFocus(win);
+
+ // Test opening a message with the "Open in Folder" button.
+ const tabSelectPromise = BrowserTestUtils.waitForEvent(window, "TabSelect");
+ EventUtils.synthesizeMouseAtCenter(openInFolderButton, {}, win);
+ const {
+ detail: { tabInfo: tab0 },
+ } = await tabSelectPromise;
+ await BrowserTestUtils.waitForEvent(tab0.chromeBrowser, "MsgLoaded");
+ Assert.equal(tab0, tabmail.tabInfo[0]);
+
+ tabmail.closeOtherTabs(tab0);
+
+ await SimpleTest.promiseFocus(win);
+
+ // Deleting messages.
+
+ // Test deleting a message with the delete column.
+ let deletePromise = PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ );
+ treeClick(0, "deleteCol", {});
+ await deletePromise;
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 9,
+ "waiting for row to be removed from tree view"
+ );
+
+ // Test deleting a message with the "Delete" button.
+ deletePromise = PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ );
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, win);
+ await deletePromise;
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 8,
+ "waiting for row to be removed from tree view"
+ );
+
+ // Test deleting a message with the keyboard.
+ treeClick(0, "subjectCol", {});
+ deletePromise = PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ );
+ EventUtils.synthesizeKey("VK_DELETE", { shiftKey: true }, win);
+ await deletePromise;
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 7,
+ "waiting for row to be removed from tree view"
+ );
+
+ // Moving messages.
+
+ // Test moving a message to another folder with the "Move To" button.
+ treeClick(0, "subjectCol", {});
+ const movePromise = PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(fileMessageButton, {}, win);
+ await BrowserTestUtils.waitForPopupEvent(fileMessagePopup, "shown");
+ const rootFolderMenu = [...fileMessagePopup.children].find(
+ i => i._folder == rootFolder
+ );
+ rootFolderMenu.openMenu(true);
+ await BrowserTestUtils.waitForPopupEvent(rootFolderMenu.menupopup, "shown");
+ const otherFolderItem = [...rootFolderMenu.menupopup.children].find(
+ i => i._folder == otherFolder
+ );
+ rootFolderMenu.menupopup.activateItem(otherFolderItem);
+ await BrowserTestUtils.waitForPopupEvent(fileMessagePopup, "hidden");
+
+ await movePromise;
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 6,
+ "waiting for row to be removed from tree view"
+ );
+
+ // TODO: Test dragging a message to another folder.
+
+ // Test the "Save as Search Folder" button.
+
+ const virtualFolderDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ {
+ async callback(vfWin) {
+ await SimpleTest.promiseFocus(vfWin);
+ await BrowserTestUtils.closeWindow(vfWin);
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(saveAsVFButton, {}, win);
+ await virtualFolderDialogPromise;
+
+ await SimpleTest.promiseFocus(win);
+
+ // Test clearing the search.
+
+ EventUtils.synthesizeMouseAtCenter(clearButton, {}, win);
+ await TestUtils.waitForCondition(
+ () => searchTermList.itemCount == 1,
+ "waiting for search term list to be cleared"
+ );
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 0,
+ "waiting for tree view to be cleared"
+ );
+
+ const newSearchTerm0 = searchTermList.getItemAtIndex(0);
+ Assert.notEqual(newSearchTerm0, searchTerm0);
+ const newInput0 = newSearchTerm0.querySelector("search-value input");
+ Assert.equal(newInput0.value, "");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ // Open the window again, and check the tree columns are as we left them.
+
+ const window2OpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ w =>
+ w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ goDoCommand("cmd_searchMessages");
+ const win2 = await window2OpenedPromise;
+ const doc2 = win.document;
+ await SimpleTest.promiseFocus(win2);
+
+ const threadTree2 = doc2.getElementById("threadTree");
+
+ checkTreeColumnsInOrder(threadTree2, [
+ "selectCol",
+ "deleteCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "correspondentCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ ]);
+
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+function checkTreeColumnsInOrder(tree, expectedOrder) {
+ Assert.deepEqual(
+ Array.from(tree.querySelectorAll("treecol:not([hidden])"))
+ .sort((a, b) => a.ordinal - b.ordinal)
+ .map(c => c.id),
+ expectedOrder
+ );
+}
diff --git a/comm/mail/base/test/browser/browser_selectionWidgetController.js b/comm/mail/base/test/browser/browser_selectionWidgetController.js
new file mode 100644
index 0000000000..8e57c64bdc
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_selectionWidgetController.js
@@ -0,0 +1,6196 @@
+/* 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 tabInfo;
+var win;
+
+add_setup(async () => {
+ tabInfo = window.openContentTab(
+ "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/selectionWidget.xhtml"
+ );
+ await BrowserTestUtils.browserLoaded(tabInfo.browser);
+
+ tabInfo.browser.focus();
+ win = tabInfo.browser.contentWindow;
+});
+
+registerCleanupFunction(() => {
+ window.tabmail.closeTab(tabInfo);
+});
+
+var selectionModels = ["focus", "browse", "browse-multi"];
+
+/**
+ * The selection widget.
+ *
+ * @type {HTMLElement}
+ */
+var widget;
+/**
+ * A focusable item before the widget.
+ *
+ * @type {HTMLElement}
+ */
+var before;
+/**
+ * A focusable item after the widget.
+ *
+ * @type {HTMLElement}
+ */
+var after;
+
+/**
+ * Reset the page and create a new widget.
+ *
+ * The "widget", "before" and "after" variables will be reset to the new
+ * elements.
+ *
+ * @param {object} options - Options to set.
+ * @param {string} options.model - The selection model to use.
+ * @param {string} [options.direction="right-to-left"] - The direction of the
+ * widget. Choosing "top-to-bottom" will layout items from top to bottom.
+ * Choosing "right-to-left" or "left-to-right" will set the page's direction
+ * to "rtl" or "ltr", respectively, and will layout items in the writing
+ * direction.
+ * @param {boolean} [options.draggable=false] - Whether to make the items
+ * draggable.
+ */
+function reset(options) {
+ function createTabStop(text) {
+ let el = win.document.createElement("span");
+ el.tabIndex = 0;
+ el.id = text;
+ el.textContent = text;
+ return el;
+ }
+ before = createTabStop("before");
+ after = createTabStop("after");
+
+ let { model, direction } = options;
+ if (!direction) {
+ // Default to a less-common format.
+ direction = "right-to-left";
+ }
+ info(`Creating ${direction} widget with "${model}" model`);
+
+ widget = win.document.createElement("test-selection-widget");
+ widget.id = "widget";
+ widget.setAttribute("selection-model", model);
+ widget.setAttribute(
+ "layout-direction",
+ direction == "top-to-bottom" ? "vertical" : "horizontal"
+ );
+ widget.toggleAttribute("items-draggable", options.draggable);
+
+ win.document.body.replaceChildren(before, widget, after);
+
+ win.document.dir = direction == "left-to-right" ? "ltr" : "rtl";
+
+ before.focus();
+}
+
+/**
+ * Create an array of sequential integers.
+ *
+ * @param {number} start - The starting integer.
+ * @param {number} num - The number of integers.
+ *
+ * @returns {number[]} - Array of integers between start and (start + num - 1).
+ */
+function range(start, num) {
+ return Array.from({ length: num }, (_, i) => start + i);
+}
+
+/**
+ * Assert that the specified items are selected in the widget, and nothing else.
+ *
+ * @param {number[]} indices - The indices of the selected items.
+ * @param {string} msg - A message to use for the assertion.
+ */
+function assertSelection(indices, msg) {
+ let selected = widget.selectedIndices();
+ Assert.deepEqual(selected, indices, `Selected indices should match: ${msg}`);
+ // Test that the return of getSelectionRanges is as expected.
+ let expectRanges = [];
+ let lastIndex = -2;
+ let rangeIndex = -1;
+ for (let index of indices) {
+ if (index == lastIndex + 1) {
+ expectRanges[rangeIndex].end++;
+ } else {
+ rangeIndex++;
+ expectRanges.push({ start: index, end: index + 1 });
+ }
+ lastIndex = index;
+ }
+ Assert.deepEqual(
+ widget.getSelectionRanges(),
+ expectRanges,
+ `Selection ranges should match expected: ${msg}`
+ );
+}
+
+/**
+ * Assert that the given element is focused.
+ *
+ * @param {object} expect - The expected focused element.
+ * @param {HTMLElement} [expect.element] - The expected element that will
+ * have focus.
+ * @param {number} [expect.index] - If the `element` property is not given, this
+ * specifies the index of the item widget we expect to have focus.
+ * @param {string} [expect.text] - Optionally test that the element also has the
+ * given text content.
+ * @param {string} msg - A message to use for the assertion.
+ */
+function assertFocus(expect, msg) {
+ let expectElement;
+ let name;
+ if (expect.element != undefined) {
+ expectElement = expect.element;
+ name = `Element #${expectElement.id}`;
+ } else {
+ expectElement = widget.items[expect.index].element;
+ name = `Item ${expect.index}`;
+ }
+ let active = win.document.activeElement;
+ let activeIndex = widget.items.findIndex(i => i.element == active);
+ if (activeIndex >= 0) {
+ active = `"${active.textContent}", index: ${activeIndex}`;
+ } else if (active.id) {
+ active = `#${active.id}`;
+ } else {
+ active = `<${active.localName}>`;
+ }
+ Assert.ok(
+ expectElement.matches(":focus"),
+ `${name} should have focus (active: ${active}): ${msg}`
+ );
+}
+
+/**
+ * Shift the focus by one step by pressing Tab and assert the new focused
+ * element.
+ *
+ * @param {boolean} forward - Whether to move the focus forward.
+ * @param {object} expect - The expected focused element after pressing tab.
+ * Same as passed to {@link assertFocus}.
+ * @param {string} msg - A message to use for the assertion.
+ */
+function stepFocus(forward, expect, msg) {
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: !forward }, win);
+ assertFocus(
+ expect,
+ `After moving ${forward ? "forward" : "backward"}: ${msg}`
+ );
+}
+
+/**
+ * @typedef {object} ItemState
+ * @property {string} text - The text content of the item.
+ * @property {boolean} [selected=false] - Whether the item is selected.
+ * @property {boolean} [focused=false] - Whether the item is focused.
+ */
+
+/**
+ * Assert the text order, selection state and focus of the widget items.
+ *
+ * @param {ItemState[]} expected - The expected state of the widget items, in
+ * the expected order of the items.
+ * @param {string} msg - A message to use for the assertion.
+ */
+function assertState(expected, msg) {
+ let textOrder = [];
+ let focusIndex;
+ let selectedIndices = [];
+ for (let [index, state] of expected.entries()) {
+ textOrder.push(state.text);
+ if (state.selected) {
+ selectedIndices.push(index);
+ }
+ if (state.focused) {
+ if (focusIndex != undefined) {
+ throw new Error("More than one item specified as having focus");
+ }
+ focusIndex = index;
+ }
+ }
+ Assert.deepEqual(
+ Array.from(widget.items, i => i.element.textContent),
+ textOrder,
+ `Text order should match: ${msg}`
+ );
+ assertSelection(selectedIndices, msg);
+ if (focusIndex != undefined) {
+ assertFocus({ index: focusIndex }, msg);
+ } else {
+ Assert.ok(
+ !widget.querySelector(":focus"),
+ `Widget should not contain any focus: ${msg}`
+ );
+ }
+}
+
+/**
+ * Click the empty space of the widget.
+ *
+ * @param {object} mouseEvent - Properties for the click event.
+ */
+function clickWidgetEmptySpace(mouseEvent) {
+ let widgetRect = widget.getBoundingClientRect();
+ if (widget.getAttribute("layout-direction") == "vertical") {
+ // Try click end, which we assume is empty.
+ EventUtils.synthesizeMouse(
+ widget,
+ widgetRect.width / 2,
+ widgetRect.height - 5,
+ mouseEvent,
+ win
+ );
+ } else if (widget.matches(":dir(rtl)")) {
+ // Try click the left, which we assume is empty.
+ EventUtils.synthesizeMouse(
+ widget,
+ 5,
+ widgetRect.height / 2,
+ mouseEvent,
+ win
+ );
+ } else {
+ // Try click the right, which we assume is empty.
+ EventUtils.synthesizeMouse(
+ widget,
+ widgetRect.width - 5,
+ widgetRect.height / 2,
+ mouseEvent,
+ win
+ );
+ }
+}
+
+/**
+ * Click the specified widget item.
+ *
+ * @param {number} index - The index of the item to click.
+ * @param {object} mouseEvent - Properties for the click event.
+ */
+function clickWidgetItem(index, mouseEvent) {
+ EventUtils.synthesizeMouseAtCenter(
+ widget.items[index].element,
+ mouseEvent,
+ win
+ );
+}
+
+/**
+ * Trigger the select-all shortcut.
+ */
+function selectAllShortcut() {
+ EventUtils.synthesizeKey(
+ "a",
+ AppConstants.platform == "macosx" ? { metaKey: true } : { ctrlKey: true },
+ win
+ );
+}
+
+// If the widget is empty, it receives focus on itself.
+add_task(function test_empty_widget_focus() {
+ for (let model of selectionModels) {
+ reset({ model });
+
+ assertFocus({ element: before }, "Initial");
+
+ // Move focus forward.
+ stepFocus(true, { element: widget }, "Move into widget");
+ stepFocus(true, { element: after }, "Move out of widget");
+
+ // Move focus backward.
+ stepFocus(false, { element: widget }, "Move back to widget");
+ stepFocus(false, { element: before }, "Move back out of widget");
+
+ // Clicking also gives focus.
+ for (let shiftKey of [false, true]) {
+ for (let ctrlKey of [false, true]) {
+ info(
+ `Clicking empty widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`
+ );
+ clickWidgetEmptySpace({ shiftKey, ctrlKey });
+ assertFocus({ element: widget }, "Widget receives focus after click");
+ // Move focus for the next loop.
+ stepFocus(true, { element: after }, "Move back out");
+ }
+ }
+ }
+});
+
+/**
+ * Test that the initial focus is as expected.
+ *
+ * @param {string} model - The selection model to use.
+ * @param {Function} setup - A callback to set up the widget.
+ * @param {number} clickIndex - The index of an item to click.
+ * @param {object} expect - The expected states.
+ * @param {number} expect.focusIndex - The expected focus index.
+ * @param {number[]} expect.selection - The expected initial selection.
+ * @param {boolean} expect.selectFocus - Whether we expect the focused item to
+ * become selected.
+ */
+function subtest_initial_focus(model, setup, expect) {
+ let { focusIndex: index, selection, selectFocus } = expect;
+
+ reset({ model });
+ setup();
+
+ assertFocus({ element: before }, "Forward start");
+ assertSelection(selection, "Initial selection");
+
+ stepFocus(true, { index }, "Move onto selected item");
+ if (selectFocus) {
+ assertSelection([index], "Focus becomes selected");
+ } else {
+ assertSelection(selection, "Selection remains when focussing");
+ }
+ stepFocus(true, { element: after }, "Move out of widget");
+
+ // Reverse.
+ reset({ model });
+ after.focus();
+ setup();
+
+ assertFocus({ element: after }, "Reverse start");
+ assertSelection(selection, "Reverse start");
+
+ stepFocus(false, { index }, "Move backward to selected item");
+ if (selectFocus) {
+ assertSelection([index], "Focus becomes selected");
+ } else {
+ assertSelection(selection, "Selection remains when focussing");
+ }
+ stepFocus(false, { element: before }, "Move out of widget");
+
+ // With mouse click.
+ for (let shiftKey of [false, true]) {
+ for (let ctrlKey of [false, true]) {
+ info(`Clicking widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`);
+
+ reset({ model });
+ setup();
+
+ assertFocus({ element: before }, "Click empty start");
+ assertSelection(selection, "Click empty start");
+ clickWidgetEmptySpace({ ctrlKey, shiftKey });
+ assertFocus(
+ { index },
+ "Selected item becomes focused with click on empty"
+ );
+ if (selectFocus) {
+ assertSelection([index], "Focus becomes selected on click on empty");
+ } else {
+ assertSelection(selection, "Selection remains when click on empty");
+ }
+
+ // With mouse click on item focus moves to the clicked item instead.
+ for (let clickIndex of [
+ (index || widget.items.length) - 1,
+ index,
+ index + 1,
+ ]) {
+ reset({ model });
+ setup();
+
+ assertFocus({ element: before }, "Click first item start");
+ assertSelection(selection, "Click first item start");
+
+ clickWidgetItem(clickIndex, { shiftKey, ctrlKey });
+
+ if (
+ (shiftKey && ctrlKey) ||
+ ((shiftKey || ctrlKey) && (model == "focus" || model == "browse"))
+ ) {
+ // Both modifiers, or multi-selection not supported, so acts the
+ // same as clicking empty.
+ assertFocus(
+ { index },
+ "Selected item becomes focused with click on item"
+ );
+ if (selectFocus) {
+ assertSelection([index], "Focus becomes selected on click on item");
+ } else {
+ assertSelection(selection, "Selection remains when click on item");
+ }
+ } else {
+ assertFocus(
+ { index: clickIndex },
+ "Clicked item becomes focused with click on item"
+ );
+ let clickSelection;
+ if (ctrlKey) {
+ if (selection.includes(clickIndex)) {
+ // Toggle off clicked item.
+ clickSelection = selection.filter(index => index != clickIndex);
+ } else {
+ clickSelection = selection.concat([clickIndex]).sort();
+ }
+ } else if (shiftKey) {
+ // Range selection is always from 0, regardless of the selection
+ // before the click.
+ clickSelection = range(0, clickIndex + 1);
+ } else {
+ clickSelection = [clickIndex];
+ }
+ assertSelection(clickSelection, "Selection after click on item");
+ }
+ }
+ }
+ }
+}
+
+// If the widget has a selection when we move into it, the selected item is
+// focused.
+add_task(function test_initial_focus() {
+ for (let model of selectionModels) {
+ // With no initial selection.
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ },
+ { focusIndex: 0, selection: [], selectFocus: true }
+ );
+ // With call to selectSingleItem
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.selectSingleItem(2);
+ },
+ { focusIndex: 2, selection: [2], selectFocus: false }
+ );
+
+ // Using the setItemSelected API
+ if (model == "focus" || model == "browse") {
+ continue;
+ }
+
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.setItemSelected(2, true);
+ },
+ { focusIndex: 2, selection: [2], selectFocus: false }
+ );
+
+ // With multiple selected, we move focus to the first selected.
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.setItemSelected(2, true);
+ widget.setItemSelected(1, true);
+ },
+ { focusIndex: 1, selection: [1, 2], selectFocus: false }
+ );
+
+ // If we use both methods.
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.selectSingleItem(2, true);
+ widget.setItemSelected(1, true);
+ },
+ { focusIndex: 1, selection: [1, 2], selectFocus: false }
+ );
+
+ // If we call selectSingleItem and then unselect it, we act same as the
+ // default case.
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.selectSingleItem(2, true);
+ widget.setItemSelected(2, false);
+ },
+ { focusIndex: 0, selection: [], selectFocus: true }
+ );
+ }
+});
+
+// If selectSingleItem API method is called, we select an item and make it the
+// focus.
+add_task(function test_select_single_item_method() {
+ function subTestSelectSingleItem(outside, index) {
+ if (outside) {
+ stepFocus(true, { element: after }, "Moving focus to outside widget");
+ }
+
+ widget.selectSingleItem(index);
+ assertSelection([index], "Item becomes selected after call");
+
+ if (outside) {
+ assertFocus({ element: after }, "Focus remains outside the widget");
+ // Return.
+ stepFocus(false, { index }, "Focus moves to selected item on return");
+ assertSelection([index], "Item remains selected on return");
+ } else {
+ assertFocus({ index }, "Focus force moved to selected item");
+ }
+ }
+
+ for (let model of selectionModels) {
+ reset({ model });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+
+ stepFocus(true, { index: 0 }, "Move onto first item");
+
+ for (let outside of [false, true]) {
+ info(`Testing selecting item${outside ? " with focus outside" : ""}`);
+
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "Focus initially on first item");
+ assertSelection([0], "Initial selection on first item");
+
+ subTestSelectSingleItem(outside, 1);
+ // Selecting again.
+ subTestSelectSingleItem(outside, 1);
+
+ if (model == "focus") {
+ continue;
+ }
+
+ // Split focus from selection
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([1], "Second item remains selected");
+
+ // Select focused item.
+ subTestSelectSingleItem(outside, 2);
+
+ // Split again.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([2], "Third item remains selected");
+
+ // Selecting selected item will still move focus.
+ subTestSelectSingleItem(outside, 2);
+
+ // Split again.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "First item has focus");
+ assertSelection([2], "Third item remains selected");
+
+ // Select neither focused nor selected.
+ subTestSelectSingleItem(outside, 1);
+ }
+
+ // With mouse click to focus.
+ for (let shiftKey of [false, true]) {
+ for (let ctrlKey of [false, true]) {
+ info(`Clicking widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`);
+
+ reset({ model });
+ widget.addItems(0, ["First", "Second", "Third"]);
+ stepFocus(true, { index: 0 }, "Move onto first item");
+ assertSelection([0], "First item becomes selected");
+
+ // Move focus outside widget.
+ stepFocus(true, { element: after }, "Move focus outside");
+
+ // Select an item.
+ widget.selectSingleItem(1);
+
+ // Click empty space will focus the selected item.
+ clickWidgetEmptySpace({});
+ assertFocus(
+ { index: 1 },
+ "Selected item becomes focused with click on empty"
+ );
+ assertSelection(
+ [1],
+ "Second item remains selected with click on empty"
+ );
+
+ // With mouse click on selected item.
+ stepFocus(false, { element: before }, "Move focus outside");
+ widget.selectSingleItem(2);
+
+ clickWidgetItem(2, { shiftKey, ctrlKey });
+ assertFocus(
+ { index: 2 },
+ "Selected item becomes focused with click on selected"
+ );
+ if (ctrlKey && !shiftKey && model == "browse-multi") {
+ assertSelection(
+ [],
+ "Item becomes unselected with Ctrl+click on selected"
+ );
+ } else {
+ // NOTE: Shift+Click will select from the item to itself.
+ assertSelection(
+ [2],
+ "Selected item remains selected with click on selected"
+ );
+ }
+
+ // With mouse click on non-selected item.
+ stepFocus(false, { element: before }, "Move focus outside");
+ widget.selectSingleItem(1);
+
+ clickWidgetItem(2, { shiftKey, ctrlKey });
+ if (
+ (shiftKey && ctrlKey) ||
+ ((shiftKey || ctrlKey) && (model == "focus" || model == "browse"))
+ ) {
+ // Both modifiers, or multi-selection not supported, so acts the
+ // same as clicking empty.
+ assertFocus(
+ { index: 1 },
+ "Selected item becomes focused with click on item"
+ );
+ assertSelection(
+ [1],
+ "Selected item remains selected with click on item"
+ );
+ } else {
+ assertFocus(
+ { index: 2 },
+ "Third item becomes focused with click on item"
+ );
+ if (ctrlKey) {
+ assertSelection(
+ [1, 2],
+ "Third item becomes selected with Ctrl+click"
+ );
+ } else if (shiftKey) {
+ assertSelection(
+ [1, 2],
+ "Second to third item become selected with Shift+click"
+ );
+ } else {
+ assertSelection(
+ [2],
+ "Third item becomes selected with click on item"
+ );
+ }
+ }
+ }
+ }
+ }
+});
+
+// If setItemSelected API method is called, we set the selection state of an
+// item but do not change anything else.
+add_task(function test_set_item_selected_method() {
+ for (let model of selectionModels) {
+ reset({ model });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]);
+ stepFocus(true, { index: 0 }, "Initial focus on first item");
+ assertSelection([0], "Initial selection on first item");
+
+ if (model == "focus" || model == "browse") {
+ // This method always throws.
+ Assert.throws(
+ () => widget.setItemSelected(2, true),
+ /Widget does not support multi-selection/
+ );
+ // Even if it would not change the single selection state.
+ Assert.throws(
+ () => widget.setItemSelected(2, false),
+ /Widget does not support multi-selection/
+ );
+ Assert.throws(
+ () => widget.setItemSelected(0, true),
+ /Widget does not support multi-selection/
+ );
+ continue;
+ }
+
+ // Can select.
+ widget.setItemSelected(2, true);
+ assertFocus({ index: 0 }, "Same focus");
+ assertSelection([0, 2], "Item 2 becomes selected");
+
+ // And unselect.
+ widget.setItemSelected(0, false);
+ assertFocus({ index: 0 }, "Same focus");
+ assertSelection([2], "Item 0 is unselected");
+
+ // Does nothing extra if already selected/unselected.
+ widget.setItemSelected(2, true);
+ assertFocus({ index: 0 }, "Same focus");
+ assertSelection([2], "Same selected");
+
+ widget.setItemSelected(0, false);
+ assertFocus({ index: 0 }, "Same focus");
+ assertSelection([2], "Same selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+
+ // Select the focused item.
+ assertFocus({ index: 3 }, "Focus on item 3");
+ assertSelection([2], "Same selected");
+
+ widget.setItemSelected(3, true);
+ assertFocus({ index: 3 }, "Same focus");
+ assertSelection([2, 3], "Item 3 selected");
+
+ widget.setItemSelected(2, false);
+ assertFocus({ index: 3 }, "Same focus");
+ assertSelection([3], "Item 2 unselected");
+
+ // Can select none this way.
+ widget.setItemSelected(3, false);
+ assertFocus({ index: 3 }, "Same focus");
+ assertSelection([], "None selected");
+ }
+});
+
+/**
+ * Test navigation for the given direction.
+ *
+ * @param {string} model - The selection model to use.
+ * @param {string} direction - The layout direction of the widget.
+ * @param {object} keys - Navigation keys.
+ * @param {string} keys.forward - The key to move forward.
+ * @param {string} keys.backward - The key to move backward.
+ */
+function subtest_keyboard_navigation(model, direction, keys) {
+ let { forward: forwardKey, backward: backwardKey } = keys;
+ reset({ model, direction });
+ widget.addItems(0, ["First", "Second", "Third"]);
+
+ stepFocus(true, { index: 0 }, "Initially on first item");
+
+ // Without Ctrl, selection follows focus.
+
+ // Forward.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertFocus({ index: 1 }, "Forward to second item");
+ assertSelection([1], "Second item becomes selected on focus");
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertFocus({ index: 2 }, "Forward to third item");
+ assertSelection([2], "Third item becomes selected on focus");
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertFocus({ index: 2 }, "Forward at end remains on third item");
+ assertSelection([2], "Third item remains selected");
+
+ // Backward.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 1 }, "Backward to second item");
+ assertSelection([1], "Second item becomes selected on focus");
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 0 }, "Backward to first item");
+ assertSelection([0], "First item becomes selected on focus");
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 0 }, "Backward at end remains on first item");
+ assertSelection([0], "First item remains selected");
+
+ // End.
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ assertFocus({ index: 2 }, "Third becomes focused on End");
+ assertSelection([2], "Third becomes selected on End");
+ // Move to middle.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ assertFocus({ index: 2 }, "Third becomes focused on End from second");
+ assertSelection([2], "Third becomes selected on End from second");
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ assertFocus({ index: 2 }, "Third remains focused on End from third");
+ assertSelection([2], "Third becomes selected on End from third");
+
+ // Home.
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "First becomes focused on Home");
+ assertSelection([0], "First becomes selected on Home");
+ // Move to middle.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "First becomes focused on Home from second");
+ assertSelection([0], "First becomes selected on Home from second");
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "First remains focused on Home from first");
+ assertSelection([0], "First becomes selected on Home from first");
+
+ // With Ctrl key, selection does not follow focus.
+ if (model == "focus") {
+ // Disabled in "focus" model.
+ // Move to middle item.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertFocus({ index: 1 }, "Second item is focused");
+ assertFocus({ index: 1 }, "Second item is selected");
+
+ for (let key of [backwardKey, forwardKey, "KEY_Home", "KEY_End"]) {
+ for (let shiftKey of [false, true]) {
+ info(
+ `Pressing Ctrl+${
+ shiftKey ? "Shift+" : ""
+ }${key} on "focus" model widget`
+ );
+ EventUtils.synthesizeKey(key, { ctrlKey: true, shiftKey }, win);
+ assertFocus({ index: 1 }, "Second item is still focused");
+ assertSelection([1], "Second item is still selected");
+ }
+ }
+ } else {
+ EventUtils.synthesizeKey(forwardKey, { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Ctrl+Forward to second item");
+ assertSelection([0], "First item remains selected on Ctrl+Forward");
+
+ EventUtils.synthesizeKey(forwardKey, { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Ctrl+Forward to third item");
+ assertSelection([0], "First item remains selected on Ctrl+Forward");
+
+ EventUtils.synthesizeKey(backwardKey, { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Ctrl+Backward to second item");
+ assertSelection([0], "First item remains selected on Ctrl+Backward");
+
+ EventUtils.synthesizeKey(backwardKey, { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "Ctrl+Backward to first item");
+ assertSelection([0], "First item remains selected on Ctrl+Backward");
+
+ EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Ctrl+End to third item");
+ assertSelection([0], "First item remains selected on Ctrl+End");
+
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 1 }, "Backward to second item");
+ assertSelection([1], "Selection moves with focus when not pressing Ctrl");
+
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "Ctrl+Home to first item");
+ assertSelection([1], "Second item remains selected on Ctrl+Home");
+
+ // Does nothing if combined with Shift.
+ for (let key of [backwardKey, forwardKey, "KEY_Home", "KEY_End"]) {
+ info(`Pressing Ctrl+Shift+${key} on "${model}" model widget`);
+ EventUtils.synthesizeKey(key, { ctrlKey: true, shiftKey: true }, win);
+ assertFocus({ index: 0 }, "First item is still focused");
+ assertSelection([1], "Second item is still selected");
+ }
+
+ // Even if focus remains the same, the selection is still updated if we
+ // don't press Ctrl.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 0 }, "Focus remains on first item");
+ assertSelection(
+ [0],
+ "Selection moves to the first item since Ctrl was not pressed"
+ );
+ }
+}
+
+// Navigating with keyboard will move focus, and possibly selection.
+add_task(function test_keyboard_navigation() {
+ for (let model of selectionModels) {
+ subtest_keyboard_navigation(model, "top-to-bottom", {
+ forward: "KEY_ArrowDown",
+ backward: "KEY_ArrowUp",
+ });
+ subtest_keyboard_navigation(model, "right-to-left", {
+ forward: "KEY_ArrowLeft",
+ backward: "KEY_ArrowRight",
+ });
+ subtest_keyboard_navigation(model, "left-to-right", {
+ forward: "KEY_ArrowRight",
+ backward: "KEY_ArrowLeft",
+ });
+ }
+});
+
+/**
+ * A method to scroll the widget.
+ *
+ * @callback ScrollMethod
+ * @param {number} pos - The position/offset to scroll to.
+ */
+/**
+ * The position of an element, relative to the layout of the widget.
+ *
+ * @typedef {object} StartEndPositions
+ * @property {number} start - The starting position of the element in the
+ * direction of the widget's layout. The value should be a pixel offset
+ * from some fixed point, such that a higher value indicates an element
+ * further from the start of the widget.
+ * @property {number} end - The ending position of the element in the
+ * direction of the widget's layout. This should use the same fixed point
+ * as the start.
+ * @property {number} xStart - An X position in the client coordinates that
+ * points to the inside of the element, close to the starting corner. I.e.
+ * the block-start and inline-start.
+ * @property {number} yStart - A Y position in the client coordinates that
+ * points to the inside of the element, close to the starting corner.
+ * @property {number} xEnd - An X position in the client coordinates that
+ * points to the inside of the element, close to the ending corner. I.e.
+ * the block-end and inline-end.
+ * @property {number} yEnd - A Y position in the client coordinates that
+ * points to the inside of the element, close to the ending corner.
+ */
+/**
+ * A method to return the starting and ending positions of the bounding
+ * client rectangle of an element.
+ *
+ * @callback GetStartEndMethod
+ * @param {DOMRect} rect - The rectangle to get the positions of.
+ * @returns {object} positions
+/**
+ * Test page navigation for the given direction.
+ *
+ * @param {string} model - The selection model to use on the widget.
+ * @param {string} direction - The direction of the widget layout.
+ * @param {object} details - Details about the direction.
+ * @param {string} details.sizeName - The CSS style name that controls the
+ * widget size in the direction of widget layout.
+ * @param {string} details.forwardKey - The key to press to move forward one
+ * item.
+ * @param {string} details.backwardKey - The key to press to move backward
+ * one item.
+ * @param {ScrollMethod} details.scrollTo - A method to call to scroll the
+ * widget.
+ * @param {GetStartEndMethod} details.getStartEnd - A method to get the
+ * positioning of an element.
+ */
+function subtest_page_navigation(model, direction, details) {
+ let { sizeName, forwardKey, backwardKey, scrollTo, getStartEnd } = details;
+ function getStartEndBoundary(element) {
+ return getStartEnd(element.getBoundingClientRect());
+ }
+ function assertInView(expect, msg) {
+ let { first, firstClipped, last, lastClipped } = expect;
+ if (!firstClipped) {
+ firstClipped = 0;
+ }
+ if (!lastClipped) {
+ lastClipped = 0;
+ }
+ let { start: viewStart, end: viewEnd } = getStartEndBoundary(widget);
+ // The widget has a 1px border that should not contribute to the view
+ // size.
+ viewStart += 1;
+ viewEnd -= 1;
+ let firstStart = getStartEndBoundary(
+ widget.items[expect.first].element
+ ).start;
+ Assert.equal(
+ firstStart,
+ viewStart - firstClipped,
+ `Item ${first} should be at the start of the view (${viewStart}) clipped by ${firstClipped}: ${msg}`
+ );
+ if (expect.first > 0) {
+ Assert.lessOrEqual(
+ getStartEndBoundary(widget.items[expect.first - 1].element).end,
+ viewStart,
+ `Item ${expect.first - 1} should be out of view: ${msg}`
+ );
+ }
+ let lastEnd = getStartEndBoundary(widget.items[expect.last].element).end;
+ Assert.equal(
+ lastEnd,
+ viewEnd + lastClipped,
+ `Item ${last} should be at the end of the view (${viewEnd}) clipped by ${lastClipped}: ${msg}`
+ );
+ if (expect.last < widget.items.length - 1) {
+ Assert.greaterOrEqual(
+ getStartEndBoundary(widget.items[expect.last + 1].element).start,
+ viewEnd,
+ `Item ${expect.last + 1} should be out of view: ${msg}`
+ );
+ }
+ }
+ reset({ model, direction });
+ widget.addItems(
+ 0,
+ range(0, 70).map(i => `add-${i}`)
+ );
+ let { start: itemStart, end: itemEnd } = getStartEndBoundary(
+ widget.items[0].element
+ );
+ Assert.equal(itemEnd - itemStart, 30, "Expected item size");
+
+ assertInView({ first: 0, last: 19 }, "First 20 items in view");
+ stepFocus(true, { index: 0 }, "Move into widget");
+ assertSelection([0], "Fist item selected");
+ assertInView({ first: 0, last: 19 }, "First 20 items still in view");
+
+ // PageDown goes to the end of the current page.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 0, last: 19 }, "First 20 item still in view");
+ assertFocus({ index: 19 }, "Focus moves to end of the page");
+ assertSelection([19], "Selection at end of the page");
+
+ // Pressing forward key will scroll the next item into view.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertInView({ first: 1, last: 20 }, "Items 1 to 20 in view");
+ assertFocus({ index: 20 }, "Focus at end of the page");
+ assertSelection([20], "Selection at end of the page");
+
+ // Pressing backward will not change the view.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertInView({ first: 1, last: 20 }, "Items 1 to 20 still in view");
+ assertFocus({ index: 19 }, "Focus moves up to 19");
+ assertSelection([19], "Selection moves up to 19");
+
+ // PageDown goes to the end of the current page.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 1, last: 20 }, "Items 1 to 20 still in view");
+ assertFocus({ index: 20 }, "Focus moves to end of page");
+ assertSelection([20], "Selection moves to end of page");
+
+ // PageDown when already at the end of the page will move to the next
+ // page.
+ // The last index from the previous page (20) should still be visible at
+ // the top.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view");
+ assertFocus({ index: 39 }, "Focus moves to end of new page");
+ assertSelection([39], "Selection moves to end of new page");
+
+ // Another PageDown will do the same.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 39, last: 58 }, "Items 39 to 58 in view");
+ assertFocus({ index: 58 }, "Focus moves to end of new page");
+ assertSelection([58], "Selection moves to end of new page");
+
+ // Last PageDown will take us to the end.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 50, last: 69 }, "Last 20 items in view");
+ assertFocus({ index: 69 }, "Focus moves to end");
+ assertSelection([69], "Selection moves to end");
+
+ // Same thing in reverse with PageUp.
+ // PageUp goes to the start of the current page.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 50, last: 69 }, "Last 20 item still in view");
+ assertFocus({ index: 50 }, "Focus moves to start of the page");
+ assertSelection([50], "Selection at end of the page");
+
+ // Pressing backward will scroll the previous item into view.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertInView({ first: 49, last: 68 }, "Items 49 to 68 in view");
+ assertFocus({ index: 49 }, "Focus at start of the page");
+ assertSelection([49], "Selection at start of the page");
+
+ // Pressing forward will not change the view.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertInView({ first: 49, last: 68 }, "Items 49 to 68 still in view");
+ assertFocus({ index: 50 }, "Focus moves up to 50");
+ assertSelection([50], "Selection moves up to 50");
+
+ // PageUp goes to the start of the current page.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 49, last: 68 }, "Items 49 to 68 still in view");
+ assertFocus({ index: 49 }, "Focus moves to start of page");
+ assertSelection([49], "Selection moves to start of page");
+
+ // PageUp when already at the start of the page will move one page up.
+ // The first index from the previously shown page (49) should still be
+ // visible at the bottom.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 30, last: 49 }, "Items 30 to 49 in view");
+ assertFocus({ index: 30 }, "Focus moves to start of new page");
+ assertSelection([30], "Selection moves to start of new page");
+
+ // Another PageUp will do the same.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 11, last: 30 }, "Items 11 to 30 in view");
+ assertFocus({ index: 11 }, "Focus moves to start of new page");
+ assertSelection([11], "Selection moves to start of new page");
+
+ // Last PageUp will take us to the start.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 0, last: 19 }, "Items 0 to 19 in view");
+ assertFocus({ index: 0 }, "Focus moves to start");
+ assertSelection([0], "Selection moves to start");
+
+ // PageDown with focus above the view. Focus should move to the end of the
+ // visible page.
+ scrollTo(120);
+ assertInView({ first: 4, last: 23 }, "Items 4 to 23 in view");
+ assertFocus({ index: 0 }, "Focus remains above the view");
+ assertSelection([0], "Selection remains above the view");
+
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 4, last: 23 }, "Same items in view");
+ assertFocus({ index: 23 }, "Focus moves to the end of the visible page");
+ assertSelection([23], "Selection moves to the end of the visible page");
+
+ // PageDown with focus below the view. Focus should shift by one page,
+ // with the previous focus at the top of the page.
+ scrollTo(60);
+ assertInView({ first: 2, last: 21 }, "Items 2 to 21 in view");
+ assertFocus({ index: 23 }, "Focus remains below the view");
+ assertSelection([23], "Selection remains below the view");
+
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView(
+ { first: 23, last: 42 },
+ "View shifts by a page relative to focus"
+ );
+ assertFocus({ index: 42 }, "Focus moves to end of new page");
+ assertSelection([42], "Selection moves to end of new page");
+
+ // PageUp with focus below the view. Focus should move to the start of the
+ // visible page.
+ scrollTo(630);
+ assertInView({ first: 21, last: 40 }, "Items 21 to 40 in view");
+ assertFocus({ index: 42 }, "Focus remains below the view");
+ assertSelection([42], "Selection remains below the view");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 21, last: 40 }, "Same items in view");
+ assertFocus({ index: 21 }, "Focus moves to the start of the visible page");
+ assertSelection([21], "Selection moves to the start of the visible page");
+
+ // PageUp with focus above the view. Focus should shift by one page, with
+ // the previous focus at the bottom of the page.
+ scrollTo(750);
+ assertInView({ first: 25, last: 44 }, "Items 25 to 44 in view");
+ assertFocus({ index: 21 }, "Focus remains above the view");
+ assertSelection([21], "Selection remains above the view");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView(
+ { first: 2, last: 21 },
+ "View shifts by a page relative to focus"
+ );
+ assertFocus({ index: 2 }, "Focus moves to start of new page");
+ assertSelection([2], "Selection moves to start of new page");
+
+ // Test when view does not exactly fit items.
+ for (let sizeDiff of [0, 10, 15, 20]) {
+ info(`Reducing widget size by ${sizeDiff}px`);
+ widget.style[sizeName] = `${600 - sizeDiff}px`;
+
+ // When we reduce the size of the view by half an item or more, we
+ // reduce the page size from 20 to 19.
+ // NOTE: At each sizeDiff still fits strictly more than 19 items in its
+ // view.
+ let pageSize = sizeDiff < 15 ? 20 : 19;
+
+ // Make sure that Home and End keys scroll the view and clip the items
+ // as expected.
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertInView(
+ { first: 0, last: 19, lastClipped: sizeDiff },
+ `Start of view with last item clipped by ${sizeDiff}px`
+ );
+ assertFocus({ index: 0 }, "First item has focus");
+ assertSelection([0], "First item is selected");
+
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ assertInView(
+ { first: 50, firstClipped: sizeDiff, last: 69 },
+ `End of view with first item clipped by ${sizeDiff}px`
+ );
+ assertFocus({ index: 69 }, "Last item has focus");
+ assertSelection([69], "Last item is selected");
+
+ for (let lastClipped of [0, 10, 15, 20]) {
+ info(`Testing PageDown with last item clipped by ${lastClipped}px`);
+ // Across all sizeDiff and lastClipped values we still want the last
+ // item to be index 21 clipped by lastClipped.
+ // E.g. when sizeDiff is 10 and lastClipped is 10, then the scroll
+ // will be 60px and the first item will be index 2 with no clipping.
+ // But when the sizeDiff is 10 and the lastClipped is 20, then the
+ // scroll will be 50px and the first item will be index 1 with 20px
+ // clipping.
+ let scroll = 60 + sizeDiff - lastClipped;
+ scrollTo(scroll);
+ let first = Math.floor(scroll / 30);
+ let firstClipped = scroll % 30;
+ clickWidgetItem(3, {});
+ assertInView(
+ { first, firstClipped, last: 21, lastClipped },
+ `Last item 21 in view clipped by ${lastClipped}px`
+ );
+ assertFocus({ index: 3 }, "Focus on item 3");
+ assertSelection([3], "Selection on item 3");
+
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ let pageEnd;
+ if (lastClipped < 15) {
+ // The last item is more than half in view, so counts as part of the
+ // page.
+ // NOTE: Index of the first item is always "2", even if it was "1"
+ // before the scroll, because the view fits (19, 20] items.
+ assertInView(
+ { first: 2, firstClipped: sizeDiff, last: 21 },
+ "Scrolls down to fully include the last item 21"
+ );
+ pageEnd = 21;
+ } else {
+ // The last item is half or less in view, so only the one before it
+ // counts as being part of the page.
+ assertInView(
+ { first, firstClipped, last: 21, lastClipped },
+ "Same view"
+ );
+ pageEnd = 20;
+ }
+ assertFocus({ index: pageEnd }, "Focus moves to pageEnd");
+ assertSelection([pageEnd], "Selection moves to pageEnd");
+
+ // Reset scroll to test scrolling when the focus is already at the
+ // pageEnd.
+ scrollTo(scroll);
+ assertInView(
+ { first, firstClipped, last: 21, lastClipped },
+ `Last item 21 in view clipped by ${lastClipped}px`
+ );
+
+ // PageDown again will move by a page. The new end of the page will be
+ // scrolled just into view at the bottom.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ let newPageEnd = pageEnd + pageSize - 1;
+ // NOTE: If the previous pageEnd would fit mostly in view, then we
+ // expect the first item in the view to be this item. Otherwise, we
+ // expect it to be the one before, which will ensure the previous
+ // pageEnd is fully visible.
+ firstClipped = sizeDiff;
+ first = sizeDiff < 15 ? pageEnd : pageEnd - 1;
+ assertInView(
+ { first, firstClipped, last: newPageEnd },
+ "New page end scrolled into view, previous page end mostly visible"
+ );
+ assertFocus({ index: newPageEnd }, "Focus moves to end of new page");
+ assertSelection([newPageEnd], "Selection moves to end of new page");
+
+ // PageUp reverses the focus.
+ // We don't test the the view since that is handled lower down.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertFocus({ index: pageEnd }, "Focus returns to pageEnd");
+ assertSelection([pageEnd], "Selection returns to pageEnd");
+ }
+
+ for (let firstClipped of [0, 10, 15, 20]) {
+ // Across all sizeDiff and firstClipped values we still want the first
+ // item to be index 24 clipped by firstClipped.
+ // E.g. when sizeDiff is 10 and firstClipped is 10, then the scroll
+ // will be 730px and the last item will be index 44 with no clipping.
+ // But when the sizeDiff is 10 and the firstClipped is 0, then the
+ // scroll will be 720px and the last item will be index 43 with 10px
+ // clipping.
+ info(`Testing PageUp with first item clipped by ${firstClipped}px`);
+ scrollTo(720 + firstClipped);
+ let viewEnd = 720 + firstClipped + 600 - sizeDiff;
+ let last = Math.floor(viewEnd / 30);
+ let lastClipped = 30 - (viewEnd % 30);
+ clickWidgetItem(42, {});
+ assertInView(
+ { first: 24, firstClipped, last, lastClipped },
+ `First item 24 in view clipped by ${firstClipped}px`
+ );
+ assertFocus({ index: 42 }, "Focus on item 42");
+ assertSelection([42], "Selection on item 42");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ let pageStart;
+ if (firstClipped < 15) {
+ // The first item is more than half in view, so counts as part of
+ // the page.
+ // NOTE: Index of the last item is always "43", even if it was "44"
+ // before the scroll, because the view fits (19, 20] items.
+ assertInView(
+ { first: 24, last: 43, lastClipped: sizeDiff },
+ "Scrolls up to fully include the first item 24"
+ );
+ pageStart = 24;
+ } else {
+ // The first item is half or less in view, so only the one after it
+ // counts as being part of the page.
+ assertInView(
+ { first: 24, firstClipped, last, lastClipped },
+ "Same view"
+ );
+ pageStart = 25;
+ }
+ assertFocus({ index: pageStart }, "Focus moves to pageStart");
+ assertSelection([pageStart], "Selection moves to pageStart");
+
+ // Reset scroll.
+ scrollTo(720 + firstClipped);
+ assertInView(
+ { first: 24, firstClipped, last, lastClipped },
+ `First item 24 in view clipped by ${firstClipped}px`
+ );
+
+ // PageUp again will move by a page. The new start of the page will be
+ // scrolled just into view at the top.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ let newPageStart = pageStart - pageSize + 1;
+ // NOTE: If the previous pageStart would fit mostly in view, then we
+ // expect the last item in the view to be this item. Otherwise, we
+ // expect it to be the one after, which will ensure the previous
+ // pageStart is fully visible.
+ lastClipped = sizeDiff;
+ last = sizeDiff < 15 ? pageStart : pageStart + 1;
+ assertInView(
+ { first: newPageStart, last, lastClipped },
+ "New page end scrolled into view, previous page end mostly visible"
+ );
+ assertFocus({ index: newPageStart }, "Focus moves to start of new page");
+ assertSelection([newPageStart], "Selection moves to start of new page");
+
+ // PageDown reverses the focus.
+ // We don't test the the view since that is handled further up.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertFocus({ index: pageStart }, "Focus returns to pageStart");
+ assertSelection([pageStart], "Selection returns to pageStart");
+ }
+ }
+
+ // When widget only fits 1 visible item or less.
+ for (let size of [10, 20, 30, 45, 50]) {
+ info(`Resizing widget to ${size}px`);
+ widget.style[sizeName] = `${size}px`;
+
+ scrollTo(600);
+ // When the view size is less than the size of an item, we cannot always
+ // click the center of the item, so we need to click the start instead.
+ let { xStart, yStart } = getStartEndBoundary(widget.items[20].element);
+ EventUtils.synthesizeMouseAtPoint(xStart, yStart, {}, win);
+ let last = size > 30 ? 21 : 20;
+ let lastClipped = size > 30 ? 60 - size : 30 - size;
+ assertInView({ first: 20, last, lastClipped }, "Small number of items");
+ assertFocus({ index: 20 }, "Focus on item 20");
+ assertSelection([20], "Item 20 selected");
+
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ if (size <= 45) {
+ // Only 1 or 0 items fit on the page, so does nothing.
+ assertInView({ first: 20, last, lastClipped }, "Same view");
+ assertFocus({ index: 20 }, "Same focus");
+ assertSelection([20], "Same selected");
+ } else {
+ // 2 items fit visibly on the page, so acts as normal.
+ assertInView(
+ { first: 20, firstClipped: lastClipped, last: 21 },
+ "Last item scrolled into view"
+ );
+ assertFocus({ index: 21 }, "Focus increases by one");
+ assertSelection([21], "Selected moves to focus");
+ }
+
+ scrollTo(660 - size);
+ let { xEnd, yEnd } = getStartEndBoundary(widget.items[21].element);
+ EventUtils.synthesizeMouseAtPoint(xEnd, yEnd, {}, win);
+ let first = size > 30 ? 20 : 21;
+ let firstClipped = size > 30 ? 60 - size : 30 - size;
+ assertInView({ first, firstClipped, last: 21 }, "Small number of items");
+ assertFocus({ index: 21 }, "Focus on item 21");
+ assertSelection([21], "Item 21 selected");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ if (size <= 45) {
+ // Only 1 or 0 items fit on the page, so does nothing.
+ assertInView({ first, firstClipped, last: 21 }, "Same view");
+ assertFocus({ index: 21 }, "Same focus");
+ assertSelection([21], "Same selected");
+ } else {
+ // 2 items fit visibly on the page, so acts as normal.
+ assertInView(
+ { first: 20, last: 21, lastClipped: firstClipped },
+ "First item scrolled into view"
+ );
+ assertFocus({ index: 20 }, "Focus decreases by one");
+ assertSelection([20], "Selected moves to focus");
+ }
+ }
+ widget.style[sizeName] = null;
+
+ // Disable page navigation.
+ // This would be used when the item sizes or the page layout do not allow
+ // for page navigation, or if PageUp and PageDown should be used for something
+ // else.
+ widget.toggleAttribute("no-pages", true);
+
+ let gotKeys = [];
+ let keydownListener = event => {
+ gotKeys.push(event.key);
+ };
+ win.document.body.addEventListener("keydown", keydownListener);
+ scrollTo(600);
+ clickWidgetItem(20, {});
+ assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view");
+ assertFocus({ index: 20 }, "First item focused");
+ assertSelection([20], "First item selected");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 20, last: 39 }, "Same view");
+ assertFocus({ index: 20 }, "Same focus");
+ assertSelection([20], "Same selected");
+ Assert.deepEqual(gotKeys, ["PageUp"], "PageUp reaches document body");
+ gotKeys = [];
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 20, last: 39 }, "Same view");
+ assertFocus({ index: 20 }, "Same focus");
+ assertSelection([20], "Same selected");
+ Assert.deepEqual(gotKeys, ["PageDown"], "PageDown reaches document body");
+ gotKeys = [];
+
+ clickWidgetItem(39, {});
+ assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view");
+ assertFocus({ index: 39 }, "Last item focused");
+ assertSelection([39], "Last item selected");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 20, last: 39 }, "Same view");
+ assertFocus({ index: 39 }, "Same focus");
+ assertSelection([39], "Same selected");
+ Assert.deepEqual(gotKeys, ["PageUp"], "PageUp reaches document body");
+ gotKeys = [];
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 20, last: 39 }, "Same view");
+ assertFocus({ index: 39 }, "Same focus");
+ assertSelection([39], "Same selected");
+ Assert.deepEqual(gotKeys, ["PageDown"], "PageDown reaches document body");
+ gotKeys = [];
+
+ widget.removeAttribute("no-pages");
+
+ // With page navigation enabled key-presses do not reach the document body.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ Assert.deepEqual(gotKeys, [], "No key reaches document body");
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ Assert.deepEqual(gotKeys, [], "No key reaches document body");
+
+ win.document.body.removeEventListener("keydown", keydownListener);
+
+ // Test with modifiers.
+ for (let { shiftKey, ctrlKey } of [
+ { shiftKey: true, ctrlKey: true },
+ { shiftKey: false, ctrlKey: true },
+ { shiftKey: true, ctrlKey: false },
+ ]) {
+ info(
+ `Pressing ${ctrlKey ? "Ctrl+" : ""}${shiftKey ? "Shift+" : ""}PageUp/Down`
+ );
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertInView({ first: 0, last: 19 }, "First 20 items in view");
+ assertFocus({ index: 1 }, "Item 1 has focus");
+ assertSelection([1], "Item 1 is selected");
+
+ EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey, shiftKey }, win);
+ assertInView({ first: 0, last: 19 }, "Same view");
+ if (
+ (ctrlKey && shiftKey) ||
+ model == "focus" ||
+ (model == "browse" && shiftKey)
+ ) {
+ // Does nothing.
+ assertFocus({ index: 1 }, "Same focus");
+ assertSelection([1], "Same selected");
+ // Move focus to the end of the view.
+ clickWidgetItem(19, {});
+ assertFocus({ index: 19 }, "Focus at end of page");
+ assertSelection([19], "Selected at end of page");
+ } else {
+ assertFocus({ index: 19 }, "Focus moves to end of page");
+ if (ctrlKey) {
+ // Splits focus from selected.
+ assertSelection([1], "Same selected");
+ } else {
+ assertSelection(range(1, 19), "Range selection from 1 to 19");
+ }
+ }
+ // And again, with focus at the end of the page.
+ EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey, shiftKey }, win);
+ if (
+ (ctrlKey && shiftKey) ||
+ model == "focus" ||
+ (model == "browse" && shiftKey)
+ ) {
+ // Does nothing.
+ assertInView({ first: 0, last: 19 }, "Same view");
+ assertFocus({ index: 19 }, "Same focus");
+ assertSelection([19], "Same selected");
+ } else {
+ assertInView({ first: 19, last: 38 }, "View scrolls to focus");
+ assertFocus({ index: 38 }, "Focus moves to end of new page");
+ if (ctrlKey) {
+ // Splits focus from selected.
+ assertSelection([1], "Same selected");
+ } else {
+ assertSelection(range(1, 38), "Range selection from 1 to 38");
+ }
+ }
+
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertInView({ first: 50, last: 69 }, "Last 20 items in view");
+ assertFocus({ index: 68 }, "Item 68 has focus");
+ assertSelection([68], "Item 68 is selected");
+
+ EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey, shiftKey }, win);
+ assertInView({ first: 50, last: 69 }, "Same view");
+ if (
+ (ctrlKey && shiftKey) ||
+ model == "focus" ||
+ (model == "browse" && shiftKey)
+ ) {
+ // Does nothing.
+ assertFocus({ index: 68 }, "Same focus");
+ assertSelection([68], "Same selected");
+ // Move focus to the end of the view.
+ clickWidgetItem(50, {});
+ assertFocus({ index: 50 }, "Focus at start of page");
+ assertSelection([50], "Selected at start of page");
+ } else {
+ assertFocus({ index: 50 }, "Focus moves to start of page");
+ if (ctrlKey) {
+ // Splits focus from selected.
+ assertSelection([68], "Same selected");
+ } else {
+ assertSelection(range(50, 19), "Range selection from 50 to 68");
+ }
+ }
+ // And again, with focus at the start of the page.
+ EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey, shiftKey }, win);
+ if (
+ (ctrlKey && shiftKey) ||
+ model == "focus" ||
+ (model == "browse" && shiftKey)
+ ) {
+ // Does nothing.
+ assertInView({ first: 50, last: 69 }, "Same view");
+ assertFocus({ index: 50 }, "Same focus");
+ assertSelection([50], "Same selected");
+ } else {
+ assertInView({ first: 31, last: 50 }, "View scrolls to focus");
+ assertFocus({ index: 31 }, "Focus moves to start of new page");
+ if (ctrlKey) {
+ // Splits focus from selected.
+ assertSelection([68], "Same selected");
+ } else {
+ assertSelection(range(31, 38), "Range selection from 31 to 68");
+ }
+ }
+ }
+
+ // Does nothing with an empty widget.
+ reset({ model, direction });
+ stepFocus(true, { element: widget }, "Focus on empty widget");
+ assertState([], "Empty");
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertFocus({ element: widget }, "No change in focus");
+ assertState([], "Empty");
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertFocus({ element: widget }, "No change in focus");
+ assertState([], "Empty");
+}
+
+// Test that pressing PageUp or PageDown shifts the view according to the
+// visible items.
+add_task(function test_page_navigation() {
+ for (let model of selectionModels) {
+ subtest_page_navigation(model, "top-to-bottom", {
+ sizeName: "height",
+ forwardKey: "KEY_ArrowDown",
+ backwardKey: "KEY_ArrowUp",
+ scrollTo: pos => {
+ widget.scrollTop = pos;
+ },
+ getStartEnd: rect => {
+ return {
+ start: rect.top,
+ end: rect.bottom,
+ xStart: rect.right - 1,
+ xEnd: rect.left + 1,
+ yStart: rect.top + 1,
+ yEnd: rect.bottom - 1,
+ };
+ },
+ });
+ subtest_page_navigation(model, "right-to-left", {
+ sizeName: "width",
+ forwardKey: "KEY_ArrowLeft",
+ backwardKey: "KEY_ArrowRight",
+ scrollTo: pos => {
+ widget.scrollLeft = -pos;
+ },
+ getStartEnd: rect => {
+ return {
+ start: -rect.right,
+ end: -rect.left,
+ xStart: rect.right - 1,
+ xEnd: rect.left + 1,
+ yStart: rect.top + 1,
+ yEnd: rect.bottom - 1,
+ };
+ },
+ });
+ subtest_page_navigation(model, "left-to-right", {
+ sizeName: "width",
+ forwardKey: "KEY_ArrowRight",
+ backwardKey: "KEY_ArrowLeft",
+ scrollTo: pos => {
+ widget.scrollLeft = pos;
+ },
+ getStartEnd: rect => {
+ return {
+ start: rect.left,
+ end: rect.right,
+ xStart: rect.left + 1,
+ xEnd: rect.right - 1,
+ yStart: rect.top + 1,
+ yEnd: rect.bottom - 1,
+ };
+ },
+ });
+ }
+});
+
+// Using Space to select items.
+add_task(function test_space_selection() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+
+ stepFocus(true, { index: 0 }, "Move focus to first item");
+ assertSelection([0], "First item is selected");
+
+ // Selecting an already selected item does nothing.
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 0 }, "First item still has focus");
+ assertSelection([0], "First item is still selected");
+
+ if (model == "focus") {
+ // Just move to second item as set up for the loop.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ } else {
+ // Selecting a non-selected item will move selection to it.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([0], "First item is still selected");
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item becomes selected");
+ }
+
+ // Ctrl + Space will toggle the selection if multi-selection is supported.
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ if (model == "focus") {
+ // Did nothing.
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item is still selected");
+ } else if (model == "browse") {
+ // Did nothing.
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item is still selected");
+ // Make sure nothing happens when on a non-selected item as well.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([1], "Second item is still selected");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([1], "Second item is still selected");
+ // Restore the previous state.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ } else {
+ // Unselected the item.
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([], "Second item was un-selected");
+ // Toggle again.
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item was re-selected");
+
+ // Do on another index.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Fourth item has focus");
+ assertSelection([1], "Second item is still selected");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Fourth item still has focus");
+ assertSelection([1, 3], "Fourth item becomes selected as well");
+
+ // Move to third without clearing.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([1, 3], "Fourth and second item remain selected");
+
+ // Merge the two ranges together.
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([1, 2, 3], "Third item becomes selected");
+
+ // Shrink the range at the end.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Fourth item has focus");
+ assertSelection([1, 2, 3], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Fourth item still has focus");
+ assertSelection([1, 2], "Fourth item unselected");
+
+ // Shrink the range at the start.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([1, 2], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([2], "Second item unselected");
+
+ // No selection.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([2], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([], "Third item unselected");
+
+ // Using arrow keys without modifier will re-introduce a single selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight");
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([1], "Second item becomes selected");
+
+ // Grow range at the start.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "First item has focus");
+ assertSelection([1], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "First item still has focus");
+ assertSelection([0, 1], "First item becomes selected");
+
+ // Grow range at the end.
+ EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([0, 1], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([0, 1, 2], "Third item becomes selected");
+
+ // Split the range in half.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([0, 1, 2], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([0, 2], "Second item unselected");
+
+ // Pressing Space without a modifier clears the multi-selection.
+ EventUtils.synthesizeKey(" ", {}, win);
+ }
+
+ // Make sure we are in the expected shared state between models.
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([1], "Second item is selected");
+
+ // Shift + Space will do nothing.
+ for (let ctrlKey of [false, true]) {
+ info(`Pressing ${ctrlKey ? "Ctrl+" : ""}Shift+space on item`);
+ // On selected item.
+ EventUtils.synthesizeKey(" ", { ctrlKey, shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item is still selected");
+
+ if (model == "focus") {
+ continue;
+ }
+
+ // On non-selected item.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([1], "Second item is still selected");
+ EventUtils.synthesizeKey(" ", { ctrlKey, shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([1], "Second item is still selected");
+
+ // Restore for next loop.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([1], "Second item is selected");
+ }
+ }
+});
+
+// Clicking an item will focus and select it.
+add_task(function test_clicking_items() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ widget.addItems(0, [
+ "First",
+ "Second",
+ "Third",
+ "Fourth",
+ "Fifth",
+ "Sixth",
+ "Seventh",
+ "Eighth",
+ ]);
+
+ assertFocus({ element: before }, "Focus initially outside widget");
+ assertSelection([], "No initial selection");
+
+ // Focus moves into widget, onto the clicked item.
+ clickWidgetItem(1, {});
+ assertFocus({ index: 1 }, "Focus clicked second item");
+ assertSelection([1], "Selected clicked second item");
+
+ // Focus moves to different item.
+ clickWidgetItem(2, {});
+ assertFocus({ index: 2 }, "Focus clicked third item");
+ assertSelection([2], "Selected clicked third item");
+
+ // Click same item.
+ clickWidgetItem(2, {});
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Selected remains on third item");
+
+ // Focus outside widget, focus moves but selection remains.
+ before.focus();
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([2], "Selected remains on third item");
+
+ // Clicking same item will return focus to it.
+ clickWidgetItem(2, {});
+ assertFocus({ index: 2 }, "Focus returns to third item");
+ assertSelection([2], "Selected remains on third item");
+
+ // Do the same, but return to a different item.
+ before.focus();
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([2], "Selected remains on third item");
+
+ // Clicking same item will return focus to it.
+ clickWidgetItem(1, {});
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1], "Selected moves to second item");
+
+ // Switching to keyboard works.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([0], "Selected moves to first item");
+
+ // Returning to mouse works.
+ clickWidgetItem(1, {});
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1], "Selected moves to second item");
+
+ // Toggle selection with Ctrl+Click.
+ clickWidgetItem(3, { ctrlKey: true });
+ if (model == "browse-multi") {
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([1, 3], "Fourth item is selected");
+
+ clickWidgetItem(7, { ctrlKey: true });
+ assertFocus({ index: 7 }, "Focus moves to eighth item");
+ assertSelection([1, 3, 7], "Eighth item selected");
+
+ // Extend selection range by one after.
+ clickWidgetItem(4, { ctrlKey: true });
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([1, 3, 4, 7], "Fifth item is selected");
+
+ // Extend selection range by one before.
+ clickWidgetItem(6, { ctrlKey: true });
+ assertFocus({ index: 6 }, "Focus moves to seventh item");
+ assertSelection([1, 3, 4, 6, 7], "Seventh item is selected");
+
+ // Merge the two ranges together.
+ clickWidgetItem(5, { ctrlKey: true });
+ assertFocus({ index: 5 }, "Focus moves to sixth item");
+ assertSelection([1, 3, 4, 5, 6, 7], "Sixth item is selected");
+
+ // Reverse by unselecting.
+ clickWidgetItem(7, { ctrlKey: true });
+ assertFocus({ index: 7 }, "Focus moves to eight item");
+ assertSelection([1, 3, 4, 5, 6], "Eight item is unselected");
+
+ clickWidgetItem(3, { ctrlKey: true });
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([1, 4, 5, 6], "Fourth item is unselected");
+
+ // Split a range.
+ clickWidgetItem(5, { ctrlKey: true });
+ assertFocus({ index: 5 }, "Focus moves to sixth item");
+ assertSelection([1, 4, 6], "Sixth item is unselected");
+
+ clickWidgetItem(1, { ctrlKey: true });
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([4, 6], "Second item is unselected");
+
+ clickWidgetItem(6, { ctrlKey: true });
+ assertFocus({ index: 6 }, "Focus moves to seventh item");
+ assertSelection([4], "Seventh item is unselected");
+
+ // Can get zero-selection.
+ clickWidgetItem(4, { ctrlKey: true });
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([], "None selected");
+
+ // Get into the same state as the other case.
+ clickWidgetItem(1, { ctrlKey: true });
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1], "Second item is selected");
+ } else {
+ // No multi-selection, so does nothing.
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([1], "Second item remains selected");
+ }
+
+ // Ctrl+Shift+Click does nothing in all models.
+ clickWidgetItem(2, { ctrlKey: true, shiftKey: true });
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([1], "Second item remains selected");
+ }
+});
+
+add_task(function test_select_all() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]);
+
+ stepFocus(true, { index: 0 }, "Move focus to first item");
+ assertSelection([0], "First item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertFocus({ index: 1 }, "Focus on second item");
+ assertSelection([1], "Second item is selected");
+
+ selectAllShortcut();
+
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ if (model == "browse-multi") {
+ assertSelection([0, 1, 2, 3, 4], "All items are selected");
+ // Can insert a hole.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([0, 1, 2, 3, 4], "All items are still selected");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([0, 1, 3, 4], "Third item was unselected");
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Focus moves to the second item");
+ assertSelection([0, 1, 3, 4], "Selection remains the same");
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 1 }, "Focus remains on the second item");
+ assertSelection([1], "Only the second item is selected");
+ } else {
+ // Did nothing.
+ assertSelection([1], "Second item is still selected");
+ }
+
+ // Wrong platform modifier does nothing.
+ EventUtils.synthesizeKey(
+ "a",
+ AppConstants.platform == "macosx" ? { ctrlKey: true } : { metaKey: true },
+ win
+ );
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([1], "Second item still selected");
+ }
+});
+
+// Holding the shift key should perform a range selection if multi-selection is
+// supported by the model.
+add_task(function test_range_selection() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]);
+
+ stepFocus(true, { index: 0 }, "Move focus to first item");
+ assertSelection([0], "First item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+
+ assertFocus({ index: 2 }, "Focus on third item");
+ assertSelection([2], "Third item is selected");
+
+ // Nothing happens with Ctrl+Shift in any model.
+ EventUtils.synthesizeKey(
+ "KEY_ArrowLeft",
+ { shiftKey: true, ctrlKey: true },
+ win
+ );
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+ EventUtils.synthesizeKey(
+ "KEY_ArrowRight",
+ { shiftKey: true, ctrlKey: true },
+ win
+ );
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+
+ // With just Shift modifier.
+ if (model == "focus" || model == "browse") {
+ // No range selection.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+
+ clickWidgetItem(3, { shiftKey: true });
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+
+ clickWidgetItem(1, { shiftKey: true });
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+ continue;
+ }
+
+ // Range selection with shift key.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus on fourth item");
+ assertSelection([2, 3], "Select from third to fourth item");
+
+ // Reverse
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus on third item");
+ assertSelection([2], "Select from third to same item");
+
+ // Go back another step.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Focus on second item");
+ assertSelection([1, 2], "Third to second items are selected");
+
+ // Split focus from selection.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus on third item");
+ assertSelection([1, 2], "Third to second items are still selected");
+
+ // Back to range selection.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus on fourth item");
+ assertSelection([2, 3], "Third to fourth items are selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 4 }, "Focus on fifth item");
+ assertSelection([2, 3, 4], "Third to fifth items are selected");
+
+ // Moving without a modifier breaks the range.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertFocus({ index: 4 }, "Focus remains on final fifth item");
+ assertSelection([4], "Fifth item is selected");
+
+ // Again at the middle.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([3, 4], "Fifth to fourth items are selected");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([2], "Only third item is selected");
+
+ // Home and End also work.
+ EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win);
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([0, 1, 2], "Up to third item is selected");
+
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertFocus({ index: 4 }, "Focus moves to last item");
+ assertSelection([2, 3, 4], "Third item and above is selected");
+
+ // Ctrl+A breaks range selection sequence, so we no longer select around the
+ // third item when we go back to using Shift+Arrow.
+ selectAllShortcut();
+ assertFocus({ index: 4 }, "Focus remains on last item");
+ assertSelection([0, 1, 2, 3, 4], "All items are selected");
+ // The new shift+range will be from the focus index (the fifth item) rather
+ // than the third item used for the previous range.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([3, 4], "Fifth to fourth item are selected");
+
+ // Ctrl+Space also breaks range selection sequence.
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([3, 4], "Range selection remains");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "Focus still on first item");
+ assertSelection([0, 3, 4], "First item added to selection");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([0, 1], "First to second item are selected");
+
+ // Same when unselecting.
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([0], "Second item is no longer selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([1, 2], "Second to third item are selected");
+
+ // Same when using setItemSelected API
+ widget.setItemSelected(4, true);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([1, 2, 4], "Fifth item becomes selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([2, 3], "Third to fourth item are selected");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([2, 3, 4], "Third to fifth item are selected");
+
+ widget.setItemSelected(3, false);
+ assertFocus({ index: 4 }, "Focus remains on fifth item");
+ assertSelection([2, 4], "Fourth item becomes unselected");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([3, 4], "Fifth to fourth item are selected");
+
+ // Even when the selection state does not change.
+ widget.setItemSelected(3, true);
+ assertFocus({ index: 3 }, "Focus remains on fourth item");
+ assertSelection([3, 4], "Same selection");
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([2, 3], "Fourth to third item are selected");
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1, 2, 3], "Fourth to second item are selected");
+
+ widget.setItemSelected(4, false);
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([1, 2, 3], "Same selection");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([1, 2], "Second to third item are selected");
+
+ // Same when selecting with space (no modifier).
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Third item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([2, 3], "Third to fourth item are selected");
+
+ // Same when using the selectSingleItem API.
+ widget.selectSingleItem(1);
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1], "Second item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([0, 1], "Second to first item are selected");
+
+ // If focus goes out and we return, the range origin is remembered.
+ stepFocus(true, { element: after }, "Move focus outside the widget");
+ assertSelection([0, 1], "Second to first item are still selected");
+ stepFocus(false, { index: 0 }, "Focus returns to the widget");
+ assertSelection([0, 1], "Second to first item are still selected");
+
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertFocus({ index: 4 }, "Focus moves to last item");
+ assertSelection([1, 2, 3, 4], "Second to fifth item are selected");
+
+ // Clicking empty space does not clear it.
+ clickWidgetEmptySpace({});
+ assertFocus({ index: 4 }, "Focus remains on last item");
+ assertSelection([1, 2, 3, 4], "Second to fifth item are still selected");
+
+ // Shift+Click an item will use the same range origin established by the
+ // current selection.
+ clickWidgetItem(3, { shiftKey: true });
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([1, 2, 3], "Second to fourth item are selected");
+
+ // Clicking without the modifier breaks the range selection sequence.
+ clickWidgetItem(2, {});
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([2], "Only the third item is selected");
+
+ // Shift click will select between the third item and the the clicked item.
+ clickWidgetItem(4, { shiftKey: true });
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([2, 3, 4], "Third to fifth item are selected");
+
+ // Reverse direction about the same point.
+ clickWidgetItem(0, { shiftKey: true });
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([0, 1, 2], "Third to first item are selected");
+
+ // Ctrl+Click breaks the range selection sequence.
+ clickWidgetItem(1, { ctrlKey: true });
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([0, 2], "Second item is unselected");
+ clickWidgetItem(3, { shiftKey: true });
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([1, 2, 3], "Second to fourth item are selected");
+
+ // Same when Ctrl+Click on non-selected.
+ clickWidgetItem(4, { ctrlKey: true });
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([1, 2, 3, 4], "Fifth item is selected");
+ clickWidgetItem(3, { shiftKey: true });
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([3, 4], "Fifth to fourth item are selected");
+
+ // Selecting-all also breaks range selection sequence.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([3, 4], "Same selection");
+
+ selectAllShortcut();
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([0, 1, 2, 3, 4], "All items selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1, 2], "Third to second item selected");
+ }
+});
+
+// Adding items to widget with existing items, should not change the selected
+// item.
+add_task(function test_add_items_to_nonempty() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ assertState([], "Empty");
+
+ widget.addItems(0, ["0-add"]);
+ stepFocus(true, { index: 0, text: "0-add" }, "Move focus to 0-add");
+ assertState([{ text: "0-add", selected: true, focused: true }], "One item");
+
+ // Add item after.
+ widget.addItems(1, ["1-add"]);
+ assertState(
+ [{ text: "0-add", selected: true, focused: true }, { text: "1-add" }],
+ "0-add still focused and selected"
+ );
+
+ // Add item before. 0-add moves to index 1.
+ widget.addItems(0, ["2-add"]);
+ assertState(
+ [
+ { text: "2-add" },
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ ],
+ "0-add still focused and selected"
+ );
+
+ // Add several before.
+ widget.addItems(1, ["3-add", "4-add", "5-add"]);
+ assertState(
+ [
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ ],
+ "0-add still focused and selected"
+ );
+
+ // Key navigation works.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertState(
+ [
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "0-add" },
+ { text: "1-add" },
+ ],
+ "5-add becomes focused and selected"
+ );
+
+ // With focus outside the widget.
+ reset({ model, direction: "right-to-left" });
+ assertState([], "Empty");
+
+ widget.addItems(0, ["0-add"]);
+ stepFocus(true, { index: 0 }, "Move focus to 0-add");
+ assertState([{ text: "0-add", selected: true, focused: true }], "One item");
+
+ stepFocus(true, { element: after }, "Move focus to after widget");
+ // Add after.
+ widget.addItems(1, ["1-add", "2-add"]);
+ assertState(
+ [{ text: "0-add", selected: true }, { text: "1-add" }, { text: "2-add" }],
+ "0-add still selected but not focused"
+ );
+ stepFocus(false, { index: 0 }, "Move focus back to 0-add");
+ assertState(
+ [
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ ],
+ "0-add selected and focused"
+ );
+
+ stepFocus(false, { element: before }, "Move focus to before widget");
+ // Add before.
+ widget.addItems(0, ["3-add", "4-add"]);
+ assertState(
+ [
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ ],
+ "0-add selected but not focused"
+ );
+ stepFocus(true, { index: 2 }, "Move focus back to 0-add");
+ assertState(
+ [
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ ],
+ "0-add selected and focused"
+ );
+
+ // With focus separate from selection.
+ if (model == "focus") {
+ continue;
+ }
+
+ reset({ model, direction: "right-to-left" });
+ assertState([], "Empty");
+
+ widget.addItems(0, ["0-add", "1-add", "2-add"]);
+ assertState(
+ [{ text: "0-add" }, { text: "1-add" }, { text: "2-add" }],
+ "None selected or focused"
+ );
+ stepFocus(true, { index: 0 }, "Move focus to 0-add");
+
+ // With selection after focus.
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add", selected: true },
+ ],
+ "Selection after focus"
+ );
+
+ // Add after both selection and focus.
+ widget.addItems(3, ["3-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ ],
+ "Same items selected and focused"
+ );
+
+ // Add before both selection and focus.
+ widget.addItems(1, ["4-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ ],
+ "Same items selected and focused"
+ );
+
+ // Before selection, after focus.
+ widget.addItems(3, ["5-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "1-add", focused: true },
+ { text: "5-add" },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ ],
+ "Same items selected and focused"
+ );
+
+ // Swap selection to be before focus.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add", selected: true },
+ { text: "1-add" },
+ { text: "5-add", focused: true },
+ { text: "2-add" },
+ { text: "3-add" },
+ ],
+ "Selection before focus"
+ );
+
+ // After selection, before focus.
+ widget.addItems(2, ["6-add", "7-add", "8-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add", selected: true },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "1-add" },
+ { text: "5-add", focused: true },
+ { text: "2-add" },
+ { text: "3-add" },
+ ],
+ "Same items selected and focused"
+ );
+
+ // With multi-selection.
+ if (model == "browse") {
+ continue;
+ }
+
+ reset({ model, direction: "right-to-left" });
+ assertState([], "Empty");
+
+ widget.addItems(0, ["0-add", "1-add", "2-add"]);
+ assertState(
+ [{ text: "0-add" }, { text: "1-add" }, { text: "2-add" }],
+ "None selected"
+ );
+ stepFocus(true, { index: 0 }, "Move focus to 0-add");
+
+ // Select all.
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ ],
+ "All selected"
+ );
+
+ // Add after all.
+ widget.addItems(3, ["3-add", "4-add", "5-add"]);
+ assertState(
+ [
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Same range selected"
+ );
+
+ // Can continue shift selection to newly added item
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Range extended to new item"
+ );
+
+ // Add before all.
+ widget.addItems(0, ["6-add", "7-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Same range selected"
+ );
+
+ // Can continue shift selection about the "0-add" item.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Range extended backward"
+ );
+
+ // And change direction of shift selection range.
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "6-add", focused: true },
+ { text: "7-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Focus moves to first item"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add", selected: true, focused: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Selection pivoted about 0-add"
+ );
+
+ // Add items in the middle of the range. Selection in the range is not added
+ // initially.
+ widget.addItems(2, ["8-add", "9-add", "10-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add", selected: true, focused: true },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Backward range selection with single gap"
+ );
+
+ // But continuing the shift selection will fill in the holes again.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add", selected: true, focused: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Backward range selection with no gap"
+ );
+
+ // Do the same but with a selection range moving forward and two holes.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "10-add", selected: true, focused: true },
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Forward range selection"
+ );
+
+ widget.addItems(3, ["11-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add" },
+ { text: "9-add", selected: true },
+ { text: "10-add", selected: true, focused: true },
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Forward range selection with one gap"
+ );
+
+ widget.addItems(5, ["12-add", "13-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add" },
+ { text: "9-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add" },
+ { text: "10-add", selected: true, focused: true },
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Forward range selection with two gaps"
+ );
+
+ // Continuing the shift selection will fill in the holes.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "13-add", selected: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Extended range forward with no gaps"
+ );
+
+ // With multi-selection via toggling.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "13-add", selected: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Selected 2-add"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "13-add", focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "De-selected 13-add"
+ );
+
+ widget.addItems(6, ["14-add", "15-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add" },
+ { text: "15-add" },
+ { text: "13-add", focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Same selected items"
+ );
+
+ widget.addItems(3, ["16-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "16-add" },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add" },
+ { text: "15-add" },
+ { text: "13-add", focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Same selected items"
+ );
+
+ // With select-all
+ selectAllShortcut();
+ assertState(
+ [
+ { text: "6-add", selected: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "16-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add", selected: true },
+ { text: "15-add", selected: true },
+ { text: "13-add", selected: true, focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "4-add", selected: true },
+ { text: "5-add", selected: true },
+ ],
+ "All items selected"
+ );
+
+ // Added items do not become selected.
+ widget.addItems(4, ["17-add", "18-add"]);
+ assertState(
+ [
+ { text: "6-add", selected: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "16-add", selected: true },
+ { text: "17-add" },
+ { text: "18-add" },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add", selected: true },
+ { text: "15-add", selected: true },
+ { text: "13-add", selected: true, focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "4-add", selected: true },
+ { text: "5-add", selected: true },
+ ],
+ "Added items not selected"
+ );
+
+ // Added items will be selected if we select-all again.
+ selectAllShortcut();
+ assertState(
+ [
+ { text: "6-add", selected: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "16-add", selected: true },
+ { text: "17-add", selected: true },
+ { text: "18-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add", selected: true },
+ { text: "15-add", selected: true },
+ { text: "13-add", selected: true, focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "4-add", selected: true },
+ { text: "5-add", selected: true },
+ ],
+ "All items selected"
+ );
+ }
+});
+
+/**
+ * Test that pressing a key on a non-empty widget that has focus on itself will
+ * move to the expected index.
+ *
+ * @param {object} initialState - The initial state of the widget to set up.
+ * @param {string} initialState.model - The selection model to use.
+ * @param {string} initialState.direction - The layout direction of the widget.
+ * @param {number} initialState.numItems - The number of items in the widget.
+ * @param {Function} [initialState.scroll] - A method to call to scroll the
+ * widget.
+ * @param {string} key - The key to press once the widget is set up.
+ * @param {number} index - The expected index for the item that will receive
+ * focus after the key press.
+ */
+function subtest_keypress_on_focused_widget(initialState, key, index) {
+ let { model, direction, numItems, scroll } = initialState;
+ for (let ctrlKey of [false, true]) {
+ for (let shiftKey of [false, true]) {
+ info(
+ `Adding items to empty ${direction} widget and then pressing ${
+ ctrlKey ? "Ctrl+" : ""
+ }${shiftKey ? "Shift+" : ""}${key}`
+ );
+ reset({ model, direction });
+
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(
+ 0,
+ range(0, numItems).map(i => `add-${i}`)
+ );
+ scroll?.();
+
+ assertFocus(
+ { element: widget },
+ "Focus remains on the widget after adding items"
+ );
+ assertSelection([], "No items are selected yet");
+
+ EventUtils.synthesizeKey(key, { ctrlKey, shiftKey }, win);
+ if (
+ (ctrlKey && shiftKey) ||
+ (model == "browse" && shiftKey) ||
+ (model == "focus" && (ctrlKey || shiftKey))
+ ) {
+ // Does nothing.
+ assertFocus({ element: widget }, "Focus remains on widget");
+ assertSelection([], "No change in selection");
+ continue;
+ }
+
+ assertFocus({ index }, `Focus moves to ${index} after ${key}`);
+ if (ctrlKey) {
+ assertSelection([], `No selection if pressing Ctrl+${key}`);
+ } else if (shiftKey) {
+ assertSelection(
+ range(0, index + 1),
+ `Range selection from 0 to ${index} if pressing Shift+${key}`
+ );
+ } else {
+ assertSelection([index], `Item selected after ${key}`);
+ }
+ }
+ }
+}
+
+// If items are added to an empty widget that has focus, nothing happens
+// initially. Arrow keys will focus the first item.
+add_task(function test_add_items_to_empty_with_focus() {
+ for (let model of selectionModels) {
+ // Step navigation always takes us to the first item.
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_ArrowUp",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_ArrowDown",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "right-to-left", numItems: 3 },
+ "KEY_ArrowRight",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "right-to-left", numItems: 3 },
+ "KEY_ArrowLeft",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "left-to-right", numItems: 3 },
+ "KEY_ArrowLeft",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "left-to-right", numItems: 3 },
+ "KEY_ArrowRight",
+ 0
+ );
+ // Home also takes us to the first item.
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_Home",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "right-to-left", numItems: 3 },
+ "KEY_Home",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "left-to-right", numItems: 3 },
+ "KEY_Home",
+ 0
+ );
+ // End takes us to the last item.
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_End",
+ 2
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "right-to-left", numItems: 3 },
+ "KEY_End",
+ 2
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "left-to-right", numItems: 3 },
+ "KEY_End",
+ 2
+ );
+ // PageUp and PageDown take us to the start or end of the visible page.
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_PageUp",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_PageDown",
+ 2
+ );
+ subtest_keypress_on_focused_widget(
+ {
+ model,
+ direction: "top-to-bottom",
+ numItems: 30,
+ scroll: () => {
+ widget.scrollTop = 270;
+ },
+ },
+ "KEY_PageUp",
+ 9
+ );
+ subtest_keypress_on_focused_widget(
+ {
+ model,
+ direction: "top-to-bottom",
+ numItems: 30,
+ scroll: () => {
+ widget.scrollTop = 60;
+ },
+ },
+ "KEY_PageDown",
+ 21
+ );
+
+ // Arrow keys in other directions do nothing.
+ reset({ model, direction: "top-to-bottom" });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second"]);
+ for (let key of ["KEY_ArrowRight", "KEY_ArrowLeft"]) {
+ EventUtils.synthesizeKey(key, {}, win);
+ assertFocus({ element: widget }, `Focus remains on widget after ${key}`);
+ assertSelection([], `No items become selected after ${key}`);
+ }
+
+ reset({ model, direction: "right-to-left" });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second"]);
+ for (let key of ["KEY_ArrowUp", "KEY_ArrowDown"]) {
+ EventUtils.synthesizeKey(key, {}, win);
+ assertFocus({ element: widget }, `Focus remains on widget after ${key}`);
+ assertSelection([], `No items become selected after ${key}`);
+ }
+
+ // Pressing Space does nothing.
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second"]);
+ for (let ctrlKey of [false, true]) {
+ for (let shiftKey of [false, true]) {
+ info(
+ `Pressing ${ctrlKey ? "Ctrl+" : ""}${shiftKey ? "Shift+" : ""}Space`
+ );
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ element: widget }, "Focus remains on widget after Space");
+ assertSelection([], "No items become selected after Space");
+ }
+ }
+
+ // Selecting all
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second", "Third"]);
+
+ selectAllShortcut();
+ assertFocus({ element: widget }, "Focus remains on the widget");
+ if (model == "browse-multi") {
+ assertSelection([0, 1, 2], "All items selected");
+ } else {
+ assertSelection([], "still no selection");
+ }
+
+ // Adding and then removing items does not set focus.
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.removeItems(2, 2);
+ assertState(
+ [{ text: "First" }, { text: "Second" }],
+ "No item focused or selected"
+ );
+ assertFocus({ element: widget }, "Focus remains on the widget");
+ widget.removeItems(0, 1);
+ assertState([{ text: "Second" }], "No item focused or selected");
+ assertFocus({ element: widget }, "Focus remains on the widget");
+
+ // Moving items does not set focus.
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.moveItems(1, 0, 2, false);
+ assertState(
+ [
+ { text: "Second" },
+ { text: "Third" },
+ { text: "First" },
+ { text: "Fourth" },
+ ],
+ "No item focused or selected"
+ );
+ assertFocus({ element: widget }, "Focus remains on the widget");
+ widget.moveItems(0, 1, 3, true);
+ assertState(
+ [
+ { text: "Fourth" },
+ { text: "Second" },
+ { text: "Third" },
+ { text: "First" },
+ ],
+ "No item focused or selected"
+ );
+ assertFocus({ element: widget }, "Focus remains on the widget");
+
+ // This does not effect clicking.
+ // NOTE: case where widget does not initially have focus on clicking is
+ // handled by test_initial_no_select_focus
+ for (let ctrlKey of [false, true]) {
+ for (let shiftKey of [false, true]) {
+ info(
+ `Adding items to empty focused widget and then ${
+ ctrlKey ? "Ctrl+" : ""
+ }${shiftKey ? "Shift+" : ""}Click`
+ );
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second", "Third"]);
+
+ // Clicking empty space does nothing.
+ clickWidgetEmptySpace({ ctrlKey, shiftKey });
+ assertFocus({ element: widget }, "Focus remains on widget");
+ assertSelection([], "No item selected");
+
+ // Clicking an item can change focus and selection.
+ clickWidgetItem(1, { ctrlKey, shiftKey });
+ if (
+ (ctrlKey && shiftKey) ||
+ ((model == "focus" || model == "browse") && (ctrlKey || shiftKey))
+ ) {
+ assertFocus({ element: widget }, "Focus remains on widget");
+ assertSelection([], "No selection");
+ continue;
+ }
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ if (shiftKey) {
+ assertSelection([0, 1], "First and second item selected");
+ } else {
+ assertSelection([1], "Second item selected");
+ }
+ }
+ }
+ }
+});
+
+// Removing items from the widget with existing items, may change focus or
+// selection if the corresponding item was removed.
+add_task(function test_remove_items_nonempty() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+
+ widget.addItems(0, ["0-add", "1-add", "2-add", "3-add", "4-add", "5-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "No initial focus or selection"
+ );
+
+ clickWidgetItem(2, {});
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add focused and selected"
+ );
+
+ // Remove one after.
+ widget.removeItems(3, 1);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add still focused and selected"
+ );
+
+ // Remove one before.
+ widget.removeItems(0, 1);
+ assertState(
+ [
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add still focused and selected"
+ );
+
+ widget.addItems(0, ["6-add", "7-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add still focused and selected"
+ );
+
+ // Remove several before.
+ widget.removeItems(1, 2);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add still focused and selected"
+ );
+
+ // Remove selected and focused. Focus should move to the next item.
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "4-add", selected: true, focused: true },
+ { text: "5-add" },
+ ],
+ "Selection and focus move to 4-add"
+ );
+
+ widget.addItems(0, ["8-add"]);
+ widget.addItems(3, ["9-add", "10-add"]);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "4-add", selected: true, focused: true },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "5-add" },
+ ],
+ "Selection and focus still on 4-add"
+ );
+
+ // Remove selected and focused, not at boundary.
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "10-add", selected: true, focused: true },
+ { text: "5-add" },
+ ],
+ "Selection and focus move to 10-add"
+ );
+
+ // Remove last item whilst it has focus. Focus should move to the new last
+ // item.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "10-add" },
+ { text: "5-add", selected: true, focused: true },
+ ],
+ "Last item is focused and selected"
+ );
+
+ widget.removeItems(2, 1);
+ assertState(
+ [{ text: "8-add" }, { text: "10-add", selected: true, focused: true }],
+ "New last item is focused and selected"
+ );
+
+ // Delete focused whilst outside widget.
+ widget.addItems(2, ["11-add"]);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "10-add", selected: true, focused: true },
+ { text: "11-add" },
+ ],
+ "10-add is focused and selected"
+ );
+ stepFocus(false, { element: before });
+
+ widget.removeItems(1, 1);
+ assertFocus({ element: before }, "Focus remains outside widget");
+ assertState(
+ [{ text: "8-add" }, { text: "11-add", selected: true }],
+ "11-add becomes selected"
+ );
+
+ stepFocus(true, { index: 1 }, "11-add becomes focused");
+ assertState(
+ [{ text: "8-add" }, { text: "11-add", selected: true, focused: true }],
+ "11-add is selected"
+ );
+
+ // With focus separate from selected.
+ if (model == "focus") {
+ continue;
+ }
+
+ // Move selection to be before focus.
+ widget.addItems(2, ["12-add", "13-add", "14-add"]);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "11-add", selected: true, focused: true },
+ { text: "12-add" },
+ { text: "13-add" },
+ { text: "14-add" },
+ ],
+ "11-add is selected and focused"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add", focused: true },
+ { text: "13-add" },
+ { text: "14-add" },
+ ],
+ "Selection before focus"
+ );
+
+ // Remove focused, but not selected.
+ widget.removeItems(2, 1);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "11-add", selected: true },
+ { text: "13-add", focused: true },
+ { text: "14-add" },
+ ],
+ "Focus moves to 13-add, but selection is the same"
+ );
+
+ // Remove focused and selected.
+ widget.removeItems(1, 2);
+ assertState(
+ [{ text: "8-add" }, { text: "14-add", selected: true, focused: true }],
+ "Focus moves to 14-add and becomes selected"
+ );
+
+ // Restore selection before focus.
+ widget.addItems(0, ["15-add"]);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "8-add" },
+ { text: "14-add", selected: true, focused: true },
+ ],
+ "14-add has focus and selection"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "8-add", selected: true, focused: true },
+ { text: "14-add" },
+ ],
+ "8-add is focused and selected"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "8-add", selected: true },
+ { text: "14-add", focused: true },
+ ],
+ "Selection before focus again"
+ );
+
+ // Remove selected, but not focused.
+ widget.removeItems(1, 1);
+ assertState(
+ [{ text: "15-add" }, { text: "14-add", focused: true }],
+ "14-add still has focus, but selection is lost"
+ );
+
+ // Move selection to be after focus.
+ widget.addItems(1, ["16-add", "17-add"]);
+ widget.addItems(4, ["18-add"]);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "16-add" },
+ { text: "17-add" },
+ { text: "14-add", focused: true },
+ { text: "18-add" },
+ ],
+ "Still no selection"
+ );
+ // Select focused.
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 3 }, "14-add has focus");
+ assertSelection([3], "14-add is selected");
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "16-add" },
+ { text: "17-add" },
+ { text: "14-add", selected: true, focused: true },
+ { text: "18-add" },
+ ],
+ "14-add is selected and focused"
+ );
+ // Move focus.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "16-add", focused: true },
+ { text: "17-add" },
+ { text: "14-add", selected: true },
+ { text: "18-add" },
+ ],
+ "Selection after focus"
+ );
+
+ // Remove focused, but not selected.
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "17-add", focused: true },
+ { text: "14-add", selected: true },
+ { text: "18-add" },
+ ],
+ "Focus moves to 17-add, selection stays on 14-add"
+ );
+
+ // Remove focused and selected.
+ widget.removeItems(1, 2);
+ assertState(
+ [{ text: "15-add" }, { text: "18-add", selected: true, focused: true }],
+ "Focus and selection moves to 18-add"
+ );
+
+ // Restore selection after focus.
+ widget.addItems(2, ["19-add", "20-add"]);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "18-add", selected: true, focused: true },
+ { text: "19-add" },
+ { text: "20-add" },
+ ],
+ "Still no selection"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "18-add" },
+ { text: "19-add", selected: true, focused: true },
+ { text: "20-add" },
+ ],
+ "19-add focused and selected"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "18-add", focused: true },
+ { text: "19-add", selected: true },
+ { text: "20-add" },
+ ],
+ "Selection after focus again"
+ );
+
+ // Remove selected, but not focused.
+ widget.removeItems(2, 2);
+ assertState(
+ [{ text: "15-add" }, { text: "18-add", focused: true }],
+ "18-add still has focus, but selection is lost"
+ );
+
+ // With multi-selection
+ if (model == "browse") {
+ continue;
+ }
+
+ widget.addItems(0, ["21-add", "22-add", "23-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "22-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", focused: true },
+ ],
+ "18-add focused, no selection yet"
+ );
+ widget.addItems(5, [
+ "24-add",
+ "25-add",
+ "26-add",
+ "27-add",
+ "28-add",
+ "29-add",
+ "30-add",
+ "31-add",
+ ]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "22-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", focused: true },
+ { text: "24-add" },
+ { text: "25-add" },
+ { text: "26-add" },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "29-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "18-add focused, no selection yet"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "22-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", selected: true },
+ { text: "24-add", selected: true },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "29-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Forward range selection from 18-add to 26-add"
+ );
+
+ // Delete after the selection range
+ widget.removeItems(10, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "22-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", selected: true },
+ { text: "24-add", selected: true },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Same range selection"
+ );
+
+ // Delete before the selection range.
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", selected: true },
+ { text: "24-add", selected: true },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Same range selection"
+ );
+
+ // Delete the start of the selection range.
+ widget.removeItems(2, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 26-add"
+ );
+
+ // Selection pivot is now around 25-add.
+ EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add", selected: true, focused: true },
+ { text: "23-add", selected: true },
+ { text: "25-add", selected: true },
+ { text: "26-add" },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 21-add"
+ );
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true },
+ { text: "27-add", selected: true },
+ { text: "28-add", selected: true },
+ { text: "30-add", selected: true },
+ { text: "31-add", selected: true, focused: true },
+ ],
+ "Selection range from 25-add to 31-add"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true },
+ { text: "27-add", selected: true },
+ { text: "28-add", selected: true, focused: true },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 28-add"
+ );
+
+ // Delete the end of the selection.
+ // As a special case, the focus moves to the end of the selection, rather
+ // than to the next item.
+ widget.removeItems(4, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 26-add"
+ );
+
+ // Do same with a gap.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true },
+ { text: "30-add", selected: true, focused: true },
+ { text: "31-add" },
+ ],
+ "Continue selection range from 25-add to 30-add"
+ );
+
+ widget.addItems(3, ["32-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add" },
+ { text: "26-add", selected: true },
+ { text: "30-add", selected: true, focused: true },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 30-add with gap"
+ );
+
+ widget.removeItems(5, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add" },
+ { text: "26-add", selected: true, focused: true },
+ { text: "31-add" },
+ ],
+ "Focus moves to the end of the range, after the gap"
+ );
+
+ // Do the same with a gap and all items after the gap are removed.
+ widget.addItems(6, ["33-add", "34-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add" },
+ { text: "26-add", selected: true, focused: true },
+ { text: "31-add" },
+ { text: "33-add" },
+ { text: "34-add" },
+ ],
+ "Added 33-add and 34-add"
+ );
+
+ clickWidgetItem(5, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true },
+ { text: "26-add", selected: true },
+ { text: "31-add", selected: true, focused: true },
+ { text: "33-add" },
+ { text: "34-add" },
+ ],
+ "Selection extended to 31-add and gap filled"
+ );
+
+ widget.addItems(4, ["35-add", "36-add", "37-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true },
+ { text: "35-add" },
+ { text: "36-add" },
+ { text: "37-add" },
+ { text: "26-add", selected: true },
+ { text: "31-add", selected: true, focused: true },
+ { text: "33-add" },
+ { text: "34-add" },
+ ],
+ "Selection from 25-add to 31-add with gap"
+ );
+
+ widget.removeItems(6, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true, focused: true },
+ { text: "35-add" },
+ { text: "36-add" },
+ { text: "33-add" },
+ { text: "34-add" },
+ ],
+ "Focus jumps gap to what is left of the selection range"
+ );
+
+ // Same, with entire gap also removed.
+ clickWidgetItem(6, { shiftKey: true });
+ widget.addItems(5, ["38-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true },
+ { text: "35-add", selected: true },
+ { text: "38-add" },
+ { text: "36-add", selected: true },
+ { text: "33-add", selected: true, focused: true },
+ { text: "34-add" },
+ ],
+ "Selection from 25-add to 33-add with gap"
+ );
+
+ widget.removeItems(4, 4);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true, focused: true },
+ { text: "34-add" },
+ ],
+ "Focus moves to end of what is left of the selection range"
+ );
+
+ // Test deleting the end of the selection with focus in a gap.
+ // We don't expect to follow the special treatment because the user has
+ // explicitly moved the focus "outside" of the selected range.
+ widget.addItems(3, ["39-add"]);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "39-add", focused: true },
+ { text: "32-add", selected: true },
+ { text: "34-add" },
+ ],
+ "Focus in range gap"
+ );
+
+ widget.removeItems(3, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "34-add", focused: true },
+ ],
+ "Focus moves from gap to 34-add, outside the selection range"
+ );
+
+ // Same, but deleting the start of the range.
+ widget.addItems(4, ["40-add", "41-add", "42-add"]);
+ clickWidgetItem(5, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true, focused: true },
+ { text: "42-add" },
+ ],
+ "Selection from 25-add to 41-add"
+ );
+
+ widget.addItems(3, ["43-add", "44-add", "45-add"]);
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "43-add", focused: true },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true },
+ { text: "42-add" },
+ ],
+ "Focus in gap"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add", focused: true },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true },
+ { text: "42-add" },
+ ],
+ "Focus moves to next item, rather than the selection start"
+ );
+
+ // Test deleting the end of the selection with the focus towards the end of
+ // the range.
+ widget.addItems(7, ["46-add", "47-add", "48-add", "49-add", "50-add"]);
+ clickWidgetItem(7, { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true },
+ { text: "42-add", selected: true, focused: true },
+ { text: "46-add", selected: true },
+ { text: "47-add" },
+ { text: "48-add" },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Range selection from 34-add to 46-add, with focus on 42-add"
+ );
+
+ widget.removeItems(6, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true, focused: true },
+ { text: "47-add" },
+ { text: "48-add" },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Focus still moves to the end of the selection"
+ );
+
+ // Test deleting with focus after the end of the range.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true },
+ { text: "47-add", focused: true },
+ { text: "48-add" },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Focus still moves to the end of the selection"
+ );
+
+ widget.removeItems(5, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "48-add", focused: true },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Focus remains outside the range"
+ );
+
+ // Test deleting with focus in the middle of the range, and end of the range
+ // is not deleted.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true, focused: true },
+ { text: "48-add", selected: true },
+ { text: "49-add", selected: true },
+ { text: "50-add" },
+ ],
+ "Focus in the middle of the range"
+ );
+
+ widget.removeItems(4, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "48-add", selected: true, focused: true },
+ { text: "49-add", selected: true },
+ { text: "50-add" },
+ ],
+ "Focus moves to next item, rather than the end of the range"
+ );
+
+ // With focus just before a gap.
+ widget.addItems(5, ["51-add", "52-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "48-add", selected: true, focused: true },
+ { text: "51-add" },
+ { text: "52-add" },
+ { text: "49-add", selected: true },
+ { text: "50-add" },
+ ],
+ "Focus just before a gap"
+ );
+
+ widget.removeItems(4, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "51-add", focused: true },
+ { text: "52-add" },
+ { text: "49-add", selected: true },
+ { text: "50-add" },
+ ],
+ "Focus moves forward into the gap"
+ );
+
+ // Selection pivot is about 34-add
+ clickWidgetItem(1, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add", selected: true, focused: true },
+ { text: "45-add", selected: true },
+ { text: "34-add", selected: true },
+ { text: "51-add" },
+ { text: "52-add" },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Selection from 34-add backward to 44-add"
+ );
+ clickWidgetItem(5, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "51-add", selected: true },
+ { text: "52-add", selected: true, focused: true },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Selection from 34-add forward to 52-add"
+ );
+
+ // Delete the whole range.
+ widget.removeItems(3, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "49-add", selected: true, focused: true },
+ { text: "50-add" },
+ ],
+ "Focus and selection moves to after the selection range"
+ );
+
+ // Do the same with focus outside the range.
+ widget.addItems(5, ["53-add", "54-add", "55-add"]);
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ clickWidgetItem(2, { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add", selected: true },
+ { text: "45-add", selected: true },
+ { text: "49-add", focused: true },
+ { text: "50-add" },
+ { text: "53-add" },
+ { text: "54-add" },
+ { text: "55-add" },
+ ],
+ "Focus outside selection"
+ );
+
+ widget.removeItems(1, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "49-add", focused: true },
+ { text: "50-add" },
+ { text: "53-add" },
+ { text: "54-add" },
+ { text: "55-add" },
+ ],
+ "Focus remains on same item and unselected"
+ );
+
+ // Do the same, but the focus is also removed.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "49-add", selected: true },
+ { text: "50-add", selected: true },
+ { text: "53-add", focused: true },
+ { text: "54-add" },
+ { text: "55-add" },
+ ],
+ "Focus outside selection"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add", selected: true, focused: true },
+ { text: "55-add" },
+ ],
+ "Focus and selection moves to 49-add"
+ );
+
+ // * Do the same tests but with selection travelling backwards. *
+ widget.addItems(3, [
+ "56-add",
+ "57-add",
+ "58-add",
+ "59-add",
+ "60-add",
+ "61-add",
+ "62-add",
+ "63-add",
+ "64-add",
+ "65-add",
+ "66-add",
+ ]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add", selected: true, focused: true },
+ { text: "55-add" },
+ { text: "56-add" },
+ { text: "57-add" },
+ { text: "58-add" },
+ { text: "59-add" },
+ { text: "60-add" },
+ { text: "61-add" },
+ { text: "62-add" },
+ { text: "63-add" },
+ { text: "64-add" },
+ { text: "65-add" },
+ { text: "66-add" },
+ ],
+ "Same selection and focus"
+ );
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "55-add" },
+ { text: "56-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "62-add", selected: true },
+ { text: "63-add" },
+ { text: "64-add" },
+ { text: "65-add" },
+ { text: "66-add" },
+ ],
+ "Backward range selection from 62-add to 57-add"
+ );
+
+ // Delete after the selection range
+ widget.removeItems(11, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "55-add" },
+ { text: "56-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "62-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Same range selection"
+ );
+
+ // Delete before the selection range.
+ widget.removeItems(2, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "62-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Same range selection"
+ );
+
+ // Delete the end of the selection range.
+ widget.removeItems(7, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 61-add to 57-add"
+ );
+
+ // Selection pivot is now around 61-add.
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "57-add" },
+ { text: "58-add" },
+ { text: "59-add" },
+ { text: "60-add" },
+ { text: "61-add", selected: true },
+ { text: "63-add", selected: true },
+ { text: "66-add", selected: true, focused: true },
+ ],
+ "Selection range forwards from 61-add to 66-add"
+ );
+ EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add", selected: true, focused: true },
+ { text: "54-add", selected: true },
+ { text: "57-add", selected: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 61-add to 21-add"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 61-add to 57-add"
+ );
+
+ // Delete the start of the selection.
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "59-add", selected: true, focused: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range shrinks to 59-add"
+ );
+
+ // Do the same with a gap after the focus and its next item.
+ widget.addItems(3, ["67-add", "68-add", "69-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "59-add", selected: true, focused: true },
+ { text: "60-add", selected: true },
+ { text: "67-add" },
+ { text: "68-add" },
+ { text: "69-add" },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 61-add to 59-add with gap"
+ );
+
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add", selected: true, focused: true },
+ { text: "67-add" },
+ { text: "68-add" },
+ { text: "69-add" },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the next item, before gap"
+ );
+
+ // Do the same with a gap and all items before the gap are removed.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add" },
+ { text: "67-add", selected: true, focused: true },
+ { text: "68-add", selected: true },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backward reduced to 67-add with gap filled"
+ );
+ widget.addItems(4, ["70-add", "71-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add" },
+ { text: "67-add", selected: true, focused: true },
+ { text: "68-add", selected: true },
+ { text: "70-add" },
+ { text: "71-add" },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backward from 61-add to 67-add with gap"
+ );
+
+ widget.removeItems(2, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add" },
+ { text: "70-add" },
+ { text: "71-add" },
+ { text: "69-add", selected: true, focused: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus jumps gap to selection range"
+ );
+
+ // Same, with entire gap also removed.
+ clickWidgetItem(1, { shiftKey: true });
+ widget.addItems(2, ["72-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add", selected: true, focused: true },
+ { text: "72-add" },
+ { text: "70-add", selected: true },
+ { text: "71-add", selected: true },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 60-add to 70-add"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "71-add", selected: true, focused: true },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the start of what is left of the selection range"
+ );
+
+ // Test deleting the start of the selection with focus in a gap.
+ // We don't expect to follow the special treatment because the user has
+ // explicitly moved the focus "outside" of the selected range.
+ widget.addItems(2, ["73-add", "74-add", "75-add"]);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "71-add", selected: true },
+ { text: "73-add", focused: true },
+ { text: "74-add" },
+ { text: "75-add" },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus in range gap"
+ );
+
+ widget.removeItems(1, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add", focused: true },
+ { text: "75-add" },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the next item, rather than the selection range"
+ );
+
+ // Same, but deleting the end of the range.
+ clickWidgetItem(1, { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true },
+ { text: "69-add", selected: true, focused: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backward from 61-add to 74-add, with focus shifted"
+ );
+ widget.addItems(3, ["76-add", "77-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true },
+ { text: "76-add" },
+ { text: "77-add" },
+ { text: "69-add", selected: true, focused: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus in gap"
+ );
+
+ widget.removeItems(5, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true },
+ { text: "76-add" },
+ { text: "77-add" },
+ { text: "63-add", focused: true },
+ { text: "66-add" },
+ ],
+ "Focus moves to next item, rather than selection range end"
+ );
+
+ // Selection pivot now about 75-add.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add" },
+ { text: "75-add", selected: true },
+ { text: "76-add", selected: true },
+ { text: "77-add", selected: true, focused: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range forward from 75-add to 77-add"
+ );
+
+ widget.addItems(1, ["78-add", "79-add", "80-add"]);
+ clickWidgetItem(2, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "78-add" },
+ { text: "79-add", selected: true, focused: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true },
+ { text: "76-add" },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backward from 75-add to 79-add"
+ );
+
+ // Move focus to the end of the range and delete again, but with no gap this
+ // time.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "78-add" },
+ { text: "79-add", selected: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true, focused: true },
+ { text: "76-add" },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moved to the end of the selection"
+ );
+
+ widget.removeItems(5, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "78-add" },
+ { text: "79-add", selected: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "77-add", focused: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moved to the next item rather than the selection start"
+ );
+
+ // Deleting with focus before the selection start.
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "78-add", focused: true },
+ { text: "79-add", selected: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus before the selection start"
+ );
+
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "79-add", selected: true, focused: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the next item, which happens to be in the selection range"
+ );
+
+ // Test deleting with focus in the middle of the range.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ assertState(
+ [
+ { text: "21-add", selected: true },
+ { text: "79-add", selected: true, focused: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 74-add to 21-add, with focus in middle"
+ );
+
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "21-add", selected: true },
+ { text: "80-add", selected: true, focused: true },
+ { text: "74-add", selected: true },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the next item, rather than the selection start or end"
+ );
+
+ // Delete the whole range.
+ widget.removeItems(0, 4);
+ assertState(
+ [{ text: "63-add", selected: true, focused: true }, { text: "66-add" }],
+ "Focus and selection move to the next remaining item"
+ );
+
+ // Do the same with focus outside the range.
+ widget.addItems(0, ["81-add", "82-add", "83-add", "84-add", "85-add"]);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "82-add" },
+ { text: "83-add" },
+ { text: "84-add", focused: true },
+ { text: "85-add", selected: true },
+ { text: "63-add", selected: true },
+ { text: "66-add" },
+ ],
+ "Focus outside backward selection range"
+ );
+
+ widget.removeItems(4, 2);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "82-add" },
+ { text: "83-add" },
+ { text: "84-add", focused: true },
+ { text: "66-add" },
+ ],
+ "Focus remains the same and is not selected"
+ );
+
+ // Same, but with focus also removed.
+ widget.addItems(5, ["86-add"]);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "82-add", focused: true },
+ { text: "83-add", selected: true },
+ { text: "84-add", selected: true },
+ { text: "66-add" },
+ { text: "86-add" },
+ ],
+ "Focus outside backwards selection range"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "66-add", selected: true, focused: true },
+ { text: "86-add" },
+ ],
+ "Focus moves to next item and selected"
+ );
+
+ // With multi-selection via toggling.
+
+ widget.addItems(3, [
+ "87-add",
+ "88-add",
+ "89-add",
+ "90-add",
+ "91-add",
+ "92-add",
+ "93-add",
+ "94-add",
+ ]);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "66-add" },
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add", selected: true },
+ { text: "89-add", selected: true },
+ { text: "90-add", selected: true, focused: true },
+ { text: "91-add" },
+ { text: "92-add" },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Start with range selection forward from 87-add to 90-add"
+ );
+
+ clickWidgetItem(4, { ctrlKey: true });
+ clickWidgetItem(5, { ctrlKey: true });
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "66-add" },
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add", focused: true },
+ { text: "90-add", selected: true },
+ { text: "91-add" },
+ { text: "92-add" },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Range selection from 87-add to 90-add, with 88-add and 89-add unselected"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ EventUtils.synthesizeKey(" ", { ctrlKey: true });
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "66-add" },
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add" },
+ { text: "90-add", selected: true },
+ { text: "91-add", selected: true, focused: true },
+ { text: "92-add" },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Mixed selection"
+ );
+
+ // Remove before the selected items
+ widget.removeItems(0, 2);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add" },
+ { text: "90-add", selected: true },
+ { text: "91-add", selected: true, focused: true },
+ { text: "92-add" },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Same selection and focus"
+ );
+
+ // Remove after
+ widget.removeItems(6, 1);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add" },
+ { text: "90-add", selected: true },
+ { text: "91-add", selected: true, focused: true },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Same selection and focus"
+ );
+
+ // Removed the focused item, unlike a simple range selection, the focused
+ // item is not bound to stay within the selected items.
+ widget.removeItems(5, 1);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add" },
+ { text: "90-add", selected: true },
+ { text: "93-add", focused: true },
+ { text: "94-add" },
+ ],
+ "Focus moves to next item and not selected"
+ );
+
+ // Remove the unselected items, merging the two ranges together.
+ widget.removeItems(2, 2);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "90-add", selected: true },
+ { text: "93-add", focused: true },
+ { text: "94-add" },
+ ],
+ "Focus remains the same"
+ );
+
+ // Remove the selected items.
+ widget.removeItems(1, 2);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "93-add", focused: true },
+ { text: "94-add" },
+ ],
+ "Focus remains the same, and not selected"
+ );
+
+ // Remove all selected items, including the focused item.
+ widget.addItems(0, [
+ "95-add",
+ "96-add",
+ "97-add",
+ "98-add",
+ "99-add",
+ "100-add",
+ "101-add",
+ ]);
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "96-add" },
+ { text: "97-add" },
+ { text: "98-add" },
+ { text: "99-add" },
+ { text: "100-add" },
+ { text: "101-add" },
+ { text: "86-add" },
+ { text: "93-add", selected: true, focused: true },
+ { text: "94-add" },
+ ],
+ "Single selection"
+ );
+
+ clickWidgetItem(6, { ctrlKey: true });
+ clickWidgetItem(5, { ctrlKey: true });
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "96-add" },
+ { text: "97-add" },
+ { text: "98-add" },
+ { text: "99-add" },
+ { text: "100-add", selected: true, focused: true },
+ { text: "101-add", selected: true },
+ { text: "86-add" },
+ { text: "93-add", selected: true },
+ { text: "94-add" },
+ ],
+ "Mixed selection with focus selected"
+ );
+
+ widget.removeItems(5, 4);
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "96-add" },
+ { text: "97-add" },
+ { text: "98-add" },
+ { text: "99-add" },
+ { text: "94-add", selected: true, focused: true },
+ ],
+ "Focus moves to next item and selected"
+ );
+
+ // Remove all selected, with focus outside the selection
+ clickWidgetItem(1, {});
+ clickWidgetItem(3, { ctrlKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true });
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "96-add", selected: true },
+ { text: "97-add", focused: true },
+ { text: "98-add", selected: true },
+ { text: "99-add" },
+ { text: "94-add" },
+ ],
+ "Mixed selection with focus not selected"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "99-add", selected: true, focused: true },
+ { text: "94-add" },
+ ],
+ "Focus moves to next item and selected"
+ );
+
+ // With select all.
+ widget.addItems(0, ["102-add", "103-add", "104-add"]);
+ widget.addItems(6, ["105-add", "106-add"]);
+ selectAllShortcut();
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "103-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ { text: "99-add", selected: true, focused: true },
+ { text: "94-add", selected: true },
+ { text: "105-add", selected: true },
+ { text: "106-add", selected: true },
+ ],
+ "All selected"
+ );
+
+ // Remove middle and focused.
+ widget.removeItems(4, 1);
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "103-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ { text: "94-add", selected: true, focused: true },
+ { text: "105-add", selected: true },
+ { text: "106-add", selected: true },
+ ],
+ "Focus moves to the next item, selections remain"
+ );
+
+ // Remove before focused.
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ { text: "94-add", selected: true, focused: true },
+ { text: "105-add", selected: true },
+ { text: "106-add", selected: true },
+ ],
+ "Focus and selection remain"
+ );
+
+ // Remove after the focus.
+ widget.removeItems(4, 2);
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ { text: "94-add", selected: true, focused: true },
+ ],
+ "Focus and selection remain"
+ );
+
+ // Remove end and focused.
+ widget.removeItems(3, 1);
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true, focused: true },
+ ],
+ "Focus moves to the last item, selection remains"
+ );
+
+ // Remove start and focused.
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "102-add", selected: true, focused: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ ],
+ "Focus on first item"
+ );
+
+ widget.removeItems(0, 1);
+ assertState(
+ [
+ { text: "104-add", selected: true, focused: true },
+ { text: "95-add", selected: true },
+ ],
+ "Focus moves to next item"
+ );
+
+ // Remove items to cause two distinct ranges to merge together, with
+ // in-between ranges removed.
+ widget.addItems(2, [
+ "107-add",
+ "108-add",
+ "109-add",
+ "110-add",
+ "111-add",
+ "112-add",
+ "113-add",
+ ]);
+ clickWidgetItem(0, { ctrlKey: true });
+ assertState(
+ [
+ { text: "104-add", focused: true },
+ { text: "95-add", selected: true },
+ { text: "107-add" },
+ { text: "108-add" },
+ { text: "109-add" },
+ { text: "110-add" },
+ { text: "111-add" },
+ { text: "112-add" },
+ { text: "113-add" },
+ ],
+ "Added items and de-selected 104-add"
+ );
+
+ clickWidgetItem(3, { ctrlKey: true });
+ clickWidgetItem(4, { ctrlKey: true });
+ clickWidgetItem(6, { ctrlKey: true });
+ clickWidgetItem(8, { ctrlKey: true });
+ assertState(
+ [
+ { text: "104-add" },
+ { text: "95-add", selected: true },
+ { text: "107-add" },
+ { text: "108-add", selected: true },
+ { text: "109-add", selected: true },
+ { text: "110-add" },
+ { text: "111-add", selected: true },
+ { text: "112-add" },
+ { text: "113-add", selected: true, focused: true },
+ ],
+ "Several selection ranges"
+ );
+
+ widget.removeItems(2, 6);
+ assertState(
+ [
+ { text: "104-add" },
+ { text: "95-add", selected: true },
+ { text: "113-add", selected: true, focused: true },
+ ],
+ "End ranges merged together"
+ );
+
+ // Do the same, but where parts of the end ranges are also removed.
+ widget.addItems(3, [
+ "114-add",
+ "115-add",
+ "116-add",
+ "117-add",
+ "118-add",
+ "119-add",
+ "120-add",
+ "121-add",
+ "122-add",
+ "123-add",
+ "124-add",
+ "125-add",
+ "126-add",
+ "127-add",
+ ]);
+ clickWidgetItem(4, { ctrlKey: true });
+ clickWidgetItem(5, { ctrlKey: true });
+ clickWidgetItem(7, { ctrlKey: true });
+ clickWidgetItem(10, { ctrlKey: true });
+ clickWidgetItem(12, { ctrlKey: true });
+ clickWidgetItem(13, { ctrlKey: true });
+ clickWidgetItem(14, { ctrlKey: true });
+ clickWidgetItem(16, { ctrlKey: true });
+
+ clickWidgetItem(9, { ctrlKey: true });
+ assertState(
+ [
+ { text: "104-add" },
+ { text: "95-add", selected: true },
+ { text: "113-add", selected: true },
+ { text: "114-add" },
+ { text: "115-add", selected: true },
+ { text: "116-add", selected: true },
+ { text: "117-add" },
+ { text: "118-add", selected: true },
+ { text: "119-add" },
+ { text: "120-add", selected: true, focused: true },
+ { text: "121-add", selected: true },
+ { text: "122-add" },
+ { text: "123-add", selected: true },
+ { text: "124-add", selected: true },
+ { text: "125-add", selected: true },
+ { text: "126-add" },
+ { text: "127-add", selected: true },
+ ],
+ "Several ranges"
+ );
+
+ widget.removeItems(5, 8);
+ assertState(
+ [
+ { text: "104-add" },
+ { text: "95-add", selected: true },
+ { text: "113-add", selected: true },
+ { text: "114-add" },
+ { text: "115-add", selected: true },
+ { text: "124-add", selected: true, focused: true },
+ { text: "125-add", selected: true },
+ { text: "126-add" },
+ { text: "127-add", selected: true },
+ ],
+ "Two ranges merged and rest removed, focus moves to next item"
+ );
+ }
+});
+
+// If widget is emptied whilst focused, focus moves to widget.
+add_task(function test_emptying_widget() {
+ for (let model of selectionModels) {
+ // Empty with focused widget.
+ reset({ model });
+ stepFocus(true, { element: widget }, "Initial");
+ widget.addItems(0, ["First", "Second"]);
+ assertFocus({ element: widget }, "Focus still on widget after adding");
+ widget.removeItems(0, 2);
+ assertFocus({ element: widget }, "Focus still on widget after removing");
+
+ // Empty with focused item.
+ widget.addItems(0, ["First", "Second"]);
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "Focus on first item");
+ widget.removeItems(0, 2);
+ assertFocus({ element: widget }, "Focus moves to widget after removing");
+
+ // Empty with focus elsewhere.
+ widget.addItems(0, ["First", "Second"]);
+ stepFocus(false, { element: before }, "Focus elsewhere");
+ widget.removeItems(0, 2);
+ assertFocus({ element: before }, "Focus still elsewhere after removing");
+ stepFocus(true, { element: widget }, "Widget becomes focused");
+
+ // Empty with focus elsewhere, but active item.
+ widget.addItems(0, ["First", "Second"]);
+ // Move away from and back to widget to focus second item.
+ stepFocus(true, { element: after }, "Focus elsewhere");
+ widget.selectSingleItem(1);
+ stepFocus(false, { index: 1 }, "Focus on second item");
+ stepFocus(false, { element: before }, "Return focus to elsewhere");
+ widget.removeItems(0, 2);
+ assertFocus({ element: before }, "Focus still elsewhere after removing");
+ stepFocus(true, { element: widget }, "Widget becomes focused");
+ }
+});
+
+/**
+ * Test moving items in the widget.
+ *
+ * @param {string} model - The selection model to use.
+ * @param {boolean} reCreate - Whether the widget should reCreate the items when
+ * moving them.
+ */
+function subtest_move_items(model, reCreate) {
+ reset({ model, direction: "right-to-left" });
+
+ widget.addItems(0, [
+ "0-add",
+ "1-add",
+ "2-add",
+ "3-add",
+ "4-add",
+ "5-add",
+ "6-add",
+ "7-add",
+ "8-add",
+ "9-add",
+ "10-add",
+ "11-add",
+ "12-add",
+ "13-add",
+ ]);
+ clickWidgetItem(5, {});
+
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Item 5 selected and focused"
+ );
+
+ // Move items before focus.
+ widget.moveItems(4, 3, 1, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+ widget.moveItems(1, 3, 2, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+
+ // Move items after focus.
+ widget.moveItems(6, 8, 2, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+ widget.moveItems(9, 6, 1, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+
+ // Move from before focus to after focus.
+ widget.moveItems(2, 3, 3, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection, but moved"
+ );
+
+ // Move from after focus to before focus.
+ widget.moveItems(3, 2, 5, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection, but moved"
+ );
+
+ // Move selected and focused up.
+ widget.moveItems(7, 3, 1, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus and selection moved to index 3"
+ );
+
+ // Move down.
+ widget.moveItems(3, 5, 1, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus and selection moved to index 5"
+ );
+
+ // Move in a group.
+ widget.moveItems(4, 5, 3, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "8-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "7-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus and selection moved to index 6"
+ );
+ widget.moveItems(5, 4, 3, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus and selection moved back to index 5"
+ );
+
+ // With focus split from selection.
+ if (model == "focus") {
+ return;
+ }
+
+ // Focus before selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus before selection"
+ );
+
+ // Move before both.
+ widget.moveItems(0, 1, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add" },
+ { text: "3-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+
+ // Move after both.
+ widget.moveItems(8, 7, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add" },
+ { text: "3-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "9-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+
+ // Move focus to after selected.
+ widget.moveItems(3, 6, 2, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "9-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus moved to after selection"
+ );
+
+ // Move focus before selected.
+ widget.moveItems(5, 2, 3, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add" },
+ { text: "9-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus moved to before selection"
+ );
+
+ // Move selection before focus.
+ widget.moveItems(5, 1, 5, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "0-add" },
+ { text: "9-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Selected moved to before focus"
+ );
+
+ // Move selection after focus.
+ widget.moveItems(2, 8, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "0-add" },
+ { text: "9-add" },
+ { text: "1-add", focused: true },
+ { text: "5-add", selected: true },
+ { text: "2-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Selected moved to after focus"
+ );
+
+ // Navigation still works.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "0-add" },
+ { text: "9-add", focused: true },
+ { text: "1-add" },
+ { text: "5-add", selected: true },
+ { text: "2-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Selected moved to after focus"
+ );
+
+ // Test with multi-selection.
+ if (model == "browse") {
+ return;
+ }
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add", selected: true, focused: true },
+ { text: "0-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "1-add" },
+ { text: "5-add" },
+ { text: "2-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 9-add to 6-add"
+ );
+
+ // Move non-selected into the middle of the selected.
+ widget.moveItems(8, 5, 2, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add", selected: true, focused: true },
+ { text: "5-add" },
+ { text: "2-add" },
+ { text: "0-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "1-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Non-selected gap"
+ );
+
+ // Moving an item always ends a Shift range selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "5-add" },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 6-add to 8-add"
+ );
+
+ clickWidgetItem(9, { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true, focused: true },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 6-add to 1-add"
+ );
+
+ // Move selected to middle of selected.
+ widget.moveItems(8, 6, 2, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true, focused: true },
+ { text: "2-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Selection block"
+ );
+
+ // Also ends a Shift range selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 1-add to 5-add"
+ );
+
+ // Move from start of selection to end.
+ widget.moveItems(5, 7, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "5-add", selected: true, focused: true },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Moved to end"
+ );
+
+ // And reverse.
+ widget.moveItems(7, 5, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Moved back to start"
+ );
+
+ // Also broke Shift range selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add", selected: true, focused: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 5-add to 3-add"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ ],
+ "Multi-selection"
+ );
+
+ // Move selected with gap into middle of a selection block.
+ widget.moveItems(8, 4, 6, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together on both sides"
+ );
+
+ // Move selected with gap to start of a selection block.
+ widget.moveItems(5, 1, 5, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at start"
+ );
+
+ // Move selected with gap to end of a selection block.
+ widget.moveItems(1, 4, 8, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at end"
+ );
+
+ // Move block with non-selected boundaries into middle of selected.
+ widget.moveItems(5, 3, 6, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Split range block"
+ );
+
+ // Move block with selected at start into middle of selected.
+ widget.moveItems(1, 6, 5, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "8-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at start"
+ );
+
+ // Move block with selected at end into middle of selected.
+ widget.moveItems(8, 6, 4, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "8-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at end"
+ );
+
+ // Move selected into non-selected region and move to start.
+ widget.moveItems(4, 0, 6, reCreate);
+ assertState(
+ [
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "8-add", selected: true },
+ { text: "4-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at end"
+ );
+
+ // Remove gap between two selections and move to end.
+ widget.moveItems(2, 9, 5, reCreate);
+ assertState(
+ [
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "8-add", selected: true },
+ { text: "4-add" },
+ ],
+ "Merged ranges together"
+ );
+
+ // Navigation still works.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertState(
+ [
+ { text: "5-add" },
+ { text: "0-add" },
+ { text: "13-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "2-add" },
+ { text: "6-add", selected: true, focused: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "8-add" },
+ { text: "4-add" },
+ ],
+ "Move by one index and single select"
+ );
+}
+
+// Moving items in the widget will move focus and selection with the moved
+// items.
+add_task(function test_move_items() {
+ for (let model of selectionModels) {
+ // We want to be sure the methods work with or without re-creating the
+ // item elements.
+ subtest_move_items(model, false);
+ subtest_move_items(model, true);
+ }
+});
+
+// Test that dragging is possible.
+add_task(function test_can_drag_items() {
+ /**
+ * Assert that dragging can occur and takes place with the expected selection.
+ *
+ * @param {number} index - The index of the item to start dragging on. We also
+ * expect this item to have focus during and after dragging.
+ * @param {number[]} selection - The expected selection during and after
+ * dragging.
+ * @param {string} msg - A message to use in assertions.
+ */
+ function assertDragstart(index, selection, msg) {
+ let element = widget.items[index].element;
+ let eventFired = false;
+
+ let dragstartListener = event => {
+ eventFired = true;
+ Assert.ok(
+ element.contains(event.target),
+ `Item ${index} contains the dragstart target`
+ );
+ assertFocus({ index }, `Item ${index} has focus in dragstart: ${msg}`);
+ assertSelection(selection, `Selection in dragstart: ${msg}`);
+ };
+ widget.addEventListener("dragstart", dragstartListener, true);
+
+ // Synthesize the start of a drag.
+ let rect = element.getBoundingClientRect();
+ let x = rect.left + rect.width / 2;
+ let y = rect.top + rect.height / 2;
+ EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win);
+ EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousemove" }, win);
+ EventUtils.synthesizeMouseAtPoint(x, y + 60, { type: "mousemove" }, win);
+ // Don't care about ending the drag.
+
+ Assert.ok(eventFired, `dragstart event fired: ${msg}`);
+ widget.removeEventListener("dragstart", dragstartListener, true);
+ assertSelection(selection, `Same selection after dragging: ${msg}`);
+ assertFocus(
+ { index },
+ `Item ${index} still has focus after dragging: ${msg}`
+ );
+ }
+
+ for (let model of selectionModels) {
+ reset({ model, draggable: true });
+ widget.addItems(0, ["First", "Second", "Third"]);
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([], "No initial selection");
+ assertDragstart(1, [1], "First drag with no focus or selection");
+
+ assertDragstart(1, [1], "Already selected item");
+ assertDragstart(2, [2], "Non-selected item");
+
+ reset({ model, draggable: true });
+ widget.addItems(0, ["First", "Second", "Third"]);
+ widget.selectSingleItem(1);
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([1], "Initial selection on item 1");
+ assertDragstart(1, [1], "First drag on selected item");
+
+ reset({ model, draggable: true });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]);
+ widget.selectSingleItem(3);
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([3], "Initial selection on item 3");
+ assertDragstart(2, [2], "First drag on non-selected item");
+
+ // With focus split from selected.
+ if (model == "focus") {
+ continue;
+ }
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Focus on item 3");
+ assertSelection([2], "Item 2 is selected");
+ assertDragstart(3, [3], "Non-selected but focused item");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus on item 2");
+ assertSelection([3], "Item 3 is selected");
+ assertDragstart(3, [3], "Selected but non-focused item");
+
+ // With mutli-selection.
+ if (model == "browse") {
+ continue;
+ }
+
+ // Clicking a non-selected item will change to selection to the single item
+ // before dragging.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Focus on item 1");
+ assertSelection([1, 3], "Multi selection");
+ assertDragstart(2, [2], "Selection moves to item 2 before drag");
+
+ // Clicking a selected item will keep the same selection for dragging.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 4 }, "Focus on item 4");
+ assertSelection([2, 4], "Multi selection");
+ assertDragstart(
+ 4,
+ [2, 4],
+ "Selection same when dragging selected and focused"
+ );
+ assertDragstart(
+ 2,
+ [2, 4],
+ "Selection same when dragging selected and non-focussed"
+ );
+ }
+});
diff --git a/comm/mail/base/test/browser/browser_smartFolderDelete.js b/comm/mail/base/test/browser/browser_smartFolderDelete.js
new file mode 100644
index 0000000000..1c3f1fb59c
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_smartFolderDelete.js
@@ -0,0 +1,75 @@
+/* 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 { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const tabmail = document.getElementById("tabmail");
+const about3Pane = tabmail.currentAbout3Pane;
+
+let rootFolder;
+let inboxFolder;
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ rootFolder = account.incomingServer.rootFolder;
+ rootFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ // Set the active modes of the folder pane. In theory we only need the "smart"
+ // mode to test with, but in practice we also need the "all" mode to generate
+ // messages in folders.
+ about3Pane.folderPane.activeModes = ["all", "smart"];
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ about3Pane.folderPane.activeModes = ["all"];
+ });
+});
+
+/**
+ * Test deleting a message from a smart folder using
+ * gDBView.applyCommandToIndices.
+ */
+add_task(async function testDeleteViaDBViewCommand() {
+ // Create an inbox folder.
+ const inboxFolder = rootFolder
+ .createLocalSubfolder("testDeleteInbox")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox);
+
+ // Add a message to the folder.
+ const generator = new MessageGenerator();
+ inboxFolder.addMessage(generator.makeMessage().toMboxString());
+
+ // Create a smart folder from the inbox.
+ const smartInboxFolder = getSmartServer().rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+
+ // Switch the view to the smart folder.
+ about3Pane.displayFolder(smartInboxFolder.URI);
+
+ // Get the DB view and tree view to use to send the command and observe its
+ // effect.
+ const dbView = about3Pane.gDBView;
+ const treeView = dbView.QueryInterface(Ci.nsITreeView);
+
+ // Ensure we currently have one message.
+ Assert.equal(treeView.rowCount, 1, "should have one message before deleting");
+
+ // Delete the message using applyCommandToIndices.
+ dbView.applyCommandToIndices(Ci.nsMsgViewCommandType.deleteMsg, [0]);
+
+ // Test that the message has been deleted.
+ await TestUtils.waitForCondition(
+ () => treeView.rowCount === 0,
+ "there should be no remaining message in the tree"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_spacesToolbar.js b/comm/mail/base/test/browser/browser_spacesToolbar.js
new file mode 100644
index 0000000000..c2432a2131
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_spacesToolbar.js
@@ -0,0 +1,1173 @@
+/* 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 the spaces toolbar features.
+ */
+
+/* globals gSpacesToolbar */
+
+var folderA;
+var folderB;
+var testAccount;
+
+add_setup(function () {
+ // Set up two folders.
+ window.MailServices.accounts.createLocalMailAccount();
+ testAccount = window.MailServices.accounts.accounts[0];
+ let rootFolder = testAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("spacesToolbarA", null);
+ folderA = rootFolder.findSubFolder("spacesToolbarA");
+ rootFolder.createSubfolder("spacesToolbarB", null);
+ folderB = rootFolder.findSubFolder("spacesToolbarB");
+});
+
+registerCleanupFunction(async () => {
+ window.MailServices.accounts.removeAccount(testAccount, true);
+ // Close all opened tabs.
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+ // Reset the spaces toolbar to its default visible state.
+ window.gSpacesToolbar.toggleToolbar(false);
+ // Reset the menubar visibility.
+ let menubar = document.getElementById("toolbar-menubar");
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
+
+async function assertMailShown(win = window) {
+ await TestUtils.waitForCondition(
+ () =>
+ win.document.getElementById("tabmail").currentTabInfo.mode.name ==
+ "mail3PaneTab",
+ "The mail tab should be visible"
+ );
+}
+
+async function assertAddressBookShown(win = window) {
+ await TestUtils.waitForCondition(() => {
+ let panel = win.document.querySelector(
+ // addressBookTabWrapper0, addressBookTabWrapper1, etc
+ "#tabpanelcontainer > [id^=addressBookTabWrapper][selected]"
+ );
+ if (!panel) {
+ return false;
+ }
+ let browser = panel.querySelector("[id^=addressBookTabBrowser]");
+ return browser.contentDocument.readyState == "complete";
+ }, "The address book tab should be visible and loaded");
+}
+
+async function assertChatShown(win = window) {
+ await TestUtils.waitForCondition(
+ () => win.document.getElementById("chatTabPanel").hasAttribute("selected"),
+ "The chat tab should be visible"
+ );
+}
+
+async function assertCalendarShown(win = window) {
+ await TestUtils.waitForCondition(() => {
+ return (
+ win.document
+ .getElementById("calendarTabPanel")
+ .hasAttribute("selected") &&
+ !win.document.getElementById("calendar-view-box").collapsed
+ );
+ }, "The calendar view should be visible");
+}
+
+async function assertTasksShown(win = window) {
+ await TestUtils.waitForCondition(() => {
+ return (
+ win.document
+ .getElementById("calendarTabPanel")
+ .hasAttribute("selected") &&
+ !win.document.getElementById("calendar-task-box").collapsed
+ );
+ }, "The task view should be visible");
+}
+
+async function assertSettingsShown(win = window) {
+ await TestUtils.waitForCondition(() => {
+ let panel = win.document.querySelector(
+ // preferencesTabWrapper0, preferencesTabWrapper1, etc
+ "#tabpanelcontainer > [id^=preferencesTabWrapper][selected]"
+ );
+ if (!panel) {
+ return false;
+ }
+ let browser = panel.querySelector("[id^=preferencesTabBrowser]");
+ return browser.contentDocument.readyState == "complete";
+ }, "The settings tab should be visible and loaded");
+}
+
+async function assertContentShown(url, win = window) {
+ await TestUtils.waitForCondition(() => {
+ let panel = win.document.querySelector(
+ // contentTabWrapper0, contentTabWrapper1, etc
+ "#tabpanelcontainer > [id^=contentTabWrapper][selected]"
+ );
+ if (!panel) {
+ return false;
+ }
+ let doc = panel.querySelector("[id^=contentTabBrowser]").contentDocument;
+ return doc.URL == url && doc.readyState == "complete";
+ }, `The selected content tab should show ${url}`);
+}
+
+async function sub_test_cycle_through_primary_tabs() {
+ // We can't really cycle through all buttons and tabs with a simple for loop
+ // since some tabs are actual collapsing views and other tabs are separate
+ // pages. We can improve this once the new 3pane tab is actually a standalone
+ // tab.
+
+ // Switch to address book.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("addressBookButton"),
+ {},
+ window
+ );
+ await assertAddressBookShown();
+
+ // Switch to calendar.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendarButton"),
+ {},
+ window
+ );
+ await assertCalendarShown();
+
+ // Switch to Mail.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("mailButton"),
+ {},
+ window
+ );
+ await assertMailShown();
+
+ // Switch to Tasks.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("tasksButton"),
+ {},
+ window
+ );
+ await assertTasksShown();
+
+ // Switch to chat.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("chatButton"),
+ {},
+ window
+ );
+ await assertChatShown();
+
+ // Switch to Settings.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("settingsButton"),
+ {},
+ window
+ );
+ await assertSettingsShown();
+
+ // Switch to Mail.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("mailButton"),
+ {},
+ window
+ );
+ await assertMailShown();
+
+ window.tabmail.closeOtherTabs(window.tabmail.tabInfo[0]);
+}
+
+add_task(async function testSpacesToolbarVisibility() {
+ let spacesToolbar = document.getElementById("spacesToolbar");
+ let toggleButton = document.getElementById("spacesToolbarReveal");
+ let pinnedButton = document.getElementById("spacesPinnedButton");
+ Assert.ok(spacesToolbar, "The spaces toolbar exists");
+
+ let assertVisibility = async function (isHidden, msg) {
+ await TestUtils.waitForCondition(
+ () => spacesToolbar.hidden == !isHidden,
+ `The spaces toolbar should be ${!isHidden ? "visible" : "hidden"}: ${msg}`
+ );
+
+ await TestUtils.waitForCondition(
+ () => toggleButton.hidden == isHidden,
+ `The toggle button should be ${isHidden ? "hidden" : "visible"}: ${msg}`
+ );
+
+ await TestUtils.waitForCondition(
+ () => pinnedButton.hidden == isHidden,
+ `The pinned button should be ${isHidden ? "hidden" : "visible"}: ${msg}`
+ );
+ };
+
+ async function toggleVisibilityWithAppMenu(expectChecked) {
+ let appMenu = document.getElementById("appMenu-popup");
+ let menuShownPromise = BrowserTestUtils.waitForEvent(appMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("button-appmenu"),
+ {},
+ window
+ );
+ await menuShownPromise;
+
+ let viewShownPromise = BrowserTestUtils.waitForEvent(
+ appMenu.querySelector("#appMenu-viewView"),
+ "ViewShown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ appMenu.querySelector("#appmenu_View"),
+ {},
+ window
+ );
+ await viewShownPromise;
+
+ let toolbarShownPromise = BrowserTestUtils.waitForEvent(
+ appMenu.querySelector("#appMenu-toolbarsView"),
+ "ViewShown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ appMenu.querySelector("#appmenu_Toolbars"),
+ {},
+ window
+ );
+ await toolbarShownPromise;
+
+ let appMenuButton = document.getElementById("appmenu_spacesToolbar");
+ Assert.equal(
+ appMenuButton.checked,
+ expectChecked,
+ `The app menu item should ${expectChecked ? "not " : ""}be checked`
+ );
+
+ EventUtils.synthesizeMouseAtCenter(appMenuButton, {}, window);
+
+ // Close the appmenu.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("button-appmenu"),
+ {},
+ window
+ );
+ }
+ await assertVisibility(true, "on initial load");
+
+ // Collapse with a mouse click.
+ let activeElement = document.activeElement;
+ let collapseButton = document.getElementById("collapseButton");
+ EventUtils.synthesizeMouseAtCenter(collapseButton, {}, window);
+ await assertVisibility(false, "after clicking collapse button");
+
+ await toggleVisibilityWithAppMenu(false);
+ await assertVisibility(true, "after revealing with the app menu");
+
+ // We already clicked the collapse button, so it should already be the
+ // focusButton for the gSpacesToolbar, and thus focusable.
+ collapseButton.focus();
+ Assert.ok(
+ collapseButton.matches(":focus"),
+ "Collapse button should be focusable"
+ );
+
+ // Hide the spaces toolbar using the collapse button, which already has focus.
+ EventUtils.synthesizeKey(" ", {}, window);
+ await assertVisibility(false, "after closing with space key press");
+ Assert.ok(
+ pinnedButton.matches(":focus"),
+ "Pinned button should be focused after closing with a key press"
+ );
+
+ // Show using the pinned button menu.
+ let pinnedMenu = document.getElementById("spacesButtonMenuPopup");
+ let pinnedMenuShown = BrowserTestUtils.waitForEvent(pinnedMenu, "popupshown");
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ await pinnedMenuShown;
+ pinnedMenu.activateItem(document.getElementById("spacesPopupButtonReveal"));
+
+ await assertVisibility(true, "after opening with pinned menu");
+ Assert.ok(
+ collapseButton.matches(":focus"),
+ "Collapse button should be focused again after showing with the pinned menu"
+ );
+
+ // Move focus to the mail button.
+ let mailButton = document.getElementById("mailButton");
+ // Loop around from the collapse button to the mailButton.
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+
+ Assert.ok(
+ mailButton.matches(":focus"),
+ "Mail button should become focused after pressing key down"
+ );
+ Assert.ok(
+ spacesToolbar.matches(":focus-within"),
+ "Spaces toolbar should contain the focus"
+ );
+
+ // Now move focus elsewhere.
+ EventUtils.synthesizeKey("KEY_Tab", {}, window);
+ activeElement = document.activeElement;
+ Assert.ok(
+ !mailButton.matches(":focus"),
+ "Mail button should no longer be focused"
+ );
+ Assert.ok(
+ !spacesToolbar.matches(":focus-within"),
+ "Spaces toolbar should no longer contain the focus"
+ );
+
+ // Hide the spaces toolbar using the app menu.
+ await toggleVisibilityWithAppMenu(true);
+ await assertVisibility(false, "after hiding with the app menu");
+
+ // macOS by default doesn't move the focus when clicking on toolbar buttons.
+ if (AppConstants.platform != "macosx") {
+ Assert.notEqual(
+ document.activeElement,
+ activeElement,
+ "The focus moved from the previous element"
+ );
+ // Focus should be on the main app menu since we used the mouse to toggle the
+ // spaces toolbar.
+ Assert.equal(
+ document.activeElement,
+ document.getElementById("button-appmenu"),
+ "Active element is on the app menu"
+ );
+ } else {
+ Assert.equal(
+ document.activeElement,
+ activeElement,
+ "The focus didn't move from the previous element"
+ );
+ }
+
+ // Now click the status bar toggle button to reveal the toolbar again.
+ toggleButton.focus();
+ Assert.ok(
+ toggleButton.matches(":focus"),
+ "Toggle button should be focusable"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ await assertVisibility(true, "after showing with the toggle button");
+ // Focus is restored to the mailButton.
+ Assert.ok(
+ mailButton.matches(":focus"),
+ "Mail button should become focused again"
+ );
+
+ // Clicked buttons open or move to the correct tab, starting with just one tab
+ // open.
+ await sub_test_cycle_through_primary_tabs();
+});
+
+add_task(async function testSpacesToolbarContextMenu() {
+ let tabmail = document.getElementById("tabmail");
+ let firstMailTabInfo = tabmail.currentTabInfo;
+ firstMailTabInfo.folder = folderB;
+
+ // Fetch context menu elements.
+ let contextMenu = document.getElementById("spacesContextMenu");
+ let newTabItem = document.getElementById("spacesContextNewTabItem");
+ let newWindowItem = document.getElementById("spacesContextNewWindowItem");
+
+ let settingsMenu = document.getElementById("settingsContextMenu");
+ let settingsItem = document.getElementById("settingsContextOpenSettingsItem");
+ let accountItem = document.getElementById(
+ "settingsContextOpenAccountSettingsItem"
+ );
+ let addonsItem = document.getElementById("settingsContextOpenAddonsItem");
+
+ /**
+ * Open the context menu, test its state, select an action and wait for it to
+ * close.
+ *
+ * @param {object} input - Input data.
+ * @param {Element} input.button - The button whose context menu should be
+ * opened.
+ * @param {Element} [input.item] - The context menu item to select. Either
+ * this or switchItem must be given.
+ * @param {number} [input.switchItem] - The nth switch-to-tab item to select.
+ * @param {object} expect - The expected state of the context menu when
+ * opened.
+ * @param {boolean} [expect.settings=false] - Whether we expect the settings
+ * context menu. If this is true, the other values are ignored.
+ * @param {boolean} [expect.newTab=false] - Whether we expect the "Open in new
+ * tab" item to be visible.
+ * @param {boolean} [expect.newWindow=false] - Whether we expect the "Open in
+ * new window" item to be visible.
+ * @param {number} [expect.numSwitch=0] - The expected number of switch-to-tab
+ * items.
+ * @param {string} msg - A message to use in tests.
+ */
+ async function useContextMenu(input, expect, msg) {
+ let menu = expect.settings ? settingsMenu : contextMenu;
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ input.button,
+ { type: "contextmenu" },
+ window
+ );
+ await shownPromise;
+ let item = input.item;
+ if (!expect.settings) {
+ Assert.equal(
+ BrowserTestUtils.is_visible(newTabItem),
+ expect.newTab || false,
+ `Open in new tab item visibility: ${msg}`
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(newWindowItem),
+ expect.newWindow || false,
+ `Open in new window item visibility: ${msg}`
+ );
+ let switchItems = menu.querySelectorAll(".switch-to-tab");
+ Assert.equal(
+ switchItems.length,
+ expect.numSwitch || 0,
+ `Should have the expected number of switch items: ${msg}`
+ );
+ if (!item) {
+ item = switchItems[input.switchItem];
+ }
+ }
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(item);
+ await hiddenPromise;
+ }
+
+ let tabScroll = document.getElementById("tabmail-arrowscrollbox").scrollbox;
+ /**
+ * Ensure the tab is scrolled into view.
+ *
+ * @param {MozTabmailTab} - The tab to scroll into view.
+ */
+ async function scrollToTab(tab) {
+ function tabInView() {
+ let tabRect = tab.getBoundingClientRect();
+ let scrollRect = tabScroll.getBoundingClientRect();
+ return (
+ tabRect.left >= scrollRect.left && tabRect.right <= scrollRect.right
+ );
+ }
+ if (tabInView()) {
+ info(`Tab ${tab.label} already in view`);
+ return;
+ }
+ tab.scrollIntoView();
+ await TestUtils.waitForCondition(
+ tabInView,
+ "Tab should be scrolled into view: " + tab.label
+ );
+ info(`Tab ${tab.label} was scrolled into view`);
+ }
+
+ let numTabs = 0;
+ /**
+ * Wait for and return the latest tab.
+ *
+ * This should be called every time a tab is created so the test can keep
+ * track of the expected number of tabs.
+ *
+ * @returns {MozTabmailTab} - The last tab.
+ */
+ async function waitForNewTab() {
+ numTabs++;
+ let tabs;
+ await TestUtils.waitForCondition(() => {
+ tabs = document.querySelectorAll("tab.tabmail-tab");
+ return tabs.length == numTabs;
+ }, `Waiting for ${numTabs} tabs`);
+ return tabs[numTabs - 1];
+ }
+
+ /**
+ * Close a tab and wait for it to close.
+ *
+ * This should be used alongside waitForNewTab so the test can keep track of
+ * the expected number of tabs.
+ *
+ * @param {MozTabmailTab} - The tab to close.
+ */
+ async function closeTab(tab) {
+ numTabs--;
+ await scrollToTab(tab);
+ EventUtils.synthesizeMouseAtCenter(
+ tab.querySelector(".tab-close-button"),
+ {},
+ window
+ );
+ await TestUtils.waitForCondition(
+ () => document.querySelectorAll("tab.tabmail-tab").length == numTabs,
+ "Waiting for tab to close"
+ );
+ }
+
+ let toolbar = document.getElementById("spacesToolbar");
+ /**
+ * Verify the current tab and space match.
+ *
+ * @param {MozTabmailTab} tab - The expected tab.
+ * @param {Element} spaceButton - The expected button to be shown as the
+ * current space in the spaces toolbar.
+ * @param {string} msg - A message to use in tests.
+ */
+ async function assertTab(tab, spaceButton, msg) {
+ await TestUtils.waitForCondition(
+ () => tab.selected,
+ `Tab should be selected: ${msg}`
+ );
+ let current = toolbar.querySelectorAll("button.current");
+ Assert.equal(current.length, 1, `Should have one current space: ${msg}`);
+ Assert.equal(
+ current[0],
+ spaceButton,
+ `Current button ${current[0].id} should match: ${msg}`
+ );
+ }
+
+ /**
+ * Click on a tab and verify we have switched tabs and spaces.
+ *
+ * @param {MozTabmailTab} tab - The tab to click.
+ * @param {Element} spaceButton - The expected button to be shown as the
+ * current space after clicking the tab.
+ * @param {string} msg - A message to use in tests.
+ */
+ async function switchTab(tab, spaceButton, msg) {
+ await scrollToTab(tab);
+ EventUtils.synthesizeMouseAtCenter(tab, {}, window);
+ await assertTab(tab, spaceButton, msg);
+ }
+
+ // -- Test initial tab --
+
+ let mailButton = document.getElementById("mailButton");
+ let firstTab = await waitForNewTab();
+ await assertTab(firstTab, mailButton, "First tab is mail tab");
+ await assertMailShown();
+
+ // -- Test spaces that only open one tab --
+
+ let calendarTab;
+ let calendarButton = document.getElementById("calendarButton");
+ for (let { name, button, assertShown } of [
+ {
+ name: "address book",
+ button: document.getElementById("addressBookButton"),
+ assertShown: assertAddressBookShown,
+ },
+ {
+ name: "calendar",
+ button: calendarButton,
+ assertShown: assertCalendarShown,
+ },
+ {
+ name: "tasks",
+ button: document.getElementById("tasksButton"),
+ assertShown: assertTasksShown,
+ },
+ {
+ name: "chat",
+ button: document.getElementById("chatButton"),
+ assertShown: assertChatShown,
+ },
+ ]) {
+ info(`Testing ${name} space`);
+ // Only have option to open in new tab.
+ await useContextMenu(
+ { button, item: newTabItem },
+ { newTab: true },
+ `Opening ${name} tab`
+ );
+ let newTab = await waitForNewTab();
+ if (name == "calendar") {
+ calendarTab = newTab;
+ }
+ await assertTab(newTab, button, `Opened ${name} tab`);
+ await assertShown();
+ // Only have option to switch tabs.
+ // Doing this from the same tab does nothing.
+ await useContextMenu(
+ { button, switchItem: 0 },
+ { numSwitch: 1 },
+ `When ${name} tab is open`
+ );
+ // Wait one tick to allow tabs to potentially change.
+ await TestUtils.waitForTick();
+ // However, the same tab should remain shown.
+ await assertShown();
+
+ // Switch to first tab and back.
+ await switchTab(firstTab, mailButton, `${name} to first tab`);
+ await assertMailShown();
+ await useContextMenu(
+ { button, switchItem: 0 },
+ { numSwitch: 1 },
+ `Switching from first tab to ${name}`
+ );
+ await assertTab(newTab, button, `Switched from first tab to ${name}`);
+ await assertShown();
+ }
+
+ // -- Test opening mail space in a new tab --
+
+ // Open new mail tabs whilst we are still in a non-mail tab.
+ await useContextMenu(
+ { button: mailButton, item: newTabItem },
+ { newWindow: true, newTab: true, numSwitch: 1 },
+ "Opening the second mail tab"
+ );
+ let secondMailTab = await waitForNewTab();
+ await assertTab(secondMailTab, mailButton, "Opened second mail tab");
+ await assertMailShown();
+ // Displayed folder should be the same as in the first mail tab.
+ let [, secondMailTabInfo] =
+ tabmail._getTabContextForTabbyThing(secondMailTab);
+ await TestUtils.waitForCondition(
+ () => secondMailTabInfo.folder?.URI == folderB.URI,
+ "Should display folder B in the second mail tab"
+ );
+
+ secondMailTabInfo.folder = folderA;
+
+ // Open a new mail tab whilst in a mail tab.
+ await useContextMenu(
+ { button: mailButton, item: newTabItem },
+ { newWindow: true, newTab: true, numSwitch: 2 },
+ "Opening the third mail tab"
+ );
+ let thirdMailTab = await waitForNewTab();
+ await assertTab(thirdMailTab, mailButton, "Opened third mail tab");
+ await assertMailShown();
+ // Displayed folder should be the same as in the mail tab that was in view
+ // when the context menu was opened, rather than the folder in the first tab.
+ let [, thirdMailTabInfo] = tabmail._getTabContextForTabbyThing(thirdMailTab);
+ await TestUtils.waitForCondition(
+ () => thirdMailTabInfo.folder?.URI == folderA.URI,
+ "Should display folder A in the third mail tab"
+ );
+
+ // -- Test switching between the multiple mail tabs --
+
+ await useContextMenu(
+ { button: mailButton, switchItem: 1 },
+ { newWindow: true, newTab: true, numSwitch: 3 },
+ "Switching to second mail tab"
+ );
+ await assertTab(secondMailTab, mailButton, "Switch to second mail tab");
+ await assertMailShown();
+ await useContextMenu(
+ { button: mailButton, switchItem: 0 },
+ { newWindow: true, newTab: true, numSwitch: 3 },
+ "Switching to first mail tab"
+ );
+ await assertTab(firstTab, mailButton, "Switch to first mail tab");
+ await assertMailShown();
+
+ await switchTab(calendarTab, calendarButton, "First mail to calendar tab");
+ await useContextMenu(
+ { button: mailButton, switchItem: 2 },
+ { newWindow: true, newTab: true, numSwitch: 3 },
+ "Switching to third mail tab"
+ );
+ await assertTab(thirdMailTab, mailButton, "Switch to third mail tab");
+ await assertMailShown();
+
+ // -- Test the mail button with multiple mail tabs --
+
+ // Clicking the mail button whilst in the mail space does nothing.
+ // Specifically, we do not want it to take us to the first tab.
+ EventUtils.synthesizeMouseAtCenter(mailButton, {}, window);
+ // Wait one cycle to see if the tab would change.
+ await TestUtils.waitForTick();
+ await assertTab(thirdMailTab, mailButton, "Remain in third tab");
+ await assertMailShown();
+ Assert.equal(
+ thirdMailTabInfo.folder.URI,
+ folderA.URI,
+ "Still display folder A in the third mail tab"
+ );
+
+ // Clicking the mail button whilst in a different space takes us to the first
+ // mail tab.
+ await switchTab(calendarTab, calendarButton, "Third mail to calendar tab");
+ EventUtils.synthesizeMouseAtCenter(mailButton, {}, window);
+ await assertTab(firstTab, mailButton, "Switch to the first mail tab");
+ await assertMailShown();
+ Assert.equal(
+ firstMailTabInfo.folder.URI,
+ folderB.URI,
+ "Still display folder B in the first mail tab"
+ );
+
+ // -- Test opening the mail space in a new window --
+
+ let windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ await useContextMenu(
+ { button: mailButton, item: newWindowItem },
+ { newWindow: true, newTab: true, numSwitch: 3 },
+ "Opening mail tab in new window"
+ );
+ let newMailWindow = await windowPromise;
+ let newTabmail = newMailWindow.document.getElementById("tabmail");
+ // Expect the same folder as the previously focused tab.
+ await TestUtils.waitForCondition(
+ () => newTabmail.currentTabInfo.folder?.URI == folderB.URI,
+ "Waiting for folder B to be displayed in the new window"
+ );
+ Assert.equal(
+ newMailWindow.document.querySelectorAll("tab.tabmail-tab").length,
+ 1,
+ "Should only have one tab in the new window"
+ );
+ await assertMailShown(newMailWindow);
+
+ // -- Test opening different tabs that belong to the settings space --
+
+ let settingsButton = document.getElementById("settingsButton");
+ await useContextMenu(
+ { button: settingsButton, item: accountItem },
+ { settings: true },
+ "Opening account settings"
+ );
+ let accountTab = await waitForNewTab();
+ // Shown as part of the settings space.
+ await assertTab(accountTab, settingsButton, "Opened account settings tab");
+ await assertContentShown("about:accountsettings");
+
+ await useContextMenu(
+ { button: settingsButton, item: settingsItem },
+ { settings: true },
+ "Opening settings"
+ );
+ let settingsTab = await waitForNewTab();
+ // Shown as part of the settings space.
+ await assertTab(settingsTab, settingsButton, "Opened settings tab");
+ await assertSettingsShown();
+
+ await useContextMenu(
+ { button: settingsButton, item: addonsItem },
+ { settings: true },
+ "Opening add-ons"
+ );
+ let addonsTab = await waitForNewTab();
+ // Shown as part of the settings space.
+ await assertTab(addonsTab, settingsButton, "Opened add-ons tab");
+ await assertContentShown("about:addons");
+
+ // -- Test the settings button with multiple settings tabs --
+
+ // Clicking the settings button whilst in the settings space does nothing.
+ EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window);
+ // Wait one cycle to see if the tab would change.
+ await TestUtils.waitForTick();
+ await assertTab(addonsTab, settingsButton, "Remain in add-ons tab");
+ await assertContentShown("about:addons");
+
+ // Clicking the settings button whilst in a different space takes us to the
+ // settings tab, rather than the first tab, since this is the primary tab for
+ // the space.
+ await switchTab(calendarTab, calendarButton, "Add-ons to calendar tab");
+ EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window);
+ await assertTab(settingsTab, settingsButton, "Switch to the settings tab");
+ await assertSettingsShown();
+
+ // Clicking the settings button whilst in a different space and no settings
+ // tab will open a new settings tab, rather than switch to another tab in the
+ // settings space because they are not the primary tab for the space.
+ await closeTab(settingsTab);
+ await switchTab(calendarTab, calendarButton, "Settings to calendar tab");
+ EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window);
+ settingsTab = await waitForNewTab();
+ await assertTab(settingsTab, settingsButton, "Re-opened settings tab");
+ await assertSettingsShown();
+
+ // -- Test opening different settings tabs when they already exist --
+
+ await useContextMenu(
+ { button: settingsButton, item: addonsItem },
+ { settings: true },
+ "Switching to add-ons"
+ );
+ await assertTab(addonsTab, settingsButton, "Switched to add-ons");
+ await assertContentShown("about:addons");
+
+ await useContextMenu(
+ { button: settingsButton, item: accountItem },
+ { settings: true },
+ "Switching to account settings"
+ );
+ await assertTab(accountTab, settingsButton, "Switched to account settings");
+ await assertContentShown("about:accountsettings");
+
+ await useContextMenu(
+ { button: settingsButton, item: settingsItem },
+ { settings: true },
+ "Switching to settings"
+ );
+ await assertTab(settingsTab, settingsButton, "Switched to settings");
+ await assertSettingsShown();
+
+ // -- Test clicking the spaces buttons when all the tabs are already open.
+
+ await sub_test_cycle_through_primary_tabs();
+
+ // Tidy up the opened window.
+ // FIXME: Closing the window earlier in the test causes a test failure on the
+ // osx build on the try server.
+ await BrowserTestUtils.closeWindow(newMailWindow);
+});
+
+add_task(async function testSpacesToolbarMenubar() {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+ let spacesToolbar = document.getElementById("spacesToolbar");
+
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("collapseButton"),
+ {},
+ window
+ );
+ Assert.ok(spacesToolbar.hidden, "The spaces toolbar is hidden");
+ Assert.ok(
+ !document.getElementById("spacesToolbarReveal").hidden,
+ "The status bar toggle button is visible"
+ );
+
+ // Test the menubar button.
+ let viewShownPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("menu_View_Popup"),
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("menu_View"),
+ {},
+ window
+ );
+ await viewShownPromise;
+
+ let toolbarsShownPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("view_toolbars_popup"),
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("menu_Toolbars"),
+ {},
+ window
+ );
+ await toolbarsShownPromise;
+
+ let menuButton = document.getElementById("viewToolbarsPopupSpacesToolbar");
+ Assert.ok(
+ menuButton.getAttribute("checked") != "true",
+ "The menu item is not checked"
+ );
+
+ let viewHiddenPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("menu_View_Popup"),
+ "popuphidden"
+ );
+ EventUtils.synthesizeMouseAtCenter(menuButton, {}, window);
+ await viewHiddenPromise;
+
+ Assert.ok(
+ menuButton.getAttribute("checked") == "true",
+ "The menu item is checked"
+ );
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+add_task(async function testSpacesToolbarOSX() {
+ let size = document
+ .getElementById("spacesToolbar")
+ .getBoundingClientRect().width;
+
+ // By default, macOS shouldn't need any custom styling.
+ Assert.ok(
+ !document.getElementById("titlebar").hasAttribute("style"),
+ "The custom styling was cleared from all toolbars"
+ );
+
+ let styleAppliedPromise = BrowserTestUtils.waitForCondition(
+ () =>
+ document.getElementById("tabmail-tabs").getAttribute("style") ==
+ `margin-inline-start: ${size}px;`,
+ "The correct style was applied to the tabmail"
+ );
+
+ // Force full screen.
+ window.fullScreen = true;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await styleAppliedPromise;
+
+ let styleRemovedPromise = BrowserTestUtils.waitForCondition(
+ () => !document.getElementById("tabmail-tabs").hasAttribute("style"),
+ "The custom styling was cleared from all toolbars"
+ );
+ // Restore original window size.
+ window.fullScreen = false;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await styleRemovedPromise;
+}).__skipMe = AppConstants.platform != "macosx";
+
+add_task(async function testSpacesToolbarClearedAlignment() {
+ // Hide the spaces toolbar to check if the style it's cleared.
+ window.gSpacesToolbar.toggleToolbar(true);
+ Assert.ok(
+ !document.getElementById("titlebar").hasAttribute("style") &&
+ !document.getElementById("navigation-toolbox").hasAttribute("style"),
+ "The custom styling was cleared from all toolbars"
+ );
+});
+
+add_task(async function testSpacesToolbarExtension() {
+ window.gSpacesToolbar.toggleToolbar(false);
+
+ for (let i = 0; i < 6; i++) {
+ await window.gSpacesToolbar.createToolbarButton(`testButton${i}`, {
+ title: `Title ${i}`,
+ url: `https://test.invalid/${i}`,
+ iconStyles: new Map([
+ [
+ "--webextension-toolbar-image",
+ 'url("chrome://messenger/content/extension.svg")',
+ ],
+ ]),
+ });
+ let button = document.getElementById(`testButton${i}`);
+ Assert.ok(button);
+ Assert.equal(button.title, `Title ${i}`);
+
+ let img = button.querySelector("img");
+ Assert.equal(
+ img.style.getPropertyValue("--webextension-toolbar-image"),
+ `url("chrome://messenger/content/extension.svg")`,
+ `Button image should have the correct icon.`
+ );
+
+ let menuitem = document.getElementById(`testButton${i}-menuitem`);
+ Assert.ok(menuitem);
+ Assert.equal(menuitem.label, `Title ${i}`);
+ Assert.equal(
+ menuitem.style.getPropertyValue("--webextension-toolbar-image"),
+ `url("chrome://messenger/content/extension.svg")`,
+ `Menuitem should have the correct icon.`
+ );
+
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `testButton${i}`
+ );
+ Assert.ok(space);
+ Assert.equal(
+ space.url,
+ `https://test.invalid/${i}`,
+ "Added url should be correct."
+ );
+ }
+
+ for (let i = 0; i < 6; i++) {
+ await window.gSpacesToolbar.updateToolbarButton(`testButton${i}`, {
+ title: `Modified Title ${i}`,
+ url: `https://test.invalid/${i + 1}`,
+ iconStyles: new Map([
+ [
+ "--webextension-toolbar-image",
+ 'url("chrome://messenger/skin/icons/new-addressbook.svg")',
+ ],
+ ]),
+ });
+ let button = document.getElementById(`testButton${i}`);
+ Assert.ok(button);
+ Assert.equal(button.title, `Modified Title ${i}`);
+
+ let img = button.querySelector("img");
+ Assert.equal(
+ img.style.getPropertyValue("--webextension-toolbar-image"),
+ `url("chrome://messenger/skin/icons/new-addressbook.svg")`,
+ `Button image should have the correct icon.`
+ );
+
+ let menuitem = document.getElementById(`testButton${i}-menuitem`);
+ Assert.ok(menuitem);
+ Assert.equal(
+ menuitem.label,
+ `Modified Title ${i}`,
+ "Updated title should be correct."
+ );
+ Assert.equal(
+ menuitem.style.getPropertyValue("--webextension-toolbar-image"),
+ `url("chrome://messenger/skin/icons/new-addressbook.svg")`,
+ `Menuitem should have the correct icon.`
+ );
+
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `testButton${i}`
+ );
+ Assert.ok(space);
+ Assert.equal(
+ space.url,
+ `https://test.invalid/${i + 1}`,
+ "Updated url should be correct."
+ );
+ }
+
+ let overflowButton = document.getElementById(
+ "spacesToolbarAddonsOverflowButton"
+ );
+
+ let originalHeight = window.outerHeight;
+ // Set a ridiculous tiny height to be sure all add-on buttons are hidden.
+ window.resizeTo(window.outerWidth, 300);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await BrowserTestUtils.waitForCondition(
+ () => !overflowButton.hidden,
+ "The overflow button is visible"
+ );
+
+ let overflowPopup = document.getElementById("spacesToolbarAddonsPopup");
+ let popupshown = BrowserTestUtils.waitForEvent(overflowPopup, "popupshown");
+ overflowButton.click();
+ await popupshown;
+
+ Assert.ok(overflowPopup.hasChildNodes());
+
+ let popuphidden = BrowserTestUtils.waitForEvent(overflowPopup, "popuphidden");
+ // Restore the original height.
+ window.resizeTo(window.outerWidth, originalHeight);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ await popuphidden;
+ await BrowserTestUtils.waitForCondition(
+ () => overflowButton.hidden,
+ "The overflow button is hidden"
+ );
+
+ // Remove all previously added toolbar buttons and make sure all previously
+ // generate elements are properly cleared.
+ for (let i = 0; i < 6; i++) {
+ await window.gSpacesToolbar.removeToolbarButton(`testButton${i}`);
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `testButton${i}`
+ );
+ Assert.ok(!space);
+
+ let button = document.getElementById(`testButton${i}`);
+ Assert.ok(!button);
+
+ let menuitem = document.getElementById(`testButton${i}-menuitem`);
+ Assert.ok(!menuitem);
+ }
+});
+
+add_task(function testPinnedSpacesBadge() {
+ window.gSpacesToolbar.toggleToolbar(true);
+ let spacesPinnedButton = document.getElementById("spacesPinnedButton");
+ let spacesPopupButtonChat = document.getElementById("spacesPopupButtonChat");
+
+ window.gSpacesToolbar.updatePinnedBadgeState();
+
+ Assert.ok(
+ !spacesPinnedButton.classList.contains("has-badge"),
+ "Pinned button does not indicate badged items without any"
+ );
+
+ spacesPopupButtonChat.classList.add("has-badge");
+ window.gSpacesToolbar.updatePinnedBadgeState();
+
+ Assert.ok(
+ spacesPinnedButton.classList.contains("has-badge"),
+ "Pinned button indicates it has badged items"
+ );
+
+ spacesPopupButtonChat.classList.remove("has-badge");
+ window.gSpacesToolbar.updatePinnedBadgeState();
+
+ Assert.ok(
+ !spacesPinnedButton.classList.contains("has-badge"),
+ "Badge state is reset from pinned button"
+ );
+});
+
+add_task(async function testSpacesToolbarFocusRing() {
+ // Make sure the spaces toolbar is visible.
+ window.gSpacesToolbar.toggleToolbar(false);
+ // Move the focus ring on the mail toolbar button.
+ document.getElementById("mailButton").focus();
+
+ // Collect an array of all currently visible buttons.
+ let buttons = [
+ ...document.querySelectorAll(".spaces-toolbar-button:not([hidden])"),
+ ];
+
+ // Simulate the Arrow Down keypress to make sure the correct button gets the
+ // focus.
+ for (let i = 1; i < buttons.length; i++) {
+ let previousElement = document.activeElement;
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ buttons[i].id,
+ "The next button is focused"
+ );
+ Assert.ok(
+ document.activeElement.tabIndex == 0 && previousElement.tabIndex == -1,
+ "The roving tab index was updated"
+ );
+ }
+
+ // Do the same with the Arrow Up key press but reversing the array.
+ buttons.reverse();
+ for (let i = 1; i < buttons.length; i++) {
+ let previousElement = document.activeElement;
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ buttons[i].id,
+ "The previous button is focused"
+ );
+ Assert.ok(
+ document.activeElement.tabIndex == 0 && previousElement.tabIndex == -1,
+ "The roving tab index was updated"
+ );
+ }
+
+ // Pressing the END key should move the focus down to the last available
+ // button.
+ EventUtils.synthesizeKey("KEY_End", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ "collapseButton",
+ "The last button is focused"
+ );
+
+ // Pressing the HOME key should move the focus up to the first available
+ // button.
+ EventUtils.synthesizeKey("KEY_Home", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ "mailButton",
+ "The first button is focused"
+ );
+
+ // Focus follows the mouse click.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendarButton"),
+ {},
+ window
+ );
+ Assert.equal(
+ document.activeElement.id,
+ "calendarButton",
+ "Focus should move to the clicked calendar button"
+ );
+
+ // Now press a key to make sure roving index was updated with the click.
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ "tasksButton",
+ "Focus should move to the tasks button"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js b/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js
new file mode 100644
index 0000000000..7fc2cc3ae5
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js
@@ -0,0 +1,119 @@
+/* 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 the spaces toolbar customization features.
+ */
+
+const BACKGROUND = "#f00000";
+const ICON = "#00ff0b";
+const ACCENT = "#0300ff";
+const ACCENT_ICON = "#fff600";
+
+const INPUTS = {
+ spacesBackgroundColor: BACKGROUND,
+ spacesIconsColor: ICON,
+ spacesAccentTextColor: ACCENT,
+ spacesAccentBgColor: ACCENT_ICON,
+};
+
+registerCleanupFunction(async () => {
+ // Reset all colors.
+ window.gSpacesToolbar.resetColorCustomization();
+ window.gSpacesToolbar.closeCustomize();
+});
+
+async function sub_test_open_customize_panel() {
+ // Open the panel.
+ let menu = document.getElementById("spacesToolbarContextMenu");
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("spacesToolbar"),
+ { type: "contextmenu" },
+ window
+ );
+ await shownPromise;
+
+ let panel = document.getElementById("spacesToolbarCustomizationPanel");
+ let panelShownPromise = BrowserTestUtils.waitForEvent(panel, "popupshown");
+ menu.activateItem(document.getElementById("spacesToolbarContextCustomize"));
+ await panelShownPromise;
+}
+
+function sub_test_apply_colors_to_inputs() {
+ for (let key in INPUTS) {
+ let input = document.getElementById(`${key}`);
+ input.value = INPUTS[key];
+ // We need to force dispatch the onchange event otherwise the listener won't
+ // fire since we're programmatically changing the color value.
+ input.dispatchEvent(new Event("change"));
+ }
+}
+
+/**
+ * Check the current state of the custom color properties applied to the
+ * document style.
+ *
+ * @param {boolean} empty - If the style properties should be empty or filled.
+ */
+function sub_test_check_for_style_properties(empty) {
+ let style = document.documentElement.style;
+ if (empty) {
+ Assert.equal(style.getPropertyValue("--spaces-bg-color"), "");
+ Assert.equal(style.getPropertyValue("--spaces-button-text-color"), "");
+ Assert.equal(
+ style.getPropertyValue("--spaces-button-active-text-color"),
+ ""
+ );
+ Assert.equal(style.getPropertyValue("--spaces-button-active-bg-color"), "");
+ return;
+ }
+
+ Assert.equal(style.getPropertyValue("--spaces-bg-color"), BACKGROUND);
+ Assert.equal(style.getPropertyValue("--spaces-button-text-color"), ICON);
+ Assert.equal(
+ style.getPropertyValue("--spaces-button-active-text-color"),
+ ACCENT
+ );
+ Assert.equal(
+ style.getPropertyValue("--spaces-button-active-bg-color"),
+ ACCENT_ICON
+ );
+}
+
+add_task(async function testSpacesToolbarCustomizationPanel() {
+ // Make sure we're starting from a clean state.
+ window.gSpacesToolbar.resetColorCustomization();
+
+ await sub_test_open_customize_panel();
+
+ // Current colors should be clear.
+ sub_test_check_for_style_properties(true);
+
+ // Test color preview.
+ sub_test_apply_colors_to_inputs();
+ sub_test_check_for_style_properties();
+
+ // Reset should clear all applied colors.
+ window.gSpacesToolbar.resetColorCustomization();
+ window.gSpacesToolbar.closeCustomize();
+ sub_test_check_for_style_properties(true);
+
+ await sub_test_open_customize_panel();
+ // Set colors again.
+ sub_test_apply_colors_to_inputs();
+
+ // "Done" should close the panel and apply all colors.
+ window.gSpacesToolbar.closeCustomize();
+ sub_test_check_for_style_properties();
+
+ // Open the panel and click reset.
+ await sub_test_open_customize_panel();
+ window.gSpacesToolbar.resetColorCustomization();
+ sub_test_check_for_style_properties(true);
+
+ // "Done" should restore the custom colors.
+ window.gSpacesToolbar.closeCustomize();
+ sub_test_check_for_style_properties(true);
+});
diff --git a/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js b/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js
new file mode 100644
index 0000000000..f6cf64cf57
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js
@@ -0,0 +1,24 @@
+/* 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 subtest shared with tabs-in-titlebar tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_spacesToolbar.js", gTestPath).href,
+ this
+);
+
+registerCleanupFunction(async () => {
+ // Reset the menubar visibility.
+ let menubar = document.getElementById("toolbar-menubar");
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
+
+add_task(async function testSpacesToolbarAlignment() {
+ // Hide titlebar in toolbar, show menu.
+ await sub_test_toolbar_alignment(false, false);
+ // Hide titlebar in toolbar, hide menu.
+ await sub_test_toolbar_alignment(false, true);
+});
diff --git a/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js b/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js
new file mode 100644
index 0000000000..5633adad6f
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js
@@ -0,0 +1,24 @@
+/* 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 subtest shared with non-tabs-in-titlebar tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_spacesToolbar.js", gTestPath).href,
+ this
+);
+
+registerCleanupFunction(async () => {
+ // Reset the menubar visibility.
+ let menubar = document.getElementById("toolbar-menubar");
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
+
+add_task(async function testSpacesToolbarAlignment() {
+ // Show titlebar in toolbar, show menu.
+ await sub_test_toolbar_alignment(true, false);
+ // Show titlebar in toolbar, hide menu.
+ await sub_test_toolbar_alignment(true, true);
+});
diff --git a/comm/mail/base/test/browser/browser_statusFeedback.js b/comm/mail/base/test/browser/browser_statusFeedback.js
new file mode 100644
index 0000000000..66132aa484
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_statusFeedback.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/. */
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const statusText = document.getElementById("statusText");
+const tabmail = document.getElementById("tabmail");
+const about3Pane = tabmail.currentAbout3Pane;
+const { threadTree } = about3Pane;
+
+add_setup(async function () {
+ // Create an account for the test.
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ // Create a folder for the account to store test messages.
+ const rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("statusFeedback", null);
+ const testFolder = rootFolder
+ .getChildNamed("statusFeedback")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ // Generate a test message.
+ const generator = new MessageGenerator();
+ testFolder.addMessage(generator.makeMessage().toMboxString());
+
+ // Use the test folder.
+ about3Pane.displayFolder(testFolder.URI);
+ await ensure_cards_view();
+
+ // Remove test account on cleanup.
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+/**
+ * Tests that the correct status message appears when opening a message.
+ */
+add_task(async function testMessageOpen() {
+ const row = threadTree.getRowAtIndex(0);
+ const subjectLine = row.querySelector(
+ ".thread-card-subject-container .subject"
+ );
+
+ // Click on the email.
+ const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ subjectLine,
+ { clickCount: 1 },
+ about3Pane
+ );
+ await selectPromise;
+
+ // Check the value of the status message.
+ Assert.equal(
+ statusText.value,
+ "Loading Message…",
+ "correct status message is shown"
+ );
+
+ // Check that the status message eventually reset
+ await TestUtils.waitForCondition(
+ () => statusText.value == "",
+ "status message should eventually reset"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_tabIcon.js b/comm/mail/base/test/browser/browser_tabIcon.js
new file mode 100644
index 0000000000..ae9d3a057f
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_tabIcon.js
@@ -0,0 +1,99 @@
+/* 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 { GlodaIndexer } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaIndexer.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+const TEST_DOCUMENT_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html";
+const TEST_IMAGE_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png";
+
+let tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, testMessages;
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("tabIcon", null);
+ testFolder = rootFolder
+ .getChildNamed("tabIcon")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ let messageFile = new FileUtils.File(
+ getTestFilePath("files/sampleContent.eml")
+ );
+ Assert.ok(messageFile.exists(), "test data file should exist");
+ let promiseCopyListener = new PromiseTestUtils.PromiseCopyListener();
+ // Copy gIncomingMailFile into the Inbox.
+ MailServices.copy.copyFileMessage(
+ messageFile,
+ testFolder,
+ null,
+ false,
+ 0,
+ "",
+ promiseCopyListener,
+ null
+ );
+ await promiseCopyListener.promise;
+ testMessages = [...testFolder.messages];
+ tabmail.currentAbout3Pane.displayFolder(testFolder);
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testMsgInFolder() {
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 0;
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentAboutMessage.getMessagePaneBrowser()
+ );
+ let icon = tabmail.tabInfo[0].tabNode.querySelector(".tab-icon-image");
+ await TestUtils.waitForCondition(() => icon.complete, "Icon loaded");
+ Assert.equal(
+ icon.src,
+ "chrome://messenger/skin/icons/new/compact/folder.svg"
+ );
+});
+
+add_task(async function testMsgInTab() {
+ window.OpenMessageInNewTab(testMessages[0], { background: false });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+ let tab = tabmail.tabInfo[1];
+ let icon = tab.tabNode.querySelector(".tab-icon-image");
+ await TestUtils.waitForCondition(() => icon.complete, "Icon loaded");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/compact/draft.svg");
+});
+
+add_task(async function testContentTab() {
+ let tab = window.openTab("contentTab", {
+ url: TEST_DOCUMENT_URL,
+ background: false,
+ });
+ await BrowserTestUtils.browserLoaded(tab.browser);
+
+ let icon = tab.tabNode.querySelector(".tab-icon-image");
+
+ // Start of TEST_IMAGE_URL as data url.
+ await TestUtils.waitForCondition(
+ () => icon.src.startsWith("data:image/png;base64,iVBORw0KGgoAAAANSUhEU"),
+ "Waited for icon to be correct"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_tagsMode.js b/comm/mail/base/test/browser/browser_tagsMode.js
new file mode 100644
index 0000000000..6077897e48
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_tagsMode.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/. */
+
+var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+var { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+let rootFolder;
+let folders = {};
+
+const FOLDER_PREFIX = "mailbox://nobody@smart%20mailboxes/tags/";
+const DEFAULT_TAGS = new Map([
+ ["$label1", { label: "Important", color: "#FF0000" }],
+ ["$label2", { label: "Work", color: "#FF9900" }],
+ ["$label3", { label: "Personal", color: "#009900" }],
+ ["$label4", { label: "To Do", color: "#3333FF" }],
+ ["$label5", { label: "Later", color: "#993399" }],
+]);
+
+add_setup(async function () {
+ let allTags = MailServices.tags.getAllTags();
+ Assert.deepEqual(
+ allTags.map(t => t.key),
+ [...DEFAULT_TAGS.keys()],
+ "sanity check tag keys"
+ );
+ Assert.deepEqual(
+ allTags.map(t => ({ label: t.tag, color: t.color })),
+ [...DEFAULT_TAGS.values()],
+ "sanity check tag labels"
+ );
+
+ about3Pane.folderPane.activeModes = ["all"];
+ resetSmartMailboxes();
+
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ "pop3"
+ );
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ for (let flag of [
+ "Inbox",
+ "Drafts",
+ "Templates",
+ "SentMail",
+ "Archive",
+ "Junk",
+ "Trash",
+ "Queue",
+ "Virtual",
+ ]) {
+ let folder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags[flag]);
+ if (!folder) {
+ folder = rootFolder.createLocalSubfolder(`tagsMode${flag}`);
+ folder.setFlag(Ci.nsMsgFolderFlags[flag]);
+ }
+ folders[flag] = folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ }
+ folders.Plain = rootFolder.createLocalSubfolder("tagsModePlain");
+
+ let msgDatabase = folders.Virtual.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+ folderInfo.setCharProperty("searchStr", "ALL");
+ folderInfo.setCharProperty("searchFolderUri", folders.Inbox.URI);
+
+ registerCleanupFunction(function () {
+ MailServices.accounts.removeAccount(account, false);
+ about3Pane.folderPane.activeModes = ["all"];
+ });
+});
+
+async function checkFolderTree(expectedTags) {
+ let tagsList = about3Pane.folderTree.querySelector(`li[data-mode="tags"] ul`);
+ await TestUtils.waitForCondition(
+ () => tagsList.childElementCount == expectedTags.size,
+ "waiting for folder tree to update"
+ );
+ let keys = expectedTags.keys();
+ let values = expectedTags.values();
+ for (let row of tagsList.children) {
+ let key = keys.next().value;
+ let { label, color } = values.next().value;
+ Assert.equal(row.uri, FOLDER_PREFIX + encodeURIComponent(key));
+ Assert.equal(row.name, label);
+ Assert.equal(row.icon.style.getPropertyValue("--icon-color"), color);
+ }
+ Assert.ok(keys.next().done, "all tags should have a row in the tree");
+}
+
+add_task(async function testFolderTree() {
+ // Check the default tags are shown initially.
+ let expectedTags = new Map(DEFAULT_TAGS);
+ about3Pane.folderPane.activeModes = ["all", "tags"];
+ await checkFolderTree(DEFAULT_TAGS);
+
+ // Add two custom tags and check they are shown.
+ MailServices.tags.addTagForKey("testkey", "testLabel", "#000000", "");
+ await TestUtils.waitForCondition(
+ () => MailServices.tags.getAllTags().length == 6,
+ "waiting for tag to be created"
+ );
+ expectedTags.set("testkey", { label: "testLabel", color: "#000000" });
+ await checkFolderTree(expectedTags);
+
+ MailServices.tags.addTagForKey("anotherkey", "anotherLabel", "#333333", "");
+ await TestUtils.waitForCondition(
+ () => MailServices.tags.getAllTags().length == 7,
+ "waiting for tag to be created"
+ );
+ expectedTags.set("anotherkey", { label: "anotherLabel", color: "#333333" });
+ await checkFolderTree(expectedTags);
+
+ // Delete the first custom tag and check it is removed.
+ MailServices.tags.deleteKey("testkey");
+ await TestUtils.waitForCondition(
+ () => MailServices.tags.getAllTags().length == 6,
+ "waiting for tag to be removed"
+ );
+ expectedTags.delete("testkey");
+ await checkFolderTree(expectedTags);
+
+ // Hide and reinitialise the Tags mode and check the list is the same.
+ about3Pane.folderPane.activeModes = ["all"];
+ about3Pane.folderPane.activeModes = ["all", "tags"];
+ await checkFolderTree(expectedTags);
+
+ // Delete the second custom tag.
+ MailServices.tags.deleteKey("anotherkey");
+ await TestUtils.waitForCondition(
+ () => MailServices.tags.getAllTags().length == 5,
+ "waiting for tag to be removed"
+ );
+ expectedTags.delete("anotherkey");
+ await checkFolderTree(expectedTags);
+});
+
+function checkVirtualFolder(tagKey, tagLabel, expectedFolderURIs) {
+ let folder = MailServices.folderLookup.getFolderForURL(
+ FOLDER_PREFIX + encodeURIComponent(tagKey)
+ );
+ Assert.ok(folder);
+ let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder);
+ Assert.equal(folder.prettyName, tagLabel);
+ Assert.equal(wrappedFolder.searchString, `AND (tag,contains,${tagKey})`);
+ Assert.equal(wrappedFolder.searchFolderURIs, "*");
+
+ about3Pane.displayFolder(folder);
+ Assert.deepEqual(
+ about3Pane.gViewWrapper._underlyingFolders.map(f => f.URI).sort(),
+ expectedFolderURIs.sort()
+ );
+}
+
+add_task(async function testFolderSelection() {
+ let expectedFolderURIs = [
+ folders.Inbox.URI,
+ folders.Drafts.URI,
+ folders.Templates.URI,
+ folders.SentMail.URI,
+ folders.Archive.URI,
+ folders.Plain.URI,
+ ];
+
+ for (let [key, { label }] of DEFAULT_TAGS) {
+ checkVirtualFolder(key, label, expectedFolderURIs);
+ }
+
+ // Add another plain folder. It should be added to the searched folders.
+ let newPlainFolder = rootFolder.createLocalSubfolder("tagsModePlain2");
+ expectedFolderURIs.push(newPlainFolder.URI);
+ checkVirtualFolder("$label1", "Important", expectedFolderURIs);
+
+ // Add a subfolder to the inbox. It should be added to the searched folders.
+ let newInboxFolder = folders.Inbox.createLocalSubfolder("tagsModeInbox2");
+ expectedFolderURIs.push(newInboxFolder.URI);
+ checkVirtualFolder("$label2", "Work", expectedFolderURIs);
+
+ // Add a subfolder to the trash. It should NOT be added to the searched folders.
+ folders.Trash.createLocalSubfolder("tagsModeTrash2");
+ checkVirtualFolder("$label1", "Important", expectedFolderURIs);
+
+ let rssAccount = FeedUtils.createRssAccount("rss");
+ let rssRootFolder = rssAccount.incomingServer.rootFolder;
+ FeedUtils.subscribeToFeed(
+ "https://example.org/browser/comm/mail/base/test/browser/files/rss.xml?tagsMode",
+ rssRootFolder,
+ null
+ );
+ await TestUtils.waitForCondition(() => rssRootFolder.subFolders.length == 2);
+ let rssFeedFolder = rssRootFolder.getChildNamed("Test Feed");
+
+ expectedFolderURIs.push(rssFeedFolder.URI);
+ checkVirtualFolder("$label2", "Work", expectedFolderURIs);
+
+ // Delete the smart mailboxes server and check it is correctly recreated.
+ about3Pane.folderPane.activeModes = ["all"];
+ resetSmartMailboxes();
+ about3Pane.folderPane.activeModes = ["all", "tags"];
+
+ for (let [key, { label }] of DEFAULT_TAGS) {
+ checkVirtualFolder(key, label, expectedFolderURIs);
+ }
+
+ MailServices.accounts.removeAccount(rssAccount, false);
+});
diff --git a/comm/mail/base/test/browser/browser_threadTreeDeleting.js b/comm/mail/base/test/browser/browser_threadTreeDeleting.js
new file mode 100644
index 0000000000..e034fbbfb3
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_threadTreeDeleting.js
@@ -0,0 +1,572 @@
+/* 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);
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let threadTree = about3Pane.threadTree;
+// Not `currentAboutMessage` as (a) that's null right now, and (b) we'll be
+// testing things that happen when about:message is hidden.
+let aboutMessage = about3Pane.messageBrowser.contentWindow;
+let messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+let multiMessageView = about3Pane.multiMessageBrowser.contentWindow;
+let generator = new MessageGenerator();
+let rootFolder, sourceMessageIDs;
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+/** Test a real folder, unthreaded. */
+add_task(async function testUnthreaded() {
+ let folderA = rootFolder
+ .createLocalSubfolder("threadTreeDeletingA")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderA.addMessageBatch(
+ generator.makeMessages({ count: 15 }).map(message => message.toMboxString())
+ );
+
+ sourceMessageIDs = Array.from(folderA.messages, m => m.messageId);
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderA.URI,
+ });
+ await ensure_cards_view();
+ goDoCommand("cmd_sort", { target: { value: "unthreaded" } });
+
+ await subtest();
+});
+
+/** Test a real folder with threads. */
+add_task(async function testThreaded() {
+ let folderB = rootFolder
+ .createLocalSubfolder("threadTreeDeletingB")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderB.addMessageBatch(
+ [
+ // No real reason for the values here other than the total count.
+ ...generator.makeMessages({ count: 4 }),
+ ...generator.makeMessages({ count: 6, msgsPerThread: 3 }),
+ ...generator.makeMessages({ count: 1 }),
+ ...generator.makeMessages({ count: 2, msgsPerThread: 2 }),
+ ...generator.makeMessages({ count: 2 }),
+ ].map(message => message.toMboxString())
+ );
+
+ sourceMessageIDs = Array.from(folderB.messages, m => m.messageId);
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderB.URI,
+ });
+ goDoCommand("cmd_sort", { target: { value: "threaded" } });
+ goDoCommand("cmd_expandAllThreads");
+
+ await subtest();
+});
+
+/** Test a virtual folder with a single backing folder. */
+add_task(async function testSingleVirtual() {
+ let folderC = rootFolder
+ .createLocalSubfolder("threadTreeDeletingC")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderC.addMessageBatch(
+ generator.makeMessages({ count: 15 }).map(message => message.toMboxString())
+ );
+
+ let virtualFolderC = rootFolder.createLocalSubfolder(
+ "threadTreeDeletingVirtualC"
+ );
+ virtualFolderC.setFlag(Ci.nsMsgFolderFlags.Virtual);
+ let folderInfoC = virtualFolderC.msgDatabase.dBFolderInfo;
+ // Search for something instead of all messages, as the "ALL" search could
+ // detected and the backing folder displayed instead, defeating the point of
+ // this test.
+ folderInfoC.setCharProperty("searchStr", "AND (date,is after,31-Dec-1999)");
+ folderInfoC.setCharProperty("searchFolderUri", folderC.URI);
+
+ sourceMessageIDs = Array.from(folderC.messages, m => m.messageId);
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: virtualFolderC.URI,
+ });
+
+ await subtest();
+});
+
+/** Test a virtual folder with multiple backing folders. */
+add_task(async function testXFVirtual() {
+ let folderD = rootFolder
+ .createLocalSubfolder("threadTreeDeletingD")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderD.addMessageBatch(
+ generator.makeMessages({ count: 4 }).map(message => message.toMboxString())
+ );
+
+ let folderE = rootFolder
+ .createLocalSubfolder("threadTreeDeletingE")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderE.addMessageBatch(
+ generator
+ .makeMessages({ count: 11, msgsPerThread: 3 })
+ .map(message => message.toMboxString())
+ );
+
+ let virtualFolderDE = rootFolder.createLocalSubfolder(
+ "threadTreeDeletingVirtualDE"
+ );
+ virtualFolderDE.setFlag(Ci.nsMsgFolderFlags.Virtual);
+ let folderInfoY = virtualFolderDE.msgDatabase.dBFolderInfo;
+ folderInfoY.setCharProperty("searchStr", "AND (date,is after,31-Dec-1999)");
+ folderInfoY.setCharProperty(
+ "searchFolderUri",
+ `${folderD.URI}|${folderE.URI}`
+ );
+
+ sourceMessageIDs = [
+ ...Array.from(folderD.messages, m => m.messageId),
+ ...Array.from(folderE.messages, m => m.messageId),
+ ];
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: virtualFolderDE.URI,
+ });
+ goDoCommand("cmd_sort", { target: { value: "threaded" } });
+ goDoCommand("cmd_expandAllThreads");
+
+ await subtest();
+});
+
+/** Test a real folder with a quick filter applied. */
+add_task(async function testQuickFiltered() {
+ let folderF = rootFolder
+ .createLocalSubfolder("threadTreeDeletingF")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderF.addMessageBatch(
+ generator.makeMessages({ count: 30 }).map(message => message.toMboxString())
+ );
+ let flaggedMessages = [];
+ let i = 0;
+ for (let message of folderF.messages) {
+ if (i++ % 2) {
+ flaggedMessages.push(message);
+ }
+ }
+ folderF.markMessagesFlagged(flaggedMessages, true);
+
+ sourceMessageIDs = flaggedMessages.map(m => m.messageId);
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderF.URI,
+ });
+ let filterer = about3Pane.quickFilterBar.filterer;
+ filterer.clear();
+ filterer.visible = true;
+ filterer.setFilterValue("starred", true);
+ about3Pane.quickFilterBar.updateSearch();
+
+ await subtest();
+});
+
+/** Test a folder sorted by date descending. */
+add_task(async function testSortDescending() {
+ let folderG = rootFolder
+ .createLocalSubfolder("threadTreeDeletingG")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderG.addMessageBatch(
+ generator.makeMessages({ count: 15 }).map(message => message.toMboxString())
+ );
+
+ sourceMessageIDs = Array.from(folderG.messages, m => m.messageId).reverse();
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderG.URI,
+ });
+ goDoCommand("cmd_sort", { target: { value: "descending" } });
+
+ await subtest();
+});
+
+/** Test a folder sorted by subject. */
+add_task(async function testSortBySubject() {
+ let folderH = rootFolder
+ .createLocalSubfolder("threadTreeDeletingH")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderH.addMessageBatch(
+ generator.makeMessages({ count: 15 }).map(message => message.toMboxString())
+ );
+
+ sourceMessageIDs = Array.from(folderH.messages)
+ .sort((m1, m2) => (m1.subject < m2.subject ? -1 : 1))
+ .map(m => m.messageId);
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderH.URI,
+ });
+ goDoCommand("cmd_sort", { target: { value: "bySubject" } });
+
+ await subtest();
+});
+
+/**
+ * Tests that deleting the selected row while smooth-scrolling does not break
+ * the scrolling and leave the tree in a bad scroll position.
+ */
+add_task(async function testDeletionWhileScrolling() {
+ let folderI = rootFolder
+ .createLocalSubfolder("threadTreeDeletingI")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderI.addMessageBatch(
+ generator
+ .makeMessages({ count: 500 })
+ .map(message => message.toMboxString())
+ );
+
+ await ensure_table_view();
+ about3Pane.restoreState({
+ messagePaneVisible: false,
+ folderURI: folderI.URI,
+ });
+
+ const scrollListener = {
+ async promiseScrollingStopped() {
+ this.lastTime = Date.now();
+ await TestUtils.waitForCondition(
+ () => Date.now() - this.lastTime > 1000,
+ "waiting for scrolling to stop"
+ );
+ delete this.direction;
+ delete this.lastPosition;
+ },
+ setScrollExpectation(direction) {
+ this.direction = direction;
+ this.lastPosition = threadTree.scrollTop;
+ },
+ setNoScrollExpectation() {
+ this.direction = 0;
+ },
+ handleEvent(event) {
+ if (this.direction === 0) {
+ Assert.report(true, undefined, undefined, "unexpected scroll event");
+ return;
+ }
+
+ const position = threadTree.scrollTop;
+ if (this.direction == -1) {
+ Assert.lessOrEqual(position, this.lastPosition);
+ } else if (this.direction == 1) {
+ Assert.greaterOrEqual(position, this.lastPosition);
+ }
+ this.lastPosition = position;
+ this.lastTime = Date.now();
+ },
+ };
+
+ async function delayThenPress(millis, key) {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, millis));
+ if (key) {
+ EventUtils.synthesizeKey(key, {}, about3Pane);
+ await TestUtils.waitForTick();
+ }
+ }
+
+ threadTree.addEventListener("scroll", scrollListener);
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = 299;
+ await scrollListener.promiseScrollingStopped();
+
+ let stopPromise = scrollListener.promiseScrollingStopped();
+ scrollListener.setScrollExpectation(-1);
+
+ // Page up a few times then delete some messages.
+
+ await delayThenPress(0, "VK_PAGE_UP");
+ await delayThenPress(60, "VK_PAGE_UP");
+ await delayThenPress(60, "VK_PAGE_UP");
+ await delayThenPress(400, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+
+ await stopPromise;
+ Assert.equal(
+ threadTree.getFirstVisibleIndex(),
+ threadTree.selectedIndex,
+ "selected row should be the first visible row"
+ );
+
+ // Page down a few times then delete some messages.
+
+ stopPromise = scrollListener.promiseScrollingStopped();
+ scrollListener.setScrollExpectation(1);
+
+ await delayThenPress(60, "VK_PAGE_DOWN");
+ await delayThenPress(60, "VK_PAGE_DOWN");
+ await delayThenPress(60, "VK_PAGE_DOWN");
+ await delayThenPress(300, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+
+ await stopPromise;
+ Assert.equal(
+ threadTree.getLastVisibleIndex(),
+ threadTree.selectedIndex,
+ "selected row should be the last visible row"
+ );
+
+ // Select a message somewhere in the middle then delete it.
+
+ scrollListener.setNoScrollExpectation();
+ threadTree.selectedIndex -= 10;
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+
+ await delayThenPress(1000);
+ Assert.less(
+ threadTree.getFirstVisibleIndex(),
+ threadTree.selectedIndex,
+ "selected row should be below the first visible row"
+ );
+ Assert.greater(
+ threadTree.getLastVisibleIndex(),
+ threadTree.selectedIndex,
+ "selected row should be above the last visible row"
+ );
+
+ threadTree.removeEventListener("scroll", scrollListener);
+});
+
+async function subtest() {
+ await TestUtils.waitForCondition(
+ () => threadTree.table.body.rows.length == 15,
+ "waiting for all of the table rows"
+ );
+
+ let dbView = about3Pane.gDBView;
+ let subjects = [];
+ for (let i = 0; i < 15; i++) {
+ subjects.push(dbView.cellTextForColumn(i, "subjectCol"));
+ }
+ verifySubjects(subjects);
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = 3;
+ await messageLoaded(3);
+
+ // Delete a single message.
+ await doDeleteCommand(4);
+ await verifySelection(14, [3], 3);
+ verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(4)]);
+
+ // Delete a single message.
+ await doDeleteCommand(5);
+ await verifySelection(13, [3], 3);
+ verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(5)]);
+
+ // Delete a single message by clicking the about:message Delete button.
+ await doDeleteClick(6);
+ await verifySelection(12, [3], 3);
+ verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(6)]);
+
+ // Delete adjacent messages.
+ threadTree.selectedIndices = [3, 4, 5];
+ threadTree.currentIndex = 6;
+ await doDeleteCommand(9);
+ await verifySelection(9, [3], 3);
+ verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(9)]);
+
+ // Delete non-adjacent messages.
+ threadTree.selectedIndices = [2, 4];
+ threadTree.currentIndex = 4;
+ // We should select the message below the current index, but we select the
+ // message below the first selected one.
+ await doDeleteCommand(9);
+ await verifySelection(7, [2], 2);
+ verifySubjects([
+ subjects[0],
+ subjects[1],
+ subjects[9],
+ ...subjects.slice(11),
+ ]);
+
+ // Delete the last message.
+ threadTree.selectedIndex = 6;
+ await messageLoaded(14);
+ await doDeleteCommand(13);
+ await verifySelection(6, [5], 5);
+ verifySubjects([
+ subjects[0],
+ subjects[1],
+ subjects[9],
+ ...subjects.slice(11, 14),
+ ]);
+
+ // Now cause a delete to happen from outside the UI.
+ await doDeleteExternal(1);
+ await verifySelection(5, [4], 4);
+ verifySubjects([subjects[0], subjects[9], ...subjects.slice(11, 14)]);
+
+ // Delete the selected message from outside the UI.
+ threadTree.selectedIndex = 2;
+ await messageLoaded(11);
+ await doDeleteExternal(2, 12);
+ await verifySelection(4, [2], 2);
+ verifySubjects([subjects[0], subjects[9], ...subjects.slice(12, 14)]);
+}
+
+async function messageLoaded(index) {
+ await BrowserTestUtils.browserLoaded(messagePaneBrowser);
+ Assert.equal(
+ aboutMessage.gMessage.messageId,
+ sourceMessageIDs[index],
+ "correct message loaded"
+ );
+}
+
+async function _doDelete(callback, index, expectedLoad) {
+ let selectCount = 0;
+ let onSelect = () => selectCount++;
+ threadTree.addEventListener("select", onSelect);
+
+ let selectPromise;
+ if (expectedLoad !== undefined) {
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ }
+ await callback();
+ if (selectPromise) {
+ await selectPromise;
+ await messageLoaded(expectedLoad);
+ }
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 25));
+ if (selectPromise) {
+ Assert.equal(selectCount, 1, "'select' event should've happened only once");
+ } else {
+ Assert.equal(selectCount, 0, "'select' event should not have happened");
+ }
+
+ threadTree.removeEventListener("select", onSelect);
+}
+
+async function doDeleteCommand(expectedLoad) {
+ await _doDelete(
+ function () {
+ goDoCommand("cmd_delete");
+ },
+ undefined,
+ expectedLoad
+ );
+}
+
+async function doDeleteClick(expectedLoad) {
+ await _doDelete(
+ function () {
+ let messageView =
+ threadTree.selectedIndices.length == 1
+ ? aboutMessage
+ : multiMessageView;
+ EventUtils.synthesizeMouseAtCenter(
+ messageView.document.getElementById("hdrTrashButton"),
+ {},
+ messageView
+ );
+ },
+ undefined,
+ expectedLoad
+ );
+}
+
+async function doDeleteExternal(index, expectedLoad) {
+ await _doDelete(
+ function () {
+ let message = about3Pane.gDBView.getMsgHdrAt(index);
+ message.folder.deleteMessages(
+ [message], // messages
+ null, // msgWindow
+ true, // deleteStorage
+ false, // isMove
+ null, // listener
+ false // canUndo
+ );
+ },
+ index,
+ expectedLoad
+ );
+}
+
+async function verifySelection(rowCount, selectedIndices, currentIndex) {
+ Assert.equal(threadTree.view.rowCount, rowCount, "row count of view");
+ await TestUtils.waitForCondition(
+ () => threadTree.table.body.rows.length == rowCount,
+ "waiting table row count to match the view's row count"
+ );
+
+ Assert.deepEqual(
+ threadTree.selectedIndices,
+ selectedIndices,
+ "table's selected indices"
+ );
+ let selectedRows = Array.from(threadTree.querySelectorAll(".selected"));
+ Assert.equal(
+ selectedRows.length,
+ selectedIndices.length,
+ "number of rows with .selected class"
+ );
+ for (let index of selectedIndices) {
+ let row = threadTree.getRowAtIndex(index);
+ Assert.ok(
+ selectedRows.includes(row),
+ `.selected row at ${index} is expected`
+ );
+ }
+
+ Assert.equal(threadTree.currentIndex, currentIndex, "table's current index");
+ let currentRows = threadTree.querySelectorAll(".current");
+ Assert.equal(currentRows.length, 1, "one row should have .current");
+ Assert.equal(
+ currentRows[0],
+ threadTree.getRowAtIndex(currentIndex),
+ `.current row at ${currentIndex} is expected`
+ );
+
+ let contextTargetRows = threadTree.querySelectorAll(".context-menu-target");
+ Assert.equal(
+ contextTargetRows.length,
+ 0,
+ "no rows should have .context-menu-target"
+ );
+}
+
+function verifySubjects(expectedSubjects) {
+ let actualSubjects = Array.from(
+ threadTree.table.body.rows,
+ row =>
+ row.querySelector(".thread-card-subject-container > .subject").textContent
+ );
+ Assert.equal(actualSubjects.length, expectedSubjects.length, "row count");
+ for (let i = 0; i < expectedSubjects.length; i++) {
+ Assert.equal(actualSubjects[i], expectedSubjects[i], `subject at ${i}`);
+ }
+}
diff --git a/comm/mail/base/test/browser/browser_threadTreeQuirks.js b/comm/mail/base/test/browser/browser_threadTreeQuirks.js
new file mode 100644
index 0000000000..f0f0012d57
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_threadTreeQuirks.js
@@ -0,0 +1,669 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let threadTree = about3Pane.threadTree;
+// Not `currentAboutMessage` as (a) that's null right now, and (b) we'll be
+// testing things that happen when about:message is hidden.
+let aboutMessage = about3Pane.messageBrowser.contentWindow;
+let messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+let rootFolder, folderA, folderB, trashFolder, sourceMessages, sourceMessageIDs;
+
+add_setup(async function () {
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("threadTreeQuirksA", null);
+ folderA = rootFolder
+ .getChildNamed("threadTreeQuirksA")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ rootFolder.createSubfolder("threadTreeQuirksB", null);
+ folderB = rootFolder.getChildNamed("threadTreeQuirksB");
+ trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+
+ // Make some messages, then change their dates to simulate a different order.
+ let syntheticMessages = generator.makeMessages({
+ count: 15,
+ msgsPerThread: 5,
+ });
+ syntheticMessages[1].date = generator.makeDate();
+ syntheticMessages[2].date = generator.makeDate();
+ syntheticMessages[3].date = generator.makeDate();
+ syntheticMessages[4].date = generator.makeDate();
+
+ folderA.addMessageBatch(
+ syntheticMessages.map(message => message.toMboxString())
+ );
+ sourceMessages = [...folderA.messages];
+ sourceMessageIDs = sourceMessages.map(m => m.messageId);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testExpandCollapseUpdates() {
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderA.URI,
+ });
+
+ // Clicking the twisty to collapse a row should update the message display.
+ goDoCommand("cmd_expandAllThreads");
+ threadTree.selectedIndex = 5;
+ await messageLoaded(10);
+
+ let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(5).querySelector(".twisty"),
+ {},
+ about3Pane
+ );
+ await selectPromise;
+ // Thread root still selected.
+ await validateTree(11, [5], 5);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became visible"
+ );
+
+ // Clicking the twisty to expand a row should update the message display.
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(5).querySelector(".twisty"),
+ {},
+ about3Pane
+ );
+ await selectPromise;
+ await messageLoaded(10);
+ await validateTree(15, [5], 5);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.messageBrowser),
+ "messageBrowser became visible"
+ );
+
+ // Collapsing all rows while the first message in a thread is selected should
+ // update the message display.
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_collapseAllThreads");
+ await selectPromise;
+ // Thread root still selected.
+ await validateTree(3, [1], 1);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became visible"
+ );
+
+ // Expanding all rows while the first message in a thread is selected should
+ // update the message display.
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_expandAllThreads");
+ await selectPromise;
+ await messageLoaded(10);
+ await validateTree(15, [5], 5);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.messageBrowser),
+ "messageBrowser became visible"
+ );
+
+ // Collapsing all rows while a message inside a thread is selected should
+ // select the first message in the thread and update the message display.
+ threadTree.selectedIndex = 2;
+ await messageLoaded(7);
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_collapseAllThreads");
+ await selectPromise;
+ // Thread root became selected.
+ await validateTree(3, [0], 0);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became visible"
+ );
+
+ // Expanding all rows while the first message in a thread is selected should
+ // update the message display. (This is effectively the same test as earlier.)
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_expandAllThreads");
+ await selectPromise;
+ await messageLoaded(5);
+ await validateTree(15, [0], 0);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.messageBrowser),
+ "messageBrowser became visible"
+ );
+
+ // Select several things and collapse all.
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ threadTree.selectedIndices = [2, 3, 5];
+ await selectPromise;
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became visible"
+ );
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_collapseAllThreads");
+ await selectPromise;
+ // Thread roots became selected.
+ await validateTree(3, [0, 1], 1);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser stayed hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser stayed visible"
+ );
+});
+
+add_task(async function testThreadUpdateKeepsSelection() {
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderB.URI,
+ });
+
+ // Put some messages from different threads in the folder and select one.
+ await move([sourceMessages[0]], folderA, folderB);
+ await move([sourceMessages[5]], folderA, folderB);
+ threadTree.selectedIndex = 1;
+ await messageLoaded(5);
+
+ // Move a "newer" message into the folder. This should switch the order of
+ // the threads, but no selection change should occur.
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ await move([sourceMessages[1]], folderA, folderB);
+ // Selection should have moved.
+ await validateTree(2, [0], 0);
+ Assert.equal(
+ aboutMessage.gMessage.messageId,
+ sourceMessageIDs[5],
+ "correct message still loaded"
+ );
+
+ // Wait to prove unwanted selection or load didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ await restoreMessages();
+});
+
+add_task(async function testArchiveDeleteUpdates() {
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderA.URI,
+ });
+ about3Pane.sortController.sortUnthreaded();
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = 3;
+ await messageLoaded(7);
+
+ let selectCount = 0;
+ let onSelect = () => selectCount++;
+ threadTree.addEventListener("select", onSelect);
+
+ let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_delete");
+ await selectPromise;
+ await messageLoaded(8);
+ await validateTree(14, [3], 3);
+ Assert.equal(selectCount, 1, "'select' event should've happened only once");
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_delete");
+ await selectPromise;
+ await messageLoaded(9);
+ await validateTree(13, [3], 3);
+ Assert.equal(selectCount, 2, "'select' event should've happened only once");
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_archive");
+ await selectPromise;
+ await messageLoaded(10);
+ await validateTree(12, [3], 3);
+ Assert.equal(selectCount, 3, "'select' event should've happened only once");
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_archive");
+ await selectPromise;
+ await messageLoaded(11);
+ await validateTree(11, [3], 3);
+ Assert.equal(selectCount, 4, "'select' event should've happened only once");
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_delete");
+ await selectPromise;
+ await messageLoaded(12);
+ await validateTree(10, [3], 3);
+ Assert.equal(selectCount, 5, "'select' event should've happened only once");
+
+ threadTree.removeEventListener("select", onSelect);
+
+ await restoreMessages();
+});
+
+add_task(async function testMessagePaneSelection() {
+ await move(sourceMessages.slice(6, 9), folderA, folderB);
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderB.URI,
+ });
+ about3Pane.sortController.sortUnthreaded();
+ about3Pane.sortController.sortThreadPane("byDate");
+ about3Pane.sortController.sortDescending();
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = 1;
+ await messageLoaded(7);
+ await validateTree(3, [1], 1);
+
+ // Check the initial selection in about:message.
+ Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1);
+ let min = {},
+ max = {};
+ aboutMessage.gDBView.selection.getRangeAt(0, min, max);
+ Assert.equal(min.value, 1);
+ Assert.equal(max.value, 1);
+
+ // Add a new message to the folder, which should appear first.
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ await move(sourceMessages.slice(9, 10), folderA, folderB);
+ await validateTree(4, [2], 2);
+
+ Assert.deepEqual(
+ Array.from(folderB.messages, m => m.messageId),
+ sourceMessageIDs.slice(6, 10),
+ "all expected messages are in the folder"
+ );
+
+ // Check the selection in about:message.
+ Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1);
+ aboutMessage.gDBView.selection.getRangeAt(0, min, max);
+ Assert.equal(min.value, 2);
+ Assert.equal(max.value, 2);
+
+ // Wait to prove unwanted selection or load didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ // Now click the delete button in about:message.
+ let deletePromise = PromiseTestUtils.promiseFolderEvent(
+ folderB,
+ "DeleteOrMoveMsgCompleted"
+ );
+ let loadPromise = messageLoaded(6);
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("hdrTrashButton"),
+ {},
+ aboutMessage
+ );
+ await Promise.all([deletePromise, loadPromise]);
+
+ // Check which message was deleted.
+ Assert.deepEqual(
+ Array.from(trashFolder.messages, m => m.messageId),
+ [sourceMessageIDs[7]],
+ "the right message was deleted"
+ );
+ Assert.deepEqual(
+ Array.from(folderB.messages, m => m.messageId),
+ [sourceMessageIDs[6], sourceMessageIDs[8], sourceMessageIDs[9]],
+ "the right messages were kept"
+ );
+
+ await validateTree(3, [2], 2);
+
+ // Check the selection in about:message again.
+ Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1);
+ aboutMessage.gDBView.selection.getRangeAt(0, min, max);
+ Assert.equal(min.value, 2);
+ Assert.equal(max.value, 2);
+
+ await restoreMessages();
+});
+
+add_task(async function testNonSelectionContextMenu() {
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let openNewTabItem = about3Pane.document.getElementById(
+ "mailContext-openNewTab"
+ );
+ let replyItem = about3Pane.document.getElementById("mailContext-replySender");
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderA.URI,
+ });
+ about3Pane.sortController.sortUnthreaded();
+ threadTree.scrollToIndex(0, true);
+
+ threadTree.selectedIndex = 0;
+ await messageLoaded(0);
+ await validateTree(15, [0], 0);
+ await subtestOpenTab(1, sourceMessageIDs[5]);
+ await subtestReply(6, sourceMessageIDs[10]);
+
+ threadTree.selectedIndices = [3, 6, 9];
+ await BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ false,
+ "about:blank"
+ );
+ await subtestOpenTab(0, sourceMessageIDs[0]);
+
+ async function doContextMenu(testIndex, messageId, itemToActivate) {
+ let originalSelection = threadTree.selectedIndices;
+
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree
+ .getRowAtIndex(testIndex)
+ .querySelector(".thread-card-subject-container"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ Assert.ok(about3Pane.mailContextMenu.selectionIsOverridden);
+ Assert.deepEqual(
+ threadTree.selectedIndices,
+ [testIndex],
+ "selection should be only the right-clicked-on row"
+ );
+ let contextTargetRows = threadTree.querySelectorAll(".context-menu-target");
+ Assert.equal(
+ contextTargetRows.length,
+ 1,
+ "one row should have .context-menu-target"
+ );
+ Assert.equal(
+ contextTargetRows[0].index,
+ testIndex,
+ "correct row has .context-menu-target"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ mailContext,
+ "popuphidden"
+ );
+ mailContext.activateItem(itemToActivate);
+ await hiddenPromise;
+
+ Assert.ok(!about3Pane.mailContextMenu.selectionIsOverridden);
+ Assert.equal(
+ document.activeElement,
+ tabmail.tabInfo[0].chromeBrowser,
+ "about:3pane should have focus after context menu"
+ );
+ Assert.equal(
+ about3Pane.document.activeElement,
+ threadTree.table.body,
+ "table body should have focus after context menu"
+ );
+
+ // Selection should be restored.
+ await validateTree(
+ 15,
+ threadTree.selectedIndices,
+ originalSelection.at(-1)
+ );
+
+ // Wait to prove unwanted selection or load didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+ }
+
+ // Opening a new tab should open the clicked-on message, not the selected.
+ async function subtestOpenTab(testIndex, messageId) {
+ let newAboutMessagePromise = BrowserTestUtils.waitForEvent(
+ tabmail,
+ "aboutMessageLoaded"
+ ).then(async function (event) {
+ await BrowserTestUtils.browserLoaded(
+ event.target.getMessagePaneBrowser()
+ );
+ return event.target;
+ });
+ await doContextMenu(testIndex, messageId, openNewTabItem);
+
+ let newAboutMessage = await newAboutMessagePromise;
+ Assert.equal(
+ newAboutMessage.gMessage.messageId,
+ messageId,
+ "correct message should have opened in a tab"
+ );
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 2,
+ "only one new tab should have opened"
+ );
+ tabmail.closeOtherTabs(0);
+ }
+
+ // Replying should quote the clicked-on message, not the selected, even when
+ // some text is selected in the message pane.
+ async function subtestReply(testIndex, messageId) {
+ Assert.stringContains(
+ messagePaneBrowser.contentDocument.body.textContent,
+ "Hello Bob Bell!"
+ );
+ messagePaneBrowser.contentWindow
+ .getSelection()
+ .selectAllChildren(messagePaneBrowser.contentDocument.body);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ await doContextMenu(testIndex, messageId, replyItem);
+ let composeWindow = await composeWindowPromise;
+ let composeEditor = composeWindow.GetCurrentEditorElement();
+ let composeBody = await TestUtils.waitForCondition(
+ () => composeEditor.contentDocument.body.textContent
+ );
+
+ Assert.stringContains(
+ composeBody,
+ "Hello Felix Flowers!",
+ "new message should quote the right-clicked-on message"
+ );
+ Assert.ok(
+ !composeBody.includes("Hello Bob Bell!"),
+ "new message should not quote the selected message"
+ );
+
+ await BrowserTestUtils.closeWindow(composeWindow);
+ }
+});
+
+async function messageLoaded(index) {
+ await BrowserTestUtils.browserLoaded(messagePaneBrowser);
+ Assert.equal(
+ aboutMessage.gMessage.messageId,
+ sourceMessageIDs[index],
+ "correct message loaded"
+ );
+}
+
+async function validateTree(rowCount, selectedIndices, currentIndex) {
+ Assert.equal(threadTree.view.rowCount, rowCount, "row count of view");
+ await TestUtils.waitForCondition(
+ () => threadTree.table.body.rows.length == rowCount,
+ "waiting table row count to match the view's row count"
+ );
+
+ Assert.deepEqual(
+ threadTree.selectedIndices,
+ selectedIndices,
+ "table's selected indices"
+ );
+ let selectedRows = Array.from(threadTree.querySelectorAll(".selected"));
+ Assert.equal(
+ selectedRows.length,
+ selectedIndices.length,
+ "number of rows with .selected class"
+ );
+ for (let index of selectedIndices) {
+ let row = threadTree.getRowAtIndex(index);
+ Assert.ok(
+ selectedRows.includes(row),
+ `.selected row at ${index} is expected`
+ );
+ }
+
+ Assert.equal(threadTree.currentIndex, currentIndex, "table's current index");
+ let currentRows = threadTree.querySelectorAll(".current");
+ Assert.equal(currentRows.length, 1, "one row should have .current");
+ Assert.equal(
+ currentRows[0],
+ threadTree.getRowAtIndex(currentIndex),
+ ".current row is expected"
+ );
+
+ let contextTargetRows = threadTree.querySelectorAll(".context-menu-target");
+ Assert.equal(
+ contextTargetRows.length,
+ 0,
+ "no rows should have .context-menu-target"
+ );
+}
+
+async function move(messages, source, dest) {
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyMessages(
+ source,
+ messages,
+ dest,
+ true,
+ copyListener,
+ top.msgWindow,
+ false
+ );
+ await copyListener.promise;
+}
+
+function reportBadSelectEvent() {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "should not have fired a select event"
+ );
+}
+
+function reportBadLoad() {
+ Assert.report(true, undefined, undefined, "should not have loaded a message");
+}
+
+async function restoreMessages() {
+ // Move all of the messages back to folder A.
+ await move([...folderB.messages], folderB, folderA);
+ let archiveFolder = rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Archive
+ );
+ if (archiveFolder) {
+ for (let folder of archiveFolder.subFolders) {
+ await move([...folder.messages], folder, folderA);
+ }
+ }
+ await move([...trashFolder.messages], trashFolder, folderA);
+
+ // Restore all of the messages in `sourceMessages`, in the right order.
+ sourceMessages = [...folderA.messages].sort(
+ (a, b) =>
+ sourceMessageIDs.indexOf(a.messageId) -
+ sourceMessageIDs.indexOf(b.messageId)
+ );
+}
+
+add_task(async function testThreadTreeA11yRoles() {
+ Assert.equal(
+ threadTree.table.body.getAttribute("role"),
+ "listbox",
+ "The tree view should be presented as ListBox"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "option",
+ "The message row should be presented as Option"
+ );
+
+ about3Pane.sortController.sortThreaded();
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.table.body.getAttribute("role") == "tree",
+ "The tree view should switch to a Tree View role"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "treeitem",
+ "The message row should be presented as Tree Item"
+ );
+
+ about3Pane.sortController.groupBySort();
+ threadTree.scrollToIndex(0, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.table.body.getAttribute("role") == "tree",
+ "The message list table should remain presented as Tree View"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "treeitem",
+ "The first dummy message row should be presented as Tree Item"
+ );
+
+ about3Pane.sortController.sortUnthreaded();
+});
diff --git a/comm/mail/base/test/browser/browser_threadTreeSorting.js b/comm/mail/base/test/browser/browser_threadTreeSorting.js
new file mode 100644
index 0000000000..be3bf0f2eb
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_threadTreeSorting.js
@@ -0,0 +1,344 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let { sortController, threadTree } = about3Pane;
+let rootFolder, testFolder, sourceMessageIDs;
+let menuHelper = new MenuTestHelper("menu_View");
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false);
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ testFolder = rootFolder
+ .createLocalSubfolder("threadTreeSort")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator
+ .makeMessages({ count: 320 })
+ .map(message => message.toMboxString())
+ );
+
+ about3Pane.restoreState({
+ messagePaneVisible: false,
+ folderURI: testFolder.URI,
+ });
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true);
+ });
+});
+
+add_task(async function () {
+ const messagesByDate = [...testFolder.messages];
+ const messagesBySubject = messagesByDate
+ .slice()
+ .sort((m1, m2) => (m1.subject < m2.subject ? -1 : 1));
+ const dateHeaderButton = about3Pane.document.getElementById("dateCol");
+ const subjectHeaderButton = about3Pane.document.getElementById("subjectCol");
+
+ // Sanity check.
+
+ Assert.equal(
+ about3Pane.gViewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.byDate,
+ "initial sort column should be byDate"
+ );
+ Assert.equal(
+ about3Pane.gViewWrapper.primarySortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "initial sort order should be ascending"
+ );
+ Assert.ok(
+ about3Pane.gViewWrapper.showThreaded,
+ "initial mode should be threaded"
+ );
+ Assert.equal(
+ threadTree.view.rowCount,
+ 320,
+ "correct number of rows in the view"
+ );
+ Assert.equal(
+ threadTree.getLastVisibleIndex(),
+ 319,
+ "should be scrolled to the bottom"
+ );
+
+ Assert.equal(getCardActualSubject(320 - 1), messagesByDate.at(-1).subject);
+ Assert.equal(getCardActualSubject(320 - 2), messagesByDate.at(-2).subject);
+ Assert.equal(getCardActualSubject(320 - 3), messagesByDate.at(-3).subject);
+
+ // Switch to horizontal layout and table view so we can interact with the
+ // table header and sort rows properly.
+ await ensure_table_view();
+
+ // Check sorting with no message selected.
+
+ await clickHeader(dateHeaderButton, "byDate", "descending");
+ Assert.equal(
+ threadTree.view.rowCount,
+ 320,
+ "correct number of rows in the view"
+ );
+ Assert.equal(
+ threadTree.getFirstVisibleIndex(),
+ 0,
+ "should be scrolled to the top"
+ );
+
+ Assert.equal(getActualSubject(0), messagesByDate.at(-1).subject);
+ Assert.equal(getActualSubject(1), messagesByDate.at(-2).subject);
+ Assert.equal(getActualSubject(2), messagesByDate.at(-3).subject);
+
+ await clickHeader(dateHeaderButton, "byDate", "ascending");
+ Assert.equal(
+ threadTree.view.rowCount,
+ 320,
+ "correct number of rows in the view"
+ );
+ Assert.equal(
+ threadTree.getLastVisibleIndex(),
+ 319,
+ "should be scrolled to the bottom"
+ );
+
+ Assert.equal(getActualSubject(320 - 1), messagesByDate.at(-1).subject);
+ Assert.equal(getActualSubject(320 - 2), messagesByDate.at(-2).subject);
+ Assert.equal(getActualSubject(320 - 3), messagesByDate.at(-3).subject);
+
+ // Select a message and check the selection remains after sorting.
+
+ const targetMessage = messagesByDate[49];
+ info(`selecting message "${targetMessage.subject}"`);
+ threadTree.scrollToIndex(49, true);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ threadTree.selectedIndex = 49;
+ verifySelection([49], [targetMessage.subject]);
+
+ await clickHeader(dateHeaderButton, "byDate", "descending");
+ verifySelection([319 - 49], [targetMessage.subject], {
+ where: "last",
+ });
+
+ await clickHeader(dateHeaderButton, "byDate", "ascending");
+ verifySelection([49], [targetMessage.subject], { where: "first" });
+
+ const targetIndexBySubject = messagesBySubject.indexOf(targetMessage);
+
+ // Switch columns.
+
+ await clickHeader(subjectHeaderButton, "bySubject", "ascending");
+ verifySelection([targetIndexBySubject], [targetMessage.subject]);
+
+ await clickHeader(subjectHeaderButton, "bySubject", "descending");
+ verifySelection([319 - targetIndexBySubject], [targetMessage.subject]);
+
+ await clickHeader(subjectHeaderButton, "bySubject", "ascending");
+ verifySelection([targetIndexBySubject], [targetMessage.subject]);
+
+ // Switch back again.
+
+ await clickHeader(dateHeaderButton, "byDate", "ascending");
+ verifySelection([49], [targetMessage.subject], { where: "first" });
+
+ // Select multiple messages, two adjacent to each other, one non-adjacent,
+ // and check the selection remains after sorting.
+
+ const targetMessages = [
+ messagesByDate[80],
+ messagesByDate[81],
+ messagesByDate[83],
+ ];
+ info(
+ `selecting messages "${targetMessages.map(m => m.subject).join('", "')}"`
+ );
+ threadTree.scrollToIndex(83, true);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ threadTree.selectedIndices = [80, 81, 83];
+ verifySelection(
+ [80, 81, 83],
+ [
+ targetMessages[0].subject,
+ targetMessages[1].subject,
+ targetMessages[2].subject,
+ ],
+ { currentIndex: 83 }
+ );
+
+ await clickHeader(dateHeaderButton, "byDate", "descending");
+ verifySelection(
+ [319 - 83, 319 - 81, 319 - 80],
+ [
+ targetMessages[2].subject,
+ // Rows for these two messages probably don't exist yet.
+ // targetMessages[1].subject,
+ // targetMessages[0].subject,
+ ],
+ { where: "last" }
+ );
+
+ await clickHeader(dateHeaderButton, "byDate", "ascending");
+ verifySelection(
+ [80, 81, 83],
+ [
+ // Rows for these two messages probably don't exist yet.
+ undefined, // targetMessages[0].subject,
+ undefined, // targetMessages[1].subject,
+ targetMessages[2].subject,
+ ],
+ { currentIndex: 83, where: "first" }
+ );
+});
+
+async function clickHeader(header, type, order) {
+ info(`sorting ${type} ${order}`);
+ const button = header.querySelector("button");
+
+ let scrollEvents = 0;
+ let listener = () => scrollEvents++;
+
+ threadTree.addEventListener("scroll", listener);
+ EventUtils.synthesizeMouseAtCenter(button, {}, about3Pane);
+
+ // Wait long enough that any more scrolling would trigger more events.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 100));
+ threadTree.removeEventListener("scroll", listener);
+ Assert.lessOrEqual(scrollEvents, 1, "only one scroll event should fire");
+
+ Assert.equal(
+ about3Pane.gViewWrapper.primarySortType,
+ Ci.nsMsgViewSortType[type],
+ `sort type should be ${type}`
+ );
+ Assert.equal(
+ about3Pane.gViewWrapper.primarySortOrder,
+ Ci.nsMsgViewSortOrder[order],
+ `sort order should be ${order}`
+ );
+ Assert.ok(about3Pane.gViewWrapper.showThreaded, "mode should be threaded");
+
+ Assert.ok(
+ button.classList.contains("sorting"),
+ "header button should have the sorted class"
+ );
+ Assert.equal(
+ button.classList.contains("ascending"),
+ order == "ascending",
+ `header button ${
+ order == "ascending" ? "should" : "should not"
+ } have the ascending class`
+ );
+ Assert.equal(
+ button.classList.contains("descending"),
+ order == "descending",
+ `header button ${
+ order == "descending" ? "should" : "should not"
+ } have the descending class`
+ );
+
+ Assert.equal(
+ threadTree.table.header.querySelectorAll(
+ ".sorting, .ascending, .descending"
+ ).length,
+ 1,
+ "no other header buttons should have sorting classes"
+ );
+
+ if (AppConstants.platform != "macosx") {
+ await menuHelper.testItems({
+ viewSortMenu: {},
+ sortByDateMenuitem: { checked: type == "byDate" },
+ sortBySubjectMenuitem: { checked: type == "bySubject" },
+ sortAscending: { checked: order == "ascending" },
+ sortDescending: { checked: order == "descending" },
+ sortThreaded: { checked: true },
+ sortUnthreaded: {},
+ groupBySort: {},
+ });
+ }
+}
+
+function verifySelection(
+ selectedIndices,
+ subjects,
+ { currentIndex, where } = {}
+) {
+ if (currentIndex === undefined) {
+ currentIndex = selectedIndices[0];
+ }
+
+ Assert.deepEqual(
+ threadTree.selectedIndices,
+ selectedIndices,
+ "selectedIndices"
+ );
+ Assert.equal(threadTree.currentIndex, currentIndex, "currentIndex");
+ if (where == "first") {
+ Assert.equal(
+ threadTree.getFirstVisibleIndex(),
+ currentIndex,
+ "currentIndex should be first"
+ );
+ } else {
+ Assert.lessOrEqual(
+ threadTree.getFirstVisibleIndex(),
+ currentIndex,
+ "currentIndex should be at or below first"
+ );
+ }
+ if (where == "last") {
+ Assert.equal(
+ threadTree.getLastVisibleIndex(),
+ currentIndex,
+ "currentIndex should be last"
+ );
+ } else {
+ Assert.greaterOrEqual(
+ threadTree.getLastVisibleIndex(),
+ currentIndex,
+ "currentIndex should be at or above last"
+ );
+ }
+ for (let i = 0; i < subjects.length; i++) {
+ if (subjects[i]) {
+ Assert.equal(getActualSubject(selectedIndices[i]), subjects[i]);
+ }
+ }
+}
+
+function getCardActualSubject(index) {
+ let row = threadTree.getRowAtIndex(index);
+ return row.querySelector(".thread-card-subject-container > .subject")
+ .textContent;
+}
+
+function getActualSubject(index) {
+ let row = threadTree.getRowAtIndex(index);
+ return row.querySelector(".subject-line > span").textContent;
+}
+
+function getActualSubjects() {
+ return Array.from(
+ threadTree.table.body.rows,
+ row => row.querySelector(".subject-line > span").textContent
+ );
+}
diff --git a/comm/mail/base/test/browser/browser_threads.js b/comm/mail/base/test/browser/browser_threads.js
new file mode 100644
index 0000000000..4bfcb5fc11
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_threads.js
@@ -0,0 +1,385 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let { threadPane, threadTree } = about3Pane;
+let { notificationBox } = threadPane;
+let rootFolder, testFolder, testMessages;
+
+add_setup(async function () {
+ Services.prefs.setStringPref(
+ "mail.ignore_thread.learn_more_url",
+ "http://mochi.test:8888/"
+ );
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("threads", null);
+ testFolder = rootFolder
+ .getChildNamed("threads")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ testFolder.addMessageBatch(
+ generator
+ .makeMessages({ count: 25, msgsPerThread: 5 })
+ .map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ about3Pane.displayFolder(testFolder.URI);
+ about3Pane.paneLayout.messagePaneVisible = false;
+ goDoCommand("cmd_expandAllThreads");
+
+ await ensure_table_view();
+
+ // Check the initial state of a sample of messages.
+
+ checkRowThreadState(0, true);
+ checkRowThreadState(1, false);
+ checkRowThreadState(2, false);
+ checkRowThreadState(3, false);
+ checkRowThreadState(4, false);
+ checkRowThreadState(5, true);
+ checkRowThreadState(10, true);
+ checkRowThreadState(15, true);
+ checkRowThreadState(20, true);
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ MailServices.accounts.removeAccount(account, false);
+ about3Pane.paneLayout.messagePaneVisible = true;
+ Services.prefs.clearUserPref("mail.ignore_thread.learn_more_url");
+ });
+});
+
+/**
+ * Test that a double click on a button doesn't trigger the opening of the
+ * message.
+ */
+add_task(async function checkDoubleClickOnThreadButton() {
+ let row = threadTree.getRowAtIndex(20);
+ Assert.ok(
+ !row.classList.contains("collapsed"),
+ "The thread row should be expanded"
+ );
+
+ Assert.equal(tabmail.tabInfo.length, 1, "Only 1 tab currently visible");
+
+ let button = row.querySelector(".thread-container .twisty");
+ // Simulate a double click on the twisty icon.
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 2 }, about3Pane);
+
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "The message wasn't opened in another tab"
+ );
+
+ // Normally a double click on the twisty would close and open the thread, but
+ // this simulated click is too fast and the second click happens before the
+ // row is collapsed. Let's click on it again once to return to the original
+ // state.
+ EventUtils.synthesizeMouseAtCenter(button, {}, about3Pane);
+
+ Assert.ok(
+ !row.classList.contains("collapsed"),
+ "The double click was registered as 2 separate clicks and the thread row is still expanded"
+ );
+});
+
+add_task(async function testIgnoreThread() {
+ // Check the menu items for the root message in a thread.
+
+ threadTree.selectedIndex = 0;
+ await checkMessageMenu({ killThread: false });
+ await checkContextMenu(0, { "mailContext-ignoreThread": false });
+
+ // Check and use the menu items for a message inside a thread. Ignoring a
+ // thread should work from any message in the thread.
+ threadTree.selectedIndex = 2;
+ await checkMessageMenu({ killThread: false });
+ await checkContextMenu(
+ 2,
+ { "mailContext-ignoreThread": false },
+ "mailContext-ignoreThread"
+ );
+
+ // Check the thread is ignored and collapsed.
+
+ checkRowThreadState(0, "ignore");
+ Assert.ok(
+ threadTree.getRowAtIndex(0).classList.contains("collapsed"),
+ "ignored row should have the 'collapsed' class"
+ );
+
+ // Restore the thread using the context menu item.
+
+ threadTree.selectedIndex = 0;
+ await checkMessageMenu({ killThread: true });
+ await checkContextMenu(
+ 0,
+ { "mailContext-ignoreThread": true },
+ "mailContext-ignoreThread"
+ );
+
+ checkRowThreadState(0, true);
+
+ // Ignore the next thread. The first thread was collapsed by ignoring it,
+ // so the next thread is at index 1.
+
+ threadTree.selectedIndex = 1;
+ await checkMessageMenu({ killThread: false });
+ await checkContextMenu(
+ 1,
+ { "mailContext-ignoreThread": false },
+ "mailContext-ignoreThread"
+ );
+
+ checkRowThreadState(1, "ignore");
+ Assert.ok(
+ threadTree.getRowAtIndex(1).classList.contains("collapsed"),
+ "ignored row should have the 'collapsed' class"
+ );
+
+ // Check the notification about the ignored thread.
+
+ let notification =
+ notificationBox.getNotificationWithValue("ignoreThreadInfo");
+ let label = notification.shadowRoot.querySelector(
+ "label.notification-message"
+ );
+ Assert.stringContains(label.textContent, testMessages[5].subject);
+ let buttons = notification.shadowRoot.querySelectorAll(
+ "button.notification-button"
+ );
+ Assert.equal(buttons.length, 2);
+
+ // Click the Learn More button, and check it opens the support page in a new tab.
+ let tabOpenPromise = BrowserTestUtils.waitForEvent(
+ tabmail.tabContainer,
+ "TabOpen"
+ );
+ EventUtils.synthesizeMouseAtCenter(buttons[0], {}, about3Pane);
+ let event = await tabOpenPromise;
+ await BrowserTestUtils.browserLoaded(event.detail.tabInfo.browser);
+ Assert.equal(
+ event.detail.tabInfo.browser.currentURI.spec,
+ "http://mochi.test:8888/"
+ );
+ tabmail.closeTab(event.detail.tabInfo);
+ Assert.ok(notification.parentNode, "notification should not be closed");
+
+ // Click the Undo button, and check it stops ignoring the thread.
+ EventUtils.synthesizeMouseAtCenter(buttons[1], {}, about3Pane);
+ await TestUtils.waitForCondition(() => !notification.parentNode);
+ checkRowThreadState(1, true);
+
+ goDoCommand("cmd_expandAllThreads");
+});
+
+add_task(async function testIgnoreSubthread() {
+ // Check and use the menu items for a message inside a thread.
+
+ threadTree.selectedIndex = 12;
+ await checkMessageMenu({ killSubthread: false });
+ await checkContextMenu(
+ 12,
+ { "mailContext-ignoreSubthread": false },
+ "mailContext-ignoreSubthread"
+ );
+
+ // Check all messages in that subthread are marked as ignored.
+
+ checkRowThreadState(12, "ignoreSubthread");
+ checkRowThreadState(13, "ignoreSubthread");
+ checkRowThreadState(14, "ignoreSubthread");
+
+ // Restore the subthread using the context menu item.
+
+ threadTree.selectedIndex = 12;
+ await checkMessageMenu({ killSubthread: true });
+ await checkContextMenu(
+ 12,
+ { "mailContext-ignoreSubthread": true },
+ "mailContext-ignoreSubthread"
+ );
+
+ checkRowThreadState(12, false);
+ checkRowThreadState(13, false);
+ checkRowThreadState(14, false);
+
+ // Ignore a different subthread.
+
+ threadTree.selectedIndex = 17;
+ await checkMessageMenu({ killSubthread: false });
+ await checkContextMenu(
+ 17,
+ { "mailContext-ignoreSubthread": false },
+ "mailContext-ignoreSubthread"
+ );
+
+ checkRowThreadState(17, "ignoreSubthread");
+ checkRowThreadState(18, "ignoreSubthread");
+ checkRowThreadState(19, "ignoreSubthread");
+
+ // Check the notification about the ignored subthread.
+
+ let notification =
+ notificationBox.getNotificationWithValue("ignoreThreadInfo");
+ let label = notification.shadowRoot.querySelector(
+ "label.notification-message"
+ );
+ Assert.stringContains(label.textContent, testMessages[17].subject);
+ let buttons = notification.shadowRoot.querySelectorAll(
+ "button.notification-button"
+ );
+ Assert.equal(buttons.length, 2);
+
+ // Click the Undo button, and check it stops ignoring the subthread.
+ EventUtils.synthesizeMouseAtCenter(buttons[1], {}, about3Pane);
+ await TestUtils.waitForCondition(() => !notification.parentNode);
+ checkRowThreadState(17, false);
+ checkRowThreadState(18, false);
+ checkRowThreadState(19, false);
+});
+
+add_task(async function testWatchThread() {
+ threadTree.selectedIndex = 20;
+ await checkMessageMenu({ watchThread: false });
+ await checkContextMenu(
+ 20,
+ { "mailContext-watchThread": false },
+ "mailContext-watchThread"
+ );
+
+ checkRowThreadState(20, "watched");
+ checkRowThreadState(21, false);
+
+ await checkMessageMenu({ watchThread: true });
+ await checkContextMenu(
+ 20,
+ { "mailContext-watchThread": true },
+ "mailContext-watchThread"
+ );
+
+ checkRowThreadState(20, true);
+ checkRowThreadState(21, false);
+});
+
+async function checkContextMenu(index, expectedStates, itemToActivate) {
+ let contextMenu = about3Pane.document.getElementById("mailContext");
+ let row = threadTree.getRowAtIndex(index);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".subject-line"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ for (let [id, checkedState] of Object.entries(expectedStates)) {
+ assertCheckedState(about3Pane.document.getElementById(id), checkedState);
+ }
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ if (itemToActivate) {
+ contextMenu.activateItem(
+ about3Pane.document.getElementById(itemToActivate)
+ );
+ } else {
+ contextMenu.hidePopup();
+ }
+ await hiddenPromise;
+}
+
+async function checkMessageMenu(expectedStates) {
+ if (AppConstants.platform == "macosx") {
+ // Can't check the menu.
+ return;
+ }
+
+ let messageMenu = document.getElementById("messageMenu");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ messageMenu.menupopup,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(messageMenu, {}, window);
+ await shownPromise;
+
+ for (let [id, checkedState] of Object.entries(expectedStates)) {
+ assertCheckedState(document.getElementById(id), checkedState);
+ }
+
+ messageMenu.menupopup.hidePopup();
+}
+
+function assertCheckedState(menuItem, checkedState) {
+ if (checkedState) {
+ Assert.equal(menuItem.getAttribute("checked"), "true");
+ } else {
+ Assert.ok(
+ !menuItem.hasAttribute("checked") ||
+ menuItem.getAttribute("checked") == "false"
+ );
+ }
+}
+
+function checkRowThreadState(index, expected) {
+ let row = threadTree.getRowAtIndex(index);
+ let icon = row.querySelector(".threadcol-column img");
+
+ if (!expected) {
+ Assert.ok(
+ !row.classList.contains("children"),
+ "row should not have the 'children' class"
+ );
+ Assert.ok(BrowserTestUtils.is_hidden(icon), "icon should be hidden");
+ return;
+ }
+
+ Assert.ok(BrowserTestUtils.is_visible(icon), "icon should be visible");
+
+ let shouldHaveChildrenClass = true;
+ let iconContent = getComputedStyle(icon).content;
+
+ switch (expected) {
+ case true:
+ Assert.stringContains(iconContent, "/thread-sm.svg");
+ break;
+ case "ignore":
+ Assert.stringContains(row.dataset.properties, "ignore");
+ Assert.stringContains(iconContent, "/thread-ignored.svg");
+ break;
+ case "ignoreSubthread":
+ Assert.stringContains(row.dataset.properties, "ignoreSubthread");
+ Assert.stringContains(iconContent, "/subthread-ignored.svg");
+ shouldHaveChildrenClass = false;
+ break;
+ case "watched":
+ Assert.stringContains(row.dataset.properties, "watch");
+ Assert.stringContains(iconContent, "/eye.svg");
+ break;
+ }
+
+ Assert.equal(
+ row.classList.contains("children"),
+ shouldHaveChildrenClass,
+ `row should${
+ shouldHaveChildrenClass ? "" : " not"
+ } have the 'children' class`
+ );
+}
diff --git a/comm/mail/base/test/browser/browser_toolsMenu.js b/comm/mail/base/test/browser/browser_toolsMenu.js
new file mode 100644
index 0000000000..8dbf5911c8
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_toolsMenu.js
@@ -0,0 +1,109 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+/** @type MenuData */
+const toolsMenuData = {
+ tasksMenuMail: { hidden: true },
+ addressBook: {},
+ menu_openSavedFilesWnd: {},
+ addonsManager: {},
+ activityManager: {},
+ imAccountsStatus: { disabled: true },
+ imStatusAvailable: {},
+ imStatusUnavailable: {},
+ imStatusOffline: {},
+ imStatusShowAccounts: {},
+ joinChatMenuItem: { disabled: true },
+ filtersCmd: {},
+ applyFilters: { disabled: ["mail3PaneTab", "contentTab"] },
+ applyFiltersToSelection: { disabled: ["mail3PaneTab", "contentTab"] },
+ runJunkControls: { disabled: true },
+ deleteJunk: { disabled: true },
+ menu_import: {},
+ menu_export: {},
+ manageKeysOpenPGP: {},
+ devtoolsMenu: {},
+ devtoolsToolbox: {},
+ addonDebugging: {},
+ javascriptConsole: {},
+ sanitizeHistory: {},
+};
+if (AppConstants.platform == "win") {
+ toolsMenuData.menu_preferences = {};
+ toolsMenuData.menu_accountmgr = {};
+}
+let helper = new MenuTestHelper("tasksMenu", toolsMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, testMessages;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("tools menu", null);
+ testFolder = rootFolder
+ .getChildNamed("tools menu")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ window.OpenMessageInNewTab(testMessages[0], { background: true });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+
+ window.openTab("contentTab", {
+ url: "https://example.com/",
+ background: true,
+ });
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function test3PaneTab() {
+ tabmail.currentAbout3Pane.displayFolder(rootFolder);
+ await helper.testAllItems("mail3PaneTab");
+
+ tabmail.currentAbout3Pane.displayFolder(testFolder);
+ await helper.testItems({
+ applyFilters: {},
+ runJunkControls: {},
+ deleteJunk: {},
+ });
+
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 1;
+ await helper.testItems({
+ applyFilters: {},
+ applyFiltersToSelection: {},
+ runJunkControls: {},
+ deleteJunk: {},
+ });
+});
+
+add_task(async function testMessageTab() {
+ tabmail.switchToTab(1);
+ await helper.testAllItems("mailMessageTab");
+});
+
+add_task(async function testContentTab() {
+ tabmail.switchToTab(2);
+ await helper.testAllItems("contentTab");
+});
diff --git a/comm/mail/base/test/browser/browser_treeListbox.js b/comm/mail/base/test/browser/browser_treeListbox.js
new file mode 100644
index 0000000000..558bef991e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_treeListbox.js
@@ -0,0 +1,1313 @@
+/* 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 tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+async function withTab(callback) {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/treeListbox.xhtml",
+ });
+ await BrowserTestUtils.browserLoaded(tab.browser);
+
+ tab.browser.focus();
+ await SpecialPowers.spawn(tab.browser, [], callback);
+
+ tabmail.closeTab(tab);
+}
+
+add_task(async function testKeyboard() {
+ await withTab(subtestKeyboard);
+});
+add_task(async function testMutation() {
+ await withTab(subtestMutation);
+});
+add_task(async function testExpandCollapse() {
+ await withTab(subtestExpandCollapse);
+});
+add_task(async function testSelectOnRemoval1() {
+ await withTab(subtestSelectOnRemoval1);
+});
+add_task(async function testSelectOnRemoval2() {
+ await withTab(subtestSelectOnRemoval2);
+});
+add_task(async function testSelectOnRemoval3() {
+ await withTab(subtestSelectOnRemoval3);
+});
+add_task(async function testUnselectable() {
+ await withTab(subtestUnselectable);
+});
+
+/**
+ * Tests keyboard navigation up and down the list.
+ */
+async function subtestKeyboard() {
+ let doc = content.document;
+
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ Assert.ok(!!list, "the list exists");
+
+ let initialRowIds = [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+ Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ initialRowIds,
+ "initial rows are correct"
+ );
+ Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = list.selectedIndex;
+ },
+ };
+
+ function pressKey(key, expectEvent = true) {
+ info(`pressing ${key}`);
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler);
+ EventUtils.synthesizeKey(key, {}, content);
+ list.removeEventListener("select", selectHandler);
+ Assert.equal(
+ !!selectHandler.seenEvent,
+ expectEvent,
+ `'select' event ${expectEvent ? "fired" : "did not fire"}`
+ );
+ }
+
+ function checkSelected(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ if (selectHandler.selectedAtEvent !== null) {
+ // Check the value was already set when the select event fired.
+ Assert.deepEqual(
+ selectHandler.selectedAtEvent,
+ expectedIndex,
+ "selectedIndex was correct at the last 'select' event"
+ );
+ }
+
+ Assert.deepEqual(
+ Array.from(list.querySelectorAll(".selected"), row => row.id),
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ // Key down the list.
+
+ list.focus();
+ for (let i = 1; i < initialRowIds.length; i++) {
+ pressKey("KEY_ArrowDown");
+ checkSelected(i, initialRowIds[i]);
+ }
+
+ pressKey("KEY_ArrowDown", false);
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_PageDown", false);
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_End", false);
+ checkSelected(7, "row-3-1-2");
+
+ // And up again.
+
+ for (let i = initialRowIds.length - 2; i >= 0; i--) {
+ pressKey("KEY_ArrowUp");
+ checkSelected(i, initialRowIds[i]);
+ }
+
+ pressKey("KEY_ArrowUp", false);
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_PageUp", false);
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_Home", false);
+ checkSelected(0, "row-1");
+
+ // Jump around.
+
+ pressKey("KEY_End");
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_PageUp");
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_PageDown");
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_Home");
+ checkSelected(0, "row-1");
+}
+
+/**
+ * Tests that rows added to or removed from the tree cause their parent rows
+ * to gain or lose the 'children' class as appropriate. This is done with a
+ * mutation observer so the tree is not updated immediately, but at the end of
+ * the event loop.
+ */
+async function subtestMutation() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ let idsWithChildren = ["row-2", "row-3", "row-3-1"];
+ let idsWithoutChildren = [
+ "row-1",
+ "row-2-1",
+ "row-2-2",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+
+ // Check the initial state.
+
+ function createNewRow() {
+ let template = doc.getElementById("rowToAdd");
+ return template.content.cloneNode(true).firstElementChild;
+ }
+
+ function checkHasClass(id, shouldHaveClass = true) {
+ let row = doc.getElementById(id);
+ if (shouldHaveClass) {
+ Assert.ok(
+ row.classList.contains("children"),
+ `${id} should have the 'children' class`
+ );
+ } else {
+ Assert.ok(
+ !row.classList.contains("children"),
+ `${id} should NOT have the 'children' class`
+ );
+ }
+ }
+
+ for (let id of idsWithChildren) {
+ checkHasClass(id, true);
+ }
+ for (let id of idsWithoutChildren) {
+ checkHasClass(id, false);
+ }
+
+ // Add a new row without children to the end of the list.
+
+ info("adding new row to end of list");
+ let newRow = list.appendChild(createNewRow());
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+
+ // Add and remove a single row to rows with existing children.
+
+ for (let id of idsWithChildren) {
+ let row = doc.getElementById(id);
+
+ info(`adding new row to ${id}`);
+ newRow = row.querySelector("ul").appendChild(createNewRow());
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing new row from ${id}`);
+ newRow.remove();
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, true);
+
+ if (id == "row-3-1") {
+ checkHasClass("row-3", true);
+ }
+ }
+
+ // Add and remove a single row to rows without existing children.
+
+ for (let id of idsWithoutChildren) {
+ let row = doc.getElementById(id);
+ let childList = row.appendChild(doc.createElement("ul"));
+
+ info(`adding new row to ${id}`);
+ newRow = childList.appendChild(createNewRow());
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing new row from ${id}`);
+ newRow.remove();
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, false);
+
+ // This time remove the child list, not the row itself.
+
+ info(`adding new row to ${id} again`);
+ newRow = childList.appendChild(createNewRow());
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing child list from ${id}`);
+ childList.remove();
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, false);
+
+ if (["row-2-1", "row-2-2"].includes(id)) {
+ checkHasClass("row-2", true);
+ } else if (["row-3-1-1", "row-3-1-2"].includes(id)) {
+ checkHasClass("row-3-1", true);
+ checkHasClass("row-3", true);
+ }
+ }
+
+ // Add a row with children and a grandchild to the end of the list. The new
+ // row should be given the "children" class. The child with a grandchild
+ // should be given the "children" class. I think it's safe to assume this
+ // works no matter where in the tree it's added.
+
+ let template = doc.getElementById("rowsToAdd");
+ newRow = template.content.cloneNode(true).firstElementChild;
+ list.appendChild(newRow);
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("added-row", true);
+ checkHasClass("added-row-1", true);
+ checkHasClass("added-row-1-1", false);
+ checkHasClass("added-row-2", false);
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+
+ // Add a new row without children to the middle of the list. Selection should
+ // be maintained.
+
+ list.selectedIndex = 5; // row-3-1
+
+ info("adding new row to middle of list");
+ newRow = template.content.cloneNode(true).firstElementChild;
+ list.insertBefore(newRow, list.querySelector("#row-3"));
+ await new Promise(r => content.setTimeout(r));
+ Assert.equal(list.selectedIndex, 9, "row-3-1 is still selected");
+
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.equal(list.selectedIndex, 5, "row-3-1 is still selected");
+
+ list.selectedIndex = 0;
+}
+
+/**
+ * Checks that expanding and collapsing works. Twisties in the test file are
+ * styled as coloured squares: red for collapsed, green for expanded.
+ *
+ * @note This is practically the same test as in browser_treeView.js,
+ * but for TreeListbox instead of TreeView. If you make changes here
+ * you may want to make changes there too.
+ */
+async function subtestExpandCollapse() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ let allIds = [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+ let idsWithoutChildren = [
+ "row-1",
+ "row-2-1",
+ "row-2-2",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+
+ let listener = {
+ reset() {
+ this.collapsedRow = null;
+ this.expandedRow = null;
+ },
+ handleEvent(event) {
+ if (event.type == "collapsed") {
+ this.collapsedRow = event.target;
+ } else if (event.type == "expanded") {
+ this.expandedRow = event.target;
+ }
+ },
+ };
+ list.addEventListener("collapsed", listener);
+ list.addEventListener("expanded", listener);
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = list.selectedIndex;
+ },
+ };
+
+ Assert.equal(
+ list.querySelectorAll("collapsed").length,
+ 0,
+ "no rows are collapsed"
+ );
+ Assert.equal(list.rowCount, 8, "row count");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ],
+ "rows property"
+ );
+
+ function checkSelected(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ let selected = [...list.querySelectorAll(".selected")].map(row => row.id);
+ Assert.deepEqual(
+ selected,
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ checkSelected(0, "row-1");
+
+ // Click the twisties of rows without children.
+
+ function performChange(id, expectedChange, changeCallback) {
+ listener.reset();
+ let row = doc.getElementById(id);
+ let before = row.classList.contains("collapsed");
+
+ changeCallback(row);
+
+ if (expectedChange == "collapsed") {
+ Assert.ok(!before, `${id} was expanded`);
+ Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`);
+ Assert.equal(listener.collapsedRow, row, `${id} fired 'collapse' event`);
+ Assert.ok(!listener.expandedRow, `${id} did not fire 'expand' event`);
+ } else if (expectedChange == "expanded") {
+ Assert.ok(before, `${id} was collapsed`);
+ Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`);
+ Assert.ok(!listener.collapsedRow, `${id} did not fire 'collapse' event`);
+ Assert.equal(listener.expandedRow, row, `${id} fired 'expand' event`);
+ } else {
+ Assert.equal(
+ row.classList.contains("collapsed"),
+ before,
+ `${id} state did not change`
+ );
+ }
+ }
+
+ function clickTwisty(id, expectedChange) {
+ info(`clicking the twisty on ${id}`);
+ performChange(id, expectedChange, row =>
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".twisty"),
+ {},
+ content
+ )
+ );
+ }
+
+ function doubleClick(id, expectedChange) {
+ info(`double clicking on ${id}`);
+ performChange(id, expectedChange, row =>
+ EventUtils.synthesizeMouseAtCenter(row, { clickCount: 2 }, content)
+ );
+ }
+
+ for (let id of idsWithoutChildren) {
+ clickTwisty(id, null);
+ Assert.equal(list.querySelector(".selected").id, id);
+ }
+
+ checkSelected(7, "row-3-1-2");
+
+ // Click the twisties of rows with children.
+
+ function checkRowsAreHidden(...hiddenIds) {
+ let remainingIds = allIds.slice();
+
+ for (let id of allIds) {
+ if (hiddenIds.includes(id)) {
+ Assert.equal(doc.getElementById(id).clientHeight, 0, `${id} is hidden`);
+ remainingIds.splice(remainingIds.indexOf(id), 1);
+ } else {
+ Assert.greater(
+ doc.getElementById(id).clientHeight,
+ 0,
+ `${id} is visible`
+ );
+ }
+ }
+
+ Assert.equal(list.rowCount, 8 - hiddenIds.length, "row count");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ remainingIds,
+ "rows property"
+ );
+ }
+
+ // Collapse row 2.
+
+ clickTwisty("row-2", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(5, "row-3-1-2");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ // Expand row 2.
+
+ doubleClick("row-2", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ doubleClick("row-3", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Collapse row 3-1.
+
+ clickTwisty("row-3-1", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3-1.
+
+ clickTwisty("row-3-1", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Test key presses.
+
+ function pressKey(id, key, expectedChange) {
+ info(`pressing ${key}`);
+ performChange(id, expectedChange, row => {
+ EventUtils.synthesizeKey(key, {}, content);
+ });
+ }
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_LEFT");
+ checkSelected(0, "row-1");
+ pressKey("row-1", "VK_RIGHT");
+ checkSelected(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ pressKey("row-2", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Toggle expansion of row 3-1 with Enter key.
+
+ list.selectedIndex = 5;
+ pressKey("row-3-1", "KEY_Enter", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "KEY_Enter", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ // Same again, with a RTL tree.
+
+ info("switching to RTL");
+ doc.documentElement.dir = "rtl";
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_RIGHT");
+ checkSelected(0, "row-1");
+ pressKey("row-1", "VK_LEFT");
+ checkSelected(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ pressKey("row-2", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Use the class methods for expanding and collapsing.
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler);
+ listener.reset();
+
+ list.collapseRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.collapsedRow, "'collapsed' event did not fire");
+
+ list.expandRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.expandedRow, "'expanded' event did not fire");
+
+ list.collapseRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(
+ listener.collapsedRow.id,
+ "row-2",
+ "row-2 fired 'collapsed' event"
+ );
+ listener.reset();
+
+ list.expandRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(
+ listener.expandedRow.id,
+ "row-2",
+ "row-2 fired 'expanded' event"
+ );
+ listener.reset();
+
+ list.collapseRowAtIndex(5); // Item with children that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 5,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(
+ listener.collapsedRow.id,
+ "row-3-1",
+ "row-3-1 fired 'collapsed' event"
+ );
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(5); // Selected item with children.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(
+ listener.expandedRow.id,
+ "row-3-1",
+ "row-3-1 fired 'expanded' event"
+ );
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+ listener.reset();
+
+ list.selectedIndex = 7;
+ selectHandler.reset();
+
+ list.collapseRowAtIndex(4); // Item with grandchildren that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 4,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(
+ listener.collapsedRow.id,
+ "row-3",
+ "row-3 fired 'collapsed' event"
+ );
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(4); // Selected item with grandchildren.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(
+ listener.expandedRow.id,
+ "row-3",
+ "row-3 fired 'expanded' event"
+ );
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+ listener.reset();
+
+ list.removeEventListener("collapsed", listener);
+ list.removeEventListener("expanded", listener);
+ list.removeEventListener("select", selectHandler);
+ doc.documentElement.dir = null;
+}
+
+/**
+ * Tests what happens to selection when a row is removed.
+ */
+async function subtestSelectOnRemoval1() {
+ let doc = content.document;
+ let list = doc.getElementById("deleteTree");
+
+ let selectPromise;
+ function promiseSelectEvent() {
+ selectPromise = new Promise(resolve =>
+ list.addEventListener(
+ "select",
+ () => resolve([list.selectedIndex, list.selectedRow?.id ?? null]),
+ {
+ once: true,
+ }
+ )
+ );
+ }
+ // dRow-1
+ // dRow-2
+ // dRow-2-1
+ // dRow-2-2
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete a row that is selected, and not at the top level. Selection should
+ // move to the next row under the shared parent.
+
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-2-1");
+
+ promiseSelectEvent();
+ list.querySelector("#dRow-2-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [2, "dRow-2-2"],
+ "selection moved to the next row"
+ );
+
+ // dRow-1
+ // dRow-2
+ // dRow-2-2
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete a row that contains the selected, and at the top level. Selection
+ // should move to the next top-level row.
+
+ Assert.equal(list.selectedRow.id, "dRow-2-2");
+ promiseSelectEvent();
+ list.querySelector("#dRow-2").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [1, "dRow-3"],
+ "selection moved to the next row"
+ );
+
+ // dRow-1
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete the first top-level row that is selected. Should select the first
+ // row.
+ list.selectedIndex = 0;
+ Assert.equal(list.selectedRow.id, "dRow-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3"],
+ "selection moved to the first row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete the last top-level row that is selected. Should select the last row.
+ list.selectedIndex = 14;
+ Assert.equal(list.selectedRow.id, "dRow-6");
+ promiseSelectEvent();
+ list.querySelector("#dRow-6").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [13, "dRow-5-1"],
+ "selection moved to the new last row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+
+ // Delete the last selected descendant should move selection to the new
+ // descendant child.
+ list.selectedIndex = 11;
+ Assert.equal(list.selectedRow.id, "dRow-4-4");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4-4").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [10, "dRow-4-3-2"],
+ "selection moved to the new last row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Delete the first selected child should move selection to the new first child.
+ list.selectedIndex = 6;
+ Assert.equal(list.selectedRow.id, "dRow-4-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [6, "dRow-4-2"],
+ "selection moved to the new first row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Delete a row that isn't selected. Nothing should happen.
+
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1");
+
+ list.querySelector("#dRow-3-1-2").remove();
+ await new Promise(resolve => content.setTimeout(resolve));
+ Assert.equal(list.selectedIndex, 2, "selection did not change");
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1", "selection did not change");
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Deleting the last row under a parent that contains the selection should
+ // select the parent.
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1");
+
+ promiseSelectEvent();
+ let rowToReplace = list.querySelector("#dRow-3-1");
+ rowToReplace.remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3"],
+ "selection moved to the parent row"
+ );
+
+ // dRow-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Deleting several rows under a parent, should select the parent row.
+ list.selectedIndex = 4;
+ Assert.equal(list.selectedRow.id, "dRow-4-3-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4 ul").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [1, "dRow-4"],
+ "selection moved to the parent row"
+ );
+
+ // Delete the last remaining rows. The selected index should be -1.
+
+ promiseSelectEvent();
+ list.replaceChildren();
+ Assert.deepEqual(await selectPromise, [-1, null], "selection was cleared");
+
+ // Add back a row. One of the row's children was selected, this should be
+ // removed and the selection set to the top-level row.
+
+ promiseSelectEvent();
+ list.appendChild(rowToReplace);
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3-1"],
+ "selection set to the added row"
+ );
+ Assert.ok(list.querySelector("#dRow-3-1-1"), "child of the added row exists");
+ Assert.ok(
+ !list.querySelector("#dRow-3-1-1").classList.contains("selected"),
+ "child of the added row is not selected"
+ );
+}
+
+/**
+ * Tests what happens to selection when a row is removed.
+ */
+async function subtestSelectOnRemoval2() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+
+ let selectPromise;
+ function promiseSelectEvent() {
+ selectPromise = new Promise(resolve =>
+ list.addEventListener("select", () => resolve(list.selectedIndex), {
+ once: true,
+ })
+ );
+ }
+
+ // Delete row-3 containing the selection.
+
+ list.selectedIndex = 7; // row-3-1-2
+
+ promiseSelectEvent();
+ list.querySelector("#row-3").remove();
+ Assert.equal(await selectPromise, 3, "selection moved to the last row");
+
+ // Delete row-2. Selection should move to the only row.
+
+ promiseSelectEvent();
+ list.querySelector("#row-2").remove();
+ Assert.equal(
+ await selectPromise,
+ 0, // row-1
+ "selection moved to the last row"
+ );
+}
+
+/**
+ * Tests what happens to selection when elements above it are removed.
+ */
+async function subtestSelectOnRemoval3() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+
+ // Delete a row.
+
+ list.selectedIndex = 6; // row-3-1-1
+
+ list.querySelector("#row-2-1").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 5, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+
+ // Delete an element that isn't a row.
+
+ list.querySelector("#row-2 div").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 5, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+
+ // Delete an element that contains a row.
+
+ list.querySelector("#row-2 ul").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 4, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+}
+
+/**
+ * Tests that rows marked as unselectable cannot be selected.
+ */
+async function subtestUnselectable() {
+ let doc = content.document;
+
+ let list = doc.querySelector(`ul#unselectableTree`);
+ Assert.ok(!!list, "the list exists");
+
+ let initialRowIds = [
+ "uRow-2-1",
+ "uRow-2-2",
+ "uRow-3-1",
+ "uRow-3-1-1",
+ "uRow-3-1-2",
+ ];
+ Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ initialRowIds,
+ "initial rows are correct"
+ );
+
+ function checkSelected(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ Assert.deepEqual(
+ Array.from(list.querySelectorAll(".selected"), row => row.id),
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ checkSelected(0, "uRow-2-1");
+
+ // Clicking unselectable rows should not change the selection.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-1 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-2 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-3 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+
+ // Unselectable rows should not be accessible by keyboard.
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, content);
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, content);
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("KEY_Home", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_End", {}, content);
+ checkSelected(4, "uRow-3-1-2");
+ EventUtils.synthesizeKey("KEY_PageUp", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_PageDown", {}, content);
+ checkSelected(4, "uRow-3-1-2");
+
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Move up to 3-1.
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Collapse.
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Try to move to 3.
+ checkSelected(2, "uRow-3-1");
+}
diff --git a/comm/mail/base/test/browser/browser_treeView.js b/comm/mail/base/test/browser/browser_treeView.js
new file mode 100644
index 0000000000..bf1eeda35a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_treeView.js
@@ -0,0 +1,1941 @@
+/* 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 tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+// We wish to run several variants of each test with minor differences, based on
+// changes in the source file, in order to verify that certain variables don't
+// impact behavior.
+const TEST_VARIANTS = ["header", "no-header"];
+
+/**
+ * Run a given test in a new tab and script sandbox.
+ *
+ * @param {Function} test - The test function to run in the sandbox.
+ * @param {string} filenameFragment - The fragment of the filename representing
+ * the test variant to run.
+ * @param {object[]} sandboxArgs - Arguments to the sandbox spawner to pass to
+ * the test function.
+ */
+async function runTestInSandbox(test, filenameFragment, sandboxArgs = []) {
+ // Create a new tab with our custom content.
+ const tab = tabmail.openTab("contentTab", {
+ url: `chrome://mochitests/content/browser/comm/mail/base/test/browser/files/tree-element-test-${filenameFragment}.xhtml`,
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+
+ // Spawn a new JavaScript sandbox for the tab and run the test function inside
+ // of it.
+ await SpecialPowers.spawn(tab.browser, sandboxArgs, test);
+
+ tabmail.closeTab(tab);
+}
+
+/**
+ * Checks that interactions with the widget do as expected.
+ */
+add_task(async function testKeyboardAndMouse() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running keyboard and mouse test for ${variant}`);
+ await runTestInSandbox(subtestKeyboardAndMouse, variant, [variant]);
+ }
+});
+
+async function subtestKeyboardAndMouse(variant) {
+ let doc = content.document;
+
+ let list = doc.getElementById("testTree");
+ Assert.ok(!!list, "the list exists");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ async function scrollListToPosition(topOfScroll) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, topOfScroll);
+ });
+ }
+
+ Assert.equal(list._rowElementName, "test-row");
+ Assert.equal(list._rowElementClass, content.customElements.get("test-row"));
+ Assert.equal(
+ list._toleranceSize,
+ 26,
+ "list should have tolerance twice the number of visible rows"
+ );
+
+ // We should be scrolled to the top already, but this will ensure we get an
+ // event fire one way or another.
+ await scrollListToPosition(0);
+
+ let rows = list.querySelectorAll(`tr[is="test-row"]`);
+ // Count is calculated from the height of `list` divided by
+ // TestCardRow.ROW_HEIGHT, plus list._toleranceSize.
+ Assert.equal(rows.length, 13 + 26, "the list has the right number of rows");
+
+ Assert.equal(doc.activeElement, doc.body);
+
+ // Verify the tab order of list elements by tabbing both forward and backward
+ // through them.
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "before",
+ "the element before the list should have focus"
+ );
+
+ if (variant == "header") {
+ // Tab order changes slightly if the table has a header versus if it
+ // doesn't, so we need to account for that variation.
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "testColButton",
+ "the list header button should have focus"
+ );
+ }
+
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(doc.activeElement.id, "testBody", "the list should have focus");
+
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "after",
+ "the element after the list should have focus"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(doc.activeElement.id, "testBody", "the list should have focus");
+
+ if (variant == "header") {
+ // Tab order changes slightly if the table has a header versus if it
+ // doesn't, so we need to account for that variation.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "testColButton",
+ "the list header button should have focus"
+ );
+ }
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "before",
+ "the element before the list should have focus"
+ );
+
+ // Check initial selection.
+
+ let selectHandler = {
+ seenEvent: null,
+ currentAtEvent: null,
+ selectedAtEvent: null,
+ t0: Date.now(),
+ time: 0,
+
+ reset() {
+ this.seenEvent = null;
+ this.currentAtEvent = null;
+ this.selectedAtEvent = null;
+ this.t0 = Date.now();
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.currentAtEvent = list.currentIndex;
+ this.selectedAtEvent = list.selectedIndices;
+ this.time = Date.now() - this.t0;
+ },
+ };
+
+ /**
+ * Check if the spacerTop TBODY of the TreeViewTable is properly allocating
+ * the height of non existing rows.
+ *
+ * @param {int} rows - The number of rows that the spacerTop should be
+ * simulating their height allocation.
+ */
+ function checkTopSpacerHeight(rows) {
+ let table = doc.querySelector(`[is="tree-view-table"]`);
+ // -26 to account for the tolerance buffer.
+ Assert.equal(
+ table.spacerTop.clientHeight,
+ list.getRowAtIndex(rows).clientHeight * (rows - 26),
+ "The top spacer has the correct height"
+ );
+ }
+
+ function checkCurrent(expectedIndex) {
+ Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct");
+ if (selectHandler.currentAtEvent !== null) {
+ Assert.equal(
+ selectHandler.currentAtEvent,
+ expectedIndex,
+ "currentIndex was correct at the last 'select' event"
+ );
+ }
+
+ let current = list.querySelectorAll(".current");
+ if (expectedIndex == -1) {
+ Assert.equal(current.length, 0, "no rows have the 'current' class");
+ } else {
+ Assert.equal(current.length, 1, "only one row has the 'current' class");
+ Assert.equal(
+ current[0].index,
+ expectedIndex,
+ "correct row has the 'current' class"
+ );
+ }
+ }
+
+ function checkSelected(...expectedIndices) {
+ Assert.deepEqual(
+ list.selectedIndices,
+ expectedIndices,
+ "selectedIndices are correct"
+ );
+
+ if (selectHandler.selectedAtEvent !== null) {
+ // Check the value was already set when the select event fired.
+ Assert.deepEqual(
+ selectHandler.selectedAtEvent,
+ expectedIndices,
+ "selectedIndices were correct at the last 'select' event"
+ );
+ }
+
+ let selected = [...list.querySelectorAll(".selected")].map(
+ row => row.index
+ );
+ expectedIndices.sort((a, b) => a - b);
+ Assert.deepEqual(
+ selected,
+ expectedIndices,
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ checkCurrent(0);
+ checkSelected(0);
+
+ // Click on some individual rows.
+
+ const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+
+ async function clickOnRow(index, modifiers = {}, expectEvent = true) {
+ if (modifiers.shiftKey) {
+ info(`clicking on row ${index} with shift key`);
+ } else if (modifiers.accelKey) {
+ info(`clicking on row ${index} with ctrl key`);
+ } else {
+ info(`clicking on row ${index}`);
+ }
+
+ let x = list.clientWidth / 2;
+ let y = list.table.header.clientHeight + index * 50 + 25;
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeMouse(list, x, y, modifiers, content);
+ await TestUtils.waitForCondition(
+ () => !!selectHandler.seenEvent == expectEvent,
+ `'select' event should ${expectEvent ? "" : "not "}get fired`
+ );
+ }
+
+ await clickOnRow(0, {}, false);
+ checkCurrent(0);
+ checkSelected(0);
+
+ await clickOnRow(1);
+ checkCurrent(1);
+ checkSelected(1);
+
+ await clickOnRow(2);
+ checkCurrent(2);
+ checkSelected(2);
+
+ // Select multiple rows by shift-clicking.
+
+ await clickOnRow(4, { shiftKey: true });
+ checkCurrent(4);
+ checkSelected(2, 3, 4);
+
+ // Holding ctrl and shift should always produce a range selection.
+ await clickOnRow(6, { accelKey: true, shiftKey: true });
+ checkCurrent(6);
+ checkSelected(2, 3, 4, 5, 6);
+
+ await clickOnRow(0, { shiftKey: true });
+ checkCurrent(0);
+ checkSelected(0, 1, 2);
+
+ await clickOnRow(2, { shiftKey: true });
+ checkCurrent(2);
+ checkSelected(2);
+
+ // Select multiple rows by ctrl-clicking.
+
+ await clickOnRow(5, { accelKey: true });
+ checkCurrent(5);
+ checkSelected(2, 5);
+
+ await clickOnRow(1, { accelKey: true });
+ checkCurrent(1);
+ checkSelected(1, 2, 5);
+
+ await clickOnRow(5, { accelKey: true });
+ checkCurrent(5);
+ checkSelected(1, 2);
+
+ await clickOnRow(1, { accelKey: true });
+ checkCurrent(1);
+ checkSelected(2);
+
+ await clickOnRow(2, { accelKey: true });
+ checkCurrent(2);
+ checkSelected();
+
+ // Move around by pressing keys.
+
+ async function pressKey(key, modifiers = {}, expectEvent = true) {
+ if (modifiers.shiftKey) {
+ info(`pressing ${key} with shift key`);
+ } else if (modifiers.accelKey) {
+ info(`pressing ${key} with accel key`);
+ } else {
+ info(`pressing ${key}`);
+ }
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeKey(key, modifiers, content);
+ await TestUtils.waitForCondition(
+ () => !!selectHandler.seenEvent == expectEvent,
+ `'select' event should ${expectEvent ? "" : "not "}get fired`
+ );
+ // We don't enforce any delay on multiselection.
+ let multiselect =
+ (AppConstants.platform == "macosx" && key == " ") ||
+ modifiers.shiftKey ||
+ modifiers.accelKey;
+ if (expectEvent && !multiselect) {
+ // We have data-select-delay="250" in treeView.xhtml
+ Assert.greater(selectHandler.time, 240, "should select only after delay");
+ }
+ }
+
+ await pressKey("VK_UP");
+ checkCurrent(1);
+ checkSelected(1);
+
+ await pressKey("VK_UP", { accelKey: true }, false);
+ checkCurrent(0);
+ checkSelected(1);
+
+ // Without Ctrl selection moves with focus again.
+ await pressKey("VK_UP");
+ checkCurrent(0);
+ checkSelected(0);
+
+ // Does nothing.
+ await pressKey("VK_UP", {}, false);
+ checkCurrent(0);
+ checkSelected(0);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(1);
+ checkSelected(0);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(2);
+ checkSelected(0);
+
+ // Multi select with only Space on macOS on a focused row, since Cmd+Space is
+ // captured by the OS.
+ if (AppConstants.platform == "macosx") {
+ await pressKey(" ");
+ } else {
+ // Multi select with Ctrl+Space for Windows and Linux.
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(2);
+ checkSelected(0, 2);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(3);
+ checkSelected(0, 2);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(4);
+ checkSelected(0, 2);
+
+ if (AppConstants.platform == "macosx") {
+ await pressKey(" ");
+ } else {
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(4);
+ checkSelected(0, 2, 4);
+
+ // Single selection restored with normal navigation.
+ await pressKey("VK_UP");
+ checkCurrent(3);
+ checkSelected(3);
+
+ // We don't allow unselecting a selected row with Space on macOS due to
+ // conflict with the `mail.advance_on_spacebar` pref.
+ if (AppConstants.platform != "macosx") {
+ // Can select none using Ctrl+Space.
+ await pressKey(" ", { accelKey: true });
+ checkCurrent(3);
+ checkSelected();
+ }
+
+ await pressKey("VK_DOWN");
+ checkCurrent(4);
+ checkSelected(4);
+
+ await pressKey("VK_HOME", { accelKey: true }, false);
+ checkCurrent(0);
+ checkSelected(4);
+
+ if (AppConstants.platform == "macosx") {
+ // We can't clear the selection with only Space on macOS, simulate a Arrow
+ // Up to force clear selection and only select the top most row.
+ await pressKey("VK_UP");
+ } else {
+ // Select only the current item with Space (no modifier).
+ await pressKey(" ");
+ }
+ checkCurrent(0);
+ checkSelected(0);
+
+ // The list is 630px high, so rows 0-12 are fully or partly visible.
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_DOWN");
+ });
+ checkCurrent(13);
+ checkSelected(13);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 1,
+ "should have scrolled down a page"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_UP", { shiftKey: true });
+ });
+ checkCurrent(0);
+ checkSelected(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled up a page"
+ );
+
+ // Shrink shift selection.
+ await pressKey("VK_DOWN", { shiftKey: true });
+ checkCurrent(1);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(2);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(3);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ if (AppConstants.platform == "macosx") {
+ // We don't allow unselecting a selected row with Space on macOS due to
+ // conflict with the `mail.advance_on_spacebar` pref, so simulate a
+ // CMD+click to unselect it.
+ await clickOnRow(3, { accelKey: true });
+ } else {
+ // Break the shift sequence by Ctrl+Space.
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(3);
+ checkSelected(1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { shiftKey: true });
+ checkCurrent(4);
+ checkSelected(3, 4);
+
+ // Reverse selection direction.
+ await pressKey("VK_HOME", { shiftKey: true });
+ checkCurrent(0);
+ checkSelected(0, 1, 2, 3);
+
+ // Now rows 138-149 are fully visible.
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_END");
+ });
+ checkCurrent(149);
+ checkSelected(149);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should have scrolled to the end"
+ );
+ checkTopSpacerHeight(137);
+
+ // Does nothing.
+ await pressKey("VK_DOWN", {}, false);
+ checkCurrent(149);
+ checkSelected(149);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should not have changed view"
+ );
+ checkTopSpacerHeight(137);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_UP");
+ });
+ checkCurrent(136);
+ checkSelected(136);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 136,
+ "should have scrolled up a page"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_DOWN", { shiftKey: true });
+ });
+ checkCurrent(149);
+ checkSelected(
+ 136,
+ 137,
+ 138,
+ 139,
+ 140,
+ 141,
+ 142,
+ 143,
+ 144,
+ 145,
+ 146,
+ 147,
+ 148,
+ 149
+ );
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should have scrolled down a page"
+ );
+ checkTopSpacerHeight(137);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_HOME");
+ });
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled to the beginning"
+ );
+
+ // Scroll around. Which rows are current and selected should be remembered
+ // even if the row element itself disappears.
+
+ selectHandler.reset();
+ await scrollListToPosition(125);
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 2,
+ "getFirstVisibleIndex is correct"
+ );
+
+ await scrollListToPosition(2525);
+ Assert.equal(list.currentIndex, 0, "currentIndex is still set");
+ Assert.ok(
+ !list.querySelector(".current"),
+ "no visible rows have the 'current' class"
+ );
+ Assert.deepEqual(list.selectedIndices, [0], "selectedIndices are still set");
+ Assert.ok(
+ !list.querySelector(".selected"),
+ "no visible rows have the 'selected' class"
+ );
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 50,
+ "getFirstVisibleIndex is correct"
+ );
+ Assert.ok(!selectHandler.seenEvent, "should not have fired 'select' event");
+ checkTopSpacerHeight(50);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_DOWN");
+ });
+ checkCurrent(1);
+ checkSelected(1);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 1,
+ "should have scrolled so that the second row is in view"
+ );
+
+ selectHandler.reset();
+ await scrollListToPosition(0);
+ checkCurrent(1);
+ checkSelected(1);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "getFirstVisibleIndex is correct"
+ );
+ Assert.ok(
+ !selectHandler.seenEvent,
+ "'select' event did not fire as expected"
+ );
+
+ await pressKey("VK_UP");
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled so that the first row is in view"
+ );
+
+ // Some literal edge cases. Clicking on a partially visible row should
+ // scroll it into view.
+
+ // Calculate the visible area in order to verify that rows appear where they
+ // are intended to appear.
+ const listRect = list.getBoundingClientRect();
+ const headerHeight = list.table.header.clientHeight;
+
+ const visibleRect = new content.DOMRect(
+ listRect.x,
+ listRect.y + headerHeight,
+ listRect.width,
+ listRect.height - headerHeight
+ );
+
+ Assert.equal(visibleRect.height, 630, "the table body should be 630px tall");
+
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ let bcr = rows[12].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.bottom),
+ "top of row 12 is visible"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.bottom),
+ "bottom of row 12 is not visible"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await clickOnRow(12);
+ });
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ bcr = rows[12].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.bottom),
+ "the top of row 12 should be visible"
+ );
+ Assert.equal(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.bottom),
+ "row 12 should be at the bottom of the visible area"
+ );
+
+ bcr = rows[0].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.top),
+ "top of row 0 is not visible"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.top),
+ "bottom of row 0 is visible"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await clickOnRow(0);
+ });
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ bcr = rows[0].getBoundingClientRect();
+ Assert.equal(
+ Math.round(bcr.top),
+ Math.round(visibleRect.top),
+ "row 0 should be at the top of the visible area"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.top),
+ "the bottom of row 0 should be visible"
+ );
+}
+
+/**
+ * Checks that changes in the view are propagated to the list.
+ */
+add_task(async function testRowCountChange() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running row count change test for ${variant}`);
+ await runTestInSandbox(subtestRowCountChange, variant);
+ }
+});
+
+async function subtestRowCountChange() {
+ let doc = content.document;
+
+ let ROW_HEIGHT = 50;
+ let list = doc.getElementById("testTree");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ let view = list.view;
+ let rows;
+
+ // Check the initial state.
+
+ function checkRows(first, last) {
+ let expectedIndices = [];
+ for (let i = first; i <= last; i++) {
+ expectedIndices.push(i);
+ }
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ Assert.deepEqual(
+ Array.from(rows, r => r.index),
+ expectedIndices,
+ "the list has the right rows"
+ );
+ Assert.deepEqual(
+ Array.from(rows, r => r.dataset.value),
+ view.values.slice(first, last + 1),
+ "the list has the right rows"
+ );
+ }
+
+ function checkSelected(indices, existingIndices) {
+ Assert.deepEqual(list.selectedIndices, indices);
+ let selectedRows = list.querySelectorAll(`tr[is="test-row"].selected`);
+ Assert.deepEqual(
+ Array.from(selectedRows, r => r.index),
+ existingIndices
+ );
+ }
+
+ let expectedCount = 150;
+
+ // Select every tenth row. We'll check what is selected remains selected.
+
+ list.selectedIndices = [4, 14, 24, 34, 44];
+
+ function getRowsHeight() {
+ return list.scrollHeight - list.table.header.clientHeight;
+ }
+
+ async function addValues(index, values) {
+ view.values.splice(index, 0, ...values);
+ info(`Added ${values.join(", ")} at ${index}`);
+ info(view.values);
+
+ expectedCount += values.length;
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+
+ await doListActionAndWaitForRowBuffer(() => {
+ list.rowCountChanged(index, values.length);
+ });
+
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+ }
+
+ async function removeValues(index, count, expectedRemoved) {
+ let values = view.values.splice(index, count);
+ info(`Removed ${values.join(", ")} from ${index}`);
+ info(view.values);
+
+ Assert.deepEqual(values, expectedRemoved);
+
+ expectedCount -= values.length;
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+
+ await doListActionAndWaitForRowBuffer(() => {
+ list.rowCountChanged(index, -count);
+ });
+
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+ }
+
+ async function scrollListToPosition(topOfScroll) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, topOfScroll);
+ });
+ }
+
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+ Assert.equal(list.scrollTop, 0, "the list is scrolled to the top");
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+
+ // We should be scrolled to the top already, but this will ensure we get an
+ // event fire one way or another.
+ await scrollListToPosition(0);
+
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 150 * 50);
+
+ // Add a value at the end. Only the scroll height should change.
+
+ await addValues(150, [150]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 151 * 50);
+
+ // Add more values at the end. Only the scroll height should change.
+
+ await addValues(151, [151, 152, 153]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 154 * 50);
+
+ // Add values between the last row and the end.
+ // Only the scroll height should change.
+
+ await addValues(40, ["39a", "39b"]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 46], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 156 * 50);
+
+ // Add values between the last visible row and the last row.
+ // The changed rows and those below them should be updated.
+
+ await addValues(18, ["17a", "17b", "17c"]);
+ checkRows(0, 38);
+ // Hard-coded sanity checks to prove checkRows is working as intended.
+ Assert.equal(rows[17].dataset.value, "17");
+ Assert.equal(rows[18].dataset.value, "17a");
+ Assert.equal(rows[19].dataset.value, "17b");
+ Assert.equal(rows[20].dataset.value, "17c");
+ Assert.equal(rows[21].dataset.value, "18");
+ checkSelected([4, 14, 27, 37, 49], [4, 14, 27, 37]);
+ Assert.equal(getRowsHeight(), 159 * 50);
+
+ // Add values in the visible rows.
+ // The changed rows and those below them should be updated.
+
+ await addValues(8, ["7a", "7b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[7].dataset.value, "7");
+ Assert.equal(rows[8].dataset.value, "7a");
+ Assert.equal(rows[9].dataset.value, "7b");
+ Assert.equal(rows[10].dataset.value, "8");
+ Assert.equal(rows[22].dataset.value, "17c");
+ checkSelected([4, 16, 29, 39, 51], [4, 16, 29]);
+ Assert.equal(getRowsHeight(), 161 * 50);
+
+ // Add a value at the start. All rows should be updated.
+
+ await addValues(0, [-1]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-1");
+ Assert.equal(rows[1].dataset.value, "0");
+ Assert.equal(rows[22].dataset.value, "17b");
+ checkSelected([5, 17, 30, 40, 52], [5, 17, 30]);
+ Assert.equal(getRowsHeight(), 162 * 50);
+
+ // Add more values at the start. All rows should be updated.
+
+ await addValues(0, [-3, -2]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[1].dataset.value, "-2");
+ Assert.equal(rows[2].dataset.value, "-1");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 164 * 50);
+
+ Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top");
+
+ // Remove values in the order we added them.
+
+ await removeValues(160, 1, [150]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 163 * 50);
+
+ await removeValues(160, 3, [151, 152, 153]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 160 * 50);
+
+ await removeValues(48, 2, ["39a", "39b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 52], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 158 * 50);
+
+ await removeValues(23, 3, ["17a", "17b", "17c"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 29, 39, 49], [7, 19, 29]);
+ Assert.equal(getRowsHeight(), 155 * 50);
+
+ await removeValues(11, 2, ["7a", "7b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[10].dataset.value, "7");
+ Assert.equal(rows[11].dataset.value, "8");
+ Assert.equal(rows[22].dataset.value, "19");
+ checkSelected([7, 17, 27, 37, 47], [7, 17, 27, 37]);
+ Assert.equal(getRowsHeight(), 153 * 50);
+
+ await removeValues(2, 1, [-1]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[1].dataset.value, "-2");
+ Assert.equal(rows[2].dataset.value, "0");
+ Assert.equal(rows[22].dataset.value, "20");
+ checkSelected([6, 16, 26, 36, 46], [6, 16, 26, 36]);
+ Assert.equal(getRowsHeight(), 152 * 50);
+
+ await removeValues(0, 2, [-3, -2]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "0");
+ Assert.equal(rows[1].dataset.value, "1");
+ Assert.equal(rows[22].dataset.value, "22");
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 150 * 50);
+
+ Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top");
+
+ // Now scroll to the middle and repeat.
+
+ await scrollListToPosition(1735);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ await addValues(150, [150]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ await addValues(38, ["37a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[29].dataset.value, "37");
+ Assert.equal(rows[30].dataset.value, "37a");
+ Assert.equal(rows[31].dataset.value, "38");
+ Assert.equal(rows[65].dataset.value, "72");
+ checkSelected([4, 14, 24, 34, 45], [14, 24, 34, 45]);
+
+ await addValues(25, ["24a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[16].dataset.value, "24");
+ Assert.equal(rows[17].dataset.value, "24a");
+ Assert.equal(rows[18].dataset.value, "25");
+ Assert.equal(rows[65].dataset.value, "71");
+ checkSelected([4, 14, 24, 35, 46], [14, 24, 35, 46]);
+
+ await addValues(11, ["10a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[2].dataset.value, "10");
+ Assert.equal(rows[3].dataset.value, "10a");
+ Assert.equal(rows[4].dataset.value, "11");
+ Assert.equal(rows[65].dataset.value, "70");
+ checkSelected([4, 15, 25, 36, 47], [15, 25, 36, 47]);
+
+ await addValues(0, ["-1"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "69");
+ checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]);
+
+ Assert.equal(
+ list.scrollTop,
+ 1735,
+ "the list is still scrolled to the middle"
+ );
+
+ await removeValues(154, 1, [150]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "69");
+ checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]);
+
+ await removeValues(41, 1, ["37a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "70");
+ checkSelected([5, 16, 26, 37, 47], [16, 26, 37, 47]);
+
+ await removeValues(27, 1, ["24a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "71");
+ checkSelected([5, 16, 26, 36, 46], [16, 26, 36, 46]);
+
+ await removeValues(12, 1, ["10a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "72");
+ checkSelected([5, 15, 25, 35, 45], [15, 25, 35, 45]);
+
+ await removeValues(0, 1, ["-1"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ Assert.equal(
+ list.scrollTop,
+ 1735,
+ "the list is still scrolled to the middle"
+ );
+
+ // Now scroll to the bottom and repeat.
+
+ await scrollListToPosition(6870);
+ checkRows(111, 149);
+ Assert.equal(rows[0].dataset.value, "111");
+ Assert.equal(rows[38].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(50, [50]);
+ checkRows(111, 150);
+ Assert.equal(rows[0].dataset.value, "110");
+ Assert.equal(rows[39].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(49, ["48a"]);
+ checkRows(111, 151);
+ Assert.equal(rows[0].dataset.value, "109");
+ Assert.equal(rows[40].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(30, ["29a"]);
+ checkRows(111, 152);
+ Assert.equal(rows[0].dataset.value, "108");
+ Assert.equal(rows[41].dataset.value, "149");
+ checkSelected([4, 14, 24, 35, 45], []);
+
+ await addValues(0, ["-1"]);
+ checkRows(111, 153);
+ Assert.equal(rows[0].dataset.value, "107");
+ Assert.equal(rows[42].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ Assert.equal(
+ list.scrollTop,
+ 6870,
+ "the list is still scrolled to the bottom"
+ );
+
+ await removeValues(53, 1, [50]);
+ checkRows(111, 152);
+ Assert.equal(rows[0].dataset.value, "108");
+ Assert.equal(rows[41].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ await removeValues(51, 1, ["48a"]);
+ checkRows(111, 151);
+ Assert.equal(rows[0].dataset.value, "109");
+ Assert.equal(rows[40].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ await removeValues(31, 1, ["29a"]);
+ checkRows(111, 150);
+ Assert.equal(rows[0].dataset.value, "110");
+ Assert.equal(rows[39].dataset.value, "149");
+ checkSelected([5, 15, 25, 35, 45], []);
+
+ await removeValues(0, 1, ["-1"]);
+ checkRows(111, 149);
+ Assert.equal(rows[0].dataset.value, "111");
+ Assert.equal(rows[38].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ Assert.equal(
+ list.scrollTop,
+ 6870,
+ "the list is still scrolled to the bottom"
+ );
+
+ // Remove a selected row and check the selection changes.
+
+ await scrollListToPosition(0);
+
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+
+ await removeValues(3, 3, [3, 4, 5]); // 4 is selected.
+ checkSelected([11, 21, 31, 41], [11, 21, 31]);
+
+ await addValues(3, [3, 4, 5]);
+ checkSelected([14, 24, 34, 44], [14, 24, 34]);
+
+ // Remove some consecutive selected rows.
+
+ list.selectedIndices = [6, 7, 8, 9];
+ checkSelected([6, 7, 8, 9], [6, 7, 8, 9]);
+
+ await removeValues(7, 1, [7]);
+ checkSelected([6, 7, 8], [6, 7, 8]);
+
+ await removeValues(7, 1, [8]);
+ checkSelected([6, 7], [6, 7]);
+
+ await removeValues(7, 1, [9]);
+ checkSelected([6], [6]);
+
+ // Reset the list.
+
+ await addValues(7, [7, 8, 9]);
+ list.selectedIndex = -1;
+}
+
+/**
+ * Checks that expanding and collapsing works. Twisties in the test file are
+ * styled as coloured squares: red for collapsed, green for expanded.
+ *
+ * @note This is practically the same test as in browser_treeListbox.js, but
+ * for TreeView instead of TreeListbox. If you make changes here you
+ * may want to make changes there too.
+ */
+add_task(async function testExpandCollapse() {
+ await runTestInSandbox(subtestExpandCollapse, "levels");
+});
+
+async function subtestExpandCollapse() {
+ let doc = content.document;
+ let list = doc.getElementById("testTree");
+ let allIds = [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+ let idsWithoutChildren = [
+ "row-1",
+ "row-2-1",
+ "row-2-2",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+
+ let listener = {
+ reset() {
+ this.collapsedIndex = null;
+ this.expandedIndex = null;
+ },
+ handleEvent(event) {
+ if (event.type == "collapsed") {
+ this.collapsedIndex = event.detail;
+ } else if (event.type == "expanded") {
+ this.expandedIndex = event.detail;
+ }
+ },
+ };
+ list.addEventListener("collapsed", listener);
+ list.addEventListener("expanded", listener);
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = list.selectedIndex;
+ },
+ };
+
+ Assert.equal(
+ list.querySelectorAll("collapsed").length,
+ 0,
+ "no rows are collapsed"
+ );
+ Assert.equal(list.view.rowCount, 8, "row count");
+ Assert.deepEqual(
+ Array.from(list.table.body.children, r => r.id),
+ [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ],
+ "rows property"
+ );
+
+ function checkCurrent(expectedIndex) {
+ Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct");
+ const current = list.querySelectorAll(".current");
+ if (expectedIndex == -1) {
+ Assert.equal(current.length, 0, "no rows have the 'current' class");
+ } else {
+ Assert.equal(current.length, 1, "only one row has the 'current' class");
+ Assert.equal(
+ current[0].index,
+ expectedIndex,
+ "correct row has the 'current' class"
+ );
+ }
+ }
+
+ function checkMultiSelect(...expectedIds) {
+ let selected = [...list.querySelectorAll(".selected")].map(row => row.id);
+ Assert.deepEqual(selected, expectedIds, "selection should be correct");
+ }
+
+ function checkSelectedAndCurrent(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ let selected = [...list.querySelectorAll(".selected")].map(row => row.id);
+ Assert.deepEqual(
+ selected,
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ checkCurrent(expectedIndex);
+ }
+
+ list.selectedIndex = 0;
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Click the twisties of rows without children.
+
+ function performChange(id, expectedChange, changeCallback) {
+ listener.reset();
+ let row = doc.getElementById(id);
+ let before = row.classList.contains("collapsed");
+
+ changeCallback(row);
+
+ row = doc.getElementById(id);
+ if (expectedChange == "collapsed") {
+ Assert.ok(!before, `${id} was expanded`);
+ Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`);
+ Assert.notEqual(
+ listener.collapsedIndex,
+ null,
+ `${id} fired 'collapse' event`
+ );
+ Assert.ok(!listener.expandedIndex, `${id} did not fire 'expand' event`);
+ } else if (expectedChange == "expanded") {
+ Assert.ok(before, `${id} was collapsed`);
+ Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`);
+ Assert.ok(
+ !listener.collapsedIndex,
+ `${id} did not fire 'collapse' event`
+ );
+ Assert.notEqual(
+ listener.expandedIndex,
+ null,
+ `${id} fired 'expand' event`
+ );
+ } else {
+ Assert.equal(
+ row.classList.contains("collapsed"),
+ before,
+ `${id} state did not change`
+ );
+ }
+ }
+
+ function clickTwisty(id, expectedChange) {
+ info(`clicking the twisty on ${id}`);
+ performChange(id, expectedChange, row =>
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".twisty"),
+ {},
+ content
+ )
+ );
+ }
+
+ function clickThread(id, expectedChange) {
+ info(`clicking the thread on ${id}`);
+ performChange(id, expectedChange, row => {
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".tree-button-thread"),
+ {},
+ content
+ );
+ });
+ }
+
+ for (let id of idsWithoutChildren) {
+ clickTwisty(id, null);
+ Assert.equal(list.querySelector(".selected").id, id);
+ }
+
+ checkSelectedAndCurrent(7, "row-3-1-2");
+
+ // Click the twisties of rows with children.
+
+ function checkRowsAreHidden(...hiddenIds) {
+ let remainingIds = allIds.slice();
+
+ for (let id of allIds) {
+ if (hiddenIds.includes(id)) {
+ Assert.ok(!doc.getElementById(id), `${id} is hidden`);
+ remainingIds.splice(remainingIds.indexOf(id), 1);
+ } else {
+ Assert.greater(
+ doc.getElementById(id).clientHeight,
+ 0,
+ `${id} is visible`
+ );
+ }
+ }
+
+ Assert.equal(list.view.rowCount, 8 - hiddenIds.length, "row count");
+ Assert.deepEqual(
+ Array.from(list.table.body.children, r => r.id),
+ remainingIds,
+ "rows property"
+ );
+ }
+
+ // Collapse row 2.
+
+ clickTwisty("row-2", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(5, "row-3-1-2");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ clickTwisty("row-2", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Collapse row 3-1.
+
+ clickTwisty("row-3-1", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3-1.
+
+ clickTwisty("row-3-1", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Test key presses.
+
+ function pressKey(id, key, expectedChange) {
+ info(`pressing ${key}`);
+ performChange(id, expectedChange, row => {
+ EventUtils.synthesizeKey(key, {}, content);
+ });
+ }
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_LEFT");
+ checkSelectedAndCurrent(0, "row-1");
+ pressKey("row-1", "VK_RIGHT");
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ pressKey("row-2", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Same again, with a RTL tree.
+
+ info("switching to RTL");
+ doc.documentElement.dir = "rtl";
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_RIGHT");
+ checkSelectedAndCurrent(0, "row-1");
+ pressKey("row-1", "VK_LEFT");
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ pressKey("row-2", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Use the class methods for expanding and collapsing.
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler);
+ listener.reset();
+
+ list.collapseRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.collapsedIndex, "'collapsed' event did not fire");
+
+ list.expandRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.expandedIndex, "'expanded' event did not fire");
+
+ list.collapseRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event");
+ listener.reset();
+
+ list.expandRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event");
+ listener.reset();
+
+ list.collapseRowAtIndex(5); // Item with children that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 5,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(listener.collapsedIndex, 5, "row-3-1 fired 'collapsed' event");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(5); // Selected item with children.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 5, "row-3-1 fired 'expanded' event");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+ listener.reset();
+
+ list.selectedIndex = 7;
+ selectHandler.reset();
+
+ list.collapseRowAtIndex(4); // Item with grandchildren that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 4,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(listener.collapsedIndex, 4, "row-3 fired 'collapsed' event");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(4); // Selected item with grandchildren.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 4, "row-3 fired 'expanded' event");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+ listener.reset();
+
+ // Click thread for already expanded thread. Should select all in thread.
+ selectHandler.reset();
+ clickThread("row-3"); // Item with grandchildren.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 4,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ checkRowsAreHidden();
+ checkMultiSelect("row-3", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkCurrent(4);
+
+ // Click thread for collapsed thread. Should expand the thread and select all
+ // children.
+ list.collapseRowAtIndex(1); // Item with children that aren't selected.
+ Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ clickThread("row-2", "expanded");
+ Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event");
+ checkMultiSelect("row-2", "row-2-1", "row-2-2");
+ checkCurrent(1);
+
+ // Select multiple messages in an expanded thread by keyboard, ending with a
+ // child message, then collapse the thread. After that, currentIndex should
+ // be the root message.
+ selectHandler.reset();
+ list.selectedIndex = 1;
+ checkSelectedAndCurrent(1, "row-2");
+ info(`pressing VK_DOWN with shift key twice`);
+ EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content);
+ EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content);
+ checkMultiSelect("row-2", "row-2-1", "row-2-2");
+ checkCurrent(3);
+ clickTwisty("row-2", "collapsed");
+ checkSelectedAndCurrent(1, "row-2");
+
+ list.removeEventListener("collapsed", listener);
+ list.removeEventListener("expanded", listener);
+ list.removeEventListener("select", selectHandler);
+ doc.documentElement.dir = null;
+}
+
+/**
+ * Checks that the row widget can be changed, redrawing the rows and
+ * maintaining the selection.
+ */
+add_task(async function testRowClassChange() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running row class change test for ${variant}`);
+ await runTestInSandbox(subtestRowClassChange, variant);
+ }
+});
+
+async function subtestRowClassChange() {
+ let doc = content.document;
+ let list = doc.getElementById("testTree");
+ let indices = (list.selectedIndices = [1, 2, 3, 5, 8, 13, 21, 34]);
+ list.currentIndex = 5;
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "test-row");
+ Assert.equal(row.clientHeight, 50);
+ Assert.equal(
+ row.classList.contains("selected"),
+ indices.includes(row.index)
+ );
+ Assert.equal(row.classList.contains("current"), row.index == 5);
+ }
+
+ info("switching row class to AlternativeCardRow");
+ list.setAttribute("rows", "alternative-row");
+ Assert.deepEqual(list.selectedIndices, indices);
+ Assert.equal(list.currentIndex, 5);
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "alternative-row");
+ Assert.equal(row.clientHeight, 80);
+ Assert.equal(
+ row.classList.contains("selected"),
+ indices.includes(row.index)
+ );
+ Assert.equal(row.classList.contains("current"), row.index == 5);
+ }
+
+ list.selectedIndex = -1;
+ Assert.deepEqual(list.selectedIndices, []);
+ Assert.equal(list.currentIndex, -1);
+
+ info("switching row class to TestCardRow");
+ list.setAttribute("rows", "test-row");
+ Assert.deepEqual(list.selectedIndices, []);
+ Assert.equal(list.currentIndex, -1);
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "test-row");
+ Assert.equal(row.clientHeight, 50);
+ Assert.ok(!row.classList.contains("selected"));
+ Assert.ok(!row.classList.contains("current"));
+ }
+}
+
+/**
+ * Checks that resizing the widget automatically adds more rows if necessary.
+ */
+add_task(async function testResize() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running resize test for ${variant}`);
+ await runTestInSandbox(subtestResize, variant);
+ }
+});
+
+async function subtestResize() {
+ let doc = content.document;
+
+ let list = doc.getElementById("testTree");
+ Assert.ok(!!list, "the list exists");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ async function scrollVerticallyBy(scrollDistance) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollBy(0, scrollDistance);
+ });
+ }
+
+ async function changeHeightTo(newHeight) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.style.height = `${newHeight}px`;
+ });
+ }
+
+ let rowCount = function () {
+ return list.querySelectorAll(`tr[is="test-row"]`).length;
+ };
+
+ let originalHeight = list.clientHeight;
+
+ // We should already be at the top, but this will force us to have finished
+ // loading before we trigger another scroll. Otherwise, we may get back a fill
+ // event for the initial fill when we expect an event in response to a scroll.
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, 0);
+ });
+
+ // Start by scrolling to somewhere in the middle of the list, so that we
+ // don't have to think about buffer rows that don't exist at the ends.
+ await scrollVerticallyBy(2650);
+
+ // The list has enough space for 13 visible rows, and 26 buffer rows should
+ // exist above and below.
+ Assert.equal(
+ rowCount(),
+ 13 + 26 + 26,
+ "the list should contain the right number of rows"
+ );
+
+ // Make the list shorter by 5 rows. This should not affect the number of rows,
+ // but this is a bit flaky, so check we have at least the minimum required.
+ await changeHeightTo(originalHeight - 250);
+ Assert.equal(list._toleranceSize, 16);
+ Assert.greaterOrEqual(
+ rowCount(),
+ 8 + 26 + 26,
+ "making the list shorter should not change the number of rows"
+ );
+
+ // Scrolling the list by any amount should remove excess rows.
+ await scrollVerticallyBy(50);
+ Assert.equal(
+ rowCount(),
+ 8 + 16 + 16,
+ "scrolling the list after resize should remove the excess rows"
+ );
+
+ // Return to the original height. More buffer rows should be added. We have
+ // to wait for the ResizeObserver to be triggered.
+ await changeHeightTo(originalHeight);
+ Assert.equal(list._toleranceSize, 26);
+ Assert.equal(
+ rowCount(),
+ 13 + 26 + 26,
+ "making the list taller should change the number of rows"
+ );
+
+ // Make the list taller by 5 rows. We have to wait for the ResizeObserver
+ // to be triggered.
+ await changeHeightTo(originalHeight + 250);
+ Assert.equal(list._toleranceSize, 36);
+ Assert.equal(
+ rowCount(),
+ 18 + 36 + 36,
+ "making the list taller should change the number of rows"
+ );
+
+ // Scrolling the list should not affect the number of rows.
+ await scrollVerticallyBy(50);
+ Assert.equal(
+ rowCount(),
+ 18 + 36 + 36,
+ "scrolling the list should not change the number of rows"
+ );
+}
diff --git a/comm/mail/base/test/browser/browser_viewMenu.js b/comm/mail/base/test/browser/browser_viewMenu.js
new file mode 100644
index 0000000000..d24e868595
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_viewMenu.js
@@ -0,0 +1,218 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+/** @type MenuData */
+const viewMenuData = {
+ menu_Toolbars: {},
+ view_toolbars_popup_quickFilterBar: { checked: true },
+ viewToolbarsPopupSpacesToolbar: { checked: true },
+ menu_showTaskbar: { checked: true },
+ customizeMailToolbars: {},
+ menu_MessagePaneLayout: {},
+ messagePaneClassic: {},
+ messagePaneWide: {},
+ messagePaneVertical: { checked: true },
+ menu_showFolderPane: { checked: true },
+ menu_toggleThreadPaneHeader: { disabled: true, checked: true },
+ menu_showMessage: {},
+ menu_FolderViews: {},
+ menu_toggleFolderHeader: { checked: true },
+ menu_allFolders: { disabled: true, checked: true },
+ menu_smartFolders: {},
+ menu_unreadFolders: {},
+ menu_favoriteFolders: {},
+ menu_recentFolders: {},
+ menu_tags: {},
+ menu_compactMode: { disabled: true },
+ menu_uiDensity: {},
+ uiDensityCompact: {},
+ uiDensityNormal: { checked: true },
+ uiDensityTouch: {},
+ viewFullZoomMenu: {},
+ menu_fullZoomEnlarge: { disabled: true },
+ menu_fullZoomReduce: { disabled: true },
+ menu_fullZoomReset: { disabled: true },
+ menu_fullZoomToggle: { disabled: true },
+ menu_uiFontSize: {},
+ menu_fontSizeEnlarge: {},
+ menu_fontSizeReduce: {},
+ menu_fontSizeReset: {},
+ calTodayPaneMenu: { hidden: true },
+ "calShowTodayPane-2": {},
+ calTodayPaneDisplayMiniday: {},
+ calTodayPaneDisplayMinimonth: {},
+ calTodayPaneDisplayNone: {},
+ calCalendarMenu: { hidden: true },
+ calChangeViewDay: {},
+ calChangeViewWeek: {},
+ calChangeViewMultiweek: {},
+ calChangeViewMonth: {},
+ calCalendarPaneMenu: {},
+ calViewCalendarPane: {},
+ calTasksViewMinimonth: {},
+ calTasksViewCalendarlist: {},
+ calCalendarCurrentViewMenu: {},
+ calWorkdaysOnlyMenuitem: {},
+ calTasksInViewMenuitem: {},
+ calShowCompletedInViewMenuItem: {},
+ calViewRotated: {},
+ calTasksMenu: { hidden: true },
+ calTasksViewFilterTasks: {},
+ calTasksViewFilterCurrent: {},
+ calTasksViewFilterToday: {},
+ calTasksViewFilterNext7days: {},
+ calTasksViewFilterNotstartedtasks: {},
+ calTasksViewFilterOverdue: {},
+ calTasksViewFilterCompleted: {},
+ calTasksViewFilterOpen: {},
+ calTasksViewFilterAll: {},
+ viewSortMenu: { disabled: true },
+ sortByDateMenuitem: {},
+ sortByReceivedMenuitem: {},
+ sortByFlagMenuitem: {},
+ sortByOrderReceivedMenuitem: {},
+ sortByPriorityMenuitem: {},
+ sortByFromMenuitem: {},
+ sortByRecipientMenuitem: {},
+ sortByCorrespondentMenuitem: {},
+ sortBySizeMenuitem: {},
+ sortByStatusMenuitem: {},
+ sortBySubjectMenuitem: {},
+ sortByUnreadMenuitem: {},
+ sortByTagsMenuitem: {},
+ sortByJunkStatusMenuitem: {},
+ sortByAttachmentsMenuitem: {},
+ sortAscending: {},
+ sortDescending: {},
+ sortThreaded: {},
+ sortUnthreaded: {},
+ groupBySort: {},
+ viewMessageViewMenu: { hidden: true },
+ viewMessageAll: {},
+ viewMessageUnread: {},
+ viewMessageNotDeleted: {},
+ viewMessageTags: {},
+ viewMessageCustomViews: {},
+ viewMessageVirtualFolder: {},
+ viewMessageCustomize: {},
+ viewMessagesMenu: { disabled: true },
+ viewAllMessagesMenuItem: { disabled: true, checked: true },
+ viewUnreadMessagesMenuItem: { disabled: true },
+ viewThreadsWithUnreadMenuItem: { disabled: true },
+ viewWatchedThreadsWithUnreadMenuItem: { disabled: true },
+ viewIgnoredThreadsMenuItem: { disabled: true },
+ menu_expandAllThreads: { disabled: true },
+ collapseAllThreads: { disabled: true },
+ viewheadersmenu: {},
+ viewallheaders: {},
+ viewnormalheaders: { checked: true },
+ viewBodyMenu: {},
+ bodyAllowHTML: { checked: true },
+ bodySanitized: {},
+ bodyAsPlaintext: {},
+ bodyAllParts: { hidden: true },
+ viewFeedSummary: { hidden: true },
+ bodyFeedGlobalWebPage: {},
+ bodyFeedGlobalSummary: {},
+ bodyFeedPerFolderPref: {},
+ bodyFeedSummaryAllowHTML: {},
+ bodyFeedSummarySanitized: {},
+ bodyFeedSummaryAsPlaintext: {},
+ viewAttachmentsInlineMenuitem: { checked: true },
+ pageSourceMenuItem: { disabled: true },
+};
+let helper = new MenuTestHelper("menu_View", viewMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let inboxFolder, rootFolder, testMessages;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("view menu", null);
+ inboxFolder = rootFolder
+ .getChildNamed("view menu")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ inboxFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...inboxFolder.messages];
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function test3PaneTab() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: rootFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+ await helper.testAllItems("mail3PaneTab");
+
+ tabmail.currentAbout3Pane.displayFolder(inboxFolder);
+ await helper.testItems({
+ menu_Toolbars: {},
+ view_toolbars_popup_quickFilterBar: { checked: true },
+ menu_MessagePaneLayout: {},
+ menu_showFolderPane: { checked: true },
+ menu_toggleThreadPaneHeader: { checked: true },
+ menu_showMessage: { checked: true },
+ viewSortMenu: { disabled: false },
+ viewMessagesMenu: { disabled: false },
+ });
+
+ goDoCommand("cmd_toggleQuickFilterBar");
+ await helper.testItems({
+ menu_Toolbars: {},
+ view_toolbars_popup_quickFilterBar: { checked: false },
+ });
+
+ goDoCommand("cmd_toggleFolderPane");
+ await helper.testItems({
+ menu_MessagePaneLayout: {},
+ menu_showFolderPane: { checked: false },
+ menu_showMessage: { checked: true },
+ });
+
+ goDoCommand("cmd_toggleThreadPaneHeader");
+ await helper.testItems({
+ menu_MessagePaneLayout: {},
+ menu_toggleThreadPaneHeader: { checked: false },
+ });
+
+ goDoCommand("cmd_toggleMessagePane");
+ await helper.testItems({
+ menu_MessagePaneLayout: {},
+ menu_showFolderPane: { checked: false },
+ menu_showMessage: { checked: false },
+ });
+
+ goDoCommand("cmd_toggleQuickFilterBar");
+ goDoCommand("cmd_toggleFolderPane");
+ goDoCommand("cmd_toggleThreadPaneHeader");
+ goDoCommand("cmd_toggleMessagePane");
+ await helper.testItems({
+ menu_Toolbars: {},
+ view_toolbars_popup_quickFilterBar: { checked: true },
+ menu_MessagePaneLayout: {},
+ menu_showFolderPane: { checked: true },
+ menu_toggleThreadPaneHeader: { checked: true },
+ menu_showMessage: { checked: true },
+ });
+});
diff --git a/comm/mail/base/test/browser/browser_webSearchTelemetry.js b/comm/mail/base/test/browser/browser_webSearchTelemetry.js
new file mode 100644
index 0000000000..cd420e3b83
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_webSearchTelemetry.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* globals openWebSearch */
+
+/**
+ * Test telemetry related to web search usage.
+ */
+
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ loadURI(aURI, aWindowContext) {},
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+/**
+ * Test that we're counting how many times search on web was used.
+ */
+add_task(async function test_web_search_usage() {
+ Services.telemetry.clearScalars();
+
+ const NUM_SEARCH = 5;
+ let engine = await Services.search.getDefault();
+ await Promise.all(
+ Array.from({ length: NUM_SEARCH }).map(() => openWebSearch("thunderbird"))
+ );
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.websearch.usage"][engine.name.toLowerCase()],
+ NUM_SEARCH,
+ "Count of search on web times must be correct."
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_zoom.js b/comm/mail/base/test/browser/browser_zoom.js
new file mode 100644
index 0000000000..0dbda439ca
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_zoom.js
@@ -0,0 +1,110 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const tabmail = document.getElementById("tabmail");
+const about3Pane = tabmail.currentAbout3Pane;
+const { threadTree } = about3Pane;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false);
+ // Create an account for the test.
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ // Create a folder for the account to store test messages.
+ const rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("zoom", null);
+ const testFolder = rootFolder
+ .getChildNamed("zoom")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ // Generate test messages.
+ const generator = new MessageGenerator();
+ testFolder.addMessageBatch(
+ generator
+ .makeMessages({ count: 5, msgsPerThread: 5 })
+ .map(message => message.toMboxString())
+ );
+
+ // Use the test folder.
+ about3Pane.displayFolder(testFolder.URI);
+ await ensure_cards_view();
+
+ // Remove test account on cleanup.
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true);
+ });
+});
+
+/**
+ * Tests zooming in and out of the multi-message view using keyboard shortcuts
+ * when viewing a thread.
+ */
+add_task(async function testMultiMessageZoom() {
+ // Threads need to be collapsed, otherwise the multi-message view
+ // won't be shown.
+ const row = threadTree.getRowAtIndex(0);
+ Assert.ok(
+ row.classList.contains("collapsed"),
+ "The thread row should be collapsed"
+ );
+
+ const subjectLine = row.querySelector(
+ ".thread-card-subject-container .subject"
+ );
+ // Simulate a click on the row's subject line to select the row.
+ const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ subjectLine,
+ { clickCount: 1 },
+ about3Pane
+ );
+ await selectPromise;
+ // Make sure the correct thread is selected and that the multi-message view is
+ // visible.
+ Assert.ok(
+ row.classList.contains("selected"),
+ "The thread row should be selected"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "The multi-message browser should be visible"
+ );
+
+ // Record the zoom value before the operation.
+ let previousZoom = top.ZoomManager.getZoomForBrowser(
+ about3Pane.multiMessageBrowser
+ );
+
+ // Emulate a zoom in.
+ EventUtils.synthesizeKey("+", { accelKey: true });
+
+ // Test that the zoom value increases.
+ await TestUtils.waitForCondition(
+ () =>
+ top.ZoomManager.getZoomForBrowser(about3Pane.multiMessageBrowser) >
+ previousZoom,
+ "zoom value should be greater than before keyboard event"
+ );
+
+ // Emulate a zoom out.
+ previousZoom = top.ZoomManager.getZoomForBrowser(
+ about3Pane.multiMessageBrowser
+ );
+ EventUtils.synthesizeKey("-", { accelKey: true });
+
+ // Test that the zoom value decreases.
+ await TestUtils.waitForCondition(
+ () =>
+ previousZoom >
+ top.ZoomManager.getZoomForBrowser(about3Pane.multiMessageBrowser),
+ "zoom value should be less than before keyboard event"
+ );
+});
diff --git a/comm/mail/base/test/browser/files/formContent.html b/comm/mail/base/test/browser/files/formContent.html
new file mode 100644
index 0000000000..6779051746
--- /dev/null
+++ b/comm/mail/base/test/browser/files/formContent.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Form Content</title>
+ </head>
+ <body>
+ <form>
+ <div>
+ <input type="date" />
+ </div>
+ <div>
+ <select>
+ <option value=""></option>
+ <option value="3.141592654">&pi;</option>
+ <option value="6.283185308">&tau;</option>
+ </select>
+ </div>
+ <div>
+ <input list="letters"/>
+ <datalist id="letters">
+ <option value="alpha"/>
+ <option value="beta"/>
+ <option value="gamma"/>
+ <option value="delta"/>
+ <option value="epsilon"/>
+ <option value="zeta"/>
+ <option value="eta"/>
+ <option value="theta"/>
+ <option value="iota"/>
+ <option value="kappa"/>
+ </datalist>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/comm/mail/base/test/browser/files/links.html b/comm/mail/base/test/browser/files/links.html
new file mode 100644
index 0000000000..f5703dc4ef
--- /dev/null
+++ b/comm/mail/base/test/browser/files/links.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title>Links to other places</title>
+</head>
+<body>
+ <h1>Links to things</h1>
+ <p>This page is a test of what happens when you click on links. It should be loaded from http://example.org:80.</p>
+
+ <h2>This page:</h2>
+ <ul>
+ <li><a id="this-hash" href="#hash">Anchor on this page</a></li>
+ <li><a id="this-nohash" href="links.html">This page</a></li>
+ </ul>
+
+ <h2>Pages on this domain:</h2>
+ <ul>
+ <li><a id="local-here" href="sampleContent.html">A page in the same directory</a></li>
+ <li><a id="local-elsewhere" href="/browser/comm/mail/components/extensions/test/browser/data/content.html">A page elsewhere</a></li>
+ </ul>
+
+ <h2>Pages on other places on this TLD:</h2>
+ <ul>
+ <li><a id="other-https" href="https://example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but over HTTPS</a></li>
+ <li><a id="other-port" href="http://example.org:8000/browser/comm/mail/base/test/browser/files/links.html">This page, but on example.com:8000</a></li>
+ <li><a id="other-subdomain" href="http://test1.example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but on test1.example.com</a></li>
+ <li><a id="other-subsubdomain" href="http://sub1.test1.example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but on sub1.test1.example.com</a></li>
+ </ul>
+
+ <h2>Pages on a completely different domain:</h2>
+ <ul style="margin-bottom: 100vh;">
+ <li><a id="other-domain" href="http://mochi.test:8888/browser/comm/mail/base/test/browser/files/links.html">This page, but on mochi.test</a></li>
+ </ul>
+
+ <h2 id="hash">This is the hash target!</h2>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/menulist.xhtml b/comm/mail/base/test/browser/files/menulist.xhtml
new file mode 100644
index 0000000000..cba2bbcf86
--- /dev/null
+++ b/comm/mail/base/test/browser/files/menulist.xhtml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/menulist.css"?>
+
+<window align="start" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml">
+ <button id="before" label="I'm just a button" onclick="alert('I\'m a button!')"/>
+
+ <menulist>
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <menulist is="menulist-editable">
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <menulist is="menulist-editable" editable="true" width="100">
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <button id="after" label="I'm just a button"/>
+</window>
diff --git a/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml b/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml
new file mode 100644
index 0000000000..63154cbce9
--- /dev/null
+++ b/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml
@@ -0,0 +1,171 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the orderable-tree-listbox custom element</title>
+ <style>
+ :focus {
+ outline: 3px blue solid;
+ }
+ html {
+ height: 100%;
+ }
+ body {
+ height: 100%;
+ display: flex;
+ margin: 0;
+ }
+ #list {
+ overflow-y: auto;
+ white-space: nowrap;
+ margin: 1em;
+ border: 1px solid black;
+ width: 400px;
+ outline: none;
+ }
+ @media not (prefers-reduced-motion) {
+ #list {
+ scroll-behavior: smooth;
+ }
+ }
+ ol, ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+ li > div {
+ display: flex;
+ align-items: center;
+ padding: 4px;
+ line-height: 24px;
+ }
+ li.selected > div {
+ color: white;
+ background-color: blue;
+ }
+ li > ul > li > div {
+ padding-inline-start: calc(1em + 8px);
+ }
+ li.collapsed > ul {
+ display: none;
+ }
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ margin-inline-end: 4px;
+ }
+ li.children > div > div.twisty {
+ background-color: green;
+ }
+ li.children.collapsed > div > div.twisty {
+ background-color: red;
+ }
+
+ #list > li {
+ transition: opacity 250ms;
+ }
+ #list > li.dragging {
+ opacity: 0.75;
+ }
+ </style>
+ <!-- This script is used for the automated test. -->
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+ <!-- This script is used when this file is loaded in a browser. -->
+ <script defer="defer" src="../../../content/widgets/tree-listbox.js"></script>
+</head>
+<body>
+ <ol id="list" is="orderable-tree-listbox" role="tree">
+ <li id="row-1">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 1
+ </div>
+ </li>
+ <li id="row-2">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 2
+ </div>
+ <ul>
+ <li id="row-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-3">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 3
+ </div>
+ <ul>
+ <li id="row-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-3-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ <li id="row-3-3">
+ <div>
+ <div class="twisty"></div>
+ Third child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-4">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 4
+ </div>
+ </li>
+ <li id="row-5">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 5
+ </div>
+ <ul>
+ <li id="row-5-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-5-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ol>
+
+ <div id="marker" style="position: absolute; left: 500px; border-top: 1px red solid;"></div>
+ <script>
+ function moveMarker(event) {
+ let marker = document.getElementById("marker");
+ marker.style.top = `${event.clientY}px`;
+ marker.textContent = `${event.type} event here`;
+ }
+
+ document.addEventListener("dragstart", moveMarker);
+ document.addEventListener("dragover", moveMarker);
+ document.addEventListener("drop", moveMarker);
+ </script>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/paneSplitter.xhtml b/comm/mail/base/test/browser/files/paneSplitter.xhtml
new file mode 100644
index 0000000000..7d25e5596e
--- /dev/null
+++ b/comm/mail/base/test/browser/files/paneSplitter.xhtml
@@ -0,0 +1,122 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the pane-splitter custom element</title>
+ <style>
+ hr[is="pane-splitter"] {
+ margin: 0 -3px;
+ border: none;
+ z-index: 1;
+ cursor: ew-resize;
+ opacity: .4;
+ background-color: red;
+ }
+
+ #splitter3,
+ #splitter4 {
+ margin: -3px 0;
+ cursor: ns-resize;
+ }
+
+ #horizontal-before {
+ display: grid;
+ grid-template-columns: minmax(auto, var(--splitter1-width)) 0 auto;
+ width: 500px;
+ height: 100px;
+ --splitter1-width: 200px;
+ margin: 1em;
+ }
+
+ #horizontal-after {
+ display: grid;
+ grid-template-columns: auto 0 minmax(auto, var(--splitter2-width));
+ width: 500px;
+ height: 100px;
+ --splitter2-width: 200px;
+ margin: 1em;
+ }
+
+ #vertical-before {
+ display: inline-grid;
+ grid-template-rows: minmax(auto, var(--splitter3-height)) 0 auto;
+ width: 100px;
+ height: 500px;
+ --splitter3-height: 200px;
+ margin: 1em;
+ }
+
+ #vertical-after {
+ display: inline-grid;
+ grid-template-rows: auto 0 minmax(auto, var(--splitter4-height));
+ width: 100px;
+ height: 500px;
+ --splitter4-height: 200px;
+ margin: 1em;
+ }
+
+ .resized {
+ background-color: lightblue;
+ }
+
+ .fill {
+ background-color: lightslategrey;
+ }
+ </style>
+ <!-- This path is used for the automated test. -->
+ <script src="chrome://messenger/content/pane-splitter.js"></script>
+ <!-- This path is used when this file is loaded in a browser. -->
+ <script src="../../../content/widgets/pane-splitter.js"></script>
+ <script>
+ function moveMarker(event) {
+ let markerX = document.getElementById("markerX");
+ markerX.style.left = `${event.clientX + window.scrollX}px`;
+ markerX.textContent = `${event.type} event here`;
+
+ let markerY = document.getElementById("markerY");
+ markerY.style.top = `${event.clientY + window.scrollY}px`;
+ markerY.textContent = `${event.type} event here`;
+ }
+
+ document.addEventListener("mousedown", moveMarker);
+ document.addEventListener("mousemove", moveMarker);
+ document.addEventListener("mouseup", moveMarker);
+
+ window.addEventListener("load", () => {
+ for (let splitter of document.querySelectorAll('hr[is="pane-splitter"]')) {
+ splitter.resizeElement = splitter.parentNode.querySelector(".resized");
+ }
+ });
+ </script>
+</head>
+<body>
+ <div id="horizontal-before">
+ <div id="splitter1-before" class="resized"></div>
+ <hr is="pane-splitter" id="splitter1" resize-direction="horizontal" />
+ <div id="splitter1-after" class="fill"></div>
+ </div>
+
+ <div id="horizontal-after">
+ <div id="splitter2-before" class="fill"></div>
+ <hr is="pane-splitter" id="splitter2" resize="next" resize-direction="horizontal" />
+ <div id="splitter2-after" class="resized"></div>
+ </div>
+
+ <div style="display: flex;">
+ <div id="vertical-before">
+ <div id="splitter3-before" class="resized"></div>
+ <hr is="pane-splitter" id="splitter3" />
+ <div id="splitter3-after" class="fill"></div>
+ </div>
+
+ <div id="vertical-after">
+ <div id="splitter4-before" class="fill"></div>
+ <hr is="pane-splitter" id="splitter4" resize="next" />
+ <div id="splitter4-after" class="resized"></div>
+ </div>
+ </div>
+
+ <div id="markerX" style="position: absolute; top: 0px; border-left: 1px red solid;"></div>
+ <div id="markerY" style="position: absolute; left: 550px; border-top: 1px red solid;"></div>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/rss.xml b/comm/mail/base/test/browser/files/rss.xml
new file mode 100644
index 0000000000..8ff0540a66
--- /dev/null
+++ b/comm/mail/base/test/browser/files/rss.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+ <channel>
+ <title>Test Feed</title>
+ <link>https://example.org/</link>
+ <description></description>
+ <lastBuildDate>Thu, 21 Jan 2021 17:57:54 +0000</lastBuildDate>
+ <language>en-US</language>
+
+ <item>
+ <title>Test Article</title>
+ <link>https://example.org/browser/comm/mail/base/test/browser/files/sampleContent.html</link>
+ <pubDate>Wed, 20 Jan 2021 17:00:39 +0000</pubDate>
+ </item>
+ </channel>
+</rss>
diff --git a/comm/mail/base/test/browser/files/sampleContent.eml b/comm/mail/base/test/browser/files/sampleContent.eml
new file mode 100644
index 0000000000..f0465ad2bd
--- /dev/null
+++ b/comm/mail/base/test/browser/files/sampleContent.eml
@@ -0,0 +1,160 @@
+From andy@anway.invalid
+Content-Type: multipart/related;
+ boundary="--------------CHOPCHOP0"
+Subject: Big Meeting Today
+From: "Andy Anway" <andy@anway.invalid>
+To: "Bob Bell" <bob@bell.invalid>
+Message-Id: <0@made.up.invalid>
+Date: Tue, 01 Feb 2000 00:00:00 +1300
+
+This is a multi-part message in MIME format.
+----------------CHOPCHOP0
+Content-Type: text/html; charset=ISO-8859-1; format=flowed
+Content-Transfer-Encoding: 7bit
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="icon" href="http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png" />
+ </head>
+ <body>
+ <p>This is a page of sample content for tests.</p>
+ <p><a href="https://www.thunderbird.net/">Link to a web page</a></p>
+ <form>
+ <input type="text" />
+ </form>
+ <p><img src="cid:logo" width="304" height="84" /></p>
+ </body>
+</html>
+
+----------------CHOPCHOP0
+Content-Type: image/png; charset=ISO-8859-1; format=flowed;
+ name="tb-logo.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="tb-logo.png"
+Content-ID: <logo>
+
+iVBORw0KGgoAAAANSUhEUgAAATAAAABUCAMAAAAyN5s5AAAC91BMVEVMaXErLDVPU1wYFx1O
+T1RPT1RNT1MTExoFBwZNTlNOTlNNT1RNTlNMTFRNTlMGBgoDBgYFBQZNTlQGBgdOTlNNTlMA
+AwMEBQVNTlNOUFRMTFUAAABNTlMEBgdNT1QFBwoEBAZOTlNNTlMuOoNNTlQACghNTlRNTlNO
+T1RNTlMTDytNT1N/qs1NTlMxPolNTlSZydyfmI8pMHosPIYaFSFNTlMdEkRNUFVNTlMrM38U
+EBczT5hNTlMtNX8UDy8+XqIqN4AkGmCTiIEwN4JHaKgRCygnLHdzmsNYdq2Fd3N2nMRvlL8g
+G00RCi0TCzAsQYsqHGJMbq5pjLsWDj9HYqV/qdh6nsxpYFljhsL////7+PL38+z59u////z8
++/f8+vT28ufz7+UoG2D+/fnx7ODu6NvRyb3g2c7k39bSzcXr5NbIvq/Z0si9sqLJw7jDuKjb
+0cCJhYHOxbbn3s/n5eGjn5mtqKPY1M+0r6rl2MS9ubVwbW54dXZgXl5HREZnZWHHxcJWVlU/
+PjevpJXazbR/fX0cJGV/d29UaaFPTka5rZx0b2P/++T77c9UVEphW01BQFtXTl6QnavN3exg
+hcBIa65bgL0rS5dOcbGKt9NKZ6dPdLZdhbk8ZKlDcbNVd7RWe7l0pcszX6c5WpyKvN5EYaI9
+aK8yWaNJdrRff7OBsdc0RZUvUppukr8xN3knRZItLXkoUqFiisUoPIlsnMVpirxKfcdekL+Z
+yusoQY4EAQktOIQOBVVWicRBaqgqMmp3msRTgr40VJ1SiLhAbrFwmc1glc5DWaEqMoBRcKtp
+kMh0ntRMfrlHdronKVek1vYVCzd/ptg7aaEYGG83UZM6X6JDc6taea1cZXwkIUJDV5kLAyW3
+6P2PwOlhp+A4QIIcDg8+TpYoIXQiFVY0SYuClLAFABlZKzUdFWgyN4zU//82MTUrIGkpHWVO
+T1SUjoosIm0eK3oSCS89RYwpGjNNQHREVIaXdlSjqrsmJCHZ+P6hTAAuJnRz9RRfAAAAWXRS
+TlMAAw0H2DaeARv9u/JCJ39bSVawUY/kECNSFBsL6jdqQS2nyiBjFnOGWS6XSv75/cP+/Y1X
+gpf+INIxbcWMRrneatP7sn305HhA1FEovMfh4/W3xtjNzNPUx47MeaUAABWdSURBVHhe7JTH
+a1xXHIUleZ1lwPEygpDgFzEZEQUjhGQiYiEtjIwV8ofd13uv03vvvaj34t7Se1/kzigkJLGJ
+DN69+VZv8eM++DjnjL2YyVXv2PjYRRlxI/bRxOJFhY0YX4tdH781Oz13oesRE2sg61377jaj
+3phYWVpeXl5aWpl44fWISx93toN1FE/sVRAPIlpIkRHM/fnVxbnndnTEJ51UU+O2EomEkrQQ
+yycwTIvK75Yd56Z37j9JG7HacXC94osqIkkiluVjKgSlynggNWTKO/mP6xGLHXZvi0laikLC
+OopMpdJS5TIeCOAOBIcf3qW/r0d4sR4TNdYVMblORsWoub9PQV9lJxTSdUliWUmSQqH5xfNm
+jrh2vZEzTuF2iUpSSUR9574cR4eqqlzVMFiJzaMsy1KL8HqEV93p9RRFXEes5Knoi/3pK6Sz
+WrXKGWy1qlVZlCAIFKVuT47i9WHMPKrW1hWPYsG9h75ME+6XrEsatFXqsqymcRyNogSPDlhw
+ua8F2xJ/lOvJpMdDIkjRx5gMQ6hQFywj1zUMbhgzjqYJAUVpCDrr6pBNR0D0zgP2dOCLRIqw
+jibDq7I09LXXzUtcb1BL2hB4YQBN8xx+zcW+dHYnkw12PENfvv1Wi2eEPMyXBC11u908rGWP
+MwwaI2gBwwQAnWFayrXGptmw/HD7a8Qa+jIpimoxOUIuwxoaRrfUBiwH6Q36yA908TwvhAVM
+v3nJnb6W1E1Bnsr+fCJCX8VWQ4W+EiW1vCPvVnu5er1taL0Bw2wBrN0WiBYP2mE0MOtKXysq
+Im5NPb3zq4dUyFjjXqNBYfUcKpfxQr9feHhExOPxbreUK5XCAACBAAwtwIzVajyOu9HYBAWs
+van72UefkiRp2vcaRypTrxuyg+OpwuHMzMZG34nHSyWAUqBdqwECxkvAYDc7IBQKudDYLbZd
+iXzTzD46IUmfbTcaKl87K+06eAASKRQK/Y3jB1I8v8uiFAFqYBivMDRWq4UgC64rpEnndzLP
+vkjvnyBIy7bvb9udszo38JWCRDL+gr//+DgSh5tPU1SrxUNXcMEw3lfEoTB90m0BA5i8Y//+
+OH2wjhQbdvb7J5+fwYCd+4pAMk2/37+RLsTjtCAQBMFjAPaxBng+ltIlXXJZKZdj7YScCh6n
+7x6cirHy0x+e/PbTWT3/l68MpBn0Z2bS/XhOwHiC57EwgAPWxhrlgCNpEjv9EgLeuHz1VQn4
+4K2rr116iam+/PZzX7nyP4Ku/Os3iE/cCzS/vfvLZwebRdPOfnWYfu+s5wRwuPm4nYLCmpBg
+0H+YLtQxDNBQWLstYJs1UA6E8LKmafMXFfDO+2++++Xrr0rYH9Taa0xbV54A8AvEMCbgD8iW
+MEJonCIeojYiFXGiTbVVsttKuyuNZjRayQ9sLNtgk5TGwRhi3qQB0mTSxwJmTAgkLjEJJHXi
+MCkkdRpPaAykXcjYycRWCiZe1IiJzSOTCRIf9pxzL8fGNYnbrdLu/wO+HI7v43f/93+Oj0ny
++/3p+eyotGjM/K1+f4R9bPNzd7/kpOFhOLjhnSsXTo5cW55tbjpR9+XE/cG52dl/PPzTkOHS
+yFILiCXHYxQWMMFYHvV9BUpXV1/nBHgep6d7YQUznPn61tnwFMvhxH8/OMlEph8ePOYnAqP5
+YfCjAkuFXbnsyLuIfyFYMnor/kbt9Svjhs8eja7W1dY1XRgefzzS9hx4XRp5tDw7u7q6+q/L
+Ho9neWoKvoDN7smJiV4ANjlxe/ih0Qoq2NClU19/HZ5iHH+kyCVy8Wn/ZGD0tOgqAR3KJBOR
+TzT2pWBbcIKdvjL0mWN+daHpSE3T/ZPjg8Zvvz1rNrQsA62F5vqjJ04crV9YnR0dHZ33LC/P
+t0yOjXX2to9Nj5lun/eCkn/GbOjpvNMRlmLMSF7pCUQaHefEKwaLSY8IlkKCsaMH+8/Tw0Pm
+R7PN/vramtove1w+Y4f5jHVqFmo1NdU11Bwp0OkqA7V19IWny575+fHpyfbuzsnzY/2G/sEh
+Oyj5l8ydNwfCBsrCSGDbQN3dii/xFYMRWyKCZSCvnUTUYP9+4fVzZ02e1aMN9U0N6sZn5+qd
+Zw0tnlnIBbACldWNVVVVjfrqyoCmtqaufr5levryTePwwwnTkME+ZHfdOmu+1Nd+071xLpa9
+lQyUUFzqF+YvECwhH9WK6MHeGT7fe3NmtBnkUn2DrOGTTz68sTQ1u1DfVFdTq6nUV2lFEoFA
+UCTWNuoCtaBpwXP54cQX7rbPTxrMZrN9yHXq1JlLA5e/uL19A0BcLBnodAqpXxJ+SWA4slLi
+gVfUYL8bPj/e+c3s0boGTaVG+1//0/zg6WozyK1akFyNWokAR5FIpQ/UNNTN+/578gv3ymSH
+HXwVYrebu7p7Lt26fNn9dkSARAiWjRt+YWA4ogeLbbvw+rjxm9UTDUf00irp89WFj+uglkZT
+UF0lRlAw5HI5+KFoDNS0Pr3++aft3hX3bbDC32Oy27s6b5nPTlz2OXZtCsb8fw7GpwfB/s39
+5en71h0LTbXVWoHo+Ucfq6sRV6BSr5UgLcpLIpHKJTK9RlO/PP15r8874+4AAcEuD5jPjvU5
+rr0VPRgf1I48RnxhFg0P+knZqcE7mpqdixpTC9FrEuiaseFS01IZjJxkgr0BLCGJUcjZnRli
+QsvOQZ8HOClZFBifiMvZzWFmhRStBH5eNumYy8iE+wbdmXGkVA46TAyZYQmpmTTinZXXT590
+TDU3VJZKiwTPn6oE1Rrk1aiVYy7SSyiUSlU6TYNn+KHX613xgi+Pulwue9dE95lbY7eXrNuj
+BvMTBIPlR5GIHHJ2ckGlW68p8MqAXmYiwI0HrzvJroVBUA4XtTCT00PAsqhd+lNIDTYHNHBB
+Ox22kWBbiZz1A/Mpr3jYkATOLB6KgmET7TsGysWTXRkxLASWnJKYQfzL0pfnrz6eqj+iL5ZL
+5QJ1gVavI70kCAoF8pIKxWKxTF2te+AeHrzt9q4Yu3sBmLl7rPvMQJ91xLo3ajAWHzWj4Cav
+T0IY2Ji8AnS6KbFoorRhOp6JW1gsDAa748hBh0KbRB7VgsBYwb1tpbIb+YDtONSaQPOvV91U
+JIeSiwSjMQszid/+5dynf/ZOHQ1UgfwBOaYLVOl0gQK9VgqQMBf0AlwisVCkUvtb+m/3Q7C+
+zm4A1jc5cKt/xGqwvp0QLVg6UMOxE7yNEQoWT4HlUucaDFImyx8SCAx74UAYHKgZTx6Lj8Bw
+4FtFNdPWxanuycHbgtUIdkZGJvF7x8wfr3TsaNJpwSMHxQLVjdXVuiqxHPhJqEDpJRTCLaFW
+qWnp6O93r6wY29u7XT2u9ulTA4NWw9CI47VowVAkFlK5k7wJWEywawrJthsVGrKNw9i9bR0M
+ezFpuRksPHNnQDCUTP58KBN+4PSEDWBZOPfgDDuPerwZ2fkUGDx7gvhnX8u5T+9dbypXIDCJ
+QN2q1jeqAR/wowJtw3STCIsEcv3RG139RvfKTNvERJfLZhsbu9MBvADYrh8Als3Hz1ZSZDB8
+hUzQNQ5tJ0Iw8vzz0GCwZR2MfJDQsMFOR3RBgcS8WD4fg6XAvEomtzM2gOWQMPlk93REijI1
+NR1PKwCY96Jl7Nvv6g8qhGJIJBC0arSNWhGoV0IqxECrSFAkBz8E2oJjDR8+6zIa3d6ZtrGJ
+jh5b1/TEHednZgB27c2oweg0PNK/DIxFC9atbXh1ITNsWoEOwyEb89azJ8OPlSkZXAfTcNIE
+wVJxW8i9xIfBYG7f0tqVWzs+kEEwYZFQpW7Qi2RSEUkG3SRyAeACWkXlmmOt75a996zDaPR6
+L96fHrZ19PSe/8prNbvsAOyNqMGSqAbOy8GSMQ0EozhZeEZJgrFx4YKByl4uBcZN2wCGx0aq
+GoSD0UETzuOtFDU7FKzfNzjV/KelI8eBjVyiUMlkumMiiVBcLIIhJpNLAEJd0NpaUCoQFO9r
+s40bV1ZuDE939twxjX9qWzKccdntPwCMHkM1FL4UjBsbBpaOZxghGZaDLw/fhkwKDCcYlkGR
+SiVqOFh+KHpKhJn+P7X5Hj9ornv21wqRUCrTqiRFWm1BgUAqLJYVAy44vy+CWvqC1oAaVLDi
+Yll5W0ev22u5cX6y697NkfF2h9Xc02My/RCwNGzzYjDcFYMl4wsNBcsmrygGRQITFUkKjBMR
+jNoPMwwMG7HRlCIrEth9J+/B0dY9lY0isUolAmOjSCs/XC0AEwixRF4kh8lVVdC6p7oKuIkB
+l+y4vr9vfMW749nnw7ar/Vb74IjB1WFzmUasrwIsB9eWUDCUU1wWFahMxlNgKZHBYrmIJwwM
+J2QSHkTCwfaeM/IenIAzVVUpGBph3RJJ5AG1ADyMUgnQ0ur2aCrLpYIiIXhCi2UA7HnPSfft
+G/P3Hw5cHbcY7CbDkK2r467JynvzFYBl4WoVBGPDo4QH58VgcUg1MTIYvi8RwLbfP2l98GGl
+rlJXrkSVHg6LYDAEVQwkl6x6jyZQDthUYuRVLFMojn9y6r57Zn7qwvkvxv9mNfe7DPaOvns2
+p5W362cDy/8+WP5LMuzFYJmbgr3RdgWAlZfr9Kri4mIRRSYorxYIRPqAJqAXCQRSsVypkiIv
+mUL2h+E/G2fmPX8fG3Mvzg31dLgMpr6T92y+z3iv/bwZxmWFRHp8NGApPxzsNd6Y68GH1ftL
+lUoZJQbIJJLDlQWaPdUygUCO5hZSlVKMvJTH1Xd6LaOewLN+p2XO4eoCYN6rJwfcVgMv7hWA
+ZZBgkWpYPhETjLiEF4Dhr0TiI4LhQTQvAhjxdvtXO5ory2RKhQKJkWQCUeCwSgAKlxhyiUVi
+mUoEvRQK5bD1uqf+A92cw8eb493q7bjLW7ly1e0wOH9DvAKwPHwlEUbJsNgUDH/AyooMhqfH
+kcC28yYvLhSoK1RlZUqFDJiJUEiBlpgM2FAsBEMo5Dr+fOajuoKD79cuuUCGOW+es9nmVk6P
+rznszr0/EVjhi8DYfmwbBKMGNXa0YLhI0SKC4TGBGQnszaW28QXNvv2lpaUqFTSjyAAUjmIQ
+sv1KmVKpLKs4VFFW8sF7f12655ybM0303vN+NzPumOOZnG/9H8GyowEjdqKNsJl+DBclRBRg
+aaHnlU5EAsO6yAgvFLGoxZgE3trMR8d0Jfv374dkKiXKM1GYlkwmUu2XAS+Qh4cOvvfuu3+3
+Dvjm5oxX7tgscxaHw+Jz+Xb9eLCskEtLS38hGAOXYzz688M+MIF90DYDY4Q+kczNwTjUQgpO
+RwxGbB+8/s2xyn0lajVphvKMQitGIUPFq3h/qQJ4VVQcOLhv3+G/Dd6zzM2du3rXt7bm4F2z
+OF28hB8PlhP81JfMwkuAkcCoZ3IrEsvkrl9Wmh/hxFG6LP9mYH5OLpyXpuP1tUhguIhtycUL
+cMH/b/i1/bsdxyrf23cQkAGzKurZJNEwl0KpKCspUwKvUgD2fuC6q3/Rsma0OXlra9d8DovJ
+tJf40WDUDWfl8HOz6dSazmZgRPb6qhYHCKCg4VYWg8anZcLlrsLIYIg9hVpI201sCoa/Ec8v
+jEdFIPT/G/5jcEd96+EDFepy0gyhqVCmQTYUsHopSkuUFRUVhwDYoZrrvpk1yxrPabq2aOH5
+LJa7zjd+NBiqTGHBpW0GFsMK75uHLzAk+N8DC3sftNkcDK3Ahe+Ril95LZ75px8HDiorSsrL
+SwAayjQy1VSwasGAryUHQM0/dKDkwPsf/WUNeK05bb7FxYtO3+LFe78hIga6mYUhYPhZwJUi
+jyxcODKyqQqOZDAYLsLsfKySnhKsS4xQjHxaHFUZsQAfTbyCvTjYMXQBcRv2jQlZ/kebqZh+
+u3OHxzM//7RGdwCY7QNmJBrKNRAVIGD1Kqs4eAiCHfjD4etAa23RYru7trjIM/Ge+GzbiU0y
+jE5HuY8zjE6nc4MZBn5D5SgNyiKUHPit1k404Id0pYFtvPaSRd39ncmxsJm6H3kYcksGNUrS
+6dgFeIC+eUQGi6Qmlamj0FGGgVfoiyNzC6XPJ7jgTwwMFndxbcqzvOwZnV+o1ZUqSsuhGVYD
+UUpGRdmBgyRYDQJ74rsz82RxzWmyPLnrfC0yGDsNRGywISENRkzoX+Oo692dmJjITEUgJAE7
+pGsM3GZj9yQGk5kFUTc052YwU1JQO4pY/Ee8N3i1qdnM7FR8SsGjxOHuOPLAYRhJ1GHSgu1v
+zTwCYIBsfnS+WbOvDJmBpxOz/W/79fPithHHYXiEDjsEWkkRaKBikKDooltaYYggpk7ZwMLa
+LOyh1zKka1hXlfES6KY/LvYpkEP/ht5Nzu02PvRajHDcXHzYg0zYwgwMm6sO/c5Kzm5buuQ+
+fjHo/vAZaTxsOh0Nwet0Mn+lBiZf/ywk//XPtXix+Bxp1aMpTKzuzS+rHx8OvnrcHw0ATamN
+jjYNh6N8OJk8v3emvMTypZBSLhfTcrn4GGnV/ensshbr7sLOdp0P7z39+vj4KM8BDRptmmT5
+ZPT8+7MKpKq1FBWbvl4zAQPTLFrNVkqsiElRXBZdZND07pP+lyffZPngH43zYT5/JaGKcyn4
+y4Uo18sPkG65f83qiZlBD8TaBvGjINn79uRxf5Dl1w2y8akaWBNb/jEr+fknSL+st/M3amIJ
+CuPdorBIEISYuGDWPx7mz7KrcvUbzy9kU3n+23nJZAdp184Oss6VWNElYWTHvR4NbIgSaiV7
++enJUdaYZZMfzip+lWTV78uSCf4R0i7D9JHzdr5aFbuUKqbUsSEXCsIgvbuXwZUifwYNxuUF
+rxNysS4Z4w80HJhhehGyHsG3skgwSAUBYEEOZLkBtdM7e+N8MsmywU9ncgM2fQFe4jOkJxgm
+HolnrUuYWL0tR2XVOSBoJXceqr/djZYQHLSYeIB0TC2MhKHn7rdasZpYM64buXYYuukX/IKL
+dzEmP0VatmP6Hg5DinHabdl0s7CbYI7juiQ6lDe9uAgQpOeZ9CNCQkoxsVwaNBu7eSgBzMbt
+6lpLMNkhqE7T1z4GsjDEwEaD2gzQrgtwXCknwIK4YDFCGoPBxoAMEwjYrtE2OZTGFQcpwUvl
+xQ8DhAyNxWBjpu97UBTVcEotsOtcm7idSigp0SqFkKztI9PcQZqPDPIhUFNoNVlz5U8Oqnpe
+RYtxWSaG4ZmGAtN6ZHU1G6ApMiVGMd2vuOJirV4pJevaKIr8BkxzNOidmiIDMQLzkkJwznrt
+9kElDlPPJNF/B7YdG5CBGE4PKthX2Woncacq2ykx8JXXv8G2Y6u/nF7SacdxYlFi7e8ngWdi
+gj1/6/W/lw0Mh9NQ9zRqE9PwMI6A65bzuBWDIqyCB2gB123z2r7Mmm9mk19z3eK1FQMylQ/B
+w3gfrq3Zpvfm2qo1Ia37G8S93Ux/GSLZAAAAAElFTkSuQmCC
+
+----------------CHOPCHOP0--
+
diff --git a/comm/mail/base/test/browser/files/sampleContent.html b/comm/mail/base/test/browser/files/sampleContent.html
new file mode 100644
index 0000000000..05528ac9f1
--- /dev/null
+++ b/comm/mail/base/test/browser/files/sampleContent.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Sample Content</title>
+ <link rel="icon" href="tb-logo.png" />
+ </head>
+ <body>
+ <p>This is a page of sample content for tests.</p>
+ <p><a href="https://www.thunderbird.net/">Link to a web page</a></p>
+ <form>
+ <input type="text" />
+ </form>
+ <p><img src="tb-logo.png" width="304" height="84" /></p>
+ </body>
+</html>
diff --git a/comm/mail/base/test/browser/files/selectionWidget.js b/comm/mail/base/test/browser/files/selectionWidget.js
new file mode 100644
index 0000000000..b1e5f98e25
--- /dev/null
+++ b/comm/mail/base/test/browser/files/selectionWidget.js
@@ -0,0 +1,225 @@
+/* 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 { SelectionWidgetController } = ChromeUtils.import(
+ "resource:///modules/SelectionWidgetController.jsm"
+);
+
+/**
+ * Data for a selectable item.
+ *
+ * @typedef {object} ItemData
+ * @property {HTMLElement} element - The DOM node for the item.
+ * @property {boolean} selected - Whether the item is selected.
+ */
+
+class TestSelectionWidget extends HTMLElement {
+ /**
+ * The selectable items for this widget, in DOM ordering.
+ *
+ * @type {ItemData[]}
+ */
+ items = [];
+ #focusItem = this;
+ #controller = null;
+
+ connectedCallback() {
+ let widget = this;
+
+ widget.tabIndex = 0;
+ widget.setAttribute("role", "listbox");
+ widget.setAttribute("aria-label", "Test selection widget");
+ widget.setAttribute(
+ "aria-orientation",
+ widget.getAttribute("layout-direction")
+ );
+ let model = widget.getAttribute("selection-model");
+ widget.setAttribute("aria-multiselectable", model == "browse-multi");
+
+ this.#controller = new SelectionWidgetController(widget, model, {
+ getLayoutDirection() {
+ return widget.getAttribute("layout-direction");
+ },
+ indexFromTarget(target) {
+ for (let i = 0; i < widget.items.length; i++) {
+ if (widget.items[i].element.contains(target)) {
+ return i;
+ }
+ }
+ return null;
+ },
+ getPageSizeDetails() {
+ if (widget.hasAttribute("no-pages")) {
+ return null;
+ }
+ let itemRect = widget.items[0]?.element.getBoundingClientRect();
+ if (widget.getAttribute("layout-direction") == "vertical") {
+ return {
+ itemSize: itemRect?.height ?? null,
+ viewSize: widget.clientHeight,
+ viewOffset: widget.scrollTop,
+ };
+ }
+ return {
+ itemSize: itemRect?.width ?? null,
+ viewSize: widget.clientWidth,
+ viewOffset: Math.abs(widget.scrollLeft),
+ };
+ },
+ setFocusableItem(index, focus) {
+ widget.#focusItem.tabIndex = -1;
+ widget.#focusItem =
+ index == null ? widget : widget.items[index].element;
+ widget.#focusItem.tabIndex = 0;
+ if (focus) {
+ widget.#focusItem.focus();
+ widget.#focusItem.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ });
+ }
+ },
+ setItemSelectionState(index, number, selected) {
+ for (let i = index; i < index + number; i++) {
+ widget.items[i].selected = selected;
+ widget.items[i].element.classList.toggle("selected", selected);
+ widget.items[i].element.setAttribute("aria-selected", selected);
+ }
+ },
+ });
+ }
+
+ #createItemElement(text) {
+ for (let { element } of this.items) {
+ if (element.textContent == text) {
+ throw new Error(`An item with the text "${text}" already exists`);
+ }
+ }
+ let element = this.ownerDocument.createElement("span");
+ element.textContent = text;
+ element.setAttribute("role", "option");
+ element.tabIndex = -1;
+ element.draggable = this.hasAttribute("items-draggable");
+ return element;
+ }
+
+ /**
+ * Create new items and add them to the widget.
+ *
+ * @param {number} index - The starting index at which to add the items.
+ * @param {string[]} textList - The textContent for the items to add. Each
+ * entry in the array will create one item in the same order.
+ */
+ addItems(index, textList) {
+ for (let [i, text] of textList.entries()) {
+ let element = this.#createItemElement(text);
+ this.insertBefore(element, this.items[index + i]?.element ?? null);
+ this.items.splice(index + i, 0, { element });
+ }
+ this.#controller.addedSelectableItems(index, textList.length);
+ // Force re-layout. This is needed for the items to be able to enter the
+ // focus cycle immediately.
+ this.getBoundingClientRect();
+ }
+
+ /**
+ * Remove items from the widget.
+ *
+ * @param {number} index - The starting index at which to remove items.
+ * @param {number} number - How many items to remove.
+ */
+ removeItems(index, number) {
+ this.#controller.removeSelectableItems(index, number, () => {
+ for (let { element } of this.items.splice(index, number)) {
+ element.remove();
+ }
+ });
+ }
+
+ /**
+ * Move items within the widget.
+ *
+ * @param {number} from - The index at which to move items from.
+ * @param {number} to - The index at which to move items to.
+ * @param {number} number - How many items to move.
+ * @param {boolean} reCreate - Whether to recreate the item when
+ * moving it. Otherwise the existing item is used.
+ */
+ moveItems(from, to, number, reCreate) {
+ if (reCreate == undefined) {
+ throw new Error("Missing reCreate argument");
+ }
+ this.#controller.moveSelectableItems(from, to, number, () => {
+ let moving = this.items.splice(from, number);
+ for (let [i, item] of moving.entries()) {
+ item.element.remove();
+ if (reCreate) {
+ let text = item.element.textContent;
+ item = { element: this.#createItemElement(text) };
+ }
+ this.insertBefore(item.element, this.items[to + i]?.element ?? null);
+ this.items.splice(to + i, 0, item);
+ }
+ });
+ }
+
+ /**
+ * Selects a single item via the SelectionWidgetController.selectSingleItem
+ * method.
+ *
+ * @param {number} index - The index of the item to select.
+ */
+ selectSingleItem(index) {
+ this.#controller.selectSingleItem(index);
+ }
+
+ /**
+ * Changes the selection state of an item via the
+ * SelectionWidgetController.setItemSelected method.
+ *
+ * @param {number} index - The index of the item to set the selection state
+ * of.
+ * @param {boolean} select - Whether to select the item.
+ */
+ setItemSelected(index, select) {
+ this.#controller.setItemSelected(index, select);
+ }
+
+ /**
+ * Get the list of selected item's indices.
+ *
+ * @returns {number[]} - The indices for selected items.
+ */
+ selectedIndices() {
+ let indices = [];
+ for (let i = 0; i < this.items.length; i++) {
+ // Assert that the item has a defined selection state set in
+ // setItemSelectionState.
+ if (typeof this.items[i].selected != "boolean") {
+ throw new Error(`Item ${i} has an undefined selection state`);
+ }
+ // Assert that our stored selection state matches that returned by the
+ // controller API.
+ let itemIsSelected = this.#controller.itemIsSelected(i);
+ if (this.items[i].selected != itemIsSelected) {
+ throw new Error(
+ `itemIsSelected(${i}): "${itemIsSelected}" does not match stored selection state "${this.items[i].selected}"`
+ );
+ }
+ if (itemIsSelected) {
+ indices.push(i);
+ }
+ }
+ return indices;
+ }
+
+ /**
+ * Get the return of SelectionWidgetController.getSelectionRanges
+ */
+ getSelectionRanges() {
+ return this.#controller.getSelectionRanges();
+ }
+}
+
+customElements.define("test-selection-widget", TestSelectionWidget);
diff --git a/comm/mail/base/test/browser/files/selectionWidget.xhtml b/comm/mail/base/test/browser/files/selectionWidget.xhtml
new file mode 100644
index 0000000000..e5f66fc30c
--- /dev/null
+++ b/comm/mail/base/test/browser/files/selectionWidget.xhtml
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for SelectionWidgetController</title>
+ <style>
+ test-selection-widget {
+ display: flex;
+ align-items: start;
+ border: 1px solid black;
+ width: 600px;
+ height: 600px;
+ overflow: auto;
+ }
+
+ test-selection-widget[layout-direction="vertical"] {
+ flex-direction: column;
+ }
+
+ /* Fit 20 items in the view. */
+ test-selection-widget[layout-direction="vertical"] > * {
+ height: 30px;
+ }
+ test-selection-widget[layout-direction="horizontal"] > * {
+ width: 30px;
+ writing-mode: vertical-rl;
+ }
+
+ test-selection-widget > * {
+ padding-inline: 10px;
+ box-sizing: border-box;
+ border: 1px solid grey;
+ white-space: nowrap;
+ flex: 0 0 auto;
+ }
+
+ .selected {
+ background: pink;
+ }
+
+ :focus {
+ outline: 3px dashed black;
+ outline-offset: -3px;
+ }
+
+ :focus-visible {
+ outline-color: blue;
+ }
+ </style>
+ <!-- Load the SelectionWidgetController class inline if testing in a browser.
+ <script src="../../../../modules/SelectionWidgetController.jsm"></script>
+ -->
+ <script defer="defer" src="selectionWidget.js"></script>
+</head>
+<body>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tb-logo.png b/comm/mail/base/test/browser/files/tb-logo.png
new file mode 100644
index 0000000000..aac56e2546
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tb-logo.png
Binary files differ
diff --git a/comm/mail/base/test/browser/files/tree-element-test-common.js b/comm/mail/base/test/browser/files/tree-element-test-common.js
new file mode 100644
index 0000000000..6f22962aca
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-common.js
@@ -0,0 +1,73 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class AlternativeCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 80;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.cell.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ }
+ }
+ customElements.define("alternative-row", AlternativeCardRow, {
+ extends: "tr",
+ });
+
+ class TestView {
+ values = [];
+
+ constructor(start, count) {
+ for (let i = start; i < start + count; i++) {
+ this.values.push(i);
+ }
+ }
+
+ get rowCount() {
+ return this.values.length;
+ }
+
+ getCellText(index, column) {
+ return `${column.id} ${this.values[index]}`;
+ }
+
+ isContainer() {
+ return false;
+ }
+
+ isContainerOpen() {
+ return false;
+ }
+
+ selectionChanged() {}
+
+ setTree() {}
+ }
+
+ const tree = document.getElementById("testTree");
+ tree.table.setBodyID("testBody");
+ tree.addEventListener("select", () => {
+ console.log("select event, selected indices:", tree.selectedIndices);
+ });
+ tree.view = new TestView(0, 150);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-header.js b/comm/mail/base/test/browser/files/tree-element-test-header.js
new file mode 100644
index 0000000000..37d3b583e4
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-header.js
@@ -0,0 +1,64 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 50;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ // Ensure that a table header is rendered in order to verify that the
+ // header's presence doesn't cause issues with scroll calculations.
+ l10n: {
+ header: "threadpane-column-header-subject",
+ menuitem: "threadpane-column-label-subject",
+ },
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.d1 = container.appendChild(document.createElement("div"));
+ this.d1.classList.add("d1");
+
+ this.d2 = this.d1.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+
+ this.d3 = this.d1.appendChild(document.createElement("div"));
+ this.d3.classList.add("d3");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.d2.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ this.d3.textContent = this.view.getCellText(index, {
+ id: "PrimaryEmail",
+ });
+ this.dataset.value = this.view.values[index];
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ const tree = document.getElementById("testTree");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-header.xhtml b/comm/mail/base/test/browser/files/tree-element-test-header.xhtml
new file mode 100644
index 0000000000..522e3e5c60
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-header.xhtml
@@ -0,0 +1,61 @@
+<?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/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <!-- Localization is necessary for the table header to display text. -->
+ <link rel="localization" href="messenger/about3Pane.ftl" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ /* We want a total visible row area of 630px, but we need to account for the
+ * height of the header as well. */
+ #testTree {
+ height: calc(var(--tree-header-table-height) + 630px);
+ }
+
+ .tree-view-scrollable-container {
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ }
+
+ tr[is="test-row"] td div.d1 {
+ flex: 1;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d2 {
+ line-height: 1.2;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d3 {
+ line-height: 1.2;
+ font-size: 13px;
+ }
+ </style>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="tree-element-test-header.js"></script>
+ <script src="tree-element-test-common.js"></script>
+</head>
+<!-- We force layout-table in order to ensure that table header rows are
+ displayed.-->
+<body class="layout-table">
+ <input id="before" placeholder="something to focus on" />
+ <tree-view id="testTree" data-select-delay="250"/>
+ <input id="after" placeholder="something to focus on" />
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tree-element-test-levels.js b/comm/mail/base/test/browser/files/tree-element-test-levels.js
new file mode 100644
index 0000000000..7ea7eb8232
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-levels.js
@@ -0,0 +1,118 @@
+/* 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 PROTO_TREE_VIEW */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 30;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.threader = container.appendChild(document.createElement("button"));
+ this.threader.textContent = "↳";
+ this.threader.classList.add("tree-button-thread");
+
+ this.twisty = container.appendChild(document.createElement("div"));
+ this.twisty.textContent = "v";
+ this.twisty.classList.add("twisty");
+
+ this.d2 = container.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.id = this.view.getRowProperties(index);
+ this.classList.remove("level0", "level1", "level2");
+ this.classList.add(`level${this.view.getLevel(index)}`);
+ this.d2.textContent = this.view.getCellText(index, { id: "text" });
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ class TreeItem {
+ _children = [];
+
+ constructor(id, text, open = false, level = 0) {
+ this._id = id;
+ this._text = text;
+ this._open = open;
+ this._level = level;
+ }
+
+ getText() {
+ return this._text;
+ }
+
+ get open() {
+ return this._open;
+ }
+
+ get level() {
+ return this._level;
+ }
+
+ get children() {
+ return this._children;
+ }
+
+ getProperties() {
+ return this._id;
+ }
+
+ addChild(treeItem) {
+ treeItem._parent = this;
+ treeItem._level = this._level + 1;
+ this.children.push(treeItem);
+ }
+ }
+
+ let testView = new PROTO_TREE_VIEW();
+ testView._rowMap.push(new TreeItem("row-1", "Item with no children"));
+ testView._rowMap.push(new TreeItem("row-2", "Item with children"));
+ testView._rowMap.push(new TreeItem("row-3", "Item with grandchildren"));
+ testView._rowMap[1].addChild(new TreeItem("row-2-1", "First child"));
+ testView._rowMap[1].addChild(new TreeItem("row-2-2", "Second child"));
+ testView._rowMap[2].addChild(new TreeItem("row-3-1", "First child"));
+ testView._rowMap[2].children[0].addChild(
+ new TreeItem("row-3-1-1", "First grandchild")
+ );
+ testView._rowMap[2].children[0].addChild(
+ new TreeItem("row-3-1-2", "Second grandchild")
+ );
+ testView.toggleOpenState(1);
+ testView.toggleOpenState(4);
+ testView.toggleOpenState(5);
+
+ let tree = document.getElementById("testTree");
+ tree.table.setBodyID("testBody");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+ tree.addEventListener("select", () => {
+ console.log("select event, selected indices:", tree.selectedIndices);
+ });
+ tree.view = testView;
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml b/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml
new file mode 100644
index 0000000000..1175887e74
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ .tree-view-scrollable-container {
+ height: 630px;
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ }
+
+ button.threader {
+ width: 1em;
+ height: 1em;
+ }
+
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ }
+
+ tr[is="test-row"].children button.threader {
+ display: inline-block;
+ }
+
+ tr[is="test-row"] button.threader {
+ display: hidden;
+ }
+
+ tr[is="test-row"].children div.twisty {
+ background-color: green;
+ }
+
+ tr[is="test-row"].children.collapsed div.twisty {
+ background-color: red;
+ }
+
+ tr[is="test-row"].level1 .d2 {
+ padding-inline-start: 1em;
+ }
+
+ tr[is="test-row"].level2 .d2 {
+ padding-inline-start: 2em;
+ }
+ </style>
+ <script type="module" defer="defer" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script>
+ <script defer="defer" src="tree-element-test-levels.js"></script>
+</head>
+<body>
+ <tree-view id="testTree" data-select-delay="250"/>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tree-element-test-no-header.js b/comm/mail/base/test/browser/files/tree-element-test-no-header.js
new file mode 100644
index 0000000000..8a515be5e2
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-no-header.js
@@ -0,0 +1,58 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 50;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.d1 = container.appendChild(document.createElement("div"));
+ this.d1.classList.add("d1");
+
+ this.d2 = this.d1.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+
+ this.d3 = this.d1.appendChild(document.createElement("div"));
+ this.d3.classList.add("d3");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.d2.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ this.d3.textContent = this.view.getCellText(index, {
+ id: "PrimaryEmail",
+ });
+ this.dataset.value = this.view.values[index];
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ const tree = document.getElementById("testTree");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml b/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml
new file mode 100644
index 0000000000..7605279ba6
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml
@@ -0,0 +1,54 @@
+<?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/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ /* We want a total visible row area of 630px. Intentionally avoid leaving
+ * room for a header. */
+ .tree-view-scrollable-container {
+ height: 630px;
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ }
+
+ tr[is="test-row"] td div.d1 {
+ flex: 1;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d2 {
+ line-height: 1.2;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d3 {
+ line-height: 1.2;
+ font-size: 13px;
+ }
+ </style>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="tree-element-test-no-header.js"></script>
+ <script src="tree-element-test-common.js"></script>
+</head>
+<body>
+ <input id="before" placeholder="something to focus on" />
+ <tree-view id="testTree" data-select-delay="250"/>
+ <input id="after" placeholder="something to focus on" />
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/treeListbox.xhtml b/comm/mail/base/test/browser/files/treeListbox.xhtml
new file mode 100644
index 0000000000..a760ca141d
--- /dev/null
+++ b/comm/mail/base/test/browser/files/treeListbox.xhtml
@@ -0,0 +1,390 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-listbox custom element</title>
+ <style>
+ :focus {
+ outline: 3px blue solid;
+ }
+ html {
+ height: 100%;
+ }
+ body {
+ height: 100%;
+ display: flex;
+ margin: 0;
+ }
+ ul[is="tree-listbox"] {
+ overflow-y: auto;
+ white-space: nowrap;
+ }
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+ li > div {
+ display: flex;
+ align-items: center;
+ }
+ li.selected > div {
+ color: white;
+ background-color: blue;
+ }
+ li > ul {
+ padding-inline-start: 1em;
+ }
+ li.collapsed > ul {
+ display: none;
+ }
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ }
+ li.children > div > div.twisty {
+ background-color: green;
+ }
+ li.children.collapsed > div > div.twisty {
+ background-color: red;
+ }
+ li.unselectable > div {
+ background-color: red;
+ }
+ </style>
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+</head>
+<body>
+ <ul is="tree-listbox" role="tree">
+ <li id="row-1">
+ <div>
+ <div class="twisty"></div>
+ Item with no children
+ </div>
+ </li>
+ <li id="row-2">
+ <div>
+ <div class="twisty"></div>
+ Item with children
+ </div>
+ <ul>
+ <li id="row-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-3">
+ <div>
+ <div class="twisty"></div>
+ Item with grandchildren
+ </div>
+ <ul>
+ <li id="row-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="row-3-1-1">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ </li>
+ <li id="row-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <template id="rowToAdd">
+ <li id="new-row">
+ <div>
+ <div class="twisty"></div>
+ New row
+ </div>
+ </li>
+ </template>
+ <template id="rowsToAdd">
+ <li id="added-row">
+ <div>
+ <div class="twisty"></div>
+ Added row
+ </div>
+ <ul>
+ <li id="added-row-1">
+ <div>
+ <div class="twisty"></div>
+ Added child
+ </div>
+ <ul>
+ <li id="added-row-1-1">
+ <div>
+ <div class="twisty"></div>
+ Added grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="added-row-2">
+ <div>
+ <div class="twisty"></div>
+ Added child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </template>
+ <!-- Larger tree for deleting from -->
+ <ul>
+ <li>Before</li>
+ <li>
+ <!-- Place under a plain <li> an <ul> to make sure our selector logic
+ - doesn't break down. -->
+ <ul is="tree-listbox" id="deleteTree" role="tree">
+ <li id="dRow-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Item with collapsed children
+ </div>
+ <ul>
+ <li id="dRow-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-2">
+ <div>
+ <div class="twisty"></div>
+ Item with children
+ </div>
+ <ul>
+ <li id="dRow-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="dRow-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-3">
+ <div>
+ <div class="twisty"></div>
+ Item with grandchildren
+ </div>
+ <ul>
+ <li id="dRow-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="dRow-3-1-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ <ul>
+ <li id="dRow-3-1-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ <li id="dRow-3-1-3" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Third grandchild
+ </div>
+ <ul>
+ <li id="dRow-3-1-3-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4">
+ <div>
+ <div class="twisty"></div>
+ Fourth item
+ </div>
+ <ul>
+ <li id="dRow-4-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="dRow-4-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 1
+ </div>
+ </li>
+ <li id="dRow-4-1-2">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 2
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ <li id="dRow-4-3">
+ <div>
+ <div class="twisty"></div>
+ Third child
+ </div>
+ <ul>
+ <li id="dRow-4-3-1">
+ <div>
+ <div class="twisty"></div>
+ First Grand child
+ </div>
+ </li>
+ <li id="dRow-4-3-2">
+ <div>
+ <div class="twisty"></div>
+ Second Grand child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4-4" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Fourth child
+ </div>
+ <ul>
+ <li id="dRow-4-4-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 1
+ </div>
+ </li>
+ <li id="dRow-4-4-2">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 2
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-5">
+ <div>
+ <div class="twisty"></div>
+ Second last item
+ </div>
+ <ul>
+ <li id="dRow-5-1">
+ <div>
+ <div class="twisty"></div>
+ Last child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-6">
+ <div>
+ <div class="twisty"></div>
+ Last item
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li>After</li>
+ </ul>
+ <!-- Tree with unselectable rows -->
+ <ul is="tree-listbox" id="unselectableTree" role="tree">
+ <li id="uRow-1" class="unselectable">
+ <div>Item with no children</div>
+ </li>
+ <li id="uRow-2" class="unselectable">
+ <div>Item with children</div>
+ <ul>
+ <li id="uRow-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="uRow-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="uRow-3" class="unselectable">
+ <div>Item with grandchildren</div>
+ <ul>
+ <li id="uRow-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="uRow-3-1-1">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ </li>
+ <li id="uRow-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/head.js b/comm/mail/base/test/browser/head.js
new file mode 100644
index 0000000000..4ee9845d89
--- /dev/null
+++ b/comm/mail/base/test/browser/head.js
@@ -0,0 +1,371 @@
+/* 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"
+);
+
+async function focusWindow(win) {
+ win.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow?.browsingContext.topChromeWindow == win,
+ "waiting for window to be focused"
+ );
+}
+
+async function openExtensionPopup(win, buttonId) {
+ await focusWindow(win.top);
+
+ let actionButton = await TestUtils.waitForCondition(
+ () =>
+ win.document.querySelector(
+ `#${buttonId}, [item-id="${buttonId}"] button`
+ ),
+ "waiting for the action button to exist"
+ );
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(actionButton),
+ "waiting for action button to be visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(actionButton, {}, win);
+
+ let panel = win.top.document.getElementById(
+ "webextension-remote-preload-panel"
+ );
+ let browser = panel.querySelector("browser");
+ await TestUtils.waitForCondition(
+ () => browser.clientWidth > 100,
+ "waiting for browser to resize"
+ );
+
+ return { actionButton, panel, browser };
+}
+
+function getSmartServer() {
+ return MailServices.accounts.findServer("nobody", "smart mailboxes", "none");
+}
+
+function resetSmartMailboxes() {
+ let oldServer = getSmartServer();
+ // Clean up any leftover server from an earlier test.
+ if (oldServer) {
+ let oldAccount = MailServices.accounts.FindAccountForServer(oldServer);
+ MailServices.accounts.removeAccount(oldAccount, false);
+ }
+}
+
+class MenuTestHelper {
+ /** @type {XULMenuElement} */
+ menu;
+
+ /**
+ * An object describing the state of a <menu> or <menuitem>.
+ *
+ * @typedef {Object} MenuItemData
+ * @property {boolean|string[]} [hidden] - true if the item should be hidden
+ * in all modes, or a list of modes in which it should be hidden.
+ * @property {boolean|string[]} [disabled] - true if the item should be
+ * disabled in all modes, or a list of modes in which it should be
+ * disabled. If the item should be hidden this property is ignored.
+ * @property {boolean|string[]} [checked] - true if the item should be
+ * checked in all modes, or a list of modes in which it should be
+ * checked. If the item should be hidden this property is ignored.
+ * @property {string} [l10nID] - the ID of the Fluent string this item
+ * should be displaying. If specified, `l10nArgs` will be checked.
+ * @property {object} [l10nArgs] - the arguments for the Fluent string this
+ * item should be displaying. If not specified, the string should not have
+ * arguments.
+ */
+ /**
+ * An object describing the possible states of a menu's items. Object keys
+ * are the item's ID, values describe the item's state.
+ *
+ * @typedef {Object.<string, MenuItemData>} MenuData
+ */
+
+ /** @type {MenuData} */
+ baseData;
+
+ constructor(menuID, baseData) {
+ this.menu = document.getElementById(menuID);
+ this.baseData = baseData;
+ }
+
+ /**
+ * Clicks on the menu and waits for it to open.
+ */
+ async openMenu() {
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ this.menu.menupopup,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(this.menu, {});
+ await shownPromise;
+ }
+
+ /**
+ * Check that an item matches the expected state.
+ *
+ * @param {XULElement} actual - A <menu> or <menuitem>.
+ * @param {MenuItemData} expected
+ */
+ checkItem(actual, expected) {
+ Assert.equal(
+ BrowserTestUtils.is_hidden(actual),
+ !!expected.hidden,
+ `${actual.id} hidden`
+ );
+ if (!expected.hidden) {
+ Assert.equal(
+ actual.disabled,
+ !!expected.disabled,
+ `${actual.id} disabled`
+ );
+ }
+ if (expected.checked) {
+ Assert.equal(
+ actual.getAttribute("checked"),
+ "true",
+ `${actual.id} checked`
+ );
+ } else if (["checkbox", "radio"].includes(actual.getAttribute("type"))) {
+ Assert.ok(
+ !actual.hasAttribute("checked") ||
+ actual.getAttribute("checked") == "false",
+ `${actual.id} not checked`
+ );
+ }
+ if (expected.l10nID) {
+ let attributes = actual.ownerDocument.l10n.getAttributes(actual);
+ Assert.equal(attributes.id, expected.l10nID, `${actual.id} L10n string`);
+ Assert.deepEqual(
+ attributes.args,
+ expected.l10nArgs ?? null,
+ `${actual.id} L10n args`
+ );
+ }
+ }
+
+ /**
+ * Recurses through submenus performing checks on items.
+ *
+ * @param {XULPopupElement} popup - The current pop-up to check.
+ * @param {MenuData} data - The expected values to test against.
+ * @param {boolean} [itemsMustBeInData=false] - If true, all menu items and
+ * menus within `popup` must be specified in `data`. If false, items not
+ * in `data` will be ignored.
+ */
+ async iterate(popup, data, itemsMustBeInData = false) {
+ if (popup.state != "open") {
+ await BrowserTestUtils.waitForEvent(popup, "popupshown");
+ }
+
+ for (let item of popup.children) {
+ if (!item.id || item.localName == "menuseparator") {
+ continue;
+ }
+
+ if (!(item.id in data)) {
+ if (itemsMustBeInData) {
+ Assert.report(true, undefined, undefined, `${item.id} in data`);
+ }
+ continue;
+ }
+ let itemData = data[item.id];
+ this.checkItem(item, itemData);
+ delete data[item.id];
+
+ if (item.localName == "menu") {
+ if (BrowserTestUtils.is_visible(item) && !item.disabled) {
+ item.openMenu(true);
+ await this.iterate(item.menupopup, data, itemsMustBeInData);
+ } else {
+ for (let hiddenItem of item.querySelectorAll("menu, menuitem")) {
+ delete data[hiddenItem.id];
+ }
+ }
+ }
+ }
+
+ popup.hidePopup();
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ /**
+ * Checks every item in the menu and submenus against the expected states.
+ *
+ * @param {string} mode - The current mode, used to select the right expected
+ * values from `baseData`.
+ */
+ async testAllItems(mode) {
+ // Get the data for just this mode.
+ let data = {};
+ for (let [id, itemData] of Object.entries(this.baseData)) {
+ data[id] = {
+ ...itemData,
+ hidden: itemData.hidden === true || itemData.hidden?.includes(mode),
+ disabled:
+ itemData.disabled === true || itemData.disabled?.includes(mode),
+ checked: itemData.checked === true || itemData.checked?.includes(mode),
+ };
+ }
+
+ // Open the menu and all submenus and check the items.
+ await this.openMenu();
+ await this.iterate(this.menu.menupopup, data, true);
+
+ // Report any unexpected items.
+ for (let id of Object.keys(data)) {
+ Assert.report(true, undefined, undefined, `extra item ${id} in data`);
+ }
+ }
+
+ /**
+ * Checks specific items in the menu.
+ *
+ * @param {MenuData} data - The expected values to test against.
+ */
+ async testItems(data) {
+ await this.openMenu();
+ await this.iterate(this.menu.menupopup, data);
+
+ for (let id of Object.keys(data)) {
+ Assert.report(true, undefined, undefined, `extra item ${id} in data`);
+ }
+
+ if (this.menu.menupopup.state != "closed") {
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ this.menu.menupopup,
+ "popuphidden"
+ );
+ this.menu.menupopup.hidePopup();
+ await hiddenPromise;
+ }
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ /**
+ * Activates the item in the menu.
+ *
+ * @note This currently only works on top-level items.
+ * @param {string} menuItemID - The item to activate.
+ * @param {MenuData} [data] - If given, the expected state of the menu item
+ * before activation.
+ */
+ async activateItem(menuItemID, data) {
+ await this.openMenu();
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ this.menu.menupopup,
+ "popuphidden"
+ );
+ let item = document.getElementById(menuItemID);
+ if (data) {
+ this.checkItem(item, data);
+ }
+ this.menu.menupopup.activateItem(item);
+ await hiddenPromise;
+ await new Promise(resolve => setTimeout(resolve));
+ }
+}
+
+/**
+ * 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"
+ );
+}
+
+// Report and remove any remaining accounts/servers. If we register a cleanup
+// function here, it will run before any other cleanup function has had a
+// chance to run. Instead, when it runs register another cleanup function
+// which will run last.
+registerCleanupFunction(function () {
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("mail.pane_config.dynamic");
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.tabInfo.length > 1) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "Unexpected tab(s) open at the end of the test run"
+ );
+ tabmail.closeOtherTabs(0);
+ }
+
+ for (let server of MailServices.accounts.allServers) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found ${server} at the end of the test run`
+ );
+ MailServices.accounts.removeIncomingServer(server, false);
+ }
+ for (let account of MailServices.accounts.accounts) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found ${account} at the end of the test run`
+ );
+ MailServices.accounts.removeAccount(account, false);
+ }
+
+ resetSmartMailboxes();
+ ensure_cards_view();
+
+ // 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();
+ });
+});
diff --git a/comm/mail/base/test/browser/head_spacesToolbar.js b/comm/mail/base/test/browser/head_spacesToolbar.js
new file mode 100644
index 0000000000..f08f3deee5
--- /dev/null
+++ b/comm/mail/base/test/browser/head_spacesToolbar.js
@@ -0,0 +1,34 @@
+/* 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 sub_test_toolbar_alignment(drawInTitlebar, hideMenu) {
+ let menubar = document.getElementById("toolbar-menubar");
+ let tabsInTitlebar =
+ document.documentElement.getAttribute("tabsintitlebar") == "true";
+ Assert.equal(tabsInTitlebar, drawInTitlebar);
+
+ if (hideMenu) {
+ menubar.setAttribute("autohide", true);
+ menubar.setAttribute("inactive", true);
+ } else {
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ }
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ let size = document
+ .getElementById("spacesToolbar")
+ .getBoundingClientRect().width;
+
+ Assert.equal(
+ document.getElementById("titlebar").getBoundingClientRect().left,
+ size,
+ "The correct style was applied to the #titlebar"
+ );
+ Assert.equal(
+ document.getElementById("toolbar-menubar").getBoundingClientRect().left,
+ size,
+ "The correct style was applied to the #toolbar-menubar"
+ );
+}
diff --git a/comm/mail/base/test/moz.build b/comm/mail/base/test/moz.build
new file mode 100644
index 0000000000..4299802bcd
--- /dev/null
+++ b/comm/mail/base/test/moz.build
@@ -0,0 +1,22 @@
+# vim: set filetype=python:
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser/browser-detachedWindows.ini",
+ "browser/browser-drawBelowTitlebar.ini",
+ "browser/browser-drawInTitlebar.ini",
+ "browser/browser.ini",
+ "performance/browser.ini",
+ "webextensions/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "unit/xpcshell.ini",
+ "unit/xpcshell_maildir.ini",
+]
+
+TESTING_JS_MODULES += [
+ "../../../../browser/components/customizableui/test/CustomizableUITestUtils.sys.mjs",
+]
diff --git a/comm/mail/base/test/performance/browser.ini b/comm/mail/base/test/performance/browser.ini
new file mode 100644
index 0000000000..4682b3f482
--- /dev/null
+++ b/comm/mail/base/test/performance/browser.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+# To avoid overhead when running the browser normally, StartupRecorder.jsm will
+# do almost nothing unless browser.startup.record is true.
+# gfx.canvas.willReadFrequently.enable is just an optimization, but needs to be
+# set during early startup to have an impact as a canvas will be used by
+# StartupRecorder.jsm
+ browser.startup.record=true
+ gfx.canvas.willReadFrequently.enable=true
+ mail.ab_remote_content.migrated=true
+ # Skip migration work in MailMigrator for browser_startup.js since it isn't
+ # representative of common startup.
+ mail.ui-rdf.version=9999999
+subsuite = thunderbird
+
+[browser_preferences_usage.js]
+skip-if = !debug
+[browser_startup.js]
diff --git a/comm/mail/base/test/performance/browser_preferences_usage.js b/comm/mail/base/test/performance/browser_preferences_usage.js
new file mode 100644
index 0000000000..e770b12b46
--- /dev/null
+++ b/comm/mail/base/test/performance/browser_preferences_usage.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+if (SpecialPowers.useRemoteSubframes) {
+ requestLongerTimeout(2);
+}
+
+const DEFAULT_PROCESS_COUNT = Services.prefs
+ .getDefaultBranch(null)
+ .getIntPref("dom.ipc.processCount");
+
+/**
+ * A test that checks whether any preference getter from the given list
+ * of stats was called more often than the max parameter.
+ *
+ * @param {Array} stats - an array of [prefName, accessCount] tuples
+ * @param {number} max - the maximum number of times any of the prefs should
+ * have been called.
+ * @param {object} knownProblematicPrefs (optional) - an object that defines
+ * prefs that should be exempt from checking the
+ * maximum access. It looks like the following:
+ *
+ * pref_name: {
+ * min: [Number] the minimum amount of times this should have
+ * been called (to avoid keeping around dead items)
+ * max: [Number] the maximum amount of times this should have
+ * been called (to avoid this creeping up further)
+ * }
+ */
+function checkPrefGetters(stats, max, knownProblematicPrefs = {}) {
+ let getterStats = Object.entries(stats).sort(
+ ([, val1], [, val2]) => val2 - val1
+ );
+
+ // Clone the list to be able to delete entries to check if we
+ // forgot any later on.
+ knownProblematicPrefs = Object.assign({}, knownProblematicPrefs);
+
+ for (let [pref, count] of getterStats) {
+ let prefLimits = knownProblematicPrefs[pref];
+ if (!prefLimits) {
+ Assert.lessOrEqual(
+ count,
+ max,
+ `${pref} should not be accessed more than ${max} times.`
+ );
+ } else {
+ // Still record how much this pref was accessed even if we don't do any real assertions.
+ if (!prefLimits.min && !prefLimits.max) {
+ info(
+ `${pref} should not be accessed more than ${max} times and was accessed ${count} times.`
+ );
+ }
+
+ if (prefLimits.min) {
+ Assert.lessOrEqual(
+ prefLimits.min,
+ count,
+ `${pref} should be accessed at least ${prefLimits.min} times.`
+ );
+ }
+ if (prefLimits.max) {
+ Assert.lessOrEqual(
+ count,
+ prefLimits.max,
+ `${pref} should be accessed at most ${prefLimits.max} times.`
+ );
+ }
+ delete knownProblematicPrefs[pref];
+ }
+ }
+
+ // This pref will be accessed by mozJSComponentLoader when loading modules,
+ // which fails TV runs since they run the test multiple times without restarting.
+ // We just ignore this pref, since it's for testing only anyway.
+ if (knownProblematicPrefs["browser.startup.record"]) {
+ delete knownProblematicPrefs["browser.startup.record"];
+ }
+
+ let unusedPrefs = Object.keys(knownProblematicPrefs);
+ is(
+ unusedPrefs.length,
+ 0,
+ `Should have accessed all known problematic prefs. Remaining: ${unusedPrefs}`
+ );
+}
+
+/**
+ * A helper function to read preference access data
+ * using the Services.prefs.readStats() function.
+ */
+function getPreferenceStats() {
+ let stats = {};
+ Services.prefs.readStats((key, value) => (stats[key] = value));
+ return stats;
+}
+
+add_task(async function debug_only() {
+ ok(AppConstants.DEBUG, "You need to run this test on a debug build.");
+});
+
+// Just checks how many prefs were accessed during startup.
+add_task(async function startup() {
+ let max = 40;
+
+ let knownProblematicPrefs = {
+ // These are all similar values to Firefox, check with the equivalent
+ // file in Firefox.
+ "browser.startup.record": {
+ // This pref is accessed in Nighly and debug builds only.
+ min: 200,
+ max: 400,
+ },
+ "network.loadinfo.skip_type_assertion": {
+ // This is accessed in debug only.
+ },
+ // Bug 944367: All gloda logs are controlled by one pref.
+ "gloda.loglevel": {
+ min: 10,
+ max: 70,
+ },
+ };
+
+ // These preferences are used in PresContext or layout areas and all have a
+ // similar number of errors - probably being loaded in the same component.
+ let prefsUsedInLayout = [
+ "browser.display.auto_quality_min_font_size",
+ "dom.send_after_paint_to_content",
+ "image.animation_mode",
+ "layout.reflow.dumpframebyframecounts",
+ "layout.reflow.dumpframecounts",
+ "layout.reflow.showframecounts",
+ "layout.scrollbar.side",
+ ];
+
+ for (let pref of prefsUsedInLayout) {
+ knownProblematicPrefs[pref] = {
+ min: 60,
+ max: 175,
+ };
+ }
+
+ if (AppConstants.platform == "macosx") {
+ for (let pref of [
+ "font.default.x-western",
+ "font.minimum-size.x-western",
+ "font.name.variable.x-western",
+ "font.size-adjust.cursive.x-western",
+ "font.size-adjust.fantasy.x-western",
+ "font.size-adjust.monospace.x-western",
+ "font.size-adjust.sans-serif.x-western",
+ "font.size-adjust.serif.x-western",
+ "font.size-adjust.system-ui.x-western",
+ "font.size-adjust.variable.x-western",
+ "font.size.cursive.x-western",
+ "font.size.fantasy.x-western",
+ "font.size.monospace.x-western",
+ "font.size.sans-serif.x-western",
+ "font.size.serif.x-western",
+ "font.size.system-ui.x-western",
+ "font.size.variable.x-western",
+ ]) {
+ knownProblematicPrefs[pref] = {
+ min: 0,
+ max: 45,
+ };
+ }
+ }
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ ok(startupRecorder.data.prefStats, "startupRecorder has prefStats");
+
+ checkPrefGetters(startupRecorder.data.prefStats, max, knownProblematicPrefs);
+});
diff --git a/comm/mail/base/test/performance/browser_startup.js b/comm/mail/base/test/performance/browser_startup.js
new file mode 100644
index 0000000000..f0c6009543
--- /dev/null
+++ b/comm/mail/base/test/performance/browser_startup.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test records at which phase of startup the JS modules are first
+ * loaded.
+ * If you made changes that cause this test to fail, it's likely because you
+ * are loading more JS code during startup.
+ * Most code has no reason to run off of the app-startup notification
+ * (this is very early, before we have selected the user profile, so
+ * preferences aren't accessible yet).
+ * If your code isn't strictly required to show the first browser window,
+ * it shouldn't be loaded before we are done with first paint.
+ * Finally, if your code isn't really needed during startup, it should not be
+ * loaded before we have started handling user events.
+ */
+
+"use strict";
+
+/* Set this to true only for debugging purpose; it makes the output noisy. */
+const kDumpAllStacks = false;
+
+const startupPhases = {
+ // For app-startup, we have an allowlist of acceptable JS files.
+ // Anything loaded during app-startup must have a compelling reason
+ // to run before we have even selected the user profile.
+ // Consider loading your code after first paint instead,
+ // eg. from MailGlue.jsm' _onFirstWindowLoaded method).
+ "before profile selection": {
+ allowlist: {
+ modules: new Set([
+ "resource:///modules/MailGlue.jsm",
+ "resource:///modules/StartupRecorder.jsm",
+ "resource://gre/modules/ActorManagerParent.sys.mjs",
+ "resource://gre/modules/AppConstants.sys.mjs",
+ "resource://gre/modules/CustomElementsListener.sys.mjs",
+ "resource://gre/modules/MainProcessSingleton.sys.mjs",
+ "resource://gre/modules/XPCOMUtils.sys.mjs",
+ ]),
+ },
+ },
+
+ // For the following phases of startup we have only a list of files that
+ // are **not** allowed to load in this phase, as too many other scripts
+ // load during this time.
+
+ // We are at this phase after creating the first browser window (ie. after final-ui-startup).
+ "before opening first browser window": {
+ denylist: {
+ modules: new Set([
+ "chrome://openpgp/content/modules/constants.jsm",
+ "resource:///modules/IMServices.sys.mjs",
+ "resource:///modules/imXPCOMUtils.sys.mjs",
+ "resource:///modules/jsProtoHelper.sys.mjs",
+ "resource:///modules/logger.sys.mjs",
+ "resource:///modules/MailNotificationManager.jsm",
+ "resource:///modules/MailNotificationService.jsm",
+ "resource:///modules/MsgIncomingServer.jsm",
+ ]),
+ services: new Set([
+ "@mozilla.org/chat/logger;1",
+ "@mozilla.org/mail/notification-manager;1",
+ "@mozilla.org/newMailNotificationService;1",
+ ]),
+ },
+ },
+
+ // We reach this phase right after showing the first browser window.
+ // This means that anything already loaded at this point has been loaded
+ // before first paint and delayed it.
+ "before first paint": {
+ denylist: {
+ modules: new Set([
+ "chrome://openpgp/content/BondOpenPGP.jsm",
+ "chrome://openpgp/content/modules/core.jsm",
+ "resource:///modules/index_im.sys.mjs",
+ "resource:///modules/MsgDBCacheManager.jsm",
+ "resource:///modules/PeriodicFilterManager.jsm",
+ "resource://gre/modules/Blocklist.sys.mjs",
+ "resource://gre/modules/NewTabUtils.sys.mjs",
+ "resource://gre/modules/Sqlite.sys.mjs",
+ // Bug 1660907: These core modules shouldn't really be being loaded
+ // until sometime after first paint.
+ // "resource://gre/modules/PlacesUtils.sys.mjs",
+ // "resource://gre/modules/Preferences.jsm",
+ // These can probably be pushed back even further.
+ ]),
+ services: new Set([
+ "@mozilla.org/browser/search-service;1",
+ "@mozilla.org/msgDatabase/msgDBService;1",
+ ]),
+ },
+ },
+
+ // We are at this phase once we are ready to handle user events.
+ // Anything loaded at this phase or before gets in the way of the user
+ // interacting with the first mail window.
+ "before handling user events": {
+ denylist: {
+ modules: new Set([
+ "resource:///modules/gloda/Everybody.jsm",
+ "resource:///modules/gloda/Gloda.jsm",
+ "resource:///modules/gloda/GlodaContent.jsm",
+ "resource:///modules/gloda/GlodaDatabind.jsm",
+ "resource:///modules/gloda/GlodaDataModel.jsm",
+ "resource:///modules/gloda/GlodaDatastore.jsm",
+ "resource:///modules/gloda/GlodaExplicitAttr.jsm",
+ "resource:///modules/gloda/GlodaFundAttr.jsm",
+ "resource:///modules/gloda/GlodaMsgIndexer.jsm",
+ "resource:///modules/gloda/GlodaPublic.jsm",
+ "resource:///modules/gloda/GlodaQueryClassFactory.jsm",
+ "resource:///modules/gloda/GlodaUtils.jsm",
+ "resource:///modules/gloda/IndexMsg.jsm",
+ "resource:///modules/gloda/MimeMessage.jsm",
+ "resource:///modules/gloda/NounFreetag.jsm",
+ "resource:///modules/gloda/NounMimetype.jsm",
+ "resource:///modules/gloda/NounTag.jsm",
+ "resource:///modules/index_im.sys.mjs",
+ "resource:///modules/jsmime.jsm",
+ "resource:///modules/MimeJSComponents.jsm",
+ "resource:///modules/mimeParser.jsm",
+ "resource://gre/modules/BookmarkHTMLUtils.sys.mjs",
+ "resource://gre/modules/Bookmarks.sys.mjs",
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ "resource://gre/modules/CrashSubmit.sys.mjs",
+ "resource://gre/modules/FxAccounts.sys.mjs",
+ "resource://gre/modules/FxAccountsStorage.sys.mjs",
+ "resource://gre/modules/PlacesBackups.sys.mjs",
+ "resource://gre/modules/PlacesSyncUtils.sys.mjs",
+ "resource://gre/modules/PushComponents.jsm",
+ ]),
+ services: new Set([
+ "@mozilla.org/browser/annotation-service;1",
+ "@mozilla.org/browser/nav-bookmarks-service;1",
+ "@mozilla.org/messenger/filter-plugin;1?name=bayesianfilter",
+ "@mozilla.org/messenger/fts3tokenizer;1",
+ "@mozilla.org/messenger/headerparser;1",
+ ]),
+ },
+ },
+
+ // Things that are expected to be completely out of the startup path
+ // and loaded lazily when used for the first time by the user should
+ // be listed here.
+ "before becoming idle": {
+ denylist: {
+ modules: new Set([
+ "resource:///modules/AddrBookManager.jsm",
+ "resource:///modules/DisplayNameUtils.jsm",
+ "resource:///modules/gloda/Facet.jsm",
+ "resource:///modules/gloda/GlodaMsgSearcher.jsm",
+ "resource:///modules/gloda/SuffixTree.jsm",
+ "resource:///modules/GlodaAutoComplete.jsm",
+ "resource:///modules/ImapIncomingServer.jsm",
+ "resource:///modules/ImapMessageMessageService.jsm",
+ "resource:///modules/ImapMessageService.jsm",
+ // Skipped due to the way ImapModuleLoader and registerProtocolHandler
+ // works, uncomment once ImapModuleLoader is removed and imap-js becomes
+ // the only IMAP implemention.
+ // "resource:///modules/ImapProtocolHandler.jsm",
+ "resource:///modules/ImapService.jsm",
+ "resource:///modules/NntpIncomingServer.jsm",
+ "resource:///modules/NntpMessageService.jsm",
+ "resource:///modules/NntpProtocolHandler.jsm",
+ "resource:///modules/NntpProtocolInfo.jsm",
+ "resource:///modules/NntpService.jsm",
+ "resource:///modules/Pop3IncomingServer.jsm",
+ "resource:///modules/Pop3ProtocolHandler.jsm",
+ "resource:///modules/Pop3ProtocolInfo.jsm",
+ // "resource:///modules/Pop3Service.jsm",
+ "resource:///modules/SmtpClient.jsm",
+ "resource:///modules/SMTPProtocolHandler.jsm",
+ "resource:///modules/SmtpServer.jsm",
+ "resource:///modules/SmtpService.jsm",
+ "resource:///modules/TemplateUtils.jsm",
+ "resource://gre/modules/AsyncPrefs.sys.mjs",
+ "resource://gre/modules/LoginManagerContextMenu.jsm",
+ "resource://pdf.js/PdfStreamConverter.jsm",
+ ]),
+ services: new Set(["@mozilla.org/autocomplete/search;1?name=gloda"]),
+ },
+ },
+};
+
+add_task(async function () {
+ if (
+ !AppConstants.NIGHTLY_BUILD &&
+ !AppConstants.MOZ_DEV_EDITION &&
+ !AppConstants.DEBUG
+ ) {
+ ok(
+ !("@mozilla.org/test/startuprecorder;1" in Cc),
+ "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" +
+ "non-debug build."
+ );
+ return;
+ }
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ let data = Cu.cloneInto(startupRecorder.data.code, {});
+ function getStack(scriptType, name) {
+ if (scriptType == "modules") {
+ return Cu.getModuleImportStack(name);
+ }
+ return "";
+ }
+
+ // This block only adds debug output to help find the next bugs to file,
+ // it doesn't contribute to the actual test.
+ SimpleTest.requestCompleteLog();
+ let previous;
+ for (let phase in data) {
+ for (let scriptType in data[phase]) {
+ for (let f of data[phase][scriptType].sort()) {
+ // phases are ordered, so if a script wasn't loaded yet at the immediate
+ // previous phase, it wasn't loaded during any of the previous phases
+ // either, and is new in the current phase.
+ if (!previous || !data[previous][scriptType].includes(f)) {
+ info(`${scriptType} loaded ${phase}: ${f}`);
+ if (kDumpAllStacks) {
+ info(getStack(scriptType, f));
+ }
+ }
+ }
+ }
+ previous = phase;
+ }
+
+ for (let phase in startupPhases) {
+ let loadedList = data[phase];
+ let allowlist = startupPhases[phase].allowlist || null;
+ if (allowlist) {
+ for (let scriptType in allowlist) {
+ loadedList[scriptType] = loadedList[scriptType].filter(c => {
+ if (!allowlist[scriptType].has(c)) {
+ return true;
+ }
+ allowlist[scriptType].delete(c);
+ return false;
+ });
+ is(
+ loadedList[scriptType].length,
+ 0,
+ `should have no unexpected ${scriptType} loaded ${phase}`
+ );
+ for (let script of loadedList[scriptType]) {
+ let message = `unexpected ${scriptType}: ${script}`;
+ record(false, message, undefined, getStack(scriptType, script));
+ }
+ is(
+ allowlist[scriptType].size,
+ 0,
+ `all ${scriptType} allowlist entries should have been used`
+ );
+ for (let script of allowlist[scriptType]) {
+ ok(false, `unused ${scriptType} allowlist entry: ${script}`);
+ }
+ }
+ }
+ let denylist = startupPhases[phase].denylist || null;
+ if (denylist) {
+ for (let scriptType in denylist) {
+ for (let file of denylist[scriptType]) {
+ let loaded = loadedList[scriptType].includes(file);
+ let message = `${file} is not allowed ${phase}`;
+ if (!loaded) {
+ ok(true, message);
+ } else {
+ record(false, message, undefined, getStack(scriptType, file));
+ }
+ }
+ }
+ }
+ }
+});
diff --git a/comm/mail/base/test/unit/distribution.ini b/comm/mail/base/test/unit/distribution.ini
new file mode 100644
index 0000000000..ea07aa3c5e
--- /dev/null
+++ b/comm/mail/base/test/unit/distribution.ini
@@ -0,0 +1,56 @@
+# Partner Distribution Configuration File
+# Mozilla Thunderbird with Example Dist settings
+
+# NOTE! These three are required.
+# id: short string unique to this distribution
+# about: a short descriptive (ui-visible) string for this
+# distribution
+# version: version of the extra distribution pieces (not the version
+# of Thunderbird)
+
+[Global]
+id=ExampleDist
+version=1.0
+about=Example Distribution Edition
+about.en-US=Example Distribution Edition EN-US
+
+# This section contains the global js prefs. You do should not list
+# here the localized preferences (see below)
+
+# Boolean preferences should be 'true' or 'false', w/o quotes. e.g.:
+# my.bool.preference=true
+#
+# Integer preferences should be unquoted numbers. e.g.:
+# my.int.preference=123
+#
+# String preferences should be in quotes. e.g.:
+# my.string.preference="foo"
+
+[Preferences]
+app.distributor="exampledist"
+app.test.data="nospecialcharacterssetting"
+app.distributor.channel=""
+mail.phishing.detection.enabled=false
+mail.spam.manualMark=false
+test.setting.random="success"
+
+# This section is used as a template for locale-specific properties
+# files. They work similarly to the GlobalPrefs section, except that
+# the %LOCALE% string gets substituted with the language string.
+
+[LocalizablePreferences]
+app.releaseNotesURL="http://example.org/%LOCALE%/%LOCALE%/"
+mailnews.start_page.welcome_url="http://example.com/%APP%/firstrun?locale=%LOCALE%version=%VERSION%&os=%OS%&buildid=%APPBUILDID%"
+test.setting.locale="http://my.senecacacollege.on.example/%LOCALE%"
+
+# This section is an example of an override for a particular locale.
+# The override sections do not interpolate %LOCALE% into strings.
+# Preferences set in override sections are *merged* with the
+# localizable defaults. That is, if you want a pref in
+# [LocalizablePreferences] to not be set in a particular locale,
+# you'll need to unset it explicitly ("pref.name=" on a line of its
+# own).
+
+[LocalizablePreferences-en-US]
+app.releaseNotesURL="http://example.com/relnotes/"
+mailnews.start_page.welcome_url="http://example.com/firstrun/"
diff --git a/comm/mail/base/test/unit/head_mailbase.js b/comm/mail/base/test/unit/head_mailbase.js
new file mode 100644
index 0000000000..0c275d8abb
--- /dev/null
+++ b/comm/mail/base/test/unit/head_mailbase.js
@@ -0,0 +1,20 @@
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+var CC = Components.Constructor;
+
+// Ensure the profile directory is set up
+do_get_profile();
+
+var gDEPTH = "../../../../";
+
+registerCleanupFunction(function () {
+ load(gDEPTH + "mailnews/resources/mailShutdown.js");
+});
diff --git a/comm/mail/base/test/unit/head_mailbase_maildir.js b/comm/mail/base/test/unit/head_mailbase_maildir.js
new file mode 100644
index 0000000000..921a2bd474
--- /dev/null
+++ b/comm/mail/base/test/unit/head_mailbase_maildir.js
@@ -0,0 +1,9 @@
+/* import-globals-from head_mailbase.js */
+
+// alternate head to set maildir as default
+load("head_mailbase.js");
+info("Running test with maildir");
+Services.prefs.setCharPref(
+ "mail.serverDefaultStoreContractID",
+ "@mozilla.org/msgstore/maildirstore;1"
+);
diff --git a/comm/mail/base/test/unit/resources/viewWrapperTestUtils.js b/comm/mail/base/test/unit/resources/viewWrapperTestUtils.js
new file mode 100644
index 0000000000..5ae301b016
--- /dev/null
+++ b/comm/mail/base/test/unit/resources/viewWrapperTestUtils.js
@@ -0,0 +1,534 @@
+/* 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 { DBViewWrapper, IDBViewWrapperListener } = ChromeUtils.import(
+ "resource:///modules/DBViewWrapper.jsm"
+);
+var { MailViewManager, MailViewConstants } = ChromeUtils.import(
+ "resource:///modules/MailViewManager.jsm"
+);
+var { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+var { MessageGenerator, MessageScenarioFactory } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { MessageInjection } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageInjection.jsm"
+);
+var { dump_view_state } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ViewHelpers.jsm"
+);
+
+var gMessageGenerator;
+var gMessageScenarioFactory;
+var messageInjection;
+var gMockViewWrapperListener;
+
+function initViewWrapperTestUtils(aInjectionConfig) {
+ if (!aInjectionConfig) {
+ throw new Error("Please provide an injection config for MessageInjection.");
+ }
+
+ gMessageGenerator = new MessageGenerator();
+ gMessageScenarioFactory = new MessageScenarioFactory(gMessageGenerator);
+
+ messageInjection = new MessageInjection(aInjectionConfig, gMessageGenerator);
+ messageInjection.registerMessageInjectionListener(VWTU_testHelper);
+ registerCleanupFunction(() => {
+ // Cleanup of VWTU_testHelper.
+ VWTU_testHelper.postTest();
+ });
+ gMockViewWrapperListener = new MockViewWrapperListener();
+}
+
+// Something less sucky than do_check_true.
+function assert_true(aBeTrue, aWhy, aDumpView) {
+ if (!aBeTrue) {
+ if (aDumpView) {
+ dump_view_state(VWTU_testHelper.active_view_wrappers[0]);
+ }
+ do_throw(aWhy);
+ }
+}
+
+function assert_false(aBeFalse, aWhy, aDumpView) {
+ if (aBeFalse) {
+ if (aDumpView) {
+ dump_view_state(VWTU_testHelper.active_view_wrappers[0]);
+ }
+ do_throw(aWhy);
+ }
+}
+
+function assert_equals(aA, aB, aWhy, aDumpView) {
+ if (aA != aB) {
+ if (aDumpView) {
+ dump_view_state(VWTU_testHelper.active_view_wrappers[0]);
+ }
+ do_throw(aWhy);
+ }
+}
+
+function assert_bit_set(aWhat, aBit, aWhy) {
+ if (!(aWhat & aBit)) {
+ do_throw(aWhy);
+ }
+}
+
+function assert_bit_not_set(aWhat, aBit, aWhy) {
+ if (aWhat & aBit) {
+ do_throw(aWhy);
+ }
+}
+
+var gFakeCommandUpdater = {
+ updateCommandStatus() {},
+
+ displayMessageChanged(aFolder, aSubject, aKeywords) {},
+
+ summarizeSelection() {},
+
+ updateNextMessageAfterDelete() {},
+};
+
+/**
+ * Track our resources used by each test. This is so we can keep our memory
+ * usage low by forcing things to be forgotten about (or even nuked) once
+ * a test completes, but also so we can provide useful information about the
+ * state of things if a test times out.
+ */
+var VWTU_testHelper = {
+ active_view_wrappers: [],
+ active_real_folders: [],
+ active_virtual_folders: [],
+
+ onVirtualFolderCreated(aVirtualFolder) {
+ this.active_virtual_folders.push(aVirtualFolder);
+ },
+
+ postTest() {
+ // Close all the views we opened.
+ this.active_view_wrappers.forEach(function (wrapper) {
+ wrapper.close();
+ });
+ // Verify that the notification helper has no outstanding listeners.
+ if (IDBViewWrapperListener.prototype._FNH.haveListeners()) {
+ let msg = "FolderNotificationHelper has listeners, but should not.";
+ dump("*** " + msg + "\n");
+ dump("Pending URIs:\n");
+ for (let folderURI in IDBViewWrapperListener.prototype._FNH
+ ._pendingFolderUriToViewWrapperLists) {
+ dump(" " + folderURI + "\n");
+ }
+ dump("Interested wrappers:\n");
+ for (let folderURI in IDBViewWrapperListener.prototype._FNH
+ ._interestedWrappers) {
+ dump(" " + folderURI + "\n");
+ }
+ dump("***\n");
+ do_throw(msg);
+ }
+ // Force the folder to forget about the message database.
+ this.active_virtual_folders.forEach(function (folder) {
+ folder.msgDatabase = null;
+ });
+ this.active_real_folders.forEach(function (folder) {
+ folder.msgDatabase = null;
+ });
+
+ this.active_view_wrappers.splice(0);
+ this.active_real_folders.splice(0);
+ this.active_virtual_folders.splice(0);
+
+ gMockViewWrapperListener.allMessagesLoadedEventCount = 0;
+ },
+ onTimeout() {
+ dump("-----------------------------------------------------------\n");
+ dump("Active things at time of timeout:\n");
+ for (let folder of this.active_real_folders) {
+ dump("Real folder: " + folder.prettyName + "\n");
+ }
+ for (let virtFolder of this.active_virtual_folders) {
+ dump("Virtual folder: " + virtFolder.prettyName + "\n");
+ }
+ for (let [i, viewWrapper] of this.active_view_wrappers.entries()) {
+ dump("-----------------------------------\n");
+ dump("Active view wrapper " + i + "\n");
+ dump_view_state(viewWrapper);
+ }
+ },
+};
+
+function make_view_wrapper() {
+ let wrapper = new DBViewWrapper(gMockViewWrapperListener);
+ VWTU_testHelper.active_view_wrappers.push(wrapper);
+ return wrapper;
+}
+
+/**
+ * Clone an open and valid view wrapper.
+ */
+function clone_view_wrapper(aViewWrapper) {
+ let wrapper = aViewWrapper.clone(gMockViewWrapperListener);
+ VWTU_testHelper.active_view_wrappers.push(wrapper);
+ return wrapper;
+}
+
+/**
+ * Open a folder for view display. This is an async operation, relying on the
+ * onMessagesLoaded(true) notification to get he test going again.
+ */
+async function view_open(aViewWrapper, aFolder) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.open(aFolder);
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+async function view_set_mail_view(aViewWrapper, aMailViewIndex, aData) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.setMailView(aMailViewIndex, aData);
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+async function view_refresh(aViewWrapper) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.refresh();
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+async function view_group_by_sort(aViewWrapper, aGroupBySort) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.showGroupedBySort = aGroupBySort;
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+/**
+ * Call endViewUpdate on your wrapper in the async idiom. This is essential if
+ * you are doing things to a cross-folder view which does its searching in a
+ * time-sliced fashion. In such a case, you would call beginViewUpdate
+ * manually, then poke at the view, then call us to end the view update.
+ */
+function async_view_end_update(aViewWrapper) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.endViewUpdate();
+ return false;
+}
+
+/**
+ * The deletion is asynchronous from a view perspective because the view ends
+ * up re-creating itself which triggers a new search. This function is
+ * nominally asynchronous because we refresh XFVF views when one of their
+ * folders gets deleted. In that case, you must pass the view wrapper you
+ * expect to be affected so we can do our async thing.
+ * If, however, you are deleting the last folder that belongs to a view, you
+ * should not pass a view wrapper, because you should expect the view wrapper
+ * to close itself and destroy the view. (Well, the view might do something
+ * too, but we don't care what it does.) We provide a |delete_folder| alias
+ * so code can look clean.
+ *
+ * @param aViewWrapper Required when you want us to operate asynchronously.
+ * @param aDontEmptyTrash This function will empty the trash after deleting the
+ * folder, unless you set this parameter to true.
+ */
+async function delete_folder(aFolder, aViewWrapper, aDontEmptyTrash) {
+ VWTU_testHelper.active_real_folders.splice(
+ VWTU_testHelper.active_real_folders.indexOf(aFolder),
+ 1
+ );
+ // Deleting tries to be helpful and move the folder to the trash...
+ aFolder.deleteSelf(null);
+
+ // Ugh. So we have the problem where that move above just triggered a
+ // re-computation of the view... which is an asynchronous operation
+ // that we don't care about at all. We don't need to wait for it to
+ // complete, but if we don't, we have a race on enabling this next
+ // notification.
+ // So we interrupt the search ourselves. This problem is exclusively
+ // limited to unit testing and is not something we would need to do
+ // normally. (Because things are single-threaded we are also
+ // guaranteed that we can interrupt it without needing locks or anything.)
+ if (aViewWrapper) {
+ if (aViewWrapper.searching) {
+ aViewWrapper.search.session.interruptSearch();
+ }
+ aViewWrapper.listener.pendingLoad = true;
+ }
+
+ // ...so now the stupid folder is in the stupid trash.
+ // Let's empty the trash, then, shall we?
+ // (For local folders it doesn't matter who we call this on.)
+ if (!aDontEmptyTrash) {
+ aFolder.emptyTrash(null);
+ }
+
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+/**
+ * For assistance in debugging, dump information about a message header.
+ */
+function dump_message_header(aMsgHdr) {
+ dump(" Subject: " + aMsgHdr.mime2DecodedSubject + "\n");
+ dump(" Date: " + new Date(aMsgHdr.date / 1000) + "\n");
+ dump(" Author: " + aMsgHdr.mime2DecodedAuthor + "\n");
+ dump(" Recipients: " + aMsgHdr.mime2DecodedRecipients + "\n");
+ let junkScore = aMsgHdr.getStringProperty("junkscore");
+ dump(
+ " Read: " +
+ aMsgHdr.isRead +
+ " Flagged: " +
+ aMsgHdr.isFlagged +
+ " Killed: " +
+ aMsgHdr.isKilled +
+ " Junk: " +
+ (junkScore == "100") +
+ "\n"
+ );
+ dump(" Keywords: " + aMsgHdr.getStringProperty("Keywords") + "\n");
+ dump(
+ " Folder: " +
+ aMsgHdr.folder.prettyName +
+ " Key: " +
+ aMsgHdr.messageKey +
+ "\n"
+ );
+}
+
+/**
+ * Verify that the messages in the provided SyntheticMessageSets are the only
+ * visible messages in the provided DBViewWrapper. If dummy headers are present
+ * in the view for group-by-sort, the code will ensure that the dummy header's
+ * underlying header corresponds to a message in the synthetic sets. However,
+ * you should generally not rely on this code to test for anything involving
+ * dummy headers.
+ *
+ * In the event the view does not contain all of the messages from the provided
+ * sets or contains messages not in the provided sets, do_throw will be invoked
+ * with a human readable explanation of the problem.
+ *
+ * @param aSynSets A single SyntheticMessageSet or a list of
+ * SyntheticMessageSets.
+ * @param aViewWrapper The DBViewWrapper whose contents you want to validate.
+ */
+function verify_messages_in_view(aSynSets, aViewWrapper) {
+ if (!("length" in aSynSets)) {
+ aSynSets = [aSynSets];
+ }
+
+ // - Iterate over all the message sets, retrieving the message header. Use
+ // this to construct a URI to populate a dictionary mapping.
+ let synMessageURIs = {}; // map URI to message header
+ for (let messageSet of aSynSets) {
+ for (let msgHdr of messageSet.msgHdrs()) {
+ synMessageURIs[msgHdr.folder.getUriForMsg(msgHdr)] = msgHdr;
+ }
+ }
+
+ // - Iterate over the contents of the view, nulling out values in
+ // synMessageURIs for found messages, and exploding for missing ones.
+ let dbView = aViewWrapper.dbView;
+ let treeView = aViewWrapper.dbView.QueryInterface(Ci.nsITreeView);
+ let rowCount = treeView.rowCount;
+
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let msgHdr = dbView.getMsgHdrAt(iViewIndex);
+ let uri = msgHdr.folder.getUriForMsg(msgHdr);
+ // Expected hit, null it out. (in the dummy case, we will just null out
+ // twice, which is also why we do an 'in' test and not a value test.
+ if (uri in synMessageURIs) {
+ synMessageURIs[uri] = null;
+ } else {
+ // The view is showing a message that should not be shown, explode.
+ dump(
+ "The view is showing the following message header and should not" +
+ " be:\n"
+ );
+ dump_message_header(msgHdr);
+ dump("View State:\n");
+ dump_view_state(aViewWrapper);
+ throw new Error(
+ "view contains header that should not be present! " + msgHdr.messageKey
+ );
+ }
+ }
+
+ // - Iterate over our URI set and make sure every message got nulled out.
+ for (let uri in synMessageURIs) {
+ let msgHdr = synMessageURIs[uri];
+ if (msgHdr != null) {
+ dump("************************\n");
+ dump(
+ "The view should have included the following message header but" +
+ " did not:\n"
+ );
+ dump_message_header(msgHdr);
+ dump("View State:\n");
+ dump_view_state(aViewWrapper);
+ throw new Error(
+ "view does not contain a header that should be present! " +
+ msgHdr.messageKey
+ );
+ }
+ }
+}
+
+/**
+ * Assert if the view wrapper is displaying any messages.
+ */
+function verify_empty_view(aViewWrapper) {
+ verify_messages_in_view([], aViewWrapper);
+}
+
+/**
+ * Build a histogram of the treeview levels and verify it matches the expected
+ * histogram. Oddly enough, I find this to be a reasonable and concise way to
+ * verify that threading mode is enabled. Keep in mind that this file is
+ * currently not used to test the actual thread logic. If/when that day comes,
+ * something less eccentric is certainly the way that should be tested.
+ */
+function verify_view_level_histogram(aExpectedHisto, aViewWrapper) {
+ let treeView = aViewWrapper.dbView.QueryInterface(Ci.nsITreeView);
+ let rowCount = treeView.rowCount;
+
+ let actualHisto = {};
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let level = treeView.getLevel(iViewIndex);
+ actualHisto[level] = (actualHisto[level] || 0) + 1;
+ }
+
+ for (let [level, count] of Object.entries(aExpectedHisto)) {
+ if (actualHisto[level] != count) {
+ dump_view_state(aViewWrapper);
+ dump("*******************\n");
+ dump(
+ "Expected count for histogram level " +
+ level +
+ " was " +
+ count +
+ " but got " +
+ actualHisto[level] +
+ "\n"
+ );
+ do_throw("View histogram does not match!");
+ }
+ }
+}
+
+/**
+ * Given a view wrapper and one or more view indices, verify that the row
+ * returns true for isContainer.
+ *
+ * @param aViewWrapper The view wrapper in question
+ * @param ... View indices to check.
+ */
+function verify_view_row_at_index_is_container(aViewWrapper, ...aArgs) {
+ let treeView = aViewWrapper.dbView.QueryInterface(Ci.nsITreeView);
+ for (let viewIndex of aArgs) {
+ if (!treeView.isContainer(viewIndex)) {
+ dump_view_state(aViewWrapper);
+ do_throw("Expected isContainer to be true at view index " + viewIndex);
+ }
+ }
+}
+
+/**
+ * Given a view wrapper and one or more view indices, verify that there is a
+ * dummy header at each provided index.
+ *
+ * @param aViewWrapper The view wrapper in question
+ * @param ... View indices to check.
+ */
+function verify_view_row_at_index_is_dummy(aViewWrapper, ...aArgs) {
+ const MSG_VIEW_FLAG_DUMMY = 0x20000000;
+ for (let viewIndex of aArgs) {
+ let flags = aViewWrapper.dbView.getFlagsAt(viewIndex);
+ if (!(flags & MSG_VIEW_FLAG_DUMMY)) {
+ dump_view_state(aViewWrapper);
+ do_throw("Expected a dummy header at view index " + viewIndex);
+ }
+ }
+}
+
+/**
+ * Expand all nodes in the view wrapper. This is a debug helper function
+ * because there's no good reason to have it be on the view wrapper at this
+ * time. You must call async_view_refresh or async_view_end_update (if you are
+ * within a view update batch) after calling this!
+ */
+function view_expand_all(aViewWrapper) {
+ // We can't use the command because it has assertions about having a tree.
+ aViewWrapper._viewFlags |= Ci.nsMsgViewFlagsType.kExpandAll;
+}
+
+/**
+ * Create a name and address pair where the provided word is part of the name.
+ */
+function make_person_with_word_in_name(aWord) {
+ let dude = gMessageGenerator.makeNameAndAddress();
+ return [aWord, dude[1]];
+}
+
+/**
+ * Create a name and address pair where the provided word is part of the mail
+ * address.
+ */
+function make_person_with_word_in_address(aWord) {
+ let dude = gMessageGenerator.makeNameAndAddress();
+ return [dude[0], aWord + "@madeup.nul"];
+}
+
+class MockViewWrapperListener extends IDBViewWrapperListener {
+ shouldUseMailViews = true;
+ shouldDeferMessageDisplayUntilAfterServerConnect = false;
+ messenger = null;
+ // Use no message window!
+ msgWindow = null;
+ threadPaneCommandUpdater = gFakeCommandUpdater;
+ // Event handlers.
+ allMessagesLoadedEventCount = 0;
+ messagesRemovedEventCount = 0;
+
+ constructor() {
+ super();
+ this._promise = new Promise(resolve => {
+ this._resolve = resolve;
+ });
+ }
+
+ shouldMarkMessagesReadOnLeavingFolder(aMsgFolder) {
+ return Services.prefs.getBoolPref(
+ "mailnews.mark_message_read." + aMsgFolder.server.type
+ );
+ }
+
+ onMessagesLoaded(aAll) {
+ if (!aAll) {
+ return;
+ }
+ this.allMessagesLoadedEventCount++;
+ if (this.pendingLoad) {
+ this.pendingLoad = false;
+ this._resolve();
+ }
+ }
+
+ onMessagesRemoved() {
+ this.messagesRemovedEventCount++;
+ }
+
+ get promise() {
+ return this._promise;
+ }
+ resetPromise() {
+ this._promise = new Promise(resolve => {
+ this._resolve = resolve;
+ });
+ }
+}
diff --git a/comm/mail/base/test/unit/test_alertHook.js b/comm/mail/base/test/unit/test_alertHook.js
new file mode 100644
index 0000000000..0189930862
--- /dev/null
+++ b/comm/mail/base/test/unit/test_alertHook.js
@@ -0,0 +1,119 @@
+/* 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/. */
+
+/**
+ * Tests the replace of alerts service with our own. This will let us check if we're
+ * prompting or not.
+ */
+
+var { alertHook } = ChromeUtils.import(
+ "resource:///modules/activity/alertHook.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+var { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+
+alertHook.init();
+
+// Wait time of 1s for slow debug builds.
+const TEST_WAITTIME = 1000;
+
+var gMsgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+);
+var mockAlertsService;
+var cid;
+var mailnewsURL;
+
+add_setup(function () {
+ // First register the mock alerts service.
+ mockAlertsService = new MockAlertsService();
+ cid = MockRegistrar.register(
+ "@mozilla.org/alerts-service;1",
+ mockAlertsService
+ );
+ // A random URL.
+ let uri = Services.io.newURI("news://localhost:80/1@regular.invalid");
+ mailnewsURL = uri.QueryInterface(Ci.nsIMsgMailNewsUrl);
+});
+
+add_task(async function test_not_shown_to_user_no_url_no_window() {
+ // Just text, no url or window => expect no error shown to user
+ MailServices.mailSession.alertUser("test error");
+ await Promise.race([
+ PromiseTestUtils.promiseDelay(TEST_WAITTIME).then(result => {
+ Assert.ok(true, "Alert is not shown with no window or no url present");
+ }),
+ mockAlertsService.promise.then(result => {
+ throw new Error(
+ "Alert is shown to the user although neither window nor url is present"
+ );
+ }),
+ ]);
+});
+
+add_task(async function test_shown_to_user() {
+ // Reset promise state.
+ mockAlertsService.deferPromise();
+ // Set a window for the URL.
+ mailnewsURL.msgWindow = gMsgWindow;
+
+ // Text, url and window => expect error shown to user
+ MailServices.mailSession.alertUser("test error 2", mailnewsURL);
+ let alertShown = await mockAlertsService.promise;
+ Assert.ok(alertShown);
+});
+
+add_task(async function test_not_shown_to_user_no_window() {
+ // Reset promise state.
+ mockAlertsService.deferPromise();
+ // No window for the URL.
+ mailnewsURL.msgWindow = null;
+
+ // Text, url and no window => export no error shown to user
+ MailServices.mailSession.alertUser("test error 3", mailnewsURL);
+ await Promise.race([
+ PromiseTestUtils.promiseDelay(TEST_WAITTIME).then(result => {
+ Assert.ok(true, "Alert is not shown with no window but a url present");
+ }),
+ mockAlertsService.promise.then(result => {
+ throw new Error(
+ "Alert is shown to the user although no window in the mailnewsURL present"
+ );
+ }),
+ ]);
+});
+
+add_task(function endTest() {
+ MockRegistrar.unregister(cid);
+});
+
+class MockAlertsService {
+ QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]);
+
+ constructor() {
+ this._deferredPromise = PromiseUtils.defer();
+ }
+
+ showAlert() {
+ this._deferredPromise.resolve(true);
+ }
+
+ deferPromise() {
+ this._deferredPromise = PromiseUtils.defer();
+ }
+
+ get promise() {
+ return this._deferredPromise.promise;
+ }
+}
diff --git a/comm/mail/base/test/unit/test_attachmentChecker.js b/comm/mail/base/test/unit/test_attachmentChecker.js
new file mode 100644
index 0000000000..b727819231
--- /dev/null
+++ b/comm/mail/base/test/unit/test_attachmentChecker.js
@@ -0,0 +1,121 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 suite for the attachmentChecker class
+ *
+ * Currently tested:
+ * - getAttachmentKeywords function.
+ */
+
+// Globals
+
+var { AttachmentChecker } = ChromeUtils.import(
+ "resource:///modules/AttachmentChecker.jsm"
+);
+
+/*
+ * UTILITIES
+ */
+
+function assert(aBeTrue, aWhy) {
+ if (!aBeTrue) {
+ do_throw(aWhy);
+ }
+}
+
+function assert_equal(aA, aB, aWhy) {
+ assert(
+ aA == aB,
+ aWhy +
+ " (" +
+ unescape(encodeURIComponent(aA)) +
+ " != " +
+ unescape(encodeURIComponent(aB)) +
+ ")."
+ );
+}
+
+/*
+ * TESTS
+ */
+
+function test_getAttachmentKeywords(desc, mailData, keywords, expected) {
+ let result = AttachmentChecker.getAttachmentKeywords(mailData, keywords);
+ assert_equal(result, expected, desc + " not equal!");
+}
+
+var tests = [
+ // Desc, mail body Data, keywords to search for, expected keywords found.
+ ["Simple keyword", "latte.ca", "latte", "latte"],
+ ["Extension", "testing document.pdf", ".pdf", "document.pdf"],
+ [
+ "Two Extensions",
+ "testing document.pdf and test.pdf",
+ ".pdf",
+ "document.pdf,test.pdf",
+ ],
+ [
+ "Two+one Extensions",
+ "testing document.pdf and test.pdf and again document.pdf",
+ ".pdf",
+ "document.pdf,test.pdf",
+ ],
+ ["Url", "testing http://document.pdf", ".pdf", ""],
+ ["Both", "testing http://document.pdf test.pdf", ".pdf", "test.pdf"],
+ ["Greek", "This is a ΘεωÏία test", "ΘεωÏία,is", "ΘεωÏία,is"],
+ ["Greek missing", "This a ΘεωÏίαω test", "ΘεωÏία", ""],
+ ["Greek and punctuation", "This a:ΘεωÏία-test", "ΘεωÏία", "ΘεωÏία"],
+ ["Greek and Japanese", "This a 添ΘεωÏία付 test", "ΘεωÏία", "ΘεωÏία"],
+ ["Japanese", "This is 添付! test", "ΘεωÏία,添付", "添付"],
+ ["More Japanese", "添付mailã‚’é€ã‚‹", "添付,cv", "添付"],
+ ["Japanese and English", "添付mailã‚’é€ã‚‹", "添付,mail", "添付,mail"],
+ ["Japanese and English Mixed", "添付mailã‚’é€ã‚‹", "添付mail", "添付mail"],
+ ["Japanese and English Mixed missing", "添付mailing", "添付mail", ""],
+ ["Japanese trailers", "This is 添添付付! test", "ΘεωÏία,添付", "添付"],
+ ["Multi-lang", "cv添付ΘεωÏία", "ΘεωÏία,添付,cv", "ΘεωÏία,添付,cv"],
+ [
+ "Should match",
+ "I've attached the http/test.pdf file",
+ ".pdf",
+ "http/test.pdf",
+ ],
+ ["Should still fail", "a https://test.pdf a", ".pdf", ""],
+ ["Should match Japanese", "a test.添付 a", ".添付", "test.添付"],
+ ["Should match Greek", "a test.ΘεωÏία a", ".ΘεωÏία", "test.ΘεωÏία"],
+ ["Should match once", "a test.pdf.doc a", ".pdf,.doc", "test.pdf.doc"],
+ [
+ "Should not match kw in url",
+ "see https://example.org/attachment.cgi?id=1 test",
+ "attachment",
+ "",
+ ],
+ [
+ "Should not match kw in url ending with kw",
+ "https://example.org/attachment",
+ "attachment",
+ "",
+ ],
+ [
+ "Should match CV and attachment",
+ "got my CV as attachment",
+ "CV,attachment",
+ "CV,attachment",
+ ],
+];
+
+function run_test() {
+ do_test_pending();
+
+ for (var i in tests) {
+ if (typeof tests[i] == "function") {
+ tests[i]();
+ } else {
+ test_getAttachmentKeywords.apply(null, tests[i]);
+ }
+ }
+
+ do_test_finished();
+}
diff --git a/comm/mail/base/test/unit/test_devtools_url.js b/comm/mail/base/test/unit/test_devtools_url.js
new file mode 100644
index 0000000000..d0c8baf21f
--- /dev/null
+++ b/comm/mail/base/test/unit/test_devtools_url.js
@@ -0,0 +1,22 @@
+/**
+ * This test checks for the URL of the developer tools toolbox. If it fails,
+ * then the code for opening the toolbox has likely changed, and the code in
+ * MailGlue that observes command-line-startup will not be working properly.
+ */
+
+Cu.importGlobalProperties(["fetch"]);
+var { MailGlue } = ChromeUtils.import("resource:///modules/MailGlue.jsm");
+
+add_task(async () => {
+ let expectedURL = `"${MailGlue.BROWSER_TOOLBOX_WINDOW_URL}"`;
+ let containingFile =
+ "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs";
+
+ let response = await fetch(containingFile);
+ let text = await response.text();
+
+ Assert.ok(
+ text.includes(expectedURL),
+ `Expected to find ${expectedURL} in ${containingFile}.`
+ );
+});
diff --git a/comm/mail/base/test/unit/test_emptyTrash_dbViewWrapper.js b/comm/mail/base/test/unit/test_emptyTrash_dbViewWrapper.js
new file mode 100644
index 0000000000..d392a9ece2
--- /dev/null
+++ b/comm/mail/base/test/unit/test_emptyTrash_dbViewWrapper.js
@@ -0,0 +1,43 @@
+/* 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 resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "imap", offline: false });
+
+add_task(async function test_real_folder_load_and_move_to_trash() {
+ let viewWrapper = make_view_wrapper();
+ let [[msgFolder], msgSet] = await messageInjection.makeFoldersWithSets(1, [
+ { count: 1 },
+ ]);
+
+ await view_open(
+ viewWrapper,
+ messageInjection.getRealInjectionFolder(msgFolder)
+ );
+ verify_messages_in_view(msgSet, viewWrapper);
+
+ await messageInjection.trashMessages(msgSet);
+ verify_empty_view(viewWrapper);
+});
+
+add_task(async function test_empty_trash() {
+ let viewWrapper = make_view_wrapper();
+ let trashHandle = await messageInjection.getTrashFolder();
+ let trashFolder = messageInjection.getRealInjectionFolder(trashHandle);
+
+ await view_open(viewWrapper, trashFolder);
+
+ await messageInjection.emptyTrash();
+ verify_empty_view(viewWrapper);
+
+ Assert.ok(viewWrapper.displayedFolder !== null);
+
+ let [msgSet] = await messageInjection.makeNewSetsInFolders(
+ [trashHandle],
+ [{ count: 1 }]
+ );
+
+ verify_messages_in_view(msgSet, viewWrapper);
+});
diff --git a/comm/mail/base/test/unit/test_mailGlue_distribution.js b/comm/mail/base/test/unit/test_mailGlue_distribution.js
new file mode 100644
index 0000000000..0eb1bc9574
--- /dev/null
+++ b/comm/mail/base/test/unit/test_mailGlue_distribution.js
@@ -0,0 +1,120 @@
+var { TBDistCustomizer } = ChromeUtils.import(
+ "resource:///modules/TBDistCustomizer.jsm"
+);
+
+function run_test() {
+ do_test_pending();
+
+ Services.locale.requestedLocales = ["en-US"];
+
+ // Create an instance of nsIFile out of the current process directory
+ let distroDir = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+
+ // Construct a descendant of the distroDir file
+ distroDir.append("distribution");
+
+ // Create a clone of distroDir
+ let iniFile = distroDir.clone();
+
+ // Create a descendant of iniFile
+ iniFile.append("distribution.ini");
+ // It's a bug if distribution.ini already exists
+ if (iniFile.exists()) {
+ do_throw(
+ "distribution.ini already exists in objdir/mozilla/dist/bin/distribution."
+ );
+ }
+
+ registerCleanupFunction(function () {
+ // Remove the distribution.ini file
+ if (iniFile.exists()) {
+ iniFile.remove(true);
+ }
+ });
+
+ let testDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ let testDistributionFile = testDir.clone();
+
+ // Construct descendant file
+ testDistributionFile.append("distribution.ini");
+ // Copy to distroDir
+ testDistributionFile.copyTo(distroDir, "distribution.ini");
+ Assert.ok(testDistributionFile.exists());
+
+ // Set the prefs
+ TBDistCustomizer.applyPrefDefaults();
+
+ let testIni = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
+ .getService(Ci.nsIINIParserFactory)
+ .createINIParser(testDistributionFile);
+
+ // Now check that prefs were set - test the Global prefs against the
+ // Global section in the ini file
+ let iniValue = testIni.getString("Global", "id");
+ let pref = Services.prefs.getCharPref("distribution.id");
+ Assert.equal(iniValue, pref);
+
+ iniValue = testIni.getString("Global", "version");
+ pref = Services.prefs.getCharPref("distribution.version");
+ Assert.equal(iniValue, pref);
+
+ let aboutLocale;
+ try {
+ aboutLocale = testIni.getString("Global", "about.en-US");
+ } catch (e) {
+ console.error(e);
+ }
+
+ if (aboutLocale == undefined) {
+ aboutLocale = testIni.getString("Global", "about");
+ }
+
+ pref = Services.prefs.getCharPref("distribution.about");
+ Assert.equal(aboutLocale, pref);
+
+ // Test Preferences section
+ let s = "Preferences";
+ for (let key of testIni.getKeys(s)) {
+ let value = TBDistCustomizer.parseValue(testIni.getString(s, key));
+ switch (typeof value) {
+ case "boolean":
+ Assert.equal(value, Services.prefs.getBoolPref(key));
+ break;
+ case "number":
+ Assert.equal(value, Services.prefs.getIntPref(key));
+ break;
+ case "string":
+ Assert.equal(value, Services.prefs.getCharPref(key));
+ break;
+ default:
+ do_throw(
+ "The preference " + key + " is of unknown type: " + typeof value
+ );
+ }
+ }
+
+ // Test the LocalizablePreferences-[locale] section
+ // Add any prefs found in it to the overrides array
+ let overrides = [];
+ s = "LocalizablePreferences-en-US";
+ for (let key of testIni.getKeys(s)) {
+ let value = TBDistCustomizer.parseValue(testIni.getString(s, key));
+ value = "data:text/plain," + key + "=" + value;
+ Assert.equal(value, Services.prefs.getCharPref(key));
+ overrides.push(key);
+ }
+
+ // Test the LocalizablePreferences section
+ // Any prefs here that aren't found in overrides are not overridden
+ // by LocalizablePrefs-[locale] and should be tested
+ s = "LocalizablePreferences";
+ for (let key of testIni.getKeys(s)) {
+ if (!overrides.includes(key)) {
+ let value = TBDistCustomizer.parseValue(testIni.getString(s, key));
+ value = value.replace(/%LOCALE%/g, "en-US");
+ value = "data:text/plain," + key + "=" + value;
+ Assert.equal(value, Services.prefs.getCharPref(key));
+ }
+ }
+ do_test_finished();
+}
diff --git a/comm/mail/base/test/unit/test_oauth_migration.js b/comm/mail/base/test/unit/test_oauth_migration.js
new file mode 100644
index 0000000000..1130757a97
--- /dev/null
+++ b/comm/mail/base/test/unit/test_oauth_migration.js
@@ -0,0 +1,319 @@
+/* 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 migrating Yahoo/AOL users to OAuth2, since "normal password" is going away
+ * on October 20, 2020.
+ */
+
+var { MailMigrator } = ChromeUtils.import(
+ "resource:///modules/MailMigrator.jsm"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+
+var gAccountList = [
+ // POP Yahoo account + Yahoo Server.
+ {
+ type: "pop3",
+ port: 1234,
+ user: "pop3user",
+ password: "pop3password",
+ hostname: "pop3.mail.yahoo.com",
+ socketType: Ci.nsMsgSocketType.plain,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: true,
+ hostname: "smtp.mail.yahoo.com",
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ },
+ ],
+ },
+ // IMAP Yahoo account + Google Server.
+ {
+ type: "imap",
+ port: 2345,
+ user: "imapuser",
+ password: "imappassword",
+ hostname: "imap.mail.yahoo.com",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: false,
+ hostname: "smtp.gmail.com",
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ // IMAP Google account + Yahoo Server.
+ {
+ type: "imap",
+ port: 2345,
+ user: "imap2user",
+ password: "imap2password",
+ hostname: "imap.gmail.com",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: false,
+ hostname: "smtp.mail.yahoo.com",
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ // IMAP Invalid account + Invalid Server.
+ {
+ type: "imap",
+ port: 2345,
+ user: "imap2user",
+ password: "imap2password",
+ hostname: "imap.mail.foo.invalid",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: false,
+ hostname: "smtp.mail.foo.invalid",
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ // AOL IMAP account.
+ {
+ type: "imap",
+ port: 993,
+ user: "aolimap",
+ password: "imap2password",
+ hostname: "imap.aol.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 465,
+ user: "imapout2",
+ password: "imapoutpassword2",
+ isDefault: false,
+ hostname: "smtp.aol.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ },
+ ],
+ },
+ // AOL POP3 account.
+ {
+ type: "pop3",
+ port: 995,
+ user: "aolpop3",
+ password: "abc",
+ hostname: "pop.aol.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 465,
+ user: "popout",
+ password: "aaa",
+ isDefault: false,
+ hostname: "smtp.aol.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ },
+ ],
+ },
+ // Google POP3 account.
+ {
+ type: "pop3",
+ port: 995,
+ user: "gmailpop3",
+ password: "abc",
+ hostname: "pop.gmail.com",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ smtpServers: [
+ {
+ port: 465,
+ user: "gmailpopout",
+ password: "aaa",
+ isDefault: true,
+ hostname: "smtp.gmail.com",
+ socketType: Ci.nsMsgAuthMethod.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ // Microsoft IMAP account
+ {
+ type: "imap",
+ port: 993,
+ user: "msimap",
+ password: "abc",
+ hostname: "outlook.office365.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 587,
+ user: "msimap",
+ password: "abc",
+ isDefault: true,
+ hostname: "smtp.office365.com",
+ socketType: Ci.nsMsgAuthMethod.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ },
+ ],
+ },
+];
+
+// An array of the incoming servers created from the setup_accounts() method.
+var gIncomingServers = [];
+
+// An array of the outgoing servers created from the setup_accounts() method.
+var gOutgoingServers = [];
+
+// An array of the accounts created from the setup_accounts() method.
+var gAccounts = [];
+
+/**
+ * Set up accounts based on the given data.
+ */
+function setup_accounts() {
+ for (let details of gAccountList) {
+ let server = localAccountUtils.create_incoming_server(
+ details.type,
+ details.port,
+ details.user,
+ details.password,
+ details.hostname
+ );
+ server.socketType = details.socketType;
+ server.authMethod = details.authMethod;
+
+ // Add the newly created server to the array for testing.
+ gIncomingServers.push(server);
+
+ let account = MailServices.accounts.FindAccountForServer(server);
+ for (let smtpDetails of details.smtpServers) {
+ let outgoing = localAccountUtils.create_outgoing_server(
+ smtpDetails.port,
+ smtpDetails.user,
+ smtpDetails.password,
+ smtpDetails.hostname
+ );
+ outgoing.socketType = smtpDetails.socketType;
+ outgoing.authMethod = smtpDetails.authMethod;
+ localAccountUtils.associate_servers(
+ account,
+ outgoing,
+ smtpDetails.isDefault
+ );
+
+ // Add the newly created server to the array for testing.
+ gOutgoingServers.push(outgoing);
+
+ // Add the newly created account to the array for cleanup.
+ gAccounts.push(account);
+ }
+ }
+}
+
+add_task(function test_oauth_migration() {
+ setup_accounts();
+
+ for (let server of gIncomingServers) {
+ // Confirm all the incoming servers are not using OAuth2 after the setup.
+ Assert.notEqual(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ "Incoming server should not use OAuth2"
+ );
+ }
+
+ for (let server of gOutgoingServers) {
+ // Confirm all the outgoing servers are not using OAuth2 after the setup.
+ Assert.notEqual(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ "Outgoing server should not use OAuth2"
+ );
+ }
+
+ // Run the migration.
+ Services.prefs.setIntPref("mail.ui-rdf.version", 21);
+ MailMigrator._migrateUI();
+
+ for (let server of gIncomingServers) {
+ // Confirm only the correct incoming servers are using OAuth2 after migration.
+ if (
+ !server.hostName.endsWith("mail.yahoo.com") &&
+ !server.hostName.endsWith("aol.com") &&
+ !server.hostName.endsWith("gmail.com") &&
+ !server.hostName.endsWith("office365.com")
+ ) {
+ Assert.notEqual(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ `Incoming server ${server.hostName} should not use OAuth2 after migration`
+ );
+ continue;
+ }
+
+ Assert.equal(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ `Incoming server ${server.hostName} should use OAuth2 after migration`
+ );
+ }
+
+ for (let server of gOutgoingServers) {
+ // Confirm only the correct outgoing servers are using OAuth2 after migration.
+ if (
+ !server.hostname.endsWith("mail.yahoo.com") &&
+ !server.hostname.endsWith("aol.com") &&
+ !server.hostname.endsWith("gmail.com") &&
+ !server.hostname.endsWith("office365.com")
+ ) {
+ Assert.notEqual(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ `Outgoing server ${server.hostname} should not use OAuth2 after migration`
+ );
+ continue;
+ }
+
+ Assert.equal(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ `Outgoing server ${server.hostname} should use OAuth2 after migration`
+ );
+ }
+
+ // Remove our test accounts and servers to leave the profile clean.
+ for (let account of gAccounts) {
+ MailServices.accounts.removeAccount(account);
+ }
+
+ for (let server of gOutgoingServers) {
+ MailServices.smtp.deleteServer(server);
+ }
+});
diff --git a/comm/mail/base/test/unit/test_treeSelection.js b/comm/mail/base/test/unit/test_treeSelection.js
new file mode 100644
index 0000000000..7e1b3193d1
--- /dev/null
+++ b/comm/mail/base/test/unit/test_treeSelection.js
@@ -0,0 +1,581 @@
+/* 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 { TreeSelection } = ChromeUtils.importESModule(
+ "chrome://messenger/content/tree-selection.mjs"
+);
+
+var fakeView = {
+ rowCount: 101,
+ selectionChanged() {},
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+};
+
+var sel = new TreeSelection(null);
+sel.view = fakeView;
+
+var tree = {
+ view: fakeView,
+
+ _invalidationCount: 0,
+ invalidate() {
+ this._invalidationCount++;
+ },
+ invalidateRange(startIndex, endIndex) {
+ for (let index = startIndex; index <= endIndex; index++) {
+ this.invalidateRow(index);
+ }
+ },
+ _invalidatedRows: [],
+ invalidateRow(index) {
+ this._invalidatedRows.push(index);
+ },
+
+ assertInvalidated() {
+ Assert.equal(this._invalidationCount, 1, "invalidated once");
+ this._invalidationCount = 0;
+ this.assertInvalidatedRows();
+ },
+ assertDidntInvalidate() {
+ Assert.equal(this._invalidationCount, 0, "didn't invalidate");
+ },
+ assertInvalidatedRows(expected) {
+ if (expected) {
+ this.assertDidntInvalidate();
+ } else {
+ expected = [];
+ }
+ let numericSort = (a, b) => a - b;
+ Assert.deepEqual(
+ this._invalidatedRows.sort(numericSort),
+ expected.sort(numericSort),
+ "invalidated rows"
+ );
+ this._invalidatedRows.length = 0;
+ },
+};
+sel.tree = tree;
+
+function createRangeArray(low, high) {
+ let array = [];
+ for (let i = low; i <= high; i++) {
+ array.push(i);
+ }
+ return array;
+}
+
+function assertSelectionRanges(expected) {
+ Assert.deepEqual(sel._ranges, expected, "selected ranges");
+}
+
+function assertCurrentIndex(index) {
+ Assert.equal(sel.currentIndex, index, `current index should be ${index}`);
+}
+
+function assertShiftPivot(index) {
+ Assert.equal(
+ sel.shiftSelectPivot,
+ index,
+ `shift select pivot should be ${index}`
+ );
+}
+
+function assertSelected(index) {
+ Assert.ok(sel.isSelected(index), `${index} should be selected`);
+}
+
+function assertNotSelected(index) {
+ Assert.ok(!sel.isSelected(index), `${index} should not be selected`);
+}
+
+function run_test() {
+ // -- select
+ sel.select(1);
+ tree.assertInvalidatedRows([1]);
+ assertSelected(1);
+ assertNotSelected(0);
+ assertNotSelected(2);
+ assertSelectionRanges([[1, 1]]);
+ assertCurrentIndex(1);
+
+ sel.select(2);
+ tree.assertInvalidatedRows([1, 2]);
+ assertSelected(2);
+ assertNotSelected(1);
+ assertNotSelected(3);
+ assertSelectionRanges([[2, 2]]);
+ assertCurrentIndex(2);
+
+ // -- clearSelection
+ sel.clearSelection();
+ tree.assertInvalidatedRows([2]);
+ assertSelectionRanges([]);
+ assertCurrentIndex(2); // should still be the same...
+
+ // -- toggleSelect
+ // start from nothing
+ sel.clearSelection();
+ tree.assertInvalidatedRows([]);
+ sel.toggleSelect(1);
+ tree.assertInvalidatedRows([1]);
+ assertSelectionRanges([[1, 1]]);
+ assertCurrentIndex(1);
+
+ // lower fusion
+ sel.select(2);
+ tree.assertInvalidatedRows([1, 2]);
+ sel.toggleSelect(1);
+ tree.assertInvalidatedRows([1]);
+ assertSelectionRanges([[1, 2]]);
+ assertCurrentIndex(1);
+
+ // upper fusion
+ sel.toggleSelect(3);
+ tree.assertInvalidatedRows([3]);
+ assertSelectionRanges([[1, 3]]);
+ assertCurrentIndex(3);
+
+ // splitting
+ sel.toggleSelect(2);
+ tree.assertInvalidatedRows([2]);
+ assertSelectionRanges([
+ [1, 1],
+ [3, 3],
+ ]);
+ assertSelected(1);
+ assertSelected(3);
+ assertNotSelected(0);
+ assertNotSelected(2);
+ assertNotSelected(4);
+ assertCurrentIndex(2);
+
+ // merge
+ sel.toggleSelect(2);
+ tree.assertInvalidatedRows([2]);
+ assertSelectionRanges([[1, 3]]);
+ assertCurrentIndex(2);
+
+ // lower shrinkage
+ sel.toggleSelect(1);
+ tree.assertInvalidatedRows([1]);
+ assertSelectionRanges([[2, 3]]);
+ assertCurrentIndex(1);
+
+ // upper shrinkage
+ sel.toggleSelect(3);
+ tree.assertInvalidatedRows([3]);
+ assertSelectionRanges([[2, 2]]);
+ assertCurrentIndex(3);
+
+ // nukage
+ sel.toggleSelect(2);
+ tree.assertInvalidatedRows([2]);
+ assertSelectionRanges([]);
+ assertCurrentIndex(2);
+
+ // -- rangedSelect
+ // simple non-augment
+ sel.rangedSelect(0, 0, false);
+ tree.assertInvalidatedRows([0]);
+ assertSelectionRanges([[0, 0]]);
+ assertShiftPivot(0);
+ assertCurrentIndex(0);
+
+ // slightly less simple non-augment
+ sel.rangedSelect(2, 4, false);
+ tree.assertInvalidatedRows([0, 2, 3, 4]);
+ assertSelectionRanges([[2, 4]]);
+ assertShiftPivot(2);
+ assertCurrentIndex(4);
+
+ // higher distinct range
+ sel.rangedSelect(7, 9, true);
+ tree.assertInvalidatedRows([7, 8, 9]);
+ assertSelectionRanges([
+ [2, 4],
+ [7, 9],
+ ]);
+ assertShiftPivot(7);
+ assertCurrentIndex(9);
+
+ // lower distinct range
+ sel.rangedSelect(0, 0, true);
+ tree.assertInvalidatedRows([0]);
+ assertSelectionRanges([
+ [0, 0],
+ [2, 4],
+ [7, 9],
+ ]);
+ assertShiftPivot(0);
+ assertCurrentIndex(0);
+
+ // lower fusion
+ sel.rangedSelect(6, 6, true);
+ tree.assertInvalidatedRows([6, 7, 8, 9]); // Ideally this would just be 6.
+ assertSelectionRanges([
+ [0, 0],
+ [2, 4],
+ [6, 9],
+ ]);
+ assertShiftPivot(6);
+ assertCurrentIndex(6);
+
+ // upper fusion
+ sel.rangedSelect(10, 11, true);
+ tree.assertInvalidatedRows([6, 7, 8, 9, 10, 11]); // 10, 11
+ assertSelectionRanges([
+ [0, 0],
+ [2, 4],
+ [6, 11],
+ ]);
+ assertShiftPivot(10);
+ assertCurrentIndex(11);
+
+ // notch merge
+ sel.rangedSelect(5, 5, true);
+ tree.assertInvalidatedRows([2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); // 5
+ assertSelectionRanges([
+ [0, 0],
+ [2, 11],
+ ]);
+ assertShiftPivot(5);
+ assertCurrentIndex(5);
+
+ // ambiguous consume with merge
+ sel.rangedSelect(0, 5, true);
+ tree.assertInvalidatedRows(createRangeArray(0, 11)); // 1
+ assertSelectionRanges([[0, 11]]);
+ assertShiftPivot(0);
+ assertCurrentIndex(5);
+
+ // aligned consumption
+ sel.rangedSelect(0, 15, true);
+ tree.assertInvalidatedRows(createRangeArray(0, 15)); // 12, 13, 14, 15
+ assertSelectionRanges([[0, 15]]);
+ assertShiftPivot(0);
+ assertCurrentIndex(15);
+
+ // excessive consumption
+ sel.rangedSelect(5, 7, false);
+ tree.assertInvalidatedRows(createRangeArray(0, 15)); // 0 to 4, 8 to 15
+ sel.rangedSelect(3, 10, true);
+ tree.assertInvalidatedRows([3, 4, 5, 6, 7, 8, 9, 10]); // 3, 4, 8, 9, 10
+ assertSelectionRanges([[3, 10]]);
+ assertShiftPivot(3);
+ assertCurrentIndex(10);
+
+ // overlap merge
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([3, 4, 5, 6, 7, 8, 9, 10]); // 3, 4
+ sel.rangedSelect(15, 20, true);
+ tree.assertInvalidatedRows([15, 16, 17, 18, 19, 20]);
+ sel.rangedSelect(7, 17, true);
+ tree.assertInvalidatedRows(createRangeArray(5, 20)); // 11, 12, 13, 14
+ assertSelectionRanges([[5, 20]]);
+ assertShiftPivot(7);
+ assertCurrentIndex(17);
+
+ // big merge and consume
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows(createRangeArray(5, 20)); // 11 to 20
+ sel.rangedSelect(15, 20, true);
+ tree.assertInvalidatedRows([15, 16, 17, 18, 19, 20]);
+ sel.rangedSelect(25, 30, true);
+ tree.assertInvalidatedRows([25, 26, 27, 28, 29, 30]);
+ sel.rangedSelect(35, 40, true);
+ tree.assertInvalidatedRows([35, 36, 37, 38, 39, 40]);
+ sel.rangedSelect(7, 37, true);
+ tree.assertInvalidatedRows(createRangeArray(5, 40)); // 11 to 14, 21 to 24, 31 to 34
+ assertSelectionRanges([[5, 40]]);
+ assertShiftPivot(7);
+ assertCurrentIndex(37);
+
+ // broad lower fusion
+ sel.rangedSelect(10, 20, false);
+ tree.assertInvalidatedRows(createRangeArray(5, 40)); // 5 to 9, 21 to 40
+ sel.rangedSelect(3, 15, true);
+ tree.assertInvalidatedRows(createRangeArray(3, 20)); // 3 to 9
+ assertSelectionRanges([[3, 20]]);
+ assertShiftPivot(3);
+ assertCurrentIndex(15);
+
+ // -- clearRange
+ sel.rangedSelect(10, 30, false);
+ tree.assertInvalidatedRows(createRangeArray(3, 30)); // 3 to 9, 21 to 30
+
+ // irrelevant low
+ sel.clearRange(0, 5);
+ tree.assertInvalidatedRows([]);
+ assertSelectionRanges([[10, 30]]);
+
+ // irrelevant high
+ sel.clearRange(40, 45);
+ tree.assertInvalidatedRows([]);
+ assertSelectionRanges([[10, 30]]);
+
+ // lower shrinkage tight
+ sel.clearRange(10, 10);
+ tree.assertInvalidatedRows([10]);
+ assertSelectionRanges([[11, 30]]);
+
+ // lower shrinkage broad
+ sel.clearRange(0, 13);
+ tree.assertInvalidatedRows(createRangeArray(0, 13)); // 11, 12, 13
+ assertSelectionRanges([[14, 30]]);
+
+ // upper shrinkage tight
+ sel.clearRange(30, 30);
+ tree.assertInvalidatedRows([30]);
+ assertSelectionRanges([[14, 29]]);
+
+ // upper shrinkage broad
+ sel.clearRange(27, 50);
+ tree.assertInvalidatedRows(createRangeArray(27, 50)); // 27, 28, 29
+ assertSelectionRanges([[14, 26]]);
+
+ // split tight
+ sel.clearRange(20, 20);
+ tree.assertInvalidatedRows([20]);
+ assertSelectionRanges([
+ [14, 19],
+ [21, 26],
+ ]);
+
+ // split broad
+ sel.toggleSelect(20);
+ tree.assertInvalidatedRows([20]);
+ sel.clearRange(19, 21);
+ tree.assertInvalidatedRows([19, 20, 21]);
+ assertSelectionRanges([
+ [14, 18],
+ [22, 26],
+ ]);
+
+ // hit two with tight shrinkage
+ sel.clearRange(18, 22);
+ tree.assertInvalidatedRows([18, 19, 20, 21, 22]); // 18, 22
+ assertSelectionRanges([
+ [14, 17],
+ [23, 26],
+ ]);
+
+ // hit two with broad shrinkage
+ sel.clearRange(15, 25);
+ tree.assertInvalidatedRows(createRangeArray(15, 25)); // 15, 16, 17, 23, 24, 25
+ assertSelectionRanges([
+ [14, 14],
+ [26, 26],
+ ]);
+
+ // obliterate
+ sel.clearRange(0, 100);
+ tree.assertInvalidatedRows(createRangeArray(0, 100)); // 14, 26
+ assertSelectionRanges([]);
+
+ // multi-obliterate
+ sel.rangedSelect(10, 20, true);
+ tree.assertInvalidatedRows(createRangeArray(10, 20));
+ sel.rangedSelect(30, 40, true);
+ tree.assertInvalidatedRows(createRangeArray(30, 40));
+ sel.clearRange(0, 100);
+ tree.assertInvalidatedRows(createRangeArray(0, 100)); // 10 to 20, 30 to 40
+ assertSelectionRanges([]);
+
+ // obliterate with shrinkage
+ sel.rangedSelect(5, 10, true);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.rangedSelect(15, 20, true);
+ tree.assertInvalidatedRows([15, 16, 17, 18, 19, 20]);
+ sel.rangedSelect(25, 30, true);
+ tree.assertInvalidatedRows([25, 26, 27, 28, 29, 30]);
+ sel.rangedSelect(35, 40, true);
+ tree.assertInvalidatedRows([35, 36, 37, 38, 39, 40]);
+ sel.clearRange(7, 37);
+ tree.assertInvalidatedRows(createRangeArray(7, 37)); // 7 to 10, 15 to 20, 25 to 30, 35 to 37
+ assertSelectionRanges([
+ [5, 6],
+ [38, 40],
+ ]);
+
+ // -- selectAll
+ sel.selectAll();
+ tree.assertInvalidated();
+ assertSelectionRanges([[0, 100]]);
+
+ // -- adjustSelection
+ // bump due to addition on simple select
+ sel.select(5);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ sel.adjustSelection(5, 1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([[6, 6]]);
+ assertCurrentIndex(6);
+
+ sel.select(5);
+ tree.assertInvalidatedRows([5, 6]);
+ sel.adjustSelection(0, 1);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[6, 6]]);
+ assertCurrentIndex(6);
+
+ // bump due to addition on ranged simple select
+ sel.rangedSelect(5, 5, false);
+ tree.assertInvalidatedRows([5, 6]);
+ sel.adjustSelection(5, 1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([[6, 6]]);
+ assertShiftPivot(6);
+ assertCurrentIndex(6);
+
+ sel.rangedSelect(5, 5, false);
+ tree.assertInvalidatedRows([5, 6]);
+ sel.adjustSelection(0, 1);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[6, 6]]);
+ assertShiftPivot(6);
+ assertCurrentIndex(6);
+
+ // bump due to addition on ranged select
+ sel.rangedSelect(5, 7, false);
+ tree.assertInvalidatedRows([5, 6, 7]);
+ sel.adjustSelection(5, 1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([[6, 8]]);
+ assertShiftPivot(6);
+ assertCurrentIndex(8);
+
+ // no-op with addition
+ sel.rangedSelect(0, 3, false);
+ tree.assertInvalidatedRows([0, 1, 2, 3, 6, 7, 8]);
+ sel.adjustSelection(10, 1);
+ tree.assertInvalidatedRows(createRangeArray(10, 100));
+ assertSelectionRanges([[0, 3]]);
+ assertShiftPivot(0);
+ assertCurrentIndex(3);
+
+ // split due to addition
+ sel.rangedSelect(5, 6, false);
+ tree.assertInvalidatedRows([0, 1, 2, 3, 5, 6]);
+ sel.adjustSelection(6, 1);
+ tree.assertInvalidatedRows(createRangeArray(6, 100));
+ assertSelectionRanges([
+ [5, 5],
+ [7, 7],
+ ]);
+ assertShiftPivot(5);
+ assertCurrentIndex(7);
+
+ // shift due to removal on simple select
+ sel.select(5);
+ tree.assertInvalidatedRows([5, 7]);
+ sel.adjustSelection(0, -1);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[4, 4]]);
+ assertCurrentIndex(4);
+
+ // shift due to removal on ranged simple select
+ sel.rangedSelect(5, 5, false);
+ tree.assertInvalidatedRows([4, 5]);
+ sel.adjustSelection(0, -1);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[4, 4]]);
+ assertShiftPivot(4);
+ assertCurrentIndex(4);
+
+ // nuked due to removal on simple select
+ sel.select(5);
+ tree.assertInvalidatedRows([4, 5]);
+ sel.adjustSelection(5, -1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([]);
+ assertCurrentIndex(-1);
+
+ // upper tight shrinkage due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(10, -1);
+ tree.assertInvalidatedRows(createRangeArray(10, 100));
+ assertSelectionRanges([[5, 9]]);
+ assertShiftPivot(5);
+ assertCurrentIndex(-1);
+
+ // upper broad shrinkage due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(6, -10);
+ tree.assertInvalidatedRows(createRangeArray(6, 100));
+ assertSelectionRanges([[5, 5]]);
+ assertShiftPivot(5);
+ assertCurrentIndex(-1);
+
+ // lower tight shrinkage due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(5, -1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([[5, 9]]);
+ assertShiftPivot(-1);
+ assertCurrentIndex(9);
+
+ // lower broad shrinkage due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(0, -10);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[0, 0]]);
+ assertShiftPivot(-1);
+ assertCurrentIndex(0);
+
+ // tight nuke due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([0, 5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(5, -6);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([]);
+ assertShiftPivot(-1);
+ assertCurrentIndex(-1);
+
+ // broad nuke due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(0, -20);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([]);
+ assertShiftPivot(-1);
+ assertCurrentIndex(-1);
+
+ // duplicateSelection (please keep this right at the end, as this modifies
+ // sel)
+ // no guarantees for the shift pivot yet, so don't test that
+ let oldSel = sel;
+ let newSel = new TreeSelection(null);
+ newSel.view = fakeView;
+ // multiple selections
+ oldSel.rangedSelect(1, 3, false);
+ oldSel.rangedSelect(5, 5, true);
+ oldSel.rangedSelect(10, 10, true);
+ oldSel.rangedSelect(6, 7, true);
+
+ oldSel.duplicateSelection(newSel);
+ // from now on we're only going to be checking newSel
+ sel = newSel;
+ assertSelectionRanges([
+ [1, 3],
+ [5, 7],
+ [10, 10],
+ ]);
+ assertCurrentIndex(7);
+
+ // single selection
+ oldSel.select(4);
+ oldSel.duplicateSelection(newSel);
+ assertSelectionRanges([[4, 4]]);
+ assertCurrentIndex(4);
+
+ // nothing selected
+ oldSel.clearSelection();
+ oldSel.duplicateSelection(newSel);
+ assertSelectionRanges([]);
+ assertCurrentIndex(4);
+}
diff --git a/comm/mail/base/test/unit/test_viewWrapper_imapFolder.js b/comm/mail/base/test/unit/test_viewWrapper_imapFolder.js
new file mode 100644
index 0000000000..5e91f587fc
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_imapFolder.js
@@ -0,0 +1,55 @@
+/* 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 DBViewWrapper against a single imap folder. Try and test all the
+ * features we can without having a fake newsgroup. (Some features are
+ * newsgroup specific.)
+ */
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "imap", offline: false });
+
+/**
+ * Create an empty folder, inject messages into it without triggering an
+ * updateFolder, sanity check that we believe there are no messages in the
+ * folder, then enter, making sure we immediately enter and that the view
+ * properly updates to reflect there being the right set of messages.
+ * (It will fail to update if the db change listener ended up detaching itself
+ * and not reattaching correctly when the updateFolder completes.)
+ */
+add_task(
+ async function test_enter_imap_folder_requiring_update_folder_immediately() {
+ // - create the folder and wait for the IMAP op to complete
+ let folderHandle = await messageInjection.makeEmptyFolder();
+ let msgFolder = messageInjection.getRealInjectionFolder(folderHandle);
+
+ // - add the messages
+ let [msgSet] = await messageInjection.makeNewSetsInFolders(
+ [folderHandle],
+ [{ count: 1 }],
+ true
+ );
+
+ let viewWrapper = make_view_wrapper();
+
+ // - make sure we don't know about the message!
+ Assert.equal(msgFolder.getTotalMessages(false), 0);
+
+ // - sync open the folder, verify we claim we entered, and make sure it has
+ // nothing in it!
+ viewWrapper.listener.pendingLoad = true;
+ viewWrapper.open(msgFolder);
+ Assert.ok(viewWrapper._enteredFolder);
+ verify_empty_view(viewWrapper);
+
+ // Wait for all the messages to load.
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+
+ // - make sure the view sees the message though...
+ verify_messages_in_view(msgSet, viewWrapper);
+ }
+);
diff --git a/comm/mail/base/test/unit/test_viewWrapper_logic.js b/comm/mail/base/test/unit/test_viewWrapper_logic.js
new file mode 100644
index 0000000000..ef56cb3997
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_logic.js
@@ -0,0 +1,359 @@
+/* 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("../../../../mailnews/resources/abSetup.js");
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "local" });
+
+/**
+ * Verify that flipping between threading and grouped by sort settings properly
+ * clears the other flag. (Because they're mutually exclusive, you see.)
+ */
+add_task(async function test_threading_grouping_mutual_exclusion() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ await view_open(viewWrapper, folder);
+ // enter an update that will never conclude. this is fine.
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showThreaded = true;
+ assert_true(viewWrapper.showThreaded, "view should be threaded");
+ assert_false(
+ viewWrapper.showGroupedBySort,
+ "view should not be grouped by sort"
+ );
+
+ viewWrapper.showGroupedBySort = true;
+ assert_false(viewWrapper.showThreaded, "view should not be threaded");
+ assert_true(viewWrapper.showGroupedBySort, "view should be grouped by sort");
+});
+
+/**
+ * Verify that flipping between the "View... Threads..." menu cases supported by
+ * |showUnreadOnly| / |specialViewThreadsWithUnread| /
+ * |specialViewThreadsWithUnread| has them all be properly mutually exclusive.
+ */
+add_task(async function test_threads_special_views_mutual_exclusion() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ await view_open(viewWrapper, folder);
+ // enter an update that will never conclude. this is fine.
+ viewWrapper.beginViewUpdate();
+
+ // turn on the special view, make sure we think it took
+ viewWrapper.specialViewThreadsWithUnread = true;
+ Assert.ok(viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(!viewWrapper.specialViewWatchedThreadsWithUnread);
+
+ // hit showUnreadOnly which should already be false, so this makes sure that
+ // just writing to it forces the special view off.
+ viewWrapper.showUnreadOnly = false;
+ Assert.ok(!viewWrapper.showUnreadOnly);
+ Assert.ok(!viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(!viewWrapper.specialViewWatchedThreadsWithUnread);
+
+ // turn on the other special view
+ viewWrapper.specialViewWatchedThreadsWithUnread = true;
+ Assert.ok(!viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(viewWrapper.specialViewWatchedThreadsWithUnread);
+
+ // turn on show unread only mode, make sure special view is cleared
+ viewWrapper.showUnreadOnly = true;
+ Assert.ok(viewWrapper.showUnreadOnly);
+ Assert.ok(!viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(!viewWrapper.specialViewWatchedThreadsWithUnread);
+
+ // turn off show unread only mode just to make sure the transition happens
+ viewWrapper.showUnreadOnly = false;
+ Assert.ok(!viewWrapper.showUnreadOnly);
+ Assert.ok(!viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(!viewWrapper.specialViewWatchedThreadsWithUnread);
+});
+
+/**
+ * Do a quick test of primary sorting to make sure we're actually changing the
+ * sort order. (However, we are not responsible for verifying correctness of
+ * the sort.)
+ */
+add_task(async function test_sort_primary() {
+ let viewWrapper = make_view_wrapper();
+ // we need to put messages in the folder or the sort logic doesn't actually
+ // save the sort state. (this is the C++ view's fault.)
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [{}]);
+
+ await view_open(viewWrapper, folder);
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byDate,
+ Ci.nsMsgViewSortOrder.ascending
+ );
+ assert_equals(
+ viewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType.byDate,
+ "sort should be by date",
+ true
+ );
+ assert_equals(
+ viewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sort order should be ascending",
+ true
+ );
+
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byAuthor,
+ Ci.nsMsgViewSortOrder.descending
+ );
+ assert_equals(
+ viewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType.byAuthor,
+ "sort should be by author",
+ true
+ );
+ assert_equals(
+ viewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "sort order should be descending",
+ true
+ );
+});
+
+/**
+ * Verify that we handle explicit secondary sorts correctly.
+ */
+add_task(async function test_sort_secondary_explicit() {
+ let viewWrapper = make_view_wrapper();
+ // we need to put messages in the folder or the sort logic doesn't actually
+ // save the sort state. (this is the C++ view's fault.)
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [{}]);
+
+ await view_open(viewWrapper, folder);
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byAuthor,
+ Ci.nsMsgViewSortOrder.ascending,
+ Ci.nsMsgViewSortType.bySubject,
+ Ci.nsMsgViewSortOrder.descending
+ );
+ // check once for what we just did, then again after refreshing to make
+ // sure the sort order 'stuck'
+ for (let i = 0; i < 2; i++) {
+ assert_equals(
+ viewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType.byAuthor,
+ "sort should be by author"
+ );
+ assert_equals(
+ viewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sort order should be ascending"
+ );
+ assert_equals(
+ viewWrapper.dbView.secondarySortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "secondary sort should be by subject"
+ );
+ assert_equals(
+ viewWrapper.dbView.secondarySortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "secondary sort order should be descending"
+ );
+ await view_refresh(viewWrapper);
+ }
+});
+
+/**
+ * Verify that we handle implicit secondary sorts correctly.
+ * An implicit secondary sort is when we sort by Y, then we sort by X, and it's
+ * okay to have the effective sort of [X, Y]. The UI has/wants this, so, uh,
+ * let's make sure we obey its assumptions unless we have gone and made the UI
+ * be explicit about these things. We can't simply depend on the view to do
+ * this for us. Why? Because we re-create the view all the bloody time.
+ */
+add_task(async function test_sort_secondary_implicit() {
+ let viewWrapper = make_view_wrapper();
+ // we need to put messages in the folder or the sort logic doesn't actually
+ // save the sort state. (this is the C++ view's fault.)
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [{}]);
+
+ await view_open(viewWrapper, folder);
+ viewWrapper.magicSort(
+ Ci.nsMsgViewSortType.bySubject,
+ Ci.nsMsgViewSortOrder.descending
+ );
+ viewWrapper.magicSort(
+ Ci.nsMsgViewSortType.byAuthor,
+ Ci.nsMsgViewSortOrder.ascending
+ );
+ // check once for what we just did, then again after refreshing to make
+ // sure the sort order 'stuck'
+ for (let i = 0; i < 2; i++) {
+ assert_equals(
+ viewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType.byAuthor,
+ "sort should be by author"
+ );
+ assert_equals(
+ viewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sort order should be ascending"
+ );
+ assert_equals(
+ viewWrapper.dbView.secondarySortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "secondary sort should be by subject"
+ );
+ assert_equals(
+ viewWrapper.dbView.secondarySortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "secondary sort order should be descending"
+ );
+ await view_refresh(viewWrapper);
+ }
+});
+
+/**
+ * Test that group-by-sort does not explode even if we try and get it to use
+ * sorts that are illegal for group-by-sort mode. It is important that we
+ * test both illegal primary sorts (fixed a while back) plus illegal
+ * secondary sorts (fixing now).
+ *
+ * Note: Sorting changes are synchronous, but toggling grouped by sort requires
+ * a view rebuild.
+ */
+add_task(async function test_sort_group_by_sort() {
+ let viewWrapper = make_view_wrapper();
+ // we need to put messages in the folder or the sort logic doesn't actually
+ // save the sort state. (this is the C++ view's fault.)
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [{}]);
+ await view_open(viewWrapper, folder);
+
+ // - start out by being in an illegal (for group-by-sort) sort mode and
+ // switch to group-by-sort.
+ // (sorting changes are synchronous)
+ viewWrapper.sort(Ci.nsMsgViewSortType.byId, Ci.nsMsgViewSortOrder.descending);
+ await view_group_by_sort(viewWrapper, true);
+
+ // there should have been no explosion, and we should have changed to date
+ assert_equals(
+ viewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.byDate,
+ "sort should have reset to date"
+ );
+
+ // - return to unthreaded, have an illegal secondary sort, go group-by-sort
+ await view_group_by_sort(viewWrapper, false);
+
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byDate,
+ Ci.nsMsgViewSortOrder.descending,
+ Ci.nsMsgViewSortType.byId,
+ Ci.nsMsgViewSortOrder.descending
+ );
+
+ await view_group_by_sort(viewWrapper, true);
+ // we should now only have a single sort type and it should be date
+ assert_equals(
+ viewWrapper._sort.length,
+ 1,
+ "we should only have one sort type active"
+ );
+ assert_equals(
+ viewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.byDate,
+ "remaining (primary) sort type should be date"
+ );
+
+ // - try and make group-by-sort sort by something illegal
+ // (we're still in group-by-sort mode)
+ viewWrapper.magicSort(
+ Ci.nsMsgViewSortType.byId,
+ Ci.nsMsgViewSortOrder.descending
+ );
+ assert_equals(
+ viewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.byDate,
+ "remaining (primary) sort type should be date"
+ );
+});
+
+/**
+ * Verify that mailview changes are properly persisted but that we only use them
+ * when the listener indicates we should use them (because the widget is
+ * presumably visible).
+ */
+add_task(async function test_mailviews_persistence() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // open the folder, ensure it is using the default mail view
+ await view_open(viewWrapper, folder);
+ Assert.equal(viewWrapper.mailViewIndex, MailViewConstants.kViewItemAll);
+
+ // set the view so as to be persisted
+ viewWrapper.setMailView(MailViewConstants.kViewItemUnread);
+ // ...but first make sure it took at all
+ Assert.equal(viewWrapper.mailViewIndex, MailViewConstants.kViewItemUnread);
+
+ // close, re-open and verify it took
+ viewWrapper.close();
+ await view_open(viewWrapper, folder);
+ Assert.equal(viewWrapper.mailViewIndex, MailViewConstants.kViewItemUnread);
+
+ // close, turn off the mailview usage indication by the listener...
+ viewWrapper.close();
+ gMockViewWrapperListener.shouldUseMailViews = false;
+ // ...open and verify that it did not take!
+ await view_open(viewWrapper, folder);
+ Assert.equal(viewWrapper.mailViewIndex, MailViewConstants.kViewItemAll);
+
+ // put the mailview setting back so other tests work
+ gMockViewWrapperListener.shouldUseMailViews = true;
+});
+
+/**
+ * Make sure:
+ * - View update depth increments / decrements as expected, and triggers a
+ * view rebuild when expected.
+ * - View update depth can't go below zero resulting in odd happenings.
+ * - That the view update depth is zeroed by a close so that we don't
+ * get into awkward states.
+ *
+ * @bug 498145
+ */
+add_task(function test_view_update_depth_logic() {
+ let viewWrapper = make_view_wrapper();
+
+ // create an instance-specific dummy method that counts calls t
+ // _applyViewChanges
+ let applyViewCount = 0;
+ viewWrapper._applyViewChanges = function () {
+ applyViewCount++;
+ };
+
+ // - view update depth basics
+ Assert.equal(viewWrapper._viewUpdateDepth, 0);
+ viewWrapper.beginViewUpdate();
+ Assert.equal(viewWrapper._viewUpdateDepth, 1);
+ viewWrapper.beginViewUpdate();
+ Assert.equal(viewWrapper._viewUpdateDepth, 2);
+ viewWrapper.endViewUpdate();
+ Assert.equal(applyViewCount, 0);
+ Assert.equal(viewWrapper._viewUpdateDepth, 1);
+ viewWrapper.endViewUpdate();
+ Assert.equal(applyViewCount, 1);
+ Assert.equal(viewWrapper._viewUpdateDepth, 0);
+
+ // - don't go below zero! (and don't trigger.)
+ applyViewCount = 0;
+ viewWrapper.endViewUpdate();
+ Assert.equal(applyViewCount, 0);
+ Assert.equal(viewWrapper._viewUpdateDepth, 0);
+
+ // - depth zeroed on clear
+ viewWrapper.beginViewUpdate();
+ viewWrapper.close(); // this does little else because there is nothing open
+ Assert.equal(viewWrapper._viewUpdateDepth, 0);
+});
diff --git a/comm/mail/base/test/unit/test_viewWrapper_realFolder.js b/comm/mail/base/test/unit/test_viewWrapper_realFolder.js
new file mode 100644
index 0000000000..fbcef1abe8
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_realFolder.js
@@ -0,0 +1,666 @@
+/* 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 DBViewWrapper against a single local folder. Try and test all the
+ * features we can without having a fake newsgroup. (Some features are
+ * newsgroup specific.)
+ */
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "local" });
+
+var { SyntheticMessageSet } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+/* ===== Real Folder, no features ===== */
+
+/**
+ * Open a pre-populated real folder, make sure all the messages show up.
+ */
+add_task(async function test_real_folder_load() {
+ let viewWrapper = make_view_wrapper();
+ let [[msgFolder], msgSet] = await messageInjection.makeFoldersWithSets(1, [
+ { count: 1 },
+ ]);
+ viewWrapper.open(msgFolder);
+ verify_messages_in_view(msgSet, viewWrapper);
+ Assert.ok("test ran to completion");
+});
+
+/**
+ * Open a real folder, add some messages, make sure they show up, remove some
+ * messages, make sure they go away.
+ */
+add_task(async function test_real_folder_update() {
+ let viewWrapper = make_view_wrapper();
+
+ // start with an empty folder
+ let msgFolder = await messageInjection.makeEmptyFolder();
+ viewWrapper.open(msgFolder);
+ verify_empty_view(viewWrapper);
+
+ // add messages (none -> some)
+ let [setOne] = await messageInjection.makeNewSetsInFolders([msgFolder], [{}]);
+ verify_messages_in_view(setOne, viewWrapper);
+
+ // add more messages! (some -> more)
+ let [setTwo] = await messageInjection.makeNewSetsInFolders([msgFolder], [{}]);
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+
+ // remove the first set of messages (more -> some)
+ await messageInjection.trashMessages(setOne);
+ verify_messages_in_view(setTwo, viewWrapper);
+
+ // remove the second set of messages (some -> none)
+ await messageInjection.trashMessages(setTwo);
+ verify_empty_view(viewWrapper);
+});
+
+/**
+ * Open a real folder, verify, open another folder, verify. We are testing
+ * ability to change folders without exploding.
+ */
+add_task(async function test_real_folder_load_after_real_folder_load() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], setOne] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+ viewWrapper.open(folderOne);
+ verify_messages_in_view(setOne, viewWrapper);
+
+ let [[folderTwo], setTwo] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+ viewWrapper.open(folderTwo);
+ verify_messages_in_view(setTwo, viewWrapper);
+});
+
+/* ===== Real Folder, Threading Modes ==== */
+/*
+ * The first three tests that verify setting the threading flags has the
+ * expected outcome do this by creating the view from scratch with the view
+ * flags applied. The view threading persistence test handles making sure
+ * that changes in threading on-the-fly work from the perspective of the
+ * bits and what not. None of these are tests of the view implementation's
+ * threading/grouping logic, just sanity checking that we are doing the right
+ * thing.
+ */
+
+add_task(async function test_real_folder_threading_unthreaded() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // create a single maximally nested thread.
+ const count = 10;
+ let messageSet = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ await messageInjection.addSetsToFolders([folder], [messageSet]);
+
+ // verify that we are not threaded (or grouped)
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showUnthreaded = true;
+ // whitebox test view flags (we've gotten them wrong before...)
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should not be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+ viewWrapper.endViewUpdate();
+ verify_view_level_histogram({ 0: count }, viewWrapper);
+});
+
+add_task(async function test_real_folder_threading_threaded() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // create a single maximally nested thread.
+ const count = 10;
+ let messageSet = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ await messageInjection.addSetsToFolders([folder], [messageSet]);
+
+ // verify that we are threaded (in such a way that we can't be grouped)
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showThreaded = true;
+ // whitebox test view flags (we've gotten them wrong before...)
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+ // expand everything so our logic below works.
+ view_expand_all(viewWrapper);
+ viewWrapper.endViewUpdate();
+ // blackbox test view flags: make sure IsContainer is true for the root
+ verify_view_row_at_index_is_container(viewWrapper, 0);
+ // do the histogram test to verify threading...
+ let expectedHisto = {};
+ for (let i = 0; i < count; i++) {
+ expectedHisto[i] = 1;
+ }
+ verify_view_level_histogram(expectedHisto, viewWrapper);
+});
+
+add_task(async function test_real_folder_threading_grouped_by_sort() {
+ let viewWrapper = make_view_wrapper();
+
+ // create some messages that belong to the 'in this week' bucket when sorting
+ // by date and grouping by date.
+ const count = 5;
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [
+ { count, age: { days: 2 }, age_incr: { mins: 1 } },
+ ]);
+
+ // group-by-sort sorted by date
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showGroupedBySort = true;
+ // whitebox test view flags (we've gotten them wrong before...)
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should be set."
+ );
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byDate,
+ Ci.nsMsgViewSortOrder.ascending
+ );
+ // expand everyone
+ view_expand_all(viewWrapper);
+ viewWrapper.endViewUpdate();
+
+ // make sure the level depths are correct
+ verify_view_level_histogram({ 0: 1, 1: count }, viewWrapper);
+ // and make sure the first dude is a dummy
+ verify_view_row_at_index_is_dummy(viewWrapper, 0);
+});
+
+/**
+ * Verify that we the threading modes are persisted. We are only checking
+ * flags here; we trust the previous tests to have done their job.
+ */
+add_task(async function test_real_folder_threading_persistence() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // create a single maximally nested thread.
+ const count = 10;
+ let messageSet = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ await messageInjection.addSetsToFolders([folder], [messageSet]);
+
+ // open the folder, set threaded mode, close it
+ viewWrapper.open(folder);
+ viewWrapper.showThreaded = true; // should be instantaneous
+ verify_view_row_at_index_is_container(viewWrapper, 0);
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+ viewWrapper.close();
+
+ // open it again, make sure we're threaded, go unthreaded, close
+ viewWrapper.open(folder);
+ assert_true(viewWrapper.showThreaded, "view should be threaded");
+ assert_false(viewWrapper.showUnthreaded, "view is lying about threading");
+ assert_false(viewWrapper.showGroupedBySort, "view is lying about threading");
+ verify_view_row_at_index_is_container(viewWrapper, 0);
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+
+ viewWrapper.showUnthreaded = true;
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should not be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+ viewWrapper.close();
+
+ // open it again, make sure we're unthreaded, go grouped, close
+ viewWrapper.open(folder);
+ assert_true(viewWrapper.showUnthreaded, "view should be unthreaded");
+ assert_false(viewWrapper.showThreaded, "view is lying about threading");
+ assert_false(viewWrapper.showGroupedBySort, "view is lying about threading");
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should not be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+
+ viewWrapper.showGroupedBySort = true;
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should be set."
+ );
+ viewWrapper.close();
+
+ // open it again, make sure we're grouped.
+ viewWrapper.open(folder);
+ assert_true(viewWrapper.showGroupedBySort, "view should be grouped");
+ assert_false(viewWrapper.showThreaded, "view is lying about threading");
+ assert_false(viewWrapper.showUnthreaded, "view is lying about threading");
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should be set."
+ );
+});
+
+/* ===== Real Folder, View Flags ===== */
+
+/*
+ * We cannot test the ignored flag for a local folder because we cannot ignore
+ * threads in a local folder. Only newsgroups can do that and that's not
+ * easily testable at this time.
+ * XXX ^^^ ignoring now works on mail as well.
+ */
+
+/**
+ * Test the kUnreadOnly flag usage. This functionality is equivalent to the
+ * mailview kViewItemUnread case, so it uses roughly the same test as
+ * test_real_folder_mail_views_unread.
+ */
+add_task(async function test_real_folder_flags_show_unread() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folder], setOne, setTwo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{}, {}]
+ );
+
+ // everything is unread to start with! #1
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showUnreadOnly = true;
+ viewWrapper.endViewUpdate();
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+
+ // add some more things (unread!), make sure they appear. #2
+ let [setThree] = await messageInjection.makeNewSetsInFolders([folder], [{}]);
+ verify_messages_in_view([setOne, setTwo, setThree], viewWrapper);
+
+ // make some things read, make sure they disappear. #3 (after refresh)
+ setTwo.setRead(true);
+ viewWrapper.refresh(); // refresh to get the messages to disappear
+
+ verify_messages_in_view([setOne, setThree], viewWrapper);
+
+ // make those things un-read again. #2
+ setTwo.setRead(false);
+ viewWrapper.refresh(); // QUICKSEARCH-VIEW-LIMITATION-REMOVE or not?
+ verify_messages_in_view([setOne, setTwo, setThree], viewWrapper);
+});
+
+/* ===== Real Folder, Mail Views ===== */
+
+/*
+ * For these tests, we are testing the filtering logic, not grouping or sorting
+ * logic. The view tests are responsible for that stuff. We test that:
+ *
+ * 1) The view is populated correctly on open.
+ * 2) The view adds things that become relevant.
+ * 3) The view removes things that are no longer relevant. Because views like
+ * to be stable (read: messages don't disappear as you look at them), this
+ * requires refreshing the view (unless the message has been deleted).
+ */
+
+/**
+ * Test the kViewItemUnread mail-view case. This functionality is equivalent
+ * to the kUnreadOnly view flag case, so it uses roughly the same test as
+ * test_real_folder_flags_show_unread.
+ */
+add_task(async function test_real_folder_mail_views_unread() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folder], setOne, setTwo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{}, {}]
+ );
+
+ // everything is unread to start with! #1
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView(MailViewConstants.kViewItemUnread, null);
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+
+ // add some more things (unread!), make sure they appear. #2
+ let [setThree] = await messageInjection.makeNewSetsInFolders([folder], [{}]);
+ verify_messages_in_view([setOne, setTwo, setThree], viewWrapper);
+
+ // make some things read, make sure they disappear. #3 (after refresh)
+ setTwo.setRead(true);
+ viewWrapper.refresh(); // refresh to get the messages to disappear
+ verify_messages_in_view([setOne, setThree], viewWrapper);
+
+ // make those things un-read again. #2
+ setTwo.setRead(false);
+ viewWrapper.refresh(); // QUICKSEARCH-VIEW-LIMITATION-REMOVE
+ verify_messages_in_view([setOne, setTwo, setThree], viewWrapper);
+});
+
+add_task(async function test_real_folder_mail_views_tags() {
+ let viewWrapper = make_view_wrapper();
+
+ // setup the initial set with the tag
+ let [[folder], setOne, setTwo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{}, {}]
+ );
+ setOne.addTag("$label1");
+
+ // open, apply mail view constraint, see those messages
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView(MailViewConstants.kViewItemTags, "$label1");
+ verify_messages_in_view(setOne, viewWrapper);
+
+ // add some more with the tag
+ setTwo.addTag("$label1");
+
+ // make sure they showed up
+ viewWrapper.refresh(); // QUICKSEARCH-VIEW-LIMITATION-REMOVE
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+
+ // remove them all
+ setOne.removeTag("$label1");
+ setTwo.removeTag("$label1");
+
+ // make sure they all disappeared. #3
+ viewWrapper.refresh();
+ verify_empty_view(viewWrapper);
+});
+
+/*
+add_task(async function test_real_folder_mail_views_not_deleted() {
+ // not sure how to test this in the absence of an IMAP account with the IMAP
+ // deletion model...
+});
+
+add_task(async function test_real_folder_mail_views_custom_people_i_know() {
+ // blurg. address book.
+});
+*/
+
+// recent mail = less than 1 day
+add_task(async function test_real_folder_mail_views_custom_recent_mail() {
+ let viewWrapper = make_view_wrapper();
+
+ // create a set that meets the threshold and a set that does not
+ let [[folder], setRecent] = await messageInjection.makeFoldersWithSets(1, [
+ { age: { mins: 0 } },
+ { age: { days: 2 }, age_incr: { mins: 1 } },
+ ]);
+
+ // open the folder, ensure only the recent guys show. #1
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView("Recent Mail", null);
+ verify_messages_in_view(setRecent, viewWrapper);
+
+ // add two more sets, one that meets, and one that doesn't. #2
+ let [setMoreRecent] = await messageInjection.makeNewSetsInFolders(
+ [folder],
+ [
+ { age: { mins: 0 } },
+ { age: { days: 2, hours: 1 }, age_incr: { mins: 1 } },
+ ]
+ );
+ // make sure that all we see is our previous recent set and our new recent set
+ verify_messages_in_view([setRecent, setMoreRecent], viewWrapper);
+
+ // we aren't going to mess with the system clock, so no #3.
+ // (we are assuming that the underlying code handles message deletion. also,
+ // we are taking the position that message timestamps should not change.)
+});
+
+add_task(async function test_real_folder_mail_views_custom_last_5_days() {
+ let viewWrapper = make_view_wrapper();
+
+ // create a set that meets the threshold and a set that does not
+ let [[folder], setRecent] = await messageInjection.makeFoldersWithSets(1, [
+ { age: { days: 2 }, age_incr: { mins: 1 } },
+ { age: { days: 6 }, age_incr: { mins: 1 } },
+ ]);
+
+ // open the folder, ensure only the recent guys show. #1
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView("Last 5 Days", null);
+ verify_messages_in_view(setRecent, viewWrapper);
+
+ // add two more sets, one that meets, and one that doesn't. #2
+ let [setMoreRecent] = await messageInjection.makeNewSetsInFolders(
+ [folder],
+ [
+ { age: { mins: 0 } },
+ { age: { days: 5, hours: 1 }, age_incr: { mins: 1 } },
+ ]
+ );
+ // make sure that all we see is our previous recent set and our new recent set
+ verify_messages_in_view([setRecent, setMoreRecent], viewWrapper);
+
+ // we aren't going to mess with the system clock, so no #3.
+ // (we are assuming that the underlying code handles message deletion. also,
+ // we are taking the position that message timestamps should not change.)
+});
+
+add_task(async function test_real_folder_mail_views_custom_not_junk() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folder], setJunk, setNotJunk] =
+ await messageInjection.makeFoldersWithSets(1, [{}, {}]);
+ setJunk.setJunk(true);
+ setNotJunk.setJunk(false);
+
+ // open, see non-junk messages. #1
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView("Not Junk", null);
+ verify_messages_in_view(setNotJunk, viewWrapper);
+
+ // add some more messages, have them be non-junk for now. #2
+ let [setFlippy] = await messageInjection.makeNewSetsInFolders([folder], [{}]);
+ setFlippy.setJunk(false);
+ viewWrapper.refresh(); // QUICKSEARCH-VIEW-LIMITATION-REMOVE
+ verify_messages_in_view([setNotJunk, setFlippy], viewWrapper);
+
+ // oops! they should be junk! #3
+ setFlippy.setJunk(true);
+ viewWrapper.refresh();
+ verify_messages_in_view(setNotJunk, viewWrapper);
+});
+
+add_task(async function test_real_folder_mail_views_custom_has_attachments() {
+ let viewWrapper = make_view_wrapper();
+
+ let attachSetDef = {
+ attachments: [
+ {
+ filename: "foo.png",
+ contentType: "image/png",
+ encoding: "base64",
+ charset: null,
+ body: "YWJj\n",
+ format: null,
+ },
+ ],
+ };
+ let noAttachSetDef = {};
+
+ let [[folder], , setAttach] = await messageInjection.makeFoldersWithSets(1, [
+ noAttachSetDef,
+ attachSetDef,
+ ]);
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView("Has Attachments", null);
+ verify_messages_in_view(setAttach, viewWrapper);
+
+ let [setMoreAttach] = await messageInjection.makeNewSetsInFolders(
+ [folder],
+ [attachSetDef, noAttachSetDef]
+ );
+ verify_messages_in_view([setAttach, setMoreAttach], viewWrapper);
+});
+
+/* ===== Real Folder, Special Views ===== */
+
+add_task(async function test_real_folder_special_views_threads_with_unread() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // create two maximally nested threads and add them to the folder.
+ const count = 10;
+ let setThreadOne = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ let setThreadTwo = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ await messageInjection.addSetsToFolders(
+ [folder],
+ [setThreadOne, setThreadTwo]
+ );
+
+ // open the view, set it to this special view
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.specialViewThreadsWithUnread = true;
+ view_expand_all(viewWrapper);
+ viewWrapper.endViewUpdate();
+
+ // no one is read at this point, make sure both threads show up.
+ verify_messages_in_view([setThreadOne, setThreadTwo], viewWrapper);
+
+ // mark both threads read, make sure they disappear (after a refresh)
+ setThreadOne.setRead(true);
+ setThreadTwo.setRead(true);
+ viewWrapper.refresh();
+ verify_empty_view(viewWrapper);
+
+ // make the first thread visible by marking his last message unread
+ setThreadOne.slice(-1).setRead(false);
+
+ view_expand_all(viewWrapper);
+ viewWrapper.refresh();
+ verify_messages_in_view(setThreadOne, viewWrapper);
+
+ // make the second thread visible by marking some message in the middle
+ setThreadTwo.slice(5, 6).setRead(false);
+ view_expand_all(viewWrapper);
+ viewWrapper.refresh();
+ verify_messages_in_view([setThreadOne, setThreadTwo], viewWrapper);
+});
+
+/**
+ * Make sure that we restore special views from their persisted state when
+ * opening the view.
+ */
+add_task(async function test_real_folder_special_views_persist() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.specialViewThreadsWithUnread = true;
+ viewWrapper.endViewUpdate();
+ viewWrapper.close();
+
+ viewWrapper.open(folder);
+ assert_true(
+ viewWrapper.specialViewThreadsWithUnread,
+ "We should be in threads-with-unread special view mode."
+ );
+});
+
+add_task(async function test_real_folder_mark_read_on_exit() {
+ // set a pref so that the local folders account will think we should
+ // mark messages read when leaving the folder.
+ Services.prefs.setBoolPref("mailnews.mark_message_read.none", true);
+
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+ viewWrapper.open(folder);
+
+ // add some unread messages.
+ let [setOne] = await messageInjection.makeNewSetsInFolders([folder], [{}]);
+ setOne.setRead(false);
+ // verify that we have unread messages.
+ assert_equals(
+ folder.getNumUnread(false),
+ setOne.synMessages.length,
+ "all messages should have been added as unread"
+ );
+ viewWrapper.close(false);
+ // verify that closing the view does the expected marking of the messages
+ // as read.
+ assert_equals(
+ folder.getNumUnread(false),
+ 0,
+ "messages should have been marked read on view close"
+ );
+ Services.prefs.clearUserPref("mailnews.mark_message_read.none");
+});
diff --git a/comm/mail/base/test/unit/test_viewWrapper_virtualFolder.js b/comm/mail/base/test/unit/test_viewWrapper_virtualFolder.js
new file mode 100644
index 0000000000..f33124b9d4
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_virtualFolder.js
@@ -0,0 +1,552 @@
+/* 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 DBViewWrapper against virtual folders.
+ *
+ * Things we do not test and our rationalizations:
+ * - threading stuff. This is not the view wrapper's problem. That is the db
+ * view's problem! (We test it in the real folder to make sure we are telling
+ * it to do things correctly.)
+ * - view flags. Again, it's a db view issue once we're sure we set the bits.
+ * - special view with threads. same deal.
+ *
+ * We could test all these things, but my patch is way behind schedule...
+ */
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "local" });
+
+// -- single-folder backed virtual folder
+
+/**
+ * Make sure we open a virtual folder backed by a single underlying folder
+ * correctly; no constraints.
+ */
+add_task(async function test_virtual_folder_single_load_no_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], setOne] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+
+ let virtFolder = messageInjection.makeVirtualFolder([folderOne], {});
+ await view_open(viewWrapper, virtFolder);
+
+ Assert.ok(viewWrapper.isVirtual);
+
+ assert_equals(
+ gMockViewWrapperListener.allMessagesLoadedEventCount,
+ 1,
+ "Should only have received a single all messages loaded notification!"
+ );
+
+ verify_messages_in_view(setOne, viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure we open a virtual folder backed by a single underlying folder
+ * correctly; one constraint.
+ */
+add_task(async function test_virtual_folder_single_load_simple_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], oneSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder([folderOne], {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view(oneSubjFoo, viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure we open a virtual folder backed by a single underlying folder
+ * correctly; two constraints ANDed together.
+ */
+add_task(async function test_virtual_folder_single_load_complex_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let whoBar = make_person_with_word_in_name("bar");
+
+ let [[folderOne], , , oneBoth] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, { from: whoBar }, { subject: "foo", from: whoBar }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(
+ [folderOne],
+ { subject: "foo", from: "bar" },
+ /* and? */ true
+ );
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view(oneBoth, viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Open a single-backed virtual folder, verify, open another single-backed
+ * virtual folder, verify. We are testing our ability to change folders
+ * without exploding.
+ */
+add_task(async function test_virtual_folder_single_load_after_load() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], oneSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+ let virtOne = messageInjection.makeVirtualFolder([folderOne], {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+
+ // use "bar" instead of "foo" to make sure constraints are properly changing
+ let [[folderTwo], twoSubjBar] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "bar" }, {}]
+ );
+ let virtTwo = messageInjection.makeVirtualFolder([folderTwo], {
+ subject: "bar",
+ });
+ await view_open(viewWrapper, virtTwo);
+ verify_messages_in_view([twoSubjBar], viewWrapper);
+ virtOne.parent.propagateDelete(virtOne, true);
+ virtTwo.parent.propagateDelete(virtTwo, true);
+});
+
+// -- multi-folder backed virtual folder
+
+/**
+ * Make sure we open a virtual folder backed by multiple underlying folders
+ * correctly; no constraints.
+ */
+add_task(async function test_virtual_folder_multi_load_no_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], setOne] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+ let [[folderTwo], setTwo] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+
+ let virtFolder = messageInjection.makeVirtualFolder(
+ [folderOne, folderTwo],
+ {}
+ );
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure the sort order of a virtual folder backed by multiple underlying
+ * folders is persistent.
+ */
+add_task(async function test_virtual_folder_multi_sortorder_persistence() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], setOne] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+ let [[folderTwo], setTwo] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+
+ let virtFolder = messageInjection.makeVirtualFolder(
+ [folderOne, folderTwo],
+ {}
+ );
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+ viewWrapper.showThreaded = true;
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.bySubject,
+ Ci.nsMsgViewSortOrder.ascending
+ );
+
+ viewWrapper.close();
+ await view_open(viewWrapper, virtFolder);
+ assert_equals(
+ viewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "should have remembered sort type."
+ );
+ assert_equals(
+ viewWrapper.primarySortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "should have remembered sort order."
+ );
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure we open a virtual folder backed by multiple underlying folders
+ * correctly; one constraint.
+ */
+add_task(async function test_virtual_folder_multi_load_simple_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], oneSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+ let [[folderTwo], twoSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder([folderOne, folderTwo], {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([oneSubjFoo, twoSubjFoo], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure we open a virtual folder backed by multiple underlying folders
+ * correctly; two constraints ANDed together.
+ */
+add_task(async function test_virtual_folder_multi_load_complex_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let whoBar = make_person_with_word_in_name("bar");
+
+ let [[folderOne], , , oneBoth] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, { from: whoBar }, { subject: "foo", from: whoBar }, {}]
+ );
+ let [[folderTwo], , , twoBoth] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, { from: whoBar }, { subject: "foo", from: whoBar }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(
+ [folderOne, folderTwo],
+ { subject: "foo", from: "bar" },
+ /* and? */ true
+ );
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([oneBoth, twoBoth], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+add_task(
+ async function test_virtual_folder_multi_load_alotta_folders_no_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ const folderCount = 4;
+ const messageCount = 64;
+
+ let [folders, setOne] = await messageInjection.makeFoldersWithSets(
+ folderCount,
+ [{ count: messageCount }]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {});
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([setOne], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+ }
+);
+
+add_task(
+ async function test_virtual_folder_multi_load_alotta_folders_simple_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ const folderCount = 16;
+ const messageCount = 256;
+
+ let [folders, setOne] = await messageInjection.makeFoldersWithSets(
+ folderCount,
+ [{ subject: "foo", count: messageCount }]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([setOne], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+ }
+);
+
+/**
+ * Make sure that opening a virtual folder backed by multiple real folders, then
+ * opening another virtual folder of the same variety works without explosions.
+ */
+add_task(async function test_virtual_folder_multi_load_after_load() {
+ let viewWrapper = make_view_wrapper();
+
+ let [foldersOne, oneSubjFoo] = await messageInjection.makeFoldersWithSets(2, [
+ { subject: "foo" },
+ {},
+ ]);
+ let virtOne = messageInjection.makeVirtualFolder(foldersOne, {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+
+ // use "bar" instead of "foo" to make sure constraints are properly changing
+ let [foldersTwo, twoSubjBar] = await messageInjection.makeFoldersWithSets(3, [
+ { subject: "bar" },
+ {},
+ ]);
+ let virtTwo = messageInjection.makeVirtualFolder(foldersTwo, {
+ subject: "bar",
+ });
+ await view_open(viewWrapper, virtTwo);
+ verify_messages_in_view([twoSubjBar], viewWrapper);
+
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+ virtOne.parent.propagateDelete(virtOne, true);
+ virtTwo.parent.propagateDelete(virtTwo, true);
+});
+
+// -- mixture of single-backed and multi-backed
+
+/**
+ * Make sure that opening a virtual folder backed by a single real folder, then
+ * a multi-backed one, then the single-backed one again doesn't explode.
+ *
+ * This is just test_virtual_folder_multi_load_after_load with foldersOne told
+ * to create just a single folder.
+ */
+add_task(async function test_virtual_folder_combo_load_after_load() {
+ let viewWrapper = make_view_wrapper();
+
+ let [foldersOne, oneSubjFoo] = await messageInjection.makeFoldersWithSets(1, [
+ { subject: "foo" },
+ {},
+ ]);
+ let virtOne = messageInjection.makeVirtualFolder(foldersOne, {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+
+ // use "bar" instead of "foo" to make sure constraints are properly changing
+ let [foldersTwo, twoSubjBar] = await messageInjection.makeFoldersWithSets(3, [
+ { subject: "bar" },
+ {},
+ ]);
+ let virtTwo = messageInjection.makeVirtualFolder(foldersTwo, {
+ subject: "bar",
+ });
+ await view_open(viewWrapper, virtTwo);
+ verify_messages_in_view([twoSubjBar], viewWrapper);
+
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+ virtOne.parent.propagateDelete(virtOne, true);
+ virtTwo.parent.propagateDelete(virtTwo, true);
+});
+
+// -- ignore things we should ignore
+
+/**
+ * Make sure that if a server is listed in a virtual folder's search Uris that
+ * it does not get into our list of _underlyingFolders.
+ */
+add_task(async function test_virtual_folder_filters_out_servers() {
+ let viewWrapper = make_view_wrapper();
+
+ let [folders] = await messageInjection.makeFoldersWithSets(2, []);
+ folders.push(folders[0].rootFolder);
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {});
+ await view_open(viewWrapper, virtFolder);
+
+ assert_equals(
+ viewWrapper._underlyingFolders.length,
+ 2,
+ "Server folder should have been filtered out."
+ );
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+// -- rare/edge cases!
+
+/**
+ * Verify that if one of the folders backing our virtual folder is deleted that
+ * we do not explode. Then verify that if we remove the rest of them that the
+ * view wrapper closes itself.
+ */
+add_task(async function test_virtual_folder_underlying_folder_deleted() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne]] = await messageInjection.makeFoldersWithSets(1, [
+ { subject: "foo" },
+ {},
+ ]);
+ let [[folderTwo], twoSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder([folderOne, folderTwo], {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtFolder);
+
+ // this triggers the search (under the view's hood), so it's async
+ await delete_folder(folderOne, viewWrapper);
+
+ // only messages from the surviving folder should be present
+ verify_messages_in_view([twoSubjFoo], viewWrapper);
+
+ // this one is not async though, because we are expecting to close the wrapper
+ // and ignore the view entirely, no resolving action.
+ delete_folder(folderTwo);
+
+ // now the view wrapper should have closed itself.
+ Assert.equal(null, viewWrapper.displayedFolder);
+ // This fails because virtFolder.parent is null, not sure why
+ // virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/* ===== Virtual Folder, Mail Views ===== */
+
+/*
+ * We do not need to test all of the mail view permutations, realFolder
+ * already did that. We just need to make sure it works at all.
+ */
+
+add_task(
+ async function test_virtual_folder_mail_views_unread_with_one_folder() {
+ let viewWrapper = make_view_wrapper();
+
+ let [folders, fooOne, fooTwo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo 1" }, { subject: "foo 2" }, {}, {}]
+ );
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {
+ subject: "foo",
+ });
+
+ // everything is unread to start with!
+ await view_open(viewWrapper, virtFolder);
+ await view_set_mail_view(viewWrapper, MailViewConstants.kViewItemUnread);
+ verify_messages_in_view([fooOne, fooTwo], viewWrapper);
+
+ // add some more things (unread!), make sure they appear.
+ let [fooThree] = await messageInjection.makeNewSetsInFolders(folders, [
+ { subject: "foo 3" },
+ {},
+ ]);
+ verify_messages_in_view([fooOne, fooTwo, fooThree], viewWrapper);
+
+ // make some things read, make sure they disappear. (after a refresh)
+ fooTwo.setRead(true);
+ await view_refresh(viewWrapper);
+ verify_messages_in_view([fooOne, fooThree], viewWrapper);
+
+ // make those things un-read again.
+ fooTwo.setRead(false);
+ // I thought this was a quick search limitation, but XFVF needs it to, at
+ // least for the unread case.
+ await view_refresh(viewWrapper);
+ verify_messages_in_view([fooOne, fooTwo, fooThree], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+ }
+);
+
+// -- mail views
+
+add_task(
+ async function test_virtual_folder_mail_views_unread_with_four_folders() {
+ let viewWrapper = make_view_wrapper();
+
+ let [folders, fooOne, fooTwo] = await messageInjection.makeFoldersWithSets(
+ 4,
+ [{ subject: "foo 1" }, { subject: "foo 2" }, {}, {}]
+ );
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {
+ subject: "foo",
+ });
+
+ // everything is unread to start with!
+ await view_open(viewWrapper, virtFolder);
+ await view_set_mail_view(viewWrapper, MailViewConstants.kViewItemUnread);
+ verify_messages_in_view([fooOne, fooTwo], viewWrapper);
+
+ // add some more things (unread!), make sure they appear.
+ let [fooThree] = await messageInjection.makeNewSetsInFolders(folders, [
+ { subject: "foo 3" },
+ {},
+ ]);
+ verify_messages_in_view([fooOne, fooTwo, fooThree], viewWrapper);
+
+ // make some things read, make sure they disappear. (after a refresh)
+ fooTwo.setRead(true);
+ await view_refresh(viewWrapper);
+ verify_messages_in_view([fooOne, fooThree], viewWrapper);
+
+ // make those things un-read again.
+ fooTwo.setRead(false);
+ // I thought this was a quick search limitation, but XFVF needs it to, at
+ // least for the unread case.
+ await view_refresh(viewWrapper);
+ verify_messages_in_view([fooOne, fooTwo, fooThree], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+ }
+);
+
+// This tests that clearing the new messages in a folder also clears the
+// new flag on saved search folders based on the real folder. This could be a
+// core view test, or a mozmill test, but I think the view wrapper stuff
+// is involved in some of the issues here, so this is a compromise.
+add_task(async function test_virtual_folder_mail_new_handling() {
+ let viewWrapper = make_view_wrapper();
+
+ let [folders] = await messageInjection.makeFoldersWithSets(1, [
+ { subject: "foo 1" },
+ { subject: "foo 2" },
+ ]);
+ let folder = folders[0];
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {
+ subject: "foo",
+ });
+
+ await view_open(viewWrapper, folder);
+
+ await messageInjection.makeNewSetsInFolders(folders, [
+ { subject: "foo 3" },
+ {},
+ ]);
+
+ if (!virtFolder.hasNewMessages) {
+ do_throw("saved search should have new messages!");
+ }
+
+ if (!folder.hasNewMessages) {
+ do_throw("folder should have new messages!");
+ }
+
+ viewWrapper.close();
+ folder.msgDatabase = null;
+ folder.clearNewMessages();
+ if (virtFolder.hasNewMessages) {
+ do_throw("saved search should not have new messages!");
+ }
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
diff --git a/comm/mail/base/test/unit/test_viewWrapper_virtualFolderCustomTerm.js b/comm/mail/base/test/unit/test_viewWrapper_virtualFolderCustomTerm.js
new file mode 100644
index 0000000000..29890db68b
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_virtualFolderCustomTerm.js
@@ -0,0 +1,65 @@
+/* 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 DBViewWrapper against a virtual folder with a custom search term.
+ *
+ * This test uses an imap message to specifically test the issues from
+ * bug 549336. The code is derived from test_viewWrapper_virtualFolder.js
+ *
+ * Original author: Kent James
+ */
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+initViewWrapperTestUtils({ mode: "imap", offline: false });
+
+/**
+ * A custom search term, that just does Subject Contains
+ */
+var gCustomSearchTermSubject = {
+ id: "mailnews@mozilla.org#test",
+ name: "Test-mailbase Subject",
+ getEnabled(scope, op) {
+ return true;
+ },
+ getAvailable(scope, op) {
+ return true;
+ },
+ getAvailableOperators(scope) {
+ return [Ci.nsMsgSearchOp.Contains];
+ },
+ match(aMsgHdr, aSearchValue, aSearchOp) {
+ return aMsgHdr.subject.includes(aSearchValue);
+ },
+ needsBody: false,
+};
+
+MailServices.filters.addCustomTerm(gCustomSearchTermSubject);
+
+/**
+ * Make sure we open a virtual folder backed by a single underlying folder
+ * correctly, with a custom search term.
+ */
+add_task(async function test_virtual_folder_single_load_custom_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], oneSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(folderOne, {
+ custom: "foo",
+ });
+
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view(oneSubjFoo, viewWrapper);
+});
diff --git a/comm/mail/base/test/unit/xpcshell.ini b/comm/mail/base/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..ad48cee56f
--- /dev/null
+++ b/comm/mail/base/test/unit/xpcshell.ini
@@ -0,0 +1,25 @@
+[DEFAULT]
+head = head_mailbase.js
+dupe-manifest =
+support-files = distribution.ini resources/*
+
+[test_alertHook.js]
+[test_attachmentChecker.js]
+[test_devtools_url.js]
+[test_emptyTrash_dbViewWrapper.js]
+run-sequentially = Avoid bustage.
+[test_viewWrapper_imapFolder.js]
+run-sequentially = Avoid bustage.
+[test_viewWrapper_logic.js]
+[test_viewWrapper_realFolder.js]
+skip-if = os == "mac" && !debug
+reason = osx shippable perma failures
+[test_viewWrapper_virtualFolder.js]
+[test_viewWrapper_virtualFolderCustomTerm.js]
+run-sequentially = Avoid bustage.
+[test_oauth_migration.js]
+[test_mailGlue_distribution.js]
+skip-if = os == 'win' && msix # MSIX has a distribution.ini and it's unwritable. Tests fail.
+[test_treeSelection.js]
+
+[include:xpcshell_maildir.ini]
diff --git a/comm/mail/base/test/unit/xpcshell_maildir.ini b/comm/mail/base/test/unit/xpcshell_maildir.ini
new file mode 100644
index 0000000000..511295fcbe
--- /dev/null
+++ b/comm/mail/base/test/unit/xpcshell_maildir.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head_mailbase_maildir.js
+dupe-manifest =
+run-sequentially =
+
+[test_viewWrapper_virtualFolder.js]
diff --git a/comm/mail/base/test/webextensions/.eslintrc.js b/comm/mail/base/test/webextensions/.eslintrc.js
new file mode 100644
index 0000000000..12effd2e27
--- /dev/null
+++ b/comm/mail/base/test/webextensions/.eslintrc.js
@@ -0,0 +1,13 @@
+"use strict";
+
+module.exports = {
+ extends: ["plugin:mozilla/browser-test"],
+
+ env: {
+ webextensions: true,
+ },
+
+ rules: {
+ "func-names": "off",
+ },
+};
diff --git a/comm/mail/base/test/webextensions/browser.ini b/comm/mail/base/test/webextensions/browser.ini
new file mode 100644
index 0000000000..5d08a06f8f
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser.ini
@@ -0,0 +1,41 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ toolkit.telemetry.testing.overrideProductsCheck=true
+subsuite = thunderbird
+support-files =
+ head.js
+ file_install_extensions.html
+ browser_webext_experiment.xpi
+ browser_webext_experiment_permissions.xpi
+ browser_webext_experiment_update1.xpi
+ browser_webext_experiment_update2.xpi
+ browser_webext_permissions.xpi
+ browser_webext_nopermissions.xpi
+ browser_webext_unsigned.xpi
+ browser_webext_update1.xpi
+ browser_webext_update2.xpi
+ browser_webext_update_icon1.xpi
+ browser_webext_update_icon2.xpi
+ browser_webext_update_perms1.xpi
+ browser_webext_update_perms2.xpi
+ browser_webext_update_origins1.xpi
+ browser_webext_update_origins2.xpi
+ browser_webext_update.json
+
+[browser_extension_install_experiment.js]
+[browser_extension_sideloading.js]
+[browser_extension_update_background.js]
+[browser_extension_update_background_noprompt.js]
+[browser_permissions_installTrigger.js]
+[browser_permissions_local_file.js]
+[browser_permissions_mozAddonManager.js]
+[browser_permissions_optional.js]
+[browser_permissions_pointerevent.js]
+[browser_permissions_unsigned.js]
+[browser_update_checkForUpdates.js]
+[browser_update_interactive_noprompt.js]
diff --git a/comm/mail/base/test/webextensions/browser_extension_install_experiment.js b/comm/mail/base/test/webextensions/browser_extension_install_experiment.js
new file mode 100644
index 0000000000..d21d8bebce
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_extension_install_experiment.js
@@ -0,0 +1,82 @@
+"use strict";
+
+async function installFile(filename) {
+ const ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ let chromeUrl = Services.io.newURI(gTestPath);
+ let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl);
+ let file = fileUrl.QueryInterface(Ci.nsIFileURL).file;
+ file.leafName = filename;
+
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.afterOpenCallback = MockFilePicker.cleanup;
+
+ let { document } = await openAddonsMgr("addons://list/extension");
+
+ // Do the install...
+ await waitAboutAddonsViewLoaded(document);
+ let installButton = document.querySelector('[action="install-from-file"]');
+ installButton.click();
+}
+
+async function testExperimentPrompt(filename) {
+ let installPromise = new Promise(resolve => {
+ let listener = {
+ onDownloadCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onDownloadFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallEnded() {
+ AddonManager.removeInstallListener(listener);
+ resolve(true);
+ },
+
+ onInstallFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+
+ await installFile(filename);
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+ await checkNotification(
+ panel,
+ isDefaultIcon,
+ [["webext-perms-description-experiment"]],
+ false,
+ true
+ );
+ panel.secondaryButton.click();
+
+ let result = await installPromise;
+ ok(!result, "Installation was cancelled");
+ let addon = await AddonManager.getAddonByID(
+ "experiment_test@tests.mozilla.org"
+ );
+ is(addon, null, "Extension is not installed");
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+}
+
+add_task(async () => {
+ await testExperimentPrompt("browser_webext_experiment.xpi");
+ await testExperimentPrompt("browser_webext_experiment_permissions.xpi");
+});
diff --git a/comm/mail/base/test/webextensions/browser_extension_sideloading.js b/comm/mail/base/test/webextensions/browser_extension_sideloading.js
new file mode 100644
index 0000000000..eb0754a922
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_extension_sideloading.js
@@ -0,0 +1,352 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+AddonTestUtils.hookAMTelemetryEvents();
+
+const kSideloaded = true;
+
+async function createWebExtension(details) {
+ let options = {
+ manifest: {
+ applications: { gecko: { id: details.id } },
+
+ name: details.name,
+
+ permissions: details.permissions,
+ },
+ };
+
+ if (details.iconURL) {
+ options.manifest.icons = { 64: details.iconURL };
+ }
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(options);
+
+ await AddonTestUtils.manuallyInstall(xpi);
+}
+
+function promiseEvent(eventEmitter, event) {
+ return new Promise(resolve => {
+ eventEmitter.once(event, resolve);
+ });
+}
+
+function getAddonElement(managerWindow, addonId) {
+ return BrowserTestUtils.waitForCondition(
+ () =>
+ managerWindow.document.querySelector(`addon-card[addon-id="${addonId}"]`),
+ `Found entry for sideload extension addon "${addonId}" in HTML about:addons`
+ );
+}
+
+function assertSideloadedAddonElementState(addonElement, pressed) {
+ const enableBtn = addonElement.querySelector('[action="toggle-disabled"]');
+ is(
+ enableBtn.pressed,
+ pressed,
+ `The enable button is ${!pressed ? " not " : ""} pressed`
+ );
+ is(enableBtn.localName, "moz-toggle", "The enable button is a toggle");
+}
+
+function clickEnableExtension(addonElement) {
+ addonElement.querySelector('[action="toggle-disabled"]').click();
+}
+
+add_task(async function test_sideloading() {
+ const DEFAULT_ICON_URL =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["xpinstall.signatures.required", false],
+ ["extensions.autoDisableScopes", 15],
+ ["extensions.ui.ignoreUnsigned", true],
+ ["extensions.allowPrivateBrowsingByDefault", false],
+ ],
+ });
+
+ const ID1 = "addon1@tests.mozilla.org";
+ await createWebExtension({
+ id: ID1,
+ name: "Test 1",
+ userDisabled: true,
+ permissions: ["accountsRead", "https://*/*"],
+ iconURL: "foo-icon.png",
+ });
+
+ const ID2 = "addon2@tests.mozilla.org";
+ await createWebExtension({
+ id: ID2,
+ name: "Test 2",
+ permissions: ["<all_urls>"],
+ });
+
+ const ID3 = "addon3@tests.mozilla.org";
+ await createWebExtension({
+ id: ID3,
+ name: "Test 3",
+ permissions: ["<all_urls>"],
+ });
+
+ testCleanup = async function () {
+ // clear out ExtensionsUI state about sideloaded extensions so
+ // subsequent tests don't get confused.
+ ExtensionsUI.sideloaded.clear();
+ ExtensionsUI.emit("change");
+ };
+
+ let changePromise = new Promise(resolve => {
+ ExtensionsUI.on("change", function listener() {
+ ExtensionsUI.off("change", listener);
+ resolve();
+ });
+ });
+ ExtensionsUI._checkForSideloaded();
+ await changePromise;
+
+ // Check for the addons badge on the hamburger menu
+ let menuButton = document.getElementById("button-appmenu");
+ is(
+ menuButton.getAttribute("badge-status"),
+ "addon-alert",
+ "Should have addon alert badge"
+ );
+
+ // Find the menu entries for sideloaded extensions
+ await gCUITestUtils.openMainMenu();
+
+ let addons = PanelUI.addonNotificationContainer;
+ is(
+ addons.children.length,
+ 3,
+ "Have 3 menu entries for sideloaded extensions"
+ );
+
+ info(
+ "Test disabling sideloaded addon 1 using the permission prompt secondary button"
+ );
+
+ // Click the first sideloaded extension
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addons.children[0].click();
+
+ // The click should hide the main menu. This is currently synchronous.
+ ok(PanelUI.panel.state != "open", "Main menu is closed or closing.");
+
+ let panel = await popupPromise;
+
+ // Check the contents of the notification, then choose "Cancel"
+ await checkNotification(
+ panel,
+ /\/foo-icon\.png$/,
+ [
+ ["webext-perms-host-description-all-urls"],
+ ["webext-perms-description-accountsRead"],
+ ],
+ kSideloaded
+ );
+
+ panel.secondaryButton.click();
+
+ let [addon1, addon2, addon3] = await AddonManager.getAddonsByIDs([
+ ID1,
+ ID2,
+ ID3,
+ ]);
+ ok(addon1.seen, "Addon should be marked as seen");
+ is(addon1.userDisabled, true, "Addon 1 should still be disabled");
+ is(addon2.userDisabled, true, "Addon 2 should still be disabled");
+ is(addon3.userDisabled, true, "Addon 3 should still be disabled");
+
+ // Should still have 2 entries in the hamburger menu
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(
+ addons.children.length,
+ 2,
+ "Have 2 menu entries for sideloaded extensions"
+ );
+
+ // Close the hamburger menu and go directly to the addons manager
+ await gCUITestUtils.hideMainMenu();
+
+ const VIEW = "addons://list/extension";
+ let win = await openAddonsMgr(VIEW);
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ // about:addons addon entry element.
+ const addonElement = await getAddonElement(win, ID2);
+
+ assertSideloadedAddonElementState(addonElement, false);
+
+ info("Test enabling sideloaded addon 2 from about:addons enable button");
+
+ // When clicking enable we should see the permissions notification
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ clickEnableExtension(addonElement);
+ panel = await popupPromise;
+ await checkNotification(
+ panel,
+ DEFAULT_ICON_URL,
+ [["webext-perms-host-description-all-urls"]],
+ kSideloaded
+ );
+
+ // Setup async test for post install notification on addon 2
+ popupPromise = promisePopupNotificationShown("addon-installed");
+
+ // Accept the permissions
+ panel.button.click();
+ await promiseEvent(ExtensionsUI, "change");
+
+ addon2 = await AddonManager.getAddonByID(ID2);
+ is(addon2.userDisabled, false, "Addon 2 should be enabled");
+ assertSideloadedAddonElementState(addonElement, true);
+
+ // Test post install notification on addon 2.
+ panel = await popupPromise;
+ panel.button.click();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+
+ // Should still have 1 entry in the hamburger menu
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have 1 menu entry for sideloaded extensions");
+
+ PanelUI.hide();
+
+ // Open the Add-Ons Manager
+ win = await openAddonsMgr(`addons://detail/${encodeURIComponent(ID3)}`);
+
+ info("Test enabling sideloaded addon 3 from app menu");
+ // Trigger addon 3 install as triggered from the app menu, to be able to cover the
+ // post install notification that should be triggered when the permission
+ // dialog is accepted from that flow.
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ ExtensionsUI.showSideloaded(tabmail, addon3);
+
+ panel = await popupPromise;
+ await checkNotification(
+ panel,
+ DEFAULT_ICON_URL,
+ [["webext-perms-host-description-all-urls"]],
+ kSideloaded
+ );
+
+ // Setup async test for post install notification on addon 3
+ popupPromise = promisePopupNotificationShown("addon-installed");
+
+ // Accept the permissions
+ panel.button.click();
+ await promiseEvent(ExtensionsUI, "change");
+
+ addon3 = await AddonManager.getAddonByID(ID3);
+ is(addon3.userDisabled, false, "Addon 3 should be enabled");
+
+ // Test post install notification on addon 3.
+ panel = await popupPromise;
+ panel.button.click();
+
+ isnot(
+ menuButton.getAttribute("badge-status"),
+ "addon-alert",
+ "Should no longer have addon alert badge"
+ );
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ for (let addon of [addon1, addon2, addon3]) {
+ await addon.uninstall();
+ }
+
+ tabmail.closeTab(tabmail.currentTabInfo);
+
+ // Assert that the expected AddonManager telemetry are being recorded.
+ const expectedExtra = { source: "app-profile", method: "sideload" };
+
+ const baseEvent = { object: "extension", extra: expectedExtra };
+ const createBaseEventAddon = n => ({
+ ...baseEvent,
+ value: `addon${n}@tests.mozilla.org`,
+ });
+ const getEventsForAddonId = (events, addonId) =>
+ events.filter(ev => ev.value === addonId);
+
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+
+ // Test telemetry events for addon1 (1 permission and 1 origin).
+ info("Test telemetry events collected for addon1");
+
+ const baseEventAddon1 = createBaseEventAddon(1);
+ const collectedEventsAddon1 = getEventsForAddonId(
+ amEvents,
+ baseEventAddon1.value
+ );
+ const expectedEventsAddon1 = [
+ {
+ ...baseEventAddon1,
+ method: "sideload_prompt",
+ extra: { ...expectedExtra, num_strings: "2" },
+ },
+ { ...baseEventAddon1, method: "uninstall" },
+ ];
+
+ let i = 0;
+ for (let event of collectedEventsAddon1) {
+ Assert.deepEqual(
+ event,
+ expectedEventsAddon1[i++],
+ "Got the expected telemetry event"
+ );
+ }
+
+ is(
+ collectedEventsAddon1.length,
+ expectedEventsAddon1.length,
+ "Got the expected number of telemetry events for addon1"
+ );
+
+ const baseEventAddon2 = createBaseEventAddon(2);
+ const collectedEventsAddon2 = getEventsForAddonId(
+ amEvents,
+ baseEventAddon2.value
+ );
+ const expectedEventsAddon2 = [
+ {
+ ...baseEventAddon2,
+ method: "sideload_prompt",
+ extra: { ...expectedExtra, num_strings: "1" },
+ },
+ { ...baseEventAddon2, method: "enable" },
+ { ...baseEventAddon2, method: "uninstall" },
+ ];
+
+ i = 0;
+ for (let event of collectedEventsAddon2) {
+ Assert.deepEqual(
+ event,
+ expectedEventsAddon2[i++],
+ "Got the expected telemetry event"
+ );
+ }
+
+ is(
+ collectedEventsAddon2.length,
+ expectedEventsAddon2.length,
+ "Got the expected number of telemetry events for addon2"
+ );
+});
diff --git a/comm/mail/base/test/webextensions/browser_extension_update_background.js b/comm/mail/base/test/webextensions/browser_extension_update_background.js
new file mode 100644
index 0000000000..5b5909711a
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_extension_update_background.js
@@ -0,0 +1,263 @@
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+AddonTestUtils.hookAMTelemetryEvents();
+
+const ID = "update2@tests.mozilla.org";
+const ID_ICON = "update_icon2@tests.mozilla.org";
+const ID_PERMS = "update_perms@tests.mozilla.org";
+const ID_EXPERIMENT = "experiment_update@test.mozilla.org";
+const FAKE_INSTALL_TELEMETRY_SOURCE = "fake-install-source";
+
+requestLongerTimeout(2);
+
+function promiseViewLoaded(tab, viewid) {
+ let win = tab.linkedBrowser.contentWindow;
+ if (
+ win.gViewController &&
+ !win.gViewController.isLoading &&
+ win.gViewController.currentViewId == viewid
+ ) {
+ return Promise.resolve();
+ }
+
+ return waitAboutAddonsViewLoaded(win.document);
+}
+
+function getBadgeStatus() {
+ let menuButton = document.getElementById("button-appmenu");
+ return menuButton.getAttribute("badge-status");
+}
+
+function promiseBadgeChange() {
+ return new Promise(resolve => {
+ let menuButton = document.getElementById("button-appmenu");
+ new MutationObserver((mutationsList, observer) => {
+ for (let mutation of mutationsList) {
+ if (mutation.attributeName == "badge-status") {
+ observer.disconnect();
+ resolve();
+ return;
+ }
+ }
+ }).observe(menuButton, {
+ attributes: true,
+ });
+ });
+}
+
+// Set some prefs that apply to all the tests in this file
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+ ],
+ });
+});
+
+// Helper function to test background updates.
+async function backgroundUpdateTest(url, id, checkIconFn) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Turn on background updates
+ ["extensions.update.enabled", true],
+
+ // Point updates to the local mochitest server
+ [
+ "extensions.update.background.url",
+ `${BASE}/browser_webext_update.json`,
+ ],
+ ],
+ });
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(url, {
+ source: FAKE_INSTALL_TELEMETRY_SOURCE,
+ });
+ let addonId = addon.id;
+
+ ok(addon, "Addon was installed");
+ is(getBadgeStatus(), "", "Should not start out with an addon alert badge");
+
+ // Trigger an update check and wait for the update for this addon
+ // to be downloaded.
+ let updatePromise = promiseInstallEvent(addon, "onDownloadEnded");
+ let badgePromise = promiseBadgeChange();
+
+ AddonManagerPrivate.backgroundUpdateCheck();
+ await Promise.all([updatePromise, badgePromise]);
+
+ is(getBadgeStatus(), "addon-alert", "Should have addon alert badge");
+
+ // Find the menu entry for the update
+ await gCUITestUtils.openMainMenu();
+
+ let addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have a menu entry for the update");
+
+ // Click the menu item
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addons.children[0].click();
+
+ // The click should hide the main menu. This is currently synchronous.
+ ok(PanelUI.panel.state != "open", "Main menu is closed or closing.");
+
+ // Wait for the permission prompt, check the contents
+ let panel = await popupPromise;
+ checkIconFn(panel.getAttribute("icon"));
+
+ // The original extension has 1 promptable permission and the new one
+ // has 2 (history and <all_urls>) plus 1 non-promptable permission (cookies).
+ // So we should only see the 1 new promptable permission in the notification.
+ let singlePermissionEl = document.getElementById(
+ "addon-webext-perm-single-entry"
+ );
+ ok(!singlePermissionEl.hidden, "Single permission entry is not hidden");
+ ok(singlePermissionEl.textContent, "Single permission entry text is set");
+
+ // Cancel the update.
+ panel.secondaryButton.click();
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "1.0", "Should still be running the old version");
+
+ // Alert badge and hamburger menu items should be gone
+ is(getBadgeStatus(), "", "Addon alert badge should be gone");
+
+ await gCUITestUtils.openMainMenu();
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 0, "Update menu entries should be gone");
+ await gCUITestUtils.hideMainMenu();
+
+ // Re-check for an update
+ updatePromise = promiseInstallEvent(addon, "onDownloadEnded");
+ badgePromise = promiseBadgeChange();
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ await Promise.all([updatePromise, badgePromise]);
+
+ is(getBadgeStatus(), "addon-alert", "Should have addon alert badge");
+
+ // Find the menu entry for the update
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have a menu entry for the update");
+
+ // Click the menu item
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addons.children[0].click();
+
+ // Wait for the permission prompt and accept it this time
+ updatePromise = waitForUpdate(addon);
+ panel = await popupPromise;
+ panel.button.click();
+
+ addon = await updatePromise;
+ is(addon.version, "2.0", "Should have upgraded to the new version");
+
+ is(getBadgeStatus(), "", "Addon alert badge should be gone");
+
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ // Test that the expected telemetry events have been recorded (and that they include the
+ // permission_prompt event).
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+ const updateEvents = amEvents
+ .filter(evt => evt.method === "update")
+ .map(evt => {
+ delete evt.value;
+ return evt;
+ });
+
+ Assert.deepEqual(
+ updateEvents.map(evt => evt.extra && evt.extra.step),
+ [
+ // First update (cancelled).
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "cancelled",
+ // Second update (completed).
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "completed",
+ ],
+ "Got the steps from the collected telemetry events"
+ );
+
+ const method = "update";
+ const object = "extension";
+ const baseExtra = {
+ addon_id: addonId,
+ source: FAKE_INSTALL_TELEMETRY_SOURCE,
+ step: "permissions_prompt",
+ updated_from: "app",
+ };
+
+ // Expect the telemetry events to have num_strings set to 1, as only the origin permissions is going
+ // to be listed in the permission prompt.
+ Assert.deepEqual(
+ updateEvents.filter(
+ evt => evt.extra && evt.extra.step === "permissions_prompt"
+ ),
+ [
+ { method, object, extra: { ...baseExtra, num_strings: "1" } },
+ { method, object, extra: { ...baseExtra, num_strings: "1" } },
+ ],
+ "Got the expected permission_prompts events"
+ );
+}
+
+function checkDefaultIcon(icon) {
+ is(
+ icon,
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg",
+ "Popup has the default extension icon"
+ );
+}
+
+add_task(() =>
+ backgroundUpdateTest(
+ `${BASE}/browser_webext_update1.xpi`,
+ ID,
+ checkDefaultIcon
+ )
+);
+
+function checkNonDefaultIcon(icon) {
+ // The icon should come from the extension, don't bother with the precise
+ // path, just make sure we've got a jar url pointing to the right path
+ // inside the jar.
+ ok(icon.startsWith("jar:file://"), "Icon is a jar url");
+ ok(icon.endsWith("/icon.png"), "Icon is icon.png inside a jar");
+}
+
+add_task(() =>
+ backgroundUpdateTest(
+ `${BASE}/browser_webext_update_icon1.xpi`,
+ ID_ICON,
+ checkNonDefaultIcon
+ )
+);
+
+// Check bug 1710359 did not introduce a loophole and a simple WebExtension being
+// upgraded to an Experiment prompts for the permission update.
+add_task(() =>
+ backgroundUpdateTest(
+ `${BASE}/browser_webext_experiment_update1.xpi`,
+ ID_EXPERIMENT,
+ checkDefaultIcon
+ )
+);
diff --git a/comm/mail/base/test/webextensions/browser_extension_update_background_noprompt.js b/comm/mail/base/test/webextensions/browser_extension_update_background_noprompt.js
new file mode 100644
index 0000000000..d0cb135368
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_extension_update_background_noprompt.js
@@ -0,0 +1,116 @@
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+AddonTestUtils.hookAMTelemetryEvents();
+
+const ID_PERMS = "update_perms@tests.mozilla.org";
+const ID_ORIGINS = "update_origins@tests.mozilla.org";
+const ID_EXPERIMENT = "experiment_test@tests.mozilla.org";
+
+function getBadgeStatus() {
+ let menuButton = document.getElementById("button-appmenu");
+ return menuButton.getAttribute("badge-status");
+}
+
+// Set some prefs that apply to all the tests in this file
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+ // Don't require the extensions to be signed
+ ["xpinstall.signatures.required", false],
+ ],
+ });
+});
+
+// Helper function to test an upgrade that should not show a prompt
+async function testNoPrompt(origUrl, id) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Turn on background updates
+ ["extensions.update.enabled", true],
+
+ // Point updates to the local mochitest server
+ [
+ "extensions.update.background.url",
+ `${BASE}/browser_webext_update.json`,
+ ],
+ ],
+ });
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(origUrl);
+
+ ok(addon, "Addon was installed");
+
+ let sawPopup = false;
+ PopupNotifications.panel.addEventListener(
+ "popupshown",
+ () => (sawPopup = true),
+ { once: true }
+ );
+
+ // Trigger an update check and wait for the update to be applied.
+ let updatePromise = waitForUpdate(addon);
+ AddonManagerPrivate.backgroundUpdateCheck();
+ await updatePromise;
+
+ // There should be no notifications about the update
+ is(getBadgeStatus(), "", "Should not have addon alert badge");
+
+ await gCUITestUtils.openMainMenu();
+ let addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 0, "Have 0 updates in the PanelUI menu");
+ await gCUITestUtils.hideMainMenu();
+
+ ok(!sawPopup, "Should not have seen permissions notification");
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "2.0", "Update should have applied");
+
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ // Test that the expected telemetry events have been recorded (and that they do not
+ // include the permission_prompt event).
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+ const updateEventsSteps = amEvents
+ .filter(evt => {
+ return evt.method === "update" && evt.extra && evt.extra.addon_id == id;
+ })
+ .map(evt => {
+ return evt.extra.step;
+ });
+
+ // Expect telemetry events related to a completed update with no permissions_prompt event.
+ Assert.deepEqual(
+ updateEventsSteps,
+ ["started", "download_started", "download_completed", "completed"],
+ "Got the steps from the collected telemetry events"
+ );
+}
+
+// Test that an update that adds new non-promptable permissions is just
+// applied without showing a notification dialog.
+add_task(() =>
+ testNoPrompt(`${BASE}/browser_webext_update_perms1.xpi`, ID_PERMS)
+);
+
+// Test that an update that narrows origin permissions is just applied without
+// showing a notification prompt
+add_task(() =>
+ testNoPrompt(`${BASE}/browser_webext_update_origins1.xpi`, ID_ORIGINS)
+);
+
+// Test that an Experiment is not prompting for additional permissions.
+add_task(() =>
+ testNoPrompt(`${BASE}/browser_webext_experiment.xpi`, ID_EXPERIMENT)
+);
diff --git a/comm/mail/base/test/webextensions/browser_permissions_installTrigger.js b/comm/mail/base/test/webextensions/browser_permissions_installTrigger.js
new file mode 100644
index 0000000000..37f8117ab3
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_installTrigger.js
@@ -0,0 +1,27 @@
+"use strict";
+
+const INSTALL_PAGE = `${BASE}/file_install_extensions.html`;
+
+async function installTrigger(filename) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+ let gBrowser = document.getElementById("tabmail");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, INSTALL_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [`${BASE}/${filename}`],
+ async function (url) {
+ content.wrappedJSObject.installTrigger(url);
+ }
+ );
+}
+
+add_task(() => testInstallMethod(installTrigger));
diff --git a/comm/mail/base/test/webextensions/browser_permissions_local_file.js b/comm/mail/base/test/webextensions/browser_permissions_local_file.js
new file mode 100644
index 0000000000..03cf35226c
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_local_file.js
@@ -0,0 +1,46 @@
+"use strict";
+
+async function installFile(filename) {
+ const ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ let chromeUrl = Services.io.newURI(gTestPath);
+ let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl);
+ let file = fileUrl.QueryInterface(Ci.nsIFileURL).file;
+ file.leafName = filename;
+
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.afterOpenCallback = MockFilePicker.cleanup;
+
+ let { document } = await openAddonsMgr("addons://list/extension");
+
+ // Do the install...
+ await waitAboutAddonsViewLoaded(document);
+ let installButton = document.querySelector('[action="install-from-file"]');
+ installButton.click();
+}
+
+add_task(async function test_install_extension_from_local_file() {
+ // Listen for the first installId so we can check it later.
+ let firstInstallId = null;
+ AddonManager.addInstallListener({
+ onNewInstall(install) {
+ firstInstallId = install.installId;
+ AddonManager.removeInstallListener(this);
+ },
+ });
+
+ // Install the add-ons.
+ await testInstallMethod(installFile);
+
+ // Check we got an installId.
+ ok(
+ firstInstallId != null && !isNaN(firstInstallId),
+ "There was an installId found"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+});
diff --git a/comm/mail/base/test/webextensions/browser_permissions_mozAddonManager.js b/comm/mail/base/test/webextensions/browser_permissions_mozAddonManager.js
new file mode 100644
index 0000000000..4f1a064760
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_mozAddonManager.js
@@ -0,0 +1,19 @@
+"use strict";
+
+const INSTALL_PAGE = `${BASE}/file_install_extensions.html`;
+
+async function installMozAM(filename) {
+ let browser = document.getElementById("tabmail").selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, INSTALL_PAGE);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await SpecialPowers.spawn(
+ browser,
+ [`${BASE}/${filename}`],
+ async function (url) {
+ await content.wrappedJSObject.installMozAM(url);
+ }
+ );
+}
+
+add_task(() => testInstallMethod(installMozAM));
diff --git a/comm/mail/base/test/webextensions/browser_permissions_optional.js b/comm/mail/base/test/webextensions/browser_permissions_optional.js
new file mode 100644
index 0000000000..750658a8fd
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_optional.js
@@ -0,0 +1,53 @@
+"use strict";
+add_task(async function test_request_permissions_without_prompt() {
+ async function pageScript() {
+ const NO_PROMPT_PERM = "activeTab";
+ window.addEventListener(
+ "keypress",
+ async () => {
+ let permGranted = await browser.permissions.request({
+ permissions: [NO_PROMPT_PERM],
+ });
+ browser.test.assertTrue(
+ permGranted,
+ `${NO_PROMPT_PERM} permission was granted.`
+ );
+ let perms = await browser.permissions.getAll();
+ browser.test.assertTrue(
+ perms.permissions.includes(NO_PROMPT_PERM),
+ `${NO_PROMPT_PERM} permission exists.`
+ );
+ browser.test.sendMessage("permsGranted");
+ },
+ { once: true }
+ );
+ browser.test.sendMessage("pageReady");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "page.html": `<html><head><script src="page.js"></script></head></html>`,
+ "page.js": pageScript,
+ },
+ manifest: {
+ optional_permissions: ["activeTab"],
+ },
+ });
+ await extension.startup();
+
+ let url = await extension.awaitMessage("ready");
+
+ let tab = openContentTab(url, undefined, null);
+ await extension.awaitMessage("pageReady");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await BrowserTestUtils.synthesizeMouseAtCenter(tab.browser, {}, tab.browser);
+ await BrowserTestUtils.synthesizeKey("a", {}, tab.browser);
+ await extension.awaitMessage("permsGranted");
+ await extension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
diff --git a/comm/mail/base/test/webextensions/browser_permissions_pointerevent.js b/comm/mail/base/test/webextensions/browser_permissions_pointerevent.js
new file mode 100644
index 0000000000..7f273dc79c
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_pointerevent.js
@@ -0,0 +1,65 @@
+/* -*- 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_pointerevent() {
+ async function contentScript() {
+ document.addEventListener("pointerdown", async e => {
+ browser.test.assertTrue(true, "Should receive pointerdown");
+ e.preventDefault();
+ });
+
+ document.addEventListener("mousedown", e => {
+ browser.test.assertTrue(true, "Should receive mousedown");
+ });
+
+ document.addEventListener("mouseup", e => {
+ browser.test.assertTrue(true, "Should receive mouseup");
+ });
+
+ document.addEventListener("pointerup", e => {
+ browser.test.assertTrue(true, "Should receive pointerup");
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("pageReady");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "page.html": `<html><head><script src="page.js"></script></head></html>`,
+ "page.js": contentScript,
+ },
+ });
+ await extension.startup();
+ await new Promise(resolve => {
+ SpecialPowers.pushPrefEnv(
+ { set: [["dom.w3c_pointer_events.enabled", true]] },
+ resolve
+ );
+ });
+ let url = await extension.awaitMessage("ready");
+ let tab = openContentTab(url, undefined, null);
+
+ await extension.awaitMessage("pageReady");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ tab.linkedBrowser.focus();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "html",
+ { type: "mousedown", button: 0 },
+ tab.linkedBrowser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "html",
+ { type: "mouseup", button: 0 },
+ tab.linkedBrowser
+ );
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
diff --git a/comm/mail/base/test/webextensions/browser_permissions_unsigned.js b/comm/mail/base/test/webextensions/browser_permissions_unsigned.js
new file mode 100644
index 0000000000..06f0b2aa14
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_unsigned.js
@@ -0,0 +1,49 @@
+"use strict";
+
+const ID = "permissions@test.mozilla.org";
+const WARNING_ICON = "chrome://browser/skin/warning.svg";
+
+add_task(async function test_unsigned() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ let testURI = makeURI("https://example.com/");
+ PermissionTestUtils.add(testURI, "install", Services.perms.ALLOW_ACTION);
+ registerCleanupFunction(() => PermissionTestUtils.remove(testURI, "install"));
+
+ let tab = openContentTab("about:blank");
+ BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ `${BASE}/file_install_extensions.html`
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [`${BASE}/browser_webext_unsigned.xpi`],
+ async function (url) {
+ content.wrappedJSObject.installTrigger(url);
+ }
+ );
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+
+ // cancel the install
+ let promise = promiseInstallEvent({ id: ID }, "onInstallCancelled");
+ panel.secondaryButton.click();
+ await promise;
+
+ let addon = await AddonManager.getAddonByID(ID);
+ is(addon, null, "Extension is not installed");
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
diff --git a/comm/mail/base/test/webextensions/browser_update_checkForUpdates.js b/comm/mail/base/test/webextensions/browser_update_checkForUpdates.js
new file mode 100644
index 0000000000..b902527cae
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_update_checkForUpdates.js
@@ -0,0 +1,17 @@
+// Invoke the "Check for Updates" menu item
+function checkAll(win) {
+ triggerPageOptionsAction(win, "check-for-updates");
+ return new Promise(resolve => {
+ let observer = {
+ observe(subject, topic, data) {
+ Services.obs.removeObserver(observer, "EM-update-check-finished");
+ resolve();
+ },
+ };
+ Services.obs.addObserver(observer, "EM-update-check-finished");
+ });
+}
+
+// Test "Check for Updates" with both auto-update settings
+add_task(() => interactiveUpdateTest(true, checkAll));
+add_task(() => interactiveUpdateTest(false, checkAll));
diff --git a/comm/mail/base/test/webextensions/browser_update_interactive_noprompt.js b/comm/mail/base/test/webextensions/browser_update_interactive_noprompt.js
new file mode 100644
index 0000000000..5d391de662
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_update_interactive_noprompt.js
@@ -0,0 +1,82 @@
+// Set some prefs that apply to all the tests in this file.
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server.
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+
+ // Don't require the extensions to be signed.
+ ["xpinstall.signatures.required", false],
+
+ // Point updates to the local mochitest server.
+ ["extensions.update.url", `${BASE}/browser_webext_update.json`],
+ ],
+ });
+});
+
+// Helper to test that an update of a given extension does not
+// generate any permission prompts.
+async function testUpdateNoPrompt(
+ filename,
+ id,
+ initialVersion = "1.0",
+ updateVersion = "2.0"
+) {
+ // Install initial version of the test extension
+ let addon = await promiseInstallAddon(`${BASE}/${filename}`);
+ ok(addon, "Addon was installed");
+ is(addon.version, initialVersion, "Version 1 of the addon is installed");
+
+ // Go to Extensions in about:addons
+ let win = await openAddonsMgr("addons://list/extension");
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ let sawPopup = false;
+ function popupListener() {
+ sawPopup = true;
+ }
+ PopupNotifications.panel.addEventListener("popupshown", popupListener);
+
+ // Trigger an update check, we should see the update get applied
+ let updatePromise = waitForUpdate(addon);
+ triggerPageOptionsAction(win, "check-for-updates");
+ await updatePromise;
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, updateVersion, "Should have upgraded");
+
+ ok(!sawPopup, "Should not have seen a permission notification");
+ PopupNotifications.panel.removeEventListener("popupshown", popupListener);
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+ await addon.uninstall();
+}
+
+// Test that we don't see a prompt when no new promptable permissions
+// are added.
+add_task(() =>
+ testUpdateNoPrompt(
+ "browser_webext_update_perms1.xpi",
+ "update_perms@tests.mozilla.org"
+ )
+);
+
+// Test that an update that narrows origin permissions is just applied without
+// showing a notification prompt.
+add_task(() =>
+ testUpdateNoPrompt(
+ "browser_webext_update_origins1.xpi",
+ "update_origins@tests.mozilla.org"
+ )
+);
+
+// Test that an Experiment is not prompting for additional permissions.
+add_task(() =>
+ testUpdateNoPrompt(
+ "browser_webext_experiment.xpi",
+ "experiment_test@tests.mozilla.org"
+ )
+);
diff --git a/comm/mail/base/test/webextensions/browser_webext_experiment.xpi b/comm/mail/base/test/webextensions/browser_webext_experiment.xpi
new file mode 100644
index 0000000000..982d5d6b25
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_experiment.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpi b/comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpi
new file mode 100644
index 0000000000..d659b876fe
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_experiment_update1.xpi b/comm/mail/base/test/webextensions/browser_webext_experiment_update1.xpi
new file mode 100644
index 0000000000..bb4a5fb008
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_experiment_update1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpi b/comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpi
new file mode 100644
index 0000000000..5f57efe3c2
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_nopermissions.xpi b/comm/mail/base/test/webextensions/browser_webext_nopermissions.xpi
new file mode 100644
index 0000000000..ab97d96a11
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_nopermissions.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_permissions.xpi b/comm/mail/base/test/webextensions/browser_webext_permissions.xpi
new file mode 100644
index 0000000000..307c25a839
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_permissions.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_unsigned.xpi b/comm/mail/base/test/webextensions/browser_webext_unsigned.xpi
new file mode 100644
index 0000000000..2ebc23b4fe
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_unsigned.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update.json b/comm/mail/base/test/webextensions/browser_webext_update.json
new file mode 100644
index 0000000000..e44372d50c
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update.json
@@ -0,0 +1,82 @@
+{
+ "addons": {
+ "update2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_update2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "update_icon2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_update_icon2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "update_perms@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_update_perms2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "update_origins@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_update_origins2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "experiment_test@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "experiment_update@test.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/comm/mail/base/test/webextensions/browser_webext_update1.xpi b/comm/mail/base/test/webextensions/browser_webext_update1.xpi
new file mode 100644
index 0000000000..79be90636c
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update2.xpi b/comm/mail/base/test/webextensions/browser_webext_update2.xpi
new file mode 100644
index 0000000000..d1a12cadca
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_icon1.xpi b/comm/mail/base/test/webextensions/browser_webext_update_icon1.xpi
new file mode 100644
index 0000000000..d3dcb3235d
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_icon1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_icon2.xpi b/comm/mail/base/test/webextensions/browser_webext_update_icon2.xpi
new file mode 100644
index 0000000000..5cd7a8cec4
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_icon2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_origins1.xpi b/comm/mail/base/test/webextensions/browser_webext_update_origins1.xpi
new file mode 100644
index 0000000000..2909f8e8fd
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_origins1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_origins2.xpi b/comm/mail/base/test/webextensions/browser_webext_update_origins2.xpi
new file mode 100644
index 0000000000..b1051affb1
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_origins2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_perms1.xpi b/comm/mail/base/test/webextensions/browser_webext_update_perms1.xpi
new file mode 100644
index 0000000000..f4942f9082
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_perms1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_perms2.xpi b/comm/mail/base/test/webextensions/browser_webext_update_perms2.xpi
new file mode 100644
index 0000000000..2c023edc9d
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_perms2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/file_install_extensions.html b/comm/mail/base/test/webextensions/file_install_extensions.html
new file mode 100644
index 0000000000..9dd8ae830d
--- /dev/null
+++ b/comm/mail/base/test/webextensions/file_install_extensions.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script type="text/javascript">
+function installMozAM(url) {
+ return navigator.mozAddonManager.createInstall({url})
+ .then(install => install.install());
+}
+
+function installTrigger(url) {
+ InstallTrigger.install({extension: url});
+}
+</script>
+</body>
+</html>
diff --git a/comm/mail/base/test/webextensions/head.js b/comm/mail/base/test/webextensions/head.js
new file mode 100644
index 0000000000..9fc4e05cbc
--- /dev/null
+++ b/comm/mail/base/test/webextensions/head.js
@@ -0,0 +1,632 @@
+/* globals openAddonsMgr, openContentTab */
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+});
+
+const BASE = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+
+const l10n = new Localization([
+ "toolkit/global/extensions.ftl",
+ "toolkit/global/extensionPermissions.ftl",
+ "messenger/extensionsUI.ftl",
+ "messenger/extensionPermissions.ftl",
+ "messenger/addonNotifications.ftl",
+ "branding/brand.ftl",
+]);
+
+var { CustomizableUITestUtils } = ChromeUtils.import(
+ "resource://testing-common/CustomizableUITestUtils.jsm"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+const { PermissionTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PermissionTestUtils.jsm"
+);
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ * The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ * Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name) {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(name);
+ if (!notification) {
+ return;
+ }
+
+ ok(notification, `${name} notification shown`);
+ ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ resolve(PopupNotifications.panel.firstElementChild);
+ }
+
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+/**
+ * Wait for a specific install event to fire for a given addon
+ *
+ * @param {AddonWrapper} addon
+ * The addon to watch for an event on
+ * @param {string}
+ * The name of the event to watch for (e.g., onInstallEnded)
+ *
+ * @returns {Promise}
+ * Resolves when the event triggers with the first argument
+ * to the event handler as the resolution value.
+ */
+function promiseInstallEvent(addon, event) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener[event] = (install, arg) => {
+ if (install.addon.id == addon.id) {
+ AddonManager.removeInstallListener(listener);
+ resolve(arg);
+ }
+ };
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+/**
+ * Install an (xpi packaged) extension
+ *
+ * @param {string} url
+ * URL of the .xpi file to install
+ * @param {object?} installTelemetryInfo
+ * an optional object that contains additional details used by the telemetry events.
+ *
+ * @returns {Promise}
+ * Resolves when the extension has been installed with the Addon
+ * object as the resolution value.
+ */
+async function promiseInstallAddon(url, telemetryInfo) {
+ let install = await AddonManager.getInstallForURL(url, { telemetryInfo });
+ install.install();
+
+ let addon = await new Promise(resolve => {
+ install.addListener({
+ onInstallEnded(_install, _addon) {
+ resolve(_addon);
+ },
+ });
+ });
+
+ if (addon.isWebExtension) {
+ await new Promise(resolve => {
+ function listener(event, extension) {
+ if (extension.id == addon.id) {
+ Management.off("ready", listener);
+ resolve();
+ }
+ }
+ Management.on("ready", listener);
+ });
+ }
+
+ return addon;
+}
+
+/**
+ * Wait for an update to the given webextension to complete.
+ * (This does not actually perform an update, it just watches for
+ * the events that occur as a result of an update.)
+ *
+ * @param {AddonWrapper} addon
+ * The addon to be updated.
+ *
+ * @returns {Promise}
+ * Resolves when the extension has ben updated.
+ */
+async function waitForUpdate(addon) {
+ let installPromise = promiseInstallEvent(addon, "onInstallEnded");
+ let readyPromise = new Promise(resolve => {
+ function listener(event, extension) {
+ if (extension.id == addon.id) {
+ Management.off("ready", listener);
+ resolve();
+ }
+ }
+ Management.on("ready", listener);
+ });
+
+ let [newAddon] = await Promise.all([installPromise, readyPromise]);
+ return newAddon;
+}
+
+function waitAboutAddonsViewLoaded(doc) {
+ return BrowserTestUtils.waitForEvent(doc, "view-loaded");
+}
+
+/**
+ * Trigger an action from the page options menu.
+ */
+function triggerPageOptionsAction(win, action) {
+ win.document.querySelector(`#page-options [action="${action}"]`).click();
+}
+
+function isDefaultIcon(icon) {
+ // These are basically the same icon, but code within webextensions
+ // generates references to the former and generic add-ons manager code
+ // generates referces to the latter.
+ return (
+ icon == "chrome://browser/content/extension.svg" ||
+ icon == "chrome://mozapps/skin/extensions/extensionGeneric.svg"
+ );
+}
+
+/**
+ * Check the contents of a permission popup notification
+ *
+ * @param {Window} panel
+ * The popup window.
+ * @param {string | RegExp | Function} checkIcon
+ * The icon expected to appear in the notification. If this is a
+ * string, it must match the icon url exactly. If it is a
+ * regular expression it is tested against the icon url, and if
+ * it is a function, it is called with the icon url and returns
+ * true if the url is correct.
+ * @param {Object[]} permissions
+ * The expected entries in the permissions list. Each element
+ * in this array is itself a 2-element array with the string key
+ * for the item (e.g., "webext-perms-description-foo") for permission foo
+ * and an optional formatting parameter.
+ * @param {boolean} sideloaded
+ * Whether the notification is for a sideloaded extenion.
+ * @param {boolean} [warning]
+ * Whether the experiments warning should be visible.
+ */
+async function checkNotification(
+ panel,
+ checkIcon,
+ permissions,
+ sideloaded,
+ warning = false
+) {
+ let icon = panel.getAttribute("icon");
+ let ul = document.getElementById("addon-webext-perm-list");
+ let singleDataEl = document.getElementById("addon-webext-perm-single-entry");
+ let experimentWarning = document.getElementById(
+ "addon-webext-experiment-warning"
+ );
+ let learnMoreLink = document.getElementById("addon-webext-perm-info");
+
+ if (checkIcon instanceof RegExp) {
+ ok(
+ checkIcon.test(icon),
+ `Notification icon is correct ${JSON.stringify(icon)} ~= ${checkIcon}`
+ );
+ } else if (typeof checkIcon == "function") {
+ ok(checkIcon(icon), "Notification icon is correct");
+ } else {
+ is(icon, checkIcon, "Notification icon is correct");
+ }
+
+ is(
+ learnMoreLink.hidden,
+ !permissions.length,
+ "Permissions learn more is hidden if there are no permissions"
+ );
+
+ if (!permissions.length) {
+ ok(ul.hidden, "Permissions list is hidden");
+ ok(singleDataEl.hidden, "Single permission data entry is hidden");
+ ok(
+ !(ul.childElementCount || singleDataEl.textContent),
+ "Permission list and single permission element have no entries"
+ );
+ } else if (permissions.length === 1) {
+ ok(ul.hidden, "Permissions list is hidden");
+ ok(!ul.childElementCount, "Permission list has no entries");
+ ok(singleDataEl.textContent, "Single permission data label has been set");
+ } else {
+ ok(singleDataEl.hidden, "Single permission data entry is hidden");
+ ok(
+ !singleDataEl.textContent,
+ "Single permission data label has not been set"
+ );
+ }
+
+ if (warning) {
+ is(experimentWarning.hidden, false, "Experiments warning is visible");
+ } else {
+ is(experimentWarning.hidden, true, "Experiments warning is hidden");
+ }
+}
+
+/**
+ * Test that install-time permission prompts work for a given
+ * installation method.
+ *
+ * @param {Function} installFn
+ * Callable that takes the name of an xpi file to install and
+ * starts to install it. Should return a Promise that resolves
+ * when the install is finished or rejects if the install is canceled.
+ *
+ * @returns {Promise}
+ */
+async function testInstallMethod(installFn) {
+ const PERMS_XPI = "browser_webext_permissions.xpi";
+ const NO_PERMS_XPI = "browser_webext_nopermissions.xpi";
+ const ID = "permissions@test.mozilla.org";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+
+ let testURI = makeURI("https://example.com/");
+ PermissionTestUtils.add(testURI, "install", Services.perms.ALLOW_ACTION);
+ registerCleanupFunction(() => PermissionTestUtils.remove(testURI, "install"));
+
+ async function runOnce(filename, cancel) {
+ let tab = openContentTab("about:blank");
+ if (tab.browser.webProgress.isLoadingDocument) {
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ }
+
+ let installPromise = new Promise(resolve => {
+ let listener = {
+ onDownloadCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onDownloadFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallEnded() {
+ AddonManager.removeInstallListener(listener);
+ resolve(true);
+ },
+
+ onInstallFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+
+ let installMethodPromise = installFn(filename);
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+ if (filename == PERMS_XPI) {
+ // The icon should come from the extension, don't bother with the precise
+ // path, just make sure we've got a jar url pointing to the right path
+ // inside the jar.
+ await checkNotification(panel, /^jar:file:\/\/.*\/icon\.png$/, [
+ ["webext-perms-host-description-wildcard", "domain"],
+ ["webext-perms-host-description-one-site", "domain"],
+ ["webext-perms-description-nativeMessaging"],
+ // The below permissions are deliberately in this order as permissions
+ // are sorted alphabetically by the permission string to match AMO.
+ ["webext-perms-description-accountsRead"],
+ ["webext-perms-description-tabs"],
+ ]);
+ } else if (filename == NO_PERMS_XPI) {
+ await checkNotification(panel, isDefaultIcon, []);
+ }
+
+ if (cancel) {
+ panel.secondaryButton.click();
+ try {
+ await installMethodPromise;
+ } catch (err) {}
+ } else {
+ // Look for post-install notification
+ let postInstallPromise = promisePopupNotificationShown("addon-installed");
+ panel.button.click();
+
+ // Press OK on the post-install notification
+ panel = await postInstallPromise;
+ panel.button.click();
+
+ await installMethodPromise;
+ }
+
+ let result = await installPromise;
+ let addon = await AddonManager.getAddonByID(ID);
+ if (cancel) {
+ ok(!result, "Installation was cancelled");
+ is(addon, null, "Extension is not installed");
+ } else {
+ ok(result, "Installation completed");
+ isnot(addon, null, "Extension is installed");
+ await addon.uninstall();
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+ }
+
+ // A few different tests for each installation method:
+ // 1. Start installation of an extension that requests no permissions,
+ // verify the notification contents, then cancel the install
+ await runOnce(NO_PERMS_XPI, true);
+
+ // 2. Same as #1 but with an extension that requests some permissions.
+ await runOnce(PERMS_XPI, true);
+
+ // 3. Repeat with the same extension from step 2 but this time,
+ // accept the permissions to install the extension. (Then uninstall
+ // the extension to clean up.)
+ await runOnce(PERMS_XPI, false);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+// Helper function to test a specific scenario for interactive updates.
+// `checkFn` is a callable that triggers a check for updates.
+// `autoUpdate` specifies whether the test should be run with
+// updates applied automatically or not.
+async function interactiveUpdateTest(autoUpdate, checkFn) {
+ AddonTestUtils.initMochitest(this);
+
+ const ID = "update2@tests.mozilla.org";
+ const FAKE_INSTALL_SOURCE = "fake-install-source";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+
+ ["extensions.update.autoUpdateDefault", autoUpdate],
+
+ // Point updates to the local mochitest server
+ ["extensions.update.url", `${BASE}/browser_webext_update.json`],
+ ],
+ });
+
+ AddonTestUtils.hookAMTelemetryEvents();
+
+ // Trigger an update check, manually applying the update if we're testing
+ // without auto-update.
+ async function triggerUpdate(win, addon) {
+ let manualUpdatePromise;
+ if (!autoUpdate) {
+ manualUpdatePromise = new Promise(resolve => {
+ let listener = {
+ onNewInstall() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+ }
+
+ let promise = checkFn(win, addon);
+
+ if (manualUpdatePromise) {
+ await manualUpdatePromise;
+
+ let doc = win.document;
+ if (win.gViewController.currentViewId !== "addons://updates/available") {
+ let showUpdatesBtn = doc.querySelector("addon-updates-message").button;
+ await TestUtils.waitForCondition(() => {
+ return !showUpdatesBtn.hidden;
+ }, "Wait for show updates button");
+ let viewChanged = waitAboutAddonsViewLoaded(doc);
+ showUpdatesBtn.click();
+ await viewChanged;
+ }
+ let card = await TestUtils.waitForCondition(() => {
+ return doc.querySelector(`addon-card[addon-id="${ID}"]`);
+ }, `Wait addon card for "${ID}"`);
+ let updateBtn = card.querySelector('panel-item[action="install-update"]');
+ ok(updateBtn, `Found update button for "${ID}"`);
+ updateBtn.click();
+ }
+
+ return { promise };
+ }
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(`${BASE}/browser_webext_update1.xpi`, {
+ source: FAKE_INSTALL_SOURCE,
+ });
+ ok(addon, "Addon was installed");
+ is(addon.version, "1.0", "Version 1 of the addon is installed");
+
+ let win = await openAddonsMgr("addons://list/extension");
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ // Trigger an update check
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let { promise: checkPromise } = await triggerUpdate(win, addon);
+ let panel = await popupPromise;
+
+ // Click the cancel button, wait to see the cancel event
+ let cancelPromise = promiseInstallEvent(addon, "onInstallCancelled");
+ panel.secondaryButton.click();
+ await cancelPromise;
+
+ addon = await AddonManager.getAddonByID(ID);
+ is(addon.version, "1.0", "Should still be running the old version");
+
+ // Make sure the update check is completely finished.
+ await checkPromise;
+
+ // Trigger a new update check
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ checkPromise = (await triggerUpdate(win, addon)).promise;
+
+ // This time, accept the upgrade
+ let updatePromise = waitForUpdate(addon);
+ panel = await popupPromise;
+ panel.button.click();
+
+ addon = await updatePromise;
+ is(addon.version, "2.0", "Should have upgraded");
+
+ await checkPromise;
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ const collectedUpdateEvents = AddonTestUtils.getAMTelemetryEvents().filter(
+ evt => {
+ return evt.method === "update";
+ }
+ );
+
+ Assert.deepEqual(
+ collectedUpdateEvents.map(evt => evt.extra.step),
+ [
+ // First update is cancelled on the permission prompt.
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "cancelled",
+ // Second update is expected to be completed.
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "completed",
+ ],
+ "Got the expected sequence on update telemetry events"
+ );
+
+ ok(
+ collectedUpdateEvents.every(evt => evt.extra.addon_id === ID),
+ "Every update telemetry event should have the expected addon_id extra var"
+ );
+
+ ok(
+ collectedUpdateEvents.every(
+ evt => evt.extra.source === FAKE_INSTALL_SOURCE
+ ),
+ "Every update telemetry event should have the expected source extra var"
+ );
+
+ ok(
+ collectedUpdateEvents.every(evt => evt.extra.updated_from === "user"),
+ "Every update telemetry event should have the update_from extra var 'user'"
+ );
+
+ let hasPermissionsExtras = collectedUpdateEvents
+ .filter(evt => {
+ return evt.extra.step === "permissions_prompt";
+ })
+ .every(evt => {
+ return Number.isInteger(parseInt(evt.extra.num_strings, 10));
+ });
+
+ ok(
+ hasPermissionsExtras,
+ "Every 'permissions_prompt' update telemetry event should have the permissions extra vars"
+ );
+
+ let hasDownloadTimeExtras = collectedUpdateEvents
+ .filter(evt => {
+ return evt.extra.step === "download_completed";
+ })
+ .every(evt => {
+ const download_time = parseInt(evt.extra.download_time, 10);
+ return !isNaN(download_time) && download_time > 0;
+ });
+
+ ok(
+ hasDownloadTimeExtras,
+ "Every 'download_completed' update telemetry event should have a download_time extra vars"
+ );
+}
+
+// The tests in this directory install a bunch of extensions but they
+// need to uninstall them before exiting, as a stray leftover extension
+// after one test can foul up subsequent tests.
+// So, add a task to run before any tests that grabs a list of all the
+// add-ons that are pre-installed in the test environment and then checks
+// the list of installed add-ons at the end of the test to make sure no
+// new add-ons have been added.
+// Individual tests can store a cleanup function in the testCleanup global
+// to ensure it gets called before the final check is performed.
+let testCleanup;
+add_task(async function () {
+ let addons = await AddonManager.getAllAddons();
+ let existingAddons = new Set(addons.map(a => a.id));
+
+ registerCleanupFunction(async function () {
+ if (testCleanup) {
+ await testCleanup();
+ testCleanup = null;
+ }
+
+ for (let addon of await AddonManager.getAllAddons()) {
+ // Builtin search extensions may have been installed by SearchService
+ // during the test run, ignore those.
+ if (
+ !existingAddons.has(addon.id) &&
+ !(addon.isBuiltin && addon.id.endsWith("@search.mozilla.org"))
+ ) {
+ ok(
+ false,
+ `Addon ${addon.id} was left installed at the end of the test`
+ );
+ await addon.uninstall();
+ }
+ }
+ });
+});
+
+registerCleanupFunction(() => {
+ // The appmenu should be closed by the end of the test.
+ ok(PanelUI.panel.state == "closed", "Main menu is closed.");
+
+ // Any opened tabs should be closed by the end of the test.
+ let tabmail = document.getElementById("tabmail");
+ is(tabmail.tabInfo.length, 1, "All tabs are closed.");
+ tabmail.closeOtherTabs(0);
+});
+
+let collectedTelemetry = [];
+function hookExtensionsTelemetry() {
+ let originalHistogram = ExtensionsUI.histogram;
+ ExtensionsUI.histogram = {
+ add(value) {
+ collectedTelemetry.push(value);
+ },
+ };
+ registerCleanupFunction(() => {
+ is(
+ collectedTelemetry.length,
+ 0,
+ "No unexamined telemetry after test is finished"
+ );
+ ExtensionsUI.histogram = originalHistogram;
+ });
+}
+
+function expectTelemetry(values) {
+ Assert.deepEqual(values, collectedTelemetry);
+ collectedTelemetry = [];
+}
diff --git a/comm/mail/branding/branding-common.mozbuild b/comm/mail/branding/branding-common.mozbuild
new file mode 100644
index 0000000000..b6eea03cd7
--- /dev/null
+++ b/comm/mail/branding/branding-common.mozbuild
@@ -0,0 +1,38 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+
+@template
+def ThunderbirdBranding():
+ if CONFIG["MOZ_BRANDING_DIRECTORY"] == "comm/mail/branding/thunderbird":
+ JS_PREFERENCE_PP_FILES += [
+ "pref/thunderbird-branding.js",
+ ]
+ else:
+ JS_PREFERENCE_FILES += [
+ "pref/thunderbird-branding.js",
+ ]
+
+ if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ FINAL_TARGET_FILES += [
+ "thunderbird.VisualElementsManifest.xml",
+ ]
+ FINAL_TARGET_FILES.VisualElements += [
+ "VisualElements_150.png",
+ "VisualElements_70.png",
+ ]
+ elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ FINAL_TARGET_FILES.chrome.icons.default += [
+ "default128.png",
+ "default16.png",
+ "default22.png",
+ "default24.png",
+ "default256.png",
+ "default32.png",
+ "default48.png",
+ "default64.png",
+ "TB-symbolic.svg",
+ ]
diff --git a/comm/mail/branding/nightly/TB-symbolic.svg b/comm/mail/branding/nightly/TB-symbolic.svg
new file mode 100644
index 0000000000..1d7fde2f9e
--- /dev/null
+++ b/comm/mail/branding/nightly/TB-symbolic.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16px" height="16px" viewBox="0 0 128 128" fill="#000">
+ <path d="M56.7 111.7c-26.15-7-41.75-25.53-42.1-50.3 0-6.86.6-11.73 1.8-15.5C28.5 9.36 62.8-5.783 90.9 12.99c5.86 5.18 11.7 10.09 17 20.53 6.6 13.13 7.5 26.59 5.5 40.46-6 19.14-19.05 33.22-37.4 37.42-4.7 1.1-17.8 1.2-19.3.3zM41 72.33c-2.4-8.18-4.67-18.28 1-19.9 1.5-1.38 1.55-.79 2.3-.17.4.88-.2 1.64-1.4 1.95-1.4.37-1.9 1.38-1.9 3.85 0 3.97.8 5.36 3.5 6.19 1.5.47 2.4-.11 3.7-2.37 2.2-3.78 3.9-3.75 8.3.15 4.4 3.86 5.5 3.24 1.9-1.04-3-3.58-7.3-6.09-10.4-6.09-1.3 0-1.8-.43-1.4-1.11.4-.61.1-1.47-.6-1.92-1.1-.63-1-1.37.5-3.56 2.7-4.15 6.6-7.41 8.8-7.41 3.5 0 11.76-3.56 13.36-6.31 1.9-3.25 8.74-7.69 13.24-7.69 3.6 0 9.9-4.37 8-5.54-4.4-.43-5.34 2.53-8.56 2.61-5.9 0-8.64 2.36-13.34 7.07-2.7 2.67-5.5 4.86-6.2 4.86-2.88.71-5.36 3.27-7.2 1.4-.9-.83-1.6-2.1-1.6-2.77 0-1.89-2.4-6.61-3.4-6.62-.72 2.18.79 5.31 1.1 7.59.5 3.5.2 4.29-1.3 4.82-1.1.35-3.1 2.21-4.5 4.14l-2.7 3.49c-3.13-4.56-5.55-10.19-9.4-14.05-.34 5.8 5.07 8.16 7.2 15.93 0 .54-1.8 1.53-4 2.18-4.6 1.39-7.9 4.42-6.3 5.94.6.66 1.6.32 2.8-1.03 3.3-3.63 3.34.2 3.92 4.55-.29 5.76 2.63 15.06 6.08 18.26.83-.62-.4-3.58-1.5-7.4z"/>
+</svg>
diff --git a/comm/mail/branding/nightly/VisualElements_150.png b/comm/mail/branding/nightly/VisualElements_150.png
new file mode 100644
index 0000000000..9e7b3b9989
--- /dev/null
+++ b/comm/mail/branding/nightly/VisualElements_150.png
Binary files differ
diff --git a/comm/mail/branding/nightly/VisualElements_70.png b/comm/mail/branding/nightly/VisualElements_70.png
new file mode 100644
index 0000000000..ac16a6c6b4
--- /dev/null
+++ b/comm/mail/branding/nightly/VisualElements_70.png
Binary files differ
diff --git a/comm/mail/branding/nightly/background.png b/comm/mail/branding/nightly/background.png
new file mode 100644
index 0000000000..e572c14294
--- /dev/null
+++ b/comm/mail/branding/nightly/background.png
Binary files differ
diff --git a/comm/mail/branding/nightly/branding.nsi b/comm/mail/branding/nightly/branding.nsi
new file mode 100755
index 0000000000..96c7407e5c
--- /dev/null
+++ b/comm/mail/branding/nightly/branding.nsi
@@ -0,0 +1,16 @@
+# 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/.
+
+# NSIS defines for nightly builds.
+# The release build branding.nsi is located in other-license/branding/thunderbird/
+
+# BrandFullNameInternal is used for some registry and file system values
+# instead of BrandFullName and typically should not be modified.
+!define BrandFullNameInternal "Daily"
+!define BrandFullName "Thunderbird Daily"
+!define CompanyName "mozilla.org"
+!define URLInfoAbout "https://www.mozilla.org/"
+!define URLUpdateInfo "https://www.thunderbird.net/"
+!define URLSystemRequirements "https://www.thunderbird.net/thunderbird/system-requirements/"
+!define SurveyURL "https://live.thunderbird.net/survey/uninstall/?locale=${AB_CD}&version=${AppVersion}"
diff --git a/comm/mail/branding/nightly/configure.sh b/comm/mail/branding/nightly/configure.sh
new file mode 100644
index 0000000000..d482e65adf
--- /dev/null
+++ b/comm/mail/branding/nightly/configure.sh
@@ -0,0 +1,6 @@
+# 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/.
+
+MOZ_APP_DISPLAYNAME="Thunderbird Daily"
+MOZ_MACBUNDLE_ID="thunderbird-daily"
diff --git a/comm/mail/branding/nightly/content/about-background.png b/comm/mail/branding/nightly/content/about-background.png
new file mode 100644
index 0000000000..d7c4b4bdbe
--- /dev/null
+++ b/comm/mail/branding/nightly/content/about-background.png
Binary files differ
diff --git a/comm/mail/branding/nightly/content/about-logo.svg b/comm/mail/branding/nightly/content/about-logo.svg
new file mode 100644
index 0000000000..b0517e058e
--- /dev/null
+++ b/comm/mail/branding/nightly/content/about-logo.svg
@@ -0,0 +1,144 @@
+<!-- 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 width="210" height="205" version="1.1" viewBox="0 0 205.5 202.05" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <linearGradient id="SVGID_1_" x1="199.53" x2="39.679" y1="201.91" y2="42.053" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#130036" offset="0"/>
+ <stop stop-color="#18023B" offset=".2297"/>
+ <stop stop-color="#26094A" offset=".5122"/>
+ <stop stop-color="#3D1563" offset=".8211"/>
+ <stop stop-color="#4E1D75" offset="1"/>
+ </linearGradient>
+ <linearGradient id="SVGID_2_" x1="10.243" x2="57.704" y1="95.311" y2="95.311" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#3156A8" offset="0"/>
+ <stop stop-color="#3351A4" offset=".2474"/>
+ <stop stop-color="#3B4397" offset=".5365"/>
+ <stop stop-color="#472C82" offset=".8453"/>
+ <stop stop-color="#4E1D75" offset="1"/>
+ </linearGradient>
+ <radialGradient id="SVGID_3_" cx="59.073" cy="113.92" r="85.247" gradientTransform="matrix(1 0 0 1.45 0 -51.265)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#14CDDA" offset=".1654"/>
+ <stop stop-color="#2061BD" offset=".5478"/>
+ <stop stop-color="#2658AC" offset=".6546"/>
+ <stop stop-color="#373F81" offset=".864"/>
+ <stop stop-color="#432D62" offset="1"/>
+ </radialGradient>
+ <linearGradient id="SVGID_4_" x1="44.539" x2="191.52" y1="57.897" y2="57.897" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#2061BD" offset="0"/>
+ <stop stop-color="#2B51AC" offset=".1846"/>
+ <stop stop-color="#442C84" offset=".6826"/>
+ <stop stop-color="#4E1D75" offset=".9409"/>
+ </linearGradient>
+ <linearGradient id="SVGID_5_" x1="66.174" x2="167.26" y1="23.206" y2="111.08" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#48A8E0" offset=".0202"/>
+ <stop stop-color="#2061BD" offset=".3883"/>
+ <stop stop-color="#2B51AC" offset=".4968"/>
+ <stop stop-color="#442C84" offset=".7892"/>
+ <stop stop-color="#4E1D75" offset=".9409"/>
+ </linearGradient>
+ <linearGradient id="SVGID_6_" x1="19.676" x2="217.84" y1="337.41" y2="43.631" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#3156A8" offset=".3787"/>
+ <stop stop-color="#4E1D75" offset="1"/>
+ </linearGradient>
+ <linearGradient id="SVGID_7_" x1="96.745" x2="206.32" y1="278.65" y2="32.542" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#29ABE2" offset="0"/>
+ <stop stop-color="#385AA6" offset=".7733"/>
+ <stop stop-color="#414293" offset=".8575"/>
+ <stop stop-color="#4E1D75" offset="1"/>
+ </linearGradient>
+ <linearGradient id="SVGID_8_" x1="48.269" x2="54.241" y1="92.034" y2="95.468" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#B0DCD6" offset="0"/>
+ <stop stop-color="#53ACE0" offset="1"/>
+ </linearGradient>
+ <mask id="SVGID_9_-9" x="162.68" y="18.355" width="14.927" height="6.629" maskUnits="userSpaceOnUse">
+ <g class="st13" filter="url(#Adobe_OpacityMaskFilter-9)">
+ <radialGradient id="SVGID_10_-3" cx="154.38" cy="67.998" r="51.967" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#fff" offset=".868"/>
+ <stop offset="1"/>
+ </radialGradient>
+ <rect class="st14" x="91.295" y="-7.218" width="100.02" height="93.339" fill="url(#SVGID_10_-3)"/>
+ </g>
+ </mask>
+ <filter id="Adobe_OpacityMaskFilter-9" x="162.68" y="18.355" width="14.927" height="6.629" filterUnits="userSpaceOnUse">
+ <feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/>
+ </filter>
+ <linearGradient id="SVGID_11_" x1="176.21" x2="164.24" y1="23.085" y2="20.555" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#3092B9" offset="0"/>
+ <stop stop-color="#258DB6" offset=".2199"/>
+ <stop stop-color="#1685B1" offset=".6564"/>
+ <stop stop-color="#1082AF" offset="1"/>
+ </linearGradient>
+ <linearGradient id="SVGID_12_" x1="80.784" x2="90.637" y1="38.025" y2="77.544" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#1398D1" stop-opacity="0" offset=".0074"/>
+ <stop stop-color="#1187C2" stop-opacity=".6197" offset=".2482"/>
+ <stop stop-color="#3F6499" stop-opacity=".71" offset=".6422"/>
+ <stop stop-color="#2F4282" stop-opacity=".5" offset="1"/>
+ </linearGradient>
+ <linearGradient id="SVGID_13_" x1="48.738" x2="43.199" y1="14.373" y2="11.303" gradientTransform="matrix(.9994 .0349 -.0349 .9994 24.591 57.12)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#F9C21B" offset="0"/>
+ <stop stop-color="#F3BA1B" offset=".1479"/>
+ <stop stop-color="#E3A41B" offset=".3787"/>
+ <stop stop-color="#C9801C" offset=".6634"/>
+ <stop stop-color="#A44E1C" offset=".9884"/>
+ <stop stop-color="#A34C1C" offset="1"/>
+ </linearGradient>
+ <linearGradient id="SVGID_14_" x1="206.21" x2="169.43" y1="130.14" y2="47.526" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#409EC3" offset="0"/>
+ <stop stop-color="#2061BD" offset=".62"/>
+ </linearGradient>
+ <linearGradient id="SVGID_15_" x1="176.76" x2="150.41" y1="103.06" y2="21.954" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#14B2DA" offset="0"/>
+ <stop stop-color="#297CCC" offset=".4028"/>
+ <stop stop-color="#256FC5" offset=".5077"/>
+ <stop stop-color="#2164BF" offset=".6492"/>
+ <stop stop-color="#2061BD" offset=".8162"/>
+ <stop stop-color="#2061BD" offset=".9835"/>
+ </linearGradient>
+ <linearGradient id="linearGradient-16" x2="81.394" y1="81.394" gradientTransform="matrix(1.5502 0 0 -.65115 2.7804 53.553)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#ff0039" offset="0"/>
+ <stop stop-color="#d70022" offset="1"/>
+ </linearGradient>
+ </defs>
+ <g transform="translate(-143.81 -49.413)">
+ <g transform="matrix(.83212 0 0 .83212 143.81 49.413)">
+ <path class="st1" d="m188.89 35.492c-14.501-9.472-33.046-12.538-43.281-13.609-10.954-1.145-20.468-0.943-28.836 0.285-0.709-9e-3 -1.414-0.037-2.127-0.037-0.544 0-1.08 0.023-1.627 0.029 0.225-0.275 0.381-0.445 0.381-0.445s-0.44 0.069-1.304 0.46c-2.717 0.044-5.421 0.138-8.085 0.32 3.711-4.068 6.751-6.2 6.751-6.2s-3.608 0.528-10.192 6.496c-3.132 0.288-6.221 0.67-9.258 1.145 6.918-9.362 14.155-13.304 14.155-13.304s-8.772-1.786-22.507 12.016c-1.149 1.155-2.215 2.356-3.246 3.572-40.031 9.687-68.681 35.538-68.681 65.919 0 5.958-1.829 12.511 0.143 19.054-0.788 10.453 0.89 38.975 0.89 38.975s10.837 58.649 52.51 68.846c0.662 0.158-8.707-14.62-12.851-32.442 8.069 8.208 17.685 14.699 28.478 15.824 1.325 0.137-6.165-8.64-12.725-19.234l94.698 31.906c49.529-21.274 43.763-19.357 54.397-30.875 23.428-25.36 26.708-39.75 20.806-82.235-3.993-28.679-25.16-57.083-48.489-66.466z" fill="url(#SVGID_1_)"/>
+ <polygon class="st2" points="172.91 219.02 201.94 95.591 22.687 62.276 8.546 142.68 16.387 166.97" fill="#fbfbfb"/>
+ <path class="st3" d="m23.128 65.01c0.785 1.689 0.554 2.089 0.065 2.089-0.22 0-0.492-0.081-0.747-0.161-0.255-0.081-0.494-0.161-0.648-0.161-0.433 0-0.189 0.639 2.263 3.72 3.468 4.406 54.399 81.293 57.182 81.293 0.015 0 0.028-2e-3 0.04-6e-3 23.71-8.448 124.54-43.058 124.54-43.058l-10.316-15.029-172.38-28.687" fill="#999"/>
+ <path class="st4" d="m24.607 63.121s0.76 3.932 4.281 8.284c3.5 4.392 50.51 75.571 52.869 74.944 30.795-8.176 142.52-51.674 142.52-51.674z" fill="#f8f8f8"/>
+ <polygon class="st3" points="19.094 171.18 168.98 221.01 167.32 217.16 18.568 167.7" fill="#999"/>
+ <path class="st5" d="m57.704 59.795s-54.122 15.85-29.288 71.033c0 0-11.387-10.426-18.167-23.757-0.305-0.597 11.301-41.829 11.301-41.829z" fill="url(#SVGID_2_)"/>
+ <path class="st6" d="m28.416 130.83c-4.707-0.081-12.269-3.964-14.722-8.665-3.066 51.098 17.782 80.939 50.88 96.853-10.815-1.24-64.574-29.609-64.574-97.876 0-58.047 46.06-109.93 118.14-112.31 0.442 3.254-35.736 11.414-36.893 15.251-1.796 5.972-5.924 12.473-9.406 17.684-4.718 7.061 6.557 13.099-1.729 15.001-12.468 2.861-29.098 1.277-41.361 17.219-18.434 23.965-6.133 51.379-0.337 56.842z" fill="url(#SVGID_3_)"/>
+ <path class="st7" d="m137.05 10.699c-46.682-3.582-70.707 15.097-86.13 36.817-5.617 7.906-5.002 15.166-3.068 23.937 0.831 3.744 1.671 5.538 0.659 9.355-0.558 2.106-0.465 4.072-1.158 5.314-0.959 1.717-2.18 3.594-2.589 6.677-1.022 7.699 1.703 10.151 3.406 12.741 2.426-2.409 7.165-8.079 15.71-11.466s14.167-8.477 24.876-13.941c14.632-7.463 31.782 3.859 64.3-6.796 10.006-3.279 33.002-37.344 38.461-38.652-15.203-16.812-41.067-22.959-54.467-23.986z" fill="url(#SVGID_4_)"/>
+ <path class="st8" d="m106.21 0.032s-8.335 4.026-11.965 11.301c8.381-4.225 13.246-6.533 15.546-6.808 0 0-2.203 1.067-4.935 6.87 4.83-1.547 6.493-2.522 7.819-2.537 0 0-0.385 0.61-0.737 6.402-7.417-2.404-21.693 0.397-29.287 5.153-2.576-13.735 23.559-20.381 23.559-20.381z" fill="#3f6499"/>
+ <path class="st9" d="m139.38 8.63c-14.309-1.854-26.27-1.489-36.439 0.47 3.198-3.065 6.898-4.575 6.898-4.575-4.795 0.424-10.781 3.554-15.917 6.868-2.588 0.826-5.054 1.758-7.39 2.799 1.43-1.885 3.094-3.899 4.72-5.445 6.039-5.743 15.006-8.715 15.006-8.715-7.654-0.562-33.268 5.995-48.681 37.213-2.526 3.077-4.883 6.235-7.129 9.398-5.798 8.164-5.162 15.658-3.162 24.715 0.852 3.867 1.009 10.04 0.168 14.029-0.168 0.799-2.363 2.863-2.926 8.428-0.602 5.905 1.505 8.804 3.582 11.203 7.38-10.792 14.661-12.931 14.661-12.931 10.33-4.232 14.63-8.752 25.688-14.393 15.108-7.709 67.939 17.45 101.51 6.448 10.332-3.384-3.977-47.359 1.663-48.709-15.699-17.36-38.494-25.02-52.254-26.803z" fill="url(#SVGID_5_)"/>
+ <path class="st10" d="m244.18 103.29c-8.276-43.237-48.096-82.572-80.117-84.752-14.211-0.968-9.847 6.319-18.739 9.094-38.32 11.959-38.906 18.941-38.906 18.941 81.252 3.819 82.484 84.064 60.724 104.37 6.123-1.428 12.762-8.914 18.859-20.666-0.732 4.931-1.36 10.98-2.274 17.729-2.788 20.595 0.823 63.614-58.356 92.405 0 0 32.372-2.646 48.425-20.717-6.528 15.012-24.441 23.121-24.441 23.121 13.182-1.928 47.81-12.146 69.868-39.732 24.966-31.225 32.474-60.519 24.957-99.789z" fill="url(#SVGID_6_)"/>
+ <path class="st11" d="m164.99 182.22s26.396-7.38 34.903-21.633c-1.032 16.619-16.107 33.662-16.107 33.662s22.038-4.46 31.436-19.968c-1.224 13.994-18.191 32.907-18.191 32.907 17.346-3.113 60.93-30.645 47.143-103.9-8.142-43.262-48.096-82.572-80.117-84.752-14.211-0.968-9.847 6.319-18.739 9.094-38.32 11.959-38.906 18.941-38.906 18.941 81.252 3.819 102.84 46.344 60.724 103.23 6.123-1.429 8.578-4.794 13.178-10.301-2e-3 0 2.084 21.78-15.324 42.72z" fill="url(#SVGID_7_)"/>
+ <path class="st12" d="m60.609 85.567s-4.719 3.301-8.136 2.46c-4.045-0.993-4.73-4.913-4.73-4.913-0.089 0.803-0.192 1.578-0.341 2.273-0.168 0.799-2.363 2.863-2.926 8.428-0.602 5.905 1.615 9.325 3.692 11.724 7.381-10.792 14.552-13.452 14.552-13.452-2.809-0.982-2.111-6.52-2.111-6.52z" fill="url(#SVGID_8_)"/>
+ <mask x="162.68" y="18.355" width="14.927" height="6.629" maskUnits="userSpaceOnUse">
+ <g class="st13" filter="url(#Adobe_OpacityMaskFilter-9)">
+ <rect class="st14" x="91.295" y="-7.218" width="100.02" height="93.339" fill="url(#SVGID_10_-3)"/>
+ </g>
+ </mask>
+ <g class="st15" mask="url(#SVGID_9_-9)">
+ <g class="st16" opacity=".6">
+ <g class="st17" opacity=".2">
+ <path class="st18" d="m163.51 18.434c1.106-0.287 5.621 0.204 8.584 1.533 2.964 1.329 8.073 3.679 3.986 4.701-4.088 1.022-6.438-0.613-8.482-2.351s-6.847-3.168-4.088-3.883z" fill="url(#SVGID_11_)"/>
+ </g>
+ </g>
+ </g>
+ <path class="st19" d="m61.051 84.921s25.888-42.662 49.292-54.175c2.173-1.093-32.405 9.191-46.32 24.55-8.271 9.129-3.891 27.002-2.972 29.625z" fill="url(#SVGID_12_)"/>
+ <path class="st20" d="m61.051 84.921c0.024-0.015 0.05-0.03 0.075-0.045 6.694-4.136 18.166-6.518 21.864-14.015 14.237-28.869 27.354-40.115 27.354-40.115-23.405 11.513-49.293 54.175-49.293 54.175z" fill="#f2f2f2" opacity=".1"/>
+ <path class="st21" d="m63.512 77.118s-4.666-7.159 2.053-13.687c3.546-3.44 8.919-1.529 9.54-0.91 2.644 2.622 0.982 8.156-1.398 11.288-1.345 1.764-5.259 4.51-10.195 3.309z" fill="#2f4282"/>
+ <path class="st22" d="m66.187 76.674s-3.224-4.949 1.422-9.459c2.446-2.376 6.161-1.056 6.592-0.63 1.826 1.812 0.676 5.637-0.968 7.8-0.929 1.223-3.635 3.119-7.046 2.289z" fill="url(#SVGID_13_)"/>
+ <path d="m72.623 71.393c-0.072 1.978-1.746 3.522-3.738 3.454-1.996-0.069-3.558-1.726-3.487-3.704 0.071-1.977 1.744-3.521 3.741-3.453 1.992 0.069 3.553 1.727 3.484 3.703z"/>
+ <circle class="st24" cx="66.725" cy="70.287" r="1.136" fill="#fff"/>
+ <path class="st23" d="m228.5 65.01c-17.721-26.976-31.761-33.831-31.761-33.831s0.645 19.65 10.484 29.128c1.124 1.083-11.774-8.432-11.774-8.432s-2.758 9.076 3.562 19.51c-1.836-2.439-3.051-3.363-3.051-3.363s-11.908 5.491-15.423 13.109c-1.815-3.426-3.176-5.444-3.176-5.444s-8.236 16.437-7.201 35.495c1.684 30.988-5.803 42.404-5.803 42.404s20.057-7.695 29.676-32.468c3.974 10.533-0.121 21.619-0.121 21.619s16.206-12.109 19.691-34.18c3.863 6.23 2.727 18.664 2.727 18.664s10.344-14.402 10.596-30.086c4.794 3.343 5.235 16.399 5.235 16.399s15.336-19.606-3.661-48.524z" fill="url(#SVGID_14_)"/>
+ <path class="st25" d="m211.28 46.158c-4.631-8.63-12.803-14.13-19.986-18.582-17.909-11.105-29.427-12.665-29.427-12.665s-16.528 3.074-13.689 7.672c0.146 0.236 0.487 0.535 0.968 0.877-14.498-6.678-22.722 8.677-22.722 8.677-8.284-0.503-18.371 3.575-21.312 14.499-0.305 1.134 3.711 0.496 5.777 0.945 12.079 2.626 23.45 8.32 28.675 11.134 12.154 6.548 19.365 17.451 23.528 25.629 5.037 9.894 7.094 27.304 7.094 27.304s13.253-18.205 10.086-27.552c5.249 3.26 6.285 14.891 6.285 14.891s8.835-13.838 6.223-25.891c6.815 4.153 7.042 12.601 7.042 12.601s6.939-9.631 3.803-25.427c6.858 5.453 8.293 12.517 8.293 12.517s5.606-14.993-0.638-26.629z" fill="url(#SVGID_15_)"/>
+ </g>
+ <g transform="matrix(.9017 0 0 .89858 227.68 178)">
+ <path d="m2.7804 53.553h112.04c7.8046 0 14.131-6.268 14.131-14v-39h-112.04c-7.8046 0-14.131 6.268-14.131 14z" fill="url(#linearGradient-16)" stroke-width="1.0047"/>
+ <path d="m39.193 26.827c0 3.8776-1.0928 6.8475-3.2784 8.9099-2.1856 2.0623-5.3417 3.0935-9.4683 3.0935h-6.6047v-23.556h7.3226c3.8075 0 6.7642 1.015 8.87 3.0451 2.1058 2.0301 3.1587 4.8658 3.1587 8.5071zm-5.137 0.12889c0-5.0592-2.2122-7.5887-6.6366-7.5887h-2.6323v15.339h2.1218c4.7647 0 7.1471-2.5832 7.1471-7.7498zm24.248 11.874-1.691-5.6069h-8.5031l-1.691 5.6069h-5.3284l8.2319-23.652h6.0463l8.2638 23.652zm-2.8716-9.796c-1.5634-5.0806-2.4435-7.9539-2.6403-8.6199-0.19676-0.66596-0.33768-1.1923-0.42276-1.579-0.35097 1.3749-1.356 4.7745-3.0152 10.199zm11.789 9.796v-23.556h4.9455v23.556zm11.47 0v-23.556h4.9455v19.431h9.4603v4.1246zm25.397-13.856 4.8658-9.6993h5.3284l-7.7373 14.388v9.1677h-4.9136v-9.0065l-7.7373-14.549h5.3603z" fill="#fff" stroke-width="1.0261"/>
+ </g>
+ </g>
+</svg>
diff --git a/comm/mail/branding/nightly/content/about-wordmark.svg b/comm/mail/branding/nightly/content/about-wordmark.svg
new file mode 100644
index 0000000000..3f3e44e1cd
--- /dev/null
+++ b/comm/mail/branding/nightly/content/about-wordmark.svg
@@ -0,0 +1,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" viewBox="0 0 789.1 90.78" width="333" height="48" fill="context-fill">
+ <path d="M584.208.18H569.31V78.17h16.998c15.469 0 33.517-7.58 33.517-39.388 0-32.374-17.475-38.6-35.618-38.6zm2.1 8.603c11.364 0 23.11 3.624 23.11 29.998 0 25.693-11.173 30.897-22.345 30.897h-7.926V8.783zM671.008 64.244V36.97c0-12.45-6.016-19.81-19.098-19.81-6.111 0-12.127 1.361-18.811 4.073l2.387 7.58c5.538-2.03 10.6-3.162 14.61-3.162 7.544 0 11.363 3.162 11.363 11.769v4.418h-8.307c-15.183 0-23.968 6.9-23.968 19.694 0 10.638 6.493 17.998 17.379 17.998 6.589 0 12.318-2.722 16.138-8.941 1.718 5.884 5.156 8.26 10.694 8.941l2.101-7.245c-2.769-1.13-4.488-2.827-4.488-8.041zm-22.344 7.475c-6.207 0-9.358-3.737-9.358-10.752 0-8.156 5.06-12.23 15.087-12.23h7.066v13.696c-3.055 6.23-7.162 9.286-12.795 9.286zM695.263-10.12c-3.915 0-6.59 3.057-6.59 7.132 0 3.961 2.675 7.017 6.59 7.017 4.01 0 6.684-3.056 6.684-7.017 0-4.075-2.674-7.131-6.684-7.131zm4.87 28.641h-9.55V78.17h9.55zM730.498 79.53c2.77 0 5.443-.796 7.544-2.042l-2.483-7.245c-1.05.45-2.196.68-3.533.68-2.482 0-3.437-1.58-3.437-4.753V-6.724l-9.454 1.245v71.88c0 8.48 4.393 13.13 11.363 13.13zM788.843 18.521h-9.836L764.78 70.59 750.17 18.52h-10.121l18.334 59.648h3.15c-3.055 9.276-6.206 13.925-17.187 15.956l1.05 8.156c14.992-1.81 21.39-11.098 25.21-23.777z"/>
+ <path d="M545 16.7c-2.6 0-4.7 2.18-4.7 4.87 0 2.7 2.1 4.84 4.7 4.84 2.8 0 4.9-2.14 4.9-4.84 0-2.69-2.1-4.87-4.9-4.87zm0 8.78c-2.1 0-3.7-1.63-3.7-3.91 0-2.27 1.6-3.94 3.7-3.94 2.3 0 3.8 1.67 3.8 3.94 0 2.28-1.5 3.91-3.8 3.91zm2.1-5.01c0-1.06-.7-1.59-2.3-1.59h-1.5v5.23h1.1V22.1h.5l1.1 2.01h1.3l-1.4-2.18c.7-.21 1.2-.7 1.2-1.46zm-2.7-.76h.5c.8 0 1.1.23 1.1.76 0 .57-.4.8-1 .8h-.6z"/>
+ <path d="M50.226 1.698H0v12.72h17.703v63.806h14.149V14.418h16.835zM85.557 17.849c-6.156 0-10.782 2.98-14.821 8.404V-5.379L57.072-3.721v81.945h13.664V37.638c2.789-5.081 5.97-8.072 9.812-8.072 3.367 0 5.587 1.876 5.587 8.63v40.028h13.664V35.537c0-11.063-5.41-17.688-14.242-17.688zM154.455 19.618h-13.617v41.025c-2.425 4.749-5.596 7.633-9.42 7.633-3.731 0-5.503-2.101-5.503-8.523V19.618h-13.711v42.13c0 11.277 4.85 18.245 14.27 18.245 6.809 0 11.845-3.098 15.483-9.616l.653 7.847h11.845zM196.427 17.849c-6.53 0-11.473 3.312-15.576 9.176l-1.026-7.407h-11.846v58.606h13.618V37.745c2.798-5.188 5.876-8.18 9.793-8.18 3.544 0 5.596 1.877 5.596 8.63v40.029h13.71V35.537c0-11.063-5.315-17.688-14.27-17.688zM254.067-5.49v29.192c-3.077-3.43-7.181-5.853-12.404-5.853-12.592 0-20.706 12.928-20.706 31.066 0 18.696 6.529 31.078 19.4 31.078 6.808 0 11.472-3.87 14.55-8.962l.84 7.193h12.031V-3.721zm-9.7 73.766c-5.503 0-9.14-4.653-9.14-19.36 0-13.712 4.01-19.457 9.98-19.457 3.73 0 6.342 2.1 8.86 5.52v26.21c-2.704 4.653-5.596 7.087-9.7 7.087zM324.393 47.597c0-18.696-8.58-29.748-23.038-29.748-14.923 0-23.13 13.71-23.13 31.399 0 18.363 8.487 30.745 24.902 30.745 7.928 0 14.27-3.313 19.307-7.965l-5.69-8.844c-4.383 3.537-8.02 5.092-12.404 5.092-6.529 0-11.1-3.324-12.125-14.601h31.898c.094-1.662.28-4.095.28-6.078zm-13.617-3.763h-18.561c.653-11.063 4.104-15.265 9.42-15.265 6.53 0 9.14 5.756 9.14 14.6zM360.582 17.956c-5.97 0-10.82 4.867-13.245 13.046l-1.212-11.384H334.28v58.606h13.617V47.918c1.865-9.39 4.757-14.707 11.006-14.707 1.679 0 2.985.332 4.477.782l2.332-15.158c-1.68-.547-3.265-.879-5.13-.879zM400.035 17.849c-5.783 0-10.82 3.087-13.99 8.072v-31.41l-13.618 1.768v81.945h12.125l.746-5.746c3.172 4.749 7.742 7.515 13.338 7.515 12.685 0 20.24-12.94 20.24-31.078 0-19.574-6.902-31.066-18.841-31.066zm-5.223 50.534c-3.638 0-6.622-2.326-8.767-6.303V36.973c2.331-4.416 5.502-7.514 9.42-7.514 5.41 0 9.14 4.202 9.14 19.456 0 14.269-4.01 19.468-9.793 19.468zM436.504-10.576c-4.944 0-8.301 3.98-8.301 9.178 0 5.197 3.357 9.179 8.3 9.179 4.85 0 8.302-3.982 8.302-9.179 0-5.198-3.451-9.178-8.301-9.178zm6.902 30.194h-13.711v58.606h13.71zM483.139 17.956c-5.97 0-10.82 4.867-13.245 13.046l-1.306-11.384h-11.845v58.606h13.71V47.918c1.866-9.39 4.664-14.707 11.007-14.707 1.679 0 2.984.332 4.477.782l2.331-15.158c-1.772-.547-3.264-.879-5.13-.879zM522.592-5.49v29.192c-3.078-3.43-7.182-5.853-12.405-5.853-12.592 0-20.706 12.928-20.706 31.066 0 18.696 6.622 31.078 19.4 31.078 6.809 0 11.566-3.87 14.644-8.962l.746 7.193h12.032V-3.721zm-9.7 73.766c-5.503 0-9.14-4.653-9.14-19.36 0-13.712 4.01-19.457 9.98-19.457 3.823 0 6.342 2.1 8.86 5.52v26.21c-2.705 4.653-5.596 7.087-9.7 7.087z"/>
+</svg>
diff --git a/comm/mail/branding/nightly/content/about.png b/comm/mail/branding/nightly/content/about.png
new file mode 100644
index 0000000000..38191f66e8
--- /dev/null
+++ b/comm/mail/branding/nightly/content/about.png
Binary files differ
diff --git a/comm/mail/branding/nightly/content/aboutDialog.css b/comm/mail/branding/nightly/content/aboutDialog.css
new file mode 100644
index 0000000000..ff5002d9c0
--- /dev/null
+++ b/comm/mail/branding/nightly/content/aboutDialog.css
@@ -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/. */
+
+#clientBox {
+ background-color: var(--client-box-background);
+}
+
+#leftBox {
+ background-image: url("chrome://branding/content/about-logo.svg");
+ background-repeat: no-repeat;
+ /* min-width and min-height create room for the logo */
+ min-width: 210px;
+ min-height: 210px;
+ margin-top: 20px;
+ margin-inline-start: 30px;
+}
+
+#rightBox {
+ background-size: 333px auto;
+}
+
+#supernova-logo {
+ height: 28px;
+ width: 86px;
+ margin-block-start: 12px;
+}
+
+#updateDeck > hbox > label:not([class="text-link"]) {
+ opacity: 0.6;
+}
+
+#trademark {
+ font-size: xx-small;
+ text-align: center;
+ opacity: 0.6;
+ margin-block: 10px;
+}
diff --git a/comm/mail/branding/nightly/content/logo-gradient.svg b/comm/mail/branding/nightly/content/logo-gradient.svg
new file mode 100644
index 0000000000..76a41ee462
--- /dev/null
+++ b/comm/mail/branding/nightly/content/logo-gradient.svg
@@ -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/. -->
+<svg fill="none" version="1.1" viewBox="0 0 526 526" xmlns="http://www.w3.org/2000/svg">
+ <path d="m399.24 84.613c-30.266-19.646-68.973-26.005-90.336-28.226-22.863-2.3748-42.72-1.9558-60.186 0.5912-1.48-0.0187-2.951-0.0768-4.439-0.0768-1.136 0-2.255 0.0477-3.396 0.0602 0.469-0.5704 0.795-0.923 0.795-0.923s-0.918 0.1431-2.722 0.9541c-5.671 0.0912-11.314 0.2862-16.875 0.6637 7.746-8.4374 14.091-12.859 14.091-12.859s-7.531 1.0951-21.273 13.473c-6.537 0.5973-12.984 1.3896-19.323 2.3748 14.439-19.418 29.544-27.594 29.544-27.594s-18.309-3.7043-46.976 24.922c-2.399 2.3956-4.624 4.8865-6.775 7.4086-83.553 20.092-143.35 73.708-143.35 136.72 0 12.357-3.8175 25.948 0.2984 39.519-1.6447 21.68 1.8576 80.837 1.8576 80.837s22.619 121.64 109.6 142.79c1.381 0.325-18.174-30.325-26.823-67.289 16.842 17.024 36.912 30.486 59.439 32.82 2.766 0.284-12.867-17.92-26.559-39.893l197.65 66.175c103.38-44.123 91.342-40.147 113.54-64.037 48.899-52.598 55.745-82.444 43.427-170.56-8.335-59.483-52.514-118.4-101.21-137.86z" clip-rule="evenodd" fill="url(#paint0_linear_1643_13650)" fill-rule="evenodd"/>
+ <path d="m52.352 140.17-29.515 166.76 16.366 50.388 326.7 107.94 60.588-255.99-374.13-69.097z" clip-rule="evenodd" fill="url(#paint1_linear_1643_13650)" fill-rule="evenodd"/>
+ <g filter="url(#filter0_f_1643_13650)" opacity=".4">
+ <path d="m58.092 143.78s1.5863 8.841 8.9353 18.627c7.3052 9.875 105.42 169.92 110.35 168.51 64.275-18.384 297.48-116.19 297.48-116.19l-416.76-70.949z" clip-rule="evenodd" fill="#000" fill-rule="evenodd"/>
+ </g>
+ <path d="m56.36 141.92s1.5863 8.155 8.9353 17.182c7.3052 9.109 105.42 156.74 110.35 155.44 64.275-16.957 297.48-107.18 297.48-107.18l-416.76-65.445z" clip-rule="evenodd" fill="url(#paint2_linear_1643_13650)" fill-rule="evenodd"/>
+ <g filter="url(#filter1_f_1643_13650)" opacity=".22349">
+ <path d="m95.235 143.33c-40.836 46.707-48.379 96.958-22.629 150.75-13.529 0-23.867-5.213-31.014-15.641-0.6033 40.753 3.8877 73.349 13.473 97.79-11.108-11.701-18.136-22.191-21.084-31.472-11.726-36.913-11.677-90.268 0-131.37 10.227-35.997 44.096-82.656 61.254-88.432 6.5474-2.204 6.5474 3.92 0 18.371z" clip-rule="evenodd" fill="#000" fill-rule="evenodd"/>
+ </g>
+ <g clip-rule="evenodd" fill-rule="evenodd">
+ <path d="m125.44 135.02s-112.96 32.874-61.13 147.33c0 0-23.767-21.625-37.918-49.274-0.6366-1.238 23.587-86.757 23.587-86.757l75.461-11.297z" fill="url(#paint3_linear_1643_13650)"/>
+ <path d="m64.31 282.7c-9.8244-0.168-25.608-8.222-30.728-17.972-6.3994 105.98 37.114 167.87 106.2 200.88-22.573-2.571-134.78-61.411-134.78-203 0-120.39 96.136-228.01 246.59-232.94 0.922 6.7491-74.588 23.674-77.003 31.632-3.749 12.386-12.365 25.87-19.632 36.678-9.848 14.645 13.685 27.168-3.609 31.113-26.023 5.934-60.734 2.649-86.329 35.713-38.475 49.706-12.801 106.56-0.7034 117.9z" fill="url(#paint4_radial_1643_13650)"/>
+ <path d="m291.05 33.191c-97.434-7.4294-147.58 31.312-179.77 76.361-11.724 16.398-10.44 31.456-6.404 49.647 1.735 7.766 3.488 11.487 1.376 19.403-1.165 4.368-0.971 8.446-2.417 11.022-2.002 3.561-4.5502 7.454-5.4038 13.849-2.1332 15.968 3.5548 21.054 7.1088 26.425 5.064-4.996 14.955-16.756 32.79-23.781s29.569-17.582 51.921-28.915c30.54-15.478 66.336 8.004 134.21-14.095 20.885-6.801 68.882-77.454 80.276-80.167-31.732-34.869-85.715-47.619-113.68-49.749z" fill="url(#paint5_linear_1643_13650)"/>
+ <path d="m226.68 11.067s-17.397 8.3502-24.973 23.439c17.493-8.763 27.647-13.55 32.447-14.12 0 0-4.598 2.213-10.3 14.249 10.081-3.2086 13.552-5.2309 16.32-5.262 0 0-0.804 1.2652-1.538 13.278-15.481-4.9861-45.278 0.8234-61.128 10.688-5.377-28.487 49.172-42.272 49.172-42.272z" fill="#3F6499"/>
+ <path d="m295.91 28.9c-29.865-3.8453-54.83-3.0883-76.055 0.9748 6.675-6.357 14.398-9.4888 14.398-9.4888-10.009 0.8794-22.502 7.3712-33.222 14.245-5.402 1.7132-10.549 3.6462-15.425 5.8053 2.985-3.9096 6.458-8.0868 9.852-11.293 12.604-11.911 31.32-18.076 31.32-18.076-15.975-1.1656-69.437 12.434-101.61 77.183-5.273 6.3819-10.192 12.932-14.88 19.492-12.102 16.932-10.774 32.475-6.6 51.26 1.778 8.021 2.106 20.824 0.351 29.098-0.351 1.657-4.9322 5.938-6.1073 17.48-1.2565 12.247 3.1413 18.26 7.4763 23.236 15.404-22.384 30.6-26.82 30.6-26.82 21.561-8.778 30.536-18.152 53.616-29.852 31.534-15.989 141.8 36.192 211.88 13.373 21.565-7.018-8.301-98.226 3.471-101.03-32.767-36.006-80.345-51.893-109.06-55.591z" fill="url(#paint6_linear_1643_13650)"/>
+ <path d="m514.64 225.23c-17.274-89.677-100.39-171.26-167.22-175.78-29.662-2.0077-20.553 13.106-39.112 18.862-79.982 24.804-81.205 39.285-81.205 39.285 169.59 7.921 172.16 174.36 126.74 216.46 12.78-2.962 26.637-18.488 39.363-42.863-1.528 10.228-2.839 22.774-4.747 36.772-5.819 42.715 1.718 131.94-121.8 191.65 0 0 67.567-5.488 101.07-42.968-13.625 31.136-51.013 47.955-51.013 47.955 27.514-3.999 99.789-25.192 145.83-82.408 52.109-64.763 67.78-125.52 52.091-206.97z" fill="url(#paint7_linear_1643_13650)"/>
+ <path d="m349.37 388.94s55.094-15.306 72.849-44.868c-2.154 34.469-33.618 69.818-33.618 69.818s45.998-9.251 65.613-41.416c-2.555 29.025-37.968 68.252-37.968 68.252 36.204-6.457 127.17-63.56 98.397-215.5-16.994-89.729-100.39-171.26-167.22-175.78-29.661-2.0077-20.553 13.106-39.112 18.862-79.982 24.804-81.205 39.285-81.205 39.285 169.59 7.921 214.66 96.121 126.74 214.11 12.78-2.964 17.904-9.943 27.505-21.365-4e-3 0 4.35 45.173-31.984 88.604z" fill="url(#paint8_linear_1643_13650)"/>
+ <path d="m131.5 188.47s-9.85 6.846-16.981 5.102c-8.443-2.06-9.873-10.19-9.873-10.19-0.186 1.665-0.401 3.273-0.712 4.714-0.35 1.658-4.9317 5.938-6.1068 17.481-1.2565 12.247 3.3708 19.34 7.7058 24.316 15.406-22.383 30.373-27.9 30.373-27.9-5.863-2.037-4.406-13.523-4.406-13.523z" fill="url(#paint9_linear_1643_13650)"/>
+ </g>
+ <g opacity=".6">
+ <g opacity=".2">
+ <path d="m346.28 49.234c2.308-0.5953 11.732 0.4231 17.916 3.1795 6.187 2.7565 16.85 7.6305 8.32 9.7502-8.532 2.1197-13.437-1.2714-17.704-4.8761-4.266-3.6047-14.291-6.5707-8.532-8.0536z" clip-rule="evenodd" fill="url(#paint10_linear_1643_13650)" fill-rule="evenodd" opacity=".2"/>
+ </g>
+ </g>
+ <g clip-rule="evenodd" fill-rule="evenodd">
+ <path d="m132.42 187.13s54.034-88.484 102.88-112.36c4.535-2.267-67.636 19.063-96.679 50.918-17.264 18.935-8.122 56.005-6.204 61.445z" fill="url(#paint11_linear_1643_13650)"/>
+ <path d="m132.43 187.13c0.05-0.031 0.104-0.062 0.156-0.093 13.972-8.579 37.916-13.519 45.635-29.069 29.715-59.876 57.093-83.201 57.093-83.201-48.851 23.879-102.88 112.36-102.88 112.36z" fill="#F2F2F2" opacity=".1"/>
+ <path d="m137.56 170.95s-9.739-14.848 4.285-28.388c7.401-7.135 18.616-3.171 19.912-1.887 5.519 5.438 2.05 16.916-2.918 23.412-2.807 3.659-10.976 9.354-21.279 6.863z" fill="#2F4282"/>
+ <path d="m143.14 170.03s-6.729-10.265 2.968-19.619c5.106-4.928 12.86-2.19 13.759-1.306 3.811 3.758 1.411 11.691-2.02 16.177-1.939 2.537-7.587 6.47-14.707 4.748z" fill="url(#paint12_linear_1643_13650)"/>
+ <path d="m156.58 159.08c-0.151 4.102-3.645 7.305-7.802 7.164-4.166-0.143-7.427-3.58-7.278-7.683 0.148-4.1 3.64-7.302 7.808-7.161 4.157 0.143 7.416 3.582 7.272 7.68z" fill="#000"/>
+ </g>
+ <path d="m144.64 158.97c1.25 0 2.264-1.009 2.264-2.254 0-1.244-1.014-2.253-2.264-2.253-1.251 0-2.265 1.009-2.265 2.253 0 1.245 1.014 2.254 2.265 2.254z" fill="#fff"/>
+ <path d="m481.79 145.76c-37.049-55.95-66.402-70.167-66.402-70.167s1.349 40.755 21.919 60.412c2.35 2.247-24.616-17.488-24.616-17.488s-5.766 18.824 7.447 40.465c-3.838-5.059-6.378-6.975-6.378-6.975s-24.896 11.388-32.245 27.188c-3.794-7.105-6.64-11.291-6.64-11.291s-17.218 34.091-15.054 73.618c3.52 64.271-12.133 87.948-12.133 87.948s41.933-15.959 62.043-67.34c8.308 21.846-0.253 44.839-0.253 44.839s33.881-25.115 41.167-70.891c8.076 12.921 5.701 38.71 5.701 38.71s21.626-29.87 22.153-62.4c10.023 6.934 10.945 34.013 10.945 34.013s32.062-40.664-7.654-100.64z" clip-rule="evenodd" fill="url(#paint13_linear_1643_13650)" fill-rule="evenodd"/>
+ <path d="m446.44 106.58c-9.673-17.891-26.742-29.293-41.745-38.522-37.407-23.022-61.465-26.256-61.465-26.256s-34.523 6.3728-28.593 15.905c0.305 0.4893 1.018 1.1092 2.022 1.8182-30.282-13.844-47.46 17.988-47.46 17.988-17.303-1.0428-38.372 7.4115-44.515 30.058-0.637 2.351 7.751 1.029 12.067 1.959 25.23 5.444 48.98 17.249 59.894 23.083 25.386 13.575 40.448 36.178 49.144 53.132 10.521 20.511 14.817 56.605 14.817 56.605s27.682-37.742 21.067-57.119c10.964 6.758 13.128 30.871 13.128 30.871s18.454-28.688 12.998-53.676c14.235 8.61 14.709 26.124 14.709 26.124s14.493-19.966 7.943-52.714c14.325 11.305 17.322 25.95 17.322 25.95s11.709-31.083-1.333-55.206z" clip-rule="evenodd" fill="url(#paint14_linear_1643_13650)" fill-rule="evenodd"/>
+ <defs>
+ <filter id="filter0_f_1643_13650" x="28.092" y="113.78" width="476.76" height="247.15" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
+ <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
+ <feGaussianBlur result="effect1_foregroundBlur_1643_13650" stdDeviation="15"/>
+ </filter>
+ <filter id="filter1_f_1643_13650" x="15.204" y="114.52" width="94.941" height="271.71" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
+ <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
+ <feGaussianBlur result="effect1_foregroundBlur_1643_13650" stdDeviation="5"/>
+ </filter>
+ <linearGradient id="paint0_linear_1643_13650" x1="421.46" x2="119.05" y1="397.51" y2="94.033" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#130036" offset="0"/>
+ <stop stop-color="#18023B" offset=".2297"/>
+ <stop stop-color="#26094A" offset=".5122"/>
+ <stop stop-color="#3D1563" offset=".8211"/>
+ <stop stop-color="#4E1D75" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint1_linear_1643_13650" x1="35.806" x2="401.54" y1="260.61" y2="410.81" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#FCFCFC" offset="0"/>
+ <stop stop-color="#EBE9E9" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint2_linear_1643_13650" x1="79.867" x2="288.57" y1="215.51" y2="310.43" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#FBFBFB" offset="0"/>
+ <stop stop-color="#F9F9F9" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint3_linear_1643_13650" x1="26.379" x2="125.44" y1="208.68" y2="208.68" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#3156A8" offset="0"/>
+ <stop stop-color="#3351A4" offset=".2474"/>
+ <stop stop-color="#3B4397" offset=".5365"/>
+ <stop stop-color="#472C82" offset=".8453"/>
+ <stop stop-color="#4E1D75" offset="1"/>
+ </linearGradient>
+ <radialGradient id="paint4_radial_1643_13650" cx="0" cy="0" r="1" gradientTransform="translate(157.73 144.36) scale(205.04 204.03)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#14CDDA" offset="0"/>
+ <stop stop-color="#2061BD" offset=".5478"/>
+ <stop stop-color="#2658AC" offset=".6546"/>
+ <stop stop-color="#373F81" offset=".864"/>
+ <stop stop-color="#432D62" offset="1"/>
+ </radialGradient>
+ <linearGradient id="paint5_linear_1643_13650" x1="97.961" x2="404.73" y1="131.08" y2="131.08" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#2061BD" offset="0"/>
+ <stop stop-color="#2B51AC" offset=".1846"/>
+ <stop stop-color="#442C84" offset=".6826"/>
+ <stop stop-color="#4E1D75" offset=".9409"/>
+ </linearGradient>
+ <linearGradient id="paint6_linear_1643_13650" x1="143.12" x2="307.42" y1="89.965" y2="233.29" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#48A8E0" offset=".0202"/>
+ <stop stop-color="#2061BD" offset=".3883"/>
+ <stop stop-color="#2B51AC" offset=".4968"/>
+ <stop stop-color="#442C84" offset=".7892"/>
+ <stop stop-color="#4E1D75" offset=".9409"/>
+ </linearGradient>
+ <linearGradient id="paint7_linear_1643_13650" x1="244.82" x2="577.58" y1="710.82" y2="215.76" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#3156A8" offset=".3787"/>
+ <stop stop-color="#4E1D75" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint8_linear_1643_13650" x1="281.11" x2="491.21" y1="588.93" y2="115.39" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#29ABE2" offset="0"/>
+ <stop stop-color="#385AA6" offset=".7733"/>
+ <stop stop-color="#414293" offset=".8575"/>
+ <stop stop-color="#4E1D75" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint9_linear_1643_13650" x1="109.37" x2="118.73" y1="201.88" y2="207.29" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#B0DCD6" offset="0"/>
+ <stop stop-color="#53ACE0" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint10_linear_1643_13650" x1="372.79" x2="348.67" y1="56.522" y2="51.407" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#3092B9" offset="0"/>
+ <stop stop-color="#258DB6" offset=".2199"/>
+ <stop stop-color="#1685B1" offset=".6564"/>
+ <stop stop-color="#1082AF" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint11_linear_1643_13650" x1="174.45" x2="194.7" y1="89.868" y2="171.39" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#1398D1" stop-opacity="0" offset=".0074"/>
+ <stop stop-color="#1187C2" stop-opacity=".6197" offset=".2482"/>
+ <stop stop-color="#3F6499" stop-opacity=".71" offset=".6422"/>
+ <stop stop-color="#2F4282" stop-opacity=".5" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint12_linear_1643_13650" x1="155.98" x2="146.11" y1="162.79" y2="156.84" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#F9C21B" offset="0"/>
+ <stop stop-color="#F3BA1B" offset=".1479"/>
+ <stop stop-color="#E3A41B" offset=".3787"/>
+ <stop stop-color="#C9801C" offset=".6634"/>
+ <stop stop-color="#A44E1C" offset=".9884"/>
+ <stop stop-color="#A34C1C" offset="1"/>
+ </linearGradient>
+ <linearGradient id="paint13_linear_1643_13650" x1="428.5" x2="360.29" y1="280.84" y2="127.36" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#409EC3" offset="0"/>
+ <stop stop-color="#2061BD" offset=".62"/>
+ </linearGradient>
+ <linearGradient id="paint14_linear_1643_13650" x1="374.34" x2="330.55" y1="206.53" y2="71.455" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#14B2DA" offset="0"/>
+ <stop stop-color="#297CCC" offset=".4028"/>
+ <stop stop-color="#256FC5" offset=".5077"/>
+ <stop stop-color="#2164BF" offset=".6492"/>
+ <stop stop-color="#2061BD" offset=".8162"/>
+ <stop stop-color="#2061BD" offset=".9835"/>
+ </linearGradient>
+ </defs>
+</svg>
diff --git a/comm/mail/branding/nightly/default128.png b/comm/mail/branding/nightly/default128.png
new file mode 100644
index 0000000000..37d2728a0c
--- /dev/null
+++ b/comm/mail/branding/nightly/default128.png
Binary files differ
diff --git a/comm/mail/branding/nightly/default16.png b/comm/mail/branding/nightly/default16.png
new file mode 100644
index 0000000000..1d307992b2
--- /dev/null
+++ b/comm/mail/branding/nightly/default16.png
Binary files differ
diff --git a/comm/mail/branding/nightly/default22.png b/comm/mail/branding/nightly/default22.png
new file mode 100644
index 0000000000..6d3cc08eb6
--- /dev/null
+++ b/comm/mail/branding/nightly/default22.png
Binary files differ
diff --git a/comm/mail/branding/nightly/default24.png b/comm/mail/branding/nightly/default24.png
new file mode 100644
index 0000000000..5a260dce7b
--- /dev/null
+++ b/comm/mail/branding/nightly/default24.png
Binary files differ
diff --git a/comm/mail/branding/nightly/default256.png b/comm/mail/branding/nightly/default256.png
new file mode 100644
index 0000000000..150a23251d
--- /dev/null
+++ b/comm/mail/branding/nightly/default256.png
Binary files differ
diff --git a/comm/mail/branding/nightly/default32.png b/comm/mail/branding/nightly/default32.png
new file mode 100644
index 0000000000..c8eaa72fe0
--- /dev/null
+++ b/comm/mail/branding/nightly/default32.png
Binary files differ
diff --git a/comm/mail/branding/nightly/default48.png b/comm/mail/branding/nightly/default48.png
new file mode 100644
index 0000000000..1dc973e444
--- /dev/null
+++ b/comm/mail/branding/nightly/default48.png
Binary files differ
diff --git a/comm/mail/branding/nightly/default64.png b/comm/mail/branding/nightly/default64.png
new file mode 100644
index 0000000000..3f18e09d23
--- /dev/null
+++ b/comm/mail/branding/nightly/default64.png
Binary files differ
diff --git a/comm/mail/branding/nightly/disk.icns b/comm/mail/branding/nightly/disk.icns
new file mode 100644
index 0000000000..b42ab1d3eb
--- /dev/null
+++ b/comm/mail/branding/nightly/disk.icns
Binary files differ
diff --git a/comm/mail/branding/nightly/dsstore b/comm/mail/branding/nightly/dsstore
new file mode 100755
index 0000000000..062daae6e9
--- /dev/null
+++ b/comm/mail/branding/nightly/dsstore
Binary files differ
diff --git a/comm/mail/branding/nightly/jar.mn b/comm/mail/branding/nightly/jar.mn
new file mode 100644
index 0000000000..ec0462ba10
--- /dev/null
+++ b/comm/mail/branding/nightly/jar.mn
@@ -0,0 +1,19 @@
+#filter substitution
+# 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 branding %content/branding/
+ content/branding/about-background.png (content/about-background.png)
+ content/branding/about-logo.svg (content/about-logo.svg)
+ content/branding/about-wordmark.svg (content/about-wordmark.svg)
+ content/branding/about.png (content/about.png)
+ content/branding/icon32.png (default32.png)
+ content/branding/icon48.png (default48.png)
+ content/branding/icon64.png (default64.png)
+ content/branding/icon128.png (default128.png)
+ content/branding/icon256.png (default256.png)
+ content/branding/aboutDialog.css (content/aboutDialog.css)
+ content/branding/logo-gradient.svg (content/logo-gradient.svg)
+ ../classic/skin/classic/messenger/icons/new-mail-alert.png (default48.png)
diff --git a/comm/mail/branding/nightly/locales/en-US/brand.dtd b/comm/mail/branding/nightly/locales/en-US/brand.dtd
new file mode 100755
index 0000000000..2b591a96aa
--- /dev/null
+++ b/comm/mail/branding/nightly/locales/en-US/brand.dtd
@@ -0,0 +1,13 @@
+<!-- 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/. -->
+
+<!ENTITY brandShortName "Daily">
+<!ENTITY brandShorterName "Daily">
+<!ENTITY brandFullName "Thunderbird Daily">
+<!-- LOCALIZATION NOTE (brandProductName):
+ This brand name can be used in messages where the product name needs to
+ remain unchanged across different versions (Daily, Beta, etc.). -->
+<!ENTITY brandProductName "Thunderbird">
+<!ENTITY vendorShortName "mozilla.org">
+<!ENTITY trademarkInfo.part1 " ">
diff --git a/comm/mail/branding/nightly/locales/en-US/brand.ftl b/comm/mail/branding/nightly/locales/en-US/brand.ftl
new file mode 100644
index 0000000000..e9b631671c
--- /dev/null
+++ b/comm/mail/branding/nightly/locales/en-US/brand.ftl
@@ -0,0 +1,21 @@
+# 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/.
+
+## Thunderbird Brand
+##
+## Thunderbird must be treated as a brand, and kept in English.
+## It cannot be:
+## - Transliterated.
+## - Translated.
+##
+## Reference: https://www.mozilla.org/styleguide/communications/translation/
+
+-brand-shorter-name = Daily
+-brand-short-name = Daily
+-brand-full-name = Thunderbird Daily
+# This brand name can be used in messages where the product name needs to
+# remain unchanged across different versions (Daily, Beta, etc.).
+-brand-product-name = Thunderbird
+-vendor-short-name = mozilla.org
+trademarkInfo = { " " }
diff --git a/comm/mail/branding/nightly/locales/en-US/brand.properties b/comm/mail/branding/nightly/locales/en-US/brand.properties
new file mode 100755
index 0000000000..2eedac1b0b
--- /dev/null
+++ b/comm/mail/branding/nightly/locales/en-US/brand.properties
@@ -0,0 +1,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/.
+
+brandShortName=Daily
+brandShorterName=Daily
+brandFullName=Thunderbird Daily
+vendorShortName=mozilla.org
diff --git a/comm/mail/branding/nightly/locales/jar.mn b/comm/mail/branding/nightly/locales/jar.mn
new file mode 100755
index 0000000000..70e1c7cc8a
--- /dev/null
+++ b/comm/mail/branding/nightly/locales/jar.mn
@@ -0,0 +1,14 @@
+#filter substitution
+# 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/.
+
+[localization] @AB_CD@.jar:
+ branding (en-US/**/*.ftl)
+
+
+@AB_CD@.jar:
+% locale branding @AB_CD@ %locale/branding/
+# Daily branding only exists in en-US
+ locale/branding/brand.dtd (en-US/brand.dtd)
+ locale/branding/brand.properties (en-US/brand.properties)
diff --git a/comm/mail/branding/nightly/locales/moz.build b/comm/mail/branding/nightly/locales/moz.build
new file mode 100644
index 0000000000..3f60fe2f91
--- /dev/null
+++ b/comm/mail/branding/nightly/locales/moz.build
@@ -0,0 +1,8 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+DEFINES["MOZ_DISTRIBUTION_ID_UNQUOTED"] = CONFIG["MOZ_DISTRIBUTION_ID"]
diff --git a/comm/mail/branding/nightly/messengerWindow.ico b/comm/mail/branding/nightly/messengerWindow.ico
new file mode 100644
index 0000000000..0ad1167408
--- /dev/null
+++ b/comm/mail/branding/nightly/messengerWindow.ico
Binary files differ
diff --git a/comm/mail/branding/nightly/moz.build b/comm/mail/branding/nightly/moz.build
new file mode 100644
index 0000000000..94bac90e27
--- /dev/null
+++ b/comm/mail/branding/nightly/moz.build
@@ -0,0 +1,12 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += ["locales"]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+include("../branding-common.mozbuild")
+ThunderbirdBranding()
diff --git a/comm/mail/branding/nightly/msix/Assets/Calendar44x44.png b/comm/mail/branding/nightly/msix/Assets/Calendar44x44.png
new file mode 100644
index 0000000000..c8474d3e9f
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/Calendar44x44.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/Email44x44.png b/comm/mail/branding/nightly/msix/Assets/Email44x44.png
new file mode 100644
index 0000000000..15f6dbc179
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/Email44x44.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/LargeTile.scale-200.png b/comm/mail/branding/nightly/msix/Assets/LargeTile.scale-200.png
new file mode 100644
index 0000000000..ad4f054218
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/LargeTile.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/News44x44.png b/comm/mail/branding/nightly/msix/Assets/News44x44.png
new file mode 100644
index 0000000000..361c9371c5
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/News44x44.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/SmallTile.scale-200.png b/comm/mail/branding/nightly/msix/Assets/SmallTile.scale-200.png
new file mode 100644
index 0000000000..8d2f5a8427
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/SmallTile.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/Square150x150Logo.scale-200.png b/comm/mail/branding/nightly/msix/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 0000000000..5cbd59fb88
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/Square150x150Logo.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png b/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png
new file mode 100644
index 0000000000..610df3aec1
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.altform-unplated_targetsize-256.png b/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.altform-unplated_targetsize-256.png
new file mode 100644
index 0000000000..610df3aec1
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.altform-unplated_targetsize-256.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.scale-200.png b/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 0000000000..a004745e3c
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.targetsize-256.png b/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.targetsize-256.png
new file mode 100644
index 0000000000..4cce9aa798
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/Square44x44Logo.targetsize-256.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/StoreLogo.scale-200.png b/comm/mail/branding/nightly/msix/Assets/StoreLogo.scale-200.png
new file mode 100644
index 0000000000..be5d5ee0c8
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/StoreLogo.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/nightly/msix/Assets/Wide310x150Logo.scale-200.png b/comm/mail/branding/nightly/msix/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 0000000000..e72d1136e9
--- /dev/null
+++ b/comm/mail/branding/nightly/msix/Assets/Wide310x150Logo.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/nightly/newmail.ico b/comm/mail/branding/nightly/newmail.ico
new file mode 100644
index 0000000000..ea43e8999f
--- /dev/null
+++ b/comm/mail/branding/nightly/newmail.ico
Binary files differ
diff --git a/comm/mail/branding/nightly/pref/thunderbird-branding.js b/comm/mail/branding/nightly/pref/thunderbird-branding.js
new file mode 100644
index 0000000000..4cb854f46e
--- /dev/null
+++ b/comm/mail/branding/nightly/pref/thunderbird-branding.js
@@ -0,0 +1,37 @@
+/* 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/. */
+
+// Default start page
+pref("mailnews.start_page.url", "https://live.thunderbird.net/%APP%/start?locale=%LOCALE%&version=%VERSION%&channel=%CHANNEL%&os=%OS%&buildid=%APPBUILDID%");
+
+// start page override to load after an update
+// pref("mailnews.start_page.override_url", "https://live.thunderbird.net/%APP%/whatsnew?locale=%LOCALE%&version=%VERSION%&channel=%CHANNEL%&os=%OS%&buildid=%APPBUILDID%&oldversion=%OLD_VERSION%");
+// Leave blank per bug 1695529 until the website has a proper "Thunderbird Daily" landing page
+pref("mailnews.start_page.override_url", "");
+
+// There's no Thunderbird Daily specific page or release notes
+// URL user can browse to manually if for some reason all update installation
+// attempts fail.
+pref("app.update.url.manual", "https://www.thunderbird.net/");
+// A default value for the "More information about this update" link
+// supplied in the "An update is available" page of the update wizard.
+pref("app.update.url.details", "https://www.thunderbird.net/");
+
+// Interval: Time between checks for a new version (in seconds)
+// nightly=1 hour, official=24 hours
+pref("app.update.interval", 3600);
+
+// Give the user x seconds to react before showing the big UI. nightly=1 hour
+pref("app.update.promptWaitTime", 3600);
+
+// The number of days a binary is permitted to be old
+// without checking for an update. This assumes that
+// app.update.checkInstallTime is true.
+pref("app.update.checkInstallTime.days", 2);
+
+// Give the user x seconds to reboot before showing a badge on the hamburger
+// button. default=immediately
+pref("app.update.badgeWaitTime", 0);
+
+pref("app.vendorURL", "https://www.thunderbird.net/%LOCALE%/");
diff --git a/comm/mail/branding/nightly/thunderbird.VisualElementsManifest.xml b/comm/mail/branding/nightly/thunderbird.VisualElementsManifest.xml
new file mode 100644
index 0000000000..1730f24636
--- /dev/null
+++ b/comm/mail/branding/nightly/thunderbird.VisualElementsManifest.xml
@@ -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/. -->
+
+<Application xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'>
+ <VisualElements
+ ShowNameOnSquare150x150Logo='on'
+ Square150x150Logo='VisualElements\VisualElements_150.png'
+ Square70x70Logo='VisualElements\VisualElements_70.png'
+ ForegroundText='light'
+ BackgroundColor='#ce5c00'/>
+</Application>
diff --git a/comm/mail/branding/nightly/thunderbird.icns b/comm/mail/branding/nightly/thunderbird.icns
new file mode 100644
index 0000000000..21c90bd4b1
--- /dev/null
+++ b/comm/mail/branding/nightly/thunderbird.icns
Binary files differ
diff --git a/comm/mail/branding/nightly/wizHeader.bmp b/comm/mail/branding/nightly/wizHeader.bmp
new file mode 100755
index 0000000000..17d970037c
--- /dev/null
+++ b/comm/mail/branding/nightly/wizHeader.bmp
Binary files differ
diff --git a/comm/mail/branding/nightly/wizHeaderRTL.bmp b/comm/mail/branding/nightly/wizHeaderRTL.bmp
new file mode 100755
index 0000000000..0e12da134c
--- /dev/null
+++ b/comm/mail/branding/nightly/wizHeaderRTL.bmp
Binary files differ
diff --git a/comm/mail/branding/nightly/wizWatermark.bmp b/comm/mail/branding/nightly/wizWatermark.bmp
new file mode 100755
index 0000000000..678ea51f89
--- /dev/null
+++ b/comm/mail/branding/nightly/wizWatermark.bmp
Binary files differ
diff --git a/comm/mail/branding/nightly/writeMessage.ico b/comm/mail/branding/nightly/writeMessage.ico
new file mode 100644
index 0000000000..62ec69573e
--- /dev/null
+++ b/comm/mail/branding/nightly/writeMessage.ico
Binary files differ
diff --git a/comm/mail/branding/thunderbird/LICENSE b/comm/mail/branding/thunderbird/LICENSE
new file mode 100644
index 0000000000..32d55b9c7f
--- /dev/null
+++ b/comm/mail/branding/thunderbird/LICENSE
@@ -0,0 +1,10 @@
+These files are under the MPL 2, as below. However, please note that you
+are not granted any trademark rights or licenses to the trademarks of the
+Mozilla Foundation or any party, including without limitation the
+Firefox name or logo.
+
+For more information, see: http://www.mozilla.org/foundation/licensing.html
+
+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/.
diff --git a/comm/mail/branding/thunderbird/TB-symbolic.svg b/comm/mail/branding/thunderbird/TB-symbolic.svg
new file mode 100644
index 0000000000..ecc0195c28
--- /dev/null
+++ b/comm/mail/branding/thunderbird/TB-symbolic.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="32" height="32" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2644 6.39002L13.2613 6.39001L13.2571 6.39001C10.6971 7.44144 10.4228 9.79162 10.8343 11.0559C10.8343 11.0559 10.8343 11.0655 10.8336 11.0829C12.3157 10.0405 14.211 9.47869 16.0003 9.47869C20.3461 9.47869 23.8691 12.3533 23.8691 15.8993C23.8691 19.4453 20.3461 22.3199 16.0003 22.3199C15.9667 22.3199 15.9294 22.3185 15.89 22.317C15.6835 22.3093 15.4175 22.2994 15.3096 22.475C15.1813 22.6841 15.4643 23.0057 15.6274 23.1901C17.3263 25.112 19.7905 25.3535 20.4377 25.4169C20.4793 25.421 20.5134 25.4243 20.5393 25.4273C14.8306 25.9794 8.13354 22.3325 8.13144 15.993C8.13094 14.4987 8.68295 13.2287 9.5642 12.2178L9.55312 12.2163C9.74707 8.97243 8.50157 5.09323 6.17175 4.33044C6.0069 4.27647 5.84356 4.40046 5.823 4.57269C5.51659 7.13857 4.85315 8.02797 4.13749 8.9874C3.06378 10.4268 2.2835 12.0904 2.31753 13.9174C1.92785 13.1019 1.61194 12.2504 1.37745 11.3701C1.34567 11.2508 1.14944 10.8844 0.93354 11.0073C0.761036 11.1054 0.631132 11.3738 0.538253 11.6537C0.183211 12.9862 0 14.4078 0 15.7843C0 24.4995 7.29965 31.7842 16 31.7842C24.8365 31.7842 32 24.6208 32 15.7843C32 12.4152 30.9587 9.28927 29.1803 6.71088C29.2544 6.70952 29.3287 6.70883 29.4032 6.70883C30.0938 6.70883 30.7674 6.76788 31.4167 6.8801C30.4378 5.79241 29.1172 4.91319 27.58 4.3421C28.6062 3.96993 29.7274 3.73934 30.9059 3.68092C29.0036 2.12725 26.2278 1.14819 23.1366 1.14819C18.6625 1.14819 14.272 2.86777 13.2644 6.39002ZM14.1733 18.6117C13.0145 17.4055 9.54358 13.3991 9.54358 13.3991L9.81225 13.4106L15.2456 17.4689C15.6527 17.7646 16.2329 17.7616 16.6362 17.4618L21.9641 13.4158L22.247 13.3939C22.247 13.3939 18.8896 17.3055 17.6037 18.5942C16.3178 19.8829 15.3322 19.8179 14.1733 18.6117ZM17.8727 5.16837C17.8727 5.16837 17.9463 6.00203 17.1043 6.2672C16.1417 6.57032 15.7151 5.76065 15.7151 5.76065C15.7151 5.76065 15.8361 5.20461 16.6171 4.94743C17.4513 4.67271 17.8727 5.16837 17.8727 5.16837Z" fill="black"/>
+</svg>
diff --git a/comm/mail/branding/thunderbird/VisualElements_150.png b/comm/mail/branding/thunderbird/VisualElements_150.png
new file mode 100644
index 0000000000..ac3f002e28
--- /dev/null
+++ b/comm/mail/branding/thunderbird/VisualElements_150.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/VisualElements_70.png b/comm/mail/branding/thunderbird/VisualElements_70.png
new file mode 100644
index 0000000000..e74c56c3c1
--- /dev/null
+++ b/comm/mail/branding/thunderbird/VisualElements_70.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/background.png b/comm/mail/branding/thunderbird/background.png
new file mode 100644
index 0000000000..272b1e33d9
--- /dev/null
+++ b/comm/mail/branding/thunderbird/background.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/branding.nsi b/comm/mail/branding/thunderbird/branding.nsi
new file mode 100644
index 0000000000..ba3df918e5
--- /dev/null
+++ b/comm/mail/branding/thunderbird/branding.nsi
@@ -0,0 +1,48 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is the Mozilla Installer code.
+#
+# The Initial Developer of the Original Code is Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2006
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+# Robert Strong <robert.bugzilla@gmail.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+# NSIS defines for official release builds.
+# The nightly build branding.nsi is located in mail/installer/windows/nsis
+
+# BrandFullNameInternal is used for some registry and file system values
+# instead of BrandFullName and typically should not be modified.
+!define BrandFullNameInternal "Mozilla Thunderbird"
+!define BrandFullName "Mozilla Thunderbird"
+!define CompanyName "Mozilla Corporation"
+!define URLInfoAbout "https://www.mozilla.org/${AB_CD}/"
+!define URLUpdateInfo "https://www.thunderbird.net/${AB_CD}/"
+!define URLSystemRequirements "https://www.thunderbird.net/thunderbird/system-requirements/"
+!define SurveyURL "https://live.thunderbird.net/survey/uninstall/?locale=${AB_CD}&version=${AppVersion}"
diff --git a/comm/mail/branding/thunderbird/configure.sh b/comm/mail/branding/thunderbird/configure.sh
new file mode 100644
index 0000000000..6525c275b1
--- /dev/null
+++ b/comm/mail/branding/thunderbird/configure.sh
@@ -0,0 +1,5 @@
+# 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/.
+
+MOZ_APP_DISPLAYNAME=Thunderbird
diff --git a/comm/mail/branding/thunderbird/content/about-logo.svg b/comm/mail/branding/thunderbird/content/about-logo.svg
new file mode 100644
index 0000000000..3371e49c55
--- /dev/null
+++ b/comm/mail/branding/thunderbird/content/about-logo.svg
@@ -0,0 +1,79 @@
+<!-- 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 width="210" height="210" viewBox="0 0 210 210" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_640_4234)">
+<path d="M89.5704 54.9563L89.5869 54.9563C94.9543 36.1931 118.343 27.0328 142.177 27.0328C158.644 27.0328 173.43 32.2483 183.564 40.5248C177.286 40.836 171.314 42.0644 165.847 44.047C174.036 47.0891 181.071 51.7728 186.285 57.567C182.826 56.9692 179.238 56.6546 175.559 56.6546C175.162 56.6546 174.766 56.6583 174.372 56.6655C183.845 70.4007 189.392 87.0525 189.392 105C189.392 152.073 151.232 190.232 104.16 190.232C57.8124 190.232 18.9268 151.427 18.9268 105C18.9268 97.6675 19.9027 90.0944 21.7941 82.9964C22.2888 81.5054 22.9808 80.0755 23.8998 79.5525C25.0499 78.8979 26.0952 80.8497 26.2645 81.4853C27.5137 86.175 29.1965 90.7106 31.2723 95.0551C31.0911 85.3227 35.2477 76.4605 40.9674 68.7927C44.7797 63.6817 48.3139 58.9438 49.9461 45.2753C50.0557 44.3578 50.9258 43.6973 51.804 43.9848C64.215 48.0482 70.8499 68.7129 69.8167 85.9931C76.6731 86.9732 76.6414 79.8116 76.6414 79.8116C74.4497 73.0767 75.911 60.5572 89.5482 54.9562L89.5704 54.9563Z" fill="url(#paint0_linear_640_4234)"/>
+<path opacity="0.9" d="M186.604 83.2946C188.676 130.173 150.139 170.589 103.145 170.589C59.154 170.589 23.1045 136.587 19.8438 93.4307C19.2693 97.4176 18.9583 101.49 18.929 105.629C19.2629 151.811 58.0543 190.232 104.16 190.232C151.232 190.232 189.392 152.073 189.392 105C189.392 97.499 188.423 90.2245 186.604 83.2946Z" fill="url(#paint1_radial_640_4234)"/>
+<g style="mix-blend-mode:screen">
+<path d="M102.083 61.1686C101.164 59.5432 96.924 57.1404 95.0715 56.7211C102.083 34.2569 137.814 27.3599 159.682 31.3347C168.782 32.9885 180.117 37.9498 183.564 40.5248C173.43 32.2483 158.644 27.0328 142.177 27.0328C118.343 27.0328 94.9542 36.1931 89.5869 54.9563L89.5704 54.9563L89.5481 54.9562C75.9109 60.5572 74.4514 73.0806 76.6431 79.8154C78.7458 71.7819 88.758 61.9098 102.083 61.1686Z" fill="url(#paint2_radial_640_4234)"/>
+</g>
+<path d="M126.851 45.6546C107.706 49.4216 101.449 50.6527 95.0406 56.7441C102.237 37.6918 120.608 33.8317 142.496 42.5152C136.471 43.7617 131.311 44.7769 126.851 45.6546Z" fill="url(#paint3_linear_640_4234)"/>
+<path d="M23.5535 80.3873C18.3232 101.802 22.3647 126.972 46.1274 148.095C39.0529 140.36 30.417 111.795 49.4764 91.3861C50.7603 90.0113 52.9692 91.0231 53.0387 92.9029C54.609 135.314 88.8332 161.217 128.287 156.373C116.062 155.686 75.633 141.527 105.707 135.924C121.427 132.996 146.075 128.404 146.075 106.29C146.075 70.4369 118.353 59.9548 101.545 61.5144C90.0397 62.5818 79.8006 69.8815 76.6481 79.8088C77.8578 83.7234 73.0353 86.4641 69.8167 86.004C70.8499 68.7238 64.2151 48.0483 51.804 43.9849C50.9258 43.6974 50.0557 44.3579 49.9462 45.2754C48.3139 58.9439 44.7797 63.6818 40.9674 68.7927C35.2477 76.4606 31.0911 85.3228 31.2724 95.0552C29.1965 90.7107 27.5137 86.175 26.2645 81.4854C26.1244 80.9591 25.3632 79.4924 24.4419 79.4231C23.9431 79.3857 23.6791 79.8728 23.5535 80.3873Z" fill="url(#paint4_radial_640_4234)"/>
+<g style="mix-blend-mode:screen">
+<path d="M95.1467 138.094C118.29 156.886 164.836 142.798 164.836 97.104C146.044 125.588 122.111 145.235 95.1467 138.094Z" fill="url(#paint5_linear_640_4234)"/>
+</g>
+<g style="mix-blend-mode:screen">
+<path d="M49.4759 91.386C49.9645 90.8628 50.5876 90.6831 51.1765 90.7648C34.1152 111.572 47.8756 148.119 57.3203 157.096C57.848 158.591 48.3615 150.817 47.0533 148.996C39.8699 142.902 29.574 112.697 49.4759 91.386Z" fill="url(#paint6_linear_640_4234)"/>
+</g>
+<path d="M104.159 139.815C127.31 139.815 146.077 124.502 146.077 105.613C146.077 86.723 127.31 71.4099 104.159 71.4099C84.4095 71.4099 62.2344 84.2587 62.2416 106.112C62.2528 139.883 97.9283 159.31 128.339 156.369C126.055 156.104 111.805 155.347 102.173 144.451C101.304 143.469 99.7963 141.756 100.48 140.642C101.164 139.528 103.043 139.815 104.159 139.815Z" fill="url(#paint7_linear_640_4234)"/>
+<path opacity="0.6" d="M141.51 90.0739L108.383 121.771C105.443 123.86 102.313 124.013 99.2237 122.116L66.7354 90.188C67.6505 88.7128 68.6928 87.2989 69.8505 85.9561C71.0211 87.0508 72.1546 88.1125 73.2605 89.1484C81.7956 97.1427 88.6943 103.604 98.4172 111.903C102.807 115.649 104.171 115.575 108.474 111.903C119.6 102.407 127.734 95.2203 138.311 85.7776C139.501 87.1389 140.572 88.5744 141.51 90.0739Z" fill="white"/>
+<mask id="mask0_640_4234" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="62" y="89" width="85" height="68">
+<path d="M146.077 105.613C146.077 124.502 127.309 139.815 104.159 139.815C103.043 139.815 101.164 139.528 100.48 140.642C99.796 141.756 101.304 143.469 102.173 144.451C111.223 154.689 124.35 155.976 127.797 156.314C128.019 156.335 128.2 156.353 128.338 156.369C97.928 159.31 62.2525 139.883 62.2413 106.112C62.2393 100.102 63.9149 94.7736 66.7562 90.2058L99.4071 119.861C101.73 121.971 105.644 121.971 107.967 119.861L141.237 89.6438C144.327 94.4106 146.077 99.8449 146.077 105.613Z" fill="white"/>
+</mask>
+<g mask="url(#mask0_640_4234)">
+<rect opacity="0.7" x="52.5337" y="66.1652" width="105.95" height="96.0624" fill="url(#paint8_linear_640_4234)"/>
+<g filter="url(#filter0_f_640_4234)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M94.4292 120.062C88.2559 113.637 69.7663 92.2938 69.7663 92.2938L71.1975 92.3553L100.141 113.974C102.31 115.549 105.401 115.533 107.549 113.936L135.931 92.3831L137.438 92.2661C137.438 92.2661 119.553 113.103 112.703 119.968C105.853 126.833 100.603 126.487 94.4292 120.062Z" fill="#458FCD"/>
+</g>
+</g>
+<path d="M110.04 54.302C114.526 52.8894 114.133 48.4484 114.133 48.4484C114.133 48.4484 111.889 45.808 107.445 47.2715C103.284 48.6415 102.64 51.6035 102.64 51.6035C102.64 51.6035 104.912 55.9167 110.04 54.302Z" fill="white"/>
+</g>
+<defs>
+<filter id="filter0_f_640_4234" x="61.9736" y="84.4734" width="83.257" height="48.3187" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
+<feGaussianBlur stdDeviation="3.89635" result="effect1_foregroundBlur_640_4234"/>
+</filter>
+<linearGradient id="paint0_linear_640_4234" x1="48.5102" y1="55.1257" x2="168.663" y2="167.201" gradientUnits="userSpaceOnUse">
+<stop stop-color="#1B91F3"/>
+<stop offset="1" stop-color="#0B68CB"/>
+</linearGradient>
+<radialGradient id="paint1_radial_640_4234" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(48.289 93.689) rotate(66.5179) scale(72.0721 69.0432)">
+<stop offset="0.525579" stop-color="#0B4186" stop-opacity="0"/>
+<stop offset="1" stop-color="#0B4186" stop-opacity="0.45"/>
+</radialGradient>
+<radialGradient id="paint2_radial_640_4234" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(106.351 64.0883) rotate(-127.994) scale(15.4079 25.4963)">
+<stop stop-color="#EF3ACC" stop-opacity="0"/>
+<stop offset="1" stop-color="#EF3ACC" stop-opacity="0.64"/>
+</radialGradient>
+<linearGradient id="paint3_linear_640_4234" x1="81.9426" y1="83.8162" x2="125.178" y2="35.6863" gradientUnits="userSpaceOnUse">
+<stop stop-color="#0F5DB0"/>
+<stop offset="1" stop-color="#0F5DB0" stop-opacity="0"/>
+</linearGradient>
+<radialGradient id="paint4_radial_640_4234" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(72.0145 151.439) rotate(-64.2627) scale(112.439 138.894)">
+<stop offset="0.0160882" stop-color="#094188"/>
+<stop offset="0.967387" stop-color="#0B4186" stop-opacity="0"/>
+</radialGradient>
+<linearGradient id="paint5_linear_640_4234" x1="157.714" y1="118.665" x2="137.613" y2="168.024" gradientUnits="userSpaceOnUse">
+<stop stop-color="#E247C4" stop-opacity="0"/>
+<stop offset="1" stop-color="#E247C4" stop-opacity="0.64"/>
+</linearGradient>
+<linearGradient id="paint6_linear_640_4234" x1="33.0531" y1="74.3982" x2="50.7635" y2="145.682" gradientUnits="userSpaceOnUse">
+<stop offset="0.104632" stop-color="#EF3ACC"/>
+<stop offset="1" stop-color="#EF3ACC" stop-opacity="0"/>
+</linearGradient>
+<linearGradient id="paint7_linear_640_4234" x1="104.161" y1="83.9355" x2="104.161" y2="156.018" gradientUnits="userSpaceOnUse">
+<stop stop-color="white"/>
+<stop offset="0.90535" stop-color="#BEE1FE"/>
+<stop offset="1" stop-color="#96CEFD"/>
+</linearGradient>
+<linearGradient id="paint8_linear_640_4234" x1="105.508" y1="124.725" x2="105.508" y2="154.922" gradientUnits="userSpaceOnUse">
+<stop stop-color="#BCE0FD"/>
+<stop offset="1" stop-color="#88CCFC"/>
+</linearGradient>
+<clipPath id="clip0_640_4234">
+<rect width="210" height="210" fill="white" transform="translate(0.800003)"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/comm/mail/branding/thunderbird/content/about-wordmark.svg b/comm/mail/branding/thunderbird/content/about-wordmark.svg
new file mode 100644
index 0000000000..1068ea4aba
--- /dev/null
+++ b/comm/mail/branding/thunderbird/content/about-wordmark.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 616.103 94.561" width="260" height="50" fill="context-fill">
+ <path d="M0 21.53h22.54v71.62h12.39V21.53h21.93l1.28-10.81H0zm94.51 6.86c-6.59 0-12.49 2.95-17.26 8.78V3.696L65.21 5.012V93.15h12.04V48.53c2.11-3.3 4.39-5.89 6.74-7.69 2.38-1.79 5.03-2.7 7.81-2.7 3.01 0 5.27.85 8.3 2.51 1.5 1.68 2.3 4.66 2.3 8.87v43.63h12.1V47.99c0-6.03-1.6-10.86-4.9-14.34-3.3-3.49-7.9-5.27-15.09-5.26zm68.29 46.16c-4.1 6.8-9.1 10.25-14.9 10.25-3.1 0-5.4-.81-6.8-2.42-1.4-1.64-2.1-4.53-2.1-8.6V29.8h-11.5v45.16c0 6.19 1 11.05 4.2 14.45 3.2 3.42 7.6 5.15 13.2 5.15 4.5 0 8.3-.92 11.5-2.73 2.7-1.57 5.1-3.94 7.3-7.07l.7 8.39H175V29.8h-12.2zm58.8-46.16c-4 0-7.7.98-10.9 2.91a23.74 23.74 0 0 0-7.4 6.57l-.9-8.07h-10.5v63.35H204V48.64c2.1-3.37 4.4-6 6.8-7.8 2.3-1.79 5.1-2.7 8.3-2.7 3 0 5.3.84 6.8 2.5 1.5 1.68 2.3 4.67 2.3 8.88v43.63h12.1V47.99c0-6.03-1.7-10.85-4.9-14.34-3.3-3.49-7.9-5.26-13.8-5.26zm72.9 6.74c-4.7-4.48-10.1-6.75-16.1-6.75-5.1 0-9.6 1.45-13.3 4.32-3.8 2.84-6.7 6.84-8.7 11.87-2 5-3 10.79-3 17.2 0 9.9 2.1 17.91 6.3 23.81 4.2 5.96 10.2 8.98 17.8 8.98 3.8 0 7.4-.92 10.5-2.73 2.7-1.54 5-3.62 6.9-6.19l.9 7.51h10.7V4.901l-12-1.449zm0 10.84v29.96c-2.1 2.9-4.3 5.11-6.6 6.56-2.2 1.46-4.9 2.2-8.1 2.2-4.3 0-7.6-1.83-9.9-5.59-2.4-3.8-3.6-9.71-3.6-17.57 0-7.77 1.3-13.67 3.9-17.55 2.5-3.85 6-5.72 10.6-5.72 2.7 0 5.2.66 7.5 1.97 2.1 1.3 4.2 3.23 6.2 5.74zm50.9-17.58c-5.4 0-10.1 1.46-14.1 4.36-4 2.89-7 6.92-9.2 11.99-2.1 5.04-3.1 10.89-3.1 17.38 0 10.01 2.5 17.99 7.4 23.74 4.9 5.77 11.9 8.7 20.6 8.7 8 0 15.4-2.66 22.1-7.91l.5-.38-5.4-7.48-.6.36c-2.6 1.84-5.3 3.2-7.7 4.04-2.4.84-5.1 1.26-8 1.26-4.5 0-8.3-1.55-11.1-4.62-2.8-2.99-4.4-7.7-5-14.01h39.5v-.58c.2-1.9.3-3.78.3-5.59 0-9.68-2.3-17.39-6.7-22.9-4.6-5.55-11.1-8.36-19.5-8.36zm-13.6 28.14c.9-12.29 5.4-18.27 13.8-18.27 4.7 0 8.2 1.54 10.5 4.58 2.3 3.09 3.5 7.68 3.5 13.63v.1h-27.8zm75.9-28.14c-3.9 0-7.4 1.23-10.3 3.68-2.4 1.96-4.3 4.72-5.9 8.22l-.9-10.49h-8.5v63.35h10.1V57.13c1.3-5.81 3.1-10.13 5.4-12.86 2.2-2.7 5.1-4.01 8.8-4.01 1.7 0 3.4.23 5.3.69l.6.17 2.2-11.86-.5-.15c-2-.48-4.1-.73-6.3-.72zm42.7 0c-6.2 0-11.9 2.68-16.3 7.97V3.452L422 4.901V93.15h10.7l.9-6c4.5 4.92 9.9 7.41 15.5 7.41 5.1 0 9.6-1.41 13.3-4.19 3.8-2.76 6.6-6.7 8.6-11.7 2-4.96 3-10.77 3-17.25 0-10.3-2.1-18.45-6.3-24.23-4.2-5.84-10-8.8-17.3-8.8zm-3.1 9.87c4.5 0 7.8 1.74 10.2 5.31 2.5 3.61 3.7 9.61 3.7 17.84 0 8-1.3 13.96-4 17.72-2.6 3.74-6.1 5.56-10.7 5.56-1.9 0-4.4-.72-6.6-2.15-2.3-1.39-4.2-3.34-5.8-5.76V47.47c4-6.11 8.7-9.21 13.2-9.21zm40.4-8.46h12.1v63.35h-12.1zM493.6 0c-2.3 0-4.3.776-5.8 2.306-1.6 1.532-2.3 3.468-2.3 5.752 0 2.292.7 4.212 2.3 5.702 1.5 1.49 3.5 2.24 5.8 2.24 2.5 0 4.5-.75 6-2.24 1.6-1.49 2.3-3.41 2.3-5.702 0-2.284-.7-4.22-2.3-5.752C498.1.776 496.1 0 493.6 0zM545 28.39c-3.9 0-7.4 1.23-10.3 3.68-2.4 1.96-4.4 4.72-5.9 8.22l-.9-10.49h-10.5v63.35h12.1V57.13c1.3-5.81 3.1-10.13 5.4-12.86 2.2-2.7 5.1-4.01 8.8-4.01 1.6 0 3.4.23 5.2.69l.7.17 2.2-11.86-.6-.15c-1.9-.48-4-.73-6.2-.72zm47.3-24.938V35.13c-4.7-4.48-10.1-6.75-16.1-6.75-4.5 0-9 1.45-12.8 4.32-3.7 2.84-6.7 6.84-8.6 11.87-2 5-2.9 10.79-2.9 17.2 0 9.9 2 17.91 6.2 23.81 4.2 5.96 10.2 8.98 17.1 8.98 3.9 0 7.5-.92 10.6-2.73 2.7-1.54 5-3.62 6.9-6.19l.9 7.51h10.8V4.901zm0 42.518v29.96c-2.2 2.9-4.3 5.11-6.6 6.56-2.2 1.46-5 2.2-8.1 2.2-4 0-7-1.83-9.3-5.59-2.4-3.8-3.6-9.71-3.6-17.57 0-7.77 1.3-13.67 3.8-17.55 2.6-3.85 5.4-5.72 10-5.72 2.8 0 5.3.66 7.5 1.97 2.2 1.3 4.3 3.23 6.3 5.74z"/>
+ <path d="M611.2 16.7c-2.6 0-4.7 2.18-4.7 4.87 0 2.7 2.1 4.84 4.7 4.84 2.8 0 4.9-2.14 4.9-4.84 0-2.69-2.1-4.87-4.9-4.87zm0 8.78c-2.1 0-3.7-1.63-3.7-3.91 0-2.27 1.6-3.94 3.7-3.94 2.3 0 3.8 1.67 3.8 3.94 0 2.28-1.5 3.91-3.8 3.91zm2.1-5.01c0-1.06-.7-1.59-2.3-1.59h-1.5v5.23h1.1V22.1h.5l1.1 2.01h1.3l-1.4-2.18c.7-.21 1.2-.7 1.2-1.46zm-2.7-.76h.5c.8 0 1.1.23 1.1.76 0 .57-.4.8-1 .8h-.6z"/>
+</svg>
diff --git a/comm/mail/branding/thunderbird/content/about.png b/comm/mail/branding/thunderbird/content/about.png
new file mode 100644
index 0000000000..df17036222
--- /dev/null
+++ b/comm/mail/branding/thunderbird/content/about.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/content/aboutDialog.css b/comm/mail/branding/thunderbird/content/aboutDialog.css
new file mode 100644
index 0000000000..e1e4dcee33
--- /dev/null
+++ b/comm/mail/branding/thunderbird/content/aboutDialog.css
@@ -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/. */
+
+#clientBox {
+ background-color: var(--client-box-background);
+}
+
+#leftBox {
+ background-image: url("chrome://branding/content/about-logo.svg");
+ background-repeat: no-repeat;
+ /* min-width and min-height create room for the logo */
+ min-width: 210px;
+ min-height: 210px;
+ margin-top: 20px;
+ margin-inline-start: 30px;
+}
+
+#rightBox {
+ background-size: 260px auto;
+}
+
+#supernova-logo {
+ height: 28px;
+ width: 86px;
+ margin-block-start: 12px;
+}
+
+#updateDeck > hbox > label:not([class="text-link"]) {
+ opacity: 0.6;
+}
+
+#trademark {
+ font-size: xx-small;
+ text-align: center;
+ opacity: 0.6;
+ margin-block: 10px;
+}
diff --git a/comm/mail/branding/thunderbird/content/logo-gradient.svg b/comm/mail/branding/thunderbird/content/logo-gradient.svg
new file mode 100644
index 0000000000..e1970369d7
--- /dev/null
+++ b/comm/mail/branding/thunderbird/content/logo-gradient.svg
@@ -0,0 +1,79 @@
+<!-- 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 width="526" height="526" viewBox="0 0 526 526" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_640_5838)">
+<path d="M218.441 112.243L218.491 112.243C234.66 55.7184 305.118 28.123 376.918 28.123C426.525 28.123 471.07 43.8347 501.598 68.7677C482.686 69.7053 464.694 73.4057 448.225 79.3782C472.894 88.5429 494.086 102.652 509.796 120.107C499.375 118.307 488.565 117.359 477.482 117.359C476.287 117.359 475.095 117.37 473.906 117.392C502.444 158.769 519.155 208.933 519.155 263C519.155 404.807 404.198 519.764 262.391 519.764C122.77 519.764 5.62622 402.861 5.62622 263C5.62622 240.911 8.56635 218.097 14.264 196.714C15.7545 192.222 17.8392 187.915 20.6075 186.339C24.0723 184.367 27.2213 190.247 27.7313 192.162C31.4943 206.289 36.564 219.953 42.8174 233.041C42.2714 203.722 54.7932 177.025 72.0238 153.925C83.5086 138.528 94.1553 124.255 99.0725 83.0787C99.4026 80.3146 102.024 78.325 104.669 79.1911C142.058 91.4321 162.045 153.685 158.933 205.742C179.588 208.694 179.492 187.12 179.492 187.12C172.89 166.831 177.292 129.116 218.374 112.243L218.441 112.243Z" fill="url(#paint0_linear_640_5838)"/>
+<path opacity="0.9" d="M510.755 197.612C516.999 338.835 400.905 460.588 259.335 460.588C126.811 460.588 18.2116 358.157 8.38878 228.147C6.65802 240.158 5.72114 252.426 5.63306 264.895C6.63878 404.018 123.498 519.764 262.391 519.764C404.198 519.764 519.155 404.807 519.155 263C519.155 240.403 516.236 218.489 510.755 197.612Z" fill="url(#paint1_radial_640_5838)"/>
+<g style="mix-blend-mode:screen">
+<path d="M256.135 130.957C253.368 126.061 240.594 118.822 235.013 117.559C256.135 49.8856 363.775 29.1084 429.655 41.0823C457.066 46.0644 491.214 61.0106 501.598 68.7677C471.07 43.8347 426.525 28.123 376.918 28.123C305.118 28.123 234.66 55.7184 218.491 112.243L218.441 112.243L218.374 112.243C177.292 129.116 172.895 166.842 179.497 187.131C185.832 162.93 215.994 133.19 256.135 130.957Z" fill="url(#paint2_radial_640_5838)"/>
+</g>
+<path d="M330.75 84.2213C273.074 95.5696 254.225 99.2783 234.92 117.629C256.6 60.2334 311.941 48.6047 377.879 74.764C359.731 78.519 344.187 81.5775 330.75 84.2213Z" fill="url(#paint3_linear_640_5838)"/>
+<path d="M19.5643 188.854C3.80784 253.365 15.9832 329.191 87.5686 392.825C66.2565 369.524 40.2406 283.469 97.6575 221.988C101.525 217.846 108.179 220.894 108.389 226.557C113.119 354.321 216.22 432.355 335.076 417.762C298.248 415.693 176.455 373.038 267.053 356.16C314.411 347.337 388.663 333.505 388.663 266.887C388.663 158.878 305.15 127.301 254.514 131.999C219.855 135.215 189.009 157.205 179.512 187.111C183.157 198.904 168.629 207.16 158.933 205.774C162.045 153.717 142.058 91.4321 104.669 79.1911C102.024 78.325 99.4026 80.3147 99.0725 83.0787C94.1553 124.255 83.5087 138.528 72.0238 153.925C54.7932 177.025 42.2715 203.722 42.8175 233.041C36.564 219.953 31.4943 206.289 27.7314 192.162C27.3091 190.576 25.0161 186.158 22.2405 185.949C20.7381 185.836 19.9428 187.304 19.5643 188.854Z" fill="url(#paint4_radial_640_5838)"/>
+<g style="mix-blend-mode:screen">
+<path d="M235.24 362.697C304.959 419.308 445.179 376.866 445.179 239.213C388.568 325.023 316.469 384.208 235.24 362.697Z" fill="url(#paint5_linear_640_5838)"/>
+</g>
+<g style="mix-blend-mode:screen">
+<path d="M97.6557 221.988C99.1278 220.411 101.005 219.87 102.779 220.117C51.3817 282.799 92.8349 392.896 121.287 419.94C122.877 424.444 94.2988 401.024 90.3577 395.54C68.7176 377.179 37.701 286.186 97.6557 221.988Z" fill="url(#paint6_linear_640_5838)"/>
+</g>
+<path d="M262.389 367.882C332.13 367.882 388.667 321.751 388.667 264.846C388.667 207.94 332.13 161.809 262.389 161.809C202.893 161.809 136.09 200.517 136.112 266.35C136.146 368.084 243.619 426.61 335.23 417.75C328.35 416.952 285.421 414.672 256.405 381.848C253.789 378.888 249.246 373.728 251.307 370.372C253.367 367.017 259.028 367.882 262.389 367.882Z" fill="url(#paint7_linear_640_5838)"/>
+<path opacity="0.6" d="M374.911 218.035L275.114 313.523C266.257 319.814 256.83 320.278 247.522 314.564L149.65 218.379C152.407 213.934 155.547 209.675 159.034 205.63C162.561 208.928 165.975 212.126 169.307 215.247C195.019 239.33 215.802 258.795 245.092 283.794C258.315 295.08 262.424 294.857 275.387 283.794C308.905 255.187 333.409 233.538 365.274 205.092C368.858 209.193 372.083 213.518 374.911 218.035Z" fill="white"/>
+<mask id="mask0_640_5838" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="136" y="216" width="253" height="203">
+<path d="M388.667 264.846C388.667 321.751 332.13 367.882 262.389 367.882C259.028 367.882 253.367 367.017 251.306 370.372C249.246 373.728 253.789 378.888 256.405 381.848C283.668 412.689 323.214 416.565 333.6 417.583C334.267 417.648 334.814 417.702 335.23 417.75C243.619 426.61 136.146 368.084 136.112 266.35C136.106 248.246 141.153 232.193 149.713 218.432L248.074 307.769C255.073 314.126 266.863 314.126 273.862 307.769L374.087 216.739C383.395 231.099 388.667 247.47 388.667 264.846Z" fill="white"/>
+</mask>
+<g mask="url(#mask0_640_5838)">
+<rect opacity="0.7" x="106.867" y="146.01" width="319.174" height="289.389" fill="url(#paint8_linear_640_5838)"/>
+<g filter="url(#filter0_f_640_5838)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M233.078 308.374C214.481 289.018 158.781 224.723 158.781 224.723L163.093 224.908L250.285 290.034C256.818 294.779 266.13 294.731 272.603 289.92L358.103 224.992L362.643 224.639C362.643 224.639 308.764 287.412 288.128 308.093C267.492 328.774 251.676 327.73 233.078 308.374Z" fill="#458FCD"/>
+</g>
+</g>
+<path d="M280.106 110.271C293.619 106.016 292.437 92.6375 292.437 92.6375C292.437 92.6375 285.676 84.6832 272.288 89.0919C259.755 93.219 257.812 102.142 257.812 102.142C257.812 102.142 264.659 115.136 280.106 110.271Z" fill="white"/>
+</g>
+<defs>
+<filter id="filter0_f_640_5838" x="135.305" y="201.164" width="250.813" height="145.561" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
+<feGaussianBlur stdDeviation="11.7378" result="effect1_foregroundBlur_640_5838"/>
+</filter>
+<linearGradient id="paint0_linear_640_5838" x1="94.7467" y1="112.753" x2="456.708" y2="450.381" gradientUnits="userSpaceOnUse">
+<stop stop-color="#1B91F3"/>
+<stop offset="1" stop-color="#0B68CB"/>
+</linearGradient>
+<radialGradient id="paint1_radial_640_5838" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(94.0803 228.926) rotate(66.5179) scale(217.118 207.993)">
+<stop offset="0.525579" stop-color="#0B4186" stop-opacity="0"/>
+<stop offset="1" stop-color="#0B4186" stop-opacity="0.45"/>
+</radialGradient>
+<radialGradient id="paint2_radial_640_5838" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(268.992 139.753) rotate(-127.994) scale(46.4165 76.8079)">
+<stop stop-color="#EF3ACC" stop-opacity="0"/>
+<stop offset="1" stop-color="#EF3ACC" stop-opacity="0.64"/>
+</radialGradient>
+<linearGradient id="paint3_linear_640_5838" x1="195.462" y1="199.184" x2="325.708" y2="54.1918" gradientUnits="userSpaceOnUse">
+<stop stop-color="#0F5DB0"/>
+<stop offset="1" stop-color="#0F5DB0" stop-opacity="0"/>
+</linearGradient>
+<radialGradient id="paint4_radial_640_5838" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(165.554 402.898) rotate(-64.2627) scale(338.724 418.421)">
+<stop offset="0.0160882" stop-color="#094188"/>
+<stop offset="0.967387" stop-color="#0B4186" stop-opacity="0"/>
+</radialGradient>
+<linearGradient id="paint5_linear_640_5838" x1="423.724" y1="304.166" x2="363.169" y2="452.859" gradientUnits="userSpaceOnUse">
+<stop stop-color="#E247C4" stop-opacity="0"/>
+<stop offset="1" stop-color="#E247C4" stop-opacity="0.64"/>
+</linearGradient>
+<linearGradient id="paint6_linear_640_5838" x1="48.1818" y1="170.812" x2="101.535" y2="385.555" gradientUnits="userSpaceOnUse">
+<stop offset="0.104632" stop-color="#EF3ACC"/>
+<stop offset="1" stop-color="#EF3ACC" stop-opacity="0"/>
+</linearGradient>
+<linearGradient id="paint7_linear_640_5838" x1="262.394" y1="199.543" x2="262.394" y2="416.692" gradientUnits="userSpaceOnUse">
+<stop stop-color="white"/>
+<stop offset="0.90535" stop-color="#BEE1FE"/>
+<stop offset="1" stop-color="#96CEFD"/>
+</linearGradient>
+<linearGradient id="paint8_linear_640_5838" x1="266.455" y1="322.423" x2="266.455" y2="413.391" gradientUnits="userSpaceOnUse">
+<stop stop-color="#BCE0FD"/>
+<stop offset="1" stop-color="#88CCFC"/>
+</linearGradient>
+<clipPath id="clip0_640_5838">
+<rect width="526" height="526" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/comm/mail/branding/thunderbird/default128.png b/comm/mail/branding/thunderbird/default128.png
new file mode 100644
index 0000000000..3b048618c7
--- /dev/null
+++ b/comm/mail/branding/thunderbird/default128.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/default16.png b/comm/mail/branding/thunderbird/default16.png
new file mode 100644
index 0000000000..f48e411e58
--- /dev/null
+++ b/comm/mail/branding/thunderbird/default16.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/default22.png b/comm/mail/branding/thunderbird/default22.png
new file mode 100644
index 0000000000..37136a7cb5
--- /dev/null
+++ b/comm/mail/branding/thunderbird/default22.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/default24.png b/comm/mail/branding/thunderbird/default24.png
new file mode 100644
index 0000000000..6c9888bd45
--- /dev/null
+++ b/comm/mail/branding/thunderbird/default24.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/default256.png b/comm/mail/branding/thunderbird/default256.png
new file mode 100644
index 0000000000..db25f9882f
--- /dev/null
+++ b/comm/mail/branding/thunderbird/default256.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/default32.png b/comm/mail/branding/thunderbird/default32.png
new file mode 100644
index 0000000000..6bbf73efb0
--- /dev/null
+++ b/comm/mail/branding/thunderbird/default32.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/default48.png b/comm/mail/branding/thunderbird/default48.png
new file mode 100644
index 0000000000..e20ccd2582
--- /dev/null
+++ b/comm/mail/branding/thunderbird/default48.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/default64.png b/comm/mail/branding/thunderbird/default64.png
new file mode 100644
index 0000000000..b7c7860db5
--- /dev/null
+++ b/comm/mail/branding/thunderbird/default64.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/disk.icns b/comm/mail/branding/thunderbird/disk.icns
new file mode 100644
index 0000000000..cd6bccfee3
--- /dev/null
+++ b/comm/mail/branding/thunderbird/disk.icns
Binary files differ
diff --git a/comm/mail/branding/thunderbird/dsstore b/comm/mail/branding/thunderbird/dsstore
new file mode 100755
index 0000000000..ce4a55c74b
--- /dev/null
+++ b/comm/mail/branding/thunderbird/dsstore
Binary files differ
diff --git a/comm/mail/branding/thunderbird/jar.mn b/comm/mail/branding/thunderbird/jar.mn
new file mode 100644
index 0000000000..0a9e85376a
--- /dev/null
+++ b/comm/mail/branding/thunderbird/jar.mn
@@ -0,0 +1,18 @@
+#filter substitution
+# 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 branding %content/branding/
+ content/branding/about-logo.svg (content/about-logo.svg)
+ content/branding/about-wordmark.svg (content/about-wordmark.svg)
+ content/branding/about.png (content/about.png)
+ content/branding/icon32.png (default32.png)
+ content/branding/icon48.png (default48.png)
+ content/branding/icon64.png (default64.png)
+ content/branding/icon128.png (default128.png)
+ content/branding/icon256.png (default256.png)
+ content/branding/aboutDialog.css (content/aboutDialog.css)
+ content/branding/logo-gradient.svg (content/logo-gradient.svg)
+ ../classic/skin/classic/messenger/icons/new-mail-alert.png (default48.png)
diff --git a/comm/mail/branding/thunderbird/locales/Makefile.in b/comm/mail/branding/thunderbird/locales/Makefile.in
new file mode 100644
index 0000000000..c0fdaa9993
--- /dev/null
+++ b/comm/mail/branding/thunderbird/locales/Makefile.in
@@ -0,0 +1,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/.
+
+LOCALE_TOPDIR=$(commtopsrcdir)
+LOCALE_RELATIVEDIR=mail/branding/thunderbird/locales
+
+include $(topsrcdir)/config/config.mk
diff --git a/comm/mail/branding/thunderbird/locales/en-US/brand.dtd b/comm/mail/branding/thunderbird/locales/en-US/brand.dtd
new file mode 100644
index 0000000000..0ed7d84857
--- /dev/null
+++ b/comm/mail/branding/thunderbird/locales/en-US/brand.dtd
@@ -0,0 +1,13 @@
+<!-- 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/. -->
+<!ENTITY brandShortName "Thunderbird">
+<!ENTITY brandShorterName "Thunderbird">
+<!ENTITY brandFullName "Mozilla Thunderbird">
+<!-- LOCALIZATION NOTE (brandProductName):
+ This brand name can be used in messages where the product name needs to
+ remain unchanged across different versions (Daily, Beta, etc.). -->
+<!ENTITY brandProductName "Thunderbird">
+<!ENTITY vendorShortName "Mozilla">
+<!ENTITY trademarkInfo.part1 "Mozilla Thunderbird and the Thunderbird logos
+ are trademarks of the Mozilla Foundation.">
diff --git a/comm/mail/branding/thunderbird/locales/en-US/brand.ftl b/comm/mail/branding/thunderbird/locales/en-US/brand.ftl
new file mode 100644
index 0000000000..1c3a9fc7c1
--- /dev/null
+++ b/comm/mail/branding/thunderbird/locales/en-US/brand.ftl
@@ -0,0 +1,21 @@
+# 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/.
+
+## Thunderbird Brand
+##
+## Thunderbird must be treated as a brand, and kept in English.
+## It cannot be:
+## - Transliterated.
+## - Translated.
+##
+## Reference: https://www.mozilla.org/styleguide/communications/translation/
+
+-brand-shorter-name = Thunderbird
+-brand-short-name = Thunderbird
+-brand-full-name = Mozilla Thunderbird
+# This brand name can be used in messages where the product name needs to
+# remain unchanged across different versions (Daily, Beta, etc.).
+-brand-product-name = Thunderbird
+-vendor-short-name = Mozilla
+trademarkInfo = Mozilla Thunderbird and the Thunderbird logos are trademarks of the Mozilla Foundation.
diff --git a/comm/mail/branding/thunderbird/locales/en-US/brand.properties b/comm/mail/branding/thunderbird/locales/en-US/brand.properties
new file mode 100644
index 0000000000..9dd5011219
--- /dev/null
+++ b/comm/mail/branding/thunderbird/locales/en-US/brand.properties
@@ -0,0 +1,7 @@
+# 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/.
+brandShortName=Thunderbird
+brandShorterName=Thunderbird
+brandFullName=Mozilla Thunderbird
+vendorShortName=Mozilla
diff --git a/comm/mail/branding/thunderbird/locales/jar.mn b/comm/mail/branding/thunderbird/locales/jar.mn
new file mode 100755
index 0000000000..601859210b
--- /dev/null
+++ b/comm/mail/branding/thunderbird/locales/jar.mn
@@ -0,0 +1,12 @@
+#filter substitution
+# 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/.
+
+[localization] @AB_CD@.jar:
+ branding (%*.ftl)
+
+@AB_CD@.jar:
+% locale branding @AB_CD@ %locale/branding/
+ locale/branding/brand.dtd (%brand.dtd)
+ locale/branding/brand.properties (%brand.properties)
diff --git a/comm/mail/branding/thunderbird/locales/moz.build b/comm/mail/branding/thunderbird/locales/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/mail/branding/thunderbird/locales/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/branding/thunderbird/messengerWindow.ico b/comm/mail/branding/thunderbird/messengerWindow.ico
new file mode 100644
index 0000000000..09473bd1a2
--- /dev/null
+++ b/comm/mail/branding/thunderbird/messengerWindow.ico
Binary files differ
diff --git a/comm/mail/branding/thunderbird/moz.build b/comm/mail/branding/thunderbird/moz.build
new file mode 100644
index 0000000000..a1906f0d14
--- /dev/null
+++ b/comm/mail/branding/thunderbird/moz.build
@@ -0,0 +1,11 @@
+# vim: set filetype=python:
+# 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/.
+
+DIRS += ["locales"]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+include("../branding-common.mozbuild")
+ThunderbirdBranding()
diff --git a/comm/mail/branding/thunderbird/msix/Assets/Calendar44x44.png b/comm/mail/branding/thunderbird/msix/Assets/Calendar44x44.png
new file mode 100644
index 0000000000..a4cac6039f
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/Calendar44x44.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/Email44x44.png b/comm/mail/branding/thunderbird/msix/Assets/Email44x44.png
new file mode 100644
index 0000000000..c52f653923
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/Email44x44.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/LargeTile.scale-200.png b/comm/mail/branding/thunderbird/msix/Assets/LargeTile.scale-200.png
new file mode 100644
index 0000000000..ea7173808b
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/LargeTile.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/News44x44.png b/comm/mail/branding/thunderbird/msix/Assets/News44x44.png
new file mode 100644
index 0000000000..bcf2859e57
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/News44x44.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/SmallTile.scale-200.png b/comm/mail/branding/thunderbird/msix/Assets/SmallTile.scale-200.png
new file mode 100644
index 0000000000..f0b7f629b1
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/SmallTile.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/Square150x150Logo.scale-200.png b/comm/mail/branding/thunderbird/msix/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 0000000000..f8cb50a244
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/Square150x150Logo.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png b/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png
new file mode 100644
index 0000000000..f6eb785fe4
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.altform-unplated_targetsize-256.png b/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.altform-unplated_targetsize-256.png
new file mode 100644
index 0000000000..f6eb785fe4
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.altform-unplated_targetsize-256.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.scale-200.png b/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 0000000000..2760b342bc
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.targetsize-256.png b/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.targetsize-256.png
new file mode 100644
index 0000000000..278b4d4b48
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/Square44x44Logo.targetsize-256.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/StoreLogo.scale-200.png b/comm/mail/branding/thunderbird/msix/Assets/StoreLogo.scale-200.png
new file mode 100644
index 0000000000..a94b2d2428
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/StoreLogo.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/msix/Assets/Wide310x150Logo.scale-200.png b/comm/mail/branding/thunderbird/msix/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 0000000000..52b13cea24
--- /dev/null
+++ b/comm/mail/branding/thunderbird/msix/Assets/Wide310x150Logo.scale-200.png
Binary files differ
diff --git a/comm/mail/branding/thunderbird/net.thunderbird.Thunderbird.appdata.xml b/comm/mail/branding/thunderbird/net.thunderbird.Thunderbird.appdata.xml
new file mode 100644
index 0000000000..30397d100f
--- /dev/null
+++ b/comm/mail/branding/thunderbird/net.thunderbird.Thunderbird.appdata.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<component type="desktop-application">
+ <id>net.thunderbird.Thunderbird</id>
+ <metadata_license>CC0-1.0</metadata_license>
+ <name>Thunderbird</name>
+ <summary>Thunderbird is a free and open source email, newsfeed, chat, and calendaring client</summary>
+ <description>
+ <!-- From https://www.thunderbird.net/en-US/about/ -->
+ <p>
+ Thunderbird is a free and open source email, newsfeed, chat, and
+ calendaring client, that’s easy to set up and customize. One of the core
+ principles of Thunderbird is the use and promotion of open standards -
+ this focus is a rejection of our world of closed platforms and services
+ that can’t communicate with each other. We want our users to have freedom
+ and choice in how they communicate.
+ </p>
+ <p>
+ Thunderbird is an open source project, which means anyone can contribute
+ ideas, designs, code, and time helping fellow users.
+ </p>
+ </description>
+ <categories>
+ <category>Calendar</category>
+ <category>Email</category>
+ <category>Office</category>
+ </categories>
+
+ <url type="homepage">https://www.thunderbird.net/</url>
+ <url type="bugtracker">https://bugzilla.mozilla.org/</url>
+ <url type="faq">https://support.mozilla.org/kb/thunderbird-faq/</url>
+ <url type="help">https://support.mozilla.org/products/thunderbird/</url>
+ <url type="donation">https://give.thunderbird.net/</url>
+ <url type="translate">https://www.thunderbird.net/en-US/get-involved/#translation</url>
+
+ <project_group>Mozilla</project_group>
+ <project_license>MPL-2.0</project_license>
+ <developer_name>Thunderbird Project</developer_name>
+
+ <icon type="remote" width="256" height="256">https://www.thunderbird.net/media/img/thunderbird/thunderbird-256.png</icon>
+
+ <mimetypes>
+ <mimetype>message/rfc822</mimetype>
+ <mimetype>x-scheme-handler/mailto</mimetype>
+ <mimetype>text/calendar</mimetype>
+ <mimetype>text/vcard</mimetype>
+ <mimetype>text/x-vcard</mimetype>
+ </mimetypes>
+
+ <!-- distributors: yes, this is a real person -->
+ <update_contact>tb-builds@thunderbird.net</update_contact>
+</component>
diff --git a/comm/mail/branding/thunderbird/newmail.ico b/comm/mail/branding/thunderbird/newmail.ico
new file mode 100644
index 0000000000..507c29dfa7
--- /dev/null
+++ b/comm/mail/branding/thunderbird/newmail.ico
Binary files differ
diff --git a/comm/mail/branding/thunderbird/pref/thunderbird-branding.js b/comm/mail/branding/thunderbird/pref/thunderbird-branding.js
new file mode 100644
index 0000000000..86286b955f
--- /dev/null
+++ b/comm/mail/branding/thunderbird/pref/thunderbird-branding.js
@@ -0,0 +1,40 @@
+// Default start page
+pref("mailnews.start_page.url", "https://live.thunderbird.net/%APP%/start?locale=%LOCALE%&version=%VERSION%&channel=%CHANNEL%&os=%OS%&buildid=%APPBUILDID%");
+
+// Start page override to load after an update. Balrog will set an appropriate
+// url for this, see whats_new_page.yml
+pref("mailnews.start_page.override_url", "");
+
+// app.update.url.manual: URL user can browse to manually if for some reason
+// all update installation attempts fail.
+// app.update.url.details: a default value for the "More information about this
+// update" link supplied in the "An update is available" page of the update
+// wizard.
+#if MOZ_UPDATE_CHANNEL == beta
+ pref("app.update.url.manual", "https://www.thunderbird.net/%LOCALE%/download/beta/");
+ pref("app.update.url.details", "https://www.thunderbird.net/notes/beta/");
+#else
+ // release channel
+ pref("app.update.url.manual", "https://www.thunderbird.net/");
+ pref("app.update.url.details", "https://www.thunderbird.net/notes/");
+#endif
+
+// Interval: Time between checks for a new version (in seconds)
+// nightly=8 hours, official=24 hours
+pref("app.update.interval", 86400);
+
+// Give the user x seconds to react before showing the big UI. default=24 hours
+pref("app.update.promptWaitTime", 86400);
+
+// The number of days a binary is permitted to be old
+// without checking for an update. This assumes that
+// app.update.checkInstallTime is true.
+pref("app.update.checkInstallTime.days", 63);
+
+// Give the user x seconds to reboot before showing a badge on the hamburger
+// button. default=4 days
+pref("app.update.badgeWaitTime", 345600);
+
+pref("app.vendorURL", "https://www.thunderbird.net/%LOCALE%/");
+
+pref("browser.search.param.ms-pc", "MOZT");
diff --git a/comm/mail/branding/thunderbird/thunderbird.VisualElementsManifest.xml b/comm/mail/branding/thunderbird/thunderbird.VisualElementsManifest.xml
new file mode 100644
index 0000000000..8b819fd36f
--- /dev/null
+++ b/comm/mail/branding/thunderbird/thunderbird.VisualElementsManifest.xml
@@ -0,0 +1,8 @@
+<Application xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'>
+ <VisualElements
+ ShowNameOnSquare150x150Logo='on'
+ Square150x150Logo='VisualElements\VisualElements_150.png'
+ Square70x70Logo='VisualElements\VisualElements_70.png'
+ ForegroundText='dark'
+ BackgroundColor='#c4d3d3'/>
+</Application>
diff --git a/comm/mail/branding/thunderbird/thunderbird.icns b/comm/mail/branding/thunderbird/thunderbird.icns
new file mode 100644
index 0000000000..db7a49ce58
--- /dev/null
+++ b/comm/mail/branding/thunderbird/thunderbird.icns
Binary files differ
diff --git a/comm/mail/branding/thunderbird/wizHeader.bmp b/comm/mail/branding/thunderbird/wizHeader.bmp
new file mode 100644
index 0000000000..a39bddbd99
--- /dev/null
+++ b/comm/mail/branding/thunderbird/wizHeader.bmp
Binary files differ
diff --git a/comm/mail/branding/thunderbird/wizHeaderRTL.bmp b/comm/mail/branding/thunderbird/wizHeaderRTL.bmp
new file mode 100644
index 0000000000..ae50ab4e40
--- /dev/null
+++ b/comm/mail/branding/thunderbird/wizHeaderRTL.bmp
Binary files differ
diff --git a/comm/mail/branding/thunderbird/wizWatermark.bmp b/comm/mail/branding/thunderbird/wizWatermark.bmp
new file mode 100644
index 0000000000..932c5ca548
--- /dev/null
+++ b/comm/mail/branding/thunderbird/wizWatermark.bmp
Binary files differ
diff --git a/comm/mail/branding/thunderbird/writeMessage.ico b/comm/mail/branding/thunderbird/writeMessage.ico
new file mode 100644
index 0000000000..62ec69573e
--- /dev/null
+++ b/comm/mail/branding/thunderbird/writeMessage.ico
Binary files differ
diff --git a/comm/mail/build.mk b/comm/mail/build.mk
new file mode 100644
index 0000000000..ea9196e73e
--- /dev/null
+++ b/comm/mail/build.mk
@@ -0,0 +1,40 @@
+# 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/.
+
+# Note that this file is "included" from $topsrcdir/Makefile.in, therefore
+# paths are relative to $topobjdir not the location of this file.
+
+package:
+ @$(MAKE) -C comm/mail/installer
+
+package-compare:
+ @$(MAKE) -C comm/mail/installer package-compare
+
+stage-package:
+ @$(MAKE) -C comm/mail/installer stage-package
+
+install::
+ @$(MAKE) -C comm/mail/installer install
+
+source-package::
+ @$(MAKE) -C comm/mail/installer source-package
+
+upload::
+ @$(MAKE) -C comm/mail/installer upload
+
+source-upload::
+ @$(MAKE) -C comm/mail/installer source-upload
+
+hg-bundle::
+ @$(MAKE) -C comm/mail/installer hg-bundle
+
+wget-en-US:
+ $(MAKE) -C comm/mail/locales wget-en-US
+
+merge-% post-merge-% installers-% langpack-% chrome-%:
+ $(MAKE) -C comm/mail/locales $@
+
+ifdef ENABLE_TESTS
+include $(topsrcdir)/comm/mail/testsuite-targets.mk
+endif
diff --git a/comm/mail/components/AboutRedirector.jsm b/comm/mail/components/AboutRedirector.jsm
new file mode 100644
index 0000000000..a652ba5a6d
--- /dev/null
+++ b/comm/mail/components/AboutRedirector.jsm
@@ -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/. */
+
+var EXPORTED_SYMBOLS = ["AboutRedirector"];
+
+function AboutRedirector() {}
+AboutRedirector.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]),
+
+ // Each entry in the map has the key as the part after the "about:" and the
+ // value as a record with url and flags entries. Note that each addition here
+ // should be coupled with a corresponding addition in mailComponents.manifest.
+ _redirMap: {
+ newserror: {
+ url: "chrome://messenger/content/newsError.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ rights: {
+ url: "chrome://messenger/content/aboutRights.xhtml",
+ flags:
+ Ci.nsIAboutModule.ALLOW_SCRIPT |
+ Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT,
+ },
+ support: {
+ url: "chrome://messenger/content/about-support/aboutSupport.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ preferences: {
+ url: "chrome://messenger/content/preferences/preferences.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ downloads: {
+ url: "chrome://messenger/content/downloads/aboutDownloads.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ policies: {
+ url: "chrome://messenger/content/policies/aboutPolicies.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ accountsettings: {
+ url: "chrome://messenger/content/AccountManager.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ accountsetup: {
+ url: "chrome://messenger/content/accountcreation/accountSetup.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ accountprovisioner: {
+ url: "chrome://messenger/content/newmailaccount/accountProvisioner.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ addressbook: {
+ url: "chrome://messenger/content/addressbook/aboutAddressBook.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ "3pane": {
+ url: "chrome://messenger/content/about3Pane.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ message: {
+ url: "chrome://messenger/content/aboutMessage.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ import: {
+ url: "chrome://messenger/content/aboutImport.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ profiling: {
+ url: "chrome://devtools/content/performance-new/aboutprofiling/index.xhtml",
+ flags:
+ Ci.nsIAboutModule.ALLOW_SCRIPT | Ci.nsIAboutModule.IS_SECURE_CHROME_UI,
+ },
+ },
+
+ /**
+ * Gets the module name from the given URI.
+ */
+ _getModuleName(aURI) {
+ // Strip out the first ? or #, and anything following it
+ let name = /[^?#]+/.exec(aURI.pathQueryRef)[0];
+ return name.toLowerCase();
+ },
+
+ getURIFlags(aURI) {
+ let name = this._getModuleName(aURI);
+ if (!(name in this._redirMap)) {
+ throw Components.Exception(`no about:${name}`, Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ return this._redirMap[name].flags;
+ },
+
+ newChannel(aURI, aLoadInfo) {
+ let name = this._getModuleName(aURI);
+ if (!(name in this._redirMap)) {
+ throw Components.Exception(`no about:${name}`, Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ let newURI = Services.io.newURI(this._redirMap[name].url);
+ let channel = Services.io.newChannelFromURIWithLoadInfo(newURI, aLoadInfo);
+ channel.originalURI = aURI;
+
+ if (
+ this._redirMap[name].flags &
+ Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT
+ ) {
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ aURI,
+ {}
+ );
+ channel.owner = principal;
+ }
+
+ return channel;
+ },
+
+ getChromeURI(aURI) {
+ let name = this._getModuleName(aURI);
+ if (!(name in this._redirMap)) {
+ throw Components.Exception(`no about:${name}`, Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ return Services.io.newURI(this._redirMap[name].url);
+ },
+};
diff --git a/comm/mail/components/AppIdleManager.jsm b/comm/mail/components/AppIdleManager.jsm
new file mode 100644
index 0000000000..cc42c23178
--- /dev/null
+++ b/comm/mail/components/AppIdleManager.jsm
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["appIdleManager"];
+
+// This module provides a mechanism to turn window focus and blur events
+// into app idle notifications. If we get a blur notification that is not
+// followed by a focus notification in less than some small number of seconds,
+// then we send a begin app idle notification.
+// If we get a focus event, and we're app idle, then we send an end app idle
+// notification.
+// The notification topic is "mail:appIdle", the values are "idle", and "back"
+
+var appIdleManager = {
+ _appIdle: false,
+ _timerInterval: 5000, // 5 seconds ought to be plenty
+ get _timer() {
+ delete this._timer;
+ return (this._timer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ ));
+ },
+
+ _timerCallback() {
+ appIdleManager._appIdle = true;
+ Services.obs.notifyObservers(null, "mail:appIdle", "idle");
+ },
+
+ onBlur() {
+ appIdleManager._timer.initWithCallback(
+ appIdleManager._timerCallback,
+ appIdleManager._timerInterval,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ onFocus() {
+ appIdleManager._timer.cancel();
+ if (appIdleManager._appIdle) {
+ appIdleManager._appIdle = false;
+ Services.obs.notifyObservers(null, "mail:appIdle", "back");
+ }
+ },
+};
diff --git a/comm/mail/components/MailComponents.manifest b/comm/mail/components/MailComponents.manifest
new file mode 100644
index 0000000000..f35c67e263
--- /dev/null
+++ b/comm/mail/components/MailComponents.manifest
@@ -0,0 +1,9 @@
+# MailGlue.jsm
+
+# This component must restrict its registration for the app-startup category
+# to the specific list of apps that use it so it doesn't get loaded in xpcshell.
+# Thus we restrict it to these apps:
+#
+# mail: {3550f703-e582-4d05-9a08-453d09bdfdc6}
+
+category app-startup MailGlue @mozilla.org/mail/mailglue;1 application={3550f703-e582-4d05-9a08-453d09bdfdc6}
diff --git a/comm/mail/components/MailGlue.jsm b/comm/mail/components/MailGlue.jsm
new file mode 100644
index 0000000000..f14237a55a
--- /dev/null
+++ b/comm/mail/components/MailGlue.jsm
@@ -0,0 +1,1380 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 EXPORTED_SYMBOLS = ["MailGlue", "MailTelemetryForTests"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+
+// lazy module getter
+
+XPCOMUtils.defineLazyGetter(lazy, "gMailBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+});
+
+if (AppConstants.NIGHTLY_BUILD) {
+ XPCOMUtils.defineLazyGetter(
+ lazy,
+ "WeaveService",
+ () => Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject
+ );
+}
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ChatCore: "resource:///modules/chatHandler.sys.mjs",
+
+ LightweightThemeConsumer:
+ "resource://gre/modules/LightweightThemeConsumer.sys.mjs",
+
+ OsEnvironment: "resource://gre/modules/OsEnvironment.sys.mjs",
+ PdfJs: "resource://pdf.js/PdfJs.sys.mjs",
+
+ RemoteSecuritySettings:
+ "resource://gre/modules/psm/RemoteSecuritySettings.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ ExtensionSupport: "resource:///modules/ExtensionSupport.jsm",
+ MailMigrator: "resource:///modules/MailMigrator.jsm",
+ MailServices: "resource:///modules/MailServices.jsm",
+ MailUsageTelemetry: "resource:///modules/MailUsageTelemetry.jsm",
+ OAuth2Providers: "resource:///modules/OAuth2Providers.jsm",
+ TBDistCustomizer: "resource:///modules/TBDistCustomizer.jsm",
+});
+
+if (AppConstants.MOZ_UPDATER) {
+ ChromeUtils.defineESModuleGetters(lazy, {
+ UpdateListener: "resource://gre/modules/UpdateListener.sys.mjs",
+ });
+}
+
+const listeners = {
+ observers: {},
+
+ observe(subject, topic, data) {
+ for (let module of this.observers[topic]) {
+ try {
+ lazy[module].observe(subject, topic, data);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ },
+
+ init() {
+ for (let observer of Object.keys(this.observers)) {
+ Services.obs.addObserver(this, observer);
+ }
+ },
+};
+if (AppConstants.MOZ_UPDATER) {
+ listeners.observers["update-downloading"] = ["UpdateListener"];
+ listeners.observers["update-staged"] = ["UpdateListener"];
+ listeners.observers["update-downloaded"] = ["UpdateListener"];
+ listeners.observers["update-available"] = ["UpdateListener"];
+ listeners.observers["update-error"] = ["UpdateListener"];
+ listeners.observers["update-swap"] = ["UpdateListener"];
+}
+
+const PREF_PDFJS_ISDEFAULT_CACHE_STATE = "pdfjs.enabledCache.state";
+
+let JSWINDOWACTORS = {
+ ChatAction: {
+ matches: ["chrome://chat/content/conv.html"],
+ parent: {
+ esModuleURI: "resource:///actors/ChatActionParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource:///actors/ChatActionChild.sys.mjs",
+ events: {
+ contextmenu: { mozSystemGroup: true },
+ },
+ },
+ },
+
+ ContextMenu: {
+ parent: {
+ esModuleURI: "resource:///actors/ContextMenuParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource:///actors/ContextMenuChild.sys.mjs",
+ events: {
+ contextmenu: { mozSystemGroup: true },
+ },
+ },
+ allFrames: true,
+ },
+
+ // As in ActorManagerParent.sys.mjs, but with single-site and single-page
+ // message manager groups added.
+ FindBar: {
+ parent: {
+ esModuleURI: "resource://gre/actors/FindBarParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource://gre/actors/FindBarChild.sys.mjs",
+ events: {
+ keypress: { mozSystemGroup: true },
+ },
+ },
+
+ allFrames: true,
+ messageManagerGroups: [
+ "browsers",
+ "single-site",
+ "single-page",
+ "test",
+ "",
+ ],
+ },
+
+ LinkClickHandler: {
+ parent: {
+ moduleURI: "resource:///actors/LinkClickHandlerParent.jsm",
+ },
+ child: {
+ moduleURI: "resource:///actors/LinkClickHandlerChild.jsm",
+ events: {
+ click: {},
+ },
+ },
+ messageManagerGroups: ["single-site", "webext-browsers"],
+ allFrames: true,
+ },
+
+ LinkHandler: {
+ parent: {
+ esModuleURI: "resource:///actors/LinkHandlerParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource:///actors/LinkHandlerChild.sys.mjs",
+ events: {
+ DOMHeadElementParsed: {},
+ DOMLinkAdded: {},
+ DOMLinkChanged: {},
+ pageshow: {},
+ // The `pagehide` event is only used to clean up state which will not be
+ // present if the actor hasn't been created.
+ pagehide: { createActor: false },
+ },
+ },
+
+ messageManagerGroups: ["browsers", "single-site", "single-page"],
+ },
+
+ // As in ActorManagerParent.sys.mjs, but with single-site and single-page
+ // message manager groups added.
+ LoginManager: {
+ parent: {
+ esModuleURI: "resource://gre/modules/LoginManagerParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource://gre/modules/LoginManagerChild.sys.mjs",
+ events: {
+ DOMDocFetchSuccess: {},
+ DOMFormBeforeSubmit: {},
+ DOMFormHasPassword: {},
+ DOMInputPasswordAdded: {},
+ },
+ },
+
+ allFrames: true,
+ messageManagerGroups: [
+ "browsers",
+ "single-site",
+ "single-page",
+ "webext-browsers",
+ "",
+ ],
+ },
+
+ MailLink: {
+ parent: {
+ moduleURI: "resource:///actors/MailLinkParent.jsm",
+ },
+ child: {
+ moduleURI: "resource:///actors/MailLinkChild.jsm",
+ events: {
+ click: {},
+ },
+ },
+ allFrames: true,
+ },
+
+ Pdfjs: {
+ parent: {
+ esModuleURI: "resource://pdf.js/PdfjsParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource://pdf.js/PdfjsChild.sys.mjs",
+ },
+ enablePreference: PREF_PDFJS_ISDEFAULT_CACHE_STATE,
+ allFrames: true,
+ },
+
+ Prompt: {
+ parent: {
+ moduleURI: "resource:///actors/PromptParent.jsm",
+ },
+ includeChrome: true,
+ allFrames: true,
+ },
+
+ StrictLinkClickHandler: {
+ parent: {
+ moduleURI: "resource:///actors/LinkClickHandlerParent.jsm",
+ },
+ child: {
+ moduleURI: "resource:///actors/LinkClickHandlerChild.jsm",
+ events: {
+ click: {},
+ },
+ },
+ messageManagerGroups: ["single-page"],
+ allFrames: true,
+ },
+
+ VCard: {
+ parent: {
+ moduleURI: "resource:///actors/VCardParent.jsm",
+ },
+ child: {
+ moduleURI: "resource:///actors/VCardChild.jsm",
+ events: {
+ click: {},
+ },
+ },
+ allFrames: true,
+ },
+};
+
+// Seconds of idle time before the late idle tasks will be scheduled.
+const LATE_TASKS_IDLE_TIME_SEC = 20;
+
+// Time after we stop tracking startup crashes.
+const STARTUP_CRASHES_END_DELAY_MS = 30 * 1000;
+
+/**
+ * Glue code that should be executed before any windows are opened. Any
+ * window-independent helper methods (a la nsBrowserGlue.js) should go in
+ * MailUtils.jsm instead.
+ */
+
+function MailGlue() {
+ XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "_userIdleService",
+ "@mozilla.org/widget/useridleservice;1",
+ "nsIUserIdleService"
+ );
+ this._init();
+}
+
+// This should match the constant of the same name in devtools
+// (devtools/client/framework/browser-toolbox/Launcher.sys.mjs). Otherwise the logic
+// in command-line-startup will fail. We have a test to ensure it matches, at
+// mail/base/test/unit/test_devtools_url.js.
+MailGlue.BROWSER_TOOLBOX_WINDOW_URL =
+ "chrome://devtools/content/framework/browser-toolbox/window.html";
+
+// A Promise that is resolved by an idle task after most start-up operations.
+MailGlue.afterStartUp = new Promise(resolve => {
+ MailGlue.resolveAfterStartUp = resolve;
+});
+
+MailGlue.prototype = {
+ _isNewProfile: undefined,
+
+ // init (called at app startup)
+ _init() {
+ // Start-up notifications, in order.
+ // app-startup happens first, registered in components.conf.
+ Services.obs.addObserver(this, "command-line-startup");
+ Services.obs.addObserver(this, "final-ui-startup");
+ Services.obs.addObserver(this, "quit-application-granted");
+ Services.obs.addObserver(this, "mail-startup-done");
+
+ // Shut-down notifications.
+ Services.obs.addObserver(this, "xpcom-shutdown");
+
+ // General notifications.
+ Services.obs.addObserver(this, "intl:app-locales-changed");
+ Services.obs.addObserver(this, "handle-xul-text-link");
+ Services.obs.addObserver(this, "chrome-document-global-created");
+ Services.obs.addObserver(this, "document-element-inserted");
+ Services.obs.addObserver(this, "handlersvc-store-initialized");
+
+ // Call the lazy getter to ensure ActorManagerParent is initialized.
+ lazy.ActorManagerParent;
+
+ // FindBar and LoginManager actors are included in JSWINDOWACTORS as they
+ // also apply to the single-site and single-page message manager groups.
+ // First we must unregister them to avoid errors.
+ ChromeUtils.unregisterWindowActor("FindBar");
+ ChromeUtils.unregisterWindowActor("LoginManager");
+
+ lazy.ActorManagerParent.addJSWindowActors(JSWINDOWACTORS);
+ },
+
+ // cleanup (called at shutdown)
+ _dispose() {
+ Services.obs.removeObserver(this, "command-line-startup");
+ Services.obs.removeObserver(this, "final-ui-startup");
+ Services.obs.removeObserver(this, "quit-application-granted");
+ // mail-startup-done is removed by its handler.
+
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+
+ Services.obs.removeObserver(this, "intl:app-locales-changed");
+ Services.obs.removeObserver(this, "handle-xul-text-link");
+ Services.obs.removeObserver(this, "chrome-document-global-created");
+ Services.obs.removeObserver(this, "document-element-inserted");
+ Services.obs.removeObserver(this, "handlersvc-store-initialized");
+
+ lazy.ExtensionSupport.unregisterWindowListener(
+ "Thunderbird-internal-BrowserConsole"
+ );
+
+ lazy.MailUsageTelemetry.uninit();
+
+ if (this._lateTasksIdleObserver) {
+ this._userIdleService.removeIdleObserver(
+ this._lateTasksIdleObserver,
+ LATE_TASKS_IDLE_TIME_SEC
+ );
+ delete this._lateTasksIdleObserver;
+ }
+ },
+
+ // nsIObserver implementation
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "app-startup":
+ // Record the previously started version. This is used to check for
+ // extensions that were disabled by an application update. We need to
+ // read this pref before the Add-Ons Manager changes it.
+ this.previousVersion = Services.prefs.getCharPref(
+ "extensions.lastAppVersion",
+ "0"
+ );
+ break;
+ case "command-line-startup":
+ // Check if this process is the developer toolbox process, and if it
+ // is, stop MailGlue from doing anything more. Also sets a flag that
+ // can be checked to see if this is the toolbox process.
+ let isToolboxProcess = false;
+ let commandLine = aSubject.QueryInterface(Ci.nsICommandLine);
+ let flagIndex = commandLine.findFlag("chrome", true) + 1;
+ if (
+ flagIndex > 0 &&
+ flagIndex < commandLine.length &&
+ commandLine.getArgument(flagIndex) ===
+ MailGlue.BROWSER_TOOLBOX_WINDOW_URL
+ ) {
+ isToolboxProcess = true;
+ }
+
+ MailGlue.__defineGetter__("isToolboxProcess", () => isToolboxProcess);
+
+ if (isToolboxProcess) {
+ // Clean up all of the listeners.
+ this._dispose();
+ }
+ break;
+ case "final-ui-startup":
+ // Initialise the permission manager. If this happens before telling
+ // the folder service that strings are available, it's a *much* less
+ // expensive operation than if it happens afterwards, because if
+ // strings are available, some types of mail URL go looking for things
+ // in message databases, causing massive amounts of I/O.
+ Services.perms.all;
+
+ Cc["@mozilla.org/msgFolder/msgFolderService;1"]
+ .getService(Ci.nsIMsgFolderService)
+ .initializeFolderStrings();
+ Cc["@mozilla.org/msgDBView/msgDBViewService;1"]
+ .getService(Ci.nsIMsgDBViewService)
+ .initializeDBViewStrings();
+ this._beforeUIStartup();
+ break;
+ case "quit-application-granted":
+ Services.startup.trackStartupCrashEnd();
+ if (AppConstants.MOZ_UPDATER) {
+ lazy.UpdateListener.reset();
+ }
+ break;
+ case "mail-startup-done":
+ this._onFirstWindowLoaded();
+ Services.obs.removeObserver(this, "mail-startup-done");
+ break;
+ case "xpcom-shutdown":
+ this._dispose();
+ break;
+ case "intl:app-locales-changed":
+ Cc["@mozilla.org/msgFolder/msgFolderService;1"]
+ .getService(Ci.nsIMsgFolderService)
+ .initializeFolderStrings();
+ Cc["@mozilla.org/msgDBView/msgDBViewService;1"]
+ .getService(Ci.nsIMsgDBViewService)
+ .initializeDBViewStrings();
+ let windows = Services.wm.getEnumerator("mail:3pane");
+ while (windows.hasMoreElements()) {
+ let win = windows.getNext();
+ win.document.getElementById("threadTree")?.invalidate();
+ }
+ // Refresh the folder tree.
+ let fls = Cc["@mozilla.org/mail/folder-lookup;1"].getService(
+ Ci.nsIFolderLookupService
+ );
+ fls.setPrettyNameFromOriginalAllFolders();
+ break;
+ case "handle-xul-text-link":
+ this._handleLink(aSubject, aData);
+ break;
+ case "chrome-document-global-created":
+ // Set up lwt, but only if the "lightweightthemes" attr is set on the root
+ // (i.e. in messenger.xhtml).
+ aSubject.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ if (
+ aSubject.document.documentElement.hasAttribute(
+ "lightweightthemes"
+ )
+ ) {
+ new lazy.LightweightThemeConsumer(aSubject.document);
+ }
+ },
+ { once: true }
+ );
+ break;
+ case "document-element-inserted":
+ let doc = aSubject;
+ if (
+ doc.nodePrincipal.isSystemPrincipal &&
+ (doc.contentType == "application/xhtml+xml" ||
+ doc.contentType == "text/html") &&
+ // People shouldn't be using our built-in custom elements in
+ // system-principal about:blank anyway, and trying to support that
+ // causes responsiveness regressions. So let's not support it.
+ doc.URL != "about:blank"
+ ) {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/customElements.js",
+ doc.ownerGlobal
+ );
+ }
+ break;
+ case "handlersvc-store-initialized": {
+ // Initialize PdfJs when running in-process and remote. This only
+ // happens once since PdfJs registers global hooks. If the PdfJs
+ // extension is installed the init method below will be overridden
+ // leaving initialization to the extension.
+ // parent only: configure default prefs, set up pref observers, register
+ // pdf content handler, and initializes parent side message manager
+ // shim for privileged api access.
+ lazy.PdfJs.init(this._isNewProfile);
+ break;
+ }
+ }
+ },
+
+ // Runs on startup, before the first command line handler is invoked
+ // (i.e. before the first window is opened).
+ _beforeUIStartup() {
+ lazy.TBDistCustomizer.applyPrefDefaults();
+
+ const UI_VERSION_PREF = "mail.ui-rdf.version";
+ this._isNewProfile = !Services.prefs.prefHasUserValue(UI_VERSION_PREF);
+
+ // handle any migration work that has to happen at profile startup
+ lazy.MailMigrator.migrateAtProfileStartup();
+
+ if (!Services.prefs.prefHasUserValue(PREF_PDFJS_ISDEFAULT_CACHE_STATE)) {
+ lazy.PdfJs.checkIsDefault(this._isNewProfile);
+ }
+
+ // Inject scripts into some devtools windows.
+ function _setupBrowserConsole(domWindow) {
+ // Browser Console is an XHTML document.
+ domWindow.document.title =
+ lazy.gMailBundle.GetStringFromName("errorConsoleTitle");
+ Services.scriptloader.loadSubScript(
+ "chrome://global/content/viewSourceUtils.js",
+ domWindow
+ );
+ }
+
+ lazy.ExtensionSupport.registerWindowListener(
+ "Thunderbird-internal-BrowserConsole",
+ {
+ chromeURLs: ["chrome://devtools/content/webconsole/index.html"],
+ onLoadWindow: _setupBrowserConsole,
+ }
+ );
+
+ // check if we're in safe mode
+ if (Services.appinfo.inSafeMode) {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/troubleshootMode.xhtml",
+ "_blank",
+ "chrome,centerscreen,modal,resizable=no",
+ null
+ );
+ }
+
+ lazy.AddonManager.maybeInstallBuiltinAddon(
+ "thunderbird-compact-light@mozilla.org",
+ "1.2",
+ "resource://builtin-themes/light/"
+ );
+ lazy.AddonManager.maybeInstallBuiltinAddon(
+ "thunderbird-compact-dark@mozilla.org",
+ "1.2",
+ "resource://builtin-themes/dark/"
+ );
+
+ if (AppConstants.MOZ_UPDATER) {
+ listeners.init();
+ }
+ },
+
+ _checkForOldBuildUpdates() {
+ // check for update if our build is old
+ if (
+ AppConstants.MOZ_UPDATER &&
+ Services.prefs.getBoolPref("app.update.checkInstallTime")
+ ) {
+ let buildID = Services.appinfo.appBuildID;
+ let today = new Date().getTime();
+ /* eslint-disable no-multi-spaces */
+ let buildDate = new Date(
+ buildID.slice(0, 4), // year
+ buildID.slice(4, 6) - 1, // months are zero-based.
+ buildID.slice(6, 8), // day
+ buildID.slice(8, 10), // hour
+ buildID.slice(10, 12), // min
+ buildID.slice(12, 14)
+ ) // ms
+ .getTime();
+ /* eslint-enable no-multi-spaces */
+
+ const millisecondsIn24Hours = 86400000;
+ let acceptableAge =
+ Services.prefs.getIntPref("app.update.checkInstallTime.days") *
+ millisecondsIn24Hours;
+
+ if (buildDate + acceptableAge < today) {
+ Cc["@mozilla.org/updates/update-service;1"]
+ .getService(Ci.nsIApplicationUpdateService)
+ .checkForBackgroundUpdates();
+ }
+ }
+ },
+
+ _onFirstWindowLoaded() {
+ // Start these services.
+ Cc["@mozilla.org/chat/logger;1"].getService(Ci.imILogger);
+
+ this._checkForOldBuildUpdates();
+
+ // On Windows 7 and above, initialize the jump list module.
+ const WINTASKBAR_CONTRACTID = "@mozilla.org/windows-taskbar;1";
+ if (
+ WINTASKBAR_CONTRACTID in Cc &&
+ Cc[WINTASKBAR_CONTRACTID].getService(Ci.nsIWinTaskbar).available
+ ) {
+ const { WinTaskbarJumpList } = ChromeUtils.import(
+ "resource:///modules/WindowsJumpLists.jsm"
+ );
+ WinTaskbarJumpList.startup();
+ }
+
+ const { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+ );
+ ExtensionsUI.init();
+
+ // If the application has been updated, check all installed extensions for
+ // updates.
+ let currentVersion = Services.appinfo.version;
+ if (this.previousVersion != "0" && this.previousVersion != currentVersion) {
+ let { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ );
+ let { XPIDatabase } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+ );
+ let addons = XPIDatabase.getAddons();
+ for (let addon of addons) {
+ if (addon.permissions() & AddonManager.PERM_CAN_UPGRADE) {
+ AddonManager.getAddonByID(addon.id).then(addon => {
+ if (!AddonManager.shouldAutoUpdate(addon)) {
+ return;
+ }
+ addon.findUpdates(
+ {
+ onUpdateFinished() {},
+ onUpdateAvailable(addon, install) {
+ install.install();
+ },
+ },
+ AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED
+ );
+ });
+ }
+ }
+ }
+
+ if (AppConstants.ASAN_REPORTER) {
+ var { AsanReporter } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsanReporter.sys.mjs"
+ );
+ AsanReporter.init();
+ }
+
+ // Check if Sync is configured
+ if (
+ AppConstants.NIGHTLY_BUILD &&
+ Services.prefs.prefHasUserValue("services.sync.username")
+ ) {
+ lazy.WeaveService.init();
+ }
+
+ this._scheduleStartupIdleTasks();
+ this._lateTasksIdleObserver = (idleService, topic, data) => {
+ if (topic == "idle") {
+ idleService.removeIdleObserver(
+ this._lateTasksIdleObserver,
+ LATE_TASKS_IDLE_TIME_SEC
+ );
+ delete this._lateTasksIdleObserver;
+ this._scheduleBestEffortUserIdleTasks();
+ }
+ };
+ this._userIdleService.addIdleObserver(
+ this._lateTasksIdleObserver,
+ LATE_TASKS_IDLE_TIME_SEC
+ );
+
+ lazy.MailUsageTelemetry.init();
+ },
+
+ /**
+ * Use this function as an entry point to schedule tasks that
+ * need to run only once after startup, and can be scheduled
+ * by using an idle callback.
+ *
+ * The functions scheduled here will fire from idle callbacks
+ * once every window has finished being restored by session
+ * restore, and it's guaranteed that they will run before
+ * the equivalent per-window idle tasks
+ * (from _schedulePerWindowIdleTasks in browser.js).
+ *
+ * If you have something that can wait even further than the
+ * per-window initialization, and is okay with not being run in some
+ * sessions, please schedule them using
+ * _scheduleBestEffortUserIdleTasks.
+ * Don't be fooled by thinking that the use of the timeout parameter
+ * will delay your function: it will just ensure that it potentially
+ * happens _earlier_ than expected (when the timeout limit has been reached),
+ * but it will not make it happen later (and out of order) compared
+ * to the other ones scheduled together.
+ */
+ _scheduleStartupIdleTasks() {
+ const idleTasks = [
+ {
+ task() {
+ // This module needs to be loaded so it registers to receive
+ // FormAutoComplete:GetSelectedIndex messages and respond
+ // appropriately, otherwise we get error messages like the one
+ // reported in bug 1635422.
+ ChromeUtils.importESModule(
+ "resource://gre/actors/AutoCompleteParent.sys.mjs"
+ );
+ },
+ },
+ {
+ task() {
+ // Make sure Gloda's up and running.
+ ChromeUtils.import("resource:///modules/gloda/GlodaPublic.jsm");
+ },
+ },
+ {
+ task() {
+ MailGlue.resolveAfterStartUp();
+ },
+ },
+ {
+ task() {
+ let { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+ );
+ setTimeout(function () {
+ Services.tm.idleDispatchToMainThread(
+ Services.startup.trackStartupCrashEnd
+ );
+ }, STARTUP_CRASHES_END_DELAY_MS);
+ },
+ },
+ {
+ condition: AppConstants.NIGHTLY_BUILD,
+ task: async () => {
+ // Register our sync engines.
+ await lazy.WeaveService.whenLoaded();
+ let Weave = lazy.WeaveService.Weave;
+
+ for (let [moduleName, engineName] of [
+ ["accounts", "AccountsEngine"],
+ ["addressBooks", "AddressBooksEngine"],
+ ["calendars", "CalendarsEngine"],
+ ["identities", "IdentitiesEngine"],
+ ]) {
+ let ns = ChromeUtils.importESModule(
+ `resource://services-sync/engines/${moduleName}.sys.mjs`
+ );
+ await Weave.Service.engineManager.register(ns[engineName]);
+ Weave.Service.engineManager
+ .get(moduleName.toLowerCase())
+ .startTracking();
+ }
+
+ if (lazy.WeaveService.enabled) {
+ // Schedule a sync (if enabled).
+ Weave.Service.scheduler.autoConnect();
+ }
+ },
+ },
+ {
+ condition: Services.prefs.getBoolPref("mail.chat.enabled"),
+ task() {
+ lazy.ChatCore.idleStart();
+ ChromeUtils.importESModule("resource:///modules/index_im.sys.mjs");
+ },
+ },
+ {
+ condition: AppConstants.MOZ_UPDATER,
+ task: () => {
+ lazy.UpdateListener.maybeShowUnsupportedNotification();
+ },
+ },
+ {
+ task() {
+ // Use idleDispatch a second time to run this after the per-window
+ // idle tasks.
+ ChromeUtils.idleDispatch(() => {
+ Services.obs.notifyObservers(
+ null,
+ "mail-startup-idle-tasks-finished"
+ );
+ });
+ },
+ },
+ // Do NOT add anything after idle tasks finished.
+ ];
+
+ for (let task of idleTasks) {
+ if ("condition" in task && !task.condition) {
+ continue;
+ }
+
+ ChromeUtils.idleDispatch(
+ () => {
+ if (!Services.startup.shuttingDown) {
+ let startTime = Cu.now();
+ try {
+ task.task();
+ } catch (ex) {
+ console.error(ex);
+ } finally {
+ ChromeUtils.addProfilerMarker("startupIdleTask", startTime);
+ }
+ }
+ },
+ task.timeout ? { timeout: task.timeout } : undefined
+ );
+ }
+ },
+
+ /**
+ * Use this function as an entry point to schedule tasks that we hope
+ * to run once per session, at any arbitrary point in time, and which we
+ * are okay with sometimes not running at all.
+ *
+ * This function will be called from an idle observer. Check the value of
+ * LATE_TASKS_IDLE_TIME_SEC to see the current value for this idle
+ * observer.
+ *
+ * Note: this function may never be called if the user is never idle for the
+ * requisite time (LATE_TASKS_IDLE_TIME_SEC). Be certain before adding
+ * something here that it's okay that it never be run.
+ */
+ _scheduleBestEffortUserIdleTasks() {
+ const idleTasks = [
+ // Certificates revocation list, etc.
+ () => lazy.RemoteSecuritySettings.init(),
+ // If we haven't already, ensure the address book manager is ready.
+ // This must happen at some point so that CardDAV address books sync.
+ () => lazy.MailServices.ab.directories,
+ // Telemetry.
+ async () => {
+ lazy.OsEnvironment.reportAllowedAppSources();
+ reportAccountTypes();
+ reportAddressBookTypes();
+ reportAccountSizes();
+ await reportCalendars();
+ reportPreferences();
+ reportUIConfiguration();
+ },
+ ];
+
+ for (let task of idleTasks) {
+ ChromeUtils.idleDispatch(async () => {
+ if (!Services.startup.shuttingDown) {
+ let startTime = Cu.now();
+ try {
+ await task();
+ } catch (ex) {
+ console.error(ex);
+ } finally {
+ ChromeUtils.addProfilerMarker("startupLateIdleTask", startTime);
+ }
+ }
+ });
+ }
+ },
+
+ _handleLink(aSubject, aData) {
+ let linkHandled = aSubject.QueryInterface(Ci.nsISupportsPRBool);
+ if (!linkHandled.data) {
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ aData = JSON.parse(aData);
+ let tabParams = { url: aData.href, linkHandler: null };
+ if (win) {
+ let tabmail = win.document.getElementById("tabmail");
+ if (tabmail) {
+ tabmail.openTab("contentTab", tabParams);
+ win.focus();
+ linkHandled.data = true;
+ return;
+ }
+ }
+
+ // If we didn't have an open 3 pane window, try and open one.
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ {
+ type: "contentTab",
+ tabParams,
+ }
+ );
+ linkHandled.data = true;
+ }
+ },
+
+ // for XPCOM
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
+
+/**
+ * Report account types to telemetry. For im accounts, use `im_protocol` as
+ * scalar key name.
+ */
+function reportAccountTypes() {
+ // Init all count with 0, so that when an account was set up before but
+ // removed now, we reset it in telemetry report.
+ let report = {
+ pop3: 0,
+ imap: 0,
+ nntp: 0,
+ exchange: 0,
+ rss: 0,
+ im_gtalk: 0,
+ im_irc: 0,
+ im_jabber: 0,
+ im_matrix: 0,
+ im_odnoklassniki: 0,
+ };
+
+ const providerReport = {
+ google: 0,
+ microsoft: 0,
+ yahoo_aol: 0,
+ other: 0,
+ };
+
+ for (let account of lazy.MailServices.accounts.accounts) {
+ const incomingServer = account.incomingServer;
+
+ let type = incomingServer.type;
+ if (type == "none") {
+ // Reporting one Local Folders account is not that useful. Skip it.
+ continue;
+ }
+
+ if (type === "im") {
+ let protocol =
+ incomingServer.wrappedJSObject.imAccount.protocol.normalizedName;
+ type = `im_${protocol}`;
+ }
+
+ // It's still possible to report other types not explicitly specified due to
+ // account types that used to exist, but no longer -- e.g. im_yahoo.
+ if (!report[type]) {
+ report[type] = 0;
+ }
+
+ report[type]++;
+
+ // Collect a rough understanding of the frequency of various OAuth
+ // providers.
+ if (incomingServer.authMethod == Ci.nsMsgAuthMethod.OAuth2) {
+ const hostnameDetails = lazy.OAuth2Providers.getHostnameDetails(
+ incomingServer.hostName
+ );
+
+ if (!hostnameDetails || hostnameDetails.length == 0) {
+ // Not a valid OAuth2 configuration; skip it
+ continue;
+ }
+
+ const host = hostnameDetails[0];
+
+ switch (host) {
+ case "accounts.google.com":
+ providerReport.google++;
+ break;
+ case "login.microsoftonline.com":
+ providerReport.microsoft++;
+ break;
+ case "login.yahoo.com":
+ case "login.aol.com":
+ providerReport.yahoo_aol++;
+ break;
+ default:
+ providerReport.other++;
+ }
+ }
+ }
+
+ for (let [type, count] of Object.entries(report)) {
+ Services.telemetry.keyedScalarSet("tb.account.count", type, count);
+ }
+
+ for (const [provider, count] of Object.entries(providerReport)) {
+ Services.telemetry.keyedScalarSet(
+ "tb.account.oauth2_provider_count",
+ provider,
+ count
+ );
+ }
+}
+
+/**
+ * Report size on disk and messages count of each type of folder to telemetry.
+ */
+function reportAccountSizes() {
+ let keys = [
+ "Inbox",
+ "Drafts",
+ "Trash",
+ "SentMail",
+ "Templates",
+ "Junk",
+ "Archive",
+ "Queue",
+ ];
+ for (let key of keys) {
+ Services.telemetry.keyedScalarSet("tb.account.total_messages", key, 0);
+ }
+ Services.telemetry.keyedScalarSet("tb.account.total_messages", "Other", 0);
+ Services.telemetry.keyedScalarSet("tb.account.total_messages", "Total", 0);
+
+ for (let server of lazy.MailServices.accounts.allServers) {
+ if (
+ server instanceof Ci.nsIPop3IncomingServer &&
+ server.deferredToAccount
+ ) {
+ // Skip deferred accounts
+ continue;
+ }
+
+ for (let folder of server.rootFolder.descendants) {
+ let key =
+ keys.find(x => folder.getFlag(Ci.nsMsgFolderFlags[x])) || "Other";
+ let totalMessages = folder.getTotalMessages(false);
+ if (totalMessages > 0) {
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.total_messages",
+ key,
+ totalMessages
+ );
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.total_messages",
+ "Total",
+ totalMessages
+ );
+ }
+ let sizeOnDisk = folder.sizeOnDisk;
+ if (sizeOnDisk > 0) {
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.size_on_disk",
+ key,
+ sizeOnDisk
+ );
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.size_on_disk",
+ "Total",
+ sizeOnDisk
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Report addressbook count and contact count to telemetry, keyed by addressbook
+ * type. Type is one of ["jsaddrbook", "jscarddav", "moz-abldapdirectory"], see
+ * AddrBookManager.jsm for more details.
+ *
+ * NOTE: We didn't use `dir.dirType` because it's just an integer, instead we
+ * use the scheme of `dir.URI` as the type.
+ */
+function reportAddressBookTypes() {
+ let report = {};
+ for (let dir of lazy.MailServices.ab.directories) {
+ let type = dir.URI.split(":")[0];
+
+ if (!report[type]) {
+ report[type] = { count: 0, contactCount: 0 };
+ }
+ report[type].count++;
+
+ try {
+ report[type].contactCount += dir.childCardCount;
+ } catch (ex) {
+ // Directories may throw NS_ERROR_NOT_IMPLEMENTED.
+ }
+ }
+
+ for (let [type, { count, contactCount }] of Object.entries(report)) {
+ Services.telemetry.keyedScalarSet(
+ "tb.addressbook.addressbook_count",
+ type,
+ count
+ );
+ Services.telemetry.keyedScalarSet(
+ "tb.addressbook.contact_count",
+ type,
+ contactCount
+ );
+ }
+}
+
+/**
+ * A telemetry probe to report calendar count and read only calendar count.
+ */
+async function reportCalendars() {
+ let telemetryReport = {};
+ let home = lazy.cal.l10n.getCalString("homeCalendarName");
+
+ for (let calendar of lazy.cal.manager.getCalendars()) {
+ if (calendar.name == home && calendar.type == "storage") {
+ // Ignore the "Home" calendar if it is disabled or unused as it's
+ // automatically added.
+ if (calendar.getProperty("disabled")) {
+ continue;
+ }
+ let items = await calendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 1,
+ null,
+ null
+ );
+ if (!items.length) {
+ continue;
+ }
+ }
+ if (!telemetryReport[calendar.type]) {
+ telemetryReport[calendar.type] = { count: 0, readOnlyCount: 0 };
+ }
+ telemetryReport[calendar.type].count++;
+ if (calendar.readOnly) {
+ telemetryReport[calendar.type].readOnlyCount++;
+ }
+ }
+
+ for (let [type, { count, readOnlyCount }] of Object.entries(
+ telemetryReport
+ )) {
+ Services.telemetry.keyedScalarSet(
+ "tb.calendar.calendar_count",
+ type.toLowerCase(),
+ count
+ );
+ Services.telemetry.keyedScalarSet(
+ "tb.calendar.read_only_calendar_count",
+ type.toLowerCase(),
+ readOnlyCount
+ );
+ }
+}
+
+function reportPreferences() {
+ let booleanPrefs = [
+ // General
+ "browser.cache.disk.smart_size.enabled",
+ "privacy.clearOnShutdown.cache",
+ "general.autoScroll",
+ "general.smoothScroll",
+ "intl.regional_prefs.use_os_locales",
+ "layers.acceleration.disabled",
+ "mail.biff.play_sound",
+ "mail.close_message_window.on_delete",
+ "mail.delete_matches_sort_order",
+ "mail.display_glyph",
+ "mail.mailnews.scroll_to_new_message",
+ "mail.prompt_purge_threshhold",
+ "mail.purge.ask",
+ "mail.showCondensedAddresses",
+ "mailnews.database.global.indexer.enabled",
+ "mailnews.mark_message_read.auto",
+ "mailnews.mark_message_read.delay",
+ "mailnews.reuse_message_window",
+ "mailnews.start_page.enabled",
+ "searchintegration.enable",
+
+ // Fonts
+ "mail.fixed_width_messages",
+
+ // Colors
+ "browser.display.use_system_colors",
+ "browser.underline_anchors",
+
+ // Read receipts
+ "mail.mdn.report.enabled",
+ "mail.receipt.request_return_receipt_on",
+
+ // Connection
+ "network.proxy.share_proxy_settings",
+ "network.proxy.socks_remote_dns",
+ "pref.advanced.proxies.disable_button.reload",
+ "signon.autologin.proxy",
+
+ // Offline
+ "offline.autoDetect",
+
+ // Compose
+ "ldap_2.autoComplete.useDirectory",
+ "mail.collect_email_address_outgoing",
+ "mail.compose.attachment_reminder",
+ "mail.compose.autosave",
+ "mail.compose.big_attachments.notify",
+ "mail.compose.default_to_paragraph",
+ "mail.e2ee.auto_enable",
+ "mail.e2ee.auto_disable",
+ "mail.e2ee.notify_on_auto_disable",
+ "mail.enable_autocomplete",
+ "mail.forward_add_extension",
+ "mail.SpellCheckBeforeSend",
+ "mail.spellcheck.inline",
+ "mail.warn_on_send_accel_key",
+ "msgcompose.default_colors",
+ "pref.ldap.disable_button.edit_directories",
+
+ // Send options
+ "mailnews.sendformat.auto_downgrade",
+
+ // Privacy
+ "browser.safebrowsing.enabled",
+ "mail.phishing.detection.enabled",
+ "mail.spam.logging.enabled",
+ "mail.spam.manualMark",
+ "mail.spam.markAsReadOnSpam",
+ "mailnews.downloadToTempFile",
+ "mailnews.message_display.disable_remote_image",
+ "network.cookie.blockFutureCookies",
+ "places.history.enabled",
+ "pref.privacy.disable_button.cookie_exceptions",
+ "pref.privacy.disable_button.view_cookies",
+ "pref.privacy.disable_button.view_passwords",
+ "privacy.donottrackheader.enabled",
+ "security.disable_button.openCertManager",
+ "security.disable_button.openDeviceManager",
+
+ // Chat
+ "messenger.options.getAttentionOnNewMessages",
+ "messenger.status.reportIdle",
+ "messenger.status.awayWhenIdle",
+ "mail.chat.enabled",
+ "mail.chat.play_sound",
+ "mail.chat.show_desktop_notifications",
+ "purple.conversations.im.send_typing",
+ "purple.logging.log_chats",
+ "purple.logging.log_ims",
+ "purple.logging.log_system",
+
+ // Calendar views
+ "calendar.view.showLocation",
+ "calendar.view-minimonth.showWeekNumber",
+ "calendar.week.d0sundaysoff",
+ "calendar.week.d1mondaysoff",
+ "calendar.week.d2tuesdaysoff",
+ "calendar.week.d3wednesdaysoff",
+ "calendar.week.d4thursdaysoff",
+ "calendar.week.d5fridaysoff",
+ "calendar.week.d6saturdaysoff",
+
+ // Calendar general
+ "calendar.item.editInTab",
+ "calendar.item.promptDelete",
+ "calendar.timezone.useSystemTimezone",
+
+ // Alarms
+ "calendar.alarms.playsound",
+ "calendar.alarms.show",
+ "calendar.alarms.showmissed",
+
+ // Unlisted
+ "mail.operate_on_msgs_in_collapsed_threads",
+ ];
+
+ let integerPrefs = [
+ // Mail UI
+ "mail.pane_config.dynamic",
+ "mail.ui.display.dateformat.default",
+ "mail.ui.display.dateformat.thisweek",
+ "mail.ui.display.dateformat.today",
+ ];
+
+ // Platform-specific preferences
+ if (AppConstants.platform === "win") {
+ booleanPrefs.push("mail.biff.show_tray_icon", "mail.minimizeToTray");
+ }
+
+ if (AppConstants.platform !== "macosx") {
+ booleanPrefs.push(
+ "mail.biff.show_alert",
+ "mail.biff.use_system_alert",
+
+ // Notifications
+ "mail.biff.alert.show_preview",
+ "mail.biff.alert.show_sender",
+ "mail.biff.alert.show_subject"
+ );
+ }
+
+ // Compile-time flag-dependent preferences
+ if (AppConstants.HAVE_SHELL_SERVICE) {
+ booleanPrefs.push("mail.shell.checkDefaultClient");
+ }
+
+ if (AppConstants.MOZ_WIDGET_GTK) {
+ booleanPrefs.push("widget.gtk.overlay-scrollbars.enabled");
+ }
+
+ if (AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ booleanPrefs.push("app.update.service.enabled");
+ }
+
+ if (AppConstants.MOZ_DATA_REPORTING) {
+ booleanPrefs.push("datareporting.healthreport.uploadEnabled");
+ }
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ booleanPrefs.push("browser.crashReports.unsubmittedCheck.autoSubmit2");
+ }
+
+ // Fetch and report preference values
+ for (let prefName of booleanPrefs) {
+ let prefValue = Services.prefs.getBoolPref(prefName, false);
+
+ Services.telemetry.keyedScalarSet(
+ "tb.preferences.boolean",
+ prefName,
+ prefValue
+ );
+ }
+
+ for (let prefName of integerPrefs) {
+ let prefValue = Services.prefs.getIntPref(prefName, 0);
+
+ Services.telemetry.keyedScalarSet(
+ "tb.preferences.integer",
+ prefName,
+ prefValue
+ );
+ }
+}
+
+function reportUIConfiguration() {
+ let docURL = "chrome://messenger/content/messenger.xhtml";
+
+ let folderTreeMode = Services.xulStore.getValue(docURL, "folderTree", "mode");
+ if (folderTreeMode) {
+ let folderTreeCompact = Services.xulStore.getValue(
+ docURL,
+ "folderTree",
+ "compact"
+ );
+ if (folderTreeCompact === "true") {
+ folderTreeMode += " (compact)";
+ }
+ Services.telemetry.scalarSet(
+ "tb.ui.configuration.folder_tree_modes",
+ folderTreeMode
+ );
+ }
+
+ let headerLayout = Services.xulStore.getValue(
+ docURL,
+ "messageHeader",
+ "layout"
+ );
+ if (headerLayout) {
+ headerLayout = JSON.parse(headerLayout);
+ for (let [key, value] of Object.entries(headerLayout)) {
+ if (key == "buttonStyle") {
+ value = { default: 0, "only-icons": 1, "only-text": 2 }[value];
+ }
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.message_header",
+ key,
+ value
+ );
+ }
+ }
+}
+
+/**
+ * Export these functions so we can test them. This object shouldn't be
+ * accessed outside of a test (hence the name).
+ */
+var MailTelemetryForTests = {
+ reportAccountTypes,
+ reportAccountSizes,
+ reportAddressBookTypes,
+ reportCalendars,
+ reportPreferences,
+ reportUIConfiguration,
+};
diff --git a/comm/mail/components/MessengerContentHandler.jsm b/comm/mail/components/MessengerContentHandler.jsm
new file mode 100644
index 0000000000..06d37a0811
--- /dev/null
+++ b/comm/mail/components/MessengerContentHandler.jsm
@@ -0,0 +1,793 @@
+/* -*- indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 EXPORTED_SYMBOLS = [
+ "MessengerContentHandler",
+ "MessageDisplayContentHandler",
+];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ FeedUtils: "resource:///modules/FeedUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MimeParser: "resource:///modules/mimeParser.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+function resolveURIInternal(aCmdLine, aArgument) {
+ var uri = aCmdLine.resolveURI(aArgument);
+
+ if (!(uri instanceof Ci.nsIFileURL)) {
+ return uri;
+ }
+
+ try {
+ if (uri.file.exists()) {
+ return uri;
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // We have interpreted the argument as a relative file URI, but the file
+ // doesn't exist. Try URI fixup heuristics: see bug 290782.
+
+ try {
+ uri = Services.uriFixup.getFixupURIInfo(aArgument, 0).preferredURI;
+ } catch (e) {
+ console.error(e);
+ }
+
+ return uri;
+}
+
+function handleIndexerResult(aFile) {
+ // Do this here because xpcshell isn't too happy with this at startup
+ // Make sure the folder tree is initialized
+ lazy.MailUtils.discoverFolders();
+
+ // Use the search integration module to convert the indexer result into a
+ // message header
+ const { SearchIntegration } = ChromeUtils.import(
+ "resource:///modules/SearchIntegration.jsm"
+ );
+ let msgHdr = SearchIntegration.handleResult(aFile);
+
+ // If we found a message header, open it, otherwise throw an exception
+ if (msgHdr) {
+ getOrOpen3PaneWindow().then(win => {
+ lazy.MailUtils.displayMessage(msgHdr);
+ });
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+}
+
+async function getOrOpen3PaneWindow() {
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+
+ if (!win) {
+ const startupPromise = new Promise(resolve => {
+ Services.obs.addObserver(
+ {
+ observe(subject) {
+ if (subject == win) {
+ Services.obs.removeObserver(this, "mail-startup-done");
+ resolve();
+ }
+ },
+ },
+ "mail-startup-done"
+ );
+ });
+
+ // Bug 277798 - we have to pass an argument to openWindow(), or
+ // else it won't honor the dialog=no instruction.
+ const argstring = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ win = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ argstring
+ );
+ await startupPromise;
+ }
+
+ await win.delayedStartupPromise;
+ return win;
+}
+
+/**
+ * Open the given uri.
+ * @param {nsIURI} uri - The uri to open.
+ */
+function openURI(uri) {
+ if (
+ !Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .isExposedProtocol(uri.scheme)
+ ) {
+ throw Components.Exception(`Can't open: ${uri.spec}`, Cr.NS_ERROR_FAILURE);
+ }
+
+ var channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ var loader = Cc["@mozilla.org/uriloader;1"].getService(Ci.nsIURILoader);
+
+ // We cannot load a URI on startup asynchronously without protecting
+ // the startup
+
+ var loadgroup = Cc["@mozilla.org/network/load-group;1"].createInstance(
+ Ci.nsILoadGroup
+ );
+
+ var loadlistener = {
+ onStartRequest(aRequest) {
+ Services.startup.enterLastWindowClosingSurvivalArea();
+ },
+
+ onStopRequest(aRequest, aStatusCode) {
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ loadgroup.groupObserver = loadlistener;
+
+ var listener = {
+ doContent(ctype, preferred, request, handler) {
+ var newHandler = Cc[
+ "@mozilla.org/uriloader/content-handler;1?type=application/x-message-display"
+ ].createInstance(Ci.nsIContentHandler);
+ newHandler.handleContent("application/x-message-display", this, request);
+ return true;
+ },
+ isPreferred(ctype, desired) {
+ if (ctype == "message/rfc822") {
+ return true;
+ }
+ return false;
+ },
+ canHandleContent(ctype, preferred, desired) {
+ return false;
+ },
+ loadCookie: null,
+ parentContentListener: null,
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIURIContentListener)) {
+ return this;
+ }
+
+ if (iid.equals(Ci.nsILoadGroup)) {
+ return loadgroup;
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ };
+ loader.openURI(channel, true, listener);
+}
+
+function MailDefaultHandler() {}
+
+MailDefaultHandler.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsICommandLineHandler",
+ "nsICommandLineValidator",
+ "nsIFactory",
+ ]),
+
+ /* nsICommandLineHandler */
+
+ handle(cmdLine) {
+ if (
+ cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH &&
+ Services.startup.wasSilentlyStarted
+ ) {
+ // If we are starting up in silent mode, don't open a window. We also need
+ // to make sure that the application doesn't immediately exit, so stay in
+ // a LastWindowClosingSurvivalArea until a window opens.
+ Services.startup.enterLastWindowClosingSurvivalArea();
+ Services.obs.addObserver(function windowOpenObserver() {
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ Services.obs.removeObserver(windowOpenObserver, "domwindowopened");
+ }, "domwindowopened");
+ return;
+ }
+
+ try {
+ var remoteCommand = cmdLine.handleFlagWithParam("remote", true);
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ }
+
+ if (remoteCommand != null) {
+ try {
+ var a = /^\s*(\w+)\(([^\)]*)\)\s*$/.exec(remoteCommand);
+ var remoteVerb = a[1].toLowerCase();
+ var remoteParams = a[2].split(",");
+
+ switch (remoteVerb) {
+ case "openurl": {
+ let xuri = cmdLine.resolveURI(remoteParams[0]);
+ openURI(xuri);
+ break;
+ }
+ case "mailto": {
+ let xuri = cmdLine.resolveURI("mailto:" + remoteParams[0]);
+ openURI(xuri);
+ break;
+ }
+ case "xfedocommand":
+ // xfeDoCommand(openBrowser)
+ switch (remoteParams[0].toLowerCase()) {
+ case "openinbox": {
+ getOrOpen3PaneWindow().then(win => win.focus());
+ break;
+ }
+ case "composemessage": {
+ let argstring = Cc[
+ "@mozilla.org/supports-string;1"
+ ].createInstance(Ci.nsISupportsString);
+ remoteParams.shift();
+ argstring.data = remoteParams.join(",");
+ let args = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ args.appendElement(argstring);
+ args.appendElement(cmdLine);
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ args
+ )
+ );
+ break;
+ }
+ default:
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ }
+ break;
+
+ default:
+ // Somebody sent us a remote command we don't know how to process:
+ // just abort.
+ throw Components.Exception(
+ `Unrecognized command: ${remoteParams[0]}`,
+ Cr.NS_ERROR_ABORT
+ );
+ }
+
+ cmdLine.preventDefault = true;
+ } catch (e) {
+ // If we had a -remote flag but failed to process it, throw
+ // NS_ERROR_ABORT so that the xremote code knows to return a failure
+ // back to the handling code.
+ dump(e);
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ }
+ }
+
+ var chromeParam = cmdLine.handleFlagWithParam("chrome", false);
+ if (chromeParam) {
+ // The parameter specifies the window to open. This code should *not*
+ // open messenger.xhtml as well.
+ try {
+ let argstring = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ let _uri = resolveURIInternal(cmdLine, chromeParam);
+ // only load URIs which do not inherit chrome privs
+ if (
+ !Services.io.URIChainHasFlags(
+ _uri,
+ Ci.nsIProtocolHandler.URI_INHERITS_SECURITY_CONTEXT
+ )
+ ) {
+ Services.ww.openWindow(
+ null,
+ _uri.spec,
+ "_blank",
+ "chrome,dialog=no,all",
+ argstring
+ );
+ cmdLine.preventDefault = true;
+ }
+ } catch (e) {
+ dump(e);
+ }
+ }
+
+ if (cmdLine.handleFlag("silent", false)) {
+ cmdLine.preventDefault = true;
+ }
+
+ // -MapiStartup
+ // indicates that this startup is due to MAPI. Don't do anything for now.
+ cmdLine.handleFlag("MapiStartup", false);
+
+ if (cmdLine.handleFlag("mail", false)) {
+ getOrOpen3PaneWindow().then(win => win.focusOnMail(0));
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("addressbook", false)) {
+ getOrOpen3PaneWindow().then(win => win.toAddressBook());
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("options", false)) {
+ getOrOpen3PaneWindow().then(win => win.openPreferencesTab());
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("calendar", false)) {
+ getOrOpen3PaneWindow().then(win => win.toCalendar());
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("keymanager", false)) {
+ getOrOpen3PaneWindow().then(win => win.openKeyManager());
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("setDefaultMail", false)) {
+ var shell = Cc["@mozilla.org/mail/shell-service;1"].getService(
+ Ci.nsIShellService
+ );
+ shell.setDefaultClient(true, Ci.nsIShellService.MAIL);
+ }
+
+ // The URI might be passed as the argument to the file parameter
+ let uri = cmdLine.handleFlagWithParam("file", false);
+ // macOS passes `-url mid:<msgid>` into the command line, drop the -url flag.
+ cmdLine.handleFlag("url", false);
+
+ var count = cmdLine.length;
+ if (count) {
+ var i = 0;
+ while (i < count) {
+ var curarg = cmdLine.getArgument(i);
+ if (!curarg.startsWith("-")) {
+ break;
+ }
+
+ dump("Warning: unrecognized command line flag " + curarg + "\n");
+ // To emulate the pre-nsICommandLine behavior, we ignore the
+ // argument after an unrecognized flag.
+ i += 2;
+ // xxxbsmedberg: make me use the console service!
+ }
+
+ if (i < count) {
+ uri = cmdLine.getArgument(i);
+
+ // mailto: URIs are frequently passed with spaces in them. They should be
+ // escaped into %20, but we hack around bad clients, see bug 231032
+ if (uri.startsWith("mailto:")) {
+ while (++i < count) {
+ var testarg = cmdLine.getArgument(i);
+ if (testarg.startsWith("-")) {
+ break;
+ }
+
+ uri += " " + testarg;
+ }
+ }
+ }
+ }
+
+ if (!uri && cmdLine.preventDefault) {
+ return;
+ }
+
+ if (!uri && cmdLine.state != Ci.nsICommandLine.STATE_INITIAL_LAUNCH) {
+ try {
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ window.focus();
+ return;
+ }
+ } catch (e) {
+ dump(e);
+ }
+ }
+ if (uri) {
+ if (/^file:/i.test(uri)) {
+ // Turn file URL into a file path so `resolveFile()` will work.
+ let fileURL = cmdLine.resolveURI(uri);
+ uri = fileURL.QueryInterface(Ci.nsIFileURL).file.path;
+ }
+ // Check for protocols first then look at the file ending.
+ // Protocols are able to contain file endings like '.ics'.
+ if (/^https?:/i.test(uri) || /^feed:/i.test(uri)) {
+ getOrOpen3PaneWindow().then(win => {
+ lazy.FeedUtils.subscribeToFeed(uri, null);
+ });
+ } else if (/^webcals?:\/\//i.test(uri)) {
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://calendar/content/calendar-creation.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,centerscreen",
+ Services.io.newURI(uri)
+ )
+ );
+ } else if (/^mid:/i.test(uri)) {
+ getOrOpen3PaneWindow().then(win => {
+ lazy.MailUtils.openMessageByMessageId(uri.slice(4));
+ });
+ } else if (/^(mailbox|imap|news)-message:\/\//.test(uri)) {
+ getOrOpen3PaneWindow().then(win => {
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ lazy.MailUtils.displayMessage(messenger.msgHdrFromURI(uri));
+ });
+ } else if (/^imap:/i.test(uri) || /^s?news:/i.test(uri)) {
+ getOrOpen3PaneWindow().then(win => {
+ openURI(cmdLine.resolveURI(uri));
+ });
+ } else if (
+ // While the leading web+ and ext+ identifiers may be case insensitive,
+ // the protocol identifiers must be lowercase.
+ /^(web|ext)\+[a-z]+:/i.test(uri) &&
+ /^[a-z]+:/.test(uri.split("+")[1])
+ ) {
+ getOrOpen3PaneWindow().then(win => {
+ win.gTabmail.openTab("contentTab", {
+ url: uri,
+ linkHandler: "single-site",
+ background: false,
+ duplicate: true,
+ });
+ });
+ } else if (
+ uri.toLowerCase().endsWith(".mozeml") ||
+ uri.toLowerCase().endsWith(".wdseml")
+ ) {
+ handleIndexerResult(cmdLine.resolveFile(uri));
+ cmdLine.preventDefault = true;
+ } else if (uri.toLowerCase().endsWith(".eml")) {
+ // Open this eml in a new message window
+ let file = cmdLine.resolveFile(uri);
+ // No point in trying to open a file if it doesn't exist or is empty
+ if (file.exists() && file.fileSize > 0) {
+ // Read this eml and extract its headers to check for X-Unsent.
+ let fstream = null;
+ let headers = new Map();
+ try {
+ fstream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ fstream.init(file, -1, 0, 0);
+ let data = lazy.NetUtil.readInputStreamToString(
+ fstream,
+ fstream.available()
+ );
+ headers = lazy.MimeParser.extractHeaders(data);
+ } catch (e) {
+ // Ignore errors on reading the eml or extracting its headers. The
+ // test for the X-Unsent header below will fail and the message
+ // window will take care of any error handling.
+ } finally {
+ if (fstream) {
+ fstream.close();
+ }
+ }
+
+ // Get the URL for this file
+ let fileURL = Services.io
+ .newFileURI(file)
+ .QueryInterface(Ci.nsIFileURL);
+ fileURL = fileURL
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ if (headers.get("X-Unsent") == "1") {
+ getOrOpen3PaneWindow().then(win => {
+ const msgWindow = Cc[
+ "@mozilla.org/messenger/msgwindow;1"
+ ].createInstance(Ci.nsIMsgWindow);
+ MailServices.compose.OpenComposeWindow(
+ win,
+ {},
+ fileURL.spec,
+ Ci.nsIMsgCompType.Draft,
+ Ci.nsIMsgCompFormat.Default,
+ null,
+ headers.get("from"),
+ msgWindow
+ );
+ });
+ } else {
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ )
+ );
+ }
+ cmdLine.preventDefault = true;
+ } else {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let title, message;
+ if (!file.exists()) {
+ title = bundle.GetStringFromName("fileNotFoundTitle");
+ message = bundle.formatStringFromName("fileNotFoundMsg", [
+ file.path,
+ ]);
+ } else {
+ // The file is empty
+ title = bundle.GetStringFromName("fileEmptyTitle");
+ message = bundle.formatStringFromName("fileEmptyMsg", [file.path]);
+ }
+
+ Services.prompt.alert(null, title, message);
+ }
+ } else if (uri.toLowerCase().endsWith(".ics")) {
+ // An .ics calendar file! Open the ics file dialog.
+ let file = cmdLine.resolveFile(uri);
+ if (file.exists() && file.fileSize > 0) {
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://calendar/content/calendar-ics-file-dialog.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,centerscreen",
+ file
+ )
+ );
+ }
+ } else if (uri.toLowerCase().endsWith(".vcf")) {
+ // A VCard! Be smart and open the "add contact" dialog.
+ let file = cmdLine.resolveFile(uri);
+ if (file.exists() && file.fileSize > 0) {
+ let winPromise = getOrOpen3PaneWindow();
+ let uriSpec = Services.io.newFileURI(file).spec;
+ lazy.NetUtil.asyncFetch(
+ { uri: uriSpec, loadUsingSystemPrincipal: true },
+ function (inputStream, status) {
+ if (!Components.isSuccessCode(status)) {
+ return;
+ }
+
+ let data = lazy.NetUtil.readInputStreamToString(
+ inputStream,
+ inputStream.available()
+ );
+ // Try to detect the character set and decode. Only UTF-8 is
+ // valid from vCard 4.0, but we support older versions, so other
+ // charsets are possible.
+ let charset = Cc["@mozilla.org/messengercompose/computils;1"]
+ .createInstance(Ci.nsIMsgCompUtils)
+ .detectCharset(data);
+ let buffer = new Uint8Array(
+ Array.from(data, c => c.charCodeAt(0))
+ );
+ data = new TextDecoder(charset).decode(buffer);
+
+ winPromise.then(win =>
+ win.toAddressBook({
+ action: "create",
+ vCard: decodeURIComponent(data),
+ })
+ );
+ }
+ );
+ }
+ } else {
+ getOrOpen3PaneWindow().then(win => {
+ // This must be a regular filename. Use it to create a new message
+ // with attachment.
+ let msgParams = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ let localFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ try {
+ // Unescape the URI so that we work with clients that escape spaces.
+ localFile.initWithPath(unescape(uri));
+ attachment.url = fileHandler.getURLSpecFromActualFile(localFile);
+ composeFields.addAttachment(attachment);
+
+ msgParams.type = Ci.nsIMsgCompType.New;
+ msgParams.format = Ci.nsIMsgCompFormat.Default;
+ msgParams.composeFields = composeFields;
+
+ MailServices.compose.OpenComposeWindowWithParams(win, msgParams);
+ } catch (e) {
+ // Let protocol handlers try to take care.
+ openURI(cmdLine.resolveURI(uri));
+ }
+ });
+ }
+ } else {
+ getOrOpen3PaneWindow();
+ }
+ },
+
+ /* nsICommandLineValidator */
+ validate(cmdLine) {
+ var osintFlagIdx = cmdLine.findFlag("osint", false);
+ if (osintFlagIdx == -1) {
+ return;
+ }
+
+ // Other handlers may use osint so only handle the osint flag if the mail
+ // or compose flag is also present and the command line is valid.
+ var mailFlagIdx = cmdLine.findFlag("mail", false);
+ var composeFlagIdx = cmdLine.findFlag("compose", false);
+ if (mailFlagIdx == -1 && composeFlagIdx == -1) {
+ return;
+ }
+
+ // If both flags are present use the first flag found so the command line
+ // length test will fail.
+ if (mailFlagIdx > -1 && composeFlagIdx > -1) {
+ var actionFlagIdx =
+ mailFlagIdx > composeFlagIdx ? composeFlagIdx : mailFlagIdx;
+ } else {
+ actionFlagIdx = mailFlagIdx > -1 ? mailFlagIdx : composeFlagIdx;
+ }
+
+ if (actionFlagIdx && osintFlagIdx > -1) {
+ var param = cmdLine.getArgument(actionFlagIdx + 1);
+ if (
+ cmdLine.length != actionFlagIdx + 2 ||
+ /thunderbird.url.(mailto|news):/.test(param)
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ }
+ cmdLine.handleFlag("osint", false);
+ }
+ },
+
+ openInExternal(uri) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ },
+
+ handleContent(aContentType, aWindowContext, aRequest) {
+ try {
+ if (
+ !Cc["@mozilla.org/webnavigation-info;1"]
+ .getService(Ci.nsIWebNavigationInfo)
+ .isTypeSupported(aContentType, null)
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+
+ aRequest.QueryInterface(Ci.nsIChannel);
+
+ // For internal protocols (e.g. imap, mailbox, mailto), we want to handle
+ // them internally as we know what to do. For http and https we don't
+ // actually deal with external windows very well, so we redirect them to
+ // the external browser.
+ if (!aRequest.URI.schemeIs("http") && !aRequest.URI.schemeIs("https")) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+
+ this.openInExternal(aRequest.URI);
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+ },
+
+ helpInfo:
+ " -mail Go to the mail tab.\n" +
+ " -addressbook Go to the address book tab.\n" +
+ " -calendar Go to the calendar tab.\n" +
+ " -options Go to the settings tab.\n" +
+ " -file Open the specified email file or ICS calendar file.\n" +
+ " -setDefaultMail Set this app as the default mail client.\n" +
+ " -keymanager Open the OpenPGP Key Manager.\n",
+
+ /* nsIFactory */
+
+ createInstance(iid) {
+ return this.QueryInterface(iid);
+ },
+};
+
+function MessengerContentHandler() {
+ if (!gMessengerContentHandler) {
+ gMessengerContentHandler = this;
+ }
+ return gMessengerContentHandler;
+}
+
+MessengerContentHandler.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIContentHandler"]),
+};
+
+var gMessengerContentHandler = new MailDefaultHandler();
+
+/**
+ * Open a message/rfc822 or eml file in a new msg window.
+ *
+ * @implements {nsIContentHandler}
+ */
+class MessageDisplayContentHandler {
+ QueryInterface = ChromeUtils.generateQI(["nsIContentHandler"]);
+
+ handleContent(contentType, windowContext, request) {
+ let channel = request.QueryInterface(Ci.nsIChannel);
+ if (!channel) {
+ throw Components.Exception(
+ "Expecting an nsIChannel",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ let uri = channel.URI;
+ let mailnewsUrl;
+ try {
+ mailnewsUrl = uri.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ } catch (e) {}
+ if (mailnewsUrl) {
+ let queryPart = mailnewsUrl.query.replace(
+ "type=message/rfc822",
+ "type=application/x-message-display"
+ );
+ uri = mailnewsUrl.mutate().setQuery(queryPart).finalize();
+ } else if (uri.scheme == "file") {
+ uri = uri
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+ }
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ uri
+ )
+ );
+ }
+}
diff --git a/comm/mail/components/StartupRecorder.jsm b/comm/mail/components/StartupRecorder.jsm
new file mode 100644
index 0000000000..f7443b6c57
--- /dev/null
+++ b/comm/mail/components/StartupRecorder.jsm
@@ -0,0 +1,229 @@
+/* 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 EXPORTED_SYMBOLS = ["StartupRecorder"];
+
+const Cm = Components.manager;
+Cm.QueryInterface(Ci.nsIServiceManager);
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+let firstPaintNotification = "widget-first-paint";
+// widget-first-paint fires much later than expected on Linux.
+if (AppConstants.platform == "linux") {
+ firstPaintNotification = "xul-window-visible";
+}
+
+let win, canvas;
+let paints = [];
+let afterPaintListener = () => {
+ let width, height;
+ canvas.width = width = win.innerWidth;
+ canvas.height = height = win.innerHeight;
+ if (width < 1 || height < 1) {
+ return;
+ }
+ let ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
+
+ ctx.drawWindow(
+ win,
+ 0,
+ 0,
+ width,
+ height,
+ "white",
+ ctx.DRAWWINDOW_DO_NOT_FLUSH |
+ ctx.DRAWWINDOW_DRAW_VIEW |
+ ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES |
+ ctx.DRAWWINDOW_USE_WIDGET_LAYERS
+ );
+ paints.push({
+ data: ctx.getImageData(0, 0, width, height).data,
+ width,
+ height,
+ });
+};
+
+/**
+ * The StartupRecorder component observes notifications at various stages of
+ * startup and records the set of JS modules that were already loaded at
+ * each of these points.
+ * The records are meant to be used by startup tests in
+ * browser/base/content/test/performance
+ * This component only exists in nightly and debug builds, it doesn't ship in
+ * our release builds.
+ */
+function StartupRecorder() {
+ this.wrappedJSObject = this;
+ this.data = {
+ images: {
+ "image-drawing": new Set(),
+ "image-loading": new Set(),
+ },
+ code: {},
+ extras: {},
+ prefStats: {},
+ };
+ this.done = new Promise(resolve => {
+ this._resolve = resolve;
+ });
+}
+StartupRecorder.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ record(name) {
+ ChromeUtils.addProfilerMarker("startupRecorder:" + name);
+ this.data.code[name] = {
+ modules: Cu.loadedJSModules.concat(Cu.loadedESModules),
+ services: Object.keys(Cc).filter(c => {
+ try {
+ return Cm.isServiceInstantiatedByContractID(c, Ci.nsISupports);
+ } catch (e) {
+ return false;
+ }
+ }),
+ };
+ this.data.extras[name] = {
+ hiddenWindowLoaded: Services.appShell.hasHiddenWindow,
+ };
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "app-startup" || topic == "content-process-ready-for-script") {
+ // Don't do anything in xpcshell.
+ if (Services.appinfo.ID != "{3550f703-e582-4d05-9a08-453d09bdfdc6}") {
+ return;
+ }
+
+ if (
+ !Services.prefs.getBoolPref("browser.startup.record", false) &&
+ !Services.prefs.getBoolPref("browser.startup.recordImages", false)
+ ) {
+ this._resolve();
+ this._resolve = null;
+ return;
+ }
+
+ // We can't ensure our observer will be called first or last, so the list of
+ // topics we observe here should avoid the topics used to trigger things
+ // during startup (eg. the topics observed by BrowserGlue.jsm).
+ let topics = [
+ "profile-do-change", // This catches stuff loaded during app-startup
+ "toplevel-window-ready", // Catches stuff from final-ui-startup
+ firstPaintNotification,
+ "mail-startup-done",
+ "mail-startup-idle-tasks-finished",
+ ];
+
+ if (Services.prefs.getBoolPref("browser.startup.recordImages", false)) {
+ // For code simplicify, recording images excludes the other startup
+ // recorder behaviors, so we can observe only the image topics.
+ topics = [
+ "image-loading",
+ "image-drawing",
+ "mail-startup-idle-tasks-finished",
+ ];
+ }
+ for (let t of topics) {
+ Services.obs.addObserver(this, t);
+ }
+ return;
+ }
+
+ // We only care about the first paint notification for browser windows, and
+ // not other types (for example, the gfx sanity test window)
+ if (topic == firstPaintNotification) {
+ // In the case we're handling xul-window-visible, we'll have been handed
+ // an nsIAppWindow instead of an nsIDOMWindow.
+ if (subject instanceof Ci.nsIAppWindow) {
+ subject = subject
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindow);
+ }
+
+ if (
+ subject.document.documentElement.getAttribute("windowtype") !=
+ "mail:3pane"
+ ) {
+ return;
+ }
+ }
+
+ if (topic == "image-drawing" || topic == "image-loading") {
+ this.data.images[topic].add(data);
+ return;
+ }
+
+ Services.obs.removeObserver(this, topic);
+
+ if (topic == firstPaintNotification) {
+ // Because of the check for mail:3pane we made earlier, we know
+ // that if we got here, then the subject must be the first browser window.
+ win = subject;
+ canvas = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.mozOpaque = true;
+ afterPaintListener();
+ win.addEventListener("MozAfterPaint", afterPaintListener);
+ }
+
+ // TODO: Figure out what can replace this section.
+ if (topic == "mail-startup-done") {
+ // We use idleDispatchToMainThread here to record the set of
+ // loaded scripts after we are fully done with startup and ready
+ // to react to user events.
+ Services.tm.dispatchToMainThread(
+ this.record.bind(this, "before handling user events")
+ );
+ } else if (topic == "mail-startup-idle-tasks-finished") {
+ if (Services.prefs.getBoolPref("browser.startup.recordImages", false)) {
+ Services.obs.removeObserver(this, "image-drawing");
+ Services.obs.removeObserver(this, "image-loading");
+ this._resolve();
+ this._resolve = null;
+ return;
+ }
+
+ this.record("before becoming idle");
+ win.removeEventListener("MozAfterPaint", afterPaintListener);
+ win = null;
+ this.data.frames = paints;
+ this.data.prefStats = {};
+ if (AppConstants.DEBUG) {
+ Services.prefs.readStats(
+ (key, value) => (this.data.prefStats[key] = value)
+ );
+ }
+ paints = null;
+
+ if (!Services.env.exists("MOZ_PROFILER_STARTUP_PERFORMANCE_TEST")) {
+ this._resolve();
+ this._resolve = null;
+ return;
+ }
+
+ Services.profiler.getProfileDataAsync().then(profileData => {
+ this.data.profile = profileData;
+ // There's no equivalent StartProfiler call in this file because the
+ // profiler is started using the MOZ_PROFILER_STARTUP environment
+ // variable in browser/base/content/test/performance/browser.ini
+ Services.profiler.StopProfiler();
+
+ this._resolve();
+ this._resolve = null;
+ });
+ } else {
+ const topicsToNames = {
+ "profile-do-change": "before profile selection",
+ "toplevel-window-ready": "before opening first browser window",
+ };
+ topicsToNames[firstPaintNotification] = "before first paint";
+ this.record(topicsToNames[topic]);
+ }
+ },
+};
diff --git a/comm/mail/components/about-support/AboutSupportMac.jsm b/comm/mail/components/about-support/AboutSupportMac.jsm
new file mode 100644
index 0000000000..16f1c258f8
--- /dev/null
+++ b/comm/mail/components/about-support/AboutSupportMac.jsm
@@ -0,0 +1,16 @@
+/* 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 EXPORTED_SYMBOLS = ["AboutSupportPlatform"];
+
+var AboutSupportPlatform = {
+ /**
+ * Given an nsIFile, gets the file system type. The type is returned as a
+ * string. Possible values are "network", "local", "unknown" and null.
+ */
+ getFileSystemType(aFile) {
+ // Not implemented
+ return null;
+ },
+};
diff --git a/comm/mail/components/about-support/AboutSupportUnix.jsm b/comm/mail/components/about-support/AboutSupportUnix.jsm
new file mode 100644
index 0000000000..a27b3c99c5
--- /dev/null
+++ b/comm/mail/components/about-support/AboutSupportUnix.jsm
@@ -0,0 +1,137 @@
+/* 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 EXPORTED_SYMBOLS = ["AboutSupportPlatform"];
+
+// JS ctypes are needed to get at the data we need
+var { ctypes } = ChromeUtils.importESModule(
+ "resource://gre/modules/ctypes.sys.mjs"
+);
+var GFile = ctypes.StructType("GFile");
+var GFileInfo = ctypes.StructType("GFileInfo");
+var GError = ctypes.StructType("GError");
+var GCancellable = ctypes.StructType("GCancellable");
+
+var G_FILE_ATTRIBUTE_FILESYSTEM_TYPE = "filesystem::type";
+
+var kNetworkFilesystems = ["afs", "cifs", "nfs", "smb"];
+
+// These libraries might not be available on all systems.
+var gLibsExist = false;
+try {
+ // GC is responsible for closing these libraries if they exist.
+ var glib = ctypes.open("libglib-2.0.so.0");
+ var gobject = ctypes.open("libgobject-2.0.so.0");
+ var gio = ctypes.open("libgio-2.0.so.0");
+ gLibsExist = true;
+} catch (ex) {}
+
+if (gLibsExist) {
+ var g_free = glib.declare(
+ "g_free",
+ ctypes.default_abi,
+ ctypes.void_t,
+ ctypes.voidptr_t
+ );
+ var g_object_unref = gobject.declare(
+ "g_object_unref",
+ ctypes.default_abi,
+ ctypes.void_t,
+ ctypes.voidptr_t
+ );
+}
+
+var AboutSupportPlatform = {
+ /**
+ * Given an nsIFile, gets the file system type. The type is returned as a
+ * string. Possible values are "network", "local", "unknown" and null.
+ */
+ getFileSystemType(aFile) {
+ // Check if the libs exist.
+ if (!gLibsExist) {
+ return "unknown";
+ }
+
+ try {
+ // Given a UTF-8 string, converts it to the current Glib locale.
+ let g_filename_from_utf8 = glib.declare(
+ "g_filename_from_utf8",
+ ctypes.default_abi,
+ ctypes.char.ptr, // return type: glib locale string
+ ctypes.char.ptr, // in: utf8string
+ ctypes.ssize_t, // in: len
+ ctypes.size_t.ptr, // out: bytes_read
+ ctypes.size_t.ptr, // out: bytes_written
+ GError.ptr // out: error
+ );
+ // Yes, we want function scoping for variables we need to free in the
+ // finally block. I think this is better than declaring lots of variables
+ // on top.
+ var filePath = g_filename_from_utf8(aFile.path, -1, null, null, null);
+ if (filePath.isNull()) {
+ throw new Error(
+ "Unable to convert " + aFile.path + " into GLib encoding"
+ );
+ }
+
+ // Given a path, creates a new GFile for it.
+ let g_file_new_for_path = gio.declare(
+ "g_file_new_for_path",
+ ctypes.default_abi,
+ GFile.ptr, // return type: a newly-allocated GFile
+ ctypes.char.ptr // in: path
+ );
+ var glibFile = g_file_new_for_path(filePath);
+
+ // Given a GFile, queries the given attributes and returns them
+ // as a GFileInfo.
+ let g_file_query_filesystem_info = gio.declare(
+ "g_file_query_filesystem_info",
+ ctypes.default_abi,
+ GFileInfo.ptr, // return type
+ GFile.ptr, // in: file
+ ctypes.char.ptr, // in: attributes
+ GCancellable.ptr, // in: cancellable
+ GError.ptr // out: error
+ );
+ var glibFileInfo = g_file_query_filesystem_info(
+ glibFile,
+ G_FILE_ATTRIBUTE_FILESYSTEM_TYPE,
+ null,
+ null
+ );
+ if (glibFileInfo.isNull()) {
+ throw new Error("Unabled to retrieve GLib file info for " + aFile.path);
+ }
+
+ let g_file_info_get_attribute_string = gio.declare(
+ "g_file_info_get_attribute_string",
+ ctypes.default_abi,
+ ctypes.char.ptr, // return type: file system type (do not free)
+ GFileInfo.ptr, // in: info
+ ctypes.char.ptr // in: attribute
+ );
+ let fsType = g_file_info_get_attribute_string(
+ glibFileInfo,
+ G_FILE_ATTRIBUTE_FILESYSTEM_TYPE
+ );
+ if (fsType.isNull()) {
+ return "unknown";
+ } else if (kNetworkFilesystems.includes(fsType.readString())) {
+ return "network";
+ }
+ return "local";
+ } finally {
+ if (filePath) {
+ g_free(filePath);
+ }
+ if (glibFile && !glibFile.isNull()) {
+ g_object_unref(glibFile);
+ }
+ if (glibFileInfo && !glibFileInfo.isNull()) {
+ g_object_unref(glibFileInfo);
+ }
+ }
+ },
+};
diff --git a/comm/mail/components/about-support/AboutSupportWin32.jsm b/comm/mail/components/about-support/AboutSupportWin32.jsm
new file mode 100644
index 0000000000..4c5e36c5bd
--- /dev/null
+++ b/comm/mail/components/about-support/AboutSupportWin32.jsm
@@ -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/. */
+
+var EXPORTED_SYMBOLS = ["AboutSupportPlatform"];
+
+// JS ctypes are needed to get at the data we need
+var { ctypes } = ChromeUtils.importESModule(
+ "resource://gre/modules/ctypes.sys.mjs"
+);
+
+var BOOL = ctypes.int32_t;
+var DRIVE_UNKNOWN = 0;
+var DRIVE_NETWORK = 4;
+
+var AboutSupportPlatform = {
+ /**
+ * Given an nsIFile, gets the file system type. The type is returned as a
+ * string. Possible values are "network", "local", "unknown" and null.
+ */
+ getFileSystemType(aFile) {
+ let kernel32 = ctypes.open("kernel32.dll");
+
+ try {
+ // Returns the path of the volume a file is on.
+ let GetVolumePathName = kernel32.declare(
+ "GetVolumePathNameW",
+ ctypes.winapi_abi,
+ BOOL, // return type: 1 indicates success, 0 failure
+ ctypes.char16_t.ptr, // in: lpszFileName
+ ctypes.char16_t.ptr, // out: lpszVolumePathName
+ ctypes.uint32_t // in: cchBufferLength
+ );
+
+ // Returns the last error.
+ let GetLastError = kernel32.declare(
+ "GetLastError",
+ ctypes.winapi_abi,
+ ctypes.uint32_t // return type: the last error
+ );
+
+ let filePath = aFile.path;
+ // The volume path should be at most 1 greater than than the length of the
+ // path -- add 1 for a trailing backslash if necessary, and 1 for the
+ // terminating null character. Note that the parentheses around the type are
+ // necessary for new to apply correctly.
+ let volumePath = new (ctypes.char16_t.array(filePath.length + 2))();
+
+ if (!GetVolumePathName(filePath, volumePath, volumePath.length)) {
+ throw new Error(
+ "Unable to get volume path for " +
+ filePath +
+ ", error " +
+ GetLastError()
+ );
+ }
+
+ // Returns the type of the drive.
+ let GetDriveType = kernel32.declare(
+ "GetDriveTypeW",
+ ctypes.winapi_abi,
+ ctypes.uint32_t, // return type: the drive type
+ ctypes.char16_t.ptr // in: lpRootPathName
+ );
+ let type = GetDriveType(volumePath);
+ // http://msdn.microsoft.com/en-us/library/aa364939
+ if (type == DRIVE_UNKNOWN) {
+ return "unknown";
+ } else if (type == DRIVE_NETWORK) {
+ return "network";
+ }
+ return "local";
+ } finally {
+ kernel32.close();
+ }
+ },
+};
diff --git a/comm/mail/components/about-support/content/aboutSupport.js b/comm/mail/components/about-support/content/aboutSupport.js
new file mode 100644
index 0000000000..fc73b59029
--- /dev/null
+++ b/comm/mail/components/about-support/content/aboutSupport.js
@@ -0,0 +1,1729 @@
+/* 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 copy of mozilla/toolkit/content/aboutSupport.js with
+ modifications for TB. */
+
+/* globals AboutSupportPlatform, populateAccountsSection, sendViaEmail
+ populateCalendarsSection, populateChatSection, populateLibrarySection */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { Troubleshoot } = ChromeUtils.importESModule(
+ "resource://gre/modules/Troubleshoot.sys.mjs"
+);
+var { ResetProfile } = ChromeUtils.importESModule(
+ "resource://gre/modules/ResetProfile.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+ ProcessType: "resource://gre/modules/ProcessType.sys.mjs",
+});
+
+// added for TB
+/* Node classes. All of these are mutually exclusive. */
+
+// Any nodes marked with this class will be considered part of the UI only,
+// and therefore will not be copied.
+var CLASS_DATA_UIONLY = "data-uionly";
+
+// Any nodes marked with this class will be considered private and will be
+// hidden if the user requests only public data to be shown or copied.
+var CLASS_DATA_PRIVATE = "data-private";
+
+// Any nodes marked with this class will only be displayed when the user chooses
+// to not display private data.
+var CLASS_DATA_PUBLIC = "data-public";
+// end of TB addition
+window.addEventListener("load", function onload(event) {
+ try {
+ window.removeEventListener("load", onload);
+ Troubleshoot.snapshot().then(async snapshot => {
+ for (let prop in snapshotFormatters) {
+ try {
+ await snapshotFormatters[prop](snapshot[prop]);
+ } catch (e) {
+ console.error(
+ "stack of snapshot error for about:support: ",
+ e,
+ ": ",
+ e.stack
+ );
+ }
+ }
+ }, console.error);
+ populateActionBox();
+ setupEventListeners();
+
+ let hasWinPackageId = false;
+ try {
+ hasWinPackageId = Services.sysinfo.getProperty("hasWinPackageId");
+ } catch (_ex) {
+ // The hasWinPackageId property doesn't exist; assume it would be false.
+ }
+ if (hasWinPackageId) {
+ $("update-dir-row").hidden = true;
+ $("update-history-row").hidden = true;
+ }
+ } catch (e) {
+ console.error(
+ "stack of load error for about:support: " + e + ": " + e.stack
+ );
+ }
+ // added for TB
+ populateAccountsSection();
+ populateCalendarsSection();
+ populateChatSection();
+ populateLibrarySection();
+ document
+ .getElementById("check-show-private-data")
+ .addEventListener("change", () => onShowPrivateDataChange());
+});
+
+function prefsTable(data) {
+ return sortedArrayFromObject(data).map(function ([name, value]) {
+ return $.new("tr", [
+ $.new("td", name, "pref-name"),
+ // Very long preference values can cause users problems when they
+ // copy and paste them into some text editors. Long values generally
+ // aren't useful anyway, so truncate them to a reasonable length.
+ $.new("td", String(value).substr(0, 120), "pref-value"),
+ ]);
+ });
+}
+
+// Fluent uses lisp-case IDs so this converts
+// the SentenceCase info IDs to lisp-case.
+const FLUENT_IDENT_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
+function toFluentID(str) {
+ if (!FLUENT_IDENT_REGEX.test(str)) {
+ return null;
+ }
+ return str
+ .toString()
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
+ .toLowerCase();
+}
+
+// Each property in this object corresponds to a property in Troubleshoot.jsm's
+// snapshot data. Each function is passed its property's corresponding data,
+// and it's the function's job to update the page with it.
+var snapshotFormatters = {
+ async application(data) {
+ $("application-box").textContent = data.name;
+ $("useragent-box").textContent = data.userAgent;
+ $("os-box").textContent = data.osVersion;
+ if (data.osTheme) {
+ $("os-theme-box").textContent = data.osTheme;
+ } else {
+ $("os-theme-row").hidden = true;
+ }
+ if (AppConstants.platform == "macosx") {
+ $("rosetta-box").textContent = data.rosetta;
+ }
+ $("binary-box").textContent = Services.dirsvc.get(
+ "XREExeF",
+ Ci.nsIFile
+ ).path;
+ $("supportLink").href = data.supportURL;
+ let version = AppConstants.MOZ_APP_VERSION_DISPLAY;
+ if (data.vendor) {
+ version += " (" + data.vendor + ")";
+ }
+ $("version-box").textContent = version;
+ $("buildid-box").textContent = data.buildID;
+ $("distributionid-box").textContent = data.distributionID;
+ if (data.updateChannel) {
+ $("updatechannel-box").textContent = data.updateChannel;
+ }
+ if (AppConstants.MOZ_UPDATER) {
+ $("update-dir-box").textContent = Services.dirsvc.get(
+ "UpdRootD",
+ Ci.nsIFile
+ ).path;
+ }
+
+ try {
+ let launcherStatusTextId = "launcher-process-status-unknown";
+ switch (data.launcherProcessState) {
+ case 0:
+ case 1:
+ case 2:
+ launcherStatusTextId =
+ "launcher-process-status-" + data.launcherProcessState;
+ break;
+ }
+
+ document.l10n.setAttributes(
+ $("launcher-process-box"),
+ launcherStatusTextId
+ );
+ } catch (e) {}
+
+ const STATUS_STRINGS = {
+ experimentControl: "fission-status-experiment-control",
+ experimentTreatment: "fission-status-experiment-treatment",
+ disabledByE10sEnv: "fission-status-disabled-by-e10s-env",
+ enabledByEnv: "fission-status-enabled-by-env",
+ enabledByDefault: "fission-status-enabled-by-default",
+ disabledByDefault: "fission-status-disabled-by-default",
+ enabledByUserPref: "fission-status-enabled-by-user-pref",
+ disabledByUserPref: "fission-status-disabled-by-user-pref",
+ disabledByE10sOther: "fission-status-disabled-by-e10s-other",
+ };
+
+ let statusTextId = STATUS_STRINGS[data.fissionDecisionStatus];
+
+ document.l10n.setAttributes(
+ $("multiprocess-box-process-count"),
+ "multi-process-windows",
+ {
+ remoteWindows: data.numRemoteWindows,
+ totalWindows: data.numTotalWindows,
+ }
+ );
+ document.l10n.setAttributes(
+ $("fission-box-process-count"),
+ "fission-windows",
+ {
+ fissionWindows: data.numFissionWindows,
+ totalWindows: data.numTotalWindows,
+ }
+ );
+ document.l10n.setAttributes($("fission-box-status"), statusTextId);
+
+ if (Services.policies) {
+ let policiesStrId = "";
+ let aboutPolicies = "about:policies";
+ switch (data.policiesStatus) {
+ case Services.policies.INACTIVE:
+ policiesStrId = "policies-inactive";
+ break;
+
+ case Services.policies.ACTIVE:
+ policiesStrId = "policies-active";
+ aboutPolicies += "#active";
+ break;
+
+ default:
+ policiesStrId = "policies-error";
+ aboutPolicies += "#errors";
+ break;
+ }
+
+ if (data.policiesStatus != Services.policies.INACTIVE) {
+ let activePolicies = $.new("a", null, null, {
+ href: aboutPolicies,
+ });
+ document.l10n.setAttributes(activePolicies, policiesStrId);
+ $("policies-status").appendChild(activePolicies);
+ } else {
+ document.l10n.setAttributes($("policies-status"), policiesStrId);
+ }
+ } else {
+ $("policies-status-row").hidden = true;
+ }
+
+ let keyLocationServiceGoogleFound = data.keyLocationServiceGoogleFound
+ ? "found"
+ : "missing";
+ document.l10n.setAttributes(
+ $("key-location-service-google-box"),
+ keyLocationServiceGoogleFound
+ );
+
+ let keySafebrowsingGoogleFound = data.keySafebrowsingGoogleFound
+ ? "found"
+ : "missing";
+ document.l10n.setAttributes(
+ $("key-safebrowsing-google-box"),
+ keySafebrowsingGoogleFound
+ );
+
+ let keyMozillaFound = data.keyMozillaFound ? "found" : "missing";
+ document.l10n.setAttributes($("key-mozilla-box"), keyMozillaFound);
+
+ $("safemode-box").textContent = data.safeMode;
+
+ const formatHumanReadableBytes = (elem, bytes) => {
+ let size = DownloadUtils.convertByteUnits(bytes);
+ document.l10n.setAttributes(elem, "app-basics-data-size", {
+ value: size[0],
+ unit: size[1],
+ });
+ };
+
+ formatHumanReadableBytes($("memory-size-box"), data.memorySizeBytes);
+ formatHumanReadableBytes($("disk-available-box"), data.diskAvailableBytes);
+
+ // added for TB
+ // Add profile path as private info into the page.
+ let currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let profElem = document.getElementById("profile-dir-button").parentNode;
+ let profDirNode = document.getElementById("profile-dir-box");
+ profDirNode.setAttribute("class", CLASS_DATA_PRIVATE);
+ let profLinkNode = document.createElement("a");
+ profLinkNode.setAttribute("href", Services.io.newFileURI(currProfD).spec);
+ profLinkNode.addEventListener("click", function (event) {
+ openProfileDirectory();
+ event.preventDefault();
+ });
+ let profPathNode = document.createTextNode(currProfD.path);
+ profLinkNode.appendChild(profPathNode);
+ profDirNode.appendChild(profLinkNode);
+ profElem.appendChild(document.createTextNode(" "));
+
+ // Show type of filesystem detected.
+ let fsType;
+ try {
+ fsType = AboutSupportPlatform.getFileSystemType(currProfD);
+ if (fsType) {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/aboutSupportMail.properties"
+ );
+ let fsText = bundle.GetStringFromName("fsType." + fsType);
+ let fsTextNode = document.createElement("span");
+ fsTextNode.textContent = fsText;
+ profElem.appendChild(fsTextNode);
+ }
+ } catch (x) {
+ console.error(x);
+ }
+ // end of TB addition
+ },
+
+ crashes(data) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ let daysRange = Troubleshoot.kMaxCrashAge / (24 * 60 * 60 * 1000);
+ document.l10n.setAttributes($("crashes-title"), "report-crash-for-days", {
+ days: daysRange,
+ });
+ let reportURL;
+ try {
+ reportURL = Services.prefs.getCharPref("breakpad.reportURL");
+ // Ignore any non http/https urls
+ if (!/^https?:/i.test(reportURL)) {
+ reportURL = null;
+ }
+ } catch (e) {}
+ if (!reportURL) {
+ $("crashes-noConfig").style.display = "block";
+ $("crashes-noConfig").classList.remove("no-copy");
+ return;
+ }
+ $("crashes-allReports").style.display = "block";
+
+ if (data.pending > 0) {
+ document.l10n.setAttributes(
+ $("crashes-allReportsWithPending"),
+ "pending-reports",
+ { reports: data.pending }
+ );
+ }
+
+ let dateNow = new Date();
+ $.append(
+ $("crashes-tbody"),
+ data.submitted.map(function (crash) {
+ let date = new Date(crash.date);
+ let timePassed = dateNow - date;
+ let formattedDateStrId;
+ let formattedDateStrArgs;
+ if (timePassed >= 24 * 60 * 60 * 1000) {
+ let daysPassed = Math.round(timePassed / (24 * 60 * 60 * 1000));
+ formattedDateStrId = "crashes-time-days";
+ formattedDateStrArgs = { days: daysPassed };
+ } else if (timePassed >= 60 * 60 * 1000) {
+ let hoursPassed = Math.round(timePassed / (60 * 60 * 1000));
+ formattedDateStrId = "crashes-time-hours";
+ formattedDateStrArgs = { hours: hoursPassed };
+ } else {
+ let minutesPassed = Math.max(Math.round(timePassed / (60 * 1000)), 1);
+ formattedDateStrId = "crashes-time-minutes";
+ formattedDateStrArgs = { minutes: minutesPassed };
+ }
+ return $.new("tr", [
+ $.new("td", [
+ $.new("a", crash.id, null, { href: reportURL + crash.id }),
+ ]),
+ $.new("td", null, null, {
+ "data-l10n-id": formattedDateStrId,
+ "data-l10n-args": formattedDateStrArgs,
+ }),
+ ]);
+ })
+ );
+ },
+
+ addons(data) {
+ $.append(
+ $("addons-tbody"),
+ data.map(function (addon) {
+ return $.new("tr", [
+ $.new("td", addon.name),
+ $.new("td", addon.type),
+ $.new("td", addon.version),
+ $.new("td", addon.isActive),
+ $.new("td", addon.id),
+ ]);
+ })
+ );
+ },
+
+ securitySoftware(data) {
+ if (!AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ $("security-software-title").hidden = true;
+ $("security-software-table").hidden = true;
+ return;
+ }
+
+ $("security-software-antivirus").textContent = data.registeredAntiVirus;
+ $("security-software-antispyware").textContent = data.registeredAntiSpyware;
+ $("security-software-firewall").textContent = data.registeredFirewall;
+ },
+
+ /* Not used by TB
+ features(data) {
+ $.append($("features-tbody"), data.map(function(feature) {
+ return $.new("tr", [
+ $.new("td", feature.name),
+ $.new("td", feature.version),
+ $.new("td", feature.id),
+ ]);
+ }));
+ },
+*/
+
+ async processes(data) {
+ async function buildEntry(name, value) {
+ const fluentName = ProcessType.fluentNameFromProcessTypeString(name);
+ let entryName = (await document.l10n.formatValue(fluentName)) || name;
+ $("processes-tbody").appendChild(
+ $.new("tr", [$.new("td", entryName), $.new("td", value)])
+ );
+ }
+
+ let remoteProcessesCount = Object.values(data.remoteTypes).reduce(
+ (a, b) => a + b,
+ 0
+ );
+ document.querySelector("#remoteprocesses-row a").textContent =
+ remoteProcessesCount;
+
+ // Display the regular "web" process type first in the list,
+ // and with special formatting.
+ if (data.remoteTypes.web) {
+ await buildEntry(
+ "web",
+ `${data.remoteTypes.web} / ${data.maxWebContentProcesses}`
+ );
+ delete data.remoteTypes.web;
+ }
+
+ for (let remoteProcessType in data.remoteTypes) {
+ await buildEntry(remoteProcessType, data.remoteTypes[remoteProcessType]);
+ }
+ },
+
+ environmentVariables(data) {
+ if (!data) {
+ return;
+ }
+ $.append(
+ $("environment-variables-tbody"),
+ Object.entries(data).map(([name, value]) => {
+ return $.new("tr", [
+ $.new("td", name, "pref-name"),
+ $.new("td", value, "pref-value"),
+ ]);
+ })
+ );
+ },
+
+ modifiedPreferences(data) {
+ $.append($("prefs-tbody"), prefsTable(data));
+ },
+
+ lockedPreferences(data) {
+ $.append($("locked-prefs-tbody"), prefsTable(data));
+ },
+
+ printingPreferences(data) {
+ if (AppConstants.platform == "android") {
+ return;
+ }
+ const tbody = $("support-printing-prefs-tbody");
+ $.append(tbody, prefsTable(data));
+ $("support-printing-clear-settings-button").addEventListener(
+ "click",
+ function () {
+ for (let name in data) {
+ Services.prefs.clearUserPref(name);
+ }
+ tbody.textContent = "";
+ }
+ );
+ },
+
+ /* eslint-disable complexity */
+ async graphics(data) {
+ function localizedMsg(msg) {
+ if (typeof msg == "object" && msg.key) {
+ return document.l10n.formatValue(msg.key, msg.args);
+ }
+ let msgId = toFluentID(msg);
+ if (msgId) {
+ return document.l10n.formatValue(msgId);
+ }
+ return "";
+ }
+
+ // Read APZ info out of data.info, stripping it out in the process.
+ let apzInfo = [];
+ let formatApzInfo = function (info) {
+ let out = [];
+ for (let type of [
+ "Wheel",
+ "Touch",
+ "Drag",
+ "Keyboard",
+ "Autoscroll",
+ "Zooming",
+ ]) {
+ let key = "Apz" + type + "Input";
+
+ if (!(key in info)) {
+ continue;
+ }
+
+ delete info[key];
+
+ out.push(toFluentID(type.toLowerCase() + "Enabled"));
+ }
+
+ return out;
+ };
+
+ // Create a <tr> element with key and value columns.
+ //
+ // @key Text in the key column. Localized automatically, unless starts with "#".
+ // @value Fluent ID for text in the value column, or array of children.
+ function buildRow(key, value) {
+ let title = key[0] == "#" ? key.substr(1) : key;
+ let keyStrId = toFluentID(key);
+ let valueStrId = Array.isArray(value) ? null : toFluentID(value);
+ let td = $.new("td", value);
+ td.style["white-space"] = "pre-wrap";
+ if (valueStrId) {
+ document.l10n.setAttributes(td, valueStrId);
+ }
+
+ let th = $.new("th", title, "column");
+ if (!key.startsWith("#")) {
+ document.l10n.setAttributes(th, keyStrId);
+ }
+ return $.new("tr", [th, td]);
+ }
+
+ // @where The name in "graphics-<name>-tbody", of the element to append to.
+ // @trs Array of row elements.
+ function addRows(where, trs) {
+ $.append($("graphics-" + where + "-tbody"), trs);
+ }
+
+ // Build and append a row.
+ //
+ // @where The name in "graphics-<name>-tbody", of the element to append to.
+ function addRow(where, key, value) {
+ addRows(where, [buildRow(key, value)]);
+ }
+ if ("info" in data) {
+ apzInfo = formatApzInfo(data.info);
+
+ let trs = sortedArrayFromObject(data.info).map(function ([prop, val]) {
+ let td = $.new("td", String(val));
+ td.style["word-break"] = "break-all";
+ return $.new("tr", [$.new("th", prop, "column"), td]);
+ });
+ addRows("diagnostics", trs);
+
+ delete data.info;
+ }
+
+ let windowUtils = window.windowUtils;
+ let gpuProcessPid = windowUtils.gpuProcessPid;
+
+ if (gpuProcessPid != -1) {
+ let gpuProcessKillButton = null;
+ if (AppConstants.NIGHTLY_BUILD || AppConstants.MOZ_DEV_EDITION) {
+ gpuProcessKillButton = $.new("button");
+
+ gpuProcessKillButton.addEventListener("click", function () {
+ windowUtils.terminateGPUProcess();
+ });
+
+ document.l10n.setAttributes(
+ gpuProcessKillButton,
+ "gpu-process-kill-button"
+ );
+ }
+
+ addRow("diagnostics", "gpu-process-pid", [new Text(gpuProcessPid)]);
+ if (gpuProcessKillButton) {
+ addRow("diagnostics", "gpu-process", [gpuProcessKillButton]);
+ }
+ }
+
+ if (
+ (AppConstants.NIGHTLY_BUILD || AppConstants.MOZ_DEV_EDITION) &&
+ AppConstants.platform != "macosx"
+ ) {
+ let gpuDeviceResetButton = $.new("button");
+
+ gpuDeviceResetButton.addEventListener("click", function () {
+ windowUtils.triggerDeviceReset();
+ });
+
+ document.l10n.setAttributes(
+ gpuDeviceResetButton,
+ "gpu-device-reset-button"
+ );
+ addRow("diagnostics", "gpu-device-reset", [gpuDeviceResetButton]);
+ }
+
+ // graphics-failures-tbody tbody
+ if ("failures" in data) {
+ // If indices is there, it should be the same length as failures,
+ // (see Troubleshoot.jsm) but we check anyway:
+ if ("indices" in data && data.failures.length == data.indices.length) {
+ let combined = [];
+ for (let i = 0; i < data.failures.length; i++) {
+ let assembled = assembleFromGraphicsFailure(i, data);
+ combined.push(assembled);
+ }
+ combined.sort(function (a, b) {
+ if (a.index < b.index) {
+ return -1;
+ }
+ if (a.index > b.index) {
+ return 1;
+ }
+ return 0;
+ });
+ $.append(
+ $("graphics-failures-tbody"),
+ combined.map(function (val) {
+ return $.new("tr", [
+ $.new("th", val.header, "column"),
+ $.new("td", val.message),
+ ]);
+ })
+ );
+ delete data.indices;
+ } else {
+ $.append($("graphics-failures-tbody"), [
+ $.new("tr", [
+ $.new("th", "LogFailure", "column"),
+ $.new(
+ "td",
+ data.failures.map(function (val) {
+ return $.new("p", val);
+ })
+ ),
+ ]),
+ ]);
+ }
+ delete data.failures;
+ } else {
+ $("graphics-failures-tbody").style.display = "none";
+ }
+
+ // Add a new row to the table, and take the key (or keys) out of data.
+ //
+ // @where Table section to add to.
+ // @key Data key to use.
+ // @colKey The localization key to use, if different from key.
+ async function addRowFromKey(where, key, colKey) {
+ if (!(key in data)) {
+ return;
+ }
+ colKey = colKey || key;
+
+ let value;
+ let messageKey = key + "Message";
+ if (messageKey in data) {
+ value = await localizedMsg(data[messageKey]);
+ delete data[messageKey];
+ } else {
+ value = data[key];
+ }
+ delete data[key];
+
+ if (value) {
+ addRow(where, colKey, [new Text(value)]);
+ }
+ }
+
+ // graphics-features-tbody
+ let compositor = "";
+ if (data.windowLayerManagerRemote) {
+ compositor = data.windowLayerManagerType;
+ } else {
+ let noOMTCString = await document.l10n.formatValue("main-thread-no-omtc");
+ compositor = "BasicLayers (" + noOMTCString + ")";
+ }
+ addRow("features", "compositing", [new Text(compositor)]);
+ delete data.windowLayerManagerRemote;
+ delete data.windowLayerManagerType;
+ delete data.numTotalWindows;
+ delete data.numAcceleratedWindows;
+ delete data.numAcceleratedWindowsMessage;
+
+ addRow(
+ "features",
+ "asyncPanZoom",
+ apzInfo.length
+ ? [
+ new Text(
+ (
+ await document.l10n.formatValues(
+ apzInfo.map(id => {
+ return { id };
+ })
+ )
+ ).join("; ")
+ ),
+ ]
+ : "apz-none"
+ );
+ let featureKeys = [
+ "webgl1WSIInfo",
+ "webgl1Renderer",
+ "webgl1Version",
+ "webgl1DriverExtensions",
+ "webgl1Extensions",
+ "webgl2WSIInfo",
+ "webgl2Renderer",
+ "webgl2Version",
+ "webgl2DriverExtensions",
+ "webgl2Extensions",
+ ["supportsHardwareH264", "hardware-h264"],
+ ["direct2DEnabled", "#Direct2D"],
+ ["windowProtocol", "graphics-window-protocol"],
+ ["desktopEnvironment", "graphics-desktop-environment"],
+ "usesTiling",
+ "targetFrameRate",
+ ];
+ for (let feature of featureKeys) {
+ if (Array.isArray(feature)) {
+ await addRowFromKey("features", feature[0], feature[1]);
+ continue;
+ }
+ await addRowFromKey("features", feature);
+ }
+
+ if ("directWriteEnabled" in data) {
+ let message = data.directWriteEnabled;
+ if ("directWriteVersion" in data) {
+ message += " (" + data.directWriteVersion + ")";
+ }
+ await addRow("features", "#DirectWrite", [new Text(message)]);
+ delete data.directWriteEnabled;
+ delete data.directWriteVersion;
+ }
+
+ // Adapter tbodies.
+ let adapterKeys = [
+ ["adapterDescription", "gpu-description"],
+ ["adapterVendorID", "gpu-vendor-id"],
+ ["adapterDeviceID", "gpu-device-id"],
+ ["driverVendor", "gpu-driver-vendor"],
+ ["driverVersion", "gpu-driver-version"],
+ ["driverDate", "gpu-driver-date"],
+ ["adapterDrivers", "gpu-drivers"],
+ ["adapterSubsysID", "gpu-subsys-id"],
+ ["adapterRAM", "gpu-ram"],
+ ];
+
+ function showGpu(id, suffix) {
+ function get(prop) {
+ return data[prop + suffix];
+ }
+
+ let trs = [];
+ for (let [prop, key] of adapterKeys) {
+ let value = get(prop);
+ if (value === undefined || value === "") {
+ continue;
+ }
+ trs.push(buildRow(key, [new Text(value)]));
+ }
+
+ if (trs.length == 0) {
+ $("graphics-" + id + "-tbody").style.display = "none";
+ return;
+ }
+
+ let active = "yes";
+ if ("isGPU2Active" in data && (suffix == "2") != data.isGPU2Active) {
+ active = "no";
+ }
+
+ addRow(id, "gpu-active", active);
+ addRows(id, trs);
+ }
+ showGpu("gpu-1", "");
+ showGpu("gpu-2", "2");
+
+ // Remove adapter keys.
+ for (let [prop /* key */] of adapterKeys) {
+ delete data[prop];
+ delete data[prop + "2"];
+ }
+ delete data.isGPU2Active;
+
+ let featureLog = data.featureLog;
+ delete data.featureLog;
+
+ if (featureLog.features.length) {
+ for (let feature of featureLog.features) {
+ let trs = [];
+ for (let entry of feature.log) {
+ let contents;
+ if (!entry.hasOwnProperty("message")) {
+ // This is a default entry.
+ contents = entry.status + " by " + entry.type;
+ } else if (entry.message.length && entry.message[0] == "#") {
+ // This is a failure ID. See nsIGfxInfo.idl.
+ let m = /#BLOCKLIST_FEATURE_FAILURE_BUG_(\d+)/.exec(entry.message);
+ if (m) {
+ let bugSpan = $.new("span");
+
+ let bugHref = $.new("a");
+ bugHref.href =
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=" + m[1];
+ bugHref.setAttribute("data-l10n-name", "bug-link");
+ bugSpan.append(bugHref);
+ document.l10n.setAttributes(bugSpan, "support-blocklisted-bug", {
+ bugNumber: m[1],
+ });
+
+ contents = [bugSpan];
+ } else {
+ let unknownFailure = $.new("span");
+ document.l10n.setAttributes(unknownFailure, "unknown-failure", {
+ failureCode: entry.message.substr(1),
+ });
+ contents = [unknownFailure];
+ }
+ } else {
+ contents =
+ entry.status + " by " + entry.type + ": " + entry.message;
+ }
+
+ trs.push($.new("tr", [$.new("td", contents)]));
+ }
+ addRow("decisions", "#" + feature.name, [$.new("table", trs)]);
+ }
+ } else {
+ $("graphics-decisions-tbody").style.display = "none";
+ }
+
+ if (featureLog.fallbacks.length) {
+ for (let fallback of featureLog.fallbacks) {
+ addRow("workarounds", "#" + fallback.name, [
+ new Text(fallback.message),
+ ]);
+ }
+ } else {
+ $("graphics-workarounds-tbody").style.display = "none";
+ }
+
+ let crashGuards = data.crashGuards;
+ delete data.crashGuards;
+
+ if (crashGuards.length) {
+ for (let guard of crashGuards) {
+ let resetButton = $.new("button");
+ let onClickReset = function () {
+ Services.prefs.setIntPref(guard.prefName, 0);
+ resetButton.removeEventListener("click", onClickReset);
+ resetButton.disabled = true;
+ };
+
+ document.l10n.setAttributes(resetButton, "reset-on-next-restart");
+ resetButton.addEventListener("click", onClickReset);
+
+ addRow("crashguards", guard.type + "CrashGuard", [resetButton]);
+ }
+ } else {
+ $("graphics-crashguards-tbody").style.display = "none";
+ }
+
+ // Now that we're done, grab any remaining keys in data and drop them into
+ // the diagnostics section.
+ for (let key in data) {
+ let value = data[key];
+ addRow("diagnostics", key, [new Text(value)]);
+ }
+ },
+ /* eslint-enable complexity */
+
+ media(data) {
+ function insertBasicInfo(key, value) {
+ function createRow(key, value) {
+ let th = $.new("th", null, "column");
+ document.l10n.setAttributes(th, key);
+ let td = $.new("td", value);
+ td.style["white-space"] = "pre-wrap";
+ td.colSpan = 8;
+ return $.new("tr", [th, td]);
+ }
+ $.append($("media-info-tbody"), [createRow(key, value)]);
+ }
+
+ function createDeviceInfoRow(device) {
+ let deviceInfo = Ci.nsIAudioDeviceInfo;
+
+ let states = {};
+ states[deviceInfo.STATE_DISABLED] = "Disabled";
+ states[deviceInfo.STATE_UNPLUGGED] = "Unplugged";
+ states[deviceInfo.STATE_ENABLED] = "Enabled";
+
+ let preferreds = {};
+ preferreds[deviceInfo.PREF_NONE] = "None";
+ preferreds[deviceInfo.PREF_MULTIMEDIA] = "Multimedia";
+ preferreds[deviceInfo.PREF_VOICE] = "Voice";
+ preferreds[deviceInfo.PREF_NOTIFICATION] = "Notification";
+ preferreds[deviceInfo.PREF_ALL] = "All";
+
+ let formats = {};
+ formats[deviceInfo.FMT_S16LE] = "S16LE";
+ formats[deviceInfo.FMT_S16BE] = "S16BE";
+ formats[deviceInfo.FMT_F32LE] = "F32LE";
+ formats[deviceInfo.FMT_F32BE] = "F32BE";
+
+ function toPreferredString(preferred) {
+ if (preferred == deviceInfo.PREF_NONE) {
+ return preferreds[deviceInfo.PREF_NONE];
+ } else if (preferred & deviceInfo.PREF_ALL) {
+ return preferreds[deviceInfo.PREF_ALL];
+ }
+ let str = "";
+ for (let pref of [
+ deviceInfo.PREF_MULTIMEDIA,
+ deviceInfo.PREF_VOICE,
+ deviceInfo.PREF_NOTIFICATION,
+ ]) {
+ if (preferred & pref) {
+ str += " " + preferreds[pref];
+ }
+ }
+ return str;
+ }
+
+ function toFromatString(dev) {
+ let str = "default: " + formats[dev.defaultFormat] + ", support:";
+ for (let fmt of [
+ deviceInfo.FMT_S16LE,
+ deviceInfo.FMT_S16BE,
+ deviceInfo.FMT_F32LE,
+ deviceInfo.FMT_F32BE,
+ ]) {
+ if (dev.supportedFormat & fmt) {
+ str += " " + formats[fmt];
+ }
+ }
+ return str;
+ }
+
+ function toRateString(dev) {
+ return (
+ "default: " +
+ dev.defaultRate +
+ ", support: " +
+ dev.minRate +
+ " - " +
+ dev.maxRate
+ );
+ }
+
+ function toLatencyString(dev) {
+ return dev.minLatency + " - " + dev.maxLatency;
+ }
+
+ return $.new("tr", [
+ $.new("td", device.name),
+ $.new("td", device.groupId),
+ $.new("td", device.vendor),
+ $.new("td", states[device.state]),
+ $.new("td", toPreferredString(device.preferred)),
+ $.new("td", toFromatString(device)),
+ $.new("td", device.maxChannels),
+ $.new("td", toRateString(device)),
+ $.new("td", toLatencyString(device)),
+ ]);
+ }
+
+ function insertDeviceInfo(side, devices) {
+ let rows = [];
+ for (let dev of devices) {
+ rows.push(createDeviceInfoRow(dev));
+ }
+ $.append($("media-" + side + "-devices-tbody"), rows);
+ }
+
+ function insertEnumerateDatabase() {
+ if (
+ !Services.prefs.getBoolPref("media.mediacapabilities.from-database")
+ ) {
+ $("media-capabilities-tbody").style.display = "none";
+ return;
+ }
+ let button = $("enumerate-database-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ let { KeyValueService } = ChromeUtils.importESModule(
+ "resource://gre/modules/kvstore.sys.mjs"
+ );
+ let currProfDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ currProfDir.append("mediacapabilities");
+ let path = currProfDir.path;
+
+ function enumerateDatabase(name) {
+ KeyValueService.getOrCreate(path, name)
+ .then(database => {
+ return database.enumerate();
+ })
+ .then(enumerator => {
+ var logs = [];
+ logs.push(`${name}:`);
+ for (let { key, value } of enumerator) {
+ logs.push(`${key}: ${value}`);
+ }
+ $("enumerate-database-result").textContent +=
+ logs.join("\n") + "\n";
+ })
+ .catch(err => {
+ $("enumerate-database-result").textContent += `${name}:\n`;
+ });
+ }
+
+ $("enumerate-database-result").style.display = "block";
+ $("enumerate-database-result").classList.remove("no-copy");
+ $("enumerate-database-result").textContent = "";
+
+ enumerateDatabase("video/av1");
+ enumerateDatabase("video/vp8");
+ enumerateDatabase("video/vp9");
+ enumerateDatabase("video/avc");
+ enumerateDatabase("video/theora");
+ });
+ }
+ }
+
+ function roundtripAudioLatency() {
+ insertBasicInfo("roundtrip-latency", "...");
+ window.windowUtils
+ .defaultDevicesRoundTripLatency()
+ .then(latency => {
+ var latencyString = `${(latency[0] * 1000).toFixed(2)}ms (${(
+ latency[1] * 1000
+ ).toFixed(2)})`;
+ data.defaultDevicesRoundTripLatency = latencyString;
+ document.querySelector(
+ 'th[data-l10n-id="roundtrip-latency"]'
+ ).nextSibling.textContent = latencyString;
+ })
+ .catch(e => {});
+ }
+
+ // Basic information
+ insertBasicInfo("audio-backend", data.currentAudioBackend);
+ insertBasicInfo("max-audio-channels", data.currentMaxAudioChannels);
+ insertBasicInfo("sample-rate", data.currentPreferredSampleRate);
+
+ if (AppConstants.platform == "macosx") {
+ var micStatus = {};
+ let permission = Cc["@mozilla.org/ospermissionrequest;1"].getService(
+ Ci.nsIOSPermissionRequest
+ );
+ permission.getAudioCapturePermissionState(micStatus);
+ if (micStatus.value == permission.PERMISSION_STATE_AUTHORIZED) {
+ roundtripAudioLatency();
+ }
+ } else {
+ roundtripAudioLatency();
+ }
+
+ // Output devices information
+ insertDeviceInfo("output", data.audioOutputDevices);
+
+ // Input devices information
+ insertDeviceInfo("input", data.audioInputDevices);
+
+ // Media Capabilitites
+ insertEnumerateDatabase();
+ },
+
+ remoteAgent(data) {
+ if (!AppConstants.ENABLE_WEBDRIVER) {
+ return;
+ }
+ $("remote-debugging-accepting-connections").textContent = data.listening;
+ $("remote-debugging-url").textContent = data.url;
+ },
+
+ accessibility(data) {
+ $("a11y-activated").textContent = data.isActive;
+ $("a11y-force-disabled").textContent = data.forceDisabled || 0;
+
+ let a11yHandlerUsed = $("a11y-handler-used");
+ if (a11yHandlerUsed) {
+ a11yHandlerUsed.textContent = data.handlerUsed;
+ }
+
+ let a11yInstantiator = $("a11y-instantiator");
+ if (a11yInstantiator) {
+ a11yInstantiator.textContent = data.instantiator;
+ }
+ },
+
+ startupCache(data) {
+ $("startup-cache-disk-cache-path").textContent = data.DiskCachePath;
+ $("startup-cache-ignore-disk-cache").textContent = data.IgnoreDiskCache;
+ $("startup-cache-found-disk-cache-on-init").textContent =
+ data.FoundDiskCacheOnInit;
+ $("startup-cache-wrote-to-disk-cache").textContent = data.WroteToDiskCache;
+ },
+
+ libraryVersions(data) {
+ let trs = [
+ $.new("tr", [
+ $.new("th", ""),
+ $.new("th", null, null, { "data-l10n-id": "min-lib-versions" }),
+ $.new("th", null, null, { "data-l10n-id": "loaded-lib-versions" }),
+ ]),
+ ];
+ sortedArrayFromObject(data).forEach(function ([name, val]) {
+ trs.push(
+ $.new("tr", [
+ $.new("td", name),
+ $.new("td", val.minVersion),
+ $.new("td", val.version),
+ ])
+ );
+ });
+ $.append($("libversions-tbody"), trs);
+ },
+
+ userJS(data) {
+ if (!data.exists) {
+ return;
+ }
+ let userJSFile = Services.dirsvc.get("PrefD", Ci.nsIFile);
+ userJSFile.append("user.js");
+ $("prefs-user-js-link").href = Services.io.newFileURI(userJSFile).spec;
+ $("prefs-user-js-section").style.display = "";
+ // Clear the no-copy class
+ $("prefs-user-js-section").className = "";
+ },
+
+ sandbox(data) {
+ if (!AppConstants.MOZ_SANDBOX) {
+ return;
+ }
+
+ let tbody = $("sandbox-tbody");
+ for (let key in data) {
+ // Simplify the display a little in the common case.
+ if (
+ key === "hasPrivilegedUserNamespaces" &&
+ data[key] === data.hasUserNamespaces
+ ) {
+ continue;
+ }
+ if (key === "syscallLog") {
+ // Not in this table.
+ continue;
+ }
+ let keyStrId = toFluentID(key);
+ let th = $.new("th", null, "column");
+ document.l10n.setAttributes(th, keyStrId);
+ tbody.appendChild($.new("tr", [th, $.new("td", data[key])]));
+ }
+
+ if ("syscallLog" in data) {
+ let syscallBody = $("sandbox-syscalls-tbody");
+ let argsHead = $("sandbox-syscalls-argshead");
+ for (let syscall of data.syscallLog) {
+ if (argsHead.colSpan < syscall.args.length) {
+ argsHead.colSpan = syscall.args.length;
+ }
+ let procTypeStrId = toFluentID(syscall.procType);
+ let cells = [
+ $.new("td", syscall.index, "integer"),
+ $.new("td", syscall.msecAgo / 1000),
+ $.new("td", syscall.pid, "integer"),
+ $.new("td", syscall.tid, "integer"),
+ $.new("td", null, null, {
+ "data-l10n-id": "sandbox-proc-type-" + procTypeStrId,
+ }),
+ $.new("td", syscall.syscall, "integer"),
+ ];
+ for (let arg of syscall.args) {
+ cells.push($.new("td", arg, "integer"));
+ }
+ syscallBody.appendChild($.new("tr", cells));
+ }
+ }
+ },
+
+ intl(data) {
+ $("intl-locale-requested").textContent = JSON.stringify(
+ data.localeService.requested
+ );
+ $("intl-locale-available").textContent = JSON.stringify(
+ data.localeService.available
+ );
+ $("intl-locale-supported").textContent = JSON.stringify(
+ data.localeService.supported
+ );
+ $("intl-locale-regionalprefs").textContent = JSON.stringify(
+ data.localeService.regionalPrefs
+ );
+ $("intl-locale-default").textContent = JSON.stringify(
+ data.localeService.defaultLocale
+ );
+
+ $("intl-osprefs-systemlocales").textContent = JSON.stringify(
+ data.osPrefs.systemLocales
+ );
+ $("intl-osprefs-regionalprefs").textContent = JSON.stringify(
+ data.osPrefs.regionalPrefsLocales
+ );
+ },
+};
+
+var $ = document.getElementById.bind(document);
+
+// eslint-disable-next-line func-names
+$.new = function $_new(tag, textContentOrChildren, className, attributes) {
+ let elt = document.createElement(tag);
+ if (className) {
+ elt.className = className;
+ }
+ if (attributes) {
+ if (attributes["data-l10n-id"]) {
+ let args = attributes.hasOwnProperty("data-l10n-args")
+ ? attributes["data-l10n-args"]
+ : undefined;
+ document.l10n.setAttributes(elt, attributes["data-l10n-id"], args);
+ delete attributes["data-l10n-id"];
+ if (args) {
+ delete attributes["data-l10n-args"];
+ }
+ }
+
+ for (let attrName in attributes) {
+ elt.setAttribute(attrName, attributes[attrName]);
+ }
+ }
+ if (Array.isArray(textContentOrChildren)) {
+ this.append(elt, textContentOrChildren);
+ } else if (!attributes || !attributes["data-l10n-id"]) {
+ elt.textContent = String(textContentOrChildren);
+ }
+ return elt;
+};
+
+// eslint-disable-next-line func-names
+$.append = function $_append(parent, children) {
+ children.forEach(c => parent.appendChild(c));
+};
+
+function assembleFromGraphicsFailure(i, data) {
+ // Only cover the cases we have today; for example, we do not have
+ // log failures that assert and we assume the log level is 1/error.
+ let message = data.failures[i];
+ let index = data.indices[i];
+ let what = "";
+ if (message.search(/\[GFX1-\]: \(LF\)/) == 0) {
+ // Non-asserting log failure - the message is substring(14)
+ what = "LogFailure";
+ message = message.substring(14);
+ } else if (message.search(/\[GFX1-\]: /) == 0) {
+ // Non-asserting - the message is substring(9)
+ what = "Error";
+ message = message.substring(9);
+ } else if (message.search(/\[GFX1\]: /) == 0) {
+ // Asserting - the message is substring(8)
+ what = "Assert";
+ message = message.substring(8);
+ }
+ let assembled = {
+ index,
+ header: "(#" + index + ") " + what,
+ message,
+ };
+ return assembled;
+}
+
+function sortedArrayFromObject(obj) {
+ let tuples = [];
+ for (let prop in obj) {
+ tuples.push([prop, obj[prop]]);
+ }
+ tuples.sort(([prop1, v1], [prop2, v2]) => prop1.localeCompare(prop2));
+ return tuples;
+}
+
+function copyRawDataToClipboard(button) {
+ if (button) {
+ button.disabled = true;
+ }
+ Troubleshoot.snapshot().then(
+ async snapshot => {
+ if (button) {
+ button.disabled = false;
+ }
+ let str = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ str.data = JSON.stringify(snapshot, undefined, 2);
+ let transferable = Cc[
+ "@mozilla.org/widget/transferable;1"
+ ].createInstance(Ci.nsITransferable);
+ transferable.init(getLoadContext());
+ transferable.addDataFlavor("text/plain");
+ transferable.setTransferData("text/plain", str);
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Ci.nsIClipboard.kGlobalClipboard
+ );
+ },
+ err => {
+ if (button) {
+ button.disabled = false;
+ }
+ console.error(err);
+ }
+ );
+}
+
+function getLoadContext() {
+ return window.docShell.QueryInterface(Ci.nsILoadContext);
+}
+
+async function copyContentsToClipboard() {
+ // Get the HTML and text representations for the important part of the page.
+ let contentsDiv = $("contents").cloneNode(true);
+ // Remove the items we don't want to copy from the clone:
+ contentsDiv.querySelectorAll(".no-copy, [hidden]").forEach(n => n.remove());
+ let dataHtml = contentsDiv.innerHTML;
+ let dataText = createTextForElement(contentsDiv);
+
+ // We can't use plain strings, we have to use nsSupportsString.
+ let supportsStringClass = Cc["@mozilla.org/supports-string;1"];
+ let ssHtml = supportsStringClass.createInstance(Ci.nsISupportsString);
+ let ssText = supportsStringClass.createInstance(Ci.nsISupportsString);
+
+ let transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transferable.init(getLoadContext());
+
+ // Add the HTML flavor.
+ transferable.addDataFlavor("text/html");
+ ssHtml.data = dataHtml;
+ transferable.setTransferData("text/html", ssHtml);
+
+ // Add the plain text flavor.
+ transferable.addDataFlavor("text/plain");
+ ssText.data = dataText;
+ transferable.setTransferData("text/plain", ssText);
+
+ // Store the data into the clipboard.
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+}
+
+// Return the plain text representation of an element. Do a little bit
+// of pretty-printing to make it human-readable.
+function createTextForElement(elem) {
+ let serializer = new Serializer();
+ let text = serializer.serialize(elem);
+
+ // Actual CR/LF pairs are needed for some Windows text editors.
+ if (AppConstants.platform == "win") {
+ text = text.replace(/\n/g, "\r\n");
+ }
+
+ return text;
+}
+
+function Serializer() {}
+
+Serializer.prototype = {
+ serialize(rootElem) {
+ this._lines = [];
+ this._startNewLine();
+ this._serializeElement(rootElem);
+ this._startNewLine();
+ return this._lines.join("\n").trim() + "\n";
+ },
+
+ // The current line is always the line that writing will start at next. When
+ // an element is serialized, the current line is updated to be the line at
+ // which the next element should be written.
+ get _currentLine() {
+ return this._lines.length ? this._lines[this._lines.length - 1] : null;
+ },
+
+ set _currentLine(val) {
+ this._lines[this._lines.length - 1] = val;
+ },
+
+ _serializeElement(elem) {
+ // table
+ if (elem.localName == "table") {
+ this._serializeTable(elem);
+ return;
+ }
+
+ // all other elements
+
+ let hasText = false;
+ for (let child of elem.childNodes) {
+ if (child.nodeType == Node.TEXT_NODE) {
+ let text = this._nodeText(child);
+ this._appendText(text);
+ hasText = hasText || !!text.trim();
+ } else if (child.nodeType == Node.ELEMENT_NODE) {
+ this._serializeElement(child);
+ }
+ }
+
+ // For headings, draw a "line" underneath them so they stand out.
+ let isHeader = /^h[0-9]+$/.test(elem.localName);
+ if (isHeader) {
+ let headerText = (this._currentLine || "").trim();
+ if (headerText) {
+ this._startNewLine();
+ this._appendText("-".repeat(headerText.length));
+ }
+ }
+
+ // Add a blank line underneath elements but only if they contain text.
+ if (hasText && (isHeader || "p" == elem.localName)) {
+ this._startNewLine();
+ this._startNewLine();
+ }
+ },
+
+ _startNewLine(lines) {
+ let currLine = this._currentLine;
+ if (currLine) {
+ // The current line is not empty. Trim it.
+ this._currentLine = currLine.trim();
+ if (!this._currentLine) {
+ // The current line became empty. Discard it.
+ this._lines.pop();
+ }
+ }
+ this._lines.push("");
+ },
+
+ _appendText(text, lines) {
+ this._currentLine += text;
+ },
+
+ _isHiddenSubHeading(th) {
+ return th.parentNode.parentNode.style.display == "none";
+ },
+
+ _serializeTable(table) {
+ // Collect the table's column headings if in fact there are any. First
+ // check thead. If there's no thead, check the first tr.
+ let colHeadings = {};
+ let tableHeadingElem = table.querySelector("thead");
+ if (!tableHeadingElem) {
+ tableHeadingElem = table.querySelector("tr");
+ }
+ if (tableHeadingElem) {
+ let tableHeadingCols = tableHeadingElem.querySelectorAll("th,td");
+ // If there's a contiguous run of th's in the children starting from the
+ // rightmost child, then consider them to be column headings.
+ for (let i = tableHeadingCols.length - 1; i >= 0; i--) {
+ let col = tableHeadingCols[i];
+ if (col.localName != "th" || col.classList.contains("title-column")) {
+ break;
+ }
+ colHeadings[i] = this._nodeText(col).trim();
+ }
+ }
+ let hasColHeadings = Object.keys(colHeadings).length > 0;
+ if (!hasColHeadings) {
+ tableHeadingElem = null;
+ }
+
+ let trs = table.querySelectorAll("table > tr, tbody > tr");
+ let startRow =
+ tableHeadingElem && tableHeadingElem.localName == "tr" ? 1 : 0;
+
+ if (startRow >= trs.length) {
+ // The table's empty.
+ return;
+ }
+
+ if (hasColHeadings) {
+ // Use column headings. Print each tr as a multi-line chunk like:
+ // Heading 1: Column 1 value
+ // Heading 2: Column 2 value
+ for (let i = startRow; i < trs.length; i++) {
+ let children = trs[i].querySelectorAll("td");
+ for (let j = 0; j < children.length; j++) {
+ let text = "";
+ if (colHeadings[j]) {
+ text += colHeadings[j] + ": ";
+ }
+ text += this._nodeText(children[j]).trim();
+ this._appendText(text);
+ this._startNewLine();
+ }
+ this._startNewLine();
+ }
+ return;
+ }
+
+ // Don't use column headings. Assume the table has only two columns and
+ // print each tr in a single line like:
+ // Column 1 value: Column 2 value
+ for (let i = startRow; i < trs.length; i++) {
+ let children = trs[i].querySelectorAll("th,td");
+ let rowHeading = this._nodeText(children[0]).trim();
+ if (children[0].classList.contains("title-column")) {
+ if (!this._isHiddenSubHeading(children[0])) {
+ this._appendText(rowHeading);
+ }
+ } else if (children.length == 1) {
+ // This is a single-cell row.
+ this._appendText(rowHeading);
+ } else {
+ let childTables = trs[i].querySelectorAll("table");
+ if (childTables.length) {
+ // If we have child tables, don't use nodeText - its trs are already
+ // queued up from querySelectorAll earlier.
+ this._appendText(rowHeading + ": ");
+ } else {
+ this._appendText(
+ rowHeading + ": " + this._nodeText(children[1]).trim()
+ );
+ }
+ }
+ this._startNewLine();
+ }
+ this._startNewLine();
+ },
+
+ _nodeText(node) {
+ return node.textContent.replace(/\s+/g, " ");
+ },
+};
+
+function openProfileDirectory() {
+ // Get the profile directory.
+ let currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let profileDir = currProfD.path;
+
+ // Show the profile directory.
+ let nsLocalFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+ );
+ new nsLocalFile(profileDir).reveal();
+}
+
+/**
+ * Profile reset is only supported for the default profile if the appropriate migrator exists.
+ */
+function populateActionBox() {
+ if (ResetProfile.resetSupported()) {
+ $("reset-box").style.display = "block";
+ }
+ if (!Services.appinfo.inSafeMode && AppConstants.platform !== "android") {
+ $("safe-mode-box").style.display = "block";
+
+ if (Services.policies && !Services.policies.isAllowed("safeMode")) {
+ $("restart-in-safe-mode-button").setAttribute("disabled", "true");
+ }
+ }
+}
+
+// Prompt user to restart the browser in safe mode
+function safeModeRestart() {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ if (!cancelQuit.data) {
+ Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit);
+ }
+}
+
+// Added for TB.
+function onShowPrivateDataChange() {
+ document
+ .getElementById("contents")
+ .classList.toggle(
+ "show-private-data",
+ document.getElementById("check-show-private-data").checked
+ );
+}
+
+/**
+ * Set up event listeners for buttons.
+ */
+function setupEventListeners() {
+ /* not used by TB
+ let button = $("reset-box-button");
+ if (button) {
+ button.addEventListener("click", function(event) {
+ ResetProfile.openConfirmationDialog(window);
+ });
+ }
+*/
+ let button = $("clear-startup-cache-button");
+ if (button) {
+ button.addEventListener("click", async function (event) {
+ const [promptTitle, promptBody, restartButtonLabel] =
+ await document.l10n.formatValues([
+ { id: "startup-cache-dialog-title2" },
+ { id: "startup-cache-dialog-body2" },
+ { id: "restart-button-label" },
+ ]);
+ const buttonFlags =
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL +
+ Services.prompt.BUTTON_POS_0_DEFAULT;
+ const result = Services.prompt.confirmEx(
+ window.docShell.chromeEventHandler.ownerGlobal,
+ promptTitle,
+ promptBody,
+ buttonFlags,
+ restartButtonLabel,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (result !== 0) {
+ return;
+ }
+ Services.appinfo.invalidateCachesOnRestart();
+ Services.startup.quit(
+ Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit
+ );
+ });
+ }
+ button = $("restart-in-safe-mode-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ if (
+ Services.obs
+ .enumerateObservers("restart-in-safe-mode")
+ .hasMoreElements()
+ ) {
+ Services.obs.notifyObservers(null, "restart-in-safe-mode");
+ } else {
+ safeModeRestart();
+ }
+ });
+ }
+ if (AppConstants.MOZ_UPDATER) {
+ button = $("update-dir-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ // Get the update directory.
+ let updateDir = Services.dirsvc.get("UpdRootD", Ci.nsIFile);
+ if (!updateDir.exists()) {
+ updateDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ let updateDirPath = updateDir.path;
+ // Show the update directory.
+ let nsLocalFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+ );
+ new nsLocalFile(updateDirPath).reveal();
+ });
+ }
+ button = $("show-update-history-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://mozapps/content/update/history.xhtml",
+ "Update:History",
+ "centerscreen,resizable=no,titlebar,modal"
+ );
+ });
+ }
+ }
+ button = $("verify-place-integrity-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ PlacesDBUtils.checkAndFixDatabase().then(tasksStatusMap => {
+ let logs = [];
+ for (let [key, value] of tasksStatusMap) {
+ logs.push(`> Task: ${key}`);
+ let prefix = value.succeeded ? "+ " : "- ";
+ logs = logs.concat(value.logs.map(m => `${prefix}${m}`));
+ }
+ $("verify-place-result").style.display = "block";
+ $("verify-place-result").classList.remove("no-copy");
+ $("verify-place-result").textContent = logs.join("\n");
+ });
+ });
+ }
+
+ // added for TB
+ $("send-via-email").addEventListener("click", function (event) {
+ sendViaEmail();
+ });
+ // end of TB addition
+ /* not used by TB
+ $("copy-raw-data-to-clipboard").addEventListener("click", function(event) {
+ copyRawDataToClipboard(this);
+ });
+*/
+ $("copy-to-clipboard").addEventListener("click", function (event) {
+ copyContentsToClipboard();
+ });
+ $("profile-dir-button").addEventListener("click", function (event) {
+ openProfileDirectory();
+ });
+}
diff --git a/comm/mail/components/about-support/content/aboutSupport.xhtml b/comm/mail/components/about-support/content/aboutSupport.xhtml
new file mode 100644
index 0000000000..8b3f5df2f6
--- /dev/null
+++ b/comm/mail/components/about-support/content/aboutSupport.xhtml
@@ -0,0 +1,956 @@
+<?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/. -->
+
+<!-- This file is a copy of mozilla/toolkit/content/aboutSupport.xhtml with
+ modifications for TB. -->
+
+<!DOCTYPE html [
+ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd"> %htmlDTD;
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> %brandDTD;
+]>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Security-Policy" content="default-src chrome:; object-src 'none'" />
+ <meta name="color-scheme" content="light dark" />
+ <title data-l10n-id="page-title"/>
+
+ <link rel="icon" type="image/png" id="favicon"
+ href="chrome://branding/content/icon48.png"/>
+ <link rel="stylesheet" href="chrome://global/skin/aboutSupport.css"
+ type="text/css"/>
+<!-- Added for TB -->
+ <link rel="stylesheet" href="chrome://messenger/skin/aboutSupport.css"
+ type="text/css"/>
+<!-- End of TB addition -->
+ <script src="chrome://messenger/content/about-support/aboutSupport.js"/>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="toolkit/about/aboutSupport.ftl"/>
+ <link rel="localization" href="toolkit/global/resetProfile.ftl"/>
+ <link rel="localization" href="toolkit/global/processTypes.ftl"/>
+<!-- Added for TB -->
+ <link rel="localization" href="messenger/aboutSupportMail.ftl"/>
+ <link rel="localization" href="messenger/aboutSupportCalendar.ftl"/>
+ <link rel="localization" href="messenger/aboutSupportChat.ftl"/>
+ <script src="chrome://messenger/content/about-support/accounts.js"/>
+ <script src="chrome://messenger/content/about-support/calendars.js"/>
+ <script src="chrome://messenger/content/about-support/chat.js"/>
+ <script src="chrome://messenger/content/about-support/libs.js"/>
+ <script src="chrome://messenger/content/about-support/export.js"/>
+<!-- End of TB addition -->
+ </head>
+
+ <body class="wide-container">
+ <h1 data-l10n-id="page-title"/>
+ <div class="header-flex">
+ <div class="content-flex">
+ <div class="page-subtitle" data-l10n-id="page-subtitle">
+ <a id="supportLink" data-l10n-name="support-link"></a>
+ </div>
+ <div id="support-buttons">
+ <!-- Not used on TB
+ <button id="copy-raw-data-to-clipboard" data-l10n-id="copy-raw-data-to-clipboard-label"/>
+ -->
+ <button id="copy-to-clipboard" data-l10n-id="copy-text-to-clipboard-label"/>
+ <!-- Added for TB -->
+ <button id="send-via-email" data-l10n-id="send-via-email"/>
+ <div>
+ <input type="checkbox"
+ id="check-show-private-data"
+ class="data-uionly"
+ role="checkbox"/>
+ <span>
+ <label for="check-show-private-data" data-l10n-id="show-private-data-main-text"/>
+ <span class="gray-text" data-l10n-id="show-private-data-explanation-text"></span>
+ </span>
+ </div>
+ <!-- End of TB addition -->
+ </div>
+ </div>
+
+#ifndef ANDROID
+ <div class="action-box">
+ <div id="reset-box">
+ <h3 data-l10n-id="refresh-profile"/>
+ <button id="reset-box-button" data-l10n-id="refresh-profile-button"/>
+ </div>
+ <div id="safe-mode-box">
+ <h3 data-l10n-id="troubleshoot-mode-title"/>
+ <button id="restart-in-safe-mode-button" data-l10n-id="restart-in-troubleshoot-mode-label"/>
+ </div>
+ <div id="clear-startup-cache-box">
+ <h3 data-l10n-id="clear-startup-cache-title"/>
+ <button id="clear-startup-cache-button" data-l10n-id="clear-startup-cache-label"/>
+ </div>
+ </div>
+#endif
+ </div>
+ <div id="contents">
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="app-basics-title"/>
+
+ <table>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="app-basics-name"/>
+
+ <td id="application-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-version"/>
+
+ <td id="version-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-build-id"/>
+ <td id="buildid-box"></td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-distribution-id"/>
+ <td id="distributionid-box"></td>
+ </tr>
+
+#ifndef ANDROID
+#ifdef MOZ_UPDATER
+ <tr id="update-dir-row" class="no-copy">
+ <th class="column" data-l10n-id="app-basics-update-dir"/>
+
+ <td>
+ <button id="update-dir-button" data-l10n-id="show-dir-label"/>
+ <span id="update-dir-box" dir="ltr">
+ </span>
+ </td>
+ </tr>
+
+ <tr id="update-history-row" class="no-copy">
+ <th class="column" data-l10n-id="app-basics-update-history"/>
+
+ <td>
+ <button id="show-update-history-button" data-l10n-id="app-basics-show-update-history"/>
+ </td>
+ </tr>
+#endif
+#endif
+
+#ifdef MOZ_UPDATER
+ <tr>
+ <th class="column" data-l10n-id="app-basics-update-channel"/>
+ <td id="updatechannel-box"></td>
+ </tr>
+#endif
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-user-agent"/>
+
+ <td id="useragent-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-os"/>
+
+ <td id="os-box">
+ </td>
+ </tr>
+
+ <tr id="os-theme-row">
+ <th class="column" data-l10n-id="app-basics-os-theme"/>
+
+ <td id="os-theme-box">
+ </td>
+ </tr>
+
+#ifdef XP_MACOSX
+ <tr>
+ <th class="column" data-l10n-id="app-basics-rosetta"/>
+
+ <td id="rosetta-box">
+ </td>
+ </tr>
+#endif
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-binary"/>
+
+ <td id="binary-box" dir="ltr">
+ </td>
+ </tr>
+
+ <tr id="profile-row" class="no-copy" dir="ltr">
+ <th class="column" data-l10n-id="app-basics-profile-dir"/>
+
+ <td>
+ <button id="profile-dir-button" data-l10n-id="show-dir-label"/>
+ <span id="profile-dir-box">
+ </span>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-build-config"/>
+
+ <td>
+ <a href="about:buildconfig" target="_blank">about:buildconfig</a>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-memory-use"/>
+
+ <td>
+ <a href="about:memory" target="_blank">about:memory</a>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-cache-use"/>
+
+ <td>
+ <a href="about:cache" target="_blank">about:cache</a>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-performance"/>
+
+ <td>
+ <a href="about:processes" target="_blank">about:processes</a>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-service-workers"/>
+
+ <td>
+ <a href="about:serviceworkers" target="_blank">about:serviceworkers</a>
+ </td>
+ </tr>
+
+#if defined(XP_WIN)
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-third-party"/>
+
+ <td>
+ <a href="about:third-party" target="_blank">about:third-party</a>
+ </td>
+ </tr>
+#endif
+
+#if defined(XP_WIN) && defined(MOZ_LAUNCHER_PROCESS)
+ <tr>
+ <th class="column" data-l10n-id="app-basics-launcher-process-status"/>
+
+ <td id="launcher-process-box">
+ </td>
+ </tr>
+#endif
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-multi-process-support"/>
+
+ <td id="multiprocess-box">
+ <span id="multiprocess-box-process-count"/>
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-fission-support"/>
+
+ <td id="fission-box">
+ <span id="fission-box-process-count"/>
+ <span id="fission-box-status"/>
+ </td>
+ </tr>
+
+ <tr id="remoteprocesses-row">
+ <th class="column" data-l10n-id="app-basics-remote-processes-count"/>
+
+ <td>
+ <a href="#remote-processes"></a>
+ </td>
+ </tr>
+
+ <tr id="policies-status-row">
+ <th class="column" data-l10n-id="app-basics-enterprise-policies"/>
+
+ <td id="policies-status">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-location-service-key-google"/>
+
+ <td id="key-location-service-google-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-safebrowsing-key-google"/>
+
+ <td id="key-safebrowsing-google-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-key-mozilla"/>
+
+ <td id="key-mozilla-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-safe-mode"/>
+
+ <td id="safemode-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-memory-size"/>
+
+ <td id="memory-size-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-disk-available"/>
+
+ <td id="disk-available-box">
+ </td>
+ </tr>
+
+#ifndef ANDROID
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-profiles"/>
+
+ <td>
+ <a href="about:profiles" target="_blank">about:profiles</a>
+ </td>
+ </tr>
+#endif
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-telemetry"/>
+
+ <td>
+ <a href="about:telemetry" target="_blank">about:telemetry</a>
+ </td>
+ </tr>
+
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+<!-- Added for TB -->
+ <h2 class="major-section" data-l10n-id="accounts-title"/>
+
+ <table id="accounts-table">
+ <thead>
+ <tr>
+ <th rowspan="2" data-l10n-id="accounts-ID"/>
+
+ <th rowspan="2" class="data-private" data-l10n-id="accounts-name"/>
+
+ <th colspan="3" data-l10n-id="accounts-incoming-server"/>
+
+ <th colspan="5" data-l10n-id="accounts-outgoing-servers"/>
+ </tr>
+ <tr class="thead-level2">
+ <!-- Incoming server -->
+ <th data-l10n-id="accounts-server-name"/>
+
+ <th data-l10n-id="accounts-conn-security"/>
+
+ <th data-l10n-id="accounts-auth-method"/>
+
+ <!-- Outgoing servers -->
+ <th class="data-private" data-l10n-id="identity-name"/>
+
+ <th data-l10n-id="accounts-server-name"/>
+
+ <th data-l10n-id="accounts-conn-security"/>
+
+ <th data-l10n-id="accounts-auth-method"/>
+
+ <th data-l10n-id="accounts-default"/>
+ </tr>
+ </thead>
+
+ <tbody id="accounts-tbody">
+ </tbody>
+ </table>
+
+ <h2 class="major-section" data-l10n-id="mail-libs-title"></h2>
+ <table class="mail-libs-table">
+ <caption></caption>
+ <thead>
+ <th data-l10n-id="libs-table-heading-library"></th>
+ <th data-l10n-id="libs-table-heading-status"></th>
+ <th data-l10n-id="libs-table-heading-expected-version"></th>
+ <th data-l10n-id="libs-table-heading-loaded-version"></th>
+ <th data-l10n-id="libs-table-heading-path"></th>
+ </thead>
+
+ <tbody>
+ <tr>
+ <td>RNP (OpenPGP)</td>
+ <td id="rnp-status">
+ </td>
+ <td id="rnp-expected-version">
+ </td>
+ <td id="rnp-loaded-version">
+ </td>
+ <td id="rnp-path">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <h2 class="major-section" data-l10n-id="calendars-title"></h2>
+
+ <div id="calendar-tables"></div>
+
+ <template id="calendars-table-template">
+ <table class="calendar-table">
+ <caption></caption>
+ <thead>
+ <th data-l10n-id="calendars-table-heading-property"></th>
+ <th data-l10n-id="calendars-table-heading-value"></th>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </template>
+
+ <template id="calendars-table-row-template">
+ <tr>
+ <td></td>
+ <td></td>
+ </tr>
+ </template>
+
+ <h2 class="major-section no-copy" data-l10n-id="chat-title"></h2>
+
+ <table class="no-copy" id="chat-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="chat-table-heading-account"></th>
+ <th data-l10n-id="chat-table-heading-protocol"></th>
+ <th data-l10n-id="chat-table-heading-name" class="data-private"></th>
+ <th data-l10n-id="chat-table-heading-actions"></th>
+ </tr>
+ </thead>
+ <tbody id="chat-tbody">
+ </tbody>
+ </table>
+
+ <template id="chat-table-row-template">
+ <tr>
+ <td></td>
+ <td></td>
+ <td class="data-private"></td>
+ <td><button class="button" type="button" data-l10n-id="chat-table-copy-debug-log"></button></td>
+ </tr>
+ </template>
+
+<!-- End of TB addition -->
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+#ifdef MOZ_CRASHREPORTER
+
+ <h2 class="major-section" id="crashes-title" data-l10n-id="crashes-title"/>
+
+ <table id="crashes-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="crashes-id"/>
+ <th data-l10n-id="crashes-send-date"/>
+ </tr>
+ </thead>
+ <tbody id="crashes-tbody">
+ </tbody>
+ </table>
+ <p id="crashes-allReports" class="hidden no-copy">
+ <a href="about:crashes" id="crashes-allReportsWithPending"
+ class="block" data-l10n-id="crashes-all-reports" target="_blank"/>
+ </p>
+ <p id="crashes-noConfig" class="hidden no-copy" data-l10n-id="crashes-no-config"/>
+
+#endif
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+ <!-- Not used by TB
+ <h2 class="major-section" data-l10n-id="features-title"/>
+
+ <table id="features-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="features-name"/>
+ <th data-l10n-id="features-version"/>
+ <th data-l10n-id="features-id"/>
+ </tr>
+ </thead>
+ <tbody id="features-tbody">
+ </tbody>
+ </table>
+ -->
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="processes-title" id="remote-processes"/>
+
+ <table id="remote-processes-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="processes-type"/>
+ <th data-l10n-id="processes-count"/>
+ </tr>
+ </thead>
+ <tbody id="processes-tbody">
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="support-addons-title"/>
+
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="support-addons-name"/>
+ <th data-l10n-id="support-addons-type"/>
+ <th data-l10n-id="support-addons-version"/>
+ <th data-l10n-id="support-addons-enabled"/>
+ <th data-l10n-id="support-addons-id"/>
+ </tr>
+ </thead>
+ <tbody id="addons-tbody">
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" id="security-software-title" data-l10n-id="security-software-title"/>
+
+ <table id="security-software-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="security-software-type"/>
+ <th data-l10n-id="security-software-name"/>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="security-software-antivirus"/>
+
+ <td id="security-software-antivirus">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="security-software-antispyware"/>
+
+ <td id="security-software-antispyware">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="security-software-firewall"/>
+
+ <td id="security-software-firewall">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="graphics-title"/>
+
+ <table>
+ <tbody id="graphics-features-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-features-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-gpu-1-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-gpu1-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-gpu-2-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-gpu2-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-diagnostics-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-diagnostics-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-decisions-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-decision-log-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-crashguards-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-crash-guards-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-workarounds-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-workarounds-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-failures-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-failure-log-title"/>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="media-title"/>
+ <table>
+ <tbody id="media-info-tbody">
+ </tbody>
+
+ <tbody id="media-output-devices-tbody">
+ <tr>
+ <th colspan="9" class="title-column" data-l10n-id="media-output-devices-title"/>
+ </tr>
+ <tr>
+ <th data-l10n-id="media-device-name"/>
+ <th data-l10n-id="media-device-group"/>
+ <th data-l10n-id="media-device-vendor"/>
+ <th data-l10n-id="media-device-state"/>
+ <th data-l10n-id="media-device-preferred"/>
+ <th data-l10n-id="media-device-format"/>
+ <th data-l10n-id="media-device-channels"/>
+ <th data-l10n-id="media-device-rate"/>
+ <th data-l10n-id="media-device-latency"/>
+ </tr>
+ </tbody>
+
+ <tbody id="media-input-devices-tbody">
+ <tr>
+ <th colspan="9" class="title-column" data-l10n-id="media-input-devices-title"/>
+ </tr>
+ <tr>
+ <th data-l10n-id="media-device-name"/>
+ <th data-l10n-id="media-device-group"/>
+ <th data-l10n-id="media-device-vendor"/>
+ <th data-l10n-id="media-device-state"/>
+ <th data-l10n-id="media-device-preferred"/>
+ <th data-l10n-id="media-device-format"/>
+ <th data-l10n-id="media-device-channels"/>
+ <th data-l10n-id="media-device-rate"/>
+ <th data-l10n-id="media-device-latency"/>
+ </tr>
+ </tbody>
+
+ <tbody id="media-capabilities-tbody">
+ <tr>
+ <th colspan="9" class="title-column" data-l10n-id="media-capabilities-title"/>
+ </tr>
+ <tr>
+ <td colspan="9">
+ <button id="enumerate-database-button" data-l10n-id="media-capabilities-enumerate"/>
+ <pre id="enumerate-database-result" class="hidden no-copy"></pre>
+ </td>
+ </tr>
+ </tbody>
+
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="environment-variables-title"/>
+
+ <table class="prefs-table">
+ <thead class="no-copy">
+ <th class="name" data-l10n-id="environment-variables-name"/>
+
+ <th class="value" data-l10n-id="environment-variables-value"/>
+ </thead>
+
+ <tbody id="environment-variables-tbody">
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="modified-key-prefs-title"/>
+
+ <table class="prefs-table">
+ <thead class="no-copy">
+ <th class="name" data-l10n-id="modified-prefs-name"/>
+
+ <th class="value" data-l10n-id="modified-prefs-value"/>
+ </thead>
+
+ <tbody id="prefs-tbody">
+ </tbody>
+ </table>
+
+ <section id="prefs-user-js-section" class="hidden no-copy">
+ <h3 data-l10n-id="user-js-title"/>
+ <p data-l10n-id="user-js-description">
+ <a id="prefs-user-js-link" data-l10n-name="user-js-link"></a>
+ </p>
+ </section>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="locked-key-prefs-title"/>
+
+ <table class="prefs-table">
+ <thead class="no-copy">
+ <th class="name" data-l10n-id="locked-prefs-name"/>
+
+ <th class="value" data-l10n-id="locked-prefs-value"/>
+ </thead>
+
+ <tbody id="locked-prefs-tbody">
+ </tbody>
+ </table>
+
+#ifndef ANDROID
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="place-database-title"/>
+
+ <table>
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="place-database-integrity"/>
+
+ <td>
+ <button id="verify-place-integrity-button" data-l10n-id="place-database-verify-integrity"/>
+ <pre id="verify-place-result" class="hidden no-copy"></pre>
+ </td>
+ </tr>
+ </table>
+#endif
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+ <h2 class="major-section" data-l10n-id="a11y-title"/>
+
+ <table>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="a11y-activated"/>
+
+ <td id="a11y-activated">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="a11y-force-disabled"/>
+
+ <td id="a11y-force-disabled">
+ </td>
+ </tr>
+#if defined(XP_WIN)
+ <tr>
+ <th class="column" data-l10n-id="a11y-handler-used"/>
+
+ <td id="a11y-handler-used">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="a11y-instantiator"/>
+
+ <td id="a11y-instantiator">
+ </td>
+ </tr>
+#endif
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+ <h2 class="major-section" data-l10n-id="library-version-title"/>
+
+ <table>
+ <tbody id="libversions-tbody">
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+#if defined(MOZ_SANDBOX)
+ <h2 class="major-section" id="sandbox" data-l10n-id="sandbox-title"/>
+
+ <table>
+ <tbody id="sandbox-tbody">
+ </tbody>
+ </table>
+
+#if defined(XP_LINUX)
+ <h4 data-l10n-id="sandbox-sys-call-log-title"/>
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="sandbox-sys-call-index"/>
+ <th data-l10n-id="sandbox-sys-call-age"/>
+ <th data-l10n-id="sandbox-sys-call-pid"/>
+ <th data-l10n-id="sandbox-sys-call-tid"/>
+ <th data-l10n-id="sandbox-sys-call-proc-type"/>
+ <th data-l10n-id="sandbox-sys-call-number"/>
+ <th id="sandbox-syscalls-argshead" data-l10n-id="sandbox-sys-call-args"/>
+ </tr>
+ </thead>
+ <tbody id="sandbox-syscalls-tbody">
+ </tbody>
+ </table>
+#endif
+#endif
+
+ <h2 class="major-section" data-l10n-id="startup-cache-title"/>
+
+ <table>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="startup-cache-disk-cache-path"/>
+
+ <td id="startup-cache-disk-cache-path">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="startup-cache-ignore-disk-cache"/>
+
+ <td id="startup-cache-ignore-disk-cache">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="startup-cache-found-disk-cache-on-init"/>
+
+ <td id="startup-cache-found-disk-cache-on-init">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="startup-cache-wrote-to-disk-cache"/>
+
+ <td id="startup-cache-wrote-to-disk-cache">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <h2 class="major-section" data-l10n-id="intl-title"/>
+
+ <table>
+ <tbody id="intl-localeservice-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="intl-app-title"/>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-locales-requested"/>
+ <td id="intl-locale-requested">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-locales-available"/>
+ <td id="intl-locale-available">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-locales-supported"/>
+ <td id="intl-locale-supported">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-regional-prefs"/>
+ <td id="intl-locale-regionalprefs">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-locales-default"/>
+ <td id="intl-locale-default">
+ </td>
+ </tr>
+ </tbody>
+ <tbody id="intl-ospreferences-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="intl-os-title"/>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-os-prefs-system-locales"/>
+ <td id="intl-osprefs-systemlocales">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-regional-prefs"/>
+ <td id="intl-osprefs-regionalprefs">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+#if defined(ENABLE_WEBDRIVER)
+ <h2 class="major-section" data-l10n-id="remote-debugging-title"/>
+
+ <table>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="remote-debugging-accepting-connections"/>
+ <td id="remote-debugging-accepting-connections"></td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="remote-debugging-url"/>
+ <td id="remote-debugging-url"></td>
+ </tr>
+ </tbody>
+ </table>
+#endif
+
+#ifndef ANDROID
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="support-printing-title"/>
+
+ <table>
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="support-printing-troubleshoot"/>
+ <td>
+ <button id="support-printing-clear-settings-button" data-l10n-id="support-printing-clear-settings-button"/>
+ </td>
+ </tr>
+ </table>
+
+ <h3 data-l10n-id="support-printing-modified-settings"/>
+
+ <table class="prefs-table">
+ <thead class="no-copy">
+ <th class="name" data-l10n-id="support-printing-prefs-name"/>
+
+ <th class="value" data-l10n-id="support-printing-prefs-value"/>
+ </thead>
+
+ <tbody id="support-printing-prefs-tbody">
+ </tbody>
+ </table>
+#endif
+
+ </div>
+
+ </body>
+
+</html>
diff --git a/comm/mail/components/about-support/content/accounts.js b/comm/mail/components/about-support/content/accounts.js
new file mode 100644
index 0000000000..33633f4a15
--- /dev/null
+++ b/comm/mail/components/about-support/content/accounts.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/. */
+
+/* globals CLASS_DATA_PRIVATE, CLASS_DATA_PUBLIC */
+
+"use strict";
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+// Platform-specific includes
+var AboutSupportPlatform;
+if ("@mozilla.org/windows-registry-key;1" in Cc) {
+ let temp = ChromeUtils.import("resource:///modules/AboutSupportWin32.jsm");
+ AboutSupportPlatform = temp.AboutSupportPlatform;
+} else if ("nsILocalFileMac" in Ci) {
+ let temp = ChromeUtils.import("resource:///modules/AboutSupportMac.jsm");
+ AboutSupportPlatform = temp.AboutSupportPlatform;
+} else {
+ let temp = ChromeUtils.import("resource:///modules/AboutSupportUnix.jsm");
+ AboutSupportPlatform = temp.AboutSupportPlatform;
+}
+
+var gMessengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+);
+
+var gSocketTypes = {};
+for (let [str, index] of Object.entries(Ci.nsMsgSocketType)) {
+ gSocketTypes[index] = str;
+}
+
+var gAuthMethods = {};
+for (let [str, index] of Object.entries(Ci.nsMsgAuthMethod)) {
+ gAuthMethods[index] = str;
+}
+
+// l10n properties in messenger.properties corresponding to each auth method
+var gAuthMethodProperties = new Map([
+ [0, "authNo"], // Special value defined to be invalid.
+ // Some accounts without auth report this.
+ [Ci.nsMsgAuthMethod.none, "authNo"],
+ [Ci.nsMsgAuthMethod.old, "authOld"],
+ [Ci.nsMsgAuthMethod.passwordCleartext, "authPasswordCleartextViaSSL"],
+ [Ci.nsMsgAuthMethod.passwordEncrypted, "authPasswordEncrypted"],
+ [Ci.nsMsgAuthMethod.GSSAPI, "authKerberos"],
+ [Ci.nsMsgAuthMethod.NTLM, "authNTLM"],
+ [Ci.nsMsgAuthMethod.External, "authExternal"],
+ [Ci.nsMsgAuthMethod.secure, "authAnySecure"],
+ [Ci.nsMsgAuthMethod.anything, "authAny"],
+ [Ci.nsMsgAuthMethod.OAuth2, "authOAuth2"],
+]);
+
+var AboutSupport = {
+ /**
+ * Gets details about SMTP servers for a given nsIMsgAccount.
+ *
+ * @returns An array of records, each record containing the name and other details
+ * about one SMTP server.
+ */
+ _getSMTPDetails(aAccount) {
+ let defaultIdentity = aAccount.defaultIdentity;
+ let smtpDetails = [];
+
+ for (let identity of aAccount.identities) {
+ let isDefault = identity == defaultIdentity;
+ let smtpServer = MailServices.smtp.getServerByIdentity(identity);
+ if (!smtpServer) {
+ continue;
+ }
+ smtpDetails.push({
+ identityName: identity.identityName,
+ name: smtpServer.displayname,
+ authMethod: smtpServer.authMethod,
+ socketType: smtpServer.socketType,
+ isDefault,
+ });
+ }
+
+ return smtpDetails;
+ },
+
+ /**
+ * Returns account details as an array of records.
+ */
+ getAccountDetails() {
+ let accountDetails = [];
+
+ for (let account of MailServices.accounts.accounts) {
+ let server = account.incomingServer;
+ accountDetails.push({
+ key: account.key,
+ name: server.prettyName,
+ hostDetails:
+ "(" +
+ server.type +
+ ") " +
+ server.hostName +
+ (server.port != -1 ? ":" + server.port : ""),
+ socketType: server.socketType,
+ authMethod: server.authMethod,
+ smtpServers: this._getSMTPDetails(account),
+ });
+ }
+
+ function idCompare(accountA, accountB) {
+ let regex = /^account([0-9]+)$/;
+ let regexA = regex.exec(accountA.key);
+ let regexB = regex.exec(accountB.key);
+ // There's an off chance that the account ID isn't in the standard
+ // accountN form. If so, use the standard string compare against a fixed
+ // string ("account") to avoid correctness issues.
+ if (!regexA || !regexB) {
+ let keyA = regexA ? "account" : accountA.key;
+ let keyB = regexB ? "account" : accountB.key;
+ return keyA.localeCompare(keyB);
+ }
+ let idA = parseInt(regexA[1]);
+ let idB = parseInt(regexB[1]);
+ return idA - idB;
+ }
+
+ // Sort accountDetails by account ID.
+ accountDetails.sort(idCompare);
+ return accountDetails;
+ },
+
+ /**
+ * Returns the corresponding text for a given socket type index. The text is
+ * returned as a record with "localized" and "neutral" entries.
+ */
+ getSocketTypeText(aIndex) {
+ let plainSocketType =
+ aIndex in gSocketTypes ? gSocketTypes[aIndex] : aIndex;
+ let prettySocketType;
+ try {
+ prettySocketType = gMessengerBundle.GetStringFromName(
+ "smtpServer-ConnectionSecurityType-" + aIndex
+ );
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FAILURE) {
+ // The string wasn't found in the bundle. Make do without it.
+ prettySocketType = plainSocketType;
+ } else {
+ throw e;
+ }
+ }
+ return { localized: prettySocketType, neutral: plainSocketType };
+ },
+
+ /**
+ * Returns the corresponding text for a given authentication method index. The
+ * text is returned as a record with "localized" and "neutral" entries.
+ */
+ getAuthMethodText(aIndex) {
+ let prettyAuthMethod;
+ let plainAuthMethod =
+ aIndex in gAuthMethods ? gAuthMethods[aIndex] : aIndex;
+ if (gAuthMethodProperties.has(parseInt(aIndex))) {
+ prettyAuthMethod = gMessengerBundle.GetStringFromName(
+ gAuthMethodProperties.get(parseInt(aIndex))
+ );
+ } else {
+ prettyAuthMethod = plainAuthMethod;
+ }
+ return { localized: prettyAuthMethod, neutral: plainAuthMethod };
+ },
+};
+
+function createParentElement(tagName, childElems) {
+ let elem = document.createElement(tagName);
+ appendChildren(elem, childElems);
+ return elem;
+}
+
+function createElement(tagName, textContent, opt_attributes, opt_copyData) {
+ if (opt_attributes == null) {
+ opt_attributes = {};
+ }
+ let elem = document.createElement(tagName);
+ elem.textContent = textContent;
+ for (let key in opt_attributes) {
+ elem.setAttribute(key, "" + opt_attributes[key]);
+ }
+
+ if (opt_copyData != null) {
+ elem.dataset.copyData = opt_copyData;
+ }
+
+ return elem;
+}
+
+function appendChildren(parentElem, children) {
+ for (let i = 0; i < children.length; i++) {
+ parentElem.appendChild(children[i]);
+ }
+}
+
+/**
+ * Coerces x into a string.
+ */
+function toStr(x) {
+ return "" + x;
+}
+
+/**
+ * Marks x as private (see below).
+ */
+function toPrivate(x) {
+ return { localized: x, neutral: x, isPrivate: true };
+}
+
+/**
+ * A list of fields for the incoming server of an account. Each element of the
+ * list is a pair of [property name, transforming function]. The transforming
+ * function should take the property and return either a string or an object
+ * with the following properties:
+ * - localized: the data in (possibly) localized form
+ * - neutral: the data in language-neutral form
+ * - isPrivate (optional): true if the data is private-only, false if public-only,
+ * not stated otherwise
+ */
+var gIncomingDetails = [
+ ["key", toStr],
+ ["name", toPrivate],
+ ["hostDetails", toStr],
+ ["socketType", AboutSupport.getSocketTypeText.bind(AboutSupport)],
+ ["authMethod", AboutSupport.getAuthMethodText.bind(AboutSupport)],
+];
+
+/**
+ * A list of fields for the outgoing servers associated with an account. This is
+ * similar to gIncomingDetails above.
+ */
+var gOutgoingDetails = [
+ ["identityName", toPrivate],
+ ["name", toStr],
+ ["socketType", AboutSupport.getSocketTypeText.bind(AboutSupport)],
+ ["authMethod", AboutSupport.getAuthMethodText.bind(AboutSupport)],
+ ["isDefault", toStr],
+];
+
+/**
+ * A list of account details.
+ */
+var gAccountDetails = AboutSupport.getAccountDetails();
+
+function populateAccountsSection() {
+ let trAccounts = [];
+
+ function createTD(data, rowSpan) {
+ let text = typeof data == "string" ? data : data.localized;
+ let copyData = typeof data == "string" ? null : data.neutral;
+ let attributes = { rowspan: rowSpan };
+ if (typeof data == "object" && "isPrivate" in data) {
+ attributes.class = data.isPrivate
+ ? CLASS_DATA_PRIVATE
+ : CLASS_DATA_PUBLIC;
+ }
+
+ return createElement("td", text, attributes, copyData);
+ }
+
+ for (let account of gAccountDetails) {
+ // We want a minimum rowspan of 1
+ let rowSpan = account.smtpServers.length || 1;
+ // incomingTDs is an array of TDs
+ let incomingTDs = gIncomingDetails.map(([prop, fn]) =>
+ createTD(fn(account[prop]), rowSpan)
+ );
+ // outgoingTDs is an array of arrays of TDs
+ let outgoingTDs = [];
+ for (let smtp of account.smtpServers) {
+ outgoingTDs.push(
+ gOutgoingDetails.map(([prop, fn]) => createTD(fn(smtp[prop]), 1))
+ );
+ }
+
+ // If there are no SMTP servers, add a dummy element to make life easier below
+ if (outgoingTDs.length == 0) {
+ outgoingTDs = [[]];
+ }
+
+ // Add the first SMTP server to this tr.
+ let tr = createParentElement("tr", incomingTDs.concat(outgoingTDs[0]));
+ trAccounts.push(tr);
+ // Add the remaining SMTP servers as separate trs
+ for (let tds of outgoingTDs.slice(1)) {
+ trAccounts.push(createParentElement("tr", tds));
+ }
+ }
+
+ appendChildren(document.getElementById("accounts-tbody"), trAccounts);
+}
+
+/**
+ * Returns a plaintext representation of the accounts data.
+ */
+function getAccountsText(aHidePrivateData, aIndent) {
+ let accumulator = [];
+
+ // Given a string or object, converts it into a language-neutral form
+ function neutralizer(data) {
+ if (typeof data == "string") {
+ return data;
+ }
+ if ("isPrivate" in data && aHidePrivateData == data.isPrivate) {
+ return "";
+ }
+ return data.neutral;
+ }
+
+ for (let account of gAccountDetails) {
+ accumulator.push(aIndent + account.key + ":");
+ // incomingData is an array of strings
+ let incomingData = gIncomingDetails.map(([prop, fn]) =>
+ neutralizer(fn(account[prop]))
+ );
+ accumulator.push(aIndent + " INCOMING: " + incomingData.join(", "));
+
+ // outgoingData is an array of arrays of strings
+ let outgoingData = [];
+ for (let smtp of account.smtpServers) {
+ outgoingData.push(
+ gOutgoingDetails.map(([prop, fn]) => neutralizer(fn(smtp[prop])))
+ );
+ }
+
+ for (let data of outgoingData) {
+ accumulator.push(aIndent + " OUTGOING: " + data.join(", "));
+ }
+
+ accumulator.push("");
+ }
+
+ return accumulator.join("\n");
+}
diff --git a/comm/mail/components/about-support/content/calendars.js b/comm/mail/components/about-support/content/calendars.js
new file mode 100644
index 0000000000..a55b59572c
--- /dev/null
+++ b/comm/mail/components/about-support/content/calendars.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/. */
+
+/* globals CLASS_DATA_PRIVATE */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+let boolean = val => (!val && val !== false ? "" : val);
+let string = val => (val ? String(val) : "");
+
+/**
+ * A list of tuples for each calendar property displayed where each tuple
+ * contains the following elements:
+ * 0 - The name of the property passed to getProperty().
+ * 1 - A function that accepts the property value and attempts it into a string.
+ * 2 - Boolean indicating whether the property is private data (optional).
+ */
+let gCalendarProperties = [
+ ["name", string, true],
+ ["type", string],
+ ["disabled", boolean],
+ ["username", string, true],
+ ["uri", string, true],
+ ["refreshInterval", string],
+ ["readOnly", boolean],
+ ["suppressAlarms", boolean],
+ ["cache.enabled", boolean],
+ ["imip.identity", identity => string(identity && identity.key)],
+ ["imip.identity.disabled", boolean],
+ ["imip.identity.account", account => string(account && account.key)],
+ ["organizerId", string, true],
+ ["forceEmailScheduling", boolean],
+ ["capabilities.alarms.popup.supported", boolean],
+ ["capabilities.alarms.oninviations.supported", boolean],
+ ["capabilities.alarms.maxCount", string],
+ ["capabilities.attachments.supported", boolean],
+ ["capabilities.categories.maxCount", string],
+ ["capabilities.privacy.supported", boolean],
+ ["capabilities.priority.supported", boolean],
+ ["capabilities.events.supported", boolean],
+ ["capabilities.tasks.supported", boolean],
+ ["capabilities.timezones.floating.supported", boolean],
+ ["capabilities.timezones.UTC.supported", boolean],
+ ["capabilities.autoschedule.supported", boolean],
+];
+
+/**
+ * Populates the "Calendars" section of the troubleshooting information page
+ * with the properties of each configured calendar.
+ */
+function populateCalendarsSection() {
+ let container = document.getElementById("calendar-tables");
+ let tableTmpl = document.getElementById("calendars-table-template");
+ let rowTmpl = document.getElementById("calendars-table-row-template");
+
+ for (let calendar of cal.manager.getCalendars()) {
+ let table = tableTmpl.content.cloneNode(true).querySelector("table");
+ table.firstElementChild.textContent = calendar.name;
+
+ let tbody = table.querySelector("tbody");
+ for (let [prop, transform, isPrivate] of gCalendarProperties) {
+ let tr = rowTmpl.content.cloneNode(true).querySelector("tr");
+ let l10nKey = `calendars-table-${prop
+ .toLowerCase()
+ .replaceAll(".", "-")}`;
+
+ tr.cells[0].setAttribute("data-l10n-id", l10nKey);
+ tr.cells[1].textContent = transform(calendar.getProperty(prop));
+ if (isPrivate) {
+ tr.cells[1].setAttribute("class", CLASS_DATA_PRIVATE);
+ }
+ tbody.appendChild(tr);
+ }
+ container.appendChild(table);
+ }
+}
diff --git a/comm/mail/components/about-support/content/chat.js b/comm/mail/components/about-support/content/chat.js
new file mode 100644
index 0000000000..50c34b9ba0
--- /dev/null
+++ b/comm/mail/components/about-support/content/chat.js
@@ -0,0 +1,73 @@
+/* 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 { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+/**
+ * Populates the "Chat" section of the troubleshooting information page with
+ * the chat accounts.
+ */
+function populateChatSection() {
+ let table = document.getElementById("chat-table");
+ let rowTmpl = document.getElementById("chat-table-row-template");
+ let dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeStyle: "long",
+ });
+ let formatDebugMessage = dbgMsg => {
+ let m = dbgMsg.message;
+ let time = new Date(m.timeStamp);
+ time = dateTimeFormatter.format(time);
+ let level = dbgMsg.logLevel;
+ if (!level) {
+ return "(" + m.errorMessage + ")";
+ }
+ if (level == dbgMsg.LEVEL_ERROR) {
+ level = "ERROR";
+ } else if (level == dbgMsg.LEVEL_WARNING) {
+ level = "WARN.";
+ } else if (level == dbgMsg.LEVEL_LOG) {
+ level = "LOG ";
+ } else {
+ level = "DEBUG";
+ }
+ return (
+ "[" +
+ time +
+ "] " +
+ level +
+ " (@ " +
+ m.sourceLine +
+ " " +
+ m.sourceName +
+ ":" +
+ m.lineNumber +
+ ")\n" +
+ m.errorMessage
+ );
+ };
+
+ let chatAccounts = IMServices.accounts.getAccounts();
+ if (!chatAccounts.length) {
+ return;
+ }
+ table.querySelector("tbody").append(
+ ...chatAccounts.map(account => {
+ const row = rowTmpl.content.cloneNode(true).querySelector("tr");
+ row.cells[0].textContent = account.id;
+ row.cells[1].textContent = account.protocol.id;
+ row.cells[2].textContent = account.name;
+ row.cells[3].addEventListener("click", () => {
+ const text = account
+ .getDebugMessages()
+ .map(formatDebugMessage)
+ .join("\n");
+ navigator.clipboard.writeText(text);
+ });
+ return row;
+ })
+ );
+}
diff --git a/comm/mail/components/about-support/content/export.js b/comm/mail/components/about-support/content/export.js
new file mode 100644
index 0000000000..46eb0c6497
--- /dev/null
+++ b/comm/mail/components/about-support/content/export.js
@@ -0,0 +1,288 @@
+/* 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 CLASS_DATA_PRIVATE, CLASS_DATA_PUBLIC, CLASS_DATA_UIONLY, createElement,
+createParentElement, getAccountsText, getLoadContext, MailServices, Services */
+
+"use strict";
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+/**
+ * Create warning text to add to any private data.
+ *
+ * @returns A HTML paragraph node containing the warning.
+ */
+function createWarning() {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/aboutSupportMail.properties"
+ );
+ return createParentElement("p", [
+ createElement("strong", bundle.GetStringFromName("warningLabel")),
+ // Add some whitespace between the label and the text
+ document.createTextNode(" "),
+ document.createTextNode(bundle.GetStringFromName("warningText")),
+ ]);
+}
+
+function getClipboardTransferable() {
+ // Get the HTML and text representations for the important part of the page.
+ let hidePrivateData = !document.getElementById("check-show-private-data")
+ .checked;
+ let contentsDiv = createCleanedUpContents(hidePrivateData);
+ let dataHtml = contentsDiv.innerHTML;
+ let dataText = createTextForElement(contentsDiv, hidePrivateData);
+
+ // We can't use plain strings, we have to use nsSupportsString.
+ let supportsStringClass = Cc["@mozilla.org/supports-string;1"];
+ let ssHtml = supportsStringClass.createInstance(Ci.nsISupportsString);
+ let ssText = supportsStringClass.createInstance(Ci.nsISupportsString);
+
+ let transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transferable.init(getLoadContext());
+
+ // Add the HTML flavor.
+ transferable.addDataFlavor("text/html");
+ ssHtml.data = dataHtml;
+ transferable.setTransferData("text/html", ssHtml);
+
+ // Add the plain text flavor.
+ transferable.addDataFlavor("text/plain");
+ ssText.data = dataText;
+ transferable.setTransferData("text/plain", ssText);
+
+ return transferable;
+}
+
+// This function intentionally has the same name as the one in aboutSupport.js
+// so that the one here is called.
+function copyContentsToClipboard() {
+ let transferable = getClipboardTransferable();
+ // Store the data into the clipboard.
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+}
+
+function sendViaEmail() {
+ // Get the HTML representation for the important part of the page.
+ let hidePrivateData = !document.getElementById("check-show-private-data")
+ .checked;
+ let contentsDiv = createCleanedUpContents(hidePrivateData);
+ let dataHtml = contentsDiv.innerHTML;
+ // The editor considers whitespace to be significant, so replace all
+ // whitespace with a single space.
+ dataHtml = dataHtml.replace(/\s+/g, " ");
+
+ // Set up parameters and fields to use for the compose window.
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.HTML;
+
+ let fields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ fields.forcePlainText = false;
+ fields.body = dataHtml;
+ // In general we can have non-ASCII characters, and compose's charset
+ // detection doesn't seem to work when the HTML part is pure ASCII but the
+ // text isn't. So take the easy way out and force UTF-8.
+ fields.bodyIsAsciiOnly = false;
+ params.composeFields = fields;
+
+ // Our params are set up. Now open a compose window.
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+}
+
+function createCleanedUpContents(aHidePrivateData) {
+ // Get the important part of the page.
+ let contentsDiv = document.getElementById("contents");
+ // Deep-clone the entire div.
+ let clonedDiv = contentsDiv.cloneNode(true);
+ // Go in and replace text with the text we actually want to copy.
+ // (this mutates the cloned node)
+ cleanUpText(clonedDiv, aHidePrivateData);
+ // Insert a warning if we need to
+ if (!aHidePrivateData) {
+ clonedDiv.insertBefore(createWarning(), clonedDiv.firstChild);
+ }
+ return clonedDiv;
+}
+
+function cleanUpText(aElem, aHidePrivateData) {
+ let node = aElem.firstChild;
+ let copyData = aElem.dataset.copyData;
+ delete aElem.dataset.copyData;
+ while (node) {
+ let classList = "classList" in node && node.classList;
+ // Delete uionly and no-copy nodes.
+ if (
+ classList &&
+ (classList.contains(CLASS_DATA_UIONLY) || classList.contains("no-copy"))
+ ) {
+ // Advance to the next node before removing the current node, since
+ // node.nextElementSibling is null after remove()
+ let nextNode = node.nextElementSibling;
+ node.remove();
+ node = nextNode;
+ continue;
+ } else if (
+ aHidePrivateData &&
+ classList &&
+ classList.contains(CLASS_DATA_PRIVATE)
+ ) {
+ // Replace private data with a blank string.
+ node.textContent = "";
+ } else if (
+ !aHidePrivateData &&
+ classList &&
+ classList.contains(CLASS_DATA_PUBLIC)
+ ) {
+ // Replace public data with a blank string.
+ node.textContent = "";
+ } else if (copyData != null) {
+ // Replace localized text with non-localized text.
+ node.textContent = copyData;
+ copyData = null;
+ }
+
+ if (node.nodeType == Node.ELEMENT_NODE) {
+ cleanUpText(node, aHidePrivateData);
+ }
+
+ // Advance!
+ node = node.nextSibling;
+ }
+}
+
+// Return the plain text representation of an element. Do a little bit
+// of pretty-printing to make it human-readable.
+function createTextForElement(elem, aHidePrivateData) {
+ // Generate the initial text.
+ let textFragmentAccumulator = [];
+ generateTextForElement(elem, aHidePrivateData, "", textFragmentAccumulator);
+ let text = textFragmentAccumulator.join("");
+
+ // Trim extraneous whitespace before newlines, then squash extraneous
+ // blank lines.
+ text = text.replace(/[ \t]+\n/g, "\n");
+ text = text.replace(/\n{3,}/g, "\n\n");
+
+ // Actual CR/LF pairs are needed for some Windows text editors.
+ if ("@mozilla.org/windows-registry-key;1" in Cc) {
+ text = text.replace(/\n/g, "\r\n");
+ }
+
+ return text;
+}
+
+/**
+ * Elements to replace entirely with custom text. Keys are element ids, values
+ * are functions that return the text. The functions themselves are defined in
+ * the files for their respective sections.
+ */
+var gElementsToReplace = {
+ "accounts-table": getAccountsText,
+};
+
+function generateTextForElement(
+ elem,
+ aHidePrivateData,
+ indent,
+ textFragmentAccumulator
+) {
+ // Add a little extra spacing around most elements.
+ if (!["td", "th", "span", "a"].includes(elem.tagName)) {
+ textFragmentAccumulator.push("\n");
+ }
+
+ // If this element is one of our elements to replace with text, do it.
+ if (elem.id in gElementsToReplace) {
+ let replaceFn = gElementsToReplace[elem.id];
+ textFragmentAccumulator.push(replaceFn(aHidePrivateData, indent + " "));
+ return;
+ }
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ if (elem.id == "crashes-table") {
+ textFragmentAccumulator.push(getCrashesText(indent));
+ return;
+ }
+ }
+
+ let childCount = elem.childElementCount;
+
+ // We're not going to spread a two-column <tr> across multiple lines, so
+ // handle that separately.
+ if (elem.tagName == "tr" && childCount == 2) {
+ textFragmentAccumulator.push(indent);
+ textFragmentAccumulator.push(
+ elem.children[0].textContent.trim() +
+ ": " +
+ elem.children[1].textContent.trim()
+ );
+ return;
+ }
+
+ // Generate the text representation for each child node.
+ let node = elem.firstChild;
+ while (node) {
+ if (node.nodeType == Node.TEXT_NODE) {
+ // Text belonging to this element uses its indentation level.
+ generateTextForTextNode(node, indent, textFragmentAccumulator);
+ } else if (node.nodeType == Node.ELEMENT_NODE) {
+ // Recurse on the child element with an extra level of indentation (but
+ // only if there's more than one child).
+ generateTextForElement(
+ node,
+ aHidePrivateData,
+ indent + (childCount > 1 ? " " : ""),
+ textFragmentAccumulator
+ );
+ }
+ // Advance!
+ node = node.nextSibling;
+ }
+}
+
+function generateTextForTextNode(node, indent, textFragmentAccumulator) {
+ // If the text node is the first of a run of text nodes, then start
+ // a new line and add the initial indentation.
+ let prevNode = node.previousSibling;
+ if (!prevNode || prevNode.nodeType == Node.TEXT_NODE) {
+ textFragmentAccumulator.push("\n" + indent);
+ }
+
+ // Trim the text node's text content and add proper indentation after
+ // any internal line breaks.
+ let text = node.textContent.trim().replace(/\n/g, "\n" + indent);
+ textFragmentAccumulator.push(text);
+}
+
+/**
+ * Returns a plaintext representation of crashes data.
+ */
+
+function getCrashesText(aIndent) {
+ let crashesData = "";
+ let recentCrashesSubmitted = document.querySelectorAll("#crashes-tbody > tr");
+ for (let i = 0; i < recentCrashesSubmitted.length; i++) {
+ let tds = recentCrashesSubmitted.item(i).querySelectorAll("td");
+ crashesData +=
+ aIndent.repeat(2) +
+ tds.item(0).firstElementChild.href +
+ " (" +
+ tds.item(1).textContent +
+ ")\n";
+ }
+ return crashesData;
+}
diff --git a/comm/mail/components/about-support/content/libs.js b/comm/mail/components/about-support/content/libs.js
new file mode 100644
index 0000000000..1c431596ae
--- /dev/null
+++ b/comm/mail/components/about-support/content/libs.js
@@ -0,0 +1,24 @@
+/* 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, {
+ BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm",
+});
+
+/**
+ * Populates the "Mail Libraries" section of the troubleshooting information page.
+ */
+function populateLibrarySection() {
+ let { min_version, loaded_version, status, path } =
+ BondOpenPGP.getRNPLibStatus();
+
+ document.getElementById("rnp-expected-version").textContent = min_version;
+ document.getElementById("rnp-loaded-version").textContent = loaded_version;
+ document.getElementById("rnp-path").textContent = path;
+ document.l10n.setAttributes(document.getElementById("rnp-status"), status);
+}
diff --git a/comm/mail/components/about-support/jar.mn b/comm/mail/components/about-support/jar.mn
new file mode 100644
index 0000000000..4dd0375f7f
--- /dev/null
+++ b/comm/mail/components/about-support/jar.mn
@@ -0,0 +1,13 @@
+# 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/about-support/accounts.js (content/accounts.js)
+ content/messenger/about-support/export.js (content/export.js)
+* content/messenger/about-support/aboutSupport.xhtml (content/aboutSupport.xhtml)
+% override chrome://global/content/aboutSupport.xhtml chrome://messenger/content/about-support/aboutSupport.xhtml
+ content/messenger/about-support/aboutSupport.js (content/aboutSupport.js)
+ content/messenger/about-support/calendars.js (content/calendars.js)
+ content/messenger/about-support/chat.js (content/chat.js)
+ content/messenger/about-support/libs.js (content/libs.js)
diff --git a/comm/mail/components/about-support/moz.build b/comm/mail/components/about-support/moz.build
new file mode 100644
index 0000000000..2c16234de4
--- /dev/null
+++ b/comm/mail/components/about-support/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# 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/.
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ EXTRA_JS_MODULES += ["AboutSupportWin32.jsm"]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ EXTRA_JS_MODULES += ["AboutSupportMac.jsm"]
+else:
+ EXTRA_JS_MODULES += ["AboutSupportUnix.jsm"]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/accountcreation/AccountConfig.jsm b/comm/mail/components/accountcreation/AccountConfig.jsm
new file mode 100644
index 0000000000..59b9604725
--- /dev/null
+++ b/comm/mail/components/accountcreation/AccountConfig.jsm
@@ -0,0 +1,463 @@
+/* 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 creates the class AccountConfig, which is a JS object that holds
+ * a configuration for a certain account. It is *not* created in the backend
+ * yet (use aw-createAccount.js for that), and it may be incomplete.
+ *
+ * Several AccountConfig objects may co-exist, e.g. for autoconfig.
+ * One AccountConfig object is used to prefill and read the widgets
+ * in the Wizard UI.
+ * When we autoconfigure, we autoconfig writes the values into a
+ * new object and returns that, and the caller can copy these
+ * values into the object used by the UI.
+ *
+ * See also
+ * <https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat>
+ * for values stored.
+ */
+
+const EXPORTED_SYMBOLS = ["AccountConfig"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountCreationUtils",
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+function AccountConfig() {
+ this.incoming = this.createNewIncoming();
+ this.incomingAlternatives = [];
+ this.outgoing = this.createNewOutgoing();
+ this.outgoingAlternatives = [];
+ this.identity = {
+ // displayed real name of user
+ realname: "%REALNAME%",
+ // email address of user, as shown in From of outgoing mails
+ emailAddress: "%EMAILADDRESS%",
+ };
+ this.inputFields = [];
+ this.domains = [];
+}
+AccountConfig.prototype = {
+ // @see createNewIncoming()
+ incoming: null,
+ // @see createNewOutgoing()
+ outgoing: null,
+ /**
+ * Other servers which can be used instead of |incoming|,
+ * in order of decreasing preference.
+ * (|incoming| itself should not be included here.)
+ * { Array of incoming/createNewIncoming() }
+ */
+ incomingAlternatives: null,
+ outgoingAlternatives: null,
+ // just an internal string to refer to this. Do not show to user.
+ id: null,
+ // who created the config.
+ // { one of kSource* }
+ source: null,
+ /**
+ * Used for telemetry purposes.
+ * - for kSourceXML, subSource is one of xml-from-{disk, db, isp-https, isp-http}.
+ * - for kSourceExchange, subSource is one of exchange-from-urlN[-guess].
+ */
+ subSource: null,
+ displayName: null,
+ // { Array of { varname (value without %), displayName, exampleValue } }
+ inputFields: null,
+ // email address domains for which this config is applicable
+ // { Array of Strings }
+ domains: null,
+
+ /**
+ * Factory function for incoming and incomingAlternatives
+ */
+ createNewIncoming() {
+ return {
+ // { String-enum: "pop3", "imap", "nntp", "exchange" }
+ type: null,
+ hostname: null,
+ // { Integer }
+ port: null,
+ // May be a placeholder (starts and ends with %). { String }
+ username: null,
+ password: null,
+ // {nsMsgSocketType} @see MailNewsTypes2.idl. -1 means not inited
+ socketType: -1,
+ /**
+ * true when the cert is invalid (and thus SSL useless), because it's
+ * 1) not from an accepted CA (including self-signed certs)
+ * 2) for a different hostname or
+ * 3) expired.
+ * May go back to false when user explicitly accepted the cert.
+ */
+ badCert: false,
+ /**
+ * How to log in to the server: plaintext or encrypted pw, GSSAPI etc.
+ * Defined by Ci.nsMsgAuthMethod
+ * Same as server pref "authMethod".
+ */
+ auth: 0,
+ /**
+ * Other auth methods that we think the server supports.
+ * They are ordered by descreasing preference.
+ * (|auth| itself is not included in |authAlternatives|)
+ * {Array of Ci.nsMsgAuthMethod} (same as .auth)
+ */
+ authAlternatives: null,
+ // in minutes { Integer }
+ checkInterval: 10,
+ loginAtStartup: true,
+ // POP3 only:
+ // Not yet implemented. { Boolean }
+ useGlobalInbox: false,
+ leaveMessagesOnServer: true,
+ daysToLeaveMessagesOnServer: 14,
+ deleteByAgeFromServer: true,
+ // When user hits delete, delete from local store and from server
+ deleteOnServerWhenLocalDelete: true,
+ downloadOnBiff: true,
+ // Override `addThisServer` for a specific incoming server
+ useGlobalPreferredServer: false,
+
+ // OAuth2 configuration, if needed.
+ oauthSettings: null,
+
+ // for Microsoft Exchange servers. Optional.
+ owaURL: null,
+ ewsURL: null,
+ easURL: null,
+ // for when an addon overrides the account type. Optional.
+ addonAccountType: null,
+ };
+ },
+ /**
+ * Factory function for outgoing and outgoingAlternatives
+ */
+ createNewOutgoing() {
+ return {
+ type: "smtp",
+ hostname: null,
+ port: null, // see incoming
+ username: null, // see incoming. may be null, if auth is 0.
+ password: null, // see incoming. may be null, if auth is 0.
+ socketType: -1, // see incoming
+ badCert: false, // see incoming
+ auth: 0, // see incoming
+ authAlternatives: null, // see incoming
+ addThisServer: true, // if we already have an SMTP server, add this
+ // if we already have an SMTP server, use it.
+ useGlobalPreferredServer: false,
+ // we should reuse an already configured SMTP server.
+ // nsISmtpServer.key
+ existingServerKey: null,
+ // user display value for existingServerKey
+ existingServerLabel: null,
+
+ // OAuth2 configuration, if needed.
+ oauthSettings: null,
+ };
+ },
+
+ /**
+ * The configuration needs an addon to handle the account type.
+ * The addon needs to be installed before the account can be created
+ * in the backend.
+ * You can choose one, if there are several addons in the list.
+ * (Optional)
+ *
+ * Array of:
+ * {
+ * id: "owl@example.com" {string},
+ *
+ * // already localized string
+ * name: "Owl" {string},
+ *
+ * // already localized string
+ * description: "A third party addon that allows you to connect to Exchange servers" {string}
+ *
+ * // Minimal version of the addon. Needed in case the addon is already installed,
+ * // to verify that the installed version is sufficient.
+ * // The XPI URL below must satisfy this.
+ * // Must satisfy <https://developer.mozilla.org/en-US/docs/Mozilla/Toolkit_version_format>
+ * minVersion: "0.2" {string}
+ *
+ * xpiURL: "https://live.thunderbird.net/autoconfig/owl.xpi" {URL},
+ * websiteURL: "https://www.beonex.com/owl/" {URL},
+ * icon32: "https://www.beonex.com/owl/owl-32x32.png" {URL},
+ *
+ * useType : {
+ * // Type shown as radio button to user in the config result.
+ * // Users won't understand OWA vs. EWS vs. EAS etc., so this is an abstraction
+ * // from the end user perspective.
+ * generalType: "exchange" {string},
+ *
+ * // Protocol
+ * // Independent of the addon
+ * protocolType: "owa" {string},
+ *
+ * // Account type in the Thunderbird backend.
+ * // What nsIMsgAccount.type will be set to when creating the account.
+ * // This is specific to the addon.
+ * addonAccountType: "owl-owa" {string},
+ * }
+ * }
+ */
+ addons: null,
+
+ /**
+ * Returns a deep copy of this object,
+ * i.e. modifying the copy will not affect the original object.
+ */
+ copy() {
+ // Workaround: deepCopy() fails to preserve base obj (instanceof)
+ let result = new AccountConfig();
+ for (let prop in this) {
+ result[prop] = lazy.AccountCreationUtils.deepCopy(this[prop]);
+ }
+
+ return result;
+ },
+
+ isComplete() {
+ return (
+ !!this.incoming.hostname &&
+ !!this.incoming.port &&
+ this.incoming.socketType != -1 &&
+ !!this.incoming.auth &&
+ !!this.incoming.username &&
+ (!!this.outgoing.existingServerKey ||
+ this.outgoing.useGlobalPreferredServer ||
+ (!!this.outgoing.hostname &&
+ !!this.outgoing.port &&
+ this.outgoing.socketType != -1 &&
+ !!this.outgoing.auth &&
+ !!this.outgoing.username))
+ );
+ },
+
+ toString() {
+ function sslToString(socketType) {
+ switch (socketType) {
+ case 0:
+ return "plain";
+ case 2:
+ return "STARTTLS";
+ case 3:
+ return "SSL";
+ default:
+ return "invalid";
+ }
+ }
+
+ function authToString(authMethod) {
+ switch (authMethod) {
+ case 0:
+ return "undefined";
+ case 1:
+ return "none";
+ case 2:
+ return "old plain";
+ case 3:
+ return "plain";
+ case 4:
+ return "encrypted";
+ case 5:
+ return "Kerberos";
+ case 6:
+ return "NTLM";
+ case 7:
+ return "external/SSL";
+ case 8:
+ return "any secure";
+ case 10:
+ return "OAuth2";
+ default:
+ return "invalid";
+ }
+ }
+
+ function passwordToString(password) {
+ return password ? "set" : "not set";
+ }
+
+ function configToString(config) {
+ return (
+ config.type +
+ ", " +
+ config.hostname +
+ ":" +
+ config.port +
+ ", " +
+ sslToString(config.socketType) +
+ ", auth: " +
+ authToString(config.auth) +
+ ", username: " +
+ (config.username || "(undefined)") +
+ ", password: " +
+ passwordToString(config.password)
+ );
+ }
+
+ let result = "Incoming: " + configToString(this.incoming) + "\nOutgoing: ";
+ if (
+ this.outgoing.useGlobalPreferredServer ||
+ this.incoming.useGlobalPreferredServer
+ ) {
+ result += "Use global server";
+ } else if (this.outgoing.existingServerKey) {
+ result += "Use existing server " + this.outgoing.existingServerKey;
+ } else {
+ result += configToString(this.outgoing);
+ }
+ for (let config of this.incomingAlternatives) {
+ result += "\nIncoming alt: " + configToString(config);
+ }
+ for (let config of this.outgoingAlternatives) {
+ result += "\nOutgoing alt: " + configToString(config);
+ }
+ return result;
+ },
+
+ /**
+ * Sort the config alternatives such that exchange is the last of the
+ * alternatives.
+ */
+ preferStandardProtocols() {
+ let alternatives = this.incomingAlternatives;
+ // Add default incoming as one alternative.
+ alternatives.unshift(this.incoming);
+ alternatives.sort((a, b) => {
+ if (a.type == "exchange") {
+ return 1;
+ }
+ if (b.type == "exchange") {
+ return -1;
+ }
+ return 0;
+ });
+ this.incomingAlternatives = alternatives;
+ this.incoming = alternatives.shift();
+ },
+};
+
+// enum consts
+
+// .source
+AccountConfig.kSourceUser = "user"; // user manually entered the config
+AccountConfig.kSourceXML = "xml"; // config from XML from ISP or Mozilla DB
+AccountConfig.kSourceGuess = "guess"; // guessConfig()
+AccountConfig.kSourceExchange = "exchange"; // from Microsoft Exchange AutoDiscover
+
+/**
+ * Some fields on the account config accept placeholders (when coming from XML).
+ *
+ * These are the predefined ones
+ * %EMAILADDRESS% (full email address of the user, usually entered by user)
+ * %EMAILLOCALPART% (email address, part before @)
+ * %EMAILDOMAIN% (email address, part after @)
+ * %REALNAME%
+ * as well as those defined in account.inputFields.*.varname, with % added
+ * before and after.
+ *
+ * These must replaced with real values, supplied by the user or app,
+ * before the account is created. This is done here. You call this function once
+ * you have all the data - gathered the standard vars mentioned above as well as
+ * all listed in account.inputFields, and pass them in here. This function will
+ * insert them in the fields, returning a fully filled-out account ready to be
+ * created.
+ *
+ * @param account {AccountConfig}
+ * The account data to be modified. It may or may not contain placeholders.
+ * After this function, it should not contain placeholders anymore.
+ * This object will be modified in-place.
+ *
+ * @param emailfull {String}
+ * Full email address of this account, e.g. "joe@example.com".
+ * Empty of incomplete email addresses will/may be rejected.
+ *
+ * @param realname {String}
+ * Real name of user, as will appear in From of outgoing messages
+ *
+ * @param password {String}
+ * The password for the incoming server and (if necessary) the outgoing server
+ */
+AccountConfig.replaceVariables = function (
+ account,
+ realname,
+ emailfull,
+ password
+) {
+ lazy.Sanitizer.nonemptystring(emailfull);
+ let emailsplit = emailfull.split("@");
+ lazy.AccountCreationUtils.assert(
+ emailsplit.length == 2,
+ "email address not in expected format: must contain exactly one @"
+ );
+ let emaillocal = lazy.Sanitizer.nonemptystring(emailsplit[0]);
+ let emaildomain = lazy.Sanitizer.hostname(emailsplit[1]);
+ lazy.Sanitizer.label(realname);
+ lazy.Sanitizer.nonemptystring(realname);
+
+ let otherVariables = {};
+ otherVariables.EMAILADDRESS = emailfull;
+ otherVariables.EMAILLOCALPART = emaillocal;
+ otherVariables.EMAILDOMAIN = emaildomain;
+ otherVariables.REALNAME = realname;
+
+ if (password) {
+ account.incoming.password = password;
+ account.outgoing.password = password; // set member only if auth required?
+ }
+ account.incoming.username = _replaceVariable(
+ account.incoming.username,
+ otherVariables
+ );
+ account.outgoing.username = _replaceVariable(
+ account.outgoing.username,
+ otherVariables
+ );
+ account.incoming.hostname = _replaceVariable(
+ account.incoming.hostname,
+ otherVariables
+ );
+ if (account.outgoing.hostname) {
+ // will be null if user picked existing server.
+ account.outgoing.hostname = _replaceVariable(
+ account.outgoing.hostname,
+ otherVariables
+ );
+ }
+ account.identity.realname = _replaceVariable(
+ account.identity.realname,
+ otherVariables
+ );
+ account.identity.emailAddress = _replaceVariable(
+ account.identity.emailAddress,
+ otherVariables
+ );
+ account.displayName = _replaceVariable(account.displayName, otherVariables);
+};
+
+function _replaceVariable(variable, values) {
+ let str = variable;
+ if (typeof str != "string") {
+ return str;
+ }
+
+ for (let varname in values) {
+ str = str.replace("%" + varname + "%", values[varname]);
+ }
+
+ return str;
+}
diff --git a/comm/mail/components/accountcreation/AccountCreationUtils.jsm b/comm/mail/components/accountcreation/AccountCreationUtils.jsm
new file mode 100644
index 0000000000..f4efb96b2d
--- /dev/null
+++ b/comm/mail/components/accountcreation/AccountCreationUtils.jsm
@@ -0,0 +1,717 @@
+/* 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/. */
+/**
+ * Some common, generic functions
+ */
+
+const EXPORTED_SYMBOLS = ["AccountCreationUtils"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+);
+const { clearInterval, clearTimeout, setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+// --------------------------
+// Low level, basic functions
+
+function assert(test, errorMsg) {
+ if (!test) {
+ throw new NotReached(
+ errorMsg ? errorMsg : "Programming bug. Assertion failed, see log."
+ );
+ }
+}
+
+function makeCallback(obj, func) {
+ return func.bind(obj);
+}
+
+/**
+ * Runs the given function sometime later
+ *
+ * Currently implemented using setTimeout(), but
+ * can later be replaced with an nsITimer impl,
+ * when code wants to use it in a module.
+ *
+ * @see |TimeoutAbortable|
+ */
+function runAsync(func) {
+ return setTimeout(func, 0);
+}
+
+/**
+ * Reads UTF8 data from a URL.
+ *
+ * @param uri {nsIURI} - what you want to read
+ * @returns {Array of String} the contents of the file, one string per line
+ */
+function readURLasUTF8(uri) {
+ assert(uri instanceof Ci.nsIURI, "uri must be an nsIURI");
+ let chan = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let is = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(
+ Ci.nsIConverterInputStream
+ );
+ is.init(
+ chan.open(),
+ "UTF-8",
+ 1024,
+ Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER
+ );
+
+ let content = "";
+ let strOut = {};
+ try {
+ while (is.readString(1024, strOut) != 0) {
+ content += strOut.value;
+ }
+ } finally {
+ is.close();
+ }
+
+ return content;
+ // TODO this has a numeric error message. We need to ship translations
+ // into human language.
+}
+
+/**
+ * @param bundleURI {String} - chrome URL to properties file
+ * @returns nsIStringBundle
+ */
+function getStringBundle(bundleURI) {
+ try {
+ return Services.strings.createBundle(bundleURI);
+ } catch (e) {
+ throw new Exception(
+ "Failed to get stringbundle URI <" + bundleURI + ">. Error: " + e
+ );
+ }
+}
+
+// ---------
+// Exception
+
+function Exception(msg) {
+ this._message = msg;
+ this.stack = Components.stack.formattedStack;
+}
+Exception.prototype = {
+ get message() {
+ return this._message;
+ },
+ toString() {
+ return this._message;
+ },
+};
+
+function NotReached(msg) {
+ Exception.call(this, msg); // call super constructor
+ console.error(this);
+}
+// Make NotReached extend Exception.
+NotReached.prototype = Object.create(Exception.prototype);
+NotReached.prototype.constructor = NotReached;
+
+// ---------
+// Abortable
+
+/**
+ * A handle for an async function which you can cancel.
+ * The async function will return an object of this type (a subtype)
+ * and you can call cancel() when you feel like killing the function.
+ */
+function Abortable() {}
+Abortable.prototype = {
+ cancel(e) {},
+};
+
+function CancelledException(msg) {
+ Exception.call(this, msg);
+}
+CancelledException.prototype = Object.create(Exception.prototype);
+CancelledException.prototype.constructor = CancelledException;
+
+function UserCancelledException(msg) {
+ // The user knows they cancelled so I don't see a need
+ // for a message to that effect.
+ if (!msg) {
+ msg = "User cancelled";
+ }
+ CancelledException.call(this, msg);
+}
+UserCancelledException.prototype = Object.create(CancelledException.prototype);
+UserCancelledException.prototype.constructor = UserCancelledException;
+
+/**
+ * Utility implementation, for waiting for a promise to resolve,
+ * but allowing its result to be cancelled.
+ */
+function PromiseAbortable(promise, successCallback, errorCallback) {
+ Abortable.call(this); // call super constructor
+ let complete = false;
+ this.cancel = function (e) {
+ if (!complete) {
+ complete = true;
+ errorCallback(e || new CancelledException());
+ }
+ };
+ promise
+ .then(function (result) {
+ if (!complete) {
+ successCallback(result);
+ complete = true;
+ }
+ })
+ .catch(function (e) {
+ if (!complete) {
+ complete = true;
+ errorCallback(e);
+ }
+ });
+}
+PromiseAbortable.prototype = Object.create(Abortable.prototype);
+PromiseAbortable.prototype.constructor = PromiseAbortable;
+
+/**
+ * Utility implementation, for allowing to abort a setTimeout.
+ * Use like: return new TimeoutAbortable(setTimeout(function(){ ... }, 0));
+ *
+ * @param setTimeoutID {Integer} - Return value of setTimeout()
+ */
+function TimeoutAbortable(setTimeoutID) {
+ Abortable.call(this); // call super constructor
+ this._id = setTimeoutID;
+}
+TimeoutAbortable.prototype = Object.create(Abortable.prototype);
+TimeoutAbortable.prototype.constructor = TimeoutAbortable;
+TimeoutAbortable.prototype.cancel = function () {
+ clearTimeout(this._id);
+};
+
+/**
+ * Utility implementation, for allowing to abort a setTimeout.
+ * Use like: return new TimeoutAbortable(setTimeout(function(){ ... }, 0));
+ *
+ * @param setIntervalID {Integer} - Return value of setInterval()
+ */
+function IntervalAbortable(setIntervalID) {
+ Abortable.call(this); // call super constructor
+ this._id = setIntervalID;
+}
+IntervalAbortable.prototype = Object.create(Abortable.prototype);
+IntervalAbortable.prototype.constructor = IntervalAbortable;
+IntervalAbortable.prototype.cancel = function () {
+ clearInterval(this._id);
+};
+
+/**
+ * Allows you to make several network calls,
+ * but return only one |Abortable| object.
+ */
+function SuccessiveAbortable() {
+ Abortable.call(this); // call super constructor
+ this._current = null;
+}
+SuccessiveAbortable.prototype = {
+ __proto__: Abortable.prototype,
+ get current() {
+ return this._current;
+ },
+ set current(abortable) {
+ assert(
+ abortable instanceof Abortable || abortable == null,
+ "need an Abortable object (or null)"
+ );
+ this._current = abortable;
+ },
+ cancel(e) {
+ if (this._current) {
+ this._current.cancel(e);
+ }
+ },
+};
+
+/**
+ * Allows you to make several network calls in parallel.
+ */
+function ParallelAbortable() {
+ Abortable.call(this); // call super constructor
+ // { Array of ParallelCall }
+ this._calls = [];
+ // { Array of Function }
+ this._finishedObservers = [];
+}
+ParallelAbortable.prototype = {
+ __proto__: Abortable.prototype,
+ /**
+ * @returns {Array of ParallelCall}
+ */
+ get results() {
+ return this._calls;
+ },
+ /**
+ * @returns {ParallelCall}
+ */
+ addCall() {
+ let call = new ParallelCall(this);
+ call.position = this._calls.length;
+ this._calls.push(call);
+ return call;
+ },
+ /**
+ * Observers will be called once one of the functions
+ * finishes, i.e. returns successfully or fails.
+ *
+ * @param {Function({ParallelCall} call)} func
+ */
+ addOneFinishedObserver(func) {
+ assert(typeof func == "function");
+ this._finishedObservers.push(func);
+ },
+ /**
+ * Will be called once *all* of the functions finished,
+ * It gives you a list of all functions that succeeded or failed,
+ * respectively.
+ *
+ * @param {Function(
+ * {Array of ParallelCall} succeeded,
+ * {Array of ParallelCall} failed
+ * )} func
+ */
+ addAllFinishedObserver(func) {
+ assert(typeof func == "function");
+ this.addOneFinishedObserver(() => {
+ if (this._calls.some(call => !call.finished)) {
+ return;
+ }
+ let succeeded = this._calls.filter(call => call.succeeded);
+ let failed = this._calls.filter(call => !call.succeeded);
+ func(succeeded, failed);
+ });
+ },
+ _notifyFinished(call) {
+ for (let observer of this._finishedObservers) {
+ try {
+ observer(call);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ },
+ cancel(e) {
+ for (let call of this._calls) {
+ if (!call.finished && call.callerAbortable) {
+ call.callerAbortable.cancel(e);
+ }
+ }
+ },
+};
+
+/**
+ * Returned by ParallelAbortable.addCall().
+ * Do not create this object directly
+ *
+ * @param {ParallelAbortable} parallelAbortable - The controlling ParallelAbortable
+ */
+function ParallelCall(parallelAbortable) {
+ assert(parallelAbortable instanceof ParallelAbortable);
+ // {ParallelAbortable} the parent
+ this._parallelAbortable = parallelAbortable;
+ // {Abortable} Abortable of the caller function that should run in parallel
+ this.callerAbortable = null;
+ // {Integer} the order in which the function was added, and its priority
+ this.position = null;
+ // {boolean} false = running, pending, false = success or failure
+ this.finished = false;
+ // {boolean} if finished: true = returned with success, false = returned with error
+ this.succeeded = false;
+ // {Exception} if failed: the error or exception that the caller function returned
+ this.e = null;
+ // {Object} if succeeded: the result of the caller function
+ this.result = null;
+
+ this._time = Date.now();
+}
+ParallelCall.prototype = {
+ /**
+ * Returns a successCallback(result) function that you pass
+ * to your function that runs in parallel.
+ *
+ * @returns {Function(result)} successCallback
+ */
+ successCallback() {
+ return result => {
+ ddump(
+ "call " +
+ this.position +
+ " took " +
+ (Date.now() - this._time) +
+ "ms and succeeded" +
+ (this.callerAbortable && this.callerAbortable._url
+ ? " at <" + this.callerAbortable._url + ">"
+ : "")
+ );
+ this.result = result;
+ this.finished = true;
+ this.succeeded = true;
+ this._parallelAbortable._notifyFinished(this);
+ };
+ },
+ /**
+ * Returns an errorCallback(e) function that you pass
+ * to your function that runs in parallel.
+ *
+ * @returns {Function(e)} errorCallback
+ */
+ errorCallback() {
+ return e => {
+ ddump(
+ "call " +
+ this.position +
+ " took " +
+ (Date.now() - this._time) +
+ "ms and failed with " +
+ (typeof e.code == "number" ? e.code + " " : "") +
+ (e.toString()
+ ? e.toString()
+ : "unknown error, probably no host connection") +
+ (this.callerAbortable && this.callerAbortable._url
+ ? " at <" + this.callerAbortable._url + ">"
+ : "")
+ );
+ this.e = e;
+ this.finished = true;
+ this.succeeded = false;
+ this._parallelAbortable._notifyFinished(this);
+ };
+ },
+ /**
+ * Call your function that needs to run in parallel
+ * and pass the resulting |Abortable| of your function here.
+ *
+ * @param {Abortable} abortable
+ */
+ setAbortable(abortable) {
+ assert(abortable instanceof Abortable);
+ this.callerAbortable = abortable;
+ },
+};
+
+/**
+ * Runs several calls in parallel.
+ * Returns the result of the "highest" priority call that succeeds.
+ * Unlike Promise.race(), does not return the fastest,
+ * but the first in the order they were added.
+ * So, the order in which the calls were added determines their priority,
+ * with the first to be added being the most desirable.
+ *
+ * E.g. the first failed, the second is pending, the third succeeded, and the forth is pending.
+ * It aborts the forth (because the third succeeded), and it waits for the second to return.
+ * If the second succeeds, it is the result, otherwise the third is the result.
+ *
+ * @param {Function(
+ * {Object} result - Result of winner call
+ * {ParallelCall} call - Winner call info
+ * )} successCallback - A call returned successfully
+ * @param {Function(e, allErrors)} errorCallback - All calls failed.
+ * {Exception} e - The first CancelledException, and otherwise
+ * the exception returned by the first call.
+ * This is just to adhere to the standard API of errorCallback(e).
+ * {Array of Exception} allErrors - The exceptions from all calls.
+ */
+function PriorityOrderAbortable(successCallback, errorCallback) {
+ assert(typeof successCallback == "function");
+ assert(typeof errorCallback == "function");
+ ParallelAbortable.call(this); // call super constructor
+ this._successfulCall = null;
+
+ this.addOneFinishedObserver(finishedCall => {
+ for (let call of this._calls) {
+ if (!call.finished) {
+ if (this._successfulCall) {
+ // abort
+ if (call.callerAbortable) {
+ call.callerAbortable.cancel(
+ new NoLongerNeededException("Another higher call succeeded")
+ );
+ }
+ continue;
+ }
+ // It's pending. do nothing and wait for it.
+ return;
+ }
+ if (!call.succeeded) {
+ // it failed. ignore it.
+ continue;
+ }
+ if (this._successfulCall) {
+ // we already have a winner. ignore it.
+ continue;
+ }
+ try {
+ successCallback(call.result, call);
+ // This is the winner.
+ this._successfulCall = call;
+ } catch (e) {
+ console.error(e);
+ // If the handler failed with this data, treat this call as failed.
+ call.e = e;
+ call.succeeded = false;
+ }
+ }
+ if (!this._successfulCall) {
+ // all failed
+ let allErrors = this._calls.map(call => call.e);
+ let e =
+ allErrors.find(e => e instanceof CancelledException) || allErrors[0];
+ errorCallback(e, allErrors); // see docs above
+ }
+ });
+}
+PriorityOrderAbortable.prototype = Object.create(ParallelAbortable.prototype);
+PriorityOrderAbortable.prototype.constructor = PriorityOrderAbortable;
+
+function NoLongerNeededException(msg) {
+ CancelledException.call(this, msg);
+}
+NoLongerNeededException.prototype = Object.create(CancelledException.prototype);
+NoLongerNeededException.prototype.constructor = NoLongerNeededException;
+
+// -------------------
+// High level features
+
+/**
+ * Allows you to install an addon.
+ *
+ * Example:
+ * var installer = new AddonInstaller({ xpiURL : "https://...xpi", id: "...", ...});
+ * installer.install();
+ *
+ * @param {object} args - Contains parameters:
+ * @param {string} name (Optional) - Name of the addon (not important)
+ * @param {string} id (Optional) - Addon ID
+ * If you pass an ID, and the addon is already installed (and the version matches),
+ * then install() will do nothing.
+ * After the XPI is downloaded, the ID will be verified. If it doesn't match, the
+ * install will fail.
+ * If you don't pass an ID, these checks will be skipped and the addon be installed
+ * unconditionally.
+ * It is recommended to pass at least an ID, because it can confuse some addons
+ * to be reloaded at runtime.
+ * @param {string} minVersion (Optional) - Minimum version of the addon
+ * If you pass a minVersion (in addition to ID), and the installed addon is older than this,
+ * the install will be done anyway. If the downloaded addon has a lower version,
+ * the install will fail.
+ * If you do not pass a minVersion, there will be no version check.
+ * @param {URL} xpiURL - Where to download the XPI from
+ */
+function AddonInstaller(args) {
+ Abortable.call(this);
+ this._name = lazy.Sanitizer.label(args.name);
+ this._id = lazy.Sanitizer.string(args.id);
+ this._minVersion = lazy.Sanitizer.string(args.minVersion);
+ this._url = lazy.Sanitizer.url(args.xpiURL);
+}
+AddonInstaller.prototype = Object.create(Abortable.prototype);
+AddonInstaller.prototype.constructor = AddonInstaller;
+
+/**
+ * Checks whether the passed-in addon matches the
+ * id and minVersion requested by the caller.
+ *
+ * @param {nsIAddon} addon
+ * @returns {boolean} is OK
+ */
+AddonInstaller.prototype.matches = function (addon) {
+ return (
+ !this._id ||
+ (this._id == addon.id &&
+ (!this._minVersion ||
+ Services.vc.compare(addon.version, this._minVersion) >= 0))
+ );
+};
+
+/**
+ * Start the installation
+ *
+ * @throws Exception in case of failure
+ */
+AddonInstaller.prototype.install = async function () {
+ if (await this.isInstalled()) {
+ return;
+ }
+ await this._installDirect();
+};
+
+/**
+ * Checks whether we already have an addon installed that matches the
+ * id and minVersion requested by the caller.
+ *
+ * @returns {boolean} is already installed and enabled
+ */
+AddonInstaller.prototype.isInstalled = async function () {
+ if (!this._id) {
+ return false;
+ }
+ var addon = await AddonManager.getAddonByID(this._id);
+ return addon && this.matches(addon) && addon.isActive;
+};
+
+/**
+ * Checks whether we already have an addon but it is disabled.
+ *
+ * @returns {boolean} is already installed but disabled
+ */
+AddonInstaller.prototype.isDisabled = async function () {
+ if (!this._id) {
+ return false;
+ }
+ let addon = await AddonManager.getAddonByID(this._id);
+ return addon && !addon.isActive;
+};
+
+/**
+ * Downloads and installs the addon.
+ * The downloaded XPI will be checked using prompt().
+ */
+AddonInstaller.prototype._installDirect = async function () {
+ var installer = (this._installer = await AddonManager.getInstallForURL(
+ this._url,
+ { name: this._name }
+ ));
+ installer.promptHandler = makeCallback(this, this.prompt);
+ await installer.install(); // throws, if failed
+
+ var addon = await AddonManager.getAddonByID(this._id);
+ await addon.enable();
+
+ // Wait for addon startup code to finish
+ // Fixes: verify password fails with NOT_AVAILABLE in createIncomingServer()
+ if ("startupPromise" in addon) {
+ await addon.startupPromise;
+ }
+ let wait = ms => new Promise(resolve => setTimeout(resolve, ms));
+ await wait(1000);
+};
+
+/**
+ * Install confirmation. You may override this, if needed.
+ *
+ * @throws Exception If you want to cancel install, then throw an exception.
+ */
+AddonInstaller.prototype.prompt = async function (info) {
+ if (!this.matches(info.addon)) {
+ // happens only when we got the wrong XPI
+ throw new Exception(
+ "The downloaded addon XPI does not match the minimum requirements"
+ );
+ }
+};
+
+AddonInstaller.prototype.cancel = function () {
+ if (this._installer) {
+ try {
+ this._installer.cancel();
+ } catch (e) {
+ // if install failed
+ ddump(e);
+ }
+ }
+};
+
+// ------------
+// Debug output
+
+function deepCopy(org) {
+ if (typeof org == "undefined") {
+ return undefined;
+ }
+ if (org == null) {
+ return null;
+ }
+ if (typeof org == "string") {
+ return org;
+ }
+ if (typeof org == "number") {
+ return org;
+ }
+ if (typeof org == "boolean") {
+ return org;
+ }
+ if (typeof org == "function") {
+ return org;
+ }
+ if (typeof org != "object") {
+ throw new Error("can't copy objects of type " + typeof org + " yet");
+ }
+
+ // TODO still instanceof org != instanceof copy
+ // var result = new org.constructor();
+ var result = {};
+ if (typeof org.length != "undefined") {
+ result = [];
+ }
+ for (var prop in org) {
+ result[prop] = deepCopy(org[prop]);
+ }
+ return result;
+}
+
+var gAccountSetupLogger = new ConsoleAPI({
+ prefix: "mail.setup",
+ maxLogLevel: "warn",
+ maxLogLevelPref: "mail.setup.loglevel",
+});
+
+function ddump(text) {
+ gAccountSetupLogger.info(text);
+}
+
+function alertPrompt(alertTitle, alertMsg) {
+ Services.prompt.alert(
+ Services.wm.getMostRecentWindow(""),
+ alertTitle,
+ alertMsg
+ );
+}
+
+var AccountCreationUtils = {
+ Abortable,
+ AddonInstaller,
+ alertPrompt,
+ assert,
+ CancelledException,
+ ddump,
+ deepCopy,
+ Exception,
+ gAccountSetupLogger,
+ getStringBundle,
+ NotReached,
+ PriorityOrderAbortable,
+ PromiseAbortable,
+ readURLasUTF8,
+ runAsync,
+ SuccessiveAbortable,
+ TimeoutAbortable,
+ UserCancelledException,
+};
diff --git a/comm/mail/components/accountcreation/ConfigVerifier.jsm b/comm/mail/components/accountcreation/ConfigVerifier.jsm
new file mode 100644
index 0000000000..cce934a159
--- /dev/null
+++ b/comm/mail/components/accountcreation/ConfigVerifier.jsm
@@ -0,0 +1,386 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["ConfigVerifier"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { OAuth2Providers } = ChromeUtils.import(
+ "resource:///modules/OAuth2Providers.jsm"
+);
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+
+/**
+ * @implements {nsIUrlListener}
+ * @implements {nsIInterfaceRequestor}
+ */
+class ConfigVerifier {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIInterfaceRequestor",
+ "nsIUrlListener",
+ ]);
+
+ // @see {nsIInterfaceRequestor}
+ getInterface(iid) {
+ return this.QueryInterface(iid);
+ }
+
+ constructor(msgWindow) {
+ this.msgWindow = msgWindow;
+ this._log = console.createInstance({
+ prefix: "mail.setup",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.setup.loglevel",
+ });
+ }
+
+ /**
+ * @param {nsIURI} url - The URL being processed.
+ * @see {nsIUrlListener}
+ */
+ OnStartRunningUrl(url) {
+ this._log.debug(`Starting to verify configuration;
+ email as username=${
+ this.config.incoming.username != this.config.identity.emailAddress
+ }
+ savedUsername=${this.config.usernameSaved ? "true" : "false"},
+ authMethod=${this.server.authMethod}`);
+ }
+
+ /**
+ * @param {nsIURI} url - The URL being processed.
+ * @param {nsresult} status - A result code of URL processing.
+ * @see {nsIUrlListener}
+ */
+ OnStopRunningUrl(url, status) {
+ if (Components.isSuccessCode(status)) {
+ this._log.debug(`Configuration verified successfully!`);
+ this.cleanup();
+ this.successCallback(this.config);
+ return;
+ }
+
+ this._log.debug(`Verifying configuration failed; status=${status}`);
+
+ let certError = false;
+ try {
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let errorClass = nssErrorsService.getErrorClass(status);
+ if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ certError = true;
+ }
+ } catch (e) {
+ // It's not an NSS error.
+ }
+
+ if (certError) {
+ let mailNewsUrl = url.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ let secInfo = mailNewsUrl.failedSecInfo;
+ this.informUserOfCertError(secInfo, url.asciiHostPort);
+ } else if (this.alter) {
+ // Try other variations.
+ this.server.closeCachedConnections();
+ this.tryNextLogon(url);
+ } else {
+ // Logon failed, and we aren't supposed to try other variations.
+ this._failed(url);
+ }
+ }
+
+ tryNextLogon(aPreviousUrl) {
+ this._log.debug("Trying next logon variation");
+ // check if we tried full email address as username
+ if (this.config.incoming.username != this.config.identity.emailAddress) {
+ this._log.debug("Changing username to email address.");
+ this.config.usernameSaved = this.config.incoming.username;
+ this.config.incoming.username = this.config.identity.emailAddress;
+ this.config.outgoing.username = this.config.identity.emailAddress;
+ this.server.username = this.config.incoming.username;
+ this.server.password = this.config.incoming.password;
+ this.verifyLogon();
+ return;
+ }
+
+ if (this.config.usernameSaved) {
+ this._log.debug("Re-setting username.");
+ // If we tried the full email address as the username, then let's go
+ // back to trying just the username before trying the other cases.
+ this.config.incoming.username = this.config.usernameSaved;
+ this.config.outgoing.username = this.config.usernameSaved;
+ this.config.usernameSaved = null;
+ this.server.username = this.config.incoming.username;
+ this.server.password = this.config.incoming.password;
+ }
+
+ // sec auth seems to have failed, and we've tried both
+ // varieties of user name, sadly.
+ // So fall back to non-secure auth, and
+ // again try the user name and email address as username
+ if (this.server.socketType == Ci.nsMsgSocketType.SSL) {
+ this._log.debug("Using SSL");
+ } else if (this.server.socketType == Ci.nsMsgSocketType.alwaysSTARTTLS) {
+ this._log.debug("Using STARTTLS");
+ }
+ if (
+ this.config.incoming.authAlternatives &&
+ this.config.incoming.authAlternatives.length
+ ) {
+ // We may be dropping back to insecure auth methods here,
+ // which is not good. But then again, we already warned the user,
+ // if it is a config without SSL.
+
+ let brokenAuth = this.config.incoming.auth;
+ // take the next best method (compare chooseBestAuthMethod() in guess)
+ this.config.incoming.auth = this.config.incoming.authAlternatives.shift();
+ this.server.authMethod = this.config.incoming.auth;
+ // Assume that SMTP server has same methods working as incoming.
+ // Broken assumption, but we currently have no SMTP verification.
+ // TODO: implement real SMTP verification
+ if (
+ this.config.outgoing.auth == brokenAuth &&
+ this.config.outgoing.authAlternatives.includes(
+ this.config.incoming.auth
+ )
+ ) {
+ this.config.outgoing.auth = this.config.incoming.auth;
+ }
+ this._log.debug(`Trying next auth method: ${this.server.authMethod}`);
+ this.verifyLogon();
+ return;
+ }
+
+ // Tried all variations we can. Give up.
+ this._log.debug("Have tried all variations. Giving up.");
+ this._failed(aPreviousUrl);
+ }
+
+ /**
+ * Clear out the server we had created for use during testing.
+ */
+ cleanup() {
+ try {
+ if (this.server) {
+ MailServices.accounts.removeIncomingServer(this.server, true);
+ this.server = null;
+ }
+ } catch (e) {
+ this._log.error(e);
+ }
+ }
+
+ /**
+ * @param {nsIURI} url - The URL being processed.
+ */
+ _failed(url) {
+ this.cleanup();
+ url = url.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ let code = url.errorCode || "login-error-unknown";
+ let msg = url.errorMessage;
+ // *Only* for known (!) username/password errors, show our message.
+ // But there are 1000 other reasons why it could have failed, e.g.
+ // server not reachable, bad auth method, server hiccups, or even
+ // custom server messages that tell the user to do something,
+ // so show the backend error message, unless we are certain
+ // that it's a wrong username or password.
+ if (
+ !msg || // Normal IMAP login error sets no error msg
+ code == "pop3UsernameFailure" ||
+ code == "pop3PasswordFailed" ||
+ code == "imapOAuth2Error"
+ ) {
+ msg = AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ ).GetStringFromName("cannot_login.error");
+ }
+ this.errorCallback(new Error(msg));
+ }
+
+ /**
+ * Inform users that we got a certificate error for the specified location.
+ * Allow them to add an exception for it.
+ *
+ * @param {nsITransportSecurityInfo} secInfo
+ * @param {string} location - "host:port" that had the problem.
+ */
+ informUserOfCertError(secInfo, location) {
+ this._log.debug(`Informing user about cert error for ${location}`);
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location,
+ };
+ Services.wm
+ .getMostRecentWindow("mail:3pane")
+ .browsingContext.topChromeWindow.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "exceptionDialog",
+ "chrome,centerscreen,modal",
+ params
+ );
+ if (!params.exceptionAdded) {
+ this._log.debug(`Did not accept exception for ${location}`);
+ this.cleanup();
+ let errorMsg = AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ ).GetStringFromName("cannot_login.error");
+ this.errorCallback(new Error(errorMsg));
+ } else {
+ this._log.debug(`Accept exception for ${location} - will retry logon.`);
+ // Retry the logon now that we've added the cert exception.
+ this.verifyLogon();
+ }
+ }
+
+ /**
+ * This checks a given config, by trying a real connection and login,
+ * with username and password.
+ *
+ * @param {AccountConfig} config - The guessed account config.
+ * username, password, realname, emailaddress etc. are not filled out,
+ * but placeholders to be filled out via replaceVariables().
+ * @param alter {boolean} - Try other usernames and login schemes, until
+ * login works. Warning: Modifies |config|.
+ * @returns {Promise<AccountConfig>} the successful configuration.
+ * @throws {Error} when we could guess not the config, either
+ * because we have not found anything or because there was an error
+ * (e.g. no network connection).
+ * The ex.message will contain a user-presentable message.
+ */
+ async verifyConfig(config, alter) {
+ this.alter = alter;
+ return new Promise((resolve, reject) => {
+ this.config = config;
+ this.successCallback = resolve;
+ this.errorCallback = reject;
+ if (
+ MailServices.accounts.findServer(
+ config.incoming.username,
+ config.incoming.hostname,
+ config.incoming.type,
+ config.incoming.port
+ )
+ ) {
+ reject(new Error("Incoming server exists"));
+ return;
+ }
+
+ // incoming server
+ if (!this.server) {
+ this.server = MailServices.accounts.createIncomingServer(
+ config.incoming.username,
+ config.incoming.hostname,
+ config.incoming.type
+ );
+ }
+ this.server.port = config.incoming.port;
+ this.server.password = config.incoming.password;
+ this.server.socketType = config.incoming.socketType;
+
+ this._log.info(
+ "Setting incoming server authMethod to " + config.incoming.auth
+ );
+ this.server.authMethod = config.incoming.auth;
+
+ try {
+ // Lookup OAuth2 issuer if needed.
+ // -- Incoming.
+ if (
+ config.incoming.auth == Ci.nsMsgAuthMethod.OAuth2 &&
+ (!config.incoming.oauthSettings ||
+ !config.incoming.oauthSettings.issuer ||
+ !config.incoming.oauthSettings.scope)
+ ) {
+ let details = OAuth2Providers.getHostnameDetails(
+ config.incoming.hostname
+ );
+ if (!details) {
+ reject(
+ new Error(
+ `Could not get OAuth2 details for hostname=${config.incoming.hostname}.`
+ )
+ );
+ }
+ config.incoming.oauthSettings = {
+ issuer: details[0],
+ scope: details[1],
+ };
+ }
+ // -- Outgoing.
+ if (
+ config.outgoing.auth == Ci.nsMsgAuthMethod.OAuth2 &&
+ (!config.outgoing.oauthSettings ||
+ !config.outgoing.oauthSettings.issuer ||
+ !config.outgoing.oauthSettings.scope)
+ ) {
+ let details = OAuth2Providers.getHostnameDetails(
+ config.outgoing.hostname
+ );
+ if (!details) {
+ reject(
+ new Error(
+ `Could not get OAuth2 details for hostname=${config.outgoing.hostname}.`
+ )
+ );
+ }
+ config.outgoing.oauthSettings = {
+ issuer: details[0],
+ scope: details[1],
+ };
+ }
+ if (config.incoming.owaURL) {
+ this.server.setUnicharValue("owa_url", config.incoming.owaURL);
+ }
+ if (config.incoming.ewsURL) {
+ this.server.setUnicharValue("ews_url", config.incoming.ewsURL);
+ }
+ if (config.incoming.easURL) {
+ this.server.setUnicharValue("eas_url", config.incoming.easURL);
+ }
+
+ if (
+ this.server.password ||
+ this.server.authMethod == Ci.nsMsgAuthMethod.OAuth2
+ ) {
+ this.verifyLogon();
+ } else {
+ this.cleanup();
+ resolve(config);
+ }
+ } catch (e) {
+ this._log.info("verifyConfig failed: " + e);
+ this.cleanup();
+ reject(e);
+ }
+ });
+ }
+
+ /**
+ * Verify that the provided credentials can log in to the incoming server.
+ */
+ verifyLogon() {
+ this._log.info("verifyLogon for server at " + this.server.hostName);
+ // Save away the old callbacks.
+ let saveCallbacks = this.msgWindow.notificationCallbacks;
+ // Set our own callbacks - this works because verifyLogon will
+ // synchronously create the transport and use the notification callbacks.
+ // Our listener listens both for the url and cert errors.
+ this.msgWindow.notificationCallbacks = this;
+ // try to work around bug where backend is clearing password.
+ try {
+ this.server.password = this.config.incoming.password;
+ let uri = this.server.verifyLogon(this, this.msgWindow);
+ // clear msgWindow so url won't prompt for passwords.
+ uri.QueryInterface(Ci.nsIMsgMailNewsUrl).msgWindow = null;
+ } finally {
+ // restore them
+ this.msgWindow.notificationCallbacks = saveCallbacks;
+ }
+ }
+}
diff --git a/comm/mail/components/accountcreation/CreateInBackend.jsm b/comm/mail/components/accountcreation/CreateInBackend.jsm
new file mode 100644
index 0000000000..c254bbb44b
--- /dev/null
+++ b/comm/mail/components/accountcreation/CreateInBackend.jsm
@@ -0,0 +1,459 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CreateInBackend"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountConfig",
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountCreationUtils",
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/* eslint-disable complexity */
+/**
+ * Takes an |AccountConfig| JS object and creates that account in the
+ * Thunderbird backend (which also writes it to prefs).
+ *
+ * @param {AccountConfig} config - The account to create
+ * @returns {nsIMsgAccount} - the newly created account
+ */
+function createAccountInBackend(config) {
+ // incoming server
+ let inServer = MailServices.accounts.createIncomingServer(
+ config.incoming.username,
+ config.incoming.hostname,
+ config.incoming.type
+ );
+ inServer.port = config.incoming.port;
+ inServer.authMethod = config.incoming.auth;
+ inServer.password = config.incoming.password;
+ // This new CLIENTID is for the outgoing server, and will be applied to the
+ // incoming only if the incoming username and hostname match the outgoing.
+ // We must generate this unconditionally because we cannot determine whether
+ // the outgoing server has clientid enabled yet or not, and we need to do it
+ // here in order to populate the incoming server if the outgoing matches.
+ let newOutgoingClientid = Services.uuid
+ .generateUUID()
+ .toString()
+ .replace(/[{}]/g, "");
+ // Grab the base domain of both incoming and outgoing hostname in order to
+ // compare the two to detect if the base domain is the same.
+ let incomingBaseDomain;
+ let outgoingBaseDomain;
+ try {
+ incomingBaseDomain = Services.eTLD.getBaseDomainFromHost(
+ config.incoming.hostname
+ );
+ } catch (e) {
+ incomingBaseDomain = config.incoming.hostname;
+ }
+ try {
+ outgoingBaseDomain = Services.eTLD.getBaseDomainFromHost(
+ config.outgoing.hostname
+ );
+ } catch (e) {
+ outgoingBaseDomain = config.outgoing.hostname;
+ }
+ if (
+ config.incoming.username == config.outgoing.username &&
+ incomingBaseDomain == outgoingBaseDomain
+ ) {
+ inServer.clientid = newOutgoingClientid;
+ } else {
+ // If the username/hostname are different then generate a new CLIENTID.
+ inServer.clientid = Services.uuid
+ .generateUUID()
+ .toString()
+ .replace(/[{}]/g, "");
+ }
+
+ if (config.rememberPassword && config.incoming.password) {
+ rememberPassword(inServer, config.incoming.password);
+ }
+
+ if (inServer.authMethod == Ci.nsMsgAuthMethod.OAuth2) {
+ inServer.setUnicharValue(
+ "oauth2.scope",
+ config.incoming.oauthSettings.scope
+ );
+ inServer.setUnicharValue(
+ "oauth2.issuer",
+ config.incoming.oauthSettings.issuer
+ );
+ }
+
+ // SSL
+ inServer.socketType = config.incoming.socketType;
+
+ // If we already have an account with an identical name, generate a unique
+ // name for the new account to avoid duplicates.
+ inServer.prettyName = checkAccountNameAlreadyExists(
+ config.identity.emailAddress
+ )
+ ? generateUniqueAccountName(config)
+ : config.identity.emailAddress;
+
+ inServer.doBiff = true;
+ inServer.biffMinutes = config.incoming.checkInterval;
+ inServer.setBoolValue("login_at_startup", config.incoming.loginAtStartup);
+ if (config.incoming.type == "pop3") {
+ inServer.setBoolValue(
+ "leave_on_server",
+ config.incoming.leaveMessagesOnServer
+ );
+ inServer.setIntValue(
+ "num_days_to_leave_on_server",
+ config.incoming.daysToLeaveMessagesOnServer
+ );
+ inServer.setBoolValue(
+ "delete_mail_left_on_server",
+ config.incoming.deleteOnServerWhenLocalDelete
+ );
+ inServer.setBoolValue(
+ "delete_by_age_from_server",
+ config.incoming.deleteByAgeFromServer
+ );
+ inServer.setBoolValue("download_on_biff", config.incoming.downloadOnBiff);
+ }
+ if (config.incoming.owaURL) {
+ inServer.setUnicharValue("owa_url", config.incoming.owaURL);
+ }
+ if (config.incoming.ewsURL) {
+ inServer.setUnicharValue("ews_url", config.incoming.ewsURL);
+ }
+ if (config.incoming.easURL) {
+ inServer.setUnicharValue("eas_url", config.incoming.easURL);
+ }
+ inServer.valid = true;
+
+ let username =
+ config.outgoing.auth != Ci.nsMsgAuthMethod.none
+ ? config.outgoing.username
+ : null;
+ let outServer = MailServices.smtp.findServer(
+ username,
+ config.outgoing.hostname
+ );
+ lazy.AccountCreationUtils.assert(
+ config.outgoing.addThisServer ||
+ config.outgoing.useGlobalPreferredServer ||
+ config.outgoing.existingServerKey,
+ "No SMTP server: inconsistent flags"
+ );
+
+ if (
+ config.outgoing.addThisServer &&
+ !outServer &&
+ !config.incoming.useGlobalPreferredServer
+ ) {
+ outServer = MailServices.smtp.createServer();
+ outServer.hostname = config.outgoing.hostname;
+ outServer.port = config.outgoing.port;
+ outServer.authMethod = config.outgoing.auth;
+ // Populate the clientid if it is enabled for this outgoing server.
+ if (outServer.clientidEnabled) {
+ outServer.clientid = newOutgoingClientid;
+ }
+ if (config.outgoing.auth != Ci.nsMsgAuthMethod.none) {
+ outServer.username = username;
+ outServer.password = config.outgoing.password;
+ if (config.rememberPassword && config.outgoing.password) {
+ rememberPassword(outServer, config.outgoing.password);
+ }
+ }
+
+ if (outServer.authMethod == Ci.nsMsgAuthMethod.OAuth2) {
+ let prefBranch = "mail.smtpserver." + outServer.key + ".";
+ Services.prefs.setCharPref(
+ prefBranch + "oauth2.scope",
+ config.outgoing.oauthSettings.scope
+ );
+ Services.prefs.setCharPref(
+ prefBranch + "oauth2.issuer",
+ config.outgoing.oauthSettings.issuer
+ );
+ }
+
+ outServer.socketType = config.outgoing.socketType;
+ outServer.description = config.displayName;
+
+ // If this is the first SMTP server, set it as default
+ if (
+ !MailServices.smtp.defaultServer ||
+ !MailServices.smtp.defaultServer.hostname
+ ) {
+ MailServices.smtp.defaultServer = outServer;
+ }
+ }
+
+ // identity
+ // TODO accounts without identity?
+ let identity = MailServices.accounts.createIdentity();
+ identity.fullName = config.identity.realname;
+ identity.email = config.identity.emailAddress;
+
+ // for new accounts, default to replies being positioned above the quote
+ // if a default account is defined already, take its settings instead
+ if (config.incoming.type == "imap" || config.incoming.type == "pop3") {
+ identity.replyOnTop = 1;
+ // identity.sigBottom = false; // don't set this until Bug 218346 is fixed
+
+ if (
+ MailServices.accounts.accounts.length &&
+ MailServices.accounts.defaultAccount
+ ) {
+ let defAccount = MailServices.accounts.defaultAccount;
+ let defIdentity = defAccount.defaultIdentity;
+ if (
+ defAccount.incomingServer.canBeDefaultServer &&
+ defIdentity &&
+ defIdentity.valid
+ ) {
+ identity.replyOnTop = defIdentity.replyOnTop;
+ identity.sigBottom = defIdentity.sigBottom;
+ }
+ }
+ }
+
+ // due to accepted conventions, news accounts should default to plain text
+ if (config.incoming.type == "nntp") {
+ identity.composeHtml = false;
+ }
+
+ identity.valid = true;
+
+ if (
+ !config.outgoing.useGlobalPreferredServer &&
+ !config.incoming.useGlobalPreferredServer
+ ) {
+ if (config.outgoing.existingServerKey) {
+ identity.smtpServerKey = config.outgoing.existingServerKey;
+ } else {
+ identity.smtpServerKey = outServer.key;
+ }
+ }
+
+ // account and hook up
+ // Note: Setting incomingServer will cause the AccountManager to refresh
+ // itself, which could be a problem if we came from it and we haven't set
+ // the identity (see bug 521955), so make sure everything else on the
+ // account is set up before you set the incomingServer.
+ let account = MailServices.accounts.createAccount();
+ account.addIdentity(identity);
+ account.incomingServer = inServer;
+ if (
+ inServer.canBeDefaultServer &&
+ (!MailServices.accounts.defaultAccount ||
+ !MailServices.accounts.defaultAccount.incomingServer.canBeDefaultServer)
+ ) {
+ MailServices.accounts.defaultAccount = account;
+ }
+
+ verifyLocalFoldersAccount(MailServices.accounts);
+ setFolders(identity, inServer);
+
+ // save
+ MailServices.accounts.saveAccountInfo();
+ try {
+ Services.prefs.savePrefFile(null);
+ } catch (ex) {
+ lazy.AccountCreationUtils.ddump("Could not write out prefs: " + ex);
+ }
+ return account;
+}
+/* eslint-enable complexity */
+
+function setFolders(identity, server) {
+ // TODO: support for local folders for global inbox (or use smart search
+ // folder instead)
+
+ var baseURI = server.serverURI + "/";
+
+ // Names will be localized in UI, not in folder names on server/disk
+ // TODO allow to override these names in the XML config file,
+ // in case e.g. Google or AOL use different names?
+ // Workaround: Let user fix it :)
+ var fccName = "Sent";
+ var draftName = "Drafts";
+ var templatesName = "Templates";
+
+ identity.draftFolder = baseURI + draftName;
+ identity.stationeryFolder = baseURI + templatesName;
+ identity.fccFolder = baseURI + fccName;
+
+ identity.fccFolderPickerMode = 0;
+ identity.draftsFolderPickerMode = 0;
+ identity.tmplFolderPickerMode = 0;
+}
+
+function rememberPassword(server, password) {
+ let passwordURI;
+ if (server instanceof Ci.nsIMsgIncomingServer) {
+ passwordURI = server.localStoreType + "://" + server.hostName;
+ } else if (server instanceof Ci.nsISmtpServer) {
+ passwordURI = "smtp://" + server.hostname;
+ } else {
+ throw new lazy.AccountCreationUtils.NotReached("Server type not supported");
+ }
+
+ let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ login.init(passwordURI, null, passwordURI, server.username, password, "", "");
+ try {
+ Services.logins.addLogin(login);
+ } catch (e) {
+ if (e.message.includes("This login already exists")) {
+ // TODO modify
+ } else {
+ throw e;
+ }
+ }
+}
+
+/**
+ * Check whether the user's setup already has an incoming server
+ * which matches (hostname, port, username) the primary one
+ * in the config.
+ * (We also check the email address as username.)
+ *
+ * @param config {AccountConfig} filled in (no placeholders)
+ * @returns {nsIMsgIncomingServer} If it already exists, the server
+ * object is returned.
+ * If it's a new server, |null| is returned.
+ */
+function checkIncomingServerAlreadyExists(config) {
+ lazy.AccountCreationUtils.assert(config instanceof lazy.AccountConfig);
+ let incoming = config.incoming;
+ let existing = MailServices.accounts.findServer(
+ incoming.username,
+ incoming.hostname,
+ incoming.type,
+ incoming.port
+ );
+
+ // if username does not have an '@', also check the e-mail
+ // address form of the name.
+ if (!existing && !incoming.username.includes("@")) {
+ existing = MailServices.accounts.findServer(
+ config.identity.emailAddress,
+ incoming.hostname,
+ incoming.type,
+ incoming.port
+ );
+ }
+ return existing;
+}
+
+/**
+ * Check whether the user's setup already has an outgoing server
+ * which matches (hostname, port, username) the primary one
+ * in the config.
+ *
+ * @param config {AccountConfig} filled in (no placeholders)
+ * @returns {nsISmtpServer} If it already exists, the server
+ * object is returned.
+ * If it's a new server, |null| is returned.
+ */
+function checkOutgoingServerAlreadyExists(config) {
+ lazy.AccountCreationUtils.assert(config instanceof lazy.AccountConfig);
+ for (let existingServer of MailServices.smtp.servers) {
+ // TODO check username with full email address, too, like for incoming
+ if (
+ existingServer.hostname == config.outgoing.hostname &&
+ existingServer.port == config.outgoing.port &&
+ existingServer.username == config.outgoing.username
+ ) {
+ return existingServer;
+ }
+ }
+ return null;
+}
+
+/**
+ * Check whether the user's setup already has an account with the same email
+ * address. This might happen if the user uses the same email for different
+ * protocols (eg. IMAP and POP3).
+ *
+ * @param {string} name - The name or email address of the new account.
+ * @returns {boolean} True if an account with the same name is found.
+ */
+function checkAccountNameAlreadyExists(name) {
+ return MailServices.accounts.accounts.some(
+ a => a.incomingServer.prettyName == name
+ );
+}
+
+/**
+ * Generate a unique account name by appending the incoming protocol type, and
+ * a counter if necessary.
+ *
+ * @param {AccountConfig} config - The config data of the account being created.
+ * @returns {string} - The unique account name.
+ */
+function generateUniqueAccountName(config) {
+ // Generate a potential unique name. e.g. "foo@bar.com (POP3)".
+ let name = `${
+ config.identity.emailAddress
+ } (${config.incoming.type.toUpperCase()})`;
+
+ // If this name already exists, append a counter until we find a unique name.
+ if (checkAccountNameAlreadyExists(name)) {
+ let counter = 2;
+ while (checkAccountNameAlreadyExists(`${name}_${counter}`)) {
+ counter++;
+ }
+ // e.g. "foo@bar.com (POP3)_1".
+ name = `${name}_${counter}`;
+ }
+
+ return name;
+}
+
+/**
+ * Check if there already is a "Local Folders". If not, create it.
+ * Copied from AccountWizard.js with minor updates.
+ */
+function verifyLocalFoldersAccount(am) {
+ let localMailServer;
+ try {
+ localMailServer = am.localFoldersServer;
+ } catch (ex) {
+ localMailServer = null;
+ }
+
+ try {
+ if (!localMailServer) {
+ // creates a copy of the identity you pass in
+ am.createLocalMailAccount();
+ try {
+ localMailServer = am.localFoldersServer;
+ } catch (ex) {
+ lazy.AccountCreationUtils.ddump(
+ "Error! we should have found the local mail server " +
+ "after we created it."
+ );
+ }
+ }
+ } catch (ex) {
+ lazy.AccountCreationUtils.ddump("Error in verifyLocalFoldersAccount " + ex);
+ }
+}
+
+var CreateInBackend = {
+ checkIncomingServerAlreadyExists,
+ checkOutgoingServerAlreadyExists,
+ createAccountInBackend,
+};
diff --git a/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm b/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm
new file mode 100644
index 0000000000..5813aa0240
--- /dev/null
+++ b/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm
@@ -0,0 +1,676 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["fetchConfigFromExchange", "getAddonsList"];
+
+var { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AccountConfig: "resource:///modules/accountcreation/AccountConfig.jsm",
+ FetchHTTP: "resource:///modules/accountcreation/FetchHTTP.jsm",
+ GuessConfig: "resource:///modules/accountcreation/GuessConfig.jsm",
+ Sanitizer: "resource:///modules/accountcreation/Sanitizer.jsm",
+});
+
+var {
+ Abortable,
+ assert,
+ ddump,
+ deepCopy,
+ Exception,
+ gAccountSetupLogger,
+ getStringBundle,
+ PriorityOrderAbortable,
+ SuccessiveAbortable,
+ TimeoutAbortable,
+} = AccountCreationUtils;
+
+/**
+ * Tries to get a configuration from an MS Exchange server
+ * using Microsoft AutoDiscover protocol.
+ *
+ * Disclaimers:
+ * - To support domain hosters, we cannot use SSL. That means we
+ * rely on insecure DNS and http, which means the results may be
+ * forged when under attack. The same is true for guessConfig(), though.
+ *
+ * @param {string} domain - The domain part of the user's email address
+ * @param {string} emailAddress - The user's email address
+ * @param {string} username - (Optional) The user's login name.
+ * If null, email address will be used.
+ * @param {string} password - The user's password for that email address
+ * @param {Function(domain, okCallback, cancelCallback)} confirmCallback - A
+ * callback that will be called to confirm redirection to another domain.
+ * @param {Function(config {AccountConfig})} successCallback - A callback that
+ * will be called when we could retrieve a configuration.
+ * The AccountConfig object will be passed in as first parameter.
+ * @param {Function(ex)} errorCallback - A callback that
+ * will be called when we could not retrieve a configuration,
+ * for whatever reason. This is expected (e.g. when there's no config
+ * for this domain at this location),
+ * so do not unconditionally show this to the user.
+ * The first parameter will be an exception object or error string.
+ */
+function fetchConfigFromExchange(
+ domain,
+ emailAddress,
+ username,
+ password,
+ confirmCallback,
+ successCallback,
+ errorCallback
+) {
+ assert(typeof successCallback == "function");
+ assert(typeof errorCallback == "function");
+ if (
+ !Services.prefs.getBoolPref(
+ "mailnews.auto_config.fetchFromExchange.enabled",
+ true
+ )
+ ) {
+ errorCallback("Exchange AutoDiscover disabled per user preference");
+ return new Abortable();
+ }
+
+ // <https://technet.microsoft.com/en-us/library/bb124251(v=exchg.160).aspx#Autodiscover%20services%20in%20Outlook>
+ // <https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638(v%3Dexchg.140)>, search for "The Autodiscover service uses one of these four methods"
+ let url1 =
+ "https://autodiscover." +
+ lazy.Sanitizer.hostname(domain) +
+ "/autodiscover/autodiscover.xml";
+ let url2 =
+ "https://" +
+ lazy.Sanitizer.hostname(domain) +
+ "/autodiscover/autodiscover.xml";
+ let url3 =
+ "http://autodiscover." +
+ lazy.Sanitizer.hostname(domain) +
+ "/autodiscover/autodiscover.xml";
+ let body = `<?xml version="1.0" encoding="utf-8"?>
+ <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
+ <Request>
+ <EMailAddress>${emailAddress}</EMailAddress>
+ <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
+ </Request>
+ </Autodiscover>`;
+ let callArgs = {
+ uploadBody: body,
+ post: true,
+ headers: {
+ // outlook.com needs this exact string, with space and lower case "utf".
+ // Compare bug 1454325 comment 15.
+ "Content-Type": "text/xml; charset=utf-8",
+ },
+ username: username || emailAddress,
+ password,
+ allowAuthPrompt: false,
+ };
+ let call;
+ let fetch;
+ let fetch3;
+
+ let successive = new SuccessiveAbortable();
+ let priority = new PriorityOrderAbortable(function (xml, call) {
+ // success
+ readAutoDiscoverResponse(
+ xml,
+ successive,
+ emailAddress,
+ username,
+ password,
+ confirmCallback,
+ config => {
+ config.subSource = `exchange-from-${call.foundMsg}`;
+ return detectStandardProtocols(config, domain, successCallback);
+ },
+ errorCallback
+ );
+ }, errorCallback); // all failed
+
+ call = priority.addCall();
+ call.foundMsg = "url1";
+ fetch = new lazy.FetchHTTP(
+ url1,
+ callArgs,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ fetch.start();
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ call.foundMsg = "url2";
+ fetch = new lazy.FetchHTTP(
+ url2,
+ callArgs,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ fetch.start();
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ call.foundMsg = "url3";
+ let call3ErrorCallback = call.errorCallback();
+ // url3 is HTTP (not HTTPS), so suppress password. Even MS spec demands so.
+ let call3Args = deepCopy(callArgs);
+ delete call3Args.username;
+ delete call3Args.password;
+ fetch3 = new lazy.FetchHTTP(url3, call3Args, call.successCallback(), ex => {
+ // url3 is an HTTP URL that will redirect to the real one, usually a
+ // HTTPS URL of the hoster. XMLHttpRequest unfortunately loses the call
+ // parameters, drops the auth, drops the body, and turns POST into GET,
+ // which cause the call to fail. For AutoDiscover mechanism to work,
+ // we need to repeat the call with the correct parameters again.
+ let redirectURL = fetch3._request.responseURL;
+ if (!redirectURL.startsWith("https:")) {
+ call3ErrorCallback(ex);
+ return;
+ }
+ let redirectURI = Services.io.newURI(redirectURL);
+ let redirectDomain = Services.eTLD.getBaseDomain(redirectURI);
+ let originalDomain = Services.eTLD.getBaseDomainFromHost(domain);
+
+ function fetchRedirect() {
+ let fetchCall = priority.addCall();
+ let fetch = new lazy.FetchHTTP(
+ redirectURL,
+ callArgs, // now with auth
+ fetchCall.successCallback(),
+ fetchCall.errorCallback()
+ );
+ fetchCall.setAbortable(fetch);
+ fetch.start();
+ }
+
+ const kSafeDomains = ["office365.com", "outlook.com"];
+ if (
+ redirectDomain != originalDomain &&
+ !kSafeDomains.includes(redirectDomain)
+ ) {
+ // Given that we received the redirect URL from an insecure HTTP call,
+ // we ask the user whether he trusts the redirect domain.
+ gAccountSetupLogger.info("AutoDiscover HTTP redirected to other domain");
+ let dialogSuccessive = new SuccessiveAbortable();
+ // Because the dialog implements Abortable, the dialog will cancel and
+ // close automatically, if a slow higher priority call returns late.
+ let dialogCall = priority.addCall();
+ dialogCall.setAbortable(dialogSuccessive);
+ call3ErrorCallback(new Exception("Redirected"));
+ dialogSuccessive.current = new TimeoutAbortable(
+ lazy.setTimeout(() => {
+ dialogSuccessive.current = confirmCallback(
+ redirectDomain,
+ () => {
+ // User agreed.
+ fetchRedirect();
+ // Remove the dialog from the call stack.
+ dialogCall.errorCallback()(new Exception("Proceed to fetch"));
+ },
+ ex => {
+ // User rejected, or action cancelled otherwise.
+ dialogCall.errorCallback()(ex);
+ }
+ );
+ // Account for a slow server response.
+ // This will prevent showing the warning message when not necessary.
+ // The timeout is just for optics. The Abortable ensures that it works.
+ }, 2000)
+ );
+ } else {
+ fetchRedirect();
+ call3ErrorCallback(new Exception("Redirected"));
+ }
+ });
+ fetch3.start();
+ call.setAbortable(fetch3);
+
+ successive.current = priority;
+ return successive;
+}
+
+var gLoopCounter = 0;
+
+/**
+ * @param {JXON} xml - The Exchange server AutoDiscover response
+ * @param {Function(config {AccountConfig})} successCallback - @see accountConfig.js
+ */
+function readAutoDiscoverResponse(
+ autoDiscoverXML,
+ successive,
+ emailAddress,
+ username,
+ password,
+ confirmCallback,
+ successCallback,
+ errorCallback
+) {
+ assert(successive instanceof SuccessiveAbortable);
+ assert(typeof successCallback == "function");
+ assert(typeof errorCallback == "function");
+
+ // redirect to other email address
+ if (
+ "Account" in autoDiscoverXML.Autodiscover.Response &&
+ "RedirectAddr" in autoDiscoverXML.Autodiscover.Response.Account
+ ) {
+ // <https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/49083e77-8dc2-4010-85c6-f40e090f3b17>
+ let redirectEmailAddress = lazy.Sanitizer.emailAddress(
+ autoDiscoverXML.Autodiscover.Response.Account.RedirectAddr
+ );
+ let domain = redirectEmailAddress.split("@").pop();
+ if (++gLoopCounter > 2) {
+ throw new Error("Too many redirects in XML response; domain=" + domain);
+ }
+ successive.current = fetchConfigFromExchange(
+ domain,
+ redirectEmailAddress,
+ // Per spec, need to authenticate with the original email address,
+ // not the redirected address (if not already overridden).
+ username || emailAddress,
+ password,
+ confirmCallback,
+ successCallback,
+ errorCallback
+ );
+ return;
+ }
+
+ let config = readAutoDiscoverXML(autoDiscoverXML, username);
+ if (config.isComplete()) {
+ successCallback(config);
+ } else {
+ errorCallback(new Exception("No valid configs found in AutoDiscover XML"));
+ }
+}
+
+/* eslint-disable complexity */
+/**
+ * @param {JXON} xml - The Exchange server AutoDiscover response
+ * @param {string} username - (Optional) The user's login name
+ * If null, email address placeholder will be used.
+ * @returns {AccountConfig} - @see accountConfig.js
+ *
+ * @see <https://www.msxfaq.de/exchange/autodiscover/autodiscover_xml.htm>
+ */
+function readAutoDiscoverXML(autoDiscoverXML, username) {
+ if (
+ typeof autoDiscoverXML != "object" ||
+ !("Autodiscover" in autoDiscoverXML) ||
+ !("Response" in autoDiscoverXML.Autodiscover) ||
+ !("Account" in autoDiscoverXML.Autodiscover.Response) ||
+ !("Protocol" in autoDiscoverXML.Autodiscover.Response.Account)
+ ) {
+ let stringBundle = getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ );
+ throw new Exception(
+ stringBundle.GetStringFromName("no_autodiscover.error")
+ );
+ }
+ var xml = autoDiscoverXML.Autodiscover.Response.Account;
+
+ function array_or_undef(value) {
+ return value === undefined ? [] : value;
+ }
+
+ var config = new lazy.AccountConfig();
+ config.source = lazy.AccountConfig.kSourceExchange;
+ config.incoming.username = username || "%EMAILADDRESS%";
+ config.incoming.socketType = Ci.nsMsgSocketType.SSL; // only https supported
+ config.incoming.port = 443;
+ config.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+ config.incoming.authAlternatives = [Ci.nsMsgAuthMethod.OAuth2];
+ config.outgoing.addThisServer = false;
+ config.outgoing.useGlobalPreferredServer = true;
+
+ for (let protocolX of array_or_undef(xml.$Protocol)) {
+ try {
+ let type = lazy.Sanitizer.enum(
+ protocolX.Type,
+ ["WEB", "EXHTTP", "EXCH", "EXPR", "POP3", "IMAP", "SMTP"],
+ "unknown"
+ );
+ if (type == "WEB") {
+ let urlsX;
+ if ("External" in protocolX) {
+ urlsX = protocolX.External;
+ } else if ("Internal" in protocolX) {
+ urlsX = protocolX.Internal;
+ }
+ if (urlsX) {
+ config.incoming.owaURL = lazy.Sanitizer.url(urlsX.OWAUrl.value);
+ if (
+ !config.incoming.ewsURL &&
+ "Protocol" in urlsX &&
+ "ASUrl" in urlsX.Protocol
+ ) {
+ config.incoming.ewsURL = lazy.Sanitizer.url(urlsX.Protocol.ASUrl);
+ }
+ config.incoming.type = "exchange";
+ let parsedURL = new URL(config.incoming.owaURL);
+ config.incoming.hostname = lazy.Sanitizer.hostname(
+ parsedURL.hostname
+ );
+ if (parsedURL.port) {
+ config.incoming.port = lazy.Sanitizer.integer(parsedURL.port);
+ }
+ }
+ } else if (type == "EXHTTP" || type == "EXCH") {
+ config.incoming.ewsURL = lazy.Sanitizer.url(protocolX.EwsUrl);
+ if (!config.incoming.ewsURL) {
+ config.incoming.ewsURL = lazy.Sanitizer.url(protocolX.ASUrl);
+ }
+ config.incoming.type = "exchange";
+ let parsedURL = new URL(config.incoming.ewsURL);
+ config.incoming.hostname = lazy.Sanitizer.hostname(parsedURL.hostname);
+ if (parsedURL.port) {
+ config.incoming.port = lazy.Sanitizer.integer(parsedURL.port);
+ }
+ } else if (type == "POP3" || type == "IMAP" || type == "SMTP") {
+ let server;
+ if (type == "SMTP") {
+ server = config.createNewOutgoing();
+ } else {
+ server = config.createNewIncoming();
+ }
+
+ server.type = lazy.Sanitizer.translate(type, {
+ POP3: "pop3",
+ IMAP: "imap",
+ SMTP: "smtp",
+ });
+ server.hostname = lazy.Sanitizer.hostname(protocolX.Server);
+ server.port = lazy.Sanitizer.integer(protocolX.Port);
+ server.socketType = Ci.nsMsgSocketType.plain;
+ if (
+ "SSL" in protocolX &&
+ protocolX.SSL.toLowerCase() == "on" // "On" or "Off"
+ ) {
+ // SSL is too unspecific. Do they mean STARTTLS or normal TLS?
+ // For now, assume normal TLS, unless it's a standard plain port.
+ switch (server.port) {
+ case 143: // IMAP standard
+ case 110: // POP3 standard
+ case 25: // SMTP standard
+ case 587: // SMTP standard
+ server.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS;
+ break;
+ case 993: // IMAP SSL
+ case 995: // POP3 SSL
+ case 465: // SMTP SSL
+ default:
+ // if non-standard port, assume normal TLS, not STARTTLS
+ server.socketType = Ci.nsMsgSocketType.SSL;
+ break;
+ }
+ }
+ server.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+ if (
+ "SPA" in protocolX &&
+ protocolX.SPA.toLowerCase() == "on" // "On" or "Off"
+ ) {
+ // Secure Password Authentication = NTLM or GSSAPI/Kerberos
+ server.auth = Ci.nsMsgAuthMethod.secure;
+ }
+ if ("LoginName" in protocolX) {
+ server.username = lazy.Sanitizer.nonemptystring(protocolX.LoginName);
+ } else {
+ server.username = username || "%EMAILADDRESS%";
+ }
+
+ if (type == "SMTP") {
+ if (!config.outgoing.hostname) {
+ config.outgoing = server;
+ } else {
+ config.outgoingAlternatives.push(server);
+ }
+ } else if (!config.incoming.hostname) {
+ // eslint-disable-line no-lonely-if
+ config.incoming = server;
+ } else {
+ config.incomingAlternatives.push(server);
+ }
+ }
+
+ // else unknown or unsupported protocol
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ // OAuth2 settings, so that createInBackend() doesn't bail out
+ if (config.incoming.owaURL || config.incoming.ewsURL) {
+ config.incoming.oauthSettings = {
+ issuer: config.incoming.hostname,
+ scope: config.incoming.owaURL || config.incoming.ewsURL,
+ };
+ config.outgoing.oauthSettings = {
+ issuer: config.incoming.hostname,
+ scope: config.incoming.owaURL || config.incoming.ewsURL,
+ };
+ }
+
+ return config;
+}
+/* eslint-enable complexity */
+
+/**
+ * Ask server which addons can handle this config.
+ *
+ * @param {AccountConfig} config
+ * @param {Function(config {AccountConfig})} successCallback
+ * @returns {Abortable}
+ */
+function getAddonsList(config, successCallback, errorCallback) {
+ let incoming = [config.incoming, ...config.incomingAlternatives].find(
+ alt => alt.type == "exchange"
+ );
+ if (!incoming) {
+ successCallback();
+ return new Abortable();
+ }
+ let url = Services.prefs.getCharPref("mailnews.auto_config.addons_url");
+ if (!url) {
+ errorCallback(new Exception("no URL for addons list configured"));
+ return new Abortable();
+ }
+ let fetch = new lazy.FetchHTTP(
+ url,
+ { allowCache: true, timeout: 10000 },
+ function (json) {
+ let addons = readAddonsJSON(json);
+ addons = addons.filter(addon => {
+ // Find types matching the current config.
+ // Pick the first in the list as the preferred one and
+ // tell the UI to use that one.
+ addon.useType = addon.supportedTypes.find(
+ type =>
+ (incoming.owaURL && type.protocolType == "owa") ||
+ (incoming.ewsURL && type.protocolType == "ews") ||
+ (incoming.easURL && type.protocolType == "eas")
+ );
+ return !!addon.useType;
+ });
+ if (addons.length == 0) {
+ errorCallback(
+ new Exception(
+ "Config found, but no addons known to handle the config"
+ )
+ );
+ return;
+ }
+ config.addons = addons;
+ successCallback(config);
+ },
+ errorCallback
+ );
+ fetch.start();
+ return fetch;
+}
+
+/**
+ * This reads the addons list JSON and makes security validations,
+ * e.g. that the URLs are not chrome: URLs, which could lead to exploits.
+ * It also chooses the right language etc..
+ *
+ * @param {JSON} json - the addons.json file contents
+ * @returns {Array of AddonInfo} - @see AccountConfig.addons
+ *
+ * accountTypes are listed in order of decreasing preference.
+ * Languages are 2-letter codes. If a language is not available,
+ * the first name or description will be used.
+ *
+ * Parse e.g.
+[
+ {
+ "id": "owl@beonex.com",
+ "name": {
+ "en": "Owl",
+ "de": "Eule"
+ },
+ "description": {
+ "en": "Owl is a paid third-party addon that allows you to access your email account on Exchange servers. See the website for prices.",
+ "de": "Eule ist eine Erweiterung von einem Drittanbieter, die Ihnen erlaubt, Exchange-Server zu benutzen. Sie ist kostenpflichtig. Die Preise finden Sie auf der Website."
+ },
+ "minVersion": "0.2",
+ "xpiURL": "http://www.beonex.com/owl/latest.xpi",
+ "websiteURL": "http://www.beonex.com/owl/",
+ "icon32": "http://www.beonex.com/owl/owl-32.png",
+ "accountTypes": [
+ {
+ "generalType": "exchange",
+ "protocolType": "owa",
+ "addonAccountType": "owl-owa"
+ },
+ {
+ "generalType": "exchange",
+ "protocolType": "eas",
+ "addonAccountType": "owl-eas"
+ }
+ ]
+ }
+]
+ */
+function readAddonsJSON(json) {
+ let addons = [];
+ function ensureArray(value) {
+ return Array.isArray(value) ? value : [];
+ }
+ let xulLocale = Services.locale.requestedLocale;
+ let locale = xulLocale ? xulLocale.substring(0, 5) : "default";
+ for (let addonJSON of ensureArray(json)) {
+ try {
+ let addon = {
+ id: addonJSON.id,
+ minVersion: addonJSON.minVersion,
+ xpiURL: lazy.Sanitizer.url(addonJSON.xpiURL),
+ websiteURL: lazy.Sanitizer.url(addonJSON.websiteURL),
+ icon32: addonJSON.icon32 ? lazy.Sanitizer.url(addonJSON.icon32) : null,
+ supportedTypes: [],
+ };
+ assert(
+ new URL(addon.xpiURL).protocol == "https:",
+ "XPI download URL needs to be https"
+ );
+ addon.name =
+ locale in addonJSON.name ? addonJSON.name[locale] : addonJSON.name[0];
+ addon.description =
+ locale in addonJSON.description
+ ? addonJSON.description[locale]
+ : addonJSON.description[0];
+ for (let typeJSON of ensureArray(addonJSON.accountTypes)) {
+ try {
+ addon.supportedTypes.push({
+ generalType: lazy.Sanitizer.alphanumdash(typeJSON.generalType),
+ protocolType: lazy.Sanitizer.alphanumdash(typeJSON.protocolType),
+ addonAccountType: lazy.Sanitizer.alphanumdash(
+ typeJSON.addonAccountType
+ ),
+ });
+ } catch (e) {
+ ddump(e);
+ }
+ }
+ addons.push(addon);
+ } catch (e) {
+ ddump(e);
+ }
+ }
+ return addons;
+}
+
+/**
+ * Probe a found Exchange server for IMAP/POP3 and SMTP support.
+ *
+ * @param {AccountConfig} config - The initial detected Exchange configuration.
+ * @param {string} domain - The domain part of the user's email address
+ * @param {Function(config {AccountConfig})} successCallback - A callback that
+ * will be called when we found an appropriate configuration.
+ * The AccountConfig object will be passed in as first parameter.
+ */
+function detectStandardProtocols(config, domain, successCallback) {
+ gAccountSetupLogger.info("Exchange Autodiscover gave some results.");
+ let alts = [config.incoming, ...config.incomingAlternatives];
+ if (alts.find(alt => alt.type == "imap" || alt.type == "pop3")) {
+ // Autodiscover found an exchange server with advertized IMAP and/or
+ // POP3 support. We're done then.
+ config.preferStandardProtocols();
+ successCallback(config);
+ return;
+ }
+
+ // Autodiscover is known not to advertise all that it supports. Let's see
+ // if there really isn't any IMAP/POP3 support by probing the Exchange
+ // server. Use the server hostname already found.
+ let config2 = new lazy.AccountConfig();
+ config2.incoming.hostname = config.incoming.hostname;
+ config2.incoming.username = config.incoming.username || "%EMAILADDRESS%";
+ // For Exchange 2013+ Kerberos/GSSAPI and NTLM options do not work by
+ // default at least for Linux users, even if support is detected.
+ config2.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+
+ config2.outgoing.hostname = config.incoming.hostname;
+ config2.outgoing.username = config.incoming.username || "%EMAILADDRESS%";
+
+ config2.incomingAlternatives = config.incomingAlternatives;
+ config2.incomingAlternatives.push(config.incoming); // type=exchange
+
+ config2.outgoingAlternatives = config.outgoingAlternatives;
+ if (config.outgoing.hostname) {
+ config2.outgoingAlternatives.push(config.outgoing);
+ }
+
+ lazy.GuessConfig.guessConfig(
+ domain,
+ function (type, hostname, port, ssl, done, config) {
+ gAccountSetupLogger.info(
+ `Probing exchange server ${hostname} for ${type} protocol support.`
+ );
+ },
+ function (probedConfig) {
+ // Probing succeeded: found open protocols, yay!
+ successCallback(probedConfig);
+ },
+ function (e, probedConfig) {
+ // Probing didn't find any open protocols.
+ // Let's use the exchange (only) config that was listed then.
+ config.subSource += "-guess";
+ successCallback(config);
+ },
+ config2,
+ "both"
+ );
+}
diff --git a/comm/mail/components/accountcreation/FetchConfig.jsm b/comm/mail/components/accountcreation/FetchConfig.jsm
new file mode 100644
index 0000000000..1bf16ca2ed
--- /dev/null
+++ b/comm/mail/components/accountcreation/FetchConfig.jsm
@@ -0,0 +1,299 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["FetchConfig"];
+
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FetchHTTP",
+ "resource:///modules/accountcreation/FetchHTTP.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "readFromXML",
+ "resource:///modules/accountcreation/readFromXML.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+const { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
+
+const {
+ Abortable,
+ ddump,
+ Exception,
+ PriorityOrderAbortable,
+ PromiseAbortable,
+ readURLasUTF8,
+ runAsync,
+ SuccessiveAbortable,
+ TimeoutAbortable,
+} = AccountCreationUtils;
+
+/**
+ * Tries to find a configuration for this ISP on the local harddisk, in the
+ * application install directory's "isp" subdirectory.
+ * Params @see fetchConfigFromISP()
+ */
+function fetchConfigFromDisk(domain, successCallback, errorCallback) {
+ return new TimeoutAbortable(
+ runAsync(function () {
+ try {
+ // <TB installdir>/isp/example.com.xml
+ var configLocation = Services.dirsvc.get("CurProcD", Ci.nsIFile);
+ configLocation.append("isp");
+ configLocation.append(lazy.Sanitizer.hostname(domain) + ".xml");
+
+ if (!configLocation.exists() || !configLocation.isReadable()) {
+ errorCallback(new Exception("local file not found"));
+ return;
+ }
+ var contents = readURLasUTF8(Services.io.newFileURI(configLocation));
+ let domParser = new DOMParser();
+ const xml = JXON.build(domParser.parseFromString(contents, "text/xml"));
+ successCallback(lazy.readFromXML(xml, "disk"));
+ } catch (e) {
+ errorCallback(e);
+ }
+ })
+ );
+}
+
+/**
+ * Tries to get a configuration from the ISP / mail provider directly.
+ *
+ * Disclaimers:
+ * - To support domain hosters, we cannot use SSL. That means we
+ * rely on insecure DNS and http, which means the results may be
+ * forged when under attack. The same is true for guessConfig(), though.
+ *
+ * @param domain {String} - The domain part of the user's email address
+ * @param emailAddress {String} - The user's email address
+ * @param successCallback {Function(config {AccountConfig}})} A callback that
+ * will be called when we could retrieve a configuration.
+ * The AccountConfig object will be passed in as first parameter.
+ * @param errorCallback {Function(ex)} - A callback that
+ * will be called when we could not retrieve a configuration,
+ * for whatever reason. This is expected (e.g. when there's no config
+ * for this domain at this location),
+ * so do not unconditionally show this to the user.
+ * The first parameter will be an exception object or error string.
+ */
+function fetchConfigFromISP(
+ domain,
+ emailAddress,
+ successCallback,
+ errorCallback
+) {
+ if (
+ !Services.prefs.getBoolPref("mailnews.auto_config.fetchFromISP.enabled")
+ ) {
+ errorCallback(new Exception("ISP fetch disabled per user preference"));
+ return new Abortable();
+ }
+
+ let conf1 =
+ "autoconfig." + lazy.Sanitizer.hostname(domain) + "/mail/config-v1.1.xml";
+ // .well-known/ <http://tools.ietf.org/html/draft-nottingham-site-meta-04>
+ let conf2 =
+ lazy.Sanitizer.hostname(domain) +
+ "/.well-known/autoconfig/mail/config-v1.1.xml";
+ // This list is sorted by decreasing priority
+ var urls = ["https://" + conf1, "https://" + conf2];
+ if (
+ !Services.prefs.getBoolPref("mailnews.auto_config.fetchFromISP.sslOnly")
+ ) {
+ urls.push("http://" + conf1, "http://" + conf2);
+ }
+ let callArgs = {
+ urlArgs: {
+ emailaddress: emailAddress,
+ },
+ };
+ if (
+ !Services.prefs.getBoolPref(
+ "mailnews.auto_config.fetchFromISP.sendEmailAddress"
+ )
+ ) {
+ delete callArgs.urlArgs.emailaddress;
+ }
+ let call;
+ let fetch;
+
+ let priority = new PriorityOrderAbortable(
+ (xml, call) =>
+ successCallback(lazy.readFromXML(xml, `isp-${call.foundMsg}`)),
+ errorCallback
+ );
+ for (let url of urls) {
+ call = priority.addCall();
+ call.foundMsg = url.startsWith("https") ? "https" : "http";
+ fetch = new lazy.FetchHTTP(
+ url,
+ callArgs,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+ fetch.start();
+ }
+
+ return priority;
+}
+
+/**
+ * Tries to get a configuration for this ISP from a central database at
+ * Mozilla servers.
+ * Params @see fetchConfigFromISP()
+ */
+function fetchConfigFromDB(domain, successCallback, errorCallback) {
+ let url = Services.prefs.getCharPref("mailnews.auto_config_url");
+ if (!url) {
+ errorCallback(new Exception("no URL for ISP DB configured"));
+ return new Abortable();
+ }
+ domain = lazy.Sanitizer.hostname(domain);
+
+ // If we don't specify a place to put the domain, put it at the end.
+ if (!url.includes("{{domain}}")) {
+ url = url + domain;
+ } else {
+ url = url.replace("{{domain}}", domain);
+ }
+
+ let fetch = new lazy.FetchHTTP(
+ url,
+ { timeout: 10000 }, // 10 seconds
+ function (result) {
+ successCallback(lazy.readFromXML(result, "db"));
+ },
+ errorCallback
+ );
+ fetch.start();
+ return fetch;
+}
+
+/**
+ * Does a lookup of DNS MX, to get the server that is responsible for
+ * receiving mail for this domain. Then it takes the domain of that
+ * server, and does another lookup (in ISPDB and possibly at ISP autoconfig
+ * server) and if such a config is found, returns that.
+ *
+ * Disclaimers:
+ * - DNS is unprotected, meaning the results could be forged.
+ * The same is true for fetchConfigFromISP() and guessConfig(), though.
+ * - DNS MX tells us the incoming server, not the mailbox (IMAP) server.
+ * They are different. This mechanism is only an approximation
+ * for hosted domains (yourname.com is served by mx.hoster.com and
+ * therefore imap.hoster.com - that "therefore" is exactly the
+ * conclusional jump we make here.) and alternative domains
+ * (e.g. yahoo.de -> yahoo.com).
+ * - We make a look up for the base domain. E.g. if MX is
+ * mx1.incoming.servers.hoster.com, we look up hoster.com.
+ * Thanks to Services.eTLD, we also get bbc.co.uk right.
+ *
+ * Params @see fetchConfigFromISP()
+ */
+function fetchConfigForMX(domain, successCallback, errorCallback) {
+ const sanitizedDomain = lazy.Sanitizer.hostname(domain);
+ const sucAbortable = new SuccessiveAbortable();
+ const time = Date.now();
+
+ sucAbortable.current = getMX(
+ sanitizedDomain,
+ function (mxHostname) {
+ // success
+ ddump("getmx took " + (Date.now() - time) + "ms");
+ let sld = Services.eTLD.getBaseDomainFromHost(mxHostname);
+ ddump("base domain " + sld + " for " + mxHostname);
+ if (sld == sanitizedDomain) {
+ errorCallback(
+ new Exception("MX lookup would be no different from domain")
+ );
+ return;
+ }
+
+ // In addition to just the base domain, also check the full domain of the MX server
+ // to differentiate between Outlook.com/Hotmail and Office365 business domains.
+ let mxDomain;
+ try {
+ mxDomain = Services.eTLD.getNextSubDomain(mxHostname);
+ } catch (ex) {
+ // e.g. hostname doesn't have enough components
+ console.error(ex); // not fatal
+ }
+ let priority = new PriorityOrderAbortable(successCallback, errorCallback);
+ if (mxDomain && sld != mxDomain) {
+ let call = priority.addCall();
+ let fetch = fetchConfigFromDB(
+ mxDomain,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+ }
+ let call = priority.addCall();
+ let fetch = fetchConfigFromDB(
+ sld,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+ sucAbortable.current = priority;
+ },
+ errorCallback
+ );
+ return sucAbortable;
+}
+
+/**
+ * Queries the DNS MX records for a given domain. Calls `successCallback` with
+ * the hostname of the MX server. If there are several entries with different
+ * preference values, only the most preferred (i.e. has the lowest value)
+ * is used. If there are several most preferred servers (i.e. round robin),
+ * only one of them is used.
+ *
+ * @param {string} sanitizedDomain @see fetchConfigFromISP()
+ * @param {function(hostname {string})} - successCallback
+ * Called when we found an MX for the domain.
+ * For |hostname|, see description above.
+ * @param {function({Exception|string})} errorCallback @see fetchConfigFromISP()
+ */
+function getMX(sanitizedDomain, successCallback, errorCallback) {
+ return new PromiseAbortable(
+ DNS.mx(sanitizedDomain),
+ function (records) {
+ const filteredRecs = records.filter(record => record.host);
+
+ if (filteredRecs.length > 0) {
+ const sortedRecs = filteredRecs.sort((a, b) => a.prio > b.prio);
+ const firstHost = sortedRecs[0].host;
+ successCallback(firstHost);
+ } else {
+ errorCallback(
+ new Exception(
+ "No hostname found in MX records for sanitizedDomain=" +
+ sanitizedDomain
+ )
+ );
+ }
+ },
+ errorCallback
+ );
+}
+
+var FetchConfig = {
+ forMX: fetchConfigForMX,
+ fromDB: fetchConfigFromDB,
+ fromISP: fetchConfigFromISP,
+ fromDisk: fetchConfigFromDisk,
+};
diff --git a/comm/mail/components/accountcreation/FetchHTTP.jsm b/comm/mail/components/accountcreation/FetchHTTP.jsm
new file mode 100644
index 0000000000..54b3629906
--- /dev/null
+++ b/comm/mail/components/accountcreation/FetchHTTP.jsm
@@ -0,0 +1,401 @@
+/* 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 is a small wrapper around XMLHttpRequest, which solves various
+ * inadequacies of the API, e.g. error handling. It is entirely generic and
+ * can be used for purposes outside of even mail.
+ *
+ * It does not provide download progress, but assumes that the
+ * fetched resource is so small (<1 10 KB) that the roundtrip and
+ * response generation is far more significant than the
+ * download time of the response. In other words, it's fine for RPC,
+ * but not for bigger file downloads.
+ */
+
+const EXPORTED_SYMBOLS = ["FetchHTTP"];
+
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+const { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
+
+const {
+ Abortable,
+ alertPrompt,
+ assert,
+ ddump,
+ Exception,
+ gAccountSetupLogger,
+ getStringBundle,
+ UserCancelledException,
+} = AccountCreationUtils;
+
+/**
+ * Set up a fetch.
+ *
+ * @param {string} url - URL of the server function.
+ * ATTENTION: The caller needs to make sure that the URL is secure to call.
+ * @param {object} args - Additional parameters as properties, see below
+ *
+ * @param {Function({string} result)} successCallback
+ * Called when the server call worked (no errors).
+ * |result| will contain the body of the HTTP response, as string.
+ * @param {Function(ex)} errorCallback
+ * Called in case of error. ex contains the error
+ * with a user-displayable but not localized |.message| and maybe a
+ * |.code|, which can be either
+ * - an nsresult error code,
+ * - an HTTP result error code (0...1000) or
+ * - negative: 0...-100 :
+ * -2 = can't resolve server in DNS etc.
+ * -4 = response body (e.g. XML) malformed
+ *
+ * The following optional parameters are supported as properties of the |args| object:
+ *
+ * @param {Object, associative array} urlArgs - Parameters to add
+ * to the end of the URL as query string. E.g.
+ * { foo: "bla", bar: "blub blub" } will add "?foo=bla&bar=blub%20blub"
+ * to the URL
+ * (unless the URL already has a "?", then it adds "&foo...").
+ * The values will be urlComponentEncoded, so pass them unencoded.
+ * @param {Object, associative array} headers - HTTP headers to be added
+ * to the HTTP request.
+ * { foo: "blub blub" } will add HTTP header "Foo: Blub blub".
+ * The values will be passed verbatim.
+ * @param {boolean} post - HTTP GET or POST
+ * Only influences the HTTP request method,
+ * i.e. first line of the HTTP request, not the body or parameters.
+ * Use POST when you modify server state,
+ * GET when you only request information.
+ * Default is GET.
+ * @param {Object, associative array} bodyFormArgs - Like urlArgs,
+ * just that the params will be sent x-url-encoded in the body,
+ * like a HTML form post.
+ * The values will be urlComponentEncoded, so pass them unencoded.
+ * This cannot be used together with |uploadBody|.
+ * @param {object} uploadBody - Arbitrary object, which to use as
+ * body of the HTTP request. Will also set the mimetype accordingly.
+ * Only supported object types, currently only JXON is supported
+ * (sending XML).
+ * Usually, you have nothing to upload, so just pass |null|.
+ * Only supported object types, currently supported:
+ * JXON -> sending XML
+ * JS object -> sending JSON
+ * string -> sending text/plain
+ * If you want to override the body mimetype, set header Content-Type below.
+ * Usually, you have nothing to upload, so just leave it at |null|.
+ * Default |null|.
+ * @param {boolean} allowCache (default true)
+ * @param {string} username (default null = no authentication)
+ * @param {string} password (default null = no authentication)
+ * @param {boolean} allowAuthPrompt (default true)
+ * @param {boolean} requireSecureAuth (default false)
+ * Ignore the username and password unless we are using https:
+ * This also applies to both https: to http: and http: to https: redirects.
+ */
+function FetchHTTP(url, args, successCallback, errorCallback) {
+ assert(typeof successCallback == "function", "BUG: successCallback");
+ assert(typeof errorCallback == "function", "BUG: errorCallback");
+ this._url = lazy.Sanitizer.string(url);
+ if (!args) {
+ args = {};
+ }
+ if (!args.urlArgs) {
+ args.urlArgs = {};
+ }
+ if (!args.headers) {
+ args.headers = {};
+ }
+
+ this._args = args;
+ this._args.post = lazy.Sanitizer.boolean(args.post || false); // default false
+ this._args.allowCache =
+ "allowCache" in args ? lazy.Sanitizer.boolean(args.allowCache) : true; // default true
+ this._args.allowAuthPrompt = lazy.Sanitizer.boolean(
+ args.allowAuthPrompt || false
+ ); // default false
+ this._args.requireSecureAuth = lazy.Sanitizer.boolean(
+ args.requireSecureAuth || false
+ ); // default false
+ this._args.timeout = lazy.Sanitizer.integer(args.timeout || 5000); // default 5 seconds
+ this._successCallback = successCallback;
+ this._errorCallback = errorCallback;
+ this._logger = gAccountSetupLogger;
+ this._logger.info("Requesting <" + url + ">");
+}
+FetchHTTP.prototype = {
+ __proto__: Abortable.prototype,
+ _url: null, // URL as passed to ctor, without arguments
+ _args: null,
+ _successCallback: null,
+ _errorCallback: null,
+ _request: null, // the XMLHttpRequest object
+ result: null,
+
+ start() {
+ let url = this._url;
+ for (let name in this._args.urlArgs) {
+ url +=
+ (!url.includes("?") ? "?" : "&") +
+ name +
+ "=" +
+ encodeURIComponent(this._args.urlArgs[name]);
+ }
+ this._request = new XMLHttpRequest();
+ let request = this._request;
+ request.mozBackgroundRequest = !this._args.allowAuthPrompt;
+ let username = null,
+ password = null;
+ if (url.startsWith("https:") || !this._args.requireSecureAuth) {
+ username = this._args.username;
+ password = this._args.password;
+ }
+ request.open(
+ this._args.post ? "POST" : "GET",
+ url,
+ true,
+ username,
+ password
+ );
+ request.channel.loadGroup = null;
+ request.timeout = this._args.timeout;
+ // needs bug 407190 patch v4 (or higher) - uncomment if that lands.
+ // try {
+ // var channel = request.channel.QueryInterface(Ci.nsIHttpChannel2);
+ // channel.connectTimeout = 5;
+ // channel.requestTimeout = 5;
+ // } catch (e) { dump(e + "\n"); }
+
+ if (!this._args.allowCache) {
+ // Disable Mozilla HTTP cache
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ }
+
+ // body
+ let mimetype = null;
+ let body = this._args.uploadBody;
+ if (typeof body == "object" && "nodeType" in body) {
+ // XML
+ mimetype = "text/xml; charset=UTF-8";
+ body = new XMLSerializer().serializeToString(body);
+ } else if (typeof body == "object") {
+ // JSON
+ mimetype = "text/json; charset=UTF-8";
+ body = JSON.stringify(body);
+ } else if (typeof body == "string") {
+ // Plaintext
+ // You can override the mimetype with { headers: {"Content-Type" : "text/foo" } }
+ mimetype = "text/plain; charset=UTF-8";
+ // body already set above
+ } else if (this._args.bodyFormArgs) {
+ mimetype = "application/x-www-form-urlencoded; charset=UTF-8";
+ body = "";
+ for (let name in this._args.bodyFormArgs) {
+ body +=
+ (body ? "&" : "") +
+ name +
+ "=" +
+ encodeURIComponent(this._args.bodyFormArgs[name]);
+ }
+ }
+
+ // Headers
+ if (mimetype && !("Content-Type" in this._args.headers)) {
+ request.setRequestHeader("Content-Type", mimetype);
+ }
+ if (username && password) {
+ // workaround, because open(..., username, password) does not work.
+ request.setRequestHeader(
+ "Authorization",
+ "Basic " +
+ btoa(
+ // btoa() takes a BinaryString.
+ String.fromCharCode(
+ ...new TextEncoder().encode(username + ":" + password)
+ )
+ )
+ );
+ }
+ for (let name in this._args.headers) {
+ request.setRequestHeader(name, this._args.headers[name]);
+ if (name == "Cookie") {
+ // Websites are not allowed to set this, but chrome is.
+ // Nevertheless, the cookie lib later overwrites our header.
+ // request.channel.setCookie(this._args.headers[name]); -- crashes
+ // So, deactivate that Firefox cookie lib.
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ }
+ }
+
+ var me = this;
+ request.onload = function () {
+ me._response(true);
+ };
+ request.onerror = function () {
+ me._response(false);
+ };
+ request.ontimeout = function () {
+ me._response(false);
+ };
+ request.send(body);
+ // Store the original stack so we can use it if there is an exception
+ this._callStack = Error().stack;
+ },
+ _response(success, exStored) {
+ try {
+ var errorCode = null;
+ var errorStr = null;
+
+ if (
+ success &&
+ this._request.status >= 200 &&
+ this._request.status < 300
+ ) {
+ // HTTP level success
+ try {
+ // response
+ var mimetype = this._request.getResponseHeader("Content-Type");
+ if (!mimetype) {
+ mimetype = "";
+ }
+ mimetype = mimetype.split(";")[0];
+ if (
+ mimetype == "text/xml" ||
+ mimetype == "application/xml" ||
+ mimetype == "text/rdf"
+ ) {
+ // XML
+ this.result = JXON.build(this._request.responseXML);
+ } else if (
+ mimetype == "text/json" ||
+ mimetype == "application/json"
+ ) {
+ // JSON
+ this.result = JSON.parse(this._request.responseText);
+ } else {
+ // Plaintext (fallback)
+ // ddump("mimetype: " + mimetype + " only supported as text");
+ this.result = this._request.responseText;
+ }
+ } catch (e) {
+ success = false;
+ errorStr = getStringBundle(
+ "chrome://messenger/locale/accountCreationUtil.properties"
+ ).GetStringFromName("bad_response_content.error");
+ errorCode = -4;
+ }
+ } else if (
+ this._args.username &&
+ this._request.responseURL.replace(/\/\/.*@/, "//") != this._url &&
+ this._request.responseURL.startsWith(
+ this._args.requireSecureAuth ? "https" : "http"
+ ) &&
+ !this._isRetry
+ ) {
+ // Redirects lack auth, see <https://stackoverflow.com/a/28411170>
+ this._logger.info(
+ "Call to <" +
+ this._url +
+ "> was redirected to <" +
+ this._request.responseURL +
+ ">, and failed. Re-trying the new URL with authentication again."
+ );
+ this._url = this._request.responseURL;
+ this._isRetry = true;
+ this.start();
+ return;
+ } else {
+ success = false;
+ try {
+ errorCode = this._request.status;
+ errorStr = this._request.statusText;
+ } catch (e) {
+ // In case .statusText throws (it's marked as [Throws] in the webidl),
+ // continue with empty errorStr.
+ }
+ if (!errorStr) {
+ // If we can't resolve the hostname in DNS etc., .statusText is empty.
+ errorCode = -2;
+ errorStr = getStringBundle(
+ "chrome://messenger/locale/accountCreationUtil.properties"
+ ).GetStringFromName("cannot_contact_server.error");
+ ddump(errorStr + " on <" + this._url + ">");
+ }
+ }
+
+ // Callbacks
+ if (success) {
+ try {
+ this._successCallback(this.result);
+ } catch (e) {
+ e.stack = this._callStack;
+ this._error(e);
+ }
+ } else if (exStored) {
+ this._error(exStored);
+ } else {
+ // Put the caller's stack into the exception
+ let e = new ServerException(errorStr, errorCode, this._url);
+ e.stack = this._callStack;
+ this._error(e);
+ }
+
+ if (this._finishedCallback) {
+ try {
+ this._finishedCallback(this);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ } catch (e) {
+ // error in our fetchhttp._response() code
+ this._error(e);
+ }
+ },
+ _error(e) {
+ try {
+ this._errorCallback(e);
+ } catch (e) {
+ // error in errorCallback, too!
+ console.error(e);
+ alertPrompt("Error in errorCallback for fetchhttp", e);
+ }
+ },
+ /**
+ * Call this between start() and finishedCallback fired.
+ */
+ cancel(ex) {
+ assert(!this.result, "Call already returned");
+
+ this._request.abort();
+
+ // Need to manually call error handler
+ // <https://bugzilla.mozilla.org/show_bug.cgi?id=218236#c11>
+ this._response(false, ex ? ex : new UserCancelledException());
+ },
+ /**
+ * Allows caller or lib to be notified when the call is done.
+ * This is useful to enable and disable a Cancel button in the UI,
+ * which allows to cancel the network request.
+ */
+ setFinishedCallback(finishedCallback) {
+ this._finishedCallback = finishedCallback;
+ },
+};
+
+function ServerException(msg, code, uri) {
+ Exception.call(this, msg);
+ this.code = code;
+ this.uri = uri;
+}
+ServerException.prototype = Object.create(Exception.prototype);
+ServerException.prototype.constructor = ServerException;
diff --git a/comm/mail/components/accountcreation/GuessConfig.jsm b/comm/mail/components/accountcreation/GuessConfig.jsm
new file mode 100644
index 0000000000..3d590311d9
--- /dev/null
+++ b/comm/mail/components/accountcreation/GuessConfig.jsm
@@ -0,0 +1,1317 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["GuessConfig"];
+
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountConfig",
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+const {
+ Abortable,
+ alertPrompt,
+ assert,
+ CancelledException,
+ ddump,
+ deepCopy,
+ Exception,
+ gAccountSetupLogger,
+ getStringBundle,
+ NotReached,
+ UserCancelledException,
+} = AccountCreationUtils;
+
+/**
+ * Try to guess the config, by:
+ * - guessing hostnames (pop3.<domain>, pop.<domain>, imap.<domain>,
+ * mail.<domain> etc.)
+ * - probing known ports (for IMAP, POP3 etc., with SSL, STARTTLS etc.)
+ * - opening a connection via the right protocol and checking the
+ * protocol-specific CAPABILITIES like that the server returns.
+ *
+ * Final verification is not done here, but in verifyConfig().
+ *
+ * This function is async.
+ *
+ * @param domain {String} the domain part of the email address
+ * @param progressCallback {function(type, hostname, port, socketType, done)}
+ * Called when we try a new hostname/port.
+ * type {String-enum} @see AccountConfig type - "imap", "pop3", "smtp"
+ * hostname {String}
+ * port {Integer}
+ * socketType {nsMsgSocketType} @see MailNewsTypes2.idl
+ * 0 = plain, 2 = STARTTLS, 3 = SSL
+ * done {Boolean} false, if we start probing this host/port, true if we're
+ * done and the host is good. (there is no notification when a host is
+ * bad, we'll just tell about the next host tried)
+ * @param successCallback {function(config {AccountConfig})}
+ * Called when we could guess the config.
+ * param accountConfig {AccountConfig} The guessed account config.
+ * username, password, realname, emailaddress etc. are not filled out,
+ * but placeholders to be filled out via replaceVariables().
+ * @param errorCallback function(ex)
+ * Called when we could guess not the config, either
+ * because we have not found anything or
+ * because there was an error (e.g. no network connection).
+ * The ex.message will contain a user-presentable message.
+ * @param resultConfig {AccountConfig} (optional)
+ * A config which may be partially filled in. If so, it will be used as base
+ * for the guess.
+ * @param which {String-enum} (optional) "incoming", "outgoing", or "both".
+ * Default "both". Whether to guess only the incoming or outgoing server.
+ * @result {Abortable} Allows you to cancel the guess
+ */
+function guessConfig(
+ domain,
+ progressCallback,
+ successCallback,
+ errorCallback,
+ resultConfig,
+ which
+) {
+ assert(typeof progressCallback == "function", "need progressCallback");
+ assert(typeof successCallback == "function", "need successCallback");
+ assert(typeof errorCallback == "function", "need errorCallback");
+
+ // Servers that we know enough that they support OAuth2 do not need guessing.
+ if (resultConfig.incoming.auth == Ci.nsMsgAuthMethod.OAuth2) {
+ successCallback(resultConfig);
+ return new Abortable();
+ }
+
+ if (!resultConfig) {
+ resultConfig = new lazy.AccountConfig();
+ }
+ resultConfig.source = lazy.AccountConfig.kSourceGuess;
+
+ if (!which) {
+ which = "both";
+ }
+
+ if (!Services.prefs.getBoolPref("mailnews.auto_config.guess.enabled")) {
+ errorCallback("Guessing config disabled per user preference");
+ return new Abortable();
+ }
+
+ var incomingHostDetector = null;
+ var outgoingHostDetector = null;
+ var incomingEx = null; // if incoming had error, store ex here
+ var outgoingEx = null; // if incoming had error, store ex here
+ var incomingDone = which == "outgoing";
+ var outgoingDone = which == "incoming";
+ // If we're offline, we're going to pick the most common settings.
+ // (Not the "best" settings, but common).
+ if (Services.io.offline) {
+ // TODO: don't do this. Bug 599173.
+ resultConfig.source = lazy.AccountConfig.kSourceUser;
+ resultConfig.incoming.hostname = "mail." + domain;
+ resultConfig.incoming.username = resultConfig.identity.emailAddress;
+ resultConfig.outgoing.username = resultConfig.identity.emailAddress;
+ resultConfig.incoming.type = "imap";
+ resultConfig.incoming.port = 143;
+ resultConfig.incoming.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS;
+ resultConfig.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+ resultConfig.outgoing.hostname = "smtp." + domain;
+ resultConfig.outgoing.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS;
+ resultConfig.outgoing.port = 587;
+ resultConfig.outgoing.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+ resultConfig.incomingAlternatives.push({
+ hostname: "mail." + domain,
+ username: resultConfig.identity.emailAddress,
+ type: "pop3",
+ port: 110,
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ auth: Ci.nsMsgAuthMethod.passwordCleartext,
+ });
+ successCallback(resultConfig);
+ return new Abortable();
+ }
+ var progress = function (thisTry) {
+ progressCallback(
+ protocolToString(thisTry.protocol),
+ thisTry.hostname,
+ thisTry.port,
+ thisTry.socketType,
+ false,
+ resultConfig
+ );
+ };
+
+ var checkDone = function () {
+ if (incomingEx) {
+ try {
+ errorCallback(incomingEx, resultConfig);
+ } catch (e) {
+ console.error(e);
+ alertPrompt("Error in errorCallback for guessConfig()", e);
+ }
+ return;
+ }
+ if (outgoingEx) {
+ try {
+ errorCallback(outgoingEx, resultConfig);
+ } catch (e) {
+ console.error(e);
+ alertPrompt("Error in errorCallback for guessConfig()", e);
+ }
+ return;
+ }
+ if (incomingDone && outgoingDone) {
+ try {
+ successCallback(resultConfig);
+ } catch (e) {
+ try {
+ errorCallback(e);
+ } catch (e) {
+ console.error(e);
+ alertPrompt("Error in errorCallback for guessConfig()", e);
+ }
+ }
+ }
+ };
+
+ var logger = gAccountSetupLogger;
+ var HostTryToAccountServer = function (thisTry, server) {
+ server.type = protocolToString(thisTry.protocol);
+ server.hostname = thisTry.hostname;
+ server.port = thisTry.port;
+ server.socketType = thisTry.socketType;
+ server.auth =
+ thisTry.authMethod || chooseBestAuthMethod(thisTry.authMethods);
+ server.authAlternatives = thisTry.authMethods;
+ // TODO
+ // cert is also bad when targetSite is set. (Same below for incoming.)
+ // Fix SSLErrorHandler and security warning dialog in accountSetup.js.
+ server.badCert = thisTry.selfSignedCert;
+ server.targetSite = thisTry.targetSite;
+ logger.info(
+ "CHOOSING " +
+ server.type +
+ " " +
+ server.hostname +
+ ":" +
+ server.port +
+ ", auth method " +
+ server.auth +
+ (server.authAlternatives.length
+ ? " " + server.authAlternatives.join(",")
+ : "") +
+ ", socketType " +
+ server.socketType +
+ (server.badCert ? " (bad cert!)" : "")
+ );
+ };
+
+ var outgoingSuccess = function (thisTry, alternativeTries) {
+ assert(thisTry.protocol == SMTP, "I only know SMTP for outgoing");
+ // Ensure there are no previously saved outgoing errors, if we've got
+ // success here.
+ outgoingEx = null;
+ HostTryToAccountServer(thisTry, resultConfig.outgoing);
+
+ for (let alternativeTry of alternativeTries) {
+ // resultConfig.createNewOutgoing(); misses username etc., so copy
+ let altServer = deepCopy(resultConfig.outgoing);
+ HostTryToAccountServer(alternativeTry, altServer);
+ assert(resultConfig.outgoingAlternatives);
+ resultConfig.outgoingAlternatives.push(altServer);
+ }
+
+ progressCallback(
+ resultConfig.outgoing.type,
+ resultConfig.outgoing.hostname,
+ resultConfig.outgoing.port,
+ resultConfig.outgoing.socketType,
+ true,
+ resultConfig
+ );
+ outgoingDone = true;
+ checkDone();
+ };
+
+ var incomingSuccess = function (thisTry, alternativeTries) {
+ // Ensure there are no previously saved incoming errors, if we've got
+ // success here.
+ incomingEx = null;
+ HostTryToAccountServer(thisTry, resultConfig.incoming);
+
+ for (let alternativeTry of alternativeTries) {
+ // resultConfig.createNewIncoming(); misses username etc., so copy
+ let altServer = deepCopy(resultConfig.incoming);
+ HostTryToAccountServer(alternativeTry, altServer);
+ assert(resultConfig.incomingAlternatives);
+ resultConfig.incomingAlternatives.push(altServer);
+ }
+
+ progressCallback(
+ resultConfig.incoming.type,
+ resultConfig.incoming.hostname,
+ resultConfig.incoming.port,
+ resultConfig.incoming.socketType,
+ true,
+ resultConfig
+ );
+ incomingDone = true;
+ checkDone();
+ };
+
+ var incomingError = function (ex) {
+ incomingEx = ex;
+ checkDone();
+ incomingHostDetector.cancel(new CancelOthersException());
+ outgoingHostDetector.cancel(new CancelOthersException());
+ };
+
+ var outgoingError = function (ex) {
+ outgoingEx = ex;
+ checkDone();
+ incomingHostDetector.cancel(new CancelOthersException());
+ outgoingHostDetector.cancel(new CancelOthersException());
+ };
+
+ incomingHostDetector = new IncomingHostDetector(
+ progress,
+ incomingSuccess,
+ incomingError
+ );
+ outgoingHostDetector = new OutgoingHostDetector(
+ progress,
+ outgoingSuccess,
+ outgoingError
+ );
+ if (which == "incoming" || which == "both") {
+ incomingHostDetector.start(
+ resultConfig.incoming.hostname ? resultConfig.incoming.hostname : domain,
+ !!resultConfig.incoming.hostname,
+ resultConfig.incoming.type,
+ resultConfig.incoming.port,
+ resultConfig.incoming.socketType,
+ resultConfig.incoming.auth
+ );
+ }
+ if (which == "outgoing" || which == "both") {
+ outgoingHostDetector.start(
+ resultConfig.outgoing.hostname ? resultConfig.outgoing.hostname : domain,
+ !!resultConfig.outgoing.hostname,
+ "smtp",
+ resultConfig.outgoing.port,
+ resultConfig.outgoing.socketType,
+ resultConfig.outgoing.auth
+ );
+ }
+
+ return new GuessAbortable(incomingHostDetector, outgoingHostDetector);
+}
+
+function GuessAbortable(incomingHostDetector, outgoingHostDetector) {
+ Abortable.call(this);
+ this._incomingHostDetector = incomingHostDetector;
+ this._outgoingHostDetector = outgoingHostDetector;
+}
+GuessAbortable.prototype = Object.create(Abortable.prototype);
+GuessAbortable.prototype.constructor = GuessAbortable;
+GuessAbortable.prototype.cancel = function (ex) {
+ this._incomingHostDetector.cancel(ex);
+ this._outgoingHostDetector.cancel(ex);
+};
+
+// --------------
+// Implementation
+
+// Objects, functions and constants that follow are not to be used outside
+// this file.
+var kNotTried = 0;
+var kOngoing = 1;
+var kFailed = 2;
+var kSuccess = 3;
+
+/**
+ * Internal object holding one server that we should try or did try.
+ * Used as |thisTry|.
+ *
+ * Note: The consts it uses for protocol is defined towards the end of this file
+ * and not the same as those used in AccountConfig (type). (fix
+ * this)
+ */
+function HostTry() {}
+HostTry.prototype = {
+ // IMAP, POP or SMTP
+ protocol: UNKNOWN,
+ // {String}
+ hostname: undefined,
+ // {Integer}
+ port: undefined,
+ // {nsMsgSocketType}
+ socketType: UNKNOWN,
+ // {String} what to send to server
+ commands: null,
+ // {Integer-enum} kNotTried, kOngoing, kFailed or kSuccess
+ status: kNotTried,
+ // {Abortable} allows to cancel the socket comm
+ abortable: null,
+
+ // {Array of {Integer-enum}} @see _advertisesAuthMethods() result
+ // Info about the server, from the protocol and SSL chat
+ authMethods: null,
+ // {String} Whether the SSL cert is not from a proper CA
+ selfSignedCert: false,
+ // {String} Which host the SSL cert is made for, if not hostname.
+ // If set, this is an SSL error.
+ targetSite: null,
+};
+
+/**
+ * When the success or errorCallbacks are called to abort the other requests
+ * which happened in parallel, this ex is used as param for cancel(), so that
+ * the cancel doesn't trigger another callback.
+ */
+function CancelOthersException() {
+ CancelledException.call(this, "we're done, cancelling the other probes");
+}
+CancelOthersException.prototype = Object.create(CancelledException.prototype);
+CancelOthersException.prototype.constructor = CancelOthersException;
+
+/**
+ * @param successCallback {function(result {HostTry}, alts {Array of HostTry})}
+ * Called when the config is OK
+ * |result| is the most preferred server.
+ * |alts| currently exists only for |IncomingHostDetector| and contains
+ * some servers of the other type (POP3 instead of IMAP), if available.
+ * @param errorCallback {function(ex)} Called when we could not find a config
+ * @param progressCallback { function(server {HostTry}) } Called when we tried
+ * (will try?) a new hostname and port
+ */
+function HostDetector(progressCallback, successCallback, errorCallback) {
+ this.mSuccessCallback = successCallback;
+ this.mProgressCallback = progressCallback;
+ this.mErrorCallback = errorCallback;
+ this._cancel = false;
+ // {Array of {HostTry}}, ordered by decreasing preference
+ this._hostsToTry = [];
+
+ // init logging
+ this._log = gAccountSetupLogger;
+ this._log.info("created host detector");
+}
+
+HostDetector.prototype = {
+ cancel(ex) {
+ this._cancel = true;
+ // We have to actively stop the network calls, as they may result in
+ // callbacks e.g. to the cert handler. If the dialog is gone by the time
+ // this happens, the javascript stack is horked.
+ for (let i = 0; i < this._hostsToTry.length; i++) {
+ let thisTry = this._hostsToTry[i]; // {HostTry}
+ if (thisTry.abortable) {
+ thisTry.abortable.cancel(ex);
+ }
+ thisTry.status = kFailed; // or don't set? Maybe we want to continue.
+ }
+ if (ex instanceof CancelOthersException) {
+ return;
+ }
+ if (!ex) {
+ ex = new CancelledException();
+ }
+ this.mErrorCallback(ex);
+ },
+
+ /**
+ * Start the detection.
+ *
+ * @param {string} domain - Domain to be used as base for guessing.
+ * Should be a domain (e.g. yahoo.co.uk).
+ * If hostIsPrecise == true, it should be a full hostname.
+ * @param {boolean} hostIsPrecise - If true, use only this hostname,
+ * do not guess hostnames.
+ * @param {"pop3"|"imap"|"exchange"|"smtp"|""} - Account type.
+ * @param {integer} port - The port to use. 0 to autodetect
+ * @param {nsMsgSocketType|-1} socketType - Socket type. -1 to autodetect.
+ * @param {nsMsgAuthMethod|0} authMethod - Authentication method. 0 to autodetect.
+ */
+ start(domain, hostIsPrecise, type, port, socketType, authMethod) {
+ domain = domain.replace(/\s*/g, ""); // Remove whitespace
+ if (!hostIsPrecise) {
+ hostIsPrecise = false;
+ }
+ var protocol = lazy.Sanitizer.translate(
+ type,
+ { imap: IMAP, pop3: POP, smtp: SMTP },
+ UNKNOWN
+ );
+ if (!port) {
+ port = UNKNOWN;
+ }
+ var ssl_only = Services.prefs.getBoolPref(
+ "mailnews.auto_config.guess.sslOnly"
+ );
+ this._cancel = false;
+ this._log.info(
+ `Starting ${protocol} detection on ${
+ !hostIsPrecise ? "~ " : ""
+ }${domain}:${port} with socketType=${socketType} and authMethod=${authMethod}`
+ );
+
+ // fill this._hostsToTry
+ this._hostsToTry = [];
+ var hostnamesToTry = [];
+ // if hostIsPrecise is true, it's because that's what the user input
+ // explicitly, and we'll just try it, nothing else.
+ if (hostIsPrecise) {
+ hostnamesToTry.push(domain);
+ } else {
+ hostnamesToTry = this._hostnamesToTry(protocol, domain);
+ }
+
+ for (let i = 0; i < hostnamesToTry.length; i++) {
+ let hostname = hostnamesToTry[i];
+ let hostEntries = this._portsToTry(hostname, protocol, socketType, port);
+ for (let j = 0; j < hostEntries.length; j++) {
+ let hostTry = hostEntries[j]; // from getHostEntry()
+ if (ssl_only && hostTry.socketType == NONE) {
+ continue;
+ }
+ hostTry.hostname = hostname;
+ hostTry.status = kNotTried;
+ hostTry.desc =
+ hostTry.hostname +
+ ":" +
+ hostTry.port +
+ " socketType=" +
+ hostTry.socketType +
+ " " +
+ protocolToString(hostTry.protocol);
+ hostTry.authMethod = authMethod;
+ this._hostsToTry.push(hostTry);
+ }
+ }
+
+ this._hostsToTry = sortTriesByPreference(this._hostsToTry);
+ this._tryAll();
+ },
+
+ // We make all host/port combinations run in parallel, store their
+ // results in an array, and as soon as one finishes successfully and all
+ // higher-priority ones have failed, we abort all lower-priority ones.
+
+ _tryAll() {
+ if (this._cancel) {
+ return;
+ }
+ var me = this;
+ var timeout = Services.prefs.getIntPref(
+ "mailnews.auto_config.guess.timeout"
+ );
+ // We assume we'll resolve the same proxy for all tries, and
+ // proceed to use the first resolved proxy for all tries. This
+ // assumption is generally sound, but not always: mechanisms like
+ // the pref network.proxy.no_proxies_on can make imap.domain and
+ // pop.domain resolve differently.
+ doProxy(this._hostsToTry[0].hostname, function (proxy) {
+ for (let i = 0; i < me._hostsToTry.length; i++) {
+ let thisTry = me._hostsToTry[i]; // {HostTry}
+ if (thisTry.status != kNotTried) {
+ continue;
+ }
+ me._log.info(thisTry.desc + ": initializing probe...");
+ if (i == 0) {
+ // showing 50 servers at once is pointless
+ me.mProgressCallback(thisTry);
+ }
+
+ thisTry.abortable = SocketUtil(
+ thisTry.hostname,
+ thisTry.port,
+ thisTry.socketType,
+ thisTry.commands,
+ timeout,
+ proxy,
+ new SSLErrorHandler(thisTry, me._log),
+ function (wiredata) {
+ // result callback
+ if (me._cancel) {
+ // Don't use response anymore.
+ return;
+ }
+ me.mProgressCallback(thisTry);
+ me._processResult(thisTry, wiredata);
+ me._checkFinished();
+ },
+ function (e) {
+ // error callback
+ if (me._cancel) {
+ // Who set cancel to true already called mErrorCallback().
+ return;
+ }
+ me._log.warn(thisTry.desc + ": " + e);
+ thisTry.status = kFailed;
+ me._checkFinished();
+ }
+ );
+ thisTry.status = kOngoing;
+ }
+ });
+ },
+
+ /**
+ * @param {HostTry} thisTry
+ * @param {string[]} wiredata - What the server returned in response to our protocol chat.
+ */
+ _processResult(thisTry, wiredata) {
+ if (thisTry._gotCertError) {
+ if (thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_MISMATCH) {
+ thisTry._gotCertError = 0;
+ thisTry.status = kFailed;
+ return;
+ }
+
+ if (
+ thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_UNTRUSTED ||
+ thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_TIME
+ ) {
+ this._log.info(
+ thisTry.desc + ": TRYING AGAIN, hopefully with exception recorded"
+ );
+ thisTry._gotCertError = 0;
+ thisTry.selfSignedCert = true; // _next_ run gets this exception
+ thisTry.status = kNotTried; // try again (with exception)
+ this._tryAll();
+ return;
+ }
+ }
+
+ if (wiredata == null || wiredata === undefined) {
+ this._log.info(thisTry.desc + ": no data");
+ thisTry.status = kFailed;
+ return;
+ }
+ this._log.info(thisTry.desc + ": wiredata: " + wiredata.join(""));
+ thisTry.authMethods = this._advertisesAuthMethods(
+ thisTry.protocol,
+ wiredata
+ );
+ if (
+ thisTry.socketType == STARTTLS &&
+ !this._hasSTARTTLS(thisTry, wiredata)
+ ) {
+ this._log.info(thisTry.desc + ": STARTTLS wanted, but not offered");
+ thisTry.status = kFailed;
+ return;
+ }
+ this._log.info(
+ thisTry.desc +
+ ": success" +
+ (thisTry.selfSignedCert ? " (selfSignedCert)" : "")
+ );
+ thisTry.status = kSuccess;
+
+ if (thisTry.selfSignedCert) {
+ // eh, ERROR_UNTRUSTED or ERROR_TIME
+ // We clear the temporary override now after success. If we clear it
+ // earlier we get into an infinite loop, probably because the cert
+ // remembering is temporary and the next try gets a new connection which
+ // isn't covered by that temporariness.
+ this._log.info(
+ thisTry.desc + ": clearing validity override for " + thisTry.hostname
+ );
+ Cc["@mozilla.org/security/certoverride;1"]
+ .getService(Ci.nsICertOverrideService)
+ .clearValidityOverride(thisTry.hostname, thisTry.port, {});
+ }
+ },
+
+ _checkFinished() {
+ var successfulTry = null;
+ var successfulTryAlternative = null; // POP3
+ var unfinishedBusiness = false;
+ // this._hostsToTry is ordered by decreasing preference
+ for (let i = 0; i < this._hostsToTry.length; i++) {
+ let thisTry = this._hostsToTry[i];
+ if (thisTry.status == kNotTried || thisTry.status == kOngoing) {
+ unfinishedBusiness = true;
+ } else if (thisTry.status == kSuccess && !unfinishedBusiness) {
+ // thisTry is good, and all higher preference tries failed, so use this
+ if (!successfulTry) {
+ successfulTry = thisTry;
+ if (successfulTry.protocol == SMTP) {
+ break;
+ }
+ } else if (successfulTry.protocol != thisTry.protocol) {
+ successfulTryAlternative = thisTry;
+ break;
+ }
+ }
+ }
+ if (successfulTry && (successfulTryAlternative || !unfinishedBusiness)) {
+ this.mSuccessCallback(
+ successfulTry,
+ successfulTryAlternative ? [successfulTryAlternative] : []
+ );
+ this.cancel(new CancelOthersException());
+ } else if (!unfinishedBusiness) {
+ // all failed
+ this._log.info("ran out of options");
+ var errorMsg = getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ ).GetStringFromName("cannot_find_server.error");
+ this.mErrorCallback(new Exception(errorMsg));
+ // no need to cancel, all failed
+ }
+ // else let ongoing calls continue
+ },
+
+ /**
+ * Which auth mechanism the server claims to support.
+ * That doesn't necessarily reflect reality, it is more an upper bound.
+ *
+ * @param {integer} protocol - IMAP, POP or SMTP
+ * @param {string[]} capaResponse - On the wire data that the server returned.
+ * May be the full exchange or just capa.
+ * @returns {nsMsgAuthMethod[]} Advertised authentication methods,
+ * in decreasing order of preference.
+ * E.g. [ nsMsgAuthMethod.GSSAPI, nsMsgAuthMethod.passwordEncrypted ]
+ * for a server that supports only Kerberos and encrypted passwords.
+ */
+ _advertisesAuthMethods(protocol, capaResponse) {
+ // For IMAP, capabilities include e.g.:
+ // "AUTH=CRAM-MD5", "AUTH=NTLM", "AUTH=GSSAPI", "AUTH=MSN", "AUTH=PLAIN"
+ // for POP3, the auth mechanisms are returned in capa as the following:
+ // "CRAM-MD5", "NTLM", "MSN", "GSSAPI"
+ // For SMTP, EHLO will return AUTH and then a list of the
+ // mechanism(s) supported, e.g.,
+ // AUTH LOGIN NTLM MSN CRAM-MD5 GSSAPI
+ var supported = new Set();
+ var line = capaResponse.join("\n").toUpperCase();
+ var prefix = "";
+ if (protocol == POP) {
+ prefix = "";
+ } else if (protocol == IMAP) {
+ prefix = "AUTH=";
+ } else if (protocol == SMTP) {
+ prefix = "AUTH.*";
+ } else {
+ throw NotReached("must pass protocol");
+ }
+ // add in decreasing order of preference
+ if (new RegExp(prefix + "GSSAPI").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.GSSAPI);
+ }
+ if (new RegExp(prefix + "CRAM-MD5").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.passwordEncrypted);
+ }
+ if (new RegExp(prefix + "(NTLM|MSN)").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.NTLM);
+ }
+ if (new RegExp(prefix + "LOGIN").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.passwordCleartext);
+ }
+ if (new RegExp(prefix + "PLAIN").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.passwordCleartext);
+ }
+ if (protocol != IMAP || !line.includes("LOGINDISABLED")) {
+ supported.add(Ci.nsMsgAuthMethod.passwordCleartext);
+ }
+ // The array elements will be in the Set's order of addition.
+ return Array.from(supported);
+ },
+
+ _hasSTARTTLS(thisTry, wiredata) {
+ var capa = thisTry.protocol == POP ? "STLS" : "STARTTLS";
+ return (
+ thisTry.socketType == STARTTLS &&
+ wiredata.join("").toUpperCase().includes(capa)
+ );
+ },
+};
+
+/**
+ * @param {nsMsgAuthMethod[]} authMethods - Authentication methods to choose from.
+ * See return value of _advertisesAuthMethods()
+ * Note: the returned auth method will be removed from the array.
+ * @returns {nsMsgAuthMethod} one of them, the preferred one
+ * Note: this might be Kerberos, which might not actually work,
+ * so you might need to try the others, too.
+ */
+function chooseBestAuthMethod(authMethods) {
+ if (!authMethods || !authMethods.length) {
+ return Ci.nsMsgAuthMethod.passwordCleartext;
+ }
+ return authMethods.shift(); // take first (= most preferred)
+}
+
+function IncomingHostDetector(
+ progressCallback,
+ successCallback,
+ errorCallback
+) {
+ HostDetector.call(this, progressCallback, successCallback, errorCallback);
+}
+IncomingHostDetector.prototype = {
+ __proto__: HostDetector.prototype,
+ _hostnamesToTry(protocol, domain) {
+ var hostnamesToTry = [];
+ if (protocol != POP) {
+ hostnamesToTry.push("imap." + domain);
+ }
+ if (protocol != IMAP) {
+ hostnamesToTry.push("pop3." + domain);
+ hostnamesToTry.push("pop." + domain);
+ }
+ hostnamesToTry.push("mail." + domain);
+ hostnamesToTry.push(domain);
+ return hostnamesToTry;
+ },
+ _portsToTry: getIncomingTryOrder,
+};
+
+function OutgoingHostDetector(
+ progressCallback,
+ successCallback,
+ errorCallback
+) {
+ HostDetector.call(this, progressCallback, successCallback, errorCallback);
+}
+OutgoingHostDetector.prototype = {
+ __proto__: HostDetector.prototype,
+ _hostnamesToTry(protocol, domain) {
+ var hostnamesToTry = [];
+ hostnamesToTry.push("smtp." + domain);
+ hostnamesToTry.push("mail." + domain);
+ hostnamesToTry.push(domain);
+ return hostnamesToTry;
+ },
+ _portsToTry: getOutgoingTryOrder,
+};
+
+// ---------------------------------------------
+// Encode protocol ports and order of preference
+
+// Protocol Types
+var UNKNOWN = -1;
+var IMAP = 0;
+var POP = 1;
+var SMTP = 2;
+// Security Types
+var NONE = Ci.nsMsgSocketType.plain;
+var STARTTLS = Ci.nsMsgSocketType.alwaysSTARTTLS;
+var SSL = Ci.nsMsgSocketType.SSL;
+
+var IMAP_PORTS = {};
+IMAP_PORTS[NONE] = 143;
+IMAP_PORTS[STARTTLS] = 143;
+IMAP_PORTS[SSL] = 993;
+
+var POP_PORTS = {};
+POP_PORTS[NONE] = 110;
+POP_PORTS[STARTTLS] = 110;
+POP_PORTS[SSL] = 995;
+
+var SMTP_PORTS = {};
+SMTP_PORTS[NONE] = 587;
+SMTP_PORTS[STARTTLS] = 587;
+SMTP_PORTS[SSL] = 465;
+
+var CMDS = {};
+CMDS[IMAP] = ["1 CAPABILITY\r\n", "2 LOGOUT\r\n"];
+CMDS[POP] = ["CAPA\r\n", "QUIT\r\n"];
+CMDS[SMTP] = ["EHLO we-guess.mozilla.org\r\n", "QUIT\r\n"];
+
+/**
+ * Sort by preference of SSL, IMAP etc.
+ *
+ * @param tries {Array of {HostTry}}
+ * @returns {Array of {HostTry}}
+ */
+function sortTriesByPreference(tries) {
+ return tries.sort(function (a, b) {
+ // -1 = a is better; 1 = b is better; 0 = equal
+ // Prefer SSL/STARTTLS above all else
+ if (a.socketType != NONE && b.socketType == NONE) {
+ return -1;
+ }
+ if (b.socketType != NONE && a.socketType == NONE) {
+ return 1;
+ }
+ // Prefer IMAP over POP
+ if (a.protocol == IMAP && b.protocol == POP) {
+ return -1;
+ }
+ if (b.protocol == IMAP && a.protocol == POP) {
+ return 1;
+ }
+ // Prefer SSL/TLS over STARTTLS
+ if (a.socketType == SSL && b.socketType == STARTTLS) {
+ return -1;
+ }
+ if (a.socketType == STARTTLS && b.socketType == SSL) {
+ return 1;
+ }
+ // For hostnames, leave existing sorting, as in _hostnamesToTry()
+ // For ports, leave existing sorting, as in getOutgoingTryOrder()
+ return 0;
+ });
+}
+
+/**
+ * @returns {HostTry[]} Hosts to try.
+ */
+function getIncomingTryOrder(host, protocol, socketType, port) {
+ var lowerCaseHost = host.toLowerCase();
+
+ if (
+ protocol == UNKNOWN &&
+ (lowerCaseHost.startsWith("pop.") || lowerCaseHost.startsWith("pop3."))
+ ) {
+ protocol = POP;
+ } else if (protocol == UNKNOWN && lowerCaseHost.startsWith("imap.")) {
+ protocol = IMAP;
+ }
+
+ if (protocol != UNKNOWN) {
+ if (socketType == UNKNOWN) {
+ return [
+ getHostEntry(protocol, STARTTLS, port),
+ getHostEntry(protocol, SSL, port),
+ getHostEntry(protocol, NONE, port),
+ ];
+ }
+ return [getHostEntry(protocol, socketType, port)];
+ }
+ if (socketType == UNKNOWN) {
+ return [
+ getHostEntry(IMAP, STARTTLS, port),
+ getHostEntry(IMAP, SSL, port),
+ getHostEntry(POP, STARTTLS, port),
+ getHostEntry(POP, SSL, port),
+ getHostEntry(IMAP, NONE, port),
+ getHostEntry(POP, NONE, port),
+ ];
+ }
+ return [
+ getHostEntry(IMAP, socketType, port),
+ getHostEntry(POP, socketType, port),
+ ];
+}
+
+/**
+ * @returns {Array of {HostTry}}
+ */
+function getOutgoingTryOrder(host, protocol, socketType, port) {
+ assert(protocol == SMTP, "need SMTP as protocol for outgoing");
+ if (socketType == UNKNOWN) {
+ if (port == UNKNOWN) {
+ // neither SSL nor port known
+ return [
+ getHostEntry(SMTP, STARTTLS, UNKNOWN),
+ getHostEntry(SMTP, STARTTLS, 25),
+ getHostEntry(SMTP, SSL, UNKNOWN),
+ getHostEntry(SMTP, NONE, UNKNOWN),
+ getHostEntry(SMTP, NONE, 25),
+ ];
+ }
+ // port known, SSL not
+ return [
+ getHostEntry(SMTP, STARTTLS, port),
+ getHostEntry(SMTP, SSL, port),
+ getHostEntry(SMTP, NONE, port),
+ ];
+ }
+ // SSL known, port not
+ if (port == UNKNOWN) {
+ if (socketType == SSL) {
+ return [getHostEntry(SMTP, SSL, UNKNOWN)];
+ }
+ return [
+ getHostEntry(SMTP, socketType, UNKNOWN),
+ getHostEntry(SMTP, socketType, 25),
+ ];
+ }
+ // SSL and port known
+ return [getHostEntry(SMTP, socketType, port)];
+}
+
+/**
+ * @returns {HostTry} with proper default port and commands,
+ * but without hostname.
+ */
+function getHostEntry(protocol, socketType, port) {
+ if (!port || port == UNKNOWN) {
+ switch (protocol) {
+ case POP:
+ port = POP_PORTS[socketType];
+ break;
+ case IMAP:
+ port = IMAP_PORTS[socketType];
+ break;
+ case SMTP:
+ port = SMTP_PORTS[socketType];
+ break;
+ default:
+ throw new NotReached("unsupported protocol " + protocol);
+ }
+ }
+
+ var r = new HostTry();
+ r.protocol = protocol;
+ r.socketType = socketType;
+ r.port = port;
+ r.commands = CMDS[protocol];
+ return r;
+}
+
+// here -> AccountConfig
+function protocolToString(type) {
+ if (type == IMAP) {
+ return "imap";
+ }
+ if (type == POP) {
+ return "pop3";
+ }
+ if (type == SMTP) {
+ return "smtp";
+ }
+ throw new NotReached("unexpected protocol");
+}
+
+// ----------------------
+// SSL cert error handler
+
+/**
+ * @param thisTry {HostTry}
+ * @param logger {ConsoleAPI}
+ */
+function SSLErrorHandler(thisTry, logger) {
+ this._try = thisTry;
+ this._log = logger;
+ // _ gotCertError will be set to an error code (one of those defined in
+ // nsICertOverrideService)
+ this._gotCertError = 0;
+}
+SSLErrorHandler.prototype = {
+ processCertError(secInfo, targetSite) {
+ this._log.error("Got Cert error for " + targetSite);
+
+ if (!secInfo) {
+ return;
+ }
+
+ let cert = secInfo.serverCert;
+
+ let parts = targetSite.split(":");
+ let host = parts[0];
+ let port = parts[1];
+
+ /* The following 2 cert problems are unfortunately common:
+ * 1) hostname mismatch:
+ * user is customer at a domain hoster, he owns yourname.org,
+ * and the IMAP server is imap.hoster.com (but also reachable as
+ * imap.yourname.org), and has a cert for imap.hoster.com.
+ * 2) self-signed:
+ * a company has an internal IMAP server, and it's only for
+ * 30 employees, and they didn't want to buy a cert, so
+ * they use a self-signed cert.
+ *
+ * We would like the above to pass, somehow, with user confirmation.
+ * The following case should *not* pass:
+ *
+ * 1) MITM
+ * User has @gmail.com, and an attacker is between the user and
+ * the Internet and runs a man-in-the-middle (MITM) attack.
+ * Attacker controls DNS and sends imap.gmail.com to his own
+ * imap.attacker.com. He has either a valid, CA-issued
+ * cert for imap.attacker.com, or a self-signed cert.
+ * Of course, attacker.com could also be legit-sounding gmailservers.com.
+ *
+ * What makes it dangerous is that we (!) propose the server to the user,
+ * and he cannot judge whether imap.gmailservers.com is correct or not,
+ * and he will likely approve it.
+ */
+
+ if (secInfo.isDomainMismatch) {
+ this._try._gotCertError = Ci.nsICertOverrideService.ERROR_MISMATCH;
+ } else if (secInfo.isUntrusted) {
+ // e.g. self-signed
+ this._try._gotCertError = Ci.nsICertOverrideService.ERROR_UNTRUSTED;
+ } else if (secInfo.isNotValidAtThisTime) {
+ this._try._gotCertError = Ci.nsICertOverrideService.ERROR_TIME;
+ } else {
+ this._try._gotCertError = -1; // other
+ }
+
+ /* We will add a temporary cert exception here, so that
+ * we can continue and connect and try.
+ * But we will remove it again as soon as we close the
+ * connection, in _processResult().
+ * _gotCertError will serve as the marker that we
+ * have to clear the override later.
+ *
+ * In verifyConfig(), before we send the password, we *must*
+ * get another cert exception, this time with dialog to the user
+ * so that he gets informed about this and can make a choice.
+ */
+ this._try.targetSite = targetSite;
+ Cc["@mozilla.org/security/certoverride;1"]
+ .getService(Ci.nsICertOverrideService)
+ .rememberValidityOverride(host, port, {}, cert, true); // temporary override
+ this._log.warn(`Added temporary override of bad cert for: ${host}:${port}`);
+ },
+};
+
+// -----------
+// Socket Util
+
+/**
+ * @param hostname {String} The DNS hostname to connect to.
+ * @param port {Integer} The numeric port to connect to on the host.
+ * @param socketType {nsMsgSocketType} SSL, STARTTLS or NONE
+ * @param commands {Array of String}: protocol commands
+ * to send to the server.
+ * @param timeout {Integer} seconds to wait for a server response, then cancel.
+ * @param proxy {nsIProxyInfo} The proxy to use (or null to not use any).
+ * @param sslErrorHandler {SSLErrorHandler}
+ * @param resultCallback {function(wiredata)} This function will
+ * be called with the result string array from the server
+ * or null if no communication occurred.
+ * @param errorCallback {function(e)}
+ */
+function SocketUtil(
+ hostname,
+ port,
+ socketType,
+ commands,
+ timeout,
+ proxy,
+ sslErrorHandler,
+ resultCallback,
+ errorCallback
+) {
+ assert(commands && commands.length, "need commands");
+
+ var index = 0; // commands[index] is next to send to server
+ var initialized = false;
+ var aborted = false;
+
+ function _error(e) {
+ if (aborted) {
+ return;
+ }
+ aborted = true;
+ errorCallback(e);
+ }
+
+ function timeoutFunc() {
+ if (!initialized) {
+ _error("timeout");
+ }
+ }
+
+ // In case DNS takes too long or does not resolve or another blocking
+ // issue occurs before the timeout can be set on the socket, this
+ // ensures that the listener callback will be fired in a timely manner.
+ // XXX There might to be some clean up needed after the timeout is fired
+ // for socket and io resources.
+
+ // The timeout value plus 2 seconds
+ setTimeout(timeoutFunc, timeout * 1000 + 2000);
+
+ var transportService = Cc[
+ "@mozilla.org/network/socket-transport-service;1"
+ ].getService(Ci.nsISocketTransportService);
+
+ // @see NS_NETWORK_SOCKET_CONTRACTID_PREFIX
+ var socketTypeName;
+ if (socketType == SSL) {
+ socketTypeName = ["ssl"];
+ } else if (socketType == STARTTLS) {
+ socketTypeName = ["starttls"];
+ } else {
+ socketTypeName = [];
+ }
+ var transport = transportService.createTransport(
+ socketTypeName,
+ hostname,
+ port,
+ proxy,
+ null
+ );
+
+ transport.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, timeout);
+ transport.setTimeout(Ci.nsISocketTransport.TIMEOUT_READ_WRITE, timeout);
+
+ var outstream = transport.openOutputStream(0, 0, 0);
+ var stream = transport.openInputStream(0, 0, 0);
+ var instream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ instream.init(stream);
+
+ var dataListener = {
+ data: [],
+ onStartRequest(request) {
+ try {
+ initialized = true;
+ if (!aborted) {
+ // Send the first request
+ let outputData = commands[index++];
+ outstream.write(outputData, outputData.length);
+ }
+ } catch (e) {
+ _error(e);
+ }
+ },
+ async onStopRequest(request, status) {
+ try {
+ instream.close();
+ outstream.close();
+ // Did it fail because of a bad certificate?
+ let isCertError = false;
+ if (!Components.isSuccessCode(status)) {
+ let nssErrorsService = Cc[
+ "@mozilla.org/nss_errors_service;1"
+ ].getService(Ci.nsINSSErrorsService);
+ try {
+ let errorType = nssErrorsService.getErrorClass(status);
+ if (errorType == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ isCertError = true;
+ }
+ } catch (e) {
+ // nsINSSErrorsService.getErrorClass throws if given a non-STARTTLS,
+ // non-cert error, so ignore this.
+ }
+ }
+ if (isCertError) {
+ if (
+ Services.prefs.getBoolPref(
+ "mailnews.auto_config.guess.requireGoodCert",
+ true
+ )
+ ) {
+ gAccountSetupLogger.info(
+ `Bad (overridable) certificate for ${hostname}:${port}. Set mailnews.auto_config.guess.requireGoodCert to false to allow detecting this as a valid SSL/TLS configuration`
+ );
+ } else {
+ let socketTransport = transport.QueryInterface(
+ Ci.nsISocketTransport
+ );
+ let secInfo =
+ await socketTransport.tlsSocketControl?.asyncGetSecurityInfo();
+ sslErrorHandler.processCertError(secInfo, hostname + ":" + port);
+ }
+ }
+ resultCallback(this.data.length ? this.data : null);
+ } catch (e) {
+ _error(e);
+ }
+ },
+ onDataAvailable(request, inputStream, offset, count) {
+ try {
+ if (!aborted) {
+ let inputData = instream.read(count);
+ this.data.push(inputData);
+ if (index < commands.length) {
+ // Send the next request to the server.
+ let outputData = commands[index++];
+ outstream.write(outputData, outputData.length);
+ }
+ }
+ } catch (e) {
+ _error(e);
+ }
+ },
+ };
+
+ try {
+ var pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance(
+ Ci.nsIInputStreamPump
+ );
+
+ pump.init(stream, 0, 0, false);
+ pump.asyncRead(dataListener);
+ return new SocketAbortable(transport);
+ } catch (e) {
+ _error(e);
+ }
+ return null;
+}
+
+function SocketAbortable(transport) {
+ Abortable.call(this);
+ assert(transport instanceof Ci.nsITransport, "need transport");
+ this._transport = transport;
+}
+SocketAbortable.prototype = Object.create(Abortable.prototype);
+SocketAbortable.prototype.constructor = UserCancelledException;
+SocketAbortable.prototype.cancel = function (ex) {
+ try {
+ this._transport.close(Cr.NS_ERROR_ABORT);
+ } catch (e) {
+ ddump("canceling socket failed: " + e);
+ }
+};
+
+/**
+ * Resolve a proxy for some domain and expose it via a callback.
+ *
+ * @param hostname {String} The hostname which a proxy will be resolved for
+ * @param resultCallback {function(proxyInfo)}
+ * Called after the proxy has been resolved for hostname.
+ * proxy {nsIProxyInfo} The resolved proxy, or null if none were found
+ * for hostname
+ */
+function doProxy(hostname, resultCallback) {
+ // This implements the nsIProtocolProxyCallback interface:
+ function ProxyResolveCallback() {}
+ ProxyResolveCallback.prototype = {
+ onProxyAvailable(req, uri, proxy, status) {
+ // Anything but a SOCKS proxy will be unusable for email.
+ if (proxy != null && proxy.type != "socks" && proxy.type != "socks4") {
+ proxy = null;
+ }
+ resultCallback(proxy);
+ },
+ };
+ var proxyService = Cc[
+ "@mozilla.org/network/protocol-proxy-service;1"
+ ].getService(Ci.nsIProtocolProxyService);
+ // Use some arbitrary scheme just because it is required...
+ var uri = Services.io.newURI("http://" + hostname);
+ // ... we'll ignore it any way. We prefer SOCKS since that's the
+ // only thing we can use for email protocols.
+ var proxyFlags =
+ Ci.nsIProtocolProxyService.RESOLVE_IGNORE_URI_SCHEME |
+ Ci.nsIProtocolProxyService.RESOLVE_PREFER_SOCKS_PROXY;
+ if (Services.prefs.getBoolPref("network.proxy.socks_remote_dns")) {
+ proxyFlags |= Ci.nsIProtocolProxyService.RESOLVE_ALWAYS_TUNNEL;
+ }
+ proxyService.asyncResolve(uri, proxyFlags, new ProxyResolveCallback());
+}
+
+var GuessConfig = {
+ UNKNOWN,
+ IMAP,
+ POP,
+ SMTP,
+ NONE,
+ STARTTLS,
+ SSL,
+ getHostEntry,
+ getIncomingTryOrder,
+ getOutgoingTryOrder,
+ guessConfig,
+};
diff --git a/comm/mail/components/accountcreation/Sanitizer.jsm b/comm/mail/components/accountcreation/Sanitizer.jsm
new file mode 100644
index 0000000000..d6bc3918bc
--- /dev/null
+++ b/comm/mail/components/accountcreation/Sanitizer.jsm
@@ -0,0 +1,249 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["Sanitizer"];
+
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+
+const { cleanUpHostName, isLegalHostNameOrIP } = ChromeUtils.import(
+ "resource:///modules/hostnameUtils.jsm"
+);
+
+/**
+ * This is a generic input validation lib. Use it when you process
+ * data from the network.
+ *
+ * Just a few functions which verify, for security purposes, that the
+ * input variables (strings, if nothing else is noted) are of the expected
+ * type and syntax.
+ *
+ * The functions take a string (unless noted otherwise) and return
+ * the expected datatype in JS types. If the value is not as expected,
+ * they throw exceptions.
+ */
+
+// To debug, set mail.setup.loglevel="All" and kDebug = true.
+var kDebug = false;
+
+var Sanitizer = {
+ integer(unchecked) {
+ if (typeof unchecked == "number" && !isNaN(unchecked)) {
+ return unchecked;
+ }
+
+ var r = parseInt(unchecked);
+ if (isNaN(r)) {
+ throw new MalformedException("no_number.error", unchecked);
+ }
+
+ return r;
+ },
+
+ integerRange(unchecked, min, max) {
+ var int = this.integer(unchecked);
+ if (int < min) {
+ throw new MalformedException("number_too_small.error", unchecked);
+ }
+
+ if (int > max) {
+ throw new MalformedException("number_too_large.error", unchecked);
+ }
+
+ return int;
+ },
+
+ boolean(unchecked) {
+ if (typeof unchecked == "boolean") {
+ return unchecked;
+ }
+
+ if (unchecked == "true") {
+ return true;
+ }
+
+ if (unchecked == "false") {
+ return false;
+ }
+
+ throw new MalformedException("boolean.error", unchecked);
+ },
+
+ string(unchecked) {
+ return String(unchecked);
+ },
+
+ nonemptystring(unchecked) {
+ if (!unchecked) {
+ throw new MalformedException("string_empty.error", unchecked);
+ }
+
+ return this.string(unchecked);
+ },
+
+ /**
+ * Allow only letters, numbers, "-" and "_".
+ *
+ * Empty strings not allowed (good idea?).
+ */
+ alphanumdash(unchecked) {
+ var str = this.nonemptystring(unchecked);
+ if (!/^[a-zA-Z0-9\-\_]*$/.test(str)) {
+ throw new MalformedException("alphanumdash.error", unchecked);
+ }
+
+ return str;
+ },
+
+ /**
+ * DNS hostnames like foo.bar.example.com
+ * Allow only letters, numbers, "-" and "."
+ * Empty strings not allowed.
+ * Currently does not support IDN (international domain names).
+ */
+ hostname(unchecked) {
+ let str = cleanUpHostName(this.nonemptystring(unchecked));
+
+ // Allow placeholders. TODO move to a new hostnameOrPlaceholder()
+ // The regex is "anything, followed by one or more (placeholders than
+ // anything)". This doesn't catch the non-placeholder case, but that's
+ // handled down below.
+ if (/^[a-zA-Z0-9\-\.]*(%[A-Z0-9]+%[a-zA-Z0-9\-\.]*)+$/.test(str)) {
+ return str;
+ }
+
+ if (!isLegalHostNameOrIP(str)) {
+ throw new MalformedException("hostname_syntax.error", unchecked);
+ }
+
+ return str.toLowerCase();
+ },
+
+ /**
+ * A value which resembles an email address.
+ */
+ emailAddress(unchecked) {
+ let str = this.nonemptystring(unchecked);
+ if (!/^[a-z0-9\-%+_\.\*]+@[a-z0-9\-\.]+\.[a-z]+$/i.test(str)) {
+ throw new MalformedException("emailaddress_syntax.error", unchecked);
+ }
+
+ return str.toLowerCase();
+ },
+
+ /**
+ * A non-chrome URL that's safe to request.
+ */
+ url(unchecked) {
+ var str = this.string(unchecked);
+
+ // DANGER ZONE: data:text/javascript or data:text/html can contain
+ // JavaScript code, run in the caller's security context, and might allow
+ // arbitrary code execution, so these must be prevented at all costs.
+ // PNG and JPEG data: URLs are fine. But SVG is again dangerous,
+ // it can contain javascript, so it would create a critical security hole.
+ // Talk to BenB or bz before relaxing *any* of the checks in this function.
+ if (
+ str.startsWith("data:image/png;") ||
+ str.startsWith("data:image/jpeg;")
+ ) {
+ return new URL(str).href;
+ }
+
+ if (!str.startsWith("http:") && !str.startsWith("https:")) {
+ throw new MalformedException("url_scheme.error", unchecked);
+ }
+
+ var uri;
+ try {
+ uri = Services.io.newURI(str);
+ uri = uri.QueryInterface(Ci.nsIURL);
+ } catch (e) {
+ throw new MalformedException("url_parsing.error", unchecked);
+ }
+
+ if (uri.scheme != "http" && uri.scheme != "https") {
+ throw new MalformedException("url_scheme.error", unchecked);
+ }
+
+ return uri.spec;
+ },
+
+ /**
+ * A value which should be shown to the user in the UI as label
+ */
+ label(unchecked) {
+ return this.string(unchecked);
+ },
+
+ /**
+ * Allows only certain values as input, otherwise throw.
+ *
+ * @param unchecked {Any} The value to check
+ * @param allowedValues {Array} List of values that |unchecked| may have.
+ * @param defaultValue {Any} (Optional) If |unchecked| does not match
+ * anything in |mapping|, a |defaultValue| can be returned instead of
+ * throwing an exception. The latter is the default and happens when
+ * no |defaultValue| is passed.
+ * @throws MalformedException
+ */
+ enum(unchecked, allowedValues, defaultValue) {
+ for (let allowedValue of allowedValues) {
+ if (allowedValue == unchecked) {
+ return allowedValue;
+ }
+ }
+ // value is bad
+ if (typeof defaultValue == "undefined") {
+ throw new MalformedException("allowed_value.error", unchecked);
+ }
+ return defaultValue;
+ },
+
+ /**
+ * Like enum, allows only certain (string) values as input, but allows the
+ * caller to specify another value to return instead of the input value. E.g.,
+ * if unchecked == "foo", return 1, if unchecked == "bar", return 2,
+ * otherwise throw. This allows to translate string enums into integer enums.
+ *
+ * @param unchecked {Any} The value to check
+ * @param mapping {Object} Associative array. property name is the input
+ * value, property value is the output value. E.g. the example above
+ * would be: { foo: 1, bar : 2 }.
+ * Use quotes when you need freaky characters: "baz-" : 3.
+ * @param defaultValue {Any} (Optional) If |unchecked| does not match
+ * anything in |mapping|, a |defaultValue| can be returned instead of
+ * throwing an exception. The latter is the default and happens when
+ * no |defaultValue| is passed.
+ * @throws MalformedException
+ */
+ translate(unchecked, mapping, defaultValue) {
+ for (var inputValue in mapping) {
+ if (inputValue == unchecked) {
+ return mapping[inputValue];
+ }
+ }
+ // value is bad
+ if (typeof defaultValue == "undefined") {
+ throw new MalformedException("allowed_value.error", unchecked);
+ }
+ return defaultValue;
+ },
+};
+
+function MalformedException(msgID, uncheckedBadValue) {
+ var stringBundle = AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationUtil.properties"
+ );
+ var msg = stringBundle.GetStringFromName(msgID);
+ if (typeof kDebug != "undefined" && kDebug) {
+ msg += " (bad value: " + uncheckedBadValue + ")";
+ }
+ AccountCreationUtils.Exception.call(this, msg);
+}
+MalformedException.prototype = Object.create(
+ AccountCreationUtils.Exception.prototype
+);
+MalformedException.prototype.constructor = MalformedException;
diff --git a/comm/mail/components/accountcreation/content/accountHub.js b/comm/mail/components/accountcreation/content/accountHub.js
new file mode 100644
index 0000000000..a703ffce16
--- /dev/null
+++ b/comm/mail/components/accountcreation/content/accountHub.js
@@ -0,0 +1,277 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Holds the main controller class.
+ *
+ * @type {?AccountHubControllerClass}
+ */
+var AccountHubController;
+
+/**
+ * Controller class to handle the primary views of the account setup flow.
+ * This class acts as a sort of controller to lazily load the needed views upon
+ * request. It doesn't handle any data and it should only be used to switch
+ * between the different setup flows.
+ * All methods of this class should be private, except for the open() method.
+ */
+class AccountHubControllerClass {
+ /**
+ * The account hub main modal dialog.
+ *
+ * @type {?HTMLElement}
+ */
+ #modal = null;
+
+ /**
+ * The currently visible view inside the dialog.
+ *
+ * @type {?HTMLElement}
+ */
+ #currentView = null;
+
+ /**
+ * Object containing all strings to trigger the needed methods for the various
+ * views.
+ */
+ #accounts = {
+ START: () => this.#viewStart(),
+ MAIL: () => this.#viewEmailSetup(),
+ CALENDAR: () => this.#viewCalendarSetup(),
+ ADDRESS_BOOK: () => this.#viewAddressBookSetup(),
+ CHAT: () => this.#viewChatSetup(),
+ FEED: () => this.#viewFeedSetup(),
+ NNTP: () => this.#viewNNTPSetup(),
+ IMPORT: () => this.#viewImportSetup(),
+ };
+
+ constructor() {
+ this.ready = this.#init();
+ }
+
+ async #init() {
+ await this.#loadScript("container");
+ const element = document.createElement("account-hub-container");
+ document.body.appendChild(element);
+ this.#modal = element.modal;
+
+ let closeButton = this.#modal.querySelector("#closeButton");
+ closeButton.hidden = !MailServices.accounts.accounts.length;
+ closeButton.addEventListener("click", () => this.#modal.close());
+
+ // Listen from closing requests coming from child elements.
+ this.#modal.addEventListener(
+ "request-close",
+ event => {
+ event.stopPropagation();
+ this.#modal.close();
+ },
+ {
+ capture: true,
+ }
+ );
+
+ this.#modal.addEventListener(
+ "open-view",
+ event => {
+ event.stopPropagation();
+ this.open(event.detail.type);
+ },
+ {
+ capture: true,
+ }
+ );
+
+ this.#modal.addEventListener("close", event => {
+ // Don't allow the dialog to be closed if some operations are can't be
+ // aborted or the UI can't be cleared.
+ if (!this.#reset()) {
+ event.preventDefault();
+ }
+ });
+
+ this.#modal.addEventListener("cancel", event => {
+ if (
+ !MailServices.accounts.accounts.length &&
+ !Services.prefs.getBoolPref("app.use_without_mail_account", false)
+ ) {
+ // Prevent closing the modal if no account is currently present and the
+ // user didn't request using Thunderbird without an email account.
+ event.preventDefault();
+ return;
+ }
+
+ // Don't allow the dialog to be canceled via the ESC key if some
+ // operations are in progress and can't be aborted or the UI can't be
+ // cleared.
+ if (!this.#reset()) {
+ event.preventDefault();
+ }
+ });
+ }
+
+ /**
+ * Check if we don't currently have the needed custom element for the
+ * requested view and load the needed script. We do this to avoid loading all
+ * the unnecessary account creation files.
+ *
+ * @param {string} view - The name of the view to load.
+ * @returns {Promise<void>} Resolves when custom element of the view is usable.
+ */
+ #loadScript(view) {
+ if (customElements.get(`account-hub-${view}`)) {
+ return Promise.resolve();
+ }
+ // eslint-disable-next-line no-unsanitized/method
+ return import(
+ `chrome://messenger/content/accountcreation/views/${view}.mjs`
+ );
+ }
+
+ /**
+ * Create a custom element and append it to the modal inner HTML, or simply
+ * show it if it was already loaded.
+ *
+ * @param {string} id - The ID of the template to clone.
+ */
+ #loadView(id) {
+ this.#hideViews();
+
+ let view = this.#modal.querySelector(id);
+ if (view) {
+ view.hidden = false;
+ this.#currentView = view;
+ // Update the UI to make sure we're refreshing old views.
+ this.#currentView.initUI();
+ return;
+ }
+
+ view = document.createElement(id);
+ this.#modal.appendChild(view);
+ this.#currentView = view;
+ }
+
+ /**
+ * Hide all the currently visible views.
+ */
+ #hideViews() {
+ for (let view of this.#modal.querySelectorAll(".account-hub-view")) {
+ view.hidden = true;
+ }
+ }
+
+ /**
+ * Open the main modal dialog and load the requested account setup view, or
+ * fallback to the initial start screen.
+ *
+ * @param {?string} type - Which account flow to load when the modal opens.
+ */
+ open(type = "START") {
+ // Interrupt if something went wrong while cleaning up a previously loaded
+ // view.
+ if (!this.#reset()) {
+ return;
+ }
+
+ this.#accounts[type].call();
+ if (!this.#modal.open) {
+ this.#modal.showModal();
+ }
+ }
+
+ /**
+ * Check if we have a current class and try to trigger the rest in order to
+ * handle abort operations and markup clean up, if possible.
+ *
+ * @returns {boolean} - True if the reset process was successful or we didn't
+ * have anything to reset.
+ */
+ #reset() {
+ let isClean = this.#currentView?.reset() ?? true;
+ // If the reset operation was successful, clear the current class.
+ if (isClean) {
+ this.#hideViews();
+ this.#currentView = null;
+ }
+ return isClean;
+ }
+
+ /**
+ * Show the initial view of the account hub dialog.
+ */
+ async #viewStart() {
+ await this.#loadScript("start");
+ this.#loadView("account-hub-start");
+ }
+
+ /**
+ * Show the email setup view.
+ */
+ async #viewEmailSetup() {
+ await this.#loadScript("email");
+ this.#loadView("account-hub-email");
+ }
+
+ /**
+ * TODO: Show the calendar setup view.
+ */
+ #viewCalendarSetup() {
+ console.log("Calendar setup");
+ }
+
+ /**
+ * TODO: Show the address book setup view.
+ */
+ #viewAddressBookSetup() {
+ console.log("Address Book setup");
+ }
+
+ /**
+ * TODO: Show the chat setup view.
+ */
+ #viewChatSetup() {
+ console.log("Chat setup");
+ }
+
+ /**
+ * TODO: Show the feed setup view.
+ */
+ #viewFeedSetup() {
+ console.log("Feed setup");
+ }
+
+ /**
+ * TODO: Show the newsgroup setup view.
+ */
+ #viewNNTPSetup() {
+ console.log("Newsgroup setup");
+ }
+
+ /**
+ * TODO: Show the import setup view.
+ */
+ #viewImportSetup() {
+ console.log("Import setup");
+ }
+}
+
+/**
+ * Open the account hub dialog and show the requested view.
+ *
+ * @param {?string} type - The type of view that should be loaded when the modal
+ * is showed. See AccountHubController::#accounts for a list references.
+ */
+async function openAccountHub(type) {
+ if (!AccountHubController) {
+ AccountHubController = new AccountHubControllerClass();
+ }
+ await AccountHubController.ready;
+ AccountHubController.open(type);
+}
diff --git a/comm/mail/components/accountcreation/content/accountSetup.js b/comm/mail/components/accountcreation/content/accountSetup.js
new file mode 100644
index 0000000000..3a214f2292
--- /dev/null
+++ b/comm/mail/components/accountcreation/content/accountSetup.js
@@ -0,0 +1,3023 @@
+/* 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 MozElements */
+
+/* import-globals-from ../../../../mailnews/base/prefs/content/accountUtils.js */
+var { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+var { fetchConfigFromExchange, getAddonsList } = ChromeUtils.import(
+ "resource:///modules/accountcreation/ExchangeAutoDiscover.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AccountConfig: "resource:///modules/accountcreation/AccountConfig.jsm",
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ CardDAVUtils: "resource:///modules/CardDAVUtils.jsm",
+ ConfigVerifier: "resource:///modules/accountcreation/ConfigVerifier.jsm",
+ CreateInBackend: "resource:///modules/accountcreation/CreateInBackend.jsm",
+ FetchConfig: "resource:///modules/accountcreation/FetchConfig.jsm",
+ GuessConfig: "resource:///modules/accountcreation/GuessConfig.jsm",
+ OAuth2Providers: "resource:///modules/OAuth2Providers.jsm",
+ Sanitizer: "resource:///modules/accountcreation/Sanitizer.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+});
+
+var {
+ Abortable,
+ AddonInstaller,
+ alertPrompt,
+ assert,
+ CancelledException,
+ Exception,
+ gAccountSetupLogger,
+ NotReached,
+ PriorityOrderAbortable,
+ UserCancelledException,
+} = AccountCreationUtils;
+
+/**
+ * This is the dialog opened by menu File | New account | Mail... .
+ *
+ * It gets the user's realname, email address and password,
+ * and tries to automatically configure the account from that,
+ * using various mechanisms. If all fails, the user can enter/edit
+ * the config, then we create the account.
+ *
+ * Steps:
+ * - User enters realname, email address and password
+ * - check for config files on disk
+ * (shipping with Thunderbird, for enterprise deployments)
+ * - (if fails) try to get the config file from the ISP via a
+ * fixed URL on the domain of the email address
+ * - (if fails) try to get the config file from our own database
+ * at MoMo servers, maintained by the community
+ * - (if fails) try to guess the config, by guessing hostnames,
+ * probing ports, checking config via server's CAPS line etc..
+ * - verify the setup, by trying to login to the configured servers
+ * - let user verify and maybe edit the server names and ports
+ * - If user clicks OK, create the account
+ */
+
+// Keep track of the prefers-reduce-motion media query for JS based animations.
+var gReducedMotion;
+
+// The main 3 Pane Window that we need to define on load in order to properly
+// update the UI when a new account is created.
+var gMainWindow;
+
+// Define standard incoming port numbers.
+var gStandardPorts = {
+ imap: [143, 993],
+ pop3: [110, 995],
+ smtp: [587, 25, 465], // order matters
+ exchange: [443],
+};
+
+// Store all ports into a flat array for greppability.
+var gAllStandardPorts = gStandardPorts.smtp
+ .concat(gStandardPorts.imap)
+ .concat(gStandardPorts.pop3)
+ .concat(gStandardPorts.exchange);
+
+// Define window event listeners.
+window.addEventListener("load", () => {
+ gAccountSetup.onLoad();
+});
+window.addEventListener("unload", () => {
+ gAccountSetup.onUnload();
+});
+
+function onSetupComplete() {
+ // Post a message to the main window at the end of a successful account setup.
+ gMainWindow.postMessage("account-created", "*");
+}
+
+/**
+ * Prompt a native HTML confirmation dialog for the Exchange auto discover.
+ *
+ * @param {string} domain - Text with the question.
+ * @param {Function} okCallback - Called when the user clicks OK.
+ * @param {function(ex)} cancelCallback - Called when the user clicks Cancel
+ * or if you call `Abortable.cancel()`.
+ * @returns {Abortable} - If `Abortable.cancel()` is called,
+ * the dialog is closed and the `cancelCallback()` is called.
+ */
+function confirmExchange(domain, okCallback, cancelCallback) {
+ let dialog = document.getElementById("exchangeDialog");
+
+ document.l10n.setAttributes(
+ document.getElementById("exchangeDialogQuestion"),
+ "exchange-dialog-question",
+ {
+ domain,
+ }
+ );
+
+ document.getElementById("exchangeDialogConfirmButton").onclick = () => {
+ dialog.close();
+ okCallback();
+ };
+
+ document.getElementById("exchangeDialogCancelButton").onclick = () => {
+ dialog.close();
+ cancelCallback(new UserCancelledException());
+ };
+
+ // Show the dialog.
+ dialog.showModal();
+
+ let abortable = new Abortable();
+ abortable.cancel = ex => {
+ dialog.close();
+ cancelCallback(ex);
+ };
+ return abortable;
+}
+
+/**
+ * This is our controller for the entire account setup workflow.
+ */
+var gAccountSetup = {
+ // Boolean attribute to keep track of the initialization status of the wizard.
+ isInited: false,
+ // Attribute to store methods to interrupt abortable operations like testing
+ // a server configuration or installing an add-on.
+ _abortable: null,
+
+ tabMonitor: {
+ monitorName: "accountSetupMonitor",
+
+ onTabTitleChanged() {},
+ onTabOpened() {},
+ onTabPersist() {},
+ onTabRestored() {},
+ onTabClosing(tab) {
+ if (tab?.urlbar?.value == "about:accountsetup") {
+ gMainWindow?.postMessage("account-setup-dismissed", "*");
+ }
+ },
+ onTabSwitched() {},
+ },
+
+ /**
+ * Initialize the main notification box for the account setup process.
+ */
+ get notificationBox() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document.getElementById("accountSetupNotifications").append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ /**
+ * Initialize the notification box for the calendar and address book sync
+ * process at the end of the account setup.
+ */
+ get syncingBox() {
+ if (!this._syncingBox) {
+ this._syncingBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document.getElementById("syncNotifications").append(element);
+ });
+ }
+ return this._syncingBox;
+ },
+
+ clearNotifications() {
+ this.notificationBox.removeAllNotifications();
+ },
+
+ onLoad() {
+ // Bail out if it was already initialized.
+ if (this.isInited) {
+ return;
+ }
+
+ gAccountSetupLogger.debug("Initializing setup wizard");
+ gReducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches;
+
+ // Store the main window.
+ gMainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ // this._currentConfig is the config we got either from the XML file or from
+ // guessing or from the user. Unless it's from the user, it contains
+ // placeholders like %EMAILLOCALPART% in username and other fields.
+ //
+ // The config here must retain these placeholders, to be able to adapt when
+ // the user enters a different realname, or password or email local part.
+ // A change of the domain name will trigger a new detection anyways. That
+ // means, before you actually use the config (e.g. to create an account or
+ // to show it to the user), you need to run replaceVariables().
+ this._currentConfig = null;
+ this._domain = "";
+ this._hostname = "";
+ this._email = "";
+ this._realname = "";
+ if ("@mozilla.org/userinfo;1" in Cc) {
+ let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo);
+ // Assume that it's a genuine full name if it includes a space.
+ if (userInfo.fullname.includes(" ")) {
+ this._realname = userInfo.fullname;
+ document.getElementById("realname").value = this._realname;
+ }
+ }
+
+ this._password = "";
+ // Keep track of the state of the password field, if password or clear text.
+ this._showPassword = false;
+ // This is used only for Exchange AutoDiscover and only if needed.
+ this._exchangeUsername = "";
+ // Store the successful callback in this attribute so we can send it around
+ // the various validation methods.
+ this._okCallback = onSetupComplete;
+ this._msgWindow = gMainWindow.msgWindow;
+
+ // If the account provisioner is preffed off, don't display the account
+ // provisioner button.
+ if (!Services.prefs.getBoolPref("mail.provider.enabled")) {
+ document.getElementById("provisionerButton").hidden = true;
+ }
+
+ // Disable the remember password checkbox if the pref is false.
+ if (!Services.prefs.getBoolPref("signon.rememberSignons")) {
+ let passwordCheckbox = document.getElementById("rememberPassword");
+ passwordCheckbox.checked = false;
+ passwordCheckbox.disabled = true;
+ }
+
+ // Ensure the cursor is on the first input field.
+ document.getElementById("realname").focus();
+
+ // In a new profile, the first request to live.thunderbird.net is much
+ // slower because of one-time overheads like DNS and OCSP. Let's create some
+ // dummy requests to prime the connections.
+ let autoconfigURL = Services.prefs.getCharPref("mailnews.auto_config_url");
+ fetch(autoconfigURL, { method: "OPTIONS" }).catch(console.error);
+
+ let addonsURL = Services.prefs.getCharPref(
+ "mailnews.auto_config.addons_url"
+ );
+ if (new URL(autoconfigURL).origin != new URL(addonsURL).origin) {
+ fetch(addonsURL, { method: "OPTIONS" }).catch(console.error);
+ }
+
+ // The tab monitor will inform us when this tab is getting closed.
+ gMainWindow.document
+ .getElementById("tabmail")
+ .registerTabMonitor(this.tabMonitor);
+
+ // We did everything, now we can update the variable.
+ this.isInited = true;
+ gAccountSetupLogger.debug("Account setup tab loaded.");
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+ },
+
+ /**
+ * Changes the window configuration to the different modes we have.
+ * Shows/hides various window parts and buttons.
+ *
+ * @param {string} modename
+ * "start" : Just the realname, email address, password fields
+ * "find-config" : detection step, adds the loading notification
+ * "result" : We found a config and display it to the user.
+ * The user may create the account.
+ * "manual-edit" : The user wants (or needs) to manually enter their
+ * the server hostname and other settings. We'll use them as provided.
+ * Additionally, there are the following sub-modes which can be entered after
+ * you entered the main mode:
+ * "manual-edit-have-hostname" : user entered a hostname for both servers
+ * that we can use
+ * "manual-edit-testing" : User pressed the [Re-test] button and
+ * we're currently detecting the "Auto" values
+ * "manual-edit-complete" : user entered (or we tested) all necessary
+ * values, and we're ready to create to account
+ * Currently, this doesn't cover the warning dialogs etc.. It may later.
+ */
+ switchToMode(modename) {
+ // Bail out if we requested the same mode we're currently viewing.
+ if (modename == this._currentModename) {
+ return;
+ }
+
+ this._currentModename = modename;
+ gAccountSetupLogger.debug(`switching to UI mode ${modename}`);
+
+ let continueButton = document.getElementById("continueButton");
+ let createButton = document.getElementById("createButton");
+ let reTestButton = document.getElementById("reTestButton");
+ let autoconfigDesc = document.getElementById("manualConfigDescription");
+ let setupTitle = document.getElementById("accountSetupTitle");
+
+ switch (modename) {
+ case "start":
+ this.clearNotifications();
+ document.getElementById("setupView").hidden = false;
+ document.getElementById("successView").hidden = true;
+
+ document.l10n.setAttributes(setupTitle, "account-setup-title");
+ setupTitle.classList.remove("success");
+ document.l10n.setAttributes(
+ document.getElementById("accountSetupDescription"),
+ "account-setup-description"
+ );
+
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("manualConfigArea").hidden = true;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+
+ reTestButton.hidden = true;
+ autoconfigDesc.hidden = true;
+ createButton.hidden = true;
+ continueButton.disabled = true;
+ continueButton.hidden = false;
+ break;
+ case "find-config":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("manualConfigArea").hidden = true;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("stopButton").hidden = false;
+
+ reTestButton.hidden = true;
+ autoconfigDesc.hidden = true;
+ createButton.hidden = true;
+ continueButton.disabled = true;
+ continueButton.hidden = false;
+ this.onStop = this.onStopFindConfig;
+ break;
+ case "result":
+ document.getElementById("manualConfigArea").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+ document.getElementById("resultsArea").hidden = false;
+ document.getElementById("manualConfigButton").hidden = false;
+
+ reTestButton.hidden = true;
+ autoconfigDesc.hidden = true;
+ continueButton.hidden = true;
+ createButton.hidden = false;
+ createButton.disabled = false;
+ break;
+ case "manual-edit":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("manualConfigArea").hidden = false;
+
+ continueButton.hidden = true;
+ reTestButton.hidden = false;
+ autoconfigDesc.hidden = false;
+ reTestButton.disabled = true;
+ createButton.hidden = false;
+ createButton.disabled = true;
+ break;
+ case "manual-edit-have-hostname":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("manualConfigArea").hidden = false;
+
+ reTestButton.hidden = false;
+ autoconfigDesc.hidden = false;
+ reTestButton.disabled = false;
+ continueButton.hidden = true;
+ createButton.hidden = false;
+ createButton.disabled = true;
+ break;
+ case "manual-edit-testing":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("manualConfigArea").hidden = false;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("stopButton").hidden = false;
+
+ reTestButton.hidden = false;
+ autoconfigDesc.hidden = false;
+ reTestButton.disabled = true;
+ continueButton.hidden = true;
+ createButton.hidden = false;
+ createButton.disabled = true;
+
+ this.onStop = this.onStopHalfManualTesting;
+ break;
+ case "manual-edit-complete":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("manualConfigArea").hidden = false;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+
+ reTestButton.hidden = false;
+ autoconfigDesc.hidden = false;
+ reTestButton.disabled = false;
+ continueButton.hidden = true;
+ createButton.disabled = false;
+ createButton.hidden = false;
+
+ document.getElementById("incomingProtocol").focus();
+ break;
+ case "success":
+ document.getElementById("setupView").hidden = true;
+ document.getElementById("successView").hidden = false;
+
+ document.l10n.setAttributes(setupTitle, "account-setup-success-title");
+ setupTitle.classList.add("success");
+ document.l10n.setAttributes(
+ document.getElementById("accountSetupDescription"),
+ "account-setup-success-description"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("accountSetupDescriptionSecondary"),
+ "account-setup-success-secondary-description"
+ );
+ break;
+ default:
+ throw new NotReached("Unknown mode requested");
+ }
+
+ // If we're offline, we're going to disable the create button, but enable
+ // the advanced config button if we have a current config.
+ if (Services.io.offline && !this._currentConfig) {
+ document.getElementById("manualConfigButton").hidden = true;
+ reTestButton.hidden = true;
+ autoconfigDesc.hidden = true;
+ createButton.hidden = true;
+ }
+ },
+
+ /**
+ * Reset the form and the entire UI of the account setup.
+ */
+ resetSetup() {
+ document.getElementById("form").reset();
+ document.getElementById("realname").focus();
+ // Call onStartOver only after resetting the form in order to properly
+ // update the form buttons.
+ this.onStartOver();
+ },
+
+ /**
+ * Start from beginning with possibly new email address.
+ */
+ onStartOver() {
+ this._currentConfig = null;
+ if (this._abortable) {
+ this.onStop();
+ }
+ this.switchToMode("start");
+ this.checkValidForm();
+ },
+
+ getConcreteConfig() {
+ let result = this._currentConfig.copy();
+
+ AccountConfig.replaceVariables(
+ result,
+ this._realname,
+ this._email,
+ this._password
+ );
+ result.rememberPassword =
+ document.getElementById("rememberPassword").checked && !!this._password;
+
+ if (result.incoming.addonAccountType) {
+ result.incoming.type = result.incoming.addonAccountType;
+ }
+
+ return result;
+ },
+
+ /**
+ * onInputEmail and onInputRealname are called on input = keypresses, and
+ * enable/disable the next button based on whether there's a semi-proper
+ * e-mail address and non-blank realname to start with.
+ *
+ * A change to the email address also automatically restarts the
+ * whole process.
+ */
+ onInputEmail() {
+ this._email = document.getElementById("email").value;
+ this.onStartOver();
+ },
+
+ onInputRealname() {
+ this._realname = document.getElementById("realname").value;
+ this.checkValidForm();
+ },
+
+ onInputUsername() {
+ this._exchangeUsername = document.getElementById("usernameEx").value;
+ this.onStartOver();
+ },
+
+ onInputPassword() {
+ this._password = document.getElementById("password").value;
+ this.onStartOver();
+
+ // Show the password toggle button only if the field is not empty.
+ let toggleButton = document.getElementById("passwordToggleButton");
+ toggleButton.hidden = !this._password;
+
+ if (!this._password) {
+ // Always reset the field to the proper type.
+ this.hidePassword();
+ }
+ },
+
+ /**
+ * Toggle the type of the password field between password and text to allow
+ * users reading their own password.
+ */
+ passwordToggle(event) {
+ // Prevent the form submission if the user presses Enter.
+ event.preventDefault();
+
+ // The password field is in plain text, change it back to the proper type.
+ if (this._showPassword) {
+ this.hidePassword();
+ return;
+ }
+
+ // Change the password field to plain text to make the text visible.
+ this.showPassword();
+ },
+
+ /**
+ * Convert the password field into a plain text field allowing users and
+ * assistive technologies to read the typed text.
+ */
+ showPassword() {
+ document.getElementById("password").type = "text";
+ document.l10n.setAttributes(
+ document.getElementById("passwordToggleButton"),
+ "account-setup-password-toggle-hide"
+ );
+
+ let toggleImage = document.getElementById("passwordInfo");
+ toggleImage.src = "chrome://messenger/skin/icons/new/compact/eye.svg";
+ toggleImage.classList.add("password-toggled");
+
+ this._showPassword = true;
+ },
+
+ /**
+ * Convert the password field back to its default password type.
+ */
+ hidePassword() {
+ // No need to reset anything if the password was never shown.
+ if (!this._showPassword) {
+ return;
+ }
+
+ document.getElementById("password").type = "password";
+ document.l10n.setAttributes(
+ document.getElementById("passwordToggleButton"),
+ "account-setup-password-toggle-show"
+ );
+
+ let toggleImage = document.getElementById("passwordInfo");
+ toggleImage.src = "chrome://messenger/skin/icons/new/compact/hidden.svg";
+ toggleImage.classList.remove("password-toggled");
+
+ this._showPassword = false;
+ },
+
+ /**
+ * Check whether the user entered the minimum amount of information needed to
+ * leave the "start" mode (name and email) and is allowed to proceed to the
+ * detection step.
+ */
+ checkValidForm() {
+ let email = document.getElementById("email");
+ let isValidForm =
+ email.checkValidity() &&
+ document.getElementById("realname").checkValidity();
+ this._domain = isValidForm ? this._email.split("@")[1].toLowerCase() : "";
+
+ document.getElementById("continueButton").disabled = !isValidForm;
+ document.getElementById("manualConfigButton").hidden = !isValidForm;
+ document.getElementById("provisionerButton").hidden = email.value;
+ },
+
+ /**
+ * When the [Continue] button is clicked, we move from the initial account
+ * information stage to using that information to configure account details.
+ */
+ onContinue() {
+ this.findConfig(this._domain, this._email);
+ },
+
+ // --------------
+ // Detection step
+
+ /**
+ * Try to find an account configuration for this email address.
+ * This is the function which runs the autoconfig.
+ */
+ findConfig(domain, emailAddress) {
+ gAccountSetupLogger.debug("findConfig()");
+ if (this._abortable) {
+ this.onStop();
+ }
+ this.switchToMode("find-config");
+ this.startLoadingState("account-setup-looking-up-settings");
+
+ let self = this;
+ let call = null;
+ let fetch = null;
+
+ let priority = (this._abortable = new PriorityOrderAbortable(
+ function (config, call) {
+ // success
+ self._abortable = null;
+ self.stopLoadingState(call.foundMsg);
+ self.foundConfig(config);
+ },
+ function (e, allErrors) {
+ // all failed
+ if (e instanceof CancelledException) {
+ self.onStartOver();
+ return;
+ }
+
+ // guess config
+ let initialConfig = new AccountConfig();
+ self._prefillConfig(initialConfig);
+ self._guessConfig(domain, initialConfig);
+ }
+ ));
+
+ try {
+ call = priority.addCall();
+ gAccountSetupLogger.debug(
+ "Looking up configuration: Thunderbird installation…"
+ );
+ call.foundMsg = "account-setup-success-settings-disk";
+ fetch = FetchConfig.fromDisk(
+ domain,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ gAccountSetupLogger.debug("Looking up configuration: Email provider…");
+ call.foundMsg = "account-setup-success-settings-isp";
+ fetch = FetchConfig.fromISP(
+ domain,
+ emailAddress,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ gAccountSetupLogger.debug(
+ "Looking up configuration: Thunderbird installation…"
+ );
+ call.foundMsg = "account-setup-success-settings-db";
+ fetch = FetchConfig.fromDB(
+ domain,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ gAccountSetupLogger.debug(
+ "Looking up configuration: Incoming mail domain…"
+ );
+ // "account-setup-success-settings-db" is correct.
+ // We display the same message for both db and mx cases.
+ call.foundMsg = "account-setup-success-settings-db";
+ fetch = FetchConfig.forMX(
+ domain,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ gAccountSetupLogger.debug("Looking up configuration: Exchange server…");
+ call.foundMsg = "account-setup-success-settings-exchange";
+ fetch = fetchConfigFromExchange(
+ domain,
+ emailAddress,
+ this._exchangeUsername,
+ this._password,
+ confirmExchange,
+ call.successCallback(),
+ (e, allErrors) => {
+ // Must call error callback in any case to stop the discover mode.
+ let errorCallback = call.errorCallback();
+ if (e instanceof CancelledException) {
+ errorCallback(e);
+ } else if (allErrors && allErrors.some(e => e.code == 401)) {
+ // Auth failed.
+ // Ask user for username.
+ this.onStartOver();
+ this.stopLoadingState(); // clears status message
+ document.getElementById("usernameRow").hidden = false;
+
+ this.showErrorNotification(
+ !this._exchangeUsername
+ ? "account-setup-credentials-incomplete"
+ : "account-setup-credentials-wrong"
+ );
+ document.getElementById("manualConfigButton").hidden = false;
+ errorCallback(new CancelledException());
+ } else {
+ errorCallback(e);
+ }
+ }
+ );
+ call.setAbortable(fetch);
+ } catch (e) {
+ this.onStop();
+ // e.g. when entering an invalid domain like "c@c.-com"
+ this.showErrorNotification(e, true);
+ }
+ },
+
+ /**
+ * Just a continuation of findConfig()
+ */
+ _guessConfig(domain, initialConfig) {
+ this.startLoadingState("account-setup-looking-up-settings-guess");
+ let self = this;
+ self._abortable = GuessConfig.guessConfig(
+ domain,
+ function (type, hostname, port, socketType, done, config) {
+ // progress
+ gAccountSetupLogger.debug(
+ `${hostname}:${port} socketType=${socketType} ${type}: progress callback`
+ );
+ },
+ function (config) {
+ // success
+ self._abortable = null;
+ self.foundConfig(config);
+ self.stopLoadingState(
+ Services.io.offline
+ ? "account-setup-success-guess-offline"
+ : "account-setup-success-guess"
+ );
+ },
+ function (e, config) {
+ // guessconfig failed
+ if (e instanceof CancelledException) {
+ return;
+ }
+ self._abortable = null;
+ gAccountSetupLogger.warn(`guessConfig failed: ${e}`);
+ self.showErrorNotification("account-setup-find-settings-failed");
+ self.editConfigDetails();
+ },
+ initialConfig,
+ "both"
+ );
+ },
+
+ /**
+ * Called after findConfig() is successful and displays the data to the user.
+ *
+ * @param {AccountConfig} config - The config to present to the user.
+ */
+ foundConfig(config) {
+ gAccountSetupLogger.debug("found config:\n" + config);
+ assert(
+ config instanceof AccountConfig,
+ "BUG: Arg 'config' needs to be an AccountConfig object"
+ );
+
+ this._haveValidConfigForDomain = this._email.split("@")[1];
+
+ // Bail out if the name and email fields are empty.
+ if (!this._realname || !this._email) {
+ return;
+ }
+
+ config.addons = [];
+ let successCallback = () => {
+ this._abortable = null;
+ this.displayConfigResult(config);
+ this.switchToMode("result");
+ this.ensureVisibleButtons();
+ };
+ this._abortable = getAddonsList(config, successCallback, e => {
+ successCallback();
+ this.showErrorNotification(e, true);
+ });
+ },
+
+ /**
+ * [Stop] button click handler.
+ * This allows the user to abort any longer operation, esp. network activity.
+ * We currently have 3 such cases here:
+ * 1. findConfig(), i.e. fetch config from DB, guessConfig etc.
+ * 2. testManualConfig(), i.e. the [Retest] button in manual config.
+ * 3. verifyConfig() - We can't stop this yet, so irrelevant here currently.
+ * Given that these need slightly different actions, this function will be set
+ * to a function (i.e. overwritten) by whoever enables the stop button.
+ *
+ * We also call this from the code when the user started a different action
+ * without explicitly clicking [Stop] for the old one first.
+ */
+ onStop() {
+ throw new NotReached("onStop should be overridden by now");
+ },
+
+ _onStopCommon() {
+ if (!this._abortable) {
+ throw new NotReached("onStop called although there's nothing to stop");
+ }
+ gAccountSetupLogger.debug("onStop cancelled _abortable");
+ this._abortable.cancel(new UserCancelledException());
+ this._abortable = null;
+ this.stopLoadingState();
+ },
+
+ onStopFindConfig() {
+ this._onStopCommon();
+ this.switchToMode("start");
+ this.checkValidForm();
+ },
+
+ onStopHalfManualTesting() {
+ this._onStopCommon();
+ this.validateManualEditComplete();
+ },
+
+ // ----------- Loading area -----------
+ /**
+ * Disable all the input fields of the main form to prevent editing and show
+ * a notification while a loading or fetching state.
+ *
+ * @param {string} stringName - The name of the fluent string that needs to be
+ * attached to the notification.
+ */
+ async startLoadingState(stringName) {
+ gAccountSetupLogger.debug(`Loading start: ${stringName}`);
+
+ this.showHelperImage("step2");
+
+ // Disable all input fields.
+ for (let input of document.querySelectorAll("#form input")) {
+ input.disabled = true;
+ }
+
+ let notificationMessage = await document.l10n.formatValue(stringName);
+
+ gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`);
+
+ let notification = this.notificationBox.getNotificationWithValue(
+ "accountSetupLoading"
+ );
+
+ // If a notification already exists, simply update the message.
+ if (notification) {
+ notification.label = notificationMessage;
+ this.ensureVisibleNotification();
+ return;
+ }
+
+ notification = this.notificationBox.appendNotification(
+ "accountSetupLoading",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_INFO_LOW,
+ },
+ null
+ );
+ notification.setAttribute("align", "center");
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ /**
+ * Update the text of a currently visible loading notification
+ *
+ * @param {string} stringName - The name of the fluent string that needs to be
+ * attached to the notification.
+ */
+ async updateLoadingState(stringName) {
+ let notification = this.notificationBox.getNotificationWithValue(
+ "accountSetupLoading"
+ );
+ // If a notification doesn't already exist, create one.
+ if (!notification) {
+ this.startLoadingState(stringName);
+ return;
+ }
+
+ let notificationMessage = await document.l10n.formatValue(stringName);
+ notification.label = notificationMessage;
+ this.ensureVisibleNotification();
+
+ gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`);
+ },
+
+ /**
+ * Clear the loading notification and show a successful notification if
+ * needed.
+ *
+ * @param {?string} stringName - The name of the fluent string that needs to
+ * be attached to the notification, or null if nothing needs to be showed.
+ */
+ async stopLoadingState(stringName) {
+ // Re-enable all form input fields.
+ for (let input of document.querySelectorAll("#form input")) {
+ input.removeAttribute("disabled");
+ }
+
+ // Always remove any leftover notification.
+ this.clearNotifications();
+
+ // Bail out if we don't need to show anything else.
+ if (!stringName) {
+ gAccountSetupLogger.debug("Loading stopped");
+ this.showHelperImage("step1");
+ return;
+ }
+
+ gAccountSetupLogger.debug(`Loading stopped: ${stringName}`);
+
+ let notificationMessage = await document.l10n.formatValue(stringName);
+
+ let notification = this.notificationBox.appendNotification(
+ "accountSetupSuccess",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_INFO_HIGH,
+ },
+ null
+ );
+ notification.setAttribute("type", "success");
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.showHelperImage("step3");
+ },
+
+ /**
+ * Show an error notification in case something went wrong.
+ *
+ * @param {string} stringName - The name of the fluent string that needs to
+ * be attached to the notification.
+ * @param {boolean} isMsgError - True if the message comes from a server error
+ * response or try/catch.
+ */
+ async showErrorNotification(stringName, isMsgError) {
+ gAccountSetupLogger.debug(`Status error: ${stringName}`);
+
+ this.showHelperImage("step4");
+
+ // Re-enable all form input fields.
+ for (let input of document.querySelectorAll("#form input")) {
+ input.removeAttribute("disabled");
+ }
+
+ // Always remove any leftover notification before creating a new one.
+ this.clearNotifications();
+
+ // Fetch the fluent string only if this is not an error message coming from
+ // a previous method.
+ let notificationMessage = isMsgError
+ ? stringName
+ : await document.l10n.formatValue(stringName);
+
+ let notification = this.notificationBox.appendNotification(
+ "accountSetupError",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ /**
+ * Hide all the helper images and show the requested one.
+ *
+ * @param {string} id - The string ID of the element to show.
+ */
+ showHelperImage(id) {
+ // Hide all currently visible articles containing helper images in the
+ // second column.
+ for (let article of document.querySelectorAll(
+ ".second-column article:not([hidden])"
+ )) {
+ article.hidden = true;
+ }
+
+ // Simply show the requested helper image if the user specified a reduced
+ // motion preference.
+ if (gReducedMotion) {
+ document.getElementById(id).hidden = false;
+ return;
+ }
+
+ // Handle a nice cross fade between steps.
+ let stepToShow = document.getElementById(id);
+ // Add the class to let the revealing element start from a proper state.
+ stepToShow.classList.add("hide");
+ stepToShow.hidden = false;
+ // Timeout to animate after the hidden attribute has been removed.
+ setTimeout(() => {
+ stepToShow.classList.remove("hide");
+ });
+ },
+
+ /**
+ * Always ensure the primary button is visible by scrolling the page until the
+ * button is above the fold.
+ */
+ ensureVisibleButtons() {
+ // We use the #footDescription element to ensure the buttons are properly
+ // scrolled above the fold.
+ document.getElementById("footDescription").scrollIntoView({
+ behavior: gReducedMotion ? "auto" : "smooth",
+ block: "end",
+ inline: "nearest",
+ });
+ },
+
+ /**
+ * Always ensure the notification area is visible when a new notification is
+ * created.
+ */
+ ensureVisibleNotification() {
+ document.getElementById("accountSetupNotifications").scrollIntoView({
+ behavior: gReducedMotion ? "auto" : "smooth",
+ block: "start",
+ inline: "nearest",
+ });
+ },
+
+ /**
+ * Populate the results config details area.
+ *
+ * @param {AccountConfig} config - The config to present to user.
+ */
+ displayConfigResult(config) {
+ assert(config instanceof AccountConfig);
+ this._currentConfig = config;
+ let configFilledIn = this.getConcreteConfig();
+
+ // Filter out Protcols we don't currently support
+ let protocols = config.incomingAlternatives.filter(protocol =>
+ ["imap", "pop3", "exchange"].includes(protocol.type)
+ );
+ protocols.unshift(config.incoming);
+ protocols = protocols.reduce((found, nextEl) => {
+ if (!found.some(prevEl => prevEl.type == nextEl.type)) {
+ found.push(nextEl);
+ }
+ return found;
+ }, []);
+
+ // Hide all the available options in order to start with a clean slate.
+ for (let row of document.querySelectorAll(".content-blocking-category")) {
+ row.classList.remove("selected");
+ row.hidden = true;
+ }
+
+ // Remove all previously generated protocol types.
+ for (let type of document.querySelectorAll(".config-type")) {
+ type.remove();
+ }
+
+ // Reveal all the matching protocols.
+ for (let protocol of protocols) {
+ let row = document.getElementById(`resultsOption-${protocol.type}`);
+ row.hidden = false;
+ // Attach the protocol to the radio input for later usage.
+ row.querySelector(`input[type="radio"]`).configIncoming = protocol;
+ }
+
+ // Preselect the default protocol type.
+ let selected = document.getElementById(
+ `resultSelect-${config.incoming.type}`
+ );
+ selected.closest(".content-blocking-category").classList.add("selected");
+ selected.checked = true;
+
+ // Update the results area title to match the protocols choice.
+ document.l10n.setAttributes(
+ document.getElementById("resultAreaTitle"),
+ "account-setup-results-area-title",
+ {
+ count: protocols.length,
+ }
+ );
+
+ // Ensure by default the "Done" button is enabled.
+ document.getElementById("createButton").disabled = false;
+
+ // Thunderbird can't handle Exchange server independently, therefore we
+ // need to prompt the user with the installation of the Owl add-on.
+ if (config.incoming.type == "exchange") {
+ let addonsInstallRows = document.getElementById("resultAddonInstallRows");
+
+ // Remove any pre-existing child element.
+ while (addonsInstallRows.hasChildNodes()) {
+ addonsInstallRows.lastChild.remove();
+ }
+
+ let container = document.getElementById("resultExchangeHostname");
+ _makeHostDisplayString(config.incoming, container);
+ document
+ .getElementById("incomingTitle-exchange")
+ .appendChild(_socketTypeSpan(config.incoming.socketType));
+
+ (async () => {
+ try {
+ for (let addon of config.addons) {
+ let installer = new AddonInstaller(addon);
+ addon.isInstalled = await installer.isInstalled();
+ addon.isDisabled = await installer.isDisabled();
+ }
+
+ let addonInfoArea = document.getElementById("installAddonInfo");
+ let installedAddon = config.addons.find(addon => addon.isInstalled);
+
+ // The needed add-on is already installed, no need to show anything.
+ if (installedAddon) {
+ config.incoming.addonAccountType =
+ installedAddon.useType.addonAccountType;
+ addonInfoArea.hidden = true;
+ return;
+ }
+ // Disable "Done" until add-on is installed, or some other protocol
+ // is selected.
+ document.getElementById("createButton").disabled = true;
+
+ addonInfoArea.hidden = false;
+
+ document.l10n.setAttributes(
+ document.getElementById("resultAddonIntro"),
+ !config.incomingAlternatives.find(alt =>
+ ["imap", "pop3"].includes(alt.type)
+ )
+ ? "account-setup-addon-install-intro"
+ : "account-setup-addon-no-protocol"
+ );
+
+ for (let addon of config.addons) {
+ // Creates and addon install section.
+ // <div><img/><a></a><button></button></div>
+ let container = document.createElement("div");
+ container.classList.add("addon-container");
+
+ let img = document.createElement("img");
+ img.alt = "";
+ img.classList.add("icon");
+ if (addon.icon32) {
+ img.setAttribute("src", addon.icon32);
+ }
+
+ let link = document.createElement("a");
+ link.classList.add("link");
+ link.setAttribute("href", addon.websiteURL);
+ link.textContent = addon.description;
+
+ let button = document.createElement("button");
+ document.l10n.setAttributes(
+ button,
+ "account-setup-addon-install-title"
+ );
+ button.addEventListener("click", () => {
+ gAccountSetup.addonInstall(addon);
+ });
+ if (addon.isDisabled) {
+ // If the add on is disabled by user, or due to incompatibility
+ // - disable install (it won't help, it's already installed)
+ // - link to the addons manager instead (so they can fix it)
+ button.disabled = true;
+ link.setAttribute("href", "about:addons");
+ link.setAttribute("target", "_blank");
+
+ // Trigger an add-on update check. If an update is available,
+ // enable the install button to (re)install.
+ AddonManager.getAddonByID(addon.id).then(a => {
+ if (!a) {
+ return;
+ }
+ let listener = {
+ onUpdateAvailable(addon, install) {
+ button.disabled = false;
+ },
+ onNoUpdateAvailable() {},
+ };
+ a.findUpdates(
+ listener,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ });
+ }
+
+ container.appendChild(img);
+ container.appendChild(link);
+ container.appendChild(button);
+
+ addonsInstallRows.appendChild(container);
+ }
+ } catch (e) {
+ this.showErrorNotification(e, true);
+ }
+ })();
+ return;
+ }
+
+ function _makeHostDisplayString(server, container) {
+ // Clean up any existing element.
+ while (container.hasChildNodes()) {
+ container.lastChild.remove();
+ }
+
+ let cert = container.parentNode.querySelector(".cert-status");
+ if (cert != null) {
+ cert.remove();
+ }
+
+ let domain = server.hostname;
+ try {
+ domain = Services.eTLD.getBaseDomainFromHost(server.hostname);
+ } catch (ex) {
+ gAccountSetupLogger.warn(ex);
+ }
+
+ let hostSpan = document.createElement("span");
+ hostSpan.classList.add("host-without-domain");
+ hostSpan.textContent = server.hostname.substr(
+ 0,
+ server.hostname.length - domain.length
+ );
+ container.appendChild(hostSpan);
+
+ let domainSpan = document.createElement("span");
+ domainSpan.classList.add("domain");
+ domainSpan.textContent = domain;
+ container.appendChild(domainSpan);
+
+ if (!gAllStandardPorts.includes(server.port)) {
+ let portSpan = document.createElement("span");
+ portSpan.classList.add("port");
+ portSpan.textContent = `:${server.port}`;
+ container.appendChild(portSpan);
+ }
+
+ if (server.badCert) {
+ container.parentNode
+ .querySelector(".cert-status")
+ .classList.add("insecure");
+ }
+ }
+
+ /**
+ * Helper method to create the span protocol element.
+ *
+ * @returns {HTMLElement} - The newly created span label.
+ */
+ function _protocolTypeSpan() {
+ let span = document.createElement("span");
+ span.classList.add("protocol-type", "config-type");
+ return span;
+ }
+
+ /**
+ * Helper method to create the span socket element.
+ *
+ * @param {nsMsgSocketType} socket - The value representing the server
+ * socket type.
+ * @returns {HTMLElement} - The newly created span label.
+ */
+ function _socketTypeSpan(socket) {
+ let ssl = Sanitizer.translate(socket, {
+ 0: "no-encryption",
+ 2: "starttls",
+ 3: "ssl",
+ });
+ let span = _protocolTypeSpan();
+ document.l10n.setAttributes(span, `account-setup-result-${ssl}`);
+ span.classList.add("ssl");
+ if (socket != 2 && socket != 3) {
+ // Not an SSL or STARTTLS socket.
+ span.classList.add("insecure");
+ }
+ return span;
+ }
+
+ let protocolType = config.incoming.type;
+ if (configFilledIn.incoming.hostname) {
+ _makeHostDisplayString(
+ configFilledIn.incoming,
+ document.getElementById(`incomingInfo-${protocolType}`)
+ );
+
+ let container = document.getElementById(`incomingTitle-${protocolType}`);
+
+ // No need to show the protocol type if it's exchange, and the socket span
+ // is generated somewhere else specifically for exchange.
+ if (protocolType != "exchange") {
+ let span = _protocolTypeSpan();
+ span.textContent = configFilledIn.incoming.type;
+ container.appendChild(span);
+ container.appendChild(_socketTypeSpan(config.incoming.socketType));
+ }
+ }
+
+ let outgoingInfo = document.getElementById(`outgoingInfo-${protocolType}`);
+ if (!config.outgoing.existingServerKey) {
+ if (configFilledIn.outgoing.hostname) {
+ _makeHostDisplayString(configFilledIn.outgoing, outgoingInfo);
+ }
+ let container = document.getElementById(`outgoingTitle-${protocolType}`);
+ // No need to show the protocol type if it's exchange, and the socket span
+ // is generated somewhere else specifically for exchange.
+ if (protocolType != "exchange") {
+ let span = _protocolTypeSpan();
+ span.textContent = configFilledIn.outgoing.type;
+ container.appendChild(span);
+ container.appendChild(_socketTypeSpan(config.outgoing.socketType));
+ }
+ } else {
+ let span = document.createElement("span");
+ document.l10n.setAttributes(
+ span,
+ "account-setup-result-outgoing-existing"
+ );
+ outgoingInfo.appendChild(span);
+ }
+
+ let usernameInfo = document.getElementById(
+ `usernameInfo-${config.incoming.type}`
+ );
+ if (configFilledIn.incoming.username == configFilledIn.outgoing.username) {
+ usernameInfo.textContent = configFilledIn.incoming.username;
+ } else {
+ document.l10n.setAttributes(
+ usernameInfo,
+ "account-setup-result-username-different",
+ {
+ incoming: configFilledIn.incoming.username,
+ outgoing: configFilledIn.outgoing.username,
+ }
+ );
+ }
+ },
+
+ /**
+ * Handle the user switching between IMAP and POP3 settings using the
+ * radio buttons.
+ */
+ onResultServerTypeChanged() {
+ let config = this._currentConfig;
+ // Add current server as best alternative to start of array.
+ config.incomingAlternatives.unshift(config.incoming);
+
+ // Clear the visually selected radio container.
+ document
+ .querySelector(".content-blocking-category.selected")
+ .classList.remove("selected");
+
+ // Use selected server (stored as special property on the <input> node).
+ let selected = document.querySelector(
+ 'input[name="resultsServerType"]:checked'
+ );
+ selected.closest(".content-blocking-category").classList.add("selected");
+ config.incoming = selected.configIncoming;
+
+ // Remove newly selected server from list of alternatives.
+ config.incomingAlternatives = config.incomingAlternatives.filter(
+ alt => alt != config.incoming
+ );
+ this.displayConfigResult(config);
+ },
+
+ /**
+ * Install the addon
+ * Called when user clicks [Install] button.
+ *
+ * @param {AddonInfo} addon - @see AccountConfig.addons
+ */
+ async addonInstall(addon) {
+ let addonInfoArea = document.getElementById("installAddonInfo");
+ let createButton = document.getElementById("createButton");
+ addonInfoArea.hidden = true;
+ createButton.disabled = true;
+
+ this.clearNotifications();
+ await this.startLoadingState("account-setup-installing-addon");
+
+ try {
+ let installer = (this._abortable = new AddonInstaller(addon));
+ await installer.install();
+
+ this._abortable = null;
+ this.stopLoadingState("account-setup-success-addon");
+ createButton.disabled = false;
+
+ this._currentConfig.incoming.addonAccountType =
+ addon.useType.addonAccountType;
+ // Remove the note about having to install an add-on.
+ let rows = document.getElementById("resultAddonInstallRows");
+ while (rows.lastChild) {
+ rows.lastChild.remove();
+ }
+ } catch (e) {
+ console.error(e);
+ this.showErrorNotification(e, true);
+ addonInfoArea.hidden = false;
+ }
+ },
+
+ // ----------------
+ // Manual Edit area
+
+ /**
+ * Gets the values from the user in the manual edit area. Realname and
+ * password are not part of that area and still placeholders, but hostname and
+ * username are concrete and no placeholders anymore.
+ */
+ getUserConfig() {
+ let config = this.getConcreteConfig() || new AccountConfig();
+ config.source = AccountConfig.kSourceUser;
+
+ // Incoming server
+ try {
+ let inHostnameField = document.getElementById("incomingHostname");
+ config.incoming.hostname = Sanitizer.hostname(inHostnameField.value);
+ inHostnameField.value = config.incoming.hostname;
+ } catch (e) {
+ gAccountSetupLogger.warn(e);
+ }
+
+ try {
+ config.incoming.port = Sanitizer.integerRange(
+ document.getElementById("incomingPort").value,
+ 1,
+ 65535
+ );
+ } catch (e) {
+ config.incoming.port = undefined; // incl. default "Auto"
+ }
+
+ config.incoming.type = Sanitizer.translate(
+ document.getElementById("incomingProtocol").value,
+ {
+ 1: "imap",
+ 2: "pop3",
+ 3: "exchange",
+ 0: null,
+ }
+ );
+ config.incoming.socketType = Sanitizer.integer(
+ document.getElementById("incomingSsl").value
+ );
+ config.incoming.auth = Sanitizer.integer(
+ document.getElementById("incomingAuthMethod").value
+ );
+ config.incoming.username =
+ document.getElementById("incomingUsername").value;
+
+ // Outgoing server
+
+ config.outgoing.username =
+ document.getElementById("outgoingUsername").value;
+
+ // The user specified a custom SMTP server.
+ config.outgoing.existingServerKey = null;
+ config.outgoing.addThisServer = true;
+ config.outgoing.useGlobalPreferredServer = false;
+
+ try {
+ let input = document.getElementById("outgoingHostname");
+ config.outgoing.hostname = Sanitizer.hostname(input.value);
+ input.value = config.outgoing.hostname;
+ } catch (e) {
+ gAccountSetupLogger.warn(e);
+ }
+
+ try {
+ config.outgoing.port = Sanitizer.integerRange(
+ document.getElementById("outgoingPort").value,
+ 1,
+ 65535
+ );
+ } catch (e) {
+ config.outgoing.port = undefined; // incl. default "Auto"
+ }
+
+ config.outgoing.socketType = Sanitizer.integer(
+ document.getElementById("outgoingSsl").value
+ );
+ config.outgoing.auth = Sanitizer.integer(
+ document.getElementById("outgoingAuthMethod").value
+ );
+
+ return config;
+ },
+
+ /**
+ * [Manual Config] button click handler. This turns the config details area
+ * into an editable form and makes the (Go) button appear. The edit button
+ * should only be available after the config probing is completely finished,
+ * replacing what was the (Stop) button.
+ */
+ onManualEdit() {
+ if (this._abortable) {
+ this.onStop();
+ }
+ this.editConfigDetails();
+ this.showHelperImage("step3");
+ },
+
+ /**
+ * Setting the config details form so it can be edited. We also disable
+ * (and hide) the create button during this time because we don't know what
+ * might have changed. The function called from the button that restarts
+ * the config check should be enabling the config button as needed.
+ */
+ editConfigDetails() {
+ gAccountSetupLogger.debug("manual edit");
+
+ if (!this._currentConfig) {
+ this._currentConfig = new AccountConfig();
+ this._currentConfig.incoming.type = "imap";
+ this._currentConfig.incoming.username = "%EMAILADDRESS%";
+ this._currentConfig.outgoing.username = "%EMAILADDRESS%";
+ this._currentConfig.incoming.hostname = ".%EMAILDOMAIN%";
+ this._currentConfig.outgoing.hostname = ".%EMAILDOMAIN%";
+ }
+ // Although we go manual, and we need to display the concrete username,
+ // however the realname and password is not part of manual config and
+ // must stay a placeholder in _currentConfig. @see getUserConfig()
+
+ this._fillManualEditFields(this.getConcreteConfig());
+
+ // _fillManualEditFields() indirectly calls validateManualEditComplete(),
+ // but it's important to not forget it in case the code is rewritten,
+ // so calling it explicitly again. Doesn't do harm, speed is irrelevant.
+ this.validateManualEditComplete();
+ },
+
+ /**
+ * Fills the manual edit textfields with the provided config.
+ *
+ * @param {AccountConfig} config - The config to present to the user.
+ */
+ _fillManualEditFields(config) {
+ assert(config instanceof AccountConfig);
+
+ let isExchange = config.incoming.type == "exchange";
+
+ // Incoming server.
+ document.getElementById("incomingProtocolExchange").hidden = !isExchange;
+ document.getElementById("incomingProtocol").value = Sanitizer.translate(
+ config.incoming.type,
+ { imap: 1, pop3: 2, exchange: 3 },
+ 1
+ );
+ document.getElementById("incomingHostname").value =
+ config.incoming.hostname;
+ document.getElementById("incomingSsl").value = Sanitizer.enum(
+ config.incoming.socketType,
+ [0, 1, 2, 3],
+ 0
+ );
+ document.getElementById("incomingAuthMethod").value = Sanitizer.enum(
+ config.incoming.auth,
+ [0, 3, 4, 5, 6, 10],
+ 0
+ );
+ document.getElementById("incomingUsername").value =
+ config.incoming.username;
+
+ // If a port number was specified other than "Auto"
+ if (config.incoming.port) {
+ document.getElementById("incomingPort").value = config.incoming.port;
+ } else {
+ this.adjustIncomingPortToSSLAndProtocol(config);
+ }
+
+ // Outgoing server.
+
+ document.getElementById("outgoingHostname").value =
+ config.outgoing.hostname;
+ document.getElementById("outgoingUsername").value =
+ config.outgoing.username;
+
+ // While sameInOutUsernames is true we synchronize values of incoming
+ // and outgoing username.
+ this.sameInOutUsernames = true;
+ document.getElementById("outgoingSsl").value = Sanitizer.enum(
+ config.outgoing.socketType,
+ [0, 1, 2, 3],
+ 0
+ );
+ document.getElementById("outgoingAuthMethod").value = Sanitizer.enum(
+ config.outgoing.auth,
+ [0, 1, 3, 4, 5, 6, 10],
+ 0
+ );
+
+ // If a port number was specified other than "Auto"
+ if (config.outgoing.port) {
+ document.getElementById("outgoingPort").value = config.outgoing.port;
+ } else {
+ this.adjustOutgoingPortToSSLAndProtocol(config);
+ }
+
+ this.adjustOAuth2Visibility(config);
+ },
+
+ /**
+ * Make OAuth2 visible as an authentication method when a hostname that
+ * OAuth2 can be used with is entered.
+ *
+ * @param {AccountConfig} config - The account configuration.
+ */
+ async adjustOAuth2Visibility(config) {
+ // If the incoming server hostname supports OAuth2, enable it.
+ let iDetails = OAuth2Providers.getHostnameDetails(config.incoming.hostname);
+ document.getElementById("in-authMethod-oauth2").hidden = !iDetails;
+ if (iDetails) {
+ gAccountSetupLogger.debug(
+ `OAuth2 details for incoming server ${config.incoming.hostname} is ${iDetails}`
+ );
+ config.incoming.oauthSettings = {};
+ [
+ config.incoming.oauthSettings.issuer,
+ config.incoming.oauthSettings.scope,
+ ] = iDetails;
+ this._currentConfig.incoming.oauthSettings =
+ config.incoming.oauthSettings;
+ }
+
+ // If the smtp hostname supports OAuth2, enable it.
+ let oDetails = OAuth2Providers.getHostnameDetails(config.outgoing.hostname);
+ document.getElementById("out-authMethod-oauth2").hidden = !oDetails;
+ if (oDetails) {
+ gAccountSetupLogger.debug(
+ `OAuth2 details for outgoing server ${config.outgoing.hostname} is ${oDetails}`
+ );
+ config.outgoing.oauthSettings = {};
+ [
+ config.outgoing.oauthSettings.issuer,
+ config.outgoing.oauthSettings.scope,
+ ] = oDetails;
+ this._currentConfig.outgoing.oauthSettings =
+ config.outgoing.oauthSettings;
+ }
+ },
+
+ /**
+ * Automatically fill port field in manual edit, unless the user entered a
+ * non-standard port.
+ *
+ * @param {AccountConfig} config - The account configuration.
+ */
+ async adjustIncomingPortToSSLAndProtocol(config) {
+ let incoming = config.incoming;
+
+ // Bail out if a port number is already defined and it's not part of the
+ // known ports array.
+ if (!gAllStandardPorts.includes(incoming.port)) {
+ return;
+ }
+
+ let input = document.getElementById("incomingPort");
+
+ switch (incoming.type) {
+ case "imap":
+ input.value = incoming.socketType == Ci.nsMsgSocketType.SSL ? 993 : 143;
+ break;
+
+ case "pop3":
+ input.value = incoming.socketType == Ci.nsMsgSocketType.SSL ? 995 : 110;
+ break;
+
+ case "exchange":
+ input.value = 443;
+ break;
+ }
+ },
+
+ /**
+ * Automatically fill port field in manual edit, unless the user entered a
+ * non-standard port.
+ *
+ * @param {AccountConfig} config - The account configuration.
+ */
+ async adjustOutgoingPortToSSLAndProtocol(config) {
+ let outgoing = config.outgoing;
+
+ // Bail out if a port number is already defined and it's not part of the
+ // known ports array.
+ if (!gAllStandardPorts.includes(outgoing.port)) {
+ return;
+ }
+
+ // Implicit TLS for SMTP is on port 465.
+ if (outgoing.socketType == Ci.nsMsgSocketType.SSL) {
+ document.getElementById("outgoingPort").value = 465;
+ return;
+ }
+
+ // Implicit TLS for SMTP is on port 465. STARTTLS won't work there.
+ if (
+ outgoing.port == 465 &&
+ outgoing.socketType == Ci.nsMsgSocketType.alwaysSTARTTLS
+ ) {
+ document.getElementById("outgoingPort").value = 587;
+ }
+ },
+
+ /**
+ * If the user changed the port manually, adjust the SSL value,
+ * (only) if the new port is impossible with the old SSL value.
+ *
+ * @param config {AccountConfig}
+ */
+ adjustIncomingSSLToPort(config) {
+ let incoming = config.incoming;
+ if (!gAllStandardPorts.includes(incoming.port)) {
+ return;
+ }
+
+ if (incoming.type == "imap") {
+ // Implicit TLS for IMAP is on port 993.
+ if (
+ incoming.port == 993 &&
+ incoming.socketType != Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("incomingSsl").value = Ci.nsMsgSocketType.SSL;
+ return;
+ }
+ if (
+ incoming.port == 143 &&
+ incoming.socketType == Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("incomingSsl").value =
+ Ci.nsMsgSocketType.alwaysSTARTTLS;
+ return;
+ }
+ }
+
+ if (incoming.type == "pop3") {
+ // Implicit TLS for POP3 is on port 995.
+ if (
+ incoming.port == 995 &&
+ incoming.socketType != Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("incomingSsl").value = Ci.nsMsgSocketType.SSL;
+ return;
+ }
+ if (
+ incoming.port == 110 &&
+ incoming.socketType == Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("incomingSsl").value =
+ Ci.nsMsgSocketType.alwaysSTARTTLS;
+ }
+ }
+ },
+
+ /**
+ * @see adjustIncomingSSLToPort()
+ */
+ adjustOutgoingSSLToPort(config) {
+ let outgoing = config.outgoing;
+ if (!gAllStandardPorts.includes(outgoing.port)) {
+ return;
+ }
+
+ // Implicit TLS for SMTP is on port 465.
+ if (outgoing.port == 465 && outgoing.socketType != Ci.nsMsgSocketType.SSL) {
+ document.getElementById("outgoingSsl").value = Ci.nsMsgSocketType.SSL;
+ return;
+ }
+
+ // Port 587 and port 25 are for plain or STARTTLS. Not for Implicit TLS.
+ if (
+ (outgoing.port == 587 || outgoing.port == 25) &&
+ outgoing.socketType == Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("outgoingSsl").value =
+ Ci.nsMsgSocketType.alwaysSTARTTLS;
+ }
+ },
+
+ onChangedProtocolIncoming() {
+ let config = this.getUserConfig();
+ this.adjustIncomingPortToSSLAndProtocol(config);
+ this.onChangedManualEdit();
+ },
+
+ onChangedPortIncoming() {
+ gAccountSetupLogger.debug(
+ "incoming port changed: " + document.getElementById("incomingPort").value
+ );
+ this.adjustIncomingSSLToPort(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ onChangedPortOutgoing() {
+ gAccountSetupLogger.debug(
+ "outgoing port changed: " + document.getElementById("outgoingPort").value
+ );
+ this.adjustOutgoingSSLToPort(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ onChangedSSLIncoming() {
+ this.adjustIncomingPortToSSLAndProtocol(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ onChangedSSLOutgoing() {
+ this.adjustOutgoingPortToSSLAndProtocol(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ onChangedInAuth() {
+ this.onChangedManualEdit();
+ },
+
+ onChangedOutAuth(event) {
+ // Disable the outgoing username field if the "No Authentication" option is
+ // selected.
+ document.getElementById("outgoingUsername").disabled =
+ event.target.selectedOptions[0].id == "outNoAuth";
+ this.onChangedManualEdit();
+ },
+
+ onInputInUsername() {
+ if (this.sameInOutUsernames) {
+ document.getElementById("outgoingUsername").value =
+ document.getElementById("incomingUsername").value;
+ }
+ this.onChangedManualEdit();
+ },
+
+ onInputOutUsername() {
+ this.sameInOutUsernames = false;
+ this.onChangedManualEdit();
+ },
+
+ onChangeHostname() {
+ this.adjustOAuth2Visibility(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ /**
+ * A value in the manual configuration area was changed.
+ */
+ onChangedManualEdit() {
+ // If there's a current operation in progress and is abortable.
+ if (this._abortable) {
+ this.onStop();
+ }
+ this.validateManualEditComplete();
+ },
+
+ /**
+ * The user interacted with an input field in the manual configuration area
+ * therefore we need to clear previous notifications and disable the "Done"
+ * button as the current config is not valid until we run again the
+ * validateManualEditComplete() method, which happens on input blur.
+ */
+ manualConfigChanged() {
+ this.clearNotifications();
+ document.getElementById("createButton").disabled = true;
+ },
+
+ /**
+ * This enables the buttons which allow the user to proceed
+ * once he has entered enough information.
+ *
+ * We can easily and fairly surely autodetect everything apart from the
+ * hostname (and username). So, once the user has entered
+ * proper hostnames, change to "manual-edit-have-hostname" mode
+ * which allows to press [Re-test], which starts the detection
+ * of the other values.
+ * Once the user has entered (or we detected) all values, he may
+ * do [Create Account] (tests login and if successful creates the account)
+ * or [Advanced Setup] (goes to Account Manager). Esp. in the latter case,
+ * we will not second-guess his setup and just to as told, so here we make
+ * sure that he at least entered all values.
+ */
+ validateManualEditComplete() {
+ // getUserConfig() is expensive, but still OK, not a problem.
+ let manualConfig = this.getUserConfig();
+ this._currentConfig = manualConfig;
+
+ if (manualConfig.isComplete()) {
+ this.switchToMode("manual-edit-complete");
+ return;
+ }
+
+ if (!!manualConfig.incoming.hostname && !!manualConfig.outgoing.hostname) {
+ this.switchToMode("manual-edit-have-hostname");
+ return;
+ }
+
+ this.switchToMode("manual-edit");
+ },
+
+ /**
+ * [Advanced Setup...] button click handler
+ * Only active in manual edit mode, and goes straight into
+ * Account Settings (pref UI) dialog. Requires a backend account,
+ * which requires proper hostname, port and protocol.
+ */
+ async onAdvancedSetup() {
+ assert(this._currentConfig instanceof AccountConfig);
+ let configFilledIn = this.getConcreteConfig();
+
+ if (CreateInBackend.checkIncomingServerAlreadyExists(configFilledIn)) {
+ let [title, description] = await document.l10n.formatValues([
+ "account-setup-creation-error-title",
+ "account-setup-error-server-exists",
+ ]);
+ Services.prompt.alert(null, title, description);
+ return;
+ }
+
+ let [title, description] = await document.l10n.formatValues([
+ "account-setup-confirm-advanced-title",
+ "account-setup-confirm-advanced-description",
+ ]);
+
+ if (!Services.prompt.confirm(null, title, description)) {
+ return;
+ }
+
+ gAccountSetupLogger.debug("creating account in backend");
+ let newAccount = CreateInBackend.createAccountInBackend(configFilledIn);
+
+ window.close();
+ gMainWindow.postMessage("account-created-in-backend", "*");
+ MsgAccountManager("am-server.xhtml", newAccount.incomingServer);
+ },
+
+ /**
+ * [Re-test] button click handler.
+ * Restarts the config guessing process after a person editing the server
+ * fields.
+ * It's called "half-manual", because we take the user-entered values
+ * as given and will not second-guess them, to respect the user wishes.
+ * (Yes, Sir! Will do as told!)
+ * The values that the user left empty or on "Auto" will be guessed/probed
+ * here. We will also check that the user-provided values work.
+ */
+ async testManualConfig() {
+ this.clearNotifications();
+ await this.startLoadingState(
+ "account-setup-looking-up-settings-half-manual"
+ );
+
+ let newConfig = this.getUserConfig();
+ gAccountSetupLogger.debug("manual config to test:\n" + newConfig);
+
+ this.switchToMode("manual-edit-testing");
+ // if (this._userPickedOutgoingServer) TODO
+ let self = this;
+ this._abortable = GuessConfig.guessConfig(
+ this._domain,
+ function (type, hostname, port, ssl, done, config) {
+ // Progress.
+ gAccountSetupLogger.debug(
+ `progress callback host: ${hostname}, port: ${port}, type: ${type}`
+ );
+ },
+ function (config) {
+ // Success.
+ self._abortable = null;
+ self._fillManualEditFields(config);
+ self.stopLoadingState("account-setup-success-half-manual");
+ self.validateManualEditComplete();
+ },
+ function (e, config) {
+ // guessConfig failed.
+ if (e instanceof CancelledException) {
+ return;
+ }
+ self._abortable = null;
+ gAccountSetupLogger.warn(`guessConfig failed: ${e}`);
+ self.showErrorNotification("account-setup-find-settings-failed");
+ self.switchToMode("manual-edit-have-hostname");
+ },
+ newConfig,
+ newConfig.outgoing.existingServerKey ? "incoming" : "both"
+ );
+ },
+
+ // -------------------
+ // UI helper functions
+
+ _prefillConfig(initialConfig) {
+ let emailsplit = this._email.split("@");
+ assert(emailsplit.length > 1);
+ let emaillocal = Sanitizer.nonemptystring(emailsplit[0]);
+ initialConfig.incoming.username = emaillocal;
+ initialConfig.outgoing.username = emaillocal;
+ return initialConfig;
+ },
+
+ clearError(which) {
+ document.getElementById(`${which}Warning`).hidden = true;
+ document.getElementById(`${which}Info`).hidden = false;
+ },
+
+ setError(which, msg_name) {
+ try {
+ document.getElementById(`${which}Info`).hidden = true;
+ document.getElementById(`${which}Warning`).hidden = false;
+ } catch (ex) {
+ alertPrompt("Missing error string", msg_name);
+ }
+ },
+
+ onFormSubmit(event) {
+ // Prevent the actual form submission.
+ event.preventDefault();
+
+ // Select the only primary button that is visible and enabled.
+ let currentButton = document.querySelector(
+ ".buttons-container-last button.primary:not([disabled],[hidden])"
+ );
+ if (currentButton) {
+ currentButton.click();
+ }
+ },
+
+ // -------------------------------
+ // Finish & dialog close functions
+
+ onCancel() {
+ // Some tests might close the account setup before it finishes loading,
+ // therefore the gMainWindow might still be null. If that's the case, do an
+ // early return since we don't need to run any condition.
+ if (!gMainWindow) {
+ window.close();
+ return;
+ }
+
+ // Ask for confirmation if the user never set Thunderbrid to be used without
+ // an email account, and no account has been configured.
+ if (
+ !Services.prefs.getBoolPref("app.use_without_mail_account", false) &&
+ !MailServices.accounts.accounts.length
+ ) {
+ // Abort any possible process before showing the confirmation dialog.
+ this.checkIfAbortable();
+ this.confirmExitDialog();
+ return;
+ }
+
+ window.close();
+ },
+
+ /**
+ * Ask for confirmation when the account setup is dismissed and the user
+ * doesn't have any configured account.
+ */
+ confirmExitDialog() {
+ let dialog = document.getElementById("confirmExitDialog");
+
+ document.getElementById("exitDialogConfirmButton").onclick = () => {
+ // Update the pref only if the checkbox was checked since it's FALSE by
+ // default. We won't expose this checkbox in the UI anymore afterward.
+ if (document.getElementById("useWithoutAccount").checked) {
+ Services.prefs.setBoolPref("app.use_without_mail_account", true);
+ }
+
+ dialog.close();
+ window.close();
+ };
+
+ document.getElementById("exitDialogCancelButton").onclick = () => {
+ dialog.close();
+ };
+
+ dialog.showModal();
+ },
+
+ /**
+ * Disable the exit dialog button if the user checks the "Use without an email
+ * account" checkbox.
+ *
+ * @param {DOMEvent} event - The checkbox change event.
+ */
+ toggleExitDialogButton(event) {
+ document.getElementById("exitDialogCancelButton").disabled =
+ event.target.checked;
+ },
+
+ checkIfAbortable() {
+ if (this._abortable) {
+ this._abortable.cancel(new UserCancelledException());
+ }
+ },
+
+ onUnload() {
+ gMainWindow.document
+ .getElementById("tabmail")
+ .unregisterTabMonitor(this.tabMonitor);
+ this.checkIfAbortable();
+ gAccountSetupLogger.debug("Shutting down email config dialog");
+ },
+
+ async onCreate() {
+ gAccountSetupLogger.debug("Create button clicked");
+
+ let configFilledIn = this.getConcreteConfig();
+ let self = this;
+ // If the dialog is not needed, it will go straight to OK callback
+ gSecurityWarningDialog.open(
+ this._currentConfig,
+ configFilledIn,
+ true,
+ async function () {
+ // on OK
+ await self.validateAndFinish(configFilledIn).catch(async ex => {
+ let errorMessage = await document.l10n.formatValue(
+ "account-setup-creation-error-title"
+ );
+ gAccountSetupLogger.error(errorMessage + ". " + ex);
+
+ self.clearNotifications();
+ let notification = self.notificationBox.appendNotification(
+ "accountSetupError",
+ {
+ label: errorMessage,
+ priority: self.notificationBox.PRIORITY_CRITICAL_HIGH,
+ },
+ null
+ );
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+ });
+ },
+ function () {
+ // on cancel, do nothing
+ }
+ );
+ },
+
+ // called by onCreate()
+ async validateAndFinish(configFilled) {
+ let configFilledIn = configFilled || this.getConcreteConfig();
+ if (
+ configFilledIn.incoming.type == "exchange" &&
+ "addonAccountType" in configFilledIn.incoming
+ ) {
+ configFilledIn.incoming.type = configFilledIn.incoming.addonAccountType;
+ }
+
+ if (CreateInBackend.checkIncomingServerAlreadyExists(configFilledIn)) {
+ let [title, description] = await document.l10n.formatValues([
+ "account-setup-creation-error-title",
+ "account-setup-error-server-exists",
+ ]);
+ Services.prompt.alert(null, title, description);
+ return;
+ }
+
+ if (configFilledIn.outgoing.addThisServer) {
+ let existingServer =
+ CreateInBackend.checkOutgoingServerAlreadyExists(configFilledIn);
+ if (existingServer) {
+ configFilledIn.outgoing.addThisServer = false;
+ configFilledIn.outgoing.existingServerKey = existingServer.key;
+ }
+ }
+
+ let createButton = document.getElementById("createButton");
+ let reTestButton = document.getElementById("reTestButton");
+ createButton.disabled = true;
+ reTestButton.disabled = true;
+
+ this.clearNotifications();
+ this.startLoadingState("account-setup-checking-password");
+ let telemetryKey =
+ this._currentConfig.source == AccountConfig.kSourceXML ||
+ this._currentConfig.source == AccountConfig.kSourceExchange
+ ? this._currentConfig.subSource
+ : this._currentConfig.source;
+
+ let self = this;
+ let verifier = new ConfigVerifier(this._msgWindow);
+ window.addEventListener("unload", event => {
+ verifier.cleanup();
+ });
+ verifier
+ .verifyConfig(
+ configFilledIn,
+ // guess login config?
+ configFilledIn.source != AccountConfig.kSourceXML
+ // TODO Instead, the following line would be correct, but I cannot use it,
+ // because some other code doesn't adhere to the expectations/specs.
+ // Find out what it was and fix it.
+ // concreteConfig.source == AccountConfig.kSourceGuess,
+ )
+ .then(successfulConfig => {
+ // success
+ self.stopLoadingState(
+ successfulConfig.incoming.password
+ ? "account-setup-success-password"
+ : null
+ );
+
+ // The auth might have changed, so we should update the current config.
+ self._currentConfig.incoming.auth = successfulConfig.incoming.auth;
+ self._currentConfig.outgoing.auth = successfulConfig.outgoing.auth;
+ self._currentConfig.incoming.username =
+ successfulConfig.incoming.username;
+ self._currentConfig.outgoing.username =
+ successfulConfig.outgoing.username;
+
+ // We loaded dynamic client registration, fill this data back in to the
+ // config set.
+ if (successfulConfig.incoming.oauthSettings) {
+ self._currentConfig.incoming.oauthSettings =
+ successfulConfig.incoming.oauthSettings;
+ }
+ if (successfulConfig.outgoing.oauthSettings) {
+ self._currentConfig.outgoing.oauthSettings =
+ successfulConfig.outgoing.oauthSettings;
+ }
+ self.finish(configFilledIn);
+
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.successful_email_account_setup",
+ telemetryKey,
+ 1
+ );
+ })
+ .catch(e => {
+ // failed
+ // Could be a wrong password, but there are 1000 other
+ // reasons why this failed. Only the backend knows.
+ // If we got no message, then something other than VerifyLogon failed.
+
+ // For an Exchange server, some known configurations can
+ // be disabled (per user or domain or server).
+ // Warn the user if the open protocol we tried didn't work.
+ if (
+ ["imap", "pop3"].includes(configFilledIn.incoming.type) &&
+ configFilledIn.incomingAlternatives.some(i => i.type == "exchange")
+ ) {
+ self.showErrorNotification(
+ "account-setup-exchange-config-unverifiable"
+ );
+ } else {
+ let msg = e.message || e.toString();
+ self.showErrorNotification(msg, true);
+ }
+
+ // give user something to proceed after fixing
+ createButton.disabled = false;
+ // hidden in non-manual mode, so it's fine to enable
+ reTestButton.disabled = false;
+
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.failed_email_account_setup",
+ telemetryKey,
+ 1
+ );
+ });
+ },
+
+ /**
+ * @param {AccountConfig} concreteConfig - The config to use.
+ */
+ finish(concreteConfig) {
+ gAccountSetupLogger.debug("creating account in backend");
+ let newAccount = CreateInBackend.createAccountInBackend(concreteConfig);
+
+ // Trigger the first login to download the folder structure and messages.
+ newAccount.incomingServer.getNewMessages(
+ newAccount.incomingServer.rootFolder,
+ this._msgWindow,
+ null
+ );
+
+ if (this._okCallback) {
+ this._okCallback();
+ }
+
+ this.showSuccessView(newAccount);
+ },
+
+ /**
+ * Toggle the visibility of the list of available services to configure.
+ */
+ toggleSetupContainer(event) {
+ let container = event.target.closest(".linked-services-section");
+ container.classList.toggle("opened");
+ container
+ .querySelector(".linked-services-container")
+ .toggleAttribute("hidden");
+ },
+
+ /**
+ * Update the account setup tab to show a successful final view with quick
+ * links and suggested next steps.
+ *
+ * @param {nsIMsgAccount} account - The newly created account.
+ */
+ async showSuccessView(account) {
+ gAccountSetupLogger.debug("Account creation successful");
+
+ // Populate the account recap info.
+ document.getElementById("newAccountName").textContent = this._realname;
+ document.getElementById("newAccountEmail").textContent = this._email;
+ document.getElementById("newAccountProtocol").textContent =
+ account.incomingServer.type;
+
+ // Store the host domain that will be used to look for CardDAV and CalDAV
+ // services.
+ this._hostname = this._email.split("@")[1];
+
+ // Set up event listeners for the quick links.
+ document.getElementById("settingsButton").addEventListener(
+ "click",
+ () => {
+ MsgAccountManager(null, account.incomingServer);
+ },
+ { once: true }
+ );
+
+ // Hide the e2ee button if the current server doesn't support it.
+ let hasEncryption =
+ account.incomingServer.type != "rss" &&
+ account.incomingServer.type != "nntp" &&
+ account.incomingServer.protocolInfo?.canGetMessages;
+ document.getElementById("encryptionButton").hidden = !hasEncryption;
+ if (hasEncryption) {
+ document
+ .getElementById("encryptionButton")
+ .addEventListener("click", () => {
+ MsgAccountManager("am-e2e.xhtml", account.incomingServer);
+ });
+ }
+
+ document.getElementById("signatureButton").addEventListener("click", () => {
+ MsgAccountManager(null, account.incomingServer);
+ });
+
+ // Finally, show the success view.
+ this.switchToMode("success");
+
+ // Initialize the fetching of possible linked services like address books
+ // or calendars.
+ gAccountSetupLogger.debug("Fetching linked address books and calendars");
+
+ let notification = this.syncingBox.appendNotification(
+ "accountSetupLoading",
+ {
+ label: await document.l10n.formatValue(
+ "account-setup-looking-up-address-books"
+ ),
+ priority: this.syncingBox.PRIORITY_INFO_LOW,
+ },
+ null
+ );
+ notification.setAttribute("align", "center");
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ // Detect linked address books.
+ await this.fetchAddressBooks();
+
+ // Update the notification and start detecting linked calendars.
+ document.l10n.setAttributes(
+ notification.messageText,
+ "account-setup-looking-up-calendars"
+ );
+ await this.fetchCalendars();
+
+ // Update the connected services description if we have at least one address
+ // book or one calendar we can connect to.
+ document.l10n.setAttributes(
+ document.getElementById("linkedServicesDescription"),
+ !this.addressBooks.length && !this.calendars.size
+ ? "account-setup-no-linked-description"
+ : "account-setup-linked-services-description"
+ );
+
+ // Clear the loading notification.
+ this.syncingBox.removeAllNotifications();
+ this.showHelperImage("step5");
+ },
+
+ /**
+ * Fetch any available CardDAV address books.
+ */
+ async fetchAddressBooks() {
+ this.addressBooks = [];
+ try {
+ this.addressBooks = await CardDAVUtils.detectAddressBooks(
+ this._email,
+ this._password,
+ `https://${this._hostname}`,
+ false
+ );
+ } catch (ex) {
+ gAccountSetupLogger.error(ex);
+ }
+
+ let hideAddressBookUI = !this.addressBooks.length;
+ document.getElementById("linkedAddressBooks").hidden = hideAddressBookUI;
+
+ // Clear the UI from any previous list.
+ let abList = document.querySelector(
+ "#addressBooksSetup .linked-services-list"
+ );
+ while (abList.hasChildNodes()) {
+ abList.lastChild.remove();
+ }
+
+ // Interrupt if we don't have anything to show.
+ if (hideAddressBookUI) {
+ return;
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("addressBooksCountDescription"),
+ "account-setup-found-address-books-description",
+ { count: this.addressBooks.length }
+ );
+
+ // Collect existing carddav address books to compare with the list of
+ // recently fetched ones.
+ let existing = MailServices.ab.directories.map(d =>
+ d.getStringValue("carddav.url", "")
+ );
+
+ // Populate the list of available address books.
+ for (let book of this.addressBooks) {
+ let provider = document.createElement("span");
+ provider.classList.add("protocol-type");
+ provider.textContent = "CardDAV";
+
+ let name = document.createElement("span");
+ name.classList.add("list-item-name");
+ name.textContent = book.name;
+
+ let button = document.createElement("button");
+ button.setAttribute("type", "button");
+
+ if (existing.includes(book.url.href)) {
+ // This address book aready exists for some reason, so disable the
+ // button and mark it as existing.
+ button.classList.add("existing", "small-button");
+ document.l10n.setAttributes(
+ button,
+ "account-setup-existing-address-book"
+ );
+ button.disabled = true;
+ } else {
+ button.classList.add("small-button");
+ document.l10n.setAttributes(button, "account-setup-connect-link");
+ button.addEventListener("click", () => {
+ this._setupAddressBook(button, book);
+ });
+ }
+
+ let row = document.createElement("li");
+ row.appendChild(provider);
+ row.appendChild(name);
+ row.appendChild(button);
+ abList.appendChild(row);
+ }
+
+ // Show a "connect all" button if we have more than one address book.
+ document.getElementById("addressBooksSetupAll").hidden =
+ this.addressBooks.length <= 1;
+ },
+
+ /**
+ * Connect to the selected address book.
+ *
+ * @param {HTMLElement} button - The clicked button in the list.
+ * @param {foundBook} book - The address book to configure.
+ */
+ _setupAddressBook(button, book) {
+ book.create();
+
+ // Update the button to reflect the creation of the new address book.
+ button.classList.add("existing");
+ document.l10n.setAttributes(button, "account-setup-existing-address-book");
+ button.disabled = true;
+
+ // Check if we have any address book left to set up and hide the
+ // "Connect all" button if not.
+ document.getElementById("addressBooksSetupAll").hidden =
+ !document.querySelectorAll(
+ "#addressBooksSetup .linked-services-list button:not(.existing)"
+ ).length;
+ },
+
+ /**
+ * Loop through all available address books found and click the connect
+ * button to trigger the method attached to the onclick listener.
+ */
+ setupAllAddressBooks() {
+ for (let button of document.querySelectorAll(
+ "#addressBooksSetup .linked-services-list button"
+ )) {
+ button.click();
+ }
+ },
+
+ /**
+ * Fetch any available CalDAV calendars.
+ */
+ async fetchCalendars() {
+ this.calendars = {};
+ try {
+ this.calendars = await cal.provider.detection.detect(
+ this._email,
+ this._password,
+ `https://${this._hostname}`,
+ document.getElementById("rememberPassword").checked,
+ [],
+ {}
+ );
+ } catch (ex) {
+ gAccountSetupLogger.error(ex);
+ }
+
+ let hideCalendarUI = !this.calendars.size;
+ document.getElementById("linkedCalendars").hidden = hideCalendarUI;
+
+ // Clear the UI from any previous list.
+ let calList = document.querySelector(
+ "#calendarsSetup .linked-services-list"
+ );
+ while (calList.hasChildNodes()) {
+ calList.lastChild.remove();
+ }
+
+ // Interrupt if we don't have anything to show.
+ if (hideCalendarUI) {
+ return;
+ }
+
+ // Collect existing calendars to compare with the list of recently fetched
+ // ones.
+ let existing = new Set(
+ cal.manager.getCalendars({}).map(calendar => calendar.uri.spec)
+ );
+
+ let calendarsCount = 0;
+
+ // Populate the list of available calendars.
+ for (let [provider, calendars] of this.calendars.entries()) {
+ for (let calendar of calendars) {
+ let cal_provider = document.createElement("span");
+ cal_provider.classList.add("protocol-type");
+ cal_provider.textContent = provider.shortName;
+
+ let cal_name = document.createElement("span");
+ cal_name.classList.add("list-item-name");
+ cal_name.textContent = calendar.name;
+
+ let button = document.createElement("button");
+ button.setAttribute("type", "button");
+
+ if (existing.has(calendar.uri.spec)) {
+ // This calendar aready exists for some reason, so disable the button
+ // and mark it as existing.
+ button.classList.add("existing", "small-button");
+ document.l10n.setAttributes(
+ button,
+ "account-setup-existing-calendar"
+ );
+ button.disabled = true;
+ } else {
+ button.classList.add("small-button");
+ document.l10n.setAttributes(button, "account-setup-connect-link");
+ button.addEventListener("click", () => {
+ // If the button has a specific data attribute it means we want to
+ // set up the calendar directly without opening the dialog.
+ if (button.hasAttribute("data-setup-calendar")) {
+ this._setupCalendar(button, calendar);
+ return;
+ }
+
+ this._showCalendarDialog(button, calendar);
+ });
+ }
+
+ let row = document.createElement("li");
+ row.appendChild(cal_provider);
+ row.appendChild(cal_name);
+ row.appendChild(button);
+ calList.appendChild(row);
+
+ calendarsCount++;
+ }
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("calendarsCountDescription"),
+ "account-setup-found-calendars-description",
+ { count: calendarsCount }
+ );
+
+ // Show a "connect all" button if we have more than one calendar.
+ document.getElementById("calendarsSetupAll").hidden = calendarsCount <= 1;
+ },
+
+ /**
+ * Show the dialog to connect the selected calendar. This native HTML dialog
+ * is a streamlined version of the calendar-properties-dialog.xhtml. The two
+ * dialogs should kept in sync if a property of the calendar changes that
+ * requires updating any field.
+ *
+ * @param {HTMLElement} button - The clicked button in the list.
+ * @param {calICalendar} calendar - The calendar to configure.
+ */
+ _showCalendarDialog(button, calendar) {
+ let dialog = document.getElementById("calendarDialog");
+
+ // Update the calendar info in the dialog.
+ let nameInput = document.getElementById("calendarName");
+ nameInput.value = calendar.name;
+
+ // Some servers provide colors as an 8-character hex string, which the color
+ // picker can't handle. Strip the alpha component.
+ let color = calendar.getProperty("color");
+ let alpha = color?.match(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/);
+ if (alpha) {
+ calendar.setProperty("color", alpha[1]);
+ color = alpha[1];
+ }
+ let colorInput = document.getElementById("calendarColor");
+ colorInput.value = color || "#A8C2E1";
+
+ let readOnlyCheckbox = document.getElementById("calendarReadOnly");
+ readOnlyCheckbox.checked = calendar.readOnly;
+
+ // Hide the "Show reminders" checkbox if the calendar doesn't support it.
+ document.getElementById("calendarShowRemindersRow").hidden =
+ calendar.getProperty("capabilities.alarms.popup.supported") === false;
+ let remindersCheckbox = document.getElementById("calendarShowReminders");
+ remindersCheckbox.checked = !calendar.getProperty("suppressAlarms");
+
+ // Hide the "Offline support" if the calendar doesn't support it.
+ let offlineCheckbox = document.getElementById("calendarOfflineSupport");
+ let canCache = calendar.getProperty("cache.supported") !== false;
+ let alwaysCache = calendar.getProperty("cache.always");
+ if (!canCache || alwaysCache) {
+ offlineCheckbox.hidden = true;
+ offlineCheckbox.disabled = true;
+ }
+ offlineCheckbox.checked =
+ alwaysCache || (canCache && calendar.getProperty("cache.enabled"));
+
+ // Set up the "Refresh calendar" menulist.
+ let calendarRefresh = document.getElementById("calendarRefresh");
+ calendarRefresh.disabled = !calendar.canRefresh;
+ calendarRefresh.value = calendar.getProperty("refreshInterval") || 30;
+
+ // Set up the dialog's action buttons.
+ document.getElementById("calendarDialogConfirmButton").onclick = () => {
+ // Update the attributes of the calendar in case the user changed some
+ // values.
+ calendar.name = nameInput.value;
+ calendar.setProperty("color", colorInput.value);
+ if (calendar.canRefresh) {
+ calendar.setProperty("refreshInterval", calendarRefresh.value);
+ }
+
+ calendar.readOnly = readOnlyCheckbox.checked;
+ calendar.setProperty("suppressAlarms", !remindersCheckbox.checked);
+ if (!alwaysCache) {
+ calendar.setProperty("cache.enabled", offlineCheckbox.checked);
+ }
+
+ this._setupCalendar(button, calendar);
+ dialog.close();
+ };
+
+ document.getElementById("calendarDialogCancelButton").onclick = () => {
+ dialog.close();
+ };
+
+ dialog.showModal();
+ },
+
+ /**
+ * Connect to the selected calendar.
+ *
+ * @param {HTMLElement} button - The clicked button in the list.
+ * @param {calICalendar} calendar - The calendar to configure.
+ */
+ _setupCalendar(button, calendar) {
+ cal.manager.registerCalendar(calendar);
+
+ // Update the button to reflect the creation of the new calendar.
+ button.classList.add("existing");
+ document.l10n.setAttributes(button, "account-setup-existing-calendar");
+ button.disabled = true;
+
+ // Check if we have any calendar left to set up and hide the "Connect all"
+ // button if not.
+ document.getElementById("calendarsSetupAll").hidden =
+ !document.querySelectorAll(
+ "#calendarsSetup .linked-services-list button:not(.existing)"
+ ).length;
+ },
+
+ /**
+ * Loop through all available calendars found and click the connect
+ * button to trigger the method attached to the onclick listener.
+ */
+ setupAllCalendars() {
+ for (let button of document.querySelectorAll(
+ "#calendarsSetup .linked-services-list button:not(.existing)"
+ )) {
+ // Set the attribute to skip the opening of the properties dialog.
+ button.setAttribute("data-setup-calendar", true);
+ button.click();
+ }
+ },
+
+ /**
+ * Called from the very final view of the account setup, when the user decides
+ * to close the wizard.
+ */
+ onFinish() {
+ // Send the message to the mail tab in case the UI didn't load during the
+ // previous setup callback.
+ gMainWindow.postMessage("account-setup-closed", "*");
+ // Close this tab.
+ window.close();
+ },
+};
+
+function serverMatches(a, b) {
+ return (
+ a.type == b.type &&
+ a.hostname == b.hostname &&
+ a.port == b.port &&
+ a.socketType == b.socketType &&
+ a.auth == b.auth
+ );
+}
+
+/**
+ * Warning dialog, warning user about lack of, or inappropriate, encryption.
+ */
+var gSecurityWarningDialog = {
+ /**
+ * {Array of {(incoming or outgoing) server part of {AccountConfig}}
+ * A list of the servers for which we already showed this dialog and the
+ * user approved the configs. For those, we won't show the warning again.
+ * (Make sure to store a copy in case the underlying object is changed.)
+ */
+ _acknowledged: [],
+
+ _inSecurityBad: 0x0001,
+ _inCertBad: 0x0010,
+ _outSecurityBad: 0x0100,
+ _outCertBad: 0x1000,
+
+ /**
+ * Checks whether we need to warn about this config.
+ *
+ * We (currently) warn if
+ * - the mail travels unsecured (no SSL/STARTTLS)
+ * - (We don't warn about unencrypted passwords specifically,
+ * because they'd be encrypted with SSL and without SSL, we'd
+ * warn anyways.)
+ *
+ * We may not warn despite these conditions if we had shown the
+ * warning for that server before and the user acknowledged it.
+ * (Given that this dialog object is static/global and persistent,
+ * we can store that approval state here in this object.)
+ *
+ * @param configSchema @see open()
+ * @param configFilledIn @see open()
+ * @returns {boolean} - True when the dialog should be shown
+ * (call open()). if false, the dialog can and should be skipped.
+ */
+ needed(configSchema, configFilledIn) {
+ assert(configSchema instanceof AccountConfig);
+ assert(configFilledIn instanceof AccountConfig);
+ assert(configSchema.isComplete());
+ assert(configFilledIn.isComplete());
+
+ let incomingBad =
+ (configFilledIn.incoming.socketType > 1 ? 0 : this._inSecurityBad) |
+ (configFilledIn.incoming.badCert ? this._inCertBad : 0);
+ let outgoingBad = 0;
+ if (configFilledIn.outgoing.addThisServer) {
+ outgoingBad =
+ (configFilledIn.outgoing.socketType > 1 ? 0 : this._outSecurityBad) |
+ (configFilledIn.outgoing.badCert ? this._outCertBad : 0);
+ }
+
+ if (incomingBad > 0) {
+ if (
+ this._acknowledged.some(ackServer => {
+ return serverMatches(ackServer, configFilledIn.incoming);
+ })
+ ) {
+ incomingBad = 0;
+ }
+ }
+ if (outgoingBad > 0) {
+ if (
+ this._acknowledged.some(ackServer => {
+ return serverMatches(ackServer, configFilledIn.outgoing);
+ })
+ ) {
+ outgoingBad = 0;
+ }
+ }
+
+ return incomingBad | outgoingBad;
+ },
+
+ /**
+ * Opens the dialog, fills it with values, and shows it to the user.
+ *
+ * The function is async: it returns immediately, and when the user clicks
+ * OK or Cancel, the callbacks are called. There the callers proceed as
+ * appropriate.
+ *
+ * @param configSchema The config, with placeholders not replaced yet.
+ * This object may be modified to store the user's confirmations, but
+ * currently that's not the case.
+ * @param configFilledIn The concrete config with placeholders replaced.
+ * @param onlyIfNeeded {Boolean} - If there is nothing to warn about,
+ * call okCallback() immediately (and sync).
+ * @param okCallback {function(config {AccountConfig})}
+ * Called when the user clicked OK and approved the config including
+ * the warnings. |config| is without placeholders replaced.
+ * @param cancalCallback {function()}
+ * Called when the user decided to heed the warnings and not approve.
+ */
+ open(configSchema, configFilledIn, onlyIfNeeded, okCallback, cancelCallback) {
+ assert(typeof okCallback == "function");
+ assert(typeof cancelCallback == "function");
+
+ // needed() also checks the parameters
+ let needed = this.needed(configSchema, configFilledIn);
+ if (needed == 0 && onlyIfNeeded) {
+ okCallback();
+ return;
+ }
+
+ assert(needed > 0, "security dialog opened needlessly");
+
+ let dialog = document.getElementById("insecureDialog");
+ this._currentConfigFilledIn = configFilledIn;
+ this._okCallback = okCallback;
+ this._cancelCallback = cancelCallback;
+ let incoming = configFilledIn.incoming;
+ let outgoing = configFilledIn.outgoing;
+
+ // Reset the dialog, in case we've shown it before.
+ document.getElementById("acknowledgeWarning").checked = false;
+ document.getElementById("insecureConfirmButton").disabled = true;
+
+ // Incoming security is bad.
+ let insecureIncoming = document.getElementById("insecureSectionIncoming");
+ if (needed & this._inSecurityBad) {
+ document.l10n.setAttributes(
+ document.getElementById("warningIncoming"),
+ "account-setup-warning-cleartext",
+ {
+ server: incoming.hostname,
+ }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("detailsIncoming"),
+ "account-setup-warning-cleartext-details"
+ );
+
+ insecureIncoming.hidden = false;
+ } else {
+ insecureIncoming.hidden = true;
+ }
+
+ // Outgoing security or certificate is bad.
+ let insecureOutgoing = document.getElementById("insecureSectionOutgoing");
+ if (needed & this._outSecurityBad) {
+ document.l10n.setAttributes(
+ document.getElementById("warningOutgoing"),
+ "account-setup-warning-cleartext",
+ {
+ server: outgoing.hostname,
+ }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("detailsOutgoing"),
+ "account-setup-warning-cleartext-details"
+ );
+
+ insecureOutgoing.hidden = false;
+ } else {
+ insecureOutgoing.hidden = true;
+ }
+
+ assert(
+ !insecureIncoming.hidden || !insecureOutgoing.hidden,
+ "warning dialog shown for unknown reason"
+ );
+
+ // Show the dialog.
+ dialog.showModal();
+ },
+
+ /**
+ * User checked checkbox that he understood it and wishes to ignore the
+ * warning.
+ */
+ toggleAcknowledge() {
+ document.getElementById("insecureConfirmButton").disabled =
+ !document.getElementById("acknowledgeWarning").checked;
+ },
+
+ /**
+ * [Cancel] button pressed. Get me out of here!
+ */
+ onCancel() {
+ document.getElementById("insecureDialog").close();
+ document.getElementById("incomingProtocol").focus();
+
+ this._cancelCallback();
+ },
+
+ /**
+ * [OK] button pressed.
+ * Implies that the user toggled the acknowledge checkbox,
+ * i.e. approved the config and ignored the warnings,
+ * otherwise the button would have been disabled.
+ */
+ onOK() {
+ assert(document.getElementById("acknowledgeWarning").checked);
+
+ // Need filled in, in case the hostname is a placeholder.
+ let storeConfig = this._currentConfigFilledIn.copy();
+ this._acknowledged.push(storeConfig.incoming);
+ this._acknowledged.push(storeConfig.outgoing);
+
+ document.getElementById("insecureDialog").close();
+
+ this._okCallback();
+ },
+};
+
+/**
+ * Helper method to open the dictionaries list in a new tab.
+ */
+function openDictionariesTab() {
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ let tabmail = mailWindow.document.getElementById("tabmail");
+
+ let url = Services.urlFormatter.formatURLPref(
+ "spellchecker.dictionaries.download.url"
+ );
+
+ // Open the dictionaries URL.
+ tabmail.openTab("contentTab", {
+ url,
+ });
+}
diff --git a/comm/mail/components/accountcreation/content/accountSetup.xhtml b/comm/mail/components/accountcreation/content/accountSetup.xhtml
new file mode 100644
index 0000000000..ed1148c561
--- /dev/null
+++ b/comm/mail/components/accountcreation/content/accountSetup.xhtml
@@ -0,0 +1,1333 @@
+<?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/. -->
+
+<!DOCTYPE html>
+<html
+ id="accountSetup"
+ 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:accountsetup"
+>
+ <head>
+ <title data-l10n-id="account-setup-tab-title"></title>
+ <meta name="color-scheme" content="light dark" />
+ <link
+ rel="icon"
+ href="chrome://messenger/skin/icons/new/compact/new-mail.svg"
+ />
+
+ <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/menulist.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/inContentDialog.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/accountSetup.css" />
+
+ <link rel="localization" href="branding/brand.ftl" />
+ <link
+ rel="localization"
+ href="messenger/accountcreation/accountSetup.ftl"
+ />
+
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/accountUtils.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/accountcreation/accountSetup.js"
+ ></script>
+ </head>
+
+ <body>
+ <!-- Native HTML dialog used for setup cancel confirmation. -->
+ <dialog id="confirmExitDialog" class="account-setup-dialog">
+ <div class="dialog-container vertical">
+ <h2 class="dialog-title">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ alt=""
+ class="dialog-header-image"
+ />
+ <span data-l10n-id="exit-dialog-title"></span>
+ </h2>
+
+ <div class="dialog-container">
+ <p
+ data-l10n-id="exit-dialog-description"
+ class="dialog-description indent"
+ ></p>
+ </div>
+
+ <label class="toggle-container-with-text indent">
+ <input
+ id="useWithoutAccount"
+ type="checkbox"
+ onchange="gAccountSetup.toggleExitDialogButton(event);"
+ />
+ <span
+ data-l10n-id="account-setup-no-account-checkbox"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+ </div>
+
+ <menu class="dialog-menu-container">
+ <button
+ id="exitDialogConfirmButton"
+ data-l10n-id="exit-dialog-confirm-button"
+ ></button>
+ <button
+ id="exitDialogCancelButton"
+ data-l10n-id="exit-dialog-cancel-button"
+ class="primary"
+ ></button>
+ </menu>
+ </dialog>
+
+ <!-- Native HTML dialog used for Exchange confirmation. -->
+ <dialog id="exchangeDialog" class="account-setup-dialog">
+ <div class="dialog-container">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/question.svg"
+ alt=""
+ class="dialog-header-image"
+ />
+ <p id="exchangeDialogQuestion" class="dialog-description"></p>
+ </div>
+ <menu class="dialog-menu-container">
+ <button
+ id="exchangeDialogCancelButton"
+ data-l10n-id="exchange-dialog-cancel-button"
+ ></button>
+ <button
+ id="exchangeDialogConfirmButton"
+ data-l10n-id="exchange-dialog-confirm-button"
+ class="primary"
+ ></button>
+ </menu>
+ </dialog>
+
+ <!-- Native HTML dialog used for insecure password confirmation. -->
+ <dialog id="insecureDialog" class="account-setup-dialog dialog-critical">
+ <div class="dialog-container vertical">
+ <h2 class="warning-title">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ alt=""
+ class="dialog-header-image warning-icon"
+ />
+ <span data-l10n-id="account-setup-insecure-title"></span>
+ </h2>
+
+ <section
+ id="insecureSectionIncoming"
+ class="insecure-section content-blocking-category"
+ hidden="hidden"
+ >
+ <h3 data-l10n-id="account-setup-insecure-incoming-title"></h3>
+ <p id="warningIncoming"></p>
+ <p
+ id="detailsIncoming"
+ class="insecure-section-description indent"
+ ></p>
+ </section>
+
+ <section
+ id="insecureSectionOutgoing"
+ class="insecure-section content-blocking-category"
+ hidden="hidden"
+ >
+ <h3 data-l10n-id="account-setup-insecure-outgoing-title"></h3>
+ <p id="warningOutgoing"></p>
+ <p
+ id="detailsOutgoing"
+ class="insecure-section-description indent"
+ ></p>
+ </section>
+
+ <p
+ class="dialog-footnote"
+ data-l10n-id="account-setup-insecure-description"
+ >
+ <a
+ href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-name="thunderbird-faq-link"
+ ></a>
+ </p>
+ </div>
+
+ <menu class="dialog-menu-container two-columns">
+ <aside>
+ <label class="toggle-container-with-text">
+ <input
+ id="acknowledgeWarning"
+ type="checkbox"
+ onchange="gSecurityWarningDialog.toggleAcknowledge();"
+ />
+ <span
+ data-l10n-id="account-setup-insecure-server-checkbox"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+ </aside>
+
+ <aside>
+ <button
+ data-l10n-id="insecure-dialog-cancel-button"
+ onclick="gSecurityWarningDialog.onCancel();"
+ ></button>
+ <button
+ id="insecureConfirmButton"
+ data-l10n-id="insecure-dialog-confirm-button"
+ class="primary"
+ disable="disabled"
+ onclick="gSecurityWarningDialog.onOK();"
+ ></button>
+ </aside>
+ </menu>
+ </dialog>
+
+ <!-- Native HTML dialog for Calendar synchronization. This is a streamlined
+ version of the calendar-properties-dialog.xhtml with fewer properties:
+ - Name
+ - Color
+ - Refresh rate
+ - Read only
+ - Show reminders
+ - Offline support
+ This dialog should be kept synced with the calendar-properties-dialog.xhtml
+ if one of these properties changes.
+ -->
+ <dialog id="calendarDialog" class="account-setup-dialog">
+ <div class="dialog-container vertical">
+ <div class="dialog-container">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/new-event.svg"
+ alt=""
+ class="dialog-header-image small"
+ />
+ <p
+ data-l10n-id="calendar-dialog-title"
+ class="dialog-description"
+ ></p>
+ </div>
+
+ <section class="calendar-dialog-form">
+ <label
+ for="calendarName"
+ data-l10n-id="account-setup-calendar-name-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="calendarName"
+ type="text"
+ autocomplete="off"
+ class="input-field input-grow"
+ data-l10n-id="account-setup-calendar-name-input"
+ required="required"
+ />
+ </div>
+
+ <label
+ for="calendarColor"
+ data-l10n-id="account-setup-calendar-color-label"
+ >
+ </label>
+ <div class="input-control">
+ <input id="calendarColor" type="color" />
+ </div>
+
+ <label
+ for="calendarRefresh"
+ data-l10n-id="account-setup-calendar-refresh-label"
+ >
+ </label>
+ <div class="input-control">
+ <select id="calendarRefresh" class="input-grow">
+ <option
+ data-l10n-id="account-setup-calendar-refresh-manual"
+ value="0"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 1 }'
+ value="1"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 5 }'
+ value="5"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 15 }'
+ value="15"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 30 }'
+ value="30"
+ selected="selected"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 60 }'
+ value="60"
+ ></option>
+ </select>
+ </div>
+ </section>
+
+ <section class="indent">
+ <label class="toggle-container-with-text">
+ <input id="calendarReadOnly" type="checkbox" />
+ <span
+ data-l10n-id="account-setup-calendar-read-only"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+
+ <label
+ id="calendarShowRemindersRow"
+ class="toggle-container-with-text"
+ >
+ <input
+ id="calendarShowReminders"
+ type="checkbox"
+ checked="checked"
+ />
+ <span
+ data-l10n-id="account-setup-calendar-show-reminders"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+
+ <label class="toggle-container-with-text">
+ <input
+ id="calendarOfflineSupport"
+ type="checkbox"
+ checked="checked"
+ />
+ <span
+ data-l10n-id="account-setup-calendar-offline-support"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+ </section>
+ </div>
+
+ <menu class="dialog-menu-container">
+ <button
+ id="calendarDialogCancelButton"
+ data-l10n-id="calendar-dialog-cancel-button"
+ ></button>
+ <button
+ id="calendarDialogConfirmButton"
+ data-l10n-id="calendar-dialog-confirm-button"
+ class="primary"
+ ></button>
+ </menu>
+ </dialog>
+
+ <header>
+ <h1
+ id="accountSetupTitle"
+ data-l10n-id="account-setup-title"
+ class="title"
+ ></h1>
+ <p
+ id="accountSetupDescription"
+ data-l10n-id="account-setup-description"
+ class="description"
+ ></p>
+ <p
+ id="accountSetupDescriptionSecondary"
+ data-l10n-id="account-setup-secondary-description"
+ class="description"
+ ></p>
+ </header>
+
+ <section class="main-container">
+ <aside id="setupView" class="column first-column">
+ <form id="form" onsubmit="gAccountSetup.onFormSubmit(event);">
+ <!-- Hidden submit field to enable the natural Enter keypress to
+ submit the form. We do this because we have the Continue and Done
+ button outside the form and we want to only handle the Enter to
+ submit on the primary fields inside the form. -->
+ <input type="submit" hidden="hidden" />
+ <label
+ for="realname"
+ data-l10n-id="account-setup-name-label"
+ data-l10n-attrs="accesskey"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="realname"
+ type="text"
+ autocomplete="off"
+ class="input-field"
+ data-l10n-id="account-setup-name-input"
+ oninput="gAccountSetup.onInputRealname();"
+ required="required"
+ />
+ <img
+ id="realnameInfo"
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ data-l10n-id="account-setup-name-info-icon"
+ alt=""
+ class="form-icon"
+ />
+ <img
+ id="realnameWarning"
+ src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ data-l10n-id="account-setup-name-warning-icon"
+ alt=""
+ class="form-icon icon-warning"
+ />
+ </div>
+
+ <label
+ for="email"
+ data-l10n-id="account-setup-email-label"
+ data-l10n-attrs="accesskey"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="email"
+ type="email"
+ autocomplete="off"
+ data-l10n-id="account-setup-email-input"
+ class="input-field"
+ oninput="gAccountSetup.onInputEmail();"
+ required="required"
+ />
+ <img
+ id="emailInfo"
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ data-l10n-id="account-setup-email-info-icon"
+ alt=""
+ class="form-icon"
+ />
+ <img
+ id="emailWarning"
+ src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ data-l10n-id="account-setup-email-warning-icon"
+ alt=""
+ class="form-icon icon-warning"
+ />
+ </div>
+
+ <div class="provisioner-button-container">
+ <button
+ id="provisionerButton"
+ type="button"
+ data-l10n-id="account-provisioner-button"
+ data-l10n-attrs="accesskey"
+ class="btn-link btn-link-new-email"
+ onclick="openAccountProvisionerTab();"
+ ></button>
+ </div>
+
+ <label
+ for="password"
+ data-l10n-id="account-setup-password-label"
+ data-l10n-attrs="accesskey"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="password"
+ type="password"
+ autocomplete="off"
+ class="input-field"
+ oninput="gAccountSetup.onInputPassword();"
+ />
+ <button
+ id="passwordToggleButton"
+ type="button"
+ onclick="gAccountSetup.passwordToggle(event);"
+ data-l10n-id="account-setup-password-toggle-show"
+ class="form-toggle-button"
+ hidden="hidden"
+ >
+ <img
+ id="passwordInfo"
+ src="chrome://messenger/skin/icons/new/compact/hidden.svg"
+ class="form-icon"
+ alt=""
+ />
+ </button>
+ </div>
+
+ <div class="remember-button-container">
+ <label class="toggle-container-with-text">
+ <input id="rememberPassword" type="checkbox" checked="checked" />
+ <span
+ data-l10n-id="account-setup-remember-password"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+ </div>
+
+ <div id="usernameRow" hidden="hidden">
+ <!-- This is only used for Exchange AutoDiscover, and even then
+ only when absolutely necessary and known to be needed. -->
+ <label
+ for="usernameEx"
+ data-l10n-id="account-setup-exchange-label"
+ data-l10n-attrs="accesskey"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="usernameEx"
+ type="text"
+ data-l10n-id="account-setup-exchange-input"
+ class="input-field"
+ oninput="gAccountSetup.onInputUsername();"
+ />
+ <img
+ id="usernameExInfo"
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ class="form-icon"
+ data-l10n-id="account-setup-exchange-info-icon"
+ alt=""
+ />
+ </div>
+ </div>
+ </form>
+
+ <section
+ id="accountSetupNotifications"
+ class="account-setup-notifications"
+ >
+ <!-- Notifications will be lazily loaded here. -->
+ </section>
+
+ <!-- Results area -->
+ <section id="resultsArea" hidden="hidden">
+ <h4 id="resultAreaTitle" class="section-title"></h4>
+
+ <!-- IMAP -->
+ <div
+ id="resultsOption-imap"
+ class="content-blocking-category results-option"
+ >
+ <label class="toggle-container-with-text">
+ <input
+ id="resultSelect-imap"
+ type="radio"
+ value="imap"
+ name="resultsServerType"
+ onchange="gAccountSetup.onResultServerTypeChanged();"
+ />
+ <span class="strong">IMAP</span>
+ <p
+ class="result-indent"
+ data-l10n-id="account-setup-result-imap-description"
+ ></p>
+ </label>
+ <aside class="result-details">
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/inbox.svg"
+ alt=""
+ />
+ <div id="incomingTitle-imap" class="result-details-title">
+ <h4 data-l10n-id="account-setup-incoming-title"></h4>
+ </div>
+ <div id="incomingInfo-imap" class="result-host-info"></div>
+ </section>
+
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/outbox.svg"
+ alt=""
+ />
+ <div id="outgoingTitle-imap" class="result-details-title">
+ <h4 data-l10n-id="account-setup-outgoing-title"></h4>
+ </div>
+ <div id="outgoingInfo-imap" class="result-host-info"></div>
+ </section>
+
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/contact.svg"
+ alt=""
+ />
+ <div class="result-details-title">
+ <h4 data-l10n-id="account-setup-username-title"></h4>
+ </div>
+ <div id="usernameInfo-imap" class="result-host-info"></div>
+ </section>
+ </aside>
+ </div>
+
+ <!-- POP3 -->
+ <div
+ id="resultsOption-pop3"
+ class="content-blocking-category results-option"
+ >
+ <label class="toggle-container-with-text">
+ <input
+ id="resultSelect-pop3"
+ type="radio"
+ value="pop3"
+ name="resultsServerType"
+ onchange="gAccountSetup.onResultServerTypeChanged();"
+ />
+ <span class="strong">POP3</span>
+ <p
+ class="result-indent"
+ data-l10n-id="account-setup-result-pop-description"
+ ></p>
+ </label>
+ <aside class="result-details">
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/inbox.svg"
+ alt=""
+ />
+ <div id="incomingTitle-pop3" class="result-details-title">
+ <h4 data-l10n-id="account-setup-incoming-title"></h4>
+ </div>
+ <div id="incomingInfo-pop3" class="result-host-info"></div>
+ </section>
+
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/outbox.svg"
+ alt=""
+ />
+ <div id="outgoingTitle-pop3" class="result-details-title">
+ <h4 data-l10n-id="account-setup-outgoing-title"></h4>
+ </div>
+ <div id="outgoingInfo-pop3" class="result-host-info"></div>
+ </section>
+
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/contact.svg"
+ alt=""
+ />
+ <div class="result-details-title">
+ <h4 data-l10n-id="account-setup-username-title"></h4>
+ </div>
+ <div id="usernameInfo-pop3" class="result-host-info"></div>
+ </section>
+ </aside>
+ </div>
+
+ <!-- EXCHANGE -->
+ <div
+ id="resultsOption-exchange"
+ class="content-blocking-category results-option"
+ >
+ <label class="toggle-container-with-text">
+ <input
+ id="resultSelect-exchange"
+ type="radio"
+ value="exchange"
+ name="resultsServerType"
+ onchange="gAccountSetup.onResultServerTypeChanged();"
+ />
+ <span class="strong"> Exchange/Office365 </span>
+ <p
+ class="result-indent"
+ data-l10n-id="account-setup-result-exchange2-description"
+ ></p>
+ </label>
+ <aside class="result-details">
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/inbox.svg"
+ alt=""
+ />
+ <div id="incomingTitle-exchange" class="result-details-title">
+ <h4 data-l10n-id="account-setup-exchange-title"></h4>
+ </div>
+ <div id="resultExchangeHostname" class="result-host-info"></div>
+ </section>
+ <aside id="installAddonInfo">
+ <p id="resultAddonIntro"></p>
+ <div id="resultAddonInstallRows"></div>
+ </aside>
+ </aside>
+ </div>
+ </section>
+ <!-- END Results area -->
+
+ <!-- Manual edit area -->
+ <section id="manualConfigArea" hidden="hidden">
+ <h4
+ class="section-title"
+ data-l10n-id="account-setup-manual-config-title"
+ ></h4>
+
+ <!-- Incoming server section -->
+ <fieldset
+ class="manual-config-grid content-blocking-category"
+ aria-describedby="manualConfigDescription"
+ >
+ <legend
+ data-l10n-id="account-setup-incoming-server-legend"
+ ></legend>
+
+ <!-- Incoming Protocol -->
+ <aside>
+ <label
+ for="incomingProtocol"
+ data-l10n-id="account-setup-protocol-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="incomingProtocol"
+ onchange="gAccountSetup.onChangedProtocolIncoming();"
+ >
+ <option value="1">IMAP</option>
+ <option value="2">POP3</option>
+ <option
+ id="incomingProtocolExchange"
+ value="3"
+ hidden="hidden"
+ >
+ Exchange
+ </option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Incoming Server -->
+ <aside>
+ <label
+ for="incomingHostname"
+ data-l10n-id="account-setup-hostname-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="incomingHostname"
+ type="text"
+ placeholder="mail.example.com"
+ onchange="gAccountSetup.onChangeHostname();"
+ oninput="gAccountSetup.manualConfigChanged();"
+ class="host uri-element input-field"
+ />
+ </div>
+ </aside>
+
+ <!-- Incoming Port -->
+ <section>
+ <aside>
+ <label
+ for="incomingPort"
+ data-l10n-id="account-setup-port-label"
+ class="option-label"
+ >
+ </label>
+ <input
+ id="incomingPort"
+ type="number"
+ min="1"
+ max="65535"
+ onchange="gAccountSetup.onChangedPortIncoming();"
+ oninput="gAccountSetup.manualConfigChanged();"
+ class="input-field"
+ />
+ </aside>
+ </section>
+
+ <!-- Incoming SSL -->
+ <aside>
+ <label
+ for="incomingSsl"
+ data-l10n-id="account-setup-ssl-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="incomingSsl"
+ class="security"
+ onchange="gAccountSetup.onChangedSSLIncoming();"
+ >
+ <!-- @see nsMsgSocketType -->
+ <option
+ data-l10n-id="ssl-autodetect-option"
+ value="-1"
+ ></option>
+ <option
+ data-l10n-id="ssl-noencryption-option"
+ value="0"
+ ></option>
+ <option value="2">STARTTLS</option>
+ <option value="3">SSL/TLS</option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Incoming Authentication -->
+ <aside>
+ <label
+ for="incomingAuthMethod"
+ data-l10n-id="account-setup-auth-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="incomingAuthMethod"
+ class="auth"
+ onchange="gAccountSetup.onChangedInAuth();"
+ >
+ <option
+ data-l10n-id="ssl-autodetect-option"
+ value="0"
+ ></option>
+ <!-- Values defined in nsMsgAuthMethod. -->
+ <option
+ data-l10n-id="ssl-cleartext-password-option"
+ value="3"
+ ></option>
+ <option
+ data-l10n-id="ssl-encrypted-password-option"
+ value="4"
+ ></option>
+ <option value="5">Kerberos / GSSAPI</option>
+ <option value="6">NTLM</option>
+ <option id="in-authMethod-oauth2" value="10" hidden="hidden">
+ OAuth2
+ </option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Incoming Username -->
+ <aside>
+ <label
+ for="incomingUsername"
+ data-l10n-id="account-setup-username-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="incomingUsername"
+ type="text"
+ data-l10n-id="account-setup-email-input"
+ oninput="gAccountSetup.onInputInUsername();"
+ class="username input-field"
+ />
+ </div>
+ </aside>
+ </fieldset>
+
+ <!-- Outgoing server section -->
+ <fieldset
+ class="manual-config-grid content-blocking-category"
+ aria-describedby="manualConfigDescription"
+ >
+ <legend
+ data-l10n-id="account-setup-outgoing-server-legend"
+ ></legend>
+
+ <!-- Outgoing Server -->
+ <aside>
+ <label
+ for="outgoingHostname"
+ data-l10n-id="account-setup-hostname-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="outgoingHostname"
+ type="text"
+ placeholder="mail.example.com"
+ onchange="gAccountSetup.onChangeHostname();"
+ oninput="gAccountSetup.manualConfigChanged();"
+ class="input-field"
+ />
+ </div>
+ </aside>
+
+ <!-- Outgoing Port -->
+ <section>
+ <aside>
+ <label
+ for="outgoingPort"
+ data-l10n-id="account-setup-port-label"
+ class="option-label"
+ >
+ </label>
+ <input
+ id="outgoingPort"
+ type="number"
+ min="1"
+ max="65535"
+ onchange="gAccountSetup.onChangedPortOutgoing();"
+ oninput="gAccountSetup.manualConfigChanged();"
+ class="input-field"
+ />
+ </aside>
+ </section>
+
+ <!-- Outgoing SSL -->
+ <aside>
+ <label
+ for="outgoingSsl"
+ data-l10n-id="account-setup-ssl-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="outgoingSsl"
+ class="security"
+ onchange="gAccountSetup.onChangedSSLOutgoing();"
+ >
+ <!-- Values defined in nsMsgSocketType. -->
+ <option
+ data-l10n-id="ssl-autodetect-option"
+ value="-1"
+ ></option>
+ <option
+ data-l10n-id="ssl-noencryption-option"
+ value="0"
+ ></option>
+ <option value="2">STARTTLS</option>
+ <option value="3">SSL/TLS</option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Outgoing Authentication -->
+ <aside>
+ <label
+ for="outgoingAuthMethod"
+ data-l10n-id="account-setup-auth-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="outgoingAuthMethod"
+ class="auth"
+ onchange="gAccountSetup.onChangedOutAuth(event);"
+ >
+ <option
+ data-l10n-id="ssl-autodetect-option"
+ value="0"
+ ></option>
+ <!-- @see incoming -->
+ <option
+ id="outNoAuth"
+ data-l10n-id="ssl-no-authentication-option"
+ value="1"
+ ></option>
+ <option
+ data-l10n-id="ssl-cleartext-password-option"
+ value="3"
+ ></option>
+ <option
+ data-l10n-id="ssl-encrypted-password-option"
+ value="4"
+ ></option>
+ <option value="5">Kerberos / GSSAPI</option>
+ <option value="6">NTLM</option>
+ <option id="out-authMethod-oauth2" value="10" hidden="hidden">
+ OAuth2
+ </option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Outgoing Username -->
+ <aside>
+ <label
+ for="outgoingUsername"
+ data-l10n-id="account-setup-username-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="outgoingUsername"
+ type="text"
+ data-l10n-id="account-setup-email-input"
+ oninput="gAccountSetup.onInputOutUsername();"
+ class="username input-field"
+ />
+ </div>
+ </aside>
+ </fieldset>
+
+ <div class="link-row">
+ <button
+ id="advancedSetupButton"
+ class="btn-link"
+ data-l10n-id="account-setup-advanced-setup-button"
+ data-l10n-attrs="accesskey"
+ onclick="gAccountSetup.onAdvancedSetup();"
+ ></button>
+ </div>
+ </section>
+ <!-- END Manual edit area -->
+
+ <div class="action-buttons-container">
+ <aside>
+ <button
+ id="stopButton"
+ type="button"
+ data-l10n-id="account-setup-button-stop"
+ data-l10n-attrs="accesskey"
+ onclick="gAccountSetup.onStop();"
+ hidden="hidden"
+ ></button>
+ <button
+ id="reTestButton"
+ type="button"
+ data-l10n-id="account-setup-button-retest"
+ data-l10n-attrs="accesskey"
+ onclick="gAccountSetup.testManualConfig();"
+ hidden="hidden"
+ ></button>
+ <button
+ id="manualConfigButton"
+ type="button"
+ data-l10n-id="account-setup-button-manual-config"
+ data-l10n-attrs="accesskey"
+ class="btn-link"
+ onclick="gAccountSetup.onManualEdit();"
+ hidden="hidden"
+ ></button>
+ </aside>
+
+ <aside class="buttons-container-last">
+ <button
+ id="cancelButton"
+ type="button"
+ data-l10n-id="account-setup-button-cancel"
+ data-l10n-attrs="accesskey"
+ onclick="gAccountSetup.onCancel();"
+ ></button>
+ <button
+ id="continueButton"
+ type="button"
+ data-l10n-id="account-setup-button-continue"
+ data-l10n-attrs="accesskey"
+ class="primary"
+ onclick="gAccountSetup.onContinue();"
+ disabled="disabled"
+ ></button>
+ <button
+ id="createButton"
+ type="button"
+ data-l10n-id="account-setup-button-done"
+ data-l10n-attrs="accesskey"
+ class="primary"
+ onclick="gAccountSetup.onCreate();"
+ hidden="hidden"
+ disabled="disabled"
+ ></button>
+ </aside>
+ </div>
+
+ <p
+ id="manualConfigDescription"
+ data-l10n-id="account-setup-auto-description"
+ class="autoconfig-note tip-caption"
+ hidden="hidden"
+ ></p>
+
+ <p
+ id="footDescription"
+ data-l10n-id="account-setup-privacy-footnote2"
+ class="foot-note tip-caption"
+ ></p>
+ </aside>
+ <!-- END first column "setupView"-->
+
+ <aside
+ id="successView"
+ class="column first-column success-column"
+ hidden="hidden"
+ >
+ <section class="account-success-block">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/mail-secure.svg"
+ class="account-type-image"
+ alt=""
+ />
+ <aside>
+ <span id="newAccountName" class="account-name"></span>
+ <span id="newAccountEmail" class="account-email"></span>
+ </aside>
+ <span id="newAccountProtocol" class="protocol-type"></span>
+ </section>
+
+ <section class="quick-links">
+ <button
+ id="settingsButton"
+ type="button"
+ data-l10n-id="account-setup-settings-button"
+ class="quick-link"
+ ></button>
+ <button
+ id="encryptionButton"
+ type="button"
+ data-l10n-id="account-setup-encryption-button"
+ class="quick-link"
+ ></button>
+ <button
+ id="signatureButton"
+ type="button"
+ data-l10n-id="account-setup-signature-button"
+ class="quick-link"
+ ></button>
+ <button
+ id="dictionariesButton"
+ type="button"
+ data-l10n-id="account-setup-dictionaries-button"
+ class="quick-link"
+ onclick="openDictionariesTab();"
+ ></button>
+ </section>
+
+ <section id="linkedServices">
+ <h3 data-l10n-id="account-setup-linked-services-title"></h3>
+ <p id="linkedServicesDescription" class="tip-caption"></p>
+
+ <section id="syncNotifications" class="account-setup-notifications">
+ <!-- Notifications will be lazily loaded here. -->
+ </section>
+
+ <aside class="services-buttons-container">
+ <section
+ id="linkedAddressBooks"
+ class="content-blocking-category linked-services-section opened"
+ hidden="hidden"
+ >
+ <button
+ type="button"
+ class="linked-services-button"
+ onclick="gAccountSetup.toggleSetupContainer(event);"
+ >
+ <aside>
+ <span
+ class="account-name"
+ data-l10n-id="account-setup-address-books-button"
+ >
+ </span>
+ <p
+ id="addressBooksCountDescription"
+ class="linked-services-description"
+ ></p>
+ </aside>
+ <span class="linked-service-dropdown">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/nav-right.svg"
+ alt=""
+ />
+ </span>
+ </button>
+
+ <div id="addressBooksSetup" class="linked-services-container">
+ <ul class="linked-services-list"></ul>
+ <button
+ id="addressBooksSetupAll"
+ data-l10n-id="account-setup-connect-all-address-books"
+ class="btn-link self-center"
+ onclick="gAccountSetup.setupAllAddressBooks();"
+ hidden="hidden"
+ ></button>
+ </div>
+ </section>
+
+ <section class="indent">
+ <button
+ id="addressBookCardDAVButton"
+ type="button"
+ data-l10n-id="account-setup-address-book-carddav-button"
+ class="quick-link"
+ onclick="addNewAddressBook('CARDDAV');"
+ ></button>
+
+ <button
+ id="addressBookLDAPButton"
+ type="button"
+ data-l10n-id="account-setup-address-book-ldap-button"
+ class="quick-link"
+ onclick="addNewAddressBook('LDAP');"
+ ></button>
+ </section>
+
+ <section
+ id="linkedCalendars"
+ class="content-blocking-category linked-services-section opened"
+ hidden="hidden"
+ >
+ <button
+ type="button"
+ class="linked-services-button"
+ onclick="gAccountSetup.toggleSetupContainer(event);"
+ >
+ <aside>
+ <span
+ class="account-name"
+ data-l10n-id="account-setup-calendars-button"
+ >
+ </span>
+ <p
+ id="calendarsCountDescription"
+ class="linked-services-description"
+ ></p>
+ </aside>
+ <span class="linked-service-dropdown">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/nav-right.svg"
+ alt=""
+ />
+ </span>
+ </button>
+
+ <div id="calendarsSetup" class="linked-services-container">
+ <ul class="linked-services-list"></ul>
+ <button
+ id="calendarsSetupAll"
+ data-l10n-id="account-setup-connect-all-calendars"
+ class="btn-link self-center"
+ onclick="gAccountSetup.setupAllCalendars();"
+ hidden="hidden"
+ ></button>
+ </div>
+ </section>
+
+ <section class="indent">
+ <button
+ id="createCalendarButton"
+ type="button"
+ data-l10n-id="account-setup-calendar-button"
+ class="quick-link"
+ onclick="showCalendarWizard();"
+ ></button>
+ </section>
+ </aside>
+ </section>
+
+ <section class="final-buttons-container">
+ <button
+ id="finishButton"
+ type="button"
+ data-l10n-id="account-setup-button-finish"
+ data-l10n-attrs="accesskey"
+ class="primary"
+ onclick="gAccountSetup.onFinish();"
+ ></button>
+ </section>
+ </aside>
+ <!-- END first column "successView"-->
+
+ <aside class="column second-column">
+ <article id="step1">
+ <img
+ src="chrome://messenger/skin/illustrations/octopus-setup.svg"
+ data-l10n-id="account-setup-step1-image"
+ alt=""
+ />
+ </article>
+ <article id="step2" hidden="hidden">
+ <img
+ src="chrome://messenger/skin/illustrations/sloth.svg"
+ data-l10n-id="account-setup-step2-image"
+ alt=""
+ />
+ </article>
+ <article id="step3" class="tip-caption" hidden="hidden">
+ <img
+ src="chrome://messenger/skin/illustrations/form.svg"
+ data-l10n-id="account-setup-step3-image"
+ alt=""
+ />
+ <p data-l10n-id="account-setup-selection-help"></p>
+ <a
+ href="https://support.mozilla.org/products/thunderbird/emails-thunderbird/set-up-email-thunderbird"
+ data-l10n-id="account-setup-documentation-help"
+ ></a>
+ -
+ <a
+ href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-id="account-setup-forum-help"
+ ></a>
+ -
+ <a
+ href="https://www.mozilla.org/privacy/thunderbird/"
+ data-l10n-id="account-setup-privacy-help"
+ ></a>
+ </article>
+ <article id="step4" class="tip-caption" hidden="hidden">
+ <img
+ src="chrome://messenger/skin/illustrations/connection-error.svg"
+ data-l10n-id="account-setup-step4-image"
+ alt=""
+ />
+ <p data-l10n-id="account-setup-selection-error"></p>
+ <a
+ href="https://support.mozilla.org/products/thunderbird/emails-thunderbird/set-up-email-thunderbird"
+ data-l10n-id="account-setup-documentation-help"
+ ></a>
+ -
+ <a
+ href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-id="account-setup-forum-help"
+ ></a>
+ </article>
+ <article id="step5" class="tip-caption" hidden="hidden">
+ <img
+ src="chrome://messenger/skin/illustrations/accounts.svg"
+ data-l10n-id="account-setup-step5-image"
+ alt=""
+ />
+ <p data-l10n-id="account-setup-success-help"></p>
+ <a
+ href="https://support.mozilla.org/products/thunderbird/learn-basics-get-started"
+ data-l10n-id="account-setup-getting-started"
+ ></a>
+ -
+ <a
+ href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-id="account-setup-forum-help"
+ ></a>
+ -
+ <a
+ href="https://www.mozilla.org/privacy/thunderbird/"
+ data-l10n-id="account-setup-privacy-help"
+ ></a>
+ </article>
+ </aside>
+ <!-- END second column-->
+ </section>
+ </body>
+</html>
diff --git a/comm/mail/components/accountcreation/jar.mn b/comm/mail/components/accountcreation/jar.mn
new file mode 100644
index 0000000000..0a3389020e
--- /dev/null
+++ b/comm/mail/components/accountcreation/jar.mn
@@ -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/.
+
+messenger.jar:
+ content/messenger/accountcreation/accountHub.js (content/accountHub.js)
+ content/messenger/accountcreation/accountSetup.js (content/accountSetup.js)
+ content/messenger/accountcreation/accountSetup.xhtml (content/accountSetup.xhtml)
+# Custom elements
+ content/messenger/accountcreation/views/container.mjs (views/container.mjs)
+ content/messenger/accountcreation/views/email.mjs (views/email.mjs)
+ content/messenger/accountcreation/views/start.mjs (views/start.mjs)
diff --git a/comm/mail/components/accountcreation/moz.build b/comm/mail/components/accountcreation/moz.build
new file mode 100644
index 0000000000..fa8ce3c258
--- /dev/null
+++ b/comm/mail/components/accountcreation/moz.build
@@ -0,0 +1,23 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES.accountcreation += [
+ "AccountConfig.jsm",
+ "AccountCreationUtils.jsm",
+ "ConfigVerifier.jsm",
+ "CreateInBackend.jsm",
+ "ExchangeAutoDiscover.jsm",
+ "FetchConfig.jsm",
+ "FetchHTTP.jsm",
+ "GuessConfig.jsm",
+ "readFromXML.jsm",
+ "Sanitizer.jsm",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/xpcshell/xpcshell.ini",
+]
diff --git a/comm/mail/components/accountcreation/readFromXML.jsm b/comm/mail/components/accountcreation/readFromXML.jsm
new file mode 100644
index 0000000000..b853a81117
--- /dev/null
+++ b/comm/mail/components/accountcreation/readFromXML.jsm
@@ -0,0 +1,352 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["readFromXML"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountConfig",
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountCreationUtils",
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+/* eslint-disable complexity */
+/**
+ * Takes an XML snipplet (as JXON) and reads the values into
+ * a new AccountConfig object.
+ * It does so securely (or tries to), by trying to avoid remote execution
+ * and similar holes which can appear when reading too naively.
+ * Of course it cannot tell whether the actual values are correct,
+ * e.g. it can't tell whether the host name is a good server.
+ *
+ * The XML format is documented at
+ * <https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat>
+ *
+ * @param clientConfigXML {JXON} - The <clientConfig> node.
+ * @param source {String} - Used for the subSource field of AccountConfig.
+ * @returns AccountConfig object filled with the data from XML
+ */
+function readFromXML(clientConfigXML, subSource) {
+ function array_or_undef(value) {
+ return value === undefined ? [] : value;
+ }
+ var exception;
+ if (
+ typeof clientConfigXML != "object" ||
+ !("clientConfig" in clientConfigXML) ||
+ !("emailProvider" in clientConfigXML.clientConfig)
+ ) {
+ dump(
+ `client config xml = ${JSON.stringify(clientConfigXML).substr(0, 50)} \n`
+ );
+ let stringBundle = lazy.AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ );
+ throw stringBundle.GetStringFromName("no_emailProvider.error");
+ }
+ var xml = clientConfigXML.clientConfig.emailProvider;
+
+ var d = new lazy.AccountConfig();
+ d.source = lazy.AccountConfig.kSourceXML;
+ d.subSource = `xml-from-${subSource}`;
+
+ d.id = lazy.Sanitizer.hostname(xml["@id"]);
+ d.displayName = d.id;
+ try {
+ d.displayName = lazy.Sanitizer.label(xml.displayName);
+ } catch (e) {
+ console.error(e);
+ }
+ for (var domain of xml.$domain) {
+ try {
+ d.domains.push(lazy.Sanitizer.hostname(domain));
+ } catch (e) {
+ console.error(e);
+ exception = e;
+ }
+ }
+ if (d.domains.length == 0) {
+ throw exception ? exception : "need proper <domain> in XML";
+ }
+ exception = null;
+
+ // incoming server
+ for (let iX of array_or_undef(xml.$incomingServer)) {
+ // input (XML)
+ let iO = d.createNewIncoming(); // output (object)
+ try {
+ // throws if not supported
+ iO.type = lazy.Sanitizer.enum(iX["@type"], [
+ "pop3",
+ "imap",
+ "nntp",
+ "exchange",
+ ]);
+ iO.hostname = lazy.Sanitizer.hostname(iX.hostname);
+ iO.port = lazy.Sanitizer.integerRange(iX.port, 1, 65535);
+ // We need a username even for Kerberos, need it even internally.
+ iO.username = lazy.Sanitizer.string(iX.username); // may be a %VARIABLE%
+
+ if ("password" in iX) {
+ d.rememberPassword = true;
+ iO.password = lazy.Sanitizer.string(iX.password);
+ }
+
+ for (let iXsocketType of array_or_undef(iX.$socketType)) {
+ try {
+ iO.socketType = lazy.Sanitizer.translate(iXsocketType, {
+ plain: Ci.nsMsgSocketType.plain,
+ SSL: Ci.nsMsgSocketType.SSL,
+ STARTTLS: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ });
+ break; // take first that we support
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (iO.socketType == -1) {
+ throw exception ? exception : "need proper <socketType> in XML";
+ }
+ exception = null;
+
+ for (let iXauth of array_or_undef(iX.$authentication)) {
+ try {
+ iO.auth = lazy.Sanitizer.translate(iXauth, {
+ "password-cleartext": Ci.nsMsgAuthMethod.passwordCleartext,
+ // @deprecated TODO remove
+ plain: Ci.nsMsgAuthMethod.passwordCleartext,
+ "password-encrypted": Ci.nsMsgAuthMethod.passwordEncrypted,
+ // @deprecated TODO remove
+ secure: Ci.nsMsgAuthMethod.passwordEncrypted,
+ GSSAPI: Ci.nsMsgAuthMethod.GSSAPI,
+ NTLM: Ci.nsMsgAuthMethod.NTLM,
+ OAuth2: Ci.nsMsgAuthMethod.OAuth2,
+ });
+ break; // take first that we support
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (!iO.auth) {
+ throw exception ? exception : "need proper <authentication> in XML";
+ }
+ exception = null;
+
+ if (iO.type == "exchange") {
+ try {
+ if ("owaURL" in iX) {
+ iO.owaURL = lazy.Sanitizer.url(iX.owaURL);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ try {
+ if ("ewsURL" in iX) {
+ iO.ewsURL = lazy.Sanitizer.url(iX.ewsURL);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ try {
+ if ("easURL" in iX) {
+ iO.easURL = lazy.Sanitizer.url(iX.easURL);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ iO.oauthSettings = {
+ issuer: iO.hostname,
+ scope: iO.owaURL || iO.ewsURL || iO.easURL,
+ };
+ }
+ // defaults are in accountConfig.js
+ if (iO.type == "pop3" && "pop3" in iX) {
+ try {
+ if ("leaveMessagesOnServer" in iX.pop3) {
+ iO.leaveMessagesOnServer = lazy.Sanitizer.boolean(
+ iX.pop3.leaveMessagesOnServer
+ );
+ }
+ if ("daysToLeaveMessagesOnServer" in iX.pop3) {
+ iO.daysToLeaveMessagesOnServer = lazy.Sanitizer.integer(
+ iX.pop3.daysToLeaveMessagesOnServer
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ try {
+ if ("downloadOnBiff" in iX.pop3) {
+ iO.downloadOnBiff = lazy.Sanitizer.boolean(iX.pop3.downloadOnBiff);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ try {
+ if ("useGlobalPreferredServer" in iX) {
+ iO.useGlobalPreferredServer = lazy.Sanitizer.boolean(
+ iX.useGlobalPreferredServer
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // processed successfully, now add to result object
+ if (!d.incoming.hostname) {
+ // first valid
+ d.incoming = iO;
+ } else {
+ d.incomingAlternatives.push(iO);
+ }
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (!d.incoming.hostname) {
+ // throw exception for last server
+ throw exception ? exception : "Need proper <incomingServer> in XML file";
+ }
+ exception = null;
+
+ // outgoing server
+ for (let oX of array_or_undef(xml.$outgoingServer)) {
+ // input (XML)
+ let oO = d.createNewOutgoing(); // output (object)
+ try {
+ if (oX["@type"] != "smtp") {
+ let stringBundle = lazy.AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ );
+ throw stringBundle.GetStringFromName("outgoing_not_smtp.error");
+ }
+ oO.hostname = lazy.Sanitizer.hostname(oX.hostname);
+ oO.port = lazy.Sanitizer.integerRange(oX.port, 1, 65535);
+
+ for (let oXsocketType of array_or_undef(oX.$socketType)) {
+ try {
+ oO.socketType = lazy.Sanitizer.translate(oXsocketType, {
+ plain: Ci.nsMsgSocketType.plain,
+ SSL: Ci.nsMsgSocketType.SSL,
+ STARTTLS: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ });
+ break; // take first that we support
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (oO.socketType == -1) {
+ throw exception ? exception : "need proper <socketType> in XML";
+ }
+ exception = null;
+
+ for (let oXauth of array_or_undef(oX.$authentication)) {
+ try {
+ oO.auth = lazy.Sanitizer.translate(oXauth, {
+ // open relay
+ none: Ci.nsMsgAuthMethod.none,
+ // inside ISP or corp network
+ "client-IP-address": Ci.nsMsgAuthMethod.none,
+ // hope for the best
+ "smtp-after-pop": Ci.nsMsgAuthMethod.none,
+ "password-cleartext": Ci.nsMsgAuthMethod.passwordCleartext,
+ // @deprecated TODO remove
+ plain: Ci.nsMsgAuthMethod.passwordCleartext,
+ "password-encrypted": Ci.nsMsgAuthMethod.passwordEncrypted,
+ // @deprecated TODO remove
+ secure: Ci.nsMsgAuthMethod.passwordEncrypted,
+ GSSAPI: Ci.nsMsgAuthMethod.GSSAPI,
+ NTLM: Ci.nsMsgAuthMethod.NTLM,
+ OAuth2: Ci.nsMsgAuthMethod.OAuth2,
+ });
+
+ break; // take first that we support
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (!oO.auth) {
+ throw exception ? exception : "need proper <authentication> in XML";
+ }
+ exception = null;
+
+ if (
+ "username" in oX ||
+ // if password-based auth, we need a username,
+ // so go there anyways and throw.
+ oO.auth == Ci.nsMsgAuthMethod.passwordCleartext ||
+ oO.auth == Ci.nsMsgAuthMethod.passwordEncrypted
+ ) {
+ oO.username = lazy.Sanitizer.string(oX.username);
+ }
+
+ if ("password" in oX) {
+ d.rememberPassword = true;
+ oO.password = lazy.Sanitizer.string(oX.password);
+ }
+
+ try {
+ // defaults are in accountConfig.js
+ if ("addThisServer" in oX) {
+ oO.addThisServer = lazy.Sanitizer.boolean(oX.addThisServer);
+ }
+ if ("useGlobalPreferredServer" in oX) {
+ oO.useGlobalPreferredServer = lazy.Sanitizer.boolean(
+ oX.useGlobalPreferredServer
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // processed successfully, now add to result object
+ if (!d.outgoing.hostname) {
+ // first valid
+ d.outgoing = oO;
+ } else {
+ d.outgoingAlternatives.push(oO);
+ }
+ } catch (e) {
+ console.error(e);
+ exception = e;
+ }
+ }
+ if (!d.outgoing.hostname) {
+ // throw exception for last server
+ throw exception ? exception : "Need proper <outgoingServer> in XML file";
+ }
+ exception = null;
+
+ d.inputFields = [];
+ for (let inputField of array_or_undef(xml.$inputField)) {
+ try {
+ let fieldset = {
+ varname: lazy.Sanitizer.alphanumdash(inputField["@key"]).toUpperCase(),
+ displayName: lazy.Sanitizer.label(inputField["@label"]),
+ exampleValue: lazy.Sanitizer.label(inputField.value),
+ };
+ d.inputFields.push(fieldset);
+ } catch (e) {
+ console.error(e);
+ // For now, don't throw,
+ // because we don't support custom fields yet anyways.
+ }
+ }
+
+ return d;
+}
+/* eslint-enable complexity */
diff --git a/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml b/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml
new file mode 100644
index 0000000000..8b8c7c4ada
--- /dev/null
+++ b/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml
@@ -0,0 +1,158 @@
+# 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/.
+
+<!--
+ List of all the account creation templates. Don't load any JavaScript or
+ CSS in here, those files are handled lazily in the controller and in the
+ various shadowRoots of the views.
+-->
+<html:template id="accountHubDialog" xmlns="http://www.w3.org/1999/xhtml">
+ <dialog class="account-hub-dialog">
+ <button id="closeButton" type="button">
+ <img src="" alt="" />
+ </button>
+ </dialog>
+</html:template>
+
+<html:template id="accountHubStart" xmlns="http://www.w3.org/1999/xhtml">
+ <header class="hub-header">
+ <div id="welcomeHeader" class="start-header" hidden="hidden">
+ <img src="chrome://branding/content/logo-gradient.svg" alt="" />
+ <h1 data-l10n-id="account-hub-welcome-line">
+ <span data-l10n-name="brand-name"></span>
+ </h1>
+ </div>
+ <div id="defaultHeader" class="start-header" hidden="hidden">
+ <img src="chrome://branding/content/logo-gradient.svg" alt="" />
+ <h1>
+ <span class="start-header-brand-name"
+ data-l10n-id="account-hub-brand"></span>
+ <span class="start-header-title"
+ data-l10n-id="account-hub-title"></span>
+ </h1>
+ </div>
+ </header>
+
+ <div class="hub-body">
+ <div class="hub-body-grid"></div>
+#ifdef NIGHTLY_BUILD
+ <button id="hubSyncButton"
+ data-l10n-id="account-hub-sync-button"
+ class="button button-flat"
+ type="button"
+ hidden="hidden"></button>
+#endif
+ </div>
+
+ <footer class="hub-footer">
+ <ul class="reset-list footer-links">
+ <li>
+ <a id="hubReleaseNotes"
+ data-l10n-id="account-hub-release-notes"></a>
+ </li>
+ <li>
+ <a href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-id="account-hub-support"
+ onclick="openLinkExternally(this.href);"></a>
+ </li>
+ <li>
+ <a href="https://give.thunderbird.net/?utm_source=thunderbird_account_hub&amp;utm_medium=referral&amp;utm_content=hub_footer"
+ data-l10n-id="account-hub-donate"
+ onclick="openLinkExternally(this.href);"></a>
+ </li>
+ </ul>
+ </footer>
+</html:template>
+
+<html:template id="accountHubEmailSetup" xmlns="http://www.w3.org/1999/xhtml">
+ <form id="emailForm" class="account-hub-form">
+ <header class="hub-header">
+ <h1 class="sub-view-title" data-l10n-id="account-hub-email-title"></h1>
+ </header>
+
+ <div class="hub-body">
+ <label for="realName"
+ data-l10n-id="account-setup-name-label"
+ data-l10n-attrs="accesskey">
+ </label>
+ <div class="input-control">
+ <input id="realName" type="text"
+ class="input-field"
+ data-l10n-id="account-setup-name-input"
+ required="required" />
+ <img src="chrome://messenger/skin/icons/new/compact/info.svg"
+ data-l10n-id="account-setup-name-info-icon"
+ alt=""
+ class="form-icon" />
+ <img src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ data-l10n-id="account-setup-name-warning-icon"
+ alt=""
+ class="form-icon icon-warning" />
+ </div>
+
+ <label for="email"
+ data-l10n-id="account-setup-email-label"
+ data-l10n-attrs="accesskey">
+ </label>
+ <div class="input-control">
+ <input id="email" type="email"
+ data-l10n-id="account-setup-email-input"
+ class="input-field"
+ required="required" />
+ <img id="emailInfo"
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ data-l10n-id="account-setup-email-info-icon"
+ alt=""
+ class="form-icon" />
+ <img id="emailWarning"
+ src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ data-l10n-id="account-setup-email-warning-icon"
+ alt=""
+ class="form-icon icon-warning" />
+ </div>
+
+ <label for="password"
+ data-l10n-id="account-setup-password-label"
+ data-l10n-attrs="accesskey">
+ </label>
+ <div class="input-control">
+ <!-- Leave the placeholder empty for CSS visibility toggle of the
+ adjacent button. -->
+ <input id="password" type="password" class="input-field"
+ placeholder="" />
+ <button id="passwordToggleButton"
+ type="button"
+ data-l10n-id="account-setup-password-toggle-show"
+ class="form-toggle-button"
+ aria-pressed="false">
+ <img src="" alt="" class="form-icon" />
+ </button>
+ </div>
+
+ <div class="remember-button-container">
+ <label class="toggle-container-with-text">
+ <input id="rememberPassword" type="checkbox" checked="checked" />
+ <span data-l10n-id="account-setup-remember-password"
+ data-l10n-attrs="accesskey">
+ </span>
+ </label>
+ </div>
+ </div>
+
+ <footer class="hub-footer">
+ <menu class="dialog-menu-container two-columns">
+ <li>
+ <button id="emailGoBackButton" type="button"
+ data-l10n-id="account-hub-email-cancel-button"></button>
+ </li>
+ <li>
+ <button id="emailContinueButton" type="submit"
+ data-l10n-id="account-hub-email-continue-button"
+ class="primary"
+ disabled="disabled"></button>
+ </li>
+ </menu>
+ </footer>
+ </form>
+</html:template>
diff --git a/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml b/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml
new file mode 100644
index 0000000000..871ce732e2
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml
@@ -0,0 +1,21 @@
+<clientConfig version="1.1">
+ <emailProvider id="example.com">
+ <domain>example.com</domain>
+ <displayName>example.com</displayName>
+ <displayShortName>example.com</displayShortName>
+ <incomingServer type="pop3">
+ <hostname>pop.example.com</hostname>
+ <port>995</port>
+ <socketType>SSL</socketType>
+ <authentication>plain</authentication>
+ <username>%EMAILLOCALPART%</username>
+ </incomingServer>
+ <outgoingServer type="smtp">
+ <hostname>smtp.example.com</hostname>
+ <port>587</port>
+ <socketType>STARTTLS</socketType>
+ <username>%EMAILADDRESS%</username>
+ <authentication>plain</authentication>
+ </outgoingServer>
+ </emailProvider>
+</clientConfig>
diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js
new file mode 100644
index 0000000000..5b57a42ebb
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js
@@ -0,0 +1,76 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/**
+ * Tests getting a configuration file from the local isp directory and
+ * reading that file.
+ */
+
+// Globals
+
+var { AccountConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+var { FetchConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/FetchConfig.jsm"
+);
+
+var kXMLFile = "example.com.xml";
+var fetchConfigAbortable;
+var copyLocation;
+
+function onTestSuccess(config) {
+ // Check that we got the expected config.
+ AccountConfig.replaceVariables(
+ config,
+ "Yamato Nadeshiko",
+ "yamato.nadeshiko@example.com",
+ "abc12345"
+ );
+
+ Assert.equal(config.incoming.username, "yamato.nadeshiko");
+ Assert.equal(config.outgoing.username, "yamato.nadeshiko@example.com");
+ Assert.equal(config.incoming.hostname, "pop.example.com");
+ Assert.equal(config.outgoing.hostname, "smtp.example.com");
+ Assert.equal(config.identity.realname, "Yamato Nadeshiko");
+ Assert.equal(config.identity.emailAddress, "yamato.nadeshiko@example.com");
+
+ Assert.equal(config.subSource, "xml-from-disk");
+
+ do_test_finished();
+}
+
+function onTestFailure(e) {
+ do_throw(e);
+}
+
+function run_test() {
+ registerCleanupFunction(finish_test);
+
+ // Copy the xml file into place
+ let file = do_get_file("data/" + kXMLFile);
+
+ copyLocation = Services.dirsvc.get("CurProcD", Ci.nsIFile);
+ copyLocation.append("isp");
+
+ file.copyTo(copyLocation, kXMLFile);
+
+ do_test_pending();
+
+ // Now run the actual test
+ // Note we keep a global copy of this so that the abortable doesn't get
+ // garbage collected before the async operation has finished.
+ fetchConfigAbortable = FetchConfig.fromDisk(
+ "example.com",
+ onTestSuccess,
+ onTestFailure
+ );
+}
+
+function finish_test() {
+ // Remove the test config file
+ copyLocation.append(kXMLFile);
+ copyLocation.remove(false);
+}
diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js
new file mode 100644
index 0000000000..763084f750
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js
@@ -0,0 +1,319 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/*
+ * Tests for GuessConfig.jsm
+ *
+ * Currently tested:
+ * - getHostEntry function.
+ * - getIncomingTryOrder function.
+ * - getOutgoingTryOrder function.
+ *
+ * TODO:
+ * - Test the returned CMDS.
+ * - Figure out what else to test.
+ */
+
+// Globals
+
+var { GuessConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/GuessConfig.jsm"
+);
+
+var {
+ UNKNOWN,
+ IMAP,
+ POP,
+ SMTP,
+ NONE,
+ STARTTLS,
+ SSL,
+ getHostEntry,
+ getIncomingTryOrder,
+ getOutgoingTryOrder,
+} = GuessConfig;
+
+/*
+ * UTILITIES
+ */
+
+function assert_equal(aA, aB, aWhy) {
+ if (aA != aB) {
+ do_throw(aWhy);
+ }
+ Assert.equal(aA, aB);
+}
+
+/**
+ * Test that two host entries are the same, ignoring the commands.
+ */
+function assert_equal_host_entries(hostEntry, expected) {
+ assert_equal(hostEntry.protocol, expected[0], "Protocols are different");
+ assert_equal(hostEntry.socketType, expected[1], "SSL values are different");
+ assert_equal(hostEntry.port, expected[2], "Port values are different");
+}
+
+/**
+ * Assert that the list of tryOrders are the same.
+ */
+function assert_equal_try_orders(aA, aB) {
+ assert_equal(aA.length, aB.length, "tryOrders have different length");
+ for (let [i, subA] of aA.entries()) {
+ let subB = aB[i];
+ assert_equal_host_entries(subA, subB);
+ }
+}
+
+/**
+ * Check that the POP calculations are correct for a given host and
+ * protocol.
+ */
+function checkPop(host, protocol) {
+ // The list of protocol+ssl+port configurations should match
+ // getIncomingTryOrder() in guessConfig.js.
+
+ // port == UNKNOWN
+ // [POP, STARTTLS, 110], [POP, SSL, 995], [POP, NONE, 110]
+ // port != UNKNOWN
+ // ssl == UNKNOWN
+ // [POP, STARTTLS, port], [POP, SSL, port], [POP, NONE, port]
+ // ssl != UNKNOWN
+ // [POP, ssl, port]
+ let ssl = UNKNOWN;
+ let port = UNKNOWN;
+ let tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [POP, STARTTLS, 110],
+ [POP, SSL, 995],
+ [POP, NONE, 110],
+ ]);
+
+ ssl = STARTTLS;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[POP, ssl, 110]]);
+
+ ssl = SSL;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[POP, ssl, 995]]);
+
+ ssl = NONE;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[POP, ssl, 110]]);
+
+ ssl = UNKNOWN;
+ port = 31337;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [POP, STARTTLS, port],
+ [POP, SSL, port],
+ [POP, NONE, port],
+ ]);
+
+ for (ssl in [STARTTLS, SSL, NONE]) {
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[POP, ssl, port]]);
+ }
+}
+
+/**
+ * Check that the IMAP calculations are correct for a given host and
+ * protocol.
+ */
+function checkImap(host, protocol) {
+ // The list of protocol+ssl+port configurations should match
+ // getIncomingTryOrder() in guessConfig.js.
+
+ // port == UNKNOWN
+ // [IMAP, STARTTLS, 143], [IMAP, SSL, 993], [IMAP, NONE, 143]
+ // port != UNKNOWN
+ // ssl == UNKNOWN
+ // [IMAP, STARTTLS, port], [IMAP, SSL, port], [IMAP, NONE, port]
+ // ssl != UNKNOWN
+ // [IMAP, ssl, port];
+
+ let ssl = UNKNOWN;
+ let port = UNKNOWN;
+ let tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, STARTTLS, 143],
+ [IMAP, SSL, 993],
+ [IMAP, NONE, 143],
+ ]);
+
+ ssl = STARTTLS;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[IMAP, ssl, 143]]);
+
+ ssl = SSL;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[IMAP, ssl, 993]]);
+
+ ssl = NONE;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[IMAP, ssl, 143]]);
+
+ ssl = UNKNOWN;
+ port = 31337;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, STARTTLS, port],
+ [IMAP, SSL, port],
+ [IMAP, NONE, port],
+ ]);
+
+ for (ssl in [STARTTLS, SSL, NONE]) {
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[IMAP, ssl, port]]);
+ }
+}
+
+/*
+ * TESTS
+ */
+
+/**
+ * Test that getHostEntry returns the correct port numbers.
+ *
+ * TODO:
+ * - Test the returned commands as well.
+ */
+function test_getHostEntry() {
+ // IMAP port numbers.
+ assert_equal_host_entries(getHostEntry(IMAP, STARTTLS, UNKNOWN), [
+ IMAP,
+ STARTTLS,
+ 143,
+ ]);
+ assert_equal_host_entries(getHostEntry(IMAP, SSL, UNKNOWN), [IMAP, SSL, 993]);
+ assert_equal_host_entries(getHostEntry(IMAP, NONE, UNKNOWN), [
+ IMAP,
+ NONE,
+ 143,
+ ]);
+
+ // POP port numbers.
+ assert_equal_host_entries(getHostEntry(POP, STARTTLS, UNKNOWN), [
+ POP,
+ STARTTLS,
+ 110,
+ ]);
+ assert_equal_host_entries(getHostEntry(POP, SSL, UNKNOWN), [POP, SSL, 995]);
+ assert_equal_host_entries(getHostEntry(POP, NONE, UNKNOWN), [POP, NONE, 110]);
+
+ // SMTP port numbers.
+ assert_equal_host_entries(getHostEntry(SMTP, STARTTLS, UNKNOWN), [
+ SMTP,
+ STARTTLS,
+ 587,
+ ]);
+ assert_equal_host_entries(getHostEntry(SMTP, SSL, UNKNOWN), [SMTP, SSL, 465]);
+ assert_equal_host_entries(getHostEntry(SMTP, NONE, UNKNOWN), [
+ SMTP,
+ NONE,
+ 587,
+ ]);
+}
+
+/**
+ * Test the getIncomingTryOrder method.
+ */
+function test_getIncomingTryOrder() {
+ // The list of protocol+ssl+port configurations should match
+ // getIncomingTryOrder() in guessConfig.js.
+
+ // protocol == POP || host starts with pop. || host starts with pop3.
+ checkPop("example.com", POP);
+ checkPop("pop.example.com", UNKNOWN);
+ checkPop("pop3.example.com", UNKNOWN);
+ checkPop("imap.example.com", POP);
+
+ // protocol == IMAP || host starts with imap.
+ checkImap("example.com", IMAP);
+ checkImap("imap.example.com", UNKNOWN);
+ checkImap("pop.example.com", IMAP);
+
+ let domain = "example.com";
+ let protocol = UNKNOWN;
+ let ssl = UNKNOWN;
+ let port = UNKNOWN;
+ let tryOrder = getIncomingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, STARTTLS, 143],
+ [IMAP, SSL, 993],
+ [POP, STARTTLS, 110],
+ [POP, SSL, 995],
+ [IMAP, NONE, 143],
+ [POP, NONE, 110],
+ ]);
+
+ ssl = SSL;
+ tryOrder = getIncomingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, SSL, 993],
+ [POP, SSL, 995],
+ ]);
+
+ ssl = UNKNOWN;
+ port = 31337;
+ tryOrder = getIncomingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, STARTTLS, port],
+ [IMAP, SSL, port],
+ [POP, STARTTLS, port],
+ [POP, SSL, port],
+ [IMAP, NONE, port],
+ [POP, NONE, port],
+ ]);
+
+ ssl = SSL;
+ tryOrder = getIncomingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, SSL, port],
+ [POP, SSL, port],
+ ]);
+}
+
+/**
+ * Test the getOutgoingTryOrder method.
+ */
+function test_getOutgoingTryOrder() {
+ // The list of protocol+ssl+port configurations should match
+ // getOutgoingTryOrder() in guessConfig.js.
+ let domain = "example.com";
+ let protocol = SMTP;
+ let ssl = UNKNOWN;
+ let port = UNKNOWN;
+ let tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [SMTP, STARTTLS, 587],
+ [SMTP, STARTTLS, 25],
+ [SMTP, SSL, 465],
+ [SMTP, NONE, 587],
+ [SMTP, NONE, 25],
+ ]);
+
+ ssl = SSL;
+ tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[SMTP, SSL, 465]]);
+
+ ssl = UNKNOWN;
+ port = 31337;
+ tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [SMTP, STARTTLS, port],
+ [SMTP, SSL, port],
+ [SMTP, NONE, port],
+ ]);
+
+ ssl = SSL;
+ tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[SMTP, SSL, port]]);
+}
+
+function run_test() {
+ test_getHostEntry();
+ test_getIncomingTryOrder();
+ test_getOutgoingTryOrder();
+}
diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js
new file mode 100644
index 0000000000..919b0ffc2f
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js
@@ -0,0 +1,266 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/**
+ * Tests accountcreation/readFromXML.js , reading the XML files
+ * containing a mail configuration.
+ *
+ * To allow forwards-compatibility (add new stuff in the future without
+ * breaking old clients on the new files), we are now fairly tolerant when
+ * reading and allow fallback mechanisms. This test checks whether that works,
+ * and of course also whether we can read a normal config and get the proper
+ * values.
+ */
+
+// Globals
+
+var { AccountConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+var { readFromXML } = ChromeUtils.import(
+ "resource:///modules/accountcreation/readFromXML.jsm"
+);
+
+var { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
+
+/*
+ * UTILITIES
+ */
+
+function assert_equal(aA, aB, aWhy) {
+ if (aA != aB) {
+ do_throw(aWhy);
+ }
+ Assert.equal(aA, aB);
+}
+
+/**
+ * Test that two config entries are the same.
+ */
+function assert_equal_config(aA, aB, field) {
+ assert_equal(aA, aB, "Configured " + field + " is incorrect.");
+}
+
+/*
+ * TESTS
+ */
+
+/**
+ * Test that the xml reader returns a proper config and
+ * is also forwards-compatible to new additions to the data format.
+ */
+function test_readFromXML_config1() {
+ var clientConfigXML =
+ "<clientConfig>" +
+ '<emailProvider id="example.com">' +
+ "<domain>example.com</domain>" +
+ "<domain>example.net</domain>" +
+ "<displayName>Example</displayName>" +
+ "<displayShortName>Example Mail</displayShortName>" +
+ // 1. - protocol not supported
+ '<incomingServer type="imap5">' +
+ "<hostname>badprotocol.example.com</hostname>" +
+ "<port>993</port>" +
+ "<socketType>SSL</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>ssl-client-cert</authentication>" +
+ "</incomingServer>" +
+ // 2. - socket type not supported
+ '<incomingServer type="imap">' +
+ "<hostname>badsocket.example.com</hostname>" +
+ "<port>993</port>" +
+ "<socketType>key-from-DNSSEC</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>password-cleartext</authentication>" +
+ "</incomingServer>" +
+ // 3. - first supported incoming server
+ '<incomingServer type="imap">' +
+ "<hostname>imapmail.example.com</hostname>" +
+ "<port>993</port>" +
+ "<socketType>SSL</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>password-cleartext</authentication>" +
+ "</incomingServer>" +
+ // 4. - auth method not supported
+ '<incomingServer type="imap">' +
+ "<hostname>badauth.example.com</hostname>" +
+ "<port>993</port>" +
+ "<socketType>SSL</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>ssl-client-cert</authentication>" +
+ // Throw in some elements we don"t support yet
+ "<imap>" +
+ '<rootFolder path="INBOX."/>' +
+ '<specialFolder id="sent" path="INBOX.Sent Mail"/>' +
+ "</imap>" +
+ "</incomingServer>" +
+ // 5. - second supported incoming server
+ '<incomingServer type="pop3">' +
+ "<hostname>popmail.example.com</hostname>" +
+ // alternative hostname, not yet supported, should be ignored
+ "<hostname>popbackup.example.com</hostname>" +
+ "<port>110</port>" +
+ "<port>7878</port>" +
+ // unsupported socket type
+ "<socketType>GSSAPI2</socketType>" +
+ // but fall back
+ "<socketType>plain</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<username>%EMAILADDRESS%</username>" +
+ // unsupported auth method
+ "<authentication>GSSAPI2</authentication>" +
+ // but fall back
+ "<authentication>password-encrypted</authentication>" +
+ "<pop3>" +
+ "<leaveMessagesOnServer>true</leaveMessagesOnServer>" +
+ "<daysToLeaveMessagesOnServer>999</daysToLeaveMessagesOnServer>" +
+ "</pop3>" +
+ "</incomingServer>" +
+ // outgoing server with invalid auth method
+ '<outgoingServer type="smtp">' +
+ "<hostname>badauth.example.com</hostname>" +
+ "<port>587</port>" +
+ "<socketType>STARTTLS</socketType>" +
+ "<username>%EMAILADDRESS%</username>" +
+ "<authentication>smtp-after-imap</authentication>" +
+ "</outgoingServer>" +
+ // outgoing server - supported
+ '<outgoingServer type="smtp">' +
+ "<hostname>smtpout.example.com</hostname>" +
+ "<hostname>smtpfallback.example.com</hostname>" +
+ "<port>587</port>" +
+ "<port>7878</port>" +
+ "<socketType>GSSAPI2</socketType>" +
+ "<socketType>STARTTLS</socketType>" +
+ "<username>%EMAILADDRESS%</username>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>GSSAPI2</authentication>" +
+ "<authentication>client-IP-address</authentication>" +
+ "<smtp/>" +
+ "</outgoingServer>" +
+ // Throw in some more elements we don"t support yet
+ '<enableURL url="http://foobar"/>' +
+ '<instructionsURL url="http://foobar"/>' +
+ "</emailProvider>" +
+ "</clientConfig>";
+
+ var domParser = new DOMParser();
+ var config = readFromXML(
+ JXON.build(domParser.parseFromString(clientConfigXML, "text/xml"))
+ );
+
+ Assert.equal(config instanceof AccountConfig, true);
+ Assert.equal("example.com", config.id);
+ Assert.equal("Example", config.displayName);
+ Assert.notEqual(-1, config.domains.indexOf("example.com"));
+ // 1. incoming server skipped because of an unsupported protocol
+ // 2. incoming server skipped because of an so-far unknown auth method
+ // 3. incoming server is fine for us: IMAP, SSL, cleartext password
+ let server = config.incoming;
+ Assert.equal("imapmail.example.com", server.hostname);
+ Assert.equal("imap", server.type);
+ Assert.equal(Ci.nsMsgSocketType.SSL, server.socketType);
+ Assert.equal(3, server.auth); // cleartext password
+ // only one more supported incoming server
+ Assert.equal(1, config.incomingAlternatives.length);
+ // 4. incoming server skipped because of an so-far unknown socketType
+ // 5. server: POP
+ server = config.incomingAlternatives[0];
+ Assert.equal("popmail.example.com", server.hostname);
+ Assert.equal("pop3", server.type);
+ Assert.equal(Ci.nsMsgSocketType.plain, server.socketType);
+ Assert.equal(4, server.auth); // encrypted password
+
+ // SMTP server, most preferred
+ server = config.outgoing;
+ Assert.equal("smtpout.example.com", server.hostname);
+ Assert.equal("smtp", server.type);
+ Assert.equal(Ci.nsMsgSocketType.alwaysSTARTTLS, server.socketType);
+ Assert.equal(1, server.auth); // no auth
+ // no other SMTP servers
+ Assert.equal(0, config.outgoingAlternatives.length);
+}
+
+/**
+ * Test the replaceVariables method.
+ */
+function test_replaceVariables() {
+ var clientConfigXML =
+ "<clientConfig>" +
+ '<emailProvider id="example.com">' +
+ "<domain>example.com</domain>" +
+ "<displayName>example.com</displayName>" +
+ "<displayShortName>example.com</displayShortName>" +
+ '<incomingServer type="pop3">' +
+ "<hostname>pop.%EMAILDOMAIN%</hostname>" +
+ "<port>995</port>" +
+ "<socketType>SSL</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>plain</authentication>" +
+ "<pop3>" +
+ "<leaveMessagesOnServer>true</leaveMessagesOnServer>" +
+ "<daysToLeaveMessagesOnServer>999</daysToLeaveMessagesOnServer>" +
+ "</pop3>" +
+ "</incomingServer>" +
+ '<outgoingServer type="smtp">' +
+ "<hostname>smtp.example.com</hostname>" +
+ "<port>587</port>" +
+ "<socketType>STARTTLS</socketType>" +
+ "<username>%EMAILADDRESS%</username>" +
+ "<authentication>plain</authentication>" +
+ "<addThisServer>true</addThisServer>" +
+ "<useGlobalPreferredServer>false</useGlobalPreferredServer>" +
+ "</outgoingServer>" +
+ "</emailProvider>" +
+ "</clientConfig>";
+
+ var domParser = new DOMParser();
+ var config = readFromXML(
+ JXON.build(domParser.parseFromString(clientConfigXML, "text/xml"))
+ );
+
+ AccountConfig.replaceVariables(
+ config,
+ "Yamato Nadeshiko",
+ "yamato.nadeshiko@example.com",
+ "abc12345"
+ );
+
+ assert_equal_config(
+ config.incoming.username,
+ "yamato.nadeshiko",
+ "incoming server username"
+ );
+ assert_equal_config(
+ config.outgoing.username,
+ "yamato.nadeshiko@example.com",
+ "outgoing server username"
+ );
+ assert_equal_config(
+ config.incoming.hostname,
+ "pop.example.com",
+ "incoming server hostname"
+ );
+ assert_equal_config(
+ config.outgoing.hostname,
+ "smtp.example.com",
+ "outgoing server hostname"
+ );
+ assert_equal_config(
+ config.identity.realname,
+ "Yamato Nadeshiko",
+ "user real name"
+ );
+ assert_equal_config(
+ config.identity.emailAddress,
+ "yamato.nadeshiko@example.com",
+ "user email address"
+ );
+}
+
+function run_test() {
+ test_readFromXML_config1();
+ test_replaceVariables();
+}
diff --git a/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini b/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..8d42ab2145
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+head =
+tail =
+support-files = data/*
+
+[test_autoconfigFetchDisk.js]
+skip-if = os == 'win' && msix # MSIX cannot write to application directory
+[test_autoconfigUtils.js]
+[test_autoconfigXML.js]
diff --git a/comm/mail/components/accountcreation/views/container.mjs b/comm/mail/components/accountcreation/views/container.mjs
new file mode 100644
index 0000000000..3bf8d9b4dc
--- /dev/null
+++ b/comm/mail/components/accountcreation/views/container.mjs
@@ -0,0 +1,50 @@
+/* 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/. */
+
+/**
+ * Custom Element containing the main account hub dialog. Used to append the
+ * needed CSS files to the shadowDom to prevent style leakage.
+ * NOTE: This could directly extend an HTMLDialogElement if it had a shadowRoot.
+ */
+class AccountHubContainer extends HTMLElement {
+ /** @type {HTMLDialogElement} */
+ modal;
+
+ /** @type {DOMLocalization} */
+ l10n;
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ // Already connected, no need to run it again.
+ return;
+ }
+
+ const shadowRoot = this.attachShadow({ mode: "open" });
+
+ // Load styles in the shadowRoot so we don't leak it.
+ let style = document.createElement("link");
+ style.rel = "stylesheet";
+ style.href = "chrome://messenger/skin/accountHub.css";
+ shadowRoot.appendChild(style);
+
+ let template = document.getElementById("accountHubDialog");
+ let clonedNode = template.content.cloneNode(true);
+ shadowRoot.appendChild(clonedNode);
+ this.modal = shadowRoot.querySelector("dialog");
+
+ // We need to create an internal DOM localization in order to let fluent
+ // see the IDs inside our shadowRoot.
+ this.l10n = new DOMLocalization([
+ "branding/brand.ftl",
+ "messenger/accountcreation/accountHub.ftl",
+ "messenger/accountcreation/accountSetup.ftl",
+ ]);
+ this.l10n.connectRoot(shadowRoot);
+ }
+
+ disconnectedCallback() {
+ this.l10n.disconnectRoot(this.shadowRoot);
+ }
+}
+customElements.define("account-hub-container", AccountHubContainer);
diff --git a/comm/mail/components/accountcreation/views/email.mjs b/comm/mail/components/accountcreation/views/email.mjs
new file mode 100644
index 0000000000..f5a4d628b7
--- /dev/null
+++ b/comm/mail/components/accountcreation/views/email.mjs
@@ -0,0 +1,185 @@
+/* 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/. */
+
+class AccountHubEmail extends HTMLElement {
+ /**
+ * The email setup form.
+ *
+ * @type {HTMLFormElement}
+ */
+ #form;
+
+ /**
+ * The account name field.
+ *
+ * @type {HTMLInputElement}
+ */
+ #realName;
+
+ /**
+ * The email field.
+ *
+ * @type {HTMLInputElement}
+ */
+ #email;
+
+ /**
+ * The password field.
+ *
+ * @type {HTMLInputElement}
+ */
+ #password;
+
+ /**
+ * The password visibility button.
+ *
+ * @type {HTMLButtonElement}
+ */
+ #passwordToggleButton;
+
+ /**
+ * The submit form button.
+ *
+ * @type {HTMLButtonElement}
+ */
+ #continueButton;
+
+ /**
+ * The domain name extrapolated from the email address.
+ *
+ * @type {string}
+ */
+ #domain = "";
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.classList.add("account-hub-view");
+
+ let template = document.getElementById("accountHubEmailSetup");
+ this.appendChild(template.content.cloneNode(true));
+
+ this.#form = this.querySelector("form");
+ this.#realName = this.querySelector("#realName");
+ this.#email = this.querySelector("#email");
+ this.#password = this.querySelector("#password");
+ this.#passwordToggleButton = this.querySelector("#passwordToggleButton");
+ this.#continueButton = this.querySelector("#emailContinueButton");
+
+ this.initUI();
+
+ this.setupEventListeners();
+ }
+
+ /**
+ * Initialize the UI of the email setup flow.
+ */
+ initUI() {
+ // Populate the account name if we can get some user info.
+ if ("@mozilla.org/userinfo;1" in Cc) {
+ let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo);
+ this.#realName.value = userInfo.fullname;
+ }
+
+ this.#realName.focus();
+ }
+
+ /**
+ * Set up the event listeners for this workflow only once.
+ */
+ setupEventListeners() {
+ this.#form.addEventListener("submit", event => {
+ event.preventDefault();
+ event.stopPropagation();
+ console.log("submit");
+ });
+
+ this.#realName.addEventListener("input", () => this.#checkValidForm());
+ this.#email.addEventListener("input", () => this.#checkValidForm());
+ this.#password.addEventListener("input", () => this.#onPasswordInput());
+
+ this.#passwordToggleButton.addEventListener("click", event => {
+ this.#togglePasswordInput(
+ event.target.getAttribute("aria-pressed") === "false"
+ );
+ });
+
+ // Set the Cancel/Back button.
+ this.querySelector("#emailGoBackButton").addEventListener("click", () => {
+ // If in first view, go back to start, otherwise go back in the flow.
+ this.dispatchEvent(
+ new CustomEvent("open-view", {
+ bubbles: true,
+ composed: true,
+ detail: { type: "START" },
+ })
+ );
+ });
+ }
+
+ /**
+ * Check whether the user entered the minimum amount of information needed to
+ * leave the first view and is allowed to proceed to the detection step.
+ */
+ #checkValidForm() {
+ const isValidForm =
+ this.#email.checkValidity() && this.#realName.checkValidity();
+ this.#domain = isValidForm
+ ? this.#email.value.split("@")[1].toLowerCase()
+ : "";
+
+ this.#continueButton.disabled = !isValidForm;
+ }
+
+ /**
+ * Handle the password visibility toggle on password input.
+ */
+ #onPasswordInput() {
+ if (!this.#password.value) {
+ this.#togglePasswordInput(false);
+ }
+ }
+
+ /**
+ * Toggle the password field type between `password` and `text` to allow users
+ * reading their typed password.
+ *
+ * @param {boolean} show - If the password field should become a text field.
+ */
+ #togglePasswordInput(show) {
+ this.#password.type = show ? "text" : "password";
+ this.#passwordToggleButton.setAttribute("aria-pressed", show.toString());
+ document.l10n.setAttributes(
+ this.#passwordToggleButton,
+ show
+ ? "account-setup-password-toggle-hide"
+ : "account-setup-password-toggle-show"
+ );
+ }
+
+ /**
+ * Check if any operation is currently in process and return true only if we
+ * can leave this view.
+ *
+ * @returns {boolean} - If the account hub can remove this view.
+ */
+ reset() {
+ // TODO
+ // Check for:
+ // - Non-abortable operations (autoconfig, email account setup, etc)
+
+ this.#form.reset();
+ this.#togglePasswordInput(false);
+ // TODO
+ // Before resetting we need to:
+ // - Clean up the fields.
+ // - Reset the autoconfig (cached server info).
+ // - Reset the view to the initial screen.
+ return true;
+ }
+}
+customElements.define("account-hub-email", AccountHubEmail);
diff --git a/comm/mail/components/accountcreation/views/start.mjs b/comm/mail/components/accountcreation/views/start.mjs
new file mode 100644
index 0000000000..66487cc928
--- /dev/null
+++ b/comm/mail/components/accountcreation/views/start.mjs
@@ -0,0 +1,163 @@
+/* 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 gSync */
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+class AccountHubStart extends HTMLElement {
+ #accounts = [
+ {
+ id: "email",
+ l10n: "account-hub-email-setup-button",
+ type: "MAIL",
+ },
+ {
+ id: "calendar",
+ l10n: "account-hub-calendar-setup-button",
+ type: "CALENDAR",
+ },
+ {
+ id: "addressBook",
+ l10n: "account-hub-address-book-setup-button",
+ type: "ADDRESS_BOOK",
+ },
+ {
+ id: "chat",
+ l10n: "account-hub-chat-setup-button",
+ type: "CHAT",
+ },
+ {
+ id: "feed",
+ l10n: "account-hub-feed-setup-button",
+ type: "FEED",
+ },
+ {
+ id: "newsgroup",
+ l10n: "account-hub-newsgroup-setup-button",
+ type: "NNTP",
+ },
+ // TODO: Import/Export of profiles is kinda broken so we don't want to
+ // expose it so much for now.
+ // {
+ // id: "import",
+ // l10n: "account-hub-import-setup-button",
+ // type: "IMPORT",
+ // },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.classList.add("account-hub-view");
+
+ let template = document.getElementById("accountHubStart");
+ this.appendChild(template.content.cloneNode(true));
+
+ this.initUI();
+
+ this.setupAccountFlows();
+ }
+
+ /**
+ * Update the UI to reflect reality whenever this view is triggered.
+ */
+ initUI() {
+ const hasAccounts = MailServices.accounts.accounts.length;
+ this.querySelector("#welcomeHeader").hidden = hasAccounts;
+ this.querySelector("#defaultHeader").hidden = !hasAccounts;
+
+ if (AppConstants.NIGHTLY_BUILD) {
+ this.updateFxAButton();
+ }
+
+ // Hide the release notes link for nightly builds since we don't have any.
+ if (AppConstants.NIGHTLY_BUILD) {
+ this.querySelector("#hubReleaseNotes").closest("li").hidden = true;
+ return;
+ }
+
+ if (
+ Services.prefs.getPrefType("app.releaseNotesURL") !=
+ Services.prefs.PREF_INVALID
+ ) {
+ let relNotesURL = Services.urlFormatter.formatURLPref(
+ "app.releaseNotesURL"
+ );
+ if (relNotesURL != "about:blank") {
+ this.querySelector("#hubReleaseNotes").href = relNotesURL;
+ return;
+ }
+ // Hide the release notes link if we don't have a URL to add.
+ this.querySelector("#hubReleaseNotes").closest("li").hidden = true;
+ }
+ }
+
+ /**
+ * Populate the main container fo the start view with all the available
+ * account creation flows.
+ */
+ setupAccountFlows() {
+ const fragment = new DocumentFragment();
+ for (const account of this.#accounts) {
+ const button = document.createElement("button");
+ button.id = `${account.id}Button`;
+ button.classList.add("button", "button-account");
+ document.l10n.setAttributes(button, account.l10n);
+ button.addEventListener("click", () => {
+ this.dispatchEvent(
+ new CustomEvent("open-view", {
+ bubbles: true,
+ composed: true,
+ detail: {
+ type: account.type,
+ },
+ })
+ );
+ });
+ fragment.append(button);
+ }
+ this.querySelector(".hub-body-grid").replaceChildren(fragment);
+
+ if (AppConstants.NIGHTLY_BUILD) {
+ this.querySelector("#hubSyncButton").addEventListener("click", () => {
+ // FIXME: Open this in a dialog or browser inside the modal, or find a
+ // way to close the account hub without an account and open it again in
+ // case the FxA login fails to set up accounts.
+ gSync.initFxA();
+ });
+ }
+ }
+
+ /**
+ * Set up the Firefox Sync button.
+ */
+ updateFxAButton() {
+ const state = UIState.get();
+ this.querySelector("#hubSyncButton").hidden =
+ state.status == UIState.STATUS_SIGNED_IN;
+ }
+
+ /**
+ * The start view doesn't have any abortable operation that needs to be
+ * checked, so we always return true.
+ *
+ * @returns {boolean} - Always true.
+ */
+ reset() {
+ return true;
+ }
+}
+customElements.define("account-hub-start", AccountHubStart);
diff --git a/comm/mail/components/activity/Activity.jsm b/comm/mail/components/activity/Activity.jsm
new file mode 100644
index 0000000000..1b23efe1c7
--- /dev/null
+++ b/comm/mail/components/activity/Activity.jsm
@@ -0,0 +1,322 @@
+/* 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 EXPORTED_SYMBOLS = ["ActivityProcess", "ActivityEvent", "ActivityWarning"];
+
+// Base class for ActivityProcess and ActivityEvent objects
+
+function Activity() {
+ this._initLogging();
+ this._listeners = [];
+ this._subjects = [];
+}
+
+Activity.prototype = {
+ id: -1,
+ bindingName: "",
+ iconClass: "",
+ groupingStyle: Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT,
+ facet: "",
+ displayText: "",
+ initiator: null,
+ contextType: "",
+ context: "",
+ contextObj: null,
+
+ _initLogging() {
+ this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ });
+ },
+
+ addListener(aListener) {
+ this._listeners.push(aListener);
+ },
+
+ removeListener(aListener) {
+ for (let i = 0; i < this._listeners.length; i++) {
+ if (this._listeners[i] == aListener) {
+ this._listeners.splice(i, 1);
+ break;
+ }
+ }
+ },
+
+ addSubject(aSubject) {
+ this._subjects.push(aSubject);
+ },
+
+ getSubjects() {
+ return this._subjects.slice();
+ },
+};
+
+function ActivityProcess() {
+ Activity.call(this);
+ this.bindingName = "activity-process-item";
+ this.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+}
+
+ActivityProcess.prototype = {
+ __proto__: Activity.prototype,
+
+ percentComplete: -1,
+ lastStatusText: "",
+ workUnitComplete: 0,
+ totalWorkUnits: 0,
+ startTime: Date.now(),
+ _cancelHandler: null,
+ _pauseHandler: null,
+ _retryHandler: null,
+ _state: Ci.nsIActivityProcess.STATE_INPROGRESS,
+
+ init(aDisplayText, aInitiator) {
+ this.displayText = aDisplayText;
+ this.initiator = aInitiator;
+ },
+
+ get state() {
+ return this._state;
+ },
+
+ set state(val) {
+ if (val == this._state) {
+ return;
+ }
+
+ // test validity of the new state
+ //
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_INPROGRESS &&
+ !(
+ val == Ci.nsIActivityProcess.STATE_COMPLETED ||
+ val == Ci.nsIActivityProcess.STATE_CANCELED ||
+ val == Ci.nsIActivityProcess.STATE_WAITINGFORRETRY ||
+ val == Ci.nsIActivityProcess.STATE_WAITINGFORINPUT ||
+ val == Ci.nsIActivityProcess.STATE_PAUSED
+ )
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ // we cannot change the state after the activity is completed,
+ // or it is canceled.
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_COMPLETED ||
+ this._state == Ci.nsIActivityProcess.STATE_CANCELED
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_PAUSED &&
+ !(
+ val == Ci.nsIActivityProcess.STATE_COMPLETED ||
+ val == Ci.nsIActivityProcess.STATE_INPROGRESS ||
+ val == Ci.nsIActivityProcess.STATE_WAITINGFORRETRY ||
+ val == Ci.nsIActivityProcess.STATE_WAITINGFORINPUT ||
+ val == Ci.nsIActivityProcess.STATE_CANCELED
+ )
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_WAITINGFORINPUT &&
+ !(
+ val == Ci.nsIActivityProcess.STATE_INPROGRESS ||
+ val == Ci.nsIActivityProcess.STATE_CANCELED
+ )
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_WAITINGFORRETRY &&
+ !(
+ val == Ci.nsIActivityProcess.STATE_INPROGRESS ||
+ val == Ci.nsIActivityProcess.STATE_CANCELED
+ )
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ let oldState = this._state;
+ this._state = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onStateChanged listeners");
+ for (let value of this._listeners) {
+ try {
+ value.onStateChanged(this, oldState);
+ } catch (e) {
+ this.log.error("Exception thrown by onStateChanged listener: " + e);
+ }
+ }
+ },
+
+ setProgress(aStatusText, aWorkUnitsComplete, aTotalWorkUnits) {
+ if (aTotalWorkUnits == 0) {
+ this.percentComplete = -1;
+ this.workUnitComplete = 0;
+ this.totalWorkUnits = 0;
+ } else {
+ this.percentComplete = parseInt(
+ (100.0 * aWorkUnitsComplete) / aTotalWorkUnits
+ );
+ this.workUnitComplete = aWorkUnitsComplete;
+ this.totalWorkUnits = aTotalWorkUnits;
+ }
+ this.lastStatusText = aStatusText;
+
+ // notify listeners
+ for (let value of this._listeners) {
+ try {
+ value.onProgressChanged(
+ this,
+ aStatusText,
+ aWorkUnitsComplete,
+ aTotalWorkUnits
+ );
+ } catch (e) {
+ this.log.error("Exception thrown by onProgressChanged listener: " + e);
+ }
+ }
+ },
+
+ get cancelHandler() {
+ return this._cancelHandler;
+ },
+
+ set cancelHandler(val) {
+ this._cancelHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ try {
+ value.onHandlerChanged(this);
+ } catch (e) {
+ this.log.error("Exception thrown by onHandlerChanged listener: " + e);
+ }
+ }
+ },
+
+ get pauseHandler() {
+ return this._pauseHandler;
+ },
+
+ set pauseHandler(val) {
+ this._pauseHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ value.onHandlerChanged(this);
+ }
+ },
+
+ get retryHandler() {
+ return this._retryHandler;
+ },
+
+ set retryHandler(val) {
+ this._retryHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ value.onHandlerChanged(this);
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityProcess", "nsIActivity"]),
+};
+
+function ActivityEvent() {
+ Activity.call(this);
+ this.bindingName = "activity-event-item";
+ this.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_STANDALONE;
+}
+
+ActivityEvent.prototype = {
+ __proto__: Activity.prototype,
+
+ statusText: "",
+ startTime: 0,
+ completionTime: 0,
+ _undoHandler: null,
+
+ init(aDisplayText, aInitiator, aStatusText, aStartTime, aCompletionTime) {
+ this.displayText = aDisplayText;
+ this.statusText = aStatusText;
+ this.startTime = aStartTime;
+ if (aCompletionTime) {
+ this.completionTime = aCompletionTime;
+ } else {
+ this.completionTime = Date.now();
+ }
+ this.initiator = aInitiator;
+ this._completionTime = aCompletionTime;
+ },
+
+ get undoHandler() {
+ return this._undoHandler;
+ },
+
+ set undoHandler(val) {
+ this._undoHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ value.onHandlerChanged(this);
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityEvent", "nsIActivity"]),
+};
+
+function ActivityWarning() {
+ Activity.call(this);
+ this.bindingName = "activity-warning-item";
+ this.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+}
+
+ActivityWarning.prototype = {
+ __proto__: Activity.prototype,
+
+ recoveryTipText: "",
+ _time: 0,
+ _recoveryHandler: null,
+
+ init(aWarningText, aInitiator, aRecoveryTipText) {
+ this.displayText = aWarningText;
+ this.initiator = aInitiator;
+ this.recoveryTipText = aRecoveryTipText;
+ this._time = Date.now();
+ },
+
+ get recoveryHandler() {
+ return this._recoveryHandler;
+ },
+
+ set recoveryHandler(val) {
+ this._recoveryHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ value.onHandlerChanged(this);
+ }
+ },
+
+ get time() {
+ return this._time;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityWarning", "nsIActivity"]),
+};
diff --git a/comm/mail/components/activity/ActivityManager.jsm b/comm/mail/components/activity/ActivityManager.jsm
new file mode 100644
index 0000000000..c808cb3354
--- /dev/null
+++ b/comm/mail/components/activity/ActivityManager.jsm
@@ -0,0 +1,157 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 EXPORTED_SYMBOLS = ["ActivityManager"];
+
+function ActivityManager() {}
+
+ActivityManager.prototype = {
+ log: console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }),
+ _listeners: [],
+ _processCount: 0,
+ _db: null,
+ _idCounter: 1,
+ _activities: new Map(),
+
+ get processCount() {
+ let count = 0;
+ for (let value of this._activities.values()) {
+ if (value instanceof Ci.nsIActivityProcess) {
+ count++;
+ }
+ }
+
+ return count;
+ },
+
+ getProcessesByContext(aContextType, aContextObj) {
+ let list = [];
+ for (let activity of this._activities.values()) {
+ if (
+ activity instanceof Ci.nsIActivityProcess &&
+ activity.contextType == aContextType &&
+ activity.contextObj == aContextObj
+ ) {
+ list.push(activity);
+ }
+ }
+ return list;
+ },
+
+ get db() {
+ return null;
+ },
+
+ get nextId() {
+ return this._idCounter++;
+ },
+
+ addActivity(aActivity) {
+ try {
+ this.log.info("adding Activity");
+ // get the next valid id for this activity
+ let id = this.nextId;
+ aActivity.id = id;
+
+ // add activity into the activities table
+ this._activities.set(id, aActivity);
+ // notify all the listeners
+ for (let value of this._listeners) {
+ try {
+ value.onAddedActivity(id, aActivity);
+ } catch (e) {
+ this.log.error("Exception calling onAddedActivity" + e);
+ }
+ }
+ return id;
+ } catch (e) {
+ // for some reason exceptions don't end up on the console if we don't
+ // explicitly log them.
+ this.log.error("Exception: " + e);
+ throw e;
+ }
+ },
+
+ removeActivity(aID) {
+ let activity = this.getActivity(aID);
+ if (!activity) {
+ return; // Nothing to remove.
+ }
+
+ // make sure that the activity is not in-progress state
+ if (
+ activity instanceof Ci.nsIActivityProcess &&
+ activity.state == Ci.nsIActivityProcess.STATE_INPROGRESS
+ ) {
+ throw Components.Exception(`Activity in progress`, Cr.NS_ERROR_FAILURE);
+ }
+
+ // remove the activity
+ this._activities.delete(aID);
+
+ // notify all the listeners
+ for (let value of this._listeners) {
+ try {
+ value.onRemovedActivity(aID);
+ } catch (e) {
+ // ignore the exception
+ }
+ }
+ },
+
+ cleanUp() {
+ // Get the list of aIDs.
+ this.log.info("cleanUp\n");
+ for (let [id, activity] of this._activities) {
+ if (activity instanceof Ci.nsIActivityProcess) {
+ // Note: The .state property will return undefined if you aren't in
+ // this if-instanceof block.
+ let state = activity.state;
+ if (
+ state != Ci.nsIActivityProcess.STATE_INPROGRESS &&
+ state != Ci.nsIActivityProcess.STATE_PAUSED &&
+ state != Ci.nsIActivityProcess.STATE_WAITINGFORINPUT &&
+ state != Ci.nsIActivityProcess.STATE_WAITINGFORRETRY
+ ) {
+ this.removeActivity(id);
+ }
+ } else {
+ this.removeActivity(id);
+ }
+ }
+ },
+
+ getActivity(aID) {
+ return this._activities.get(aID);
+ },
+
+ containsActivity(aID) {
+ return this._activities.has(aID);
+ },
+
+ getActivities() {
+ return [...this._activities.values()];
+ },
+
+ addListener(aListener) {
+ this.log.info("addListener\n");
+ this._listeners.push(aListener);
+ },
+
+ removeListener(aListener) {
+ this.log.info("removeListener\n");
+ for (let i = 0; i < this._listeners.length; i++) {
+ if (this._listeners[i] == aListener) {
+ this._listeners.splice(i, 1);
+ }
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityManager"]),
+};
diff --git a/comm/mail/components/activity/ActivityManagerUI.jsm b/comm/mail/components/activity/ActivityManagerUI.jsm
new file mode 100644
index 0000000000..b36b9b5a72
--- /dev/null
+++ b/comm/mail/components/activity/ActivityManagerUI.jsm
@@ -0,0 +1,47 @@
+/* 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 EXPORTED_SYMBOLS = ["ActivityManagerUI"];
+
+const ACTIVITY_MANAGER_URL = "chrome://messenger/content/activity.xhtml";
+
+function ActivityManagerUI() {}
+
+ActivityManagerUI.prototype = {
+ show(aWindowContext, aID) {
+ // First we see if it is already visible
+ let window = this.recentWindow;
+ if (window) {
+ window.focus();
+ return;
+ }
+
+ let parent = null;
+ try {
+ if (aWindowContext) {
+ parent = aWindowContext.docShell.domWindow;
+ }
+ } catch (e) {
+ /* it's OK to not have a parent window */
+ }
+
+ Services.ww.openWindow(
+ parent,
+ ACTIVITY_MANAGER_URL,
+ "ActivityManager",
+ "chrome,dialog=no,resizable",
+ {}
+ );
+ },
+
+ get visible() {
+ return null != this.recentWindow;
+ },
+
+ get recentWindow() {
+ return Services.wm.getMostRecentWindow("Activity:Manager");
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityManagerUI"]),
+};
diff --git a/comm/mail/components/activity/components.conf b/comm/mail/components/activity/components.conf
new file mode 100644
index 0000000000..429fd62bb8
--- /dev/null
+++ b/comm/mail/components/activity/components.conf
@@ -0,0 +1,38 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{B2C036A3-F7CE-401C-95EE-9C21505167FD}',
+ 'contract_ids': ['@mozilla.org/activity-process;1'],
+ 'jsm': 'resource:///modules/Activity.jsm',
+ 'constructor': 'ActivityProcess',
+ },
+ {
+ 'cid': '{87AAEB20-89D9-4B95-9542-3BF72405CAB2}',
+ 'contract_ids': ['@mozilla.org/activity-event;1'],
+ 'jsm': 'resource:///modules/Activity.jsm',
+ 'constructor': 'ActivityEvent',
+ },
+ {
+ 'cid': '{968BAC9E-798B-4952-B384-86B21B8CC71E}',
+ 'contract_ids': ['@mozilla.org/activity-warning;1'],
+ 'jsm': 'resource:///modules/Activity.jsm',
+ 'constructor': 'ActivityWarning',
+ },
+ {
+ 'cid': '{8aa5972e-19cb-41cc-9696-645f8a8d1a06}',
+ 'contract_ids': ['@mozilla.org/activity-manager;1'],
+ 'jsm': 'resource:///modules/ActivityManager.jsm',
+ 'constructor': 'ActivityManager',
+ },
+ {
+ 'cid': '{5fa5974e-09cb-40cc-9696-643f8a8d9a06}',
+ 'contract_ids': ['@mozilla.org/activity-manager-ui;1'],
+ 'jsm': 'resource:///modules/ActivityManagerUI.jsm',
+ 'constructor': 'ActivityManagerUI',
+ },
+]
diff --git a/comm/mail/components/activity/content/activity-widgets.js b/comm/mail/components/activity/content/activity-widgets.js
new file mode 100644
index 0000000000..44ee16bff8
--- /dev/null
+++ b/comm/mail/components/activity/content/activity-widgets.js
@@ -0,0 +1,384 @@
+/* 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 MozXULElement, activityManager */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { makeFriendlyDateAgo } = ChromeUtils.import(
+ "resource:///modules/TemplateUtils.jsm"
+ );
+
+ let activityStrings = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ );
+
+ /**
+ * The ActivityItemBase widget is the base class for all the activity item.
+ * It initializes activity details: i.e. id, status, icon, name, progress,
+ * date etc. for the activity widgets.
+ *
+ * @abstract
+ * @augments HTMLLIElement
+ */
+ class ActivityItemBase extends HTMLLIElement {
+ connectedCallback() {
+ if (!this.hasChildNodes()) {
+ // fetch the activity and set the base attributes
+ this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ });
+ let actID = this.getAttribute("actID");
+ this._activity = activityManager.getActivity(actID);
+ this._activity.QueryInterface(this.constructor.activityInterface);
+
+ // Construct the children.
+ this.classList.add("activityitem");
+
+ let icon = document.createElement("img");
+ icon.setAttribute(
+ "src",
+ this._activity.iconClass
+ ? `chrome://messenger/skin/icons/new/activity/${this._activity.iconClass}Icon.svg`
+ : this.constructor.defaultIconSrc
+ );
+ icon.setAttribute("alt", "");
+ this.appendChild(icon);
+
+ let display = document.createElement("span");
+ display.classList.add("displayText");
+ this.appendChild(display);
+
+ if (this.isEvent || this.isWarning) {
+ let time = document.createElement("time");
+ time.classList.add("dateTime");
+ this.appendChild(time);
+ }
+
+ if (this.isProcess) {
+ let progress = document.createElement("progress");
+ progress.setAttribute("value", "0");
+ progress.setAttribute("max", "100");
+ progress.classList.add("progressmeter");
+ this.appendChild(progress);
+ }
+
+ let statusText = document.createElement("span");
+ statusText.setAttribute("role", "note");
+ statusText.classList.add("statusText");
+ this.appendChild(statusText);
+ }
+ // (Re-)Attach the listener.
+ this.attachToActivity();
+ }
+
+ disconnectedCallback() {
+ this.detachFromActivity();
+ }
+
+ get isProcess() {
+ return this.constructor.activityInterface == Ci.nsIActivityProcess;
+ }
+
+ get isEvent() {
+ return this.constructor.activityInterface == Ci.nsIActivityEvent;
+ }
+
+ get isWarning() {
+ return this.constructor.activityInterface == Ci.nsIActivityWarning;
+ }
+
+ get isGroup() {
+ return false;
+ }
+
+ get activity() {
+ return this._activity;
+ }
+
+ detachFromActivity() {
+ if (this.activityListener) {
+ this._activity.removeListener(this.activityListener);
+ }
+ }
+
+ attachToActivity() {
+ if (this.activityListener) {
+ this._activity.addListener(this.activityListener);
+ }
+ }
+
+ static _dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "long",
+ timeStyle: "short",
+ });
+
+ /**
+ * The time the activity occurred.
+ *
+ * @type {number} - The time in milliseconds since the epoch.
+ */
+ set dateTime(time) {
+ let element = this.querySelector(".dateTime");
+ if (!element) {
+ return;
+ }
+ time = new Date(parseInt(time));
+
+ element.setAttribute("datetime", time.toISOString());
+ element.textContent = makeFriendlyDateAgo(time);
+ element.setAttribute(
+ "title",
+ this.constructor._dateTimeFormatter.format(time)
+ );
+ }
+
+ /**
+ * The text that describes additional information to the user.
+ *
+ * @type {string}
+ */
+ set statusText(val) {
+ this.querySelector(".statusText").textContent = val;
+ }
+
+ get statusText() {
+ return this.querySelector(".statusText").textContent;
+ }
+
+ /**
+ * The text that describes the activity to the user.
+ *
+ * @type {string}
+ */
+ set displayText(val) {
+ this.querySelector(".displayText").textContent = val;
+ }
+
+ get displayText() {
+ return this.querySelector(".displayText").textContent;
+ }
+ }
+
+ /**
+ * The MozActivityEvent widget displays information about events (like
+ * deleting or moving the message): e.g image, name, date and description.
+ * It is typically used in Activity Manager window.
+ *
+ * @augments ActivityItemBase
+ */
+ class ActivityEventItem extends ActivityItemBase {
+ static defaultIconSrc =
+ "chrome://messenger/skin/icons/new/activity/defaultEventIcon.svg";
+ static activityInterface = Ci.nsIActivityEvent;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("is", "activity-event-item");
+
+ this.displayText = this.activity.displayText;
+ this.statusText = this.activity.statusText;
+ this.dateTime = this.activity.completionTime;
+ }
+ }
+
+ customElements.define("activity-event-item", ActivityEventItem, {
+ extends: "li",
+ });
+
+ /**
+ * The ActivityGroupItem widget displays information about the activities of
+ * the group: e.g. name of the group, list of the activities with their name,
+ * progress and icon. It is shown in Activity Manager window. It gets removed
+ * when there is no activities from the group.
+ *
+ * @augments HTMLLIElement
+ */
+ class ActivityGroupItem extends HTMLLIElement {
+ constructor() {
+ super();
+
+ let heading = document.createElement("h2");
+ heading.classList.add("contextDisplayText");
+ this.appendChild(heading);
+
+ let list = document.createElement("ul");
+ list.classList.add("activitygroup-list", "activityview");
+ this.appendChild(list);
+
+ this.classList.add("activitygroup");
+ this.setAttribute("is", "activity-group-item");
+ }
+
+ /**
+ * The text heading for the group, as seen by the user.
+ *
+ * @type {string}
+ */
+ set contextDisplayText(val) {
+ this.querySelector(".contextDisplayText").textContent = val;
+ }
+
+ get contextDisplayText() {
+ return this.querySelctor(".contextDisplayText").textContent;
+ }
+
+ get isGroup() {
+ return true;
+ }
+ }
+
+ customElements.define("activity-group-item", ActivityGroupItem, {
+ extends: "li",
+ });
+
+ /**
+ * The ActivityProcessItem widget displays information about the internal
+ * process : e.g image, progress, name, date and description.
+ * It is typically used in Activity Manager window.
+ *
+ * @augments ActivityItemBase
+ */
+ class ActivityProcessItem extends ActivityItemBase {
+ static defaultIconSrc =
+ "chrome://messenger/skin/icons/new/activity/deafultProcessIcon.svg";
+ static activityInterface = Ci.nsIActivityProcess;
+ static textMap = {
+ paused: activityStrings.GetStringFromName("paused2"),
+ canceled: activityStrings.GetStringFromName("canceled"),
+ failed: activityStrings.GetStringFromName("failed"),
+ waitingforinput: activityStrings.GetStringFromName("waitingForInput"),
+ waitingforretry: activityStrings.GetStringFromName("waitingForRetry"),
+ };
+
+ constructor() {
+ super();
+
+ this.activityListener = {
+ onStateChanged: (activity, oldState) => {
+ // change the view of the element according to the new state
+ // default states for each item
+ let hideProgressMeter = false;
+ let statusText = this.statusText;
+
+ switch (this.activity.state) {
+ case Ci.nsIActivityProcess.STATE_INPROGRESS:
+ statusText = "";
+ break;
+ case Ci.nsIActivityProcess.STATE_COMPLETED:
+ hideProgressMeter = true;
+ statusText = "";
+ break;
+ case Ci.nsIActivityProcess.STATE_CANCELED:
+ hideProgressMeter = true;
+ statusText = this.constructor.textMap.canceled;
+ break;
+ case Ci.nsIActivityProcess.STATE_PAUSED:
+ statusText = this.constructor.textMap.paused;
+ break;
+ case Ci.nsIActivityProcess.STATE_WAITINGFORINPUT:
+ statusText = this.constructor.textMap.waitingforinput;
+ break;
+ case Ci.nsIActivityProcess.STATE_WAITINGFORRETRY:
+ hideProgressMeter = true;
+ statusText = this.constructor.textMap.waitingforretry;
+ break;
+ }
+
+ // Set the visibility
+ let meter = this.querySelector(".progressmeter");
+ meter.hidden = hideProgressMeter;
+
+ // Ensure progress meter not active when hidden
+ if (hideProgressMeter) {
+ meter.value = 0;
+ }
+
+ // Update Status text and Display Text Areas
+ // In some states we need to modify Display Text area of
+ // the process (e.g. Failure).
+ this.statusText = statusText;
+ },
+ onProgressChanged: (
+ activity,
+ statusText,
+ workUnitsComplete,
+ totalWorkUnits
+ ) => {
+ let element = document.querySelector(".progressmeter");
+ if (totalWorkUnits == 0) {
+ element.removeAttribute("value");
+ } else {
+ let _percentComplete = (100.0 * workUnitsComplete) / totalWorkUnits;
+ element.value = _percentComplete;
+ }
+ this.statusText = statusText;
+ },
+ };
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("is", "activity-process-item");
+
+ this.displayText = this.activity.displayText;
+ // make sure that custom element reflects the latest state of the process
+ this.activityListener.onStateChanged(
+ this.activity.state,
+ Ci.nsIActivityProcess.STATE_NOTSTARTED
+ );
+ this.activityListener.onProgressChanged(
+ this.activity,
+ this.activity.lastStatusText,
+ this.activity.workUnitComplete,
+ this.activity.totalWorkUnits
+ );
+ }
+
+ get inProgress() {
+ return this.activity.state == Ci.nsIActivityProcess.STATE_INPROGRESS;
+ }
+
+ get isRemovable() {
+ return (
+ this.activity.state == Ci.nsIActivityProcess.STATE_COMPLETED ||
+ this.activity.state == Ci.nsIActivityProcess.STATE_CANCELED
+ );
+ }
+ }
+
+ customElements.define("activity-process-item", ActivityProcessItem, {
+ extends: "li",
+ });
+
+ /**
+ * The ActivityWarningItem widget displays information about
+ * warnings : e.g image, name, date and description.
+ * It is typically used in Activity Manager window.
+ *
+ * @augments ActivityItemBase
+ */
+ class ActivityWarningItem extends ActivityItemBase {
+ static defaultIconSrc =
+ "chrome://messenger/skin/icons/new/activity/warning.svg";
+ static activityInterface = Ci.nsIActivityWarning;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("is", "activity-warning-item");
+
+ this.displayText = this.activity.displayText;
+ this.dateTime = this.activity.time;
+ this.statusText = this.activity.recoveryTipText;
+ }
+ }
+
+ customElements.define("activity-warning-item", ActivityWarningItem, {
+ extends: "li",
+ });
+}
diff --git a/comm/mail/components/activity/content/activity.js b/comm/mail/components/activity/content/activity.js
new file mode 100644
index 0000000000..dcaba3d808
--- /dev/null
+++ b/comm/mail/components/activity/content/activity.js
@@ -0,0 +1,239 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 activityManager = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+);
+
+var ACTIVITY_LIMIT = 250;
+
+var activityObject = {
+ _activityMgrListener: null,
+ _activitiesView: null,
+ _activityLogger: console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }),
+ _ignoreNotifications: false,
+ _groupCache: new Map(),
+
+ // Utility Functions for Activity element management
+
+ /**
+ * Creates the proper element for the given activity
+ */
+ createActivityWidget(type) {
+ let element = document.createElement("li", {
+ is: type.bindingName,
+ });
+
+ if (element) {
+ element.setAttribute("actID", type.id);
+ }
+
+ return element;
+ },
+
+ /**
+ * Returns the activity group element that matches the context_type
+ * and context of the given activity, if any.
+ */
+ getActivityGroupElementByContext(aContextType, aContextObj) {
+ return this._groupCache.get(aContextType + ":" + aContextObj);
+ },
+
+ /**
+ * Inserts the given element into the correct position on the
+ * activity manager window.
+ */
+ placeActivityElement(element) {
+ if (element.isGroup || element.isProcess) {
+ this._activitiesView.insertBefore(
+ element,
+ this._activitiesView.firstElementChild
+ );
+ } else {
+ let next = this._activitiesView.firstElementChild;
+ while (next && (next.isWarning || next.isProcess || next.isGroup)) {
+ next = next.nextElementSibling;
+ }
+ if (next) {
+ this._activitiesView.insertBefore(element, next);
+ } else {
+ this._activitiesView.appendChild(element);
+ }
+ }
+ if (element.isGroup) {
+ this._groupCache.set(
+ element.contextType + ":" + element.contextObj,
+ element
+ );
+ }
+ while (this._activitiesView.children.length > ACTIVITY_LIMIT) {
+ this.removeActivityElement(
+ this._activitiesView.lastElementChild.getAttribute("actID")
+ );
+ }
+ },
+
+ /**
+ * Adds a new element to activity manager window for the
+ * given activity. It is called by ActivityMgrListener when
+ * a new activity is added into the activity manager's internal
+ * list.
+ */
+ addActivityElement(aID, aActivity) {
+ try {
+ this._activityLogger.info(`Adding ActivityElement: ${aID}, ${aActivity}`);
+ // get |groupingStyle| of the activity. Grouping style determines
+ // whether we show the activity standalone or grouped by context in
+ // the activity manager window.
+ let isGroupByContext =
+ aActivity.groupingStyle == Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+
+ // find out if an activity group has already been created for this context
+ let group = null;
+ if (isGroupByContext) {
+ group = this.getActivityGroupElementByContext(
+ aActivity.contextType,
+ aActivity.contextObj
+ );
+ // create a group if it's not already created.
+ if (!group) {
+ group = document.createElement("li", {
+ is: "activity-group-item",
+ });
+ this._activityLogger.info("created group element");
+ // Set the context type and object of the newly created group
+ group.contextType = aActivity.contextType;
+ group.contextObj = aActivity.contextObj;
+ group.contextDisplayText = aActivity.contextDisplayText;
+
+ // add group into the list
+ this.placeActivityElement(group);
+ }
+ }
+
+ // create the appropriate element for the activity
+ let actElement = this.createActivityWidget(aActivity);
+ this._activityLogger.info("created activity element");
+
+ if (group) {
+ // get the inner list element of the group
+ let groupView = group.querySelector(".activitygroup-list");
+ groupView.appendChild(actElement);
+ } else {
+ this.placeActivityElement(actElement);
+ }
+ } catch (e) {
+ this._activityLogger.error("addActivityElement: " + e);
+ throw e;
+ }
+ },
+
+ /**
+ * Removes the activity element from the activity manager window.
+ * It is called by ActivityMgrListener when the activity in question
+ * is removed from the activity manager's internal list.
+ */
+ removeActivityElement(aID) {
+ this._activityLogger.info("removing Activity ID: " + aID);
+ let item = this._activitiesView.querySelector(`[actID="${aID}"]`);
+
+ if (item) {
+ let group = item.closest(".activitygroup");
+ item.remove();
+ if (group && !group.querySelector(".activityitem")) {
+ // Empty group is removed.
+ this._groupCache.delete(group.contextType + ":" + group.contextObj);
+ group.remove();
+ }
+ }
+ },
+
+ // -----------------
+ // Startup, Shutdown
+
+ startup() {
+ try {
+ this._activitiesView = document.getElementById("activityView");
+
+ let activities = activityManager.getActivities();
+ for (
+ let iActivity = Math.max(0, activities.length - ACTIVITY_LIMIT);
+ iActivity < activities.length;
+ iActivity++
+ ) {
+ let activity = activities[iActivity];
+ this.addActivityElement(activity.id, activity);
+ }
+
+ // start listening changes in the activity manager's
+ // internal list
+ this._activityMgrListener = new this.ActivityMgrListener();
+ activityManager.addListener(this._activityMgrListener);
+ } catch (e) {
+ this._activityLogger.error("Exception: " + e);
+ }
+ },
+
+ rebuild() {
+ let activities = activityManager.getActivities();
+ for (let activity of activities) {
+ this.addActivityElement(activity.id, activity);
+ }
+ },
+
+ shutdown() {
+ activityManager.removeListener(this._activityMgrListener);
+ },
+
+ // -----------------
+ // Utility Functions
+
+ /**
+ * Remove all activities not in-progress from the activity list.
+ */
+ clearActivityList() {
+ this._activityLogger.debug("clearActivityList");
+
+ this._ignoreNotifications = true;
+ // If/when we implement search, we'll want to remove just the items
+ // that are on the search display, however for now, we'll just clear up
+ // everything.
+ activityManager.cleanUp();
+
+ while (this._activitiesView.lastChild) {
+ this._activitiesView.lastChild.remove();
+ }
+
+ this._groupCache.clear();
+ this.rebuild();
+ this._ignoreNotifications = false;
+ this._activitiesView.focus();
+ },
+};
+
+// An object to monitor nsActivityManager operations. This class acts as
+// binding layer between nsActivityManager and nsActivityManagerUI objects.
+activityObject.ActivityMgrListener = function () {};
+activityObject.ActivityMgrListener.prototype = {
+ onAddedActivity(aID, aActivity) {
+ activityObject._activityLogger.info(`added activity: ${aID} ${aActivity}`);
+ if (!activityObject._ignoreNotifications) {
+ activityObject.addActivityElement(aID, aActivity);
+ }
+ },
+
+ onRemovedActivity(aID) {
+ if (!activityObject._ignoreNotifications) {
+ activityObject.removeActivityElement(aID);
+ }
+ },
+};
+
+window.addEventListener("load", () => activityObject.startup());
+window.addEventListener("unload", () => activityObject.shutdown());
diff --git a/comm/mail/components/activity/content/activity.xhtml b/comm/mail/components/activity/content/activity.xhtml
new file mode 100644
index 0000000000..cdff19cbe6
--- /dev/null
+++ b/comm/mail/components/activity/content/activity.xhtml
@@ -0,0 +1,61 @@
+<?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/. -->
+
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+#define XP_GNOME 1
+#endif
+#endif
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/activity/activity.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE html [
+<!ENTITY % activityManagerDTD SYSTEM "chrome://messenger/locale/activity.dtd">
+%activityManagerDTD;
+]>
+
+<html 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"
+ id="activityManager" windowtype="Activity:Manager"
+ width="&window.width2;" height="&window.height;"
+ screenX="10" screenY="10"
+ persist="width height screenX screenY sizemode"
+ lightweightthemes="true">
+<head>
+ <title>&activity.title;</title>
+
+ <script defer="defer" src="chrome://messenger/content/activity.js"></script>
+ <script defer="defer" src="chrome://messenger/content/activity-widgets.js"></script>
+</head>
+<body>
+ <xul:keyset id="activityKeys">
+ <xul:key id="key_close" key="&cmd.close.commandkey;"
+ oncommand="window.close();" modifiers="accel"/>
+#ifdef XP_GNOME
+ <xul:key id="key_close2" key="&cmd.close2Unix.commandkey;"
+ oncommand="window.close();" modifiers="accel"/>
+#else
+ <xul:key id="key_close2" key="&cmd.close2.commandkey;"
+ oncommand="window.close();" modifiers="accel"/>
+#endif
+ <xul:key keycode="VK_ESCAPE" oncommand="window.close();"/>
+ </xul:keyset>
+
+ <div id="activityContainer">
+ <ul id="activityView" class="activityview"></ul>
+ <button id="clearListButton"
+ onclick="activityObject.clearActivityList();"
+ accesskey="&cmd.clearList.accesskey;"
+ title="&cmd.clearList.tooltip;">
+ &cmd.clearList.label;
+ </button>
+ </div>
+</body>
+</html>
diff --git a/comm/mail/components/activity/jar.mn b/comm/mail/components/activity/jar.mn
new file mode 100644
index 0000000000..babeeac23d
--- /dev/null
+++ b/comm/mail/components/activity/jar.mn
@@ -0,0 +1,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/.
+
+messenger.jar:
+ content/messenger/activity.js (content/activity.js)
+ content/messenger/activity-widgets.js (content/activity-widgets.js)
+* content/messenger/activity.xhtml (content/activity.xhtml)
diff --git a/comm/mail/components/activity/modules/activityModules.jsm b/comm/mail/components/activity/modules/activityModules.jsm
new file mode 100644
index 0000000000..945f7473c2
--- /dev/null
+++ b/comm/mail/components/activity/modules/activityModules.jsm
@@ -0,0 +1,33 @@
+/* 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 module is designed to be a central place to initialise activity related
+// modules.
+
+const EXPORTED_SYMBOLS = [];
+
+const { sendLaterModule } = ChromeUtils.import(
+ "resource:///modules/activity/sendLater.jsm"
+);
+sendLaterModule.init();
+const { moveCopyModule } = ChromeUtils.import(
+ "resource:///modules/activity/moveCopy.jsm"
+);
+moveCopyModule.init();
+const { glodaIndexerActivity } = ChromeUtils.import(
+ "resource:///modules/activity/glodaIndexer.jsm"
+);
+glodaIndexerActivity.init();
+const { autosyncModule } = ChromeUtils.import(
+ "resource:///modules/activity/autosync.jsm"
+);
+autosyncModule.init();
+const { alertHook } = ChromeUtils.import(
+ "resource:///modules/activity/alertHook.jsm"
+);
+alertHook.init();
+const { pop3DownloadModule } = ChromeUtils.import(
+ "resource:///modules/activity/pop3Download.jsm"
+);
+pop3DownloadModule.init();
diff --git a/comm/mail/components/activity/modules/alertHook.jsm b/comm/mail/components/activity/modules/alertHook.jsm
new file mode 100644
index 0000000000..b3083aef0a
--- /dev/null
+++ b/comm/mail/components/activity/modules/alertHook.jsm
@@ -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/. */
+
+const EXPORTED_SYMBOLS = ["alertHook"];
+
+var nsActWarning = Components.Constructor(
+ "@mozilla.org/activity-warning;1",
+ "nsIActivityWarning",
+ "init"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+// This module provides a link between the send later service and the activity
+// manager.
+var alertHook = {
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get alertService() {
+ delete this.alertService;
+ return (this.alertService = Cc["@mozilla.org/alerts-service;1"].getService(
+ Ci.nsIAlertsService
+ ));
+ },
+
+ get brandShortName() {
+ delete this.brandShortName;
+ return (this.brandShortName = Services.strings
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShortName"));
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgUserFeedbackListener"]),
+
+ onAlert(aMessage, aUrl) {
+ // Create a new warning.
+ let warning = new nsActWarning(aMessage, this.activityMgr, "");
+
+ if (aUrl && aUrl.server && aUrl.server.prettyName) {
+ warning.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+ warning.contextType = "incomingServer";
+ warning.contextDisplayText = aUrl.server.prettyName;
+ warning.contextObj = aUrl.server;
+ } else {
+ warning.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_STANDALONE;
+ }
+
+ this.activityMgr.addActivity(warning);
+
+ // If we have a message window in the url, then show a warning prompt,
+ // just like the modal code used to. Otherwise, don't.
+ try {
+ if (!aUrl || !aUrl.msgWindow) {
+ return true;
+ }
+ } catch (ex) {
+ // nsIMsgMailNewsUrl.msgWindow will throw on a null pointer, so that's
+ // what we're handling here.
+ if (
+ ex instanceof Ci.nsIException &&
+ ex.result == Cr.NS_ERROR_INVALID_POINTER
+ ) {
+ return true;
+ }
+ throw ex;
+ }
+
+ try {
+ let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(
+ Ci.nsIAlertNotification
+ );
+ alert.init(
+ "", // name
+ "chrome://branding/content/icon48.png",
+ this.brandShortName,
+ aMessage
+ );
+ this.alertService.showAlert(alert);
+ } catch (ex) {
+ // XXX On Linux, if libnotify isn't supported, showAlert
+ // can throw an error, so fall-back to the old method of modal dialogs.
+ return false;
+ }
+
+ return true;
+ },
+
+ init() {
+ // We shouldn't need to remove the listener as we're not being held by
+ // anyone except by the send later instance.
+ MailServices.mailSession.addUserFeedbackListener(this);
+ },
+};
diff --git a/comm/mail/components/activity/modules/autosync.jsm b/comm/mail/components/activity/modules/autosync.jsm
new file mode 100644
index 0000000000..c2483c4b53
--- /dev/null
+++ b/comm/mail/components/activity/modules/autosync.jsm
@@ -0,0 +1,433 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["autosyncModule"];
+
+var nsActProcess = Components.Constructor(
+ "@mozilla.org/activity-process;1",
+ "nsIActivityProcess",
+ "init"
+);
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+
+/**
+ * This code aims to mediate between the auto-sync code and the activity mgr.
+ *
+ * Not every auto-sync activity is directly mapped to a process or event.
+ * To prevent a possible event overflow, Auto-Sync monitor generates one
+ * sync'd event per account when after all its _pending_ folders are sync'd,
+ * rather than generating one event per folder sync.
+ */
+
+var autosyncModule = {
+ _inQFolderList: [],
+ _running: false,
+ _syncInfoPerFolder: new Map(),
+ _syncInfoPerServer: new Map(),
+ _lastMessage: new Map(),
+
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get autoSyncManager() {
+ delete this.autoSyncManager;
+ return (this.autoSyncManager = Cc[
+ "@mozilla.org/imap/autosyncmgr;1"
+ ].getService(Ci.nsIAutoSyncManager));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ getString(stringName) {
+ try {
+ return this.bundle.GetStringFromName(stringName);
+ } catch (e) {
+ this.log.error("error trying to get a string called: " + stringName);
+ throw e;
+ }
+ },
+
+ createSyncMailProcess(folder) {
+ try {
+ // create an activity process for this folder
+ let msg = this.bundle.formatStringFromName("autosyncProcessDisplayText", [
+ folder.prettyName,
+ ]);
+ let process = new nsActProcess(msg, this.autoSyncManager);
+ // we want to use default auto-sync icon
+ process.iconClass = "syncMail";
+ process.addSubject(folder);
+ // group processes under folder's imap account
+ process.contextType = "account";
+ process.contextDisplayText = this.bundle.formatStringFromName(
+ "autosyncContextDisplayText",
+ [folder.server.prettyName]
+ );
+
+ process.contextObj = folder.server;
+
+ return process;
+ } catch (e) {
+ this.log.error("createSyncMailProcess: " + e);
+ throw e;
+ }
+ },
+
+ createSyncMailEvent(syncItem) {
+ try {
+ // extract the relevant parts
+ let process = syncItem.activity;
+ let folder = syncItem.syncFolder;
+
+ // create an activity event
+
+ let msg = this.bundle.formatStringFromName("autosyncEventDisplayText", [
+ folder.server.prettyName,
+ ]);
+
+ let statusMsg;
+ let numOfMessages = this._syncInfoPerServer.get(
+ folder.server
+ ).totalDownloads;
+ if (numOfMessages) {
+ statusMsg = this.bundle.formatStringFromName(
+ "autosyncEventStatusText",
+ [numOfMessages]
+ );
+ } else {
+ statusMsg = this.getString("autosyncEventStatusTextNoMsgs");
+ }
+
+ let event = new nsActEvent(
+ msg,
+ this.autoSyncManager,
+ statusMsg,
+ this._syncInfoPerServer.get(folder.server).startTime,
+ Date.now()
+ ); // completion time
+
+ // since auto-sync events do not have undo option by nature,
+ // setting these values are informational only.
+ event.contextType = process.contextType;
+ event.contextDisplayText = this.bundle.formatStringFromName(
+ "autosyncContextDisplayText",
+ [folder.server.prettyName]
+ );
+ event.contextObj = process.contextObj;
+ event.iconClass = "syncMail";
+
+ // transfer all subjects.
+ // same as above, not mandatory
+ let subjects = process.getSubjects();
+ for (let subject of subjects) {
+ event.addSubject(subject);
+ }
+
+ return event;
+ } catch (e) {
+ this.log.error("createSyncMailEvent: " + e);
+ throw e;
+ }
+ },
+
+ onStateChanged(running) {
+ try {
+ this._running = running;
+ this.log.info(
+ "OnStatusChanged: " + (running ? "running" : "sleeping") + "\n"
+ );
+ } catch (e) {
+ this.log.error("onStateChanged: " + e);
+ throw e;
+ }
+ },
+
+ onFolderAddedIntoQ(queue, folder) {
+ try {
+ if (
+ folder instanceof Ci.nsIMsgFolder &&
+ queue == Ci.nsIAutoSyncMgrListener.PriorityQueue
+ ) {
+ this._inQFolderList.push(folder);
+ this.log.info(
+ "Auto_Sync OnFolderAddedIntoQ [" +
+ this._inQFolderList.length +
+ "] " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName
+ );
+ // create an activity process for this folder
+ let process = this.createSyncMailProcess(folder);
+
+ // create a sync object to keep track of the process of this folder
+ let imapFolder = folder.QueryInterface(Ci.nsIMsgImapMailFolder);
+ let syncItem = {
+ syncFolder: folder,
+ activity: process,
+ percentComplete: 0,
+ totalDownloaded: 0,
+ pendingMsgCount: imapFolder.autoSyncStateObj.pendingMessageCount,
+ };
+
+ // if this is the first folder of this server in the queue, then set the sync start time
+ // for activity event
+ if (!this._syncInfoPerServer.has(folder.server)) {
+ this._syncInfoPerServer.set(folder.server, {
+ startTime: Date.now(),
+ totalDownloads: 0,
+ });
+ }
+
+ // associate the sync object with the folder in question
+ // use folder.URI as key
+ this._syncInfoPerFolder.set(folder.URI, syncItem);
+ }
+ } catch (e) {
+ this.log.error("onFolderAddedIntoQ: " + e);
+ throw e;
+ }
+ },
+ onFolderRemovedFromQ(queue, folder) {
+ try {
+ if (
+ folder instanceof Ci.nsIMsgFolder &&
+ queue == Ci.nsIAutoSyncMgrListener.PriorityQueue
+ ) {
+ let i = this._inQFolderList.indexOf(folder);
+ if (i > -1) {
+ this._inQFolderList.splice(i, 1);
+ }
+
+ this.log.info(
+ "OnFolderRemovedFromQ [" +
+ this._inQFolderList.length +
+ "] " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName +
+ "\n"
+ );
+
+ let syncItem = this._syncInfoPerFolder.get(folder.URI);
+ let process = syncItem.activity;
+ let canceled = false;
+ if (process instanceof Ci.nsIActivityProcess) {
+ canceled = process.state == Ci.nsIActivityProcess.STATE_CANCELED;
+ process.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+
+ try {
+ this.activityMgr.removeActivity(process.id);
+ } catch (e) {
+ // It is OK to end up here; If the folder is queued and the
+ // message get manually downloaded by the user, we might get
+ // a folder removed notification even before a download
+ // started for this folder. This behavior stems from the fact
+ // that we add activities into the activity manager in
+ // onDownloadStarted notification rather than onFolderAddedIntoQ.
+ // This is an expected side effect.
+ // Log a warning, but do not throw an error.
+ this.log.warn("onFolderRemovedFromQ: " + e);
+ }
+
+ // remove the folder/syncItem association from the table
+ this._syncInfoPerFolder.delete(folder.URI);
+ }
+
+ // if this is the last folder of this server in the queue
+ // create a sync event and clean the sync start time
+ let found = false;
+ for (let value of this._syncInfoPerFolder.values()) {
+ if (value.syncFolder.server == folder.server) {
+ found = true;
+ break;
+ }
+ }
+ this.log.info(
+ "Auto_Sync OnFolderRemovedFromQ Last folder of the server: " + !found
+ );
+ if (!found) {
+ // create an sync event for the completed process if it's not canceled
+ if (!canceled) {
+ let key = folder.server.prettyName;
+ if (
+ this._lastMessage.has(key) &&
+ this.activityMgr.containsActivity(this._lastMessage.get(key))
+ ) {
+ this.activityMgr.removeActivity(this._lastMessage.get(key));
+ }
+ this._lastMessage.set(
+ key,
+ this.activityMgr.addActivity(this.createSyncMailEvent(syncItem))
+ );
+ }
+ this._syncInfoPerServer.delete(folder.server);
+ }
+ }
+ } catch (e) {
+ this.log.error("onFolderRemovedFromQ: " + e);
+ throw e;
+ }
+ },
+ onDownloadStarted(folder, numOfMessages, totalPending) {
+ try {
+ if (folder instanceof Ci.nsIMsgFolder) {
+ this.log.info(
+ "OnDownloadStarted (" +
+ numOfMessages +
+ "/" +
+ totalPending +
+ "): " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName +
+ "\n"
+ );
+
+ let syncItem = this._syncInfoPerFolder.get(folder.URI);
+ let process = syncItem.activity;
+
+ // Update the totalPending number. if new messages have been discovered in the folder
+ // after we added the folder into the q, totalPending might be greater than what we have
+ // initially set
+ if (totalPending > syncItem.pendingMsgCount) {
+ syncItem.pendingMsgCount = totalPending;
+ }
+
+ if (process instanceof Ci.nsIActivityProcess) {
+ // if the process has not beed added to activity manager already, add now
+ if (!this.activityMgr.containsActivity(process.id)) {
+ this.log.info(
+ "Auto_Sync OnDownloadStarted: No process, adding a new process"
+ );
+ this.activityMgr.addActivity(process);
+ }
+
+ syncItem.totalDownloaded += numOfMessages;
+
+ process.state = Ci.nsIActivityProcess.STATE_INPROGRESS;
+ let percent =
+ (syncItem.totalDownloaded / syncItem.pendingMsgCount) * 100;
+ if (percent > syncItem.percentComplete) {
+ syncItem.percentComplete = percent;
+ }
+
+ let msg = this.bundle.formatStringFromName(
+ "autosyncProcessProgress2",
+ [
+ syncItem.totalDownloaded,
+ syncItem.pendingMsgCount,
+ folder.prettyName,
+ folder.server.prettyName,
+ ]
+ );
+
+ process.setProgress(
+ msg,
+ syncItem.totalDownloaded,
+ syncItem.pendingMsgCount
+ );
+
+ let serverInfo = this._syncInfoPerServer.get(
+ syncItem.syncFolder.server
+ );
+ serverInfo.totalDownloads += numOfMessages;
+ this._syncInfoPerServer.set(syncItem.syncFolder.server, serverInfo);
+ }
+ }
+ } catch (e) {
+ this.log.error("onDownloadStarted: " + e);
+ throw e;
+ }
+ },
+
+ onDownloadCompleted(folder) {
+ try {
+ if (folder instanceof Ci.nsIMsgFolder) {
+ this.log.info(
+ "OnDownloadCompleted: " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName
+ );
+
+ let process = this._syncInfoPerFolder.get(folder.URI).activity;
+ if (process instanceof Ci.nsIActivityProcess && !this._running) {
+ this.log.info(
+ "OnDownloadCompleted: Auto-Sync Manager is paused, pausing the process"
+ );
+ process.state = Ci.nsIActivityProcess.STATE_PAUSED;
+ }
+ }
+ } catch (e) {
+ this.log.error("onDownloadCompleted: " + e);
+ throw e;
+ }
+ },
+
+ onDownloadError(folder) {
+ if (folder instanceof Ci.nsIMsgFolder) {
+ this.log.error(
+ "OnDownloadError: " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName +
+ "\n"
+ );
+ }
+ },
+
+ onDiscoveryQProcessed(folder, numOfHdrsProcessed, leftToProcess) {
+ this.log.info(
+ "onDiscoveryQProcessed: Processed " +
+ numOfHdrsProcessed +
+ "/" +
+ (leftToProcess + numOfHdrsProcessed) +
+ " of " +
+ folder.prettyName +
+ "\n"
+ );
+ },
+
+ onAutoSyncInitiated(folder) {
+ this.log.info(
+ "onAutoSyncInitiated: " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName +
+ " has been updated.\n"
+ );
+ },
+
+ init() {
+ // XXX when do we need to remove ourselves?
+ this.log.info("initing");
+ Cc["@mozilla.org/imap/autosyncmgr;1"]
+ .getService(Ci.nsIAutoSyncManager)
+ .addListener(this);
+ },
+};
diff --git a/comm/mail/components/activity/modules/glodaIndexer.jsm b/comm/mail/components/activity/modules/glodaIndexer.jsm
new file mode 100644
index 0000000000..5307d5cefa
--- /dev/null
+++ b/comm/mail/components/activity/modules/glodaIndexer.jsm
@@ -0,0 +1,251 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["glodaIndexerActivity"];
+
+var nsActProcess = Components.Constructor(
+ "@mozilla.org/activity-process;1",
+ "nsIActivityProcess",
+ "init"
+);
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ Gloda: "resource:///modules/gloda/GlodaPublic.jsm",
+ GlodaConstants: "resource:///modules/gloda/GlodaConstants.jsm",
+ GlodaIndexer: "resource:///modules/gloda/GlodaIndexer.jsm",
+});
+
+/**
+ * Gloda message indexer feedback.
+ */
+var glodaIndexerActivity = {
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ getString(stringName) {
+ try {
+ return this.bundle.GetStringFromName(stringName);
+ } catch (e) {
+ this.log.error("error trying to get a string called: " + stringName);
+ throw e;
+ }
+ },
+
+ init() {
+ // Register a listener with the Gloda indexer that receives notifications
+ // about Gloda indexing status. We wrap the listener in this function so we
+ // can set |this| to the GlodaIndexerActivity object inside the listener.
+ function listenerWrapper(...aArgs) {
+ glodaIndexerActivity.listener(...aArgs);
+ }
+ lazy.GlodaIndexer.addListener(listenerWrapper);
+ },
+
+ /**
+ * Information about the current job. An object with these properties:
+ *
+ * folder {String}
+ * the name of the folder being processed by the job
+ * jobNumber {Number}
+ * the index of the job in the list of jobs
+ * process {nsIActivityProcess}
+ * the activity process corresponding to the current job
+ * startTime {Date}
+ * the time at which we were first notified about the job
+ * totalItemNum {Number}
+ * the total number of messages being indexed in the job
+ * jobType {String}
+ * The IndexinbJob jobType (ex: "folder", "folderCompact")
+ */
+ currentJob: null,
+
+ listener(aStatus, aFolder, aJobNumber, aItemNumber, aTotalItemNum, aJobType) {
+ this.log.debug("Gloda Indexer Folder/Status: " + aFolder + "/" + aStatus);
+ this.log.debug("Gloda Indexer Job: " + aJobNumber);
+ this.log.debug("Gloda Indexer Item: " + aItemNumber + "/" + aTotalItemNum);
+
+ if (aStatus == lazy.GlodaConstants.kIndexerIdle) {
+ if (this.currentJob) {
+ this.onJobCompleted();
+ }
+ } else {
+ // If the job numbers have changed, the indexer has finished the job
+ // we were previously tracking, so convert the corresponding process
+ // into an event and start a new process to track the new job.
+ if (this.currentJob && aJobNumber != this.currentJob.jobNumber) {
+ this.onJobCompleted();
+ }
+
+ // If we aren't tracking a job, either this is the first time we've been
+ // called or the last job we were tracking was completed. Either way,
+ // start tracking the new job.
+ if (!this.currentJob) {
+ this.onJobBegun(aFolder, aJobNumber, aTotalItemNum, aJobType);
+ }
+
+ // If there is only one item, don't bother creating a progress item.
+ if (aTotalItemNum != 1) {
+ this.onJobProgress(aFolder, aItemNumber, aTotalItemNum);
+ }
+ }
+ },
+
+ onJobBegun(aFolder, aJobNumber, aTotalItemNum, aJobType) {
+ let displayText = aFolder
+ ? this.getString("indexingFolder").replace("#1", aFolder)
+ : this.getString("indexing");
+ let process = new nsActProcess(displayText, lazy.Gloda);
+
+ process.iconClass = "indexMail";
+ process.contextType = "account";
+ process.contextObj = aFolder;
+ process.addSubject(aFolder);
+
+ this.currentJob = {
+ folder: aFolder,
+ jobNumber: aJobNumber,
+ process,
+ startTime: new Date(),
+ totalItemNum: aTotalItemNum,
+ jobType: aJobType,
+ };
+
+ this.activityMgr.addActivity(process);
+ },
+
+ onJobProgress(aFolder, aItemNumber, aTotalItemNum) {
+ this.currentJob.process.state = Ci.nsIActivityProcess.STATE_INPROGRESS;
+ // The total number of items being processed in the job can change, as can
+ // the folder being processed, since we sometimes get notified about a job
+ // before it has determined these things, so we update them here.
+ this.currentJob.folder = aFolder;
+ this.currentJob.totalItemNum = aTotalItemNum;
+
+ let statusText;
+ if (aTotalItemNum == null) {
+ statusText = aFolder
+ ? this.getString("indexingFolderStatusVague").replace("#1", aFolder)
+ : this.getString("indexingStatusVague");
+ } else {
+ let percentComplete =
+ aTotalItemNum == 0
+ ? 100
+ : parseInt((aItemNumber / aTotalItemNum) * 100);
+ // Note: we must replace the folder name placeholder last; otherwise,
+ // if the name happens to contain another one of the placeholders, we'll
+ // hork the name when replacing it.
+ statusText = this.getString(
+ aFolder ? "indexingFolderStatusExact" : "indexingStatusExact"
+ );
+ statusText = lazy.PluralForm.get(aTotalItemNum, statusText)
+ .replace("#1", aItemNumber + 1)
+ .replace("#2", aTotalItemNum)
+ .replace("#3", percentComplete)
+ .replace("#4", aFolder);
+ }
+
+ this.currentJob.process.setProgress(statusText, aItemNumber, aTotalItemNum);
+ },
+
+ onJobCompleted() {
+ this.currentJob.process.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+
+ this.activityMgr.removeActivity(this.currentJob.process.id);
+
+ // this.currentJob.totalItemNum might still be null at this point
+ // if we were first notified about the job before the indexer determined
+ // the number of messages to index and then it didn't find any to index.
+ let totalItemNum = this.currentJob.totalItemNum || 0;
+
+ // We only create activity events when specific folders get indexed,
+ // since event-driven indexing jobs are too numerous. We also only create
+ // them when we ended up indexing something in the folder, since otherwise
+ // we'd spam the activity manager with too many "indexed 0 messages" items
+ // that aren't useful enough to justify their presence in the manager.
+ // TODO: Aggregate event-driven indexing jobs into batches significant
+ // enough for us to create activity events for them.
+ if (
+ this.currentJob.jobType == "folder" &&
+ this.currentJob.folder &&
+ totalItemNum > 0
+ ) {
+ // Note: we must replace the folder name placeholder last; otherwise,
+ // if the name happens to contain another one of the placeholders, we'll
+ // hork the name when replacing it.
+ let displayText = lazy.PluralForm.get(
+ totalItemNum,
+ this.getString("indexedFolder")
+ )
+ .replace("#1", totalItemNum)
+ .replace("#2", this.currentJob.folder);
+
+ let endTime = new Date();
+ let secondsElapsed = parseInt(
+ (endTime - this.currentJob.startTime) / 1000
+ );
+
+ let statusText = lazy.PluralForm.get(
+ secondsElapsed,
+ this.getString("indexedFolderStatus")
+ ).replace("#1", secondsElapsed);
+
+ let event = new nsActEvent(
+ displayText,
+ lazy.Gloda,
+ statusText,
+ this.currentJob.startTime,
+ endTime
+ );
+ event.contextType = this.currentJob.contextType;
+ event.contextObj = this.currentJob.contextObj;
+ event.iconClass = "indexMail";
+
+ // Transfer subjects.
+ let subjects = this.currentJob.process.getSubjects();
+ for (let subject of subjects) {
+ event.addSubject(subject);
+ }
+
+ this.activityMgr.addActivity(event);
+ }
+
+ this.currentJob = null;
+ },
+};
diff --git a/comm/mail/components/activity/modules/moveCopy.jsm b/comm/mail/components/activity/modules/moveCopy.jsm
new file mode 100644
index 0000000000..de3e51d85b
--- /dev/null
+++ b/comm/mail/components/activity/modules/moveCopy.jsm
@@ -0,0 +1,396 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["moveCopyModule"];
+
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+
+// This module provides a link between the move/copy code and the activity
+// manager.
+var moveCopyModule = {
+ lastMessage: {},
+ lastFolder: {},
+
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ getString(stringName) {
+ try {
+ return this.bundle.GetStringFromName(stringName);
+ } catch (e) {
+ this.log.error("error trying to get a string called: " + stringName);
+ throw e;
+ }
+ },
+
+ msgAdded(aMsg) {},
+
+ msgsDeleted(aMsgList) {
+ this.log.info("in msgsDeleted");
+
+ if (aMsgList.length <= 0) {
+ return;
+ }
+
+ let displayCount = aMsgList.length;
+ // get the folder of the deleted messages
+ let folder = aMsgList[0].folder;
+
+ let activities = this.activityMgr.getActivities();
+ if (
+ activities.length > 0 &&
+ activities[activities.length - 1].id == this.lastMessage.id &&
+ this.lastMessage.type == "deleteMail" &&
+ this.lastMessage.folder == folder.prettyName
+ ) {
+ displayCount += this.lastMessage.count;
+ this.activityMgr.removeActivity(this.lastMessage.id);
+ }
+
+ this.lastMessage = {};
+ let displayText = PluralForm.get(
+ displayCount,
+ this.getString("deletedMessages2")
+ );
+ displayText = displayText.replace("#1", displayCount);
+ this.lastMessage.count = displayCount;
+ displayText = displayText.replace("#2", folder.prettyName);
+ this.lastMessage.folder = folder.prettyName;
+
+ let statusText = folder.server.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ folder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.iconClass = "deleteMail";
+ this.lastMessage.type = event.iconClass;
+
+ for (let msgHdr of aMsgList) {
+ event.addSubject(msgHdr.messageId);
+ }
+
+ this.lastMessage.id = this.activityMgr.addActivity(event);
+ },
+
+ msgsMoveCopyCompleted(aMove, aSrcMsgList, aDestFolder) {
+ try {
+ this.log.info("in msgsMoveCopyCompleted");
+
+ let count = aSrcMsgList.length;
+ if (count <= 0) {
+ return;
+ }
+
+ // get the folder of the moved/copied messages
+ let folder = aSrcMsgList[0].folder;
+ this.log.info("got folder");
+
+ let displayCount = count;
+
+ let activities = this.activityMgr.getActivities();
+ if (
+ activities.length > 0 &&
+ activities[activities.length - 1].id == this.lastMessage.id &&
+ this.lastMessage.type == (aMove ? "moveMail" : "copyMail") &&
+ this.lastMessage.sourceFolder == folder.prettyName &&
+ this.lastMessage.destFolder == aDestFolder.prettyName
+ ) {
+ displayCount += this.lastMessage.count;
+ this.activityMgr.removeActivity(this.lastMessage.id);
+ }
+
+ let statusText = "";
+ if (folder.server != aDestFolder.server) {
+ statusText = this.getString("fromServerToServer");
+ statusText = statusText.replace("#1", folder.server.prettyName);
+ statusText = statusText.replace("#2", aDestFolder.server.prettyName);
+ } else {
+ statusText = folder.server.prettyName;
+ }
+
+ this.lastMessage = {};
+ let displayText;
+ if (aMove) {
+ displayText = PluralForm.get(
+ displayCount,
+ this.getString("movedMessages")
+ );
+ } else {
+ displayText = PluralForm.get(
+ displayCount,
+ this.getString("copiedMessages")
+ );
+ }
+
+ displayText = displayText.replace("#1", displayCount);
+ this.lastMessage.count = displayCount;
+ displayText = displayText.replace("#2", folder.prettyName);
+ this.lastMessage.sourceFolder = folder.prettyName;
+ displayText = displayText.replace("#3", aDestFolder.prettyName);
+ this.lastMessage.destFolder = aDestFolder.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ folder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+ event.iconClass = aMove ? "moveMail" : "copyMail";
+ this.lastMessage.type = event.iconClass;
+
+ for (let msgHdr of aSrcMsgList) {
+ event.addSubject(msgHdr.messageId);
+ }
+ this.lastMessage.id = this.activityMgr.addActivity(event);
+ } catch (e) {
+ this.log.error("Exception: " + e);
+ }
+ },
+
+ folderAdded(aFolder) {},
+
+ folderDeleted(aFolder) {
+ // When a new account is created we get this notification with an empty named
+ // folder that can't return its server. Ignore it.
+ // TODO: find out what it is.
+ let server = aFolder.server;
+ // If the account has been removed, we're going to ignore this notification.
+ if (
+ !MailServices.accounts.findServer(
+ server.username,
+ server.hostName,
+ server.type
+ )
+ ) {
+ return;
+ }
+
+ let displayText;
+ let statusText = server.prettyName;
+
+ // Display a different message depending on whether we emptied the trash
+ // or actually deleted a folder
+ if (aFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, false)) {
+ displayText = this.getString("emptiedTrash");
+ } else {
+ displayText = this.getString("deletedFolder").replace(
+ "#1",
+ aFolder.prettyName
+ );
+ }
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ server,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.addSubject(aFolder);
+ event.iconClass = "deleteMail";
+
+ // When we rename, we get a delete event as well as a rename, so store
+ // the last folder we deleted
+ this.lastFolder = {};
+ this.lastFolder.URI = aFolder.URI;
+ this.lastFolder.event = this.activityMgr.addActivity(event);
+ },
+
+ folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) {
+ this.log.info("in folderMoveCopyCompleted, aMove = " + aMove);
+
+ let displayText;
+ if (aMove) {
+ displayText = this.getString("movedFolder");
+ } else {
+ displayText = this.getString("copiedFolder");
+ }
+
+ displayText = displayText.replace("#1", aSrcFolder.prettyName);
+ displayText = displayText.replace("#2", aDestFolder.prettyName);
+
+ let statusText = "";
+ if (aSrcFolder.server != aDestFolder.server) {
+ statusText = this.getString("fromServerToServer");
+ statusText = statusText.replace("#1", aSrcFolder.server.prettyName);
+ statusText = statusText.replace("#2", aDestFolder.server.prettyName);
+ } else {
+ statusText = aSrcFolder.server.prettyName;
+ }
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ aSrcFolder.server,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.addSubject(aSrcFolder);
+ event.addSubject(aDestFolder);
+ event.iconClass = aMove ? "moveMail" : "copyMail";
+
+ this.activityMgr.addActivity(event);
+ },
+
+ folderRenamed(aOrigFolder, aNewFolder) {
+ this.log.info(
+ "in folderRenamed, aOrigFolder = " +
+ aOrigFolder.prettyName +
+ ", aNewFolder = " +
+ aNewFolder.prettyName
+ );
+
+ let displayText;
+ let statusText = aNewFolder.server.prettyName;
+
+ // Display a different message depending on whether we moved the folder
+ // to the trash or actually renamed the folder.
+ if (aNewFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true)) {
+ displayText = this.getString("movedFolderToTrash");
+ displayText = displayText.replace("#1", aOrigFolder.prettyName);
+ } else {
+ displayText = this.getString("renamedFolder");
+ displayText = displayText.replace("#1", aOrigFolder.prettyName);
+ displayText = displayText.replace("#2", aNewFolder.prettyName);
+ }
+
+ // When renaming a folder, a delete event is always fired first
+ if (this.lastFolder.URI == aOrigFolder.URI) {
+ this.activityMgr.removeActivity(this.lastFolder.event);
+ }
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ aOrigFolder.server,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.addSubject(aOrigFolder);
+ event.addSubject(aNewFolder);
+
+ this.activityMgr.addActivity(event);
+ },
+
+ msgUnincorporatedMoved(srcFolder, msgHdr) {
+ try {
+ this.log.info("in msgUnincorporatedMoved");
+
+ // get the folder of the moved/copied messages
+ let destFolder = msgHdr.folder;
+ this.log.info("got folder");
+
+ let displayCount = 1;
+
+ let activities = this.activityMgr.getActivities();
+ if (
+ activities.length > 0 &&
+ activities[activities.length - 1].id == this.lastMessage.id &&
+ this.lastMessage.type == "moveMail" &&
+ this.lastMessage.sourceFolder == srcFolder.prettyName &&
+ this.lastMessage.destFolder == destFolder.prettyName
+ ) {
+ displayCount += this.lastMessage.count;
+ this.activityMgr.removeActivity(this.lastMessage.id);
+ }
+
+ let statusText = "";
+ if (srcFolder.server != destFolder.server) {
+ statusText = this.getString("fromServerToServer");
+ statusText = statusText.replace("#1", srcFolder.server.prettyName);
+ statusText = statusText.replace("#2", destFolder.server.prettyName);
+ } else {
+ statusText = srcFolder.server.prettyName;
+ }
+
+ this.lastMessage = {};
+ let displayText;
+ displayText = PluralForm.get(
+ displayCount,
+ this.getString("movedMessages")
+ );
+
+ displayText = displayText.replace("#1", displayCount);
+ this.lastMessage.count = displayCount;
+ displayText = displayText.replace("#2", srcFolder.prettyName);
+ this.lastMessage.sourceFolder = srcFolder.prettyName;
+ displayText = displayText.replace("#3", destFolder.prettyName);
+ this.lastMessage.destFolder = destFolder.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ srcFolder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.iconClass = "moveMail";
+ this.lastMessage.type = event.iconClass;
+ event.addSubject(msgHdr.messageId);
+ this.lastMessage.id = this.activityMgr.addActivity(event);
+ } catch (e) {
+ this.log.error("Exception: " + e);
+ }
+ },
+
+ init() {
+ // XXX when do we need to remove ourselves?
+ MailServices.mfn.addListener(
+ this,
+ MailServices.mfn.msgsDeleted |
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed |
+ MailServices.mfn.msgUnincorporatedMoved
+ );
+ },
+};
diff --git a/comm/mail/components/activity/modules/pop3Download.jsm b/comm/mail/components/activity/modules/pop3Download.jsm
new file mode 100644
index 0000000000..f203b33212
--- /dev/null
+++ b/comm/mail/components/activity/modules/pop3Download.jsm
@@ -0,0 +1,154 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["pop3DownloadModule"];
+
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+
+// This module provides a link between the pop3 service code and the activity
+// manager.
+var pop3DownloadModule = {
+ // hash table of most recent download items per folder
+ _mostRecentActivityForFolder: new Map(),
+ // hash table of prev download items per folder, so we can
+ // coalesce consecutive no new message events.
+ _prevActivityForFolder: new Map(),
+
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ getString(stringName) {
+ try {
+ return this.bundle.GetStringFromName(stringName);
+ } catch (e) {
+ this.log.error("error trying to get a string called: " + stringName);
+ throw e;
+ }
+ },
+
+ onDownloadStarted(aFolder) {
+ this.log.info("in onDownloadStarted");
+
+ let displayText = this.bundle.formatStringFromName(
+ "pop3EventStartDisplayText2",
+ [
+ aFolder.server.prettyName, // account name
+ aFolder.prettyName,
+ ]
+ ); // folder name
+ // remember the prev activity for this folder, if any.
+ this._prevActivityForFolder.set(
+ aFolder.URI,
+ this._mostRecentActivityForFolder.get(aFolder.URI)
+ );
+ let statusText = aFolder.server.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ aFolder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.iconClass = "syncMail";
+
+ let downloadItem = {};
+ downloadItem.eventID = this.activityMgr.addActivity(event);
+ this._mostRecentActivityForFolder.set(aFolder.URI, downloadItem);
+ },
+
+ onDownloadProgress(aFolder, aNumMsgsDownloaded, aTotalMsgs) {
+ this.log.info("in onDownloadProgress");
+ },
+
+ onDownloadCompleted(aFolder, aNumMsgsDownloaded) {
+ this.log.info("in onDownloadCompleted");
+
+ // Remove activity if there was any.
+ // It can happen that download never started (e.g. couldn't connect to server),
+ // with onDownloadStarted, but we still get a onDownloadCompleted event
+ // when the connection is given up.
+ let recentActivity = this._mostRecentActivityForFolder.get(aFolder.URI);
+ if (recentActivity) {
+ this.activityMgr.removeActivity(recentActivity.eventID);
+ }
+
+ let displayText;
+ if (aNumMsgsDownloaded > 0) {
+ displayText = PluralForm.get(
+ aNumMsgsDownloaded,
+ this.getString("pop3EventStatusText")
+ );
+ displayText = displayText.replace("#1", aNumMsgsDownloaded);
+ } else {
+ displayText = this.getString("pop3EventStatusTextNoMsgs");
+ }
+
+ let statusText = aFolder.server.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ aFolder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.iconClass = "syncMail";
+
+ let downloadItem = { numMsgsDownloaded: aNumMsgsDownloaded };
+ this._mostRecentActivityForFolder.set(aFolder.URI, downloadItem);
+ downloadItem.eventID = this.activityMgr.addActivity(event);
+ if (!aNumMsgsDownloaded) {
+ // If we didn't download any messages this time, and the prev event
+ // for this folder also didn't download any messages, remove the
+ // prev event from the activity manager.
+ let prevItem = this._prevActivityForFolder.get(aFolder.URI);
+ if (prevItem != undefined && !prevItem.numMsgsDownloaded) {
+ if (this.activityMgr.containsActivity(prevItem.eventID)) {
+ this.activityMgr.removeActivity(prevItem.eventID);
+ }
+ }
+ }
+ },
+ init() {
+ // XXX when do we need to remove ourselves?
+ MailServices.pop3.addListener(this);
+ },
+};
diff --git a/comm/mail/components/activity/modules/sendLater.jsm b/comm/mail/components/activity/modules/sendLater.jsm
new file mode 100644
index 0000000000..37027d96f1
--- /dev/null
+++ b/comm/mail/components/activity/modules/sendLater.jsm
@@ -0,0 +1,298 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["sendLaterModule"];
+
+var nsActProcess = Components.Constructor(
+ "@mozilla.org/activity-process;1",
+ "nsIActivityProcess",
+ "init"
+);
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+var nsActWarning = Components.Constructor(
+ "@mozilla.org/activity-warning;1",
+ "nsIActivityWarning",
+ "init"
+);
+
+/**
+ * This really, really, sucks. Due to mailnews widespread use of
+ * nsIMsgStatusFeedback we're bound to the UI to get any sensible feedback of
+ * mail sending operations. The current send later code can't hook into the
+ * progress listener easily to get the state of messages being sent, so we'll
+ * just have to do it here.
+ */
+var sendMsgProgressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMsgStatusFeedback",
+ "nsISupportsWeakReference",
+ ]),
+
+ showStatusString(aStatusText) {
+ sendLaterModule.onMsgStatus(aStatusText);
+ },
+
+ startMeteors() {},
+
+ stopMeteors() {},
+
+ showProgress(aPercentage) {
+ sendLaterModule.onMessageSendProgress(0, 0, aPercentage, 0);
+ },
+};
+
+// This module provides a link between the send later service and the activity
+// manager.
+var sendLaterModule = {
+ _sendProcess: null,
+ _copyProcess: null,
+ _identity: null,
+ _subject: null,
+
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgSendLaterListener"]),
+
+ _displayTextForHeader(aLocaleStringBase, aSubject) {
+ return aSubject
+ ? this.bundle.formatStringFromName(aLocaleStringBase + "WithSubject", [
+ aSubject,
+ ])
+ : this.bundle.GetStringFromName(aLocaleStringBase);
+ },
+
+ _newProcess(aLocaleStringBase, aAddSubject) {
+ let process = new nsActProcess(
+ this._displayTextForHeader(
+ aLocaleStringBase,
+ aAddSubject ? this._subject : ""
+ ),
+ this.activityMgr
+ );
+
+ process.iconClass = "sendMail";
+ process.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+ process.contextObj = this;
+ process.contextType = "SendLater";
+ process.contextDisplayText =
+ this.bundle.GetStringFromName("sendingMessages");
+
+ return process;
+ },
+
+ // Use this to group an activity by the identity if we have one.
+ _applyIdentityGrouping(aActivity) {
+ if (this._identity) {
+ aActivity.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+ aActivity.contextType = this._identity.key;
+ aActivity.contextObj = this._identity;
+ let contextDisplayText = this._identity.identityName;
+ if (!contextDisplayText) {
+ contextDisplayText = this._identity.email;
+ }
+
+ aActivity.contextDisplayText = contextDisplayText;
+ } else {
+ aActivity.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_STANDALONE;
+ }
+ },
+
+ // Replaces the process with an event that reflects a completed process.
+ _replaceProcessWithEvent(aProcess) {
+ this.activityMgr.removeActivity(aProcess.id);
+
+ let event = new nsActEvent(
+ this._displayTextForHeader("sentMessage", this._subject),
+ this.activityMgr,
+ "",
+ aProcess.startTime,
+ new Date()
+ );
+
+ event.iconClass = "sendMail";
+ this._applyIdentityGrouping(event);
+
+ this.activityMgr.addActivity(event);
+ },
+
+ // Replaces the process with a warning that reflects the failed process.
+ _replaceProcessWithWarning(
+ aProcess,
+ aCopyOrSend,
+ aStatus,
+ aMsg,
+ aMessageHeader
+ ) {
+ this.activityMgr.removeActivity(aProcess.id);
+
+ let warning = new nsActWarning(
+ this._displayTextForHeader("failedTo" + aCopyOrSend, this._subject),
+ this.activityMgr,
+ ""
+ );
+
+ warning.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_STANDALONE;
+ this._applyIdentityGrouping(warning);
+
+ this.activityMgr.addActivity(warning);
+ },
+
+ onStartSending(aTotalMessageCount) {
+ if (!aTotalMessageCount) {
+ this.log.error("onStartSending called with zero messages\n");
+ }
+ },
+
+ onMessageStartSending(
+ aCurrentMessage,
+ aTotalMessageCount,
+ aMessageHeader,
+ aIdentity
+ ) {
+ // We want to use the identity and subject later, so store them for now.
+ this._identity = aIdentity;
+ if (aMessageHeader) {
+ this._subject = aMessageHeader.mime2DecodedSubject;
+ }
+
+ // Create the process to display the send activity.
+ let process = this._newProcess("sendingMessage", true);
+ this._sendProcess = process;
+ this.activityMgr.addActivity(process);
+
+ // Now the one for the copy process.
+ process = this._newProcess("copyMessage", false);
+ this._copyProcess = process;
+ this.activityMgr.addActivity(process);
+ },
+
+ onMessageSendProgress(
+ aCurrentMessage,
+ aTotalMessageCount,
+ aMessageSendPercent,
+ aMessageCopyPercent
+ ) {
+ if (aMessageSendPercent < 100) {
+ // Ensure we are in progress...
+ if (this._sendProcess.state != Ci.nsIActivityProcess.STATE_INPROGRESS) {
+ this._sendProcess.state = Ci.nsIActivityProcess.STATE_INPROGRESS;
+ }
+
+ // ... and update the progress.
+ this._sendProcess.setProgress(
+ this._sendProcess.lastStatusText,
+ aMessageSendPercent,
+ 100
+ );
+ } else if (aMessageSendPercent == 100) {
+ if (aMessageCopyPercent == 0) {
+ // Set send state to completed
+ if (this._sendProcess.state != Ci.nsIActivityProcess.STATE_COMPLETED) {
+ this._sendProcess.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+ }
+ this._replaceProcessWithEvent(this._sendProcess);
+
+ // Set copy state to in progress.
+ if (this._copyProcess.state != Ci.nsIActivityProcess.STATE_INPROGRESS) {
+ this._copyProcess.state = Ci.nsIActivityProcess.STATE_INPROGRESS;
+ }
+
+ // We don't know the progress of the copy, so just set to 0, and we'll
+ // display an undetermined progress meter.
+ this._copyProcess.setProgress(this._copyProcess.lastStatusText, 0, 0);
+ } else if (aMessageCopyPercent >= 100) {
+ // We need to set this to completed otherwise activity manager
+ // complains.
+ if (this._copyProcess) {
+ this._copyProcess.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+ this.activityMgr.removeActivity(this._copyProcess.id);
+ this._copyProcess = null;
+ }
+
+ this._sendProcess = null;
+ }
+ }
+ },
+
+ onMessageSendError(aCurrentMessage, aMessageHeader, aStatus, aMsg) {
+ if (
+ this._sendProcess &&
+ this._sendProcess.state != Ci.nsIActivityProcess.STATE_COMPLETED
+ ) {
+ this._sendProcess.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+ this._replaceProcessWithWarning(
+ this._sendProcess,
+ "SendMessage",
+ aStatus,
+ aMsg,
+ aMessageHeader
+ );
+ this._sendProcess = null;
+
+ if (
+ this._copyProcess &&
+ this._copyProcess.state != Ci.nsIActivityProcess.STATE_COMPLETED
+ ) {
+ this._copyProcess.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+ this.activityMgr.removeActivity(this._copyProcess.id);
+ this._copyProcess = null;
+ }
+ }
+ },
+
+ onMsgStatus(aStatusText) {
+ this._sendProcess.setProgress(
+ aStatusText,
+ this._sendProcess.workUnitComplete,
+ this._sendProcess.totalWorkUnits
+ );
+ },
+
+ onStopSending(aStatus, aMsg, aTotalTried, aSuccessful) {},
+
+ init() {
+ // We should need to remove the listener as we're not being held by anyone
+ // except by the send later instance.
+ let sendLaterService = Cc[
+ "@mozilla.org/messengercompose/sendlater;1"
+ ].getService(Ci.nsIMsgSendLater);
+
+ sendLaterService.addListener(this);
+
+ // Also add the nsIMsgStatusFeedback object.
+ let statusFeedback = Cc[
+ "@mozilla.org/messenger/statusfeedback;1"
+ ].createInstance(Ci.nsIMsgStatusFeedback);
+
+ statusFeedback.setWrappedStatusFeedback(sendMsgProgressListener);
+
+ sendLaterService.statusFeedback = statusFeedback;
+ },
+};
diff --git a/comm/mail/components/activity/moz.build b/comm/mail/components/activity/moz.build
new file mode 100644
index 0000000000..efceaacf9f
--- /dev/null
+++ b/comm/mail/components/activity/moz.build
@@ -0,0 +1,34 @@
+# vim: set filetype=python:
+# 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/.
+
+XPIDL_SOURCES += [
+ "nsIActivity.idl",
+ "nsIActivityManager.idl",
+ "nsIActivityManagerUI.idl",
+]
+
+XPIDL_MODULE = "activity"
+
+EXTRA_JS_MODULES.activity += [
+ "modules/activityModules.jsm",
+ "modules/alertHook.jsm",
+ "modules/autosync.jsm",
+ "modules/glodaIndexer.jsm",
+ "modules/moveCopy.jsm",
+ "modules/pop3Download.jsm",
+ "modules/sendLater.jsm",
+]
+
+EXTRA_JS_MODULES += [
+ "Activity.jsm",
+ "ActivityManager.jsm",
+ "ActivityManagerUI.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/activity/nsIActivity.idl b/comm/mail/components/activity/nsIActivity.idl
new file mode 100644
index 0000000000..d80e69088f
--- /dev/null
+++ b/comm/mail/components/activity/nsIActivity.idl
@@ -0,0 +1,492 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsISupports.idl"
+#include "nsISupportsPrimitives.idl"
+
+interface nsIActivityListener;
+interface nsIActivity;
+interface nsIActivityProcess;
+interface nsIActivityEvent;
+interface nsIActivityWarning;
+interface nsIVariant;
+interface nsISupportsPRTime;
+
+/**
+ * See https://wiki.mozilla.org/Thunderbird:Activity_Manager/Developer for UML
+ * diagram and sample codes.
+ */
+
+/**
+ * Background:
+ * Activity handlers define the behavioral capabilities of the activities. They
+ * are used by the Activity Manager to change the execution flow of the activity
+ * based on the user interaction. They are not mandatory, but when set, causes
+ * behavioral changes on the binding representing the activity, such as showing
+ * a cancel button, etc. The following handlers are currently supported;
+ */
+
+/**
+ * The handler to invoke when the recover button is pressed. Used by a Warning
+ * to recover from the situation causing the warning. For instance, recovery
+ * action for a "Over Quota Limit" warning, would be to cleanup some disk space,
+ * and this operation can be implemented and set by the activity developer in
+ * form of nsIActivityRecoveryHandler component.
+ */
+[scriptable, uuid(30E0A76F-880A-4093-8F3C-AF2239977A3D)]
+interface nsIActivityRecoveryHandler : nsISupports {
+ nsresult recover(in nsIActivityWarning aActivity);
+};
+
+/**
+ * The handler to invoke when the cancel button is pressed. Used by a Process to
+ * cancel the operation.
+ */
+[scriptable, uuid(35ee2461-70db-4b3a-90d0-7a68c856e911)]
+interface nsIActivityCancelHandler : nsISupports {
+ nsresult cancel(in nsIActivityProcess aActivity);
+};
+
+/**
+ * The handler to invoke when the pause button is pressed. Used by a Process to
+ * pause/resume the operation.
+ */
+[scriptable, uuid(9eee22bf-5378-460e-83a7-781cdcc9050b)]
+interface nsIActivityPauseHandler : nsISupports {
+ nsresult pause(in nsIActivityProcess aActivity);
+ nsresult resume(in nsIActivityProcess aActivity);
+};
+
+/**
+ * The handler to invoke when the retry button is pressed. Used by a Process to
+ * retry the operation in case of failure.
+ */
+[scriptable, uuid(8ec42517-951f-4bc0-aba5-fde7258b1705)]
+interface nsIActivityRetryHandler : nsISupports {
+ nsresult retry(in nsIActivityProcess aActivity);
+};
+
+/**
+ * The handler to invoke when the undo button is pressed. Used by a Event to
+ * undo the operation generated the event.
+ */
+[scriptable, uuid(b8632ac7-9d8b-4341-a349-ef000e8c89ac)]
+interface nsIActivityUndoHandler : nsISupports {
+ nsresult undo(in nsIActivityEvent aActivity);
+};
+
+/**
+ * Base interface of all activity interfaces. It is abstract in a sense that
+ * there is no component in the activity management system that solely
+ * implements this interface.
+ */
+[scriptable, uuid(6CD33E65-B2D8-4634-9B6D-B80BF1273E99)]
+interface nsIActivity : nsISupports {
+
+ /**
+ * Shows the activity as a standalone item.
+ */
+ const short GROUPING_STYLE_STANDALONE = 1;
+
+ /**
+ * Groups activity by its context.
+ */
+ const short GROUPING_STYLE_BYCONTEXT = 2;
+
+ /**
+ * Internal ID given by the activity manager when
+ * added into the activity list. Not readonly so that
+ * the activity manager can write to them, but not to be written to
+ * by anyone else.
+ */
+ attribute unsigned long id;
+
+ // Following attributes change the UI characteristics of the activity
+
+ /**
+ * A brief description of the activity, to be shown by the
+ * associated binding (XBL) in the Activity Manager window.
+ */
+ readonly attribute AString displayText;
+
+ /**
+ * Changes the default icon associated with the activity. Core activity
+ * icons are declared in |mail/themes/<themename>/mail/activity/activity.css|
+ * files.
+ *
+ * Extension developers can add and assign their own icons by setting
+ * this attribute.
+ */
+ attribute AString iconClass;
+
+ /**
+ * Textual id of the XBL binding that will be used to represent the
+ * activity in the Activity Manager window.
+ *
+ * This attribute allows to associate default activity components
+ * with custom XBL bindings. See |activity.xml| file for default
+ * activity XBL bindings, and |activity.css| file for default binding
+ * associations.
+ */
+ attribute AString bindingName;
+
+ /**
+ * Defines the grouping style of the activity when being shown in the
+ * activity manager window:
+ * GROUPING_STYLE_STANDALONE or GROUPING_STYLE_BYCONTEXT
+ */
+ attribute short groupingStyle;
+
+ /**
+ * A text value to associate a facet type with the activity. If empty,
+ * the activity will be shown in the 'Misc' section.
+ */
+ attribute AString facet;
+
+ // UI related attributes end.
+
+ /**
+ * Gets the initiator of the activity. An initiator represents an object
+ * that generates and controls the activity. For example, Copy Service can be
+ * the initiator of the copy, and move activities. Similarly Gloda can be the
+ * initiator of indexing activity, etc.
+ *
+ * This attribute is used mostly by handler components to change the execution
+ * flow of the activity such as canceling, pausing etc. Since not used by the
+ * Activity Manager, it is not mandatory to set it.
+ *
+ * An initiator can be any JS Object or an XPCOM component that provides an
+ * nsIVariant interface.
+ */
+ readonly attribute nsIVariant initiator;
+
+ /**
+ * Adds an object to the activity's internal subject list. Subject list
+ * provides argument(s) to activity handlers to complete their operation.
+ * For example, nsIActivityUndoHandler needs the source and destination
+ * folders to undo a move operation.
+ *
+ * Since subjects are not used by the Activity Manager, it is up to the
+ * activity developer to provide these objects.
+ *
+ * A subject can be any JS object or XPCOM component that supports nsIVariant
+ * interface.
+ */
+ void addSubject(in nsIVariant aSubject);
+
+ /**
+ * Retrieves all subjects associated with this activity.
+ *
+ * @return The list of subject objects associated by the activity.
+ */
+ Array<nsIVariant> getSubjects();
+
+ /*
+ * Background:
+ * A context is a generic concept that is used to group the processes and
+ * warnings having similar properties such as same imap server, same smtp
+ * server etc.
+ * A context is uniquely identified by its "type" and "object" attributes.
+ * Each activity that has the same context type and object are considered
+ * belong to the same logical group, context.
+ *
+ * There are 4 predefined context types known by the Activity Manager:
+ * Account, Smtp, Calendar, and Addressbook. The most common context type
+ * for activities is the "Account Context" and when combined with an account
+ * server instance, it allows to group different activities happening on the
+ * the same account server.
+ */
+
+ /**
+ * Sets and gets the context object of the activity. A context object can be
+ * any JS object or XPCOM component that supports nsIVariant interface.
+ */
+ attribute nsIVariant contextObj;
+
+ /**
+ * Sets and gets the context type of the activity. If this is set, then
+ * the contextDisplayText should also be set.
+ */
+ attribute AString contextType;
+
+ /**
+ * Return the displayText to be used for the context
+ **/
+ attribute AString contextDisplayText;
+
+ /**
+ * Adds a listener. See nsIActivityListener below.
+ */
+ void addListener(in nsIActivityListener aListener);
+
+ /**
+ * Removes the given listener. See nsIActivityListener below.
+ */
+ void removeListener(in nsIActivityListener aListener);
+};
+
+
+/**
+ * A Process represents an on-going activity.
+ */
+[scriptable, uuid(9DC7CA67-828D-4AFD-A5C6-3ECE091A98B8)]
+interface nsIActivityProcess : nsIActivity {
+
+ /**
+ * Default state for uninitialized process activity
+ * object.
+ */
+ const short STATE_NOTSTARTED = -1;
+
+ /**
+ * Activity is currently in progress.
+ */
+ const short STATE_INPROGRESS = 0;
+
+ /**
+ * Activity is completed.
+ */
+ const short STATE_COMPLETED = 1;
+
+ /**
+ * Activity was canceled by the user.
+ * (same as completed)
+ */
+ const short STATE_CANCELED = 2;
+
+ /**
+ * Activity was paused by the user.
+ */
+ const short STATE_PAUSED = 3;
+
+ /**
+ * Activity waits for the user input's to retry.
+ * (i.e. login password)
+ */
+ const short STATE_WAITINGFORINPUT = 4;
+
+ /**
+ * Activity is ready for an automatic or manual retry.
+ */
+ const short STATE_WAITINGFORRETRY = 5;
+
+ /**
+ * The state of the activity.
+ * See above for possible values.
+ * @exception NS_ERROR_ILLEGAL_VALUE if the state isn't one of the states
+ * defined above.
+ */
+ attribute short state;
+
+ /**
+ * The percentage of activity completed.
+ * If the max value is unknown it'll be -1 here.
+ */
+ readonly attribute long percentComplete;
+
+ /**
+ * A brief text about the process' status.
+ */
+ readonly attribute AString lastStatusText;
+
+ /**
+ * The amount of work units completed so far.
+ */
+ readonly attribute unsigned long workUnitComplete;
+
+ /**
+ * Total amount of work units.
+ */
+ readonly attribute unsigned long totalWorkUnits;
+
+ /**
+ * The starting time of the process.
+ * 64-bit signed integers relative to midnight (00:00:00), January 1, 1970
+ * Greenwich Mean Time (GMT). (GMT is also known as Coordinated Universal
+ * Time, UTC.). The units of time are in microseconds.
+ *
+ * In JS Date.now(), in C++ PR_Now() / PR_USEC_PER_MSEC can be used to set
+ * this value.
+ */
+ readonly attribute long long startTime;
+
+ /**
+ * The handler to invoke when the cancel button is pressed. If present
+ * (non-null), the activity can be canceled and a cancel button will be
+ * displayed to the user. If null, it cannot be canceled and no button will
+ * be displayed.
+ */
+ attribute nsIActivityCancelHandler cancelHandler;
+
+ /**
+ * The handler to invoke when the pause button is pressed. If present
+ * (non-null), the activity can be pauseable and a pause button will be
+ * displayed to the user. If null, it cannot be paused and no button will
+ * be displayed.
+ */
+ attribute nsIActivityPauseHandler pauseHandler;
+
+ /**
+ * The handler to invoke when the retry button is pressed. If present
+ * (non-null), the activity can be retryable and a retry button will be
+ * displayed to the user. If null, it cannot be retried and no button will
+ * be displayed.
+ */
+ attribute nsIActivityRetryHandler retryHandler;
+
+ /**
+ * Updates the activity progress info.
+ *
+ * @param aStatusText A localized text describing the current status of the
+ * process
+ * @param aWorkUnitComplete The amount of work units completed. Not used by
+ * Activity Manager or default binding for any
+ * purpose.
+ * @param aTotalWorkUnits Total amount of work units. Not used by
+ * Activity Manager or default binding for any
+ * purpose. If set to zero, this indicates that the
+ * number of work units is unknown, and the percentage
+ * attribute will be set to -1.
+ */
+ void setProgress(in AString aStatusText,
+ in unsigned long aWorkUnitComplete,
+ in unsigned long aTotalWorkUnits);
+
+ /**
+ * Component initialization method.
+ *
+ * @param aDisplayText A localized text to be shown on the Activity Manager
+ * window
+ * @param aInitiator The initiator of the process
+ */
+ void init(in AString aDisplayText, in nsIVariant aInitiator);
+};
+
+/**
+ * Historical actions performed by the user, by extensions or by the system.
+ */
+[scriptable, uuid(5B1B0D03-2820-4E37-8BF8-102AFDE4FC45)]
+interface nsIActivityEvent : nsIActivity {
+
+ /**
+ * Any localized textual information related to this event.
+ * It is shown at the bottom of the displayText area.
+ */
+ readonly attribute AString statusText;
+
+ /**
+ * The starting time of the event.
+ * 64-bit signed integers relative to midnight (00:00:00), January 1, 1970
+ * Greenwich Mean Time (GMT). (GMT is also known as Coordinated Universal
+ * Time, UTC.). The units of time are in microseconds.
+ *
+ * In JS Date.now(), in C++ PR_Now() / PR_USEC_PER_MSEC can be used to set
+ * this value.
+ */
+ readonly attribute long long startTime;
+
+ /**
+ * The completion time of the event in microseconds.
+ * 64-bit signed integers relative to midnight (00:00:00), January 1, 1970
+ * Greenwich Mean Time (GMT). (GMT is also known as Coordinated Universal
+ * Time, UTC.). The units of time are in microseconds.
+ *
+ * In JS Date.now(), in C++ PR_Now() / PR_USEC_PER_MSEC can be used to set
+ * this value.
+ */
+ readonly attribute long long completionTime;
+
+ /**
+ * The handler to invoke when the undo button is pressed. If present
+ * (non-null), the activity can be undoable and an undo button will be
+ * displayed to the user. If null, it cannot be undone and no button will
+ * be displayed.
+ */
+ attribute nsIActivityUndoHandler undoHandler;
+
+ /**
+ * Component initialization method.
+ *
+ * @param aDisplayText Any localized text describing the event and its context
+ * @param aInitiator The initiator of the event
+ * @param aStatusText Any localized additional information about the event
+ * @param aStartTime The starting time of the event
+ * @param aCompletionTime The completion time of the event
+ */
+ void init(in AString aDisplayText, in nsIVariant aInitiator,
+ in AString aStatusText, in long long aStartTime,
+ in long long aCompletionTime);
+};
+
+[scriptable, uuid(8265833e-c604-4585-a43c-a76bd8ed3a8c)]
+interface nsIActivityWarning : nsIActivity {
+
+ /**
+ * Any localized textual information related to this warning.
+ */
+ readonly attribute AString warningText;
+
+ /**
+ * The time of the warning.
+ * 64-bit signed integers relative to midnight (00:00:00), January 1, 1970
+ * Greenwich Mean Time (GMT). (GMT is also known as Coordinated Universal
+ * Time, UTC.). The units of time are in microseconds.
+ *
+ * In JS Date.now(), in C++ PR_Now() / PR_USEC_PER_MSEC can be used to set
+ * this value.
+ */
+ readonly attribute long long time;
+
+ /**
+ * Recovery tip of the warning, localized.
+ */
+ readonly attribute AString recoveryTipText;
+
+ /**
+ * The handler to invoke when the recover button is pressed. If present
+ * (non-null), the activity can be recoverable and a recover button will be
+ * displayed to the user. If null, it cannot be recovered and no button will
+ * be displayed.
+ */
+ attribute nsIActivityRecoveryHandler recoveryHandler;
+
+ /**
+ * Component initialization method.
+ *
+ * @param aWarningText The localized text that will be shown on the display
+ * area
+ * @param aInitiator The initiator of the warning
+ * @param aRecoveryTip A localized textual information to guide the user in
+ * order to recover from the warning situation.
+ */
+ void init(in AString aWarningText, in nsIVariant aInitiator,
+ in AString aRecoveryTip);
+};
+
+[scriptable, uuid(bd11519f-b297-4b34-a793-1861dc90d5e9)]
+interface nsIActivityListener : nsISupports {
+ /**
+ * Triggered after activity state is changed.
+ */
+ void onStateChanged(in nsIActivity aActivity, in short aOldState);
+
+ /**
+ * Triggered after the progress of the process activity is changed.
+ */
+ void onProgressChanged(in nsIActivity aActivity,
+ in AString aStatusText,
+ in unsigned long aWorkUnitsCompleted,
+ in unsigned long aTotalWorkUnits);
+
+ /**
+ * Triggered after one of the activity handler is set.
+ *
+ * This is mostly used to update the UI of the activity when
+ * one of the handler is set to null after the operation is completed.
+ * For example after the activity is undone, to make the undo button
+ * invisible.
+ */
+ void onHandlerChanged(in nsIActivity aActivity);
+};
diff --git a/comm/mail/components/activity/nsIActivityManager.idl b/comm/mail/components/activity/nsIActivityManager.idl
new file mode 100644
index 0000000000..860b4e1e2b
--- /dev/null
+++ b/comm/mail/components/activity/nsIActivityManager.idl
@@ -0,0 +1,135 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsISupports.idl"
+
+interface mozIStorageConnection;
+interface nsIActivity;
+interface nsIActivityProcess;
+interface nsIVariant;
+
+/**
+ * See https://wiki.mozilla.org/Thunderbird:Activity_Manager/Developer for UML
+ * diagram and sample codes.
+ */
+
+/**
+ * An interface to get notified by the major Activity Manager events.
+ * Mostly used by UI glue code in activity.js.
+ */
+[scriptable, uuid(14cfad1c-3401-4c44-ab04-4a11b6662663)]
+interface nsIActivityMgrListener : nsISupports {
+ /**
+ * Called _after_ activity manager adds an activity into
+ * the managed list.
+ */
+ void onAddedActivity(in unsigned long aID, in nsIActivity aActivity);
+
+ /**
+ * Called _after_ activity manager removes an activity from
+ * the managed list.
+ */
+ void onRemovedActivity(in unsigned long aID);
+};
+
+/**
+ * Activity Manager is a simple component that understands how do display a
+ * combination of user activity and history. The activity manager works in
+ * conjunction with the 'Interactive Status Bar' to give the user the right
+ * level of notifications concerning what Thunderbird is doing on it's own and
+ * how Thunderbird has handled user requests.
+ *
+ * There are 3 different classifications of activity items which can be
+ * displayed in the Activity Manager Window:
+ * o Process: Processes are transient in the display. They are not written to
+ * disk as they are always acting on some data that already exists
+ * locally or remotely. If a process has finished and needs to keep
+ * some state for the user (like last sync time) it can convert
+ * itself into an event.
+ * o Event: Historical actions performed by the user and created by a process
+ * for the Activity Manager Window. Events can show up in the
+ * 'Interactive Status Bar' and be displayed to users as they are
+ * created.
+ * o Warning: Alerts sent by Thunderbird or servers (i.e. imap server) that need
+ * attention by the user. For example a Quota Alert from the imap
+ * server can be represented as a Warning to the user. They are not
+ * written to disk.
+ */
+[scriptable, uuid(9BFCC031-50E1-4D30-A35F-23509ABCB8D1)]
+interface nsIActivityManager : nsISupports {
+
+ /**
+ * Adds the given activity into the managed activities list.
+ *
+ * @param aActivity The activity that will be added.
+ *
+ * @return Unique activity identifier.
+ */
+ unsigned long addActivity(in nsIActivity aActivity);
+
+ /**
+ * Removes the activity with the given id if it's not currently
+ * in-progress.
+ *
+ * @param aID The unique ID of the activity.
+ *
+ * @throws NS_ERROR_FAILURE if the activity is in-progress.
+ */
+ void removeActivity(in unsigned long aID);
+
+ /**
+ * Retrieves an activity managed by the activity manager. This can be one that
+ * is in progress, or one that has completed in the past and is stored in the
+ * persistent store.
+ *
+ * @param aID The unique ID of the activity.
+ *
+ * @return The activity with the specified ID, or null if not found.
+ */
+ nsIActivity getActivity(in unsigned long aID);
+
+ /**
+ * Tests whether the activity in question in the activity list or not.
+ */
+ boolean containsActivity(in unsigned long aID);
+
+ /**
+ * Retrieves all activities managed by the activity manager. This can be one
+ * that is in progress (process), one that is represented as a warning, or one
+ * that has completed (event) in the past and is stored in the persistent
+ * store.
+ *
+ * @return A read-only list of activities managed by the activity manager.
+ */
+ Array<nsIActivity> getActivities();
+
+ /**
+ * Retrieves processes with given context type and object.
+ *
+ * @return A read-only list of processes matching to given criteria.
+ */
+ Array<nsIActivityProcess> getProcessesByContext(in AString aContextType,
+ in nsIVariant aContextObject);
+
+ /**
+ * Call to remove all activities apart from those that are in progress.
+ */
+ void cleanUp();
+
+ /**
+ * The number of processes in the activity list.
+ */
+ readonly attribute long processCount;
+
+ /**
+ * Adds a listener.
+ */
+ void addListener(in nsIActivityMgrListener aListener);
+
+ /**
+ * Removes the given listener.
+ */
+ void removeListener(in nsIActivityMgrListener aListener);
+};
diff --git a/comm/mail/components/activity/nsIActivityManagerUI.idl b/comm/mail/components/activity/nsIActivityManagerUI.idl
new file mode 100644
index 0000000000..07a2a30394
--- /dev/null
+++ b/comm/mail/components/activity/nsIActivityManagerUI.idl
@@ -0,0 +1,50 @@
+/* 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/. */
+
+#include "nsISupports.idl"
+
+interface nsIInterfaceRequestor;
+
+[scriptable, uuid(ae7853b0-2e1f-4dc1-89cd-f8bbfb745d4d)]
+interface nsIActivityManagerUI : nsISupports {
+ /**
+ * The reason that should be passed when the user requests to show the
+ * activity manager's UI.
+ */
+ const short REASON_USER_INTERACTED = 0;
+
+ /**
+ * The reason that should be passed to the show method when we are displaying
+ * the UI because a new activity is being added to it.
+ */
+ const short REASON_NEW_ACTIVITY = 1;
+
+ /**
+ * Shows the Activity Manager's UI to the user.
+ *
+ * @param [optional] aWindowContext
+ * The parent window context to show the UI.
+ * @param [optional] aID
+ * The id of the activity to be preselected upon opening.
+ * @param [optional] aReason
+ * The reason to show the activity manager's UI. This defaults to
+ * REASON_USER_INTERACTED, and should be one of the previously listed
+ * constants.
+ */
+ void show([optional] in nsIInterfaceRequestor aWindowContext,
+ [optional] in unsigned long aID,
+ [optional] in short aReason);
+
+ /**
+ * Indicates if the UI is visible or not.
+ */
+ readonly attribute boolean visible;
+
+ /**
+ * Brings attention to the UI if it is already visible
+ *
+ * @throws NS_ERROR_UNEXPECTED if the UI is not visible.
+ */
+ void getAttention();
+};
diff --git a/comm/mail/components/addrbook/content/abCommon.js b/comm/mail/components/addrbook/content/abCommon.js
new file mode 100644
index 0000000000..36f251206e
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abCommon.js
@@ -0,0 +1,145 @@
+/* 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 ../../../../mailnews/addrbook/content/abResultsPane.js */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gAbView = null;
+
+var kDefaultAscending = "ascending";
+var kDefaultDescending = "descending";
+var kAllDirectoryRoot = "moz-abdirectory://";
+var kPersonalAddressbookURI = "jsaddrbook://abook.sqlite";
+
+async function AbDelete() {
+ let types = GetSelectedCardTypes();
+ if (types == kNothingSelected) {
+ return;
+ }
+
+ let cards = GetSelectedAbCards();
+
+ // Determine strings for smart and context-sensitive user prompts
+ // for confirming deletion.
+ let action, name, list;
+ let selectedDir = gAbView.directory;
+
+ switch (types) {
+ case kListsAndCards:
+ action = "delete-mixed";
+ break;
+ case kSingleListOnly:
+ case kMultipleListsOnly:
+ action = "delete-lists";
+ name = cards[0].displayName;
+ break;
+ default: {
+ let nameFormatFromPref = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst"
+ );
+ name = cards[0].generateName(nameFormatFromPref);
+ if (selectedDir && selectedDir.isMailList) {
+ action = "remove-contacts";
+ list = selectedDir.dirName;
+ } else {
+ action = "delete-contacts";
+ }
+ break;
+ }
+ }
+
+ // Adjust strings to match translations.
+ let actionString;
+ switch (action) {
+ case "delete-contacts":
+ actionString = !cards.length
+ ? "delete-contacts-single"
+ : "delete-contacts-multi";
+ break;
+ case "remove-contacts":
+ actionString = !cards.length
+ ? "remove-contacts-single"
+ : "remove-contacts-multi";
+ break;
+ default:
+ actionString = action;
+ break;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ {
+ id: `about-addressbook-confirm-${action}-title`,
+ args: { count: cards.length },
+ },
+ {
+ id: `about-addressbook-confirm-${actionString}`,
+ args: {
+ count: cards.length,
+ name,
+ list,
+ },
+ },
+ ]);
+
+ // Finally, show our smart confirmation message, and act upon it!
+ if (!Services.prompt.confirm(window, title, message)) {
+ // Deletion cancelled by user.
+ return;
+ }
+
+ // Delete cards from address books or mailing lists.
+ gAbView.deleteSelectedCards();
+}
+
+function AbNewMessage(address) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.Default;
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (address) {
+ params.composeFields.to = address;
+ } else {
+ params.composeFields.to = GetSelectedAddresses();
+ }
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+}
+
+/**
+ * Make a mailbox string from the card, for use in the UI.
+ *
+ * @param {nsIAbCard} - The card to use.
+ * @returns {string} A mailbox representation of the card.
+ */
+function makeMailboxObjectFromCard(card) {
+ if (!card) {
+ return "";
+ }
+
+ let email;
+ if (card.isMailList) {
+ let directory = GetDirectoryFromURI(card.mailListURI);
+ email = directory.description || card.displayName;
+ } else {
+ email = card.primaryEmail;
+ }
+
+ return MailServices.headerParser
+ .makeMailboxObject(card.displayName, email)
+ .toString();
+}
+
+function GetDirectoryFromURI(uri) {
+ if (uri.startsWith("moz-abdirectory://")) {
+ return null;
+ }
+ return MailServices.ab.getDirectory(uri);
+}
diff --git a/comm/mail/components/addrbook/content/abContactsPanel.js b/comm/mail/components/addrbook/content/abContactsPanel.js
new file mode 100644
index 0000000000..c1e3481318
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abContactsPanel.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/. */
+
+/* import-globals-from ../../../../../toolkit/content/editMenuOverlay.js */
+/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from abCommon.js */
+
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { getSearchTokens, getModelQuery, generateQueryURI } = ChromeUtils.import(
+ "resource:///modules/ABQueryUtils.jsm"
+);
+
+// A boolean variable determining whether AB column should be shown
+// in Contacts Sidebar in compose window.
+var gShowAbColumnInComposeSidebar = false;
+var gQueryURIFormat = null;
+
+UIDensity.registerWindow(window);
+
+function GetAbViewListener() {
+ // the ab panel doesn't care if the total changes, or if the selection changes
+ return null;
+}
+
+/**
+ * Handle the command event on abContextMenuButton (click, Enter, spacebar).
+ */
+function abContextMenuButtonOnCommand(event) {
+ showContextMenu("sidebarAbContextMenu", event, [
+ event.target,
+ "after_end",
+ 0,
+ 0,
+ true,
+ ]);
+}
+
+/**
+ * Handle the context menu event of results tree (right-click, context menu key
+ * press, etc.). Show the respective context menu for selected contact(s) or
+ * results tree blank space (work around for XUL tree bug 1331377).
+ *
+ * @param aEvent a context menu event (right-click, context menu key press, etc.)
+ */
+function contactsListOnContextMenu(aEvent) {
+ let target = aEvent.target;
+ let contextMenuID;
+ let positionArray;
+
+ // For right-click on column header or column picker, don't show context menu.
+ if (target.localName == "treecol" || target.localName == "treecolpicker") {
+ return;
+ }
+
+ // On treechildren, if there's no selection, show "sidebarAbContextMenu".
+ if (gAbView.selection.count == 0) {
+ contextMenuID = gAbResultsTree.getAttribute("contextNoSelection");
+ // If "sidebarAbContextMenu" menu was activated by keyboard,
+ // position it in the topleft corner of gAbResultsTree.
+ if (!aEvent.button) {
+ positionArray = [gAbResultsTree, "overlap", 0, 0, true];
+ }
+ // If there's a selection, show "cardProperties" context menu.
+ } else {
+ contextMenuID = gAbResultsTree.getAttribute("contextSelection");
+ updateCardPropertiesMenu();
+ }
+ showContextMenu(contextMenuID, aEvent, positionArray);
+}
+
+/**
+ * Update the single row card properties context menu to show or hide the "Edit"
+ * menu item only depending on the selection type.
+ */
+function updateCardPropertiesMenu() {
+ let cards = GetSelectedAbCards();
+
+ let separator = document.getElementById("abContextBeforeEditContact");
+ let menuitem = document.getElementById("abContextEditContact");
+
+ // Only show the Edit item if one item is selected, is not a mailing list, and
+ // the contact is not part of a readOnly address book.
+ if (
+ cards.length != 1 ||
+ cards.some(c => c.isMailList) ||
+ MailServices.ab.getDirectoryFromUID(cards[0].directoryUID)?.readOnly
+ ) {
+ separator.hidden = true;
+ menuitem.hidden = true;
+ return;
+ }
+
+ separator.hidden = false;
+ menuitem.hidden = false;
+}
+
+/**
+ * Handle the click event of the results tree (workaround for XUL tree
+ * bug 1331377).
+ *
+ * @param aEvent a click event
+ */
+function contactsListOnClick(aEvent) {
+ CommandUpdate_AddressBook();
+
+ let target = aEvent.target;
+
+ // Left click on column header: Change sort direction.
+ if (target.localName == "treecol" && aEvent.button == 0) {
+ let sortDirection =
+ target.getAttribute("sortDirection") == kDefaultDescending
+ ? kDefaultAscending
+ : kDefaultDescending;
+ SortAndUpdateIndicators(target.id, sortDirection);
+ return;
+ }
+ // Any click on gAbResultsTree view (rows or blank space).
+ if (target.localName == "treechildren") {
+ let row = gAbResultsTree.getRowAt(aEvent.clientX, aEvent.clientY);
+ if (row < 0 || row >= gAbResultsTree.view.rowCount) {
+ // Any click on results tree whitespace.
+ if ((aEvent.detail == 1 && aEvent.button == 0) || aEvent.button == 2) {
+ // Single left click or any right click on results tree blank space:
+ // Clear selection. This also triggers on the first click of any
+ // double-click, but that's ok. MAC OS X doesn't return event.detail==1
+ // for single right click, so we also let this trigger for the second
+ // click of right double-click.
+ gAbView.selection.clearSelection();
+ }
+ } else if (aEvent.button == 0 && aEvent.detail == 2) {
+ // Any click on results tree rows.
+ // Double-click on a row: Go ahead and add the entry.
+ addSelectedAddresses("addr_to");
+ }
+ }
+}
+
+/**
+ * Appends the currently selected cards as new recipients in the composed message.
+ *
+ * @param recipientType Type of recipient, e.g. "addr_to".
+ */
+function addSelectedAddresses(recipientType) {
+ var cards = GetSelectedAbCards();
+
+ // Turn each card into a properly formatted address.
+ let addresses = cards.map(makeMailboxObjectFromCard).filter(addr => addr);
+ parent.addressRowAddRecipientsArray(
+ parent.document.querySelector(
+ `.address-row[data-recipienttype="${recipientType}"]`
+ ),
+ addresses
+ );
+}
+
+/**
+ * Open the address book tab and trigger the edit of the selected contact.
+ */
+function editSelectedAddress() {
+ let cards = GetSelectedAbCards();
+ window.top.toAddressBook({ action: "edit", card: cards[0] });
+}
+
+function AddressBookMenuListChange(aValue) {
+ let searchInput = document.getElementById("peopleSearchInput");
+ if (searchInput.value && !searchInput.showingSearchCriteria) {
+ onEnterInSearchBar();
+ } else {
+ ChangeDirectoryByURI(aValue);
+ }
+
+ // Hide the addressbook column if the selected addressbook isn't
+ // "All address books". Since the column is redundant in all other cases.
+ let abList = document.getElementById("addressbookList");
+ let addrbookColumn = document.getElementById("addrbook");
+ if (abList.value.startsWith(kAllDirectoryRoot + "?")) {
+ addrbookColumn.hidden = !gShowAbColumnInComposeSidebar;
+ addrbookColumn.removeAttribute("ignoreincolumnpicker");
+ } else {
+ addrbookColumn.hidden = true;
+ addrbookColumn.setAttribute("ignoreincolumnpicker", "true");
+ }
+
+ CommandUpdate_AddressBook();
+}
+
+var mutationObs = null;
+
+function AbPanelLoad() {
+ if (location.search == "?focus") {
+ document.getElementById("peopleSearchInput").focus();
+ }
+
+ document.title = parent.document.getElementById("contactsTitle").value;
+
+ // Get the URI of the directory to display.
+ let startupURI = Services.prefs.getCharPref("mail.addr_book.view.startupURI");
+ // If the URI is a mailing list, use the parent directory instead, since
+ // mailing lists are not displayed here.
+ startupURI = startupURI.replace(/^(jsaddrbook:\/\/[\w\.-]*)\/.*$/, "$1");
+
+ let abPopup = document.getElementById("addressbookList");
+ abPopup.value = startupURI;
+
+ // If provided directory is not on abPopup, fall back to All Address Books.
+ if (!abPopup.selectedItem) {
+ abPopup.selectedIndex = 0;
+ }
+
+ // Postpone the slow contacts load so that the sidebar document
+ // gets a chance to display quickly.
+ setTimeout(ChangeDirectoryByURI, 0, abPopup.value);
+
+ mutationObs = new MutationObserver(function (aMutations) {
+ aMutations.forEach(function (mutation) {
+ if (
+ getSelectedDirectoryURI() == kAllDirectoryRoot + "?" &&
+ mutation.type == "attributes" &&
+ mutation.attributeName == "hidden"
+ ) {
+ let curState = document.getElementById("addrbook").hidden;
+ gShowAbColumnInComposeSidebar = !curState;
+ }
+ });
+ });
+
+ document.getElementById("addrbook").hidden = !gShowAbColumnInComposeSidebar;
+
+ mutationObs.observe(document.getElementById("addrbook"), {
+ attributes: true,
+ childList: true,
+ });
+}
+
+function AbPanelUnload() {
+ mutationObs.disconnect();
+
+ // If there's no default startupURI, save the last used URI as new startupURI.
+ if (!Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ Services.prefs.setCharPref(
+ "mail.addr_book.view.startupURI",
+ getSelectedDirectoryURI()
+ );
+ }
+
+ CloseAbView();
+}
+
+function AbResultsPaneDoubleClick(card) {
+ // double click for ab panel means "send mail to this person / list"
+ AbNewMessage();
+}
+
+function CommandUpdate_AddressBook() {
+ // Toggle disable state of to,cc,bcc buttons.
+ let disabled = GetNumSelectedCards() == 0 ? "true" : "false";
+ document.getElementById("cmd_addrTo").setAttribute("disabled", disabled);
+ document.getElementById("cmd_addrCc").setAttribute("disabled", disabled);
+ document.getElementById("cmd_addrBcc").setAttribute("disabled", disabled);
+
+ goUpdateCommand("cmd_delete");
+}
+
+/**
+ * Handle the onpopupshowing event of #sidebarAbContextMenu.
+ * Update the checkmark of #sidebarAbContext-startupDir menuitem when context
+ * menu opens, so as to always be in sync with changes from the main AB window.
+ */
+function onAbContextShowing() {
+ let startupItem = document.getElementById("sidebarAbContext-startupDir");
+ if (Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ let startupURI = Services.prefs.getCharPref(
+ "mail.addr_book.view.startupURI"
+ );
+ startupItem.setAttribute(
+ "checked",
+ startupURI == getSelectedDirectoryURI()
+ );
+ } else {
+ startupItem.setAttribute("checked", "false");
+ }
+}
+
+function onEnterInSearchBar() {
+ if (!gQueryURIFormat) {
+ // Get model query from pref. We don't want the query starting with "?"
+ // as we have to prefix "?and" to this format.
+ /* eslint-disable no-global-assign */
+ gQueryURIFormat = getModelQuery("mail.addr_book.quicksearchquery.format");
+ /* eslint-enable no-global-assign */
+ }
+
+ let searchURI = getSelectedDirectoryURI();
+ let searchQuery;
+ let searchInput = document.getElementById("peopleSearchInput");
+
+ // Use helper method to split up search query to multi-word search
+ // query against multiple fields.
+ if (searchInput) {
+ let searchWords = getSearchTokens(searchInput.value);
+ searchQuery = generateQueryURI(gQueryURIFormat, searchWords);
+ }
+
+ SetAbView(searchURI, searchQuery, searchInput ? searchInput.value : "");
+}
+
+/**
+ * Open a menupopup as a context menu
+ *
+ * @param aContextMenuID The ID of a menupopup to be shown as context menu
+ * @param aEvent The event which triggered this.
+ * @param positionArray An optional array containing the parameters for openPopup() method;
+ * if omitted, mouse pointer position will be used.
+ */
+function showContextMenu(aContextMenuID, aEvent, aPositionArray) {
+ let theContextMenu = document.getElementById(aContextMenuID);
+ if (!aPositionArray) {
+ aPositionArray = [null, "", aEvent.clientX, aEvent.clientY, true];
+ }
+ theContextMenu.openPopup(...aPositionArray);
+}
+
+/**
+ * Get the URI of the selected directory.
+ *
+ * @returns The URI of the currently selected directory
+ */
+function getSelectedDirectoryURI() {
+ return document.getElementById("addressbookList").value;
+}
+
+function abToggleSelectedDirStartup() {
+ let selectedDirURI = getSelectedDirectoryURI();
+ if (!selectedDirURI) {
+ return;
+ }
+
+ let isDefault = Services.prefs.getBoolPref(
+ "mail.addr_book.view.startupURIisDefault"
+ );
+ let startupURI = Services.prefs.getCharPref("mail.addr_book.view.startupURI");
+
+ if (isDefault && startupURI == selectedDirURI) {
+ // The current directory has been the default startup view directory;
+ // toggle that off now. So there's no default startup view directory any more.
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ false
+ );
+ } else {
+ // The current directory will now be the default view
+ // when starting up the main AB window.
+ Services.prefs.setCharPref(
+ "mail.addr_book.view.startupURI",
+ selectedDirURI
+ );
+ Services.prefs.setBoolPref("mail.addr_book.view.startupURIisDefault", true);
+ }
+
+ // Update the checkbox in the menuitem.
+ goUpdateCommand("cmd_abToggleStartupDir");
+}
+
+function ChangeDirectoryByURI(uri = kPersonalAddressbookURI) {
+ SetAbView(uri);
+
+ // Actively de-selecting if there are any pre-existing selections
+ // in the results list.
+ if (gAbView && gAbView.selection && gAbView.getCardFromRow(0)) {
+ gAbView.selection.clearSelection();
+ }
+}
diff --git a/comm/mail/components/addrbook/content/abContactsPanel.xhtml b/comm/mail/components/addrbook/content/abContactsPanel.xhtml
new file mode 100644
index 0000000000..18163eafda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abContactsPanel.xhtml
@@ -0,0 +1,234 @@
+<?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://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/abContactsPanel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % abResultsPaneDTD SYSTEM "chrome://messenger/locale/addressbook/abResultsPane.dtd">
+%abResultsPaneDTD;
+<!ENTITY % abContactsPanelDTD SYSTEM "chrome://messenger/locale/addressbook/abContactsPanel.dtd" >
+%abContactsPanelDTD;
+<!ENTITY % abMainWindowDTD SYSTEM "chrome://messenger/locale/addressbook/abMainWindow.dtd" >
+%abMainWindowDTD; ]>
+
+<window
+ id="abContactsPanel"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="AbPanelLoad();"
+ onunload="AbPanelUnload();"
+>
+ <html:link
+ rel="localization"
+ href="messenger/addressbook/aboutAddressBook.ftl"
+ />
+
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://communicator/content/utilityOverlay.js" />
+ <script src="chrome://messenger/content/addressbook/abDragDrop.js" />
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abResultsPane.js" />
+ <script src="chrome://messenger/content/addressbook/abContactsPanel.js" />
+ <script src="chrome://messenger/content/jsTreeView.js" />
+ <script src="chrome://messenger/content/addressbook/abView.js" />
+
+ <commandset
+ id="CommandUpdate_AddressBook"
+ commandupdater="true"
+ events="focus,addrbook-select"
+ oncommandupdate="CommandUpdate_AddressBook()"
+ >
+ <command
+ id="cmd_addrTo"
+ oncommand="addSelectedAddresses('addr_to')"
+ disabled="true"
+ />
+ <command
+ id="cmd_addrCc"
+ oncommand="addSelectedAddresses('addr_cc')"
+ disabled="true"
+ />
+ <command
+ id="cmd_addrBcc"
+ oncommand="addSelectedAddresses('addr_bcc')"
+ disabled="true"
+ />
+ <command id="cmd_delete" oncommand="goDoCommand('cmd_delete');" />
+ </commandset>
+
+ <keyset id="keyset_abContactsPanel">
+ <!-- This key (key_delete) does not trigger any command, but it is used
+ only to show the hotkey on the corresponding menuitem. -->
+ <key id="key_delete" keycode="VK_DELETE" internal="true" />
+ </keyset>
+
+ <menupopup id="cardProperties">
+ <menuitem
+ label="&addtoToFieldMenu.label;"
+ accesskey="&addtoToFieldMenu.accesskey;"
+ command="cmd_addrTo"
+ />
+ <menuitem
+ label="&addtoCcFieldMenu.label;"
+ accesskey="&addtoCcFieldMenu.accesskey;"
+ command="cmd_addrCc"
+ />
+ <menuitem
+ label="&addtoBccFieldMenu.label;"
+ accesskey="&addtoBccFieldMenu.accesskey;"
+ command="cmd_addrBcc"
+ />
+ <menuseparator />
+ <menuitem
+ label="&deleteAddrBookCard.label;"
+ accesskey="&deleteAddrBookCard.accesskey;"
+ key="key_delete"
+ command="cmd_delete"
+ />
+ <menuseparator id="abContextBeforeEditContact" hidden="true" />
+ <menuitem
+ id="abContextEditContact"
+ label="&editContactContext.label;"
+ accesskey="&editContactContext.accesskey;"
+ oncommand="editSelectedAddress();"
+ hidden="true"
+ />
+ </menupopup>
+
+ <menupopup
+ id="sidebarAbContextMenu"
+ class="no-accel-menupopup"
+ onpopupshowing="onAbContextShowing();"
+ >
+ <menuitem
+ id="sidebarAbContext-startupDir"
+ label="&showAsDefault.label;"
+ accesskey="&showAsDefault.accesskey;"
+ type="checkbox"
+ checked="false"
+ oncommand="abToggleSelectedDirStartup();"
+ />
+ </menupopup>
+
+ <vbox id="results_box" flex="1">
+ <separator class="thin" />
+ <hbox id="AbPickerHeader" class="themeable-full">
+ <label
+ value="&addressbookPicker.label;"
+ accesskey="&addressbookPicker.accesskey;"
+ control="addressbookList"
+ />
+ <spacer flex="1" />
+ <button
+ id="abContextMenuButton"
+ tooltiptext="&abContextMenuButton.tooltip;"
+ oncommand="abContextMenuButtonOnCommand(event);"
+ />
+ </hbox>
+ <hbox id="panel-bar" class="themeable-full" align="center">
+ <menulist
+ is="menulist-addrbooks"
+ id="addressbookList"
+ alladdressbooks="true"
+ oncommand="AddressBookMenuListChange(this.value);"
+ flex="1"
+ />
+ </hbox>
+
+ <separator class="thin" />
+
+ <vbox>
+ <label
+ value="&searchContacts.label;"
+ accesskey="&searchContacts.accesskey;"
+ control="peopleSearchInput"
+ />
+ <search-textbox
+ id="peopleSearchInput"
+ class="searchBox"
+ flex="1"
+ timeout="800"
+ placeholder="&SearchNameOrEmail.label;"
+ oncommand="onEnterInSearchBar();"
+ />
+ </vbox>
+
+ <separator class="thin" />
+
+ <tree
+ id="abResultsTree"
+ flex="1"
+ class="plain"
+ sortCol="GeneratedName"
+ persist="sortCol"
+ contextSelection="cardProperties"
+ contextNoSelection="sidebarAbContextMenu"
+ oncontextmenu="contactsListOnContextMenu(event);"
+ onclick="contactsListOnClick(event);"
+ onselect="this.view.selectionChanged(); document.commandDispatcher.updateCommands('addrbook-select');"
+ >
+ <treecols>
+ <!-- these column ids must match up to the mork column names, see nsIAddrDatabase.idl -->
+ <treecol
+ id="GeneratedName"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&GeneratedName.label;"
+ primary="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="addrbook"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&Addrbook.label;"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="PrimaryEmail"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&PrimaryEmail.label;"
+ />
+ </treecols>
+ <treechildren ondragstart="abResultsPaneObserver.onDragStart(event);" />
+ </tree>
+
+ <separator class="thin" />
+
+ <hbox pack="center">
+ <vbox>
+ <button
+ id="toButton"
+ label="&toButton.label;"
+ accesskey="&toButton.accesskey;"
+ command="cmd_addrTo"
+ />
+ <button
+ id="ccButton"
+ label="&ccButton.label;"
+ accesskey="&ccButton.accesskey;"
+ command="cmd_addrCc"
+ />
+ <button
+ id="bccButton"
+ label="&bccButton.label;"
+ accesskey="&bccButton.accesskey;"
+ command="cmd_addrBcc"
+ />
+ </vbox>
+ </hbox>
+
+ <separator class="thin" />
+ </vbox>
+</window>
diff --git a/comm/mail/components/addrbook/content/abEditListDialog.xhtml b/comm/mail/components/addrbook/content/abEditListDialog.xhtml
new file mode 100644
index 0000000000..bf775c274b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abEditListDialog.xhtml
@@ -0,0 +1,99 @@
+<?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://messenger/skin/addressbook/cardDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/addressbook/abMailListDialog.dtd">
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&mailListWindowAdd.title;"
+ onload="OnLoadEditList();"
+ ondragover="DragOverAddressListTree(event);"
+ ondrop="DropOnAddressListTree(event);"
+>
+ <dialog id="ablistWindow">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- move needed functions into a single js file -->
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abMailListDialog.js" />
+
+ <vbox id="editlist">
+ <html:div class="grid-two-column-fr grid-items-center">
+ <label
+ control="ListName"
+ value="&ListName.label;"
+ accesskey="&ListName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListNickName"
+ value="&ListNickName.label;"
+ accesskey="&ListNickName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListNickName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListDescription"
+ value="&ListDescription.label;"
+ accesskey="&ListDescription.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListDescription" type="text" class="input-inline" />
+ </hbox>
+ </html:div>
+
+ <spacer style="height: 1em" />
+ <label
+ control="addressCol1#1"
+ value="&AddressTitle.label;"
+ accesskey="&AddressTitle.accesskey;"
+ />
+ <spacer style="height: 0.1em" />
+
+ <richlistbox
+ id="addressingWidget"
+ onclick="awClickEmptySpace(event.target, true)"
+ >
+ <richlistitem class="addressingWidgetItem" allowevents="true">
+ <hbox
+ class="addressingWidgetCell input-container"
+ flex="1"
+ role="combobox"
+ >
+ <html:label for="addressCol1#1" class="person-icon"></html:label>
+ <html:input
+ is="autocomplete-input"
+ id="addressCol1#1"
+ class="plain textbox-addressingWidget uri-element"
+ aria-labelledby="addressCol1#1"
+ autocompletesearch="addrbook ldap"
+ autocompletesearchparam="{}"
+ timeout="300"
+ maxrows="4"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="3"
+ onkeypress="awAbRecipientKeyPress(event, this);"
+ onkeydown="awRecipientKeyDown(event, this);"
+ />
+ </hbox>
+ </richlistitem>
+ </richlistbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/addrbook/content/abMailListDialog.xhtml b/comm/mail/components/addrbook/content/abMailListDialog.xhtml
new file mode 100644
index 0000000000..5b0cf11dda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abMailListDialog.xhtml
@@ -0,0 +1,116 @@
+<?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://messenger/skin/addressbook/cardDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/addressbook/abMailListDialog.dtd">
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&mailListWindowAdd.title;"
+ onload="OnLoadNewMailList();"
+ ondragover="DragOverAddressListTree(event);"
+ ondrop="DropOnAddressListTree(event);"
+>
+ <dialog id="ablistWindow">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- move needed functions into a single js file -->
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abMailListDialog.js" />
+
+ <hbox align="center">
+ <label
+ control="abPopup"
+ value="&addToAddressBook.label;"
+ accesskey="&addToAddressBook.accesskey;"
+ />
+ <menulist
+ is="menulist-addrbooks"
+ id="abPopup"
+ supportsmaillists="true"
+ flex="1"
+ writable="true"
+ />
+ </hbox>
+
+ <spacer style="height: 1em" />
+
+ <vbox id="editlist">
+ <html:div class="grid-two-column-fr grid-items-center">
+ <label
+ control="ListName"
+ value="&ListName.label;"
+ accesskey="&ListName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListNickName"
+ value="&ListNickName.label;"
+ accesskey="&ListNickName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListNickName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListDescription"
+ value="&ListDescription.label;"
+ accesskey="&ListDescription.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListDescription" type="text" class="input-inline" />
+ </hbox>
+ </html:div>
+
+ <spacer style="height: 1em" />
+ <label
+ control="addressCol1#1"
+ value="&AddressTitle.label;"
+ accesskey="&AddressTitle.accesskey;"
+ />
+ <spacer style="height: 0.1em" />
+
+ <richlistbox
+ id="addressingWidget"
+ onclick="awClickEmptySpace(event.target, true)"
+ >
+ <richlistitem class="addressingWidgetItem" allowevents="true">
+ <hbox
+ class="addressingWidgetCell input-container"
+ flex="1"
+ role="combobox"
+ >
+ <html:label for="addressCol1#1" class="person-icon"></html:label>
+ <html:input
+ is="autocomplete-input"
+ id="addressCol1#1"
+ class="plain textbox-addressingWidget uri-element"
+ aria-labelledby="addressCol1#1"
+ autocompletesearch="addrbook ldap"
+ autocompletesearchparam="{}"
+ timeout="300"
+ maxrows="4"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="3"
+ onkeypress="awAbRecipientKeyPress(event, this);"
+ onkeydown="awRecipientKeyDown(event, this);"
+ />
+ </hbox>
+ </richlistitem>
+ </richlistbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/addrbook/content/abSearchDialog.js b/comm/mail/components/addrbook/content/abSearchDialog.js
new file mode 100644
index 0000000000..694d17c12b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abSearchDialog.js
@@ -0,0 +1,408 @@
+/* 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 ../../../../mailnews/addrbook/content/abResultsPane.js */
+/* import-globals-from ../../../../mailnews/base/content/dateFormat.js */
+/* import-globals-from ../../../../mailnews/search/content/searchTerm.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from abCommon.js */
+
+var { encodeABTermValue } = ChromeUtils.import(
+ "resource:///modules/ABQueryUtils.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+
+var searchSessionContractID = "@mozilla.org/messenger/searchSession;1";
+var gSearchSession;
+
+var nsMsgSearchScope = Ci.nsMsgSearchScope;
+var nsMsgSearchOp = Ci.nsMsgSearchOp;
+var nsMsgSearchAttrib = Ci.nsMsgSearchAttrib;
+
+var gStatusText;
+var gSearchBundle;
+var gAddressBookBundle;
+
+var gSearchStopButton;
+var gPropertiesCmd;
+var gComposeCmd;
+var gDeleteCmd;
+var gSearchPhoneticName = "false";
+
+var gSearchAbViewListener = {
+ onSelectionChanged() {
+ UpdateCardView();
+ },
+ onCountChanged(aTotal) {
+ let statusText;
+ if (aTotal == 0) {
+ statusText = gAddressBookBundle.GetStringFromName("noMatchFound");
+ } else {
+ statusText = PluralForm.get(
+ aTotal,
+ gAddressBookBundle.GetStringFromName("matchesFound1")
+ ).replace("#1", aTotal);
+ }
+
+ gStatusText.setAttribute("value", statusText);
+ },
+};
+
+function searchOnLoad() {
+ initializeSearchWidgets();
+ initializeSearchWindowWidgets();
+
+ gSearchBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/search.properties"
+ );
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForSearchButton.accesskey")
+ );
+ gAddressBookBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ gSearchSession = Cc[searchSessionContractID].createInstance(
+ Ci.nsIMsgSearchSession
+ );
+
+ // initialize a flag for phonetic name search
+ gSearchPhoneticName = Services.prefs.getComplexValue(
+ "mail.addr_book.show_phonetic_fields",
+ Ci.nsIPrefLocalizedString
+ ).data;
+
+ if (window.arguments && window.arguments[0]) {
+ SelectDirectory(window.arguments[0].directory);
+ } else {
+ SelectDirectory(
+ document.getElementById("abPopup-menupopup").firstElementChild.value
+ );
+ }
+
+ onMore(null);
+}
+
+function searchOnUnload() {
+ CloseAbView();
+}
+
+function disableCommands() {
+ gPropertiesCmd.setAttribute("disabled", "true");
+ gComposeCmd.setAttribute("disabled", "true");
+ gDeleteCmd.setAttribute("disabled", "true");
+}
+
+function initializeSearchWindowWidgets() {
+ gSearchStopButton = document.getElementById("search-button");
+ gPropertiesCmd = document.getElementById("cmd_properties");
+ gComposeCmd = document.getElementById("cmd_compose");
+ gDeleteCmd = document.getElementById("cmd_deleteCard");
+ gStatusText = document.getElementById("statusText");
+ disableCommands();
+ // matchAll doesn't make sense for address book search
+ hideMatchAllItem();
+}
+
+function onSearchStop() {}
+
+function onAbSearchReset(event) {
+ disableCommands();
+ CloseAbView();
+
+ onReset(event);
+ gStatusText.setAttribute("value", "");
+}
+
+function SelectDirectory(aURI) {
+ // set popup with address book names
+ let abPopup = document.getElementById("abPopup");
+ if (abPopup) {
+ if (aURI) {
+ abPopup.value = aURI;
+ } else {
+ abPopup.selectedIndex = 0;
+ }
+ }
+
+ setSearchScope(GetScopeForDirectoryURI(aURI));
+}
+
+function GetScopeForDirectoryURI(aURI) {
+ let directory;
+ if (aURI && aURI != "moz-abdirectory://?") {
+ directory = MailServices.ab.getDirectory(aURI);
+ }
+ let booleanAnd = gSearchBooleanRadiogroup.selectedItem.value == "and";
+
+ if (directory?.isRemote) {
+ if (booleanAnd) {
+ return nsMsgSearchScope.LDAPAnd;
+ }
+ return nsMsgSearchScope.LDAP;
+ }
+
+ if (booleanAnd) {
+ return nsMsgSearchScope.LocalABAnd;
+ }
+ return nsMsgSearchScope.LocalAB;
+}
+
+function onEnterInSearchTerm() {
+ // on enter
+ // if not searching, start the search
+ // if searching, stop and then start again
+ if (
+ gSearchStopButton.getAttribute("label") ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ onSearch();
+ }
+}
+
+function onSearch() {
+ gStatusText.setAttribute("value", "");
+ disableCommands();
+
+ gSearchSession.clearScopes();
+
+ var currentAbURI = document.getElementById("abPopup").getAttribute("value");
+
+ gSearchSession.addDirectoryScopeTerm(GetScopeForDirectoryURI(currentAbURI));
+ gSearchSession.searchTerms = saveSearchTerms(
+ gSearchSession.searchTerms,
+ gSearchSession
+ );
+
+ let searchUri = "?(";
+ for (let i = 0; i < gSearchSession.searchTerms.length; i++) {
+ let searchTerm = gSearchSession.searchTerms[i];
+ if (!searchTerm.value.str) {
+ continue;
+ }
+ // get the "and" / "or" value from the first term
+ if (i == 0) {
+ if (searchTerm.booleanAnd) {
+ searchUri += "and";
+ } else {
+ searchUri += "or";
+ }
+ }
+
+ var attrs;
+
+ switch (searchTerm.attrib) {
+ case nsMsgSearchAttrib.Name:
+ if (gSearchPhoneticName != "true") {
+ attrs = [
+ "DisplayName",
+ "FirstName",
+ "LastName",
+ "NickName",
+ "_AimScreenName",
+ ];
+ } else {
+ attrs = [
+ "DisplayName",
+ "FirstName",
+ "LastName",
+ "NickName",
+ "_AimScreenName",
+ "PhoneticFirstName",
+ "PhoneticLastName",
+ ];
+ }
+ break;
+ case nsMsgSearchAttrib.DisplayName:
+ attrs = ["DisplayName"];
+ break;
+ case nsMsgSearchAttrib.Email:
+ attrs = ["PrimaryEmail"];
+ break;
+ case nsMsgSearchAttrib.PhoneNumber:
+ attrs = [
+ "HomePhone",
+ "WorkPhone",
+ "FaxNumber",
+ "PagerNumber",
+ "CellularNumber",
+ ];
+ break;
+ case nsMsgSearchAttrib.Organization:
+ attrs = ["Company"];
+ break;
+ case nsMsgSearchAttrib.Department:
+ attrs = ["Department"];
+ break;
+ case nsMsgSearchAttrib.City:
+ attrs = ["WorkCity"];
+ break;
+ case nsMsgSearchAttrib.Street:
+ attrs = ["WorkAddress"];
+ break;
+ case nsMsgSearchAttrib.Nickname:
+ attrs = ["NickName"];
+ break;
+ case nsMsgSearchAttrib.WorkPhone:
+ attrs = ["WorkPhone"];
+ break;
+ case nsMsgSearchAttrib.HomePhone:
+ attrs = ["HomePhone"];
+ break;
+ case nsMsgSearchAttrib.Fax:
+ attrs = ["FaxNumber"];
+ break;
+ case nsMsgSearchAttrib.Pager:
+ attrs = ["PagerNumber"];
+ break;
+ case nsMsgSearchAttrib.Mobile:
+ attrs = ["CellularNumber"];
+ break;
+ case nsMsgSearchAttrib.Title:
+ attrs = ["JobTitle"];
+ break;
+ case nsMsgSearchAttrib.AdditionalEmail:
+ attrs = ["SecondEmail"];
+ break;
+ case nsMsgSearchAttrib.ScreenName:
+ attrs = ["_AimScreenName"];
+ break;
+ default:
+ dump("XXX " + searchTerm.attrib + " not a supported search attr!\n");
+ attrs = ["DisplayName"];
+ break;
+ }
+
+ var opStr;
+
+ switch (searchTerm.op) {
+ case nsMsgSearchOp.Contains:
+ opStr = "c";
+ break;
+ case nsMsgSearchOp.DoesntContain:
+ opStr = "!c";
+ break;
+ case nsMsgSearchOp.Is:
+ opStr = "=";
+ break;
+ case nsMsgSearchOp.Isnt:
+ opStr = "!=";
+ break;
+ case nsMsgSearchOp.BeginsWith:
+ opStr = "bw";
+ break;
+ case nsMsgSearchOp.EndsWith:
+ opStr = "ew";
+ break;
+ case nsMsgSearchOp.SoundsLike:
+ opStr = "~=";
+ break;
+ default:
+ opStr = "c";
+ break;
+ }
+
+ // currently, we can't do "and" and "or" searches at the same time
+ // (it's either all "and"s or all "or"s)
+ var max_attrs = attrs.length;
+
+ for (var j = 0; j < max_attrs; j++) {
+ // append the term(s) to the searchUri
+ searchUri +=
+ "(" +
+ attrs[j] +
+ "," +
+ opStr +
+ "," +
+ encodeABTermValue(searchTerm.value.str) +
+ ")";
+ }
+ }
+
+ searchUri += ")";
+ if (searchUri == "?()") {
+ // Empty search.
+ searchUri = "";
+ }
+ SetAbView(currentAbURI, searchUri, "");
+}
+
+// used to toggle functionality for Search/Stop button.
+function onSearchButton(event) {
+ if (
+ event.target.label ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ }
+}
+
+function GetAbViewListener() {
+ return gSearchAbViewListener;
+}
+
+function onProperties() {
+ if (!gPropertiesCmd.hasAttribute("disabled")) {
+ window.opener.toAddressBook({ action: "display", card: GetSelectedCard() });
+ }
+}
+
+function onCompose() {
+ if (!gComposeCmd.hasAttribute("disabled")) {
+ AbNewMessage();
+ }
+}
+
+function onDelete() {
+ if (!gDeleteCmd.hasAttribute("disabled")) {
+ AbDelete();
+ }
+}
+
+function AbResultsPaneKeyPress(event) {
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_RETURN:
+ onProperties();
+ break;
+ case KeyEvent.DOM_VK_DELETE:
+ case KeyEvent.DOM_VK_BACK_SPACE:
+ onDelete();
+ }
+}
+
+function AbResultsPaneDoubleClick(card) {
+ // Kept for abResultsPane.js.
+}
+
+function UpdateCardView() {
+ disableCommands();
+ let numSelected = GetNumSelectedCards();
+
+ if (!numSelected) {
+ return;
+ }
+
+ if (MailServices.accounts.allIdentities.length > 0) {
+ gComposeCmd.removeAttribute("disabled");
+ }
+
+ gDeleteCmd.removeAttribute("disabled");
+ if (numSelected == 1) {
+ gPropertiesCmd.removeAttribute("disabled");
+ }
+}
diff --git a/comm/mail/components/addrbook/content/abSearchDialog.xhtml b/comm/mail/components/addrbook/content/abSearchDialog.xhtml
new file mode 100644
index 0000000000..75a40df839
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abSearchDialog.xhtml
@@ -0,0 +1,200 @@
+<?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://messenger/skin/searchDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/abResultsPane.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/abSearchDialog.css" type="text/css"?>
+
+<!DOCTYPE window [
+ <!ENTITY % abResultsPaneDTD SYSTEM "chrome://messenger/locale/addressbook/abResultsPane.dtd">
+ %abResultsPaneDTD;
+ <!ENTITY % SearchDialogDTD SYSTEM "chrome://messenger/locale/SearchDialog.dtd">
+ %SearchDialogDTD;
+ <!ENTITY % searchTermDTD SYSTEM "chrome://messenger/locale/searchTermOverlay.dtd">
+ %searchTermDTD;
+]>
+<window id="searchAddressBookWindow"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="searchOnLoad();"
+ onunload="searchOnUnload();"
+ onclose="onSearchStop();"
+ windowtype="mailnews:absearch"
+ title="&abSearchDialogTitle.label;"
+ style="min-width: 52em; min-height: 34em;"
+ lightweightthemes="true"
+ persist="screenX screenY width height sizemode">
+ <html:link rel="localization" href="messenger/addressbook/aboutAddressBook.ftl" />
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://messenger/content/addressbook/abSearchDialog.js"/>
+ <script src="chrome://messenger/content/addressbook/abResultsPane.js"/>
+ <script src="chrome://messenger/content/addressbook/abCommon.js"/>
+ <script src="chrome://messenger/content/searchTerm.js"/>
+ <script src="chrome://messenger/content/searchWidgets.js"/>
+ <script src="chrome://messenger/content/dateFormat.js"/>
+ <script src="chrome://messenger/content/jsTreeView.js"/>
+ <script src="chrome://messenger/content/addressbook/abView.js"/>
+
+ <keyset id="mailKeys">
+ <key key="&closeCmd.key;" modifiers="accel" oncommand="onSearchStop(); window.close();"/>
+ <key keycode="VK_ESCAPE" oncommand="onSearchStop(); window.close();"/>
+ </keyset>
+
+ <commandset id="AbCommands">
+ <command id="cmd_properties" oncommand="onProperties();"/>
+ <command id="cmd_compose" oncommand="onCompose();"/>
+ <command id="cmd_deleteCard" oncommand="onDelete();"/>
+ </commandset>
+
+ <vbox id="searchTerms" class="themeable-brighttext" persist="height">
+ <vbox>
+ <hbox align="center">
+ <label value="&abSearchHeading.label;" accesskey="&abSearchHeading.accesskey;" control="abPopup"/>
+ <menulist is="menulist-addrbooks" id="abPopup"
+ oncommand="SelectDirectory(this.value);"
+ alladdressbooks="true"
+ flex="1"/>
+ <spacer style="flex: 3 3;"/>
+ <button id="search-button" oncommand="onSearchButton(event);" default="true"/>
+ </hbox>
+ <hbox align="center">
+ <spacer flex="1"/>
+ <button label="&resetButton.label;" oncommand="onAbSearchReset(event);" accesskey="&resetButton.accesskey;"/>
+ </hbox>
+ </vbox>
+
+ <hbox flex="1">
+ <vbox id="searchTermListBox" flex="1">
+#include ../../../../mailnews/search/content/searchTerm.inc.xhtml
+ </hbox>
+ </vbox>
+
+ <splitter id="gray_horizontal_splitter" orient="vertical"/>
+
+ <vbox id="searchResults" persist="height">
+ <vbox id="searchResultListBox">
+ <tree id="abResultsTree" flex="1" enableColumnDrag="true" class="plain"
+ onclick="AbResultsPaneOnClick(event);"
+ onkeypress="AbResultsPaneKeyPress(event);"
+ onselect="this.view.selectionChanged();"
+ sortCol="GeneratedName"
+ persist="sortCol">
+
+ <treecols id="abResultsTreeCols">
+ <!-- these column ids must match up to the mork column names, except for GeneratedName and ChatName, see nsIAddrDatabase.idl -->
+ <treecol id="GeneratedName"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&GeneratedName.label;"
+ primary="true"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="PrimaryEmail"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&PrimaryEmail.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="ChatName"
+ hidden="true"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&ChatName.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Company"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&Company.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="NickName"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&NickName.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="SecondEmail"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&SecondEmail.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Department"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&Department.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="JobTitle"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&JobTitle.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="CellularNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&CellularNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="PagerNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&PagerNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="FaxNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&FaxNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="HomePhone"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&HomePhone.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="WorkPhone"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&WorkPhone.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Addrbook"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&Addrbook.label;"/>
+ <!-- LOCALIZATION NOTE: _PhoneticName may be enabled for Japanese builds. -->
+ <!--
+ <treecol id="_PhoneticName"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&_PhoneticName.label;"/>
+ <splitter class="tree-splitter"/>
+ -->
+
+ </treecols>
+ <treechildren ondragstart="abResultsPaneObserver.onDragStart(event);"/>
+ </tree>
+ </vbox>
+ <hbox align="start">
+ <button label="&propertiesButton.label;"
+ accesskey="&propertiesButton.accesskey;"
+ command="cmd_properties"/>
+ <button label="&composeButton.label;"
+ accesskey="&composeButton.accesskey;"
+ command="cmd_compose"/>
+ <button label="&deleteCardButton.label;"
+ accesskey="&deleteCardButton.accesskey;"
+ command="cmd_deleteCard"/>
+ </hbox>
+ </vbox>
+
+ <hbox id="status-bar" class="statusbar chromeclass-status" role="status">
+ <label id="statusText" class="statusbarpanel" crop="end" flex="1"/>
+ </hbox>
+
+</window>
diff --git a/comm/mail/components/addrbook/content/abView-new.js b/comm/mail/components/addrbook/content/abView-new.js
new file mode 100644
index 0000000000..cb3eca969c
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abView-new.js
@@ -0,0 +1,577 @@
+/* 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 PROTO_TREE_VIEW */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+function ABView(
+ directory,
+ searchQuery,
+ searchString,
+ sortColumn,
+ sortDirection
+) {
+ this.__proto__.__proto__ = new PROTO_TREE_VIEW();
+ this.directory = directory;
+ this.searchString = searchString;
+
+ let directories = directory ? [directory] : MailServices.ab.directories;
+ if (searchQuery) {
+ this._searchesInProgress = directories.length;
+ searchQuery = searchQuery.replace(/^\?+/, "");
+ for (let dir of directories) {
+ dir.search(searchQuery, searchString, this);
+ }
+ } else {
+ for (let dir of directories) {
+ for (let card of dir.childCards) {
+ this._rowMap.push(new abViewCard(card, dir));
+ }
+ }
+ }
+ this.sortBy(sortColumn, sortDirection);
+}
+ABView.nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+);
+ABView.NOT_SEARCHING = 0;
+ABView.SEARCHING = 1;
+ABView.SEARCH_COMPLETE = 2;
+ABView.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsITreeView",
+ "nsIAbDirSearchListener",
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ directory: null,
+ _notifications: [
+ "addrbook-directory-deleted",
+ "addrbook-directory-invalidated",
+ "addrbook-contact-created",
+ "addrbook-contact-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-added",
+ "addrbook-list-member-removed",
+ ],
+
+ sortColumn: "",
+ sortDirection: "",
+ collator: new Intl.Collator(undefined, { numeric: true }),
+
+ deleteSelectedCards() {
+ let directoryMap = new Map();
+ for (let i of this._tree.selectedIndices) {
+ let card = this.getCardFromRow(i);
+ let cardSet = directoryMap.get(card.directoryUID);
+ if (!cardSet) {
+ cardSet = new Set();
+ directoryMap.set(card.directoryUID, cardSet);
+ }
+ cardSet.add(card);
+ }
+
+ for (let [directoryUID, cardSet] of directoryMap) {
+ let directory;
+ if (this.directory && this.directory.isMailList) {
+ // Removes cards from the list instead of deleting them.
+ directory = this.directory;
+ } else {
+ directory = MailServices.ab.getDirectoryFromUID(directoryUID);
+ }
+
+ cardSet = [...cardSet];
+ directory.deleteCards(cardSet.filter(card => !card.isMailList));
+ for (let card of cardSet.filter(card => card.isMailList)) {
+ MailServices.ab.deleteAddressBook(card.mailListURI);
+ }
+ }
+ },
+ getCardFromRow(row) {
+ return this._rowMap[row] ? this._rowMap[row].card : null;
+ },
+ getDirectoryFromRow(row) {
+ return this._rowMap[row] ? this._rowMap[row].directory : null;
+ },
+ getIndexForUID(uid) {
+ return this._rowMap.findIndex(row => row.id == uid);
+ },
+ sortBy(sortColumn, sortDirection, resort) {
+ let selectionExists = false;
+ if (this._tree) {
+ let { selectedIndices, currentIndex } = this._tree;
+ selectionExists = selectedIndices.length;
+ // Remember what was selected.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._rowMap[i].wasSelected = selectedIndices.includes(i);
+ this._rowMap[i].wasCurrent = currentIndex == i;
+ }
+ }
+
+ // Do the sort.
+ if (sortColumn == this.sortColumn && !resort) {
+ if (sortDirection == this.sortDirection) {
+ return;
+ }
+ this._rowMap.reverse();
+ } else {
+ this._rowMap.sort((a, b) => {
+ let aText = a.getText(sortColumn);
+ let bText = b.getText(sortColumn);
+ if (sortDirection == "descending") {
+ return this.collator.compare(bText, aText);
+ }
+ return this.collator.compare(aText, bText);
+ });
+ }
+
+ // Restore what was selected.
+ if (this._tree) {
+ this._tree.reset();
+ if (selectionExists) {
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._tree.toggleSelectionAtIndex(
+ i,
+ this._rowMap[i].wasSelected,
+ true
+ );
+ }
+ // Can't do this until updating the selection is finished.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ if (this._rowMap[i].wasCurrent) {
+ this._tree.currentIndex = i;
+ break;
+ }
+ }
+ this.selectionChanged();
+ }
+ }
+ this.sortColumn = sortColumn;
+ this.sortDirection = sortDirection;
+ },
+ get searchState() {
+ if (this._searchesInProgress === undefined) {
+ return ABView.NOT_SEARCHING;
+ }
+ return this._searchesInProgress ? ABView.SEARCHING : ABView.SEARCH_COMPLETE;
+ },
+
+ // nsITreeView
+
+ selectionChanged() {},
+ setTree(tree) {
+ this._tree = tree;
+ for (let topic of this._notifications) {
+ if (tree) {
+ Services.obs.addObserver(this, topic, true);
+ } else {
+ try {
+ Services.obs.removeObserver(this, topic);
+ } catch (ex) {
+ // `this` might not be a valid observer.
+ }
+ }
+ }
+ Services.prefs.addObserver("mail.addr_book.lastnamefirst", this, true);
+ },
+
+ // nsIAbDirSearchListener
+
+ onSearchFoundCard(card) {
+ // Instead of duplicating the insertion code below, just call it.
+ this.observe(card, "addrbook-contact-created", this.directory?.UID);
+ },
+ onSearchFinished(status, complete, secInfo, location) {
+ // Special handling for Bad Cert errors.
+ let offerCertException = false;
+ try {
+ // If code is not an NSS error, getErrorClass() will fail.
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let errorClass = nssErrorsService.getErrorClass(status);
+ if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ offerCertException = true;
+ }
+ } catch (ex) {}
+
+ if (offerCertException) {
+ // Give the user the option of adding an exception for the bad cert.
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location,
+ };
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ // params.exceptionAdded will be set if the user added an exception.
+ }
+
+ this._searchesInProgress--;
+ if (!this._searchesInProgress && this._tree) {
+ this._tree.dispatchEvent(new CustomEvent("searchstatechange"));
+ }
+ },
+
+ // nsIObserver
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ ABView.nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+ for (let card of this._rowMap) {
+ delete card._getTextCache.GeneratedName;
+ }
+ if (this._tree) {
+ if (this.sortColumn == "GeneratedName") {
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ } else {
+ // Remember what was selected.
+ let { selectedIndices, currentIndex } = this._tree;
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._rowMap[i].wasSelected = selectedIndices.includes(i);
+ this._rowMap[i].wasCurrent = currentIndex == i;
+ }
+
+ this._tree.reset();
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._tree.toggleSelectionAtIndex(
+ i,
+ this._rowMap[i].wasSelected,
+ true
+ );
+ }
+ // Can't do this until updating the selection is finished.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ if (this._rowMap[i].wasCurrent) {
+ this._tree.currentIndex = i;
+ break;
+ }
+ }
+ }
+ }
+ return;
+ }
+
+ if (this.directory && data && this.directory.UID != data) {
+ return;
+ }
+
+ // If we make it here, we're in the root directory, or the right directory.
+
+ switch (topic) {
+ case "addrbook-directory-deleted": {
+ if (this.directory) {
+ break;
+ }
+
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (this._rowMap[i].directory.UID == subject.UID) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ case "addrbook-directory-invalidated":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (subject == this.directory) {
+ this._rowMap.length = 0;
+ for (let card of this.directory.childCards) {
+ this._rowMap.push(new abViewCard(card, this.directory));
+ }
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ }
+ break;
+ case "addrbook-list-created": {
+ let parentDir = MailServices.ab.getDirectoryFromUID(data);
+ // `subject` is an nsIAbDirectory, make it the matching card instead.
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ for (let card of parentDir.childCards) {
+ if (card.UID == subject.UID) {
+ subject = card;
+ break;
+ }
+ }
+ }
+ // Falls through.
+ case "addrbook-list-member-added":
+ case "addrbook-contact-created":
+ if (topic == "addrbook-list-member-added" && !this.directory) {
+ break;
+ }
+
+ subject.QueryInterface(Ci.nsIAbCard);
+ let viewCard = new abViewCard(subject);
+ let sortText = viewCard.getText(this.sortColumn);
+ let addIndex = null;
+ for (let i = 0; addIndex === null && i < this._rowMap.length; i++) {
+ let comparison = this.collator.compare(
+ sortText,
+ this._rowMap[i].getText(this.sortColumn)
+ );
+ if (
+ (comparison < 0 && this.sortDirection == "ascending") ||
+ (comparison >= 0 && this.sortDirection == "descending")
+ ) {
+ addIndex = i;
+ }
+ }
+ if (addIndex === null) {
+ addIndex = this._rowMap.length;
+ }
+ this._rowMap.splice(addIndex, 0, viewCard);
+ if (this._tree) {
+ this._tree.rowCountChanged(addIndex, 1);
+ }
+ break;
+
+ case "addrbook-list-updated": {
+ let parentDir = this.directory;
+ if (!parentDir) {
+ parentDir = MailServices.ab.getDirectoryFromUID(data);
+ }
+ // `subject` is an nsIAbDirectory, make it the matching card instead.
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ for (let card of parentDir.childCards) {
+ if (card.UID == subject.UID) {
+ subject = card;
+ break;
+ }
+ }
+ }
+ // Falls through.
+ case "addrbook-contact-updated": {
+ subject.QueryInterface(Ci.nsIAbCard);
+ let needsSort = false;
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (
+ this._rowMap[i].card.equals(subject) &&
+ this._rowMap[i].card.directoryUID == subject.directoryUID
+ ) {
+ this._rowMap.splice(i, 1, new abViewCard(subject));
+ needsSort = true;
+ }
+ }
+ if (needsSort) {
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ }
+ break;
+ }
+
+ case "addrbook-list-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (this._rowMap[i].card.UID == subject.UID) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ case "addrbook-list-member-removed":
+ if (!this.directory) {
+ break;
+ }
+ // Falls through.
+ case "addrbook-contact-deleted": {
+ subject.QueryInterface(Ci.nsIAbCard);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (
+ this._rowMap[i].card.equals(subject) &&
+ this._rowMap[i].card.directoryUID == subject.directoryUID
+ ) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ }
+ },
+};
+
+/**
+ * Representation of a card, used as a table row in ABView.
+ *
+ * @param {nsIAbCard} card - contact or mailing list card for this row.
+ * @param {nsIAbDirectory} [directoryHint] - the directory containing card,
+ * if available (this is a performance optimization only).
+ */
+function abViewCard(card, directoryHint) {
+ this.card = card;
+ this._getTextCache = {};
+ if (directoryHint) {
+ this._directory = directoryHint;
+ } else {
+ this._directory = MailServices.ab.getDirectoryFromUID(
+ this.card.directoryUID
+ );
+ }
+}
+abViewCard.listFormatter = new Services.intl.ListFormat(
+ Services.appinfo.name == "xpcshell" ? "en-US" : undefined,
+ { type: "unit" }
+);
+abViewCard.prototype = {
+ _getText(columnID) {
+ try {
+ let { getProperty, supportsVCard, vCardProperties } = this.card;
+
+ if (this.card.isMailList) {
+ if (columnID == "GeneratedName") {
+ return this.card.displayName;
+ }
+ if (["NickName", "Notes"].includes(columnID)) {
+ return getProperty(columnID, "");
+ }
+ if (columnID == "addrbook") {
+ return MailServices.ab.getDirectoryFromUID(this.card.directoryUID)
+ .dirName;
+ }
+ return "";
+ }
+
+ switch (columnID) {
+ case "addrbook":
+ return this._directory.dirName;
+ case "GeneratedName":
+ return this.card.generateName(ABView.nameFormat);
+ case "EmailAddresses":
+ return abViewCard.listFormatter.format(this.card.emailAddresses);
+ case "PhoneNumbers": {
+ let phoneNumbers;
+ if (supportsVCard) {
+ phoneNumbers = vCardProperties.getAllValues("tel");
+ } else {
+ phoneNumbers = [
+ getProperty("WorkPhone", ""),
+ getProperty("HomePhone", ""),
+ getProperty("CellularNumber", ""),
+ getProperty("FaxNumber", ""),
+ getProperty("PagerNumber", ""),
+ ];
+ }
+ return abViewCard.listFormatter.format(phoneNumbers.filter(Boolean));
+ }
+ case "Addresses": {
+ let addresses;
+ if (supportsVCard) {
+ addresses = vCardProperties
+ .getAllValues("adr")
+ .map(v => v.join(" ").trim());
+ } else {
+ addresses = [
+ this.formatAddress("Work"),
+ this.formatAddress("Home"),
+ ];
+ }
+ return abViewCard.listFormatter.format(addresses.filter(Boolean));
+ }
+ case "JobTitle":
+ case "Title":
+ if (supportsVCard) {
+ return vCardProperties.getFirstValue("title");
+ }
+ return getProperty("JobTitle", "");
+ case "Department":
+ if (supportsVCard) {
+ let vCardValue = vCardProperties.getFirstValue("org");
+ if (Array.isArray(vCardValue)) {
+ return vCardValue[1] || "";
+ }
+ return "";
+ }
+ return getProperty(columnID, "");
+ case "Company":
+ case "Organization":
+ if (supportsVCard) {
+ let vCardValue = vCardProperties.getFirstValue("org");
+ if (Array.isArray(vCardValue)) {
+ return vCardValue[0] || "";
+ }
+ return vCardValue;
+ }
+ return getProperty("Company", "");
+ default:
+ return getProperty(columnID, "");
+ }
+ } catch (ex) {
+ return "";
+ }
+ },
+ getText(columnID) {
+ if (!(columnID in this._getTextCache)) {
+ this._getTextCache[columnID] = this._getText(columnID)?.trim() ?? "";
+ }
+ return this._getTextCache[columnID];
+ },
+ get id() {
+ return this.card.UID;
+ },
+ get open() {
+ return false;
+ },
+ get level() {
+ return 0;
+ },
+ get children() {
+ return [];
+ },
+ getProperties() {
+ return "";
+ },
+ get directory() {
+ return this._directory;
+ },
+
+ /**
+ * Creates a string representation of an address from card properties.
+ *
+ * @param {"Work"|"Home"} prefix
+ * @returns {string}
+ */
+ formatAddress(prefix) {
+ return Array.from(
+ ["Address", "Address2", "City", "State", "ZipCode", "Country"],
+ field => this.card.getProperty(`${prefix}${field}`, "")
+ )
+ .join(" ")
+ .trim();
+ },
+};
diff --git a/comm/mail/components/addrbook/content/aboutAddressBook.js b/comm/mail/components/addrbook/content/aboutAddressBook.js
new file mode 100644
index 0000000000..8f0eeca693
--- /dev/null
+++ b/comm/mail/components/addrbook/content/aboutAddressBook.js
@@ -0,0 +1,4445 @@
+/* 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 ABView */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "ABQueryUtils", function () {
+ return ChromeUtils.import("resource:///modules/ABQueryUtils.jsm");
+});
+XPCOMUtils.defineLazyGetter(this, "AddrBookUtils", function () {
+ return ChromeUtils.import("resource:///modules/AddrBookUtils.jsm");
+});
+
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ AddrBookUtils: "resource:///modules/AddrBookUtils.jsm",
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalMetronome: "resource:///modules/CalMetronome.jsm",
+ CardDAVDirectory: "resource:///modules/CardDAVDirectory.jsm",
+ GlodaMsgSearcher: "resource:///modules/gloda/GlodaMsgSearcher.jsm",
+ ICAL: "resource:///modules/calendar/Ical.jsm",
+ MailE10SUtils: "resource:///modules/MailE10SUtils.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+ VCardPropertyEntry: "resource:///modules/VCardUtils.jsm",
+});
+XPCOMUtils.defineLazyGetter(this, "SubDialog", function () {
+ const { SubDialogManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/SubDialog.sys.mjs"
+ );
+ return new SubDialogManager({
+ dialogStack: document.getElementById("dialogStack"),
+ dialogTemplate: document.getElementById("dialogTemplate"),
+ dialogOptions: {
+ styleSheets: [
+ "chrome://messenger/skin/preferences/dialog.css",
+ "chrome://messenger/skin/shared/preferences/subdialog.css",
+ "chrome://messenger/skin/abFormFields.css",
+ ],
+ resizeCallback: ({ title, frame }) => {
+ UIFontSize.registerWindow(frame.contentWindow);
+
+ // Resize the dialog to fit the content with edited font size.
+ requestAnimationFrame(() => {
+ let dialogs = frame.ownerGlobal.SubDialog._dialogs;
+ let dialog = dialogs.find(
+ d => d._frame.contentDocument == frame.contentDocument
+ );
+ if (dialog) {
+ UIFontSize.resizeSubDialog(dialog);
+ }
+ });
+ },
+ },
+ });
+});
+
+UIDensity.registerWindow(window);
+UIFontSize.registerWindow(window);
+
+var booksList;
+
+window.addEventListener("load", () => {
+ document
+ .getElementById("toolbarCreateBook")
+ .addEventListener("command", event => {
+ let type = event.target.value || "JS_DIRECTORY_TYPE";
+ createBook(Ci.nsIAbManager[type]);
+ });
+ document
+ .getElementById("toolbarCreateContact")
+ .addEventListener("command", () => createContact());
+ document
+ .getElementById("toolbarCreateList")
+ .addEventListener("command", () => createList());
+ document
+ .getElementById("toolbarImport")
+ .addEventListener("command", () => importBook());
+
+ document.getElementById("bookContext").addEventListener("command", event => {
+ switch (event.target.id) {
+ case "bookContextProperties":
+ booksList.showPropertiesOfSelected();
+ break;
+ case "bookContextSynchronize":
+ booksList.synchronizeSelected();
+ break;
+ case "bookContextPrint":
+ booksList.printSelected();
+ break;
+ case "bookContextExport":
+ booksList.exportSelected();
+ break;
+ case "bookContextDelete":
+ booksList.deleteSelected();
+ break;
+ case "bookContextRemove":
+ booksList.deleteSelected();
+ break;
+ case "bookContextStartupDefault":
+ if (event.target.hasAttribute("checked")) {
+ booksList.setSelectedAsStartupDefault();
+ } else {
+ booksList.clearStartupDefault();
+ }
+ break;
+ }
+ });
+
+ booksList = document.getElementById("books");
+ cardsPane.init();
+ detailsPane.init();
+ photoDialog.init();
+
+ setKeyboardShortcuts();
+
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ let startupURI = Services.prefs.getStringPref(
+ "mail.addr_book.view.startupURI",
+ ""
+ );
+ if (startupURI) {
+ for (let index = 0; index < booksList.rows.length; index++) {
+ let row = booksList.rows[index];
+ if (row._book?.URI == startupURI || row._list?.URI == startupURI) {
+ booksList.selectedIndex = index;
+ break;
+ }
+ }
+ }
+
+ if (booksList.selectedIndex == 0) {
+ // Index 0 was selected before we started listening.
+ booksList.dispatchEvent(new CustomEvent("select"));
+ }
+
+ cardsPane.searchInput.focus();
+
+ window.dispatchEvent(new CustomEvent("about-addressbook-ready"));
+});
+
+window.addEventListener("unload", () => {
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ if (!Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ let pref = "mail.addr_book.view.startupURI";
+ if (booksList.selectedIndex === 0) {
+ Services.prefs.clearUserPref(pref);
+ } else {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let directory = row._book || row._list;
+ Services.prefs.setCharPref(pref, directory.URI);
+ }
+ }
+
+ // Disconnect the view (if there is one) and tree, so that the view cleans
+ // itself up and stops listening for observer service notifications.
+ cardsPane.cardsList.view = null;
+ detailsPane.uninit();
+});
+
+window.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used.
+ if (
+ event.key == " " &&
+ detailsPane.isEditing &&
+ document.activeElement.tagName == "body"
+ ) {
+ event.preventDefault();
+ }
+});
+
+/**
+ * Add a keydown document event listener for international keyboard shortcuts.
+ */
+async function setKeyboardShortcuts() {
+ let [newContactKey] = await document.l10n.formatValues([
+ { id: "about-addressbook-new-contact-key" },
+ ]);
+
+ document.addEventListener("keydown", event => {
+ if (
+ !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) ||
+ ["Shift", "Control", "Meta"].includes(event.key)
+ ) {
+ return;
+ }
+
+ // Always use lowercase to compare the key and avoid OS inconsistencies:
+ // For Cmd/Ctrl+Shift+A, on Mac, key = "a" vs. on Windows/Linux, key = "A".
+ switch (event.key.toLowerCase()) {
+ // Always prevent the default behavior of the keydown if we intercepted
+ // the key in order to avoid triggering OS specific shortcuts.
+ case newContactKey.toLowerCase(): {
+ // Ctrl/Cmd+n.
+ event.preventDefault();
+ if (!detailsPane.isEditing) {
+ createContact();
+ }
+ break;
+ }
+ }
+ });
+}
+
+/**
+ * Called on load from `toAddressBook` to create, display or edit a card.
+ *
+ * @param {"create"|"display"|"edit"|"create_ab_*"} action - What to do with the args given.
+ * @param {?string} address - Create a new card with this email address.
+ * @param {?string} vCard - Create a new card from this vCard.
+ * @param {?nsIAbCard} card - Display or edit this card.
+ */
+function externalAction({ action, address, card, vCard } = {}) {
+ if (action == "create") {
+ if (address) {
+ detailsPane.editNewContact(
+ `BEGIN:VCARD\r\nEMAIL:${address}\r\nEND:VCARD\r\n`
+ );
+ } else {
+ detailsPane.editNewContact(vCard);
+ }
+ } else if (action == "display" || action == "edit") {
+ if (!card || !card.directoryUID) {
+ return;
+ }
+
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ if (!book) {
+ return;
+ }
+
+ booksList.selectedIndex = booksList.getIndexForUID(card.directoryUID);
+ cardsPane.cardsList.selectedIndex = cardsPane.cardsList.view.getIndexForUID(
+ card.UID
+ );
+
+ if (action == "edit" && book && !book.readOnly) {
+ detailsPane.editCurrentContact();
+ }
+ } else if (action == "print") {
+ if (document.activeElement == booksList) {
+ booksList.printSelected();
+ } else {
+ cardsPane.printSelected();
+ }
+ } else if (action == "create_ab_JS") {
+ createBook();
+ } else if (action == "create_ab_CARDDAV") {
+ createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ } else if (action == "create_ab_LDAP") {
+ createBook(Ci.nsIAbManager.LDAP_DIRECTORY_TYPE);
+ }
+}
+
+/**
+ * Show UI to create a new address book of the type specified.
+ *
+ * @param {integer} [type=Ci.nsIAbManager.JS_DIRECTORY_TYPE] - One of the
+ * nsIAbManager directory type constants.
+ */
+function createBook(type = Ci.nsIAbManager.JS_DIRECTORY_TYPE) {
+ const typeURLs = {
+ [Ci.nsIAbManager.LDAP_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/pref-directory-add.xhtml",
+ [Ci.nsIAbManager.JS_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml",
+ [Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml",
+ };
+
+ let url = typeURLs[type];
+ if (!url) {
+ throw new Components.Exception(
+ `Unexpected type: ${type}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let params = {};
+ SubDialog.open(
+ url,
+ {
+ features: "resizable=no",
+ closedCallback: () => {
+ if (params.newDirectoryUID) {
+ booksList.selectedIndex = booksList.getIndexForUID(
+ params.newDirectoryUID
+ );
+ booksList.focus();
+ }
+ },
+ },
+ params
+ );
+}
+
+/**
+ * Show UI to create a new contact in the current address book.
+ */
+function createContact() {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+
+ if (bookUID) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ }
+
+ detailsPane.editNewContact();
+}
+
+/**
+ * Show UI to create a new list in the current address book.
+ * For now this loads the old list UI, the intention is to replace it.
+ *
+ * @param {nsIAbCard[]} cards - The contacts, if any, to add to the list.
+ */
+function createList(cards) {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+
+ let params = { cards };
+ if (bookUID) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ if (!book.supportsMailingLists) {
+ throw new Components.Exception(
+ "Address book does not support lists",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ params.selectedAB = book.URI;
+ }
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml",
+ {
+ features: "resizable=no",
+ closedCallback: () => {
+ if (params.newListUID) {
+ booksList.selectedIndex = booksList.getIndexForUID(params.newListUID);
+ booksList.focus();
+ }
+ },
+ },
+ params
+ );
+}
+
+/**
+ * Import an address book from a file. This shows the generic Thunderbird
+ * import wizard, which isn't ideal but better than nothing.
+ */
+function importBook() {
+ let createdDirectory;
+ let observer = function (subject) {
+ // It might be possible for more than one directory to be imported, select
+ // the first one.
+ if (!createdDirectory) {
+ createdDirectory = subject.QueryInterface(Ci.nsIAbDirectory);
+ }
+ };
+
+ Services.obs.addObserver(observer, "addrbook-directory-created");
+ window.browsingContext.topChromeWindow.toImport("addressBook");
+ Services.obs.removeObserver(observer, "addrbook-directory-created");
+
+ // Select the directory after the import UI closes, so the user sees the change.
+ if (createdDirectory) {
+ booksList.selectedIndex = booksList.getIndexForUID(createdDirectory.UID);
+ }
+}
+
+/**
+ * Sets the total count for the current selected address book at the bottom
+ * of the address book view.
+ */
+async function updateAddressBookCount() {
+ let cardCount = document.getElementById("cardCount");
+ let { rowCount: count, directory } = cardsPane.cardsList.view;
+
+ if (directory) {
+ document.l10n.setAttributes(cardCount, "about-addressbook-card-count", {
+ name: directory.dirName,
+ count,
+ });
+ } else {
+ document.l10n.setAttributes(cardCount, "about-addressbook-card-count-all", {
+ count,
+ });
+ }
+}
+
+/**
+ * Update the shared splitter between the cardsPane and detailsPane in order to
+ * properly set its properties to handle the correct pane based on the layout.
+ *
+ * @param {boolean} isTableLayout - If the current body layout is a table.
+ */
+function updateSharedSplitter(isTableLayout) {
+ let splitter = document.getElementById("sharedSplitter");
+ splitter.resizeDirection = isTableLayout ? "vertical" : "horizontal";
+ splitter.resizeElement = document.getElementById(
+ isTableLayout ? "detailsPane" : "cardsPane"
+ );
+
+ splitter.isCollapsed =
+ document.getElementById("detailsPane").hidden && isTableLayout;
+}
+
+// Books
+
+/**
+ * The list of address books.
+ *
+ * @augments {TreeListbox}
+ */
+class AbTreeListbox extends customElements.get("tree-listbox") {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+ this.setAttribute("is", "ab-tree-listbox");
+
+ this.addEventListener("select", this);
+ this.addEventListener("collapsed", this);
+ this.addEventListener("expanded", this);
+ this.addEventListener("keypress", this);
+ this.addEventListener("contextmenu", this);
+ this.addEventListener("dragover", this);
+ this.addEventListener("dragleave", this);
+ this.addEventListener("drop", this);
+
+ for (let book of MailServices.ab.directories) {
+ this.appendChild(this._createBookRow(book));
+ }
+
+ this._abObserver.observe = this._abObserver.observe.bind(this);
+ for (let topic of this._abObserver._notifications) {
+ Services.obs.addObserver(this._abObserver, topic, true);
+ }
+
+ window.addEventListener("unload", this);
+
+ // Add event listener to update the total count of the selected address
+ // book.
+ this.addEventListener("select", e => {
+ updateAddressBookCount();
+ });
+
+ // Row 0 is the "All Address Books" item.
+ document.body.classList.toggle("all-ab-selected", this.selectedIndex === 0);
+ }
+
+ destroy() {
+ this.removeEventListener("select", this);
+ this.removeEventListener("collapsed", this);
+ this.removeEventListener("expanded", this);
+ this.removeEventListener("keypress", this);
+ this.removeEventListener("contextmenu", this);
+ this.removeEventListener("dragover", this);
+ this.removeEventListener("dragleave", this);
+ this.removeEventListener("drop", this);
+
+ for (let topic of this._abObserver._notifications) {
+ Services.obs.removeObserver(this._abObserver, topic);
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+
+ switch (event.type) {
+ case "select":
+ this._onSelect(event);
+ break;
+ case "collapsed":
+ this._onCollapsed(event);
+ break;
+ case "expanded":
+ this._onExpanded(event);
+ break;
+ case "keypress":
+ this._onKeyPress(event);
+ break;
+ case "contextmenu":
+ this._onContextMenu(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "dragleave":
+ this._clearDropTarget(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "unload":
+ this.destroy();
+ break;
+ }
+ }
+
+ _createBookRow(book) {
+ let row = document
+ .getElementById("bookRow")
+ .content.firstElementChild.cloneNode(true);
+ row.id = `book-${book.UID}`;
+ row.setAttribute("aria-label", book.dirName);
+ row.title = book.dirName;
+ if (
+ Services.xulStore.getValue(cardsPane.URL, row.id, "collapsed") == "true"
+ ) {
+ row.classList.add("collapsed");
+ }
+ if (book.isRemote) {
+ row.classList.add("remote");
+ }
+ if (book.readOnly) {
+ row.classList.add("readOnly");
+ }
+ if (
+ ["ldap_2.servers.history", "ldap_2.servers.pab"].includes(book.dirPrefId)
+ ) {
+ row.classList.add("noDelete");
+ }
+ if (book.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) {
+ row.classList.add("carddav");
+ }
+ row.dataset.uid = book.UID;
+ row._book = book;
+ row.querySelector("span").textContent = book.dirName;
+
+ for (let list of book.childNodes) {
+ row.querySelector("ul").appendChild(this._createListRow(book.UID, list));
+ }
+ return row;
+ }
+
+ _createListRow(bookUID, list) {
+ let row = document
+ .getElementById("listRow")
+ .content.firstElementChild.cloneNode(true);
+ row.id = `list-${list.UID}`;
+ row.setAttribute("aria-label", list.dirName);
+ row.title = list.dirName;
+ row.dataset.uid = list.UID;
+ row.dataset.book = bookUID;
+ row._list = list;
+ row.querySelector("span").textContent = list.dirName;
+ return row;
+ }
+
+ /**
+ * Get the index of the row representing a book or list.
+ *
+ * @param {string|null} uid - The UID of the book or list to find, or null
+ * for All Address Books.
+ * @returns {integer} - Index of the book or list.
+ */
+ getIndexForUID(uid) {
+ if (!uid) {
+ return 0;
+ }
+ return this.rows.findIndex(r => r.dataset.uid == uid);
+ }
+
+ /**
+ * Get the row representing a book or list.
+ *
+ * @param {string|null} uid - The UID of the book or list to find, or null
+ * for All Address Books.
+ * @returns {HTMLLIElement} - Row of the book or list.
+ */
+ getRowForUID(uid) {
+ if (!uid) {
+ return this.firstElementChild;
+ }
+ return this.querySelector(`li[data-uid="${uid}"]`);
+ }
+
+ /**
+ * Show UI to modify the selected address book or list.
+ */
+ showPropertiesOfSelected() {
+ if (this.selectedIndex === 0) {
+ throw new Components.Exception(
+ "Cannot modify the All Address Books item",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let row = this.rows[this.selectedIndex];
+
+ if (row.classList.contains("listRow")) {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.book);
+ let list = book.childNodes.find(l => l.UID == row.dataset.uid);
+
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml",
+ { features: "resizable=no" },
+ { listURI: list.URI }
+ );
+ return;
+ }
+
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+
+ SubDialog.open(
+ book.propertiesChromeURI,
+ { features: "resizable=no" },
+ { selectedDirectory: book }
+ );
+ }
+
+ /**
+ * Synchronize the selected address book. (CardDAV only.)
+ */
+ synchronizeSelected() {
+ let row = this.rows[this.selectedIndex];
+ if (!row.classList.contains("carddav")) {
+ throw new Components.Exception(
+ "Attempting to synchronize a non-CardDAV book.",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let directory = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+ directory = CardDAVDirectory.forFile(directory.fileName);
+ directory.syncWithServer().then(res => {
+ updateAddressBookCount();
+ });
+ }
+
+ /**
+ * Print the selected address book.
+ */
+ printSelected() {
+ if (this.selectedIndex === 0) {
+ printHandler.printDirectory();
+ return;
+ }
+
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("listRow")) {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.book);
+ let list = book.childNodes.find(l => l.UID == row.dataset.uid);
+ printHandler.printDirectory(list);
+ } else {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+ printHandler.printDirectory(book);
+ }
+ }
+
+ /**
+ * Export the selected address book to a file.
+ */
+ exportSelected() {
+ if (this.selectedIndex == 0) {
+ return;
+ }
+
+ let row = this.getRowAtIndex(this.selectedIndex);
+ let directory = row._book || row._list;
+ AddrBookUtils.exportDirectory(directory);
+ }
+
+ /**
+ * Prompt the user and delete the selected address book.
+ */
+ async deleteSelected() {
+ if (this.selectedIndex === 0) {
+ throw new Components.Exception(
+ "Cannot delete the All Address Books item",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("noDelete")) {
+ throw new Components.Exception(
+ "Refusing to delete a built-in address book",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let action, name, uri;
+ if (row.classList.contains("listRow")) {
+ action = "delete-lists";
+ name = row._list.dirName;
+ uri = row._list.URI;
+ } else {
+ if (
+ [
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE,
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE,
+ ].includes(row._book.dirType)
+ ) {
+ action = "remove-remote-book";
+ } else {
+ action = "delete-book";
+ }
+
+ name = row._book.dirName;
+ uri = row._book.URI;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-confirm-${action}-title`, args: { count: 1 } },
+ {
+ id: `about-addressbook-confirm-${action}`,
+ args: { name, count: 1 },
+ },
+ ]);
+
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) === 0
+ ) {
+ MailServices.ab.deleteAddressBook(uri);
+ }
+ }
+
+ /**
+ * Set the selected directory to be the one opened when the page opens.
+ */
+ setSelectedAsStartupDefault() {
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ Services.prefs.setBoolPref("mail.addr_book.view.startupURIisDefault", true);
+ if (this.selectedIndex === 0) {
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURI");
+ return;
+ }
+
+ let row = this.rows[this.selectedIndex];
+ let directory = row._book || row._list;
+ Services.prefs.setStringPref(
+ "mail.addr_book.view.startupURI",
+ directory.URI
+ );
+ }
+
+ /**
+ * Clear the directory to be opened when the page opens. Instead, the
+ * last-selected directory will be opened.
+ */
+ clearStartupDefault() {
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ false
+ );
+ }
+
+ _onSelect() {
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("listRow")) {
+ cardsPane.displayList(row.dataset.book, row.dataset.uid);
+ } else {
+ cardsPane.displayBook(row.dataset.uid);
+ }
+
+ // Row 0 is the "All Address Books" item.
+ if (this.selectedIndex === 0) {
+ document.getElementById("toolbarCreateContact").disabled = false;
+ document.getElementById("toolbarCreateList").disabled = false;
+ document.body.classList.add("all-ab-selected");
+ } else {
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+
+ document.getElementById("toolbarCreateContact").disabled = book.readOnly;
+ document.getElementById("toolbarCreateList").disabled =
+ book.readOnly || !book.supportsMailingLists;
+ document.body.classList.remove("all-ab-selected");
+ }
+ }
+
+ _onCollapsed(event) {
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ event.target.id,
+ "collapsed",
+ "true"
+ );
+ }
+
+ _onExpanded(event) {
+ Services.xulStore.removeValue(cardsPane.URL, event.target.id, "collapsed");
+ }
+
+ _onKeyPress(event) {
+ if (event.altKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Delete":
+ this.deleteSelected();
+ break;
+ }
+ }
+
+ _onClick(event) {
+ super._onClick(event);
+
+ // Only handle left-clicks. Right-clicking on the menu button will cause
+ // the menu to appear anyway, and other buttons can be ignored.
+ if (
+ event.button !== 0 ||
+ !event.target.closest(".bookRow-menu, .listRow-menu")
+ ) {
+ return;
+ }
+
+ this._showContextMenu(event);
+ }
+
+ _onContextMenu(event) {
+ this._showContextMenu(event);
+ }
+
+ _onDragOver(event) {
+ let cards = event.dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ if (!cards) {
+ return;
+ }
+ if (cards.some(c => c.isMailList)) {
+ return;
+ }
+
+ // TODO: Handle dropping a vCard here.
+
+ let row = event.target.closest("li");
+ if (!row || row.classList.contains("readOnly")) {
+ return;
+ }
+
+ let rowIsList = row.classList.contains("listRow");
+ event.dataTransfer.effectAllowed = rowIsList ? "link" : "copyMove";
+
+ if (rowIsList) {
+ let bookUID = row.dataset.book;
+ for (let card of cards) {
+ if (card.directoryUID != bookUID) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = "link";
+ } else {
+ let bookUID = row.dataset.uid;
+ for (let card of cards) {
+ // Prevent dropping a card where it already is.
+ if (card.directoryUID == bookUID) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = event.ctrlKey ? "copy" : "move";
+ }
+
+ this._clearDropTarget();
+ row.classList.add("drop-target");
+
+ event.preventDefault();
+ }
+
+ _clearDropTarget() {
+ this.querySelector(".drop-target")?.classList.remove("drop-target");
+ }
+
+ _onDrop(event) {
+ this._clearDropTarget();
+ if (event.dataTransfer.dropEffect == "none") {
+ // Somehow this is possible. It should not be possible.
+ return;
+ }
+
+ let cards = event.dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ let row = event.target.closest("li");
+
+ if (row.classList.contains("listRow")) {
+ for (let card of cards) {
+ row._list.addCard(card);
+ }
+ } else if (event.dataTransfer.dropEffect == "copy") {
+ for (let card of cards) {
+ row._book.dropCard(card, true);
+ }
+ } else {
+ let booksMap = new Map();
+ let bookUID = row.dataset.uid;
+ for (let card of cards) {
+ if (bookUID == card.directoryUID) {
+ continue;
+ }
+ row._book.dropCard(card, false);
+ let bookSet = booksMap.get(card.directoryUID);
+ if (!bookSet) {
+ bookSet = new Set();
+ booksMap.set(card.directoryUID, bookSet);
+ }
+ bookSet.add(card);
+ }
+ for (let [uid, bookSet] of booksMap) {
+ MailServices.ab.getDirectoryFromUID(uid).deleteCards([...bookSet]);
+ }
+ }
+
+ event.preventDefault();
+ }
+
+ _showContextMenu(event) {
+ let row =
+ event.target == this
+ ? this.rows[this.selectedIndex]
+ : event.target.closest("li");
+ if (!row) {
+ return;
+ }
+
+ let popup = document.getElementById("bookContext");
+ let synchronizeItem = document.getElementById("bookContextSynchronize");
+ let exportItem = document.getElementById("bookContextExport");
+ let deleteItem = document.getElementById("bookContextDelete");
+ let removeItem = document.getElementById("bookContextRemove");
+ let startupDefaultItem = document.getElementById(
+ "bookContextStartupDefault"
+ );
+
+ let isDefault = Services.prefs.getBoolPref(
+ "mail.addr_book.view.startupURIisDefault"
+ );
+
+ this.selectedIndex = this.rows.indexOf(row);
+ this.focus();
+ if (this.selectedIndex === 0) {
+ // All Address Books - only the startup default item is relevant.
+ for (let item of popup.children) {
+ item.hidden = item != startupDefaultItem;
+ }
+
+ isDefault =
+ isDefault &&
+ !Services.prefs.prefHasUserValue("mail.addr_book.view.startupURI");
+ } else {
+ for (let item of popup.children) {
+ item.hidden = false;
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("bookContextProperties"),
+ row.classList.contains("listRow")
+ ? "about-addressbook-books-context-edit-list"
+ : "about-addressbook-books-context-properties"
+ );
+
+ synchronizeItem.hidden = !row.classList.contains("carddav");
+ exportItem.hidden = row.classList.contains("remote");
+
+ deleteItem.disabled = row.classList.contains("noDelete");
+ deleteItem.hidden = row.classList.contains("carddav");
+
+ removeItem.disabled = row.classList.contains("noDelete");
+ removeItem.hidden = !row.classList.contains("carddav");
+
+ let directory = row._book || row._list;
+ isDefault =
+ isDefault &&
+ Services.prefs.getStringPref("mail.addr_book.view.startupURI") ==
+ directory.URI;
+ }
+
+ if (isDefault) {
+ startupDefaultItem.setAttribute("checked", "true");
+ } else {
+ startupDefaultItem.removeAttribute("checked");
+ }
+
+ if (event.type == "contextmenu" && event.button == 2) {
+ // This is a right-click. Open where it happened.
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ // This is a click on the menu button, or the context menu key was
+ // pressed. Open near the menu button.
+ popup.openPopup(
+ row.querySelector(".bookRow-container, .listRow-container"),
+ {
+ triggerEvent: event,
+ position: "end_before",
+ x: -26,
+ y: 30,
+ }
+ );
+ }
+ event.preventDefault();
+ }
+
+ _abObserver = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ _notifications: [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-directory-request-start",
+ "addrbook-directory-request-end",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ ],
+
+ // Bound to `booksList`.
+ observe(subject, topic, data) {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ switch (topic) {
+ case "addrbook-directory-created": {
+ let row = this._createBookRow(subject);
+ let next = this.children[1];
+ while (next) {
+ if (
+ AddrBookUtils.compareAddressBooks(
+ subject,
+ MailServices.ab.getDirectoryFromUID(next.dataset.uid)
+ ) < 0
+ ) {
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+ this.insertBefore(row, next);
+ break;
+ }
+ case "addrbook-directory-updated":
+ case "addrbook-list-updated": {
+ let row = this.getRowForUID(subject.UID);
+ row.querySelector(".bookRow-name, .listRow-name").textContent =
+ subject.dirName;
+ row.setAttribute("aria-label", subject.dirName);
+ if (cardsPane.cardsList.view.directory?.UID == subject.UID) {
+ document.l10n.setAttributes(
+ cardsPane.searchInput,
+ "about-addressbook-search",
+ { name: subject.dirName }
+ );
+ }
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ this.getRowForUID(subject.UID).remove();
+ break;
+ }
+ case "addrbook-directory-request-start":
+ this.getRowForUID(data).classList.add("requesting");
+ break;
+ case "addrbook-directory-request-end":
+ this.getRowForUID(data).classList.remove("requesting");
+ break;
+ case "addrbook-list-created": {
+ let row = this.getRowForUID(data);
+ let childList = row.querySelector("ul");
+ if (!childList) {
+ childList = row.appendChild(document.createElement("ul"));
+ }
+
+ let listRow = this._createListRow(data, subject);
+ let next = childList.firstElementChild;
+ while (next) {
+ if (AddrBookUtils.compareAddressBooks(subject, next._list) < 0) {
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+ childList.insertBefore(listRow, next);
+ break;
+ }
+ case "addrbook-list-deleted": {
+ let row = this.getRowForUID(data);
+ let childList = row.querySelector("ul");
+ let listRow = childList.querySelector(`[data-uid="${subject.UID}"]`);
+ listRow.remove();
+ if (childList.childElementCount == 0) {
+ setTimeout(() => childList.remove());
+ }
+ break;
+ }
+ }
+ },
+ };
+}
+customElements.define("ab-tree-listbox", AbTreeListbox, { extends: "ul" });
+
+// Cards
+
+/**
+ * Search field for card list. An HTML port of MozSearchTextbox.
+ */
+class AbCardSearchInput extends HTMLInputElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this._fireCommand = this._fireCommand.bind(this);
+
+ this.addEventListener("input", this);
+ this.addEventListener("keypress", this);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "input":
+ this._onInput(event);
+ break;
+ case "keypress":
+ this._onKeyPress(event);
+ break;
+ }
+ }
+
+ _onInput() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ }
+ this._timer = setTimeout(this._fireCommand, 500, this);
+ }
+
+ _onKeyPress(event) {
+ switch (event.key) {
+ case "Escape":
+ if (this._clearSearch()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ break;
+ case "Return":
+ this._enterSearch();
+ event.preventDefault();
+ event.stopPropagation();
+ break;
+ }
+ }
+
+ _fireCommand() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ }
+ this._timer = null;
+ this.dispatchEvent(new CustomEvent("command"));
+ }
+
+ _enterSearch() {
+ this._fireCommand();
+ }
+
+ _clearSearch() {
+ if (this.value) {
+ this.value = "";
+ this._fireCommand();
+ return true;
+ }
+ return false;
+ }
+}
+customElements.define("ab-card-search-input", AbCardSearchInput, {
+ extends: "input",
+});
+
+customElements.whenDefined("tree-view-table-row").then(() => {
+ /**
+ * A row in the list of cards.
+ *
+ * @augments {TreeViewTableRow}
+ */
+ class AbCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 46;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+
+ this.cell = document.createElement("td");
+
+ let container = this.cell.appendChild(document.createElement("div"));
+ container.classList.add("card-container");
+
+ this.avatar = container.appendChild(document.createElement("div"));
+ this.avatar.classList.add("recipient-avatar");
+ let dataContainer = container.appendChild(document.createElement("div"));
+ dataContainer.classList.add("ab-card-row-data");
+
+ this.firstLine = dataContainer.appendChild(document.createElement("p"));
+ this.firstLine.classList.add("ab-card-first-line");
+ this.name = this.firstLine.appendChild(document.createElement("span"));
+ this.name.classList.add("name");
+
+ let secondLine = dataContainer.appendChild(document.createElement("p"));
+ secondLine.classList.add("ab-card-second-line");
+ this.address = secondLine.appendChild(document.createElement("span"));
+ this.address.classList.add("address");
+
+ this.appendChild(this.cell);
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ /**
+ * Override the row setter to generate the layout.
+ *
+ * @note This element could be recycled, make sure you set or clear all
+ * properties.
+ */
+ set index(index) {
+ super.index = index;
+
+ let card = this.view.getCardFromRow(index);
+ this.name.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+
+ // Add the address book name for All Address Books if in the sort Context
+ // Address Book is checked. This is done for the list view only.
+ if (
+ document.getElementById("books").selectedIndex == "0" &&
+ document
+ .getElementById("sortContext")
+ .querySelector(`menuitem[value="addrbook"]`)
+ .getAttribute("checked") === "true"
+ ) {
+ let addressBookName = this.querySelector(".address-book-name");
+ if (!addressBookName) {
+ addressBookName = document.createElement("span");
+ addressBookName.classList.add("address-book-name");
+ this.firstLine.appendChild(addressBookName);
+ }
+ addressBookName.textContent = this.view.getCellText(index, {
+ id: "addrbook",
+ });
+ } else {
+ this.querySelector(".address-book-name")?.remove();
+ }
+
+ // Don't try to fetch the avatar or show the parent AB if this is a list.
+ if (!card.isMailList) {
+ this.classList.remove("MailList");
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = this.name.textContent;
+ img.src = photoURL;
+ this.avatar.replaceChildren(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(
+ this.name.textContent
+ )[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ this.avatar.replaceChildren(letter);
+ }
+ this.address.textContent = card.primaryEmail;
+ } else {
+ this.classList.add("MailList");
+ let img = document.createElement("img");
+ img.alt = "";
+ img.src = "chrome://messenger/skin/icons/new/compact/user-list-alt.svg";
+ this.avatar.replaceChildren(img);
+ this.avatar.classList.add("is-mail-list");
+ this.address.textContent = "";
+ }
+
+ this.cell.setAttribute("aria-label", this.name.textContent);
+ }
+ }
+ customElements.define("ab-card-row", AbCardRow, { extends: "tr" });
+
+ /**
+ * A row in the table list of cards.
+ *
+ * @augments {TreeViewTableRow}
+ */
+ class AbTableCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 22;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+
+ for (let column of cardsPane.COLUMNS) {
+ this.appendChild(document.createElement("td")).classList.add(
+ `${column.id.toLowerCase()}-column`
+ );
+ }
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ /**
+ * Override the row setter to generate the layout.
+ *
+ * @note This element could be recycled, make sure you set or clear all
+ * properties.
+ */
+ set index(index) {
+ super.index = index;
+
+ let card = this.view.getCardFromRow(index);
+ this.classList.toggle("MailList", card.isMailList);
+
+ for (let column of cardsPane.COLUMNS) {
+ let cell = this.querySelector(`.${column.id.toLowerCase()}-column`);
+ if (!column.hidden) {
+ cell.textContent = this.view.getCellText(index, { id: column.id });
+ continue;
+ }
+
+ cell.hidden = true;
+ }
+
+ this.setAttribute("aria-label", this.firstElementChild.textContent);
+ }
+ }
+ customElements.define("ab-table-card-row", AbTableCardRow, {
+ extends: "tr",
+ });
+});
+
+var cardsPane = {
+ /**
+ * The document URL for saving and retrieving values in the XUL Store.
+ *
+ * @type {string}
+ */
+ URL: "about:addressbook",
+
+ /**
+ * The array of columns for the table layout.
+ *
+ * @type {Array}
+ */
+ COLUMNS: [
+ {
+ id: "GeneratedName",
+ l10n: {
+ header: "about-addressbook-column-header-generatedname2",
+ menuitem: "about-addressbook-column-label-generatedname2",
+ },
+ },
+ {
+ id: "EmailAddresses",
+ l10n: {
+ header: "about-addressbook-column-header-emailaddresses2",
+ menuitem: "about-addressbook-column-label-emailaddresses2",
+ },
+ },
+ {
+ id: "NickName",
+ l10n: {
+ header: "about-addressbook-column-header-nickname2",
+ menuitem: "about-addressbook-column-label-nickname2",
+ },
+ hidden: true,
+ },
+ {
+ id: "PhoneNumbers",
+ l10n: {
+ header: "about-addressbook-column-header-phonenumbers2",
+ menuitem: "about-addressbook-column-label-phonenumbers2",
+ },
+ },
+ {
+ id: "Addresses",
+ l10n: {
+ header: "about-addressbook-column-header-addresses2",
+ menuitem: "about-addressbook-column-label-addresses2",
+ },
+ },
+ {
+ id: "Title",
+ l10n: {
+ header: "about-addressbook-column-header-title2",
+ menuitem: "about-addressbook-column-label-title2",
+ },
+ hidden: true,
+ },
+ {
+ id: "Department",
+ l10n: {
+ header: "about-addressbook-column-header-department2",
+ menuitem: "about-addressbook-column-label-department2",
+ },
+ hidden: true,
+ },
+ {
+ id: "Organization",
+ l10n: {
+ header: "about-addressbook-column-header-organization2",
+ menuitem: "about-addressbook-column-label-organization2",
+ },
+ hidden: true,
+ },
+ {
+ id: "addrbook",
+ l10n: {
+ header: "about-addressbook-column-header-addrbook2",
+ menuitem: "about-addressbook-column-label-addrbook2",
+ },
+ hidden: true,
+ },
+ ],
+
+ /**
+ * Make the list rows density aware.
+ */
+ densityChange() {
+ let rowClass = customElements.get("ab-card-row");
+ let tableRowClass = customElements.get("ab-table-card-row");
+ switch (UIDensity.prefValue) {
+ case UIDensity.MODE_COMPACT:
+ rowClass.ROW_HEIGHT = 36;
+ tableRowClass.ROW_HEIGHT = 18;
+ break;
+ case UIDensity.MODE_TOUCH:
+ rowClass.ROW_HEIGHT = 60;
+ tableRowClass.ROW_HEIGHT = 32;
+ break;
+ default:
+ rowClass.ROW_HEIGHT = 46;
+ tableRowClass.ROW_HEIGHT = 22;
+ break;
+ }
+ this.cardsList.reset();
+ },
+
+ searchInput: null,
+
+ cardsList: null,
+
+ init() {
+ this.searchInput = document.getElementById("searchInput");
+ this.displayButton = document.getElementById("displayButton");
+ this.sortContext = document.getElementById("sortContext");
+ this.cardContext = document.getElementById("cardContext");
+
+ this.cardsList = document.getElementById("cards");
+ this.table = this.cardsList.table;
+ this.table.editable = true;
+ this.table.setBodyID("cardsBody");
+ this.cardsList.setAttribute("rows", "ab-card-row");
+
+ if (
+ Services.xulStore.getValue(cardsPane.URL, "cardsPane", "layout") ==
+ "table"
+ ) {
+ this.toggleLayout(true);
+ }
+
+ let nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+ this.sortContext
+ .querySelector(`[name="format"][value="${nameFormat}"]`)
+ ?.setAttribute("checked", "true");
+
+ let columns = Services.xulStore.getValue(cardsPane.URL, "cards", "columns");
+ if (columns) {
+ columns = columns.split(",");
+ for (let column of cardsPane.COLUMNS) {
+ column.hidden = !columns.includes(column.id);
+ }
+ }
+
+ this.table.setColumns(cardsPane.COLUMNS);
+ this.table.restoreColumnsWidths(cardsPane.URL);
+
+ // Only add the address book toggle to the filter button outside the table
+ // layout view. All other toggles are only for a table context.
+ let abColumn = cardsPane.COLUMNS.find(c => c.id == "addrbook");
+ let menuitem = this.sortContext.insertBefore(
+ document.createXULElement("menuitem"),
+ this.sortContext.querySelector("menuseparator:last-of-type")
+ );
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("name", "toggle");
+ menuitem.setAttribute("value", abColumn.id);
+ menuitem.setAttribute("closemenu", "none");
+ if (abColumn.l10n?.menuitem) {
+ document.l10n.setAttributes(menuitem, abColumn.l10n.menuitem);
+ }
+ if (!abColumn.hidden) {
+ menuitem.setAttribute("checked", "true");
+ }
+
+ menuitem.addEventListener("command", event =>
+ this._onColumnsChanged({ target: menuitem, value: abColumn.id })
+ );
+
+ this.searchInput.addEventListener("command", this);
+ this.displayButton.addEventListener("click", this);
+ this.sortContext.addEventListener("command", this);
+ this.table.addEventListener("columns-changed", this);
+ this.table.addEventListener("sort-changed", this);
+ this.table.addEventListener("column-resized", this);
+ this.cardsList.addEventListener("select", this);
+ this.cardsList.addEventListener("keydown", this);
+ this.cardsList.addEventListener("dblclick", this);
+ this.cardsList.addEventListener("dragstart", this);
+ this.cardsList.addEventListener("contextmenu", this);
+ this.cardsList.addEventListener("rowcountchange", () => {
+ if (
+ document.activeElement == this.cardsList &&
+ this.cardsList.view.rowCount == 0
+ ) {
+ this.searchInput.focus();
+ }
+ });
+ this.cardsList.addEventListener("searchstatechange", () =>
+ this._updatePlaceholder()
+ );
+ this.cardContext.addEventListener("command", this);
+
+ window.addEventListener("uidensitychange", () => cardsPane.densityChange());
+ customElements
+ .whenDefined("ab-table-card-row")
+ .then(() => cardsPane.densityChange());
+
+ document
+ .getElementById("placeholderCreateContact")
+ .addEventListener("click", () => createContact());
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "command":
+ this._onCommand(event);
+ break;
+ case "click":
+ this._onClick(event);
+ break;
+ case "select":
+ this._onSelect(event);
+ break;
+ case "keydown":
+ this._onKeyDown(event);
+ break;
+ case "dblclick":
+ this._onDoubleClick(event);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "contextmenu":
+ this._onContextMenu(event);
+ break;
+ case "columns-changed":
+ this._onColumnsChanged(event.detail);
+ break;
+ case "sort-changed":
+ this._onSortChanged(event);
+ break;
+ case "column-resized":
+ this._onColumnResized(event);
+ break;
+ }
+ },
+
+ /**
+ * Store the resized column value in the xul store.
+ *
+ * @param {DOMEvent} event - The dom event bubbling from the resized action.
+ */
+ _onColumnResized(event) {
+ this.table.setColumnsWidths(cardsPane.URL, event);
+ },
+
+ _onSortChanged(event) {
+ const { sortColumn, sortDirection } = this.cardsList.view;
+ const column = event.detail.column;
+ this.sortRows(
+ column,
+ sortColumn == column && sortDirection == "ascending"
+ ? "descending"
+ : "ascending"
+ );
+ },
+
+ _onColumnsChanged(data) {
+ let column = data.value;
+ let checked = data.target.hasAttribute("checked");
+
+ for (let columnDef of cardsPane.COLUMNS) {
+ if (columnDef.id == column) {
+ columnDef.hidden = !checked;
+ break;
+ }
+ }
+
+ this.table.updateColumns(cardsPane.COLUMNS);
+ this.cardsList.reset();
+
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cards",
+ "columns",
+ cardsPane.COLUMNS.filter(c => !c.hidden)
+ .map(c => c.id)
+ .join(",")
+ );
+ },
+
+ /**
+ * Switch between list and table layouts.
+ *
+ * @param {?boolean} isTableLayout - Use table layout if `true` or list
+ * layout if `false`. If unspecified, switch layouts.
+ */
+ toggleLayout(isTableLayout) {
+ isTableLayout = document.body.classList.toggle(
+ "layout-table",
+ isTableLayout
+ );
+
+ updateSharedSplitter(isTableLayout);
+
+ this.cardsList.setAttribute(
+ "rows",
+ isTableLayout ? "ab-table-card-row" : "ab-card-row"
+ );
+ this.cardsList.setSpacersColspan(
+ isTableLayout ? cardsPane.COLUMNS.filter(c => !c.hidden).length : 0
+ );
+ if (isTableLayout) {
+ this.sortContext
+ .querySelector("#sortContextTableLayout")
+ .setAttribute("checked", "true");
+ } else {
+ this.sortContext
+ .querySelector("#sortContextTableLayout")
+ .removeAttribute("checked");
+ }
+
+ if (this.cardsList.selectedIndex > -1) {
+ this.cardsList.scrollToIndex(this.cardsList.selectedIndex);
+ }
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cardsPane",
+ "layout",
+ isTableLayout ? "table" : "list"
+ );
+ },
+
+ /**
+ * Gets an address book query string based on the value of the search input.
+ *
+ * @returns {string}
+ */
+ getQuery() {
+ if (!this.searchInput.value) {
+ return null;
+ }
+
+ let searchWords = ABQueryUtils.getSearchTokens(this.searchInput.value);
+ let queryURIFormat = ABQueryUtils.getModelQuery(
+ "mail.addr_book.quicksearchquery.format"
+ );
+ return ABQueryUtils.generateQueryURI(queryURIFormat, searchWords);
+ },
+
+ /**
+ * Display an address book, or all address books.
+ *
+ * @param {string|null} uid - The UID of the book or list to display, or null
+ * for All Address Books.
+ */
+ displayBook(uid) {
+ let book = uid ? MailServices.ab.getDirectoryFromUID(uid) : null;
+ if (book) {
+ document.l10n.setAttributes(
+ this.searchInput,
+ "about-addressbook-search",
+ { name: book.dirName }
+ );
+ } else {
+ document.l10n.setAttributes(
+ this.searchInput,
+ "about-addressbook-search-all"
+ );
+ }
+ let sortColumn =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortColumn") ||
+ "GeneratedName";
+ let sortDirection =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortDirection") ||
+ "ascending";
+ this.cardsList.view = new ABView(
+ book,
+ this.getQuery(),
+ this.searchInput.value,
+ sortColumn,
+ sortDirection
+ );
+ this.sortRows(sortColumn, sortDirection);
+ this._updatePlaceholder();
+
+ detailsPane.displayCards();
+ },
+
+ /**
+ * Display a list.
+ *
+ * @param {bookUID} uid - The UID of the address book containing the list.
+ * @param {string} uid - The UID of the list to display.
+ */
+ displayList(bookUID, uid) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ let list = book.childNodes.find(l => l.UID == uid);
+ document.l10n.setAttributes(this.searchInput, "about-addressbook-search", {
+ name: list.dirName,
+ });
+ let sortColumn =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortColumn") ||
+ "GeneratedName";
+ let sortDirection =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortDirection") ||
+ "ascending";
+ this.cardsList.view = new ABView(
+ list,
+ this.getQuery(),
+ this.searchInput.value,
+ sortColumn,
+ sortDirection
+ );
+ this.sortRows(sortColumn, sortDirection);
+ this._updatePlaceholder();
+
+ detailsPane.displayCards();
+ },
+
+ get selectedCards() {
+ return this.cardsList.selectedIndices.map(i =>
+ this.cardsList.view.getCardFromRow(i)
+ );
+ },
+
+ /**
+ * Display the right message in the cards list placeholder. The placeholder
+ * is only visible if there are no cards in the list, but it's kept
+ * up-to-date at all times, so we don't have to keep track of the size of
+ * the list.
+ */
+ _updatePlaceholder() {
+ let { directory, searchState } = this.cardsList.view;
+
+ let idsToShow;
+ switch (searchState) {
+ case ABView.NOT_SEARCHING:
+ if (directory?.isRemote && !Services.io.offline) {
+ idsToShow = ["placeholderSearchOnly"];
+ } else {
+ idsToShow = ["placeholderEmptyBook"];
+ if (!directory?.readOnly && !directory?.isMailList) {
+ idsToShow.push("placeholderCreateContact");
+ }
+ }
+ break;
+ case ABView.SEARCHING:
+ idsToShow = ["placeholderSearching"];
+ break;
+ case ABView.SEARCH_COMPLETE:
+ idsToShow = ["placeholderNoSearchResults"];
+ break;
+ }
+
+ this.cardsList.updatePlaceholders(idsToShow);
+ },
+
+ /**
+ * Set the name format to be displayed.
+ *
+ * @param {integer} format - One of the nsIAbCard.GENERATE_* constants.
+ */
+ setNameFormat(event) {
+ // ABView will detect this change and update automatically.
+ Services.prefs.setIntPref(
+ "mail.addr_book.lastnamefirst",
+ event.target.value
+ );
+ },
+
+ /**
+ * Change the sort order of the rows being displayed. If `column` and
+ * `direction` match the existing values no sorting occurs but the UI items
+ * are always updated.
+ *
+ * @param {string} column
+ * @param {"ascending"|"descending"} direction
+ */
+ sortRows(column, direction) {
+ // Uncheck the sort button menu item for the previously sorted column, if
+ // there is one, then check the sort button menu item for the column to be
+ // sorted.
+ this.sortContext
+ .querySelector(`[name="sort"][checked]`)
+ ?.removeAttribute("checked");
+ this.sortContext
+ .querySelector(`[name="sort"][value="${column} ${direction}"]`)
+ ?.setAttribute("checked", "true");
+
+ // Unmark the header of previously sorted column, then mark the header of
+ // the column to be sorted.
+ this.table
+ .querySelector(".sorting")
+ ?.classList.remove("sorting", "ascending", "descending");
+ this.table
+ .querySelector(`#${column} button`)
+ ?.classList.add("sorting", direction);
+
+ if (
+ this.cardsList.view.sortColumn == column &&
+ this.cardsList.view.sortDirection == direction
+ ) {
+ return;
+ }
+
+ this.cardsList.view.sortBy(column, direction);
+
+ Services.xulStore.setValue(cardsPane.URL, "cards", "sortColumn", column);
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cards",
+ "sortDirection",
+ direction
+ );
+ },
+
+ /**
+ * Start a new message to the given addresses.
+ *
+ * @param {string[]} addresses
+ */
+ writeTo(addresses) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.Default;
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ params.composeFields.to = addresses.join(",");
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ },
+
+ /**
+ * Start a new message to the selected contact(s) and/or mailing list(s).
+ */
+ writeToSelected() {
+ let selectedAddresses = [];
+
+ for (let card of this.selectedCards) {
+ let email;
+ if (card.isMailList) {
+ email = card.getProperty("Notes", "") || card.displayName;
+ } else {
+ email = card.emailAddresses[0];
+ }
+
+ if (email) {
+ selectedAddresses.push(
+ MailServices.headerParser.makeMimeAddress(card.displayName, email)
+ );
+ }
+ }
+
+ this.writeTo(selectedAddresses);
+ },
+
+ /**
+ * Print delete the selected card(s).
+ */
+ printSelected() {
+ let selectedCards = this.selectedCards;
+ if (selectedCards.length) {
+ // Some cards are selected. Print them.
+ printHandler.printCards(selectedCards);
+ } else if (this.cardsList.view.searchString) {
+ // Nothing's selected, so print everything. But this is a search, so we
+ // can't just print the selected book/list.
+ let allCards = [];
+ for (let i = 0; i < this.cardsList.view.rowCount; i++) {
+ allCards.push(this.cardsList.view.getCardFromRow(i));
+ }
+ printHandler.printCards(allCards);
+ } else {
+ // Nothing's selected, so print the selected book/list.
+ booksList.printSelected();
+ }
+ },
+
+ /**
+ * Export the selected mailing list to a file.
+ */
+ exportSelected() {
+ let card = this.selectedCards[0];
+ if (!card || !card.isMailList) {
+ return;
+ }
+ let row = booksList.getRowForUID(card.UID);
+ AddrBookUtils.exportDirectory(row._list);
+ },
+
+ _canModifySelected() {
+ if (this.cardsList.view.directory?.readOnly) {
+ return false;
+ }
+
+ let seenDirectories = new Set();
+ for (let index of this.cardsList.selectedIndices) {
+ let { directoryUID } = this.cardsList.view.getCardFromRow(index);
+ if (seenDirectories.has(directoryUID)) {
+ continue;
+ }
+ if (MailServices.ab.getDirectoryFromUID(directoryUID).readOnly) {
+ return false;
+ }
+ seenDirectories.add(directoryUID);
+ }
+ return true;
+ },
+
+ /**
+ * Prompt the user and delete the selected card(s).
+ */
+ async deleteSelected() {
+ if (!this._canModifySelected()) {
+ return;
+ }
+
+ let selectedLists = [];
+ let selectedContacts = [];
+
+ for (let index of this.cardsList.selectedIndices) {
+ let card = this.cardsList.view.getCardFromRow(index);
+ if (card.isMailList) {
+ selectedLists.push(card);
+ } else {
+ selectedContacts.push(card);
+ }
+ }
+
+ if (selectedLists.length + selectedContacts.length == 0) {
+ return;
+ }
+
+ // Determine strings for smart and context-sensitive user prompts
+ // for confirming deletion.
+ let action, name, list;
+ let count = selectedLists.length + selectedContacts.length;
+ let selectedDir = this.cardsList.view.directory;
+
+ if (selectedLists.length && selectedContacts.length) {
+ action = "delete-mixed";
+ } else if (selectedLists.length) {
+ action = "delete-lists";
+ name = selectedLists[0].displayName;
+ } else {
+ let nameFormatFromPref = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst"
+ );
+ name = selectedContacts[0].generateName(nameFormatFromPref);
+ if (selectedDir && selectedDir.isMailList) {
+ action = "remove-contacts";
+ list = selectedDir.dirName;
+ } else {
+ action = "delete-contacts";
+ }
+ }
+
+ // Adjust strings to match translations.
+ let actionString;
+ switch (action) {
+ case "delete-contacts":
+ actionString =
+ count > 1 ? "delete-contacts-multi" : "delete-contacts-single";
+ break;
+ case "remove-contacts":
+ actionString =
+ count > 1 ? "remove-contacts-multi" : "remove-contacts-single";
+ break;
+ default:
+ actionString = action;
+ break;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-confirm-${action}-title`, args: { count } },
+ {
+ id: `about-addressbook-confirm-${actionString}`,
+ args: { count, name, list },
+ },
+ ]);
+
+ // Finally, show our smart confirmation message, and act upon it!
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) !== 0
+ ) {
+ // Deletion cancelled by user.
+ return;
+ }
+
+ // TODO: Setting the index should be unnecessary.
+ let indexAfterDelete = this.cardsList.currentIndex;
+ // Delete cards from address books or mailing lists.
+ this.cardsList.view.deleteSelectedCards();
+ this.cardsList.currentIndex = Math.min(
+ indexAfterDelete,
+ this.cardsList.view.rowCount - 1
+ );
+ },
+
+ _onContextMenu(event) {
+ this._showContextMenu(event);
+ },
+
+ _showContextMenu(event) {
+ let row;
+ if (event.target == this.cardsList.table.body) {
+ row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex);
+ } else {
+ row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ }
+ if (!row) {
+ return;
+ }
+ if (!this.cardsList.selectedIndices.includes(row.index)) {
+ this.cardsList.selectedIndex = row.index;
+ // Re-fetch the row in case it was replaced.
+ row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex);
+ }
+
+ this.cardsList.table.body.focus();
+
+ let writeMenuItem = document.getElementById("cardContextWrite");
+ let writeMenu = document.getElementById("cardContextWriteMenu");
+ let writeMenuSeparator = document.getElementById(
+ "cardContextWriteSeparator"
+ );
+ let editItem = document.getElementById("cardContextEdit");
+ // Always reset the edit item to its default string.
+ document.l10n.setAttributes(
+ editItem,
+ "about-addressbook-books-context-edit"
+ );
+ let exportItem = document.getElementById("cardContextExport");
+ if (this.cardsList.selectedIndices.length == 1) {
+ let card = this.cardsList.view.getCardFromRow(
+ this.cardsList.selectedIndex
+ );
+ if (card.isMailList) {
+ writeMenuItem.hidden = writeMenuSeparator.hidden = false;
+ writeMenu.hidden = true;
+ editItem.hidden = !this._canModifySelected();
+ document.l10n.setAttributes(
+ editItem,
+ "about-addressbook-books-context-edit-list"
+ );
+ exportItem.hidden = false;
+ } else {
+ let addresses = card.emailAddresses;
+
+ if (addresses.length == 0) {
+ writeMenuItem.hidden =
+ writeMenu.hidden =
+ writeMenuSeparator.hidden =
+ true;
+ } else if (addresses.length == 1) {
+ writeMenuItem.hidden = writeMenuSeparator.hidden = false;
+ writeMenu.hidden = true;
+ } else {
+ while (writeMenu.menupopup.lastChild) {
+ writeMenu.menupopup.lastChild.remove();
+ }
+
+ for (let address of addresses) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.label = MailServices.headerParser.makeMimeAddress(
+ card.displayName,
+ address
+ );
+ menuitem.addEventListener("command", () =>
+ this.writeTo([menuitem.label])
+ );
+ writeMenu.menupopup.appendChild(menuitem);
+ }
+
+ writeMenuItem.hidden = true;
+ writeMenu.hidden = writeMenuSeparator.hidden = false;
+ }
+
+ editItem.hidden = !this._canModifySelected();
+ exportItem.hidden = true;
+ }
+ } else {
+ writeMenuItem.hidden = false;
+ writeMenu.hidden = true;
+ editItem.hidden = true;
+ exportItem.hidden = true;
+ }
+
+ let deleteItem = document.getElementById("cardContextDelete");
+ let removeItem = document.getElementById("cardContextRemove");
+
+ let inMailList = this.cardsList.view.directory?.isMailList;
+ deleteItem.hidden = inMailList;
+ removeItem.hidden = !inMailList;
+ deleteItem.disabled = removeItem.disabled = !this._canModifySelected();
+
+ if (event.type == "contextmenu" && event.button == 2) {
+ // This is a right-click. Open where it happened.
+ this.cardContext.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ // This is a context menu key press. Open near the middle of the row.
+ this.cardContext.openPopup(row, {
+ triggerEvent: event,
+ position: "overlap",
+ x: row.clientWidth / 2,
+ y: row.clientHeight / 2,
+ });
+ }
+ event.preventDefault();
+ },
+
+ _onCommand(event) {
+ if (event.target == this.searchInput) {
+ this.cardsList.view = new ABView(
+ this.cardsList.view.directory,
+ this.getQuery(),
+ this.searchInput.value,
+ this.cardsList.view.sortColumn,
+ this.cardsList.view.sortDirection
+ );
+ this._updatePlaceholder();
+ detailsPane.displayCards();
+ return;
+ }
+
+ switch (event.target.id) {
+ case "sortContextTableLayout":
+ this.toggleLayout(event.target.getAttribute("checked") === "true");
+ break;
+ case "cardContextWrite":
+ this.writeToSelected();
+ return;
+ case "cardContextEdit":
+ detailsPane.editCurrent();
+ return;
+ case "cardContextPrint":
+ this.printSelected();
+ return;
+ case "cardContextExport":
+ this.exportSelected();
+ return;
+ case "cardContextDelete":
+ this.deleteSelected();
+ return;
+ case "cardContextRemove":
+ this.deleteSelected();
+ return;
+ }
+
+ if (event.target.getAttribute("name") == "format") {
+ this.setNameFormat(event);
+ }
+ if (event.target.getAttribute("name") == "sort") {
+ let [column, direction] = event.target.value.split(" ");
+ this.sortRows(column, direction);
+ }
+ },
+
+ _onClick(event) {
+ if (event.target.closest("button") == this.displayButton) {
+ this.sortContext.openPopup(this.displayButton, { triggerEvent: event });
+ event.preventDefault();
+ }
+ },
+
+ _onSelect(event) {
+ detailsPane.displayCards(this.selectedCards);
+ },
+
+ _onKeyDown(event) {
+ if (event.altKey || event.shiftKey) {
+ return;
+ }
+
+ let modifier = event.ctrlKey;
+ let antiModifier = event.metaKey;
+ if (AppConstants.platform == "macosx") {
+ [modifier, antiModifier] = [antiModifier, modifier];
+ }
+ if (antiModifier) {
+ return;
+ }
+
+ switch (event.key) {
+ case "a":
+ if (modifier) {
+ this.cardsList.view.selection.selectAll();
+ this.cardsList.dispatchEvent(new CustomEvent("select"));
+ event.preventDefault();
+ }
+ break;
+ case "Delete":
+ if (!modifier) {
+ this.deleteSelected();
+ event.preventDefault();
+ }
+ break;
+ case "Enter":
+ if (!modifier) {
+ if (this.cardsList.currentIndex >= 0) {
+ this._activateRow(this.cardsList.currentIndex);
+ }
+ event.preventDefault();
+ }
+ break;
+ }
+ },
+
+ _onDoubleClick(event) {
+ if (
+ event.button != 0 ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ return;
+ }
+ let row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ if (row) {
+ this._activateRow(row.index);
+ }
+ event.preventDefault();
+ },
+
+ /**
+ * "Activate" the row by opening the corresponding card for editing. This will
+ * necessarily change the selection to the given index.
+ *
+ * @param {number} index - The index of the row to activate.
+ */
+ _activateRow(index) {
+ if (detailsPane.isEditing) {
+ return;
+ }
+ // Change selection to just the target.
+ this.cardsList.selectedIndex = index;
+ // We expect the selection to change the detailsPane immediately.
+ detailsPane.editCurrent();
+ },
+
+ _onDragStart(event) {
+ function makeMimeAddressFromCard(card) {
+ if (!card) {
+ return "";
+ }
+
+ let email;
+ if (card.isMailList) {
+ let directory = MailServices.ab.getDirectory(card.mailListURI);
+ email = directory.description || card.displayName;
+ } else {
+ email = card.emailAddresses[0];
+ }
+ if (!email) {
+ return "";
+ }
+ return MailServices.headerParser.makeMimeAddress(card.displayName, email);
+ }
+
+ let row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ if (!row) {
+ event.preventDefault();
+ return;
+ }
+
+ let indices = this.cardsList.selectedIndices;
+ if (!indices.includes(row.index)) {
+ indices = [row.index];
+ }
+ let cards = indices.map(index => this.cardsList.view.getCardFromRow(index));
+
+ let addresses = cards.map(makeMimeAddressFromCard);
+ event.dataTransfer.mozSetDataAt("moz/abcard-array", cards, 0);
+ event.dataTransfer.setData("text/x-moz-address", addresses);
+ event.dataTransfer.setData("text/plain", addresses);
+
+ let card = this.cardsList.view.getCardFromRow(row.index);
+ if (card && card.displayName && !card.isMailList) {
+ try {
+ // A card implementation may throw NS_ERROR_NOT_IMPLEMENTED.
+ // Don't break drag-and-drop if that happens.
+ let vCard = card.translateTo("vcard");
+ event.dataTransfer.setData("text/vcard", decodeURIComponent(vCard));
+ event.dataTransfer.setData(
+ "application/x-moz-file-promise-dest-filename",
+ `${card.displayName}.vcf`.replace(/(.{74}).*(.{10})$/u, "$1...$2")
+ );
+ event.dataTransfer.setData(
+ "application/x-moz-file-promise-url",
+ "data:text/vcard," + vCard
+ );
+ event.dataTransfer.setData(
+ "application/x-moz-file-promise",
+ this._flavorDataProvider
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ event.dataTransfer.effectAllowed = "all";
+ let bcr = row.getBoundingClientRect();
+ event.dataTransfer.setDragImage(
+ row,
+ event.clientX - bcr.x,
+ event.clientY - bcr.y
+ );
+ },
+
+ _flavorDataProvider: {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(transferable, flavor, data) {
+ if (flavor == "application/x-moz-file-promise") {
+ let primitive = {};
+ transferable.getTransferData("text/vcard", primitive);
+ let vCard = primitive.value.QueryInterface(Ci.nsISupportsString).data;
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dest-filename",
+ primitive
+ );
+ let leafName = primitive.value.QueryInterface(
+ Ci.nsISupportsString
+ ).data;
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ primitive
+ );
+ let localFile = primitive.value.QueryInterface(Ci.nsIFile).clone();
+ localFile.append(leafName);
+
+ let ofStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ ofStream.init(localFile, -1, -1, 0);
+ let converter = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ converter.init(ofStream, null);
+ converter.writeString(vCard);
+ converter.close();
+
+ data.value = localFile;
+ }
+ },
+ },
+};
+
+/**
+ * Object holding the contact view pane to show all vcard info and handle data
+ * changes and mutations between the view and edit state of a contact.
+ */
+var detailsPane = {
+ currentCard: null,
+
+ dirtyFields: new Set(),
+
+ _notifications: [
+ "addrbook-contact-created",
+ "addrbook-contact-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-removed",
+ ],
+
+ init() {
+ let booksSplitter = document.getElementById("booksSplitter");
+ let booksSplitterWidth = Services.xulStore.getValue(
+ cardsPane.URL,
+ "booksSplitter",
+ "width"
+ );
+ if (booksSplitterWidth) {
+ booksSplitter.width = booksSplitterWidth;
+ }
+ booksSplitter.addEventListener("splitter-resized", () =>
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "booksSplitter",
+ "width",
+ booksSplitter.width
+ )
+ );
+
+ let isTableLayout = document.body.classList.contains("layout-table");
+ updateSharedSplitter(isTableLayout);
+
+ this.splitter = document.getElementById("sharedSplitter");
+ let sharedSplitterWidth = Services.xulStore.getValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "width"
+ );
+ if (sharedSplitterWidth) {
+ this.splitter.width = sharedSplitterWidth;
+ }
+ let sharedSplitterHeight = Services.xulStore.getValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "height"
+ );
+ if (sharedSplitterHeight) {
+ this.splitter.height = sharedSplitterHeight;
+ }
+ this.splitter.addEventListener("splitter-resized", () => {
+ if (isTableLayout) {
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "height",
+ this.splitter.height
+ );
+ return;
+ }
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "width",
+ this.splitter.width
+ );
+ });
+
+ this.node = document.getElementById("detailsPane");
+ this.actions = document.getElementById("detailsActions");
+ this.writeButton = document.getElementById("detailsWriteButton");
+ this.eventButton = document.getElementById("detailsEventButton");
+ this.searchButton = document.getElementById("detailsSearchButton");
+ this.newListButton = document.getElementById("detailsNewListButton");
+ this.editButton = document.getElementById("editButton");
+ this.selectedCardsSection = document.getElementById("selectedCards");
+ this.form = document.getElementById("editContactForm");
+ this.vCardEdit = this.form.querySelector("vcard-edit");
+ this.deleteButton = document.getElementById("detailsDeleteButton");
+ this.addContactBookList = document.getElementById("addContactBookList");
+ this.cancelEditButton = document.getElementById("cancelEditButton");
+ this.saveEditButton = document.getElementById("saveEditButton");
+
+ this.actions.addEventListener("click", this);
+ document.getElementById("detailsFooter").addEventListener("click", this);
+
+ let photoImage = document.getElementById("viewContactPhoto");
+ photoImage.addEventListener("error", event => {
+ if (!detailsPane.currentCard) {
+ return;
+ }
+
+ let vCard = detailsPane.currentCard.getProperty("_vCard", "");
+ let match = /^PHOTO.*/im.exec(vCard);
+ if (match) {
+ console.warn(
+ `Broken contact photo, vCard data starts with: ${match[0]}`
+ );
+ } else {
+ console.warn(`Broken contact photo, source is: ${photoImage.src}`);
+ }
+ });
+
+ this.form.addEventListener("input", event => {
+ let { type, checked, value, _originalValue } = event.target;
+ let changed;
+ if (type == "checkbox") {
+ changed = checked != _originalValue;
+ } else {
+ changed = value != _originalValue;
+ }
+ if (changed) {
+ this.dirtyFields.add(event.target);
+ } else {
+ this.dirtyFields.delete(event.target);
+ }
+
+ // If there are no dirty fields, clear the flag, otherwise set it.
+ this.isDirty = this.dirtyFields.size > 0;
+ });
+ this.form.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on a button or
+ // checkbox.
+ if (
+ event.key == " " &&
+ ["button", "checkbox"].includes(document.activeElement.type)
+ ) {
+ event.preventDefault();
+ }
+
+ if (event.key != "Escape") {
+ return;
+ }
+
+ event.preventDefault();
+ this.form.reset();
+ });
+ this.form.addEventListener("reset", async event => {
+ event.preventDefault();
+ if (this.isDirty) {
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-unsaved-changes-prompt-title` },
+ { id: `about-addressbook-unsaved-changes-prompt` },
+ ]);
+
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPrompt.BUTTON_TITLE_SAVE * Ci.nsIPrompt.BUTTON_POS_0 +
+ Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1 +
+ Ci.nsIPrompt.BUTTON_TITLE_DONT_SAVE * Ci.nsIPrompt.BUTTON_POS_2,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (buttonPressed === 0) {
+ // Don't call this.form.submit, the submit event won't fire.
+ this.validateBeforeSaving();
+ return;
+ } else if (buttonPressed === 1) {
+ return;
+ }
+ }
+ this.isEditing = false;
+ if (this.currentCard) {
+ // Refresh the card from the book to get exactly what was saved.
+ let book = MailServices.ab.getDirectoryFromUID(
+ this.currentCard.directoryUID
+ );
+ let card = book.childCards.find(c => c.UID == this.currentCard.UID);
+ this.displayContact(card);
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ this.editButton.focus();
+ }
+ } else {
+ this.displayCards(cardsPane.selectedCards);
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ cardsPane.searchInput.focus();
+ }
+ }
+ });
+ this.form.addEventListener("submit", event => {
+ event.preventDefault();
+ this.validateBeforeSaving();
+ });
+
+ this.photoInput = document.getElementById("photoInput");
+ // NOTE: We put the paste handler on the button parent because the
+ // html:button will not be targeted by the paste event.
+ this.photoInput.addEventListener("paste", photoDialog);
+ this.photoInput.addEventListener("dragover", photoDialog);
+ this.photoInput.addEventListener("drop", photoDialog);
+
+ let photoButton = document.getElementById("photoButton");
+ photoButton.addEventListener("click", () => {
+ if (this._photoDetails.sourceURL) {
+ photoDialog.showWithURL(
+ this._photoDetails.sourceURL,
+ this._photoDetails.cropRect,
+ true
+ );
+ } else {
+ photoDialog.showEmpty();
+ }
+ });
+
+ this.cancelEditButton.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on this button.
+ if (event.key == " ") {
+ event.preventDefault();
+ }
+ });
+ this.saveEditButton.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on this button.
+ if (event.key == " ") {
+ event.preventDefault();
+ }
+ });
+
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ },
+
+ uninit() {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ this._onClick(event);
+ break;
+ }
+ },
+
+ async observe(subject, topic, data) {
+ let hadFocus =
+ this.node.contains(document.activeElement) ||
+ document.activeElement == document.body;
+
+ switch (topic) {
+ case "addrbook-contact-created":
+ subject.QueryInterface(Ci.nsIAbCard);
+ updateAddressBookCount();
+ if (
+ !this.currentCard ||
+ this.currentCard.directoryUID != data ||
+ this.currentCard.UID != subject.getProperty("_originalUID", "")
+ ) {
+ break;
+ }
+
+ // The card being displayed had its UID changed by the server. Select
+ // the new card to display it. (If we're already editing the new card
+ // when the server responds, that's just tough luck.)
+ this.isEditing = false;
+ cardsPane.cardsList.selectedIndex =
+ cardsPane.cardsList.view.getIndexForUID(subject.UID);
+ break;
+ case "addrbook-contact-updated":
+ subject.QueryInterface(Ci.nsIAbCard);
+ if (
+ !this.currentCard ||
+ this.currentCard.directoryUID != data ||
+ !this.currentCard.equals(subject)
+ ) {
+ break;
+ }
+
+ // If there's editing in progress, we could attempt to update the
+ // editing interface with the changes, which is difficult, or alert
+ // the user. For now, changes will be overwritten if the edit is saved.
+
+ if (!this.isEditing) {
+ this.displayContact(subject);
+ }
+ break;
+ case "addrbook-contact-deleted":
+ case "addrbook-list-member-removed":
+ subject.QueryInterface(Ci.nsIAbCard);
+ updateAddressBookCount();
+
+ const directoryUID =
+ topic == "addrbook-contact-deleted"
+ ? this.currentCard?.directoryUID
+ : cardsPane.cardsList.view.directory?.UID;
+ if (directoryUID == data && this.currentCard?.equals(subject)) {
+ // The card being displayed was deleted.
+ this.isEditing = false;
+ this.displayCards();
+
+ if (hadFocus) {
+ // Ensure this happens *after* the view handles this notification.
+ Services.tm.dispatchToMainThread(() => {
+ if (cardsPane.cardsList.view.rowCount == 0) {
+ cardsPane.searchInput.focus();
+ } else {
+ cardsPane.cardsList.table.body.focus();
+ }
+ });
+ }
+ } else if (!this.selectedCardsSection.hidden) {
+ for (let li of this.selectedCardsSection.querySelectorAll("li")) {
+ if (li._card.equals(subject)) {
+ // A selected card was deleted.
+ this.displayCards(cardsPane.selectedCards);
+ break;
+ }
+ }
+ }
+ break;
+ case "addrbook-list-updated":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (this.currentList && this.currentList.mailListURI == subject.URI) {
+ this.displayList(this.currentList);
+ }
+ break;
+ case "addrbook-list-deleted":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (this.currentList && this.currentList.mailListURI == subject.URI) {
+ // The list being displayed was deleted.
+ this.displayCards();
+
+ if (hadFocus) {
+ if (cardsPane.cardsList.view.rowCount == 0) {
+ cardsPane.searchInput.focus();
+ } else {
+ cardsPane.cardsList.table.body.focus();
+ }
+ }
+ } else if (!this.selectedCardsSection.hidden) {
+ for (let li of this.selectedCardsSection.querySelectorAll("li")) {
+ if (
+ li._card.directoryUID == data &&
+ li._card.mailListURI == subject.URI
+ ) {
+ // A selected list was deleted.
+ this.displayCards(cardsPane.selectedCards);
+ break;
+ }
+ }
+ }
+ break;
+ }
+ },
+
+ /**
+ * Is a card being edited?
+ *
+ * @type {boolean}
+ */
+ get isEditing() {
+ return document.body.classList.contains("is-editing");
+ },
+
+ set isEditing(editing) {
+ if (editing == this.isEditing) {
+ return;
+ }
+
+ document.body.classList.toggle("is-editing", editing);
+
+ // Disable the toolbar buttons when starting to edit. Remember their state
+ // to restore it when editing stops.
+ for (let toolbarButton of document.querySelectorAll(
+ "#toolbox > toolbar > toolbarbutton"
+ )) {
+ if (editing) {
+ toolbarButton._wasDisabled = toolbarButton.disabled;
+ toolbarButton.disabled = true;
+ } else {
+ toolbarButton.disabled = toolbarButton._wasDisabled;
+ delete toolbarButton._wasDisabled;
+ }
+ }
+
+ // Remove these elements from (or add them back to) the tab focus cycle.
+ for (let id of ["books", "searchInput", "displayButton", "cardsBody"]) {
+ document.getElementById(id).tabIndex = editing ? -1 : 0;
+ }
+
+ if (editing) {
+ this.addContactBookList.hidden = !!this.currentCard;
+ this.addContactBookList.previousElementSibling.hidden =
+ !!this.currentCard;
+
+ let book = booksList
+ .getRowAtIndex(booksList.selectedIndex)
+ .closest(".bookRow")._book;
+ if (book) {
+ // TODO: convert this to UID.
+ this.addContactBookList.value = book.URI;
+ }
+ } else {
+ this.isDirty = false;
+ }
+ },
+
+ /**
+ * If a card is being edited, has any field changed?
+ *
+ * @type {boolean}
+ */
+ get isDirty() {
+ return this.isEditing && document.body.classList.contains("is-dirty");
+ },
+
+ set isDirty(dirty) {
+ if (!dirty) {
+ this.dirtyFields.clear();
+ }
+ document.body.classList.toggle("is-dirty", this.isEditing && dirty);
+ },
+
+ clearDisplay() {
+ this.currentCard = null;
+ this.currentList = null;
+
+ for (let section of document.querySelectorAll(
+ "#viewContact :is(.contact-header, .list-header, .selection-header), #detailsBody > section"
+ )) {
+ section.hidden = true;
+ }
+ },
+
+ displayCards(cards = []) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+
+ if (cards.length == 0) {
+ this.node.hidden = true;
+ this.splitter.isCollapsed =
+ document.body.classList.contains("layout-table");
+ return;
+ }
+ if (cards.length == 1) {
+ if (cards[0].isMailList) {
+ this.displayList(cards[0]);
+ } else {
+ this.displayContact(cards[0]);
+ }
+ return;
+ }
+
+ let contacts = cards.filter(c => !c.isMailList);
+ let contactsWithAddresses = contacts.filter(c => c.primaryEmail);
+ let lists = cards.filter(c => c.isMailList);
+
+ document.querySelector("#viewContact .selection-header").hidden = false;
+ let headerString;
+ if (contacts.length) {
+ if (lists.length) {
+ headerString = "about-addressbook-selection-mixed-header2";
+ } else {
+ headerString = "about-addressbook-selection-contacts-header2";
+ }
+ } else {
+ headerString = "about-addressbook-selection-lists-header2";
+ }
+ document.l10n.setAttributes(
+ document.getElementById("viewSelectionCount"),
+ headerString,
+ { count: cards.length }
+ );
+
+ this.writeButton.hidden = contactsWithAddresses.length + lists.length == 0;
+ this.eventButton.hidden =
+ !contactsWithAddresses.length ||
+ !cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar).length;
+ this.searchButton.hidden = true;
+ this.newListButton.hidden = contactsWithAddresses.length == 0;
+ this.editButton.hidden = true;
+
+ this.actions.hidden = this.writeButton.hidden;
+
+ let list = this.selectedCardsSection.querySelector("ul");
+ list.replaceChildren();
+ let template =
+ document.getElementById("selectedCard").content.firstElementChild;
+ for (let card of cards) {
+ let li = list.appendChild(template.cloneNode(true));
+ li._card = card;
+ let avatar = li.querySelector(".recipient-avatar");
+ let name = li.querySelector(".name");
+ let address = li.querySelector(".address");
+
+ if (!card.isMailList) {
+ name.textContent = card.generateName(ABView.nameFormat);
+ address.textContent = card.primaryEmail;
+
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = name.textContent;
+ img.src = photoURL;
+ avatar.appendChild(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(name.textContent)[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ avatar.appendChild(letter);
+ }
+ } else {
+ name.textContent = card.displayName;
+
+ let img = avatar.appendChild(document.createElement("img"));
+ img.alt = "";
+ img.src = "chrome://messenger/skin/icons/new/compact/user-list-alt.svg";
+ avatar.classList.add("is-mail-list");
+ }
+ }
+ this.selectedCardsSection.hidden = false;
+
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ /**
+ * Show a read-only representation of a card in the details pane.
+ *
+ * @param {nsIAbCard?} card - The card to display. This should not be a
+ * mailing list card. Pass null to hide the details pane.
+ */
+ displayContact(card) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+ if (!card || card.isMailList) {
+ return;
+ }
+ this.currentCard = card;
+
+ this.fillContactDetails(document.getElementById("viewContact"), card);
+ document.getElementById("viewContactPhoto").hidden = document.querySelector(
+ "#viewContact .contact-headings"
+ ).hidden = false;
+ document.querySelector("#viewContact .contact-header").hidden = false;
+
+ this.writeButton.hidden = this.searchButton.hidden = !card.primaryEmail;
+ this.eventButton.hidden =
+ !card.primaryEmail ||
+ !cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar).length;
+ this.newListButton.hidden = true;
+
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ this.editButton.hidden = book.readOnly;
+ this.actions.hidden = this.writeButton.hidden && this.editButton.hidden;
+
+ this.isEditing = false;
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ /**
+ * Set all the values for displaying a contact.
+ *
+ * @param {HTMLElement} element - The element to fill, either the on-screen
+ * contact display or a clone of the printing template.
+ * @param {nsIAbCard} card - The card to display. This should not be a
+ * mailing list card.
+ */
+ fillContactDetails(element, card) {
+ let vCardProperties = card.supportsVCard
+ ? card.vCardProperties
+ : VCardProperties.fromPropertyMap(
+ new Map(card.properties.map(p => [p.name, p.value]))
+ );
+
+ element.querySelector(".contact-photo").src =
+ card.photoURL || "chrome://messenger/skin/icons/new/compact/user.svg";
+ element.querySelector(".contact-heading-name").textContent =
+ card.generateName(ABView.nameFormat);
+ let nickname = element.querySelector(".contact-heading-nickname");
+ let nicknameValue = vCardProperties.getFirstValue("nickname");
+ nickname.hidden = !nicknameValue;
+ nickname.textContent = nicknameValue;
+ element.querySelector(".contact-heading-email").textContent =
+ card.primaryEmail;
+
+ let template = document.getElementById("entryItem");
+ let createEntryItem = function (name) {
+ let li = template.content.firstElementChild.cloneNode(true);
+ if (name) {
+ document.l10n.setAttributes(
+ li.querySelector(".entry-type"),
+ `about-addressbook-entry-name-${name}`
+ );
+ }
+ return li;
+ };
+ let setEntryType = function (li, entry, allowed = ["work", "home"]) {
+ if (!entry.params.type) {
+ return;
+ }
+ let lowerTypes = Array.isArray(entry.params.type)
+ ? entry.params.type.map(t => t.toLowerCase())
+ : [entry.params.type.toLowerCase()];
+ let lowerType = lowerTypes.find(t => allowed.includes(t));
+ if (!lowerType) {
+ return;
+ }
+
+ document.l10n.setAttributes(
+ li.querySelector(".entry-type"),
+ `about-addressbook-entry-type-${lowerType}`
+ );
+ };
+
+ let section = element.querySelector(".details-email-addresses");
+ let list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("email")) {
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let addr = MailServices.headerParser.makeMimeAddress(
+ card.displayName,
+ entry.value
+ );
+ let a = document.createElement("a");
+ a.href = "mailto:" + encodeURIComponent(addr);
+ a.textContent = entry.value;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-phone-numbers");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("tel")) {
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry, ["work", "home", "fax", "cell", "pager"]);
+ let a = document.createElement("a");
+ // Handle tel: uri, some other scheme, or plain text number.
+ let number = entry.value.replace(/^[a-z\+]{3,}:/, "");
+ let scheme = entry.value.split(/([a-z\+]{3,}):/)[1] || "tel";
+ a.href = `${scheme}:${number.replaceAll(/[^\d\+]/g, "")}`;
+ a.textContent = number;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-addresses");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("adr")) {
+ let parts = entry.value.flat();
+ // Put extended address after street address.
+ parts[2] = parts.splice(1, 1, parts[2])[0];
+
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let span = li.querySelector(".entry-value");
+ for (let part of parts.filter(Boolean)) {
+ if (span.firstChild) {
+ span.appendChild(document.createElement("br"));
+ }
+ span.appendChild(document.createTextNode(part));
+ }
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-notes");
+ let note = vCardProperties.getFirstValue("note");
+ if (note) {
+ section.querySelector("div").textContent = note;
+ section.hidden = false;
+ } else {
+ section.hidden = true;
+ }
+
+ section = element.querySelector(".details-websites");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ for (let entry of vCardProperties.getAllEntries("url")) {
+ let value = entry.value;
+ if (!/https?:\/\//.test(value)) {
+ continue;
+ }
+
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let a = document.createElement("a");
+ a.href = value;
+ let url = new URL(value);
+ a.textContent =
+ url.pathname == "/" && !url.search
+ ? url.host
+ : `${url.host}${url.pathname}${url.search}`;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-instant-messaging");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ this._screenNamesToIMPPs(card);
+ for (let entry of vCardProperties.getAllEntries("impp")) {
+ let li = list.appendChild(createEntryItem());
+ let url;
+ try {
+ url = new URL(entry.value);
+ } catch (e) {
+ li.querySelector(".entry-value").textContent = entry.value;
+ continue;
+ }
+ let a = document.createElement("a");
+ a.href = entry.value;
+ a.target = "_blank";
+ a.textContent = url.toString();
+ li.querySelector(".entry-value").append(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-other-info");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ let formatDate = function (date) {
+ try {
+ date = ICAL.VCardTime.fromDateAndOrTimeString(date);
+ } catch (ex) {
+ console.error(ex);
+ return "";
+ }
+ if (date.year && date.month && date.day) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }).format(new Date(date.year, date.month - 1, date.day));
+ }
+ if (date.year && date.month) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ }).format(new Date(date.year, date.month - 1, 1));
+ }
+ if (date.year) {
+ return date.year;
+ }
+ if (date.month && date.day) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ month: "long",
+ day: "numeric",
+ }).format(new Date(2024, date.month - 1, date.day));
+ }
+ if (date.month) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ month: "long",
+ }).format(new Date(2024, date.month - 1, 1));
+ }
+ if (date.day) {
+ return date.day;
+ }
+ return "";
+ };
+
+ let bday = vCardProperties.getFirstValue("bday");
+ if (bday) {
+ let value = formatDate(bday);
+ if (value) {
+ let li = list.appendChild(createEntryItem("birthday"));
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ let anniversary = vCardProperties.getFirstValue("anniversary");
+ if (anniversary) {
+ let value = formatDate(anniversary);
+ if (value) {
+ let li = list.appendChild(createEntryItem("anniversary"));
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ let title = vCardProperties.getFirstValue("title");
+ if (title) {
+ let li = list.appendChild(createEntryItem("title"));
+ li.querySelector(".entry-value").textContent = title;
+ }
+
+ let role = vCardProperties.getFirstValue("role");
+ if (role) {
+ let li = list.appendChild(createEntryItem("role"));
+ li.querySelector(".entry-value").textContent = role;
+ }
+
+ let org = vCardProperties.getFirstValue("org");
+ if (Array.isArray(org)) {
+ let li = list.appendChild(createEntryItem("organization"));
+ let span = li.querySelector(".entry-value");
+ for (let part of org.filter(Boolean).reverse()) {
+ if (span.firstChild) {
+ span.append(" • ");
+ }
+ span.appendChild(document.createTextNode(part));
+ }
+ } else if (org) {
+ let li = list.appendChild(createEntryItem("organization"));
+ li.querySelector(".entry-value").textContent = org;
+ }
+
+ let tz = vCardProperties.getFirstValue("tz");
+ if (tz) {
+ let li = list.appendChild(createEntryItem("time-zone"));
+ try {
+ li.querySelector(".entry-value").textContent =
+ cal.timezoneService.getTimezone(tz).displayName;
+ } catch {
+ li.querySelector(".entry-value").textContent = tz;
+ }
+ li.querySelector(".entry-value").appendChild(
+ document.createElement("br")
+ );
+
+ let time = document.createElement("span", { is: "active-time" });
+ time.setAttribute("tz", tz);
+ li.querySelector(".entry-value").appendChild(time);
+ }
+
+ for (let key of ["custom1", "custom2", "custom3", "custom4"]) {
+ let value = vCardProperties.getFirstValue(`x-${key}`);
+ if (value) {
+ let li = list.appendChild(createEntryItem(key));
+ li.querySelector(".entry-type").style.setProperty(
+ "white-space",
+ "nowrap"
+ );
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ section.hidden = list.childElementCount == 0;
+ },
+
+ /**
+ * Show this given contact photo in the edit form.
+ *
+ * @param {?string} url - The URL of the photo to display, or null to
+ * display none.
+ */
+ showEditPhoto(url) {
+ this.photoInput.querySelector(".contact-photo").src =
+ url || "chrome://messenger/skin/icons/new/compact/user.svg";
+ },
+
+ /**
+ * Store the given photo details to save later, and display the photo in the
+ * edit form.
+ *
+ * @param {?object} details - The photo details to save, or null to remove the
+ * photo.
+ * @param {Blob} details.blob - The image blob of the photo to save.
+ * @param {string} details.sourceURL - The image basis of the photo, before
+ * cropping.
+ * @param {DOMRect} details.cropRect - The cropping rectangle for the photo.
+ */
+ setPhoto(details) {
+ this._photoChanged = true;
+ this._photoDetails = details || {};
+ this.showEditPhoto(
+ details?.blob ? URL.createObjectURL(details.blob) : null
+ );
+ this.dirtyFields.add(this.photoInput);
+ this.isDirty = true;
+ },
+
+ /**
+ * Show controls for editing a new card.
+ *
+ * @param {?string} vCard - A vCard containing properties for the new card.
+ */
+ async editNewContact(vCard) {
+ this.currentCard = null;
+ this.editCurrentContact(vCard);
+ if (!vCard) {
+ this.vCardEdit.contactNameHeading.textContent =
+ await document.l10n.formatValue("about-addressbook-new-contact-header");
+ }
+ },
+
+ /**
+ * Takes old nsIAbCard chat names and put them on the card as IMPP URIs.
+ *
+ * @param {nsIAbCard?} card - The card to change.
+ */
+ _screenNamesToIMPPs(card) {
+ if (!card.supportsVCard) {
+ return;
+ }
+
+ let existingIMPPValues = card.vCardProperties.getAllValues("impp");
+ for (let key of [
+ "_GoogleTalk",
+ "_AimScreenName",
+ "_Yahoo",
+ "_Skype",
+ "_QQ",
+ "_MSN",
+ "_ICQ",
+ "_JabberId",
+ "_IRC",
+ ]) {
+ let value = card.getProperty(key, "");
+ if (!value) {
+ continue;
+ }
+ switch (key) {
+ case "_GoogleTalk":
+ value = `gtalk:chat?jid=${value}`;
+ break;
+ case "_AimScreenName":
+ value = `aim:goim?screenname=${value}`;
+ break;
+ case "_Yahoo":
+ value = `ymsgr:sendIM?${value}`;
+ break;
+ case "_Skype":
+ value = `skype:${value}`;
+ break;
+ case "_QQ":
+ value = `mqq://${value}`;
+ break;
+ case "_MSN":
+ value = `msnim:chat?contact=${value}`;
+ break;
+ case "_ICQ":
+ value = `icq:message?uin=${value}`;
+ break;
+ case "_JabberId":
+ value = `xmpp:${value}`;
+ break;
+ case "_IRC":
+ // Guess host, in case we have an irc account configured.
+ let host =
+ IMServices.accounts
+ .getAccounts()
+ .find(a => a.protocol.normalizedName == "irc")
+ ?.name.split("@", 2)[1] || "irc.example.org";
+ value = `ircs://${host}/${value},isuser`;
+ break;
+ }
+ if (!existingIMPPValues.includes(value)) {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry(`impp`, {}, "uri", value)
+ );
+ }
+ }
+ },
+
+ /**
+ * Show controls for editing the currently displayed card.
+ *
+ * @param {?string} vCard - A vCard containing properties for a new card.
+ */
+ editCurrentContact(vCard) {
+ let card = this.currentCard;
+ this.deleteButton.hidden = !card;
+ if (card && card.supportsVCard) {
+ this._screenNamesToIMPPs(card);
+
+ this.vCardEdit.vCardProperties = card.vCardProperties;
+ // getProperty may return a "1" or "0" string, we want a boolean.
+ this.vCardEdit.preferDisplayName.checked =
+ // eslint-disable-next-line mozilla/no-compare-against-boolean-literals
+ card.getProperty("PreferDisplayName", true) == true;
+ } else {
+ this.vCardEdit.vCardString = vCard ?? "";
+ card = new AddrBookCard();
+ card.setProperty("_vCard", vCard);
+ }
+
+ this.showEditPhoto(card?.photoURL);
+ this._photoDetails = { sourceURL: card?.photoURL };
+ this._photoChanged = false;
+ this.isEditing = true;
+ this.node.hidden = this.splitter.isCollapsed = false;
+ this.form.querySelector(".contact-details-scroll").scrollTo(0, 0);
+ // If we enter editing directly from the cards list we want to return to it
+ // once we are done.
+ this._focusOnCardsList =
+ document.activeElement == cardsPane.cardsList.table.body;
+ this.vCardEdit.setFocus();
+ },
+
+ /**
+ * Edit the currently displayed contact or list.
+ */
+ editCurrent() {
+ // The editButton is disabled if the book is readOnly.
+ if (this.editButton.hidden) {
+ return;
+ }
+ if (this.currentCard) {
+ this.editCurrentContact();
+ } else if (this.currentList) {
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml",
+ { features: "resizable=no" },
+ { listURI: this.currentList.mailListURI }
+ );
+ }
+ },
+
+ /**
+ * Properly handle a failed form validation.
+ */
+ handleInvalidForm() {
+ // FIXME: Drop this in favor of an inline notification with fluent strings.
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ Services.prompt.alert(
+ window,
+ bundle.GetStringFromName("cardRequiredDataMissingTitle"),
+ bundle.GetStringFromName("cardRequiredDataMissingMessage")
+ );
+ },
+
+ /**
+ * Make sure the data is valid before saving the contact.
+ */
+ validateBeforeSaving() {
+ // Make sure the minimum required data is present.
+ if (!this.vCardEdit.checkMinimumRequirements()) {
+ this.handleInvalidForm();
+ return;
+ }
+
+ // Make sure the dates are filled properly.
+ if (!this.vCardEdit.validateDates()) {
+ // Simply return as the validateDates() will handle focus and visual cue.
+ return;
+ }
+
+ // Extra validation for any form field that has validatity requirements
+ // set on them (through pattern etc.).
+ if (!this.form.checkValidity()) {
+ this.form.querySelector("input:invalid").focus();
+ return;
+ }
+
+ this.saveCurrentContact();
+ },
+
+ /**
+ * Save the currently displayed card.
+ */
+ async saveCurrentContact() {
+ let card = this.currentCard;
+ let book;
+
+ if (card) {
+ book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ } else {
+ card = new AddrBookCard();
+
+ // TODO: convert this to UID.
+ book = MailServices.ab.getDirectory(this.addContactBookList.value);
+ if (book.getBoolValue("carddav.vcard3", false)) {
+ // This is a CardDAV book, and the server discards photos unless the
+ // vCard 3 format is used. Since we know this is a new card, setting
+ // the version here won't cause a problem.
+ this.vCardEdit.vCardProperties.addValue("version", "3.0");
+ }
+ }
+ if (!book || book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ // Tell vcard-edit to read the input fields. Setting the _vCard property
+ // MUST happen before accessing `card.vCardProperties` or creating new
+ // cards will fail.
+ this.vCardEdit.saveVCard();
+ card.setProperty("_vCard", this.vCardEdit.vCardString);
+ card.setProperty(
+ "PreferDisplayName",
+ this.vCardEdit.preferDisplayName.checked
+ );
+
+ // Old screen names should by now be on the vCard. Delete them.
+ for (let key of [
+ "_GoogleTalk",
+ "_AimScreenName",
+ "_Yahoo",
+ "_Skype",
+ "_QQ",
+ "_MSN",
+ "_ICQ",
+ "_JabberId",
+ "_IRC",
+ ]) {
+ card.deleteProperty(key);
+ }
+
+ // No photo or a new photo. Delete the old one.
+ if (this._photoChanged) {
+ let oldLeafName = card.getProperty("PhotoName", "");
+ if (oldLeafName) {
+ let oldPath = PathUtils.join(
+ PathUtils.profileDir,
+ "Photos",
+ oldLeafName
+ );
+ await IOUtils.remove(oldPath);
+
+ card.setProperty("PhotoName", "");
+ card.setProperty("PhotoType", "");
+ card.setProperty("PhotoURI", "");
+ }
+ if (card.supportsVCard) {
+ for (let entry of card.vCardProperties.getAllEntries("photo")) {
+ card.vCardProperties.removeEntry(entry);
+ }
+ }
+ }
+
+ // Save the new photo.
+ if (this._photoChanged && this._photoDetails.blob) {
+ if (book.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) {
+ let reader = new FileReader();
+ await new Promise(resolve => {
+ reader.onloadend = resolve;
+ reader.readAsDataURL(this._photoDetails.blob);
+ });
+ if (card.vCardProperties.getFirstValue("version") == "4.0") {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry("photo", {}, "uri", reader.result)
+ );
+ } else {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry(
+ "photo",
+ { encoding: "B" },
+ "binary",
+ reader.result.substring(reader.result.indexOf(",") + 1)
+ )
+ );
+ }
+ } else {
+ let leafName = `${AddrBookUtils.newUID()}.jpg`;
+ let path = PathUtils.join(PathUtils.profileDir, "Photos", leafName);
+ let buffer = await this._photoDetails.blob.arrayBuffer();
+ await IOUtils.write(path, new Uint8Array(buffer));
+ card.setProperty("PhotoName", leafName);
+ }
+ }
+ this._photoChanged = false;
+ this.isEditing = false;
+
+ if (!card.directoryUID) {
+ card = book.addCard(card);
+ cardsPane.cardsList.selectedIndex =
+ cardsPane.cardsList.view.getIndexForUID(card.UID);
+ // The selection change will update the UI.
+ } else {
+ book.modifyCard(card);
+ // The addrbook-contact-updated notification will update the UI.
+ }
+
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ this.editButton.focus();
+ }
+ },
+
+ /**
+ * Delete the currently displayed card.
+ */
+ async deleteCurrentContact() {
+ let card = this.currentCard;
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+
+ if (!book) {
+ throw new Components.Exception(
+ "Card doesn't have a book to delete from",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ let name = card.displayName;
+ let [title, message] = await document.l10n.formatValues([
+ {
+ id: "about-addressbook-confirm-delete-contacts-title",
+ args: { count: 1 },
+ },
+ {
+ id: "about-addressbook-confirm-delete-contacts-single",
+ args: { name },
+ },
+ ]);
+
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) === 0
+ ) {
+ // TODO: Setting the index should be unnecessary.
+ let indexAfterDelete = cardsPane.cardsList.currentIndex;
+ book.deleteCards([card]);
+ cardsPane.cardsList.currentIndex = Math.min(
+ indexAfterDelete,
+ cardsPane.cardsList.view.rowCount - 1
+ );
+ // The addrbook-contact-deleted notification will update the details pane UI.
+ }
+ },
+
+ displayList(listCard) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+ if (!listCard || !listCard.isMailList) {
+ return;
+ }
+ this.currentList = listCard;
+
+ let listDirectory = MailServices.ab.getDirectory(listCard.mailListURI);
+
+ document.querySelector("#viewContact .list-header").hidden = false;
+ document.querySelector(
+ "#viewContact .list-header > h1"
+ ).textContent = `${listDirectory.dirName}`;
+
+ let cards = Array.from(listDirectory.childCards, card => {
+ return {
+ name: card.generateName(ABView.nameFormat),
+ email: card.primaryEmail,
+ photoURL: card.photoURL,
+ };
+ });
+ let { sortColumn, sortDirection } = cardsPane.cardsList.view;
+ let key = sortColumn == "EmailAddresses" ? "email" : "name";
+ cards.sort((a, b) => {
+ if (sortDirection == "descending") {
+ [b, a] = [a, b];
+ }
+ return ABView.prototype.collator.compare(a[key], b[key]);
+ });
+
+ let list = this.selectedCardsSection.querySelector("ul");
+ list.replaceChildren();
+ let template =
+ document.getElementById("selectedCard").content.firstElementChild;
+ for (let card of cards) {
+ let li = list.appendChild(template.cloneNode(true));
+ li._card = card;
+ let avatar = li.querySelector(".recipient-avatar");
+ let name = li.querySelector(".name");
+ let address = li.querySelector(".address");
+ name.textContent = card.name;
+ address.textContent = card.email;
+
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = name.textContent;
+ img.src = photoURL;
+ avatar.appendChild(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(name.textContent)[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ avatar.appendChild(letter);
+ }
+ }
+ this.selectedCardsSection.hidden = list.childElementCount == 0;
+
+ let book = MailServices.ab.getDirectoryFromUID(listCard.directoryUID);
+ this.writeButton.hidden = list.childElementCount == 0;
+ this.eventButton.hidden = this.writeButton.hidden;
+ this.searchButton.hidden = true;
+ this.newListButton.hidden = true;
+ this.editButton.hidden = book.readOnly;
+
+ this.actions.hidden = this.writeButton.hidden && this.editButton.hidden;
+
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ _onClick(event) {
+ let selectedContacts = cardsPane.selectedCards.filter(
+ card => !card.isMailList && card.primaryEmail
+ );
+
+ switch (event.target.id) {
+ case "detailsWriteButton":
+ cardsPane.writeToSelected();
+ break;
+ case "detailsEventButton": {
+ let contacts;
+ if (this.currentList) {
+ let directory = MailServices.ab.getDirectory(
+ this.currentList.mailListURI
+ );
+ contacts = directory.childCards;
+ } else {
+ contacts = selectedContacts;
+ }
+ let attendees = contacts.map(card => {
+ let attendee = new CalAttendee();
+ attendee.id = `mailto:${card.primaryEmail}`;
+ attendee.commonName = card.displayName;
+ return attendee;
+ });
+ if (attendees.length) {
+ window.browsingContext.topChromeWindow.createEventWithDialog(
+ null,
+ null,
+ null,
+ null,
+ null,
+ false,
+ attendees
+ );
+ }
+ break;
+ }
+ case "detailsSearchButton":
+ if (this.currentCard.primaryEmail) {
+ let searchString = this.currentCard.emailAddresses.join(" ");
+ window.browsingContext.topChromeWindow.tabmail.openTab("glodaFacet", {
+ searcher: new GlodaMsgSearcher(null, searchString, false),
+ });
+ }
+ break;
+ case "detailsNewListButton":
+ if (selectedContacts.length) {
+ createList(selectedContacts);
+ }
+ break;
+ case "editButton":
+ this.editCurrent();
+ break;
+ case "detailsDeleteButton":
+ this.deleteCurrentContact();
+ break;
+ }
+ },
+};
+
+var photoDialog = {
+ /**
+ * The ratio of pixels in the source image to pixels in the preview.
+ *
+ * @type {number}
+ */
+ _scale: null,
+
+ /**
+ * The square to which the image will be cropped, in preview pixels.
+ *
+ * @type {DOMRect}
+ */
+ _cropRect: null,
+
+ /**
+ * The bounding rectangle of the image in the preview, in preview pixels.
+ * Cached for efficiency.
+ *
+ * @type {DOMRect}
+ */
+ _previewRect: null,
+
+ init() {
+ this._dialog = document.getElementById("photoDialog");
+ this._dialog.saveButton = this._dialog.querySelector(".accept");
+ this._dialog.cancelButton = this._dialog.querySelector(".cancel");
+ this._dialog.discardButton = this._dialog.querySelector(".extra1");
+
+ this._dropTarget = this._dialog.querySelector("#photoDropTarget");
+ this._svg = this._dialog.querySelector("svg");
+ this._preview = this._svg.querySelector("image");
+ this._cropMask = this._svg.querySelector("path");
+ this._dragRect = this._svg.querySelector("rect");
+ this._corners = this._svg.querySelectorAll("rect.corner");
+
+ this._dialog.addEventListener("dragover", this);
+ this._dialog.addEventListener("drop", this);
+ this._dialog.addEventListener("paste", this);
+ this._dropTarget.addEventListener("click", event => {
+ if (event.button != 0) {
+ return;
+ }
+ this._showFilePicker();
+ });
+ this._dropTarget.addEventListener("keydown", event => {
+ if (event.key != " " && event.key != "Enter") {
+ return;
+ }
+ this._showFilePicker();
+ });
+
+ class Mover {
+ constructor(element) {
+ element.addEventListener("mousedown", this);
+ }
+
+ handleEvent(event) {
+ if (event.type == "mousedown") {
+ if (event.buttons != 1) {
+ return;
+ }
+ this.onMouseDown(event);
+ window.addEventListener("mousemove", this);
+ window.addEventListener("mouseup", this);
+ } else if (event.type == "mousemove") {
+ if (event.buttons != 1) {
+ // The button was released and we didn't get a mouseup event, or the
+ // button(s) pressed changed. Either way, stop dragging.
+ this.onMouseUp();
+ return;
+ }
+ this.onMouseMove(event);
+ } else {
+ this.onMouseUp(event);
+ }
+ }
+
+ onMouseUp(event) {
+ delete this._dragPosition;
+ window.removeEventListener("mousemove", this);
+ window.removeEventListener("mouseup", this);
+ }
+ }
+
+ new (class extends Mover {
+ onMouseDown(event) {
+ this._dragPosition = {
+ x: event.clientX - photoDialog._cropRect.x,
+ y: event.clientY - photoDialog._cropRect.y,
+ };
+ }
+
+ onMouseMove(event) {
+ photoDialog._cropRect.x = Math.min(
+ Math.max(0, event.clientX - this._dragPosition.x),
+ photoDialog._previewRect.width - photoDialog._cropRect.width
+ );
+ photoDialog._cropRect.y = Math.min(
+ Math.max(0, event.clientY - this._dragPosition.y),
+ photoDialog._previewRect.height - photoDialog._cropRect.height
+ );
+ photoDialog._redrawCropRect();
+ }
+ })(this._dragRect);
+
+ class CornerMover extends Mover {
+ constructor(element, xEdge, yEdge) {
+ super(element);
+ this.xEdge = xEdge;
+ this.yEdge = yEdge;
+ }
+
+ onMouseDown(event) {
+ this._dragPosition = {
+ x: event.clientX - photoDialog._cropRect[this.xEdge],
+ y: event.clientY - photoDialog._cropRect[this.yEdge],
+ };
+ }
+
+ onMouseMove(event) {
+ let { width, height } = photoDialog._previewRect;
+ let { top, right, bottom, left } = photoDialog._cropRect;
+ let { x, y } = this._dragPosition;
+
+ // New coordinates of the dragged corner, constrained to the image size.
+ x = Math.max(0, Math.min(width, event.clientX - x));
+ y = Math.max(0, Math.min(height, event.clientY - y));
+
+ // New size based on the dragged corner and a minimum size of 80px.
+ let newWidth = this.xEdge == "right" ? x - left : right - x;
+ let newHeight = this.yEdge == "bottom" ? y - top : bottom - y;
+ let newSize = Math.max(80, Math.min(newWidth, newHeight));
+
+ photoDialog._cropRect.width = newSize;
+ if (this.xEdge == "left") {
+ photoDialog._cropRect.x = right - photoDialog._cropRect.width;
+ }
+ photoDialog._cropRect.height = newSize;
+ if (this.yEdge == "top") {
+ photoDialog._cropRect.y = bottom - photoDialog._cropRect.height;
+ }
+ photoDialog._redrawCropRect();
+ }
+ }
+
+ new CornerMover(this._corners[0], "left", "top");
+ new CornerMover(this._corners[1], "right", "top");
+ new CornerMover(this._corners[2], "right", "bottom");
+ new CornerMover(this._corners[3], "left", "bottom");
+
+ this._dialog.saveButton.addEventListener("click", () => this._save());
+ this._dialog.cancelButton.addEventListener("click", () => this._cancel());
+ this._dialog.discardButton.addEventListener("click", () => this._discard());
+ },
+
+ _setState(state) {
+ if (state == "preview") {
+ this._dropTarget.hidden = true;
+ this._svg.toggleAttribute("hidden", false);
+ this._dialog.saveButton.disabled = false;
+ return;
+ }
+
+ this._dropTarget.classList.toggle("drop-target", state == "target");
+ this._dropTarget.classList.toggle("drop-loading", state == "loading");
+ this._dropTarget.classList.toggle("drop-error", state == "error");
+ document.l10n.setAttributes(
+ this._dropTarget.querySelector(".label"),
+ `about-addressbook-photo-drop-${state}`
+ );
+
+ this._dropTarget.hidden = false;
+ this._svg.toggleAttribute("hidden", true);
+ this._dialog.saveButton.disabled = true;
+ },
+
+ /**
+ * Show the photo dialog, with no displayed image.
+ */
+ showEmpty() {
+ this._setState("target");
+
+ if (!this._dialog.open) {
+ this._dialog.discardButton.hidden = true;
+ this._dialog.showModal();
+ }
+ },
+
+ /**
+ * Show the photo dialog, with `file` as the displayed image.
+ *
+ * @param {File} file
+ */
+ showWithFile(file) {
+ this.showWithURL(URL.createObjectURL(file));
+ },
+
+ /**
+ * Show the photo dialog, with `URL` as the displayed image and (optionally)
+ * a pre-set crop rectangle
+ *
+ * @param {string} url - The URL of the image.
+ * @param {?DOMRect} cropRect - The rectangle used to crop the image.
+ * @param {boolean} [showDiscard=false] - Whether to show a discard button
+ * when opening the dialog.
+ */
+ showWithURL(url, cropRect, showDiscard = false) {
+ // Load the image from the URL, to figure out the scale factor.
+ let img = document.createElement("img");
+ img.addEventListener("load", () => {
+ const PREVIEW_SIZE = 500;
+
+ let { naturalWidth, naturalHeight } = img;
+ this._scale = Math.max(
+ 1,
+ img.naturalWidth / PREVIEW_SIZE,
+ img.naturalHeight / PREVIEW_SIZE
+ );
+
+ let previewWidth = naturalWidth / this._scale;
+ let previewHeight = naturalHeight / this._scale;
+ let smallDimension = Math.min(previewWidth, previewHeight);
+
+ this._previewRect = new DOMRect(0, 0, previewWidth, previewHeight);
+ if (cropRect) {
+ this._cropRect = DOMRect.fromRect(cropRect);
+ } else {
+ this._cropRect = new DOMRect(
+ (this._previewRect.width - smallDimension) / 2,
+ (this._previewRect.height - smallDimension) / 2,
+ smallDimension,
+ smallDimension
+ );
+ }
+
+ this._preview.setAttribute("href", url);
+ this._preview.setAttribute("width", previewWidth);
+ this._preview.setAttribute("height", previewHeight);
+
+ this._svg.setAttribute("width", previewWidth + 20);
+ this._svg.setAttribute("height", previewHeight + 20);
+ this._svg.setAttribute(
+ "viewBox",
+ `-10 -10 ${previewWidth + 20} ${previewHeight + 20}`
+ );
+
+ this._redrawCropRect();
+ this._setState("preview");
+ this._dialog.saveButton.focus();
+ });
+ img.addEventListener("error", () => this._setState("error"));
+ img.src = url;
+
+ this._setState("loading");
+
+ if (!this._dialog.open) {
+ this._dialog.discardButton.hidden = !showDiscard;
+ this._dialog.showModal();
+ }
+ },
+
+ /**
+ * Resize the crop controls to match the current _cropRect.
+ */
+ _redrawCropRect() {
+ let { top, right, bottom, left, width, height } = this._cropRect;
+
+ this._cropMask.setAttribute(
+ "d",
+ `M0 0H${this._previewRect.width}V${this._previewRect.height}H0Z M${left} ${top}V${bottom}H${right}V${top}Z`
+ );
+
+ this._dragRect.setAttribute("x", left);
+ this._dragRect.setAttribute("y", top);
+ this._dragRect.setAttribute("width", width);
+ this._dragRect.setAttribute("height", height);
+
+ this._corners[0].setAttribute("x", left - 10);
+ this._corners[0].setAttribute("y", top - 10);
+ this._corners[1].setAttribute("x", right - 30);
+ this._corners[1].setAttribute("y", top - 10);
+ this._corners[2].setAttribute("x", right - 30);
+ this._corners[2].setAttribute("y", bottom - 30);
+ this._corners[3].setAttribute("x", left - 10);
+ this._corners[3].setAttribute("y", bottom - 30);
+ },
+
+ /**
+ * Crop, shrink, convert the image to a JPEG, then assign it to the photo
+ * element and close the dialog. Doesn't save the JPEG to disk, that happens
+ * when (if) the contact is saved.
+ */
+ async _save() {
+ const DOUBLE_SIZE = 600;
+ const FINAL_SIZE = 300;
+
+ let source = this._preview;
+ let { x, y, width, height } = this._cropRect;
+ x *= this._scale;
+ y *= this._scale;
+ width *= this._scale;
+ height *= this._scale;
+
+ // If the image is much larger than our target size, draw an intermediate
+ // version at twice the size first. This produces better-looking results.
+ if (width > DOUBLE_SIZE) {
+ let canvas1 = document.createElement("canvas");
+ canvas1.width = canvas1.height = DOUBLE_SIZE;
+ let context1 = canvas1.getContext("2d");
+ context1.drawImage(
+ source,
+ x,
+ y,
+ width,
+ height,
+ 0,
+ 0,
+ DOUBLE_SIZE,
+ DOUBLE_SIZE
+ );
+
+ source = canvas1;
+ x = y = 0;
+ width = height = DOUBLE_SIZE;
+ }
+
+ let canvas2 = document.createElement("canvas");
+ canvas2.width = canvas2.height = FINAL_SIZE;
+ let context2 = canvas2.getContext("2d");
+ context2.drawImage(
+ source,
+ x,
+ y,
+ width,
+ height,
+ 0,
+ 0,
+ FINAL_SIZE,
+ FINAL_SIZE
+ );
+
+ let blob = await new Promise(resolve =>
+ canvas2.toBlob(resolve, "image/jpeg")
+ );
+
+ detailsPane.setPhoto({
+ blob,
+ sourceURL: this._preview.getAttribute("href"),
+ cropRect: DOMRect.fromRect(this._cropRect),
+ });
+
+ this._dialog.close();
+ },
+
+ /**
+ * Just close the dialog.
+ */
+ _cancel() {
+ this._dialog.close();
+ },
+
+ /**
+ * Throw away the contact's existing photo, and close the dialog. Doesn't
+ * remove the existing photo from disk, that happens when (if) the contact
+ * is saved.
+ */
+ _discard() {
+ this._dialog.close();
+ detailsPane.setPhoto(null);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "paste":
+ this._onPaste(event);
+ break;
+ }
+ },
+
+ /**
+ * Gets the first image file from a DataTransfer object, or null if there
+ * are no image files in the object.
+ *
+ * @param {DataTransfer} dataTransfer
+ * @returns {File|null}
+ */
+ _getUseableFile(dataTransfer) {
+ if (
+ dataTransfer.files.length &&
+ dataTransfer.files[0].type.startsWith("image/")
+ ) {
+ return dataTransfer.files[0];
+ }
+ return null;
+ },
+
+ /**
+ * Gets the first image file from a DataTransfer object, or null if there
+ * are no image files in the object.
+ *
+ * @param {DataTransfer} dataTransfer
+ * @returns {string|null}
+ */
+ _getUseableURL(dataTransfer) {
+ let data = dataTransfer.getData("text/plain");
+
+ return /^https?:\/\//.test(data) ? data : null;
+ },
+
+ _onDragOver(event) {
+ if (
+ this._getUseableFile(event.dataTransfer) ||
+ this._getUseableURL(event.clipboardData)
+ ) {
+ event.dataTransfer.dropEffect = "move";
+ event.preventDefault();
+ }
+ },
+
+ _onDrop(event) {
+ let file = this._getUseableFile(event.dataTransfer);
+ if (file) {
+ this.showWithFile(file);
+ event.preventDefault();
+ } else {
+ let url = this._getUseableURL(event.clipboardData);
+ if (url) {
+ this.showWithURL(url);
+ event.preventDefault();
+ }
+ }
+ },
+
+ _onPaste(event) {
+ let file = this._getUseableFile(event.clipboardData);
+ if (file) {
+ this.showWithFile(file);
+ } else {
+ let url = this._getUseableURL(event.clipboardData);
+ if (url) {
+ this.showWithURL(url);
+ }
+ }
+ event.preventDefault();
+ },
+
+ /**
+ * Show a file picker to choose an image.
+ */
+ async _showFilePicker() {
+ let title = await document.l10n.formatValue(
+ "about-addressbook-photo-filepicker-title"
+ );
+
+ let picker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ picker.init(
+ window.browsingContext.topChromeWindow,
+ title,
+ Ci.nsIFilePicker.modeOpen
+ );
+ picker.appendFilters(Ci.nsIFilePicker.filterImages);
+ let result = await new Promise(resolve => picker.open(resolve));
+
+ if (result != Ci.nsIFilePicker.returnOK) {
+ return;
+ }
+
+ this.showWithFile(await File.createFromNsIFile(picker.file));
+ },
+};
+
+// Printing
+
+var printHandler = {
+ printDirectory(directory) {
+ let title = directory ? directory.dirName : document.title;
+
+ let cards;
+ if (directory) {
+ cards = directory.childCards;
+ } else {
+ cards = [];
+ for (let directory of MailServices.ab.directories) {
+ cards = cards.concat(directory.childCards);
+ }
+ }
+
+ this._printCards(title, cards);
+ },
+
+ printCards(cards) {
+ this._printCards(document.title, cards);
+ },
+
+ async _printCards(title, cards) {
+ let collator = new Intl.Collator(undefined, { numeric: true });
+ let nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+
+ cards.sort((a, b) => {
+ let aName = a.generateName(nameFormat);
+ let bName = b.generateName(nameFormat);
+ return collator.compare(aName, bName);
+ });
+
+ let printDocument = document.implementation.createHTMLDocument();
+ printDocument.title = title;
+ printDocument.head
+ .appendChild(printDocument.createElement("meta"))
+ .setAttribute("charset", "utf-8");
+ let link = printDocument.head.appendChild(
+ printDocument.createElement("link")
+ );
+ link.setAttribute("rel", "stylesheet");
+ link.setAttribute("href", "chrome://messagebody/skin/abPrint.css");
+
+ let printTemplate = document.getElementById("printTemplate");
+
+ for (let card of cards) {
+ if (card.isMailList) {
+ continue;
+ }
+
+ let div = printDocument.createElement("div");
+ div.append(printTemplate.content.cloneNode(true));
+ detailsPane.fillContactDetails(div, card);
+ let photo = div.querySelector(".contact-photo");
+ if (photo.src.startsWith("chrome:")) {
+ photo.hidden = true;
+ }
+ await document.l10n.translateFragment(div);
+ printDocument.body.appendChild(div);
+ }
+
+ let html = new XMLSerializer().serializeToString(printDocument);
+ this._printURL(URL.createObjectURL(new File([html], "text/html")));
+ },
+
+ async _printURL(url) {
+ let topWindow = window.browsingContext.topChromeWindow;
+ await topWindow.PrintUtils.loadPrintBrowser(url);
+ topWindow.PrintUtils.startPrintWindow(
+ topWindow.PrintUtils.printBrowser.browsingContext,
+ {}
+ );
+ },
+};
+
+/**
+ * A span that displays the current time in a given time zone.
+ * The time is updated every minute.
+ */
+class ActiveTime extends HTMLSpanElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ this.hasConnected = true;
+ this.setAttribute("is", "active-time");
+
+ try {
+ this.formatter = new Services.intl.DateTimeFormat(undefined, {
+ timeZone: this.getAttribute("tz"),
+ weekday: "long",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ } catch {
+ // DateTimeFormat will throw if the time zone is unknown.
+ // If it does this will just be an empty span.
+ return;
+ }
+ this.update = this.update.bind(this);
+ this.update();
+
+ CalMetronome.on("minute", this.update);
+ window.addEventListener("unload", this, { once: true });
+ }
+
+ disconnectedCallback() {
+ CalMetronome.off("minute", this.update);
+ }
+
+ handleEvent() {
+ CalMetronome.off("minute", this.update);
+ }
+
+ update() {
+ this.textContent = this.formatter.format(new Date());
+ }
+}
+customElements.define("active-time", ActiveTime, { extends: "span" });
diff --git a/comm/mail/components/addrbook/content/aboutAddressBook.xhtml b/comm/mail/components/addrbook/content/aboutAddressBook.xhtml
new file mode 100644
index 0000000000..51a689106a
--- /dev/null
+++ b/comm/mail/components/addrbook/content/aboutAddressBook.xhtml
@@ -0,0 +1,460 @@
+<?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/. -->
+
+<!DOCTYPE html>
+<html 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"
+ lightweightthemes="true">
+<head>
+ <meta charset="utf-8" />
+ <title data-l10n-id="about-addressbook-title"></title>
+ <meta http-equiv="Content-Security-Policy"
+ content="default-src chrome:; script-src chrome: 'unsafe-inline'; img-src blob: chrome: data: http: https:; style-src chrome: 'unsafe-inline'; object-src 'none'" />
+ <meta name="color-scheme" content="light dark" />
+
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/address-book.svg" />
+
+ <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" />
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/primaryToolbar.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/inContentDialog.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/avatars.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/aboutAddressBook.css" />
+
+ <link rel="localization" href="messenger/treeView.ftl" />
+ <link rel="localization" href="messenger/addressbook/aboutAddressBook.ftl" />
+ <link rel="localization" href="messenger/preferences/preferences.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+
+ <script src="chrome://messenger/content/globalOverlay.js"></script>
+ <script src="chrome://global/content/editMenuOverlay.js"></script>
+ <script src="chrome://messenger/content/pane-splitter.js"></script>
+ <script src="chrome://messenger/content/tree-listbox.js"></script>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="chrome://messenger/content/jsTreeView.js"></script>
+ <script src="chrome://messenger/content/addressbook/abView-new.js"></script>
+ <script src="chrome://messenger/content/addressbook/aboutAddressBook.js"></script>
+</head>
+<body>
+ <xul:toolbox id="toolbox" class="contentTabToolbox" labelalign="end">
+ <xul:toolbar class="chromeclass-toolbar contentTabToolbar themeable-full" mode="full">
+ <xul:toolbarbutton id="toolbarCreateBook" is="toolbarbutton-menu-button" type="menu-button"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-address-book"
+ tabindex="0">
+ <xul:menupopup>
+ <xul:menuitem data-l10n-id="about-addressbook-toolbar-new-address-book"/>
+ <xul:menuitem value="CARDDAV_DIRECTORY_TYPE"
+ data-l10n-id="about-addressbook-toolbar-add-carddav-address-book"/>
+ <xul:menuitem value="LDAP_DIRECTORY_TYPE"
+ data-l10n-id="about-addressbook-toolbar-add-ldap-address-book"/>
+ </xul:menupopup>
+ </xul:toolbarbutton>
+ <xul:toolbarbutton id="toolbarCreateContact"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-contact"
+ tabindex="0"/>
+ <xul:toolbarbutton id="toolbarCreateList"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-list"
+ tabindex="0"/>
+ <xul:toolbarbutton id="toolbarImport"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-import"
+ tabindex="0"/>
+ </xul:toolbar>
+ </xul:toolbox>
+ <div id="booksPane" class="no-overscroll">
+ <ul is="ab-tree-listbox" id="books" role="tree">
+ <li id="allAddressBooks"
+ class="bookRow noDelete readOnly"
+ data-l10n-id="all-address-books-row">
+ <div class="bookRow-container">
+ <div class="twisty"></div>
+ <div class="bookRow-icon"></div>
+ <span class="bookRow-name" tabindex="-1" data-l10n-id="all-address-books"></span>
+ <div class="bookRow-menu"></div>
+ </div>
+ </li>
+ </ul>
+ <div id="cardCount"></div>
+ <template id="bookRow">
+ <li class="bookRow">
+ <div class="bookRow-container">
+ <div class="twisty">
+ <img class="twisty-icon" src="chrome://messenger/skin/icons/new/nav-down-sm.svg" alt="" />
+ </div>
+ <div class="bookRow-icon"></div>
+ <span class="bookRow-name" tabindex="-1"></span>
+ <div class="bookRow-menu"></div>
+ </div>
+ <ul></ul>
+ </li>
+ </template>
+ <template id="listRow">
+ <li class="listRow">
+ <div class="listRow-container">
+ <div class="listRow-icon"></div>
+ <span class="listRow-name" tabindex="-1"></span>
+ <div class="listRow-menu"></div>
+ </div>
+ </li>
+ </template>
+ </div>
+ <hr is="pane-splitter" id="booksSplitter"
+ resize-direction="horizontal"
+ resize-id="booksPane"/>
+ <div id="cardsPane">
+ <div id="cardsPaneHeader">
+ <input is="ab-card-search-input" id="searchInput"
+ type="search"
+ data-l10n-attrs="placeholder" />
+ <button id="displayButton"
+ class="button icon-button icon-only button-flat"
+ data-l10n-id="about-addressbook-sort-button2">
+ </button>
+ </div>
+
+ <tree-view id="cards">
+ <slot name="placeholders">
+ <div id="placeholderEmptyBook"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-empty-book"></div>
+ <button id="placeholderCreateContact"
+ class="icon-button"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-new-contact"></button>
+ <div id="placeholderSearchOnly"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-search-only"></div>
+ <div id="placeholderSearching"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-searching"></div>
+ <div id="placeholderNoSearchResults"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-no-search-results"></div>
+ </slot>
+ </tree-view>
+ </div>
+ <!-- We will dynamically switch this splitter to be horizontal or vertical and
+ affect the cardsPane or detailsPane based on the required layout. -->
+ <hr is="pane-splitter" id="sharedSplitter" />
+ <div id="detailsPane" hidden="hidden">
+ <article id="viewContact" class="contact-details-scroll">
+ <!-- If you're changing this, you probably want to change #printTemplate too. -->
+ <header>
+ <div class="contact-header">
+ <img id="viewContactPhoto" class="contact-photo" alt="" />
+ <div class="contact-headings">
+ <h1 id="viewContactName" class="contact-heading-name"></h1>
+ <p id="viewContactNickName" class="contact-heading-nickname"></p>
+ <p id="viewPrimaryEmail" class="contact-heading-email"></p>
+ </div>
+ </div>
+ <div class="list-header">
+ <div class="recipient-avatar is-mail-list">
+ <img alt="" src="chrome://messenger/skin/icons/new/compact/user-list-alt.svg" />
+ </div>
+ <h1 id="viewListName" class="contact-heading-name"></h1>
+ </div>
+ <div class="selection-header">
+ <h1 id="viewSelectionCount" class="contact-heading-name"></h1>
+ </div>
+ </header>
+ <div id="detailsBody">
+ <section id="detailsActions" class="button-block">
+ <div>
+ <button type="button" id="detailsWriteButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-write-action-button"></button>
+ <button type="button" id="detailsEventButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-event-action-button"></button>
+ <button type="button" id="detailsSearchButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-search-action-button"></button>
+ <button type="button" id="detailsNewListButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-new-list-action-button"></button>
+ </div>
+ <div class="edit-block">
+ <button type="button" id="editButton"
+ data-l10n-id="about-addressbook-begin-edit-contact-button"></button>
+ </div>
+ </section>
+ <section id="emailAddresses" class="details-email-addresses">
+ <h2 data-l10n-id="about-addressbook-details-email-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="phoneNumbers" class="details-phone-numbers">
+ <h2 data-l10n-id="about-addressbook-details-phone-numbers-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="addresses" class="details-addresses">
+ <h2 data-l10n-id="about-addressbook-details-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="notes" class="details-notes">
+ <h2 data-l10n-id="about-addressbook-details-notes-header"></h2>
+ <div></div>
+ </section>
+ <section id="websites" class="details-websites">
+ <h2 data-l10n-id="about-addressbook-details-websites-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="instantMessaging" class="details-instant-messaging">
+ <h2 data-l10n-id="about-addressbook-details-impp-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="otherInfo" class="details-other-info">
+ <h2 data-l10n-id="about-addressbook-details-other-info-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="selectedCards">
+ <ul></ul>
+ </section>
+ <template id="entryItem">
+ <li class="entry-item">
+ <span class="entry-type"></span>
+ <span class="entry-value"></span>
+ </li>
+ </template>
+ <template id="selectedCard">
+ <li class="selected-card">
+ <div class="recipient-avatar"></div>
+ <div class="ab-card-row-data">
+ <p class="ab-card-first-line">
+ <span class="name"></span>
+ </p>
+ <p class="ab-card-second-line">
+ <span class="address"></span>
+ </p>
+ </div>
+ </li>
+ </template>
+ </div>
+ </article>
+ <form id="editContactForm"
+ autocomplete="off"
+ aria-labelledby="editContactHeadingName">
+ <div class="contact-details-scroll">
+ <div class="contact-header">
+ <div class="contact-headings">
+ <h1 id="editContactHeadingName" class="contact-heading-name"></h1>
+ <p id="editContactHeadingNickName" class="contact-heading-nickname">
+ </p>
+ <p id="editContactHeadingEmail" class="contact-heading-email"></p>
+ </div>
+ <!-- NOTE: We place the photo 'input' after the headings, since it is
+ - functionally a form control. However, we style the photo to
+ - appear at the inline-start of the contact-header. -->
+ <!-- NOTE: We wrap the button with a plain div because the button
+ - itself will not receive the paste event. -->
+ <div id="photoInput">
+ <button type="button" id="photoButton"
+ class="plain-button"
+ data-l10n-id="about-addressbook-details-edit-photo">
+ <img class="contact-photo" alt="" />
+ <div id="photoOverlay"></div>
+ </button>
+ </div>
+ </div>
+ #include vcard-edit/vCardTemplates.inc.xhtml
+ <vcard-edit />
+ </div>
+ <div id="detailsFooter" class="button-block">
+ <div>
+ <button type="button" id="detailsDeleteButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-delete-edit-contact-button"></button>
+ </div>
+ <div>
+ <xul:label control="addContactBookList"
+ data-l10n-id="about-addressbook-add-contact-to"/>
+ <xul:menulist is="menulist-addrbooks" id="addContactBookList"
+ writable="true"/>
+ <button type="reset" id="cancelEditButton"
+ data-l10n-id="about-addressbook-cancel-edit-contact-button"></button>
+ <button type="submit" id="saveEditButton"
+ class="primary"
+ data-l10n-id="about-addressbook-save-edit-contact-button"></button>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div id="detailsPaneBackdrop"><!--
+ When editing a card, this element covers everything except #detailsPane,
+ preventing change to another card.
+ --></div>
+
+ <dialog id="photoDialog">
+ <div id="photoDialogInner">
+ <!-- FIXME: The dialog is not semantic or accessible.
+ - We use a tabindex and role="alert" as a temporary solution. -->
+ <div id="photoDropTarget" role="alert" tabindex="0">
+ <div class="icon"></div>
+ <div class="label" data-l10n-id="about-addressbook-photo-drop-target"></div>
+ </div>
+ <svg xmlns="http://www.w3.org/2000/svg" width="520" height="520" viewBox="-10 -10 520 520">
+ <image/>
+ <path fill="#000000" fill-opacity="0.5" d="M0 0H500V500H0Z M200 200V300H300V200Z"/>
+ <rect x="0" y="0" width="500" height="500"/>
+ <rect class="corner nw" width="40" height="40"/>
+ <rect class="corner ne" width="40" height="40"/>
+ <rect class="corner se" width="40" height="40"/>
+ <rect class="corner sw" width="40" height="40"/>
+ </svg>
+ </div>
+
+ <menu class="dialog-menu-container">
+ <button class="extra1" data-l10n-id="about-addressbook-photo-discard"></button>
+ <button class="cancel" data-l10n-id="about-addressbook-photo-cancel"></button>
+ <button class="accept primary" data-l10n-id="about-addressbook-photo-save"></button>
+ </menu>
+ </dialog>
+
+ <!-- In-content dialogs. -->
+ <xul:stack id="dialogStack" hidden="true"/>
+ <xul:vbox id="dialogTemplate"
+ class="dialogOverlay"
+ align="center"
+ pack="center"
+ topmost="true"
+ hidden="true">
+ <xul:vbox class="dialogBox"
+ pack="end"
+ role="dialog"
+ aria-labelledby="dialogTitle">
+ <xul:hbox class="dialogTitleBar" align="center">
+ <xul:label class="dialogTitle" flex="1"/>
+ <xul:button class="dialogClose close-icon" data-l10n-id="close-button"/>
+ </xul:hbox>
+ <xul:browser class="dialogFrame"
+ autoscroll="false"
+ disablehistory="true"/>
+ </xul:vbox>
+ </xul:vbox>
+
+ <template id="printTemplate">
+ <!-- If you're changing this, you probably want to change #viewContact too. -->
+ <div class="contact-header">
+ <img class="contact-photo" alt="" />
+ <div class="contact-headings">
+ <h1 class="contact-heading-name"></h1>
+ <p class="contact-heading-nickname"></p>
+ <p class="contact-heading-email"></p>
+ </div>
+ </div>
+ <div class="contact-body">
+ <section class="details-email-addresses">
+ <h2 data-l10n-id="about-addressbook-details-email-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-phone-numbers">
+ <h2 data-l10n-id="about-addressbook-details-phone-numbers-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-addresses">
+ <h2 data-l10n-id="about-addressbook-details-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-notes">
+ <h2 data-l10n-id="about-addressbook-details-notes-header"></h2>
+ <div></div>
+ </section>
+ <section class="details-websites">
+ <h2 data-l10n-id="about-addressbook-details-websites-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-instant-messaging">
+ <h2 data-l10n-id="about-addressbook-details-impp-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-other-info">
+ <h2 data-l10n-id="about-addressbook-details-other-info-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ </div>
+ </template>
+</body>
+<xul:menupopup id="bookContext">
+ <xul:menuitem id="bookContextProperties"/>
+ <xul:menuitem id="bookContextSynchronize"
+ data-l10n-id="about-addressbook-books-context-synchronize"/>
+ <xul:menuitem id="bookContextPrint"
+ data-l10n-id="about-addressbook-books-context-print"/>
+ <xul:menuitem id="bookContextExport"
+ data-l10n-id="about-addressbook-books-context-export"/>
+ <xul:menuitem id="bookContextDelete"
+ data-l10n-id="about-addressbook-books-context-delete"/>
+ <xul:menuitem id="bookContextRemove"
+ data-l10n-id="about-addressbook-books-context-remove"/>
+ <xul:menuseparator/>
+ <xul:menuitem id="bookContextStartupDefault" type="checkbox"
+ data-l10n-id="about-addressbook-books-context-startup-default"/>
+</xul:menupopup>
+<xul:menupopup id="sortContext"
+ position="bottomleft topleft">
+ <xul:menuitem type="radio"
+ name="format"
+ value="0"
+ checked="true"
+ data-l10n-id="about-addressbook-name-format-display"/>
+ <xul:menuitem type="radio"
+ name="format"
+ value="2"
+ data-l10n-id="about-addressbook-name-format-firstlast"/>
+ <xul:menuitem type="radio"
+ name="format"
+ value="1"
+ data-l10n-id="about-addressbook-name-format-lastfirst"/>
+ <xul:menuseparator/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="GeneratedName ascending"
+ checked="true"
+ data-l10n-id="about-addressbook-sort-name-ascending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="GeneratedName descending"
+ data-l10n-id="about-addressbook-sort-name-descending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="EmailAddresses ascending"
+ data-l10n-id="about-addressbook-sort-email-ascending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="EmailAddresses descending"
+ data-l10n-id="about-addressbook-sort-email-descending"/>
+ <xul:menuseparator/>
+ <xul:menuitem id="sortContextTableLayout"
+ type="checkbox"
+ data-l10n-id="about-addressbook-table-layout"/>
+</xul:menupopup>
+<xul:menupopup id="cardContext">
+ <xul:menuitem id="cardContextWrite"
+ data-l10n-id="about-addressbook-cards-context-write"/>
+ <xul:menu id="cardContextWriteMenu"
+ data-l10n-id="about-addressbook-cards-context-write">
+ <xul:menupopup>
+ <!-- Filled dynamically. -->
+ </xul:menupopup>
+ </xul:menu>
+ <xul:menuseparator id="cardContextWriteSeparator"/>
+ <xul:menuitem id="cardContextEdit"
+ data-l10n-id="about-addressbook-books-context-edit"/>
+ <xul:menuitem id="cardContextPrint"
+ data-l10n-id="about-addressbook-books-context-print"/>
+ <xul:menuitem id="cardContextExport"
+ data-l10n-id="about-addressbook-books-context-export"/>
+ <xul:menuitem id="cardContextDelete"
+ data-l10n-id="about-addressbook-books-context-delete"/>
+ <xul:menuitem id="cardContextRemove"
+ data-l10n-id="about-addressbook-books-context-remove"/>
+</xul:menupopup>
+</html>
diff --git a/comm/mail/components/addrbook/content/addressBookTab.js b/comm/mail/components/addrbook/content/addressBookTab.js
new file mode 100644
index 0000000000..5605612daf
--- /dev/null
+++ b/comm/mail/components/addrbook/content/addressBookTab.js
@@ -0,0 +1,172 @@
+/* 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/. */
+
+// mail/base/content/specialTabs.js
+/* globals contentTabBaseType, DOMLinkHandler */
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+/**
+ * A tab to show the Address Book.
+ */
+var addressBookTabType = {
+ __proto__: contentTabBaseType,
+ name: "addressBookTab",
+ perTabPanel: "vbox",
+ lastBrowserId: 0,
+ bundle: Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ ),
+ protoSvc: Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService(
+ Ci.nsIExternalProtocolService
+ ),
+
+ get loadingTabString() {
+ delete this.loadingTabString;
+ return (this.loadingTabString = document
+ .getElementById("bundle_messenger")
+ .getString("loadingTab"));
+ },
+
+ modes: {
+ addressBookTab: {
+ type: "addressBookTab",
+ },
+ },
+
+ shouldSwitchTo(aArgs) {
+ if (!this.tab) {
+ return -1;
+ }
+
+ if ("onLoad" in aArgs) {
+ if (this.tab.browser.contentDocument.readyState != "complete") {
+ this.tab.browser.addEventListener(
+ "about-addressbook-ready",
+ event => aArgs.onLoad(event, this.tab.browser),
+ {
+ capture: true,
+ once: true,
+ }
+ );
+ } else {
+ aArgs.onLoad(null, this.tab.browser);
+ }
+ }
+ return document.getElementById("tabmail").tabInfo.indexOf(this.tab);
+ },
+
+ closeTab(aTab) {
+ this.tab = null;
+ },
+
+ openTab(aTab, aArgs) {
+ aTab.tabNode.setIcon(
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("preferencesTab")
+ .firstElementChild.cloneNode(true);
+
+ clone.setAttribute("id", "addressBookTab" + this.lastBrowserId);
+ clone.setAttribute("collapsed", false);
+
+ aTab.panel.setAttribute("id", "addressBookTabWrapper" + this.lastBrowserId);
+ aTab.panel.appendChild(clone);
+
+ // Start setting up the browser.
+ aTab.browser = aTab.panel.querySelector("browser");
+ aTab.browser.setAttribute(
+ "id",
+ "addressBookTabBrowser" + this.lastBrowserId
+ );
+ aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler);
+
+ aTab.findbar = document.createXULElement("findbar");
+ aTab.findbar.setAttribute(
+ "browserid",
+ "addressBookTabBrowser" + this.lastBrowserId
+ );
+ aTab.panel.appendChild(aTab.findbar);
+
+ // Default to reload being disabled.
+ aTab.reloadEnabled = false;
+
+ aTab.url = "about:addressbook";
+ aTab.paneID = aArgs.paneID;
+ aTab.scrollPaneTo = aArgs.scrollPaneTo;
+ aTab.otherArgs = aArgs.otherArgs;
+
+ // Now set up the listeners.
+ this._setUpTitleListener(aTab);
+ this._setUpCloseWindowListener(aTab);
+
+ // Wait for full loading of the tab and the automatic selecting of last tab.
+ // Then run the given onload code.
+ aTab.browser.addEventListener(
+ "about-addressbook-ready",
+ function (event) {
+ aTab.pageLoading = false;
+ aTab.pageLoaded = true;
+
+ if ("onLoad" in aArgs) {
+ // Let selection of the initial pane complete before selecting another.
+ // Otherwise we can end up with two panes selected at once.
+ aTab.browser.contentWindow.setTimeout(() => {
+ // By now, the tab could already be closed. Check that it isn't.
+ if (aTab.panel) {
+ aArgs.onLoad(event, aTab.browser);
+ }
+ });
+ }
+ },
+ {
+ capture: true,
+ once: true,
+ }
+ );
+
+ // Initialize our unit testing variables.
+ aTab.pageLoading = true;
+ aTab.pageLoaded = false;
+
+ // Now start loading the content.
+ aTab.title = this.loadingTabString;
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", aTab.browser);
+ let params = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ postData: aArgs.postData || null,
+ };
+ aTab.browser.loadURI(Services.io.newURI("about:addressbook"), params);
+
+ this.tab = aTab;
+ this.lastBrowserId++;
+ },
+
+ persistTab(aTab) {
+ if (aTab.browser.currentURI.spec == "about:blank") {
+ return null;
+ }
+
+ return {};
+ },
+
+ restoreTab(aTabmail, aPersistedState) {
+ aTabmail.openTab("addressBookTab", {});
+ },
+
+ doCommand(aCommand, aTab) {
+ if (aCommand == "cmd_print") {
+ aTab.browser.contentWindow.externalAction({ action: "print" });
+ return;
+ }
+ this.__proto__.doCommand(aCommand, aTab);
+ },
+};
diff --git a/comm/mail/components/addrbook/content/menulist-addrbooks.js b/comm/mail/components/addrbook/content/menulist-addrbooks.js
new file mode 100644
index 0000000000..6d919d98ad
--- /dev/null
+++ b/comm/mail/components/addrbook/content/menulist-addrbooks.js
@@ -0,0 +1,271 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+// The menulist CE is defined lazily. Create one now to get menulist defined,
+// allowing us to inherit from it.
+if (!customElements.get("menulist")) {
+ delete document.createXULElement("menulist");
+}
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ /**
+ * MozMenulistAddrbooks is a menulist widget that is automatically
+ * populated with the complete address book list.
+ *
+ * @augments {MozMenuList}
+ */
+ class MozMenulistAddrbooks extends customElements.get("menulist") {
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ if (this.menupopup) {
+ return;
+ }
+
+ this._directories = [];
+
+ this._rebuild();
+
+ // Store as a member of `this` so there's a strong reference.
+ this._addressBookListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ _notifications: [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-reloaded",
+ ],
+
+ init() {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic, true);
+ }
+ window.addEventListener("unload", this);
+ },
+
+ cleanUp() {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ window.removeEventListener("unload", this);
+ },
+
+ handleEvent(event) {
+ this.cleanUp();
+ },
+
+ observe: (subject, topic, data) => {
+ // Test-only reload of the address book manager.
+ if (topic == "addrbook-reloaded") {
+ this._rebuild();
+ return;
+ }
+
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ switch (topic) {
+ case "addrbook-directory-created": {
+ if (this._matches(subject)) {
+ this._rebuild();
+ }
+ break;
+ }
+ case "addrbook-directory-updated": {
+ // Find the item in the list to rename.
+ // We can't use indexOf here because we need loose equality.
+ let len = this._directories.length;
+ for (var oldIndex = len - 1; oldIndex >= 0; oldIndex--) {
+ if (this._directories[oldIndex] == subject) {
+ break;
+ }
+ }
+ if (oldIndex != -1) {
+ this._rebuild();
+ }
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ // Find the item in the list to remove.
+ // We can't use indexOf here because we need loose equality.
+ let len = this._directories.length;
+ for (var index = len - 1; index >= 0; index--) {
+ if (this._directories[index] == subject) {
+ break;
+ }
+ }
+ if (index != -1) {
+ this._directories.splice(index, 1);
+ // Are we removing the selected directory?
+ if (
+ this.selectedItem ==
+ this.menupopup.removeChild(this.menupopup.children[index])
+ ) {
+ // If so, try to select the first directory, if available.
+ if (this.menupopup.hasChildNodes()) {
+ this.menupopup.firstElementChild.doCommand();
+ } else {
+ this.selectedItem = null;
+ }
+ }
+ }
+ break;
+ }
+ }
+ },
+ };
+
+ this._addressBookListener.init();
+ }
+
+ /**
+ * Returns the address book type based on the remoteonly attribute
+ * of the menulist.
+ *
+ * "URI" Local Address Book
+ * "dirPrefId" Remote LDAP Directory
+ */
+ get _type() {
+ return this.getAttribute("remoteonly") ? "dirPrefId" : "URI";
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._addressBookListener.cleanUp();
+ this._teardown();
+ }
+
+ _rebuild() {
+ // Init the address book cache.
+ this._directories.length = 0;
+
+ for (let ab of MailServices.ab.directories) {
+ if (this._matches(ab)) {
+ this._directories.push(ab);
+
+ if (this.getAttribute("mailinglists") == "true") {
+ // Also append contained mailinglists.
+ for (let list of ab.childNodes) {
+ if (this._matches(list)) {
+ this._directories.push(list);
+ }
+ }
+ }
+ }
+ }
+
+ this._teardown();
+
+ if (this.hasAttribute("none")) {
+ // Create a dummy menuitem representing no selection.
+ this._directories.unshift(null);
+ let listItem = this.appendItem(this.getAttribute("none"), "");
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+ }
+
+ if (this.hasAttribute("alladdressbooks")) {
+ // Insert a menuitem representing All Addressbooks.
+ let allABLabel = this.getAttribute("alladdressbooks");
+ if (allABLabel == "true") {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ allABLabel = bundle.GetStringFromName("allAddressBooks");
+ }
+
+ this._directories.unshift(null);
+ let listItem = this.appendItem(allABLabel, "moz-abdirectory://?");
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+ }
+
+ // Now create menuitems for all displayed directories.
+ let type = this._type;
+ for (let ab of this._directories) {
+ if (!ab) {
+ // Skip the empty members added above.
+ continue;
+ }
+
+ let listItem = this.appendItem(ab.dirName, ab[type]);
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+
+ // Style the items by type.
+ if (ab.isMailList) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/user-list.svg"
+ );
+ } else if (ab.isRemote && ab.isSecure) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/globe-secure.svg"
+ );
+ } else if (ab.isRemote) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/globe.svg"
+ );
+ } else {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+ }
+ }
+
+ // Attempt to select the persisted or otherwise first directory.
+ this.selectedIndex = this._directories.findIndex(d => {
+ return d && d[type] == this.value;
+ });
+
+ if (!this.selectedItem && this.menupopup.hasChildNodes()) {
+ this.selectedIndex = 0;
+ }
+ }
+
+ _teardown() {
+ // Empty out anything in the list.
+ while (this.menupopup && this.menupopup.hasChildNodes()) {
+ this.menupopup.lastChild.remove();
+ }
+ }
+
+ _matches(ab) {
+ // This condition is used for instance when creating cards
+ if (this.getAttribute("writable") == "true" && ab.readOnly) {
+ return false;
+ }
+
+ // This condition is used for instance when creating mailing lists
+ if (
+ this.getAttribute("supportsmaillists") == "true" &&
+ !ab.supportsMailingLists
+ ) {
+ return false;
+ }
+
+ return (
+ this.getAttribute(ab.isRemote ? "localonly" : "remoteonly") != "true"
+ );
+ }
+ }
+
+ customElements.define("menulist-addrbooks", MozMenulistAddrbooks, {
+ extends: "menulist",
+ });
+}
diff --git a/comm/mail/components/addrbook/content/vcard-edit/adr.mjs b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs
new file mode 100644
index 0000000000..2f395173f3
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs
@@ -0,0 +1,149 @@
+/* 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 { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ADR
+ */
+export class VCardAdrComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("adr", {}, "text", [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ]);
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-adr");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.streetEl = this.querySelector('textarea[name="street"]');
+ this.assignIds(this.streetEl, this.querySelector('label[for="street"]'));
+ this.streetEl.addEventListener("input", () => {
+ this.resizeStreetEl();
+ });
+
+ this.localityEl = this.querySelector('input[name="locality"]');
+ this.assignIds(
+ this.localityEl,
+ this.querySelector('label[for="locality"]')
+ );
+
+ this.regionEl = this.querySelector('input[name="region"]');
+ this.assignIds(this.regionEl, this.querySelector('label[for="region"]'));
+
+ this.codeEl = this.querySelector('input[name="code"]');
+ this.assignIds(this.regionEl, this.querySelector('label[for="code"]'));
+
+ this.countryEl = this.querySelector('input[name="country"]');
+ this.assignIds(this.countryEl, this.querySelector('label[for="country"]'));
+
+ // Create the adr type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ });
+
+ this.fromVCardPropertyEntryToUI();
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ }
+
+ fromVCardPropertyEntryToUI() {
+ if (Array.isArray(this.vCardPropertyEntry.value[2])) {
+ this.streetEl.value = this.vCardPropertyEntry.value[2].join("\n");
+ } else {
+ this.streetEl.value = this.vCardPropertyEntry.value[2] || "";
+ }
+ // Per RFC 6350, post office box and extended address SHOULD be empty.
+ let pobox = this.vCardPropertyEntry.value[0] || "";
+ let extendedAddr = this.vCardPropertyEntry.value[1] || "";
+ if (extendedAddr) {
+ this.streetEl.value = this.streetEl.value + "\n" + extendedAddr.trim();
+ delete this.vCardPropertyEntry.value[1];
+ }
+ if (pobox) {
+ this.streetEl.value = pobox.trim() + "\n" + this.streetEl.value;
+ delete this.vCardPropertyEntry.value[0];
+ }
+
+ this.resizeStreetEl();
+ this.localityEl.value = this.vCardPropertyEntry.value[3] || "";
+ this.regionEl.value = this.vCardPropertyEntry.value[4] || "";
+ this.codeEl.value = this.vCardPropertyEntry.value[5] || "";
+ this.countryEl.value = this.vCardPropertyEntry.value[6] || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ let streetValue = this.streetEl.value || "";
+ streetValue = streetValue.trim();
+ if (streetValue.includes("\n")) {
+ streetValue = streetValue.replaceAll("\r", "");
+ streetValue = streetValue.split("\n");
+ }
+
+ this.vCardPropertyEntry.value = [
+ "",
+ "",
+ streetValue,
+ this.localityEl.value || "",
+ this.regionEl.value || "",
+ this.codeEl.value || "",
+ this.countryEl.value || "",
+ ];
+ }
+
+ valueIsEmpty() {
+ return [
+ this.streetEl,
+ this.localityEl,
+ this.regionEl,
+ this.codeEl,
+ this.countryEl,
+ ].every(e => !e.value);
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+
+ resizeStreetEl() {
+ this.streetEl.rows = Math.max(1, this.streetEl.value.split("\n").length);
+ }
+}
+
+customElements.define("vcard-adr", VCardAdrComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/custom.mjs b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs
new file mode 100644
index 0000000000..bcdb1f6531
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs
@@ -0,0 +1,60 @@
+/* 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 { vCardIdGen } from "./id-gen.mjs";
+
+export class VCardCustomComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry[]} */
+ vCardPropertyEntries = null;
+ /** @type {HTMLInputElement[]} */
+ inputEls = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-custom");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.inputEls = this.querySelectorAll("input");
+ let labelEls = this.querySelectorAll("label");
+ for (let i = 0; i < 4; i++) {
+ let inputId = vCardIdGen.next().value;
+ document.l10n.setAttributes(
+ labelEls[i],
+ `about-addressbook-entry-name-custom${i + 1}`
+ );
+ labelEls[i].htmlFor = inputId;
+ this.inputEls[i].id = inputId;
+ }
+ this.fromVCardPropertyEntryToUI();
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-custom").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ }
+
+ fromVCardPropertyEntryToUI() {
+ for (let i = 0; i < 4; i++) {
+ this.inputEls[i].value = this.vCardPropertyEntries[i].value;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ for (let i = 0; i < 4; i++) {
+ this.vCardPropertyEntries[i].value = this.inputEls[i].value;
+ }
+ }
+}
+
+customElements.define("vcard-custom", VCardCustomComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/edit.mjs b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs
new file mode 100644
index 0000000000..90463e33bb
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs
@@ -0,0 +1,1094 @@
+/* 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 { vCardIdGen } from "./id-gen.mjs";
+import { VCardAdrComponent } from "./adr.mjs";
+import { VCardCustomComponent } from "./custom.mjs";
+import { VCardEmailComponent } from "./email.mjs";
+import { VCardIMPPComponent } from "./impp.mjs";
+import { VCardNComponent } from "./n.mjs";
+import { VCardFNComponent } from "./fn.mjs";
+import { VCardNickNameComponent } from "./nickname.mjs";
+import { VCardNoteComponent } from "./note.mjs";
+import {
+ VCardOrgComponent,
+ VCardRoleComponent,
+ VCardTitleComponent,
+} from "./org.mjs";
+import { VCardSpecialDateComponent } from "./special-date.mjs";
+import { VCardTelComponent } from "./tel.mjs";
+import { VCardTZComponent } from "./tz.mjs";
+import { VCardURLComponent } from "./url.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardProperties",
+ "resource:///modules/VCardUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+class VCardEdit extends HTMLElement {
+ constructor() {
+ super();
+
+ this.contactNameHeading = document.getElementById("editContactHeadingName");
+ this.contactNickNameHeading = document.getElementById(
+ "editContactHeadingNickName"
+ );
+ this.contactEmailHeading = document.getElementById(
+ "editContactHeadingEmail"
+ );
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.updateView();
+
+ this.addEventListener("vcard-remove-property", e => {
+ if (e.target.vCardPropertyEntries) {
+ for (let entry of e.target.vCardPropertyEntries) {
+ this.vCardProperties.removeEntry(entry);
+ }
+ } else {
+ this.vCardProperties.removeEntry(e.target.vCardPropertyEntry);
+ }
+
+ // Move the focus to the first available valid element of the fieldset.
+ let sibling =
+ e.target.nextElementSibling || e.target.previousElementSibling;
+ // If we got a button, focus it since it's the "add row" button.
+ if (sibling?.type == "button") {
+ sibling.focus();
+ return;
+ }
+
+ // Otherwise we have a row field, so try to find a focusable element.
+ if (sibling && this.moveFocusIntoElement(sibling)) {
+ return;
+ }
+
+ // If we reach this point, the markup was unpredictable and we should
+ // move the focus to a valid element to avoid focus lost.
+ e.target
+ .closest("fieldset")
+ .querySelector(".add-property-button")
+ .focus();
+ });
+ }
+ }
+
+ disconnectedCallback() {
+ this.replaceChildren();
+ }
+
+ get vCardString() {
+ return this._vCardProperties.toVCard();
+ }
+
+ set vCardString(value) {
+ if (value) {
+ try {
+ this.vCardProperties = lazy.VCardProperties.fromVCard(value);
+ return;
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ this.vCardProperties = new lazy.VCardProperties("4.0");
+ }
+
+ get vCardProperties() {
+ return this._vCardProperties;
+ }
+
+ set vCardProperties(value) {
+ this._vCardProperties = value;
+ // If no n property is present set one.
+ if (!this._vCardProperties.getFirstEntry("n")) {
+ this._vCardProperties.addEntry(VCardNComponent.newVCardPropertyEntry());
+ }
+ // If no fn property is present set one.
+ if (!this._vCardProperties.getFirstEntry("fn")) {
+ this._vCardProperties.addEntry(VCardFNComponent.newVCardPropertyEntry());
+ }
+ // If no nickname property is present set one.
+ if (!this._vCardProperties.getFirstEntry("nickname")) {
+ this._vCardProperties.addEntry(
+ VCardNickNameComponent.newVCardPropertyEntry()
+ );
+ }
+ // If no email property is present set one.
+ if (!this._vCardProperties.getFirstEntry("email")) {
+ let emailEntry = VCardEmailComponent.newVCardPropertyEntry();
+ emailEntry.params.pref = "1"; // Set as default email.
+ this._vCardProperties.addEntry(emailEntry);
+ }
+ // If one of the organizational properties is present,
+ // make sure they all are.
+ let title = this._vCardProperties.getFirstEntry("title");
+ let role = this._vCardProperties.getFirstEntry("role");
+ let org = this._vCardProperties.getFirstEntry("org");
+ if (title || role || org) {
+ if (!title) {
+ this._vCardProperties.addEntry(
+ VCardTitleComponent.newVCardPropertyEntry()
+ );
+ }
+ if (!role) {
+ this._vCardProperties.addEntry(
+ VCardRoleComponent.newVCardPropertyEntry()
+ );
+ }
+ if (!org) {
+ this._vCardProperties.addEntry(
+ VCardOrgComponent.newVCardPropertyEntry()
+ );
+ }
+ }
+
+ for (let i = 1; i <= 4; i++) {
+ if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "")
+ );
+ }
+ }
+
+ this.updateView();
+ }
+
+ updateView() {
+ // Create new DOM and replacing other vCardProperties.
+ let template = document.getElementById("template-addr-book-edit");
+ let clonedTemplate = template.content.cloneNode(true);
+ // Making the next two calls in one go causes a console error to be logged.
+ this.replaceChildren();
+ this.append(clonedTemplate);
+
+ if (!this.vCardProperties) {
+ return;
+ }
+
+ this.addFieldsetActions();
+
+ // Insert the vCard property entries.
+ for (let vCardPropertyEntry of this.vCardProperties.entries) {
+ this.insertVCardElement(vCardPropertyEntry, false);
+ }
+
+ let customProperties = ["x-custom1", "x-custom2", "x-custom3", "x-custom4"];
+ if (customProperties.some(key => this.vCardProperties.getFirstValue(key))) {
+ // If one of these properties has a value, display all of them.
+ let customFieldset = this.querySelector("#addr-book-edit-custom");
+ let customEl =
+ customFieldset.querySelector("vcard-custom") ||
+ new VCardCustomComponent();
+ customEl.vCardPropertyEntries = customProperties.map(key =>
+ this._vCardProperties.getFirstEntry(key)
+ );
+ let addCustom = document.getElementById("vcard-add-custom");
+ customFieldset.insertBefore(customEl, addCustom);
+ addCustom.hidden = true;
+ }
+
+ let nameEl = this.querySelector("vcard-n");
+ this.firstName = nameEl.firstNameEl.querySelector("input");
+ this.lastName = nameEl.lastNameEl.querySelector("input");
+ this.prefixName = nameEl.prefixEl.querySelector("input");
+ this.middleName = nameEl.middleNameEl.querySelector("input");
+ this.suffixName = nameEl.suffixEl.querySelector("input");
+ this.displayName = this.querySelector("vcard-fn").displayEl;
+
+ [
+ this.firstName,
+ this.lastName,
+ this.prefixName,
+ this.middleName,
+ this.suffixName,
+ this.displayName,
+ ].forEach(element => {
+ element.addEventListener("input", event =>
+ this.generateContactName(event)
+ );
+ });
+
+ // Only set the strings and define this selector if we're inside the
+ // address book edit panel.
+ if (document.getElementById("detailsPane")) {
+ this.preferDisplayName = this.querySelector("vcard-fn").preferDisplayEl;
+ document.l10n.setAttributes(
+ this.preferDisplayName.closest(".vcard-checkbox").querySelector("span"),
+ "about-addressbook-prefer-display-name"
+ );
+ }
+
+ this.nickName = this.querySelector("vcard-nickname").nickNameEl;
+ this.nickName.addEventListener("input", () => this.updateNickName());
+
+ if (this.vCardProperties) {
+ this.toggleDefaultEmailView();
+ this.checkForBdayOccurrences();
+ }
+
+ this.updateNickName();
+ this.updateEmailHeading();
+ this.generateContactName();
+ }
+
+ /**
+ * Update the contact name to reflect the users' choice.
+ *
+ * @param {?Event} event - The DOM event if we have one.
+ */
+ async generateContactName(event = null) {
+ // Don't generate any preview if the contact name element is not available,
+ // which it might happen since this component is used in other areas outside
+ // the address book UI.
+ if (!this.contactNameHeading) {
+ return;
+ }
+
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ let result = "";
+ let pref = Services.prefs.getIntPref("mail.addr_book.lastnamefirst");
+ switch (pref) {
+ case Ci.nsIAbCard.GENERATE_DISPLAY_NAME:
+ result = this.buildDefaultName();
+ break;
+
+ case Ci.nsIAbCard.GENERATE_LAST_FIRST_ORDER:
+ if (this.lastName.value) {
+ result = bundle.formatStringFromName("lastFirstFormat", [
+ this.lastName.value,
+ [
+ this.prefixName.value,
+ this.firstName.value,
+ this.middleName.value,
+ this.suffixName.value,
+ ]
+ .filter(Boolean)
+ .join(" "),
+ ]);
+ } else {
+ // Get the generic name if we don't have a last name.
+ result = this.buildDefaultName();
+ }
+ break;
+
+ default:
+ result = bundle.formatStringFromName("firstLastFormat", [
+ [this.prefixName.value, this.firstName.value, this.middleName.value]
+ .filter(Boolean)
+ .join(" "),
+ [this.lastName.value, this.suffixName.value]
+ .filter(Boolean)
+ .join(" "),
+ ]);
+ break;
+ }
+
+ if (result == "" || result == ", ") {
+ // We don't have anything to show as a contact name, so let's find the
+ // default email and show that, if we have it, otherwise pass an empty
+ // string to remove any leftover data.
+ let email = this.getDefaultEmail();
+ result = email ? email.split("@", 1)[0] : "";
+ }
+
+ this.contactNameHeading.textContent = result;
+ this.fillDisplayName(event);
+ }
+
+ /**
+ * Returns the name to show for this contact if the display name is available
+ * or it generates one from the available N data.
+ *
+ * @returns {string} - The name to show for this contact.
+ */
+ buildDefaultName() {
+ return this.displayName.isDirty
+ ? this.displayName.value
+ : [
+ this.prefixName.value,
+ this.firstName.value,
+ this.middleName.value,
+ this.lastName.value,
+ this.suffixName.value,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ }
+
+ /**
+ * Update the nickname value of the contact header.
+ */
+ updateNickName() {
+ // Don't generate any preview if the contact nickname element is not
+ // available, which it might happen since this component is used in other
+ // areas outside the address book UI.
+ if (!this.contactNickNameHeading) {
+ return;
+ }
+
+ let value = this.nickName.value.trim();
+ this.contactNickNameHeading.hidden = !value;
+ this.contactNickNameHeading.textContent = value;
+ }
+
+ /**
+ * Update the email value of the contact header.
+ *
+ * @param {?string} email - The email value the user is currently typing.
+ */
+ updateEmailHeading(email = null) {
+ // Don't generate any preview if the contact nickname email is not
+ // available, which it might happen since this component is used in other
+ // areas outside the address book UI.
+ if (!this.contactEmailHeading) {
+ return;
+ }
+
+ // If no email string was passed, it means this method was called when the
+ // view or edit pane refreshes, therefore we need to fetch the correct
+ // default email address.
+ let value = email ?? this.getDefaultEmail();
+ this.contactEmailHeading.hidden = !value;
+ this.contactEmailHeading.textContent = value;
+ }
+
+ /**
+ * Find the default email used for this contact.
+ *
+ * @returns {VCardEmailComponent}
+ */
+ getDefaultEmail() {
+ let emails = document.getElementById("vcard-email").children;
+ if (emails.length == 1) {
+ return emails[0].emailEl.value;
+ }
+
+ let defaultEmail = [...emails].find(
+ el => el.vCardPropertyEntry.params.pref === "1"
+ );
+
+ // If no email is marked as preferred, use the first one.
+ if (!defaultEmail) {
+ defaultEmail = emails[0];
+ }
+
+ return defaultEmail.emailEl.value;
+ }
+
+ /**
+ * Auto fill the display name only if the pref is set, the user is not
+ * editing the display name field, and the field was never edited.
+ * The intention is to prefill while entering a new contact. Don't fill
+ * if we don't have a proper default name to show, but only a placeholder.
+ *
+ * @param {?Event} event - The DOM event if we have one.
+ */
+ fillDisplayName(event = null) {
+ if (
+ Services.prefs.getBoolPref("mail.addr_book.displayName.autoGeneration") &&
+ event?.originalTarget.id != "vCardDisplayName" &&
+ !this.displayName.isDirty &&
+ this.buildDefaultName()
+ ) {
+ this.displayName.value = this.contactNameHeading.textContent;
+ }
+ }
+
+ /**
+ * Inserts a custom element for a {VCardPropertyEntry}
+ *
+ * - Assigns rich data (not bind to a html attribute) and therefore
+ * the reference.
+ * - Inserts the element in the form at the correct position.
+ *
+ * @param {VCardPropertyEntry} entry
+ * @param {boolean} addEntry Adds the entry to the vCardProperties.
+ * @returns {VCardPropertyEntryView | undefined}
+ */
+ insertVCardElement(entry, addEntry) {
+ // Add the entry to the vCardProperty data.
+ if (addEntry) {
+ this.vCardProperties.addEntry(entry);
+ }
+
+ let fieldset;
+ let addButton;
+ switch (entry.name) {
+ case "n":
+ let n = new VCardNComponent();
+ n.vCardPropertyEntry = entry;
+ fieldset = document.getElementById("addr-book-edit-n");
+ let displayNicknameContainer = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(n, displayNicknameContainer);
+ return n;
+ case "fn":
+ let fn = new VCardFNComponent();
+ fn.vCardPropertyEntry = entry;
+ fieldset = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(fn, fieldset.firstElementChild);
+ return fn;
+ case "nickname":
+ let nickname = new VCardNickNameComponent();
+ nickname.vCardPropertyEntry = entry;
+ fieldset = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(
+ nickname,
+ fieldset.firstElementChild?.nextElementSibling
+ );
+ return nickname;
+ case "email":
+ let email = document.createElement("tr", { is: "vcard-email" });
+ email.vCardPropertyEntry = entry;
+ document.getElementById("vcard-email").appendChild(email);
+ return email;
+ case "url":
+ let url = new VCardURLComponent();
+ url.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-url");
+ addButton = document.getElementById("vcard-add-url");
+ fieldset.insertBefore(url, addButton);
+ return url;
+ case "tel":
+ let tel = new VCardTelComponent();
+ tel.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-tel");
+ addButton = document.getElementById("vcard-add-tel");
+ fieldset.insertBefore(tel, addButton);
+ return tel;
+ case "tz":
+ let tz = new VCardTZComponent();
+ tz.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-tz");
+ addButton = document.getElementById("vcard-add-tz");
+ fieldset.insertBefore(tz, addButton);
+ addButton.hidden = true;
+ return tz;
+ case "impp":
+ let impp = new VCardIMPPComponent();
+ impp.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-impp");
+ addButton = document.getElementById("vcard-add-impp");
+ fieldset.insertBefore(impp, addButton);
+ return impp;
+ case "anniversary":
+ let anniversary = new VCardSpecialDateComponent();
+ anniversary.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-bday-anniversary");
+ addButton = document.getElementById("vcard-add-bday-anniversary");
+ fieldset.insertBefore(anniversary, addButton);
+ return anniversary;
+ case "bday":
+ let bday = new VCardSpecialDateComponent();
+ bday.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-bday-anniversary");
+ addButton = document.getElementById("vcard-add-bday-anniversary");
+ fieldset.insertBefore(bday, addButton);
+ return bday;
+ case "adr":
+ let address = new VCardAdrComponent();
+ address.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-address");
+ addButton = document.getElementById("vcard-add-adr");
+ fieldset.insertBefore(address, addButton);
+ return address;
+ case "note":
+ let note = new VCardNoteComponent();
+ note.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-note");
+ addButton = document.getElementById("vcard-add-note");
+ fieldset.insertBefore(note, addButton);
+ // Only one note is allowed via UI.
+ addButton.hidden = true;
+ return note;
+ case "title":
+ let title = new VCardTitleComponent();
+ title.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(
+ title,
+ fieldset.querySelector("vcard-role, vcard-org, #vcard-add-org")
+ );
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one title is allowed via UI.
+ addButton.hidden = true;
+ return title;
+ case "role":
+ let role = new VCardRoleComponent();
+ role.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(
+ role,
+ fieldset.querySelector("vcard-org, #vcard-add-org")
+ );
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one role is allowed via UI.
+ addButton.hidden = true;
+ return role;
+ case "org":
+ let org = new VCardOrgComponent();
+ org.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(org, addButton);
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one org is allowed via UI.
+ addButton.hidden = true;
+ return org;
+ default:
+ return undefined;
+ }
+ }
+
+ /**
+ * Creates a VCardPropertyEntry with a matching
+ * name to the vCard spec.
+ *
+ * @param {string} entryName - A name which should be a vCard spec property.
+ * @returns {VCardPropertyEntry | undefined}
+ */
+ static createVCardProperty(entryName) {
+ switch (entryName) {
+ case "n":
+ return VCardNComponent.newVCardPropertyEntry();
+ case "fn":
+ return VCardFNComponent.newVCardPropertyEntry();
+ case "nickname":
+ return VCardNickNameComponent.newVCardPropertyEntry();
+ case "email":
+ return VCardEmailComponent.newVCardPropertyEntry();
+ case "url":
+ return VCardURLComponent.newVCardPropertyEntry();
+ case "tel":
+ return VCardTelComponent.newVCardPropertyEntry();
+ case "tz":
+ return VCardTZComponent.newVCardPropertyEntry();
+ case "impp":
+ return VCardIMPPComponent.newVCardPropertyEntry();
+ case "bday":
+ return VCardSpecialDateComponent.newBdayVCardPropertyEntry();
+ case "anniversary":
+ return VCardSpecialDateComponent.newAnniversaryVCardPropertyEntry();
+ case "adr":
+ return VCardAdrComponent.newVCardPropertyEntry();
+ case "note":
+ return VCardNoteComponent.newVCardPropertyEntry();
+ case "title":
+ return VCardTitleComponent.newVCardPropertyEntry();
+ case "role":
+ return VCardRoleComponent.newVCardPropertyEntry();
+ case "org":
+ return VCardOrgComponent.newVCardPropertyEntry();
+ default:
+ return undefined;
+ }
+ }
+
+ /**
+ * Mutates the referenced vCardPropertyEntry(s).
+ * If the value of a VCardPropertyEntry is empty, the entry gets
+ * removed from the vCardProperty.
+ */
+ saveVCard() {
+ for (let node of [
+ ...this.querySelectorAll("vcard-adr"),
+ ...this.querySelectorAll("vcard-custom"),
+ ...document.getElementById("vcard-email").children,
+ ...this.querySelectorAll("vcard-fn"),
+ ...this.querySelectorAll("vcard-impp"),
+ ...this.querySelectorAll("vcard-n"),
+ ...this.querySelectorAll("vcard-nickname"),
+ ...this.querySelectorAll("vcard-note"),
+ ...this.querySelectorAll("vcard-org"),
+ ...this.querySelectorAll("vcard-role"),
+ ...this.querySelectorAll("vcard-title"),
+ ...this.querySelectorAll("vcard-special-date"),
+ ...this.querySelectorAll("vcard-tel"),
+ ...this.querySelectorAll("vcard-tz"),
+ ...this.querySelectorAll("vcard-url"),
+ ]) {
+ if (typeof node.fromUIToVCardPropertyEntry === "function") {
+ node.fromUIToVCardPropertyEntry();
+ }
+
+ // Filter out empty fields.
+ if (typeof node.valueIsEmpty === "function" && node.valueIsEmpty()) {
+ this.vCardProperties.removeEntry(node.vCardPropertyEntry);
+ }
+ }
+
+ // If no email has a pref value of 1, set it to the first email.
+ let emailEntries = this.vCardProperties.getAllEntries("email");
+ if (
+ emailEntries.length >= 1 &&
+ emailEntries.every(entry => entry.params.pref !== "1")
+ ) {
+ emailEntries[0].params.pref = "1";
+ }
+
+ for (let i = 1; i <= 4; i++) {
+ let entry = this._vCardProperties.getFirstEntry(`x-custom${i}`);
+ if (entry && !entry.value) {
+ this._vCardProperties.removeEntry(entry);
+ }
+ }
+ }
+
+ /**
+ * Move focus into the form.
+ */
+ setFocus() {
+ this.querySelector("vcard-n input:not([hidden])").focus();
+ }
+
+ /**
+ * Move focus to the first visible form element below the given element.
+ *
+ * @param {Element} element - The element to move focus into.
+ * @returns {boolean} - If the focus was moved into the element.
+ */
+ moveFocusIntoElement(element) {
+ for (let child of element.querySelectorAll(
+ "select,input,textarea,button"
+ )) {
+ // Make sure it is visible.
+ if (child.clientWidth != 0 && child.clientHeight != 0) {
+ child.focus();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Add buttons and further actions of the groupings for vCard property
+ * entries.
+ */
+ addFieldsetActions() {
+ // Add email button.
+ let addEmail = document.getElementById("vcard-add-email");
+ this.registerAddButton(addEmail, "email", () => {
+ this.toggleDefaultEmailView();
+ });
+
+ // Add listener to update the email written in the contact header.
+ this.addEventListener("vcard-email-default-changed", event => {
+ this.updateEmailHeading(
+ event.target.querySelector('input[type="email"]').value
+ );
+ });
+
+ // Add listener to be sure that only one checkbox from the emails is ticked.
+ this.addEventListener("vcard-email-default-checkbox", event => {
+ // Show the newly selected default email in the contact header.
+ this.updateEmailHeading(
+ event.target.querySelector('input[type="email"]').value
+ );
+ for (let vCardEmailComponent of document.getElementById("vcard-email")
+ .children) {
+ if (event.target !== vCardEmailComponent) {
+ vCardEmailComponent.checkboxEl.checked = false;
+ }
+ }
+ });
+
+ // Handling the VCardPropertyEntry change with the select.
+ let specialDatesFieldset = document.getElementById(
+ "addr-book-edit-bday-anniversary"
+ );
+ specialDatesFieldset.addEventListener(
+ "vcard-bday-anniversary-change",
+ event => {
+ let newVCardPropertyEntry = new lazy.VCardPropertyEntry(
+ event.detail.name,
+ event.target.vCardPropertyEntry.params,
+ event.target.vCardPropertyEntry.type,
+ event.target.vCardPropertyEntry.value
+ );
+ this.vCardProperties.removeEntry(event.target.vCardPropertyEntry);
+ event.target.vCardPropertyEntry = newVCardPropertyEntry;
+ this.vCardProperties.addEntry(newVCardPropertyEntry);
+ this.checkForBdayOccurrences();
+ }
+ );
+
+ // Add special date button.
+ let addSpecialDate = document.getElementById("vcard-add-bday-anniversary");
+ addSpecialDate.addEventListener("click", e => {
+ let newVCardProperty;
+ if (!this.vCardProperties.getFirstEntry("bday")) {
+ newVCardProperty = VCardEdit.createVCardProperty("bday");
+ } else {
+ newVCardProperty = VCardEdit.createVCardProperty("anniversary");
+ }
+ let el = this.insertVCardElement(newVCardProperty, true);
+ this.checkForBdayOccurrences();
+ this.moveFocusIntoElement(el);
+ });
+
+ // Organizational Properties.
+ let addOrg = document.getElementById("vcard-add-org");
+ addOrg.addEventListener("click", event => {
+ let title = VCardEdit.createVCardProperty("title");
+ let role = VCardEdit.createVCardProperty("role");
+ let org = VCardEdit.createVCardProperty("org");
+
+ let titleEl = this.insertVCardElement(title, true);
+ this.insertVCardElement(role, true);
+ this.insertVCardElement(org, true);
+
+ this.moveFocusIntoElement(titleEl);
+ addOrg.hidden = true;
+ });
+
+ let addAddress = document.getElementById("vcard-add-adr");
+ this.registerAddButton(addAddress, "adr");
+
+ let addURL = document.getElementById("vcard-add-url");
+ this.registerAddButton(addURL, "url");
+
+ let addTel = document.getElementById("vcard-add-tel");
+ this.registerAddButton(addTel, "tel");
+
+ let addTZ = document.getElementById("vcard-add-tz");
+ this.registerAddButton(addTZ, "tz", () => {
+ addTZ.hidden = true;
+ });
+
+ let addIMPP = document.getElementById("vcard-add-impp");
+ this.registerAddButton(addIMPP, "impp");
+
+ let addNote = document.getElementById("vcard-add-note");
+ this.registerAddButton(addNote, "note", () => {
+ addNote.hidden = true;
+ });
+
+ let addCustom = document.getElementById("vcard-add-custom");
+ addCustom.addEventListener("click", event => {
+ let el = new VCardCustomComponent();
+
+ // When the custom properties are deleted and added again ensure that
+ // the properties are set.
+ for (let i = 1; i <= 4; i++) {
+ if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "")
+ );
+ }
+ }
+
+ el.vCardPropertyEntries = [
+ this._vCardProperties.getFirstEntry("x-custom1"),
+ this._vCardProperties.getFirstEntry("x-custom2"),
+ this._vCardProperties.getFirstEntry("x-custom3"),
+ this._vCardProperties.getFirstEntry("x-custom4"),
+ ];
+ addCustom.parentNode.insertBefore(el, addCustom);
+
+ this.moveFocusIntoElement(el);
+ addCustom.hidden = true;
+ });
+
+ // Delete button for Organization Properties. This property has multiple
+ // fields, so we should dispatch the remove event only once after everything
+ // has been removed.
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).addEventListener("click", event => {
+ this.querySelector("vcard-title").remove();
+ this.querySelector("vcard-role").remove();
+ let org = this.querySelector("vcard-org");
+ // Reveal the "Add" button so we can focus it.
+ document.getElementById("vcard-add-org").hidden = false;
+ // Dispatch the event before removing the element so we can handle focus.
+ org.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ org.remove();
+ event.target.hidden = true;
+ });
+ }
+
+ /**
+ * Registers a click event for addButton which creates a new vCardProperty
+ * and inserts it.
+ *
+ * @param {HTMLButtonElement} addButton
+ * @param {string} VCardPropertyName RFC6350 vCard property name.
+ * @param {(vCardElement) => {}} callback For further refinement.
+ * Like different focus instead of an input field.
+ */
+ registerAddButton(addButton, VCardPropertyName, callback) {
+ addButton.addEventListener("click", event => {
+ let newVCardProperty = VCardEdit.createVCardProperty(VCardPropertyName);
+ let el = this.insertVCardElement(newVCardProperty, true);
+
+ this.moveFocusIntoElement(el);
+ if (callback) {
+ callback(el);
+ }
+ });
+ }
+
+ /**
+ * If one BDAY vCardPropertyEntry is present disable
+ * the option to change an Anniversary to a BDAY.
+ *
+ * @see VCardSpecialDateComponent
+ */
+ checkForBdayOccurrences() {
+ let bdayOccurrence = this.vCardProperties.getFirstEntry("bday");
+ this.querySelectorAll("vcard-special-date").forEach(specialDate => {
+ specialDate.birthdayAvailability({ hasBday: !!bdayOccurrence });
+ });
+ }
+
+ /**
+ * Hide the default checkbox if we only have one email field.
+ */
+ toggleDefaultEmailView() {
+ let hideDefault =
+ document.getElementById("vcard-email").children.length <= 1;
+ let defaultColumn = this.querySelector(".default-column");
+ if (defaultColumn) {
+ defaultColumn.hidden = hideDefault;
+ }
+ document.getElementById("addr-book-edit-email-default").hidden =
+ hideDefault;
+
+ // Add class to position legend absolute.
+ document
+ .getElementById("addr-book-edit-email")
+ .classList.toggle("default-table-header", !hideDefault);
+ }
+
+ /**
+ * Validate the form with the minimum required data to save or update a
+ * contact. We can't use the built-in checkValidity() since our fields
+ * are not handled properly by the form element.
+ *
+ * @returns {boolean} - If the form is valid or not.
+ */
+ checkMinimumRequirements() {
+ let hasEmail = [...document.getElementById("vcard-email").children].find(
+ s => {
+ let field = s.querySelector(`input[type="email"]`);
+ return field.value.trim() && field.checkValidity();
+ }
+ );
+ let hasOrg = [...this.querySelectorAll("vcard-org")].find(n =>
+ n.orgEl.value.trim()
+ );
+
+ return (
+ this.firstName.value.trim() ||
+ this.lastName.value.trim() ||
+ this.displayName.value.trim() ||
+ hasEmail ||
+ hasOrg
+ );
+ }
+
+ /**
+ * Validate the special date fields making sure that we have a valid
+ * DATE-AND-OR-TIME. See date, date-noreduc.
+ * That is, valid if any of the fields are valid, but the combination of
+ * only year and day is not valid.
+ *
+ * @returns {boolean} - True all created special date fields are valid.
+ * @see https://datatracker.ietf.org/doc/html/rfc6350#section-4.3.4
+ */
+ validateDates() {
+ for (let field of document.querySelectorAll("vcard-special-date")) {
+ let y = field.querySelector(`input[type="number"][name="year"]`);
+ let m = field.querySelector(`select[name="month"]`);
+ let d = field.querySelector(`select[name="day"]`);
+ if (!y.checkValidity()) {
+ y.focus();
+ return false;
+ }
+ if (y.value && d.value && !m.value) {
+ m.required = true;
+ m.focus();
+ return false;
+ }
+ }
+ return true;
+ }
+}
+customElements.define("vcard-edit", VCardEdit);
+
+/**
+ * Responsible for the type selection of a vCard property.
+ *
+ * Couples the given vCardPropertyEntry with a <select> element.
+ * This is safe because contact editing always creates a new contact, even
+ * when an existing contact is selected for editing.
+ *
+ * @see RFC6350 TYPE
+ */
+class VCardTypeSelectionComponent extends HTMLElement {
+ /**
+ * The select element created by this custom element.
+ *
+ * @type {HTMLSelectElement}
+ */
+ selectEl;
+
+ /**
+ * Initializes the type selector elements to control the given
+ * vCardPropertyEntry.
+ *
+ * @param {VCardPropertyEntry} vCardPropertyEntry - The VCardPropertyEntry
+ * this element should control.
+ * @param {boolean} [options.createLabel] - Whether a Type label should be
+ * created for the selectEl element. If this is not `true`, then the label
+ * for the selectEl should be provided through some other means, such as the
+ * labelledBy property.
+ * @param {string} [options.labelledBy] - Optional `id` of the element that
+ * should label the selectEl element (through aria-labelledby).
+ * @param {string} [options.propertyType] - Specifies the set of types that
+ * should be available and shown for the corresponding property. Set as
+ * "tel" to use the set of telephone types. Otherwise defaults to only using
+ * the `home`, `work` and `(None)` types.
+ */
+ createTypeSelection(vCardPropertyEntry, options) {
+ let template;
+ let types;
+ switch (options.propertyType) {
+ case "tel":
+ types = ["work", "home", "cell", "fax", "pager"];
+ template = document.getElementById("template-vcard-edit-type-tel");
+ break;
+ default:
+ types = ["work", "home"];
+ template = document.getElementById("template-vcard-edit-type");
+ break;
+ }
+
+ let clonedTemplate = template.content.cloneNode(true);
+ this.replaceChildren(clonedTemplate);
+
+ this.selectEl = this.querySelector("select");
+ let selectId = vCardIdGen.next().value;
+ this.selectEl.id = selectId;
+
+ // Just abandon any values we don't have UI for. We don't have any way to
+ // know whether to keep them or not, and they're very rarely used.
+ let paramsType = vCardPropertyEntry.params.type;
+ // toLowerCase is called because other vCard sources are saving the type
+ // in upper case. E.g. from Google.
+ if (Array.isArray(paramsType)) {
+ let lowerCaseTypes = paramsType.map(type => type.toLowerCase());
+ this.selectEl.value = lowerCaseTypes.find(t => types.includes(t)) || "";
+ } else if (paramsType && types.includes(paramsType.toLowerCase())) {
+ this.selectEl.value = paramsType.toLowerCase();
+ }
+
+ // Change the value on the vCardPropertyEntry.
+ this.selectEl.addEventListener("change", e => {
+ if (this.selectEl.value) {
+ vCardPropertyEntry.params.type = this.selectEl.value;
+ } else {
+ delete vCardPropertyEntry.params.type;
+ }
+ });
+
+ // Set an aria-labelledyby on the select.
+ if (options.labelledBy) {
+ if (!document.getElementById(options.labelledBy)) {
+ throw new Error(`No such label element with id ${options.labelledBy}`);
+ }
+ this.querySelector("select").setAttribute(
+ "aria-labelledby",
+ options.labelledBy
+ );
+ }
+
+ // Create a label element for the select.
+ if (options.createLabel) {
+ let labelEl = document.createElement("label");
+ labelEl.htmlFor = selectId;
+ labelEl.setAttribute("data-l10n-id", "vcard-entry-type-label");
+ labelEl.classList.add("screen-reader-only");
+ this.insertBefore(labelEl, this.selectEl);
+ }
+ }
+}
+
+customElements.define("vcard-type", VCardTypeSelectionComponent);
+
+/**
+ * Interface for vCard Fields in the edit view.
+ *
+ * @interface VCardPropertyEntryView
+ */
+
+/**
+ * Getter/Setter for rich data do not use HTMLAttributes for this.
+ * Keep the reference intact through vCardProperties for proper saving.
+ *
+ * @property
+ * @name VCardPropertyEntryView#vCardPropertyEntry
+ */
+
+/**
+ * fromUIToVCardPropertyEntry should directly change data with the reference
+ * through vCardPropertyEntry.
+ * It's there for an action to read the user input values into the
+ * vCardPropertyEntry.
+ *
+ * @function
+ * @name VCardPropertyEntryView#fromUIToVCardPropertyEntry
+ * @returns {void}
+ */
+
+/**
+ * Updates the UI accordingly to the vCardPropertyEntry.
+ *
+ * @function
+ * @name VCardPropertyEntryView#fromVCardPropertyEntryToUI
+ * @returns {void}
+ */
+
+/**
+ * Checks if the value of VCardPropertyEntry is empty.
+ *
+ * @function
+ * @name VCardPropertyEntryView#valueIsEmpty
+ * @returns {boolean}
+ */
+
+/**
+ * Creates a new VCardPropertyEntry for usage in the a new Field.
+ *
+ * @function
+ * @name VCardPropertyEntryView#newVCardPropertyEntry
+ * @static
+ * @returns {VCardPropertyEntry}
+ */
diff --git a/comm/mail/components/addrbook/content/vcard-edit/email.mjs b/comm/mail/components/addrbook/content/vcard-edit/email.mjs
new file mode 100644
index 0000000000..751399ac6c
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/email.mjs
@@ -0,0 +1,135 @@
+/* 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 lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 EMAIL
+ */
+export class VCardEmailComponent extends HTMLTableRowElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ emailEl;
+ /** @type {HTMLInputElement} */
+ checkboxEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("email", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-email");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.emailEl = this.querySelector('input[type="email"]');
+ this.checkboxEl = this.querySelector('input[type="checkbox"]');
+
+ this.emailEl.addEventListener("input", () => {
+ // Dispatch the event only if this field is the currently selected
+ // default/preferred email address.
+ if (this.checkboxEl.checked) {
+ this.dispatchEvent(VCardEmailComponent.EmailEvent());
+ }
+ });
+
+ // Uncheck the checkbox of other VCardEmailComponents if this one is
+ // checked.
+ this.checkboxEl.addEventListener("change", event => {
+ if (event.target.checked === true) {
+ this.dispatchEvent(VCardEmailComponent.CheckboxEvent());
+ }
+ });
+
+ // Create the email type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ labelledBy: "addr-book-edit-email-type",
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ document.querySelector("vcard-edit").toggleDefaultEmailView();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.emailEl.value = this.vCardPropertyEntry.value;
+
+ let pref = this.vCardPropertyEntry.params.pref;
+ if (pref === "1") {
+ this.checkboxEl.checked = true;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.emailEl.value;
+
+ if (this.checkboxEl.checked) {
+ this.vCardPropertyEntry.params.pref = "1";
+ } else if (
+ this.vCardPropertyEntry.params.pref &&
+ this.vCardPropertyEntry.params.pref === "1"
+ ) {
+ // Only delete the pref if a pref of 1 is set and the checkbox is not
+ // checked. The pref mechanic is not fully supported yet. Leave all other
+ // prefs untouched.
+ delete this.vCardPropertyEntry.params.pref;
+ }
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ /**
+ * This event is fired when the checkbox is checked and we need to uncheck the
+ * other checkboxes from each VCardEmailComponent.
+ * FIXME: This should be a radio button part of radiogroup.
+ *
+ * @returns {CustomEvent}
+ */
+ static CheckboxEvent() {
+ return new CustomEvent("vcard-email-default-checkbox", {
+ detail: {},
+ bubbles: true,
+ });
+ }
+
+ /**
+ * This event is fired when the value of an email input field is changed. The
+ * event is fired only if the current email si set as default/preferred.
+ *
+ * @returns {CustomEvent}
+ */
+ static EmailEvent() {
+ return new CustomEvent("vcard-email-default-changed", {
+ detail: {},
+ bubbles: true,
+ });
+ }
+}
+
+customElements.define("vcard-email", VCardEmailComponent, { extends: "tr" });
diff --git a/comm/mail/components/addrbook/content/vcard-edit/fn.mjs b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs
new file mode 100644
index 0000000000..446a262f28
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs
@@ -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/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 FN
+ */
+export class VCardFNComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLElement} */
+ displayEl;
+ /** @type {HTMLElement} */
+ preferDisplayEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("fn", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-fn");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.displayEl = this.querySelector("#vCardDisplayName");
+ this.displayEl.addEventListener(
+ "input",
+ () => {
+ this.displayEl.isDirty = true;
+ },
+ { once: true }
+ );
+ this.preferDisplayEl = this.querySelector("#vCardPreferDisplayName");
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.displayEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.displayEl.value = this.vCardPropertyEntry.value;
+ this.displayEl.isDirty = !!this.displayEl.value.trim();
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.displayEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+customElements.define("vcard-fn", VCardFNComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs
new file mode 100644
index 0000000000..b4ce37bfda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs
@@ -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/. */
+
+function* vCardHtmlIdGen() {
+ let internalId = 0;
+ while (true) {
+ yield `vcard-id-${internalId++}`;
+ }
+}
+
+export let vCardIdGen = vCardHtmlIdGen();
diff --git a/comm/mail/components/addrbook/content/vcard-edit/impp.mjs b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs
new file mode 100644
index 0000000000..232925942e
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs
@@ -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/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 IMPP
+ */
+export class VCardIMPPComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ imppEl;
+ /** @type {HTMLSelectElement} */
+ protocolEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("impp", {}, "uri", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-impp");
+ this.appendChild(template.content.cloneNode(true));
+
+ this.imppEl = this.querySelector('input[name="impp"]');
+ document.l10n
+ .formatValue("vcard-impp-input-title")
+ .then(t => (this.imppEl.title = t));
+
+ this.protocolEl = this.querySelector('select[name="protocol"]');
+ this.protocolEl.id = vCardIdGen.next().value;
+
+ let protocolLabel = this.querySelector('label[for="protocol"]');
+ protocolLabel.htmlFor = this.protocolEl.id;
+
+ this.protocolEl.addEventListener("change", event => {
+ let entered = this.imppEl.value.split(":", 1)[0]?.toLowerCase();
+ if (entered) {
+ this.protocolEl.value =
+ [...this.protocolEl.options].find(o => o.value.startsWith(entered))
+ ?.value || "";
+ }
+ this.imppEl.placeholder = this.protocolEl.value;
+ this.imppEl.pattern = this.protocolEl.selectedOptions[0].dataset.pattern;
+ });
+
+ this.imppEl.id = vCardIdGen.next().value;
+ let imppLabel = this.querySelector('label[for="impp"]');
+ imppLabel.htmlFor = this.imppEl.id;
+ document.l10n.setAttributes(imppLabel, "vcard-impp-label");
+ this.imppEl.addEventListener("change", event => {
+ this.protocolEl.dispatchEvent(new CustomEvent("change"));
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ this.imppEl.dispatchEvent(new CustomEvent("change"));
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.imppEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.imppEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-impp", VCardIMPPComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/n.mjs b/comm/mail/components/addrbook/content/vcard-edit/n.mjs
new file mode 100644
index 0000000000..ae5d386d93
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/n.mjs
@@ -0,0 +1,186 @@
+/* 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 lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 N
+ */
+export class VCardNComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLElement} */
+ prefixEl;
+ /** @type {HTMLElement} */
+ firstNameEl;
+ /** @type {HTMLElement} */
+ middleNameEl;
+ /** @type {HTMLElement} */
+ lastNameEl;
+ /** @type {HTMLElement} */
+ suffixEl;
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-n");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.registerListComponents();
+ this.fromVCardPropertyEntryToUI();
+ this.sortAsOrder();
+ }
+ }
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("n", {}, "text", ["", "", "", "", ""]);
+ }
+
+ /**
+ * Assigns the vCardPropertyEntry values to the individual
+ * NListComponentText elements.
+ *
+ * @TODO sort-as param should be used for the order.
+ * The use-case is that not every language has the order of
+ * prefix, firstName, middleName, lastName, suffix.
+ * Aswell that the user is able to change the sorting as he like
+ * on a per contact base.
+ */
+ sortAsOrder() {
+ if (!this.vCardPropertyEntry.params["sort-as"]) {
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ /**
+ * @TODO
+ * The sort-as DOM Mutation
+ */
+ }
+
+ fromVCardPropertyEntryToUI() {
+ let prefixVal = this.vCardPropertyEntry.value[3] || "";
+ let prefixInput = this.prefixEl.querySelector("input");
+ prefixInput.value = prefixVal;
+ if (prefixVal) {
+ this.prefixEl.querySelector("button").hidden = true;
+ } else {
+ this.prefixEl.classList.add("hasButton");
+ this.prefixEl.querySelector("label").hidden = true;
+ prefixInput.hidden = true;
+ }
+
+ // First Name is always shown.
+ this.firstNameEl.querySelector("input").value =
+ this.vCardPropertyEntry.value[1] || "";
+
+ let middleNameVal = this.vCardPropertyEntry.value[2] || "";
+ let middleNameInput = this.middleNameEl.querySelector("input");
+ middleNameInput.value = middleNameVal;
+ if (middleNameVal) {
+ this.middleNameEl.querySelector("button").hidden = true;
+ } else {
+ this.middleNameEl.classList.add("hasButton");
+ this.middleNameEl.querySelector("label").hidden = true;
+ middleNameInput.hidden = true;
+ }
+
+ // Last Name is always shown.
+ this.lastNameEl.querySelector("input").value =
+ this.vCardPropertyEntry.value[0] || "";
+
+ let suffixVal = this.vCardPropertyEntry.value[4] || "";
+ let suffixInput = this.suffixEl.querySelector("input");
+ suffixInput.value = suffixVal;
+ if (suffixVal) {
+ this.suffixEl.querySelector("button").hidden = true;
+ } else {
+ this.suffixEl.classList.add("hasButton");
+ this.suffixEl.querySelector("label").hidden = true;
+ suffixInput.hidden = true;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = [
+ this.lastNameEl.querySelector("input").value,
+ this.firstNameEl.querySelector("input").value,
+ this.middleNameEl.querySelector("input").value,
+ this.prefixEl.querySelector("input").value,
+ this.suffixEl.querySelector("input").value,
+ ];
+ }
+
+ valueIsEmpty() {
+ let noEmptyStrings = [
+ this.prefixEl,
+ this.firstNameEl,
+ this.middleNameEl,
+ this.lastNameEl,
+ this.suffixEl,
+ ].filter(node => {
+ return node.querySelector("input").value !== "";
+ });
+ return noEmptyStrings.length === 0;
+ }
+
+ registerListComponents() {
+ this.prefixEl = this.querySelector("#n-list-component-prefix");
+ let prefixInput = this.prefixEl.querySelector("input");
+ let prefixButton = this.prefixEl.querySelector("button");
+ prefixButton.addEventListener("click", e => {
+ this.prefixEl.querySelector("label").hidden = false;
+ prefixInput.hidden = false;
+ prefixButton.hidden = true;
+ this.prefixEl.classList.remove("hasButton");
+ prefixInput.focus();
+ });
+
+ this.firstNameEl = this.querySelector("#n-list-component-firstname");
+
+ this.middleNameEl = this.querySelector("#n-list-component-middlename");
+ let middleNameInput = this.middleNameEl.querySelector("input");
+ let middleNameButton = this.middleNameEl.querySelector("button");
+ middleNameButton.addEventListener("click", e => {
+ this.middleNameEl.querySelector("label").hidden = false;
+ middleNameInput.hidden = false;
+ middleNameButton.hidden = true;
+ this.middleNameEl.classList.remove("hasButton");
+ middleNameInput.focus();
+ });
+
+ this.lastNameEl = this.querySelector("#n-list-component-lastname");
+
+ this.suffixEl = this.querySelector("#n-list-component-suffix");
+ let suffixInput = this.suffixEl.querySelector("input");
+ let suffixButton = this.suffixEl.querySelector("button");
+ suffixButton.addEventListener("click", e => {
+ this.suffixEl.querySelector("label").hidden = false;
+ suffixInput.hidden = false;
+ suffixButton.hidden = true;
+ this.suffixEl.classList.remove("hasButton");
+ suffixInput.focus();
+ });
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.prefixEl = null;
+ this.firstNameEl = null;
+ this.middleNameEl = null;
+ this.lastNameEl = null;
+ this.suffixEl = null;
+ }
+ }
+}
+customElements.define("vcard-n", VCardNComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs
new file mode 100644
index 0000000000..3622b28997
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs
@@ -0,0 +1,59 @@
+/* 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 lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 NICKNAME
+ */
+export class VCardNickNameComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+ /** @type {HTMLElement} */
+ nickNameEl;
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-nickname");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("nickname", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.nickNameEl = this.querySelector("#vCardNickName");
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.nickNameEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.nickNameEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.nickNameEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+customElements.define("vcard-nickname", VCardNickNameComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/note.mjs b/comm/mail/components/addrbook/content/vcard-edit/note.mjs
new file mode 100644
index 0000000000..f78f4a16d8
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/note.mjs
@@ -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/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 Note
+ */
+export class VCardNoteComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLTextAreaElement} */
+ textAreaEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("note", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-note");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.textAreaEl = this.querySelector("textarea");
+ this.textAreaEl.addEventListener("input", () => {
+ this.resizeTextAreaEl();
+ });
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-note").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.textAreaEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.textAreaEl.value = this.vCardPropertyEntry.value;
+ this.resizeTextAreaEl();
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.textAreaEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ resizeTextAreaEl() {
+ this.textAreaEl.rows = Math.min(
+ 15,
+ Math.max(5, this.textAreaEl.value.split("\n").length)
+ );
+ }
+}
+
+customElements.define("vcard-note", VCardNoteComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/org.mjs b/comm/mail/components/addrbook/content/vcard-edit/org.mjs
new file mode 100644
index 0000000000..fb788c3043
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/org.mjs
@@ -0,0 +1,197 @@
+/* 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 { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 TITLE
+ */
+export class VCardTitleComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ titleEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("title", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-title");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.titleEl = this.querySelector('input[name="title"]');
+ this.assignIds(this.titleEl, this.querySelector('label[for="title"]'));
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.vCardPropertyEntry = null;
+ this.titleEl = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.titleEl.value = this.vCardPropertyEntry.value || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.titleEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+}
+customElements.define("vcard-title", VCardTitleComponent);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ROLE
+ */
+export class VCardRoleComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ roleEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("role", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-role");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.roleEl = this.querySelector('input[name="role"]');
+ this.assignIds(this.roleEl, this.querySelector('label[for="role"]'));
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.vCardPropertyEntry = null;
+ this.roleEl = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.roleEl.value = this.vCardPropertyEntry.value || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.roleEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+}
+customElements.define("vcard-role", VCardRoleComponent);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ORG
+ */
+export class VCardOrgComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+ /** @type {HTMLInputElement} */
+ orgEl;
+ /** @type {HTMLInputElement} */
+ unitEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("org", {}, "text", ["", ""]);
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-org");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.orgEl = this.querySelector('input[name="org"]');
+ this.orgEl.id = vCardIdGen.next().value;
+ this.querySelector('label[for="org"]').htmlFor = this.orgEl.id;
+
+ this.unitEl = this.querySelector('input[name="orgUnit"]');
+ this.unitEl.id = vCardIdGen.next().value;
+ this.querySelector('label[for="orgUnit"]').htmlFor = this.unitEl.id;
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ let values = this.vCardPropertyEntry.value;
+ if (!values) {
+ this.orgEl.value = "";
+ this.unitEl.value = "";
+ return;
+ }
+ if (!Array.isArray(values)) {
+ values = [values];
+ }
+ this.orgEl.value = values.shift() || "";
+ // In case data had more levels of units, just pull them together.
+ this.unitEl.value = values.join(", ");
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = [this.orgEl.value.trim()];
+ if (this.unitEl.value.trim()) {
+ this.vCardPropertyEntry.value.push(this.unitEl.value.trim());
+ }
+ }
+
+ valueIsEmpty() {
+ return (
+ !this.vCardPropertyEntry.value ||
+ (Array.isArray(this.vCardPropertyEntry.value) &&
+ this.vCardPropertyEntry.value.every(v => v === ""))
+ );
+ }
+}
+customElements.define("vcard-org", VCardOrgComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs
new file mode 100644
index 0000000000..17c7df493b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs
@@ -0,0 +1,269 @@
+/* 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 { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+/**
+ * ANNIVERSARY and BDAY both have a cardinality of
+ * 1 ("Exactly one instance per vCard MAY be present.").
+ *
+ * For Anniversary we changed the cardinality to
+ * ("One or more instances per vCard MAY be present.")".
+ *
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ANNIVERSARY and BDAY
+ */
+export class VCardSpecialDateComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLSelectElement} */
+ selectEl;
+ /** @type {HTMLInputElement} */
+ year;
+ /** @type {HTMLSelectElement} */
+ month;
+ /** @type {HTMLSelectElement} */
+ day;
+
+ /**
+ * Object containing the available days for each month.
+ *
+ * @type {object}
+ */
+ monthDays = {
+ 1: 31,
+ 2: 28,
+ 3: 31,
+ 4: 30,
+ 5: 31,
+ 6: 30,
+ 7: 31,
+ 8: 31,
+ 9: 30,
+ 10: 31,
+ 11: 30,
+ 12: 31,
+ };
+
+ static newAnniversaryVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("anniversary", {}, "date", "");
+ }
+
+ static newBdayVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("bday", {}, "date", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById(
+ "template-vcard-edit-bday-anniversary"
+ );
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.selectEl = this.querySelector(".vcard-type-selection");
+ let selectId = vCardIdGen.next().value;
+ this.selectEl.id = selectId;
+ this.querySelector(".vcard-type-label").htmlFor = selectId;
+
+ this.selectEl.addEventListener("change", event => {
+ this.dispatchEvent(
+ VCardSpecialDateComponent.ChangeVCardPropertyEntryEvent(
+ event.target.value
+ )
+ );
+ });
+
+ this.month = this.querySelector("#month");
+ let monthId = vCardIdGen.next().value;
+ this.month.id = monthId;
+ this.querySelector('label[for="month"]').htmlFor = monthId;
+ this.month.addEventListener("change", () => {
+ this.fillDayOptions();
+ });
+
+ this.day = this.querySelector("#day");
+ let dayId = vCardIdGen.next().value;
+ this.day.id = dayId;
+ this.querySelector('label[for="day"]').htmlFor = dayId;
+
+ this.year = this.querySelector("#year");
+ let yearId = vCardIdGen.next().value;
+ this.year.id = yearId;
+ this.querySelector('label[for="year"]').htmlFor = yearId;
+ this.year.addEventListener("input", () => {
+ this.fillDayOptions();
+ });
+
+ document.l10n.formatValues([{ id: "vcard-date-year" }]).then(yearLabel => {
+ this.year.placeholder = yearLabel;
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fillMonthOptions();
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.selectEl.value = this.vCardPropertyEntry.name;
+ if (this.vCardPropertyEntry.type === "text") {
+ // TODO: support of text type for special-date
+ this.hidden = true;
+ return;
+ }
+ // Default value is date-and-or-time.
+ let dateValue;
+ try {
+ dateValue = ICAL.VCardTime.fromDateAndOrTimeString(
+ this.vCardPropertyEntry.value || "",
+ "date-and-or-time"
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ // Always set the month first since that controls the available days.
+ this.month.value = dateValue?.month || "";
+ this.fillDayOptions();
+ this.day.value = dateValue?.day || "";
+ this.year.value = dateValue?.year || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ if (this.vCardPropertyEntry.type === "text") {
+ // TODO: support of text type for special-date
+ return;
+ }
+ // Default value is date-and-or-time.
+ let dateValue = new ICAL.VCardTime({}, null, "date");
+ // Set the properties directly instead of using the VCardTime
+ // constructor argument, which causes null values to become 0.
+ dateValue.year = this.year.value ? Number(this.year.value) : null;
+ dateValue.month = this.month.value ? Number(this.month.value) : null;
+ dateValue.day = this.day.value ? Number(this.day.value) : null;
+ this.vCardPropertyEntry.value = dateValue.toString();
+ }
+
+ valueIsEmpty() {
+ return !this.year.value && !this.month.value && !this.day.value;
+ }
+
+ /**
+ * @param {"bday" | "anniversary"} entryName
+ * @returns {CustomEvent}
+ */
+ static ChangeVCardPropertyEntryEvent(entryName) {
+ return new CustomEvent("vcard-bday-anniversary-change", {
+ detail: {
+ name: entryName,
+ },
+ bubbles: true,
+ });
+ }
+
+ /**
+ * Check if the specified year is a leap year in order to add or remove the
+ * extra day to February.
+ *
+ * @returns {boolean} True if the currently specified year is a leap year,
+ * or if no valid year value is available.
+ */
+ isLeapYear() {
+ // If the year is empty, we can't know if it's a leap year so must assume
+ // it is. Otherwise year-less dates can't show Feb 29.
+ if (!this.year.checkValidity() || this.year.value === "") {
+ return true;
+ }
+
+ let year = parseInt(this.year.value);
+ return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
+ }
+
+ fillMonthOptions() {
+ let formatter = Intl.DateTimeFormat(undefined, { month: "long" });
+ for (let m = 1; m <= 12; m++) {
+ let option = document.createElement("option");
+ option.setAttribute("value", m);
+ option.setAttribute("label", formatter.format(new Date(2000, m - 1, 2)));
+ this.month.appendChild(option);
+ }
+ }
+
+ /**
+ * Update the Day select element to reflect the available days of the selected
+ * month.
+ */
+ fillDayOptions() {
+ let prevDay = 0;
+ // Save the previously selected day if we have one.
+ if (this.day.childNodes.length > 1) {
+ prevDay = this.day.value;
+ }
+
+ // Always clear old options.
+ let defaultOption = document.createElement("option");
+ defaultOption.value = "";
+ document.l10n
+ .formatValues([{ id: "vcard-date-day" }])
+ .then(([dayLabel]) => {
+ defaultOption.textContent = dayLabel;
+ });
+ this.day.replaceChildren(defaultOption);
+
+ let monthValue = this.month.value || 1;
+ // Add a day to February if this is a leap year and we're in February.
+ if (monthValue === "2") {
+ this.monthDays["2"] = this.isLeapYear() ? 29 : 28;
+ }
+
+ let formatter = Intl.DateTimeFormat(undefined, { day: "numeric" });
+ for (let d = 1; d <= this.monthDays[monthValue]; d++) {
+ let option = document.createElement("option");
+ option.setAttribute("value", d);
+ option.setAttribute("label", formatter.format(new Date(2000, 0, d)));
+ this.day.appendChild(option);
+ }
+ // Reset the previously selected day, if it's available in the currently
+ // selected month.
+ this.day.value = prevDay <= this.monthDays[monthValue] ? prevDay : "";
+ }
+
+ /**
+ * @param {boolean} options.hasBday
+ */
+ birthdayAvailability(options) {
+ if (this.vCardPropertyEntry.name === "bday") {
+ return;
+ }
+ Array.from(this.selectEl.options).forEach(option => {
+ if (option.value === "bday") {
+ option.disabled = options.hasBday;
+ }
+ });
+ }
+}
+
+customElements.define("vcard-special-date", VCardSpecialDateComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/tel.mjs b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs
new file mode 100644
index 0000000000..a5eb30c6d5
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs
@@ -0,0 +1,83 @@
+/* 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 { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 TEL
+ *
+ * @TODO missing type-param-tel support.
+ * "text, voice, video, textphone"
+ */
+export class VCardTelComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ inputElement;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("tel", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-tel");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.inputElement = this.querySelector('input[type="text"]');
+ let urlId = vCardIdGen.next().value;
+ this.inputElement.id = urlId;
+ let urlLabel = this.querySelector('label[for="text"]');
+ urlLabel.htmlFor = urlId;
+ document.l10n.setAttributes(urlLabel, "vcard-tel-label");
+ this.inputElement.type = "tel";
+
+ // Create the tel type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ propertyType: "tel",
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.inputElement.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.inputElement.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-tel", VCardTelComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/tz.mjs b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs
new file mode 100644
index 0000000000..cf77114db6
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs
@@ -0,0 +1,86 @@
+/* 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 lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "cal",
+ "resource:///modules/calendar/calUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 URL
+ */
+export class VCardTZComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLSelectElement} */
+ selectEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("tz", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-tz");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.selectEl = this.querySelector("select");
+ for (let tzid of lazy.cal.timezoneService.timezoneIds) {
+ let option = this.selectEl.appendChild(
+ document.createElement("option")
+ );
+ option.value = tzid;
+ option.textContent =
+ lazy.cal.timezoneService.getTimezone(tzid).displayName;
+ }
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-tz").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.selectEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.selectEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.selectEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-tz", VCardTZComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/url.mjs b/comm/mail/components/addrbook/content/vcard-edit/url.mjs
new file mode 100644
index 0000000000..98a1b42951
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/url.mjs
@@ -0,0 +1,89 @@
+/* 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 { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 URL
+ */
+export class VCardURLComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ urlEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("url", {}, "uri", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-type-text");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.urlEl = this.querySelector('input[type="text"]');
+ let urlId = vCardIdGen.next().value;
+ this.urlEl.id = urlId;
+ let urlLabel = this.querySelector('label[for="text"]');
+ urlLabel.htmlFor = urlId;
+ this.urlEl.type = "url";
+ document.l10n.setAttributes(urlLabel, "vcard-url-label");
+
+ this.urlEl.addEventListener("input", () => {
+ // Auto add https:// if the url is missing scheme.
+ if (
+ this.urlEl.value.length > "https://".length &&
+ !/^https?:\/\//.test(this.urlEl.value)
+ ) {
+ this.urlEl.value = "https://" + this.urlEl.value;
+ }
+ });
+
+ // Create the url type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.urlEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.urlEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-url", VCardURLComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml
new file mode 100644
index 0000000000..56d53f57f1
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml
@@ -0,0 +1,398 @@
+# 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/.
+
+<!-- Styles -->
+<link rel="stylesheet" href="chrome://messenger/skin/vcard.css" />
+
+<!-- Scripts -->
+<script type="module" src="chrome://messenger/content/addressbook/edit/edit.mjs"></script>
+
+<!-- Localization -->
+<link rel="localization" href="messenger/addressbook/vcard.ftl" />
+
+<!-- Edit View -->
+<template id="template-addr-book-edit">
+ <!-- Name -->
+ <fieldset id="addr-book-edit-n" class="addr-book-edit-fieldset fieldset-reset">
+ <legend class="screen-reader-only" data-l10n-id="vcard-name-header"/>
+ <div class="addr-book-edit-display-nickname">
+ </div>
+ </fieldset>
+ <fieldset id="addr-book-edit-email" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-email-header"/>
+ <table>
+ <thead>
+ <tr>
+ <th id="addr-book-edit-email-type" scope="col">
+ <!-- NOTE: We use the <span> so we can apply the screen-reader-only
+ - class to the <span> rather than the <th> element. If we apply
+ - the class to the <th> element directly it causes problems with
+ - Orca's "browse mode" table navigation. See bug 1776644. -->
+ <span class="screen-reader-only"
+ data-l10n-id="vcard-entry-type-label">
+ </span>
+ </th>
+ <th id="addr-book-edit-email-label" scope="col">
+ <span class="screen-reader-only"
+ data-l10n-id="vcard-email-label">
+ </span>
+ </th>
+ <th id="addr-book-edit-email-default" scope="col">
+ <span data-l10n-id="vcard-primary-email-label"></span>
+ </th>
+ </tr>
+ </thead>
+ <tbody id="vcard-email"></tbody>
+ </table>
+ <button id="vcard-add-email"
+ data-l10n-id="vcard-email-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- URL -->
+ <fieldset id="addr-book-edit-url" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-url-header"/>
+ <button id="vcard-add-url"
+ data-l10n-id="vcard-url-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Address -->
+ <fieldset id="addr-book-edit-address" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-adr-header"/>
+ <button id="vcard-add-adr"
+ data-l10n-id="vcard-adr-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Tel -->
+ <fieldset id="addr-book-edit-tel" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-tel-header"/>
+ <button id="vcard-add-tel"
+ data-l10n-id="vcard-tel-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Time Zone -->
+ <fieldset id="addr-book-edit-tz" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-tz-header"/>
+ <button id="vcard-add-tz"
+ data-l10n-id="vcard-tz-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- IMPP (Chat) -->
+ <fieldset id="addr-book-edit-impp" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-impp2-header"/>
+ <button id="vcard-add-impp"
+ data-l10n-id="vcard-impp-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Birthday and Anniversary (Special dates) -->
+ <fieldset id="addr-book-edit-bday-anniversary" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-bday-anniversary-header"/>
+ <button id="vcard-add-bday-anniversary"
+ data-l10n-id="vcard-bday-anniversary-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Notes -->
+ <fieldset id="addr-book-edit-note" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-note-header"/>
+ <button id="vcard-add-note"
+ data-l10n-id="vcard-note-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Organization Info -->
+ <fieldset id="addr-book-edit-org" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-org-header"/>
+ <button id="vcard-add-org"
+ data-l10n-id="vcard-org-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"
+ hidden="hidden"></button>
+ </fieldset>
+ <!-- Custom -->
+ <fieldset id="addr-book-edit-custom" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-custom-header"/>
+ <button id="vcard-add-custom"
+ data-l10n-id="vcard-custom-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+</template>
+
+<!-- Individual fields -->
+
+<!-- N field -->
+<template id="template-vcard-edit-n">
+ <div id="n-list-component-prefix" class="n-list-component">
+ <label for="vcard-n-prefix" data-l10n-id="vcard-n-prefix" />
+ <input id="vcard-n-prefix"
+ type="text"
+ autocomplete="off" />
+ <button class="primary" data-l10n-id="vcard-n-add-prefix"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ </div>
+ <div id="n-list-component-firstname" class="n-list-component">
+ <label for="vcard-n-firstname" data-l10n-id="vcard-n-firstname" />
+ <input id="vcard-n-firstname"
+ type="text"
+ autocomplete="off" />
+ </div>
+ <div id="n-list-component-middlename" class="n-list-component">
+ <label for="vcard-n-middlename" data-l10n-id="vcard-n-middlename" />
+ <input id="vcard-n-middlename"
+ type="text"
+ autocomplete="off" />
+ <button class="primary" data-l10n-id="vcard-n-add-middlename"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ </div>
+ <div id="n-list-component-lastname" class="n-list-component">
+ <label for="vcard-n-lastname" data-l10n-id="vcard-n-lastname" />
+ <input id="vcard-n-lastname"
+ type="text"
+ autocomplete="off" />
+ </div>
+ <div id="n-list-component-suffix" class="n-list-component">
+ <label for="vcard-n-suffix" data-l10n-id="vcard-n-suffix" />
+ <button class="primary" data-l10n-id="vcard-n-add-suffix"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ <input id="vcard-n-suffix"
+ type="text"
+ autocomplete="off" />
+ </div>
+</template>
+
+<!-- FN field. -->
+<template id="template-vcard-edit-fn">
+ <label for="vCardDisplayName" data-l10n-id="vcard-displayname"></label>
+ <input id="vCardDisplayName" type="text"/>
+ <label id="vCardDisplayNameCheckbox" class="vcard-checkbox">
+ <!-- There is no l10n ID on this element because the vCard edit form is
+ also used in other sections that don't use this checkbox and don't have
+ access to the fluent string. The string is added when needed by the
+ address book edit.js file. -->
+ <input type="checkbox" id="vCardPreferDisplayName" checked="checked" />
+ <!-- SPAN element needed for fluent string. -->
+ <span></span>
+ </label>
+</template>
+
+<!-- NICKNAME field. -->
+<template id="template-vcard-edit-nickname">
+ <label for="vCardNickName" data-l10n-id="vcard-nickname"></label>
+ <input id="vCardNickName" type="text"/>
+</template>
+
+<!-- Email -->
+<template id="template-vcard-edit-email">
+ <td>
+ <vcard-type></vcard-type>
+ </td>
+ <td class="email-column">
+ <input type="email"
+ aria-labelledby="addr-book-edit-email-label" />
+ </td>
+ <td class="default-column">
+ <input type="checkbox"
+ aria-labelledby="addr-book-edit-email-default" />
+ </td>
+ <td>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+ </td>
+</template>
+
+<!-- Phone -->
+<template id="template-vcard-edit-tel">
+ <vcard-type></vcard-type>
+ <label class="screen-reader-only" for="text"/>
+ <input type="text"/>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Field with type and text -->
+<template id="template-vcard-edit-type-text">
+ <vcard-type></vcard-type>
+ <label class="screen-reader-only" for="text"/>
+ <input type="text"/>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Time Zone -->
+<template id="template-vcard-edit-tz">
+ <select>
+ <option value=""></option>
+ </select>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- IMPP -->
+<template id="template-vcard-edit-impp">
+ <label class="screen-reader-only" for="protocol" data-l10n-id="vcard-impp-select"></label>
+ <select name="protocol" class="vcard-type-selection">
+ <option value="matrix:u/john:example.org" data-pattern="matrix:.+/.+:.+">Matrix</option>
+ <option value="xmpp:john@example.org" data-pattern="xmpp:.+@.+">XMPP</option>
+ <option value="ircs://irc.example.org/john,isuser" data-pattern="ircs?://.+/.+,.+">IRC</option>
+ <option value="sip:1-555-123-4567@voip.example.org" data-pattern="sip:.+@.+">SIP</option>
+ <option value="skype:johndoe" data-pattern="skype:[A-Za-z\d\-\._]{6,32}">Skype</option>
+ <option value="" data-l10n-id="vcard-impp-option-other" data-pattern="..+:..+"></option>
+ </select>
+ <label class="screen-reader-only" for="impp" data-l10n-id="vcard-impp-input-label"></label>
+ <input type="text" name="impp" pattern="..+:..+" />
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Birthday and Anniversary -->
+<template id="template-vcard-edit-bday-anniversary">
+ <label class="vcard-type-label screen-reader-only"
+ data-l10n-id="vcard-entry-type-label"></label>
+ <select class="vcard-type-selection">
+ <option value="bday" data-l10n-id="vcard-bday-label" selected="selected"/>
+ <option value="anniversary" data-l10n-id="vcard-anniversary-label"/>
+ </select>
+
+ <div class="vcard-year-month-day-container">
+ <label class="screen-reader-only" for="year" data-l10n-id="vcard-date-year"></label>
+ <input id="year" name="year" type="number" min="1000" max="9999" pattern="[0-9]{4}" class="size5" />
+
+ <label class="screen-reader-only" for="month" data-l10n-id="vcard-date-month"></label>
+ <select id="month" name="month" class="vcard-month-select">
+ <option value="" data-l10n-id="vcard-date-month" selected="selected"></option>
+ </select>
+
+ <label class="screen-reader-only" for="day" data-l10n-id="vcard-date-day"></label>
+ <select id="day" name="day" class="vcard-day-select">
+ <option value="" data-l10n-id="vcard-date-day" selected="selected"></option>
+ </select>
+
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+ </div>
+</template>
+
+<!-- Address -->
+<template id="template-vcard-edit-adr">
+ <fieldset class="fieldset-grid fieldset-reset">
+ <legend class="screen-reader-only" data-l10n-id="vcard-adr-label"/>
+ <vcard-type></vcard-type>
+ <div class="vcard-adr-inputs">
+ <label for="street" data-l10n-id="vcard-adr-street"/>
+ <textarea name="street"></textarea>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="locality" data-l10n-id="vcard-adr-locality"/>
+ <input type="text" name="locality"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="region" data-l10n-id="vcard-adr-region"/>
+ <input type="text" name="region"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="code" data-l10n-id="vcard-adr-code"/>
+ <input type="text" name="code"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="country" data-l10n-id="vcard-adr-country"/>
+ <input type="text" name="country"/>
+ </div>
+ </fieldset>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- Notes -->
+<template id="template-vcard-edit-note">
+ <textarea></textarea>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- Organization Info -->
+<template id="template-vcard-edit-title">
+ <div class="vcard-adr-inputs">
+ <label for="title" data-l10n-id="vcard-org-title"/>
+ <input type="text" data-l10n-id="vcard-org-title-input" name="title" />
+ </div>
+</template>
+<template id="template-vcard-edit-role">
+ <div class="vcard-adr-inputs">
+ <label for="role" data-l10n-id="vcard-org-role"/>
+ <input type="text" data-l10n-id="vcard-org-role-input" name="role" />
+ </div>
+</template>
+<template id="template-vcard-edit-org">
+ <div class="vcard-adr-inputs">
+ <label for="org" data-l10n-id="vcard-org-org" />
+ <input type="text" name="org" data-l10n-id="vcard-org-org-input" />
+ <label for="orgUnit" data-l10n-id="vcard-org-org-unit" class="screen-reader-only"/>
+ <input type="text" name="orgUnit" data-l10n-id="vcard-org-org-unit-input" />
+ </div>
+</template>
+
+<!-- Custom -->
+<template id="template-vcard-edit-custom">
+ <div class="vcard-adr-inputs">
+ <label for="custom1"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom2"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom3"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom4"/>
+ <input type="text"/>
+ </div>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<template id="template-vcard-edit-type">
+ <select class="vcard-type-selection">
+ <option value="work" data-l10n-id="vcard-entry-type-work"/>
+ <option value="home" data-l10n-id="vcard-entry-type-home"/>
+ <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/>
+ </select>
+</template>
+
+<template id="template-vcard-edit-type-tel">
+ <select class="vcard-type-selection">
+ <option value="work" data-l10n-id="vcard-entry-type-work"/>
+ <option value="home" data-l10n-id="vcard-entry-type-home"/>
+ <option value="cell" data-l10n-id="vcard-entry-type-cell"/>
+ <option value="fax" data-l10n-id="vcard-entry-type-fax"/>
+ <option value="pager" data-l10n-id="vcard-entry-type-pager"/>
+ <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/>
+ </select>
+</template>
diff --git a/comm/mail/components/addrbook/jar.mn b/comm/mail/components/addrbook/jar.mn
new file mode 100644
index 0000000000..48d6cc9b2f
--- /dev/null
+++ b/comm/mail/components/addrbook/jar.mn
@@ -0,0 +1,35 @@
+# 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/addressbook/abCommon.js (content/abCommon.js)
+ content/messenger/addressbook/abEditListDialog.xhtml (content/abEditListDialog.xhtml)
+ content/messenger/addressbook/abMailListDialog.xhtml (content/abMailListDialog.xhtml)
+ content/messenger/addressbook/abContactsPanel.xhtml (content/abContactsPanel.xhtml)
+ content/messenger/addressbook/abContactsPanel.js (content/abContactsPanel.js)
+* content/messenger/addressbook/abSearchDialog.xhtml (content/abSearchDialog.xhtml)
+ content/messenger/addressbook/abSearchDialog.js (content/abSearchDialog.js)
+ content/messenger/addressbook/menulist-addrbooks.js (content/menulist-addrbooks.js)
+
+ content/messenger/addressbook/aboutAddressBook.js (content/aboutAddressBook.js)
+* content/messenger/addressbook/aboutAddressBook.xhtml (content/aboutAddressBook.xhtml)
+ content/messenger/addressbook/addressBookTab.js (content/addressBookTab.js)
+# TODO: Rename this after removal of mailnews/addrbook/content/abView.js.
+ content/messenger/addressbook/abView-new.js (content/abView-new.js)
+# Edit view
+ content/messenger/addressbook/edit/adr.mjs (content/vcard-edit/adr.mjs)
+ content/messenger/addressbook/edit/custom.mjs (content/vcard-edit/custom.mjs)
+ content/messenger/addressbook/edit/edit.mjs (content/vcard-edit/edit.mjs)
+ content/messenger/addressbook/edit/email.mjs (content/vcard-edit/email.mjs)
+ content/messenger/addressbook/edit/fn.mjs (content/vcard-edit/fn.mjs)
+ content/messenger/addressbook/edit/impp.mjs (content/vcard-edit/impp.mjs)
+ content/messenger/addressbook/edit/n.mjs (content/vcard-edit/n.mjs)
+ content/messenger/addressbook/edit/nickname.mjs (content/vcard-edit/nickname.mjs)
+ content/messenger/addressbook/edit/note.mjs (content/vcard-edit/note.mjs)
+ content/messenger/addressbook/edit/org.mjs (content/vcard-edit/org.mjs)
+ content/messenger/addressbook/edit/special-date.mjs (content/vcard-edit/special-date.mjs)
+ content/messenger/addressbook/edit/tel.mjs (content/vcard-edit/tel.mjs)
+ content/messenger/addressbook/edit/tz.mjs (content/vcard-edit/tz.mjs)
+ content/messenger/addressbook/edit/url.mjs (content/vcard-edit/url.mjs)
+ content/messenger/addressbook/edit/id-gen.mjs (content/vcard-edit/id-gen.mjs)
diff --git a/comm/mail/components/addrbook/moz.build b/comm/mail/components/addrbook/moz.build
new file mode 100644
index 0000000000..7ca81b6ae6
--- /dev/null
+++ b/comm/mail/components/addrbook/moz.build
@@ -0,0 +1,10 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/addrbook/test/browser/browser.ini b/comm/mail/components/addrbook/test/browser/browser.ini
new file mode 100644
index 0000000000..99d7d9190d
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser.ini
@@ -0,0 +1,37 @@
+[DEFAULT]
+head = head.js
+prefs =
+ carddav.setup.loglevel=Debug
+ carddav.sync.loglevel=Debug
+ ldap_2.servers.osx.dirType=-1
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.oauth.loglevel=Debug
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ signon.rememberSignons=true
+subsuite = thunderbird
+support-files = data/**
+tags = addrbook
+
+[browser_cardDAV_init.js]
+[browser_cardDAV_oAuth.js]
+tags = oauth
+[browser_cardDAV_properties.js]
+[browser_cardDAV_sync.js]
+[browser_contact_sidebar.js]
+[browser_contact_tree.js]
+[browser_directory_tree.js]
+[browser_display_card.js]
+[browser_display_multiple.js]
+[browser_drag_drop.js]
+[browser_edit_async.js]
+[browser_edit_card.js]
+[browser_edit_photo.js]
+[browser_ldap_search.js]
+support-files = ../../../../../mailnews/addrbook/test/unit/data/ldap_contacts.json
+[browser_mailing_lists.js]
+[browser_open_actions.js]
+[browser_search.js]
+[browser_telemetry.js]
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js
new file mode 100644
index 0000000000..36e44a84c7
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js
@@ -0,0 +1,664 @@
+/* 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 { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+// A list of books returned by CardDAVServer unless changed.
+const DEFAULT_BOOKS = [
+ {
+ label: "Not This One",
+ url: "/addressbooks/me/default/",
+ },
+ {
+ label: "CardDAV Test",
+ url: "/addressbooks/me/test/",
+ },
+];
+
+async function wrappedTest(testInitCallback, ...attemptArgs) {
+ Services.logins.removeAllLogins();
+
+ CardDAVServer.open("alice", "alice");
+ if (testInitCallback) {
+ await testInitCallback();
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ for (let args of attemptArgs) {
+ if (args.url?.startsWith("/")) {
+ args.url = CardDAVServer.origin + args.url;
+ }
+ await attemptInit(dialogWindow, args);
+ }
+ dialogWindow.document.querySelector("dialog").getButton("cancel").click();
+ });
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+ CardDAVServer.resetHandlers();
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(logins.length, 0, "no faulty logins were saved");
+}
+
+async function attemptInit(
+ dialogWindow,
+ {
+ username,
+ url,
+ certError,
+ password,
+ savePassword,
+ expectedStatus = "carddav-connection-error",
+ expectedBooks = [],
+ }
+) {
+ let dialogDocument = dialogWindow.document;
+ let acceptButton = dialogDocument.querySelector("dialog").getButton("accept");
+
+ let usernameInput = dialogDocument.getElementById("carddav-username");
+ let urlInput = dialogDocument.getElementById("carddav-location");
+ let statusMessage = dialogDocument.getElementById("carddav-statusMessage");
+ let availableBooks = dialogDocument.getElementById("carddav-availableBooks");
+
+ if (username) {
+ usernameInput.select();
+ EventUtils.sendString(username, dialogWindow);
+ }
+ if (url) {
+ urlInput.select();
+ EventUtils.sendString(url, dialogWindow);
+ }
+
+ let certPromise =
+ certError === undefined ? Promise.resolve() : handleCertError();
+ let promptPromise =
+ password === undefined
+ ? Promise.resolve()
+ : handlePasswordPrompt(username, password, savePassword);
+
+ acceptButton.click();
+
+ Assert.equal(
+ statusMessage.getAttribute("data-l10n-id"),
+ "carddav-loading",
+ "Correct status message"
+ );
+
+ await certPromise;
+ await promptPromise;
+ await BrowserTestUtils.waitForEvent(dialogWindow, "status-changed");
+
+ Assert.equal(
+ statusMessage.getAttribute("data-l10n-id"),
+ expectedStatus,
+ "Correct status message"
+ );
+
+ Assert.equal(
+ availableBooks.childElementCount,
+ expectedBooks.length,
+ "Expected number of address books found"
+ );
+ for (let i = 0; i < expectedBooks.length; i++) {
+ Assert.equal(availableBooks.children[i].label, expectedBooks[i].label);
+ if (expectedBooks[i].url.startsWith("/")) {
+ Assert.equal(
+ availableBooks.children[i].value,
+ `${CardDAVServer.origin}${expectedBooks[i].url}`
+ );
+ } else {
+ Assert.equal(availableBooks.children[i].value, expectedBooks[i].url);
+ }
+ Assert.ok(availableBooks.children[i].checked);
+ }
+}
+
+function handleCertError() {
+ return BrowserTestUtils.promiseAlertDialog(
+ "cancel",
+ "chrome://pippki/content/exceptionDialog.xhtml"
+ );
+}
+
+function handlePasswordPrompt(expectedUsername, password, savePassword = true) {
+ return BrowserTestUtils.promiseAlertDialog(null, undefined, {
+ async callback(prompt) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == prompt,
+ "waiting for prompt to become active"
+ );
+
+ if (!password) {
+ prompt.document.querySelector("dialog").getButton("cancel").click();
+ return;
+ }
+
+ if (expectedUsername) {
+ Assert.equal(
+ prompt.document.getElementById("loginTextbox").value,
+ expectedUsername
+ );
+ } else {
+ prompt.document.getElementById("loginTextbox").value = "alice";
+ }
+ prompt.document.getElementById("password1Textbox").value = password;
+
+ let checkbox = prompt.document.getElementById("checkbox");
+ Assert.greater(checkbox.getBoundingClientRect().width, 0);
+ Assert.ok(checkbox.checked);
+
+ if (!savePassword) {
+ EventUtils.synthesizeMouseAtCenter(checkbox, {}, prompt);
+ Assert.ok(!checkbox.checked);
+ }
+
+ prompt.document.querySelector("dialog").getButton("accept").click();
+ },
+ });
+}
+
+/** Test URLs that don't respond. */
+add_task(function testBadURLs() {
+ return wrappedTest(
+ null,
+ { url: "mochi.test:8888" },
+ { url: "http://mochi.test:8888" },
+ { url: "https://mochi.test:8888" }
+ );
+});
+
+/** Test a server with a certificate problem. */
+add_task(function testBadSSL() {
+ return wrappedTest(null, {
+ url: "https://expired.example.com/",
+ certError: true,
+ });
+});
+
+/** Test an ordinary HTTP server that doesn't support CardDAV. */
+add_task(function testNotACardDAVServer() {
+ return wrappedTest(
+ () => {
+ CardDAVServer.server.registerPathHandler("/", null);
+ CardDAVServer.server.registerPathHandler("/.well-known/carddav", null);
+ },
+ {
+ url: "/",
+ }
+ );
+});
+
+/** Test a CardDAV server without the /.well-known/carddav response. */
+add_task(function testNoWellKnown() {
+ return wrappedTest(
+ () =>
+ CardDAVServer.server.registerPathHandler("/.well-known/carddav", null),
+ {
+ url: "/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test cancelling the password prompt when it appears. */
+add_task(function testPasswordCancelled() {
+ return wrappedTest(null, {
+ url: "/",
+ password: null,
+ });
+});
+
+/** Test entering the wrong password, then retrying with the right one. */
+add_task(function testBadPassword() {
+ return wrappedTest(
+ null,
+ {
+ url: "/",
+ password: "bob",
+ },
+ {
+ url: "/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test that entering the full URL of a book links to (only) that book. */
+add_task(function testDirectLink() {
+ return wrappedTest(null, {
+ url: "/addressbooks/me/test/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [DEFAULT_BOOKS[1]],
+ });
+});
+
+/** Test that entering only a username finds the right URL. */
+add_task(function testEmailGoodPreset() {
+ return wrappedTest(
+ async () => {
+ // The server is open but we need it on a specific port.
+ await CardDAVServer.close();
+ CardDAVServer.open("alice@test.invalid", "alice", 9999);
+ },
+ {
+ username: "alice@test.invalid",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test that entering only a bad username fails appropriately. */
+add_task(function testEmailBadPreset() {
+ return wrappedTest(null, {
+ username: "alice@bad.invalid",
+ expectedStatus: "carddav-known-incompatible",
+ });
+});
+
+/**
+ * Test that we correctly use DNS discovery. This uses the mochitest server
+ * (files in the data directory) instead of CardDAVServer because the latter
+ * can't speak HTTPS, and we only do DNS discovery for HTTPS.
+ */
+add_task(async function testDNS() {
+ let _srv = DNS.srv;
+ let _txt = DNS.txt;
+
+ DNS.srv = function (name) {
+ Assert.equal(name, "_carddavs._tcp.dnstest.invalid");
+ return [{ prio: 0, weight: 0, host: "example.org", port: 443 }];
+ };
+ DNS.txt = function (name) {
+ Assert.equal(name, "_carddavs._tcp.dnstest.invalid");
+ return [
+ {
+ data: "path=/browser/comm/mail/components/addrbook/test/browser/data/dns.sjs",
+ },
+ ];
+ };
+
+ let abWindow = await openAddressBookWindow();
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ username: "carol@dnstest.invalid",
+ password: "carol",
+ expectedStatus: null,
+ expectedBooks: [
+ {
+ label: "You found me!",
+ url: "https://example.org/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs",
+ },
+ ],
+ });
+ dialogWindow.document.querySelector("dialog").getButton("cancel").click();
+ });
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+
+ DNS.srv = _srv;
+ DNS.txt = _txt;
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test doing everything correctly, including creating the directory and
+ * doing the initial sync.
+ */
+add_task(async function testEveryThingOK() {
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ });
+
+ let availableBooks = dialogWindow.document.getElementById(
+ "carddav-availableBooks"
+ );
+ availableBooks.children[0].checked = false;
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = new Promise(resolve => {
+ let observer = {
+ observe(directory) {
+ Services.obs.removeObserver(this, "addrbook-directory-synced");
+ resolve(directory);
+ },
+ };
+ Services.obs.addObserver(observer, "addrbook-directory-synced");
+ });
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let directory = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.url
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 2, "new book got selected");
+
+ await closeAddressBookWindow();
+
+ // Don't close the server or delete the directory, they're needed below.
+});
+
+/**
+ * Tests adding a second directory on the same server. The auth prompt should
+ * show again, even though we've saved the credentials in the previous test.
+ */
+add_task(async function testEveryThingOKAgain() {
+ // Ensure at least a second has passed since the previous test, since we use
+ // context identifiers based on the current time in seconds.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [DEFAULT_BOOKS[0]],
+ });
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let [directory] = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.altURL
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 5);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(3).querySelector(".bookRow-name")
+ .textContent,
+ "Not This One"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 3, "new book got selected");
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+
+ let otherDirectory = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.CardDAVTest"
+ );
+ await promiseDirectoryRemoved(directory.URI);
+ await promiseDirectoryRemoved(otherDirectory.URI);
+
+ Services.logins.removeAllLogins();
+});
+
+/**
+ * Test setting up a directory but not saving the password. The username
+ * should be saved and no further password prompt should appear. We can't test
+ * restarting Thunderbird but if we could the password prompt would appear
+ * next time the directory makes a request.
+ */
+add_task(async function testNoSavePassword() {
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ savePassword: false,
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ });
+
+ let availableBooks = dialogWindow.document.getElementById(
+ "carddav-availableBooks"
+ );
+ availableBooks.children[0].checked = false;
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+ let [directory] = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.url
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 0, "login was NOT saved");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 2, "new book got selected");
+
+ await closeAddressBookWindow();
+
+ // Disable sync as we're going to start the address book manager again.
+ directory.setIntValue("carddav.syncinterval", 0);
+
+ // Don't close the server or delete the directory, they're needed below.
+});
+
+/**
+ * Tests saving a previously unsaved password. This uses the directory from
+ * the previous test and simulates a restart of the address book manager.
+ */
+add_task(async function testSavePasswordLater() {
+ let reloadPromise = TestUtils.topicObserved("addrbook-reloaded");
+ Services.obs.notifyObservers(null, "addrbook-reload");
+ await reloadPromise;
+
+ Assert.equal(MailServices.ab.directories.length, 3);
+ let directory = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.CardDAVTest"
+ );
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ let promptPromise = handlePasswordPrompt("alice", "alice");
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ davDirectory.fetchAllFromServer();
+ await promptPromise;
+ await syncPromise;
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice",
+ "username was saved"
+ );
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ await CardDAVServer.close();
+
+ await promiseDirectoryRemoved(directory.URI);
+
+ Services.logins.removeAllLogins();
+});
+
+/**
+ * Tests that an address book can still be created if the server returns no
+ * name. The hostname of the server is used instead.
+ */
+add_task(async function testNoName() {
+ CardDAVServer._books = CardDAVServer.books;
+ CardDAVServer.books = { "/addressbooks/me/noname/": undefined };
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [{ label: "noname", url: "/addressbooks/me/noname/" }],
+ });
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = new Promise(resolve => {
+ let observer = {
+ observe(directory) {
+ Services.obs.removeObserver(this, "addrbook-directory-synced");
+ resolve(directory);
+ },
+ };
+ Services.obs.addObserver(observer, "addrbook-directory-synced");
+ });
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let directory = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ `${CardDAVServer.origin}/addressbooks/me/noname/`
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "noname"
+ );
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+ CardDAVServer.books = CardDAVServer._books;
+
+ await promiseDirectoryRemoved(directory.URI);
+
+ Services.logins.removeAllLogins();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js
new file mode 100644
index 0000000000..137a13e221
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js
@@ -0,0 +1,143 @@
+/* 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/. */
+
+// Creates address books in various configurations (current and legacy) and
+// performs requests in each of them to prove that OAuth2 authentication is
+// working as expected.
+
+var { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+
+var LoginInfo = Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+);
+
+// Ideal login info. This is what would be saved if you created a new calendar.
+const ORIGIN = "oauth://mochi.test";
+const SCOPE = "test_scope";
+const USERNAME = "bob@test.invalid";
+const VALID_TOKEN = "bobs_refresh_token";
+
+const PATH = "comm/mail/components/addrbook/test/browser/data/";
+const URL = `http://mochi.test:8888/browser/${PATH}`;
+
+/**
+ * Set a string pref for the given directory.
+ *
+ * @param {string} dirPrefId
+ * @param {string} key
+ * @param {string} value
+ */
+function setPref(dirPrefId, key, value) {
+ Services.prefs.setStringPref(`ldap_2.servers.${dirPrefId}.${key}`, value);
+}
+
+/**
+ * Clear any existing saved logins and add the given ones.
+ *
+ * @param {string[][]} - Zero or more arrays consisting of origin, realm,
+ * username, and password.
+ */
+function setLogins(...logins) {
+ Services.logins.removeAllLogins();
+ for (let [origin, realm, username, password] of logins) {
+ Services.logins.addLogin(
+ new LoginInfo(origin, null, realm, username, password, "", "")
+ );
+ }
+}
+
+/**
+ * Create a directory with the given id, perform a request, and check that the
+ * correct authorisation header was used. If the user is required to
+ * re-authenticate with the provider, check that the new token is stored in the
+ * right place.
+ *
+ * @param {string} dirPrefId - Pref ID of the new directory.
+ * @param {string} uid - UID of the new directory.
+ * @param {string} [newTokenUsername] - If given, re-authentication must happen
+ * and the new token stored with this user name.
+ */
+async function subtest(dirPrefId, uid, newTokenUsername) {
+ let directory = new CardDAVDirectory();
+ directory._dirPrefId = dirPrefId;
+ directory._uid = uid;
+ directory.__prefBranch = Services.prefs.getBranch(
+ `ldap_2.servers.${dirPrefId}.`
+ );
+ directory.__prefBranch.setStringPref("carddav.url", URL);
+
+ let response = await directory._makeRequest("auth_headers.sjs");
+ Assert.equal(response.status, 200);
+ let headers = JSON.parse(response.text);
+
+ if (newTokenUsername) {
+ Assert.equal(headers.authorization, "Bearer new_access_token");
+
+ let logins = Services.logins
+ .findLogins(ORIGIN, null, SCOPE)
+ .filter(l => l.username == newTokenUsername);
+ Assert.equal(logins.length, 1);
+ Assert.equal(logins[0].username, newTokenUsername);
+ Assert.equal(logins[0].password, "new_refresh_token");
+ } else {
+ Assert.equal(headers.authorization, "Bearer bobs_access_token");
+ }
+
+ Services.logins.removeAllLogins();
+}
+
+// Test making a request when there is no matching token stored.
+
+/** No token stored, no username set. */
+add_task(function testAddressBookOAuth_uid_none() {
+ let dirPrefId = "uid_none";
+ let uid = "testAddressBookOAuth_uid_none";
+ return subtest(dirPrefId, uid, uid);
+});
+
+// Test making a request when there IS a matching token, but the server rejects
+// it. Currently a new token is not requested on failure.
+
+/** Expired token stored with UID. */
+add_task(function testAddressBookOAuth_uid_expired() {
+ let dirPrefId = "uid_expired";
+ let uid = "testAddressBookOAuth_uid_expired";
+ setLogins([ORIGIN, SCOPE, uid, "expired_token"]);
+ return subtest(dirPrefId, uid, uid);
+}).skip(); // Broken.
+
+// Test making a request with a valid token.
+
+/** Valid token stored with UID. This is the old way of storing the token. */
+add_task(function testAddressBookOAuth_uid_valid() {
+ let dirPrefId = "uid_valid";
+ let uid = "testAddressBookOAuth_uid_valid";
+ setLogins([ORIGIN, SCOPE, uid, VALID_TOKEN]);
+ return subtest(dirPrefId, uid);
+});
+
+/** Valid token stored with username, exact scope. */
+add_task(function testAddressBookOAuth_username_validSingle() {
+ let dirPrefId = "username_validSingle";
+ let uid = "testAddressBookOAuth_username_validSingle";
+ setPref(dirPrefId, "carddav.username", USERNAME);
+ setLogins(
+ [ORIGIN, SCOPE, USERNAME, VALID_TOKEN],
+ [ORIGIN, "other_scope", USERNAME, "other_refresh_token"]
+ );
+ return subtest(dirPrefId, uid);
+});
+
+/** Valid token stored with username, many scopes. */
+add_task(function testAddressBookOAuth_username_validMultiple() {
+ let dirPrefId = "username_validMultiple";
+ let uid = "testAddressBookOAuth_username_validMultiple";
+ setPref(dirPrefId, "carddav.username", USERNAME);
+ setLogins([ORIGIN, "scope test_scope other_scope", USERNAME, VALID_TOKEN]);
+ return subtest(dirPrefId, uid);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js
new file mode 100644
index 0000000000..0acd0b3540
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js
@@ -0,0 +1,245 @@
+/* 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/. */
+
+/**
+ * Tests CardDAV properties dialog.
+ */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+add_task(async () => {
+ const INTERVAL_PREF = "ldap_2.servers.props.carddav.syncinterval";
+ const TOKEN_PREF = "ldap_2.servers.props.carddav.token";
+ const TOKEN_VALUE = "http://mochi.test/sync/0";
+ const URL_PREF = "ldap_2.servers.props.carddav.url";
+ const URL_VALUE = "https://mochi.test/carddav/test";
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "props",
+ undefined,
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ Assert.equal(dirPrefId, "ldap_2.servers.props");
+ Assert.equal([...MailServices.ab.directories].length, 3);
+
+ let directory = MailServices.ab.getDirectoryFromId(dirPrefId);
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+ registerCleanupFunction(async () => {
+ Assert.equal(davDirectory._syncTimer, null, "sync timer cleaned up");
+ });
+ Assert.equal(directory.dirType, Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ Services.prefs.setIntPref(INTERVAL_PREF, 0);
+ Services.prefs.setStringPref(TOKEN_PREF, TOKEN_VALUE);
+ Services.prefs.setStringPref(URL_PREF, URL_VALUE);
+
+ Assert.ok(davDirectory);
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory._syncToken, TOKEN_VALUE);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+
+ openDirectory(directory);
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(directory.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+
+ let menu = abDocument.getElementById("bookContext");
+ let menuItem = abDocument.getElementById("bookContextProperties");
+
+ let subtest = async function (expectedValues, newValues, buttonAction) {
+ Assert.equal(booksList.selectedIndex, 2);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ booksList.getRowAtIndex(2),
+ { type: "contextmenu" },
+ abWindow
+ );
+ await shownPromise;
+
+ Assert.ok(BrowserTestUtils.is_visible(menuItem));
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVProperties.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("carddav-name");
+ Assert.equal(nameInput.value, expectedValues.name);
+ if ("name" in newValues) {
+ nameInput.value = newValues.name;
+ }
+
+ let urlInput = dialogDocument.getElementById("carddav-url");
+ Assert.equal(urlInput.value, expectedValues.url);
+ if ("url" in newValues) {
+ urlInput.value = newValues.url;
+ }
+
+ let refreshActiveInput = dialogDocument.getElementById(
+ "carddav-refreshActive"
+ );
+ let refreshIntervalInput = dialogDocument.getElementById(
+ "carddav-refreshInterval"
+ );
+
+ Assert.equal(refreshActiveInput.checked, expectedValues.refreshActive);
+ Assert.equal(
+ refreshIntervalInput.disabled,
+ !expectedValues.refreshActive
+ );
+ if (
+ "refreshActive" in newValues &&
+ newValues.refreshActive != expectedValues.refreshActive
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ refreshActiveInput,
+ {},
+ dialogWindow
+ );
+ Assert.equal(refreshIntervalInput.disabled, !newValues.refreshActive);
+ }
+
+ Assert.equal(refreshIntervalInput.value, expectedValues.refreshInterval);
+ if ("refreshInterval" in newValues) {
+ refreshIntervalInput.value = newValues.refreshInterval;
+ }
+
+ let readOnlyInput = dialogDocument.getElementById("carddav-readOnly");
+
+ Assert.equal(readOnlyInput.checked, expectedValues.readOnly);
+ if ("readOnly" in newValues) {
+ readOnlyInput.checked = newValues.readOnly;
+ }
+
+ dialogDocument.querySelector("dialog").getButton(buttonAction).click();
+ });
+ menu.activateItem(menuItem);
+ await dialogPromise;
+
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ };
+
+ info("Open the dialog and cancel it. Nothing should change.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {},
+ "cancel"
+ );
+
+ Assert.equal(davDirectory.dirName, "props");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 0);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ info("Open the dialog and accept it. Nothing should change.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {},
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "props");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 0);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ info("Open the dialog and change the values.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {
+ name: "CardDAV Properties Test",
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 30);
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+ let currentSyncTimer = davDirectory._syncTimer;
+ Assert.equal(davDirectory.readOnly, true);
+
+ info("Open the dialog and accept it. Nothing should change.");
+ await subtest(
+ {
+ name: "CardDAV Properties Test",
+ url: URL_VALUE,
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ {},
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 30);
+ Assert.equal(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "same sync scheduled"
+ );
+ Assert.equal(davDirectory.readOnly, true);
+
+ info("Open the dialog and change the interval.");
+ await subtest(
+ {
+ name: "CardDAV Properties Test",
+ url: URL_VALUE,
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ { refreshInterval: 60 },
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 60);
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "new sync scheduled"
+ );
+ Assert.equal(davDirectory.readOnly, true);
+
+ await promiseDirectoryRemoved(directory.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js
new file mode 100644
index 0000000000..1c4e4fb07a
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js
@@ -0,0 +1,138 @@
+/* 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/. */
+
+/**
+ * Tests CardDAV synchronization.
+ */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+add_task(async () => {
+ CardDAVServer.open();
+ registerCleanupFunction(async () => {
+ await CardDAVServer.close();
+ });
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "sync",
+ undefined,
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ Assert.equal(dirPrefId, "ldap_2.servers.sync");
+ Assert.equal([...MailServices.ab.directories].length, 3);
+
+ let directory = MailServices.ab.getDirectoryFromId(dirPrefId);
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+ Assert.equal(directory.dirType, Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ Services.prefs.setStringPref(
+ "ldap_2.servers.sync.carddav.token",
+ "http://mochi.test/sync/0"
+ );
+ Services.prefs.setStringPref(
+ "ldap_2.servers.sync.carddav.url",
+ CardDAVServer.url
+ );
+
+ Assert.ok(davDirectory);
+ Assert.equal(davDirectory._serverURL, CardDAVServer.url);
+ Assert.equal(davDirectory._syncToken, "http://mochi.test/sync/0");
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ // This test becomes unreliable if we don't pause for a moment.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 500));
+
+ openDirectory(directory);
+ checkNamesListed();
+
+ let menu = abDocument.getElementById("bookContext");
+ let menuItem = abDocument.getElementById("bookContextSynchronize");
+ let openContext = async (index, itemHidden) => {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.booksList.getRowAtIndex(index),
+ { type: "contextmenu" },
+ abWindow
+ );
+ await shownPromise;
+ Assert.equal(menuItem.hidden, itemHidden);
+ };
+
+ for (let index of [1, 3]) {
+ await openContext(index, true);
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ CardDAVServer.putCardInternal(
+ "first.vcf",
+ "BEGIN:VCARD\r\nUID:first\r\nFN:First\r\nEND:VCARD\r\n"
+ );
+
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+
+ let syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.notEqual(davDirectory._syncTimer, null, "first sync scheduled");
+ let currentSyncTimer = davDirectory._syncTimer;
+
+ checkNamesListed("First");
+
+ CardDAVServer.putCardInternal(
+ "second.vcf",
+ "BEGIN:VCARD\r\nUID:second\r\nFN:Second\r\nEND:VCARD\r\n"
+ );
+
+ syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "second sync not the same as the first"
+ );
+ currentSyncTimer = davDirectory._syncTimer;
+
+ checkNamesListed("First", "Second");
+
+ CardDAVServer.deleteCardInternal("second.vcf");
+ CardDAVServer.putCardInternal(
+ "third.vcf",
+ "BEGIN:VCARD\r\nUID:third\r\nFN:Third\r\nEND:VCARD\r\n"
+ );
+
+ syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "third sync not the same as the second"
+ );
+
+ checkNamesListed("First", "Third");
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(directory.URI);
+ Assert.equal(davDirectory._syncTimer, null, "sync timer cleaned up");
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js b/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js
new file mode 100644
index 0000000000..3fb0f70b25
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js
@@ -0,0 +1,470 @@
+/* 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 { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+var dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+add_task(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let book1 = createAddressBook("Book 1");
+ book1.addCard(createContact("daniel", "test"));
+ book1.addCard(createContact("jonathan", "test"));
+ book1.addCard(createContact("năthån", "test"));
+
+ let book2 = createAddressBook("Book 2");
+ book2.addCard(createContact("danielle", "test"));
+ book2.addCard(createContact("katherine", "test"));
+ book2.addCard(createContact("natalie", "test"));
+ book2.addCard(createContact("sūsãnáh", "test"));
+
+ let list = createMailingList("pèóplë named tēst");
+ book2.addMailList(list);
+
+ registerCleanupFunction(async function () {
+ MailServices.accounts.removeAccount(account, true);
+ await promiseDirectoryRemoved(book1.URI);
+ await promiseDirectoryRemoved(book2.URI);
+ });
+
+ // Open a compose window.
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == composeWindow
+ );
+ let composeDocument = composeWindow.document;
+ let toAddrInput = composeDocument.getElementById("toAddrInput");
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+ let ccAddrInput = composeDocument.getElementById("ccAddrInput");
+ let ccAddrRow = composeDocument.getElementById("addressRowCc");
+ let bccAddrInput = composeDocument.getElementById("bccAddrInput");
+ let bccAddrRow = composeDocument.getElementById("addressRowBcc");
+
+ // The compose window waits before deciding whether to open the sidebar.
+ // We must wait longer.
+ await new Promise(resolve => composeWindow.setTimeout(resolve, 100));
+
+ // Make sure the contacts sidebar is open.
+
+ let sidebar = composeDocument.getElementById("contactsSidebar");
+ if (BrowserTestUtils.is_hidden(sidebar)) {
+ EventUtils.synthesizeKey("KEY_F9", {}, composeWindow);
+ }
+ let sidebarBrowser = composeDocument.getElementById("contactsBrowser");
+ await TestUtils.waitForCondition(
+ () =>
+ sidebarBrowser.currentURI.spec.includes("abContactsPanel.xhtml") &&
+ sidebarBrowser.contentDocument.readyState == "complete"
+ );
+ let sidebarWindow = sidebarBrowser.contentWindow;
+ let sidebarDocument = sidebarBrowser.contentDocument;
+
+ let abList = sidebarDocument.getElementById("addressbookList");
+ let searchBox = sidebarDocument.getElementById("peopleSearchInput");
+ let cardsList = sidebarDocument.getElementById("abResultsTree");
+ let cardsContext = sidebarDocument.getElementById("cardProperties");
+ let toButton = sidebarDocument.getElementById("toButton");
+ let ccButton = sidebarDocument.getElementById("ccButton");
+ let bccButton = sidebarDocument.getElementById("bccButton");
+
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 0);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ Assert.equal(cardsList.view.selection.count, 0, "no contact selected");
+ Assert.ok(toButton.disabled, "to button disabled with no contact selected");
+ Assert.ok(ccButton.disabled, "cc button disabled with no contact selected");
+ Assert.ok(bccButton.disabled, "bcc button disabled with no contact selected");
+
+ function clickOnRow(row, event) {
+ mailTestUtils.treeClick(
+ EventUtils,
+ sidebarWindow,
+ cardsList,
+ row,
+ 0,
+ event
+ );
+ }
+
+ async function doMenulist(value) {
+ let shownPromise = BrowserTestUtils.waitForEvent(abList, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(abList, {}, sidebarWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(abList, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(
+ abList.querySelector(`[value="${value}"]`),
+ {},
+ sidebarWindow
+ );
+ await hiddenPromise;
+ }
+
+ async function doContextMenu(row, command) {
+ clickOnRow(row, {});
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popupshown"
+ );
+ clickOnRow(row, { type: "contextmenu" });
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popuphidden"
+ );
+ cardsContext.activateItem(
+ cardsContext.querySelector(`[command="${command}"]`)
+ );
+ await hiddenPromise;
+ }
+
+ function checkListNames(expectedNames, message) {
+ let actualNames = [];
+ for (let row = 0; row < cardsList.view.rowCount; row++) {
+ actualNames.push(
+ cardsList.view.getCellText(row, cardsList.columns.GeneratedName)
+ );
+ }
+
+ Assert.deepEqual(actualNames, expectedNames, message);
+ }
+
+ function checkPills(row, expectedPills) {
+ let actualPills = Array.from(
+ row.querySelectorAll("mail-address-pill"),
+ p => p.label
+ );
+ Assert.deepEqual(
+ actualPills,
+ expectedPills,
+ "message recipients match expected"
+ );
+ }
+
+ function clearPills() {
+ for (let input of [toAddrInput, ccAddrInput, bccAddrInput]) {
+ EventUtils.synthesizeMouseAtCenter(input, {}, composeWindow);
+ EventUtils.synthesizeKey(
+ "a",
+ {
+ accelKey: AppConstants.platform == "macosx",
+ ctrlKey: AppConstants.platform != "macosx",
+ },
+ composeWindow
+ );
+ EventUtils.synthesizeKey("KEY_Delete", {}, composeWindow);
+ }
+ checkPills(toAddrRow, []);
+ checkPills(ccAddrRow, []);
+ checkPills(bccAddrRow, []);
+ }
+
+ async function inABEditingMode() {
+ let topWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ let abWindow = await topWindow.toAddressBook();
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+ let tabmail = topWindow.document.getElementById("tabmail");
+ let tab = tabmail.tabInfo.find(
+ t => t.browser?.currentURI.spec == "about:addressbook"
+ );
+ tabmail.closeTab(tab);
+ }
+
+ /**
+ * Make sure the "edit contact" menuitem only shows up for the correct
+ * contacts, and it properly opens the address book tab.
+ *
+ * @param {int} row - The row index to activate.
+ * @param {boolean} isEditable - If the selected contact should be editable.
+ */
+ async function checkEditContact(row, isEditable) {
+ clickOnRow(row, {});
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popupshown"
+ );
+ clickOnRow(row, { type: "contextmenu" });
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popuphidden"
+ );
+
+ Assert.equal(
+ cardsContext.querySelector("#abContextBeforeEditContact").hidden,
+ !isEditable
+ );
+ Assert.equal(
+ cardsContext.querySelector("#abContextEditContact").hidden,
+ !isEditable
+ );
+
+ // If it's an editable row, we should see the edit contact menu items.
+ if (isEditable) {
+ cardsContext.activateItem(
+ cardsContext.querySelector("#abContextEditContact")
+ );
+ await hiddenPromise;
+ await inABEditingMode();
+ composeWindow.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == composeWindow
+ );
+ } else {
+ cardsContext.activateItem(
+ cardsContext.querySelector(`[command="cmd_addrBcc"]`)
+ );
+ await hiddenPromise;
+ }
+ }
+
+ // Click on a contact and make sure is editable.
+ await checkEditContact(2, true);
+ // Click on a mailing list and make sure is NOT editable.
+ await checkEditContact(6, false);
+
+ // Check that the address book picker works.
+
+ await doMenulist(book1.URI);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 0);
+ checkListNames(
+ ["daniel test", "jonathan test", "năthån test"],
+ "book1 contacts are shown"
+ );
+
+ await doMenulist(book2.URI);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 3);
+ checkListNames(
+ [
+ "danielle test",
+ "katherine test",
+ "natalie test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "book2 contacts are shown"
+ );
+
+ await doMenulist("moz-abdirectory://?");
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 5);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that the search works.
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, sidebarWindow);
+
+ EventUtils.synthesizeKey("a", { accelKey: true }, sidebarWindow);
+ EventUtils.sendString("dan", sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 8);
+ checkListNames(
+ ["daniel test", "danielle test"],
+ "matching contacts are shown"
+ );
+
+ EventUtils.synthesizeKey("a", { accelKey: true }, sidebarWindow);
+ EventUtils.sendString("kat", sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 2);
+ checkListNames(["katherine test"], "matching contacts are shown");
+
+ EventUtils.synthesizeKey("KEY_Escape", { accelKey: true }, sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 1);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that double-clicking works.
+
+ clickOnRow(1, { clickCount: 2 });
+ checkPills(toAddrRow, ["danielle test <danielle.test@invalid>"]);
+
+ clickOnRow(3, { clickCount: 2 });
+ checkPills(toAddrRow, [
+ "danielle test <danielle.test@invalid>",
+ "katherine test <katherine.test@invalid>",
+ ]);
+
+ clickOnRow(6, { clickCount: 2 });
+ checkPills(toAddrRow, [
+ "danielle test <danielle.test@invalid>",
+ "katherine test <katherine.test@invalid>",
+ "pèóplë named tēst <pèóplë named tēst>",
+ ]);
+
+ clearPills();
+
+ // Check that drag and drop to the recipients section works.
+
+ clickOnRow(5, {});
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList,
+ toAddrInput,
+ null,
+ null,
+ sidebarWindow,
+ composeWindow
+ );
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ toAddrInput,
+ composeWindow
+ );
+
+ dragService.endDragSession(true);
+ checkPills(toAddrRow, ["năthån test <năthån.test@invalid>"]);
+
+ clearPills();
+
+ // Check that the "Add to" buttons work.
+
+ clickOnRow(7, {});
+
+ Assert.ok(!toButton.disabled, "to button enabled with a contact selected");
+ Assert.ok(!ccButton.disabled, "cc button enabled with a contact selected");
+ Assert.ok(!bccButton.disabled, "bcc button enabled with a contact selected");
+
+ EventUtils.synthesizeMouseAtCenter(toButton, {}, sidebarWindow);
+ checkPills(toAddrRow, ["sūsãnáh test <sūsãnáh.test@invalid>"]);
+
+ clickOnRow(0, {});
+ EventUtils.synthesizeMouseAtCenter(ccButton, {}, sidebarWindow);
+ Assert.ok(BrowserTestUtils.is_visible(ccAddrRow), "cc row visible");
+ checkPills(ccAddrRow, ["daniel test <daniel.test@invalid>"]);
+
+ clickOnRow(2, {});
+ EventUtils.synthesizeMouseAtCenter(bccButton, {}, sidebarWindow);
+ Assert.ok(BrowserTestUtils.is_visible(bccAddrRow), "bcc row visible");
+ checkPills(bccAddrRow, ["jonathan test <jonathan.test@invalid>"]);
+
+ clearPills();
+
+ // Check that the context menu works.
+
+ await doContextMenu(7, "cmd_addrTo");
+ checkPills(toAddrRow, ["sūsãnáh test <sūsãnáh.test@invalid>"]);
+
+ await doContextMenu(4, "cmd_addrCc");
+ checkPills(ccAddrRow, ["natalie test <natalie.test@invalid>"]);
+
+ await doContextMenu(2, "cmd_addrBcc");
+ checkPills(bccAddrRow, ["jonathan test <jonathan.test@invalid>"]);
+
+ clearPills();
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let deletedPromise = TestUtils.topicObserved(
+ "addrbook-contact-deleted",
+ c => c.displayName == "daniel test"
+ );
+ doContextMenu(0, "cmd_delete");
+ await promptPromise;
+ await deletedPromise;
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 8);
+ checkListNames(
+ [
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that the keyboard commands work.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ deletedPromise = TestUtils.topicObserved(
+ "addrbook-contact-deleted",
+ c => c.displayName == "danielle test"
+ );
+ clickOnRow(0, {});
+ EventUtils.synthesizeKey("KEY_Delete", {}, sidebarWindow);
+ await promptPromise;
+ await deletedPromise;
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 7);
+ checkListNames(
+ [
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // TODO sidebar context menu
+
+ // Close the compose window and clean up.
+
+ EventUtils.synthesizeKey("KEY_F9", {}, composeWindow);
+ await TestUtils.waitForCondition(() => BrowserTestUtils.is_hidden(sidebar));
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ let closePromise = BrowserTestUtils.windowClosed(composeWindow);
+ composeWindow.goDoCommand("cmd_close");
+ await promptPromise;
+ await closePromise;
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_contact_tree.js b/comm/mail/components/addrbook/test/browser/browser_contact_tree.js
new file mode 100644
index 0000000000..f502fe855a
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_contact_tree.js
@@ -0,0 +1,1261 @@
+/* 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/. */
+
+function rightClickOnIndex(index) {
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let menu = abWindow.document.getElementById("cardContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { type: "contextmenu" },
+ abWindow
+ );
+ return shownPromise;
+}
+
+/**
+ * Tests that additions and removals are accurately displayed, or not
+ * displayed if they happen outside the current address book.
+ */
+add_task(async function test_additions_and_removals() {
+ async function deleteRowWithPrompt(index) {
+ let promptPromise = BrowserTestUtils.promiseAlertDialogOpen("accept");
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ EventUtils.synthesizeKey("VK_DELETE", {}, abWindow);
+ await promptPromise;
+ await new Promise(r => abWindow.setTimeout(r));
+ await new Promise(r => abWindow.setTimeout(r));
+ }
+
+ let bookA = createAddressBook("book A");
+ let contactA1 = bookA.addCard(createContact("contact", "A1"));
+ let bookB = createAddressBook("book B");
+ let contactB1 = bookB.addCard(createContact("contact", "B1"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ await openAllAddressBooks();
+ info("Performing check #1");
+ checkCardsListed(contactA1, contactB1);
+
+ // While in bookA, add a contact and list. Check that they show up.
+ openDirectory(bookA);
+ checkCardsListed(contactA1);
+ let contactA2 = bookA.addCard(createContact("contact", "A2")); // Add A2.
+ checkCardsListed(contactA1, contactA2);
+ let listC = bookA.addMailList(createMailingList("list C")); // Add C.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA1, contactA2, listC);
+ listC.addCard(contactA1);
+ checkCardsListed(contactA1, contactA2, listC);
+
+ await openAllAddressBooks();
+ info("Performing check #2");
+ checkCardsListed(contactA1, contactA2, contactB1, listC);
+
+ // While in listC, add a member and remove a member. Check that they show up
+ // or disappear as appropriate.
+ openDirectory(listC);
+ checkCardsListed(contactA1);
+ listC.addCard(contactA2);
+ checkCardsListed(contactA1, contactA2);
+ await deleteRowWithPrompt(0);
+ checkCardsListed(contactA2);
+ Assert.equal(cardsList.currentIndex, 0);
+
+ await openAllAddressBooks();
+ info("Performing check #3");
+ checkCardsListed(contactA1, contactA2, contactB1, listC);
+
+ // While in bookA, delete a contact. Check it disappears.
+ openDirectory(bookA);
+ checkCardsListed(contactA1, contactA2, listC);
+ await deleteRowWithPrompt(0); // Delete A1.
+ checkCardsListed(contactA2, listC);
+ Assert.equal(cardsList.currentIndex, 0);
+ // Now do some things in an unrelated book. Check nothing changes here.
+ let contactB2 = bookB.addCard(createContact("contact", "B2")); // Add B2.
+ checkCardsListed(contactA2, listC);
+ let listD = bookB.addMailList(createMailingList("list D")); // Add D.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA2, listC);
+ listD.addCard(contactB1);
+ checkCardsListed(contactA2, listC);
+
+ await openAllAddressBooks();
+ info("Performing check #4");
+ checkCardsListed(contactA2, contactB1, contactB2, listC, listD);
+
+ // While in listC, do some things in an unrelated list. Check nothing
+ // changes here.
+ openDirectory(listC);
+ checkCardsListed(contactA2);
+ listD.addCard(contactB2);
+ checkCardsListed(contactA2);
+ listD.deleteCards([contactB1]);
+ checkCardsListed(contactA2);
+ bookB.deleteCards([contactB1]);
+ checkCardsListed(contactA2);
+
+ await openAllAddressBooks();
+ info("Performing check #5");
+ checkCardsListed(contactA2, contactB2, listC, listD);
+
+ // While in bookA, do some things in an unrelated book. Check nothing
+ // changes here.
+ openDirectory(bookA);
+ checkCardsListed(contactA2, listC);
+ bookB.deleteDirectory(listD); // Delete D.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA2, listC);
+ await deleteRowWithPrompt(1); // Delete C.
+ checkCardsListed(contactA2);
+
+ // While in "All Address Books", make some changes and check that things
+ // appear or disappear as appropriate.
+ await openAllAddressBooks();
+ info("Performing check #6");
+ checkCardsListed(contactA2, contactB2);
+ let listE = bookB.addMailList(createMailingList("list E")); // Add E.
+ checkDirectoryDisplayed(null);
+ checkCardsListed(contactA2, contactB2, listE);
+ listE.addCard(contactB2);
+ checkCardsListed(contactA2, contactB2, listE);
+ listE.deleteCards([contactB2]);
+ checkCardsListed(contactA2, contactB2, listE);
+ bookB.deleteDirectory(listE); // Delete E.
+ checkDirectoryDisplayed(null);
+ checkCardsListed(contactA2, contactB2);
+ await deleteRowWithPrompt(1);
+ checkCardsListed(contactA2);
+ Assert.equal(cardsList.currentIndex, 0);
+ bookA.deleteCards([contactA2]);
+ checkCardsListed();
+ Assert.equal(cardsList.currentIndex, -1);
+
+ // While in "All Address Books", delete a directory that has contacts and
+ // mailing lists. They should disappear.
+ let contactA3 = bookA.addCard(createContact("contact", "A3")); // Add A3.
+ checkCardsListed(contactA3);
+ let listF = bookA.addMailList(createMailingList("list F")); // Add F.
+ checkCardsListed(contactA3, listF);
+ await promiseDirectoryRemoved(bookA.URI);
+ checkCardsListed();
+
+ abWindow.close();
+
+ await promiseDirectoryRemoved(bookB.URI);
+});
+
+/**
+ * Tests that added contacts are inserted in the right place in the list.
+ */
+add_task(async function test_insertion_order() {
+ await openAddressBookWindow();
+
+ let bookA = createAddressBook("book A");
+ openDirectory(bookA);
+ checkCardsListed();
+ let contactA2 = bookA.addCard(createContact("contact", "A2"));
+ checkCardsListed(contactA2);
+ let contactA1 = bookA.addCard(createContact("contact", "A1")); // Add first.
+ checkCardsListed(contactA1, contactA2);
+ let contactA5 = bookA.addCard(createContact("contact", "A5")); // Add last.
+ checkCardsListed(contactA1, contactA2, contactA5);
+ let contactA3 = bookA.addCard(createContact("contact", "A3")); // Add in the middle.
+ checkCardsListed(contactA1, contactA2, contactA3, contactA5);
+
+ // Flip sort direction.
+ await showSortMenu("sort", "GeneratedName descending");
+
+ checkCardsListed(contactA5, contactA3, contactA2, contactA1);
+ let contactA4 = bookA.addCard(createContact("contact", "A4")); // Add in the middle.
+ checkCardsListed(contactA5, contactA4, contactA3, contactA2, contactA1);
+ let contactA7 = bookA.addCard(createContact("contact", "A7")); // Add first.
+ checkCardsListed(
+ contactA7,
+ contactA5,
+ contactA4,
+ contactA3,
+ contactA2,
+ contactA1
+ );
+ let contactA0 = bookA.addCard(createContact("contact", "A0")); // Add last.
+ checkCardsListed(
+ contactA7,
+ contactA5,
+ contactA4,
+ contactA3,
+ contactA2,
+ contactA1,
+ contactA0
+ );
+
+ contactA3.displayName = "contact A6";
+ contactA3.lastName = "contact A3";
+ contactA3.primaryEmail = "contact.A6@invalid";
+ bookA.modifyCard(contactA3); // Rename, should change position.
+ checkCardsListed(
+ contactA7,
+ contactA3, // Actually A6.
+ contactA5,
+ contactA4,
+ contactA2,
+ contactA1,
+ contactA0
+ );
+
+ // Restore original sort direction.
+ await showSortMenu("sort", "GeneratedName ascending");
+
+ checkCardsListed(
+ contactA0,
+ contactA1,
+ contactA2,
+ contactA4,
+ contactA5,
+ contactA3, // Actually A6.
+ contactA7
+ );
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(bookA.URI);
+});
+
+/**
+ * Tests the name column is updated when the format changes.
+ */
+add_task(async function test_name_column() {
+ const {
+ GENERATE_DISPLAY_NAME,
+ GENERATE_LAST_FIRST_ORDER,
+ GENERATE_FIRST_LAST_ORDER,
+ } = Ci.nsIAbCard;
+
+ let book = createAddressBook("book");
+ book.addCard(createContact("alpha", "tango", "kilo"));
+ book.addCard(createContact("bravo", "zulu", "quebec"));
+ book.addCard(createContact("charlie", "mike", "whiskey"));
+ book.addCard(createContact("delta", "foxtrot", "sierra"));
+ book.addCard(createContact("echo", "november", "uniform"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ // Check the format is display name, ascending.
+ Assert.equal(
+ Services.prefs.getIntPref("mail.addr_book.lastnamefirst"),
+ GENERATE_DISPLAY_NAME
+ );
+
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+
+ // Select the "delta foxtrot" contact. This should remain selected throughout.
+ cardsList.selectedIndex = 2;
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "foxtrot, delta",
+ "mike, charlie",
+ "november, echo",
+ "tango, alpha",
+ "zulu, bravo"
+ );
+ Assert.equal(cardsList.selectedIndex, 0);
+ Assert.deepEqual(cardsList.selectedIndices, [0]);
+
+ // Change the format to first last.
+ await showSortMenu("format", GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Flip the order to descending.
+ await showSortMenu("sort", "GeneratedName descending");
+
+ checkNamesListed(
+ "echo november",
+ "delta foxtrot",
+ "charlie mike",
+ "bravo zulu",
+ "alpha tango"
+ );
+ Assert.equal(cardsList.selectedIndex, 1);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "zulu, bravo",
+ "tango, alpha",
+ "november, echo",
+ "mike, charlie",
+ "foxtrot, delta"
+ );
+ Assert.equal(cardsList.selectedIndex, 4);
+
+ // Change the format to display name.
+ await showSortMenu("format", GENERATE_DISPLAY_NAME);
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ // Sort by email address, ascending.
+ await showSortMenu("sort", "EmailAddresses ascending");
+
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "tango, alpha",
+ "zulu, bravo",
+ "mike, charlie",
+ "foxtrot, delta",
+ "november, echo"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to first last.
+ await showSortMenu("format", GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to display name.
+ await showSortMenu("format", GENERATE_DISPLAY_NAME);
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Restore original sort column and direction.
+ await showSortMenu("sort", "GeneratedName ascending");
+
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests that sort order and name format survive closing and reopening.
+ */
+add_task(async function test_persistence() {
+ let book = createAddressBook("book");
+ book.addCard(createContact("alpha", "tango", "kilo"));
+ book.addCard(createContact("bravo", "zulu", "quebec"));
+ book.addCard(createContact("charlie", "mike", "whiskey"));
+ book.addCard(createContact("delta", "foxtrot", "sierra"));
+ book.addCard(createContact("echo", "november", "uniform"));
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.addr_book.lastnamefirst");
+
+ await openAddressBookWindow();
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+
+ info("sorting by GeneratedName, descending");
+ await showSortMenu("sort", "GeneratedName descending");
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+
+ info("sorting by EmailAddresses, ascending");
+ await showSortMenu("sort", "EmailAddresses ascending");
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+
+ info("setting name format to first last");
+ await showSortMenu("format", Ci.nsIAbCard.GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.addr_book.lastnamefirst");
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the context menu compose items.
+ */
+add_task(async function test_context_menu_compose() {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ let book = createAddressBook("Book");
+ let contactA = book.addCard(createContact("Contact", "A"));
+ let contactB = createContact("Contact", "B");
+ contactB.setProperty("SecondEmail", "b.contact@invalid");
+ contactB = book.addCard(contactB);
+ let contactC = createContact("Contact", "C");
+ contactC.primaryEmail = null;
+ contactC.setProperty("SecondEmail", "c.contact@invalid");
+ contactC = book.addCard(contactC);
+ let contactD = createContact("Contact", "D");
+ contactD.primaryEmail = null;
+ contactD = book.addCard(contactD);
+ let list = book.addMailList(createMailingList("List"));
+ list.addCard(contactA);
+ list.addCard(contactB);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let writeMenuItem = abDocument.getElementById("cardContextWrite");
+ let writeMenu = abDocument.getElementById("cardContextWriteMenu");
+ let writeMenuSeparator = abDocument.getElementById(
+ "cardContextWriteSeparator"
+ );
+
+ openDirectory(book);
+
+ // Contact A, first and only email address.
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(0);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact A <contact.a@invalid>"
+ );
+
+ // Contact B, first email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(1);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(!writeMenu.hidden, "write menu shown");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ let shownPromise = BrowserTestUtils.waitForEvent(writeMenu, "popupshown");
+ writeMenu.openMenu(true);
+ await shownPromise;
+ let subMenuItems = writeMenu.querySelectorAll("menuitem");
+ Assert.equal(subMenuItems.length, 2);
+ Assert.equal(subMenuItems[0].label, "Contact B <contact.b@invalid>");
+ Assert.equal(subMenuItems[1].label, "Contact B <b.contact@invalid>");
+
+ writeMenu.menupopup.activateItem(subMenuItems[0]);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>"
+ );
+
+ // Contact B, second email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(1);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(!writeMenu.hidden, "write menu shown");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ shownPromise = BrowserTestUtils.waitForEvent(writeMenu, "popupshown");
+ writeMenu.openMenu(true);
+ await shownPromise;
+ subMenuItems = writeMenu.querySelectorAll("menuitem");
+ Assert.equal(subMenuItems.length, 2);
+ Assert.equal(subMenuItems[0].label, "Contact B <contact.b@invalid>");
+ Assert.equal(subMenuItems[1].label, "Contact B <b.contact@invalid>");
+
+ writeMenu.menupopup.activateItem(subMenuItems[1]);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <b.contact@invalid>"
+ );
+
+ // Contact C, second and only email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(2);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact C <c.contact@invalid>"
+ );
+
+ // Contact D, no email address.
+
+ await rightClickOnIndex(3);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(writeMenuSeparator.hidden, "write menu separator hidden");
+ menu.hidePopup();
+
+ // List.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(4);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(await composeWindowPromise, "List <List>");
+
+ // Contact A and Contact D.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [0, 3];
+ await rightClickOnIndex(3);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact A <contact.a@invalid>"
+ );
+
+ // Contact B and Contact C.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [1, 2];
+ await rightClickOnIndex(2);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>",
+ "Contact C <c.contact@invalid>"
+ );
+
+ // Contact B and List.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [1, 4];
+ await rightClickOnIndex(4);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>",
+ "List <List>"
+ );
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the context menu edit items.
+ */
+add_task(async function test_context_menu_edit() {
+ let normalBook = createAddressBook("Normal Book");
+ let normalList = normalBook.addMailList(createMailingList("Normal List"));
+ let normalContact = normalBook.addCard(createContact("Normal", "Contact"));
+ normalList.addCard(normalContact);
+
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ let readOnlyContact = readOnlyBook.addCard(
+ createContact("Read-Only", "Contact")
+ );
+ readOnlyList.addCard(readOnlyContact);
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let editMenuItem = abDocument.getElementById("cardContextEdit");
+ let exportMenuItem = abDocument.getElementById("cardContextExport");
+
+ async function checkEditItems(index, hidden, isMailList = false) {
+ await rightClickOnIndex(index);
+
+ Assert.equal(
+ editMenuItem.hidden,
+ hidden,
+ `editMenuItem should be hidden=${hidden} on index ${index}`
+ );
+ Assert.equal(
+ exportMenuItem.hidden,
+ !isMailList,
+ `exportMenuItem should be hidden=${!isMailList} on index ${index}`
+ );
+
+ Assert.deepEqual(document.l10n.getAttributes(editMenuItem), {
+ id: isMailList
+ ? "about-addressbook-books-context-edit-list"
+ : "about-addressbook-books-context-edit",
+ args: null,
+ });
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ info("Testing Normal Book");
+ openDirectory(normalBook);
+ await checkEditItems(0, false); // normal contact
+ await checkEditItems(1, false, true); // normal list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkEditItems(0, true); // normal contact + normal list
+ await checkEditItems(1, true); // normal contact + normal list
+
+ info("Testing Normal List");
+ openDirectory(normalList);
+ await checkEditItems(0, false); // normal contact
+
+ info("Testing Read-Only Book");
+ openDirectory(readOnlyBook);
+ await checkEditItems(0, true); // read-only contact
+ await checkEditItems(1, true, true); // read-only list
+
+ info("Testing Read-Only List");
+ openDirectory(readOnlyList);
+ await checkEditItems(0, true); // read-only contact
+
+ info("Testing All Address Books");
+ openAllAddressBooks();
+ await checkEditItems(0, false); // normal contact
+ await checkEditItems(1, false, true); // normal list
+ await checkEditItems(2, true); // read-only contact
+ await checkEditItems(3, true, true); // read-only list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkEditItems(1, true); // normal contact + normal list
+
+ cardsList.selectedIndices = [0, 2];
+ await checkEditItems(2, true); // normal contact + read-only contact
+
+ cardsList.selectedIndices = [1, 3];
+ await checkEditItems(3, true); // normal list + read-only list
+
+ cardsList.selectedIndices = [0, 1, 2, 3];
+ await checkEditItems(3, true); // everything
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(normalBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Tests the context menu delete items.
+ */
+add_task(async function test_context_menu_delete() {
+ let normalBook = createAddressBook("Normal Book");
+ let normalList = normalBook.addMailList(createMailingList("Normal List"));
+ let normalContact = normalBook.addCard(createContact("Normal", "Contact"));
+ normalList.addCard(normalContact);
+
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ let readOnlyContact = readOnlyBook.addCard(
+ createContact("Read-Only", "Contact")
+ );
+ readOnlyList.addCard(readOnlyContact);
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let deleteMenuItem = abDocument.getElementById("cardContextDelete");
+ let removeMenuItem = abDocument.getElementById("cardContextRemove");
+
+ async function checkDeleteItems(index, deleteHidden, removeHidden, disabled) {
+ await rightClickOnIndex(index);
+
+ Assert.equal(
+ deleteMenuItem.hidden,
+ deleteHidden,
+ `deleteMenuItem.hidden on index ${index}`
+ );
+ Assert.equal(
+ deleteMenuItem.disabled,
+ disabled,
+ `deleteMenuItem.disabled on index ${index}`
+ );
+ Assert.equal(
+ removeMenuItem.hidden,
+ removeHidden,
+ `removeMenuItem.hidden on index ${index}`
+ );
+ Assert.equal(
+ removeMenuItem.disabled,
+ disabled,
+ `removeMenuItem.disabled on index ${index}`
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ info("Testing Normal Book");
+ openDirectory(normalBook);
+ await checkDeleteItems(0, false, true, false); // normal contact
+ await checkDeleteItems(1, false, true, false); // normal list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkDeleteItems(0, false, true, false); // normal contact + normal list
+ await checkDeleteItems(1, false, true, false); // normal contact + normal list
+
+ info("Testing Normal List");
+ openDirectory(normalList);
+ await checkDeleteItems(0, true, false, false); // normal contact
+
+ info("Testing Read-Only Book");
+ openDirectory(readOnlyBook);
+ await checkDeleteItems(0, false, true, true); // read-only contact
+ await checkDeleteItems(1, false, true, true); // read-only list
+
+ info("Testing Read-Only List");
+ openDirectory(readOnlyList);
+ await checkDeleteItems(0, true, false, true); // read-only contact
+
+ info("Testing All Address Books");
+ openAllAddressBooks();
+ await checkDeleteItems(0, false, true, false); // normal contact
+ await checkDeleteItems(1, false, true, false); // normal list
+ await checkDeleteItems(2, false, true, true); // read-only contact
+ await checkDeleteItems(3, false, true, true); // read-only list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkDeleteItems(1, false, true, false); // normal contact + normal list
+
+ cardsList.selectedIndices = [0, 2];
+ await checkDeleteItems(2, false, true, true); // normal contact + read-only contact
+
+ cardsList.selectedIndices = [1, 3];
+ await checkDeleteItems(3, false, true, true); // normal list + read-only list
+
+ cardsList.selectedIndices = [0, 1, 2, 3];
+ await checkDeleteItems(3, false, true, true); // everything
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(normalBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+add_task(async function test_layout() {
+ function checkColumns(visibleColumns, sortColumn, sortDirection) {
+ let visibleHeaders = cardsHeader.querySelectorAll(
+ `th[is="tree-view-table-header-cell"]:not([hidden])`
+ );
+ Assert.deepEqual(
+ Array.from(visibleHeaders, h => h.id),
+ visibleColumns,
+ "visible columns are correct"
+ );
+
+ for (let header of visibleHeaders) {
+ let button = header.querySelector("button");
+ Assert.equal(
+ button.classList.contains("ascending"),
+ header.id == sortColumn && sortDirection == "ascending",
+ `${header.id} header is ascending`
+ );
+ Assert.equal(
+ button.classList.contains("descending"),
+ header.id == sortColumn && sortDirection == "descending",
+ `${header.id} header is descending`
+ );
+ }
+ }
+
+ function checkRowHeight(height) {
+ Assert.equal(cardsList.getRowAtIndex(0).clientHeight, height);
+ }
+
+ Services.prefs.setIntPref("mail.uidensity", 0);
+ personalBook.addCard(
+ createContact("contact", "one", undefined, "first@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "two", undefined, "second@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "three", undefined, "third@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "four", undefined, "fourth@invalid")
+ );
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+ let sharedSplitter = abDocument.getElementById("sharedSplitter");
+
+ // Sanity check.
+
+ Assert.ok(
+ !abDocument.body.classList.contains("layout-table"),
+ "not table layout on opening"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "horizontal",
+ "splitter direction is horizontal"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "cardsPane",
+ "splitter is affecting the cards pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-card-row",
+ "list row implementation used"
+ );
+
+ // Switch layout to table.
+
+ await toggleLayout();
+
+ Assert.ok(
+ abDocument.body.classList.contains("layout-table"),
+ "layout changed"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "vertical",
+ "splitter direction is vertical"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "detailsPane",
+ "splitter is affecting the details pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-table-card-row",
+ "table row implementation used"
+ );
+
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "GeneratedName",
+ "ascending"
+ );
+ checkNamesListed(
+ "contact four",
+ "contact one",
+ "contact three",
+ "contact two"
+ );
+ checkRowHeight(18);
+
+ // Click the email addresses header to sort.
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsHeader.querySelector(`[id="EmailAddressesButton"]`),
+ {},
+ abWindow
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "EmailAddresses",
+ "ascending"
+ );
+ checkNamesListed(
+ "contact one",
+ "contact four",
+ "contact two",
+ "contact three"
+ );
+
+ // Click the email addresses header again to flip the sort.
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsHeader.querySelector(`[id="EmailAddressesButton"]`),
+ {},
+ abWindow
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "EmailAddresses",
+ "descending"
+ );
+ checkNamesListed(
+ "contact three",
+ "contact two",
+ "contact four",
+ "contact one"
+ );
+
+ // Add a column.
+
+ await showPickerMenu("toggle", "Title");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="Title"]`).hidden
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+
+ // Remove a column.
+
+ await showPickerMenu("toggle", "Addresses");
+ await TestUtils.waitForCondition(
+ () => cardsHeader.querySelector(`[id="Addresses"]`).hidden
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+
+ // Change the density.
+
+ Services.prefs.setIntPref("mail.uidensity", 1);
+ checkRowHeight(22);
+
+ Services.prefs.setIntPref("mail.uidensity", 2);
+ checkRowHeight(32);
+
+ // Close and reopen the Address Book and check that settings were remembered.
+
+ await closeAddressBookWindow();
+
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+ cardsList = abWindow.cardsPane.cardsList;
+ cardsHeader = abWindow.cardsPane.table.header;
+ sharedSplitter = abDocument.getElementById("sharedSplitter");
+
+ Assert.ok(
+ abDocument.body.classList.contains("layout-table"),
+ "table layout preserved on reopening"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "vertical",
+ "splitter direction preserved as vertical"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "detailsPane",
+ "splitter preserved affecting the details pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-table-card-row",
+ "table row implementation used"
+ );
+
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+ checkNamesListed(
+ "contact three",
+ "contact two",
+ "contact four",
+ "contact one"
+ );
+ checkRowHeight(32);
+
+ // Reset layout to list.
+
+ await toggleLayout();
+
+ Assert.ok(
+ !abDocument.body.classList.contains("layout-table"),
+ "layout changed"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "horizontal",
+ "splitter direction is horizontal"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "cardsPane",
+ "splitter is affecting the cards pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-card-row",
+ "list row implementation used"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.uidensity");
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+add_task(async function test_placeholders() {
+ let writableBook = createAddressBook("Writable Book");
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let placeholderCreateContact = abWindow.document.getElementById(
+ "placeholderCreateContact"
+ );
+
+ info("checking all address books");
+ await openAllAddressBooks();
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ info("checking writable book");
+ await openDirectory(writableBook);
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ let writableList = writableBook.addMailList(
+ createMailingList("Writable List")
+ );
+ checkPlaceholders();
+
+ info("checking writable list");
+ await openDirectory(writableList);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking writable book");
+ await openDirectory(writableBook);
+ writableBook.deleteDirectory(writableList);
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ info("checking read-only book");
+ await openDirectory(readOnlyBook);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ // This wouldn't happen but we need to check the state in a read-only list.
+ readOnlyBook.setBoolValue("readOnly", false);
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ readOnlyBook.setBoolValue("readOnly", true);
+ checkPlaceholders();
+
+ info("checking read-only list");
+ await openDirectory(readOnlyList);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking read-only book");
+ await openDirectory(readOnlyBook);
+ readOnlyBook.setBoolValue("readOnly", false);
+ readOnlyBook.deleteDirectory(readOnlyList);
+ readOnlyBook.setBoolValue("readOnly", true);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking button opens a new contact to edit");
+ await openAllAddressBooks();
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+ EventUtils.synthesizeMouseAtCenter(placeholderCreateContact, {}, abWindow);
+
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(writableBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Checks that mailling lists address books are shown in the table layout.
+ */
+add_task(async function test_list_table_layout() {
+ let book = createAddressBook("Book");
+ book.addCard(createContact("contact", "one"));
+ let list = createMailingList("list one");
+ book.addMailList(list);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+
+ // Switch layout to table.
+
+ await toggleLayout();
+
+ await showPickerMenu("toggle", "addrbook");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="addrbook"]`).hidden
+ );
+
+ // Check for the contact that the column is shown.
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".addrbook-column").hidden,
+ "Address book column is shown."
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".addrbook-column")
+ .textContent.includes("Book"),
+ "Address book column has the correct name for a contact."
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".addrbook-column")
+ .textContent.includes("Book"),
+ "Address book column has the correct name for a list."
+ );
+
+ Services.xulStore.removeDocument("about:addressbook");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the option of showing the address book for All Address Book for the
+ * list view (vertical layout).
+ */
+add_task(async function test_list_all_address_book() {
+ let firstBook = createAddressBook("First Book");
+ let secondBook = createAddressBook("Second Book");
+ firstBook.addCard(createContact("contact", "one"));
+ secondBook.addCard(createContact("contact", "two"));
+ let list = createMailingList("list two");
+ secondBook.addMailList(list);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+
+ info("Check that no address book suffix is present.");
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+ Assert.ok(
+ !cardsList.getRowAtIndex(1).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+ Assert.ok(
+ !cardsList.getRowAtIndex(2).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+
+ info("Toggle the option to show address books.");
+ await showSortMenu("toggle", "addrbook");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="addrbook"]`).hidden
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".address-book-name")
+ .textContent.includes("First Book"),
+ "Address book suffix is present."
+ );
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(1)
+ .querySelector(".address-book-name")
+ .textContent.includes("Second Book"),
+ "Address book suffix is present."
+ );
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(2)
+ .querySelector(".address-book-name")
+ .textContent.includes("Second Book"),
+ "Address book suffix is present for a list."
+ );
+
+ info(`Select another address book and check that no address book suffix is
+ present for another book besides All Address Book`);
+ await openDirectory(secondBook);
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".address-book-name"),
+ "Address book suffix is only present in All Address Book."
+ );
+
+ Services.xulStore.removeDocument("about:addressbook");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(firstBook.URI);
+ await promiseDirectoryRemoved(secondBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_directory_tree.js b/comm/mail/components/addrbook/test/browser/browser_directory_tree.js
new file mode 100644
index 0000000000..ee4b31ab7c
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_directory_tree.js
@@ -0,0 +1,982 @@
+/* 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/. */
+
+function rightClickOnIndex(index) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.booksList;
+ let menu = abWindow.document.getElementById("bookContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ booksList
+ .getRowAtIndex(index)
+ .querySelector(".bookRow-name, .listRow-name"),
+ { type: "contextmenu" },
+ abWindow
+ );
+ return shownPromise;
+}
+
+/**
+ * Tests that additions and removals are accurately displayed.
+ */
+add_task(async function test_additions_and_removals() {
+ function checkBooksOrder(...expected) {
+ function checkRow(index, { level, open, isList, text, uid }) {
+ info(`Row ${index}`);
+ let row = rows[index];
+
+ let containingList = row.closest("ul");
+ if (level == 1) {
+ Assert.equal(containingList.getAttribute("is"), "ab-tree-listbox");
+ } else if (level == 2) {
+ Assert.equal(containingList.parentNode.localName, "li");
+ containingList = containingList.parentNode.closest("ul");
+ Assert.equal(containingList.getAttribute("is"), "ab-tree-listbox");
+ }
+
+ let childList = row.querySelector("ul");
+ // NOTE: We're not explicitly handling open === false because no test
+ // needed it.
+ if (open) {
+ // Ancestor shouldn't have the collapsed class and the UL child list
+ // should be expanded and visible.
+ Assert.ok(!row.classList.contains("collapsed"));
+ Assert.greater(childList.clientHeight, 0);
+ } else if (childList) {
+ if (row.classList.contains("collapsed")) {
+ // If we have a UL child list and the ancestor element has a collapsed
+ // class, the child list shouldn't be visible.
+ Assert.equal(childList.clientHeight, 0);
+ } else if (childList.childNodes.length) {
+ // If the ancestor doesn't have the collapsed class, and the UL child
+ // list has at least one child node, the child list should be visible.
+ Assert.greater(childList.clientHeight, 0);
+ }
+ }
+
+ Assert.equal(row.classList.contains("listRow"), isList);
+ Assert.equal(row.querySelector("span").textContent, text);
+ Assert.equal(row.getAttribute("aria-label"), text);
+ Assert.equal(row.dataset.uid, uid);
+ }
+
+ let rows = abWindow.booksList.rows;
+ Assert.equal(rows.length, expected.length + 1);
+ for (let i = 0; i < expected.length; i++) {
+ let dir = expected[i].directory;
+ checkRow(i + 1, {
+ ...expected[i],
+ isList: dir.isMailList,
+ text: dir.dirName,
+ uid: dir.UID,
+ });
+ }
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ // Check the initial order.
+
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add one book, *not* using the UI, and check that we don't move to it.
+
+ let newBook1 = createAddressBook("New Book 1");
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add another book, using the UI, and check that we move to the new book.
+
+ let newBook2 = await createAddressBookWithUI("New Book 2");
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add some lists, *not* using the UI, and check that we don't move to them.
+
+ let list1 = newBook1.addMailList(createMailingList("New Book 1 - List 1"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list1 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list3 = newBook1.addMailList(createMailingList("New Book 1 - List 3"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list0 = newBook1.addMailList(createMailingList("New Book 1 - List 0"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list2 = newBook1.addMailList(createMailingList("New Book 1 - List 2"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Close the window and open it again. The tree should be as it was before.
+
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ openDirectory(newBook2);
+
+ let list4 = newBook2.addMailList(createMailingList("New Book 2 - List 4"));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add a new list, using the UI, and check that we move to it.
+
+ let list5 = await createMailingListWithUI(newBook2, "New Book 2 - List 5");
+ checkDirectoryDisplayed(list5);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list6 = await createMailingListWithUI(newBook2, "New Book 2 - List 6");
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+ // Delete a list that isn't displayed, and check that we don't move.
+
+ newBook1.deleteDirectory(list3);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Select list5
+ let list5Row = abWindow.booksList.getRowForUID(list5.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ list5Row.querySelector("span"),
+ {},
+ abWindow
+ );
+ checkDirectoryDisplayed(list5);
+
+ // Delete the displayed list, and check that we move to the next list under
+ // the same book.
+
+ newBook2.deleteDirectory(list5);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Delete the last list, and check we move to the previous list under the same
+ // book.
+ newBook2.deleteDirectory(list6);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list4);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Delete the displayed book, and check that we move to the next book.
+
+ await promiseDirectoryRemoved(newBook2.URI);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(historyBook);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Select a list in the first book, then delete the book. Check that we
+ // move to the next book.
+
+ openDirectory(list1);
+ await promiseDirectoryRemoved(newBook1.URI);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(historyBook);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: historyBook }
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests that renaming or deleting books or lists is reflected in the UI.
+ */
+add_task(async function test_rename_and_delete() {
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+ let searchInput = abWindow.searchInput;
+ Assert.equal(booksList.rowCount, 3);
+
+ // Create a book.
+
+ EventUtils.synthesizeMouseAtCenter(booksList, {}, abWindow);
+ let newBook = await createAddressBookWithUI("New Book");
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let bookRow = booksList.getRowAtIndex(2);
+ Assert.equal(bookRow.querySelector(".bookRow-name").textContent, "New Book");
+ Assert.equal(bookRow.getAttribute("aria-label"), "New Book");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search New Book",
+ "search placeholder updated"
+ );
+
+ // Rename the book.
+
+ let menu = abDocument.getElementById("bookContext");
+ let propertiesMenuItem = abDocument.getElementById("bookContextProperties");
+
+ await rightClickOnIndex(2);
+
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.deepEqual(document.l10n.getAttributes(propertiesMenuItem), {
+ id: "about-addressbook-books-context-properties",
+ args: null,
+ });
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("name");
+ Assert.equal(nameInput.value, "New Book");
+ nameInput.value = "Old Book";
+
+ dialogDocument.querySelector("dialog").getButton("accept").click();
+ });
+ menu.activateItem(propertiesMenuItem);
+ await dialogPromise;
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ bookRow = booksList.getRowAtIndex(2);
+ Assert.equal(bookRow.querySelector(".bookRow-name").textContent, "Old Book");
+ Assert.equal(bookRow.getAttribute("aria-label"), "Old Book");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search Old Book",
+ "search placeholder updated"
+ );
+
+ // Create a list.
+
+ let newList = await createMailingListWithUI(newBook, "New List");
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.getIndexForUID(newList.UID), 3);
+ Assert.equal(booksList.selectedIndex, 3);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let listRow = booksList.getRowAtIndex(3);
+ Assert.equal(
+ listRow.compareDocumentPosition(bookRow),
+ Node.DOCUMENT_POSITION_CONTAINS | Node.DOCUMENT_POSITION_PRECEDING
+ );
+ Assert.equal(listRow.querySelector(".listRow-name").textContent, "New List");
+ Assert.equal(listRow.getAttribute("aria-label"), "New List");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search New List",
+ "search placeholder updated"
+ );
+
+ // Rename the list.
+
+ await rightClickOnIndex(3);
+
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.deepEqual(document.l10n.getAttributes(propertiesMenuItem), {
+ id: "about-addressbook-books-context-edit-list",
+ args: null,
+ });
+
+ dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("ListName");
+ Assert.equal(nameInput.value, "New List");
+ nameInput.value = "Old List";
+
+ dialogDocument.querySelector("dialog").getButton("accept").click();
+ });
+ menu.activateItem(propertiesMenuItem);
+ await dialogPromise;
+
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.getIndexForUID(newList.UID), 3);
+ Assert.equal(booksList.selectedIndex, 3);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ listRow = booksList.getRowAtIndex(3);
+ Assert.equal(listRow.querySelector(".listRow-name").textContent, "Old List");
+ Assert.equal(listRow.getAttribute("aria-label"), "Old List");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search Old List",
+ "search placeholder updated"
+ );
+
+ // Delete the list.
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ EventUtils.synthesizeKey("KEY_Delete", {}, abWindow);
+ await promptPromise;
+ await selectPromise;
+ Assert.equal(newBook.childNodes.length, 0, "list was actually deleted");
+ await new Promise(r => abWindow.setTimeout(r));
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.getIndexForUID(newList.UID), -1);
+ // Moves to parent when last list is deleted.
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ bookRow = booksList.getRowAtIndex(2);
+ Assert.ok(!bookRow.classList.contains("children"));
+ Assert.ok(!bookRow.querySelector("ul, li"));
+
+ // Delete the book.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ EventUtils.synthesizeKey("KEY_Delete", {}, abWindow);
+ await promptPromise;
+ await selectPromise;
+ Assert.equal(
+ MailServices.ab.directories.length,
+ 2,
+ "book was actually deleted"
+ );
+
+ Assert.equal(booksList.rowCount, 3);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), -1);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ // Attempt to delete the All Address Books entry.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 0;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Cannot delete the All Address Books item/,
+ "Attempting to delete All Address Books should fail."
+ );
+
+ // Attempt to delete Personal Address Book.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 1;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Refusing to delete a built-in address book/,
+ "Attempting to delete Personal Address Book should fail."
+ );
+
+ // Attempt to delete Collected Addresses.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 2;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Refusing to delete a built-in address book/,
+ "Attempting to delete Collected Addresses should fail."
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests the context menu of the list.
+ */
+add_task(async function test_context_menu() {
+ let book = createAddressBook("Ordinary Book");
+ book.addMailList(createMailingList("Ordinary List"));
+ createAddressBook("CardDAV Book", Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+
+ let menu = abWindow.document.getElementById("bookContext");
+ let propertiesMenuItem = abDocument.getElementById("bookContextProperties");
+ let synchronizeMenuItem = abDocument.getElementById("bookContextSynchronize");
+ let printMenuItem = abDocument.getElementById("bookContextPrint");
+ let deleteMenuItem = abDocument.getElementById("bookContextDelete");
+ let removeMenuItem = abDocument.getElementById("bookContextRemove");
+ let startupDefaultItem = abDocument.getElementById(
+ "bookContextStartupDefault"
+ );
+
+ Assert.equal(booksList.rowCount, 6);
+
+ // Test that the menu does not show for All Address Books.
+
+ await rightClickOnIndex(0);
+ Assert.equal(booksList.selectedIndex, 0);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let visibleItems = [...menu.children].filter(BrowserTestUtils.is_visible);
+ Assert.equal(visibleItems.length, 1);
+ Assert.equal(
+ visibleItems[0],
+ startupDefaultItem,
+ "only the startup default item should be visible"
+ );
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+
+ // Test directories that can't be deleted.
+
+ for (let index of [1, booksList.rowCount - 1]) {
+ await rightClickOnIndex(index);
+ Assert.equal(booksList.selectedIndex, index);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(deleteMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(removeMenuItem));
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+ }
+
+ // Test and delete CardDAV directory at index 4.
+
+ await rightClickOnIndex(4);
+ Assert.equal(booksList.selectedIndex, 4);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(!synchronizeMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(removeMenuItem));
+ Assert.ok(!removeMenuItem.disabled);
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(removeMenuItem);
+ await promptPromise;
+ await selectPromise;
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.selectedIndex, 4);
+ Assert.equal(menu.state, "closed");
+
+ // Test and delete list at index 3, then directory at index 2.
+
+ for (let index of [3, 2]) {
+ await new Promise(r => abWindow.setTimeout(r, 250));
+ await rightClickOnIndex(index);
+ Assert.equal(booksList.selectedIndex, index);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(!deleteMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(removeMenuItem));
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(deleteMenuItem);
+ await promptPromise;
+ await selectPromise;
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+
+ if (index == 3) {
+ Assert.equal(booksList.rowCount, 4);
+ // Moves to parent when last list is deleted.
+ Assert.equal(booksList.selectedIndex, 2);
+ } else {
+ Assert.equal(booksList.rowCount, 3);
+ Assert.equal(booksList.selectedIndex, 2);
+ }
+ Assert.equal(menu.state, "closed");
+ }
+
+ // Test that the menu does not show beyond the last book.
+
+ EventUtils.synthesizeMouseAtCenter(
+ booksList,
+ 100,
+ booksList.clientHeight - 10,
+ { type: "contextmenu" },
+ abWindow
+ );
+ Assert.equal(booksList.selectedIndex, 2);
+ await new Promise(r => abWindow.setTimeout(r, 500));
+ Assert.equal(menu.state, "closed", "menu stayed closed as expected");
+ Assert.equal(abDocument.activeElement, booksList);
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests the menu button on each item.
+ */
+add_task(async function test_context_menu_button() {
+ let book = createAddressBook("Ordinary Book");
+ book.addMailList(createMailingList("Ordinary List"));
+
+ let abWindow = await openAddressBookWindow();
+ let booksList = abWindow.booksList;
+ let menu = abWindow.document.getElementById("bookContext");
+
+ for (let row of booksList.rows) {
+ info(row.querySelector(".bookRow-name, .listRow-name").textContent);
+ let button = row.querySelector(".bookRow-menu, .listRow-menu");
+ Assert.ok(BrowserTestUtils.is_hidden(button), "menu button is hidden");
+
+ EventUtils.synthesizeMouse(row, 100, 5, { type: "mousemove" }, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(button), "menu button is visible");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(button, {}, abWindow);
+ await shownPromise;
+
+ let buttonRect = button.getBoundingClientRect();
+ let menuRect = menu.getBoundingClientRect();
+ Assert.less(
+ Math.abs(menuRect.top - buttonRect.bottom),
+ 13,
+ "menu appeared near the button vertically"
+ );
+ Assert.less(
+ Math.abs(menuRect.left - buttonRect.left),
+ 20,
+ "menu appeared near the button horizontally"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests that the collapsed state of books survives a reload of the page.
+ */
+add_task(async function test_collapse_expand() {
+ Services.xulStore.removeDocument("about:addressbook");
+
+ personalBook.addMailList(createMailingList("Personal List 1"));
+ personalBook.addMailList(createMailingList("Personal List 2"));
+
+ historyBook.addMailList(createMailingList("History List 1"));
+
+ let book1 = createAddressBook("Book 1");
+ book1.addMailList(createMailingList("Book 1 List 1"));
+ book1.addMailList(createMailingList("Book 1 List 2"));
+
+ let book2 = createAddressBook("Book 2");
+ book2.addMailList(createMailingList("Book 2 List 1"));
+ book2.addMailList(createMailingList("Book 2 List 2"));
+ book2.addMailList(createMailingList("Book 2 List 3"));
+
+ function getRowForBook(book) {
+ return abDocument.getElementById(`book-${book.UID}`);
+ }
+
+ function checkCollapsedState(book, expectedCollapsed) {
+ Assert.equal(
+ getRowForBook(book).classList.contains("collapsed"),
+ expectedCollapsed,
+ `${book.dirName} is ${expectedCollapsed ? "collapsed" : "expanded"}`
+ );
+ }
+
+ function toggleCollapsedState(book) {
+ let twisty = getRowForBook(book).querySelector(".twisty");
+ Assert.ok(
+ BrowserTestUtils.is_visible(twisty),
+ `twisty for ${book.dirName} is visible`
+ );
+ EventUtils.synthesizeMouseAtCenter(twisty, {}, abWindow);
+ }
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, false);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(book2, false);
+ checkCollapsedState(historyBook, false);
+
+ toggleCollapsedState(personalBook);
+ toggleCollapsedState(book1);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, true);
+ checkCollapsedState(book1, true);
+ checkCollapsedState(book2, false);
+ checkCollapsedState(historyBook, false);
+
+ toggleCollapsedState(book1);
+ toggleCollapsedState(book2);
+ toggleCollapsedState(historyBook);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, true);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(book2, true);
+ checkCollapsedState(historyBook, true);
+
+ toggleCollapsedState(personalBook);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book2.URI);
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, false);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(historyBook, true);
+
+ await closeAddressBookWindow();
+
+ personalBook.childNodes.forEach(list => personalBook.deleteDirectory(list));
+ historyBook.childNodes.forEach(list => historyBook.deleteDirectory(list));
+ await promiseDirectoryRemoved(book1.URI);
+ Services.xulStore.removeDocument("about:addressbook");
+});
+
+/**
+ * Tests that the chosen default directory (or lack thereof) is opened when
+ * the page opens.
+ */
+add_task(async function test_startup_directory() {
+ const URI_PREF = "mail.addr_book.view.startupURI";
+ const DEFAULT_PREF = "mail.addr_book.view.startupURIisDefault";
+
+ Services.prefs.clearUserPref(URI_PREF);
+ Services.prefs.clearUserPref(DEFAULT_PREF);
+
+ async function checkMenuItem(index, expectChecked, toggle = false) {
+ await rightClickOnIndex(index);
+
+ let menu = abWindow.document.getElementById("bookContext");
+ let item = abWindow.document.getElementById("bookContextStartupDefault");
+ Assert.equal(
+ item.hasAttribute("checked"),
+ expectChecked,
+ `directory at index ${index} is the default?`
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ if (toggle) {
+ menu.activateItem(item);
+ } else {
+ menu.hidePopup();
+ }
+ await hiddenPromise;
+ }
+
+ // With the defaults, All Address Books should open.
+ // No changes should be made to the prefs.
+
+ let abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(0, true);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+
+ // Now we'll set the default to "last-used".
+ // The last-used book should be saved.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(0, true);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ Services.prefs.setBoolPref(DEFAULT_PREF, false);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), personalBook.URI);
+
+ // The last-used book should open.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(personalBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ openDirectory(historyBook);
+ await closeAddressBookWindow();
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), historyBook.URI);
+
+ // The last-used book should open.
+ // We'll set a default directory again.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(historyBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false, true);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), historyBook.URI);
+
+ // Check that the saved default opens. Change the default.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(historyBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(2, true);
+ await checkMenuItem(1, false, true);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), personalBook.URI);
+
+ // Check that the saved default opens. Change the default to All Address Books.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(personalBook);
+ await checkMenuItem(1, true);
+ await checkMenuItem(2, false);
+ await checkMenuItem(0, false, true);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+
+ // Check that the saved default opens. Clear the default.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ await checkMenuItem(0, true, true);
+ await closeAddressBookWindow();
+ Assert.ok(!Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+});
+
+add_task(async function test_total_address_book_count() {
+ let book1 = createAddressBook("First Book");
+ let book2 = createAddressBook("Second Book");
+ book1.addMailList(createMailingList("Ordinary List"));
+
+ book1.addCard(createContact("contact1", "book 1"));
+ book1.addCard(createContact("contact2", "book 1"));
+ book1.addCard(createContact("contact3", "book 1"));
+
+ book2.addCard(createContact("contact1", "book 2"));
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+ let cardCount = abDocument.getElementById("cardCount");
+
+ await openAllAddressBooks();
+ Assert.deepEqual(abDocument.l10n.getAttributes(cardCount), {
+ id: "about-addressbook-card-count-all",
+ args: {
+ count: 5,
+ },
+ });
+
+ for (let [index, [name, count]] of [
+ ["Personal Address Book", 0],
+ ["First Book", 4],
+ ["Ordinary List", 0],
+ ["Second Book", 1],
+ ].entries()) {
+ booksList.getRowAtIndex(index + 1).click();
+ Assert.deepEqual(abDocument.l10n.getAttributes(cardCount), {
+ id: "about-addressbook-card-count",
+ args: { name, count },
+ });
+ }
+
+ // Create a contact and check that the count updates.
+ // Select second book.
+ booksList.getRowAtIndex(4).click();
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ book2.addCard(createContact("contact2", "book 2"));
+ await createdPromise;
+ Assert.deepEqual(
+ abDocument.l10n.getAttributes(cardCount),
+ {
+ id: "about-addressbook-card-count",
+ args: { name: "Second Book", count: 2 },
+ },
+ "Address Book count is updated on contact creation."
+ );
+
+ // Delete a contact an check that the count updates.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let deletedPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ let cards = abWindow.cardsPane.cardsList;
+ EventUtils.synthesizeMouseAtCenter(cards.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeKey("VK_DELETE", {}, abWindow);
+ await promptPromise;
+ await deletedPromise;
+ Assert.deepEqual(
+ abDocument.l10n.getAttributes(cardCount),
+ {
+ id: "about-addressbook-card-count",
+ args: { name: "Second Book", count: 1 },
+ },
+ "Address Book count is updated on contact deletion."
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book1.URI);
+ await promiseDirectoryRemoved(book2.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_display_card.js b/comm/mail/components/addrbook/test/browser/browser_display_card.js
new file mode 100644
index 0000000000..4d468ed646
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_display_card.js
@@ -0,0 +1,1020 @@
+/* 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 { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+var { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+var { AddrBookCard } = ChromeUtils.import(
+ "resource:///modules/AddrBookCard.jsm"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(aProtocolScheme) {},
+ getApplicationDescription(aScheme) {},
+ getProtocolHandlerInfo(aProtocolScheme) {},
+ getProtocolHandlerInfoFromOS(aProtocolScheme, aFound) {},
+ isExposedProtocol(aProtocolScheme) {},
+ loadURI(aURI, aWindowContext) {
+ this._loadedURLs.push(aURI.spec);
+ },
+ setProtocolHandlerDefaults(aHandlerInfo, aOSHandlerExists) {},
+ urlLoaded(aURL) {
+ return this._loadedURLs.includes(aURL);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+add_setup(async function () {
+ // Card 0.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard("BEGIN:VCARD\r\nEND:VCARD\r\n")
+ );
+ // Card 1.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:basic person
+ EMAIL:basic@invalid
+ END:VCARD
+ `)
+ );
+ // Card 2.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:complex person
+ EMAIL:secondary@invalid
+ EMAIL;PREF=1:primary@invalid
+ EMAIL;TYPE=WORK:tertiary@invalid
+ TEL;VALUE=URI:tel:000-0000
+ TEL;TYPE=WORK,VOICE:callto:111-1111
+ TEL;TYPE=VOICE,WORK:222-2222
+ TEL;TYPE=HOME;TYPE=VIDEO:tel:333-3333
+ ADR:;;street,suburb;city;state;zip;country
+ ANNIVERSARY:2018-06-11
+ BDAY;VALUE=DATE:--0229
+ NOTE:mary had a little lamb\\nits fleece was white as snow\\nand everywhere t
+ hat mary went\\nthe lamb was sure to go
+ ORG:thunderbird;engineering
+ ROLE:sheriff
+ TITLE:senior engineering lead
+ TZ;VALUE=TEXT:Pacific/Auckland
+ URL;TYPE=work:https://www.thunderbird.net/
+ IMPP:xmpp:cowboy@example.org
+ END:VCARD
+ `)
+ );
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let calendar = CalendarTestUtils.createCalendar();
+
+ let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+ );
+
+ registerCleanupFunction(async () => {
+ personalBook.deleteCards(personalBook.childCards);
+ MailServices.accounts.removeAccount(account, true);
+ CalendarTestUtils.removeCalendar(calendar);
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+ });
+});
+
+/**
+ * Checks basic display.
+ */
+add_task(async function testDisplay() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewPrimaryEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editButton = abDocument.getElementById("editButton");
+
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let phoneNumbersSection = abDocument.getElementById("phoneNumbers");
+ let addressesSection = abDocument.getElementById("addresses");
+ let notesSection = abDocument.getElementById("notes");
+ let websitesSection = abDocument.getElementById("websites");
+ let imppSection = abDocument.getElementById("instantMessaging");
+ let otherInfoSection = abDocument.getElementById("otherInfo");
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+
+ Assert.equal(cardsList.view.rowCount, personalBook.childCardCount);
+ Assert.ok(detailsPane.hidden);
+
+ // Card 0: an empty card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 1: an basic card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "basic person");
+ Assert.equal(viewPrimaryEmail.textContent, "basic@invalid");
+
+ // Action buttons.
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ let items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[0].querySelector("a").href,
+ `mailto:basic%20person%20%3Cbasic%40invalid%3E`
+ );
+ Assert.equal(items[0].querySelector("a").textContent, "basic@invalid");
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(items[0].querySelector("a"), {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "basic person <basic@invalid>"
+ );
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 2: an complex card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "complex person");
+ Assert.equal(viewPrimaryEmail.textContent, "primary@invalid");
+
+ // Action buttons.
+ await checkActionButtons(
+ "primary@invalid",
+ "complex person",
+ "primary@invalid secondary@invalid tertiary@invalid"
+ );
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 3);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[0].querySelector("a").href,
+ `mailto:complex%20person%20%3Csecondary%40invalid%3E`
+ );
+ Assert.equal(items[0].querySelector("a").textContent, "secondary@invalid");
+
+ Assert.equal(items[1].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[1].querySelector("a").href,
+ `mailto:complex%20person%20%3Cprimary%40invalid%3E`
+ );
+ Assert.equal(items[1].querySelector("a").textContent, "primary@invalid");
+
+ Assert.equal(
+ items[2].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(
+ items[2].querySelector("a").href,
+ `mailto:complex%20person%20%3Ctertiary%40invalid%3E`
+ );
+ Assert.equal(items[2].querySelector("a").textContent, "tertiary@invalid");
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(items[2].querySelector("a"), {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "complex person <tertiary@invalid>"
+ );
+
+ // Phone numbers section.
+ Assert.ok(BrowserTestUtils.is_visible(phoneNumbersSection));
+ items = phoneNumbersSection.querySelectorAll("li");
+ Assert.equal(items.length, 4);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value a").href, `tel:0000000`);
+
+ Assert.equal(
+ items[1].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(items[1].querySelector(".entry-value").textContent, "111-1111");
+ Assert.equal(items[1].querySelector(".entry-value a").href, `callto:1111111`);
+
+ Assert.equal(
+ items[2].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(items[2].querySelector(".entry-value").textContent, "222-2222");
+
+ Assert.equal(
+ items[3].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-home"
+ );
+ Assert.equal(items[3].querySelector(".entry-value").textContent, "333-3333");
+ Assert.equal(items[3].querySelector(".entry-value a").href, `tel:3333333`);
+
+ // Addresses section.
+ Assert.ok(BrowserTestUtils.is_visible(addressesSection));
+ items = addressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value").childNodes.length, 11);
+ Assert.deepEqual(
+ Array.from(
+ items[0].querySelector(".entry-value").childNodes,
+ n => n.textContent
+ ),
+ ["street", "", "suburb", "", "city", "", "state", "", "zip", "", "country"]
+ );
+
+ // Notes section.
+ Assert.ok(BrowserTestUtils.is_visible(notesSection));
+ Assert.equal(
+ notesSection.querySelector("div").textContent,
+ "mary had a little lamb\nits fleece was white as snow\nand everywhere that mary went\nthe lamb was sure to go"
+ );
+
+ // Websites section
+ Assert.ok(BrowserTestUtils.is_visible(websitesSection));
+ items = websitesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "https://www.thunderbird.net/"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").textContent,
+ "www.thunderbird.net"
+ );
+ items[0].children[1].querySelector("a").scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ items[0].children[1].querySelector("a"),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(
+ () => mockExternalProtocolService.urlLoaded("https://www.thunderbird.net/"),
+ "attempted to load website in a browser"
+ );
+
+ // Instant messaging section
+ Assert.ok(BrowserTestUtils.is_visible(imppSection));
+ items = imppSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "xmpp:cowboy@example.org"
+ );
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 6, "number of <li> in section should be correct");
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-birthday"
+ );
+ Assert.equal(items[0].children[1].textContent, "February 29");
+ Assert.equal(
+ items[1].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[1].children[1].textContent, "June 11, 2018");
+
+ Assert.equal(
+ items[2].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-title"
+ );
+ Assert.equal(items[2].children[1].textContent, "senior engineering lead");
+ Assert.equal(
+ items[3].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-role"
+ );
+ Assert.equal(items[3].children[1].textContent, "sheriff");
+ Assert.equal(
+ items[4].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-organization"
+ );
+ Assert.deepEqual(
+ Array.from(
+ items[4].querySelector(".entry-value").childNodes,
+ n => n.textContent
+ ),
+ ["engineering", " • ", "thunderbird"]
+ );
+ Assert.equal(
+ items[5].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-time-zone"
+ );
+ Assert.equal(items[5].children[1].firstChild.nodeValue, "Pacific/Auckland");
+ Assert.equal(
+ items[5].children[1].lastChild.getAttribute("is"),
+ "active-time"
+ );
+ Assert.equal(
+ items[5].children[1].lastChild.getAttribute("tz"),
+ "Pacific/Auckland"
+ );
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 0, again, just to prove that everything was cleared properly.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test the display of dates with various components missing.
+ */
+add_task(async function testDates() {
+ let abWindow = await openAddressBookWindow();
+ let otherInfoSection = abWindow.document.getElementById("otherInfo");
+
+ // Year only.
+
+ let yearCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic3@invalid
+ ANNIVERSARY:2005
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ let items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "2005");
+
+ // Year and month.
+
+ let yearMonthCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic4@invalid
+ ANNIVERSARY:2006-06
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "June 2006");
+
+ // Month only.
+ let monthCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic5@invalid
+ ANNIVERSARY:--12
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "December");
+
+ // Month and day.
+ let monthDayCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic6@invalid
+ ANNIVERSARY;VALUE=DATE:--0704
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "July 4");
+
+ // Day only.
+ let dayCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic7@invalid
+ ANNIVERSARY:---30
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "30");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([
+ yearCard,
+ yearMonthCard,
+ monthCard,
+ monthDayCard,
+ dayCard,
+ ]);
+});
+
+/**
+ * Only an organisation name.
+ */
+add_task(async function testOrganisationNameOnly() {
+ let card = await addAndDisplayCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ ORG:organisation
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await getAddressBookWindow();
+ let viewContactName = abWindow.document.getElementById("viewContactName");
+ Assert.equal(viewContactName.textContent, "organisation");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Tests that custom properties (Custom1 etc.) are displayed.
+ */
+add_task(async function testCustomProperties() {
+ let card = new AddrBookCard();
+ card._properties = new Map([
+ ["PopularityIndex", 0],
+ ["Custom2", "custom two"],
+ ["Custom4", "custom four"],
+ [
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ FN:custom person
+ X-CUSTOM3:x-custom three
+ X-CUSTOM4:x-custom four
+ END:VCARD
+ `,
+ ],
+ ]);
+ card = await addAndDisplayCard(card);
+
+ let abWindow = await getAddressBookWindow();
+ let otherInfoSection = abWindow.document.getElementById("otherInfo");
+
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+
+ let items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 3);
+ // Custom 1 has no value, should not display.
+ // Custom 2 has an old property value, should display that.
+
+ await TestUtils.waitForCondition(() => {
+ return items[0].children[0].textContent;
+ }, "text not created in time");
+
+ Assert.equal(items[0].children[0].textContent, "Custom 2");
+ Assert.equal(items[0].children[1].textContent, "custom two");
+ // Custom 3 has a vCard property value, should display that.
+ Assert.equal(items[1].children[0].textContent, "Custom 3");
+ Assert.equal(items[1].children[1].textContent, "x-custom three");
+ // Custom 4 has both types of value, the vCard value should be displayed.
+ Assert.equal(items[2].children[0].textContent, "Custom 4");
+ Assert.equal(items[2].children[1].textContent, "x-custom four");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Checks that the edit button is hidden for read-only contacts.
+ */
+add_task(async function testReadOnlyActions() {
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ readOnlyBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:read-only person
+ END:VCARD
+ `)
+ );
+ readOnlyList.addCard(
+ readOnlyBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:read-only person with email
+ EMAIL:read.only@invalid
+ END:VCARD
+ `)
+ )
+ );
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let contactView = abDocument.getElementById("viewContact");
+
+ let actions = abDocument.getElementById("detailsActions");
+ let editButton = abDocument.getElementById("editButton");
+ let editForm = abDocument.getElementById("editContactForm");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = cardsList.selectedIndex;
+ },
+ };
+
+ // Check contacts with the book displayed.
+
+ openDirectory(readOnlyBook);
+ Assert.equal(cardsList.view.rowCount, 3);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // Without email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(actions),
+ "actions section should be hidden"
+ );
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Double clicking on the item will select but not edit it.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { clickCount: 1 },
+ abWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { clickCount: 2 },
+ abWindow
+ );
+ // Wait one loop to see if edit form was opened.
+ await TestUtils.waitForTick();
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(actions),
+ "actions section should be hidden"
+ );
+ Assert.equal(
+ cardsList.table.body,
+ abDocument.activeElement,
+ "Cards list should be the active element"
+ );
+
+ selectHandler.reset();
+ cardsList.addEventListener("select", selectHandler, { once: true });
+ // Same with Enter on the second item.
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, abWindow);
+ await TestUtils.waitForCondition(
+ () => selectHandler.seenEvent,
+ `'select' event should get fired`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(actions),
+ "actions section should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editButton),
+ "editButton should be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ await TestUtils.waitForTick();
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(actions),
+ "actions section should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+
+ // Check contacts with the list displayed.
+
+ openDirectory(readOnlyList);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Check contacts with All Address Books displayed.
+
+ openAllAddressBooks();
+ Assert.equal(cardsList.view.rowCount, 6);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // Basic person from Personal Address Books.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton), "edit button is shown");
+
+ // Without email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(4), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_hidden(actions), "actions section is hidden");
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(5), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Basic person again, to prove the buttons aren't hidden forever.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton), "edit button is shown");
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Tests that we correctly fix Google's bad escaping of colons in values, and
+ * other characters in URI values.
+ */
+add_task(async function testGoogleEscaping() {
+ let googleBook = createAddressBook("Google Book");
+ googleBook.wrappedJSObject._isGoogleCardDAV = true;
+ googleBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ N:test;en\\\\c\\:oding;;;
+ FN:en\\\\c\\:oding test
+ TITLE:title\\:title\\;title\\,title\\\\title\\\\\\:title\\\\\\;title\\\\\\,title\\\\\\\\
+ TEL:tel\\:0123\\\\4567
+ NOTE:notes\\:\\nnotes\\;\\nnotes\\,\\nnotes\\\\
+ URL:https\\://host/url\\:url\\;url\\,url\\\\url
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewPrimaryEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editButton = abDocument.getElementById("editButton");
+
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let phoneNumbersSection = abDocument.getElementById("phoneNumbers");
+ let addressesSection = abDocument.getElementById("addresses");
+ let notesSection = abDocument.getElementById("notes");
+ let websitesSection = abDocument.getElementById("websites");
+ let imppSection = abDocument.getElementById("instantMessaging");
+ let otherInfoSection = abDocument.getElementById("otherInfo");
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+
+ openDirectory(googleBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "en\\c:oding test");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+
+ // Phone numbers section.
+ Assert.ok(BrowserTestUtils.is_visible(phoneNumbersSection));
+ let items = phoneNumbersSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value").textContent, "01234567");
+
+ // Addresses section.
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+
+ // Notes section.
+ Assert.ok(BrowserTestUtils.is_visible(notesSection));
+ Assert.equal(
+ notesSection.querySelector("div").textContent,
+ "notes:\nnotes;\nnotes,\nnotes\\"
+ );
+
+ // Websites section
+ Assert.ok(BrowserTestUtils.is_visible(websitesSection));
+ items = websitesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "https://host/url:url;url,url/url"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").textContent,
+ "host/url:url;url,url/url"
+ );
+ items[0].children[1].querySelector("a").scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ items[0].children[1].querySelector("a"),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ mockExternalProtocolService.urlLoaded("https://host/url:url;url,url/url"),
+ "attempted to load website in a browser"
+ );
+
+ // Instant messaging section.
+ Assert.ok(BrowserTestUtils.is_hidden(imppSection));
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-title"
+ );
+ Assert.equal(
+ items[0].children[1].textContent,
+ "title:title;title,title\\title\\:title\\;title\\,title\\\\"
+ );
+
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(googleBook.URI);
+});
+
+async function addAndDisplayCard(card) {
+ if (typeof card == "string") {
+ card = VCardUtils.vCardToAbCard(card);
+ }
+ card = personalBook.addCard(card);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let index = cardsList.view.getIndexForUID(card.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+ return card;
+}
+
+async function checkActionButtons(
+ primaryEmail,
+ displayName,
+ searchString = primaryEmail
+) {
+ let tabmail = document.getElementById("tabmail");
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let writeButton = abDocument.getElementById("detailsWriteButton");
+ let eventButton = abDocument.getElementById("detailsEventButton");
+ let searchButton = abDocument.getElementById("detailsSearchButton");
+ let newListButton = abDocument.getElementById("detailsNewListButton");
+
+ if (primaryEmail) {
+ // Write.
+ Assert.ok(
+ BrowserTestUtils.is_visible(writeButton),
+ "write button is visible"
+ );
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(writeButton, {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ `${displayName} <${primaryEmail}>`
+ );
+
+ // Search. Do this before the event test to stop a strange macOS failure.
+ Assert.ok(
+ BrowserTestUtils.is_visible(searchButton),
+ "search button is visible"
+ );
+
+ let searchTabPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ EventUtils.synthesizeMouseAtCenter(searchButton, {}, abWindow);
+ let {
+ detail: { tabInfo: searchTab },
+ } = await searchTabPromise;
+
+ let searchBox = tabmail.selectedTab.panel.querySelector(".searchBox");
+ Assert.equal(searchBox.value, searchString);
+
+ searchTabPromise = BrowserTestUtils.waitForEvent(window, "TabClose");
+ tabmail.closeTab(searchTab);
+ await searchTabPromise;
+
+ // Event.
+ Assert.ok(
+ BrowserTestUtils.is_visible(eventButton),
+ "event button is visible"
+ );
+
+ let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(eventButton, {}, abWindow);
+ let eventWindow = await eventWindowPromise;
+
+ let iframe = eventWindow.document.getElementById(
+ "calendar-item-panel-iframe"
+ );
+ let tabPanels = iframe.contentDocument.getElementById(
+ "event-grid-tabpanels"
+ );
+ let attendeesTabPanel = iframe.contentDocument.getElementById(
+ "event-grid-tabpanel-attendees"
+ );
+ Assert.equal(
+ tabPanels.selectedPanel,
+ attendeesTabPanel,
+ "attendees are displayed"
+ );
+ let attendeeNames = attendeesTabPanel.querySelectorAll(
+ ".attendee-list .attendee-name"
+ );
+ Assert.deepEqual(
+ Array.from(attendeeNames, a => a.textContent),
+ [`${displayName} <${primaryEmail}>`],
+ "attendees are correct"
+ );
+
+ eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow);
+ BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+ await eventWindowPromise;
+ Assert.report(false, undefined, undefined, "Item dialog closed");
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(writeButton),
+ "write button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(eventButton),
+ "event button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(searchButton),
+ "search button is hidden"
+ );
+ }
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(newListButton),
+ "new list button is hidden"
+ );
+}
diff --git a/comm/mail/components/addrbook/test/browser/browser_display_multiple.js b/comm/mail/components/addrbook/test/browser/browser_display_multiple.js
new file mode 100644
index 0000000000..02642f4408
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_display_multiple.js
@@ -0,0 +1,468 @@
+/* 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 { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+
+add_setup(async function () {
+ let card1 = personalBook.addCard(createContact("victor", "test"));
+ personalBook.addCard(createContact("romeo", "test", undefined, ""));
+ let card3 = personalBook.addCard(createContact("oscar", "test"));
+ personalBook.addCard(createContact("mike", "test", undefined, ""));
+ const card5 = personalBook.addCard(createContact("xray", "test"));
+ const card6 = personalBook.addCard(createContact("yankee", "test"));
+ const card7 = personalBook.addCard(createContact("zulu", "test"));
+ let list1 = personalBook.addMailList(createMailingList("list 1"));
+ list1.addCard(card1);
+ list1.addCard(card3);
+ list1.addCard(card5);
+ list1.addCard(card6);
+ list1.addCard(card7);
+ let list2 = personalBook.addMailList(createMailingList("list 2"));
+ list2.addCard(card3);
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let calendar = CalendarTestUtils.createCalendar();
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+add_task(async function testSelectMultiple() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ // In order; list 1, list 2, mike, oscar, romeo, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 9);
+ Assert.ok(detailsPane.hidden);
+
+ // Select list 1 and check the list display.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await checkHeader({ listName: "list 1" });
+ await checkActionButtons(
+ ["list 1 <list 1>"],
+ [],
+ [
+ "victor test <victor.test@invalid>",
+ "oscar test <oscar.test@invalid>",
+ "xray test <xray.test@invalid>",
+ "yankee test <yankee.test@invalid>",
+ "zulu test <zulu.test@invalid>",
+ ]
+ );
+ await checkList([
+ "oscar test",
+ "victor test",
+ "xray test",
+ "yankee test",
+ "zulu test",
+ ]);
+
+ // list 1 and list 2.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "lists" });
+ await checkActionButtons(["list 1 <list 1>", "list 2 <list 2>"]);
+ await checkList(["list 1", "list 2"]);
+
+ // list 1 and mike (no address).
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkActionButtons(["list 1 <list 1>"]);
+ await checkList(["list 1", "mike test"]);
+
+ // list 1 and oscar.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkActionButtons(
+ ["list 1 <list 1>"],
+ ["oscar test <oscar.test@invalid>"]
+ );
+ await checkList(["list 1", "oscar test"]);
+
+ // mike (no address) and oscar.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkActionButtons([], ["oscar test <oscar.test@invalid>"]);
+ await checkList(["mike test", "oscar test"]);
+
+ // mike (no address), oscar, romeo (no address) and victor.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 4, selectionType: "contacts" });
+ await checkActionButtons(
+ [],
+ ["oscar test <oscar.test@invalid>", "victor test <victor.test@invalid>"]
+ );
+ await checkList(["mike test", "oscar test", "romeo test", "victor test"]);
+
+ // mike and romeo (no addresses).
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(4),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkActionButtons();
+ await checkList(["mike test", "romeo test"]);
+
+ // Everything.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 6, selectionType: "mixed" });
+ await checkActionButtons(
+ ["list 1 <list 1>", "list 2 <list 2>"],
+ ["oscar test <oscar.test@invalid>", "victor test <victor.test@invalid>"]
+ );
+ await checkList([
+ "list 1",
+ "list 2",
+ "mike test",
+ "oscar test",
+ "romeo test",
+ "victor test",
+ ]);
+
+ await closeAddressBookWindow();
+});
+
+add_task(async function testDeleteMultiple() {
+ const abWindow = await openAddressBookWindow();
+ const booksList = abWindow.booksList;
+
+ // Open mailing list list1.
+ booksList.getRowAtIndex(2).click();
+
+ const abDocument = abWindow.document;
+ const cardsList = abDocument.getElementById("cards");
+ const detailsPane = abDocument.getElementById("detailsPane");
+
+ // In order; oscar, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 5);
+ Assert.ok(detailsPane.hidden);
+
+ // Select victor and yankee.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkList(["victor test", "yankee test"]);
+
+ // Delete victor and yankee.
+ let deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-list-member-removed");
+ Assert.equal(cardsList.view.rowCount, 3);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing two mailing list members."
+ );
+
+ // Select all contacts.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 3, selectionType: "contacts" });
+ await checkList(["oscar test", "xray test", "zulu test"]);
+
+ // Delete all contacts.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-list-member-removed");
+ Assert.equal(cardsList.view.rowCount, 0);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing all mailing list members."
+ );
+
+ // Open address book personalBook.
+ booksList.getRowAtIndex(1).click();
+
+ // In order; list 1, list 2, mike, oscar, romeo, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 9);
+ Assert.ok(detailsPane.hidden);
+
+ // Select list 2 and victor.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkList(["list 2", "victor test"]);
+
+ // Delete list 2 and victor.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-contact-deleted");
+ Assert.equal(cardsList.view.rowCount, 7);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after deleting one list and one contact."
+ );
+
+ // Select all contacts.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(6),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 7, selectionType: "mixed" });
+ await checkList([
+ "list 1",
+ "mike test",
+ "oscar test",
+ "romeo test",
+ "xray test",
+ "yankee test",
+ "zulu test",
+ ]);
+
+ // Delete all contacts.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-contact-deleted");
+ Assert.equal(cardsList.view.rowCount, 0);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing all contacts."
+ );
+ await closeAddressBookWindow();
+});
+
+function checkHeader({ listName, selectionCount, selectionType } = {}) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let contactPhoto = abDocument.getElementById("viewContactPhoto");
+ let contactName = abDocument.getElementById("viewContactName");
+ let listHeader = abDocument.getElementById("viewListName");
+ let selectionHeader = abDocument.getElementById("viewSelectionCount");
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(contactPhoto),
+ "contact photo should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(contactName),
+ "contact name should be hidden"
+ );
+ if (listName) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(listHeader),
+ "list header should be visible"
+ );
+ Assert.equal(
+ listHeader.textContent,
+ listName,
+ "list header text is correct"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(selectionHeader),
+ "selection header should be hidden"
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(listHeader),
+ "list header should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(selectionHeader),
+ "selection header should be visible"
+ );
+ Assert.deepEqual(abDocument.l10n.getAttributes(selectionHeader), {
+ id: `about-addressbook-selection-${selectionType}-header2`,
+ args: {
+ count: selectionCount,
+ },
+ });
+ }
+}
+
+async function checkActionButtons(
+ listAddresses = [],
+ cardAddresses = [],
+ eventAddresses = cardAddresses
+) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let writeButton = abDocument.getElementById("detailsWriteButton");
+ let eventButton = abDocument.getElementById("detailsEventButton");
+ let searchButton = abDocument.getElementById("detailsSearchButton");
+ let newListButton = abDocument.getElementById("detailsNewListButton");
+
+ if (cardAddresses.length || listAddresses.length) {
+ // Write.
+ Assert.ok(
+ BrowserTestUtils.is_visible(writeButton),
+ "write button is visible"
+ );
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(writeButton, {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ ...listAddresses,
+ ...cardAddresses
+ );
+ }
+
+ if (eventAddresses.length) {
+ // Event.
+ Assert.ok(
+ BrowserTestUtils.is_visible(eventButton),
+ "event button is visible"
+ );
+
+ let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(eventButton, {}, abWindow);
+ let eventWindow = await eventWindowPromise;
+
+ let iframe = eventWindow.document.getElementById(
+ "calendar-item-panel-iframe"
+ );
+ let tabPanels = iframe.contentDocument.getElementById(
+ "event-grid-tabpanels"
+ );
+ let attendeesTabPanel = iframe.contentDocument.getElementById(
+ "event-grid-tabpanel-attendees"
+ );
+ Assert.equal(
+ tabPanels.selectedPanel,
+ attendeesTabPanel,
+ "attendees are displayed"
+ );
+ let attendeeNames = attendeesTabPanel.querySelectorAll(
+ ".attendee-list .attendee-name"
+ );
+ Assert.deepEqual(
+ Array.from(attendeeNames, a => a.textContent),
+ eventAddresses,
+ "attendees are correct"
+ );
+
+ eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow);
+ BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+ await eventWindowPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ Assert.report(false, undefined, undefined, "Item dialog closed");
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(eventButton),
+ "event button is hidden"
+ );
+ }
+
+ if (cardAddresses.length) {
+ // New List.
+ Assert.ok(
+ BrowserTestUtils.is_visible(newListButton),
+ "new list button is visible"
+ );
+ let listWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(newListButton, {}, abWindow);
+ let listWindow = await listWindowPromise;
+ let memberNames = listWindow.document.querySelectorAll(
+ ".textbox-addressingWidget"
+ );
+ Assert.deepEqual(
+ Array.from(memberNames, aw => aw.value),
+ [...cardAddresses, ""],
+ "list members are correct"
+ );
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, listWindow);
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(newListButton),
+ "new list button is hidden"
+ );
+ }
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(searchButton),
+ "search button is hidden"
+ );
+}
+
+function checkList(names) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+ let otherSections = abDocument.querySelectorAll(
+ "#detailsBody > section:not(#detailsActions, #selectedCards)"
+ );
+
+ Assert.ok(BrowserTestUtils.is_visible(selectedCardsSection));
+ for (let section of otherSections) {
+ Assert.ok(BrowserTestUtils.is_hidden(section), `${section.id} is hidden`);
+ }
+
+ Assert.deepEqual(
+ Array.from(
+ selectedCardsSection.querySelectorAll("li .name"),
+ li => li.textContent
+ ),
+ names
+ );
+}
diff --git a/comm/mail/components/addrbook/test/browser/browser_drag_drop.js b/comm/mail/components/addrbook/test/browser/browser_drag_drop.js
new file mode 100644
index 0000000000..4f3c23aa5b
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_drag_drop.js
@@ -0,0 +1,417 @@
+/* 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 dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+function doDrag(sourceIndex, destIndex, modifiers, expectedEffect) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.document.getElementById("cards");
+
+ let destElement = abWindow.document.body;
+ if (destIndex !== null) {
+ destElement = booksList.getRowAtIndex(destIndex);
+ }
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList.getRowAtIndex(sourceIndex),
+ destElement,
+ null,
+ null,
+ abWindow,
+ abWindow,
+ modifiers
+ );
+
+ Assert.equal(dataTransfer.effectAllowed, "all");
+ Assert.equal(dataTransfer.dropEffect, expectedEffect);
+
+ return [result, dataTransfer];
+}
+
+function doDragToBooksList(sourceIndex, destIndex, modifiers, expectedEffect) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ let [result, dataTransfer] = doDrag(
+ sourceIndex,
+ destIndex,
+ modifiers,
+ expectedEffect
+ );
+
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ booksList.getRowAtIndex(destIndex),
+ abWindow,
+ modifiers
+ );
+
+ dragService.endDragSession(true);
+}
+
+async function doDragToComposeWindow(sourceIndices, expectedPills) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "load");
+ let composeDocument = composeWindow.document;
+ let toAddrInput = composeDocument.getElementById("toAddrInput");
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ cardsList.selectedIndices = sourceIndices;
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList.getRowAtIndex(sourceIndices[0]),
+ toAddrInput,
+ null,
+ null,
+ abWindow,
+ composeWindow
+ );
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ toAddrInput,
+ composeWindow
+ );
+
+ dragService.endDragSession(true);
+
+ let pills = toAddrRow.querySelectorAll("mail-address-pill");
+ Assert.equal(pills.length, expectedPills.length);
+ for (let i = 0; i < expectedPills.length; i++) {
+ Assert.equal(pills[i].label, expectedPills[i]);
+ }
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ composeWindow.goDoCommand("cmd_close");
+ await promptPromise;
+}
+
+function checkCardsInDirectory(directory, expectedCards = [], copiedCard) {
+ let actualCards = directory.childCards.slice();
+
+ for (let card of expectedCards) {
+ let index = actualCards.findIndex(c => c.UID == card.UID);
+ Assert.greaterOrEqual(index, 0);
+ actualCards.splice(index, 1);
+ }
+
+ if (copiedCard) {
+ Assert.equal(actualCards.length, 1);
+ Assert.equal(actualCards[0].firstName, copiedCard.firstName);
+ Assert.equal(actualCards[0].lastName, copiedCard.lastName);
+ Assert.equal(actualCards[0].primaryEmail, copiedCard.primaryEmail);
+ Assert.notEqual(actualCards[0].UID, copiedCard.UID);
+ } else {
+ Assert.equal(actualCards.length, 0);
+ }
+}
+
+add_task(async function test_drag() {
+ let sourceBook = createAddressBook("Source Book");
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+
+ // Drag just contact1.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ let [, dataTransfer] = doDrag(0, null, {}, "none");
+
+ let transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 1);
+ Assert.ok(transferCards[0].equals(contact1));
+
+ let transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(transferUnicode, "contact 1 <contact.1@invalid>");
+
+ let transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact1.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ // Drag contact2 without selecting it.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ [, dataTransfer] = doDrag(1, null, {}, "none");
+
+ transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 1);
+ Assert.ok(transferCards[0].equals(contact2));
+
+ transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(transferUnicode, "contact 2 <contact.2@invalid>");
+
+ transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact2.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ // Drag all contacts.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { shiftKey: true },
+ abWindow
+ );
+ [, dataTransfer] = doDrag(0, null, {}, "none");
+
+ transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 3);
+ Assert.ok(transferCards[0].equals(contact1));
+ Assert.ok(transferCards[1].equals(contact2));
+ Assert.ok(transferCards[2].equals(contact3));
+
+ transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(
+ transferUnicode,
+ "contact 1 <contact.1@invalid>,contact 2 <contact.2@invalid>,contact 3 <contact.3@invalid>"
+ );
+
+ transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact1.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(sourceBook.URI);
+});
+
+add_task(async function test_drop_on_books_list() {
+ let sourceBook = createAddressBook("Source Book");
+ let sourceList = sourceBook.addMailList(createMailingList("Source List"));
+ let destBook = createAddressBook("Destination Book");
+ let destList = destBook.addMailList(createMailingList("Destination List"));
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+
+ let abWindow = await openAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.document.getElementById("cards");
+
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList);
+ checkCardsInDirectory(destBook, [destList]);
+ checkCardsInDirectory(destList);
+
+ Assert.equal(booksList.rowCount, 7);
+ openDirectory(sourceBook);
+
+ // Check drag effect set correctly for dragging a card.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ doDrag(0, 0, {}, "none"); // All Address Books
+ doDrag(0, 0, { ctrlKey: true }, "none");
+
+ doDrag(0, 1, {}, "move"); // Personal Address Book
+ doDrag(0, 1, { ctrlKey: true }, "copy");
+
+ doDrag(0, 2, {}, "move"); // Destination Book
+ doDrag(0, 2, { ctrlKey: true }, "copy");
+
+ doDrag(0, 3, {}, "none"); // Destination List
+ doDrag(0, 3, { ctrlKey: true }, "none");
+
+ doDrag(0, 4, {}, "none"); // Source Book
+ doDrag(0, 4, { ctrlKey: true }, "none");
+
+ doDrag(0, 5, {}, "link"); // Source List
+ doDrag(0, 5, { ctrlKey: true }, "link");
+
+ doDrag(0, 6, {}, "move"); // Collected Addresses
+ doDrag(0, 6, { ctrlKey: true }, "copy");
+
+ dragService.endDragSession(true);
+
+ // Check drag effect set correctly for dragging a list.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(3), {}, abWindow);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ doDrag(3, 0, {}, "none"); // All Address Books
+ doDrag(3, 0, { ctrlKey: true }, "none");
+
+ doDrag(3, 1, {}, "none"); // Personal Address Book
+ doDrag(3, 1, { ctrlKey: true }, "none");
+
+ doDrag(3, 2, {}, "none"); // Destination Book
+ doDrag(3, 2, { ctrlKey: true }, "none");
+
+ doDrag(3, 3, {}, "none"); // Destination List
+ doDrag(3, 3, { ctrlKey: true }, "none");
+
+ doDrag(3, 4, {}, "none"); // Source Book
+ doDrag(3, 4, { ctrlKey: true }, "none");
+
+ doDrag(3, 5, {}, "none"); // Source List
+ doDrag(3, 5, { ctrlKey: true }, "none");
+
+ doDrag(3, 6, {}, "none"); // Collected Addresses
+ doDrag(3, 6, { ctrlKey: true }, "none");
+
+ dragService.endDragSession(true);
+
+ // Drag contact1 into sourceList.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ doDragToBooksList(0, 5, {}, "link");
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList, [contact1]);
+
+ // Drag contact1 into destList. Nothing should happen.
+
+ doDragToBooksList(0, 3, {}, "none");
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(destBook, [destList]);
+ checkCardsInDirectory(destList);
+
+ // Drag contact1 into destBook. It should be moved into destBook.
+
+ doDragToBooksList(0, 2, {}, "move");
+ checkCardsInDirectory(sourceBook, [contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList);
+ checkCardsInDirectory(destBook, [contact1, destList]);
+
+ // Drag contact2 into destBook with Ctrl pressed.
+ // It should be copied into destBook.
+
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ doDragToBooksList(0, 2, { ctrlKey: true }, "copy");
+ checkCardsInDirectory(sourceBook, [contact2, contact3, sourceList]);
+ checkCardsInDirectory(destBook, [contact1, destList], contact2);
+ checkCardsInDirectory(destList);
+
+ // Delete the cards from destBook as it's confusing.
+
+ destBook.deleteCards(destBook.childCards.filter(c => !c.isMailList));
+ checkCardsInDirectory(destBook, [destList]);
+
+ // Drag contact2 and contact3 to destBook.
+
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { shiftKey: true },
+ abWindow
+ );
+
+ doDragToBooksList(0, 2, {}, "move");
+ checkCardsInDirectory(sourceBook, [sourceList]);
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ // Drag contact2 to the book it's already in. Nothing should happen.
+ // This test doesn't actually catch the bug it was written for, but maybe
+ // one day it will catch something.
+
+ openDirectory(destBook);
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ doDragToBooksList(0, 2, {}, "none");
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ // Drag destList to the book it's already in. Nothing should happen.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ doDragToBooksList(2, 2, {}, "none");
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(sourceBook.URI);
+ await promiseDirectoryRemoved(destBook.URI);
+});
+
+add_task(async function test_drop_on_compose() {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ let sourceBook = createAddressBook("Source Book");
+ let sourceList = sourceBook.addMailList(createMailingList("Source List"));
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+ sourceList.addCard(contact1);
+ sourceList.addCard(contact2);
+ sourceList.addCard(contact3);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ Assert.equal(cardsList.view.rowCount, 4);
+
+ // One contact.
+
+ await doDragToComposeWindow([0], ["contact 1 <contact.1@invalid>"]);
+
+ // Multiple contacts.
+
+ await doDragToComposeWindow(
+ [0, 1, 2],
+ [
+ "contact 1 <contact.1@invalid>",
+ "contact 2 <contact.2@invalid>",
+ "contact 3 <contact.3@invalid>",
+ ]
+ );
+
+ // A mailing list.
+
+ await doDragToComposeWindow([3], [`Source List <"Source List">`]);
+
+ // A mailing list and a contact.
+
+ await doDragToComposeWindow(
+ [3, 2],
+ ["contact 3 <contact.3@invalid>", `Source List <"Source List">`]
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(sourceBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_async.js b/comm/mail/components/addrbook/test/browser/browser_edit_async.js
new file mode 100644
index 0000000000..76588aee76
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_async.js
@@ -0,0 +1,363 @@
+/* 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 { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+let book;
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+add_setup(async function () {
+ CardDAVServer.open("alice", "alice");
+
+ book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "alice");
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "alice", "alice", "", "");
+ Services.logins.addLogin(loginInfo);
+});
+
+registerCleanupFunction(async function () {
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.close();
+ CardDAVServer.reset();
+ CardDAVServer.modifyCardOnPut = false;
+});
+
+/**
+ * Test the UI as we create/modify/delete a card and wait for responses from
+ * the server.
+ */
+add_task(async function testCreateCard() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let bookRow = abWindow.booksList.getRowForUID(book.UID);
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+
+ openDirectory(book);
+
+ // First, create a new contact.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "new contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise1 = TestUtils.topicObserved("addrbook-contact-created");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise1;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ let promise2 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay.resolve();
+ await promise2;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Edit the contact.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "edited contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise3 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise3;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ let promise4 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay.resolve();
+ await promise4;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Delete the contact.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise5 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promise5;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, searchInput);
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ CardDAVServer.responseDelay.resolve();
+ await TestUtils.waitForCondition(
+ () => !bookRow.classList.contains("requesting")
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test the UI as we create a card and wait for responses from the server.
+ * In this test the server will assign the card a new UID, which means the
+ * client code has to do things differently, but the UI should behave as it
+ * did in the previous test.
+ */
+add_task(async function testCreateCardWithUIDChange() {
+ CardDAVServer.modifyCardOnPut = true;
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let bookRow = abWindow.booksList.getRowForUID(book.UID);
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+
+ openDirectory(book);
+
+ // First, create a new contact.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "new contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise1 = TestUtils.topicObserved("addrbook-contact-created");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise1;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ let initialCard = abWindow.detailsPane.currentCard;
+ Assert.equal(initialCard.getProperty("_href", "RIGHT"), "RIGHT");
+
+ // Now allow the server to respond and check the UI state again.
+ let promise2 = TestUtils.topicObserved("addrbook-contact-created");
+ let promise3 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay.resolve();
+ let [changedCard] = await promise2;
+ let [deletedCard] = await promise3;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.equal(changedCard.UID, [...initialCard.UID].reverse().join(""));
+ Assert.equal(
+ changedCard.getProperty("_originalUID", "WRONG"),
+ initialCard.UID
+ );
+ Assert.equal(deletedCard.UID, initialCard.UID);
+
+ let displayedCard = abWindow.detailsPane.currentCard;
+ Assert.equal(displayedCard.directoryUID, book.UID);
+ Assert.notEqual(displayedCard.getProperty("_href", "WRONG"), "WRONG");
+ Assert.equal(displayedCard.UID, [...initialCard.UID].reverse().join(""));
+
+ // Delete the contact. This would fail if the UI hadn't been updated.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise4 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promise4;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, searchInput);
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ CardDAVServer.responseDelay.resolve();
+ await TestUtils.waitForCondition(
+ () => !bookRow.classList.contains("requesting")
+ );
+
+ await closeAddressBookWindow();
+ CardDAVServer.modifyCardOnPut = false;
+});
+
+/**
+ * Test that a modification to the card being edited causes a prompt to appear
+ * when saving the card.
+ */
+add_task(async function testModificationUpdatesUI() {
+ let card = personalBook.addCard(createContact("a", "person"));
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let contactName = abDocument.getElementById("viewContactName");
+ let editButton = abDocument.getElementById("editButton");
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ openDirectory(personalBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+
+ // Display a card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ let items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(items[0].querySelector("a").textContent, "a.person@invalid");
+
+ // Modify the card and check the display is updated.
+
+ let updatePromise = BrowserTestUtils.waitForMutationCondition(
+ detailsPane,
+ { childList: true, subtree: true },
+ () => true
+ );
+ card.vCardProperties.addValue("email", "person.a@lastfirst.invalid");
+ personalBook.modifyCard(card);
+
+ await updatePromise;
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 2);
+ Assert.equal(items[0].querySelector("a").textContent, "a.person@invalid");
+ Assert.equal(
+ items[1].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+
+ // Enter edit mode. Clear one of the email addresses.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ Assert.equal(abWindow.detailsPane.vCardEdit.displayName.value, "a person");
+ abDocument.querySelector(`#vcard-email tr input[type="email"]`).value = "";
+
+ // Modify the card. Nothing should happen at this point.
+
+ card.displayName = "a different person";
+ personalBook.modifyCard(card);
+
+ // Click to save.
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ [card] = personalBook.childCards;
+ Assert.equal(
+ card.displayName,
+ "a person",
+ "programmatic changes were overwritten"
+ );
+ Assert.deepEqual(
+ card.emailAddresses,
+ ["person.a@lastfirst.invalid"],
+ "UI changes were saved"
+ );
+
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+
+ // Enter edit mode again. Change the display name.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ abWindow.detailsPane.vCardEdit.displayName.value = "a changed person";
+
+ // Modify the card. Nothing should happen at this point.
+
+ card.displayName = "a different person";
+ card.vCardProperties.addValue("email", "a.person@invalid");
+ personalBook.modifyCard(card);
+
+ // Click to cancel. The modified card should be shown.
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ Assert.equal(contactName.textContent, "a different person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 2);
+ Assert.equal(
+ items[0].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+ Assert.equal(items[1].querySelector("a").textContent, "a.person@invalid");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_card.js b/comm/mail/components/addrbook/test/browser/browser_edit_card.js
new file mode 100644
index 0000000000..27cabfa4d4
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_card.js
@@ -0,0 +1,3517 @@
+/* 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 { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+var { AddrBookCard } = ChromeUtils.import(
+ "resource:///modules/AddrBookCard.jsm"
+);
+
+requestLongerTimeout(2);
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "Waiting on entering editing mode"
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ abDocument.getElementById("detailsPaneBackdrop")
+ ),
+ "backdrop should be visible"
+ );
+ checkToolbarState(false);
+}
+
+/**
+ * Wait until we are no longer in editing mode.
+ *
+ * @param {Element} expectedFocus - The element that is expected to have focus
+ * after leaving editing.
+ */
+async function notInEditingMode(expectedFocus) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ abDocument.getElementById("detailsPaneBackdrop")
+ ),
+ "backdrop should be hidden"
+ );
+ checkToolbarState(true);
+ Assert.equal(
+ abDocument.activeElement,
+ expectedFocus,
+ `Focus should be on #${expectedFocus.id}`
+ );
+}
+
+function getInput(entryName, addIfNeeded = false) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ switch (entryName) {
+ case "DisplayName":
+ return abDocument.querySelector("vcard-fn #vCardDisplayName");
+ case "PreferDisplayName":
+ return abDocument.querySelector("vcard-fn #vCardPreferDisplayName");
+ case "NickName":
+ return abDocument.querySelector("vcard-nickname #vCardNickName");
+ case "Prefix":
+ let prefixInput = abDocument.querySelector("vcard-n #vcard-n-prefix");
+ if (addIfNeeded && BrowserTestUtils.is_hidden(prefixInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector("vcard-n #n-list-component-prefix button"),
+ {},
+ abWindow
+ );
+ }
+ return prefixInput;
+ case "FirstName":
+ return abDocument.querySelector("vcard-n #vcard-n-firstname");
+ case "MiddleName":
+ let middleNameInput = abDocument.querySelector(
+ "vcard-n #vcard-n-middlename"
+ );
+ if (addIfNeeded && BrowserTestUtils.is_hidden(middleNameInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector(
+ "vcard-n #n-list-component-middlename button"
+ ),
+ {},
+ abWindow
+ );
+ }
+ return middleNameInput;
+ case "LastName":
+ return abDocument.querySelector("vcard-n #vcard-n-lastname");
+ case "Suffix":
+ let suffixInput = abDocument.querySelector("vcard-n #vcard-n-suffix");
+ if (addIfNeeded && BrowserTestUtils.is_hidden(suffixInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector("vcard-n #n-list-component-suffix button"),
+ {},
+ abWindow
+ );
+ }
+ return suffixInput;
+ case "PrimaryEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 1
+ ) {
+ let addButton = abDocument.getElementById("vcard-add-email");
+ addButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addButton, {}, abWindow);
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(1) input[type="email"]`
+ );
+ case "PrimaryEmailCheckbox":
+ return getInput("PrimaryEmail")
+ .closest(`tr`)
+ .querySelector(`input[type="checkbox"]`);
+ case "SecondEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 2
+ ) {
+ let addButton = abDocument.getElementById("vcard-add-email");
+ addButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addButton, {}, abWindow);
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(2) input[type="email"]`
+ );
+ case "SecondEmailCheckbox":
+ return getInput("SecondEmail")
+ .closest(`tr`)
+ .querySelector(`input[type="checkbox"]`);
+ }
+
+ return null;
+}
+
+function getFields(entryName, addIfNeeded = false, count) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let fieldsSelector;
+ let addButtonId;
+ let expectFocusSelector;
+ switch (entryName) {
+ case "email":
+ fieldsSelector = `#vcard-email tr`;
+ addButtonId = "vcard-add-email";
+ expectFocusSelector = "tr:last-of-type .vcard-type-selection";
+ break;
+ case "impp":
+ fieldsSelector = "vcard-impp";
+ addButtonId = "vcard-add-impp";
+ expectFocusSelector = "vcard-impp:last-of-type select";
+ break;
+ case "url":
+ fieldsSelector = "vcard-url";
+ addButtonId = "vcard-add-url";
+ expectFocusSelector = "vcard-url:last-of-type .vcard-type-selection";
+ break;
+ case "tel":
+ fieldsSelector = "vcard-tel";
+ addButtonId = "vcard-add-tel";
+ expectFocusSelector = "vcard-tel:last-of-type .vcard-type-selection";
+ break;
+ case "note":
+ fieldsSelector = "vcard-note";
+ addButtonId = "vcard-add-note";
+ expectFocusSelector = "vcard-note:last-of-type textarea";
+ break;
+ case "title":
+ fieldsSelector = "vcard-title";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "vcard-title:last-of-type input";
+ break;
+ case "custom":
+ fieldsSelector = "vcard-custom";
+ addButtonId = "vcard-add-custom";
+ expectFocusSelector = "vcard-custom:last-of-type input";
+ break;
+ case "specialDate":
+ fieldsSelector = "vcard-special-date";
+ addButtonId = "vcard-add-bday-anniversary";
+ expectFocusSelector =
+ "vcard-special-date:last-of-type .vcard-type-selection";
+ break;
+ case "adr":
+ fieldsSelector = "vcard-adr";
+ addButtonId = "vcard-add-adr";
+ expectFocusSelector = "vcard-adr:last-of-type .vcard-type-selection";
+ break;
+ case "tz":
+ fieldsSelector = "vcard-tz";
+ addButtonId = "vcard-add-tz";
+ expectFocusSelector = "vcard-tz:last-of-type select";
+ break;
+ case "org":
+ fieldsSelector = "vcard-org";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "#addr-book-edit-org input";
+ break;
+ case "role":
+ fieldsSelector = "vcard-role";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "#addr-book-edit-org input";
+ break;
+ default:
+ throw new Error("entryName not found: " + entryName);
+ }
+ let fields = abDocument.querySelectorAll(fieldsSelector).length;
+ if (addIfNeeded && fields < count) {
+ let addButton = abDocument.getElementById(addButtonId);
+ for (let clickTimes = fields; clickTimes < count; clickTimes++) {
+ addButton.focus();
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ let expectFocus = abDocument.querySelector(expectFocusSelector);
+ Assert.ok(
+ expectFocus,
+ `Expected focus element should now exist for ${entryName}`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(expectFocus),
+ `Expected focus element for ${entryName} should be visible`
+ );
+ Assert.equal(
+ expectFocus,
+ abDocument.activeElement,
+ `Expected focus element for ${entryName} should be active`
+ );
+ }
+ }
+ return abDocument.querySelectorAll(fieldsSelector);
+}
+
+function checkToolbarState(shouldBeEnabled) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ for (let id of [
+ "toolbarCreateBook",
+ "toolbarCreateContact",
+ "toolbarCreateList",
+ "toolbarImport",
+ ]) {
+ Assert.equal(
+ abDocument.getElementById(id).disabled,
+ !shouldBeEnabled,
+ id + (!shouldBeEnabled ? " should not" : " should") + " be disabled"
+ );
+ }
+}
+
+function checkDisplayValues(expected) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, values] of Object.entries(expected)) {
+ let section = abWindow.document.getElementById(key);
+ let items = Array.from(
+ section.querySelectorAll("li .entry-value"),
+ li => li.textContent
+ );
+ Assert.deepEqual(items, values);
+ }
+}
+
+function checkInputValues(expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ Assert.ok(BrowserTestUtils.is_visible(input));
+ if (input.type == "checkbox") {
+ Assert.equal(input.checked, value, `${key} checked`);
+ } else {
+ Assert.equal(input.value, value, `${key} value`);
+ }
+ }
+}
+
+function checkVCardInputValues(expected) {
+ for (let [key, expectedEntries] of Object.entries(expected)) {
+ let fields = getFields(key, false, expectedEntries.length);
+
+ Assert.equal(
+ fields.length,
+ expectedEntries.length,
+ `${key} occurred ${fields.length} time(s) and ${expectedEntries.length} time(s) is expected.`
+ );
+
+ for (let [index, field] of fields.entries()) {
+ let expectedEntry = expectedEntries[index];
+ let valueField;
+ let typeField;
+ switch (key) {
+ case "email":
+ valueField = field.emailEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "impp":
+ valueField = field.imppEl;
+ break;
+ case "url":
+ valueField = field.urlEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "tel":
+ valueField = field.inputElement;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "note":
+ valueField = field.textAreaEl;
+ break;
+ case "title":
+ valueField = field.titleEl;
+ break;
+ case "specialDate":
+ Assert.equal(
+ expectedEntry.value[0],
+ field.year.value,
+ `Year value of ${key} at position ${index}`
+ );
+ Assert.equal(
+ expectedEntry.value[1],
+ field.month.value,
+ `Month value of ${key} at position ${index}`
+ );
+ Assert.equal(
+ expectedEntry.value[2],
+ field.day.value,
+ `Day value of ${key} at position ${index}`
+ );
+ break;
+ case "adr":
+ typeField = field.vCardType.selectEl;
+ let addressValue = [
+ field.streetEl.value,
+ field.localityEl.value,
+ field.regionEl.value,
+ field.codeEl.value,
+ field.countryEl.value,
+ ];
+
+ Assert.deepEqual(
+ expectedEntry.value,
+ addressValue,
+ `Value of ${key} at position ${index}`
+ );
+ break;
+ case "tz":
+ valueField = field.selectEl;
+ break;
+ case "org":
+ let orgValue = [field.orgEl.value];
+ if (field.unitEl.value) {
+ orgValue.push(field.unitEl.value);
+ }
+ Assert.deepEqual(
+ expectedEntry.value,
+ orgValue,
+ `Value of ${key} at position ${index}`
+ );
+ break;
+ case "role":
+ valueField = field.roleEl;
+ break;
+ }
+
+ // Check the input value of the field.
+ if (valueField) {
+ Assert.equal(
+ expectedEntry.value,
+ valueField.value,
+ `Value of ${key} at position ${index}`
+ );
+ }
+
+ // Check the type of the field.
+ if (expectedEntry.type || typeField) {
+ Assert.equal(
+ expectedEntry.type || "",
+ typeField.value,
+ `Type of ${key} at position ${index}`
+ );
+ }
+ }
+ }
+}
+
+function checkCardValues(card, expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ if (value) {
+ Assert.equal(
+ card.getProperty(key, "WRONG!"),
+ value,
+ `${key} has the right value`
+ );
+ } else {
+ Assert.equal(
+ card.getProperty(key, "RIGHT!"),
+ "RIGHT!",
+ `${key} has no value`
+ );
+ }
+ }
+}
+
+function checkVCardValues(card, expected) {
+ for (let [key, expectedEntries] of Object.entries(expected)) {
+ let cardValues = card.vCardProperties.getAllEntries(key);
+
+ Assert.equal(
+ expectedEntries.length,
+ cardValues.length,
+ `${key} is expected to occur ${expectedEntries.length} time(s) and ${cardValues.length} time(s) is found.`
+ );
+
+ for (let [index, entry] of cardValues.entries()) {
+ let expectedEntry = expectedEntries[index];
+
+ Assert.deepEqual(
+ expectedEntry.value,
+ entry.value,
+ `Value of ${key} at position ${index}`
+ );
+
+ if (entry.params.type || expectedEntry.type) {
+ Assert.equal(
+ expectedEntry.type,
+ entry.params.type,
+ `Type of ${key} at position ${index}`
+ );
+ }
+
+ if (entry.params.pref || expectedEntry.pref) {
+ Assert.equal(
+ expectedEntry.pref,
+ entry.params.pref,
+ `Pref of ${key} at position ${index}`
+ );
+ }
+ }
+ }
+}
+
+function setInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, value] of Object.entries(changes)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ if (input.type == "checkbox") {
+ EventUtils.synthesizeMouseAtCenter(input, {}, abWindow);
+ Assert.equal(
+ input.checked,
+ value,
+ `${key} ${value ? "checked" : "unchecked"}`
+ );
+ } else {
+ input.select();
+ if (value) {
+ EventUtils.sendString(value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+/**
+ * Uses EventUtils.synthesizeMouseAtCenter and XULPopup.activateItem to
+ * activate optionValue from the select element typeField.
+ *
+ * @param {HTMLSelectElement} typeField Select element.
+ * @param {string} optionValue The value attribute of the option element from
+ * typeField.
+ */
+async function activateTypeSelect(typeField, optionValue) {
+ let abWindow = getAddressBookWindow();
+ // Ensure that the select field is inside the viewport.
+ typeField.scrollIntoView({ block: "nearest" });
+ let shownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ EventUtils.synthesizeMouseAtCenter(typeField, {}, abWindow);
+ let selectPopup = await shownPromise;
+
+ // Get the index of the optionValue from typeField
+ let index = Array.from(typeField.children).findIndex(
+ child => child.value === optionValue
+ );
+ Assert.ok(index >= 0, "Type in select field found");
+
+ // No change event is fired if the same option is activated.
+ if (index === typeField.selectedIndex) {
+ let popupHidden = BrowserTestUtils.waitForEvent(selectPopup, "popuphidden");
+ selectPopup.hidePopup();
+ await popupHidden;
+ return;
+ }
+
+ // The change event saves the vCard value.
+ let changeEvent = BrowserTestUtils.waitForEvent(typeField, "change");
+ selectPopup.activateItem(selectPopup.children[index]);
+ await changeEvent;
+}
+
+async function setVCardInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, entries] of Object.entries(changes)) {
+ let fields = getFields(key, true, entries.length);
+ // Somehow prevents an error on macOS when using <select> widgets that
+ // have just been added.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 250));
+
+ for (let [index, field] of fields.entries()) {
+ let changeEntry = entries[index];
+ let valueField;
+ let typeField;
+ switch (key) {
+ case "email":
+ valueField = field.emailEl;
+ typeField = field.vCardType.selectEl;
+
+ if (
+ (field.checkboxEl.checked && changeEntry && !changeEntry.pref) ||
+ (!field.checkboxEl.checked &&
+ changeEntry &&
+ changeEntry.pref == "1")
+ ) {
+ field.checkboxEl.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(field.checkboxEl, {}, abWindow);
+ }
+ break;
+ case "impp":
+ valueField = field.imppEl;
+ break;
+ case "url":
+ valueField = field.urlEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "tel":
+ valueField = field.inputElement;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "note":
+ valueField = field.textAreaEl;
+ break;
+ case "specialDate":
+ if (changeEntry && changeEntry.value) {
+ field.month.value = changeEntry.value[1];
+ field.day.value = changeEntry.value[2];
+ field.year.value = changeEntry.value[0];
+ } else {
+ field.month.value = "";
+ field.day.value = "";
+ field.year.value = "";
+ }
+
+ if (changeEntry && changeEntry.key === "bday") {
+ field.selectEl.value = "bday";
+ } else {
+ field.selectEl.value = "anniversary";
+ }
+ break;
+ case "adr":
+ typeField = field.vCardType.selectEl;
+
+ for (let [index, input] of [
+ field.streetEl,
+ field.localityEl,
+ field.regionEl,
+ field.codeEl,
+ field.countryEl,
+ ].entries()) {
+ input.select();
+ if (
+ changeEntry &&
+ Array.isArray(changeEntry.value) &&
+ changeEntry.value[index]
+ ) {
+ EventUtils.sendString(changeEntry.value[index]);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ break;
+ case "tz":
+ if (changeEntry && changeEntry.value) {
+ field.selectEl.value = changeEntry.value;
+ } else {
+ field.selectEl.value = "";
+ }
+ break;
+ case "title":
+ valueField = field.titleEl;
+ break;
+ case "org":
+ for (let [index, input] of [field.orgEl, field.unitEl].entries()) {
+ input.select();
+ if (
+ changeEntry &&
+ Array.isArray(changeEntry.value) &&
+ changeEntry.value[index]
+ ) {
+ EventUtils.sendString(changeEntry.value[index]);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ break;
+ case "role":
+ valueField = field.roleEl;
+ break;
+ case "custom":
+ valueField = field.querySelector("vcard-custom:last-of-type input");
+ break;
+ }
+
+ if (valueField) {
+ valueField.select();
+ if (changeEntry && changeEntry.value) {
+ EventUtils.sendString(changeEntry.value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+
+ if (typeField && changeEntry && changeEntry.type) {
+ await activateTypeSelect(typeField, changeEntry.type);
+ } else if (typeField) {
+ await activateTypeSelect(typeField, "");
+ }
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+/**
+ * Open the contact at the given index in the #cards element.
+ *
+ * @param {number} index - The index of the contact to edit.
+ * @param {object} options - Options for how the contact is selected for
+ * editing.
+ * @param {boolean} options.useMouse - Whether to use mouse events to select the
+ * contact. Otherwise uses keyboard events.
+ * @param {boolean} options.useActivate - Whether to activate the contact for
+ * editing directly from the #cards list using "Enter" or double click.
+ * Otherwise uses the "Edit" button in the contact display.
+ */
+async function editContactAtIndex(index, options) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = cardsList.selectedIndex;
+ },
+ };
+
+ if (!options.useMouse) {
+ cardsList.table.body.focus();
+ if (cardsList.currentIndex != index) {
+ selectHandler.reset();
+ cardsList.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeKey("KEY_Home", {}, abWindow);
+ for (let i = 0; i < index; i++) {
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, abWindow);
+ }
+ await TestUtils.waitForCondition(
+ () => selectHandler.seenEvent,
+ `'select' event should get fired`
+ );
+ }
+ }
+
+ if (options.useActivate) {
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { clickCount: 1 },
+ abWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { clickCount: 2 },
+ abWindow
+ );
+ } else {
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ }
+ } else {
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ }
+
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ } else {
+ while (abDocument.activeElement != editButton) {
+ EventUtils.synthesizeKey("KEY_Tab", {}, abWindow);
+ }
+ EventUtils.synthesizeKey(" ", {}, abWindow);
+ }
+ }
+
+ await inEditingMode();
+}
+
+add_task(async function test_basic_edit() {
+ let book = createAddressBook("Test Book");
+ book.addCard(createContact("contact", "1"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let booksList = abDocument.getElementById("books");
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewContactNickName = abDocument.getElementById("viewContactNickName");
+ let viewContactEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editContactName = abDocument.getElementById("editContactHeadingName");
+ let editContactNickName = abDocument.getElementById(
+ "editContactHeadingNickName"
+ );
+ let editContactEmail = abDocument.getElementById("editContactHeadingEmail");
+
+ /**
+ * Assert that the heading has the expected text content and visibility.
+ *
+ * @param {Element} headingEl - The heading to test.
+ * @param {string} expect - The expected text content. If this is "", the
+ * heading is expected to be hidden as well.
+ */
+ function assertHeading(headingEl, expect) {
+ Assert.equal(
+ headingEl.textContent,
+ expect,
+ `Heading ${headingEl.id} content should match`
+ );
+ if (expect) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(headingEl),
+ `Heading ${headingEl.id} should be visible`
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(headingEl),
+ `Heading ${headingEl.id} should be visible`
+ );
+ }
+ }
+
+ /**
+ * Assert the headings shown in the contact view page.
+ *
+ * @param {string} name - The expected name, or an empty string if none is
+ * expected.
+ * @param {string} nickname - The expected nickname, or an empty string if
+ * none is expected.
+ * @param {string} email - The expected email, or an empty string if none is
+ * expected.
+ */
+ function assertViewHeadings(name, nickname, email) {
+ assertHeading(viewContactName, name);
+ assertHeading(viewContactNickName, nickname);
+ assertHeading(viewContactEmail, email);
+ }
+
+ /**
+ * Assert the headings shown in the contact edit page.
+ *
+ * @param {string} name - The expected name, or an empty string if none is
+ * expected.
+ * @param {string} nickname - The expected nickname, or an empty string if
+ * none is expected.
+ * @param {string} email - The expected email, or an empty string if none is
+ * expected.
+ */
+ function assertEditHeadings(name, nickname, email) {
+ assertHeading(editContactName, name);
+ assertHeading(editContactNickName, nickname);
+ assertHeading(editContactEmail, email);
+ }
+
+ Assert.ok(detailsPane.hidden);
+ Assert.ok(!document.querySelector("vcard-n"));
+ Assert.ok(!abDocument.getElementById("vcard-email").children.length);
+
+ // Select a card in the list. Check the display in view mode.
+
+ Assert.equal(cardsList.view.rowCount, 1);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ assertViewHeadings("contact 1", "", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Try to trigger the creation of a new contact while in edit mode.
+ EventUtils.synthesizeKey("n", { ctrlKey: true }, abWindow);
+
+ // Headings reflect initial values and shouldn't have changed.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ // Check that pressing Tab can't get us stuck on an element that shouldn't
+ // have focus.
+
+ abDocument.documentElement.focus();
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.documentElement,
+ "focus should be on the root element"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+ Assert.ok(
+ abDocument
+ .getElementById("editContactForm")
+ .contains(abDocument.activeElement),
+ "focus should be on the editing form"
+ );
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.documentElement,
+ "focus should be on the root element again"
+ );
+
+ // Check that clicking outside the form doesn't steal focus.
+
+ EventUtils.synthesizeMouseAtCenter(booksList, {}, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.body,
+ "focus should be on the body element"
+ );
+ EventUtils.synthesizeMouseAtCenter(cardsList, {}, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.body,
+ "focus should be on the body element still"
+ );
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ // Make sure the header values reflect the fields values.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ // Make some changes but cancel them.
+
+ setInputValues({
+ LastName: "one",
+ DisplayName: "contact one",
+ NickName: "contact nickname",
+ PrimaryEmail: "contact.1.edited@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Headings reflect new values.
+ assertEditHeadings(
+ "contact one",
+ "contact nickname",
+ "contact.1.edited@invalid"
+ );
+
+ // Change the preferred email to the secondary.
+ setInputValues({
+ SecondEmailCheckbox: true,
+ });
+ // The new email value should be reflected in the heading.
+ assertEditHeadings("contact one", "contact nickname", "i@roman.invalid");
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ // Heading reflects initial values.
+ assertViewHeadings("contact 1", "", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ PrimaryEmail: "contact.1@invalid",
+ });
+
+ // Click to edit again. The changes should have been reversed.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ // Headings are restored.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ // Make some changes again, and this time save them.
+
+ setInputValues({
+ LastName: "one",
+ DisplayName: "contact one",
+ NickName: "contact nickname",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ assertEditHeadings("contact one", "contact nickname", "contact.1@invalid");
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ // Headings show new values
+ assertViewHeadings("contact one", "contact nickname", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid", "i@roman.invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "one",
+ DisplayName: "contact one",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Click to edit again. The new values should be shown.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "one",
+ DisplayName: "contact one",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Cancel the edit by pressing the Escape key.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Click to edit again. This time make some changes.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ setInputValues({
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Cancel the edit by pressing the Escape key and cancel the prompt.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ Assert.ok(
+ abWindow.detailsPane.isEditing,
+ "still editing after cancelling prompt"
+ );
+
+ // Cancel the edit by pressing the Escape key and accept the prompt.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(editButton);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ checkCardValues(book.childCards[0], {
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Click to edit again.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ setInputValues({
+ LastName: "11",
+ DisplayName: "person 11",
+ SecondEmail: "xi@roman.invalid",
+ });
+
+ // Cancel the edit by pressing the Escape key and discard the changes.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(editButton);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ checkCardValues(book.childCards[0], {
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Click to edit again.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Make some changes again, and this time save them by pressing Enter.
+
+ setInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ SecondEmail: null,
+ });
+
+ getInput("SecondEmail").focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_special_fields() {
+ Services.prefs.setStringPref("mail.addr_book.show_phonetic_fields", "true");
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // The order of the FirstName and LastName fields can be reversed by L10n.
+ // This means they can be broken by L10n. Check that they're alright in the
+ // default configuration. We need to find a more robust way of doing this,
+ // but it is what it is for now.
+
+ let firstName = abDocument.getElementById("FirstName");
+ let lastName = abDocument.getElementById("LastName");
+ Assert.equal(
+ firstName.compareDocumentPosition(lastName),
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ "LastName follows FirstName"
+ );
+
+ // The phonetic name fields should be visible, because the preference is set.
+ // They can also be broken by L10n.
+
+ let phoneticFirstName = abDocument.getElementById("PhoneticFirstName");
+ let phoneticLastName = abDocument.getElementById("PhoneticLastName");
+ Assert.ok(BrowserTestUtils.is_visible(phoneticFirstName));
+ Assert.ok(BrowserTestUtils.is_visible(phoneticLastName));
+ Assert.equal(
+ phoneticFirstName.compareDocumentPosition(phoneticLastName),
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ "PhoneticLastName follows PhoneticFirstName"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.prefs.setStringPref("mail.addr_book.show_phonetic_fields", "false");
+
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+ createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // The phonetic name fields should be visible, because the preference is set.
+ // They can also be broken by L10n.
+
+ phoneticFirstName = abDocument.getElementById("PhoneticFirstName");
+ phoneticLastName = abDocument.getElementById("PhoneticLastName");
+ Assert.ok(BrowserTestUtils.is_hidden(phoneticFirstName));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneticLastName));
+
+ await closeAddressBookWindow();
+
+ Services.prefs.clearUserPref("mail.addr_book.show_phonetic_fields");
+}).skip(); // Phonetic fields not implemented.
+
+/**
+ * Test that the display name field is populated when it should be, and not
+ * when it shouldn't be.
+ */
+add_task(async function test_generate_display_name() {
+ Services.prefs.setBoolPref("mail.addr_book.displayName.autoGeneration", true);
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "false"
+ );
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ // Try saving an empty contact.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await inEditingMode();
+
+ // First name, no last name.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "first" });
+
+ // Last name, no first name.
+ setInputValues({ FirstName: "", LastName: "last" });
+ checkInputValues({ DisplayName: "last" });
+
+ // Both names.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "first last" });
+
+ // Modify the display name, it should not be overwritten.
+ setInputValues({ DisplayName: "don't touch me" });
+ setInputValues({ FirstName: "second" });
+ checkInputValues({ DisplayName: "don't touch me" });
+
+ // Clear the modified display name, it should still not be overwritten.
+ setInputValues({ DisplayName: "" });
+ setInputValues({ FirstName: "third" });
+ checkInputValues({ DisplayName: "" });
+
+ // Flip the order.
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "true"
+ );
+ setInputValues({ FirstName: "fourth" });
+ checkInputValues({ DisplayName: "" });
+
+ // Turn off generation.
+ Services.prefs.setBoolPref(
+ "mail.addr_book.displayName.autoGeneration",
+ false
+ );
+ setInputValues({ FirstName: "fifth" });
+ checkInputValues({ DisplayName: "" });
+
+ setInputValues({ DisplayName: "last, fourth" });
+
+ // Save the card and check the values.
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+ checkCardValues(personalBook.childCards[0], {
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+ Assert.ok(!abWindow.detailsPane.isDirty, "dirty flag is cleared");
+
+ // Reset the order and turn generation back on.
+ Services.prefs.setBoolPref("mail.addr_book.displayName.autoGeneration", true);
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "false"
+ );
+
+ // Reload the card and check the values.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ checkInputValues({
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+
+ // Clear all required values.
+ setInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ });
+
+ // Try saving the empty contact.
+ promptPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await inEditingMode();
+
+ // Close the edit without saving.
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+
+ // Enter edit mode again. The values shouldn't have changed.
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ await inEditingMode();
+ checkInputValues({
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+
+ // Check the saved name isn't overwritten.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "last, fourth" });
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ Services.prefs.clearUserPref("mail.addr_book.displayName.autoGeneration");
+ Services.prefs.clearUserPref("mail.addr_book.displayName.lastnamefirst");
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Test that the "prefer display name" checkbox is visible when it should be
+ * (in edit mode and only if there is a display name).
+ */
+add_task(async function test_prefer_display_name() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Make a new card. Check the default value is true.
+ // The display name shouldn't be affected by first and last name if the field
+ // is not empty.
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+
+ checkInputValues({ DisplayName: "", PreferDisplayName: true });
+
+ setInputValues({ DisplayName: "test" });
+ setInputValues({ FirstName: "first" });
+
+ checkInputValues({ DisplayName: "test" });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.equal(personalBook.childCardCount, 1);
+ checkCardValues(personalBook.childCards[0], {
+ DisplayName: "test",
+ PreferDisplayName: "1",
+ });
+
+ // Edit the card. Check the UI matches the card value.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({ DisplayName: "test" });
+ checkInputValues({ FirstName: "first" });
+
+ // Change the card value.
+
+ let preferDisplayName = abDocument.querySelector(
+ "vcard-fn #vCardPreferDisplayName"
+ );
+ EventUtils.synthesizeMouseAtCenter(preferDisplayName, {}, abWindow);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.equal(personalBook.childCardCount, 1);
+ checkCardValues(personalBook.childCards[0], {
+ DisplayName: "test",
+ PreferDisplayName: "0",
+ });
+
+ // Edit the card. Check the UI matches the card value.
+
+ preferDisplayName.checked = true; // Ensure it gets set.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Clear the display name. The first and last name shouldn't affect it.
+ setInputValues({ DisplayName: "" });
+ checkInputValues({ FirstName: "first" });
+
+ setInputValues({ LastName: "last" });
+ checkInputValues({ DisplayName: "" });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Checks the state of the toolbar buttons is restored after editing.
+ */
+add_task(async function test_toolbar_state() {
+ personalBook.addCard(createContact("contact", "2"));
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // In All Address Books, the "create card" and "create list" buttons should
+ // be disabled.
+
+ await openAllAddressBooks();
+ checkToolbarState(true);
+
+ // In other directories, all buttons should be enabled.
+
+ await openDirectory(personalBook);
+ checkToolbarState(true);
+
+ // Back to All Address Books.
+
+ await openAllAddressBooks();
+ checkToolbarState(true);
+
+ // Select a card, no change.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ checkToolbarState(true);
+
+ // Edit a card, all buttons disabled.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Cancel editing, button states restored.
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Edit a card again, all buttons disabled.
+
+ EventUtils.synthesizeKey(" ", {}, abWindow);
+ await inEditingMode();
+
+ // Cancel editing, button states restored.
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+add_task(async function test_delete_button() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let searchInput = abDocument.getElementById("searchInput");
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let editButton = abDocument.getElementById("editButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane), "details pane is hidden");
+
+ // Create a new card. The delete button shouldn't be visible at this point.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ setInputValues({
+ FirstName: "delete",
+ LastName: "me",
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+
+ Assert.equal(personalBook.childCardCount, 1, "contact was not deleted");
+ let contact = personalBook.childCards[0];
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(deleteButton));
+
+ // Click to delete, cancel the deletion.
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ Assert.ok(abWindow.detailsPane.isEditing, "still in editing mode");
+ Assert.equal(personalBook.childCardCount, 1, "contact was not deleted");
+
+ // Click to delete, accept the deletion.
+
+ let deletionPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(searchInput);
+
+ let [subject, data] = await deletionPromise;
+ Assert.equal(subject.UID, contact.UID, "correct card was deleted");
+ Assert.equal(data, personalBook.UID, "card was deleted from correct place");
+ Assert.equal(personalBook.childCardCount, 0, "contact was deleted");
+ Assert.equal(
+ cardsList.view.directory.UID,
+ personalBook.UID,
+ "view didn't change"
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_hidden(detailsPane)
+ );
+
+ // Now let's delete a contact while viewing a list.
+
+ let listContact = createContact("delete", "me too");
+ let list = personalBook.addMailList(createMailingList("a list"));
+ list.addCard(listContact);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ openDirectory(list);
+ Assert.equal(cardsList.view.rowCount, 1);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(deleteButton));
+
+ // Click to delete, accept the deletion.
+ deletionPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(searchInput);
+
+ [subject, data] = await deletionPromise;
+ Assert.equal(subject.UID, listContact.UID, "correct card was deleted");
+ Assert.equal(data, personalBook.UID, "card was deleted from correct place");
+ Assert.equal(personalBook.childCardCount, 0, "contact was deleted");
+ Assert.equal(cardsList.view.directory.UID, list.UID, "view didn't change");
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_hidden(detailsPane)
+ );
+
+ personalBook.deleteDirectory(list);
+ await closeAddressBookWindow();
+});
+
+function checkNFieldState({ prefix, middlename, suffix }) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ Assert.equal(abDocument.querySelectorAll("vcard-n").length, 1);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById("vcard-n-firstname")),
+ "Firstname is always shown."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById("vcard-n-lastname")),
+ "Lastname is always shown."
+ );
+
+ for (let [subValueName, inputId, buttonSelector, inputVisible] of [
+ ["prefix", "vcard-n-prefix", "#n-list-component-prefix button", prefix],
+ [
+ "middlename",
+ "vcard-n-middlename",
+ "#n-list-component-middlename button",
+ middlename,
+ ],
+ ["suffix", "vcard-n-suffix", "#n-list-component-suffix button", suffix],
+ ]) {
+ let inputEl = abDocument.getElementById(inputId);
+ Assert.ok(inputEl);
+ let buttonEl = abDocument.querySelector(buttonSelector);
+ Assert.ok(buttonEl);
+
+ if (inputVisible) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(inputEl),
+ `${subValueName} input is shown with an initial value or a click on the button.`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(buttonEl),
+ `${subValueName} button is hidden when the input is shown.`
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(inputEl),
+ `${subValueName} input is not shown initially.`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(buttonEl),
+ `${subValueName} button is shown when the input is hidden.`
+ );
+ }
+ }
+}
+
+/**
+ * Save repeatedly names of two contacts and ensure that no fields are leaking
+ * to another card.
+ */
+add_task(async function test_name_fields() {
+ let book = createAddressBook("Test Book N Field");
+ book.addCard(createContact("contact1", "lastname1"));
+ book.addCard(createContact("contact2", "lastname2"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ // Edit contact1.
+ await editContactAtIndex(0, {});
+
+ // Check for the original values of contact1.
+ checkInputValues({ FirstName: "contact1", LastName: "lastname1" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "", "", ""] }],
+ });
+
+ // Edit contact1 set all n values.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ Prefix: "prefix 1",
+ FirstName: "contact1 changed",
+ MiddleName: "middle name 1",
+ LastName: "lastname1 changed",
+ Suffix: "suffix 1",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [
+ {
+ value: [
+ "lastname1 changed",
+ "contact1 changed",
+ "middle name 1",
+ "prefix 1",
+ "suffix 1",
+ ],
+ },
+ ],
+ });
+
+ // Edit contact2.
+ await editContactAtIndex(1, {});
+
+ // Check for the original values of contact2 after saving contact1.
+ checkInputValues({ FirstName: "contact2", LastName: "lastname2" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Ensure that both vCardValues of contact1 and contact2 are correct.
+ checkVCardValues(book.childCards[0], {
+ n: [
+ {
+ value: [
+ "lastname1 changed",
+ "contact1 changed",
+ "middle name 1",
+ "prefix 1",
+ "suffix 1",
+ ],
+ },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Edit contact1 and change the values to only firstname and lastname values
+ // to see that the button/input handling of the field is correct.
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ Prefix: "prefix 1",
+ FirstName: "contact1 changed",
+ MiddleName: "middle name 1",
+ LastName: "lastname1 changed",
+ Suffix: "suffix 1",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ setInputValues({
+ Prefix: "",
+ FirstName: "contact1 changed",
+ MiddleName: "",
+ LastName: "lastname1 changed",
+ Suffix: "",
+ });
+
+ // Fields are still visible until the contact is saved and edited again.
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1 changed", "contact1 changed", "", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Check in contact1 that prefix, middlename and suffix inputs are hidden
+ // again. Then remove the N last values and save.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ checkInputValues({
+ FirstName: "contact1 changed",
+ LastName: "lastname1 changed",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ // Let firstname and lastname empty for contact1.
+ setInputValues({
+ FirstName: "",
+ LastName: "",
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ // If useActivate is called, expect the focus to return to the cards list.
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Edit contact2.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkInputValues({ FirstName: "contact2", LastName: "lastname2" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Edit contact1.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ checkInputValues({ FirstName: "", LastName: "" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ FirstName: "contact1",
+ MiddleName: "middle name 1",
+ LastName: "lastname1",
+ });
+
+ checkNFieldState({ prefix: false, middlename: true, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Now check when cancelling that no data is leaked between edits.
+ // Edit contact2 for this first.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ setInputValues({
+ Prefix: "prefix 2",
+ FirstName: "contact2",
+ MiddleName: "middle name",
+ LastName: "lastname2",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Ensure that prefix, middlename and lastname are correctly shown after
+ // cancelling contact2. Then cancel contact2 again and look at contact1.
+ await editContactAtIndex(1, {});
+
+ checkInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Ensure that a cancel from contact2 doesn't leak to contact1.
+ await editContactAtIndex(0, {});
+
+ checkNFieldState({ prefix: false, middlename: true, suffix: false });
+
+ checkInputValues({
+ FirstName: "contact1",
+ MiddleName: "middle name 1",
+ LastName: "lastname1",
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Checks if the default choice is visible or hidden.
+ * If the default choice is expected checks that at maximum one
+ * default email is ticked.
+ *
+ * @param {boolean} expectedDefaultChoiceVisible
+ * @param {number} expectedDefaultIndex
+ */
+async function checkDefaultEmailChoice(
+ expectedDefaultChoiceVisible,
+ expectedDefaultIndex
+) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let emailFields = abDocument.querySelectorAll(`#vcard-email tr`);
+
+ for (let [index, emailField] of emailFields.entries()) {
+ if (expectedDefaultChoiceVisible) {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(emailField.checkboxEl),
+ `Email at index ${index} has a visible default email choice.`
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_hidden(emailField.checkboxEl),
+ `Email at index ${index} has a hidden default email choice.`
+ );
+ }
+
+ // Default email checking of the field.
+ Assert.equal(
+ expectedDefaultIndex === index,
+ emailField.checkboxEl.checked,
+ `Pref of email at position ${index}`
+ );
+ }
+
+ // Check that at max one checkbox is ticked.
+ if (expectedDefaultChoiceVisible) {
+ let checked = Array.from(emailFields).filter(
+ emailField => emailField.checkboxEl.checked
+ );
+ Assert.ok(
+ checked.length <= 1,
+ "At maximum one email is ticked for the default email."
+ );
+ }
+}
+
+add_task(async function test_email_fields() {
+ let book = createAddressBook("Test Book Email Field");
+ book.addCard(createContact("contact1", "lastname1"));
+ book.addCard(createContact("contact2", "lastname2"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ // Edit contact1.
+ await editContactAtIndex(0, { useActivate: true });
+
+ // Check for the original values of contact1.
+ checkVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ // Focus moves to cards list if we activate the edit directly from the list.
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", pref: "1" }],
+ });
+
+ // Edit contact1 set type.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ await setVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid", type: "work" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", type: "work", pref: "1" }],
+ });
+
+ // Check for the original values of contact2.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ email: [{ value: "contact2.lastname2@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Ensure that both vCardValues of contact1 and contact2 are correct.
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", type: "work", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Edit contact1 and add another email to see that the default email
+ // choosing is visible.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid", type: "work" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", pref: "1", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1.lastname1@invalid", pref: "1", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Choose another default email in contact1.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Remove the first email from contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [{}, { value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ // The default email choosing is still visible until the contact is saved.
+ await checkDefaultEmailChoice(true, 1);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Add multiple emails to contact2 and click each as the default email.
+ // The last default clicked email should be set as default email and
+ // only one should be selected.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ email: [{ value: "contact2.lastname2@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid", pref: "1" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [
+ { value: "home.contact2@invalid", type: "home" },
+ { value: "work.contact2@invalid", type: "work" },
+ { value: "some.contact2@invalid" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ // Remove 3 emails from contact2.
+ await editContactAtIndex(1, { useActivate: true, useMouse: true });
+
+ checkVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home" },
+ { value: "work.contact2@invalid", type: "work" },
+ { value: "some.contact2@invalid" },
+ { value: "default.email.contact2@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ await setVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ // The default email choosing is still visible until the contact is saved.
+ // For this case the default email is left on an empty field which will be
+ // removed.
+ await checkDefaultEmailChoice(true, 3);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Now check when cancelling that no data is leaked between edits.
+ // Edit contact2 for this first.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid", pref: "1" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Ensure that the default email choosing is not shown after
+ // cancelling contact2. Then cancel contact2 again and look at contact1.
+ await editContactAtIndex(1, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Ensure that a cancel from contact2 doesn't leak to contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ email: [{ value: "another.contact1@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_vCard_fields() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book VCard Fields");
+
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+ let contact2 = createContact("contact2", "lastname");
+ book.addCard(contact2);
+
+ openDirectory(book);
+
+ let cardsList = abDocument.getElementById("cards");
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Check that no field is initially shown with a new contact.
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ for (let [selector, label] of [
+ ["vcard-impp", "Chat accounts"],
+ ["vcard-url", "Websites"],
+ ["vcard-tel", "Phone numbers"],
+ ["vcard-note", "Notes"],
+ ["vcard-special-dates", "Special dates"],
+ ["vcard-adr", "Addresses"],
+ ["vcard-tz", "Time Zone"],
+ ["vcard-role", "Organizational properties"],
+ ["vcard-title", "Organizational properties"],
+ ["vcard-org", "Organizational properties"],
+ ]) {
+ Assert.equal(
+ abDocument.querySelectorAll(selector).length,
+ 0,
+ `${label} are not initially shown.`
+ );
+ }
+
+ // Cancel the new contact creation.
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(searchInput);
+
+ // Set values for contact1 with one entry for each field.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ await setVCardInputValues({
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ ],
+ adr: [
+ {
+ value: ["123 Main Street", "Any Town", "CA", "91921-1234", "U.S.A"],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [{ value: "1980-12-15" }],
+ adr: [
+ {
+ value: [
+ "",
+ "",
+ "123 Main Street",
+ "Any Town",
+ "CA",
+ "91921-1234",
+ "U.S.A",
+ ],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ // Edit the same contact and set multiple fields.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ ],
+ adr: [
+ {
+ value: ["123 Main Street", "Any Town", "CA", "91921-1234", "U.S.A"],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ await setVCardInputValues({
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ { value: [1960, 9, 17], key: "anniversary" },
+ { value: [2010, 7, 1], key: "anniversary" },
+ ],
+ adr: [
+ { value: ["123 Main Street", "", "", "", ""] },
+ { value: ["456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [
+ { value: "1980-12-15" },
+ { value: "1960-09-17" },
+ { value: "2010-07-01" },
+ ],
+ adr: [
+ { value: ["", "", "123 Main Street", "", "", "", ""] },
+ { value: ["", "", "456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["", "", "789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ // Switch from contact1 to contact2 and set some entries.
+ // Ensure that no fields from contact1 are leaked.
+ await editContactAtIndex(1, { useMouse: true });
+
+ checkVCardInputValues({
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ specialDate: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ await setVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: ["Organization contact 2"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [
+ { value: "1980-12-15" },
+ { value: "1960-09-17" },
+ { value: "2010-07-01" },
+ ],
+ adr: [
+ { value: ["", "", "123 Main Street", "", "", "", ""] },
+ { value: ["", "", "456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["", "", "789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Ensure that no fields from contact2 are leaked to contact1.
+ // Check and remove all values from contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ { value: [1960, 9, 17], key: "anniversary" },
+ { value: [2010, 7, 1], key: "anniversary" },
+ ],
+ adr: [
+ { value: ["123 Main Street", "", "", "", ""] },
+ { value: ["456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ await setVCardInputValues({
+ impp: [{}, {}, {}],
+ url: [{}, {}, {}],
+ tel: [{}, {}, {}],
+ note: [{}],
+ specialDate: [{}, {}, {}, {}],
+ adr: [{}, {}, {}],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check contact2 make changes and cancel.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ await setVCardInputValues({
+ impp: [{ value: "" }],
+ url: [
+ { value: "https://www.thunderbird.net" },
+ { value: "www.another.url", type: "work" },
+ ],
+ tel: [{ value: "650-903-0800" }, { value: "+123 456 789", type: "home" }],
+ note: [],
+ specialDate: [{}, { value: [1980, 12, 15], key: "anniversary" }],
+ adr: [],
+ tz: [],
+ role: [{ value: "Some Role contact 2" }],
+ title: [],
+ org: [{ value: "Some Organization" }],
+ });
+
+ // Cancel the changes.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check that the cancel for contact2 worked cancel afterwards.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check that no values from contact2 are leaked to contact1 when cancelling.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ specialDate: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_vCard_minimal() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ let addOrgButton = abDocument.getElementById("vcard-add-org");
+ addOrgButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addOrgButton, {}, abWindow);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-title")),
+ "Title should be visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-role")),
+ "Role should be visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-org")),
+ "Organization should be visible"
+ );
+
+ abDocument.querySelector("vcard-org input").value = "FBI";
+
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let editButton = abDocument.getElementById("editButton");
+
+ // Should allow to save with only Organization filled.
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(personalBook.childCards[0], {
+ org: [{ value: "FBI" }],
+ });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Switches to different types to verify that all works accordingly.
+ */
+add_task(async function test_type_selection() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book Type Selection");
+
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+
+ openDirectory(book);
+
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ await editContactAtIndex(0, {});
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1@invalid" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1@invalid", pref: "1" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1@invalid", pref: "1" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1@invalid", type: "work" },
+ { value: "home.contact1@invalid" },
+ { value: "work.contact1@invalid", type: "home" },
+ ],
+ url: [
+ { value: "https://none.example.com", type: "work" },
+ { value: "https://home.example.com" },
+ { value: "https://work.example.com", type: "home" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "pager" },
+ { value: "809 HOME 77 666 8" },
+ { value: "+111 WORK 3456789", type: "home" },
+ { value: "+123 CELL 456 789" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "cell" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1@invalid", type: "work", pref: "1" },
+ { value: "home.contact1@invalid" },
+ { value: "work.contact1@invalid", type: "home" },
+ ],
+ url: [
+ { value: "https://none.example.com", type: "work" },
+ { value: "https://home.example.com" },
+ { value: "https://work.example.com", type: "home" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "pager" },
+ { value: "809 HOME 77 666 8" },
+ { value: "+111 WORK 3456789", type: "home" },
+ { value: "+123 CELL 456 789" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "cell" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Other vCard contacts are using uppercase types for the predefined spec
+ * labels. This tests our support for them for the edit of a contact.
+ */
+add_task(async function test_support_types_uppercase() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book Uppercase Type Support");
+
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Add a card with uppercase types.
+ book.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:contact 1
+ TEL:+123456 789
+ TEL;TYPE=HOME:809 HOME 77 666 8
+ TEL;TYPE=WORK:+111 WORK 3456789
+ TEL;TYPE=CELL:+123 CELL 456 789
+ TEL;TYPE=FAX:809 FAX 77 666 8
+ TEL;TYPE=PAGER:+111 PAGER 3456789
+ END:VCARD
+`)
+ );
+
+ openDirectory(book);
+
+ // First open the edit and check that the values are shown.
+ // Do not change anything.
+ await editContactAtIndex(0, {});
+
+ // The UI uses lowercase types but only changes them when the type is
+ // touched.
+ checkVCardInputValues({
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // We haven't touched these values so they are not changed to lower case.
+ checkVCardValues(book.childCards[0], {
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "HOME" },
+ { value: "+111 WORK 3456789", type: "WORK" },
+ { value: "+123 CELL 456 789", type: "CELL" },
+ { value: "809 FAX 77 666 8", type: "FAX" },
+ { value: "+111 PAGER 3456789", type: "PAGER" },
+ ],
+ });
+
+ // Now make changes to the types.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ await setVCardInputValues({
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 HOME 77 666 8", type: "cell" },
+ { value: "+111 WORK 3456789", type: "pager" },
+ { value: "+123 CELL 456 789", type: "fax" },
+ { value: "809 FAX 77 666 8", type: "" },
+ { value: "+111 PAGER 3456789", type: "work" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // As we touched the type values they are now saved in lowercase.
+ // At this point it is up to the other vCard implementation to handle this.
+ checkVCardValues(book.childCards[0], {
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 HOME 77 666 8", type: "cell" },
+ { value: "+111 WORK 3456789", type: "pager" },
+ { value: "+123 CELL 456 789", type: "fax" },
+ { value: "809 FAX 77 666 8", type: "" },
+ { value: "+111 PAGER 3456789", type: "work" },
+ ],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_special_date_field() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ // Add data to the default values to allow saving.
+ setInputValues({
+ FirstName: "contact",
+ PrimaryEmail: "contact.1.edited@invalid",
+ });
+
+ let addSpecialDate = abDocument.getElementById("vcard-add-bday-anniversary");
+ addSpecialDate.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addSpecialDate, {}, abWindow);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-special-date")),
+ "The special date field is visible."
+ );
+ // Somehow prevents an error on macOS when using <select> widgets that have
+ // just been added.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 250));
+
+ let firstYear = abDocument.querySelector(
+ `vcard-special-date input[type="number"]`
+ );
+ Assert.ok(!firstYear.value, "year empty");
+ let firstMonth = abDocument.querySelector(
+ `vcard-special-date .vcard-month-select`
+ );
+ Assert.equal(firstMonth.value, "", "month should be on placeholder");
+ let firstDay = abDocument.querySelector(
+ `vcard-special-date .vcard-day-select`
+ );
+ Assert.equal(firstDay.value, "", "day should be on placeholder");
+ Assert.equal(firstDay.childNodes.length, 32, "all days should be possible");
+
+ // Set date to a leap year.
+ firstYear.value = 2004;
+
+ let shownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ firstMonth.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(firstMonth, {}, abWindow);
+ let selectPopup = await shownPromise;
+
+ let changePromise = BrowserTestUtils.waitForEvent(firstMonth, "change");
+ selectPopup.activateItem(selectPopup.children[2]);
+ await changePromise;
+
+ await BrowserTestUtils.waitForCondition(
+ () => firstDay.childNodes.length == 30, // 29 days + empty option 0.
+ "day options filled with leap year"
+ );
+
+ // No leap year.
+ firstYear.select();
+ EventUtils.sendString("2003");
+ await BrowserTestUtils.waitForCondition(
+ () => firstDay.childNodes.length == 29, // 28 days + empty option 0.
+ "day options filled without leap year"
+ );
+
+ // Remove the field.
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector(`vcard-special-date .remove-property-button`),
+ {},
+ abWindow
+ );
+
+ Assert.ok(
+ !abDocument.querySelector("vcard-special-date"),
+ "The special date field was removed."
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests that custom properties (Custom1 etc.) are editable.
+ */
+add_task(async function testCustomProperties() {
+ let card = new AddrBookCard();
+ card._properties = new Map([
+ ["PopularityIndex", 0],
+ ["Custom2", "custom two"],
+ ["Custom4", "custom four"],
+ [
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ FN:custom person
+ X-CUSTOM3:x-custom three
+ X-CUSTOM4:x-custom four
+ END:VCARD
+ `,
+ ],
+ ]);
+ card = personalBook.addCard(card);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ let index = cardsList.view.getIndexForUID(card.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ let customField = getFields("custom")[0];
+ let inputs = customField.querySelectorAll("input");
+ Assert.equal(inputs.length, 4);
+ Assert.equal(inputs[0].value, "");
+ Assert.equal(inputs[1].value, "custom two");
+ Assert.equal(inputs[2].value, "x-custom three");
+ Assert.equal(inputs[3].value, "x-custom four");
+
+ inputs[0].value = "x-custom one";
+ inputs[1].value = "x-custom two";
+ inputs[3].value = "";
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ card = personalBook.childCards.find(c => c.UID == card.UID);
+ checkCardValues(card, {
+ Custom2: null,
+ Custom4: null,
+ });
+ checkVCardValues(card, {
+ "x-custom1": [{ value: "x-custom one" }],
+ "x-custom2": [{ value: "x-custom two" }],
+ "x-custom3": [{ value: "x-custom three" }],
+ "x-custom4": [],
+ });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Tests that we correctly fix Google's bad escaping of colons in values, and
+ * other characters in URI values.
+ */
+add_task(async function testGoogleEscaping() {
+ let googleBook = createAddressBook("Google Book");
+ googleBook.wrappedJSObject._isGoogleCardDAV = true;
+ googleBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ N:test;en\\\\c\\:oding;;;
+ FN:en\\\\c\\:oding test
+ TITLE:title\\:title\\;title\\,title\\\\title\\\\\\:title\\\\\\;title\\\\\\,title\\\\\\\\
+ TEL:tel\\:0123\\\\4567
+ EMAIL:test\\\\test@invalid
+ NOTE:notes\\:\\nnotes\\;\\nnotes\\,\\nnotes\\\\
+ URL:https\\://host/url\\:url\\;url\\,url\\\\url
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ openDirectory(googleBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ FirstName: "en\\c:oding",
+ LastName: "test",
+ DisplayName: "en\\c:oding test",
+ });
+
+ checkVCardInputValues({
+ title: [
+ { value: "title:title;title,title\\title\\:title\\;title\\,title\\\\" },
+ ],
+ tel: [{ value: "tel:01234567" }],
+ email: [{ value: "test\\test@invalid" }],
+ note: [{ value: "notes:\nnotes;\nnotes,\nnotes\\" }],
+ url: [{ value: "https://host/url:url;url,url\\url" }],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(googleBook.URI);
+});
+
+/**
+ * Tests that contacts with nickname can be edited.
+ */
+add_task(async function testNickname() {
+ let book = createAddressBook("Nick");
+ book.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:jsmith@example.org
+ NICKNAME:Johnny
+ N:SMITH;JOHN;;;
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ openDirectory(book);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ FirstName: "JOHN",
+ LastName: "SMITH",
+ NickName: "Johnny",
+ PrimaryEmail: "jsmith@example.org",
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_remove_button() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let book = createAddressBook("Test Book VCard Fields");
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+
+ openDirectory(book);
+
+ await editContactAtIndex(0, {});
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let removeButtons = detailsPane.querySelectorAll(".remove-property-button");
+ Assert.equal(
+ removeButtons.length,
+ 2,
+ "Email and Organization Properties remove button is present."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ abDocument
+ .getElementById("addr-book-edit-email")
+ .querySelector(".remove-property-button")
+ ),
+ "Email is present and remove button is visible."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ abDocument
+ .getElementById("addr-book-edit-org")
+ .querySelector(".remove-property-button")
+ ),
+ "Organization Properties are not filled and the remove button is not visible."
+ );
+
+ // Set a value for each field.
+ await setVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [{ value: [1966, 12, 15], key: "bday" }],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ custom: [{ value: "foo" }],
+ });
+
+ let vCardEdit = detailsPane.querySelector("vcard-edit");
+
+ // Click the remove buttons and check that the properties are removed.
+
+ for (let [propertyName, fieldsetId, propertySelector, addButton] of [
+ ["adr", "addr-book-edit-address", "vcard-adr"],
+ ["impp", "addr-book-edit-impp", "vcard-impp"],
+ ["tel", "addr-book-edit-tel", "vcard-tel"],
+ ["url", "addr-book-edit-url", "vcard-url"],
+ ["email", "addr-book-edit-email", "#vcard-email tr"],
+ ["bday", "addr-book-edit-bday-anniversary", "vcard-special-date"],
+ ["tz", "addr-book-edit-tz", "vcard-tz", "vcard-add-tz"],
+ ["note", "addr-book-edit-note", "vcard-note", "vcard-add-note"],
+ ["org", "addr-book-edit-org", "vcard-org", "vcard-add-org"],
+ ["x-custom1", "addr-book-edit-custom", "vcard-custom", "vcard-add-custom"],
+ ]) {
+ Assert.ok(
+ vCardEdit.vCardProperties.getFirstEntry(propertyName),
+ `${propertyName} is present.`
+ );
+ let removeButton = abDocument
+ .getElementById(fieldsetId)
+ .querySelector(".remove-property-button");
+
+ removeButton.scrollIntoView({ block: "nearest" });
+ let removeEvent = BrowserTestUtils.waitForEvent(
+ vCardEdit,
+ "vcard-remove-property"
+ );
+ EventUtils.synthesizeMouseAtCenter(removeButton, {}, abWindow);
+ await removeEvent;
+
+ await Assert.ok(
+ !vCardEdit.vCardProperties.getFirstEntry(propertyName),
+ `${propertyName} is removed.`
+ );
+ Assert.equal(
+ vCardEdit.querySelectorAll(propertySelector).length,
+ 0,
+ `All elements representing ${propertyName} are removed.`
+ );
+
+ // For single entries the add button have to be visible again.
+ // Time Zone, Notes, Organizational Properties, Custom Properties
+ if (addButton) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById(addButton)),
+ `Add button for ${propertyName} is visible after remove.`
+ );
+ Assert.equal(
+ abDocument.activeElement.id,
+ addButton,
+ `The focus for ${propertyName} was moved to the add button.`
+ );
+ }
+ }
+
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let editButton = abDocument.getElementById("editButton");
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_photo.js b/comm/mail/components/addrbook/test/browser/browser_edit_photo.js
new file mode 100644
index 0000000000..0b0da4771d
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_photo.js
@@ -0,0 +1,866 @@
+/* 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 { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+async function waitForDialogOpenState(state) {
+ let abWindow = getAddressBookWindow();
+ let dialog = abWindow.document.getElementById("photoDialog");
+ await TestUtils.waitForCondition(
+ () => dialog.open == state,
+ "waiting for photo dialog to change state"
+ );
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+}
+
+async function waitForPreviewChange() {
+ let abWindow = getAddressBookWindow();
+ let preview = abWindow.document.querySelector("#photoDialog svg > image");
+ let oldValue = preview.getAttribute("href");
+ await BrowserTestUtils.waitForEvent(
+ preview,
+ "load",
+ false,
+ () => preview.getAttribute("href") != oldValue
+ );
+ await new Promise(resolve => abWindow.requestAnimationFrame(resolve));
+}
+
+async function waitForPhotoChange() {
+ let abWindow = getAddressBookWindow();
+ let photo = abWindow.document.querySelector("#photoButton .contact-photo");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let oldValue = photo.src;
+ await BrowserTestUtils.waitForMutationCondition(
+ photo,
+ { attributes: true },
+ () => photo.src != oldValue
+ );
+ await new Promise(resolve => abWindow.requestAnimationFrame(resolve));
+ Assert.ok(!dialog.open, "dialog was closed when photo changed");
+}
+
+function dropFile(target, path) {
+ let abWindow = getAddressBookWindow();
+ let file = new FileUtils.File(getTestFilePath(path));
+
+ let dataTransfer = new DataTransfer();
+ dataTransfer.dropEffect = "copy";
+ dataTransfer.mozSetDataAt("application/x-moz-file", file, 0);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_COPY);
+ dragService.getCurrentSession().dataTransfer = dataTransfer;
+
+ EventUtils.synthesizeDragOver(
+ target,
+ target,
+ [{ type: "application/x-moz-file", data: file }],
+ "copy",
+ abWindow
+ );
+
+ // This make sure that the fake dataTransfer has still the expected drop
+ // effect after the synthesizeDragOver call.
+ dataTransfer.dropEffect = "copy";
+
+ EventUtils.synthesizeDropAfterDragOver(null, dataTransfer, target, abWindow, {
+ _domDispatchOnly: true,
+ });
+
+ dragService.endDragSession(true);
+}
+
+function checkDialogElements({
+ dropTargetClass = "",
+ svgVisible = false,
+ saveButtonVisible = false,
+ saveButtonDisabled = false,
+ discardButtonVisible = false,
+}) {
+ let abWindow = getAddressBookWindow();
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton, discardButton } = dialog;
+ let dropTarget = dialog.querySelector("#photoDropTarget");
+ let svg = dialog.querySelector("svg");
+ Assert.equal(
+ BrowserTestUtils.is_visible(dropTarget),
+ !!dropTargetClass,
+ "drop target visibility"
+ );
+ if (dropTargetClass) {
+ Assert.stringContains(
+ dropTarget.className,
+ dropTargetClass,
+ "drop target message"
+ );
+ }
+ Assert.equal(BrowserTestUtils.is_visible(svg), svgVisible, "SVG visibility");
+ Assert.equal(
+ BrowserTestUtils.is_visible(saveButton),
+ saveButtonVisible,
+ "save button visibility"
+ );
+ Assert.equal(
+ saveButton.disabled,
+ saveButtonDisabled,
+ "save button disabled state"
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(discardButton),
+ discardButtonVisible,
+ "discard button visibility"
+ );
+}
+
+function getInput(entryName, addIfNeeded = false) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ switch (entryName) {
+ case "DisplayName":
+ return abDocument.querySelector("vcard-fn #vCardDisplayName");
+ case "FirstName":
+ return abDocument.querySelector("vcard-n #vcard-n-firstname");
+ case "LastName":
+ return abDocument.querySelector("vcard-n #vcard-n-lastname");
+ case "PrimaryEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 1
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.getElementById("vcard-add-email"),
+ {},
+ abWindow
+ );
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(1) input[type="email"]`
+ );
+ case "SecondEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 2
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.getElementById("vcard-add-email"),
+ {},
+ abWindow
+ );
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(2) input[type="email"]`
+ );
+ }
+
+ return null;
+}
+
+function setInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, value] of Object.entries(changes)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ input.select();
+ if (value) {
+ EventUtils.sendString(value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+add_setup(async function () {
+ await openAddressBookWindow();
+ openDirectory(personalBook);
+});
+
+registerCleanupFunction(async function cleanUp() {
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+ await CardDAVServer.close();
+});
+
+/** Create a new contact. We'll add a photo to this contact. */
+async function subtest_add_photo(book) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton } = dialog;
+
+ openDirectory(book);
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // Open the photo dialog by clicking on the photo.
+
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Drop a file on the photo dialog.
+
+ let previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo1.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Accept the photo dialog.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(saveButton, {}, abWindow);
+ await photoChangePromise;
+ Assert.notEqual(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown"
+ );
+
+ // Save the contact.
+
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ setInputValues({
+ DisplayName: "Person with Photo 1",
+ });
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ // Photo shown in view.
+ Assert.notEqual(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown in contact view"
+ );
+
+ let [card, uid] = await createdPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Create another new contact. This time we'll add a photo, but discard it. */
+async function subtest_dont_add_photo(book) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton, cancelButton, discardButton } = dialog;
+ let svg = dialog.querySelector("svg");
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // Drop a file on the photo.
+
+ dropFile(photoButton, "data/photo2.jpg");
+ await waitForDialogOpenState(true);
+ await TestUtils.waitForCondition(() => BrowserTestUtils.is_visible(svg));
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Cancel the photo dialog.
+
+ EventUtils.synthesizeMouseAtCenter(cancelButton, {}, abWindow);
+ await waitForDialogOpenState(false);
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Drop a file on the photo dialog.
+
+ let previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo1.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Drop another file on the photo dialog.
+
+ previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo2.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Accept the photo dialog.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(saveButton, {}, abWindow);
+ await photoChangePromise;
+ Assert.notEqual(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown"
+ );
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ discardButtonVisible: true,
+ });
+
+ // Click to discard the photo.
+
+ photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(discardButton, {}, abWindow);
+ await photoChangePromise;
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ EventUtils.synthesizeMouseAtCenter(cancelButton, {}, abWindow);
+ await waitForDialogOpenState(false);
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ // Save the contact and check the photo was NOT saved.
+
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ setInputValues({
+ DisplayName: "Person with Photo 2",
+ });
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ Assert.equal(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown in contact view"
+ );
+
+ let [card, uid] = await createdPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Go back to the first contact and discard the photo. */
+async function subtest_discard_photo(book, checkPhotoCallback) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { discardButton } = dialog;
+
+ openDirectory(book);
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ Assert.ok(
+ checkPhotoCallback(viewPhoto.src),
+ "saved photo shown in contact view"
+ );
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Open the photo dialog by clicking on the photo.
+
+ Assert.ok(
+ checkPhotoCallback(editPhoto.src),
+ "saved photo shown in edit view"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ discardButtonVisible: true,
+ });
+
+ // Click to discard the photo.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(discardButton, {}, abWindow);
+ await photoChangePromise;
+
+ // Save the contact and check the photo was removed.
+
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+ Assert.equal(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "photo no longer shown in contact view"
+ );
+
+ let [card, uid] = await updatedPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Check that pasting URLs on photo widgets works. */
+async function subtest_paste_url() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let dropTarget = abDocument.getElementById("photoDropTarget");
+
+ // Start a new contact and focus on the photo button.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ Assert.equal(abDocument.activeElement.id, "vcard-n-firstname");
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ // Focus is on name prefix button.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ photoButton,
+ "photo button is focused"
+ );
+
+ // Paste a URL.
+
+ let previewChangePromise = waitForPreviewChange();
+
+ let wrapper1 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper1.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/photo1.jpg";
+ let transfer1 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer1.init(null);
+ transfer1.addDataFlavor("text/plain");
+ transfer1.setTransferData("text/plain", wrapper1);
+ Services.clipboard.setData(transfer1, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await waitForDialogOpenState(true);
+ await previewChangePromise;
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ saveButtonDisabled: false,
+ });
+
+ // Close then reopen the dialog.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Paste a URL.
+
+ previewChangePromise = waitForPreviewChange();
+
+ let wrapper2 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper2.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/photo2.jpg";
+ let transfer2 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer2.init(null);
+ transfer2.addDataFlavor("text/plain");
+ transfer2.setTransferData("text/plain", wrapper2);
+ Services.clipboard.setData(transfer2, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await previewChangePromise;
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ saveButtonDisabled: false,
+ });
+
+ // Close then reopen the dialog.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Paste an invalid URL.
+
+ let wrapper3 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper3.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/fake.jpg";
+ let transfer3 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer3.init(null);
+ transfer3.addDataFlavor("text/plain");
+ transfer3.setTransferData("text/plain", wrapper3);
+ Services.clipboard.setData(transfer3, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await TestUtils.waitForCondition(() =>
+ dropTarget.classList.contains("drop-error")
+ );
+
+ checkDialogElements({
+ dropTargetClass: "drop-error",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode();
+}
+
+/** Test photo operations with a local address book. */
+add_task(async function test_local() {
+ // Create a new contact. We'll add a photo to this contact.
+
+ let card1 = await subtest_add_photo(personalBook);
+ let photo1Name = card1.getProperty("PhotoName", "");
+ Assert.ok(photo1Name, "PhotoName property saved on card");
+
+ let photo1Path = PathUtils.join(profileDir, "Photos", photo1Name);
+ let photo1File = new FileUtils.File(photo1Path);
+ Assert.ok(photo1File.exists(), "photo saved to disk");
+
+ let image = new Image();
+ let loadedPromise = BrowserTestUtils.waitForEvent(image, "load");
+ image.src = Services.io.newFileURI(photo1File).spec;
+ await loadedPromise;
+
+ Assert.equal(image.naturalWidth, 300, "photo saved at correct width");
+ Assert.equal(image.naturalHeight, 300, "photo saved at correct height");
+
+ // Create another new contact. This time we'll add a photo, but discard it.
+
+ let card2 = await subtest_dont_add_photo(personalBook);
+ Assert.equal(
+ card2.getProperty("PhotoName", "NO VALUE"),
+ "NO VALUE",
+ "PhotoName property not saved on card"
+ );
+
+ // Go back to the first contact and discard the photo.
+
+ let card3 = await subtest_discard_photo(personalBook, src =>
+ src.endsWith(photo1Name)
+ );
+ Assert.equal(
+ card3.getProperty("PhotoName", "NO VALUE"),
+ "NO VALUE",
+ "PhotoName property removed from card"
+ );
+ Assert.ok(
+ !new FileUtils.File(photo1Path).exists(),
+ "photo removed from disk"
+ );
+
+ // Check that pasting URLs on photo widgets works.
+
+ await subtest_paste_url(personalBook);
+});
+
+/**
+ * Test photo operations with a CardDAV address book and a server that only
+ * speaks vCard 3, i.e. Google.
+ */
+add_task(async function test_add_photo_carddav3() {
+ // Set up the server, address book and password.
+
+ CardDAVServer.open("alice", "alice");
+ CardDAVServer.mimicGoogle = true;
+
+ let book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "alice");
+ book.setBoolValue("carddav.vcard3", true);
+ book.wrappedJSObject._isGoogleCardDAV = true;
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "alice", "alice", "", "");
+ Services.logins.addLogin(loginInfo);
+
+ // Create a new contact. We'll add a photo to this contact.
+
+ // This notification fires when we retrieve the saved card from the server,
+ // which happens before subtest_add_photo finishes.
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ let card1 = await subtest_add_photo(book);
+ Assert.equal(
+ card1.getProperty("PhotoName", "RIGHT"),
+ "RIGHT",
+ "PhotoName property not saved on card"
+ );
+
+ // Check the card we sent.
+ let photoProp = card1.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card1.vCardProperties.designSet === ICAL.design.vcard3);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, "B");
+ Assert.equal(photoProp.type, "binary");
+ Assert.ok(photoProp.value.startsWith("/9j/"));
+
+ // Check the card we received from the server. If the server didn't like it,
+ // the photo will be removed and this will fail.
+ let [card2] = await updatedPromise;
+ photoProp = card2.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card2.vCardProperties.designSet === ICAL.design.vcard3);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, "B");
+ Assert.equal(photoProp.type, "binary");
+ Assert.ok(photoProp.value.startsWith("/9j/"));
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ let [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ serverCard.vCard.includes("\nPHOTO;ENCODING=B:/9j/"),
+ "photo included in card on server"
+ );
+
+ // Discard the photo.
+
+ let card3 = await subtest_discard_photo(book, src =>
+ src.startsWith("data:image/jpeg;base64,/9j/")
+ );
+
+ // Check the card we sent.
+ Assert.equal(card3.vCardProperties.getFirstEntry("photo"), null);
+
+ // This notification is the second of two, and fires when we retrieve the
+ // saved card from the server, which doesn't happen before
+ // subtest_discard_photo finishes.
+ let [card4] = await TestUtils.topicObserved("addrbook-contact-updated");
+ Assert.equal(card4.vCardProperties.getFirstEntry("photo"), null);
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ !serverCard.vCard.includes("PHOTO:"),
+ "photo removed from card on server"
+ );
+
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.mimicGoogle = false;
+ CardDAVServer.close();
+ CardDAVServer.reset();
+});
+
+/**
+ * Test photo operations with a CardDAV address book and a server that can
+ * handle vCard 4.
+ */
+add_task(async function test_add_photo_carddav4() {
+ // Set up the server, address book and password.
+
+ CardDAVServer.open("bob", "bob");
+
+ let book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "bob");
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+
+ // Create a new contact. We'll add a photo to this contact.
+
+ // This notification fires when we retrieve the saved card from the server,
+ // which happens before subtest_add_photo finishes.
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ let card1 = await subtest_add_photo(book);
+ Assert.equal(
+ card1.getProperty("PhotoName", "RIGHT"),
+ "RIGHT",
+ "PhotoName property not saved on card"
+ );
+
+ // Check the card we sent.
+ let photoProp = card1.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card1.vCardProperties.designSet === ICAL.design.vcard);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, undefined);
+ Assert.equal(photoProp.type, "uri");
+ Assert.ok(photoProp.value.startsWith("data:image/jpeg;base64,/9j/"));
+
+ // Check the card we received from the server.
+ let [card2] = await updatedPromise;
+ photoProp = card2.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card2.vCardProperties.designSet === ICAL.design.vcard);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, undefined);
+ Assert.equal(photoProp.type, "uri");
+ Assert.ok(photoProp.value.startsWith("data:image/jpeg;base64,/9j/"));
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ let [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ serverCard.vCard.includes("\nPHOTO:data:image/jpeg;base64\\,/9j/"),
+ "photo included in card on server"
+ );
+
+ // Discard the photo.
+
+ let card3 = await subtest_discard_photo(book, src =>
+ src.startsWith("data:image/jpeg;base64,/9j/")
+ );
+
+ // Check the card we sent.
+ Assert.equal(card3.vCardProperties.getFirstEntry("photo"), null);
+
+ // This notification is the second of two, and fires when we retrieve the
+ // saved card from the server, which doesn't happen before
+ // subtest_discard_photo finishes.
+ let [card4] = await TestUtils.topicObserved("addrbook-contact-updated");
+ Assert.equal(card4.vCardProperties.getFirstEntry("photo"), null);
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ [serverCard] = [...CardDAVServer.cards.values()];
+ console.log(serverCard.vCard);
+ Assert.ok(
+ !serverCard.vCard.includes("PHOTO:"),
+ "photo removed from card on server"
+ );
+
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.close();
+ CardDAVServer.reset();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_ldap_search.js b/comm/mail/components/addrbook/test/browser/browser_ldap_search.js
new file mode 100644
index 0000000000..6eb7322bb4
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_ldap_search.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/. */
+
+const { LDAPServer } = ChromeUtils.import(
+ "resource://testing-common/LDAPServer.jsm"
+);
+
+const jsonFile =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/ldap_contacts.json";
+
+add_task(async () => {
+ function waitForCountChange(expectedCount) {
+ return new Promise(resolve => {
+ cardsList.addEventListener("rowcountchange", function onRowCountChange() {
+ console.log(cardsList.view.rowCount, expectedCount);
+ if (cardsList.view.rowCount == expectedCount) {
+ cardsList.removeEventListener("rowcountchange", onRowCountChange);
+ resolve();
+ }
+ });
+ });
+ }
+
+ // Set up some local people.
+
+ let cardsToRemove = [];
+ for (let name of ["daniel", "jonathan", "nathan"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = personalBook.addCard(card);
+ cardsToRemove.push(card);
+ }
+
+ // Set up the LDAP server.
+
+ LDAPServer.open();
+ let response = await fetch(jsonFile);
+ let ldapContacts = await response.json();
+
+ let bookPref = MailServices.ab.newAddressBook(
+ "Mochitest",
+ `ldap://localhost:${LDAPServer.port}/`,
+ 0
+ );
+ let book = MailServices.ab.getDirectoryFromId(bookPref);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let searchBox = abDocument.getElementById("searchInput");
+ let cardsList = abWindow.cardsPane.cardsList;
+ let noSearchResults = abDocument.getElementById("placeholderNoSearchResults");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ // Search for some people in the LDAP directory.
+
+ openDirectory(book);
+ checkPlaceholders(["placeholderSearchOnly"]);
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.sendString("holmes", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.mycroft);
+ LDAPServer.writeSearchResultEntry(ldapContacts.sherlock);
+ LDAPServer.writeSearchResultDone();
+
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await waitForCountChange(2);
+ checkNamesListed("Mycroft Holmes", "Sherlock Holmes");
+ checkPlaceholders();
+
+ // Check that displaying an LDAP card works without error.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("john", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("John Watson");
+ checkPlaceholders();
+
+ // Now move back to the "All Address Books" view and search again.
+ // The search string is retained when switching books.
+
+ openAllAddressBooks();
+ checkNamesListed();
+ Assert.equal(searchBox.value, "john");
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("John Watson");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("irene", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.irene);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("Irene Adler");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("jo", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed("jonathan");
+ checkPlaceholders();
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(2);
+ checkNamesListed("John Watson", "jonathan");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("mark", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultDone();
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(noSearchResults)
+ );
+ checkNamesListed();
+ checkPlaceholders(["placeholderNoSearchResults"]);
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(cardsToRemove);
+ await promiseDirectoryRemoved(book.URI);
+ LDAPServer.close();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js b/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js
new file mode 100644
index 0000000000..64d679ec13
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js
@@ -0,0 +1,474 @@
+/* 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 MailServices, MailUtils */
+
+var { DisplayNameUtils } = ChromeUtils.import(
+ "resource:///modules/DisplayNameUtils.jsm"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const inputs = {
+ abName: "Mochitest Address Book",
+ mlName: "Mochitest Mailing List",
+ nickName: "Nicky",
+ description: "Just a test mailing list.",
+ addresses: [
+ "alan@example.com",
+ "betty@example.com",
+ "clyde@example.com",
+ "deb@example.com",
+ ],
+ modification: " (modified)",
+};
+
+const getDisplayedAddress = address => `${address} <${address}>`;
+
+let global = {};
+
+/**
+ * Set up: create a new address book to hold the mailing list.
+ */
+add_task(async () => {
+ let bookPrefName = MailServices.ab.newAddressBook(
+ inputs.abName,
+ null,
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let addressBook = MailServices.ab.getDirectoryFromId(bookPrefName);
+
+ let abWindow = await openAddressBookWindow();
+
+ global = {
+ abWindow,
+ addressBook,
+ booksList: abWindow.booksList,
+ mailListUID: undefined,
+ };
+});
+
+/**
+ * Create a new mailing list with some addresses, in the new address book.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ ).then(async function (mlWindow) {
+ let mlDocument = mlWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ let listName = mlDocument.getElementById("ListName");
+ if (mlDocument.activeElement != listName) {
+ await BrowserTestUtils.waitForEvent(listName, "focus");
+ }
+
+ let abPopup = mlDocument.getElementById("abPopup");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInputsCount = mlDocument
+ .getElementById("addressingWidget")
+ .querySelectorAll("input").length;
+
+ Assert.equal(
+ abPopup.label,
+ global.addressBook.dirName,
+ "the correct address book is selected in the menu"
+ );
+ Assert.equal(
+ abPopup.value,
+ global.addressBook.URI,
+ "the address book selected in the menu has the correct address book URI"
+ );
+ Assert.equal(listName.value, "", "no text in the list name field");
+ Assert.equal(listNickName.value, "", "no text in the list nickname field");
+ Assert.equal(listDescription.value, "", "no text in the description field");
+ Assert.equal(addressInput1.value, "", "no text in the addresses list");
+ Assert.equal(addressInputsCount, 1, "only one address list input exists");
+
+ EventUtils.sendString(inputs.mlName, mlWindow);
+
+ // Tab to nickname input.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.nickName, mlWindow);
+
+ // Tab to description input.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.description, mlWindow);
+
+ // Tab to address input and add addresses zero and one by entering
+ // both of them there.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.addresses.slice(0, 2).join(", "), mlWindow);
+
+ mlDocElement.getButton("accept").click();
+ });
+
+ // Select the address book.
+ openDirectory(global.addressBook);
+
+ // Open the new mailing list dialog, the callback above interacts with it.
+ EventUtils.synthesizeMouseAtCenter(
+ global.abWindow.document.getElementById("toolbarCreateList"),
+ { clickCount: 1 },
+ global.abWindow
+ );
+
+ await mailingListWindowPromise;
+
+ // Confirm that the mailing list and addresses were saved in the backend.
+
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[0]),
+ "address zero was saved"
+ );
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[1]),
+ "address one was saved"
+ );
+
+ let childCards = global.addressBook.childCards;
+
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[0]),
+ "address zero was saved in the correct address book"
+ );
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[1]),
+ "address one was saved in the correct address book"
+ );
+
+ let mailList = MailUtils.findListInAddressBooks(inputs.mlName);
+
+ // Save the mailing list UID so we can confirm it is the same later.
+ global.mailListUID = mailList.UID;
+
+ Assert.ok(mailList, "mailing list was created");
+ Assert.ok(
+ global.addressBook.hasMailListWithName(inputs.mlName),
+ "mailing list was created in the correct address book"
+ );
+ Assert.equal(mailList.dirName, inputs.mlName, "mailing list name was saved");
+ Assert.equal(
+ mailList.listNickName,
+ inputs.nickName,
+ "mailing list nick name was saved"
+ );
+ Assert.equal(
+ mailList.description,
+ inputs.description,
+ "mailing list description was saved"
+ );
+
+ let listCards = mailList.childCards;
+ Assert.equal(listCards.length, 2, "two cards exist in the mailing list");
+ Assert.ok(
+ listCards[0].hasEmailAddress(inputs.addresses[0]),
+ "address zero was saved in the mailing list"
+ );
+ Assert.ok(
+ listCards[1].hasEmailAddress(inputs.addresses[1]),
+ "address one was saved in the mailing list"
+ );
+});
+
+/**
+ * Open the mailing list dialog and modify the mailing list.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (mlWindow) {
+ let mlDocument = mlWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ if (!mlDocument.getElementById("addressCol1#3")) {
+ // The address input nodes are not there yet when the dialog window is
+ // loaded, so wait until they exist.
+ await mailTestUtils.awaitElementExistence(
+ MutationObserver,
+ mlDocument,
+ "addressingWidget",
+ "addressCol1#3"
+ );
+ }
+
+ if (mlDocument.activeElement.id != "addressCol1#3") {
+ await BrowserTestUtils.waitForEvent(
+ mlDocument.getElementById("addressCol1#3"),
+ "focus"
+ );
+ }
+
+ let listName = mlDocument.getElementById("ListName");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInput2 = mlDocument.getElementById("addressCol1#2");
+
+ Assert.equal(
+ listName.value,
+ inputs.mlName,
+ "list name is displayed correctly"
+ );
+ Assert.equal(
+ listNickName.value,
+ inputs.nickName,
+ "list nickname is displayed correctly"
+ );
+ Assert.equal(
+ listDescription.value,
+ inputs.description,
+ "list description is displayed correctly"
+ );
+ Assert.equal(
+ addressInput1 && addressInput1.value,
+ getDisplayedAddress(inputs.addresses[0]),
+ "address zero is displayed correctly"
+ );
+ Assert.equal(
+ addressInput2 && addressInput2.value,
+ getDisplayedAddress(inputs.addresses[1]),
+ "address one is displayed correctly"
+ );
+
+ let textInputs = mlDocument.querySelectorAll(".textbox-addressingWidget");
+ Assert.equal(textInputs.length, 3, "no extraneous addresses are displayed");
+
+ // Add addresses two and three.
+ EventUtils.sendString(inputs.addresses.slice(2, 4).join(", "), mlWindow);
+ EventUtils.sendKey("RETURN", mlWindow);
+ await new Promise(resolve => mlWindow.setTimeout(resolve));
+
+ // Delete the address in the second row (address one).
+ EventUtils.synthesizeMouseAtCenter(
+ addressInput2,
+ { clickCount: 1 },
+ mlWindow
+ );
+ EventUtils.synthesizeKey("a", { accelKey: true }, mlWindow);
+ EventUtils.sendKey("BACK_SPACE", mlWindow);
+
+ // Modify the list's name, nick name, and description fields.
+ let modifyField = id => {
+ id.focus();
+ EventUtils.sendKey("DOWN", mlWindow);
+ EventUtils.sendString(inputs.modification, mlWindow);
+ };
+ modifyField(listName);
+ modifyField(listNickName);
+ modifyField(listDescription);
+
+ mlDocElement.getButton("accept").click();
+ });
+
+ // Open the mailing list dialog, the callback above interacts with it.
+ global.booksList.selectedIndex = 3;
+ global.booksList.showPropertiesOfSelected();
+
+ await mailingListWindowPromise;
+
+ // Confirm that the mailing list and addresses were saved in the backend.
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(3).querySelector("span").textContent,
+ inputs.mlName + inputs.modification,
+ `mailing list ("${
+ inputs.mlName + inputs.modification
+ }") is displayed in the address book list`
+ );
+
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[2]),
+ "address two was saved"
+ );
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[3]),
+ "address three was saved"
+ );
+
+ let childCards = global.addressBook.childCards;
+
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[2]),
+ "address two was saved in the correct address book"
+ );
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[3]),
+ "address three was saved in the correct address book"
+ );
+
+ let mailList = MailUtils.findListInAddressBooks(
+ inputs.mlName + inputs.modification
+ );
+
+ Assert.equal(
+ mailList && mailList.UID,
+ global.mailListUID,
+ "mailing list still exists"
+ );
+
+ Assert.ok(
+ global.addressBook.hasMailListWithName(inputs.mlName + inputs.modification),
+ "mailing list is still in the correct address book"
+ );
+ Assert.equal(
+ mailList.dirName,
+ inputs.mlName + inputs.modification,
+ "modified mailing list name was saved"
+ );
+ Assert.equal(
+ mailList.listNickName,
+ inputs.nickName + inputs.modification,
+ "modified mailing list nick name was saved"
+ );
+ Assert.equal(
+ mailList.description,
+ inputs.description + inputs.modification,
+ "modified mailing list description was saved"
+ );
+
+ let listCards = mailList.childCards;
+
+ Assert.equal(listCards.length, 3, "three cards exist in the mailing list");
+
+ Assert.ok(
+ listCards[0].hasEmailAddress(inputs.addresses[0]),
+ "address zero was saved in the mailing list (is still there)"
+ );
+ Assert.ok(
+ listCards[1].hasEmailAddress(inputs.addresses[2]),
+ "address two was saved in the mailing list"
+ );
+ Assert.ok(
+ listCards[2].hasEmailAddress(inputs.addresses[3]),
+ "address three was saved in the mailing list"
+ );
+
+ let hasAddressOne = listCards.find(card =>
+ card.hasEmailAddress(inputs.addresses[1])
+ );
+
+ Assert.ok(!hasAddressOne, "address one was deleted from the mailing list");
+});
+
+/**
+ * Open the mailing list dialog and confirm the changes are displayed.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (mailingListWindow) {
+ let mlDocument = mailingListWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ if (!mlDocument.getElementById("addressCol1#4")) {
+ // The address input nodes are not there yet when the dialog window is
+ // loaded, so wait until they exist.
+ await mailTestUtils.awaitElementExistence(
+ MutationObserver,
+ mlDocument,
+ "addressingWidget",
+ "addressCol1#4"
+ );
+ }
+
+ if (mlDocument.activeElement.id != "addressCol1#4") {
+ await BrowserTestUtils.waitForEvent(
+ mlDocument.getElementById("addressCol1#4"),
+ "focus"
+ );
+ }
+
+ let listName = mlDocument.getElementById("ListName");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInput2 = mlDocument.getElementById("addressCol1#2");
+ let addressInput3 = mlDocument.getElementById("addressCol1#3");
+
+ Assert.equal(
+ listName.value,
+ inputs.mlName + inputs.modification,
+ "modified list name is displayed correctly"
+ );
+ Assert.equal(
+ listNickName.value,
+ inputs.nickName + inputs.modification,
+ "modified list nickname is displayed correctly"
+ );
+ Assert.equal(
+ listDescription.value,
+ inputs.description + inputs.modification,
+ "modified list description is displayed correctly"
+ );
+ Assert.equal(
+ addressInput1 && addressInput1.value,
+ getDisplayedAddress(inputs.addresses[0]),
+ "address zero is displayed correctly (is still there)"
+ );
+ Assert.equal(
+ addressInput2 && addressInput2.value,
+ getDisplayedAddress(inputs.addresses[2]),
+ "address two is displayed correctly"
+ );
+ Assert.equal(
+ addressInput3 && addressInput3.value,
+ getDisplayedAddress(inputs.addresses[3]),
+ "address three is displayed correctly"
+ );
+
+ let textInputs = mlDocument.querySelectorAll(".textbox-addressingWidget");
+ Assert.equal(textInputs.length, 4, "no extraneous addresses are displayed");
+
+ mlDocElement.getButton("cancel").click();
+ });
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(3).querySelector("span").textContent,
+ inputs.mlName + inputs.modification,
+ `mailing list ("${
+ inputs.mlName + inputs.modification
+ }") is still displayed in the address book list`
+ );
+
+ // Open the mailing list dialog, the callback above interacts with it.
+ global.booksList.selectedIndex = 3;
+ global.booksList.showPropertiesOfSelected();
+
+ await mailingListWindowPromise;
+});
+
+/**
+ * Tear down: delete the address book and close the address book window.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ let deletePromise = TestUtils.topicObserved("addrbook-directory-deleted");
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(2).querySelector("span").textContent,
+ inputs.abName,
+ `address book ("${inputs.abName}") is displayed in the address book list`
+ );
+
+ global.booksList.focus();
+ global.booksList.selectedIndex = 2;
+ EventUtils.sendKey("DELETE", global.abWindow);
+
+ await Promise.all([mailingListWindowPromise, deletePromise]);
+
+ let addressBook = MailServices.ab.directories.find(
+ directory => directory.dirName == inputs.abName
+ );
+
+ Assert.ok(!addressBook, "address book was deleted");
+
+ closeAddressBookWindow();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_open_actions.js b/comm/mail/components/addrbook/test/browser/browser_open_actions.js
new file mode 100644
index 0000000000..cb6f681ec8
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_open_actions.js
@@ -0,0 +1,157 @@
+/* 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 tabmail = document.getElementById("tabmail");
+let writableBook, writableCard, readOnlyBook, readOnlyCard;
+
+add_setup(function () {
+ writableBook = createAddressBook("writable book");
+ writableCard = writableBook.addCard(createContact("writable", "card"));
+
+ readOnlyBook = createAddressBook("read-only book");
+ readOnlyCard = readOnlyBook.addCard(createContact("read-only", "card"));
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ registerCleanupFunction(async function () {
+ await promiseDirectoryRemoved(writableBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+ });
+});
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+/**
+ * Tests than a `toAddressBook` call with no argument opens the Address Book.
+ * Then call it again with the tab open and check that it doesn't reload.
+ */
+add_task(async function testNoAction() {
+ let abWindow1 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ await notInEditingMode();
+
+ let abWindow2 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ Assert.equal(
+ abWindow2.browsingContext.currentWindowGlobal.innerWindowId,
+ abWindow1.browsingContext.currentWindowGlobal.innerWindowId,
+ "address book page did not reload"
+ );
+ await notInEditingMode();
+
+ tabmail.selectTabByIndex(undefined, 1);
+ let abWindow3 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ Assert.equal(
+ abWindow3.browsingContext.currentWindowGlobal.innerWindowId,
+ abWindow1.browsingContext.currentWindowGlobal.innerWindowId,
+ "address book page did not reload"
+ );
+ await notInEditingMode();
+
+ await closeAddressBookWindow();
+ Assert.equal(tabmail.tabInfo.length, 1);
+});
+
+/**
+ * Tests than a call to toAddressBook with only a create action opens the
+ * Address Book. A new blank card should open in edit mode.
+ */
+add_task(async function testCreateBlank() {
+ await window.toAddressBook({ action: "create" });
+ await inEditingMode();
+ // TODO check blank
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a create action and an email
+ * address opens the Address Book. A new card with the email address should
+ * open in edit mode.
+ */
+add_task(async function testCreateWithAddress() {
+ await window.toAddressBook({ action: "create", address: "test@invalid" });
+ await inEditingMode();
+ // TODO check address matches
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a create action and a vCard opens
+ * the Address Book. A new card should open in edit mode.
+ */
+add_task(async function testCreateWithVCard() {
+ await window.toAddressBook({
+ action: "create",
+ vCard:
+ "BEGIN:VCARD\r\nFN:a test person\r\nN:person;test;;a;\r\nEND:VCARD\r\n",
+ });
+ await inEditingMode();
+ // TODO check card matches
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a display action opens the Address
+ * Book. The card should be displayed.
+ */
+add_task(async function testDisplayCard() {
+ await window.toAddressBook({ action: "display", card: writableCard });
+ checkDirectoryDisplayed(writableBook);
+ await notInEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "writable contact");
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with an edit action and a writable card
+ * opens the Address Book. The card should open in edit mode.
+ */
+add_task(async function testEditCardWritable() {
+ await window.toAddressBook({ action: "edit", card: writableCard });
+ checkDirectoryDisplayed(writableBook);
+ await inEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "writable contact");
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with an edit action and a read-only card
+ * opens the Address Book. The card should open in display mode.
+ */
+add_task(async function testEditCardReadOnly() {
+ await window.toAddressBook({ action: "edit", card: readOnlyCard });
+ checkDirectoryDisplayed(readOnlyBook);
+ await notInEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "read-only contact");
+
+ await closeAddressBookWindow();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_search.js b/comm/mail/components/addrbook/test/browser/browser_search.js
new file mode 100644
index 0000000000..ab4f7a221f
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_search.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/. */
+
+add_task(async () => {
+ async function doSearch(searchString, ...expectedCards) {
+ let viewChangePromise = BrowserTestUtils.waitForEvent(
+ cardsList,
+ "viewchange"
+ );
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ if (searchString) {
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString(searchString, abWindow);
+ EventUtils.synthesizeKey("VK_RETURN", {}, abWindow);
+ } else {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ }
+
+ await viewChangePromise;
+ checkCardsListed(...expectedCards);
+ checkPlaceholders(
+ expectedCards.length ? [] : ["placeholderNoSearchResults"]
+ );
+ }
+
+ let cards = {};
+ let cardsToRemove = {
+ personal: [],
+ history: [],
+ };
+ for (let name of ["daniel", "jonathan", "nathan"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = personalBook.addCard(card);
+ cards[name] = card;
+ cardsToRemove.personal.push(card);
+ }
+ for (let name of ["danielle", "katherine", "natalie", "susanah"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = historyBook.addCard(card);
+ cards[name] = card;
+ cardsToRemove.history.push(card);
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ registerCleanupFunction(() => {
+ abWindow.close();
+ personalBook.deleteCards(cardsToRemove.personal);
+ historyBook.deleteCards(cardsToRemove.history);
+ });
+
+ let abDocument = abWindow.document;
+ let searchBox = abDocument.getElementById("searchInput");
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ Assert.equal(
+ abDocument.activeElement,
+ searchBox,
+ "search box was focused when the page loaded"
+ );
+
+ // All address books.
+
+ checkCardsListed(
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+ checkPlaceholders();
+
+ // Personal address book.
+
+ openDirectory(personalBook);
+ checkCardsListed(cards.daniel, cards.jonathan, cards.nathan);
+ checkPlaceholders();
+
+ await doSearch("daniel", cards.daniel);
+ await doSearch("nathan", cards.jonathan, cards.nathan);
+
+ // History address book.
+
+ openDirectory(historyBook);
+ checkCardsListed();
+ checkPlaceholders(["placeholderNoSearchResults"]);
+
+ await doSearch(
+ null,
+ cards.danielle,
+ cards.katherine,
+ cards.natalie,
+ cards.susanah
+ );
+
+ await doSearch("daniel", cards.danielle);
+ await doSearch("nathan");
+
+ // All address books.
+
+ openAllAddressBooks();
+ checkCardsListed(cards.jonathan, cards.nathan);
+ checkPlaceholders();
+
+ await doSearch(
+ null,
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+
+ await doSearch("daniel", cards.daniel, cards.danielle);
+ await doSearch("nathan", cards.jonathan, cards.nathan);
+ await doSearch(
+ null,
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_telemetry.js b/comm/mail/components/addrbook/test/browser/browser_telemetry.js
new file mode 100644
index 0000000000..36b73207c2
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_telemetry.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test telemetry related to address book.
+ */
+
+let { MailTelemetryForTests } = ChromeUtils.import(
+ "resource:///modules/MailGlue.jsm"
+);
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Test we're counting address books and contacts.
+ */
+add_task(async function test_address_book_count() {
+ Services.telemetry.clearScalars();
+
+ // Adding some address books and contracts.
+ let addrBook1 = createAddressBook("AB 1");
+ let addrBook2 = createAddressBook("AB 2");
+ let ldapBook = createAddressBook(
+ "LDAP Book",
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE
+ );
+
+ let contact1 = createContact("test1", "example");
+ let contact2 = createContact("test2", "example");
+ let contact3 = createContact("test3", "example");
+ addrBook1.addCard(contact1);
+ addrBook2.addCard(contact2);
+ addrBook2.addCard(contact3);
+
+ // Run the probe.
+ MailTelemetryForTests.reportAddressBookTypes();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.addressbook.addressbook_count"]["moz-abldapdirectory"],
+ 1,
+ "LDAP address book count must be correct"
+ );
+ Assert.equal(
+ scalars["tb.addressbook.addressbook_count"].jsaddrbook,
+ 4,
+ "JS address book count must be correct"
+ );
+ Assert.equal(
+ scalars["tb.addressbook.contact_count"].jsaddrbook,
+ 3,
+ "Contact count must be correct"
+ );
+
+ await promiseDirectoryRemoved(addrBook1.URI);
+ await promiseDirectoryRemoved(addrBook2.URI);
+ await promiseDirectoryRemoved(ldapBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/data/addressbook.sjs b/comm/mail/components/addrbook/test/browser/data/addressbook.sjs
new file mode 100644
index 0000000000..bd28437261
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/addressbook.sjs
@@ -0,0 +1,47 @@
+/* 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/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <getetag/>
+ // <getctag/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:"
+ xmlns:card="urn:ietf:params:xml:ns:carddav"
+ xmlns:cs="http://calendarserver.org/ns/">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <card:addressbook/>
+ </resourcetype>
+ <cs:getctag>0</cs:getctag>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <getetag/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs b/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs
new file mode 100644
index 0000000000..0380dee3ab
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs
@@ -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/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <displayname/>
+ // <current-user-privilege-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <displayname>Things found by DNS</displayname>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <card:addressbook/>
+ </resourcetype>
+ <displayname>You found me!</displayname>
+ <current-user-privilege-set>
+ <privilege>
+ <all/>
+ </privilege>
+ </current-user-privilege-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs b/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs
new file mode 100644
index 0000000000..640d2acc54
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Echoes request headers as JSON so a test can check what was sent.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setHeader("Content-Type", "application/json", false);
+
+ let headers = {};
+ let enumerator = request.headers;
+ while (enumerator.hasMoreElements()) {
+ let header = enumerator.getNext().data;
+ headers[header.toLowerCase()] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/dns.sjs b/comm/mail/components/addrbook/test/browser/data/dns.sjs
new file mode 100644
index 0000000000..11121cce7c
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/dns.sjs
@@ -0,0 +1,48 @@
+/* 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/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <displayname/>
+ // <current-user-principal/>
+ // <current-user-privilege-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/dns.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <current-user-principal>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/principal.sjs</href>
+ </current-user-principal>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-principal/>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/photo1.jpg b/comm/mail/components/addrbook/test/browser/data/photo1.jpg
new file mode 100644
index 0000000000..35608787bf
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/photo1.jpg
Binary files differ
diff --git a/comm/mail/components/addrbook/test/browser/data/photo2.jpg b/comm/mail/components/addrbook/test/browser/data/photo2.jpg
new file mode 100644
index 0000000000..41fd1e90fc
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/photo2.jpg
Binary files differ
diff --git a/comm/mail/components/addrbook/test/browser/data/principal.sjs b/comm/mail/components/addrbook/test/browser/data/principal.sjs
new file mode 100644
index 0000000000..659cd3cd91
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/principal.sjs
@@ -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/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <addressbook-home-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/principal.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <principal/>
+ </resourcetype>
+ <card:addressbook-home-set>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs</href>
+ </card:addressbook-home-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs b/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs
new file mode 100644
index 0000000000..a9285c21d0
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Serves as the authorisation endpoint for OAuth2 testing.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams", "URL"]);
+
+function handleRequest(request, response) {
+ let params = new URLSearchParams(request.queryString);
+
+ if (request.method == "POST") {
+ response.setStatusLine(request.httpVersion, 303, "Redirected");
+ } else {
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ }
+
+ let url = new URL(params.get("redirect_uri"));
+ url.searchParams.set("code", "success");
+ response.setHeader("Location", url.href);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/token.sjs b/comm/mail/components/addrbook/test/browser/data/token.sjs
new file mode 100644
index 0000000000..e070f8d55f
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/token.sjs
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Serves as the token endpoint for OAuth2 testing.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+ let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(request.bodyInputStream);
+
+ let input = stream.readBytes(request.bodyInputStream.available());
+ let params = new URLSearchParams(input);
+
+ response.setHeader("Content-Type", "application/json", false);
+
+ if (params.get("refresh_token") == "expired_token") {
+ response.setStatusLine("1.1", 400, "Bad Request");
+ response.write(JSON.stringify({ error: "invalid_grant" }));
+ return;
+ }
+
+ let data = { access_token: "bobs_access_token" };
+
+ if (params.get("code") == "success") {
+ // Authorisation just happened, set a different access token so the test
+ // can detect it, and provide a refresh token.
+ data.access_token = "new_access_token";
+ data.refresh_token = "new_refresh_token";
+ }
+
+ response.write(JSON.stringify(data));
+}
diff --git a/comm/mail/components/addrbook/test/browser/head.js b/comm/mail/components/addrbook/test/browser/head.js
new file mode 100644
index 0000000000..37fc445410
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/head.js
@@ -0,0 +1,445 @@
+/* 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"
+);
+
+const personalBook = MailServices.ab.getDirectoryFromId("ldap_2.servers.pab");
+const historyBook = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.history"
+);
+
+add_setup(async () => {
+ // Force the window to be full screen to avoid issues with buttons not being
+ // reachable. This is a temporary solution while we update the details pane
+ // UI to be properly responsive and wrap elements correctly.
+ window.fullScreen = true;
+});
+
+// We want to check that everything has been removed/reset, but if we register
+// a cleanup function here, it will run before any other cleanup function has
+// had a chance to run. Instead, when it runs register another cleanup
+// function which will run last.
+registerCleanupFunction(function () {
+ registerCleanupFunction(async function () {
+ Assert.equal(
+ MailServices.ab.directories.length,
+ 2,
+ "Only Personal ab and Collected Addresses should be left."
+ );
+ for (let directory of MailServices.ab.directories) {
+ if (
+ directory.dirPrefId == "ldap_2.servers.history" ||
+ directory.dirPrefId == "ldap_2.servers.pab"
+ ) {
+ Assert.equal(
+ directory.childCardCount,
+ 0,
+ `All contacts should have been removed from ${directory.dirName}`
+ );
+ if (directory.childCardCount) {
+ directory.deleteCards(directory.childCards);
+ }
+ } else {
+ await promiseDirectoryRemoved(directory.URI);
+ }
+ }
+ closeAddressBookWindow();
+
+ // TODO: convert this to UID.
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURI");
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURIisDefault");
+
+ // 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();
+ // Reset the window to its default size.
+ window.fullScreen = false;
+ });
+});
+
+async function openAddressBookWindow() {
+ return new Promise(resolve => {
+ window.openTab("addressBookTab", {
+ onLoad(event, browser) {
+ resolve(browser.contentWindow);
+ },
+ });
+ });
+}
+
+function closeAddressBookWindow() {
+ let abTab = getAddressBookTab();
+ if (abTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(abTab);
+ }
+}
+
+function getAddressBookTab() {
+ let tabmail = document.getElementById("tabmail");
+ return tabmail.tabInfo.find(
+ t => t.browser?.currentURI.spec == "about:addressbook"
+ );
+}
+
+function getAddressBookWindow() {
+ let tab = getAddressBookTab();
+ return tab?.browser.contentWindow;
+}
+
+async function openAllAddressBooks() {
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.querySelector("#books > li"),
+ {},
+ abWindow
+ );
+ await new Promise(r => abWindow.setTimeout(r));
+}
+
+function openDirectory(directory) {
+ let abWindow = getAddressBookWindow();
+ let row = abWindow.booksList.getRowForUID(directory.UID);
+ EventUtils.synthesizeMouseAtCenter(row.querySelector("span"), {}, abWindow);
+}
+
+function createAddressBook(dirName, type = Ci.nsIAbManager.JS_DIRECTORY_TYPE) {
+ let prefName = MailServices.ab.newAddressBook(dirName, null, type);
+ return MailServices.ab.getDirectoryFromId(prefName);
+}
+
+async function createAddressBookWithUI(abName) {
+ let newAddressBookPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml"
+ );
+
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.getElementById("toolbarCreateBook"),
+ {},
+ abWindow
+ );
+
+ let abNameDialog = await newAddressBookPromise;
+ EventUtils.sendString(abName, abNameDialog);
+ abNameDialog.document.querySelector("dialog").getButton("accept").click();
+
+ let addressBook = MailServices.ab.directories.find(
+ directory => directory.dirName == abName
+ );
+
+ Assert.ok(addressBook, "a new address book was created");
+
+ // At this point we need to wait for the UI to update.
+ await new Promise(r => abWindow.setTimeout(r));
+
+ return addressBook;
+}
+
+function createContact(firstName, lastName, displayName, primaryEmail) {
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.displayName = displayName ?? `${firstName} ${lastName}`;
+ contact.firstName = firstName;
+ contact.lastName = lastName;
+ contact.primaryEmail =
+ primaryEmail ?? `${firstName}.${lastName}@invalid`.toLowerCase();
+ return contact;
+}
+
+function createMailingList(name) {
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(
+ Ci.nsIAbDirectory
+ );
+ list.isMailList = true;
+ list.dirName = name;
+ return list;
+}
+
+async function createMailingListWithUI(mlParent, mlName) {
+ openDirectory(mlParent);
+
+ let newAddressBookPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ );
+
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.getElementById("toolbarCreateList"),
+ {},
+ abWindow
+ );
+
+ let abListDialog = await newAddressBookPromise;
+ let abListDocument = abListDialog.document;
+ await new Promise(resolve => abListDialog.setTimeout(resolve));
+
+ abListDocument.getElementById("abPopup").value = mlParent.URI;
+ abListDocument.getElementById("ListName").value = mlName;
+ abListDocument.querySelector("dialog").getButton("accept").click();
+
+ let list = mlParent.childNodes.find(list => list.dirName == mlName);
+
+ Assert.ok(list, "a new list was created");
+
+ // At this point we need to wait for the UI to update.
+ await new Promise(r => abWindow.setTimeout(r));
+
+ return list;
+}
+
+function checkDirectoryDisplayed(directory) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ if (directory) {
+ Assert.equal(
+ booksList.selectedIndex,
+ booksList.getIndexForUID(directory.UID)
+ );
+ Assert.equal(cardsList.view.directory?.UID, directory.UID);
+ } else {
+ Assert.equal(booksList.selectedIndex, 0);
+ Assert.ok(!cardsList.view.directory);
+ }
+}
+
+function checkCardsListed(...expectedCards) {
+ checkNamesListed(
+ ...expectedCards.map(card =>
+ card.isMailList ? card.dirName : card.displayName
+ )
+ );
+
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ for (let i = 0; i < expectedCards.length; i++) {
+ let row = cardsList.getRowAtIndex(i);
+ Assert.equal(
+ row.classList.contains("MailList"),
+ expectedCards[i].isMailList,
+ `row ${
+ expectedCards[i].isMailList ? "should" : "should not"
+ } be a mailing list row`
+ );
+ Assert.equal(
+ row.address.textContent,
+ expectedCards[i].primaryEmail ?? "",
+ "correct address should be displayed"
+ );
+ Assert.equal(
+ row.avatar.childElementCount,
+ 1,
+ "only one avatar image should be displayed"
+ );
+ }
+}
+
+function checkNamesListed(...expectedNames) {
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ let expectedCount = expectedNames.length;
+
+ Assert.equal(
+ cardsList.view.rowCount,
+ expectedCount,
+ "Tree view has the right number of rows"
+ );
+
+ for (let i = 0; i < expectedCount; i++) {
+ Assert.equal(
+ cardsList.view.getCellText(i, { id: "GeneratedName" }),
+ expectedNames[i],
+ "view should give the correct name"
+ );
+ Assert.equal(
+ cardsList.getRowAtIndex(i).querySelector(".generatedname-column, .name")
+ .textContent,
+ expectedNames[i],
+ "correct name should be displayed"
+ );
+ }
+}
+
+function checkPlaceholders(expectedVisible = []) {
+ let abWindow = getAddressBookWindow();
+ let placeholder = abWindow.cardsPane.cardsList.placeholder;
+
+ if (!expectedVisible.length) {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(placeholder),
+ "placeholders are hidden"
+ );
+ return;
+ }
+
+ for (let element of placeholder.children) {
+ let id = element.id;
+ if (expectedVisible.includes(id)) {
+ Assert.ok(BrowserTestUtils.is_visible(element), `${id} is visible`);
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(element), `${id} is hidden`);
+ }
+ }
+}
+
+async function showSortMenu(name, value) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let displayButton = abDocument.getElementById("displayButton");
+ let sortContext = abDocument.getElementById("sortContext");
+ let shownPromise = BrowserTestUtils.waitForEvent(sortContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(sortContext, "popuphidden");
+ sortContext.activateItem(
+ sortContext.querySelector(`[name="${name}"][value="${value}"]`)
+ );
+ if (name == "toggle") {
+ sortContext.hidePopup();
+ }
+ await hiddenPromise;
+}
+
+async function showPickerMenu(name, value) {
+ let abWindow = getAddressBookWindow();
+ let cardsHeader = abWindow.cardsPane.table.header;
+ let pickerButton = cardsHeader.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ );
+ let menupopup = cardsHeader.querySelector(
+ `th[is="tree-view-table-column-picker"] menupopup`
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(pickerButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ menupopup.activateItem(
+ menupopup.querySelector(`[name="${name}"][value="${value}"]`)
+ );
+ if (name == "toggle") {
+ menupopup.hidePopup();
+ }
+ await hiddenPromise;
+}
+
+async function toggleLayout() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let displayButton = abDocument.getElementById("displayButton");
+ let sortContext = abDocument.getElementById("sortContext");
+ let shownPromise = BrowserTestUtils.waitForEvent(sortContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(sortContext, "popuphidden");
+ sortContext.activateItem(abDocument.getElementById("sortContextTableLayout"));
+ await hiddenPromise;
+}
+
+async function checkComposeWindow(composeWindow, ...expectedAddresses) {
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ let composeDocument = composeWindow.document;
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+
+ let pills = toAddrRow.querySelectorAll("mail-address-pill");
+ Assert.equal(pills.length, expectedAddresses.length);
+ for (let i = 0; i < expectedAddresses.length; i++) {
+ Assert.equal(pills[i].label, expectedAddresses[i]);
+ }
+
+ await Promise.all([
+ BrowserTestUtils.closeWindow(composeWindow),
+ BrowserTestUtils.waitForEvent(window, "activate"),
+ ]);
+}
+
+function promiseDirectoryRemoved(uri) {
+ let removePromise = TestUtils.topicObserved("addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(uri);
+ return removePromise;
+}
+
+function promiseLoadSubDialog(url) {
+ let abWindow = getAddressBookWindow();
+
+ return new Promise((resolve, reject) => {
+ abWindow.SubDialog._dialogStack.addEventListener(
+ "dialogopen",
+ function dialogopen(aEvent) {
+ if (
+ aEvent.detail.dialog._frame.contentWindow.location == "about:blank"
+ ) {
+ return;
+ }
+ abWindow.SubDialog._dialogStack.removeEventListener(
+ "dialogopen",
+ dialogopen
+ );
+
+ Assert.equal(
+ aEvent.detail.dialog._frame.contentWindow.location.toString(),
+ url,
+ "Check the proper URL is loaded"
+ );
+
+ // Check visibility
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ aEvent.detail.dialog._overlay,
+ "Overlay is visible"
+ )
+ );
+
+ // Check that stylesheets were injected
+ let expectedStyleSheetURLs =
+ aEvent.detail.dialog._injectedStyleSheets.slice(0);
+ for (let styleSheet of aEvent.detail.dialog._frame.contentDocument
+ .styleSheets) {
+ let i = expectedStyleSheetURLs.indexOf(styleSheet.href);
+ if (i >= 0) {
+ info("found " + styleSheet.href);
+ expectedStyleSheetURLs.splice(i, 1);
+ }
+ }
+ Assert.equal(
+ expectedStyleSheetURLs.length,
+ 0,
+ "All expectedStyleSheetURLs should have been found"
+ );
+
+ // Wait for the next event tick to make sure the remaining part of the
+ // testcase runs after the dialog gets ready for input.
+ executeSoon(() => resolve(aEvent.detail.dialog._frame.contentWindow));
+ }
+ );
+ });
+}
+
+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("");
+}
diff --git a/comm/mail/components/cloudfile/cloudFileAccounts.jsm b/comm/mail/components/cloudfile/cloudFileAccounts.jsm
new file mode 100644
index 0000000000..3cb478f60f
--- /dev/null
+++ b/comm/mail/components/cloudfile/cloudFileAccounts.jsm
@@ -0,0 +1,215 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["cloudFileAccounts"];
+
+var ACCOUNT_ROOT = "mail.cloud_files.accounts.";
+
+var { EventEmitter } = ChromeUtils.importESModule(
+ "resource://gre/modules/EventEmitter.sys.mjs"
+);
+
+var cloudFileAccounts = new (class extends EventEmitter {
+ get constants() {
+ return {
+ offlineErr: 0x80550014, // NS_MSG_ERROR_OFFLINE
+ authErr: 0x8055001e, // NS_MSG_USER_NOT_AUTHENTICATED
+ uploadErr: 0x8055311a, // NS_MSG_ERROR_ATTACHING_FILE
+ uploadWouldExceedQuota: 0x8055311b,
+ uploadExceedsFileLimit: 0x8055311c,
+ uploadCancelled: 0x8055311d,
+ uploadErrWithCustomMessage: 0x8055311f,
+ renameErr: 0x80553120,
+ renameErrWithCustomMessage: 0x80553121,
+ renameNotSupported: 0x80553122,
+ deleteErr: 0x80553123,
+ attachmentErr: 0x80553124,
+ accountErr: 0x80553125,
+ };
+ }
+
+ constructor() {
+ super();
+ this._providers = new Map();
+ this._accounts = new Map();
+ this._highestOrdinal = 0;
+ }
+
+ get _accountKeys() {
+ let accountKeySet = new Set();
+ let branch = Services.prefs.getBranch(ACCOUNT_ROOT);
+ let children = branch.getChildList("");
+ for (let child of children) {
+ let subbranch = child.substr(0, child.indexOf("."));
+ accountKeySet.add(subbranch);
+
+ let match = /^account(\d+)$/.exec(subbranch);
+ if (match) {
+ let ordinal = parseInt(match[1], 10);
+ this._highestOrdinal = Math.max(this._highestOrdinal, ordinal);
+ }
+ }
+
+ // TODO: sort by ordinal
+ return accountKeySet.keys();
+ }
+
+ /**
+ * Ensure that we have the account key for an account. If we already have the
+ * key, just return it. If we have the account, get the key from it.
+ *
+ * @param aKeyOrAccount the key or the account object
+ * @returns the account key
+ */
+ _ensureKey(aKeyOrAccount) {
+ if (typeof aKeyOrAccount == "string") {
+ return aKeyOrAccount;
+ }
+ if ("accountKey" in aKeyOrAccount) {
+ return aKeyOrAccount.accountKey;
+ }
+ throw new Error("String or cloud file account expected");
+ }
+
+ /**
+ * Register a cloudfile provider, e.g. from an extension.
+ *
+ * @param {object} The implementation to register
+ */
+ registerProvider(aType, aProvider) {
+ if (this._providers.has(aType)) {
+ throw new Error(`Cloudfile provider ${aType} is already registered`);
+ }
+ this._providers.set(aType, aProvider);
+ this.emit("providerRegistered", aProvider);
+ }
+
+ /**
+ * Unregister a cloudfile provider.
+ *
+ * @param {string} aType - The provider type to unregister
+ */
+ unregisterProvider(aType) {
+ if (!this._providers.has(aType)) {
+ throw new Error(`Cloudfile provider ${aType} is not registered`);
+ }
+
+ for (let account of this.getAccountsForType(aType)) {
+ this._accounts.delete(account.accountKey);
+ }
+
+ this._providers.delete(aType);
+ this.emit("providerUnregistered", aType);
+ }
+
+ get providers() {
+ return [...this._providers.values()];
+ }
+
+ getProviderForType(aType) {
+ return this._providers.get(aType);
+ }
+
+ createAccount(aType) {
+ this._highestOrdinal++;
+ let key = "account" + this._highestOrdinal;
+
+ try {
+ let provider = this.getProviderForType(aType);
+ let account = provider.initAccount(key);
+
+ Services.prefs.setCharPref(ACCOUNT_ROOT + key + ".type", aType);
+ Services.prefs.setCharPref(
+ ACCOUNT_ROOT + key + ".displayName",
+ account.displayName
+ );
+
+ this._accounts.set(key, account);
+ this.emit("accountAdded", account);
+ return account;
+ } catch (e) {
+ for (let prefName of Services.prefs.getChildList(
+ `${ACCOUNT_ROOT}${key}.`
+ )) {
+ Services.prefs.clearUserPref(prefName);
+ }
+ throw e;
+ }
+ }
+
+ removeAccount(aKeyOrAccount) {
+ let key = this._ensureKey(aKeyOrAccount);
+ let type = Services.prefs.getCharPref(ACCOUNT_ROOT + key + ".type");
+
+ this._accounts.delete(key);
+ for (let prefName of Services.prefs.getChildList(
+ `${ACCOUNT_ROOT}${key}.`
+ )) {
+ Services.prefs.clearUserPref(prefName);
+ }
+
+ this.emit("accountDeleted", key, type);
+ }
+
+ get accounts() {
+ let arr = [];
+ for (let key of this._accountKeys) {
+ let account = this.getAccount(key);
+ if (account) {
+ arr.push(account);
+ }
+ }
+ return arr;
+ }
+
+ get configuredAccounts() {
+ return this.accounts.filter(account => account.configured);
+ }
+
+ getAccount(aKey) {
+ if (this._accounts.has(aKey)) {
+ return this._accounts.get(aKey);
+ }
+
+ let type = Services.prefs.getCharPref(ACCOUNT_ROOT + aKey + ".type", "");
+ if (type) {
+ let provider = this.getProviderForType(type);
+ if (provider) {
+ let account = provider.initAccount(aKey);
+ this._accounts.set(aKey, account);
+ return account;
+ }
+ }
+ return null;
+ }
+
+ getAccountsForType(aType) {
+ let result = [];
+
+ for (let accountKey of this._accountKeys) {
+ let type = Services.prefs.getCharPref(
+ ACCOUNT_ROOT + accountKey + ".type"
+ );
+ if (type === aType) {
+ result.push(this.getAccount(accountKey));
+ }
+ }
+
+ return result;
+ }
+
+ getDisplayName(aKeyOrAccount) {
+ // If no display name has been set, we return the empty string.
+ let key = this._ensureKey(aKeyOrAccount);
+ return Services.prefs.getCharPref(ACCOUNT_ROOT + key + ".displayName", "");
+ }
+
+ setDisplayName(aKeyOrAccount, aDisplayName) {
+ let key = this._ensureKey(aKeyOrAccount);
+ Services.prefs.setCharPref(
+ ACCOUNT_ROOT + key + ".displayName",
+ aDisplayName
+ );
+ }
+})();
diff --git a/comm/mail/components/cloudfile/content/selectDialog.js b/comm/mail/components/cloudfile/content/selectDialog.js
new file mode 100644
index 0000000000..4c49d11aa3
--- /dev/null
+++ b/comm/mail/components/cloudfile/content/selectDialog.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/. */
+
+/* import-globals-from ../../../../../toolkit/components/prompts/content/selectDialog.js */
+
+function cloudfileDialogOnLoad() {
+ let icons = propBag.getProperty("icons");
+ let listItems = listBox.itemChildren;
+ for (let i = 0; i < listItems.length; i++) {
+ listItems[i].setAttribute("align", "center");
+ let image = document.createElement("img");
+ image.setAttribute("src", icons[i]);
+ image.setAttribute("alt", "");
+ listItems[i].insertBefore(image, listItems[i].firstElementChild);
+ }
+}
diff --git a/comm/mail/components/cloudfile/content/selectDialog.xhtml b/comm/mail/components/cloudfile/content/selectDialog.xhtml
new file mode 100644
index 0000000000..3f99fea350
--- /dev/null
+++ b/comm/mail/components/cloudfile/content/selectDialog.xhtml
@@ -0,0 +1,32 @@
+<?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://messenger/skin/cloudfileSelectDialog.css" type="text/css"?>
+<!DOCTYPE window>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="cloudfileDialogOnLoad();"
+>
+ <dialog>
+ <script
+ type="application/javascript"
+ src="chrome://messenger/content/cloudfile/selectDialog.js"
+ />
+ <script
+ type="application/javascript"
+ src="chrome://global/content/selectDialog.js"
+ />
+ <keyset id="dialogKeys" />
+ <vbox style="width: 24em; margin: 5px">
+ <label id="info.txt" />
+ <vbox>
+ <richlistbox id="list" class="theme-listbox" style="height: 8em" />
+ </vbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/cloudfile/jar.mn b/comm/mail/components/cloudfile/jar.mn
new file mode 100644
index 0000000000..ad4eeb3065
--- /dev/null
+++ b/comm/mail/components/cloudfile/jar.mn
@@ -0,0 +1,7 @@
+# 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/cloudfile/selectDialog.js (content/selectDialog.js)
+ content/messenger/cloudfile/selectDialog.xhtml (content/selectDialog.xhtml)
diff --git a/comm/mail/components/cloudfile/moz.build b/comm/mail/components/cloudfile/moz.build
new file mode 100644
index 0000000000..f456f7ad85
--- /dev/null
+++ b/comm/mail/components/cloudfile/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# 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_JS_MODULES += [
+ "cloudFileAccounts.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/cloudfile/test/browser/browser.ini b/comm/mail/components/cloudfile/test/browser/browser.ini
new file mode 100644
index 0000000000..8f4b1a954a
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = files/icon.svg files/management.html
+
+[browser_repeat_upload.js]
+support-files = files/green_eggs.txt
diff --git a/comm/mail/components/cloudfile/test/browser/browser_repeat_upload.js b/comm/mail/components/cloudfile/test/browser/browser_repeat_upload.js
new file mode 100644
index 0000000000..7390354a8c
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/browser_repeat_upload.js
@@ -0,0 +1,246 @@
+/* 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 ../../../../base/content/mailWindowOverlay.js */
+
+let { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+const ICON_URL = getRootDirectory(gTestPath) + "files/icon.svg";
+const MANAGEMENT_URL = getRootDirectory(gTestPath) + "files/management.html";
+
+function getFileFromChromeURL(leafName) {
+ let ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ let url = Services.io.newURI(
+ getRootDirectory(gTestPath) + "files/" + leafName
+ );
+ let fileURL = ChromeRegistry.convertChromeURL(url).QueryInterface(
+ Ci.nsIFileURL
+ );
+ return fileURL.file;
+}
+
+add_task(async () => {
+ let uploadedFiles = [];
+ let provider = {
+ type: "Mochitest",
+ displayName: "Mochitest",
+ iconURL: ICON_URL,
+ initAccount(accountKey) {
+ return {
+ accountKey,
+ type: "Mochitest",
+ get displayName() {
+ return Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${this.accountKey}.displayName`,
+ "Mochitest Account"
+ );
+ },
+ getPreviousUploads() {
+ return uploadedFiles;
+ },
+ urlForFile(file) {
+ return "https://mochi.test/" + file.leafName;
+ },
+ iconURL: ICON_URL,
+ configured: true,
+ managementURL: MANAGEMENT_URL,
+ reuseUploads: true,
+ };
+ },
+ };
+
+ Assert.equal(
+ cloudFileAccounts.configuredAccounts.length,
+ 0,
+ "Should have no cloudfile accounts starting off."
+ );
+
+ cloudFileAccounts.registerProvider(provider.type, provider);
+ let account = cloudFileAccounts.createAccount(provider.type);
+ Assert.equal(
+ cloudFileAccounts.configuredAccounts.length,
+ 1,
+ "Should have only the one account we created."
+ );
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MsgNewMessage();
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == composeWindow
+ );
+ let composeDocument = composeWindow.document;
+
+ // Compose window loaded.
+ // Check the attach dropdown has our account as a <menuitem>.
+
+ let toolbarButton = composeDocument.getElementById("button-attach");
+ let rect = toolbarButton.getBoundingClientRect();
+ EventUtils.synthesizeMouse(
+ toolbarButton,
+ rect.width - 5,
+ 5,
+ { clickCount: 1 },
+ composeWindow
+ );
+ await promiseAnimationFrame(composeWindow);
+
+ let menu = composeDocument.getElementById(
+ "button-attachPopup_attachCloudMenu"
+ );
+ ok(!BrowserTestUtils.is_hidden(menu));
+
+ let popupshown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(menu, { clickCount: 1 }, composeWindow);
+ await popupshown;
+
+ Assert.equal(
+ cloudFileAccounts.configuredAccounts.length,
+ 1,
+ "Should still have one registered account."
+ );
+
+ let menuitems = menu.menupopup.children;
+ is(menuitems.length, 1);
+ is(menuitems[0].getAttribute("image"), ICON_URL);
+ is(menuitems[0].getAttribute("label"), "Mochitest Account\u2026");
+
+ composeDocument.getElementById("button-attachPopup").hidePopup();
+
+ // Pretend we uploaded some files before.
+
+ uploadedFiles = [
+ {
+ id: 1,
+ name: "green_eggs.txt",
+ path: getFileFromChromeURL("green_eggs.txt").path,
+ size: 30,
+ url: "https://mochi.test/green_eggs.txt",
+ serviceName: "MyCloud",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ },
+ {
+ id: 2,
+ name: "ham.zip",
+ path: getFileFromChromeURL("ham.zip").path,
+ size: 1234,
+ url: "https://mochi.test/ham.zip",
+ },
+ ];
+ is(account.getPreviousUploads().length, 2);
+
+ // Check the attach dropdown has our account as a <menu>.
+
+ await new Promise(resolve => {
+ toolbarButton.addEventListener("popupshown", resolve, { once: true });
+ EventUtils.synthesizeMouse(
+ toolbarButton,
+ rect.width - 5,
+ 5,
+ { clickCount: 1 },
+ composeWindow
+ );
+ });
+ info("toolbar button menu opened");
+ await promiseAnimationFrame(composeWindow);
+
+ await new Promise(resolve => {
+ menu.menupopup.addEventListener("popupshown", resolve, { once: true });
+ EventUtils.synthesizeMouseAtCenter(menu, { clickCount: 1 }, composeWindow);
+ });
+ info("file link menu opened");
+ await promiseAnimationFrame(composeWindow);
+
+ menuitems = menu.menupopup.children;
+ is(menuitems.length, 2);
+ is(menuitems[0].getAttribute("image"), ICON_URL);
+ is(menuitems[0].getAttribute("label"), "Mochitest Account\u2026");
+ is(menuitems[1].localName, "menuitem");
+ is(menuitems[1].getAttribute("image"), "moz-icon://green_eggs.txt");
+ is(menuitems[1].getAttribute("label"), "green_eggs.txt");
+ // TODO: Enable this when we handle files that no longer exist on the filesystem.
+ // is(menuitems[2].localName, "menuitem");
+ // is(menuitems[2].getAttribute("image"), "moz-icon://ham.zip");
+ // is(menuitems[2].getAttribute("label"), "ham.zip");
+
+ // Select one of the previously-uploaded items and check the attachment is added.
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ await new Promise(resolve => {
+ bucket.addEventListener("attachments-added", resolve, { once: true });
+ menu.menupopup.activateItem(menuitems[1]);
+ });
+ info("attachment added");
+ await promiseAnimationFrame(composeWindow);
+ ok(toolbarButton.open === false);
+
+ is(bucket.itemCount, 1);
+ let attachment = bucket.itemChildren[0];
+ is(attachment.getAttribute("name"), "green_eggs.txt");
+ ok(attachment.attachment.sendViaCloud);
+ is(attachment.attachment.cloudFileAccountKey, account.accountKey);
+ is(
+ attachment.attachment.contentLocation,
+ "https://mochi.test/green_eggs.txt"
+ );
+
+ is(
+ attachment.querySelector("img.attachmentcell-icon").src,
+ uploadedFiles[0].serviceIcon,
+ "CloudFile icon should be correct."
+ );
+
+ // Check the content of the editor for the added template.
+ let editor = composeWindow.GetCurrentEditor();
+ let urls = editor.document.querySelectorAll(
+ "body > #cloudAttachmentListRoot > #cloudAttachmentList"
+ );
+ Assert.equal(urls.length, 1, "Found 1 FileLink template in the document.");
+
+ // Template is added asynchronously.
+ await TestUtils.waitForCondition(() => urls[0].querySelector("li"));
+ Assert.equal(
+ urls[0].querySelector(".cloudfile-name").textContent,
+ "green_eggs.txt",
+ "The name of the cloud file in the template should be correct."
+ );
+
+ Assert.equal(
+ urls[0].querySelector(".cloudfile-name").href,
+ "https://mochi.test/green_eggs.txt",
+ "The URL attached to the name of the cloud file in the template should be correct."
+ );
+
+ Assert.equal(
+ urls[0].querySelector(".cloudfile-service-name").textContent,
+ "MyCloud",
+ "The used service name in the template should be correct."
+ );
+
+ Assert.equal(
+ urls[0].querySelector(".cloudfile-service-icon").src,
+ "data:image/svg+xml;filename=globe.svg;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGZpbGw9ImNvbnRleHQtZmlsbCIgZD0iTTggMGE4IDggMCAxIDAgOCA4IDguMDA5IDguMDA5IDAgMCAwLTgtOHptNS4xNjMgNC45NThoLTEuNTUyYTcuNyA3LjcgMCAwIDAtMS4wNTEtMi4zNzYgNi4wMyA2LjAzIDAgMCAxIDIuNjAzIDIuMzc2ek0xNCA4YTUuOTYzIDUuOTYzIDAgMCAxLS4zMzUgMS45NThoLTEuODIxQTEyLjMyNyAxMi4zMjcgMCAwIDAgMTIgOGExMi4zMjcgMTIuMzI3IDAgMCAwLS4xNTYtMS45NThoMS44MjFBNS45NjMgNS45NjMgMCAwIDEgMTQgOHptLTYgNmMtMS4wNzUgMC0yLjAzNy0xLjItMi41NjctMi45NThoNS4xMzVDMTAuMDM3IDEyLjggOS4wNzUgMTQgOCAxNHpNNS4xNzQgOS45NThhMTEuMDg0IDExLjA4NCAwIDAgMSAwLTMuOTE2aDUuNjUxQTExLjExNCAxMS4xMTQgMCAwIDEgMTEgOGExMS4xMTQgMTEuMTE0IDAgMCAxLS4xNzQgMS45NTh6TTIgOGE1Ljk2MyA1Ljk2MyAwIDAgMSAuMzM1LTEuOTU4aDEuODIxYTEyLjM2MSAxMi4zNjEgMCAwIDAgMCAzLjkxNkgyLjMzNUE1Ljk2MyA1Ljk2MyAwIDAgMSAyIDh6bTYtNmMxLjA3NSAwIDIuMDM3IDEuMiAyLjU2NyAyLjk1OEg1LjQzM0M1Ljk2MyAzLjIgNi45MjUgMiA4IDJ6bS0yLjU2LjU4MmE3LjcgNy43IDAgMCAwLTEuMDUxIDIuMzc2SDIuODM3QTYuMDMgNi4wMyAwIDAgMSA1LjQ0IDIuNTgyem0tMi42IDguNDZoMS41NDlhNy43IDcuNyAwIDAgMCAxLjA1MSAyLjM3NiA2LjAzIDYuMDMgMCAwIDEtMi42MDMtMi4zNzZ6bTcuNzIzIDIuMzc2YTcuNyA3LjcgMCAwIDAgMS4wNTEtMi4zNzZoMS41NTJhNi4wMyA2LjAzIDAgMCAxLTIuNjA2IDIuMzc2eiI+PC9wYXRoPgo8L3N2Zz4K",
+ "The used service icon should be correct."
+ );
+
+ // clean up
+ cloudFileAccounts.removeAccount(account);
+ cloudFileAccounts.unregisterProvider(provider.type);
+ Assert.equal(
+ cloudFileAccounts.configuredAccounts.length,
+ 0,
+ "Should leave no cloudfile accounts when done"
+ );
+ composeWindow.close();
+
+ // Request focus on something in the main window so the test doesn't time
+ // out waiting for focus.
+ document.getElementById("button-appmenu").focus();
+});
diff --git a/comm/mail/components/cloudfile/test/browser/files/green_eggs.txt b/comm/mail/components/cloudfile/test/browser/files/green_eggs.txt
new file mode 100644
index 0000000000..058318befb
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/files/green_eggs.txt
@@ -0,0 +1 @@
+I do not like them, Sam I Am!
diff --git a/comm/mail/components/cloudfile/test/browser/files/icon.svg b/comm/mail/components/cloudfile/test/browser/files/icon.svg
new file mode 100644
index 0000000000..6c1a552445
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/files/icon.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+ <circle cx="8" cy="8" r="7.5" fill="#ffffff" stroke="#00aa00" stroke-width="1.5"/>
+ <circle cx="5" cy="6" r="1.5" fill="#00aa00"/>
+ <circle cx="11" cy="6" r="1.5" fill="#00aa00"/>
+ <path d="M 12.83,9.30 C 12.24,11.48 10.26,13 8,13 5.75,13 3.74,11.48 3.17,9.29" fill="none" stroke="#00aa00" stroke-width="1.5"/>
+</svg>
diff --git a/comm/mail/components/cloudfile/test/browser/files/management.html b/comm/mail/components/cloudfile/test/browser/files/management.html
new file mode 100644
index 0000000000..5a51891fd7
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/files/management.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title></title>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/comm/mail/components/cloudfile/test/browser/head.js b/comm/mail/components/cloudfile/test/browser/head.js
new file mode 100644
index 0000000000..3dd17de883
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/head.js
@@ -0,0 +1,48 @@
+/* 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(async function () {
+ let gAccount = createAccount();
+ addIdentity(gAccount);
+ let rootFolder = gAccount.incomingServer.rootFolder;
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(rootFolder.URI);
+ await new Promise(resolve => executeSoon(resolve));
+});
+
+function createAccount() {
+ registerCleanupFunction(() => {
+ MailServices.accounts.accounts.forEach(cleanUpAccount);
+ });
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ info(`Created account ${account.toString()}`);
+
+ return account;
+}
+
+function cleanUpAccount(account) {
+ info(`Cleaning up account ${account.toString()}`);
+ MailServices.accounts.removeAccount(account, true);
+}
+
+function addIdentity(account) {
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "mochitest@localhost";
+ account.addIdentity(identity);
+ account.defaultIdentity = identity;
+ info(`Created identity ${identity.toString()}`);
+}
+
+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));
+}
diff --git a/comm/mail/components/components.conf b/comm/mail/components/components.conf
new file mode 100644
index 0000000000..e68428af4d
--- /dev/null
+++ b/comm/mail/components/components.conf
@@ -0,0 +1,76 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ "cid": "{8cc51368-6aa0-43e8-b762-bde9b9fd828c}",
+ "contract_ids": [
+ "@mozilla.org/network/protocol/about;1?what=newserror",
+ "@mozilla.org/network/protocol/about;1?what=rights",
+ "@mozilla.org/network/protocol/about;1?what=preferences",
+ "@mozilla.org/network/protocol/about;1?what=downloads",
+ "@mozilla.org/network/protocol/about;1?what=policies",
+ "@mozilla.org/network/protocol/about;1?what=accountsettings",
+ "@mozilla.org/network/protocol/about;1?what=accountsetup",
+ "@mozilla.org/network/protocol/about;1?what=accountprovisioner",
+ "@mozilla.org/network/protocol/about;1?what=addressbook",
+ "@mozilla.org/network/protocol/about;1?what=3pane",
+ "@mozilla.org/network/protocol/about;1?what=message",
+ "@mozilla.org/network/protocol/about;1?what=import",
+ "@mozilla.org/network/protocol/about;1?what=profiling",
+ ],
+ "jsm": "resource:///modules/AboutRedirector.jsm",
+ "constructor": "AboutRedirector",
+ },
+ {
+ "cid": "{eb239c82-fac9-431e-98d7-11cacd0f71b8}",
+ "contract_ids": ["@mozilla.org/mail/mailglue;1"],
+ "jsm": "resource:///modules/MailGlue.jsm",
+ "constructor": "MailGlue",
+ },
+ {
+ "cid": "{44346520-c5d2-44e5-a1ec-034e04d7fac4}",
+ "contract_ids": [
+ "@mozilla.org/uriloader/content-handler;1?type=text/html",
+ "@mozilla.org/uriloader/content-handler;1?type=text/plain",
+ "@mozilla.org/mail/default-mail-clh;1",
+ "@mozilla.org/mail/clh;1",
+ ],
+ "jsm": "resource:///modules/MessengerContentHandler.jsm",
+ "constructor": "MessengerContentHandler",
+ "categories": {
+ "command-line-handler": "x-default",
+ "command-line-validator": "b-default",
+ },
+ },
+ {
+ "cid": "{048227f7-852a-473c-b9b5-7748684b57e2}",
+ "contract_ids": [
+ "@mozilla.org/uriloader/content-handler;1?type=application/x-message-display",
+ ],
+ "jsm": "resource:///modules/MessengerContentHandler.jsm",
+ "constructor": "MessageDisplayContentHandler",
+ },
+]
+
+if buildconfig.substs.get("MOZ_DEBUG") or buildconfig.substs.get("NIGHTLY_BUILD"):
+ Categories = {
+ "app-startup": {
+ "startupRecorder": (
+ "@mozilla.org/test/startuprecorder;1",
+ ProcessSelector.MAIN_PROCESS_ONLY,
+ ),
+ },
+ }
+
+ Classes += [
+ {
+ "cid": "{11c095b2-e42e-4bdf-9dd0-aed87595f6a4}",
+ "contract_ids": ["@mozilla.org/test/startuprecorder;1"],
+ "jsm": "resource:///modules/StartupRecorder.jsm",
+ "constructor": "StartupRecorder",
+ },
+ ]
diff --git a/comm/mail/components/compose/composer.js b/comm/mail/components/compose/composer.js
new file mode 100644
index 0000000000..68e94cdc55
--- /dev/null
+++ b/comm/mail/components/compose/composer.js
@@ -0,0 +1,65 @@
+#filter dumbComments emptyLines substitution
+
+// 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/.
+
+pref("editor.author", "");
+
+pref("editor.text_color", "#000000");
+pref("editor.link_color", "#0000FF");
+pref("editor.active_link_color", "#000088");
+pref("editor.followed_link_color", "#FF0000");
+pref("editor.background_color", "#FFFFFF");
+pref("editor.use_background_image", false);
+pref("editor.default_background_image", "");
+pref("editor.use_custom_default_colors", 1);
+
+pref("editor.hrule.height", 2);
+pref("editor.hrule.width", 100);
+pref("editor.hrule.width_percent", true);
+pref("editor.hrule.shading", true);
+// center
+pref("editor.hrule.align", 1);
+
+pref("editor.table.maintain_structure", true);
+
+pref("editor.prettyprint", true);
+
+pref("editor.history.url_maximum", 10);
+
+pref("editor.publish.", "");
+pref("editor.lastFileLocation.image", "");
+pref("editor.lastFileLocation.html", "");
+pref("editor.save_associated_files", true);
+pref("editor.always_show_publish_dialog", false);
+
+//
+// What are the entities that you want Mozilla to save using mnemonic
+// names rather than numeric codes? E.g. If set, we'll output &nbsp;
+// otherwise, we may output 0xa0 depending on the charset.
+//
+// "none" : don't use any entity names; only use numeric codes.
+// "basic" : use entity names just for &nbsp; &amp; &lt; &gt; &quot; for
+// interoperability/exchange with products that don't support more
+// than that.
+// "latin1" : use entity names for 8bit accented letters and other special
+// symbols between 128 and 255.
+// "html" : use entity names for 8bit accented letters, greek letters, and
+// other special markup symbols as defined in HTML4.
+//
+
+//pref("editor.encode_entity", "html");
+
+#ifndef XP_MACOSX
+#ifdef XP_UNIX
+pref("editor.disable_spell_checker", false);
+pref("editor.dont_lock_spell_files", true);
+#endif
+#endif
+
+pref("editor.CR_creates_new_p", false);
+
+// Pasting images from the clipboard, order of encoding preference:
+// JPEG-PNG-GIF=0, PNG-JPEG-GIF=1, GIF-JPEG-PNG=2
+pref("clipboard.paste_image_type", 1);
diff --git a/comm/mail/components/compose/content/ComposerCommands.js b/comm/mail/components/compose/content/ComposerCommands.js
new file mode 100644
index 0000000000..7e9d7a992d
--- /dev/null
+++ b/comm/mail/components/compose/content/ComposerCommands.js
@@ -0,0 +1,2261 @@
+/* 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/. */
+
+/**
+ * Implementations of nsIControllerCommand for composer commands. These commands
+ * are related to editing. You can fire these commands with following functions:
+ * goDoCommand and goDoCommandParams(If command requires any parameters).
+ *
+ * Sometimes, we want to reflect the changes in the UI also. We have two functions
+ * for that: pokeStyleUI and pokeMultiStateUI. The pokeStyleUI function is for those
+ * commands which are boolean in nature for example "cmd_bold" command, text can
+ * be bold or not. The pokeMultiStateUI function is for the commands which can have
+ * multiple values for example "cmd_fontFace" can have different values like
+ * arial, variable width etc.
+ *
+ * Here, some of the commands are getting executed by document.execCommand.
+ * Those are listed in the gCommandMap Map object. In that also, some commands
+ * are of type boolean and some are of multiple state. We have two functions to
+ * execute them: doStatefulCommand and doStyleUICommand.
+ *
+ * All commands are not executable through document.execCommand.
+ * In all those cases, we will use goDoCommand or goDoCommandParams.
+ * The goDoCommandParams function is implemented in this file.
+ * The goDoCOmmand function is from globalOverlay.js. For the Commands
+ * which can be executed by document.execCommand, we will use doStatefulCommand
+ * and doStyleUICommand.
+ */
+
+/* import-globals-from ../../../../../toolkit/components/printing/content/printUtils.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from ../../../base/content/utilityOverlay.js */
+/* import-globals-from editor.js */
+/* import-globals-from editorUtilities.js */
+/* import-globals-from MsgComposeCommands.js */
+
+var gComposerJSCommandControllerID = 0;
+
+/**
+ * Used to register commands we have created manually.
+ */
+function SetupHTMLEditorCommands() {
+ var commandTable = GetComposerCommandTable();
+ if (!commandTable) {
+ return;
+ }
+
+ // Include everything a text editor does
+ SetupTextEditorCommands();
+
+ // dump("Registering HTML editor commands\n");
+
+ commandTable.registerCommand("cmd_renderedHTMLEnabler", nsDummyHTMLCommand);
+
+ commandTable.registerCommand("cmd_listProperties", nsListPropertiesCommand);
+ commandTable.registerCommand("cmd_colorProperties", nsColorPropertiesCommand);
+ commandTable.registerCommand("cmd_increaseFontStep", nsIncreaseFontCommand);
+ commandTable.registerCommand("cmd_decreaseFontStep", nsDecreaseFontCommand);
+ commandTable.registerCommand(
+ "cmd_objectProperties",
+ nsObjectPropertiesCommand
+ );
+ commandTable.registerCommand(
+ "cmd_removeNamedAnchors",
+ nsRemoveNamedAnchorsCommand
+ );
+
+ commandTable.registerCommand("cmd_image", nsImageCommand);
+ commandTable.registerCommand("cmd_hline", nsHLineCommand);
+ commandTable.registerCommand("cmd_link", nsLinkCommand);
+ commandTable.registerCommand("cmd_anchor", nsAnchorCommand);
+ commandTable.registerCommand(
+ "cmd_insertHTMLWithDialog",
+ nsInsertHTMLWithDialogCommand
+ );
+ commandTable.registerCommand(
+ "cmd_insertMathWithDialog",
+ nsInsertMathWithDialogCommand
+ );
+ commandTable.registerCommand("cmd_insertBreakAll", nsInsertBreakAllCommand);
+
+ commandTable.registerCommand("cmd_table", nsInsertOrEditTableCommand);
+ commandTable.registerCommand("cmd_editTable", nsEditTableCommand);
+ commandTable.registerCommand("cmd_SelectTable", nsSelectTableCommand);
+ commandTable.registerCommand("cmd_SelectRow", nsSelectTableRowCommand);
+ commandTable.registerCommand("cmd_SelectColumn", nsSelectTableColumnCommand);
+ commandTable.registerCommand("cmd_SelectCell", nsSelectTableCellCommand);
+ commandTable.registerCommand(
+ "cmd_SelectAllCells",
+ nsSelectAllTableCellsCommand
+ );
+ commandTable.registerCommand("cmd_InsertTable", nsInsertTableCommand);
+ commandTable.registerCommand(
+ "cmd_InsertRowAbove",
+ nsInsertTableRowAboveCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertRowBelow",
+ nsInsertTableRowBelowCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertColumnBefore",
+ nsInsertTableColumnBeforeCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertColumnAfter",
+ nsInsertTableColumnAfterCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertCellBefore",
+ nsInsertTableCellBeforeCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertCellAfter",
+ nsInsertTableCellAfterCommand
+ );
+ commandTable.registerCommand("cmd_DeleteTable", nsDeleteTableCommand);
+ commandTable.registerCommand("cmd_DeleteRow", nsDeleteTableRowCommand);
+ commandTable.registerCommand("cmd_DeleteColumn", nsDeleteTableColumnCommand);
+ commandTable.registerCommand("cmd_DeleteCell", nsDeleteTableCellCommand);
+ commandTable.registerCommand(
+ "cmd_DeleteCellContents",
+ nsDeleteTableCellContentsCommand
+ );
+ commandTable.registerCommand("cmd_JoinTableCells", nsJoinTableCellsCommand);
+ commandTable.registerCommand("cmd_SplitTableCell", nsSplitTableCellCommand);
+ commandTable.registerCommand(
+ "cmd_TableOrCellColor",
+ nsTableOrCellColorCommand
+ );
+ commandTable.registerCommand("cmd_smiley", nsSetSmiley);
+ commandTable.registerCommand("cmd_ConvertToTable", nsConvertToTable);
+}
+
+function SetupTextEditorCommands() {
+ var commandTable = GetComposerCommandTable();
+ if (!commandTable) {
+ return;
+ }
+ // dump("Registering plain text editor commands\n");
+
+ commandTable.registerCommand("cmd_findReplace", nsFindReplaceCommand);
+ commandTable.registerCommand("cmd_find", nsFindCommand);
+ commandTable.registerCommand("cmd_findNext", nsFindAgainCommand);
+ commandTable.registerCommand("cmd_findPrev", nsFindAgainCommand);
+ commandTable.registerCommand("cmd_rewrap", nsRewrapCommand);
+ commandTable.registerCommand("cmd_spelling", nsSpellingCommand);
+ commandTable.registerCommand("cmd_insertChars", nsInsertCharsCommand);
+}
+
+/**
+ * Used to register the command controller in the editor document.
+ *
+ * @returns {nsIControllerCommandTable|null} - A controller used to
+ * register the manually created commands.
+ */
+function GetComposerCommandTable() {
+ var controller;
+ if (gComposerJSCommandControllerID) {
+ try {
+ controller = window.content.controllers.getControllerById(
+ gComposerJSCommandControllerID
+ );
+ } catch (e) {}
+ }
+ if (!controller) {
+ // create it
+ controller =
+ Cc["@mozilla.org/embedcomp/base-command-controller;1"].createInstance();
+
+ var editorController = controller.QueryInterface(Ci.nsIControllerContext);
+ editorController.setCommandContext(GetCurrentEditorElement());
+ window.content.controllers.insertControllerAt(0, controller);
+
+ // Store the controller ID so we can be sure to get the right one later
+ gComposerJSCommandControllerID =
+ window.content.controllers.getControllerId(controller);
+ }
+
+ if (controller) {
+ var interfaceRequestor = controller.QueryInterface(
+ Ci.nsIInterfaceRequestor
+ );
+ return interfaceRequestor.getInterface(Ci.nsIControllerCommandTable);
+ }
+ return null;
+}
+
+/* eslint-disable complexity */
+
+/**
+ * Get the state of the given command and call the pokeStyleUI or pokeMultiStateUI
+ * according to the type of the command to reflect the UI changes in the editor.
+ *
+ * @param {string} command - The id of the command.
+ */
+function goUpdateCommandState(command) {
+ try {
+ var controller =
+ document.commandDispatcher.getControllerForCommand(command);
+ if (!(controller instanceof Ci.nsICommandController)) {
+ return;
+ }
+
+ var params = newCommandParams();
+ if (!params) {
+ return;
+ }
+
+ controller.getCommandStateWithParams(command, params);
+
+ switch (command) {
+ case "cmd_bold":
+ case "cmd_italic":
+ case "cmd_underline":
+ case "cmd_var":
+ case "cmd_samp":
+ case "cmd_code":
+ case "cmd_acronym":
+ case "cmd_abbr":
+ case "cmd_cite":
+ case "cmd_strong":
+ case "cmd_em":
+ case "cmd_superscript":
+ case "cmd_subscript":
+ case "cmd_strikethrough":
+ case "cmd_tt":
+ case "cmd_nobreak":
+ case "cmd_ul":
+ case "cmd_ol":
+ pokeStyleUI(command, params.getBooleanValue("state_all"));
+ break;
+
+ case "cmd_paragraphState":
+ case "cmd_align":
+ case "cmd_highlight":
+ case "cmd_backgroundColor":
+ case "cmd_fontColor":
+ case "cmd_fontFace":
+ pokeMultiStateUI(command, params);
+ break;
+
+ case "cmd_indent":
+ case "cmd_outdent":
+ case "cmd_increaseFont":
+ case "cmd_decreaseFont":
+ case "cmd_increaseFontStep":
+ case "cmd_decreaseFontStep":
+ case "cmd_removeStyles":
+ case "cmd_smiley":
+ break;
+
+ default:
+ dump("no update for command: " + command + "\n");
+ }
+ } catch (e) {
+ console.error(e);
+ }
+}
+/* eslint-enable complexity */
+
+/**
+ * Used in the oncommandupdate attribute of the goUpdateComposerMenuItems.
+ * For any commandset events fired, this function will be called.
+ * Used to update the UI state of the editor buttons and menulist.
+ * Whenever you change your selection in the editor part, i.e. if you move
+ * your cursor, you will find this functions getting called and
+ * updating the editor UI of toolbarbuttons and menulists. This is mainly
+ * to update the UI according to your selection in the editor part.
+ *
+ * @param {XULElement} commandset - The <xul:commandset> element to update for.
+ */
+function goUpdateComposerMenuItems(commandset) {
+ // dump("Updating commands for " + commandset.id + "\n");
+ for (var i = 0; i < commandset.children.length; i++) {
+ var commandNode = commandset.children[i];
+ var commandID = commandNode.id;
+ if (commandID) {
+ goUpdateCommand(commandID); // enable or disable
+ if (commandNode.hasAttribute("state")) {
+ goUpdateCommandState(commandID);
+ }
+ }
+ }
+}
+
+/**
+ * Execute the command with the provided parameters.
+ * This is directly calling commands with multiple state attributes, which
+ * are not supported by document.execCommand()
+ *
+ * @param {string} command - The command ID.
+ * @param {string} paramValue - The parameter value.
+ */
+function goDoCommandParams(command, paramValue) {
+ try {
+ let params = newCommandParams();
+ params.setStringValue("state_attribute", paramValue);
+ let controller =
+ document.commandDispatcher.getControllerForCommand(command);
+ if (controller && controller.isCommandEnabled(command)) {
+ if (controller instanceof Ci.nsICommandController) {
+ controller.doCommandWithParams(command, params);
+ } else {
+ controller.doCommand(command);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+/**
+ * Update the UI to reflect setting a given state for a command. This
+ * is used for boolean type of commands.
+ *
+ * @param {string} uiID - The id of the command.
+ * @param {boolean} desiredState - State to set for the command.
+ */
+function pokeStyleUI(uiID, desiredState) {
+ let commandNode = document.getElementById(uiID);
+ let uiState = commandNode.getAttribute("state") == "true";
+ if (desiredState != uiState) {
+ commandNode.setAttribute("state", desiredState ? "true" : "false");
+ let buttonId;
+ switch (uiID) {
+ case "cmd_bold":
+ buttonId = "boldButton";
+ break;
+ case "cmd_italic":
+ buttonId = "italicButton";
+ break;
+ case "cmd_underline":
+ buttonId = "underlineButton";
+ break;
+ case "cmd_ul":
+ buttonId = "ulButton";
+ break;
+ case "cmd_ol":
+ buttonId = "olButton";
+ break;
+ }
+ if (buttonId) {
+ document.getElementById(buttonId).checked = desiredState;
+ }
+ }
+}
+
+/**
+ * Maps internal command names to their document.execCommand() command string.
+ */
+let gCommandMap = new Map([
+ ["cmd_bold", "bold"],
+ ["cmd_italic", "italic"],
+ ["cmd_underline", "underline"],
+ ["cmd_strikethrough", "strikethrough"],
+ ["cmd_superscript", "superscript"],
+ ["cmd_subscript", "subscript"],
+ ["cmd_ul", "InsertUnorderedList"],
+ ["cmd_ol", "InsertOrderedList"],
+ ["cmd_fontFace", "fontName"],
+
+ // This are currently implemented with the help of
+ // color selection dialog box in the editor.js.
+ // ["cmd_highlight", "backColor"],
+ // ["cmd_fontColor", "foreColor"],
+]);
+
+/**
+ * Used for the boolean type commands available through
+ * document.execCommand(). We will also call pokeStyleUI to update
+ * the UI.
+ *
+ * @param {string} cmdStr - The id of the command.
+ */
+function doStyleUICommand(cmdStr) {
+ GetCurrentEditorElement().contentDocument.execCommand(
+ gCommandMap.get(cmdStr),
+ false,
+ null
+ );
+ let commandNode = document.getElementById(cmdStr);
+ let newState = commandNode.getAttribute("state") != "true";
+ pokeStyleUI(cmdStr, newState);
+}
+
+// Copied from jsmime.js.
+function stringToTypedArray(buffer) {
+ var typedarray = new Uint8Array(buffer.length);
+ for (var i = 0; i < buffer.length; i++) {
+ typedarray[i] = buffer.charCodeAt(i);
+ }
+ return typedarray;
+}
+
+/**
+ * Update the UI to reflect setting a given state for a command. This is used
+ * when the command state has a string value i.e. multiple state type commands.
+ *
+ * @param {string} uiID - The id of the command.
+ * @param {nsICommandParams} cmdParams - Command parameters object.
+ */
+function pokeMultiStateUI(uiID, cmdParams) {
+ let desiredAttrib;
+ if (cmdParams.getBooleanValue("state_mixed")) {
+ desiredAttrib = "mixed";
+ } else if (
+ cmdParams.getValueType("state_attribute") == Ci.nsICommandParams.eStringType
+ ) {
+ desiredAttrib = cmdParams.getCStringValue("state_attribute");
+ // Decode UTF-8, for example for font names in Japanese.
+ desiredAttrib = new TextDecoder("UTF-8").decode(
+ stringToTypedArray(desiredAttrib)
+ );
+ } else {
+ desiredAttrib = cmdParams.getStringValue("state_attribute");
+ }
+
+ let commandNode = document.getElementById(uiID);
+ let uiState = commandNode.getAttribute("state");
+ if (desiredAttrib != uiState) {
+ commandNode.setAttribute("state", desiredAttrib);
+ switch (uiID) {
+ case "cmd_paragraphState": {
+ onParagraphFormatChange();
+ break;
+ }
+ case "cmd_fontFace": {
+ onFontFaceChange();
+ break;
+ }
+ case "cmd_fontColor": {
+ onFontColorChange();
+ break;
+ }
+ case "cmd_backgroundColor": {
+ onBackgroundColorChange();
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * Perform the action of the multiple states type commands available through
+ * document.execCommand().
+ *
+ * @param {string} commandID - The id of the command.
+ * @param {string} newState - The parameter value.
+ * @param {boolean} updateUI - updates the UI if true. Used when
+ * function is called in another JavaScript function.
+ */
+function doStatefulCommand(commandID, newState, updateUI) {
+ if (commandID == "cmd_align") {
+ let command;
+ switch (newState) {
+ case "left":
+ command = "justifyLeft";
+ break;
+ case "center":
+ command = "justifyCenter";
+ break;
+ case "right":
+ command = "justifyRight";
+ break;
+ case "justify":
+ command = "justifyFull";
+ break;
+ }
+ GetCurrentEditorElement().contentDocument.execCommand(command, false, null);
+ } else if (commandID == "cmd_fontFace" && newState == "") {
+ goDoCommandParams(commandID, newState);
+ } else {
+ GetCurrentEditorElement().contentDocument.execCommand(
+ gCommandMap.get(commandID),
+ false,
+ newState
+ );
+ }
+
+ if (updateUI) {
+ let commandNode = document.getElementById(commandID);
+ commandNode.setAttribute("state", newState);
+ switch (commandID) {
+ case "cmd_fontFace": {
+ onFontFaceChange();
+ break;
+ }
+ }
+ } else {
+ let commandNode = document.getElementById(commandID);
+ if (commandNode) {
+ commandNode.setAttribute("state", newState);
+ }
+ }
+}
+
+var nsDummyHTMLCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // do nothing
+ dump("Hey, who's calling the dummy command?\n");
+ },
+};
+
+// ------- output utilities ----- //
+
+// returns a fileExtension string
+function GetExtensionBasedOnMimeType(aMIMEType) {
+ try {
+ var mimeService = null;
+ mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ var fileExtension = mimeService.getPrimaryExtension(aMIMEType, null);
+
+ // the MIME service likes to give back ".htm" for text/html files,
+ // so do a special-case fix here.
+ if (fileExtension == "htm") {
+ fileExtension = "html";
+ }
+
+ return fileExtension;
+ } catch (e) {}
+ return "";
+}
+
+function GetSuggestedFileName(aDocumentURLString, aMIMEType) {
+ var extension = GetExtensionBasedOnMimeType(aMIMEType);
+ if (extension) {
+ extension = "." + extension;
+ }
+
+ // check for existing file name we can use
+ if (aDocumentURLString && !IsUrlAboutBlank(aDocumentURLString)) {
+ try {
+ let docURI = Services.io.newURI(
+ aDocumentURLString,
+ GetCurrentEditor().documentCharacterSet
+ );
+ docURI = docURI.QueryInterface(Ci.nsIURL);
+
+ // grab the file name
+ let url = validateFileName(decodeURIComponent(docURI.fileBaseName));
+ if (url) {
+ return url + extension;
+ }
+ } catch (e) {}
+ }
+
+ // Check if there is a title we can use to generate a valid filename,
+ // if we can't, use the default filename.
+ var title =
+ validateFileName(GetDocumentTitle()) ||
+ GetString("untitledDefaultFilename");
+ return title + extension;
+}
+
+/**
+ * @returns {Promise} dialogResult
+ */
+function PromptForSaveLocation(
+ aDoSaveAsText,
+ aEditorType,
+ aMIMEType,
+ aDocumentURLString
+) {
+ var dialogResult = {};
+ dialogResult.filepickerClick = Ci.nsIFilePicker.returnCancel;
+ dialogResult.resultingURI = "";
+ dialogResult.resultingLocalFile = null;
+
+ var fp = null;
+ try {
+ fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ } catch (e) {}
+ if (!fp) {
+ return dialogResult;
+ }
+
+ // determine prompt string based on type of saving we'll do
+ var promptString;
+ if (aDoSaveAsText || aEditorType == "text") {
+ promptString = GetString("SaveTextAs");
+ } else {
+ promptString = GetString("SaveDocumentAs");
+ }
+
+ fp.init(window, promptString, Ci.nsIFilePicker.modeSave);
+
+ // Set filters according to the type of output
+ if (aDoSaveAsText) {
+ fp.appendFilters(Ci.nsIFilePicker.filterText);
+ } else {
+ fp.appendFilters(Ci.nsIFilePicker.filterHTML);
+ }
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ // now let's actually set the filepicker's suggested filename
+ var suggestedFileName = GetSuggestedFileName(aDocumentURLString, aMIMEType);
+ if (suggestedFileName) {
+ fp.defaultString = suggestedFileName;
+ }
+
+ // set the file picker's current directory
+ // assuming we have information needed (like prior saved location)
+ try {
+ var fileHandler = GetFileProtocolHandler();
+
+ var isLocalFile = true;
+ try {
+ let docURI = Services.io.newURI(
+ aDocumentURLString,
+ GetCurrentEditor().documentCharacterSet
+ );
+ isLocalFile = docURI.schemeIs("file");
+ } catch (e) {}
+
+ var parentLocation = null;
+ if (isLocalFile) {
+ var fileLocation = fileHandler.getFileFromURLSpec(aDocumentURLString); // this asserts if url is not local
+ parentLocation = fileLocation.parent;
+ }
+ if (parentLocation) {
+ // Save current filepicker's default location
+ if ("gFilePickerDirectory" in window) {
+ gFilePickerDirectory = fp.displayDirectory;
+ }
+
+ fp.displayDirectory = parentLocation;
+ } else {
+ // Initialize to the last-used directory for the particular type (saved in prefs)
+ SetFilePickerDirectory(fp, aEditorType);
+ }
+ } catch (e) {}
+
+ return new Promise(resolve => {
+ fp.open(rv => {
+ dialogResult.filepickerClick = rv;
+ if (rv != Ci.nsIFilePicker.returnCancel && fp.file) {
+ // Allow OK and replace.
+ // reset urlstring to new save location
+ dialogResult.resultingURIString = fileHandler.getURLSpecFromActualFile(
+ fp.file
+ );
+ dialogResult.resultingLocalFile = fp.file;
+ SaveFilePickerDirectory(fp, aEditorType);
+ resolve(dialogResult);
+ } else if ("gFilePickerDirectory" in window && gFilePickerDirectory) {
+ fp.displayDirectory = gFilePickerDirectory;
+ resolve(null);
+ }
+ });
+ });
+}
+
+/**
+ * If needed, prompt for document title and set the document title to the
+ * preferred value.
+ *
+ * @returns true if the title was set up successfully;
+ * false if the user cancelled the title prompt
+ */
+function PromptAndSetTitleIfNone() {
+ if (GetDocumentTitle()) {
+ // we have a title; no need to prompt!
+ return true;
+ }
+
+ let result = { value: null };
+ let captionStr = GetString("DocumentTitle");
+ let msgStr = GetString("NeedDocTitle") + "\n" + GetString("DocTitleHelp");
+ let confirmed = Services.prompt.prompt(
+ window,
+ captionStr,
+ msgStr,
+ result,
+ null,
+ { value: 0 }
+ );
+ if (confirmed) {
+ SetDocumentTitle(TrimString(result.value));
+ }
+
+ return confirmed;
+}
+
+var gPersistObj;
+
+// Don't forget to do these things after calling OutputFileWithPersistAPI:
+// we need to update the uri before notifying listeners
+// UpdateWindowTitle();
+// if (!aSaveCopy)
+// editor.resetModificationCount();
+// this should cause notification to listeners that document has changed
+
+function OutputFileWithPersistAPI(
+ editorDoc,
+ aDestinationLocation,
+ aRelatedFilesParentDir,
+ aMimeType
+) {
+ gPersistObj = null;
+ var editor = GetCurrentEditor();
+ try {
+ editor.forceCompositionEnd();
+ } catch (e) {}
+
+ var isLocalFile = false;
+ try {
+ aDestinationLocation.QueryInterface(Ci.nsIFile);
+ isLocalFile = true;
+ } catch (e) {
+ try {
+ var tmp = aDestinationLocation.QueryInterface(Ci.nsIURI);
+ isLocalFile = tmp.schemeIs("file");
+ } catch (e) {}
+ }
+
+ try {
+ // we should supply a parent directory if/when we turn on functionality to save related documents
+ var persistObj = Cc[
+ "@mozilla.org/embedding/browser/nsWebBrowserPersist;1"
+ ].createInstance(Ci.nsIWebBrowserPersist);
+ persistObj.progressListener = gEditorOutputProgressListener;
+
+ var wrapColumn = GetWrapColumn();
+ var outputFlags = GetOutputFlags(aMimeType, wrapColumn);
+
+ // for 4.x parity as well as improving readability of file locally on server
+ // this will always send crlf for upload (http/ftp)
+ if (!isLocalFile) {
+ // if we aren't saving locally then send both cr and lf
+ outputFlags |=
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_CR_LINEBREAKS |
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_LF_LINEBREAKS;
+
+ // we want to serialize the output for all remote publishing
+ // some servers can handle only one connection at a time
+ // some day perhaps we can make this user-configurable per site?
+ persistObj.persistFlags =
+ persistObj.persistFlags |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_SERIALIZE_OUTPUT;
+ }
+
+ // note: we always want to set the replace existing files flag since we have
+ // already given user the chance to not replace an existing file (file picker)
+ // or the user picked an option where the file is implicitly being replaced (save)
+ persistObj.persistFlags =
+ persistObj.persistFlags |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_NO_BASE_TAG_MODIFICATIONS |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_REPLACE_EXISTING_FILES |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_DONT_FIXUP_LINKS |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_DONT_CHANGE_FILENAMES |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_FIXUP_ORIGINAL_DOM;
+ persistObj.saveDocument(
+ editorDoc,
+ aDestinationLocation,
+ aRelatedFilesParentDir,
+ aMimeType,
+ outputFlags,
+ wrapColumn
+ );
+ gPersistObj = persistObj;
+ } catch (e) {
+ dump("caught an error, bail\n");
+ return false;
+ }
+
+ return true;
+}
+
+// returns output flags based on mimetype, wrapCol and prefs
+function GetOutputFlags(aMimeType, aWrapColumn) {
+ var outputFlags = 0;
+ var editor = GetCurrentEditor();
+ var outputEntity =
+ editor && editor.documentCharacterSet == "ISO-8859-1"
+ ? Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_LATIN1_ENTITIES
+ : Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES;
+ if (aMimeType == "text/plain") {
+ // When saving in "text/plain" format, always do formatting
+ outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_FORMATTED;
+ } else {
+ // Should we prettyprint? Check the pref
+ if (Services.prefs.getBoolPref("editor.prettyprint")) {
+ outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_FORMATTED;
+ }
+
+ try {
+ // How much entity names should we output? Check the pref
+ switch (Services.prefs.getCharPref("editor.encode_entity")) {
+ case "basic":
+ outputEntity =
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES;
+ break;
+ case "latin1":
+ outputEntity =
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_LATIN1_ENTITIES;
+ break;
+ case "html":
+ outputEntity =
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_HTML_ENTITIES;
+ break;
+ case "none":
+ outputEntity = 0;
+ break;
+ }
+ } catch (e) {}
+ }
+ outputFlags |= outputEntity;
+
+ if (aWrapColumn > 0) {
+ outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_WRAP;
+ }
+
+ return outputFlags;
+}
+
+// returns number of column where to wrap
+function GetWrapColumn() {
+ try {
+ return GetCurrentEditor().wrapWidth;
+ } catch (e) {}
+ return 0;
+}
+
+const gShowDebugOutputStateChange = false;
+const gShowDebugOutputProgress = false;
+const gShowDebugOutputStatusChange = false;
+
+const gShowDebugOutputLocationChange = false;
+const gShowDebugOutputSecurityChange = false;
+
+const kErrorBindingAborted = 2152398850;
+const kErrorBindingRedirected = 2152398851;
+const kFileNotFound = 2152857618;
+
+var gEditorOutputProgressListener = {
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ // Use this to access onStateChange flags
+ var requestSpec;
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ requestSpec = StripUsernamePasswordFromURI(channel.URI);
+ } catch (e) {
+ if (gShowDebugOutputStateChange) {
+ dump("***** onStateChange; NO REQUEST CHANNEL\n");
+ }
+ }
+
+ if (gShowDebugOutputStateChange) {
+ dump("\n***** onStateChange request: " + requestSpec + "\n");
+ dump(" state flags: ");
+
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ dump(" STATE_START, ");
+ }
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ dump(" STATE_STOP, ");
+ }
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK) {
+ dump(" STATE_IS_NETWORK ");
+ }
+
+ dump(`\n * requestSpec=${requestSpec}, aStatus=${aStatus}\n`);
+
+ DumpDebugStatus(aStatus);
+ }
+ },
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ if (!gPersistObj) {
+ return;
+ }
+
+ if (gShowDebugOutputProgress) {
+ dump(
+ "\n onProgressChange: gPersistObj.result=" + gPersistObj.result + "\n"
+ );
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("***** onProgressChange request: " + channel.URI.spec + "\n");
+ } catch (e) {}
+ dump(
+ "***** self: " +
+ aCurSelfProgress +
+ " / " +
+ aMaxSelfProgress +
+ "\n"
+ );
+ dump(
+ "***** total: " +
+ aCurTotalProgress +
+ " / " +
+ aMaxTotalProgress +
+ "\n\n"
+ );
+
+ if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_READY) {
+ dump(" Persister is ready to save data\n\n");
+ } else if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_SAVING) {
+ dump(" Persister is saving data.\n\n");
+ } else if (
+ gPersistObj.currentState == gPersistObj.PERSIST_STATE_FINISHED
+ ) {
+ dump(" PERSISTER HAS FINISHED SAVING DATA\n\n\n");
+ }
+ }
+ },
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ if (gShowDebugOutputLocationChange) {
+ dump("***** onLocationChange: " + aLocation.spec + "\n");
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("***** request: " + channel.URI.spec + "\n");
+ } catch (e) {}
+ }
+ },
+
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ if (gShowDebugOutputStatusChange) {
+ dump("***** onStatusChange: " + aMessage + "\n");
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("***** request: " + channel.URI.spec + "\n");
+ } catch (e) {
+ dump(" couldn't get request\n");
+ }
+
+ DumpDebugStatus(aStatus);
+
+ if (gPersistObj) {
+ if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_READY) {
+ dump(" Persister is ready to save data\n\n");
+ } else if (
+ gPersistObj.currentState == gPersistObj.PERSIST_STATE_SAVING
+ ) {
+ dump(" Persister is saving data.\n\n");
+ } else if (
+ gPersistObj.currentState == gPersistObj.PERSIST_STATE_FINISHED
+ ) {
+ dump(" PERSISTER HAS FINISHED SAVING DATA\n\n\n");
+ }
+ }
+ }
+ },
+
+ onSecurityChange(aWebProgress, aRequest, state) {
+ if (gShowDebugOutputSecurityChange) {
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("***** onSecurityChange request: " + channel.URI.spec + "\n");
+ } catch (e) {}
+ }
+ },
+
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {},
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+/* eslint-disable complexity */
+function DumpDebugStatus(aStatus) {
+ // see nsError.h and netCore.h and ftpCore.h
+
+ if (aStatus == kErrorBindingAborted) {
+ dump("***** status is NS_BINDING_ABORTED\n");
+ } else if (aStatus == kErrorBindingRedirected) {
+ dump("***** status is NS_BINDING_REDIRECTED\n");
+ } else if (aStatus == 2152398859) {
+ // in netCore.h 11
+ dump("***** status is ALREADY_CONNECTED\n");
+ } else if (aStatus == 2152398860) {
+ // in netCore.h 12
+ dump("***** status is NOT_CONNECTED\n");
+ } else if (aStatus == 2152398861) {
+ // in nsISocketTransportService.idl 13
+ dump("***** status is CONNECTION_REFUSED\n");
+ } else if (aStatus == 2152398862) {
+ // in nsISocketTransportService.idl 14
+ dump("***** status is NET_TIMEOUT\n");
+ } else if (aStatus == 2152398863) {
+ // in netCore.h 15
+ dump("***** status is IN_PROGRESS\n");
+ } else if (aStatus == 2152398864) {
+ // 0x804b0010 in netCore.h 16
+ dump("***** status is OFFLINE\n");
+ } else if (aStatus == 2152398865) {
+ // in netCore.h 17
+ dump("***** status is NO_CONTENT\n");
+ } else if (aStatus == 2152398866) {
+ // in netCore.h 18
+ dump("***** status is UNKNOWN_PROTOCOL\n");
+ } else if (aStatus == 2152398867) {
+ // in netCore.h 19
+ dump("***** status is PORT_ACCESS_NOT_ALLOWED\n");
+ } else if (aStatus == 2152398868) {
+ // in nsISocketTransportService.idl 20
+ dump("***** status is NET_RESET\n");
+ } else if (aStatus == 2152398869) {
+ // in ftpCore.h 21
+ dump("***** status is FTP_LOGIN\n");
+ } else if (aStatus == 2152398870) {
+ // in ftpCore.h 22
+ dump("***** status is FTP_CWD\n");
+ } else if (aStatus == 2152398871) {
+ // in ftpCore.h 23
+ dump("***** status is FTP_PASV\n");
+ } else if (aStatus == 2152398872) {
+ // in ftpCore.h 24
+ dump("***** status is FTP_PWD\n");
+ } else if (aStatus == 2152857601) {
+ dump("***** status is UNRECOGNIZED_PATH\n");
+ } else if (aStatus == 2152857602) {
+ dump("***** status is UNRESOLABLE SYMLINK\n");
+ } else if (aStatus == 2152857604) {
+ dump("***** status is UNKNOWN_TYPE\n");
+ } else if (aStatus == 2152857605) {
+ dump("***** status is DESTINATION_NOT_DIR\n");
+ } else if (aStatus == 2152857606) {
+ dump("***** status is TARGET_DOES_NOT_EXIST\n");
+ } else if (aStatus == 2152857608) {
+ dump("***** status is ALREADY_EXISTS\n");
+ } else if (aStatus == 2152857609) {
+ dump("***** status is INVALID_PATH\n");
+ } else if (aStatus == 2152857610) {
+ dump("***** status is DISK_FULL\n");
+ } else if (aStatus == 2152857612) {
+ dump("***** status is NOT_DIRECTORY\n");
+ } else if (aStatus == 2152857613) {
+ dump("***** status is IS_DIRECTORY\n");
+ } else if (aStatus == 2152857614) {
+ dump("***** status is IS_LOCKED\n");
+ } else if (aStatus == 2152857615) {
+ dump("***** status is TOO_BIG\n");
+ } else if (aStatus == 2152857616) {
+ dump("***** status is NO_DEVICE_SPACE\n");
+ } else if (aStatus == 2152857617) {
+ dump("***** status is NAME_TOO_LONG\n");
+ } else if (aStatus == 2152857618) {
+ // 80520012
+ dump("***** status is FILE_NOT_FOUND\n");
+ } else if (aStatus == 2152857619) {
+ dump("***** status is READ_ONLY\n");
+ } else if (aStatus == 2152857620) {
+ dump("***** status is DIR_NOT_EMPTY\n");
+ } else if (aStatus == 2152857621) {
+ dump("***** status is ACCESS_DENIED\n");
+ } else if (aStatus == 2152398878) {
+ dump("***** status is ? (No connection or time out?)\n");
+ } else {
+ dump("***** status is " + aStatus + "\n");
+ }
+}
+/* eslint-enable complexity */
+
+const kSupportedTextMimeTypes = [
+ "text/plain",
+ "text/css",
+ "text/rdf",
+ "text/xsl",
+ "text/javascript", // obsolete type
+ "text/ecmascript", // obsolete type
+ "application/javascript",
+ "application/ecmascript",
+ "application/x-javascript", // obsolete type
+ "application/xhtml+xml",
+];
+
+function IsSupportedTextMimeType(aMimeType) {
+ for (var i = 0; i < kSupportedTextMimeTypes.length; i++) {
+ if (kSupportedTextMimeTypes[i] == aMimeType) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/* eslint-disable complexity */
+// throws an error or returns true if user attempted save; false if user canceled save
+async function SaveDocument(aSaveAs, aSaveCopy, aMimeType) {
+ var editor = GetCurrentEditor();
+ if (!aMimeType || !editor) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ var editorDoc = editor.document;
+ if (!editorDoc) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ // if we don't have the right editor type bail (we handle text and html)
+ var editorType = GetCurrentEditorType();
+ if (!["text", "html", "htmlmail", "textmail"].includes(editorType)) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ var saveAsTextFile = IsSupportedTextMimeType(aMimeType);
+
+ // check if the file is to be saved is a format we don't understand; if so, bail
+ if (
+ aMimeType != kHTMLMimeType &&
+ aMimeType != kXHTMLMimeType &&
+ !saveAsTextFile
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ if (saveAsTextFile) {
+ aMimeType = "text/plain";
+ }
+
+ var urlstring = GetDocumentUrl();
+ var mustShowFileDialog =
+ aSaveAs || IsUrlAboutBlank(urlstring) || urlstring == "";
+
+ // If editing a remote URL, force SaveAs dialog
+ if (!mustShowFileDialog && GetScheme(urlstring) != "file") {
+ mustShowFileDialog = true;
+ }
+
+ var doUpdateURI = false;
+ var tempLocalFile = null;
+
+ if (mustShowFileDialog) {
+ try {
+ // Prompt for title if we are saving to HTML
+ if (!saveAsTextFile && editorType == "html") {
+ var userContinuing = PromptAndSetTitleIfNone(); // not cancel
+ if (!userContinuing) {
+ return false;
+ }
+ }
+
+ var dialogResult = await PromptForSaveLocation(
+ saveAsTextFile,
+ editorType,
+ aMimeType,
+ urlstring
+ );
+ if (!dialogResult) {
+ return false;
+ }
+
+ // What is this unused 'replacing' var supposed to be doing?
+ /* eslint-disable-next-line no-unused-vars */
+ var replacing =
+ dialogResult.filepickerClick == Ci.nsIFilePicker.returnReplace;
+
+ urlstring = dialogResult.resultingURIString;
+ tempLocalFile = dialogResult.resultingLocalFile;
+
+ // update the new URL for the webshell unless we are saving a copy
+ if (!aSaveCopy) {
+ doUpdateURI = true;
+ }
+ } catch (e) {
+ console.error(e);
+ return false;
+ }
+ } // mustShowFileDialog
+
+ var success = true;
+ try {
+ // if somehow we didn't get a local file but we did get a uri,
+ // attempt to create the localfile if it's a "file" url
+ var docURI;
+ if (!tempLocalFile) {
+ docURI = Services.io.newURI(urlstring, editor.documentCharacterSet);
+
+ if (docURI.schemeIs("file")) {
+ var fileHandler = GetFileProtocolHandler();
+ tempLocalFile = fileHandler
+ .getFileFromURLSpec(urlstring)
+ .QueryInterface(Ci.nsIFile);
+ }
+ }
+
+ // this is the location where the related files will go
+ var relatedFilesDir = null;
+
+ // Only change links or move files if pref is set
+ // and we are saving to a new location
+ if (Services.prefs.getBoolPref("editor.save_associated_files") && aSaveAs) {
+ try {
+ if (tempLocalFile) {
+ // if we are saving to the same parent directory, don't set relatedFilesDir
+ // grab old location, chop off file
+ // grab new location, chop off file, compare
+ var oldLocation = GetDocumentUrl();
+ var oldLocationLastSlash = oldLocation.lastIndexOf("/");
+ if (oldLocationLastSlash != -1) {
+ oldLocation = oldLocation.slice(0, oldLocationLastSlash);
+ }
+
+ var relatedFilesDirStr = urlstring;
+ var newLocationLastSlash = relatedFilesDirStr.lastIndexOf("/");
+ if (newLocationLastSlash != -1) {
+ relatedFilesDirStr = relatedFilesDirStr.slice(
+ 0,
+ newLocationLastSlash
+ );
+ }
+ if (
+ oldLocation == relatedFilesDirStr ||
+ IsUrlAboutBlank(oldLocation)
+ ) {
+ relatedFilesDir = null;
+ } else {
+ relatedFilesDir = tempLocalFile.parent;
+ }
+ } else {
+ var lastSlash = urlstring.lastIndexOf("/");
+ if (lastSlash != -1) {
+ var relatedFilesDirString = urlstring.slice(0, lastSlash + 1); // include last slash
+ relatedFilesDir = Services.io.newURI(
+ relatedFilesDirString,
+ editor.documentCharacterSet
+ );
+ }
+ }
+ } catch (e) {
+ relatedFilesDir = null;
+ }
+ }
+
+ let destinationLocation = tempLocalFile ? tempLocalFile : docURI;
+
+ success = OutputFileWithPersistAPI(
+ editorDoc,
+ destinationLocation,
+ relatedFilesDir,
+ aMimeType
+ );
+ } catch (e) {
+ success = false;
+ }
+
+ if (success) {
+ try {
+ if (doUpdateURI) {
+ // If a local file, we must create a new uri from nsIFile
+ if (tempLocalFile) {
+ docURI = GetFileProtocolHandler().newFileURI(tempLocalFile);
+ }
+ }
+
+ // Update window title to show possibly different filename
+ // This also covers problem that after undoing a title change,
+ // window title loses the extra [filename] part that this adds
+ UpdateWindowTitle();
+
+ if (!aSaveCopy) {
+ editor.resetModificationCount();
+ }
+ // this should cause notification to listeners that document has changed
+
+ // Set UI based on whether we're editing a remote or local url
+ goUpdateCommand("cmd_save");
+ } catch (e) {}
+ } else {
+ Services.prompt.alert(
+ window,
+ GetString("SaveDocument"),
+ GetString("SaveFileFailed")
+ );
+ }
+ return success;
+}
+/* eslint-enable complexity */
+
+var nsFindReplaceCommand = {
+ isCommandEnabled(aCommand, editorElement) {
+ return editorElement.getEditor(editorElement.contentWindow) != null;
+ },
+
+ getCommandStateParams(aCommand, aParams, editorElement) {},
+ doCommandParams(aCommand, aParams, editorElement) {},
+
+ doCommand(aCommand, editorElement) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdReplace.xhtml",
+ "_blank",
+ "chrome,modal,titlebar",
+ editorElement
+ );
+ },
+};
+
+var nsFindCommand = {
+ isCommandEnabled(aCommand, editorElement) {
+ return editorElement.getEditor(editorElement.contentWindow) != null;
+ },
+
+ getCommandStateParams(aCommand, aParams, editorElement) {},
+ doCommandParams(aCommand, aParams, editorElement) {},
+
+ doCommand(aCommand, editorElement) {
+ document.getElementById("FindToolbar").onFindCommand();
+ },
+};
+
+var nsFindAgainCommand = {
+ isCommandEnabled(aCommand, editorElement) {
+ // we can only do this if the search pattern is non-empty. Not sure how
+ // to get that from here
+ return editorElement.getEditor(editorElement.contentWindow) != null;
+ },
+
+ getCommandStateParams(aCommand, aParams, editorElement) {},
+ doCommandParams(aCommand, aParams, editorElement) {},
+
+ doCommand(aCommand, editorElement) {
+ let findPrev = aCommand == "cmd_findPrev";
+ document.getElementById("FindToolbar").onFindAgainCommand(findPrev);
+ },
+};
+
+var nsRewrapCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return (
+ IsDocumentEditable() &&
+ !IsInHTMLSourceMode() &&
+ GetCurrentEditor() instanceof Ci.nsIEditorMailSupport
+ );
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ GetCurrentEditor().QueryInterface(Ci.nsIEditorMailSupport).rewrap(false);
+ },
+};
+
+var nsSpellingCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return (
+ IsDocumentEditable() && !IsInHTMLSourceMode() && IsSpellCheckerInstalled()
+ );
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.cancelSendMessage = false;
+ try {
+ var skipBlockQuotes =
+ window.document.documentElement.getAttribute("windowtype") ==
+ "msgcompose";
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml",
+ "_blank",
+ "dialog,close,titlebar,modal,resizable",
+ false,
+ skipBlockQuotes,
+ true
+ );
+ } catch (ex) {}
+ },
+};
+
+var nsImageCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdImageProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ },
+};
+
+var nsHLineCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // Inserting an HLine is different in that we don't use properties dialog
+ // unless we are editing an existing line's attributes
+ // We get the last-used attributes from the prefs and insert immediately
+
+ var tagName = "hr";
+ var editor = GetCurrentEditor();
+
+ var hLine;
+ try {
+ hLine = editor.getSelectedElement(tagName);
+ } catch (e) {
+ return;
+ }
+
+ if (hLine) {
+ // We only open the dialog for an existing HRule
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdHLineProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ } else {
+ try {
+ hLine = editor.createElementWithDefaults(tagName);
+
+ // We change the default attributes to those saved in the user prefs
+ let align = Services.prefs.getIntPref("editor.hrule.align");
+ if (align == 0) {
+ editor.setAttributeOrEquivalent(hLine, "align", "left", true);
+ } else if (align == 2) {
+ editor.setAttributeOrEquivalent(hLine, "align", "right", true);
+ }
+
+ // Note: Default is center (don't write attribute)
+
+ let width = Services.prefs.getIntPref("editor.hrule.width");
+ if (Services.prefs.getBoolPref("editor.hrule.width_percent")) {
+ width = width + "%";
+ }
+
+ editor.setAttributeOrEquivalent(hLine, "width", width, true);
+
+ let height = Services.prefs.getIntPref("editor.hrule.height");
+ editor.setAttributeOrEquivalent(hLine, "size", String(height), true);
+
+ if (Services.prefs.getBoolPref("editor.hrule.shading")) {
+ hLine.removeAttribute("noshade");
+ } else {
+ hLine.setAttribute("noshade", "noshade");
+ }
+
+ editor.insertElementAtSelection(hLine, true);
+ } catch (e) {}
+ }
+ },
+};
+
+var nsLinkCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // If selected element is an image, launch that dialog instead
+ // since last tab panel handles link around an image
+ var element = GetObjectForProperties();
+ if (element && element.nodeName.toLowerCase() == "img") {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdImageProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ null,
+ true
+ );
+ } else {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdLinkProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ }
+ },
+};
+
+var nsAnchorCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdNamedAnchorProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ ""
+ );
+ },
+};
+
+var nsInsertHTMLWithDialogCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ gMsgCompose.allowRemoteContent = true;
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsSrc.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable",
+ ""
+ );
+ },
+};
+
+var nsInsertMathWithDialogCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsertMath.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable",
+ ""
+ );
+ },
+};
+
+var nsInsertCharsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorFindOrCreateInsertCharWindow();
+ },
+};
+
+var nsInsertBreakAllCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentEditor().insertHTML("<br clear='all'>");
+ } catch (e) {}
+ },
+};
+
+var nsListPropertiesCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdListProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ },
+};
+
+var nsObjectPropertiesCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ var isEnabled = false;
+ if (IsDocumentEditable() && IsEditingRenderedHTML()) {
+ isEnabled =
+ GetObjectForProperties() != null ||
+ GetCurrentEditor().getSelectedElement("href") != null;
+ }
+ return isEnabled;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // Launch Object properties for appropriate selected element
+ var element = GetObjectForProperties();
+ if (element) {
+ var name = element.nodeName.toLowerCase();
+ switch (name) {
+ case "img":
+ gMsgCompose.allowRemoteContent = true;
+ goDoCommand("cmd_image");
+ break;
+ case "hr":
+ goDoCommand("cmd_hline");
+ break;
+ case "table":
+ EditorInsertOrEditTable(false);
+ break;
+ case "td":
+ case "th":
+ EditorTableCellProperties();
+ break;
+ case "ol":
+ case "ul":
+ case "dl":
+ case "li":
+ goDoCommand("cmd_listProperties");
+ break;
+ case "a":
+ if (element.name) {
+ goDoCommand("cmd_anchor");
+ } else if (element.href) {
+ goDoCommand("cmd_link");
+ }
+ break;
+ case "math":
+ goDoCommand("cmd_insertMathWithDialog");
+ break;
+ default:
+ doAdvancedProperties(element);
+ break;
+ }
+ } else {
+ // We get a partially-selected link if asked for specifically
+ try {
+ element = GetCurrentEditor().getSelectedElement("href");
+ } catch (e) {}
+ if (element) {
+ goDoCommand("cmd_link");
+ }
+ }
+ },
+};
+
+var nsSetSmiley = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {
+ try {
+ let editor = GetCurrentEditor();
+ let smileyCode = aParams.getStringValue("state_attribute");
+ editor.insertHTML(smileyCode);
+ window.content.focus();
+ } catch (e) {
+ dump("Exception occurred in smiley InsertElementAtSelection\n");
+ }
+ },
+ // This is now deprecated in favor of "doCommandParams"
+ doCommand(aCommand) {},
+};
+
+function doAdvancedProperties(element) {
+ if (element) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable=yes",
+ "",
+ element
+ );
+ }
+}
+
+var nsColorPropertiesCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdColorProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ ""
+ );
+ UpdateDefaultColors();
+ },
+};
+
+var nsIncreaseFontCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ if (!(IsDocumentEditable() && IsEditingRenderedHTML())) {
+ return false;
+ }
+ let setIndex = parseInt(getLegacyFontSize());
+ return setIndex < 6;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ let setIndex = parseInt(getLegacyFontSize());
+ EditorSetFontSize((setIndex + 1).toString());
+ },
+};
+
+var nsDecreaseFontCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ if (!(IsDocumentEditable() && IsEditingRenderedHTML())) {
+ return false;
+ }
+ let setIndex = parseInt(getLegacyFontSize());
+ return setIndex > 1;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ let setIndex = parseInt(getLegacyFontSize());
+ EditorSetFontSize((setIndex - 1).toString());
+ },
+};
+
+var nsRemoveNamedAnchorsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ // We could see if there's any link in selection, but it doesn't seem worth the work!
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorRemoveTextProperty("name", "");
+ window.content.focus();
+ },
+};
+
+var nsInsertOrEditTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ if (IsInTableCell()) {
+ EditorTableCellProperties();
+ } else {
+ EditorInsertOrEditTable(true);
+ }
+ },
+};
+
+var nsEditTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorInsertOrEditTable(false);
+ },
+};
+
+var nsSelectTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectTable();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSelectTableRowCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectTableRow();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSelectTableColumnCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectTableColumn();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSelectTableCellCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectTableCell();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSelectAllTableCellsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectAllTableCells();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorInsertTable();
+ },
+};
+
+var nsInsertTableRowAboveCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableRow(1, false);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableRowBelowCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableRow(1, true);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableColumnBeforeCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableColumn(1, false);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableColumnAfterCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableColumn(1, true);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableCellBeforeCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableCell(1, false);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableCellAfterCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableCell(1, true);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().deleteTable();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableRowCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ var rows = GetNumberOfContiguousSelectedRows();
+ // Delete at least one row
+ if (rows == 0) {
+ rows = 1;
+ }
+
+ try {
+ var editor = GetCurrentTableEditor();
+ editor.beginTransaction();
+
+ // Loop to delete all blocks of contiguous, selected rows
+ while (rows) {
+ editor.deleteTableRow(rows);
+ rows = GetNumberOfContiguousSelectedRows();
+ }
+ } finally {
+ editor.endTransaction();
+ }
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableColumnCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ var columns = GetNumberOfContiguousSelectedColumns();
+ // Delete at least one column
+ if (columns == 0) {
+ columns = 1;
+ }
+
+ try {
+ var editor = GetCurrentTableEditor();
+ editor.beginTransaction();
+
+ // Loop to delete all blocks of contiguous, selected columns
+ while (columns) {
+ editor.deleteTableColumn(columns);
+ columns = GetNumberOfContiguousSelectedColumns();
+ }
+ } finally {
+ editor.endTransaction();
+ }
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableCellCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().deleteTableCell(1);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableCellContentsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().deleteTableCellContents();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsJoinTableCellsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ if (IsDocumentEditable() && IsEditingRenderedHTML()) {
+ try {
+ var editor = GetCurrentTableEditor();
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ var cell = editor.getSelectedOrParentTableElement(tagNameObj, countObj);
+
+ // We need a cell and either > 1 selected cell or a cell to the right
+ // (this cell may originate in a row spanned from above current row)
+ // Note that editor returns "td" for "th" also.
+ // (this is a pain! Editor and gecko use lowercase tagNames, JS uses uppercase!)
+ if (cell && tagNameObj.value == "td") {
+ // Selected cells
+ if (countObj.value > 1) {
+ return true;
+ }
+
+ var colSpan = cell.getAttribute("colspan");
+
+ // getAttribute returns string, we need number
+ // no attribute means colspan = 1
+ if (!colSpan) {
+ colSpan = Number(1);
+ } else {
+ colSpan = Number(colSpan);
+ }
+
+ var rowObj = { value: 0 };
+ var colObj = { value: 0 };
+ editor.getCellIndexes(cell, rowObj, colObj);
+
+ // Test if cell exists to the right of current cell
+ // (cells with 0 span should never have cells to the right
+ // if there is, user can select the 2 cells to join them)
+ return (
+ colSpan &&
+ editor.getCellAt(null, rowObj.value, colObj.value + colSpan)
+ );
+ }
+ } catch (e) {}
+ }
+ return false;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // Param: Don't merge non-contiguous cells
+ try {
+ GetCurrentTableEditor().joinTableCells(false);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSplitTableCellCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ if (IsDocumentEditable() && IsEditingRenderedHTML()) {
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ var cell;
+ try {
+ cell = GetCurrentTableEditor().getSelectedOrParentTableElement(
+ tagNameObj,
+ countObj
+ );
+ } catch (e) {}
+
+ // We need a cell parent and there's just 1 selected cell
+ // or selection is entirely inside 1 cell
+ if (
+ cell &&
+ tagNameObj.value == "td" &&
+ countObj.value <= 1 &&
+ IsSelectionInOneCell()
+ ) {
+ var colSpan = cell.getAttribute("colspan");
+ var rowSpan = cell.getAttribute("rowspan");
+ if (!colSpan) {
+ colSpan = 1;
+ }
+ if (!rowSpan) {
+ rowSpan = 1;
+ }
+ return colSpan > 1 || rowSpan > 1 || colSpan == 0 || rowSpan == 0;
+ }
+ }
+ return false;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().splitTableCell();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsTableOrCellColorCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorSelectColor("TableOrCell");
+ },
+};
+
+var nsConvertToTable = {
+ isCommandEnabled(aCommand, dummy) {
+ if (IsDocumentEditable() && IsEditingRenderedHTML()) {
+ var selection;
+ try {
+ selection = GetCurrentEditor().selection;
+ } catch (e) {}
+
+ if (selection && !selection.isCollapsed) {
+ // Don't allow if table or cell is the selection
+ var element;
+ try {
+ element = GetCurrentEditor().getSelectedElement("");
+ } catch (e) {}
+ if (element) {
+ var name = element.nodeName.toLowerCase();
+ if (
+ name == "td" ||
+ name == "th" ||
+ name == "caption" ||
+ name == "table"
+ ) {
+ return false;
+ }
+ }
+
+ // Selection start and end must be in the same cell
+ // in same cell or both are NOT in a cell
+ if (
+ GetParentTableCell(selection.focusNode) !=
+ GetParentTableCell(selection.anchorNode)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+ }
+ return false;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ if (this.isCommandEnabled()) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdConvertToTable.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ }
+ },
+};
diff --git a/comm/mail/components/compose/content/MsgComposeCommands.js b/comm/mail/components/compose/content/MsgComposeCommands.js
new file mode 100644
index 0000000000..6a0045b58d
--- /dev/null
+++ b/comm/mail/components/compose/content/MsgComposeCommands.js
@@ -0,0 +1,11654 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../../toolkit/content/contentAreaUtils.js */
+/* import-globals-from ../../../../mailnews/addrbook/content/abDragDrop.js */
+/* import-globals-from ../../../../mailnews/base/prefs/content/accountUtils.js */
+/* import-globals-from ../../../base/content/contentAreaClick.js */
+/* import-globals-from ../../../base/content/mailCore.js */
+/* import-globals-from ../../../base/content/messenger-customization.js */
+/* import-globals-from ../../../base/content/toolbarIconColor.js */
+/* import-globals-from ../../../base/content/utilityOverlay.js */
+/* import-globals-from ../../../base/content/viewZoomOverlay.js */
+/* import-globals-from ../../../base/content/widgets/browserPopups.js */
+/* import-globals-from ../../../extensions/openpgp/content/ui/keyAssistant.js */
+/* import-globals-from addressingWidgetOverlay.js */
+/* import-globals-from cloudAttachmentLinkManager.js */
+/* import-globals-from ComposerCommands.js */
+/* import-globals-from editor.js */
+/* import-globals-from editorUtilities.js */
+
+/**
+ * Commands for the message composition window.
+ */
+
+// Ensure the activity modules are loaded for this window.
+ChromeUtils.import("resource:///modules/activity/activityModules.jsm");
+var { AttachmentChecker } = ChromeUtils.import(
+ "resource:///modules/AttachmentChecker.jsm"
+);
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs",
+ ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ FolderUtils: "resource:///modules/FolderUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "l10nCompose",
+ () =>
+ new Localization([
+ "branding/brand.ftl",
+ "messenger/messengercompose/messengercompose.ftl",
+ ])
+);
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "l10nComposeSync",
+ () =>
+ new Localization(
+ ["branding/brand.ftl", "messenger/messengercompose/messengercompose.ftl"],
+ true
+ )
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://messenger/content/printUtils.js"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+});
+
+/**
+ * Global message window object. This is used by mail-offline.js and therefore
+ * should not be renamed. We need to avoid doing this kind of cross file global
+ * stuff in the future and instead pass this object as parameter when needed by
+ * functions in the other js file.
+ */
+var msgWindow;
+
+var gMessenger;
+
+/**
+ * Global variables, need to be re-initialized every time mostly because
+ * we need to release them when the window closes.
+ */
+var gMsgCompose;
+var gOriginalMsgURI;
+var gWindowLocked;
+var gSendLocked;
+var gContentChanged;
+var gSubjectChanged;
+var gAutoSaving;
+var gCurrentIdentity;
+var defaultSaveOperation;
+var gSendOperationInProgress;
+var gSaveOperationInProgress;
+var gCloseWindowAfterSave;
+var gSavedSendNowKey;
+var gContextMenu;
+var gLastFocusElement = null;
+var gLoadingComplete = false;
+
+var gAttachmentBucket;
+var gAttachmentCounter;
+/**
+ * typedef {Object} FocusArea
+ *
+ * @property {Element} root - The root of a given area of the UI.
+ * @property {moveFocusWithin} focus - A method to move the focus within the
+ * root.
+ */
+/**
+ * @callback moveFocusWithin
+ *
+ * @param {Element} root - The element to move the focus within.
+ *
+ * @returns {boolean} - Whether the focus was successfully moved to within the
+ * given element.
+ */
+/**
+ * An ordered list of non-intersecting areas we want to jump focus between.
+ * Ordering should be in the same order as tab focus. See
+ * {@link moveFocusToNeighbouringArea}.
+ *
+ * @type {FocusArea[]}
+ */
+var gFocusAreas;
+// TODO: Maybe the following two variables can be combined.
+var gManualAttachmentReminder;
+var gDisableAttachmentReminder;
+var gComposeType;
+var gLanguageObserver;
+var gRecipientObserver;
+var gWantCannotEncryptBCCNotification = true;
+var gRecipientKeysObserver;
+var gCheckPublicRecipientsTimer;
+var gBodyFromArgs;
+
+// gSMFields is the nsIMsgComposeSecure instance for S/MIME.
+// gMsgCompose.compFields.composeSecure is set to this instance most of
+// the time. Because the S/MIME code has no knowledge of the OpenPGP
+// implementation, gMsgCompose.compFields.composeSecure is set to an
+// instance of PgpMimeEncrypt only temporarily. Keeping variable
+// gSMFields separate allows switching as needed.
+var gSMFields = null;
+
+var gSMPendingCertLookupSet = new Set();
+var gSMCertsAlreadyLookedUpInLDAP = new Set();
+
+var gSelectedTechnologyIsPGP = false;
+
+// The initial flags store the value we used at composer open time.
+// Some flags might be automatically changed as a consequence of other
+// changes. When reverting automatic actions, the initial flags help
+// us know what value we should use for restoring.
+
+var gSendSigned = false;
+
+var gAttachMyPublicPGPKey = false;
+
+var gSendEncrypted = false;
+
+// gEncryptSubject contains the preference for subject encryption,
+// considered only if encryption is enabled and the technology allows it.
+// In other words, gEncryptSubject might be set to true, but if
+// encryption is disabled, or if S/MIME is used,
+// gEncryptSubject==true is ignored.
+var gEncryptSubject = false;
+
+var gUserTouchedSendEncrypted = false;
+var gUserTouchedSendSigned = false;
+var gUserTouchedAttachMyPubKey = false;
+var gUserTouchedEncryptSubject = false;
+
+var gIsRelatedToEncryptedOriginal = false;
+
+var gOpened = Date.now();
+
+var gEncryptedURIService = Cc[
+ "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1"
+].getService(Ci.nsIEncryptedSMIMEURIsService);
+
+try {
+ var gDragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+ );
+} catch (e) {}
+
+/**
+ * Boolean variable to keep track of the dragging action of files above the
+ * compose window.
+ *
+ * @type {boolean}
+ */
+var gIsDraggingAttachments;
+
+/**
+ * Boolean variable to allow showing the attach inline overlay when dragging
+ * links that otherwise would only trigger the add as attachment overlay.
+ *
+ * @type {boolean}
+ */
+var gIsValidInline;
+
+// i18n globals
+var _gComposeBundle;
+function getComposeBundle() {
+ // That one has to be lazy. Getting a reference to an element with a XBL
+ // binding attached will cause the XBL constructors to fire if they haven't
+ // already. If we get a reference to the compose bundle at script load-time,
+ // this will cause the XBL constructor that's responsible for the personas to
+ // fire up, thus executing the personas code while the DOM is not fully built.
+ // Since this <script> comes before the <statusbar>, the Personas code will
+ // fail.
+ if (!_gComposeBundle) {
+ _gComposeBundle = document.getElementById("bundle_composeMsgs");
+ }
+ return _gComposeBundle;
+}
+
+var gLastWindowToHaveFocus;
+var gLastKnownComposeStates;
+var gReceiptOptionChanged;
+var gDSNOptionChanged;
+var gAttachVCardOptionChanged;
+
+var gAutoSaveInterval;
+var gAutoSaveTimeout;
+var gAutoSaveKickedIn;
+var gEditingDraft;
+var gNumUploadingAttachments;
+
+// From the user's point-of-view, is spell checking enabled? This value only
+// changes if the user makes the change, it's not affected by the process of
+// sending or saving the message or any other reason the actual state of the
+// spellchecker might change.
+var gSpellCheckingEnabled;
+
+var kComposeAttachDirPrefName = "mail.compose.attach.dir";
+
+window.addEventListener("unload", event => {
+ ComposeUnload();
+});
+window.addEventListener("load", event => {
+ ComposeLoad();
+});
+window.addEventListener("close", event => {
+ if (!ComposeCanClose()) {
+ event.preventDefault();
+ }
+});
+window.addEventListener("focus", event => {
+ EditorOnFocus();
+});
+window.addEventListener("click", event => {
+ composeWindowOnClick(event);
+});
+
+document.addEventListener("focusin", event => {
+ // Listen for focusin event in composition. gLastFocusElement might well be
+ // null, e.g. when focusin enters a different document like contacts sidebar.
+ gLastFocusElement = event.relatedTarget;
+});
+
+// For WebExtensions.
+this.__defineGetter__("browser", GetCurrentEditorElement);
+
+/**
+ * @implements {nsIXULBrowserWindow}
+ */
+var XULBrowserWindow = {
+ // Used to show the link-being-hovered-over in the status bar. Do nothing here.
+ setOverLink(url, anchorElt) {},
+
+ // Called before links are navigated to to allow us to retarget them if needed.
+ onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) {
+ return originalTarget;
+ },
+
+ // Called by BrowserParent::RecvShowTooltip.
+ showTooltip(xDevPix, yDevPix, tooltip, direction, browser) {
+ if (
+ Cc["@mozilla.org/widget/dragservice;1"]
+ .getService(Ci.nsIDragService)
+ .getCurrentSession()
+ ) {
+ return;
+ }
+
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.label = tooltip;
+ elt.style.direction = direction;
+ elt.openPopupAtScreen(
+ xDevPix / window.devicePixelRatio,
+ yDevPix / window.devicePixelRatio,
+ false,
+ null
+ );
+ },
+
+ // Called by BrowserParent::RecvHideTooltip.
+ hideTooltip() {
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.hidePopup();
+ },
+
+ getTabCount() {
+ return 1;
+ },
+};
+window
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShellTreeItem)
+ .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.XULBrowserWindow;
+
+// Observer for the autocomplete input.
+const inputObserver = {
+ observe: (subject, topic, data) => {
+ if (topic == "autocomplete-did-enter-text") {
+ let input = subject.QueryInterface(
+ Ci.nsIAutoCompleteInput
+ ).wrappedJSObject;
+
+ // Interrupt if there's no input proxy, or the input doesn't have an ID,
+ // the latter meaning that the autocomplete event was triggered within an
+ // already existing pill, so we don't want to create a new pill.
+ if (!input || !input.id) {
+ return;
+ }
+
+ // Trigger the pill creation.
+ recipientAddPills(document.getElementById(input.id));
+ }
+ },
+};
+
+const keyObserver = {
+ observe: async (subject, topic, data) => {
+ switch (topic) {
+ case "openpgp-key-change":
+ EnigmailKeyRing.clearCache();
+ // fall through
+ case "openpgp-acceptance-change":
+ checkEncryptionState(topic);
+ gKeyAssistant.onExternalKeyChange();
+ break;
+ default:
+ break;
+ }
+ },
+};
+
+// Non translatable international shortcuts.
+var SHOW_TO_KEY = "T";
+var SHOW_CC_KEY = "C";
+var SHOW_BCC_KEY = "B";
+
+function InitializeGlobalVariables() {
+ gMessenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+ gMsgCompose = null;
+ gOriginalMsgURI = null;
+ gWindowLocked = false;
+ gContentChanged = false;
+ gSubjectChanged = false;
+ gCurrentIdentity = null;
+ defaultSaveOperation = "draft";
+ gSendOperationInProgress = false;
+ gSaveOperationInProgress = false;
+ gAutoSaving = false;
+ gCloseWindowAfterSave = false;
+ gSavedSendNowKey = null;
+ gManualAttachmentReminder = false;
+ gDisableAttachmentReminder = false;
+ gLanguageObserver = null;
+ gRecipientObserver = null;
+
+ gLastWindowToHaveFocus = null;
+ gLastKnownComposeStates = {};
+ gReceiptOptionChanged = false;
+ gDSNOptionChanged = false;
+ gAttachVCardOptionChanged = false;
+ gNumUploadingAttachments = 0;
+ // eslint-disable-next-line no-global-assign
+ msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+ );
+ MailServices.mailSession.AddMsgWindow(msgWindow);
+
+ // Add the observer.
+ Services.obs.addObserver(inputObserver, "autocomplete-did-enter-text");
+ Services.obs.addObserver(keyObserver, "openpgp-key-change");
+ Services.obs.addObserver(keyObserver, "openpgp-acceptance-change");
+}
+InitializeGlobalVariables();
+
+function ReleaseGlobalVariables() {
+ gCurrentIdentity = null;
+ gMsgCompose = null;
+ gOriginalMsgURI = null;
+ gMessenger = null;
+ gRecipientObserver = null;
+ gDisableAttachmentReminder = false;
+ _gComposeBundle = null;
+ MailServices.mailSession.RemoveMsgWindow(msgWindow);
+ // eslint-disable-next-line no-global-assign
+ msgWindow = null;
+
+ gLastKnownComposeStates = null;
+
+ // Remove the observers.
+ Services.obs.removeObserver(inputObserver, "autocomplete-did-enter-text");
+ Services.obs.removeObserver(keyObserver, "openpgp-key-change");
+ Services.obs.removeObserver(keyObserver, "openpgp-acceptance-change");
+}
+
+// Notification box shown at the bottom of the window.
+XPCOMUtils.defineLazyGetter(this, "gComposeNotification", () => {
+ return new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document.getElementById("compose-notification-bottom").append(element);
+ });
+});
+
+/**
+ * Get the first next sibling element matching the selector (if specified).
+ *
+ * @param {HTMLElement} element - The source element whose sibling to look for.
+ * @param {string} [selector] - The CSS query selector to match.
+ *
+ * @returns {(HTMLElement|null)} - The first matching sibling element, or null.
+ */
+function getNextSibling(element, selector) {
+ let sibling = element.nextElementSibling;
+ if (!selector) {
+ // If there's no selector, return the first next sibling.
+ return sibling;
+ }
+ while (sibling) {
+ if (sibling.matches(selector)) {
+ // Return the current sibling if it matches the selector.
+ return sibling;
+ }
+ // Otherwise, continue the loop with the following next sibling.
+ sibling = sibling.nextElementSibling;
+ }
+ return null;
+}
+
+/**
+ * Get the first previous sibling element matching the selector (if specified).
+ *
+ * @param {HTMLElement} element - The source element whose sibling to look for.
+ * @param {string} [selector] - The CSS query selector to match.
+ *
+ * @returns {(HTMLElement|null)} - The first matching sibling element, or null.
+ */
+function getPreviousSibling(element, selector) {
+ let sibling = element.previousElementSibling;
+ if (!selector) {
+ // If there's no selector, return the first previous sibling.
+ return sibling;
+ }
+ while (sibling) {
+ if (sibling.matches(selector)) {
+ // Return the current sibling if it matches the selector.
+ return sibling;
+ }
+ // Otherwise, continue the loop with the preceding previous sibling.
+ sibling = sibling.previousElementSibling;
+ }
+ return null;
+}
+
+/**
+ * Get a pretty, human-readable shortcut key string from a given <key> id.
+ *
+ * @param aKeyId the ID of a <key> element
+ * @returns string pretty, human-readable shortcut key string from the <key>
+ */
+function getPrettyKey(aKeyId) {
+ return ShortcutUtils.prettifyShortcut(document.getElementById(aKeyId));
+}
+
+/**
+ * Disables or enables editable elements in the window.
+ * The elements to operate on are marked with the "disableonsend" attribute.
+ * This includes elements like the address list, attachment list, subject
+ * and message body.
+ *
+ * @param aDisable true = disable items. false = enable items.
+ */
+function updateEditableFields(aDisable) {
+ if (!gMsgCompose) {
+ return;
+ }
+
+ if (aDisable) {
+ gMsgCompose.editor.flags |= Ci.nsIEditor.eEditorReadonlyMask;
+ } else {
+ gMsgCompose.editor.flags &= ~Ci.nsIEditor.eEditorReadonlyMask;
+
+ try {
+ let checker = GetCurrentEditor().getInlineSpellChecker(true);
+ checker.enableRealTimeSpell = gSpellCheckingEnabled;
+ } catch (ex) {
+ // An error will be thrown if there are no dictionaries. Just ignore it.
+ }
+ }
+
+ // Disable all the input fields and labels.
+ for (let element of document.querySelectorAll('[disableonsend="true"]')) {
+ element.disabled = aDisable;
+ }
+
+ // Update the UI of the addressing rows.
+ for (let row of document.querySelectorAll(".address-container")) {
+ row.classList.toggle("disable-container", aDisable);
+ }
+
+ // Prevent any interaction with the addressing pills.
+ for (let pill of document.querySelectorAll("mail-address-pill")) {
+ pill.toggleAttribute("disabled", aDisable);
+ }
+}
+
+/**
+ * Small helper function to check whether the node passed in is a signature.
+ * Note that a text node is not a DOM element, hence .localName can't be used.
+ */
+function isSignature(aNode) {
+ return (
+ ["DIV", "PRE"].includes(aNode.nodeName) &&
+ aNode.classList.contains("moz-signature")
+ );
+}
+
+var stateListener = {
+ NotifyComposeFieldsReady() {
+ ComposeFieldsReady();
+ updateSendCommands(true);
+ },
+
+ NotifyComposeBodyReady() {
+ // Look all the possible compose types (nsIMsgComposeParams.idl):
+ switch (gComposeType) {
+ case Ci.nsIMsgCompType.MailToUrl:
+ gBodyFromArgs = true;
+ // Falls through
+ case Ci.nsIMsgCompType.New:
+ case Ci.nsIMsgCompType.NewsPost:
+ case Ci.nsIMsgCompType.ForwardAsAttachment:
+ this.NotifyComposeBodyReadyNew();
+ break;
+
+ case Ci.nsIMsgCompType.Reply:
+ case Ci.nsIMsgCompType.ReplyAll:
+ case Ci.nsIMsgCompType.ReplyToSender:
+ case Ci.nsIMsgCompType.ReplyToGroup:
+ case Ci.nsIMsgCompType.ReplyToSenderAndGroup:
+ case Ci.nsIMsgCompType.ReplyWithTemplate:
+ case Ci.nsIMsgCompType.ReplyToList:
+ this.NotifyComposeBodyReadyReply();
+ break;
+
+ case Ci.nsIMsgCompType.Redirect:
+ case Ci.nsIMsgCompType.ForwardInline:
+ this.NotifyComposeBodyReadyForwardInline();
+ break;
+
+ case Ci.nsIMsgCompType.EditTemplate:
+ defaultSaveOperation = "template";
+ break;
+ case Ci.nsIMsgCompType.Draft:
+ case Ci.nsIMsgCompType.Template:
+ case Ci.nsIMsgCompType.EditAsNew:
+ break;
+
+ default:
+ dump(
+ "Unexpected nsIMsgCompType in NotifyComposeBodyReady (" +
+ gComposeType +
+ ")\n"
+ );
+ }
+
+ // Setting the selected item in the identity list will cause an
+ // identity/signature switch. This can only be done once the message
+ // body has already been assembled with the signature we need to switch.
+ if (gMsgCompose.identity != gCurrentIdentity) {
+ let identityList = document.getElementById("msgIdentity");
+ identityList.selectedItem = identityList.getElementsByAttribute(
+ "identitykey",
+ gMsgCompose.identity.key
+ )[0];
+ LoadIdentity(false);
+ }
+ if (gMsgCompose.composeHTML) {
+ loadHTMLMsgPrefs();
+ }
+ AdjustFocus();
+ },
+
+ NotifyComposeBodyReadyNew() {
+ let useParagraph = Services.prefs.getBoolPref(
+ "mail.compose.default_to_paragraph"
+ );
+ let insertParagraph = gMsgCompose.composeHTML && useParagraph;
+
+ let mailBody = getBrowser().contentDocument.querySelector("body");
+ if (insertParagraph && gBodyFromArgs) {
+ // Check for "empty" body before allowing paragraph to be inserted.
+ // Non-empty bodies in a new message can occur when clicking on a
+ // mailto link or when using the command line option -compose.
+ // An "empty" body can be one of these three cases:
+ // 1) <br> and nothing follows (no next sibling)
+ // 2) <div/pre class="moz-signature">
+ // 3) No elements, just text
+ // Note that <br><div/pre class="moz-signature"> doesn't happen in
+ // paragraph mode.
+ let firstChild = mailBody.firstChild;
+ let firstElementChild = mailBody.firstElementChild;
+ if (firstElementChild) {
+ if (
+ (firstElementChild.nodeName != "BR" ||
+ firstElementChild.nextElementSibling) &&
+ !isSignature(firstElementChild)
+ ) {
+ insertParagraph = false;
+ }
+ } else if (firstChild && firstChild.nodeType == Node.TEXT_NODE) {
+ insertParagraph = false;
+ }
+ }
+
+ // Control insertion of line breaks.
+ if (insertParagraph) {
+ let editor = GetCurrentEditor();
+ editor.enableUndo(false);
+
+ editor.selection.collapse(mailBody, 0);
+ let pElement = editor.createElementWithDefaults("p");
+ pElement.appendChild(editor.createElementWithDefaults("br"));
+ editor.insertElementAtSelection(pElement, false);
+
+ document.getElementById("cmd_paragraphState").setAttribute("state", "p");
+
+ editor.beginningOfDocument();
+ editor.enableUndo(true);
+ editor.resetModificationCount();
+ } else {
+ document.getElementById("cmd_paragraphState").setAttribute("state", "");
+ }
+ onParagraphFormatChange();
+ },
+
+ NotifyComposeBodyReadyReply() {
+ // Control insertion of line breaks.
+ let useParagraph = Services.prefs.getBoolPref(
+ "mail.compose.default_to_paragraph"
+ );
+ if (gMsgCompose.composeHTML && useParagraph) {
+ let mailBody = getBrowser().contentDocument.querySelector("body");
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+
+ // Make sure the selection isn't inside the signature.
+ if (isSignature(mailBody.firstElementChild)) {
+ selection.collapse(mailBody, 0);
+ }
+
+ let range = selection.getRangeAt(0);
+ let start = range.startOffset;
+
+ if (start != range.endOffset) {
+ // The selection is not collapsed, most likely due to the
+ // "select the quote" option. In this case we do nothing.
+ return;
+ }
+
+ if (range.startContainer != mailBody) {
+ dump("Unexpected selection in NotifyComposeBodyReadyReply\n");
+ return;
+ }
+
+ editor.enableUndo(false);
+
+ let pElement = editor.createElementWithDefaults("p");
+ pElement.appendChild(editor.createElementWithDefaults("br"));
+ editor.insertElementAtSelection(pElement, false);
+
+ // Position into the paragraph.
+ selection.collapse(pElement, 0);
+
+ document.getElementById("cmd_paragraphState").setAttribute("state", "p");
+
+ editor.enableUndo(true);
+ editor.resetModificationCount();
+ } else {
+ document.getElementById("cmd_paragraphState").setAttribute("state", "");
+ }
+ onParagraphFormatChange();
+ },
+
+ NotifyComposeBodyReadyForwardInline() {
+ let mailBody = getBrowser().contentDocument.querySelector("body");
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+
+ editor.enableUndo(false);
+
+ // Control insertion of line breaks.
+ selection.collapse(mailBody, 0);
+ let useParagraph = Services.prefs.getBoolPref(
+ "mail.compose.default_to_paragraph"
+ );
+ if (gMsgCompose.composeHTML && useParagraph) {
+ let pElement = editor.createElementWithDefaults("p");
+ let brElement = editor.createElementWithDefaults("br");
+ pElement.appendChild(brElement);
+ editor.insertElementAtSelection(pElement, false);
+ document.getElementById("cmd_paragraphState").setAttribute("state", "p");
+ } else {
+ // insertLineBreak() has been observed to insert two <br> elements
+ // instead of one before a <div>, so we'll do it ourselves here.
+ let brElement = editor.createElementWithDefaults("br");
+ editor.insertElementAtSelection(brElement, false);
+ document.getElementById("cmd_paragraphState").setAttribute("state", "");
+ }
+
+ onParagraphFormatChange();
+ editor.beginningOfDocument();
+ editor.enableUndo(true);
+ editor.resetModificationCount();
+ },
+
+ ComposeProcessDone(aResult) {
+ ToggleWindowLock(false);
+
+ if (aResult == Cr.NS_OK) {
+ if (!gAutoSaving) {
+ SetContentAndBodyAsUnmodified();
+ }
+
+ if (gCloseWindowAfterSave) {
+ // Notify the SendListener that Send has been aborted and Stopped
+ if (gMsgCompose) {
+ gMsgCompose.onSendNotPerformed(null, Cr.NS_ERROR_ABORT);
+ }
+
+ MsgComposeCloseWindow();
+ }
+ } else if (gAutoSaving) {
+ // If we failed to save, and we're autosaving, need to re-mark the editor
+ // as changed, so that we won't lose the changes.
+ gMsgCompose.bodyModified = true;
+ gContentChanged = true;
+ }
+ gAutoSaving = false;
+ gCloseWindowAfterSave = false;
+ },
+
+ SaveInFolderDone(folderURI) {
+ DisplaySaveFolderDlg(folderURI);
+ },
+};
+
+var gSendListener = {
+ // nsIMsgSendListener
+ onStartSending(aMsgID, aMsgSize) {},
+ onProgress(aMsgID, aProgress, aProgressMax) {},
+ onStatus(aMsgID, aMsg) {},
+ onStopSending(aMsgID, aStatus, aMsg, aReturnFile) {
+ if (Components.isSuccessCode(aStatus)) {
+ Services.obs.notifyObservers(null, "mail:composeSendSucceeded", aMsgID);
+ }
+ },
+ onGetDraftFolderURI(aMsgID, aFolderURI) {},
+ onSendNotPerformed(aMsgID, aStatus) {},
+ onTransportSecurityError(msgID, status, secInfo, location) {
+ // We're only interested in Bad Cert errors here.
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let errorClass = nssErrorsService.getErrorClass(status);
+ if (errorClass != Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ return;
+ }
+
+ // Give the user the option of adding an exception for the bad cert.
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location,
+ };
+ window.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ // params.exceptionAdded will be set if the user added an exception.
+ },
+};
+
+// all progress notifications are done through the nsIWebProgressListener implementation...
+var progressListener = {
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ let progressMeter = document.getElementById("compose-progressmeter");
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ progressMeter.hidden = false;
+ progressMeter.removeAttribute("value");
+ }
+
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ gSendOperationInProgress = false;
+ gSaveOperationInProgress = false;
+ progressMeter.hidden = true;
+ progressMeter.value = 0;
+ document.getElementById("statusText").textContent = "";
+ Services.obs.notifyObservers(
+ { composeWindow: window },
+ "mail:composeSendProgressStop"
+ );
+ }
+ },
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ // Calculate percentage.
+ var percent;
+ if (aMaxTotalProgress > 0) {
+ percent = Math.round((aCurTotalProgress * 100) / aMaxTotalProgress);
+ if (percent > 100) {
+ percent = 100;
+ }
+
+ // Advance progress meter.
+ document.getElementById("compose-progressmeter").value = percent;
+ } else {
+ // Progress meter should be barber-pole in this case.
+ document.getElementById("compose-progressmeter").removeAttribute("value");
+ }
+ },
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ // we can ignore this notification
+ },
+
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ // Looks like it's possible that we get call while the document has been already delete!
+ // therefore we need to protect ourself by using try/catch
+ try {
+ let statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = aMessage;
+ }
+ } catch (ex) {}
+ },
+
+ onSecurityChange(aWebProgress, aRequest, state) {
+ // we can ignore this notification
+ },
+
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {
+ // we can ignore this notification
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+var defaultController = {
+ commands: {
+ cmd_attachFile: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ AttachFile();
+ },
+ },
+
+ cmd_attachCloud: {
+ isEnabled() {
+ // Hide the command entirely if there are no cloud accounts or
+ // the feature is disabled.
+ let cmd = document.getElementById("cmd_attachCloud");
+ cmd.hidden =
+ !Services.prefs.getBoolPref("mail.cloud_files.enabled") ||
+ cloudFileAccounts.configuredAccounts.length == 0 ||
+ Services.io.offline;
+ return !cmd.hidden && !gWindowLocked;
+ },
+ doCommand() {
+ // We should never actually call this, since the <command> node calls
+ // a different function.
+ },
+ },
+
+ cmd_attachPage: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ gMsgCompose.allowRemoteContent = true;
+ AttachPage();
+ },
+ },
+
+ cmd_attachVCard: {
+ isEnabled() {
+ let cmd = document.getElementById("cmd_attachVCard");
+ cmd.setAttribute("checked", gMsgCompose.compFields.attachVCard);
+ return !!gCurrentIdentity?.escapedVCard;
+ },
+ doCommand() {},
+ },
+
+ cmd_attachPublicKey: {
+ isEnabled() {
+ let cmd = document.getElementById("cmd_attachPublicKey");
+ cmd.setAttribute("checked", gAttachMyPublicPGPKey);
+ return isPgpConfigured();
+ },
+ doCommand() {},
+ },
+
+ cmd_toggleAttachmentPane: {
+ isEnabled() {
+ return !gWindowLocked && gAttachmentBucket.itemCount;
+ },
+ doCommand() {
+ toggleAttachmentPane("toggle");
+ },
+ },
+
+ cmd_reorderAttachments: {
+ isEnabled() {
+ if (!gAttachmentBucket.itemCount) {
+ let reorderAttachmentsPanel = document.getElementById(
+ "reorderAttachmentsPanel"
+ );
+ if (reorderAttachmentsPanel.state == "open") {
+ // When the panel is open and all attachments get deleted,
+ // we get notified here and want to close the panel.
+ reorderAttachmentsPanel.hidePopup();
+ }
+ }
+ return gAttachmentBucket.itemCount > 1;
+ },
+ doCommand() {
+ showReorderAttachmentsPanel();
+ },
+ },
+
+ cmd_removeAllAttachments: {
+ isEnabled() {
+ return !gWindowLocked && gAttachmentBucket.itemCount;
+ },
+ doCommand() {
+ RemoveAllAttachments();
+ },
+ },
+
+ cmd_close: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ if (ComposeCanClose()) {
+ window.close();
+ }
+ },
+ },
+
+ cmd_saveDefault: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ Save();
+ },
+ },
+
+ cmd_saveAsFile: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ SaveAsFile(true);
+ },
+ },
+
+ cmd_saveAsDraft: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ SaveAsDraft();
+ },
+ },
+
+ cmd_saveAsTemplate: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ SaveAsTemplate();
+ },
+ },
+
+ cmd_sendButton: {
+ isEnabled() {
+ return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked;
+ },
+ doCommand() {
+ if (Services.io.offline) {
+ SendMessageLater();
+ } else {
+ SendMessage();
+ }
+ },
+ },
+
+ cmd_sendNow: {
+ isEnabled() {
+ return (
+ !gWindowLocked &&
+ !Services.io.offline &&
+ !gSendLocked &&
+ !gNumUploadingAttachments
+ );
+ },
+ doCommand() {
+ SendMessage();
+ },
+ },
+
+ cmd_sendLater: {
+ isEnabled() {
+ return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked;
+ },
+ doCommand() {
+ SendMessageLater();
+ },
+ },
+
+ cmd_sendWithCheck: {
+ isEnabled() {
+ return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked;
+ },
+ doCommand() {
+ SendMessageWithCheck();
+ },
+ },
+
+ cmd_print: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ DoCommandPrint();
+ },
+ },
+
+ cmd_delete: {
+ isEnabled() {
+ let cmdDelete = document.getElementById("cmd_delete");
+ let textValue = cmdDelete.getAttribute("valueDefault");
+ let accesskeyValue = cmdDelete.getAttribute("valueDefaultAccessKey");
+
+ cmdDelete.setAttribute("label", textValue);
+ cmdDelete.setAttribute("accesskey", accesskeyValue);
+
+ return false;
+ },
+ doCommand() {},
+ },
+
+ cmd_account: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ let currentAccountKey = getCurrentAccountKey();
+ let account = MailServices.accounts.getAccount(currentAccountKey);
+ MsgAccountManager(null, account.incomingServer);
+ },
+ },
+
+ cmd_showFormatToolbar: {
+ isEnabled() {
+ return gMsgCompose && gMsgCompose.composeHTML;
+ },
+ doCommand() {
+ goToggleToolbar("FormatToolbar", "menu_showFormatToolbar");
+ },
+ },
+
+ cmd_quoteMessage: {
+ isEnabled() {
+ let selectedURIs = GetSelectedMessages();
+ return selectedURIs && selectedURIs.length > 0;
+ },
+ doCommand() {
+ QuoteSelectedMessage();
+ },
+ },
+
+ cmd_toggleReturnReceipt: {
+ isEnabled() {
+ if (!gMsgCompose) {
+ return false;
+ }
+ return !gWindowLocked;
+ },
+ doCommand() {
+ ToggleReturnReceipt();
+ },
+ },
+
+ cmd_fullZoomReduce: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ ZoomManager.reduce();
+ },
+ },
+
+ cmd_fullZoomEnlarge: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ ZoomManager.enlarge();
+ },
+ },
+
+ cmd_fullZoomReset: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ ZoomManager.reset();
+ },
+ },
+
+ cmd_spelling: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ window.cancelSendMessage = false;
+ var skipBlockQuotes =
+ window.document.documentElement.getAttribute("windowtype") ==
+ "msgcompose";
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml",
+ "_blank",
+ "dialog,close,titlebar,modal,resizable",
+ false,
+ skipBlockQuotes,
+ true
+ );
+ },
+ },
+
+ cmd_fullZoomToggle: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ ZoomManager.toggleZoom();
+ },
+ },
+ },
+
+ supportsCommand(aCommand) {
+ return aCommand in this.commands;
+ },
+
+ isCommandEnabled(aCommand) {
+ if (!this.supportsCommand(aCommand)) {
+ return false;
+ }
+ return this.commands[aCommand].isEnabled();
+ },
+
+ doCommand(aCommand) {
+ if (!this.supportsCommand(aCommand)) {
+ return;
+ }
+ var cmd = this.commands[aCommand];
+ if (!cmd.isEnabled()) {
+ return;
+ }
+ cmd.doCommand();
+ },
+
+ onEvent(event) {},
+};
+
+var attachmentBucketController = {
+ commands: {
+ cmd_selectAll: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ gAttachmentBucket.selectAll();
+ },
+ },
+
+ cmd_delete: {
+ isEnabled() {
+ let cmdDelete = document.getElementById("cmd_delete");
+ let textValue = getComposeBundle().getString("removeAttachmentMsgs");
+ textValue = PluralForm.get(gAttachmentBucket.selectedCount, textValue);
+ let accesskeyValue = cmdDelete.getAttribute(
+ "valueRemoveAttachmentAccessKey"
+ );
+ cmdDelete.setAttribute("label", textValue);
+ cmdDelete.setAttribute("accesskey", accesskeyValue);
+
+ return gAttachmentBucket.selectedCount;
+ },
+ doCommand() {
+ RemoveSelectedAttachment();
+ },
+ },
+
+ cmd_openAttachment: {
+ isEnabled() {
+ return gAttachmentBucket.selectedCount == 1;
+ },
+ doCommand() {
+ OpenSelectedAttachment();
+ },
+ },
+
+ cmd_renameAttachment: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount == 1 &&
+ !gAttachmentBucket.selectedItem.uploading
+ );
+ },
+ doCommand() {
+ RenameSelectedAttachment();
+ },
+ },
+
+ cmd_moveAttachmentLeft: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount && !attachmentsSelectionIsBlock("top")
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("left");
+ },
+ },
+
+ cmd_moveAttachmentRight: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount &&
+ !attachmentsSelectionIsBlock("bottom")
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("right");
+ },
+ },
+
+ cmd_moveAttachmentBundleUp: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount > 1 && !attachmentsSelectionIsBlock()
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("bundleUp");
+ },
+ },
+
+ cmd_moveAttachmentBundleDown: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount > 1 && !attachmentsSelectionIsBlock()
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("bundleDown");
+ },
+ },
+
+ cmd_moveAttachmentTop: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount && !attachmentsSelectionIsBlock("top")
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("top");
+ },
+ },
+
+ cmd_moveAttachmentBottom: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount &&
+ !attachmentsSelectionIsBlock("bottom")
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("bottom");
+ },
+ },
+
+ cmd_sortAttachmentsToggle: {
+ isEnabled() {
+ let sortSelection;
+ let currSortOrder;
+ let isBlock;
+ let btnAscending;
+ let toggleCmd = document.getElementById("cmd_sortAttachmentsToggle");
+ let toggleBtn = document.getElementById("btn_sortAttachmentsToggle");
+ let sortDirection;
+ let btnLabelAttr;
+
+ if (
+ gAttachmentBucket.selectedCount > 1 &&
+ gAttachmentBucket.selectedCount < gAttachmentBucket.itemCount
+ ) {
+ // Sort selected attachments only, which needs at least 2 of them,
+ // but not all.
+ sortSelection = true;
+ currSortOrder = attachmentsSelectionGetSortOrder();
+ isBlock = attachmentsSelectionIsBlock();
+ // If current sorting is ascending AND it's a block; OR
+ // if current sorting is descending AND it's NOT a block yet:
+ // Offer toggle button face to sort descending.
+ // In all other cases, offer toggle button face to sort ascending.
+ btnAscending = !(
+ (currSortOrder == "ascending" && isBlock) ||
+ (currSortOrder == "descending" && !isBlock)
+ );
+ // Set sortDirection for toggleCmd, and respective button face.
+ if (btnAscending) {
+ sortDirection = "ascending";
+ btnLabelAttr = "label-selection-AZ";
+ } else {
+ sortDirection = "descending";
+ btnLabelAttr = "label-selection-ZA";
+ }
+ } else {
+ // gAttachmentBucket.selectedCount <= 1 or all attachments are selected.
+ // Sort all attachments.
+ sortSelection = false;
+ currSortOrder = attachmentsGetSortOrder();
+ btnAscending = !(currSortOrder == "ascending");
+ // Set sortDirection for toggleCmd, and respective button face.
+ if (btnAscending) {
+ sortDirection = "ascending";
+ btnLabelAttr = "label-AZ";
+ } else {
+ sortDirection = "descending";
+ btnLabelAttr = "label-ZA";
+ }
+ }
+
+ // Set the sort direction for toggleCmd.
+ toggleCmd.setAttribute("sortdirection", sortDirection);
+ // The button's icon is set dynamically via CSS involving the button's
+ // sortdirection attribute, which is forwarded by the command.
+ toggleBtn.setAttribute("label", toggleBtn.getAttribute(btnLabelAttr));
+
+ return sortSelection
+ ? !(currSortOrder == "equivalent" && isBlock)
+ : !(currSortOrder == "equivalent");
+ },
+ doCommand() {
+ moveSelectedAttachments("toggleSort");
+ },
+ },
+
+ cmd_convertCloud: {
+ isEnabled() {
+ // Hide the command entirely if Filelink is disabled, or if there are
+ // no cloud accounts.
+ let cmd = document.getElementById("cmd_convertCloud");
+
+ cmd.hidden =
+ !Services.prefs.getBoolPref("mail.cloud_files.enabled") ||
+ cloudFileAccounts.configuredAccounts.length == 0 ||
+ Services.io.offline;
+ if (cmd.hidden) {
+ return false;
+ }
+
+ for (let item of gAttachmentBucket.selectedItems) {
+ if (item.uploading) {
+ return false;
+ }
+ }
+ return true;
+ },
+ doCommand() {
+ // We should never actually call this, since the <command> node calls
+ // a different function.
+ },
+ },
+
+ cmd_convertAttachment: {
+ isEnabled() {
+ if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) {
+ return false;
+ }
+
+ for (let item of gAttachmentBucket.selectedItems) {
+ if (item.uploading) {
+ return false;
+ }
+ }
+ return true;
+ },
+ doCommand() {
+ convertSelectedToRegularAttachment();
+ },
+ },
+
+ cmd_cancelUpload: {
+ isEnabled() {
+ let cmd = document.getElementById(
+ "composeAttachmentContext_cancelUploadItem"
+ );
+
+ // If Filelink is disabled, hide this menuitem and bailout.
+ if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) {
+ cmd.hidden = true;
+ return false;
+ }
+
+ for (let item of gAttachmentBucket.selectedItems) {
+ if (item && item.uploading) {
+ cmd.hidden = false;
+ return true;
+ }
+ }
+
+ // Hide the command entirely if the selected attachments aren't cloud
+ // files.
+ // For some reason, the hidden property isn't propagating from the cmd
+ // to the menuitem.
+ cmd.hidden = true;
+ return false;
+ },
+ doCommand() {
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ for (let item of gAttachmentBucket.selectedItems) {
+ if (item && item.uploading) {
+ let file = fileHandler.getFileFromURLSpec(item.attachment.url);
+ item.uploading.cancelFileUpload(window, file);
+ }
+ }
+ },
+ },
+ },
+
+ supportsCommand(aCommand) {
+ return aCommand in this.commands;
+ },
+
+ isCommandEnabled(aCommand) {
+ if (!this.supportsCommand(aCommand)) {
+ return false;
+ }
+ return this.commands[aCommand].isEnabled();
+ },
+
+ doCommand(aCommand) {
+ if (!this.supportsCommand(aCommand)) {
+ return;
+ }
+ var cmd = this.commands[aCommand];
+ if (!cmd.isEnabled()) {
+ return;
+ }
+ cmd.doCommand();
+ },
+
+ onEvent(event) {},
+};
+
+/**
+ * Start composing a new message.
+ */
+function goOpenNewMessage(aEvent) {
+ // If aEvent is passed, check if Shift key was pressed for composition in
+ // non-default format (HTML vs. plaintext).
+ let msgCompFormat =
+ aEvent && aEvent.shiftKey
+ ? Ci.nsIMsgCompFormat.OppositeOfDefault
+ : Ci.nsIMsgCompFormat.Default;
+
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ null,
+ Ci.nsIMsgCompType.New,
+ msgCompFormat,
+ gCurrentIdentity,
+ null,
+ null
+ );
+}
+
+function QuoteSelectedMessage() {
+ var selectedURIs = GetSelectedMessages();
+ if (selectedURIs) {
+ gMsgCompose.allowRemoteContent = false;
+ for (let i = 0; i < selectedURIs.length; i++) {
+ gMsgCompose.quoteMessage(selectedURIs[i]);
+ }
+ }
+}
+
+function GetSelectedMessages() {
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (!mailWindow) {
+ return null;
+ }
+ let tab = mailWindow.document.getElementById("tabmail").currentTabInfo;
+ if (tab.mode.name == "mail3PaneTab" && tab.message) {
+ return tab.chromeBrowser.contentWindow?.gDBView?.getURIsForSelection();
+ } else if (tab.mode.name == "mailMessageTab") {
+ return [tab.messageURI];
+ }
+ return null;
+}
+
+function SetupCommandUpdateHandlers() {
+ top.controllers.appendController(defaultController);
+ gAttachmentBucket.controllers.appendController(attachmentBucketController);
+
+ document
+ .getElementById("optionsMenuPopup")
+ .addEventListener("popupshowing", updateOptionItems, true);
+}
+
+function UnloadCommandUpdateHandlers() {
+ document
+ .getElementById("optionsMenuPopup")
+ .removeEventListener("popupshowing", updateOptionItems, true);
+
+ gAttachmentBucket.controllers.removeController(attachmentBucketController);
+ top.controllers.removeController(defaultController);
+}
+
+function CommandUpdate_MsgCompose() {
+ var focusedWindow = top.document.commandDispatcher.focusedWindow;
+
+ // we're just setting focus to where it was before
+ if (focusedWindow == gLastWindowToHaveFocus) {
+ return;
+ }
+
+ gLastWindowToHaveFocus = focusedWindow;
+ updateComposeItems();
+}
+
+function findbarFindReplace() {
+ focusMsgBody();
+ let findbar = document.getElementById("FindToolbar");
+ findbar.close();
+ goDoCommand("cmd_findReplace");
+ findbar.open();
+}
+
+function updateComposeItems() {
+ try {
+ // Edit Menu
+ goUpdateCommand("cmd_rewrap");
+
+ // Insert Menu
+ if (gMsgCompose && gMsgCompose.composeHTML) {
+ goUpdateCommand("cmd_renderedHTMLEnabler");
+ goUpdateCommand("cmd_fontColor");
+ goUpdateCommand("cmd_backgroundColor");
+ goUpdateCommand("cmd_decreaseFontStep");
+ goUpdateCommand("cmd_increaseFontStep");
+ goUpdateCommand("cmd_bold");
+ goUpdateCommand("cmd_italic");
+ goUpdateCommand("cmd_underline");
+ goUpdateCommand("cmd_removeStyles");
+ goUpdateCommand("cmd_ul");
+ goUpdateCommand("cmd_ol");
+ goUpdateCommand("cmd_indent");
+ goUpdateCommand("cmd_outdent");
+ goUpdateCommand("cmd_align");
+ goUpdateCommand("cmd_smiley");
+ }
+
+ // Options Menu
+ goUpdateCommand("cmd_spelling");
+
+ // Workaround to update 'Quote' toolbar button. (See bug 609926.)
+ goUpdateCommand("cmd_quoteMessage");
+ goUpdateCommand("cmd_toggleReturnReceipt");
+ } catch (e) {}
+}
+
+/**
+ * Disables or restores all toolbar items (menus/buttons) in the window.
+ *
+ * @param {boolean} disable - Meaning true = disable all items, false = restore
+ * items to the state stored before disabling them.
+ */
+function updateAllItems(disable) {
+ for (let item of document.querySelectorAll(
+ "menu, toolbarbutton, [command], [oncommand]"
+ )) {
+ if (disable) {
+ // Disable all items
+ item.setAttribute("stateBeforeSend", item.getAttribute("disabled"));
+ item.setAttribute("disabled", "disabled");
+ } else {
+ // Restore initial state
+ let stateBeforeSend = item.getAttribute("stateBeforeSend");
+ if (stateBeforeSend == "disabled" || stateBeforeSend == "true") {
+ item.setAttribute("disabled", stateBeforeSend);
+ } else {
+ item.removeAttribute("disabled");
+ }
+ item.removeAttribute("stateBeforeSend");
+ }
+ }
+}
+
+function InitFileSaveAsMenu() {
+ document
+ .getElementById("cmd_saveAsFile")
+ .setAttribute("checked", defaultSaveOperation == "file");
+ document
+ .getElementById("cmd_saveAsDraft")
+ .setAttribute("checked", defaultSaveOperation == "draft");
+ document
+ .getElementById("cmd_saveAsTemplate")
+ .setAttribute("checked", defaultSaveOperation == "template");
+}
+
+function isSmimeSigningConfigured() {
+ return !!gCurrentIdentity?.getUnicharAttribute("signing_cert_name");
+}
+
+function isSmimeEncryptionConfigured() {
+ return !!gCurrentIdentity?.getUnicharAttribute("encryption_cert_name");
+}
+
+function isPgpConfigured() {
+ return !!gCurrentIdentity?.getUnicharAttribute("openpgp_key_id");
+}
+
+function toggleGlobalSignMessage() {
+ gSendSigned = !gSendSigned;
+ gUserTouchedSendSigned = true;
+
+ updateAttachMyPubKey();
+ showSendEncryptedAndSigned();
+}
+
+function updateAttachMyPubKey() {
+ if (!gUserTouchedAttachMyPubKey) {
+ if (gSendSigned) {
+ gAttachMyPublicPGPKey = gCurrentIdentity.attachPgpKey;
+ } else {
+ gAttachMyPublicPGPKey = false;
+ }
+ }
+}
+
+function removeAutoDisableNotification() {
+ let notification = gComposeNotification.getNotificationWithValue(
+ "e2eeDisableNotification"
+ );
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+}
+
+function toggleEncryptMessage() {
+ gSendEncrypted = !gSendEncrypted;
+
+ if (gSendEncrypted) {
+ removeAutoDisableNotification();
+ }
+
+ gUserTouchedSendEncrypted = true;
+ checkEncryptionState();
+}
+
+function toggleAttachMyPublicKey(target) {
+ gAttachMyPublicPGPKey = target.getAttribute("checked") != "true";
+ target.setAttribute("checked", gAttachMyPublicPGPKey);
+ gUserTouchedAttachMyPubKey = true;
+}
+
+function updateEncryptedSubject() {
+ let warnSubjectUnencrypted =
+ (!gSelectedTechnologyIsPGP && gSendEncrypted) ||
+ (isPgpConfigured() &&
+ gSelectedTechnologyIsPGP &&
+ gSendEncrypted &&
+ !gEncryptSubject);
+
+ document
+ .getElementById("msgSubject")
+ .classList.toggle("with-icon", warnSubjectUnencrypted);
+ document.getElementById("msgEncryptedSubjectIcon").hidden =
+ !warnSubjectUnencrypted;
+}
+
+function toggleEncryptedSubject() {
+ gEncryptSubject = !gEncryptSubject;
+ gUserTouchedEncryptSubject = true;
+ updateEncryptedSubject();
+}
+
+/**
+ * Update user interface elements
+ *
+ * @param {string} menu_id - suffix of the menu ID of the menu to update
+ */
+function setSecuritySettings(menu_id) {
+ let encItem = document.getElementById("menu_securityEncrypt" + menu_id);
+ encItem.setAttribute("checked", gSendEncrypted);
+
+ let disableSig = false;
+ let disableEnc = false;
+
+ if (gSelectedTechnologyIsPGP) {
+ if (!isPgpConfigured()) {
+ disableSig = true;
+ disableEnc = true;
+ }
+ } else {
+ if (!isSmimeSigningConfigured()) {
+ disableSig = true;
+ }
+ if (!isSmimeEncryptionConfigured()) {
+ disableEnc = true;
+ }
+ }
+
+ let sigItem = document.getElementById("menu_securitySign" + menu_id);
+ sigItem.setAttribute("checked", gSendSigned && !disableSig);
+
+ // The radio button to disable encryption is always active.
+ // This is necessary, even if the current identity doesn't have
+ // e2ee configured. If the user switches the sender identity of an
+ // email, we might keep encryption enabled, to not surprise the user.
+ // This means, we must always allow the user to disable encryption.
+ encItem.disabled = disableEnc && !gSendEncrypted;
+
+ sigItem.disabled = disableSig;
+
+ let pgpItem = document.getElementById("encTech_OpenPGP" + menu_id);
+ let smimeItem = document.getElementById("encTech_SMIME" + menu_id);
+
+ smimeItem.disabled =
+ !isSmimeSigningConfigured() && !isSmimeEncryptionConfigured();
+
+ let encryptSubjectItem = document.getElementById(
+ `menu_securityEncryptSubject${menu_id}`
+ );
+
+ pgpItem.setAttribute("checked", gSelectedTechnologyIsPGP);
+ smimeItem.setAttribute("checked", !gSelectedTechnologyIsPGP);
+ encryptSubjectItem.setAttribute(
+ "checked",
+ !disableEnc && gSelectedTechnologyIsPGP && gSendEncrypted && gEncryptSubject
+ );
+ encryptSubjectItem.setAttribute(
+ "disabled",
+ disableEnc || !gSelectedTechnologyIsPGP || !gSendEncrypted
+ );
+
+ document.getElementById("menu_recipientStatus" + menu_id).disabled =
+ disableEnc;
+ let manager = document.getElementById("menu_openManager" + menu_id);
+ manager.disabled = disableEnc;
+ manager.hidden = !gSelectedTechnologyIsPGP;
+}
+
+/**
+ * Show the message security status based on the selected encryption technology.
+ *
+ * @param {boolean} [isSending=false] - If the key assistant was triggered
+ * during a sending attempt.
+ */
+function showMessageComposeSecurityStatus(isSending = false) {
+ if (gSelectedTechnologyIsPGP) {
+ if (
+ Services.prefs.getBoolPref("mail.openpgp.key_assistant.enable", false)
+ ) {
+ gKeyAssistant.show(getEncryptionCompatibleRecipients(), isSending);
+ } else {
+ Recipients2CompFields(gMsgCompose.compFields);
+ window.openDialog(
+ "chrome://openpgp/content/ui/composeKeyStatus.xhtml",
+ "",
+ "chrome,modal,resizable,centerscreen",
+ {
+ compFields: gMsgCompose.compFields,
+ currentIdentity: gCurrentIdentity,
+ }
+ );
+ checkEncryptionState();
+ }
+ } else {
+ Recipients2CompFields(gMsgCompose.compFields);
+ // Copy current flags to S/MIME composeSecure object.
+ gMsgCompose.compFields.composeSecure.requireEncryptMessage = gSendEncrypted;
+ gMsgCompose.compFields.composeSecure.signMessage = gSendSigned;
+ window.openDialog(
+ "chrome://messenger-smime/content/msgCompSecurityInfo.xhtml",
+ "",
+ "chrome,modal,resizable,centerscreen",
+ {
+ compFields: gMsgCompose.compFields,
+ subject: document.getElementById("msgSubject").value,
+ isSigningCertAvailable:
+ gCurrentIdentity.getUnicharAttribute("signing_cert_name") != "",
+ isEncryptionCertAvailable:
+ gCurrentIdentity.getUnicharAttribute("encryption_cert_name") != "",
+ currentIdentity: gCurrentIdentity,
+ recipients: getEncryptionCompatibleRecipients(),
+ }
+ );
+ }
+}
+
+function msgComposeContextOnShowing(event) {
+ if (event.target.id != "msgComposeContext") {
+ return;
+ }
+
+ // gSpellChecker handles all spell checking related to the context menu,
+ // except whether or not spell checking is enabled. We need the editor's
+ // spell checker for that.
+ gSpellChecker.initFromRemote(
+ nsContextMenu.contentData.spellInfo,
+ nsContextMenu.contentData.actor.manager
+ );
+
+ let canSpell = gSpellChecker.canSpellCheck;
+ let showDictionaries = canSpell && gSpellChecker.enabled;
+ let onMisspelling = gSpellChecker.overMisspelling;
+ let showUndo = canSpell && gSpellChecker.canUndo();
+
+ document.getElementById("spellCheckSeparator").hidden = !canSpell;
+ document.getElementById("spellCheckEnable").hidden = !canSpell;
+ document
+ .getElementById("spellCheckEnable")
+ .setAttribute("checked", canSpell && gSpellCheckingEnabled);
+
+ document.getElementById("spellCheckAddToDictionary").hidden = !onMisspelling;
+ document.getElementById("spellCheckUndoAddToDictionary").hidden = !showUndo;
+ document.getElementById("spellCheckIgnoreWord").hidden = !onMisspelling;
+
+ // Suggestion list.
+ document.getElementById("spellCheckSuggestionsSeparator").hidden =
+ !onMisspelling && !showUndo;
+ let separator = document.getElementById("spellCheckAddSep");
+ separator.hidden = !onMisspelling;
+ if (onMisspelling) {
+ let addMenuItem = document.getElementById("spellCheckAddToDictionary");
+ let suggestionCount = gSpellChecker.addSuggestionsToMenu(
+ addMenuItem.parentNode,
+ separator,
+ nsContextMenu.contentData.spellInfo.spellSuggestions
+ );
+ document.getElementById("spellCheckNoSuggestions").hidden =
+ !suggestionCount == 0;
+ } else {
+ document.getElementById("spellCheckNoSuggestions").hidden = !false;
+ }
+
+ // Dictionary list.
+ document.getElementById("spellCheckDictionaries").hidden = !showDictionaries;
+ if (canSpell) {
+ let dictMenu = document.getElementById("spellCheckDictionariesMenu");
+ let dictSep = document.getElementById("spellCheckLanguageSeparator");
+ let count = gSpellChecker.addDictionaryListToMenu(dictMenu, dictSep);
+ dictSep.hidden = count == 0;
+ document.getElementById("spellCheckAddDictionariesMain").hidden = !false;
+ } else if (this.onSpellcheckable) {
+ // when there is no spellchecker but we might be able to spellcheck
+ // add the add to dictionaries item. This will ensure that people
+ // with no dictionaries will be able to download them
+ document.getElementById("spellCheckLanguageSeparator").hidden =
+ !showDictionaries;
+ document.getElementById("spellCheckAddDictionariesMain").hidden =
+ !showDictionaries;
+ } else {
+ document.getElementById("spellCheckAddDictionariesMain").hidden = !false;
+ }
+
+ updateEditItems();
+
+ // The rest of this block sends menu information to WebExtensions.
+
+ let editor = GetCurrentEditorElement();
+ let target = editor.contentDocument.elementFromPoint(
+ editor._contextX,
+ editor._contextY
+ );
+
+ let selectionInfo = SelectionUtils.getSelectionDetails(window);
+ let isContentSelected = !selectionInfo.docSelectionIsCollapsed;
+ let textSelected = selectionInfo.text;
+ let isTextSelected = !!textSelected.length;
+
+ // Set up early the right flags for editable / not editable.
+ let editFlags = SpellCheckHelper.isEditable(target, window);
+ let onTextInput = (editFlags & SpellCheckHelper.TEXTINPUT) !== 0;
+ let onEditable =
+ (editFlags &
+ (SpellCheckHelper.EDITABLE | SpellCheckHelper.CONTENTEDITABLE)) !==
+ 0;
+
+ let onImage = false;
+ let srcUrl = undefined;
+
+ if (target.nodeType == Node.ELEMENT_NODE) {
+ if (target instanceof Ci.nsIImageLoadingContent && target.currentURI) {
+ onImage = true;
+ srcUrl = target.currentURI.spec;
+ }
+ }
+
+ let onLink = false;
+ let linkText = undefined;
+ let linkUrl = undefined;
+
+ let link = target.closest("a");
+ if (link) {
+ onLink = true;
+ linkText =
+ link.textContent ||
+ link.getAttribute("title") ||
+ link.getAttribute("a") ||
+ link.href ||
+ "";
+ linkUrl = link.href;
+ }
+
+ let subject = {
+ menu: event.target,
+ tab: window,
+ isContentSelected,
+ isTextSelected,
+ onTextInput,
+ onLink,
+ onImage,
+ onEditable,
+ srcUrl,
+ linkText,
+ linkUrl,
+ selectionText: isTextSelected ? selectionInfo.fullText : undefined,
+ pageUrl: target.ownerGlobal.top.location.href,
+ onComposeBody: true,
+ };
+ subject.context = subject;
+ subject.wrappedJSObject = subject;
+
+ Services.obs.notifyObservers(subject, "on-prepare-contextmenu");
+ Services.obs.notifyObservers(subject, "on-build-contextmenu");
+}
+
+function msgComposeContextOnHiding(event) {
+ if (event.target.id != "msgComposeContext") {
+ return;
+ }
+
+ if (nsContextMenu.contentData.actor) {
+ nsContextMenu.contentData.actor.hiding();
+ }
+
+ nsContextMenu.contentData = null;
+ gSpellChecker.clearSuggestionsFromMenu();
+ gSpellChecker.clearDictionaryListFromMenu();
+ gSpellChecker.uninit();
+}
+
+function updateEditItems() {
+ goUpdateCommand("cmd_paste");
+ goUpdateCommand("cmd_pasteNoFormatting");
+ goUpdateCommand("cmd_pasteQuote");
+ goUpdateCommand("cmd_delete");
+ goUpdateCommand("cmd_renameAttachment");
+ goUpdateCommand("cmd_reorderAttachments");
+ goUpdateCommand("cmd_selectAll");
+ goUpdateCommand("cmd_openAttachment");
+ goUpdateCommand("cmd_findReplace");
+ goUpdateCommand("cmd_find");
+ goUpdateCommand("cmd_findNext");
+ goUpdateCommand("cmd_findPrev");
+}
+
+function updateViewItems() {
+ goUpdateCommand("cmd_toggleAttachmentPane");
+}
+
+function updateOptionItems() {
+ goUpdateCommand("cmd_quoteMessage");
+ goUpdateCommand("cmd_toggleReturnReceipt");
+}
+
+function updateAttachmentItems() {
+ goUpdateCommand("cmd_toggleAttachmentPane");
+ goUpdateCommand("cmd_attachCloud");
+ goUpdateCommand("cmd_convertCloud");
+ goUpdateCommand("cmd_convertAttachment");
+ goUpdateCommand("cmd_cancelUpload");
+ goUpdateCommand("cmd_delete");
+ goUpdateCommand("cmd_removeAllAttachments");
+ goUpdateCommand("cmd_renameAttachment");
+ updateReorderAttachmentsItems();
+ goUpdateCommand("cmd_selectAll");
+ goUpdateCommand("cmd_openAttachment");
+ goUpdateCommand("cmd_attachVCard");
+ goUpdateCommand("cmd_attachPublicKey");
+}
+
+function updateReorderAttachmentsItems() {
+ goUpdateCommand("cmd_reorderAttachments");
+ goUpdateCommand("cmd_moveAttachmentLeft");
+ goUpdateCommand("cmd_moveAttachmentRight");
+ goUpdateCommand("cmd_moveAttachmentBundleUp");
+ goUpdateCommand("cmd_moveAttachmentBundleDown");
+ goUpdateCommand("cmd_moveAttachmentTop");
+ goUpdateCommand("cmd_moveAttachmentBottom");
+ goUpdateCommand("cmd_sortAttachmentsToggle");
+}
+
+/**
+ * Update all the commands for sending a message to reflect their current state.
+ */
+function updateSendCommands(aHaveController) {
+ updateSendLock();
+ if (aHaveController) {
+ goUpdateCommand("cmd_sendButton");
+ goUpdateCommand("cmd_sendNow");
+ goUpdateCommand("cmd_sendLater");
+ goUpdateCommand("cmd_sendWithCheck");
+ } else {
+ goSetCommandEnabled(
+ "cmd_sendButton",
+ defaultController.isCommandEnabled("cmd_sendButton")
+ );
+ goSetCommandEnabled(
+ "cmd_sendNow",
+ defaultController.isCommandEnabled("cmd_sendNow")
+ );
+ goSetCommandEnabled(
+ "cmd_sendLater",
+ defaultController.isCommandEnabled("cmd_sendLater")
+ );
+ goSetCommandEnabled(
+ "cmd_sendWithCheck",
+ defaultController.isCommandEnabled("cmd_sendWithCheck")
+ );
+ }
+
+ let changed = false;
+ let currentStates = {};
+ let changedStates = {};
+ for (let state of ["cmd_sendNow", "cmd_sendLater"]) {
+ currentStates[state] = defaultController.isCommandEnabled(state);
+ if (
+ !gLastKnownComposeStates.hasOwnProperty(state) ||
+ gLastKnownComposeStates[state] != currentStates[state]
+ ) {
+ gLastKnownComposeStates[state] = currentStates[state];
+ changedStates[state] = currentStates[state];
+ changed = true;
+ }
+ }
+ if (changed) {
+ window.dispatchEvent(
+ new CustomEvent("compose-state-changed", { detail: changedStates })
+ );
+ }
+}
+
+function addAttachCloudMenuItems(aParentMenu) {
+ while (aParentMenu.hasChildNodes()) {
+ aParentMenu.lastChild.remove();
+ }
+
+ for (let account of cloudFileAccounts.configuredAccounts) {
+ if (
+ aParentMenu.lastElementChild &&
+ aParentMenu.lastElementChild.cloudFileUpload
+ ) {
+ aParentMenu.appendChild(document.createXULElement("menuseparator"));
+ }
+
+ let item = document.createXULElement("menuitem");
+ let iconURL = account.iconURL;
+ item.cloudFileAccount = account;
+ item.setAttribute(
+ "label",
+ cloudFileAccounts.getDisplayName(account) + "\u2026"
+ );
+ if (iconURL) {
+ item.setAttribute("class", `${item.localName}-iconic`);
+ item.setAttribute("image", iconURL);
+ }
+ aParentMenu.appendChild(item);
+
+ let previousUploads = account.getPreviousUploads();
+ let addedFiles = [];
+ for (let upload of previousUploads) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(upload.path);
+
+ // TODO: Figure out how to handle files that no longer exist on the filesystem.
+ if (!file.exists()) {
+ continue;
+ }
+ if (!addedFiles.find(f => f.name == upload.name || f.url == upload.url)) {
+ let fileItem = document.createXULElement("menuitem");
+ fileItem.cloudFileUpload = upload;
+ fileItem.cloudFileAccount = account;
+ fileItem.setAttribute("label", upload.name);
+ fileItem.setAttribute("class", "menuitem-iconic");
+ fileItem.setAttribute("image", "moz-icon://" + upload.name);
+ aParentMenu.appendChild(fileItem);
+ addedFiles.push({ name: upload.name, url: upload.url });
+ }
+ }
+ }
+}
+
+function addConvertCloudMenuItems(aParentMenu, aAfterNodeId, aRadioGroup) {
+ let afterNode = document.getElementById(aAfterNodeId);
+ while (afterNode.nextElementSibling) {
+ afterNode.nextElementSibling.remove();
+ }
+
+ if (!gAttachmentBucket.selectedItem.sendViaCloud) {
+ let item = document.getElementById(
+ "convertCloudMenuItems_popup_convertAttachment"
+ );
+ item.setAttribute("checked", "true");
+ }
+
+ for (let account of cloudFileAccounts.configuredAccounts) {
+ let item = document.createXULElement("menuitem");
+ let iconURL = account.iconURL;
+ item.cloudFileAccount = account;
+ item.setAttribute("label", cloudFileAccounts.getDisplayName(account));
+ item.setAttribute("type", "radio");
+ item.setAttribute("name", aRadioGroup);
+
+ if (
+ gAttachmentBucket.selectedItem.cloudFileAccount &&
+ gAttachmentBucket.selectedItem.cloudFileAccount.accountKey ==
+ account.accountKey
+ ) {
+ item.setAttribute("checked", "true");
+ } else if (iconURL) {
+ item.setAttribute("class", "menu-iconic");
+ item.setAttribute("image", iconURL);
+ }
+
+ aParentMenu.appendChild(item);
+ }
+
+ // Check if the cloudFile has an invalid account and deselect the default
+ // option, allowing to convert it back to a regular file.
+ if (
+ gAttachmentBucket.selectedItem.attachment.sendViaCloud &&
+ !gAttachmentBucket.selectedItem.cloudFileAccount
+ ) {
+ let regularItem = document.getElementById(
+ "convertCloudMenuItems_popup_convertAttachment"
+ );
+ regularItem.removeAttribute("checked");
+ }
+}
+
+async function updateAttachmentItemProperties(attachmentItem) {
+ // FIXME: The UI logic should be handled by the attachment list or item
+ // itself.
+ if (attachmentItem.uploading) {
+ // uploading/renaming
+ attachmentItem.setAttribute(
+ "tooltiptext",
+ getComposeBundle().getFormattedString("cloudFileUploadingTooltip", [
+ cloudFileAccounts.getDisplayName(attachmentItem.uploading),
+ ])
+ );
+ gAttachmentBucket.setCloudIcon(attachmentItem, "");
+ } else if (attachmentItem.attachment.sendViaCloud) {
+ let [tooltipUnknownAccountText, introText, titleText] =
+ await document.l10n.formatValues([
+ "cloud-file-unknown-account-tooltip",
+ {
+ id: "cloud-file-placeholder-intro",
+ args: { filename: attachmentItem.attachment.name },
+ },
+ {
+ id: "cloud-file-placeholder-title",
+ args: { filename: attachmentItem.attachment.name },
+ },
+ ]);
+
+ // uploaded
+ let tooltiptext;
+ if (attachmentItem.cloudFileAccount) {
+ tooltiptext = getComposeBundle().getFormattedString(
+ "cloudFileUploadedTooltip",
+ [cloudFileAccounts.getDisplayName(attachmentItem.cloudFileAccount)]
+ );
+ } else {
+ tooltiptext = tooltipUnknownAccountText;
+ }
+ attachmentItem.setAttribute("tooltiptext", tooltiptext);
+
+ gAttachmentBucket.setAttachmentName(
+ attachmentItem,
+ attachmentItem.attachment.name
+ );
+ gAttachmentBucket.setCloudIcon(
+ attachmentItem,
+ attachmentItem.cloudFileUpload.serviceIcon
+ );
+
+ // Update the CloudPartHeaderData, if there is a valid cloudFileUpload.
+ if (attachmentItem.cloudFileUpload) {
+ let json = JSON.stringify(attachmentItem.cloudFileUpload);
+ // Convert 16bit JavaScript string to a byteString, to make it work with
+ // btoa().
+ attachmentItem.attachment.cloudPartHeaderData = btoa(
+ MailStringUtils.stringToByteString(json)
+ );
+ }
+
+ // Update the cloudFile placeholder file.
+ attachmentItem.attachment.htmlAnnotation = `<!DOCTYPE html>
+<html>
+ <head>
+ <title>${titleText}</title>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div style="padding: 15px; font-family: Calibri, sans-serif;">
+ <div style="margin-bottom: 15px;" id="cloudAttachmentListHeader">${introText}</div>
+ <ul>${
+ (
+ await gCloudAttachmentLinkManager._createNode(
+ document,
+ attachmentItem.cloudFileUpload,
+ true
+ )
+ ).outerHTML
+ }</ul>
+ </div>
+ </body>
+</html>`;
+
+ // Calculate size of placeholder attachment.
+ attachmentItem.cloudHtmlFileSize = new TextEncoder().encode(
+ attachmentItem.attachment.htmlAnnotation
+ ).length;
+ } else {
+ // local
+ attachmentItem.setAttribute("tooltiptext", attachmentItem.attachment.url);
+ gAttachmentBucket.setAttachmentName(
+ attachmentItem,
+ attachmentItem.attachment.name
+ );
+ gAttachmentBucket.setCloudIcon(attachmentItem, "");
+
+ // Remove placeholder file size information.
+ delete attachmentItem.cloudHtmlFileSize;
+ }
+ updateAttachmentPane();
+}
+
+async function showLocalizedCloudFileAlert(
+ ex,
+ provider = ex.cloudProvider,
+ filename = ex.cloudFileName
+) {
+ let bundle = getComposeBundle();
+ let localizedTitle, localizedMessage;
+
+ switch (ex.result) {
+ case cloudFileAccounts.constants.uploadCancelled:
+ // No alerts for cancelled uploads.
+ return;
+ case cloudFileAccounts.constants.deleteErr:
+ localizedTitle = bundle.getString("errorCloudFileDeletion.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileDeletion.message",
+ [provider, filename]
+ );
+ break;
+ case cloudFileAccounts.constants.offlineErr:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-connection-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-connection-error",
+ {
+ provider,
+ }
+ );
+ break;
+ case cloudFileAccounts.constants.authErr:
+ localizedTitle = bundle.getString("errorCloudFileAuth.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileAuth.message",
+ [provider]
+ );
+ break;
+ case cloudFileAccounts.constants.uploadErrWithCustomMessage:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-upload-error-with-custom-message-title",
+ {
+ provider,
+ filename,
+ }
+ );
+ localizedMessage = ex.message;
+ break;
+ case cloudFileAccounts.constants.uploadErr:
+ localizedTitle = bundle.getString("errorCloudFileUpload.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileUpload.message",
+ [provider, filename]
+ );
+ break;
+ case cloudFileAccounts.constants.uploadWouldExceedQuota:
+ localizedTitle = bundle.getString("errorCloudFileQuota.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileQuota.message",
+ [provider, filename]
+ );
+ break;
+ case cloudFileAccounts.constants.uploadExceedsFileLimit:
+ localizedTitle = bundle.getString("errorCloudFileLimit.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileLimit.message",
+ [provider, filename]
+ );
+ break;
+ case cloudFileAccounts.constants.renameNotSupported:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-rename-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-rename-not-supported",
+ {
+ provider,
+ }
+ );
+ break;
+ case cloudFileAccounts.constants.renameErrWithCustomMessage:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-rename-error-with-custom-message-title",
+ {
+ provider,
+ filename,
+ }
+ );
+ localizedMessage = ex.message;
+ break;
+ case cloudFileAccounts.constants.renameErr:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-rename-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-rename-error",
+ {
+ provider,
+ filename,
+ }
+ );
+ break;
+ case cloudFileAccounts.constants.attachmentErr:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-attachment-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-attachment-error",
+ {
+ filename,
+ }
+ );
+ break;
+ case cloudFileAccounts.constants.accountErr:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-account-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-account-error",
+ {
+ filename,
+ }
+ );
+ break;
+ default:
+ localizedTitle = bundle.getString("errorCloudFileOther.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileOther.message",
+ [provider]
+ );
+ }
+
+ Services.prompt.alert(window, localizedTitle, localizedMessage);
+}
+
+/**
+ * @typedef UpdateSettings
+ * @property {CloudFileAccount} [cloudFileAccount] - cloud file account to store
+ * the attachment
+ * @property {CloudFileUpload} [relatedCloudFileUpload] - information about an
+ * already uploaded file this upload is related to, e.g. renaming a repeatedly
+ * used cloud file or updating the content of a cloud file
+ * @property {nsIFile} [file] - file to replace the current attachments content
+ * @property {string} [name] - name to replace the current attachments name
+ */
+
+/**
+ * Update the name and or the content of an attachment, as well as its local/cloud
+ * state.
+ *
+ * @param {DOMNode} attachmentItem - the existing attachmentItem
+ * @param {UpdateSettings} [updateSettings] - object defining how to update the
+ * attachment
+ */
+async function UpdateAttachment(attachmentItem, updateSettings = {}) {
+ if (!attachmentItem || !attachmentItem.attachment) {
+ throw new Error("Unexpected: Invalid attachment item.");
+ }
+
+ let originalAttachment = Object.assign({}, attachmentItem.attachment);
+ let eventOnDone = false;
+
+ // Ignore empty or falsy names.
+ let name = updateSettings.name || attachmentItem.attachment.name;
+
+ let destCloudFileAccount = updateSettings.hasOwnProperty("cloudFileAccount")
+ ? updateSettings.cloudFileAccount
+ : attachmentItem.cloudFileAccount;
+
+ try {
+ if (
+ // Bypass upload and set provided relatedCloudFileUpload.
+ updateSettings.relatedCloudFileUpload &&
+ updateSettings.cloudFileAccount &&
+ updateSettings.cloudFileAccount.reuseUploads &&
+ !updateSettings.file &&
+ !updateSettings.name
+ ) {
+ attachmentItem.attachment.sendViaCloud = true;
+ attachmentItem.attachment.contentLocation =
+ updateSettings.relatedCloudFileUpload.url;
+ attachmentItem.attachment.cloudFileAccountKey =
+ updateSettings.cloudFileAccount.accountKey;
+
+ attachmentItem.cloudFileAccount = updateSettings.cloudFileAccount;
+ attachmentItem.cloudFileUpload = updateSettings.relatedCloudFileUpload;
+ gAttachmentBucket.setCloudIcon(
+ attachmentItem,
+ updateSettings.relatedCloudFileUpload.serviceIcon
+ );
+
+ eventOnDone = new CustomEvent("attachment-uploaded", {
+ bubbles: true,
+ cancelable: true,
+ });
+ } else if (
+ // Handle a local -> local replace/rename.
+ !attachmentItem.attachment.sendViaCloud &&
+ !updateSettings.hasOwnProperty("cloudFileAccount")
+ ) {
+ // Both modes - rename and replace - require the same UI handling.
+ eventOnDone = new CustomEvent("attachment-renamed", {
+ bubbles: true,
+ cancelable: true,
+ detail: originalAttachment,
+ });
+ } else if (
+ // Handle a cloud -> local conversion.
+ attachmentItem.attachment.sendViaCloud &&
+ updateSettings.cloudFileAccount === null
+ ) {
+ // Throw if the linked local file does not exists (i.e. invalid draft).
+ if (!(await IOUtils.exists(attachmentItem.cloudFileUpload.path))) {
+ throw Components.Exception(
+ `CloudFile Error: Attachment file not found: ${attachmentItem.cloudFileUpload.path}`,
+ cloudFileAccounts.constants.attachmentErr
+ );
+ }
+
+ if (attachmentItem.cloudFileAccount) {
+ // A cloud delete error is not considered to be a fatal error. It is
+ // not preventing the attachment from being removed from the composer.
+ attachmentItem.cloudFileAccount
+ .deleteFile(window, attachmentItem.cloudFileUpload.id)
+ .catch(ex => console.warn(ex.message));
+ }
+ // Clean up attachment from cloud bits.
+ attachmentItem.attachment.sendViaCloud = false;
+ attachmentItem.attachment.htmlAnnotation = "";
+ attachmentItem.attachment.contentLocation = "";
+ attachmentItem.attachment.cloudFileAccountKey = "";
+ attachmentItem.attachment.cloudPartHeaderData = "";
+ delete attachmentItem.cloudFileAccount;
+ delete attachmentItem.cloudFileUpload;
+
+ eventOnDone = new CustomEvent("attachment-converted-to-regular", {
+ bubbles: true,
+ cancelable: true,
+ detail: originalAttachment,
+ });
+ } else if (
+ // Exit early if offline.
+ Services.io.offline
+ ) {
+ throw Components.Exception(
+ "Connection error: Offline",
+ cloudFileAccounts.constants.offlineErr
+ );
+ } else {
+ // Handle a cloud -> cloud move/rename or a local -> cloud upload.
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ let mode = "upload";
+ if (attachmentItem.attachment.sendViaCloud) {
+ // Throw if the used cloudFile account does not exists (invalid draft,
+ // disabled add-on, removed account).
+ if (
+ !destCloudFileAccount ||
+ !cloudFileAccounts.getAccount(destCloudFileAccount.accountKey)
+ ) {
+ throw Components.Exception(
+ `CloudFile Error: Account not found: ${destCloudFileAccount?.accountKey}`,
+ cloudFileAccounts.constants.accountErr
+ );
+ }
+
+ if (
+ attachmentItem.cloudFileUpload &&
+ attachmentItem.cloudFileAccount == destCloudFileAccount &&
+ !updateSettings.file &&
+ !destCloudFileAccount.isReusedUpload(attachmentItem.cloudFileUpload)
+ ) {
+ mode = "rename";
+ } else {
+ mode = "move";
+ // Throw if the linked local file does not exists (invalid draft, removed
+ // local file).
+ if (
+ !fileHandler
+ .getFileFromURLSpec(attachmentItem.attachment.url)
+ .exists()
+ ) {
+ throw Components.Exception(
+ `CloudFile Error: Attachment file not found: ${
+ fileHandler.getFileFromURLSpec(attachmentItem.attachment.url)
+ .path
+ }`,
+ cloudFileAccounts.constants.attachmentErr
+ );
+ }
+ if (!(await IOUtils.exists(attachmentItem.cloudFileUpload.path))) {
+ throw Components.Exception(
+ `CloudFile Error: Attachment file not found: ${attachmentItem.cloudFileUpload.path}`,
+ cloudFileAccounts.constants.attachmentErr
+ );
+ }
+ }
+ }
+
+ // Notify the UI that we're starting the upload process: disable send commands
+ // and show a "connecting" icon for the attachment.
+ gNumUploadingAttachments++;
+ updateSendCommands(true);
+
+ attachmentItem.uploading = destCloudFileAccount;
+ await updateAttachmentItemProperties(attachmentItem);
+
+ const eventsOnStart = {
+ upload: "attachment-uploading",
+ move: "attachment-moving",
+ };
+ if (eventsOnStart[mode]) {
+ attachmentItem.dispatchEvent(
+ new CustomEvent(eventsOnStart[mode], {
+ bubbles: true,
+ cancelable: true,
+ detail: attachmentItem.attachment,
+ })
+ );
+ }
+
+ try {
+ let upload;
+ if (mode == "rename") {
+ upload = await destCloudFileAccount.renameFile(
+ window,
+ attachmentItem.cloudFileUpload.id,
+ name
+ );
+ } else {
+ let file =
+ updateSettings.file ||
+ fileHandler.getFileFromURLSpec(attachmentItem.attachment.url);
+
+ upload = await destCloudFileAccount.uploadFile(
+ window,
+ file,
+ name,
+ updateSettings.relatedCloudFileUpload
+ );
+
+ attachmentItem.cloudFileAccount = destCloudFileAccount;
+ attachmentItem.attachment.sendViaCloud = true;
+ attachmentItem.attachment.cloudFileAccountKey =
+ destCloudFileAccount.accountKey;
+
+ Services.telemetry.keyedScalarAdd(
+ "tb.filelink.uploaded_size",
+ destCloudFileAccount.type,
+ file.fileSize
+ );
+ }
+
+ attachmentItem.cloudFileUpload = upload;
+ attachmentItem.attachment.contentLocation = upload.url;
+
+ const eventsOnSuccess = {
+ upload: "attachment-uploaded",
+ move: "attachment-moved",
+ rename: "attachment-renamed",
+ };
+ if (eventsOnSuccess[mode]) {
+ eventOnDone = new CustomEvent(eventsOnSuccess[mode], {
+ bubbles: true,
+ cancelable: true,
+ detail: originalAttachment,
+ });
+ }
+ } catch (ex) {
+ const eventsOnFailure = {
+ upload: "attachment-upload-failed",
+ move: "attachment-move-failed",
+ };
+ if (eventsOnFailure[mode]) {
+ eventOnDone = new CustomEvent(eventsOnFailure[mode], {
+ bubbles: true,
+ cancelable: true,
+ detail: ex.result,
+ });
+ }
+ throw ex;
+ } finally {
+ attachmentItem.uploading = false;
+ gNumUploadingAttachments--;
+ updateSendCommands(true);
+ }
+ }
+
+ // Update the local attachment.
+ if (updateSettings.file) {
+ let attachment = FileToAttachment(updateSettings.file);
+ attachmentItem.attachment.size = attachment.size;
+ attachmentItem.attachment.url = attachment.url;
+ }
+ attachmentItem.attachment.name = name;
+
+ AttachmentsChanged();
+ // Update cmd_sortAttachmentsToggle because replacing/renaming may change the
+ // current sort order.
+ goUpdateCommand("cmd_sortAttachmentsToggle");
+ } catch (ex) {
+ // Attach provider and fileName to the Exception, so showLocalizedCloudFileAlert()
+ // can display the proper alert message.
+ ex.cloudProvider = destCloudFileAccount
+ ? cloudFileAccounts.getDisplayName(destCloudFileAccount)
+ : "";
+ ex.cloudFileName = originalAttachment?.name || name;
+ throw ex;
+ } finally {
+ await updateAttachmentItemProperties(attachmentItem);
+ if (eventOnDone) {
+ attachmentItem.dispatchEvent(eventOnDone);
+ }
+ }
+}
+
+function attachToCloud(event) {
+ gMsgCompose.allowRemoteContent = true;
+ if (event.target.cloudFileUpload) {
+ attachToCloudRepeat(
+ event.target.cloudFileUpload,
+ event.target.cloudFileAccount
+ );
+ } else {
+ attachToCloudNew(event.target.cloudFileAccount);
+ }
+ event.stopPropagation();
+}
+
+/**
+ * Attach a file that has already been uploaded to a cloud provider.
+ *
+ * @param {object} upload - the cloudFileUpload of the already uploaded file
+ * @param {object} account - the cloudFileAccount of the already uploaded file
+ */
+async function attachToCloudRepeat(upload, account) {
+ gMsgCompose.allowRemoteContent = true;
+ let file = FileUtils.File(upload.path);
+ let attachment = FileToAttachment(file);
+ attachment.name = upload.name;
+
+ let addedAttachmentItems = await AddAttachments([attachment]);
+ if (addedAttachmentItems.length > 0) {
+ try {
+ await UpdateAttachment(addedAttachmentItems[0], {
+ cloudFileAccount: account,
+ relatedCloudFileUpload: upload,
+ });
+ } catch (ex) {
+ showLocalizedCloudFileAlert(ex);
+ }
+ }
+}
+
+/**
+ * Prompt the user for a list of files to attach via a cloud provider.
+ *
+ * @param aAccount the cloud provider to upload the files to
+ */
+async function attachToCloudNew(aAccount) {
+ // We need to let the user pick local file(s) to upload to the cloud and
+ // gather url(s) to those files.
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(
+ window,
+ getComposeBundle().getFormattedString("chooseFileToAttachViaCloud", [
+ cloudFileAccounts.getDisplayName(aAccount),
+ ]),
+ Ci.nsIFilePicker.modeOpenMultiple
+ );
+
+ var lastDirectory = GetLastAttachDirectory();
+ if (lastDirectory) {
+ fp.displayDirectory = lastDirectory;
+ }
+
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ let rv = await new Promise(resolve => fp.open(resolve));
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.files) {
+ return;
+ }
+
+ let files = [...fp.files];
+ let attachments = files.map(f => FileToAttachment(f));
+ let addedAttachmentItems = await AddAttachments(attachments);
+ SetLastAttachDirectory(files[files.length - 1]);
+
+ let promises = [];
+ for (let attachmentItem of addedAttachmentItems) {
+ promises.push(
+ UpdateAttachment(attachmentItem, { cloudFileAccount: aAccount }).catch(
+ ex => {
+ RemoveAttachments([attachmentItem]);
+ showLocalizedCloudFileAlert(ex);
+ }
+ )
+ );
+ }
+
+ await Promise.all(promises);
+}
+
+/**
+ * Convert an array of attachments to cloud attachments.
+ *
+ * @param aItems an array of <attachmentitem>s containing the attachments in
+ * question
+ * @param aAccount the cloud account to upload the files to
+ */
+async function convertListItemsToCloudAttachment(aItems, aAccount) {
+ gMsgCompose.allowRemoteContent = true;
+ let promises = [];
+ for (let item of aItems) {
+ // Bail out, if we would convert to the current account.
+ if (
+ item.attachment.sendViaCloud &&
+ item.cloudFileAccount &&
+ item.cloudFileAccount == aAccount
+ ) {
+ continue;
+ }
+ promises.push(
+ UpdateAttachment(item, { cloudFileAccount: aAccount }).catch(
+ showLocalizedCloudFileAlert
+ )
+ );
+ }
+ await Promise.all(promises);
+}
+
+/**
+ * Convert the selected attachments to cloud attachments.
+ *
+ * @param aAccount the cloud account to upload the files to
+ */
+function convertSelectedToCloudAttachment(aAccount) {
+ convertListItemsToCloudAttachment(
+ [...gAttachmentBucket.selectedItems],
+ aAccount
+ );
+}
+
+/**
+ * Convert an array of nsIMsgAttachments to cloud attachments.
+ *
+ * @param aAttachments an array of nsIMsgAttachments
+ * @param aAccount the cloud account to upload the files to
+ */
+function convertToCloudAttachment(aAttachments, aAccount) {
+ let items = [];
+ for (let attachment of aAttachments) {
+ let item = gAttachmentBucket.findItemForAttachment(attachment);
+ if (item) {
+ items.push(item);
+ }
+ }
+
+ convertListItemsToCloudAttachment(items, aAccount);
+}
+
+/**
+ * Convert an array of attachments to regular (non-cloud) attachments.
+ *
+ * @param aItems an array of <attachmentitem>s containing the attachments in
+ * question
+ */
+async function convertListItemsToRegularAttachment(aItems) {
+ let promises = [];
+ for (let item of aItems) {
+ if (!item.attachment.sendViaCloud) {
+ continue;
+ }
+ promises.push(
+ UpdateAttachment(item, { cloudFileAccount: null }).catch(
+ showLocalizedCloudFileAlert
+ )
+ );
+ }
+ await Promise.all(promises);
+}
+
+/**
+ * Convert the selected attachments to regular (non-cloud) attachments.
+ */
+function convertSelectedToRegularAttachment() {
+ return convertListItemsToRegularAttachment([
+ ...gAttachmentBucket.selectedItems,
+ ]);
+}
+
+/**
+ * Convert an array of nsIMsgAttachments to regular (non-cloud) attachments.
+ *
+ * @param aAttachments an array of nsIMsgAttachments
+ */
+function convertToRegularAttachment(aAttachments) {
+ let items = [];
+ for (let attachment of aAttachments) {
+ let item = gAttachmentBucket.findItemForAttachment(attachment);
+ if (item) {
+ items.push(item);
+ }
+ }
+
+ return convertListItemsToRegularAttachment(items);
+}
+
+/* messageComposeOfflineQuitObserver is notified whenever the network
+ * connection status has switched to offline, or when the application
+ * has received a request to quit.
+ */
+var messageComposeOfflineQuitObserver = {
+ observe(aSubject, aTopic, aData) {
+ // sanity checks
+ if (aTopic == "network:offline-status-changed") {
+ MessageComposeOfflineStateChanged(Services.io.offline);
+ } else if (
+ aTopic == "quit-application-requested" &&
+ aSubject instanceof Ci.nsISupportsPRBool &&
+ !aSubject.data
+ ) {
+ // Check whether to veto the quit request
+ // (unless another observer already did).
+ aSubject.data = !ComposeCanClose();
+ }
+ },
+};
+
+function AddMessageComposeOfflineQuitObserver() {
+ Services.obs.addObserver(
+ messageComposeOfflineQuitObserver,
+ "network:offline-status-changed"
+ );
+ Services.obs.addObserver(
+ messageComposeOfflineQuitObserver,
+ "quit-application-requested"
+ );
+
+ // set the initial state of the send button
+ MessageComposeOfflineStateChanged(Services.io.offline);
+}
+
+function RemoveMessageComposeOfflineQuitObserver() {
+ Services.obs.removeObserver(
+ messageComposeOfflineQuitObserver,
+ "network:offline-status-changed"
+ );
+ Services.obs.removeObserver(
+ messageComposeOfflineQuitObserver,
+ "quit-application-requested"
+ );
+}
+
+function MessageComposeOfflineStateChanged(goingOffline) {
+ try {
+ var sendButton = document.getElementById("button-send");
+ var sendNowMenuItem = document.getElementById("menu-item-send-now");
+
+ if (!gSavedSendNowKey) {
+ gSavedSendNowKey = sendNowMenuItem.getAttribute("key");
+ }
+
+ // don't use goUpdateCommand here ... the defaultController might not be installed yet
+ updateSendCommands(false);
+
+ if (goingOffline) {
+ sendButton.label = sendButton.getAttribute("later_label");
+ sendButton.setAttribute(
+ "tooltiptext",
+ sendButton.getAttribute("later_tooltiptext")
+ );
+ sendNowMenuItem.removeAttribute("key");
+ } else {
+ sendButton.label = sendButton.getAttribute("now_label");
+ sendButton.setAttribute(
+ "tooltiptext",
+ sendButton.getAttribute("now_tooltiptext")
+ );
+ if (gSavedSendNowKey) {
+ sendNowMenuItem.setAttribute("key", gSavedSendNowKey);
+ }
+ }
+ } catch (e) {}
+}
+
+function DoCommandPrint() {
+ let browser = GetCurrentEditorElement();
+ browser.contentDocument.title =
+ document.getElementById("msgSubject").value.trim() ||
+ getComposeBundle().getString("defaultSubject");
+ PrintUtils.startPrintWindow(browser.browsingContext, {});
+}
+
+/**
+ * Locks/Unlocks the window widgets while a message is being saved/sent.
+ * Locking means to disable all possible items in the window so that
+ * the user can't click/activate anything.
+ *
+ * @param aDisable true = lock the window. false = unlock the window.
+ */
+function ToggleWindowLock(aDisable) {
+ if (aDisable) {
+ // Save the active element so we can focus it again.
+ ToggleWindowLock.activeElement = document.activeElement;
+ }
+ gWindowLocked = aDisable;
+ updateAllItems(aDisable);
+ updateEditableFields(aDisable);
+ if (!aDisable) {
+ updateComposeItems();
+ // Refocus what had focus when the lock began.
+ ToggleWindowLock.activeElement?.focus();
+ }
+}
+
+/* This function will go away soon as now arguments are passed to the window using a object of type nsMsgComposeParams instead of a string */
+function GetArgs(originalData) {
+ var args = {};
+
+ if (originalData == "") {
+ return null;
+ }
+
+ var data = "";
+ var separator = String.fromCharCode(1);
+
+ var quoteChar = "";
+ var prevChar = "";
+ var nextChar = "";
+ for (let i = 0; i < originalData.length; i++, prevChar = aChar) {
+ var aChar = originalData.charAt(i);
+ var aCharCode = originalData.charCodeAt(i);
+ if (i < originalData.length - 1) {
+ nextChar = originalData.charAt(i + 1);
+ } else {
+ nextChar = "";
+ }
+
+ if (aChar == quoteChar && (nextChar == "," || nextChar == "")) {
+ quoteChar = "";
+ data += aChar;
+ } else if ((aCharCode == 39 || aCharCode == 34) && prevChar == "=") {
+ // quote or double quote
+ if (quoteChar == "") {
+ quoteChar = aChar;
+ }
+ data += aChar;
+ } else if (aChar == ",") {
+ if (quoteChar == "") {
+ data += separator;
+ } else {
+ data += aChar;
+ }
+ } else {
+ data += aChar;
+ }
+ }
+
+ var pairs = data.split(separator);
+ // dump("Compose: argument: {" + data + "}\n");
+
+ for (let i = pairs.length - 1; i >= 0; i--) {
+ var pos = pairs[i].indexOf("=");
+ if (pos == -1) {
+ continue;
+ }
+ var argname = pairs[i].substring(0, pos);
+ var argvalue = pairs[i].substring(pos + 1);
+ if (argvalue.startsWith("'") && argvalue.endsWith("'")) {
+ args[argname] = argvalue.substring(1, argvalue.length - 1);
+ } else {
+ try {
+ args[argname] = decodeURIComponent(argvalue);
+ } catch (e) {
+ args[argname] = argvalue;
+ }
+ }
+ // dump("[" + argname + "=" + args[argname] + "]\n");
+ }
+ return args;
+}
+
+function ComposeFieldsReady() {
+ // If we are in plain text, we need to set the wrap column
+ if (!gMsgCompose.composeHTML) {
+ try {
+ gMsgCompose.editor.wrapWidth = gMsgCompose.wrapLength;
+ } catch (e) {
+ dump("### textEditor.wrapWidth exception text: " + e + " - failed\n");
+ }
+ }
+
+ CompFields2Recipients(gMsgCompose.compFields);
+ SetComposeWindowTitle();
+ updateEditableFields(false);
+ gLoadingComplete = true;
+
+ // Set up observers to recheck limit and encyption on recipients change.
+ observeRecipientsChange();
+
+ // Perform the initial checks.
+ checkPublicRecipientsLimit();
+ checkEncryptionState();
+}
+
+/**
+ * Set up observers to recheck limit and encyption on recipients change.
+ */
+function observeRecipientsChange() {
+ // Observe childList changes of `To` and `Cc` address rows to check if we need
+ // to show the public bulk recipients notification according to the threshold.
+ // So far we're only counting recipient pills, not plain text addresses.
+ gRecipientObserver = new MutationObserver(function (mutations) {
+ if (mutations.some(m => m.type == "childList")) {
+ checkPublicRecipientsLimit();
+ }
+ });
+ gRecipientObserver.observe(document.getElementById("toAddrContainer"), {
+ childList: true,
+ });
+ gRecipientObserver.observe(document.getElementById("ccAddrContainer"), {
+ childList: true,
+ });
+
+ function callCheckEncryptionState() {
+ // We must not pass the parameters that we get from observing.
+ checkEncryptionState();
+ }
+
+ gRecipientKeysObserver = new MutationObserver(callCheckEncryptionState);
+ gRecipientKeysObserver.observe(document.getElementById("toAddrContainer"), {
+ childList: true,
+ });
+ gRecipientKeysObserver.observe(document.getElementById("ccAddrContainer"), {
+ childList: true,
+ });
+ gRecipientKeysObserver.observe(document.getElementById("bccAddrContainer"), {
+ childList: true,
+ });
+}
+
+// checks if the passed in string is a mailto url, if it is, generates nsIMsgComposeParams
+// for the url and returns them.
+function handleMailtoArgs(mailtoUrl) {
+ // see if the string is a mailto url....do this by checking the first 7 characters of the string
+ if (mailtoUrl.toLowerCase().startsWith("mailto:")) {
+ // if it is a mailto url, turn the mailto url into a MsgComposeParams object....
+ let uri = Services.io.newURI(mailtoUrl);
+
+ if (uri) {
+ return MailServices.compose.getParamsForMailto(uri);
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Handle ESC keypress from composition window for
+ * notifications with close button in the
+ * attachmentNotificationBox.
+ */
+function handleEsc() {
+ let activeElement = document.activeElement;
+
+ if (activeElement.id == "messageEditor") {
+ // Focus within the message body.
+ let findbar = document.getElementById("FindToolbar");
+ if (!findbar.hidden) {
+ // If findbar is visible hide it.
+ // Focus on the findbar is handled by findbar itself.
+ findbar.close();
+ } else {
+ // Close the most recently shown notification.
+ gComposeNotification.currentNotification?.close();
+ }
+ return;
+ }
+
+ // If focus is within a notification, close the corresponding notification.
+ for (let notification of gComposeNotification.allNotifications) {
+ if (notification.contains(activeElement)) {
+ notification.close();
+ return;
+ }
+ }
+}
+
+/**
+ * This state machine manages all showing and hiding of the attachment
+ * notification bar. It is only called if any change happened so that
+ * recalculating of the notification is needed:
+ * - keywords changed
+ * - manual reminder was toggled
+ * - attachments changed
+ * - manual reminder is disabled
+ *
+ * It does not track whether the notification is still up when it should be.
+ * That allows the user to close it any time without this function showing
+ * it again.
+ * We ensure notification is only shown on right events, e.g. only when we have
+ * keywords and attachments were removed (but not when we have keywords and
+ * manual reminder was just turned off). We always show the notification
+ * again if keywords change (if no attachments and no manual reminder).
+ *
+ * @param aForce If set to true, notification will be shown immediately if
+ * there are any keywords. If set to false, it is shown only when
+ * they have changed.
+ */
+function manageAttachmentNotification(aForce = false) {
+ let keywords;
+ let keywordsCount = 0;
+
+ // First see if the notification is to be hidden due to reasons other than
+ // not having keywords.
+ let removeNotification = attachmentNotificationSupressed();
+
+ // If that is not true, we need to look at the state of keywords.
+ if (!removeNotification) {
+ if (attachmentWorker.lastMessage) {
+ // We know the state of keywords, so process them.
+ if (attachmentWorker.lastMessage.length) {
+ keywords = attachmentWorker.lastMessage.join(", ");
+ keywordsCount = attachmentWorker.lastMessage.length;
+ }
+ removeNotification = keywordsCount == 0;
+ } else {
+ // We don't know keywords, so get them first.
+ // If aForce was true, and some keywords are found, we get to run again from
+ // attachmentWorker.onmessage().
+ gAttachmentNotifier.redetectKeywords(aForce);
+ return;
+ }
+ }
+
+ let notification =
+ gComposeNotification.getNotificationWithValue("attachmentReminder");
+ if (removeNotification) {
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ return;
+ }
+
+ // We have some keywords, however only pop up the notification if requested
+ // to do so.
+ if (!aForce) {
+ return;
+ }
+
+ let textValue = getComposeBundle().getString(
+ "attachmentReminderKeywordsMsgs"
+ );
+ textValue = PluralForm.get(keywordsCount, textValue).replace(
+ "#1",
+ keywordsCount
+ );
+ // If the notification already exists, we simply add the new attachment
+ // specific keywords to the existing notification instead of creating it
+ // from scratch.
+ if (notification) {
+ let msgContainer = notification.messageText.querySelector(
+ "#attachmentReminderText"
+ );
+ msgContainer.textContent = textValue;
+ let keywordsContainer = notification.messageText.querySelector(
+ "#attachmentKeywords"
+ );
+ keywordsContainer.textContent = keywords;
+ return;
+ }
+
+ // Construct the notification as we don't have one.
+ let msg = document.createElement("div");
+ msg.onclick = function (event) {
+ openOptionsDialog("paneCompose", "compositionAttachmentsCategory", {
+ subdialog: "attachment_reminder_button",
+ });
+ };
+
+ let msgText = document.createElement("span");
+ msg.appendChild(msgText);
+ msgText.id = "attachmentReminderText";
+ msgText.textContent = textValue;
+ let msgKeywords = document.createElement("span");
+ msg.appendChild(msgKeywords);
+ msgKeywords.id = "attachmentKeywords";
+ msgKeywords.textContent = keywords;
+ let addButton = {
+ "l10n-id": "add-attachment-notification-reminder2",
+ callback(aNotificationBar, aButton) {
+ goDoCommand("cmd_attachFile");
+ return true; // keep notification open (the state machine will decide on it later)
+ },
+ };
+
+ let remindLaterMenuPopup = document.createXULElement("menupopup");
+ remindLaterMenuPopup.id = "reminderBarPopup";
+ let disableAttachmentReminder = document.createXULElement("menuitem");
+ disableAttachmentReminder.id = "disableReminder";
+ disableAttachmentReminder.setAttribute(
+ "label",
+ getComposeBundle().getString("disableAttachmentReminderButton")
+ );
+ disableAttachmentReminder.addEventListener("command", event => {
+ gDisableAttachmentReminder = true;
+ toggleAttachmentReminder(false);
+ event.stopPropagation();
+ });
+ remindLaterMenuPopup.appendChild(disableAttachmentReminder);
+
+ // The notification code only deals with buttons but we need a toolbarbutton,
+ // so we construct it and add it ourselves.
+ let remindButton = document.createXULElement("toolbarbutton", {
+ is: "toolbarbutton-menu-button",
+ });
+ remindButton.classList.add("notification-button", "small-button");
+ remindButton.setAttribute(
+ "accessKey",
+ getComposeBundle().getString("remindLaterButton.accesskey")
+ );
+ remindButton.setAttribute(
+ "label",
+ getComposeBundle().getString("remindLaterButton")
+ );
+ remindButton.addEventListener("command", function (event) {
+ toggleAttachmentReminder(true);
+ });
+ remindButton.appendChild(remindLaterMenuPopup);
+
+ notification = gComposeNotification.appendNotification(
+ "attachmentReminder",
+ {
+ label: "",
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ [addButton]
+ );
+ notification.setAttribute("id", "attachmentNotificationBox");
+
+ notification.messageText.appendChild(msg);
+ notification.buttonContainer.appendChild(remindButton);
+}
+
+function clearRecipPillKeyIssues() {
+ for (let pill of document.querySelectorAll("mail-address-pill.key-issue")) {
+ pill.classList.remove("key-issue");
+ }
+}
+
+/**
+ * @returns {string[]} - All current recipient email addresses, lowercase.
+ */
+function getEncryptionCompatibleRecipients() {
+ let recipientPills = [
+ ...document.querySelectorAll(
+ "#toAddrContainer > mail-address-pill, #ccAddrContainer > mail-address-pill, #bccAddrContainer > mail-address-pill"
+ ),
+ ];
+ let recipients = [
+ ...new Set(recipientPills.map(pill => pill.emailAddress.toLowerCase())),
+ ];
+ return recipients;
+}
+
+const PRErrorCodeSuccess = 0;
+const certificateUsageEmailRecipient = 0x0020;
+
+var gEmailsWithMissingKeys = null;
+var gEmailsWithMissingCerts = null;
+
+/**
+ * @returns {boolean} true if checking openpgp keys is necessary
+ */
+function mustCheckRecipientKeys() {
+ let remindOpenPGP = Services.prefs.getBoolPref(
+ "mail.openpgp.remind_encryption_possible"
+ );
+
+ let autoEnablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_enable",
+ false
+ );
+
+ return (
+ isPgpConfigured() && (gSendEncrypted || remindOpenPGP || autoEnablePref)
+ );
+}
+
+/**
+ * Check available OpenPGP public encryption keys for the given email
+ * addresses. (This function assumes the caller has already called
+ * mustCheckRecipientKeys() and the result was true.)
+ *
+ * gEmailsWithMissingKeys will be set to an array of email addresses
+ * (a subset of the input) that do NOT have a usable
+ * (valid + accepted) key.
+ *
+ * @param {string[]} recipients - The addresses to lookup.
+ */
+async function checkRecipientKeys(recipients) {
+ gEmailsWithMissingKeys = [];
+
+ for (let addr of recipients) {
+ let keyMetas = await EnigmailKeyRing.getEncryptionKeyMeta(addr);
+
+ if (keyMetas.length == 1 && keyMetas[0].readiness == "alias") {
+ // Skip if this is an alias email.
+ continue;
+ }
+
+ if (!keyMetas.some(k => k.readiness == "accepted")) {
+ gEmailsWithMissingKeys.push(addr);
+ continue;
+ }
+ }
+}
+
+/**
+ * @returns {boolean} true if checking s/mime certificates is necessary
+ */
+function mustCheckRecipientCerts() {
+ let remindSMime = Services.prefs.getBoolPref(
+ "mail.smime.remind_encryption_possible"
+ );
+
+ let autoEnablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_enable",
+ false
+ );
+
+ return (
+ isSmimeEncryptionConfigured() &&
+ (gSendEncrypted || remindSMime || autoEnablePref)
+ );
+}
+
+/**
+ * Check available S/MIME encryption certificates for the given email
+ * addresses. (This function assumes the caller has already called
+ * mustCheckRecipientCerts() and the result was true.)
+ *
+ * gEmailsWithMissingCerts will be set to an array of email addresses
+ * (a subset of the input) that do NOT have a usable (valid) certificate.
+ *
+ * This function might take significant time to complete, because
+ * certificate verification involves OCSP, which runs on a background
+ * thread.
+ *
+ * @param {string[]} recipients - The addresses to lookup.
+ */
+function checkRecipientCerts(recipients) {
+ return new Promise((resolve, reject) => {
+ if (gSMPendingCertLookupSet.size) {
+ reject(
+ new Error(
+ "Must not be called while previous checks are still in progress"
+ )
+ );
+ }
+
+ gEmailsWithMissingCerts = [];
+
+ function continueCheckRecipientCerts() {
+ gEmailsWithMissingCerts = recipients.filter(
+ email => !gSMFields.haveValidCertForEmail(email)
+ );
+ resolve();
+ }
+
+ /** @implements {nsIDoneFindCertForEmailCallback} */
+ let doneFindCertForEmailCallback = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIDoneFindCertForEmailCallback",
+ ]),
+
+ findCertDone(email, cert) {
+ let isStaleResult = !gSMPendingCertLookupSet.has(email);
+ // isStaleResult true means, this recipient was removed by the
+ // user while we were looking for the cert in the background.
+ // Let's remember the result, but don't trigger any actions
+ // based on it.
+
+ if (cert) {
+ gSMFields.cacheValidCertForEmail(email, cert ? cert.dbKey : "");
+ }
+ if (isStaleResult) {
+ return;
+ }
+ gSMPendingCertLookupSet.delete(email);
+ if (!cert && !gSMCertsAlreadyLookedUpInLDAP.has(email)) {
+ let autocompleteLdap = Services.prefs.getBoolPref(
+ "ldap_2.autoComplete.useDirectory"
+ );
+
+ if (autocompleteLdap) {
+ gSMCertsAlreadyLookedUpInLDAP.add(email);
+
+ let autocompleteDirectory = null;
+ if (gCurrentIdentity.overrideGlobalPref) {
+ autocompleteDirectory = gCurrentIdentity.directoryServer;
+ } else {
+ autocompleteDirectory = Services.prefs.getCharPref(
+ "ldap_2.autoComplete.directoryServer"
+ );
+ }
+
+ if (autocompleteDirectory) {
+ window.openDialog(
+ "chrome://messenger-smime/content/certFetchingStatus.xhtml",
+ "",
+ "chrome,resizable=1,modal=1,dialog=1",
+ autocompleteDirectory,
+ [email]
+ );
+ }
+
+ gSMPendingCertLookupSet.add(email);
+ gSMFields.asyncFindCertByEmailAddr(
+ email,
+ doneFindCertForEmailCallback
+ );
+ }
+ }
+
+ if (gSMPendingCertLookupSet.size) {
+ // must continue to wait for more queued lookups to complete
+ return;
+ }
+
+ // No more lookups pending.
+ continueCheckRecipientCerts();
+ },
+ };
+
+ for (let email of recipients) {
+ if (gSMFields.haveValidCertForEmail(email)) {
+ continue;
+ }
+
+ if (gSMPendingCertLookupSet.has(email)) {
+ throw new Error(`cert lookup still pending for ${email}`);
+ }
+
+ gSMPendingCertLookupSet.add(email);
+ gSMFields.asyncFindCertByEmailAddr(email, doneFindCertForEmailCallback);
+ }
+
+ // If we haven't queued any lookups, we continue immediately
+ if (!gSMPendingCertLookupSet.size) {
+ continueCheckRecipientCerts();
+ }
+ });
+}
+
+/**
+ * gCheckEncryptionStateCompletionIsPending means that async work
+ * started by checkEncryptionState() has not yet completed.
+ */
+var gCheckEncryptionStateCompletionIsPending = false;
+
+/**
+ * gCheckEncryptionStateNeedsRestart means that checkEncryptionState()
+ * was called, while its async operations were still running.
+ * The additional to checkEncryptionState() was treated as a no-op,
+ * but gCheckEncryptionStateNeedsRestart was set to true, to remember
+ * that checkEncryptionState() must be immediately restarted after its
+ * previous execution is done. This will the restarted
+ * checkEncryptionState() execution to detect and handle changes that
+ * could result in a different state.
+ */
+var gCheckEncryptionStateNeedsRestart = false;
+
+/**
+ * gWasCESTriggeredByComposerChange is used to track whether an
+ * encryption-state-checked event should be sent after an ongoing
+ * execution of checkEncryptionState() is done.
+ * The purpose of the encryption-state-checked event is to allow our
+ * automated tests to be notified as soon as an automatic call to
+ * checkEncryptionState() (and all related async calls) is complete,
+ * which means all automatic adjustments to the global encryption state
+ * are done, and the automated test code may proceed to compare the
+ * state to our exptectations.
+ * We want that event to be sent after modifications were made to the
+ * composer window itself, such as sender identity and recipients.
+ * However, we want to ignore calls to checkEncryptionState() that
+ * were triggered indirectly after OpenPGP keys were changed.
+ * If an event was originally triggered by a change to OpenPGP keys,
+ * and the async processing of checkEncryptionState() was still running,
+ * and another direct change to the composer window was made, which
+ * shall result in sending a encryption-state-checked after completion,
+ * then the flag gWasCESTriggeredByComposerChange will be set,
+ * which will cause the event to be sent after the restarted call
+ * to checkEncryptionState() is complete.
+ */
+var gWasCESTriggeredByComposerChange = false;
+
+/**
+ * Perform all checks that are necessary to update the state of
+ * email encryption, based on the current recipients. This should be
+ * done whenever the recipient list or the status of available keys/certs
+ * has changed. All automatic actions for encryption related settings
+ * will be triggered accordingly.
+ * This function will trigger async activity, and the resulting actions
+ * (e.g. update of UI elements) may happen after a delay.
+ * It's safe to call this while processing hasn't completed yet, in this
+ * scenario the processing will be restarted, once pending
+ * activity has completed.
+ *
+ * @param {string} [trigger] - A string that gives information about
+ * the reason why this function is being called.
+ * This parameter is intended to help with automated testing.
+ * If the trigger string starts with "openpgp-" then no completition
+ * event will be dispatched. This allows the automated test code to
+ * wait for events that are directly related to properties of the
+ * composer window, only.
+ */
+async function checkEncryptionState(trigger) {
+ if (!gLoadingComplete) {
+ // Let's not do this while we're still loading the composer window,
+ // it can have side effects, see bug 1777683.
+ // Also, if multiple recipients are added to an email automatically
+ // e.g. during reply-all, it doesn't make sense to execute this
+ // function every time after one of them gets added.
+ return;
+ }
+
+ if (!/^openpgp-/.test(trigger)) {
+ gWasCESTriggeredByComposerChange = true;
+ }
+
+ if (gCheckEncryptionStateCompletionIsPending) {
+ // avoid concurrency
+ gCheckEncryptionStateNeedsRestart = true;
+ return;
+ }
+
+ let remindSMime = Services.prefs.getBoolPref(
+ "mail.smime.remind_encryption_possible"
+ );
+ let remindOpenPGP = Services.prefs.getBoolPref(
+ "mail.openpgp.remind_encryption_possible"
+ );
+ let autoEnablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_enable",
+ false
+ );
+
+ if (!gSendEncrypted && !autoEnablePref && !remindSMime && !remindOpenPGP) {
+ // No need to check.
+ updateEncryptionDependencies();
+ updateKeyCertNotifications([]);
+ updateEncryptionTechReminder(null);
+ if (gWasCESTriggeredByComposerChange) {
+ document.dispatchEvent(new CustomEvent("encryption-state-checked"));
+ gWasCESTriggeredByComposerChange = false;
+ }
+ return;
+ }
+
+ let recipients = getEncryptionCompatibleRecipients();
+ let checkingCerts = mustCheckRecipientCerts();
+ let checkingKeys = mustCheckRecipientKeys();
+
+ async function continueCheckEncryptionStateSub() {
+ let canEncryptSMIME =
+ recipients.length && checkingCerts && !gEmailsWithMissingCerts.length;
+ let canEncryptOpenPGP =
+ recipients.length && checkingKeys && !gEmailsWithMissingKeys.length;
+
+ let autoEnabledJustNow = false;
+
+ if (
+ gSendEncrypted &&
+ gUserTouchedSendEncrypted &&
+ !isPgpConfigured() &&
+ !isSmimeEncryptionConfigured()
+ ) {
+ notifyIdentityCannotEncrypt(true, gCurrentIdentity.email);
+ } else {
+ notifyIdentityCannotEncrypt(false, gCurrentIdentity.email);
+ }
+
+ if (
+ !gSendEncrypted &&
+ autoEnablePref &&
+ !gUserTouchedSendEncrypted &&
+ recipients.length &&
+ (canEncryptSMIME || canEncryptOpenPGP)
+ ) {
+ if (!canEncryptSMIME) {
+ gSelectedTechnologyIsPGP = true;
+ } else if (!canEncryptOpenPGP) {
+ gSelectedTechnologyIsPGP = false;
+ }
+ gSendEncrypted = true;
+ autoEnabledJustNow = true;
+ removeAutoDisableNotification();
+ }
+
+ if (
+ !gIsRelatedToEncryptedOriginal &&
+ !autoEnabledJustNow &&
+ !gUserTouchedSendEncrypted &&
+ gSendEncrypted &&
+ !canEncryptSMIME &&
+ !canEncryptOpenPGP
+ ) {
+ // The auto_disable pref is ignored if auto_enable is false
+ let autoDisablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_disable",
+ false
+ );
+ if (autoEnablePref && autoDisablePref && !gUserTouchedSendEncrypted) {
+ gSendEncrypted = false;
+ let notifyPref = Services.prefs.getBoolPref(
+ "mail.e2ee.notify_on_auto_disable",
+ true
+ );
+ if (notifyPref) {
+ // Most likely the notification is not showing yet, and we
+ // must append it. (We should have removed an existing
+ // notification at the time encryption was enabled.)
+ // However, double check to avoid that we'll show it twice.
+ const NOTIFICATION_NAME = "e2eeDisableNotification";
+ let notification =
+ gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME);
+ if (!notification) {
+ gComposeNotification.appendNotification(
+ NOTIFICATION_NAME,
+ {
+ label: { "l10n-id": "auto-disable-e2ee-warning" },
+ priority: gComposeNotification.PRIORITY_WARNING_LOW,
+ },
+ []
+ );
+ }
+ }
+ }
+ }
+
+ let techPref = gCurrentIdentity.getIntAttribute("e2etechpref");
+
+ if (gSendEncrypted && canEncryptSMIME && canEncryptOpenPGP) {
+ // No change if 0
+ if (techPref == 1) {
+ gSelectedTechnologyIsPGP = false;
+ } else if (techPref == 2) {
+ gSelectedTechnologyIsPGP = true;
+ }
+ }
+
+ if (
+ gSendEncrypted &&
+ canEncryptSMIME &&
+ !canEncryptOpenPGP &&
+ gSelectedTechnologyIsPGP
+ ) {
+ gSelectedTechnologyIsPGP = false;
+ }
+
+ if (
+ gSendEncrypted &&
+ !canEncryptSMIME &&
+ canEncryptOpenPGP &&
+ !gSelectedTechnologyIsPGP
+ ) {
+ gSelectedTechnologyIsPGP = true;
+ }
+
+ updateEncryptionDependencies();
+
+ if (!gSendEncrypted) {
+ updateKeyCertNotifications([]);
+ if (recipients.length && (canEncryptSMIME || canEncryptOpenPGP)) {
+ let useTech;
+ if (canEncryptSMIME && canEncryptOpenPGP) {
+ if (techPref == 1) {
+ useTech = "SMIME";
+ } else {
+ useTech = "OpenPGP";
+ }
+ } else {
+ useTech = canEncryptOpenPGP ? "OpenPGP" : "SMIME";
+ }
+ updateEncryptionTechReminder(useTech);
+ } else {
+ updateEncryptionTechReminder(null);
+ }
+ } else {
+ updateKeyCertNotifications(
+ gSelectedTechnologyIsPGP
+ ? gEmailsWithMissingKeys
+ : gEmailsWithMissingCerts
+ );
+ updateEncryptionTechReminder(null);
+ }
+
+ gCheckEncryptionStateCompletionIsPending = false;
+
+ if (gCheckEncryptionStateNeedsRestart) {
+ // Recursive call, which is acceptable (and not blocking),
+ // because necessary long actions will be triggered asynchronously.
+ gCheckEncryptionStateNeedsRestart = false;
+ await checkEncryptionState(trigger);
+ } else if (gWasCESTriggeredByComposerChange) {
+ document.dispatchEvent(new CustomEvent("encryption-state-checked"));
+ gWasCESTriggeredByComposerChange = false;
+ }
+ }
+
+ let pendingPromises = [];
+
+ if (checkingCerts) {
+ pendingPromises.push(checkRecipientCerts(recipients));
+ }
+
+ if (checkingKeys) {
+ pendingPromises.push(checkRecipientKeys(recipients));
+ }
+
+ gCheckEncryptionStateNeedsRestart = false;
+ gCheckEncryptionStateCompletionIsPending = true;
+
+ Promise.all(pendingPromises).then(continueCheckEncryptionStateSub);
+}
+
+/**
+ * Display (or hide) the notification that informs the user that
+ * encryption is possible (but currently not enabled).
+ *
+ * @param {string} technology - The technology that is possible,
+ * ("OpenPGP" or "SMIME"), or null if none is possible.
+ */
+function updateEncryptionTechReminder(technology) {
+ let enableNotification =
+ gComposeNotification.getNotificationWithValue("enableNotification");
+ if (enableNotification) {
+ gComposeNotification.removeNotification(enableNotification);
+ }
+
+ if (!technology || (technology != "OpenPGP" && technology != "SMIME")) {
+ return;
+ }
+
+ let labelId =
+ technology == "OpenPGP"
+ ? "can-encrypt-openpgp-notification"
+ : "can-encrypt-smime-notification";
+
+ gComposeNotification.appendNotification(
+ "enableNotification",
+ {
+ label: { "l10n-id": labelId },
+ priority: gComposeNotification.PRIORITY_INFO_LOW,
+ },
+ [
+ {
+ "l10n-id": "can-e2e-encrypt-button",
+ callback() {
+ gSelectedTechnologyIsPGP = technology == "OpenPGP";
+ gSendEncrypted = true;
+ gUserTouchedSendEncrypted = true;
+ checkEncryptionState();
+ return true;
+ },
+ },
+ ]
+ );
+}
+
+/**
+ * Display (or hide) the notification that informs the user that
+ * encryption isn't possible, because the currently selected Sender
+ * (From) identity isn't configured for end-to-end-encryption.
+ *
+ * @param {boolean} show - Show if true, hide if false.
+ * @param {string} addr - email address to show in notification
+ */
+async function notifyIdentityCannotEncrypt(show, addr) {
+ const NOTIFICATION_NAME = "IdentityCannotEncrypt";
+
+ let notification =
+ gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME);
+
+ if (show) {
+ if (!notification) {
+ gComposeNotification.appendNotification(
+ NOTIFICATION_NAME,
+ {
+ label: await document.l10n.formatValue(
+ "openpgp-key-issue-notification-from",
+ {
+ addr,
+ }
+ ),
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ []
+ );
+ }
+ } else if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+}
+
+/**
+ * Show an appropriate notification based on the given list of
+ * email addresses that cannot be used with email encryption
+ * (because of missing usable OpenPGP public keys or S/MIME certs).
+ * The list may be empty, which means no notification will be shown
+ * (or existing notifications will be removed).
+ *
+ * @param {string[]} emailsWithMissing - The email addresses that prevent
+ * using encryption, because certs/keys are missing.
+ */
+function updateKeyCertNotifications(emailsWithMissing) {
+ const NOTIFICATION_NAME = "keyNotification";
+
+ let notification =
+ gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME);
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+
+ // Always refresh the pills UI.
+ clearRecipPillKeyIssues();
+
+ // Interrupt if we don't have any issue.
+ if (!emailsWithMissing.length) {
+ return;
+ }
+
+ // Update recipient pills.
+ for (let pill of document.querySelectorAll("mail-address-pill")) {
+ if (
+ emailsWithMissing.includes(pill.emailAddress.toLowerCase()) &&
+ !pill.classList.contains("invalid-address")
+ ) {
+ pill.classList.add("key-issue");
+ }
+ }
+
+ /**
+ * Display the new key notification.
+ */
+ let buttons = [];
+ buttons.push({
+ "l10n-id": "key-notification-disable-encryption",
+ callback() {
+ gUserTouchedSendEncrypted = true;
+ gSendEncrypted = false;
+ checkEncryptionState();
+ return true;
+ },
+ });
+
+ if (gSelectedTechnologyIsPGP) {
+ buttons.push({
+ "l10n-id": "key-notification-resolve",
+ callback() {
+ showMessageComposeSecurityStatus();
+ return true;
+ },
+ });
+ }
+
+ let label;
+
+ if (emailsWithMissing.length == 1) {
+ let id = gSelectedTechnologyIsPGP
+ ? "openpgp-key-issue-notification-single"
+ : "smime-cert-issue-notification-single";
+ label = {
+ "l10n-id": id,
+ "l10n-args": { addr: emailsWithMissing[0] },
+ };
+ } else {
+ let id = gSelectedTechnologyIsPGP
+ ? "openpgp-key-issue-notification-multi"
+ : "smime-cert-issue-notification-multi";
+
+ label = {
+ "l10n-id": id,
+ "l10n-args": { count: emailsWithMissing.length },
+ };
+ }
+
+ gComposeNotification.appendNotification(
+ NOTIFICATION_NAME,
+ {
+ label,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+}
+
+/**
+ * Returns whether the attachment notification should be suppressed regardless
+ * of the state of keywords.
+ */
+function attachmentNotificationSupressed() {
+ return (
+ gDisableAttachmentReminder ||
+ gManualAttachmentReminder ||
+ gAttachmentBucket.getRowCount()
+ );
+}
+
+var attachmentWorker = new Worker("resource:///modules/AttachmentChecker.jsm");
+
+// The array of currently found keywords. Or null if keyword detection wasn't
+// run yet so we don't know.
+attachmentWorker.lastMessage = null;
+
+attachmentWorker.onerror = function (error) {
+ console.error("Attachment Notification Worker error!!! " + error.message);
+ throw error;
+};
+
+/**
+ * Called when attachmentWorker finishes checking of the message for keywords.
+ *
+ * @param event If defined, event.data contains an array of found keywords.
+ * @param aManage If set to true and we determine keywords have changed,
+ * manage the notification.
+ * If set to false, just store the new keyword list but do not
+ * touch the notification. That effectively eats the
+ * "keywords changed" event which usually shows the notification
+ * if it was hidden. See manageAttachmentNotification().
+ */
+attachmentWorker.onmessage = function (event, aManage = true) {
+ // Exit if keywords haven't changed.
+ if (
+ !event ||
+ (attachmentWorker.lastMessage &&
+ event.data.toString() == attachmentWorker.lastMessage.toString())
+ ) {
+ return;
+ }
+
+ let data = event ? event.data : [];
+ attachmentWorker.lastMessage = data.slice(0);
+ if (aManage) {
+ manageAttachmentNotification(true);
+ }
+};
+
+/**
+ * Update attachment-related internal flags, UI, and commands.
+ * Called when number of attachments changes.
+ *
+ * @param aShowPane {string} "show": show the attachment pane
+ * "hide": hide the attachment pane
+ * omitted: just update without changing pane visibility
+ * @param aContentChanged {Boolean} optional value to assign to gContentChanged;
+ * defaults to true.
+ */
+function AttachmentsChanged(aShowPane, aContentChanged = true) {
+ gContentChanged = aContentChanged;
+ updateAttachmentPane(aShowPane);
+ manageAttachmentNotification(true);
+ updateAttachmentItems();
+}
+
+/**
+ * This functions returns an array of valid spellcheck languages. It checks
+ * that a dictionary exists for the language passed in, if any. It also
+ * retrieves the corresponding preference and ensures that a dictionary exists.
+ * If not, it adjusts the preference accordingly.
+ * When the nominated dictionary does not exist, the effects are very confusing
+ * to the user: Inline spell checking does not work, although the option is
+ * selected and a spell check dictionary seems to be selected in the options
+ * dialog (the dropdown shows the first list member if the value is not in
+ * the list). It is not at all obvious that the preference value is wrong.
+ * This case can happen two scenarios:
+ * 1) The dictionary that was selected in the preference is removed.
+ * 2) The selected dictionary changes the way it announces itself to the system,
+ * so for example "it_IT" changes to "it-IT" and the previously stored
+ * preference value doesn't apply any more.
+ *
+ * @param {string[]|null} [draftLanguages] - Languages that the message was
+ * composed in.
+ * @returns {string[]}
+ */
+function getValidSpellcheckerDictionaries(draftLanguages) {
+ let prefValue = Services.prefs.getCharPref("spellchecker.dictionary");
+ let spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+ let dictionaries = Array.from(new Set(prefValue?.split(",")));
+
+ let dictList = spellChecker.getDictionaryList();
+ let count = dictList.length;
+
+ if (count == 0) {
+ // If there are no dictionaries, we can't check the value, so return it.
+ return dictionaries;
+ }
+
+ // Make sure that the draft language contains a valid value.
+ if (
+ draftLanguages &&
+ draftLanguages.every(language => dictList.includes(language))
+ ) {
+ return draftLanguages;
+ }
+
+ // Make sure preference contains a valid value.
+ if (dictionaries.every(language => dictList.includes(language))) {
+ return dictionaries;
+ }
+
+ // Set a valid value, any value will do.
+ Services.prefs.setCharPref("spellchecker.dictionary", dictList[0]);
+ return [dictList[0]];
+}
+
+var dictionaryRemovalObserver = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "spellcheck-dictionary-remove") {
+ return;
+ }
+ let spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+
+ let dictList = spellChecker.getDictionaryList();
+ let languages = Array.from(gActiveDictionaries);
+ languages = languages.filter(lang => dictList.includes(lang));
+ if (languages.length === 0) {
+ // Set a valid language from the preference.
+ let prefValue = Services.prefs.getCharPref("spellchecker.dictionary");
+ let prefLanguages = prefValue?.split(",") ?? [];
+ languages = prefLanguages.filter(lang => dictList.includes(lang));
+ if (prefLanguages.length != languages.length && languages.length > 0) {
+ // Fix the preference while we're here. We know it's invalid.
+ Services.prefs.setCharPref(
+ "spellchecker.dictionary",
+ languages.join(",")
+ );
+ }
+ }
+ // Only update the language if we will still be left with any active choice.
+ if (languages.length > 0) {
+ ComposeChangeLanguage(languages);
+ }
+ },
+
+ isAdded: false,
+
+ addObserver() {
+ Services.obs.addObserver(this, "spellcheck-dictionary-remove");
+ this.isAdded = true;
+ },
+
+ removeObserver() {
+ if (this.isAdded) {
+ Services.obs.removeObserver(this, "spellcheck-dictionary-remove");
+ this.isAdded = false;
+ }
+ },
+};
+
+function EditorClick(event) {
+ if (event.target.matches(".remove-card")) {
+ let card = event.target.closest(".moz-card");
+ let url = card.querySelector(".url").href;
+ if (card.matches(".url-replaced")) {
+ card.replaceWith(url);
+ } else {
+ card.remove();
+ }
+ } else if (event.target.matches(`.add-card[data-opened='${gOpened}']`)) {
+ let url = event.target.getAttribute("data-url");
+ let meRect = document.getElementById("messageEditor").getClientRects()[0];
+ let settings = document.getElementById("linkPreviewSettings");
+ let settingsW = 500;
+ settings.style.position = "fixed";
+ settings.style.left =
+ Math.max(settingsW + 20, event.clientX) - settingsW + "px";
+ settings.style.top = meRect.top + event.clientY + 20 + "px";
+ settings.hidden = false;
+ event.target.remove();
+ settings.querySelector(".close").onclick = event => {
+ settings.hidden = true;
+ };
+ settings.querySelector(".preview-replace").onclick = event => {
+ addLinkPreview(url, true);
+ settings.hidden = true;
+ };
+ settings.querySelector(".preview-autoadd").onclick = event => {
+ Services.prefs.setBoolPref(
+ "mail.compose.add_link_preview",
+ event.target.checked
+ );
+ };
+ settings.querySelector(".preview-replace").focus();
+ settings.onkeydown = event => {
+ if (event.key == "Escape") {
+ settings.hidden = true;
+ }
+ };
+ }
+}
+
+/**
+ * Grab Open Graph or Twitter card data from the URL and insert a link preview
+ * into the editor. If no proper data could be found, nothing is inserted.
+ *
+ * @param {string} url - The URL to add preview for.
+ */
+async function addLinkPreview(url) {
+ return fetch(url)
+ .then(response => response.text())
+ .then(text => {
+ let doc = new DOMParser().parseFromString(text, "text/html");
+
+ // If the url has an Open Graph or Twitter card, create a nicer
+ // representation and use that instead.
+ // @see https://ogp.me/
+ // @see https://developer.twitter.com/en/docs/twitter-for-websites/cards/
+ // Also look for standard meta information as a fallback.
+
+ let title =
+ doc
+ .querySelector("meta[property='og:title'],meta[name='twitter:title']")
+ ?.getAttribute("content") ||
+ doc.querySelector("title")?.textContent.trim();
+ let description = doc
+ .querySelector(
+ "meta[property='og:description'],meta[name='twitter:description'],meta[name='description']"
+ )
+ ?.getAttribute("content");
+
+ // Handle the case where we didn't get proper data.
+ if (!title && !description) {
+ console.debug(`No link preview data for url=${url}`);
+ return;
+ }
+
+ let image = doc
+ .querySelector("meta[property='og:image']")
+ ?.getAttribute("content");
+ let alt =
+ doc
+ .querySelector("meta[property='og:image:alt']")
+ ?.getAttribute("content") || "";
+ if (!image) {
+ image = doc
+ .querySelector("meta[name='twitter:image']")
+ ?.getAttribute("content");
+ alt =
+ doc
+ .querySelector("meta[name='twitter:image:alt']")
+ ?.getAttribute("content") || "";
+ }
+ let imgIsTouchIcon = false;
+ if (!image) {
+ image = doc
+ .querySelector(
+ `link[rel='icon']:is(
+ [sizes~='any'],
+ [sizes~='196x196' i],
+ [sizes~='192x192' i]
+ [sizes~='180x180' i],
+ [sizes~='128x128' i]
+ )`
+ )
+ ?.getAttribute("href");
+ alt = "";
+ imgIsTouchIcon = Boolean(image);
+ }
+
+ // Grab our template and fill in the variables.
+ let card = document
+ .getElementById("dataCardTemplate")
+ .content.cloneNode(true).firstElementChild;
+ card.id = "card-" + Date.now();
+ card.querySelector("img").src = image;
+ card.querySelector("img").alt = alt;
+ card.querySelector(".title").textContent = title;
+
+ card.querySelector(".description").textContent = description;
+ card.querySelector(".url").textContent = "🔗 " + url;
+ card.querySelector(".url").href = url;
+ card.querySelector(".url").title = new URL(url).hostname;
+ card.querySelector(".site").textContent = new URL(url).hostname;
+
+ // twitter:card "summary" = Summary Card
+ // twitter:card "summary_large_image" = Summary Card with Large Image
+ if (
+ !imgIsTouchIcon &&
+ (doc.querySelector(
+ "meta[name='twitter:card'][content='summary_large_image']"
+ ) ||
+ doc
+ .querySelector("meta[property='og:image:width']")
+ ?.getAttribute("content") >= 600)
+ ) {
+ card.querySelector("img").style.width = "600px";
+ }
+
+ if (!image) {
+ card.querySelector(".card-pic").remove();
+ }
+
+ // If subject is empty, set that as well.
+ let subject = document.getElementById("msgSubject");
+ if (!subject.value && title) {
+ subject.value = title;
+ }
+
+ // Select the inserted URL so that if the preview is found one can
+ // use undo to remove it and only use the URL instead.
+ // Only do it if there was no typing after the url.
+ let selection = getBrowser().contentDocument.getSelection();
+ let n = selection.focusNode;
+ if (n.textContent.endsWith(url)) {
+ selection.extend(n, n.textContent.lastIndexOf(url));
+ card.classList.add("url-replaced");
+ }
+
+ // Add a line after the card. Otherwise it's hard to continue writing.
+ let line = GetCurrentEditor().returnInParagraphCreatesNewParagraph
+ ? "<p>&#160;</p>"
+ : "<br />";
+ card.classList.add("loading"); // Used for fade-in effect.
+ getBrowser().contentDocument.execCommand(
+ "insertHTML",
+ false,
+ card.outerHTML + line
+ );
+ let cardInDoc = getBrowser().contentDocument.getElementById(card.id);
+ cardInDoc.classList.remove("loading");
+ });
+}
+
+/**
+ * On paste or drop, we may want to modify the content before inserting it into
+ * the editor, replacing file URLs with data URLs when appropriate.
+ */
+function onPasteOrDrop(e) {
+ if (!gMsgCompose.composeHTML) {
+ // We're in the plain text editor. Nothing to do here.
+ return;
+ }
+ gMsgCompose.allowRemoteContent = true;
+
+ // For paste use e.clipboardData, for drop use e.dataTransfer.
+ let dataTransfer = "clipboardData" in e ? e.clipboardData : e.dataTransfer;
+ if (
+ Services.prefs.getBoolPref("mail.compose.add_link_preview", false) &&
+ !Services.io.offline &&
+ !dataTransfer.types.includes("text/html")
+ ) {
+ let type = dataTransfer.types.find(t =>
+ ["text/uri-list", "text/x-moz-url", "text/plain"].includes(t)
+ );
+ if (type) {
+ let url = dataTransfer.getData(type).split("\n")[0].trim();
+ if (/^https?:\/\/\S+$/.test(url)) {
+ e.preventDefault(); // We'll handle the pasting manually.
+ getBrowser().contentDocument.execCommand("insertHTML", false, url);
+ addLinkPreview(url);
+ return;
+ }
+ }
+ }
+
+ if (!dataTransfer.types.includes("text/html")) {
+ return;
+ }
+
+ // Ok, we have html content to paste.
+ let html = dataTransfer.getData("text/html");
+ let doc = new DOMParser().parseFromString(html, "text/html");
+ let tmpD = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ let pendingConversions = 0;
+ let needToPreventDefault = true;
+ for (let img of doc.images) {
+ if (!/^file:/i.test(img.src)) {
+ // Doesn't start with file:. Nothing to do here.
+ continue;
+ }
+
+ // This may throw if the URL is invalid for the OS.
+ let nsFile;
+ try {
+ nsFile = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getFileFromURLSpec(img.src);
+ } catch (ex) {
+ continue;
+ }
+
+ if (!nsFile.exists()) {
+ continue;
+ }
+
+ if (!tmpD.contains(nsFile)) {
+ // Not anywhere under the temp dir.
+ continue;
+ }
+
+ let contentType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromFile(nsFile);
+ if (!contentType.startsWith("image/")) {
+ continue;
+ }
+
+ // If we ever get here, we need to prevent the default paste or drop since
+ // the code below will do its own insertion.
+ if (needToPreventDefault) {
+ e.preventDefault();
+ needToPreventDefault = false;
+ }
+
+ File.createFromNsIFile(nsFile).then(function (file) {
+ if (file.lastModified < Date.now() - 60000) {
+ // Not put in temp in the last minute. May be something other than
+ // a copy-paste. Let's not allow that.
+ return;
+ }
+
+ let doTheInsert = function () {
+ // Now run it through sanitation to make sure there wasn't any
+ // unwanted things in the content.
+ let ParserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+ );
+ let html2 = ParserUtils.sanitize(
+ doc.documentElement.innerHTML,
+ ParserUtils.SanitizerAllowStyle
+ );
+ getBrowser().contentDocument.execCommand("insertHTML", false, html2);
+ };
+
+ // Everything checks out. Convert file to data URL.
+ let reader = new FileReader();
+ reader.addEventListener("load", function () {
+ let dataURL = reader.result;
+ pendingConversions--;
+ img.src = dataURL;
+ if (pendingConversions == 0) {
+ doTheInsert();
+ }
+ });
+ reader.addEventListener("error", function () {
+ pendingConversions--;
+ if (pendingConversions == 0) {
+ doTheInsert();
+ }
+ });
+
+ pendingConversions++;
+ reader.readAsDataURL(file);
+ });
+ }
+}
+
+/* eslint-disable complexity */
+async function ComposeStartup() {
+ // Findbar overlay
+ if (!document.getElementById("findbar-replaceButton")) {
+ let replaceButton = document.createXULElement("toolbarbutton");
+ replaceButton.setAttribute("id", "findbar-replaceButton");
+ replaceButton.setAttribute("class", "toolbarbutton-1 tabbable");
+ replaceButton.setAttribute(
+ "label",
+ getComposeBundle().getString("replaceButton.label")
+ );
+ replaceButton.setAttribute(
+ "accesskey",
+ getComposeBundle().getString("replaceButton.accesskey")
+ );
+ replaceButton.setAttribute(
+ "tooltiptext",
+ getComposeBundle().getString("replaceButton.tooltip")
+ );
+ replaceButton.setAttribute("oncommand", "findbarFindReplace();");
+
+ let findbar = document.getElementById("FindToolbar");
+ let lastButton = findbar.getElement("find-entire-word");
+ let tSeparator = document.createXULElement("toolbarseparator");
+ tSeparator.setAttribute("id", "findbar-beforeReplaceSeparator");
+ lastButton.parentNode.insertBefore(
+ replaceButton,
+ lastButton.nextElementSibling
+ );
+ lastButton.parentNode.insertBefore(
+ tSeparator,
+ lastButton.nextElementSibling
+ );
+ }
+
+ var params = null; // New way to pass parameters to the compose window as a nsIMsgComposeParameters object
+ var args = null; // old way, parameters are passed as a string
+ gBodyFromArgs = false;
+
+ if (window.arguments && window.arguments[0]) {
+ try {
+ if (window.arguments[0] instanceof Ci.nsIMsgComposeParams) {
+ params = window.arguments[0];
+ gBodyFromArgs = params.composeFields && params.composeFields.body;
+ } else {
+ params = handleMailtoArgs(window.arguments[0]);
+ }
+ } catch (ex) {
+ dump("ERROR with parameters: " + ex + "\n");
+ }
+
+ // if still no dice, try and see if the params is an old fashioned list of string attributes
+ // XXX can we get rid of this yet?
+ if (!params) {
+ args = GetArgs(window.arguments[0]);
+ }
+ }
+
+ // Set a sane starting width/height for all resolutions on new profiles.
+ // Do this before the window loads.
+ if (!document.documentElement.hasAttribute("width")) {
+ // Prefer 860x800.
+ let defaultHeight = Math.min(screen.availHeight, 800);
+ let defaultWidth = Math.min(screen.availWidth, 860);
+
+ // On small screens, default to maximized state.
+ if (defaultHeight <= 600) {
+ document.documentElement.setAttribute("sizemode", "maximized");
+ }
+
+ document.documentElement.setAttribute("width", defaultWidth);
+ document.documentElement.setAttribute("height", defaultHeight);
+ // Make sure we're safe at the left/top edge of screen
+ document.documentElement.setAttribute("screenX", screen.availLeft);
+ document.documentElement.setAttribute("screenY", screen.availTop);
+ }
+
+ // Observe dictionary removals.
+ dictionaryRemovalObserver.addObserver();
+
+ let messageEditor = document.getElementById("messageEditor");
+ messageEditor.addEventListener("paste", onPasteOrDrop);
+ messageEditor.addEventListener("drop", onPasteOrDrop);
+
+ let identityList = document.getElementById("msgIdentity");
+ if (identityList) {
+ FillIdentityList(identityList);
+ }
+
+ if (!params) {
+ // This code will go away soon as now arguments are passed to the window using a object of type nsMsgComposeParams instead of a string
+
+ params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(
+ Ci.nsIMsgComposeParams
+ );
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (args) {
+ // Convert old fashion arguments into params
+ var composeFields = params.composeFields;
+ if (args.bodyislink == "true") {
+ params.bodyIsLink = true;
+ }
+ if (args.type) {
+ params.type = args.type;
+ }
+ if (args.format) {
+ // Only use valid values.
+ if (
+ args.format == Ci.nsIMsgCompFormat.PlainText ||
+ args.format == Ci.nsIMsgCompFormat.HTML ||
+ args.format == Ci.nsIMsgCompFormat.OppositeOfDefault
+ ) {
+ params.format = args.format;
+ } else if (args.format.toLowerCase().trim() == "html") {
+ params.format = Ci.nsIMsgCompFormat.HTML;
+ } else if (args.format.toLowerCase().trim() == "text") {
+ params.format = Ci.nsIMsgCompFormat.PlainText;
+ }
+ }
+ if (args.originalMsgURI) {
+ params.originalMsgURI = args.originalMsgURI;
+ }
+ if (args.preselectid) {
+ params.identity = MailServices.accounts.getIdentity(args.preselectid);
+ }
+ if (args.from) {
+ composeFields.from = args.from;
+ }
+ if (args.to) {
+ composeFields.to = args.to;
+ }
+ if (args.cc) {
+ composeFields.cc = args.cc;
+ }
+ if (args.bcc) {
+ composeFields.bcc = args.bcc;
+ }
+ if (args.newsgroups) {
+ composeFields.newsgroups = args.newsgroups;
+ }
+ if (args.subject) {
+ composeFields.subject = args.subject;
+ }
+ if (args.attachment && window.arguments[1] instanceof Ci.nsICommandLine) {
+ let attachmentList = args.attachment.split(",");
+ for (let attachmentName of attachmentList) {
+ // resolveURI does all the magic around working out what the
+ // attachment is, including web pages, and generating the correct uri.
+ let uri = window.arguments[1].resolveURI(attachmentName);
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ // If uri is for a file and it exists set the attachment size.
+ if (uri instanceof Ci.nsIFileURL) {
+ if (uri.file.exists()) {
+ attachment.size = uri.file.fileSize;
+ } else {
+ attachment = null;
+ }
+ }
+
+ // Only want to attach if a file that exists or it is not a file.
+ if (attachment) {
+ attachment.url = uri.spec;
+ composeFields.addAttachment(attachment);
+ } else {
+ let title = getComposeBundle().getString("errorFileAttachTitle");
+ let msg = getComposeBundle().getFormattedString(
+ "errorFileAttachMessage",
+ [attachmentName]
+ );
+ Services.prompt.alert(null, title, msg);
+ }
+ }
+ }
+ if (args.newshost) {
+ composeFields.newshost = args.newshost;
+ }
+ if (args.message) {
+ let msgFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ if (PathUtils.parent(args.message) == ".") {
+ let workingDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ args.message = PathUtils.join(
+ workingDir.path,
+ PathUtils.filename(args.message)
+ );
+ }
+ msgFile.initWithPath(args.message);
+
+ if (!msgFile.exists()) {
+ let title = getComposeBundle().getString("errorFileMessageTitle");
+ let msg = getComposeBundle().getFormattedString(
+ "errorFileMessageMessage",
+ [args.message]
+ );
+ Services.prompt.alert(null, title, msg);
+ } else {
+ let data = "";
+ let fstream = null;
+ let cstream = null;
+
+ try {
+ fstream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ cstream = Cc[
+ "@mozilla.org/intl/converter-input-stream;1"
+ ].createInstance(Ci.nsIConverterInputStream);
+ fstream.init(msgFile, -1, 0, 0); // Open file in default/read-only mode.
+ cstream.init(fstream, "UTF-8", 0, 0);
+
+ let str = {};
+ let read = 0;
+
+ do {
+ // Read as much as we can and put it in str.value.
+ read = cstream.readString(0xffffffff, str);
+ data += str.value;
+ } while (read != 0);
+ } catch (e) {
+ let title = getComposeBundle().getString("errorFileMessageTitle");
+ let msg = getComposeBundle().getFormattedString(
+ "errorLoadFileMessageMessage",
+ [args.message]
+ );
+ Services.prompt.alert(null, title, msg);
+ } finally {
+ if (cstream) {
+ cstream.close();
+ }
+ if (fstream) {
+ fstream.close();
+ }
+ }
+
+ if (data) {
+ let pos = data.search(/\S/); // Find first non-whitespace character.
+
+ if (
+ params.format != Ci.nsIMsgCompFormat.PlainText &&
+ (args.message.endsWith(".htm") ||
+ args.message.endsWith(".html") ||
+ data.substr(pos, 14).toLowerCase() == "<!doctype html" ||
+ data.substr(pos, 5).toLowerCase() == "<html")
+ ) {
+ // We replace line breaks because otherwise they'll be converted to
+ // <br> in nsMsgCompose::BuildBodyMessageAndSignature().
+ // Don't do the conversion if the user asked explicitly for plain text.
+ data = data.replace(/\r?\n/g, " ");
+ }
+ gBodyFromArgs = true;
+ composeFields.body = data;
+ }
+ }
+ } else if (args.body) {
+ gBodyFromArgs = true;
+ composeFields.body = args.body;
+ }
+ }
+ }
+
+ gComposeType = params.type;
+
+ // Detect correct identity when missing or mismatched. An identity with no
+ // email is likely not valid.
+ // When editing a draft, 'params.identity' is pre-populated with the identity
+ // that created the draft or the identity owning the draft folder for a
+ // "foreign" draft, see ComposeMessage() in mailCommands.js. We don't want the
+ // latter so use the creator identity which could be null.
+ // Only do this detection for drafts and templates.
+ // Redirect will have from set as the original sender and we don't want to
+ // warn about that.
+ if (
+ gComposeType == Ci.nsIMsgCompType.Draft ||
+ gComposeType == Ci.nsIMsgCompType.Template
+ ) {
+ let creatorKey = params.composeFields.creatorIdentityKey;
+ params.identity = creatorKey
+ ? MailServices.accounts.getIdentity(creatorKey)
+ : null;
+ }
+
+ let from = null;
+ // Get the from address from the headers. For Redirect, from is set to
+ // the original author, so don't look at it here.
+ if (params.composeFields.from && gComposeType != Ci.nsIMsgCompType.Redirect) {
+ let fromAddrs = MailServices.headerParser.parseEncodedHeader(
+ params.composeFields.from,
+ null
+ );
+ if (fromAddrs.length) {
+ from = fromAddrs[0].email.toLowerCase();
+ }
+ }
+
+ if (
+ !params.identity ||
+ !params.identity.email ||
+ (from && !emailSimilar(from, params.identity.email))
+ ) {
+ let identities = MailServices.accounts.allIdentities;
+ let suitableCount = 0;
+
+ // Search for a matching identity.
+ if (from) {
+ for (let ident of identities) {
+ if (ident.email && from == ident.email.toLowerCase()) {
+ if (suitableCount == 0) {
+ params.identity = ident;
+ }
+ suitableCount++;
+ if (suitableCount > 1) {
+ // No need to find more, it's already not unique.
+ break;
+ }
+ }
+ }
+ }
+
+ if (!params.identity || !params.identity.email) {
+ let identity = null;
+ // No preset identity and no match, so use the default account.
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ identity = defaultAccount.defaultIdentity;
+ }
+ if (!identity) {
+ // Get the first identity we have in the list.
+ let identitykey = identityList
+ .getItemAtIndex(0)
+ .getAttribute("identitykey");
+ identity = MailServices.accounts.getIdentity(identitykey);
+ }
+ params.identity = identity;
+ }
+
+ // Warn if no or more than one match was found.
+ // But don't warn for +suffix additions (a+b@c.com).
+ if (
+ from &&
+ (suitableCount > 1 ||
+ (suitableCount == 0 && !emailSimilar(from, params.identity.email)))
+ ) {
+ gComposeNotificationBar.setIdentityWarning(params.identity.identityName);
+ }
+ }
+
+ if (params.identity) {
+ identityList.selectedItem = identityList.getElementsByAttribute(
+ "identitykey",
+ params.identity.key
+ )[0];
+ }
+
+ // Here we set the From from the original message, be it a draft or another
+ // message, for example a template, we want to "edit as new".
+ // Only do this if the message is our own draft or template or any type of reply.
+ if (
+ params.composeFields.from &&
+ (params.composeFields.creatorIdentityKey ||
+ gComposeType == Ci.nsIMsgCompType.Reply ||
+ gComposeType == Ci.nsIMsgCompType.ReplyAll ||
+ gComposeType == Ci.nsIMsgCompType.ReplyToSender ||
+ gComposeType == Ci.nsIMsgCompType.ReplyToGroup ||
+ gComposeType == Ci.nsIMsgCompType.ReplyToSenderAndGroup ||
+ gComposeType == Ci.nsIMsgCompType.ReplyToList)
+ ) {
+ let from = MailServices.headerParser
+ .parseEncodedHeader(params.composeFields.from, null)
+ .join(", ");
+ if (from != identityList.value) {
+ MakeFromFieldEditable(true);
+ identityList.value = from;
+ }
+ }
+ LoadIdentity(true);
+
+ // Get the <editor> element to startup an editor
+ var editorElement = GetCurrentEditorElement();
+
+ // Remember the original message URI. When editing a draft which is a reply
+ // or forwarded message, this gets overwritten by the ancestor's message URI so
+ // the disposition flags ("replied" or "forwarded") can be set on the ancestor.
+ // For our purposes we need the URI of the message being processed, not its
+ // original ancestor.
+ gOriginalMsgURI = params.originalMsgURI;
+ gMsgCompose = MailServices.compose.initCompose(
+ params,
+ window,
+ editorElement.docShell
+ );
+
+ // If a message is a draft, we rely on draft status flags to decide
+ // about encryption setting. Don't set gIsRelatedToEncryptedOriginal
+ // simply because a message was saved as an encrypted draft, because
+ // we save draft messages encrypted as soon as the account is able
+ // to encrypt, regardless of the user's desire for encryption for
+ // this message.
+
+ if (
+ gComposeType != Ci.nsIMsgCompType.Draft &&
+ gComposeType != Ci.nsIMsgCompType.Template &&
+ gEncryptedURIService &&
+ gEncryptedURIService.isEncrypted(gMsgCompose.originalMsgURI)
+ ) {
+ gIsRelatedToEncryptedOriginal = true;
+ }
+
+ gMsgCompose.addMsgSendListener(gSendListener);
+
+ document
+ .getElementById("dsnMenu")
+ .setAttribute("checked", gMsgCompose.compFields.DSN);
+ document
+ .getElementById("cmd_attachVCard")
+ .setAttribute("checked", gMsgCompose.compFields.attachVCard);
+ document
+ .getElementById("cmd_attachPublicKey")
+ .setAttribute("checked", gAttachMyPublicPGPKey);
+ toggleAttachmentReminder(gMsgCompose.compFields.attachmentReminder);
+ initSendFormatMenu();
+
+ let editortype = gMsgCompose.composeHTML ? "htmlmail" : "textmail";
+ editorElement.makeEditable(editortype, true);
+
+ // setEditorType MUST be called before setContentWindow
+ if (gMsgCompose.composeHTML) {
+ initLocalFontFaceMenu(document.getElementById("FontFacePopup"));
+ } else {
+ // We are editing in plain text mode, so hide the formatting menus and the
+ // output format selector.
+ document.getElementById("FormatToolbar").hidden = true;
+ document.getElementById("formatMenu").hidden = true;
+ document.getElementById("insertMenu").hidden = true;
+ document.getElementById("menu_showFormatToolbar").hidden = true;
+ document.getElementById("outputFormatMenu").hidden = true;
+ }
+
+ // Do setup common to Message Composer and Web Composer.
+ EditorSharedStartup();
+ ToggleReturnReceipt(gMsgCompose.compFields.returnReceipt);
+
+ if (params.bodyIsLink) {
+ let body = gMsgCompose.compFields.body;
+ if (gMsgCompose.composeHTML) {
+ let cleanBody;
+ try {
+ cleanBody = decodeURI(body);
+ } catch (e) {
+ cleanBody = body;
+ }
+
+ body = body.replace(/&/g, "&amp;");
+ gMsgCompose.compFields.body =
+ '<br /><a href="' + body + '">' + cleanBody + "</a><br />";
+ } else {
+ gMsgCompose.compFields.body = "\n<" + body + ">\n";
+ }
+ }
+
+ document.getElementById("msgSubject").value = gMsgCompose.compFields.subject;
+
+ // Do not await async calls before registering the stateListener, otherwise it
+ // will miss states.
+ gMsgCompose.RegisterStateListener(stateListener);
+
+ let addedAttachmentItems = await AddAttachments(
+ gMsgCompose.compFields.attachments,
+ false
+ );
+ // If any of the pre-loaded attachments is a cloudFile, this is most probably a
+ // re-opened draft. Restore the cloudFile information.
+ for (let attachmentItem of addedAttachmentItems) {
+ if (
+ attachmentItem.attachment.sendViaCloud &&
+ attachmentItem.attachment.contentLocation &&
+ attachmentItem.attachment.cloudFileAccountKey &&
+ attachmentItem.attachment.cloudPartHeaderData
+ ) {
+ let byteString = atob(attachmentItem.attachment.cloudPartHeaderData);
+ let uploadFromDraft = JSON.parse(
+ MailStringUtils.byteStringToString(byteString)
+ );
+ if (uploadFromDraft && uploadFromDraft.path && uploadFromDraft.name) {
+ let cloudFileUpload;
+ let cloudFileAccount = cloudFileAccounts.getAccount(
+ attachmentItem.attachment.cloudFileAccountKey
+ );
+ let bigFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ bigFile.initWithPath(uploadFromDraft.path);
+
+ if (cloudFileAccount) {
+ // Try to find the upload for the draft attachment in the already known
+ // uploads.
+ cloudFileUpload = cloudFileAccount
+ .getPreviousUploads()
+ .find(
+ upload =>
+ upload.url == attachmentItem.attachment.contentLocation &&
+ upload.url == uploadFromDraft.url &&
+ upload.id == uploadFromDraft.id &&
+ upload.name == uploadFromDraft.name &&
+ upload.size == uploadFromDraft.size &&
+ upload.path == uploadFromDraft.path &&
+ upload.serviceName == uploadFromDraft.serviceName &&
+ upload.serviceIcon == uploadFromDraft.serviceIcon &&
+ upload.serviceUrl == uploadFromDraft.serviceUrl &&
+ upload.downloadPasswordProtected ==
+ uploadFromDraft.downloadPasswordProtected &&
+ upload.downloadLimit == uploadFromDraft.downloadLimit &&
+ upload.downloadExpiryDate == uploadFromDraft.downloadExpiryDate
+ );
+ if (!cloudFileUpload) {
+ // Create a new upload from the data stored in the draft.
+ cloudFileUpload = cloudFileAccount.newUploadForFile(
+ bigFile,
+ uploadFromDraft
+ );
+ }
+ // A restored cloudFile may have been send/used already in a previous
+ // session, or may be changed and reverted again by not saving a draft.
+ // Mark it as immutable.
+ cloudFileAccount.markAsImmutable(cloudFileUpload.id);
+ attachmentItem.cloudFileAccount = cloudFileAccount;
+ attachmentItem.cloudFileUpload = cloudFileUpload;
+ } else {
+ attachmentItem.cloudFileUpload = uploadFromDraft;
+ delete attachmentItem.cloudFileUpload.id;
+ }
+
+ // Restore file information from the linked real file.
+ attachmentItem.attachment.name = uploadFromDraft.name;
+ attachmentItem.attachment.size = uploadFromDraft.size;
+ let bigAttachment;
+ if (bigFile.exists()) {
+ bigAttachment = FileToAttachment(bigFile);
+ }
+ if (bigAttachment && bigAttachment.size == uploadFromDraft.size) {
+ // Remove the temporary html placeholder file.
+ let uri = Services.io
+ .newURI(attachmentItem.attachment.url)
+ .QueryInterface(Ci.nsIFileURL);
+ await IOUtils.remove(uri.file.path);
+
+ attachmentItem.attachment.url = bigAttachment.url;
+ attachmentItem.attachment.contentType = "";
+ attachmentItem.attachment.temporary = false;
+ }
+
+ await updateAttachmentItemProperties(attachmentItem);
+ continue;
+ }
+ }
+ // Did not find the required data in the draft to reconstruct the cloudFile
+ // information. Fall back to no-draft-restore-support.
+ attachmentItem.attachment.sendViaCloud = false;
+ }
+
+ if (Services.prefs.getBoolPref("mail.compose.show_attachment_pane")) {
+ toggleAttachmentPane("show");
+ }
+
+ // Fill custom headers.
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+ for (let i = 0; i < otherHeaders.length; i++) {
+ if (gMsgCompose.compFields.otherHeaders[i]) {
+ let row = document.getElementById(`addressRow${otherHeaders[i]}`);
+ addressRowSetVisibility(row, true);
+ let input = document.getElementById(`${otherHeaders[i]}AddrInput`);
+ input.value = gMsgCompose.compFields.otherHeaders[i];
+ }
+ }
+
+ document
+ .getElementById("msgcomposeWindow")
+ .dispatchEvent(
+ new Event("compose-window-init", { bubbles: false, cancelable: true })
+ );
+
+ dispatchAttachmentBucketEvent(
+ "attachments-added",
+ gMsgCompose.compFields.attachments
+ );
+
+ // Add an observer to be called when document is done loading,
+ // which creates the editor.
+ try {
+ GetCurrentCommandManager().addCommandObserver(
+ gMsgEditorCreationObserver,
+ "obs_documentCreated"
+ );
+
+ // Load empty page to create the editor. The "?compose" is there so this
+ // URL does not exactly match "about:blank", which has some drawbacks. In
+ // particular it prevents WebExtension content scripts from running in
+ // this document.
+ let loadURIOptions = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ };
+ editorElement.webNavigation.loadURI(
+ Services.io.newURI("about:blank?compose"),
+ loadURIOptions
+ );
+ } catch (e) {
+ console.error(e);
+ }
+
+ gEditingDraft = gMsgCompose.compFields.draftId;
+
+ // Set up contacts sidebar.
+ let pageURL = document.URL;
+ let contactsSplitter = document.getElementById("contactsSplitter");
+ let contactsShown = Services.xulStore.getValue(
+ pageURL,
+ "contactsSplitter",
+ "shown"
+ );
+ let contactsWidth = Services.xulStore.getValue(
+ pageURL,
+ "contactsSplitter",
+ "width"
+ );
+ contactsSplitter.width =
+ contactsWidth == "" ? null : parseFloat(contactsWidth);
+ setContactsSidebarVisibility(contactsShown == "true", false);
+ contactsSplitter.addEventListener("splitter-resized", () => {
+ let width = contactsSplitter.width;
+ Services.xulStore.setValue(
+ pageURL,
+ "contactsSplitter",
+ "width",
+ width == null ? "" : String(width)
+ );
+ });
+ contactsSplitter.addEventListener("splitter-collapsed", () => {
+ Services.xulStore.setValue(pageURL, "contactsSplitter", "shown", "false");
+ });
+ contactsSplitter.addEventListener("splitter-expanded", () => {
+ Services.xulStore.setValue(pageURL, "contactsSplitter", "shown", "true");
+ });
+
+ // Update the priority button.
+ if (gMsgCompose.compFields.priority) {
+ updatePriorityToolbarButton(gMsgCompose.compFields.priority);
+ }
+
+ gAutoSaveInterval = Services.prefs.getBoolPref("mail.compose.autosave")
+ ? Services.prefs.getIntPref("mail.compose.autosaveinterval") * 60000
+ : 0;
+
+ if (gAutoSaveInterval) {
+ gAutoSaveTimeout = setTimeout(AutoSave, gAutoSaveInterval);
+ }
+
+ gAutoSaveKickedIn = false;
+}
+/* eslint-enable complexity */
+
+function splitEmailAddress(aEmail) {
+ let at = aEmail.lastIndexOf("@");
+ return at != -1 ? [aEmail.slice(0, at), aEmail.slice(at + 1)] : [aEmail, ""];
+}
+
+// Emails are equal ignoring +suffixes (email+suffix@example.com).
+function emailSimilar(a, b) {
+ if (!a || !b) {
+ return a == b;
+ }
+ a = splitEmailAddress(a.toLowerCase());
+ b = splitEmailAddress(b.toLowerCase());
+ return a[1] == b[1] && a[0].split("+", 1)[0] == b[0].split("+", 1)[0];
+}
+
+// The new, nice, simple way of getting notified when a new editor has been created
+var gMsgEditorCreationObserver = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "obs_documentCreated") {
+ var editor = GetCurrentEditor();
+ if (editor && GetCurrentCommandManager() == aSubject) {
+ InitEditor();
+ }
+ // Now that we know this document is an editor, update commands now if
+ // the document has focus, or next time it receives focus via
+ // CommandUpdate_MsgCompose()
+ if (gLastWindowToHaveFocus == document.commandDispatcher.focusedWindow) {
+ updateComposeItems();
+ } else {
+ gLastWindowToHaveFocus = null;
+ }
+ }
+ },
+};
+
+/**
+ * Adjust sign/encrypt settings after the identity was switched.
+ *
+ * @param {?nsIMsgIdentity} prevIdentity - The previously selected
+ * identity, when switching to a different identity.
+ * Null on initial identity setup.
+ */
+async function adjustEncryptAfterIdentityChange(prevIdentity) {
+ let identityHasConfiguredSMIME =
+ isSmimeSigningConfigured() || isSmimeEncryptionConfigured();
+
+ let identityHasConfiguredOpenPGP = isPgpConfigured();
+
+ // Show widgets based on the technologies available across all identities.
+ let allEmailIdentities = MailServices.accounts.allIdentities.filter(
+ i => i.email
+ );
+ let anyIdentityHasConfiguredOpenPGP = allEmailIdentities.some(i =>
+ i.getUnicharAttribute("openpgp_key_id")
+ );
+ let anyIdentityHasConfiguredSMIMEEncryption = allEmailIdentities.some(i =>
+ i.getUnicharAttribute("encryption_cert_name")
+ );
+
+ // Disable encryption widgets if this identity has no encryption configured.
+ // However, if encryption is currently enabled, we must keep it enabled,
+ // to allow the user to manually disable encryption (we don't disable
+ // encryption automatically, as the user might have seen that it is
+ // enabled and might rely on it).
+ let e2eeConfigured =
+ identityHasConfiguredOpenPGP || identityHasConfiguredSMIME;
+
+ let autoEnablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_enable",
+ false
+ );
+
+ // If neither OpenPGP nor SMIME are configured for any identity,
+ // then hide the entire menu.
+ let encOpt = document.getElementById("button-encryption-options");
+ if (encOpt) {
+ encOpt.hidden =
+ !anyIdentityHasConfiguredOpenPGP &&
+ !anyIdentityHasConfiguredSMIMEEncryption;
+ encOpt.disabled = !e2eeConfigured && !gSendEncrypted;
+ document.getElementById("encTech_OpenPGP_Toolbar").disabled =
+ !identityHasConfiguredOpenPGP;
+ document.getElementById("encTech_SMIME_Toolbar").disabled =
+ !identityHasConfiguredSMIME;
+ }
+ document.getElementById("encryptionMenu").hidden =
+ !anyIdentityHasConfiguredOpenPGP &&
+ !anyIdentityHasConfiguredSMIMEEncryption;
+
+ // Show menu items only if both technologies are available.
+ document.getElementById("encTech_OpenPGP_Menubar").hidden =
+ !anyIdentityHasConfiguredOpenPGP ||
+ !anyIdentityHasConfiguredSMIMEEncryption;
+ document.getElementById("encTech_SMIME_Menubar").hidden =
+ !anyIdentityHasConfiguredOpenPGP ||
+ !anyIdentityHasConfiguredSMIMEEncryption;
+ document.getElementById("encryptionOptionsSeparator_Menubar").hidden =
+ !anyIdentityHasConfiguredOpenPGP ||
+ !anyIdentityHasConfiguredSMIMEEncryption;
+
+ let encToggle = document.getElementById("button-encryption");
+ if (encToggle) {
+ encToggle.disabled = !e2eeConfigured && !gSendEncrypted;
+ }
+ let sigToggle = document.getElementById("button-signing");
+ if (sigToggle) {
+ sigToggle.disabled = !e2eeConfigured;
+ }
+
+ document.getElementById("encryptionMenu").disabled =
+ !e2eeConfigured && !gSendEncrypted;
+
+ // Enable the encryption menus of the technologies that are configured for
+ // this identity.
+ document.getElementById("encTech_OpenPGP_Menubar").disabled =
+ !identityHasConfiguredOpenPGP;
+
+ document.getElementById("encTech_SMIME_Menubar").disabled =
+ !identityHasConfiguredSMIME;
+
+ if (!prevIdentity) {
+ // For identities without any e2ee setup, we want a good default
+ // technology selection. Avoid a technology that isn't configured
+ // anywhere.
+
+ if (identityHasConfiguredOpenPGP) {
+ gSelectedTechnologyIsPGP = true;
+ } else if (identityHasConfiguredSMIME) {
+ gSelectedTechnologyIsPGP = false;
+ } else {
+ gSelectedTechnologyIsPGP = anyIdentityHasConfiguredOpenPGP;
+ }
+
+ if (identityHasConfiguredOpenPGP) {
+ if (!identityHasConfiguredSMIME) {
+ gSelectedTechnologyIsPGP = true;
+ } else {
+ // both are configured
+ let techPref = gCurrentIdentity.getIntAttribute("e2etechpref");
+ gSelectedTechnologyIsPGP = techPref != 1;
+ }
+ }
+
+ gSendSigned = false;
+
+ if (autoEnablePref) {
+ gSendEncrypted = gIsRelatedToEncryptedOriginal;
+ } else {
+ gSendEncrypted =
+ gIsRelatedToEncryptedOriginal ||
+ ((identityHasConfiguredOpenPGP || identityHasConfiguredSMIME) &&
+ gCurrentIdentity.encryptionPolicy > 0);
+ }
+
+ await checkEncryptionState();
+ return;
+ }
+
+ // Not initialCall (switching from, or changed recipients)
+
+ // If the new identity has only one technology configured,
+ // which is different than the currently selected technology,
+ // then switch over to that other technology.
+ // However, if the new account doesn't have any technology
+ // configured, then it doesn't really matter, so let's keep what's
+ // currently selected for consistency (in case the user switches
+ // the identity again).
+ if (
+ gSelectedTechnologyIsPGP &&
+ !identityHasConfiguredOpenPGP &&
+ identityHasConfiguredSMIME
+ ) {
+ gSelectedTechnologyIsPGP = false;
+ } else if (
+ !gSelectedTechnologyIsPGP &&
+ !identityHasConfiguredSMIME &&
+ identityHasConfiguredOpenPGP
+ ) {
+ gSelectedTechnologyIsPGP = true;
+ }
+
+ if (
+ !autoEnablePref &&
+ !gSendEncrypted &&
+ !gUserTouchedEncryptSubject &&
+ prevIdentity.encryptionPolicy == 0 &&
+ gCurrentIdentity.encryptionPolicy > 0
+ ) {
+ gSendEncrypted = true;
+ }
+
+ await checkEncryptionState();
+}
+
+async function ComposeLoad() {
+ updateTroubleshootMenuItem();
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+
+ AddMessageComposeOfflineQuitObserver();
+
+ BondOpenPGP.init();
+
+ // Give the message header a minimum height based on its current height,
+ // before more recipient rows are revealed in #extraAddressRowsArea. This
+ // ensures that the area cannot be shrunk below its current height by the
+ // #headersSplitter.
+ // NOTE: At this stage, we only expect the "To" row to be visible within the
+ // recipients container.
+ let messageHeader = document.getElementById("MsgHeadersToolbar");
+ let recipientsContainer = document.getElementById("recipientsContainer");
+ // In the unlikely situation where the recipients container is already
+ // overflowing, we make sure to increase the minHeight by the overflow.
+ let headerHeight =
+ messageHeader.clientHeight +
+ recipientsContainer.scrollHeight -
+ recipientsContainer.clientHeight;
+ messageHeader.style.minHeight = `${headerHeight}px`;
+
+ // Setup the attachment bucket.
+ gAttachmentBucket = document.getElementById("attachmentBucket");
+
+ let attachmentArea = document.getElementById("attachmentArea");
+ attachmentArea.addEventListener("toggle", attachmentAreaOnToggle);
+
+ // Setup the attachment animation counter.
+ gAttachmentCounter = document.getElementById("newAttachmentIndicator");
+ gAttachmentCounter.addEventListener(
+ "animationend",
+ toggleAttachmentAnimation
+ );
+
+ // Set up the drag & drop event listeners.
+ let messageArea = document.getElementById("messageArea");
+ messageArea.addEventListener("dragover", event =>
+ envelopeDragObserver.onDragOver(event)
+ );
+ messageArea.addEventListener("dragleave", event =>
+ envelopeDragObserver.onDragLeave(event)
+ );
+ messageArea.addEventListener("drop", event =>
+ envelopeDragObserver.onDrop(event)
+ );
+
+ // Setup the attachment overlay animation listeners.
+ let overlay = document.getElementById("dropAttachmentOverlay");
+ overlay.addEventListener("animationend", e => {
+ // Make the overlay constantly visible If the user is dragging a file over
+ // the compose windown.
+ if (e.animationName == "showing-animation") {
+ // We don't remove the "showing" class here since the dragOver event will
+ // keep adding it and we would have a flashing effect.
+ overlay.classList.add("show");
+ return;
+ }
+
+ // Permanently hide the overlay after the hiding animation ended.
+ if (e.animationName == "hiding-animation") {
+ overlay.classList.remove("show", "hiding");
+ // Remove the hover class from the child items to reset the style.
+ document.getElementById("addInline").classList.remove("hover");
+ document.getElementById("addAsAttachment").classList.remove("hover");
+ }
+ });
+
+ if (otherHeaders) {
+ let extraAddressRowsMenu = document.getElementById("extraAddressRowsMenu");
+
+ let existingTypes = Array.from(
+ document.querySelectorAll(".address-row"),
+ row => row.dataset.recipienttype
+ );
+
+ for (let header of otherHeaders) {
+ if (existingTypes.includes(header)) {
+ continue;
+ }
+ existingTypes.push(header);
+
+ header = header.trim();
+ let recipient = {
+ rowId: `addressRow${header}`,
+ labelId: `${header}AddrLabel`,
+ containerId: `${header}AddrContainer`,
+ inputId: `${header}AddrInput`,
+ showRowMenuItemId: `${header}ShowAddressRowMenuItem`,
+ type: header,
+ };
+
+ let newEls = recipientsContainer.buildRecipientRow(recipient, true);
+
+ recipientsContainer.appendChild(newEls.row);
+ extraAddressRowsMenu.appendChild(newEls.showRowMenuItem);
+ }
+ }
+
+ try {
+ SetupCommandUpdateHandlers();
+ await ComposeStartup();
+ } catch (ex) {
+ console.error(ex);
+ Services.prompt.alert(
+ window,
+ getComposeBundle().getString("initErrorDlogTitle"),
+ getComposeBundle().getString("initErrorDlgMessage")
+ );
+
+ MsgComposeCloseWindow();
+ return;
+ }
+
+ ToolbarIconColor.init();
+
+ // initialize the customizeDone method on the customizeable toolbar
+ var toolbox = document.getElementById("compose-toolbox");
+ toolbox.customizeDone = function (aEvent) {
+ MailToolboxCustomizeDone(aEvent, "CustomizeComposeToolbar");
+ };
+
+ updateAttachmentPane();
+ updateAriaLabelsAndTooltipsOfAllAddressRows();
+
+ for (let input of document.querySelectorAll(".address-row-input")) {
+ input.onBeforeHandleKeyDown = event =>
+ addressInputOnBeforeHandleKeyDown(event);
+ }
+
+ top.controllers.appendController(SecurityController);
+ gMsgCompose.compFields.composeSecure = null;
+ gSMFields = Cc[
+ "@mozilla.org/messengercompose/composesecure;1"
+ ].createInstance(Ci.nsIMsgComposeSecure);
+ if (gSMFields) {
+ gMsgCompose.compFields.composeSecure = gSMFields;
+ }
+
+ // Set initial encryption settings.
+ adjustEncryptAfterIdentityChange(null);
+
+ ExtensionParent.apiManager.emit(
+ "extension-browser-inserted",
+ GetCurrentEditorElement()
+ );
+
+ setComposeLabelsAndMenuItems();
+ setKeyboardShortcuts();
+
+ gFocusAreas = [
+ {
+ // #abContactsPanel.
+ // NOTE: If focus is within the browser shadow document, then the
+ // top.document.activeElement points to the browser, which is below
+ // #contactsSidebar.
+ root: document.getElementById("contactsSidebar"),
+ focus: focusContactsSidebarSearchInput,
+ },
+ {
+ // #msgIdentity, .recipient-button and #extraAddressRowsMenuButton.
+ root: document.getElementById("top-gradient-box"),
+ focus: focusMsgIdentity,
+ },
+ ...Array.from(document.querySelectorAll(".address-row"), row => {
+ return { root: row, focus: focusAddressRowInput };
+ }),
+ {
+ root: document.getElementById("subject-box"),
+ focus: focusSubjectInput,
+ },
+ // "#FormatToolbox" cannot receive focus.
+ {
+ // #messageEditor and #FindToolbar
+ root: document.getElementById("messageArea"),
+ focus: focusMsgBody,
+ },
+ {
+ root: document.getElementById("attachmentArea"),
+ focus: focusAttachmentBucket,
+ },
+ {
+ root: document.getElementById("compose-notification-bottom"),
+ focus: focusNotification,
+ },
+ {
+ root: document.getElementById("status-bar"),
+ focus: focusStatusBar,
+ },
+ ];
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+}
+
+/**
+ * Add fluent strings to labels and menu items requiring a shortcut key.
+ */
+function setComposeLabelsAndMenuItems() {
+ // To field.
+ document.l10n.setAttributes(
+ document.getElementById("menu_showToField"),
+ "show-to-row-main-menuitem",
+ {
+ key: SHOW_TO_KEY,
+ }
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_toShowAddressRowMenuItem"),
+ "show-to-row-extra-menuitem"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_toShowAddressRowButton"),
+ "show-to-row-button",
+ {
+ key: SHOW_TO_KEY,
+ }
+ );
+
+ // Cc field.
+ document.l10n.setAttributes(
+ document.getElementById("menu_showCcField"),
+ "show-cc-row-main-menuitem",
+ {
+ key: SHOW_CC_KEY,
+ }
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_ccShowAddressRowMenuItem"),
+ "show-cc-row-extra-menuitem"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_ccShowAddressRowButton"),
+ "show-cc-row-button",
+ {
+ key: SHOW_CC_KEY,
+ }
+ );
+
+ // Bcc field.
+ document.l10n.setAttributes(
+ document.getElementById("menu_showBccField"),
+ "show-bcc-row-main-menuitem",
+ {
+ key: SHOW_BCC_KEY,
+ }
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_bccShowAddressRowMenuItem"),
+ "show-bcc-row-extra-menuitem"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_bccShowAddressRowButton"),
+ "show-bcc-row-button",
+ {
+ key: SHOW_BCC_KEY,
+ }
+ );
+}
+
+/**
+ * Add a keydown document event listener for international keyboard shortcuts.
+ */
+async function setKeyboardShortcuts() {
+ let [filePickerKey, toggleBucketKey] = await l10nCompose.formatValues([
+ { id: "trigger-attachment-picker-key" },
+ { id: "toggle-attachment-pane-key" },
+ ]);
+
+ document.addEventListener("keydown", event => {
+ // Return if we don't have the right modifier combination, CTRL/CMD + SHIFT,
+ // or if the pressed key is a modifier (each modifier will keep firing
+ // keydown event until another key is pressed in addition).
+ if (
+ !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) ||
+ !event.shiftKey ||
+ ["Shift", "Control", "Meta"].includes(event.key)
+ ) {
+ return;
+ }
+
+ // Always use lowercase to compare the key and avoid OS inconsistencies:
+ // For Cmd/Ctrl+Shift+A, on Mac, key = "a" vs. on Windows/Linux, key = "A".
+ switch (event.key.toLowerCase()) {
+ // Always prevent the default behavior of the keydown if we intercepted
+ // the key in order to avoid triggering OS specific shortcuts.
+ case filePickerKey.toLowerCase():
+ // Ctrl/Cmd+Shift+A.
+ event.preventDefault();
+ goDoCommand("cmd_attachFile");
+ break;
+ case toggleBucketKey.toLowerCase():
+ // Ctrl/Cmd+Shift+M.
+ event.preventDefault();
+ goDoCommand("cmd_toggleAttachmentPane");
+ break;
+ case SHOW_TO_KEY.toLowerCase():
+ // Ctrl/Cmd+Shift+T.
+ event.preventDefault();
+ showAndFocusAddressRow("addressRowTo");
+ break;
+ case SHOW_CC_KEY.toLowerCase():
+ // Ctrl/Cmd+Shift+C.
+ event.preventDefault();
+ showAndFocusAddressRow("addressRowCc");
+ break;
+ case SHOW_BCC_KEY.toLowerCase():
+ // Ctrl/Cmd+Shift+B.
+ event.preventDefault();
+ showAndFocusAddressRow("addressRowBcc");
+ break;
+ }
+ });
+
+ document.addEventListener("keypress", event => {
+ // If the user presses Esc and the drop attachment overlay is still visible,
+ // call the onDragLeave() method to properly hide it.
+ if (
+ event.key == "Escape" &&
+ document
+ .getElementById("dropAttachmentOverlay")
+ .classList.contains("show")
+ ) {
+ envelopeDragObserver.onDragLeave(event);
+ }
+ });
+}
+
+function ComposeUnload() {
+ // Send notification that the window is going away completely.
+ document
+ .getElementById("msgcomposeWindow")
+ .dispatchEvent(
+ new Event("compose-window-unload", { bubbles: false, cancelable: false })
+ );
+
+ GetCurrentCommandManager().removeCommandObserver(
+ gMsgEditorCreationObserver,
+ "obs_documentCreated"
+ );
+ UnloadCommandUpdateHandlers();
+
+ // In some tests, the window is closed so quickly that the observer
+ // hasn't fired and removed itself yet, so let's remove it here.
+ spellCheckReadyObserver.removeObserver();
+ // Stop spell checker so personal dictionary is saved.
+ enableInlineSpellCheck(false);
+
+ EditorCleanup();
+
+ if (gMsgCompose) {
+ gMsgCompose.removeMsgSendListener(gSendListener);
+ }
+
+ RemoveMessageComposeOfflineQuitObserver();
+ gAttachmentNotifier.shutdown();
+ ToolbarIconColor.uninit();
+
+ // Stop observing dictionary removals.
+ dictionaryRemovalObserver.removeObserver();
+
+ if (gMsgCompose) {
+ // Notify the SendListener that Send has been aborted and Stopped
+ gMsgCompose.onSendNotPerformed(null, Cr.NS_ERROR_ABORT);
+ gMsgCompose.UnregisterStateListener(stateListener);
+ }
+ if (gAutoSaveTimeout) {
+ clearTimeout(gAutoSaveTimeout);
+ }
+ if (msgWindow) {
+ msgWindow.closeWindow();
+ }
+
+ ReleaseGlobalVariables();
+
+ top.controllers.removeController(SecurityController);
+
+ // This destroys the window for us.
+ MsgComposeCloseWindow();
+}
+
+function onEncryptionChoice(value) {
+ switch (value) {
+ case "OpenPGP":
+ if (isPgpConfigured()) {
+ gSelectedTechnologyIsPGP = true;
+ checkEncryptionState();
+ }
+ break;
+
+ case "SMIME":
+ if (isSmimeEncryptionConfigured()) {
+ gSelectedTechnologyIsPGP = false;
+ checkEncryptionState();
+ }
+ break;
+
+ case "enc":
+ toggleEncryptMessage();
+ break;
+
+ case "encsub":
+ gEncryptSubject = !gEncryptSubject;
+ gUserTouchedEncryptSubject = true;
+ updateEncryptedSubject();
+ break;
+
+ case "sig":
+ toggleGlobalSignMessage();
+ break;
+
+ case "status":
+ showMessageComposeSecurityStatus();
+ break;
+
+ case "manager":
+ openKeyManager();
+ break;
+ }
+}
+
+var SecurityController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_viewSecurityStatus":
+ return true;
+
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(command) {
+ switch (command) {
+ case "cmd_viewSecurityStatus":
+ return true;
+
+ default:
+ return false;
+ }
+ },
+};
+
+function updateEncryptOptionsMenuElements() {
+ let encOpt = document.getElementById("button-encryption-options");
+ if (encOpt) {
+ document.l10n.setAttributes(
+ encOpt,
+ gSelectedTechnologyIsPGP
+ ? "encryption-options-openpgp"
+ : "encryption-options-smime"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("menu_recipientStatus_Toolbar"),
+ gSelectedTechnologyIsPGP ? "menu-manage-keys" : "menu-view-certificates"
+ );
+ document.getElementById("menu_securityEncryptSubject_Toolbar").hidden =
+ !gSelectedTechnologyIsPGP;
+ }
+ document.l10n.setAttributes(
+ document.getElementById("menu_recipientStatus_Menubar"),
+ gSelectedTechnologyIsPGP ? "menu-manage-keys" : "menu-view-certificates"
+ );
+ document.getElementById("menu_securityEncryptSubject_Menubar").hidden =
+ !gSelectedTechnologyIsPGP;
+}
+
+/**
+ * Update the aria labels of all non-custom address inputs and all pills in the
+ * addressing area. Also update the tooltips of the close labels of all address
+ * rows, including custom header fields.
+ */
+async function updateAriaLabelsAndTooltipsOfAllAddressRows() {
+ for (let row of document
+ .getElementById("recipientsContainer")
+ .querySelectorAll(".address-row")) {
+ updateAriaLabelsOfAddressRow(row);
+ updateTooltipsOfAddressRow(row);
+ }
+}
+
+/**
+ * Update the aria labels of the address input and all pills of an address row.
+ * This is needed whenever a pill gets added or removed, because the aria label
+ * of each pill contains the current count of all pills in that row ("1 of n").
+ *
+ * @param {Element} row - The address row.
+ */
+async function updateAriaLabelsOfAddressRow(row) {
+ // Bail out for custom header input where pills are disabled.
+ if (row.classList.contains("address-row-raw")) {
+ return;
+ }
+ let input = row.querySelector(".address-row-input");
+
+ let type = row.querySelector(".address-label-container > label").value;
+ let pills = row.querySelectorAll("mail-address-pill");
+
+ input.setAttribute(
+ "aria-label",
+ await l10nCompose.formatValue("address-input-type-aria-label", {
+ type,
+ count: pills.length,
+ })
+ );
+
+ for (let pill of pills) {
+ pill.setAttribute(
+ "aria-label",
+ await l10nCompose.formatValue("pill-aria-label", {
+ email: pill.fullAddress,
+ count: pills.length,
+ })
+ );
+ }
+}
+
+/**
+ * Update the tooltip of the close label of an address row.
+ *
+ * @param {Element} row - The address row.
+ */
+function updateTooltipsOfAddressRow(row) {
+ let type = row.querySelector(".address-label-container > label").value;
+ let el = row.querySelector(".remove-field-button");
+ document.l10n.setAttributes(el, "remove-address-row-button", { type });
+}
+
+function onSendSMIME() {
+ let emailAddresses = [];
+
+ try {
+ if (!gMsgCompose.compFields.composeSecure.requireEncryptMessage) {
+ return;
+ }
+
+ for (let email of getEncryptionCompatibleRecipients()) {
+ if (!gSMFields.haveValidCertForEmail(email)) {
+ emailAddresses.push(email);
+ }
+ }
+ } catch (e) {
+ return;
+ }
+
+ if (emailAddresses.length == 0) {
+ return;
+ }
+
+ // The rules here: If the current identity has a directoryServer set, then
+ // use that, otherwise, try the global preference instead.
+
+ let autocompleteDirectory;
+
+ // Does the current identity override the global preference?
+ if (gCurrentIdentity.overrideGlobalPref) {
+ autocompleteDirectory = gCurrentIdentity.directoryServer;
+ } else if (Services.prefs.getBoolPref("ldap_2.autoComplete.useDirectory")) {
+ // Try the global one
+ autocompleteDirectory = Services.prefs.getCharPref(
+ "ldap_2.autoComplete.directoryServer"
+ );
+ }
+
+ if (autocompleteDirectory) {
+ window.openDialog(
+ "chrome://messenger-smime/content/certFetchingStatus.xhtml",
+ "",
+ "chrome,modal,resizable,centerscreen",
+ autocompleteDirectory,
+ emailAddresses
+ );
+ }
+}
+
+// Add-ons can override this to customize the behavior.
+function DoSpellCheckBeforeSend() {
+ return Services.prefs.getBoolPref("mail.SpellCheckBeforeSend");
+}
+
+/**
+ * Updates gMsgCompose.compFields to match the UI.
+ *
+ * @returns {nsIMsgCompFields}
+ */
+function GetComposeDetails() {
+ let msgCompFields = gMsgCompose.compFields;
+
+ Recipients2CompFields(msgCompFields);
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(
+ document.getElementById("msgIdentity").value
+ );
+ msgCompFields.from = MailServices.headerParser.makeMimeHeader(addresses);
+ msgCompFields.subject = document.getElementById("msgSubject").value;
+ Attachments2CompFields(msgCompFields);
+
+ return msgCompFields;
+}
+
+/**
+ * Updates the UI to match newValues.
+ *
+ * @param {object} newValues - New values to use. Values that should not change
+ * should be null or not present.
+ * @param {string} [newValues.to]
+ * @param {string} [newValues.cc]
+ * @param {string} [newValues.bcc]
+ * @param {string} [newValues.replyTo]
+ * @param {string} [newValues.newsgroups]
+ * @param {string} [newValues.followupTo]
+ * @param {string} [newValues.subject]
+ * @param {string} [newValues.body]
+ * @param {string} [newValues.plainTextBody]
+ */
+function SetComposeDetails(newValues) {
+ if (newValues.identityKey !== null) {
+ let identityList = document.getElementById("msgIdentity");
+ for (let menuItem of identityList.menupopup.children) {
+ if (menuItem.getAttribute("identitykey") == newValues.identityKey) {
+ identityList.selectedItem = menuItem;
+ LoadIdentity(false);
+ break;
+ }
+ }
+ }
+ CompFields2Recipients(newValues);
+ if (typeof newValues.subject == "string") {
+ gMsgCompose.compFields.subject = document.getElementById(
+ "msgSubject"
+ ).value = newValues.subject;
+ SetComposeWindowTitle();
+ }
+ if (
+ typeof newValues.body == "string" &&
+ typeof newValues.plainTextBody == "string"
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let editor = GetCurrentEditor();
+ if (typeof newValues.body == "string") {
+ if (!IsHTMLEditor()) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ editor.rebuildDocumentFromSource(newValues.body);
+ gMsgCompose.bodyModified = true;
+ }
+ if (typeof newValues.plainTextBody == "string") {
+ editor.selectAll();
+ // Remove \r from line endings, which cause extra newlines (bug 1672407).
+ let mailEditor = editor.QueryInterface(Ci.nsIEditorMailSupport);
+ if (newValues.plainTextBody === "") {
+ editor.deleteSelection(editor.eNone, editor.eStrip);
+ } else {
+ mailEditor.insertTextWithQuotations(
+ newValues.plainTextBody.replaceAll("\r\n", "\n")
+ );
+ }
+ gMsgCompose.bodyModified = true;
+ }
+ gContentChanged = true;
+}
+
+/**
+ * Handles message sending operations.
+ *
+ * @param {nsIMsgCompDeliverMode} mode - The delivery mode of the operation.
+ */
+async function GenericSendMessage(msgType) {
+ let msgCompFields = GetComposeDetails();
+
+ // Some other msgCompFields have already been updated instantly in their
+ // respective toggle functions, e.g. ToggleReturnReceipt(), ToggleDSN(),
+ // ToggleAttachVCard(), and toggleAttachmentReminder().
+
+ let sending =
+ msgType == Ci.nsIMsgCompDeliverMode.Now ||
+ msgType == Ci.nsIMsgCompDeliverMode.Later ||
+ msgType == Ci.nsIMsgCompDeliverMode.Background;
+
+ // Notify about a new message being prepared for sending.
+ window.dispatchEvent(
+ new CustomEvent("compose-prepare-message-start", {
+ detail: { msgType },
+ })
+ );
+
+ try {
+ if (sending) {
+ // Since the onBeforeSend event can manipulate compose details, execute it
+ // before the final sanity checks.
+ try {
+ await new Promise((resolve, reject) => {
+ let beforeSendEvent = new CustomEvent("beforesend", {
+ cancelable: true,
+ detail: {
+ resolve,
+ reject,
+ },
+ });
+ window.dispatchEvent(beforeSendEvent);
+ if (!beforeSendEvent.defaultPrevented) {
+ resolve();
+ }
+ });
+ } catch (ex) {
+ throw new Error(`Send aborted by an onBeforeSend event`);
+ }
+
+ expandRecipients();
+ // Check if e-mail addresses are complete, in case user turned off
+ // autocomplete to local domain.
+ if (!CheckValidEmailAddress(msgCompFields)) {
+ throw new Error(`Send aborted: invalid recipient address found`);
+ }
+
+ // Do we need to check the spelling?
+ if (DoSpellCheckBeforeSend()) {
+ // We disable spellcheck for the following -subject line, attachment
+ // pane, identity and addressing widget therefore we need to explicitly
+ // focus on the mail body when we have to do a spellcheck.
+ focusMsgBody();
+ window.cancelSendMessage = false;
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml",
+ "_blank",
+ "dialog,close,titlebar,modal,resizable",
+ true,
+ true,
+ false
+ );
+
+ if (window.cancelSendMessage) {
+ throw new Error(`Send aborted by the user: spelling errors found`);
+ }
+ }
+
+ // Strip trailing spaces and long consecutive WSP sequences from the
+ // subject line to prevent getting only WSP chars on a folded line.
+ let subject = msgCompFields.subject;
+ let fixedSubject = subject.replace(/\s{74,}/g, " ").trimRight();
+ if (fixedSubject != subject) {
+ subject = fixedSubject;
+ msgCompFields.subject = fixedSubject;
+ document.getElementById("msgSubject").value = fixedSubject;
+ }
+
+ // Remind the person if there isn't a subject
+ if (subject == "") {
+ if (
+ Services.prompt.confirmEx(
+ window,
+ getComposeBundle().getString("subjectEmptyTitle"),
+ getComposeBundle().getString("subjectEmptyMessage"),
+ Services.prompt.BUTTON_TITLE_IS_STRING *
+ Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING *
+ Services.prompt.BUTTON_POS_1,
+ getComposeBundle().getString("sendWithEmptySubjectButton"),
+ getComposeBundle().getString("cancelSendingButton"),
+ null,
+ null,
+ { value: 0 }
+ ) == 1
+ ) {
+ document.getElementById("msgSubject").focus();
+ throw new Error(`Send aborted by the user: subject missing`);
+ }
+ }
+
+ // Attachment Reminder: Alert the user if
+ // - the user requested "Remind me later" from either the notification bar or the menu
+ // (alert regardless of the number of files already attached: we can't guess for how many
+ // or which files users want the reminder, and guessing wrong will annoy them a lot), OR
+ // - the aggressive pref is set and the latest notification is still showing (implying
+ // that the message has no attachment(s) yet, message still contains some attachment
+ // keywords, and notification was not dismissed).
+ if (
+ gManualAttachmentReminder ||
+ (Services.prefs.getBoolPref(
+ "mail.compose.attachment_reminder_aggressive"
+ ) &&
+ gComposeNotification.getNotificationWithValue("attachmentReminder"))
+ ) {
+ let flags =
+ Services.prompt.BUTTON_POS_0 *
+ Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_IS_STRING;
+ let hadForgotten = Services.prompt.confirmEx(
+ window,
+ getComposeBundle().getString("attachmentReminderTitle"),
+ getComposeBundle().getString("attachmentReminderMsg"),
+ flags,
+ getComposeBundle().getString("attachmentReminderFalseAlarm"),
+ getComposeBundle().getString("attachmentReminderYesIForgot"),
+ null,
+ null,
+ { value: 0 }
+ );
+ // Deactivate manual attachment reminder after showing the alert to avoid alert loop.
+ // We also deactivate reminder when user ignores alert with [x] or [ESC].
+ if (gManualAttachmentReminder) {
+ toggleAttachmentReminder(false);
+ }
+
+ if (hadForgotten) {
+ throw new Error(`Send aborted by the user: attachment missing`);
+ }
+ }
+
+ // Aggressive many public recipients prompt.
+ let publicRecipientCount = getPublicAddressPillsCount();
+ if (
+ Services.prefs.getBoolPref(
+ "mail.compose.warn_public_recipients.aggressive"
+ ) &&
+ publicRecipientCount >=
+ Services.prefs.getIntPref(
+ "mail.compose.warn_public_recipients.threshold"
+ )
+ ) {
+ let flags =
+ Services.prompt.BUTTON_POS_0 *
+ Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_IS_STRING;
+ let [title, msg, cancel, send] = l10nComposeSync.formatValuesSync([
+ "many-public-recipients-prompt-title",
+ {
+ id: "many-public-recipients-prompt-msg",
+ args: { count: getPublicAddressPillsCount() },
+ },
+ "many-public-recipients-prompt-cancel",
+ "many-public-recipients-prompt-send",
+ ]);
+ let willCancel = Services.prompt.confirmEx(
+ window,
+ title,
+ msg,
+ flags,
+ send,
+ cancel,
+ null,
+ null,
+ { value: 0 }
+ );
+
+ if (willCancel) {
+ if (!gRecipientObserver) {
+ // Re-create this observer as it is destroyed when the user dismisses
+ // the warning.
+ gRecipientObserver = new MutationObserver(function (mutations) {
+ if (mutations.some(m => m.type == "childList")) {
+ checkPublicRecipientsLimit();
+ }
+ });
+ }
+ checkPublicRecipientsLimit();
+ throw new Error(
+ `Send aborted by the user: too many public recipients found`
+ );
+ }
+ }
+
+ // Check if the user tries to send a message to a newsgroup through a mail
+ // account.
+ var currentAccountKey = getCurrentAccountKey();
+ let account = MailServices.accounts.getAccount(currentAccountKey);
+ if (
+ account.incomingServer.type != "nntp" &&
+ msgCompFields.newsgroups != ""
+ ) {
+ const kDontAskAgainPref = "mail.compose.dontWarnMail2Newsgroup";
+ // default to ask user if the pref is not set
+ let dontAskAgain = Services.prefs.getBoolPref(kDontAskAgainPref);
+ if (!dontAskAgain) {
+ let checkbox = { value: false };
+ let okToProceed = Services.prompt.confirmCheck(
+ window,
+ getComposeBundle().getString("noNewsgroupSupportTitle"),
+ getComposeBundle().getString("recipientDlogMessage"),
+ getComposeBundle().getString("CheckMsg"),
+ checkbox
+ );
+ if (!okToProceed) {
+ throw new Error(`Send aborted by the user: wrong account used`);
+ }
+
+ if (checkbox.value) {
+ Services.prefs.setBoolPref(kDontAskAgainPref, true);
+ }
+ }
+
+ // remove newsgroups to prevent news_p to be set
+ // in nsMsgComposeAndSend::DeliverMessage()
+ msgCompFields.newsgroups = "";
+ }
+
+ if (Services.prefs.getBoolPref("mail.compose.add_link_preview", true)) {
+ // Remove any card "close" button from content before sending.
+ for (let close of getBrowser().contentDocument.querySelectorAll(
+ ".moz-card .remove-card"
+ )) {
+ close.remove();
+ }
+ }
+
+ let sendFormat = determineSendFormat();
+ switch (sendFormat) {
+ case Ci.nsIMsgCompSendFormat.PlainText:
+ msgCompFields.forcePlainText = true;
+ msgCompFields.useMultipartAlternative = false;
+ break;
+ case Ci.nsIMsgCompSendFormat.HTML:
+ msgCompFields.forcePlainText = false;
+ msgCompFields.useMultipartAlternative = false;
+ break;
+ case Ci.nsIMsgCompSendFormat.Both:
+ msgCompFields.forcePlainText = false;
+ msgCompFields.useMultipartAlternative = true;
+ break;
+ default:
+ throw new Error(`Invalid send format ${sendFormat}`);
+ }
+ }
+
+ await CompleteGenericSendMessage(msgType);
+ window.dispatchEvent(new CustomEvent("compose-prepare-message-success"));
+ } catch (exception) {
+ console.error(exception);
+ window.dispatchEvent(
+ new CustomEvent("compose-prepare-message-failure", {
+ detail: { exception },
+ })
+ );
+ }
+}
+
+/**
+ * Finishes message sending. This should ONLY be called directly from
+ * GenericSendMessage. This is a separate function so that it can be easily mocked
+ * in tests.
+ *
+ * @param msgType nsIMsgCompDeliverMode of the operation.
+ */
+async function CompleteGenericSendMessage(msgType) {
+ // hook for extra compose pre-processing
+ Services.obs.notifyObservers(window, "mail:composeOnSend");
+
+ if (!gSelectedTechnologyIsPGP) {
+ gMsgCompose.compFields.composeSecure.requireEncryptMessage = gSendEncrypted;
+ gMsgCompose.compFields.composeSecure.signMessage = gSendSigned;
+ onSendSMIME();
+ }
+
+ let sendError = null;
+ try {
+ // Just before we try to send the message, fire off the
+ // compose-send-message event for listeners, so they can do
+ // any pre-security work before sending.
+ var event = document.createEvent("UIEvents");
+ event.initEvent("compose-send-message", false, true);
+ var msgcomposeWindow = document.getElementById("msgcomposeWindow");
+ msgcomposeWindow.setAttribute("msgtype", msgType);
+ msgcomposeWindow.dispatchEvent(event);
+ if (event.defaultPrevented) {
+ throw Components.Exception(
+ "compose-send-message prevented",
+ Cr.NS_ERROR_ABORT
+ );
+ }
+
+ gAutoSaving = msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft;
+
+ // disable the ui if we're not auto-saving
+ if (!gAutoSaving) {
+ ToggleWindowLock(true);
+ } else {
+ // If we're auto saving, mark the body as not changed here, and not
+ // when the save is done, because the user might change it between now
+ // and when the save is done.
+ SetContentAndBodyAsUnmodified();
+ }
+
+ // Keep track of send/saved cloudFiles and mark them as immutable.
+ let items = [...gAttachmentBucket.itemChildren];
+ for (let item of items) {
+ if (item.attachment.sendViaCloud && item.cloudFileAccount) {
+ item.cloudFileAccount.markAsImmutable(item.cloudFileUpload.id);
+ }
+ }
+
+ var progress = Cc["@mozilla.org/messenger/progress;1"].createInstance(
+ Ci.nsIMsgProgress
+ );
+ if (progress) {
+ progress.registerListener(progressListener);
+ if (
+ msgType == Ci.nsIMsgCompDeliverMode.Save ||
+ msgType == Ci.nsIMsgCompDeliverMode.SaveAsDraft ||
+ msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft ||
+ msgType == Ci.nsIMsgCompDeliverMode.SaveAsTemplate
+ ) {
+ gSaveOperationInProgress = true;
+ } else {
+ gSendOperationInProgress = true;
+ }
+ }
+ msgWindow.domWindow = window;
+ msgWindow.rootDocShell.allowAuth = true;
+ await gMsgCompose.sendMsg(
+ msgType,
+ gCurrentIdentity,
+ getCurrentAccountKey(),
+ msgWindow,
+ progress
+ );
+ } catch (ex) {
+ console.error("GenericSendMessage FAILED: " + ex);
+ ToggleWindowLock(false);
+ sendError = ex;
+ }
+
+ if (
+ msgType == Ci.nsIMsgCompDeliverMode.Now ||
+ msgType == Ci.nsIMsgCompDeliverMode.Later ||
+ msgType == Ci.nsIMsgCompDeliverMode.Background
+ ) {
+ window.dispatchEvent(new CustomEvent("aftersend"));
+
+ let maxSize =
+ Services.prefs.getIntPref("mail.compose.big_attachments.threshold_kb") *
+ 1024;
+ let items = [...gAttachmentBucket.itemChildren];
+
+ // When any big attachment is not sent via filelink, increment
+ // `tb.filelink.ignored`.
+ if (
+ items.some(
+ item => item.attachment.size >= maxSize && !item.attachment.sendViaCloud
+ )
+ ) {
+ Services.telemetry.scalarAdd("tb.filelink.ignored", 1);
+ }
+ } else if (
+ msgType == Ci.nsIMsgCompDeliverMode.Save ||
+ msgType == Ci.nsIMsgCompDeliverMode.SaveAsDraft ||
+ msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft ||
+ msgType == Ci.nsIMsgCompDeliverMode.SaveAsTemplate
+ ) {
+ window.dispatchEvent(new CustomEvent("aftersave"));
+ }
+
+ if (sendError) {
+ throw sendError;
+ }
+}
+
+/**
+ * Check if the given email address is valid (contains an @).
+ *
+ * @param {string} address - The email address string to check.
+ */
+function isValidAddress(address) {
+ return address.includes("@", 1) && !address.endsWith("@");
+}
+
+/**
+ * Check if the given news address is valid (contains a dot).
+ *
+ * @param {string} address - The news address string to check.
+ */
+function isValidNewsAddress(address) {
+ return address.includes(".", 1) && !address.endsWith(".");
+}
+
+/**
+ * Force the focus on the autocomplete input if the user clicks on an empty
+ * area of the address container.
+ *
+ * @param {Event} event - the event triggered by the click.
+ */
+function focusAddressInputOnClick(event) {
+ let container = event.target;
+ if (container.classList.contains("address-container")) {
+ container.querySelector(".address-row-input").focus();
+ }
+}
+
+/**
+ * Keep the Send buttons disabled until any recipient is entered.
+ */
+function updateSendLock() {
+ gSendLocked = true;
+ if (!gMsgCompose) {
+ return;
+ }
+
+ const addressRows = [
+ "toAddrContainer",
+ "ccAddrContainer",
+ "bccAddrContainer",
+ "newsgroupsAddrContainer",
+ ];
+
+ for (let parentID of addressRows) {
+ if (!gSendLocked) {
+ break;
+ }
+
+ let parent = document.getElementById(parentID);
+
+ if (!parent) {
+ continue;
+ }
+
+ for (let address of parent.querySelectorAll(".address-pill")) {
+ let listNames = MimeParser.parseHeaderField(
+ address.fullAddress,
+ MimeParser.HEADER_ADDRESS
+ );
+ let isMailingList =
+ listNames.length > 0 &&
+ MailServices.ab.mailListNameExists(listNames[0].name);
+
+ if (
+ isValidAddress(address.emailAddress) ||
+ isMailingList ||
+ address.emailInput.classList.contains("news-input")
+ ) {
+ gSendLocked = false;
+ break;
+ }
+ }
+ }
+
+ // Check the non pillified input text inside the autocomplete input fields.
+ for (let input of document.querySelectorAll(
+ ".address-row:not(.hidden):not(.address-row-raw) .address-row-input"
+ )) {
+ let inputValueTrim = input.value.trim();
+ // If there's no text in the input, proceed with next input.
+ if (!inputValueTrim) {
+ continue;
+ }
+ // If text contains " >> " (typically from an unfinished autocompletion),
+ // lock Send and return.
+ if (inputValueTrim.includes(" >> ")) {
+ gSendLocked = true;
+ return;
+ }
+
+ // If we find at least one valid pill, and in spite of potential other
+ // invalid pills or invalid addresses in the input, enable the Send button.
+ // It might be disabled again if the above autocomplete artifact is present
+ // in a subsequent row, to prevent sending the artifact as a valid address.
+ if (
+ input.classList.contains("news-input")
+ ? isValidNewsAddress(inputValueTrim)
+ : isValidAddress(inputValueTrim)
+ ) {
+ gSendLocked = false;
+ }
+ }
+}
+
+/**
+ * Check if the entered addresses are valid and alert the user if they are not.
+ *
+ * @param aMsgCompFields A nsIMsgCompFields object containing the fields to check.
+ */
+function CheckValidEmailAddress(aMsgCompFields) {
+ let invalidStr;
+ let recipientCount = 0;
+ // Check that each of the To, CC, and BCC recipients contains a '@'.
+ for (let type of ["to", "cc", "bcc"]) {
+ let recipients = aMsgCompFields.splitRecipients(
+ aMsgCompFields[type],
+ false
+ );
+ // MsgCompFields contains only non-empty recipients.
+ recipientCount += recipients.length;
+ for (let recipient of recipients) {
+ if (!isValidAddress(recipient)) {
+ invalidStr = recipient;
+ break;
+ }
+ }
+ if (invalidStr) {
+ break;
+ }
+ }
+
+ if (recipientCount == 0 && aMsgCompFields.newsgroups.trim() == "") {
+ Services.prompt.alert(
+ window,
+ getComposeBundle().getString("addressInvalidTitle"),
+ getComposeBundle().getString("noRecipients")
+ );
+ return false;
+ }
+
+ if (invalidStr) {
+ Services.prompt.alert(
+ window,
+ getComposeBundle().getString("addressInvalidTitle"),
+ getComposeBundle().getFormattedString("addressInvalid", [invalidStr], 1)
+ );
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Cycle through all the currently visible autocomplete addressing rows and
+ * generate pills for those inputs with leftover strings. Do the same if we
+ * have a pill currently being edited. This is necessary in case a user writes
+ * an extra address and clicks "Send" or "Save as..." before the text is
+ * converted into a pill. The input onBlur doesn't work if the click interaction
+ * happens on the window's menu bar.
+ */
+async function pillifyRecipients() {
+ for (let input of document.querySelectorAll(
+ ".address-row:not(.hidden):not(.address-row-raw) .address-row-input"
+ )) {
+ // If we find a leftover string in the input field, create a pill. If the
+ // newly created pill is not a valid address, the sending will stop.
+ if (input.value.trim()) {
+ recipientAddPills(input);
+ }
+ }
+
+ // Update the currently editing pill, if any.
+ // It's impossible to edit more than one pill at once.
+ await document.querySelector("mail-address-pill.editing")?.updatePill();
+}
+
+/**
+ * Handle the dragover event on a recipient disclosure label.
+ *
+ * @param {Event} - The DOM dragover event on a recipient disclosure label.
+ */
+function showAddressRowButtonOnDragover(event) {
+ // Prevent dragover event's default action (which resets the current drag
+ // operation to "none").
+ event.preventDefault();
+}
+
+/**
+ * Handle the drop event on a recipient disclosure label.
+ *
+ * @param {Event} - The DOM drop event on a recipient disclosure label.
+ */
+function showAddressRowButtonOnDrop(event) {
+ if (event.dataTransfer.types.includes("text/pills")) {
+ // If the dragged data includes the type "text/pills", we believe that
+ // the user is dragging our own pills, so we try to move the selected pills
+ // to the address row of the recipient label they were dropped on (Cc, Bcc,
+ // etc.), which will also show the row if needed. If there are no selected
+ // pills (so "text/pills" was generated elsewhere), moveSelectedPills() will
+ // bail out and we'll do nothing.
+ let row = document.getElementById(event.target.dataset.addressRow);
+ document.getElementById("recipientsContainer").moveSelectedPills(row);
+ }
+}
+
+/**
+ * Command handler: Cut the selected pills.
+ */
+function cutSelectedPillsOnCommand() {
+ document.getElementById("recipientsContainer").cutSelectedPills();
+}
+
+/**
+ * Command handler: Copy the selected pills.
+ */
+function copySelectedPillsOnCommand() {
+ document.getElementById("recipientsContainer").copySelectedPills();
+}
+
+/**
+ * Command handler: Select the focused pill and all siblings in the same
+ * address row.
+ *
+ * @param {Element} focusPill - The focused <mail-address-pill> element.
+ */
+function selectAllSiblingPillsOnCommand(focusPill) {
+ let recipientsContainer = document.getElementById("recipientsContainer");
+ // First deselect all pills to ensure that no pills outside the current
+ // address row are selected, e.g. when this action was triggered from
+ // context menu on already selected pill(s).
+ recipientsContainer.deselectAllPills();
+ // Select all pills of the current address row.
+ recipientsContainer.selectSiblingPills(focusPill);
+}
+
+/**
+ * Command handler: Select all recipient pills in the addressing area.
+ */
+function selectAllPillsOnCommand() {
+ document.getElementById("recipientsContainer").selectAllPills();
+}
+
+/**
+ * Command handler: Delete the selected pills.
+ */
+function deleteSelectedPillsOnCommand() {
+ document.getElementById("recipientsContainer").removeSelectedPills();
+}
+
+/**
+ * Command handler: Move the selected pills to another address row.
+ *
+ * @param {string} rowId - The id of the address row to move to.
+ */
+function moveSelectedPillsOnCommand(rowId) {
+ document
+ .getElementById("recipientsContainer")
+ .moveSelectedPills(document.getElementById(rowId));
+}
+
+/**
+ * Check if there are too many public recipients and offer to send them as BCC.
+ */
+function checkPublicRecipientsLimit() {
+ let notification = gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+
+ let recipLimit = Services.prefs.getIntPref(
+ "mail.compose.warn_public_recipients.threshold"
+ );
+
+ let publicAddressPillsCount = getPublicAddressPillsCount();
+
+ if (publicAddressPillsCount < recipLimit) {
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ return;
+ }
+
+ // Reuse the existing notification since one is shown already.
+ if (notification) {
+ if (publicAddressPillsCount > 1) {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "public-recipients-notice-multi",
+ {
+ count: publicAddressPillsCount,
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "public-recipients-notice-single"
+ );
+ }
+ return;
+ }
+
+ // Construct the notification as we don't have one.
+ let bccButton = {
+ "l10n-id": "many-public-recipients-bcc",
+ callback() {
+ // Get public addresses before we remove the pills.
+ let publicAddresses = getPublicAddressPills().map(
+ pill => pill.fullAddress
+ );
+
+ addressRowClearPills(document.getElementById("addressRowTo"));
+ addressRowClearPills(document.getElementById("addressRowCc"));
+ // Add previously public address pills to Bcc address row and select them.
+ let bccRow = document.getElementById("addressRowBcc");
+ addressRowAddRecipientsArray(bccRow, publicAddresses, true);
+ // Focus last added pill to prevent sticky selection with focus elsewhere.
+ bccRow.querySelector("mail-address-pill:last-of-type").focus();
+ return false;
+ },
+ };
+
+ let ignoreButton = {
+ "l10n-id": "many-public-recipients-ignore",
+ callback() {
+ gRecipientObserver.disconnect();
+ gRecipientObserver = null;
+ // After closing notification with `Keep Recipients Public`, actively
+ // manage focus to prevent weird focus change e.g. to Contacts Sidebar.
+ // If focus was in addressing area before, restore that as the user might
+ // dismiss the notification when it appears while still adding recipients.
+ if (gLastFocusElement?.classList.contains("address-input")) {
+ gLastFocusElement.focus();
+ return false;
+ }
+
+ // Otherwise if there's no subject yet, focus that (ux-error-prevention).
+ let msgSubject = document.getElementById("msgSubject");
+ if (!msgSubject.value) {
+ msgSubject.focus();
+ return false;
+ }
+
+ // Otherwise default to focusing message body.
+ document.getElementById("messageEditor").focus();
+ return false;
+ },
+ };
+
+ // NOTE: setting "public-recipients-notice-single" below, after the notification
+ // has been appended, so that the notification can be found and no further
+ // notifications are appended.
+ notification = gComposeNotification.appendNotification(
+ "warnPublicRecipientsNotification",
+ {
+ label: "", // "public-recipients-notice-single"
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ eventCallback(state) {
+ if (state == "dismissed") {
+ ignoreButton.callback();
+ }
+ },
+ },
+ [bccButton, ignoreButton]
+ );
+
+ if (notification) {
+ if (publicAddressPillsCount > 1) {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "public-recipients-notice-multi",
+ {
+ count: publicAddressPillsCount,
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "public-recipients-notice-single"
+ );
+ }
+ }
+}
+
+/**
+ * Get all the address pills in the "To" and "Cc" fields.
+ *
+ * @returns {Element[]} All <mail-address-pill> elements in "To" and "CC" fields.
+ */
+function getPublicAddressPills() {
+ return [
+ ...document.querySelectorAll("#toAddrContainer > mail-address-pill"),
+ ...document.querySelectorAll("#ccAddrContainer > mail-address-pill"),
+ ];
+}
+
+/**
+ * Gets the count of all the address pills in the "To" and "Cc" fields. This
+ * takes mailing lists into consideration as well.
+ */
+function getPublicAddressPillsCount() {
+ let pills = getPublicAddressPills();
+ return pills.reduce(
+ (total, pill) =>
+ pill.isMailList ? total + pill.listAddressCount : total + 1,
+ 0
+ );
+}
+
+/**
+ * Check for Bcc recipients in an encrypted message and warn the user.
+ * The warning is not shown if the only Bcc recipient is the sender.
+ */
+async function checkEncryptedBccRecipients() {
+ let notification = gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ );
+
+ if (!gWantCannotEncryptBCCNotification) {
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ return;
+ }
+
+ let bccRecipients = [
+ ...document.querySelectorAll("#bccAddrContainer > mail-address-pill"),
+ ];
+ let bccIsSender = bccRecipients.every(
+ pill => pill.emailAddress == gCurrentIdentity.email
+ );
+
+ if (!gSendEncrypted || !bccRecipients.length || bccIsSender) {
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ return;
+ }
+
+ if (notification) {
+ return;
+ }
+
+ let ignoreButton = {
+ "l10n-id": "encrypted-bcc-ignore-button",
+ callback() {
+ gWantCannotEncryptBCCNotification = false;
+ return false;
+ },
+ };
+
+ gComposeNotification.appendNotification(
+ "warnEncryptedBccRecipients",
+ {
+ label: await document.l10n.formatValue("encrypted-bcc-warning"),
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ eventCallback(state) {
+ if (state == "dismissed") {
+ ignoreButton.callback();
+ }
+ },
+ },
+ [ignoreButton]
+ );
+}
+
+async function SendMessage() {
+ await pillifyRecipients();
+ let sendInBackground = Services.prefs.getBoolPref(
+ "mailnews.sendInBackground"
+ );
+ if (sendInBackground && AppConstants.platform != "macosx") {
+ let count = [...Services.wm.getEnumerator(null)].length;
+ if (count == 1) {
+ sendInBackground = false;
+ }
+ }
+
+ await GenericSendMessage(
+ sendInBackground
+ ? Ci.nsIMsgCompDeliverMode.Background
+ : Ci.nsIMsgCompDeliverMode.Now
+ );
+ ExitFullscreenMode();
+}
+
+async function SendMessageWithCheck() {
+ await pillifyRecipients();
+ var warn = Services.prefs.getBoolPref("mail.warn_on_send_accel_key");
+
+ if (warn) {
+ let bundle = getComposeBundle();
+ let checkValue = { value: false };
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ bundle.getString("sendMessageCheckWindowTitle"),
+ bundle.getString("sendMessageCheckLabel"),
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1,
+ bundle.getString("sendMessageCheckSendButtonLabel"),
+ null,
+ null,
+ bundle.getString("CheckMsg"),
+ checkValue
+ );
+ if (buttonPressed != 0) {
+ return;
+ }
+ if (checkValue.value) {
+ Services.prefs.setBoolPref("mail.warn_on_send_accel_key", false);
+ }
+ }
+
+ let sendInBackground = Services.prefs.getBoolPref(
+ "mailnews.sendInBackground"
+ );
+
+ let mode;
+ if (Services.io.offline) {
+ mode = Ci.nsIMsgCompDeliverMode.Later;
+ } else {
+ mode = sendInBackground
+ ? Ci.nsIMsgCompDeliverMode.Background
+ : Ci.nsIMsgCompDeliverMode.Now;
+ }
+ await GenericSendMessage(mode);
+ ExitFullscreenMode();
+}
+
+async function SendMessageLater() {
+ await pillifyRecipients();
+ await GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later);
+ ExitFullscreenMode();
+}
+
+function ExitFullscreenMode() {
+ // On OS X we need to deliberately exit full screen mode after sending.
+ if (AppConstants.platform == "macosx") {
+ window.fullScreen = false;
+ }
+}
+
+function Save() {
+ switch (defaultSaveOperation) {
+ case "file":
+ SaveAsFile(false);
+ break;
+ case "template":
+ SaveAsTemplate(false).catch(console.error);
+ break;
+ default:
+ SaveAsDraft(false).catch(console.error);
+ break;
+ }
+}
+
+function SaveAsFile(saveAs) {
+ GetCurrentEditorElement().contentDocument.title =
+ document.getElementById("msgSubject").value;
+
+ if (gMsgCompose.bodyConvertible() == Ci.nsIMsgCompConvertible.Plain) {
+ SaveDocument(saveAs, false, "text/plain");
+ } else {
+ SaveDocument(saveAs, false, "text/html");
+ }
+ defaultSaveOperation = "file";
+}
+
+async function SaveAsDraft() {
+ gAutoSaveKickedIn = false;
+ gEditingDraft = true;
+
+ await pillifyRecipients();
+ await GenericSendMessage(Ci.nsIMsgCompDeliverMode.SaveAsDraft);
+ defaultSaveOperation = "draft";
+}
+
+async function SaveAsTemplate() {
+ gAutoSaveKickedIn = false;
+ gEditingDraft = false;
+
+ await pillifyRecipients();
+ let savedReferences = null;
+ if (gMsgCompose && gMsgCompose.compFields) {
+ // Clear References header. When we use the template, we don't want that
+ // header, yet, "edit as new message" maintains it. So we need to clear
+ // it when saving the template.
+ // Note: The In-Reply-To header is the last entry in the references header,
+ // so it will get cleared as well.
+ savedReferences = gMsgCompose.compFields.references;
+ gMsgCompose.compFields.references = null;
+ }
+
+ await GenericSendMessage(Ci.nsIMsgCompDeliverMode.SaveAsTemplate);
+ defaultSaveOperation = "template";
+
+ if (savedReferences) {
+ gMsgCompose.compFields.references = savedReferences;
+ }
+}
+
+// Sets the additional FCC, in addition to the default FCC.
+function MessageFcc(aFolder) {
+ if (!gMsgCompose) {
+ return;
+ }
+
+ var msgCompFields = gMsgCompose.compFields;
+ if (!msgCompFields) {
+ return;
+ }
+
+ // Get the uri for the folder to FCC into.
+ var fccURI = aFolder.URI;
+ msgCompFields.fcc2 = msgCompFields.fcc2 == fccURI ? "nocopy://" : fccURI;
+}
+
+function updateOptionsMenu() {
+ setSecuritySettings("_Menubar");
+
+ let menuItem = document.getElementById("menu_inlineSpellCheck");
+ if (gSpellCheckingEnabled) {
+ menuItem.setAttribute("checked", "true");
+ } else {
+ menuItem.removeAttribute("checked");
+ }
+}
+
+function updatePriorityMenu() {
+ if (gMsgCompose) {
+ var msgCompFields = gMsgCompose.compFields;
+ if (msgCompFields && msgCompFields.priority) {
+ var priorityMenu = document.getElementById("priorityMenu");
+ priorityMenu.querySelector('[checked="true"]').removeAttribute("checked");
+ priorityMenu
+ .querySelector('[value="' + msgCompFields.priority + '"]')
+ .setAttribute("checked", "true");
+ }
+ }
+}
+
+function updatePriorityToolbarButton(newPriorityValue) {
+ var prioritymenu = document.getElementById("priorityMenu-button");
+ if (prioritymenu) {
+ prioritymenu.value = newPriorityValue;
+ }
+}
+
+function PriorityMenuSelect(target) {
+ if (gMsgCompose) {
+ var msgCompFields = gMsgCompose.compFields;
+ if (msgCompFields) {
+ msgCompFields.priority = target.getAttribute("value");
+ }
+
+ // keep priority toolbar button in synch with possible changes via the menu item
+ updatePriorityToolbarButton(target.getAttribute("value"));
+ }
+}
+
+/**
+ * Initialise the send format menu using the current gMsgCompose.compFields.
+ */
+function initSendFormatMenu() {
+ let formatToId = new Map([
+ [Ci.nsIMsgCompSendFormat.PlainText, "format_plain"],
+ [Ci.nsIMsgCompSendFormat.HTML, "format_html"],
+ [Ci.nsIMsgCompSendFormat.Both, "format_both"],
+ [Ci.nsIMsgCompSendFormat.Auto, "format_auto"],
+ ]);
+
+ let sendFormat = gMsgCompose.compFields.deliveryFormat;
+
+ if (sendFormat == Ci.nsIMsgCompSendFormat.Unset) {
+ sendFormat = Services.prefs.getIntPref(
+ "mail.default_send_format",
+ Ci.nsIMsgCompSendFormat.Auto
+ );
+
+ if (!formatToId.has(sendFormat)) {
+ // Unknown preference value.
+ sendFormat = Ci.nsIMsgCompSendFormat.Auto;
+ }
+ }
+
+ // Make the composition field uses the same as determined above. Specifically,
+ // if the deliveryFormat was Unset, we now set it to a specific value.
+ gMsgCompose.compFields.deliveryFormat = sendFormat;
+
+ for (let [format, id] of formatToId.entries()) {
+ let menuitem = document.getElementById(id);
+ menuitem.value = String(format);
+ if (format == sendFormat) {
+ menuitem.setAttribute("checked", "true");
+ } else {
+ menuitem.removeAttribute("checked");
+ }
+ }
+
+ document
+ .getElementById("outputFormatMenu")
+ .addEventListener("command", event => {
+ let prevSendFormat = gMsgCompose.compFields.deliveryFormat;
+ let newSendFormat = parseInt(event.target.value, 10);
+ gMsgCompose.compFields.deliveryFormat = newSendFormat;
+ gContentChanged = prevSendFormat != newSendFormat;
+ });
+}
+
+/**
+ * Walk through a plain text list of recipients and add them to the inline spell
+ * checker ignore list, e.g. to avoid that known recipient names get marked
+ * wrong in message body.
+ *
+ * @param {string} aAddressesToAdd - A (comma-separated) recipient(s) string.
+ */
+function addRecipientsToIgnoreList(aAddressesToAdd) {
+ if (gSpellCheckingEnabled) {
+ // break the list of potentially many recipients back into individual names
+ let addresses =
+ MailServices.headerParser.parseEncodedHeader(aAddressesToAdd);
+ let tokenizedNames = [];
+
+ // Each name could consist of multiple word delimited by either commas or spaces, i.e. Green Lantern
+ // or Lantern,Green. Tokenize on comma first, then tokenize again on spaces.
+ for (let addr of addresses) {
+ if (!addr.name) {
+ continue;
+ }
+ let splitNames = addr.name.split(",");
+ for (let i = 0; i < splitNames.length; i++) {
+ // now tokenize off of white space
+ let splitNamesFromWhiteSpaceArray = splitNames[i].split(" ");
+ for (
+ let whiteSpaceIndex = 0;
+ whiteSpaceIndex < splitNamesFromWhiteSpaceArray.length;
+ whiteSpaceIndex++
+ ) {
+ if (splitNamesFromWhiteSpaceArray[whiteSpaceIndex]) {
+ tokenizedNames.push(splitNamesFromWhiteSpaceArray[whiteSpaceIndex]);
+ }
+ }
+ }
+ }
+ spellCheckReadyObserver.addWordsToIgnore(tokenizedNames);
+ }
+}
+
+/**
+ * Observer waiting for spell checker to become initialized or to complete
+ * checking. When it fires, it pushes new words to be ignored to the speller.
+ */
+var spellCheckReadyObserver = {
+ _topic: "inlineSpellChecker-spellCheck-ended",
+
+ _ignoreWords: [],
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != this._topic) {
+ return;
+ }
+
+ this.removeObserver();
+ this._addWords();
+ },
+
+ _isAdded: false,
+
+ addObserver() {
+ if (this._isAdded) {
+ return;
+ }
+
+ Services.obs.addObserver(this, this._topic);
+ this._isAdded = true;
+ },
+
+ removeObserver() {
+ if (!this._isAdded) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, this._topic);
+ this._clearPendingWords();
+ this._isAdded = false;
+ },
+
+ addWordsToIgnore(aIgnoreWords) {
+ this._ignoreWords.push(...aIgnoreWords);
+ let checker = GetCurrentEditorSpellChecker();
+ if (!checker || checker.spellCheckPending) {
+ // spellchecker is enabled, but we must wait for its init to complete
+ this.addObserver();
+ } else {
+ this._addWords();
+ }
+ },
+
+ _addWords() {
+ // At the time the speller finally got initialized, we may already be closing
+ // the compose together with the speller, so we need to check if they
+ // are still valid.
+ let checker = GetCurrentEditorSpellChecker();
+ if (gMsgCompose && checker?.enableRealTimeSpell) {
+ checker.ignoreWords(this._ignoreWords);
+ }
+ this._clearPendingWords();
+ },
+
+ _clearPendingWords() {
+ this._ignoreWords.length = 0;
+ },
+};
+
+/**
+ * Called if the list of recipients changed in any way.
+ *
+ * @param {boolean} automatic - Set to true if the change of recipients was
+ * invoked programmatically and should not be considered a change of message
+ * content.
+ */
+function onRecipientsChanged(automatic) {
+ if (!automatic) {
+ gContentChanged = true;
+ }
+ updateSendCommands(true);
+}
+
+/**
+ * Show the popup identified by aPopupID
+ * at the anchor element identified by aAnchorID.
+ *
+ * Note: All but the first 2 parameters are identical with the parameters of
+ * the openPopup() method of XUL popup element. For details, please consult docs.
+ * Except aPopupID, all parameters are optional.
+ * Example: showPopupById("aPopupID", "aAnchorID");
+ *
+ * @param aPopupID the ID of the popup element to be shown
+ * @param aAnchorID the ID of an element to which the popup should be anchored
+ * @param aPosition a single-word alignment value for the position parameter
+ * of openPopup() method; defaults to "after_start" if omitted.
+ * @param x x offset from default position
+ * @param y y offset from default position
+ * @param isContextMenu {boolean} For details, see documentation.
+ * @param attributesOverride {boolean} whether the position attribute on the
+ * popup node overrides the position parameter
+ * @param triggerEvent the event that triggered the popup
+ */
+function showPopupById(
+ aPopupID,
+ aAnchorID,
+ aPosition = "after_start",
+ x,
+ y,
+ isContextMenu,
+ attributesOverride,
+ triggerEvent
+) {
+ let popup = document.getElementById(aPopupID);
+ let anchor = document.getElementById(aAnchorID);
+ popup.openPopup(
+ anchor,
+ aPosition,
+ x,
+ y,
+ isContextMenu,
+ attributesOverride,
+ triggerEvent
+ );
+}
+
+function InitLanguageMenu() {
+ var languageMenuList = document.getElementById("languageMenuList");
+ if (!languageMenuList) {
+ return;
+ }
+
+ var spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+
+ // Get the list of dictionaries from
+ // the spellchecker.
+
+ var dictList = spellChecker.getDictionaryList();
+
+ let extraItemCount = dictList.length === 0 ? 1 : 2;
+
+ // If dictionary count hasn't changed then no need to update the menu.
+ if (dictList.length + extraItemCount == languageMenuList.childElementCount) {
+ return;
+ }
+
+ var sortedList = gSpellChecker.sortDictionaryList(dictList);
+
+ let getMoreItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(getMoreItem, "spell-add-dictionaries");
+ getMoreItem.addEventListener("command", event => {
+ event.stopPropagation();
+ openDictionaryList();
+ });
+ let getMoreArray = [getMoreItem];
+
+ if (extraItemCount > 1) {
+ getMoreArray.unshift(document.createXULElement("menuseparator"));
+ }
+
+ // Remove any languages from the list.
+ languageMenuList.replaceChildren(
+ ...sortedList.map(dict => {
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", dict.displayName);
+ item.setAttribute("value", dict.localeCode);
+ item.setAttribute("type", "checkbox");
+ item.setAttribute("selection-type", "multiple");
+ if (dictList.length > 1) {
+ item.setAttribute("closemenu", "none");
+ }
+ return item;
+ }),
+ ...getMoreArray
+ );
+}
+
+function OnShowDictionaryMenu(aTarget) {
+ InitLanguageMenu();
+
+ for (let item of aTarget.children) {
+ item.setAttribute(
+ "checked",
+ gActiveDictionaries.has(item.getAttribute("value"))
+ );
+ }
+}
+
+function languageMenuListOpened() {
+ document
+ .getElementById("languageStatusButton")
+ .setAttribute("aria-expanded", "true");
+}
+
+function languageMenuListClosed() {
+ document
+ .getElementById("languageStatusButton")
+ .setAttribute("aria-expanded", "false");
+}
+
+/**
+ * Set of the active dictionaries. We maintain this cached state so we don't
+ * need a spell checker instance to know the active dictionaries. This is
+ * especially relevant when inline spell checking is disabled.
+ *
+ * @type {Set<string>}
+ */
+var gActiveDictionaries = new Set();
+/**
+ * Change the language of the composition and if we are using inline
+ * spell check, recheck the message with the new dictionary.
+ *
+ * Note: called from the "Check Spelling" panel in SelectLanguage().
+ *
+ * @param {string[]} languages - New languages to set.
+ */
+async function ComposeChangeLanguage(languages) {
+ let currentLanguage = document.documentElement.getAttribute("lang");
+ if (
+ (languages.length === 1 && currentLanguage != languages[0]) ||
+ languages.length !== 1
+ ) {
+ let languageToSet = "";
+ if (languages.length === 1) {
+ languageToSet = languages[0];
+ }
+ // Update the document language as well.
+ document.documentElement.setAttribute("lang", languageToSet);
+ }
+
+ await gSpellChecker?.selectDictionaries(languages);
+
+ let checker = GetCurrentEditorSpellChecker();
+ if (checker?.spellChecker) {
+ await checker.spellChecker.setCurrentDictionaries(languages);
+ }
+ // Update subject spell checker languages. If for some reason the spell
+ // checker isn't ready yet, don't auto-create it, hence pass 'false'.
+ let subjectSpellChecker = checker?.spellChecker
+ ? document.getElementById("msgSubject").editor.getInlineSpellChecker(false)
+ : null;
+ if (subjectSpellChecker?.spellChecker) {
+ await subjectSpellChecker.spellChecker.setCurrentDictionaries(languages);
+ }
+
+ // now check the document over again with the new dictionary
+ if (gSpellCheckingEnabled) {
+ if (checker?.spellChecker) {
+ checker.spellCheckRange(null);
+ }
+
+ if (subjectSpellChecker?.spellChecker) {
+ // Also force a recheck of the subject.
+ subjectSpellChecker.spellCheckRange(null);
+ }
+ }
+
+ await updateLanguageInStatusBar(languages);
+
+ // Update the language in the composition fields, so we can save it
+ // to the draft next time.
+ if (gMsgCompose?.compFields) {
+ let langs = "";
+ if (!Services.prefs.getBoolPref("mail.suppress_content_language")) {
+ langs = languages.join(", ");
+ }
+ gMsgCompose.compFields.contentLanguage = langs;
+ }
+
+ gActiveDictionaries = new Set(languages);
+
+ // Notify compose WebExtension API about changed dictionaries.
+ window.dispatchEvent(
+ new CustomEvent("active-dictionaries-changed", {
+ detail: languages.join(","),
+ })
+ );
+}
+
+/**
+ * Change the language of the composition and if we are using inline
+ * spell check, recheck the message with the new dictionary.
+ *
+ * @param {Event} event - Event of selecting an item in the spelling button
+ * menulist popup.
+ */
+function ChangeLanguage(event) {
+ let curLangs = new Set(gActiveDictionaries);
+ if (curLangs.has(event.target.value)) {
+ curLangs.delete(event.target.value);
+ } else {
+ curLangs.add(event.target.value);
+ }
+ ComposeChangeLanguage(Array.from(curLangs));
+ event.stopPropagation();
+}
+
+/**
+ * Update the active dictionaries in the status bar.
+ *
+ * @param {string[]} dictionaries
+ */
+async function updateLanguageInStatusBar(dictionaries) {
+ // HACK: calling sortDictionaryList (in InitLanguageMenu) may fail the first
+ // time due to synchronous loading of the .ftl files. If we load the files
+ // and wait for a known value asynchronously, no such failure will happen.
+ await new Localization([
+ "toolkit/intl/languageNames.ftl",
+ "toolkit/intl/regionNames.ftl",
+ ]).formatValue("language-name-en");
+
+ InitLanguageMenu();
+ let languageMenuList = document.getElementById("languageMenuList");
+ let languageStatusButton = document.getElementById("languageStatusButton");
+ if (!languageMenuList || !languageStatusButton) {
+ return;
+ }
+
+ if (!dictionaries) {
+ dictionaries = Array.from(gActiveDictionaries);
+ }
+ let listFormat = new Intl.ListFormat(undefined, {
+ type: "conjunction",
+ style: "short",
+ });
+ let languages = [];
+ let item = languageMenuList.firstElementChild;
+
+ // No status display, if there is only one or no spelling dictionary available.
+ if (languageMenuList.childElementCount <= 3) {
+ languageStatusButton.hidden = true;
+ languageStatusButton.textContent = "";
+ return;
+ }
+
+ languageStatusButton.hidden = false;
+ while (item) {
+ if (item.tagName.toLowerCase() === "menuseparator") {
+ break;
+ }
+ if (dictionaries.includes(item.getAttribute("value"))) {
+ languages.push(item.getAttribute("label"));
+ }
+ item = item.nextElementSibling;
+ }
+ if (languages.length > 0) {
+ languageStatusButton.textContent = listFormat.format(languages);
+ } else {
+ languageStatusButton.textContent = listFormat.format(dictionaries);
+ }
+}
+
+/**
+ * Toggle Return Receipt (Disposition-Notification-To: header).
+ *
+ * @param {boolean} [forcedState] - Forced state to use for returnReceipt.
+ * If not set, the current state will be toggled.
+ */
+function ToggleReturnReceipt(forcedState) {
+ let msgCompFields = gMsgCompose.compFields;
+ if (!msgCompFields) {
+ return;
+ }
+ if (forcedState === undefined) {
+ msgCompFields.returnReceipt = !msgCompFields.returnReceipt;
+ gReceiptOptionChanged = true;
+ } else {
+ if (msgCompFields.returnReceipt != forcedState) {
+ gReceiptOptionChanged = true;
+ }
+ msgCompFields.returnReceipt = forcedState;
+ }
+ for (let item of document.querySelectorAll(`menuitem[command="cmd_toggleReturnReceipt"],
+ toolbarbutton[command="cmd_toggleReturnReceipt"]`)) {
+ item.setAttribute("checked", msgCompFields.returnReceipt);
+ }
+}
+
+function ToggleDSN(target) {
+ let msgCompFields = gMsgCompose.compFields;
+ if (msgCompFields) {
+ msgCompFields.DSN = !msgCompFields.DSN;
+ target.setAttribute("checked", msgCompFields.DSN);
+ gDSNOptionChanged = true;
+ }
+}
+
+function ToggleAttachVCard(target) {
+ var msgCompFields = gMsgCompose.compFields;
+ if (msgCompFields) {
+ msgCompFields.attachVCard = !msgCompFields.attachVCard;
+ target.setAttribute("checked", msgCompFields.attachVCard);
+ gAttachVCardOptionChanged = true;
+ }
+}
+
+/**
+ * Toggles or sets the status of manual Attachment Reminder, i.e. whether
+ * the user will get the "Attachment Reminder" alert before sending or not.
+ * Toggles checkmark on "Remind me later" menuitem and internal
+ * gManualAttachmentReminder flag accordingly.
+ *
+ * @param aState (optional) true = activate reminder.
+ * false = deactivate reminder.
+ * (default) = toggle reminder state.
+ */
+function toggleAttachmentReminder(aState = !gManualAttachmentReminder) {
+ gManualAttachmentReminder = aState;
+ document.getElementById("cmd_remindLater").setAttribute("checked", aState);
+ gMsgCompose.compFields.attachmentReminder = aState;
+
+ // If we enabled manual reminder, the reminder can't be turned off.
+ if (aState) {
+ gDisableAttachmentReminder = false;
+ }
+
+ manageAttachmentNotification(false);
+}
+
+/**
+ * Triggers or removes the CSS animation for the counter of newly uploaded
+ * attachments.
+ */
+function toggleAttachmentAnimation() {
+ gAttachmentCounter.classList.toggle("is_animating");
+}
+
+function FillIdentityList(menulist) {
+ let accounts = FolderUtils.allAccountsSorted(true);
+
+ let accountHadSeparator = false;
+ let firstAccountWithIdentities = true;
+ for (let account of accounts) {
+ let identities = account.identities;
+
+ if (identities.length == 0) {
+ continue;
+ }
+
+ let needSeparator = identities.length > 1;
+ if (needSeparator || accountHadSeparator) {
+ // Separate identities from this account from the previous
+ // account's identities if there is more than 1 in the current
+ // or previous account.
+ if (!firstAccountWithIdentities) {
+ // only if this is not the first account shown
+ let separator = document.createXULElement("menuseparator");
+ menulist.menupopup.appendChild(separator);
+ }
+ accountHadSeparator = needSeparator;
+ }
+ firstAccountWithIdentities = false;
+
+ for (let i = 0; i < identities.length; i++) {
+ let identity = identities[i];
+ let item = menulist.appendItem(
+ identity.identityName,
+ identity.fullAddress,
+ account.incomingServer.prettyName
+ );
+ item.setAttribute("identitykey", identity.key);
+ item.setAttribute("accountkey", account.key);
+ if (i == 0) {
+ // Mark the first identity as default.
+ item.setAttribute("default", "true");
+ }
+ // Create the menuitem description and add it after the last label in the
+ // menuitem internals.
+ let desc = document.createXULElement("label");
+ desc.value = item.getAttribute("description");
+ desc.classList.add("menu-description");
+ desc.setAttribute("crop", "end");
+ item.querySelector("label:last-child").after(desc);
+ }
+ }
+
+ menulist.menupopup.appendChild(document.createXULElement("menuseparator"));
+ menulist.menupopup
+ .appendChild(document.createXULElement("menuitem"))
+ .setAttribute("command", "cmd_customizeFromAddress");
+}
+
+function getCurrentAccountKey() {
+ // Get the account's key.
+ let identityList = document.getElementById("msgIdentity");
+ return identityList.getAttribute("accountkey");
+}
+
+function getCurrentIdentityKey() {
+ // Get the identity key.
+ return gCurrentIdentity.key;
+}
+
+function AdjustFocus() {
+ // If is NNTP account, check the newsgroup field.
+ let account = MailServices.accounts.getAccount(getCurrentAccountKey());
+ let accountType = account.incomingServer.type;
+
+ let element =
+ accountType == "nntp"
+ ? document.getElementById("newsgroupsAddrContainer")
+ : document.getElementById("toAddrContainer");
+
+ // Focus on the recipient input field if no pills are present.
+ if (element.querySelectorAll("mail-address-pill").length == 0) {
+ element.querySelector(".address-row-input").focus();
+ return;
+ }
+
+ // Focus subject if empty.
+ element = document.getElementById("msgSubject");
+ if (element.value == "") {
+ element.focus();
+ return;
+ }
+
+ // Focus message body.
+ focusMsgBody();
+}
+
+/**
+ * Set the compose window title with flavors (Write | Print Preview).
+ *
+ * @param isPrintPreview (optional) true: Set title for 'Print Preview' window.
+ * false: Set title for 'Write' window (default).
+ */
+function SetComposeWindowTitle(isPrintPreview = false) {
+ let aStringName = isPrintPreview
+ ? "windowTitlePrintPreview"
+ : "windowTitleWrite";
+ let subject =
+ document.getElementById("msgSubject").value.trim() ||
+ getComposeBundle().getString("defaultSubject");
+ let brandBundle = document.getElementById("brandBundle");
+ let brandShortName = brandBundle.getString("brandShortName");
+ let newTitle = getComposeBundle().getFormattedString(aStringName, [
+ subject,
+ brandShortName,
+ ]);
+ document.title = newTitle;
+ if (AppConstants.platform == "macosx") {
+ document.getElementById("titlebar-title-label").value = newTitle;
+ }
+}
+
+// Check for changes to document and allow saving before closing
+// This is hooked up to the OS's window close widget (e.g., "X" for Windows)
+function ComposeCanClose() {
+ // No open compose window?
+ if (!gMsgCompose) {
+ return true;
+ }
+
+ // Do this early, so ldap sessions have a better chance to
+ // cleanup after themselves.
+ if (gSendOperationInProgress || gSaveOperationInProgress) {
+ let result;
+
+ let brandBundle = document.getElementById("brandBundle");
+ let brandShortName = brandBundle.getString("brandShortName");
+ let promptTitle = gSendOperationInProgress
+ ? getComposeBundle().getString("quitComposeWindowTitle")
+ : getComposeBundle().getString("quitComposeWindowSaveTitle");
+ let promptMsg = gSendOperationInProgress
+ ? getComposeBundle().getFormattedString(
+ "quitComposeWindowMessage2",
+ [brandShortName],
+ 1
+ )
+ : getComposeBundle().getFormattedString(
+ "quitComposeWindowSaveMessage",
+ [brandShortName],
+ 1
+ );
+ let quitButtonLabel = getComposeBundle().getString(
+ "quitComposeWindowQuitButtonLabel2"
+ );
+ let waitButtonLabel = getComposeBundle().getString(
+ "quitComposeWindowWaitButtonLabel2"
+ );
+
+ result = Services.prompt.confirmEx(
+ window,
+ promptTitle,
+ promptMsg,
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1,
+ waitButtonLabel,
+ quitButtonLabel,
+ null,
+ null,
+ { value: 0 }
+ );
+
+ if (result == 1) {
+ gMsgCompose.abort();
+ return true;
+ }
+ return false;
+ }
+
+ // Returns FALSE only if user cancels save action
+ if (
+ gContentChanged ||
+ gMsgCompose.bodyModified ||
+ gAutoSaveKickedIn ||
+ gReceiptOptionChanged ||
+ gDSNOptionChanged
+ ) {
+ // call window.focus, since we need to pop up a dialog
+ // and therefore need to be visible (to prevent user confusion)
+ window.focus();
+ let draftFolderURI = gCurrentIdentity.draftFolder;
+ let draftFolderName =
+ MailUtils.getOrCreateFolder(draftFolderURI).prettyName;
+ let result = Services.prompt.confirmEx(
+ window,
+ getComposeBundle().getString("saveDlogTitle"),
+ getComposeBundle().getFormattedString("saveDlogMessages3", [
+ draftFolderName,
+ ]),
+ Services.prompt.BUTTON_TITLE_SAVE * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_2,
+ null,
+ null,
+ getComposeBundle().getString("discardButtonLabel"),
+ null,
+ { value: 0 }
+ );
+ switch (result) {
+ case 0: // Save
+ // Since we're going to save the message, we tell toolkit that
+ // the close command failed, by returning false, and then
+ // we close the window ourselves after the save is done.
+ gCloseWindowAfterSave = true;
+ // We catch the exception because we need to tell toolkit that it
+ // shouldn't close the window, because we're going to close it
+ // ourselves. If we don't tell toolkit that, and then close the window
+ // ourselves, the toolkit code that keeps track of the open windows
+ // gets off by one and the app can close unexpectedly on os's that
+ // shutdown the app when the last window is closed.
+ GenericSendMessage(Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft).catch(
+ console.error
+ );
+ return false;
+ case 1: // Cancel
+ return false;
+ case 2: // Don't Save
+ // don't delete the draft if we didn't start off editing a draft
+ // and the user hasn't explicitly saved it.
+ if (!gEditingDraft && gAutoSaveKickedIn) {
+ RemoveDraft();
+ }
+ // Remove auto-saved draft created during "edit template".
+ if (gMsgCompose.compFields.templateId && gAutoSaveKickedIn) {
+ RemoveDraft();
+ }
+ break;
+ }
+ }
+
+ return true;
+}
+
+function RemoveDraft() {
+ try {
+ var draftUri = gMsgCompose.compFields.draftId;
+ var msgKey = draftUri.substr(draftUri.indexOf("#") + 1);
+ let folder = MailUtils.getExistingFolder(gMsgCompose.savedFolderURI);
+ if (!folder) {
+ return;
+ }
+ try {
+ if (folder.getFlag(Ci.nsMsgFolderFlags.Drafts)) {
+ let msgHdr = folder.GetMessageHeader(msgKey);
+ folder.deleteMessages([msgHdr], null, true, false, null, false);
+ }
+ } catch (ex) {
+ // couldn't find header - perhaps an imap folder.
+ var imapFolder = folder.QueryInterface(Ci.nsIMsgImapMailFolder);
+ if (imapFolder) {
+ imapFolder.storeImapFlags(
+ Ci.nsMsgFolderFlags.Expunged,
+ true,
+ [msgKey],
+ null
+ );
+ }
+ }
+ } catch (ex) {}
+}
+
+function SetContentAndBodyAsUnmodified() {
+ gMsgCompose.bodyModified = false;
+ gContentChanged = false;
+}
+
+function MsgComposeCloseWindow() {
+ if (gMsgCompose) {
+ gMsgCompose.CloseWindow();
+ } else {
+ window.close();
+ }
+}
+
+function GetLastAttachDirectory() {
+ var lastDirectory;
+
+ try {
+ lastDirectory = Services.prefs.getComplexValue(
+ kComposeAttachDirPrefName,
+ Ci.nsIFile
+ );
+ } catch (ex) {
+ // this will fail the first time we attach a file
+ // as we won't have a pref value.
+ lastDirectory = null;
+ }
+
+ return lastDirectory;
+}
+
+// attachedLocalFile must be a nsIFile
+function SetLastAttachDirectory(attachedLocalFile) {
+ try {
+ let file = attachedLocalFile.QueryInterface(Ci.nsIFile);
+ let parent = file.parent.QueryInterface(Ci.nsIFile);
+
+ Services.prefs.setComplexValue(
+ kComposeAttachDirPrefName,
+ Ci.nsIFile,
+ parent
+ );
+ } catch (ex) {
+ dump("error: SetLastAttachDirectory failed: " + ex + "\n");
+ }
+}
+
+function AttachFile() {
+ if (gAttachmentBucket.itemCount) {
+ // If there are existing attachments already, restore attachment pane before
+ // showing the file picker so that user can see them while adding more.
+ toggleAttachmentPane("show");
+ }
+
+ // Get file using nsIFilePicker and convert to URL
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(
+ window,
+ getComposeBundle().getString("chooseFileToAttach"),
+ Ci.nsIFilePicker.modeOpenMultiple
+ );
+
+ let lastDirectory = GetLastAttachDirectory();
+ if (lastDirectory) {
+ fp.displayDirectory = lastDirectory;
+ }
+
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.files) {
+ return;
+ }
+
+ let file;
+ let attachments = [];
+
+ for (file of [...fp.files]) {
+ attachments.push(FileToAttachment(file));
+ }
+
+ AddAttachments(attachments);
+ SetLastAttachDirectory(file);
+ });
+}
+
+/**
+ * Convert an nsIFile instance into an nsIMsgAttachment.
+ *
+ * @param file the nsIFile
+ * @returns an attachment pointing to the file
+ */
+function FileToAttachment(file) {
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+
+ attachment.url = fileHandler.getURLSpecFromActualFile(file);
+ attachment.size = file.fileSize;
+ return attachment;
+}
+
+async function messageAttachmentToFile(attachment) {
+ let pathTempDir = PathUtils.join(
+ PathUtils.tempDir,
+ "pid-" + Services.appinfo.processID
+ );
+ await IOUtils.makeDirectory(pathTempDir, { permissions: 0o700 });
+ let pathTempFile = await IOUtils.createUniqueFile(
+ pathTempDir,
+ attachment.name.replaceAll(/[/:*?\"<>|]/g, "_"),
+ 0o600
+ );
+ let tempFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ tempFile.initWithPath(pathTempFile);
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+
+ let service = MailServices.messageServiceFromURI(attachment.url);
+ let bytes = await new Promise((resolve, reject) => {
+ let streamlistener = {
+ _data: [],
+ _stream: null,
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ if (!this._stream) {
+ this._stream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ this._stream.init(aInputStream);
+ }
+ this._data.push(this._stream.read(aCount));
+ },
+ onStartRequest() {},
+ onStopRequest(aRequest, aStatus) {
+ if (aStatus == Cr.NS_OK) {
+ resolve(this._data.join(""));
+ } else {
+ console.error(aStatus);
+ reject();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+ };
+
+ service.streamMessage(
+ attachment.url,
+ streamlistener,
+ null, // aMsgWindow
+ null, // aUrlListener
+ false, // aConvertData
+ "" //aAdditionalHeader
+ );
+ });
+ await IOUtils.write(
+ pathTempFile,
+ lazy.MailStringUtils.byteStringToUint8Array(bytes)
+ );
+ return tempFile;
+}
+
+/**
+ * Add a list of attachment objects as attachments. The attachment URLs must
+ * be set.
+ *
+ * @param {nsIMsgAttachment[]} aAttachments - Objects to add as attachments.
+ * @param {boolean} [aContentChanged=true] - Optional value to assign gContentChanged
+ * after adding attachments.
+ */
+async function AddAttachments(aAttachments, aContentChanged = true) {
+ let addedAttachments = [];
+ let items = [];
+
+ for (let attachment of aAttachments) {
+ if (!attachment?.url || DuplicateFileAlreadyAttached(attachment)) {
+ continue;
+ }
+
+ if (!attachment.name) {
+ attachment.name = gMsgCompose.AttachmentPrettyName(attachment.url, null);
+ }
+
+ // For security reasons, don't allow *-message:// uris to leak out.
+ // We don't want to reveal the .slt path (for mailbox://), or the username
+ // or hostname.
+ // Don't allow file or mail/news protocol uris to leak out either.
+ if (
+ /^mailbox-message:|^imap-message:|^news-message:/i.test(attachment.name)
+ ) {
+ attachment.name = getComposeBundle().getString(
+ "messageAttachmentSafeName"
+ );
+ } else if (/^file:|^mailbox:|^imap:|^s?news:/i.test(attachment.name)) {
+ attachment.name = getComposeBundle().getString("partAttachmentSafeName");
+ }
+
+ // Create temporary files for message attachments.
+ if (
+ /^mailbox-message:|^imap-message:|^news-message:/i.test(attachment.url)
+ ) {
+ try {
+ let messageFile = await messageAttachmentToFile(attachment);
+ // Store the original mailbox:// url in contentLocation.
+ attachment.contentLocation = attachment.url;
+ attachment.url = Services.io.newFileURI(messageFile).spec;
+ } catch (ex) {
+ console.error(
+ `Could not save message attachment ${attachment.url} as file: ${ex}`
+ );
+ }
+ }
+
+ if (
+ attachment.msgUri &&
+ /^mailbox-message:|^imap-message:|^news-message:/i.test(
+ attachment.msgUri
+ ) &&
+ attachment.url &&
+ /^mailbox:|^imap:|^s?news:/i.test(attachment.url)
+ ) {
+ // This is an attachment of another message, create a temporary file and
+ // update the url.
+ let pathTempDir = PathUtils.join(
+ PathUtils.tempDir,
+ "pid-" + Services.appinfo.processID
+ );
+ await IOUtils.makeDirectory(pathTempDir, { permissions: 0o700 });
+ let tempDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ tempDir.initWithPath(pathTempDir);
+
+ let tempFile = gMessenger.saveAttachmentToFolder(
+ attachment.contentType,
+ attachment.url,
+ encodeURIComponent(attachment.name),
+ attachment.msgUri,
+ tempDir
+ );
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+ // Store the original mailbox:// url in contentLocation.
+ attachment.contentLocation = attachment.url;
+ attachment.url = Services.io.newFileURI(tempFile).spec;
+ }
+
+ let item = gAttachmentBucket.appendItem(attachment);
+ addedAttachments.push(attachment);
+
+ let tooltiptext;
+ try {
+ tooltiptext = decodeURI(attachment.url);
+ } catch {
+ tooltiptext = attachment.url;
+ }
+ item.setAttribute("tooltiptext", tooltiptext);
+ item.addEventListener("command", OpenSelectedAttachment);
+ items.push(item);
+ }
+
+ if (addedAttachments.length > 0) {
+ // Trigger a visual feedback to let the user know how many attachments have
+ // been added.
+ gAttachmentCounter.textContent = `+${addedAttachments.length}`;
+ toggleAttachmentAnimation();
+
+ // Move the focus on the last attached file so the user can see a visual
+ // feedback of what was added.
+ gAttachmentBucket.selectedIndex = gAttachmentBucket.getIndexOfItem(
+ items[items.length - 1]
+ );
+
+ // Ensure the selected item is visible and if not the box will scroll to it.
+ gAttachmentBucket.ensureIndexIsVisible(gAttachmentBucket.selectedIndex);
+
+ AttachmentsChanged("show", aContentChanged);
+ dispatchAttachmentBucketEvent("attachments-added", addedAttachments);
+
+ // Set min height for the attachment bucket.
+ if (!gAttachmentBucket.style.minHeight) {
+ // Min height is the height of the first child plus padding and border.
+ // Note: we assume the computed styles have px values.
+ let bucketStyle = getComputedStyle(gAttachmentBucket);
+ let childStyle = getComputedStyle(gAttachmentBucket.firstChild);
+ let minHeight =
+ gAttachmentBucket.firstChild.getBoundingClientRect().height +
+ parseFloat(childStyle.marginBlockStart) +
+ parseFloat(childStyle.marginBlockEnd) +
+ parseFloat(bucketStyle.paddingBlockStart) +
+ parseFloat(bucketStyle.paddingBlockEnd) +
+ parseFloat(bucketStyle.borderBlockStartWidth) +
+ parseFloat(bucketStyle.borderBlockEndWidth);
+ gAttachmentBucket.style.minHeight = `${minHeight}px`;
+ }
+ }
+
+ // Always show the attachment pane if we have any attachment, to prevent
+ // keeping the panel collapsed when the user interacts with the attachment
+ // button.
+ if (gAttachmentBucket.itemCount) {
+ toggleAttachmentPane("show");
+ }
+
+ return items;
+}
+
+/**
+ * Returns a sorted-by-index, "non-live" array of attachment list items.
+ *
+ * @param aAscending {boolean}: true (default): sort return array ascending
+ * false : sort return array descending
+ * @param aSelectedOnly {boolean}: true: return array of selected items only.
+ * false (default): return array of all items.
+ *
+ * @returns {Array} an array of (all | selected) listItem elements in
+ * attachmentBucket listbox, "non-live" and sorted by their index
+ * in the list; [] if there are (no | no selected) attachments.
+ */
+function attachmentsGetSortedArray(aAscending = true, aSelectedOnly = false) {
+ let listItems;
+
+ if (aSelectedOnly) {
+ // Selected attachments only.
+ if (!gAttachmentBucket.selectedCount) {
+ return [];
+ }
+
+ // gAttachmentBucket.selectedItems is a "live" and "unordered" node list
+ // (items get added in the order they were added to the selection). But we
+ // want a stable ("non-live") array of selected items, sorted by their index
+ // in the list.
+ listItems = [...gAttachmentBucket.selectedItems];
+ } else {
+ // All attachments.
+ if (!gAttachmentBucket.itemCount) {
+ return [];
+ }
+
+ listItems = [...gAttachmentBucket.itemChildren];
+ }
+
+ if (aAscending) {
+ listItems.sort(
+ (a, b) =>
+ gAttachmentBucket.getIndexOfItem(a) -
+ gAttachmentBucket.getIndexOfItem(b)
+ );
+ } else {
+ // descending
+ listItems.sort(
+ (a, b) =>
+ gAttachmentBucket.getIndexOfItem(b) -
+ gAttachmentBucket.getIndexOfItem(a)
+ );
+ }
+ return listItems;
+}
+
+/**
+ * Returns a sorted-by-index, "non-live" array of selected attachment list items.
+ *
+ * @param aAscending {boolean}: true (default): sort return array ascending
+ * false : sort return array descending
+ * @returns {Array} an array of selected listitem elements in attachmentBucket
+ * listbox, "non-live" and sorted by their index in the list;
+ * [] if no attachments selected
+ */
+function attachmentsSelectionGetSortedArray(aAscending = true) {
+ return attachmentsGetSortedArray(aAscending, true);
+}
+
+/**
+ * Return true if the selected attachment items are a coherent block in the list,
+ * otherwise false.
+ *
+ * @param aListPosition (optional) - "top" : Return true only if the block is
+ * at the top of the list.
+ * "bottom": Return true only if the block is
+ * at the bottom of the list.
+ * @returns {boolean} true : The selected attachment items are a coherent block
+ * (at the list edge if/as specified by 'aListPosition'),
+ * or only 1 item selected.
+ * false: The selected attachment items are NOT a coherent block
+ * (at the list edge if/as specified by 'aListPosition'),
+ * or no attachments selected, or no attachments,
+ * or no attachmentBucket.
+ */
+function attachmentsSelectionIsBlock(aListPosition) {
+ if (!gAttachmentBucket.selectedCount) {
+ // No attachments selected, no attachments, or no attachmentBucket.
+ return false;
+ }
+
+ let selItems = attachmentsSelectionGetSortedArray();
+ let indexFirstSelAttachment = gAttachmentBucket.getIndexOfItem(selItems[0]);
+ let indexLastSelAttachment = gAttachmentBucket.getIndexOfItem(
+ selItems[gAttachmentBucket.selectedCount - 1]
+ );
+ let isBlock =
+ indexFirstSelAttachment ==
+ indexLastSelAttachment + 1 - gAttachmentBucket.selectedCount;
+
+ switch (aListPosition) {
+ case "top":
+ // True if selection is a coherent block at the top of the list.
+ return indexFirstSelAttachment == 0 && isBlock;
+ case "bottom":
+ // True if selection is a coherent block at the bottom of the list.
+ return (
+ indexLastSelAttachment == gAttachmentBucket.itemCount - 1 && isBlock
+ );
+ default:
+ // True if selection is a coherent block.
+ return isBlock;
+ }
+}
+
+function AttachPage() {
+ let result = { value: "http://" };
+ if (
+ Services.prompt.prompt(
+ window,
+ getComposeBundle().getString("attachPageDlogTitle"),
+ getComposeBundle().getString("attachPageDlogMessage"),
+ result,
+ null,
+ { value: 0 }
+ )
+ ) {
+ if (result.value.length <= "http://".length) {
+ // Nothing filled, just show the dialog again.
+ AttachPage();
+ return;
+ }
+
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ attachment.url = result.value;
+ AddAttachments([attachment]);
+ }
+}
+
+/**
+ * Check if the given attachment already exists in the attachment bucket.
+ *
+ * @param nsIMsgAttachment - the attachment to check
+ * @returns true if the attachment is already attached
+ */
+function DuplicateFileAlreadyAttached(attachment) {
+ for (let item of gAttachmentBucket.itemChildren) {
+ if (item.attachment && item.attachment.url) {
+ if (item.attachment.url == attachment.url) {
+ return true;
+ }
+ // Also check, if an attachment has been saved as a temporary file and its
+ // original url is a match.
+ if (
+ item.attachment.contentLocation &&
+ item.attachment.contentLocation == attachment.url
+ ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+function Attachments2CompFields(compFields) {
+ // First, we need to clear all attachment in the compose fields.
+ compFields.removeAttachments();
+
+ for (let item of gAttachmentBucket.itemChildren) {
+ if (item.attachment) {
+ compFields.addAttachment(item.attachment);
+ }
+ }
+}
+
+async function RemoveAllAttachments() {
+ // Ensure that attachment pane is shown before removing all attachments.
+ toggleAttachmentPane("show");
+
+ if (!gAttachmentBucket.itemCount) {
+ return;
+ }
+
+ await RemoveAttachments(gAttachmentBucket.itemChildren);
+}
+
+/**
+ * Show or hide the attachment pane after updating its header bar information
+ * (number and total file size of attachments) and tooltip.
+ *
+ * @param aShowBucket {Boolean} true: show the attachment pane
+ * false (or omitted): hide the attachment pane
+ */
+function UpdateAttachmentBucket(aShowBucket) {
+ updateAttachmentPane(aShowBucket ? "show" : "hide");
+}
+
+/**
+ * Update the header bar information (number and total file size of attachments)
+ * and tooltip of attachment pane, then (optionally) show or hide the pane.
+ *
+ * @param aShowPane {string} "show": show the attachment pane
+ * "hide": hide the attachment pane
+ * omitted: just update without changing pane visibility
+ */
+function updateAttachmentPane(aShowPane) {
+ let count = gAttachmentBucket.itemCount;
+
+ document.l10n.setAttributes(
+ document.getElementById("attachmentBucketCount"),
+ "attachment-bucket-count-value",
+ {
+ count,
+ }
+ );
+
+ let attachmentsSize = 0;
+ for (let item of gAttachmentBucket.itemChildren) {
+ gAttachmentBucket.invalidateItem(item);
+ attachmentsSize += item.cloudHtmlFileSize
+ ? item.cloudHtmlFileSize
+ : item.attachment.size;
+ }
+
+ document.getElementById("attachmentBucketSize").textContent =
+ count > 0 ? gMessenger.formatFileSize(attachmentsSize) : "";
+
+ document
+ .getElementById("composeContentBox")
+ .classList.toggle("attachment-area-hidden", !count);
+
+ attachmentBucketUpdateTooltips();
+
+ // If aShowPane argument is omitted, it's just updating, so we're done.
+ if (aShowPane === undefined) {
+ return;
+ }
+
+ // Otherwise, show or hide the panel per aShowPane argument.
+ toggleAttachmentPane(aShowPane);
+}
+
+async function RemoveSelectedAttachment() {
+ if (!gAttachmentBucket.selectedCount) {
+ return;
+ }
+
+ await RemoveAttachments(gAttachmentBucket.selectedItems);
+}
+
+/**
+ * Removes the provided attachmentItems from the composer and deletes all
+ * associated cloud files.
+ *
+ * Note: Cloud file delete errors are not considered to be fatal errors. They do
+ * not prevent the attachments from being removed from the composer. Such
+ * errors are caught and logged to the console.
+ *
+ * @param {DOMNode[]} items - AttachmentItems to be removed
+ */
+async function RemoveAttachments(items) {
+ // Remember the current focus index so we can try to restore it when done.
+ let focusIndex = gAttachmentBucket.currentIndex;
+
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let removedAttachments = [];
+
+ let promises = [];
+ for (let i = items.length - 1; i >= 0; i--) {
+ let item = items[i];
+
+ if (item.attachment.sendViaCloud && item.cloudFileAccount) {
+ if (item.uploading) {
+ let file = fileHandler.getFileFromURLSpec(item.attachment.url);
+ promises.push(
+ item.uploading
+ .cancelFileUpload(window, file)
+ .catch(ex => console.warn(ex.message))
+ );
+ } else {
+ promises.push(
+ item.cloudFileAccount
+ .deleteFile(window, item.cloudFileUpload.id)
+ .catch(ex => console.warn(ex.message))
+ );
+ }
+ }
+
+ removedAttachments.push(item.attachment);
+ // Let's release the attachment object held by the node else it won't go
+ // away until the window is destroyed
+ item.attachment = null;
+ item.remove();
+ }
+
+ if (removedAttachments.length > 0) {
+ // Bug 1661507 workaround: Force update of selectedCount and selectedItem,
+ // both wrong after item removal, to avoid confusion for listening command
+ // controllers.
+ gAttachmentBucket.clearSelection();
+
+ AttachmentsChanged();
+ dispatchAttachmentBucketEvent("attachments-removed", removedAttachments);
+ }
+
+ // Collapse the attachment container if all the items have been deleted.
+ if (!gAttachmentBucket.itemCount) {
+ toggleAttachmentPane("hide");
+ } else {
+ // Try to restore the original focused item or somewhere close by.
+ gAttachmentBucket.currentIndex =
+ focusIndex < gAttachmentBucket.itemCount
+ ? focusIndex
+ : gAttachmentBucket.itemCount - 1;
+ }
+
+ await Promise.all(promises);
+}
+
+async function RenameSelectedAttachment() {
+ if (gAttachmentBucket.selectedItems.length != 1) {
+ // Not one attachment selected.
+ return;
+ }
+
+ let item = gAttachmentBucket.getSelectedItem(0);
+ let originalName = item.attachment.name;
+ let attachmentName = { value: originalName };
+ if (
+ Services.prompt.prompt(
+ window,
+ getComposeBundle().getString("renameAttachmentTitle"),
+ getComposeBundle().getString("renameAttachmentMessage"),
+ attachmentName,
+ null,
+ { value: 0 }
+ )
+ ) {
+ if (attachmentName.value == "" || attachmentName.value == originalName) {
+ // Name was not filled nor changed, bail out.
+ return;
+ }
+ try {
+ await UpdateAttachment(item, {
+ name: attachmentName.value,
+ relatedCloudFileUpload: item.CloudFileUpload,
+ });
+ } catch (ex) {
+ showLocalizedCloudFileAlert(ex);
+ }
+ }
+}
+
+/* eslint-disable complexity */
+/**
+ * Move selected attachment(s) within the attachment list.
+ *
+ * @param {string} aDirection - The direction in which to move the attachments.
+ * "left" : Move attachments left in the list.
+ * "right" : Move attachments right in the list.
+ * "top" : Move attachments to the top of the list.
+ * "bottom" : Move attachments to the bottom of the list.
+ * "bundleUp" : Move attachments together (upwards).
+ * "bundleDown": Move attachments together (downwards).
+ * "toggleSort": Sort attachments alphabetically (toggle).
+ */
+function moveSelectedAttachments(aDirection) {
+ // Command controllers will bail out if no or all attachments are selected,
+ // or if block selections can't be moved, or if other direction-specific
+ // adverse circumstances prevent the intended movement.
+ if (!aDirection) {
+ return;
+ }
+
+ // Ensure focus on gAttachmentBucket when we're coming from
+ // 'Reorder Attachments' panel.
+ gAttachmentBucket.focus();
+
+ // Get a sorted and "non-live" array of gAttachmentBucket.selectedItems.
+ let selItems = attachmentsSelectionGetSortedArray();
+
+ // In case of misspelled aDirection.
+ let visibleIndex = gAttachmentBucket.currentIndex;
+ // Keep track of the item we had focused originally. Deselect it though,
+ // since listbox gets confused if you move its focused item around.
+ let focusItem = gAttachmentBucket.currentItem;
+ gAttachmentBucket.currentItem = null;
+ let upwards;
+ let targetItem;
+
+ switch (aDirection) {
+ case "left":
+ case "right":
+ // Move selected attachments upwards/downwards.
+ upwards = aDirection == "left";
+ let blockItems = [];
+
+ for (let item of selItems) {
+ // Handle adjacent selected items en block, via blockItems array.
+ blockItems.push(item); // Add current selItem to blockItems.
+ let nextItem = item.nextElementSibling;
+ if (!nextItem || !nextItem.selected) {
+ // If current selItem is the last blockItem, check out its adjacent
+ // item in the intended direction to see if there's room for moving.
+ // Note that the block might contain one or more items.
+ let checkItem = upwards
+ ? blockItems[0].previousElementSibling
+ : nextItem;
+ // If block-adjacent checkItem exists (and is not selected because
+ // then it would be part of the block), we can move the block to the
+ // right position.
+ if (checkItem) {
+ targetItem = upwards
+ ? // Upwards: Insert block items before checkItem,
+ // i.e. before previousElementSibling of block.
+ checkItem
+ : // Downwards: Insert block items *after* checkItem,
+ // i.e. *before* nextElementSibling.nextElementSibling of block,
+ // which works according to spec even if that's null.
+ checkItem.nextElementSibling;
+ // Move current blockItems.
+ for (let blockItem of blockItems) {
+ gAttachmentBucket.insertBefore(blockItem, targetItem);
+ }
+ }
+ // Else if checkItem doesn't exist, the block is already at the edge
+ // of the list, so we can't move it in the intended direction.
+ blockItems.length = 0; // Either way, we're done with the current block.
+ }
+ // Else if current selItem is NOT the end of the current block, proceed:
+ // Add next selItem to the block and see if that's the end of the block.
+ } // Next selItem.
+
+ // Ensure helpful visibility of moved items (scroll into view if needed):
+ // If first item of selection is now at the top, first list item.
+ // Else if last item of selection is now at the bottom, last list item.
+ // Otherwise, let's see where we are going by ensuring visibility of the
+ // nearest unselected sibling of selection according to direction of move.
+ if (gAttachmentBucket.getIndexOfItem(selItems[0]) == 0) {
+ visibleIndex = 0;
+ } else if (
+ gAttachmentBucket.getIndexOfItem(selItems[selItems.length - 1]) ==
+ gAttachmentBucket.itemCount - 1
+ ) {
+ visibleIndex = gAttachmentBucket.itemCount - 1;
+ } else if (upwards) {
+ visibleIndex = gAttachmentBucket.getIndexOfItem(
+ selItems[0].previousElementSibling
+ );
+ } else {
+ visibleIndex = gAttachmentBucket.getIndexOfItem(
+ selItems[selItems.length - 1].nextElementSibling
+ );
+ }
+ break;
+
+ case "top":
+ case "bottom":
+ case "bundleUp":
+ case "bundleDown":
+ // Bundle selected attachments to top/bottom of the list or upwards/downwards.
+
+ upwards = ["top", "bundleUp"].includes(aDirection);
+ // Downwards: Reverse order of selItems so we can use the same algorithm.
+ if (!upwards) {
+ selItems.reverse();
+ }
+
+ if (["top", "bottom"].includes(aDirection)) {
+ let listEdgeItem = gAttachmentBucket.getItemAtIndex(
+ upwards ? 0 : gAttachmentBucket.itemCount - 1
+ );
+ let selEdgeItem = selItems[0];
+ if (selEdgeItem != listEdgeItem) {
+ // Top/Bottom: Move the first/last selected item to the edge of the list
+ // so that we always have an initial anchor target block in the right
+ // place, so we can use the same algorithm for top/bottom and
+ // inner bundling.
+ targetItem = upwards
+ ? // Upwards: Insert before first list item.
+ listEdgeItem
+ : // Downwards: Insert after last list item, i.e.
+ // *before* non-existing listEdgeItem.nextElementSibling,
+ // which is null. It works because it's a feature.
+ null;
+ gAttachmentBucket.insertBefore(selEdgeItem, targetItem);
+ }
+ }
+ // We now have a selected block (at least one item) at the target position.
+ // Let's find the end (inner edge) of that block and move only the
+ // remaining selected items to avoid unnecessary moves.
+ targetItem = null;
+ for (let item of selItems) {
+ if (targetItem) {
+ // We know where to move it, so move it!
+ gAttachmentBucket.insertBefore(item, targetItem);
+ if (!upwards) {
+ // Downwards: As selItems are reversed, and there's no insertAfter()
+ // method to insert *after* a stable target, we need to insert
+ // *before* the first item of the target block at target position,
+ // which is the current selItem which we've just moved onto the block.
+ targetItem = item;
+ }
+ } else {
+ // If there's no targetItem yet, find the inner edge of the target block.
+ let nextItem = upwards
+ ? item.nextElementSibling
+ : item.previousElementSibling;
+ if (!nextItem.selected) {
+ // If nextItem is not selected, current selItem is the inner edge of
+ // the initial anchor target block, so we can set targetItem.
+ targetItem = upwards
+ ? // Upwards: set stable targetItem.
+ nextItem
+ : // Downwards: set initial targetItem.
+ item;
+ }
+ // Else if nextItem is selected, it is still part of initial anchor
+ // target block, so just proceed to look for the edge of that block.
+ }
+ } // next selItem
+
+ // Ensure visibility of first/last selected item after the move.
+ visibleIndex = gAttachmentBucket.getIndexOfItem(selItems[0]);
+ break;
+
+ case "toggleSort":
+ // Sort the selected attachments alphabetically after moving them together.
+ // The command updater of cmd_sortAttachmentsToggle toggles the sorting
+ // direction based on the current sorting and block status of the selection.
+
+ let toggleCmd = document.getElementById("cmd_sortAttachmentsToggle");
+ let sortDirection =
+ toggleCmd.getAttribute("sortdirection") || "ascending";
+ let sortItems;
+ let sortSelection;
+
+ if (gAttachmentBucket.selectedCount > 1) {
+ // Sort selected attachments only.
+ sortSelection = true;
+ sortItems = selItems;
+ // Move selected attachments together before sorting as a block.
+ goDoCommand("cmd_moveAttachmentBundleUp");
+
+ // Find the end of the selected block to find our targetItem.
+ for (let item of selItems) {
+ let nextItem = item.nextElementSibling;
+ if (!nextItem || !nextItem.selected) {
+ // If there's no nextItem (block at list bottom), or nextItem is
+ // not selected, we've reached the end of the block.
+ // Set the block's nextElementSibling as targetItem and exit loop.
+ // Works by definition even if nextElementSibling aka nextItem is null.
+ targetItem = nextItem;
+ break;
+ }
+ // else if (nextItem && nextItem.selected), nextItem is still part of
+ // the block, so proceed with checking its nextElementSibling.
+ } // next selItem
+ } else {
+ // Sort all attachments.
+ sortSelection = false;
+ sortItems = attachmentsGetSortedArray();
+ targetItem = null; // Insert at the end of the list.
+ }
+ // Now let's sort our sortItems according to sortDirection.
+ if (sortDirection == "ascending") {
+ sortItems.sort((a, b) =>
+ a.attachment.name.localeCompare(b.attachment.name)
+ );
+ } else {
+ // "descending"
+ sortItems.sort((a, b) =>
+ b.attachment.name.localeCompare(a.attachment.name)
+ );
+ }
+
+ // Insert sortItems in new order before the nextElementSibling of the block.
+ for (let item of sortItems) {
+ gAttachmentBucket.insertBefore(item, targetItem);
+ }
+
+ if (sortSelection) {
+ // After sorting selection: Ensure visibility of first selected item.
+ visibleIndex = gAttachmentBucket.getIndexOfItem(selItems[0]);
+ } else {
+ // After sorting all items: Ensure visibility of selected item,
+ // otherwise first list item.
+ visibleIndex =
+ selItems.length == 1 ? gAttachmentBucket.selectedIndex : 0;
+ }
+ break;
+ } // end switch (aDirection)
+
+ // Restore original focus.
+ gAttachmentBucket.currentItem = focusItem;
+ // Ensure smart visibility of a relevant item according to direction.
+ gAttachmentBucket.ensureIndexIsVisible(visibleIndex);
+
+ // Moving selected items around does not trigger auto-updating of our command
+ // handlers, so we must do it now as the position of selected items has changed.
+ updateReorderAttachmentsItems();
+}
+/* eslint-enable complexity */
+
+/**
+ * Toggle attachment pane view state: show or hide it.
+ * If aAction parameter is omitted, toggle current view state.
+ *
+ * @param {string} [aAction = "toggle"] - "show": show attachment pane
+ * "hide": hide attachment pane
+ * "toggle": toggle attachment pane
+ */
+function toggleAttachmentPane(aAction = "toggle") {
+ let attachmentArea = document.getElementById("attachmentArea");
+
+ if (aAction == "toggle") {
+ // Interrupt if we don't have any attachment as we don't want nor need to
+ // show an empty container.
+ if (!gAttachmentBucket.itemCount) {
+ return;
+ }
+
+ if (attachmentArea.open && document.activeElement != gAttachmentBucket) {
+ // Interrupt and move the focus to the attachment pane if it's already
+ // visible but not currently focused.
+ moveFocusToAttachmentPane();
+ return;
+ }
+
+ // Toggle attachment pane.
+ attachmentArea.open = !attachmentArea.open;
+ } else {
+ attachmentArea.open = aAction != "hide";
+ }
+}
+
+/**
+ * Update the #attachmentArea according to its open state.
+ */
+function attachmentAreaOnToggle() {
+ let attachmentArea = document.getElementById("attachmentArea");
+ let bucketHasFocus = document.activeElement == gAttachmentBucket;
+ if (attachmentArea.open && !bucketHasFocus) {
+ moveFocusToAttachmentPane();
+ } else if (!attachmentArea.open && bucketHasFocus) {
+ // Move the focus to the message body only if the bucket was focused.
+ focusMsgBody();
+ }
+
+ // Make the splitter non-interactive whilst the bucket is hidden.
+ document
+ .getElementById("composeContentBox")
+ .classList.toggle("attachment-bucket-closed", !attachmentArea.open);
+
+ // Update the checkmark on menuitems hooked up with cmd_toggleAttachmentPane.
+ // Menuitem does not have .checked property nor .toggleAttribute(), sigh.
+ for (let menuitem of document.querySelectorAll(
+ 'menuitem[command="cmd_toggleAttachmentPane"]'
+ )) {
+ if (attachmentArea.open) {
+ menuitem.setAttribute("checked", "true");
+ continue;
+ }
+ menuitem.removeAttribute("checked");
+ }
+
+ // Update the title based on the collapsed status of the bucket.
+ document.l10n.setAttributes(
+ attachmentArea.querySelector("summary"),
+ attachmentArea.open ? "attachment-area-hide" : "attachment-area-show"
+ );
+}
+
+/**
+ * Ensure the focus is properly moved to the Attachment Bucket, and to the first
+ * available item if present.
+ */
+function moveFocusToAttachmentPane() {
+ gAttachmentBucket.focus();
+
+ if (gAttachmentBucket.currentItem) {
+ gAttachmentBucket.ensureElementIsVisible(gAttachmentBucket.currentItem);
+ }
+}
+
+function showReorderAttachmentsPanel() {
+ // Ensure attachment pane visibility as it might be collapsed.
+ toggleAttachmentPane("show");
+ showPopupById(
+ "reorderAttachmentsPanel",
+ "attachmentBucket",
+ "after_start",
+ 15,
+ 0
+ );
+ // After the panel is shown, focus attachmentBucket so that keyboard
+ // operation for selecting and moving attachment items works; the panel
+ // helpfully presents the keyboard shortcuts for moving things around.
+ // Bucket focus is also required because the panel will only close with ESC
+ // or attachmentBucketOnBlur(), and that's because we're using noautohide as
+ // event.preventDefault() of onpopuphiding event fails when the panel
+ // is auto-hiding, but we don't want panel to hide when focus goes to bucket.
+ gAttachmentBucket.focus();
+}
+
+/**
+ * Returns a string representing the current sort order of selected attachment
+ * items by their names. We don't check if selected items form a coherent block
+ * or not; use attachmentsSelectionIsBlock() to check on that.
+ *
+ * @returns {string} "ascending" : Sort order is ascending.
+ * "descending": Sort order is descending.
+ * "equivalent": The names of all selected items are equivalent.
+ * "" : There's no sort order, or only 1 item selected,
+ * or no items selected, or no attachments,
+ * or no attachmentBucket.
+ */
+function attachmentsSelectionGetSortOrder() {
+ return attachmentsGetSortOrder(true);
+}
+
+/**
+ * Returns a string representing the current sort order of attachment items
+ * by their names.
+ *
+ * @param aSelectedOnly {boolean}: true: return sort order of selected items only.
+ * false (default): return sort order of all items.
+ *
+ * @returns {string} "ascending" : Sort order is ascending.
+ * "descending": Sort order is descending.
+ * "equivalent": The names of the items are equivalent.
+ * "" : There's no sort order, or no attachments,
+ * or no attachmentBucket; or (with aSelectedOnly),
+ * only 1 item selected, or no items selected.
+ */
+function attachmentsGetSortOrder(aSelectedOnly = false) {
+ let listItems;
+ if (aSelectedOnly) {
+ if (gAttachmentBucket.selectedCount <= 1) {
+ return "";
+ }
+
+ listItems = attachmentsSelectionGetSortedArray();
+ } else {
+ // aSelectedOnly == false
+ if (!gAttachmentBucket.itemCount) {
+ return "";
+ }
+
+ listItems = attachmentsGetSortedArray();
+ }
+
+ // We're comparing each item to the next item, so exclude the last item.
+ let listItems1 = listItems.slice(0, -1);
+ let someAscending;
+ let someDescending;
+
+ // Check if some adjacent items are sorted ascending.
+ someAscending = listItems1.some(
+ (item, index) =>
+ item.attachment.name.localeCompare(listItems[index + 1].attachment.name) <
+ 0
+ );
+
+ // Check if some adjacent items are sorted descending.
+ someDescending = listItems1.some(
+ (item, index) =>
+ item.attachment.name.localeCompare(listItems[index + 1].attachment.name) >
+ 0
+ );
+
+ // Unsorted (but not all equivalent in sort order)
+ if (someAscending && someDescending) {
+ return "";
+ }
+
+ if (someAscending && !someDescending) {
+ return "ascending";
+ }
+
+ if (someDescending && !someAscending) {
+ return "descending";
+ }
+
+ // No ascending pairs, no descending pairs, so all equivalent in sort order.
+ // if (!someAscending && !someDescending)
+ return "equivalent";
+}
+
+function reorderAttachmentsPanelOnPopupShowing() {
+ let panel = document.getElementById("reorderAttachmentsPanel");
+ let buttonsNodeList = panel.querySelectorAll(".panelButton");
+ let buttons = [...buttonsNodeList]; // convert NodeList to Array
+ // Let's add some pretty keyboard shortcuts to the buttons.
+ buttons.forEach(btn => {
+ if (btn.hasAttribute("key")) {
+ btn.setAttribute("prettykey", getPrettyKey(btn.getAttribute("key")));
+ }
+ });
+ // Focus attachment bucket to activate attachmentBucketController, which is
+ // required for updating the reorder commands.
+ gAttachmentBucket.focus();
+ // We're updating commands before showing the panel so that button states
+ // don't change after the panel is shown, and also because focus is still
+ // in attachment bucket right now, which is required for updating them.
+ updateReorderAttachmentsItems();
+}
+
+function attachmentHeaderContextOnPopupShowing() {
+ let initiallyShowItem = document.getElementById(
+ "attachmentHeaderContext_initiallyShowItem"
+ );
+
+ initiallyShowItem.setAttribute(
+ "checked",
+ Services.prefs.getBoolPref("mail.compose.show_attachment_pane")
+ );
+}
+
+function toggleInitiallyShowAttachmentPane(aMenuItem) {
+ Services.prefs.setBoolPref(
+ "mail.compose.show_attachment_pane",
+ aMenuItem.getAttribute("checked")
+ );
+}
+
+/**
+ * Handle blur event on attachment pane and control visibility of
+ * reorderAttachmentsPanel.
+ */
+function attachmentBucketOnBlur() {
+ let reorderAttachmentsPanel = document.getElementById(
+ "reorderAttachmentsPanel"
+ );
+ // If attachment pane has really lost focus, and if reorderAttachmentsPanel is
+ // not currently in the process of showing up, hide reorderAttachmentsPanel.
+ // Otherwise, keep attachments selected and the reorderAttachmentsPanel open
+ // when reordering and after renaming via dialog.
+ if (
+ document.activeElement.id != "attachmentBucket" &&
+ reorderAttachmentsPanel.state != "showing"
+ ) {
+ reorderAttachmentsPanel.hidePopup();
+ }
+}
+
+/**
+ * Handle the keypress on the attachment bucket.
+ *
+ * @param {Event} event - The keypress DOM Event.
+ */
+function attachmentBucketOnKeyPress(event) {
+ // Interrupt if the Alt modifier is pressed, meaning the user is reordering
+ // the list of attachments.
+ if (event.altKey) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Escape":
+ let reorderAttachmentsPanel = document.getElementById(
+ "reorderAttachmentsPanel"
+ );
+
+ // Close the reorderAttachmentsPanel if open and interrupt.
+ if (reorderAttachmentsPanel.state == "open") {
+ reorderAttachmentsPanel.hidePopup();
+ return;
+ }
+
+ if (gAttachmentBucket.itemCount) {
+ // Deselect selected items in a full bucket if any.
+ if (gAttachmentBucket.selectedCount) {
+ gAttachmentBucket.clearSelection();
+ return;
+ }
+
+ // Move the focus to the message body.
+ focusMsgBody();
+ return;
+ }
+
+ // Close an empty bucket.
+ toggleAttachmentPane("hide");
+ break;
+
+ case "Enter":
+ // Enter on empty bucket to add file attachments, convenience
+ // keyboard equivalent of single-click on bucket whitespace.
+ if (!gAttachmentBucket.itemCount) {
+ goDoCommand("cmd_attachFile");
+ }
+ break;
+
+ case "ArrowLeft":
+ gAttachmentBucket.moveByOffset(-1, !event.ctrlKey, event.shiftKey);
+ event.preventDefault();
+ break;
+
+ case "ArrowRight":
+ gAttachmentBucket.moveByOffset(1, !event.ctrlKey, event.shiftKey);
+ event.preventDefault();
+ break;
+
+ case "ArrowDown":
+ gAttachmentBucket.moveByOffset(
+ gAttachmentBucket._itemsPerRow(),
+ !event.ctrlKey,
+ event.shiftKey
+ );
+ event.preventDefault();
+ break;
+
+ case "ArrowUp":
+ gAttachmentBucket.moveByOffset(
+ -gAttachmentBucket._itemsPerRow(),
+ !event.ctrlKey,
+ event.shiftKey
+ );
+
+ event.preventDefault();
+ break;
+ }
+}
+
+function attachmentBucketOnClick(aEvent) {
+ // Handle click on attachment pane whitespace normally clear selection.
+ // If there are no attachments in the bucket, show 'Attach File(s)' dialog.
+ if (
+ aEvent.button == 0 &&
+ aEvent.target.getAttribute("is") == "attachment-list" &&
+ !aEvent.target.firstElementChild
+ ) {
+ goDoCommand("cmd_attachFile");
+ }
+}
+
+function attachmentBucketOnSelect() {
+ attachmentBucketUpdateTooltips();
+ updateAttachmentItems();
+}
+
+function attachmentBucketUpdateTooltips() {
+ // Attachment pane whitespace tooltip
+ if (gAttachmentBucket.selectedCount) {
+ gAttachmentBucket.tooltipText = getComposeBundle().getString(
+ "attachmentBucketClearSelectionTooltip"
+ );
+ } else {
+ gAttachmentBucket.tooltipText = getComposeBundle().getString(
+ "attachmentBucketAttachFilesTooltip"
+ );
+ }
+}
+
+function OpenSelectedAttachment() {
+ if (gAttachmentBucket.selectedItems.length != 1) {
+ return;
+ }
+ let attachment = gAttachmentBucket.getSelectedItem(0).attachment;
+ let attachmentUrl = attachment.url;
+
+ let messagePrefix = /^mailbox-message:|^imap-message:|^news-message:/i;
+ if (messagePrefix.test(attachmentUrl)) {
+ // we must be dealing with a forwarded attachment, treat this special
+ let msgHdr =
+ MailServices.messageServiceFromURI(attachmentUrl).messageURIToMsgHdr(
+ attachmentUrl
+ );
+ if (msgHdr) {
+ MailUtils.openMessageInNewWindow(msgHdr);
+ }
+ return;
+ }
+ if (
+ attachment.contentType == "application/pdf" ||
+ /\.pdf$/i.test(attachment.name)
+ ) {
+ // @see msgHdrView.js which has simililar opening functionality
+ let handlerInfo = gMIMEService.getFromTypeAndExtension(
+ attachment.contentType,
+ attachment.name.split(".").pop()
+ );
+ // Only open a new tab for pdfs if we are handling them internally.
+ if (
+ !handlerInfo.alwaysAskBeforeHandling &&
+ handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally
+ ) {
+ // Add the content type to avoid a "how do you want to open this?"
+ // dialog. The type may already be there, but that doesn't matter.
+ let url = attachment.url;
+ if (!url.includes("type=")) {
+ url += url.includes("?") ? "&" : "?";
+ url += "type=application/pdf";
+ }
+ let tabmail = Services.wm
+ .getMostRecentWindow("mail:3pane")
+ ?.document.getElementById("tabmail");
+ if (tabmail) {
+ tabmail.openTab("contentTab", {
+ url,
+ background: false,
+ linkHandler: "single-page",
+ });
+ tabmail.ownerGlobal.focus();
+ return;
+ }
+ // If no tabmail, open PDF same as other attachments.
+ }
+ }
+ let uri = Services.io.newURI(attachmentUrl);
+ let channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let uriLoader = Cc["@mozilla.org/uriloader;1"].getService(Ci.nsIURILoader);
+ uriLoader.openURI(channel, true, new nsAttachmentOpener());
+}
+
+function nsAttachmentOpener() {}
+
+nsAttachmentOpener.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIURIContentListener",
+ "nsIInterfaceRequestor",
+ ]),
+
+ doContent(contentType, isContentPreferred, request, contentHandler) {
+ // If we came here to display an attached message, make sure we provide a type.
+ if (/[?&]part=/i.test(request.URI.query)) {
+ let newQuery = request.URI.query + "&type=message/rfc822";
+ request.URI = request.URI.mutate().setQuery(newQuery).finalize();
+ }
+ let newHandler = Cc[
+ "@mozilla.org/uriloader/content-handler;1?type=application/x-message-display"
+ ].createInstance(Ci.nsIContentHandler);
+ newHandler.handleContent("application/x-message-display", this, request);
+ return true;
+ },
+
+ isPreferred(contentType, desiredContentType) {
+ if (contentType == "message/rfc822") {
+ return true;
+ }
+ return false;
+ },
+
+ canHandleContent(contentType, isContentPreferred, desiredContentType) {
+ return false;
+ },
+
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIDOMWindow)) {
+ return window;
+ }
+ if (iid.equals(Ci.nsIDocShell)) {
+ return window.docShell;
+ }
+ return this.QueryInterface(iid);
+ },
+
+ loadCookie: null,
+ parentContentListener: null,
+};
+
+/**
+ * Determine the sending format depending on the selected format, or the content
+ * of the message body.
+ *
+ * @returns {nsIMsgCompSendFormat} The determined send format: either PlainText,
+ * HTML or Both (never Auto or Unset).
+ */
+function determineSendFormat() {
+ if (!gMsgCompose.composeHTML) {
+ return Ci.nsIMsgCompSendFormat.PlainText;
+ }
+
+ let sendFormat = gMsgCompose.compFields.deliveryFormat;
+ if (sendFormat != Ci.nsIMsgCompSendFormat.Auto) {
+ return sendFormat;
+ }
+
+ // Auto downgrade if safe to do so.
+ let convertible;
+ try {
+ convertible = gMsgCompose.bodyConvertible();
+ } catch (ex) {
+ return Ci.nsIMsgCompSendFormat.Both;
+ }
+ return convertible == Ci.nsIMsgCompConvertible.Plain
+ ? Ci.nsIMsgCompSendFormat.PlainText
+ : Ci.nsIMsgCompSendFormat.Both;
+}
+
+/**
+ * Expands mailinglists found in the recipient fields.
+ */
+function expandRecipients() {
+ gMsgCompose.expandMailingLists();
+}
+
+/**
+ * Hides addressing options (To, CC, Bcc, Newsgroup, Followup-To, etc.)
+ * that are not relevant for the account type used for sending.
+ *
+ * @param {string} accountKey - Key of the account that is currently selected
+ * as the sending account.
+ * @param {string} prevKey - Key of the account that was previously selected
+ * as the sending account.
+ */
+function hideIrrelevantAddressingOptions(accountKey, prevKey) {
+ let showNews = false;
+ for (let account of MailServices.accounts.accounts) {
+ if (account.incomingServer.type == "nntp") {
+ showNews = true;
+ }
+ }
+ // If there is no News (NNTP) account existing then
+ // hide the Newsgroup and Followup-To recipient type menuitems.
+ for (let item of document.querySelectorAll(".news-show-row-menuitem")) {
+ showAddressRowMenuItemSetVisibility(item, showNews);
+ }
+
+ let account = MailServices.accounts.getAccount(accountKey);
+ let accountType = account.incomingServer.type;
+
+ // If the new account is a News (NNTP) account.
+ if (accountType == "nntp") {
+ updateUIforNNTPAccount();
+ return;
+ }
+
+ // If the new account is a Mail account and a previous account was selected.
+ if (accountType != "nntp" && prevKey != "") {
+ updateUIforMailAccount();
+ }
+}
+
+function LoadIdentity(startup) {
+ let identityElement = document.getElementById("msgIdentity");
+ let prevIdentity = gCurrentIdentity;
+
+ let idKey = null;
+ let accountKey = null;
+ let prevKey = getCurrentAccountKey();
+ if (identityElement.selectedItem) {
+ // Set the identity key value on the menu list.
+ idKey = identityElement.selectedItem.getAttribute("identitykey");
+ identityElement.setAttribute("identitykey", idKey);
+ gCurrentIdentity = MailServices.accounts.getIdentity(idKey);
+
+ // Set the account key value on the menu list.
+ accountKey = identityElement.selectedItem.getAttribute("accountkey");
+ identityElement.setAttribute("accountkey", accountKey);
+
+ // Update the addressing options only if a new account was selected.
+ if (prevKey != getCurrentAccountKey()) {
+ hideIrrelevantAddressingOptions(accountKey, prevKey);
+ }
+ }
+ for (let input of document.querySelectorAll(".mail-input,.news-input")) {
+ let params = JSON.parse(input.searchParam);
+ params.idKey = idKey;
+ params.accountKey = accountKey;
+ input.searchParam = JSON.stringify(params);
+ }
+
+ if (startup) {
+ // During compose startup, bail out here.
+ return;
+ }
+
+ // Since switching the signature loses the caret position, we record it
+ // and restore it later.
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+ let range = selection.getRangeAt(0);
+ let start = range.startOffset;
+ let startNode = range.startContainer;
+
+ editor.enableUndo(false);
+
+ // Handle non-startup changing of identity.
+ if (prevIdentity && idKey != prevIdentity.key) {
+ let changedRecipients = false;
+ let prevReplyTo = prevIdentity.replyTo;
+ let prevCc = "";
+ let prevBcc = "";
+ let prevReceipt = prevIdentity.requestReturnReceipt;
+ let prevDSN = prevIdentity.DSN;
+ let prevAttachVCard = prevIdentity.attachVCard;
+
+ if (prevIdentity.doCc && prevIdentity.doCcList) {
+ prevCc += prevIdentity.doCcList;
+ }
+
+ if (prevIdentity.doBcc && prevIdentity.doBccList) {
+ prevBcc += prevIdentity.doBccList;
+ }
+
+ let newReplyTo = gCurrentIdentity.replyTo;
+ let newCc = "";
+ let newBcc = "";
+ let newReceipt = gCurrentIdentity.requestReturnReceipt;
+ let newDSN = gCurrentIdentity.DSN;
+ let newAttachVCard = gCurrentIdentity.attachVCard;
+
+ if (gCurrentIdentity.doCc && gCurrentIdentity.doCcList) {
+ newCc += gCurrentIdentity.doCcList;
+ }
+
+ if (gCurrentIdentity.doBcc && gCurrentIdentity.doBccList) {
+ newBcc += gCurrentIdentity.doBccList;
+ }
+
+ let msgCompFields = gMsgCompose.compFields;
+ // Update recipients in msgCompFields to match pills currently in the UI.
+ Recipients2CompFields(msgCompFields);
+
+ if (
+ !gReceiptOptionChanged &&
+ prevReceipt == msgCompFields.returnReceipt &&
+ prevReceipt != newReceipt
+ ) {
+ msgCompFields.returnReceipt = newReceipt;
+ ToggleReturnReceipt(msgCompFields.returnReceipt);
+ }
+
+ if (
+ !gDSNOptionChanged &&
+ prevDSN == msgCompFields.DSN &&
+ prevDSN != newDSN
+ ) {
+ msgCompFields.DSN = newDSN;
+ document
+ .getElementById("dsnMenu")
+ .setAttribute("checked", msgCompFields.DSN);
+ }
+
+ if (
+ !gAttachVCardOptionChanged &&
+ prevAttachVCard == msgCompFields.attachVCard &&
+ prevAttachVCard != newAttachVCard
+ ) {
+ msgCompFields.attachVCard = newAttachVCard;
+ document
+ .getElementById("cmd_attachVCard")
+ .setAttribute("checked", msgCompFields.attachVCard);
+ }
+
+ if (newReplyTo != prevReplyTo) {
+ if (prevReplyTo != "") {
+ awRemoveRecipients(msgCompFields, "addr_reply", prevReplyTo);
+ }
+ if (newReplyTo != "") {
+ awAddRecipients(msgCompFields, "addr_reply", newReplyTo);
+ }
+ }
+
+ let toCcAddrs = new Set([
+ ...msgCompFields.splitRecipients(msgCompFields.to, true),
+ ...msgCompFields.splitRecipients(msgCompFields.cc, true),
+ ]);
+
+ if (newCc != prevCc) {
+ if (prevCc) {
+ awRemoveRecipients(msgCompFields, "addr_cc", prevCc);
+ }
+ if (newCc) {
+ // Add only Auto-Cc recipients whose email is not already in To or CC.
+ newCc = msgCompFields
+ .splitRecipients(newCc, false)
+ .filter(
+ x => !toCcAddrs.has(...msgCompFields.splitRecipients(x, true))
+ )
+ .join(", ");
+ awAddRecipients(msgCompFields, "addr_cc", newCc);
+ }
+ changedRecipients = true;
+ }
+
+ if (newBcc != prevBcc) {
+ let toCcBccAddrs = new Set([
+ ...toCcAddrs,
+ ...msgCompFields.splitRecipients(newCc, true),
+ ...msgCompFields.splitRecipients(msgCompFields.bcc, true),
+ ]);
+
+ if (prevBcc) {
+ awRemoveRecipients(msgCompFields, "addr_bcc", prevBcc);
+ }
+ if (newBcc) {
+ // Add only Auto-Bcc recipients whose email is not already in To, Cc,
+ // Bcc, or added as Auto-CC from newCc declared above.
+ newBcc = msgCompFields
+ .splitRecipients(newBcc, false)
+ .filter(
+ x => !toCcBccAddrs.has(...msgCompFields.splitRecipients(x, true))
+ )
+ .join(", ");
+ awAddRecipients(msgCompFields, "addr_bcc", newBcc);
+ }
+ changedRecipients = true;
+ }
+
+ // Handle showing/hiding of empty CC/BCC row after changing identity.
+ // Whenever "Cc/Bcc these email addresses" aka mail.identity.id#.doCc/doBcc
+ // is checked in Account Settings, show the address row, even if empty.
+ // This is a feature especially for ux-efficiency of enterprise workflows.
+ let addressRowCc = document.getElementById("addressRowCc");
+ if (gCurrentIdentity.doCc) {
+ // Per identity's doCc pref, show CC row, even if empty.
+ showAndFocusAddressRow("addressRowCc");
+ } else if (
+ prevIdentity.doCc &&
+ !addressRowCc.querySelector("mail-address-pill")
+ ) {
+ // Current identity doesn't need CC row shown, but previous identity did.
+ // Hide CC row if it's empty.
+ addressRowSetVisibility(addressRowCc, false);
+ }
+
+ let addressRowBcc = document.getElementById("addressRowBcc");
+ if (gCurrentIdentity.doBcc) {
+ // Per identity's doBcc pref, show BCC row, even if empty.
+ showAndFocusAddressRow("addressRowBcc");
+ } else if (
+ prevIdentity.doBcc &&
+ !addressRowBcc.querySelector("mail-address-pill")
+ ) {
+ // Current identity doesn't need BCC row shown, but previous identity did.
+ // Hide BCC row if it's empty.
+ addressRowSetVisibility(addressRowBcc, false);
+ }
+
+ // Trigger async checking and updating of encryption UI.
+ adjustEncryptAfterIdentityChange(prevIdentity);
+
+ try {
+ gMsgCompose.identity = gCurrentIdentity;
+ } catch (ex) {
+ dump("### Cannot change the identity: " + ex + "\n");
+ }
+
+ window.dispatchEvent(new CustomEvent("compose-from-changed"));
+
+ gComposeNotificationBar.clearIdentityWarning();
+
+ // Trigger this method only if the Cc or Bcc recipients changed from the
+ // previous identity.
+ if (changedRecipients) {
+ onRecipientsChanged(true);
+ }
+ }
+
+ // Only do this if we aren't starting up...
+ // It gets done as part of startup already.
+ addRecipientsToIgnoreList(gCurrentIdentity.fullAddress);
+
+ // If the From field is editable, reset the address from the identity.
+ if (identityElement.editable) {
+ identityElement.value = identityElement.selectedItem.value;
+ identityElement.placeholder = getComposeBundle().getFormattedString(
+ "msgIdentityPlaceholder",
+ [identityElement.selectedItem.value]
+ );
+ }
+
+ editor.enableUndo(true);
+ editor.resetModificationCount();
+ selection.collapse(startNode, start);
+
+ // Try to focus the first available address row. If there are none, focus the
+ // Subject which is always available.
+ for (let row of document.querySelectorAll(".address-row")) {
+ if (focusAddressRowInput(row)) {
+ return;
+ }
+ }
+ focusSubjectInput();
+}
+
+function MakeFromFieldEditable(ignoreWarning) {
+ let bundle = getComposeBundle();
+ if (
+ !ignoreWarning &&
+ !Services.prefs.getBoolPref("mail.compose.warned_about_customize_from")
+ ) {
+ var check = { value: false };
+ if (
+ Services.prompt.confirmEx(
+ window,
+ bundle.getString("customizeFromAddressTitle"),
+ bundle.getString("customizeFromAddressWarning"),
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_OK +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL +
+ Services.prompt.BUTTON_POS_1_DEFAULT,
+ null,
+ null,
+ null,
+ bundle.getString("customizeFromAddressIgnore"),
+ check
+ ) != 0
+ ) {
+ return;
+ }
+ Services.prefs.setBoolPref(
+ "mail.compose.warned_about_customize_from",
+ check.value
+ );
+ }
+
+ let customizeMenuitem = document.getElementById("cmd_customizeFromAddress");
+ customizeMenuitem.setAttribute("disabled", "true");
+ let identityElement = document.getElementById("msgIdentity");
+ let identityElementWidth = `${
+ identityElement.getBoundingClientRect().width
+ }px`;
+ identityElement.style.width = identityElementWidth;
+ identityElement.removeAttribute("type");
+ identityElement.setAttribute("editable", "true");
+ identityElement.focus();
+ identityElement.value = identityElement.selectedItem.value;
+ identityElement.select();
+ identityElement.placeholder = bundle.getFormattedString(
+ "msgIdentityPlaceholder",
+ [identityElement.selectedItem.value]
+ );
+}
+
+/**
+ * Set up autocomplete search parameters for address inputs of inbuilt headers.
+ *
+ * @param {Element} input - The address input of an inbuilt header field.
+ */
+function setupAutocompleteInput(input) {
+ let params = JSON.parse(input.getAttribute("autocompletesearchparam"));
+ params.type = input.closest(".address-row").dataset.recipienttype;
+ input.setAttribute("autocompletesearchparam", JSON.stringify(params));
+
+ // This method overrides the autocomplete binding's openPopup (essentially
+ // duplicating the logic from the autocomplete popup binding's
+ // openAutocompletePopup method), modifying it so that the popup is aligned
+ // and sized based on the parentNode of the input field.
+ input.openPopup = () => {
+ if (input.focused) {
+ input.popup.openAutocompletePopup(
+ input.nsIAutocompleteInput,
+ input.closest(".address-container")
+ );
+ }
+ };
+}
+
+/**
+ * Handle the keypress event of the From field.
+ *
+ * @param {Event} event - A DOM keypress event on #msgIdentity.
+ */
+function fromKeyPress(event) {
+ if (event.key == "Enter") {
+ // Move the focus to the first available address input.
+ document
+ .querySelector(
+ "#recipientsContainer .address-row:not(.hidden) .address-row-input"
+ )
+ .focus();
+ }
+}
+
+/**
+ * Handle the keypress event of the subject input.
+ *
+ * @param {Event} event - A DOM keypress event on #msgSubject.
+ */
+function subjectKeyPress(event) {
+ if (event.key == "Delete" && event.repeat && gPreventRowDeletionKeysRepeat) {
+ // Prevent repeated Delete keypress event if the flag is set.
+ event.preventDefault();
+ return;
+ }
+ // Enable repeated deletion if any other key is pressed, or if the Delete
+ // keypress event is not repeated, or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ // Move the focus to the body only if the Enter key is pressed without any
+ // modifier, as that would mean the user wants to send the message.
+ if (event.key == "Enter" && !event.ctrlKey && !event.metaKey) {
+ focusMsgBody();
+ }
+}
+
+/**
+ * Handle the input event of the subject input element.
+ *
+ * @param {Event} event - A DOM input event on #msgSubject.
+ */
+function msgSubjectOnInput(event) {
+ gSubjectChanged = true;
+ gContentChanged = true;
+ SetComposeWindowTitle();
+}
+
+// Content types supported in the envelopeDragObserver.
+const DROP_FLAVORS = [
+ "application/x-moz-file",
+ "text/x-moz-address",
+ "text/x-moz-message",
+ "text/x-moz-url",
+ "text/uri-list",
+];
+
+// We can drag and drop addresses, files, messages and urls into the compose
+// envelope.
+var envelopeDragObserver = {
+ /**
+ * Adjust the drop target when dragging from the attachment bucket onto itself
+ * by picking the nearest possible insertion point (generally, between two
+ * list items).
+ *
+ * @param {Event} event - The drag-and-drop event being performed.
+ * @returns {attachmentitem|string} - the adjusted drop target:
+ * - an attachmentitem node for inserting *before*
+ * - "none" if this isn't a valid insertion point
+ * - "afterLastItem" for appending at the bottom of the list.
+ */
+ _adjustDropTarget(event) {
+ let target = event.target;
+ if (target == gAttachmentBucket) {
+ // Dragging or dropping at top/bottom border of the listbox
+ if (
+ (event.screenY - target.screenY) /
+ target.getBoundingClientRect().height <
+ 0.5
+ ) {
+ target = gAttachmentBucket.firstElementChild;
+ } else {
+ target = gAttachmentBucket.lastElementChild;
+ }
+ // We'll check below if this is a valid target.
+ } else if (target.id == "attachmentBucketCount") {
+ // Dragging or dropping at top border of the listbox.
+ // Allow bottom half of attachment list header as extended drop target
+ // for top of list, because otherwise it would be too small.
+ if (
+ (event.screenY - target.screenY) /
+ target.getBoundingClientRect().height >=
+ 0.5
+ ) {
+ target = gAttachmentBucket.firstElementChild;
+ // We'll check below if this is a valid target.
+ } else {
+ // Top half of attachment list header: sorry, can't drop here.
+ return "none";
+ }
+ }
+
+ // Target is an attachmentitem.
+ if (target.matches("richlistitem.attachmentItem")) {
+ // If we're dragging/dropping in bottom half of attachmentitem,
+ // adjust target to target.nextElementSibling (to show dropmarker above that).
+ if (
+ (event.screenY - target.screenY) /
+ target.getBoundingClientRect().height >=
+ 0.5
+ ) {
+ target = target.nextElementSibling;
+
+ // If there's no target.nextElementSibling, we're dragging/dropping
+ // to the bottom of the list.
+ if (!target) {
+ // We can't move a bottom block selection to the bottom.
+ if (attachmentsSelectionIsBlock("bottom")) {
+ return "none";
+ }
+
+ // Not a bottom block selection: Target is *after* the last item.
+ return "afterLastItem";
+ }
+ }
+ // Check if the adjusted target attachmentitem is a valid target.
+ let isBlock = attachmentsSelectionIsBlock();
+ let prevItem = target.previousElementSibling;
+ // If target is first list item, there's no previous sibling;
+ // treat like unselected previous sibling.
+ let prevSelected = prevItem ? prevItem.selected : false;
+ if (
+ (target.selected && (isBlock || prevSelected)) ||
+ // target at end of block selection
+ (isBlock && prevSelected)
+ ) {
+ // We can't move a block selection before/after itself,
+ // or any selection onto itself, so trigger dropeffect "none".
+ return "none";
+ }
+ return target;
+ }
+
+ return "none";
+ },
+
+ _showDropMarker(targetItem) {
+ // Hide old drop marker.
+ this._hideDropMarker();
+
+ if (targetItem == "afterLastItem") {
+ targetItem = gAttachmentBucket.lastElementChild;
+ targetItem.setAttribute("dropOn", "after");
+ } else {
+ targetItem.setAttribute("dropOn", "before");
+ }
+ },
+
+ _hideDropMarker() {
+ gAttachmentBucket
+ .querySelector(".attachmentItem[dropOn]")
+ ?.removeAttribute("dropOn");
+ },
+
+ /**
+ * Loop through all the valid data type flavors and return a list of valid
+ * attachments to handle the various drag&drop actions.
+ *
+ * @param {Event} event - The drag-and-drop event being performed.
+ * @param {boolean} isDropping - If the action was performed from the onDrop
+ * method and it needs to handle pills creation.
+ *
+ * @returns {nsIMsgAttachment[]} - The array of valid attachments.
+ */
+ getValidAttachments(event, isDropping) {
+ let attachments = [];
+ let dt = event.dataTransfer;
+ let dataList = [];
+
+ // Extract all the flavors matching the data type of the dragged elements.
+ for (let i = 0; i < dt.mozItemCount; i++) {
+ let types = Array.from(dt.mozTypesAt(i));
+ for (let flavor of DROP_FLAVORS) {
+ if (types.includes(flavor)) {
+ let data = dt.mozGetDataAt(flavor, i);
+ if (data) {
+ dataList.push({ data, flavor });
+ break;
+ }
+ }
+ }
+ }
+
+ // Check if we have any valid attachment in the dragged data.
+ for (let { data, flavor } of dataList) {
+ gIsValidInline = false;
+ let isValidAttachment = false;
+ let prettyName;
+ let size;
+ let contentType;
+ let msgUri;
+ let cloudFileInfo;
+
+ // We could be dropping an attachment of various flavors OR an address;
+ // check and do the right thing.
+ switch (flavor) {
+ // Process attachments.
+ case "application/x-moz-file":
+ if (data instanceof Ci.nsIFile) {
+ size = data.fileSize;
+ }
+ try {
+ data = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getURLSpecFromActualFile(data);
+ isValidAttachment = true;
+ } catch (e) {
+ console.error(
+ "Couldn't process the dragged file " + data.leafName + ":" + e
+ );
+ }
+ break;
+
+ case "text/x-moz-message":
+ isValidAttachment = true;
+ let msgHdr =
+ MailServices.messageServiceFromURI(data).messageURIToMsgHdr(data);
+ prettyName = msgHdr.mime2DecodedSubject;
+ if (Services.prefs.getBoolPref("mail.forward_add_extension")) {
+ prettyName += ".eml";
+ }
+
+ size = msgHdr.messageSize;
+ contentType = "message/rfc822";
+ break;
+
+ // Data type representing:
+ // - URL strings dragged from a URL bar (Allow both attach and append).
+ // NOTE: This only works for macOS and Windows.
+ // - Attachments dragged from another message (Only attach).
+ // - Images dragged from the body of another message (Only append).
+ case "text/uri-list":
+ case "text/x-moz-url":
+ let pieces = data.split("\n");
+ data = pieces[0];
+ if (pieces.length > 1) {
+ prettyName = pieces[1];
+ }
+ if (pieces.length > 2) {
+ size = parseInt(pieces[2]);
+ }
+ if (pieces.length > 3) {
+ contentType = pieces[3];
+ }
+ if (pieces.length > 4) {
+ msgUri = pieces[4];
+ }
+ if (pieces.length > 6) {
+ cloudFileInfo = {
+ cloudFileAccountKey: pieces[5],
+ cloudPartHeaderData: pieces[6],
+ };
+ }
+
+ // Show the attachment overlay only if the user is not dragging an
+ // image form another message, since we can't get the correct file
+ // name, nor we can properly handle the append inline outside the
+ // editor drop event.
+ isValidAttachment = !event.dataTransfer.types.includes(
+ "application/x-moz-nativeimage"
+ );
+ // Show the append inline overlay only if this is not a file that was
+ // dragged from the attachment bucket of another message.
+ gIsValidInline = !event.dataTransfer.types.includes(
+ "application/x-moz-file-promise"
+ );
+ break;
+
+ // Process address: Drop it into recipient field.
+ case "text/x-moz-address":
+ // Process the drop only if the message body wasn't the target and we
+ // called this method from the onDrop() method.
+ if (event.target.baseURI != "about:blank?compose" && isDropping) {
+ DropRecipient(event.target, data);
+ // Prevent the default behaviour which drops the address text into
+ // the widget.
+ event.preventDefault();
+ }
+ break;
+ }
+
+ // Create the attachment and add it to attachments array.
+ if (isValidAttachment) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ attachment.url = data;
+ attachment.name = prettyName;
+ attachment.contentType = contentType;
+ attachment.msgUri = msgUri;
+
+ if (size !== undefined) {
+ attachment.size = size;
+ }
+
+ if (cloudFileInfo) {
+ attachment.cloudFileAccountKey = cloudFileInfo.cloudFileAccountKey;
+ attachment.cloudPartHeaderData = cloudFileInfo.cloudPartHeaderData;
+ }
+
+ attachments.push(attachment);
+ }
+ }
+
+ return attachments;
+ },
+
+ /**
+ * Reorder the attachments dragged within the attachment bucket.
+ *
+ * @param {Event} event - The drag event.
+ */
+ _reorderDraggedAttachments(event) {
+ // Adjust the drop target according to mouse position on list (items).
+ let target = this._adjustDropTarget(event);
+ // Get a non-live, sorted list of selected attachment list items.
+ let selItems = attachmentsSelectionGetSortedArray();
+ // Keep track of the item we had focused originally. Deselect it though,
+ // since listbox gets confused if you move its focused item around.
+ let focus = gAttachmentBucket.currentItem;
+ gAttachmentBucket.currentItem = null;
+ // Moving possibly non-coherent multiple selections around correctly
+ // is much more complex than one might think...
+ if (
+ (target.matches && target.matches("richlistitem.attachmentItem")) ||
+ target == "afterLastItem"
+ ) {
+ // Drop before targetItem in the list, or after last item.
+ let blockItems = [];
+ let targetItem;
+ for (let item of selItems) {
+ blockItems.push(item);
+ if (target == "afterLastItem") {
+ // Original target is the end of the list; append all items there.
+ gAttachmentBucket.appendChild(item);
+ } else if (target == selItems[0]) {
+ // Original target is first item of first selected block.
+ if (blockItems.includes(target)) {
+ // Item is in first block: do nothing, find the end of the block.
+ let nextItem = item.nextElementSibling;
+ if (!nextItem || !nextItem.selected) {
+ // We've reached the end of the first block.
+ blockItems.length = 0;
+ targetItem = nextItem;
+ }
+ } else {
+ // Item is NOT in first block: insert before targetItem,
+ // i.e. after end of first block.
+ gAttachmentBucket.insertBefore(item, targetItem);
+ }
+ } else if (target.selected) {
+ // Original target is not first item of first block,
+ // but first item of another block.
+ if (
+ gAttachmentBucket.getIndexOfItem(item) <
+ gAttachmentBucket.getIndexOfItem(target)
+ ) {
+ // Insert all items from preceding blocks before original target.
+ gAttachmentBucket.insertBefore(item, target);
+ } else if (blockItems.includes(target)) {
+ // target is included in any selected block except first:
+ // do nothing for that block, find its end.
+ let nextItem = item.nextElementSibling;
+ if (!nextItem || !nextItem.selected) {
+ // end of block containing target
+ blockItems.length = 0;
+ targetItem = nextItem;
+ }
+ } else {
+ // Item from block after block containing target: insert before
+ // targetItem, i.e. after end of block containing target.
+ gAttachmentBucket.insertBefore(item, targetItem);
+ }
+ } else {
+ // target != selItems [0]
+ // Original target is NOT first item of any block, and NOT selected:
+ // Insert all items before the original target.
+ gAttachmentBucket.insertBefore(item, target);
+ }
+ }
+ }
+ gAttachmentBucket.currentItem = focus;
+ },
+
+ handleInlineDrop(event) {
+ // It would be nice here to be able to append images, but we can't really
+ // assume if users want to add the image URL as clickable link or embedded
+ // image, so we always default to clickable link.
+ // We can later explore adding some UI choice to allow controlling the
+ // outcome of this drop action, but users can still copy and paste the image
+ // in the editor to cirumvent this potential issue.
+ let editor = GetCurrentEditor();
+ let attachments = this.getValidAttachments(event, true);
+
+ for (let attachment of attachments) {
+ if (!attachment?.url) {
+ continue;
+ }
+
+ let link = editor.createElementWithDefaults("a");
+ link.setAttribute("href", attachment.url);
+ link.textContent =
+ attachment.name ||
+ gMsgCompose.AttachmentPrettyName(attachment.url, null);
+ editor.insertElementAtSelection(link, true);
+ }
+ },
+
+ async onDrop(event) {
+ this._hideDropOverlay();
+
+ let dragSession = gDragService.getCurrentSession();
+ if (dragSession.sourceNode?.parentNode == gAttachmentBucket) {
+ // We dragged from the attachment pane onto itself, so instead of
+ // attaching a new object, we're just reordering them.
+ this._reorderDraggedAttachments(event);
+ this._hideDropMarker();
+ return;
+ }
+
+ // Interrupt if we're dropping elements from within the message body.
+ if (dragSession.sourceNode?.ownerDocument.URL == "about:blank?compose") {
+ return;
+ }
+
+ // Interrupt if we're not dropping a file from outside the compose window
+ // and we're not dragging a supported data type.
+ if (
+ !event.dataTransfer.files.length &&
+ !DROP_FLAVORS.some(f => event.dataTransfer.types.includes(f))
+ ) {
+ return;
+ }
+
+ // If the drop happened on the inline container, and the dragged data is
+ // valid for inline, bail out and handle it as inline text link.
+ if (event.target.id == "addInline" && gIsValidInline) {
+ this.handleInlineDrop(event);
+ return;
+ }
+
+ // Handle the inline adding of images without triggering the creation of
+ // any attachment if the user dropped only images above the #addInline box.
+ if (
+ event.target.id == "addInline" &&
+ !this.isNotDraggingOnlyImages(event.dataTransfer)
+ ) {
+ this.appendImagesInline(event.dataTransfer);
+ return;
+ }
+
+ let attachments = this.getValidAttachments(event, true);
+
+ // Interrupt if we don't have anything to attach.
+ if (!attachments.length) {
+ return;
+ }
+
+ let addedAttachmentItems = await AddAttachments(attachments);
+ // Convert attachments back to cloudFiles, if any.
+ for (let attachmentItem of addedAttachmentItems) {
+ if (
+ !attachmentItem.attachment.cloudFileAccountKey ||
+ !attachmentItem.attachment.cloudPartHeaderData
+ ) {
+ continue;
+ }
+ try {
+ let account = cloudFileAccounts.getAccount(
+ attachmentItem.attachment.cloudFileAccountKey
+ );
+ let upload = JSON.parse(
+ atob(attachmentItem.attachment.cloudPartHeaderData)
+ );
+ await UpdateAttachment(attachmentItem, {
+ cloudFileAccount: account,
+ relatedCloudFileUpload: upload,
+ });
+ } catch (ex) {
+ showLocalizedCloudFileAlert(ex);
+ }
+ }
+ gAttachmentBucket.focus();
+
+ // Stop the propagation only if we actually attached something.
+ event.stopPropagation();
+ },
+
+ onDragOver(event) {
+ let dragSession = gDragService.getCurrentSession();
+
+ // Check if we're dragging from the attachment bucket onto itself.
+ if (dragSession.sourceNode?.parentNode == gAttachmentBucket) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ // Show a drop marker.
+ let target = this._adjustDropTarget(event);
+
+ if (
+ (target.matches && target.matches("richlistitem.attachmentItem")) ||
+ target == "afterLastItem"
+ ) {
+ // Adjusted target is an attachment list item; show dropmarker.
+ this._showDropMarker(target);
+ return;
+ }
+
+ // target == "none", target is not a listItem, or no target:
+ // Indicate that we can't drop here.
+ this._hideDropMarker();
+ event.dataTransfer.dropEffect = "none";
+ return;
+ }
+
+ // Interrupt if we're dragging elements from within the message body.
+ if (dragSession.sourceNode?.ownerDocument.URL == "about:blank?compose") {
+ return;
+ }
+
+ // No need to check for the same dragged files if the previous dragging
+ // action didn't end.
+ if (gIsDraggingAttachments) {
+ // Prevent the default action of the event otherwise the onDrop event
+ // won't be triggered.
+ event.preventDefault();
+ this.detectHoveredOverlay(event.target.id);
+ return;
+ }
+
+ if (DROP_FLAVORS.some(f => event.dataTransfer.types.includes(f))) {
+ // Show the drop overlay only if we dragged files or supported types.
+ let attachments = this.getValidAttachments(event);
+ if (attachments.length) {
+ // We're dragging files that can potentially be attached or added
+ // inline, so update the variable.
+ gIsDraggingAttachments = true;
+
+ event.stopPropagation();
+ event.preventDefault();
+ document
+ .getElementById("dropAttachmentOverlay")
+ .classList.add("showing");
+
+ document.l10n.setAttributes(
+ document.getElementById("addAsAttachmentLabel"),
+ "drop-file-label-attachment",
+ {
+ count: attachments.length || 1,
+ }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("addInlineLabel"),
+ "drop-file-label-inline",
+ {
+ count: attachments.length || 1,
+ }
+ );
+
+ // Show the #addInline box only if the user is dragging text that we
+ // want to allow adding as text, as well as dragging only images, and
+ // if this is not a plain text message.
+ // NOTE: We're using event.dataTransfer.files.length instead of
+ // attachments.length because we only need to consider images coming
+ // from outside the application. The attachments array might contain
+ // files dragged from other compose windows or received message, which
+ // should not trigger the inline attachment overlay.
+ document
+ .getElementById("addInline")
+ .classList.toggle(
+ "hidden",
+ !gIsValidInline &&
+ (!event.dataTransfer.files.length ||
+ this.isNotDraggingOnlyImages(event.dataTransfer) ||
+ !gMsgCompose.composeHTML)
+ );
+ } else {
+ DragAddressOverTargetControl(event);
+ }
+ }
+
+ this.detectHoveredOverlay(event.target.id);
+ },
+
+ onDragLeave(event) {
+ // Set the variable to false as a drag leave event was triggered.
+ gIsDraggingAttachments = false;
+
+ // We use a timeout since a drag leave event might occur also when the drag
+ // motion passes above a child element and doesn't actually leave the
+ // compose window.
+ setTimeout(() => {
+ // If after the timeout, the dragging boolean is true, it means the user
+ // is still dragging something above the compose window, so let's bail out
+ // to prevent visual flickering of the drop overlay.
+ if (gIsDraggingAttachments) {
+ return;
+ }
+
+ this._hideDropOverlay();
+ }, 100);
+
+ this._hideDropMarker();
+ },
+
+ /**
+ * Hide the drag & drop overlay and update the global dragging variable to
+ * false. This operations are set in a dedicated method since they need to be
+ * called outside of the onDragleave() method.
+ */
+ _hideDropOverlay() {
+ gIsDraggingAttachments = false;
+
+ let overlay = document.getElementById("dropAttachmentOverlay");
+ overlay.classList.remove("showing");
+ overlay.classList.add("hiding");
+ },
+
+ /**
+ * Loop through all the currently dragged or dropped files to see if there's
+ * at least 1 file which is not an image.
+ *
+ * @param {DataTransfer} dataTransfer - The dataTransfer object from the drag
+ * or drop event.
+ * @returns {boolean} True if at least one file is not an image.
+ */
+ isNotDraggingOnlyImages(dataTransfer) {
+ for (let file of dataTransfer.files) {
+ if (!file.type.includes("image/")) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Add or remove the hover effect to the droppable containers. We can't do it
+ * simply via CSS since the hover events don't work when dragging an item.
+ *
+ * @param {string} targetId - The ID of the hovered overlay element.
+ */
+ detectHoveredOverlay(targetId) {
+ document
+ .getElementById("addInline")
+ .classList.toggle("hover", targetId == "addInline");
+ document
+ .getElementById("addAsAttachment")
+ .classList.toggle("hover", targetId == "addAsAttachment");
+ },
+
+ /**
+ * Loop through all the images that have been dropped above the #addInline
+ * box and create an image element to append to the message body.
+ *
+ * @param {DataTransfer} dataTransfer - The dataTransfer object from the drop
+ * event.
+ */
+ appendImagesInline(dataTransfer) {
+ focusMsgBody();
+ let editor = GetCurrentEditor();
+ editor.beginTransaction();
+
+ for (let file of dataTransfer.files) {
+ if (!file.mozFullPath) {
+ continue;
+ }
+
+ let realFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ realFile.initWithPath(file.mozFullPath);
+
+ let imageElement;
+ try {
+ imageElement = editor.createElementWithDefaults("img");
+ } catch (e) {
+ dump("Failed to create a new image element!\n");
+ console.error(e);
+ continue;
+ }
+
+ let src = Services.io.newFileURI(realFile).spec;
+ imageElement.setAttribute("src", src);
+ imageElement.setAttribute("moz-do-not-send", "false");
+
+ editor.insertElementAtSelection(imageElement, true);
+
+ try {
+ loadBlockedImage(src);
+ } catch (e) {
+ dump("Failed to load the appended image!\n");
+ console.error(e);
+ continue;
+ }
+ }
+
+ editor.endTransaction();
+ },
+};
+
+// See attachmentListDNDObserver, which should have the same logic.
+let attachmentBucketDNDObserver = {
+ onDragStart(event) {
+ // NOTE: Starting a drag on an attachment item will normally also select
+ // the attachment item before this method is called. But this is not
+ // necessarily the case. E.g. holding Shift when starting the drag
+ // operation. When it isn't selected, we just don't transfer.
+ if (event.target.matches(".attachmentItem[selected]")) {
+ // Also transfer other selected attachment items.
+ let attachments = Array.from(
+ gAttachmentBucket.querySelectorAll(".attachmentItem[selected]"),
+ item => item.attachment
+ );
+ setupDataTransfer(event, attachments);
+ }
+ event.stopPropagation();
+ },
+};
+
+function DisplaySaveFolderDlg(folderURI) {
+ try {
+ var showDialog = gCurrentIdentity.showSaveMsgDlg;
+ } catch (e) {
+ return;
+ }
+
+ if (showDialog) {
+ let msgfolder = MailUtils.getExistingFolder(folderURI);
+ if (!msgfolder) {
+ return;
+ }
+ let checkbox = { value: 0 };
+ let bundle = getComposeBundle();
+ let SaveDlgTitle = bundle.getString("SaveDialogTitle");
+ let dlgMsg = bundle.getFormattedString("SaveDialogMsg", [
+ msgfolder.name,
+ msgfolder.server.prettyName,
+ ]);
+
+ Services.prompt.alertCheck(
+ window,
+ SaveDlgTitle,
+ dlgMsg,
+ bundle.getString("CheckMsg"),
+ checkbox
+ );
+ try {
+ gCurrentIdentity.showSaveMsgDlg = !checkbox.value;
+ } catch (e) {}
+ }
+}
+
+/**
+ * Focus the people search input in the contacts side panel.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {boolean} - Whether the peopleSearchInput was focused.
+ */
+function focusContactsSidebarSearchInput() {
+ if (document.getElementById("contactsSplitter").isCollapsed) {
+ return false;
+ }
+ let input = document
+ .getElementById("contactsBrowser")
+ .contentDocument.getElementById("peopleSearchInput");
+ if (!input) {
+ return false;
+ }
+ input.focus();
+ return true;
+}
+
+/**
+ * Focus the "From" identity input/selector.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {true} - Always returns true.
+ */
+function focusMsgIdentity() {
+ document.getElementById("msgIdentity").focus();
+ return true;
+}
+
+/**
+ * Focus the address row input, provided the row is not hidden.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @param {Element} row - The address row to focus.
+ *
+ * @returns {boolean} - Whether the input was focused.
+ */
+function focusAddressRowInput(row) {
+ if (row.classList.contains("hidden")) {
+ return false;
+ }
+ row.querySelector(".address-row-input").focus();
+ return true;
+}
+
+/**
+ * Focus the "Subject" input.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {true} - Always returns true.
+ */
+function focusSubjectInput() {
+ document.getElementById("msgSubject").focus();
+ return true;
+}
+
+/**
+ * Focus the composed message body.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {true} - Always returns true.
+ */
+function focusMsgBody() {
+ // window.content.focus() fails to blur the currently focused element
+ document.commandDispatcher.advanceFocusIntoSubtree(
+ document.getElementById("messageArea")
+ );
+ return true;
+}
+
+/**
+ * Focus the attachment bucket, provided it is not hidden.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @param {Element} attachmentArea - The attachment container.
+ *
+ * @returns {boolean} - Whether the attachment bucket was focused.
+ */
+function focusAttachmentBucket(attachmentArea) {
+ if (
+ document
+ .getElementById("composeContentBox")
+ .classList.contains("attachment-area-hidden")
+ ) {
+ return false;
+ }
+ if (!attachmentArea.open) {
+ // Focus the expander instead.
+ attachmentArea.querySelector("summary").focus();
+ return true;
+ }
+ gAttachmentBucket.focus();
+ return true;
+}
+
+/**
+ * Focus the first notification button.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {boolean} - Whether a notification received focused.
+ */
+function focusNotification() {
+ let notification = gComposeNotification.allNotifications[0];
+ if (notification) {
+ let button = notification.buttonContainer.querySelector("button");
+ if (button) {
+ button.focus();
+ } else {
+ // Focus the close button instead.
+ notification.closeButton.focus();
+ }
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Focus the first focusable descendant of the status bar.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @param {Element} attachmentArea - The status bar.
+ *
+ * @returns {boolean} - Whether a status bar descendant received focused.
+ */
+function focusStatusBar(statusBar) {
+ let button = statusBar.querySelector("button:not([hidden])");
+ if (!button) {
+ return false;
+ }
+ button.focus();
+ return true;
+}
+
+/**
+ * Fast-track focus ring: Switch focus between important (not all) elements
+ * in the message compose window in response to Ctrl+[Shift+]Tab or [Shift+]F6.
+ *
+ * @param {Event} event - A DOM keyboard event of a fast focus ring shortcut key
+ */
+function moveFocusToNeighbouringArea(event) {
+ event.preventDefault();
+ let currentElement = document.activeElement;
+
+ for (let i = 0; i < gFocusAreas.length; i++) {
+ // Go through each area and check if focus is within.
+ let area = gFocusAreas[i];
+ if (!area.root.contains(currentElement)) {
+ continue;
+ }
+ // Focus is within, so we find the neighbouring area to move focus to.
+ let end = i;
+ while (true) {
+ // Get the next neighbour.
+ // NOTE: The focus will loop around.
+ if (event.shiftKey) {
+ // Move focus backward. If the index points to the start of the Array,
+ // we loop back to the end of the Array.
+ i = (i || gFocusAreas.length) - 1;
+ } else {
+ // Move focus forward. If the index points to the end of the Array, we
+ // loop back to the start of the Array.
+ i = (i + 1) % gFocusAreas.length;
+ }
+ if (i == end) {
+ // Full loop around without finding an area to focus.
+ // Unexpected, but we make sure to stop looping.
+ break;
+ }
+ area = gFocusAreas[i];
+ if (area.focus(area.root)) {
+ // Successfully moved focus.
+ break;
+ }
+ // Else, try the next neighbour.
+ }
+ return;
+ }
+ // Focus is currently outside the gFocusAreas list, so do nothing.
+}
+
+/**
+ * If the contacts sidebar is shown, hide it. Otherwise, show the contacts
+ * sidebar and focus it.
+ */
+function toggleContactsSidebar() {
+ setContactsSidebarVisibility(
+ document.getElementById("contactsSplitter").isCollapsed,
+ true
+ );
+}
+
+/**
+ * Show or hide contacts sidebar.
+ *
+ * @param {boolean} show - Whether to show the sidebar or hide the sidebar.
+ * @param {boolean} focus - Whether to focus peopleSearchInput if the sidebar is
+ * shown.
+ */
+function setContactsSidebarVisibility(show, focus) {
+ let contactsSplitter = document.getElementById("contactsSplitter");
+ let sidebarAddrMenu = document.getElementById("menu_AddressSidebar");
+ let contactsButton = document.getElementById("button-contacts");
+
+ if (show) {
+ contactsSplitter.expand();
+ sidebarAddrMenu.setAttribute("checked", "true");
+ if (contactsButton) {
+ contactsButton.setAttribute("checked", "true");
+ }
+
+ let contactsBrowser = document.getElementById("contactsBrowser");
+ if (contactsBrowser.getAttribute("src") == "") {
+ // Url not yet set, load contacts side bar and focus the search
+ // input if applicable: We pass "?focus" as a URL querystring, then via
+ // onload event of <window id="abContactsPanel">, in AbPanelLoad() of
+ // abContactsPanel.js, we do the focusing first thing to avoid timing
+ // issues when trying to focus from here while contacts side bar is still
+ // loading.
+ let url = "chrome://messenger/content/addressbook/abContactsPanel.xhtml";
+ if (focus) {
+ url += "?focus";
+ }
+ contactsBrowser.setAttribute("src", url);
+ } else if (focus) {
+ // Url already set, so we can focus immediately if applicable.
+ focusContactsSidebarSearchInput();
+ }
+ } else {
+ let contactsSidebar = document.getElementById("contactsSidebar");
+ // Before closing, check if the focus was within the contacts sidebar.
+ let sidebarFocussed = contactsSidebar.contains(document.activeElement);
+
+ contactsSplitter.collapse();
+ sidebarAddrMenu.removeAttribute("checked");
+ if (contactsButton) {
+ contactsButton.removeAttribute("checked");
+ }
+
+ // Don't change the focus unless it was within the contacts sidebar.
+ if (!sidebarFocussed) {
+ return;
+ }
+ // Else, we need to explicitly move the focus out of the contacts sidebar.
+ // We choose the subject input if it is empty, otherwise the message body.
+ if (!document.getElementById("msgSubject").value) {
+ focusSubjectInput();
+ } else {
+ focusMsgBody();
+ }
+ }
+}
+
+function loadHTMLMsgPrefs() {
+ let fontFace = Services.prefs.getStringPref("msgcompose.font_face", "");
+ if (fontFace) {
+ doStatefulCommand("cmd_fontFace", fontFace, true);
+ }
+
+ let fontSize = Services.prefs.getCharPref("msgcompose.font_size", "3");
+ EditorSetFontSize(fontSize);
+
+ let bodyElement = GetBodyElement();
+
+ let useDefault = Services.prefs.getBoolPref("msgcompose.default_colors");
+
+ let textColor = useDefault
+ ? ""
+ : Services.prefs.getCharPref("msgcompose.text_color", "");
+ if (!bodyElement.getAttribute("text") && textColor) {
+ bodyElement.setAttribute("text", textColor);
+ gDefaultTextColor = textColor;
+ document.getElementById("cmd_fontColor").setAttribute("state", textColor);
+ onFontColorChange();
+ }
+
+ let bgColor = useDefault
+ ? ""
+ : Services.prefs.getCharPref("msgcompose.background_color", "");
+ if (!bodyElement.getAttribute("bgcolor") && bgColor) {
+ bodyElement.setAttribute("bgcolor", bgColor);
+ gDefaultBackgroundColor = bgColor;
+ document
+ .getElementById("cmd_backgroundColor")
+ .setAttribute("state", bgColor);
+ onBackgroundColorChange();
+ }
+}
+
+async function AutoSave() {
+ if (
+ gMsgCompose.editor &&
+ (gContentChanged || gMsgCompose.bodyModified) &&
+ !gSendOperationInProgress &&
+ !gSaveOperationInProgress
+ ) {
+ try {
+ await GenericSendMessage(Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft);
+ } catch (ex) {
+ console.error(ex);
+ }
+ gAutoSaveKickedIn = true;
+ }
+
+ gAutoSaveTimeout = setTimeout(AutoSave, gAutoSaveInterval);
+}
+
+/**
+ * Periodically check for keywords in the message.
+ */
+var gAttachmentNotifier = {
+ _obs: null,
+
+ enabled: false,
+
+ init(aDocument) {
+ if (this._obs) {
+ this.shutdown();
+ }
+
+ this.enabled = Services.prefs.getBoolPref(
+ "mail.compose.attachment_reminder"
+ );
+ if (!this.enabled) {
+ return;
+ }
+
+ this._obs = new MutationObserver(function (aMutations) {
+ gAttachmentNotifier.timer.cancel();
+ gAttachmentNotifier.timer.initWithCallback(
+ gAttachmentNotifier.event,
+ 500,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ });
+
+ this._obs.observe(aDocument, {
+ attributes: true,
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+
+ // Add an input event listener for the subject field since there
+ // are ways of changing its value without key presses.
+ document
+ .getElementById("msgSubject")
+ .addEventListener("input", this.subjectInputObserver, true);
+
+ // We could have been opened with a draft message already containing
+ // some keywords, so run the checker once to pick them up.
+ this.event.notify();
+ },
+
+ // Timer based function triggered by the inputEventListener
+ // for the subject field.
+ subjectInputObserver() {
+ gAttachmentNotifier.timer.cancel();
+ gAttachmentNotifier.timer.initWithCallback(
+ gAttachmentNotifier.event,
+ 500,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ /**
+ * Checks for new keywords synchronously and run the usual handler.
+ *
+ * @param aManage Determines whether to manage the notification according to keywords found.
+ */
+ redetectKeywords(aManage) {
+ if (!this.enabled) {
+ return;
+ }
+
+ attachmentWorker.onmessage(
+ { data: this._checkForAttachmentKeywords(false) },
+ aManage
+ );
+ },
+
+ /**
+ * Check if there are any keywords in the message.
+ *
+ * @param async Whether we should run the regex checker asynchronously or not.
+ *
+ * @returns If async is true, attachmentWorker.message is called with the array
+ * of found keywords and this function returns null.
+ * If it is false, the array is returned from this function immediately.
+ */
+ _checkForAttachmentKeywords(async) {
+ if (!this.enabled) {
+ return async ? null : [];
+ }
+
+ if (attachmentNotificationSupressed()) {
+ // If we know we don't need to show the notification,
+ // we can skip the expensive checking of keywords in the message.
+ // but mark it in the .lastMessage that the keywords are unknown.
+ attachmentWorker.lastMessage = null;
+ return async ? null : [];
+ }
+
+ let keywordsInCsv = Services.prefs.getComplexValue(
+ "mail.compose.attachment_reminder_keywords",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ let mailBody = getBrowser().contentDocument.querySelector("body");
+
+ // We use a new document and import the body into it. We do that to avoid
+ // loading images that were previously blocked. Content policy of the newly
+ // created data document will block the loads. Details: Bug 1409458 comment #22.
+ let newDoc = getBrowser().contentDocument.implementation.createDocument(
+ "",
+ "",
+ null
+ );
+ let mailBodyNode = newDoc.importNode(mailBody, true);
+
+ // Don't check quoted text from reply.
+ let blockquotes = mailBodyNode.getElementsByTagName("blockquote");
+ for (let i = blockquotes.length - 1; i >= 0; i--) {
+ blockquotes[i].remove();
+ }
+
+ // For plaintext composition the quotes we need to find and exclude are
+ // <span _moz_quote="true">.
+ let spans = mailBodyNode.querySelectorAll("span[_moz_quote]");
+ for (let i = spans.length - 1; i >= 0; i--) {
+ spans[i].remove();
+ }
+
+ // Ignore signature (html compose mode).
+ let sigs = mailBodyNode.getElementsByClassName("moz-signature");
+ for (let i = sigs.length - 1; i >= 0; i--) {
+ sigs[i].remove();
+ }
+
+ // Replace brs with line breaks so node.textContent won't pull foo<br>bar
+ // together to foobar.
+ let brs = mailBodyNode.getElementsByTagName("br");
+ for (let i = brs.length - 1; i >= 0; i--) {
+ brs[i].parentNode.replaceChild(
+ mailBodyNode.ownerDocument.createTextNode("\n"),
+ brs[i]
+ );
+ }
+
+ // Ignore signature (plain text compose mode).
+ let mailData = mailBodyNode.textContent;
+ let sigIndex = mailData.indexOf("-- \n");
+ if (sigIndex > 0) {
+ mailData = mailData.substring(0, sigIndex);
+ }
+
+ // Ignore replied messages (plain text and html compose mode).
+ let repText = getComposeBundle().getString(
+ "mailnews.reply_header_originalmessage"
+ );
+ let repIndex = mailData.indexOf(repText);
+ if (repIndex > 0) {
+ mailData = mailData.substring(0, repIndex);
+ }
+
+ // Ignore forwarded messages (plain text and html compose mode).
+ let fwdText = getComposeBundle().getString(
+ "mailnews.forward_header_originalmessage"
+ );
+ let fwdIndex = mailData.indexOf(fwdText);
+ if (fwdIndex > 0) {
+ mailData = mailData.substring(0, fwdIndex);
+ }
+
+ // Prepend the subject to see if the subject contains any attachment
+ // keywords too, after making sure that the subject has changed
+ // or after reopening a draft. For reply, redirect and forward,
+ // only check when the input was changed by the user.
+ let subject = document.getElementById("msgSubject").value;
+ if (
+ subject &&
+ (gSubjectChanged ||
+ (gEditingDraft &&
+ (gComposeType == Ci.nsIMsgCompType.New ||
+ gComposeType == Ci.nsIMsgCompType.NewsPost ||
+ gComposeType == Ci.nsIMsgCompType.Draft ||
+ gComposeType == Ci.nsIMsgCompType.Template ||
+ gComposeType == Ci.nsIMsgCompType.EditTemplate ||
+ gComposeType == Ci.nsIMsgCompType.EditAsNew ||
+ gComposeType == Ci.nsIMsgCompType.MailToUrl)))
+ ) {
+ mailData = subject + " " + mailData;
+ }
+
+ if (!async) {
+ return AttachmentChecker.getAttachmentKeywords(mailData, keywordsInCsv);
+ }
+
+ attachmentWorker.postMessage([mailData, keywordsInCsv]);
+ return null;
+ },
+
+ shutdown() {
+ if (this._obs) {
+ this._obs.disconnect();
+ }
+ gAttachmentNotifier.timer.cancel();
+
+ this._obs = null;
+ },
+
+ event: {
+ notify(timer) {
+ // Only run the checker if the compose window is initialized
+ // and not shutting down.
+ if (gMsgCompose) {
+ // This runs the attachmentWorker asynchronously so if keywords are found
+ // manageAttachmentNotification is run from attachmentWorker.onmessage.
+ gAttachmentNotifier._checkForAttachmentKeywords(true);
+ }
+ },
+ },
+
+ timer: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer),
+};
+
+/**
+ * Helper function to remove a query part from a URL, so for example:
+ * ...?remove=xx&other=yy becomes ...?other=yy.
+ *
+ * @param aURL the URL from which to remove the query part
+ * @param aQuery the query part to remove
+ * @returns the URL with the query part removed
+ */
+function removeQueryPart(aURL, aQuery) {
+ // Quick pre-check.
+ if (!aURL.includes(aQuery)) {
+ return aURL;
+ }
+
+ let indexQM = aURL.indexOf("?");
+ if (indexQM < 0) {
+ return aURL;
+ }
+
+ let queryParts = aURL.substr(indexQM + 1).split("&");
+ let indexPart = queryParts.indexOf(aQuery);
+ if (indexPart < 0) {
+ return aURL;
+ }
+ queryParts.splice(indexPart, 1);
+ return aURL.substr(0, indexQM + 1) + queryParts.join("&");
+}
+
+function InitEditor() {
+ var editor = GetCurrentEditor();
+
+ // Set eEditorMailMask flag to avoid using content prefs for spell checker,
+ // otherwise dictionary setting in preferences is ignored and dictionary is
+ // inconsistent in subject and message body.
+ let eEditorMailMask = Ci.nsIEditor.eEditorMailMask;
+ editor.flags |= eEditorMailMask;
+ document.getElementById("msgSubject").editor.flags |= eEditorMailMask;
+
+ // Control insertion of line breaks.
+ editor.returnInParagraphCreatesNewParagraph = Services.prefs.getBoolPref(
+ "editor.CR_creates_new_p"
+ );
+ editor.document.execCommand(
+ "defaultparagraphseparator",
+ false,
+ gMsgCompose.composeHTML &&
+ Services.prefs.getBoolPref("mail.compose.default_to_paragraph")
+ ? "p"
+ : "br"
+ );
+ if (gMsgCompose.composeHTML) {
+ // Re-enable table/image resizers.
+ editor.QueryInterface(
+ Ci.nsIHTMLAbsPosEditor
+ ).absolutePositioningEnabled = true;
+ editor.QueryInterface(
+ Ci.nsIHTMLInlineTableEditor
+ ).inlineTableEditingEnabled = true;
+ editor.QueryInterface(Ci.nsIHTMLObjectResizer).objectResizingEnabled = true;
+ }
+
+ // We use loadSheetUsingURIString so that we get a synchronous load, rather
+ // than having a late-finishing async load mark our editor as modified when
+ // the user hasn't typed anything yet, but that means the sheet must not
+ // @import slow things, especially not over the network.
+ let domWindowUtils = GetCurrentEditorElement().contentWindow.windowUtils;
+ domWindowUtils.loadSheetUsingURIString(
+ "chrome://messenger/skin/messageQuotes.css",
+ domWindowUtils.AGENT_SHEET
+ );
+ domWindowUtils.loadSheetUsingURIString(
+ "chrome://messenger/skin/shared/composerOverlay.css",
+ domWindowUtils.AGENT_SHEET
+ );
+
+ window.content.browsingContext.allowJavascript = false;
+ window.content.browsingContext.docShell.allowAuth = false;
+ window.content.browsingContext.docShell.allowMetaRedirects = false;
+ gMsgCompose.initEditor(editor, window.content);
+
+ if (!editor.document.doctype) {
+ editor.document.insertBefore(
+ editor.document.implementation.createDocumentType("html", "", ""),
+ editor.document.firstChild
+ );
+ }
+
+ // Then, we enable related UI entries.
+ enableInlineSpellCheck(Services.prefs.getBoolPref("mail.spellcheck.inline"));
+ gAttachmentNotifier.init(editor.document);
+
+ // Listen for spellchecker changes, set document language to
+ // dictionary picked by the user via the right-click menu in the editor.
+ document.addEventListener("spellcheck-changed", updateDocumentLanguage);
+
+ // XXX: the error event fires twice for each load. Why??
+ editor.document.body.addEventListener(
+ "error",
+ function (event) {
+ if (event.target.localName != "img") {
+ return;
+ }
+
+ if (event.target.getAttribute("moz-do-not-send") == "true") {
+ return;
+ }
+
+ let src = event.target.src;
+ if (!src) {
+ return;
+ }
+ if (!/^file:/i.test(src)) {
+ // Check if this is a protocol that can fetch parts.
+ let protocol = src.substr(0, src.indexOf(":")).toLowerCase();
+ if (
+ !(
+ Services.io.getProtocolHandler(protocol) instanceof
+ Ci.nsIMsgMessageFetchPartService
+ )
+ ) {
+ // Can't fetch parts, don't try to load.
+ return;
+ }
+ }
+
+ if (event.target.classList.contains("loading-internal")) {
+ // We're already loading this, or tried so unsuccessfully.
+ return;
+ }
+ if (gOriginalMsgURI) {
+ let msgSvc = MailServices.messageServiceFromURI(gOriginalMsgURI);
+ let originalMsgNeckoURI = msgSvc.getUrlForUri(gOriginalMsgURI);
+ if (
+ src.startsWith(
+ removeQueryPart(
+ originalMsgNeckoURI.spec,
+ "type=application/x-message-display"
+ )
+ ) ||
+ // Special hack for saved messages.
+ (src.includes("?number=0&") &&
+ originalMsgNeckoURI.spec.startsWith("file://") &&
+ src.startsWith(
+ removeQueryPart(
+ originalMsgNeckoURI.spec,
+ "type=application/x-message-display"
+ ).replace("file://", "mailbox://") + "number=0"
+ ))
+ ) {
+ // Reply/Forward/Edit Draft/Edit as New can contain references to
+ // images in the original message. Load those and make them data: URLs
+ // now.
+ event.target.classList.add("loading-internal");
+ try {
+ loadBlockedImage(src);
+ } catch (e) {
+ // Couldn't load the referenced image.
+ console.error(e);
+ }
+ } else {
+ // Appears to reference a random message. Notify and keep blocking.
+ gComposeNotificationBar.setBlockedContent(src);
+ }
+ } else {
+ // For file:, and references to parts of random messages, show the
+ // blocked content notification.
+ gComposeNotificationBar.setBlockedContent(src);
+ }
+ },
+ true
+ );
+
+ // Convert mailnews URL back to data: URL.
+ let background = editor.document.body.background;
+ if (background && gOriginalMsgURI) {
+ // Check that background has the same URL as the message itself.
+ let msgSvc = MailServices.messageServiceFromURI(gOriginalMsgURI);
+ let originalMsgNeckoURI = msgSvc.getUrlForUri(gOriginalMsgURI);
+ if (
+ background.startsWith(
+ removeQueryPart(
+ originalMsgNeckoURI.spec,
+ "type=application/x-message-display"
+ )
+ )
+ ) {
+ try {
+ editor.document.body.background = loadBlockedImage(background, true);
+ } catch (e) {
+ // Couldn't load the referenced image.
+ console.error(e);
+ }
+ }
+ }
+
+ // Run menubar initialization first, to avoid TabsInTitlebar code picking
+ // up mutations from it and causing a reflow.
+ if (AppConstants.platform != "macosx") {
+ AutoHideMenubar.init();
+ }
+
+ // For plain text compose, set the styles for quoted text according to
+ // preferences.
+ if (!gMsgCompose.composeHTML) {
+ let style = editor.document.createElement("style");
+ editor.document.head.appendChild(style);
+ let fontStyle = "";
+ let fontSize = "";
+ switch (Services.prefs.getIntPref("mail.quoted_style")) {
+ case 1:
+ fontStyle = "font-weight: bold;";
+ break;
+ case 2:
+ fontStyle = "font-style: italic;";
+ break;
+ case 3:
+ fontStyle = "font-weight: bold; font-style: italic;";
+ break;
+ }
+
+ switch (Services.prefs.getIntPref("mail.quoted_size")) {
+ case 1:
+ fontSize = "font-size: large;";
+ break;
+ case 2:
+ fontSize = "font-size: small;";
+ break;
+ }
+
+ let citationColor =
+ "color: " + Services.prefs.getCharPref("mail.citation_color") + ";";
+
+ style.sheet.insertRule(
+ `span[_moz_quote="true"] {
+ ${fontStyle}
+ ${fontSize}
+ ${citationColor}
+ }`
+ );
+ gMsgCompose.bodyModified = false;
+ }
+
+ // Set document language to the draft language or the preference
+ // if this is a draft or template we prepared.
+ let draftLanguages = null;
+ if (
+ gMsgCompose.compFields.creatorIdentityKey &&
+ gMsgCompose.compFields.contentLanguage
+ ) {
+ draftLanguages = gMsgCompose.compFields.contentLanguage
+ .split(",")
+ .map(lang => lang.trim());
+ }
+
+ let dictionaries = getValidSpellcheckerDictionaries(draftLanguages);
+ ComposeChangeLanguage(dictionaries).catch(console.error);
+}
+
+function setFontSize(event) {
+ // Increase Font Menuitem and Decrease Font Menuitem from the main menu
+ // will call this function because of oncommand attribute on the menupopup
+ // and fontSize will be null for such function calls.
+ let fontSize = event.target.value;
+ if (fontSize) {
+ EditorSetFontSize(fontSize);
+ }
+}
+
+function setParagraphState(event) {
+ editorSetParagraphState(event.target.value);
+}
+
+// This is used as event listener to spellcheck-changed event to update
+// document language.
+function updateDocumentLanguage(e) {
+ ComposeChangeLanguage(e.detail.dictionaries).catch(console.error);
+}
+
+function toggleSpellCheckingEnabled() {
+ enableInlineSpellCheck(!gSpellCheckingEnabled);
+}
+
+// This function is called either at startup (see InitEditor above), or when
+// the user clicks on one of the two menu items that allow them to toggle the
+// spellcheck feature (either context menu or Options menu).
+function enableInlineSpellCheck(aEnableInlineSpellCheck) {
+ let checker = GetCurrentEditorSpellChecker();
+ if (!checker) {
+ return;
+ }
+ if (gSpellCheckingEnabled != aEnableInlineSpellCheck) {
+ // If state of spellchecker is about to change, clear any pending observer.
+ spellCheckReadyObserver.removeObserver();
+ }
+
+ gSpellCheckingEnabled = checker.enableRealTimeSpell = aEnableInlineSpellCheck;
+ document
+ .getElementById("msgSubject")
+ .setAttribute("spellcheck", aEnableInlineSpellCheck);
+}
+
+function getMailToolbox() {
+ return document.getElementById("compose-toolbox");
+}
+
+/**
+ * Helper function to dispatch a CustomEvent to the attachmentbucket.
+ *
+ * @param aEventType the name of the event to fire.
+ * @param aData any detail data to pass to the CustomEvent.
+ */
+function dispatchAttachmentBucketEvent(aEventType, aData) {
+ gAttachmentBucket.dispatchEvent(
+ new CustomEvent(aEventType, {
+ bubbles: true,
+ cancelable: true,
+ detail: aData,
+ })
+ );
+}
+
+/** Update state of zoom type (text vs. full) menu item. */
+function UpdateFullZoomMenu() {
+ let menuItem = document.getElementById("menu_fullZoomToggle");
+ menuItem.setAttribute("checked", !ZoomManager.useFullZoom);
+}
+
+/**
+ * Return the <editor> element of the mail compose window. The name is somewhat
+ * unfortunate; we need to maintain it since the zoom manager, view source and
+ * other functions still rely on it.
+ */
+function getBrowser() {
+ return document.getElementById("messageEditor");
+}
+
+function goUpdateMailMenuItems(commandset) {
+ for (let i = 0; i < commandset.children.length; i++) {
+ let commandID = commandset.children[i].getAttribute("id");
+ if (commandID) {
+ goUpdateCommand(commandID);
+ }
+ }
+}
+
+/**
+ * Object to handle message related notifications that are showing in a
+ * notificationbox below the composed message content.
+ */
+var gComposeNotificationBar = {
+ get brandBundle() {
+ delete this.brandBundle;
+ return (this.brandBundle = document.getElementById("brandBundle"));
+ },
+
+ setBlockedContent(aBlockedURI) {
+ let brandName = this.brandBundle.getString("brandShortName");
+ let buttonLabel = getComposeBundle().getString(
+ AppConstants.platform == "win"
+ ? "blockedContentPrefLabel"
+ : "blockedContentPrefLabelUnix"
+ );
+ let buttonAccesskey = getComposeBundle().getString(
+ AppConstants.platform == "win"
+ ? "blockedContentPrefAccesskey"
+ : "blockedContentPrefAccesskeyUnix"
+ );
+
+ let buttons = [
+ {
+ label: buttonLabel,
+ accessKey: buttonAccesskey,
+ popup: "blockedContentOptions",
+ callback(aNotification, aButton) {
+ return true; // keep notification open
+ },
+ },
+ ];
+
+ // The popup value is a space separated list of all the blocked urls.
+ let popup = document.getElementById("blockedContentOptions");
+ let urls = popup.value ? popup.value.split(" ") : [];
+ if (!urls.includes(aBlockedURI)) {
+ urls.push(aBlockedURI);
+ }
+ popup.value = urls.join(" ");
+
+ let msg = getComposeBundle().getFormattedString("blockedContentMessage", [
+ brandName,
+ brandName,
+ ]);
+ msg = PluralForm.get(urls.length, msg);
+
+ if (!this.isShowingBlockedContentNotification()) {
+ gComposeNotification.appendNotification(
+ "blockedContent",
+ {
+ label: msg,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+ } else {
+ gComposeNotification
+ .getNotificationWithValue("blockedContent")
+ .setAttribute("label", msg);
+ }
+ },
+
+ isShowingBlockedContentNotification() {
+ return !!gComposeNotification.getNotificationWithValue("blockedContent");
+ },
+
+ clearBlockedContentNotification() {
+ gComposeNotification.removeNotification(
+ gComposeNotification.getNotificationWithValue("blockedContent")
+ );
+ },
+
+ clearNotifications(aValue) {
+ gComposeNotification.removeAllNotifications(true);
+ },
+
+ /**
+ * Show a warning notification when a newly typed identity in the Form field
+ * doesn't match any existing identity.
+ *
+ * @param {string} identity - The name of the identity to add to the
+ * notification. Most likely an email address.
+ */
+ async setIdentityWarning(identity) {
+ // Bail out if we are already showing this type of notification.
+ if (gComposeNotification.getNotificationWithValue("identityWarning")) {
+ return;
+ }
+
+ gComposeNotification.appendNotification(
+ "identityWarning",
+ {
+ label: await document.l10n.formatValue(
+ "compose-missing-identity-warning",
+ {
+ identity,
+ }
+ ),
+ priority: gComposeNotification.PRIORITY_WARNING_HIGH,
+ },
+ null
+ );
+ },
+
+ clearIdentityWarning() {
+ let idWarning =
+ gComposeNotification.getNotificationWithValue("identityWarning");
+ if (idWarning) {
+ gComposeNotification.removeNotification(idWarning);
+ }
+ },
+};
+
+/**
+ * Populate the menuitems of what blocked content to unblock.
+ */
+function onBlockedContentOptionsShowing(aEvent) {
+ let urls = aEvent.target.value ? aEvent.target.value.split(" ") : [];
+
+ // Out with the old...
+ while (aEvent.target.lastChild) {
+ aEvent.target.lastChild.remove();
+ }
+
+ // ... and in with the new.
+ for (let url of urls) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute(
+ "label",
+ getComposeBundle().getFormattedString("blockedAllowResource", [url])
+ );
+ menuitem.setAttribute("crop", "center");
+ menuitem.setAttribute("value", url);
+ menuitem.setAttribute(
+ "oncommand",
+ "onUnblockResource(this.value, this.parentNode);"
+ );
+ aEvent.target.appendChild(menuitem);
+ }
+}
+
+/**
+ * Handle clicking the "Load <url>" in the blocked content notification bar.
+ *
+ * @param {string} aURL - the URL that was unblocked
+ * @param {Node} aNode - the node holding as value the URLs of the blocked
+ * resources in the message (space separated).
+ */
+function onUnblockResource(aURL, aNode) {
+ try {
+ loadBlockedImage(aURL);
+ } catch (e) {
+ // Couldn't load the referenced image.
+ console.error(e);
+ } finally {
+ // Remove it from the list on success and failure.
+ let urls = aNode.value.split(" ");
+ for (let i = 0; i < urls.length; i++) {
+ if (urls[i] == aURL) {
+ urls.splice(i, 1);
+ aNode.value = urls.join(" ");
+ if (urls.length == 0) {
+ gComposeNotificationBar.clearBlockedContentNotification();
+ }
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * Convert the blocked content to a data URL and swap the src to that for the
+ * elements that were using it.
+ *
+ * @param {string} aURL - (necko) URL to unblock
+ * @param {Bool} aReturnDataURL - return data: URL instead of processing image
+ * @returns {string} the image as data: URL.
+ * @throw Error() if reading the data failed
+ */
+function loadBlockedImage(aURL, aReturnDataURL = false) {
+ let filename;
+ if (/^(file|chrome|moz-extension):/i.test(aURL)) {
+ filename = aURL.substr(aURL.lastIndexOf("/") + 1);
+ } else {
+ let fnMatch = /[?&;]filename=([^?&]+)/.exec(aURL);
+ filename = (fnMatch && fnMatch[1]) || "";
+ }
+ filename = decodeURIComponent(filename);
+ let uri = Services.io.newURI(aURL);
+ let contentType;
+ if (filename) {
+ try {
+ contentType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromURI(uri);
+ } catch (ex) {
+ contentType = "image/png";
+ }
+
+ if (!contentType.startsWith("image/")) {
+ // Unsafe to unblock this. It would just be garbage either way.
+ throw new Error(
+ "Won't unblock; URL=" + aURL + ", contentType=" + contentType
+ );
+ }
+ } else {
+ // Assuming image/png is the best we can do.
+ contentType = "image/png";
+ }
+ let channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let inputStream = channel.open();
+ let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(inputStream);
+ let streamData = "";
+ try {
+ while (stream.available() > 0) {
+ streamData += stream.readBytes(stream.available());
+ }
+ } catch (e) {
+ stream.close();
+ throw new Error("Couldn't read all data from URL=" + aURL + " (" + e + ")");
+ }
+ stream.close();
+ let encoded = btoa(streamData);
+ let dataURL =
+ "data:" +
+ contentType +
+ (filename ? ";filename=" + encodeURIComponent(filename) : "") +
+ ";base64," +
+ encoded;
+
+ if (aReturnDataURL) {
+ return dataURL;
+ }
+
+ let editor = GetCurrentEditor();
+ for (let img of editor.document.images) {
+ if (img.src == aURL) {
+ img.src = dataURL; // Swap to data URL.
+ img.classList.remove("loading-internal");
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Update state of encrypted/signed toolbar buttons
+ */
+function showSendEncryptedAndSigned() {
+ let encToggle = document.getElementById("button-encryption");
+ if (encToggle) {
+ if (gSendEncrypted) {
+ encToggle.setAttribute("checked", "true");
+ } else {
+ encToggle.removeAttribute("checked");
+ }
+ }
+
+ let sigToggle = document.getElementById("button-signing");
+ if (sigToggle) {
+ if (gSendSigned) {
+ sigToggle.setAttribute("checked", "true");
+ } else {
+ sigToggle.removeAttribute("checked");
+ }
+ }
+
+ // Should button remain enabled? Identity might be unable to
+ // encrypt, but we might have kept button enabled after identity change.
+ let identityHasConfiguredSMIME =
+ isSmimeSigningConfigured() || isSmimeEncryptionConfigured();
+ let identityHasConfiguredOpenPGP = isPgpConfigured();
+ let e2eeNotConfigured =
+ !identityHasConfiguredOpenPGP && !identityHasConfiguredSMIME;
+
+ if (encToggle) {
+ encToggle.disabled = e2eeNotConfigured && !gSendEncrypted;
+ }
+ if (sigToggle) {
+ sigToggle.disabled = e2eeNotConfigured;
+ }
+}
+
+/**
+ * Look at the current encryption setting, and perform necessary
+ * automatic adjustments to related settings.
+ */
+function updateEncryptionDependencies() {
+ let canSign = gSelectedTechnologyIsPGP
+ ? isPgpConfigured()
+ : isSmimeSigningConfigured();
+
+ if (!canSign) {
+ gSendSigned = false;
+ gUserTouchedSendSigned = false;
+ } else if (!gSendEncrypted) {
+ if (!gUserTouchedSendSigned) {
+ gSendSigned = gCurrentIdentity.signMail;
+ }
+ } else if (!gUserTouchedSendSigned) {
+ gSendSigned = true;
+ }
+
+ // if (!gSendEncrypted) we don't need to change gEncryptSubject,
+ // it will be ignored anyway.
+ if (gSendEncrypted) {
+ if (!gUserTouchedEncryptSubject) {
+ gEncryptSubject = gCurrentIdentity.protectSubject;
+ }
+ }
+
+ if (!gSendSigned) {
+ if (!gUserTouchedAttachMyPubKey) {
+ gAttachMyPublicPGPKey = false;
+ }
+ } else if (!gUserTouchedAttachMyPubKey) {
+ gAttachMyPublicPGPKey = gCurrentIdentity.attachPgpKey;
+ }
+
+ if (!gSendEncrypted) {
+ clearRecipPillKeyIssues();
+ }
+
+ if (gSMFields && !gSelectedTechnologyIsPGP) {
+ gSMFields.requireEncryptMessage = gSendEncrypted;
+ gSMFields.signMessage = gSendSigned;
+ }
+
+ updateAttachMyPubKey();
+
+ updateEncryptedSubject();
+ showSendEncryptedAndSigned();
+
+ updateEncryptOptionsMenuElements();
+ checkEncryptedBccRecipients();
+}
+
+/**
+ * Listen to the click events on the compose window.
+ *
+ * @param {Event} event - The DOM Event
+ */
+function composeWindowOnClick(event) {
+ // Don't deselect pills if the click happened on another pill as the selection
+ // and focus change is handled by the pill itself. We also ignore clicks on
+ // toolbarbuttons, menus, and menu items. This will also prevent the unwanted
+ // deselection when opening the context menu on macOS.
+ if (
+ event.target?.tagName == "mail-address-pill" ||
+ event.target?.tagName == "toolbarbutton" ||
+ event.target?.tagName == "menu" ||
+ event.target?.tagName == "menuitem"
+ ) {
+ return;
+ }
+
+ document.getElementById("recipientsContainer").deselectAllPills();
+}
diff --git a/comm/mail/components/compose/content/addressingWidgetOverlay.js b/comm/mail/components/compose/content/addressingWidgetOverlay.js
new file mode 100644
index 0000000000..cee4b6889e
--- /dev/null
+++ b/comm/mail/components/compose/content/addressingWidgetOverlay.js
@@ -0,0 +1,1336 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 MsgComposeCommands.js */
+/* import-globals-from ../../addrbook/content/abCommon.js */
+/* globals goDoCommand */ // From globalOverlay.js
+
+var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+var { DisplayNameUtils } = ChromeUtils.import(
+ "resource:///modules/DisplayNameUtils.jsm"
+);
+
+// Temporarily prevent repeated deletion key events in address rows or subject.
+// Prevent the keyboard shortcut for removing an empty address row (long
+// Backspace or Delete keypress) from affecting another row. Also, when a long
+// deletion keypress has just removed all text or all visible text from a row
+// input, prevent the ongoing keypress from removing the row.
+var gPreventRowDeletionKeysRepeat = false;
+
+/**
+ * Convert all the written recipients into string and store them into the
+ * msgCompFields array to be printed in the message header.
+ *
+ * @param {object} msgCompFields - An object to receive the recipients.
+ */
+function Recipients2CompFields(msgCompFields) {
+ if (!msgCompFields) {
+ throw new Error(
+ "Message Compose Error: msgCompFields is null (ExtractRecipients)"
+ );
+ }
+
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+ for (let row of document.querySelectorAll(".address-row-raw")) {
+ let recipientType = row.dataset.recipienttype;
+ let headerValue = row.querySelector(".address-row-input").value.trim();
+ if (headerValue) {
+ msgCompFields.setRawHeader(recipientType, headerValue);
+ } else if (otherHeaders.includes(recipientType)) {
+ msgCompFields.deleteHeader(recipientType);
+ }
+ }
+
+ let getRecipientList = recipientType =>
+ Array.from(
+ document.querySelectorAll(
+ `.address-row[data-recipienttype="${recipientType}"] mail-address-pill`
+ ),
+ pill => {
+ // Expect each pill to contain exactly one address.
+ let { name, email } = MailServices.headerParser.makeFromDisplayAddress(
+ pill.fullAddress
+ )[0];
+ return MailServices.headerParser.makeMimeAddress(name, email);
+ }
+ ).join(",");
+
+ msgCompFields.to = getRecipientList("addr_to");
+ msgCompFields.cc = getRecipientList("addr_cc");
+ msgCompFields.bcc = getRecipientList("addr_bcc");
+ msgCompFields.replyTo = getRecipientList("addr_reply");
+ msgCompFields.newsgroups = getRecipientList("addr_newsgroups");
+ msgCompFields.followupTo = getRecipientList("addr_followup");
+}
+
+/**
+ * Replace the specified address row's pills with new ones generated by the
+ * given header value. The address row will be automatically shown if the header
+ * value is non-empty.
+ *
+ * @param {string} rowId - The id of the address row to set.
+ * @param {string} headerValue - The headerValue to create pills from.
+ * @param {boolean} multi - If the headerValue contains potentially multiple
+ * addresses and needs to be parsed to extract them.
+ * @param {boolean} [forceShow=false] - Whether to show the row, even if the
+ * given value is empty.
+ */
+function setAddressRowFromCompField(
+ rowId,
+ headerValue,
+ multi,
+ forceShow = false
+) {
+ let row = document.getElementById(rowId);
+ addressRowClearPills(row);
+
+ let value = multi
+ ? MailServices.headerParser.parseEncodedHeaderW(headerValue).join(", ")
+ : headerValue;
+
+ if (value || forceShow) {
+ addressRowSetVisibility(row, true);
+ }
+ if (value) {
+ let input = row.querySelector(".address-row-input");
+ input.value = value;
+ recipientAddPills(input, true);
+ }
+}
+
+/**
+ * Convert all the recipients coming from a message header into pills.
+ *
+ * @param {object} msgCompFields - An object containing all the recipients. If
+ * any property is not a string, it is ignored.
+ */
+function CompFields2Recipients(msgCompFields) {
+ if (msgCompFields) {
+ // Populate all the recipients with the proper values.
+ if (typeof msgCompFields.replyTo == "string") {
+ setAddressRowFromCompField(
+ "addressRowReply",
+ msgCompFields.replyTo,
+ true
+ );
+ }
+
+ if (typeof msgCompFields.to == "string") {
+ setAddressRowFromCompField("addressRowTo", msgCompFields.to, true);
+ }
+
+ if (typeof msgCompFields.cc == "string") {
+ setAddressRowFromCompField(
+ "addressRowCc",
+ msgCompFields.cc,
+ true,
+ gCurrentIdentity.doCc
+ );
+ }
+
+ if (typeof msgCompFields.bcc == "string") {
+ setAddressRowFromCompField(
+ "addressRowBcc",
+ msgCompFields.bcc,
+ true,
+ gCurrentIdentity.doBcc
+ );
+ }
+
+ if (typeof msgCompFields.newsgroups == "string") {
+ setAddressRowFromCompField(
+ "addressRowNewsgroups",
+ msgCompFields.newsgroups,
+ false
+ );
+ }
+
+ if (typeof msgCompFields.followupTo == "string") {
+ setAddressRowFromCompField(
+ "addressRowFollowup",
+ msgCompFields.followupTo,
+ true
+ );
+ }
+
+ // Add the sender to our spell check ignore list.
+ if (gCurrentIdentity) {
+ addRecipientsToIgnoreList(gCurrentIdentity.fullAddress);
+ }
+
+ // Trigger this method only after all the pills have been created.
+ onRecipientsChanged(true);
+ }
+}
+
+/**
+ * Update the recipients area UI to show News related fields and hide
+ * Mail related fields.
+ */
+function updateUIforNNTPAccount() {
+ // Hide the `mail-primary-input` field row if no pills have been created.
+ let mailContainer = document
+ .querySelector(".mail-primary-input")
+ .closest(".address-container");
+ if (mailContainer.querySelectorAll("mail-address-pill").length == 0) {
+ mailContainer
+ .closest(".address-row")
+ .querySelector(".remove-field-button")
+ .click();
+ }
+
+ // Show the closing label.
+ mailContainer
+ .closest(".address-row")
+ .querySelector(".remove-field-button").hidden = false;
+
+ // Show the `news-primary-input` field row if not already visible.
+ let newsContainer = document
+ .querySelector(".news-primary-input")
+ .closest(".address-row");
+ showAndFocusAddressRow(newsContainer.id);
+
+ // Hide the closing label.
+ newsContainer.querySelector(".remove-field-button").hidden = true;
+
+ // Prefer showing the buttons for news-show-row-menuitem items.
+ for (let item of document.querySelectorAll(".news-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, true);
+ }
+
+ for (let item of document.querySelectorAll(".mail-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, false);
+ }
+}
+
+/**
+ * Update the recipients area UI to show Mail related fields and hide
+ * News related fields. This method is called only if the UI was previously
+ * updated to accommodate a News account type.
+ */
+function updateUIforMailAccount() {
+ // Show the `mail-primary-input` field row if not already visible.
+ let mailContainer = document
+ .querySelector(".mail-primary-input")
+ .closest(".address-row");
+ showAndFocusAddressRow(mailContainer.id);
+
+ // Hide the closing label.
+ mailContainer.querySelector(".remove-field-button").hidden = true;
+
+ // Hide the `news-primary-input` field row if no pills have been created.
+ let newsContainer = document
+ .querySelector(".news-primary-input")
+ .closest(".address-row");
+ if (newsContainer.querySelectorAll("mail-address-pill").length == 0) {
+ newsContainer.querySelector(".remove-field-button").click();
+ }
+
+ // Show the closing label.
+ newsContainer.querySelector(".remove-field-button").hidden = false;
+
+ // Prefer showing the buttons for mail-show-row-menuitem items.
+ for (let item of document.querySelectorAll(".mail-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, true);
+ }
+
+ for (let item of document.querySelectorAll(".news-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, false);
+ }
+}
+
+/**
+ * Remove recipient pills from a specific addressing field based on full address
+ * matching. This is commonly used to clear previous Auto-CC/BCC recipients when
+ * loading a new identity.
+ *
+ * @param {object} msgCompFields - gMsgCompose.compFields, for helper functions.
+ * @param {string} recipientType - The type of recipients to remove,
+ * e.g. "addr_to" (recipient label id).
+ * @param {string} recipientsList - Comma-separated string containing recipients
+ * to be removed. May contain display names, and other commas therein. We only
+ * remove first exact match (full address).
+ */
+function awRemoveRecipients(msgCompFields, recipientType, recipientsList) {
+ if (!recipientType || !recipientsList) {
+ return;
+ }
+
+ let container;
+ switch (recipientType) {
+ case "addr_cc":
+ container = document.getElementById("ccAddrContainer");
+ break;
+ case "addr_bcc":
+ container = document.getElementById("bccAddrContainer");
+ break;
+ case "addr_reply":
+ container = document.getElementById("replyAddrContainer");
+ break;
+ case "addr_to":
+ container = document.getElementById("toAddrContainer");
+ break;
+ }
+
+ // Convert csv string of recipients to be deleted into full addresses array.
+ let recipientsArray = msgCompFields.splitRecipients(recipientsList, false);
+
+ // Remove first instance of specified recipients from specified container.
+ for (let recipientFullAddress of recipientsArray) {
+ let pill = container.querySelector(
+ `mail-address-pill[fullAddress="${recipientFullAddress}"]`
+ );
+ if (pill) {
+ pill.remove();
+ }
+ }
+
+ let addressRow = container.closest(`.address-row`);
+
+ // Remove entire address row if empty, no user input, and not type "addr_to".
+ if (
+ recipientType != "addr_to" &&
+ !container.querySelector(`mail-address-pill`) &&
+ !container.querySelector(`input[is="autocomplete-input"]`).value
+ ) {
+ addressRowSetVisibility(addressRow, false);
+ }
+
+ updateAriaLabelsOfAddressRow(addressRow);
+}
+
+/**
+ * Adds a batch of new rows matching recipientType and drops in the list of addresses.
+ *
+ * @param msgCompFields A nsIMsgCompFields object that is only used as a helper,
+ * it will not get the addresses appended.
+ * @param recipientType Type of recipient, e.g. "addr_to".
+ * @param recipientList A string of addresses to add.
+ */
+function awAddRecipients(msgCompFields, recipientType, recipientsList) {
+ if (!msgCompFields || !recipientsList) {
+ return;
+ }
+
+ addressRowAddRecipientsArray(
+ document.querySelector(
+ `.address-row[data-recipienttype="${recipientType}"]`
+ ),
+ msgCompFields.splitRecipients(recipientsList, false)
+ );
+}
+
+/**
+ * Adds a batch of new recipient pill matching recipientType and drops in the
+ * array of addresses.
+ *
+ * @param {Element} row - The row to add the addresses to.
+ * @param {string[]} addressArray - Recipient addresses (strings) to add.
+ * @param {boolean=false} select - If the newly generated pills should be
+ * selected.
+ */
+function addressRowAddRecipientsArray(row, addressArray, select = false) {
+ let addresses = [];
+ for (let addr of addressArray) {
+ addresses.push(...MailServices.headerParser.makeFromDisplayAddress(addr));
+ }
+
+ if (row.classList.contains("hidden")) {
+ showAndFocusAddressRow(row.id, true);
+ }
+
+ let recipientArea = document.getElementById("recipientsContainer");
+ let input = row.querySelector(".address-row-input");
+ for (let address of addresses) {
+ let pill = recipientArea.createRecipientPill(input, address);
+ if (select) {
+ pill.setAttribute("selected", "selected");
+ }
+ }
+
+ row
+ .querySelector(".address-container")
+ .classList.add("addressing-field-edited");
+
+ // Add the recipients to our spell check ignore list.
+ addRecipientsToIgnoreList(addressArray.join(", "));
+ updateAriaLabelsOfAddressRow(row);
+
+ if (row.id != "addressRowReply") {
+ onRecipientsChanged();
+ }
+}
+
+/**
+ * Find the autocomplete input when an address is dropped in the compose header.
+ *
+ * @param {XULElement} target - The element where an address was dropped.
+ * @param {string} recipient - The email address dragged by the user.
+ */
+function DropRecipient(target, recipient) {
+ let row;
+ if (target.classList.contains("address-row")) {
+ row = target;
+ } else if (target.dataset.addressRow) {
+ row = document.getElementById(target.dataset.addressRow);
+ } else {
+ row = target.closest(".address-row");
+ }
+ if (!row || row.classList.contains("address-row-raw")) {
+ return;
+ }
+
+ addressRowAddRecipientsArray(row, [recipient]);
+}
+
+// Returns the load context for the current window
+function getLoadContext() {
+ return window.docShell.QueryInterface(Ci.nsILoadContext);
+}
+
+/**
+ * Focus the next available address row's input. Otherwise, focus the "Subject"
+ * input.
+ *
+ * @param {Element} currentInput - The current input to search from.
+ */
+function focusNextAddressRow(currentInput) {
+ let addressRow = currentInput.closest(".address-row").nextElementSibling;
+ while (addressRow) {
+ if (focusAddressRowInput(addressRow)) {
+ return;
+ }
+ addressRow = addressRow.nextElementSibling;
+ }
+ focusSubjectInput();
+}
+
+/**
+ * Handle keydown events for other header input fields in the compose window.
+ * Only applies to rows created from mail.compose.other.header pref; no pills.
+ * Keep behaviour in sync with addressInputOnBeforeHandleKeyDown().
+ *
+ * @param {Event} event - The DOM keydown event.
+ */
+function otherHeaderInputOnKeyDown(event) {
+ let input = event.target;
+
+ switch (event.key) {
+ case " ":
+ // If the existing input value is empty string or whitespace only,
+ // prevent entering space and clear whitespace-only input text.
+ if (!input.value.trim()) {
+ event.preventDefault();
+ input.value = "";
+ }
+ break;
+
+ case "Enter":
+ // Break if modifier keys were used, to prevent hijacking unrelated
+ // keyboard shortcuts like Ctrl/Cmd+[Shift]+Enter for sending.
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
+ break;
+ }
+
+ // Enter was pressed: Focus the next available address row or subject.
+ // Prevent Enter from firing again on the element we move the focus to.
+ event.preventDefault();
+ focusNextAddressRow(input);
+ break;
+
+ case "Backspace":
+ case "Delete":
+ if (event.repeat && gPreventRowDeletionKeysRepeat) {
+ // Prevent repeated deletion keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion in case of a non-repeated deletion keydown
+ // event, or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ !event.repeat ||
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]") ||
+ event.altKey
+ ) {
+ // Break if it is not a long deletion keypress, input still has text,
+ // or cursor selection is not at position 0 while deleting whitespace,
+ // to allow regular text deletion before we remove the row.
+ // Also break for non-removable rows with hidden [x] button, and if Alt
+ // key is pressed, to avoid interfering with undo shortcut Alt+Backspace.
+ break;
+ }
+ // Prevent event and set flag to prevent further unwarranted deletion in
+ // the adjacent row, which will receive focus while the key is still down.
+ event.preventDefault();
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated
+ // deletion keydown event occurred, and it has an [x] button for removal.
+ hideAddressRowFromWithin(
+ input,
+ event.key == "Backspace" ? "previous" : "next"
+ );
+ break;
+ }
+}
+
+/**
+ * Handle keydown events for autocomplete address inputs in the compose window.
+ * Does not apply to rows created from mail.compose.other.header pref, which are
+ * handled with a subset of this function in otherHeaderInputOnKeyDown().
+ *
+ * @param {Event} event - The DOM keydown event.
+ */
+function addressInputOnBeforeHandleKeyDown(event) {
+ let input = event.target;
+
+ switch (event.key) {
+ case "a":
+ // Break if there's text in the input, if not Ctrl/Cmd+A, or for other
+ // modifiers, to not hijack our own (Ctrl/Cmd+Shift+A) or OS shortcuts.
+ if (
+ input.value ||
+ !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ break;
+ }
+
+ // Ctrl/Cmd+A on empty input: Select all pills of the current row.
+ // Prevent a pill keypress event when the focus moves on it.
+ event.preventDefault();
+
+ let lastPill = input
+ .closest(".address-container")
+ .querySelector("mail-address-pill:last-of-type");
+ let mailRecipientsArea = input.closest("mail-recipients-area");
+ if (lastPill) {
+ // Select all pills of current address row.
+ mailRecipientsArea.selectSiblingPills(lastPill);
+ lastPill.focus();
+ break;
+ }
+ // No pills in the current address row, select all pills in all rows.
+ let lastPillGlobal = mailRecipientsArea.querySelector(
+ "mail-address-pill:last-of-type"
+ );
+ if (lastPillGlobal) {
+ mailRecipientsArea.selectAllPills();
+ lastPillGlobal.focus();
+ }
+ break;
+
+ case " ":
+ case ",":
+ let selection = input.value.substring(
+ input.selectionStart,
+ input.selectionEnd
+ );
+
+ // If keydown would normally replace all of the current trimmed input,
+ // including if the current input is empty, then suppress the key and
+ // clear the input instead.
+ if (selection.includes(input.value.trim())) {
+ event.preventDefault();
+ input.value = "";
+ break;
+ }
+
+ // Otherwise, comma may trigger pill creation.
+ if (event.key !== ",") {
+ break;
+ }
+
+ let beforeComma;
+ let afterComma;
+ if (input.selectionEnd == input.selectionStart) {
+ // If there is no selected text, we will try to create a pill for the
+ // text prior to the typed comma.
+ // NOTE: This also captures auto complete suggestions that are not
+ // inline. E.g. suggestion popup is shown and the user selects one with
+ // the arrow keys.
+ beforeComma = input.value.substring(0, input.selectionEnd);
+ afterComma = input.value.substring(input.selectionEnd);
+ // Only create a pill for valid addresses.
+ if (!isValidAddress(beforeComma)) {
+ break;
+ }
+ } else if (
+ // There is an auto complete suggestion ...
+ input.controller.searchStatus ==
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH &&
+ input.controller.matchCount &&
+ // that is also shown inline (the end of the input is selected).
+ input.selectionEnd == input.value.length
+ // NOTE: This should exclude cases where no suggestion is selected (user
+ // presses "DownArrow" then "UpArrow" when the suggestion pops up), or
+ // if the suggestions were cancelled with "Esc", or the inline
+ // suggestion was cleared with "Backspace".
+ ) {
+ if (input.value[input.selectionStart] == ",") {
+ // Don't create the pill in the special case where the auto-complete
+ // suggestion starts with a comma.
+ break;
+ }
+ // Complete the suggestion as a pill.
+ beforeComma = input.value;
+ afterComma = "";
+ } else {
+ // If any other part of the text is selected, we treat it as normal.
+ break;
+ }
+
+ event.preventDefault();
+ input.value = beforeComma;
+ input.handleEnter(event);
+ // Keep any left over text in the input.
+ input.value = afterComma;
+ // Keep the cursor at the same position.
+ input.selectionStart = 0;
+ input.selectionEnd = 0;
+ break;
+
+ case "Home":
+ case "ArrowLeft":
+ case "Backspace":
+ if (
+ event.key == "Backspace" &&
+ event.repeat &&
+ gPreventRowDeletionKeysRepeat
+ ) {
+ // Prevent repeated backspace keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion if Home or ArrowLeft were pressed, or if it is
+ // a non-repeated Backspace keydown event, or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ event.altKey
+ ) {
+ // Break and allow the key's default behavior if the row has content,
+ // or the cursor is not at position 0, or the Alt modifier is pressed.
+ break;
+ }
+ // Navigate into pills if there are any, and if the input is empty or
+ // whitespace-only, and the cursor is at position 0, and the Alt key was
+ // not used (prevent undo via Alt+Backspace from deleting pills).
+ // We'll sanitize whitespace on blur.
+
+ // Prevent a pill keypress event when the focus moves on it, or prevent
+ // deletion in previous row after removing current row via long keydown.
+ event.preventDefault();
+
+ let targetPill = input
+ .closest(".address-container")
+ .querySelector(
+ "mail-address-pill" + (event.key == "Home" ? "" : ":last-of-type")
+ );
+ if (targetPill) {
+ if (event.repeat) {
+ // Prevent navigating into pills for repeated keydown from the middle
+ // of whitespace.
+ break;
+ }
+ input
+ .closest("mail-recipients-area")
+ .checkKeyboardSelected(event, targetPill);
+ // Prevent removing the current row after deleting the last pill with
+ // repeated deletion keydown.
+ gPreventRowDeletionKeysRepeat = true;
+ break;
+ }
+
+ // No pill found, so the address row is empty except whitespace.
+ // Check for long Backspace keyboard shortcut to remove the row.
+ if (
+ event.key != "Backspace" ||
+ !event.repeat ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]")
+ ) {
+ break;
+ }
+ // Set flag to prevent further unwarranted deletion in the previous row,
+ // which will receive focus while the key is still down. We have already
+ // prevented the event above.
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated
+ // Backspace keydown event occurred, and it has an [x] button for removal.
+ hideAddressRowFromWithin(input, "previous");
+ break;
+
+ case "Delete":
+ if (event.repeat && gPreventRowDeletionKeysRepeat) {
+ // Prevent repeated Delete keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion in case of a non-repeated Delete keydown event,
+ // or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ !event.repeat ||
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ input
+ .closest(".address-container")
+ .querySelector("mail-address-pill") ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]")
+ ) {
+ // Break and allow the key's default behaviour if the address row has
+ // content, or the cursor is not at position 0, or the row is not
+ // removable.
+ break;
+ }
+ // Prevent the event and set flag to prevent further unwarranted deletion
+ // in the next row, which will receive focus while the key is still down.
+ event.preventDefault();
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated Delete
+ // keydown event occurred, cursor is at position 0, and it has an
+ // [x] button for removal.
+ hideAddressRowFromWithin(input, "next");
+ break;
+
+ case "Enter":
+ // Break if unrelated modifier keys are used. The toolkit hack for Mac
+ // will consume metaKey, and we'll exclude shiftKey after that.
+ if (event.ctrlKey || event.altKey) {
+ break;
+ }
+
+ // MacOS-only variation necessary to send messages via Cmd+[Shift]+Enter
+ // since autocomplete input fields prevent that by default (bug 1682147).
+ if (event.metaKey) {
+ // Cmd+[Shift]+Enter: Send message [later].
+ let sendCmd = event.shiftKey ? "cmd_sendLater" : "cmd_sendWithCheck";
+ goDoCommand(sendCmd);
+ break;
+ }
+
+ // Break if there's text in the address input, or if Shift modifier is
+ // used, to prevent hijacking shortcuts like Ctrl+Shift+Enter.
+ if (input.value.trim() || event.shiftKey) {
+ break;
+ }
+
+ // Enter on empty input: Focus the next available address row or subject.
+ // Prevent Enter from firing again on the element we move the focus to.
+ event.preventDefault();
+ focusNextAddressRow(input);
+ break;
+
+ case "Tab":
+ // Return if the Alt or Cmd modifiers were pressed, meaning the user is
+ // switching between windows and not tabbing out of the address input.
+ if (event.altKey || event.metaKey) {
+ break;
+ }
+ // Trigger the autocomplete controller only if we have a value,
+ // to prevent interfering with the natural change of focus on Tab.
+ if (input.value.trim()) {
+ // Prevent Tab from firing again on address input after pill creation.
+ event.preventDefault();
+
+ // Use the setTimeout only if the input field implements a forced
+ // autocomplete and we don't have any match as we might need to wait for
+ // the autocomplete suggestions to show up.
+ if (input.forceComplete && input.mController.matchCount == 0) {
+ // Prevent fast user input to become an error pill before
+ // autocompletion kicks in with its default timeout.
+ setTimeout(() => {
+ input.handleEnter(event);
+ }, input.timeout);
+ } else {
+ input.handleEnter(event);
+ }
+ }
+
+ // Handle Shift+Tab, but not Ctrl+Shift+Tab, which is handled by
+ // moveFocusToNeighbouringAreas.
+ if (event.shiftKey && !event.ctrlKey) {
+ event.preventDefault();
+ input.closest("mail-recipients-area").moveFocusToPreviousElement(input);
+ }
+ break;
+ }
+}
+
+/**
+ * Handle input events for all types of address inputs in the compose window.
+ *
+ * @param {Event} event - A DOM input event.
+ * @param {boolean} rawInput - A flag for plain text inputs created via
+ * mail.compose.other.header, which do not have autocompletion and pills.
+ */
+function addressInputOnInput(event, rawInput) {
+ let input = event.target;
+
+ if (
+ !input.value ||
+ (!input.value.trim() &&
+ input.selectionStart + input.selectionEnd == 0 &&
+ event.inputType == "deleteContentBackward")
+ ) {
+ // Temporarily disable repeated deletion to prevent premature
+ // removal of the current row if input text has just become empty or
+ // whitespace-only with cursor at position 0 from backwards deletion.
+ gPreventRowDeletionKeysRepeat = true;
+ }
+
+ if (rawInput) {
+ // For raw inputs, we are done.
+ return;
+ }
+ // Now handling only autocomplete inputs.
+
+ // Trigger onRecipientsChanged() for every input text change in order
+ // to properly update the "Send" button and trigger the save as draft
+ // prompt even before the creation of any pill.
+ onRecipientsChanged();
+
+ // Change the min size of the input field on input change only if the
+ // current width is smaller than 80% of its container's width
+ // to prevent overflow.
+ if (
+ input.clientWidth <
+ input.closest(".address-container").clientWidth * 0.8
+ ) {
+ document
+ .getElementById("recipientsContainer")
+ .resizeInputField(input, input.value.trim().length);
+ }
+}
+
+/**
+ * Add one or more <mail-address-pill> elements to the containing address row.
+ *
+ * @param {Element} input - Address input where "autocomplete-did-enter-text"
+ * was observed, and/or to whose containing address row pill(s) will be added.
+ * @param {boolean} [automatic=false] - Set to true if the change of recipients
+ * was invoked programmatically and should not be considered a change of
+ * message content.
+ */
+function recipientAddPills(input, automatic = false) {
+ if (!input.value.trim()) {
+ return;
+ }
+
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(input.value);
+ let recipientArea = document.getElementById("recipientsContainer");
+
+ for (let address of addresses) {
+ recipientArea.createRecipientPill(input, address);
+ }
+
+ // Add the just added recipient address(es) to the spellcheck ignore list.
+ addRecipientsToIgnoreList(input.value.trim());
+
+ // Reset the input element.
+ input.removeAttribute("nomatch");
+ input.setAttribute("size", 1);
+ input.value = "";
+
+ // We need to detach the autocomplete Controller to prevent the input
+ // to be filled with the previously selected address when the "blur" event
+ // gets triggered.
+ input.detachController();
+ // If it was detached, attach it again to enable autocomplete.
+ if (!input.controller.input) {
+ input.attachController();
+ }
+
+ // Prevent triggering some methods if the pill creation was done automatically
+ // for example during the move of an existing pill between addressing fields.
+ if (!automatic) {
+ input
+ .closest(".address-container")
+ .classList.add("addressing-field-edited");
+ onRecipientsChanged();
+ }
+
+ updateAriaLabelsOfAddressRow(input.closest(".address-row"));
+}
+
+/**
+ * Remove all <mail-address-pill> elements from the containing address row.
+ *
+ * @param {Element} row - The address row to clear.
+ */
+function addressRowClearPills(row) {
+ for (let pill of row.querySelectorAll(
+ ".address-container mail-address-pill"
+ )) {
+ pill.remove();
+ }
+ updateAriaLabelsOfAddressRow(row);
+}
+
+/**
+ * Handle focus event of address inputs: Force a focused styling on the closest
+ * address container of the currently focused input element.
+ *
+ * @param {Element} input - The address input element receiving focus.
+ */
+function addressInputOnFocus(input) {
+ input.closest(".address-container").setAttribute("focused", "true");
+}
+
+/**
+ * Handle blur event of address inputs: Remove focused styling from the closest
+ * address container and create address pills if valid recipients were written.
+ *
+ * @param {Element} input - The input element losing focus.
+ */
+function addressInputOnBlur(input) {
+ input.closest(".address-container").removeAttribute("focused");
+
+ // If the input is still the active element after blur (when switching to
+ // another window), return to prevent autocompletion and pillification
+ // and let the user continue editing the address later where he left.
+ if (document.activeElement == input) {
+ return;
+ }
+
+ // For other headers aka raw input, trim and we are done.
+ if (input.getAttribute("is") != "autocomplete-input") {
+ input.value = input.value.trim();
+ return;
+ }
+
+ let address = input.value.trim();
+ if (!address) {
+ // If input is empty or whitespace only, clear input to remove any leftover
+ // whitespace, reset the input size, and return.
+ input.value = "";
+ input.setAttribute("size", 1);
+ return;
+ }
+
+ if (input.forceComplete && input.mController.matchCount >= 1) {
+ // If input.forceComplete is true and there are autocomplete matches,
+ // we need to call the inbuilt Enter handler to force the input text
+ // to the best autocomplete match because we've set input._dontBlur.
+ input.mController.handleEnter(true);
+ return;
+ }
+
+ // Otherwise, try to parse the input text as comma-separated recipients and
+ // convert them into recipient pills.
+ let listNames = MimeParser.parseHeaderField(
+ address,
+ MimeParser.HEADER_ADDRESS
+ );
+ let isMailingList =
+ listNames.length > 0 &&
+ MailServices.ab.mailListNameExists(listNames[0].name);
+
+ if (
+ address &&
+ (isValidAddress(address) ||
+ isMailingList ||
+ input.classList.contains("news-input"))
+ ) {
+ recipientAddPills(input);
+ }
+
+ // Trim any remaining input for which we didn't create a pill.
+ if (input.value.trim()) {
+ input.value = input.value.trim();
+ }
+}
+
+/**
+ * Trigger the startEditing() method of the mail-address-pill element.
+ *
+ * @param {XULlement} element - The element from which the context menu was
+ * opened.
+ * @param {Event} event - The DOM event.
+ */
+function editAddressPill(element, event) {
+ document
+ .getElementById("recipientsContainer")
+ .startEditing(element.closest("mail-address-pill"), event);
+}
+
+/**
+ * Expands all the selected mailing list pills into their composite addresses.
+ *
+ * @param {XULlement} element - The element from which the context menu was
+ * opened.
+ */
+function expandList(element) {
+ let pill = element.closest("mail-address-pill");
+ if (pill.isMailList) {
+ let addresses = [];
+ for (let currentPill of pill.parentNode.querySelectorAll(
+ "mail-address-pill"
+ )) {
+ if (currentPill == pill) {
+ let dir = MailServices.ab.getDirectory(pill.listURI);
+ if (dir) {
+ for (let card of dir.childCards) {
+ addresses.push(makeMailboxObjectFromCard(card));
+ }
+ }
+ } else {
+ addresses.push(currentPill.fullAddress);
+ }
+ }
+ let row = pill.closest(".address-row");
+ addressRowClearPills(row);
+ addressRowAddRecipientsArray(row, addresses, false);
+ }
+}
+
+/**
+ * Handle the disabling of context menu items according to the types and count
+ * of selected pills.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+function onPillPopupShowing(event) {
+ let menu = event.target;
+ // Reset previously hidden menuitems.
+ for (let menuitem of menu.querySelectorAll(
+ ".pill-action-move, .pill-action-edit"
+ )) {
+ menuitem.hidden = false;
+ }
+
+ let recipientsContainer = document.getElementById("recipientsContainer");
+
+ // Check if the pill where the context menu was originated is not selected.
+ let pill = event.explicitOriginalTarget.closest("mail-address-pill");
+ if (!pill.hasAttribute("selected")) {
+ recipientsContainer.deselectAllPills();
+ pill.setAttribute("selected", "selected");
+ }
+
+ let allSelectedPills = recipientsContainer.getAllSelectedPills();
+ // If more than one pill is selected, hide the editing item.
+ if (recipientsContainer.getAllSelectedPills().length > 1) {
+ menu.querySelector("#editAddressPill").hidden = true;
+ }
+
+ // Update the recipient type in the menu label of #menu_selectAllSiblingPills.
+ let type = pill
+ .closest(".address-row")
+ .querySelector(".address-label-container > label").value;
+ document.l10n.setAttributes(
+ menu.querySelector("#menu_selectAllSiblingPills"),
+ "pill-action-select-all-sibling-pills",
+ { type }
+ );
+
+ // Hide the `Expand List` menuitem and the preceding menuseparator if not all
+ // selected pills are mailing lists.
+ let isNotMailingList = [...allSelectedPills].some(pill => !pill.isMailList);
+ menu.querySelector("#expandList").hidden = isNotMailingList;
+ menu.querySelector("#pillContextBeforeExpandListSeparator").hidden =
+ isNotMailingList;
+
+ // If any Newsgroup or Followup pill is selected, hide all move actions.
+ if (
+ recipientsContainer.querySelector(
+ ":is(#addressRowNewsgroups, #addressRowFollowup) " +
+ "mail-address-pill[selected]"
+ )
+ ) {
+ for (let menuitem of menu.querySelectorAll(".pill-action-move")) {
+ menuitem.hidden = true;
+ }
+ // Hide the menuseparator before the move items, as there's nothing below.
+ menu.querySelector("#pillContextBeforeMoveItemsSeparator").hidden = true;
+ return;
+ }
+ // Show the menuseparator before the move items as no Newsgroup or Followup
+ // pill is selected.
+ menu.querySelector("#pillContextBeforeMoveItemsSeparator").hidden = false;
+
+ let selectedType = "";
+ // Check if all selected pills are in the same address row.
+ for (let row of recipientsContainer.querySelectorAll(
+ ".address-row:not(.hidden)"
+ )) {
+ // Check if there's at least one selected pill in the address row.
+ let selectedPill = row.querySelector("mail-address-pill[selected]");
+ if (!selectedPill) {
+ continue;
+ }
+ // Return if we already have a selectedType: More than one type selected.
+ if (selectedType) {
+ return;
+ }
+ selectedType = row.dataset.recipienttype;
+ }
+
+ // All selected pills are of the same type, hide the type's move action.
+ switch (selectedType) {
+ case "addr_to":
+ menu.querySelector("#moveAddressPillTo").hidden = true;
+ break;
+
+ case "addr_cc":
+ menu.querySelector("#moveAddressPillCc").hidden = true;
+ break;
+
+ case "addr_bcc":
+ menu.querySelector("#moveAddressPillBcc").hidden = true;
+ break;
+ }
+}
+
+/**
+ * Show the specified address row and focus its input. If showing the address
+ * row is disabled, the focus is not changed.
+ *
+ * @param {string} rowId - The id of the row to show.
+ */
+function showAndFocusAddressRow(rowId) {
+ let row = document.getElementById(rowId);
+ if (addressRowSetVisibility(row, true)) {
+ row.querySelector(".address-row-input").focus();
+ }
+}
+
+/**
+ * Set the visibility of an address row (Cc, Bcc, etc.).
+ *
+ * @param {Element} row - The address row.
+ * @param {boolean} [show=true] - Whether to show the row or hide it.
+ *
+ * @returns {boolean} - Whether the visibility was set.
+ */
+function addressRowSetVisibility(row, show) {
+ let menuItem = document.getElementById(row.dataset.showSelfMenuitem);
+ if (show && menuItem.hasAttribute("disabled")) {
+ return false;
+ }
+
+ // Show/hide the row and hide/show the menuitem or button
+ row.classList.toggle("hidden", !show);
+ showAddressRowMenuItemSetVisibility(menuItem, !show);
+ return true;
+}
+
+/**
+ * Set the visibility of a menu item that shows an address row.
+ *
+ * @param {Element} menuItem - The menu item.
+ * @param {boolean} [show=true] - Whether to show the item or hide it.
+ */
+function showAddressRowMenuItemSetVisibility(menuItem, show) {
+ let buttonId = menuItem.dataset.buttonId;
+ let button = buttonId && document.getElementById(buttonId);
+ if (button && menuItem.dataset.preferButton == "true") {
+ button.hidden = !show;
+ // Make sure the menuItem is never shown.
+ menuItem.hidden = true;
+ } else {
+ menuItem.hidden = !show;
+ if (button) {
+ button.hidden = true;
+ }
+ }
+
+ updateRecipientsVisibility();
+}
+
+/**
+ * Set whether a menu item that shows an address row should prefer being
+ * displayed as the button specified by its "data-button-id" attribute, if it
+ * has one.
+ *
+ * @param {Element} menuItem - The menu item.
+ * @param {boolean} preferButton - Whether to prefer showing the button rather
+ * than the menu item.
+ */
+function showAddressRowMenuItemSetPreferButton(menuItem, preferButton) {
+ let buttonId = menuItem.dataset.buttonId;
+ if (!buttonId || menuItem.dataset.preferButton == String(preferButton)) {
+ return;
+ }
+ let button = document.getElementById(buttonId);
+
+ menuItem.dataset.preferButton = preferButton;
+ if (preferButton) {
+ button.hidden = menuItem.hidden;
+ menuItem.hidden = true;
+ } else {
+ menuItem.hidden = button.hidden;
+ button.hidden = true;
+ }
+
+ updateRecipientsVisibility();
+}
+
+/**
+ * Hide or show the menu button for the extra recipients based on the current
+ * hidden status of menuitems and buttons.
+ */
+function updateRecipientsVisibility() {
+ document.getElementById("extraAddressRowsMenuButton").hidden =
+ !document.querySelector("#extraAddressRowsMenu > :not([hidden])");
+
+ let buttonbox = document.getElementById("extraAddressRowsArea");
+ // Toggle the class to show/hide the pseudo element separator
+ // of the msgIdentity field.
+ buttonbox.classList.toggle(
+ "addressingWidget-separator",
+ !!buttonbox.querySelector("button:not([hidden])")
+ );
+}
+
+/**
+ * Hide the container row of a recipient (Cc, Bcc, etc.).
+ * The container can't be hidden if previously typed addresses are listed.
+ *
+ * @param {Element} element - A descendant element of the row to be hidden (or
+ * the row itself), usually the [x] label when triggered, or an empty address
+ * input upon Backspace or Del keydown.
+ * @param {("next"|"previous")} [focusType="next"] - How to move focus after
+ * hiding the address row: try to focus the input of an available next sibling
+ * row (for [x] or DEL) or previous sibling row (for BACKSPACE).
+ */
+function hideAddressRowFromWithin(element, focusType = "next") {
+ let addressRow = element.closest(".address-row");
+
+ // Prevent address row removal when sending (disable-on-send).
+ if (
+ addressRow
+ .querySelector(".address-container")
+ .classList.contains("disable-container")
+ ) {
+ return;
+ }
+
+ let pills = addressRow.querySelectorAll("mail-address-pill");
+ let isEdited = addressRow
+ .querySelector(".address-container")
+ .classList.contains("addressing-field-edited");
+
+ // Ask the user to confirm the removal of all the typed addresses if the field
+ // holds addressing pills and has been previously edited.
+ if (isEdited && pills.length) {
+ let fieldName = addressRow.querySelector(
+ ".address-label-container > label"
+ );
+ let confirmTitle = getComposeBundle().getFormattedString(
+ "confirmRemoveRecipientRowTitle2",
+ [fieldName.value]
+ );
+ let confirmBody = getComposeBundle().getFormattedString(
+ "confirmRemoveRecipientRowBody2",
+ [fieldName.value]
+ );
+ let confirmButton = getComposeBundle().getString(
+ "confirmRemoveRecipientRowButton"
+ );
+
+ let result = Services.prompt.confirmEx(
+ window,
+ confirmTitle,
+ confirmBody,
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL,
+ confirmButton,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (result == 1) {
+ return;
+ }
+ }
+
+ for (let pill of pills) {
+ pill.remove();
+ }
+
+ // Reset the original input.
+ let input = addressRow.querySelector(".address-row-input");
+ input.value = "";
+
+ addressRowSetVisibility(addressRow, false);
+
+ // Update the Send button only if the content was previously changed.
+ if (isEdited) {
+ onRecipientsChanged(true);
+ }
+ updateAriaLabelsOfAddressRow(addressRow);
+
+ // Move focus to the next focusable address input field.
+ let addressRowSibling =
+ focusType == "next"
+ ? getNextSibling(addressRow, ".address-row:not(.hidden)")
+ : getPreviousSibling(addressRow, ".address-row:not(.hidden)");
+
+ if (addressRowSibling) {
+ addressRowSibling.querySelector(".address-row-input").focus();
+ return;
+ }
+ // Otherwise move focus to the subject field or to the first available input.
+ let fallbackFocusElement =
+ focusType == "next"
+ ? document.getElementById("msgSubject")
+ : getNextSibling(addressRow, ".address-row:not(.hidden)").querySelector(
+ ".address-row-input"
+ );
+ fallbackFocusElement.focus();
+}
+
+/**
+ * Handle the click event on the close label of an address row.
+ *
+ * @param {Event} event - The DOM click event.
+ */
+function closeLabelOnClick(event) {
+ hideAddressRowFromWithin(event.target);
+}
+
+function extraAddressRowsMenuOpened() {
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .setAttribute("aria-expanded", "true");
+}
+
+function extraAddressRowsMenuClosed() {
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .setAttribute("aria-expanded", "false");
+}
+
+/**
+ * Show the menu for extra address rows (extraAddressRowsMenu).
+ */
+function openExtraAddressRowsMenu() {
+ let button = document.getElementById("extraAddressRowsMenuButton");
+ let menu = document.getElementById("extraAddressRowsMenu");
+ // NOTE: menu handlers handle the aria-expanded state of the button.
+ menu.openPopup(button, "after_end", 8, 0);
+}
diff --git a/comm/mail/components/compose/content/bigFileObserver.js b/comm/mail/components/compose/content/bigFileObserver.js
new file mode 100644
index 0000000000..f741af7afa
--- /dev/null
+++ b/comm/mail/components/compose/content/bigFileObserver.js
@@ -0,0 +1,368 @@
+/* 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 MozElements */
+
+/* import-globals-from MsgComposeCommands.js */
+
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+var kUploadNotificationValue = "bigAttachmentUploading";
+var kPrivacyWarningNotificationValue = "bigAttachmentPrivacyWarning";
+
+var gBigFileObserver = {
+ bigFiles: [],
+ sessionHidden: false,
+ privacyWarned: false,
+
+ get hidden() {
+ return (
+ this.sessionHidden ||
+ !Services.prefs.getBoolPref("mail.cloud_files.enabled") ||
+ !Services.prefs.getBoolPref("mail.compose.big_attachments.notify") ||
+ Services.io.offline
+ );
+ },
+
+ hide(aPermanent) {
+ if (aPermanent) {
+ Services.prefs.setBoolPref("mail.compose.big_attachments.notify", false);
+ } else {
+ this.sessionHidden = true;
+ }
+ },
+
+ init() {
+ let bucket = document.getElementById("attachmentBucket");
+ bucket.addEventListener("attachments-added", this);
+ bucket.addEventListener("attachments-removed", this);
+ bucket.addEventListener("attachment-converted-to-regular", this);
+ bucket.addEventListener("attachment-uploading", this);
+ bucket.addEventListener("attachment-uploaded", this);
+ bucket.addEventListener("attachment-upload-failed", this);
+
+ this.sessionHidden = false;
+ this.privacyWarned = false;
+ this.bigFiles = [];
+ },
+
+ handleEvent(event) {
+ if (this.hidden) {
+ return;
+ }
+
+ switch (event.type) {
+ case "attachments-added":
+ this.bigFileTrackerAdd(event.detail);
+ break;
+ case "attachments-removed":
+ this.bigFileTrackerRemove(event.detail);
+ this.checkAndHidePrivacyNotification();
+ break;
+ case "attachment-converted-to-regular":
+ this.checkAndHidePrivacyNotification();
+ break;
+ case "attachment-uploading":
+ // Remove the currently uploading item from bigFiles, to remove the big
+ // file notification already during upload.
+ this.bigFileTrackerRemove([event.detail]);
+ this.updateUploadingNotification();
+ break;
+ case "attachment-upload-failed":
+ this.updateUploadingNotification();
+ break;
+ case "attachment-uploaded":
+ this.updateUploadingNotification();
+ if (this.uploadsInProgress == 0) {
+ this.showPrivacyNotification();
+ }
+ break;
+ default:
+ // Do not update the notification for other events.
+ return;
+ }
+
+ this.updateBigFileNotification();
+ },
+
+ bigFileTrackerAdd(aAttachments) {
+ let threshold =
+ Services.prefs.getIntPref("mail.compose.big_attachments.threshold_kb") *
+ 1024;
+
+ for (let attachment of aAttachments) {
+ if (attachment.size >= threshold && !attachment.sendViaCloud) {
+ this.bigFiles.push(attachment);
+ }
+ }
+ },
+
+ bigFileTrackerRemove(aAttachments) {
+ for (let attachment of aAttachments) {
+ let index = this.bigFiles.findIndex(e => e.url == attachment.url);
+ if (index != -1) {
+ this.bigFiles.splice(index, 1);
+ }
+ }
+ },
+
+ formatString(key, replacements, plural) {
+ let str = getComposeBundle().getString(key);
+ if (plural !== undefined) {
+ str = PluralForm.get(plural, str);
+ }
+ if (replacements !== undefined) {
+ for (let i = 0; i < replacements.length; i++) {
+ str = str.replace("#" + (i + 1), replacements[i]);
+ }
+ }
+ return str;
+ },
+
+ updateBigFileNotification() {
+ let bigFileNotification =
+ gComposeNotification.getNotificationWithValue("bigAttachment");
+ if (this.bigFiles.length) {
+ if (bigFileNotification) {
+ bigFileNotification.label = this.formatString(
+ "bigFileDescription",
+ [this.bigFiles.length],
+ this.bigFiles.length
+ );
+ return;
+ }
+
+ let buttons = [
+ {
+ label: getComposeBundle().getString("learnMore.label"),
+ accessKey: getComposeBundle().getString("learnMore.accesskey"),
+ callback: this.openLearnMore.bind(this),
+ },
+ {
+ label: this.formatString("bigFileShare.label", []),
+ accessKey: this.formatString("bigFileShare.accesskey"),
+ callback: this.convertAttachments.bind(this),
+ },
+ {
+ label: this.formatString("bigFileAttach.label", []),
+ accessKey: this.formatString("bigFileAttach.accesskey"),
+ callback: this.hideBigFileNotification.bind(this),
+ },
+ ];
+
+ let msg = this.formatString(
+ "bigFileDescription",
+ [this.bigFiles.length],
+ this.bigFiles.length
+ );
+
+ bigFileNotification = gComposeNotification.appendNotification(
+ "bigAttachment",
+ {
+ label: msg,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+ } else if (bigFileNotification) {
+ gComposeNotification.removeNotification(bigFileNotification);
+ }
+ },
+
+ openLearnMore() {
+ let url = Services.prefs.getCharPref("mail.cloud_files.learn_more_url");
+ openContentTab(url);
+ return true;
+ },
+
+ convertAttachments() {
+ let account;
+ let accounts = cloudFileAccounts.configuredAccounts;
+
+ if (accounts.length == 1) {
+ account = accounts[0];
+ } else if (accounts.length > 1) {
+ // We once used Services.prompt.select for this UI, but it doesn't support displaying an
+ // icon for each item. The following code does the same thing with a replacement dialog.
+ let { PromptUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromptUtils.sys.mjs"
+ );
+
+ let names = accounts.map(i => cloudFileAccounts.getDisplayName(i));
+ let icons = accounts.map(i => i.iconURL);
+ let args = {
+ promptType: "select",
+ title: this.formatString("bigFileChooseAccount.title"),
+ text: this.formatString("bigFileChooseAccount.text"),
+ list: names,
+ icons,
+ selected: -1,
+ ok: false,
+ };
+
+ let propBag = PromptUtils.objectToPropBag(args);
+ openDialog(
+ "chrome://messenger/content/cloudfile/selectDialog.xhtml",
+ "_blank",
+ "centerscreen,chrome,modal,titlebar",
+ propBag
+ );
+ PromptUtils.propBagToObject(propBag, args);
+
+ if (args.ok) {
+ account = accounts[args.selected];
+ }
+ } else {
+ openPreferencesTab("paneCompose", "compositionAttachmentsCategory");
+ return true;
+ }
+
+ if (account) {
+ convertToCloudAttachment(this.bigFiles, account);
+ }
+
+ return false;
+ },
+
+ hideBigFileNotification() {
+ let never = {};
+ if (
+ Services.prompt.confirmCheck(
+ window,
+ this.formatString("bigFileHideNotification.title"),
+ this.formatString("bigFileHideNotification.text"),
+ this.formatString("bigFileHideNotification.check"),
+ never
+ )
+ ) {
+ this.hide(never.value);
+ return false;
+ }
+ return true;
+ },
+
+ updateUploadingNotification() {
+ // We will show the uploading notification for a minimum of 2.5 seconds
+ // seconds.
+ const kThreshold = 2500; // milliseconds
+
+ if (
+ !Services.prefs.getBoolPref(
+ "mail.compose.big_attachments.insert_notification"
+ )
+ ) {
+ return;
+ }
+
+ let activeUploads = this.uploadsInProgress;
+ let notification = gComposeNotification.getNotificationWithValue(
+ kUploadNotificationValue
+ );
+
+ if (activeUploads == 0) {
+ if (notification) {
+ // Check the timestamp that we stashed in the timeout field of the
+ // notification...
+ let now = Date.now();
+ if (now >= notification.timeout) {
+ gComposeNotification.removeNotification(notification);
+ } else {
+ setTimeout(function () {
+ gComposeNotification.removeNotification(notification);
+ }, notification.timeout - now);
+ }
+ }
+ return;
+ }
+
+ let message = this.formatString("cloudFileUploadingNotification");
+ message = PluralForm.get(activeUploads, message);
+
+ if (notification) {
+ notification.label = message;
+ return;
+ }
+
+ let showUploadButton = {
+ accessKey: this.formatString(
+ "stopShowingUploadingNotification.accesskey"
+ ),
+ label: this.formatString("stopShowingUploadingNotification.label"),
+ callback(aNotificationBar, aButton) {
+ Services.prefs.setBoolPref(
+ "mail.compose.big_attachments.insert_notification",
+ false
+ );
+ },
+ };
+ notification = gComposeNotification.appendNotification(
+ kUploadNotificationValue,
+ {
+ label: message,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ [showUploadButton]
+ );
+ notification.timeout = Date.now() + kThreshold;
+ },
+
+ hidePrivacyNotification() {
+ this.privacyWarned = false;
+ let notification = gComposeNotification.getNotificationWithValue(
+ kPrivacyWarningNotificationValue
+ );
+
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ },
+
+ checkAndHidePrivacyNotification() {
+ if (
+ !gAttachmentBucket.itemChildren.find(
+ item => item.attachment && item.attachment.sendViaCloud
+ )
+ ) {
+ this.hidePrivacyNotification();
+ }
+ },
+
+ showPrivacyNotification() {
+ if (this.privacyWarned) {
+ return;
+ }
+ this.privacyWarned = true;
+
+ let notification = gComposeNotification.getNotificationWithValue(
+ kPrivacyWarningNotificationValue
+ );
+
+ if (notification) {
+ return;
+ }
+
+ let message = this.formatString("cloudFilePrivacyNotification");
+ gComposeNotification.appendNotification(
+ kPrivacyWarningNotificationValue,
+ {
+ label: message,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ },
+
+ get uploadsInProgress() {
+ let items = [...document.getElementById("attachmentBucket").itemChildren];
+ return items.filter(e => e.uploading).length;
+ },
+};
+
+window.addEventListener(
+ "compose-window-init",
+ gBigFileObserver.init.bind(gBigFileObserver),
+ true
+);
diff --git a/comm/mail/components/compose/content/cloudAttachmentLinkManager.js b/comm/mail/components/compose/content/cloudAttachmentLinkManager.js
new file mode 100644
index 0000000000..9693f1aa8d
--- /dev/null
+++ b/comm/mail/components/compose/content/cloudAttachmentLinkManager.js
@@ -0,0 +1,758 @@
+/* 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 MsgComposeCommands.js */
+
+let { MsgUtils } = ChromeUtils.import(
+ "resource:///modules/MimeMessageUtils.jsm"
+);
+
+var gCloudAttachmentLinkManager = {
+ init() {
+ this.cloudAttachments = [];
+
+ let bucket = document.getElementById("attachmentBucket");
+ bucket.addEventListener("attachments-removed", this);
+ bucket.addEventListener("attachment-converted-to-regular", this);
+ bucket.addEventListener("attachment-uploaded", this);
+ bucket.addEventListener("attachment-moved", this);
+ bucket.addEventListener("attachment-renamed", this);
+
+ // If we're restoring a draft that has some attachments,
+ // check to see if any of them are marked to be sent via
+ // cloud, and if so, add them to our list.
+ for (let i = 0; i < bucket.getRowCount(); ++i) {
+ let attachment = bucket.getItemAtIndex(i).attachment;
+ if (attachment && attachment.sendViaCloud) {
+ this.cloudAttachments.push(attachment);
+ }
+ }
+
+ gMsgCompose.RegisterStateListener(this);
+ },
+
+ NotifyComposeFieldsReady() {},
+ NotifyComposeBodyReady() {},
+ ComposeProcessDone() {},
+ SaveInFolderDone() {},
+
+ async handleEvent(event) {
+ let mailDoc = document.getElementById("messageEditor").contentDocument;
+
+ if (
+ event.type == "attachment-renamed" ||
+ event.type == "attachment-moved"
+ ) {
+ let cloudFileUpload = event.target.cloudFileUpload;
+ let items = [];
+
+ let list = mailDoc.getElementById("cloudAttachmentList");
+ if (list) {
+ items = list.getElementsByClassName("cloudAttachmentItem");
+ }
+
+ for (let item of items) {
+ // The original attachment is stored in the events detail property.
+ if (item.dataset.contentLocation == event.detail.contentLocation) {
+ item.replaceWith(await this._createNode(mailDoc, cloudFileUpload));
+ }
+ }
+ if (event.type == "attachment-moved") {
+ await this._updateServiceProviderLinks(mailDoc);
+ }
+ } else if (event.type == "attachment-uploaded") {
+ if (this.cloudAttachments.length == 0) {
+ this._insertHeader(mailDoc);
+ }
+
+ let cloudFileUpload = event.target.cloudFileUpload;
+ let attachment = event.target.attachment;
+ this.cloudAttachments.push(attachment);
+ await this._insertItem(mailDoc, cloudFileUpload);
+ } else if (
+ event.type == "attachments-removed" ||
+ event.type == "attachment-converted-to-regular"
+ ) {
+ let items = [];
+ let list = mailDoc.getElementById("cloudAttachmentList");
+ if (list) {
+ items = list.getElementsByClassName("cloudAttachmentItem");
+ }
+
+ let attachments = Array.isArray(event.detail)
+ ? event.detail
+ : [event.detail];
+ for (let attachment of attachments) {
+ // Remove the attachment from the message body.
+ if (list) {
+ for (let item of items) {
+ if (item.dataset.contentLocation == attachment.contentLocation) {
+ item.remove();
+ }
+ }
+ }
+
+ // Now, remove the attachment from our internal list.
+ let index = this.cloudAttachments.indexOf(attachment);
+ if (index != -1) {
+ this.cloudAttachments.splice(index, 1);
+ }
+ }
+
+ await this._updateAttachmentCount(mailDoc);
+ await this._updateServiceProviderLinks(mailDoc);
+
+ if (items.length == 0) {
+ if (list) {
+ list.remove();
+ }
+ this._removeRoot(mailDoc);
+ }
+ }
+ },
+
+ /**
+ * Removes the root node for an attachment list in an HTML email.
+ *
+ * @param {Document} aDocument - the document to remove the root node from
+ */
+ _removeRoot(aDocument) {
+ let header = aDocument.getElementById("cloudAttachmentListRoot");
+ if (header) {
+ header.remove();
+ }
+ },
+
+ /**
+ * Given some node, returns the textual HTML representation for the node
+ * and its children.
+ *
+ * @param {Document} aDocument - the document that the node is embedded in
+ * @param {DOMNode} aNode - the node to get the textual representation from
+ */
+ _getHTMLRepresentation(aDocument, aNode) {
+ let tmp = aDocument.createElement("p");
+ tmp.appendChild(aNode);
+ return tmp.innerHTML;
+ },
+
+ /**
+ * Returns the plain text equivalent of the given HTML markup, ready to be
+ * inserted into a compose editor.
+ *
+ * @param {string} aMarkup - the HTML markup that should be converted
+ */
+ _getTextRepresentation(aMarkup) {
+ return MsgUtils.convertToPlainText(aMarkup, true).replaceAll("\r\n", "\n");
+ },
+
+ /**
+ * Generates an appropriately styled link.
+ *
+ * @param {Document} aDocument - the document to append the link to - doesn't
+ * actually get appended, but is used to generate the anchor node
+ * @param {string} aContent - the textual content of the link
+ * @param {string} aHref - the HREF attribute for the generated link
+ * @param {string} aColor - the CSS color string for the link
+ */
+ _generateLink(aDocument, aContent, aHref, aColor) {
+ let link = aDocument.createElement("a");
+ link.href = aHref;
+ link.textContent = aContent;
+ link.style.cssText = `color: ${aColor} !important`;
+ return link;
+ },
+
+ _findInsertionPoint(aDocument) {
+ let mailBody = aDocument.querySelector("body");
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+
+ let childNodes = mailBody.childNodes;
+ let childToInsertAfter, childIndex;
+
+ // First, search for any text nodes that are immediate children of
+ // the body. If we find any, we'll insert after those.
+ for (childIndex = childNodes.length - 1; childIndex >= 0; childIndex--) {
+ if (childNodes[childIndex].nodeType == Node.TEXT_NODE) {
+ childToInsertAfter = childNodes[childIndex];
+ break;
+ }
+ }
+
+ if (childIndex != -1) {
+ selection.collapse(
+ childToInsertAfter,
+ childToInsertAfter.nodeValue ? childToInsertAfter.nodeValue.length : 0
+ );
+ if (
+ childToInsertAfter.nodeValue &&
+ childToInsertAfter.nodeValue.length > 0
+ ) {
+ editor.insertLineBreak();
+ }
+ editor.insertLineBreak();
+ return;
+ }
+
+ // If there's a signature, let's get a hold of it now.
+ let signature = mailBody.querySelector(".moz-signature");
+
+ // Are we replying?
+ let replyCitation = mailBody.querySelector(".moz-cite-prefix");
+ if (replyCitation) {
+ if (gCurrentIdentity && gCurrentIdentity.replyOnTop == 0) {
+ // Replying below quote - we'll select the point right before
+ // the signature. If there's no signature, we'll just use the
+ // last node.
+ if (signature && signature.previousSibling) {
+ selection.collapse(
+ mailBody,
+ Array.from(childNodes).indexOf(signature.previousSibling)
+ );
+ } else {
+ selection.collapse(mailBody, childNodes.length - 1);
+ editor.insertLineBreak();
+
+ if (!gMsgCompose.composeHTML) {
+ editor.insertLineBreak();
+ }
+
+ selection.collapse(mailBody, childNodes.length - 2);
+ }
+ } else if (replyCitation.previousSibling) {
+ // Replying above quote
+ let nodeIndex = Array.from(childNodes).indexOf(
+ replyCitation.previousSibling
+ );
+ if (nodeIndex <= 0) {
+ editor.insertLineBreak();
+ nodeIndex = 1;
+ }
+ selection.collapse(mailBody, nodeIndex);
+ } else {
+ editor.beginningOfDocument();
+ editor.insertLineBreak();
+ }
+ return;
+ }
+
+ // Are we forwarding?
+ let forwardBody = mailBody.querySelector(".moz-forward-container");
+ if (forwardBody) {
+ if (forwardBody.previousSibling) {
+ let nodeIndex = Array.from(childNodes).indexOf(
+ forwardBody.previousSibling
+ );
+ if (nodeIndex <= 0) {
+ editor.insertLineBreak();
+ nodeIndex = 1;
+ }
+ // If we're forwarding, insert just before the forward body.
+ selection.collapse(mailBody, nodeIndex);
+ } else {
+ // Just insert after a linebreak at the top.
+ editor.beginningOfDocument();
+ editor.insertLineBreak();
+ selection.collapse(mailBody, 1);
+ }
+ return;
+ }
+
+ // If we haven't figured it out at this point, let's see if there's a
+ // signature, and just insert before it.
+ if (signature && signature.previousSibling) {
+ let nodeIndex = Array.from(childNodes).indexOf(signature.previousSibling);
+ if (nodeIndex <= 0) {
+ editor.insertLineBreak();
+ nodeIndex = 1;
+ }
+ selection.collapse(mailBody, nodeIndex);
+ return;
+ }
+
+ // If we haven't figured it out at this point, let's just put it
+ // at the bottom of the message body. If the "bottom" is also the top,
+ // then we'll insert a linebreak just above it.
+ let nodeIndex = childNodes.length - 1;
+ if (nodeIndex <= 0) {
+ editor.insertLineBreak();
+ nodeIndex = 1;
+ }
+ selection.collapse(mailBody, nodeIndex);
+ },
+
+ /**
+ * Attempts to find any elements with an id in aIDs, and sets those elements
+ * id attribute to the empty string, freeing up the ids for later use.
+ *
+ * @param {Document} aDocument - the document to search for the elements
+ * @param {string[]} aIDs - an array of id strings
+ */
+ _resetNodeIDs(aDocument, aIDs) {
+ for (let id of aIDs) {
+ let node = aDocument.getElementById(id);
+ if (node) {
+ node.id = "";
+ }
+ }
+ },
+
+ /**
+ * Insert the header for the cloud attachment list, which we'll use to
+ * as an insertion point for the individual cloud attachments.
+ *
+ * @param {Document} aDocument - the document to insert the header into
+ */
+ _insertHeader(aDocument) {
+ // If there already exists a cloudAttachmentListRoot,
+ // cloudAttachmentListHeader, cloudAttachmentListFooter or
+ // cloudAttachmentList in the document, strip them of their IDs so that we
+ // don't conflict with them.
+ this._resetNodeIDs(aDocument, [
+ "cloudAttachmentListRoot",
+ "cloudAttachmentListHeader",
+ "cloudAttachmentList",
+ "cloudAttachmentListFooter",
+ ]);
+
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+ let originalAnchor = selection.anchorNode;
+ let originalOffset = selection.anchorOffset;
+
+ // Save off the selection ranges so we can restore them later.
+ let ranges = [];
+ for (let i = 0; i < selection.rangeCount; i++) {
+ ranges.push(selection.getRangeAt(i));
+ }
+
+ this._findInsertionPoint(aDocument);
+
+ let root = editor.createElementWithDefaults("div");
+ let header = editor.createElementWithDefaults("div");
+ let list = editor.createElementWithDefaults("div");
+ let footer = editor.createElementWithDefaults("div");
+
+ if (gMsgCompose.composeHTML) {
+ root.style.padding = "15px";
+ root.style.backgroundColor = "#D9EDFF";
+
+ header.style.marginBottom = "15px";
+
+ list = editor.createElementWithDefaults("ul");
+ list.style.backgroundColor = "#FFFFFF";
+ list.style.padding = "15px";
+ list.style.listStyleType = "none";
+ list.display = "inline-block";
+ }
+
+ root.id = "cloudAttachmentListRoot";
+ header.id = "cloudAttachmentListHeader";
+ list.id = "cloudAttachmentList";
+ footer.id = "cloudAttachmentListFooter";
+
+ // It's really quite strange, but if we don't set
+ // the innerHTML of each element to be non-empty, then
+ // the nodes fail to be added to the compose window.
+ root.innerHTML = " ";
+ header.innerHTML = " ";
+ list.innerHTML = " ";
+ footer.innerHTML = " ";
+
+ root.appendChild(header);
+ root.appendChild(list);
+ root.appendChild(footer);
+ editor.insertElementAtSelection(root, false);
+ if (!root.previousSibling || root.previousSibling.localName == "span") {
+ root.parentNode.insertBefore(editor.document.createElement("br"), root);
+ }
+
+ // Remove the space, which would end up in the plain text converted
+ // version.
+ list.innerHTML = "";
+ selection.collapse(originalAnchor, originalOffset);
+
+ // Restore the selection ranges.
+ for (let range of ranges) {
+ selection.addRange(range);
+ }
+ },
+
+ /**
+ * Updates the count of how many attachments have been added
+ * in HTML emails.
+ *
+ * @param {Document} aDocument - the document that contains the header node
+ */
+ async _updateAttachmentCount(aDocument) {
+ let header = aDocument.getElementById("cloudAttachmentListHeader");
+ if (!header) {
+ return;
+ }
+
+ let entries = aDocument.querySelectorAll(
+ "#cloudAttachmentList > .cloudAttachmentItem"
+ );
+
+ header.textContent = await l10nCompose.formatValue(
+ "cloud-file-count-header",
+ {
+ count: entries.length,
+ }
+ );
+ },
+
+ /**
+ * Updates the service provider links in the footer.
+ *
+ * @param {Document} aDocument - the document that contains the footer node
+ */
+ async _updateServiceProviderLinks(aDocument) {
+ let footer = aDocument.getElementById("cloudAttachmentListFooter");
+ if (!footer) {
+ return;
+ }
+
+ let providers = [];
+ let entries = aDocument.querySelectorAll(
+ "#cloudAttachmentList > .cloudAttachmentItem"
+ );
+ for (let entry of entries) {
+ if (!entry.dataset.serviceUrl) {
+ continue;
+ }
+
+ let link_markup = this._generateLink(
+ aDocument,
+ entry.dataset.serviceName,
+ entry.dataset.serviceUrl,
+ "dark-grey"
+ ).outerHTML;
+
+ if (!providers.includes(link_markup)) {
+ providers.push(link_markup);
+ }
+ }
+
+ let content = "";
+ if (providers.length == 1) {
+ content = await l10nCompose.formatValue(
+ "cloud-file-service-provider-footer-single",
+ {
+ link: providers[0],
+ }
+ );
+ } else if (providers.length > 1) {
+ let lastLink = providers.pop();
+ let firstLinks = providers.join(", ");
+ content = await l10nCompose.formatValue(
+ "cloud-file-service-provider-footer-multiple",
+ {
+ firstLinks,
+ lastLink,
+ }
+ );
+ }
+
+ if (gMsgCompose.composeHTML) {
+ // eslint-disable-next-line no-unsanitized/property
+ footer.innerHTML = content;
+ } else {
+ footer.textContent = this._getTextRepresentation(content);
+ }
+ },
+
+ /**
+ * Insert the information for a cloud attachment.
+ *
+ * @param {Document} aDocument - the document to insert the item into
+ * @param {CloudFileTemplate} aCloudFileUpload - object with information about
+ * the uploaded file
+ */
+ async _insertItem(aDocument, aCloudFileUpload) {
+ let list = aDocument.getElementById("cloudAttachmentList");
+
+ if (!list) {
+ this._insertHeader(aDocument);
+ list = aDocument.getElementById("cloudAttachmentList");
+ }
+ list.appendChild(await this._createNode(aDocument, aCloudFileUpload));
+ await this._updateAttachmentCount(aDocument);
+ await this._updateServiceProviderLinks(aDocument);
+ },
+
+ /**
+ * @typedef CloudFileDate
+ * @property {integer} timestamp - milliseconds since epoch
+ * @property {DateTimeFormat} format - format object of Intl.DateTimeFormat
+ */
+
+ /**
+ * @typedef CloudFileTemplate
+ * @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
+ */
+
+ /**
+ * Create the link node for a cloud attachment.
+ *
+ * @param {Document} aDocument - the document to insert the item into
+ * @param {CloudFileTemplate} aCloudFileUpload - object with information about
+ * the uploaded file
+ * @param {boolean} composeHTML - override gMsgCompose.composeHTML
+ */
+ async _createNode(
+ aDocument,
+ aCloudFileUpload,
+ composeHTML = gMsgCompose.composeHTML
+ ) {
+ const iconSize = 32;
+ const locales = {
+ service: 0,
+ size: 1,
+ link: 2,
+ "password-protected-link": 3,
+ "expiry-date": 4,
+ "download-limit": 5,
+ "tooltip-password-protected-link": 6,
+ };
+
+ let l10n_values = await l10nCompose.formatValues([
+ { id: "cloud-file-template-service-name" },
+ { id: "cloud-file-template-size" },
+ { id: "cloud-file-template-link" },
+ { id: "cloud-file-template-password-protected-link" },
+ { id: "cloud-file-template-expiry-date" },
+ { id: "cloud-file-template-download-limit" },
+ { id: "cloud-file-tooltip-password-protected-link" },
+ ]);
+
+ let node = aDocument.createElement("li");
+ node.style.border = "1px solid #CDCDCD";
+ node.style.borderRadius = "5px";
+ node.style.marginTop = "10px";
+ node.style.marginBottom = "10px";
+ node.style.padding = "15px";
+ node.style.display = "grid";
+ node.style.gridTemplateColumns = "0fr 1fr 0fr 0fr";
+ node.style.alignItems = "center";
+
+ const statsRow = (name, content, contentLink) => {
+ let entry = aDocument.createElement("span");
+ entry.style.gridColumn = `2 / span 3`;
+ entry.style.fontSize = "small";
+
+ let description = aDocument.createElement("span");
+ description.style.color = "dark-grey";
+ description.textContent = `${l10n_values[locales[name]]} `;
+ entry.appendChild(description);
+
+ let value;
+ if (composeHTML && contentLink) {
+ value = this._generateLink(aDocument, content, contentLink, "#595959");
+ } else {
+ value = aDocument.createElement("span");
+ value.style.color = "#595959";
+ value.textContent = content;
+ }
+ value.classList.add(`cloudfile-${name}`);
+ entry.appendChild(value);
+
+ entry.appendChild(aDocument.createElement("br"));
+ return entry;
+ };
+
+ const serviceRow = () => {
+ let service = aDocument.createDocumentFragment();
+
+ let description = aDocument.createElement("span");
+ description.style.display = "none";
+ description.textContent = `${l10n_values[locales.service]} `;
+ service.appendChild(description);
+
+ let providerName = aDocument.createElement("span");
+ providerName.style.gridArea = "1 / 4";
+ providerName.style.color = "#595959";
+ providerName.style.fontSize = "small";
+ providerName.textContent = aCloudFileUpload.serviceName;
+ providerName.classList.add("cloudfile-service-name");
+ service.appendChild(providerName);
+
+ service.appendChild(aDocument.createElement("br"));
+ return service;
+ };
+
+ // If this message is send in plain text only, do not add a link to the file
+ // name.
+ let name = aDocument.createElement("span");
+ name.textContent = aCloudFileUpload.name;
+ if (composeHTML) {
+ name = this._generateLink(
+ aDocument,
+ aCloudFileUpload.name,
+ aCloudFileUpload.url,
+ "#0F7EDB"
+ );
+ name.setAttribute("moz-do-not-send", "true");
+ name.style.gridArea = "1 / 2";
+ }
+ name.classList.add("cloudfile-name");
+ node.appendChild(name);
+
+ let paperclip = aDocument.createElement("img");
+ paperclip.classList.add("paperClipIcon");
+ paperclip.style.gridArea = "1 / 1";
+ paperclip.alt = "";
+ paperclip.style.marginRight = "5px";
+ paperclip.width = `${iconSize}`;
+ paperclip.height = `${iconSize}`;
+ if (aCloudFileUpload.downloadPasswordProtected) {
+ paperclip.title = l10n_values[locales["tooltip-password-protected-link"]];
+ paperclip.src =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIfSURBVFhH7ZfLK0RRHMfvNd6PMV4Lj5UkO5bslJIdf4ClRw2TlY2yt2EhsZO9DYoFoiSvJBZkI6SsNMyIiLnH93vmXDF5HNe9pHzqM797fufMPb+Zc4Z7jC+QBnvgJryD93AddkH2eUop3IPiHXdgCfSEdLgLOdE+bIFFSl4zZxeRAl2HXzsn2IIZTCTAHPs4hsvhOlxz3rxRtt6GfRyzJlsucw1582zZehv2cUxEtlyGN6afkThuFa7EL7+H0wK03pek4q/xJwtYVv4YumurO+4V/3vgvwAvC5iHTfHL9zFV/Ah7J9tjE9s2r/K3YwWlD8IaREP+ExPCWBDJVl+gM3LEto0nBURHCiuNpBiflvLjqWcufDFfdVbo4ly1PVoC0xrAaz4qnLdiVjk1hVhArvDRFxuSYxQeFSAaGHzCbAuEIsf0URjtsithX3i1Cf18yewKn8kWyOu+OlWXuSpKnBRwpWKxioTXi7BCtr6Ak004BZvhJAwyAUZhb3Q0bwKxXmY+xVzyB8MNOgXwE/NrC0A+clXBDZV7iYkC7GK18AcvTZ0lOFGRE5NDWAtn4A28hdPQEToFcG1Jq4qERXAZ+DCaBXk+cIROAePQgh2whgk30SngAA7CVDgLq6Fr6P4M++Ec5PmPp6BhWAdzIA+m3BOO0C2AJ2GuMyfme0KQp6Ao5EmZf/fLDGFuI2oi+EEcUQm5JDywhpWc2MFGNIwn/WmcKhqF50UAAAAASUVORK5CYII=";
+ } else {
+ paperclip.src =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAVFJREFUWIXtl8FKw0AQhj8EbQ/p0Ut8AVEPgYLUB+i5L6J9E0Wtr1HPgl48WU8K1Tfw4LktxUAhHvZfiMXUbdhVhB0Yms78M/NldwkJuFsD6AMjYCYfASfKBbUd4BkoKvxJmiDWKA1/AXrAtrynmIUIshJ9DXgEmt/km8oVwHEIANu8u0LTleYhBMBUzZMVmkSaSQgAe9DW1d3L/wzAqW6jJpQ3+5cA3vbW1Vz3Np6BCBABIkAE+DWAmX7TUixdynm15Wf6jf5fa3Cq60K5qrraNuHrK1kbmJcGWJ8rB9DC4yvaq5odlmK7wBB4lw8Vs9ZRzdgHwLmaXa5RM1DNmQ+AA2ABfACZgz4DctXs+QAAuMLc0dsPEJk0BXDhazjAFnCnxjlmiTuYg5kAR4rl0twCmz4BLMQAs7RVH6kLzJ17H162fczhGmO+mqa6PqXGnn8CxMN0PcC9DrQAAAAASUVORK5CYII=";
+ }
+ node.appendChild(paperclip);
+
+ let serviceIcon = aDocument.createElement("img");
+ serviceIcon.classList.add("cloudfile-service-icon");
+ serviceIcon.style.gridArea = "1 / 3";
+ serviceIcon.alt = "";
+ serviceIcon.style.margin = "0 5px";
+ serviceIcon.width = `${iconSize}`;
+ serviceIcon.height = `${iconSize}`;
+ node.appendChild(serviceIcon);
+
+ if (aCloudFileUpload.serviceIcon) {
+ if (!/^(chrome|moz-extension):\/\//i.test(aCloudFileUpload.serviceIcon)) {
+ serviceIcon.src = aCloudFileUpload.serviceIcon;
+ } else {
+ try {
+ // Let's use the goodness from MsgComposeCommands.js since we're
+ // sitting right in a compose window.
+ serviceIcon.src = window.loadBlockedImage(
+ aCloudFileUpload.serviceIcon,
+ true
+ );
+ } catch (e) {
+ // Couldn't load the referenced image.
+ console.error(e);
+ }
+ }
+ }
+ node.appendChild(aDocument.createElement("br"));
+
+ node.appendChild(
+ statsRow("size", gMessenger.formatFileSize(aCloudFileUpload.size))
+ );
+
+ if (aCloudFileUpload.downloadExpiryDate) {
+ node.appendChild(
+ statsRow(
+ "expiry-date",
+ new Date(
+ aCloudFileUpload.downloadExpiryDate.timestamp
+ ).toLocaleString(
+ undefined,
+ aCloudFileUpload.downloadExpiryDate.format || {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZoneName: "short",
+ }
+ )
+ )
+ );
+ }
+
+ if (aCloudFileUpload.downloadLimit) {
+ node.appendChild(
+ statsRow("download-limit", aCloudFileUpload.downloadLimit)
+ );
+ }
+
+ if (composeHTML || aCloudFileUpload.serviceUrl) {
+ node.appendChild(serviceRow());
+ }
+
+ let linkElementLocaleId = aCloudFileUpload.downloadPasswordProtected
+ ? "password-protected-link"
+ : "link";
+ node.appendChild(
+ statsRow(linkElementLocaleId, aCloudFileUpload.url, aCloudFileUpload.url)
+ );
+
+ // An extra line break is needed for the converted plain text version, if it
+ // should have a gap between its <li> elements.
+ if (composeHTML) {
+ node.appendChild(aDocument.createElement("br"));
+ }
+
+ // Generate the plain text version from the HTML. The used method needs a <ul>
+ // element wrapped around the <li> element to produce the correct content.
+ if (!composeHTML) {
+ let ul = aDocument.createElement("ul");
+ ul.appendChild(node);
+ node = aDocument.createElement("p");
+ node.textContent = this._getTextRepresentation(ul.outerHTML);
+ }
+
+ node.className = "cloudAttachmentItem";
+ node.dataset.contentLocation = aCloudFileUpload.url;
+ node.dataset.serviceName = aCloudFileUpload.serviceName;
+ node.dataset.serviceUrl = aCloudFileUpload.serviceUrl;
+ return node;
+ },
+
+ /**
+ * Event handler for when mail is sent. For mail that is being sent
+ * (and not saved!), find any cloudAttachmentList* nodes that we've created,
+ * and strip their IDs out. That way, if the receiving user replies by
+ * sending some BigFiles, we don't run into ID conflicts.
+ */
+ send(aEvent) {
+ let msgType = parseInt(aEvent.target.getAttribute("msgtype"));
+
+ if (
+ msgType == Ci.nsIMsgCompDeliverMode.Now ||
+ msgType == Ci.nsIMsgCompDeliverMode.Later ||
+ msgType == Ci.nsIMsgCompDeliverMode.Background
+ ) {
+ const kIDs = [
+ "cloudAttachmentListRoot",
+ "cloudAttachmentListHeader",
+ "cloudAttachmentList",
+ "cloudAttachmentListFooter",
+ ];
+ let mailDoc = document.getElementById("messageEditor").contentDocument;
+
+ for (let id of kIDs) {
+ let element = mailDoc.getElementById(id);
+ if (element) {
+ element.removeAttribute("id");
+ }
+ }
+ }
+ },
+};
+
+window.addEventListener(
+ "compose-window-init",
+ gCloudAttachmentLinkManager.init.bind(gCloudAttachmentLinkManager),
+ true
+);
+window.addEventListener(
+ "compose-send-message",
+ gCloudAttachmentLinkManager.send.bind(gCloudAttachmentLinkManager),
+ true
+);
diff --git a/comm/mail/components/compose/content/dialogs/EdAEAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEAttributes.js
new file mode 100644
index 0000000000..52b7e30fac
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAEAttributes.js
@@ -0,0 +1,973 @@
+/* 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/. */
+
+// HTML Attributes object for "Name" menulist
+var gHTMLAttr = {};
+
+// JS Events Attributes object for "Name" menulist
+var gJSAttr = {};
+
+// Core HTML attribute values //
+// This is appended to Name menulist when "_core" is attribute name
+var gCoreHTMLAttr = ["^id", "class", "title"];
+
+// Core event attribute values //
+// This is appended to all JS menulists
+// except those elements having "noJSEvents"
+// as a value in their gJSAttr array.
+var gCoreJSEvents = [
+ "onclick",
+ "ondblclick",
+ "onmousedown",
+ "onmouseup",
+ "onmouseover",
+ "onmousemove",
+ "onmouseout",
+ "-",
+ "onkeypress",
+ "onkeydown",
+ "onkeyup",
+];
+
+// Following are commonly-used strings
+
+// Also accept: sRGB: #RRGGBB //
+var gHTMLColors = [
+ "Aqua",
+ "Black",
+ "Blue",
+ "Fuchsia",
+ "Gray",
+ "Green",
+ "Lime",
+ "Maroon",
+ "Navy",
+ "Olive",
+ "Purple",
+ "Red",
+ "Silver",
+ "Teal",
+ "White",
+ "Yellow",
+];
+
+var gHAlign = ["left", "center", "right"];
+
+var gHAlignJustify = ["left", "center", "right", "justify"];
+
+var gHAlignTableContent = ["left", "center", "right", "justify", "char"];
+
+var gVAlignTable = ["top", "middle", "bottom", "baseline"];
+
+var gTarget = ["_blank", "_self", "_parent", "_top"];
+
+// ================ HTML Attributes ================ //
+/* For each element, there is an array of attributes,
+ whose name is the element name,
+ used to fill the "Attribute Name" menulist.
+ For each of those attributes, if they have a specific
+ set of values, those are listed in an array named:
+ "elementName_attName".
+
+ In each values string, the following characters
+ are signal to do input filtering:
+ "#" Allow only integer values
+ "%" Allow integer values or a number ending in "%"
+ "+" Allow integer values and allow "+" or "-" as first character
+ "!" Allow only one character
+ "^" The first character can be only be A-Z, a-z, hyphen, underscore, colon or period
+ "$" is an attribute required by HTML DTD
+*/
+
+/*
+ Most elements have the "dir" attribute,
+ so we use this value array
+ for all elements instead of specifying
+ separately for each element
+*/
+gHTMLAttr.all_dir = ["ltr", "rtl"];
+
+gHTMLAttr.a = [
+ "charset",
+ "type",
+ "name",
+ "href",
+ "^hreflang",
+ "target",
+ "rel",
+ "rev",
+ "!accesskey",
+ "shape", // with imagemap //
+ "coords", // with imagemap //
+ "#tabindex",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.a_target = gTarget;
+
+gHTMLAttr.a_rel = [
+ "alternate",
+ "stylesheet",
+ "start",
+ "next",
+ "prev",
+ "contents",
+ "index",
+ "glossary",
+ "copyright",
+ "chapter",
+ "section",
+ "subsection",
+ "appendix",
+ "help",
+ "bookmark",
+];
+
+gHTMLAttr.a_rev = [
+ "alternate",
+ "stylesheet",
+ "start",
+ "next",
+ "prev",
+ "contents",
+ "index",
+ "glossary",
+ "copyright",
+ "chapter",
+ "section",
+ "subsection",
+ "appendix",
+ "help",
+ "bookmark",
+];
+
+gHTMLAttr.a_shape = ["rect", "circle", "poly", "default"];
+
+gHTMLAttr.abbr = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.acronym = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.address = ["_core", "-", "^lang", "dir"];
+
+// this is deprecated //
+gHTMLAttr.applet = [
+ "codebase",
+ "archive",
+ "code",
+ "object",
+ "alt",
+ "name",
+ "%$width",
+ "%$height",
+ "align",
+ "#hspace",
+ "#vspace",
+ "-",
+ "_core",
+];
+
+gHTMLAttr.applet_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.area = [
+ "shape",
+ "coords",
+ "href",
+ "nohref",
+ "target",
+ "$alt",
+ "#tabindex",
+ "!accesskey",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.area_target = gTarget;
+
+gHTMLAttr.area_shape = ["rect", "circle", "poly", "default"];
+
+gHTMLAttr.area_nohref = ["nohref"];
+
+gHTMLAttr.b = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.base = ["href", "target"];
+
+gHTMLAttr.base_target = gTarget;
+
+// this is deprecated //
+gHTMLAttr.basefont = ["^id", "$size", "color", "face"];
+
+gHTMLAttr.basefont_color = gHTMLColors;
+
+gHTMLAttr.bdo = ["_core", "-", "^lang", "$dir"];
+
+gHTMLAttr.bdo_dir = ["ltr", "rtl"];
+
+gHTMLAttr.big = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.blockquote = ["cite", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.body = [
+ "background",
+ "bgcolor",
+ "text",
+ "link",
+ "vlink",
+ "alink",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.body_bgcolor = gHTMLColors;
+
+gHTMLAttr.body_text = gHTMLColors;
+
+gHTMLAttr.body_link = gHTMLColors;
+
+gHTMLAttr.body_vlink = gHTMLColors;
+
+gHTMLAttr.body_alink = gHTMLColors;
+
+gHTMLAttr.br = ["clear", "-", "_core"];
+
+gHTMLAttr.br_clear = ["none", "left", "all", "right"];
+
+gHTMLAttr.button = [
+ "name",
+ "value",
+ "$type",
+ "disabled",
+ "#tabindex",
+ "!accesskey",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.button_type = ["submit", "button", "reset"];
+
+gHTMLAttr.button_disabled = ["disabled"];
+
+gHTMLAttr.caption = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.caption_align = ["top", "bottom", "left", "right"];
+
+// this is deprecated //
+gHTMLAttr.center = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.cite = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.code = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.col = [
+ "#$span",
+ "%width",
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "char",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.col_span = [
+ "1", // default
+];
+
+gHTMLAttr.col_align = gHAlignTableContent;
+
+gHTMLAttr.col_valign = ["top", "middle", "bottom", "baseline"];
+
+gHTMLAttr.colgroup = [
+ "#$span",
+ "%width",
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.colgroup_span = [
+ "1", // default
+];
+
+gHTMLAttr.colgroup_align = gHAlignTableContent;
+
+gHTMLAttr.colgroup_valign = ["top", "middle", "bottom", "baseline"];
+
+gHTMLAttr.dd = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.del = ["cite", "datetime", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.dfn = ["_core", "-", "^lang", "dir"];
+
+// this is deprecated //
+gHTMLAttr.dir = ["compact", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.dir_compact = ["compact"];
+
+gHTMLAttr.div = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.div_align = gHAlignJustify;
+
+gHTMLAttr.dl = ["compact", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.dl_compact = ["compact"];
+
+gHTMLAttr.dt = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.em = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.fieldset = ["_core", "-", "^lang", "dir"];
+
+// this is deprecated //
+gHTMLAttr.font = ["+size", "color", "face", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.font_color = gHTMLColors;
+
+gHTMLAttr.form = [
+ "$action",
+ "$method",
+ "enctype",
+ "accept",
+ "name",
+ "accept-charset",
+ "target",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.form_method = ["get", "post"];
+
+gHTMLAttr.form_enctype = ["application/x-www-form-urlencoded"];
+
+gHTMLAttr.form_target = gTarget;
+
+gHTMLAttr.frame = [
+ "longdesc",
+ "name",
+ "src",
+ "#frameborder",
+ "#marginwidth",
+ "#marginheight",
+ "noresize",
+ "$scrolling",
+];
+
+gHTMLAttr.frame_frameborder = ["1", "0"];
+
+gHTMLAttr.frame_noresize = ["noresize"];
+
+gHTMLAttr.frame_scrolling = ["auto", "yes", "no"];
+
+gHTMLAttr.frameset = ["rows", "cols", "-", "_core"];
+
+gHTMLAttr.h1 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h1_align = gHAlignJustify;
+
+gHTMLAttr.h2 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h2_align = gHAlignJustify;
+
+gHTMLAttr.h3 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h3_align = gHAlignJustify;
+
+gHTMLAttr.h4 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h4_align = gHAlignJustify;
+
+gHTMLAttr.h5 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h5_align = gHAlignJustify;
+
+gHTMLAttr.h6 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h6_align = gHAlignJustify;
+
+gHTMLAttr.head = ["profile", "-", "^lang", "dir"];
+
+gHTMLAttr.hr = [
+ "align",
+ "noshade",
+ "#size",
+ "%width",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.hr_align = gHAlign;
+
+gHTMLAttr.hr_noshade = ["noshade"];
+
+gHTMLAttr.html = ["version", "-", "^lang", "dir"];
+
+gHTMLAttr.i = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.iframe = [
+ "longdesc",
+ "name",
+ "src",
+ "$frameborder",
+ "marginwidth",
+ "marginheight",
+ "$scrolling",
+ "align",
+ "%height",
+ "%width",
+ "-",
+ "_core",
+];
+
+gHTMLAttr.iframe_frameborder = ["1", "0"];
+
+gHTMLAttr.iframe_scrolling = ["auto", "yes", "no"];
+
+gHTMLAttr.iframe_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.img = [
+ "$src",
+ "$alt",
+ "longdesc",
+ "name",
+ "%height",
+ "%width",
+ "usemap",
+ "ismap",
+ "align",
+ "#border",
+ "#hspace",
+ "#vspace",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.img_ismap = ["ismap"];
+
+gHTMLAttr.img_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.input = [
+ "$type",
+ "name",
+ "value",
+ "checked",
+ "disabled",
+ "readonly",
+ "#size",
+ "#maxlength",
+ "src",
+ "alt",
+ "usemap",
+ "ismap",
+ "#tabindex",
+ "!accesskey",
+ "accept",
+ "align",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.input_type = [
+ "text",
+ "password",
+ "checkbox",
+ "radio",
+ "submit",
+ "reset",
+ "file",
+ "hidden",
+ "image",
+ "button",
+];
+
+gHTMLAttr.input_checked = ["checked"];
+
+gHTMLAttr.input_disabled = ["disabled"];
+
+gHTMLAttr.input_readonly = ["readonly"];
+
+gHTMLAttr.input_ismap = ["ismap"];
+
+gHTMLAttr.input_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.ins = ["cite", "datetime", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.isindex = ["prompt", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.kbd = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.label = ["for", "!accesskey", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.legend = ["!accesskey", "align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.legend_align = ["top", "bottom", "left", "right"];
+
+gHTMLAttr.li = ["type", "#value", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.li_type = ["disc", "square", "circle", "-", "1", "a", "A", "i", "I"];
+
+gHTMLAttr.link = [
+ "charset",
+ "href",
+ "^hreflang",
+ "type",
+ "rel",
+ "rev",
+ "media",
+ "target",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.link_target = gTarget;
+
+gHTMLAttr.link_rel = [
+ "alternate",
+ "stylesheet",
+ "start",
+ "next",
+ "prev",
+ "contents",
+ "index",
+ "glossary",
+ "copyright",
+ "chapter",
+ "section",
+ "subsection",
+ "appendix",
+ "help",
+ "bookmark",
+];
+
+gHTMLAttr.link_rev = [
+ "alternate",
+ "stylesheet",
+ "start",
+ "next",
+ "prev",
+ "contents",
+ "index",
+ "glossary",
+ "copyright",
+ "chapter",
+ "section",
+ "subsection",
+ "appendix",
+ "help",
+ "bookmark",
+];
+
+gHTMLAttr.map = ["$name", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.menu = ["compact", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.menu_compact = ["compact"];
+
+gHTMLAttr.meta = [
+ "http-equiv",
+ "name",
+ "$content",
+ "scheme",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.noframes = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.noscript = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.object = [
+ "declare",
+ "classid",
+ "codebase",
+ "data",
+ "type",
+ "codetype",
+ "archive",
+ "standby",
+ "%height",
+ "%width",
+ "usemap",
+ "name",
+ "#tabindex",
+ "align",
+ "#border",
+ "#hspace",
+ "#vspace",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.object_declare = ["declare"];
+
+gHTMLAttr.object_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.ol = ["type", "compact", "#start", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.ol_type = ["1", "a", "A", "i", "I"];
+
+gHTMLAttr.ol_compact = ["compact"];
+
+gHTMLAttr.optgroup = ["disabled", "$label", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.optgroup_disabled = ["disabled"];
+
+gHTMLAttr.option = [
+ "selected",
+ "disabled",
+ "label",
+ "value",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.option_selected = ["selected"];
+
+gHTMLAttr.option_disabled = ["disabled"];
+
+gHTMLAttr.p = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.p_align = gHAlignJustify;
+
+gHTMLAttr.param = ["^id", "$name", "value", "$valuetype", "type"];
+
+gHTMLAttr.param_valuetype = ["data", "ref", "object"];
+
+gHTMLAttr.pre = ["%width", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.q = ["cite", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.s = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.samp = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.script = ["charset", "$type", "language", "src", "defer"];
+
+gHTMLAttr.script_defer = ["defer"];
+
+gHTMLAttr.select = [
+ "name",
+ "#size",
+ "multiple",
+ "disabled",
+ "#tabindex",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.select_multiple = ["multiple"];
+
+gHTMLAttr.select_disabled = ["disabled"];
+
+gHTMLAttr.small = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.span = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.strike = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.strong = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.style = ["$type", "media", "title", "-", "^lang", "dir"];
+
+gHTMLAttr.sub = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.sup = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.table = [
+ "summary",
+ "%width",
+ "#border",
+ "frame",
+ "rules",
+ "#cellspacing",
+ "#cellpadding",
+ "align",
+ "bgcolor",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.table_frame = [
+ "void",
+ "above",
+ "below",
+ "hsides",
+ "lhs",
+ "rhs",
+ "vsides",
+ "box",
+ "border",
+];
+
+gHTMLAttr.table_rules = ["none", "groups", "rows", "cols", "all"];
+
+// Note; This is alignment of the table,
+// not table contents, like all other table child elements
+gHTMLAttr.table_align = gHAlign;
+
+gHTMLAttr.table_bgcolor = gHTMLColors;
+
+gHTMLAttr.tbody = [
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.tbody_align = gHAlignTableContent;
+
+gHTMLAttr.tbody_valign = gVAlignTable;
+
+gHTMLAttr.td = [
+ "abbr",
+ "axis",
+ "headers",
+ "scope",
+ "$#rowspan",
+ "$#colspan",
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "nowrap",
+ "bgcolor",
+ "%width",
+ "%height",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.td_scope = ["row", "col", "rowgroup", "colgroup"];
+
+gHTMLAttr.td_rowspan = [
+ "1", // default
+];
+
+gHTMLAttr.td_colspan = [
+ "1", // default
+];
+
+gHTMLAttr.td_align = gHAlignTableContent;
+
+gHTMLAttr.td_valign = gVAlignTable;
+
+gHTMLAttr.td_nowrap = ["nowrap"];
+
+gHTMLAttr.td_bgcolor = gHTMLColors;
+
+gHTMLAttr.textarea = [
+ "name",
+ "$#rows",
+ "$#cols",
+ "disabled",
+ "readonly",
+ "#tabindex",
+ "!accesskey",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.textarea_disabled = ["disabled"];
+
+gHTMLAttr.textarea_readonly = ["readonly"];
+
+gHTMLAttr.tfoot = [
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.tfoot_align = gHAlignTableContent;
+
+gHTMLAttr.tfoot_valign = gVAlignTable;
+
+gHTMLAttr.th = [
+ "abbr",
+ "axis",
+ "headers",
+ "scope",
+ "$#rowspan",
+ "$#colspan",
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "nowrap",
+ "bgcolor",
+ "%width",
+ "%height",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.th_scope = ["row", "col", "rowgroup", "colgroup"];
+
+gHTMLAttr.th_rowspan = [
+ "1", // default
+];
+
+gHTMLAttr.th_colspan = [
+ "1", // default
+];
+
+gHTMLAttr.th_align = gHAlignTableContent;
+
+gHTMLAttr.th_valign = gVAlignTable;
+
+gHTMLAttr.th_nowrap = ["nowrap"];
+
+gHTMLAttr.th_bgcolor = gHTMLColors;
+
+gHTMLAttr.thead = [
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.thead_align = gHAlignTableContent;
+
+gHTMLAttr.thead_valign = gVAlignTable;
+
+gHTMLAttr.title = ["^lang", "dir"];
+
+gHTMLAttr.tr = [
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "bgcolor",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.tr_align = gHAlignTableContent;
+
+gHTMLAttr.tr_valign = gVAlignTable;
+
+gHTMLAttr.tr_bgcolor = gHTMLColors;
+
+gHTMLAttr.tt = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.u = ["_core", "-", "^lang", "dir"];
+gHTMLAttr.ul = ["type", "compact", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.ul_type = ["disc", "square", "circle"];
+
+gHTMLAttr.ul_compact = ["compact"];
+
+// Prefix with "_" since this is reserved (it's stripped out)
+gHTMLAttr._var = ["_core", "-", "^lang", "dir"];
+
+// ================ JS Attributes ================ //
+// These are element specific even handlers.
+/* Most all elements use gCoreJSEvents, so those
+ are assumed except for those listed here with "noEvents"
+*/
+
+gJSAttr.a = ["onfocus", "onblur"];
+
+gJSAttr.area = ["onfocus", "onblur"];
+
+gJSAttr.body = ["onload", "onupload"];
+
+gJSAttr.button = ["onfocus", "onblur"];
+
+gJSAttr.form = ["onsubmit", "onreset"];
+
+gJSAttr.frameset = ["onload", "onunload"];
+
+gJSAttr.input = ["onfocus", "onblur", "onselect", "onchange"];
+
+gJSAttr.label = ["onfocus", "onblur"];
+
+gJSAttr.select = ["onfocus", "onblur", "onchange"];
+
+gJSAttr.textarea = ["onfocus", "onblur", "onselect", "onchange"];
+
+// Elements that don't have JSEvents:
+gJSAttr.font = ["noJSEvents"];
+
+gJSAttr.applet = ["noJSEvents"];
+
+gJSAttr.isindex = ["noJSEvents"];
+
+gJSAttr.iframe = ["noJSEvents"];
diff --git a/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js b/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js
new file mode 100644
index 0000000000..ca54fa16da
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.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/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdAdvancedEdit.js */
+/* import-globals-from EdDialogCommon.js */
+
+// build attribute list in tree form from element attributes
+function BuildCSSAttributeTable() {
+ var style = gElement.style;
+ if (style == undefined) {
+ dump("Inline styles undefined\n");
+ return;
+ }
+
+ var declLength = style.length;
+
+ if (declLength == undefined || declLength == 0) {
+ if (declLength == undefined) {
+ dump("Failed to query the number of inline style declarations\n");
+ }
+
+ return;
+ }
+
+ if (declLength > 0) {
+ for (var i = 0; i < declLength; ++i) {
+ var name = style.item(i);
+ var value = style.getPropertyValue(name);
+ AddTreeItem(name, value, "CSSAList", CSSAttrs);
+ }
+ }
+
+ ClearCSSInputWidgets();
+}
+
+function onChangeCSSAttribute() {
+ var name = TrimString(gDialog.AddCSSAttributeNameInput.value);
+ if (!name) {
+ return;
+ }
+
+ var value = TrimString(gDialog.AddCSSAttributeValueInput.value);
+
+ // First try to update existing attribute
+ // If not found, add new attribute
+ if (!UpdateExistingAttribute(name, value, "CSSAList") && value) {
+ AddTreeItem(name, value, "CSSAList", CSSAttrs);
+ }
+}
+
+function ClearCSSInputWidgets() {
+ gDialog.AddCSSAttributeTree.view.selection.clearSelection();
+ gDialog.AddCSSAttributeNameInput.value = "";
+ gDialog.AddCSSAttributeValueInput.value = "";
+ SetTextboxFocus(gDialog.AddCSSAttributeNameInput);
+}
+
+function onSelectCSSTreeItem() {
+ if (!gDoOnSelectTree) {
+ return;
+ }
+
+ var tree = gDialog.AddCSSAttributeTree;
+ if (tree && tree.view.selection.count) {
+ gDialog.AddCSSAttributeNameInput.value = GetTreeItemAttributeStr(
+ getSelectedItem(tree)
+ );
+ gDialog.AddCSSAttributeValueInput.value = GetTreeItemValueStr(
+ getSelectedItem(tree)
+ );
+ }
+}
+
+function onInputCSSAttributeName() {
+ var attName = TrimString(
+ gDialog.AddCSSAttributeNameInput.value
+ ).toLowerCase();
+ var newValue = "";
+
+ var existingValue = GetAndSelectExistingAttributeValue(attName, "CSSAList");
+ if (existingValue) {
+ newValue = existingValue;
+ }
+
+ gDialog.AddCSSAttributeValueInput.value = newValue;
+}
+
+function editCSSAttributeValue(targetCell) {
+ if (IsNotTreeHeader(targetCell)) {
+ gDialog.AddCSSAttributeValueInput.select();
+ }
+}
+
+function UpdateCSSAttributes() {
+ var CSSAList = document.getElementById("CSSAList");
+ var styleString = "";
+ for (var i = 0; i < CSSAList.children.length; i++) {
+ var item = CSSAList.children[i];
+ var name = GetTreeItemAttributeStr(item);
+ var value = GetTreeItemValueStr(item);
+ // this code allows users to be sloppy in typing in values, and enter
+ // things like "foo: " and "bar;". This will trim off everything after the
+ // respective character.
+ if (name.includes(":")) {
+ name = name.substring(0, name.lastIndexOf(":"));
+ }
+ if (value.includes(";")) {
+ value = value.substring(0, value.lastIndexOf(";"));
+ }
+ if (i == CSSAList.children.length - 1) {
+ // Last property.
+ styleString += name + ": " + value + ";";
+ } else {
+ styleString += name + ": " + value + "; ";
+ }
+ }
+ if (styleString) {
+ // Use editor transactions if modifying the element directly in the document
+ doRemoveAttribute("style");
+ doSetAttribute("style", styleString); // NOTE BUG 18894!!!
+ } else if (gElement.getAttribute("style")) {
+ doRemoveAttribute("style");
+ }
+}
+
+function RemoveCSSAttribute() {
+ // We only allow 1 selected item
+ if (gDialog.AddCSSAttributeTree.view.selection.count) {
+ // Remove the item from the tree
+ // We always rebuild complete "style" string,
+ // so no list of "removed" items
+ getSelectedItem(gDialog.AddCSSAttributeTree).remove();
+
+ ClearCSSInputWidgets();
+ }
+}
+
+function SelectCSSTree(index) {
+ gDoOnSelectTree = false;
+ try {
+ gDialog.AddCSSAttributeTree.selectedIndex = index;
+ } catch (e) {}
+ gDoOnSelectTree = true;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js
new file mode 100644
index 0000000000..127bfb858b
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js
@@ -0,0 +1,362 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdAdvancedEdit.js */
+/* import-globals-from EdDialogCommon.js */
+
+function BuildHTMLAttributeNameList() {
+ gDialog.AddHTMLAttributeNameInput.removeAllItems();
+
+ var elementName = gElement.localName;
+ var attNames = gHTMLAttr[elementName];
+
+ if (attNames && attNames.length) {
+ var menuitem;
+
+ for (var i = 0; i < attNames.length; i++) {
+ var name = attNames[i];
+
+ if (name == "_core") {
+ // Signal to append the common 'core' attributes.
+ for (var j = 0; j < gCoreHTMLAttr.length; j++) {
+ name = gCoreHTMLAttr[j];
+
+ // only filtering rule used for core attributes as of 8-20-01
+ // Add more rules if necessary.
+ if (name.includes("^")) {
+ name = name.replace(/\^/g, "");
+ menuitem = gDialog.AddHTMLAttributeNameInput.appendItem(name, name);
+ menuitem.setAttribute("limitFirstChar", "true");
+ } else {
+ gDialog.AddHTMLAttributeNameInput.appendItem(name, name);
+ }
+ }
+ } else if (name == "-") {
+ // Signal for separator
+ var popup = gDialog.AddHTMLAttributeNameInput.menupopup;
+ if (popup) {
+ var sep = document.createXULElement("menuseparator");
+ if (sep) {
+ popup.appendChild(sep);
+ }
+ }
+ } else {
+ // Get information about value filtering
+ let forceOneChar = name.includes("!");
+ let forceInteger = name.includes("#");
+ let forceSignedInteger = name.includes("+");
+ let forceIntOrPercent = name.includes("%");
+ let limitFirstChar = name.includes("^");
+ // let required = name.includes("$");
+
+ // Strip flag characters
+ name = name.replace(/[!^#%$+]/g, "");
+
+ menuitem = gDialog.AddHTMLAttributeNameInput.appendItem(name, name);
+ if (menuitem) {
+ // Signify "required" attributes by special style
+ // TODO: Don't do this until next version, when we add
+ // explanatory text and an 'Autofill Required Attributes' button
+ // if (required)
+ // menuitem.setAttribute("class", "menuitem-highlight-1");
+
+ // Set flags to filter value input
+ if (forceOneChar) {
+ menuitem.setAttribute("forceOneChar", "true");
+ }
+ if (limitFirstChar) {
+ menuitem.setAttribute("limitFirstChar", "true");
+ }
+ if (forceInteger) {
+ menuitem.setAttribute("forceInteger", "true");
+ }
+ if (forceSignedInteger) {
+ menuitem.setAttribute("forceSignedInteger", "true");
+ }
+ if (forceIntOrPercent) {
+ menuitem.setAttribute("forceIntOrPercent", "true");
+ }
+ }
+ }
+ }
+ }
+}
+
+// build attribute list in tree form from element attributes
+function BuildHTMLAttributeTable() {
+ var nodeMap = gElement.attributes;
+ var i;
+ if (nodeMap.length > 0) {
+ var added = false;
+ for (i = 0; i < nodeMap.length; i++) {
+ let name = nodeMap[i].name.trim().toLowerCase();
+ if (
+ CheckAttributeNameSimilarity(nodeMap[i].nodeName, HTMLAttrs) ||
+ name.startsWith("on") ||
+ name == "style"
+ ) {
+ continue; // repeated or non-HTML attribute, ignore this one and go to next
+ }
+ if (
+ !name.startsWith("_moz") &&
+ AddTreeItem(name, nodeMap[i].value, "HTMLAList", HTMLAttrs)
+ ) {
+ added = true;
+ }
+ }
+
+ if (added) {
+ SelectHTMLTree(0);
+ }
+ }
+}
+
+function ClearHTMLInputWidgets() {
+ gDialog.AddHTMLAttributeTree.view.selection.clearSelection();
+ gDialog.AddHTMLAttributeNameInput.value = "";
+ gDialog.AddHTMLAttributeValueInput.value = "";
+ SetTextboxFocus(gDialog.AddHTMLAttributeNameInput);
+}
+
+function onSelectHTMLTreeItem() {
+ if (!gDoOnSelectTree) {
+ return;
+ }
+
+ var tree = gDialog.AddHTMLAttributeTree;
+ if (tree && tree.view.selection.count) {
+ var inputName = TrimString(
+ gDialog.AddHTMLAttributeNameInput.value
+ ).toLowerCase();
+ var selectedItem = getSelectedItem(tree);
+ var selectedName =
+ selectedItem.firstElementChild.firstElementChild.getAttribute("label");
+
+ if (inputName == selectedName) {
+ // Already editing selected name - just update the value input
+ gDialog.AddHTMLAttributeValueInput.value =
+ GetTreeItemValueStr(selectedItem);
+ } else {
+ gDialog.AddHTMLAttributeNameInput.value = selectedName;
+
+ // Change value input based on new selected name
+ onInputHTMLAttributeName();
+ }
+ }
+}
+
+function onInputHTMLAttributeName() {
+ let attName = gDialog.AddHTMLAttributeNameInput.value.toLowerCase().trim();
+
+ // Clear value widget, but prevent triggering update in tree
+ gUpdateTreeValue = false;
+ gDialog.AddHTMLAttributeValueInput.value = "";
+ gUpdateTreeValue = true;
+
+ if (attName) {
+ // Get value list for current attribute name
+ var valueListName;
+
+ // Most elements have the "dir" attribute,
+ // so we have just one array for the allowed values instead
+ // requiring duplicate entries for each element in EdAEAttributes.js
+ if (attName == "dir") {
+ valueListName = "all_dir";
+ } else {
+ valueListName = gElement.localName + "_" + attName;
+ }
+
+ // Strip off leading "_" we sometimes use (when element name is reserved word)
+ if (valueListName.startsWith("_")) {
+ valueListName = valueListName.slice(1);
+ }
+
+ let useMenulist = false; // Editable menulist vs. input for the value.
+ var newValue = "";
+ if (valueListName in gHTMLAttr) {
+ var valueList = gHTMLAttr[valueListName];
+
+ let listLen = valueList.length;
+ useMenulist = listLen > 1;
+ if (listLen == 1) {
+ newValue = valueList[0];
+ }
+
+ // Note: For case where "value list" is actually just
+ // one (default) item, don't use menulist for that
+ if (useMenulist) {
+ gDialog.AddHTMLAttributeValueMenulist.removeAllItems();
+
+ // Rebuild the list
+ for (var i = 0; i < listLen; i++) {
+ if (valueList[i] == "-") {
+ // Signal for separator
+ var popup = gDialog.AddHTMLAttributeValueInput.menupopup;
+ if (popup) {
+ var sep = document.createXULElement("menuseparator");
+ if (sep) {
+ popup.appendChild(sep);
+ }
+ }
+ } else {
+ gDialog.AddHTMLAttributeValueMenulist.appendItem(
+ valueList[i],
+ valueList[i]
+ );
+ }
+ }
+ }
+ }
+ if (useMenulist) {
+ // Switch to using editable menulist instead of the input.
+ gDialog.AddHTMLAttributeValueMenulist.parentElement.collapsed = false;
+ gDialog.AddHTMLAttributeValueTextbox.parentElement.collapsed = true;
+ gDialog.AddHTMLAttributeValueInput =
+ gDialog.AddHTMLAttributeValueMenulist;
+ } else {
+ // No list: Use input instead of editable menulist.
+ gDialog.AddHTMLAttributeValueMenulist.parentElement.collapsed = true;
+ gDialog.AddHTMLAttributeValueTextbox.parentElement.collapsed = false;
+ gDialog.AddHTMLAttributeValueInput = gDialog.AddHTMLAttributeValueTextbox;
+ }
+
+ // If attribute already exists in tree, use associated value,
+ // else use default found above
+ var existingValue = GetAndSelectExistingAttributeValue(
+ attName,
+ "HTMLAList"
+ );
+ if (existingValue) {
+ newValue = existingValue;
+ }
+
+ gDialog.AddHTMLAttributeValueInput.value = newValue;
+
+ if (!existingValue) {
+ onInputHTMLAttributeValue();
+ }
+ }
+}
+
+function onInputHTMLAttributeValue() {
+ if (!gUpdateTreeValue) {
+ return;
+ }
+
+ var name = TrimString(gDialog.AddHTMLAttributeNameInput.value);
+ if (!name) {
+ return;
+ }
+
+ // Trim spaces only from left since we must allow spaces within the string
+ // (we always reset the input field's value below)
+ var value = TrimStringLeft(gDialog.AddHTMLAttributeValueInput.value);
+ if (value) {
+ // Do value filtering based on type of attribute
+ // (Do not use "forceInteger()" to avoid multiple
+ // resetting of input's value and flickering)
+ var selectedItem = gDialog.AddHTMLAttributeNameInput.selectedItem;
+
+ if (selectedItem) {
+ if (
+ selectedItem.getAttribute("forceOneChar") == "true" &&
+ value.length > 1
+ ) {
+ value = value.slice(0, 1);
+ }
+
+ if (selectedItem.getAttribute("forceIntOrPercent") == "true") {
+ // Allow integer with optional "%" as last character
+ var percent = TrimStringRight(value).slice(-1);
+ value = value.replace(/\D+/g, "");
+ if (percent == "%") {
+ value += percent;
+ }
+ } else if (selectedItem.getAttribute("forceInteger") == "true") {
+ value = value.replace(/\D+/g, "");
+ } else if (selectedItem.getAttribute("forceSignedInteger") == "true") {
+ // Allow integer with optional "+" or "-" as first character
+ var sign = value[0];
+ value = value.replace(/\D+/g, "");
+ if (sign == "+" || sign == "-") {
+ value = sign + value;
+ }
+ }
+
+ // Special case attributes
+ if (selectedItem.getAttribute("limitFirstChar") == "true") {
+ // Limit first character to letter, and all others to
+ // letters, numbers, and a few others
+ value = value
+ .replace(/^[^a-zA-Z\u0080-\uFFFF]/, "")
+ .replace(/[^a-zA-Z0-9_\.\-\:\u0080-\uFFFF]+/g, "");
+ }
+
+ // Update once only if it changed
+ if (value != gDialog.AddHTMLAttributeValueInput.value) {
+ gDialog.AddHTMLAttributeValueInput.value = value;
+ }
+ }
+ }
+
+ // Update value in the tree list
+ // If not found, add new attribute
+ if (!UpdateExistingAttribute(name, value, "HTMLAList") && value) {
+ AddTreeItem(name, value, "HTMLAList", HTMLAttrs);
+ }
+}
+
+function editHTMLAttributeValue(targetCell) {
+ if (IsNotTreeHeader(targetCell)) {
+ gDialog.AddHTMLAttributeValueInput.select();
+ }
+}
+
+// update the object with added and removed attributes
+function UpdateHTMLAttributes() {
+ var HTMLAList = document.getElementById("HTMLAList");
+ var i;
+
+ // remove removed attributes
+ for (i = 0; i < HTMLRAttrs.length; i++) {
+ var name = HTMLRAttrs[i];
+
+ if (gElement.hasAttribute(name)) {
+ doRemoveAttribute(name);
+ }
+ }
+
+ // Set added or changed attributes
+ for (i = 0; i < HTMLAList.children.length; i++) {
+ var item = HTMLAList.children[i];
+ doSetAttribute(GetTreeItemAttributeStr(item), GetTreeItemValueStr(item));
+ }
+}
+
+function RemoveHTMLAttribute() {
+ // We only allow 1 selected item
+ if (gDialog.AddHTMLAttributeTree.view.selection.count) {
+ var item = getSelectedItem(gDialog.AddHTMLAttributeTree);
+ var attr = GetTreeItemAttributeStr(item);
+
+ // remove the item from the attribute array
+ HTMLRAttrs[HTMLRAttrs.length] = attr;
+ RemoveNameFromAttArray(attr, HTMLAttrs);
+
+ // Remove the item from the tree
+ item.remove();
+
+ // Clear inputs and selected item in tree
+ ClearHTMLInputWidgets();
+ }
+}
+
+function SelectHTMLTree(index) {
+ gDoOnSelectTree = false;
+ try {
+ gDialog.AddHTMLAttributeTree.selectedIndex = index;
+ } catch (e) {}
+ gDoOnSelectTree = true;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js
new file mode 100644
index 0000000000..8f902b74cd
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.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/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdAdvancedEdit.js */
+/* import-globals-from EdDialogCommon.js */
+
+function BuildJSEAttributeNameList() {
+ gDialog.AddJSEAttributeNameList.removeAllItems();
+
+ // Get events specific to current element
+ var elementName = gElement.localName;
+ if (elementName in gJSAttr) {
+ var attNames = gJSAttr[elementName];
+ var i;
+ var popup;
+ var sep;
+
+ if (attNames && attNames.length) {
+ // Since we don't allow user-editable JS events yet (but we will soon)
+ // simply remove the JS tab to not allow adding JS events
+ if (attNames[0] == "noJSEvents") {
+ var tab = document.getElementById("tabJSE");
+ if (tab) {
+ tab.remove();
+ }
+
+ return;
+ }
+
+ for (i = 0; i < attNames.length; i++) {
+ gDialog.AddJSEAttributeNameList.appendItem(attNames[i], attNames[i]);
+ }
+
+ popup = gDialog.AddJSEAttributeNameList.firstElementChild;
+ if (popup) {
+ sep = document.createXULElement("menuseparator");
+ if (sep) {
+ popup.appendChild(sep);
+ }
+ }
+ }
+ }
+
+ // Always add core JS events unless we aborted above
+ for (i = 0; i < gCoreJSEvents.length; i++) {
+ if (gCoreJSEvents[i] == "-") {
+ if (!popup) {
+ popup = gDialog.AddJSEAttributeNameList.firstElementChild;
+ }
+
+ sep = document.createXULElement("menuseparator");
+
+ if (popup && sep) {
+ popup.appendChild(sep);
+ }
+ } else {
+ gDialog.AddJSEAttributeNameList.appendItem(
+ gCoreJSEvents[i],
+ gCoreJSEvents[i]
+ );
+ }
+ }
+
+ gDialog.AddJSEAttributeNameList.selectedIndex = 0;
+
+ // Use current name and value of first tree item if it exists
+ onSelectJSETreeItem();
+}
+
+// build attribute list in tree form from element attributes
+function BuildJSEAttributeTable() {
+ var nodeMap = gElement.attributes;
+ if (nodeMap.length > 0) {
+ var added = false;
+ for (var i = 0; i < nodeMap.length; i++) {
+ let name = nodeMap[i].nodeName.toLowerCase();
+ if (CheckAttributeNameSimilarity(nodeMap[i].nodeName, JSEAttrs)) {
+ // Repeated or non-JS handler, ignore this one and go to next.
+ continue;
+ }
+ if (!name.startsWith("on")) {
+ // Attribute isn't an event handler.
+ continue;
+ }
+ var value = gElement.getAttribute(nodeMap[i].nodeName);
+ if (AddTreeItem(name, value, "JSEAList", JSEAttrs)) {
+ // add item to tree
+ added = true;
+ }
+ }
+
+ // Select first item
+ if (added) {
+ gDialog.AddJSEAttributeTree.selectedIndex = 0;
+ }
+ }
+}
+
+function onSelectJSEAttribute() {
+ if (!gDoOnSelectTree) {
+ return;
+ }
+
+ gDialog.AddJSEAttributeValueInput.value = GetAndSelectExistingAttributeValue(
+ gDialog.AddJSEAttributeNameList.label,
+ "JSEAList"
+ );
+}
+
+function onSelectJSETreeItem() {
+ var tree = gDialog.AddJSEAttributeTree;
+ if (tree && tree.view.selection.count) {
+ // Select attribute name in list
+ gDialog.AddJSEAttributeNameList.value = GetTreeItemAttributeStr(
+ getSelectedItem(tree)
+ );
+
+ // Set value input to that in tree (no need to update this in the tree)
+ gUpdateTreeValue = false;
+ gDialog.AddJSEAttributeValueInput.value = GetTreeItemValueStr(
+ getSelectedItem(tree)
+ );
+ gUpdateTreeValue = true;
+ }
+}
+
+function onInputJSEAttributeValue() {
+ if (gUpdateTreeValue) {
+ var name = TrimString(gDialog.AddJSEAttributeNameList.label);
+ var value = TrimString(gDialog.AddJSEAttributeValueInput.value);
+
+ // Update value in the tree list
+ // Since we have a non-editable menulist,
+ // we MUST automatically add the event attribute if it doesn't exist
+ if (!UpdateExistingAttribute(name, value, "JSEAList") && value) {
+ AddTreeItem(name, value, "JSEAList", JSEAttrs);
+ }
+ }
+}
+
+function editJSEAttributeValue(targetCell) {
+ if (IsNotTreeHeader(targetCell)) {
+ gDialog.AddJSEAttributeValueInput.select();
+ }
+}
+
+function UpdateJSEAttributes() {
+ var JSEAList = document.getElementById("JSEAList");
+ var i;
+
+ // remove removed attributes
+ for (i = 0; i < JSERAttrs.length; i++) {
+ var name = JSERAttrs[i];
+
+ if (gElement.hasAttribute(name)) {
+ doRemoveAttribute(name);
+ }
+ }
+
+ // Add events
+ for (i = 0; i < JSEAList.children.length; i++) {
+ var item = JSEAList.children[i];
+
+ // set the event handler
+ doSetAttribute(GetTreeItemAttributeStr(item), GetTreeItemValueStr(item));
+ }
+}
+
+function RemoveJSEAttribute() {
+ // This differs from HTML and CSS panels:
+ // We reselect after removing, because there is not
+ // editable attribute name input, so we can't clear that
+ // like we do in other panels
+ var newIndex = gDialog.AddJSEAttributeTree.selectedIndex;
+
+ // We only allow 1 selected item
+ if (gDialog.AddJSEAttributeTree.view.selection.count) {
+ var item = getSelectedItem(gDialog.AddJSEAttributeTree);
+
+ // Name is the text of the treecell
+ var attr = GetTreeItemAttributeStr(item);
+
+ // remove the item from the attribute array
+ if (newIndex >= JSEAttrs.length - 1) {
+ newIndex--;
+ }
+
+ // remove the item from the attribute array
+ JSERAttrs[JSERAttrs.length] = attr;
+ RemoveNameFromAttArray(attr, JSEAttrs);
+
+ // Remove the item from the tree
+ item.remove();
+
+ // Reselect an item
+ gDialog.AddJSEAttributeTree.selectedIndex = newIndex;
+ }
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js
new file mode 100644
index 0000000000..5f2515c2f6
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js
@@ -0,0 +1,342 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdAEAttributes.js */
+/* import-globals-from EdAECSSAttributes.js */
+/* import-globals-from EdAEHTMLAttributes.js */
+/* import-globals-from EdAEJSEAttributes.js */
+/* import-globals-from EdDialogCommon.js */
+
+/** ************ GLOBALS */
+var gElement = null; // handle to actual element edited
+
+var HTMLAttrs = []; // html attributes
+var CSSAttrs = []; // css attributes
+var JSEAttrs = []; // js events
+
+var HTMLRAttrs = []; // removed html attributes
+var JSERAttrs = []; // removed js events
+
+/* Set false to allow changing selection in tree
+ without doing "onselect" handler actions
+*/
+var gDoOnSelectTree = true;
+var gUpdateTreeValue = true;
+
+/** ************ INITIALISATION && SETUP */
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+/**
+ * function : void Startup();
+ * parameters : none
+ * returns : none
+ * desc. : startup and initialisation, prepares dialog.
+ */
+function Startup() {
+ var editor = GetCurrentEditor();
+
+ // Element to edit is passed in
+ if (!editor || !window.arguments[1]) {
+ dump("Advanced Edit: No editor or element to edit not supplied\n");
+ window.close();
+ return;
+ }
+ // This is the return value for the parent,
+ // who only needs to know if OK was clicked
+ window.opener.AdvancedEditOK = false;
+
+ // The actual element edited (not a copy!)
+ gElement = window.arguments[1];
+
+ // place the tag name in the header
+ var tagLabel = document.getElementById("tagLabel");
+ tagLabel.setAttribute("value", "<" + gElement.localName + ">");
+
+ // Create dialog object to store controls for easy access
+ gDialog.AddHTMLAttributeNameInput = document.getElementById(
+ "AddHTMLAttributeNameInput"
+ );
+
+ gDialog.AddHTMLAttributeValueMenulist = document.getElementById(
+ "AddHTMLAttributeValueMenulist"
+ );
+ gDialog.AddHTMLAttributeValueTextbox = document.getElementById(
+ "AddHTMLAttributeValueTextbox"
+ );
+ gDialog.AddHTMLAttributeValueInput = gDialog.AddHTMLAttributeValueTextbox;
+
+ gDialog.AddHTMLAttributeTree = document.getElementById("HTMLATree");
+ gDialog.AddCSSAttributeNameInput = document.getElementById(
+ "AddCSSAttributeNameInput"
+ );
+ gDialog.AddCSSAttributeValueInput = document.getElementById(
+ "AddCSSAttributeValueInput"
+ );
+ gDialog.AddCSSAttributeTree = document.getElementById("CSSATree");
+ gDialog.AddJSEAttributeNameList = document.getElementById(
+ "AddJSEAttributeNameList"
+ );
+ gDialog.AddJSEAttributeValueInput = document.getElementById(
+ "AddJSEAttributeValueInput"
+ );
+ gDialog.AddJSEAttributeTree = document.getElementById("JSEATree");
+ gDialog.okButton = document.querySelector("dialog").getButton("accept");
+
+ // build the attribute trees
+ BuildHTMLAttributeTable();
+ BuildCSSAttributeTable();
+ BuildJSEAttributeTable();
+
+ // Build attribute name arrays for menulists
+ BuildJSEAttributeNameList();
+ BuildHTMLAttributeNameList();
+ // No menulists for CSS panel (yet)
+
+ // Set focus to Name editable menulist in HTML panel
+ SetTextboxFocus(gDialog.AddHTMLAttributeNameInput);
+
+ // size the dialog properly
+ window.sizeToContent();
+
+ SetWindowLocation();
+}
+
+/**
+ * function : bool onAccept ( void );
+ * parameters : none
+ * returns : boolean true to close the window
+ * desc. : event handler for ok button
+ */
+function onAccept() {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+ try {
+ // Update our gElement attributes
+ UpdateHTMLAttributes();
+ UpdateCSSAttributes();
+ UpdateJSEAttributes();
+ } catch (ex) {
+ dump(ex);
+ }
+ editor.endTransaction();
+
+ window.opener.AdvancedEditOK = true;
+ SaveWindowLocation();
+}
+
+// Helpers for removing and setting attributes
+// Use editor transactions if modifying the element already in the document
+// (Temporary element from a property dialog won't have a parent node)
+function doRemoveAttribute(attrib) {
+ try {
+ var editor = GetCurrentEditor();
+ if (gElement.parentNode) {
+ editor.removeAttribute(gElement, attrib);
+ } else {
+ gElement.removeAttribute(attrib);
+ }
+ } catch (ex) {}
+}
+
+function doSetAttribute(attrib, value) {
+ try {
+ var editor = GetCurrentEditor();
+ if (gElement.parentNode) {
+ editor.setAttribute(gElement, attrib, value);
+ } else {
+ gElement.setAttribute(attrib, value);
+ }
+ } catch (ex) {}
+}
+
+/**
+ * function : bool CheckAttributeNameSimilarity ( string attName, array attArray );
+ * parameters : attribute to look for, array of current attributes
+ * returns : true if attribute already exists, false if it does not
+ * desc. : checks to see if any other attributes by the same name as the arg supplied
+ * already exist.
+ */
+function CheckAttributeNameSimilarity(attName, attArray) {
+ for (var i = 0; i < attArray.length; i++) {
+ if (attName.toLowerCase() == attArray[i].toLowerCase()) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * function : bool UpdateExistingAttribute ( string attName, string attValue, string treeChildrenId );
+ * parameters : attribute to look for, new value, ID of <treeChildren> node in XUL tree
+ * returns : true if attribute already exists in tree, false if it does not
+ * desc. : checks to see if any other attributes by the same name as the arg supplied
+ * already exist while setting the associated value if different from current value
+ */
+function UpdateExistingAttribute(attName, attValue, treeChildrenId) {
+ var treeChildren = document.getElementById(treeChildrenId);
+ if (!treeChildren) {
+ return false;
+ }
+
+ var name;
+ var i;
+ attName = TrimString(attName).toLowerCase();
+ attValue = TrimString(attValue);
+
+ for (i = 0; i < treeChildren.children.length; i++) {
+ var item = treeChildren.children[i];
+ name = GetTreeItemAttributeStr(item);
+ if (name.toLowerCase() == attName) {
+ // Set the text in the "value' column treecell
+ SetTreeItemValueStr(item, attValue);
+
+ // Select item just changed,
+ // but don't trigger the tree's onSelect handler
+ gDoOnSelectTree = false;
+ try {
+ selectTreeItem(treeChildren, item);
+ } catch (e) {}
+ gDoOnSelectTree = true;
+
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * function : string GetAndSelectExistingAttributeValue ( string attName, string treeChildrenId );
+ * parameters : attribute to look for, ID of <treeChildren> node in XUL tree
+ * returns : value in from the tree or empty string if name not found
+ */
+function GetAndSelectExistingAttributeValue(attName, treeChildrenId) {
+ if (!attName) {
+ return "";
+ }
+
+ var treeChildren = document.getElementById(treeChildrenId);
+ var name;
+ var i;
+
+ for (i = 0; i < treeChildren.children.length; i++) {
+ var item = treeChildren.children[i];
+ name = GetTreeItemAttributeStr(item);
+ if (name.toLowerCase() == attName.toLowerCase()) {
+ // Select item in the tree
+ // but don't trigger the tree's onSelect handler
+ gDoOnSelectTree = false;
+ try {
+ selectTreeItem(treeChildren, item);
+ } catch (e) {}
+ gDoOnSelectTree = true;
+
+ // Get the text in the "value' column treecell
+ return GetTreeItemValueStr(item);
+ }
+ }
+
+ // Attribute doesn't exist in tree, so remove selection
+ gDoOnSelectTree = false;
+ try {
+ treeChildren.parentNode.view.selection.clearSelection();
+ } catch (e) {}
+ gDoOnSelectTree = true;
+
+ return "";
+}
+
+/* Tree structure:
+ <treeItem>
+ <treeRow>
+ <treeCell> // Name Cell
+ <treeCell // Value Cell
+*/
+function GetTreeItemAttributeStr(treeItem) {
+ if (treeItem) {
+ return TrimString(
+ treeItem.firstElementChild.firstElementChild.getAttribute("label")
+ );
+ }
+
+ return "";
+}
+
+function GetTreeItemValueStr(treeItem) {
+ if (treeItem) {
+ return TrimString(
+ treeItem.firstElementChild.lastElementChild.getAttribute("label")
+ );
+ }
+
+ return "";
+}
+
+function SetTreeItemValueStr(treeItem, value) {
+ if (treeItem && GetTreeItemValueStr(treeItem) != value) {
+ treeItem.firstElementChild.lastElementChild.setAttribute("label", value);
+ }
+}
+
+function IsNotTreeHeader(treeCell) {
+ if (treeCell) {
+ return treeCell.parentNode.parentNode.nodeName != "treehead";
+ }
+
+ return false;
+}
+
+function RemoveNameFromAttArray(attName, attArray) {
+ for (var i = 0; i < attArray.length; i++) {
+ if (attName.toLowerCase() == attArray[i].toLowerCase()) {
+ // Remove 1 array item
+ attArray.splice(i, 1);
+ break;
+ }
+ }
+}
+
+// adds a generalised treeitem.
+function AddTreeItem(name, value, treeChildrenId, attArray) {
+ attArray[attArray.length] = name;
+ var treeChildren = document.getElementById(treeChildrenId);
+ var treeitem = document.createXULElement("treeitem");
+ var treerow = document.createXULElement("treerow");
+
+ var attrCell = document.createXULElement("treecell");
+ attrCell.setAttribute("class", "propertylist");
+ attrCell.setAttribute("label", name);
+
+ var valueCell = document.createXULElement("treecell");
+ valueCell.setAttribute("class", "propertylist");
+ valueCell.setAttribute("label", value);
+
+ treerow.appendChild(attrCell);
+ treerow.appendChild(valueCell);
+ treeitem.appendChild(treerow);
+ treeChildren.appendChild(treeitem);
+
+ // Select item just added, but suppress calling the onSelect handler.
+ gDoOnSelectTree = false;
+ try {
+ selectTreeItem(treeChildren, treeitem);
+ } catch (e) {}
+ gDoOnSelectTree = true;
+
+ return treeitem;
+}
+
+function selectTreeItem(treeChildren, item) {
+ var index = treeChildren.parentNode.view.getIndexOfItem(item);
+ treeChildren.parentNode.view.selection.select(index);
+}
+
+function getSelectedItem(tree) {
+ if (tree.view.selection.count == 1) {
+ return tree.view.getItemAtIndex(tree.currentIndex);
+ }
+ return null;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml
new file mode 100644
index 0000000000..cfeff95b42
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml
@@ -0,0 +1,243 @@
+<?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/. -->
+
+<!-- first checkin of the year 2000! -->
+<!-- Ben Goodger, 12:50AM, 01/00/00 NZST -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/menulist.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdAdvancedEdit.dtd">
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="min-width: 40em"
+ title="&WindowTitle.label;"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog id="advancedEditDlg">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <!-- element page functions -->
+ <script src="chrome://messenger/content/messengercompose/EdAEHTMLAttributes.js" />
+ <script src="chrome://messenger/content/messengercompose/EdAECSSAttributes.js" />
+ <script src="chrome://messenger/content/messengercompose/EdAEJSEAttributes.js" />
+ <script src="chrome://messenger/content/messengercompose/EdAEAttributes.js" />
+
+ <!-- global dialog functions -->
+ <script src="chrome://messenger/content/messengercompose/EdAdvancedEdit.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <hbox>
+ <label value="&currentattributesfor.label;" />
+ <label class="header" id="tagLabel" />
+ </hbox>
+
+ <separator class="thin" />
+
+ <tabbox flex="1">
+ <tabs>
+ <tab label="&tabHTML.label;" />
+ <tab label="&tabCSS.label;" />
+ <tab label="&tabJSE.label;" id="tabJSE" />
+ </tabs>
+ <tabpanels flex="1">
+ <!-- ============================================================== -->
+ <!-- HTML Attributes -->
+ <!-- ============================================================== -->
+ <vbox>
+ <tree
+ id="HTMLATree"
+ class="AttributesTree"
+ flex="1"
+ hidecolumnpicker="true"
+ seltype="single"
+ onselect="onSelectHTMLTreeItem();"
+ onclick="onSelectHTMLTreeItem();"
+ ondblclick="editHTMLAttributeValue(event.target);"
+ >
+ <treecols>
+ <treecol id="HTMLAttrCol" label="&tree.attributeHeader.label;" />
+ <splitter class="tree-splitter" />
+ <treecol id="HTMLValCol" label="&tree.valueHeader.label;" />
+ </treecols>
+ <treechildren id="HTMLAList" flex="1" />
+ </tree>
+ <hbox align="center">
+ <label value="&editAttribute.label;" />
+ <spacer flex="1" />
+ <button
+ label="&removeAttribute.label;"
+ oncommand="RemoveHTMLAttribute();"
+ />
+ </hbox>
+ <hbox>
+ <vbox flex="1">
+ <label
+ control="AddHTMLAttributeNameInput"
+ value="&AttName.label;"
+ />
+ <menulist
+ is="menulist-editable"
+ id="AddHTMLAttributeNameInput"
+ class="editorAdvancedEditableMenulist"
+ editable="true"
+ flex="1"
+ oninput="onInputHTMLAttributeName();"
+ oncommand="onInputHTMLAttributeName();"
+ />
+ </vbox>
+ <vbox flex="1">
+ <label
+ id="AddHTMLAttributeValueLabel"
+ control="AddHTMLAttributeValueInput"
+ value="&AttValue.label;"
+ />
+ <vbox flex="1">
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="AddHTMLAttributeValueTextbox"
+ type="text"
+ class="input-inline"
+ onchange="onInputHTMLAttributeValue();"
+ aria-labelledby="AddHTMLAttributeValueLabel"
+ />
+ </hbox>
+ <hbox flex="1" collapsed="true">
+ <menulist
+ is="menulist-editable"
+ id="AddHTMLAttributeValueMenulist"
+ editable="true"
+ flex="1"
+ oninput="onInputHTMLAttributeValue();"
+ oncommand="onInputHTMLAttributeValue();"
+ />
+ </hbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ <!-- ============================================================== -->
+ <!-- CSS Attributes -->
+ <!-- ============================================================== -->
+ <vbox>
+ <tree
+ id="CSSATree"
+ class="AttributesTree"
+ flex="1"
+ hidecolumnpicker="true"
+ seltype="single"
+ onselect="onSelectCSSTreeItem();"
+ onclick="onSelectCSSTreeItem();"
+ ondblclick="editCSSAttributeValue(event.target);"
+ >
+ <treecols>
+ <treecol id="CSSPropCol" label="&tree.propertyHeader.label;" />
+ <splitter class="tree-splitter" />
+ <treecol id="CSSValCol" label="&tree.valueHeader.label;" />
+ </treecols>
+ <treechildren id="CSSAList" flex="1" />
+ </tree>
+ <hbox align="center">
+ <label value="&editAttribute.label;" />
+ <spacer flex="1" />
+ <button
+ label="&removeAttribute.label;"
+ oncommand="RemoveCSSAttribute();"
+ />
+ </hbox>
+ <hbox>
+ <vbox flex="1">
+ <label
+ id="AddCSSAttributeNameLabel"
+ value="&PropertyName.label;"
+ />
+ <html:input
+ id="AddCSSAttributeNameInput"
+ type="text"
+ class="input-inline"
+ onchange="onInputCSSAttributeName();"
+ aria-labelledby="AddCSSAttributeNameLabel"
+ />
+ </vbox>
+ <vbox flex="1">
+ <label id="AddCSSAttributeValueLabel" value="&AttValue.label;" />
+ <html:input
+ id="AddCSSAttributeValueInput"
+ type="text"
+ class="input-inline"
+ onchange="onChangeCSSAttribute();"
+ aria-labelledby="AddCSSAttributeValueLabel"
+ />
+ </vbox>
+ </hbox>
+ </vbox>
+ <!-- ============================================================== -->
+ <!-- JavaScript Event Handlers -->
+ <!-- ============================================================== -->
+ <vbox>
+ <tree
+ id="JSEATree"
+ class="AttributesTree"
+ flex="1"
+ hidecolumnpicker="true"
+ seltype="single"
+ onselect="onSelectJSETreeItem();"
+ onclick="onSelectJSETreeItem();"
+ ondblclick="editJSEAttributeValue(event.target);"
+ >
+ <treecols>
+ <treecol id="AttrCol" label="&tree.attributeHeader.label;" />
+ <splitter class="tree-splitter" />
+ <treecol id="HeaderCol" label="&tree.valueHeader.label;" />
+ </treecols>
+ <treechildren id="JSEAList" flex="1" />
+ </tree>
+ <hbox align="center">
+ <label value="&editAttribute.label;" />
+ <spacer flex="1" />
+ <button
+ label="&removeAttribute.label;"
+ oncommand="RemoveJSEAttribute()"
+ />
+ </hbox>
+ <hbox>
+ <vbox flex="1">
+ <label value="&AttName.label;" />
+ <menulist
+ id="AddJSEAttributeNameList"
+ oncommand="onSelectJSEAttribute();"
+ />
+ </vbox>
+ <vbox flex="1">
+ <label id="AddJSEAttributeValueLabel" value="&AttValue.label;" />
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="AddJSEAttributeValueInput"
+ type="text"
+ class="input-inline"
+ onchange="onInputJSEAttributeValue();"
+ aria-labelledby="AddJSEAttributeValueLabel"
+ />
+ </hbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ </tabpanels>
+ </tabbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdColorPicker.js b/comm/mail/components/compose/content/dialogs/EdColorPicker.js
new file mode 100644
index 0000000000..ef03a1d10b
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdColorPicker.js
@@ -0,0 +1,290 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+
+var insertNew = true;
+var tagname = "TAG NAME";
+var gColor = "";
+var LastPickedColor = "";
+var ColorType = "Text";
+var TextType = false;
+var HighlightType = false;
+var TableOrCell = false;
+var LastPickedIsDefault = true;
+var NoDefault = false;
+var gColorObj;
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancelColor);
+
+function Startup() {
+ if (!window.arguments[1]) {
+ dump("EdColorPicker: Missing color object param\n");
+ return;
+ }
+
+ // window.arguments[1] is object to get initial values and return color data
+ gColorObj = window.arguments[1];
+ gColorObj.Cancel = false;
+
+ gDialog.ColorPicker = document.getElementById("ColorPicker");
+ gDialog.ColorInput = document.getElementById("ColorInput");
+ gDialog.LastPickedButton = document.getElementById("LastPickedButton");
+ gDialog.LastPickedColor = document.getElementById("LastPickedColor");
+ gDialog.CellOrTableGroup = document.getElementById("CellOrTableGroup");
+ gDialog.TableRadio = document.getElementById("TableRadio");
+ gDialog.CellRadio = document.getElementById("CellRadio");
+ gDialog.ColorSwatch = document.getElementById("ColorPickerSwatch");
+ gDialog.Ok = document.querySelector("dialog").getButton("accept");
+
+ // The type of color we are setting:
+ // text: Text, Link, ActiveLink, VisitedLink,
+ // or background: Page, Table, or Cell
+ if (gColorObj.Type) {
+ ColorType = gColorObj.Type;
+ // Get string for dialog title from passed-in type
+ // (note constraint on editor.properties string name)
+ let IsCSSPrefChecked = Services.prefs.getBoolPref("editor.use_css");
+
+ if (GetCurrentEditor()) {
+ if (ColorType == "Page" && IsCSSPrefChecked && IsHTMLEditor()) {
+ document.title = GetString("BlockColor");
+ } else {
+ document.title = GetString(ColorType + "Color");
+ }
+ }
+ }
+
+ gDialog.ColorInput.value = "";
+ var tmpColor;
+ var haveTableRadio = false;
+
+ switch (ColorType) {
+ case "Page":
+ tmpColor = gColorObj.PageColor;
+ if (tmpColor && tmpColor.toLowerCase() != "window") {
+ gColor = tmpColor;
+ }
+ break;
+ case "Table":
+ if (gColorObj.TableColor) {
+ gColor = gColorObj.TableColor;
+ }
+ break;
+ case "Cell":
+ if (gColorObj.CellColor) {
+ gColor = gColorObj.CellColor;
+ }
+ break;
+ case "TableOrCell":
+ TableOrCell = true;
+ document.getElementById("TableOrCellGroup").collapsed = false;
+ haveTableRadio = true;
+ if (gColorObj.SelectedType == "Cell") {
+ gColor = gColorObj.CellColor;
+ gDialog.CellOrTableGroup.selectedItem = gDialog.CellRadio;
+ gDialog.CellRadio.focus();
+ } else {
+ gColor = gColorObj.TableColor;
+ gDialog.CellOrTableGroup.selectedItem = gDialog.TableRadio;
+ gDialog.TableRadio.focus();
+ }
+ break;
+ case "Highlight":
+ HighlightType = true;
+ if (gColorObj.HighlightColor) {
+ gColor = gColorObj.HighlightColor;
+ }
+ break;
+ default:
+ // Any other type will change some kind of text,
+ TextType = true;
+ tmpColor = gColorObj.TextColor;
+ if (tmpColor && tmpColor.toLowerCase() != "windowtext") {
+ gColor = gColorObj.TextColor;
+ }
+ break;
+ }
+
+ // Set initial color in input field and in the colorpicker
+ SetCurrentColor(gColor);
+ gDialog.ColorPicker.value = gColor;
+
+ // Use last-picked colors passed in, or those persistent on dialog
+ if (TextType) {
+ if (!("LastTextColor" in gColorObj) || !gColorObj.LastTextColor) {
+ gColorObj.LastTextColor =
+ gDialog.LastPickedColor.getAttribute("LastTextColor");
+ }
+ LastPickedColor = gColorObj.LastTextColor;
+ } else if (HighlightType) {
+ if (!("LastHighlightColor" in gColorObj) || !gColorObj.LastHighlightColor) {
+ gColorObj.LastHighlightColor =
+ gDialog.LastPickedColor.getAttribute("LastHighlightColor");
+ }
+ LastPickedColor = gColorObj.LastHighlightColor;
+ } else {
+ if (
+ !("LastBackgroundColor" in gColorObj) ||
+ !gColorObj.LastBackgroundColor
+ ) {
+ gColorObj.LastBackgroundColor = gDialog.LastPickedColor.getAttribute(
+ "LastBackgroundColor"
+ );
+ }
+ LastPickedColor = gColorObj.LastBackgroundColor;
+ }
+
+ // Set method to detect clicking on OK button
+ // so we don't get fooled by changing "default" behavior
+ gDialog.Ok.setAttribute("onclick", "SetDefaultToOk()");
+
+ if (!LastPickedColor) {
+ // Hide the button, as there is no last color available.
+ gDialog.LastPickedButton.hidden = true;
+ } else {
+ gDialog.LastPickedColor.setAttribute(
+ "style",
+ "background-color: " + LastPickedColor
+ );
+
+ // Make "Last-picked" the default button, until the user selects a color.
+ gDialog.Ok.removeAttribute("default");
+ gDialog.LastPickedButton.setAttribute("default", "true");
+ }
+
+ // Caller can prevent user from submitting an empty, i.e., default color
+ NoDefault = gColorObj.NoDefault;
+ if (NoDefault) {
+ // Hide the "Default button -- user must pick a color
+ document.getElementById("DefaultColorButton").collapsed = true;
+ }
+
+ // Set focus to colorpicker if not set to table radio buttons above
+ if (!haveTableRadio) {
+ gDialog.ColorPicker.focus();
+ }
+
+ SetWindowLocation();
+}
+
+function SelectColor() {
+ var color = gDialog.ColorPicker.value;
+ if (color) {
+ SetCurrentColor(color);
+ }
+}
+
+function RemoveColor() {
+ SetCurrentColor("");
+ gDialog.ColorInput.focus();
+ SetDefaultToOk();
+}
+
+function SelectColorByKeypress(aEvent) {
+ if (aEvent.charCode == aEvent.DOM_VK_SPACE) {
+ SelectColor();
+ SetDefaultToOk();
+ }
+}
+
+function SelectLastPickedColor() {
+ SetCurrentColor(LastPickedColor);
+ if (onAccept()) {
+ // window.close();
+ return true;
+ }
+
+ return false;
+}
+
+function SetCurrentColor(color) {
+ // TODO: Validate color?
+ if (!color) {
+ color = "";
+ }
+ gColor = TrimString(color).toLowerCase();
+ if (gColor == "mixed") {
+ gColor = "";
+ }
+ gDialog.ColorInput.value = gColor;
+ SetColorSwatch();
+}
+
+function SetColorSwatch() {
+ gDialog.ColorSwatch.setAttribute(
+ "style",
+ `background-color: ${TrimString(gDialog.ColorInput.value) || "inherit"}`
+ );
+}
+
+function SetDefaultToOk() {
+ gDialog.LastPickedButton.removeAttribute("default");
+ gDialog.Ok.setAttribute("default", "true");
+ LastPickedIsDefault = false;
+}
+
+function ValidateData() {
+ if (LastPickedIsDefault) {
+ gColor = LastPickedColor;
+ } else {
+ gColor = gDialog.ColorInput.value;
+ }
+
+ gColor = TrimString(gColor).toLowerCase();
+
+ // TODO: Validate the color string!
+
+ if (NoDefault && !gColor) {
+ ShowInputErrorMessage(GetString("NoColorError"));
+ SetTextboxFocus(gDialog.ColorInput);
+ return false;
+ }
+ return true;
+}
+
+function onAccept(event) {
+ if (!ValidateData()) {
+ event.preventDefault();
+ return;
+ }
+
+ // Set return values and save in persistent color attributes
+ if (TextType) {
+ gColorObj.TextColor = gColor;
+ if (gColor.length > 0) {
+ gDialog.LastPickedColor.setAttribute("LastTextColor", gColor);
+ gColorObj.LastTextColor = gColor;
+ }
+ } else if (HighlightType) {
+ gColorObj.HighlightColor = gColor;
+ if (gColor.length > 0) {
+ gDialog.LastPickedColor.setAttribute("LastHighlightColor", gColor);
+ gColorObj.LastHighlightColor = gColor;
+ }
+ } else {
+ gColorObj.BackgroundColor = gColor;
+ if (gColor.length > 0) {
+ gDialog.LastPickedColor.setAttribute("LastBackgroundColor", gColor);
+ gColorObj.LastBackgroundColor = gColor;
+ }
+ // If table or cell requested, tell caller which element to set on
+ if (TableOrCell && gDialog.TableRadio.selected) {
+ gColorObj.Type = "Table";
+ }
+ }
+ SaveWindowLocation();
+}
+
+function onCancelColor() {
+ // Tells caller that user canceled
+ gColorObj.Cancel = true;
+ SaveWindowLocation();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml b/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml
new file mode 100644
index 0000000000..8576fc27da
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml
@@ -0,0 +1,103 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdColorPicker.dtd">
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdColorPicker.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <hbox id="TableOrCellGroup" align="center" collapsed="true">
+ <label
+ control="CellOrTableGroup"
+ value="&background.label;"
+ accesskey="&background.accessKey;"
+ />
+ <radiogroup id="CellOrTableGroup" orient="horizontal">
+ <radio
+ id="TableRadio"
+ label="&table.label;"
+ accesskey="&table.accessKey;"
+ />
+ <radio
+ id="CellRadio"
+ label="&cell.label;"
+ accesskey="&cell.accessKey;"
+ />
+ </radiogroup>
+ </hbox>
+ <hbox align="center">
+ <label value="&chooseColor1.label;" />
+ <html:input
+ type="color"
+ id="ColorPicker"
+ onclick="SetDefaultToOk();"
+ ondblclick="if (onAccept()) { window.close(); }"
+ onkeypress="SelectColorByKeypress(event);"
+ onchange="SelectColor();"
+ />
+ <spacer flex="1" />
+ <button
+ id="LastPickedButton"
+ label="&lastPickedColor.label;"
+ accesskey="&lastPickedColor.accessKey;"
+ crop="right"
+ oncommand="SelectLastPickedColor();"
+ >
+ <spacer
+ id="LastPickedColor"
+ LastTextColor=""
+ LastBackgroundColor=""
+ persist="LastTextColor LastBackgroundColor"
+ />
+ </button>
+ </hbox>
+
+ <spacer class="spacer" />
+ <hbox align="center" flex="1">
+ <vbox>
+ <label
+ class="tip-caption"
+ value="&chooseColor2.label;"
+ accesskey="&chooseColor2.accessKey;"
+ control="ColorInput"
+ />
+ <label class="tip-caption" value="&setColorExample.label;" />
+ </vbox>
+ <html:input
+ id="ColorInput"
+ type="text"
+ style="width: 8em"
+ oninput="SetColorSwatch(); SetDefaultToOk();"
+ />
+ <label id="ColorPickerSwatch" />
+ <spacer flex="1" />
+ <button
+ id="DefaultColorButton"
+ label="&default.label;"
+ accesskey="&default.accessKey;"
+ oncommand="RemoveColor()"
+ />
+ </hbox>
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdColorProps.js b/comm/mail/components/compose/content/dialogs/EdColorProps.js
new file mode 100644
index 0000000000..c2635912d5
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdColorProps.js
@@ -0,0 +1,476 @@
+/* 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/. */
+
+/*
+ Behavior notes:
+ Radio buttons select "UseDefaultColors" vs. "UseCustomColors" modes.
+ If any color attribute is set in the body, mode is "Custom Colors",
+ even if 1 or more (but not all) are actually null (= "use default")
+ When in "Custom Colors" mode, all colors will be set on body tag,
+ even if they are just default colors, to assure compatible colors in page.
+ User cannot select "use default" for individual colors
+*/
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+var gBodyElement;
+var prefs;
+var gBackgroundImage;
+
+// Initialize in case we can't get them from prefs???
+var defaultTextColor = "#000000";
+var defaultLinkColor = "#000099";
+var defaultActiveColor = "#000099";
+var defaultVisitedColor = "#990099";
+var defaultBackgroundColor = "#FFFFFF";
+const styleStr = "style";
+const textStr = "text";
+const linkStr = "link";
+const vlinkStr = "vlink";
+const alinkStr = "alink";
+const bgcolorStr = "bgcolor";
+const backgroundStr = "background";
+const cssColorStr = "color";
+const cssBackgroundColorStr = "background-color";
+const cssBackgroundImageStr = "background-image";
+const colorStyle = cssColorStr + ": ";
+const backColorStyle = cssBackgroundColorStr + ": ";
+const backImageStyle = "; " + cssBackgroundImageStr + ": url(";
+
+var customTextColor;
+var customLinkColor;
+var customActiveColor;
+var customVisitedColor;
+var customBackgroundColor;
+var previewBGColor;
+
+// dialog initialization code
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ gDialog.ColorPreview = document.getElementById("ColorPreview");
+ gDialog.NormalText = document.getElementById("NormalText");
+ gDialog.LinkText = document.getElementById("LinkText");
+ gDialog.ActiveLinkText = document.getElementById("ActiveLinkText");
+ gDialog.VisitedLinkText = document.getElementById("VisitedLinkText");
+ gDialog.PageColorGroup = document.getElementById("PageColorGroup");
+ gDialog.DefaultColorsRadio = document.getElementById("DefaultColorsRadio");
+ gDialog.CustomColorsRadio = document.getElementById("CustomColorsRadio");
+ gDialog.BackgroundImageInput = document.getElementById(
+ "BackgroundImageInput"
+ );
+
+ try {
+ gBodyElement = editor.rootElement;
+ } catch (e) {}
+
+ if (!gBodyElement) {
+ dump("Failed to get BODY element!\n");
+ window.close();
+ }
+
+ // Set element we will edit
+ globalElement = gBodyElement.cloneNode(false);
+
+ // Initialize default colors from browser prefs
+ var browserColors = GetDefaultBrowserColors();
+ if (browserColors) {
+ // Use author's browser pref colors passed into dialog
+ defaultTextColor = browserColors.TextColor;
+ defaultLinkColor = browserColors.LinkColor;
+ defaultActiveColor = browserColors.ActiveLinkColor;
+ defaultVisitedColor = browserColors.VisitedLinkColor;
+ defaultBackgroundColor = browserColors.BackgroundColor;
+ }
+
+ // We only need to test for this once per dialog load
+ gHaveDocumentUrl = GetDocumentBaseUrl();
+
+ InitDialog();
+
+ gDialog.PageColorGroup.focus();
+
+ SetWindowLocation();
+}
+
+function InitDialog() {
+ // Get image from document
+ gBackgroundImage = GetHTMLOrCSSStyleValue(
+ globalElement,
+ backgroundStr,
+ cssBackgroundImageStr
+ );
+ if (/url\((.*)\)/.test(gBackgroundImage)) {
+ gBackgroundImage = RegExp.$1;
+ }
+
+ if (gBackgroundImage) {
+ // Shorten data URIs for display.
+ shortenImageData(gBackgroundImage, gDialog.BackgroundImageInput);
+ gDialog.ColorPreview.setAttribute(
+ styleStr,
+ backImageStyle + gBackgroundImage + ");"
+ );
+ }
+
+ SetRelativeCheckbox();
+
+ customTextColor = GetHTMLOrCSSStyleValue(globalElement, textStr, cssColorStr);
+ customTextColor = ConvertRGBColorIntoHEXColor(customTextColor);
+ customLinkColor = globalElement.getAttribute(linkStr);
+ customActiveColor = globalElement.getAttribute(alinkStr);
+ customVisitedColor = globalElement.getAttribute(vlinkStr);
+ customBackgroundColor = GetHTMLOrCSSStyleValue(
+ globalElement,
+ bgcolorStr,
+ cssBackgroundColorStr
+ );
+ customBackgroundColor = ConvertRGBColorIntoHEXColor(customBackgroundColor);
+
+ var haveCustomColor =
+ customTextColor ||
+ customLinkColor ||
+ customVisitedColor ||
+ customActiveColor ||
+ customBackgroundColor;
+
+ // Set default color explicitly for any that are missing
+ // PROBLEM: We are using "windowtext" and "window" for the Windows OS
+ // default color values. This works with CSS in preview window,
+ // but we should NOT use these as values for HTML attributes!
+
+ if (!customTextColor) {
+ customTextColor = defaultTextColor;
+ }
+ if (!customLinkColor) {
+ customLinkColor = defaultLinkColor;
+ }
+ if (!customActiveColor) {
+ customActiveColor = defaultActiveColor;
+ }
+ if (!customVisitedColor) {
+ customVisitedColor = defaultVisitedColor;
+ }
+ if (!customBackgroundColor) {
+ customBackgroundColor = defaultBackgroundColor;
+ }
+
+ if (haveCustomColor) {
+ // If any colors are set, then check the "Custom" radio button
+ gDialog.PageColorGroup.selectedItem = gDialog.CustomColorsRadio;
+ UseCustomColors();
+ } else {
+ gDialog.PageColorGroup.selectedItem = gDialog.DefaultColorsRadio;
+ UseDefaultColors();
+ }
+}
+
+function GetColorAndUpdate(ColorWellID) {
+ // Only allow selecting when in custom mode
+ if (!gDialog.CustomColorsRadio.selected) {
+ return;
+ }
+
+ var colorWell = document.getElementById(ColorWellID);
+ if (!colorWell) {
+ return;
+ }
+
+ // Don't allow a blank color, i.e., using the "default"
+ var colorObj = {
+ NoDefault: true,
+ Type: "",
+ TextColor: 0,
+ PageColor: 0,
+ Cancel: false,
+ };
+
+ switch (ColorWellID) {
+ case "textCW":
+ colorObj.Type = "Text";
+ colorObj.TextColor = customTextColor;
+ break;
+ case "linkCW":
+ colorObj.Type = "Link";
+ colorObj.TextColor = customLinkColor;
+ break;
+ case "activeCW":
+ colorObj.Type = "ActiveLink";
+ colorObj.TextColor = customActiveColor;
+ break;
+ case "visitedCW":
+ colorObj.Type = "VisitedLink";
+ colorObj.TextColor = customVisitedColor;
+ break;
+ case "backgroundCW":
+ colorObj.Type = "Page";
+ colorObj.PageColor = customBackgroundColor;
+ break;
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdColorPicker.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ colorObj
+ );
+
+ // User canceled the dialog
+ if (colorObj.Cancel) {
+ return;
+ }
+
+ var color = "";
+ switch (ColorWellID) {
+ case "textCW":
+ color = customTextColor = colorObj.TextColor;
+ break;
+ case "linkCW":
+ color = customLinkColor = colorObj.TextColor;
+ break;
+ case "activeCW":
+ color = customActiveColor = colorObj.TextColor;
+ break;
+ case "visitedCW":
+ color = customVisitedColor = colorObj.TextColor;
+ break;
+ case "backgroundCW":
+ color = customBackgroundColor = colorObj.BackgroundColor;
+ break;
+ }
+
+ setColorWell(ColorWellID, color);
+ SetColorPreview(ColorWellID, color);
+}
+
+function SetColorPreview(ColorWellID, color) {
+ switch (ColorWellID) {
+ case "textCW":
+ gDialog.NormalText.setAttribute(styleStr, colorStyle + color);
+ break;
+ case "linkCW":
+ gDialog.LinkText.setAttribute(styleStr, colorStyle + color);
+ break;
+ case "activeCW":
+ gDialog.ActiveLinkText.setAttribute(styleStr, colorStyle + color);
+ break;
+ case "visitedCW":
+ gDialog.VisitedLinkText.setAttribute(styleStr, colorStyle + color);
+ break;
+ case "backgroundCW":
+ // Must combine background color and image style values
+ var styleValue = backColorStyle + color;
+ if (gBackgroundImage) {
+ styleValue += ";" + backImageStyle + gBackgroundImage + ");";
+ }
+
+ gDialog.ColorPreview.setAttribute(styleStr, styleValue);
+ previewBGColor = color;
+ break;
+ }
+}
+
+function UseCustomColors() {
+ SetElementEnabledById("TextButton", true);
+ SetElementEnabledById("LinkButton", true);
+ SetElementEnabledById("ActiveLinkButton", true);
+ SetElementEnabledById("VisitedLinkButton", true);
+ SetElementEnabledById("BackgroundButton", true);
+ SetElementEnabledById("Text", true);
+ SetElementEnabledById("Link", true);
+ SetElementEnabledById("Active", true);
+ SetElementEnabledById("Visited", true);
+ SetElementEnabledById("Background", true);
+
+ SetColorPreview("textCW", customTextColor);
+ SetColorPreview("linkCW", customLinkColor);
+ SetColorPreview("activeCW", customActiveColor);
+ SetColorPreview("visitedCW", customVisitedColor);
+ SetColorPreview("backgroundCW", customBackgroundColor);
+
+ setColorWell("textCW", customTextColor);
+ setColorWell("linkCW", customLinkColor);
+ setColorWell("activeCW", customActiveColor);
+ setColorWell("visitedCW", customVisitedColor);
+ setColorWell("backgroundCW", customBackgroundColor);
+}
+
+function UseDefaultColors() {
+ SetColorPreview("textCW", defaultTextColor);
+ SetColorPreview("linkCW", defaultLinkColor);
+ SetColorPreview("activeCW", defaultActiveColor);
+ SetColorPreview("visitedCW", defaultVisitedColor);
+ SetColorPreview("backgroundCW", defaultBackgroundColor);
+
+ // Setting to blank color will remove color from buttons,
+ setColorWell("textCW", "");
+ setColorWell("linkCW", "");
+ setColorWell("activeCW", "");
+ setColorWell("visitedCW", "");
+ setColorWell("backgroundCW", "");
+
+ // Disable color buttons and labels
+ SetElementEnabledById("TextButton", false);
+ SetElementEnabledById("LinkButton", false);
+ SetElementEnabledById("ActiveLinkButton", false);
+ SetElementEnabledById("VisitedLinkButton", false);
+ SetElementEnabledById("BackgroundButton", false);
+ SetElementEnabledById("Text", false);
+ SetElementEnabledById("Link", false);
+ SetElementEnabledById("Active", false);
+ SetElementEnabledById("Visited", false);
+ SetElementEnabledById("Background", false);
+}
+
+function chooseFile() {
+ // Get a local image file, converted into URL format
+ GetLocalFileURL("img").then(fileURL => {
+ // Always try to relativize local file URLs
+ if (gHaveDocumentUrl) {
+ fileURL = MakeRelativeUrl(fileURL);
+ }
+
+ gDialog.BackgroundImageInput.value = fileURL;
+
+ SetRelativeCheckbox();
+ ValidateAndPreviewImage(true);
+ SetTextboxFocus(gDialog.BackgroundImageInput);
+ });
+}
+
+function ChangeBackgroundImage() {
+ // Don't show error message for image while user is typing
+ ValidateAndPreviewImage(false);
+ SetRelativeCheckbox();
+}
+
+function ValidateAndPreviewImage(ShowErrorMessage) {
+ // First make a string with just background color
+ var styleValue = backColorStyle + previewBGColor + ";";
+
+ var retVal = true;
+ var image = TrimString(gDialog.BackgroundImageInput.value);
+ if (image) {
+ if (isImageDataShortened(image)) {
+ gBackgroundImage = restoredImageData(gDialog.BackgroundImageInput);
+ } else {
+ gBackgroundImage = image;
+
+ // Display must use absolute URL if possible
+ var displayImage = gHaveDocumentUrl ? MakeAbsoluteUrl(image) : image;
+ styleValue += backImageStyle + displayImage + ");";
+ }
+ } else {
+ gBackgroundImage = null;
+ }
+
+ // Set style on preview (removes image if not valid)
+ gDialog.ColorPreview.setAttribute(styleStr, styleValue);
+
+ // Note that an "empty" string is valid
+ return retVal;
+}
+
+function ValidateData() {
+ var editor = GetCurrentEditor();
+ try {
+ // Colors values are updated as they are picked, no validation necessary
+ if (gDialog.DefaultColorsRadio.selected) {
+ editor.removeAttributeOrEquivalent(globalElement, textStr, true);
+ globalElement.removeAttribute(linkStr);
+ globalElement.removeAttribute(vlinkStr);
+ globalElement.removeAttribute(alinkStr);
+ editor.removeAttributeOrEquivalent(globalElement, bgcolorStr, true);
+ } else {
+ // Do NOT accept the CSS "WindowsOS" color strings!
+ // Problem: We really should try to get the actual color values
+ // from windows, but I don't know how to do that!
+ var tmpColor = customTextColor.toLowerCase();
+ if (tmpColor != "windowtext") {
+ editor.setAttributeOrEquivalent(
+ globalElement,
+ textStr,
+ customTextColor,
+ true
+ );
+ } else {
+ editor.removeAttributeOrEquivalent(globalElement, textStr, true);
+ }
+
+ tmpColor = customBackgroundColor.toLowerCase();
+ if (tmpColor != "window") {
+ editor.setAttributeOrEquivalent(
+ globalElement,
+ bgcolorStr,
+ customBackgroundColor,
+ true
+ );
+ } else {
+ editor.removeAttributeOrEquivalent(globalElement, bgcolorStr, true);
+ }
+
+ globalElement.setAttribute(linkStr, customLinkColor);
+ globalElement.setAttribute(vlinkStr, customVisitedColor);
+ globalElement.setAttribute(alinkStr, customActiveColor);
+ }
+
+ if (ValidateAndPreviewImage(true)) {
+ // A valid image may be null for no image
+ if (gBackgroundImage) {
+ globalElement.setAttribute(backgroundStr, gBackgroundImage);
+ } else {
+ editor.removeAttributeOrEquivalent(globalElement, backgroundStr, true);
+ }
+
+ return true;
+ }
+ } catch (e) {}
+ return false;
+}
+
+function onAccept(event) {
+ // If it's a file, convert to a data URL.
+ if (gBackgroundImage && /^file:/i.test(gBackgroundImage)) {
+ let nsFile = Services.io
+ .newURI(gBackgroundImage)
+ .QueryInterface(Ci.nsIFileURL).file;
+ if (nsFile.exists()) {
+ let reader = new FileReader();
+ reader.addEventListener("load", function () {
+ gBackgroundImage = reader.result;
+ gDialog.BackgroundImageInput.value = reader.result;
+ if (onAccept(event)) {
+ window.close();
+ }
+ });
+ File.createFromNsIFile(nsFile).then(file => {
+ reader.readAsDataURL(file);
+ });
+ event.preventDefault(); // Don't close just yet...
+ return false;
+ }
+ }
+ if (ValidateData()) {
+ // Copy attributes to element we are changing
+ try {
+ GetCurrentEditor().cloneAttributes(gBodyElement, globalElement);
+ } catch (e) {}
+
+ SaveWindowLocation();
+ return true; // do close the window
+ }
+ event.preventDefault();
+ return false;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml b/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml
new file mode 100644
index 0000000000..633b1639d9
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml
@@ -0,0 +1,211 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edColorPropertiesDTD SYSTEM "chrome://messenger/locale/messengercompose/EditorColorProperties.dtd">
+%edColorPropertiesDTD;
+<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd">
+%composeEditorOverlayDTD;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdColorProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <html:fieldset align="start">
+ <html:legend>&pageColors.label;</html:legend>
+ <radiogroup id="PageColorGroup">
+ <radio
+ id="DefaultColorsRadio"
+ label="&defaultColorsRadio.label;"
+ oncommand="UseDefaultColors()"
+ accesskey="&defaultColorsRadio.accessKey;"
+ tooltiptext="&defaultColorsRadio.tooltip;"
+ />
+ <radio
+ id="CustomColorsRadio"
+ label="&customColorsRadio.label;"
+ oncommand="UseCustomColors()"
+ accesskey="&customColorsRadio.accessKey;"
+ tooltiptext="&customColorsRadio.tooltip;"
+ />
+ </radiogroup>
+ <hbox class="indent">
+ <hbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Text"
+ control="TextButton"
+ value="&normalText.label;&colon.character;"
+ accesskey="&normalText.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Link"
+ flex="1"
+ control="LinkButton"
+ value="&linkText.label;&colon.character;"
+ accesskey="&linkText.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Active"
+ flex="1"
+ control="ActiveLinkButton"
+ value="&activeLinkText.label;&colon.character;"
+ accesskey="&activeLinkText.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Visited"
+ flex="1"
+ control="VisitedLinkButton"
+ value="&visitedLinkText.label;&colon.character;"
+ accesskey="&visitedLinkText.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Background"
+ flex="1"
+ control="BackgroundButton"
+ value="&background.label;"
+ accesskey="&background.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ <vbox>
+ <button
+ id="TextButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('textCW');"
+ >
+ <spacer id="textCW" class="color-well" />
+ </button>
+ <button
+ id="LinkButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('linkCW');"
+ >
+ <spacer id="linkCW" class="color-well" />
+ </button>
+ <button
+ id="ActiveLinkButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('activeCW');"
+ >
+ <spacer id="activeCW" class="color-well" />
+ </button>
+ <button
+ id="VisitedLinkButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('visitedCW');"
+ >
+ <spacer id="visitedCW" class="color-well" />
+ </button>
+ <button
+ id="BackgroundButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('backgroundCW');"
+ >
+ <spacer id="backgroundCW" class="color-well" />
+ </button>
+ </vbox>
+ </hbox>
+ <vbox id="ColorPreview">
+ <spacer flex="1" />
+ <label class="larger" id="NormalText" value="&normalText.label;" />
+ <spacer flex="1" />
+ <label class="larger" id="LinkText" value="&linkText.label;" />
+ <spacer flex="1" />
+ <label
+ class="larger"
+ id="ActiveLinkText"
+ value="&activeLinkText.label;"
+ />
+ <spacer flex="1" />
+ <label
+ class="larger"
+ id="VisitedLinkText"
+ value="&visitedLinkText.label;"
+ />
+ <spacer flex="1" />
+ </vbox>
+ <spacer flex="1" />
+ </hbox>
+ <spacer class="spacer" />
+ </html:fieldset>
+ <spacer class="spacer" />
+ <label
+ control="BackgroundImageInput"
+ value="&backgroundImage.label;"
+ tooltiptext="&backgroundImage.tooltip;"
+ accesskey="&backgroundImage.accessKey;"
+ />
+ <tooltip id="shortenedDataURI">
+ <label value="&backgroundImage.shortenedDataURI;" />
+ </tooltip>
+ <html:input
+ id="BackgroundImageInput"
+ type="text"
+ class="uri-element input-inline"
+ onchange="ChangeBackgroundImage()"
+ aria-label="&backgroundImage.tooltip;"
+ />
+ <hbox align="center">
+ <checkbox
+ id="MakeRelativeCheckbox"
+ for="BackgroundImageInput"
+ label="&makeUrlRelative.label;"
+ accesskey="&makeUrlRelative.accessKey;"
+ oncommand="MakeInputValueRelativeOrAbsolute(this);"
+ tooltiptext="&makeUrlRelative.tooltip;"
+ />
+ <spacer flex="1" />
+ <button
+ id="ChooseFile"
+ oncommand="chooseFile()"
+ label="&chooseFileButton.label;"
+ accesskey="&chooseFileButton.accessKey;"
+ />
+ </hbox>
+ <spacer class="smallspacer" />
+ <hbox>
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton"
+ oncommand="onAdvancedEdit();"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdConvertToTable.js b/comm/mail/components/compose/content/dialogs/EdConvertToTable.js
new file mode 100644
index 0000000000..e7f19cff67
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdConvertToTable.js
@@ -0,0 +1,325 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+var gIndex;
+var gCommaIndex = "0";
+var gSpaceIndex = "1";
+var gOtherIndex = "2";
+
+// dialog initialization code
+function Startup() {
+ if (!GetCurrentEditor()) {
+ window.close();
+ return;
+ }
+
+ gDialog.sepRadioGroup = document.getElementById("SepRadioGroup");
+ gDialog.sepCharacterInput = document.getElementById("SepCharacterInput");
+ gDialog.deleteSepCharacter = document.getElementById("DeleteSepCharacter");
+ gDialog.collapseSpaces = document.getElementById("CollapseSpaces");
+
+ // We persist the user's separator character
+ gDialog.sepCharacterInput.value =
+ gDialog.sepRadioGroup.getAttribute("character");
+
+ gIndex = gDialog.sepRadioGroup.getAttribute("index");
+
+ switch (gIndex) {
+ case gCommaIndex:
+ default:
+ gDialog.sepRadioGroup.selectedItem = document.getElementById("comma");
+ break;
+ case gSpaceIndex:
+ gDialog.sepRadioGroup.selectedItem = document.getElementById("space");
+ break;
+ case gOtherIndex:
+ gDialog.sepRadioGroup.selectedItem = document.getElementById("other");
+ break;
+ }
+
+ // Set initial enable state on character input and "collapse" checkbox
+ SelectCharacter(gIndex);
+
+ SetWindowLocation();
+}
+
+function InputSepCharacter() {
+ var str = gDialog.sepCharacterInput.value;
+
+ // Limit input to 1 character
+ if (str.length > 1) {
+ str = str.slice(0, 1);
+ }
+
+ // We can never allow tag or entity delimiters for separator character
+ if (str == "<" || str == ">" || str == "&" || str == ";" || str == " ") {
+ str = "";
+ }
+
+ gDialog.sepCharacterInput.value = str;
+}
+
+function SelectCharacter(radioGroupIndex) {
+ gIndex = radioGroupIndex;
+ SetElementEnabledById("SepCharacterInput", gIndex == gOtherIndex);
+ SetElementEnabledById("CollapseSpaces", gIndex == gSpaceIndex);
+}
+
+/* eslint-disable complexity */
+function onAccept() {
+ var sepCharacter = "";
+ switch (gIndex) {
+ case gCommaIndex:
+ sepCharacter = ",";
+ break;
+ case gSpaceIndex:
+ sepCharacter = " ";
+ break;
+ case gOtherIndex:
+ sepCharacter = gDialog.sepCharacterInput.value.slice(0, 1);
+ break;
+ }
+
+ var editor = GetCurrentEditor();
+ var str;
+ try {
+ str = editor.outputToString(
+ "text/html",
+ kOutputLFLineBreak | kOutputSelectionOnly
+ );
+ } catch (e) {}
+ if (!str) {
+ SaveWindowLocation();
+ return;
+ }
+
+ // Replace nbsp with spaces:
+ str = str.replace(/\u00a0/g, " ");
+
+ // Strip out </p> completely
+ str = str.replace(/\s*<\/p>\s*/g, "");
+
+ // Trim whitespace adjacent to <p> and <br> tags
+ // and replace <p> with <br>
+ // (which will be replaced with </tr> below)
+ str = str.replace(/\s*<p>\s*|\s*<br>\s*/g, "<br>");
+
+ // Trim leading <br>s
+ str = str.replace(/^(<br>)+/, "");
+
+ // Trim trailing <br>s
+ str = str.replace(/(<br>)+$/, "");
+
+ // Reduce multiple internal <br> to just 1
+ // TODO: Maybe add a checkbox to let user decide
+ // str = str.replace(/(<br>)+/g, "<br>");
+
+ // Trim leading and trailing spaces
+ str = str.trim();
+
+ // Remove all tag contents so we don't replace
+ // separator character within tags
+ // Also converts lists to something useful
+ var stack = [];
+ var start;
+ var end;
+ var searchStart = 0;
+ var listSeparator = "";
+ var listItemSeparator = "";
+ var endList = false;
+
+ do {
+ start = str.indexOf("<", searchStart);
+
+ if (start >= 0) {
+ end = str.indexOf(">", start + 1);
+ if (end > start) {
+ let tagContent = str.slice(start + 1, end).trim();
+
+ if (/^ol|^ul|^dl/.test(tagContent)) {
+ // Replace list tag with <BR> to start new row
+ // at beginning of second or greater list tag
+ str = str.slice(0, start) + listSeparator + str.slice(end + 1);
+ if (listSeparator == "") {
+ listSeparator = "<br>";
+ }
+
+ // Reset for list item separation into cells
+ listItemSeparator = "";
+ } else if (/^li|^dt|^dd/.test(tagContent)) {
+ // Start a new row if this is first item after the ending the last list
+ if (endList) {
+ listItemSeparator = "<br>";
+ }
+
+ // Start new cell at beginning of second or greater list items
+ str = str.slice(0, start) + listItemSeparator + str.slice(end + 1);
+
+ if (endList || listItemSeparator == "") {
+ listItemSeparator = sepCharacter;
+ }
+
+ endList = false;
+ } else {
+ // Find end tags
+ endList = /^\/ol|^\/ul|^\/dl/.test(tagContent);
+ if (endList || /^\/li|^\/dt|^\/dd/.test(tagContent)) {
+ // Strip out tag
+ str = str.slice(0, start) + str.slice(end + 1);
+ } else {
+ // Not a list-related tag: Store tag contents in an array
+ stack.push(tagContent);
+
+ // Keep the "<" and ">" while removing from source string
+ start++;
+ str = str.slice(0, start) + str.slice(end);
+ }
+ }
+ }
+ searchStart = start + 1;
+ }
+ } while (start >= 0);
+
+ // Replace separator characters with table cells
+ var replaceString;
+ if (gDialog.deleteSepCharacter.checked) {
+ replaceString = "";
+ } else {
+ // Don't delete separator character,
+ // so include it at start of string to replace
+ replaceString = sepCharacter;
+ }
+
+ replaceString += "<td>";
+
+ if (sepCharacter.length > 0) {
+ var tempStr = sepCharacter;
+ var regExpChars = ".!@#$%^&*-+[]{}()|\\/";
+ if (regExpChars.includes(sepCharacter)) {
+ tempStr = "\\" + sepCharacter;
+ }
+
+ if (gIndex == gSpaceIndex) {
+ // If checkbox is checked,
+ // one or more adjacent spaces are one separator
+ if (gDialog.collapseSpaces.checked) {
+ tempStr = "\\s+";
+ } else {
+ tempStr = "\\s";
+ }
+ }
+ var pattern = new RegExp(tempStr, "g");
+ str = str.replace(pattern, replaceString);
+ }
+
+ // Put back tag contents that we removed above
+ searchStart = 0;
+ var stackIndex = 0;
+ do {
+ start = str.indexOf("<", searchStart);
+ end = start + 1;
+ if (start >= 0 && str.charAt(end) == ">") {
+ // We really need a FIFO stack!
+ str = str.slice(0, end) + stack[stackIndex++] + str.slice(end);
+ }
+ searchStart = end;
+ } while (start >= 0);
+
+ // End table row and start another for each br or p
+ str = str.replace(/\s*<br>\s*/g, "</tr>\n<tr><td>");
+
+ // Add the table tags and the opening and closing tr/td tags
+ // Default table attributes should be same as those used in nsHTMLEditor::CreateElementWithDefaults()
+ // (Default width="100%" is used in EdInsertTable.js)
+ str =
+ '<table border="1" width="100%" cellpadding="2" cellspacing="2">\n<tr><td>' +
+ str +
+ "</tr>\n</table>\n";
+
+ editor.beginTransaction();
+
+ // Delete the selection -- makes it easier to find where table will insert
+ var nodeBeforeTable = null;
+ var nodeAfterTable = null;
+ try {
+ editor.deleteSelection(editor.eNone, editor.eStrip);
+
+ var anchorNodeBeforeInsert = editor.selection.anchorNode;
+ var offset = editor.selection.anchorOffset;
+ if (anchorNodeBeforeInsert.nodeType == Node.TEXT_NODE) {
+ // Text was split. Table should be right after the first or before
+ nodeBeforeTable = anchorNodeBeforeInsert.previousSibling;
+ nodeAfterTable = anchorNodeBeforeInsert;
+ } else {
+ // Table should be inserted right after node pointed to by selection
+ if (offset > 0) {
+ nodeBeforeTable = anchorNodeBeforeInsert.childNodes.item(offset - 1);
+ }
+
+ nodeAfterTable = anchorNodeBeforeInsert.childNodes.item(offset);
+ }
+
+ editor.insertHTML(str);
+ } catch (e) {}
+
+ var table = null;
+ if (nodeAfterTable) {
+ var previous = nodeAfterTable.previousSibling;
+ if (previous && previous.nodeName.toLowerCase() == "table") {
+ table = previous;
+ }
+ }
+ if (!table && nodeBeforeTable) {
+ var next = nodeBeforeTable.nextSibling;
+ if (next && next.nodeName.toLowerCase() == "table") {
+ table = next;
+ }
+ }
+
+ if (table) {
+ // Fixup table only if pref is set
+ var firstRow;
+ try {
+ if (Services.prefs.getBoolPref("editor.table.maintain_structure")) {
+ editor.normalizeTable(table);
+ }
+
+ firstRow = editor.getFirstRow(table);
+ } catch (e) {}
+
+ // Put caret in first cell
+ if (firstRow) {
+ var node2 = firstRow.firstChild;
+ do {
+ if (
+ node2.nodeName.toLowerCase() == "td" ||
+ node2.nodeName.toLowerCase() == "th"
+ ) {
+ try {
+ editor.selection.collapse(node2, 0);
+ } catch (e) {}
+ break;
+ }
+ node2 = node2.nextSibling;
+ } while (node2);
+ }
+ }
+
+ editor.endTransaction();
+
+ // Save persisted attributes
+ gDialog.sepRadioGroup.setAttribute("index", gIndex);
+ if (gIndex == gOtherIndex) {
+ gDialog.sepRadioGroup.setAttribute("character", sepCharacter);
+ }
+
+ SaveWindowLocation();
+}
+/* eslint-enable complexity */
diff --git a/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml b/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml
new file mode 100644
index 0000000000..6f2d9ad5b1
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml
@@ -0,0 +1,86 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdConvertToTable.dtd">
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="Startup()"
+ lightweightthemes="true"
+ style="min-width: 20em"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <!--- Element-specific methods -->
+ <script src="chrome://messenger/content/messengercompose/EdConvertToTable.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+ <description class="wrap" flex="1">&instructions1.label;</description>
+ <description class="wrap" flex="1">&instructions2.label;</description>
+ <radiogroup
+ id="SepRadioGroup"
+ persist="index character"
+ index="0"
+ character=""
+ >
+ <radio
+ id="comma"
+ label="&commaRadio.label;"
+ oncommand="SelectCharacter('0');"
+ />
+ <radio
+ id="space"
+ label="&spaceRadio.label;"
+ oncommand="SelectCharacter('1');"
+ />
+ <hbox>
+ <spacer class="radio-spacer" />
+ <checkbox
+ id="CollapseSpaces"
+ label="&collapseSpaces.label;"
+ checked="true"
+ persist="checked"
+ tooltiptext="&collapseSpaces.tooltip;"
+ />
+ </hbox>
+ <hbox align="center">
+ <radio
+ id="other"
+ label="&otherRadio.label;"
+ oncommand="SelectCharacter('2');"
+ />
+ <html:input
+ id="SepCharacterInput"
+ type="text"
+ aria-labelledby="other"
+ class="narrow input-inline"
+ oninput="InputSepCharacter()"
+ />
+ </hbox>
+ </radiogroup>
+ <spacer class="spacer" />
+ <checkbox
+ id="DeleteSepCharacter"
+ label="&deleteCharCheck.label;"
+ persist="checked"
+ />
+ <spacer class="spacer" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdDialogCommon.js b/comm/mail/components/compose/content/dialogs/EdDialogCommon.js
new file mode 100644
index 0000000000..ce377e4bbf
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdDialogCommon.js
@@ -0,0 +1,679 @@
+/* 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/. */
+
+// Each editor window must include this file
+
+/* import-globals-from ../editorUtilities.js */
+/* globals InitDialog, ChangeLinkLocation, ValidateData */
+
+// Object to attach commonly-used widgets (all dialogs should use this)
+var gDialog = {};
+
+var gHaveDocumentUrl = false;
+var gValidationError = false;
+
+// Use for 'defaultIndex' param in InitPixelOrPercentMenulist
+const gPixel = 0;
+const gPercent = 1;
+
+const gMaxPixels = 100000; // Used for image size, borders, spacing, and padding
+// Gecko code uses 1000 for maximum rowspan, colspan
+// Also, editing performance is really bad above this
+const gMaxRows = 1000;
+const gMaxColumns = 1000;
+const gMaxTableSize = 1000000; // Width or height of table or cells
+
+// A XUL element with id="location" for managing
+// dialog location relative to parent window
+var gLocation;
+
+// The element being edited - so AdvancedEdit can have access to it
+var globalElement;
+
+/* Validate contents of an input field
+ *
+ * inputWidget The 'input' element for the the attribute's value
+ * listWidget The 'menulist' XUL element for choosing "pixel" or "percent"
+ * May be null when no pixel/percent is used.
+ * minVal minimum allowed for input widget's value
+ * maxVal maximum allowed for input widget's value
+ * (when "listWidget" is used, maxVal is used for "pixel" maximum,
+ * 100% is assumed if "percent" is the user's choice)
+ * element The DOM element that we set the attribute on. May be null.
+ * attName Name of the attribute to set. May be null or ignored if "element" is null
+ * mustHaveValue If true, error dialog is displayed if "value" is empty string
+ *
+ * This calls "ValidateNumberRange()", which puts up an error dialog to inform the user.
+ * If error, we also:
+ * Shift focus and select contents of the inputWidget,
+ * Switch to appropriate panel of tabbed dialog if user implements "SwitchToValidate()",
+ * and/or will expand the dialog to full size if "More / Fewer" feature is implemented
+ *
+ * Returns the "value" as a string, or "" if error or input contents are empty
+ * The global "gValidationError" variable is set true if error was found
+ */
+function ValidateNumber(
+ inputWidget,
+ listWidget,
+ minVal,
+ maxVal,
+ element,
+ attName,
+ mustHaveValue
+) {
+ if (!inputWidget) {
+ gValidationError = true;
+ return "";
+ }
+
+ // Global error return value
+ gValidationError = false;
+ var maxLimit = maxVal;
+ var isPercent = false;
+
+ var numString = TrimString(inputWidget.value);
+ if (numString || mustHaveValue) {
+ if (listWidget) {
+ isPercent = listWidget.selectedIndex == 1;
+ }
+ if (isPercent) {
+ maxLimit = 100;
+ }
+
+ // This method puts up the error message
+ numString = ValidateNumberRange(numString, minVal, maxLimit, mustHaveValue);
+ if (!numString) {
+ // Switch to appropriate panel for error reporting
+ SwitchToValidatePanel();
+
+ // Error - shift to offending input widget
+ SetTextboxFocus(inputWidget);
+ gValidationError = true;
+ } else {
+ if (isPercent) {
+ numString += "%";
+ }
+ if (element) {
+ GetCurrentEditor().setAttributeOrEquivalent(
+ element,
+ attName,
+ numString,
+ true
+ );
+ }
+ }
+ } else if (element) {
+ GetCurrentEditor().removeAttributeOrEquivalent(element, attName, true);
+ }
+ return numString;
+}
+
+/* Validate contents of an input field
+ *
+ * value number to validate
+ * minVal minimum allowed for input widget's value
+ * maxVal maximum allowed for input widget's value
+ * (when "listWidget" is used, maxVal is used for "pixel" maximum,
+ * 100% is assumed if "percent" is the user's choice)
+ * mustHaveValue If true, error dialog is displayed if "value" is empty string
+ *
+ * If inputWidget's value is outside of range, or is empty when "mustHaveValue" = true,
+ * an error dialog is popuped up to inform the user. The focus is shifted
+ * to the inputWidget.
+ *
+ * Returns the "value" as a string, or "" if error or input contents are empty
+ * The global "gValidationError" variable is set true if error was found
+ */
+function ValidateNumberRange(value, minValue, maxValue, mustHaveValue) {
+ // Initialize global error flag
+ gValidationError = false;
+ value = TrimString(String(value));
+
+ // We don't show error for empty string unless caller wants to
+ if (!value && !mustHaveValue) {
+ return "";
+ }
+
+ var numberStr = "";
+
+ if (value.length > 0) {
+ // Extract just numeric characters
+ var number = Number(value.replace(/\D+/g, ""));
+ if (number >= minValue && number <= maxValue) {
+ // Return string version of the number
+ return String(number);
+ }
+ numberStr = String(number);
+ }
+
+ var message = "";
+
+ if (numberStr.length > 0) {
+ // We have a number from user outside of allowed range
+ message = GetString("ValidateRangeMsg");
+ message = message.replace(/%n%/, numberStr);
+ message += "\n ";
+ }
+ message += GetString("ValidateNumberMsg");
+
+ // Replace variable placeholders in message with number values
+ message = message.replace(/%min%/, minValue).replace(/%max%/, maxValue);
+ ShowInputErrorMessage(message);
+
+ // Return an empty string to indicate error
+ gValidationError = true;
+ return "";
+}
+
+function SetTextboxFocusById(id) {
+ SetTextboxFocus(document.getElementById(id));
+}
+
+function SetTextboxFocus(input) {
+ if (input) {
+ input.focus();
+ }
+}
+
+function ShowInputErrorMessage(message) {
+ Services.prompt.alert(window, GetString("InputError"), message);
+ window.focus();
+}
+
+// Get the text appropriate to parent container
+// to determine what a "%" value is referring to.
+// elementForAtt is element we are actually setting attributes on
+// (a temporary copy of element in the doc to allow canceling),
+// but elementInDoc is needed to find parent context in document
+function GetAppropriatePercentString(elementForAtt, elementInDoc) {
+ var editor = GetCurrentEditor();
+ try {
+ var name = elementForAtt.nodeName.toLowerCase();
+ if (name == "td" || name == "th") {
+ return GetString("PercentOfTable");
+ }
+
+ // Check if element is within a table cell
+ if (editor.getElementOrParentByTagName("td", elementInDoc)) {
+ return GetString("PercentOfCell");
+ }
+ return GetString("PercentOfWindow");
+ } catch (e) {
+ return "";
+ }
+}
+
+function ClearListbox(listbox) {
+ if (listbox) {
+ listbox.clearSelection();
+ while (listbox.hasChildNodes()) {
+ listbox.lastChild.remove();
+ }
+ }
+}
+
+function forceInteger(elementID) {
+ var editField = document.getElementById(elementID);
+ if (!editField) {
+ return;
+ }
+
+ var stringIn = editField.value;
+ if (stringIn && stringIn.length > 0) {
+ // Strip out all nonnumeric characters
+ stringIn = stringIn.replace(/\D+/g, "");
+ if (!stringIn) {
+ stringIn = "";
+ }
+
+ // Write back only if changed
+ if (stringIn != editField.value) {
+ editField.value = stringIn;
+ }
+ }
+}
+
+function InitPixelOrPercentMenulist(
+ elementForAtt,
+ elementInDoc,
+ attribute,
+ menulistID,
+ defaultIndex
+) {
+ if (!defaultIndex) {
+ defaultIndex = gPixel;
+ }
+
+ // var size = elementForAtt.getAttribute(attribute);
+ var size = GetHTMLOrCSSStyleValue(elementForAtt, attribute, attribute);
+ var menulist = document.getElementById(menulistID);
+ var pixelItem;
+ var percentItem;
+
+ if (!menulist) {
+ dump("NO MENULIST found for ID=" + menulistID + "\n");
+ return size;
+ }
+
+ menulist.removeAllItems();
+ pixelItem = menulist.appendItem(GetString("Pixels"));
+
+ if (!pixelItem) {
+ return 0;
+ }
+
+ percentItem = menulist.appendItem(
+ GetAppropriatePercentString(elementForAtt, elementInDoc)
+ );
+ if (size && size.length > 0) {
+ // Search for a "%" or "px"
+ if (size.includes("%")) {
+ // Strip out the %
+ size = size.substr(0, size.indexOf("%"));
+ if (percentItem) {
+ menulist.selectedItem = percentItem;
+ }
+ } else {
+ if (size.includes("px")) {
+ // Strip out the px
+ size = size.substr(0, size.indexOf("px"));
+ }
+ menulist.selectedItem = pixelItem;
+ }
+ } else {
+ menulist.selectedIndex = defaultIndex;
+ }
+
+ return size;
+}
+
+function onAdvancedEdit() {
+ // First validate data from widgets in the "simpler" property dialog
+ if (ValidateData()) {
+ // Set true if OK is clicked in the Advanced Edit dialog
+ window.AdvancedEditOK = false;
+ // Open the AdvancedEdit dialog, passing in the element to be edited
+ // (the copy named "globalElement")
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable=yes",
+ "",
+ globalElement
+ );
+ window.focus();
+ if (window.AdvancedEditOK) {
+ // Copy edited attributes to the dialog widgets:
+ InitDialog();
+ }
+ }
+}
+
+function getColor(ColorPickerID) {
+ var colorPicker = document.getElementById(ColorPickerID);
+ var color;
+ if (colorPicker) {
+ // Extract color from colorPicker and assign to colorWell.
+ color = colorPicker.getAttribute("color");
+ if (color && color == "") {
+ return null;
+ }
+ // Clear color so next if it's called again before
+ // color picker is actually used, we dedect the "don't set color" state
+ colorPicker.setAttribute("color", "");
+ }
+
+ return color;
+}
+
+function setColorWell(ColorWellID, color) {
+ var colorWell = document.getElementById(ColorWellID);
+ if (colorWell) {
+ if (!color || color == "") {
+ // Don't set color (use default)
+ // Trigger change to not show color swatch
+ colorWell.setAttribute("default", "true");
+ // Style in CSS sets "background-color",
+ // but color won't clear unless we do this:
+ colorWell.removeAttribute("style");
+ } else {
+ colorWell.removeAttribute("default");
+ // Use setAttribute so colorwell can be a XUL element, such as button
+ colorWell.setAttribute("style", "background-color:" + color);
+ }
+ }
+}
+
+function SwitchToValidatePanel() {
+ // no default implementation
+ // Only EdTableProps.js currently implements this
+}
+
+/**
+ * @returns {Promise} URL spec of the file chosen, or null
+ */
+function GetLocalFileURL(filterType) {
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ var fileType = "html";
+
+ if (filterType == "img") {
+ fp.init(window, GetString("SelectImageFile"), Ci.nsIFilePicker.modeOpen);
+ fp.appendFilters(Ci.nsIFilePicker.filterImages);
+ fileType = "image";
+ } else if (filterType.startsWith("html")) {
+ // Current usage of this is in Link dialog,
+ // where we always want HTML first
+ fp.init(window, GetString("OpenHTMLFile"), Ci.nsIFilePicker.modeOpen);
+
+ // When loading into Composer, direct user to prefer HTML files and text files,
+ // so we call separately to control the order of the filter list
+ fp.appendFilters(Ci.nsIFilePicker.filterHTML);
+ fp.appendFilters(Ci.nsIFilePicker.filterText);
+
+ // Link dialog also allows linking to images
+ if (filterType.includes("img", 1)) {
+ fp.appendFilters(Ci.nsIFilePicker.filterImages);
+ }
+ }
+ // Default or last filter is "All Files"
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ // set the file picker's current directory to last-opened location saved in prefs
+ SetFilePickerDirectory(fp, fileType);
+
+ return new Promise(resolve => {
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ resolve(null);
+ return;
+ }
+ SaveFilePickerDirectory(fp, fileType);
+ resolve(fp.fileURL.spec);
+ });
+ });
+}
+
+function SetWindowLocation() {
+ gLocation = document.getElementById("location");
+ if (gLocation) {
+ const screenX = Math.max(
+ 0,
+ Math.min(
+ window.opener.screenX + Number(gLocation.getAttribute("offsetX")),
+ screen.availWidth - window.outerWidth
+ )
+ );
+ const screenY = Math.max(
+ 0,
+ Math.min(
+ window.opener.screenY + Number(gLocation.getAttribute("offsetY")),
+ screen.availHeight - window.outerHeight
+ )
+ );
+ window.moveTo(screenX, screenY);
+ }
+}
+
+function SaveWindowLocation() {
+ if (gLocation) {
+ gLocation.setAttribute("offsetX", window.screenX - window.opener.screenX);
+ gLocation.setAttribute("offsetY", window.screenY - window.opener.screenY);
+ }
+}
+
+function onCancel() {
+ SaveWindowLocation();
+}
+
+function SetRelativeCheckbox(checkbox) {
+ if (!checkbox) {
+ checkbox = document.getElementById("MakeRelativeCheckbox");
+ if (!checkbox) {
+ return;
+ }
+ }
+
+ var editor = GetCurrentEditor();
+ // Mail never allows relative URLs, so hide the checkbox
+ if (editor && editor.flags & Ci.nsIEditor.eEditorMailMask) {
+ checkbox.collapsed = true;
+ return;
+ }
+
+ var input = document.getElementById(checkbox.getAttribute("for"));
+ if (!input) {
+ return;
+ }
+
+ var url = TrimString(input.value);
+ var urlScheme = GetScheme(url);
+
+ // Check it if url is relative (no scheme).
+ checkbox.checked = url.length > 0 && !urlScheme;
+
+ // Now do checkbox enabling:
+ var enable = false;
+
+ var docUrl = GetDocumentBaseUrl();
+ var docScheme = GetScheme(docUrl);
+
+ if (url && docUrl && docScheme) {
+ if (urlScheme) {
+ // Url is absolute
+ // If we can make a relative URL, then enable must be true!
+ // (this lets the smarts of MakeRelativeUrl do all the hard work)
+ enable = GetScheme(MakeRelativeUrl(url)).length == 0;
+ } else if (url[0] == "#") {
+ // Url is relative
+ // Check if url is a named anchor
+ // but document doesn't have a filename
+ // (it's probably "index.html" or "index.htm",
+ // but we don't want to allow a malformed URL)
+ var docFilename = GetFilename(docUrl);
+ enable = docFilename.length > 0;
+ } else {
+ // Any other url is assumed
+ // to be ok to try to make absolute
+ enable = true;
+ }
+ }
+
+ SetElementEnabled(checkbox, enable);
+}
+
+// oncommand handler for the Relativize checkbox in EditorOverlay.xhtml
+function MakeInputValueRelativeOrAbsolute(checkbox) {
+ var input = document.getElementById(checkbox.getAttribute("for"));
+ if (!input) {
+ return;
+ }
+
+ var docUrl = GetDocumentBaseUrl();
+ if (!docUrl) {
+ // Checkbox should be disabled if not saved,
+ // but keep this error message in case we change that
+ Services.prompt.alert(window, "", GetString("SaveToUseRelativeUrl"));
+ window.focus();
+ } else {
+ // Note that "checked" is opposite of its last state,
+ // which determines what we want to do here
+ if (checkbox.checked) {
+ input.value = MakeRelativeUrl(input.value);
+ } else {
+ input.value = MakeAbsoluteUrl(input.value);
+ }
+
+ // Reset checkbox to reflect url state
+ SetRelativeCheckbox(checkbox);
+ }
+}
+
+var IsBlockParent = [
+ "applet",
+ "blockquote",
+ "body",
+ "center",
+ "dd",
+ "div",
+ "form",
+ "li",
+ "noscript",
+ "object",
+ "td",
+ "th",
+];
+
+var NotAnInlineParent = [
+ "col",
+ "colgroup",
+ "dl",
+ "dir",
+ "menu",
+ "ol",
+ "table",
+ "tbody",
+ "tfoot",
+ "thead",
+ "tr",
+ "ul",
+];
+
+function FillLinkMenulist(linkMenulist, headingsArray) {
+ var editor = GetCurrentEditor();
+ try {
+ var treeWalker = editor.document.createTreeWalker(
+ editor.document,
+ 1,
+ null,
+ true
+ );
+ var headingList = [];
+ var anchorList = []; // for sorting
+ var anchorMap = {}; // for weeding out duplicates and making heading anchors unique
+ var anchor;
+ var i;
+ for (
+ var element = treeWalker.nextNode();
+ element;
+ element = treeWalker.nextNode()
+ ) {
+ // grab headings
+ // Skip headings that already have a named anchor as their first child
+ // (this may miss nearby anchors, but at least we don't insert another
+ // under the same heading)
+ if (
+ HTMLHeadingElement.isInstance(element) &&
+ element.textContent &&
+ !(
+ HTMLAnchorElement.isInstance(element.firstChild) &&
+ element.firstChild.name
+ )
+ ) {
+ headingList.push(element);
+ }
+
+ // grab named anchors
+ if (HTMLAnchorElement.isInstance(element) && element.name) {
+ anchor = "#" + element.name;
+ if (!(anchor in anchorMap)) {
+ anchorList.push({ anchor, sortkey: anchor.toLowerCase() });
+ anchorMap[anchor] = true;
+ }
+ }
+
+ // grab IDs
+ if (element.id) {
+ anchor = "#" + element.id;
+ if (!(anchor in anchorMap)) {
+ anchorList.push({ anchor, sortkey: anchor.toLowerCase() });
+ anchorMap[anchor] = true;
+ }
+ }
+ }
+ // add anchor for headings
+ for (i = 0; i < headingList.length; i++) {
+ var heading = headingList[i];
+
+ // Use just first 40 characters, don't add "...",
+ // and replace whitespace with "_" and strip non-word characters
+ anchor =
+ "#" +
+ ConvertToCDATAString(
+ TruncateStringAtWordEnd(heading.textContent, 40, false)
+ );
+
+ // Append "_" to any name already in the list
+ while (anchor in anchorMap) {
+ anchor += "_";
+ }
+ anchorList.push({ anchor, sortkey: anchor.toLowerCase() });
+ anchorMap[anchor] = true;
+
+ // Save nodes in an array so we can create anchor node under it later
+ headingsArray[anchor] = heading;
+ }
+ let menuItems = [];
+ if (anchorList.length) {
+ // case insensitive sort
+ anchorList.sort((a, b) => {
+ if (a.sortkey < b.sortkey) {
+ return -1;
+ }
+ if (a.sortkey > b.sortkey) {
+ return 1;
+ }
+ return 0;
+ });
+ for (i = 0; i < anchorList.length; i++) {
+ menuItems.push(createMenuItem(anchorList[i].anchor));
+ }
+ } else {
+ // Don't bother with named anchors in Mail.
+ if (editor && editor.flags & Ci.nsIEditor.eEditorMailMask) {
+ linkMenulist.removeAttribute("enablehistory");
+ return;
+ }
+ let item = createMenuItem(GetString("NoNamedAnchorsOrHeadings"));
+ item.setAttribute("disabled", "true");
+ menuItems.push(item);
+ }
+ window.addEventListener("contextmenu", event => {
+ if (document.getElementById("datalist-menuseparator")) {
+ return;
+ }
+ let menuseparator = document.createXULElement("menuseparator");
+ menuseparator.setAttribute("id", "datalist-menuseparator");
+ document.getElementById("textbox-contextmenu").appendChild(menuseparator);
+ for (let menuitem of menuItems) {
+ document.getElementById("textbox-contextmenu").appendChild(menuitem);
+ }
+ });
+ } catch (e) {}
+}
+
+function createMenuItem(label) {
+ var menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", label);
+ menuitem.addEventListener("click", event => {
+ gDialog.hrefInput.value = label;
+ ChangeLinkLocation();
+ });
+ return menuitem;
+}
+
+// Shared by Image and Link dialogs for the "Choose" button for links
+function chooseLinkFile() {
+ GetLocalFileURL("html, img").then(fileURL => {
+ // Always try to relativize local file URLs
+ if (gHaveDocumentUrl) {
+ fileURL = MakeRelativeUrl(fileURL);
+ }
+
+ gDialog.hrefInput.value = fileURL;
+
+ // Do stuff specific to a particular dialog
+ // (This is defined separately in Image and Link dialogs)
+ ChangeLinkLocation();
+ });
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdDictionary.js b/comm/mail/components/compose/content/dialogs/EdDictionary.js
new file mode 100644
index 0000000000..a79a01469c
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdDictionary.js
@@ -0,0 +1,138 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gSpellChecker;
+var gWordToAdd;
+
+function Startup() {
+ if (!GetCurrentEditor()) {
+ window.close();
+ return;
+ }
+ // Get the SpellChecker shell
+ if ("gSpellChecker" in window.opener && window.opener.gSpellChecker) {
+ gSpellChecker = window.opener.gSpellChecker;
+ }
+
+ if (!gSpellChecker) {
+ dump("SpellChecker not found!!!\n");
+ window.close();
+ return;
+ }
+ // The word to add word is passed as the 2nd extra parameter in window.openDialog()
+ gWordToAdd = window.arguments[1];
+
+ gDialog.WordInput = document.getElementById("WordInput");
+ gDialog.DictionaryList = document.getElementById("DictionaryList");
+
+ gDialog.WordInput.value = gWordToAdd;
+ FillDictionaryList();
+
+ // Select the supplied word if it is already in the list
+ SelectWordToAddInList();
+ SetTextboxFocus(gDialog.WordInput);
+}
+
+function ValidateWordToAdd() {
+ gWordToAdd = TrimString(gDialog.WordInput.value);
+ if (gWordToAdd.length > 0) {
+ return true;
+ }
+ return false;
+}
+
+function SelectWordToAddInList() {
+ for (var i = 0; i < gDialog.DictionaryList.getRowCount(); i++) {
+ var wordInList = gDialog.DictionaryList.getItemAtIndex(i);
+ if (wordInList && gWordToAdd == wordInList.label) {
+ gDialog.DictionaryList.selectedIndex = i;
+ break;
+ }
+ }
+}
+
+function AddWord() {
+ if (ValidateWordToAdd()) {
+ try {
+ gSpellChecker.AddWordToDictionary(gWordToAdd);
+ } catch (e) {
+ dump(
+ "Exception occurred in gSpellChecker.AddWordToDictionary\nWord to add probably already existed\n"
+ );
+ }
+
+ // Rebuild the dialog list
+ FillDictionaryList();
+
+ SelectWordToAddInList();
+ gDialog.WordInput.value = "";
+ }
+}
+
+function RemoveWord() {
+ var selIndex = gDialog.DictionaryList.selectedIndex;
+ if (selIndex >= 0) {
+ var word = gDialog.DictionaryList.selectedItem.label;
+
+ // Remove word from list
+ gDialog.DictionaryList.selectedItem.remove();
+
+ // Remove from dictionary
+ try {
+ // Not working: BUG 43348
+ gSpellChecker.RemoveWordFromDictionary(word);
+ } catch (e) {
+ dump("Failed to remove word from dictionary\n");
+ }
+
+ ResetSelectedItem(selIndex);
+ }
+}
+
+function FillDictionaryList() {
+ var selIndex = gDialog.DictionaryList.selectedIndex;
+
+ // Clear the current contents of the list
+ ClearListbox(gDialog.DictionaryList);
+
+ // Get the list from the spell checker
+ gSpellChecker.GetPersonalDictionary();
+
+ var haveList = false;
+
+ // Get words until an empty string is returned
+ do {
+ var word = gSpellChecker.GetPersonalDictionaryWord();
+ if (word != "") {
+ gDialog.DictionaryList.appendItem(word, "");
+ haveList = true;
+ }
+ } while (word != "");
+
+ // XXX: BUG 74467: If list is empty, it doesn't layout to full height correctly
+ // (ignores "rows" attribute) (bug is latered, so we are fixing here for now)
+ if (!haveList) {
+ gDialog.DictionaryList.appendItem("", "");
+ }
+
+ ResetSelectedItem(selIndex);
+}
+
+function ResetSelectedItem(index) {
+ var lastIndex = gDialog.DictionaryList.getRowCount() - 1;
+ if (index > lastIndex) {
+ index = lastIndex;
+ }
+
+ // If we didn't have a selected item,
+ // set it to the first item
+ if (index == -1 && lastIndex >= 0) {
+ index = 0;
+ }
+
+ gDialog.DictionaryList.selectedIndex = index;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml b/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml
new file mode 100644
index 0000000000..c5c33212a9
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml
@@ -0,0 +1,88 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorPersonalDictionary.dtd">
+<window
+ id="dictionaryDlg"
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog
+ buttonlabelaccept="&CloseButton.label;"
+ buttonaccesskeyaccept="&CloseButton.accessKey;"
+ buttons="accept"
+ >
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDictionary.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <hbox flex="1">
+ <div xmlns="http://www.w3.org/1999/xhtml" class="grid-two-column">
+ <div class="flex-items-center grid-item-span-row">
+ <xul:label
+ id="WordInputLabel"
+ value="&wordEditField.label;"
+ control="WordInput"
+ accesskey="&wordEditField.accessKey;"
+ />
+ </div>
+ <div>
+ <input
+ id="WordInput"
+ type="text"
+ style="width: 14.5em"
+ aria-labelledby="WordInputLabel"
+ />
+ </div>
+ <div>
+ <xul:button
+ id="AddWord"
+ oncommand="AddWord()"
+ label="&AddButton.label;"
+ accesskey="&AddButton.accessKey;"
+ />
+ </div>
+ <div class="flex-items-center grid-item-span-row">
+ <xul:label
+ value="&DictionaryList.label;"
+ control="DictionaryList"
+ accesskey="&DictionaryList.accessKey;"
+ />
+ </div>
+ <div>
+ <xul:richlistbox
+ id="DictionaryList"
+ style="width: 15em; height: 10em"
+ />
+ </div>
+ <div>
+ <xul:button
+ id="RemoveWord"
+ oncommand="RemoveWord()"
+ label="&RemoveButton.label;"
+ accesskey="&RemoveButton.accessKey;"
+ />
+ </div>
+ </div>
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdHLineProps.js b/comm/mail/components/compose/content/dialogs/EdHLineProps.js
new file mode 100644
index 0000000000..4a5393d1dc
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdHLineProps.js
@@ -0,0 +1,227 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var tagName = "hr";
+var gHLineElement;
+var width;
+var height;
+var align;
+var shading;
+const gMaxHRSize = 1000; // This is hard-coded in nsHTMLHRElement::StringToAttribute()
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+ try {
+ // Get the selected horizontal line
+ gHLineElement = editor.getSelectedElement(tagName);
+ } catch (e) {}
+
+ if (!gHLineElement) {
+ // We should never be here if not editing an existing HLine
+ window.close();
+ return;
+ }
+ gDialog.heightInput = document.getElementById("height");
+ gDialog.widthInput = document.getElementById("width");
+ gDialog.leftAlign = document.getElementById("leftAlign");
+ gDialog.centerAlign = document.getElementById("centerAlign");
+ gDialog.rightAlign = document.getElementById("rightAlign");
+ gDialog.alignGroup = gDialog.rightAlign.radioGroup;
+ gDialog.shading = document.getElementById("3dShading");
+ gDialog.pixelOrPercentMenulist = document.getElementById(
+ "pixelOrPercentMenulist"
+ );
+
+ // Make a copy to use for AdvancedEdit and onSaveDefault
+ globalElement = gHLineElement.cloneNode(false);
+
+ // Initialize control values based on existing attributes
+ InitDialog();
+
+ // SET FOCUS TO FIRST CONTROL
+ SetTextboxFocus(gDialog.widthInput);
+
+ // Resize window
+ window.sizeToContent();
+
+ SetWindowLocation();
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitDialog() {
+ // Just to be confusing, "size" is used instead of height because it does
+ // not accept % values, only pixels
+ var height = GetHTMLOrCSSStyleValue(globalElement, "size", "height");
+ if (height.includes("px")) {
+ height = height.substr(0, height.indexOf("px"));
+ }
+ if (!height) {
+ height = 2; // Default value
+ }
+
+ // We will use "height" here and in UI
+ gDialog.heightInput.value = height;
+
+ // Get the width attribute of the element, stripping out "%"
+ // This sets contents of menulist (adds pixel and percent menuitems elements)
+ gDialog.widthInput.value = InitPixelOrPercentMenulist(
+ globalElement,
+ gHLineElement,
+ "width",
+ "pixelOrPercentMenulist"
+ );
+
+ var marginLeft = GetHTMLOrCSSStyleValue(
+ globalElement,
+ "align",
+ "margin-left"
+ ).toLowerCase();
+ var marginRight = GetHTMLOrCSSStyleValue(
+ globalElement,
+ "align",
+ "margin-right"
+ ).toLowerCase();
+ align = marginLeft + " " + marginRight;
+ gDialog.leftAlign.checked = align == "left left" || align == "0px auto";
+ gDialog.centerAlign.checked =
+ align == "center center" || align == "auto auto" || align == " ";
+ gDialog.rightAlign.checked = align == "right right" || align == "auto 0px";
+
+ if (gDialog.centerAlign.checked) {
+ gDialog.alignGroup.selectedItem = gDialog.centerAlign;
+ } else if (gDialog.rightAlign.checked) {
+ gDialog.alignGroup.selectedItem = gDialog.rightAlign;
+ } else {
+ gDialog.alignGroup.selectedItem = gDialog.leftAlign;
+ }
+
+ gDialog.shading.checked = !globalElement.hasAttribute("noshade");
+}
+
+function onSaveDefault() {
+ // "false" means set attributes on the globalElement,
+ // not the real element being edited
+ if (ValidateData()) {
+ var alignInt;
+ if (align == "left") {
+ alignInt = 0;
+ } else if (align == "right") {
+ alignInt = 2;
+ } else {
+ alignInt = 1;
+ }
+ Services.prefs.setIntPref("editor.hrule.align", alignInt);
+
+ var percent;
+ var widthInt;
+ var heightInt;
+
+ if (width) {
+ if (width.includes("%")) {
+ percent = true;
+ widthInt = Number(width.substr(0, width.indexOf("%")));
+ } else {
+ percent = false;
+ widthInt = Number(width);
+ }
+ } else {
+ percent = true;
+ widthInt = Number(100);
+ }
+
+ heightInt = height ? Number(height) : 2;
+
+ Services.prefs.setIntPref("editor.hrule.width", widthInt);
+ Services.prefs.setBoolPref("editor.hrule.width_percent", percent);
+ Services.prefs.setIntPref("editor.hrule.height", heightInt);
+ Services.prefs.setBoolPref("editor.hrule.shading", shading);
+
+ // Write the prefs out NOW!
+ Services.prefs.savePrefFile(null);
+ }
+}
+
+// Get and validate data from widgets.
+// Set attributes on globalElement so they can be accessed by AdvancedEdit()
+function ValidateData() {
+ // Height is always pixels
+ height = ValidateNumber(
+ gDialog.heightInput,
+ null,
+ 1,
+ gMaxHRSize,
+ globalElement,
+ "size",
+ false
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ width = ValidateNumber(
+ gDialog.widthInput,
+ gDialog.pixelOrPercentMenulist,
+ 1,
+ gMaxPixels,
+ globalElement,
+ "width",
+ false
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ align = "left";
+ if (gDialog.centerAlign.selected) {
+ // Don't write out default attribute
+ align = "";
+ } else if (gDialog.rightAlign.selected) {
+ align = "right";
+ }
+ if (align) {
+ globalElement.setAttribute("align", align);
+ } else {
+ try {
+ GetCurrentEditor().removeAttributeOrEquivalent(
+ globalElement,
+ "align",
+ true
+ );
+ } catch (e) {}
+ }
+
+ if (gDialog.shading.checked) {
+ shading = true;
+ globalElement.removeAttribute("noshade");
+ } else {
+ shading = false;
+ globalElement.setAttribute("noshade", "noshade");
+ }
+ return true;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ // Copy attributes from the globalElement to the document element
+ try {
+ GetCurrentEditor().cloneAttributes(gHLineElement, globalElement);
+ } catch (e) {}
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml b/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml
new file mode 100644
index 0000000000..21fa52147c
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml
@@ -0,0 +1,131 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edHLineProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorHLineProperties.dtd">
+%edHLineProperties;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <!--- Element-specific methods -->
+ <script src="chrome://messenger/content/messengercompose/EdHLineProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <html:fieldset>
+ <html:legend>&dimensionsBox.label;</html:legend>
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ id="widthLabel"
+ control="width"
+ value="&widthEditField.label;"
+ accesskey="&widthEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="width"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="widthLabel"
+ />
+ </html:td>
+ <html:td>
+ <menulist id="pixelOrPercentMenulist" />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ id="heightLabel"
+ control="height"
+ value="&heightEditField.label;"
+ accesskey="&heightEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="height"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="heightLabel"
+ />
+ </html:td>
+ <html:td>
+ <label value="&pixelsPopup.value;" />
+ </html:td>
+ </html:tr>
+ </html:table>
+ <checkbox
+ id="3dShading"
+ label="&threeDShading.label;"
+ accesskey="&threeDShading.accessKey;"
+ />
+ </html:fieldset>
+ <html:fieldset>
+ <html:legend>&alignmentBox.label;</html:legend>
+ <radiogroup id="alignmentGroup" orient="horizontal">
+ <spacer class="spacer" />
+ <radio
+ id="leftAlign"
+ label="&leftRadio.label;"
+ accesskey="&leftRadio.accessKey;"
+ />
+ <radio
+ id="centerAlign"
+ label="&centerRadio.label;"
+ accesskey="&centerRadio.accessKey;"
+ />
+ <radio
+ id="rightAlign"
+ label="&rightRadio.label;"
+ accesskey="&rightRadio.accessKey;"
+ />
+ </radiogroup>
+ </html:fieldset>
+ <spacer class="spacer" />
+ <hbox>
+ <button
+ id="SaveDefault"
+ label="&saveSettings.label;"
+ accesskey="&saveSettings.accessKey;"
+ oncommand="onSaveDefault()"
+ tooltiptext="&saveSettings.tooltip;"
+ />
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton"
+ oncommand="onAdvancedEdit();"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdImageDialog.js b/comm/mail/components/compose/content/dialogs/EdImageDialog.js
new file mode 100644
index 0000000000..91e558cd50
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdImageDialog.js
@@ -0,0 +1,639 @@
+/* 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/. */
+
+/*
+ Note: We encourage non-empty alt text for images inserted into a page.
+ When there's no alt text, we always write 'alt=""' as the attribute, since "alt" is a required attribute.
+ We allow users to not have alt text by checking a "Don't use alterate text" radio button,
+ and we don't accept spaces as valid alt text. A space used to be required to avoid the error message
+ if user didn't enter alt text, but is unnecessary now that we no longer annoy the user
+ with the error dialog if alt="" is present on an img element.
+ We trim all spaces at the beginning and end of user's alt text
+*/
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gInsertNewImage = true;
+var gDoAltTextError = false;
+var gConstrainOn = false;
+// Note used in current version, but these are set correctly
+// and could be used to reset width and height used for constrain ratio
+var gConstrainWidth = 0;
+var gConstrainHeight = 0;
+var imageElement;
+var gImageMap = 0;
+var gCanRemoveImageMap = false;
+var gRemoveImageMap = false;
+var gImageMapDisabled = false;
+var gActualWidth = "";
+var gActualHeight = "";
+var gOriginalSrc = "";
+var gTimerID;
+var gValidateTab;
+var gInsertNewIMap;
+
+// These must correspond to values in EditorDialog.css for each theme
+// (unfortunately, setting "style" attribute here doesn't work!)
+var gPreviewImageWidth = 80;
+var gPreviewImageHeight = 50;
+
+// dialog initialization code
+
+function ImageStartup() {
+ gDialog.tabBox = document.getElementById("TabBox");
+ gDialog.tabLocation = document.getElementById("imageLocationTab");
+ gDialog.tabDimensions = document.getElementById("imageDimensionsTab");
+ gDialog.tabBorder = document.getElementById("imageBorderTab");
+ gDialog.srcInput = document.getElementById("srcInput");
+ gDialog.titleInput = document.getElementById("titleInput");
+ gDialog.altTextInput = document.getElementById("altTextInput");
+ gDialog.altTextRadioGroup = document.getElementById("altTextRadioGroup");
+ gDialog.altTextRadio = document.getElementById("altTextRadio");
+ gDialog.noAltTextRadio = document.getElementById("noAltTextRadio");
+ gDialog.actualSizeRadio = document.getElementById("actualSizeRadio");
+ gDialog.constrainCheckbox = document.getElementById("constrainCheckbox");
+ gDialog.widthInput = document.getElementById("widthInput");
+ gDialog.heightInput = document.getElementById("heightInput");
+ gDialog.widthUnitsMenulist = document.getElementById("widthUnitsMenulist");
+ gDialog.heightUnitsMenulist = document.getElementById("heightUnitsMenulist");
+ gDialog.imagelrInput = document.getElementById("imageleftrightInput");
+ gDialog.imagetbInput = document.getElementById("imagetopbottomInput");
+ gDialog.border = document.getElementById("border");
+ gDialog.alignTypeSelect = document.getElementById("alignTypeSelect");
+ gDialog.PreviewWidth = document.getElementById("PreviewWidth");
+ gDialog.PreviewHeight = document.getElementById("PreviewHeight");
+ gDialog.PreviewImage = document.getElementById("preview-image");
+ gDialog.PreviewImage.addEventListener("load", PreviewImageLoaded);
+ gDialog.OkButton = document.querySelector("dialog").getButton("accept");
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitImage() {
+ // Set the controls to the image's attributes
+ var src = globalElement.getAttribute("src");
+
+ // For image insertion the 'src' attribute is null.
+ if (src) {
+ // Shorten data URIs for display.
+ shortenImageData(src, gDialog.srcInput);
+ }
+
+ // Set "Relativize" checkbox according to current URL state
+ SetRelativeCheckbox();
+
+ // Force loading of image from its source and show preview image
+ LoadPreviewImage();
+
+ gDialog.titleInput.value = globalElement.getAttribute("title");
+
+ var hasAltText = globalElement.hasAttribute("alt");
+ var altText = globalElement.getAttribute("alt");
+ gDialog.altTextInput.value = altText;
+ if (altText || (!hasAltText && globalElement.hasAttribute("src"))) {
+ gDialog.altTextRadioGroup.selectedItem = gDialog.altTextRadio;
+ } else if (hasAltText) {
+ gDialog.altTextRadioGroup.selectedItem = gDialog.noAltTextRadio;
+ }
+ SetAltTextDisabled(
+ gDialog.altTextRadioGroup.selectedItem == gDialog.noAltTextRadio
+ );
+
+ // setup the height and width widgets
+ var width = InitPixelOrPercentMenulist(
+ globalElement,
+ gInsertNewImage ? null : imageElement,
+ "width",
+ "widthUnitsMenulist",
+ gPixel
+ );
+ var height = InitPixelOrPercentMenulist(
+ globalElement,
+ gInsertNewImage ? null : imageElement,
+ "height",
+ "heightUnitsMenulist",
+ gPixel
+ );
+
+ // Set actual radio button if both set values are the same as actual
+ SetSizeWidgets(width, height);
+
+ gDialog.widthInput.value = gConstrainWidth = width || gActualWidth || "";
+ gDialog.heightInput.value = gConstrainHeight = height || gActualHeight || "";
+
+ // set spacing editfields
+ gDialog.imagelrInput.value = globalElement.getAttribute("hspace");
+ gDialog.imagetbInput.value = globalElement.getAttribute("vspace");
+
+ // dialog.border.value = globalElement.getAttribute("border");
+ var bv = GetHTMLOrCSSStyleValue(globalElement, "border", "border-top-width");
+ if (bv.includes("px")) {
+ // Strip out the px
+ bv = bv.substr(0, bv.indexOf("px"));
+ } else if (bv == "thin") {
+ bv = "1";
+ } else if (bv == "medium") {
+ bv = "3";
+ } else if (bv == "thick") {
+ bv = "5";
+ }
+ gDialog.border.value = bv;
+
+ // Get alignment setting
+ var align = globalElement.getAttribute("align");
+ if (align) {
+ align = align.toLowerCase();
+ }
+
+ switch (align) {
+ case "top":
+ case "middle":
+ case "right":
+ case "left":
+ gDialog.alignTypeSelect.value = align;
+ break;
+ default:
+ // Default or "bottom"
+ gDialog.alignTypeSelect.value = "bottom";
+ }
+
+ // Get image map for image
+ gImageMap = GetImageMap();
+
+ doOverallEnabling();
+ doDimensionEnabling();
+}
+
+function SetSizeWidgets(width, height) {
+ if (
+ !(width || height) ||
+ (gActualWidth &&
+ gActualHeight &&
+ width == gActualWidth &&
+ height == gActualHeight)
+ ) {
+ gDialog.actualSizeRadio.radioGroup.selectedItem = gDialog.actualSizeRadio;
+ }
+
+ if (!gDialog.actualSizeRadio.selected) {
+ // Decide if user's sizes are in the same ratio as actual sizes
+ if (gActualWidth && gActualHeight) {
+ if (gActualWidth > gActualHeight) {
+ gDialog.constrainCheckbox.checked =
+ Math.round((gActualHeight * width) / gActualWidth) == height;
+ } else {
+ gDialog.constrainCheckbox.checked =
+ Math.round((gActualWidth * height) / gActualHeight) == width;
+ }
+ }
+ }
+}
+
+// Disable alt text input when "Don't use alt" radio is checked
+function SetAltTextDisabled(disable) {
+ gDialog.altTextInput.disabled = disable;
+}
+
+function GetImageMap() {
+ var usemap = globalElement.getAttribute("usemap");
+ if (usemap) {
+ gCanRemoveImageMap = true;
+ let mapname = usemap.substr(1);
+ try {
+ return GetCurrentEditor().document.querySelector(
+ '[name="' + mapname + '"]'
+ );
+ } catch (e) {}
+ } else {
+ gCanRemoveImageMap = false;
+ }
+
+ return null;
+}
+
+function chooseFile() {
+ if (gTimerID) {
+ clearTimeout(gTimerID);
+ }
+
+ // Put focus into the input field
+ SetTextboxFocus(gDialog.srcInput);
+
+ GetLocalFileURL("img").then(fileURL => {
+ // Always try to relativize local file URLs
+ if (gHaveDocumentUrl) {
+ fileURL = MakeRelativeUrl(fileURL);
+ }
+
+ gDialog.srcInput.value = fileURL;
+
+ SetRelativeCheckbox();
+ doOverallEnabling();
+ LoadPreviewImage();
+ });
+}
+
+function PreviewImageLoaded() {
+ if (gDialog.PreviewImage) {
+ // Image loading has completed -- we can get actual width
+ gActualWidth = gDialog.PreviewImage.naturalWidth;
+ gActualHeight = gDialog.PreviewImage.naturalHeight;
+
+ if (gActualWidth && gActualHeight) {
+ // Use actual size or scale to fit preview if either dimension is too large
+ var width = gActualWidth;
+ var height = gActualHeight;
+ if (gActualWidth > gPreviewImageWidth) {
+ width = gPreviewImageWidth;
+ height = gActualHeight * (gPreviewImageWidth / gActualWidth);
+ }
+ if (height > gPreviewImageHeight) {
+ height = gPreviewImageHeight;
+ width = gActualWidth * (gPreviewImageHeight / gActualHeight);
+ }
+ gDialog.PreviewImage.width = width;
+ gDialog.PreviewImage.height = height;
+
+ gDialog.PreviewWidth.setAttribute("value", gActualWidth);
+ gDialog.PreviewHeight.setAttribute("value", gActualHeight);
+
+ document.getElementById("imagePreview").hidden = false;
+
+ SetSizeWidgets(gDialog.widthInput.value, gDialog.heightInput.value);
+ }
+
+ if (gDialog.actualSizeRadio.selected) {
+ SetActualSize();
+ }
+
+ window.sizeToContent();
+ }
+}
+
+function LoadPreviewImage() {
+ var imageSrc = gDialog.srcInput.value.trim();
+ if (!imageSrc) {
+ return;
+ }
+ if (isImageDataShortened(imageSrc)) {
+ imageSrc = restoredImageData(gDialog.srcInput);
+ }
+
+ try {
+ // Remove the image URL from image cache so it loads fresh
+ // (if we don't do this, loads after the first will always use image cache
+ // and we won't see image edit changes or be able to get actual width and height)
+
+ // We must have an absolute URL to preview it or remove it from the cache
+ imageSrc = MakeAbsoluteUrl(imageSrc);
+
+ if (GetScheme(imageSrc)) {
+ let uri = Services.io.newURI(imageSrc);
+ if (uri) {
+ let imgCache = Cc["@mozilla.org/image/cache;1"].getService(
+ Ci.imgICache
+ );
+
+ // This returns error if image wasn't in the cache; ignore that
+ imgCache.removeEntry(uri);
+ }
+ }
+ } catch (e) {}
+
+ gDialog.PreviewImage.addEventListener("load", PreviewImageLoaded, true);
+ gDialog.PreviewImage.src = imageSrc;
+}
+
+function SetActualSize() {
+ gDialog.widthInput.value = gActualWidth ? gActualWidth : "";
+ gDialog.widthUnitsMenulist.selectedIndex = 0;
+ gDialog.heightInput.value = gActualHeight ? gActualHeight : "";
+ gDialog.heightUnitsMenulist.selectedIndex = 0;
+ doDimensionEnabling();
+}
+
+function ChangeImageSrc() {
+ if (gTimerID) {
+ clearTimeout(gTimerID);
+ }
+
+ gTimerID = setTimeout(LoadPreviewImage, 800);
+
+ SetRelativeCheckbox();
+ doOverallEnabling();
+}
+
+function doDimensionEnabling() {
+ // Enabled unless "Actual Size" is selected
+ var enable = !gDialog.actualSizeRadio.selected;
+
+ // BUG 74145: After input field is disabled,
+ // setting it enabled causes blinking caret to appear
+ // even though focus isn't set to it.
+ SetElementEnabledById("heightInput", enable);
+ SetElementEnabledById("heightLabel", enable);
+ SetElementEnabledById("heightUnitsMenulist", enable);
+
+ SetElementEnabledById("widthInput", enable);
+ SetElementEnabledById("widthLabel", enable);
+ SetElementEnabledById("widthUnitsMenulist", enable);
+
+ var constrainEnable =
+ enable &&
+ gDialog.widthUnitsMenulist.selectedIndex == 0 &&
+ gDialog.heightUnitsMenulist.selectedIndex == 0;
+
+ SetElementEnabledById("constrainCheckbox", constrainEnable);
+}
+
+function doOverallEnabling() {
+ var enabled = TrimString(gDialog.srcInput.value) != "";
+
+ SetElementEnabled(gDialog.OkButton, enabled);
+ SetElementEnabledById("AdvancedEditButton1", enabled);
+ SetElementEnabledById("imagemapLabel", enabled);
+ SetElementEnabledById("removeImageMap", gCanRemoveImageMap);
+}
+
+function ToggleConstrain() {
+ // If just turned on, save the current width and height as basis for constrain ratio
+ // Thus clicking on/off lets user say "Use these values as aspect ration"
+ if (
+ gDialog.constrainCheckbox.checked &&
+ !gDialog.constrainCheckbox.disabled &&
+ gDialog.widthUnitsMenulist.selectedIndex == 0 &&
+ gDialog.heightUnitsMenulist.selectedIndex == 0
+ ) {
+ gConstrainWidth = Number(TrimString(gDialog.widthInput.value));
+ gConstrainHeight = Number(TrimString(gDialog.heightInput.value));
+ }
+}
+
+function constrainProportions(srcID, destID) {
+ var srcElement = document.getElementById(srcID);
+ if (!srcElement) {
+ return;
+ }
+
+ var destElement = document.getElementById(destID);
+ if (!destElement) {
+ return;
+ }
+
+ // always force an integer (whether we are constraining or not)
+ forceInteger(srcID);
+
+ if (
+ !gActualWidth ||
+ !gActualHeight ||
+ !(gDialog.constrainCheckbox.checked && !gDialog.constrainCheckbox.disabled)
+ ) {
+ return;
+ }
+
+ // double-check that neither width nor height is in percent mode; bail if so!
+ if (
+ gDialog.widthUnitsMenulist.selectedIndex != 0 ||
+ gDialog.heightUnitsMenulist.selectedIndex != 0
+ ) {
+ return;
+ }
+
+ // This always uses the actual width and height ratios
+ // which is kind of funky if you change one number without the constrain
+ // and then turn constrain on and change a number
+ // I prefer the old strategy (below) but I can see some merit to this solution
+ if (srcID == "widthInput") {
+ destElement.value = Math.round(
+ (srcElement.value * gActualHeight) / gActualWidth
+ );
+ } else {
+ destElement.value = Math.round(
+ (srcElement.value * gActualWidth) / gActualHeight
+ );
+ }
+
+ /*
+ // With this strategy, the width and height ratio
+ // can be reset to whatever the user entered.
+ if (srcID == "widthInput") {
+ destElement.value = Math.round( srcElement.value * gConstrainHeight / gConstrainWidth );
+ } else {
+ destElement.value = Math.round( srcElement.value * gConstrainWidth / gConstrainHeight );
+ }
+ */
+}
+
+function removeImageMap() {
+ gRemoveImageMap = true;
+ gCanRemoveImageMap = false;
+ SetElementEnabledById("removeImageMap", false);
+}
+
+function SwitchToValidatePanel() {
+ if (
+ gDialog.tabBox &&
+ gValidateTab &&
+ gDialog.tabBox.selectedTab != gValidateTab
+ ) {
+ gDialog.tabBox.selectedTab = gValidateTab;
+ }
+}
+
+// Get data from widgets, validate, and set for the global element
+// accessible to AdvancedEdit() [in EdDialogCommon.js]
+function ValidateImage() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ return false;
+ }
+
+ gValidateTab = gDialog.tabLocation;
+ if (!gDialog.srcInput.value) {
+ Services.prompt.alert(
+ window,
+ GetString("Alert"),
+ GetString("MissingImageError")
+ );
+ SwitchToValidatePanel();
+ gDialog.srcInput.focus();
+ return false;
+ }
+
+ // We must convert to "file:///" or "http://" format else image doesn't load!
+ let src = gDialog.srcInput.value.trim();
+
+ if (isImageDataShortened(src)) {
+ src = restoredImageData(gDialog.srcInput);
+ } else {
+ var checkbox = document.getElementById("MakeRelativeCheckbox");
+ try {
+ if (checkbox && !checkbox.checked) {
+ src = Services.uriFixup.createFixupURI(
+ src,
+ Ci.nsIURIFixup.FIXUP_FLAG_NONE
+ ).spec;
+ }
+ } catch (e) {}
+
+ globalElement.setAttribute("src", src);
+ }
+
+ let title = gDialog.titleInput.value.trim();
+ if (title) {
+ globalElement.setAttribute("title", title);
+ } else {
+ globalElement.removeAttribute("title");
+ }
+
+ // Force user to enter Alt text only if "Alternate text" radio is checked
+ // Don't allow just spaces in alt text
+ var alt = "";
+ var useAlt = gDialog.altTextRadioGroup.selectedItem == gDialog.altTextRadio;
+ if (useAlt) {
+ alt = TrimString(gDialog.altTextInput.value);
+ }
+
+ if (alt || !useAlt) {
+ globalElement.setAttribute("alt", alt);
+ } else if (!gDoAltTextError) {
+ globalElement.removeAttribute("alt");
+ } else {
+ Services.prompt.alert(window, GetString("Alert"), GetString("NoAltText"));
+ SwitchToValidatePanel();
+ gDialog.altTextInput.focus();
+ return false;
+ }
+
+ var width = "";
+ var height = "";
+
+ gValidateTab = gDialog.tabDimensions;
+ if (!gDialog.actualSizeRadio.selected) {
+ // Get user values for width and height
+ width = ValidateNumber(
+ gDialog.widthInput,
+ gDialog.widthUnitsMenulist,
+ 1,
+ gMaxPixels,
+ globalElement,
+ "width",
+ false,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ height = ValidateNumber(
+ gDialog.heightInput,
+ gDialog.heightUnitsMenulist,
+ 1,
+ gMaxPixels,
+ globalElement,
+ "height",
+ false,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+ }
+
+ // We always set the width and height attributes, even if same as actual.
+ // This speeds up layout of pages since sizes are known before image is loaded
+ if (!width) {
+ width = gActualWidth;
+ }
+ if (!height) {
+ height = gActualHeight;
+ }
+
+ // Remove existing width and height only if source changed
+ // and we couldn't obtain actual dimensions
+ var srcChanged = src != gOriginalSrc;
+ if (width) {
+ editor.setAttributeOrEquivalent(globalElement, "width", width, true);
+ } else if (srcChanged) {
+ editor.removeAttributeOrEquivalent(globalElement, "width", true);
+ }
+
+ if (height) {
+ editor.setAttributeOrEquivalent(globalElement, "height", height, true);
+ } else if (srcChanged) {
+ editor.removeAttributeOrEquivalent(globalElement, "height", true);
+ }
+
+ // spacing attributes
+ gValidateTab = gDialog.tabBorder;
+ ValidateNumber(
+ gDialog.imagelrInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalElement,
+ "hspace",
+ false,
+ true,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ ValidateNumber(
+ gDialog.imagetbInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalElement,
+ "vspace",
+ false,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ // note this is deprecated and should be converted to stylesheets
+ ValidateNumber(
+ gDialog.border,
+ null,
+ 0,
+ gMaxPixels,
+ globalElement,
+ "border",
+ false,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ // Default or setting "bottom" means don't set the attribute
+ // Note that the attributes "left" and "right" are opposite
+ // of what we use in the UI, which describes where the TEXT wraps,
+ // not the image location (which is what the HTML describes)
+ switch (gDialog.alignTypeSelect.value) {
+ case "top":
+ case "middle":
+ case "right":
+ case "left":
+ editor.setAttributeOrEquivalent(
+ globalElement,
+ "align",
+ gDialog.alignTypeSelect.value,
+ true
+ );
+ break;
+ default:
+ try {
+ editor.removeAttributeOrEquivalent(globalElement, "align", true);
+ } catch (e) {}
+ }
+
+ return true;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js b/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js
new file mode 100644
index 0000000000..9c41679c15
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js
@@ -0,0 +1,144 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gMsgCompProcessLink = false;
+var gMsgCompInputElement = null;
+var gMsgCompPrevInputValue = null;
+var gMsgCompPrevMozDoNotSendAttribute;
+var gMsgCompAttachSourceElement = null;
+
+function OnLoadDialog() {
+ gMsgCompAttachSourceElement = document.getElementById("AttachSourceToMail");
+ var editor = GetCurrentEditor();
+ if (
+ gMsgCompAttachSourceElement &&
+ editor &&
+ editor.flags & Ci.nsIEditor.eEditorMailMask
+ ) {
+ SetRelativeCheckbox = function () {
+ SetAttachCheckbox();
+ };
+ // initialize the AttachSourceToMail checkbox
+ gMsgCompAttachSourceElement.hidden = false;
+
+ switch (document.querySelector("dialog").id) {
+ case "imageDlg":
+ gMsgCompInputElement = gDialog.srcInput;
+ gMsgCompProcessLink = false;
+ break;
+ case "linkDlg":
+ gMsgCompInputElement = gDialog.hrefInput;
+ gMsgCompProcessLink = true;
+ break;
+ }
+ if (gMsgCompInputElement) {
+ SetAttachCheckbox();
+ gMsgCompPrevMozDoNotSendAttribute =
+ globalElement.getAttribute("moz-do-not-send");
+ }
+ }
+}
+addEventListener("load", OnLoadDialog, false);
+
+function OnAcceptDialog() {
+ // Auto-convert file URLs to data URLs. If we're in the link properties
+ // dialog convert only when requested - for the image dialog do it always.
+ if (
+ /^file:/i.test(gMsgCompInputElement.value.trim()) &&
+ (gMsgCompAttachSourceElement.checked || !gMsgCompProcessLink)
+ ) {
+ var dataURI = GenerateDataURL(gMsgCompInputElement.value.trim());
+ gMsgCompInputElement.value = dataURI;
+ gMsgCompAttachSourceElement.checked = true;
+ }
+ DoAttachSourceCheckbox();
+}
+document.addEventListener("dialogaccept", OnAcceptDialog, true);
+
+function SetAttachCheckbox() {
+ var resetCheckbox = false;
+ var mozDoNotSend = globalElement.getAttribute("moz-do-not-send");
+
+ // In case somebody played with the advanced property and changed the moz-do-not-send attribute
+ if (mozDoNotSend != gMsgCompPrevMozDoNotSendAttribute) {
+ gMsgCompPrevMozDoNotSendAttribute = mozDoNotSend;
+ resetCheckbox = true;
+ }
+
+ // Has the URL changed
+ if (
+ gMsgCompInputElement &&
+ gMsgCompInputElement.value != gMsgCompPrevInputValue
+ ) {
+ gMsgCompPrevInputValue = gMsgCompInputElement.value;
+ resetCheckbox = true;
+ }
+
+ if (gMsgCompInputElement && resetCheckbox) {
+ // Here is the rule about how to set the checkbox Attach Source To Message:
+ // If the attribute "moz-do-not-send" has not been set, we look at the scheme of the URL
+ // and at some preference to decide what is the best for the user.
+ // If it is set to "false", the checkbox is checked, otherwise unchecked.
+ var attach = false;
+ if (mozDoNotSend == null) {
+ // We haven't yet set the "moz-do-not-send" attribute.
+ var inputValue = gMsgCompInputElement.value.trim();
+ if (/^(file|data):/i.test(inputValue)) {
+ // For files or data URLs, default to attach them.
+ attach = true;
+ } else if (
+ !gMsgCompProcessLink && // Implies image dialogue.
+ /^https?:/i.test(inputValue)
+ ) {
+ // For images loaded via http(s) we default to the preference value.
+ attach = Services.prefs.getBoolPref("mail.compose.attach_http_images");
+ }
+ } else {
+ attach = mozDoNotSend == "false";
+ }
+
+ gMsgCompAttachSourceElement.checked = attach;
+ }
+}
+
+function DoAttachSourceCheckbox() {
+ gMsgCompPrevMozDoNotSendAttribute =
+ (!gMsgCompAttachSourceElement.checked).toString();
+ globalElement.setAttribute(
+ "moz-do-not-send",
+ gMsgCompPrevMozDoNotSendAttribute
+ );
+}
+
+function GenerateDataURL(url) {
+ var file = Services.io.newURI(url).QueryInterface(Ci.nsIFileURL).file;
+ var contentType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromFile(file);
+ var inputStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ inputStream.init(file, 0x01, 0o600, 0);
+ var stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(inputStream);
+ let data = "";
+ while (stream.available() > 0) {
+ data += stream.readBytes(stream.available());
+ }
+ let encoded = btoa(data);
+ stream.close();
+ return (
+ "data:" +
+ contentType +
+ ";filename=" +
+ encodeURIComponent(file.leafName) +
+ ";base64," +
+ encoded
+ );
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdImageProps.js b/comm/mail/components/compose/content/dialogs/EdImageProps.js
new file mode 100644
index 0000000000..861d098edc
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdImageProps.js
@@ -0,0 +1,293 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+/* import-globals-from EdImageDialog.js */
+
+var gAnchorElement = null;
+var gLinkElement = null;
+var gOriginalHref = "";
+var gHNodeArray = {};
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ ImageStartup();
+ gDialog.hrefInput = document.getElementById("hrefInput");
+ gDialog.makeRelativeLink = document.getElementById("MakeRelativeLink");
+ gDialog.showLinkBorder = document.getElementById("showLinkBorder");
+ gDialog.linkTab = document.getElementById("imageLinkTab");
+ gDialog.linkAdvanced = document.getElementById("LinkAdvancedEditButton");
+
+ // Get a single selected image element
+ var tagName = "img";
+ if ("arguments" in window && window.arguments[0]) {
+ imageElement = window.arguments[0];
+ // We've been called from form field properties, so we can't insert a link
+ gDialog.linkTab.remove();
+ gDialog.linkTab = null;
+ } else {
+ // First check for <input type="image">
+ try {
+ imageElement = editor.getSelectedElement("input");
+
+ if (!imageElement || imageElement.getAttribute("type") != "image") {
+ // Get a single selected image element
+ imageElement = editor.getSelectedElement(tagName);
+ if (imageElement) {
+ gAnchorElement = editor.getElementOrParentByTagName(
+ "href",
+ imageElement
+ );
+ }
+ }
+ } catch (e) {}
+ }
+
+ if (imageElement) {
+ // We found an element and don't need to insert one
+ if (imageElement.hasAttribute("src")) {
+ gInsertNewImage = false;
+ gActualWidth = imageElement.naturalWidth;
+ gActualHeight = imageElement.naturalHeight;
+ }
+ } else {
+ gInsertNewImage = true;
+
+ // We don't have an element selected,
+ // so create one with default attributes
+ try {
+ imageElement = editor.createElementWithDefaults(tagName);
+ } catch (e) {}
+
+ if (!imageElement) {
+ dump("Failed to get selected element or create a new one!\n");
+ window.close();
+ return;
+ }
+ try {
+ gAnchorElement = editor.getSelectedElement("href");
+ } catch (e) {}
+ }
+
+ // Make a copy to use for AdvancedEdit
+ globalElement = imageElement.cloneNode(false);
+
+ // We only need to test for this once per dialog load
+ gHaveDocumentUrl = GetDocumentBaseUrl();
+
+ InitDialog();
+ if (gAnchorElement) {
+ gOriginalHref = gAnchorElement.getAttribute("href");
+ // Make a copy to use for AdvancedEdit
+ gLinkElement = gAnchorElement.cloneNode(false);
+ } else {
+ gLinkElement = editor.createElementWithDefaults("a");
+ }
+ gDialog.hrefInput.value = gOriginalHref;
+
+ FillLinkMenulist(gDialog.hrefInput, gHNodeArray);
+ ChangeLinkLocation();
+
+ // Save initial source URL
+ gOriginalSrc = gDialog.srcInput.value;
+
+ // By default turn constrain on, but both width and height must be in pixels
+ gDialog.constrainCheckbox.checked =
+ gDialog.widthUnitsMenulist.selectedIndex == 0 &&
+ gDialog.heightUnitsMenulist.selectedIndex == 0;
+
+ // Start in "Link" tab if 2nd argument is true
+ if (gDialog.linkTab && "arguments" in window && window.arguments[1]) {
+ document.getElementById("TabBox").selectedTab = gDialog.linkTab;
+ SetTextboxFocus(gDialog.hrefInput);
+ } else {
+ SetTextboxFocus(gDialog.srcInput);
+ }
+
+ SetWindowLocation();
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitDialog() {
+ InitImage();
+ var border = TrimString(gDialog.border.value);
+ gDialog.showLinkBorder.checked = border != "" && border > 0;
+}
+
+function ChangeLinkLocation() {
+ var href = TrimString(gDialog.hrefInput.value);
+ SetRelativeCheckbox(gDialog.makeRelativeLink);
+ gDialog.showLinkBorder.disabled = !href;
+ gDialog.linkAdvanced.disabled = !href;
+ gLinkElement.setAttribute("href", href);
+}
+
+function ToggleShowLinkBorder() {
+ if (gDialog.showLinkBorder.checked) {
+ var border = TrimString(gDialog.border.value);
+ if (!border || border == "0") {
+ gDialog.border.value = "2";
+ }
+ } else {
+ gDialog.border.value = "0";
+ }
+}
+
+// Get data from widgets, validate, and set for the global element
+// accessible to AdvancedEdit() [in EdDialogCommon.js]
+function ValidateData() {
+ return ValidateImage();
+}
+
+function onAccept(event) {
+ // Use this now (default = false) so Advanced Edit button dialog doesn't trigger error message
+ gDoAltTextError = true;
+ window.opener.gMsgCompose.allowRemoteContent = true;
+ if (ValidateData()) {
+ if ("arguments" in window && window.arguments[0]) {
+ SaveWindowLocation();
+ return;
+ }
+
+ var editor = GetCurrentEditor();
+
+ editor.beginTransaction();
+
+ try {
+ if (gRemoveImageMap) {
+ globalElement.removeAttribute("usemap");
+ if (gImageMap) {
+ editor.deleteNode(gImageMap);
+ gInsertNewIMap = true;
+ gImageMap = null;
+ }
+ } else if (gImageMap) {
+ // un-comment to see that inserting image maps does not work!
+ /*
+ gImageMap = editor.createElementWithDefaults("map");
+ gImageMap.setAttribute("name", "testing");
+ var testArea = editor.createElementWithDefaults("area");
+ testArea.setAttribute("shape", "circle");
+ testArea.setAttribute("coords", "86,102,52");
+ testArea.setAttribute("href", "test");
+ gImageMap.appendChild(testArea);
+ */
+
+ // Assign to map if there is one
+ var mapName = gImageMap.getAttribute("name");
+ if (mapName != "") {
+ globalElement.setAttribute("usemap", "#" + mapName);
+ if (globalElement.getAttribute("border") == "") {
+ globalElement.setAttribute("border", 0);
+ }
+ }
+ }
+
+ // Create or remove the link as appropriate
+ var href = gDialog.hrefInput.value;
+ if (href != gOriginalHref) {
+ if (href && !gInsertNewImage) {
+ EditorSetTextProperty("a", "href", href);
+ // gAnchorElement is needed for cloning attributes later.
+ if (!gAnchorElement) {
+ gAnchorElement = editor.getElementOrParentByTagName(
+ "href",
+ imageElement
+ );
+ }
+ } else {
+ EditorRemoveTextProperty("href", "");
+ }
+ }
+
+ // If inside a link, always write the 'border' attribute
+ if (href) {
+ if (gDialog.showLinkBorder.checked) {
+ // Use default = 2 if border attribute is empty
+ if (!globalElement.hasAttribute("border")) {
+ globalElement.setAttribute("border", "2");
+ }
+ } else {
+ globalElement.setAttribute("border", "0");
+ }
+ }
+
+ if (gInsertNewImage) {
+ if (href) {
+ gLinkElement.appendChild(imageElement);
+ editor.insertElementAtSelection(gLinkElement, true);
+ } else {
+ // 'true' means delete the selection before inserting
+ editor.insertElementAtSelection(imageElement, true);
+ }
+ }
+
+ // Check to see if the link was to a heading
+ // Do this last because it moves the caret (BAD!)
+ if (href in gHNodeArray) {
+ var anchorNode = editor.createElementWithDefaults("a");
+ if (anchorNode) {
+ anchorNode.name = href.substr(1);
+ // Remember to use editor method so it is undoable!
+ editor.insertNode(anchorNode, gHNodeArray[href], 0);
+ }
+ }
+ // All values are valid - copy to actual element in doc or
+ // element we just inserted
+ editor.cloneAttributes(imageElement, globalElement);
+ if (gAnchorElement) {
+ editor.cloneAttributes(gAnchorElement, gLinkElement);
+ }
+
+ // If document is empty, the map element won't insert,
+ // so always insert the image first
+ if (gImageMap && gInsertNewIMap) {
+ // Insert the ImageMap element at beginning of document
+ var body = editor.rootElement;
+ editor.setShouldTxnSetSelection(false);
+ editor.insertNode(gImageMap, body, 0);
+ editor.setShouldTxnSetSelection(true);
+ }
+ } catch (e) {
+ dump(e);
+ }
+
+ editor.endTransaction();
+
+ SaveWindowLocation();
+ return;
+ }
+
+ gDoAltTextError = false;
+
+ event.preventDefault();
+}
+
+function onLinkAdvancedEdit() {
+ window.AdvancedEditOK = false;
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable=yes",
+ "",
+ gLinkElement
+ );
+ window.focus();
+ if (window.AdvancedEditOK) {
+ gDialog.hrefInput.value = gLinkElement.getAttribute("href");
+ }
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml b/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml
new file mode 100644
index 0000000000..c894a30175
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml
@@ -0,0 +1,454 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edImageProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorImageProperties.dtd">
+%edImageProperties;
+<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd">
+%composeEditorOverlayDTD;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<!-- dialog containing a control requiring initial setup -->
+<window
+ windowtype="Mail:image"
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="min-height: 24em"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog id="imageDlg" buttons="accept,cancel" style="width: 68ch">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdImageProps.js" />
+ <script src="chrome://messenger/content/messengercompose/EdImageDialog.js" />
+ <script src="chrome://messenger/content/messengercompose/EdImageLinkLoader.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <tabbox id="TabBox">
+ <tabs flex="1">
+ <tab id="imageLocationTab" label="&imageLocationTab.label;" />
+ <tab id="imageDimensionsTab" label="&imageDimensionsTab.label;" />
+ <tab id="imageAppearanceTab" label="&imageAppearanceTab.label;" />
+ <tab id="imageLinkTab" label="&imageLinkTab.label;" />
+ </tabs>
+ <tabpanels>
+ <vbox id="imageLocation">
+ <spacer class="spacer" />
+ <label
+ id="srcLabel"
+ control="srcInput"
+ value="&locationEditField.label;"
+ accesskey="&locationEditField.accessKey;"
+ tooltiptext="&locationEditField.tooltip;"
+ />
+ <tooltip id="shortenedDataURI">
+ <label value="&locationEditField.shortenedDataURI;" />
+ </tooltip>
+ <html:input
+ id="srcInput"
+ type="text"
+ oninput="ChangeImageSrc();"
+ tabindex="1"
+ class="uri-element input-inline"
+ title="&locationEditField.tooltip;"
+ aria-labelledby="srcLabel"
+ />
+ <hbox id="MakeRelativeHbox">
+ <checkbox
+ id="MakeRelativeCheckbox"
+ tabindex="2"
+ label="&makeUrlRelative.label;"
+ accesskey="&makeUrlRelative.accessKey;"
+ oncommand="MakeInputValueRelativeOrAbsolute(this);"
+ tooltiptext="&makeUrlRelative.tooltip;"
+ />
+ <checkbox
+ id="AttachSourceToMail"
+ hidden="true"
+ label="&attachImageSource.label;"
+ accesskey="&attachImageSource.accesskey;"
+ oncommand="DoAttachSourceCheckbox()"
+ />
+ <spacer flex="1" />
+ <button
+ id="ChooseFile"
+ tabindex="3"
+ oncommand="chooseFile()"
+ label="&chooseFileButton.label;"
+ accesskey="&chooseFileButton.accessKey;"
+ />
+ </hbox>
+ <spacer class="spacer" />
+ <radiogroup id="altTextRadioGroup" flex="1">
+ <hbox>
+ <vbox>
+ <hbox align="center" flex="1">
+ <label
+ id="titleLabel"
+ style="margin-left: 26px"
+ control="titleInput"
+ accesskey="&title.accessKey;"
+ value="&title.label;"
+ tooltiptext="&title.tooltip;"
+ />
+ </hbox>
+ <hbox align="center" flex="1">
+ <radio
+ id="altTextRadio"
+ value="usealt-yes"
+ label="&altText.label;"
+ accesskey="&altText.accessKey;"
+ tooltiptext="&altTextEditField.tooltip;"
+ persist="selected"
+ oncommand="SetAltTextDisabled(false);"
+ tabindex="5"
+ />
+ </hbox>
+ </vbox>
+ <vbox flex="1">
+ <html:input
+ id="titleInput"
+ type="text"
+ class="MinWidth20em input-inline"
+ title="&title.tooltip;"
+ tabindex="4"
+ aria-labelledby="titleLabel"
+ />
+ <html:input
+ id="altTextInput"
+ type="text"
+ class="MinWidth20em input-inline"
+ title="&altTextEditField.tooltip;"
+ oninput="SetAltTextDisabled(false);"
+ tabindex="6"
+ aria-labelledby="altTextRadio"
+ />
+ </vbox>
+ </hbox>
+ <radio
+ id="noAltTextRadio"
+ value="usealt-no"
+ label="&noAltText.label;"
+ accesskey="&noAltText.accessKey;"
+ persist="selected"
+ oncommand="SetAltTextDisabled(true);"
+ />
+ </radiogroup>
+ </vbox>
+
+ <vbox id="imageDimensions" align="start">
+ <spacer class="spacer" />
+ <hbox>
+ <radiogroup id="imgSizeGroup">
+ <radio
+ id="actualSizeRadio"
+ label="&actualSizeRadio.label;"
+ accesskey="&actualSizeRadio.accessKey;"
+ tooltiptext="&actualSizeRadio.tooltip;"
+ oncommand="SetActualSize()"
+ value="actual"
+ />
+ <radio
+ id="customSizeRadio"
+ label="&customSizeRadio.label;"
+ selected="true"
+ accesskey="&customSizeRadio.accessKey;"
+ tooltiptext="&customSizeRadio.tooltip;"
+ oncommand="doDimensionEnabling();"
+ value="custom"
+ />
+ </radiogroup>
+ <spacer flex="1" />
+ <vbox>
+ <spacer flex="1" />
+ <checkbox
+ id="constrainCheckbox"
+ label="&constrainCheckbox.label;"
+ accesskey="&constrainCheckbox.accessKey;"
+ oncommand="ToggleConstrain()"
+ tooltiptext="&constrainCheckbox.tooltip;"
+ />
+ </vbox>
+ <spacer flex="1" />
+ </hbox>
+ <spacer class="spacer" />
+ <hbox class="indent">
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ id="widthLabel"
+ control="widthInput"
+ accesskey="&widthEditField.accessKey;"
+ value="&widthEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="widthInput"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ oninput="constrainProportions(this.id,'heightInput')"
+ aria-labelledby="widthLabel"
+ />
+ </html:td>
+ <html:td>
+ <menulist
+ id="widthUnitsMenulist"
+ oncommand="doDimensionEnabling();"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ id="heightLabel"
+ control="heightInput"
+ accesskey="&heightEditField.accessKey;"
+ value="&heightEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="heightInput"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ oninput="constrainProportions(this.id,'widthInput')"
+ aria-labelledby="heightLabel"
+ />
+ </html:td>
+ <html:td>
+ <menulist
+ id="heightUnitsMenulist"
+ oncommand="doDimensionEnabling();"
+ />
+ </html:td>
+ </html:tr>
+ </html:table>
+ </hbox>
+ <spacer flex="1" />
+ </vbox>
+
+ <vbox id="imageAppearance">
+ <html:legend id="spacingLabel">&spacingBox.label;</html:legend>
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ id="leftrightLabel"
+ class="align-right"
+ control="imageleftrightInput"
+ accesskey="&leftRightEditField.accessKey;"
+ value="&leftRightEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="imageleftrightInput"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ aria-labelledby="leftrightLabel"
+ />
+ </html:td>
+ <html:td id="leftrighttypeLabel"> &pixelsPopup.value; </html:td>
+ <html:td style="width: 80%">
+ <spacer />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ id="topbottomLabel"
+ class="align-right"
+ control="imagetopbottomInput"
+ accesskey="&topBottomEditField.accessKey;"
+ value="&topBottomEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="imagetopbottomInput"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ aria-labelledby="topbottomLabel"
+ />
+ </html:td>
+ <html:td id="topbottomtypeLabel"> &pixelsPopup.value; </html:td>
+ <html:td>
+ <spacer />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ id="borderLabel"
+ class="align-right"
+ control="border"
+ accesskey="&borderEditField.accessKey;"
+ value="&borderEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="border"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ aria-labelledby="borderLabel"
+ />
+ </html:td>
+ <html:td id="bordertypeLabel"> &pixelsPopup.value; </html:td>
+ <html:td>
+ <spacer />
+ </html:td>
+ </html:tr>
+ </html:table>
+ <separator class="thin" />
+ <html:legend id="alignLabel">&alignment.label;</html:legend>
+ <menulist id="alignTypeSelect" class="align-menu">
+ <menupopup>
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="top"
+ label="&topPopup.value;"
+ />
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="middle"
+ label="&centerPopup.value;"
+ />
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="bottom"
+ label="&bottomPopup.value;"
+ />
+ <!-- HTML attribute value is opposite of the button label on purpose -->
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="right"
+ label="&wrapLeftPopup.value;"
+ />
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="left"
+ label="&wrapRightPopup.value;"
+ />
+ </menupopup>
+ </menulist>
+ <separator class="thin" />
+ <html:legend id="imagemapLabel">&imagemapBox.label;</html:legend>
+ <html:div class="grid-two-column-equalsize">
+ <button
+ id="removeImageMap"
+ oncommand="removeImageMap()"
+ accesskey="&removeImageMapButton.accessKey;"
+ label="&removeImageMapButton.label;"
+ />
+ <spacer /><!-- remove when we restore Image Map Editor -->
+ </html:div>
+ </vbox>
+ <vbox>
+ <spacer class="spacer" />
+ <vbox id="LinkLocationBox">
+ <label
+ id="hrefLabel"
+ control="hrefInput"
+ accesskey="&LinkURLEditField2.accessKey;"
+ width="1"
+ >&LinkURLEditField2.label;</label
+ >
+ <html:input
+ id="hrefInput"
+ type="text"
+ class="uri-element padded input-inline"
+ oninput="ChangeLinkLocation();"
+ aria-labelledby="hrefLabel"
+ />
+ <hbox align="center">
+ <checkbox
+ id="MakeRelativeLink"
+ for="hrefInput"
+ label="&makeUrlRelative.label;"
+ accesskey="&makeUrlRelative.accessKey;"
+ oncommand="MakeInputValueRelativeOrAbsolute(this);"
+ tooltiptext="&makeUrlRelative.tooltip;"
+ />
+ <spacer flex="1" />
+ <button
+ label="&chooseFileLinkButton.label;"
+ accesskey="&chooseFileLinkButton.accessKey;"
+ oncommand="chooseLinkFile();"
+ />
+ </hbox>
+ </vbox>
+ <spacer class="spacer" />
+ <hbox>
+ <checkbox
+ id="showLinkBorder"
+ label="&showImageLinkBorder.label;"
+ accesskey="&showImageLinkBorder.accessKey;"
+ oncommand="ToggleShowLinkBorder();"
+ />
+ <spacer flex="1" />
+ </hbox>
+ <separator class="thin" />
+ <hbox pack="end">
+ <button
+ id="LinkAdvancedEditButton"
+ label="&LinkAdvancedEditButton.label;"
+ accesskey="&LinkAdvancedEditButton.accessKey;"
+ tooltiptext="&LinkAdvancedEditButton.tooltip;"
+ oncommand="onLinkAdvancedEdit();"
+ />
+ </hbox>
+ </vbox>
+ </tabpanels>
+ </tabbox>
+
+ <spacer flex="1" />
+
+ <html:fieldset id="imagePreview" hidden="hidden">
+ <html:legend>&previewBox.label;</html:legend>
+
+ <html:figure>
+ <html:img id="preview-image" style="display: inline-block" alt="" />
+ <html:figcaption style="float: right">
+ <label value="&actualSize.label;" />
+ <label id="PreviewWidth" />x<label id="PreviewHeight" />
+ </html:figcaption>
+ </html:figure>
+ </html:fieldset>
+
+ <hbox pack="end">
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsSrc.js b/comm/mail/components/compose/content/dialogs/EdInsSrc.js
new file mode 100644
index 0000000000..d00f119ed7
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsSrc.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/. */
+
+/* Insert Source HTML dialog */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gFullDataStrings = new Map();
+var gShortDataStrings = new Map();
+var gListenerAttached = false;
+
+window.addEventListener("load", Startup);
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ let editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ document
+ .querySelector("dialog")
+ .getButton("accept")
+ .removeAttribute("default");
+
+ // Create dialog object to store controls for easy access
+ gDialog.srcInput = document.getElementById("srcInput");
+
+ // Attach a paste listener so we can detect pasted data URIs we need to shorten.
+ gDialog.srcInput.addEventListener("paste", onPaste);
+
+ let selection;
+ try {
+ selection = editor.outputToString(
+ "text/html",
+ kOutputFormatted | kOutputSelectionOnly | kOutputWrap
+ );
+ } catch (e) {}
+ if (selection) {
+ selection = selection.replace(/<body[^>]*>/, "").replace(/<\/body>/, "");
+
+ // Shorten data URIs for display.
+ selection = replaceDataURIs(selection);
+
+ if (selection) {
+ gDialog.srcInput.value = selection;
+ }
+ }
+ // Set initial focus
+ gDialog.srcInput.focus();
+ SetWindowLocation();
+}
+
+function replaceDataURIs(input) {
+ return input.replace(
+ /(data:.+;base64,)([^"' >]+)/gi,
+ function (match, nonDataPart, dataPart) {
+ if (gShortDataStrings.has(dataPart)) {
+ // We found the exact same data URI, just return the shortened URI.
+ return nonDataPart + gShortDataStrings.get(dataPart);
+ }
+
+ let l = 5;
+ let key;
+ // Normally we insert the ellipsis after five characters but if it's not unique
+ // we include more data.
+ do {
+ key =
+ dataPart.substr(0, l) + "…" + dataPart.substr(dataPart.length - 10);
+ l++;
+ } while (gFullDataStrings.has(key) && l < dataPart.length - 10);
+ gFullDataStrings.set(key, dataPart);
+ gShortDataStrings.set(dataPart, key);
+
+ // Attach listeners. In case anyone copies/cuts from the HTML window,
+ // we want to restore the data URI on the clipboard.
+ if (!gListenerAttached) {
+ gDialog.srcInput.addEventListener("copy", onCopyOrCut);
+ gDialog.srcInput.addEventListener("cut", onCopyOrCut);
+ gListenerAttached = true;
+ }
+
+ return nonDataPart + key;
+ }
+ );
+}
+
+function onCopyOrCut(event) {
+ let startPos = gDialog.srcInput.selectionStart;
+ if (startPos == undefined) {
+ return;
+ }
+ let endPos = gDialog.srcInput.selectionEnd;
+ let clipboard = gDialog.srcInput.value.substring(startPos, endPos);
+
+ // Add back the original data URIs we stashed away earlier.
+ clipboard = clipboard.replace(
+ /(data:.+;base64,)([^"' >]+)/gi,
+ function (match, nonDataPart, key) {
+ if (!gFullDataStrings.has(key)) {
+ // User changed data URI.
+ return match;
+ }
+ return nonDataPart + gFullDataStrings.get(key);
+ }
+ );
+ event.clipboardData.setData("text/plain", clipboard);
+ if (event.type == "cut") {
+ // We have to cut the selection manually.
+ gDialog.srcInput.value =
+ gDialog.srcInput.value.substr(0, startPos) +
+ gDialog.srcInput.value.substr(endPos);
+ }
+ event.preventDefault();
+}
+
+function onPaste(event) {
+ let startPos = gDialog.srcInput.selectionStart;
+ if (startPos == undefined) {
+ return;
+ }
+ let endPos = gDialog.srcInput.selectionEnd;
+ let clipboard = event.clipboardData.getData("text/plain");
+
+ // We do out own paste by replacing the selection with the pre-processed
+ // clipboard data.
+ gDialog.srcInput.value =
+ gDialog.srcInput.value.substr(0, startPos) +
+ replaceDataURIs(clipboard) +
+ gDialog.srcInput.value.substr(endPos);
+ event.preventDefault();
+}
+
+function onAccept(event) {
+ let html = gDialog.srcInput.value;
+ if (!html) {
+ event.preventDefault();
+ return;
+ }
+
+ // Add back the original data URIs we stashed away earlier.
+ html = html.replace(
+ /(data:.+;base64,)([^"' >]+)/gi,
+ function (match, nonDataPart, key) {
+ if (!gFullDataStrings.has(key)) {
+ // User changed data URI.
+ return match;
+ }
+ return nonDataPart + gFullDataStrings.get(key);
+ }
+ );
+
+ try {
+ GetCurrentEditor().insertHTML(html);
+ } catch (e) {}
+ SaveWindowLocation();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml b/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml
new file mode 100644
index 0000000000..1f35de996d
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml
@@ -0,0 +1,67 @@
+<?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://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE html SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertSource.dtd">
+<html
+ 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"
+ lightweightthemes="true"
+ style="min-height: 430px; min-width: 600px"
+ scrolling="false"
+>
+ <head>
+ <title>&windowTitle.label;</title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/dialogShadowDom.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/messengercompose/editorUtilities.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/messengercompose/EdDialogCommon.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/messengercompose/EdInsSrc.js"
+ ></script>
+ </head>
+ <body>
+ <xul:dialog
+ buttonlabelaccept="&insertButton.label;"
+ buttonaccesskeyaccept="&insertButton.accesskey;"
+ >
+ <p id="srcMessage">&sourceEditField.label;</p>
+ <textarea id="srcInput" style="flex: 1" rows="18" cols="70"></textarea>
+ <p>
+ &example.label;
+ <code class="bold">
+ &exampleOpenTag.label;
+ <i>&exampleText.label;</i> &exampleCloseTag.label;
+ </code>
+ </p>
+ <hr />
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertChars.js b/comm/mail/components/compose/content/dialogs/EdInsertChars.js
new file mode 100644
index 0000000000..b710fb91a0
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertChars.js
@@ -0,0 +1,412 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// ------------------------------------------------------------------
+// From Unicode 3.0 Page 54. 3.11 Conjoining Jamo Behavior
+var SBase = 0xac00;
+var LBase = 0x1100;
+var VBase = 0x1161;
+var TBase = 0x11a7;
+var LCount = 19;
+var VCount = 21;
+var TCount = 28;
+var NCount = VCount * TCount;
+// End of Unicode 3.0
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onClose);
+
+// dialog initialization code
+function Startup() {
+ if (!GetCurrentEditor()) {
+ window.close();
+ return;
+ }
+
+ StartupLatin();
+
+ // Set a variable on the opener window so we
+ // can track ownership of close this window with it
+ window.opener.InsertCharWindow = window;
+ window.sizeToContent();
+
+ SetWindowLocation();
+}
+
+function onAccept(event) {
+ // Insert the character
+ try {
+ GetCurrentEditor().insertText(LatinM.label);
+ } catch (e) {}
+
+ // Set persistent attributes to save
+ // which category, letter, and character modifier was used
+ CategoryGroup.setAttribute("category", category);
+ CategoryGroup.setAttribute("letter_index", indexL);
+ CategoryGroup.setAttribute("char_index", indexM);
+
+ // Don't close the dialog
+ event.preventDefault();
+}
+
+// Don't allow inserting in HTML Source Mode
+function onFocus() {
+ var enable = true;
+ if ("gEditorDisplayMode" in window.opener) {
+ enable = !window.opener.IsInHTMLSourceMode();
+ }
+
+ SetElementEnabled(
+ document.querySelector("dialog").getButton("accept"),
+ enable
+ );
+}
+
+function onClose() {
+ window.opener.InsertCharWindow = null;
+ SaveWindowLocation();
+}
+
+// ------------------------------------------------------------------
+var LatinL;
+var LatinM;
+var LatinL_Label;
+var LatinM_Label;
+var indexL = 0;
+var indexM = 0;
+var indexM_AU = 0;
+var indexM_AL = 0;
+var indexM_U = 0;
+var indexM_L = 0;
+var indexM_S = 0;
+var LItems = 0;
+var category;
+var CategoryGroup;
+var initialize = true;
+
+function StartupLatin() {
+ LatinL = document.getElementById("LatinL");
+ LatinM = document.getElementById("LatinM");
+ LatinL_Label = document.getElementById("LatinL_Label");
+ LatinM_Label = document.getElementById("LatinM_Label");
+
+ var Symbol = document.getElementById("Symbol");
+ var AccentUpper = document.getElementById("AccentUpper");
+ var AccentLower = document.getElementById("AccentLower");
+ var Upper = document.getElementById("Upper");
+ var Lower = document.getElementById("Lower");
+ CategoryGroup = document.getElementById("CatGrp");
+
+ // Initialize which radio button is set from persistent attribute...
+ var category = CategoryGroup.getAttribute("category");
+
+ // ...as well as indexes into the letter and character lists
+ var index = Number(CategoryGroup.getAttribute("letter_index"));
+ if (index && index >= 0) {
+ indexL = index;
+ }
+ index = Number(CategoryGroup.getAttribute("char_index"));
+ if (index && index >= 0) {
+ indexM = index;
+ }
+
+ switch (category) {
+ case "AccentUpper": // Uppercase Diacritical
+ CategoryGroup.selectedItem = AccentUpper;
+ indexM_AU = indexM;
+ break;
+ case "AccentLower": // Lowercase Diacritical
+ CategoryGroup.selectedItem = AccentLower;
+ indexM_AL = indexM;
+ break;
+ case "Upper": // Uppercase w/o Diacritical
+ CategoryGroup.selectedItem = Upper;
+ indexM_U = indexM;
+ break;
+ case "Lower": // Lowercase w/o Diacritical
+ CategoryGroup.selectedItem = Lower;
+ indexM_L = indexM;
+ break;
+ default:
+ category = "Symbol";
+ CategoryGroup.selectedItem = Symbol;
+ indexM_S = indexM;
+ break;
+ }
+
+ ChangeCategory(category);
+ initialize = false;
+}
+
+function ChangeCategory(newCategory) {
+ if (category != newCategory || initialize) {
+ category = newCategory;
+ // Note: Must do L before M to set LatinL.selectedIndex
+ UpdateLatinL();
+ UpdateLatinM();
+ UpdateCharacter();
+ }
+}
+
+function SelectLatinLetter() {
+ if (LatinL.selectedIndex != indexL) {
+ indexL = LatinL.selectedIndex;
+ UpdateLatinM();
+ UpdateCharacter();
+ }
+}
+
+function SelectLatinModifier() {
+ if (LatinM.selectedIndex != indexM) {
+ indexM = LatinM.selectedIndex;
+ UpdateCharacter();
+ }
+}
+function DisableLatinL(disable) {
+ if (disable) {
+ LatinL_Label.setAttribute("disabled", "true");
+ LatinL.setAttribute("disabled", "true");
+ } else {
+ LatinL_Label.removeAttribute("disabled");
+ LatinL.removeAttribute("disabled");
+ }
+}
+
+function UpdateLatinL() {
+ LatinL.removeAllItems();
+ if (category == "AccentUpper" || category == "AccentLower") {
+ DisableLatinL(false);
+ // No Q or q
+ var alphabet =
+ category == "AccentUpper"
+ ? "ABCDEFGHIJKLMNOPRSTUVWXYZ"
+ : "abcdefghijklmnoprstuvwxyz";
+ for (var letter = 0; letter < alphabet.length; letter++) {
+ LatinL.appendItem(alphabet.charAt(letter));
+ }
+
+ LatinL.selectedIndex = indexL;
+ } else {
+ // Other categories don't hinge on a "letter"
+ DisableLatinL(true);
+ // Note: don't change the indexL so it can be used next time
+ }
+}
+
+function UpdateLatinM() {
+ LatinM.removeAllItems();
+ var i, accent;
+ switch (category) {
+ case "AccentUpper": // Uppercase Diacritical
+ accent = upper[indexL];
+ for (i = 0; i < accent.length; i++) {
+ LatinM.appendItem(accent.charAt(i));
+ }
+
+ if (indexM_AU < accent.length) {
+ indexM = indexM_AU;
+ } else {
+ indexM = accent.length - 1;
+ }
+ indexM_AU = indexM;
+ break;
+
+ case "AccentLower": // Lowercase Diacritical
+ accent = lower[indexL];
+ for (i = 0; i < accent.length; i++) {
+ LatinM.appendItem(accent.charAt(i));
+ }
+
+ if (indexM_AL < accent.length) {
+ indexM = indexM_AL;
+ } else {
+ indexM = lower[indexL].length - 1;
+ }
+ indexM_AL = indexM;
+ break;
+
+ case "Upper": // Uppercase w/o Diacritical
+ for (i = 0; i < otherupper.length; i++) {
+ LatinM.appendItem(otherupper.charAt(i));
+ }
+
+ if (indexM_U < otherupper.length) {
+ indexM = indexM_U;
+ } else {
+ indexM = otherupper.length - 1;
+ }
+ indexM_U = indexM;
+ break;
+
+ case "Lower": // Lowercase w/o Diacritical
+ for (i = 0; i < otherlower.length; i++) {
+ LatinM.appendItem(otherlower.charAt(i));
+ }
+
+ if (indexM_L < otherlower.length) {
+ indexM = indexM_L;
+ } else {
+ indexM = otherlower.length - 1;
+ }
+ indexM_L = indexM;
+ break;
+
+ case "Symbol": // Symbol
+ for (i = 0; i < symbol.length; i++) {
+ LatinM.appendItem(symbol.charAt(i));
+ }
+
+ if (indexM_S < symbol.length) {
+ indexM = indexM_S;
+ } else {
+ indexM = symbol.length - 1;
+ }
+ indexM_S = indexM;
+ break;
+ }
+ LatinM.selectedIndex = indexM;
+}
+
+function UpdateCharacter() {
+ indexM = LatinM.selectedIndex;
+
+ switch (category) {
+ case "AccentUpper": // Uppercase Diacritical
+ indexM_AU = indexM;
+ break;
+ case "AccentLower": // Lowercase Diacritical
+ indexM_AL = indexM;
+ break;
+ case "Upper": // Uppercase w/o Diacritical
+ indexM_U = indexM;
+ break;
+ case "Lower": // Lowercase w/o Diacritical
+ indexM_L = indexM;
+ break;
+ case "Symbol":
+ indexM_S = indexM;
+ break;
+ }
+ // dump("Letter Index="+indexL+", Character Index="+indexM+", Character = "+LatinM.label+"\n");
+}
+
+const upper = [
+ // A
+ "\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5\u0100\u0102\u0104\u01cd\u01de\u01de\u01e0\u01fa\u0200\u0202\u0226\u1e00\u1ea0\u1ea2\u1ea4\u1ea6\u1ea8\u1eaa\u1eac\u1eae\u1eb0\u1eb2\u1eb4\u1eb6",
+ // B
+ "\u0181\u0182\u0184\u1e02\u1e04\u1e06",
+ // C
+ "\u00c7\u0106\u0108\u010a\u010c\u0187\u1e08",
+ // D
+ "\u010e\u0110\u0189\u018a\u1e0a\u1e0c\u1e0e\u1e10\u1e12",
+ // E
+ "\u00C8\u00C9\u00CA\u00CB\u0112\u0114\u0116\u0118\u011A\u0204\u0206\u0228\u1e14\u1e16\u1e18\u1e1a\u1e1c\u1eb8\u1eba\u1ebc\u1ebe\u1ec0\u1ec2\u1ec4\u1ec6",
+ // F
+ "\u1e1e",
+ // G
+ "\u011c\u011E\u0120\u0122\u01e4\u01e6\u01f4\u1e20",
+ // H
+ "\u0124\u0126\u021e\u1e22\u1e24\u1e26\u1e28\u1e2a",
+ // I
+ "\u00CC\u00CD\u00CE\u00CF\u0128\u012a\u012C\u012e\u0130\u0208\u020a\u1e2c\u1e2e\u1ec8\u1eca",
+ // J
+ "\u0134\u01f0",
+ // K
+ "\u0136\u0198\u01e8\u1e30\u1e32\u1e34",
+ // L
+ "\u0139\u013B\u013D\u013F\u0141\u1e36\u1e38\u1e3a\u1e3c",
+ // M
+ "\u1e3e\u1e40\u1e42",
+ // N
+ "\u00D1\u0143\u0145\u0147\u014A\u01F8\u1e44\u1e46\u1e48\u1e4a",
+ // O
+ "\u00D2\u00D3\u00D4\u00D5\u00D6\u014C\u014E\u0150\u01ea\u01ec\u020c\u020e\u022A\u022C\u022E\u0230\u1e4c\u1e4e\u1e50\u1e52\u1ecc\u1ece\u1ed0\u1ed2\u1ed4\u1ed6\u1ed8\u1eda\u1edc\u1ede\u1ee0\u1ee2",
+ // P
+ "\u1e54\u1e56",
+ // No Q
+ // R
+ "\u0154\u0156\u0158\u0210\u0212\u1e58\u1e5a\u1e5c\u1e5e",
+ // S
+ "\u015A\u015C\u015E\u0160\u0218\u1e60\u1e62\u1e64\u1e66\u1e68",
+ // T
+ "\u0162\u0164\u0166\u021A\u1e6a\u1e6c\u1e6e\u1e70",
+ // U
+ "\u00D9\u00DA\u00DB\u00DC\u0168\u016A\u016C\u016E\u0170\u0172\u0214\u0216\u1e72\u1e74\u1e76\u1e78\u1e7a\u1ee4\u1ee6\u1ee8\u1eea\u1eec\u1eee\u1ef0",
+ // V
+ "\u1e7c\u1e7e",
+ // W
+ "\u0174\u1e80\u1e82\u1e84\u1e86\u1e88",
+ // X
+ "\u1e8a\u1e8c",
+ // Y
+ "\u00DD\u0176\u0178\u0232\u1e8e\u1ef2\u1ef4\u1ef6\u1ef8",
+ // Z
+ "\u0179\u017B\u017D\u0224\u1e90\u1e92\u1e94",
+];
+
+const lower = [
+ // a
+ "\u00e0\u00e1\u00e2\u00e3\u00e4\u00e5\u0101\u0103\u0105\u01ce\u01df\u01e1\u01fb\u0201\u0203\u0227\u1e01\u1e9a\u1ea1\u1ea3\u1ea5\u1ea7\u1ea9\u1eab\u1ead\u1eaf\u1eb1\u1eb3\u1eb5\u1eb7",
+ // b
+ "\u0180\u0183\u0185\u1e03\u1e05\u1e07",
+ // c
+ "\u00e7\u0107\u0109\u010b\u010d\u0188\u1e09",
+ // d
+ "\u010f\u0111\u1e0b\u1e0d\u1e0f\u1e11\u1e13",
+ // e
+ "\u00e8\u00e9\u00ea\u00eb\u0113\u0115\u0117\u0119\u011b\u0205\u0207\u0229\u1e15\u1e17\u1e19\u1e1b\u1e1d\u1eb9\u1ebb\u1ebd\u1ebf\u1ec1\u1ec3\u1ec5\u1ec7",
+ // f
+ "\u1e1f",
+ // g
+ "\u011d\u011f\u0121\u0123\u01e5\u01e7\u01f5\u1e21",
+ // h
+ "\u0125\u0127\u021f\u1e23\u1e25\u1e27\u1e29\u1e2b\u1e96",
+ // i
+ "\u00ec\u00ed\u00ee\u00ef\u0129\u012b\u012d\u012f\u0131\u01d0\u0209\u020b\u1e2d\u1e2f\u1ec9\u1ecb",
+ // j
+ "\u0135",
+ // k
+ "\u0137\u0138\u01e9\u1e31\u1e33\u1e35",
+ // l
+ "\u013a\u013c\u013e\u0140\u0142\u1e37\u1e39\u1e3b\u1e3d",
+ // m
+ "\u1e3f\u1e41\u1e43",
+ // n
+ "\u00f1\u0144\u0146\u0148\u0149\u014b\u01f9\u1e45\u1e47\u1e49\u1e4b",
+ // o
+ "\u00f2\u00f3\u00f4\u00f5\u00f6\u014d\u014f\u0151\u01d2\u01eb\u01ed\u020d\u020e\u022b\u022d\u022f\u0231\u1e4d\u1e4f\u1e51\u1e53\u1ecd\u1ecf\u1ed1\u1ed3\u1ed5\u1ed7\u1ed9\u1edb\u1edd\u1edf\u1ee1\u1ee3",
+ // p
+ "\u1e55\u1e57",
+ // No q
+ // r
+ "\u0155\u0157\u0159\u0211\u0213\u1e59\u1e5b\u1e5d\u1e5f",
+ // s
+ "\u015b\u015d\u015f\u0161\u0219\u1e61\u1e63\u1e65\u1e67\u1e69",
+ // t
+ "\u0162\u0163\u0165\u0167\u021b\u1e6b\u1e6d\u1e6f\u1e71\u1e97",
+ // u
+ "\u00f9\u00fa\u00fb\u00fc\u0169\u016b\u016d\u016f\u0171\u0173\u01d4\u01d6\u01d8\u01da\u01dc\u0215\u0217\u1e73\u1e75\u1e77\u1e79\u1e7b\u1ee5\u1ee7\u1ee9\u1eeb\u1eed\u1eef\u1ef1",
+ // v
+ "\u1e7d\u1e7f",
+ // w
+ "\u0175\u1e81\u1e83\u1e85\u1e87\u1e89\u1e98",
+ // x
+ "\u1e8b\u1e8d",
+ // y
+ "\u00fd\u00ff\u0177\u0233\u1e8f\u1e99\u1ef3\u1ef5\u1ef7\u1ef9",
+ // z
+ "\u017a\u017c\u017e\u0225\u1e91\u1e93\u1e95",
+];
+
+const symbol =
+ "\u00a1\u00a2\u00a3\u00a4\u00a5\u20ac\u00a6\u00a7\u00a8\u00a9\u00aa\u00ab\u00ac\u00ae\u00af\u00b0\u00b1\u00b2\u00b3\u00b4\u00b5\u00b6\u00b7\u00b8\u00b9\u00ba\u00bb\u00bc\u00bd\u00be\u00bf\u00d7\u00f7";
+
+const otherupper =
+ "\u00c6\u00d0\u00d8\u00de\u0132\u0152\u0186\u01c4\u01c5\u01c7\u01c8\u01ca\u01cb\u01F1\u01f2";
+
+const otherlower =
+ "\u00e6\u00f0\u00f8\u00fe\u00df\u0133\u0153\u01c6\u01c9\u01cc\u01f3";
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml
new file mode 100644
index 0000000000..c610abdd88
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertChars.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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/EdInsertChars.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertChars.dtd">
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="Startup()"
+ onfocus="onFocus()"
+ lightweightthemes="true"
+ style="min-width: 20em"
+>
+ <dialog
+ id="insertCharsDlg"
+ buttonlabelaccept="&insertButton.label;"
+ buttonlabelcancel="&closeButton.label;"
+ >
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdInsertChars.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <html:fieldset>
+ <html:legend>&category.label;</html:legend>
+ <radiogroup id="CatGrp" persist="category letter_index char_index">
+ <radio
+ id="AccentUpper"
+ label="&accentUpper.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ <radio
+ id="AccentLower"
+ label="&accentLower.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ <radio
+ id="Upper"
+ label="&otherUpper.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ <radio
+ id="Lower"
+ label="&otherLower.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ <radio
+ id="Symbol"
+ label="&commonSymbols.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ </radiogroup>
+ <spacer class="spacer" />
+ </html:fieldset>
+ <html:div class="grid-two-column-equalsize">
+ <!-- value is set in JS from editor.properties strings -->
+ <label
+ id="LatinL_Label"
+ control="LatinL"
+ value="&letter.label;"
+ accesskey="&letter.accessKey;"
+ />
+ <menulist id="LatinL" oncommand="SelectLatinLetter()">
+ <menupopup />
+ </menulist>
+ <label
+ id="LatinM_Label"
+ control="LatinM"
+ value="&character.label;"
+ accesskey="&character.accessKey;"
+ />
+ <menulist id="LatinM" oncommand="SelectLatinModifier()">
+ <menupopup />
+ </menulist>
+ </html:div>
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertMath.js b/comm/mail/components/compose/content/dialogs/EdInsertMath.js
new file mode 100644
index 0000000000..a60a3affcc
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertMath.js
@@ -0,0 +1,317 @@
+/* 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/. */
+
+/* Insert MathML dialog */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ // Create dialog object for easy access
+ gDialog.accept = document.querySelector("dialog").getButton("accept");
+ gDialog.mode = document.getElementById("optionMode");
+ gDialog.direction = document.getElementById("optionDirection");
+ gDialog.input = document.getElementById("input");
+ gDialog.output = document.getElementById("output");
+ gDialog.tabbox = document.getElementById("tabboxInsertLaTeXCommand");
+
+ // Set initial focus
+ gDialog.input.focus();
+
+ // Load TeXZilla
+ // TeXZilla.js contains non-ASCII characters and explicitly sets
+ // window.TeXZilla, so we have to specify the charset parameter but don't
+ // need to worry about the targetObj parameter.
+ /* globals TeXZilla */
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/messengercompose/TeXZilla.js",
+ {},
+ "UTF-8"
+ );
+
+ // Verify if the selection is on a <math> and initialize the dialog.
+ gDialog.oldMath = editor.getElementOrParentByTagName("math", null);
+ if (gDialog.oldMath) {
+ // When these attributes are absent or invalid, they default to "inline" and "ltr" respectively.
+ gDialog.mode.selectedIndex =
+ gDialog.oldMath.getAttribute("display") == "block" ? 1 : 0;
+ gDialog.direction.selectedIndex =
+ gDialog.oldMath.getAttribute("dir") == "rtl" ? 1 : 0;
+ gDialog.input.value = TeXZilla.getTeXSource(gDialog.oldMath);
+ }
+
+ // Create the tabbox with LaTeX commands.
+ createCommandPanel({
+ "√⅗²": [
+ "{⋯}^{⋯}",
+ "{⋯}_{⋯}",
+ "{⋯}_{⋯}^{⋯}",
+ "\\underset{⋯}{⋯}",
+ "\\overset{⋯}{⋯}",
+ "\\underoverset{⋯}{⋯}{⋯}",
+ "\\left(⋯\\right)",
+ "\\left[⋯\\right]",
+ "\\frac{⋯}{⋯}",
+ "\\binom{⋯}{⋯}",
+ "\\sqrt{⋯}",
+ "\\sqrt[⋯]{⋯}",
+ "\\cos\\left({⋯}\\right)",
+ "\\sin\\left({⋯}\\right)",
+ "\\tan\\left({⋯}\\right)",
+ "\\exp\\left({⋯}\\right)",
+ "\\ln\\left({⋯}\\right)",
+ "\\underbrace{⋯}",
+ "\\underline{⋯}",
+ "\\overbrace{⋯}",
+ "\\widevec{⋯}",
+ "\\widetilde{⋯}",
+ "\\widehat{⋯}",
+ "\\widecheck{⋯}",
+ "\\widebar{⋯}",
+ "\\dot{⋯}",
+ "\\ddot{⋯}",
+ "\\boxed{⋯}",
+ "\\slash{⋯}",
+ ],
+ "(â–¦)": [
+ "\\begin{matrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{matrix}",
+ "\\begin{pmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{pmatrix}",
+ "\\begin{bmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{bmatrix}",
+ "\\begin{Bmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{Bmatrix}",
+ "\\begin{vmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{vmatrix}",
+ "\\begin{Vmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{Vmatrix}",
+ "\\begin{cases} ⋯ \\\\ ⋯ \\end{cases}",
+ "\\begin{aligned} ⋯ &= ⋯ \\\\ ⋯ &= ⋯ \\end{aligned}",
+ ],
+ });
+ createSymbolPanels([
+ "âˆâˆâˆ‘∫∬∭⨌∮⊎⊕⊖⊗⊘⊙⋀â‹â‹‚⋃⌈⌉⌊⌋⎰⎱⟨⟩⟪⟫∥⫼⨀â¨â¨‚⨄⨅⨆ðıȷâ„ℑℓ℘ℜℵℶ",
+ "∀∃∄∅∉∊∋∌⊂⊃⊄⊅⊆⊇⊈⊈⊉⊊⊊⊋⊋âŠâŠâŠ‘⊒⊓⊔⊥â‹â‹‘⋔⫅⫆⫋⫋⫌⫌…⋮⋯⋰⋱♭♮♯∂∇",
+ "±×÷†‡•∓∔∗∘âˆâˆ âˆ¡âˆ¢âˆ§âˆ¨âˆ´âˆµâˆ¼âˆ½â‰â‰ƒâ‰…≇≈≈≊â‰â‰Žâ‰â‰â‰‘≒≓≖≗≜≡≢≬⊚⊛⊞⊡⊢⊣⊤⊥",
+ "⊨⊩⊪⊫⊬⊭⊯⊲⊲⊳⊴⊵⊸⊻⋄⋅⋇⋈⋉⋊⋋⋌â‹â‹Žâ‹â‹’⋓⌅⌆⌣△▴▵▸▹▽▾▿◂◃◊○★♠♡♢♣⧫",
+ "≦≧≨≩≩≪≫≮≯≰≱≲≳≶≷≺≻≼≽≾≿⊀âŠâ‹–⋗⋘⋙⋚⋛⋞⋟⋦⋧⋨⋩⩽⩾⪅⪆⪇⪈⪉⪊⪋⪌⪕⪯⪰⪷⪸⪹⪺",
+ "â†â†‘→↓↔↕↖↗↘↙↜â†â†žâ† â†¢â†£â†¦â†©â†ªâ†«â†¬â†­â†­â†°â†±â†¼â†½â†¾â†¿â‡€â‡â‡‚⇃⇄⇆⇇⇈⇉⇊⇋⇌â‡â‡‘⇒⇓⇕⇖⇗⇘⇙⟺",
+ "αβγδϵ϶εζηθϑικϰλμνξℴπϖÏϱσςτυϕφχψωΓΔΘΛΞΠΣϒΦΨΩÏ℧",
+ "ð•’ð•“ð•”ð••ð•–ð•—ð•˜ð•™ð•šð•›ð•œð•ð•žð•Ÿð• ð•¡ð•¢ð•£ð•¤ð•¥ð•¦ð•§ð•¨ð•©ð•ªð•«ð”¸ð”¹â„‚ð”»ð”¼ð”½ð”¾â„ð•€ð•ð•‚ð•ƒð•„â„•ð•†â„™â„šâ„ð•Šð•‹ð•Œð•ð•Žð•ð•â„¤",
+ "ð’¶ð’·ð’¸ð’¹â„¯ð’»â„Šð’½ð’¾ð’¿ð“€ð“ð“‚ð“ƒâ„´ð“…ð“†ð“‡ð“ˆð“‰ð“Šð“‹ð“Œð“ð“Žð“ð’œâ„¬ð’žð’Ÿâ„°â„±ð’¢â„‹â„ð’¥ð’¦â„’ℳð’©ð’ªð’«ð’¬â„›ð’®ð’¯ð’°ð’±ð’²ð’³ð’´ð’µ",
+ "ð”žð”Ÿð” ð”¡ð”¢ð”£ð”¤ð”¥ð”¦ð”§ð”¨ð”©ð”ªð”«ð”¬ð”­ð”®ð”¯ð”°ð”±ð”²ð”³ð”´ð”µð”¶ð”·ð”„ð”…â„­ð”‡ð”ˆð”‰ð”Šâ„Œâ„‘ð”ð”Žð”ð”ð”‘ð”’ð”“ð””â„œð”–ð”—ð”˜ð”™ð”šð”›ð”œâ„¨",
+ ]);
+ gDialog.tabbox.selectedIndex = 0;
+
+ updateMath();
+
+ SetWindowLocation();
+}
+
+function insertLaTeXCommand(aButton) {
+ gDialog.input.focus();
+
+ // For a single math symbol, just use the insertText command.
+ if (aButton.label) {
+ gDialog.input.editor.insertText(aButton.label);
+ return;
+ }
+
+ // Otherwise, it's a LaTeX command with at least one argument...
+ var latex = TeXZilla.getTeXSource(aButton.firstElementChild);
+ var selectionStart = gDialog.input.selectionStart;
+ var selectionEnd = gDialog.input.selectionEnd;
+
+ // If the selection is not empty, we replace the first argument of the LaTeX
+ // command with the current selection.
+ var selection = gDialog.input.value.substring(selectionStart, selectionEnd);
+ if (selection != "") {
+ latex = latex.replace("⋯", selection);
+ }
+
+ // Try and move to the next position.
+ var latexNewStart = latex.indexOf("⋯"),
+ latexNewEnd;
+ if (latexNewStart == -1) {
+ // This is a unary function and the selection was used as an argument above.
+ // We select the expression again so that one can choose to apply further
+ // command to it or just move the caret after that text.
+ latexNewStart = 0;
+ latexNewEnd = latex.length;
+ } else {
+ // Otherwise, select the dots representing the next argument.
+ latexNewEnd = latexNewStart + 1;
+ }
+
+ // Update the input text and selection.
+ gDialog.input.editor.insertText(latex);
+ gDialog.input.setSelectionRange(
+ selectionStart + latexNewStart,
+ selectionStart + latexNewEnd
+ );
+
+ updateMath();
+}
+
+function createCommandPanel(aCommandPanelList) {
+ const columnCount = 10;
+
+ for (var label in aCommandPanelList) {
+ var commands = aCommandPanelList[label];
+
+ // Create the <table> element with the <tr>.
+ var table = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "table"
+ );
+
+ var i = 0,
+ row;
+ for (var command of commands) {
+ if (i % columnCount == 0) {
+ // Create a new row.
+ row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr");
+ table.appendChild(row);
+ }
+
+ // Create a new button to insert the symbol.
+ var button = document.createXULElement("toolbarbutton");
+ var td = document.createElementNS("http://www.w3.org/1999/xhtml", "td");
+ button.setAttribute("class", "tabbable");
+ button.appendChild(TeXZilla.toMathML(command));
+ td.append(button);
+ row.appendChild(td);
+
+ i++;
+ }
+
+ // Create a new <tab> element.
+ var tab = document.createXULElement("tab");
+ tab.setAttribute("label", label);
+ gDialog.tabbox.tabs.appendChild(tab);
+
+ // Append the new tab panel.
+ gDialog.tabbox.tabpanels.appendChild(table);
+ }
+}
+
+function createSymbolPanels(aSymbolPanelList) {
+ const columnCount = 13,
+ tabLabelLength = 3;
+
+ for (var symbols of aSymbolPanelList) {
+ // Create the <table> element with the <tr>.
+ var table = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "table"
+ );
+ var i = 0,
+ tabLabel = "",
+ row;
+ for (var symbol of symbols) {
+ if (i % columnCount == 0) {
+ // Create a new row.
+ row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr");
+ table.appendChild(row);
+ }
+
+ // Build the tab label from the first symbols of this tab.
+ if (i < tabLabelLength) {
+ tabLabel += symbol;
+ }
+
+ // Create a new button to insert the symbol.
+ var button = document.createXULElement("toolbarbutton");
+ var td = document.createElementNS("http://www.w3.org/1999/xhtml", "td");
+ button.setAttribute("label", symbol);
+ button.setAttribute("class", "tabbable");
+ td.append(button);
+ row.appendChild(td);
+
+ i++;
+ }
+
+ // Create a new <tab> element with the label determined above.
+ var tab = document.createXULElement("tab");
+ tab.setAttribute("label", tabLabel);
+ gDialog.tabbox.tabs.appendChild(tab);
+
+ // Append the new tab panel.
+ gDialog.tabbox.tabpanels.appendChild(table);
+ }
+}
+
+function onAccept(event) {
+ if (gDialog.output.firstElementChild) {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+
+ try {
+ var newMath = editor.document.importNode(
+ gDialog.output.firstElementChild,
+ true
+ );
+ if (gDialog.oldMath) {
+ // Replace the old <math> element with the new one.
+ editor.selectElement(gDialog.oldMath);
+ editor.insertElementAtSelection(newMath, true);
+ } else {
+ // Insert the new <math> element.
+ editor.insertElementAtSelection(newMath, false);
+ }
+ } catch (e) {}
+
+ editor.endTransaction();
+ } else {
+ dump("Null value -- not inserting in MathML Source dialog\n");
+ event.preventDefault();
+ }
+ SaveWindowLocation();
+}
+
+function updateMath() {
+ // Remove the preview, if any.
+ if (gDialog.output.firstElementChild) {
+ gDialog.output.firstElementChild.remove();
+ }
+
+ // Try to convert the LaTeX source into MathML using TeXZilla.
+ // We use the placeholder text if no input is provided.
+ try {
+ var input = gDialog.input.value || gDialog.input.placeholder;
+ var newMath = TeXZilla.toMathML(
+ input,
+ gDialog.mode.selectedIndex,
+ gDialog.direction.selectedIndex,
+ true
+ );
+ gDialog.output.appendChild(document.importNode(newMath, true));
+ gDialog.output.style.opacity = gDialog.input.value ? 1 : 0.5;
+ } catch (e) {}
+ // Disable the accept button if parsing fails or when the placeholder is used.
+ gDialog.accept.disabled =
+ !gDialog.input.value || !gDialog.output.firstElementChild;
+}
+
+function updateMode() {
+ if (gDialog.output.firstElementChild) {
+ gDialog.output.firstElementChild.setAttribute(
+ "display",
+ gDialog.mode.selectedIndex ? "block" : "inline"
+ );
+ }
+}
+
+function updateDirection() {
+ if (gDialog.output.firstElementChild) {
+ gDialog.output.firstElementChild.setAttribute(
+ "dir",
+ gDialog.direction.selectedIndex ? "rtl" : "ltr"
+ );
+ }
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml
new file mode 100644
index 0000000000..d76a518b0a
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml
@@ -0,0 +1,73 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertMath.dtd">
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup();"
+>
+ <dialog
+ buttonlabelaccept="&insertButton.label;"
+ buttonaccesskeyaccept="&insertButton.accesskey;"
+ >
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdInsertMath.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <label id="srcMessage" value="&sourceEditField.label;" />
+ <html:textarea
+ id="input"
+ rows="5"
+ oninput="updateMath();"
+ placeholder="\sqrt{x_1} + \frac{Ï€^3}{2}"
+ />
+ <vbox flex="1" style="overflow: auto; width: 30em; height: 5em">
+ <description id="output" />
+ </vbox>
+ <tabbox id="tabboxInsertLaTeXCommand">
+ <tabs />
+ <tabpanels oncommand="insertLaTeXCommand(event.target);" />
+ </tabbox>
+ <spacer class="spacer" />
+ <html:fieldset>
+ <html:legend>&options.label;</html:legend>
+ <hbox>
+ <radiogroup id="optionMode" oncommand="updateMode();">
+ <radio
+ label="&optionInline.label;"
+ accesskey="&optionInline.accesskey;"
+ />
+ <radio
+ label="&optionDisplay.label;"
+ accesskey="&optionDisplay.accesskey;"
+ />
+ </radiogroup>
+ <radiogroup id="optionDirection" oncommand="updateDirection();">
+ <radio label="&optionLTR.label;" accesskey="&optionLTR.accesskey;" />
+ <radio label="&optionRTL.label;" accesskey="&optionRTL.accesskey;" />
+ </radiogroup>
+ </hbox>
+ </html:fieldset>
+ <spacer class="spacer" />
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTOC.js b/comm/mail/components/compose/content/dialogs/EdInsertTOC.js
new file mode 100644
index 0000000000..45d0972f3b
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertTOC.js
@@ -0,0 +1,378 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// tocHeadersArray is the array containing the pairs tag/class
+// defining TOC entries
+var tocHeadersArray = new Array(6);
+
+// a global used when building the TOC
+var currentHeaderLevel = 0;
+
+// a global set to true if the TOC is to be readonly
+var readonly = false;
+
+// a global set to true if user wants indexes in the TOC
+var orderedList = true;
+
+// constants
+const kMozToc = "mozToc";
+const kMozTocLength = 6;
+const kMozTocIdPrefix = "mozTocId";
+const kMozTocIdPrefixLength = 8;
+const kMozTocClassPrefix = "mozToc";
+const kMozTocClassPrefixLength = 6;
+
+document.addEventListener("dialogaccept", () => BuildTOC(true));
+
+// Startup() is called when EdInsertTOC.xhtml is opened
+function Startup() {
+ // early way out if if we have no editor
+ if (!GetCurrentEditor()) {
+ window.close();
+ return;
+ }
+
+ var i;
+ // clean the table of tag/class pairs we look for
+ for (i = 0; i < 6; ++i) {
+ tocHeadersArray[i] = ["", ""];
+ }
+
+ // reset all settings
+ for (i = 1; i < 7; ++i) {
+ var menulist = document.getElementById("header" + i + "Menulist");
+ var menuitem = document.getElementById("header" + i + "none");
+ var textbox = document.getElementById("header" + i + "Class");
+ menulist.selectedItem = menuitem;
+ textbox.setAttribute("disabled", "true");
+ }
+
+ var theDocument = GetCurrentEditor().document;
+
+ // do we already have a TOC in the document ? It should have "mozToc" ID
+ var toc = theDocument.getElementById(kMozToc);
+
+ // default TOC definition, use h1-h6 for TOC entry levels 1-6
+ var headers = "h1 1 h2 2 h3 3 h4 4 h5 5 h6 6";
+
+ var orderedListCheckbox = document.getElementById("orderedListCheckbox");
+ orderedListCheckbox.checked = true;
+
+ if (toc) {
+ // man, there is already a TOC here
+
+ if (toc.getAttribute("class") == "readonly") {
+ // and it's readonly
+ var checkbox = document.getElementById("readOnlyCheckbox");
+ checkbox.checked = true;
+ readonly = true;
+ }
+
+ // let's see if it's an OL or an UL
+ orderedList = toc.nodeName.toLowerCase() == "ol";
+ orderedListCheckbox.checked = orderedList;
+
+ var nodeList = toc.childNodes;
+ // let's look at the children of the TOC ; if we find a comment beginning
+ // with "mozToc", it contains the TOC definition
+ for (i = 0; i < nodeList.length; ++i) {
+ if (
+ nodeList.item(i).nodeType == Node.COMMENT_NODE &&
+ nodeList.item(i).data.startsWith(kMozToc)
+ ) {
+ // yep, there is already a definition here; parse it !
+ headers = nodeList
+ .item(i)
+ .data.substr(
+ kMozTocLength + 1,
+ nodeList.item(i).length - kMozTocLength - 1
+ );
+ break;
+ }
+ }
+ }
+
+ // let's get an array filled with the (tag.class, index level) pairs
+ var headersArray = headers.split(" ");
+
+ for (i = 0; i < headersArray.length; i += 2) {
+ var tag = headersArray[i],
+ className = "";
+ var index = headersArray[i + 1];
+ menulist = document.getElementById("header" + index + "Menulist");
+ if (menulist) {
+ var sep = tag.indexOf(".");
+ if (sep != -1) {
+ // the tag variable contains in fact "tag.className", let's parse
+ // the class and get the real tag name
+ var tmp = tag.substr(0, sep);
+ className = tag.substr(sep + 1, tag.length - sep - 1);
+ tag = tmp;
+ }
+
+ // update the dialog
+ menuitem = document.getElementById("header" + index + tag.toUpperCase());
+ textbox = document.getElementById("header" + index + "Class");
+ menulist.selectedItem = menuitem;
+ if (tag != "") {
+ textbox.removeAttribute("disabled");
+ }
+ if (className != "") {
+ textbox.value = className;
+ }
+ tocHeadersArray[index - 1] = [tag, className];
+ }
+ }
+}
+
+function BuildTOC(update) {
+ // controlClass() is a node filter that accepts a node if
+ // (a) we don't look for a class (b) we look for a class and
+ // node has it
+ function controlClass(node, index) {
+ currentHeaderLevel = index + 1;
+ if (tocHeadersArray[index][1] == "") {
+ // we are not looking for a specific class, this node is ok
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ if (node.getAttribute("class")) {
+ // yep, we look for a class, let's look at all the classes
+ // the node has
+ var classArray = node.getAttribute("class").split(" ");
+ for (var j = 0; j < classArray.length; j++) {
+ if (classArray[j] == tocHeadersArray[index][1]) {
+ // hehe, we found it...
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ }
+ }
+ return NodeFilter.FILTER_SKIP;
+ }
+
+ // the main node filter for our node iterator
+ // it selects the tag names as specified in the dialog
+ // then calls the controlClass filter above
+ function acceptNode(node) {
+ switch (node.nodeName.toLowerCase()) {
+ case tocHeadersArray[0][0]:
+ return controlClass(node, 0);
+ case tocHeadersArray[1][0]:
+ return controlClass(node, 1);
+ case tocHeadersArray[2][0]:
+ return controlClass(node, 2);
+ case tocHeadersArray[3][0]:
+ return controlClass(node, 3);
+ case tocHeadersArray[4][0]:
+ return controlClass(node, 4);
+ case tocHeadersArray[5][0]:
+ return controlClass(node, 5);
+ default:
+ return NodeFilter.FILTER_SKIP;
+ }
+ }
+
+ var editor = GetCurrentEditor();
+ var theDocument = editor.document;
+ // let's create a TreeWalker to look for our nodes
+ var treeWalker = theDocument.createTreeWalker(
+ theDocument.documentElement,
+ NodeFilter.SHOW_ELEMENT,
+ acceptNode,
+ true
+ );
+ // we need an array to store all TOC entries we find in the document
+ var tocArray = [];
+ if (treeWalker) {
+ var tocSourceNode = treeWalker.nextNode();
+ while (tocSourceNode) {
+ var headerIndex = currentHeaderLevel;
+
+ // we have a node, we need to get all its textual contents
+ var textTreeWalker = theDocument.createTreeWalker(
+ tocSourceNode,
+ NodeFilter.SHOW_TEXT,
+ null,
+ true
+ );
+ var textNode = textTreeWalker.nextNode(),
+ headerText = "";
+ while (textNode) {
+ headerText += textNode.data;
+ textNode = textTreeWalker.nextNode();
+ }
+
+ var anchor = tocSourceNode.firstChild,
+ id;
+ // do we have a named anchor as 1st child of our node ?
+ if (
+ anchor.nodeName.toLowerCase() == "a" &&
+ anchor.hasAttribute("name") &&
+ anchor.getAttribute("name").startsWith(kMozTocIdPrefix)
+ ) {
+ // yep, get its name
+ id = anchor.getAttribute("name");
+ } else {
+ // no we don't and we need to create one
+ anchor = theDocument.createElement("a");
+ tocSourceNode.insertBefore(anchor, tocSourceNode.firstChild);
+ // let's give it a random ID
+ var c = 1000000 * Math.random();
+ id = kMozTocIdPrefix + Math.round(c);
+ anchor.setAttribute("name", id);
+ anchor.setAttribute(
+ "class",
+ kMozTocClassPrefix + tocSourceNode.nodeName.toUpperCase()
+ );
+ }
+ // and store that new entry in our array
+ tocArray.push(headerIndex, headerText, id);
+ tocSourceNode = treeWalker.nextNode();
+ }
+ }
+
+ /* generate the TOC itself */
+ headerIndex = 0;
+ var item, toc;
+ for (var i = 0; i < tocArray.length; i += 3) {
+ if (!headerIndex) {
+ // do we need to create an ol/ul container for the first entry ?
+ ++headerIndex;
+ toc = theDocument.getElementById(kMozToc);
+ if (!toc || !update) {
+ // we need to create a list container for the table of contents
+ toc = GetCurrentEditor().createElementWithDefaults(
+ orderedList ? "ol" : "ul"
+ );
+ // grrr, we need to create a LI inside the list otherwise
+ // Composer will refuse an empty list and will remove it !
+ var pit = theDocument.createElement("li");
+ toc.appendChild(pit);
+ GetCurrentEditor().insertElementAtSelection(toc, true);
+ // ah, now it's inserted so let's remove the useless list item...
+ toc.removeChild(pit);
+ // we need to recognize later that this list is our TOC
+ toc.setAttribute("id", kMozToc);
+ } else if (orderedList != (toc.nodeName.toLowerCase() == "ol")) {
+ // we have to update an existing TOC, is the existing TOC of the
+ // desired type (ordered or not) ?
+
+ // nope, we have to recreate the list
+ var newToc = GetCurrentEditor().createElementWithDefaults(
+ orderedList ? "ol" : "ul"
+ );
+ toc.parentNode.insertBefore(newToc, toc);
+ // and remove the old one
+ toc.remove();
+ toc = newToc;
+ toc.setAttribute("id", kMozToc);
+ } else {
+ // we can keep the list itself but let's get rid of the TOC entries
+ while (toc.hasChildNodes()) {
+ toc.lastChild.remove();
+ }
+ }
+
+ var commentText = "mozToc ";
+ for (var j = 0; j < 6; j++) {
+ if (tocHeadersArray[j][0] != "") {
+ commentText += tocHeadersArray[j][0];
+ if (tocHeadersArray[j][1] != "") {
+ commentText += "." + tocHeadersArray[j][1];
+ }
+ commentText += " " + (j + 1) + " ";
+ }
+ }
+ // important, we have to remove trailing spaces
+ commentText = TrimStringRight(commentText);
+
+ // forge a comment we'll insert in the TOC ; that comment will hold
+ // the TOC definition for us
+ var ct = theDocument.createComment(commentText);
+ toc.appendChild(ct);
+
+ // assign a special class to the TOC top element if the TOC is readonly
+ // the definition of this class is in EditorOverride.css
+ if (readonly) {
+ toc.setAttribute("class", "readonly");
+ } else {
+ toc.removeAttribute("class");
+ }
+
+ // We need a new variable to hold the local ul/ol container
+ // The toplevel TOC element is not the parent element of a
+ // TOC entry if its depth is > 1...
+ var tocList = toc;
+ // create a list item
+ var tocItem = theDocument.createElement("li");
+ // and an anchor in this list item
+ var tocAnchor = theDocument.createElement("a");
+ // make it target the source of the TOC entry
+ tocAnchor.setAttribute("href", "#" + tocArray[i + 2]);
+ // and put the textual contents of the TOC entry in that anchor
+ var tocEntry = theDocument.createTextNode(tocArray[i + 1]);
+ // now, insert everything where it has to be inserted
+ tocAnchor.appendChild(tocEntry);
+ tocItem.appendChild(tocAnchor);
+ tocList.appendChild(tocItem);
+ item = tocList;
+ } else {
+ if (tocArray[i] < headerIndex) {
+ // if the depth of the new TOC entry is less than the depth of the
+ // last entry we created, find the good ul/ol ancestor
+ for (j = headerIndex - tocArray[i]; j > 0; --j) {
+ if (item != toc) {
+ item = item.parentNode.parentNode;
+ }
+ }
+ tocItem = theDocument.createElement("li");
+ } else if (tocArray[i] > headerIndex) {
+ // to the contrary, it's deeper than the last one
+ // we need to create sub ul/ol's and li's
+ for (j = tocArray[i] - headerIndex; j > 0; --j) {
+ tocList = theDocument.createElement(orderedList ? "ol" : "ul");
+ item.lastChild.appendChild(tocList);
+ tocItem = theDocument.createElement("li");
+ tocList.appendChild(tocItem);
+ item = tocList;
+ }
+ } else {
+ tocItem = theDocument.createElement("li");
+ }
+ tocAnchor = theDocument.createElement("a");
+ tocAnchor.setAttribute("href", "#" + tocArray[i + 2]);
+ tocEntry = theDocument.createTextNode(tocArray[i + 1]);
+ tocAnchor.appendChild(tocEntry);
+ tocItem.appendChild(tocAnchor);
+ item.appendChild(tocItem);
+ headerIndex = tocArray[i];
+ }
+ }
+ SaveWindowLocation();
+}
+
+function selectHeader(elt, index) {
+ var tag = elt.value;
+ tocHeadersArray[index - 1][0] = tag;
+ var textbox = document.getElementById("header" + index + "Class");
+ if (tag == "") {
+ textbox.setAttribute("disabled", "true");
+ } else {
+ textbox.removeAttribute("disabled");
+ }
+}
+
+function changeClass(elt, index) {
+ tocHeadersArray[index - 1][1] = elt.value;
+}
+
+function ToggleReadOnlyToc(elt) {
+ readonly = elt.checked;
+}
+
+function ToggleOrderedList(elt) {
+ orderedList = elt.checked;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml
new file mode 100644
index 0000000000..38c85c764d
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml
@@ -0,0 +1,505 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertTOC.dtd">
+
+<window
+ title="&Window.title;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="Startup();"
+ lightweightthemes="true"
+ oncancel="window.close(); return true;"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdInsertTOC.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+ <spacer id="dummy" style="display: none" />
+ <vbox flex="1">
+ <html:fieldset>
+ <html:legend>&buildToc.label;</html:legend>
+ <html:table>
+ <html:tr>
+ <html:th></html:th>
+ <html:th>&tag.label;</html:th>
+ <html:th>&class.label;</html:th>
+ </html:tr>
+ <html:tr>
+ <html:th id="header1Label">&header1.label;</html:th>
+ <html:td>
+ <menulist id="header1Menulist">
+ <menupopup>
+ <menuitem
+ id="header1none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header1H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 1)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header1Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 1)"
+ aria-labelledby="header1Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header2Label">&header2.label;</html:th>
+ <html:td>
+ <menulist id="header2Menulist">
+ <menupopup>
+ <menuitem
+ id="header2none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header2H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 2)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header2Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 2)"
+ aria-labelledby="header2Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header3Label">&header3.label;</html:th>
+ <html:td>
+ <menulist id="header3Menulist">
+ <menupopup>
+ <menuitem
+ id="header3none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header3H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 3)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header3Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 3)"
+ aria-labelledby="header3Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header4Label">&header4.label;</html:th>
+ <html:td>
+ <menulist id="header4Menulist">
+ <menupopup>
+ <menuitem
+ id="header4none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header4H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 4)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header4Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 4)"
+ aria-labelledby="header4Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header5Label">&header5.label;</html:th>
+ <html:td>
+ <menulist id="header5Menulist">
+ <menupopup>
+ <menuitem
+ id="header5none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header5H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 5)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header5Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 5)"
+ aria-labelledby="header5Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header6Label">&header6.label;</html:th>
+ <html:td>
+ <menulist id="header6Menulist">
+ <menupopup>
+ <menuitem
+ id="header6none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header6H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 6)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header6Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 6)"
+ aria-labelledby="header6Label"
+ />
+ </html:td>
+ </html:tr>
+ </html:table>
+ </html:fieldset>
+ <vbox>
+ <checkbox
+ id="orderedListCheckbox"
+ label="&orderedList.label;"
+ oncommand="ToggleOrderedList(this)"
+ />
+ <checkbox
+ id="readOnlyCheckbox"
+ label="&makeReadOnly.label;"
+ oncommand="ToggleReadOnlyToc(this)"
+ />
+ </vbox>
+ <separator class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTable.js b/comm/mail/components/compose/content/dialogs/EdInsertTable.js
new file mode 100644
index 0000000000..5da0da46d3
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertTable.js
@@ -0,0 +1,258 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+
+var gTableElement = null;
+var gRows;
+var gColumns;
+var gActiveEditor;
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ gActiveEditor = GetCurrentTableEditor();
+ if (!gActiveEditor) {
+ dump("Failed to get active editor!\n");
+ window.close();
+ return;
+ }
+
+ try {
+ gTableElement = gActiveEditor.createElementWithDefaults("table");
+ } catch (e) {}
+
+ if (!gTableElement) {
+ dump("Failed to create a new table!\n");
+ window.close();
+ return;
+ }
+ gDialog.rowsInput = document.getElementById("rowsInput");
+ gDialog.columnsInput = document.getElementById("columnsInput");
+ gDialog.widthInput = document.getElementById("widthInput");
+ gDialog.borderInput = document.getElementById("borderInput");
+ gDialog.widthPixelOrPercentMenulist = document.getElementById(
+ "widthPixelOrPercentMenulist"
+ );
+ gDialog.OkButton = document.querySelector("dialog").getButton("accept");
+
+ // Make a copy to use for AdvancedEdit
+ globalElement = gTableElement.cloneNode(false);
+ try {
+ if (
+ Services.prefs.getBoolPref("editor.use_css") &&
+ IsHTMLEditor() &&
+ !(gActiveEditor.flags & Ci.nsIEditor.eEditorMailMask)
+ ) {
+ // only for Composer and not for htmlmail
+ globalElement.setAttribute("style", "text-align: left;");
+ }
+ } catch (e) {}
+
+ // Initialize all widgets with image attributes
+ InitDialog();
+
+ // Set initial number to 2 rows, 2 columns:
+ // Note, these are not attributes on the table,
+ // so don't put them in InitDialog(),
+ // else the user's values will be trashed when they use
+ // the Advanced Edit dialog
+ gDialog.rowsInput.value = 2;
+ gDialog.columnsInput.value = 2;
+
+ // If no default value on the width, set to 100%
+ if (gDialog.widthInput.value.length == 0) {
+ gDialog.widthInput.value = "100";
+ gDialog.widthPixelOrPercentMenulist.selectedIndex = 1;
+ }
+
+ SetTextboxFocusById("rowsInput");
+
+ SetWindowLocation();
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitDialog() {
+ // Get default attributes set on the created table:
+ // Get the width attribute of the element, stripping out "%"
+ // This sets contents of menu combobox list
+ // 2nd param = null: Use current selection to find if parent is table cell or window
+ gDialog.widthInput.value = InitPixelOrPercentMenulist(
+ globalElement,
+ null,
+ "width",
+ "widthPixelOrPercentMenulist",
+ gPercent
+ );
+ gDialog.borderInput.value = globalElement.getAttribute("border");
+}
+
+function ChangeRowOrColumn(id) {
+ // Allow only integers
+ forceInteger(id);
+
+ // Enable OK only if both rows and columns have a value > 0
+ var enable =
+ gDialog.rowsInput.value.length > 0 &&
+ gDialog.rowsInput.value > 0 &&
+ gDialog.columnsInput.value.length > 0 &&
+ gDialog.columnsInput.value > 0;
+
+ SetElementEnabled(gDialog.OkButton, enable);
+ SetElementEnabledById("AdvancedEditButton1", enable);
+}
+
+// Get and validate data from widgets.
+// Set attributes on globalElement so they can be accessed by AdvancedEdit()
+function ValidateData() {
+ gRows = ValidateNumber(
+ gDialog.rowsInput,
+ null,
+ 1,
+ gMaxRows,
+ null,
+ null,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ gColumns = ValidateNumber(
+ gDialog.columnsInput,
+ null,
+ 1,
+ gMaxColumns,
+ null,
+ null,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ // Set attributes: NOTE: These may be empty strings (last param = false)
+ ValidateNumber(
+ gDialog.borderInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalElement,
+ "border",
+ false
+ );
+ // TODO: Deal with "BORDER" without value issue
+ if (gValidationError) {
+ return false;
+ }
+
+ ValidateNumber(
+ gDialog.widthInput,
+ gDialog.widthPixelOrPercentMenulist,
+ 1,
+ gMaxTableSize,
+ globalElement,
+ "width",
+ false
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ return true;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ gActiveEditor.beginTransaction();
+ try {
+ gActiveEditor.cloneAttributes(gTableElement, globalElement);
+
+ // Create necessary rows and cells for the table
+ var tableBody = gActiveEditor.createElementWithDefaults("tbody");
+ if (tableBody) {
+ gTableElement.appendChild(tableBody);
+
+ // Create necessary rows and cells for the table
+ for (var i = 0; i < gRows; i++) {
+ var newRow = gActiveEditor.createElementWithDefaults("tr");
+ if (newRow) {
+ tableBody.appendChild(newRow);
+ for (var j = 0; j < gColumns; j++) {
+ var newCell = gActiveEditor.createElementWithDefaults("td");
+ if (newCell) {
+ newRow.appendChild(newCell);
+ }
+ }
+ }
+ }
+ }
+ // Detect when entire cells are selected:
+ // Get number of cells selected
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ var element = gActiveEditor.getSelectedOrParentTableElement(
+ tagNameObj,
+ countObj
+ );
+ var deletePlaceholder = false;
+
+ if (tagNameObj.value == "table") {
+ // Replace entire selected table with new table, so delete the table
+ gActiveEditor.deleteTable();
+ } else if (tagNameObj.value == "td") {
+ if (countObj.value >= 1) {
+ if (countObj.value > 1) {
+ // Assume user wants to replace a block of
+ // contiguous cells with a table, so
+ // join the selected cells
+ gActiveEditor.joinTableCells(false);
+
+ // Get the cell everything was merged into
+ element = gActiveEditor.getSelectedCells()[0];
+
+ // Collapse selection into just that cell
+ gActiveEditor.selection.collapse(element, 0);
+ }
+
+ if (element) {
+ // Empty just the contents of the cell
+ gActiveEditor.deleteTableCellContents();
+
+ // Collapse selection to start of empty cell...
+ gActiveEditor.selection.collapse(element, 0);
+ // ...but it will contain a <br> placeholder
+ deletePlaceholder = true;
+ }
+ }
+ }
+
+ // true means delete selection when inserting
+ gActiveEditor.insertElementAtSelection(gTableElement, true);
+
+ if (
+ deletePlaceholder &&
+ gTableElement &&
+ gTableElement.nextElementSibling
+ ) {
+ // Delete the placeholder <br>
+ gActiveEditor.deleteNode(gTableElement.nextElementSibling);
+ }
+ } catch (e) {}
+
+ gActiveEditor.endTransaction();
+
+ SaveWindowLocation();
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml
new file mode 100644
index 0000000000..b114e09d44
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml
@@ -0,0 +1,126 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edInsertTable SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertTable.dtd">
+%edInsertTable;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdInsertTable.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ control="rowsInput"
+ value="&numRowsEditField.label;"
+ accesskey="&numRowsEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="rowsInput"
+ type="number"
+ class="narrow input-inline"
+ oninput="ChangeRowOrColumn(this.id)"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ control="columnsInput"
+ value="&numColumnsEditField.label;"
+ accesskey="&numColumnsEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="columnsInput"
+ type="number"
+ class="narrow input-inline"
+ oninput="ChangeRowOrColumn(this.id)"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ control="widthInput"
+ value="&widthEditField.label;"
+ accesskey="&widthEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="widthInput"
+ type="number"
+ class="narrow input-inline"
+ oninput="forceInteger(this.id)"
+ />
+ </html:td>
+ <html:td>
+ <menulist id="widthPixelOrPercentMenulist" class="menulist-narrow" />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ control="borderInput"
+ value="&borderEditField.label;"
+ accesskey="&borderEditField.accessKey;"
+ tooltiptext="&borderEditField.tooltip;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="borderInput"
+ type="number"
+ class="narrow input-inline"
+ oninput="forceInteger(this.id)"
+ />
+ </html:td>
+ <html:td>
+ <label value="&pixels.label;" />
+ </html:td>
+ </html:tr>
+ </html:table>
+ <vbox id="AdvancedEdit">
+ <hbox flex="1" style="margin-top: 0.2em" align="center">
+ <!-- This will right-align the button -->
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator id="advancedSeparator" class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdLinkProps.js b/comm/mail/components/compose/content/dialogs/EdLinkProps.js
new file mode 100644
index 0000000000..903a4d3099
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdLinkProps.js
@@ -0,0 +1,323 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gActiveEditor;
+var anchorElement = null;
+var imageElement = null;
+var insertNew = false;
+var replaceExistingLink = false;
+var insertLinkAtCaret;
+var needLinkText = false;
+var href;
+var newLinkText;
+var gHNodeArray = {};
+var gHaveNamedAnchors = false;
+var gHaveHeadings = false;
+var gCanChangeHeadingSelected = true;
+var gCanChangeAnchorSelected = true;
+
+// NOTE: Use "href" instead of "a" to distinguish from Named Anchor
+// The returned node is has an "a" tagName
+var tagName = "href";
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ gActiveEditor = GetCurrentEditor();
+ if (!gActiveEditor) {
+ dump("Failed to get active editor!\n");
+ window.close();
+ return;
+ }
+ // Message was wrapped in a <label> or <div>, so actual text is a child text node
+ gDialog.linkTextCaption = document.getElementById("linkTextCaption");
+ gDialog.linkTextMessage = document.getElementById("linkTextMessage");
+ gDialog.linkTextInput = document.getElementById("linkTextInput");
+ gDialog.hrefInput = document.getElementById("hrefInput");
+ gDialog.makeRelativeLink = document.getElementById("MakeRelativeLink");
+ gDialog.AdvancedEditSection = document.getElementById("AdvancedEdit");
+
+ // See if we have a single selected image
+ imageElement = gActiveEditor.getSelectedElement("img");
+
+ if (imageElement) {
+ // Get the parent link if it exists -- more efficient than GetSelectedElement()
+ anchorElement = gActiveEditor.getElementOrParentByTagName(
+ "href",
+ imageElement
+ );
+ if (anchorElement) {
+ if (anchorElement.children.length > 1) {
+ // If there are other children, then we want to break
+ // this image away by inserting a new link around it,
+ // so make a new node and copy existing attributes
+ anchorElement = anchorElement.cloneNode(false);
+ // insertNew = true;
+ replaceExistingLink = true;
+ }
+ }
+ } else {
+ // Get an anchor element if caret or
+ // entire selection is within the link.
+ anchorElement = gActiveEditor.getSelectedElement(tagName);
+
+ if (anchorElement) {
+ // Select the entire link
+ gActiveEditor.selectElement(anchorElement);
+ } else {
+ // If selection starts in a link, but extends beyond it,
+ // the user probably wants to extend existing link to new selection,
+ // so check if either end of selection is within a link
+ // POTENTIAL PROBLEM: This prevents user from selecting text in an existing
+ // link and making 2 links.
+ // Note that this isn't a problem with images, handled above
+
+ anchorElement = gActiveEditor.getElementOrParentByTagName(
+ "href",
+ gActiveEditor.selection.anchorNode
+ );
+ if (!anchorElement) {
+ anchorElement = gActiveEditor.getElementOrParentByTagName(
+ "href",
+ gActiveEditor.selection.focusNode
+ );
+ }
+
+ if (anchorElement) {
+ // But clone it for reinserting/merging around existing
+ // link that only partially overlaps the selection
+ anchorElement = anchorElement.cloneNode(false);
+ // insertNew = true;
+ replaceExistingLink = true;
+ }
+ }
+ }
+
+ if (!anchorElement) {
+ // No existing link -- create a new one
+ anchorElement = gActiveEditor.createElementWithDefaults(tagName);
+ insertNew = true;
+ // Hide message about removing existing link
+ // document.getElementById("RemoveLinkMsg").hidden = true;
+ }
+ if (!anchorElement) {
+ dump("Failed to get selected element or create a new one!\n");
+ window.close();
+ return;
+ }
+
+ // We insert at caret only when nothing is selected
+ insertLinkAtCaret = gActiveEditor.selection.isCollapsed;
+
+ var selectedText;
+ if (insertLinkAtCaret) {
+ // Groupbox caption:
+ gDialog.linkTextCaption.setAttribute("label", GetString("LinkText"));
+
+ // Message above input field:
+ gDialog.linkTextMessage.setAttribute("value", GetString("EnterLinkText"));
+ gDialog.linkTextMessage.setAttribute(
+ "accesskey",
+ GetString("EnterLinkTextAccessKey")
+ );
+ } else {
+ if (!imageElement) {
+ // We get here if selection is exactly around a link node
+ // Check if selection has some text - use that first
+ selectedText = GetSelectionAsText();
+ if (!selectedText) {
+ // No text, look for first image in the selection
+ imageElement = anchorElement.querySelector("img");
+ }
+ }
+ // Set "caption" for link source and the source text or image URL
+ if (imageElement) {
+ gDialog.linkTextCaption.setAttribute("label", GetString("LinkImage"));
+ // Link source string is the source URL of image
+ // TODO: THIS DOESN'T HANDLE MULTIPLE SELECTED IMAGES!
+ gDialog.linkTextMessage.setAttribute("value", imageElement.src);
+ } else {
+ gDialog.linkTextCaption.setAttribute("label", GetString("LinkText"));
+ if (selectedText) {
+ // Use just the first 60 characters and add "..."
+ gDialog.linkTextMessage.setAttribute(
+ "value",
+ TruncateStringAtWordEnd(
+ ReplaceWhitespace(selectedText, " "),
+ 60,
+ true
+ )
+ );
+ } else {
+ gDialog.linkTextMessage.setAttribute(
+ "value",
+ GetString("MixedSelection")
+ );
+ }
+ }
+ }
+
+ // Make a copy to use for AdvancedEdit and onSaveDefault
+ globalElement = anchorElement.cloneNode(false);
+
+ // Get the list of existing named anchors and headings
+ FillLinkMenulist(gDialog.hrefInput, gHNodeArray);
+
+ // We only need to test for this once per dialog load
+ gHaveDocumentUrl = GetDocumentBaseUrl();
+
+ // Set data for the dialog controls
+ InitDialog();
+
+ // Search for a URI pattern in the selected text
+ // as candidate href
+ selectedText = TrimString(selectedText);
+ if (!gDialog.hrefInput.value && TextIsURI(selectedText)) {
+ gDialog.hrefInput.value = selectedText;
+ }
+
+ // Set initial focus
+ if (insertLinkAtCaret) {
+ // We will be using the HREF inputbox, so text message
+ gDialog.linkTextInput.focus();
+ } else {
+ gDialog.hrefInput.select();
+ gDialog.hrefInput.focus();
+
+ // We will not insert a new link at caret, so remove link text input field
+ gDialog.linkTextInput.hidden = true;
+ gDialog.linkTextInput = null;
+ }
+
+ // This sets enable state on OK button
+ doEnabling();
+
+ SetWindowLocation();
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitDialog() {
+ // Must use getAttribute, not "globalElement.href",
+ // or foreign chars aren't converted correctly!
+ gDialog.hrefInput.value = globalElement.getAttribute("href");
+
+ // Set "Relativize" checkbox according to current URL state
+ SetRelativeCheckbox(gDialog.makeRelativeLink);
+}
+
+function doEnabling() {
+ // We disable Ok button when there's no href text only if inserting a new link
+ var enable = insertNew
+ ? TrimString(gDialog.hrefInput.value).length > 0
+ : true;
+
+ // anon. content, so can't use SetElementEnabledById here
+ var dialogNode = document.getElementById("linkDlg");
+ dialogNode.getButton("accept").disabled = !enable;
+
+ SetElementEnabledById("AdvancedEditButton1", enable);
+}
+
+function ChangeLinkLocation() {
+ SetRelativeCheckbox(gDialog.makeRelativeLink);
+ // Set OK button enable state
+ doEnabling();
+}
+
+// Get and validate data from widgets.
+// Set attributes on globalElement so they can be accessed by AdvancedEdit()
+function ValidateData() {
+ href = TrimString(gDialog.hrefInput.value);
+ if (href) {
+ // Set the HREF directly on the editor document's anchor node
+ // or on the newly-created node if insertNew is true
+ globalElement.setAttribute("href", href);
+ } else if (insertNew) {
+ // We must have a URL to insert a new link
+ // NOTE: We accept an empty HREF on existing link to indicate removing the link
+ ShowInputErrorMessage(GetString("EmptyHREFError"));
+ return false;
+ }
+ if (gDialog.linkTextInput) {
+ // The text we will insert isn't really an attribute,
+ // but it makes sense to validate it
+ newLinkText = TrimString(gDialog.linkTextInput.value);
+ if (!newLinkText) {
+ if (href) {
+ newLinkText = href;
+ } else {
+ ShowInputErrorMessage(GetString("EmptyLinkTextError"));
+ SetTextboxFocus(gDialog.linkTextInput);
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ if (href.length > 0) {
+ // Copy attributes to element we are changing or inserting
+ gActiveEditor.cloneAttributes(anchorElement, globalElement);
+
+ // Coalesce into one undo transaction
+ gActiveEditor.beginTransaction();
+
+ // Get text to use for a new link
+ if (insertLinkAtCaret) {
+ // Append the link text as the last child node
+ // of the anchor node
+ var textNode = gActiveEditor.document.createTextNode(newLinkText);
+ if (textNode) {
+ anchorElement.appendChild(textNode);
+ }
+ try {
+ gActiveEditor.insertElementAtSelection(anchorElement, false);
+ } catch (e) {
+ dump("Exception occurred in InsertElementAtSelection\n");
+ return;
+ }
+ } else if (insertNew || replaceExistingLink) {
+ // Link source was supplied by the selection,
+ // so insert a link node as parent of this
+ // (may be text, image, or other inline content)
+ try {
+ gActiveEditor.insertLinkAroundSelection(anchorElement);
+ } catch (e) {
+ dump("Exception occurred in InsertElementAtSelection\n");
+ return;
+ }
+ }
+ // Check if the link was to a heading
+ if (href in gHNodeArray) {
+ var anchorNode = gActiveEditor.createElementWithDefaults("a");
+ if (anchorNode) {
+ anchorNode.name = href.substr(1);
+
+ // Insert the anchor into the document,
+ // but don't let the transaction change the selection
+ gActiveEditor.setShouldTxnSetSelection(false);
+ gActiveEditor.insertNode(anchorNode, gHNodeArray[href], 0);
+ gActiveEditor.setShouldTxnSetSelection(true);
+ }
+ }
+ gActiveEditor.endTransaction();
+ } else if (!insertNew) {
+ // We already had a link, but empty HREF means remove it
+ EditorRemoveTextProperty("href", "");
+ }
+ SaveWindowLocation();
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml b/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml
new file mode 100644
index 0000000000..7c550a7a45
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml
@@ -0,0 +1,112 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % linkPropertiesDTD SYSTEM "chrome://messenger/locale/messengercompose/EditorLinkProperties.dtd">
+%linkPropertiesDTD;
+<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd">
+%composeEditorOverlayDTD;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+ style="min-height: 26em"
+>
+ <dialog id="linkDlg" style="width: 50ch">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdLinkProps.js" />
+ <script src="chrome://messenger/content/messengercompose/EdImageLinkLoader.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <vbox>
+ <html:fieldset>
+ <html:legend><label id="linkTextCaption" /></html:legend>
+ <vbox>
+ <label id="linkTextMessage" control="linkTextInput" />
+ <html:input
+ id="linkTextInput"
+ type="text"
+ class="input-inline"
+ aria-labelledby="linkTextMessage"
+ />
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="LinkURLBox">
+ <html:legend>&LinkURLBox.label;</html:legend>
+ <vbox id="LinkLocationBox">
+ <label
+ id="hrefLabel"
+ control="hrefInput"
+ accesskey="&LinkURLEditField2.accessKey;"
+ width="1"
+ >&LinkURLEditField2.label;</label
+ >
+ <html:input
+ id="hrefInput"
+ type="text"
+ class="input-inline uri-element padded"
+ oninput="ChangeLinkLocation();"
+ aria-labelledby="hrefLabel"
+ />
+ <hbox align="center">
+ <checkbox
+ id="MakeRelativeLink"
+ for="hrefInput"
+ label="&makeUrlRelative.label;"
+ accesskey="&makeUrlRelative.accessKey;"
+ oncommand="MakeInputValueRelativeOrAbsolute(this);"
+ tooltiptext="&makeUrlRelative.tooltip;"
+ />
+ <spacer flex="1" />
+ <button
+ label="&chooseFileLinkButton.label;"
+ accesskey="&chooseFileLinkButton.accessKey;"
+ oncommand="chooseLinkFile();"
+ />
+ </hbox>
+ </vbox>
+ <checkbox
+ id="AttachSourceToMail"
+ hidden="true"
+ label="&attachLinkSource.label;"
+ accesskey="&attachLinkSource.accesskey;"
+ oncommand="DoAttachSourceCheckbox()"
+ />
+ </html:fieldset>
+ </vbox>
+ <vbox id="AdvancedEdit">
+ <hbox flex="1" style="margin-top: 0.2em" align="center">
+ <!-- This will right-align the button -->
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator id="advancedSeparator" class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdListProps.js b/comm/mail/components/compose/content/dialogs/EdListProps.js
new file mode 100644
index 0000000000..c33efc9bb1
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdListProps.js
@@ -0,0 +1,455 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+var gBulletStyleType = "";
+var gNumberStyleType = "";
+var gListElement;
+var gOriginalListType = "";
+var gListType = "";
+var gMixedListSelection = false;
+var gStyleType = "";
+var gOriginalStyleType = "";
+const gOnesArray = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"];
+const gTensArray = ["", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"];
+const gHundredsArray = [
+ "",
+ "C",
+ "CC",
+ "CCC",
+ "CD",
+ "D",
+ "DC",
+ "DCC",
+ "DCCC",
+ "CM",
+];
+const gThousandsArray = [
+ "",
+ "M",
+ "MM",
+ "MMM",
+ "MMMM",
+ "MMMMM",
+ "MMMMMM",
+ "MMMMMMM",
+ "MMMMMMMM",
+ "MMMMMMMMM",
+];
+const gRomanDigits = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
+const A = "A".charCodeAt(0);
+const gArabic = "1";
+const gUpperRoman = "I";
+const gLowerRoman = "i";
+const gUpperLetters = "A";
+const gLowerLetters = "a";
+const gDecimalCSS = "decimal";
+const gUpperRomanCSS = "upper-roman";
+const gLowerRomanCSS = "lower-roman";
+const gUpperAlphaCSS = "upper-alpha";
+const gLowerAlphaCSS = "lower-alpha";
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+ gDialog.ListTypeList = document.getElementById("ListType");
+ gDialog.BulletStyleList = document.getElementById("BulletStyle");
+ gDialog.BulletStyleLabel = document.getElementById("BulletStyleLabel");
+ gDialog.StartingNumberInput = document.getElementById("StartingNumber");
+ gDialog.StartingNumberLabel = document.getElementById("StartingNumberLabel");
+ gDialog.AdvancedEditButton = document.getElementById("AdvancedEditButton1");
+ gDialog.RadioGroup = document.getElementById("RadioGroup");
+ gDialog.ChangeAllRadio = document.getElementById("ChangeAll");
+ gDialog.ChangeSelectedRadio = document.getElementById("ChangeSelected");
+
+ // Try to get an existing list(s)
+ var mixedObj = { value: null };
+ try {
+ gListType = editor.getListState(mixedObj, {}, {}, {});
+
+ // We may have mixed list and non-list, or > 1 list type in selection
+ gMixedListSelection = mixedObj.value;
+
+ // Get the list element at the anchor node
+ gListElement = editor.getElementOrParentByTagName("list", null);
+ } catch (e) {}
+
+ // The copy to use in AdvancedEdit
+ if (gListElement) {
+ globalElement = gListElement.cloneNode(false);
+ }
+
+ // Show extra options for changing entire list if we have one already.
+ gDialog.RadioGroup.collapsed = !gListElement;
+ if (gListElement) {
+ // Radio button index is persistent
+ if (gDialog.RadioGroup.getAttribute("index") == "1") {
+ gDialog.RadioGroup.selectedItem = gDialog.ChangeSelectedRadio;
+ } else {
+ gDialog.RadioGroup.selectedItem = gDialog.ChangeAllRadio;
+ }
+ }
+
+ InitDialog();
+
+ gOriginalListType = gListType;
+
+ gDialog.ListTypeList.focus();
+
+ SetWindowLocation();
+}
+
+function InitDialog() {
+ // Note that if mixed, we we pay attention
+ // only to the anchor node's list type
+ // (i.e., don't confuse user with "mixed" designation)
+ if (gListElement) {
+ gListType = gListElement.nodeName.toLowerCase();
+ } else {
+ gListType = "";
+ }
+
+ gDialog.ListTypeList.value = gListType;
+ gDialog.StartingNumberInput.value = "";
+
+ // Last param = true means attribute value is case-sensitive
+ var type = globalElement
+ ? GetHTMLOrCSSStyleValue(globalElement, "type", "list-style-type")
+ : null;
+
+ if (gListType == "ul") {
+ if (type) {
+ type = type.toLowerCase();
+ gBulletStyleType = type;
+ gOriginalStyleType = type;
+ }
+ } else if (gListType == "ol") {
+ // Translate CSS property strings
+ switch (type.toLowerCase()) {
+ case gDecimalCSS:
+ type = gArabic;
+ break;
+ case gUpperRomanCSS:
+ type = gUpperRoman;
+ break;
+ case gLowerRomanCSS:
+ type = gLowerRoman;
+ break;
+ case gUpperAlphaCSS:
+ type = gUpperLetters;
+ break;
+ case gLowerAlphaCSS:
+ type = gLowerLetters;
+ break;
+ }
+ if (type) {
+ gNumberStyleType = type;
+ gOriginalStyleType = type;
+ }
+
+ // Convert attribute number to appropriate letter or roman numeral
+ gDialog.StartingNumberInput.value = ConvertStartAttrToUserString(
+ globalElement.getAttribute("start"),
+ type
+ );
+ }
+ BuildBulletStyleList();
+}
+
+// Convert attribute number to appropriate letter or roman numeral
+function ConvertStartAttrToUserString(startAttr, type) {
+ switch (type) {
+ case gUpperRoman:
+ startAttr = ConvertArabicToRoman(startAttr);
+ break;
+ case gLowerRoman:
+ startAttr = ConvertArabicToRoman(startAttr).toLowerCase();
+ break;
+ case gUpperLetters:
+ startAttr = ConvertArabicToLetters(startAttr);
+ break;
+ case gLowerLetters:
+ startAttr = ConvertArabicToLetters(startAttr).toLowerCase();
+ break;
+ }
+ return startAttr;
+}
+
+function BuildBulletStyleList() {
+ gDialog.BulletStyleList.removeAllItems();
+ var label;
+
+ if (gListType == "ul") {
+ gDialog.BulletStyleList.removeAttribute("disabled");
+ gDialog.BulletStyleLabel.removeAttribute("disabled");
+ gDialog.StartingNumberInput.setAttribute("disabled", "true");
+ gDialog.StartingNumberLabel.setAttribute("disabled", "true");
+
+ label = GetString("BulletStyle");
+
+ gDialog.BulletStyleList.appendItem(GetString("Automatic"), "");
+ gDialog.BulletStyleList.appendItem(GetString("SolidCircle"), "disc");
+ gDialog.BulletStyleList.appendItem(GetString("OpenCircle"), "circle");
+ gDialog.BulletStyleList.appendItem(GetString("SolidSquare"), "square");
+
+ gDialog.BulletStyleList.value = gBulletStyleType;
+ } else if (gListType == "ol") {
+ gDialog.BulletStyleList.removeAttribute("disabled");
+ gDialog.BulletStyleLabel.removeAttribute("disabled");
+ gDialog.StartingNumberInput.removeAttribute("disabled");
+ gDialog.StartingNumberLabel.removeAttribute("disabled");
+ label = GetString("NumberStyle");
+
+ gDialog.BulletStyleList.appendItem(GetString("Automatic"), "");
+ gDialog.BulletStyleList.appendItem(GetString("Style_1"), gArabic);
+ gDialog.BulletStyleList.appendItem(GetString("Style_I"), gUpperRoman);
+ gDialog.BulletStyleList.appendItem(GetString("Style_i"), gLowerRoman);
+ gDialog.BulletStyleList.appendItem(GetString("Style_A"), gUpperLetters);
+ gDialog.BulletStyleList.appendItem(GetString("Style_a"), gLowerLetters);
+
+ gDialog.BulletStyleList.value = gNumberStyleType;
+ } else {
+ gDialog.BulletStyleList.setAttribute("disabled", "true");
+ gDialog.BulletStyleLabel.setAttribute("disabled", "true");
+ gDialog.StartingNumberInput.setAttribute("disabled", "true");
+ gDialog.StartingNumberLabel.setAttribute("disabled", "true");
+ }
+
+ // Disable advanced edit button if changing to "normal"
+ if (gListType) {
+ gDialog.AdvancedEditButton.removeAttribute("disabled");
+ } else {
+ gDialog.AdvancedEditButton.setAttribute("disabled", "true");
+ }
+
+ if (label) {
+ gDialog.BulletStyleLabel.textContent = label;
+ }
+}
+
+function SelectListType() {
+ // Each list type is stored in the "value" of each menuitem
+ var NewType = gDialog.ListTypeList.value;
+
+ if (NewType == "ol") {
+ SetTextboxFocus(gDialog.StartingNumberInput);
+ }
+
+ if (gListType != NewType) {
+ gListType = NewType;
+
+ // Create a newlist object for Advanced Editing
+ try {
+ if (gListType) {
+ globalElement = GetCurrentEditor().createElementWithDefaults(gListType);
+ }
+ } catch (e) {}
+
+ BuildBulletStyleList();
+ }
+}
+
+function SelectBulletStyle() {
+ // Save the selected index so when user changes
+ // list style, restore index to associated list
+ // Each bullet or number type is stored in the "value" of each menuitem
+ if (gListType == "ul") {
+ gBulletStyleType = gDialog.BulletStyleList.value;
+ } else if (gListType == "ol") {
+ var type = gDialog.BulletStyleList.value;
+ if (gNumberStyleType != type) {
+ // Convert existing input value to attr number first,
+ // then convert to the appropriate format for the newly-selected
+ gDialog.StartingNumberInput.value = ConvertStartAttrToUserString(
+ ConvertUserStringToStartAttr(gNumberStyleType),
+ type
+ );
+
+ gNumberStyleType = type;
+ SetTextboxFocus(gDialog.StartingNumberInput);
+ }
+ }
+}
+
+function ValidateData() {
+ gBulletStyleType = gDialog.BulletStyleList.value;
+ // globalElement should already be of the correct type
+
+ if (globalElement) {
+ var editor = GetCurrentEditor();
+ if (gListType == "ul") {
+ if (gBulletStyleType && gDialog.ChangeAllRadio.selected) {
+ globalElement.setAttribute("type", gBulletStyleType);
+ } else {
+ try {
+ editor.removeAttributeOrEquivalent(globalElement, "type", true);
+ } catch (e) {}
+ }
+ } else if (gListType == "ol") {
+ if (gBulletStyleType) {
+ globalElement.setAttribute("type", gBulletStyleType);
+ } else {
+ try {
+ editor.removeAttributeOrEquivalent(globalElement, "type", true);
+ } catch (e) {}
+ }
+
+ var startingNumber = ConvertUserStringToStartAttr(gBulletStyleType);
+ if (startingNumber) {
+ globalElement.setAttribute("start", startingNumber);
+ } else {
+ globalElement.removeAttribute("start");
+ }
+ }
+ }
+ return true;
+}
+
+function ConvertUserStringToStartAttr(type) {
+ var startingNumber = TrimString(gDialog.StartingNumberInput.value);
+
+ switch (type) {
+ case gUpperRoman:
+ case gLowerRoman:
+ // If the input isn't an integer, assume it's a roman numeral. Convert it.
+ if (!Number(startingNumber)) {
+ startingNumber = ConvertRomanToArabic(startingNumber);
+ }
+ break;
+ case gUpperLetters:
+ case gLowerLetters:
+ // Get the number equivalent of the letters
+ if (!Number(startingNumber)) {
+ startingNumber = ConvertLettersToArabic(startingNumber);
+ }
+ break;
+ }
+ return startingNumber;
+}
+
+function ConvertRomanToArabic(num) {
+ num = num.toUpperCase();
+ if (num && !/[^MDCLXVI]/i.test(num)) {
+ var Arabic = 0;
+ var last_digit = 1000;
+ for (var i = 0; i < num.length; i++) {
+ var digit = gRomanDigits[num.charAt(i)];
+ if (last_digit < digit) {
+ Arabic -= 2 * last_digit;
+ }
+
+ last_digit = digit;
+ Arabic += last_digit;
+ }
+ return Arabic;
+ }
+
+ return "";
+}
+
+function ConvertArabicToRoman(num) {
+ if (/^\d{1,4}$/.test(num)) {
+ var digits = ("000" + num).substr(-4);
+ return (
+ gThousandsArray[digits.charAt(0)] +
+ gHundredsArray[digits.charAt(1)] +
+ gTensArray[digits.charAt(2)] +
+ gOnesArray[digits.charAt(3)]
+ );
+ }
+ return "";
+}
+
+function ConvertLettersToArabic(letters) {
+ letters = letters.toUpperCase();
+ if (!letters || /[^A-Z]/.test(letters)) {
+ return "";
+ }
+
+ var num = 0;
+ for (var i = 0; i < letters.length; i++) {
+ num = num * 26 + letters.charCodeAt(i) - A + 1;
+ }
+ return num;
+}
+
+function ConvertArabicToLetters(num) {
+ var letters = "";
+ while (num) {
+ num--;
+ letters = String.fromCharCode(A + (num % 26)) + letters;
+ num = Math.floor(num / 26);
+ }
+ return letters;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ // Coalesce into one undo transaction
+ var editor = GetCurrentEditor();
+
+ editor.beginTransaction();
+
+ var changeEntireList =
+ gDialog.RadioGroup.selectedItem == gDialog.ChangeAllRadio;
+
+ // Remember which radio button was selected
+ if (gListElement) {
+ gDialog.RadioGroup.setAttribute("index", changeEntireList ? "0" : "1");
+ }
+
+ var changeList;
+ if (gListElement && gDialog.ChangeAllRadio.selected) {
+ changeList = true;
+ } else {
+ changeList =
+ gMixedListSelection ||
+ gListType != gOriginalListType ||
+ gBulletStyleType != gOriginalStyleType;
+ }
+ if (changeList) {
+ try {
+ if (gListType) {
+ editor.makeOrChangeList(
+ gListType,
+ changeEntireList,
+ gBulletStyleType != gOriginalStyleType ? gBulletStyleType : null
+ );
+
+ // Get the new list created:
+ gListElement = editor.getElementOrParentByTagName(gListType, null);
+
+ editor.cloneAttributes(gListElement, globalElement);
+ } else {
+ // Remove all existing lists
+ if (gListElement && changeEntireList) {
+ editor.selectElement(gListElement);
+ }
+
+ editor.removeList("ol");
+ editor.removeList("ul");
+ editor.removeList("dl");
+ }
+ } catch (e) {}
+ }
+
+ editor.endTransaction();
+
+ SaveWindowLocation();
+
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdListProps.xhtml b/comm/mail/components/compose/content/dialogs/EdListProps.xhtml
new file mode 100644
index 0000000000..b8d7c40cb2
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdListProps.xhtml
@@ -0,0 +1,101 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edListProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorListProperties.dtd">
+%edListProperties;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdListProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <html:fieldset>
+ <html:legend>&ListType.label;</html:legend>
+ <menulist id="ListType" oncommand="SelectListType()">
+ <menupopup>
+ <menuitem label="&none.value;" />
+ <menuitem value="ul" label="&bulletList.value;" />
+ <menuitem value="ol" label="&numberList.value;" />
+ <menuitem value="dl" label="&definitionList.value;" />
+ </menupopup>
+ </menulist>
+ </html:fieldset>
+ <spacer class="spacer" />
+
+ <!-- message text and list items are set in JS
+ text value should be identical to string with id=BulletStyle in editor.properties
+ -->
+ <html:fieldset>
+ <html:legend id="BulletStyleLabel">&bulletStyle.label;</html:legend>
+ <menulist id="BulletStyle" oncommand="SelectBulletStyle()">
+ <menupopup />
+ </menulist>
+ <spacer class="spacer" />
+ <hbox align="center">
+ <label
+ id="StartingNumberLabel"
+ control="StartingNumber"
+ value="&startingNumber.label;"
+ accesskey="&startingNumber.accessKey;"
+ />
+ <html:input
+ id="StartingNumber"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="StartingNumberLabel"
+ />
+ <spacer />
+ </hbox>
+ </html:fieldset>
+ <radiogroup id="RadioGroup" index="0" persist="index">
+ <radio
+ id="ChangeAll"
+ label="&changeEntireListRadio.label;"
+ accesskey="&changeEntireListRadio.accessKey;"
+ />
+ <radio
+ id="ChangeSelected"
+ label="&changeSelectedRadio.label;"
+ accesskey="&changeSelectedRadio.accessKey;"
+ />
+ </radiogroup>
+ <vbox id="AdvancedEdit">
+ <hbox flex="1" style="margin-top: 0.2em" align="center">
+ <!-- This will right-align the button -->
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator id="advancedSeparator" class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js
new file mode 100644
index 0000000000..c943cc2833
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js
@@ -0,0 +1,159 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gInsertNew = true;
+var gAnchorElement = null;
+var gOriginalName = "";
+const kTagName = "anchor";
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ gDialog.OkButton = document.querySelector("dialog").getButton("accept");
+ gDialog.NameInput = document.getElementById("nameInput");
+
+ // Get a single selected element of the desired type
+ gAnchorElement = editor.getSelectedElement(kTagName);
+
+ if (gAnchorElement) {
+ // We found an element and don't need to insert one
+ gInsertNew = false;
+
+ // Make a copy to use for AdvancedEdit
+ globalElement = gAnchorElement.cloneNode(false);
+ gOriginalName = ConvertToCDATAString(gAnchorElement.name);
+ } else {
+ gInsertNew = true;
+ // We don't have an element selected,
+ // so create one with default attributes
+ gAnchorElement = editor.createElementWithDefaults(kTagName);
+ if (gAnchorElement) {
+ // Use the current selection as suggested name
+ var name = GetSelectionAsText();
+ // Get 40 characters of the selected text and don't add "...",
+ // replace whitespace with "_" and strip non-word characters
+ name = ConvertToCDATAString(TruncateStringAtWordEnd(name, 40, false));
+ // Be sure the name is unique to the document
+ if (AnchorNameExists(name)) {
+ name += "_";
+ }
+
+ // Make a copy to use for AdvancedEdit
+ globalElement = gAnchorElement.cloneNode(false);
+ globalElement.setAttribute("name", name);
+ }
+ }
+ if (!gAnchorElement) {
+ dump("Failed to get selected element or create a new one!\n");
+ window.close();
+ return;
+ }
+
+ InitDialog();
+
+ DoEnabling();
+ SetTextboxFocus(gDialog.NameInput);
+ SetWindowLocation();
+}
+
+function InitDialog() {
+ gDialog.NameInput.value = globalElement.getAttribute("name");
+}
+
+function ChangeName() {
+ if (gDialog.NameInput.value.length > 0) {
+ // Replace spaces with "_" and strip other non-URL characters
+ // Note: we could use ConvertAndEscape, but then we'd
+ // have to UnEscapeAndConvert beforehand - too messy!
+ gDialog.NameInput.value = ConvertToCDATAString(gDialog.NameInput.value);
+ }
+ DoEnabling();
+}
+
+function DoEnabling() {
+ var enable = gDialog.NameInput.value.length > 0;
+ SetElementEnabled(gDialog.OkButton, enable);
+ SetElementEnabledById("AdvancedEditButton1", enable);
+}
+
+function AnchorNameExists(name) {
+ var anchorList;
+ try {
+ anchorList = GetCurrentEditor().document.anchors;
+ } catch (e) {}
+
+ if (anchorList) {
+ for (var i = 0; i < anchorList.length; i++) {
+ if (anchorList[i].name == name) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+// Get and validate data from widgets.
+// Set attributes on globalElement so they can be accessed by AdvancedEdit()
+function ValidateData() {
+ var name = TrimString(gDialog.NameInput.value);
+ if (!name) {
+ ShowInputErrorMessage(GetString("MissingAnchorNameError"));
+ SetTextboxFocus(gDialog.NameInput);
+ return false;
+ }
+ // Replace spaces with "_" and strip other characters
+ // Note: we could use ConvertAndEscape, but then we'd
+ // have to UnConverAndEscape beforehand - too messy!
+ name = ConvertToCDATAString(name);
+
+ if (gOriginalName != name && AnchorNameExists(name)) {
+ ShowInputErrorMessage(
+ GetString("DuplicateAnchorNameError").replace(/%name%/, name)
+ );
+ SetTextboxFocus(gDialog.NameInput);
+ return false;
+ }
+ globalElement.name = name;
+
+ return true;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ if (gOriginalName != globalElement.name) {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+
+ try {
+ // "false" = don't delete selected text when inserting
+ if (gInsertNew) {
+ // We must insert element before copying CSS style attribute,
+ // but we must set the name else it won't insert at all
+ gAnchorElement.name = globalElement.name;
+ editor.insertElementAtSelection(gAnchorElement, false);
+ }
+
+ // Copy attributes to element we are changing or inserting
+ editor.cloneAttributes(gAnchorElement, globalElement);
+ } catch (e) {}
+
+ editor.endTransaction();
+ }
+ SaveWindowLocation();
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml
new file mode 100644
index 0000000000..d26f4d73b4
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml
@@ -0,0 +1,67 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edNamedAnchorProperties SYSTEM "chrome://messenger/locale/messengercompose/EdNamedAnchorProperties.dtd">
+%edNamedAnchorProperties;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdNamedAnchorProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <label
+ id="nameLabel"
+ control="nameInput"
+ value="&anchorNameEditField.label;"
+ accesskey="&anchorNameEditField.accessKey;"
+ />
+ <html:input
+ id="nameInput"
+ type="text"
+ class="MinWidth20em input-inline"
+ oninput="ChangeName()"
+ title="&nameInput.tooltip;"
+ aria-labelledby="nameLabel"
+ />
+ <spacer class="spacer" />
+ <vbox id="AdvancedEdit">
+ <hbox flex="1" style="margin-top: 0.2em" align="center">
+ <!-- This will right-align the button -->
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator id="advancedSeparator" class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdReplace.js b/comm/mail/components/compose/content/dialogs/EdReplace.js
new file mode 100644
index 0000000000..c937702416
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdReplace.js
@@ -0,0 +1,380 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gReplaceDialog; // Quick access to document/form elements.
+var gFindInst; // nsIWebBrowserFind that we're going to use
+var gFindService; // Global service which remembers find params
+var gEditor; // the editor we're using
+
+document.addEventListener("dialogaccept", event => {
+ onFindNext();
+ event.preventDefault();
+});
+
+function initDialogObject() {
+ // Create gReplaceDialog object and initialize.
+ gReplaceDialog = {};
+ gReplaceDialog.findInput = document.getElementById("dialog.findInput");
+ gReplaceDialog.replaceInput = document.getElementById("dialog.replaceInput");
+ gReplaceDialog.caseSensitive = document.getElementById(
+ "dialog.caseSensitive"
+ );
+ gReplaceDialog.wrap = document.getElementById("dialog.wrap");
+ gReplaceDialog.searchBackwards = document.getElementById(
+ "dialog.searchBackwards"
+ );
+ gReplaceDialog.findNext = document.getElementById("findNext");
+ gReplaceDialog.replace = document.getElementById("replace");
+ gReplaceDialog.replaceAndFind = document.getElementById("replaceAndFind");
+ gReplaceDialog.replaceAll = document.getElementById("replaceAll");
+}
+
+function loadDialog() {
+ // Set initial dialog field contents.
+ // Set initial dialog field contents. Use the gFindInst attributes first,
+ // this is necessary for window.find()
+ gReplaceDialog.findInput.value = gFindInst.searchString
+ ? gFindInst.searchString
+ : gFindService.searchString;
+ gReplaceDialog.replaceInput.value = gFindService.replaceString;
+ gReplaceDialog.caseSensitive.checked = gFindInst.matchCase
+ ? gFindInst.matchCase
+ : gFindService.matchCase;
+ gReplaceDialog.wrap.checked = gFindInst.wrapFind
+ ? gFindInst.wrapFind
+ : gFindService.wrapFind;
+ gReplaceDialog.searchBackwards.checked = gFindInst.findBackwards
+ ? gFindInst.findBackwards
+ : gFindService.findBackwards;
+
+ doEnabling();
+}
+
+function onLoad() {
+ // Get the xul <editor> element:
+ var editorElement = window.arguments[0];
+
+ // If we don't get the editor, then we won't allow replacing.
+ gEditor = editorElement.getEditor(editorElement.contentWindow);
+ if (!gEditor) {
+ window.close();
+ return;
+ }
+
+ // Get the nsIWebBrowserFind service:
+ gFindInst = editorElement.webBrowserFind;
+
+ try {
+ // get the find service, which stores global find state
+ gFindService = Cc["@mozilla.org/find/find_service;1"].getService(
+ Ci.nsIFindService
+ );
+ } catch (e) {
+ dump("No find service!\n");
+ gFindService = 0;
+ }
+
+ // Init gReplaceDialog.
+ initDialogObject();
+
+ // Change "OK" to "Find".
+ // dialog.find.label = document.getElementById("fBLT").getAttribute("label");
+
+ // Fill dialog.
+ loadDialog();
+
+ if (gReplaceDialog.findInput.value) {
+ gReplaceDialog.findInput.select();
+ } else {
+ gReplaceDialog.findInput.focus();
+ }
+}
+
+function saveFindData() {
+ // Set data attributes per user input.
+ if (gFindService) {
+ gFindService.searchString = gReplaceDialog.findInput.value;
+ gFindService.matchCase = gReplaceDialog.caseSensitive.checked;
+ gFindService.wrapFind = gReplaceDialog.wrap.checked;
+ gFindService.findBackwards = gReplaceDialog.searchBackwards.checked;
+ }
+}
+
+function setUpFindInst() {
+ gFindInst.searchString = gReplaceDialog.findInput.value;
+ gFindInst.matchCase = gReplaceDialog.caseSensitive.checked;
+ gFindInst.wrapFind = gReplaceDialog.wrap.checked;
+ gFindInst.findBackwards = gReplaceDialog.searchBackwards.checked;
+}
+
+function onFindNext() {
+ // Transfer dialog contents to the find service.
+ saveFindData();
+ // set up the find instance
+ setUpFindInst();
+
+ // Search.
+ var result = gFindInst.findNext();
+
+ if (!result) {
+ var bundle = document.getElementById("findBundle");
+ Services.prompt.alert(
+ window,
+ GetString("Alert"),
+ bundle.getString("notFoundWarning")
+ );
+ SetTextboxFocus(gReplaceDialog.findInput);
+ gReplaceDialog.findInput.select();
+ gReplaceDialog.findInput.focus();
+ return false;
+ }
+ return true;
+}
+
+function onReplace() {
+ if (!gEditor) {
+ return false;
+ }
+
+ // Does the current selection match the find string?
+ var selection = gEditor.selection;
+
+ var selStr = selection.toString();
+ var specStr = gReplaceDialog.findInput.value;
+ if (!gReplaceDialog.caseSensitive.checked) {
+ selStr = selStr.toLowerCase();
+ specStr = specStr.toLowerCase();
+ }
+ // Unfortunately, because of whitespace we can't just check
+ // whether (selStr == specStr), but have to loop ourselves.
+ // N chars of whitespace in specStr can match any M >= N in selStr.
+ var matches = true;
+ var specLen = specStr.length;
+ var selLen = selStr.length;
+ if (selLen < specLen) {
+ matches = false;
+ } else {
+ var specArray = specStr.match(/\S+|\s+/g);
+ var selArray = selStr.match(/\S+|\s+/g);
+ if (specArray.length != selArray.length) {
+ matches = false;
+ } else {
+ for (var i = 0; i < selArray.length; i++) {
+ if (selArray[i] != specArray[i]) {
+ if (/\S/.test(selArray[i][0]) || /\S/.test(specArray[i][0])) {
+ // not a space chunk -- match fails
+ matches = false;
+ break;
+ } else if (selArray[i].length < specArray[i].length) {
+ // if it's a space chunk then we only care that sel be
+ // at least as long as spec
+ matches = false;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // If the current selection doesn't match the pattern,
+ // then we want to find the next match, but not do the replace.
+ // That's what most other apps seem to do.
+ // So here, just return.
+ if (!matches) {
+ return false;
+ }
+
+ // Transfer dialog contents to the find service.
+ saveFindData();
+
+ // For reverse finds, need to remember the caret position
+ // before current selection
+ var newRange;
+ if (gReplaceDialog.searchBackwards.checked && selection.rangeCount > 0) {
+ newRange = selection.getRangeAt(0).cloneRange();
+ newRange.collapse(true);
+ }
+
+ // nsPlaintextEditor::InsertText fails if the string is empty,
+ // so make that a special case:
+ var replStr = gReplaceDialog.replaceInput.value;
+ if (replStr == "") {
+ gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip);
+ } else {
+ gEditor.insertText(replStr);
+ }
+
+ // For reverse finds, need to move caret just before the replaced text
+ if (gReplaceDialog.searchBackwards.checked && newRange) {
+ gEditor.selection.removeAllRanges();
+ gEditor.selection.addRange(newRange);
+ }
+
+ return true;
+}
+
+function onReplaceAll() {
+ if (!gEditor) {
+ return;
+ }
+
+ var findStr = gReplaceDialog.findInput.value;
+ var repStr = gReplaceDialog.replaceInput.value;
+
+ // Transfer dialog contents to the find service.
+ saveFindData();
+
+ var finder = Cc["@mozilla.org/embedcomp/rangefind;1"]
+ .createInstance()
+ .QueryInterface(Ci.nsIFind);
+
+ finder.caseSensitive = gReplaceDialog.caseSensitive.checked;
+ finder.findBackwards = gReplaceDialog.searchBackwards.checked;
+
+ // We want the whole operation to be undoable in one swell foop,
+ // so start a transaction:
+ gEditor.beginTransaction();
+
+ // and to make sure we close the transaction, guard against exceptions:
+ try {
+ // Make a range containing the current selection,
+ // so we don't go past it when we wrap.
+ var selection = gEditor.selection;
+ var selecRange;
+ if (selection.rangeCount > 0) {
+ selecRange = selection.getRangeAt(0);
+ }
+ var origRange = selecRange.cloneRange();
+
+ // We'll need a range for the whole document:
+ var wholeDocRange = gEditor.document.createRange();
+ var rootNode = gEditor.rootElement;
+ wholeDocRange.selectNodeContents(rootNode);
+
+ // And start and end points:
+ var endPt = gEditor.document.createRange();
+
+ if (gReplaceDialog.searchBackwards.checked) {
+ endPt.setStart(wholeDocRange.startContainer, wholeDocRange.startOffset);
+ endPt.setEnd(wholeDocRange.startContainer, wholeDocRange.startOffset);
+ } else {
+ endPt.setStart(wholeDocRange.endContainer, wholeDocRange.endOffset);
+ endPt.setEnd(wholeDocRange.endContainer, wholeDocRange.endOffset);
+ }
+
+ // Find and replace from here to end (start) of document:
+ var foundRange;
+ var searchRange = wholeDocRange.cloneRange();
+ while (
+ (foundRange = finder.Find(findStr, searchRange, selecRange, endPt)) !=
+ null
+ ) {
+ gEditor.selection.removeAllRanges();
+ gEditor.selection.addRange(foundRange);
+
+ // The editor will leave the caret at the end of the replaced text.
+ // For reverse finds, we need it at the beginning,
+ // so save the next position now.
+ if (gReplaceDialog.searchBackwards.checked) {
+ selecRange = foundRange.cloneRange();
+ selecRange.setEnd(selecRange.startContainer, selecRange.startOffset);
+ }
+
+ // nsPlaintextEditor::InsertText fails if the string is empty,
+ // so make that a special case:
+ if (repStr == "") {
+ gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip);
+ } else {
+ gEditor.insertText(repStr);
+ }
+
+ // If we're going forward, we didn't save selecRange before, so do it now:
+ if (!gReplaceDialog.searchBackwards.checked) {
+ selection = gEditor.selection;
+ if (selection.rangeCount <= 0) {
+ gEditor.endTransaction();
+ return;
+ }
+ selecRange = selection.getRangeAt(0).cloneRange();
+ }
+ }
+
+ // If no wrapping, then we're done
+ if (!gReplaceDialog.wrap.checked) {
+ gEditor.endTransaction();
+ return;
+ }
+
+ // If wrapping, find from start/end of document back to start point.
+ if (gReplaceDialog.searchBackwards.checked) {
+ // Collapse origRange to end
+ origRange.setStart(origRange.endContainer, origRange.endOffset);
+ // Set current position to document end
+ selecRange.setEnd(wholeDocRange.endContainer, wholeDocRange.endOffset);
+ selecRange.setStart(wholeDocRange.endContainer, wholeDocRange.endOffset);
+ } else {
+ // Collapse origRange to start
+ origRange.setEnd(origRange.startContainer, origRange.startOffset);
+ // Set current position to document start
+ selecRange.setStart(
+ wholeDocRange.startContainer,
+ wholeDocRange.startOffset
+ );
+ selecRange.setEnd(
+ wholeDocRange.startContainer,
+ wholeDocRange.startOffset
+ );
+ }
+
+ while (
+ (foundRange = finder.Find(
+ findStr,
+ wholeDocRange,
+ selecRange,
+ origRange
+ )) != null
+ ) {
+ gEditor.selection.removeAllRanges();
+ gEditor.selection.addRange(foundRange);
+
+ // Save insert point for backward case
+ if (gReplaceDialog.searchBackwards.checked) {
+ selecRange = foundRange.cloneRange();
+ selecRange.setEnd(selecRange.startContainer, selecRange.startOffset);
+ }
+
+ // nsPlaintextEditor::InsertText fails if the string is empty,
+ // so make that a special case:
+ if (repStr == "") {
+ gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip);
+ } else {
+ gEditor.insertText(repStr);
+ }
+
+ // Get insert point for forward case
+ if (!gReplaceDialog.searchBackwards.checked) {
+ selection = gEditor.selection;
+ if (selection.rangeCount <= 0) {
+ gEditor.endTransaction();
+ return;
+ }
+ selecRange = selection.getRangeAt(0);
+ }
+ }
+ } catch (e) {}
+
+ gEditor.endTransaction();
+}
+
+function doEnabling() {
+ var findStr = gReplaceDialog.findInput.value;
+ gReplaceDialog.enabled = findStr;
+ gReplaceDialog.findNext.disabled = !findStr;
+ gReplaceDialog.replace.disabled = !findStr;
+ gReplaceDialog.replaceAndFind.disabled = !findStr;
+ gReplaceDialog.replaceAll.disabled = !findStr;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdReplace.xhtml b/comm/mail/components/compose/content/dialogs/EdReplace.xhtml
new file mode 100644
index 0000000000..62ce5a67e2
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdReplace.xhtml
@@ -0,0 +1,126 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorReplace.dtd">
+
+<window
+ id="replaceDlg"
+ title="&replaceDialog.title;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ onload="onLoad()"
+>
+ <dialog buttons="cancel">
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdReplace.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <stringbundle
+ id="findBundle"
+ src="chrome://global/locale/finddialog.properties"
+ />
+
+ <hbox>
+ <vbox>
+ <spacer class="spacer" />
+ <html:div class="grid-two-column">
+ <html:div class="flex-items-center">
+ <label
+ value="&findField.label;"
+ accesskey="&findField.accesskey;"
+ control="dialog.findInput"
+ />
+ </html:div>
+ <html:div>
+ <html:input
+ id="dialog.findInput"
+ class="input-inline"
+ oninput="doEnabling();"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <label
+ value="&replaceField.label;"
+ accesskey="&replaceField.accesskey;"
+ control="dialog.replaceInput"
+ />
+ </html:div>
+ <html:div>
+ <html:input
+ id="dialog.replaceInput"
+ class="input-inline"
+ oninput="doEnabling();"
+ />
+ </html:div>
+ <html:div class="grid-item-col2">
+ <vbox align="start">
+ <checkbox
+ id="dialog.caseSensitive"
+ label="&caseSensitiveCheckbox.label;"
+ accesskey="&caseSensitiveCheckbox.accesskey;"
+ />
+ <checkbox
+ id="dialog.wrap"
+ label="&wrapCheckbox.label;"
+ accesskey="&wrapCheckbox.accesskey;"
+ />
+ <checkbox
+ id="dialog.searchBackwards"
+ label="&backwardsCheckbox.label;"
+ accesskey="&backwardsCheckbox.accesskey;"
+ />
+ </vbox>
+ </html:div>
+ </html:div>
+ </vbox>
+ <spacer class="spacer" />
+ <vbox>
+ <button
+ id="findNext"
+ label="&findNextButton.label;"
+ accesskey="&findNextButton.accesskey;"
+ oncommand="onFindNext();"
+ default="true"
+ />
+ <button
+ id="replace"
+ label="&replaceButton.label;"
+ accesskey="&replaceButton.accesskey;"
+ oncommand="onReplace();"
+ />
+ <button
+ id="replaceAndFind"
+ label="&replaceAndFindButton.label;"
+ accesskey="&replaceAndFindButton.accesskey;"
+ oncommand="onReplace(); onFindNext();"
+ />
+ <button
+ id="replaceAll"
+ label="&replaceAllButton.label;"
+ accesskey="&replaceAllButton.accesskey;"
+ oncommand="onReplaceAll();"
+ />
+ <button
+ dlgtype="cancel"
+ label="&closeButton.label;"
+ accesskey="&closeButton.accesskey;"
+ />
+ </vbox>
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdSpellCheck.js b/comm/mail/components/compose/content/dialogs/EdSpellCheck.js
new file mode 100644
index 0000000000..5b54205bc3
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdSpellCheck.js
@@ -0,0 +1,496 @@
+/* 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 ../../../../base/content/utilityOverlay.js */
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var { InlineSpellChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+);
+
+var gMisspelledWord;
+var gSpellChecker = null;
+var gAllowSelectWord = true;
+var gPreviousReplaceWord = "";
+var gFirstTime = true;
+var gDictCount = 0;
+
+document.addEventListener("dialogaccept", doDefault);
+document.addEventListener("dialogcancel", CancelSpellCheck);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ // Get the spellChecker shell
+ gSpellChecker = Cu.createSpellChecker();
+ if (!gSpellChecker) {
+ dump("SpellChecker not found!!!\n");
+ window.close();
+ return;
+ }
+
+ // Start the spell checker module.
+ try {
+ var skipBlockQuotes = window.arguments[1];
+ var enableSelectionChecking = window.arguments[2];
+
+ gSpellChecker.setFilterType(
+ skipBlockQuotes
+ ? Ci.nsIEditorSpellCheck.FILTERTYPE_MAIL
+ : Ci.nsIEditorSpellCheck.FILTERTYPE_NORMAL
+ );
+ gSpellChecker.InitSpellChecker(
+ editor,
+ enableSelectionChecking,
+ spellCheckStarted
+ );
+ } catch (ex) {
+ dump("*** Exception error: InitSpellChecker\n");
+ window.close();
+ }
+}
+
+function spellCheckStarted() {
+ gDialog.MisspelledWordLabel = document.getElementById("MisspelledWordLabel");
+ gDialog.MisspelledWord = document.getElementById("MisspelledWord");
+ gDialog.ReplaceButton = document.getElementById("Replace");
+ gDialog.IgnoreButton = document.getElementById("Ignore");
+ gDialog.StopButton = document.getElementById("Stop");
+ gDialog.CloseButton = document.getElementById("Close");
+ gDialog.ReplaceWordInput = document.getElementById("ReplaceWordInput");
+ gDialog.SuggestedList = document.getElementById("SuggestedList");
+ gDialog.LanguageMenulist = document.getElementById("LanguageMenulist");
+
+ // Fill in the language menulist and sync it up
+ // with the spellchecker's current language.
+
+ var curLangs;
+
+ try {
+ curLangs = new Set(gSpellChecker.getCurrentDictionaries());
+ } catch (ex) {
+ curLangs = new Set();
+ }
+
+ InitLanguageMenu(curLangs);
+
+ // Get the first misspelled word and setup all UI
+ NextWord();
+
+ // When startup param is true, setup different UI when spell checking
+ // just before sending mail message
+ if (window.arguments[0]) {
+ // If no misspelled words found, simply close dialog and send message
+ if (!gMisspelledWord) {
+ onClose();
+ return;
+ }
+
+ // Hide "Close" button and use "Send" instead
+ gDialog.CloseButton.hidden = true;
+ gDialog.CloseButton = document.getElementById("Send");
+ gDialog.CloseButton.hidden = false;
+ } else {
+ // Normal spell checking - hide the "Stop" button
+ // (Note that this button is the "Cancel" button for
+ // Esc keybinding and related window close actions)
+ gDialog.StopButton.hidden = true;
+ }
+
+ // Clear flag that determines message when
+ // no misspelled word is found
+ // (different message when used for the first time)
+ gFirstTime = false;
+
+ window.sizeToContent();
+}
+
+/**
+ * Populate the dictionary language selector menu.
+ *
+ * @param {Set<string>} activeDictionaries - Currently active dictionaries.
+ */
+function InitLanguageMenu(activeDictionaries) {
+ // Get the list of dictionaries from
+ // the spellchecker.
+
+ var dictList;
+ try {
+ dictList = gSpellChecker.GetDictionaryList();
+ } catch (ex) {
+ dump("Failed to get DictionaryList!\n");
+ return;
+ }
+
+ // If we're not just starting up and dictionary count
+ // hasn't changed then no need to update the menu.
+ if (gDictCount == dictList.length) {
+ return;
+ }
+
+ // Store current dictionary count.
+ gDictCount = dictList.length;
+
+ var inlineSpellChecker = new InlineSpellChecker();
+ var sortedList = inlineSpellChecker.sortDictionaryList(dictList);
+
+ // Remove any languages from the list.
+ let list = document.getElementById("dictionary-list");
+ let template = document.getElementById("language-item");
+
+ list.replaceChildren(
+ ...sortedList.map(({ displayName, localeCode }) => {
+ let item = template.content.cloneNode(true);
+ item.querySelector(".checkbox-label").textContent = displayName;
+ let input = item.querySelector("input");
+ input.addEventListener("input", () => {
+ SelectLanguage(localeCode);
+ });
+ input.checked = activeDictionaries.has(localeCode);
+ return item;
+ })
+ );
+}
+
+function DoEnabling() {
+ if (!gMisspelledWord) {
+ // No more misspelled words
+ gDialog.MisspelledWord.setAttribute(
+ "value",
+ GetString(gFirstTime ? "NoMisspelledWord" : "CheckSpellingDone")
+ );
+
+ gDialog.ReplaceButton.removeAttribute("default");
+ gDialog.IgnoreButton.removeAttribute("default");
+
+ gDialog.CloseButton.setAttribute("default", "true");
+ // Shouldn't have to do this if "default" is true?
+ gDialog.CloseButton.focus();
+
+ SetElementEnabledById("MisspelledWordLabel", false);
+ SetElementEnabledById("ReplaceWordLabel", false);
+ SetElementEnabledById("ReplaceWordInput", false);
+ SetElementEnabledById("CheckWord", false);
+ SetElementEnabledById("SuggestedListLabel", false);
+ SetElementEnabledById("SuggestedList", false);
+ SetElementEnabledById("Ignore", false);
+ SetElementEnabledById("IgnoreAll", false);
+ SetElementEnabledById("Replace", false);
+ SetElementEnabledById("ReplaceAll", false);
+ SetElementEnabledById("AddToDictionary", false);
+ } else {
+ SetElementEnabledById("MisspelledWordLabel", true);
+ SetElementEnabledById("ReplaceWordLabel", true);
+ SetElementEnabledById("ReplaceWordInput", true);
+ SetElementEnabledById("CheckWord", true);
+ SetElementEnabledById("SuggestedListLabel", true);
+ SetElementEnabledById("SuggestedList", true);
+ SetElementEnabledById("Ignore", true);
+ SetElementEnabledById("IgnoreAll", true);
+ SetElementEnabledById("AddToDictionary", true);
+
+ gDialog.CloseButton.removeAttribute("default");
+ SetReplaceEnable();
+ }
+}
+
+function NextWord() {
+ gMisspelledWord = gSpellChecker.GetNextMisspelledWord();
+ SetWidgetsForMisspelledWord();
+}
+
+function SetWidgetsForMisspelledWord() {
+ gDialog.MisspelledWord.setAttribute("value", gMisspelledWord);
+
+ // Initial replace word is misspelled word
+ gDialog.ReplaceWordInput.value = gMisspelledWord;
+ gPreviousReplaceWord = gMisspelledWord;
+
+ // This sets gDialog.ReplaceWordInput to first suggested word in list
+ FillSuggestedList(gMisspelledWord);
+
+ DoEnabling();
+
+ if (gMisspelledWord) {
+ SetTextboxFocus(gDialog.ReplaceWordInput);
+ }
+}
+
+function CheckWord() {
+ var word = gDialog.ReplaceWordInput.value;
+ if (word) {
+ if (gSpellChecker.CheckCurrentWord(word)) {
+ FillSuggestedList(word);
+ SetReplaceEnable();
+ } else {
+ ClearListbox(gDialog.SuggestedList);
+ var item = gDialog.SuggestedList.appendItem(
+ GetString("CorrectSpelling"),
+ ""
+ );
+ if (item) {
+ item.setAttribute("disabled", "true");
+ }
+ // Suppress being able to select the message text
+ gAllowSelectWord = false;
+ }
+ }
+}
+
+function SelectSuggestedWord() {
+ if (gAllowSelectWord) {
+ if (gDialog.SuggestedList.selectedItem) {
+ var selValue = gDialog.SuggestedList.selectedItem.label;
+ gDialog.ReplaceWordInput.value = selValue;
+ gPreviousReplaceWord = selValue;
+ } else {
+ gDialog.ReplaceWordInput.value = gPreviousReplaceWord;
+ }
+ SetReplaceEnable();
+ }
+}
+
+function ChangeReplaceWord() {
+ // Calling this triggers SelectSuggestedWord(),
+ // so temporarily suppress the effect of that
+ var saveAllow = gAllowSelectWord;
+ gAllowSelectWord = false;
+
+ // Select matching word in list
+ var newSelectedItem;
+ var replaceWord = TrimString(gDialog.ReplaceWordInput.value);
+ if (replaceWord) {
+ for (var i = 0; i < gDialog.SuggestedList.getRowCount(); i++) {
+ var item = gDialog.SuggestedList.getItemAtIndex(i);
+ if (item.label == replaceWord) {
+ newSelectedItem = item;
+ break;
+ }
+ }
+ }
+ gDialog.SuggestedList.selectedItem = newSelectedItem;
+
+ gAllowSelectWord = saveAllow;
+
+ // Remember the new word
+ gPreviousReplaceWord = gDialog.ReplaceWordInput.value;
+
+ SetReplaceEnable();
+}
+
+function Ignore() {
+ NextWord();
+}
+
+function IgnoreAll() {
+ if (gMisspelledWord) {
+ gSpellChecker.IgnoreWordAllOccurrences(gMisspelledWord);
+ }
+ NextWord();
+}
+
+function Replace(newWord) {
+ if (!newWord) {
+ return;
+ }
+
+ if (gMisspelledWord && gMisspelledWord != newWord) {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+ try {
+ gSpellChecker.ReplaceWord(gMisspelledWord, newWord, false);
+ } catch (e) {}
+ editor.endTransaction();
+ }
+ NextWord();
+}
+
+function ReplaceAll() {
+ var newWord = gDialog.ReplaceWordInput.value;
+ if (gMisspelledWord && gMisspelledWord != newWord) {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+ try {
+ gSpellChecker.ReplaceWord(gMisspelledWord, newWord, true);
+ } catch (e) {}
+ editor.endTransaction();
+ }
+ NextWord();
+}
+
+function AddToDictionary() {
+ if (gMisspelledWord) {
+ gSpellChecker.AddWordToDictionary(gMisspelledWord);
+ }
+ NextWord();
+}
+
+function EditDictionary() {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdDictionary.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ gMisspelledWord
+ );
+}
+
+/**
+ * Change the selection state of the given dictionary language.
+ *
+ * @param {string} language
+ */
+function SelectLanguage(language) {
+ let activeDictionaries = new Set(gSpellChecker.getCurrentDictionaries());
+ if (activeDictionaries.has(language)) {
+ activeDictionaries.delete(language);
+ } else {
+ activeDictionaries.add(language);
+ }
+ let activeDictionariesArray = Array.from(activeDictionaries);
+ gSpellChecker.setCurrentDictionaries(activeDictionariesArray);
+ // For compose windows we need to set the "lang" attribute so the
+ // core editor uses the correct dictionary for the inline spell check.
+ if (window.arguments[1]) {
+ if ("ComposeChangeLanguage" in window.opener) {
+ // We came here from a compose window.
+ window.opener.ComposeChangeLanguage(activeDictionariesArray);
+ } else if (activeDictionaries.size === 1) {
+ window.opener.document.documentElement.setAttribute(
+ "lang",
+ activeDictionariesArray[0]
+ );
+ } else {
+ window.opener.document.documentElement.setAttribute("lang", "");
+ }
+ }
+}
+
+function Recheck() {
+ var recheckLanguages;
+
+ function finishRecheck() {
+ gSpellChecker.setCurrentDictionaries(recheckLanguages);
+ gMisspelledWord = gSpellChecker.GetNextMisspelledWord();
+ SetWidgetsForMisspelledWord();
+ }
+
+ // TODO: Should we bother to add a "Recheck" method to interface?
+ try {
+ recheckLanguages = gSpellChecker.getCurrentDictionaries();
+ gSpellChecker.UninitSpellChecker();
+ // Clear the ignore all list.
+ Cc["@mozilla.org/spellchecker/personaldictionary;1"]
+ .getService(Ci.mozIPersonalDictionary)
+ .endSession();
+ gSpellChecker.InitSpellChecker(GetCurrentEditor(), false, finishRecheck);
+ } catch (ex) {
+ console.error(ex);
+ }
+}
+
+function FillSuggestedList(misspelledWord) {
+ var list = gDialog.SuggestedList;
+
+ // Clear the current contents of the list
+ gAllowSelectWord = false;
+ ClearListbox(list);
+ var item;
+
+ if (misspelledWord.length > 0) {
+ // Get suggested words until an empty string is returned
+ var count = 0;
+ do {
+ var word = gSpellChecker.GetSuggestedWord();
+ if (word.length > 0) {
+ list.appendItem(word, "");
+ count++;
+ }
+ } while (word.length > 0);
+
+ if (count == 0) {
+ // No suggestions - show a message but don't let user select it
+ item = list.appendItem(GetString("NoSuggestedWords"));
+ if (item) {
+ item.setAttribute("disabled", "true");
+ }
+ gAllowSelectWord = false;
+ } else {
+ gAllowSelectWord = true;
+ // Initialize with first suggested list by selecting it
+ gDialog.SuggestedList.selectedIndex = 0;
+ }
+ } else {
+ item = list.appendItem("", "");
+ if (item) {
+ item.setAttribute("disabled", "true");
+ }
+ }
+}
+
+function SetReplaceEnable() {
+ // Enable "Change..." buttons only if new word is different than misspelled
+ var newWord = gDialog.ReplaceWordInput.value;
+ var enable = newWord.length > 0 && newWord != gMisspelledWord;
+ SetElementEnabledById("Replace", enable);
+ SetElementEnabledById("ReplaceAll", enable);
+ if (enable) {
+ gDialog.ReplaceButton.setAttribute("default", "true");
+ gDialog.IgnoreButton.removeAttribute("default");
+ } else {
+ gDialog.IgnoreButton.setAttribute("default", "true");
+ gDialog.ReplaceButton.removeAttribute("default");
+ }
+}
+
+function doDefault(event) {
+ if (gDialog.ReplaceButton.getAttribute("default") == "true") {
+ Replace(gDialog.ReplaceWordInput.value);
+ } else if (gDialog.IgnoreButton.getAttribute("default") == "true") {
+ Ignore();
+ } else if (gDialog.CloseButton.getAttribute("default") == "true") {
+ onClose();
+ }
+
+ event.preventDefault();
+}
+
+function ExitSpellChecker() {
+ if (gSpellChecker) {
+ try {
+ gSpellChecker.UninitSpellChecker();
+ // now check the document over again with the new dictionary
+ // if we have an inline spellchecker
+ if (
+ "InlineSpellCheckerUI" in window.opener &&
+ window.opener.InlineSpellCheckerUI.enabled
+ ) {
+ window.opener.InlineSpellCheckerUI.mInlineSpellChecker.spellCheckRange(
+ null
+ );
+ }
+ } finally {
+ gSpellChecker = null;
+ }
+ }
+}
+
+function CancelSpellCheck() {
+ ExitSpellChecker();
+
+ // Signal to calling window that we canceled
+ window.opener.cancelSendMessage = true;
+}
+
+function onClose() {
+ ExitSpellChecker();
+
+ window.opener.cancelSendMessage = false;
+ window.close();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml b/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml
new file mode 100644
index 0000000000..fcff0e1703
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml
@@ -0,0 +1,209 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorSpellCheck.dtd">
+
+<!-- dialog containing a control requiring initial setup -->
+<window
+ id="spellCheckDlg"
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog buttons="cancel">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://communicator/content/utilityOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/EdSpellCheck.js" />
+ <script src="chrome://global/content/contentAreaUtils.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <stringbundle
+ id="languageBundle"
+ src="chrome://global/locale/languageNames.properties"
+ />
+ <stringbundle
+ id="regionBundle"
+ src="chrome://global/locale/regionNames.properties"
+ />
+
+ <html:div class="grid-three-column-auto-x-auto">
+ <html:div class="flex-items-center">
+ <label id="MisspelledWordLabel" value="&misspelledWord.label;" />
+ </html:div>
+ <html:div class="flex-items-center">
+ <label id="MisspelledWord" class="bold" crop="end" />
+ </html:div>
+ <html:div class="flex-items-center">
+ <button
+ class="spell-check"
+ label="&recheckButton2.label;"
+ oncommand="Recheck();"
+ accesskey="&recheckButton2.accessKey;"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <label
+ id="ReplaceWordLabel"
+ value="&wordEditField.label;"
+ control="ReplaceWordInput"
+ accesskey="&wordEditField.accessKey;"
+ />
+ </html:div>
+ <html:div>
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="ReplaceWordInput"
+ type="text"
+ class="input-inline"
+ onchange="ChangeReplaceWord()"
+ aria-labelledby="ReplaceWordLabel"
+ />
+ </hbox>
+ </html:div>
+ <html:div class="flex-items-center">
+ <button
+ id="CheckWord"
+ class="spell-check"
+ oncommand="CheckWord()"
+ label="&checkwordButton.label;"
+ accesskey="&checkwordButton.accessKey;"
+ />
+ </html:div>
+ </html:div>
+ <label
+ id="SuggestedListLabel"
+ value="&suggestions.label;"
+ control="SuggestedList"
+ accesskey="&suggestions.accessKey;"
+ />
+ <hbox flex="1" class="display-flex">
+ <html:div class="grid-two-column-x-auto flex-1">
+ <html:div class="display-flex">
+ <richlistbox
+ id="SuggestedList"
+ class="display-flex flex-1"
+ onselect="SelectSuggestedWord()"
+ ondblclick="if (gAllowSelectWord) { Replace(event.target.value); }"
+ />
+ </html:div>
+ <html:div>
+ <vbox>
+ <html:div class="grid-two-column-equalsize">
+ <button
+ id="Replace"
+ class="spell-check"
+ label="&replaceButton.label;"
+ oncommand="Replace(gDialog.ReplaceWordInput.value);"
+ accesskey="&replaceButton.accessKey;"
+ />
+ <button
+ id="Ignore"
+ class="spell-check"
+ oncommand="Ignore();"
+ label="&ignoreButton.label;"
+ accesskey="&ignoreButton.accessKey;"
+ />
+ <button
+ id="ReplaceAll"
+ class="spell-check"
+ oncommand="ReplaceAll();"
+ label="&replaceAllButton.label;"
+ accesskey="&replaceAllButton.accessKey;"
+ />
+ <button
+ id="IgnoreAll"
+ class="spell-check"
+ oncommand="IgnoreAll();"
+ label="&ignoreAllButton.label;"
+ accesskey="&ignoreAllButton.accessKey;"
+ />
+ </html:div>
+ <separator />
+ <label value="&userDictionary.label;" />
+ <hbox align="start">
+ <button
+ id="AddToDictionary"
+ class="spell-check"
+ oncommand="AddToDictionary()"
+ label="&addToUserDictionaryButton.label;"
+ accesskey="&addToUserDictionaryButton.accessKey;"
+ />
+ <button
+ id="EditDictionary"
+ class="spell-check"
+ oncommand="EditDictionary()"
+ label="&editUserDictionaryButton.label;"
+ accesskey="&editUserDictionaryButton.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ </html:div>
+ <html:div class="grid-item-span-row">
+ <label
+ value="&languagePopup.label;"
+ control="LanguageMenulist"
+ accesskey="&languagePopup.accessKey;"
+ />
+ </html:div>
+ <html:div>
+ <html:ul id="dictionary-list"> </html:ul>
+ <html:template id="language-item"
+ ><html:li>
+ <html:label
+ ><html:input type="checkbox"></html:input>
+ <html:span class="checkbox-label"></html:span
+ ></html:label> </html:li
+ ></html:template>
+ <html:a onclick="openDictionaryList()" href=""
+ >&moreDictionaries.label;</html:a
+ >
+ </html:div>
+ <html:div>
+ <hbox class="display-flex">
+ <button
+ id="Stop"
+ class="spell-check"
+ dlgtype="cancel"
+ label="&stopButton.label;"
+ oncommand="CancelSpellCheck();"
+ accesskey="&stopButton.accessKey;"
+ />
+ <spacer class="flex-1" />
+ <button
+ id="Close"
+ class="spell-check"
+ label="&closeButton.label;"
+ oncommand="onClose();"
+ accesskey="&closeButton.accessKey;"
+ />
+ <button
+ id="Send"
+ class="spell-check"
+ label="&sendButton.label;"
+ oncommand="onClose();"
+ accesskey="&sendButton.accessKey;"
+ hidden="true"
+ />
+ </hbox>
+ </html:div>
+ </html:div>
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdTableProps.js b/comm/mail/components/compose/content/dialogs/EdTableProps.js
new file mode 100644
index 0000000000..fd4ab40f3a
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdTableProps.js
@@ -0,0 +1,1426 @@
+/* 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 ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+
+var gTableElement;
+var gCellElement;
+var gTableCaptionElement;
+var globalCellElement;
+var globalTableElement;
+var gValidateTab;
+const defHAlign = "left";
+const centerStr = "center"; // Index=1
+const rightStr = "right"; // 2
+const justifyStr = "justify"; // 3
+const charStr = "char"; // 4
+const defVAlign = "middle";
+const topStr = "top";
+const bottomStr = "bottom";
+const bgcolor = "bgcolor";
+var gTableColor;
+var gCellColor;
+
+const cssBackgroundColorStr = "background-color";
+
+var gRowCount = 1;
+var gColCount = 1;
+var gLastRowIndex;
+var gLastColIndex;
+var gNewRowCount;
+var gNewColCount;
+var gCurRowIndex;
+var gCurColIndex;
+var gCurColSpan;
+var gSelectedCellsType = 1;
+const SELECT_CELL = 1;
+const SELECT_ROW = 2;
+const SELECT_COLUMN = 3;
+const RESET_SELECTION = 0;
+var gCellData = {
+ value: null,
+ startRowIndex: 0,
+ startColIndex: 0,
+ rowSpan: 0,
+ colSpan: 0,
+ actualRowSpan: 0,
+ actualColSpan: 0,
+ isSelected: false,
+};
+var gAdvancedEditUsed;
+var gAlignWasChar = false;
+
+/*
+From C++:
+ 0 TABLESELECTION_TABLE
+ 1 TABLESELECTION_CELL There are 1 or more cells selected
+ but complete rows or columns are not selected
+ 2 TABLESELECTION_ROW All cells are in 1 or more rows
+ and in each row, all cells selected
+ Note: This is the value if all rows (thus all cells) are selected
+ 3 TABLESELECTION_COLUMN All cells are in 1 or more columns
+*/
+
+var gSelectedCellCount = 0;
+var gApplyUsed = false;
+var gSelection;
+var gCellDataChanged = false;
+var gCanDelete = false;
+var gUseCSS = true;
+var gActiveEditor;
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogextra1", Apply);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ gActiveEditor = GetCurrentTableEditor();
+ if (!gActiveEditor) {
+ window.close();
+ return;
+ }
+
+ try {
+ gSelection = gActiveEditor.selection;
+ } catch (e) {}
+ if (!gSelection) {
+ return;
+ }
+
+ // Get dialog widgets - Table Panel
+ gDialog.TableRowsInput = document.getElementById("TableRowsInput");
+ gDialog.TableColumnsInput = document.getElementById("TableColumnsInput");
+ gDialog.TableWidthInput = document.getElementById("TableWidthInput");
+ gDialog.TableWidthUnits = document.getElementById("TableWidthUnits");
+ gDialog.TableHeightInput = document.getElementById("TableHeightInput");
+ gDialog.TableHeightUnits = document.getElementById("TableHeightUnits");
+ try {
+ if (
+ !Services.prefs.getBoolPref("editor.use_css") ||
+ gActiveEditor.flags & 1
+ ) {
+ gUseCSS = false;
+ var tableHeightLabel = document.getElementById("TableHeightLabel");
+ tableHeightLabel.remove();
+ gDialog.TableHeightInput.remove();
+ gDialog.TableHeightUnits.remove();
+ }
+ } catch (e) {}
+ gDialog.BorderWidthInput = document.getElementById("BorderWidthInput");
+ gDialog.SpacingInput = document.getElementById("SpacingInput");
+ gDialog.PaddingInput = document.getElementById("PaddingInput");
+ gDialog.TableAlignList = document.getElementById("TableAlignList");
+ gDialog.TableCaptionList = document.getElementById("TableCaptionList");
+ gDialog.TableInheritColor = document.getElementById("TableInheritColor");
+ gDialog.TabBox = document.getElementById("TabBox");
+
+ // Cell Panel
+ gDialog.SelectionList = document.getElementById("SelectionList");
+ gDialog.PreviousButton = document.getElementById("PreviousButton");
+ gDialog.NextButton = document.getElementById("NextButton");
+ // Currently, we always apply changes and load new attributes when changing selection
+ // (Let's keep this for possible future use)
+ // gDialog.ApplyBeforeMove = document.getElementById("ApplyBeforeMove");
+ // gDialog.KeepCurrentData = document.getElementById("KeepCurrentData");
+
+ gDialog.CellHeightInput = document.getElementById("CellHeightInput");
+ gDialog.CellHeightUnits = document.getElementById("CellHeightUnits");
+ gDialog.CellWidthInput = document.getElementById("CellWidthInput");
+ gDialog.CellWidthUnits = document.getElementById("CellWidthUnits");
+ gDialog.CellHAlignList = document.getElementById("CellHAlignList");
+ gDialog.CellVAlignList = document.getElementById("CellVAlignList");
+ gDialog.CellInheritColor = document.getElementById("CellInheritColor");
+ gDialog.CellStyleList = document.getElementById("CellStyleList");
+ gDialog.TextWrapList = document.getElementById("TextWrapList");
+
+ // In cell panel, user must tell us which attributes to apply via checkboxes,
+ // else we would apply values from one cell to ALL in selection
+ // and that's probably not what they expect!
+ gDialog.CellHeightCheckbox = document.getElementById("CellHeightCheckbox");
+ gDialog.CellWidthCheckbox = document.getElementById("CellWidthCheckbox");
+ gDialog.CellHAlignCheckbox = document.getElementById("CellHAlignCheckbox");
+ gDialog.CellVAlignCheckbox = document.getElementById("CellVAlignCheckbox");
+ gDialog.CellStyleCheckbox = document.getElementById("CellStyleCheckbox");
+ gDialog.TextWrapCheckbox = document.getElementById("TextWrapCheckbox");
+ gDialog.CellColorCheckbox = document.getElementById("CellColorCheckbox");
+ gDialog.TableTab = document.getElementById("TableTab");
+ gDialog.CellTab = document.getElementById("CellTab");
+ gDialog.AdvancedEditCell = document.getElementById("AdvancedEditButton2");
+ // Save "normal" tooltip message for Advanced Edit button
+ gDialog.AdvancedEditCellToolTipText =
+ gDialog.AdvancedEditCell.getAttribute("tooltiptext");
+
+ try {
+ gTableElement = gActiveEditor.getElementOrParentByTagName("table", null);
+ } catch (e) {}
+ if (!gTableElement) {
+ dump("Failed to get table element!\n");
+ window.close();
+ return;
+ }
+ globalTableElement = gTableElement.cloneNode(false);
+
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ var tableOrCellElement;
+ try {
+ tableOrCellElement = gActiveEditor.getSelectedOrParentTableElement(
+ tagNameObj,
+ countObj
+ );
+ } catch (e) {}
+
+ if (tagNameObj.value == "td") {
+ // We are in a cell
+ gSelectedCellCount = countObj.value;
+ gCellElement = tableOrCellElement;
+ globalCellElement = gCellElement.cloneNode(false);
+
+ // Tells us whether cell, row, or column is selected
+ try {
+ gSelectedCellsType = gActiveEditor.getSelectedCellsType(gTableElement);
+ } catch (e) {}
+
+ // Ignore types except Cell, Row, and Column
+ if (
+ gSelectedCellsType < SELECT_CELL ||
+ gSelectedCellsType > SELECT_COLUMN
+ ) {
+ gSelectedCellsType = SELECT_CELL;
+ }
+
+ // Be sure at least 1 cell is selected.
+ // (If the count is 0, then we were inside the cell.)
+ if (gSelectedCellCount == 0) {
+ DoCellSelection();
+ }
+
+ // Get location in the cell map
+ var rowIndexObj = { value: 0 };
+ var colIndexObj = { value: 0 };
+ try {
+ gActiveEditor.getCellIndexes(gCellElement, rowIndexObj, colIndexObj);
+ } catch (e) {}
+ gCurRowIndex = rowIndexObj.value;
+ gCurColIndex = colIndexObj.value;
+
+ // We save the current colspan to quickly
+ // move selection from from cell to cell
+ if (GetCellData(gCurRowIndex, gCurColIndex)) {
+ gCurColSpan = gCellData.colSpan;
+ }
+
+ // Starting TabPanel name is passed in
+ if (window.arguments[1] == "CellPanel") {
+ gDialog.TabBox.selectedTab = gDialog.CellTab;
+ }
+ }
+
+ if (gDialog.TabBox.selectedTab == gDialog.TableTab) {
+ // We may call this with table selected, but no cell,
+ // so disable the Cell Properties tab
+ if (!gCellElement) {
+ // XXX: Disabling of tabs is currently broken, so for
+ // now we'll just remove the tab completely.
+ // gDialog.CellTab.disabled = true;
+ gDialog.CellTab.remove();
+ }
+ }
+
+ // Note: we must use gTableElement, not globalTableElement for these,
+ // thus we should not put this in InitDialog.
+ // Instead, monitor desired counts with separate globals
+ var rowCountObj = { value: 0 };
+ var colCountObj = { value: 0 };
+ try {
+ gActiveEditor.getTableSize(gTableElement, rowCountObj, colCountObj);
+ } catch (e) {}
+
+ gRowCount = rowCountObj.value;
+ gLastRowIndex = gRowCount - 1;
+ gColCount = colCountObj.value;
+ gLastColIndex = gColCount - 1;
+
+ // Set appropriate icons and enable state for the Previous/Next buttons
+ SetSelectionButtons();
+
+ // If only one cell in table, disable change-selection widgets
+ if (gRowCount == 1 && gColCount == 1) {
+ gDialog.SelectionList.setAttribute("disabled", "true");
+ }
+
+ // User can change these via textboxes
+ gNewRowCount = gRowCount;
+ gNewColCount = gColCount;
+
+ // This flag is used to control whether set check state
+ // on "set attribute" checkboxes
+ // (Advanced Edit dialog use calls InitDialog when done)
+ gAdvancedEditUsed = false;
+ InitDialog();
+ gAdvancedEditUsed = true;
+
+ // If first initializing, we really aren't changing anything
+ gCellDataChanged = false;
+
+ SetWindowLocation();
+}
+
+function InitDialog() {
+ // Get Table attributes
+ gDialog.TableRowsInput.value = gRowCount;
+ gDialog.TableColumnsInput.value = gColCount;
+ gDialog.TableWidthInput.value = InitPixelOrPercentMenulist(
+ globalTableElement,
+ gTableElement,
+ "width",
+ "TableWidthUnits",
+ gPercent
+ );
+ if (gUseCSS) {
+ gDialog.TableHeightInput.value = InitPixelOrPercentMenulist(
+ globalTableElement,
+ gTableElement,
+ "height",
+ "TableHeightUnits",
+ gPercent
+ );
+ }
+ gDialog.BorderWidthInput.value = globalTableElement.border;
+ gDialog.SpacingInput.value = globalTableElement.cellSpacing;
+ gDialog.PaddingInput.value = globalTableElement.cellPadding;
+
+ var marginLeft = GetHTMLOrCSSStyleValue(
+ globalTableElement,
+ "align",
+ "margin-left"
+ );
+ var marginRight = GetHTMLOrCSSStyleValue(
+ globalTableElement,
+ "align",
+ "margin-right"
+ );
+ var halign = marginLeft.toLowerCase() + " " + marginRight.toLowerCase();
+ if (halign == "center center" || halign == "auto auto") {
+ gDialog.TableAlignList.value = "center";
+ } else if (halign == "right right" || halign == "auto 0px") {
+ gDialog.TableAlignList.value = "right";
+ } else {
+ // Default is left.
+ gDialog.TableAlignList.value = "left";
+ }
+
+ // Be sure to get caption from table in doc, not the copied "globalTableElement"
+ gTableCaptionElement = gTableElement.caption;
+ if (gTableCaptionElement) {
+ var align = GetHTMLOrCSSStyleValue(
+ gTableCaptionElement,
+ "align",
+ "caption-side"
+ );
+ if (align != "bottom" && align != "left" && align != "right") {
+ align = "top";
+ }
+ gDialog.TableCaptionList.value = align;
+ }
+
+ gTableColor = GetHTMLOrCSSStyleValue(
+ globalTableElement,
+ bgcolor,
+ cssBackgroundColorStr
+ );
+ gTableColor = ConvertRGBColorIntoHEXColor(gTableColor);
+ SetColor("tableBackgroundCW", gTableColor);
+
+ InitCellPanel();
+}
+
+function InitCellPanel() {
+ // Get cell attributes
+ if (globalCellElement) {
+ // This assumes order of items is Cell, Row, Column
+ gDialog.SelectionList.value = gSelectedCellsType;
+
+ var previousValue = gDialog.CellHeightInput.value;
+ gDialog.CellHeightInput.value = InitPixelOrPercentMenulist(
+ globalCellElement,
+ gCellElement,
+ "height",
+ "CellHeightUnits",
+ gPixel
+ );
+ gDialog.CellHeightCheckbox.checked =
+ gAdvancedEditUsed && previousValue != gDialog.CellHeightInput.value;
+
+ previousValue = gDialog.CellWidthInput.value;
+ gDialog.CellWidthInput.value = InitPixelOrPercentMenulist(
+ globalCellElement,
+ gCellElement,
+ "width",
+ "CellWidthUnits",
+ gPixel
+ );
+ gDialog.CellWidthCheckbox.checked =
+ gAdvancedEditUsed && previousValue != gDialog.CellWidthInput.value;
+
+ var previousIndex = gDialog.CellVAlignList.selectedIndex;
+ var valign = GetHTMLOrCSSStyleValue(
+ globalCellElement,
+ "valign",
+ "vertical-align"
+ ).toLowerCase();
+ if (valign == topStr || valign == bottomStr) {
+ gDialog.CellVAlignList.value = valign;
+ } else {
+ // Default is middle.
+ gDialog.CellVAlignList.value = defVAlign;
+ }
+
+ gDialog.CellVAlignCheckbox.checked =
+ gAdvancedEditUsed &&
+ previousIndex != gDialog.CellVAlignList.selectedIndex;
+
+ previousIndex = gDialog.CellHAlignList.selectedIndex;
+
+ gAlignWasChar = false;
+
+ var halign = GetHTMLOrCSSStyleValue(
+ globalCellElement,
+ "align",
+ "text-align"
+ ).toLowerCase();
+ switch (halign) {
+ case centerStr:
+ case rightStr:
+ case justifyStr:
+ gDialog.CellHAlignList.value = halign;
+ break;
+ case charStr:
+ // We don't support UI for this because layout doesn't work: bug 2212.
+ // Remember that's what they had so we don't change it
+ // unless they change the alignment by using the menulist
+ gAlignWasChar = true;
+ // Fall through to use show default alignment in menu
+ default:
+ // Default depends on cell type (TH is "center", TD is "left")
+ gDialog.CellHAlignList.value =
+ globalCellElement.nodeName.toLowerCase() == "th" ? "center" : "left";
+ break;
+ }
+
+ gDialog.CellHAlignCheckbox.checked =
+ gAdvancedEditUsed &&
+ previousIndex != gDialog.CellHAlignList.selectedIndex;
+
+ previousIndex = gDialog.CellStyleList.selectedIndex;
+ gDialog.CellStyleList.value = globalCellElement.nodeName.toLowerCase();
+ gDialog.CellStyleCheckbox.checked =
+ gAdvancedEditUsed && previousIndex != gDialog.CellStyleList.selectedIndex;
+
+ previousIndex = gDialog.TextWrapList.selectedIndex;
+ if (
+ GetHTMLOrCSSStyleValue(globalCellElement, "nowrap", "white-space") ==
+ "nowrap"
+ ) {
+ gDialog.TextWrapList.value = "nowrap";
+ } else {
+ gDialog.TextWrapList.value = "wrap";
+ }
+ gDialog.TextWrapCheckbox.checked =
+ gAdvancedEditUsed && previousIndex != gDialog.TextWrapList.selectedIndex;
+
+ previousValue = gCellColor;
+ gCellColor = GetHTMLOrCSSStyleValue(
+ globalCellElement,
+ bgcolor,
+ cssBackgroundColorStr
+ );
+ gCellColor = ConvertRGBColorIntoHEXColor(gCellColor);
+ SetColor("cellBackgroundCW", gCellColor);
+ gDialog.CellColorCheckbox.checked =
+ gAdvancedEditUsed && previousValue != gCellColor;
+
+ // We want to set this true in case changes came
+ // from Advanced Edit dialog session (must assume something changed)
+ gCellDataChanged = true;
+ }
+}
+
+function GetCellData(rowIndex, colIndex) {
+ // Get actual rowspan and colspan
+ var startRowIndexObj = { value: 0 };
+ var startColIndexObj = { value: 0 };
+ var rowSpanObj = { value: 0 };
+ var colSpanObj = { value: 0 };
+ var actualRowSpanObj = { value: 0 };
+ var actualColSpanObj = { value: 0 };
+ var isSelectedObj = { value: false };
+
+ try {
+ gActiveEditor.getCellDataAt(
+ gTableElement,
+ rowIndex,
+ colIndex,
+ gCellData,
+ startRowIndexObj,
+ startColIndexObj,
+ rowSpanObj,
+ colSpanObj,
+ actualRowSpanObj,
+ actualColSpanObj,
+ isSelectedObj
+ );
+ // We didn't find a cell
+ if (!gCellData.value) {
+ return false;
+ }
+ } catch (ex) {
+ return false;
+ }
+
+ gCellData.startRowIndex = startRowIndexObj.value;
+ gCellData.startColIndex = startColIndexObj.value;
+ gCellData.rowSpan = rowSpanObj.value;
+ gCellData.colSpan = colSpanObj.value;
+ gCellData.actualRowSpan = actualRowSpanObj.value;
+ gCellData.actualColSpan = actualColSpanObj.value;
+ gCellData.isSelected = isSelectedObj.value;
+ return true;
+}
+
+function SelectCellHAlign() {
+ SetCheckbox("CellHAlignCheckbox");
+ // Once user changes the alignment,
+ // we lose their original "CharAt" alignment"
+ gAlignWasChar = false;
+}
+
+function GetColorAndUpdate(ColorWellID) {
+ var colorWell = document.getElementById(ColorWellID);
+ if (!colorWell) {
+ return;
+ }
+
+ var colorObj = {
+ Type: "",
+ TableColor: 0,
+ CellColor: 0,
+ NoDefault: false,
+ Cancel: false,
+ BackgroundColor: 0,
+ };
+
+ switch (ColorWellID) {
+ case "tableBackgroundCW":
+ colorObj.Type = "Table";
+ colorObj.TableColor = gTableColor;
+ break;
+ case "cellBackgroundCW":
+ colorObj.Type = "Cell";
+ colorObj.CellColor = gCellColor;
+ break;
+ }
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdColorPicker.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ colorObj
+ );
+
+ // User canceled the dialog
+ if (colorObj.Cancel) {
+ return;
+ }
+
+ switch (ColorWellID) {
+ case "tableBackgroundCW":
+ gTableColor = colorObj.BackgroundColor;
+ SetColor(ColorWellID, gTableColor);
+ break;
+ case "cellBackgroundCW":
+ gCellColor = colorObj.BackgroundColor;
+ SetColor(ColorWellID, gCellColor);
+ SetCheckbox("CellColorCheckbox");
+ break;
+ }
+}
+
+function SetColor(ColorWellID, color) {
+ // Save the color
+ if (ColorWellID == "cellBackgroundCW") {
+ if (color) {
+ try {
+ gActiveEditor.setAttributeOrEquivalent(
+ globalCellElement,
+ bgcolor,
+ color,
+ true
+ );
+ } catch (e) {}
+ gDialog.CellInheritColor.collapsed = true;
+ } else {
+ try {
+ gActiveEditor.removeAttributeOrEquivalent(
+ globalCellElement,
+ bgcolor,
+ true
+ );
+ } catch (e) {}
+ // Reveal addition message explaining "default" color
+ gDialog.CellInheritColor.collapsed = false;
+ }
+ } else {
+ if (color) {
+ try {
+ gActiveEditor.setAttributeOrEquivalent(
+ globalTableElement,
+ bgcolor,
+ color,
+ true
+ );
+ } catch (e) {}
+ gDialog.TableInheritColor.collapsed = true;
+ } else {
+ try {
+ gActiveEditor.removeAttributeOrEquivalent(
+ globalTableElement,
+ bgcolor,
+ true
+ );
+ } catch (e) {}
+ gDialog.TableInheritColor.collapsed = false;
+ }
+ SetCheckbox("CellColorCheckbox");
+ }
+
+ setColorWell(ColorWellID, color);
+}
+
+function ChangeSelectionToFirstCell() {
+ if (!GetCellData(0, 0)) {
+ dump("Can't find first cell in table!\n");
+ return;
+ }
+ gCellElement = gCellData.value;
+ globalCellElement = gCellElement;
+
+ gCurRowIndex = 0;
+ gCurColIndex = 0;
+ ChangeSelection(RESET_SELECTION);
+}
+
+function ChangeSelection(newType) {
+ newType = Number(newType);
+
+ if (gSelectedCellsType == newType) {
+ return;
+ }
+
+ if (newType == RESET_SELECTION) {
+ // Restore selection to existing focus cell
+ gSelection.collapse(gCellElement, 0);
+ } else {
+ gSelectedCellsType = newType;
+ }
+
+ // Keep the same focus gCellElement, just change the type
+ DoCellSelection();
+ SetSelectionButtons();
+
+ // Note: globalCellElement should still be a clone of gCellElement
+}
+
+function MoveSelection(forward) {
+ var newRowIndex = gCurRowIndex;
+ var newColIndex = gCurColIndex;
+ var inRow = false;
+
+ if (gSelectedCellsType == SELECT_ROW) {
+ newRowIndex += forward ? 1 : -1;
+
+ // Wrap around if before first or after last row
+ if (newRowIndex < 0) {
+ newRowIndex = gLastRowIndex;
+ } else if (newRowIndex > gLastRowIndex) {
+ newRowIndex = 0;
+ }
+ inRow = true;
+
+ // Use first cell in row for focus cell
+ newColIndex = 0;
+ } else {
+ // Cell or column:
+ if (!forward) {
+ newColIndex--;
+ }
+
+ if (gSelectedCellsType == SELECT_CELL) {
+ // Skip to next cell
+ if (forward) {
+ newColIndex += gCurColSpan;
+ }
+ } else {
+ // SELECT_COLUMN
+ // Use first cell in column for focus cell
+ newRowIndex = 0;
+
+ // Don't skip by colspan,
+ // but find first cell in next cellmap column
+ if (forward) {
+ newColIndex++;
+ }
+ }
+
+ if (newColIndex < 0) {
+ // Request is before the first cell in column
+
+ // Wrap to last cell in column
+ newColIndex = gLastColIndex;
+
+ if (gSelectedCellsType == SELECT_CELL) {
+ // If moving by cell, also wrap to previous...
+ if (newRowIndex > 0) {
+ newRowIndex -= 1;
+ } else {
+ // ...or the last row.
+ newRowIndex = gLastRowIndex;
+ }
+
+ inRow = true;
+ }
+ } else if (newColIndex > gLastColIndex) {
+ // Request is after the last cell in column
+
+ // Wrap to first cell in column
+ newColIndex = 0;
+
+ if (gSelectedCellsType == SELECT_CELL) {
+ // If moving by cell, also wrap to next...
+ if (newRowIndex < gLastRowIndex) {
+ newRowIndex++;
+ } else {
+ // ...or the first row.
+ newRowIndex = 0;
+ }
+
+ inRow = true;
+ }
+ }
+ }
+
+ // Get the cell at the new location
+ do {
+ if (!GetCellData(newRowIndex, newColIndex)) {
+ dump("MoveSelection: CELL NOT FOUND\n");
+ return;
+ }
+ if (inRow) {
+ if (gCellData.startRowIndex == newRowIndex) {
+ break;
+ } else {
+ // Cell spans from a row above, look for the next cell in row.
+ newRowIndex += gCellData.actualRowSpan;
+ }
+ } else if (gCellData.startColIndex == newColIndex) {
+ break;
+ } else {
+ // Cell spans from a Col above, look for the next cell in column
+ newColIndex += gCellData.actualColSpan;
+ }
+ } while (true);
+
+ // Save data for current selection before changing
+ if (gCellDataChanged) {
+ // && gDialog.ApplyBeforeMove.checked)
+ if (!ValidateCellData()) {
+ return;
+ }
+
+ gActiveEditor.beginTransaction();
+ // Apply changes to all selected cells
+ ApplyCellAttributes();
+ gActiveEditor.endTransaction();
+
+ SetCloseButton();
+ }
+
+ // Set cell and other data for new selection
+ gCellElement = gCellData.value;
+
+ // Save globals for new current cell
+ gCurRowIndex = gCellData.startRowIndex;
+ gCurColIndex = gCellData.startColIndex;
+ gCurColSpan = gCellData.actualColSpan;
+
+ // Copy for new global cell
+ globalCellElement = gCellElement.cloneNode(false);
+
+ // Change the selection
+ DoCellSelection();
+
+ // Scroll page so new selection is visible
+ // Using SELECTION_ANCHOR_REGION makes the upper-left corner of first selected cell
+ // the point to bring into view.
+ try {
+ var selectionController = gActiveEditor.selectionController;
+ selectionController.scrollSelectionIntoView(
+ selectionController.SELECTION_NORMAL,
+ selectionController.SELECTION_ANCHOR_REGION,
+ true
+ );
+ } catch (e) {}
+
+ // Reinitialize dialog using new cell
+ // if (!gDialog.KeepCurrentData.checked)
+ // Setting this false unchecks all "set attributes" checkboxes
+ gAdvancedEditUsed = false;
+ InitCellPanel();
+ gAdvancedEditUsed = true;
+}
+
+function DoCellSelection() {
+ // Collapse selection into to the focus cell
+ // so editor uses that as start cell
+ gSelection.collapse(gCellElement, 0);
+
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ try {
+ switch (gSelectedCellsType) {
+ case SELECT_CELL:
+ gActiveEditor.selectTableCell();
+ break;
+ case SELECT_ROW:
+ gActiveEditor.selectTableRow();
+ break;
+ default:
+ gActiveEditor.selectTableColumn();
+ break;
+ }
+ // Get number of cells selected
+ gActiveEditor.getSelectedOrParentTableElement(tagNameObj, countObj);
+ } catch (e) {}
+
+ if (tagNameObj.value == "td") {
+ gSelectedCellCount = countObj.value;
+ } else {
+ gSelectedCellCount = 0;
+ }
+
+ // Currently, we can only allow advanced editing on ONE cell element at a time
+ // else we ignore CSS, JS, and HTML attributes not already in dialog
+ SetElementEnabled(gDialog.AdvancedEditCell, gSelectedCellCount == 1);
+
+ gDialog.AdvancedEditCell.setAttribute(
+ "tooltiptext",
+ gSelectedCellCount > 1
+ ? GetString("AdvancedEditForCellMsg")
+ : gDialog.AdvancedEditCellToolTipText
+ );
+}
+
+function SetSelectionButtons() {
+ if (gSelectedCellsType == SELECT_ROW) {
+ // Trigger CSS to set images of up and down arrows
+ gDialog.PreviousButton.setAttribute("type", "row");
+ gDialog.NextButton.setAttribute("type", "row");
+ } else {
+ // or images of left and right arrows
+ gDialog.PreviousButton.setAttribute("type", "col");
+ gDialog.NextButton.setAttribute("type", "col");
+ }
+ DisableSelectionButtons(
+ (gSelectedCellsType == SELECT_ROW && gRowCount == 1) ||
+ (gSelectedCellsType == SELECT_COLUMN && gColCount == 1) ||
+ (gRowCount == 1 && gColCount == 1)
+ );
+}
+
+function DisableSelectionButtons(disable) {
+ gDialog.PreviousButton.setAttribute("disabled", disable ? "true" : "false");
+ gDialog.NextButton.setAttribute("disabled", disable ? "true" : "false");
+}
+
+function SwitchToValidatePanel() {
+ if (gDialog.TabBox.selectedTab != gValidateTab) {
+ gDialog.TabBox.selectedTab = gValidateTab;
+ }
+}
+
+function SetAlign(listID, defaultValue, element, attName) {
+ var value = document.getElementById(listID).value;
+ if (value == defaultValue) {
+ try {
+ gActiveEditor.removeAttributeOrEquivalent(element, attName, true);
+ } catch (e) {}
+ } else {
+ try {
+ gActiveEditor.setAttributeOrEquivalent(element, attName, value, true);
+ } catch (e) {}
+ }
+}
+
+function ValidateTableData() {
+ gValidateTab = gDialog.TableTab;
+ gNewRowCount = Number(
+ ValidateNumber(gDialog.TableRowsInput, null, 1, gMaxRows, null, true, true)
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ gNewColCount = Number(
+ ValidateNumber(
+ gDialog.TableColumnsInput,
+ null,
+ 1,
+ gMaxColumns,
+ null,
+ true,
+ true
+ )
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ // If user is deleting any cells, get confirmation
+ // (This is a global to the dialog and we ask only once per dialog session)
+ if (!gCanDelete && (gNewRowCount < gRowCount || gNewColCount < gColCount)) {
+ if (
+ ConfirmWithTitle(
+ GetString("DeleteTableTitle"),
+ GetString("DeleteTableMsg"),
+ GetString("DeleteCells")
+ )
+ ) {
+ gCanDelete = true;
+ } else {
+ SetTextboxFocus(
+ gNewRowCount < gRowCount
+ ? gDialog.TableRowsInput
+ : gDialog.TableColumnsInput
+ );
+ return false;
+ }
+ }
+
+ ValidateNumber(
+ gDialog.TableWidthInput,
+ gDialog.TableWidthUnits,
+ 1,
+ gMaxTableSize,
+ globalTableElement,
+ "width"
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ if (gUseCSS) {
+ ValidateNumber(
+ gDialog.TableHeightInput,
+ gDialog.TableHeightUnits,
+ 1,
+ gMaxTableSize,
+ globalTableElement,
+ "height"
+ );
+ if (gValidationError) {
+ return false;
+ }
+ }
+
+ ValidateNumber(
+ gDialog.BorderWidthInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalTableElement,
+ "border"
+ );
+ // TODO: Deal with "BORDER" without value issue
+ if (gValidationError) {
+ return false;
+ }
+
+ ValidateNumber(
+ gDialog.SpacingInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalTableElement,
+ "cellspacing"
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ ValidateNumber(
+ gDialog.PaddingInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalTableElement,
+ "cellpadding"
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ SetAlign("TableAlignList", defHAlign, globalTableElement, "align");
+
+ // Color is set on globalCellElement immediately
+ return true;
+}
+
+function ValidateCellData() {
+ gValidateTab = gDialog.CellTab;
+
+ if (gDialog.CellHeightCheckbox.checked) {
+ ValidateNumber(
+ gDialog.CellHeightInput,
+ gDialog.CellHeightUnits,
+ 1,
+ gMaxTableSize,
+ globalCellElement,
+ "height"
+ );
+ if (gValidationError) {
+ return false;
+ }
+ }
+
+ if (gDialog.CellWidthCheckbox.checked) {
+ ValidateNumber(
+ gDialog.CellWidthInput,
+ gDialog.CellWidthUnits,
+ 1,
+ gMaxTableSize,
+ globalCellElement,
+ "width"
+ );
+ if (gValidationError) {
+ return false;
+ }
+ }
+
+ if (gDialog.CellHAlignCheckbox.checked) {
+ var hAlign = gDialog.CellHAlignList.value;
+
+ // Horizontal alignment is complicated by "char" type
+ // We don't change current values if user didn't edit alignment
+ if (!gAlignWasChar) {
+ globalCellElement.removeAttribute(charStr);
+
+ // Always set "align" attribute,
+ // so the default "left" is effective in a cell
+ // when parent row has align set.
+ globalCellElement.setAttribute("align", hAlign);
+ }
+ }
+
+ if (gDialog.CellVAlignCheckbox.checked) {
+ // Always set valign (no default in 2nd param) so
+ // the default "middle" is effective in a cell
+ // when parent row has valign set.
+ SetAlign("CellVAlignList", "", globalCellElement, "valign");
+ }
+
+ if (gDialog.TextWrapCheckbox.checked) {
+ if (gDialog.TextWrapList.value == "nowrap") {
+ try {
+ gActiveEditor.setAttributeOrEquivalent(
+ globalCellElement,
+ "nowrap",
+ "nowrap",
+ true
+ );
+ } catch (e) {}
+ } else {
+ try {
+ gActiveEditor.removeAttributeOrEquivalent(
+ globalCellElement,
+ "nowrap",
+ true
+ );
+ } catch (e) {}
+ }
+ }
+
+ return true;
+}
+
+function ValidateData() {
+ var result;
+
+ // Validate current panel first
+ if (gDialog.TabBox.selectedTab == gDialog.TableTab) {
+ result = ValidateTableData();
+ if (result) {
+ result = ValidateCellData();
+ }
+ } else {
+ result = ValidateCellData();
+ if (result) {
+ result = ValidateTableData();
+ }
+ }
+ if (!result) {
+ return false;
+ }
+
+ // Set global element for AdvancedEdit
+ if (gDialog.TabBox.selectedTab == gDialog.TableTab) {
+ globalElement = globalTableElement;
+ } else {
+ globalElement = globalCellElement;
+ }
+
+ return true;
+}
+
+function ChangeCellTextbox(textboxID) {
+ // Filter input for just integers
+ forceInteger(textboxID);
+
+ if (gDialog.TabBox.selectedTab == gDialog.CellTab) {
+ gCellDataChanged = true;
+ }
+}
+
+// Call this when a textbox or menulist is changed
+// so the checkbox is automatically set
+function SetCheckbox(checkboxID) {
+ if (checkboxID && checkboxID.length > 0) {
+ // Set associated checkbox
+ document.getElementById(checkboxID).checked = true;
+ }
+ gCellDataChanged = true;
+}
+
+function ChangeIntTextbox(checkboxID) {
+ // Set associated checkbox
+ SetCheckbox(checkboxID);
+}
+
+function CloneAttribute(destElement, srcElement, attr) {
+ var value = srcElement.getAttribute(attr);
+ // Use editor methods since we are always
+ // modifying a table in the document and
+ // we need transaction system for undo
+ try {
+ if (!value || value.length == 0) {
+ gActiveEditor.removeAttributeOrEquivalent(destElement, attr, false);
+ } else {
+ gActiveEditor.setAttributeOrEquivalent(destElement, attr, value, false);
+ }
+ } catch (e) {}
+}
+
+/* eslint-disable complexity */
+function ApplyTableAttributes() {
+ var newAlign = gDialog.TableCaptionList.value;
+ if (!newAlign) {
+ newAlign = "";
+ }
+
+ if (gTableCaptionElement) {
+ // Get current alignment
+ var align = GetHTMLOrCSSStyleValue(
+ gTableCaptionElement,
+ "align",
+ "caption-side"
+ ).toLowerCase();
+ // This is the default
+ if (!align) {
+ align = "top";
+ }
+
+ if (newAlign == "") {
+ // Remove existing caption
+ try {
+ gActiveEditor.deleteNode(gTableCaptionElement);
+ } catch (e) {}
+ gTableCaptionElement = null;
+ } else if (newAlign != align) {
+ try {
+ if (newAlign == "top") {
+ // This is default, so don't explicitly set it
+ gActiveEditor.removeAttributeOrEquivalent(
+ gTableCaptionElement,
+ "align",
+ false
+ );
+ } else {
+ gActiveEditor.setAttributeOrEquivalent(
+ gTableCaptionElement,
+ "align",
+ newAlign,
+ false
+ );
+ }
+ } catch (e) {}
+ }
+ } else if (newAlign != "") {
+ // Create and insert a caption:
+ try {
+ gTableCaptionElement = gActiveEditor.createElementWithDefaults("caption");
+ } catch (e) {}
+ if (gTableCaptionElement) {
+ if (newAlign != "top") {
+ gTableCaptionElement.setAttribute("align", newAlign);
+ }
+
+ // Insert it into the table - caption is always inserted as first child
+ try {
+ gActiveEditor.insertNode(gTableCaptionElement, gTableElement, 0);
+ } catch (e) {}
+
+ // Put selection back where it was
+ ChangeSelection(RESET_SELECTION);
+ }
+ }
+
+ var countDelta;
+ var foundCell;
+ var i;
+
+ if (gNewRowCount != gRowCount) {
+ countDelta = gNewRowCount - gRowCount;
+ if (gNewRowCount > gRowCount) {
+ // Append new rows
+ // Find first cell in last row
+ if (GetCellData(gLastRowIndex, 0)) {
+ try {
+ // Move selection to the last cell
+ gSelection.collapse(gCellData.value, 0);
+ // Insert new rows after it
+ gActiveEditor.insertTableRow(countDelta, true);
+ gRowCount = gNewRowCount;
+ gLastRowIndex = gRowCount - 1;
+ // Put selection back where it was
+ ChangeSelection(RESET_SELECTION);
+ } catch (ex) {
+ dump("FAILED TO FIND FIRST CELL IN LAST ROW\n");
+ }
+ }
+ } else if (gCanDelete) {
+ // Delete rows
+ // Find first cell starting in first row we delete
+ var firstDeleteRow = gRowCount + countDelta;
+ foundCell = false;
+ for (i = 0; i <= gLastColIndex; i++) {
+ if (!GetCellData(firstDeleteRow, i)) {
+ // We failed to find a cell.
+ break;
+ }
+
+ if (gCellData.startRowIndex == firstDeleteRow) {
+ foundCell = true;
+ break;
+ }
+ }
+ if (foundCell) {
+ try {
+ // Move selection to the cell we found
+ gSelection.collapse(gCellData.value, 0);
+ gActiveEditor.deleteTableRow(-countDelta);
+ gRowCount = gNewRowCount;
+ gLastRowIndex = gRowCount - 1;
+ if (gCurRowIndex > gLastRowIndex) {
+ // We are deleting our selection
+ // move it to start of table
+ ChangeSelectionToFirstCell();
+ } else {
+ // Put selection back where it was.
+ ChangeSelection(RESET_SELECTION);
+ }
+ } catch (ex) {
+ dump("FAILED TO FIND FIRST CELL IN LAST ROW\n");
+ }
+ }
+ }
+ }
+
+ if (gNewColCount != gColCount) {
+ countDelta = gNewColCount - gColCount;
+
+ if (gNewColCount > gColCount) {
+ // Append new columns
+ // Find last cell in first column
+ if (GetCellData(0, gLastColIndex)) {
+ try {
+ // Move selection to the last cell
+ gSelection.collapse(gCellData.value, 0);
+ gActiveEditor.insertTableColumn(countDelta, true);
+ gColCount = gNewColCount;
+ gLastColIndex = gColCount - 1;
+ // Restore selection
+ ChangeSelection(RESET_SELECTION);
+ } catch (ex) {
+ dump("FAILED TO FIND FIRST CELL IN LAST COLUMN\n");
+ }
+ }
+ } else if (gCanDelete) {
+ // Delete columns
+ var firstDeleteCol = gColCount + countDelta;
+ foundCell = false;
+ for (i = 0; i <= gLastRowIndex; i++) {
+ // Find first cell starting in first column we delete
+ if (!GetCellData(i, firstDeleteCol)) {
+ // We failed to find a cell.
+ break;
+ }
+
+ if (gCellData.startColIndex == firstDeleteCol) {
+ foundCell = true;
+ break;
+ }
+ }
+ if (foundCell) {
+ try {
+ // Move selection to the cell we found
+ gSelection.collapse(gCellData.value, 0);
+ gActiveEditor.deleteTableColumn(-countDelta);
+ gColCount = gNewColCount;
+ gLastColIndex = gColCount - 1;
+ if (gCurColIndex > gLastColIndex) {
+ ChangeSelectionToFirstCell();
+ } else {
+ ChangeSelection(RESET_SELECTION);
+ }
+ } catch (ex) {
+ dump("FAILED TO FIND FIRST CELL IN LAST ROW\n");
+ }
+ }
+ }
+ }
+
+ // Clone all remaining attributes to pick up
+ // anything changed by Advanced Edit Dialog
+ try {
+ gActiveEditor.cloneAttributes(gTableElement, globalTableElement);
+ } catch (e) {}
+}
+/* eslint-enable complexity */
+
+function ApplyCellAttributes() {
+ let selectedCells = gActiveEditor.getSelectedCells();
+ if (selectedCells.length == 0) {
+ return;
+ }
+
+ if (selectedCells.length == 1) {
+ let cell = selectedCells[0];
+ // When only one cell is selected, simply clone entire element,
+ // thus CSS and JS from Advanced edit is copied
+
+ gActiveEditor.cloneAttributes(cell, globalCellElement);
+
+ if (gDialog.CellStyleCheckbox.checked) {
+ let currentStyleIndex = cell.nodeName.toLowerCase() == "th" ? 1 : 0;
+ if (gDialog.CellStyleList.selectedIndex != currentStyleIndex) {
+ // Switch cell types
+ // (replaces with new cell and copies attributes and contents)
+ gActiveEditor.switchTableCellHeaderType(cell);
+ }
+ }
+ } else {
+ // Apply changes to all selected cells
+ // XXX THIS DOESN'T COPY ADVANCED EDIT CHANGES!
+ for (let cell of selectedCells) {
+ ApplyAttributesToOneCell(cell);
+ }
+ }
+ gCellDataChanged = false;
+}
+
+function ApplyAttributesToOneCell(destElement) {
+ if (gDialog.CellHeightCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "height");
+ }
+
+ if (gDialog.CellWidthCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "width");
+ }
+
+ if (gDialog.CellHAlignCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "align");
+ CloneAttribute(destElement, globalCellElement, charStr);
+ }
+
+ if (gDialog.CellVAlignCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "valign");
+ }
+
+ if (gDialog.TextWrapCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "nowrap");
+ }
+
+ if (gDialog.CellStyleCheckbox.checked) {
+ var newStyleIndex = gDialog.CellStyleList.selectedIndex;
+ var currentStyleIndex = destElement.nodeName.toLowerCase() == "th" ? 1 : 0;
+
+ if (newStyleIndex != currentStyleIndex) {
+ // Switch cell types
+ // (replaces with new cell and copies attributes and contents)
+ try {
+ destElement = gActiveEditor.switchTableCellHeaderType(destElement);
+ } catch (e) {}
+ }
+ }
+
+ if (gDialog.CellColorCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "bgcolor");
+ }
+}
+
+function SetCloseButton() {
+ // Change text on "Cancel" button after Apply is used
+ if (!gApplyUsed) {
+ document
+ .querySelector("dialog")
+ .setAttribute(
+ "buttonlabelcancel",
+ document.querySelector("dialog").getAttribute("buttonlabelclose")
+ );
+ gApplyUsed = true;
+ }
+}
+
+function Apply() {
+ if (ValidateData()) {
+ gActiveEditor.beginTransaction();
+
+ ApplyTableAttributes();
+
+ // We may have just a table, so check for cell element
+ if (globalCellElement) {
+ ApplyCellAttributes();
+ }
+
+ gActiveEditor.endTransaction();
+
+ SetCloseButton();
+ return true;
+ }
+ return false;
+}
+
+function onAccept(event) {
+ // Do same as Apply and close window if ValidateData succeeded
+ var retVal = Apply();
+ if (retVal) {
+ SaveWindowLocation();
+ } else {
+ event.preventDefault();
+ }
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml b/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml
new file mode 100644
index 0000000000..a82d5e18c5
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml
@@ -0,0 +1,472 @@
+<?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://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edTableProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorTableProperties.dtd">
+%edTableProperties;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&tableWindow.title;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog
+ id="tableDlg"
+ buttons="accept,extra1,cancel"
+ buttonlabelclose="&closeButton.label;"
+ buttonlabelextra1="&applyButton.label;"
+ buttonaccesskeyextra1="&applyButton.accesskey;"
+ >
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdTableProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <tabbox id="TabBox">
+ <tabs flex="1">
+ <tab id="TableTab" label="&tableTab.label;" />
+ <tab id="CellTab" label="&cellTab.label;" />
+ </tabs>
+ <tabpanels>
+ <!-- TABLE PANEL -->
+ <vbox>
+ <html:fieldset orient="horizontal">
+ <html:legend>&size.label;</html:legend>
+ <hbox>
+ <vbox>
+ <hbox>
+ <vbox>
+ <hbox align="center" flex="1">
+ <label
+ id="TableRowsLabel"
+ value="&tableRows.label;"
+ accesskey="&tableRows.accessKey;"
+ control="TableRowsInput"
+ />
+ </hbox>
+ <hbox align="center" flex="1">
+ <label
+ id="TableColumnsLabel"
+ value="&tableColumns.label;"
+ accesskey="&tableColumns.accessKey;"
+ control="TableColumnsInput"
+ />
+ </hbox>
+ </vbox>
+ <vbox>
+ <html:input
+ id="TableRowsInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="TableRowsLabel"
+ />
+ <html:input
+ id="TableColumnsInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="TableColumnsLabel"
+ />
+ </vbox>
+ </hbox>
+ </vbox>
+ <vbox>
+ <html:div class="grid-three-column">
+ <html:div class="flex-items-center">
+ <label
+ id="TableHeightLabel"
+ value="&tableHeight.label;"
+ accesskey="&tableHeight.accessKey;"
+ control="TableHeightInput"
+ />
+ </html:div>
+ <html:div>
+ <html:input
+ id="TableHeightInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="TableHeightLabel"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <menulist id="TableHeightUnits" />
+ </html:div>
+ <html:div class="flex-items-center">
+ <label
+ id="TableWidthLabel"
+ value="&tableWidth.label;"
+ accesskey="&tableWidth.accessKey;"
+ control="TableWidthInput"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <html:input
+ id="TableWidthInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="TableWidthLabel"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <menulist id="TableWidthUnits" />
+ </html:div>
+ </html:div>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ <html:fieldset>
+ <html:legend>&tableBorderSpacing.label;</html:legend>
+ <hbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <label
+ id="BorderWidthLabel"
+ control="BorderWidthInput"
+ value="&tableBorderWidth.label;"
+ accesskey="&tableBorderWidth.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="SpacingLabel"
+ control="SpacingInput"
+ value="&tableSpacing.label;"
+ accesskey="&tableSpacing.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="PaddingLabel"
+ control="PaddingInput"
+ value="&tablePadding.label;"
+ accesskey="&tablePadding.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ <vbox>
+ <html:input
+ id="BorderWidthInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="BorderWidthLabel"
+ />
+ <html:input
+ id="SpacingInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="SpacingLabel"
+ />
+ <html:input
+ id="PaddingInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="PaddingLabel"
+ />
+ </vbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <label align="start" value="&pixels.label;" />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label value="&tablePxBetwCells.label;" />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label value="&tablePxBetwBrdrCellContent.label;" />
+ </hbox>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ <!-- Table Alignment and Caption -->
+ <hbox flex="1" align="center">
+ <label
+ control="TableAlignList"
+ value="&tableAlignment.label;"
+ accesskey="&tableAlignment.accessKey;"
+ />
+ <menulist id="TableAlignList">
+ <menupopup>
+ <menuitem label="&AlignLeft.label;" value="left" />
+ <menuitem label="&AlignCenter.label;" value="center" />
+ <menuitem label="&AlignRight.label;" value="right" />
+ </menupopup>
+ </menulist>
+ <spacer class="spacer" />
+ <label
+ control="TableCaptionList"
+ value="&tableCaption.label;"
+ accesskey="&tableCaption.accessKey;"
+ />
+ <menulist id="TableCaptionList">
+ <menupopup>
+ <menuitem label="&tableCaptionNone.label;" value="" />
+ <menuitem label="&tableCaptionAbove.label;" value="top" />
+ <menuitem label="&tableCaptionBelow.label;" value="bottom" />
+ <menuitem label="&tableCaptionLeft.label;" value="left" />
+ <menuitem label="&tableCaptionRight.label;" value="right" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <separator class="groove" />
+ <hbox align="center">
+ <label value="&backgroundColor.label;" />
+ <button
+ id="tableBackground"
+ class="color-button"
+ oncommand="GetColorAndUpdate('tableBackgroundCW');"
+ >
+ <spacer id="tableBackgroundCW" class="color-well" />
+ </button>
+ <spacer class="spacer" />
+ <label
+ id="TableInheritColor"
+ value="&tableInheritColor.label;"
+ collapsed="true"
+ />
+ </hbox>
+ <separator class="groove" />
+ <hbox flex="1" align="center">
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton"
+ oncommand="onAdvancedEdit();"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <spacer flex="1" /> </vbox
+ ><!-- Table Panel -->
+
+ <!-- CELL PANEL -->
+ <vbox>
+ <html:fieldset>
+ <html:legend>&cellSelection.label;</html:legend>
+ <vbox>
+ <menulist
+ id="SelectionList"
+ oncommand="ChangeSelection(event.target.value)"
+ >
+ <menupopup>
+ <!-- JS code assumes order is Cell, Row, Column -->
+ <menuitem label="&cellSelectCell.label;" value="1" />
+ <menuitem label="&cellSelectRow.label;" value="2" />
+ <menuitem label="&cellSelectColumn.label;" value="3" />
+ </menupopup>
+ </menulist>
+ <hbox>
+ <button
+ id="PreviousButton"
+ label="&cellSelectPrevious.label;"
+ accesskey="&cellSelectPrevious.accessKey;"
+ oncommand="MoveSelection(0)"
+ />
+ <button
+ id="NextButton"
+ label="&cellSelectNext.label;"
+ accesskey="&cellSelectNext.accessKey;"
+ oncommand="MoveSelection(1)"
+ />
+ </hbox>
+ <hbox flex="1"> &applyBeforeChange.label; </hbox>
+ </vbox>
+ </html:fieldset>
+
+ <separator class="groove" />
+
+ <hbox align="center">
+ <html:fieldset>
+ <html:legend>&size.label;</html:legend>
+ <hbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <checkbox
+ id="CellHeightCheckbox"
+ label="&tableHeight.label;"
+ accesskey="&tableHeight.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <checkbox
+ id="CellWidthCheckbox"
+ label="&tableWidth.label;"
+ accesskey="&tableWidth.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ <vbox flex="1">
+ <hbox flex="1" align="center">
+ <html:input
+ id="CellHeightInput"
+ type="number"
+ class="narrow input-inline"
+ onchange="ChangeIntTextbox('CellHeightCheckbox');"
+ aria-labelledby="CellHeightCheckbox"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <html:input
+ id="CellWidthInput"
+ type="number"
+ class="narrow input-inline"
+ onchange="ChangeIntTextbox('CellWidthCheckbox');"
+ aria-labelledby="CellWidthCheckbox"
+ />
+ </hbox>
+ </vbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <menulist
+ id="CellHeightUnits"
+ oncommand="SetCheckbox('CellHeightCheckbox');"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <menulist
+ id="CellWidthUnits"
+ oncommand="SetCheckbox('CellWidthCheckbox');"
+ />
+ </hbox>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ <html:fieldset>
+ <html:legend>&cellContentAlignment.label;</html:legend>
+ <hbox>
+ <vbox>
+ <hbox align="center" flex="1">
+ <checkbox
+ id="CellVAlignCheckbox"
+ label="&cellVertical.label;"
+ accesskey="&cellVertical.accessKey;"
+ />
+ </hbox>
+ <hbox align="center" flex="1">
+ <checkbox
+ id="CellHAlignCheckbox"
+ label="&cellHorizontal.label;"
+ accesskey="&cellHorizontal.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ <vbox flex="1">
+ <menulist
+ id="CellVAlignList"
+ oncommand="SetCheckbox('CellVAlignCheckbox');"
+ >
+ <menupopup>
+ <menuitem label="&cellAlignTop.label;" value="top" />
+ <menuitem
+ label="&cellAlignMiddle.label;"
+ value="middle"
+ />
+ <menuitem
+ label="&cellAlignBottom.label;"
+ value="bottom"
+ />
+ </menupopup>
+ </menulist>
+ <menulist id="CellHAlignList" oncommand="SelectCellHAlign()">
+ <menupopup>
+ <menuitem label="&AlignLeft.label;" value="left" />
+ <menuitem label="&AlignCenter.label;" value="center" />
+ <menuitem label="&AlignRight.label;" value="right" />
+ <menuitem
+ label="&cellAlignJustify.label;"
+ value="justify"
+ />
+ </menupopup>
+ </menulist>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ </hbox>
+ <spacer class="spacer" />
+ <hbox align="center">
+ <checkbox
+ id="CellStyleCheckbox"
+ label="&cellStyle.label;"
+ accesskey="&cellStyle.accessKey;"
+ />
+ <menulist
+ id="CellStyleList"
+ oncommand="SetCheckbox('CellStyleCheckbox');"
+ >
+ <menupopup>
+ <menuitem label="&cellNormal.label;" value="td" />
+ <menuitem label="&cellHeader.label;" value="th" />
+ </menupopup>
+ </menulist>
+ <spacer flex="1" />
+ <checkbox
+ id="TextWrapCheckbox"
+ label="&cellTextWrap.label;"
+ accesskey="&cellTextWrap.accessKey;"
+ />
+ <menulist
+ id="TextWrapList"
+ oncommand="SetCheckbox('TextWrapCheckbox');"
+ >
+ <menupopup>
+ <menuitem label="&cellWrap.label;" value="wrap" />
+ <menuitem label="&cellNoWrap.label;" value="nowrap" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <separator class="groove" />
+ <hbox align="center">
+ <checkbox
+ id="CellColorCheckbox"
+ label="&backgroundColor.label;"
+ accesskey="&backgroundColor.accessKey;"
+ />
+ <button
+ class="color-button"
+ oncommand="GetColorAndUpdate('cellBackgroundCW');"
+ >
+ <spacer id="cellBackgroundCW" class="color-well" />
+ </button>
+ <spacer class="spacer" />
+ <label
+ id="CellInheritColor"
+ value="&cellInheritColor.label;"
+ collapsed="true"
+ />
+ </hbox>
+ <separator class="groove" />
+ <hbox align="center">
+ <description class="wrap" flex="1" style="width: 1em"
+ >&cellUseCheckboxHelp.label;</description
+ >
+ <button
+ id="AdvancedEditButton2"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <spacer flex="1" /> </vbox
+ ><!-- Cell Panel -->
+ </tabpanels>
+ </tabbox>
+ <spacer class="spacer" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/editFormatButtons.inc.xhtml b/comm/mail/components/compose/content/editFormatButtons.inc.xhtml
new file mode 100644
index 0000000000..f84b2610e6
--- /dev/null
+++ b/comm/mail/components/compose/content/editFormatButtons.inc.xhtml
@@ -0,0 +1,282 @@
+# 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/.
+
+ <!-- Formatting toolbar items. "value" are HTML tagnames, don't translate -->
+ <menulist id="ParagraphSelect"
+ class="toolbar-focustarget"
+ oncommand="setParagraphState(event);"
+ crop="end"
+ tooltiptext="&ParagraphSelect.tooltip;"
+ observes="cmd_renderedHTMLEnabler">
+ <menupopup id="ParagraphPopup">
+ <menuitem id="toolbarmenu_bodyText" label="&bodyTextCmd.label;" value=""/>
+ <menuitem id="toolbarmenu_paragraph" label="&paragraphParagraphCmd.label;" value="p"/>
+ <menuitem id="toolbarmenu_h1" label="&heading1Cmd.label;" value="h1"/>
+ <menuitem id="toolbarmenu_h2" label="&heading2Cmd.label;" value="h2"/>
+ <menuitem id="toolbarmenu_h3" label="&heading3Cmd.label;" value="h3"/>
+ <menuitem id="toolbarmenu_h4" label="&heading4Cmd.label;" value="h4"/>
+ <menuitem id="toolbarmenu_h5" label="&heading5Cmd.label;" value="h5"/>
+ <menuitem id="toolbarmenu_h6" label="&heading6Cmd.label;" value="h6"/>
+ <menuitem id="toolbarmenu_address" label="&paragraphAddressCmd.label;" value="address"/>
+ <menuitem id="toolbarmenu_pre" label="&paragraphPreformatCmd.label;" value="pre"/>
+ </menupopup>
+ </menulist>
+
+ <!-- "value" are HTML tagnames, don't translate -->
+ <menulist id="FontFaceSelect"
+ class="toolbar-focustarget"
+ oncommand="doStatefulCommand('cmd_fontFace', event.target.value)"
+ crop="center"
+ sizetopopup="pref"
+ tooltiptext="&FontFaceSelect.tooltip;"
+ observes="cmd_renderedHTMLEnabler">
+ <menupopup id="FontFacePopup">
+ <menuitem id="toolbarmenu_fontVarWidth" label="&fontVarWidth.label;" value=""/>
+ <menuitem id="toolbarmenu_fontFixedWidth" label="&fontFixedWidth.label;" value="monospace"/>
+ <menuseparator id="toolbarmenuAfterGenericFontsSeparator"/>
+ <menuitem id="toolbarmenu_fontHelvetica" label="&fontHelvetica.label;"
+ value="Helvetica, Arial, sans-serif"
+ value_parsed="helvetica,arial,sans-serif"/>
+ <menuitem id="toolbarmenu_fontTimes" label="&fontTimes.label;"
+ value="Times New Roman, Times, serif"
+ value_parsed="times new roman,times,serif"/>
+ <menuitem id="toolbarmenu_fontCourier" label="&fontCourier.label;"
+ value="Courier New, Courier, monospace"
+ value_parsed="courier new,courier,monospace"/>
+ <menuseparator id="toolbarmenuAfterDefaultFontsSeparator"
+ class="fontFaceMenuAfterDefaultFonts"/>
+ <menuseparator id="toolbarmenuAfterUsedFontsSeparator"
+ class="fontFaceMenuAfterUsedFonts"
+ hidden="true"/>
+ <!-- Local font face items added here by initLocalFontFaceMenu() -->
+ </menupopup>
+ </menulist>
+
+ <toolbaritem id="color-buttons-container"
+ class="formatting-button"
+ align="center">
+ <stack id="ColorButtons">
+ <box class="color-button" id="BackgroundColorButton"
+ onclick="if (!this.hasAttribute('disabled') || this.getAttribute('disabled') != 'true') { EditorSelectColor('', event); }"
+ tooltiptext="&BackgroundColorButton.tooltip;"
+ observes="cmd_backgroundColor"
+ oncommand="/* See MsgComposeCommands.js::updateAllItems for why this attribute is needed here. */"/>
+ <box class="color-button" id="TextColorButton"
+ onclick="if (!this.hasAttribute('disabled') || this.getAttribute('disabled') != 'true') { EditorSelectColor('Text', event); }"
+ tooltiptext="&TextColorButton.tooltip;"
+ observes="cmd_fontColor"
+ oncommand="/* See MsgComposeCommands.js::updateAllItems for why this attribute is needed here. */"/>
+ </stack>
+ </toolbaritem>
+
+ <toolbarbutton id="AbsoluteFontSizeButton"
+ class="formatting-button"
+ tooltiptext="&absoluteFontSizeToolbarCmd.tooltip;"
+ type="menu"
+ observes="cmd_renderedHTMLEnabler">
+ <menupopup id="AbsoluteFontSizeButtonPopup"
+ onpopupshowing="initFontSizeMenu(this);"
+ oncommand="setFontSize(event)">
+ <menuitem id="toobarmenu_fontSize_x-small"
+ label="&size-tinyCmd.label;"
+ type="radio" name="fontSize"
+ value="1"/>
+ <menuitem id="toobarmenu_fontSize_small"
+ label="&size-smallCmd.label;"
+ type="radio" name="fontSize"
+ value="2"/>
+ <menuitem id="toobarmenu_fontSize_medium"
+ label="&size-mediumCmd.label;"
+ type="radio" name="fontSize"
+ value="3"/>
+ <menuitem id="toobarmenu_fontSize_large"
+ label="&size-largeCmd.label;"
+ type="radio" name="fontSize"
+ value="4"/>
+ <menuitem id="toobarmenu_fontSize_x-large"
+ label="&size-extraLargeCmd.label;"
+ type="radio" name="fontSize"
+ value="5"/>
+ <menuitem id="toobarmenu_fontSize_xx-large"
+ label="&size-hugeCmd.label;"
+ type="radio" name="fontSize"
+ value="6"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton id="DecreaseFontSizeButton"
+ class="formatting-button"
+ tooltiptext="&decreaseFontSizeToolbarCmd.tooltip;"
+ observes="cmd_decreaseFontStep"/>
+
+ <toolbarbutton id="IncreaseFontSizeButton"
+ class="formatting-button"
+ tooltiptext="&increaseFontSizeToolbarCmd.tooltip;"
+ observes="cmd_increaseFontStep"/>
+
+ <toolbarseparator class="toolbarseparator-standard"/>
+
+ <toolbarbutton id="boldButton"
+ class="formatting-button"
+ tooltiptext="&boldToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_bold"/>
+
+ <toolbarbutton id="italicButton"
+ class="formatting-button"
+ tooltiptext="&italicToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_italic"/>
+
+ <toolbarbutton id="underlineButton"
+ class="formatting-button"
+ tooltiptext="&underlineToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_underline"/>
+
+ <toolbarseparator class="toolbarseparator-standard"/>
+
+ <toolbarbutton id="removeStylingButton"
+ class="formatting-button"
+ data-l10n-id="compose-tool-button-remove-text-styling"
+ observes="cmd_removeStyles"/>
+
+ <toolbarseparator class="toolbarseparator-standard"/>
+
+ <toolbarbutton id="ulButton"
+ class="formatting-button"
+ tooltiptext="&bulletListToolbarCmd.tooltip;"
+ type="radio"
+ group="lists"
+ autoCheck="false"
+ observes="cmd_ul"/>
+
+ <toolbarbutton id="olButton"
+ class="formatting-button"
+ tooltiptext="&numberListToolbarCmd.tooltip;"
+ type="radio"
+ group="lists"
+ autoCheck="false"
+ observes="cmd_ol"/>
+
+ <toolbarbutton id="outdentButton"
+ class="formatting-button"
+ tooltiptext="&outdentToolbarCmd.tooltip;"
+ observes="cmd_outdent"/>
+
+ <toolbarbutton id="indentButton"
+ class="formatting-button"
+ tooltiptext="&indentToolbarCmd.tooltip;"
+ observes="cmd_indent"/>
+
+ <toolbarseparator class="toolbarseparator-standard"/>
+
+ <toolbarbutton id="AlignPopupButton"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&AlignPopupButton.tooltip;"
+ observes="cmd_align">
+ <menupopup id="AlignPopup">
+ <menuitem id="AlignLeftItem" class="menuitem-iconic" label="&alignLeft.label;"
+ oncommand="doStatefulCommand('cmd_align', 'left')"
+ tooltiptext="&alignLeftButton.tooltip;" />
+ <menuitem id="AlignCenterItem" class="menuitem-iconic" label="&alignCenter.label;"
+ oncommand="doStatefulCommand('cmd_align', 'center')"
+ tooltiptext="&alignCenterButton.tooltip;" />
+ <menuitem id="AlignRightItem" class="menuitem-iconic" label="&alignRight.label;"
+ oncommand="doStatefulCommand('cmd_align', 'right')"
+ tooltiptext="&alignRightButton.tooltip;" />
+ <menuitem id="AlignJustifyItem" class="menuitem-iconic" label="&alignJustify.label;"
+ oncommand="doStatefulCommand('cmd_align', 'justify')"
+ tooltiptext="&alignJustifyButton.tooltip;" />
+ </menupopup>
+ </toolbarbutton>
+
+ <!-- InsertPopupButton is used by messengercompose.xhtml -->
+ <toolbarbutton id="InsertPopupButton"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&InsertPopupButton.tooltip;"
+ observes="cmd_renderedHTMLEnabler">
+ <menupopup id="InsertPopup">
+ <menuitem id="InsertLinkItem" class="menuitem-iconic" observes="cmd_link"
+ oncommand="goDoCommand('cmd_link')" label="&linkToolbarCmd.label;"
+ tooltiptext="&linkToolbarCmd.tooltip;" />
+ <menuitem id="InsertAnchorItem" class="menuitem-iconic" observes="cmd_anchor"
+ oncommand="goDoCommand('cmd_anchor')" label="&anchorToolbarCmd.label;"
+ tooltiptext="&anchorToolbarCmd.tooltip;" />
+ <menuitem id="InsertImageItem" class="menuitem-iconic" observes="cmd_image"
+ oncommand="goDoCommand('cmd_image')" label="&imageToolbarCmd.label;"
+ tooltiptext="&imageToolbarCmd.tooltip;" />
+ <menuitem id="InsertHRuleItem" class="menuitem-iconic" observes="cmd_hline"
+ oncommand="goDoCommand('cmd_hline')" label="&hruleToolbarCmd.label;"
+ tooltiptext="&hruleToolbarCmd.tooltip;" />
+ <menuitem id="InsertTableItem" class="menuitem-iconic" observes="cmd_table"
+ oncommand="goDoCommand('cmd_table')" label="&tableToolbarCmd.label;"
+ tooltiptext="&tableToolbarCmd.tooltip;" />
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton id="smileButtonMenu"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&SmileButton.tooltip;"
+ observes="cmd_smiley">
+ <menupopup id="smileyPopup" class="no-icon-menupopup">
+ <menuitem id="smileySmile" class="menuitem-iconic"
+ label="&#128578; &smiley1Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128578;')"/>
+ <menuitem id="smileyFrown" class="menuitem-iconic"
+ label="&#128577; &smiley2Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128577;')"/>
+ <menuitem id="smileyWink" class="menuitem-iconic"
+ label="&#128521; &smiley3Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128521;')"/>
+ <menuitem id="smileyTongue" class="menuitem-iconic"
+ label="&#128539; &smiley4Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128539;')"/>
+ <menuitem id="smileyLaughing" class="menuitem-iconic"
+ label="&#128514; &smiley5Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128514;')"/>
+ <menuitem id="smileyEmbarassed" class="menuitem-iconic"
+ label="&#128563; &smiley6Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128563;')"/>
+ <menuitem id="smileyUndecided" class="menuitem-iconic"
+ label="&#128533; &smiley7Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128533;')"/>
+ <menuitem id="smileySurprise" class="menuitem-iconic"
+ label="&#128558; &smiley8Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128558;')"/>
+ <menuitem id="smileyKiss" class="menuitem-iconic"
+ label="&#128536; &smiley9Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128536;')"/>
+ <menuitem id="smileyYell" class="menuitem-iconic"
+ label="&#128544; &smiley10Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128544;')"/>
+ <menuitem id="smileyCool" class="menuitem-iconic"
+ label="&#128526; &smiley11Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128526;')"/>
+ <menuitem id="smileyMoney" class="menuitem-iconic"
+ label="&#129297; &smiley12Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#129297;')"/>
+ <menuitem id="smileyFoot" class="menuitem-iconic"
+ label="&#128556; &smiley13Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128556;')"/>
+ <menuitem id="smileyInnocent" class="menuitem-iconic"
+ label="&#128519; &smiley14Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128519;')"/>
+ <menuitem id="smileyCry" class="menuitem-iconic"
+ label="&#128557; &smiley15Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128557;')"/>
+ <menuitem id="smileySealed" class="menuitem-iconic"
+ label="&#129296; &smiley16Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#129296;')"/>
+ </menupopup>
+ </toolbarbutton>
diff --git a/comm/mail/components/compose/content/editor.js b/comm/mail/components/compose/content/editor.js
new file mode 100644
index 0000000000..7535ecb0d1
--- /dev/null
+++ b/comm/mail/components/compose/content/editor.js
@@ -0,0 +1,2392 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../../toolkit/content/viewZoomOverlay.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from ComposerCommands.js */
+/* import-globals-from editorUtilities.js */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+/* Main Composer window UI control */
+
+var gComposerWindowControllerID = 0;
+var prefAuthorString = "";
+
+var kDisplayModeNormal = 0;
+var kDisplayModeAllTags = 1;
+var kDisplayModeSource = 2;
+var kDisplayModePreview = 3;
+
+const kDisplayModeMenuIDs = [
+ "viewNormalMode",
+ "viewAllTagsMode",
+ "viewSourceMode",
+ "viewPreviewMode",
+];
+const kDisplayModeTabIDS = [
+ "NormalModeButton",
+ "TagModeButton",
+ "SourceModeButton",
+ "PreviewModeButton",
+];
+const kNormalStyleSheet = "chrome://messenger/skin/shared/editorContent.css";
+const kContentEditableStyleSheet = "resource://gre/res/contenteditable.css";
+
+var kTextMimeType = "text/plain";
+var kHTMLMimeType = "text/html";
+var kXHTMLMimeType = "application/xhtml+xml";
+
+var gPreviousNonSourceDisplayMode = 1;
+var gEditorDisplayMode = -1;
+var gDocWasModified = false; // Check if clean document, if clean then unload when user "Opens"
+var gContentWindow = 0;
+var gSourceContentWindow = 0;
+var gSourceTextEditor = null;
+var gContentWindowDeck;
+var gFormatToolbar;
+var gFormatToolbarHidden = false;
+var gViewFormatToolbar;
+var gChromeState;
+var gColorObj = {
+ LastTextColor: "",
+ LastBackgroundColor: "",
+ LastHighlightColor: "",
+ Type: "",
+ SelectedType: "",
+ NoDefault: false,
+ Cancel: false,
+ HighlightColor: "",
+ BackgroundColor: "",
+ PageColor: "",
+ TextColor: "",
+ TableColor: "",
+ CellColor: "",
+};
+var gDefaultTextColor = "";
+var gDefaultBackgroundColor = "";
+var gCSSPrefListener;
+var gEditorToolbarPrefListener;
+var gReturnInParagraphPrefListener;
+var gLocalFonts = null;
+
+var gLastFocusNode = null;
+var gLastFocusNodeWasSelected = false;
+
+// These must be kept in synch with the XUL <options> lists
+var gFontSizeNames = [
+ "xx-small",
+ "x-small",
+ "small",
+ "medium",
+ "large",
+ "x-large",
+ "xx-large",
+];
+
+var kUseCssPref = "editor.use_css";
+var kCRInParagraphsPref = "editor.CR_creates_new_p";
+
+// This should be called by all editor users when they close their window.
+function EditorCleanup() {
+ SwitchInsertCharToAnotherEditorOrClose();
+}
+
+/** @implements {nsIDocumentStateListener} */
+var DocumentReloadListener = {
+ NotifyDocumentWillBeDestroyed() {},
+
+ NotifyDocumentStateChanged(isNowDirty) {
+ var editor = GetCurrentEditor();
+ try {
+ // unregister the listener to prevent multiple callbacks
+ editor.removeDocumentStateListener(DocumentReloadListener);
+
+ var charset = editor.documentCharacterSet;
+
+ // update the META charset with the current presentation charset
+ editor.documentCharacterSet = charset;
+ } catch (e) {}
+ },
+};
+
+// implements nsIObserver
+var gEditorDocumentObserver = {
+ observe(aSubject, aTopic, aData) {
+ // Should we allow this even if NOT the focused editor?
+ var commandManager = GetCurrentCommandManager();
+ if (commandManager != aSubject) {
+ return;
+ }
+
+ var editor = GetCurrentEditor();
+ switch (aTopic) {
+ case "obs_documentCreated":
+ // Just for convenience
+ gContentWindow = window.content;
+
+ // Get state to see if document creation succeeded
+ var params = newCommandParams();
+ if (!params) {
+ return;
+ }
+
+ try {
+ commandManager.getCommandState(aTopic, gContentWindow, params);
+ var errorStringId = 0;
+ var editorStatus = params.getLongValue("state_data");
+ if (!editor && editorStatus == Ci.nsIEditingSession.eEditorOK) {
+ dump(
+ "\n ****** NO EDITOR BUT NO EDITOR ERROR REPORTED ******* \n\n"
+ );
+ editorStatus = Ci.nsIEditingSession.eEditorErrorUnknown;
+ }
+
+ switch (editorStatus) {
+ case Ci.nsIEditingSession.eEditorErrorCantEditFramesets:
+ errorStringId = "CantEditFramesetMsg";
+ break;
+ case Ci.nsIEditingSession.eEditorErrorCantEditMimeType:
+ errorStringId = "CantEditMimeTypeMsg";
+ break;
+ case Ci.nsIEditingSession.eEditorErrorUnknown:
+ errorStringId = "CantEditDocumentMsg";
+ break;
+ // Note that for "eEditorErrorFileNotFound,
+ // network code popped up an alert dialog, so we don't need to
+ }
+ if (errorStringId) {
+ Services.prompt.alert(window, "", GetString(errorStringId));
+ }
+ } catch (e) {
+ dump("EXCEPTION GETTING obs_documentCreated state " + e + "\n");
+ }
+
+ // We have a bad editor -- nsIEditingSession will rebuild an editor
+ // with a blank page, so simply abort here
+ if (editorStatus) {
+ return;
+ }
+
+ if (!("InsertCharWindow" in window)) {
+ window.InsertCharWindow = null;
+ }
+
+ let domWindowUtils =
+ GetCurrentEditorElement().contentWindow.windowUtils;
+ // And extra styles for showing anchors, table borders, smileys, etc.
+ domWindowUtils.loadSheetUsingURIString(
+ kNormalStyleSheet,
+ domWindowUtils.AGENT_SHEET
+ );
+
+ // Remove contenteditable stylesheets if they were applied by the
+ // editingSession.
+ domWindowUtils.removeSheetUsingURIString(
+ kContentEditableStyleSheet,
+ domWindowUtils.AGENT_SHEET
+ );
+
+ // Add mouse click watcher if right type of editor
+ if (IsHTMLEditor()) {
+ // Force color widgets to update
+ onFontColorChange();
+ onBackgroundColorChange();
+ }
+ break;
+
+ case "cmd_setDocumentModified":
+ window.updateCommands("save");
+ break;
+
+ case "obs_documentWillBeDestroyed":
+ dump("obs_documentWillBeDestroyed notification\n");
+ break;
+
+ case "obs_documentLocationChanged":
+ // Ignore this when editor doesn't exist,
+ // which happens once when page load starts
+ if (editor) {
+ try {
+ editor.updateBaseURL();
+ } catch (e) {
+ dump(e);
+ }
+ }
+ break;
+
+ case "cmd_bold":
+ // Update all style items
+ // cmd_bold is a proxy; see EditorSharedStartup (above) for details
+ window.updateCommands("style");
+ window.updateCommands("undo");
+ break;
+ }
+ },
+};
+
+function SetFocusOnStartup() {
+ gContentWindow.focus();
+}
+
+function EditorLoadUrl(url) {
+ try {
+ if (url) {
+ let loadURIOptions = {
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ };
+ GetCurrentEditorElement().webNavigation.fixupAndLoadURIString(
+ url,
+ loadURIOptions
+ );
+ }
+ } catch (e) {
+ dump(" EditorLoadUrl failed: " + e + "\n");
+ }
+}
+
+// This should be called by all Composer types
+function EditorSharedStartup() {
+ // Just for convenience
+ gContentWindow = window.content;
+
+ // Disable DNS Prefetching on the docshell - we don't need it for composer
+ // type windows.
+ GetCurrentEditorElement().docShell.allowDNSPrefetch = false;
+
+ let messageEditorBrowser = GetCurrentEditorElement();
+ messageEditorBrowser.addEventListener(
+ "DoZoomEnlargeBy10",
+ () => {
+ ZoomManager.scrollZoomEnlarge(messageEditorBrowser);
+ },
+ true
+ );
+ messageEditorBrowser.addEventListener(
+ "DoZoomReduceBy10",
+ () => {
+ ZoomManager.scrollReduceEnlarge(messageEditorBrowser);
+ },
+ true
+ );
+
+ // Set up the mime type and register the commands.
+ if (IsHTMLEditor()) {
+ SetupHTMLEditorCommands();
+ } else {
+ SetupTextEditorCommands();
+ }
+
+ // add observer to be called when document is really done loading
+ // and is modified
+ // Note: We're really screwed if we fail to install this observer!
+ try {
+ var commandManager = GetCurrentCommandManager();
+ commandManager.addCommandObserver(
+ gEditorDocumentObserver,
+ "obs_documentCreated"
+ );
+ commandManager.addCommandObserver(
+ gEditorDocumentObserver,
+ "cmd_setDocumentModified"
+ );
+ commandManager.addCommandObserver(
+ gEditorDocumentObserver,
+ "obs_documentWillBeDestroyed"
+ );
+ commandManager.addCommandObserver(
+ gEditorDocumentObserver,
+ "obs_documentLocationChanged"
+ );
+
+ // Until nsIControllerCommandGroup-based code is implemented,
+ // we will observe just the bold command to trigger update of
+ // all toolbar style items
+ commandManager.addCommandObserver(gEditorDocumentObserver, "cmd_bold");
+ } catch (e) {
+ dump(e);
+ }
+
+ var isMac = AppConstants.platform == "macosx";
+
+ // Set platform-specific hints for how to select cells
+ // Mac uses "Cmd", all others use "Ctrl"
+ var tableKey = GetString(isMac ? "XulKeyMac" : "TableSelectKey");
+ var dragStr = tableKey + GetString("Drag");
+ var clickStr = tableKey + GetString("Click");
+
+ var delStr = GetString(isMac ? "Clear" : "Del");
+
+ SafeSetAttribute("menu_SelectCell", "acceltext", clickStr);
+ SafeSetAttribute("menu_SelectRow", "acceltext", dragStr);
+ SafeSetAttribute("menu_SelectColumn", "acceltext", dragStr);
+ SafeSetAttribute("menu_SelectAllCells", "acceltext", dragStr);
+ // And add "Del" or "Clear"
+ SafeSetAttribute("menu_DeleteCellContents", "acceltext", delStr);
+
+ // Set text for indent, outdent keybinding
+
+ // hide UI that we don't have components for
+ RemoveInapplicableUIElements();
+
+ // Use browser colors as initial values for editor's default colors
+ var BrowserColors = GetDefaultBrowserColors();
+ if (BrowserColors) {
+ gDefaultTextColor = BrowserColors.TextColor;
+ gDefaultBackgroundColor = BrowserColors.BackgroundColor;
+ }
+
+ // For new window, no default last-picked colors
+ gColorObj.LastTextColor = "";
+ gColorObj.LastBackgroundColor = "";
+ gColorObj.LastHighlightColor = "";
+}
+
+function SafeSetAttribute(nodeID, attributeName, attributeValue) {
+ var theNode = document.getElementById(nodeID);
+ if (theNode) {
+ theNode.setAttribute(attributeName, attributeValue);
+ }
+}
+
+async function CheckAndSaveDocument(command, allowDontSave) {
+ var document;
+ try {
+ // if we don't have an editor or an document, bail
+ var editor = GetCurrentEditor();
+ document = editor.document;
+ if (!document) {
+ return true;
+ }
+ } catch (e) {
+ return true;
+ }
+
+ if (!IsDocumentModified() && !IsHTMLSourceChanged()) {
+ return true;
+ }
+
+ // call window.focus, since we need to pop up a dialog
+ // and therefore need to be visible (to prevent user confusion)
+ top.document.commandDispatcher.focusedWindow.focus();
+
+ var strID;
+ switch (command) {
+ case "cmd_close":
+ strID = "BeforeClosing";
+ break;
+ }
+
+ var reasonToSave = strID ? GetString(strID) : "";
+
+ var title = document.title || GetString("untitledDefaultFilename");
+
+ var dialogTitle = GetString("SaveDocument");
+ var dialogMsg = GetString("SaveFilePrompt");
+ dialogMsg = dialogMsg
+ .replace(/%title%/, title)
+ .replace(/%reason%/, reasonToSave);
+
+ let result = { value: 0 };
+ let promptFlags =
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
+ let button1Title = null;
+ let button3Title = null;
+
+ promptFlags +=
+ Services.prompt.BUTTON_TITLE_SAVE * Services.prompt.BUTTON_POS_0;
+
+ // If allowing "Don't..." button, add that
+ if (allowDontSave) {
+ promptFlags +=
+ Services.prompt.BUTTON_TITLE_DONT_SAVE * Services.prompt.BUTTON_POS_2;
+ }
+
+ result = Services.prompt.confirmEx(
+ window,
+ dialogTitle,
+ dialogMsg,
+ promptFlags,
+ button1Title,
+ null,
+ button3Title,
+ null,
+ { value: 0 }
+ );
+
+ if (result == 0) {
+ // Save to local disk
+ return SaveDocument(false, false, editor.contentsMIMEType);
+ }
+
+ if (result == 2) {
+ // "Don't Save"
+ return true;
+ }
+
+ // Default or result == 1 (Cancel)
+ return false;
+}
+
+// --------------------------- Text style ---------------------------
+
+function editorSetParagraphState(state) {
+ if (state === "") {
+ // Corresponds to body text. Has no corresponding formatBlock value.
+ goDoCommandParams("cmd_paragraphState", "");
+ } else {
+ GetCurrentEditor().document.execCommand("formatBlock", false, state);
+ }
+ document.getElementById("cmd_paragraphState").setAttribute("state", state);
+ onParagraphFormatChange();
+}
+
+function onParagraphFormatChange() {
+ let paraMenuList = document.getElementById("ParagraphSelect");
+ if (!paraMenuList) {
+ return;
+ }
+
+ var commandNode = document.getElementById("cmd_paragraphState");
+ var state = commandNode.getAttribute("state");
+
+ // force match with "normal"
+ if (state == "body") {
+ state = "";
+ }
+
+ if (state == "mixed") {
+ // Selection is the "mixed" ( > 1 style) state
+ paraMenuList.selectedItem = null;
+ paraMenuList.setAttribute("label", GetString("Mixed"));
+ } else {
+ var menuPopup = document.getElementById("ParagraphPopup");
+ for (let menuItem of menuPopup.children) {
+ if (menuItem.value === state) {
+ paraMenuList.selectedItem = menuItem;
+ break;
+ }
+ }
+ }
+}
+
+function editorRemoveTextStyling() {
+ GetCurrentEditor().document.execCommand("removeFormat", false, null);
+ // After removing the formatting, update the full styling command set.
+ window.updateCommands("style");
+}
+
+/**
+ * Selects the current font face in the menulist.
+ */
+function onFontFaceChange() {
+ let fontFaceMenuList = document.getElementById("FontFaceSelect");
+ var commandNode = document.getElementById("cmd_fontFace");
+ var editorFont = commandNode.getAttribute("state");
+
+ // Strip quotes in font names. Experiments have shown that we only
+ // ever get double quotes around the font name, never single quotes,
+ // even if they were in the HTML source. Also single or double
+ // quotes within the font name are never returned.
+ editorFont = editorFont.replace(/"/g, "");
+
+ switch (editorFont) {
+ case "mixed":
+ // Selection is the "mixed" ( > 1 style) state.
+ fontFaceMenuList.selectedItem = null;
+ fontFaceMenuList.setAttribute("label", GetString("Mixed"));
+ return;
+ case "":
+ case "serif":
+ case "sans-serif":
+ // Generic variable width.
+ fontFaceMenuList.selectedIndex = 0;
+ return;
+ case "tt":
+ case "monospace":
+ // Generic fixed width.
+ fontFaceMenuList.selectedIndex = 1;
+ return;
+ default:
+ }
+
+ let menuPopup = fontFaceMenuList.menupopup;
+ let menuItems = menuPopup.children;
+
+ const genericFamilies = [
+ "serif",
+ "sans-serif",
+ "monospace",
+ "fantasy",
+ "cursive",
+ ];
+ // Bug 1139524: Normalise before we compare: Make it lower case
+ // and replace ", " with "," so that entries like
+ // "Helvetica, Arial, sans-serif" are always recognised correctly
+ let editorFontToLower = editorFont.toLowerCase().replace(/, /g, ",");
+ let foundFont = null;
+ let exactMatch = false;
+ let usedFontsSep = menuPopup.querySelector(
+ "menuseparator.fontFaceMenuAfterUsedFonts"
+ );
+ let editorFontOptions = editorFontToLower.split(",");
+ let editorOptionsCount = editorFontOptions.length;
+ let matchedFontIndex = editorOptionsCount; // initialise to high invalid value
+
+ // The font menu has this structure:
+ // 0: Variable Width
+ // 1: Fixed Width
+ // 2: Separator
+ // 3: Helvetica, Arial (stored as Helvetica, Arial, sans-serif)
+ // 4: Times (stored as Times New Roman, Times, serif)
+ // 5: Courier (stored as Courier New, Courier, monospace)
+ // 6: Separator, "menuseparator.fontFaceMenuAfterDefaultFonts"
+ // from 7: Used Font Section (for quick selection)
+ // followed by separator, "menuseparator.fontFaceMenuAfterUsedFonts"
+ // followed by all other available fonts.
+ // The following variable keeps track of where we are when we loop over the menu.
+ let afterUsedFontSection = false;
+
+ // The menu items not only have "label" and "value", but also some other attributes:
+ // "value_parsed": Is the toLowerCase() and space-stripped value.
+ // "value_cache": Is a concatenation of all editor fonts that were ever mapped
+ // onto this menu item. This is done for optimization.
+ // "used": This item is in the used font section.
+
+ for (let i = 0; i < menuItems.length; i++) {
+ let menuItem = menuItems.item(i);
+ if (
+ menuItem.hasAttribute("label") &&
+ menuItem.hasAttribute("value_parsed")
+ ) {
+ // The element seems to represent a font <menuitem>.
+ let fontMenuValue = menuItem.getAttribute("value_parsed");
+ if (
+ fontMenuValue == editorFontToLower ||
+ (menuItem.hasAttribute("value_cache") &&
+ menuItem
+ .getAttribute("value_cache")
+ .split("|")
+ .includes(editorFontToLower))
+ ) {
+ // This menuitem contains the font we are looking for.
+ foundFont = menuItem;
+ exactMatch = true;
+ break;
+ } else if (editorOptionsCount > 1 && afterUsedFontSection) {
+ // Once we are in the list of all other available fonts,
+ // we will find the one that best matches one of the options.
+ let matchPos = editorFontOptions.indexOf(fontMenuValue);
+ if (matchPos >= 0 && matchPos < matchedFontIndex) {
+ // This menu font comes earlier in the list of options,
+ // so prefer it.
+ matchedFontIndex = matchPos;
+ foundFont = menuItem;
+ // If we matched the first option, we don't need to look for
+ // a better match.
+ if (matchPos == 0) {
+ break;
+ }
+ }
+ }
+ } else if (menuItem == usedFontsSep) {
+ // Some other element type.
+ // We have now passed the section of used fonts and are now in the list of all.
+ afterUsedFontSection = true;
+ }
+ }
+
+ if (foundFont) {
+ let defaultFontsSep = menuPopup.querySelector(
+ "menuseparator.fontFaceMenuAfterDefaultFonts"
+ );
+ if (exactMatch) {
+ if (afterUsedFontSection) {
+ // Copy the matched font into the section of used fonts.
+ // We insert after the separator following the default fonts,
+ // so right at the beginning of the used fonts section.
+ let copyItem = foundFont.cloneNode(true);
+ menuPopup.insertBefore(copyItem, defaultFontsSep.nextElementSibling);
+ usedFontsSep.hidden = false;
+ foundFont = copyItem;
+ foundFont.setAttribute("used", "true");
+ }
+ } else {
+ // Keep only the found font and generic families in the font string.
+ editorFont = editorFont
+ .replace(/, /g, ",")
+ .split(",")
+ .filter(
+ font =>
+ font.toLowerCase() == foundFont.getAttribute("value_parsed") ||
+ genericFamilies.includes(font)
+ )
+ .join(",");
+
+ // Check if such an item is already in the used font section.
+ if (afterUsedFontSection) {
+ foundFont = menuPopup.querySelector(
+ 'menuitem[used="true"][value_parsed="' +
+ editorFont.toLowerCase() +
+ '"]'
+ );
+ }
+ // If not, create a new entry which will be inserted into that section.
+ if (!foundFont) {
+ foundFont = createFontFaceMenuitem(editorFont, editorFont, menuPopup);
+ }
+
+ // Add the editor font string into the 'cache' attribute in the element
+ // so we can later find it quickly without building the reduced string again.
+ let fontCache = "";
+ if (foundFont.hasAttribute("value_cache")) {
+ fontCache = foundFont.getAttribute("value_cache");
+ }
+ foundFont.setAttribute(
+ "value_cache",
+ fontCache + "|" + editorFontToLower
+ );
+
+ // If we created a new item, set it up and insert.
+ if (!foundFont.hasAttribute("used")) {
+ foundFont.setAttribute("used", "true");
+ usedFontsSep.hidden = false;
+ menuPopup.insertBefore(foundFont, defaultFontsSep.nextElementSibling);
+ }
+ }
+ } else {
+ // The editor encountered a font that is not installed on this system.
+ // Add it to the font menu now, in the used-fonts section right at the
+ // bottom before the separator of the section.
+ let fontLabel = GetFormattedString("NotInstalled", editorFont);
+ foundFont = createFontFaceMenuitem(fontLabel, editorFont, menuPopup);
+ foundFont.setAttribute("used", "true");
+ usedFontsSep.hidden = false;
+ menuPopup.insertBefore(foundFont, usedFontsSep);
+ }
+ fontFaceMenuList.selectedItem = foundFont;
+}
+
+/**
+ * Changes the font size for the selection or at the insertion point. This
+ * requires an integer from 1-7 as a value argument (x-small - xxx-large)
+ *
+ * @param {"1"|"2"|"3"|"4"|"5"|"6"|"7"} size - The font size.
+ */
+function EditorSetFontSize(size) {
+ // For normal/medium size (that is 3), we clear size.
+ if (size == "3") {
+ EditorRemoveTextProperty("font", "size");
+ // Also remove big and small,
+ // else it will seem like size isn't changing correctly
+ EditorRemoveTextProperty("small", "");
+ EditorRemoveTextProperty("big", "");
+ } else {
+ GetCurrentEditor().document.execCommand("fontSize", false, size);
+ }
+ // Enable or Disable the toolbar buttons according to the font size.
+ goUpdateCommand("cmd_decreaseFontStep");
+ goUpdateCommand("cmd_increaseFontStep");
+ gContentWindow.focus();
+}
+
+function initFontFaceMenu(menuPopup) {
+ initLocalFontFaceMenu(menuPopup);
+
+ if (menuPopup) {
+ var children = menuPopup.children;
+ if (!children) {
+ return;
+ }
+
+ var mixed = { value: false };
+ var editorFont = GetCurrentEditor().getFontFaceState(mixed);
+
+ // Strip quotes in font names. Experiments have shown that we only
+ // ever get double quotes around the font name, never single quotes,
+ // even if they were in the HTML source. Also single or double
+ // quotes within the font name are never returned.
+ editorFont = editorFont.replace(/"/g, "");
+
+ if (!mixed.value) {
+ switch (editorFont) {
+ case "":
+ case "serif":
+ case "sans-serif":
+ // Generic variable width.
+ editorFont = "";
+ break;
+ case "tt":
+ case "monospace":
+ // Generic fixed width.
+ editorFont = "monospace";
+ break;
+ default:
+ editorFont = editorFont.toLowerCase().replace(/, /g, ","); // bug 1139524
+ }
+ }
+
+ var editorFontOptions = editorFont.split(",");
+ var matchedOption = editorFontOptions.length; // initialise to high invalid value
+ for (var i = 0; i < children.length; i++) {
+ var menuItem = children[i];
+ if (menuItem.localName == "menuitem") {
+ var matchFound = false;
+ if (!mixed.value) {
+ var menuFont = menuItem
+ .getAttribute("value")
+ .toLowerCase()
+ .replace(/, /g, ",");
+
+ // First compare the entire font string to match items that contain commas.
+ if (menuFont == editorFont) {
+ menuItem.setAttribute("checked", "true");
+ break;
+ } else if (editorFontOptions.length > 1) {
+ // Next compare the individual options.
+ var matchPos = editorFontOptions.indexOf(menuFont);
+ if (matchPos >= 0 && matchPos < matchedOption) {
+ // This menu font comes earlier in the list of options,
+ // so prefer it.
+ menuItem.setAttribute("checked", "true");
+
+ // If we matched the first option, we don't need to look for
+ // a better match.
+ if (matchPos == 0) {
+ break;
+ }
+
+ matchedOption = matchPos;
+ matchFound = true;
+ }
+ }
+ }
+
+ // In case this item doesn't match, make sure we've cleared the checkmark.
+ if (!matchFound) {
+ menuItem.removeAttribute("checked");
+ }
+ }
+ }
+ }
+}
+
+// Number of fixed font face menuitems, these are:
+// Variable Width
+// Fixed Width
+// ==separator
+// Helvetica, Arial
+// Times
+// Courier
+// ==separator
+// ==separator
+const kFixedFontFaceMenuItems = 8;
+
+function initLocalFontFaceMenu(menuPopup) {
+ if (!gLocalFonts) {
+ // Build list of all local fonts once per editor
+ try {
+ var enumerator = Cc["@mozilla.org/gfx/fontenumerator;1"].getService(
+ Ci.nsIFontEnumerator
+ );
+ gLocalFonts = enumerator.EnumerateAllFonts();
+ } catch (e) {}
+ }
+
+ // Don't use radios for menulists.
+ let useRadioMenuitems = menuPopup.parentNode.localName == "menu";
+ menuPopup.setAttribute("useRadios", useRadioMenuitems);
+ if (menuPopup.children.length == kFixedFontFaceMenuItems) {
+ if (gLocalFonts.length == 0) {
+ menuPopup.querySelector(".fontFaceMenuAfterDefaultFonts").hidden = true;
+ }
+ for (let i = 0; i < gLocalFonts.length; ++i) {
+ // Remove Linux system generic fonts that collide with CSS generic fonts.
+ if (
+ gLocalFonts[i] != "" &&
+ gLocalFonts[i] != "serif" &&
+ gLocalFonts[i] != "sans-serif" &&
+ gLocalFonts[i] != "monospace"
+ ) {
+ let itemNode = createFontFaceMenuitem(
+ gLocalFonts[i],
+ gLocalFonts[i],
+ menuPopup
+ );
+ menuPopup.appendChild(itemNode);
+ }
+ }
+ }
+}
+
+/**
+ * Creates a menuitem element for the font faces menulist. Returns the menuitem
+ * but does not add it automatically to the menupopup.
+ *
+ * @param aFontLabel Label to be displayed for the item.
+ * @param aFontName The font face value to be used for the item.
+ * Will be used in <font face="value"> in the edited document.
+ * @param aMenuPopup The menupopup for which this menuitem is created.
+ */
+function createFontFaceMenuitem(aFontLabel, aFontName, aMenuPopup) {
+ let itemNode = document.createXULElement("menuitem");
+ itemNode.setAttribute("label", aFontLabel);
+ itemNode.setAttribute("value", aFontName);
+ itemNode.setAttribute(
+ "value_parsed",
+ aFontName.toLowerCase().replace(/, /g, ",")
+ );
+ itemNode.setAttribute("tooltiptext", aFontLabel);
+ if (aMenuPopup.getAttribute("useRadios") == "true") {
+ itemNode.setAttribute("type", "radio");
+ itemNode.setAttribute("observes", "cmd_renderedHTMLEnabler");
+ }
+ return itemNode;
+}
+
+/**
+ * Helper function
+ *
+ * @see https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#legacy-font-size-for
+ */
+function getLegacyFontSize() {
+ let fontSize = GetCurrentEditor().document.queryCommandValue("fontSize");
+ // If one selects all the texts in the editor and deletes it, the editor
+ // will return null fontSize. We will set it to default value then.
+ if (!fontSize) {
+ fontSize = Services.prefs.getCharPref("msgcompose.font_size", "3");
+ }
+ return fontSize;
+}
+
+function initFontSizeMenu(menuPopup) {
+ if (menuPopup) {
+ let fontSize = getLegacyFontSize();
+ for (let menuitem of menuPopup.children) {
+ if (menuitem.getAttribute("value") == fontSize) {
+ menuitem.setAttribute("checked", true);
+ }
+ }
+ }
+}
+
+function onFontColorChange() {
+ ChangeButtonColor("cmd_fontColor", "TextColorButton", gDefaultTextColor);
+}
+
+function onBackgroundColorChange() {
+ ChangeButtonColor(
+ "cmd_backgroundColor",
+ "BackgroundColorButton",
+ gDefaultBackgroundColor
+ );
+}
+
+/* Helper function that changes the button color.
+ * commandID - The ID of the command element.
+ * id - The ID of the button needing to be changed.
+ * defaultColor - The default color the button gets set to.
+ */
+function ChangeButtonColor(commandID, id, defaultColor) {
+ var commandNode = document.getElementById(commandID);
+ if (commandNode) {
+ var color = commandNode.getAttribute("state");
+ var button = document.getElementById(id);
+ if (button) {
+ button.setAttribute("color", color);
+
+ // No color or a mixed color - get color set on page or other defaults.
+ if (!color || color == "mixed") {
+ color = defaultColor;
+ }
+
+ button.style.backgroundColor = color;
+ }
+ }
+}
+
+// Call this when user changes text and/or background colors of the page
+function UpdateDefaultColors() {
+ var BrowserColors = GetDefaultBrowserColors();
+ var bodyelement = GetBodyElement();
+ var defTextColor = gDefaultTextColor;
+ var defBackColor = gDefaultBackgroundColor;
+
+ if (bodyelement) {
+ var color = bodyelement.getAttribute("text");
+ if (color) {
+ gDefaultTextColor = color;
+ } else if (BrowserColors) {
+ gDefaultTextColor = BrowserColors.TextColor;
+ }
+
+ color = bodyelement.getAttribute("bgcolor");
+ if (color) {
+ gDefaultBackgroundColor = color;
+ } else if (BrowserColors) {
+ gDefaultBackgroundColor = BrowserColors.BackgroundColor;
+ }
+ }
+
+ // Trigger update on toolbar
+ if (defTextColor != gDefaultTextColor) {
+ goUpdateCommandState("cmd_fontColor");
+ onFontColorChange();
+ }
+ if (defBackColor != gDefaultBackgroundColor) {
+ goUpdateCommandState("cmd_backgroundColor");
+ onBackgroundColorChange();
+ }
+}
+
+function GetBackgroundElementWithColor() {
+ var editor = GetCurrentTableEditor();
+ if (!editor) {
+ return null;
+ }
+
+ gColorObj.Type = "";
+ gColorObj.PageColor = "";
+ gColorObj.TableColor = "";
+ gColorObj.CellColor = "";
+ gColorObj.BackgroundColor = "";
+ gColorObj.SelectedType = "";
+
+ var tagNameObj = { value: "" };
+ var element;
+ try {
+ element = editor.getSelectedOrParentTableElement(tagNameObj, { value: 0 });
+ } catch (e) {}
+
+ if (element && tagNameObj && tagNameObj.value) {
+ gColorObj.BackgroundColor = GetHTMLOrCSSStyleValue(
+ element,
+ "bgcolor",
+ "background-color"
+ );
+ gColorObj.BackgroundColor = ConvertRGBColorIntoHEXColor(
+ gColorObj.BackgroundColor
+ );
+ if (tagNameObj.value.toLowerCase() == "td") {
+ gColorObj.Type = "Cell";
+ gColorObj.CellColor = gColorObj.BackgroundColor;
+
+ // Get any color that might be on parent table
+ var table = GetParentTable(element);
+ gColorObj.TableColor = GetHTMLOrCSSStyleValue(
+ table,
+ "bgcolor",
+ "background-color"
+ );
+ gColorObj.TableColor = ConvertRGBColorIntoHEXColor(gColorObj.TableColor);
+ } else {
+ gColorObj.Type = "Table";
+ gColorObj.TableColor = gColorObj.BackgroundColor;
+ }
+ gColorObj.SelectedType = gColorObj.Type;
+ } else {
+ let IsCSSPrefChecked = Services.prefs.getBoolPref(kUseCssPref);
+ if (IsCSSPrefChecked && IsHTMLEditor()) {
+ let selection = editor.selection;
+ if (selection) {
+ element = selection.focusNode;
+ while (!editor.nodeIsBlock(element)) {
+ element = element.parentNode;
+ }
+ } else {
+ element = GetBodyElement();
+ }
+ } else {
+ element = GetBodyElement();
+ }
+ if (element) {
+ gColorObj.Type = "Page";
+ gColorObj.BackgroundColor = GetHTMLOrCSSStyleValue(
+ element,
+ "bgcolor",
+ "background-color"
+ );
+ if (gColorObj.BackgroundColor == "") {
+ gColorObj.BackgroundColor = "transparent";
+ } else {
+ gColorObj.BackgroundColor = ConvertRGBColorIntoHEXColor(
+ gColorObj.BackgroundColor
+ );
+ }
+ gColorObj.PageColor = gColorObj.BackgroundColor;
+ }
+ }
+ return element;
+}
+
+/* eslint-disable complexity */
+function EditorSelectColor(colorType, mouseEvent) {
+ var editor = GetCurrentEditor();
+ if (!editor || !gColorObj) {
+ return;
+ }
+
+ // Shift + mouse click automatically applies last color, if available
+ var useLastColor = mouseEvent
+ ? mouseEvent.button == 0 && mouseEvent.shiftKey
+ : false;
+ var element;
+ var table;
+ var currentColor = "";
+ var commandNode;
+
+ if (!colorType) {
+ colorType = "";
+ }
+
+ if (colorType == "Text") {
+ gColorObj.Type = colorType;
+
+ // Get color from command node state
+ commandNode = document.getElementById("cmd_fontColor");
+ currentColor = commandNode.getAttribute("state");
+ currentColor = ConvertRGBColorIntoHEXColor(currentColor);
+ gColorObj.TextColor = currentColor;
+
+ if (useLastColor && gColorObj.LastTextColor) {
+ gColorObj.TextColor = gColorObj.LastTextColor;
+ } else {
+ useLastColor = false;
+ }
+ } else if (colorType == "Highlight") {
+ gColorObj.Type = colorType;
+
+ // Get color from command node state
+ commandNode = document.getElementById("cmd_highlight");
+ currentColor = commandNode.getAttribute("state");
+ currentColor = ConvertRGBColorIntoHEXColor(currentColor);
+ gColorObj.HighlightColor = currentColor;
+
+ if (useLastColor && gColorObj.LastHighlightColor) {
+ gColorObj.HighlightColor = gColorObj.LastHighlightColor;
+ } else {
+ useLastColor = false;
+ }
+ } else {
+ element = GetBackgroundElementWithColor();
+ if (!element) {
+ return;
+ }
+
+ // Get the table if we found a cell
+ if (gColorObj.Type == "Table") {
+ table = element;
+ } else if (gColorObj.Type == "Cell") {
+ table = GetParentTable(element);
+ }
+
+ // Save to avoid resetting if not necessary
+ currentColor = gColorObj.BackgroundColor;
+
+ if (colorType == "TableOrCell" || colorType == "Cell") {
+ if (gColorObj.Type == "Cell") {
+ gColorObj.Type = colorType;
+ } else if (gColorObj.Type != "Table") {
+ return;
+ }
+ } else if (colorType == "Table" && gColorObj.Type == "Page") {
+ return;
+ }
+
+ if (colorType == "" && gColorObj.Type == "Cell") {
+ // Using empty string for requested type means
+ // we can let user select cell or table
+ gColorObj.Type = "TableOrCell";
+ }
+
+ if (useLastColor && gColorObj.LastBackgroundColor) {
+ gColorObj.BackgroundColor = gColorObj.LastBackgroundColor;
+ } else {
+ useLastColor = false;
+ }
+ }
+ // Save the type we are really requesting
+ colorType = gColorObj.Type;
+
+ if (!useLastColor) {
+ // Avoid the JS warning
+ gColorObj.NoDefault = false;
+
+ // Launch the ColorPicker dialog
+ // TODO: Figure out how to position this under the color buttons on the toolbar
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdColorPicker.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ gColorObj
+ );
+
+ // User canceled the dialog
+ if (gColorObj.Cancel) {
+ return;
+ }
+ }
+
+ if (gColorObj.Type == "Text") {
+ if (currentColor != gColorObj.TextColor) {
+ if (gColorObj.TextColor) {
+ GetCurrentEditor().document.execCommand(
+ "foreColor",
+ false,
+ gColorObj.TextColor
+ );
+ } else {
+ EditorRemoveTextProperty("font", "color");
+ }
+ }
+ // Update the command state (this will trigger color button update)
+ goUpdateCommandState("cmd_fontColor");
+ } else if (gColorObj.Type == "Highlight") {
+ if (currentColor != gColorObj.HighlightColor) {
+ if (gColorObj.HighlightColor) {
+ GetCurrentEditor().document.execCommand(
+ "backColor",
+ false,
+ gColorObj.HighlightColor
+ );
+ } else {
+ EditorRemoveTextProperty("font", "bgcolor");
+ }
+ }
+ // Update the command state (this will trigger color button update)
+ goUpdateCommandState("cmd_highlight");
+ } else if (element) {
+ if (gColorObj.Type == "Table") {
+ // Set background on a table
+ // Note that we shouldn't trust "currentColor" because of "TableOrCell" behavior
+ if (table) {
+ var bgcolor = table.getAttribute("bgcolor");
+ if (bgcolor != gColorObj.BackgroundColor) {
+ try {
+ if (gColorObj.BackgroundColor) {
+ editor.setAttributeOrEquivalent(
+ table,
+ "bgcolor",
+ gColorObj.BackgroundColor,
+ false
+ );
+ } else {
+ editor.removeAttributeOrEquivalent(table, "bgcolor", false);
+ }
+ } catch (e) {}
+ }
+ }
+ } else if (currentColor != gColorObj.BackgroundColor && IsHTMLEditor()) {
+ editor.beginTransaction();
+ try {
+ editor.setBackgroundColor(gColorObj.BackgroundColor);
+
+ if (gColorObj.Type == "Page" && gColorObj.BackgroundColor) {
+ // Set all page colors not explicitly set,
+ // else you can end up with unreadable pages
+ // because viewer's default colors may not be same as page author's
+ var bodyelement = GetBodyElement();
+ if (bodyelement) {
+ var defColors = GetDefaultBrowserColors();
+ if (defColors) {
+ if (!bodyelement.getAttribute("text")) {
+ editor.setAttributeOrEquivalent(
+ bodyelement,
+ "text",
+ defColors.TextColor,
+ false
+ );
+ }
+
+ // The following attributes have no individual CSS declaration counterparts
+ // Getting rid of them in favor of CSS implies CSS rules management
+ if (!bodyelement.getAttribute("link")) {
+ editor.setAttribute(bodyelement, "link", defColors.LinkColor);
+ }
+
+ if (!bodyelement.getAttribute("alink")) {
+ editor.setAttribute(
+ bodyelement,
+ "alink",
+ defColors.ActiveLinkColor
+ );
+ }
+
+ if (!bodyelement.getAttribute("vlink")) {
+ editor.setAttribute(
+ bodyelement,
+ "vlink",
+ defColors.VisitedLinkColor
+ );
+ }
+ }
+ }
+ }
+ } catch (e) {}
+
+ editor.endTransaction();
+ }
+
+ goUpdateCommandState("cmd_backgroundColor");
+ }
+ gContentWindow.focus();
+}
+/* eslint-enable complexity */
+
+function GetParentTable(element) {
+ var node = element;
+ while (node) {
+ if (node.nodeName.toLowerCase() == "table") {
+ return node;
+ }
+
+ node = node.parentNode;
+ }
+ return node;
+}
+
+function GetParentTableCell(element) {
+ var node = element;
+ while (node) {
+ if (
+ node.nodeName.toLowerCase() == "td" ||
+ node.nodeName.toLowerCase() == "th"
+ ) {
+ return node;
+ }
+
+ node = node.parentNode;
+ }
+ return node;
+}
+
+function EditorDblClick(event) {
+ // Only bring up properties if clicked on an element or selected link
+ let element = event.target;
+ // We use "href" instead of "a" to not be fooled by named anchor
+ if (!element) {
+ try {
+ element = GetCurrentEditor().getSelectedElement("href");
+ } catch (e) {}
+ }
+
+ // Don't fire for body/p and other block elements.
+ // It's common that people try to double-click
+ // to select a word, but the click hits an empty area.
+ if (
+ element &&
+ ![
+ "body",
+ "p",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "blockquote",
+ "div",
+ "pre",
+ ].includes(element.nodeName.toLowerCase())
+ ) {
+ goDoCommand("cmd_objectProperties");
+ event.preventDefault();
+ }
+}
+
+/* TODO: We need an oncreate hook to do enabling/disabling for the
+ Format menu. There should be code like this for the
+ object-specific "Properties" item
+*/
+// For property dialogs, we want the selected element,
+// but will accept a parent link, list, or table cell if inside one
+function GetObjectForProperties() {
+ var editor = GetCurrentEditor();
+ if (!editor || !IsHTMLEditor()) {
+ return null;
+ }
+
+ var element;
+ try {
+ element = editor.getSelectedElement("");
+ } catch (e) {}
+ if (element) {
+ if (element.namespaceURI == "http://www.w3.org/1998/Math/MathML") {
+ // If the object is a MathML element, we collapse the selection on it and
+ // we return its <math> ancestor. Hence the math dialog will be used.
+ GetCurrentEditor().selection.collapse(element, 0);
+ } else {
+ return element;
+ }
+ }
+
+ // Find nearest parent of selection anchor node
+ // that is a link, list, table cell, or table
+
+ var anchorNode;
+ var node;
+ try {
+ anchorNode = editor.selection.anchorNode;
+ if (anchorNode.firstChild) {
+ // Start at actual selected node
+ var offset = editor.selection.anchorOffset;
+ // Note: If collapsed, offset points to element AFTER caret,
+ // thus node may be null
+ node = anchorNode.childNodes.item(offset);
+ }
+ if (!node) {
+ node = anchorNode;
+ }
+ } catch (e) {}
+
+ while (node) {
+ if (node.nodeName) {
+ var nodeName = node.nodeName.toLowerCase();
+
+ // Done when we hit the body or #text.
+ if (nodeName == "body" || nodeName == "#text") {
+ break;
+ }
+
+ if (
+ (nodeName == "a" && node.href) ||
+ nodeName == "ol" ||
+ nodeName == "ul" ||
+ nodeName == "dl" ||
+ nodeName == "td" ||
+ nodeName == "th" ||
+ nodeName == "table" ||
+ nodeName == "math"
+ ) {
+ return node;
+ }
+ }
+ node = node.parentNode;
+ }
+ return null;
+}
+
+function UpdateWindowTitle() {
+ try {
+ var filename = "";
+ var windowTitle = "";
+ var title = document.title;
+
+ // Append just the 'leaf' filename to the Doc. Title for the window caption
+ var docUrl = GetDocumentUrl();
+ if (docUrl && !IsUrlAboutBlank(docUrl)) {
+ var scheme = GetScheme(docUrl);
+ filename = GetFilename(docUrl);
+ if (filename) {
+ windowTitle = " [" + scheme + ":/.../" + filename + "]";
+ }
+
+ var fileType = IsHTMLEditor() ? "html" : "text";
+ // Save changed title in the recent pages data in prefs
+ SaveRecentFilesPrefs(title, fileType);
+ }
+
+ document.title = (title || filename) + windowTitle;
+ } catch (e) {
+ dump(e);
+ }
+}
+
+function SaveRecentFilesPrefs(aTitle, aFileType) {
+ var curUrl = StripPassword(GetDocumentUrl());
+ var historyCount = Services.prefs.getIntPref("editor.history.url_maximum");
+
+ var titleArray = [];
+ var urlArray = [];
+ var typeArray = [];
+
+ if (historyCount && !IsUrlAboutBlank(curUrl) && GetScheme(curUrl) != "data") {
+ titleArray.push(aTitle);
+ urlArray.push(curUrl);
+ typeArray.push(aFileType);
+ }
+
+ for (let i = 0; i < historyCount && urlArray.length < historyCount; i++) {
+ let url = Services.prefs.getStringPref("editor.history_url_" + i, "");
+
+ // Continue if URL pref is missing because
+ // a URL not found during loading may have been removed
+
+ // Skip over current an "data" URLs
+ if (url && url != curUrl && GetScheme(url) != "data") {
+ let title = Services.prefs.getStringPref("editor.history_title_" + i, "");
+ let fileType = Services.prefs.getStringPref(
+ "editor.history_type_" + i,
+ ""
+ );
+ titleArray.push(title);
+ urlArray.push(url);
+ typeArray.push(fileType);
+ }
+ }
+
+ // Resave the list back to prefs in the new order
+ for (let i = 0; i < urlArray.length; i++) {
+ SetStringPref("editor.history_title_" + i, titleArray[i]);
+ SetStringPref("editor.history_url_" + i, urlArray[i]);
+ SetStringPref("editor.history_type_" + i, typeArray[i]);
+ }
+}
+
+function EditorInitFormatMenu() {
+ try {
+ InitObjectPropertiesMenuitem();
+ InitRemoveStylesMenuitems(
+ "removeStylesMenuitem",
+ "removeLinksMenuitem",
+ "removeNamedAnchorsMenuitem"
+ );
+ } catch (ex) {}
+}
+
+function InitObjectPropertiesMenuitem() {
+ // Set strings and enable for the [Object] Properties item
+ // Note that we directly do the enabling instead of
+ // using goSetCommandEnabled since we already have the command.
+ var cmd = document.getElementById("cmd_objectProperties");
+ if (!cmd) {
+ return null;
+ }
+
+ var element;
+ var menuStr = GetString("AdvancedProperties");
+ var name;
+
+ if (IsEditingRenderedHTML()) {
+ element = GetObjectForProperties();
+ }
+
+ if (element && element.nodeName) {
+ var objStr = "";
+ cmd.removeAttribute("disabled");
+ name = element.nodeName.toLowerCase();
+ switch (name) {
+ case "img":
+ // Check if img is enclosed in link
+ // (use "href" to not be fooled by named anchor)
+ try {
+ if (GetCurrentEditor().getElementOrParentByTagName("href", element)) {
+ objStr = GetString("ImageAndLink");
+ // Return "href" so it is detected as a link.
+ name = "href";
+ }
+ } catch (e) {}
+
+ if (objStr == "") {
+ objStr = GetString("Image");
+ }
+ break;
+ case "hr":
+ objStr = GetString("HLine");
+ break;
+ case "table":
+ objStr = GetString("Table");
+ break;
+ case "th":
+ name = "td";
+ // Falls through
+ case "td":
+ objStr = GetString("TableCell");
+ break;
+ case "ol":
+ case "ul":
+ case "dl":
+ objStr = GetString("List");
+ break;
+ case "li":
+ objStr = GetString("ListItem");
+ break;
+ case "form":
+ objStr = GetString("Form");
+ break;
+ case "input":
+ var type = element.getAttribute("type");
+ if (type && type.toLowerCase() == "image") {
+ objStr = GetString("InputImage");
+ } else {
+ objStr = GetString("InputTag");
+ }
+ break;
+ case "textarea":
+ objStr = GetString("TextArea");
+ break;
+ case "select":
+ objStr = GetString("Select");
+ break;
+ case "button":
+ objStr = GetString("Button");
+ break;
+ case "label":
+ objStr = GetString("Label");
+ break;
+ case "fieldset":
+ objStr = GetString("FieldSet");
+ break;
+ case "a":
+ if (element.name) {
+ objStr = GetString("NamedAnchor");
+ name = "anchor";
+ } else if (element.href) {
+ objStr = GetString("Link");
+ name = "href";
+ }
+ break;
+ }
+ if (objStr) {
+ menuStr = GetString("ObjectProperties").replace(/%obj%/, objStr);
+ }
+ } else {
+ // We show generic "Properties" string, but disable the command.
+ cmd.setAttribute("disabled", "true");
+ }
+ cmd.setAttribute("label", menuStr);
+ cmd.setAttribute("accesskey", GetString("ObjectPropertiesAccessKey"));
+ return name;
+}
+
+function InitParagraphMenu() {
+ var mixedObj = { value: null };
+ var state;
+ try {
+ state = GetCurrentEditor().getParagraphState(mixedObj);
+ } catch (e) {}
+ var IDSuffix;
+
+ // PROBLEM: When we get blockquote, it masks other styles contained by it
+ // We need a separate method to get blockquote state
+
+ // We use "x" as uninitialized paragraph state
+ if (!state || state == "x") {
+ // No paragraph container.
+ IDSuffix = "bodyText";
+ } else {
+ IDSuffix = state;
+ }
+
+ // Set "radio" check on one item, but...
+ var menuItem = document.getElementById("menu_" + IDSuffix);
+ menuItem.setAttribute("checked", "true");
+
+ // ..."bodyText" is returned if mixed selection, so remove checkmark
+ if (mixedObj.value) {
+ menuItem.setAttribute("checked", "false");
+ }
+}
+
+function GetListStateString() {
+ try {
+ var editor = GetCurrentEditor();
+
+ var mixedObj = { value: null };
+ var hasOL = { value: false };
+ var hasUL = { value: false };
+ var hasDL = { value: false };
+ editor.getListState(mixedObj, hasOL, hasUL, hasDL);
+
+ if (mixedObj.value) {
+ return "mixed";
+ }
+ if (hasOL.value) {
+ return "ol";
+ }
+ if (hasUL.value) {
+ return "ul";
+ }
+
+ if (hasDL.value) {
+ var hasLI = { value: false };
+ var hasDT = { value: false };
+ var hasDD = { value: false };
+ editor.getListItemState(mixedObj, hasLI, hasDT, hasDD);
+ if (mixedObj.value) {
+ return "mixed";
+ }
+ if (hasLI.value) {
+ return "li";
+ }
+ if (hasDT.value) {
+ return "dt";
+ }
+ if (hasDD.value) {
+ return "dd";
+ }
+ }
+ } catch (e) {}
+
+ // return "noList" if we aren't in a list at all
+ return "noList";
+}
+
+function InitListMenu() {
+ if (!IsHTMLEditor()) {
+ return;
+ }
+
+ var IDSuffix = GetListStateString();
+
+ // Set enable state for the "None" menuitem
+ goSetCommandEnabled("cmd_removeList", IDSuffix != "noList");
+
+ // Set "radio" check on one item, but...
+ // we won't find a match if it's "mixed"
+ var menuItem = document.getElementById("menu_" + IDSuffix);
+ if (menuItem) {
+ menuItem.setAttribute("checked", "true");
+ }
+}
+
+function GetAlignmentString() {
+ var mixedObj = { value: null };
+ var alignObj = { value: null };
+ try {
+ GetCurrentEditor().getAlignment(mixedObj, alignObj);
+ } catch (e) {}
+
+ if (mixedObj.value) {
+ return "mixed";
+ }
+ if (alignObj.value == Ci.nsIHTMLEditor.eLeft) {
+ return "left";
+ }
+ if (alignObj.value == Ci.nsIHTMLEditor.eCenter) {
+ return "center";
+ }
+ if (alignObj.value == Ci.nsIHTMLEditor.eRight) {
+ return "right";
+ }
+ if (alignObj.value == Ci.nsIHTMLEditor.eJustify) {
+ return "justify";
+ }
+
+ // return "left" if we got here
+ return "left";
+}
+
+function InitAlignMenu() {
+ if (!IsHTMLEditor()) {
+ return;
+ }
+
+ var IDSuffix = GetAlignmentString();
+
+ // we won't find a match if it's "mixed"
+ var menuItem = document.getElementById("menu_" + IDSuffix);
+ if (menuItem) {
+ menuItem.setAttribute("checked", "true");
+ }
+}
+
+function EditorSetDefaultPrefsAndDoctype() {
+ var editor = GetCurrentEditor();
+
+ var domdoc;
+ try {
+ domdoc = editor.document;
+ } catch (e) {
+ dump(e + "\n");
+ }
+ if (!domdoc) {
+ dump("EditorSetDefaultPrefsAndDoctype: EDITOR DOCUMENT NOT FOUND\n");
+ return;
+ }
+
+ // Insert a doctype element
+ // if it is missing from existing doc
+ if (!domdoc.doctype) {
+ var newdoctype = domdoc.implementation.createDocumentType(
+ "HTML",
+ "-//W3C//DTD HTML 4.01 Transitional//EN",
+ ""
+ );
+ if (newdoctype) {
+ domdoc.insertBefore(newdoctype, domdoc.firstChild);
+ }
+ }
+
+ // search for head; we'll need this for meta tag additions
+ let headelement = domdoc.querySelector("head");
+ if (!headelement) {
+ headelement = domdoc.createElement("head");
+ domdoc.insertAfter(headelement, domdoc.firstChild);
+ }
+
+ /* only set default prefs for new documents */
+ if (!IsUrlAboutBlank(GetDocumentUrl())) {
+ return;
+ }
+
+ // search for author meta tag.
+ // if one is found, don't do anything.
+ // if not, create one and make it a child of the head tag
+ // and set its content attribute to the value of the editor.author preference.
+
+ if (domdoc.querySelector("meta")) {
+ // we should do charset first since we need to have charset before
+ // hitting other 8-bit char in other meta tags
+ // grab charset pref and make it the default charset
+ var element;
+ var prefCharsetString = Services.prefs.getCharPref(
+ "intl.charset.fallback.override"
+ );
+ if (prefCharsetString) {
+ editor.documentCharacterSet = prefCharsetString;
+ }
+
+ // let's start by assuming we have an author in case we don't have the pref
+
+ var prefAuthorString = null;
+ let authorFound = domdoc.querySelector('meta[name="author"]');
+ try {
+ prefAuthorString = Services.prefs.getStringPref("editor.author");
+ } catch (ex) {}
+ if (
+ prefAuthorString &&
+ prefAuthorString != 0 &&
+ !authorFound &&
+ headelement
+ ) {
+ // create meta tag with 2 attributes
+ element = domdoc.createElement("meta");
+ if (element) {
+ element.setAttribute("name", "author");
+ element.setAttribute("content", prefAuthorString);
+ headelement.appendChild(element);
+ }
+ }
+ }
+
+ // add title tag if not present
+ if (headelement && !editor.document.querySelector("title")) {
+ var titleElement = domdoc.createElement("title");
+ if (titleElement) {
+ headelement.appendChild(titleElement);
+ }
+ }
+
+ // find body node
+ var bodyelement = GetBodyElement();
+ if (bodyelement) {
+ if (Services.prefs.getBoolPref("editor.use_custom_colors")) {
+ let text_color = Services.prefs.getCharPref("editor.text_color");
+ let background_color = Services.prefs.getCharPref(
+ "editor.background_color"
+ );
+
+ // add the color attributes to the body tag.
+ // and use them for the default text and background colors if not empty
+ editor.setAttributeOrEquivalent(bodyelement, "text", text_color, true);
+ gDefaultTextColor = text_color;
+ editor.setAttributeOrEquivalent(
+ bodyelement,
+ "bgcolor",
+ background_color,
+ true
+ );
+ gDefaultBackgroundColor = background_color;
+ bodyelement.setAttribute(
+ "link",
+ Services.prefs.getCharPref("editor.link_color")
+ );
+ bodyelement.setAttribute(
+ "alink",
+ Services.prefs.getCharPref("editor.active_link_color")
+ );
+ bodyelement.setAttribute(
+ "vlink",
+ Services.prefs.getCharPref("editor.followed_link_color")
+ );
+ }
+ // Default image is independent of Custom colors???
+ try {
+ let background_image = Services.prefs.getCharPref(
+ "editor.default_background_image"
+ );
+ if (background_image) {
+ editor.setAttributeOrEquivalent(
+ bodyelement,
+ "background",
+ background_image,
+ true
+ );
+ }
+ } catch (e) {
+ dump("BACKGROUND EXCEPTION: " + e + "\n");
+ }
+ }
+ // auto-save???
+}
+
+function GetBodyElement() {
+ try {
+ return GetCurrentEditor().rootElement;
+ } catch (ex) {
+ dump("no body tag found?!\n");
+ // better have one, how can we blow things up here?
+ }
+ return null;
+}
+
+// --------------------------------------------------------------------
+function initFontStyleMenu(menuPopup) {
+ for (var i = 0; i < menuPopup.children.length; i++) {
+ var menuItem = menuPopup.children[i];
+ var theStyle = menuItem.getAttribute("state");
+ if (theStyle) {
+ menuItem.setAttribute("checked", theStyle);
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------------
+function IsSpellCheckerInstalled() {
+ return true; // Always installed.
+}
+
+// -----------------------------------------------------------------------------------
+function IsFindInstalled() {
+ return (
+ "@mozilla.org/embedcomp/rangefind;1" in Cc &&
+ "@mozilla.org/find/find_service;1" in Cc
+ );
+}
+
+// -----------------------------------------------------------------------------------
+function RemoveInapplicableUIElements() {
+ // For items that are in their own menu block, remove associated separator
+ // (we can't use "hidden" since class="hide-in-IM" CSS rule interferes)
+
+ // if no find, remove find ui
+ if (!IsFindInstalled()) {
+ HideItem("menu_find");
+ HideItem("menu_findnext");
+ HideItem("menu_replace");
+ HideItem("menu_find");
+ RemoveItem("sep_find");
+ }
+
+ // if no spell checker, remove spell checker ui
+ if (!IsSpellCheckerInstalled()) {
+ HideItem("spellingButton");
+ HideItem("menu_checkspelling");
+ RemoveItem("sep_checkspelling");
+ }
+
+ // Remove menu items (from overlay shared with HTML editor) in non-HTML.
+ if (!IsHTMLEditor()) {
+ HideItem("insertAnchor");
+ HideItem("insertImage");
+ HideItem("insertHline");
+ HideItem("insertTable");
+ HideItem("insertHTML");
+ HideItem("insertFormMenu");
+ HideItem("fileExportToText");
+ HideItem("viewFormatToolbar");
+ HideItem("viewEditModeToolbar");
+ }
+}
+
+function HideItem(id) {
+ var item = document.getElementById(id);
+ if (item) {
+ item.hidden = true;
+ }
+}
+
+function RemoveItem(id) {
+ var item = document.getElementById(id);
+ if (item) {
+ item.remove();
+ }
+}
+
+// Command Updating Strategy:
+// Don't update on on selection change, only when menu is displayed,
+// with this "oncreate" handler:
+function EditorInitTableMenu() {
+ try {
+ InitJoinCellMenuitem("menu_JoinTableCells");
+ } catch (ex) {}
+
+ // Set enable states for all table commands
+ goUpdateTableMenuItems(document.getElementById("composerTableMenuItems"));
+}
+
+function InitJoinCellMenuitem(id) {
+ // Change text on the "Join..." item depending if we
+ // are joining selected cells or just cell to right
+ // TODO: What to do about normal selection that crosses
+ // table border? Try to figure out all cells
+ // included in the selection?
+ var menuText;
+ var menuItem = document.getElementById(id);
+ if (!menuItem) {
+ return;
+ }
+
+ // Use "Join selected cells if there's more than 1 cell selected
+ var numSelected;
+ var foundElement;
+
+ try {
+ var tagNameObj = {};
+ var countObj = { value: 0 };
+ foundElement = GetCurrentTableEditor().getSelectedOrParentTableElement(
+ tagNameObj,
+ countObj
+ );
+ numSelected = countObj.value;
+ } catch (e) {}
+ if (foundElement && numSelected > 1) {
+ menuText = GetString("JoinSelectedCells");
+ } else {
+ menuText = GetString("JoinCellToRight");
+ }
+
+ menuItem.setAttribute("label", menuText);
+ menuItem.setAttribute("accesskey", GetString("JoinCellAccesskey"));
+}
+
+function InitRemoveStylesMenuitems(
+ removeStylesId,
+ removeLinksId,
+ removeNamedAnchorsId
+) {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ return;
+ }
+
+ // Change wording of menuitems depending on selection
+ var stylesItem = document.getElementById(removeStylesId);
+ var linkItem = document.getElementById(removeLinksId);
+
+ var isCollapsed = editor.selection.isCollapsed;
+ if (stylesItem) {
+ stylesItem.setAttribute(
+ "label",
+ isCollapsed ? GetString("StopTextStyles") : GetString("RemoveTextStyles")
+ );
+ stylesItem.setAttribute(
+ "accesskey",
+ GetString("RemoveTextStylesAccesskey")
+ );
+ }
+ if (linkItem) {
+ linkItem.setAttribute(
+ "label",
+ isCollapsed ? GetString("StopLinks") : GetString("RemoveLinks")
+ );
+ linkItem.setAttribute("accesskey", GetString("RemoveLinksAccesskey"));
+ // Note: disabling text style is a pain since there are so many - forget it!
+
+ // Disable if not in a link, but always allow "Remove"
+ // if selection isn't collapsed since we only look at anchor node
+ try {
+ SetElementEnabled(
+ linkItem,
+ !isCollapsed || editor.getElementOrParentByTagName("href", null)
+ );
+ } catch (e) {}
+ }
+ // Disable if selection is collapsed
+ SetElementEnabledById(removeNamedAnchorsId, !isCollapsed);
+}
+
+function goUpdateTableMenuItems(commandset) {
+ var editor = GetCurrentTableEditor();
+ if (!editor) {
+ dump("goUpdateTableMenuItems: too early, not initialized\n");
+ return;
+ }
+
+ var enabled = false;
+ var enabledIfTable = false;
+
+ var flags = editor.flags;
+ if (!(flags & Ci.nsIEditor.eEditorReadonlyMask) && IsEditingRenderedHTML()) {
+ var tagNameObj = { value: "" };
+ var element;
+ try {
+ element = editor.getSelectedOrParentTableElement(tagNameObj, {
+ value: 0,
+ });
+ } catch (e) {}
+
+ if (element) {
+ // Value when we need to have a selected table or inside a table
+ enabledIfTable = true;
+
+ // All others require being inside a cell or selected cell
+ enabled = tagNameObj.value == "td";
+ }
+ }
+
+ // Loop through command nodes
+ for (var i = 0; i < commandset.children.length; i++) {
+ var commandID = commandset.children[i].getAttribute("id");
+ if (commandID) {
+ if (
+ commandID == "cmd_InsertTable" ||
+ commandID == "cmd_JoinTableCells" ||
+ commandID == "cmd_SplitTableCell" ||
+ commandID == "cmd_ConvertToTable"
+ ) {
+ // Call the update method in the command class
+ goUpdateCommand(commandID);
+ } else if (
+ commandID == "cmd_DeleteTable" ||
+ commandID == "cmd_editTable" ||
+ commandID == "cmd_TableOrCellColor" ||
+ commandID == "cmd_SelectTable"
+ ) {
+ // Directly set with the values calculated here
+ goSetCommandEnabled(commandID, enabledIfTable);
+ } else {
+ goSetCommandEnabled(commandID, enabled);
+ }
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------------
+// Helpers for inserting and editing tables:
+
+function IsInTable() {
+ var editor = GetCurrentEditor();
+ try {
+ var flags = editor.flags;
+ return (
+ IsHTMLEditor() &&
+ !(flags & Ci.nsIEditor.eEditorReadonlyMask) &&
+ IsEditingRenderedHTML() &&
+ null != editor.getElementOrParentByTagName("table", null)
+ );
+ } catch (e) {}
+ return false;
+}
+
+function IsInTableCell() {
+ try {
+ var editor = GetCurrentEditor();
+ var flags = editor.flags;
+ return (
+ IsHTMLEditor() &&
+ !(flags & Ci.nsIEditor.eEditorReadonlyMask) &&
+ IsEditingRenderedHTML() &&
+ null != editor.getElementOrParentByTagName("td", null)
+ );
+ } catch (e) {}
+ return false;
+}
+
+function IsSelectionInOneCell() {
+ try {
+ var editor = GetCurrentEditor();
+ var selection = editor.selection;
+
+ if (selection.rangeCount == 1) {
+ // We have a "normal" single-range selection
+ if (
+ !selection.isCollapsed &&
+ selection.anchorNode != selection.focusNode
+ ) {
+ // Check if both nodes are within the same cell
+ var anchorCell = editor.getElementOrParentByTagName(
+ "td",
+ selection.anchorNode
+ );
+ var focusCell = editor.getElementOrParentByTagName(
+ "td",
+ selection.focusNode
+ );
+ return (
+ focusCell != null && anchorCell != null && focusCell == anchorCell
+ );
+ }
+ // Collapsed selection or anchor == focus (thus must be in 1 cell)
+ return true;
+ }
+ } catch (e) {}
+ return false;
+}
+
+// Call this with insertAllowed = true to allow inserting if not in existing table,
+// else use false to do nothing if not in a table
+function EditorInsertOrEditTable(insertAllowed) {
+ if (IsInTable()) {
+ // Edit properties of existing table
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdTableProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ "TablePanel"
+ );
+ gContentWindow.focus();
+ } else if (insertAllowed) {
+ try {
+ if (GetCurrentEditor().selection.isCollapsed) {
+ // If we have a caret, insert a blank table...
+ EditorInsertTable();
+ } else {
+ // Else convert the selection into a table.
+ goDoCommand("cmd_ConvertToTable");
+ }
+ } catch (e) {}
+ }
+}
+
+function EditorInsertTable() {
+ // Insert a new table
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsertTable.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ ""
+ );
+ gContentWindow.focus();
+}
+
+function EditorTableCellProperties() {
+ if (!IsHTMLEditor()) {
+ return;
+ }
+
+ try {
+ var cell = GetCurrentEditor().getElementOrParentByTagName("td", null);
+ if (cell) {
+ // Start Table Properties dialog on the "Cell" panel
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdTableProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ "CellPanel"
+ );
+ gContentWindow.focus();
+ }
+ } catch (e) {}
+}
+
+function GetNumberOfContiguousSelectedRows() {
+ if (!IsHTMLEditor()) {
+ return 0;
+ }
+
+ var rows = 0;
+ var editor = GetCurrentTableEditor();
+ var rowObj = { value: 0 };
+ var colObj = { value: 0 };
+ var cell = editor.getFirstSelectedCellInTable(rowObj, colObj);
+ if (!cell) {
+ return 0;
+ }
+
+ // We have at least one row
+ rows++;
+
+ var lastIndex = rowObj.value;
+ for (let cell of editor.getSelectedCells()) {
+ editor.getCellIndexes(cell, rowObj, colObj);
+ var index = rowObj.value;
+ if (index == lastIndex + 1) {
+ lastIndex = index;
+ rows++;
+ }
+ }
+
+ return rows;
+}
+
+function GetNumberOfContiguousSelectedColumns() {
+ if (!IsHTMLEditor()) {
+ return 0;
+ }
+
+ var columns = 0;
+
+ var editor = GetCurrentTableEditor();
+ var colObj = { value: 0 };
+ var rowObj = { value: 0 };
+ var cell = editor.getFirstSelectedCellInTable(rowObj, colObj);
+ if (!cell) {
+ return 0;
+ }
+
+ // We have at least one column
+ columns++;
+
+ var lastIndex = colObj.value;
+ for (let cell of editor.getSelectedCells()) {
+ editor.getCellIndexes(cell, rowObj, colObj);
+ var index = colObj.value;
+ if (index == lastIndex + 1) {
+ lastIndex = index;
+ columns++;
+ }
+ }
+
+ return columns;
+}
+
+function EditorOnFocus() {
+ // Current window already has the InsertCharWindow
+ if ("InsertCharWindow" in window && window.InsertCharWindow) {
+ return;
+ }
+
+ // Find window with an InsertCharsWindow and switch association to this one
+ var windowWithDialog = FindEditorWithInsertCharDialog();
+ if (windowWithDialog) {
+ // Switch the dialog to current window
+ // this sets focus to dialog, so bring focus back to editor window
+ if (SwitchInsertCharToThisWindow(windowWithDialog)) {
+ top.document.commandDispatcher.focusedWindow.focus();
+ }
+ }
+}
+
+function SwitchInsertCharToThisWindow(windowWithDialog) {
+ if (
+ windowWithDialog &&
+ "InsertCharWindow" in windowWithDialog &&
+ windowWithDialog.InsertCharWindow
+ ) {
+ // Move dialog association to the current window
+ window.InsertCharWindow = windowWithDialog.InsertCharWindow;
+ windowWithDialog.InsertCharWindow = null;
+
+ // Switch the dialog's opener to current window's
+ window.InsertCharWindow.opener = window;
+
+ // Bring dialog to the foreground
+ window.InsertCharWindow.focus();
+ return true;
+ }
+ return false;
+}
+
+function FindEditorWithInsertCharDialog() {
+ try {
+ // Find window with an InsertCharsWindow and switch association to this one
+
+ for (let tempWindow of Services.wm.getEnumerator(null)) {
+ if (
+ !tempWindow.closed &&
+ tempWindow != window &&
+ "InsertCharWindow" in tempWindow &&
+ tempWindow.InsertCharWindow
+ ) {
+ return tempWindow;
+ }
+ }
+ } catch (e) {}
+ return null;
+}
+
+function EditorFindOrCreateInsertCharWindow() {
+ if ("InsertCharWindow" in window && window.InsertCharWindow) {
+ window.InsertCharWindow.focus();
+ } else {
+ // Since we switch the dialog during EditorOnFocus(),
+ // this should really never be found, but it's good to be sure
+ var windowWithDialog = FindEditorWithInsertCharDialog();
+ if (windowWithDialog) {
+ SwitchInsertCharToThisWindow(windowWithDialog);
+ } else {
+ // The dialog will set window.InsertCharWindow to itself
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsertChars.xhtml",
+ "_blank",
+ "chrome,close,titlebar",
+ ""
+ );
+ }
+ }
+}
+
+// Find another HTML editor window to associate with the InsertChar dialog
+// or close it if none found (May be a mail composer)
+function SwitchInsertCharToAnotherEditorOrClose() {
+ if ("InsertCharWindow" in window && window.InsertCharWindow) {
+ var enumerator;
+ try {
+ enumerator = Services.wm.getEnumerator(null);
+ } catch (e) {}
+ if (!enumerator) {
+ return;
+ }
+
+ // TODO: Fix this to search for command controllers and look for "cmd_InsertChars"
+ // For now, detect just Web Composer and HTML Mail Composer
+ for (let tempWindow of enumerator) {
+ if (
+ !tempWindow.closed &&
+ tempWindow != window &&
+ tempWindow != window.InsertCharWindow &&
+ "GetCurrentEditor" in tempWindow &&
+ tempWindow.GetCurrentEditor()
+ ) {
+ tempWindow.InsertCharWindow = window.InsertCharWindow;
+ window.InsertCharWindow = null;
+ tempWindow.InsertCharWindow.opener = tempWindow;
+ return;
+ }
+ }
+ // Didn't find another editor - close the dialog
+ window.InsertCharWindow.close();
+ }
+}
+
+function UpdateTOC() {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsertTOC.xhtml",
+ "_blank",
+ "chrome,close,modal,titlebar"
+ );
+ window.content.focus();
+}
+
+function InitTOCMenu() {
+ var elt = GetCurrentEditor().document.getElementById("mozToc");
+ var createMenuitem = document.getElementById("insertTOCMenuitem");
+ var updateMenuitem = document.getElementById("updateTOCMenuitem");
+ var removeMenuitem = document.getElementById("removeTOCMenuitem");
+ if (removeMenuitem && createMenuitem && updateMenuitem) {
+ if (elt) {
+ createMenuitem.setAttribute("disabled", "true");
+ updateMenuitem.removeAttribute("disabled");
+ removeMenuitem.removeAttribute("disabled");
+ } else {
+ createMenuitem.removeAttribute("disabled");
+ removeMenuitem.setAttribute("disabled", "true");
+ updateMenuitem.setAttribute("disabled", "true");
+ }
+ }
+}
+
+function RemoveTOC() {
+ var theDocument = GetCurrentEditor().document;
+ var elt = theDocument.getElementById("mozToc");
+ if (elt) {
+ elt.remove();
+ }
+
+ let anchorNodes = theDocument.querySelectorAll('a[name^="mozTocId"]');
+ for (let node of anchorNodes) {
+ if (node.parentNode) {
+ node.remove();
+ }
+ }
+}
diff --git a/comm/mail/components/compose/content/editorUtilities.js b/comm/mail/components/compose/content/editorUtilities.js
new file mode 100644
index 0000000000..3af6810c9c
--- /dev/null
+++ b/comm/mail/components/compose/content/editorUtilities.js
@@ -0,0 +1,1015 @@
+/* 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 editor.js */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+// Each editor window must include this file
+// Variables shared by all dialogs:
+
+// Object to attach commonly-used widgets (all dialogs should use this)
+var gDialog = {};
+
+var kOutputEncodeBasicEntities =
+ Ci.nsIDocumentEncoder.OutputEncodeBasicEntities;
+var kOutputEncodeHTMLEntities = Ci.nsIDocumentEncoder.OutputEncodeHTMLEntities;
+var kOutputEncodeLatin1Entities =
+ Ci.nsIDocumentEncoder.OutputEncodeLatin1Entities;
+var kOutputEncodeW3CEntities = Ci.nsIDocumentEncoder.OutputEncodeW3CEntities;
+var kOutputFormatted = Ci.nsIDocumentEncoder.OutputFormatted;
+var kOutputLFLineBreak = Ci.nsIDocumentEncoder.OutputLFLineBreak;
+var kOutputSelectionOnly = Ci.nsIDocumentEncoder.OutputSelectionOnly;
+var kOutputWrap = Ci.nsIDocumentEncoder.OutputWrap;
+
+var gStringBundle;
+var gFilePickerDirectory;
+
+/** *********** Message dialogs */
+
+// Optional: Caller may supply text to substitute for "Ok" and/or "Cancel"
+function ConfirmWithTitle(title, message, okButtonText, cancelButtonText) {
+ let okFlag = okButtonText
+ ? Services.prompt.BUTTON_TITLE_IS_STRING
+ : Services.prompt.BUTTON_TITLE_OK;
+ let cancelFlag = cancelButtonText
+ ? Services.prompt.BUTTON_TITLE_IS_STRING
+ : Services.prompt.BUTTON_TITLE_CANCEL;
+
+ return (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ okFlag * Services.prompt.BUTTON_POS_0 +
+ cancelFlag * Services.prompt.BUTTON_POS_1,
+ okButtonText,
+ cancelButtonText,
+ null,
+ null,
+ { value: 0 }
+ ) == 0
+ );
+}
+
+/** *********** String Utilities */
+
+function GetString(name) {
+ if (!gStringBundle) {
+ try {
+ gStringBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messengercompose/editor.properties"
+ );
+ } catch (ex) {}
+ }
+ if (gStringBundle) {
+ try {
+ return gStringBundle.GetStringFromName(name);
+ } catch (e) {}
+ }
+ return null;
+}
+
+function GetFormattedString(aName, aVal) {
+ if (!gStringBundle) {
+ try {
+ gStringBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messengercompose/editor.properties"
+ );
+ } catch (ex) {}
+ }
+ if (gStringBundle) {
+ try {
+ return gStringBundle.formatStringFromName(aName, [aVal]);
+ } catch (e) {}
+ }
+ return null;
+}
+
+function TrimStringLeft(string) {
+ if (!string) {
+ return "";
+ }
+ return string.trimLeft();
+}
+
+function TrimStringRight(string) {
+ if (!string) {
+ return "";
+ }
+ return string.trimRight();
+}
+
+// Remove whitespace from both ends of a string
+function TrimString(string) {
+ if (!string) {
+ return "";
+ }
+ return string.trim();
+}
+
+function TruncateStringAtWordEnd(string, maxLength, addEllipses) {
+ // Return empty if string is null, undefined, or the empty string
+ if (!string) {
+ return "";
+ }
+
+ // We assume they probably don't want whitespace at the beginning
+ string = string.trimLeft();
+ if (string.length <= maxLength) {
+ return string;
+ }
+
+ // We need to truncate the string to maxLength or fewer chars
+ if (addEllipses) {
+ maxLength -= 3;
+ }
+ string = string.replace(RegExp("(.{0," + maxLength + "})\\s.*"), "$1");
+
+ if (string.length > maxLength) {
+ string = string.slice(0, maxLength);
+ }
+
+ if (addEllipses) {
+ string += "...";
+ }
+ return string;
+}
+
+// Replace all whitespace characters with supplied character
+// E.g.: Use charReplace = " ", to "unwrap" the string by removing line-end chars
+// Use charReplace = "_" when you don't want spaces (like in a URL)
+function ReplaceWhitespace(string, charReplace) {
+ return string.trim().replace(/\s+/g, charReplace);
+}
+
+// Replace whitespace with "_" and allow only HTML CDATA
+// characters: "a"-"z","A"-"Z","0"-"9", "_", ":", "-", ".",
+// and characters above ASCII 127
+function ConvertToCDATAString(string) {
+ return string
+ .replace(/\s+/g, "_")
+ .replace(/[^a-zA-Z0-9_\.\-\:\u0080-\uFFFF]+/g, "");
+}
+
+function GetSelectionAsText() {
+ try {
+ return GetCurrentEditor().outputToString(
+ "text/plain",
+ kOutputSelectionOnly
+ );
+ } catch (e) {}
+
+ return "";
+}
+
+/** *********** Get Current Editor and associated interfaces or info */
+
+function GetCurrentEditor() {
+ // Get the active editor from the <editor> tag
+ // XXX This will probably change if we support > 1 editor in main Composer window
+ // (e.g. a plaintext editor for HTMLSource)
+
+ // For dialogs: Search up parent chain to find top window with editor
+ var editor;
+ try {
+ var editorElement = GetCurrentEditorElement();
+ editor = editorElement.getEditor(editorElement.contentWindow);
+
+ // Do QIs now so editor users won't have to figure out which interface to use
+ // Using "instanceof" does the QI for us.
+ editor instanceof Ci.nsIHTMLEditor;
+ } catch (e) {
+ dump(e) + "\n";
+ }
+
+ return editor;
+}
+
+function GetCurrentTableEditor() {
+ var editor = GetCurrentEditor();
+ return editor && editor instanceof Ci.nsITableEditor ? editor : null;
+}
+
+function GetCurrentEditorElement() {
+ var tmpWindow = window;
+
+ do {
+ // Get the <editor> element(s)
+ let editorItem = tmpWindow.document.querySelector("editor");
+
+ // This will change if we support > 1 editor element
+ if (editorItem) {
+ return editorItem;
+ }
+
+ tmpWindow = tmpWindow.opener;
+ } while (tmpWindow);
+
+ return null;
+}
+
+function GetCurrentCommandManager() {
+ try {
+ return GetCurrentEditorElement().commandManager;
+ } catch (e) {
+ dump(e) + "\n";
+ }
+
+ return null;
+}
+
+function GetCurrentEditorType() {
+ try {
+ return GetCurrentEditorElement().editortype;
+ } catch (e) {
+ dump(e) + "\n";
+ }
+
+ return "";
+}
+
+/**
+ * Gets the editor's spell checker. Could return null if there are no
+ * dictionaries installed.
+ *
+ * @returns {nsIInlineSpellChecker?}
+ */
+function GetCurrentEditorSpellChecker() {
+ try {
+ return GetCurrentEditor().getInlineSpellChecker(true);
+ } catch (ex) {}
+ return null;
+}
+
+function IsHTMLEditor() {
+ // We don't have an editorElement, just return false
+ if (!GetCurrentEditorElement()) {
+ return false;
+ }
+
+ var editortype = GetCurrentEditorType();
+ switch (editortype) {
+ case "html":
+ case "htmlmail":
+ return true;
+
+ case "text":
+ case "textmail":
+ return false;
+
+ default:
+ dump("INVALID EDITOR TYPE: " + editortype + "\n");
+ break;
+ }
+ return false;
+}
+
+function PageIsEmptyAndUntouched() {
+ return IsDocumentEmpty() && !IsDocumentModified() && !IsHTMLSourceChanged();
+}
+
+function IsInHTMLSourceMode() {
+ return gEditorDisplayMode == kDisplayModeSource;
+}
+
+// are we editing HTML (i.e. neither in HTML source mode, nor editing a text file)
+function IsEditingRenderedHTML() {
+ return IsHTMLEditor() && !IsInHTMLSourceMode();
+}
+
+function IsDocumentEditable() {
+ try {
+ return GetCurrentEditor().isDocumentEditable;
+ } catch (e) {}
+ return false;
+}
+
+function IsDocumentEmpty() {
+ try {
+ return GetCurrentEditor().documentIsEmpty;
+ } catch (e) {}
+ return false;
+}
+
+function IsDocumentModified() {
+ try {
+ return GetCurrentEditor().documentModified;
+ } catch (e) {}
+ return false;
+}
+
+function IsHTMLSourceChanged() {
+ // gSourceTextEditor will not be defined if we're just a text editor.
+ return gSourceTextEditor ? gSourceTextEditor.documentModified : false;
+}
+
+function newCommandParams() {
+ try {
+ return Cu.createCommandParams();
+ } catch (e) {
+ dump("error thrown in newCommandParams: " + e + "\n");
+ }
+ return null;
+}
+
+/** *********** General editing command utilities */
+
+function GetDocumentTitle() {
+ try {
+ return GetCurrentEditorElement().contentDocument.title;
+ } catch (e) {}
+
+ return "";
+}
+
+function SetDocumentTitle(title) {
+ try {
+ GetCurrentEditorElement().contentDocument.title = title;
+
+ // Update window title (doesn't work if called from a dialog)
+ if ("UpdateWindowTitle" in window) {
+ window.UpdateWindowTitle();
+ }
+ } catch (e) {}
+}
+
+function EditorGetTextProperty(
+ property,
+ attribute,
+ value,
+ firstHas,
+ anyHas,
+ allHas
+) {
+ try {
+ return GetCurrentEditor().getInlinePropertyWithAttrValue(
+ property,
+ attribute,
+ value,
+ firstHas,
+ anyHas,
+ allHas
+ );
+ } catch (e) {}
+}
+
+function EditorSetTextProperty(property, attribute, value) {
+ try {
+ GetCurrentEditor().setInlineProperty(property, attribute, value);
+ if ("gContentWindow" in window) {
+ window.gContentWindow.focus();
+ }
+ } catch (e) {}
+}
+
+function EditorRemoveTextProperty(property, attribute) {
+ try {
+ GetCurrentEditor().removeInlineProperty(property, attribute);
+ if ("gContentWindow" in window) {
+ window.gContentWindow.focus();
+ }
+ } catch (e) {}
+}
+
+/** *********** Element enbabling/disabling */
+
+// this function takes an elementID and a flag
+// if the element can be found by ID, then it is either enabled (by removing "disabled" attr)
+// or disabled (setAttribute) as specified in the "doEnable" parameter
+function SetElementEnabledById(elementID, doEnable) {
+ SetElementEnabled(document.getElementById(elementID), doEnable);
+}
+
+function SetElementEnabled(element, doEnable) {
+ if (element) {
+ if (doEnable) {
+ element.removeAttribute("disabled");
+ } else {
+ element.setAttribute("disabled", "true");
+ }
+ } else {
+ dump("Element not found in SetElementEnabled\n");
+ }
+}
+
+/** *********** Services / Prefs */
+
+function GetFileProtocolHandler() {
+ let handler = Services.io.getProtocolHandler("file");
+ return handler.QueryInterface(Ci.nsIFileProtocolHandler);
+}
+
+function SetStringPref(aPrefName, aPrefValue) {
+ try {
+ Services.prefs.setStringPref(aPrefName, aPrefValue);
+ } catch (e) {}
+}
+
+// Set initial directory for a filepicker from URLs saved in prefs
+function SetFilePickerDirectory(filePicker, fileType) {
+ if (filePicker) {
+ try {
+ // Save current directory so we can reset it in SaveFilePickerDirectory
+ gFilePickerDirectory = filePicker.displayDirectory;
+
+ let location = Services.prefs.getComplexValue(
+ "editor.lastFileLocation." + fileType,
+ Ci.nsIFile
+ );
+ if (location) {
+ filePicker.displayDirectory = location;
+ }
+ } catch (e) {}
+ }
+}
+
+// Save the directory of the selected file to prefs
+function SaveFilePickerDirectory(filePicker, fileType) {
+ if (filePicker && filePicker.file) {
+ try {
+ var fileDir;
+ if (filePicker.file.parent) {
+ fileDir = filePicker.file.parent.QueryInterface(Ci.nsIFile);
+ }
+
+ Services.prefs.setComplexValue(
+ "editor.lastFileLocation." + fileType,
+ Ci.nsIFile,
+ fileDir
+ );
+
+ Services.prefs.savePrefFile(null);
+ } catch (e) {}
+ }
+
+ // Restore the directory used before SetFilePickerDirectory was called;
+ // This reduces interference with Browser and other module directory defaults
+ if (gFilePickerDirectory) {
+ filePicker.displayDirectory = gFilePickerDirectory;
+ }
+
+ gFilePickerDirectory = null;
+}
+
+function GetDefaultBrowserColors() {
+ var colors = {
+ TextColor: 0,
+ BackgroundColor: 0,
+ LinkColor: 0,
+ ActiveLinkColor: 0,
+ VisitedLinkColor: 0,
+ };
+ var useSysColors = Services.prefs.getBoolPref(
+ "browser.display.use_system_colors",
+ false
+ );
+
+ if (!useSysColors) {
+ colors.TextColor = Services.prefs.getCharPref(
+ "browser.display.foreground_color",
+ 0
+ );
+ colors.BackgroundColor = Services.prefs.getCharPref(
+ "browser.display.background_color",
+ 0
+ );
+ }
+ // Use OS colors for text and background if explicitly asked or pref is not set
+ if (!colors.TextColor) {
+ colors.TextColor = "windowtext";
+ }
+
+ if (!colors.BackgroundColor) {
+ colors.BackgroundColor = "window";
+ }
+
+ colors.LinkColor = Services.prefs.getCharPref("browser.anchor_color");
+ colors.ActiveLinkColor = Services.prefs.getCharPref("browser.active_color");
+ colors.VisitedLinkColor = Services.prefs.getCharPref("browser.visited_color");
+
+ return colors;
+}
+
+/** *********** URL handling */
+
+function TextIsURI(selectedText) {
+ return (
+ selectedText &&
+ /^http:\/\/|^https:\/\/|^file:\/\/|^ftp:\/\/|^about:|^mailto:|^news:|^snews:|^telnet:|^ldap:|^ldaps:|^gopher:|^finger:|^javascript:/i.test(
+ selectedText
+ )
+ );
+}
+
+function IsUrlAboutBlank(urlString) {
+ return urlString.startsWith("about:blank");
+}
+
+function MakeRelativeUrl(url) {
+ let inputUrl = url.trim();
+ if (!inputUrl) {
+ return inputUrl;
+ }
+
+ // Get the filespec relative to current document's location
+ // NOTE: Can't do this if file isn't saved yet!
+ var docUrl = GetDocumentBaseUrl();
+ var docScheme = GetScheme(docUrl);
+
+ // Can't relativize if no doc scheme (page hasn't been saved)
+ if (!docScheme) {
+ return inputUrl;
+ }
+
+ var urlScheme = GetScheme(inputUrl);
+
+ // Do nothing if not the same scheme or url is already relativized
+ if (docScheme != urlScheme) {
+ return inputUrl;
+ }
+
+ // Host must be the same
+ var docHost = GetHost(docUrl);
+ var urlHost = GetHost(inputUrl);
+ if (docHost != urlHost) {
+ return inputUrl;
+ }
+
+ // Get just the file path part of the urls
+ // XXX Should we use GetCurrentEditor().documentCharacterSet for 2nd param ?
+ let docPath = Services.io.newURI(
+ docUrl,
+ GetCurrentEditor().documentCharacterSet
+ ).pathQueryRef;
+ let urlPath = Services.io.newURI(
+ inputUrl,
+ GetCurrentEditor().documentCharacterSet
+ ).pathQueryRef;
+
+ // We only return "urlPath", so we can convert the entire docPath for
+ // case-insensitive comparisons.
+ var doCaseInsensitive = docScheme == "file" && AppConstants.platform == "win";
+ if (doCaseInsensitive) {
+ docPath = docPath.toLowerCase();
+ }
+
+ // Get document filename before we start chopping up the docPath
+ var docFilename = GetFilename(docPath);
+
+ // Both url and doc paths now begin with "/"
+ // Look for shared dirs starting after that
+ urlPath = urlPath.slice(1);
+ docPath = docPath.slice(1);
+
+ var firstDirTest = true;
+ var nextDocSlash = 0;
+ var done = false;
+
+ // Remove all matching subdirs common to both doc and input urls
+ do {
+ nextDocSlash = docPath.indexOf("/");
+ var nextUrlSlash = urlPath.indexOf("/");
+
+ if (nextUrlSlash == -1) {
+ // We're done matching and all dirs in url
+ // what's left is the filename
+ done = true;
+
+ // Remove filename for named anchors in the same file
+ if (nextDocSlash == -1 && docFilename) {
+ var anchorIndex = urlPath.indexOf("#");
+ if (anchorIndex > 0) {
+ var urlFilename = doCaseInsensitive ? urlPath.toLowerCase() : urlPath;
+
+ if (urlFilename.startsWith(docFilename)) {
+ urlPath = urlPath.slice(anchorIndex);
+ }
+ }
+ }
+ } else if (nextDocSlash >= 0) {
+ // Test for matching subdir
+ var docDir = docPath.slice(0, nextDocSlash);
+ var urlDir = urlPath.slice(0, nextUrlSlash);
+ if (doCaseInsensitive) {
+ urlDir = urlDir.toLowerCase();
+ }
+
+ if (urlDir == docDir) {
+ // Remove matching dir+"/" from each path
+ // and continue to next dir.
+ docPath = docPath.slice(nextDocSlash + 1);
+ urlPath = urlPath.slice(nextUrlSlash + 1);
+ } else {
+ // No match, we're done.
+ done = true;
+
+ // Be sure we are on the same local drive or volume
+ // (the first "dir" in the path) because we can't
+ // relativize to different drives/volumes.
+ // UNIX doesn't have volumes, so we must not do this else
+ // the first directory will be misinterpreted as a volume name.
+ if (
+ firstDirTest &&
+ docScheme == "file" &&
+ AppConstants.platform != "unix"
+ ) {
+ return inputUrl;
+ }
+ }
+ } else {
+ // No more doc dirs left, we're done
+ done = true;
+ }
+
+ firstDirTest = false;
+ } while (!done);
+
+ // Add "../" for each dir left in docPath
+ while (nextDocSlash > 0) {
+ urlPath = "../" + urlPath;
+ nextDocSlash = docPath.indexOf("/", nextDocSlash + 1);
+ }
+ return urlPath;
+}
+
+function MakeAbsoluteUrl(url) {
+ let resultUrl = TrimString(url);
+ if (!resultUrl) {
+ return resultUrl;
+ }
+
+ // Check if URL is already absolute, i.e., it has a scheme
+ let urlScheme = GetScheme(resultUrl);
+
+ if (urlScheme) {
+ return resultUrl;
+ }
+
+ let docUrl = GetDocumentBaseUrl();
+ let docScheme = GetScheme(docUrl);
+
+ // Can't relativize if no doc scheme (page hasn't been saved)
+ if (!docScheme) {
+ return resultUrl;
+ }
+
+ // Make a URI object to use its "resolve" method
+ let absoluteUrl = resultUrl;
+ let docUri = Services.io.newURI(
+ docUrl,
+ GetCurrentEditor().documentCharacterSet
+ );
+
+ try {
+ absoluteUrl = docUri.resolve(resultUrl);
+ // This is deprecated and buggy!
+ // If used, we must make it a path for the parent directory (remove filename)
+ // absoluteUrl = IOService.resolveRelativePath(resultUrl, docUrl);
+ } catch (e) {}
+
+ return absoluteUrl;
+}
+
+// Get the HREF of the page's <base> tag or the document location
+// returns empty string if no base href and document hasn't been saved yet
+function GetDocumentBaseUrl() {
+ try {
+ var docUrl;
+
+ // if document supplies a <base> tag, use that URL instead
+ let base = GetCurrentEditor().document.querySelector("base");
+ if (base) {
+ docUrl = base.getAttribute("href");
+ }
+ if (!docUrl) {
+ docUrl = GetDocumentUrl();
+ }
+
+ if (!IsUrlAboutBlank(docUrl)) {
+ return docUrl;
+ }
+ } catch (e) {}
+ return "";
+}
+
+function GetDocumentUrl() {
+ try {
+ return GetCurrentEditor().document.URL;
+ } catch (e) {}
+ return "";
+}
+
+// Extract the scheme (e.g., 'file', 'http') from a URL string
+function GetScheme(urlspec) {
+ var resultUrl = TrimString(urlspec);
+ // Unsaved document URL has no acceptable scheme yet
+ if (!resultUrl || IsUrlAboutBlank(resultUrl)) {
+ return "";
+ }
+
+ var scheme = "";
+ try {
+ // This fails if there's no scheme
+ scheme = Services.io.extractScheme(resultUrl);
+ } catch (e) {}
+
+ return scheme ? scheme.toLowerCase() : "";
+}
+
+function GetHost(urlspec) {
+ if (!urlspec) {
+ return "";
+ }
+
+ var host = "";
+ try {
+ host = Services.io.newURI(urlspec).host;
+ } catch (e) {}
+
+ return host;
+}
+
+function GetUsername(urlspec) {
+ if (!urlspec) {
+ return "";
+ }
+
+ var username = "";
+ try {
+ username = Services.io.newURI(urlspec).username;
+ } catch (e) {}
+
+ return username;
+}
+
+function GetFilename(urlspec) {
+ if (!urlspec || IsUrlAboutBlank(urlspec)) {
+ return "";
+ }
+
+ var filename;
+
+ try {
+ let uri = Services.io.newURI(urlspec);
+ if (uri) {
+ let url = uri.QueryInterface(Ci.nsIURL);
+ if (url) {
+ filename = url.fileName;
+ }
+ }
+ } catch (e) {}
+
+ return filename ? filename : "";
+}
+
+// Return the url without username and password
+// Optional output objects return extracted username and password strings
+// This uses just string routines via nsIIOServices
+function StripUsernamePassword(urlspec, usernameObj, passwordObj) {
+ urlspec = TrimString(urlspec);
+ if (!urlspec || IsUrlAboutBlank(urlspec)) {
+ return urlspec;
+ }
+
+ if (usernameObj) {
+ usernameObj.value = "";
+ }
+ if (passwordObj) {
+ passwordObj.value = "";
+ }
+
+ // "@" must exist else we will never detect username or password
+ var atIndex = urlspec.indexOf("@");
+ if (atIndex > 0) {
+ try {
+ let uri = Services.io.newURI(urlspec);
+ let username = uri.username;
+ let password = uri.password;
+
+ if (usernameObj && username) {
+ usernameObj.value = username;
+ }
+ if (passwordObj && password) {
+ passwordObj.value = password;
+ }
+ if (username) {
+ let usernameStart = urlspec.indexOf(username);
+ if (usernameStart != -1) {
+ return urlspec.slice(0, usernameStart) + urlspec.slice(atIndex + 1);
+ }
+ }
+ } catch (e) {}
+ }
+ return urlspec;
+}
+
+function StripPassword(urlspec, passwordObj) {
+ urlspec = TrimString(urlspec);
+ if (!urlspec || IsUrlAboutBlank(urlspec)) {
+ return urlspec;
+ }
+
+ if (passwordObj) {
+ passwordObj.value = "";
+ }
+
+ // "@" must exist else we will never detect password
+ var atIndex = urlspec.indexOf("@");
+ if (atIndex > 0) {
+ try {
+ let password = Services.io.newURI(urlspec).password;
+
+ if (passwordObj && password) {
+ passwordObj.value = password;
+ }
+ if (password) {
+ // Find last ":" before "@"
+ let colon = urlspec.lastIndexOf(":", atIndex);
+ if (colon != -1) {
+ // Include the "@"
+ return urlspec.slice(0, colon) + urlspec.slice(atIndex);
+ }
+ }
+ } catch (e) {}
+ }
+ return urlspec;
+}
+
+// Version to use when you have an nsIURI object
+function StripUsernamePasswordFromURI(uri) {
+ var urlspec = "";
+ if (uri) {
+ try {
+ urlspec = uri.spec;
+ var userPass = uri.userPass;
+ if (userPass) {
+ let start = urlspec.indexOf(userPass);
+ urlspec =
+ urlspec.slice(0, start) + urlspec.slice(start + userPass.length + 1);
+ }
+ } catch (e) {}
+ }
+ return urlspec;
+}
+
+function InsertUsernameIntoUrl(urlspec, username) {
+ if (!urlspec || !username) {
+ return urlspec;
+ }
+
+ try {
+ let URI = Services.io.newURI(
+ urlspec,
+ GetCurrentEditor().documentCharacterSet
+ );
+ URI.username = username;
+ return URI.spec;
+ } catch (e) {}
+
+ return urlspec;
+}
+
+function ConvertRGBColorIntoHEXColor(color) {
+ if (/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/.test(color)) {
+ var r = Number(RegExp.$1).toString(16);
+ if (r.length == 1) {
+ r = "0" + r;
+ }
+ var g = Number(RegExp.$2).toString(16);
+ if (g.length == 1) {
+ g = "0" + g;
+ }
+ var b = Number(RegExp.$3).toString(16);
+ if (b.length == 1) {
+ b = "0" + b;
+ }
+ return "#" + r + g + b;
+ }
+
+ return color;
+}
+
+/** *********** CSS */
+
+function GetHTMLOrCSSStyleValue(element, attrName, cssPropertyName) {
+ var value;
+ if (Services.prefs.getBoolPref("editor.use_css") && IsHTMLEditor()) {
+ value = element.style.getPropertyValue(cssPropertyName);
+ }
+
+ if (!value) {
+ value = element.getAttribute(attrName);
+ }
+
+ if (!value) {
+ return "";
+ }
+
+ return value;
+}
+
+/** *********** Miscellaneous */
+// Clone simple JS objects
+function Clone(obj) {
+ var clone = {};
+ for (var i in obj) {
+ if (typeof obj[i] == "object") {
+ clone[i] = Clone(obj[i]);
+ } else {
+ clone[i] = obj[i];
+ }
+ }
+ return clone;
+}
+
+/**
+ * Utility functions to handle shortended data: URLs in EdColorProps.js and EdImageOverlay.js.
+ */
+
+/**
+ * Is the passed in image URI a shortened data URI?
+ *
+ * @returns {bool}
+ */
+function isImageDataShortened(aImageData) {
+ return /^data:/i.test(aImageData) && aImageData.includes("…");
+}
+
+/**
+ * Event handler for Copy or Cut
+ *
+ * @param aEvent the event
+ */
+function onCopyOrCutShortened(aEvent) {
+ // Put the original data URI onto the clipboard in case the value
+ // is a shortened data URI.
+ let field = aEvent.target;
+ let startPos = field.selectionStart;
+ if (startPos == undefined) {
+ return;
+ }
+ let endPos = field.selectionEnd;
+ let selection = field.value.substring(startPos, endPos).trim();
+
+ // Test that a) the user selected the whole value,
+ // b) the value is a data URI,
+ // c) it contains the ellipsis we added. Otherwise it could be
+ // a new value that the user pasted in.
+ if (selection == field.value.trim() && isImageDataShortened(selection)) {
+ aEvent.clipboardData.setData("text/plain", field.fullDataURI);
+ if (aEvent.type == "cut") {
+ // We have to cut the selection manually. Since we tested that
+ // everything was selected, we can just reset the field.
+ field.value = "";
+ }
+ aEvent.preventDefault();
+ }
+}
+
+/**
+ * Set up element showing an image URI with a shortened version.
+ * and add event handler for Copy or Cut.
+ *
+ * @param aImageData the data: URL of the image to be shortened.
+ * Note: Original stored in 'aDialogField.fullDataURI'.
+ * @param aDialogField The field of the dialog to contain the data.
+ * @returns {bool} URL was shortened?
+ */
+function shortenImageData(aImageData, aDialogField) {
+ let shortened = false;
+ aDialogField.value = aImageData.replace(
+ /^(data:.+;base64,)(.*)/i,
+ function (match, nonDataPart, dataPart) {
+ if (dataPart.length <= 35) {
+ return match;
+ }
+
+ shortened = true;
+ aDialogField.addEventListener("copy", onCopyOrCutShortened);
+ aDialogField.addEventListener("cut", onCopyOrCutShortened);
+ aDialogField.fullDataURI = aImageData;
+ aDialogField.removeAttribute("tooltiptext");
+ aDialogField.setAttribute("tooltip", "shortenedDataURI");
+ return (
+ nonDataPart +
+ dataPart.substring(0, 5) +
+ "…" +
+ dataPart.substring(dataPart.length - 30)
+ );
+ }
+ );
+ return shortened;
+}
+
+/**
+ * Return full data URIs for a shortened element.
+ *
+ * @param aDialogField The field of the dialog containing the data.
+ */
+function restoredImageData(aDialogField) {
+ return aDialogField.fullDataURI;
+}
diff --git a/comm/mail/components/compose/content/images/tag-anchor.gif b/comm/mail/components/compose/content/images/tag-anchor.gif
new file mode 100644
index 0000000000..ccb809b50b
--- /dev/null
+++ b/comm/mail/components/compose/content/images/tag-anchor.gif
Binary files differ
diff --git a/comm/mail/components/compose/content/messengercompose.xhtml b/comm/mail/components/compose/content/messengercompose.xhtml
new file mode 100644
index 0000000000..6881bf7cf3
--- /dev/null
+++ b/comm/mail/components/compose/content/messengercompose.xhtml
@@ -0,0 +1,2572 @@
+<?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://messenger/skin/messengercompose/messengercompose.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/menulist.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/inContentDialog.css" type="text/css"?>
+
+<!DOCTYPE html [
+ <!ENTITY % messengercomposeDTD SYSTEM "chrome://messenger/locale/messengercompose/messengercompose.dtd" >
+ %messengercomposeDTD;
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+ %brandDTD;
+ <!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd">
+ %customizeToolbarDTD;
+ <!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd">
+ %viewZoomOverlayDTD;
+ <!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd">
+ %baseMenuOverlayDTD;
+ <!ENTITY % msgCompSMIMEDTD SYSTEM "chrome://messenger-smime/locale/msgCompSMIMEOverlay.dtd">
+ %msgCompSMIMEDTD;
+ <!ENTITY % editorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/editorOverlay.dtd">
+ %editorOverlayDTD;
+ <!ENTITY % utilityOverlayDTD SYSTEM
+ "chrome://communicator/locale/utilityOverlay.dtd">
+ %utilityOverlayDTD;
+ <!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd">
+ %messengerDTD;
+]>
+
+<html id="msgcomposeWindow" 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"
+ icon="msgcomposeWindow"
+ scrolling="false"
+ windowtype="msgcompose"
+ toggletoolbar="true"
+ persist="screenX screenY width height sizemode"
+ lightweightthemes="true"
+#ifdef XP_MACOSX
+ macanimationtype="document"
+ chromemargin="0,-1,-1,-1"
+#endif
+ fullscreenbutton="true">
+<head>
+ <title>&msgComposeWindow.title;</title>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="messenger/messengercompose/messengercompose.ftl" />
+ <link rel="localization" href="messenger/menubar.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <link rel="localization" href="messenger/openpgp/keyAssistant.ftl" />
+ <link rel="localization" href="messenger/openpgp/composeKeyStatus.ftl"/>
+ <link rel="localization" href="toolkit/main-window/findbar.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="toolkit/printing/printUI.ftl" />
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/pane-splitter.js"></script>
+ <script defer="defer" src="chrome://messenger/content/accountUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCore.js"></script>
+ <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/editor.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/editorUtilities.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/ComposerCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/MsgComposeCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/bigFileObserver.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/cloudAttachmentLinkManager.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script>
+ <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script>
+ <script defer="defer" src="chrome://messenger/content/addressbook/abDragDrop.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/addressingWidgetOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/addressbook/abCommon.js"></script>
+ <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script>
+#ifdef XP_MACOSX
+ <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script>
+#endif
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/enigmailMsgComposeOverlay.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/commonWorkflows.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/keyAssistant.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <stringbundle id="bundle_composeMsgs" src="chrome://messenger/locale/messengercompose/composeMsgs.properties"/>
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+ <stringbundle id="brandBundle" src="chrome://branding/locale/brand.properties"/>
+
+<commandset id="composeCommands">
+ <commandset id="msgComposeCommandUpdate"
+ commandupdater="true"
+ events="focus"
+ oncommandupdate="CommandUpdate_MsgCompose()"/>
+ <commandset id="globalEditMenuItems"
+ commandupdater="true"
+ events="focus"
+ oncommandupdate="goUpdateGlobalEditMenuItems()"/>
+ <commandset id="selectEditMenuItems"
+ commandupdater="true"
+ events="select"
+ oncommandupdate="goUpdateSelectEditMenuItems()"/>
+ <commandset id="undoEditMenuItems"
+ commandupdater="true"
+ events="undo"
+ oncommandupdate="goUpdateUndoEditMenuItems()"/>
+ <commandset id="clipboardEditMenuItems"
+ commandupdater="true"
+ events="clipboard"
+ oncommandupdate="goUpdatePasteMenuItems()"/>
+
+ <!-- commands updated when the editor gets created -->
+ <commandset id="commonEditorMenuItems"
+ commandupdater="true"
+ events="create"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <command id="cmd_print" oncommand="goDoCommand('cmd_print')"/>
+ <command id="cmd_quitApplication" oncommand="goDoCommand('cmd_quitApplication')"/>
+ </commandset>
+
+ <commandset id="composerMenuItems"
+ commandupdater="true"
+ events="create, mode_switch"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <!-- format menu -->
+ <command id="cmd_listProperties" oncommand="goDoCommand('cmd_listProperties')"/>
+ <command id="cmd_colorProperties" oncommand="goDoCommand('cmd_colorProperties')"/>
+
+ <command id="cmd_link" oncommand="goDoCommand('cmd_link')"/>
+ <command id="cmd_anchor" oncommand="goDoCommand('cmd_anchor')"/>
+ <command id="cmd_image" oncommand="goDoCommand('cmd_image')"/>
+ <command id="cmd_hline" oncommand="goDoCommand('cmd_hline')"/>
+ <command id="cmd_table" oncommand="goDoCommand('cmd_table')"/>
+ <command id="cmd_objectProperties" oncommand="goDoCommand('cmd_objectProperties')"/>
+ <command id="cmd_insertChars" oncommand="goDoCommand('cmd_insertChars')" label="&insertCharsCmd.label;"/>
+ <command id="cmd_insertHTMLWithDialog" oncommand="goDoCommand('cmd_insertHTMLWithDialog')" label="&insertHTMLCmd.label;"/>
+ <command id="cmd_insertMathWithDialog" oncommand="goDoCommand('cmd_insertMathWithDialog')" label="&insertMathCmd.label;"/>
+
+ <command id="cmd_insertBreakAll" oncommand="goDoCommand('cmd_insertBreakAll')"/>
+
+ <!-- dummy command used just to disable things in non-HTML modes -->
+ <command id="cmd_renderedHTMLEnabler"/>
+ </commandset>
+
+ <!-- edit menu commands. These get updated by code in globalOverlay.js -->
+ <commandset id="composerEditMenuItems"
+ commandupdater="true"
+ events="create, mode_switch"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <command id="cmd_pasteNoFormatting" oncommand="goDoCommand('cmd_pasteNoFormatting')"
+ label="&pasteNoFormatting.label;" accesskey="&pasteNoFormatting.accesskey;"/>
+ <command id="cmd_findReplace" oncommand="goDoCommand('cmd_findReplace')"/>
+ <command id="cmd_find" oncommand="goDoCommand('cmd_find')"/>
+ <command id="cmd_findNext" oncommand="goDoCommand('cmd_findNext');"/>
+ <command id="cmd_findPrev" oncommand="goDoCommand('cmd_findPrev');"/>
+ <command id="cmd_spelling" oncommand="goDoCommand('cmd_spelling')"/>
+ <command id="cmd_pasteQuote" oncommand="goDoCommand('cmd_pasteQuote')" label="&pasteAsQuotationCmd.label;"/>
+ </commandset>
+
+ <!-- style related commands that update on creation, and on selection change -->
+ <commandset id="composerStyleMenuItems"
+ commandupdater="true"
+ events="create, style, mode_switch"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <command id="cmd_bold" state="false" oncommand="doStyleUICommand('cmd_bold')"/>
+ <command id="cmd_italic" state="false" oncommand="doStyleUICommand('cmd_italic')"/>
+ <command id="cmd_underline" state="false" oncommand="doStyleUICommand('cmd_underline')"/>
+ <command id="cmd_tt" state="false" oncommand="goDoCommand('cmd_tt')"/>
+ <command id="cmd_smiley"/>
+
+ <command id="cmd_strikethrough" state="false" oncommand="doStyleUICommand('cmd_strikethrough');"/>
+ <command id="cmd_superscript" state="false" oncommand="doStyleUICommand('cmd_superscript');"/>
+ <command id="cmd_subscript" state="false" oncommand="doStyleUICommand('cmd_subscript');"/>
+ <command id="cmd_nobreak" state="false" oncommand="goDoCommand('cmd_nobreak');"/>
+
+ <command id="cmd_em" state="false" oncommand="goDoCommand('cmd_em')"/>
+ <command id="cmd_strong" state="false" oncommand="goDoCommand('cmd_strong')"/>
+ <command id="cmd_cite" state="false" oncommand="goDoCommand('cmd_cite')"/>
+ <command id="cmd_abbr" state="false" oncommand="goDoCommand('cmd_abbr')"/>
+ <command id="cmd_acronym" state="false" oncommand="goDoCommand('cmd_acronym')"/>
+ <command id="cmd_code" state="false" oncommand="goDoCommand('cmd_code')"/>
+ <command id="cmd_samp" state="false" oncommand="goDoCommand('cmd_samp')"/>
+ <command id="cmd_var" state="false" oncommand="goDoCommand('cmd_var')"/>
+
+ <command id="cmd_ul" state="false" oncommand="doStyleUICommand('cmd_ul')"/>
+ <command id="cmd_ol" state="false" oncommand="doStyleUICommand('cmd_ol')"/>
+
+ <command id="cmd_indent" oncommand="goDoCommand('cmd_indent')"/>
+ <command id="cmd_outdent" oncommand="goDoCommand('cmd_outdent')"/>
+
+ <command id="cmd_paragraphState" state=""/>
+ <command id="cmd_fontFace" state="" oncommand="doStatefulCommand('cmd_fontFace', event.target.value)"/>
+
+ <!-- No "oncommand", use EditorSelectColor() to bring up color dialog -->
+ <command id="cmd_fontColor" state="" disabled="false"/>
+ <command id="cmd_backgroundColor" state="" disabled="false"/>
+ <command id="cmd_highlight" state="transparent" oncommand="EditorSelectColor('Highlight', event);"/>
+
+ <command id="cmd_align" state=""/>
+
+ <command id="cmd_increaseFontStep" oncommand="goDoCommand('cmd_increaseFontStep')"/>
+ <command id="cmd_decreaseFontStep" oncommand="goDoCommand('cmd_decreaseFontStep')"/>
+
+ <command id="cmd_removeStyles" oncommand="editorRemoveTextStyling();"/>
+ <command id="cmd_removeLinks" oncommand="goDoCommand('cmd_removeLinks')"/>
+ <command id="cmd_removeNamedAnchors" oncommand="goDoCommand('cmd_removeNamedAnchors')"/>
+ </commandset>
+
+ <commandset id="composerTableMenuItems"
+ commandupdater="true"
+ events="create, mode_switch"
+ oncommandupdate="goUpdateTableMenuItems(this)">
+ <!-- Table menu -->
+ <command id="cmd_SelectTable" oncommand="goDoCommand('cmd_SelectTable')"/>
+ <command id="cmd_SelectRow" oncommand="goDoCommand('cmd_SelectRow')"/>
+ <command id="cmd_SelectColumn" oncommand="goDoCommand('cmd_SelectColumn')"/>
+ <command id="cmd_SelectCell" oncommand="goDoCommand('cmd_SelectCell')"/>
+ <command id="cmd_SelectAllCells" oncommand="goDoCommand('cmd_SelectAllCells')"/>
+ <command id="cmd_InsertTable" oncommand="goDoCommand('cmd_InsertTable')"/>
+ <command id="cmd_InsertRowAbove" oncommand="goDoCommand('cmd_InsertRowAbove')"/>
+ <command id="cmd_InsertRowBelow" oncommand="goDoCommand('cmd_InsertRowBelow')"/>
+ <command id="cmd_InsertColumnBefore" oncommand="goDoCommand('cmd_InsertColumnBefore')"/>
+ <command id="cmd_InsertColumnAfter" oncommand="goDoCommand('cmd_InsertColumnAfter')"/>
+ <command id="cmd_InsertCellBefore" oncommand="goDoCommand('cmd_InsertCellBefore')"/>
+ <command id="cmd_InsertCellAfter" oncommand="goDoCommand('cmd_InsertCellAfter')"/>
+ <command id="cmd_DeleteTable" oncommand="goDoCommand('cmd_DeleteTable')"/>
+ <command id="cmd_DeleteRow" oncommand="goDoCommand('cmd_DeleteRow')"/>
+ <command id="cmd_DeleteColumn" oncommand="goDoCommand('cmd_DeleteColumn')"/>
+ <command id="cmd_DeleteCell" oncommand="goDoCommand('cmd_DeleteCell')"/>
+ <command id="cmd_DeleteCellContents" oncommand="goDoCommand('cmd_DeleteCellContents')"/>
+ <command id="cmd_JoinTableCells" oncommand="goDoCommand('cmd_JoinTableCells')"/>
+ <command id="cmd_SplitTableCell" oncommand="goDoCommand('cmd_SplitTableCell')"/>
+ <command id="cmd_ConvertToTable" oncommand="goDoCommand('cmd_ConvertToTable')"/>
+ <command id="cmd_TableOrCellColor" oncommand="goDoCommand('cmd_TableOrCellColor')"/>
+ <command id="cmd_editTable" oncommand="goDoCommand('cmd_editTable')"/>
+ </commandset>
+
+ <!-- commands updated only when the menu gets created -->
+ <commandset id="composerListMenuItems"
+ commandupdater="true"
+ events="create, mode_switch"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <!-- List menu -->
+ <command id="cmd_dt" oncommand="goDoCommand('cmd_dt')"/>
+ <command id="cmd_dd" oncommand="goDoCommand('cmd_dd')"/>
+ <command id="cmd_removeList" oncommand="goDoCommand('cmd_removeList')"/>
+ <!-- cmd_ul and cmd_ol are shared with toolbar and are in composerStyleMenuItems commandset -->
+ </commandset>
+
+ <!-- File Menu -->
+ <command id="cmd_new" oncommand="goDoCommand('cmd_newMessage')"/>
+ <command id="cmd_attachFile" oncommand="goDoCommand('cmd_attachFile')"/>
+ <command id="cmd_attachCloud" oncommand="attachToCloud(event)"/>
+ <command id="cmd_attachPage" oncommand="goDoCommand('cmd_attachPage')"/>
+ <command id="cmd_attachVCard" checked="false"
+ oncommand="ToggleAttachVCard(event.target)"/>
+ <command id="cmd_attachPublicKey" checked="false"
+ oncommand="toggleAttachMyPublicKey(event.target)"/>
+ <command id="cmd_remindLater" checked="false"
+ oncommand="toggleAttachmentReminder()"/>
+ <command id="cmd_close" oncommand="goDoCommand('cmd_close')"/>
+ <command id="cmd_saveDefault" oncommand="goDoCommand('cmd_saveDefault')"/>
+ <command id="cmd_saveAsFile" oncommand="goDoCommand('cmd_saveAsFile')"/>
+ <command id="cmd_saveAsDraft" oncommand="goDoCommand('cmd_saveAsDraft')"/>
+ <command id="cmd_saveAsTemplate" oncommand="goDoCommand('cmd_saveAsTemplate')"/>
+ <command id="cmd_sendButton" oncommand="goDoCommand('cmd_sendButton')"/>
+ <command id="cmd_sendNow" oncommand="goDoCommand('cmd_sendNow')"/>
+ <command id="cmd_sendWithCheck" oncommand="goDoCommand('cmd_sendWithCheck')"/>
+ <command id="cmd_sendLater" oncommand="goDoCommand('cmd_sendLater')"/>
+ <command id="cmd_print" oncommand="goDoCommand('cmd_print')"/>
+
+ <!-- Edit Menu -->
+ <!--command id="cmd_pasteQuote"/ DO NOT INCLUDE THOSE COMMANDS ELSE THE EDIT MENU WILL BE BROKEN! -->
+ <!--command id="cmd_find"/-->
+ <!--command id="cmd_findNext"/-->
+ <command id="cmd_undo" oncommand="goDoCommand('cmd_undo')" disabled="true"/>
+ <command id="cmd_redo" oncommand="goDoCommand('cmd_redo')" disabled="true"/>
+ <command id="cmd_cut" oncommand="goDoCommand('cmd_cut')" disabled="true"/>
+ <command id="cmd_copy" oncommand="goDoCommand('cmd_copy')" disabled="true"/>
+ <command id="cmd_paste" oncommand="goDoCommand('cmd_paste')" disabled="true"/>
+ <command id="cmd_rewrap" oncommand="goDoCommand('cmd_rewrap')"/>
+ <command id="cmd_delete"
+ oncommand="goDoCommand('cmd_delete')"
+ valueDefault="&deleteCmd.label;"
+ valueDefaultAccessKey="&deleteCmd.accesskey;"
+ valueRemoveAttachmentAccessKey="&removeAttachment.accesskey;"
+ disabled="true"/>
+ <command id="cmd_selectAll"
+ oncommand="goDoCommand('cmd_selectAll')" disabled="true"/>
+ <command id="cmd_removeAllAttachments"
+ oncommand="goDoCommand('cmd_removeAllAttachments')"/>
+ <command id="cmd_openAttachment"
+ oncommand="goDoCommand('cmd_openAttachment')" disabled="true"/>
+ <command id="cmd_renameAttachment"
+ oncommand="goDoCommand('cmd_renameAttachment')" disabled="true"/>
+ <command id="cmd_reorderAttachments"
+ oncommand="goDoCommand('cmd_reorderAttachments')" disabled="true"/>
+ <command id="cmd_toggleAttachmentPane"
+ oncommand="goDoCommand('cmd_toggleAttachmentPane')"/>
+ <command id="cmd_account"
+ oncommand="goDoCommand('cmd_account')"/>
+
+ <!-- Reorder Attachments Panel -->
+ <command id="cmd_moveAttachmentLeft"
+ oncommand="goDoCommand('cmd_moveAttachmentLeft')" disabled="true"/>
+ <command id="cmd_moveAttachmentRight"
+ oncommand="goDoCommand('cmd_moveAttachmentRight')" disabled="true"/>
+ <command id="cmd_moveAttachmentBundleUp"
+ oncommand="goDoCommand('cmd_moveAttachmentBundleUp')" disabled="true"/>
+ <command id="cmd_moveAttachmentBundleDown"
+ oncommand="goDoCommand('cmd_moveAttachmentBundleDown')" disabled="true"/>
+ <command id="cmd_moveAttachmentTop"
+ oncommand="goDoCommand('cmd_moveAttachmentTop')" disabled="true"/>
+ <command id="cmd_moveAttachmentBottom"
+ oncommand="goDoCommand('cmd_moveAttachmentBottom')" disabled="true"/>
+ <command id="cmd_sortAttachmentsToggle"
+ sortdirection="ascending"
+ oncommand="goDoCommand('cmd_sortAttachmentsToggle')" disabled="true"/>
+
+ <!-- View Menu -->
+ <command id="cmd_showFormatToolbar"
+ oncommand="goDoCommand('cmd_showFormatToolbar')"/>
+
+ <commandset id="viewZoomCommands"
+ commandupdater="false"
+ events="create-menu-view"
+ oncommandupdate="goUpdateMailMenuItems(this);">
+ <command id="cmd_fullZoomReduce"
+ oncommand="goDoCommand('cmd_fullZoomReduce');"/>
+ <command id="cmd_fullZoomEnlarge"
+ oncommand="goDoCommand('cmd_fullZoomEnlarge');"/>
+ <command id="cmd_fullZoomReset"
+ oncommand="goDoCommand('cmd_fullZoomReset');"/>
+ <command id="cmd_fullZoomToggle"
+ oncommand="goDoCommand('cmd_fullZoomToggle');"/>
+ </commandset>
+
+ <!-- Options Menu -->
+ <command id="cmd_quoteMessage" oncommand="goDoCommand('cmd_quoteMessage')"/>
+ <command id="cmd_toggleReturnReceipt"
+ oncommand="goDoCommand('cmd_toggleReturnReceipt')"/>
+ <command id="cmd_insert"/>
+ <command id="cmd_viewSecurityStatus"
+ oncommand="showMessageComposeSecurityStatus();"/>
+
+#ifdef XP_MACOSX
+ <!-- Mac Window menu -->
+ <command id="minimizeWindow" label="&minimizeWindow.label;" oncommand="window.minimize();"/>
+ <command id="zoomWindow" label="&zoomWindow.label;" oncommand="zoomWindow();"/>
+#endif
+
+ <command id="cmd_CustomizeComposeToolbar"
+ oncommand="CustomizeMailToolbar('compose-toolbox', 'CustomizeComposeToolbar')"/>
+
+ <command id="cmd_convertCloud" oncommand="convertSelectedToCloudAttachment(event.target.cloudFileAccount); event.stopPropagation();"/>
+ <command id="cmd_convertAttachment" oncommand="goDoCommand('cmd_convertAttachment')"/>
+ <command id="cmd_cancelUpload" oncommand="goDoCommand('cmd_cancelUpload')"/>
+ <command id="cmd_customizeFromAddress" oncommand="MakeFromFieldEditable();"
+ checked="false" label="&customizeFromAddress.label;"/>
+</commandset>
+
+ <commandset>
+ <command id="cmd_reload" oncommand="document.getElementById('requestFrame').reload()"/>
+ <command id="cmd_stop" oncommand="document.getElementById('requestFrame').stop()"/>
+ <command id="cmd_copyLink" oncommand="goDoCommand('cmd_copyLink')" disabled="false"/>
+ <command id="cmd_copyImage" oncommand="goDoCommand('cmd_copyImageContents')" disabled="false"/>
+ </commandset>
+
+<keyset id="tasksKeys">
+ <!-- File Menu -->
+ <key id="key_newMessage" key="&newMessageCmd2.key;" oncommand="goOpenNewMessage(null);" modifiers="accel"/>
+ <key id="key_close" key="&closeCmd.key;" command="cmd_close" modifiers="accel"/>
+ <key id="key_save" key="&saveCmd.key;" command="cmd_saveDefault" modifiers="accel"/>
+ <key id="key_send" keycode="&sendCmd.keycode;" observes="cmd_sendWithCheck" modifiers="accel"/>
+ <key id="key_sendLater" keycode="&sendLaterCmd.keycode;" observes="cmd_sendLater" modifiers="accel, shift"/>
+ <key id="key_print" key="&printCmd.key;" command="cmd_print" modifiers="accel"/>
+ <key id="printKb" key="&printCmd.key;" command="cmd_print" modifiers="accel"/>
+
+ <!-- Edit Menu -->
+ <key id="key_undo" data-l10n-id="text-action-undo-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_redo"
+#ifdef XP_UNIX
+ data-l10n-id="text-action-undo-shortcut"
+ modifiers="accel,shift"
+#else
+ data-l10n-id="text-action-redo-shortcut"
+ modifiers="accel"
+#endif
+ internal="true"/>
+ <key id="key_cut" data-l10n-id="text-action-cut-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_paste" data-l10n-id="text-action-paste-shortcut" modifiers="accel" internal="true"/>
+ <key id="pastequotationkb" key="&pasteAsQuotationCmd.key;"
+ observes="cmd_pasteQuote" modifiers="accel, shift"/>
+ <key id="pastenoformattingkb" key="&pasteNoFormattingCmd.key;"
+ modifiers="accel, shift" observes="cmd_pasteNoFormatting"/>
+ <key id="key_rewrap" key="&editRewrapCmd.key;" command="cmd_rewrap" modifiers="accel"/>
+#ifdef XP_MACOSX
+ <key id="key_delete" keycode="VK_BACK" command="cmd_delete"/>
+ <key id="key_delete2" keycode="VK_DELETE" command="cmd_delete"/>
+#else
+ <key id="key_delete" keycode="VK_DELETE" command="cmd_delete"/>
+ <key id="key_renameAttachment" keycode="VK_F2"
+ command="cmd_renameAttachment"/>
+#endif
+ <key id="key_reorderAttachments"
+ key="&reorderAttachmentsCmd.key;" modifiers="accel,shift"
+ command="cmd_reorderAttachments"/>
+ <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_find" key="&findBarCmd.key;" command="cmd_find" modifiers="accel"/>
+#ifndef XP_MACOSX
+ <key id="key_findReplace" key="&findReplaceCmd.key;" command="cmd_findReplace" modifiers="accel"/>
+#endif
+ <key id="key_findNext" key="&findAgainCmd.key;" command="cmd_findNext" modifiers="accel"/>
+ <key id="key_findPrev" key="&findPrevCmd.key;" command="cmd_findPrev" modifiers="accel, shift"/>
+ <key keycode="&findAgainCmd.key2;" command="cmd_findNext"/>
+ <key keycode="&findPrevCmd.key2;" command="cmd_findPrev" modifiers="shift"/>
+
+ <!-- Reorder Attachments Panel -->
+ <key id="key_moveAttachmentLeft" keycode="VK_LEFT" modifiers="alt"
+ command="cmd_moveAttachmentLeft"/>
+ <key id="key_moveAttachmentRight" keycode="VK_RIGHT" modifiers="alt"
+ command="cmd_moveAttachmentRight"/>
+ <key id="key_moveAttachmentBundleUp" keycode="VK_UP" modifiers="alt"
+ command="cmd_moveAttachmentBundleUp"/>
+ <key id="key_moveAttachmentBundleDown" keycode="VK_DOWN" modifiers="alt"
+ command="cmd_moveAttachmentBundleDown"/>
+#ifdef XP_MACOSX
+ <key id="key_moveAttachmentTop" keycode="VK_UP" modifiers="accel alt"
+ command="cmd_moveAttachmentTop"/>
+ <key id="key_moveAttachmentBottom" keycode="VK_DOWN" modifiers="accel alt"
+ command="cmd_moveAttachmentBottom"/>
+ <key id="key_moveAttachmentTop2" keycode="VK_Home" modifiers="alt"
+ command="cmd_moveAttachmentTop"/>
+ <key id="key_moveAttachmentBottom2" keycode="VK_End" modifiers="alt"
+ command="cmd_moveAttachmentBottom"/>
+#else
+ <key id="key_moveAttachmentTop" keycode="VK_Home" modifiers="alt"
+ command="cmd_moveAttachmentTop"/>
+ <key id="key_moveAttachmentBottom" keycode="VK_End" modifiers="alt"
+ command="cmd_moveAttachmentBottom"/>
+#endif
+ <key id="key_sortAttachmentsToggle" key="&sortAttachmentsPanelBtn.key;"
+ modifiers="alt" command="cmd_sortAttachmentsToggle"/>
+
+ <!-- View Menu -->
+ <key id="key_addressSidebar" keycode="VK_F9" oncommand="toggleContactsSidebar();"/>
+
+ <keyset id="viewZoomKeys">
+ <key id="key_fullZoomReduce" key="&fullZoomReduceCmd.commandkey;"
+ command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key key="&fullZoomReduceCmd.commandkey2;"
+ command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key id="key_fullZoomEnlarge" key="&fullZoomEnlargeCmd.commandkey;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key key="&fullZoomEnlargeCmd.commandkey2;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key key="&fullZoomEnlargeCmd.commandkey3;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key id="key_fullZoomReset" key="&fullZoomResetCmd.commandkey;"
+ command="cmd_fullZoomReset" modifiers="accel"/>
+ <key key="&fullZoomResetCmd.commandkey2;"
+ command="cmd_fullZoomReset" modifiers="accel"/>
+ </keyset>
+
+ <!-- Options Menu -->
+ <key id="key_checkspelling" key="&checkSpellingCmd2.key;" command="cmd_spelling" modifiers="accel,shift"/>
+
+#ifdef XP_WIN
+ <key keycode="&checkSpellingCmd2.key2;" command="cmd_spelling"/>
+#endif
+
+ <!-- Tools Menu -->
+ <key id="key_mail" key="&messengerCmd.commandkey;" oncommand="toMessengerWindow();" modifiers="accel"/>
+
+ <!-- Tab/F6 Keys -->
+ <key keycode="VK_TAB" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control"/>
+ <key keycode="VK_TAB" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control,shift"/>
+ <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control"/>
+ <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control,shift"/>
+ <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="shift"/>
+ <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);"/>
+
+#ifdef XP_MACOSX
+ <!-- Mac Window Menu -->
+ <key id="key_minimizeWindow" command="minimizeWindow" key="&minimizeWindow.key;" modifiers="accel"/>
+ <key id="key_openHelp" oncommand="openSupportURL();" key="&productHelpMac.commandkey;" modifiers="&productHelpMac.modifiers;"/>
+#else
+ <key id="key_openHelp" oncommand="openSupportURL();" keycode="&productHelp.commandkey;"/>
+#endif
+ <key keycode="VK_ESCAPE" oncommand="handleEsc();"/>
+</keyset>
+
+<keyset id="editorKeys">
+ <key id="boldkb" key="&styleBoldCmd.key;" observes="cmd_bold" modifiers="accel"/>
+ <key id="italickb" key="&styleItalicCmd.key;" observes="cmd_italic" modifiers="accel"/>
+ <key id="underlinekb" key="&styleUnderlineCmd.key;" observes="cmd_underline" modifiers="accel"/>
+ <key id="fixedwidthkb" key="&fontFixedWidth.key;" observes="cmd_tt" modifiers="accel"/>
+
+ <key id="increaseindentkb" key="&increaseIndent.key;" observes="cmd_indent" modifiers="accel"/>
+ <key id="decreaseindentkb" key="&decreaseIndent.key;" observes="cmd_outdent" modifiers="accel"/>
+
+ <key id="removestyleskb" key="&formatRemoveStyles.key;" observes="cmd_removeStyles" modifiers="accel, shift"/>
+ <key id="removestyleskb2" key=" " observes="cmd_removeStyles" modifiers="accel"/>
+ <key id="removelinkskb" key="&formatRemoveLinks.key;" observes="cmd_removeLinks" modifiers="accel, shift"/>
+ <key id="removenamedanchorskb" key="&formatRemoveNamedAnchors2.key;" observes="cmd_removeNamedAnchors" modifiers="accel, shift"/>
+ <key id="decreasefontsizekb" key="&decrementFontSize.key;" observes="cmd_decreaseFontStep" modifiers="accel"/>
+ <key key="&decrementFontSize.key;" observes="cmd_decreaseFontStep" modifiers="accel, shift"/>
+ <key key="&decrementFontSize.key2;" observes="cmd_decreaseFontStep" modifiers="accel"/>
+
+ <key id="increasefontsizekb" key="&incrementFontSize.key;" observes="cmd_increaseFontStep" modifiers="accel"/>
+ <key key="&incrementFontSize.key;" observes="cmd_increaseFontStep" modifiers="accel, shift"/>
+ <key key="&incrementFontSize.key2;" observes="cmd_increaseFontStep" modifiers="accel"/>
+
+ <key id="insertlinkkb" key="&insertLinkCmd2.key;" observes="cmd_link" modifiers="accel"/>
+</keyset>
+
+<popupset id="mainPopupSet">
+#include ../../../base/content/widgets/browserPopups.inc.xhtml
+</popupset>
+
+<!-- Reorder Attachments Panel -->
+<panel id="reorderAttachmentsPanel"
+ orient="vertical"
+ type="arrow"
+ flip="slide"
+ onpopupshowing="reorderAttachmentsPanelOnPopupShowing();"
+ consumeoutsideclicks="false"
+ noautohide="true">
+ <description class="panelTitle">&reorderAttachmentsPanel.label;</description>
+ <toolbarbutton id="btn_moveAttachmentFirst"
+ class="panelButton"
+ data-l10n-id="move-attachment-first-panel-button"
+ key="key_moveAttachmentTop"
+ command="cmd_moveAttachmentTop"/>
+ <toolbarbutton id="btn_moveAttachmentLeft"
+ class="panelButton"
+ data-l10n-id="move-attachment-left-panel-button"
+ key="key_moveAttachmentLeft"
+ command="cmd_moveAttachmentLeft"/>
+ <toolbarbutton id="btn_moveAttachmentBundleUp"
+ class="panelButton"
+ label="&moveAttachmentBundleUpPanelBtn.label;"
+ key="key_moveAttachmentBundleUp"
+ command="cmd_moveAttachmentBundleUp"/>
+ <toolbarbutton id="btn_moveAttachmentRight"
+ class="panelButton"
+ data-l10n-id="move-attachment-right-panel-button"
+ key="key_moveAttachmentRight"
+ command="cmd_moveAttachmentRight"/>
+ <toolbarbutton id="btn_moveAttachmentLast"
+ class="panelButton"
+ data-l10n-id="move-attachment-last-panel-button"
+ key="key_moveAttachmentBottom"
+ command="cmd_moveAttachmentBottom"/>
+ <toolbarbutton id="btn_sortAttachmentsToggle"
+ class="panelButton"
+ label="&sortAttachmentsPanelBtn.Sort.AZ.label;"
+ label-AZ="&sortAttachmentsPanelBtn.Sort.AZ.label;"
+ label-ZA="&sortAttachmentsPanelBtn.Sort.ZA.label;"
+ label-selection-AZ="&sortAttachmentsPanelBtn.SortSelection.AZ.label;"
+ label-selection-ZA="&sortAttachmentsPanelBtn.SortSelection.ZA.label;"
+ key="key_sortAttachmentsToggle"
+ command="cmd_sortAttachmentsToggle"/>
+</panel>
+
+<menupopup id="extraAddressRowsMenu"
+ class="no-icon-menupopup no-accel-menupopup"
+ onpopupshown="extraAddressRowsMenuOpened();"
+ onpopuphidden="extraAddressRowsMenuClosed();">
+ <!-- Default set up is for a mail account, where we prefer showing the
+ - buttons, rather than the menu items, for the mail rows.
+ - For the news rows, we prefer the menu items over the buttons. -->
+ <menuitem id="addr_replyShowAddressRowMenuItem"
+ class="menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowReply')"
+ label="&replyAddr2.label;"/>
+ <menuitem id="addr_toShowAddressRowMenuItem" disableonsend="true"
+ class="mail-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowTo')"
+ hidden="true"
+ data-button-id="addr_toShowAddressRowButton"
+ data-prefer-button="true"/>
+ <menuitem id="addr_ccShowAddressRowMenuItem" disableonsend="true"
+ class="mail-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowCc')"
+ hidden="true"
+ data-button-id="addr_ccShowAddressRowButton"
+ data-prefer-button="true"/>
+ <menuitem id="addr_bccShowAddressRowMenuItem" disableonsend="true"
+ class="mail-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowBcc')"
+ hidden="true"
+ data-button-id="addr_bccShowAddressRowButton"
+ data-prefer-button="true"/>
+ <menuitem id="addr_newsgroupsShowAddressRowMenuItem"
+ class="news-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowNewsgroups')"
+ data-button-id="addr_newsgroupsShowAddressRowButton"
+ label="&newsgroupsAddr2.label;"
+ data-prefer-button="false"/>
+ <menuitem id="addr_followupShowAddressRowMenuItem"
+ class="news-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowFollowup')"
+ data-button-id="addr_followupShowAddressRowButton"
+ label="&followupAddr2.label;"
+ data-prefer-button="false"/>
+</menupopup>
+
+<menupopup id="msgComposeContext"
+ onpopupshowing="msgComposeContextOnShowing(event);"
+ onpopuphiding="msgComposeContextOnHiding(event);">
+
+ <!-- Spellchecking menu items -->
+ <menuitem id="spellCheckNoSuggestions"
+ data-l10n-id="text-action-spell-no-suggestions"
+ disabled="true"/>
+ <menuseparator id="spellCheckAddSep" />
+ <menuitem id="spellCheckAddToDictionary"
+ data-l10n-id="text-action-spell-add-to-dictionary"
+ oncommand="gSpellChecker.addToDictionary();"/>
+ <menuitem id="spellCheckUndoAddToDictionary"
+ data-l10n-id="text-action-spell-undo-add-to-dictionary"
+ oncommand="gSpellChecker.undoAddToDictionary();" />
+ <menuitem id="spellCheckIgnoreWord" label="&spellCheckIgnoreWord.label;"
+ accesskey="&spellCheckIgnoreWord.accesskey;"
+ oncommand="gSpellChecker.ignoreWord();"/>
+ <menuseparator id="spellCheckSuggestionsSeparator"/>
+
+ <menuitem data-l10n-id="text-action-undo" command="cmd_undo"/>
+ <menuitem data-l10n-id="text-action-cut" command="cmd_cut"/>
+ <menuitem data-l10n-id="text-action-copy" command="cmd_copy"/>
+ <menuitem data-l10n-id="text-action-paste" command="cmd_paste"/>
+ <menuitem command="cmd_pasteNoFormatting"/>
+ <menuitem label="&pasteQuote.label;" accesskey="&pasteQuote.accesskey;" command="cmd_pasteQuote"/>
+ <menuitem data-l10n-id="text-action-delete" command="cmd_delete"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll"/>
+
+ <!-- Spellchecking general menu items (enable, add dictionaries...) -->
+ <menuseparator id="spellCheckSeparator"/>
+ <menuitem id="spellCheckEnable"
+ data-l10n-id="text-action-spell-check-toggle"
+ type="checkbox"
+ oncommand="toggleSpellCheckingEnabled();"/>
+ <menuitem id="spellCheckAddDictionariesMain"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="openDictionaryList();"/>
+ <menu id="spellCheckDictionaries"
+ data-l10n-id="text-action-spell-dictionaries">
+ <menupopup id="spellCheckDictionariesMenu">
+ <menuseparator id="spellCheckLanguageSeparator"/>
+ <menuitem id="spellCheckAddDictionaries"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="openDictionaryList();"/>
+ </menupopup>
+ </menu>
+
+</menupopup>
+
+<menupopup id="msgComposeAttachmentItemContext"
+ onpopupshowing="updateAttachmentItems();">
+ <menuitem id="composeAttachmentContext_openItem"
+ label="&openAttachment.label;"
+ accesskey="&openAttachment.accesskey;"
+ command="cmd_openAttachment"/>
+ <menuitem id="composeAttachmentContext_renameItem"
+ label="&renameAttachment.label;"
+ accesskey="&renameAttachment.accesskey;"
+ command="cmd_renameAttachment"/>
+ <menuitem id="composeAttachmentContext_reorderItem"
+ label="&reorderAttachments.label;"
+ accesskey="&reorderAttachments.accesskey;"
+ command="cmd_reorderAttachments"/>
+ <menuseparator id="composeAttachmentContext_beforeRemoveSeparator"/>
+ <menuitem id="composeAttachmentContext_deleteItem"
+ label="&removeAttachment.label;"
+ accesskey="&removeAttachment.accesskey;"
+ command="cmd_delete"/>
+ <menu id="composeAttachmentContext_convertCloudMenu"
+ label="&convertCloud.label;"
+ accesskey="&convertCloud.accesskey;"
+ command="cmd_convertCloud">
+ <menupopup id="convertCloudMenuItems_popup"
+ onpopupshowing="addConvertCloudMenuItems(this, 'convertCloudSeparator', 'context_convertCloud');">
+ <menuitem id="convertCloudMenuItems_popup_convertAttachment"
+ type="radio" name="context_convertCloud"
+ label="&convertRegularAttachment.label;"
+ accesskey="&convertRegularAttachment.accesskey;"
+ command="cmd_convertAttachment"/>
+ <menuseparator id="convertCloudSeparator"/>
+ </menupopup>
+ </menu>
+ <menuitem id="composeAttachmentContext_cancelUploadItem"
+ label="&cancelUpload.label;"
+ accesskey="&cancelUpload.accesskey;"
+ command="cmd_cancelUpload"/>
+ <menuseparator/>
+ <menuitem id="composeAttachmentContext_selectAllItem"
+ label="&selectAll.label;"
+ accesskey="&selectAll.accesskey;"
+ command="cmd_selectAll"/>
+</menupopup>
+
+<menupopup id="msgComposeAttachmentListContext"
+ onpopupshowing="updateAttachmentItems();">
+ <menuitem id="attachmentListContext_selectAllItem"
+ label="&selectAll.label;"
+ accesskey="&selectAll.accesskey;"
+ command="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem id="attachmentListContext_attachFileItem"
+ data-l10n-id="context-menuitem-attach-files"
+ data-l10n-attrs="acceltext"
+ command="cmd_attachFile"/>
+ <menu id="attachmentListContext_attachCloudMenu"
+ label="&attachCloud.label;"
+ accesskey="&attachCloud.accesskey;"
+ command="cmd_attachCloud">
+ <menupopup id="attachCloudMenu_attachCloudPopup" onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/>
+ </menu>
+ <menuitem id="attachmentListContext_attachPageItem"
+ label="&attachPage.label;"
+ accesskey="&attachPage.accesskey;"
+ command="cmd_attachPage"/>
+ <menuseparator id="attachmentListContext_remindLaterSeparator"/>
+ <menuitem id="attachmentListContext_remindLaterItem"
+ type="checkbox"
+ label="&remindLater.label;"
+ accesskey="&remindLater.accesskey;"
+ command="cmd_remindLater"/>
+ <menuitem id="attachmentListContext_reorderItem"
+ label="&reorderAttachments.label;"
+ accesskey="&reorderAttachments.accesskey;"
+ command="cmd_reorderAttachments"/>
+ <menuseparator id="attachmentListContext_removeAllSeparator"/>
+ <menuitem id="attachmentListContext_removeAllItem"
+ label="&removeAllAttachments.label;"
+ accesskey="&removeAllAttachments.accesskey;"
+ command="cmd_removeAllAttachments"/>
+</menupopup>
+
+<menupopup id="attachmentHeaderContext"
+ onpopupshowing="attachmentHeaderContextOnPopupShowing();">
+ <menuitem id="attachmentHeaderContext_initiallyShowItem"
+ type="checkbox"
+ label="&initiallyShowAttachmentPane.label;"
+ accesskey="&initiallyShowAttachmentPane.accesskey;"
+ oncommand="toggleInitiallyShowAttachmentPane(this);"/>
+</menupopup>
+
+<menupopup id="format-toolbar-context-menu"
+ onpopupshowing="ToolbarContextMenu.updateExtension(this);">
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
+
+<menupopup id="toolbar-context-menu"
+ onpopupshowing="onViewToolbarsPopupShowing(event, 'compose-toolbox'); ToolbarContextMenu.updateExtension(this);">
+ <menuseparator/>
+ <menuitem id="CustomizeComposeToolbar"
+ command="cmd_CustomizeComposeToolbar"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"/>
+ <menuseparator id="extensionsMailToolbarMenuSeparator"/>
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
+
+<menupopup id="blockedContentOptions" value=""
+ onpopupshowing="onBlockedContentOptionsShowing(event);">
+</menupopup>
+
+<menupopup id="languageMenuList"
+ oncommand="ChangeLanguage(event);"
+ onpopupshowing="OnShowDictionaryMenu(event.target);"
+ onpopupshown="languageMenuListOpened();"
+ onpopuphidden="languageMenuListClosed();">
+</menupopup>
+
+<menupopup id="emailAddressPillPopup"
+ class="emailAddressPopup"
+ onpopupshowing="onPillPopupShowing(event);">
+ <menuitem id="editAddressPill"
+ class="pill-action-edit"
+ data-l10n-id="pill-action-edit"
+ oncommand="editAddressPill(this.parentNode.triggerNode, event)"/>
+ <menuitem id="menu_delete"
+ data-l10n-id="text-action-delete"
+ oncommand="deleteSelectedPillsOnCommand()"/>
+ <menuseparator/>
+ <menuitem id="menu_cut"
+ data-l10n-id="text-action-cut"
+ oncommand="cutSelectedPillsOnCommand()"/>
+ <menuitem id="menu_copy"
+ data-l10n-id="text-action-copy"
+ oncommand="copySelectedPillsOnCommand()"/>
+ <menuseparator id="pillContextBeforeSelectAllSeparator"/>
+ <menuitem id="menu_selectAllSiblingPills"
+ oncommand="selectAllSiblingPillsOnCommand(this.parentNode.triggerNode)"/>
+ <menuitem id="menu_selectAllPills"
+ data-l10n-id="pill-action-select-all-pills"
+ oncommand="selectAllPillsOnCommand()"/>
+ <menuseparator id="pillContextBeforeMoveItemsSeparator"/>
+ <menuitem id="moveAddressPillTo"
+ class="pill-action-move"
+ data-l10n-id="pill-action-move-to"
+ oncommand="moveSelectedPillsOnCommand('addressRowTo')"/>
+ <menuitem id="moveAddressPillCc"
+ class="pill-action-move"
+ data-l10n-id="pill-action-move-cc"
+ oncommand="moveSelectedPillsOnCommand('addressRowCc')"/>
+ <menuitem id="moveAddressPillBcc"
+ class="pill-action-move"
+ data-l10n-id="pill-action-move-bcc"
+ oncommand="moveSelectedPillsOnCommand('addressRowBcc')"/>
+ <menuseparator id="pillContextBeforeExpandListSeparator"/>
+ <menuitem id="expandList"
+ class="pill-action-edit"
+ data-l10n-id="pill-action-expand-list"
+ hidden="true"
+ oncommand="expandList(this.parentNode.triggerNode)"/>
+</menupopup>
+
+ <toolbox id="compose-toolbox"
+ class="toolbox-top"
+ mode="full"
+ defaultmode="full"
+#ifdef XP_MACOSX
+ iconsize="small"
+ defaulticonsize="small"
+#endif
+ labelalign="end"
+ defaultlabelalign="end">
+
+#ifdef XP_MACOSX
+ <hbox id="titlebar">
+ <hbox id="titlebar-title" align="center" flex="1">
+ <label id="titlebar-title-label" value="&msgComposeWindow.title;" flex="1" crop="end"/>
+ </hbox>
+#include ../../../base/content/messenger-titlebar-items.inc.xhtml
+ </hbox>
+#endif
+ <!-- Menu -->
+ <!-- if you change the id of the menubar, be sure to update mailCore.js::CustomizeMailToolbar and MailToolboxCustomizeDone -->
+ <toolbar is="customizable-toolbar" id="compose-toolbar-menubar2"
+ class="chromeclass-menubar themeable-full"
+ type="menubar"
+ customizable="true"
+#ifdef XP_MACOSX
+ defaultset="menubar-items"
+#else
+ defaultset="menubar-items,spring"
+#endif
+#ifdef XP_WIN
+ toolbarname="&menubarCmd.label;"
+ accesskey="&menubarCmd.accesskey;"
+#endif
+ context="toolbar-context-menu" mode="full">
+
+ <toolbaritem id="menubar-items" align="center">
+ <menubar id="mail-menubar">
+ <menu id="menu_File" label="&fileMenu.label;" accesskey="&fileMenu.accesskey;">
+ <menupopup id="menu_FilePopup">
+ <menu id="menu_New"
+ label="&newMenu.label;" accesskey="&newMenu.accesskey;">
+ <menupopup id="menu_NewPopup">
+ <menuitem id="menu_NewMessage"
+ label="&newMessage.label;" accesskey="&newMessage.accesskey;"
+ key="key_newMessage" oncommand="goOpenNewMessage(event);"/>
+ <menuseparator/>
+ <menuitem id="menu_NewContact" label="&newContact.label;"
+ accesskey="&newContact.accesskey;"
+ oncommand="toAddressBook({ action: 'create' });"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_Attach" label="&attachMenu.label;" accesskey="&attachMenu.accesskey;">
+ <menupopup id="menu_AttachPopup" onpopupshowing="updateAttachmentItems();">
+ <menuitem data-l10n-id="menuitem-attach-files"
+ data-l10n-attrs="acceltext"
+ command="cmd_attachFile"/>
+ <menu label="&attachCloudCmd.label;" accesskey="&attachCloudCmd.accesskey;"
+ command="cmd_attachCloud">
+ <menupopup onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/>
+ </menu>
+ <menuitem label="&attachPageCmd.label;"
+ accesskey="&attachPageCmd.accesskey;" command="cmd_attachPage"/>
+ <menuseparator/>
+ <menuitem type="checkbox"
+ data-l10n-id="context-menuitem-attach-vcard"
+ command="cmd_attachVCard"/>
+ <menuitem id="menu_AttachPopup_attachPublicKey" type="checkbox"
+ data-l10n-id="context-menuitem-attach-openpgp-key"
+ command="cmd_attachPublicKey"/>
+ <menuseparator id="menu_Attach_RemindLaterSeparator"/>
+ <menuitem id="menu_Attach_RemindLaterItem" type="checkbox" label="&remindLater.label;"
+ accesskey="&remindLater.accesskey;" command="cmd_remindLater"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menuitem id="menu_SaveCmd" label="&saveCmd.label;" accesskey="&saveCmd.accesskey;"
+ key="key_save" command="cmd_saveDefault"/>
+ <menu id="menu_SaveAsCmd"
+ label="&saveAsCmd.label;" accesskey="&saveAsCmd.accesskey;">
+ <menupopup id="menu_SaveAsCmdPopup" onpopupshowing="InitFileSaveAsMenu();">
+ <menuitem id="menu_SaveAsFileCmd"
+ label="&saveAsFileCmd.label;" accesskey="&saveAsFileCmd.accesskey;"
+ command="cmd_saveAsFile" type="radio" name="radiogroup_SaveAs"/>
+ <menuseparator/>
+ <menuitem label="&saveAsDraftCmd.label;" accesskey="&saveAsDraftCmd.accesskey;"
+ command="cmd_saveAsDraft" type="radio" name="radiogroup_SaveAs"/>
+ <menuitem label="&saveAsTemplateCmd.label;" accesskey="&saveAsTemplateCmd.accesskey;"
+ command="cmd_saveAsTemplate" type="radio" name="radiogroup_SaveAs"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menuitem label="&sendNowCmd.label;"
+ accesskey="&sendNowCmd.accesskey;" key="key_send"
+ command="cmd_sendNow" id="menu-item-send-now"/>
+ <menuitem label="&sendLaterCmd.label;"
+ accesskey="&sendLaterCmd.accesskey;" key="key_sendLater"
+ command="cmd_sendLater"/>
+ <menuseparator/>
+ <menuitem id="printMenuItem"
+ label="&printCmd.label;" accesskey="&printCmd.accesskey;"
+ key="key_print" command="cmd_print"/>
+ <menuseparator id="menu_FileCloseSeparator"/>
+ <menuitem id="menu_close"
+ label="&closeCmd.label;"
+ key="key_close"
+ accesskey="&closeCmd.accesskey;"
+ command="cmd_close"/>
+ </menupopup>
+ </menu>
+
+ <!-- Edit Menu -->
+ <menu id="menu_Edit" label="&editMenu.label;" accesskey="&editMenu.accesskey;">
+ <menupopup id="menu_EditPopup" onpopupshowing="updateEditItems();">
+ <menuitem id="menu_undo"
+ data-l10n-id="text-action-undo"
+ key="key_undo" command="cmd_undo"/>
+ <menuitem id="menu_redo"
+ data-l10n-id="text-action-redo"
+ key="key_redo" command="cmd_redo"/>
+ <menuseparator/>
+ <menuitem id="menu_cut"
+ data-l10n-id="text-action-cut"
+ key="key_cut" command="cmd_cut"/>
+ <menuitem id="menu_copy"
+ data-l10n-id="text-action-copy"
+ key="key_copy" command="cmd_copy"/>
+ <menuitem id="menu_paste"
+ data-l10n-id="text-action-paste"
+ key="key_paste" command="cmd_paste"/>
+ <menuitem id="menu_pasteNoFormatting"
+ command="cmd_pasteNoFormatting" key="pastenoformattingkb"/>
+ <menuitem id="menu_pasteQuote"
+ accesskey="&pasteAsQuotationCmd.accesskey;"
+ command="cmd_pasteQuote"
+ key="pastequotationkb"/>
+ <menuitem id="menu_delete"
+ data-l10n-id="text-action-delete"
+ key="key_delete"
+ command="cmd_delete"/>
+ <menuseparator/>
+ <menuitem id="menu_rewrap"
+ label="&editRewrapCmd.label;"
+ accesskey="&editRewrapCmd.accesskey;"
+ key="key_rewrap"
+ command="cmd_rewrap"/>
+ <menuitem id="menu_RenameAttachment"
+ label="&renameAttachmentCmd.label;"
+ accesskey="&renameAttachmentCmd.accesskey;"
+ key="key_renameAttachment"
+ command="cmd_renameAttachment"/>
+ <menuitem id="menu_reorderAttachments"
+ label="&reorderAttachmentsCmd.label;"
+ accesskey="&reorderAttachmentsCmd.accesskey;"
+ key="key_reorderAttachments"
+ command="cmd_reorderAttachments"/>
+ <menuseparator/>
+ <menuitem id="menu_selectAll"
+ data-l10n-id="text-action-select-all"
+ key="key_selectAll"
+ command="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem id="menu_findBar"
+ label="&findBarCmd.label;"
+ accesskey="&findBarCmd.accesskey;"
+ key="key_find"
+ command="cmd_find"/>
+#ifndef XP_MACOSX
+ <menuitem id="menu_findReplace"
+ label="&findReplaceCmd.label;"
+ accesskey="&findReplaceCmd.accesskey;"
+ key="key_findReplace"
+ command="cmd_findReplace"/>
+#else
+ <menuitem id="menu_findReplace"
+ label="&findReplaceCmd.label;"
+ accesskey="&findReplaceCmd.accesskey;"
+ command="cmd_findReplace"/>
+#endif
+ <menuitem id="menu_findNext"
+ label="&findAgainCmd.label;"
+ accesskey="&findAgainCmd.accesskey;"
+ key="key_findNext"
+ command="cmd_findNext"/>
+ <menuitem id="menu_findPrev"
+ label="&findPrevCmd.label;"
+ accesskey="&findPrevCmd.accesskey;"
+ key="key_findPrev"
+ command="cmd_findPrev"/>
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmdUnix2.accesskey;"
+ command="cmd_account"/>
+ <menuitem id="menu_preferences"
+ data-l10n-id="menu-tools-settings"
+ oncommand="openOptionsDialog('paneCompose');"/>
+#endif
+#endif
+ </menupopup>
+ </menu>
+
+ <!-- View Menu -->
+ <menu id="menu_View" label="&viewMenu.label;" accesskey="&viewMenu.accesskey;">
+ <menupopup id="menu_View_Popup" onpopupshowing="updateViewItems();">
+ <menu id="menu_ToolbarsNew"
+ label="&viewToolbarsMenuNew.label;"
+ accesskey="&viewToolbarsMenuNew.accesskey;"
+ onpopupshowing="onViewToolbarsPopupShowing(event, 'compose-toolbox');">
+ <menupopup id="view_toolbars_popup">
+ <menuitem id="menu_showFormatToolbar"
+ type="checkbox"
+ label="&showFormattingBarCmd.label;"
+ accesskey="&showFormattingBarCmd.accesskey;"
+ command="cmd_showFormatToolbar"
+ checked="true"/>
+ <menuitem id="menu_showTaskbar"
+ type="checkbox"
+ label="&showTaskbarCmd.label;"
+ accesskey="&showTaskbarCmd.accesskey;"
+ oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')"
+ checked="true"/>
+ <menuseparator id="viewMenuBeforeCustomizeComposeToolbarsSeparator"/>
+ <menuitem id="customizeComposeToolbars"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"
+ command="cmd_CustomizeComposeToolbar"/>
+ </menupopup>
+ </menu>
+ <menu id="viewFullZoomMenu" label="&fullZoom.label;" accesskey="&fullZoom.accesskey;"
+ onpopupshowing="UpdateFullZoomMenu()">
+ <menupopup id="viewFullZoomPopupMenu">
+ <menuitem id="menu_fullZoomEnlarge" key="key_fullZoomEnlarge"
+ label="&fullZoomEnlargeCmd.label;"
+ accesskey="&fullZoomEnlargeCmd.accesskey;"
+ command="cmd_fullZoomEnlarge"/>
+ <menuitem id="menu_fullZoomReduce" key="key_fullZoomReduce"
+ label="&fullZoomReduceCmd.label;"
+ accesskey="&fullZoomReduceCmd.accesskey;"
+ command="cmd_fullZoomReduce"/>
+ <menuseparator id="fullZoomAfterReduceSeparator"/>
+ <menuitem id="menu_fullZoomReset" key="key_fullZoomReset"
+ label="&fullZoomResetCmd.label;"
+ accesskey="&fullZoomResetCmd.accesskey;"
+ command="cmd_fullZoomReset"/>
+ <menuseparator id="fullZoomAfterResetSeparator"/>
+ <menuitem id="menu_fullZoomToggle" label="&fullZoomToggleCmd.label;"
+ accesskey="&fullZoomToggleCmd.accesskey;"
+ type="checkbox" command="cmd_fullZoomToggle" checked="false"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="viewMenuBeforeShowToFieldSeparator"/>
+ <menuitem id="menu_showToField"
+ data-l10n-attrs="acceltext"
+ oncommand="showAndFocusAddressRow('addressRowTo')"/>
+ <menuitem id="menu_showCcField"
+ data-l10n-attrs="acceltext"
+ oncommand="showAndFocusAddressRow('addressRowCc')"/>
+ <menuitem id="menu_showBccField"
+ data-l10n-attrs="acceltext"
+ oncommand="showAndFocusAddressRow('addressRowBcc')"/>
+ <menuseparator id="viewMenuBeforeAddressSidebarSeparator"/>
+ <menuitem id="menu_AddressSidebar"
+ label="&addressSidebar.label;" accesskey="&addressSidebar.accesskey;"
+ type="checkbox"
+ key="key_addressSidebar"
+ oncommand="toggleContactsSidebar();"/>
+ <menuitem id="menu_toggleAttachmentPane"
+ data-l10n-id="menuitem-toggle-attachment-pane"
+ data-l10n-attrs="acceltext"
+ type="checkbox"
+ command="cmd_toggleAttachmentPane"/>
+ </menupopup>
+ </menu>
+
+ <menu id="insertMenu" label="&insertMenu.label;"
+ accesskey="&insertMenu.accesskey;" command="cmd_renderedHTMLEnabler">
+ <menupopup id="insertMenuPopup">
+ <menuitem id="insertImage"
+ label="&insertImageCmd.label;"
+ accesskey="&insertImageCmd.accesskey;"
+ observes="cmd_image"/>
+ <menuitem id="insertTable"
+ label="&insertTableCmd.label;"
+ accesskey="&insertTableCmd.accesskey;"
+ observes="cmd_InsertTable"/>
+ <menuitem id="insertLink"
+ label="&insertLinkCmd2.label;"
+ accesskey="&insertLinkCmd2.accesskey;"
+ key="insertlinkkb"
+ observes="cmd_link"/>
+ <menuitem id="insertAnchor"
+ label="&insertAnchorCmd.label;"
+ accesskey="&insertAnchorCmd.accesskey;"
+ observes="cmd_anchor"/>
+ <menuitem id="insertHline"
+ label="&insertHLineCmd.label;"
+ accesskey="&insertHLineCmd.accesskey;"
+ observes="cmd_hline"/>
+ <menuitem id="insertHTMLSource"
+ accesskey="&insertHTMLCmd.accesskey;"
+ observes="cmd_insertHTMLWithDialog"/>
+ <menuitem id="insertMath"
+ accesskey="&insertMathCmd.accesskey;"
+ observes="cmd_insertMathWithDialog"/>
+ <menuitem id="insertChars"
+ accesskey="&insertCharsCmd.accesskey;"
+ command="cmd_insertChars"/>
+
+ <menu id="insertTOC" label="&tocMenu.label;" accesskey="&tocMenu.accesskey;">
+ <menupopup id="insertTOCPopup" onpopupshowing="InitTOCMenu()">
+ <menuitem id="insertTOCMenuitem"
+ label="&insertTOC.label;"
+ accesskey="&insertTOC.accesskey;"
+ oncommand="UpdateTOC()"/>
+ <menuitem id="updateTOCMenuitem"
+ label="&updateTOC.label;"
+ accesskey="&updateTOC.accesskey;"
+ oncommand="UpdateTOC()"/>
+ <menuitem id="removeTOCMenuitem"
+ label="&removeTOC.label;"
+ accesskey="&removeTOC.accesskey;"
+ oncommand="RemoveTOC()"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="insertMenuSeparator"/>
+ <menuitem id="insertBreakAll"
+ accesskey="&insertBreakAllCmd.accesskey;"
+ observes="cmd_insertBreakAll"
+ label="&insertBreakAllCmd.label;"/>
+ </menupopup>
+ </menu>
+
+ <menu id="formatMenu" label="&formatMenu.label;" accesskey="&formatMenu.accesskey;" command="cmd_renderedHTMLEnabler">
+ <menupopup id="formatMenuPopup" onpopupshowing="EditorInitFormatMenu()">
+ <!-- Font face submenu -->
+ <menu id="fontFaceMenu"
+ label="&fontfaceMenu.label;" accesskey="&fontfaceMenu.accesskey;"
+ position="1">
+ <menupopup id="fontFaceMenuPopup"
+ oncommand="if (event.target.localName == 'menuitem') {
+ doStatefulCommand('cmd_fontFace', event.target.getAttribute('value'));
+ }"
+ onpopupshowing="initFontFaceMenu(this);">
+ <menuitem id="menu_fontFaceVarWidth"
+ label="&fontVarWidth.label;"
+ accesskey="&fontVarWidth.accesskey;"
+ value=""
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_fontFaceFixedWidth"
+ label="&fontFixedWidth.label;"
+ accesskey="&fontFixedWidth.accesskey;"
+ value="monospace"
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuseparator id="fontFaceMenuAfterGenericFontsSeparator"/>
+ <menuitem id="menu_fontFaceHelvetica"
+ label="&fontHelvetica.label;"
+ accesskey="&fontHelvetica.accesskey;"
+ value="Helvetica, Arial, sans-serif"
+ value_parsed="helvetica,arial,sans-serif"
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_fontFaceTimes"
+ label="&fontTimes.label;"
+ accesskey="&fontTimes.accesskey;"
+ value="Times New Roman, Times, serif"
+ value_parsed="times new roman,times,serif"
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_fontFaceCourier"
+ label="&fontCourier.label;"
+ accesskey="&fontCourier.accesskey;"
+ value="Courier New, Courier, monospace"
+ value_parsed="courier new,courier,monospace"
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuseparator id="fontFaceMenuAfterDefaultFontsSeparator"
+ class="fontFaceMenuAfterDefaultFonts"/>
+ <menuseparator id="fontFaceMenuAfterUsedFontsSeparator"
+ class="fontFaceMenuAfterUsedFonts"
+ hidden="true"/>
+ <!-- Local font face items added here by initLocalFontFaceMenu() -->
+ </menupopup>
+ </menu>
+
+ <!-- Font size submenu -->
+ <menu id="fontSizeMenu" label="&fontSizeMenu.label;"
+ accesskey="&fontSizeMenu.accesskey;"
+ position="2">
+ <menupopup id="fontSizeMenuPopup"
+ onpopupshowing="initFontSizeMenu(this)"
+ oncommand="setFontSize(event)">
+ <menuitem id="menu_decreaseFontSize"
+ label="&decreaseFontSize.label;"
+ accesskey="&decreaseFontSize.accesskey;"
+ key="decreasefontsizekb"
+ observes="cmd_decreaseFontStep"
+ type="radio" name="decreaseFontSize" autocheck="false"/>
+ <menuitem id="menu_increaseFontSize"
+ label="&increaseFontSize.label;"
+ accesskey="&increaseFontSize.accesskey;"
+ key="increasefontsizekb"
+ observes="cmd_increaseFontStep"
+ type="radio" name="increaseFontSize" autocheck="false"/>
+ <menuseparator id="fontSizeMenuAfterIncreaseFontSizeSeparator"/>
+ <menuitem id="menu_size-x-small"
+ label="&size-tinyCmd.label;"
+ accesskey="&size-tinyCmd.accesskey;"
+ value="1"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-small"
+ label="&size-smallCmd.label;"
+ accesskey="&size-smallCmd.accesskey;"
+ value="2"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-medium"
+ label="&size-mediumCmd.label;"
+ accesskey="&size-mediumCmd.accesskey;"
+ value="3"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-large"
+ label="&size-largeCmd.label;"
+ accesskey="&size-largeCmd.accesskey;"
+ value="4"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-x-large"
+ label="&size-extraLargeCmd.label;"
+ accesskey="&size-extraLargeCmd.accesskey;"
+ value="5"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-xx-large"
+ label="&size-hugeCmd.label;"
+ accesskey="&size-hugeCmd.accesskey;"
+ value="6"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ </menupopup>
+ </menu>
+
+ <!-- Font style submenu -->
+ <menu id="fontStyleMenu" label="&fontStyleMenu.label;"
+ accesskey="&fontStyleMenu.accesskey;"
+ position="3">
+ <menupopup id="fontStyleMenuPopup" onpopupshowing="initFontStyleMenu(this)">
+ <menuitem id="menu_styleBold"
+ label="&styleBoldCmd.label;"
+ accesskey="&styleBoldCmd.accesskey;"
+ observes="cmd_bold"
+ type="checkbox"
+ key="boldkb"/>
+ <menuitem id="menu_styleItalic"
+ label="&styleItalicCmd.label;"
+ accesskey="&styleItalicCmd.accesskey;"
+ observes="cmd_italic"
+ type="checkbox"
+ key="italickb"/>
+ <menuitem id="menu_styleUnderline"
+ label="&styleUnderlineCmd.label;"
+ accesskey="&styleUnderlineCmd.accesskey;"
+ observes="cmd_underline"
+ type="checkbox"
+ key="underlinekb"/>
+ <menuitem id="menu_styleStrikeThru"
+ label="&styleStrikeThruCmd.label;"
+ accesskey="&styleStrikeThruCmd.accesskey;"
+ observes="cmd_strikethrough"
+ type="checkbox"/>
+ <menuitem id="menu_styleSuperscript"
+ label="&styleSuperscriptCmd.label;"
+ accesskey="&styleSuperscriptCmd.accesskey;"
+ observes="cmd_superscript"
+ type="checkbox"/>
+ <menuitem id="menu_styleSubscript"
+ label="&styleSubscriptCmd.label;"
+ accesskey="&styleSubscriptCmd.accesskey;"
+ observes="cmd_subscript"
+ type="checkbox"/>
+ <menuitem id="menu_fontFixedWidth"
+ label="&fontFixedWidth.label;"
+ accesskey="&fontFixedWidth.accesskey;"
+ observes="cmd_tt"
+ type="checkbox"
+ key="fixedwidthkb"/>
+ <menuitem id="menu_styleNonbreaking"
+ label="&styleNonbreakingCmd.label;"
+ accesskey="&styleNonbreakingCmd.accesskey;"
+ observes="cmd_nobreak"
+ type="checkbox"/>
+ <menuseparator id="fontStyleMenuAfterNonbreakingSeparator"/>
+ <menuitem id="menu_styleEm"
+ label="&styleEm.label;"
+ accesskey="&styleEm.accesskey;"
+ observes="cmd_em"
+ type="checkbox"/>
+ <menuitem id="menu_styleStrong"
+ label="&styleStrong.label;"
+ accesskey="&styleStrong.accesskey;"
+ observes="cmd_strong"
+ type="checkbox"/>
+ <menuitem id="menu_styleCite"
+ label="&styleCite.label;"
+ accesskey="&styleCite.accesskey;"
+ observes="cmd_cite"
+ type="checkbox"/>
+ <menuitem id="menu_styleAbbr"
+ label="&styleAbbr.label;"
+ accesskey="&styleAbbr.accesskey;"
+ observes="cmd_abbr"
+ type="checkbox"/>
+ <menuitem id="menu_styleAcronym"
+ label="&styleAcronym.label;"
+ accesskey="&styleAcronym.accesskey;"
+ observes="cmd_acronym"
+ type="checkbox"/>
+ <menuitem id="menu_styleCode"
+ label="&styleCode.label;"
+ accesskey="&styleCode.accesskey;"
+ observes="cmd_code"
+ type="checkbox"/>
+ <menuitem id="menu_styleSamp"
+ label="&styleSamp.label;"
+ accesskey="&styleSamp.accesskey;"
+ observes="cmd_samp"
+ type="checkbox"/>
+ <menuitem id="menu_styleVar"
+ label="&styleVar.label;"
+ accesskey="&styleVar.accesskey;"
+ observes="cmd_var"
+ type="checkbox"/>
+ </menupopup>
+ </menu>
+
+ <!-- Note: "cmd_fontColor" only monitors color state, it doesn't execute the command
+ (We should use "cmd_fontColorState" and "cmd_backgroundColorState" ?) -->
+ <menuitem id="fontColor" label="&formatFontColor.label;"
+ accesskey="&formatFontColor.accesskey;"
+ observes="cmd_fontColor"
+ oncommand="EditorSelectColor('Text', null);"
+ position="4"/>
+ <menuseparator id="removeSep" position="5"/>
+
+ <!-- label and accesskey set at runtime from strings -->
+ <menuitem id="removeStylesMenuitem" key="removestyleskb"
+ observes="cmd_removeStyles"
+ position="6"/>
+ <menuitem id="removeLinksMenuitem" key="removelinkskb"
+ observes="cmd_removeLinks"
+ position="7"/>
+ <menuitem id="removeNamedAnchorsMenuitem" label="&formatRemoveNamedAnchors.label;"
+ key="removenamedanchorskb"
+ accesskey="&formatRemoveNamedAnchors.accesskey;"
+ observes="cmd_removeNamedAnchors"
+ position="8"/>
+ <menuseparator id="tabSep" position="9"/>
+
+ <!-- Note: the 'Init' menu methods for Paragraph, List, and Align
+ assume that the id = 'menu_'+tagName (the 'value' label),
+ except for the first ('none') item
+ -->
+ <!-- Paragraph Style submenu -->
+ <menu id="paragraphMenu" label="&paragraphMenu.label;"
+ accesskey="&paragraphMenu.accesskey;"
+ position="10" onpopupshowing="InitParagraphMenu()">
+ <menupopup id="paragraphMenuPopup"
+ oncommand="setParagraphState(event);">
+ <menuitem id="menu_bodyText"
+ type="radio"
+ name="1"
+ label="&bodyTextCmd.label;"
+ accesskey="&bodyTextCmd.accesskey;"
+ value=""
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_p"
+ type="radio"
+ name="1"
+ label="&paragraphParagraphCmd.label;"
+ accesskey="&paragraphParagraphCmd.accesskey;"
+ value="p"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h1"
+ type="radio"
+ name="1"
+ label="&heading1Cmd.label;"
+ accesskey="&heading1Cmd.accesskey;"
+ value="h1"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h2"
+ type="radio"
+ name="1"
+ label="&heading2Cmd.label;"
+ accesskey="&heading2Cmd.accesskey;"
+ value="h2"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h3"
+ type="radio"
+ name="1"
+ label="&heading3Cmd.label;"
+ accesskey="&heading3Cmd.accesskey;"
+ value="h3"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h4"
+ type="radio"
+ name="1"
+ label="&heading4Cmd.label;"
+ accesskey="&heading4Cmd.accesskey;"
+ value="h4"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h5"
+ type="radio"
+ name="1"
+ label="&heading5Cmd.label;"
+ accesskey="&heading5Cmd.accesskey;"
+ value="h5"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h6"
+ type="radio"
+ name="1"
+ label="&heading6Cmd.label;"
+ accesskey="&heading6Cmd.accesskey;"
+ value="h6"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_address"
+ type="radio"
+ name="1"
+ label="&paragraphAddressCmd.label;"
+ accesskey="&paragraphAddressCmd.accesskey;"
+ value="address"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_pre"
+ type="radio"
+ name="1"
+ label="&paragraphPreformatCmd.label;"
+ accesskey="&paragraphPreformatCmd.accesskey;"
+ value="pre"
+ observes="cmd_renderedHTMLEnabler"/>
+ </menupopup>
+ </menu>
+
+ <!-- List Style submenu -->
+ <menu id="listMenu" label="&formatlistMenu.label;"
+ accesskey="&formatlistMenu.accesskey;"
+ position="11" onpopupshowing="InitListMenu()">
+ <menupopup id="listMenuPopup">
+ <menuitem id="menu_noList"
+ type="radio"
+ name="1"
+ label="&noneCmd.label;"
+ accesskey="&noneCmd.accesskey;"
+ observes="cmd_removeList"/>
+ <menuitem id="menu_ul"
+ type="radio"
+ name="1"
+ label="&listBulletCmd.label;"
+ accesskey="&listBulletCmd.accesskey;"
+ observes="cmd_ul"/>
+ <menuitem id="menu_ol"
+ type="radio"
+ name="1"
+ label="&listNumberedCmd.label;"
+ accesskey="&listNumberedCmd.accesskey;"
+ observes="cmd_ol"/>
+ <menuitem id="menu_dt"
+ type="radio"
+ name="1"
+ label="&listTermCmd.label;"
+ accesskey="&listTermCmd.accesskey;"
+ observes="cmd_dt"/>
+ <menuitem id="menu_dd"
+ type="radio"
+ name="1"
+ label="&listDefinitionCmd.label;"
+ accesskey="&listDefinitionCmd.accesskey;"
+ observes="cmd_dd"/>
+ <menuseparator/>
+ <menuitem id="listProps"
+ label="&listPropsCmd.label;"
+ accesskey="&listPropsCmd.accesskey;"
+ observes="cmd_listProperties"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="identingSep" position="12"/>
+
+ <menuitem id="increaseIndent"
+ label="&increaseIndent.label;"
+ accesskey="&increaseIndent.accesskey;"
+ key="increaseindentkb"
+ observes="cmd_indent"
+ position="13"/>
+ <menuitem id="decreaseIndent"
+ label="&decreaseIndent.label;"
+ accesskey="&decreaseIndent.accesskey;"
+ key="decreaseindentkb"
+ observes="cmd_outdent"
+ position="14"/>
+
+ <menu id="alignMenu" label="&alignMenu.label;" accesskey="&alignMenu.accesskey;"
+ onpopupshowing="InitAlignMenu()"
+ position="15">
+ <!-- Align submenu -->
+ <menupopup id="alignMenuPopup"
+ oncommand="doStatefulCommand('cmd_align', event.target.getAttribute('value'))">
+ <menuitem id="menu_left"
+ label="&alignLeft.label;"
+ accesskey="&alignLeft.accesskey;"
+ type="radio"
+ name="1"
+ value="left"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_center"
+ label="&alignCenter.label;"
+ accesskey="&alignCenter.accesskey;"
+ type="radio"
+ name="1"
+ value="center"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_right"
+ label="&alignRight.label;"
+ accesskey="&alignRight.accesskey;"
+ type="radio"
+ name="1"
+ value="right"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_justify"
+ label="&alignJustify.label;"
+ accesskey="&alignJustify.accesskey;"
+ type="radio"
+ name="1"
+ value="justify"
+ observes="cmd_renderedHTMLEnabler"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="tableSep" position="16"/>
+ <menu id="tableMenu" label="&tableMenu.label;" accesskey="&tableMenu.accesskey;">
+ <menupopup id="tableMenuPopup" onpopupshowing="EditorInitTableMenu()">
+ <menu id="tableInsertMenu" label="&tableInsertMenu.label;" accesskey="&tableInsertMenu.accesskey;">
+ <menupopup id="tableMenuPopup">
+ <menuitem id="menu_insertTable"
+ label="&insertTableCmd.label;"
+ accesskey="&insertTableCmd.accesskey;"
+ observes="cmd_InsertTable"/>
+ <menuseparator id="tableMenuAfterInsertTableSeparator"/>
+ <menuitem id="menu_tableRowAbove"
+ label="&tableRowAbove.label;"
+ accesskey="&tableRowAbove.accesskey;"
+ observes="cmd_InsertRowAbove"/>
+ <menuitem id="menu_tableRowBelow"
+ label="&tableRowBelow.label;"
+ accesskey="&tableRowBelow.accesskey;"
+ observes="cmd_InsertRowBelow"/>
+ <menuseparator id="tableMenuAfterTableRowSeparator"/>
+ <menuitem id="menu_tableColumnBefore"
+ label="&tableColumnBefore.label;"
+ accesskey="&tableColumnBefore.accesskey;"
+ observes="cmd_InsertColumnBefore"/>
+ <menuitem id="menu_tableColumnAfter"
+ label="&tableColumnAfter.label;"
+ accesskey="&tableColumnAfter.accesskey;"
+ observes="cmd_InsertColumnAfter"/>
+ <menuseparator id="tableMenuAfterInsertColumnSeparator"/>
+ <menuitem id="menu_tableCellBefore"
+ label="&tableCellBefore.label;"
+ accesskey="&tableCellBefore.accesskey;"
+ observes="cmd_InsertCellBefore"/>
+ <menuitem id="menu_tableCellAfter"
+ label="&tableCellAfter.label;"
+ accesskey="&tableCellAfter.accesskey;"
+ observes="cmd_InsertCellAfter"/>
+ </menupopup>
+ </menu>
+ <menu id="tableSelectMenu"
+ label="&tableSelectMenu.label;"
+ accesskey="&tableSelectMenu.accesskey;" >
+ <menupopup id="tableSelectPopup">
+ <menuitem id="menu_SelectTable"
+ label="&tableTable.label;"
+ accesskey="&tableTable.accesskey;"
+ observes="cmd_SelectTable"/>
+ <menuitem id="menu_SelectRow"
+ label="&tableRow.label;"
+ accesskey="&tableRow.accesskey;"
+ observes="cmd_SelectRow"/>
+ <menuitem id="menu_SelectColumn"
+ label="&tableColumn.label;"
+ accesskey="&tableColumn.accesskey;"
+ observes="cmd_SelectColumn"/>
+ <menuitem id="menu_SelectCell"
+ label="&tableCell.label;"
+ accesskey="&tableCell.accesskey;"
+ observes="cmd_SelectCell"/>
+ <menuitem id="menu_SelectAllCells"
+ label="&tableAllCells.label;"
+ accesskey="&tableAllCells.accesskey;"
+ observes="cmd_SelectAllCells"/>
+ </menupopup>
+ </menu>
+ <menu id="tableDeleteMenu"
+ label="&tableDeleteMenu.label;"
+ accesskey="&tableDeleteMenu.accesskey;">
+ <menupopup id="tableDeletePopup">
+ <menuitem id="menu_DeleteTable"
+ label="&tableTable.label;"
+ accesskey="&tableTable.accesskey;"
+ observes="cmd_DeleteTable"/>
+ <menuitem id="menu_DeleteRow"
+ label="&tableRows.label;"
+ accesskey="&tableRow.accesskey;"
+ observes="cmd_DeleteRow"/>
+ <menuitem id="menu_DeleteColumn"
+ label="&tableColumns.label;"
+ accesskey="&tableColumn.accesskey;"
+ observes="cmd_DeleteColumn"/>
+ <menuitem id="menu_DeleteCell"
+ label="&tableCells.label;"
+ accesskey="&tableCell.accesskey;"
+ observes="cmd_DeleteCell"/>
+ <menuitem id="menu_DeleteCellContents"
+ label="&tableCellContents.label;"
+ accesskey="&tableCellContents.accesskey;"
+ observes="cmd_DeleteCellContents"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <!-- menu label is set in InitTableMenu -->
+ <menuitem id="menu_JoinTableCells"
+ label="&tableJoinCells.label;"
+ accesskey="&tableJoinCells.accesskey;"
+ observes="cmd_JoinTableCells"/>
+ <menuitem id="menu_SlitTableCell"
+ label="&tableSplitCell.label;"
+ accesskey="&tableSplitCell.accesskey;"
+ observes="cmd_SplitTableCell"/>
+ <menuitem id="menu_ConvertToTable"
+ label="&convertToTable.label;"
+ accesskey="&convertToTable.accesskey;"
+ observes="cmd_ConvertToTable"/>
+ <menuseparator/>
+ <menuitem id="menu_TableOrCellColor"
+ label="&tableOrCellColor.label;"
+ accesskey="&tableOrCellColor.accesskey;"
+ observes="cmd_TableOrCellColor"/>
+ <menuitem id="menu_tableProperties"
+ label="&tableProperties.label;"
+ accesskey="&tableProperties.accesskey;"
+ observes="cmd_editTable"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <!-- label and accesskey filled in during menu creation -->
+ <menuitem id="objectProperties"
+ command="cmd_objectProperties"/>
+ <!-- Don't use 'observes', must call command correctly -->
+ <menuitem id="colorsAndBackground"
+ label="&colorsAndBackground.label;"
+ accesskey="&colorsAndBackground.accesskey;"
+ oncommand="goDoCommand('cmd_colorProperties')"
+ observes="cmd_renderedHTMLEnabler"/>
+ </menupopup>
+ </menu>
+
+ <menu id="optionsMenu" label="&optionsMenu.label;" accesskey="&optionsMenu.accesskey;">
+ <menupopup id="optionsMenuPopup" onpopupshowing="updateOptionsMenu();">
+ <menuitem id="menu_checkspelling"
+ label="&checkSpellingCmd2.label;"
+ accesskey="&checkSpellingCmd2.accesskey;"
+ key="key_checkspelling"
+ command="cmd_spelling"/>
+ <menuitem id="menu_inlineSpellCheck"
+ label="&enableInlineSpellChecker.label;"
+ accesskey="&enableInlineSpellChecker.accesskey;"
+ type="checkbox"
+ oncommand="toggleSpellCheckingEnabled();"/>
+ <menuitem id="menu_quoteMessage"
+ label="&quoteCmd.label;"
+ accesskey="&quoteCmd.accesskey;"
+ command="cmd_quoteMessage"/>
+ <menuseparator/>
+ <menuitem id="returnReceiptMenu" type="checkbox"
+ label="&returnReceiptMenu.label;"
+ accesskey="&returnReceiptMenu.accesskey;"
+ checked="false"
+ command="cmd_toggleReturnReceipt"/>
+ <menuitem id="dsnMenu" type="checkbox" label="&dsnMenu.label;" accesskey="&dsnMenu.accesskey;" oncommand="ToggleDSN(event.target)"/>
+ <menuseparator/>
+ <menu id="outputFormatMenu" data-l10n-id="compose-send-format-menu">
+ <menupopup id="outputFormatMenuPopup">
+ <menuitem type="radio" name="output_format" id="format_auto" data-l10n-id="compose-send-auto-menu-item"/>
+ <menuitem type="radio" name="output_format" id="format_both" data-l10n-id="compose-send-both-menu-item"/>
+ <menuitem type="radio" name="output_format" id="format_html" data-l10n-id="compose-send-html-menu-item"/>
+ <menuitem type="radio" name="output_format" id="format_plain" data-l10n-id="compose-send-plain-menu-item"/>
+ </menupopup>
+ </menu>
+ <menu id="priorityMenu" label="&priorityMenu.label;" accesskey="&priorityMenu.accesskey;" onpopupshowing="updatePriorityMenu();" oncommand="PriorityMenuSelect(event.target);">
+ <menupopup id="priorityMenuPopup">
+ <menuitem type="radio" name="priority" label="&highestPriorityCmd.label;" accesskey="&highestPriorityCmd.accesskey;" value="Highest" id="priority_highest"/>
+ <menuitem type="radio" name="priority" label="&highPriorityCmd.label;" accesskey="&highPriorityCmd.accesskey;" value="High" id="priority_high"/>
+ <menuitem type="radio" name="priority" label="&normalPriorityCmd.label;" accesskey="&normalPriorityCmd.accesskey;" value="" id="priority_normal" checked="true"/>
+ <menuitem type="radio" name="priority" label="&lowPriorityCmd.label;" accesskey="&lowPriorityCmd.accesskey;" value="Low" id="priority_low"/>
+ <menuitem type="radio" name="priority" label="&lowestPriorityCmd.label;" accesskey="&lowestPriorityCmd.accesskey;" value="Lowest" id="priority_lowest"/>
+ </menupopup>
+ </menu>
+ <menu id="fccMenu" label="&fileCarbonCopyCmd.label;"
+ accesskey="&fileCarbonCopyCmd.accesskey;"
+ oncommand="MessageFcc(event.target._folder)">
+ <menupopup is="folder-menupopup" id="fccMenuPopup" mode="filing"
+ showFileHereLabel="true" fileHereLabel="&fileHereMenu.label;"/>
+ </menu>
+ <menuseparator/>
+ <menuitem type="checkbox" command="cmd_customizeFromAddress"
+ accesskey="&customizeFromAddress.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menu id="encryptionMenu" data-l10n-id="encryption-menu">
+ <menupopup onpopupshowing="setSecuritySettings('_Menubar');">
+
+ <menuitem id="encTech_OpenPGP_Menubar"
+ label="&menu_techPGP.label;" accesskey="&menu_techPGP.accesskey;"
+ value="OpenPGP" type="radio" name="radiogroup_encTech"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+ <menuitem id="encTech_SMIME_Menubar"
+ label="&menu_techSMIME.label;" accesskey="&menu_techSMIME.accesskey;"
+ value="SMIME" type="radio" name="radiogroup_encTech"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+
+ <menuseparator id="encryptionOptionsSeparator_Menubar"/>
+
+ <menuitem id="menu_securityEncrypt_Menubar"
+ type="checkbox"
+ data-l10n-id="menu-encrypt"
+ value="enc"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+ <menuitem id="menu_securityEncryptSubject_Menubar"
+ type="checkbox"
+ data-l10n-id="menu-encrypt-subject"
+ value="encsub"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+ <menuitem id="menu_securitySign_Menubar"
+ type="checkbox"
+ data-l10n-id="menu-sign"
+ value="sig"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+
+ <menuseparator id="statusInfoSeparator"/>
+
+ <menuitem id="menu_recipientStatus_Menubar"
+ data-l10n-id="menu-manage-keys"
+ value="status"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+ <menuitem id="menu_openManager_Menubar"
+ data-l10n-id="menu-open-key-manager"
+ value="manager"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+
+ </menupopup>
+ </menu>
+
+ <menu id="tasksMenu" label="&tasksMenu.label;" accesskey="&tasksMenu.accesskey;">
+ <menupopup id="taskPopup">
+ <menuitem id="tasksMenuMail" accesskey="&messengerCmd.accesskey;"
+ label="&messengerCmd.label;" key="key_mail"
+ oncommand="toMessengerWindow();"/>
+ <menuitem id="tasksMenuAddressBook"
+ label="&addressBookCmd.label;"
+ accesskey="&addressBookCmd.accesskey;"
+ oncommand="toAddressBook();"/>
+#ifndef XP_MACOSX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmd2.accesskey;"
+ command="cmd_account"/>
+ <menuitem id="menu_preferences"
+ data-l10n-id="menu-tools-settings"
+ oncommand="openOptionsDialog('paneCompose');"/>
+#endif
+ </menupopup>
+ </menu>
+
+#ifdef XP_MACOSX
+#include ../../../base/content/macWindowMenu.inc.xhtml
+#endif
+
+ <!-- Help -->
+#include ../../../base/content/helpMenu.inc.xhtml
+ </menubar>
+ </toolbaritem>
+ </toolbar>
+
+ <toolbarpalette id="MsgComposeToolbarPalette">
+
+ <toolbarbutton class="toolbarbutton-1"
+ id="button-send" label="&sendButton.label;"
+ tooltiptext="&sendButton.tooltip;"
+ command="cmd_sendButton"
+ now_label="&sendButton.label;"
+ now_tooltiptext="&sendButton.tooltip;"
+ later_label="&sendLaterCmd.label;"
+ later_tooltiptext="&sendlaterButton.tooltip;">
+ </toolbarbutton>
+
+ <toolbarbutton class="toolbarbutton-1"
+ id="button-contacts" label="&addressButton.label;"
+ tooltiptext="&addressButton.tooltip;"
+ autoCheck="false" type="checkbox"
+ oncommand="toggleContactsSidebar();"/>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-attach"
+ data-l10n-id="toolbar-button-add-attachment"
+ type="menu"
+ class="toolbarbutton-1"
+ command="cmd_attachFile">
+ <menupopup id="button-attachPopup" onpopupshowing="updateAttachmentItems();">
+ <menuitem id="button-attachPopup_attachFileItem"
+ data-l10n-id="menuitem-attach-files"
+ data-l10n-attrs="acceltext"
+ command="cmd_attachFile"/>
+ <menu id="button-attachPopup_attachCloudMenu"
+ label="&attachCloudCmd.label;"
+ accesskey="&attachCloudCmd.accesskey;"
+ command="cmd_attachCloud">
+ <menupopup id="attachCloudMenu_popup" onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/>
+ </menu>
+ <menuitem id="button-attachPopup_attachPageItem"
+ label="&attachPageCmd.label;"
+ accesskey="&attachPageCmd.accesskey;"
+ command="cmd_attachPage"/>
+ <menuseparator/>
+ <menuitem id="button-attachPopup_attachVCardItem"
+ type="checkbox"
+ data-l10n-id="context-menuitem-attach-vcard"
+ command="cmd_attachVCard"/>
+ <menuitem id="button-attachPopup_attachPublicKey"
+ type="checkbox"
+ data-l10n-id="context-menuitem-attach-openpgp-key"
+ command="cmd_attachPublicKey"/>
+ <menuseparator id="button-attachPopup_remindLaterSeparator"/>
+ <menuitem id="button-attachPopup_remindLaterItem"
+ type="checkbox"
+ label="&remindLater.label;"
+ accesskey="&remindLater.accesskey;"
+ command="cmd_remindLater"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton id="button-encryption"
+ type="checkbox" autoCheck="false"
+ class="toolbarbutton-1"
+ data-l10n-id="encryption-toggle"
+ oncommand="toggleEncryptMessage();"/>
+
+ <toolbarbutton id="button-signing"
+ type="checkbox" autoCheck="false"
+ class="toolbarbutton-1"
+ data-l10n-id="signing-toggle"
+ oncommand="toggleGlobalSignMessage();"/>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-encryption-options"
+ type="menu"
+ class="toolbarbutton-1"
+ data-l10n-id="encryption-options-openpgp"
+ oncommand="showPopupById('encryptionToolbarMenu', 'button-encryption-options');">
+ <menupopup id="encryptionToolbarMenu"
+ onpopupshowing="setSecuritySettings('_Toolbar');"
+ oncommand="onEncryptionChoice(event.target.value);">
+
+ <menuitem id="encTech_OpenPGP_Toolbar"
+ label="&menu_techPGP.label;" accesskey="&menu_techPGP.accesskey;"
+ value="OpenPGP" type="radio" name="radiogroup_encTech"/>
+ <menuitem id="encTech_SMIME_Toolbar"
+ label="&menu_techSMIME.label;" accesskey="&menu_techSMIME.accesskey;"
+ value="SMIME" type="radio" name="radiogroup_encTech"/>
+
+ <menuseparator id="encryptionOptionsSeparator_Toolbar"/>
+
+ <menuitem id="menu_securityEncrypt_Toolbar"
+ type="checkbox"
+ data-l10n-id="menu-encrypt"
+ value="enc"/>
+ <menuitem id="menu_securityEncryptSubject_Toolbar"
+ type="checkbox"
+ data-l10n-id="menu-encrypt-subject"
+ value="encsub"/>
+ <menuitem id="menu_securitySign_Toolbar"
+ type="checkbox"
+ data-l10n-id="menu-sign"
+ value="sig"/>
+
+ <menuseparator id="statusInfoSeparator"/>
+
+ <menuitem id="menu_recipientStatus_Toolbar"
+ data-l10n-id="menu-manage-keys"
+ value="status"/>
+ <menuitem id="menu_openManager_Toolbar"
+ data-l10n-id="menu-open-key-manager"
+ value="manager"/>
+
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="spellingButton"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&spellingButton.label;"
+ tooltiptext="&spellingButton.tooltip;"
+ command="cmd_spelling">
+ <!-- workaround for the bug that split menu doesn't take popup="popupID" -->
+ <menupopup onpopupshowing="event.preventDefault();
+ showPopupById('languageMenuList',
+ 'spellingButton');"/>
+ </toolbarbutton>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-save"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&saveButton.label;"
+ tooltiptext="&saveButton.tooltip;"
+ command="cmd_saveDefault">
+ <menupopup id="button-savePopup" onpopupshowing="InitFileSaveAsMenu();">
+ <menuitem id="savePopup_saveAsFile"
+ label="&saveAsFileCmd.label;" accesskey="&saveAsFileCmd.accesskey;"
+ command="cmd_saveAsFile" type="radio" name="radiogroup_SaveAs"/>
+ <menuseparator/>
+ <menuitem id="savePopup_saveAsDraft"
+ label="&saveAsDraftCmd.label;" accesskey="&saveAsDraftCmd.accesskey;"
+ command="cmd_saveAsDraft" type="radio" name="radiogroup_SaveAs"/>
+ <menuitem id="savePopup_saveAsTemplate"
+ label="&saveAsTemplateCmd.label;" accesskey="&saveAsTemplateCmd.accesskey;"
+ command="cmd_saveAsTemplate" type="radio" name="radiogroup_SaveAs"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton id="button-print"
+ class="toolbarbutton-1"
+ label="&printButton.label;"
+ command="cmd_print"
+ tooltiptext="&printButton.tooltip;"/>
+ <toolbarbutton class="toolbarbutton-1"
+ id="quoteButton" label="&quoteButton.label;"
+ tooltiptext="&quoteButton.tooltip;"
+ command="cmd_quoteMessage"/>
+
+ <toolbarbutton id="cut-button" class="toolbarbutton-1"
+ data-l10n-id="text-action-cut"
+ command="cmd_cut"
+ tooltiptext="&cutButton.tooltip;"/>
+ <toolbarbutton id="copy-button" class="toolbarbutton-1"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"
+ tooltiptext="&copyButton.tooltip;"/>
+ <toolbarbutton id="paste-button" class="toolbarbutton-1"
+ data-l10n-id="text-action-paste"
+ command="cmd_paste"
+ tooltiptext="&pasteButton.tooltip;"/>
+
+ <toolbaritem id="priority-button"
+ align="center"
+ pack="center"
+ title="&priorityButton.title;"
+ tooltiptext="&priorityButton.tooltiptext;">
+ <label value="&priorityButton.label;" control="priorityMenu-button"/>
+ <menulist id="priorityMenu-button" value="" oncommand="PriorityMenuSelect(event.target);">
+ <menupopup id="priorityMenu-buttonPopup">
+ <menuitem id="list_priority_highest"
+ name="priority"
+ label="&highestPriorityCmd.label;"
+ value="Highest"/>
+ <menuitem id="list_priority_high"
+ name="priority"
+ label="&highPriorityCmd.label;"
+ value="High"/>
+ <menuitem id="list_priority_normal"
+ name="priority"
+ selected="true"
+ label="&normalPriorityCmd.label;"
+ value=""/>
+ <menuitem id="list_priority_low"
+ name="priority"
+ label="&lowPriorityCmd.label;"
+ value="Low"/>
+ <menuitem id="list_priority_lowest"
+ name="priority"
+ label="&lowestPriorityCmd.label;"
+ value="Lowest"/>
+ </menupopup>
+ </menulist>
+ </toolbaritem>
+
+ <toolbarbutton id="button-returnReceipt"
+ class="toolbarbutton-1"
+ data-l10n-id="button-return-receipt"
+ type="checkbox" autoCheck="false"
+ command="cmd_toggleReturnReceipt"/>
+ </toolbarpalette>
+ <toolbar is="customizable-toolbar"
+ id="composeToolbar2"
+ class="chromeclass-toolbar themeable-full"
+ toolbarname="&showCompositionToolbarCmd.label;"
+ accesskey="&showCompositionToolbarCmd.accesskey;"
+ fullscreentoolbar="true" mode="full"
+#ifdef XP_MACOSX
+ iconsize="small"
+#endif
+ defaultset="button-send,separator,button-encryption,button-encryption-options,button-address,spellingButton,button-save,button-contacts,spring,button-attach"
+ customizable="true"
+ context="toolbar-context-menu">
+ </toolbar>
+</toolbox>
+ <html:div id="composeContentBox" class="printPreviewStack attachment-area-hidden">
+ <html:div id="contactsSidebar">
+ <box class="sidebar-header" align="center">
+ <label id="contactsTitle" value="&addressesSidebarTitle.label;"/>
+ <spacer flex="1"/>
+ <toolbarbutton class="close-icon"
+ oncommand="toggleContactsSidebar();"/>
+ </box>
+ <browser id="contactsBrowser" src="" disablehistory="true"/>
+ </html:div>
+
+ <html:hr is="pane-splitter" id="contactsSplitter"
+ resize-direction="horizontal"
+ resize-id="contactsSidebar" />
+
+ <toolbar is="customizable-toolbar" id="MsgHeadersToolbar"
+ class="themeable-full"
+ customizable="true" nowindowdrag="true"
+ ondragover="envelopeDragObserver.onDragOver(event);"
+ ondrop="envelopeDragObserver.onDrop(event);"
+ ondragleave="envelopeDragObserver.onDragLeave(event);">
+ <hbox id="top-gradient-box" class="address-identity-recipient">
+ <hbox class="aw-firstColBox"/>
+ <hbox id="identityLabel-box" align="center"
+ pack="end" style="&headersSpace2.style;">
+ <label id="identityLabel" value="&fromAddr2.label;"
+ accesskey="&fromAddr.accesskey;" control="msgIdentity"/>
+ </hbox>
+ <menulist is="menulist-editable" id="msgIdentity"
+ type="description"
+ disableautoselect="true" onkeypress="fromKeyPress(event);"
+ oncommand="LoadIdentity(false);" disableonsend="true">
+ <menupopup id="msgIdentityPopup"/>
+ </menulist>
+
+ <html:div id="extraAddressRowsArea">
+ <!-- Default set up is for a mail account, where we prefer
+ - showing the buttons, rather than the menu items, for
+ - the mail rows.
+ - The To field is already shown, so the button is hidden.
+ - For the news rows, we prefer the menu items over the
+ - buttons, so we hide them. -->
+ <html:button id="addr_toShowAddressRowButton"
+ disableonsend="true"
+ class="recipient-button plain-button"
+ data-address-row="addressRowTo"
+ onclick="showAndFocusAddressRow('addressRowTo');"
+ ondrop="showAddressRowButtonOnDrop(event);"
+ ondragover="showAddressRowButtonOnDragover(event);"
+ hidden="hidden">
+ </html:button>
+ <html:button id="addr_ccShowAddressRowButton"
+ disableonsend="true"
+ class="recipient-button plain-button"
+ data-address-row="addressRowCc"
+ onclick="showAndFocusAddressRow('addressRowCc');"
+ ondrop="showAddressRowButtonOnDrop(event);"
+ ondragover="showAddressRowButtonOnDragover(event);">
+ </html:button>
+ <html:button id="addr_bccShowAddressRowButton"
+ disableonsend="true"
+ class="recipient-button plain-button"
+ data-address-row="addressRowBcc"
+ onclick="showAndFocusAddressRow('addressRowBcc');"
+ ondrop="showAddressRowButtonOnDrop(event);"
+ ondragover="showAddressRowButtonOnDragover(event);">
+ </html:button>
+ <html:button id="addr_newsgroupsShowAddressRowButton"
+ class="recipient-button plain-button"
+ hidden="hidden"
+ onclick="showAndFocusAddressRow('addressRowNewsgroups')">
+ &newsgroupsAddr2.label;
+ </html:button>
+ <html:button id="addr_followupShowAddressRowButton"
+ class="recipient-button plain-button"
+ hidden="hidden"
+ onclick="showAndFocusAddressRow('addressRowFollowup')">
+ &followupAddr2.label;
+ </html:button>
+ <html:button id="extraAddressRowsMenuButton"
+ data-l10n-id="extra-address-rows-menu-button"
+ aria-expanded="false"
+ aria-haspopup="menu"
+ aria-controls="extraAddressRowsMenu"
+ disableonsend="true"
+ class="plain-button"
+ onclick="openExtraAddressRowsMenu();">
+ <!-- NOTE: button title should provide the accessibility
+ - context. -->
+ <html:img class="overflow-icon"
+ src="chrome://messenger/skin/icons/new/compact/overflow.svg"
+ alt="" />
+ </html:button>
+ </html:div>
+ </hbox>
+
+ <mail-recipients-area id="recipientsContainer" orient="vertical"
+ class="recipients-container">
+ <hbox id="addressRowReply"
+ class="address-row hidden"
+ data-recipienttype="addr_reply"
+ data-show-self-menuitem="addr_replyShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="replyAddrLabel" value="&replyAddr2.label;"
+ control="replyAddrInput"/>
+ </hbox>
+ <hbox id="replyAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="replyAddrInput"
+ type="text"
+ class="plain address-input address-row-input mail-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowTo"
+ class="address-row"
+ data-recipienttype="addr_to"
+ data-show-self-menuitem="addr_toShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);"
+ hidden="hidden">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="toAddrLabel"
+ data-l10n-id="to-address-row-label"
+ control="toAddrInput"/>
+ </hbox>
+ <hbox id="toAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="toAddrInput"
+ type="text"
+ class="plain address-input address-row-input mail-input mail-primary-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowCc"
+ class="address-row hidden"
+ data-recipienttype="addr_cc"
+ data-show-self-menuitem="addr_ccShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="ccAddrLabel"
+ data-l10n-id="cc-address-row-label"
+ control="ccAddrInput"/>
+ </hbox>
+ <hbox id="ccAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="ccAddrInput"
+ type="text"
+ class="plain address-input address-row-input mail-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowBcc"
+ class="address-row hidden"
+ data-recipienttype="addr_bcc"
+ data-show-self-menuitem="addr_bccShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="bccAddrLabel"
+ data-l10n-id="bcc-address-row-label"
+ control="bccAddrInput"/>
+ </hbox>
+ <hbox id="bccAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="bccAddrInput"
+ type="text"
+ class="plain address-input address-row-input mail-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowNewsgroups"
+ class="address-row hidden"
+ data-recipienttype="addr_newsgroups"
+ data-show-self-menuitem="addr_newsgroupsShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="newsgroupsAddrLabel" value="&newsgroupsAddr2.label;"
+ control="newsgroupsAddrInput"/>
+ </hbox>
+ <hbox id="newsgroupsAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="newsgroupsAddrInput"
+ type="text"
+ class="plain address-input address-row-input news-input news-primary-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowFollowup"
+ class="address-row hidden"
+ data-recipienttype="addr_followup"
+ data-show-self-menuitem="addr_followupShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="followupAddrLabel" value="&followupAddr2.label;"
+ control="followupAddrInput"/>
+ </hbox>
+ <hbox id="followupAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="followupAddrInput"
+ type="text"
+ class="plain address-input address-row-input news-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+ </mail-recipients-area>
+
+ <hbox id="subject-box">
+ <hbox class="aw-firstColBox"/>
+ <hbox id="subjectLabel-box" align="center"
+ pack="end" style="&headersSpace2.style;">
+ <label id="subjectLabel" value="&subject2.label;"
+ accesskey="&subject.accesskey;" control="msgSubject"/>
+ </hbox>
+ <hbox id="msgSubjectContainer" flex="1" align="center"
+ class="input-container">
+ <moz-input-box spellcheck="true" style="flex: 1;">
+ <html:img id="msgEncryptedSubjectIcon"
+ src="chrome://messenger/skin/icons/message-encrypted-notok.svg"
+ onclick="toggleEncryptedSubject(event);"
+ hidden="hidden"
+ alt="" />
+ <html:input id="msgSubject"
+ type="text"
+ class="input-inline textbox-input"
+ disableonsend="true"
+ oninput="msgSubjectOnInput(event);"
+ onkeypress="subjectKeyPress(event);"
+ aria-labelledby="subjectLabel"
+ style="flex: 1;"/>
+ </moz-input-box>
+ </hbox>
+ </hbox>
+ </toolbar>
+
+ <toolbox id="FormatToolbox" mode="icons">
+ <toolbar id="FormatToolbar"
+ class="chromeclass-toolbar themeable-brighttext"
+ persist="collapsed"
+ nowindowdrag="true">
+#include editFormatButtons.inc.xhtml
+ <spacer flex="1"/>
+ </toolbar>
+ </toolbox>
+
+ <html:hr is="pane-splitter" id="headersSplitter"
+ resize-direction="vertical"
+ resize-id="MsgHeadersToolbar" />
+ <html:div id="messageArea">
+ <html:div id="dropAttachmentOverlay" class="drop-attachment-overlay">
+ <html:aside id="addInline" class="drop-attachment-box">
+ <html:span id="addInlineLabel"
+ data-l10n-id="drop-file-label-inline"
+ data-l10n-args='{"count": 1}'
+ class="drop-inline"></html:span>
+ </html:aside>
+ <html:aside id="addAsAttachment"
+ class="drop-attachment-box">
+ <html:span id="addAsAttachmentLabel"
+ data-l10n-id="drop-file-label-attachment"
+ data-l10n-args='{"count": 1}'
+ class="drop-as-attachment"></html:span>
+ </html:aside>
+ </html:div>
+ <!--
+ - The mail message body frame. The src does not exactly match
+ - "about:blank" so that WebExtension content scripts are not loaded
+ - here in the moments before navigation to about:blank?compose occurs.
+ -->
+ <editor id="messageEditor"
+ type="content"
+ primary="true"
+ src="about:blank?"
+ name="browser.message.body"
+ aria-label="&aria.message.bodyName;"
+ messagemanagergroup="browsers"
+ oncontextmenu="this._contextX = event.pageX; this._contextY = event.pageY;"
+ onclick="EditorClick(event);"
+ ondblclick="EditorDblClick(event);"
+ context="msgComposeContext"/>
+
+ <html:div id="linkPreviewSettings" xmlns="http://www.w3.org/1999/xhtml" hidden="hidden">
+ <span class="close">+</span>
+ <h2 data-l10n-id="link-preview-title"></h2>
+ <p data-l10n-id="link-preview-description"></p>
+ <p>
+ <input class="preview-autoadd" id="link-preview-autoadd" type="checkbox" />
+ <label data-l10n-id="link-preview-autoadd" for="link-preview-autoadd"></label>
+ </p>
+ <p class="bottom">
+ <span data-l10n-id="link-preview-replace-now"></span>
+ <button class="preview-replace" data-l10n-id="link-preview-yes-replace"></button>
+ </p>
+ </html:div>
+
+ <findbar id="FindToolbar" browserid="messageEditor"/>
+ </html:div>
+
+ <!-- NOTE: The splitter controls #attachmentBucket's size directly. -->
+ <html:hr is="pane-splitter" id="attachmentSplitter"
+ resize-direction="vertical"
+ resize-id="attachmentBucket" />
+ <html:details id="attachmentArea">
+ <html:summary>
+ <!-- Hide from accessibility tree since this is only used for a brief
+ - animation effect. -->
+ <html:span id="newAttachmentIndicator" aria-hidden="true"></html:span>
+ <html:img id="attachmentToggle"
+ src="chrome://messenger/skin/icons/new/nav-down-sm.svg"
+ alt="" />
+ <html:span id="attachmentBucketCount"></html:span>
+ <html:span id="attachmentBucketSize" role="note"></html:span>
+ </html:summary>
+
+ <richlistbox is="attachment-list" id="attachmentBucket"
+ aria-describedby="attachmentBucketCount"
+ class="attachmentList"
+ disableonsend="true"
+ seltype="multiple"
+ flex="1"
+ role="listbox"
+ context="msgComposeAttachmentListContext"
+ itemcontext="msgComposeAttachmentItemContext"
+ onclick="attachmentBucketOnClick(event);"
+ onkeypress="attachmentBucketOnKeyPress(event);"
+ onselect="attachmentBucketOnSelect();"
+ ondragstart="attachmentBucketDNDObserver.onDragStart(event);"
+ ondragover="envelopeDragObserver.onDragOver(event);"
+ ondrop="envelopeDragObserver.onDrop(event);"
+ ondragleave="envelopeDragObserver.onDragLeave(event);"
+ onblur="attachmentBucketOnBlur();"/>
+ </html:details>
+ </html:div>
+
+ <panel id="customizeToolbarSheetPopup" noautohide="true">
+ <iframe id="customizeToolbarSheetIFrame"
+ style="&dialog.dimensions;"
+ hidden="true"/>
+ </panel>
+
+ <vbox id="compose-notification-bottom">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+
+ <html:div id="status-bar" class="statusbar" role="status">
+ <html:div id="statusText"></html:div>
+ <html:progress id="compose-progressmeter"
+ class="progressmeter-statusbar"
+ value="0" max="100"
+ hidden="hidden">
+ </html:progress>
+ <html:button id="languageStatusButton"
+ class="plain-button"
+ aria-expanded="false"
+ aria-haspopup="menu"
+ aria-controls="languageMenuList"
+ title="&languageStatusButton.tooltip;"
+ onclick="showPopupById('languageMenuList', 'languageStatusButton', 'before_start');"
+ hidden="hidden">
+ </html:button>
+ </html:div>
+
+#include ../../../base/content/tabDialogs.inc.xhtml
+#include ../../../extensions/openpgp/content/ui/keyAssistant.inc.xhtml
+
+<html:template id="dataCardTemplate" xmlns="http://www.w3.org/1999/xhtml">
+ <aside class="moz-card" style="width:600px; display:flex; align-items:center; justify-content:center; flex-direction:row; flex-wrap:wrap; border-radius:10px; border:1px solid silver;">
+ <a class="remove-card">+</a>
+ <div class="card-pic" style="display:flex; flex-direction:column; flex-basis:100%; flex:1;">
+ <div style="margin:0 5px;">
+ <img src="IMAGE" style="width:120px;" alt="" />
+ </div>
+ </div>
+ <div class="card-content" style="display:flex; flex-direction:column; flex-basis:100%; flex:3;">
+ <div style="margin:0 1em;">
+ <p><small class="site" style="font-weight:lighter;">SITE</small></p>
+ <p>
+ <a href="#" style="font-weight:600; text-decoration:none;"><big class="title">TITLE</big></a>
+ </p>
+ <p class="description">DESCRIPTION</p>
+ <p>
+ <a href="#" class="url" style="display:inline-block; text-decoration:none; text-indent:-2ch; margin-inline:2ch;">URL</a>
+ </p>
+ </div>
+ </div>
+ </aside>
+</html:template>
+</html:body>
+</html>
diff --git a/comm/mail/components/compose/jar.mn b/comm/mail/components/compose/jar.mn
new file mode 100644
index 0000000000..81761972d1
--- /dev/null
+++ b/comm/mail/components/compose/jar.mn
@@ -0,0 +1,58 @@
+# 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/messengercompose/messengercompose.xhtml (content/messengercompose.xhtml)
+ content/messenger/messengercompose/MsgComposeCommands.js (content/MsgComposeCommands.js)
+ content/messenger/messengercompose/bigFileObserver.js (content/bigFileObserver.js)
+ content/messenger/messengercompose/cloudAttachmentLinkManager.js (content/cloudAttachmentLinkManager.js)
+ content/messenger/messengercompose/addressingWidgetOverlay.js (content/addressingWidgetOverlay.js)
+ content/messenger/messengercompose/editor.js (content/editor.js)
+ content/messenger/messengercompose/editorUtilities.js (content/editorUtilities.js)
+ content/messenger/messengercompose/ComposerCommands.js (content/ComposerCommands.js)
+ content/messenger/messengercompose/images/tag-anchor.gif (content/images/tag-anchor.gif)
+ content/messenger/messengercompose/EdDialogCommon.js (content/dialogs/EdDialogCommon.js)
+ content/messenger/messengercompose/EdLinkProps.xhtml (content/dialogs/EdLinkProps.xhtml)
+ content/messenger/messengercompose/EdLinkProps.js (content/dialogs/EdLinkProps.js)
+ content/messenger/messengercompose/EdImageProps.xhtml (content/dialogs/EdImageProps.xhtml)
+ content/messenger/messengercompose/EdImageProps.js (content/dialogs/EdImageProps.js)
+ content/messenger/messengercompose/EdImageLinkLoader.js (content/dialogs/EdImageLinkLoader.js)
+ content/messenger/messengercompose/EdImageDialog.js (content/dialogs/EdImageDialog.js)
+ content/messenger/messengercompose/EdHLineProps.xhtml (content/dialogs/EdHLineProps.xhtml)
+ content/messenger/messengercompose/EdHLineProps.js (content/dialogs/EdHLineProps.js)
+ content/messenger/messengercompose/EdReplace.xhtml (content/dialogs/EdReplace.xhtml)
+ content/messenger/messengercompose/EdReplace.js (content/dialogs/EdReplace.js)
+ content/messenger/messengercompose/EdSpellCheck.xhtml (content/dialogs/EdSpellCheck.xhtml)
+ content/messenger/messengercompose/EdSpellCheck.js (content/dialogs/EdSpellCheck.js)
+ content/messenger/messengercompose/EdDictionary.xhtml (content/dialogs/EdDictionary.xhtml)
+ content/messenger/messengercompose/EdDictionary.js (content/dialogs/EdDictionary.js)
+ content/messenger/messengercompose/EdNamedAnchorProps.xhtml (content/dialogs/EdNamedAnchorProps.xhtml)
+ content/messenger/messengercompose/EdNamedAnchorProps.js (content/dialogs/EdNamedAnchorProps.js)
+ content/messenger/messengercompose/EdInsertTOC.xhtml (content/dialogs/EdInsertTOC.xhtml)
+ content/messenger/messengercompose/EdInsertTOC.js (content/dialogs/EdInsertTOC.js)
+ content/messenger/messengercompose/EdInsertTable.xhtml (content/dialogs/EdInsertTable.xhtml)
+ content/messenger/messengercompose/EdInsertTable.js (content/dialogs/EdInsertTable.js)
+ content/messenger/messengercompose/EdInsertMath.xhtml (content/dialogs/EdInsertMath.xhtml)
+ content/messenger/messengercompose/EdInsertMath.js (content/dialogs/EdInsertMath.js)
+ content/messenger/messengercompose/EdTableProps.xhtml (content/dialogs/EdTableProps.xhtml)
+ content/messenger/messengercompose/EdTableProps.js (content/dialogs/EdTableProps.js)
+ content/messenger/messengercompose/EdInsSrc.xhtml (content/dialogs/EdInsSrc.xhtml)
+ content/messenger/messengercompose/EdInsSrc.js (content/dialogs/EdInsSrc.js)
+ content/messenger/messengercompose/EdInsertChars.xhtml (content/dialogs/EdInsertChars.xhtml)
+ content/messenger/messengercompose/EdInsertChars.js (content/dialogs/EdInsertChars.js)
+ content/messenger/messengercompose/EdAdvancedEdit.xhtml (content/dialogs/EdAdvancedEdit.xhtml)
+ content/messenger/messengercompose/EdAdvancedEdit.js (content/dialogs/EdAdvancedEdit.js)
+ content/messenger/messengercompose/EdListProps.xhtml (content/dialogs/EdListProps.xhtml)
+ content/messenger/messengercompose/EdListProps.js (content/dialogs/EdListProps.js)
+ content/messenger/messengercompose/EdColorProps.xhtml (content/dialogs/EdColorProps.xhtml)
+ content/messenger/messengercompose/EdColorProps.js (content/dialogs/EdColorProps.js)
+ content/messenger/messengercompose/EdColorPicker.xhtml (content/dialogs/EdColorPicker.xhtml)
+ content/messenger/messengercompose/EdColorPicker.js (content/dialogs/EdColorPicker.js)
+ content/messenger/messengercompose/EdAECSSAttributes.js (content/dialogs/EdAECSSAttributes.js)
+ content/messenger/messengercompose/EdAEHTMLAttributes.js (content/dialogs/EdAEHTMLAttributes.js)
+ content/messenger/messengercompose/EdAEJSEAttributes.js (content/dialogs/EdAEJSEAttributes.js)
+ content/messenger/messengercompose/EdAEAttributes.js (content/dialogs/EdAEAttributes.js)
+ content/messenger/messengercompose/EdConvertToTable.xhtml (content/dialogs/EdConvertToTable.xhtml)
+ content/messenger/messengercompose/EdConvertToTable.js (content/dialogs/EdConvertToTable.js)
+ content/messenger/messengercompose/TeXZilla.js (texzilla/TeXZilla.js)
diff --git a/comm/mail/components/compose/moz.build b/comm/mail/components/compose/moz.build
new file mode 100644
index 0000000000..2e5de88212
--- /dev/null
+++ b/comm/mail/components/compose/moz.build
@@ -0,0 +1,8 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+JS_PREFERENCE_PP_FILES += ["composer.js"]
diff --git a/comm/mail/components/compose/texzilla/TeXZilla.js b/comm/mail/components/compose/texzilla/TeXZilla.js
new file mode 100644
index 0000000000..0f8e3e5b29
--- /dev/null
+++ b/comm/mail/components/compose/texzilla/TeXZilla.js
@@ -0,0 +1,339 @@
+/* THIS IS A GENERATED FILE. DO NOT EDIT THIS DIRECTLY. */
+/* 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/. */
+
+(function() {
+"using strict";
+var nb=void 0,tb=!0,xb=null,yb=!1,zb=function(){function c(b,a,c){var $a;c=c||{};for($a=b.length;$a--;c[b[$a]]=a);return c}function Fb(b){return b.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function sb(b){b="negativeveryverythinmathspace negativeverythinmathspace negativemediummathspace negativethickmathspace negativeverythickmathspace negativeveryverythickmathspace veryverythinmathspace verythinmathspace thinmathspace mediummathspace thickmathspace verythickmathspace veryverythickmathspace".split(" ").indexOf(b);
+return(-1===b?0:b-6)/18}function tc(b){b=b.trim();var a=/(-?[0-9]*(?:[0-9]\.?|\.[0-9])[0-9]*)(e[mx]|in|cm|mm|p[xtc]|%)?/.exec(b);return a?(a[1]=parseFloat(a[1]),a[2]||(a[1]*=100,a[2]="%"),{i:a[1],k:a[2]}):{i:sb(b),k:"em"}}function Pb(b){var a="<"+b.tag,c;for(c in b.attributes)b.attributes[c]!==nb&&(a+=" "+c+'="'+b.attributes[c]+'"');b.content?(a+=">",Array.isArray(b.content)?b.content.forEach(function(b){a+=Pb(b)}):a+=b.content,a+="</"+b.tag+">"):a+="/>";return a}function e(b,a,c){return{tag:b,content:a,
+attributes:c}}function ab(b,a,c){return e("mo",Fb(b),{lspace:a!==nb?a+"em":nb,rspace:c!==nb?c+"em":nb})}function Ub(b,a){return e("mi",Fb(b),a?{mathvariant:"normal"}:nb)}function Gb(b){return e("mspace",xb,{width:b+"em"})}function uc(b,a){var c="bold italic bold-italic script bold-script fraktur double-struck bold-fraktur sans-serif bold-sans-serif sans-serif-italic sans-serif-bold-italic monospace initial tailed looped stretched".split(" ").indexOf(a);if(930==b)return b;if(988==b)return 0==c?120778:
+b;if(989==b)return 0==c?120779:b;if(305==b)return 1==c?120484:b;if(567==b)return 1==c?120485:b;var $a;if(65<=b&&90>=b||97<=b&&122>=b){if(12<c)return b;c=(90>=b?b-65:26+b-97)+119808+52*c;$a={119893:8462,119965:8492,119968:8496,119969:8497,119971:8459,119972:8464,119975:8466,119976:8499,119981:8475,119994:8495,119996:8458,120004:8500,120070:8493,120075:8460,120076:8465,120085:8476,120093:8488,120122:8450,120127:8461,120133:8469,120135:8473,120136:8474,120137:8477,120145:8484};return $a[c]?$a[c]:c}if(48<=
+b&&57>=b){switch(c){case 0:c=0;break;case 6:c=1;break;case 8:c=2;break;case 9:c=3;break;case 12:c=4;break;default:return b}return b-48+10*c+120782}if(1536<=b&&1791>=b){switch(c){case 13:$a={1576:126497,1578:126517,1579:126518,1580:126498,1581:126503,1582:126519,1587:126510,1588:126516,1589:126513,1590:126521,1593:126511,1594:126523,1601:126512,1602:126514,1603:126506,1604:126507,1605:126508,1606:126509,1607:126500,1610:126505};break;case 14:$a={1580:126530,1581:126535,1582:126551,1587:126542,1588:126548,
+1589:126545,1590:126553,1593:126543,1594:126555,1602:126546,1604:126539,1606:126541,1610:126537,1647:126559,1722:126557};break;case 16:$a={1576:126561,1578:126581,1579:126582,1580:126562,1581:126567,1582:126583,1587:126574,1588:126580,1589:126577,1590:126585,1591:126568,1592:126586,1593:126575,1594:126587,1601:126576,1602:126578,1603:126570,1605:126572,1606:126573,1607:126564,1610:126569,1646:126588,1697:126590};break;case 15:$a={1575:126592,1576:126593,1578:126613,1579:126614,1580:126594,1581:126599,
+1582:126615,1583:126595,1584:126616,1585:126611,1586:126598,1587:126606,1588:126612,1589:126609,1590:126617,1591:126600,1592:126618,1593:126607,1594:126619,1601:126608,1602:126610,1604:126603,1605:126604,1606:126605,1607:126596,1608:126597,1610:126601};break;case 6:$a={1576:126625,1578:126645,1579:126646,1580:126626,1581:126631,1582:126647,1583:126627,1584:126648,1585:126643,1586:126630,1587:126638,1588:126644,1589:126641,1590:126649,1591:126632,1592:126650,1593:126639,1594:126651,1601:126640,1602:126642,
+1604:126635,1605:126636,1606:126637,1608:126629,1610:126633};break;default:return b}return $a[b]?$a[b]:b}if(913<=b&&937>=b)$a=b-913;else if(945<=b&&969>=b)$a=26+b-945;else switch(b){case 1012:$a=17;break;case 8711:$a=25;break;case 8706:$a=51;break;case 1013:$a=52;break;case 977:$a=53;break;case 1008:$a=54;break;case 981:$a=55;break;case 1009:$a=56;break;case 982:$a=57;break;default:return b}switch(c){case 0:c=0;break;case 1:c=1;break;case 2:c=2;break;case 9:c=3;break;case 11:c=4;break;default:return b}return $a+
+120488+58*c}function vc(b,a){var c=tb,$a;for($a in a)-1!==["mathcolor","mathbackground","mathvariant"].indexOf($a)?"mathvariant"!==$a&&1!=b.length?c=yb:b.forEach(function(b){if(-1!==["mi","mn","mo","mtext","ms"].indexOf(b.tag)){if(b.attributes||(b.attributes={}),!b.attributes[$a])if("mathvariant"===$a){var d;if(!(d="normal"!==a[$a])){if("mi"!==b.tag)d=yb;else{d=b.content;var e=d.codePointAt(0);d=1===d.length&&65535>=e||2===d.length&&65535<e}d=!d}if(d){if(d=a[$a],"normal"!==d){for(var e=b.content,
+g="",m=0;m<e.length;m++){var s=e.codePointAt(m);65535<s?(g+=e[m],m++,g+=e[m]):g+=String.fromCodePoint(uc(s,d))}b.content=g}}else b.attributes[$a]=a[$a]}else b.attributes[$a]=a[$a]}else c=yb}):c=yb;return c}function db(b,a,c){a=a||"mrow";if("mstyle"===a){if(1==b.length&&"mrow"===b[0].tag&&!b[0].attributes)return db(b[0].content,a,c);if(vc(b,c))return db(b)}return 1==b.length&&"mrow"===a&&!c?b[0]:e(a,b,c)}function Ib(b,a,c,$a){return e("math",[e("semantics",[db(b),e("annotation",Fb($a),{encoding:"TeX"})])],
+{xmlns:Qb,display:a?"block":nb,dir:c?"rtl":nb})}function Vb(b){if(!b||b.namespaceURI!==Qb)return xb;if("semantics"===b.tagName)for(b=b.firstElementChild;b;b=b.nextElementSibling){if(b.namespaceURI===Qb&&"annotation"===b.localName&&-1!==wc.indexOf(b.getAttribute("encoding")))return b.textContent}else if(1===b.childElementCount)return Vb(b.firstElementChild);return xb}function xc(b){for(var a="",c,$a,e=0;e<b.length;e++)c=b.charCodeAt(e),128>c?a+=b.charAt(e):55296<=c&&56319>=c?(e++,$a=b.charCodeAt(e),
+a+="&#x"+(1024*(c-55296)+$a-56320+65536).toString(16)+";"):a+="&#x"+c.toString(16)+";";return a}function Rb(){this.e={}}var Wb=[1,4],Xb=[1,6],Yb=[1,7],Zb=[1,8],$b=[1,9],Ab=[68,195,198,200,202,204],m=[1,27],s=[1,124],v=[1,52],x=[1,48],h=[1,28],q=[1,29],p=[1,30],y=[1,31],f=[1,32],u=[1,33],n=[1,34],k=[1,35],r=[1,37],t=[1,38],l=[1,39],w=[1,40],z=[1,41],A=[1,42],B=[1,43],C=[1,44],D=[1,45],E=[1,46],F=[1,47],G=[1,49],H=[1,50],I=[1,51],J=[1,53],K=[1,54],L=[1,55],M=[1,56],N=[1,57],O=[1,58],P=[1,59],Q=[1,60],
+R=[1,61],S=[1,62],T=[1,63],U=[1,64],V=[1,65],W=[1,66],X=[1,67],Y=[1,68],Z=[1,69],$=[1,70],aa=[1,71],ba=[1,72],ca=[1,73],da=[1,74],ea=[1,75],fa=[1,76],ga=[1,77],ha=[1,78],ia=[1,79],ja=[1,80],ka=[1,81],la=[1,82],ma=[1,83],na=[1,84],oa=[1,85],pa=[1,86],qa=[1,87],ra=[1,88],sa=[1,89],ta=[1,90],ua=[1,91],va=[1,92],wa=[1,93],xa=[1,94],ya=[1,95],za=[1,96],Aa=[1,97],Ba=[1,98],Ca=[1,99],Da=[1,100],Ea=[1,101],Fa=[1,102],Ga=[1,103],Ha=[1,104],Ia=[1,105],Ja=[1,106],Ka=[1,107],eb=[1,24],La=[1,108],Ma=[1,109],Na=
+[1,110],Oa=[1,111],Pa=[1,112],Qa=[1,113],Ra=[1,114],Sa=[1,115],Ta=[1,116],Ua=[1,117],Va=[1,118],Wa=[1,119],Xa=[1,120],Ya=[1,121],bb=[1,122],cb=[1,123],fb=[1,16],gb=[1,17],hb=[1,18],ib=[1,19],jb=[1,20],kb=[1,21],lb=[1,22],ac=[6,10,53,64,65,66,144,146,148,150,152,154,156,158,160,162,164,189,192,199,201,203,205],Db=[8,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,
+114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,145,147,149,151,153,155,157,159,161,163,165,166,173,174,179,180,181,182,183,184,185],mb=[1,134],ub=[6,8,10,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,
+141,142,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,173,174,189,192,199,201,203,205],Za=[1,137],g=[6,8,10,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,138,139,141,142,144,145,146,147,148,149,150,151,152,153,154,
+155,156,157,158,159,160,161,162,163,164,165,166,169,170,171,173,174,189,192,199,201,203,205],Sb=[1,161],pb=[2,197],qb=[1,217],vb=[1,214],Eb=[6,8,10,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,144,145,146,147,148,149,150,151,152,153,154,155,156,
+157,158,159,160,161,162,163,164,165,166,169,170,173,174,189,192,199,201,203,205],bc=[1,241],Bb=[1,243],Cb=[1,244],Mb=[1,259],cc=[4,8],dc=[1,275],ec=[8,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,138,139,141,142,145,147,149,151,153,155,157,159,161,163,165,166],wb=[1,286],
+Nb=[10,144,146,148,150,152,154,156,158,160,162,164,192],fc=[1,288],Jb=[10,144,146,148,150,152,154,156,158,160,162,164,189,192],gc=[164,189,192],Tb=[10,189,192],Kb=[1,343],Lb=[1,344],hc=[1,352],ic=[1,353],jc=[4,8,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,
+145,147,149,151,153,155,157,159,161,163,165,166],Ob=[10,21,23],Hb=[10,21,23,25,27],kc=[1,399],lc=[1,400],mc=[1,401],nc=[1,402],oc=[1,403],pc=[1,404],qc=[1,405],rc=[1,406],sc=[10,19,21,23,25,27,29,31,33,35,37,39,41],ob=[10,19,21,23,29,31,33,35,37,39,41],rb={trace:function(){},e:{},la:{error:2,textOptArg:3,"[":4,TEXTOPTARG:5,"]":6,textArg:7,"{":8,TEXTARG:9,"}":10,lengthOptArg:11,lengthArg:12,attrOptArg:13,attrArg:14,tokenContent:15,arrayAlign:16,columnAlign:17,collayout:18,COLLAYOUT:19,colalign:20,
+COLALIGN:21,rowalign:22,ROWALIGN:23,rowspan:24,ROWSPAN:25,colspan:26,COLSPAN:27,align:28,ALIGN:29,eqrows:30,EQROWS:31,eqcols:32,EQCOLS:33,rowlines:34,ROWLINES:35,collines:36,COLLINES:37,frame:38,FRAME:39,padding:40,PADDING:41,cellopt:42,celloptList:43,rowopt:44,arrayopt:45,arrayoptList:46,rowoptList:47,left:48,LEFT:49,OPFS:50,".":51,right:52,RIGHT:53,closedTerm:54,styledExpression:55,BIG:56,BBIG:57,BIGG:58,BBIGG:59,BIGL:60,BBIGL:61,BIGGL:62,BBIGGL:63,TEXATOP:64,TEXOVER:65,TEXCHOOSE:66,NUM:67,TEXT:68,
+A:69,AILL:70,AIUL:71,AILG:72,AIUG:73,F:74,MI:75,MN:76,MO:77,OP:78,OPS:79,OPAS:80,MS:81,MTEXT:82,HIGH_SURROGATE:83,LOW_SURROGATE:84,BMP_CHARACTER:85,OPERATORNAME:86,MATHOP:87,MATHBIN:88,MATHREL:89,FRAC:90,ROOT:91,SQRT:92,UNDERSET:93,OVERSET:94,UNDEROVERSET:95,XARROW:96,MATHRLAP:97,MATHLLAP:98,MATHCLAP:99,PHANTOM:100,TFRAC:101,BINOM:102,TBINOM:103,PMOD:104,UNDERBRACE:105,UNDERLINE:106,OVERBRACE:107,ACCENT:108,ACCENTNS:109,BOXED:110,SLASH:111,QUAD:112,QQUAD:113,NEGSPACE:114,NEGMEDSPACE:115,NEGTHICKSPACE:116,
+THINSPACE:117,MEDSPACE:118,THICKSPACE:119,SPACE:120,MATHRAISEBOX:121,MATHBB:122,MATHBF:123,MATHBIT:124,MATHSCR:125,MATHBSCR:126,MATHSF:127,MATHFRAK:128,MATHIT:129,MATHTT:130,MATHRM:131,HREF:132,STATUSLINE:133,TOOLTIP:134,TOGGLE:135,BTOGGLE:136,closedTermList:137,ETOGGLE:138,TENSOR:139,subsupList:140,MULTI:141,BMATRIX:142,tableRowList:143,EMATRIX:144,BGATHERED:145,EGATHERED:146,BPMATRIX:147,EPMATRIX:148,BBMATRIX:149,EBMATRIX:150,BVMATRIX:151,EVMATRIX:152,BBBMATRIX:153,EBBMATRIX:154,BVVMATRIX:155,EVVMATRIX:156,
+BSMALLMATRIX:157,ESMALLMATRIX:158,BCASES:159,ECASES:160,BALIGNED:161,EALIGNED:162,BARRAY:163,EARRAY:164,SUBSTACK:165,ARRAY:166,ARRAYOPTS:167,compoundTerm:168,_:169,"^":170,OPP:171,opm:172,OPM:173,FM:174,compoundTermList:175,subsupTermScript:176,subsupTerm:177,textstyle:178,DISPLAYSTYLE:179,TEXTSTYLE:180,TEXTSIZE:181,SCRIPTSIZE:182,SCRIPTSCRIPTSIZE:183,COLOR:184,BGCOLOR:185,tableCell:186,CELLOPTS:187,tableCellList:188,COLSEP:189,tableRow:190,ROWOPTS:191,ROWSEP:192,document:193,documentItemList:194,
+EOF:195,documentItem:196,mathItem:197,STARTMATH0:198,ENDMATH0:199,STARTMATH1:200,ENDMATH1:201,STARTMATH2:202,ENDMATH2:203,STARTMATH3:204,ENDMATH3:205,$accept:0,$end:1},z:{2:"error",4:"[",5:"TEXTOPTARG",6:"]",8:"{",9:"TEXTARG",10:"}",19:"COLLAYOUT",21:"COLALIGN",23:"ROWALIGN",25:"ROWSPAN",27:"COLSPAN",29:"ALIGN",31:"EQROWS",33:"EQCOLS",35:"ROWLINES",37:"COLLINES",39:"FRAME",41:"PADDING",49:"LEFT",50:"OPFS",51:".",53:"RIGHT",56:"BIG",57:"BBIG",58:"BIGG",59:"BBIGG",60:"BIGL",61:"BBIGL",62:"BIGGL",63:"BBIGGL",
+64:"TEXATOP",65:"TEXOVER",66:"TEXCHOOSE",67:"NUM",68:"TEXT",69:"A",70:"AILL",71:"AIUL",72:"AILG",73:"AIUG",74:"F",75:"MI",76:"MN",77:"MO",78:"OP",79:"OPS",80:"OPAS",81:"MS",82:"MTEXT",83:"HIGH_SURROGATE",84:"LOW_SURROGATE",85:"BMP_CHARACTER",86:"OPERATORNAME",87:"MATHOP",88:"MATHBIN",89:"MATHREL",90:"FRAC",91:"ROOT",92:"SQRT",93:"UNDERSET",94:"OVERSET",95:"UNDEROVERSET",96:"XARROW",97:"MATHRLAP",98:"MATHLLAP",99:"MATHCLAP",100:"PHANTOM",101:"TFRAC",102:"BINOM",103:"TBINOM",104:"PMOD",105:"UNDERBRACE",
+106:"UNDERLINE",107:"OVERBRACE",108:"ACCENT",109:"ACCENTNS",110:"BOXED",111:"SLASH",112:"QUAD",113:"QQUAD",114:"NEGSPACE",115:"NEGMEDSPACE",116:"NEGTHICKSPACE",117:"THINSPACE",118:"MEDSPACE",119:"THICKSPACE",120:"SPACE",121:"MATHRAISEBOX",122:"MATHBB",123:"MATHBF",124:"MATHBIT",125:"MATHSCR",126:"MATHBSCR",127:"MATHSF",128:"MATHFRAK",129:"MATHIT",130:"MATHTT",131:"MATHRM",132:"HREF",133:"STATUSLINE",134:"TOOLTIP",135:"TOGGLE",136:"BTOGGLE",138:"ETOGGLE",139:"TENSOR",141:"MULTI",142:"BMATRIX",144:"EMATRIX",
+145:"BGATHERED",146:"EGATHERED",147:"BPMATRIX",148:"EPMATRIX",149:"BBMATRIX",150:"EBMATRIX",151:"BVMATRIX",152:"EVMATRIX",153:"BBBMATRIX",154:"EBBMATRIX",155:"BVVMATRIX",156:"EVVMATRIX",157:"BSMALLMATRIX",158:"ESMALLMATRIX",159:"BCASES",160:"ECASES",161:"BALIGNED",162:"EALIGNED",163:"BARRAY",164:"EARRAY",165:"SUBSTACK",166:"ARRAY",167:"ARRAYOPTS",169:"_",170:"^",171:"OPP",173:"OPM",174:"FM",179:"DISPLAYSTYLE",180:"TEXTSTYLE",181:"TEXTSIZE",182:"SCRIPTSIZE",183:"SCRIPTSCRIPTSIZE",184:"COLOR",185:"BGCOLOR",
+187:"CELLOPTS",189:"COLSEP",191:"ROWOPTS",192:"ROWSEP",195:"EOF",198:"STARTMATH0",199:"ENDMATH0",200:"STARTMATH1",201:"ENDMATH1",202:"STARTMATH2",203:"ENDMATH2",204:"STARTMATH3",205:"ENDMATH3"},W:[0,[3,3],[7,3],[11,3],[12,3],[13,1],[14,1],[15,1],[16,1],[17,1],[18,2],[20,2],[22,2],[24,2],[26,2],[28,2],[30,2],[32,2],[34,2],[36,2],[38,2],[40,2],[42,1],[42,1],[42,1],[42,1],[43,1],[43,2],[44,1],[44,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[46,1],[46,2],[47,1],[47,2],[48,
+2],[48,2],[52,2],[52,2],[54,2],[54,3],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,3],[54,5],[54,5],[54,5],[54,5],[54,5],[54,5],[54,1],[54,1],[54,1],[54,1],[54,1],[54,1],[54,1],[54,1],[54,2],[54,2],[54,2],[54,1],[54,1],[54,1],[54,1],[54,1],[54,2],[54,4],[54,2],[54,2],[54,1],[54,2],[54,2],[54,2],[54,2],[54,3],[54,3],[54,2],[54,5],[54,3],[54,3],[54,4],[54,5],[54,2],[54,2],[54,2],[54,2],[54,2],[54,3],[54,3],[54,3],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,1],[54,1],[54,
+1],[54,1],[54,1],[54,1],[54,1],[54,1],[54,4],[54,5],[54,4],[54,3],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,3],[54,3],[54,3],[54,3],[54,3],[54,5],[54,8],[54,7],[54,7],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,5],[54,4],[54,4],[54,4],[54,8],[137,1],[137,2],[168,3],[168,5],[168,4],[168,5],[168,4],[168,3],[168,3],[168,2],[168,1],[168,5],[168,5],[168,3],[168,3],[168,1],[172,1],[172,1],[175,1],[175,2],[176,1],[176,1],[177,4],[177,2],[177,
+2],[177,3],[140,1],[140,2],[178,1],[178,1],[178,1],[178,1],[178,1],[178,2],[178,2],[55,2],[55,1],[186,0],[186,5],[186,1],[188,1],[188,3],[190,5],[190,1],[143,1],[143,3],[193,2],[194,1],[194,2],[196,1],[196,1],[197,2],[197,3],[197,2],[197,3],[197,3],[197,3]],H:function(b,a,c,$a,g,d){b=d.length-1;switch(g){case 1:this.b=d[b-1].replace(/\\[\\\]]/g,function(a){return a.slice(1)});this.b=Fb(this.b);break;case 2:this.b=d[b-1].replace(/\\[\\\}]/g,function(a){return a.slice(1)});this.b=Fb(this.b);break;case 3:case 4:this.b=
+tc(d[b-1]);break;case 5:case 6:this.b=d[b].replace(/"/g,"&#x22;");break;case 7:this.b=d[b].replace(/\s+/g," ").replace(/^ | $/g," ");break;case 8:d[b]=d[b].trim();if("t"===d[b])this.b="axis 1";else if("c"===d[b])this.b="center";else if("b"===d[b])this.b="axis -1";else throw"Unknown array alignment";break;case 9:this.b="";d[b]=d[b].replace(/\s+/g,"");for($a=0;$a<d[b].length;$a++)"c"===d[b][$a]?this.b+=" center":"l"===d[b][$a]?this.b+=" left":"r"===d[b][$a]&&(this.b+=" right");if(this.b.length)this.b=
+this.b.slice(1);else throw"Invalid column alignments";break;case 10:case 11:this.b={columnalign:d[b]};break;case 12:this.b={rowalign:d[b]};break;case 13:this.b={rowspan:d[b]};break;case 14:this.b={colspan:d[b]};break;case 15:this.b={align:d[b]};break;case 16:this.b={equalrows:d[b]};break;case 17:this.b={equalcolumns:d[b]};break;case 18:this.b={rowlines:d[b]};break;case 19:this.b={columnlines:d[b]};break;case 20:this.b={frame:d[b]};break;case 21:this.b={rowspacing:d[b],columnspacing:d[b]};break;case 22:case 23:case 24:case 25:case 26:case 28:case 29:case 30:case 31:case 32:case 33:case 34:case 35:case 36:case 37:case 38:case 39:case 40:case 42:case 170:case 175:case 180:case 181:case 186:case 196:case 207:case 209:this.b=
+d[b];break;case 27:case 41:case 43:this.b=Object.assign(d[b-1],d[b]);break;case 44:case 46:this.b=ab(d[b]);break;case 45:case 47:this.b="";break;case 48:this.b=e("mrow");break;case 49:this.b=db(d[b-1]);break;case 50:case 54:this.b=e("mo",d[b],{maxsize:"1.2em",minsize:"1.2em"});break;case 51:case 55:this.b=e("mo",d[b],{maxsize:"1.8em",minsize:"1.8em"});break;case 52:case 56:this.b=e("mo",d[b],{maxsize:"2.4em",minsize:"2.4em"});break;case 53:case 57:this.b=e("mo",d[b],{maxsize:"3em",minsize:"3em"});
+break;case 58:this.b=e("mrow",[d[b-2],db(d[b-1]),d[b]]);break;case 59:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])],{linethickness:"0px"});break;case 60:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])],{linethickness:"0px"});this.b=e("mrow",[d[b-4],this.b,d[b]]);break;case 61:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])]);break;case 62:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])]);this.b=e("mrow",[d[b-4],this.b,d[b]]);break;case 63:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])],{linethickness:"0px"});this.b=e("mrow",[ab("("),
+this.b,ab(")")]);break;case 64:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])],{linethickness:"0px"});this.b=e("mrow",[d[b-4],this.b,d[b]]);this.b=e("mrow",[ab("("),this.b,ab(")")]);break;case 65:case 74:this.b=e("mn",d[b]);break;case 66:case 83:case 85:this.b=e("mtext",d[b]);break;case 67:case 68:case 69:case 70:this.b=Ub(d[b]);break;case 71:this.b=Ub(d[b],tb);break;case 72:case 177:this.b=ab(d[b],0,0);break;case 73:this.b=e("mi",d[b]);break;case 75:case 76:case 77:case 176:this.b=ab(d[b]);break;case 78:case 79:case 80:this.b=
+e("mo",d[b],{stretchy:"false"});break;case 81:this.b=e("ms",d[b]);break;case 82:this.b=e("ms",d[b],{lquote:d[b-2],rquote:d[b-1]});break;case 84:this.b=e("mtext",d[b-1]+d[b]);break;case 86:this.b=ab(d[b],0,sb("thinmathspace"));break;case 87:this.b=ab(d[b],sb("thinmathspace"),sb("thinmathspace"));break;case 88:this.b=ab(d[b],sb("mediummathspace"),sb("mediummathspace"));break;case 89:this.b=ab(d[b],sb("thickmathspace"),sb("thickmathspace"));break;case 90:this.b=e("mfrac",[d[b-1],d[b]]);break;case 91:this.b=
+e("mroot",[d[b],d[b-1]]);break;case 92:this.b=e("msqrt",[d[b]]);break;case 93:this.b=e("mroot",[d[b],db(d[b-2])]);break;case 94:this.b=e("munder",[d[b],d[b-1]]);break;case 95:this.b=e("mover",[d[b],d[b-1]]);break;case 96:this.b=e("munderover",[d[b],d[b-2],d[b-1]]);break;case 97:this.b="mrow"===d[b].tag&&!d[b].content&&!d[b].attributes?e("munder",[ab(d[b-4]),db(d[b-2])]):e("munderover",[ab(d[b-4]),db(d[b-2]),d[b]]);break;case 98:this.b=e("mover",[ab(d[b-1]),d[b]]);break;case 99:this.b=e("mpadded",
+[d[b]],{width:"0em"});break;case 100:this.b=e("mpadded",[d[b]],{width:"0em",lspace:"-100%width"});break;case 101:this.b=e("mpadded",[d[b]],{width:"0em",lspace:"-50%width"});break;case 102:this.b=e("mphantom",[d[b]]);break;case 103:this.b=e("mfrac",[d[b-1],d[b]]);this.b=db([this.b],"mstyle",{displaystyle:"false"});break;case 104:this.b=e("mfrac",[d[b-1],d[b]],{linethickness:"0px"});this.b=e("mrow",[ab("("),this.b,ab(")")]);break;case 105:this.b=e("mfrac",[d[b-1],d[b]],{linethickness:"0px"});this.b=
+db([this.b],"mstyle",{displaystyle:"false"});this.b=e("mrow",[ab("("),this.b,ab(")")]);break;case 106:this.b=e("mrow",[ab("(",sb("mediummathspace")),ab("mod",nb,sb("thinmathspace")),d[b],ab(")",nb,sb("mediummathspace"))]);break;case 107:this.b=e("munder",[d[b],ab("âŸ")]);break;case 108:this.b=e("munder",[d[b],ab("_")]);break;case 109:this.b=e("mover",[d[b],ab("âž")]);break;case 110:this.b=e("mover",[d[b],ab(d[b-1])]);break;case 111:this.b=e("mover",[d[b],e("mo",d[b-1],{stretchy:"false"})]);break;case 112:this.b=
+e("menclose",[d[b]],{notation:"box"});break;case 113:this.b=e("menclose",[d[b]],{notation:"updiagonalstrike"});break;case 114:this.b=Gb(1);break;case 115:this.b=Gb(2);break;case 116:this.b=Gb(sb("negativethinmathspace"));break;case 117:this.b=Gb(sb("negativemediummathspace"));break;case 118:this.b=Gb(sb("negativethickmathspace"));break;case 119:this.b=Gb(sb("thinmathspace"));break;case 120:this.b=Gb(sb("mediummathspace"));break;case 121:this.b=Gb(sb("thickmathspace"));break;case 122:this.b=e("mspace",
+xb,{height:"."+d[b-2]+"ex",depth:"."+d[b-1]+"ex",width:"."+d[b]+"em"});break;case 123:this.b=e("mpadded",[d[b]],{voffset:d[b-3].i+d[b-3].k,height:d[b-2].i+d[b-2].k,depth:d[b-1].i+d[b-1].k});break;case 124:this.b=e("mpadded",[d[b]],{voffset:d[b-2].i+d[b-2].k,height:d[b-1].i+d[b-1].k,depth:0>d[b-2].i?"+"+-d[b-2].i+d[b-2].k:"depth"});break;case 125:$a={voffset:d[b-1].i+d[b-1].k};0<=d[b-1].i?$a.height="+"+d[b-1].i+d[b-1].k:($a.height="0pt",$a.depth="+"+-d[b-1].i+d[b-1].k);this.b=e("mpadded",[d[b]],$a);
+break;case 126:this.b=db([d[b]],"mstyle",{mathvariant:"double-struck"});break;case 127:this.b=db([d[b]],"mstyle",{mathvariant:"bold"});break;case 128:this.b=db([d[b]],"mstyle",{mathvariant:"bold-italic"});break;case 129:this.b=db([d[b]],"mstyle",{mathvariant:"script"});break;case 130:this.b=db([d[b]],"mstyle",{mathvariant:"bold-script"});break;case 131:this.b=db([d[b]],"mstyle",{mathvariant:"sans-serif"});break;case 132:this.b=db([d[b]],"mstyle",{mathvariant:"fraktur"});break;case 133:this.b=db([d[b]],
+"mstyle",{mathvariant:"italic"});break;case 134:this.b=db([d[b]],"mstyle",{mathvariant:"monospace"});break;case 135:this.b=db([d[b]],"mstyle",{mathvariant:"normal"});break;case 136:this.b=e("mrow",[d[b]],$a.v?xb:{href:d[b-1]});break;case 137:this.b=$a.v?d[b]:e("maction",[d[b],e("mtext",d[b-1])],{actiontype:"statusline"});break;case 138:this.b=$a.v?d[b]:e("maction",[d[b],e("mtext",d[b-1])],{actiontype:"tooltip"});break;case 139:this.b=$a.v?d[b]:e("maction",[d[b-1],d[b]],{actiontype:"toggle",selection:"2"});
+break;case 140:this.b=$a.v?e("mrow",d[b-1]):e("maction",d[b-1],{actiontype:"toggle"});break;case 141:case 144:this.b=e("mmultiscripts",[d[b-3]].concat(d[b-1]));break;case 142:this.b=e("mmultiscripts",[d[b-3]].concat(d[b-1]).concat(e("mprescripts")).concat(d[b-5]));break;case 143:this.b=e("mmultiscripts",[d[b-2],e("mprescripts")].concat(d[b-4]));break;case 145:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});break;case 146:this.b=e("mtable",d[b-1],{displaystyle:"true",rowspacing:"1.0ex"});
+break;case 147:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("("),this.b,ab(")")]);break;case 148:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("["),this.b,ab("]")]);break;case 149:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("|"),this.b,ab("|")]);break;case 150:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("{"),this.b,ab("}")]);break;
+case 151:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("‖"),this.b,ab("‖")]);break;case 152:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=db([this.b],"mstyle",{scriptlevel:"2"});break;case 153:this.b=e("mtable",d[b-1],{displaystyle:"false",columnalign:"left left"});this.b=e("mrow",[ab("{"),this.b]);break;case 154:this.b=e("mtable",d[b-1],{displaystyle:"true",columnalign:"right left right left right left right left right left",
+columnspacing:"0em"});break;case 155:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex",align:d[b-3],columnalign:d[b-2]});break;case 156:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex",columnalign:d[b-2]});break;case 157:this.b=e("mtable",d[b-1],{displaystyle:"false",columnalign:"center",rowspacing:"0.5ex"});break;case 158:this.b=e("mtable",d[b-1],{displaystyle:"false"});break;case 159:this.b=e("mtable",d[b-1],Object.assign(d[b-3],{displaystyle:"false"}));break;case 160:this.b=
+[d[b]];break;case 161:this.b=d[b-1].concat([d[b]]);break;case 162:this.b=e("mmultiscripts",[d[b-1]].concat(d[b]));break;case 163:this.b=e("msubsup",[d[b-4],d[b-2],d[b]]);break;case 164:this.b=e("msubsup",[d[b-3],d[b-1],ab(d[b])]);break;case 165:this.b=e("msubsup",[d[b-4],d[b],d[b-2]]);break;case 166:this.b=e("msubsup",[d[b-3],d[b],ab(d[b-2])]);break;case 167:this.b=e("msub",[d[b-2],d[b]]);break;case 168:this.b=e("msup",[d[b-2],d[b]]);break;case 169:this.b=e("msup",[d[b-1],ab(d[b])]);break;case 171:this.b=
+e("munderover",[d[b-4],d[b-2],d[b]]);break;case 172:this.b=e("munderover",[d[b-4],d[b],d[b-2]]);break;case 173:this.b=e("munder",[d[b-2],d[b]]);break;case 174:this.b=e("mover",[d[b-2],d[b]]);break;case 178:case 200:case 204:this.b=[d[b]];break;case 179:this.b=d[b-1].concat([d[b]]);break;case 182:this.b=[d[b-2],d[b]];break;case 183:this.b=[d[b],e("none")];break;case 184:case 185:this.b=[e("none"),d[b]];break;case 187:this.b=d[b-1].concat(d[b]);break;case 188:this.b={displaystyle:"true"};break;case 189:this.b=
+{displaystyle:"false"};break;case 190:this.b={scriptlevel:"0"};break;case 191:this.b={scriptlevel:"1"};break;case 192:this.b={scriptlevel:"2"};break;case 193:this.b={mathcolor:d[b]};break;case 194:this.b={mathbackground:d[b]};break;case 195:this.b=[db(d[b],"mstyle",d[b-1])];break;case 197:this.b=e("mtd",[]);break;case 198:this.b=db(d[b],"mtd",d[b-2]);break;case 199:this.b=db(d[b],"mtd");break;case 201:case 205:this.b=d[b-2].concat([d[b]]);break;case 202:this.b=this.b=e("mtr",d[b],d[b-2]);break;case 203:this.b=
+e("mtr",d[b]);break;case 206:return this.b=d[b-1];case 208:this.b=d[b-1]+d[b];break;case 210:this.b=Pb(d[b]);break;case 211:this.b=Ib([e("mrow")],yb,yb,$a.t);break;case 212:this.b=Ib(d[b-1],yb,yb,$a.t);break;case 213:this.b=Ib([e("mrow")],tb,yb,$a.t);break;case 214:this.b=Ib(d[b-1],tb,yb,$a.t);break;case 215:this.b=Ib(d[b-1],yb,yb,$a.t);break;case 216:this.b=Ib(d[b-1],tb,yb,$a.t)}},ma:[{68:Wb,193:1,194:2,196:3,197:5,198:Xb,200:Yb,202:Zb,204:$b},{1:[3]},{68:Wb,195:[1,10],196:11,197:5,198:Xb,200:Yb,
+202:Zb,204:$b},c(Ab,[2,207]),c(Ab,[2,209]),c(Ab,[2,210]),{8:m,48:36,49:s,50:v,51:x,54:25,55:13,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,199:[1,12]},{8:m,48:36,49:s,50:v,51:x,54:25,55:126,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,
+101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,201:[1,125]},{8:m,48:36,49:s,50:v,51:x,54:25,55:127,56:h,
+57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,
+153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:128,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,
+117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{1:[2,206]},c(Ab,[2,208]),c(Ab,[2,211]),{199:[1,129]},{8:m,48:36,49:s,50:v,51:x,54:25,55:130,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,
+74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,
+173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},c(ac,[2,196],{54:25,172:26,48:36,168:131,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,
+124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb}),c(Db,[2,188]),c(Db,[2,189]),c(Db,[2,190]),c(Db,[2,191]),c(Db,[2,192]),{7:133,8:mb,14:132},{7:133,8:mb,14:135},c(ub,[2,178]),{8:m,48:36,49:s,50:v,51:x,54:136,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,
+87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,170],{169:[1,138],170:[1,139],171:[1,140]}),c(ub,[2,175],{169:[1,
+141],170:[1,142]}),{8:m,10:[1,143],48:36,49:s,50:v,51:x,54:25,55:144,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,
+134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{50:[1,145]},{50:[1,146]},{50:[1,147]},{50:[1,148]},{50:[1,149]},{50:[1,150]},{50:[1,151]},{50:[1,152]},{8:m,48:36,49:s,50:v,51:x,54:25,55:153,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,
+88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,
+184:kb,185:lb},c(g,[2,65]),c(g,[2,66]),c(g,[2,67]),c(g,[2,68]),c(g,[2,69]),c(g,[2,70]),c(g,[2,71]),c(g,[2,72]),{7:155,8:mb,15:154},{7:155,8:mb,15:156},{7:155,8:mb,15:157},c(g,[2,76]),c(g,[2,77]),c(g,[2,78]),c(g,[2,79]),c(g,[2,80]),{3:160,4:Sb,7:155,8:mb,13:159,15:158},{7:155,8:mb,15:162},{84:[1,163]},c(g,[2,85]),{7:164,8:mb},{7:165,8:mb},{7:166,8:mb},{7:167,8:mb},{8:m,48:36,49:s,50:v,51:x,54:168,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,
+80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:169,56:h,57:q,
+58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,
+155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{4:[1,171],8:m,48:36,49:s,50:v,51:x,54:170,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:172,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,
+117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:173,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,
+104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:174,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,
+87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{4:[1,175],8:m,48:36,49:s,50:v,51:x,54:176,56:h,57:q,58:p,59:y,60:f,61:u,
+62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,
+161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:177,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,
+135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:178,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,
+122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:179,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,
+109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:180,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,
+94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:181,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,
+74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,
+49:s,50:v,51:x,54:182,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,
+145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:183,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,
+127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:184,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,
+114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:185,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,
+101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:186,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,
+81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:187,56:h,57:q,58:p,
+59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,
+157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:188,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,
+133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:189,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,
+120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:190,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,
+107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:191,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,
+91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,114]),c(g,[2,115]),c(g,[2,116]),c(g,[2,117]),c(g,[2,118]),c(g,[2,119]),c(g,[2,120]),
+c(g,[2,121]),{7:192,8:mb},{8:[1,194],12:193},{8:m,48:36,49:s,50:v,51:x,54:195,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,
+132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:196,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,
+119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:197,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,
+106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:198,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,
+89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:199,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,
+69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,
+166:Ya},{8:m,48:36,49:s,50:v,51:x,54:200,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,
+141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:201,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,
+125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:202,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,
+112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:203,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,
+98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:204,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,
+78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{7:133,8:mb,14:205},{7:206,8:mb},
+{7:207,8:mb},{8:m,48:36,49:s,50:v,51:x,54:208,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,
+139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:210,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,
+124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,137:209,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:[1,211]},c([144,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,143:212,190:213,188:215,186:216,55:218,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,
+94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([146,189,192],pb,{178:14,175:15,
+168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:219,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,
+131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([148,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:220,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,
+92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([150,189,192],pb,
+{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:221,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,
+129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([152,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:222,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,
+89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([154,
+189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:223,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,
+128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([156,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:224,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,
+88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),
+c([158,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:225,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,
+127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([160,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:226,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,
+86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,
+191:vb}),c([162,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:227,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,
+125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),{3:230,4:Sb,7:231,8:mb,16:228,17:229},{8:[1,232]},{8:[1,233]},c(Eb,[2,176]),c(Eb,[2,177]),{50:[1,234],51:[1,235]},c(Ab,[2,213]),{201:[1,236]},{203:[1,237]},{205:[1,238]},c(Ab,[2,212]),c(ac,[2,195]),c(ub,[2,179]),c(Db,[2,193]),c([8,10,
+19,21,23,25,27,29,31,33,35,37,39,41,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,145,147,149,151,153,155,157,159,161,163,165,166,173,174,179,180,181,182,183,184,185],[2,6]),{9:[1,239]},c(Db,[2,194]),{8:bc,140:240,169:Bb,170:Cb,177:242},{8:m,48:36,49:s,50:v,
+51:x,54:245,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,
+149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:246,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,
+129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:247,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,
+116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,169],{169:[1,248]}),{8:m,48:36,49:s,50:v,51:x,54:249,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,
+98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:250,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,
+78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,48]),{10:[1,251],64:[1,252],
+65:[1,253],66:[1,254]},c(g,[2,50]),c(g,[2,51]),c(g,[2,52]),c(g,[2,53]),c(g,[2,54]),c(g,[2,55]),c(g,[2,56]),c(g,[2,57]),{52:255,53:Mb,64:[1,256],65:[1,257],66:[1,258]},c(g,[2,73]),c(g,[2,7]),c(g,[2,74]),c(g,[2,75]),c(g,[2,81]),{3:160,4:Sb,13:260},c(cc,[2,5]),{5:[1,261]},c(g,[2,83]),c(g,[2,84]),c(g,[2,86]),c(g,[2,87]),c(g,[2,88]),c(g,[2,89]),{8:m,48:36,49:s,50:v,51:x,54:262,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,
+86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:263,56:h,57:q,58:p,59:y,60:f,61:u,62:n,
+63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,
+161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,92]),{8:m,48:36,49:s,50:v,51:x,54:25,55:264,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,
+132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:265,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,
+106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:266,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,
+89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:267,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,
+69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,
+166:Ya},{8:m,48:36,49:s,50:v,51:x,54:25,55:268,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,
+139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},c(g,[2,98]),c(g,[2,99]),c(g,[2,100]),c(g,[2,101]),c(g,[2,102]),{8:m,48:36,49:s,50:v,51:x,54:269,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,
+102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:270,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,
+83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:271,56:h,57:q,58:p,59:y,60:f,
+61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,
+159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,106]),c(g,[2,107]),c(g,[2,108]),c(g,[2,109]),c(g,[2,110]),c(g,[2,111]),c(g,[2,112]),c(g,[2,113]),{7:272,8:mb},{4:dc,8:m,11:273,48:36,49:s,50:v,51:x,54:274,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,
+115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{9:[1,276]},c(g,[2,126]),c(g,[2,127]),c(g,[2,128]),c(g,[2,129]),c(g,[2,130]),c(g,[2,131]),c(g,[2,132]),c(g,[2,133]),c(g,[2,134]),c(g,[2,135]),{8:m,48:36,49:s,50:v,51:x,54:277,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,
+73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,
+49:s,50:v,51:x,54:278,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,
+145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:279,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,
+127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:280,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,
+114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:282,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,
+101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,138:[1,281],139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ec,[2,160]),{10:[1,284],140:283,169:Bb,170:Cb,177:242},{144:[1,285],192:wb},c(Nb,[2,204]),{8:[1,287]},c(Nb,[2,203],{189:fc}),c(Jb,
+[2,200]),{8:[1,289]},c(Jb,[2,199]),{146:[1,290],192:wb},{148:[1,291],192:wb},{150:[1,292],192:wb},{152:[1,293],192:wb},{154:[1,294],192:wb},{156:[1,295],192:wb},{158:[1,296],192:wb},{160:[1,297],192:wb},{162:[1,298],192:wb},{7:231,8:mb,17:299},c(gc,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:300,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,
+90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),{8:[2,8]},
+c([8,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,145,147,149,151,153,155,157,159,161,163,164,165,166,173,174,179,180,181,182,183,184,185,187,189,191,192],[2,9]),c(Tb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:301,8:m,49:s,
+50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,
+149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c(Tb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:302,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,
+107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,167:[1,303],173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c(Db,[2,44]),c(Db,[2,45]),c(Ab,[2,214]),c(Ab,[2,215]),c(Ab,[2,216]),{10:[1,304]},c(ub,[2,162],{177:305,
+169:Bb,170:Cb}),{140:306,169:Bb,170:Cb,177:242},c(Eb,[2,186]),{8:m,48:36,49:s,50:v,51:x,54:309,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,170:[1,308],172:310,173:bb,174:cb,176:307},{8:m,48:36,49:s,50:v,51:x,54:309,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,
+111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,172:310,173:bb,174:cb,176:311},{8:bc},c(ub,[2,167],{170:[1,312],171:[1,313]}),c(ub,[2,168],{169:[1,314]}),{8:m,48:36,49:s,50:v,51:x,54:315,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,
+74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,173],
+{170:[1,316]}),c(ub,[2,174],{169:[1,317]}),c(g,[2,49]),{8:m,48:36,49:s,50:v,51:x,54:25,55:318,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:319,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,
+103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:320,56:h,57:q,58:p,59:y,60:f,61:u,
+62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,
+161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},c(g,[2,58]),{8:m,48:36,49:s,50:v,51:x,54:25,55:321,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,
+119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:322,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,
+88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,
+184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:323,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,
+136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{50:[1,324],51:[1,325]},{7:155,8:mb,15:326},{6:[1,327]},c(g,[2,90]),c(g,[2,91]),{6:[1,328]},c(g,[2,94]),c(g,[2,95]),{8:m,48:36,49:s,50:v,51:x,54:329,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,
+90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{6:[1,330]},c(g,[2,103]),c(g,[2,104]),c(g,[2,105]),{7:331,8:mb},{4:dc,8:m,11:332,48:36,
+49:s,50:v,51:x,54:333,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,
+145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,125]),{5:[1,334]},{10:[1,335]},c(g,[2,136]),c(g,[2,137]),c(g,[2,138]),c(g,[2,139]),c(g,[2,140]),c(ec,[2,161]),{10:[1,336],169:Bb,170:Cb,177:305},{8:m,48:36,49:s,50:v,51:x,54:337,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,
+105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,145]),c(Jb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,188:215,186:216,55:218,190:338,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,
+71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,
+173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),{20:341,21:Kb,22:342,23:Lb,44:340,47:339},c(Jb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,55:218,186:345,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,
+114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb}),{20:348,21:Kb,22:349,23:Lb,24:350,25:hc,26:351,27:ic,42:347,43:346},c(g,[2,146]),c(g,[2,147]),c(g,[2,148]),c(g,[2,149]),c(g,[2,150]),c(g,[2,151]),c(g,[2,152]),c(g,
+[2,153]),c(g,[2,154]),c(gc,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:354,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,
+125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),{164:[1,355],192:wb},{10:[1,356],192:wb},{10:[1,357],192:wb},{8:[1,358]},c([6,8,10,19,21,23,25,27,29,31,33,35,37,39,41,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,
+91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,138,139,141,142,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,169,170,171,173,174,179,180,181,182,183,184,185,187,189,191,192,199,201,203,205],[2,2]),c(Eb,[2,187]),{10:[1,359],169:Bb,170:Cb,177:305},c([6,8,10,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,
+76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,169,173,174,189,192,199,201,203,205],[2,183],{170:[1,360]}),{8:m,48:36,49:s,50:v,51:x,54:309,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,
+77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,172:310,173:bb,174:cb,176:361},
+c(Eb,[2,180]),c(Eb,[2,181]),c(Eb,[2,184]),{8:m,48:36,49:s,50:v,51:x,54:362,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,
+133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,164]),{8:m,48:36,49:s,50:v,51:x,54:363,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,
+118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,166]),{8:m,48:36,49:s,50:v,51:x,54:364,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,
+103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:365,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,
+85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{10:[1,366]},{10:[1,367]},{10:[1,368]},{52:369,53:Mb},{52:370,
+53:Mb},{52:371,53:Mb},c(g,[2,46]),c(g,[2,47]),c(g,[2,82]),c(cc,[2,1]),{8:m,48:36,49:s,50:v,51:x,54:372,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,
+129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,96]),{8:m,48:36,49:s,50:v,51:x,54:373,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,
+114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,122]),{8:m,48:36,49:s,50:v,51:x,54:374,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,
+98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,124]),{6:[1,375]},c(jc,[2,4]),{8:m,48:36,49:s,50:v,51:x,54:376,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,
+71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},
+{8:[1,377]},c(Nb,[2,205]),{10:[1,378],20:341,21:Kb,22:342,23:Lb,44:379},c(Ob,[2,42]),c(Ob,[2,28]),c(Ob,[2,29]),{7:133,8:mb,14:380},{7:133,8:mb,14:381},c(Jb,[2,201]),{10:[1,382],20:348,21:Kb,22:349,23:Lb,24:350,25:hc,26:351,27:ic,42:383},c(Hb,[2,26]),c(Hb,[2,22]),c(Hb,[2,23]),c(Hb,[2,24]),c(Hb,[2,25]),{7:133,8:mb,14:384},{7:133,8:mb,14:385},{164:[1,386],192:wb},c(g,[2,156]),c(g,[2,157]),c(g,[2,158]),{18:389,19:kc,20:390,21:Kb,22:391,23:Lb,28:392,29:lc,30:393,31:mc,32:394,33:nc,34:395,35:oc,36:396,
+37:pc,38:397,39:qc,40:398,41:rc,45:388,46:387},c(g,[2,141]),{8:m,48:36,49:s,50:v,51:x,54:309,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,172:310,173:bb,174:cb,176:407},c(Eb,[2,185]),c(ub,[2,163]),c(ub,[2,165]),c(ub,[2,171]),c(ub,[2,172]),c(g,[2,59]),c(g,[2,61]),c(g,[2,63]),c(g,[2,60]),c(g,[2,62]),c(g,[2,64]),c(g,[2,93]),c(g,[2,97]),c(g,[2,123]),c(jc,[2,3]),{8:[1,408]},{140:409,169:Bb,170:Cb,177:242},c(Jb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,186:216,55:218,188:410,8:m,49:s,
+50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,
+149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb}),c(Ob,[2,43]),c(sc,[2,11]),c(sc,[2,12]),{8:m,48:36,49:s,50:v,51:x,54:25,55:411,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,
+112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},c(Hb,[2,27]),c(Hb,[2,13]),c(Hb,[2,14]),c(g,[2,155]),{10:[1,412],18:389,19:kc,20:390,21:Kb,22:391,23:Lb,28:392,29:lc,30:393,31:mc,
+32:394,33:nc,34:395,35:oc,36:396,37:pc,38:397,39:qc,40:398,41:rc,45:413},c(ob,[2,40]),c(ob,[2,30]),c(ob,[2,31]),c(ob,[2,32]),c(ob,[2,33]),c(ob,[2,34]),c(ob,[2,35]),c(ob,[2,36]),c(ob,[2,37]),c(ob,[2,38]),c(ob,[2,39]),{7:133,8:mb,14:414},{7:133,8:mb,14:415},{7:133,8:mb,14:416},{7:133,8:mb,14:417},{7:133,8:mb,14:418},{7:133,8:mb,14:419},{7:133,8:mb,14:420},{7:133,8:mb,14:421},c(Eb,[2,182]),{10:[1,423],140:422,169:Bb,170:Cb,177:242},{10:[1,424],169:Bb,170:Cb,177:305},c(Nb,[2,202],{189:fc}),c(Jb,[2,198]),
+c(Tb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:425,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,
+128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c(ob,[2,41]),c(ob,[2,10]),c(ob,[2,15]),c(ob,[2,16]),c(ob,[2,17]),c(ob,[2,18]),c(ob,[2,19]),c(ob,[2,20]),c(ob,[2,21]),{10:[1,426],169:Bb,170:Cb,177:305},c(g,[2,143]),c(g,[2,144]),{10:[1,427],192:wb},c(g,[2,142]),c(g,[2,159])],N:{10:[2,206],230:[2,8]},parseError:function(b,
+a){if(a.va)this.trace(b);else{var c=Error(b);c.hash=a;throw c;}},parse:function(b){var a=[0],c=[xb],e=[],g=this.ma,d="",m=0,s=0,v=0,x=e.slice.call(arguments,1),h=Object.create(this.S),q={},p;for(p in this.e)Object.prototype.hasOwnProperty.call(this.e,p)&&(q[p]=this.e[p]);h.ga(b,q);q.S=h;q.V=this;"undefined"==typeof h.c&&(h.c={});p=h.c;e.push(p);var y=h.options&&h.options.w;this.parseError="function"===typeof q.parseError?q.parseError:Object.getPrototypeOf(this).parseError;for(var f,u,n,k,r={},t,l;;){n=
+a[a.length-1];if(this.N[n])k=this.N[n];else{if(f===xb||"undefined"==typeof f)f=nb,f=h.R()||1,"number"!==typeof f&&(f=this.la[f]||f);k=g[n]&&g[n][f]}if("undefined"===typeof k||!k.length||!k[0]){var w="";l=[];for(t in g[n])this.z[t]&&2<t&&l.push("'"+this.z[t]+"'");w=h.D?"Parse error on line "+(m+1)+":\n"+h.D()+"\nExpecting "+l.join(", ")+", got '"+(this.z[f]||f)+"'":"Parse error on line "+(m+1)+": Unexpected "+(1==f?"end of input":"'"+(this.z[f]||f)+"'");this.parseError(w,{text:h.match,$:this.z[f]||
+f,T:h.f,ta:p,qa:l})}if(k[0]instanceof Array&&1<k.length)throw Error("Parse Error: multiple actions possible at state: "+n+", token: "+f);switch(k[0]){case 1:a.push(f);c.push(h.a);e.push(h.c);a.push(k[1]);f=xb;u?(f=u,u=xb):(s=h.q,d=h.a,m=h.f,p=h.c,0<v&&v--);break;case 2:l=this.W[k[1]][1];r.b=c[c.length-l];r.K={r:e[e.length-(l||1)].r,o:e[e.length-1].o,l:e[e.length-(l||1)].l,m:e[e.length-1].m};y&&(r.K.n=[e[e.length-(l||1)].n[0],e[e.length-1].n[1]]);n=this.H.apply(r,[d,s,m,q,k[1],c,e].concat(x));if("undefined"!==
+typeof n)return n;l&&(a=a.slice(0,-2*l),c=c.slice(0,-1*l),e=e.slice(0,-1*l));a.push(this.W[k[1]][0]);c.push(r.b);e.push(r.K);k=g[a[a.length-2]][a[a.length-1]];a.push(k);break;case 3:return tb}}return tb}},Qb="http://www.w3.org/1998/Math/MathML",wc="TeX LaTeX text/x-tex text/x-latex application/x-tex application/x-latex".split(" ");try{rb.C=new DOMParser}catch(yc){rb.C={parseFromString:function(){throw"DOMParser undefined. Did you call TeXZilla.setDOMParser?";}}}rb.fa=function(b){this.C=b};try{rb.G=
+new XMLSerializer}catch(zc){rb.G={serializeToString:function(){throw"XMLSerializer undefined. Did you call TeXZilla.setXMLSerializer?";}}}rb.ja=function(b){this.G=b};rb.U=function(b){return this.C.parseFromString(b,"application/xml").documentElement};rb.ia=function(b){this.e.v=b};rb.ha=function(b){this.e.da=b};rb.ca=function(b){"string"===typeof b&&(b=this.U(b));return Vb(b)};rb.Z=function(b,a,c,g){var f;try{f=this.parse("\\("+b+"\\)"),c&&(f=f.replace(/^<math/,'<math dir="rtl"')),a&&(f=f.replace(/^<math/,
+'<math display="block"'))}catch(d){if(g)throw d;f=Pb(Ib([e("merror",[e("mtext",Fb(d.message))])],a,c,b))}return f};rb.Y=function(b,a,c,e){return this.U(this.Z(b,a,c,e))};rb.na=function(b,a,c,e,f){var d,g;e===nb&&(e=64);f===nb&&(f=window.document);a=this.Y(b,tb,a);a.setAttribute("mathsize",e+"px");e=document.createElement("div");e.style.visibility="hidden";e.style.position="absolute";e.appendChild(a);f.body.appendChild(e);d=a.getBoundingClientRect();f.body.removeChild(e);e.removeChild(a);c?(c=Math.pow(2,
+Math.ceil(Math.log(d.width)/Math.LN2)),f=Math.pow(2,Math.ceil(Math.log(d.height)/Math.LN2))):(c=Math.ceil(d.width),f=Math.ceil(d.height));g=document.createElementNS("http://www.w3.org/2000/svg","svg");g.setAttribute("width",c+"px");g.setAttribute("height",f+"px");e=document.createElementNS("http://www.w3.org/2000/svg","g");e.setAttribute("transform","translate("+(c-d.width)/2+","+(f-d.height)/2+")");g.appendChild(e);e=document.createElementNS("http://www.w3.org/2000/svg","foreignObject");e.setAttribute("width",
+d.width);e.setAttribute("height",d.height);e.appendChild(a);g.firstChild.appendChild(e);a=new Image;a.src="data:image/svg+xml;base64,"+window.btoa(xc(this.G.serializeToString(g)));a.width=c;a.height=f;a.alt=Fb(b);return a};rb.Q=function(b,a){try{return this.parse(b)}catch(c){if(a)throw c;return b}};rb.P=function(b,a){var c,e,f;for(f=b.firstChild;f;f=f.nextSibling)switch(f.nodeType){case 1:this.P(f,a);break;case 3:this.e.O=tb;c=this.C.parseFromString("<root>"+zb.Q(f.data,a)+"</root>","application/xml").documentElement;
+for(this.e.O=yb;e=c.firstChild;)b.insertBefore(c.removeChild(e),f);e=f.previousSibling;b.removeChild(f);f=e}};rb.S=function(){return{J:1,parseError:function(b,a){if(this.e.V)this.e.V.parseError(b,a);else throw Error(b);},ga:function(b,a){this.e=a||this.e||{};this.g=b;this.u=this.B=this.s=yb;this.f=this.q=0;this.a=this.h=this.match="";this.d=["INITIAL"];this.c={r:1,l:0,o:1,m:0};this.options.w&&(this.c.n=[0,0]);this.offset=0;return this},input:function(){var b=this.g[0];this.a+=b;this.q++;this.offset++;
+this.match+=b;this.h+=b;b.match(/(?:\r\n?|\n).*/g)?(this.f++,this.c.o++):this.c.m++;this.options.w&&this.c.n[1]++;this.g=this.g.slice(1);return b},I:function(b){var a=b.length,c=b.split(/(?:\r\n?|\n)/g);this.g=b+this.g;this.a=this.a.substr(0,this.a.length-a);this.offset-=a;b=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1);this.h=this.h.substr(0,this.h.length-1);c.length-1&&(this.f-=c.length-1);var e=this.c.n;this.c={r:this.c.r,o:this.f+1,l:this.c.l,m:c?(c.length===
+b.length?this.c.l:0)+b[b.length-c.length].length-c[0].length:this.c.l-a};this.options.w&&(this.c.n=[e[0],e[0]+this.q-a]);this.q=this.a.length;return this},ua:function(){this.u=tb;return this},wa:function(){if(this.options.L)this.B=tb;else return this.parseError("Lexical error on line "+(this.f+1)+". You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).\n"+this.D(),{text:"",$:xb,T:this.f});return this},sa:function(b){this.I(this.match.slice(b))},
+ea:function(){var b=this.h.substr(0,this.h.length-this.match.length);return(20<b.length?"...":"")+b.substr(-20).replace(/\n/g,"")},oa:function(){var b=this.match;20>b.length&&(b+=this.g.substr(0,20-b.length));return(b.substr(0,20)+(20<b.length?"...":"")).replace(/\n/g,"")},D:function(){var b=this.ea(),a=Array(b.length+1).join("-");return b+this.oa()+"\n"+a+"^"},X:function(b,a){var c,e;this.options.L&&(e={f:this.f,c:{r:this.c.r,o:this.o,l:this.c.l,m:this.c.m},a:this.a,match:this.match,matches:this.matches,
+h:this.h,q:this.q,offset:this.offset,u:this.u,g:this.g,e:this.e,d:this.d.slice(0),s:this.s},this.options.w&&(e.c.n=this.c.n.slice(0)));if(c=b[0].match(/(?:\r\n?|\n).*/g))this.f+=c.length;this.c={r:this.c.o,o:this.f+1,l:this.c.m,m:c?c[c.length-1].length-c[c.length-1].match(/\r?\n?/)[0].length:this.c.m+b[0].length};this.a+=b[0];this.match+=b[0];this.matches=b;this.q=this.a.length;this.options.w&&(this.c.n=[this.offset,this.offset+=this.q]);this.B=this.u=yb;this.g=this.g.slice(b[0].length);this.h+=b[0];
+c=this.H.call(this,this.e,this,a,this.d[this.d.length-1]);this.s&&this.g&&(this.s=yb);if(c)return c;if(this.B)for(var f in e)this[f]=e[f];return yb},next:function(){if(this.s)return this.J;this.g||(this.s=tb);var b,a,c;this.u||(this.match=this.a="");for(var e=this.aa(),f=0;f<e.length;f++)if((a=this.g.match(this.rules[e[f]]))&&(!b||a[0].length>b[0].length))if(b=a,c=f,this.options.L){b=this.X(a,e[f]);if(b!==yb)return b;if(this.B)b=yb;else return yb}else if(!this.options.ra)break;return b?(b=this.X(b,
+e[c]),b!==yb?b:yb):""===this.g?this.J:this.parseError("Lexical error on line "+(this.f+1)+". Unrecognized text.\n"+this.D(),{text:"",$:xb,T:this.f})},R:function(){var b=this.next();return b?b:this.R()},j:function(b){this.d.push(b)},p:function(){return 0<this.d.length-1?this.d.pop():this.d[0]},aa:function(){return this.d.length&&this.d[this.d.length-1]?this.M[this.d[this.d.length-1]].rules:this.M.INITIAL.rules},ya:function(b){b=this.d.length-1-Math.abs(b||0);return 0<=b?this.d[b]:"INITIAL"},pushState:function(b){this.j(b)},
+xa:function(){return this.d.length},options:{},H:function(b,a,c){switch(c){case 0:this.I(a.a);this.pushState("DOCUMENT");break;case 1:return this.pushState("MATH"+(0+!!b.da)),b.ka=this.h.length,"STARTMATH"+(2*("$"==a.a[0])+("$"==a.a[1]||"["==a.a[1]));case 2:return this.p(),"EOF";case 3:return a.a=a.a[1],"TEXT";case 4:return b.O&&(a.a=Fb(a.a)),"TEXT";case 5:return"TEXT";case 6:return this.p(),"[";case 7:this.I(a.a);this.p();this.p();break;case 8:return"TEXTOPTARG";case 9:return this.p(),"]";case 10:return"{";
+case 11:return"TEXTARG";case 12:return this.p(),"}";case 13:return this.p(),"]";case 15:return this.p(),b.ba=this.h.length-this.match.length,b.t=this.h.substring(b.ka,b.ba),"ENDMATH"+(2*("$"==a.a[0])+("$"==a.a[1]||"]"==a.a[1]));case 16:return"{";case 17:return"}";case 18:return"^";case 19:return"_";case 20:return".";case 21:return"COLSEP";case 22:return"ROWSEP";case 23:return"NUM";case 24:return"A";case 25:return a.a="Ζ","AIUG";case 26:return a.a="ζ","AILG";case 27:return this.pushState("OPTARG"),
+this.pushState("TRYOPTARG"),a.a="⇌","XARROW";case 28:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="⇒","XARROW";case 29:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="→","XARROW";case 30:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="↦","XARROW";case 31:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="⇋","XARROW";case 32:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="⇔","XARROW";case 33:return this.pushState("OPTARG"),
+this.pushState("TRYOPTARG"),a.a="↔","XARROW";case 34:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="â‡","XARROW";case 35:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="â†","XARROW";case 36:return a.a="Ξ","AIUG";case 37:return a.a="ξ","AILG";case 38:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="↪","XARROW";case 39:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="↩","XARROW";case 40:return a.a="≀","OP";case 41:return a.a="℘","A";case 42:return a.a=
+"⇀","ACCENT";case 43:return a.a="Ëœ","ACCENT";case 44:return a.a="^","ACCENT";case 45:return a.a="ˇ","ACCENT";case 46:return a.a="¯","ACCENT";case 47:return a.a="≙","OP";case 48:return a.a="â‹€","OPM";case 49:return a.a="∧","OP";case 50:return a.a="⦀","OPFS";case 51:return a.a="⊪","OP";case 52:return a.a="‖","OPFS";case 53:return a.a="|","OPFS";case 54:return a.a="⊻","OP";case 55:return a.a="â‹","OPM";case 56:return a.a="∨","OP";case 57:return a.a="⇀","ACCENTNS";case 58:return a.a="â‹®","OP";case 59:return a.a=
+"⊫","OP";case 60:return a.a="⊩","OP";case 61:return a.a="⊨","OP";case 62:return a.a="⊢","OP";case 63:return a.a="â««","OP";case 64:return a.a="⊳","OP";case 65:return a.a="⊲","OP";case 66:return a.a="â–µ","OP";case 67:return a.a="Ï‘","AILG";case 68:return a.a="⫌︀","OP";case 69:return a.a="⊋︀","OP";case 70:return a.a="⫋︀","OP";case 71:return a.a="⊊︀","OP";case 72:return a.a="⊊︀","OP";case 73:return a.a="Ï‚","A";case 74:return a.a="ϱ","AILG";case 75:return a.a="âˆ","OP";case 76:return a.a="Ï–","AILG";case 77:return a.a=
+"φ","AILG";case 78:return a.a="∅","A";case 79:return a.a="ϰ","AILG";case 80:return a.a="ε","AILG";case 81:return a.a="⤊","OPS";case 82:return a.a="⇈","OPS";case 83:return a.a="ϒ","A";case 84:return a.a="υ","AILG";case 85:return a.a="ϒ","A";case 86:return a.a="⊎","OP";case 87:return a.a="⨛","OP";case 88:return a.a="↿","OPS";case 89:return a.a="↾","OPS";case 90:return a.a="⇕","OPS";case 91:return a.a="↕","OPS";case 92:return a.a="↕","OPS";case 93:return a.a="⇑","OPS";case 94:return a.a="↑","OPS";case 95:return a.a=
+"↑","OPS";case 96:return a.a="⊵","OP";case 97:return a.a="⊴","OP";case 98:return a.a="⋃","OPM";case 99:return a.a="∪","OP";case 100:return"UNDERSET";case 101:return"UNDEROVERSET";case 102:return"UNDERLINE";case 103:return"UNDERBRACE";case 104:return a.a="⋰","OP";case 105:return"OP";case 106:return"OP";case 107:return"OP";case 108:return"OP";case 109:return"OP";case 110:return"OP";case 111:return"OP";case 112:return"OP";case 113:return"OP";case 114:return"OP";case 115:return"OP";case 116:return"OP";
+case 117:return"OP";case 118:return"OP";case 119:return"OP";case 120:return"OP";case 121:return"OP";case 122:return"OP";case 123:return"OP";case 124:return"OP";case 125:return"OP";case 126:return"OP";case 127:return"OP";case 128:return"OP";case 129:return"OP";case 130:return"OP";case 131:return"OP";case 132:return"OP";case 133:return"OP";case 134:return"OP";case 135:return"OP";case 136:return"OP";case 137:return"OP";case 138:return"OPFS";case 139:return"OPFS";case 140:return"OP";case 141:return"OP";
+case 142:return"OP";case 143:return"OP";case 144:return"OP";case 145:return"OP";case 146:return"OP";case 147:return"OP";case 148:return"OP";case 149:return"OP";case 150:return"OP";case 151:return"OP";case 152:return"OP";case 153:return"OP";case 154:return"OP";case 155:return"OP";case 156:return"OP";case 157:return"OP";case 158:return"OP";case 159:return"OP";case 160:return"OP";case 161:return a.a="⤖","OP";case 162:return a.a="↠","OPS";case 163:return a.a="↞","OPS";case 164:return a.a="∭","OP";case 165:return a.a=
+"⊵","OP";case 166:return a.a="▹","OP";case 167:return a.a="≜","OP";case 168:return a.a="⊴","OP";case 169:return a.a="◃","OP";case 170:return a.a="▿","OP";case 171:return a.a="▵","OP";case 172:return a.a="⤪","OP";case 173:return a.a="⤩","OP";case 174:return a.a="⊤","OP";case 175:return this.pushState("TEXTARG"),"TOOLTIP";case 176:return a.a="⤧","OP";case 177:return"TOGGLE";case 178:return a.a="⤨","OP";case 179:return a.a="→","OPS";case 180:return a.a="⊠","OP";case 181:return a.a="×","OP";case 182:return a.a=
+"Ëœ","ACCENTNS";case 183:return"THINSPACE";case 184:return"THICKSPACE";case 185:return a.a="∼","OP";case 186:return a.a="≈","OP";case 187:return a.a="Θ","AIUG";case 188:return a.a="θ","AILG";case 189:return a.a="∴","OP";case 190:return"TFRAC";case 191:return"TEXTSTYLE";case 192:return"TEXTSIZE";case 193:return a.a="â€","OPF";case 194:return a.a="“","OPF";case 195:return a.a="~","OPS";case 196:return a.a="`","OP";case 197:return a.a="^","OPS";case 198:return a.a="´","OP";case 199:return this.j("TEXTARG"),
+"MTEXT";case 200:return"TENSOR";case 201:return"TBINOM";case 202:return a.a="Τ","AIUG";case 203:return a.a="τ","AILG";case 204:return a.a="⇙","OPS";case 205:return a.a="↙","OPS";case 206:return a.a="⇙","OPS";case 207:return a.a="↙","OPS";case 208:return a.a="√","OPS";case 209:return a.a="⫌","OP";case 210:return a.a="⊋","OP";case 211:return a.a="⫆","OP";case 212:return a.a="⊇","OP";case 213:return a.a="⋑","OP";case 214:return a.a="⊃","OP";case 215:return a.a="∑","OPM";case 216:return a.a="≿","OP";
+case 217:return a.a="â‹©","OP";case 218:return a.a="⪶","OP";case 219:return a.a="⪺","OP";case 220:return a.a="⪰","OP";case 221:return a.a="≽","OP";case 222:return a.a="⪸","OP";case 223:return a.a="≻","OP";case 224:return"SUBSTACK";case 225:return a.a="â«‹","OP";case 226:return a.a="⊊","OP";case 227:return a.a="â«…","OP";case 228:return a.a="⊆","OP";case 229:return a.a="â‹","OP";case 230:return a.a="⊂","OP";case 231:return this.pushState("TEXTARG"),"STATUSLINE";case 232:return a.a="⋆","OP";case 233:return"OVERSET";
+case 234:return a.a="⫽","OP";case 235:return a.a="â–¡","OP";case 236:return a.a="⊒","OP";case 237:return a.a="âŠ","OP";case 238:return a.a="⊑","OP";case 239:return a.a="âŠ","OP";case 240:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),"SQRT";case 241:return a.a="⊔","OP";case 242:return a.a="⊓","OP";case 243:return a.a="∢","OP";case 244:return a.a="â™ ","OP";case 245:return this.pushState("TEXTARG"),this.pushState("TEXTARG"),this.pushState("TEXTARG"),"SPACE";case 246:return a.a="⌣","OP";case 247:return a.a=
+"⌣","OP";case 248:return a.a="∖","OP";case 249:return a.a="⌢","OP";case 250:return"SLASH";case 251:return a.a="≃","OP";case 252:return a.a="∼","OP";case 253:return a.a="Σ","AIUG";case 254:return a.a="σ","AILG";case 255:return a.a="⧢","OP";case 256:return a.a="∥","OP";case 257:return a.a="∣","OP";case 258:return a.a="♯","OP";case 259:return a.a="∖","OP";case 260:return a.a="⤭","OP";case 261:return a.a="⇘","OPS";case 262:return a.a="↘","OPS";case 263:return a.a="⇘","OPS";case 264:return a.a="↘","OPS";
+case 265:return"SCRIPTSIZE";case 266:return"SCRIPTSCRIPTSIZE";case 267:return a.a="⋊","OP";case 268:return a.a="↱","OPS";case 269:return a.a="⇛","OPS";case 270:return a.a="⟫","OPFS";case 271:return a.a="’","OPF";case 272:return this.j("TEXTARG"),"ROWSPAN";case 273:return"ROWOPTS";case 274:return this.pushState("TEXTARG"),"ROWLINES";case 275:return this.j("TEXTARG"),"ROWALIGN";case 276:return"ROOT";case 277:return a.a="⎱","OP";case 278:return a.a="≓","OP";case 279:return a.a="⟲","OP";case 280:return a.a=
+"â‹Œ","OP";case 281:return a.a="â†","OPS";case 282:return a.a="⇉","OPS";case 283:return a.a="⇌","OPS";case 284:return a.a="⇄","OPS";case 285:return a.a="⇀","OPS";case 286:return a.a="â‡","OPS";case 287:return a.a="⇾","OPS";case 288:return a.a="↣","OPS";case 289:return a.a="⇒","OPS";case 290:return a.a="→","OPS";case 291:return"RIGHT";case 292:return a.a="Ρ","AIUG";case 293:return a.a="Ï","AILG";case 294:return a.a="⊳","OP";case 295:return a.a="⌋","OPFS";case 296:return a.a="â„œ","A";case 297:return a.a=
+"⤰","OP";case 298:return a.a="⤫","OP";case 299:return a.a="⌉","OPFS";case 300:return a.a="]","OPFS";case 301:return a.a="}","OPFS";case 302:return a.a="⟩","OPFS";case 303:return a.a="⟩","OPFS";case 304:return a.a="≟","OP";case 305:return a.a="⨌","OP";case 306:return"QUAD";case 307:return"QQUAD";case 308:return a.a="â–ª","OP";case 309:return a.a="Ψ","AIUG";case 310:return a.a="ψ","AILG";case 311:return a.a="âˆ","OP";case 312:return a.a="âˆ","OPM";case 313:return a.a="âˆ","OPM";case 314:return a.a="′","OPP";
+case 315:return a.a="≾","OP";case 316:return a.a="⋨","OP";case 317:return a.a="⪵","OP";case 318:return a.a="⪹","OP";case 319:return a.a="⪯","OP";case 320:return a.a="≼","OP";case 321:return a.a="⪷","OP";case 322:return a.a="≺","OP";case 323:return"PMOD";case 324:return a.a="±","OP";case 325:return a.a="⨥","OP";case 326:return a.a="⊞","OP";case 327:return a.a="⋔","OP";case 328:return a.a="Π","AIUG";case 329:return a.a="π","AILG";case 330:return a.a="Φ","AIUG";case 331:return a.a="ϕ","AILG";case 332:return"PHANTOM";
+case 333:return a.a="⫫","OP";case 334:return a.a="⊥","OP";case 335:return a.a="⪣","OP";case 336:return a.a="∂","OP";case 337:return a.a="⅋","OP";case 338:return a.a="∥","OP";case 339:return this.pushState("TEXTARG"),"PADDING";case 340:return"OVERSET";case 341:return a.a="¯","ACCENT";case 342:return"OVERBRACE";case 343:return"TEXOVER";case 344:return a.a="⨴","OP";case 345:return a.a="⊗","OP";case 346:return a.a="⊘","OP";case 347:return"OPS";case 348:return"OPP";case 349:return"OPM";case 350:return a.a=
+"⨭","OP";case 351:return a.a="⊕","OP";case 352:return"OPFS";case 353:return"OPF";case 354:return this.j("TEXTARG"),"OPERATORNAME";case 355:return"OP";case 356:return a.a="⊖","OP";case 357:return a.a="â„´","A";case 358:return a.a="Ω","AIUG";case 359:return a.a="ω","AILG";case 360:return a.a="∮","OP";case 361:return a.a="∯","OP";case 362:return a.a="∰","OP";case 363:return a.a="⊙","OP";case 364:return a.a="âŠ","OP";case 365:return a.a="⦸","OP";case 366:return a.a="⤲","OP";case 367:return a.a="⇖","OPS";
+case 368:return a.a="↖","OPS";case 369:return a.a="⇖","OPS";case 370:return a.a="↖","OPS";case 371:return a.a="⊯","OP";case 372:return a.a="⊮","OP";case 373:return a.a="⊭","OP";case 374:return a.a="⊬","OP";case 375:return"NUM";case 376:return a.a="Î","AIUG";case 377:return a.a="ν","AILG";case 378:return a.a="â‹­","OP";case 379:return a.a="â‹«","OP";case 380:return a.a="⋬","OP";case 381:return a.a="⋪","OP";case 382:return a.a="⊉","OP";case 383:return a.a="⊅","OP";case 384:return a.a="≿̸","OP";case 385:return a.a=
+"⪰̸","OP";case 386:return a.a="âŠ","OP";case 387:return a.a="⊈","OP";case 388:return a.a="⊈","OP";case 389:return a.a="⊄","OP";case 390:return a.a="≄","OP";case 391:return a.a="â‰","OP";case 392:return a.a="∦","OP";case 393:return a.a="∤","OP";case 394:return a.a="â‡","OP";case 395:return a.a="↛","OP";case 396:return a.a="⪯̸","OP";case 397:return a.a="⊀","OP";case 398:return a.a="∦","OP";case 399:return a.a="∌","OP";case 400:return a.a="∉","OP";case 401:return a.a="¬","OP";case 402:return a.a="∤","OP";
+case 403:return a.a="≮","OP";case 404:return a.a="⩽̸","OP";case 405:return a.a="⩽̸","OP";case 406:return a.a="≰","OP";case 407:return a.a="⇎","OP";case 408:return a.a="↮","OP";case 409:return a.a="â‡","OP";case 410:return a.a="↚","OP";case 411:return a.a="∋","OP";case 412:return a.a="≯","OP";case 413:return a.a="⩾̸","OP";case 414:return a.a="⩾̸","OP";case 415:return a.a="≱","OP";case 416:return a.a="∄","OP";case 417:return a.a="≢","OP";case 418:return a.a="≂̸","OP";case 419:return a.a="≠","OP";case 420:return a.a=
+"⤮","OP";case 421:return a.a="⤱","OP";case 422:return"NEGTHICKSPACE";case 423:return"NEGSPACE";case 424:return"NEGMEDSPACE";case 425:return a.a="¬","OP";case 426:return a.a="⇗","OPS";case 427:return a.a="↗","OPS";case 428:return a.a="⇗","OPS";case 429:return a.a="↗","OPS";case 430:return a.a="≠","OP";case 431:return a.a="≇","OP";case 432:return a.a="≎̸","OP";case 433:return a.a="â‰Ì¸","OP";case 434:return a.a="â™®","OP";case 435:return a.a="≉","OP";case 436:return a.a="∇","OP";case 437:return"MULTI";
+case 438:return a.a="⊸","OP";case 439:return a.a="Μ","AIUG";case 440:return a.a="μ","AILG";case 441:return this.j("TEXTARG"),"MTEXT";case 442:return this.pushState("TEXTARG"),this.pushState("TEXTOPTARG"),this.pushState("TRYOPTARG"),this.pushState("TEXTOPTARG"),this.pushState("TRYOPTARG"),"MS";case 443:return a.a="∓","OP";case 444:return a.a="⊧","OP";case 445:return a.a="mod","MO";case 446:return this.pushState("TEXTARG"),"MO";case 447:return this.pushState("TEXTARG"),"MN";case 448:return a.a="⫛",
+"OP";case 449:return a.a="⨪","OP";case 450:return a.a="⊟","OP";case 451:return a.a="−","OP";case 452:return a.a=a.a.slice(1),"FM";case 453:return a.a="∣","OP";case 454:return this.pushState("TEXTARG"),"MI";case 455:return a.a="℧","A";case 456:return a.a="℧","A";case 457:return"MEDSPACE";case 458:return a.a="∡","OP";case 459:return"MATHTT";case 460:return"MATHSF";case 461:return"MATHSCR";case 462:return"MATHRM";case 463:return"MATHRLAP";case 464:return this.j("TEXTARG"),"MATHREL";case 465:return this.pushState("TEXTOPTARG"),
+this.pushState("TRYOPTARG"),this.pushState("TEXTOPTARG"),this.pushState("TRYOPTARG"),this.pushState("TEXTARG"),"MATHRAISEBOX";case 466:return this.j("TEXTARG"),"MATHOP";case 467:return"MATHIT";case 468:return"MATHLLAP";case 469:return"MATHIT";case 470:return"MATHFRAK";case 471:return"MATHFRAK";case 472:return"MATHCLAP";case 473:return"MATHSCR";case 474:return"MATHBSCR";case 475:return"MATHBIT";case 476:return this.j("TEXTARG"),"MATHBIN";case 477:return"MATHBF";case 478:return"MATHBSCR";case 479:return"MATHBB";
+case 480:return a.a="⤇","OP";case 481:return a.a="↦","OPS";case 482:return a.a="⤆","OP";case 483:return a.a="↦","OPS";case 484:return a.a="≨︀","OP";case 485:return a.a="≨︀","OP";case 486:return a.a="⋉","OP";case 487:return a.a="<","OP";case 488:return a.a="↰","OPS";case 489:return a.a="‘","OPF";case 490:return a.a="◊","OP";case 491:return a.a="⨜","OP";case 492:return a.a="↬","OPS";case 493:return a.a="↫","OPS";case 494:return a.a="⟹","OPS";case 495:return a.a="⟶","OPS";case 496:return a.a="⟼","OPS";
+case 497:return a.a="⟺","OPS";case 498:return a.a="⟷","OPS";case 499:return a.a="⟸","OPS";case 500:return a.a="⟵","OPS";case 501:return a.a="⋦","OP";case 502:return a.a="≨","OP";case 503:return a.a="⪇","OP";case 504:return a.a="⪉","OP";case 505:return a.a="⎰","OP";case 506:return a.a="⋘","OP";case 507:return a.a="⇚","OPS";case 508:return a.a="⟪","OPFS";case 509:return a.a="≪","OP";case 510:return a.a="⊲","OP";case 511:return a.a="⌊","OPFS";case 512:return a.a="≲","OP";case 513:return a.a="≶","OP";
+case 514:return a.a="⪋","OP";case 515:return a.a="⋚","OP";case 516:return a.a="⋖","OP";case 517:return a.a="⪅","OP";case 518:return a.a="<","OP";case 519:return a.a="⩽","OP";case 520:return a.a="≦","OP";case 521:return a.a="≤","OP";case 522:return a.a="⟳","OP";case 523:return a.a="⋋","OP";case 524:return a.a="↜","OPS";case 525:return a.a="↭","OPS";case 526:return a.a="⇋","OPS";case 527:return a.a="⇿","OPS";case 528:return a.a="⇆","OPS";case 529:return a.a="⇔","OPS";case 530:return a.a="↔","OPS";case 531:return a.a=
+"⇇","OPS";case 532:return a.a="↼","OPS";case 533:return a.a="↽","OPS";case 534:return a.a="⇽","OPS";case 535:return a.a="↢","OPS";case 536:return a.a="â‡","OPS";case 537:return a.a="â†","OPS";case 538:return"LEFT";case 539:return a.a="≤","OP";case 540:return a.a="…","OP";case 541:return a.a="⌈","OPFS";case 542:return a.a="[","OPFS";case 543:return a.a="{","OPFS";case 544:return a.a="⟨","OPFS";case 545:return a.a="⟨","OPFS";case 546:return a.a="Λ","AIUG";case 547:return a.a="λ","AILG";case 548:return a.a=
+"∻","OP";case 549:return a.a="Κ","AIUG";case 550:return a.a="κ","AILG";case 551:return a.a="ȷ","AILL";case 552:return this.pushState("TEXTARG"),"MN";case 553:return a.a="Ι","AIUG";case 554:return a.a="ι","AILG";case 555:return a.a="⅋","OP";case 556:return a.a="⨘","OP";case 557:return a.a="⨽","OP";case 558:return a.a="⨼","OP";case 559:return a.a="⋂","OPM";case 560:return a.a="∩","OP";case 561:return a.a="⫴","OP";case 562:return a.a="⊺","OP";case 563:return a.a="∫","OP";case 564:return a.a="⨚","OP";
+case 565:return a.a="⨙","OP";case 566:return a.a="⨎","OP";case 567:return a.a="â¨","OP";case 568:return a.a="∫","OP";case 569:return a.a="∞","NUM";case 570:return a.a="∞","NUM";case 571:return a.a=a.a.slice(1),"FM";case 572:return a.a="∊","OP";case 573:return a.a="⇒","OPS";case 574:return a.a="â‡","OPS";case 575:return a.a="ı","AILL";case 576:return a.a="â„‘","A";case 577:return a.a="∬","OP";case 578:return a.a="∭","OP";case 579:return a.a="⨌","OP";case 580:return a.a="⟺","OPS";case 581:return a.a="â„",
+"A";case 582:return this.pushState("TEXTARG"),"HREF";case 583:return a.a="↪","OPS";case 584:return a.a="↩","OPS";case 585:return a.a="⤦","OP";case 586:return a.a="⤥","OP";case 587:return a.a="♡","OP";case 588:return a.a="â„","A";case 589:return a.a="^","ACCENTNS";case 590:return a.a="≩︀","OP";case 591:return a.a="≩︀","OP";case 592:return a.a="≳","OP";case 593:return a.a="≷","OP";case 594:return a.a="⪌","OP";case 595:return a.a="â‹›","OP";case 596:return a.a="â‹—","OP";case 597:return a.a="⪆","OP";case 598:return a.a=
+">","OP";case 599:return a.a=">","OP";case 600:return a.a="⋧","OP";case 601:return a.a="≩","OP";case 602:return a.a="⪈","OP";case 603:return a.a="⪊","OP";case 604:return a.a="ℷ","A";case 605:return a.a="⋙","OP";case 606:return a.a="≫","OP";case 607:return a.a="⩾","OP";case 608:return a.a="≧","OP";case 609:return a.a="≥","OP";case 610:return a.a="≥","OP";case 611:return a.a="Γ","AIUG";case 612:return a.a="γ","AILG";case 613:return a.a="⌢","OP";case 614:return this.pushState("TEXTARG"),"FRAME";case 615:return"FRAC";
+case 616:return a.a="â«","OP";case 617:return a.a="â«Ì¸","OP";case 618:return a.a="∀","OP";case 619:return a.a="â™­","OP";case 620:return a.a="⤬","OP";case 621:return a.a="⤯","OP";case 622:return a.a="≒","OP";case 623:return a.a="∃","OP";case 624:return a.a="ð","A";case 625:return a.a="ð","A";case 626:return a.a="Η","AIUG";case 627:return a.a="η","AILG";case 628:return a.a="≡","OP";case 629:return this.pushState("TEXTARG"),"EQROWS";case 630:return this.pushState("TEXTARG"),"EQCOLS";case 631:return a.a=
+"⪕","OP";case 632:return a.a="⪖","OP";case 633:return a.a="≂","OP";case 634:return a.a="=∷","OP";case 635:return a.a="≕","OP";case 636:return a.a="−∷","OP";case 637:return a.a="=∷","OP";case 638:return a.a="=∷","OP";case 639:return a.a="=∷","OP";case 640:return a.a="≕","OP";case 641:return a.a="≖","OP";case 642:return a.a="ϵ","AILG";case 643:return"EVVMATRIX";case 644:return"EVMATRIX";case 645:return"ETOGGLE";case 646:return"EALIGNED";case 647:return"ESMALLMATRIX";case 648:return"EPMATRIX";case 649:return"EMATRIX";
+case 650:return"EGATHERED";case 651:return"ECASES";case 652:return"EBBMATRIX";case 653:return"EBMATRIX";case 654:return"EARRAY";case 655:return"EALIGNED";case 656:return a.a="∅","A";case 657:return a.a="∅","A";case 658:return a.a="↪","OPS";case 659:return a.a="â„“","A";case 660:return a.a="↕","OPS";case 661:return a.a="⧟","OP";case 662:return a.a="â¤","OPS";case 663:return a.a="↕","OPS";case 664:return a.a="⇂","OPS";case 665:return a.a="⇃","OPS";case 666:return a.a="⇊","OPS";case 667:return a.a="⇓",
+"OPS";case 668:return a.a="↓","OPS";case 669:return a.a="∬","OP";case 670:return a.a="â©ž","OP";case 671:return a.a="⌆","OP";case 672:return a.a="…","OP";case 673:return a.a="∔","OP";case 674:return a.a="∸","OP";case 675:return a.a="≑","OP";case 676:return a.a="≑","OP";case 677:return a.a="â‰","OP";case 678:return a.a="Ë™","ACCENT";case 679:return a.a="⋇","OP";case 680:return a.a="÷","OP";case 681:return"DISPLAYSTYLE";case 682:return a.a="⨈","OPM";case 683:return a.a="Ï","A";case 684:return a.a="♢","OP";
+case 685:return a.a="⋄","OP";case 686:return a.a="⋄","OP";case 687:return a.a=a.a.slice(1),"FM";case 688:return a.a="Δ","AIUG";case 689:return a.a="δ","AILG";case 690:return a.a="∇","OP";case 691:return a.a="°","OP";case 692:return a.a="⤋","OPS";case 693:return a.a="⩷","OP";case 694:return a.a="⋱","OP";case 695:return a.a="̈","ACCENT";case 696:return a.a="⃛","OP";case 697:return a.a="⃛","ACCENT";case 698:return a.a="⃜","OP";case 699:return a.a="⃜","ACCENT";case 700:return a.a="‡","OP";case 701:return a.a=
+"∷","OP";case 702:return a.a="â¤","OPS";case 703:return a.a="⫤","OP";case 704:return a.a="â«£","OP";case 705:return a.a="⊣","OP";case 706:return a.a="â¤","OPS";case 707:return a.a="⤎","OPS";case 708:return a.a="↓","OPS";case 709:return a.a="ℸ","A";case 710:return a.a="†","OP";case 711:return a.a="↷","OP";case 712:return a.a="↶","OP";case 713:return a.a="⤻","OP";case 714:return a.a="â‹","OP";case 715:return a.a="â‹Ž","OP";case 716:return a.a="â‹Ÿ","OP";case 717:return a.a="â‹ž","OP";case 718:return a.a="âŠ","OP";
+case 719:return a.a="â‹“","OP";case 720:return a.a="∪","OP";case 721:return a.a="âˆ","OPM";case 722:return a.a="âˆ","OPM";case 723:return a.a="∮","OP";case 724:return a.a="⨇","OPM";case 725:return a.a="∮","OP";case 726:return a.a="≅","OP";case 727:return a.a="âˆ","OP";case 728:return this.j("TEXTARG"),"COLSPAN";case 729:return this.pushState("TEXTARG"),"COLOR";case 730:return a.a="∷∼","OP";case 731:return a.a="∶∼","OP";case 732:return a.a="â©´","OP";case 733:return a.a="≔","OP";case 734:return a.a="∷−",
+"OP";case 735:return a.a="≔","OP";case 736:return a.a="∷≈","OP";case 737:return a.a="∶≈","OP";case 738:return a.a="∷","OP";case 739:return a.a=":","OP";case 740:return this.pushState("TEXTARG"),"COLLINES";case 741:return this.pushState("TEXTARG"),"COLLAYOUT";case 742:return this.j("TEXTARG"),"COLALIGN";case 743:return a.a="♣","OP";case 744:return a.a="¯","ACCENT";case 745:return a.a="âŠ","OP";case 746:return a.a="⊚","OP";case 747:return a.a="⊛","OP";case 748:return a.a="â¥","OP";case 749:return a.a=
+"⥀","OP";case 750:return a.a="≗","OP";case 751:return a.a="∘","OP";case 752:return"TEXCHOOSE";case 753:return a.a="χ","AILG";case 754:return a.a="ˇ","ACCENTNS";case 755:return"CELLOPTS";case 756:return a.a="⋯","OP";case 757:return a.a="·","OP";case 758:return a.a="â‹…","OP";case 759:return a.a="â‹’","OP";case 760:return a.a="∩","OP";case 761:return a.a="⪮","OP";case 762:return a.a="≎","OP";case 763:return a.a="â‰","OP";case 764:return a.a="•","OP";case 765:return a.a="⨲","OP";case 766:return a.a="⊠","OP";
+case 767:return a.a="⊞","OP";case 768:return a.a="⊟","OP";case 769:return"BOXED";case 770:return a.a="⊡","OP";case 771:return a.a="⧄","OP";case 772:return a.a="⧇","OP";case 773:return a.a="⧅","OP";case 774:return a.a="⧆","OP";case 775:return a.a="□","OP";case 776:return a.a="⋈","OP";case 777:return a.a="⊥","OP";case 778:return a.a="⊥","OP";case 779:return"MATHBF";case 780:return a.a="▸","OP";case 781:return a.a="◂","OP";case 782:return a.a="▾","OP";case 783:return a.a="▴","OP";case 784:return a.a=
+"â– ","OP";case 785:return a.a="⧫","OP";case 786:return a.a="â¤","OPS";case 787:return"BINOM";case 788:return a.a="â‹€","OPM";case 789:return a.a="â‹","OPM";case 790:return a.a="⨄","OPM";case 791:return a.a="â–³","OP";case 792:return a.a="â–½","OP";case 793:return a.a="⨉","OPM";case 794:return a.a="★","OP";case 795:return a.a="⨆","OPM";case 796:return a.a="⨅","OPM";case 797:return"BBIG";case 798:return"BIG";case 799:return a.a="⨂","OPM";case 800:return a.a="â¨","OPM";case 801:return a.a="⨀","OPM";case 802:return"BBIGL";
+case 803:return"BIGL";case 804:return a.a="⫼","OPM";case 805:return"BBIGG";case 806:return"BIGG";case 807:return"BBIGGL";case 808:return"BIGGL";case 809:return"BBIGG";case 810:return"BIGG";case 811:return a.a="⨃","OPM";case 812:return a.a="⋃","OPM";case 813:return a.a="○","OP";case 814:return a.a="⋂","OPM";case 815:return"BBIG";case 816:return"BIG";case 817:return this.pushState("TEXTARG"),"BGCOLOR";case 818:return a.a="≬","OP";case 819:return a.a="ℶ","A";case 820:return a.a="Β","AIUG";case 821:return a.a=
+"β","AILG";case 822:return"BVVMATRIX";case 823:return"BVMATRIX";case 824:return"BTOGGLE";case 825:return"BALIGNED";case 826:return"BSMALLMATRIX";case 827:return"BPMATRIX";case 828:return"BMATRIX";case 829:return"BGATHERED";case 830:return"BCASES";case 831:return"BBBMATRIX";case 832:return"BBMATRIX";case 833:return this.pushState("TEXTARG"),this.pushState("TEXTOPTARG"),this.pushState("TRYOPTARG"),"BARRAY";case 834:return"BALIGNED";case 835:return a.a="∵","OP";case 836:return a.a="ℿ","A";case 837:return a.a=
+"⌅","OP";case 838:return a.a="¯","ACCENTNS";case 839:return a.a="\\","OP";case 840:return a.a="â‹","OP";case 841:return a.a="∽","OP";case 842:return a.a="‵","OPP";case 843:return a.a="϶","OP";case 844:return"TEXATOP";case 845:return a.a="â‰","OP";case 846:return a.a="∗","OP";case 847:return"ARRAYOPTS";case 848:return"ARRAY";case 849:return a.a=a.a.slice(1),"F";case 850:return a.a="≊","OP";case 851:return a.a="≈","OP";case 852:return a.a="∠","OP";case 853:return a.a="⨿","OP";case 854:return a.a="Α",
+"AIUG";case 855:return a.a="α","AILG";case 856:return this.pushState("TEXTARG"),"ALIGN";case 857:return a.a="ℵ","A";case 858:return"AIUL";case 859:return"AIUG";case 860:return"AILL";case 861:return"AILG";case 862:return a.a="⋰","OP";case 863:return a.a="Å","A";case 864:return"A";case 865:return a.a="$","A";case 866:return a.a="}","OPFS";case 867:return a.a="‖","OPFS";case 868:return a.a="{","OPFS";case 869:return"THICKSPACE";case 870:return"MEDSPACE";case 871:return"THINSPACE";case 872:return a.a=
+"&","A";case 873:return a.a="%","A";case 874:return a.a="#","OP";case 875:return"NEGSPACE";case 876:return a.a="−","OP";case 877:return a.a="â—","OPP";case 878:return a.a="‴","OPP";case 879:return a.a="″","OPP";case 880:return a.a="′","OPP";case 881:return"HIGH_SURROGATE";case 882:return"LOW_SURROGATE";case 883:return"BMP_CHARACTER"}},rules:[/^(?:.)/,/^(?:\$\$|\\\[|\$|\\\()/,/^(?:$)/,/^(?:\\[$\\])/,/^(?:[<&>])/,/^(?:[^])/,/^(?:\s*\[)/,/^(?:.)/,/^(?:([^\\\]]|(\\[\\\]]))+)/,/^(?:\])/,/^(?:\s*\{)/,/^(?:([^\\\}]|(\\[\\\}]))+)/,
+/^(?:\})/,/^(?:\])/,/^(?:\s+)/,/^(?:\$\$|\\\]|\$|\\\))/,/^(?:\{)/,/^(?:\})/,/^(?:\^)/,/^(?:_)/,/^(?:\.)/,/^(?:&)/,/^(?:\\\\)/,/^(?:[0-9]+(?:\.[0-9]+)?|[\u0660-\u0669]+(?:\u066B[\u0660-\u0669]+)?|(?:\uD835[\uDFCE-\uDFD7])+|(?:\uD835[\uDFD8-\uDFE1])+|(?:\uD835[\uDFE2-\uDFEB])+|(?:\uD835[\uDFEC-\uDFF5])+|(?:\uD835[\uDFF6-\uDFFF])+)/,/^(?:[a-zA-Z]+)/,/^(?:\\Zeta)/,/^(?:\\zeta)/,/^(?:\\xrightleftharpoons)/,/^(?:\\xRightarrow)/,/^(?:\\xrightarrow)/,/^(?:\\xmapsto)/,/^(?:\\xleftrightharpoons)/,/^(?:\\xLeftrightarrow)/,
+/^(?:\\xleftrightarrow)/,/^(?:\\xLeftarrow)/,/^(?:\\xleftarrow)/,/^(?:\\Xi)/,/^(?:\\xi)/,/^(?:\\xhookrightarrow)/,/^(?:\\xhookleftarrow)/,/^(?:\\wr)/,/^(?:\\wp)/,/^(?:\\widevec)/,/^(?:\\widetilde)/,/^(?:\\widehat)/,/^(?:\\widecheck)/,/^(?:\\widebar)/,/^(?:\\wedgeq)/,/^(?:\\Wedge)/,/^(?:\\wedge)/,/^(?:\\Vvert)/,/^(?:\\Vvdash)/,/^(?:\\Vert)/,/^(?:\\vert)/,/^(?:\\veebar)/,/^(?:\\Vee)/,/^(?:\\vee)/,/^(?:\\vec)/,/^(?:\\vdots)/,/^(?:\\VDash)/,/^(?:\\Vdash)/,/^(?:\\vDash)/,/^(?:\\vdash)/,/^(?:\\Vbar)/,/^(?:\\vartriangleright)/,
+/^(?:\\vartriangleleft)/,/^(?:\\vartriangle)/,/^(?:\\vartheta)/,/^(?:\\varsupsetneqq)/,/^(?:\\varsupsetneq)/,/^(?:\\varsubsetneqq)/,/^(?:\\varsubsetneqq)/,/^(?:\\varsubsetneq)/,/^(?:\\varsigma)/,/^(?:\\varrho)/,/^(?:\\varpropto)/,/^(?:\\varpi)/,/^(?:\\varphi)/,/^(?:\\varnothing)/,/^(?:\\varkappa)/,/^(?:\\varepsilon)/,/^(?:\\Uuparrow)/,/^(?:\\upuparrows)/,/^(?:\\Upsilon)/,/^(?:\\upsilon)/,/^(?:\\Upsi)/,/^(?:\\uplus)/,/^(?:\\upint)/,/^(?:\\upharpoonright)/,/^(?:\\upharpoonleft)/,/^(?:\\Updownarrow)/,
+/^(?:\\updownarrow)/,/^(?:\\updarr)/,/^(?:\\Uparrow)/,/^(?:\\uparrow)/,/^(?:\\uparr)/,/^(?:\\unrhd)/,/^(?:\\unlhd)/,/^(?:\\Union)/,/^(?:\\union)/,/^(?:\\underset)/,/^(?:\\underoverset)/,/^(?:\\underline)/,/^(?:\\underbrace)/,/^(?:\\udots)/,/^(?:\u2ADD\u0338)/,/^(?:\u2ACC\uFE00)/,/^(?:\u2ACB\uFE00)/,/^(?:\u2AB0\u0338)/,/^(?:\u2AAF\u0338)/,/^(?:\u2AA2\u0338)/,/^(?:\u2AA1\u0338)/,/^(?:\u2A7E\u0338)/,/^(?:\u2A7D\u0338)/,/^(?:\u29D0\u0338)/,/^(?:\u29CF\u0338)/,/^(?:\u2290\u0338)/,/^(?:\u228F\u0338)/,/^(?:\u228B\uFE00)/,
+/^(?:\u228A\uFE00)/,/^(?:\u2283\u20D2)/,/^(?:\u2282\u20D2)/,/^(?:\u227F\u0338)/,/^(?:\u226B\u0338)/,/^(?:\u226A\u0338)/,/^(?:\u2269\uFE00)/,/^(?:\u2268\uFE00)/,/^(?:\u2266\u0338)/,/^(?:\u224F\u0338)/,/^(?:\u224E\u0338)/,/^(?:\u2242\u0338)/,/^(?:\u223D\u0331)/,/^(?:\u2237\u2248)/,/^(?:\u2237\u223C)/,/^(?:\u2237\u2212)/,/^(?:\u2236\u2248)/,/^(?:\u2236\u223C)/,/^(?:\u2212\u2237)/,/^(?:\u007C\u007C\u007C)/,/^(?:\u007C\u007C)/,/^(?:\u003E\u003D)/,/^(?:\u003D\u2237)/,/^(?:\u003D\u2237)/,/^(?:\u003D\u003D)/,
+/^(?:\u003C\u003E)/,/^(?:\u003C\u003D)/,/^(?:\u003A\u003D)/,/^(?:\u002F\u003D)/,/^(?:\u002F\u002F)/,/^(?:\u002E\u002E\u002E)/,/^(?:\u002E\u002E)/,/^(?:\u002D\u003E)/,/^(?:\u002D\u003D)/,/^(?:\u002D\u002D)/,/^(?:\u002B\u003D)/,/^(?:\u002B\u002B)/,/^(?:\u002A\u003D)/,/^(?:\u002A\u002A)/,/^(?:\u0026\u0026)/,/^(?:\u0021\u003D)/,/^(?:\u0021\u0021)/,/^(?:\\twoheadrightarrowtail)/,/^(?:\\twoheadrightarrow)/,/^(?:\\twoheadleftarrow)/,/^(?:\\tripleintegral)/,/^(?:\\trianglerighteq)/,/^(?:\\triangleright)/,
+/^(?:\\triangleq)/,/^(?:\\trianglelefteq)/,/^(?:\\triangleleft)/,/^(?:\\triangledown)/,/^(?:\\triangle)/,/^(?:\\towa)/,/^(?:\\tosa)/,/^(?:\\top)/,/^(?:\\tooltip)/,/^(?:\\tona)/,/^(?:\\toggle)/,/^(?:\\toea)/,/^(?:\\to)/,/^(?:\\timesb)/,/^(?:\\times)/,/^(?:\\tilde)/,/^(?:\\thinspace)/,/^(?:\\thickspace)/,/^(?:\\thicksim)/,/^(?:\\thickapprox)/,/^(?:\\Theta)/,/^(?:\\theta)/,/^(?:\\therefore)/,/^(?:\\tfrac)/,/^(?:\\textstyle)/,/^(?:\\textsize)/,/^(?:\\textquotedblright)/,/^(?:\\textquotedblleft)/,/^(?:\\textasciitilde)/,
+/^(?:\\textasciigrave)/,/^(?:\\textasciicircumflex)/,/^(?:\\textasciiacute)/,/^(?:\\text)/,/^(?:\\tensor)/,/^(?:\\tbinom)/,/^(?:\\Tau)/,/^(?:\\tau)/,/^(?:\\swArrow)/,/^(?:\\swarrow)/,/^(?:\\swArr)/,/^(?:\\swarr)/,/^(?:\\surd)/,/^(?:\\supsetneqq)/,/^(?:\\supsetneq)/,/^(?:\\supseteqq)/,/^(?:\\supseteq)/,/^(?:\\Supset)/,/^(?:\\supset)/,/^(?:\\sum)/,/^(?:\\succsim)/,/^(?:\\succnsim)/,/^(?:\\succneqq)/,/^(?:\\succnapprox)/,/^(?:\\succeq)/,/^(?:\\succcurlyeq)/,/^(?:\\succapprox)/,/^(?:\\succ)/,/^(?:\\substack)/,
+/^(?:\\subsetneqq)/,/^(?:\\subsetneq)/,/^(?:\\subseteqq)/,/^(?:\\subseteq)/,/^(?:\\Subset)/,/^(?:\\subset)/,/^(?:\\statusline)/,/^(?:\\star)/,/^(?:\\stackrel)/,/^(?:\\sslash)/,/^(?:\\square)/,/^(?:\\sqsupseteq)/,/^(?:\\sqsupset)/,/^(?:\\sqsubseteq)/,/^(?:\\sqsubset)/,/^(?:\\sqrt)/,/^(?:\\sqcup)/,/^(?:\\sqcap)/,/^(?:\\sphericalangle)/,/^(?:\\spadesuit)/,/^(?:\\space)/,/^(?:\\smile)/,/^(?:\\smallsmile)/,/^(?:\\smallsetminus)/,/^(?:\\smallfrown)/,/^(?:\\slash)/,/^(?:\\simeq)/,/^(?:\\sim)/,/^(?:\\Sigma)/,
+/^(?:\\sigma)/,/^(?:\\shuffle)/,/^(?:\\shortparallel)/,/^(?:\\shortmid)/,/^(?:\\sharp)/,/^(?:\\setminus)/,/^(?:\\seovnearrow)/,/^(?:\\seArrow)/,/^(?:\\searrow)/,/^(?:\\seArr)/,/^(?:\\searr)/,/^(?:\\scriptsize)/,/^(?:\\scriptscriptsize)/,/^(?:\\rtimes)/,/^(?:\\Rsh)/,/^(?:\\Rrightarrow)/,/^(?:\\rrangle)/,/^(?:\\rq)/,/^(?:\\rowspan)/,/^(?:\\rowopts)/,/^(?:\\rowlines)/,/^(?:\\rowalign)/,/^(?:\\root)/,/^(?:\\rmoustache)/,/^(?:\\risingdotseq)/,/^(?:\\righttoleftarrow)/,/^(?:\\rightthreetimes)/,/^(?:\\rightsquigarrow)/,
+/^(?:\\rightrightarrows)/,/^(?:\\rightleftharpoons)/,/^(?:\\rightleftarrows)/,/^(?:\\rightharpoonup)/,/^(?:\\rightharpoondown)/,/^(?:\\rightarrowtriangle)/,/^(?:\\rightarrowtail)/,/^(?:\\Rightarrow)/,/^(?:\\rightarrow)/,/^(?:\\right)/,/^(?:\\Rho)/,/^(?:\\rho)/,/^(?:\\rhd)/,/^(?:\\rfloor)/,/^(?:\\Re)/,/^(?:\\rdiagovsearrow)/,/^(?:\\rdiagovfdiag)/,/^(?:\\rceil)/,/^(?:\\rbrack)/,/^(?:\\rbrace)/,/^(?:\\rangle)/,/^(?:\\rang)/,/^(?:\\questeq)/,/^(?:\\quadrupleintegral)/,/^(?:\\quad)/,/^(?:\\qquad)/,/^(?:\\qed)/,
+/^(?:\\Psi)/,/^(?:\\psi)/,/^(?:\\propto)/,/^(?:\\product)/,/^(?:\\prod)/,/^(?:\\prime)/,/^(?:\\precsim)/,/^(?:\\precnsim)/,/^(?:\\precneqq)/,/^(?:\\precnapprox)/,/^(?:\\preceq)/,/^(?:\\preccurlyeq)/,/^(?:\\precapprox)/,/^(?:\\prec)/,/^(?:\\pmod)/,/^(?:\\pm)/,/^(?:\\plusdot)/,/^(?:\\plusb)/,/^(?:\\pitchfork)/,/^(?:\\Pi)/,/^(?:\\pi)/,/^(?:\\Phi)/,/^(?:\\phi)/,/^(?:\\phantom)/,/^(?:\\Perp)/,/^(?:\\perp)/,/^(?:\\partialmeetcontraction)/,/^(?:\\partial)/,/^(?:\\parr)/,/^(?:\\parallel)/,/^(?:\\padding)/,
+/^(?:\\overset)/,/^(?:\\overline)/,/^(?:\\overbrace)/,/^(?:\\over)/,/^(?:\\Otimes)/,/^(?:\\otimes)/,/^(?:\\oslash)/,/^(?:[\u007E\u00AF\u02C6\u02C7\u02C9\u02CD\u02DC\u02F7\u0302\u203E\u2044\u2190-\u2199\u219C-\u21AD\u21AF-\u21B5\u21B9\u21BC-\u21CC\u21D0-\u21DD\u21E0-\u21F0\u21F3\u21F5\u21F6\u21FD-\u21FF\u2215\u221A\u23B4\u23B5\u23DC-\u23E1\u27F0\u27F1\u27F5-\u27FF\u290A-\u2910\u2912\u2913\u2921\u2922\u294E-\u2961\u296E\u296F\u2B45\u2B46])/,/^(?:[\u2032-\u2035\u2057])/,/^(?:[\u220F-\u2211\u22C0-\u22C3\u2A00-\u2A0A\u2A10-\u2A14\u2AFC\u2AFF])/,
+/^(?:\\Oplus)/,/^(?:\\oplus)/,/^(?:[\u0028\u0029\u005B\u005D\u007C\u2016\u2308-\u230B\u2329\u232A\u2772\u2773\u27E6-\u27EF\u2980\u2983-\u2998\u29FC\u29FD])/,/^(?:[\u2018\u2019\u201C\u201D])/,/^(?:\\operatorname)/,/^(?:[\u0021-\u0023\u002A-\u002C\u002F\u003A-\u0040\u0060\u00A8\u00AA\u00AC\u00B0-\u00B4\u00B7-\u00BA\u00D7\u00F7\u02CA\u02CB\u02D8-\u02DA\u02DD\u0311\u03F6\u201A\u201B\u201E-\u2022\u2026\u2036\u2037\u2043\u2061-\u2064\u20DB\u20DC\u2145\u2146\u214B\u219A\u219B\u21AE\u21B6-\u21B8\u21BA\u21BB\u21CD-\u21CF\u21DE\u21DF\u21F1\u21F2\u21F4\u21F7-\u21FC\u2200-\u2204\u2206-\u220E\u2212-\u2214\u2216-\u2219\u221B-\u221D\u221F-\u22BF\u22C4-\u22FF\u2305\u2306\u2322\u2323\u23B0\u23B1\u25A0\u25A1\u25AA\u25AB\u25AD-\u25B9\u25BC-\u25CF\u25D6\u25D7\u25E6\u2605\u2660-\u2663\u266D-\u266F\u2758\u27F2\u27F3\u2900-\u2909\u2911\u2914-\u2920\u2923-\u294D\u2962-\u296D\u2970-\u297F\u2981\u2982\u2999-\u29D9\u29DB-\u29FB\u29FE\u29FF\u2A0B-\u2A0F\u2A15-\u2ADB\u2ADD-\u2AFB\u2AFD\u2AFE])/,
+/^(?:\\ominus)/,/^(?:\\omicron)/,/^(?:\\Omega)/,/^(?:\\omega)/,/^(?:\\oint)/,/^(?:\\oiint)/,/^(?:\\oiiint)/,/^(?:\\odot)/,/^(?:\\odash)/,/^(?:\\obslash)/,/^(?:\\nwovnearrow)/,/^(?:\\nwArrow)/,/^(?:\\nwarrow)/,/^(?:\\nwArr)/,/^(?:\\nwarr)/,/^(?:\\nVDash)/,/^(?:\\nVdash)/,/^(?:\\nvDash)/,/^(?:\\nvdash)/,/^(?:\u221E)/,/^(?:\\Nu)/,/^(?:\\nu)/,/^(?:\\ntrianglerighteq)/,/^(?:\\ntriangleright)/,/^(?:\\ntrianglelefteq)/,/^(?:\\ntriangleleft)/,/^(?:\\nsupseteq)/,/^(?:\\nsupset)/,/^(?:\\nsuccsim)/,/^(?:\\nsucceq)/,
+/^(?:\\nsucc)/,/^(?:\\nsubseteqq)/,/^(?:\\nsubseteq)/,/^(?:\\nsubset)/,/^(?:\\nsime)/,/^(?:\\nsim)/,/^(?:\\nshortparallel)/,/^(?:\\nshortmid)/,/^(?:\\nRightarrow)/,/^(?:\\nrightarrow)/,/^(?:\\npreceq)/,/^(?:\\nprec)/,/^(?:\\nparallel)/,/^(?:\\notni)/,/^(?:\\notin)/,/^(?:\\not)/,/^(?:\\nmid)/,/^(?:\\nless)/,/^(?:\\nleqslant)/,/^(?:\\nleqq)/,/^(?:\\nleq)/,/^(?:\\nLeftrightarrow)/,/^(?:\\nleftrightarrow)/,/^(?:\\nLeftarrow)/,/^(?:\\nleftarrow)/,/^(?:\\ni)/,/^(?:\\ngtr)/,/^(?:\\ngeqslant)/,/^(?:\\ngeqq)/,
+/^(?:\\ngeq)/,/^(?:\\nexists)/,/^(?:\\nequiv)/,/^(?:\\neqsim)/,/^(?:\\neq)/,/^(?:\\neovsearrow)/,/^(?:\\neovnwarrow)/,/^(?:\\negthickspace)/,/^(?:\\negspace)/,/^(?:\\negmedspace)/,/^(?:\\neg)/,/^(?:\\neArrow)/,/^(?:\\nearrow)/,/^(?:\\neArr)/,/^(?:\\nearr)/,/^(?:\\ne)/,/^(?:\\ncong)/,/^(?:\\nBumpeq)/,/^(?:\\nbumpeq)/,/^(?:\\natural)/,/^(?:\\napprox)/,/^(?:\\nabla)/,/^(?:\\multiscripts)/,/^(?:\\multimap)/,/^(?:\\Mu)/,/^(?:\\mu)/,/^(?:\\mtext)/,/^(?:\\ms)/,/^(?:\\mp)/,/^(?:\\models)/,/^(?:\\mod)/,/^(?:\\mo)/,
+/^(?:\\mn)/,/^(?:\\mlcp)/,/^(?:\\minusdot)/,/^(?:\\minusb)/,/^(?:\\minus)/,/^(?:\\min)/,/^(?:\\mid)/,/^(?:\\mi)/,/^(?:\\mho)/,/^(?:\\mho)/,/^(?:\\medspace)/,/^(?:\\measuredangle)/,/^(?:\\mathtt)/,/^(?:\\mathsf)/,/^(?:\\mathscr)/,/^(?:\\mathrm)/,/^(?:\\mathrlap)/,/^(?:\\mathrel)/,/^(?:\\mathraisebox)/,/^(?:\\mathop)/,/^(?:\\mathmit)/,/^(?:\\mathllap)/,/^(?:\\mathit)/,/^(?:\\mathfrak)/,/^(?:\\mathfr)/,/^(?:\\mathclap)/,/^(?:\\mathcal)/,/^(?:\\mathbscr)/,/^(?:\\mathbit)/,/^(?:\\mathbin)/,/^(?:\\mathbf)/,
+/^(?:\\mathbcal)/,/^(?:\\mathbb)/,/^(?:\\Mapsto)/,/^(?:\\mapsto)/,/^(?:\\Mapsfrom)/,/^(?:\\map)/,/^(?:\\lvertneqq)/,/^(?:\\lvertneqq)/,/^(?:\\ltimes)/,/^(?:\\lt)/,/^(?:\\Lsh)/,/^(?:\\lq)/,/^(?:\\lozenge)/,/^(?:\\lowint)/,/^(?:\\looparrowright)/,/^(?:\\looparrowleft)/,/^(?:\\Longrightarrow)/,/^(?:\\longrightarrow)/,/^(?:\\longmapsto)/,/^(?:\\Longleftrightarrow)/,/^(?:\\longleftrightarrow)/,/^(?:\\Longleftarrow)/,/^(?:\\longleftarrow)/,/^(?:\\lnsim)/,/^(?:\\lneqq)/,/^(?:\\lneq)/,/^(?:\\lnapprox)/,/^(?:\\lmoustache)/,
+/^(?:\\lll)/,/^(?:\\Lleftarrow)/,/^(?:\\llangle)/,/^(?:\\ll)/,/^(?:\\lhd)/,/^(?:\\lfloor)/,/^(?:\\lesssim)/,/^(?:\\lessgtr)/,/^(?:\\lesseqqgtr)/,/^(?:\\lesseqgtr)/,/^(?:\\lessdot)/,/^(?:\\lessapprox)/,/^(?:\\less)/,/^(?:\\leqslant)/,/^(?:\\leqq)/,/^(?:\\leq)/,/^(?:\\lefttorightarrow)/,/^(?:\\leftthreetimes)/,/^(?:\\leftsquigarrow)/,/^(?:\\leftrightsquigarrow)/,/^(?:\\leftrightharpoons)/,/^(?:\\leftrightarrowtria\*)/,/^(?:\\leftrightarrows)/,/^(?:\\Leftrightarrow)/,/^(?:\\leftrightarrow)/,/^(?:\\leftleftarrows)/,
+/^(?:\\leftharpoonup)/,/^(?:\\leftharpoondown)/,/^(?:\\leftarrowtriangle)/,/^(?:\\leftarrowtail)/,/^(?:\\Leftarrow)/,/^(?:\\leftarrow)/,/^(?:\\left)/,/^(?:\\le)/,/^(?:\\ldots)/,/^(?:\\lceil)/,/^(?:\\lbrack)/,/^(?:\\lbrace)/,/^(?:\\langle)/,/^(?:\\lang)/,/^(?:\\Lambda)/,/^(?:\\lambda)/,/^(?:\\kernelcontraction)/,/^(?:\\Kappa)/,/^(?:\\kappa)/,/^(?:\\jmath)/,/^(?:\\itexnum)/,/^(?:\\Iota)/,/^(?:\\iota)/,/^(?:\\invamp)/,/^(?:\\intx)/,/^(?:\\intprodr)/,/^(?:\\intprod)/,/^(?:\\Intersection)/,/^(?:\\intersection)/,
+/^(?:\\interleave)/,/^(?:\\intercal)/,/^(?:\\integral)/,/^(?:\\intcup)/,/^(?:\\intcap)/,/^(?:\\intBar)/,/^(?:\\intbar)/,/^(?:\\int)/,/^(?:\\infty)/,/^(?:\\infinity)/,/^(?:\\inf)/,/^(?:\\in)/,/^(?:\\implies)/,/^(?:\\impliedby)/,/^(?:\\imath)/,/^(?:\\Im)/,/^(?:\\iint)/,/^(?:\\iiint)/,/^(?:\\iiiint)/,/^(?:\\iff)/,/^(?:\\hslash)/,/^(?:\\href)/,/^(?:\\hookrightarrow)/,/^(?:\\hookleftarrow)/,/^(?:\\hkswarow)/,/^(?:\\hksearow)/,/^(?:\\heartsuit)/,/^(?:\\hbar)/,/^(?:\\hat)/,/^(?:\\gvertneqq)/,/^(?:\\gvertneqq)/,
+/^(?:\\gtrsim)/,/^(?:\\gtrless)/,/^(?:\\gtreqqless)/,/^(?:\\gtreqless)/,/^(?:\\gtrdot)/,/^(?:\\gtrapprox)/,/^(?:\\gt)/,/^(?:\\greater)/,/^(?:\\gnsim)/,/^(?:\\gneqq)/,/^(?:\\gneq)/,/^(?:\\gnapprox)/,/^(?:\\gimel)/,/^(?:\\ggg)/,/^(?:\\gg)/,/^(?:\\geqslant)/,/^(?:\\geqq)/,/^(?:\\geq)/,/^(?:\\ge)/,/^(?:\\Gamma)/,/^(?:\\gamma)/,/^(?:\\frown)/,/^(?:\\frame)/,/^(?:\\frac)/,/^(?:\\forksnot)/,/^(?:\\forks)/,/^(?:\\forall)/,/^(?:\\flat)/,/^(?:\\fdiagovrdiag)/,/^(?:\\fdiagovnearrow)/,/^(?:\\fallingdotseq)/,
+/^(?:\\exists)/,/^(?:\\eth)/,/^(?:\\eth)/,/^(?:\\Eta)/,/^(?:\\eta)/,/^(?:\\equiv)/,/^(?:\\equalrows)/,/^(?:\\equalcols)/,/^(?:\\eqslantless)/,/^(?:\\eqslantgtr)/,/^(?:\\eqsim)/,/^(?:\\Eqqcolon)/,/^(?:\\eqqcolon)/,/^(?:\\Eqcolon)/,/^(?:\\Eqcolon)/,/^(?:\\Eqcolon)/,/^(?:\\Eqcolon)/,/^(?:\\eqcolon)/,/^(?:\\eqcirc)/,/^(?:\\epsilon)/,/^(?:\\end\{Vmatrix\})/,/^(?:\\end\{vmatrix\})/,/^(?:\\endtoggle)/,/^(?:\\end\{split\})/,/^(?:\\end\{smallmatrix\})/,/^(?:\\end\{pmatrix\})/,/^(?:\\end\{matrix\})/,/^(?:\\end\{gathered\})/,
+/^(?:\\end\{cases\})/,/^(?:\\end\{Bmatrix\})/,/^(?:\\end\{bmatrix\})/,/^(?:\\end\{array\})/,/^(?:\\end\{aligned\})/,/^(?:\\emptyset)/,/^(?:\\empty)/,/^(?:\\embedsin)/,/^(?:\\ell)/,/^(?:\\duparr)/,/^(?:\\dualmap)/,/^(?:\\drbkarrow)/,/^(?:\\downuparrow)/,/^(?:\\downharpoonright)/,/^(?:\\downharpoonleft)/,/^(?:\\downdownarrows)/,/^(?:\\Downarrow)/,/^(?:\\downarrow)/,/^(?:\\doubleintegral)/,/^(?:\\doublebarwedge)/,/^(?:\\doublebarwedge)/,/^(?:\\dots)/,/^(?:\\dotplus)/,/^(?:\\dotminus)/,/^(?:\\doteqdot)/,
+/^(?:\\Doteq)/,/^(?:\\doteq)/,/^(?:\\dot)/,/^(?:\\divideontimes)/,/^(?:\\div)/,/^(?:\\displaystyle)/,/^(?:\\disjquant)/,/^(?:\\digamma)/,/^(?:\\diamondsuit)/,/^(?:\\Diamond)/,/^(?:\\diamond)/,/^(?:\\det|\\gcd|\\liminf|\\limsup|\\lim|\\max|\\Pr|\\sup)/,/^(?:\\Delta)/,/^(?:\\delta)/,/^(?:\\Del)/,/^(?:\\degree)/,/^(?:\\Ddownarrow)/,/^(?:\\ddotseq)/,/^(?:\\ddots)/,/^(?:\\ddot)/,/^(?:\\dddot)/,/^(?:\\dddot)/,/^(?:\\ddddot)/,/^(?:\\ddddot)/,/^(?:\\ddagger)/,/^(?:\\dblcolon)/,/^(?:\\dbkarow)/,/^(?:\\Dashv)/,
+/^(?:\\dashV)/,/^(?:\\dashv)/,/^(?:\\dashrightarrow)/,/^(?:\\dashleftarrow)/,/^(?:\\darr)/,/^(?:\\daleth)/,/^(?:\\dagger)/,/^(?:\\curvearrowright)/,/^(?:\\curvearrowleft)/,/^(?:\\curvearrowbotright)/,/^(?:\\curlywedge)/,/^(?:\\curlyvee)/,/^(?:\\curlyeqsucc)/,/^(?:\\curlyeqprec)/,/^(?:\\cupdot)/,/^(?:\\Cup)/,/^(?:\\cup)/,/^(?:\\coproduct)/,/^(?:\\coprod)/,/^(?:\\contourintegral)/,/^(?:\\conjquant)/,/^(?:\\conint)/,/^(?:\\cong)/,/^(?:\\complement)/,/^(?:\\colspan)/,/^(?:\\color)/,/^(?:\\Colonsim)/,
+/^(?:\\colonsim)/,/^(?:\\Coloneqq)/,/^(?:\\coloneqq)/,/^(?:\\Coloneq)/,/^(?:\\coloneq)/,/^(?:\\Colonapprox)/,/^(?:\\colonapprox)/,/^(?:\\Colon)/,/^(?:\\colon)/,/^(?:\\collines)/,/^(?:\\collayout)/,/^(?:\\colalign)/,/^(?:\\clubsuit)/,/^(?:\\closure)/,/^(?:\\circleddash)/,/^(?:\\circledcirc)/,/^(?:\\circledast)/,/^(?:\\circlearrowright)/,/^(?:\\circlearrowleft)/,/^(?:\\circeq)/,/^(?:\\circ)/,/^(?:\\choose)/,/^(?:\\chi)/,/^(?:\\check)/,/^(?:\\cellopts)/,/^(?:\\cdots)/,/^(?:\\cdotp)/,/^(?:\\cdot)/,/^(?:\\Cap)/,
+/^(?:\\cap)/,/^(?:\\bumpeqq)/,/^(?:\\Bumpeq)/,/^(?:\\bumpeq)/,/^(?:\\bullet)/,/^(?:\\btimes)/,/^(?:\\boxtimes)/,/^(?:\\boxplus)/,/^(?:\\boxminus)/,/^(?:\\boxed)/,/^(?:\\boxdot)/,/^(?:\\boxdiag)/,/^(?:\\boxcircle)/,/^(?:\\boxbslash)/,/^(?:\\boxast)/,/^(?:\\Box)/,/^(?:\\bowtie)/,/^(?:\\bottom)/,/^(?:\\bot)/,/^(?:\\boldsymbol)/,/^(?:\\blacktriangleright)/,/^(?:\\blacktriangleleft)/,/^(?:\\blacktriangledown)/,/^(?:\\blacktriangle)/,/^(?:\\blacksquare)/,/^(?:\\blacklozenge)/,/^(?:\\bkarow)/,/^(?:\\binom)/,
+/^(?:\\bigwedge)/,/^(?:\\bigvee)/,/^(?:\\biguplus)/,/^(?:\\bigtriangleup)/,/^(?:\\bigtriangledown)/,/^(?:\\bigtimes)/,/^(?:\\bigstar)/,/^(?:\\bigsqcup)/,/^(?:\\bigsqcap)/,/^(?:\\Bigr)/,/^(?:\\bigr)/,/^(?:\\bigotimes)/,/^(?:\\bigoplus)/,/^(?:\\bigodot)/,/^(?:\\Bigl)/,/^(?:\\bigl)/,/^(?:\\biginterleave)/,/^(?:\\Biggr)/,/^(?:\\biggr)/,/^(?:\\Biggl)/,/^(?:\\biggl)/,/^(?:\\Bigg)/,/^(?:\\bigg)/,/^(?:\\bigcupdot)/,/^(?:\\bigcup)/,/^(?:\\bigcirc)/,/^(?:\\bigcap)/,/^(?:\\Big)/,/^(?:\\big)/,/^(?:\\bgcolor)/,
+/^(?:\\between)/,/^(?:\\beth)/,/^(?:\\Beta)/,/^(?:\\beta)/,/^(?:\\begin\{Vmatrix\})/,/^(?:\\begin\{vmatrix\})/,/^(?:\\begintoggle)/,/^(?:\\begin\{split\})/,/^(?:\\begin\{smallmatrix\})/,/^(?:\\begin\{pmatrix\})/,/^(?:\\begin\{matrix\})/,/^(?:\\begin\{gathered\})/,/^(?:\\begin\{cases\})/,/^(?:\\begin\{Bmatrix\})/,/^(?:\\begin\{bmatrix\})/,/^(?:\\begin\{array\})/,/^(?:\\begin\{aligned\})/,/^(?:\\because)/,/^(?:\\BbbPi)/,/^(?:\\barwedge)/,/^(?:\\bar)/,/^(?:\\backslash)/,/^(?:\\backsimeq)/,/^(?:\\backsim)/,
+/^(?:\\backprime)/,/^(?:\\backepsilon)/,/^(?:\\atop)/,/^(?:\\asymp)/,/^(?:\\ast)/,/^(?:\\arrayopts)/,/^(?:\\array)/,/^(?:\\arccos|\\arcsin|\\arctan|\\arg|\\cosh|\\cos|\\coth|\\cot|\\csc|\\deg|\\dim|\\exp|\\hom|\\ker|\\lg|\\ln|\\log|\\sec|\\sinh|\\sin|\\tanh|\\tan)/,/^(?:\\approxeq)/,/^(?:\\approx)/,/^(?:\\angle)/,/^(?:\\amalg)/,/^(?:\\Alpha)/,/^(?:\\alpha)/,/^(?:\\align)/,/^(?:\\aleph)/,/^(?:[\u0041-\u005A])/,/^(?:[\u0391-\u03A1\u03A3\u03A4\u03A6-\u03A9])/,/^(?:[\u0061-\u007A\u0131\u0237])/,/^(?:[\u03B1-\u03C1\u03C3-\u03C9\u03D1\u03D5\u03D6\u03F0\u03F1\u03F4\u03F5])/,
+/^(?:\\adots)/,/^(?:\\AA)/,/^(?:[\u00F0\u03C2\u03D0\u03D2\u03DA-\u03DD\u03E0\u03E1\u0428\u0608\u0627-\u063A\u2102\u210A-\u210D\u210F-\u2113\u2115\u2118-\u211D\u2124\u2127\u2128\u212B-\u212D\u212F-\u2131\u2133-\u2138\u213C\u213D\u213F\u2205]|\uD83B[\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB\uDEF0\uDEF1]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDFCB])/,
+/^(?:\\\$)/,/^(?:\\\})/,/^(?:\\\|)/,/^(?:\\\{)/,/^(?:\\;)/,/^(?:\\:)/,/^(?:\\,)/,/^(?:\\&)/,/^(?:\\%)/,/^(?:\\#)/,/^(?:\\!)/,/^(?:-)/,/^(?:'''')/,/^(?:''')/,/^(?:'')/,/^(?:')/,/^(?:[\uD800-\uDBFF])/,/^(?:[\uDC00-\uDFFF])/,/^(?:.)/],M:{MATH0:{rules:[14,15,16,17,18,19,20,21,22,23,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,
+99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,
+225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,
+351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,
+477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,
+603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,
+729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,
+855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883],inclusive:tb},MATH1:{rules:[14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,
+125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,
+251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,
+377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,
+503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,
+629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,
+755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,
+881,882,883],inclusive:tb},OPTARG:{rules:[13,14,15,16,17,18,19,20,21,22,23,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,
+151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,
+277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,
+403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,
+529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,
+655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,
+781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883],inclusive:tb},DOCUMENT:{rules:[1,2,3,4,5],inclusive:yb},TRYOPTARG:{rules:[6,7],inclusive:yb},
+TEXTOPTARG:{rules:[8,9],inclusive:yb},TEXTARG:{rules:[10,11,12],inclusive:yb},INITIAL:{rules:[0,14,15,16,17,18,19,20,21,22,23,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,
+138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,
+264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,
+390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,
+516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,
+642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,
+768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883],inclusive:tb}}}}();Rb.prototype=rb;rb.pa=
+Rb;return new Rb}();window.TeXZilla=zb;window.TeXZilla.setDOMParser=zb.fa;window.TeXZilla.setXMLSerializer=zb.ja;window.TeXZilla.setSafeMode=zb.ia;window.TeXZilla.setItexIdentifierMode=zb.ha;window.TeXZilla.getTeXSource=zb.ca;window.TeXZilla.toMathMLString=zb.Z;window.TeXZilla.toMathML=zb.Y;window.TeXZilla.toImage=zb.na;window.TeXZilla.filterString=zb.Q;window.TeXZilla.filterElement=zb.P;
+})();
diff --git a/comm/mail/components/customizableui/CustomizableUI.sys.mjs b/comm/mail/components/customizableui/CustomizableUI.sys.mjs
new file mode 100644
index 0000000000..2628bd6109
--- /dev/null
+++ b/comm/mail/components/customizableui/CustomizableUI.sys.mjs
@@ -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/. */
+
+// This file is a copy of a file with the same name in Firefox. Only the
+// pieces we're using, and a few pieces the devtools rely on such as the
+// constants, remain.
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+});
+
+/**
+ * gPanelsForWindow is a list of known panels in a window which we may need to close
+ * should command events fire which target them.
+ */
+var gPanelsForWindow = new WeakMap();
+
+var CustomizableUIInternal = {
+ addPanelCloseListeners(aPanel) {
+ Services.els.addSystemEventListener(aPanel, "click", this, false);
+ Services.els.addSystemEventListener(aPanel, "keypress", this, false);
+ let win = aPanel.ownerGlobal;
+ if (!gPanelsForWindow.has(win)) {
+ gPanelsForWindow.set(win, new Set());
+ }
+ gPanelsForWindow.get(win).add(this._getPanelForNode(aPanel));
+ },
+
+ removePanelCloseListeners(aPanel) {
+ Services.els.removeSystemEventListener(aPanel, "click", this, false);
+ Services.els.removeSystemEventListener(aPanel, "keypress", this, false);
+ let win = aPanel.ownerGlobal;
+ let panels = gPanelsForWindow.get(win);
+ if (panels) {
+ panels.delete(this._getPanelForNode(aPanel));
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "click":
+ case "keypress":
+ this.maybeAutoHidePanel(aEvent);
+ break;
+ }
+ },
+
+ _getPanelForNode(aNode) {
+ return aNode.closest("panel");
+ },
+
+ /*
+ * If people put things in the panel which need more than single-click interaction,
+ * we don't want to close it. Right now we check for text inputs and menu buttons.
+ * We also check for being outside of any toolbaritem/toolbarbutton, ie on a blank
+ * part of the menu.
+ */
+ _isOnInteractiveElement(aEvent) {
+ function getMenuPopupForDescendant(aNode) {
+ let lastPopup = null;
+ while (
+ aNode &&
+ aNode.parentNode &&
+ aNode.parentNode.localName.startsWith("menu")
+ ) {
+ lastPopup = aNode.localName == "menupopup" ? aNode : lastPopup;
+ aNode = aNode.parentNode;
+ }
+ return lastPopup;
+ }
+
+ let target = aEvent.target;
+ let panel = this._getPanelForNode(aEvent.currentTarget);
+ // This can happen in e.g. customize mode. If there's no panel,
+ // there's clearly nothing for us to close; pretend we're interactive.
+ if (!panel) {
+ return true;
+ }
+ // We keep track of:
+ // whether we're in an input container (text field)
+ let inInput = false;
+ // whether we're in a popup/context menu
+ let inMenu = false;
+ // whether we're in a toolbarbutton/toolbaritem
+ let inItem = false;
+ // whether the current menuitem has a valid closemenu attribute
+ let menuitemCloseMenu = "auto";
+
+ // While keeping track of that, we go from the original target back up,
+ // to the panel if we have to. We bail as soon as we find an input,
+ // a toolbarbutton/item, or the panel:
+ while (target) {
+ // Skip out of iframes etc:
+ if (target.nodeType == target.DOCUMENT_NODE) {
+ if (!target.defaultView) {
+ // Err, we're done.
+ break;
+ }
+ // Find containing browser or iframe element in the parent doc.
+ target = target.defaultView.docShell.chromeEventHandler;
+ if (!target) {
+ break;
+ }
+ }
+ let tagName = target.localName;
+ inInput = tagName == "input";
+ inItem = tagName == "toolbaritem" || tagName == "toolbarbutton";
+ let isMenuItem = tagName == "menuitem";
+ inMenu = inMenu || isMenuItem;
+
+ if (isMenuItem && target.hasAttribute("closemenu")) {
+ let closemenuVal = target.getAttribute("closemenu");
+ menuitemCloseMenu =
+ closemenuVal == "single" || closemenuVal == "none"
+ ? closemenuVal
+ : "auto";
+ }
+
+ // Keep the menu open and break out of the loop if the click happened on
+ // the ShadowRoot or a disabled menu item.
+ if (
+ target.nodeType == target.DOCUMENT_FRAGMENT_NODE ||
+ target.getAttribute("disabled") == "true"
+ ) {
+ return true;
+ }
+
+ // This isn't in the loop condition because we want to break before
+ // changing |target| if any of these conditions are true
+ if (inInput || inItem || target == panel) {
+ break;
+ }
+ // We need specific code for popups: the item on which they were invoked
+ // isn't necessarily in their parentNode chain:
+ if (isMenuItem) {
+ let topmostMenuPopup = getMenuPopupForDescendant(target);
+ target =
+ (topmostMenuPopup && topmostMenuPopup.triggerNode) ||
+ target.parentNode;
+ } else {
+ target = target.parentNode;
+ }
+ }
+
+ // If the user clicked a menu item...
+ if (inMenu) {
+ // We care if we're in an input also,
+ // or if the user specified closemenu!="auto":
+ if (inInput || menuitemCloseMenu != "auto") {
+ return true;
+ }
+ // Otherwise, we're probably fine to close the panel
+ return false;
+ }
+ // If we're not in a menu, and we *are* in a type="menu" toolbarbutton,
+ // we'll now interact with the menu
+ if (inItem && target.getAttribute("type") == "menu") {
+ return true;
+ }
+ return inInput || !inItem;
+ },
+
+ hidePanelForNode(aNode) {
+ let panel = this._getPanelForNode(aNode);
+ if (panel) {
+ lazy.PanelMultiView.hidePopup(panel);
+ }
+ },
+
+ maybeAutoHidePanel(aEvent) {
+ let eventType = aEvent.type;
+ if (eventType == "keypress" && aEvent.keyCode != aEvent.DOM_VK_RETURN) {
+ return;
+ }
+
+ if (eventType == "click" && aEvent.button != 0) {
+ return;
+ }
+
+ // We don't check preventDefault - it makes sense that this was prevented,
+ // but we probably still want to close the panel. If consumers don't want
+ // this to happen, they should specify the closemenu attribute.
+ if (eventType != "command" && this._isOnInteractiveElement(aEvent)) {
+ return;
+ }
+
+ // We can't use event.target because we might have passed an anonymous
+ // content boundary as well, and so target points to the outer element in
+ // that case. Unfortunately, this means we get anonymous child nodes instead
+ // of the real ones, so looking for the 'stoooop, don't close me' attributes
+ // is more involved.
+ let target = aEvent.originalTarget;
+ while (target.parentNode && target.localName != "panel") {
+ if (
+ target.getAttribute("closemenu") == "none" ||
+ target.getAttribute("widget-type") == "view" ||
+ target.getAttribute("widget-type") == "button-and-view"
+ ) {
+ return;
+ }
+ target = target.parentNode;
+ }
+
+ // If we get here, we can actually hide the popup:
+ this.hidePanelForNode(aEvent.target);
+ },
+};
+Object.freeze(CustomizableUIInternal);
+
+export var CustomizableUI = {
+ /**
+ * Constant reference to the ID of the navigation toolbar.
+ */
+ AREA_NAVBAR: "nav-bar",
+ /**
+ * Constant reference to the ID of the menubar's toolbar.
+ */
+ AREA_MENUBAR: "toolbar-menubar",
+ /**
+ * Constant reference to the ID of the tabstrip toolbar.
+ */
+ AREA_TABSTRIP: "TabsToolbar",
+ /**
+ * Constant reference to the ID of the bookmarks toolbar.
+ */
+ AREA_BOOKMARKS: "PersonalToolbar",
+ /**
+ * Constant reference to the ID of the non-dymanic (fixed) list in the overflow panel.
+ */
+ AREA_FIXED_OVERFLOW_PANEL: "widget-overflow-fixed-list",
+
+ /**
+ * Constant indicating the area is a menu panel.
+ */
+ TYPE_MENU_PANEL: "menu-panel",
+ /**
+ * Constant indicating the area is a toolbar.
+ */
+ TYPE_TOOLBAR: "toolbar",
+
+ /**
+ * Constant indicating a XUL-type provider.
+ */
+ PROVIDER_XUL: "xul",
+ /**
+ * Constant indicating an API-type provider.
+ */
+ PROVIDER_API: "api",
+ /**
+ * Constant indicating dynamic (special) widgets: spring, spacer, and separator.
+ */
+ PROVIDER_SPECIAL: "special",
+
+ /**
+ * Constant indicating the widget is built-in
+ */
+ SOURCE_BUILTIN: "builtin",
+ /**
+ * Constant indicating the widget is externally provided
+ * (e.g. by add-ons or other items not part of the builtin widget set).
+ */
+ SOURCE_EXTERNAL: "external",
+
+ /**
+ * Constant indicating the reason the event was fired was a window closing
+ */
+ REASON_WINDOW_CLOSED: "window-closed",
+ /**
+ * Constant indicating the reason the event was fired was an area being
+ * unregistered separately from window closing mechanics.
+ */
+ REASON_AREA_UNREGISTERED: "area-unregistered",
+
+ /**
+ * Add a widget to an area.
+ * If the area to which you try to add is not known to CustomizableUI,
+ * this will throw.
+ * If the area to which you try to add is the same as the area in which
+ * the widget is currently placed, this will do the same as
+ * moveWidgetWithinArea.
+ * If the widget cannot be removed from its original location, this will
+ * no-op.
+ *
+ * This will fire an onWidgetAdded notification,
+ * and an onWidgetBeforeDOMChange and onWidgetAfterDOMChange notification
+ * for each window CustomizableUI knows about.
+ *
+ * @param aWidgetId the ID of the widget to add
+ * @param aArea the ID of the area to add the widget to
+ * @param aPosition the position at which to add the widget. If you do not
+ * pass a position, the widget will be added to the end
+ * of the area.
+ */
+ addWidgetToArea(aWidgetId, aArea, aPosition) {},
+ /**
+ * Remove a widget from its area. If the widget cannot be removed from its
+ * area, or is not in any area, this will no-op. Otherwise, this will fire an
+ * onWidgetRemoved notification, and an onWidgetBeforeDOMChange and
+ * onWidgetAfterDOMChange notification for each window CustomizableUI knows
+ * about.
+ *
+ * @param aWidgetId the ID of the widget to remove
+ */
+ removeWidgetFromArea(aWidgetId) {},
+ /**
+ * Get the placement of a widget. This is by far the best way to obtain
+ * information about what the state of your widget is. The internals of
+ * this call are cheap (no DOM necessary) and you will know where the user
+ * has put your widget.
+ *
+ * @param aWidgetId the ID of the widget whose placement you want to know
+ * @returns
+ * {
+ * area: "somearea", // The ID of the area where the widget is placed
+ * position: 42 // the index in the placements array corresponding to
+ * // your widget.
+ * }
+ *
+ * OR
+ *
+ * null // if the widget is not placed anywhere (ie in the palette)
+ */
+ getPlacementOfWidget(aWidgetId, aOnlyRegistered = true, aDeadAreas = false) {
+ return null;
+ },
+ /**
+ * Add listeners to a panel that will close it. For use from the menu panel
+ * and overflowable toolbar implementations, unlikely to be useful for
+ * consumers.
+ *
+ * @param aPanel the panel to which listeners should be attached.
+ */
+ addPanelCloseListeners(aPanel) {
+ CustomizableUIInternal.addPanelCloseListeners(aPanel);
+ },
+ /**
+ * Remove close listeners that have been added to a panel with
+ * addPanelCloseListeners. For use from the menu panel and overflowable
+ * toolbar implementations, unlikely to be useful for consumers.
+ *
+ * @param aPanel the panel from which listeners should be removed.
+ */
+ removePanelCloseListeners(aPanel) {
+ CustomizableUIInternal.removePanelCloseListeners(aPanel);
+ },
+ /**
+ * Notify toolbox(es) of a particular event. If you don't pass aWindow,
+ * all toolboxes will be notified. For use from Customize Mode only,
+ * do not use otherwise.
+ *
+ * @param aEvent the name of the event to send.
+ * @param aDetails optional, the details of the event.
+ * @param aWindow optional, the window in which to send the event.
+ */
+ dispatchToolboxEvent(aEvent, aDetails = {}, aWindow = null) {},
+};
+Object.freeze(CustomizableUI);
diff --git a/comm/mail/components/customizableui/PanelMultiView.sys.mjs b/comm/mail/components/customizableui/PanelMultiView.sys.mjs
new file mode 100644
index 0000000000..c68f88c586
--- /dev/null
+++ b/comm/mail/components/customizableui/PanelMultiView.sys.mjs
@@ -0,0 +1,1699 @@
+/* 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/. */
+
+/**
+ * Allows a popup panel to host multiple subviews. The main view shown when the
+ * panel is opened may slide out to display a subview, which in turn may lead to
+ * other subviews in a cascade menu pattern.
+ *
+ * The <panel> element should contain a <panelmultiview> element. Views are
+ * declared using <panelview> elements that are usually children of the main
+ * <panelmultiview> element, although they don't need to be, as views can also
+ * be imported into the panel from other panels or popup sets.
+ *
+ * The panel should be opened asynchronously using the openPopup static method
+ * on the PanelMultiView object. This will display the view specified using the
+ * mainViewId attribute on the contained <panelmultiview> element.
+ *
+ * Specific subviews can slide in using the showSubView method, and backwards
+ * navigation can be done using the goBack method or through a button in the
+ * subview headers.
+ *
+ * The process of displaying the main view or a new subview requires multiple
+ * steps to be completed, hence at any given time the <panelview> element may
+ * be in different states:
+ *
+ * -- Open or closed
+ *
+ * All the <panelview> elements start "closed", meaning that they are not
+ * associated to a <panelmultiview> element and can be located anywhere in
+ * the document. When the openPopup or showSubView methods are called, the
+ * relevant view becomes "open" and the <panelview> element may be moved to
+ * ensure it is a descendant of the <panelmultiview> element.
+ *
+ * The "ViewShowing" event is fired at this point, when the view is not
+ * visible yet. The event is allowed to cancel the operation, in which case
+ * the view is closed immediately.
+ *
+ * Closing the view does not move the node back to its original position.
+ *
+ * -- Visible or invisible
+ *
+ * This indicates whether the view is visible in the document from a layout
+ * perspective, regardless of whether it is currently scrolled into view. In
+ * fact, all subviews are already visible before they start sliding in.
+ *
+ * Before scrolling into view, a view may become visible but be placed in a
+ * special off-screen area of the document where layout and measurements can
+ * take place asynchronously.
+ *
+ * When navigating forward, an open view may become invisible but stay open
+ * after sliding out of view. The last known size of these views is still
+ * taken into account for determining the overall panel size.
+ *
+ * When navigating backwards, an open subview will first become invisible and
+ * then will be closed.
+ *
+ * -- Active or inactive
+ *
+ * This indicates whether the view is fully scrolled into the visible area
+ * and ready to receive mouse and keyboard events. An active view is always
+ * visible, but a visible view may be inactive. For example, during a scroll
+ * transition, both views will be inactive.
+ *
+ * When a view becomes active, the ViewShown event is fired synchronously,
+ * and the showSubView and goBack methods can be called for navigation.
+ *
+ * For the main view of the panel, the ViewShown event is dispatched during
+ * the "popupshown" event, which means that other "popupshown" handlers may
+ * be called before the view is active. Thus, code that needs to perform
+ * further navigation automatically should either use the ViewShown event or
+ * wait for an event loop tick, like BrowserTestUtils.waitForEvent does.
+ *
+ * -- Navigating with the keyboard
+ *
+ * An open view may keep state related to keyboard navigation, even if it is
+ * invisible. When a view is closed, keyboard navigation state is cleared.
+ *
+ * This diagram shows how <panelview> nodes move during navigation:
+ *
+ * In this <panelmultiview> In other panels Action
+ * ┌───┬───┬───┠┌───┬───â”
+ * │(A)│ B │ C │ │ D │ E │ Open panel
+ * └───┴───┴───┘ └───┴───┘
+ * ┌───┬───┬───┠┌───┬───â”
+ * │{A}│(C)│ B │ │ D │ E │ Show subview C
+ * └───┴───┴───┘ └───┴───┘
+ * ┌───┬───┬───┬───┠┌───â”
+ * │{A}│{C}│(D)│ B │ │ E │ Show subview D
+ * └───┴───┴───┴───┘ └───┘
+ * │ ┌───┬───┬───┬───┠┌───â”
+ * │ │{A}│(C)│ D │ B │ │ E │ Go back
+ * │ └───┴───┴───┴───┘ └───┘
+ * │ │ │
+ * │ │ └── Currently visible view
+ * │ │ │
+ * └───┴───┴── Open views
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "gBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+});
+
+/**
+ * Safety timeout after which asynchronous events will be canceled if any of the
+ * registered blockers does not return.
+ */
+const BLOCKERS_TIMEOUT_MS = 10000;
+
+const TRANSITION_PHASES = Object.freeze({
+ START: 1,
+ PREPARE: 2,
+ TRANSITION: 3,
+});
+
+let gNodeToObjectMap = new WeakMap();
+let gWindowsWithUnloadHandler = new WeakSet();
+
+/**
+ * Allows associating an object to a node lazily using a weak map.
+ *
+ * Classes deriving from this one may be easily converted to Custom Elements,
+ * although they would lose the ability of being associated lazily.
+ */
+var AssociatedToNode = class {
+ constructor(node) {
+ /**
+ * Node associated to this object.
+ */
+ this.node = node;
+
+ /**
+ * This promise is resolved when the current set of blockers set by event
+ * handlers have all been processed.
+ */
+ this._blockersPromise = Promise.resolve();
+ }
+
+ /**
+ * Retrieves the instance associated with the given node, constructing a new
+ * one if necessary. When the last reference to the node is released, the
+ * object instance will be garbage collected as well.
+ */
+ static forNode(node) {
+ let associatedToNode = gNodeToObjectMap.get(node);
+ if (!associatedToNode) {
+ associatedToNode = new this(node);
+ gNodeToObjectMap.set(node, associatedToNode);
+ }
+ return associatedToNode;
+ }
+
+ get document() {
+ return this.node.ownerDocument;
+ }
+
+ get window() {
+ return this.node.ownerGlobal;
+ }
+
+ _getBoundsWithoutFlushing(element) {
+ return this.window.windowUtils.getBoundsWithoutFlushing(element);
+ }
+
+ /**
+ * Dispatches a custom event on this element.
+ *
+ * @param {string} eventName Name of the event to dispatch.
+ * @param {object} [detail] Event detail object. Optional.
+ * @param {boolean} cancelable If the event can be canceled.
+ * @returns {boolean} `true` if the event was canceled by an event handler, `false`
+ * otherwise.
+ */
+ dispatchCustomEvent(eventName, detail, cancelable = false) {
+ let event = new this.window.CustomEvent(eventName, {
+ detail,
+ bubbles: true,
+ cancelable,
+ });
+ this.node.dispatchEvent(event);
+ return event.defaultPrevented;
+ }
+
+ /**
+ * Dispatches a custom event on this element and waits for any blocking
+ * promises registered using the "addBlocker" function on the details object.
+ * If this function is called again, the event is only dispatched after all
+ * the previously registered blockers have returned.
+ *
+ * The event can be canceled either by resolving any blocking promise to the
+ * boolean value "false" or by calling preventDefault on the event. Rejections
+ * and exceptions will be reported and will cancel the event.
+ *
+ * Blocking should be used sporadically because it slows down the interface.
+ * Also, non-reentrancy is not strictly guaranteed because a safety timeout of
+ * BLOCKERS_TIMEOUT_MS is implemented, after which the event will be canceled.
+ * This helps to prevent deadlocks if any of the event handlers does not
+ * resolve a blocker promise.
+ *
+ * @note Since there is no use case for dispatching different asynchronous
+ * events in parallel for the same element, this function will also wait
+ * for previous blockers when the event name is different.
+ *
+ * @param eventName
+ * Name of the custom event to dispatch.
+ *
+ * @resolves True if the event was canceled by a handler, false otherwise.
+ */
+ async dispatchAsyncEvent(eventName) {
+ // Wait for all the previous blockers before dispatching the event.
+ let blockersPromise = this._blockersPromise.catch(() => {});
+ return (this._blockersPromise = blockersPromise.then(async () => {
+ let blockers = new Set();
+ let cancel = this.dispatchCustomEvent(
+ eventName,
+ {
+ addBlocker(promise) {
+ // Any exception in the blocker will cancel the operation.
+ blockers.add(
+ promise.catch(ex => {
+ console.error(ex);
+ return true;
+ })
+ );
+ },
+ },
+ true
+ );
+ if (blockers.size) {
+ let timeoutPromise = new Promise((resolve, reject) => {
+ this.window.setTimeout(reject, BLOCKERS_TIMEOUT_MS);
+ });
+ try {
+ let results = await Promise.race([
+ Promise.all(blockers),
+ timeoutPromise,
+ ]);
+ cancel = cancel || results.some(result => result === false);
+ } catch (ex) {
+ console.error(
+ new Error(`One of the blockers for ${eventName} timed out.`)
+ );
+ return true;
+ }
+ }
+ return cancel;
+ }));
+ }
+};
+
+/**
+ * This is associated to <panelmultiview> elements.
+ */
+export class PanelMultiView extends AssociatedToNode {
+ /**
+ * Tries to open the specified <panel> and displays the main view specified
+ * with the "mainViewId" attribute on the <panelmultiview> node it contains.
+ *
+ * If the panel does not contain a <panelmultiview>, it is opened directly.
+ * This allows consumers like page actions to accept different panel types.
+ *
+ * @see The non-static openPopup method for details.
+ */
+ static async openPopup(panelNode, ...args) {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ return this.forNode(panelMultiViewNode).openPopup(...args);
+ }
+ panelNode.openPopup(...args);
+ return true;
+ }
+
+ /**
+ * Closes the specified <panel> which contains a <panelmultiview> node.
+ *
+ * If the panel does not contain a <panelmultiview>, it is closed directly.
+ * This allows consumers like page actions to accept different panel types.
+ *
+ * @see The non-static hidePopup method for details.
+ */
+ static hidePopup(panelNode) {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ this.forNode(panelMultiViewNode).hidePopup();
+ } else {
+ panelNode.hidePopup();
+ }
+ }
+
+ /**
+ * Removes the specified <panel> from the document, ensuring that any
+ * <panelmultiview> node it contains is destroyed properly.
+ *
+ * If the viewCacheId attribute is present on the <panelmultiview> element,
+ * imported subviews will be moved out again to the element it specifies, so
+ * that the panel element can be removed safely.
+ *
+ * If the panel does not contain a <panelmultiview>, it is removed directly.
+ * This allows consumers like page actions to accept different panel types.
+ */
+ static removePopup(panelNode) {
+ try {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ let panelMultiView = this.forNode(panelMultiViewNode);
+ panelMultiView._moveOutKids();
+ panelMultiView.disconnect();
+ }
+ } finally {
+ // Make sure to remove the panel element even if disconnecting fails.
+ panelNode.remove();
+ }
+ }
+
+ /**
+ * Ensures that when the specified window is closed all the <panelmultiview>
+ * node it contains are destroyed properly.
+ */
+ static ensureUnloadHandlerRegistered(window) {
+ if (gWindowsWithUnloadHandler.has(window)) {
+ return;
+ }
+
+ window.addEventListener(
+ "unload",
+ () => {
+ for (let panelMultiViewNode of window.document.querySelectorAll(
+ "panelmultiview"
+ )) {
+ this.forNode(panelMultiViewNode).disconnect();
+ }
+ },
+ { once: true }
+ );
+
+ gWindowsWithUnloadHandler.add(window);
+ }
+
+ get _panel() {
+ return this.node.parentNode;
+ }
+
+ set _transitioning(val) {
+ if (val) {
+ this.node.setAttribute("transitioning", "true");
+ } else {
+ this.node.removeAttribute("transitioning");
+ }
+ }
+
+ get _screenManager() {
+ if (this.__screenManager) {
+ return this.__screenManager;
+ }
+ return (this.__screenManager = Cc[
+ "@mozilla.org/gfx/screenmanager;1"
+ ].getService(Ci.nsIScreenManager));
+ }
+
+ constructor(node) {
+ super(node);
+ this._openPopupPromise = Promise.resolve(false);
+ this._openPopupCancelCallback = () => {};
+ }
+
+ connect() {
+ this.connected = true;
+
+ PanelMultiView.ensureUnloadHandlerRegistered(this.window);
+
+ let viewContainer = (this._viewContainer =
+ this.document.createXULElement("box"));
+ viewContainer.classList.add("panel-viewcontainer");
+
+ let viewStack = (this._viewStack = this.document.createXULElement("box"));
+ viewStack.classList.add("panel-viewstack");
+ viewContainer.append(viewStack);
+
+ let offscreenViewContainer = this.document.createXULElement("box");
+ offscreenViewContainer.classList.add("panel-viewcontainer", "offscreen");
+
+ let offscreenViewStack = (this._offscreenViewStack =
+ this.document.createXULElement("box"));
+ offscreenViewStack.classList.add("panel-viewstack");
+ offscreenViewContainer.append(offscreenViewStack);
+
+ this.node.prepend(offscreenViewContainer);
+ this.node.prepend(viewContainer);
+
+ this.openViews = [];
+
+ this._panel.addEventListener("popupshowing", this);
+ this._panel.addEventListener("popuppositioned", this);
+ this._panel.addEventListener("popuphidden", this);
+ this._panel.addEventListener("popupshown", this);
+
+ // Proxy these public properties and methods, as used elsewhere by various
+ // parts of the browser, to this instance.
+ ["goBack", "showSubView"].forEach(method => {
+ Object.defineProperty(this.node, method, {
+ enumerable: true,
+ value: (...args) => this[method](...args),
+ });
+ });
+ }
+
+ disconnect() {
+ // Guard against re-entrancy.
+ if (!this.node || !this.connected) {
+ return;
+ }
+
+ this._panel.removeEventListener("mousemove", this);
+ this._panel.removeEventListener("popupshowing", this);
+ this._panel.removeEventListener("popuppositioned", this);
+ this._panel.removeEventListener("popupshown", this);
+ this._panel.removeEventListener("popuphidden", this);
+ this.window.removeEventListener("keydown", this, true);
+ this.node =
+ this._openPopupPromise =
+ this._openPopupCancelCallback =
+ this._viewContainer =
+ this._viewStack =
+ this._transitionDetails =
+ null;
+ }
+
+ /**
+ * Tries to open the panel associated with this PanelMultiView, and displays
+ * the main view specified with the "mainViewId" attribute.
+ *
+ * The hidePopup method can be called while the operation is in progress to
+ * prevent the panel from being displayed. View events may also cancel the
+ * operation, so there is no guarantee that the panel will become visible.
+ *
+ * The "popuphidden" event will be fired either when the operation is canceled
+ * or when the popup is closed later. This event can be used for example to
+ * reset the "open" state of the anchor or tear down temporary panels.
+ *
+ * If this method is called again before the panel is shown, the result
+ * depends on the operation currently in progress. If the operation was not
+ * canceled, the panel is opened using the arguments from the previous call,
+ * and this call is ignored. If the operation was canceled, it will be
+ * retried again using the arguments from this call.
+ *
+ * It's not necessary for the <panelmultiview> binding to be connected when
+ * this method is called, but the containing panel must have its display
+ * turned on, for example it shouldn't have the "hidden" attribute.
+ *
+ * @param anchor
+ * The node to anchor the popup to.
+ * @param options
+ * Either options to use or a string position. This is forwarded to
+ * the openPopup method of the panel.
+ * @param args
+ * Additional arguments to be forwarded to the openPopup method of the
+ * panel.
+ *
+ * @resolves With true as soon as the request to display the panel has been
+ * sent, or with false if the operation was canceled. The state of
+ * the panel at this point is not guaranteed. It may be still
+ * showing, completely shown, or completely hidden.
+ * @rejects If an exception is thrown at any point in the process before the
+ * request to display the panel is sent.
+ */
+ async openPopup(anchor, options, ...args) {
+ // Set up the function that allows hidePopup or a second call to showPopup
+ // to cancel the specific panel opening operation that we're starting below.
+ // This function must be synchronous, meaning we can't use Promise.race,
+ // because hidePopup wants to dispatch the "popuphidden" event synchronously
+ // even if the panel has not been opened yet.
+ let canCancel = true;
+ let cancelCallback = (this._openPopupCancelCallback = () => {
+ // If the cancel callback is called and the panel hasn't been prepared
+ // yet, cancel showing it. Setting canCancel to false will prevent the
+ // popup from opening. If the panel has opened by the time the cancel
+ // callback is called, canCancel will be false already, and we will not
+ // fire the "popuphidden" event.
+ if (canCancel && this.node) {
+ canCancel = false;
+ this.dispatchCustomEvent("popuphidden");
+ }
+ });
+
+ // Create a promise that is resolved with the result of the last call to
+ // this method, where errors indicate that the panel was not opened.
+ let openPopupPromise = this._openPopupPromise.catch(() => {
+ return false;
+ });
+
+ // Make the preparation done before showing the panel non-reentrant. The
+ // promise created here will be resolved only after the panel preparation is
+ // completed, even if a cancellation request is received in the meantime.
+ return (this._openPopupPromise = openPopupPromise.then(async wasShown => {
+ // The panel may have been destroyed in the meantime.
+ if (!this.node) {
+ return false;
+ }
+ // If the panel has been already opened there is nothing more to do. We
+ // check the actual state of the panel rather than setting some state in
+ // our handler of the "popuphidden" event because this has a lower chance
+ // of locking indefinitely if events aren't raised in the expected order.
+ if (wasShown && ["open", "showing"].includes(this._panel.state)) {
+ return true;
+ }
+ try {
+ if (!this.connected) {
+ this.connect();
+ }
+ // Allow any of the ViewShowing handlers to prevent showing the main view.
+ if (!(await this._showMainView())) {
+ cancelCallback();
+ }
+ } catch (ex) {
+ cancelCallback();
+ throw ex;
+ }
+ // If a cancellation request was received there is nothing more to do.
+ if (!canCancel || !this.node) {
+ return false;
+ }
+ // We have to set canCancel to false before opening the popup because the
+ // hidePopup method of PanelMultiView can be re-entered by event handlers.
+ // If the openPopup call fails, however, we still have to dispatch the
+ // "popuphidden" event even if canCancel was set to false.
+ try {
+ canCancel = false;
+ this._panel.openPopup(anchor, options, ...args);
+
+ // On Windows, if another popup is hiding while we call openPopup, the
+ // call won't fail but the popup won't open. In this case, we have to
+ // dispatch an artificial "popuphidden" event to reset our state.
+ if (this._panel.state == "closed" && this.openViews.length) {
+ this.dispatchCustomEvent("popuphidden");
+ return false;
+ }
+
+ if (
+ options &&
+ typeof options == "object" &&
+ options.triggerEvent &&
+ options.triggerEvent.type == "keypress" &&
+ this.openViews.length
+ ) {
+ // This was opened via the keyboard, so focus the first item.
+ this.openViews[0].focusWhenActive = true;
+ }
+
+ return true;
+ } catch (ex) {
+ this.dispatchCustomEvent("popuphidden");
+ throw ex;
+ }
+ }));
+ }
+
+ /**
+ * Closes the panel associated with this PanelMultiView.
+ *
+ * If the openPopup method was called but the panel has not been displayed
+ * yet, the operation is canceled and the panel will not be displayed, but the
+ * "popuphidden" event is fired synchronously anyways.
+ *
+ * This means that by the time this method returns all the operations handled
+ * by the "popuphidden" event are completed, for example resetting the "open"
+ * state of the anchor, and the panel is already invisible.
+ */
+ hidePopup() {
+ if (!this.node || !this.connected) {
+ return;
+ }
+
+ // If we have already reached the _panel.openPopup call in the openPopup
+ // method, we can call hidePopup. Otherwise, we have to cancel the latest
+ // request to open the panel, which will have no effect if the request has
+ // been canceled already.
+ if (["open", "showing"].includes(this._panel.state)) {
+ this._panel.hidePopup();
+ } else {
+ this._openPopupCancelCallback();
+ }
+
+ // We close all the views synchronously, so that they are ready to be opened
+ // in other PanelMultiView instances. The "popuphidden" handler may also
+ // call this function, but the second time openViews will be empty.
+ this.closeAllViews();
+ }
+
+ /**
+ * Move any child subviews into the element defined by "viewCacheId" to make
+ * sure they will not be removed together with the <panelmultiview> element.
+ */
+ _moveOutKids() {
+ let viewCacheId = this.node.getAttribute("viewCacheId");
+ if (!viewCacheId) {
+ return;
+ }
+
+ // Node.children and Node.children is live to DOM changes like the
+ // ones we're about to do, so iterate over a static copy:
+ let subviews = Array.from(this._viewStack.children);
+ let viewCache = this.document.getElementById(viewCacheId);
+ for (let subview of subviews) {
+ viewCache.appendChild(subview);
+ }
+ }
+
+ /**
+ * Slides in the specified view as a subview.
+ *
+ * @param viewIdOrNode
+ * DOM element or string ID of the <panelview> to display.
+ * @param anchor
+ * DOM element that triggered the subview, which will be highlighted
+ * and whose "label" attribute will be used for the title of the
+ * subview when a "title" attribute is not specified.
+ */
+ showSubView(viewIdOrNode, anchor) {
+ this._showSubView(viewIdOrNode, anchor).catch(console.error);
+ }
+ async _showSubView(viewIdOrNode, anchor) {
+ let viewNode =
+ typeof viewIdOrNode == "string"
+ ? this.document.getElementById(viewIdOrNode)
+ : viewIdOrNode;
+ if (!viewNode) {
+ console.error(new Error(`Subview ${viewIdOrNode} doesn't exist.`));
+ return;
+ }
+
+ if (!this.openViews.length) {
+ console.error(new Error(`Cannot show a subview in a closed panel.`));
+ return;
+ }
+
+ let prevPanelView = this.openViews[this.openViews.length - 1];
+ let nextPanelView = PanelView.forNode(viewNode);
+ if (this.openViews.includes(nextPanelView)) {
+ console.error(new Error(`Subview ${viewNode.id} is already open.`));
+ return;
+ }
+
+ // Do not re-enter the process if navigation is already in progress. Since
+ // there is only one active view at any given time, we can do this check
+ // safely, even considering that during the navigation process the actual
+ // view to which prevPanelView refers will change.
+ if (!prevPanelView.active) {
+ return;
+ }
+ // If prevPanelView._doingKeyboardActivation is true, it will be reset to
+ // false synchronously. Therefore, we must capture it before we use any
+ // "await" statements.
+ let doingKeyboardActivation = prevPanelView._doingKeyboardActivation;
+ // Marking the view that is about to scrolled out of the visible area as
+ // inactive will prevent re-entrancy and also disable keyboard navigation.
+ // From this point onwards, "await" statements can be used safely.
+ prevPanelView.active = false;
+
+ // Provide visual feedback while navigation is in progress, starting before
+ // the transition starts and ending when the previous view is invisible.
+ if (anchor) {
+ anchor.setAttribute("open", "true");
+ }
+ try {
+ // If the ViewShowing event cancels the operation we have to re-enable
+ // keyboard navigation, but this must be avoided if the panel was closed.
+ if (!(await this._openView(nextPanelView))) {
+ if (prevPanelView.isOpenIn(this)) {
+ // We don't raise a ViewShown event because nothing actually changed.
+ // Technically we should use a different state flag just because there
+ // is code that could check the "active" property to determine whether
+ // to wait for a ViewShown event later, but this only happens in
+ // regression tests and is less likely to be a technique used in
+ // production code, where use of ViewShown is less common.
+ prevPanelView.active = true;
+ }
+ return;
+ }
+
+ prevPanelView.captureKnownSize();
+
+ // The main view of a panel can be a subview in another one. Make sure to
+ // reset all the properties that may be set on a subview.
+ nextPanelView.mainview = false;
+ // The header may change based on how the subview was opened.
+ nextPanelView.headerText =
+ viewNode.getAttribute("title") ||
+ (anchor && anchor.getAttribute("label"));
+ // The constrained width of subviews may also vary between panels.
+ nextPanelView.minMaxWidth = prevPanelView.knownWidth;
+
+ if (anchor) {
+ viewNode.classList.add("PanelUI-subView");
+ }
+
+ await this._transitionViews(prevPanelView.node, viewNode, false, anchor);
+ } finally {
+ if (anchor) {
+ anchor.removeAttribute("open");
+ }
+ }
+
+ nextPanelView.focusWhenActive = doingKeyboardActivation;
+ this._activateView(nextPanelView);
+ }
+
+ /**
+ * Navigates backwards by sliding out the most recent subview.
+ */
+ goBack() {
+ this._goBack().catch(console.error);
+ }
+ async _goBack() {
+ if (this.openViews.length < 2) {
+ // This may be called by keyboard navigation or external code when only
+ // the main view is open.
+ return;
+ }
+
+ let prevPanelView = this.openViews[this.openViews.length - 1];
+ let nextPanelView = this.openViews[this.openViews.length - 2];
+
+ // Like in the showSubView method, do not re-enter navigation while it is
+ // in progress, and make the view inactive immediately. From this point
+ // onwards, "await" statements can be used safely.
+ if (!prevPanelView.active) {
+ return;
+ }
+
+ prevPanelView.active = false;
+
+ prevPanelView.captureKnownSize();
+
+ await this._transitionViews(prevPanelView.node, nextPanelView.node, true);
+
+ this._closeLatestView();
+
+ this._activateView(nextPanelView);
+ }
+
+ /**
+ * Prepares the main view before showing the panel.
+ */
+ async _showMainView() {
+ let nextPanelView = PanelView.forNode(
+ this.document.getElementById(this.node.getAttribute("mainViewId"))
+ );
+
+ // If the view is already open in another panel, close the panel first.
+ let oldPanelMultiViewNode = nextPanelView.node.panelMultiView;
+ if (oldPanelMultiViewNode) {
+ PanelMultiView.forNode(oldPanelMultiViewNode).hidePopup();
+ // Wait for a layout flush after hiding the popup, otherwise the view may
+ // not be displayed correctly for some time after the new panel is opened.
+ // This is filed as bug 1441015.
+ await this.window.promiseDocumentFlushed(() => {});
+ }
+
+ if (!(await this._openView(nextPanelView))) {
+ return false;
+ }
+
+ // The main view of a panel can be a subview in another one. Make sure to
+ // reset all the properties that may be set on a subview.
+ nextPanelView.mainview = true;
+ nextPanelView.headerText = "";
+ nextPanelView.minMaxWidth = 0;
+
+ // Ensure the view will be visible once the panel is opened.
+ nextPanelView.visible = true;
+
+ return true;
+ }
+
+ /**
+ * Opens the specified PanelView and dispatches the ViewShowing event, which
+ * can be used to populate the subview or cancel the operation.
+ *
+ * This also clears all the attributes and styles that may be left by a
+ * transition that was interrupted.
+ *
+ * @resolves With true if the view was opened, false otherwise.
+ */
+ async _openView(panelView) {
+ if (panelView.node.parentNode != this._viewStack) {
+ this._viewStack.appendChild(panelView.node);
+ }
+
+ panelView.node.panelMultiView = this.node;
+ this.openViews.push(panelView);
+
+ let canceled = await panelView.dispatchAsyncEvent("ViewShowing");
+
+ // The panel can be hidden while we are processing the ViewShowing event.
+ // This results in all the views being closed synchronously, and at this
+ // point the ViewHiding event has already been dispatched for all of them.
+ if (!this.openViews.length) {
+ return false;
+ }
+
+ // Check if the event requested cancellation but the panel is still open.
+ if (canceled) {
+ // Handlers for ViewShowing can't know if a different handler requested
+ // cancellation, so this will dispatch a ViewHiding event to give a chance
+ // to clean up.
+ this._closeLatestView();
+ return false;
+ }
+
+ // Clean up all the attributes and styles related to transitions. We do this
+ // here rather than when the view is closed because we are likely to make
+ // other DOM modifications soon, which isn't the case when closing.
+ let { style } = panelView.node;
+ style.removeProperty("outline");
+ style.removeProperty("width");
+
+ return true;
+ }
+
+ /**
+ * Activates the specified view and raises the ViewShown event, unless the
+ * view was closed in the meantime.
+ */
+ _activateView(panelView) {
+ if (panelView.isOpenIn(this)) {
+ panelView.active = true;
+ if (panelView.focusWhenActive) {
+ panelView.focusFirstNavigableElement(false, true);
+ panelView.focusWhenActive = false;
+ }
+ panelView.dispatchCustomEvent("ViewShown");
+ }
+ }
+
+ /**
+ * Closes the most recent PanelView and raises the ViewHiding event.
+ *
+ * @note The ViewHiding event is not cancelable and should probably be renamed
+ * to ViewHidden or ViewClosed instead, see bug 1438507.
+ */
+ _closeLatestView() {
+ let panelView = this.openViews.pop();
+ panelView.clearNavigation();
+ panelView.dispatchCustomEvent("ViewHiding");
+ panelView.node.panelMultiView = null;
+ // Views become invisible synchronously when they are closed, and they won't
+ // become visible again until they are opened. When this is called at the
+ // end of backwards navigation, the view is already invisible.
+ panelView.visible = false;
+ }
+
+ /**
+ * Closes all the views that are currently open.
+ */
+ closeAllViews() {
+ // Raise ViewHiding events for open views in reverse order.
+ while (this.openViews.length) {
+ this._closeLatestView();
+ }
+ }
+
+ /**
+ * Apply a transition to 'slide' from the currently active view to the next
+ * one.
+ * Sliding the next subview in means that the previous panelview stays where it
+ * is and the active panelview slides in from the left in LTR mode, right in
+ * RTL mode.
+ *
+ * @param {panelview} previousViewNode Node that is currently displayed, but
+ * is about to be transitioned away. This
+ * must be already inactive at this point.
+ * @param {panelview} viewNode - Node that will becode the active view,
+ * after the transition has finished.
+ * @param {boolean} reverse Whether we're navigation back to a
+ * previous view or forward to a next view.
+ */
+ async _transitionViews(previousViewNode, viewNode, reverse) {
+ const { window } = this;
+
+ let nextPanelView = PanelView.forNode(viewNode);
+ let prevPanelView = PanelView.forNode(previousViewNode);
+
+ let details = (this._transitionDetails = {
+ phase: TRANSITION_PHASES.START,
+ });
+
+ // Set the viewContainer dimensions to make sure only the current view is
+ // visible.
+ let olderView = reverse ? nextPanelView : prevPanelView;
+ this._viewContainer.style.minHeight = olderView.knownHeight + "px";
+ this._viewContainer.style.height = prevPanelView.knownHeight + "px";
+ this._viewContainer.style.width = prevPanelView.knownWidth + "px";
+ // Lock the dimensions of the window that hosts the popup panel.
+ let rect = this._getBoundsWithoutFlushing(this._panel);
+ this._panel.style.width = rect.width + "px";
+ this._panel.style.height = rect.height + "px";
+
+ let viewRect;
+ if (reverse) {
+ // Use the cached size when going back to a previous view, but not when
+ // reopening a subview, because its contents may have changed.
+ viewRect = {
+ width: nextPanelView.knownWidth,
+ height: nextPanelView.knownHeight,
+ };
+ nextPanelView.visible = true;
+ } else if (viewNode.customRectGetter) {
+ // We use a customRectGetter for WebExtensions panels, because they need
+ // to query the size from an embedded browser. The presence of this
+ // getter also provides an indication that the view node shouldn't be
+ // moved around, otherwise the state of the browser would get disrupted.
+ let width = prevPanelView.knownWidth;
+ let height = prevPanelView.knownHeight;
+ viewRect = Object.assign({ height, width }, viewNode.customRectGetter());
+ nextPanelView.visible = true;
+ // Until the header is visible, it has 0 height.
+ // Wait for layout before measuring it
+ let header = viewNode.firstElementChild;
+ if (header && header.classList.contains("panel-header")) {
+ viewRect.height += await window.promiseDocumentFlushed(() => {
+ return this._getBoundsWithoutFlushing(header).height;
+ });
+ }
+ } else {
+ this._offscreenViewStack.style.minHeight = olderView.knownHeight + "px";
+ this._offscreenViewStack.appendChild(viewNode);
+ nextPanelView.visible = true;
+
+ viewRect = await window.promiseDocumentFlushed(() => {
+ return this._getBoundsWithoutFlushing(viewNode);
+ });
+ // Bail out if the panel was closed in the meantime.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+
+ // Place back the view after all the other views that are already open in
+ // order for the transition to work as expected.
+ this._viewStack.appendChild(viewNode);
+
+ this._offscreenViewStack.style.removeProperty("min-height");
+ }
+
+ this._transitioning = true;
+ details.phase = TRANSITION_PHASES.PREPARE;
+
+ // The 'magic' part: build up the amount of pixels to move right or left.
+ let moveToLeft =
+ (this.window.RTL_UI && !reverse) || (!this.window.RTL_UI && reverse);
+ let deltaX = prevPanelView.knownWidth;
+ let deepestNode = reverse ? previousViewNode : viewNode;
+
+ // With a transition when navigating backwards - user hits the 'back'
+ // button - we need to make sure that the views are positioned in a way
+ // that a translateX() unveils the previous view from the right direction.
+ if (reverse) {
+ this._viewStack.style.marginInlineStart = "-" + deltaX + "px";
+ }
+
+ // Set the transition style and listen for its end to clean up and make sure
+ // the box sizing becomes dynamic again.
+ // Somehow, putting these properties in PanelUI.css doesn't work for newly
+ // shown nodes in a XUL parent node.
+ this._viewStack.style.transition =
+ "transform var(--animation-easing-function)" +
+ " var(--panelui-subview-transition-duration)";
+ this._viewStack.style.willChange = "transform";
+ // Use an outline instead of a border so that the size is not affected.
+ deepestNode.style.outline = "1px solid var(--panel-separator-color)";
+
+ // Now that all the elements are in place for the start of the transition,
+ // give the layout code a chance to set the initial values.
+ await window.promiseDocumentFlushed(() => {});
+ // Bail out if the panel was closed in the meantime.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+
+ // Now set the viewContainer dimensions to that of the new view, which
+ // kicks of the height animation.
+ this._viewContainer.style.height = viewRect.height + "px";
+ this._viewContainer.style.width = viewRect.width + "px";
+ this._panel.style.removeProperty("width");
+ this._panel.style.removeProperty("height");
+
+ // We're setting the width property to prevent flickering during the
+ // sliding animation with smaller views.
+ viewNode.style.width = viewRect.width + "px";
+
+ // Kick off the transition!
+ details.phase = TRANSITION_PHASES.TRANSITION;
+
+ // If we're going to show the main view, we can remove the
+ // min-height property on the view container.
+ if (viewNode.getAttribute("mainview")) {
+ this._viewContainer.style.removeProperty("min-height");
+ }
+
+ this._viewStack.style.transform =
+ "translateX(" + (moveToLeft ? "" : "-") + deltaX + "px)";
+
+ await new Promise(resolve => {
+ details.resolve = resolve;
+ this._viewContainer.addEventListener(
+ "transitionend",
+ (details.listener = ev => {
+ // It's quite common that `height` on the view container doesn't need
+ // to transition, so we make sure to do all the work on the transform
+ // transition-end, because that is guaranteed to happen.
+ if (ev.target != this._viewStack || ev.propertyName != "transform") {
+ return;
+ }
+ this._viewContainer.removeEventListener(
+ "transitionend",
+ details.listener
+ );
+ delete details.listener;
+ resolve();
+ })
+ );
+ this._viewContainer.addEventListener(
+ "transitioncancel",
+ (details.cancelListener = ev => {
+ if (ev.target != this._viewStack) {
+ return;
+ }
+ this._viewContainer.removeEventListener(
+ "transitioncancel",
+ details.cancelListener
+ );
+ delete details.cancelListener;
+ resolve();
+ })
+ );
+ });
+
+ // Bail out if the panel was closed during the transition.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+ prevPanelView.visible = false;
+
+ // This will complete the operation by removing any transition properties.
+ nextPanelView.node.style.removeProperty("width");
+ deepestNode.style.removeProperty("outline");
+ this._cleanupTransitionPhase();
+
+ nextPanelView.focusSelectedElement();
+ }
+
+ /**
+ * Attempt to clean up the attributes and properties set by `_transitionViews`
+ * above. Which attributes and properties depends on the phase the transition
+ * was left from.
+ */
+ _cleanupTransitionPhase() {
+ if (!this._transitionDetails) {
+ return;
+ }
+
+ let { phase, resolve, listener, cancelListener } = this._transitionDetails;
+ this._transitionDetails = null;
+
+ if (phase >= TRANSITION_PHASES.START) {
+ this._panel.style.removeProperty("width");
+ this._panel.style.removeProperty("height");
+ this._viewContainer.style.removeProperty("height");
+ this._viewContainer.style.removeProperty("width");
+ }
+ if (phase >= TRANSITION_PHASES.PREPARE) {
+ this._transitioning = false;
+ this._viewStack.style.removeProperty("margin-inline-start");
+ this._viewStack.style.removeProperty("transition");
+ }
+ if (phase >= TRANSITION_PHASES.TRANSITION) {
+ this._viewStack.style.removeProperty("transform");
+ if (listener) {
+ this._viewContainer.removeEventListener("transitionend", listener);
+ }
+ if (cancelListener) {
+ this._viewContainer.removeEventListener(
+ "transitioncancel",
+ cancelListener
+ );
+ }
+ if (resolve) {
+ resolve();
+ }
+ }
+ }
+
+ _calculateMaxHeight(aEvent) {
+ // While opening the panel, we have to limit the maximum height of any
+ // view based on the space that will be available. We cannot just use
+ // window.screen.availTop and availHeight because these may return an
+ // incorrect value when the window spans multiple screens.
+ let anchor = this._panel.anchorNode;
+ let anchorRect = anchor.getBoundingClientRect();
+
+ let screen = this._screenManager.screenForRect(
+ anchor.screenX,
+ anchor.screenY,
+ anchorRect.width,
+ anchorRect.height
+ );
+ let availTop = {},
+ availHeight = {};
+ screen.GetAvailRect({}, availTop, {}, availHeight);
+ let cssAvailTop = availTop.value / screen.defaultCSSScaleFactor;
+
+ // The distance from the anchor to the available margin of the screen is
+ // based on whether the panel will open towards the top or the bottom.
+ let maxHeight;
+ if (aEvent.alignmentPosition.startsWith("before_")) {
+ maxHeight = anchor.screenY - cssAvailTop;
+ } else {
+ let anchorScreenBottom = anchor.screenY + anchorRect.height;
+ let cssAvailHeight = availHeight.value / screen.defaultCSSScaleFactor;
+ maxHeight = cssAvailTop + cssAvailHeight - anchorScreenBottom;
+ }
+
+ // To go from the maximum height of the panel to the maximum height of
+ // the view stack, we need to subtract the height of the arrow and the
+ // height of the opposite margin, but we cannot get their actual values
+ // because the panel is not visible yet. However, we know that this is
+ // currently 11px on Mac, 13px on Windows, and 13px on Linux. We also
+ // want an extra margin, both for visual reasons and to prevent glitches
+ // due to small rounding errors. So, we just use a value that makes
+ // sense for all platforms. If the arrow visuals change significantly,
+ // this value will be easy to adjust.
+ const EXTRA_MARGIN_PX = 20;
+ maxHeight -= EXTRA_MARGIN_PX;
+ return maxHeight;
+ }
+
+ handleEvent(aEvent) {
+ // Only process actual popup events from the panel or events we generate
+ // ourselves, but not from menus being shown from within the panel.
+ if (
+ aEvent.type.startsWith("popup") &&
+ aEvent.target != this._panel &&
+ aEvent.target != this.node
+ ) {
+ return;
+ }
+ switch (aEvent.type) {
+ case "keydown":
+ // Since we start listening for the "keydown" event when the popup is
+ // already showing and stop listening when the panel is hidden, we
+ // always have at least one view open.
+ let currentView = this.openViews[this.openViews.length - 1];
+ currentView.keyNavigation(aEvent);
+ break;
+ case "mousemove":
+ this.openViews.forEach(panelView => panelView.clearNavigation());
+ break;
+ case "popupshowing": {
+ this._viewContainer.setAttribute("panelopen", "true");
+ if (!this.node.hasAttribute("disablekeynav")) {
+ // We add the keydown handler on the window so that it handles key
+ // presses when a panel appears but doesn't get focus, as happens
+ // when a button to open a panel is clicked with the mouse.
+ // However, this means the listener is on an ancestor of the panel,
+ // which means that handlers such as ToolbarKeyboardNavigator are
+ // deeper in the tree. Therefore, this must be a capturing listener
+ // so we get the event first.
+ this.window.addEventListener("keydown", this, true);
+ this._panel.addEventListener("mousemove", this);
+ }
+ break;
+ }
+ case "popuppositioned": {
+ if (this._panel.state == "showing") {
+ let maxHeight = this._calculateMaxHeight(aEvent);
+ this._viewStack.style.maxHeight = maxHeight + "px";
+ this._offscreenViewStack.style.maxHeight = maxHeight + "px";
+ }
+ break;
+ }
+ case "popupshown":
+ // The main view is always open and visible when the panel is first
+ // shown, so we can check the height of the description elements it
+ // contains and notify consumers using the ViewShown event. In order to
+ // minimize flicker we need to allow synchronous reflows, and we still
+ // make sure the ViewShown event is dispatched synchronously.
+ let mainPanelView = this.openViews[0];
+ this._activateView(mainPanelView);
+ break;
+ case "popuphidden": {
+ // WebExtensions consumers can hide the popup from viewshowing, or
+ // mid-transition, which disrupts our state:
+ this._transitioning = false;
+ this._viewContainer.removeAttribute("panelopen");
+ this._cleanupTransitionPhase();
+ this.window.removeEventListener("keydown", this, true);
+ this._panel.removeEventListener("mousemove", this);
+ this.closeAllViews();
+
+ // Clear the main view size caches. The dimensions could be different
+ // when the popup is opened again, e.g. through touch mode sizing.
+ this._viewContainer.style.removeProperty("min-height");
+ this._viewStack.style.removeProperty("max-height");
+ this._viewContainer.style.removeProperty("width");
+ this._viewContainer.style.removeProperty("height");
+
+ this.dispatchCustomEvent("PanelMultiViewHidden");
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * This is associated to <panelview> elements.
+ */
+export class PanelView extends AssociatedToNode {
+ constructor(node) {
+ super(node);
+
+ /**
+ * Indicates whether the view is active. When this is false, consumers can
+ * wait for the ViewShown event to know when the view becomes active.
+ */
+ this.active = false;
+
+ /**
+ * Specifies whether the view should be focused when active. When this
+ * is true, the first navigable element in the view will be focused
+ * when the view becomes active. This should be set to true when the view
+ * is activated from the keyboard. It will be set to false once the view
+ * is active.
+ */
+ this.focusWhenActive = false;
+ }
+
+ /**
+ * Indicates whether the view is open in the specified PanelMultiView object.
+ */
+ isOpenIn(panelMultiView) {
+ return this.node.panelMultiView == panelMultiView.node;
+ }
+
+ /**
+ * The "mainview" attribute is set before the panel is opened when this view
+ * is displayed as the main view, and is removed before the <panelview> is
+ * displayed as a subview. The same view element can be displayed as a main
+ * view and as a subview at different times.
+ */
+ set mainview(value) {
+ if (value) {
+ this.node.setAttribute("mainview", true);
+ } else {
+ this.node.removeAttribute("mainview");
+ }
+ }
+
+ /**
+ * Determines whether the view is visible. Setting this to false also resets
+ * the "active" property.
+ */
+ set visible(value) {
+ if (value) {
+ this.node.setAttribute("visible", true);
+ } else {
+ this.node.removeAttribute("visible");
+ this.active = false;
+ this.focusWhenActive = false;
+ }
+ }
+
+ /**
+ * Constrains the width of this view using the "min-width" and "max-width"
+ * styles. Setting this to zero removes the constraints.
+ */
+ set minMaxWidth(value) {
+ let style = this.node.style;
+ if (value) {
+ style.minWidth = style.maxWidth = value + "px";
+ } else {
+ style.removeProperty("min-width");
+ style.removeProperty("max-width");
+ }
+ }
+
+ /**
+ * Adds a header with the given title, or removes it if the title is empty.
+ */
+ set headerText(value) {
+ // If the header already exists, update or remove it as requested.
+ let header = this.node.firstElementChild;
+ if (header && header.classList.contains("panel-header")) {
+ if (value) {
+ header.querySelector(".panel-header > h1 > span").textContent = value;
+ } else {
+ header.remove();
+ }
+ return;
+ }
+
+ // The header doesn't exist, only create it if needed.
+ if (!value) {
+ return;
+ }
+
+ header = this.document.createXULElement("box");
+ header.classList.add("panel-header");
+
+ let backButton = this.document.createXULElement("toolbarbutton");
+ backButton.className =
+ "subviewbutton subviewbutton-iconic subviewbutton-back";
+ backButton.setAttribute("closemenu", "none");
+ backButton.setAttribute("tabindex", "0");
+
+ backButton.setAttribute(
+ "aria-label",
+ lazy.gBundle.GetStringFromName("panel.back")
+ );
+
+ backButton.addEventListener("command", () => {
+ // The panelmultiview element may change if the view is reused.
+ this.node.panelMultiView.goBack();
+ backButton.blur();
+ });
+
+ let h1 = this.document.createElement("h1");
+ let span = this.document.createElement("span");
+ span.textContent = value;
+ h1.appendChild(span);
+
+ header.append(backButton, h1);
+ this.node.prepend(header);
+ }
+
+ /**
+ * Populates the "knownWidth" and "knownHeight" properties with the current
+ * dimensions of the view. These may be zero if the view is invisible.
+ *
+ * These values are relevant during transitions and are retained for backwards
+ * navigation if the view is still open but is invisible.
+ */
+ captureKnownSize() {
+ let rect = this._getBoundsWithoutFlushing(this.node);
+ this.knownWidth = rect.width;
+ this.knownHeight = rect.height;
+ }
+
+ /**
+ * Determine whether an element can only be navigated to with tab/shift+tab,
+ * not the arrow keys.
+ */
+ _isNavigableWithTabOnly(element) {
+ let tag = element.localName;
+ return (
+ tag == "menulist" ||
+ tag == "input" ||
+ tag == "textarea" ||
+ // Allow tab to reach embedded documents in extension panels.
+ tag == "browser"
+ );
+ }
+
+ /**
+ * Make a TreeWalker for keyboard navigation.
+ *
+ * @param {boolean} arrowKey If `true`, elements only navigable with tab are
+ * excluded.
+ */
+ _makeNavigableTreeWalker(arrowKey) {
+ let filter = node => {
+ if (node.disabled) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ let bounds = this._getBoundsWithoutFlushing(node);
+ if (bounds.width == 0 || bounds.height == 0) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ if (
+ node.tagName == "button" ||
+ node.tagName == "toolbarbutton" ||
+ node.classList.contains("text-link") ||
+ (!arrowKey && this._isNavigableWithTabOnly(node))
+ ) {
+ // Set the tabindex attribute to make sure the node is focusable.
+ if (!node.hasAttribute("tabindex")) {
+ node.setAttribute("tabindex", "-1");
+ }
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_SKIP;
+ };
+ return this.document.createTreeWalker(
+ this.node,
+ NodeFilter.SHOW_ELEMENT,
+ filter
+ );
+ }
+
+ /**
+ * Get a TreeWalker which finds elements navigable with tab/shift+tab.
+ */
+ get _tabNavigableWalker() {
+ if (!this.__tabNavigableWalker) {
+ this.__tabNavigableWalker = this._makeNavigableTreeWalker(false);
+ }
+ return this.__tabNavigableWalker;
+ }
+
+ /**
+ * Get a TreeWalker which finds elements navigable with up/down arrow keys.
+ */
+ get _arrowNavigableWalker() {
+ if (!this.__arrowNavigableWalker) {
+ this.__arrowNavigableWalker = this._makeNavigableTreeWalker(true);
+ }
+ return this.__arrowNavigableWalker;
+ }
+
+ /**
+ * Element that is currently selected with the keyboard, or null if no element
+ * is selected. Since the reference is held weakly, it can become null or
+ * undefined at any time.
+ */
+ get selectedElement() {
+ return this._selectedElement && this._selectedElement.get();
+ }
+ set selectedElement(value) {
+ if (!value) {
+ delete this._selectedElement;
+ } else {
+ this._selectedElement = Cu.getWeakReference(value);
+ }
+ }
+
+ /**
+ * Focuses and moves keyboard selection to the first navigable element.
+ * This is a no-op if there are no navigable elements.
+ *
+ * @param {boolean} homeKey - `true` if this is for the home key.
+ * @param {boolean} skipBack - `true` if the Back button should be skipped.
+ */
+ focusFirstNavigableElement(homeKey = false, skipBack = false) {
+ // The home key is conceptually similar to the up/down arrow keys.
+ let walker = homeKey
+ ? this._arrowNavigableWalker
+ : this._tabNavigableWalker;
+ walker.currentNode = walker.root;
+ this.selectedElement = walker.firstChild();
+ if (
+ skipBack &&
+ walker.currentNode &&
+ walker.currentNode.classList.contains("subviewbutton-back") &&
+ walker.nextNode()
+ ) {
+ this.selectedElement = walker.currentNode;
+ }
+ this.focusSelectedElement(/* byKey */ true);
+ }
+
+ /**
+ * Focuses and moves keyboard selection to the last navigable element.
+ * This is a no-op if there are no navigable elements.
+ *
+ * @param {boolean} endKey - `true` if this is for the end key.
+ */
+ focusLastNavigableElement(endKey = false) {
+ // The end key is conceptually similar to the up/down arrow keys.
+ let walker = endKey ? this._arrowNavigableWalker : this._tabNavigableWalker;
+ walker.currentNode = walker.root;
+ this.selectedElement = walker.lastChild();
+ this.focusSelectedElement(/* byKey */ true);
+ }
+
+ /**
+ * Based on going up or down, select the previous or next focusable element.
+ *
+ * @param {boolean} isDown - whether we're going down (true) or up (false).
+ * @param {boolean} arrowKey - `true` if this is for the up/down arrow keys.
+ *
+ * @returns {DOMNode} the element we selected.
+ */
+ moveSelection(isDown, arrowKey = false) {
+ let walker = arrowKey
+ ? this._arrowNavigableWalker
+ : this._tabNavigableWalker;
+ let oldSel = this.selectedElement;
+ let newSel;
+ if (oldSel) {
+ walker.currentNode = oldSel;
+ newSel = isDown ? walker.nextNode() : walker.previousNode();
+ }
+ // If we couldn't find something, select the first or last item:
+ if (!newSel) {
+ walker.currentNode = walker.root;
+ newSel = isDown ? walker.firstChild() : walker.lastChild();
+ }
+ this.selectedElement = newSel;
+ return newSel;
+ }
+
+ /**
+ * Allow for navigating subview buttons using the arrow keys and the Enter key.
+ * The Up and Down keys can be used to navigate the list up and down and the
+ * Enter, Right or Left - depending on the text direction - key can be used to
+ * simulate a click on the currently selected button.
+ * The Right or Left key - depending on the text direction - can be used to
+ * navigate to the previous view, functioning as a shortcut for the view's
+ * back button.
+ * Thus, in LTR mode:
+ * - The Right key functions the same as the Enter key, simulating a click
+ * - The Left key triggers a navigation back to the previous view.
+ *
+ * Key navigation is only enabled while the view is active, meaning that this
+ * method will return early if it is invoked during a sliding transition.
+ *
+ * @param {KeyEvent} event
+ */
+ /* eslint-disable-next-line complexity */
+ keyNavigation(event) {
+ if (!this.active) {
+ return;
+ }
+
+ let focus = this.document.activeElement;
+ // Make sure the focus is actually inside the panel. (It might not be if
+ // the panel was opened with the mouse.) If it isn't, we don't care
+ // about it for our purposes.
+ // We use Node.compareDocumentPosition because Node.contains doesn't
+ // behave as expected for anonymous content; e.g. the input inside a
+ // textbox.
+ if (
+ focus &&
+ !(
+ this.node.compareDocumentPosition(focus) &
+ Node.DOCUMENT_POSITION_CONTAINED_BY
+ )
+ ) {
+ focus = null;
+ }
+
+ // Extension panels contain embedded documents. We can't manage
+ // keyboard navigation within those.
+ if (focus && focus.tagName == "browser") {
+ return;
+ }
+
+ let stop = () => {
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ // If the focused element is only navigable with tab, it wants the arrow
+ // keys, etc. We shouldn't handle any keys except tab and shift+tab.
+ // We make a function for this for performance reasons: we only want to
+ // check this for keys we potentially care about, not *all* keys.
+ let tabOnly = () => {
+ // We use the real focus rather than this.selectedElement because focus
+ // might have been moved without keyboard navigation (e.g. mouse click)
+ // and this.selectedElement is only updated for keyboard navigation.
+ return focus && this._isNavigableWithTabOnly(focus);
+ };
+
+ // If a context menu is open, we must let it handle all keys.
+ // Normally, this just happens, but because we have a capturing window
+ // keydown listener, our listener takes precedence.
+ // Again, we only want to do this check on demand for performance.
+ let isContextMenuOpen = () => {
+ if (!focus) {
+ return false;
+ }
+ let contextNode = focus.closest("[context]");
+ if (!contextNode) {
+ return false;
+ }
+ let context = contextNode.getAttribute("context");
+ let popup = this.document.getElementById(context);
+ return popup && popup.state == "open";
+ };
+
+ let keyCode = event.code;
+ switch (keyCode) {
+ case "ArrowDown":
+ case "ArrowUp":
+ if (tabOnly()) {
+ break;
+ }
+ // Fall-through...
+ case "Tab": {
+ if (isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ let isDown =
+ keyCode == "ArrowDown" || (keyCode == "Tab" && !event.shiftKey);
+ let button = this.moveSelection(isDown, keyCode != "Tab");
+ Services.focus.setFocus(button, Services.focus.FLAG_BYKEY);
+ break;
+ }
+ case "Home":
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ this.focusFirstNavigableElement(true);
+ break;
+ case "End":
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ this.focusLastNavigableElement(true);
+ break;
+ case "ArrowLeft":
+ case "ArrowRight": {
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ if (
+ (!this.window.RTL_UI && keyCode == "ArrowLeft") ||
+ (this.window.RTL_UI && keyCode == "ArrowRight")
+ ) {
+ this.node.panelMultiView.goBack();
+ break;
+ }
+ // If the current button is _not_ one that points to a subview, pressing
+ // the arrow key shouldn't do anything.
+ let button = this.selectedElement;
+ if (!button || !button.classList.contains("subviewbutton-nav")) {
+ break;
+ }
+ }
+ // Fall-through...
+ case "Space":
+ case "NumpadEnter":
+ case "Enter": {
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ let button = this.selectedElement;
+ if (!button) {
+ break;
+ }
+ stop();
+
+ this._doingKeyboardActivation = true;
+ // Unfortunately, 'tabindex' doesn't execute the default action, so
+ // we explicitly do this here.
+ // We are sending a command event, a mousedown event and then a click
+ // event. This is done in order to mimic a "real" mouse click event.
+ // Normally, the command event executes the action, then the click event
+ // closes the menu. However, in some cases (e.g. the Library button),
+ // there is no command event handler and the mousedown event executes the
+ // action instead.
+ button.doCommand();
+ let dispEvent = new event.target.ownerGlobal.MouseEvent("mousedown", {
+ bubbles: true,
+ });
+ button.dispatchEvent(dispEvent);
+ dispEvent = new event.target.ownerGlobal.MouseEvent("click", {
+ bubbles: true,
+ });
+ button.dispatchEvent(dispEvent);
+ this._doingKeyboardActivation = false;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Focus the last selected element in the view, if any.
+ *
+ * @param byKey {Boolean} whether focus was moved by the user pressing a key.
+ * Needed to ensure we show focus styles in the right cases.
+ */
+ focusSelectedElement(byKey = false) {
+ let selected = this.selectedElement;
+ if (selected) {
+ let flag = byKey ? "FLAG_BYKEY" : "FLAG_BYELEMENTFOCUS";
+ Services.focus.setFocus(selected, Services.focus[flag]);
+ }
+ }
+
+ /**
+ * Clear all traces of keyboard navigation happening right now.
+ */
+ clearNavigation() {
+ let selected = this.selectedElement;
+ if (selected) {
+ selected.blur();
+ this.selectedElement = null;
+ }
+ }
+}
diff --git a/comm/mail/components/customizableui/content/customizeMode.inc.xhtml b/comm/mail/components/customizableui/content/customizeMode.inc.xhtml
new file mode 100644
index 0000000000..fc7eb0595b
--- /dev/null
+++ b/comm/mail/components/customizableui/content/customizeMode.inc.xhtml
@@ -0,0 +1,128 @@
+<!-- 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/. -->
+
+<box id="customization-content-container">
+<box flex="1" id="customization-palette-container">
+ <label id="customization-header" data-l10n-id="customize-mode-menu-and-toolbars-header"></label>
+ <vbox id="customization-palette" class="customization-palette" hidden="true"/>
+ <vbox id="customization-pong-arena" hidden="true"/>
+ <spacer id="customization-spacer"/>
+</box>
+<vbox id="customization-panel-container">
+ <vbox id="customization-panelWrapper">
+ <box class="panel-arrowbox">
+ <image class="panel-arrow" side="top"/>
+ </box>
+ <box class="panel-arrowcontent" side="top" flex="1">
+ <vbox id="customization-panelHolder">
+ <description id="customization-panelHeader" data-l10n-id="customize-mode-overflow-list-title"></description>
+ <description id="customization-panelDescription" data-l10n-id="customize-mode-overflow-list-description"></description>
+ </vbox>
+ <box class="panel-inner-arrowcontentfooter" hidden="true"/>
+ </box>
+ </vbox>
+</vbox>
+</box>
+<hbox id="customization-footer">
+<checkbox id="customization-titlebar-visibility-checkbox" class="customizationmode-checkbox"
+# NB: because oncommand fires after click, by the time we've fired, the checkbox binding
+# will already have switched the button's state, so this is correct:
+ oncommand="gCustomizeMode.toggleTitlebar(this.checked)" data-l10n-id="customize-mode-titlebar"/>
+<checkbox id="customization-extra-drag-space-checkbox" class="customizationmode-checkbox"
+ data-l10n-id="customize-mode-extra-drag-space"
+ oncommand="gCustomizeMode.toggleDragSpace(this.checked)"/>
+<button id="customization-toolbar-visibility-button" class="customizationmode-button" type="menu" data-l10n-id="customize-mode-toolbars">
+ <menupopup id="customization-toolbar-menu" onpopupshowing="onViewToolbarsPopupShowing(event)"/>
+</button>
+<button id="customization-lwtheme-button" data-l10n-id="customize-mode-lwthemes" class="customizationmode-button" type="menu">
+ <panel type="arrow" id="customization-lwtheme-menu"
+ orient="vertical"
+ onpopupshowing="gCustomizeMode.onThemesMenuShowing(event);"
+ position="topcenter bottomleft"
+ flip="none"
+ role="menu">
+ <label id="customization-lwtheme-menu-header" data-l10n-id="customize-mode-lwthemes-my-themes"/>
+ <hbox id="customization-lwtheme-menu-footer">
+ <toolbarbutton class="customization-lwtheme-menu-footeritem"
+ data-l10n-id="customize-mode-lwthemes-menu-manage"
+ tabindex="0"
+ oncommand="gCustomizeMode.openAddonsManagerThemes(event);"/>
+ <toolbarbutton class="customization-lwtheme-menu-footeritem"
+ data-l10n-id="customize-mode-lwthemes-menu-get-more"
+ tabindex="0"
+ oncommand="gCustomizeMode.getMoreThemes(event);"/>
+ </hbox>
+ </panel>
+</button>
+<button id="customization-uidensity-button"
+ data-l10n-id="customize-mode-uidensity"
+ class="customizationmode-button"
+ type="menu">
+ <panel type="arrow" id="customization-uidensity-menu"
+ onpopupshowing="gCustomizeMode.onUIDensityMenuShowing();"
+ position="topcenter bottomleft"
+ flip="none"
+ role="menu">
+ <menuitem id="customization-uidensity-menuitem-compact"
+ class="menuitem-iconic customization-uidensity-menuitem"
+ role="menuitemradio"
+ data-l10n-id="customize-mode-uidensity-menu-compact"
+ tabindex="0"
+ onfocus="gCustomizeMode.updateUIDensity(this.mode);"
+ onmouseover="gCustomizeMode.updateUIDensity(this.mode);"
+ onblur="gCustomizeMode.resetUIDensity();"
+ onmouseout="gCustomizeMode.resetUIDensity();"
+ oncommand="gCustomizeMode.setUIDensity(this.mode);"/>
+ <menuitem id="customization-uidensity-menuitem-normal"
+ class="menuitem-iconic customization-uidensity-menuitem"
+ role="menuitemradio"
+ data-l10n-id="customize-mode-uidensity-menu-normal"
+ tabindex="0"
+ onfocus="gCustomizeMode.updateUIDensity(this.mode);"
+ onmouseover="gCustomizeMode.updateUIDensity(this.mode);"
+ onblur="gCustomizeMode.resetUIDensity();"
+ onmouseout="gCustomizeMode.resetUIDensity();"
+ oncommand="gCustomizeMode.setUIDensity(this.mode);"/>
+#ifndef XP_MACOSX
+ <menuitem id="customization-uidensity-menuitem-touch"
+ class="menuitem-iconic customization-uidensity-menuitem"
+ role="menuitemradio"
+ data-l10n-id="customize-mode-uidensity-menu-touch"
+ tabindex="0"
+ onfocus="gCustomizeMode.updateUIDensity(this.mode);"
+ onmouseover="gCustomizeMode.updateUIDensity(this.mode);"
+ onblur="gCustomizeMode.resetUIDensity();"
+ onmouseout="gCustomizeMode.resetUIDensity();"
+ oncommand="gCustomizeMode.setUIDensity(this.mode);">
+ </menuitem>
+ <spacer hidden="true" id="customization-uidensity-touch-spacer"/>
+ <checkbox id="customization-uidensity-autotouchmode-checkbox"
+ hidden="true"
+ data-l10n-id="customize-mode-uidensity-auto-touch-mode-checkbox"
+ oncommand="gCustomizeMode.updateAutoTouchMode(this.checked)"/>
+#endif
+ </panel>
+</button>
+
+<button id="whimsy-button"
+ type="checkbox"
+ class="customizationmode-button"
+ oncommand="gCustomizeMode.togglePong(this.checked);"
+ hidden="true"/>
+
+<spacer id="customization-footer-spacer"/>
+<button id="customization-undo-reset-button"
+ class="customizationmode-button"
+ hidden="true"
+ oncommand="gCustomizeMode.undoReset();"
+ data-l10n-id="customize-mode-undo-cmd"/>
+<button id="customization-reset-button"
+ oncommand="gCustomizeMode.reset();"
+ data-l10n-id="customize-mode-restore-defaults"
+ class="customizationmode-button"/>
+<button id="customization-done-button"
+ oncommand="gCustomizeMode.exit();"
+ data-l10n-id="customize-mode-done"
+ class="customizationmode-button"/>
+</hbox>
diff --git a/comm/mail/components/customizableui/content/jar.mn b/comm/mail/components/customizableui/content/jar.mn
new file mode 100644
index 0000000000..db1978fdb0
--- /dev/null
+++ b/comm/mail/components/customizableui/content/jar.mn
@@ -0,0 +1,6 @@
+# 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/panelUI.js
diff --git a/comm/mail/components/customizableui/content/moz.build b/comm/mail/components/customizableui/content/moz.build
new file mode 100644
index 0000000000..d988c0ff9b
--- /dev/null
+++ b/comm/mail/components/customizableui/content/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/customizableui/content/panelUI.inc.xhtml b/comm/mail/components/customizableui/content/panelUI.inc.xhtml
new file mode 100644
index 0000000000..3b965da756
--- /dev/null
+++ b/comm/mail/components/customizableui/content/panelUI.inc.xhtml
@@ -0,0 +1,606 @@
+<!-- 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/. -->
+
+<panel id="appMenu-popup"
+ class="cui-widget-panel panel-no-padding"
+ role="group"
+ type="arrow"
+ hidden="true"
+ flip="slide"
+ position="bottomright topright"
+ noautofocus="true">
+ <panelmultiview id="appMenu-multiView"
+ mainViewId="appMenu-mainView"
+ viewCacheId="appMenu-viewCache">
+
+ <!-- Main Appmenu View -->
+ <panelview id="appMenu-mainView" class="PanelUI-subView">
+ <vbox id="appMenu-mainViewItems"
+ class="panel-subview-body">
+ <vbox id="appMenu-addon-banners"/>
+ <toolbarbutton class="panel-banner-item"
+ oncommand="PanelUI._onBannerItemSelected(event)"
+ hidden="true"/>
+#ifdef NIGHTLY_BUILD
+ <toolbarbutton id="appmenu_signin"
+ data-l10n-id="appmenu-signin-panel"
+ class="subviewbutton subviewbutton-iconic"
+ hidden="true"
+ oncommand="gSync.initFxA();"/>
+ <toolbarbutton id="appmenu_sync"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ hidden="true"
+ align="center"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-syncView', this)">
+ <hbox flex="1">
+ <html:img id="appmenu-sync-icon"
+ class="toolbarbutton-icon"
+ alt=""/>
+ <vbox flex="1">
+ <label id="appmenu-sync-sync"
+ crop="end"
+ data-l10n-id="appmenu-sync-sync"/>
+ <label id="appmenu-sync-account"
+ class="appmenu-sync-account-email"
+ crop="end"
+ data-l10n-id="appmenu-sync-account"/>
+ </vbox>
+ </hbox>
+ </toolbarbutton>
+ <toolbarseparator id="syncSeparator" hidden="true"/>
+#endif
+ <toolbarbutton id="appmenu_new"
+ data-l10n-id="appmenu-new-account-panel"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-newView', this)"/>
+ <toolbarbutton id="appmenu_create"
+ data-l10n-id="appmenu-create-panel"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-createView', this)"/>
+ <toolbarseparator id="appmenu_createPopupMenuSeparator"/>
+ <toolbarbutton id="appmenu_open"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="appmenu-open-file-panel"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-openView', this)"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_View"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="appmenu-view-panel"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-viewView', this)"/>
+ <toolbaritem id="appMenu-uiDensity-controls"
+ class="subviewbutton subviewbutton-iconic toolbaritem-combined-buttons"
+ closemenu="none">
+ <html:img class="toolbarbutton-icon" src="" alt=""/>
+ <label class="toolbarbutton-text" data-l10n-id="appmenu-mail-uidensity-value"/>
+ <toolbarbutton id="appmenu_uiDensityCompact"
+ class="subviewbutton subviewbutton-iconic subviewbutton"
+ data-l10n-id="appmenu-uidensity-compact"
+ type="radio"
+ oncommand="PanelUI.setUIDensity(event);"/>
+ <toolbarbutton id="appmenu_uiDensityNormal"
+ class="subviewbutton subviewbutton-iconic subviewbutton"
+ data-l10n-id="appmenu-uidensity-default"
+ type="radio"
+ oncommand="PanelUI.setUIDensity(event);"/>
+ <toolbarbutton id="appmenu_uiDensityTouch"
+ class="subviewbutton subviewbutton-iconic subviewbutton"
+ data-l10n-id="appmenu-uidensity-relaxed"
+ type="radio"
+ oncommand="PanelUI.setUIDensity(event);"/>
+ </toolbaritem>
+ <toolbaritem id="appMenu-fontSize-controls"
+ class="subviewbutton subviewbutton-iconic toolbaritem-combined-buttons"
+ closemenu="none">
+ <html:img class="toolbarbutton-icon" src="" alt=""/>
+ <label class="toolbarbutton-text" data-l10n-id="appmenu-font-size-value"/>
+ <toolbarbutton id="appMenu-fontSizeReduce-button"
+ class="subviewbutton subviewbutton-iconic"
+ oncommand="UIFontSize.reduceSize();"
+ data-l10n-id="appmenuitem-font-size-reduce"/>
+ <toolbarbutton id="appMenu-fontSizeReset-button"
+ class="subviewbutton"
+ oncommand="UIFontSize.resetSize();"
+ tooltip="fontSizeReset"/>
+ <toolbarbutton id="appMenu-fontSizeEnlarge-button"
+ class="subviewbutton subviewbutton-iconic"
+ oncommand="UIFontSize.increaseSize();"
+ data-l10n-id="appmenuitem-font-size-enlarge"/>
+ </toolbaritem>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_preferences"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-settings"
+ oncommand="openOptionsDialog();"/>
+ <toolbarbutton id="appmenu_accountmgr"
+ class="subviewbutton subviewbutton-iconic"
+ label="&accountManagerCmd2.label;"
+ oncommand="MsgAccountManager(null);"/>
+ <toolbarbutton id="appmenu_addons"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-addons-and-themes"
+ oncommand="openAddonsMgr();"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_toolsMenu"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="appmenu-tools-panel"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-toolsView', this)"/>
+ <toolbarbutton id="appmenu_help"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="menu-help-help-title"
+ closemenu="none"
+ oncommand="buildHelpMenu(); PanelUI.showSubView('appMenu-helpView', this)"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu-quit"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="menu-quit"
+ key="key_quitApplication"
+ command="cmd_quitApplication"/>
+ </vbox>
+ </panelview>
+#ifdef NIGHTLY_BUILD
+ <!-- Sync -->
+ <panelview id="appMenu-syncView"
+ data-l10n-id="appmenu-sync-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-syncViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_manageSyncAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ align="center"
+ oncommand="gSync.openFxAManagePage();">
+ <hbox flex="1">
+ <html:img id="appmenu-manage-sync-icon"
+ class="toolbarbutton-icon"
+ alt=""/>
+ <vbox flex="1">
+ <label id="appmenu-sync-menu-manage"
+ crop="end"
+ data-l10n-id="appmenu-sync-manage"/>
+ <label id="appmenu-sync-menu-account"
+ class="appmenu-sync-account-email"
+ crop="end"
+ data-l10n-id="appmenu-sync-account"/>
+ </vbox>
+ </hbox>
+ </toolbarbutton>
+
+ <toolbarbutton id="appmenu-submenu-sync-now"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-sync-now"
+ closemenu="none"
+ oncommand="Weave.Service.sync({});"/>
+ <toolbarbutton id="appmenu-submenu-sync-settings"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-sync-settings"
+ oncommand="openPreferencesTab('sync');"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu-submenu-sync-sign-out"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-sync-sign-out"
+ oncommand="gSync.disconnect({ confirm: true });"/>
+ </vbox>
+ </panelview>
+#endif
+ <!-- New -->
+ <panelview id="appMenu-newView"
+ data-l10n-id="appmenu-new-account-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-newViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_newCreateEmailAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-create-new-mail-account"
+ oncommand="openAccountProvisionerTab();"/>
+ <toolbarbutton id="appmenu_newMailAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-mail-account"
+ oncommand="openAccountSetupTab();"/>
+#ifdef MAIN_WINDOW
+ <toolbarbutton id="appmenu_calendar-new-calendar-menu-item"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-calendar"
+ command="calendar_new_calendar_command"/>
+#endif
+ <toolbarbutton id="appmenu_newAB"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="appmenu-newab-panel"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-newabView', this)"/>
+ <toolbarbutton id="appmenu_newIMAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-chat-account"
+ oncommand="openIMAccountWizard();"/>
+ <toolbarbutton id="appmenu_newFeedAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-feed"
+ oncommand="AddFeedAccount();"/>
+ <toolbarbutton id="appmenu_newNewsgroupAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-newsgroup"
+ oncommand="openNewsgroupAccountWizard();"/>
+ </vbox>
+ </panelview>
+
+ <!-- New AB -->
+ <panelview id="appMenu-newabView"
+ data-l10n-id="appmenu-newab-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-newABItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_newABMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-addressbook"
+ oncommand="openNewABDialog();"/>
+ <toolbarbutton id="appmenu_newCardDAVMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-carddav"
+ oncommand="openNewABDialog('CARDDAV');"/>
+ <toolbarbutton id="appmenu_newLdapMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-ldap"
+ oncommand="openNewABDialog('LDAP');"/>
+ </vbox>
+ </panelview>
+
+ <!-- Create -->
+ <panelview id="appMenu-createView"
+ data-l10n-id="appmenu-create-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-createViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_newNewMsgCmd"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-create-message"
+ key = "key_newMessage2"
+ command="cmd_newMessage"/>
+#ifdef MAIN_WINDOW
+ <toolbarbutton id="appmenu_calendar-new-event-menu-item"
+ class="subviewbutton subviewbutton-iconic hide-when-calendar-deactivated"
+ data-l10n-id="appmenu-create-event"
+ command="calendar_new_event_command"/>
+ <toolbarbutton id="appmenu_calendar-new-task-menu-item"
+ class="subviewbutton subviewbutton-iconic hide-when-calendar-deactivated"
+ data-l10n-id="appmenu-create-task"
+ command="calendar_new_todo_command"/>
+#endif
+ <toolbarbutton id="appmenu_newCard"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-create-contact"
+ command="cmd_newCard"/>
+ </vbox>
+ </panelview>
+
+ <!-- Open -->
+ <panelview id="appMenu-openView"
+ data-l10n-id="appmenu-open-file-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-openViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_OpenMessageFileMenuitem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-open-message"
+ oncommand="MsgOpenFromFile();"/>
+ <toolbarbutton id="appmenu_OpenCalendarFileMenuitem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-open-calendar"
+ oncommand="openLocalCalendar();"/>
+ </vbox>
+ </panelview>
+
+ <!-- View / Toolbars -->
+ <panelview id="appMenu-toolbarsView"
+ title="&viewToolbarsMenu.label;"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+#ifdef MAIN_WINDOW
+ <toolbarbutton id="appmenu_quickFilterBar"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-toggle"
+ command="cmd_toggleQuickFilterBar"/>
+ <toolbarbutton id="appmenu_spacesToolbar"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ data-l10n-id="menu-spaces-toolbar-button"
+ closemenu="none"
+ oncommand="gSpacesToolbar.toggleToolbarFromMenu();"/>
+#endif
+ <toolbarbutton id="appmenu_showStatusbar"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ label="&showTaskbarCmd.label;"
+ oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')"
+ closemenu="none"
+ checked="true"
+ observes="menu_showTaskbar"/>
+ <toolbarseparator id="appmenu_toggleToolbarsSeparator"/>
+ <toolbarbutton id="appmenu_toolbarLayout"
+ class="subviewbutton subviewbutton-iconic"
+ label="&appmenuToolbarLayout.label;"
+ command="cmd_CustomizeMailToolbar"/>
+ </vbox>
+ </panelview>
+
+ <!-- View / Layout -->
+ <panelview id="appMenu-preferencesLayoutView"
+ title="&messagePaneLayoutStyle.label;"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu_messagePaneClassic"
+ class="subviewbutton subviewbutton-iconic"
+ type="radio"
+ label="&messagePaneClassic.label;"
+ name="viewlayoutgroup"
+ command="cmd_viewClassicMailLayout"/>
+ <toolbarbutton id="appmenu_messagePaneWide"
+ class="subviewbutton subviewbutton-iconic"
+ type="radio"
+ label="&messagePaneWide.label;"
+ name="viewlayoutgroup"
+ command="cmd_viewWideMailLayout"/>
+ <toolbarbutton id="appmenu_messagePaneVertical"
+ class="subviewbutton subviewbutton-iconic"
+ type="radio"
+ label="&messagePaneVertical.label;"
+ name="viewlayoutgroup"
+ command="cmd_viewVerticalMailLayout"/>
+ <toolbarseparator id="appmenu_viewMenuAfterPaneVerticalSeparator"/>
+ <toolbarbutton id="appmenu_showFolderPane"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ closemenu="none"
+ label="&showFolderPaneCmd.label;"
+ command="cmd_toggleFolderPane"/>
+ <toolbarbutton id="appmenu_toggleThreadPaneHeader"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ name="threadheader"
+ closemenu="none"
+ data-l10n-id="appmenuitem-toggle-thread-pane-header"
+ command="cmd_toggleThreadPaneHeader"/>
+ <toolbarbutton id="appmenu_showMessage"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ closemenu="none"
+ label="&showMessageCmd.label;"
+ key="key_toggleMessagePane"
+ command="cmd_toggleMessagePane"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_calShowTodayPane-2"
+ class="subviewbutton subviewbutton-iconic"
+ label="&todaypane.showTodayPane.label;"
+ type="checkbox"
+ command="calendar_toggle_todaypane_command"/>
+ </vbox>
+ </panelview>
+
+ <!-- View -->
+ <panelview id="appMenu-viewView"
+ class="PanelUI-subView"
+ data-l10n-id="appmenu-view-panel-title">
+ <vbox id="appMenu-viewViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_Toolbars"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ label="&viewToolbarsMenu.label;"
+ accesskey="&viewToolbarsMenu.accesskey;"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-toolbarsView', this)"/>
+ <toolbarbutton id="appmenu_MessagePaneLayout"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ label="&messagePaneLayoutStyle.label;"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-preferencesLayoutView', this)"/>
+ <toolbarbutton id="appmenu_FolderViews"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ label="&folderView.label;"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-foldersView', this)"/>
+ </vbox>
+ </panelview>
+
+ <!-- View / Folders -->
+ <panelview id="appMenu-foldersView"
+ title="&folderView.label;"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu_toggleFolderHeader"
+ class="subviewbutton subviewbutton-iconic"
+ name="paneheader"
+ value="toggle-header"
+ data-l10n-id="menu-view-folders-toggle-header"
+ type="checkbox"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarseparator id="appmenu_folderModesSeparator"/>
+ <toolbarbutton id="appmenu_allFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="all"
+ data-l10n-id="show-all-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarbutton id="appmenu_smartFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="smart"
+ data-l10n-id="show-smart-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarbutton id="appmenu_unreadFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="unread"
+ data-l10n-id="show-unread-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarbutton id="appmenu_favoriteFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="favorite"
+ data-l10n-id="show-favorite-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarbutton id="appmenu_recentFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="recent"
+ data-l10n-id="show-recent-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_tagsFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="tags"
+ data-l10n-id="show-tags-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarseparator id="appmenu_compactPropertiesSeparator"/>
+ <toolbarbutton id="appmenu_compactMode"
+ class="subviewbutton subviewbutton-iconic"
+ value="compact"
+ data-l10n-id="folder-toolbar-toggle-folder-compact-view"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderCompactMenuOnCommand(event)"/>
+ <toolbarseparator id="appmenu_favoritePropertiesSeparator"/>
+ <toolbarbutton id="appmenu_favoriteFolder"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ label="&menuFavoriteFolder.label;"
+ checked="false"
+ command="cmd_toggleFavoriteFolder"/>
+ <toolbarbutton id="appmenu_properties"
+ class="subviewbutton subviewbutton-iconic"
+ command="cmd_properties"/>
+ </vbox>
+ </panelview>
+
+ <!-- View / Messages / Tags -->
+ <!-- Dynamically populated when shown. -->
+ <panelview id="appMenu-viewMessagesTagsView"
+ title="&viewTags.label;"
+ class="PanelUI-subView"
+ oncommand="ViewChangeByMenuitem(event.target);">
+ <vbox class="panel-subview-body"/>
+ </panelview>
+
+ <!-- View / Messages / Custom Views -->
+ <!-- Dynamically populated when shown. -->
+ <panelview id="appMenu-viewMessagesCustomViewsView"
+ title="&viewCustomViews.label;"
+ class="PanelUI-subView"
+ oncommand="ViewChangeByMenuitem(event.target);">
+ <vbox class="panel-subview-body"/>
+ </panelview>
+
+ <!-- Tools -->
+ <panelview id="appMenu-toolsView"
+ data-l10n-id="appmenu-tools-panel-title"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu_import"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-import"
+ oncommand="toImport();"/>
+ <toolbarbutton id="appmenu_export"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-export"
+ oncommand="toExport();"/>
+ <toolbarseparator id="importExportSeparator"/>
+ <toolbarbutton id="appmenu_searchCmd"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-message-search"
+ key="key_searchMail"
+ command="cmd_searchMessages"/>
+ <toolbarbutton id="appmenu_filtersCmd"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-message-filters"
+ oncommand="MsgFilters();"/>
+ <toolbarbutton id="appmenu_manageKeysOpenPGP"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="openpgp-manage-keys-openpgp-cmd"
+ oncommand="openKeyManager()"/>
+ <toolbarbutton id="appmenu_openSavedFilesWnd"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-download-manager"
+ key="key_savedFiles"
+ oncommand="openSavedFilesWnd();"/>
+ <toolbarbutton id="appmenu_activityManager"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-activity-manager"
+ oncommand="openActivityMgr();"/>
+ <toolbarseparator id="devToolsSeparator"/>
+ <toolbarbutton id="appmenu_devtoolsToolbox"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-dev-tools"
+ key="key_devtoolsToolbox"
+ oncommand="BrowserToolboxLauncher.init();"/>
+ </vbox>
+ </panelview>
+
+ <!-- Help -->
+ <panelview id="appMenu-helpView"
+ data-l10n-id="appmenu-help-panel-title"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu_openHelp"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-get-help"
+ key="key_openHelp"
+ oncommand="openSupportURL();"/>
+ <toolbarbutton id="appmenu_openTour"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-explore-features"
+ oncommand="openLinkText(event, 'tourURL');"/>
+ <toolbarbutton id="appmenu_keyboardShortcuts"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-shortcuts"
+ oncommand="openLinkText(event, 'keyboardShortcutsURL');"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_getInvolved"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-get-involved"
+ oncommand="openLinkText(event, 'getInvolvedURL');"/>
+ <toolbarbutton id="appmenu_makeDonation"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-donation"
+ oncommand="openLinkText(event, 'donateURL');"/>
+ <toolbarbutton id="appmenu_submitFeedback"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-share-feedback"
+ oncommand="openLinkText(event, 'feedbackURL');"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_troubleshootMode"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-enter-troubleshoot-mode2"
+ oncommand="safeModeRestart();"/>
+ <toolbarbutton id="appmenu_troubleshootingInfo"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-troubleshooting-info"
+ oncommand="openAboutSupport();"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_about"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-about-product"
+ oncommand="openAboutDialog();"/>
+ </vbox>
+ </panelview>
+ </panelmultiview>
+</panel>
diff --git a/comm/mail/components/customizableui/content/panelUI.js b/comm/mail/components/customizableui/content/panelUI.js
new file mode 100644
index 0000000000..bad418abb4
--- /dev/null
+++ b/comm/mail/components/customizableui/content/panelUI.js
@@ -0,0 +1,882 @@
+/* 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 ../../../base/content/globalOverlay.js */
+/* import-globals-from ../../../base/content/mailCore.js */
+/* import-globals-from ../../../base/content/mailWindowOverlay.js */
+/* import-globals-from ../../../base/content/messenger.js */
+/* import-globals-from ../../../extensions/mailviews/content/msgViewPickerOverlay.js */
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ShortcutUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ShortcutUtils.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionsUI",
+ "resource:///modules/ExtensionsUI.jsm"
+);
+
+/**
+ * Maintains the state and dispatches events for the main menu panel.
+ */
+const PanelUI = {
+ /** Panel events that we listen for. */
+ get kEvents() {
+ return [
+ "popupshowing",
+ "popupshown",
+ "popuphiding",
+ "popuphidden",
+ "ViewShowing",
+ ];
+ },
+ /**
+ * Used for lazily getting and memoizing elements from the document. Lazy
+ * getters are set in init, and memoizing happens after the first retrieval.
+ */
+ get kElements() {
+ return {
+ mainView: "appMenu-mainView",
+ multiView: "appMenu-multiView",
+ menuButton: "button-appmenu",
+ panel: "appMenu-popup",
+ addonNotificationContainer: "appMenu-addon-banners",
+ navbar: "mail-bar3",
+ };
+ },
+
+ kAppMenuButtons: new Set(),
+
+ _initialized: false,
+ _notifications: null,
+
+ init() {
+ this._initElements();
+ this.initAppMenuButton("button-appmenu", "mail-toolbox");
+
+ this.menuButton = this.menuButtonMail;
+
+ Services.obs.addObserver(this, "fullscreen-nav-toolbox");
+ Services.obs.addObserver(this, "appMenu-notifications");
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "autoHideToolbarInFullScreen",
+ "browser.fullscreen.autohide",
+ false,
+ (pref, previousValue, newValue) => {
+ // On OSX, or with autohide preffed off, MozDOMFullscreen is the only
+ // event we care about, since fullscreen should behave just like non
+ // fullscreen. Otherwise, we don't want to listen to these because
+ // we'd just be spamming ourselves with both of them whenever a user
+ // opened a video.
+ if (newValue) {
+ window.removeEventListener("MozDOMFullscreen:Entered", this);
+ window.removeEventListener("MozDOMFullscreen:Exited", this);
+ window.addEventListener("fullscreen", this);
+ } else {
+ window.addEventListener("MozDOMFullscreen:Entered", this);
+ window.addEventListener("MozDOMFullscreen:Exited", this);
+ window.removeEventListener("fullscreen", this);
+ }
+
+ this._updateNotifications(false);
+ },
+ autoHidePref => autoHidePref && Services.appinfo.OS !== "Darwin"
+ );
+
+ if (this.autoHideToolbarInFullScreen) {
+ window.addEventListener("fullscreen", this);
+ } else {
+ window.addEventListener("MozDOMFullscreen:Entered", this);
+ window.addEventListener("MozDOMFullscreen:Exited", this);
+ }
+
+ window.addEventListener("activate", this);
+
+ Services.obs.notifyObservers(
+ null,
+ "appMenu-notifications-request",
+ "refresh"
+ );
+
+ this._initialized = true;
+ },
+
+ _initElements() {
+ for (let [k, v] of Object.entries(this.kElements)) {
+ // Need to do fresh let-bindings per iteration
+ let getKey = k;
+ let id = v;
+ this.__defineGetter__(getKey, function () {
+ delete this[getKey];
+ // eslint-disable-next-line consistent-return
+ return (this[getKey] = document.getElementById(id));
+ });
+ }
+ },
+
+ initAppMenuButton(id, toolboxId) {
+ let button = document.getElementById(id);
+ if (!button) {
+ // If not in the document, the button should be in the toolbox palette,
+ // which isn't part of the document.
+ let toolbox = document.getElementById(toolboxId);
+ if (toolbox) {
+ button = toolbox.palette.querySelector(`#${id}`);
+ }
+ }
+
+ if (button) {
+ button.addEventListener("mousedown", PanelUI);
+ button.addEventListener("keypress", PanelUI);
+
+ this.kAppMenuButtons.add(button);
+ }
+ },
+
+ _eventListenersAdded: false,
+ _ensureEventListenersAdded() {
+ if (this._eventListenersAdded) {
+ return;
+ }
+ this._addEventListeners();
+ },
+
+ _addEventListeners() {
+ for (let event of this.kEvents) {
+ this.panel.addEventListener(event, this);
+ }
+ this._eventListenersAdded = true;
+ },
+
+ _removeEventListeners() {
+ for (let event of this.kEvents) {
+ this.panel.removeEventListener(event, this);
+ }
+ this._eventListenersAdded = false;
+ },
+
+ uninit() {
+ this._removeEventListeners();
+
+ Services.obs.removeObserver(this, "fullscreen-nav-toolbox");
+ Services.obs.removeObserver(this, "appMenu-notifications");
+
+ window.removeEventListener("MozDOMFullscreen:Entered", this);
+ window.removeEventListener("MozDOMFullscreen:Exited", this);
+ window.removeEventListener("fullscreen", this);
+ window.removeEventListener("activate", this);
+
+ [this.menuButtonMail, this.menuButtonChat].forEach(button => {
+ // There's no chat button in the messageWindow.xhtml context.
+ if (button) {
+ button.removeEventListener("mousedown", this);
+ button.removeEventListener("keypress", this);
+ }
+ });
+ },
+
+ /**
+ * Opens the menu panel if it's closed, or closes it if it's open.
+ *
+ * @param event the event that triggers the toggle.
+ */
+ toggle(event) {
+ // Don't show the panel if the window is in customization mode,
+ // since this button doubles as an exit path for the user in this case.
+ if (document.documentElement.hasAttribute("customizing")) {
+ return;
+ }
+
+ // Since we have several menu buttons, make sure the current one is used.
+ // This works for now, but in the long run, if we're showing badges etc.
+ // then the current menuButton needs to be set when the app's view/tab
+ // changes, not just when the menu is toggled.
+ this.menuButton = event.target;
+
+ this._ensureEventListenersAdded();
+ if (this.panel.state == "open") {
+ this.hide();
+ } else if (this.panel.state == "closed") {
+ this.show(event);
+ }
+ },
+
+ /**
+ * Opens the menu panel. If the event target has a child with the
+ * toolbarbutton-icon attribute, the panel will be anchored on that child.
+ * Otherwise, the panel is anchored on the event target itself.
+ *
+ * @param aEvent the event (if any) that triggers showing the menu.
+ */
+ show(aEvent) {
+ this._ensureShortcutsShown();
+ (async () => {
+ await this.ensureReady();
+
+ if (
+ this.panel.state == "open" ||
+ document.documentElement.hasAttribute("customizing")
+ ) {
+ return;
+ }
+
+ let domEvent = null;
+ if (aEvent && aEvent.type != "command") {
+ domEvent = aEvent;
+ }
+
+ // We try to use the event.target to account for clicks triggered
+ // from the #button-chat-appmenu. In case the opening of the menu isn't
+ // triggered by a click event, fallback to the main menu button as anchor.
+ let anchor = this._getPanelAnchor(
+ aEvent ? aEvent.target : this.menuButton
+ );
+ await PanelMultiView.openPopup(this.panel, anchor, {
+ triggerEvent: domEvent,
+ });
+ })().catch(console.error);
+ },
+
+ /**
+ * If the menu panel is being shown, hide it.
+ */
+ hide() {
+ if (document.documentElement.hasAttribute("customizing")) {
+ return;
+ }
+
+ PanelMultiView.hidePopup(this.panel);
+ },
+
+ observe(subject, topic, status) {
+ switch (topic) {
+ case "fullscreen-nav-toolbox":
+ if (this._notifications) {
+ this._updateNotifications(false);
+ }
+ break;
+ case "appMenu-notifications":
+ // Don't initialize twice.
+ if (status == "init" && this._notifications) {
+ break;
+ }
+ this._notifications = AppMenuNotifications.notifications;
+ this._updateNotifications(true);
+ break;
+ }
+ },
+
+ handleEvent(event) {
+ // Ignore context menus and menu button menus showing and hiding:
+ if (event.type.startsWith("popup") && event.target != this.panel) {
+ return;
+ }
+ switch (event.type) {
+ case "popupshowing":
+ initAppMenuPopup();
+ // Fall through
+ case "popupshown":
+ if (event.type == "popupshown") {
+ CustomizableUI.addPanelCloseListeners(this.panel);
+ }
+ // Fall through
+ case "popuphiding":
+ // Fall through
+ case "popuphidden":
+ this._updateNotifications();
+ this._updatePanelButton(event.target);
+ if (event.type == "popuphidden") {
+ CustomizableUI.removePanelCloseListeners(this.panel);
+ }
+ break;
+ case "mousedown":
+ if (event.button == 0) {
+ this.toggle(event);
+ }
+ break;
+ case "keypress":
+ if (event.key == " " || event.key == "Enter") {
+ this.toggle(event);
+ event.stopPropagation();
+ }
+ break;
+ case "MozDOMFullscreen:Entered":
+ case "MozDOMFullscreen:Exited":
+ case "fullscreen":
+ case "activate":
+ this._updateNotifications();
+ break;
+ case "ViewShowing":
+ PanelUI._handleViewShowingEvent(event);
+ break;
+ }
+ },
+
+ /**
+ * When a ViewShowing event happens when a <panelview> element is shown,
+ * do any required set up for that particular view.
+ *
+ * @param {ViewShowingEvent} event - ViewShowing event.
+ */
+ _handleViewShowingEvent(event) {
+ // Typically event.target for "ViewShowing" is a <panelview> element.
+ PanelUI._ensureShortcutsShown(event.target);
+
+ switch (event.target.id) {
+ case "appMenu-foldersView":
+ this._onFoldersViewShow(event);
+ break;
+ case "appMenu-addonsView":
+ initAddonPrefsMenu(
+ event.target.querySelector(".panel-subview-body"),
+ "toolbarbutton",
+ "subviewbutton subviewbutton-iconic",
+ "subviewbutton subviewbutton-iconic"
+ );
+ break;
+ case "appMenu-toolbarsView":
+ onViewToolbarsPopupShowing(
+ event,
+ "mail-toolbox",
+ document.getElementById("appmenu_quickFilterBar"),
+ "toolbarbutton",
+ "subviewbutton subviewbutton-iconic",
+ true
+ );
+ break;
+ case "appMenu-preferencesLayoutView":
+ PanelUI._onPreferencesLayoutViewShow(event);
+ break;
+ // View
+ case "appMenu-viewMessagesTagsView":
+ PanelUI._refreshDynamicView(event, RefreshTagsPopup);
+ break;
+ case "appMenu-viewMessagesCustomViewsView":
+ PanelUI._refreshDynamicView(event, RefreshCustomViewsPopup);
+ break;
+ }
+ },
+
+ /**
+ * Refreshes some views that are dynamically populated. Typically called by
+ * event listeners responding to a ViewShowing event. It calls a given refresh
+ * function (that populates the view), passing appmenu-specific arguments.
+ *
+ * @param {ViewShowingEvent} event - ViewShowing event.
+ * @param {Function} refreshFunction - Function that refreshes a particular view.
+ */
+ _refreshDynamicView(event, refreshFunction) {
+ refreshFunction(
+ event.target.querySelector(".panel-subview-body"),
+ "toolbarbutton",
+ "subviewbutton subviewbutton-iconic",
+ "toolbarseparator"
+ );
+ },
+
+ get isReady() {
+ return !!this._isReady;
+ },
+
+ /**
+ * Registering the menu panel is done lazily for performance reasons. This
+ * method is exposed so that CustomizationMode can force panel-readyness in the
+ * event that customization mode is started before the panel has been opened
+ * by the user.
+ *
+ * @param aCustomizing (optional) set to true if this was called while entering
+ * customization mode. If that's the case, we trust that customization
+ * mode will handle calling beginBatchUpdate and endBatchUpdate.
+ *
+ * @returns a Promise that resolves once the panel is ready to roll.
+ */
+ async ensureReady() {
+ if (this._isReady) {
+ return;
+ }
+
+ await window.delayedStartupPromise;
+ this._ensureEventListenersAdded();
+ this.panel.hidden = false;
+ this._isReady = true;
+ },
+
+ /**
+ * Shows a subview in the panel with a given ID.
+ *
+ * @param aViewId the ID of the subview to show.
+ * @param aAnchor the element that spawned the subview.
+ */
+ async showSubView(aViewId, aAnchor) {
+ this._ensureEventListenersAdded();
+ let viewNode = document.getElementById(aViewId);
+ if (!viewNode) {
+ console.error("Could not show panel subview with id: " + aViewId);
+ return;
+ }
+
+ if (!aAnchor) {
+ console.error(
+ "Expected an anchor when opening subview with id: " + aViewId
+ );
+ return;
+ }
+
+ let container = aAnchor.closest("panelmultiview");
+ if (container) {
+ container.showSubView(aViewId, aAnchor);
+ }
+ },
+
+ /**
+ * NB: The enable- and disableSingleSubviewPanelAnimations methods only
+ * affect the hiding/showing animations of single-subview panels (tempPanel
+ * in the showSubView method).
+ */
+ disableSingleSubviewPanelAnimations() {
+ this._disableAnimations = true;
+ },
+
+ enableSingleSubviewPanelAnimations() {
+ this._disableAnimations = false;
+ },
+
+ /**
+ * Sets the anchor node into the open or closed state, depending
+ * on the state of the panel.
+ */
+ _updatePanelButton() {
+ this.menuButton.open =
+ this.panel.state == "open" || this.panel.state == "showing";
+ },
+
+ /**
+ * Event handler for showing the Preferences/Layout view. Removes "checked"
+ * from all layout menu items and then checks the current layout menu item.
+ *
+ * @param {ViewShowingEvent} event - ViewShowing event.
+ */
+ _onPreferencesLayoutViewShow(event) {
+ event.target
+ .querySelectorAll("[name='viewlayoutgroup']")
+ .forEach(item => item.removeAttribute("checked"));
+
+ InitViewLayoutStyleMenu(event, true);
+ },
+
+ /**
+ * Event listener for showing the Folders view.
+ *
+ * @param {ViewShowingEvent} event - ViewShowing event.
+ */
+ _onFoldersViewShow(event) {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let folder = about3Pane.gFolder;
+
+ const paneHeaderMenuitem = event.target.querySelector(
+ '[name="paneheader"]'
+ );
+ if (about3Pane.folderPane.isFolderPaneHeaderHidden()) {
+ paneHeaderMenuitem.removeAttribute("checked");
+ } else {
+ paneHeaderMenuitem.setAttribute("checked", "true");
+ }
+
+ let { activeModes, canBeCompact, isCompact } = about3Pane.folderPane;
+ if (isCompact) {
+ activeModes.push("compact");
+ }
+
+ for (let item of event.target.querySelectorAll('[name="viewmessages"]')) {
+ let mode = item.getAttribute("value");
+ if (activeModes.includes(mode)) {
+ item.setAttribute("checked", "true");
+ if (mode == "all") {
+ item.disabled = activeModes.length == 1;
+ }
+ } else {
+ item.removeAttribute("checked");
+ }
+ if (mode == "compact") {
+ item.disabled = !canBeCompact;
+ }
+ }
+
+ goUpdateCommand("cmd_properties");
+ let propertiesMenuItem = document.getElementById("appmenu_properties");
+ if (folder?.server.type == "nntp") {
+ document.l10n.setAttributes(
+ propertiesMenuItem,
+ "menu-edit-newsgroup-properties"
+ );
+ } else {
+ document.l10n.setAttributes(
+ propertiesMenuItem,
+ "menu-edit-folder-properties"
+ );
+ }
+
+ let favoriteFolderMenu = document.getElementById("appmenu_favoriteFolder");
+ if (folder?.getFlag(Ci.nsMsgFolderFlags.Favorite)) {
+ favoriteFolderMenu.setAttribute("checked", "true");
+ } else {
+ favoriteFolderMenu.removeAttribute("checked");
+ }
+ },
+
+ _onToolsMenuShown(event) {
+ let noAccounts = MailServices.accounts.accounts.length == 0;
+ event.target.querySelector("#appmenu_searchCmd").disabled = noAccounts;
+ event.target.querySelector("#appmenu_filtersCmd").disabled = noAccounts;
+ },
+
+ _updateNotifications(notificationsChanged) {
+ let notifications = this._notifications;
+ if (!notifications || !notifications.length) {
+ if (notificationsChanged) {
+ this._clearAllNotifications();
+ }
+ return;
+ }
+
+ let doorhangers = notifications.filter(
+ n => !n.dismissed && !n.options.badgeOnly
+ );
+
+ if (this.panel.state == "showing" || this.panel.state == "open") {
+ // If the menu is already showing, then we need to dismiss all notifications
+ // since we don't want their doorhangers competing for attention
+ doorhangers.forEach(n => {
+ n.dismissed = true;
+ if (n.options.onDismissed) {
+ n.options.onDismissed(window);
+ }
+ });
+ this._clearBadge();
+ if (!notifications[0].options.badgeOnly) {
+ this._showBannerItem(notifications[0]);
+ }
+ } else if (doorhangers.length > 0) {
+ // Only show the doorhanger if the window is focused and not fullscreen
+ if (
+ (window.fullScreen && this.autoHideToolbarInFullScreen) ||
+ Services.focus.activeWindow !== window
+ ) {
+ this._showBadge(doorhangers[0]);
+ this._showBannerItem(doorhangers[0]);
+ } else {
+ this._clearBadge();
+ }
+ } else {
+ this._showBadge(notifications[0]);
+ this._showBannerItem(notifications[0]);
+ }
+ },
+
+ _clearAllNotifications() {
+ this._clearBadge();
+ this._clearBannerItem();
+ },
+
+ _formatDescriptionMessage(n) {
+ let text = {};
+ let array = n.options.message.split("<>");
+ text.start = array[0] || "";
+ text.name = n.options.name || "";
+ text.end = array[1] || "";
+ return text;
+ },
+
+ _showBadge(notification) {
+ let badgeStatus = this._getBadgeStatus(notification);
+ for (let menuButton of this.kAppMenuButtons) {
+ menuButton.setAttribute("badge-status", badgeStatus);
+ }
+ },
+
+ // "Banner item" here refers to an item in the hamburger panel menu. They will
+ // typically show up as a colored row in the panel.
+ _showBannerItem(notification) {
+ const supportedIds = [
+ "update-downloading",
+ "update-available",
+ "update-manual",
+ "update-unsupported",
+ "update-restart",
+ ];
+ if (!supportedIds.includes(notification.id)) {
+ return;
+ }
+
+ if (!this._panelBannerItem) {
+ this._panelBannerItem = this.mainView.querySelector(".panel-banner-item");
+ }
+
+ let l10nId = "appmenuitem-banner-" + notification.id;
+ document.l10n.setAttributes(this._panelBannerItem, l10nId);
+
+ this._panelBannerItem.setAttribute("notificationid", notification.id);
+ this._panelBannerItem.hidden = false;
+ this._panelBannerItem.notification = notification;
+ },
+
+ _clearBadge() {
+ for (let menuButton of this.kAppMenuButtons) {
+ menuButton.removeAttribute("badge-status");
+ }
+ },
+
+ _clearBannerItem() {
+ if (this._panelBannerItem) {
+ this._panelBannerItem.notification = null;
+ this._panelBannerItem.hidden = true;
+ }
+ },
+
+ _onNotificationButtonEvent(event, type) {
+ let notificationEl = getNotificationFromElement(event.target);
+
+ if (!notificationEl) {
+ throw new Error(
+ "PanelUI._onNotificationButtonEvent: couldn't find notification element"
+ );
+ }
+
+ if (!notificationEl.notification) {
+ throw new Error(
+ "PanelUI._onNotificationButtonEvent: couldn't find notification"
+ );
+ }
+
+ let notification = notificationEl.notification;
+
+ if (type == "secondarybuttoncommand") {
+ AppMenuNotifications.callSecondaryAction(window, notification);
+ } else {
+ AppMenuNotifications.callMainAction(window, notification, true);
+ }
+ },
+
+ _onBannerItemSelected(event) {
+ let target = event.target;
+ if (!target.notification) {
+ throw new Error(
+ "menucommand target has no associated action/notification"
+ );
+ }
+
+ event.stopPropagation();
+ AppMenuNotifications.callMainAction(window, target.notification, false);
+ },
+
+ _getPopupId(notification) {
+ return "appMenu-" + notification.id + "-notification";
+ },
+
+ _getBadgeStatus(notification) {
+ return notification.id;
+ },
+
+ _getPanelAnchor(candidate) {
+ let iconAnchor = candidate.badgeStack || candidate.icon;
+ return iconAnchor || candidate;
+ },
+
+ _ensureShortcutsShown(view = this.mainView) {
+ if (view.hasAttribute("added-shortcuts")) {
+ return;
+ }
+ view.setAttribute("added-shortcuts", "true");
+ for (let button of view.querySelectorAll("toolbarbutton[key]")) {
+ let keyId = button.getAttribute("key");
+ let key = document.getElementById(keyId);
+ if (!key) {
+ continue;
+ }
+ button.setAttribute("shortcut", ShortcutUtils.prettifyShortcut(key));
+ }
+ },
+
+ folderViewMenuOnCommand(event) {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ if (!about3Pane) {
+ return;
+ }
+
+ let mode = event.target.getAttribute("value");
+ if (mode == "toggle-header") {
+ about3Pane.folderPane.toggleHeader(event.target.hasAttribute("checked"));
+ return;
+ }
+
+ let activeModes = about3Pane.folderPane.activeModes;
+ let index = activeModes.indexOf(mode);
+ if (event.target.hasAttribute("checked")) {
+ if (index == -1) {
+ activeModes.push(mode);
+ }
+ } else if (index >= 0) {
+ activeModes.splice(index, 1);
+ }
+ about3Pane.folderPane.activeModes = activeModes;
+
+ this._onFoldersViewShow({ target: event.target.parentNode });
+ },
+
+ folderCompactMenuOnCommand(event) {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ if (!about3Pane) {
+ return;
+ }
+
+ about3Pane.folderPane.isCompact = event.target.hasAttribute("checked");
+ },
+
+ setUIDensity(event) {
+ // Loops through all available options and uncheck them. This is necessary
+ // since the toolbarbuttons don't uncheck themselves even if they're radio.
+ for (let item of event.originalTarget
+ .closest(".panel-subview-body")
+ .querySelectorAll("toolbarbutton")) {
+ // Skip this item if it's the one clicked.
+ if (item == event.originalTarget) {
+ continue;
+ }
+
+ item.removeAttribute("checked");
+ }
+ // Update the UI density.
+ UIDensity.setMode(event.originalTarget.mode);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "PanelUI", PanelUI);
+
+/**
+ * Gets the currently selected locale for display.
+ *
+ * @returns the selected locale
+ */
+function getLocale() {
+ return Services.locale.appLocaleAsBCP47;
+}
+
+/**
+ * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>.
+ */
+function getNotificationFromElement(aElement) {
+ return aElement.closest("popupnotification");
+}
+
+/**
+ * This object is Thunderbird's version of the same object in
+ * browser/base/content/browser-addons.js.
+ */
+var gExtensionsNotifications = {
+ initialized: false,
+ init() {
+ this.updateAlerts();
+ this.boundUpdate = this.updateAlerts.bind(this);
+ ExtensionsUI.on("change", this.boundUpdate);
+ this.initialized = true;
+ },
+
+ uninit() {
+ // uninit() can race ahead of init() in some cases, if that happens,
+ // we have no handler to remove.
+ if (!this.initialized) {
+ return;
+ }
+ ExtensionsUI.off("change", this.boundUpdate);
+ },
+
+ get l10n() {
+ if (this._l10n) {
+ return this._l10n;
+ }
+ return (this._l10n = new Localization(
+ ["messenger/addonNotifications.ftl", "branding/brand.ftl"],
+ true
+ ));
+ },
+
+ _createAddonButton(l10nId, addon, callback) {
+ let text = this.l10n.formatValueSync(l10nId, { addonName: addon.name });
+ let button = document.createXULElement("toolbarbutton");
+ button.setAttribute("wrap", "true");
+ button.setAttribute("label", text);
+ button.setAttribute("tooltiptext", text);
+ const DEFAULT_EXTENSION_ICON =
+ "chrome://messenger/skin/icons/new/compact/extension.svg";
+ button.setAttribute("image", addon.iconURL || DEFAULT_EXTENSION_ICON);
+ button.className = "addon-banner-item subviewbutton";
+
+ button.addEventListener("command", callback);
+ PanelUI.addonNotificationContainer.appendChild(button);
+ },
+
+ updateAlerts() {
+ let gBrowser = document.getElementById("tabmail");
+ let sideloaded = ExtensionsUI.sideloaded;
+ let updates = ExtensionsUI.updates;
+
+ let container = PanelUI.addonNotificationContainer;
+
+ while (container.firstChild) {
+ container.firstChild.remove();
+ }
+
+ let items = 0;
+ for (let update of updates) {
+ if (++items > 4) {
+ break;
+ }
+ this._createAddonButton(
+ "webext-perms-update-menu-item",
+ update.addon,
+ evt => {
+ ExtensionsUI.showUpdate(gBrowser, update);
+ }
+ );
+ }
+
+ for (let addon of sideloaded) {
+ if (++items > 4) {
+ break;
+ }
+ this._createAddonButton("webext-perms-sideload-menu-item", addon, evt => {
+ // We need to hide the main menu manually because the toolbarbutton is
+ // removed immediately while processing this event, and PanelUI is
+ // unable to identify which panel should be closed automatically.
+ PanelUI.hide();
+ ExtensionsUI.showSideloaded(gBrowser, addon);
+ });
+ }
+ },
+};
+
+addEventListener("unload", () => gExtensionsNotifications.uninit(), {
+ once: true,
+});
diff --git a/comm/mail/components/customizableui/moz.build b/comm/mail/components/customizableui/moz.build
new file mode 100644
index 0000000000..4bc53e73ea
--- /dev/null
+++ b/comm/mail/components/customizableui/moz.build
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+ "content",
+]
+
+EXTRA_JS_MODULES += [
+ "CustomizableUI.sys.mjs",
+ "PanelMultiView.sys.mjs",
+]
diff --git a/comm/mail/components/devtools/components.conf b/comm/mail/components/devtools/components.conf
new file mode 100644
index 0000000000..023940a05f
--- /dev/null
+++ b/comm/mail/components/devtools/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{089694e9-106a-4704-abf7-62a88545e194}',
+ 'contract_ids': ['@mozilla.org/messenger/devtools-startup-clh;1'],
+ 'jsm': 'resource:///modules/devtools-loader.jsm',
+ 'constructor': 'DevToolsStartup',
+ 'categories': {'command-line-handler': 'm-aaa-tb-devtools'},
+ },
+]
diff --git a/comm/mail/components/devtools/devtools-loader.jsm b/comm/mail/components/devtools/devtools-loader.jsm
new file mode 100644
index 0000000000..a951bb2b94
--- /dev/null
+++ b/comm/mail/components/devtools/devtools-loader.jsm
@@ -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/. */
+
+"use strict";
+
+function DevToolsStartup() {}
+
+DevToolsStartup.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsICommandLineHandler"]),
+
+ helpInfo: "",
+ handle(cmdLine) {
+ this.initialize();
+
+ // We want to overwrite the -devtools flag and open the toolbox instead
+ let devtoolsFlag = cmdLine.handleFlag("devtools", false);
+ if (devtoolsFlag) {
+ this.handleDevToolsFlag(cmdLine);
+ }
+ },
+
+ handleDevToolsFlag(cmdLine) {
+ const { BrowserToolboxLauncher } = ChromeUtils.importESModule(
+ "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs"
+ );
+ BrowserToolboxLauncher.init();
+
+ if (cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_AUTO) {
+ cmdLine.preventDefault = true;
+ }
+ },
+
+ initialize() {
+ let { loader, require, DevToolsLoader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ let { DevToolsServer } = require("devtools/server/devtools-server");
+ let { gDevTools } = require("devtools/client/framework/devtools");
+
+ // Set up the client and server chrome window type, make sure it can't be set
+ Object.defineProperty(DevToolsServer, "chromeWindowType", {
+ get: () => "mail:3pane",
+ set: () => {},
+ configurable: true,
+ });
+ Object.defineProperty(gDevTools, "chromeWindowType", {
+ get: () => "mail:3pane",
+ set: () => {},
+ configurable: true,
+ });
+
+ // Make sure our root actor is always registered, no matter how devtools are called.
+ let devtoolsRegisterActors =
+ DevToolsServer.registerActors.bind(DevToolsServer);
+ DevToolsServer.registerActors = function (options) {
+ devtoolsRegisterActors(options);
+ if (options.root) {
+ const {
+ createRootActor,
+ } = require("resource:///modules/tb-root-actor.js");
+ DevToolsServer.setRootActor(createRootActor);
+ }
+ };
+
+ // Make the loader visible to the debugger by default and for the already
+ // loaded instance. Thunderbird now also provides the Browser Toolbox for
+ // chrome debugging, which uses its own separate loader instance.
+ DevToolsLoader.prototype.invisibleToDebugger = false;
+ loader.invisibleToDebugger = false;
+ DevToolsServer.allowChromeProcess = true;
+
+ // Initialize and load the toolkit/browser actors. This will also call above function to set the
+ // Thunderbird root actor
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+ },
+};
+
+var EXPORTED_SYMBOLS = ["DevToolsStartup"];
diff --git a/comm/mail/components/devtools/moz.build b/comm/mail/components/devtools/moz.build
new file mode 100644
index 0000000000..fbe8acc2cb
--- /dev/null
+++ b/comm/mail/components/devtools/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# 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_JS_MODULES += [
+ "devtools-loader.jsm",
+ "tb-root-actor.js",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/devtools/tb-root-actor.js b/comm/mail/components/devtools/tb-root-actor.js
new file mode 100644
index 0000000000..3f546605f6
--- /dev/null
+++ b/comm/mail/components/devtools/tb-root-actor.js
@@ -0,0 +1,104 @@
+/* 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 loader, require, exports */
+
+/**
+ * Actors for Thunderbird Developer Tools, for example the root actor or tab
+ * list actor.
+ */
+
+var { ActorRegistry } = require("devtools/server/actors/utils/actor-registry");
+
+loader.lazyRequireGetter(
+ this,
+ "RootActor",
+ "devtools/server/actors/root",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "BrowserTabList",
+ "devtools/server/actors/webbrowser",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "BrowserAddonList",
+ "devtools/server/actors/webbrowser",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "sendShutdownEvent",
+ "devtools/server/actors/webbrowser",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WorkerDescriptorActorList",
+ "devtools/server/actors/worker/worker-descriptor-actor-list",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ServiceWorkerRegistrationActorList",
+ "devtools/server/actors/worker/service-worker-registration-list",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ProcessActorList",
+ "devtools/server/actors/process",
+ true
+);
+
+/**
+ * Create the root actor for Thunderbird.
+ *
+ * @param aConnection The debugger connection to create the actor for.
+ * @returns The mail actor for the connection.
+ */
+exports.createRootActor = function (aConnection) {
+ let parameters = {
+ tabList: new TBTabList(aConnection),
+ addonList: new BrowserAddonList(aConnection),
+ workerList: new WorkerDescriptorActorList(aConnection, {}),
+ serviceWorkerRegistrationList: new ServiceWorkerRegistrationActorList(
+ aConnection
+ ),
+ processList: new ProcessActorList(),
+ globalActorFactories: ActorRegistry.globalActorFactories,
+ onShutdown: sendShutdownEvent,
+ };
+
+ // Create the root actor and set the application type
+ let rootActor = new RootActor(aConnection, parameters);
+ rootActor.applicationType = "mail";
+
+ return rootActor;
+};
+
+/**
+ * Thunderbird's version of the tab list. We don't have gBrowser, but tabmail has similar functions
+ * that will be helpful. The tabs displayed are those tabs in tabmail that have a browser element.
+ * This is mainly the contentTabs, but can also be others such as the start page.
+ */
+class TBTabList extends BrowserTabList {
+ _getSelectedBrowser(window) {
+ let tabmail = window.document.getElementById("tabmail");
+ return tabmail ? tabmail.selectedBrowser : null;
+ }
+
+ _getChildren(window) {
+ let tabmail = window.document.getElementById("tabmail");
+ if (!tabmail) {
+ return [];
+ }
+
+ return tabmail.tabInfo
+ .map(tab => tabmail.getBrowserForTab(tab))
+ .filter(Boolean);
+ }
+}
diff --git a/comm/mail/components/downloads/content/aboutDownloads.js b/comm/mail/components/downloads/content/aboutDownloads.js
new file mode 100644
index 0000000000..6cd7e2973c
--- /dev/null
+++ b/comm/mail/components/downloads/content/aboutDownloads.js
@@ -0,0 +1,414 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
+/* 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 goUpdateCommand */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+});
+
+window.addEventListener("load", event => {
+ DownloadsView.init();
+});
+
+var DownloadsView = {
+ init() {
+ window.controllers.insertControllerAt(0, this);
+ this.listElement = document.getElementById("msgDownloadsRichListBox");
+
+ this.items = new Map();
+
+ Downloads.getList(Downloads.ALL)
+ .then(list => list.addView(this))
+ .catch(console.error);
+
+ window.addEventListener("unload", aEvent => {
+ Downloads.getList(Downloads.ALL)
+ .then(list => list.removeView(this))
+ .catch(console.error);
+ window.controllers.removeController(this);
+ });
+ },
+
+ insertOrMoveItem(aItem) {
+ let compare = (a, b) => {
+ // active downloads always before stopped downloads
+ if (a.stopped != b.stopped) {
+ return b.stopped ? -1 : 1;
+ }
+ // most recent downloads first
+ return b.startTime - a.startTime;
+ };
+
+ let at = this.listElement.firstElementChild;
+ while (at && compare(aItem.download, at.download) > 0) {
+ at = at.nextElementSibling;
+ }
+ this.listElement.insertBefore(aItem.element, at);
+ },
+
+ onDownloadAdded(aDownload) {
+ let isPurgedFromDisk = download => {
+ if (!download.succeeded) {
+ return false;
+ }
+ let targetFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ targetFile.initWithPath(download.target.path);
+ return !targetFile.exists();
+ };
+ if (isPurgedFromDisk(aDownload)) {
+ Downloads.getList(Downloads.ALL).then(list => list.remove(aDownload));
+ return;
+ }
+
+ let item = new DownloadItem(aDownload);
+ this.items.set(aDownload, item);
+ this.insertOrMoveItem(item);
+ },
+
+ onDownloadChanged(aDownload) {
+ let item = this.items.get(aDownload);
+ if (!item) {
+ console.error("No DownloadItem found for download");
+ return;
+ }
+
+ if (item.stateChanged) {
+ this.insertOrMoveItem(item);
+ }
+
+ item.onDownloadChanged();
+ },
+
+ onDownloadRemoved(aDownload) {
+ let item = this.items.get(aDownload);
+ if (!item) {
+ console.error("No DownloadItem found for download");
+ return;
+ }
+
+ this.items.delete(aDownload);
+ this.listElement.removeChild(item.element);
+ },
+
+ onDownloadContextMenu() {
+ this.updateCommands();
+ },
+
+ clearDownloads() {
+ Downloads.getList(Downloads.ALL)
+ .then(list => list.removeFinished())
+ .catch(console.error);
+ },
+
+ searchDownloads() {
+ let searchString = document.getElementById("searchBox").value.toLowerCase();
+ for (let i = 0; i < this.listElement.itemCount; i++) {
+ let downloadElem = this.listElement.getItemAtIndex(i);
+ downloadElem.collapsed = !downloadElem.downloadItem.fileName
+ .toLowerCase()
+ .includes(searchString);
+ }
+ this.listElement.clearSelection();
+ },
+
+ supportsCommand(aCommand) {
+ return (
+ this.commands.includes(aCommand) ||
+ DownloadItem.prototype.supportsCommand(aCommand)
+ );
+ },
+
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "msgDownloadsCmd_clearDownloads":
+ case "msgDownloadsCmd_searchDownloads":
+ // We could disable these if there are no downloads in the list, but
+ // updating the commands when new items become available is tricky.
+ return true;
+ }
+
+ let element = this.listElement.selectedItem;
+ if (element) {
+ return element.downloadItem.isCommandEnabled(aCommand);
+ }
+
+ return false;
+ },
+
+ doCommand(aCommand) {
+ switch (aCommand) {
+ case "msgDownloadsCmd_clearDownloads":
+ this.clearDownloads();
+ return;
+ case "msgDownloadsCmd_searchDownloads":
+ this.searchDownloads();
+ return;
+ }
+
+ if (this.listElement.selectedCount == 0) {
+ return;
+ }
+
+ for (let element of this.listElement.selectedItems) {
+ element.downloadItem.doCommand(aCommand);
+ }
+ },
+
+ onEvent() {},
+
+ updateCommands() {
+ this.commands.forEach(goUpdateCommand);
+ DownloadItem.prototype.commands.forEach(goUpdateCommand);
+ },
+
+ commands: [
+ "msgDownloadsCmd_clearDownloads",
+ "msgDownloadsCmd_searchDownloads",
+ ],
+};
+
+function DownloadItem(aDownload) {
+ this._download = aDownload;
+ this._updateFromDownload();
+
+ if (aDownload._unknownProperties && aDownload._unknownProperties.sender) {
+ this._sender = aDownload._unknownProperties.sender;
+ } else {
+ this._sender = "";
+ }
+ this._fileName = this._htmlEscape(PathUtils.filename(aDownload.target.path));
+ this._iconUrl = "moz-icon://" + this._fileName + "?size=32";
+ this._startDate = this._htmlEscape(
+ DownloadUtils.getReadableDates(aDownload.startTime)[0]
+ );
+ this._filePath = aDownload.target.path;
+}
+
+var kDownloadStatePropertyNames = [
+ "stopped",
+ "succeeded",
+ "canceled",
+ "error",
+ "startTime",
+];
+
+DownloadItem.prototype = {
+ _htmlEscape(s) {
+ s = s.replace(/&/g, "&amp;");
+ s = s.replace(/>/g, "&gt;");
+ s = s.replace(/</g, "&lt;");
+ s = s.replace(/"/g, "&quot;");
+ s = s.replace(/'/g, "&apos;");
+ return s;
+ },
+
+ _updateFromDownload() {
+ this._state = {};
+ for (let name of kDownloadStatePropertyNames) {
+ this._state[name] = this._download[name];
+ }
+ },
+
+ get stateChanged() {
+ for (let name of kDownloadStatePropertyNames) {
+ if (this._state[name] != this._download[name]) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ get download() {
+ return this._download;
+ },
+
+ get element() {
+ if (!this._element) {
+ this._element = this.createXULElement();
+ }
+
+ return this._element;
+ },
+
+ createXULElement() {
+ let element = document.createXULElement("richlistitem");
+ element.classList.add("download");
+ element.setAttribute("align", "center");
+
+ let image = document.createElement("img");
+ image.setAttribute("alt", "");
+ // Allow the given src to be invalid.
+ image.classList.add("fileTypeIcon", "invisible-on-broken");
+
+ let vbox = document.createXULElement("vbox");
+ vbox.setAttribute("pack", "center");
+ vbox.setAttribute("flex", "1");
+
+ let hbox = document.createXULElement("hbox");
+ let hbox2 = document.createXULElement("hbox");
+
+ let sender = document.createXULElement("description");
+ sender.classList.add("sender");
+
+ let fileName = document.createXULElement("description");
+ fileName.setAttribute("crop", "center");
+ fileName.classList.add("fileName");
+
+ let size = document.createXULElement("description");
+ size.classList.add("size");
+
+ let startDate = document.createXULElement("description");
+ startDate.setAttribute("crop", "end");
+ startDate.classList.add("startDate");
+
+ hbox.appendChild(fileName);
+ hbox.appendChild(size);
+ hbox2.appendChild(sender);
+ hbox2.appendChild(startDate);
+
+ vbox.appendChild(hbox);
+ vbox.appendChild(hbox2);
+
+ let vbox2 = document.createXULElement("vbox");
+
+ let downloadButton = document.createXULElement("button");
+ downloadButton.classList.add("downloadButton", "downloadIconShow");
+
+ vbox2.appendChild(downloadButton);
+
+ element.appendChild(image);
+ element.appendChild(vbox2);
+ element.appendChild(vbox);
+
+ // launch the download if double clicked
+ vbox.addEventListener("dblclick", aEvent => this.launch());
+
+ // Show the downloaded file in folder if the folder icon is clicked.
+ downloadButton.addEventListener("click", aEvent => this.show());
+
+ // set download as an expando property for the context menu
+ element.download = this.download;
+ element.downloadItem = this;
+
+ this.updateElement(element);
+
+ return element;
+ },
+
+ updateElement(element) {
+ let fileTypeIcon = element.querySelector(".fileTypeIcon");
+ fileTypeIcon.setAttribute("src", this.iconUrl);
+
+ let size = element.querySelector(".size");
+ size.setAttribute("value", this.size);
+ size.setAttribute("tooltiptext", this.size);
+
+ let fileName = element.querySelector(".fileName");
+ fileName.setAttribute("value", this.fileName);
+ fileName.setAttribute("tooltiptext", this.fileName);
+
+ let sender = element.querySelector(".sender");
+ sender.setAttribute("value", this.sender);
+ sender.setAttribute("tooltiptext", this.sender);
+
+ let startDate = element.querySelector(".startDate");
+ startDate.setAttribute("value", this.startDate);
+ startDate.setAttribute("tooltiptext", this.startDate);
+ },
+
+ launch() {
+ if (this.download.succeeded) {
+ this.download.launch().catch(console.error);
+ }
+ },
+
+ remove() {
+ Downloads.getList(Downloads.ALL)
+ .then(list => list.remove(this.download))
+ .then(() => this.download.finalize(true))
+ .catch(console.error);
+ },
+
+ show() {
+ if (this.download.succeeded) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(this._filePath);
+ file.reveal();
+ }
+ },
+
+ onDownloadChanged() {
+ this._updateFromDownload();
+ this.updateElement(this.element);
+ },
+
+ get fileName() {
+ return this._fileName;
+ },
+
+ get iconUrl() {
+ return this._iconUrl;
+ },
+
+ get sender() {
+ return this._sender;
+ },
+
+ get size() {
+ let bytes;
+ if (this.download.succeeded || this.download.hasProgress) {
+ bytes = this.download.target.size;
+ } else {
+ bytes = this.download.currentBytes;
+ }
+ return DownloadUtils.convertByteUnits(bytes).join("");
+ },
+
+ get startDate() {
+ return this._startDate;
+ },
+
+ supportsCommand(aCommand) {
+ return this.commands.includes(aCommand);
+ },
+
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "msgDownloadsCmd_open":
+ case "msgDownloadsCmd_show":
+ return this.download.succeeded;
+ case "msgDownloadsCmd_remove":
+ return true;
+ }
+ return false;
+ },
+
+ doCommand(aCommand) {
+ switch (aCommand) {
+ case "msgDownloadsCmd_open":
+ this.launch();
+ break;
+ case "msgDownloadsCmd_show":
+ this.show();
+ break;
+ case "msgDownloadsCmd_remove":
+ this.remove();
+ break;
+ }
+ },
+
+ commands: [
+ "msgDownloadsCmd_remove",
+ "msgDownloadsCmd_open",
+ "msgDownloadsCmd_show",
+ ],
+};
diff --git a/comm/mail/components/downloads/content/aboutDownloads.xhtml b/comm/mail/components/downloads/content/aboutDownloads.xhtml
new file mode 100644
index 0000000000..fdc570c06f
--- /dev/null
+++ b/comm/mail/components/downloads/content/aboutDownloads.xhtml
@@ -0,0 +1,98 @@
+<?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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet href="chrome://global/skin/in-content/common.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/downloads/aboutDownloads.css"?>
+
+<!DOCTYPE html [
+<!ENTITY % aboutDownloadsDTD SYSTEM "chrome://messenger/locale/aboutDownloads.dtd">
+%aboutDownloadsDTD;
+]>
+
+<html id="aboutDownloads" 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"
+ scrolling="false"
+ lightweightthemes="true">
+<head>
+ <title>&aboutDownloads.title;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/downloads/aboutDownloads.js"></script>
+ <meta http-equiv="Content-Security-Policy" content="default-src chrome:; img-src chrome: moz-icon:; object-src 'none'; script-src chrome: 'unsafe-inline'" />
+ <meta name="color-scheme" content="light dark" />
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/download.svg" />
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <commandset id="msgDownloadCommands"
+ commandupdater="true"
+ events="focus,select,contextmenu">
+ <command id="msgDownloadsCmd_open"
+ oncommand="goDoCommand('msgDownloadsCmd_open')"/>
+ <command id="msgDownloadsCmd_show"
+ oncommand="goDoCommand('msgDownloadsCmd_show')"/>
+ <command id="msgDownloadsCmd_remove"
+ oncommand="goDoCommand('msgDownloadsCmd_remove')"/>
+ <command id="msgDownloadsCmd_clearDownloads"
+ oncommand="goDoCommand('msgDownloadsCmd_clearDownloads')"/>
+ <command id="msgDownloadsCmd_searchDownloads"
+ oncommand="goDoCommand('msgDownloadsCmd_searchDownloads')"/>
+ </commandset>
+
+ <keyset id="downloadKeys">
+ <key keycode="&cmd.searchDownloads.key;" modifiers="accel"
+ oncommand="document.getElementById('searchBox').focus();"/>
+ </keyset>
+
+ <hbox id="downloadTopBox"
+ align="center">
+ <button id="clearDownloads"
+ command="msgDownloadsCmd_clearDownloads"
+ label="&cmd.clearList.label;"
+ accesskey="&cmd.clearList.accesskey;"
+ tooltiptext="&cmd.clearList.tooltip;"/>
+
+ <spacer flex="1"/>
+ <search-textbox id="searchBox"
+ class="themeableSearchBox"
+ command="msgDownloadsCmd_searchDownloads"
+ placeholder="&cmd.searchDownloads.label;"/>
+ </hbox>
+
+ <hbox id="downloadBottomBox" flex="1">
+ <richlistbox id="msgDownloadsRichListBox"
+ flex="1"
+ seltype="multiple"
+ context="msgDownloadsContextMenu"
+ oncontextmenu="DownloadsView.onDownloadContextMenu();"/>
+ </hbox>
+
+ <menupopup id="msgDownloadsContextMenu">
+ <menuitem command="msgDownloadsCmd_remove"
+ class="msgDownloadRemoveFromHistoryMenuItem"
+ label="&cmd.removeFromHistory.label;"
+ accesskey="&cmd.removeFromHistory.accesskey;"/>
+ <menuitem command="msgDownloadsCmd_open"
+ label="&cmd.open.label;"
+ accesskey="&cmd.open.accesskey;"/>
+ <menuitem command="msgDownloadsCmd_show"
+ class="msgDownloadShowMenuItem"
+#ifdef XP_MACOSX
+ label="&cmd.showMac.label;"
+ accesskey="&cmd.showMac.accesskey;"
+#else
+ label="&cmd.show.label;"
+ accesskey="&cmd.show.accesskey;"
+#endif
+ />
+ <menuitem command="msgDownloadsCmd_clearDownloads"
+ label="&cmd.clearList.label;"
+ accesskey="&cmd.clearList.accesskey;"
+ tooltiptext="&cmd.clearList.tooltip;"/>
+ </menupopup>
+</html:body>
+</html>
diff --git a/comm/mail/components/downloads/jar.mn b/comm/mail/components/downloads/jar.mn
new file mode 100644
index 0000000000..ff6628e82d
--- /dev/null
+++ b/comm/mail/components/downloads/jar.mn
@@ -0,0 +1,7 @@
+# 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/downloads/aboutDownloads.js (content/aboutDownloads.js)
+* content/messenger/downloads/aboutDownloads.xhtml (content/aboutDownloads.xhtml)
diff --git a/comm/mail/components/downloads/moz.build b/comm/mail/components/downloads/moz.build
new file mode 100644
index 0000000000..ea0b25aae8
--- /dev/null
+++ b/comm/mail/components/downloads/moz.build
@@ -0,0 +1,5 @@
+# vim: set filetype=python:
+# 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/.
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/enterprisepolicies/Policies.sys.mjs b/comm/mail/components/enterprisepolicies/Policies.sys.mjs
new file mode 100644
index 0000000000..d35e9a2d30
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/Policies.sys.mjs
@@ -0,0 +1,1758 @@
+/* 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";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"],
+ gExternalProtocolService: [
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ "nsIExternalProtocolService",
+ ],
+ gHandlerService: [
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService",
+ ],
+ gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"],
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ProxyPolicies: "resource:///modules/policies/ProxyPolicies.sys.mjs",
+});
+
+const PREF_LOGLEVEL = "browser.policies.loglevel";
+const ABOUT_CONTRACT = "@mozilla.org/network/protocol/about;1?what=";
+
+const isXpcshell = Services.env.exists("XPCSHELL_TEST_PROFILE_DIR");
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ prefix: "Policies.jsm",
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.jsm for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: PREF_LOGLEVEL,
+ });
+});
+
+/*
+ * ============================
+ * = POLICIES IMPLEMENTATIONS =
+ * ============================
+ *
+ * The Policies object below is where the implementation for each policy
+ * happens. An object for each policy should be defined, containing
+ * callback functions that will be called by the engine.
+ *
+ * See the _callbacks object in EnterprisePolicies.js for the list of
+ * possible callbacks and an explanation of each.
+ *
+ * Each callback will be called with two parameters:
+ * - manager
+ * This is the EnterprisePoliciesManager singleton object from
+ * EnterprisePolicies.js
+ *
+ * - param
+ * The parameter defined for this policy in policies-schema.json.
+ * It will be different for each policy. It could be a boolean,
+ * a string, an array or a complex object. All parameters have
+ * been validated according to the schema, and no unknown
+ * properties will be present on them.
+ *
+ * The callbacks will be bound to their parent policy object.
+ */
+export var Policies = {
+ // Used for cleaning up policies.
+ // Use the same timing that you used for setting up the policy.
+ _cleanup: {
+ onBeforeAddons(manager) {
+ if (Cu.isInAutomation || isXpcshell) {
+ console.log("_cleanup from onBeforeAddons");
+ clearBlockedAboutPages();
+ }
+ },
+ onProfileAfterChange(manager) {
+ if (Cu.isInAutomation || isXpcshell) {
+ console.log("_cleanup from onProfileAfterChange");
+ }
+ },
+ onBeforeUIStartup(manager) {
+ if (Cu.isInAutomation || isXpcshell) {
+ console.log("_cleanup from onBeforeUIStartup");
+ }
+ },
+ onAllWindowsRestored(manager) {
+ if (Cu.isInAutomation || isXpcshell) {
+ console.log("_cleanup from onAllWindowsRestored");
+ }
+ },
+ },
+
+ "3rdparty": {
+ onBeforeAddons(manager, param) {
+ manager.setExtensionPolicies(param.Extensions);
+ },
+ },
+
+ AppAutoUpdate: {
+ onBeforeUIStartup(manager, param) {
+ // Logic feels a bit reversed here, but it's correct. If AppAutoUpdate is
+ // true, we disallow turning off auto updating, and visa versa.
+ if (param) {
+ manager.disallowFeature("app-auto-updates-off");
+ } else {
+ manager.disallowFeature("app-auto-updates-on");
+ }
+ },
+ },
+
+ AppUpdatePin: {
+ validate(param) {
+ // This is the version when pinning was introduced. Attempting to set a
+ // pin before this will not work, because Balrog's pinning table will
+ // never have the necessary entry.
+ const earliestPinMajorVersion = 102;
+ const earliestPinMinorVersion = 0;
+
+ let pinParts = param.split(".");
+
+ if (pinParts.length < 2) {
+ lazy.log.error("AppUpdatePin has too few dots.");
+ return false;
+ }
+ if (pinParts.length > 3) {
+ lazy.log.error("AppUpdatePin has too many dots.");
+ return false;
+ }
+
+ const trailingPinPart = pinParts.pop();
+ if (trailingPinPart != "") {
+ lazy.log.error("AppUpdatePin does not end with a trailing dot.");
+ return false;
+ }
+
+ const pinMajorVersionStr = pinParts.shift();
+ if (!pinMajorVersionStr.length) {
+ lazy.log.error("AppUpdatePin's major version is empty.");
+ return false;
+ }
+ if (!/^\d+$/.test(pinMajorVersionStr)) {
+ lazy.log.error(
+ "AppUpdatePin's major version contains a non-numeric character."
+ );
+ return false;
+ }
+ if (/^0/.test(pinMajorVersionStr)) {
+ lazy.log.error("AppUpdatePin's major version contains a leading 0.");
+ return false;
+ }
+ const pinMajorVersionInt = parseInt(pinMajorVersionStr, 10);
+ if (isNaN(pinMajorVersionInt)) {
+ lazy.log.error(
+ "AppUpdatePin's major version could not be parsed to an integer."
+ );
+ return false;
+ }
+ if (pinMajorVersionInt < earliestPinMajorVersion) {
+ lazy.log.error(
+ `AppUpdatePin must not be earlier than '${earliestPinMajorVersion}.${earliestPinMinorVersion}.'.`
+ );
+ return false;
+ }
+
+ if (pinParts.length) {
+ const pinMinorVersionStr = pinParts.shift();
+ if (!pinMinorVersionStr.length) {
+ lazy.log.error("AppUpdatePin's minor version is empty.");
+ return false;
+ }
+ if (!/^\d+$/.test(pinMinorVersionStr)) {
+ lazy.log.error(
+ "AppUpdatePin's minor version contains a non-numeric character."
+ );
+ return false;
+ }
+ if (/^0\d/.test(pinMinorVersionStr)) {
+ lazy.log.error("AppUpdatePin's minor version contains a leading 0.");
+ return false;
+ }
+ const pinMinorVersionInt = parseInt(pinMinorVersionStr, 10);
+ if (isNaN(pinMinorVersionInt)) {
+ lazy.log.error(
+ "AppUpdatePin's minor version could not be parsed to an integer."
+ );
+ return false;
+ }
+ if (
+ pinMajorVersionInt == earliestPinMajorVersion &&
+ pinMinorVersionInt < earliestPinMinorVersion
+ ) {
+ lazy.log.error(
+ `AppUpdatePin must not be earlier than '${earliestPinMajorVersion}.${earliestPinMinorVersion}.'.`
+ );
+ return false;
+ }
+ }
+
+ return true;
+ },
+ // No additional implementation needed here. UpdateService.sys.mjs will
+ // check for this policy directly when determining the update URL.
+ },
+
+ AppUpdateURL: {
+ // No implementation needed here. UpdateService.sys.mjs will check for this
+ // policy directly when determining the update URL.
+ },
+
+ Authentication: {
+ onBeforeAddons(manager, param) {
+ let locked = true;
+ if ("Locked" in param) {
+ locked = param.Locked;
+ }
+
+ if ("SPNEGO" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.negotiate-auth.trusted-uris",
+ param.SPNEGO.join(", "),
+ locked
+ );
+ }
+ if ("Delegated" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.negotiate-auth.delegation-uris",
+ param.Delegated.join(", "),
+ locked
+ );
+ }
+ if ("NTLM" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.automatic-ntlm-auth.trusted-uris",
+ param.NTLM.join(", "),
+ locked
+ );
+ }
+ if ("AllowNonFQDN" in param) {
+ if ("NTLM" in param.AllowNonFQDN) {
+ PoliciesUtils.setDefaultPref(
+ "network.automatic-ntlm-auth.allow-non-fqdn",
+ param.AllowNonFQDN.NTLM,
+ locked
+ );
+ }
+ if ("SPNEGO" in param.AllowNonFQDN) {
+ PoliciesUtils.setDefaultPref(
+ "network.negotiate-auth.allow-non-fqdn",
+ param.AllowNonFQDN.SPNEGO,
+ locked
+ );
+ }
+ }
+ if ("AllowProxies" in param) {
+ if ("NTLM" in param.AllowProxies) {
+ PoliciesUtils.setDefaultPref(
+ "network.automatic-ntlm-auth.allow-proxies",
+ param.AllowProxies.NTLM,
+ locked
+ );
+ }
+ if ("SPNEGO" in param.AllowProxies) {
+ PoliciesUtils.setDefaultPref(
+ "network.negotiate-auth.allow-proxies",
+ param.AllowProxies.SPNEGO,
+ locked
+ );
+ }
+ }
+ if ("PrivateBrowsing" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.auth.private-browsing-sso",
+ param.PrivateBrowsing,
+ locked
+ );
+ }
+ },
+ },
+
+ BackgroundAppUpdate: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ manager.disallowFeature("app-background-update-off");
+ } else {
+ manager.disallowFeature("app-background-update-on");
+ }
+ },
+ },
+
+ BlockAboutAddons: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ blockAboutPage(manager, "about:addons", true);
+ }
+ },
+ },
+
+ BlockAboutConfig: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ blockAboutPage(manager, "about:config");
+ setAndLockPref("devtools.chrome.enabled", false);
+ }
+ },
+ },
+
+ BlockAboutProfiles: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ blockAboutPage(manager, "about:profiles");
+ }
+ },
+ },
+
+ BlockAboutSupport: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ blockAboutPage(manager, "about:support");
+ }
+ },
+ },
+
+ CaptivePortal: {
+ onBeforeAddons(manager, param) {
+ setAndLockPref("network.captive-portal-service.enabled", param);
+ },
+ },
+
+ Certificates: {
+ onBeforeAddons(manager, param) {
+ if ("ImportEnterpriseRoots" in param) {
+ setAndLockPref(
+ "security.enterprise_roots.enabled",
+ param.ImportEnterpriseRoots
+ );
+ }
+ if ("Install" in param) {
+ (async () => {
+ let dirs = [];
+ let platform = AppConstants.platform;
+ if (platform == "win") {
+ dirs = [
+ // Ugly, but there is no official way to get %USERNAME\AppData\Roaming\Mozilla.
+ Services.dirsvc.get("XREUSysExt", Ci.nsIFile).parent,
+ // Even more ugly, but there is no official way to get %USERNAME\AppData\Local\Mozilla.
+ Services.dirsvc.get("DefProfLRt", Ci.nsIFile).parent.parent,
+ ];
+ } else if (platform == "macosx" || platform == "linux") {
+ dirs = [
+ // These two keys are named wrong. They return the Mozilla directory.
+ Services.dirsvc.get("XREUserNativeManifests", Ci.nsIFile),
+ Services.dirsvc.get("XRESysNativeManifests", Ci.nsIFile),
+ ];
+ }
+ dirs.unshift(Services.dirsvc.get("XREAppDist", Ci.nsIFile));
+ for (let certfilename of param.Install) {
+ let certfile;
+ try {
+ certfile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ certfile.initWithPath(certfilename);
+ } catch (e) {
+ for (let dir of dirs) {
+ certfile = dir.clone();
+ certfile.append(
+ platform == "linux" ? "certificates" : "Certificates"
+ );
+ certfile.append(certfilename);
+ if (certfile.exists()) {
+ break;
+ }
+ }
+ }
+ let file;
+ try {
+ file = await File.createFromNsIFile(certfile);
+ } catch (e) {
+ lazy.log.error(`Unable to find certificate - ${certfilename}`);
+ continue;
+ }
+ let reader = new FileReader();
+ reader.onloadend = function () {
+ if (reader.readyState != reader.DONE) {
+ lazy.log.error(`Unable to read certificate - ${certfile.path}`);
+ return;
+ }
+ let certFile = reader.result;
+ let certFileArray = [];
+ for (let i = 0; i < certFile.length; i++) {
+ certFileArray.push(certFile.charCodeAt(i));
+ }
+ let cert;
+ try {
+ cert = lazy.gCertDB.constructX509(certFileArray);
+ } catch (e) {
+ lazy.log.debug(
+ `constructX509 failed with error '${e}' - trying constructX509FromBase64.`
+ );
+ try {
+ // It might be PEM instead of DER.
+ cert = lazy.gCertDB.constructX509FromBase64(
+ pemToBase64(certFile)
+ );
+ } catch (ex) {
+ lazy.log.error(
+ `Unable to add certificate - ${certfile.path}`,
+ ex
+ );
+ }
+ }
+ if (cert) {
+ if (
+ lazy.gCertDB.isCertTrusted(
+ cert,
+ Ci.nsIX509Cert.CA_CERT,
+ Ci.nsIX509CertDB.TRUSTED_SSL
+ )
+ ) {
+ // Certificate is already installed.
+ return;
+ }
+ try {
+ lazy.gCertDB.addCert(certFile, "CT,CT,");
+ } catch (e) {
+ // It might be PEM instead of DER.
+ lazy.gCertDB.addCertFromBase64(
+ pemToBase64(certFile),
+ "CT,CT,"
+ );
+ }
+ }
+ };
+ reader.readAsBinaryString(file);
+ }
+ })();
+ }
+ },
+ },
+
+ Cookies: {
+ onBeforeUIStartup(manager, param) {
+ addAllowDenyPermissions("cookie", param.Allow, param.Block);
+
+ if (param.Block) {
+ const hosts = param.Block.map(url => url.hostname)
+ .sort()
+ .join("\n");
+ runOncePerModification("clearCookiesForBlockedHosts", hosts, () => {
+ for (let blocked of param.Block) {
+ Services.cookies.removeCookiesWithOriginAttributes(
+ "{}",
+ blocked.hostname
+ );
+ }
+ });
+ }
+
+ if (
+ param.Default !== undefined ||
+ param.AcceptThirdParty !== undefined ||
+ param.Locked
+ ) {
+ const ACCEPT_COOKIES = 0;
+ const REJECT_THIRD_PARTY_COOKIES = 1;
+ const REJECT_ALL_COOKIES = 2;
+ const REJECT_UNVISITED_THIRD_PARTY = 3;
+
+ let newCookieBehavior = ACCEPT_COOKIES;
+ if (param.Default !== undefined && !param.Default) {
+ newCookieBehavior = REJECT_ALL_COOKIES;
+ } else if (param.AcceptThirdParty) {
+ if (param.AcceptThirdParty == "never") {
+ newCookieBehavior = REJECT_THIRD_PARTY_COOKIES;
+ } else if (param.AcceptThirdParty == "from-visited") {
+ newCookieBehavior = REJECT_UNVISITED_THIRD_PARTY;
+ }
+ }
+
+ PoliciesUtils.setDefaultPref(
+ "network.cookie.cookieBehavior",
+ newCookieBehavior,
+ param.Locked
+ );
+ PoliciesUtils.setDefaultPref(
+ "network.cookie.cookieBehavior.pbmode",
+ newCookieBehavior,
+ param.Locked
+ );
+ }
+
+ if (param.ExpireAtSessionEnd != undefined) {
+ lazy.log.error(
+ "'ExpireAtSessionEnd' has been deprecated and it has no effect anymore."
+ );
+ }
+ },
+ },
+
+ DefaultDownloadDirectory: {
+ onBeforeAddons(manager, param) {
+ PoliciesUtils.setDefaultPref(
+ "browser.download.dir",
+ replacePathVariables(param)
+ );
+ // If a custom download directory is being used, just lock folder list to 2.
+ setAndLockPref("browser.download.folderList", 2);
+ },
+ },
+
+ DisableAppUpdate: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ manager.disallowFeature("appUpdate");
+ }
+ },
+ },
+
+ DisableBuiltinPDFViewer: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ setAndLockPref("pdfjs.disabled", true);
+ }
+ },
+ },
+
+ DisabledCiphers: {
+ onBeforeAddons(manager, param) {
+ let cipherPrefs = {
+ TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256:
+ "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256",
+ TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:
+ "security.ssl3.ecdhe_ecdsa_aes_128_gcm_sha256",
+ TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256:
+ "security.ssl3.ecdhe_ecdsa_chacha20_poly1305_sha256",
+ TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256:
+ "security.ssl3.ecdhe_rsa_chacha20_poly1305_sha256",
+ TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384:
+ "security.ssl3.ecdhe_ecdsa_aes_256_gcm_sha384",
+ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384:
+ "security.ssl3.ecdhe_rsa_aes_256_gcm_sha384",
+ TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA:
+ "security.ssl3.ecdhe_rsa_aes_128_sha",
+ TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA:
+ "security.ssl3.ecdhe_ecdsa_aes_128_sha",
+ TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA:
+ "security.ssl3.ecdhe_rsa_aes_256_sha",
+ TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA:
+ "security.ssl3.ecdhe_ecdsa_aes_256_sha",
+ TLS_DHE_RSA_WITH_AES_128_CBC_SHA: "security.ssl3.dhe_rsa_aes_128_sha",
+ TLS_DHE_RSA_WITH_AES_256_CBC_SHA: "security.ssl3.dhe_rsa_aes_256_sha",
+ TLS_RSA_WITH_AES_128_GCM_SHA256: "security.ssl3.rsa_aes_128_gcm_sha256",
+ TLS_RSA_WITH_AES_256_GCM_SHA384: "security.ssl3.rsa_aes_256_gcm_sha384",
+ TLS_RSA_WITH_AES_128_CBC_SHA: "security.ssl3.rsa_aes_128_sha",
+ TLS_RSA_WITH_AES_256_CBC_SHA: "security.ssl3.rsa_aes_256_sha",
+ TLS_RSA_WITH_3DES_EDE_CBC_SHA:
+ "security.ssl3.deprecated.rsa_des_ede3_sha",
+ };
+
+ for (let cipher in param) {
+ setAndLockPref(cipherPrefs[cipher], !param[cipher]);
+ }
+ },
+ },
+
+ DisableDeveloperTools: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ setAndLockPref("devtools.policy.disabled", true);
+ setAndLockPref("devtools.chrome.enabled", false);
+
+ manager.disallowFeature("devtools");
+ blockAboutPage(manager, "about:debugging");
+ blockAboutPage(manager, "about:devtools-toolbox");
+ }
+ },
+ },
+
+ DisableMasterPasswordCreation: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ manager.disallowFeature("createMasterPassword");
+ }
+ },
+ },
+
+ DisablePasswordReveal: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ manager.disallowFeature("passwordReveal");
+ }
+ },
+ },
+
+ DisableSafeMode: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ manager.disallowFeature("safeMode");
+ }
+ },
+ },
+
+ DisableSecurityBypass: {
+ onBeforeUIStartup(manager, param) {
+ if ("InvalidCertificate" in param) {
+ setAndLockPref(
+ "security.certerror.hideAddException",
+ param.InvalidCertificate
+ );
+ }
+
+ if ("SafeBrowsing" in param) {
+ setAndLockPref(
+ "browser.safebrowsing.allowOverride",
+ !param.SafeBrowsing
+ );
+ }
+ },
+ },
+
+ DisableSystemAddonUpdate: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ manager.disallowFeature("SysAddonUpdate");
+ }
+ },
+ },
+
+ DisableTelemetry: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ setAndLockPref("datareporting.healthreport.uploadEnabled", false);
+ setAndLockPref("datareporting.policy.dataSubmissionEnabled", false);
+ setAndLockPref("toolkit.telemetry.archive.enabled", false);
+ blockAboutPage(manager, "about:telemetry");
+ }
+ },
+ },
+
+ DNSOverHTTPS: {
+ onBeforeAddons(manager, param) {
+ let locked = false;
+ if ("Locked" in param) {
+ locked = param.Locked;
+ }
+ if ("Enabled" in param) {
+ let mode = param.Enabled ? 2 : 5;
+ PoliciesUtils.setDefaultPref("network.trr.mode", mode, locked);
+ }
+ if ("ProviderURL" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.trr.uri",
+ param.ProviderURL.href,
+ locked
+ );
+ }
+ if ("ExcludedDomains" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.trr.excluded-domains",
+ param.ExcludedDomains.join(","),
+ locked
+ );
+ }
+ },
+ },
+
+ DownloadDirectory: {
+ onBeforeAddons(manager, param) {
+ setAndLockPref("browser.download.dir", replacePathVariables(param));
+ // If a custom download directory is being used, just lock folder list to 2.
+ setAndLockPref("browser.download.folderList", 2);
+ // Per Chrome spec, user can't choose to download every time
+ // if this is set.
+ setAndLockPref("browser.download.useDownloadDir", true);
+ },
+ },
+
+ Extensions: {
+ onBeforeUIStartup(manager, param) {
+ let uninstallingPromise = Promise.resolve();
+ if ("Uninstall" in param) {
+ uninstallingPromise = runOncePerModification(
+ "extensionsUninstall",
+ JSON.stringify(param.Uninstall),
+ async () => {
+ // If we're uninstalling add-ons, re-run the extensionsInstall runOnce even if it hasn't
+ // changed, which will allow add-ons to be updated.
+ Services.prefs.clearUserPref(
+ "browser.policies.runOncePerModification.extensionsInstall"
+ );
+ let addons = await lazy.AddonManager.getAddonsByIDs(
+ param.Uninstall
+ );
+ for (let addon of addons) {
+ if (addon) {
+ try {
+ await addon.uninstall();
+ } catch (e) {
+ // This can fail for add-ons that can't be uninstalled.
+ lazy.log.debug(
+ `Add-on ID (${addon.id}) couldn't be uninstalled.`
+ );
+ }
+ }
+ }
+ }
+ );
+ }
+ if ("Install" in param) {
+ runOncePerModification(
+ "extensionsInstall",
+ JSON.stringify(param.Install),
+ async () => {
+ await uninstallingPromise;
+ for (let location of param.Install) {
+ let uri;
+ try {
+ // We need to try as a file first because
+ // Windows paths are valid URIs.
+ // This is done for legacy support (old API)
+ let xpiFile = new lazy.FileUtils.File(location);
+ uri = Services.io.newFileURI(xpiFile);
+ } catch (e) {
+ uri = Services.io.newURI(location);
+ }
+ installAddonFromURL(uri.spec);
+ }
+ }
+ );
+ }
+ if ("Locked" in param) {
+ for (let ID of param.Locked) {
+ manager.disallowFeature(`uninstall-extension:${ID}`);
+ manager.disallowFeature(`disable-extension:${ID}`);
+ }
+ }
+ },
+ },
+
+ ExtensionSettings: {
+ onBeforeAddons(manager, param) {
+ try {
+ manager.setExtensionSettings(param);
+ } catch (e) {
+ lazy.log.error("Invalid ExtensionSettings");
+ }
+ },
+ async onBeforeUIStartup(manager, param) {
+ let extensionSettings = param;
+ let blockAllExtensions = false;
+ if ("*" in extensionSettings) {
+ if (
+ "installation_mode" in extensionSettings["*"] &&
+ extensionSettings["*"].installation_mode == "blocked"
+ ) {
+ blockAllExtensions = true;
+ // Turn off discovery pane in about:addons
+ setAndLockPref("extensions.getAddons.showPane", false);
+ // Turn off recommendations
+ setAndLockPref(
+ "extensions.htmlaboutaddons.recommendations.enable",
+ false
+ );
+ // Block about:debugging
+ blockAboutPage(manager, "about:debugging");
+ }
+ if ("restricted_domains" in extensionSettings["*"]) {
+ let restrictedDomains = Services.prefs
+ .getCharPref("extensions.webextensions.restrictedDomains")
+ .split(",");
+ setAndLockPref(
+ "extensions.webextensions.restrictedDomains",
+ restrictedDomains
+ .concat(extensionSettings["*"].restricted_domains)
+ .join(",")
+ );
+ }
+ }
+ let addons = await lazy.AddonManager.getAllAddons();
+ let allowedExtensions = [];
+ for (let extensionID in extensionSettings) {
+ if (extensionID == "*") {
+ // Ignore global settings
+ continue;
+ }
+ if ("installation_mode" in extensionSettings[extensionID]) {
+ if (
+ extensionSettings[extensionID].installation_mode ==
+ "force_installed" ||
+ extensionSettings[extensionID].installation_mode ==
+ "normal_installed"
+ ) {
+ if (!extensionSettings[extensionID].install_url) {
+ throw new Error(`Missing install_url for ${extensionID}`);
+ }
+ installAddonFromURL(
+ extensionSettings[extensionID].install_url,
+ extensionID,
+ addons.find(addon => addon.id == extensionID)
+ );
+ manager.disallowFeature(`uninstall-extension:${extensionID}`);
+ if (
+ extensionSettings[extensionID].installation_mode ==
+ "force_installed"
+ ) {
+ manager.disallowFeature(`disable-extension:${extensionID}`);
+ }
+ allowedExtensions.push(extensionID);
+ } else if (
+ extensionSettings[extensionID].installation_mode == "allowed"
+ ) {
+ allowedExtensions.push(extensionID);
+ } else if (
+ extensionSettings[extensionID].installation_mode == "blocked"
+ ) {
+ if (addons.find(addon => addon.id == extensionID)) {
+ // Can't use the addon from getActiveAddons since it doesn't have uninstall.
+ let addon = await lazy.AddonManager.getAddonByID(extensionID);
+ try {
+ await addon.uninstall();
+ } catch (e) {
+ // This can fail for add-ons that can't be uninstalled.
+ lazy.log.debug(
+ `Add-on ID (${addon.id}) couldn't be uninstalled.`
+ );
+ }
+ }
+ }
+ }
+ }
+ if (blockAllExtensions) {
+ for (let addon of addons) {
+ if (
+ addon.isSystem ||
+ addon.isBuiltin ||
+ !(addon.scope & lazy.AddonManager.SCOPE_PROFILE)
+ ) {
+ continue;
+ }
+ if (!allowedExtensions.includes(addon.id)) {
+ try {
+ // Can't use the addon from getActiveAddons since it doesn't have uninstall.
+ let addonToUninstall = await lazy.AddonManager.getAddonByID(
+ addon.id
+ );
+ await addonToUninstall.uninstall();
+ } catch (e) {
+ // This can fail for add-ons that can't be uninstalled.
+ lazy.log.debug(
+ `Add-on ID (${addon.id}) couldn't be uninstalled.`
+ );
+ }
+ }
+ }
+ }
+ },
+ },
+
+ ExtensionUpdate: {
+ onBeforeAddons(manager, param) {
+ if (!param) {
+ setAndLockPref("extensions.update.enabled", param);
+ }
+ },
+ },
+
+ Handlers: {
+ onBeforeAddons(manager, param) {
+ if ("mimeTypes" in param) {
+ for (let mimeType in param.mimeTypes) {
+ let mimeInfo = param.mimeTypes[mimeType];
+ let realMIMEInfo = lazy.gMIMEService.getFromTypeAndExtension(
+ mimeType,
+ ""
+ );
+ processMIMEInfo(mimeInfo, realMIMEInfo);
+ }
+ }
+ if ("extensions" in param) {
+ for (let extension in param.extensions) {
+ let mimeInfo = param.extensions[extension];
+ try {
+ let realMIMEInfo = lazy.gMIMEService.getFromTypeAndExtension(
+ "",
+ extension
+ );
+ processMIMEInfo(mimeInfo, realMIMEInfo);
+ } catch (e) {
+ lazy.log.error(`Invalid file extension (${extension})`);
+ }
+ }
+ }
+ if ("schemes" in param) {
+ for (let scheme in param.schemes) {
+ let handlerInfo = param.schemes[scheme];
+ let realHandlerInfo =
+ lazy.gExternalProtocolService.getProtocolHandlerInfo(scheme);
+ processMIMEInfo(handlerInfo, realHandlerInfo);
+ }
+ }
+ },
+ },
+
+ HardwareAcceleration: {
+ onBeforeAddons(manager, param) {
+ if (!param) {
+ setAndLockPref("layers.acceleration.disabled", true);
+ }
+ },
+ },
+
+ InstallAddonsPermission: {
+ onBeforeUIStartup(manager, param) {
+ if ("Allow" in param) {
+ addAllowDenyPermissions("install", param.Allow, null);
+ }
+ if ("Default" in param) {
+ setAndLockPref("xpinstall.enabled", param.Default);
+ if (!param.Default) {
+ blockAboutPage(manager, "about:debugging");
+ manager.disallowFeature("xpinstall");
+ }
+ }
+ },
+ },
+
+ ManualAppUpdateOnly: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ manager.disallowFeature("autoAppUpdateChecking");
+ }
+ },
+ },
+
+ NetworkPrediction: {
+ onBeforeAddons(manager, param) {
+ setAndLockPref("network.dns.disablePrefetch", !param);
+ setAndLockPref("network.dns.disablePrefetchFromHTTPS", !param);
+ },
+ },
+
+ OfferToSaveLogins: {
+ onBeforeUIStartup(manager, param) {
+ setAndLockPref("signon.rememberSignons", param);
+ setAndLockPref("services.passwordSavingEnabled", param);
+ },
+ },
+
+ OfferToSaveLoginsDefault: {
+ onBeforeUIStartup(manager, param) {
+ let policies = Services.policies.getActivePolicies();
+ if ("OfferToSaveLogins" in policies) {
+ lazy.log.error(
+ `OfferToSaveLoginsDefault ignored because OfferToSaveLogins is present.`
+ );
+ } else {
+ PoliciesUtils.setDefaultPref("signon.rememberSignons", param);
+ }
+ },
+ },
+
+ PasswordManagerEnabled: {
+ onBeforeUIStartup(manager, param) {
+ if (!param) {
+ blockAboutPage(manager, "about:logins", true);
+ setAndLockPref("pref.privacy.disable_button.view_passwords", true);
+ }
+ setAndLockPref("signon.rememberSignons", param);
+ },
+ },
+
+ PDFjs: {
+ onBeforeAddons(manager, param) {
+ if ("Enabled" in param) {
+ setAndLockPref("pdfjs.disabled", !param.Enabled);
+ }
+ if ("EnablePermissions" in param) {
+ setAndLockPref("pdfjs.enablePermissions", !param.Enabled);
+ }
+ },
+ },
+
+ Preferences: {
+ onBeforeAddons(manager, param) {
+ const allowedPrefixes = [
+ "accessibility.",
+ "app.update.",
+ "browser.",
+ "calendar.",
+ "chat.",
+ "datareporting.policy.",
+ "dom.",
+ "extensions.",
+ "general.autoScroll",
+ "general.smoothScroll",
+ "geo.",
+ "gfx.",
+ "intl.",
+ "layers.",
+ "layout.",
+ "mail.",
+ "mailnews.",
+ "media.",
+ "network.",
+ "pdfjs.",
+ "places.",
+ "print.",
+ "signon.",
+ "spellchecker.",
+ "ui.",
+ "widget.",
+ ];
+ const allowedSecurityPrefs = [
+ "security.default_personal_cert",
+ "security.insecure_connection_text.enabled",
+ "security.insecure_connection_text.pbmode.enabled",
+ "security.insecure_field_warning.contextual.enabled",
+ "security.mixed_content.block_active_content",
+ "security.osclientcerts.autoload",
+ "security.ssl.errorReporting.enabled",
+ "security.tls.hello_downgrade_check",
+ "security.tls.version.enable-deprecated",
+ "security.warn_submit_secure_to_insecure",
+ ];
+ const blockedPrefs = [
+ "app.update.channel",
+ "app.update.lastUpdateTime",
+ "app.update.migrated",
+ ];
+
+ for (let preference in param) {
+ if (blockedPrefs.includes(preference)) {
+ lazy.log.error(
+ `Unable to set preference ${preference}. Preference not allowed for security reasons.`
+ );
+ continue;
+ }
+ if (preference.startsWith("security.")) {
+ if (!allowedSecurityPrefs.includes(preference)) {
+ lazy.log.error(
+ `Unable to set preference ${preference}. Preference not allowed for security reasons.`
+ );
+ continue;
+ }
+ } else if (
+ !allowedPrefixes.some(prefix => preference.startsWith(prefix))
+ ) {
+ lazy.log.error(
+ `Unable to set preference ${preference}. Preference not allowed for stability reasons.`
+ );
+ continue;
+ }
+ if (typeof param[preference] != "object") {
+ // Legacy policy preferences
+ setAndLockPref(preference, param[preference]);
+ } else {
+ if (param[preference].Status == "clear") {
+ Services.prefs.clearUserPref(preference);
+ continue;
+ }
+
+ if (param[preference].Status == "user") {
+ var prefBranch = Services.prefs;
+ } else {
+ prefBranch = Services.prefs.getDefaultBranch("");
+ }
+
+ try {
+ switch (typeof param[preference].Value) {
+ case "boolean":
+ prefBranch.setBoolPref(preference, param[preference].Value);
+ break;
+
+ case "number":
+ if (!Number.isInteger(param[preference].Value)) {
+ throw new Error(`Non-integer value for ${preference}`);
+ }
+
+ // This is ugly, but necessary. On Windows GPO and macOS
+ // configs, booleans are converted to 0/1. In the previous
+ // Preferences implementation, the schema took care of
+ // automatically converting these values to booleans.
+ // Since we allow arbitrary prefs now, we have to do
+ // something different. See bug 1666836.
+ if (
+ prefBranch.getPrefType(preference) == prefBranch.PREF_INT ||
+ ![0, 1].includes(param[preference].Value)
+ ) {
+ prefBranch.setIntPref(preference, param[preference].Value);
+ } else {
+ prefBranch.setBoolPref(preference, !!param[preference].Value);
+ }
+ break;
+
+ case "string":
+ prefBranch.setStringPref(preference, param[preference].Value);
+ break;
+ }
+ } catch (e) {
+ lazy.log.error(
+ `Unable to set preference ${preference}. Probable type mismatch.`
+ );
+ }
+
+ if (param[preference].Status == "locked") {
+ Services.prefs.lockPref(preference);
+ }
+ }
+ }
+ },
+ },
+
+ PrimaryPassword: {
+ onAllWindowsRestored(manager, param) {
+ if (param) {
+ manager.disallowFeature("removeMasterPassword");
+ } else {
+ manager.disallowFeature("createMasterPassword");
+ }
+ },
+ },
+
+ PromptForDownloadLocation: {
+ onBeforeAddons(manager, param) {
+ setAndLockPref("browser.download.useDownloadDir", !param);
+ },
+ },
+
+ Proxy: {
+ onBeforeAddons(manager, param) {
+ if (param.Locked) {
+ manager.disallowFeature("changeProxySettings");
+ lazy.ProxyPolicies.configureProxySettings(param, setAndLockPref);
+ } else {
+ lazy.ProxyPolicies.configureProxySettings(
+ param,
+ PoliciesUtils.setDefaultPref
+ );
+ }
+ },
+ },
+
+ RequestedLocales: {
+ onBeforeAddons(manager, param) {
+ let requestedLocales;
+ if (Array.isArray(param)) {
+ requestedLocales = param;
+ } else if (param) {
+ requestedLocales = param.split(",");
+ } else {
+ requestedLocales = [];
+ }
+ runOncePerModification(
+ "requestedLocales",
+ JSON.stringify(requestedLocales),
+ () => {
+ Services.locale.requestedLocales = requestedLocales;
+ }
+ );
+ },
+ },
+
+ SearchEngines: {
+ onBeforeUIStartup(manager, param) {
+ if (param.PreventInstalls) {
+ manager.disallowFeature("installSearchEngine", true);
+ }
+ },
+ onAllWindowsRestored(manager, param) {
+ Services.search.init().then(async () => {
+ // Adding of engines is handled by the SearchService in the init().
+ // Remove can happen after those are added - no engines are allowed
+ // to replace the application provided engines, even if they have been
+ // removed.
+ if (param.Remove) {
+ // Only rerun if the list of engine names has changed.
+ await runOncePerModification(
+ "removeSearchEngines",
+ JSON.stringify(param.Remove),
+ async function () {
+ for (let engineName of param.Remove) {
+ let engine = Services.search.getEngineByName(engineName);
+ if (engine) {
+ try {
+ await Services.search.removeEngine(engine);
+ } catch (ex) {
+ lazy.log.error("Unable to remove the search engine", ex);
+ }
+ }
+ }
+ }
+ );
+ }
+ if (param.Default) {
+ await runOncePerModification(
+ "setDefaultSearchEngine",
+ param.Default,
+ async () => {
+ let defaultEngine;
+ try {
+ defaultEngine = Services.search.getEngineByName(param.Default);
+ if (!defaultEngine) {
+ throw new Error("No engine by that name could be found");
+ }
+ } catch (ex) {
+ lazy.log.error(
+ `Search engine lookup failed when attempting to set ` +
+ `the default engine. Requested engine was ` +
+ `"${param.Default}".`,
+ ex
+ );
+ }
+ if (defaultEngine) {
+ try {
+ await Services.search.setDefault(
+ defaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_ENTERPRISE
+ );
+ } catch (ex) {
+ lazy.log.error("Unable to set the default search engine", ex);
+ }
+ }
+ }
+ );
+ }
+ if (param.DefaultPrivate) {
+ await runOncePerModification(
+ "setDefaultPrivateSearchEngine",
+ param.DefaultPrivate,
+ async () => {
+ let defaultPrivateEngine;
+ try {
+ defaultPrivateEngine = Services.search.getEngineByName(
+ param.DefaultPrivate
+ );
+ if (!defaultPrivateEngine) {
+ throw new Error("No engine by that name could be found");
+ }
+ } catch (ex) {
+ lazy.log.error(
+ `Search engine lookup failed when attempting to set ` +
+ `the default private engine. Requested engine was ` +
+ `"${param.DefaultPrivate}".`,
+ ex
+ );
+ }
+ if (defaultPrivateEngine) {
+ try {
+ await Services.search.setDefaultPrivate(
+ defaultPrivateEngine,
+ Ci.nsISearchService.CHANGE_REASON_ENTERPRISE
+ );
+ } catch (ex) {
+ lazy.log.error(
+ "Unable to set the default private search engine",
+ ex
+ );
+ }
+ }
+ }
+ );
+ }
+ });
+ },
+ },
+
+ SSLVersionMax: {
+ onBeforeAddons(manager, param) {
+ let tlsVersion;
+ switch (param) {
+ case "tls1":
+ tlsVersion = 1;
+ break;
+ case "tls1.1":
+ tlsVersion = 2;
+ break;
+ case "tls1.2":
+ tlsVersion = 3;
+ break;
+ case "tls1.3":
+ tlsVersion = 4;
+ break;
+ }
+ setAndLockPref("security.tls.version.max", tlsVersion);
+ },
+ },
+
+ SSLVersionMin: {
+ onBeforeAddons(manager, param) {
+ let tlsVersion;
+ switch (param) {
+ case "tls1":
+ tlsVersion = 1;
+ break;
+ case "tls1.1":
+ tlsVersion = 2;
+ break;
+ case "tls1.2":
+ tlsVersion = 3;
+ break;
+ case "tls1.3":
+ tlsVersion = 4;
+ break;
+ }
+ setAndLockPref("security.tls.version.min", tlsVersion);
+ },
+ },
+};
+
+/*
+ * ====================
+ * = HELPER FUNCTIONS =
+ * ====================
+ *
+ * The functions below are helpers to be used by several policies.
+ */
+
+/**
+ * setAndLockPref
+ *
+ * Sets the _default_ value of a pref, and locks it (meaning that
+ * the default value will always be returned, independent from what
+ * is stored as the user value).
+ * The value is only changed in memory, and not stored to disk.
+ *
+ * @param {string} prefName
+ * The pref to be changed
+ * @param {boolean,number,string} prefValue
+ * The value to set and lock
+ */
+export function setAndLockPref(prefName, prefValue) {
+ PoliciesUtils.setDefaultPref(prefName, prefValue, true);
+}
+
+/**
+ * setDefaultPref
+ *
+ * Sets the _default_ value of a pref and optionally locks it.
+ * The value is only changed in memory, and not stored to disk.
+ *
+ * @param {string} prefName
+ * The pref to be changed
+ * @param {boolean,number,string} prefValue
+ * The value to set
+ * @param {boolean} locked
+ * Optionally lock the pref
+ */
+export var PoliciesUtils = {
+ setDefaultPref(prefName, prefValue, locked = false) {
+ if (Services.prefs.prefIsLocked(prefName)) {
+ Services.prefs.unlockPref(prefName);
+ }
+
+ let defaults = Services.prefs.getDefaultBranch("");
+
+ switch (typeof prefValue) {
+ case "boolean":
+ defaults.setBoolPref(prefName, prefValue);
+ break;
+
+ case "number":
+ if (!Number.isInteger(prefValue)) {
+ throw new Error(`Non-integer value for ${prefName}`);
+ }
+
+ // This is ugly, but necessary. On Windows GPO and macOS
+ // configs, booleans are converted to 0/1. In the previous
+ // Preferences implementation, the schema took care of
+ // automatically converting these values to booleans.
+ // Since we allow arbitrary prefs now, we have to do
+ // something different. See bug 1666836.
+ if (
+ defaults.getPrefType(prefName) == defaults.PREF_INT ||
+ ![0, 1].includes(prefValue)
+ ) {
+ defaults.setIntPref(prefName, prefValue);
+ } else {
+ defaults.setBoolPref(prefName, !!prefValue);
+ }
+ break;
+
+ case "string":
+ defaults.setStringPref(prefName, prefValue);
+ break;
+ }
+
+ if (locked) {
+ Services.prefs.lockPref(prefName);
+ }
+ },
+};
+
+/**
+ * addAllowDenyPermissions
+ *
+ * Helper function to call the permissions manager (Services.perms.addFromPrincipal)
+ * for two arrays of URLs.
+ *
+ * @param {string} permissionName
+ * The name of the permission to change
+ * @param {Array} allowList
+ * The list of URLs to be set as ALLOW_ACTION for the chosen permission.
+ * @param {Array} blockList
+ * The list of URLs to be set as DENY_ACTION for the chosen permission.
+ */
+function addAllowDenyPermissions(permissionName, allowList, blockList) {
+ allowList = allowList || [];
+ blockList = blockList || [];
+
+ for (let origin of allowList) {
+ try {
+ Services.perms.addFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin),
+ permissionName,
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ Ci.nsIPermissionManager.EXPIRE_POLICY
+ );
+ } catch (ex) {
+ lazy.log
+ .error(`Added by default for ${permissionName} permission in the permission
+ manager - ${origin.href}`);
+ }
+ }
+
+ for (let origin of blockList) {
+ Services.perms.addFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin),
+ permissionName,
+ Ci.nsIPermissionManager.DENY_ACTION,
+ Ci.nsIPermissionManager.EXPIRE_POLICY
+ );
+ }
+}
+
+/**
+ * runOnce
+ *
+ * Helper function to run a callback only once per policy.
+ *
+ * @param {string} actionName
+ * A given name which will be used to track if this callback has run.
+ * @param {Function} callback
+ * The callback to run only once.
+ */
+export function runOnce(actionName, callback) {
+ let prefName = `browser.policies.runonce.${actionName}`;
+ if (Services.prefs.getBoolPref(prefName, false)) {
+ lazy.log.debug(
+ `Not running action ${actionName} again because it has already run.`
+ );
+ return;
+ }
+ Services.prefs.setBoolPref(prefName, true);
+ callback();
+}
+
+/**
+ * runOncePerModification
+ *
+ * Helper function similar to runOnce. The difference is that runOnce runs the
+ * callback once when the policy is set, then never again.
+ * runOncePerModification runs the callback once each time the policy value
+ * changes from its previous value.
+ * If the callback that was passed is an async function, you can await on this
+ * function to await for the callback.
+ *
+ * @param {string} actionName
+ * A given name which will be used to track if this callback has run.
+ * This string will be part of a pref name.
+ * @param {string} policyValue
+ * The current value of the policy. This will be compared to previous
+ * values given to this function to determine if the policy value has
+ * changed. Regardless of the data type of the policy, this must be a
+ * string.
+ * @param {Function} callback
+ * The callback to be run when the pref value changes
+ * @returns Promise
+ * A promise that will resolve once the callback finishes running.
+ *
+ */
+async function runOncePerModification(actionName, policyValue, callback) {
+ let prefName = `browser.policies.runOncePerModification.${actionName}`;
+ let oldPolicyValue = Services.prefs.getStringPref(prefName, undefined);
+ if (policyValue === oldPolicyValue) {
+ lazy.log.debug(
+ `Not running action ${actionName} again because the policy's value is unchanged`
+ );
+ return Promise.resolve();
+ }
+ Services.prefs.setStringPref(prefName, policyValue);
+ return callback();
+}
+
+/**
+ * clearRunOnceModification
+ *
+ * Helper function that clears a runOnce policy.
+ */
+function clearRunOnceModification(actionName) {
+ let prefName = `browser.policies.runOncePerModification.${actionName}`;
+ Services.prefs.clearUserPref(prefName);
+}
+
+function replacePathVariables(path) {
+ if (path.includes("${home}")) {
+ return path.replace("${home}", lazy.FileUtils.getFile("Home", []).path);
+ }
+ return path;
+}
+
+/**
+ * installAddonFromURL
+ *
+ * Helper function that installs an addon from a URL
+ * and verifies that the addon ID matches.
+ */
+function installAddonFromURL(url, extensionID, addon) {
+ if (
+ addon &&
+ addon.sourceURI &&
+ addon.sourceURI.spec == url &&
+ !addon.sourceURI.schemeIs("file")
+ ) {
+ // It's the same addon, don't reinstall.
+ return;
+ }
+ lazy.AddonManager.getInstallForURL(url, {
+ telemetryInfo: { source: "enterprise-policy" },
+ }).then(install => {
+ if (install.addon && install.addon.appDisabled) {
+ lazy.log.error(`Incompatible add-on - ${install.addon.id}`);
+ install.cancel();
+ return;
+ }
+ let listener = {
+ /* eslint-disable-next-line no-shadow */
+ onDownloadEnded: install => {
+ // Install failed, error will be reported elsewhere.
+ if (!install.addon) {
+ return;
+ }
+ if (extensionID && install.addon.id != extensionID) {
+ lazy.log.error(
+ `Add-on downloaded from ${url} had unexpected id (got ${install.addon.id} expected ${extensionID})`
+ );
+ install.removeListener(listener);
+ install.cancel();
+ }
+ if (install.addon.appDisabled) {
+ lazy.log.error(`Incompatible add-on - ${url}`);
+ install.removeListener(listener);
+ install.cancel();
+ }
+ if (
+ addon &&
+ Services.vc.compare(addon.version, install.addon.version) == 0
+ ) {
+ lazy.log.debug(
+ "Installation cancelled because versions are the same"
+ );
+ install.removeListener(listener);
+ install.cancel();
+ }
+ },
+ onDownloadFailed: () => {
+ install.removeListener(listener);
+ lazy.log.error(
+ `Download failed - ${lazy.AddonManager.errorToString(
+ install.error
+ )} - ${url}`
+ );
+ clearRunOnceModification("extensionsInstall");
+ },
+ onInstallFailed: () => {
+ install.removeListener(listener);
+ lazy.log.error(
+ `Installation failed - ${lazy.AddonManager.errorToString(
+ install.error
+ )} - {url}`
+ );
+ },
+ /* eslint-disable-next-line no-shadow */
+ onInstallEnded: (install, addon) => {
+ if (addon.type == "theme") {
+ addon.enable();
+ }
+ install.removeListener(listener);
+ lazy.log.debug(`Installation succeeded - ${url}`);
+ },
+ };
+ install.addListener(listener);
+ install.install();
+ });
+}
+
+let gBlockedAboutPages = [];
+
+function clearBlockedAboutPages() {
+ gBlockedAboutPages = [];
+}
+
+function blockAboutPage(manager, feature, neededOnContentProcess = false) {
+ addChromeURLBlocker();
+ gBlockedAboutPages.push(feature);
+
+ try {
+ let aboutModule = Cc[ABOUT_CONTRACT + feature.split(":")[1]].getService(
+ Ci.nsIAboutModule
+ );
+ let chromeURL = aboutModule.getChromeURI(Services.io.newURI(feature)).spec;
+ gBlockedAboutPages.push(chromeURL);
+ } catch (e) {
+ // Some about pages don't have chrome URLS (compat)
+ }
+}
+
+let ChromeURLBlockPolicy = {
+ shouldLoad(contentLocation, loadInfo, mimeTypeGuess) {
+ let contentType = loadInfo.externalContentPolicyType;
+ if (
+ (contentLocation.scheme != "chrome" &&
+ contentLocation.scheme != "about") ||
+ (contentType != Ci.nsIContentPolicy.TYPE_DOCUMENT &&
+ contentType != Ci.nsIContentPolicy.TYPE_SUBDOCUMENT)
+ ) {
+ return Ci.nsIContentPolicy.ACCEPT;
+ }
+ if (
+ gBlockedAboutPages.some(function (aboutPage) {
+ return contentLocation.spec.startsWith(aboutPage);
+ })
+ ) {
+ return Ci.nsIContentPolicy.REJECT_POLICY;
+ }
+ return Ci.nsIContentPolicy.ACCEPT;
+ },
+ shouldProcess(contentLocation, loadInfo, mimeTypeGuess) {
+ return Ci.nsIContentPolicy.ACCEPT;
+ },
+ classDescription: "Policy Engine Content Policy",
+ contractID: "@mozilla-org/policy-engine-content-policy-service;1",
+ classID: Components.ID("{ba7b9118-cabc-4845-8b26-4215d2a59ed7}"),
+ QueryInterface: ChromeUtils.generateQI(["nsIContentPolicy"]),
+ createInstance(iid) {
+ return this.QueryInterface(iid);
+ },
+};
+
+function addChromeURLBlocker() {
+ if (Cc[ChromeURLBlockPolicy.contractID]) {
+ return;
+ }
+
+ let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.registerFactory(
+ ChromeURLBlockPolicy.classID,
+ ChromeURLBlockPolicy.classDescription,
+ ChromeURLBlockPolicy.contractID,
+ ChromeURLBlockPolicy
+ );
+
+ Services.catMan.addCategoryEntry(
+ "content-policy",
+ ChromeURLBlockPolicy.contractID,
+ ChromeURLBlockPolicy.contractID,
+ false,
+ true
+ );
+}
+
+function pemToBase64(pem) {
+ return pem
+ .replace(/(.*)-----BEGIN CERTIFICATE-----/, "")
+ .replace(/-----END CERTIFICATE-----(.*)/, "")
+ .replace(/[\r\n]/g, "");
+}
+
+function processMIMEInfo(mimeInfo, realMIMEInfo) {
+ if ("handlers" in mimeInfo) {
+ let firstHandler = true;
+ for (let handler of mimeInfo.handlers) {
+ // handler can be null which means they don't
+ // want a preferred handler.
+ if (handler) {
+ let handlerApp;
+ if ("path" in handler) {
+ try {
+ let file = new lazy.FileUtils.File(handler.path);
+ handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.executable = file;
+ } catch (ex) {
+ lazy.log.error(
+ `Unable to create handler executable (${handler.path})`
+ );
+ continue;
+ }
+ } else if ("uriTemplate" in handler) {
+ let templateURL = new URL(handler.uriTemplate);
+ if (templateURL.protocol != "https:") {
+ lazy.log.error(
+ `Web handler must be https (${handler.uriTemplate})`
+ );
+ continue;
+ }
+ if (
+ !templateURL.pathname.includes("%s") &&
+ !templateURL.search.includes("%s")
+ ) {
+ lazy.log.error(
+ `Web handler must contain %s (${handler.uriTemplate})`
+ );
+ continue;
+ }
+ handlerApp = Cc[
+ "@mozilla.org/uriloader/web-handler-app;1"
+ ].createInstance(Ci.nsIWebHandlerApp);
+ handlerApp.uriTemplate = handler.uriTemplate;
+ } else {
+ lazy.log.error("Invalid handler");
+ continue;
+ }
+ if ("name" in handler) {
+ handlerApp.name = handler.name;
+ }
+ realMIMEInfo.possibleApplicationHandlers.appendElement(handlerApp);
+ if (firstHandler) {
+ realMIMEInfo.preferredApplicationHandler = handlerApp;
+ }
+ }
+ firstHandler = false;
+ }
+ }
+ if ("action" in mimeInfo) {
+ let action = realMIMEInfo[mimeInfo.action];
+ if (
+ action == realMIMEInfo.useHelperApp &&
+ !realMIMEInfo.possibleApplicationHandlers.length
+ ) {
+ lazy.log.error("useHelperApp requires a handler");
+ return;
+ }
+ realMIMEInfo.preferredAction = action;
+ }
+ if ("ask" in mimeInfo) {
+ realMIMEInfo.alwaysAskBeforeHandling = mimeInfo.ask;
+ }
+ lazy.gHandlerService.store(realMIMEInfo);
+}
diff --git a/comm/mail/components/enterprisepolicies/content/aboutPolicies.js b/comm/mail/components/enterprisepolicies/content/aboutPolicies.js
new file mode 100644
index 0000000000..850286e001
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/aboutPolicies.js
@@ -0,0 +1,410 @@
+/* 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ schema: "resource:///modules/policies/schema.sys.mjs",
+});
+
+function col(text, className) {
+ let column = document.createElement("td");
+ if (className) {
+ column.classList.add(className);
+ }
+ let content = document.createTextNode(text);
+ column.appendChild(content);
+ return column;
+}
+
+function addMissingColumns() {
+ const table = document.getElementById("activeContent");
+ let maxColumns = 0;
+
+ // count the number of columns per row and set the max number of columns
+ for (let i = 0, length = table.rows.length; i < length; i++) {
+ if (maxColumns < table.rows[i].cells.length) {
+ maxColumns = table.rows[i].cells.length;
+ }
+ }
+
+ // add the missing columns
+ for (let i = 0, length = table.rows.length; i < length; i++) {
+ const rowLength = table.rows[i].cells.length;
+
+ if (rowLength < maxColumns) {
+ let missingColumns = maxColumns - rowLength;
+
+ while (missingColumns > 0) {
+ table.rows[i].insertCell();
+ missingColumns--;
+ }
+ }
+ }
+}
+
+/*
+ * This function generates the Active Policies content to be displayed by calling
+ * a recursive function called generatePolicy() according to the policy schema.
+ */
+
+function generateActivePolicies(data) {
+ let new_cont = document.getElementById("activeContent");
+ new_cont.classList.add("active-policies");
+
+ let policy_count = 0;
+
+ for (let policyName in data) {
+ const color_class = ++policy_count % 2 === 0 ? "even" : "odd";
+
+ if (lazy.schema.properties[policyName].type == "array") {
+ for (let count in data[policyName]) {
+ let isFirstRow = count == 0;
+ let isLastRow = count == data[policyName].length - 1;
+ let row = document.createElement("tr");
+ row.classList.add(color_class);
+ row.appendChild(col(isFirstRow ? policyName : ""));
+ generatePolicy(
+ data[policyName][count],
+ row,
+ 1,
+ new_cont,
+ isLastRow,
+ data[policyName].length > 1
+ );
+ }
+ } else if (lazy.schema.properties[policyName].type == "object") {
+ let count = 0;
+ for (let obj in data[policyName]) {
+ let isFirstRow = count == 0;
+ let isLastRow = count == Object.keys(data[policyName]).length - 1;
+ let row = document.createElement("tr");
+ row.classList.add(color_class);
+ row.appendChild(col(isFirstRow ? policyName : ""));
+ row.appendChild(col(obj));
+ generatePolicy(
+ data[policyName][obj],
+ row,
+ 2,
+ new_cont,
+ isLastRow,
+ true
+ );
+ count++;
+ }
+ } else {
+ let row = document.createElement("tr");
+ row.appendChild(col(policyName));
+ row.appendChild(col(JSON.stringify(data[policyName])));
+ row.classList.add(color_class, "last_row");
+ new_cont.appendChild(row);
+ }
+ }
+
+ if (policy_count < 1) {
+ let current_tab = document.querySelector(".active");
+ if (Services.policies.status == Services.policies.ACTIVE) {
+ current_tab.classList.add("no-specified-policies");
+ } else {
+ current_tab.classList.add("inactive-service");
+ }
+ }
+
+ addMissingColumns();
+}
+
+/*
+ * This is a helper recursive function that iterates levels of each
+ * policy and formats the content to be displayed accordingly.
+ */
+
+function generatePolicy(data, row, depth, new_cont, islast, arr_sep = false) {
+ const color_class = row.classList.contains("odd") ? "odd" : "even";
+
+ if (Array.isArray(data)) {
+ for (let count in data) {
+ if (count == 0) {
+ if (count == data.length - 1) {
+ generatePolicy(
+ data[count],
+ row,
+ depth + 1,
+ new_cont,
+ islast ? islast : false,
+ true
+ );
+ } else {
+ generatePolicy(data[count], row, depth + 1, new_cont, false, false);
+ }
+ } else if (count == data.length - 1) {
+ let last_row = document.createElement("tr");
+ last_row.classList.add(color_class, "arr_sep");
+
+ for (let i = 0; i < depth; i++) {
+ last_row.appendChild(col(""));
+ }
+
+ generatePolicy(
+ data[count],
+ last_row,
+ depth + 1,
+ new_cont,
+ islast ? islast : false,
+ arr_sep
+ );
+ } else {
+ let new_row = document.createElement("tr");
+ new_row.classList.add(color_class);
+
+ for (let i = 0; i < depth; i++) {
+ new_row.appendChild(col(""));
+ }
+
+ generatePolicy(data[count], new_row, depth + 1, new_cont, false, false);
+ }
+ }
+ } else if (typeof data == "object" && Object.keys(data).length > 0) {
+ let count = 0;
+ for (let obj in data) {
+ if (count == 0) {
+ row.appendChild(col(obj));
+ if (count == Object.keys(data).length - 1) {
+ generatePolicy(
+ data[obj],
+ row,
+ depth + 1,
+ new_cont,
+ islast ? islast : false,
+ arr_sep
+ );
+ } else {
+ generatePolicy(data[obj], row, depth + 1, new_cont, false, false);
+ }
+ } else if (count == Object.keys(data).length - 1) {
+ let last_row = document.createElement("tr");
+ for (let i = 0; i < depth; i++) {
+ last_row.appendChild(col(""));
+ }
+
+ last_row.appendChild(col(obj));
+ last_row.classList.add(color_class);
+
+ if (arr_sep) {
+ last_row.classList.add("arr_sep");
+ }
+
+ generatePolicy(
+ data[obj],
+ last_row,
+ depth + 1,
+ new_cont,
+ islast ? islast : false,
+ false
+ );
+ } else {
+ let new_row = document.createElement("tr");
+ new_row.classList.add(color_class);
+
+ for (let i = 0; i < depth; i++) {
+ new_row.appendChild(col(""));
+ }
+
+ new_row.appendChild(col(obj));
+ generatePolicy(data[obj], new_row, depth + 1, new_cont, false, false);
+ }
+ count++;
+ }
+ } else {
+ row.appendChild(col(JSON.stringify(data)));
+
+ if (arr_sep) {
+ row.classList.add("arr_sep");
+ }
+ if (islast) {
+ row.classList.add("last_row");
+ }
+ new_cont.appendChild(row);
+ }
+}
+
+function generateErrors() {
+ const consoleStorage = Cc["@mozilla.org/consoleAPI-storage;1"];
+ const storage = consoleStorage.getService(Ci.nsIConsoleAPIStorage);
+ const consoleEvents = storage.getEvents();
+ const prefixes = [
+ "Enterprise Policies",
+ "JsonSchemaValidator.jsm",
+ "Policies.jsm",
+ "GPOParser.jsm",
+ "Enterprise Policies Child",
+ "BookmarksPolicies.jsm",
+ "ProxyPolicies.jsm",
+ "WebsiteFilter Policy",
+ "macOSPoliciesParser.jsm",
+ ];
+
+ let new_cont = document.getElementById("errorsContent");
+ new_cont.classList.add("errors");
+
+ let flag = false;
+ for (let err of consoleEvents) {
+ if (prefixes.includes(err.prefix)) {
+ flag = true;
+ let row = document.createElement("tr");
+ row.appendChild(col(err.arguments[0]));
+ new_cont.appendChild(row);
+ }
+ }
+ if (!flag) {
+ let errors_tab = document.getElementById("category-errors");
+ errors_tab.style.display = "none";
+ }
+}
+
+function generateDocumentation() {
+ let new_cont = document.getElementById("documentationContent");
+ new_cont.setAttribute("id", "documentationContent");
+
+ // map specific policies to a different string ID, to allow updates to
+ // existing descriptions
+ let string_mapping = {
+ BackgroundAppUpdate: "BackgroundAppUpdate2",
+ Certificates: "CertificatesDescription",
+ };
+
+ for (let policyName in lazy.schema.properties) {
+ let main_tbody = document.createElement("tbody");
+ main_tbody.classList.add("collapsible");
+ main_tbody.addEventListener("click", function () {
+ let content = this.nextElementSibling;
+ content.classList.toggle("content");
+ });
+ let row = document.createElement("tr");
+ row.appendChild(col(policyName));
+ let descriptionColumn = col("");
+ let stringID = string_mapping[policyName] || policyName;
+ descriptionColumn.setAttribute("data-l10n-id", `policy-${stringID}`);
+ row.appendChild(descriptionColumn);
+ main_tbody.appendChild(row);
+ let sec_tbody = document.createElement("tbody");
+ sec_tbody.classList.add("content");
+ sec_tbody.classList.add("content-style");
+ let schema_row = document.createElement("tr");
+ if (lazy.schema.properties[policyName].properties) {
+ let column = col(
+ JSON.stringify(lazy.schema.properties[policyName].properties, null, 1),
+ "schema"
+ );
+ column.colSpan = "2";
+ schema_row.appendChild(column);
+ sec_tbody.appendChild(schema_row);
+ } else if (lazy.schema.properties[policyName].items) {
+ let column = col(
+ JSON.stringify(lazy.schema.properties[policyName], null, 1),
+ "schema"
+ );
+ column.colSpan = "2";
+ schema_row.appendChild(column);
+ sec_tbody.appendChild(schema_row);
+ } else {
+ let column = col(
+ "type: " + lazy.schema.properties[policyName].type,
+ "schema"
+ );
+ column.colSpan = "2";
+ schema_row.appendChild(column);
+ sec_tbody.appendChild(schema_row);
+ if (lazy.schema.properties[policyName].enum) {
+ let enum_row = document.createElement("tr");
+ column = col(
+ "enum: " +
+ JSON.stringify(lazy.schema.properties[policyName].enum, null, 1),
+ "schema"
+ );
+ column.colSpan = "2";
+ enum_row.appendChild(column);
+ sec_tbody.appendChild(enum_row);
+ }
+ }
+ new_cont.appendChild(main_tbody);
+ new_cont.appendChild(sec_tbody);
+ }
+}
+
+let gInited = false;
+function init() {
+ if (gInited) {
+ return;
+ }
+ gInited = true;
+
+ let data = Services.policies.getActivePolicies();
+ generateActivePolicies(data);
+ generateErrors();
+ generateDocumentation();
+
+ // Event delegation on #categories element
+ let menu = document.getElementById("categories");
+ for (let category of menu.children) {
+ category.addEventListener("click", () => show(category));
+ }
+
+ if (location.hash) {
+ let sectionButton = document.getElementById(
+ "category-" + location.hash.substring(1)
+ );
+ if (sectionButton) {
+ sectionButton.click();
+ }
+ }
+
+ window.addEventListener("hashchange", function () {
+ if (location.hash) {
+ let sectionButton = document.getElementById(
+ "category-" + location.hash.substring(1)
+ );
+ sectionButton.click();
+ }
+ });
+}
+
+function show(button) {
+ let current_tab = document.querySelector(".active");
+ let category = button.getAttribute("id").substring("category-".length);
+ let content = document.getElementById(category);
+ if (current_tab == content) {
+ return;
+ }
+ saveScrollPosition(current_tab.id);
+ current_tab.classList.remove("active");
+ current_tab.hidden = true;
+ content.classList.add("active");
+ content.hidden = false;
+
+ let current_button = document.querySelector("[selected=true]");
+ current_button.removeAttribute("selected");
+ button.setAttribute("selected", "true");
+
+ let title = document.getElementById("sectionTitle");
+ title.textContent = button.children[1].textContent;
+ location.hash = category;
+ restoreScrollPosition(category);
+}
+
+const scrollPositions = {};
+function saveScrollPosition(category) {
+ const mainContent = document.querySelector(".main-content");
+ scrollPositions[category] = mainContent.scrollTop;
+}
+
+function restoreScrollPosition(category) {
+ const scrollY = scrollPositions[category] || 0;
+ const mainContent = document.querySelector(".main-content");
+ mainContent.scrollTo(0, scrollY);
+}
diff --git a/comm/mail/components/enterprisepolicies/content/aboutPolicies.xhtml b/comm/mail/components/enterprisepolicies/content/aboutPolicies.xhtml
new file mode 100644
index 0000000000..6ded7130a4
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/aboutPolicies.xhtml
@@ -0,0 +1,107 @@
+<?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/.
+-->
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <title data-l10n-id="about-policies-title" />
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/aboutPolicies.css"
+ type="text/css"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/policies/aboutPolicies.ftl" />
+ <link
+ rel="localization"
+ href="messenger/policies/policies-descriptions.ftl"
+ />
+ <script
+ type="application/javascript"
+ src="chrome://messenger/content/policies/aboutPolicies.js"
+ />
+ </head>
+ <body id="body" onload="init()">
+ <div id="categories">
+ <div class="category" selected="true" id="category-active">
+ <img
+ class="category-icon"
+ src="chrome://messenger/content/policies/policies-active.svg"
+ alt=""
+ />
+ <label class="category-name" data-l10n-id="active-policies-tab"></label>
+ </div>
+ <div class="category" id="category-documentation">
+ <img
+ class="category-icon"
+ src="chrome://messenger/content/policies/policies-documentation.svg"
+ alt=""
+ />
+ <label class="category-name" data-l10n-id="documentation-tab"></label>
+ </div>
+ <div class="category" id="category-errors">
+ <img
+ class="category-icon"
+ src="chrome://messenger/content/policies/policies-error.svg"
+ alt=""
+ />
+ <label class="category-name" data-l10n-id="errors-tab"></label>
+ </div>
+ </div>
+ <div class="main-content">
+ <div class="header">
+ <div
+ id="sectionTitle"
+ class="header-name"
+ data-l10n-id="active-policies-tab"
+ />
+ </div>
+
+ <div id="active" class="tab active">
+ <h3
+ class="inactive-service-message"
+ data-l10n-id="inactive-message"
+ ></h3>
+ <h3
+ class="no-specified-policies-message"
+ data-l10n-id="no-specified-policies-message"
+ ></h3>
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="policy-name" />
+ <th data-l10n-id="policy-value" />
+ </tr>
+ </thead>
+ <tbody id="activeContent" />
+ </table>
+ </div>
+
+ <div id="documentation" class="tab" hidden="true">
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="policy-name" />
+ </tr>
+ </thead>
+ <tbody id="documentationContent" />
+ </table>
+ </div>
+
+ <div id="errors" class="tab" hidden="true">
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="policy-errors" />
+ </tr>
+ </thead>
+ <tbody id="errorsContent" />
+ </table>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/components/enterprisepolicies/content/policies-active.svg b/comm/mail/components/enterprisepolicies/content/policies-active.svg
new file mode 100644
index 0000000000..9ec258d4e0
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/policies-active.svg
@@ -0,0 +1,6 @@
+<!-- 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 height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M11 10a1 1 0 01-1-1V5H3v8a1 1 0 001 1h2a1 1 0 010 2H3a2 2 0 01-2-2V4c0-1.1.9-2 2-2h1.05a2.5 2.5 0 014.9 0H10a2 2 0 012 2v5a1 1 0 01-1 1zm-1-6V3H8.95a1 1 0 01-.98-.8 1.5 1.5 0 00-2.94 0 1 1 0 01-.98.8H3v1zM6.5 2a.5.5 0 110 1 .5.5 0 010-1zm-2 5a.5.5 0 010-1h4a.5.5 0 010 1zm0 2a.5.5 0 010-1h2a.5.5 0 010 1zm0 2a.5.5 0 110-1h3a.5.5 0 110 1zm5.16 5a.67.67 0 01-.47-.2l-2-2a.67.67 0 01.94-.95l1.44 1.44 4.22-6.02a.67.67 0 011.1.77L10.2 15.7a.67.67 0 01-.55.29z"/>
+</svg>
diff --git a/comm/mail/components/enterprisepolicies/content/policies-documentation.svg b/comm/mail/components/enterprisepolicies/content/policies-documentation.svg
new file mode 100644
index 0000000000..a2817d3514
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/policies-documentation.svg
@@ -0,0 +1,6 @@
+<!-- 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 height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M11 7a1 1 0 01-1-1V5H3v8a1 1 0 001 1h2a1 1 0 110 2H3a2 2 0 01-2-2V4c0-1.1.9-2 2-2h1.05a2.5 2.5 0 014.9 0H10a2 2 0 012 2v2a1 1 0 01-1 1zm-1-3V3H8.95a1 1 0 01-.98-.8 1.5 1.5 0 00-2.94 0 1 1 0 01-.98.8H3v1zM6.5 2a.5.5 0 110 1 .5.5 0 010-1zm-2 5a.5.5 0 110-1h4a.5.5 0 010 1zm0 2a.5.5 0 110-1h2a.5.5 0 010 1zm0 2a.5.5 0 110-1h1a.5.5 0 110 1zm6.5 5a4 4 0 110-8 4 4 0 010 8zm.46-2a.46.46 0 10-.92 0 .46.46 0 00.92 0zm-1.06-3c0-.3.25-.6.6-.6s.6.3.6.6c0 .12-.06.25-.16.39-.08.1-.16.17-.23.24l-.1.09-.01.02a1.3 1.3 0 00-.5 1.06.4.4 0 10.8 0c0-.17.04-.26.08-.3a.61.61 0 01.08-.1l.05-.05.07-.07.04-.03c.12-.11.24-.24.35-.37.16-.2.33-.5.33-.88a1.4 1.4 0 00-2.8 0 .4.4 0 10.8 0z"/>
+</svg>
diff --git a/comm/mail/components/enterprisepolicies/content/policies-error.svg b/comm/mail/components/enterprisepolicies/content/policies-error.svg
new file mode 100644
index 0000000000..fad8ddd632
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/policies-error.svg
@@ -0,0 +1,6 @@
+<!-- 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 height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M11 7a1 1 0 01-1-1V5H3v8a1 1 0 001 1h1a1 1 0 110 2H3a2 2 0 01-2-2V4c0-1.1.9-2 2-2h1.05a2.5 2.5 0 014.9 0H10a2 2 0 012 2v2a1 1 0 01-1 1zm-1-3V3H8.95a1 1 0 01-.98-.8 1.5 1.5 0 00-2.94 0 1 1 0 01-.98.8H3v1zM6.5 2a.5.5 0 110 1 .5.5 0 010-1zm-2 5a.5.5 0 110-1h4a.5.5 0 010 1zm0 2a.5.5 0 110-1h3a.5.5 0 010 1zm0 2a.5.5 0 110-1h2a.5.5 0 110 1zm10.4 3.34A1.15 1.15 0 0113.83 16h-5.7a1.15 1.15 0 01-1-1.66l2.85-5.7a1.15 1.15 0 012.06 0zm-4.44-3.78v1.73a.58.58 0 001.15 0v-1.73a.58.58 0 10-1.15 0zm.57 4.13a.68.68 0 100-1.36.68.68 0 000 1.36z"/>
+</svg>
diff --git a/comm/mail/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs b/comm/mail/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs
new file mode 100644
index 0000000000..086a928c60
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs
@@ -0,0 +1,111 @@
+/* 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 = {};
+
+const PREF_LOGLEVEL = "browser.policies.loglevel";
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ prefix: "ProxyPolicies.jsm",
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.jsm for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: PREF_LOGLEVEL,
+ });
+});
+
+// Don't use const here because this is accessed by
+// tests through the BackstagePass object.
+export var PROXY_TYPES_MAP = new Map([
+ ["none", Ci.nsIProtocolProxyService.PROXYCONFIG_DIRECT],
+ ["system", Ci.nsIProtocolProxyService.PROXYCONFIG_SYSTEM],
+ ["manual", Ci.nsIProtocolProxyService.PROXYCONFIG_MANUAL],
+ ["autoDetect", Ci.nsIProtocolProxyService.PROXYCONFIG_WPAD],
+ ["autoConfig", Ci.nsIProtocolProxyService.PROXYCONFIG_PAC],
+]);
+
+export var ProxyPolicies = {
+ configureProxySettings(param, setPref) {
+ if (param.Mode) {
+ setPref("network.proxy.type", PROXY_TYPES_MAP.get(param.Mode));
+ }
+
+ if (param.AutoConfigURL) {
+ setPref("network.proxy.autoconfig_url", param.AutoConfigURL.href);
+ }
+
+ if (param.UseProxyForDNS !== undefined) {
+ setPref("network.proxy.socks_remote_dns", param.UseProxyForDNS);
+ }
+
+ if (param.AutoLogin !== undefined) {
+ setPref("signon.autologin.proxy", param.AutoLogin);
+ }
+
+ if (param.SOCKSVersion !== undefined) {
+ if (param.SOCKSVersion != 4 && param.SOCKSVersion != 5) {
+ lazy.log.error("Invalid SOCKS version");
+ } else {
+ setPref("network.proxy.socks_version", param.SOCKSVersion);
+ }
+ }
+
+ if (param.Passthrough !== undefined) {
+ setPref("network.proxy.no_proxies_on", param.Passthrough);
+ }
+
+ if (param.UseHTTPProxyForAllProtocols !== undefined) {
+ setPref(
+ "network.proxy.share_proxy_settings",
+ param.UseHTTPProxyForAllProtocols
+ );
+ }
+
+ if (param.FTPProxy) {
+ lazy.log.warn("FTPProxy support was removed in bug 1574475");
+ }
+
+ function setProxyHostAndPort(type, address) {
+ let url;
+ try {
+ // Prepend https just so we can use the URL parser
+ // instead of parsing manually.
+ url = new URL(`https://${address}`);
+ } catch (e) {
+ lazy.log.error(`Invalid address for ${type} proxy: ${address}`);
+ return;
+ }
+
+ setPref(`network.proxy.${type}`, url.hostname);
+ if (url.port) {
+ setPref(`network.proxy.${type}_port`, Number(url.port));
+ }
+ }
+
+ if (param.HTTPProxy) {
+ setProxyHostAndPort("http", param.HTTPProxy);
+
+ // network.proxy.share_proxy_settings is a UI feature, not handled by the
+ // network code. That pref only controls if the checkbox is checked, and
+ // then we must manually set the other values.
+ if (param.UseHTTPProxyForAllProtocols) {
+ param.SSLProxy = param.SOCKSProxy = param.HTTPProxy;
+ }
+ }
+
+ if (param.SSLProxy) {
+ setProxyHostAndPort("ssl", param.SSLProxy);
+ }
+
+ if (param.SOCKSProxy) {
+ setProxyHostAndPort("socks", param.SOCKSProxy);
+ }
+ },
+};
diff --git a/comm/mail/components/enterprisepolicies/helpers/moz.build b/comm/mail/components/enterprisepolicies/helpers/moz.build
new file mode 100644
index 0000000000..5f07c95153
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/helpers/moz.build
@@ -0,0 +1,12 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Thunderbird", "OS Integration")
+
+EXTRA_JS_MODULES.policies += [
+ "ProxyPolicies.sys.mjs",
+]
diff --git a/comm/mail/components/enterprisepolicies/jar.mn b/comm/mail/components/enterprisepolicies/jar.mn
new file mode 100644
index 0000000000..b344553811
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/jar.mn
@@ -0,0 +1,10 @@
+# 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/policies/aboutPolicies.xhtml (content/aboutPolicies.xhtml)
+ content/messenger/policies/aboutPolicies.js (content/aboutPolicies.js)
+ content/messenger/policies/policies-active.svg (content/policies-active.svg)
+ content/messenger/policies/policies-documentation.svg (content/policies-documentation.svg)
+ content/messenger/policies/policies-error.svg (content/policies-error.svg)
diff --git a/comm/mail/components/enterprisepolicies/moz.build b/comm/mail/components/enterprisepolicies/moz.build
new file mode 100644
index 0000000000..ac89d4fc17
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/moz.build
@@ -0,0 +1,23 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Thunderbird", "OS Integration")
+
+DIRS += [
+ "helpers",
+ "schemas",
+]
+
+TEST_DIRS += ["tests"]
+
+EXTRA_JS_MODULES.policies += [
+ "Policies.sys.mjs",
+]
+
+JAR_MANIFESTS += [
+ "jar.mn",
+]
diff --git a/comm/mail/components/enterprisepolicies/schemas/configuration.json b/comm/mail/components/enterprisepolicies/schemas/configuration.json
new file mode 100644
index 0000000000..8d3e9e43c2
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/schemas/configuration.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "type": "object",
+ "properties": {
+ "policies": {
+ "$ref": "policies.json"
+ }
+ },
+ "required": ["policies"]
+}
diff --git a/comm/mail/components/enterprisepolicies/schemas/moz.build b/comm/mail/components/enterprisepolicies/schemas/moz.build
new file mode 100644
index 0000000000..658f0b1ed7
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/schemas/moz.build
@@ -0,0 +1,12 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Thunderbird", "OS Integration")
+
+EXTRA_PP_JS_MODULES.policies += [
+ "schema.sys.mjs",
+]
diff --git a/comm/mail/components/enterprisepolicies/schemas/policies-schema.json b/comm/mail/components/enterprisepolicies/schemas/policies-schema.json
new file mode 100644
index 0000000000..c49aa5ac16
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/schemas/policies-schema.json
@@ -0,0 +1,634 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "type": "object",
+ "properties": {
+ "3rdparty": {
+ "type": "object",
+ "properties": {
+ "Extensions": {
+ "type": "object",
+ "patternProperties": {
+ "^.*$": {
+ "type": "JSON"
+ }
+ }
+ }
+ }
+ },
+
+ "AppAutoUpdate": {
+ "type": "boolean"
+ },
+
+ "AppUpdatePin": {
+ "type": "string"
+ },
+
+ "AppUpdateURL": {
+ "type": "URL"
+ },
+
+ "Authentication": {
+ "type": "object",
+ "properties": {
+ "SPNEGO": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Delegated": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "NTLM": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "AllowNonFQDN": {
+ "type": "object",
+ "properties": {
+ "SPNEGO": {
+ "type": "boolean"
+ },
+
+ "NTLM": {
+ "type": "boolean"
+ }
+ }
+ },
+ "AllowProxies": {
+ "type": "object",
+ "properties": {
+ "SPNEGO": {
+ "type": "boolean"
+ },
+
+ "NTLM": {
+ "type": "boolean"
+ }
+ }
+ },
+ "Locked": {
+ "type": "boolean"
+ },
+ "PrivateBrowsing": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "BackgroundAppUpdate": {
+ "type": "boolean"
+ },
+
+ "BlockAboutAddons": {
+ "type": "boolean"
+ },
+
+ "BlockAboutConfig": {
+ "type": "boolean"
+ },
+
+ "BlockAboutProfiles": {
+ "type": "boolean"
+ },
+
+ "BlockAboutSupport": {
+ "type": "boolean"
+ },
+
+ "CaptivePortal": {
+ "type": "boolean"
+ },
+
+ "Certificates": {
+ "type": "object",
+ "properties": {
+ "ImportEnterpriseRoots": {
+ "type": "boolean"
+ },
+ "Install": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+
+ "Cookies": {
+ "type": "object",
+ "properties": {
+ "Allow": {
+ "type": "array",
+ "strict": false,
+ "items": {
+ "type": "origin"
+ }
+ },
+
+ "Block": {
+ "type": "array",
+ "strict": false,
+ "items": {
+ "type": "origin"
+ }
+ },
+
+ "Default": {
+ "type": "boolean"
+ },
+
+ "AcceptThirdParty": {
+ "type": "string",
+ "enum": ["always", "never", "from-visited"]
+ },
+
+ "ExpireAtSessionEnd": {
+ "type": "boolean"
+ },
+
+ "Locked": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "DefaultDownloadDirectory": {
+ "type": "string"
+ },
+
+ "DisableAppUpdate": {
+ "type": "boolean"
+ },
+
+ "DisableBuiltinPDFViewer": {
+ "type": "boolean"
+ },
+
+ "DisabledCiphers": {
+ "type": "object",
+ "properties": {
+ "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_DHE_RSA_WITH_AES_128_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_DHE_RSA_WITH_AES_256_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_AES_128_GCM_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_AES_256_GCM_SHA384": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_AES_128_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_AES_256_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_3DES_EDE_CBC_SHA": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "DisableDeveloperTools": {
+ "type": "boolean"
+ },
+
+ "DisableMasterPasswordCreation": {
+ "type": "boolean"
+ },
+
+ "DisablePasswordReveal": {
+ "type": "boolean"
+ },
+
+ "DisableSafeMode": {
+ "type": "boolean"
+ },
+
+ "DisableSecurityBypass": {
+ "type": "object",
+ "properties": {
+ "InvalidCertificate": {
+ "type": "boolean"
+ },
+
+ "SafeBrowsing": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "DisableSystemAddonUpdate": {
+ "type": "boolean"
+ },
+
+ "DisableTelemetry": {
+ "type": "boolean"
+ },
+
+ "DNSOverHTTPS": {
+ "type": "object",
+ "properties": {
+ "Enabled": {
+ "type": "boolean"
+ },
+ "ProviderURL": {
+ "type": "URLorEmpty"
+ },
+ "ExcludedDomains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Locked": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "DownloadDirectory": {
+ "type": "string"
+ },
+
+ "Extensions": {
+ "type": "object",
+ "properties": {
+ "Install": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Uninstall": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Locked": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+
+ "ExtensionSettings": {
+ "type": "object",
+ "properties": {
+ "*": {
+ "type": "object",
+ "properties": {
+ "installation_mode": {
+ "type": "string",
+ "enum": ["allowed", "blocked"]
+ },
+ "allowed_types": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["extension", "dictionary", "locale", "theme"]
+ }
+ },
+ "blocked_install_message": {
+ "type": "string"
+ },
+ "install_sources": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "restricted_domains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "patternProperties": {
+ "^.*$": {
+ "type": "object",
+ "properties": {
+ "installation_mode": {
+ "type": "string",
+ "enum": [
+ "allowed",
+ "blocked",
+ "force_installed",
+ "normal_installed"
+ ]
+ },
+ "install_url": {
+ "type": "string"
+ },
+ "blocked_install_message": {
+ "type": "string"
+ },
+ "updates_disabled": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ },
+
+ "ExtensionUpdate": {
+ "type": "boolean"
+ },
+
+ "Handlers": {
+ "type": "object",
+ "patternProperties": {
+ "^(mimeTypes|extensions|schemes)$": {
+ "type": "object",
+ "patternProperties": {
+ "^.*$": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["saveToDisk", "useHelperApp", "useSystemDefault"]
+ },
+ "ask": {
+ "type": "boolean"
+ },
+ "handlers": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "uriTemplate": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ "HardwareAcceleration": {
+ "type": "boolean"
+ },
+
+ "InstallAddonsPermission": {
+ "type": "object",
+ "properties": {
+ "Allow": {
+ "type": "array",
+ "strict": false,
+ "items": {
+ "type": "origin"
+ }
+ },
+ "Default": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "ManualAppUpdateOnly": {
+ "type": "boolean"
+ },
+
+ "NetworkPrediction": {
+ "type": "boolean"
+ },
+
+ "OfferToSaveLogins": {
+ "type": "boolean"
+ },
+
+ "OfferToSaveLoginsDefault": {
+ "type": "boolean"
+ },
+
+ "PasswordManagerEnabled": {
+ "type": "boolean"
+ },
+
+ "PDFjs": {
+ "type": "object",
+ "properties": {
+ "Enabled": {
+ "type": "boolean"
+ },
+ "EnablePermissions": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "Preferences": {
+ "type": "object",
+ "patternProperties": {
+ "^.*$": {
+ "type": ["number", "boolean", "string", "object"],
+ "properties": {
+ "Value": {
+ "type": ["number", "boolean", "string"]
+ },
+ "Status": {
+ "type": "string",
+ "enum": ["default", "locked", "user", "clear"]
+ }
+ }
+ }
+ }
+ },
+
+ "PrimaryPassword": {
+ "type": "boolean"
+ },
+
+ "PromptForDownloadLocation": {
+ "type": "boolean"
+ },
+
+ "Proxy": {
+ "type": "object",
+ "properties": {
+ "Mode": {
+ "type": "string",
+ "enum": ["none", "system", "manual", "autoDetect", "autoConfig"]
+ },
+
+ "Locked": {
+ "type": "boolean"
+ },
+
+ "AutoConfigURL": {
+ "type": "URLorEmpty"
+ },
+
+ "FTPProxy": {
+ "type": "string"
+ },
+
+ "HTTPProxy": {
+ "type": "string"
+ },
+
+ "SSLProxy": {
+ "type": "string"
+ },
+
+ "SOCKSProxy": {
+ "type": "string"
+ },
+
+ "SOCKSVersion": {
+ "type": "number",
+ "enum": [4, 5]
+ },
+
+ "UseHTTPProxyForAllProtocols": {
+ "type": "boolean"
+ },
+
+ "Passthrough": {
+ "type": "string"
+ },
+
+ "UseProxyForDNS": {
+ "type": "boolean"
+ },
+
+ "AutoLogin": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "RequestedLocales": {
+ "type": ["string", "array"],
+ "items": {
+ "type": "string"
+ }
+ },
+
+ "SearchEngines": {
+ "enterprise_only": true,
+
+ "type": "object",
+ "properties": {
+ "Add": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["Name", "URLTemplate"],
+
+ "properties": {
+ "Name": {
+ "type": "string"
+ },
+ "IconURL": {
+ "type": "URLorEmpty"
+ },
+ "Alias": {
+ "type": "string"
+ },
+ "Description": {
+ "type": "string"
+ },
+ "Encoding": {
+ "type": "string"
+ },
+ "Method": {
+ "type": "string",
+ "enum": ["GET", "POST"]
+ },
+ "URLTemplate": {
+ "type": "string"
+ },
+ "PostData": {
+ "type": "string"
+ },
+ "SuggestURLTemplate": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "Default": {
+ "type": "string"
+ },
+ "DefaultPrivate": {
+ "type": "string"
+ },
+ "PreventInstalls": {
+ "type": "boolean"
+ },
+ "Remove": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+
+ "SSLVersionMax": {
+ "type": "string",
+ "enum": ["tls1", "tls1.1", "tls1.2", "tls1.3"]
+ },
+
+ "SSLVersionMin": {
+ "type": "string",
+ "enum": ["tls1", "tls1.1", "tls1.2", "tls1.3"]
+ }
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/schemas/schema.sys.mjs b/comm/mail/components/enterprisepolicies/schemas/schema.sys.mjs
new file mode 100644
index 0000000000..671a8cfca1
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/schemas/schema.sys.mjs
@@ -0,0 +1,16 @@
+/* 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 initialSchema =
+#include policies-schema.json
+
+export let schema = initialSchema;
+
+export function modifySchemaForTests(customSchema) {
+ if (customSchema) {
+ schema = customSchema;
+ } else {
+ schema = initialSchema;
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser.ini b/comm/mail/components/enterprisepolicies/tests/browser/browser.ini
new file mode 100644
index 0000000000..9c709131a2
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser.ini
@@ -0,0 +1,32 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files =
+ policytest_v0.1.xpi
+ policytest_v0.2.xpi
+ extensionsettings.html
+
+[browser_policies_setAndLockPref_API.js]
+[browser_policy_app_auto_update.js]
+skip-if = os == 'win' && msix # Updater is disabled in MSIX builds
+[browser_policy_app_update.js]
+skip-if = os == 'win' && msix # Updater is disabled in MSIX builds
+[browser_policy_background_app_update.js]
+skip-if = os == 'win' && msix # Updater is disabled in MSIX builds
+[browser_policy_block_about.js]
+[browser_policy_cookie_settings.js]
+[browser_policy_disable_safemode.js]
+[browser_policy_disable_telemetry.js]
+[browser_policy_downloads.js]
+[browser_policy_extensions.js]
+[browser_policy_extensionsettings.js]
+[browser_policy_extensionsettings2.js]
+[browser_policy_handlers.js]
+[browser_policy_masterpassword.js]
+[browser_policy_passwordmanager.js]
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js
new file mode 100644
index 0000000000..0cad8e5aa3
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js
@@ -0,0 +1,179 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { Policies, setAndLockPref, PoliciesUtils } = ChromeUtils.importESModule(
+ "resource:///modules/policies/Policies.sys.mjs"
+);
+
+add_task(async function test_API_directly() {
+ await setupPolicyEngineWithJson("");
+ setAndLockPref("policies.test.boolPref", true);
+ checkLockedPref("policies.test.boolPref", true);
+
+ // Check that a previously-locked pref can be changed
+ // (it will be unlocked first).
+ setAndLockPref("policies.test.boolPref", false);
+ checkLockedPref("policies.test.boolPref", false);
+
+ setAndLockPref("policies.test.intPref", 2);
+ checkLockedPref("policies.test.intPref", 2);
+
+ setAndLockPref("policies.test.stringPref", "policies test");
+ checkLockedPref("policies.test.stringPref", "policies test");
+
+ PoliciesUtils.setDefaultPref(
+ "policies.test.lockedPref",
+ "policies test",
+ true
+ );
+ checkLockedPref("policies.test.lockedPref", "policies test");
+
+ // Test that user values do not override the prefs, and the get*Pref call
+ // still return the value set through setAndLockPref
+ Services.prefs.setBoolPref("policies.test.boolPref", true);
+ checkLockedPref("policies.test.boolPref", false);
+
+ Services.prefs.setIntPref("policies.test.intPref", 10);
+ checkLockedPref("policies.test.intPref", 2);
+
+ Services.prefs.setStringPref("policies.test.stringPref", "policies test");
+ checkLockedPref("policies.test.stringPref", "policies test");
+
+ try {
+ // Test that a non-integer value is correctly rejected, even though
+ // typeof(val) == "number"
+ setAndLockPref("policies.test.intPref", 1.5);
+ ok(false, "Integer value should be rejected");
+ } catch (ex) {
+ ok(true, "Integer value was rejected");
+ }
+});
+
+add_task(async function test_API_through_policies() {
+ // Ensure that the values received by the policies have the correct
+ // type to make sure things are properly working.
+
+ // Implement functions to handle the three simple policies
+ // that will be added to the schema.
+ Policies.bool_policy = {
+ onBeforeUIStartup(manager, param) {
+ setAndLockPref("policies.test2.boolPref", param);
+ },
+ };
+
+ Policies.int_policy = {
+ onBeforeUIStartup(manager, param) {
+ setAndLockPref("policies.test2.intPref", param);
+ },
+ };
+
+ Policies.string_policy = {
+ onBeforeUIStartup(manager, param) {
+ setAndLockPref("policies.test2.stringPref", param);
+ },
+ };
+
+ await setupPolicyEngineWithJson(
+ // policies.json
+ {
+ policies: {
+ bool_policy: true,
+ int_policy: 42,
+ string_policy: "policies test 2",
+ },
+ },
+
+ // custom schema
+ {
+ properties: {
+ bool_policy: {
+ type: "boolean",
+ },
+
+ int_policy: {
+ type: "integer",
+ },
+
+ string_policy: {
+ type: "string",
+ },
+ },
+ }
+ );
+
+ is(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Engine is active"
+ );
+
+ // The expected values come from config_setAndLockPref.json
+ checkLockedPref("policies.test2.boolPref", true);
+ checkLockedPref("policies.test2.intPref", 42);
+ checkLockedPref("policies.test2.stringPref", "policies test 2");
+
+ delete Policies.bool_policy;
+ delete Policies.int_policy;
+ delete Policies.string_policy;
+});
+
+add_task(async function test_pref_tracker() {
+ // Tests the test harness functionality that tracks usage of
+ // the setAndLockPref and setDefualtPref APIs.
+
+ let defaults = Services.prefs.getDefaultBranch("");
+
+ // Test prefs that had a default value and got changed to another
+ defaults.setIntPref("test1.pref1", 10);
+ defaults.setStringPref("test1.pref2", "test");
+
+ setAndLockPref("test1.pref1", 20);
+ PoliciesUtils.setDefaultPref("test1.pref2", "NEW VALUE");
+ setAndLockPref("test1.pref3", "NEW VALUE");
+ PoliciesUtils.setDefaultPref("test1.pref4", 20);
+
+ PoliciesPrefTracker.restoreDefaultValues();
+
+ is(
+ Services.prefs.getIntPref("test1.pref1"),
+ 10,
+ "Expected value for test1.pref1"
+ );
+ is(
+ Services.prefs.getStringPref("test1.pref2"),
+ "test",
+ "Expected value for test1.pref2"
+ );
+ is(
+ Services.prefs.prefIsLocked("test1.pref1"),
+ false,
+ "test1.pref1 got unlocked"
+ );
+ ok(
+ !Services.prefs.getStringPref("test1.pref3", undefined),
+ "test1.pref3 should have had its value unset"
+ );
+ is(
+ Services.prefs.getIntPref("test1.pref4", -1),
+ -1,
+ "test1.pref4 should have had its value unset"
+ );
+
+ // Test a pref that had a default value and a user value
+ defaults.setIntPref("test2.pref1", 10);
+ Services.prefs.setIntPref("test2.pref1", 20);
+
+ setAndLockPref("test2.pref1", 20);
+
+ PoliciesPrefTracker.restoreDefaultValues();
+
+ is(Services.prefs.getIntPref("test2.pref1"), 20, "Correct user value");
+ is(defaults.getIntPref("test2.pref1"), 10, "Correct default value");
+ is(
+ Services.prefs.prefIsLocked("test2.pref1"),
+ false,
+ "felipe pref is not locked"
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js
new file mode 100644
index 0000000000..433408642f
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+ChromeUtils.defineESModuleGetters(this, {
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+async function test_app_update_auto(expectedEnabled, expectedLocked) {
+ let actualEnabled = await UpdateUtils.getAppUpdateAutoEnabled();
+ is(
+ actualEnabled,
+ expectedEnabled,
+ `Actual auto update enabled setting should match the expected value of ${expectedEnabled}`
+ );
+
+ let actualLocked = UpdateUtils.appUpdateAutoSettingIsLocked();
+ is(
+ actualLocked,
+ expectedLocked,
+ `Auto update enabled setting ${
+ expectedLocked ? "should" : "should not"
+ } be locked`
+ );
+
+ let setSuccess = true;
+ try {
+ await UpdateUtils.setAppUpdateAutoEnabled(actualEnabled);
+ } catch (error) {
+ setSuccess = false;
+ }
+ is(
+ setSuccess,
+ !expectedLocked,
+ `Setting auto update ${expectedLocked ? "should" : "should not"} fail`
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ let prefsDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL == "about:preferences") {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ resolve(subject);
+ }
+ }, "chrome-document-loaded");
+ window.openPreferencesTab("paneGeneral", "updateApp");
+ });
+
+ await new Promise(resolve => setTimeout(resolve));
+
+ is(
+ prefsDocument.getElementById("updateSettingsContainer").hidden,
+ expectedLocked,
+ `When auto update ${
+ expectedLocked ? "is" : "isn't"
+ } locked, the corresponding preferences entry ${
+ expectedLocked ? "should" : "shouldn't"
+ } be hidden`
+ );
+
+ tabmail.closeTab(prefsTabMode.tabs[0]);
+}
+
+add_task(async function test_app_auto_update_policy() {
+ let originalUpdateAutoValue = await UpdateUtils.getAppUpdateAutoEnabled();
+ registerCleanupFunction(async () => {
+ await UpdateUtils.setAppUpdateAutoEnabled(originalUpdateAutoValue);
+ });
+
+ await UpdateUtils.setAppUpdateAutoEnabled(true);
+ await test_app_update_auto(true, false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ AppAutoUpdate: false,
+ },
+ });
+ await test_app_update_auto(false, true);
+
+ await setupPolicyEngineWithJson({});
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ await test_app_update_auto(false, false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ AppAutoUpdate: true,
+ },
+ });
+ await test_app_update_auto(true, true);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_update.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_update.js
new file mode 100644
index 0000000000..14a9c92bc5
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_update.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+ChromeUtils.defineESModuleGetters(this, {
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+var updateService = Cc["@mozilla.org/updates/update-service;1"].getService(
+ Ci.nsIApplicationUpdateService
+);
+
+// This test is intended to ensure that nsIUpdateService::canCheckForUpdates
+// is true before the "DisableAppUpdate" policy is applied. Testing that
+// nsIUpdateService::canCheckForUpdates is false after the "DisableAppUpdate"
+// policy is applied needs to occur in a different test since the policy does
+// not properly take effect unless it is applied during application startup.
+add_task(async function test_updates_pre_policy() {
+ // Turn off automatic update before we set app.update.disabledForTesting to
+ // false so that we don't cause an actual update.
+ let originalUpdateAutoValue = await UpdateUtils.getAppUpdateAutoEnabled();
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ registerCleanupFunction(async () => {
+ await UpdateUtils.setAppUpdateAutoEnabled(originalUpdateAutoValue);
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.update.disabledForTesting", false]],
+ });
+
+ is(
+ Services.policies.isAllowed("appUpdate"),
+ true,
+ "Since no policies have been set, appUpdate should be allowed by default"
+ );
+
+ is(
+ updateService.canCheckForUpdates,
+ true,
+ "Should be able to check for updates before any policies are in effect."
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js
new file mode 100644
index 0000000000..4c0369df3d
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+const PREF_NAME = "app.update.background.enabled";
+
+async function test_background_update_pref(expectedEnabled, expectedLocked) {
+ let actualEnabled = await UpdateUtils.readUpdateConfigSetting(PREF_NAME);
+ is(
+ actualEnabled,
+ expectedEnabled,
+ `Actual background update enabled setting should be ${expectedEnabled}`
+ );
+
+ let actualLocked = UpdateUtils.appUpdateSettingIsLocked(PREF_NAME);
+ is(
+ actualLocked,
+ expectedLocked,
+ `Background update enabled setting ${
+ expectedLocked ? "should" : "should not"
+ } be locked`
+ );
+
+ let setSuccess = true;
+ try {
+ await UpdateUtils.writeUpdateConfigSetting(PREF_NAME, actualEnabled);
+ } catch (error) {
+ setSuccess = false;
+ }
+ is(
+ setSuccess,
+ !expectedLocked,
+ `Setting background update pref ${
+ expectedLocked ? "should" : "should not"
+ } fail`
+ );
+
+ if (AppConstants.MOZ_UPDATER && AppConstants.MOZ_UPDATE_AGENT) {
+ let shouldShowUI =
+ !expectedLocked && UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED;
+ await BrowserTestUtils.withNewTab("about:preferences", browser => {
+ is(
+ browser.contentDocument.getElementById("backgroundUpdate").hidden,
+ !shouldShowUI,
+ `When background update ${
+ expectedLocked ? "is" : "isn't"
+ } locked, and per-installation prefs ${
+ UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED ? "are" : "aren't"
+ } supported, the corresponding preferences entry ${
+ shouldShowUI ? "shouldn't" : "should"
+ } be hidden`
+ );
+ });
+ } else {
+ // The backgroundUpdate element is #ifdef'ed out if MOZ_UPDATER and
+ // MOZ_UPDATE_AGENT are not both defined.
+ info(
+ "Warning: UI testing skipped because support for background update is " +
+ "not present"
+ );
+ }
+}
+
+add_task(async function test_background_app_update_policy() {
+ // Turn on the background update UI so we can test it.
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.update.background.scheduling.enabled", true]],
+ });
+
+ const origBackgroundUpdateVal = await UpdateUtils.readUpdateConfigSetting(
+ PREF_NAME
+ );
+ registerCleanupFunction(async () => {
+ await UpdateUtils.writeUpdateConfigSetting(
+ PREF_NAME,
+ origBackgroundUpdateVal
+ );
+ });
+
+ await UpdateUtils.writeUpdateConfigSetting(PREF_NAME, true);
+ await test_background_update_pref(true, false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ BackgroundAppUpdate: false,
+ },
+ });
+ await test_background_update_pref(false, true);
+
+ await setupPolicyEngineWithJson({});
+ await UpdateUtils.writeUpdateConfigSetting(PREF_NAME, false);
+ await test_background_update_pref(false, false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ BackgroundAppUpdate: true,
+ },
+ });
+ await test_background_update_pref(true, true);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_block_about.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_block_about.js
new file mode 100644
index 0000000000..4be9c7fee8
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_block_about.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+
+const ABOUT_CONTRACT = "@mozilla.org/network/protocol/about;1?what=";
+
+const policiesToTest = [
+ {
+ policies: {
+ BlockAboutAddons: true,
+ },
+ urls: ["about:addons"],
+ },
+
+ {
+ policies: {
+ BlockAboutConfig: true,
+ },
+ urls: ["about:config"],
+ },
+ {
+ policies: {
+ BlockAboutProfiles: true,
+ },
+ urls: ["about:profiles"],
+ },
+
+ {
+ policies: {
+ BlockAboutSupport: true,
+ },
+ urls: ["about:support"],
+ },
+
+ {
+ policies: {
+ DisableDeveloperTools: true,
+ },
+ urls: ["about:debugging", "about:devtools-toolbox"],
+ },
+ {
+ policies: {
+ DisableTelemetry: true,
+ },
+ urls: ["about:telemetry"],
+ },
+];
+
+add_task(async function testAboutTask() {
+ for (let policyToTest of policiesToTest) {
+ let policyJSON = { policies: {} };
+ policyJSON.policies = policyToTest.policies;
+ for (let url of policyToTest.urls) {
+ if (url.startsWith("about")) {
+ let feature = url.split(":")[1];
+ let aboutModule = Cc[ABOUT_CONTRACT + feature].getService(
+ Ci.nsIAboutModule
+ );
+ let chromeURL = aboutModule.getChromeURI(Services.io.newURI(url)).spec;
+ await testPageBlockedByPolicy(policyJSON, chromeURL);
+ }
+ await testPageBlockedByPolicy(policyJSON, url);
+ }
+ }
+});
+
+async function testPageBlockedByPolicy(policyJSON, page) {
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(policyJSON);
+
+ await withNewTab({ url: "about:blank" }, async browser => {
+ BrowserTestUtils.loadURIString(browser, page);
+ await BrowserTestUtils.browserLoaded(browser, false, page, true);
+ await SpecialPowers.spawn(browser, [page], async function (innerPage) {
+ ok(
+ content.document.documentURI.startsWith(
+ "about:neterror?e=blockedByPolicy"
+ ),
+ content.document.documentURI +
+ " should start with about:neterror?e=blockedByPolicy"
+ );
+ });
+ });
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js
new file mode 100644
index 0000000000..f43500d723
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js
@@ -0,0 +1,323 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { UrlClassifierTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlClassifierTestUtils.sys.mjs"
+);
+Services.cookies.QueryInterface(Ci.nsICookieService);
+
+function restore_prefs() {
+ // Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default"
+ Services.prefs.clearUserPref("network.cookie.sameSite.laxByDefault");
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+ Services.prefs.clearUserPref(
+ "network.cookieJarSettings.unblocked_for_testing"
+ );
+ Services.prefs.clearUserPref(
+ "network.cookie.rejectForeignWithExceptions.enabled"
+ );
+}
+
+registerCleanupFunction(restore_prefs);
+
+async function fake_profile_change() {
+ await new Promise(resolve => {
+ Services.obs.addObserver(function waitForDBClose() {
+ Services.obs.removeObserver(waitForDBClose, "cookie-db-closed");
+ resolve();
+ }, "cookie-db-closed");
+ Services.cookies
+ .QueryInterface(Ci.nsIObserver)
+ .observe(null, "profile-before-change", "shutdown-persist");
+ });
+ await new Promise(resolve => {
+ Services.obs.addObserver(function waitForDBOpen() {
+ Services.obs.removeObserver(waitForDBOpen, "cookie-db-read");
+ resolve();
+ }, "cookie-db-read");
+ Services.cookies
+ .QueryInterface(Ci.nsIObserver)
+ .observe(null, "profile-do-change", "");
+ });
+}
+
+async function test_cookie_settings({
+ cookiesEnabled,
+ thirdPartyCookiesEnabled,
+ cookieJarSettingsLocked,
+}) {
+ let firstPartyURI = NetUtil.newURI("https://example.com/");
+ let thirdPartyURI = NetUtil.newURI("https://example.org/");
+ let channel = NetUtil.newChannel({
+ uri: firstPartyURI,
+ loadUsingSystemPrincipal: true,
+ });
+ channel.QueryInterface(
+ Ci.nsIHttpChannelInternal
+ ).forceAllowThirdPartyCookie = true;
+ Services.cookies.removeAll();
+ Services.cookies.setCookieStringFromHttp(firstPartyURI, "key=value", channel);
+ Services.cookies.setCookieStringFromHttp(thirdPartyURI, "key=value", channel);
+
+ let expectedFirstPartyCookies = 1;
+ let expectedThirdPartyCookies = 1;
+ if (!cookiesEnabled) {
+ expectedFirstPartyCookies = 0;
+ }
+ if (!cookiesEnabled || !thirdPartyCookiesEnabled) {
+ expectedThirdPartyCookies = 0;
+ }
+ is(
+ Services.cookies.countCookiesFromHost(firstPartyURI.host),
+ expectedFirstPartyCookies,
+ "Number of first-party cookies should match expected"
+ );
+ is(
+ Services.cookies.countCookiesFromHost(thirdPartyURI.host),
+ expectedThirdPartyCookies,
+ "Number of third-party cookies should match expected"
+ );
+
+ // Add a cookie so we can check if it persists past the end of the session
+ // but, first remove existing cookies set by this host to put us in a known state
+ Services.cookies.removeAll();
+ Services.cookies.setCookieStringFromHttp(
+ firstPartyURI,
+ "key=value; max-age=1000",
+ channel
+ );
+
+ await fake_profile_change();
+
+ // Now check if the cookie persisted or not
+ let expectedCookieCount = 1;
+ if (!cookiesEnabled) {
+ expectedCookieCount = 0;
+ }
+ is(
+ Services.cookies.countCookiesFromHost(firstPartyURI.host),
+ expectedCookieCount,
+ "Number of cookies was not what expected after restarting session"
+ );
+
+ is(
+ Services.prefs.prefIsLocked("network.cookie.cookieBehavior"),
+ cookieJarSettingsLocked,
+ "Cookie behavior pref lock status should be what is expected"
+ );
+
+ window.openPreferencesTab("panePrivacy");
+ await BrowserTestUtils.browserLoaded(
+ window.preferencesTabType.tab.browser,
+ undefined,
+ url => url.startsWith("about:preferences")
+ );
+ let { contentDocument } = window.preferencesTabType.tab.browser;
+ await TestUtils.waitForCondition(() =>
+ contentDocument.getElementById("acceptCookies")
+ );
+ let expectControlsDisabled = !cookiesEnabled || cookieJarSettingsLocked;
+
+ for (let id of ["acceptCookies", "showCookiesButton"]) {
+ is(
+ contentDocument.getElementById(id).disabled,
+ cookieJarSettingsLocked,
+ `#${id} disabled status should match expected`
+ );
+ }
+ for (let id of ["acceptThirdPartyMenu"]) {
+ is(
+ contentDocument.getElementById(id).disabled,
+ expectControlsDisabled,
+ `#${id} disabled status should match expected`
+ );
+ }
+
+ is(
+ contentDocument.getElementById("cookieExceptions").disabled,
+ cookieJarSettingsLocked,
+ "#cookieExceptions disabled status should matched expected"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+}
+
+add_task(async function prepare_tracker_tables() {
+ await UrlClassifierTestUtils.addTestTrackers();
+});
+
+add_task(async function test_initial_state() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false);
+ await test_cookie_settings({
+ cookiesEnabled: true,
+ thirdPartyCookiesEnabled: true,
+ cookieJarSettingsLocked: false,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_undefined_unlocked() {
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 3);
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {},
+ },
+ });
+ is(
+ Services.prefs.getIntPref("network.cookie.cookieBehavior", undefined),
+ 3,
+ "An empty cookie policy should not have changed the cookieBehavior preference"
+ );
+ restore_prefs();
+});
+
+add_task(async function test_disabled() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Default: false,
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: false,
+ thirdPartyCookiesEnabled: true,
+ cookieJarSettingsLocked: false,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_third_party_disabled() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ AcceptThirdParty: "never",
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: true,
+ thirdPartyCookiesEnabled: false,
+ cookieJarSettingsLocked: false,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_disabled_and_third_party_disabled() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Default: false,
+ AcceptThirdParty: "never",
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: false,
+ thirdPartyCookiesEnabled: false,
+ cookieJarSettingsLocked: false,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_disabled_and_third_party_disabled_locked() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Default: false,
+ AcceptThirdParty: "never",
+ Locked: true,
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: false,
+ thirdPartyCookiesEnabled: false,
+ cookieJarSettingsLocked: true,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_undefined_locked() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Locked: true,
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: true,
+ thirdPartyCookiesEnabled: true,
+ cookieJarSettingsLocked: true,
+ });
+ restore_prefs();
+});
+
+add_task(async function prepare_tracker_tables() {
+ await UrlClassifierTestUtils.cleanupTestTrackers();
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js
new file mode 100644
index 0000000000..b69787422e
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const MASTER_PASSWORD = "omgsecret!";
+const mpToken = Cc["@mozilla.org/security/pk11tokendb;1"]
+ .getService(Ci.nsIPK11TokenDB)
+ .getInternalKeyToken();
+
+async function checkDeviceManager({ buttonIsDisabled }) {
+ let deviceManagerWindow = window.openDialog(
+ "chrome://pippki/content/device_manager.xhtml",
+ "",
+ ""
+ );
+ await BrowserTestUtils.waitForEvent(deviceManagerWindow, "load");
+
+ let tree = deviceManagerWindow.document.getElementById("device_tree");
+ ok(tree, "The device tree exists");
+
+ // Find and select the item related to the internal key token
+ for (let i = 0; i < tree.view.rowCount; i++) {
+ tree.view.selection.select(i);
+
+ try {
+ let selected_token = deviceManagerWindow.selected_slot.getToken();
+ if (selected_token.isInternalKeyToken) {
+ break;
+ }
+ } catch (e) {}
+ }
+
+ // Check to see if the button was updated correctly
+ let changePwButton =
+ deviceManagerWindow.document.getElementById("change_pw_button");
+ is(
+ changePwButton.getAttribute("disabled") == "true",
+ buttonIsDisabled,
+ "Change Password button is in the correct state: " + buttonIsDisabled
+ );
+
+ await BrowserTestUtils.closeWindow(deviceManagerWindow);
+}
+
+async function checkAboutPreferences({ checkboxIsDisabled }) {
+ await BrowserTestUtils.withNewTab(
+ "about:preferences#privacy",
+ async browser => {
+ is(
+ browser.contentDocument.getElementById("useMasterPassword").disabled,
+ checkboxIsDisabled,
+ "Primary Password checkbox is in the correct state: " +
+ checkboxIsDisabled
+ );
+ }
+ );
+}
+
+add_task(async function test_policy_disable_masterpassword() {
+ ok(!mpToken.hasPassword, "Starting the test with no password");
+
+ // No password and no policy: access to setting a primary password
+ // should be enabled.
+ await checkDeviceManager({ buttonIsDisabled: false });
+ await checkAboutPreferences({ checkboxIsDisabled: false });
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ DisableMasterPasswordCreation: true,
+ },
+ });
+
+ // With the `DisableMasterPasswordCreation: true` policy active, the
+ // UI entry points for creating a Primary Password should be disabled.
+ await checkDeviceManager({ buttonIsDisabled: true });
+ await checkAboutPreferences({ checkboxIsDisabled: true });
+
+ mpToken.changePassword("", MASTER_PASSWORD);
+ ok(mpToken.hasPassword, "Master password was set");
+
+ // If a Primary Password is already set, there's no point in disabling
+ // the
+ await checkDeviceManager({ buttonIsDisabled: false });
+ await checkAboutPreferences({ checkboxIsDisabled: false });
+
+ // Clean up
+ mpToken.changePassword(MASTER_PASSWORD, "");
+ ok(!mpToken.hasPassword, "Master password was cleaned up");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js
new file mode 100644
index 0000000000..3ce719b5f1
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_setup(async function () {
+ await setupPolicyEngineWithJson({
+ policies: {
+ DisableSafeMode: true,
+ },
+ });
+});
+
+add_task(async function test_help_menu() {
+ buildHelpMenu();
+ let safeModeMenu = document.getElementById("helpTroubleshootMode");
+ is(
+ safeModeMenu.getAttribute("disabled"),
+ "true",
+ "The `Restart with Add-ons Disabled...` item should be disabled"
+ );
+ let safeModeAppMenu = document.getElementById("appmenu_troubleshootMode");
+ is(
+ safeModeAppMenu.getAttribute("disabled"),
+ "true",
+ "The `Restart with Add-ons Disabled...` appmenu item should be disabled"
+ );
+});
+
+add_task(async function test_safemode_from_about_support() {
+ await withNewTab({ url: "about:support" }, browser => {
+ let button = content.document.getElementById("restart-in-safe-mode-button");
+ is(
+ button.getAttribute("disabled"),
+ "true",
+ "The `Restart with Add-ons Disabled...` button should be disabled"
+ );
+ });
+});
+
+add_task(async function test_safemode_from_about_profiles() {
+ await withNewTab({ url: "about:profiles" }, browser => {
+ let button = content.document.getElementById("restart-in-safe-mode-button");
+ is(
+ button.getAttribute("disabled"),
+ "true",
+ "The `Restart with Add-ons Disabled...` button should be disabled"
+ );
+ });
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js
new file mode 100644
index 0000000000..600be47763
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_policy_disable_telemetry() {
+ const { TelemetryReportingPolicy } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryReportingPolicy.sys.mjs"
+ );
+
+ ok(TelemetryReportingPolicy, "TelemetryReportingPolicy exists");
+ is(TelemetryReportingPolicy.canUpload(), true, "Telemetry is enabled");
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ DisableTelemetry: true,
+ },
+ });
+
+ is(TelemetryReportingPolicy.canUpload(), false, "Telemetry is disabled");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_downloads.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_downloads.js
new file mode 100644
index 0000000000..520fcc67aa
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_downloads.js
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+add_task(async function test_defaultdownload() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ DefaultDownloadDirectory: "${home}/Downloads",
+ PromptForDownloadLocation: false,
+ },
+ });
+
+ window.openPreferencesTab("paneGeneral");
+ await BrowserTestUtils.browserLoaded(
+ window.preferencesTabType.tab.browser,
+ undefined,
+ url => url.startsWith("about:preferences")
+ );
+ let { contentDocument } = window.preferencesTabType.tab.browser;
+ await TestUtils.waitForCondition(() =>
+ contentDocument.getElementById("alwaysAsk")
+ );
+ await new Promise(resolve =>
+ window.preferencesTabType.tab.browser.contentWindow.setTimeout(resolve)
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "alwaysAsk"
+ ).disabled,
+ true,
+ "alwaysAsk should be disabled."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "saveTo"
+ ).selected,
+ true,
+ "saveTo should be selected."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "saveTo"
+ ).disabled,
+ true,
+ "saveTo should be disabled."
+ );
+ let home = FileUtils.getFile("Home", []).path;
+ is(
+ Services.prefs.getStringPref("browser.download.dir"),
+ home + "/Downloads",
+ "browser.download.dir should be ${home}/Downloads."
+ );
+ is(
+ Services.prefs.getBoolPref("browser.download.useDownloadDir"),
+ true,
+ "browser.download.useDownloadDir should be true."
+ );
+ is(
+ Services.prefs.prefIsLocked("browser.download.useDownloadDir"),
+ true,
+ "browser.download.useDownloadDir should be locked."
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+});
+
+add_task(async function test_download() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ DownloadDirectory: "${home}/Documents",
+ },
+ });
+
+ window.openPreferencesTab("paneGeneral");
+ await BrowserTestUtils.browserLoaded(
+ window.preferencesTabType.tab.browser,
+ undefined,
+ url => url.startsWith("about:preferences")
+ );
+ let { contentDocument } = window.preferencesTabType.tab.browser;
+ await TestUtils.waitForCondition(() =>
+ contentDocument.getElementById("alwaysAsk")
+ );
+ await new Promise(resolve =>
+ window.preferencesTabType.tab.browser.contentWindow.setTimeout(resolve)
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "alwaysAsk"
+ ).disabled,
+ true,
+ "alwaysAsk should be disabled."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "saveTo"
+ ).selected,
+ true,
+ "saveTo should be selected."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "saveTo"
+ ).disabled,
+ true,
+ "saveTo should be disabled."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "downloadFolder"
+ ).disabled,
+ true,
+ "downloadFolder should be disabled."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "chooseFolder"
+ ).disabled,
+ true,
+ "chooseFolder should be disabled."
+ );
+ let home = FileUtils.getFile("Home", []).path;
+ is(
+ Services.prefs.getStringPref("browser.download.dir"),
+ home + "/Documents",
+ "browser.download.dir should be ${home}/Documents."
+ );
+ is(
+ Services.prefs.getBoolPref("browser.download.useDownloadDir"),
+ true,
+ "browser.download.useDownloadDir should be true."
+ );
+ is(
+ Services.prefs.prefIsLocked("browser.download.useDownloadDir"),
+ true,
+ "browser.download.useDownloadDir should be locked."
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensions.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensions.js
new file mode 100644
index 0000000000..062fa2b714
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensions.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const ADDON_ID = "policytest@mozilla.com";
+const BASE_URL =
+ "http://mochi.test:8888/browser/comm/mail/components/enterprisepolicies/tests/browser";
+
+async function isExtensionLocked(win, addonID) {
+ let addonCard = await BrowserTestUtils.waitForCondition(() => {
+ return win.document.querySelector(`addon-card[addon-id="${addonID}"]`);
+ }, `Get addon-card for "${addonID}"`);
+ let disableBtn = addonCard.querySelector('[action="toggle-disabled"]');
+ let removeBtn = addonCard.querySelector('panel-item[action="remove"]');
+ ok(removeBtn.disabled, "Remove button should be disabled");
+ ok(disableBtn.hidden, "Disable button should be hidden");
+}
+
+add_task(async function test_addon_install() {
+ let installPromise = waitForAddonInstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Install: [`${BASE_URL}/policytest_v0.1.xpi`],
+ Locked: [ADDON_ID],
+ },
+ },
+ });
+ await installPromise;
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ isnot(addon, null, "Addon not installed.");
+ is(addon.version, "0.1", "Addon version is correct");
+
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ { source: "enterprise-policy" },
+ "Got the expected addon.installTelemetryInfo"
+ );
+});
+
+add_task(async function test_addon_locked() {
+ let tabmail = document.getElementById("tabmail");
+ let index = tabmail.tabInfo.length;
+ await window.openAddonsMgr("addons://list/extension");
+ let tab = tabmail.tabInfo[index];
+ let browser = tab.browser;
+
+ await isExtensionLocked(browser.contentWindow, ADDON_ID);
+
+ tabmail.closeTab(tab);
+});
+
+add_task(async function test_addon_reinstall() {
+ // Test that uninstalling and reinstalling the same addon ID works as expected.
+ // This can be used to update an addon.
+
+ let uninstallPromise = waitForAddonUninstall(ADDON_ID);
+ let installPromise = waitForAddonInstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Uninstall: [ADDON_ID],
+ Install: [`${BASE_URL}/policytest_v0.2.xpi`],
+ },
+ },
+ });
+
+ // Older version was uninstalled
+ await uninstallPromise;
+
+ // New version was installed
+ await installPromise;
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ isnot(
+ addon,
+ null,
+ "Addon still exists because the policy was used to update it."
+ );
+ is(addon.version, "0.2", "New version is correct");
+});
+
+add_task(async function test_addon_uninstall() {
+ EnterprisePolicyTesting.resetRunOnceState();
+
+ let uninstallPromise = waitForAddonUninstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Uninstall: [ADDON_ID],
+ },
+ },
+ });
+ await uninstallPromise;
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ is(addon, null, "Addon should be uninstalled.");
+});
+
+add_task(async function test_addon_download_failure() {
+ // Test that if the download fails, the runOnce pref
+ // is cleared so that the download will happen again.
+
+ let installPromise = waitForAddonInstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Install: [`${BASE_URL}/policytest_invalid.xpi`],
+ },
+ },
+ });
+
+ await installPromise;
+ is(
+ Services.prefs.prefHasUserValue(
+ "browser.policies.runOncePerModification.extensionsInstall"
+ ),
+ false,
+ "runOnce pref should be unset"
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js
new file mode 100644
index 0000000000..7fced9c1ad
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js
@@ -0,0 +1,261 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+
+const BASE_URL =
+ "http://mochi.test:8888/browser/comm/mail/components/enterprisepolicies/tests/browser/";
+
+async function openTab(url) {
+ let tab = window.openContentTab(url, null, null);
+ if (
+ tab.browser.webProgress?.isLoadingDocument ||
+ tab.browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ }
+ return tab;
+}
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ * The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ * Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name) {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(name);
+ if (!notification) {
+ return;
+ }
+
+ ok(notification, `${name} notification shown`);
+ ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ resolve(PopupNotifications.panel.firstElementChild);
+ }
+
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function dismissNotification(win = window) {
+ return new Promise(resolve => {
+ function popuphidden() {
+ PopupNotifications.panel.removeEventListener("popuphidden", popuphidden);
+ resolve();
+ }
+ PopupNotifications.panel.addEventListener("popuphidden", popuphidden);
+ executeSoon(function () {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, win);
+ });
+ });
+}
+
+add_setup(async function setupTestEnvironment() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+});
+
+add_task(async function test_install_source_blocked_link() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://blocks.other.install.sources/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-policy-blocked"
+ );
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_installtrigger() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://blocks.other.install.sources/*"],
+ blocked_install_message: "blocked_install_message",
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-policy-blocked"
+ );
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest_installtrigger").click();
+ });
+ let popup = await popupPromise;
+ let description = popup.querySelector(".popup-notification-description");
+ ok(
+ description.textContent.endsWith("blocked_install_message"),
+ "Custom install message present"
+ );
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_otherdomain() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-policy-blocked"
+ );
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest_otherdomain").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_direct() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://blocks.other.install.sources/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-policy-blocked"
+ );
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ baseUrl: BASE_URL }],
+ async function ({ baseUrl }) {
+ content.document.location.href = baseUrl + "policytest_v0.1.xpi";
+ }
+ );
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_link() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_installtrigger() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest_installtrigger").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_otherdomain() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*", "http://example.org/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest_otherdomain").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_direct() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ baseUrl: BASE_URL }],
+ async function ({ baseUrl }) {
+ content.document.location.href = baseUrl + "policytest_v0.1.xpi";
+ }
+ );
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js
new file mode 100644
index 0000000000..e335d70fe0
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const ADDON_ID = "policytest@mozilla.com";
+const BASE_URL =
+ "http://mochi.test:8888/browser/comm/mail/components/enterprisepolicies/tests/browser";
+
+async function isExtensionLockedAndUpdateDisabled(win, addonID) {
+ let addonCard = await BrowserTestUtils.waitForCondition(() => {
+ return win.document.querySelector(`addon-card[addon-id="${addonID}"]`);
+ }, `Get addon-card for "${addonID}"`);
+ let disableBtn = addonCard.querySelector('[action="toggle-disabled"]');
+ let removeBtn = addonCard.querySelector('panel-item[action="remove"]');
+ ok(removeBtn.disabled, "Remove button should be disabled");
+ ok(disableBtn.hidden, "Disable button should be hidden");
+ let updateRow = addonCard.querySelector(".addon-detail-row-updates");
+ is(updateRow.hidden, true, "Update row should be hidden");
+}
+
+add_task(async function test_addon_install() {
+ let installPromise = waitForAddonInstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "policytest@mozilla.com": {
+ install_url: `${BASE_URL}/policytest_v0.1.xpi`,
+ installation_mode: "force_installed",
+ updates_disabled: true,
+ },
+ },
+ },
+ });
+ await installPromise;
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ isnot(addon, null, "Addon not installed.");
+ is(addon.version, "0.1", "Addon version is correct");
+
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ { source: "enterprise-policy" },
+ "Got the expected addon.installTelemetryInfo"
+ );
+});
+
+add_task(async function test_addon_locked_update_disabled() {
+ let tabmail = document.getElementById("tabmail");
+ let index = tabmail.tabInfo.length;
+ await window.openAddonsMgr("addons://detail/" + encodeURIComponent(ADDON_ID));
+ let tab = tabmail.tabInfo[index];
+ let browser = tab.browser;
+ let win = browser.contentWindow;
+
+ await isExtensionLockedAndUpdateDisabled(win, ADDON_ID);
+
+ tabmail.closeTab(tab);
+});
+
+add_task(async function test_addon_uninstall() {
+ let uninstallPromise = waitForAddonUninstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "policytest@mozilla.com": {
+ installation_mode: "blocked",
+ },
+ },
+ },
+ });
+ await uninstallPromise;
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ is(addon, null, "Addon should be uninstalled.");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_handlers.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_handlers.js
new file mode 100644
index 0000000000..6242625756
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_handlers.js
@@ -0,0 +1,183 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const gMIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+const gExternalProtocolService = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+].getService(Ci.nsIExternalProtocolService);
+
+const gHandlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+].getService(Ci.nsIHandlerService);
+
+// This seems odd, but for test purposes, this just has to be a file that we know exists,
+// and by using this file, we don't have to worry about different platforms.
+let exeFile = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+
+add_task(async function test_valid_handlers() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Handlers: {
+ mimeTypes: {
+ "application/marimba": {
+ action: "useHelperApp",
+ ask: true,
+ handlers: [
+ {
+ name: "Launch",
+ path: exeFile.path,
+ },
+ ],
+ },
+ },
+ schemes: {
+ fake_scheme: {
+ action: "useHelperApp",
+ ask: false,
+ handlers: [
+ {
+ name: "Name",
+ uriTemplate: "https://www.example.org/?%s",
+ },
+ ],
+ },
+ },
+ extensions: {
+ txt: {
+ action: "saveToDisk",
+ ask: false,
+ },
+ },
+ },
+ },
+ });
+
+ let handlerInfo = gMIMEService.getFromTypeAndExtension(
+ "application/marimba",
+ ""
+ );
+ is(handlerInfo.preferredAction, handlerInfo.useHelperApp);
+ is(handlerInfo.alwaysAskBeforeHandling, true);
+ is(handlerInfo.preferredApplicationHandler.name, "Launch");
+ is(handlerInfo.preferredApplicationHandler.executable.path, exeFile.path);
+
+ handlerInfo.preferredApplicationHandler = null;
+ gHandlerService.store(handlerInfo);
+
+ handlerInfo = handlerInfo = gMIMEService.getFromTypeAndExtension(
+ "application/marimba",
+ ""
+ );
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+
+ handlerInfo = gExternalProtocolService.getProtocolHandlerInfo("fake_scheme");
+ is(handlerInfo.preferredAction, handlerInfo.useHelperApp);
+ is(handlerInfo.alwaysAskBeforeHandling, false);
+ is(handlerInfo.preferredApplicationHandler.name, "Name");
+ is(
+ handlerInfo.preferredApplicationHandler.uriTemplate,
+ "https://www.example.org/?%s"
+ );
+
+ handlerInfo.preferredApplicationHandler = null;
+ gHandlerService.store(handlerInfo);
+
+ handlerInfo = gExternalProtocolService.getProtocolHandlerInfo("fake_scheme");
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+
+ handlerInfo = gMIMEService.getFromTypeAndExtension("", "txt");
+ is(handlerInfo.preferredAction, handlerInfo.saveToDisk);
+ is(handlerInfo.alwaysAskBeforeHandling, false);
+
+ handlerInfo.preferredApplicationHandler = null;
+ gHandlerService.store(handlerInfo);
+ handlerInfo = gMIMEService.getFromTypeAndExtension("", "txt");
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+});
+
+add_task(async function test_no_handler() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Handlers: {
+ schemes: {
+ no_handler: {
+ action: "useHelperApp",
+ },
+ },
+ },
+ },
+ });
+
+ let handlerInfo =
+ gExternalProtocolService.getProtocolHandlerInfo("no_handler");
+ is(handlerInfo.preferredAction, handlerInfo.alwaysAsk);
+ is(handlerInfo.alwaysAskBeforeHandling, true);
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+});
+
+add_task(async function test_bad_web_handler1() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Handlers: {
+ schemes: {
+ bas_web_handler1: {
+ action: "useHelperApp",
+ handlers: [
+ {
+ name: "Name",
+ uriTemplate: "https://www.example.org/?%s",
+ },
+ ],
+ },
+ },
+ },
+ },
+ });
+
+ let handlerInfo =
+ gExternalProtocolService.getProtocolHandlerInfo("bad_web_handler1");
+ is(handlerInfo.preferredAction, handlerInfo.alwaysAsk);
+ is(handlerInfo.alwaysAskBeforeHandling, true);
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+});
+
+add_task(async function test_bad_web_handler2() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Handlers: {
+ schemes: {
+ bas_web_handler1: {
+ action: "useHelperApp",
+ handlers: [
+ {
+ name: "Name",
+ uriTemplate: "https://www.example.org/",
+ },
+ ],
+ },
+ },
+ },
+ },
+ });
+
+ let handlerInfo =
+ gExternalProtocolService.getProtocolHandlerInfo("bad_web_handler1");
+ is(handlerInfo.preferredAction, handlerInfo.alwaysAsk);
+ is(handlerInfo.alwaysAskBeforeHandling, true);
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js
new file mode 100644
index 0000000000..8320897341
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { LoginTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/LoginTestUtils.sys.mjs"
+);
+
+// Test that once a password is set, you can't unset it
+add_task(async function test_policy_masterpassword_set() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ PrimaryPassword: true,
+ },
+ });
+
+ LoginTestUtils.primaryPassword.enable();
+
+ window.openPreferencesTab("panePrivacy");
+ await BrowserTestUtils.browserLoaded(
+ window.preferencesTabType.tab.browser,
+ undefined,
+ url => url.startsWith("about:preferences")
+ );
+ let { contentDocument } = window.preferencesTabType.tab.browser;
+ await TestUtils.waitForCondition(() =>
+ contentDocument.getElementById("useMasterPassword")
+ );
+
+ is(
+ contentDocument.getElementById("useMasterPassword").disabled,
+ true,
+ "Primary Password checkbox should be disabled"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+
+ LoginTestUtils.primaryPassword.disable();
+});
+
+// Test that password can't be removed in changemp.xhtml
+add_task(async function test_policy_nochangemp() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ PrimaryPassword: true,
+ },
+ });
+
+ LoginTestUtils.primaryPassword.enable();
+
+ let changeMPWindow = window.openDialog(
+ "chrome://mozapps/content/preferences/changemp.xhtml",
+ "",
+ ""
+ );
+ await BrowserTestUtils.waitForEvent(changeMPWindow, "load");
+
+ is(
+ changeMPWindow.document.getElementById("admin").hidden,
+ true,
+ "Admin message should not be visible because there is a password."
+ );
+
+ changeMPWindow.document.getElementById("oldpw").value =
+ LoginTestUtils.primaryPassword.masterPassword;
+
+ is(
+ changeMPWindow.document.getElementById("changemp").getButton("accept")
+ .disabled,
+ true,
+ "OK button should not be enabled if there is an old password."
+ );
+
+ await BrowserTestUtils.closeWindow(changeMPWindow);
+
+ LoginTestUtils.primaryPassword.disable();
+});
+
+// Test that admin message shows
+add_task(async function test_policy_admin() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ PrimaryPassword: true,
+ },
+ });
+
+ let changeMPWindow = window.openDialog(
+ "chrome://mozapps/content/preferences/changemp.xhtml",
+ "",
+ ""
+ );
+ await BrowserTestUtils.waitForEvent(changeMPWindow, "load");
+
+ is(
+ changeMPWindow.document.getElementById("admin").hidden,
+ false,
+ true,
+ "Admin message should not be hidden because there is not a password."
+ );
+
+ await BrowserTestUtils.closeWindow(changeMPWindow);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js
new file mode 100644
index 0000000000..1f74d46754
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_pwmanagerbutton() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ PasswordManagerEnabled: false,
+ },
+ });
+
+ window.openPreferencesTab("panePrivacy");
+ await BrowserTestUtils.browserLoaded(window.preferencesTabType.tab.browser);
+ await new Promise(resolve => setTimeout(resolve));
+
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "showPasswords"
+ ).disabled,
+ true,
+ "showPasswords should be disabled."
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser.ini b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser.ini
new file mode 100644
index 0000000000..73162633db
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ app.update.disabledForTesting=false
+ browser.policies.alternatePath='<test-root>/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json'
+subsuite = thunderbird
+support-files =
+ config_disable_app_update.json
+skip-if = os == 'win' && msix # Updater is disabled in MSIX builds
+
+[browser_policy_disable_app_update.js]
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js
new file mode 100644
index 0000000000..28dcd780d1
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+var updateService = Cc["@mozilla.org/updates/update-service;1"].getService(
+ Ci.nsIApplicationUpdateService
+);
+
+add_task(async function test_updates_post_policy() {
+ is(
+ Services.policies.isAllowed("appUpdate"),
+ false,
+ "appUpdate should be disabled by policy."
+ );
+
+ is(
+ updateService.canCheckForUpdates,
+ false,
+ "Should not be able to check for updates with DisableAppUpdate enabled."
+ );
+});
+
+add_task(async function test_update_preferences_ui() {
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ let prefsDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL == "about:preferences") {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ resolve(subject);
+ }
+ }, "chrome-document-loaded");
+ window.openPreferencesTab("paneGeneral", "updateApp");
+ });
+
+ await new Promise(resolve => setTimeout(resolve));
+
+ let setting = prefsDocument.getElementById("updateSettingsContainer");
+ is(
+ setting.hidden,
+ true,
+ "Update choices should be disabled when app update is locked by policy"
+ );
+
+ tabmail.closeTab(prefsTabMode.tabs[0]);
+});
+
+add_task(async function test_update_about_ui() {
+ let aboutDialog = await waitForAboutDialog();
+ let panelId = "policyDisabled";
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ aboutDialog.gAppUpdater.selectedPanel &&
+ aboutDialog.gAppUpdater.selectedPanel.id == panelId,
+ 'Waiting for expected panel ID - expected "' + panelId + '"'
+ );
+ is(
+ aboutDialog.gAppUpdater.selectedPanel.id,
+ panelId,
+ "The About Dialog panel Id should equal " + panelId
+ );
+
+ // Make sure that we still remain on the "disabled by policy" panel after
+ // `AppUpdater.stop()` is called.
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ is(
+ aboutDialog.gAppUpdater.selectedPanel.id,
+ panelId,
+ "The About Dialog panel Id should still equal " + panelId
+ );
+
+ aboutDialog.close();
+});
+
+/**
+ * Waits for the About Dialog to load.
+ *
+ * @returns A promise that returns the domWindow for the About Dialog and
+ * resolves when the About Dialog loads.
+ */
+function waitForAboutDialog() {
+ return new Promise(resolve => {
+ var listener = {
+ onOpenWindow: aAppWindow => {
+ Services.wm.removeListener(listener);
+
+ async function aboutDialogOnLoad() {
+ domwindow.removeEventListener("load", aboutDialogOnLoad, true);
+ let chromeURI = "chrome://messenger/content/aboutDialog.xhtml";
+ is(
+ domwindow.document.location.href,
+ chromeURI,
+ "About dialog appeared"
+ );
+ resolve(domwindow);
+ }
+
+ var domwindow = aAppWindow.docShell.domWindow;
+ domwindow.addEventListener("load", aboutDialogOnLoad, true);
+ },
+ onCloseWindow: aAppWindow => {},
+ };
+
+ Services.wm.addListener(listener);
+ openAboutDialog();
+ });
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json
new file mode 100644
index 0000000000..f36622021f
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json
@@ -0,0 +1,5 @@
+{
+ "policies": {
+ "DisableAppUpdate": true
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.ini b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.ini
new file mode 100644
index 0000000000..b40dff605b
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ browser.policies.alternatePath='<test-root>/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json'
+subsuite = thunderbird
+support-files =
+ config_disable_developer_tools.json
+
+[browser_policy_disable_developer_tools.js]
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js
new file mode 100644
index 0000000000..35ad87ab4d
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_updates_post_policy() {
+ is(
+ Services.policies.isAllowed("devtools"),
+ false,
+ "devtools should be disabled by policy."
+ );
+
+ is(
+ Services.prefs.getBoolPref("devtools.policy.disabled"),
+ true,
+ "devtools dedicated disabled pref is set to true"
+ );
+
+ Services.prefs.setBoolPref("devtools.policy.disabled", false);
+
+ is(
+ Services.prefs.getBoolPref("devtools.policy.disabled"),
+ true,
+ "devtools dedicated disabled pref can not be updated"
+ );
+
+ await expectErrorPage("about:devtools-toolbox");
+ await expectErrorPage("about:debugging");
+
+ info("Check that devtools menu items are hidden");
+ let devtoolsMenu = window.document.getElementById("devtoolsMenu");
+ ok(devtoolsMenu.hidden, "The Web Developer item of the tools menu is hidden");
+});
+
+const expectErrorPage = async function (url) {
+ let tabmail = document.getElementById("tabmail");
+ let index = tabmail.tabInfo.length;
+ window.openContentTab("about:blank");
+ let tab = tabmail.tabInfo[index];
+ let browser = tab.browser;
+
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser, false, url, true);
+ await SpecialPowers.spawn(browser, [url], async function () {
+ ok(
+ content.document.documentURI.startsWith(
+ "about:neterror?e=blockedByPolicy"
+ ),
+ content.document.documentURI +
+ " should start with about:neterror?e=blockedByPolicy"
+ );
+ });
+
+ tabmail.closeTab(tab);
+};
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json
new file mode 100644
index 0000000000..08c393dec6
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json
@@ -0,0 +1,5 @@
+{
+ "policies": {
+ "DisableDeveloperTools": true
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/extensionsettings.html b/comm/mail/components/enterprisepolicies/tests/browser/extensionsettings.html
new file mode 100644
index 0000000000..a54c011968
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/extensionsettings.html
@@ -0,0 +1,23 @@
+
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<script type="text/javascript">
+function installTrigger(url) {
+ InstallTrigger.install({extension: url});
+}
+</script>
+</head>
+<body>
+<p>
+<a id="policytest" href="policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+<p>
+<a id="policytest_installtrigger" onclick="installTrigger(this.href);return false;" href="policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+<p>
+<a id="policytest_otherdomain" href="http://example.org:80/browser/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+</body>
+</html>
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.ini b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.ini
new file mode 100644
index 0000000000..01e3e74e22
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+prefs =
+ browser.policies.alternatePath='<test-root>/browser/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json'
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+support-files =
+ disable_hardware_acceleration.json
+
+[browser_policy_hardware_acceleration.js]
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js
new file mode 100644
index 0000000000..59ca2a3631
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_policy_hardware_acceleration() {
+ let winUtils = Services.wm.getMostRecentWindow("").windowUtils;
+ is(winUtils.layerManagerType, "Basic", "Hardware acceleration disabled");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json
new file mode 100644
index 0000000000..acbdc0a3f4
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json
@@ -0,0 +1,5 @@
+{
+ "policies": {
+ "HardwareAcceleration": false
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/head.js b/comm/mail/components/enterprisepolicies/tests/browser/head.js
new file mode 100644
index 0000000000..b557ea3d22
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/head.js
@@ -0,0 +1,103 @@
+/* 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 { EnterprisePolicyTesting, PoliciesPrefTracker } =
+ ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+ );
+
+PoliciesPrefTracker.start();
+
+async function setupPolicyEngineWithJson(json, customSchema) {
+ PoliciesPrefTracker.restoreDefaultValues();
+ if (typeof json != "object") {
+ let filePath = getTestFilePath(json ? json : "non-existing-file.json");
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(
+ filePath,
+ customSchema
+ );
+ }
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(json, customSchema);
+}
+
+function checkLockedPref(prefName, prefValue) {
+ EnterprisePolicyTesting.checkPolicyPref(prefName, prefValue, true);
+}
+
+function checkUnlockedPref(prefName, prefValue) {
+ EnterprisePolicyTesting.checkPolicyPref(prefName, prefValue, false);
+}
+
+async function withNewTab(options, taskFn) {
+ let tab = window.openContentTab(options.url);
+ await BrowserTestUtils.browserLoaded(tab.browser);
+
+ let result = await taskFn(tab.browser);
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+ return Promise.resolve(result);
+}
+
+add_setup(async function policies_headjs_startWithCleanSlate() {
+ if (Services.policies.status != Ci.nsIEnterprisePolicies.INACTIVE) {
+ await setupPolicyEngineWithJson("");
+ }
+ is(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.INACTIVE,
+ "Engine is inactive at the start of the test"
+ );
+});
+
+registerCleanupFunction(async function policies_headjs_finishWithCleanSlate() {
+ if (Services.policies.status != Ci.nsIEnterprisePolicies.INACTIVE) {
+ await setupPolicyEngineWithJson("");
+ }
+ is(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.INACTIVE,
+ "Engine is inactive at the end of the test"
+ );
+
+ EnterprisePolicyTesting.resetRunOnceState();
+ PoliciesPrefTracker.stop();
+});
+
+function waitForAddonInstall(addon_id) {
+ return new Promise(resolve => {
+ let listener = {
+ onInstallEnded(install, addon) {
+ if (addon.id == addon_id) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ onDownloadFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ onInstallFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+function waitForAddonUninstall(addon_id) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener.onUninstalled = addon => {
+ if (addon.id == addon_id) {
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ };
+ AddonManager.addAddonListener(listener);
+ });
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi b/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi
new file mode 100644
index 0000000000..ee2a6289ee
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi
Binary files differ
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.2.xpi b/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.2.xpi
new file mode 100644
index 0000000000..59d589eba9
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.2.xpi
Binary files differ
diff --git a/comm/mail/components/enterprisepolicies/tests/moz.build b/comm/mail/components/enterprisepolicies/tests/moz.build
new file mode 100644
index 0000000000..c5014bbc67
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/moz.build
@@ -0,0 +1,16 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser/browser.ini",
+ "browser/disable_app_update/browser.ini",
+ "browser/disable_developer_tools/browser.ini",
+ "browser/hardware_acceleration/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "xpcshell/xpcshell.ini",
+]
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/head.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/head.js
new file mode 100644
index 0000000000..2fcf00a21b
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/head.js
@@ -0,0 +1,140 @@
+/* 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 lazy = {};
+
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+const { updateAppInfo, getAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const { PermissionTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PermissionTestUtils.jsm"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+});
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+updateAppInfo({
+ name: "XPCShell",
+ ID: "xpcshell@tests.mozilla.org",
+ version: "48",
+ platformVersion: "48",
+});
+
+// This initializes the policy engine for xpcshell tests
+let policies = Cc["@mozilla.org/enterprisepolicies;1"].getService(
+ Ci.nsIObserver
+);
+policies.observe(null, "policies-startup", null);
+
+async function setupPolicyEngineWithJson(json, customSchema) {
+ if (typeof json != "object") {
+ let filePath = do_get_file(json ? json : "non-existing-file.json").path;
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(
+ filePath,
+ customSchema
+ );
+ }
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(json, customSchema);
+}
+
+/**
+ * Loads a new enterprise policy, and re-initialise the search service
+ * with the new policy. Also waits for the search service to write the settings
+ * file to disk.
+ *
+ * @param {object} policy
+ * The enterprise policy to use.
+ * @param {object} customSchema
+ * A custom schema to use to validate the enterprise policy.
+ */
+async function setupPolicyEngineWithJsonWithSearch(json, customSchema) {
+ Services.search.wrappedJSObject.reset();
+ if (typeof json != "object") {
+ let filePath = do_get_file(json ? json : "non-existing-file.json").path;
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(
+ filePath,
+ customSchema
+ );
+ } else {
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(json, customSchema);
+ }
+ let settingsWritten = lazy.SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ await Services.search.init();
+ return settingsWritten;
+}
+
+function checkLockedPref(prefName, prefValue) {
+ equal(
+ Preferences.locked(prefName),
+ true,
+ `Pref ${prefName} is correctly locked`
+ );
+ equal(
+ Preferences.get(prefName),
+ prefValue,
+ `Pref ${prefName} has the correct value`
+ );
+}
+
+function checkUnlockedPref(prefName, prefValue) {
+ equal(
+ Preferences.locked(prefName),
+ false,
+ `Pref ${prefName} is correctly unlocked`
+ );
+ equal(
+ Preferences.get(prefName),
+ prefValue,
+ `Pref ${prefName} has the correct value`
+ );
+}
+
+function checkUserPref(prefName, prefValue) {
+ equal(
+ Preferences.get(prefName),
+ prefValue,
+ `Pref ${prefName} has the correct value`
+ );
+}
+
+function checkClearPref(prefName, prefValue) {
+ equal(
+ Services.prefs.prefHasUserValue(prefName),
+ false,
+ `Pref ${prefName} has no user value`
+ );
+}
+
+function checkDefaultPref(prefName, prefValue) {
+ let defaultPrefBranch = Services.prefs.getDefaultBranch("");
+ let prefType = defaultPrefBranch.getPrefType(prefName);
+ notEqual(
+ prefType,
+ Services.prefs.PREF_INVALID,
+ `Pref ${prefName} is set on the default branch`
+ );
+}
+
+function checkUnsetPref(prefName) {
+ let defaultPrefBranch = Services.prefs.getDefaultBranch("");
+ let prefType = defaultPrefBranch.getPrefType(prefName);
+ equal(
+ prefType,
+ Services.prefs.PREF_INVALID,
+ `Pref ${prefName} is not set on the default branch`
+ );
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js
new file mode 100644
index 0000000000..b9dabb758d
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_setup(async function () {
+ await setupPolicyEngineWithJson({
+ policies: {
+ "3rdparty": {
+ Extensions: {
+ "3rdparty-policy@mozilla.com": {
+ string: "value",
+ },
+ },
+ },
+ },
+ });
+
+ let extensionPolicy = Services.policies.getExtensionPolicy(
+ "3rdparty-policy@mozilla.com"
+ );
+ deepEqual(extensionPolicy, { string: "value" });
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js
new file mode 100644
index 0000000000..01e3810a05
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Note that these tests only ensure that the pin is properly added to the
+ * update URL and to the telemetry. They do not test that the update applied
+ * will be of the correct version. This is because we are not attempting to have
+ * Thunderbird check if the update provided is valid given the pin, we are leaving
+ * it to the update server (Balrog) to find and serve the correct version.
+ */
+
+async function test_update_pin(pinString, pinIsValid = true) {
+ await setupPolicyEngineWithJson({
+ policies: {
+ AppUpdateURL: "https://www.example.com/update.xml",
+ AppUpdatePin: pinString,
+ },
+ });
+ Services.telemetry.clearScalars();
+
+ equal(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Engine is active"
+ );
+
+ let policies = Services.policies.getActivePolicies();
+ equal(
+ "AppUpdatePin" in policies,
+ pinIsValid,
+ "AppUpdatePin policy should only be active if the pin was valid."
+ );
+
+ let checker = Cc["@mozilla.org/updates/update-checker;1"].getService(
+ Ci.nsIUpdateChecker
+ );
+ let updateURL = await checker.getUpdateURL(checker.BACKGROUND_CHECK);
+
+ let expected = pinIsValid
+ ? `https://www.example.com/update.xml?pin=${pinString}`
+ : "https://www.example.com/update.xml";
+
+ equal(updateURL, expected, "App Update URL should match expected URL.");
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+ if (pinIsValid) {
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "update.version_pin",
+ pinString,
+ "Update pin telemetry should be set"
+ );
+ } else {
+ TelemetryTestUtils.assertScalarUnset(scalars, "update.version_pin");
+ }
+}
+
+add_task(async function test_app_update_pin() {
+ await test_update_pin("102.");
+ await test_update_pin("102.0.");
+ await test_update_pin("102.1.");
+ await test_update_pin("102.1.1", false);
+ await test_update_pin("102.1.1.", false);
+ await test_update_pin("102", false);
+ await test_update_pin("foobar", false);
+ await test_update_pin("-102.1.", false);
+ await test_update_pin("102.-1.", false);
+ await test_update_pin("102a.1.", false);
+ await test_update_pin("102.1a.", false);
+ await test_update_pin("0102.1.", false);
+ // Should not accept version numbers that will never be in Balrog's pinning
+ // table (i.e. versions before 102.0).
+ await test_update_pin("101.1.", false);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js
new file mode 100644
index 0000000000..48d04e1a8d
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_app_update_URL() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ AppUpdateURL: "https://www.example.com/",
+ },
+ });
+
+ equal(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Engine is active"
+ );
+
+ let checker = Cc["@mozilla.org/updates/update-checker;1"].getService(
+ Ci.nsIUpdateChecker
+ );
+ let expected = await checker.getUpdateURL(checker.BACKGROUND_CHECK);
+
+ equal("https://www.example.com/", expected, "Correct app update URL");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js
new file mode 100644
index 0000000000..1449e664c2
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_bug1658259_1() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ OfferToSaveLogins: false,
+ OfferToSaveLoginsDefault: true,
+ },
+ });
+ checkLockedPref("signon.rememberSignons", false);
+});
+
+add_task(async function test_bug1658259_2() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ OfferToSaveLogins: true,
+ OfferToSaveLoginsDefault: false,
+ },
+ });
+ checkLockedPref("signon.rememberSignons", true);
+});
+
+add_task(async function test_bug1658259_3() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ OfferToSaveLoginsDefault: true,
+ OfferToSaveLogins: false,
+ },
+ });
+ checkLockedPref("signon.rememberSignons", false);
+});
+
+add_task(async function test_bug1658259_4() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ OfferToSaveLoginsDefault: false,
+ OfferToSaveLogins: true,
+ },
+ });
+ checkLockedPref("signon.rememberSignons", true);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js
new file mode 100644
index 0000000000..17149f787f
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const HOSTNAME_DOMAIN = "browser_policy_clear_blocked_cookies.com";
+const ORIGIN_DOMAIN = "browser_policy_clear_blocked_cookies.org";
+
+add_setup(async function () {
+ const expiry = Date.now() + 24 * 60 * 60;
+ Services.cookies.add(
+ HOSTNAME_DOMAIN,
+ "/",
+ "secure",
+ "true",
+ true,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ Services.cookies.add(
+ HOSTNAME_DOMAIN,
+ "/",
+ "insecure",
+ "true",
+ false,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+ Services.cookies.add(
+ ORIGIN_DOMAIN,
+ "/",
+ "secure",
+ "true",
+ true,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ Services.cookies.add(
+ ORIGIN_DOMAIN,
+ "/",
+ "insecure",
+ "true",
+ false,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+ Services.cookies.add(
+ "example.net",
+ "/",
+ "secure",
+ "true",
+ true,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Block: [`http://${HOSTNAME_DOMAIN}`, `https://${ORIGIN_DOMAIN}:8080`],
+ },
+ },
+ });
+});
+
+function retrieve_all_cookies(host) {
+ const values = [];
+ for (let cookie of Services.cookies.getCookiesFromHost(host, {})) {
+ values.push({
+ host: cookie.host,
+ name: cookie.name,
+ path: cookie.path,
+ });
+ }
+ return values;
+}
+
+add_task(async function test_cookies_for_blocked_sites_cleared() {
+ const cookies = {
+ hostname: retrieve_all_cookies(HOSTNAME_DOMAIN),
+ origin: retrieve_all_cookies(ORIGIN_DOMAIN),
+ keep: retrieve_all_cookies("example.net"),
+ };
+ const expected = {
+ hostname: [],
+ origin: [],
+ keep: [{ host: "example.net", name: "secure", path: "/" }],
+ };
+ equal(
+ JSON.stringify(cookies),
+ JSON.stringify(expected),
+ "All stored cookies for blocked origins should be cleared"
+ );
+});
+
+add_task(function teardown() {
+ for (let host of [HOSTNAME_DOMAIN, ORIGIN_DOMAIN, "example.net"]) {
+ Services.cookies.removeCookiesWithOriginAttributes("{}", host);
+ }
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js
new file mode 100644
index 0000000000..096852612c
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { macOSPoliciesParser } = ChromeUtils.importESModule(
+ "resource://gre/modules/policies/macOSPoliciesParser.sys.mjs"
+);
+
+add_task(async function test_object_unflatten() {
+ // Note: these policies are just examples and they won't actually
+ // run through the policy engine on this test. We're just testing
+ // that the unflattening algorithm produces the correct output.
+ let input = {
+ DisplayBookmarksToolbar: true,
+
+ Homepage__URL: "https://www.mozilla.org",
+ Homepage__Locked: "true",
+ Homepage__Additional__0: "https://extra-homepage-1.example.com",
+ Homepage__Additional__1: "https://extra-homepage-2.example.com",
+
+ WebsiteFilter__Block__0: "*://*.example.org/*",
+ WebsiteFilter__Block__1: "*://*.example.net/*",
+ WebsiteFilter__Exceptions__0: "*://*.example.org/*exception*",
+
+ Permissions__Camera__Allow__0: "https://www.example.com",
+
+ Permissions__Notifications__Allow__0: "https://www.example.com",
+ Permissions__Notifications__Allow__1: "https://www.example.org",
+ Permissions__Notifications__Block__0: "https://www.example.net",
+
+ Permissions__Notifications__BlockNewRequests: true,
+ Permissions__Notifications__Locked: true,
+
+ Bookmarks__0__Title: "Bookmark 1",
+ Bookmarks__0__URL: "https://bookmark1.example.com",
+
+ Bookmarks__1__Title: "Bookmark 2",
+ Bookmarks__1__URL: "https://bookmark2.example.com",
+ Bookmarks__1__Folder: "Folder",
+ };
+
+ let expected = {
+ DisplayBookmarksToolbar: true,
+
+ Homepage: {
+ URL: "https://www.mozilla.org",
+ Locked: "true",
+ Additional: [
+ "https://extra-homepage-1.example.com",
+ "https://extra-homepage-2.example.com",
+ ],
+ },
+
+ WebsiteFilter: {
+ Block: ["*://*.example.org/*", "*://*.example.net/*"],
+ Exceptions: ["*://*.example.org/*exception*"],
+ },
+
+ Permissions: {
+ Camera: {
+ Allow: ["https://www.example.com"],
+ },
+
+ Notifications: {
+ Allow: ["https://www.example.com", "https://www.example.org"],
+ Block: ["https://www.example.net"],
+ BlockNewRequests: true,
+ Locked: true,
+ },
+ },
+
+ Bookmarks: [
+ {
+ Title: "Bookmark 1",
+ URL: "https://bookmark1.example.com",
+ },
+ {
+ Title: "Bookmark 2",
+ URL: "https://bookmark2.example.com",
+ Folder: "Folder",
+ },
+ ],
+ };
+
+ let unflattened = macOSPoliciesParser.unflatten(input);
+
+ deepEqual(unflattened, expected, "Input was unflattened correctly.");
+});
+
+add_task(async function test_array_unflatten() {
+ let input = {
+ Foo__1: 1,
+ Foo__5: 5,
+ Foo__10: 10,
+ Foo__30: 30,
+ Foo__51: 51, // This one should not be included as the limit is 50
+ };
+
+ let unflattened = macOSPoliciesParser.unflatten(input);
+ equal(unflattened.Foo.length, 31, "Array size is correct");
+
+ let expected = {
+ Foo: [, 1, , , , 5], // eslint-disable-line no-sparse-arrays
+ };
+ expected.Foo[10] = 10;
+ expected.Foo[30] = 30;
+
+ deepEqual(unflattened, expected, "Array was unflattened correctly.");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js
new file mode 100644
index 0000000000..be16829867
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js
@@ -0,0 +1,490 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+Services.prefs.setBoolPref("browser.search.log", true);
+SearchTestUtils.init(this);
+
+AddonTestUtils.init(this, false);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "48",
+ "48"
+);
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+ await Services.search.init();
+ console.log("done init");
+});
+
+add_task(async function test_install_and_set_default() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.notEqual(
+ (await Services.search.getDefault()).name,
+ "MozSearch",
+ "Default search engine should not be MozSearch when test starts"
+ );
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ 'Engine "Foo" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "MozSearch",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ Default: "MozSearch",
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ // If this passes, it means that the new search engine was properly installed
+ // *and* was properly set as the default.
+ Assert.equal(
+ (await Services.search.getDefault()).name,
+ "MozSearch",
+ "Specified search engine should be the default"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_and_set_default_private() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.notEqual(
+ (await Services.search.getDefaultPrivate()).name,
+ "MozSearch",
+ "Default search engine should not be MozSearch when test starts"
+ );
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ 'Engine "Foo" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "MozSearch",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ DefaultPrivate: "MozSearch",
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ // If this passes, it means that the new search engine was properly installed
+ // *and* was properly set as the default.
+ Assert.equal(
+ (await Services.search.getDefaultPrivate()).name,
+ "MozSearch",
+ "Specified search engine should be the default private engine"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+// Same as the last test, but with "PreventInstalls" set to true to make sure
+// it does not prevent search engines from being installed properly
+add_task(async function test_install_and_set_default_prevent_installs() {
+ Assert.notEqual(
+ (await Services.search.getDefault()).name,
+ "MozSearch",
+ "Default search engine should not be MozSearch when test starts"
+ );
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ 'Engine "Foo" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "MozSearch",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ Default: "MozSearch",
+ PreventInstalls: true,
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ Assert.equal(
+ (await Services.search.getDefault()).name,
+ "MozSearch",
+ "Specified search engine should be the default"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_and_remove() {
+ let iconURL =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
+
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ 'Engine "Foo" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Foo",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ IconURL: iconURL,
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ // If this passes, it means that the new search engine was properly installed
+
+ let engine = Services.search.getEngineByName("Foo");
+ Assert.notEqual(engine, null, "Specified search engine should be installed");
+
+ Assert.equal(
+ engine.wrappedJSObject.iconURI.spec,
+ iconURL,
+ "Icon should be present"
+ );
+ Assert.equal(
+ engine.wrappedJSObject.queryCharset,
+ "UTF-8",
+ "Should default to utf-8"
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Remove: ["Foo"],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ // If this passes, it means that the specified engine was properly removed
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ "Specified search engine should not be installed"
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_post_method_engine() {
+ Assert.equal(
+ Services.search.getEngineByName("Post"),
+ null,
+ 'Engine "Post" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Post",
+ Method: "POST",
+ PostData: "q={searchTerms}&anotherParam=yes",
+ URLTemplate: "http://example.com/",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("Post");
+ Assert.notEqual(engine, null, "Specified search engine should be installed");
+
+ Assert.equal(
+ engine.wrappedJSObject._urls[0].method,
+ "POST",
+ "Method should be POST"
+ );
+
+ let submission = engine.getSubmission("term", "text/html");
+ Assert.notEqual(submission.postData, null, "Post data should not be null");
+
+ let scriptableInputStream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ scriptableInputStream.init(submission.postData);
+ Assert.equal(
+ scriptableInputStream.read(scriptableInputStream.available()),
+ "q=term&anotherParam=yes",
+ "Post data should be present"
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_with_encoding() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.equal(
+ Services.search.getEngineByName("Encoding"),
+ null,
+ 'Engine "Encoding" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Encoding",
+ Encoding: "windows-1252",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("Encoding");
+ Assert.equal(
+ engine.wrappedJSObject.queryCharset,
+ "windows-1252",
+ "Should have correct encoding"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_and_update() {
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "ToUpdate",
+ URLTemplate: "http://initial.example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("ToUpdate");
+ Assert.notEqual(engine, null, "Specified search engine should be installed");
+
+ Assert.equal(
+ engine.getSubmission("test").uri.spec,
+ "http://initial.example.com/?q=test",
+ "Initial submission URL should be correct."
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "ToUpdate",
+ URLTemplate: "http://update.example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ engine = Services.search.getEngineByName("ToUpdate");
+ Assert.notEqual(engine, null, "Specified search engine should be installed");
+
+ Assert.equal(
+ engine.getSubmission("test").uri.spec,
+ "http://update.example.com/?q=test",
+ "Updated Submission URL should be correct."
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_with_suggest() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.equal(
+ Services.search.getEngineByName("Suggest"),
+ null,
+ 'Engine "Suggest" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Suggest",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ SuggestURLTemplate: "http://suggest.example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("Suggest");
+
+ Assert.equal(
+ engine.getSubmission("test", "application/x-suggestions+json").uri.spec,
+ "http://suggest.example.com/?q=test",
+ "Updated Submission URL should be correct."
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_and_restart_keeps_settings() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.equal(
+ Services.search.getEngineByName("Settings"),
+ null,
+ 'Engine "Settings" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Settings",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ let engine = Services.search.getEngineByName("Settings");
+ engine.hidden = true;
+ engine.alias = "settings";
+ await settingsWritten;
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Settings",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+
+ engine = Services.search.getEngineByName("Settings");
+
+ Assert.ok(engine.hidden, "Should have kept the engine hidden after restart");
+ Assert.equal(
+ engine.alias,
+ "settings",
+ "Should have kept the engine alias after restart"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_reset_default() {
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Remove: ["DuckDuckGo"],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("DuckDuckGo");
+
+ Assert.equal(
+ engine.hidden,
+ true,
+ "Application specified engine should be hidden."
+ );
+
+ await Services.search.restoreDefaultEngines();
+
+ engine = Services.search.getEngineByName("DuckDuckGo");
+ Assert.equal(
+ engine.hidden,
+ false,
+ "Application specified engine should not be hidden"
+ );
+
+ EnterprisePolicyTesting.resetRunOnceState();
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_preferences.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_preferences.js
new file mode 100644
index 0000000000..6ad883fe42
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_preferences.js
@@ -0,0 +1,255 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const OLD_PREFERENCES_TESTS = [
+ {
+ policies: {
+ Preferences: {
+ "network.IDN_show_punycode": true,
+ "accessibility.force_disabled": 1,
+ "security.default_personal_cert": "Select Automatically",
+ // "geo.enabled": 1,
+ "extensions.getAddons.showPane": 0,
+ },
+ },
+ lockedPrefs: {
+ "network.IDN_show_punycode": true,
+ "accessibility.force_disabled": 1,
+ "security.default_personal_cert": "Select Automatically",
+ // "geo.enabled": true,
+ "extensions.getAddons.showPane": false,
+ },
+ },
+];
+
+const NEW_PREFERENCES_TESTS = [
+ {
+ policies: {
+ Preferences: {
+ "browser.policies.test.default.boolean": {
+ Value: true,
+ Status: "default",
+ },
+ "browser.policies.test.default.string": {
+ Value: "string",
+ Status: "default",
+ },
+ "browser.policies.test.default.number": {
+ Value: 11,
+ Status: "default",
+ },
+ "browser.policies.test.locked.boolean": {
+ Value: true,
+ Status: "locked",
+ },
+ "browser.policies.test.locked.string": {
+ Value: "string",
+ Status: "locked",
+ },
+ "browser.policies.test.locked.number": {
+ Value: 11,
+ Status: "locked",
+ },
+ "browser.policies.test.user.boolean": {
+ Value: true,
+ Status: "user",
+ },
+ "browser.policies.test.user.string": {
+ Value: "string",
+ Status: "user",
+ },
+ "browser.policies.test.user.number": {
+ Value: 11,
+ Status: "user",
+ },
+ "mail.openMessageBehavior": {
+ Value: 1,
+ Status: "locked",
+ },
+ "mailnews.display.prefer_plaintext": {
+ Value: true,
+ Status: "locked",
+ },
+ "chat.enabled": {
+ Value: false,
+ Status: "locked",
+ },
+ "calendar.agenda.days": {
+ Value: 21,
+ Status: "locked",
+ },
+ },
+ },
+ defaultPrefs: {
+ "browser.policies.test.default.boolean": true,
+ "browser.policies.test.default.string": "string",
+ "browser.policies.test.default.number": 11,
+ },
+ lockedPrefs: {
+ "browser.policies.test.locked.boolean": true,
+ "browser.policies.test.locked.string": "string",
+ "browser.policies.test.locked.number": 11,
+ "mail.openMessageBehavior": 1,
+ "mailnews.display.prefer_plaintext": true,
+ "chat.enabled": false,
+ "calendar.agenda.days": 21,
+ },
+ userPrefs: {
+ "browser.policies.test.user.boolean": true,
+ "browser.policies.test.user.string": "string",
+ "browser.policies.test.user.number": 11,
+ },
+ },
+ {
+ policies: {
+ Preferences: {
+ "browser.policies.test.user.boolean": {
+ Status: "clear",
+ },
+ "browser.policies.test.user.string": {
+ Status: "clear",
+ },
+ "browser.policies.test.user.number": {
+ Status: "clear",
+ },
+ },
+ },
+
+ clearPrefs: {
+ "browser.policies.test.user.boolean": true,
+ "browser.policies.test.user.string": "string",
+ "browser.policies.test.user.number": 11,
+ },
+ },
+];
+
+const BAD_PREFERENCES_TESTS = [
+ {
+ policies: {
+ Preferences: {
+ "not.a.valid.branch": {
+ Value: true,
+ Status: "default",
+ },
+ "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer":
+ {
+ Value: true,
+ Status: "default",
+ },
+ },
+ },
+ defaultPrefs: {
+ "not.a.valid.branch": true,
+ "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer": true,
+ },
+ },
+];
+
+add_task(async function test_old_preferences() {
+ for (let test of OLD_PREFERENCES_TESTS) {
+ await setupPolicyEngineWithJson({
+ policies: test.policies,
+ });
+
+ info("Checking policy: " + Object.keys(test.policies)[0]);
+
+ for (let [prefName, prefValue] of Object.entries(test.lockedPrefs || {})) {
+ checkLockedPref(prefName, prefValue);
+ }
+ }
+});
+
+add_task(async function test_new_preferences() {
+ for (let test of NEW_PREFERENCES_TESTS) {
+ await setupPolicyEngineWithJson({
+ policies: test.policies,
+ });
+
+ info("Checking policy: " + Object.keys(test.policies)[0]);
+
+ for (let [prefName, prefValue] of Object.entries(test.lockedPrefs || {})) {
+ checkLockedPref(prefName, prefValue);
+ }
+
+ for (let [prefName, prefValue] of Object.entries(test.defaultPrefs || {})) {
+ checkDefaultPref(prefName, prefValue);
+ }
+
+ for (let [prefName, prefValue] of Object.entries(test.userPrefs || {})) {
+ checkUserPref(prefName, prefValue);
+ }
+
+ for (let [prefName, prefValue] of Object.entries(test.clearPrefs || {})) {
+ checkClearPref(prefName, prefValue);
+ }
+ }
+});
+
+add_task(async function test_bad_preferences() {
+ for (let test of BAD_PREFERENCES_TESTS) {
+ await setupPolicyEngineWithJson({
+ policies: test.policies,
+ });
+
+ info("Checking policy: " + Object.keys(test.policies)[0]);
+
+ for (let prefName of Object.entries(test.defaultPrefs || {})) {
+ checkUnsetPref(prefName);
+ }
+ }
+});
+
+add_task(async function test_user_default_preference() {
+ Services.prefs
+ .getDefaultBranch("")
+ .setBoolPref("browser.policies.test.override", true);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ Preferences: {
+ "browser.policies.test.override": {
+ Value: true,
+ Status: "user",
+ },
+ },
+ },
+ });
+
+ checkUserPref("browser.policies.test.override", true);
+});
+
+add_task(async function test_security_preference() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Preferences: {
+ "security.this.should.not.work": {
+ Value: true,
+ Status: "default",
+ },
+ },
+ },
+ });
+
+ checkUnsetPref("security.this.should.not.work");
+});
+
+add_task(async function test_bug_1666836() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Preferences: {
+ "browser.tabs.warnOnClose": {
+ Value: 0,
+ Status: "default",
+ },
+ },
+ },
+ });
+
+ equal(
+ Preferences.get("browser.tabs.warnOnClose"),
+ false,
+ `browser.tabs.warnOnClose should be false`
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_proxy.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_proxy.js
new file mode 100644
index 0000000000..ef5ad1e178
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_proxy.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_task(async function test_proxy_modes_and_autoconfig() {
+ // Directly test the proxy Mode and AutoconfigURL parameters through
+ // the API instead of the policy engine, because the test harness
+ // uses these prefs, and changing them interfere with the harness.
+
+ // Checks that every Mode value translates correctly to the expected pref value
+ let { ProxyPolicies, PROXY_TYPES_MAP } = ChromeUtils.importESModule(
+ "resource:///modules/policies/ProxyPolicies.sys.mjs"
+ );
+
+ for (let [mode, expectedValue] of PROXY_TYPES_MAP) {
+ ProxyPolicies.configureProxySettings({ Mode: mode }, (_, value) => {
+ equal(value, expectedValue, "Correct proxy mode");
+ });
+ }
+
+ let autoconfigURL = new URL("data:text/plain,test");
+ ProxyPolicies.configureProxySettings(
+ { AutoConfigURL: autoconfigURL },
+ (_, value) => {
+ equal(value, autoconfigURL.href, "AutoconfigURL correctly set");
+ }
+ );
+});
+
+add_task(async function test_proxy_boolean_settings() {
+ // Tests that both false and true values are correctly set and locked
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ UseProxyForDNS: false,
+ AutoLogin: false,
+ },
+ },
+ });
+
+ checkUnlockedPref("network.proxy.socks_remote_dns", false);
+ checkUnlockedPref("signon.autologin.proxy", false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ UseProxyForDNS: true,
+ AutoLogin: true,
+ },
+ },
+ });
+
+ checkUnlockedPref("network.proxy.socks_remote_dns", true);
+ checkUnlockedPref("signon.autologin.proxy", true);
+});
+
+add_task(async function test_proxy_socks_and_passthrough() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ SOCKSVersion: 4,
+ Passthrough: "a, b, c",
+ },
+ },
+ });
+
+ checkUnlockedPref("network.proxy.socks_version", 4);
+ checkUnlockedPref("network.proxy.no_proxies_on", "a, b, c");
+});
+
+add_task(async function test_proxy_addresses() {
+ function checkProxyPref(proxytype, address, port) {
+ checkUnlockedPref(`network.proxy.${proxytype}`, address);
+ checkUnlockedPref(`network.proxy.${proxytype}_port`, port);
+ }
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ HTTPProxy: "http.proxy.example.com:10",
+ SSLProxy: "ssl.proxy.example.com:30",
+ SOCKSProxy: "socks.proxy.example.com:40",
+ },
+ },
+ });
+
+ checkProxyPref("http", "http.proxy.example.com", 10);
+ checkProxyPref("ssl", "ssl.proxy.example.com", 30);
+ checkProxyPref("socks", "socks.proxy.example.com", 40);
+
+ // Do the same, but now use the UseHTTPProxyForAllProtocols option
+ // and check that it takes effect.
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ HTTPProxy: "http.proxy.example.com:10",
+ // FTP support was removed in bug 1574475
+ // Setting an FTPProxy should result in a warning but should not fail
+ FTPProxy: "ftp.proxy.example.com:20",
+ SSLProxy: "ssl.proxy.example.com:30",
+ SOCKSProxy: "socks.proxy.example.com:40",
+ UseHTTPProxyForAllProtocols: true,
+ },
+ },
+ });
+
+ checkProxyPref("http", "http.proxy.example.com", 10);
+ checkProxyPref("ssl", "http.proxy.example.com", 10);
+ checkProxyPref("socks", "http.proxy.example.com", 10);
+
+ // Make sure the FTPProxy setting did nothing
+ Assert.equal(
+ Preferences.has("network.proxy.ftp"),
+ false,
+ "network.proxy.ftp should not be set"
+ );
+ Assert.equal(
+ Preferences.has("network.proxy.ftp_port"),
+ false,
+ "network.proxy.ftp_port should not be set"
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js
new file mode 100644
index 0000000000..6c298cee5a
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const REQ_LOC_CHANGE_EVENT = "intl:requested-locales-changed";
+
+function promiseLocaleChanged(requestedLocale) {
+ return new Promise(resolve => {
+ let localeObserver = {
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case REQ_LOC_CHANGE_EVENT:
+ let reqLocs = Services.locale.requestedLocales;
+ equal(reqLocs[0], requestedLocale);
+ Services.obs.removeObserver(localeObserver, REQ_LOC_CHANGE_EVENT);
+ resolve();
+ }
+ },
+ };
+ Services.obs.addObserver(localeObserver, REQ_LOC_CHANGE_EVENT);
+ });
+}
+
+add_task(async function test_requested_locale_array() {
+ let originalLocales = Services.locale.requestedLocales;
+ let localePromise = promiseLocaleChanged("de");
+ await setupPolicyEngineWithJson({
+ policies: {
+ RequestedLocales: ["de"],
+ },
+ });
+ await localePromise;
+ Services.locale.requestedLocales = originalLocales;
+});
+
+add_task(async function test_requested_locale_string() {
+ let originalLocales = Services.locale.requestedLocales;
+ let localePromise = promiseLocaleChanged("fr");
+ await setupPolicyEngineWithJson({
+ policies: {
+ RequestedLocales: "fr",
+ },
+ });
+ await localePromise;
+ Services.locale.requestedLocales = originalLocales;
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js
new file mode 100644
index 0000000000..c8e73b3422
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { runOnce } = ChromeUtils.importESModule(
+ "resource:///modules/policies/Policies.sys.mjs"
+);
+
+let runCount = 0;
+function callback() {
+ runCount++;
+}
+
+add_task(async function test_runonce_helper() {
+ runOnce("test_action", callback);
+ equal(runCount, 1, "Callback ran for the first time.");
+
+ runOnce("test_action", callback);
+ equal(runCount, 1, "Callback didn't run again.");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js
new file mode 100644
index 0000000000..90da242a72
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js
@@ -0,0 +1,378 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * Use this file to add tests to policies that are
+ * simple pref flips.
+ *
+ * It's best to make a test to actually test the feature
+ * instead of the pref flip, but if that feature is well
+ * covered by tests, including that its pref actually works,
+ * it's OK to have the policy test here just to ensure
+ * that the right pref values are set.
+ */
+
+const POLICIES_TESTS = [
+ /*
+ * Example:
+ * {
+ * // Policies to be set at once through the engine
+ * policies: { "DisableFoo": true, "ConfigureBar": 42 },
+ *
+ * // Locked prefs to check
+ * lockedPrefs: { "feature.foo": false },
+ *
+ * // Unlocked prefs to check
+ * unlockedPrefs: { "bar.baz": 42 }
+ * },
+ */
+
+ // POLICY: RememberPasswords
+ {
+ policies: { OfferToSaveLogins: false },
+ lockedPrefs: { "signon.rememberSignons": false },
+ },
+ {
+ policies: { OfferToSaveLogins: true },
+ lockedPrefs: { "signon.rememberSignons": true },
+ },
+
+ // POLICY: DisableSecurityBypass
+ {
+ policies: {
+ DisableSecurityBypass: {
+ InvalidCertificate: true,
+ SafeBrowsing: true,
+ },
+ },
+ lockedPrefs: {
+ "security.certerror.hideAddException": true,
+ "browser.safebrowsing.allowOverride": false,
+ },
+ },
+
+ // POLICY: DisableBuiltinPDFViewer
+ {
+ policies: { DisableBuiltinPDFViewer: true },
+ lockedPrefs: { "pdfjs.disabled": true },
+ },
+
+ // POLICY: Authentication
+ {
+ policies: {
+ Authentication: {
+ SPNEGO: ["a.com", "b.com"],
+ Delegated: ["a.com", "b.com"],
+ NTLM: ["a.com", "b.com"],
+ AllowNonFQDN: {
+ SPNEGO: true,
+ NTLM: true,
+ },
+ AllowProxies: {
+ SPNEGO: false,
+ NTLM: false,
+ },
+ PrivateBrowsing: true,
+ },
+ },
+ lockedPrefs: {
+ "network.negotiate-auth.trusted-uris": "a.com, b.com",
+ "network.negotiate-auth.delegation-uris": "a.com, b.com",
+ "network.automatic-ntlm-auth.trusted-uris": "a.com, b.com",
+ "network.automatic-ntlm-auth.allow-non-fqdn": true,
+ "network.negotiate-auth.allow-non-fqdn": true,
+ "network.automatic-ntlm-auth.allow-proxies": false,
+ "network.negotiate-auth.allow-proxies": false,
+ "network.auth.private-browsing-sso": true,
+ },
+ },
+
+ // POLICY: Authentication (unlocked)
+ {
+ policies: {
+ Authentication: {
+ SPNEGO: ["a.com", "b.com"],
+ Delegated: ["a.com", "b.com"],
+ NTLM: ["a.com", "b.com"],
+ AllowNonFQDN: {
+ SPNEGO: true,
+ NTLM: true,
+ },
+ AllowProxies: {
+ SPNEGO: false,
+ NTLM: false,
+ },
+ PrivateBrowsing: true,
+ Locked: false,
+ },
+ },
+ unlockedPrefs: {
+ "network.negotiate-auth.trusted-uris": "a.com, b.com",
+ "network.negotiate-auth.delegation-uris": "a.com, b.com",
+ "network.automatic-ntlm-auth.trusted-uris": "a.com, b.com",
+ "network.automatic-ntlm-auth.allow-non-fqdn": true,
+ "network.negotiate-auth.allow-non-fqdn": true,
+ "network.automatic-ntlm-auth.allow-proxies": false,
+ "network.negotiate-auth.allow-proxies": false,
+ "network.auth.private-browsing-sso": true,
+ },
+ },
+
+ // POLICY: Certificates (true)
+ {
+ policies: {
+ Certificates: {
+ ImportEnterpriseRoots: true,
+ },
+ },
+ lockedPrefs: {
+ "security.enterprise_roots.enabled": true,
+ },
+ },
+
+ // POLICY: Certificates (false)
+ {
+ policies: {
+ Certificates: {
+ ImportEnterpriseRoots: false,
+ },
+ },
+ lockedPrefs: {
+ "security.enterprise_roots.enabled": false,
+ },
+ },
+
+ // POLICY: InstallAddons.Default (block addon installs)
+ {
+ policies: {
+ InstallAddonsPermission: {
+ Default: false,
+ },
+ },
+ lockedPrefs: {
+ "xpinstall.enabled": false,
+ },
+ },
+
+ // POLICY: DNSOverHTTPS Locked
+ {
+ policies: {
+ DNSOverHTTPS: {
+ Enabled: true,
+ ProviderURL: "http://example.com/provider",
+ ExcludedDomains: ["example.com", "example.org"],
+ Locked: true,
+ },
+ },
+ lockedPrefs: {
+ "network.trr.mode": 2,
+ "network.trr.uri": "http://example.com/provider",
+ "network.trr.excluded-domains": "example.com,example.org",
+ },
+ },
+
+ // POLICY: DNSOverHTTPS Unlocked
+ {
+ policies: {
+ DNSOverHTTPS: {
+ Enabled: false,
+ ProviderURL: "http://example.com/provider",
+ ExcludedDomains: ["example.com", "example.org"],
+ },
+ },
+ unlockedPrefs: {
+ "network.trr.mode": 5,
+ "network.trr.uri": "http://example.com/provider",
+ "network.trr.excluded-domains": "example.com,example.org",
+ },
+ },
+
+ // POLICY: SSLVersionMin/SSLVersionMax (1)
+ {
+ policies: {
+ SSLVersionMin: "tls1",
+ SSLVersionMax: "tls1.1",
+ },
+ lockedPrefs: {
+ "security.tls.version.min": 1,
+ "security.tls.version.max": 2,
+ },
+ },
+
+ // POLICY: SSLVersionMin/SSLVersionMax (2)
+ {
+ policies: {
+ SSLVersionMin: "tls1.2",
+ SSLVersionMax: "tls1.3",
+ },
+ lockedPrefs: {
+ "security.tls.version.min": 3,
+ "security.tls.version.max": 4,
+ },
+ },
+
+ // POLICY: CaptivePortal
+ {
+ policies: {
+ CaptivePortal: false,
+ },
+ lockedPrefs: {
+ "network.captive-portal-service.enabled": false,
+ },
+ },
+
+ // POLICY: NetworkPrediction
+ {
+ policies: {
+ NetworkPrediction: false,
+ },
+ lockedPrefs: {
+ "network.dns.disablePrefetch": true,
+ "network.dns.disablePrefetchFromHTTPS": true,
+ },
+ },
+
+ // POLICY: ExtensionUpdate
+ {
+ policies: {
+ ExtensionUpdate: false,
+ },
+ lockedPrefs: {
+ "extensions.update.enabled": false,
+ },
+ },
+
+ // POLICY: OfferToSaveLoginsDefault
+ {
+ policies: {
+ OfferToSaveLoginsDefault: false,
+ },
+ unlockedPrefs: {
+ "signon.rememberSignons": false,
+ },
+ },
+
+ // POLICY: PDFjs
+
+ {
+ policies: {
+ PDFjs: {
+ Enabled: false,
+ EnablePermissions: true,
+ },
+ },
+ lockedPrefs: {
+ "pdfjs.disabled": true,
+ "pdfjs.enablePermissions": true,
+ },
+ },
+
+ // POLICY: DisabledCiphers
+ {
+ policies: {
+ DisabledCiphers: {
+ TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: false,
+ TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: false,
+ TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: false,
+ TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: false,
+ TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: false,
+ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: false,
+ TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: false,
+ TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: false,
+ TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: false,
+ TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: false,
+ TLS_DHE_RSA_WITH_AES_128_CBC_SHA: false,
+ TLS_DHE_RSA_WITH_AES_256_CBC_SHA: false,
+ TLS_RSA_WITH_AES_128_GCM_SHA256: false,
+ TLS_RSA_WITH_AES_256_GCM_SHA384: false,
+ TLS_RSA_WITH_AES_128_CBC_SHA: false,
+ TLS_RSA_WITH_AES_256_CBC_SHA: false,
+ TLS_RSA_WITH_3DES_EDE_CBC_SHA: false,
+ },
+ },
+ lockedPrefs: {
+ "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256": true,
+ "security.ssl3.ecdhe_ecdsa_aes_128_gcm_sha256": true,
+ "security.ssl3.ecdhe_ecdsa_chacha20_poly1305_sha256": true,
+ "security.ssl3.ecdhe_rsa_chacha20_poly1305_sha256": true,
+ "security.ssl3.ecdhe_ecdsa_aes_256_gcm_sha384": true,
+ "security.ssl3.ecdhe_rsa_aes_256_gcm_sha384": true,
+ "security.ssl3.ecdhe_rsa_aes_128_sha": true,
+ "security.ssl3.ecdhe_ecdsa_aes_128_sha": true,
+ "security.ssl3.ecdhe_rsa_aes_256_sha": true,
+ "security.ssl3.ecdhe_ecdsa_aes_256_sha": true,
+ "security.ssl3.dhe_rsa_aes_128_sha": true,
+ "security.ssl3.dhe_rsa_aes_256_sha": true,
+ "security.ssl3.rsa_aes_128_gcm_sha256": true,
+ "security.ssl3.rsa_aes_256_gcm_sha384": true,
+ "security.ssl3.rsa_aes_128_sha": true,
+ "security.ssl3.rsa_aes_256_sha": true,
+ "security.ssl3.deprecated.rsa_des_ede3_sha": true,
+ },
+ },
+
+ {
+ policies: {
+ DisabledCiphers: {
+ TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: true,
+ TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: true,
+ TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: true,
+ TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: true,
+ TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: true,
+ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: true,
+ TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: true,
+ TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: true,
+ TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: true,
+ TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: true,
+ TLS_DHE_RSA_WITH_AES_128_CBC_SHA: true,
+ TLS_DHE_RSA_WITH_AES_256_CBC_SHA: true,
+ TLS_RSA_WITH_AES_128_GCM_SHA256: true,
+ TLS_RSA_WITH_AES_256_GCM_SHA384: true,
+ TLS_RSA_WITH_AES_128_CBC_SHA: true,
+ TLS_RSA_WITH_AES_256_CBC_SHA: true,
+ TLS_RSA_WITH_3DES_EDE_CBC_SHA: true,
+ },
+ },
+ lockedPrefs: {
+ "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256": false,
+ "security.ssl3.ecdhe_ecdsa_aes_128_gcm_sha256": false,
+ "security.ssl3.ecdhe_ecdsa_chacha20_poly1305_sha256": false,
+ "security.ssl3.ecdhe_rsa_chacha20_poly1305_sha256": false,
+ "security.ssl3.ecdhe_ecdsa_aes_256_gcm_sha384": false,
+ "security.ssl3.ecdhe_rsa_aes_256_gcm_sha384": false,
+ "security.ssl3.ecdhe_rsa_aes_128_sha": false,
+ "security.ssl3.ecdhe_ecdsa_aes_128_sha": false,
+ "security.ssl3.ecdhe_rsa_aes_256_sha": false,
+ "security.ssl3.ecdhe_ecdsa_aes_256_sha": false,
+ "security.ssl3.dhe_rsa_aes_128_sha": false,
+ "security.ssl3.dhe_rsa_aes_256_sha": false,
+ "security.ssl3.rsa_aes_128_gcm_sha256": false,
+ "security.ssl3.rsa_aes_256_gcm_sha384": false,
+ "security.ssl3.rsa_aes_128_sha": false,
+ "security.ssl3.rsa_aes_256_sha": false,
+ "security.ssl3.deprecated.rsa_des_ede3_sha": false,
+ },
+ },
+];
+
+add_task(async function test_policy_simple_prefs() {
+ for (let test of POLICIES_TESTS) {
+ await setupPolicyEngineWithJson({
+ policies: test.policies,
+ });
+
+ info("Checking policy: " + Object.keys(test.policies)[0]);
+
+ for (let [prefName, prefValue] of Object.entries(test.lockedPrefs || {})) {
+ checkLockedPref(prefName, prefValue);
+ }
+
+ for (let [prefName, prefValue] of Object.entries(
+ test.unlockedPrefs || {}
+ )) {
+ checkUnlockedPref(prefName, prefValue);
+ }
+ }
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js
new file mode 100644
index 0000000000..0d246c850c
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function checkArrayIsSorted(array, msg) {
+ let sorted = true;
+ let sortedArray = array.slice().sort(function (a, b) {
+ return a.localeCompare(b);
+ });
+
+ for (let i = 0; i < array.length; i++) {
+ if (array[i] != sortedArray[i]) {
+ sorted = false;
+ break;
+ }
+ }
+ ok(sorted, msg);
+}
+
+add_task(async function test_policies_sorted() {
+ let { schema } = ChromeUtils.importESModule(
+ "resource:///modules/policies/schema.sys.mjs"
+ );
+ let { Policies } = ChromeUtils.importESModule(
+ "resource:///modules/policies/Policies.sys.mjs"
+ );
+
+ checkArrayIsSorted(
+ Object.keys(schema.properties),
+ "policies-schema.json is alphabetically sorted."
+ );
+ checkArrayIsSorted(
+ Object.keys(Policies),
+ "Policies.jsm is alphabetically sorted."
+ );
+});
+
+add_task(async function check_naming_conventions() {
+ let { schema } = ChromeUtils.importESModule(
+ "resource:///modules/policies/schema.sys.mjs"
+ );
+ equal(
+ Object.keys(schema.properties).some(key => key.includes("__")),
+ false,
+ "Can't use __ in a policy name as it's used as a delimiter"
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/xpcshell.ini b/comm/mail/components/enterprisepolicies/tests/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..ab47cca7bd
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+firefox-appdir = browser
+head = head.js
+
+[test_3rdparty.js]
+[test_appupdatepin.js]
+[test_appupdateurl.js]
+[test_bug1658259.js]
+[test_clear_blocked_cookies.js]
+[test_macosparser_unflatten.js]
+skip-if = os != 'mac'
+[test_policy_search_engine.js]
+[test_preferences.js]
+[test_proxy.js]
+[test_requestedlocales.js]
+[test_runOnce_helper.js]
+[test_simple_pref_policies.js]
+[test_sorted_alphabetically.js]
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).
diff --git a/comm/mail/components/im/IMIncomingServer.sys.mjs b/comm/mail/components/im/IMIncomingServer.sys.mjs
new file mode 100644
index 0000000000..aea800cec7
--- /dev/null
+++ b/comm/mail/components/im/IMIncomingServer.sys.mjs
@@ -0,0 +1,359 @@
+/* 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 { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+export function IMIncomingServer() {}
+
+IMIncomingServer.prototype = {
+ get wrappedJSObject() {
+ return this;
+ },
+ _imAccount: null,
+ get imAccount() {
+ if (this._imAccount) {
+ return this._imAccount;
+ }
+
+ let id = this.getCharValue("imAccount");
+ if (!id) {
+ return null;
+ }
+ IMServices.core.init();
+ return (this._imAccount = IMServices.accounts.getAccountById(id));
+ },
+ set imAccount(aImAccount) {
+ this._imAccount = aImAccount;
+ this.setCharValue("imAccount", aImAccount.id);
+ },
+ _prefBranch: null,
+ valid: true,
+ hidden: false,
+ get offlineSupportLevel() {
+ return 0;
+ },
+ get supportsDiskSpace() {
+ return false;
+ },
+ _key: "",
+ get key() {
+ return this._key;
+ },
+ set key(aKey) {
+ this._key = aKey;
+ this._prefBranch = Services.prefs.getBranch("mail.server." + aKey + ".");
+ },
+ equals(aServer) {
+ return "wrappedJSObject" in aServer && aServer.wrappedJSObject == this;
+ },
+
+ clearAllValues() {
+ IMServices.accounts.deleteAccount(this.imAccount.id);
+ for (let prefName of this._prefBranch.getChildList("")) {
+ this._prefBranch.clearUserPref(prefName);
+ }
+ delete this._prefBranch;
+ delete this._imAccount;
+ },
+
+ // Returns the directory where the account would have its data stored.
+ // There are currently conversation logs only.
+ // It may not exist yet.
+ // This is used in account removal dialog and should return the same path
+ // that the removeFiles() function deletes.
+ get localPath() {
+ let logPath = IMServices.logs.getLogFolderPathForAccount(this.imAccount);
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(logPath);
+ return file;
+ },
+
+ // Removes files created by this account.
+ removeFiles() {
+ IMServices.logs.deleteLogFolderForAccount(this.imAccount);
+ },
+
+ // called by nsMsgAccountManager while deleting an account:
+ forgetSessionPassword() {},
+
+ forgetPassword() {
+ // Password is cleared in imAccount.remove()
+ // TODO: this may need to be implemented here as a separate function
+ // once IM accounts support changing username/hostname.
+ },
+
+ // Shown in the "Remove Account" confirm prompt.
+ get prettyName() {
+ let protocol = this.imAccount.protocol.name || this.imAccount.protocol.id;
+ return protocol + " - " + this.imAccount.name;
+ },
+
+ // XXX Flo: I don't think constructedPrettyName is visible in the UI
+ get constructedPrettyName() {
+ return "constructedPrettyName FIXME";
+ },
+
+ port: -1,
+ accountManagerChrome: "am-im.xhtml",
+
+ // FIXME need a new imIIncomingService iface + classinfo for these 3 properties :(
+ get password() {
+ return this.imAccount.password;
+ },
+ set password(aPassword) {
+ this.imAccount.password = aPassword;
+ },
+ get alias() {
+ return this.imAccount.alias;
+ },
+ set alias(aAlias) {
+ this.imAccount.alias = aAlias;
+ },
+ get autojoin() {
+ try {
+ let prefName = "messenger.account." + this.imAccount.id + ".autoJoin";
+ return Services.prefs.getStringPref(prefName);
+ } catch (e) {
+ return "";
+ }
+ },
+ set autojoin(aAutoJoin) {
+ let prefName = "messenger.account." + this.imAccount.id + ".autoJoin";
+ Services.prefs.setStringPref(prefName, aAutoJoin);
+ },
+ get autologin() {
+ try {
+ let prefName = "messenger.account." + this.imAccount.id + ".autoLogin";
+ return Services.prefs.getBoolPref(prefName);
+ } catch (e) {
+ return false;
+ }
+ },
+ set autologin(aAutoLogin) {
+ let prefName = "messenger.account." + this.imAccount.id + ".autoLogin";
+ Services.prefs.setBoolPref(prefName, aAutoLogin);
+ },
+
+ // This is used for user-visible advanced preferences.
+ setUnicharValue(aPrefName, aValue) {
+ if (aPrefName == "autojoin") {
+ this.autojoin = aValue;
+ } else if (aPrefName == "alias") {
+ this.alias = aValue;
+ } else if (aPrefName == "password") {
+ this.password = aValue;
+ } else {
+ this.imAccount.setString(aPrefName, aValue);
+ }
+ },
+ getUnicharValue(aPrefName) {
+ if (aPrefName == "autojoin") {
+ return this.autojoin;
+ }
+ if (aPrefName == "alias") {
+ return this.alias;
+ }
+ if (aPrefName == "password") {
+ return this.password;
+ }
+
+ try {
+ let prefName =
+ "messenger.account." + this.imAccount.id + ".options." + aPrefName;
+ return Services.prefs.getStringPref(prefName);
+ } catch (x) {
+ return this._getDefault(aPrefName);
+ }
+ },
+ setBoolValue(aPrefName, aValue) {
+ if (aPrefName == "autologin") {
+ this.autologin = aValue;
+ }
+ this.imAccount.setBool(aPrefName, aValue);
+ },
+ getBoolValue(aPrefName) {
+ if (aPrefName == "autologin") {
+ return this.autologin;
+ }
+ try {
+ let prefName =
+ "messenger.account." + this.imAccount.id + ".options." + aPrefName;
+ return Services.prefs.getBoolPref(prefName);
+ } catch (x) {
+ return this._getDefault(aPrefName);
+ }
+ },
+ setIntValue(aPrefName, aValue) {
+ this.imAccount.setInt(aPrefName, aValue);
+ },
+ getIntValue(aPrefName) {
+ try {
+ let prefName =
+ "messenger.account." + this.imAccount.id + ".options." + aPrefName;
+ return Services.prefs.getIntPref(prefName);
+ } catch (x) {
+ return this._getDefault(aPrefName);
+ }
+ },
+ _defaultOptionValues: null,
+ _getDefault(aPrefName) {
+ if (aPrefName == "otrVerifyNudge") {
+ return Services.prefs.getBoolPref("chat.otr.default.verifyNudge");
+ }
+ if (aPrefName == "otrRequireEncryption") {
+ return Services.prefs.getBoolPref("chat.otr.default.requireEncryption");
+ }
+ if (aPrefName == "otrAllowMsgLog") {
+ return Services.prefs.getBoolPref("chat.otr.default.allowMsgLog");
+ }
+ if (this._defaultOptionValues) {
+ return this._defaultOptionValues[aPrefName];
+ }
+
+ this._defaultOptionValues = {};
+ for (let opt of this.imAccount.protocol.getOptions()) {
+ let type = opt.type;
+ if (type == Ci.prplIPref.typeBool) {
+ this._defaultOptionValues[opt.name] = opt.getBool();
+ } else if (type == Ci.prplIPref.typeInt) {
+ this._defaultOptionValues[opt.name] = opt.getInt();
+ } else if (type == Ci.prplIPref.typeString) {
+ this._defaultOptionValues[opt.name] = opt.getString();
+ } else if (type == Ci.prplIPref.typeList) {
+ this._defaultOptionValues[opt.name] = opt.getListDefault();
+ }
+ }
+ return this._defaultOptionValues[aPrefName];
+ },
+
+ // the "Char" type will be used only for "imAccount" and internally.
+ setCharValue(aPrefName, aValue) {
+ this._prefBranch.setCharPref(aPrefName, aValue);
+ },
+ getCharValue(aPrefName) {
+ try {
+ return this._prefBranch.getCharPref(aPrefName);
+ } catch (x) {
+ return "";
+ }
+ },
+
+ get type() {
+ return this._prefBranch.getCharPref("type");
+ },
+ set type(aType) {
+ this._prefBranch.setCharPref("type", aType);
+ },
+
+ get username() {
+ return this._prefBranch.getCharPref("userName");
+ },
+ set username(aUsername) {
+ if (!aUsername) {
+ // nsMsgAccountManager::GetIncomingServer expects the pref to
+ // be named userName but some early test versions with IM had
+ // the pref named username.
+ return;
+ }
+ this._prefBranch.setCharPref("userName", aUsername);
+ },
+
+ get hostName() {
+ return this._prefBranch.getCharPref("hostname");
+ },
+ set hostName(aHostName) {
+ this._prefBranch.setCharPref("hostname", aHostName);
+ },
+
+ writeToFolderCache() {},
+ closeCachedConnections() {},
+
+ // Shutdown the server instance so at least disconnect from the server.
+ shutdown() {
+ // Ensure this account has not been destroyed already.
+ if (this.imAccount.prplAccount) {
+ this.imAccount.disconnect();
+ }
+ },
+
+ setFilterList() {},
+
+ get canBeDefaultServer() {
+ return false;
+ },
+
+ // AccountManager.js verifies that spamSettings is non-null before
+ // using the initialize method, but we can't just use a null value
+ // because that would crash nsMsgPurgeService::PerformPurge which
+ // only verifies the nsresult return value of the spamSettings
+ // getter before accessing the level property.
+ get spamSettings() {
+ return {
+ level: 0,
+ initialize(aServer) {},
+ QueryInterface: ChromeUtils.generateQI(["nsISpamSettings"]),
+ };
+ },
+
+ // nsMsgDBFolder.cpp crashes in HandleAutoCompactEvent if this doesn't exist:
+ msgStore: {
+ supportsCompaction: false,
+ },
+
+ get serverURI() {
+ return "im://" + this.imAccount.protocol.id + "/" + this.imAccount.name;
+ },
+ _rootFolder: null,
+ get rootMsgFolder() {
+ return this.rootFolder;
+ },
+ get rootFolder() {
+ if (this._rootFolder) {
+ return this._rootFolder;
+ }
+
+ return (this._rootFolder = {
+ isServer: true,
+ server: this,
+ get URI() {
+ return this.server.serverURI;
+ },
+ get prettyName() {
+ return this.server.prettyName;
+ }, // used in the account manager tree
+ get name() {
+ return this.server.prettyName + " name";
+ }, // never displayed?
+ // used in the folder pane tree, if we don't hide the IM accounts:
+ get abbreviatedName() {
+ return this.server.prettyName + "abbreviatedName";
+ },
+ AddFolderListener() {},
+ RemoveFolderListener() {},
+ descendants: [],
+ getFlag: () => false,
+ getFolderWithFlags: aFlags => null,
+ getFoldersWithFlags: aFlags => [],
+ get subFolders() {
+ return [];
+ },
+ getStringProperty: aPropertyName => "",
+ getNumUnread: aDeep => 0,
+ Shutdown() {},
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgFolder"]),
+ });
+ },
+
+ get sortOrder() {
+ return 300000000;
+ },
+
+ get protocolInfo() {
+ return Cc["@mozilla.org/messenger/protocol/info;1?type=im"].getService(
+ Ci.nsIMsgProtocolInfo
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgIncomingServer"]),
+};
diff --git a/comm/mail/components/im/IMProtocolInfo.sys.mjs b/comm/mail/components/im/IMProtocolInfo.sys.mjs
new file mode 100644
index 0000000000..975a3a4a0a
--- /dev/null
+++ b/comm/mail/components/im/IMProtocolInfo.sys.mjs
@@ -0,0 +1,49 @@
+/* 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/. */
+
+export function IMProtocolInfo() {}
+
+IMProtocolInfo.prototype = {
+ defaultLocalPath: null,
+ get serverIID() {
+ return null;
+ },
+ get requiresUsername() {
+ return true;
+ },
+ get preflightPrettyNameWithEmailAddress() {
+ return false;
+ },
+ get canDelete() {
+ return true;
+ },
+ // Even though IM accounts can login at startup, canLoginAtStartUp
+ // should be false as it's used to decide if new messages should be
+ // fetched at startup and that concept of message doesn't apply to
+ // IM accounts.
+ get canLoginAtStartUp() {
+ return false;
+ },
+ get canDuplicate() {
+ return false;
+ },
+ getDefaultServerPort: () => 0,
+ get canGetMessages() {
+ return false;
+ },
+ get canGetIncomingMessages() {
+ return false;
+ },
+ get defaultDoBiff() {
+ return false;
+ },
+ get showComposeMsgLink() {
+ return false;
+ },
+ get foldersCreatedAsync() {
+ return false;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgProtocolInfo"]),
+};
diff --git a/comm/mail/components/im/all-im.js b/comm/mail/components/im/all-im.js
new file mode 100644
index 0000000000..a2ca249f08
--- /dev/null
+++ b/comm/mail/components/im/all-im.js
@@ -0,0 +1,14 @@
+/* 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/. */
+
+pref("messenger.options.messagesStyle.theme", "mail");
+pref("messenger.options.emoticonsTheme", "messenger-emoticons");
+pref("messenger.options.getAttentionOnNewMessages", false);
+pref("messenger.conversations.textbox.autoResize", true);
+pref("messenger.conversations.doubleClickToReply", true);
+pref("messenger.conversations.showNicks", true);
+pref("purple.debug.loglevel", 3);
+
+// Limit the number of gloda IM results
+pref("mailnews.database.global.search.im.limit", 1000);
diff --git a/comm/mail/components/im/components.conf b/comm/mail/components/im/components.conf
new file mode 100644
index 0000000000..2d379db965
--- /dev/null
+++ b/comm/mail/components/im/components.conf
@@ -0,0 +1,20 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{13118758-dad2-418c-a03d-1acbfed0cd01}',
+ 'contract_ids': ['@mozilla.org/messenger/protocol/info;1?type=im'],
+ 'esModule': 'resource:///modules/IMProtocolInfo.sys.mjs',
+ 'constructor': 'IMProtocolInfo',
+ },
+ {
+ 'cid': '{9dd7f36b-5960-4f0a-8789-f5f516bd083d}',
+ 'contract_ids': ['@mozilla.org/messenger/server;1?type=im'],
+ 'esModule': 'resource:///modules/IMIncomingServer.sys.mjs',
+ 'constructor': 'IMIncomingServer',
+ },
+]
diff --git a/comm/mail/components/im/content/.eslintrc.js b/comm/mail/components/im/content/.eslintrc.js
new file mode 100644
index 0000000000..c862f88e3e
--- /dev/null
+++ b/comm/mail/components/im/content/.eslintrc.js
@@ -0,0 +1,22 @@
+"use strict";
+
+module.exports = {
+ overrides: [
+ {
+ files: ["imconversation.xml"],
+ globals: {
+ AppConstants: true,
+ chatHandler: true,
+ gChatTab: true,
+ Services: true,
+
+ // chat/modules/imStatusUtils.jsm
+ Status: true,
+
+ // chat/modules/imTextboxUtils.jsm
+ MessageFormat: true,
+ TextboxSize: true,
+ },
+ },
+ ],
+};
diff --git a/comm/mail/components/im/content/addbuddy.js b/comm/mail/components/im/content/addbuddy.js
new file mode 100644
index 0000000000..f5b3eb7deb
--- /dev/null
+++ b/comm/mail/components/im/content/addbuddy.js
@@ -0,0 +1,58 @@
+/* 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 { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+var addBuddy = {
+ onload() {
+ let accountList = document.getElementById("accountlist");
+ for (let acc of IMServices.accounts.getAccounts()) {
+ if (!acc.connected) {
+ continue;
+ }
+ let proto = acc.protocol;
+ let item = accountList.appendItem(acc.name, acc.id, proto.name);
+ item.setAttribute("image", ChatIcons.getProtocolIconURI(proto));
+ item.setAttribute("class", "menuitem-iconic");
+ }
+ if (!accountList.itemCount) {
+ document
+ .getElementById("addBuddyDialog")
+ .querySelector("dialog")
+ .cancelDialog();
+ throw new Error("No connected account!");
+ }
+ accountList.selectedIndex = 0;
+ },
+
+ oninput() {
+ document.querySelector("dialog").getButton("accept").disabled =
+ !addBuddy.getValue("name");
+ },
+
+ getValue(aId) {
+ return document.getElementById(aId).value;
+ },
+
+ create() {
+ let account = IMServices.accounts.getAccountById(
+ this.getValue("accountlist")
+ );
+ let group = Services.strings
+ .createBundle("chrome://messenger/locale/chat.properties")
+ .GetStringFromName("defaultGroup");
+ account.addBuddy(IMServices.tags.createTag(group), this.getValue("name"));
+ },
+};
+
+document.addEventListener("dialogaccept", addBuddy.create.bind(addBuddy));
+
+window.addEventListener("load", event => {
+ addBuddy.onload();
+});
diff --git a/comm/mail/components/im/content/addbuddy.xhtml b/comm/mail/components/im/content/addbuddy.xhtml
new file mode 100644
index 0000000000..5c4fbfbf94
--- /dev/null
+++ b/comm/mail/components/im/content/addbuddy.xhtml
@@ -0,0 +1,59 @@
+<?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://messenger/skin/imMenulist.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+
+<!DOCTYPE html SYSTEM "chrome://messenger/locale/addbuddy.dtd">
+
+<html
+ id="addBuddyDialog"
+ 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"
+ scrolling="false"
+>
+ <head>
+ <title>&addBuddyWindow.title;</title>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/addbuddy.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog buttons="accept,cancel" buttondisabledaccept="true">
+ <hbox>
+ <vbox id="nameBox">
+ <hbox align="center" flex="1">
+ <label value="&name.label;" control="name" />
+ </hbox>
+ <hbox align="center" flex="1">
+ <label value="&account.label;" control="accountlist" />
+ </hbox>
+ </vbox>
+ <vbox id="accountBox">
+ <html:input
+ id="name"
+ type="text"
+ class="input-inline"
+ oninput="addBuddy.oninput()"
+ />
+ <menulist id="accountlist" />
+ </vbox>
+ </hbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/mail/components/im/content/am-im.js b/comm/mail/components/im/content/am-im.js
new file mode 100644
index 0000000000..494e0aa1fd
--- /dev/null
+++ b/comm/mail/components/im/content/am-im.js
@@ -0,0 +1,291 @@
+/* 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/. */
+
+// chat/content/imAccountOptionsHelper.js
+/* globals accountOptionsHelper */
+
+const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs",
+ OTR: "resource:///modules/OTR.sys.mjs",
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+});
+
+var autoJoinPref = "autoJoin";
+
+function onPreInit(aAccount, aAccountValue) {
+ account.init(aAccount.incomingServer.wrappedJSObject.imAccount);
+}
+
+function onBeforeUnload() {
+ if (account.encryptionObserver) {
+ Services.obs.removeObserver(
+ account.encryptionObserver,
+ "account-sessions-changed"
+ );
+ Services.obs.removeObserver(
+ account.encryptionObserver,
+ "account-encryption-status-changed"
+ );
+ }
+}
+
+var account = {
+ async init(aAccount) {
+ let title = document.querySelector(".dialogheader .dialogheader-title");
+ let defaultTitle = title.getAttribute("defaultTitle");
+ let titleValue;
+
+ if (aAccount.name) {
+ titleValue = defaultTitle + " - <" + aAccount.name + ">";
+ } else {
+ titleValue = defaultTitle;
+ }
+
+ title.setAttribute("value", titleValue);
+ document.title = titleValue;
+
+ this.account = aAccount;
+ this.proto = this.account.protocol;
+ document.getElementById("accountName").value = this.account.name;
+ document.getElementById("protocolName").value =
+ this.proto.name || this.proto.id;
+ document.getElementById("protocolIcon").src = ChatIcons.getProtocolIconURI(
+ this.proto,
+ 48
+ );
+
+ let password = document.getElementById("server.password");
+ let passwordBox = document.getElementById("passwordBox");
+ if (this.proto.noPassword) {
+ passwordBox.hidden = true;
+ password.removeAttribute("wsm_persist");
+ } else {
+ passwordBox.hidden = false;
+ try {
+ // Should we force layout here to ensure password.value works?
+ // Will throw if we don't have a protocol plugin for the account.
+ password.value = this.account.password;
+ password.setAttribute("wsm_persist", "true");
+ } catch (e) {
+ passwordBox.hidden = true;
+ password.removeAttribute("wsm_persist");
+ }
+ }
+
+ document.getElementById("server.alias").value = this.account.alias;
+
+ if (ChatEncryption.canConfigureEncryption(this.account.protocol)) {
+ document.getElementById("imTabEncryption").hidden = false;
+ document.querySelector(".otr-settings").hidden = !OTRUI.enabled;
+ document.getElementById("server.otrAllowMsgLog").value =
+ this.account.otrAllowMsgLog;
+ if (OTRUI.enabled) {
+ document.getElementById("server.otrVerifyNudge").value =
+ this.account.otrVerifyNudge;
+ document.getElementById("server.otrRequireEncryption").value =
+ this.account.otrRequireEncryption;
+
+ let fpa = this.account.normalizedName;
+ let fpp = this.account.protocol.normalizedName;
+ let fp = OTR.privateKeyFingerprint(fpa, fpp);
+ if (!fp) {
+ fp = await document.l10n.formatValue("otr-not-yet-available");
+ }
+ document.getElementById("otrFingerprint").value = fp;
+ }
+ document.querySelector(".chat-encryption-settings").hidden =
+ !this.account.protocol.canEncrypt;
+ if (this.account.protocol.canEncrypt) {
+ document.l10n.setAttributes(
+ document.getElementById("chat-encryption-description"),
+ "chat-encryption-description",
+ {
+ protocol: this.proto.name,
+ }
+ );
+ this.buildEncryptionStatus();
+ this.buildAccountSessionsList();
+ this.encryptionObserver = {
+ observe: (subject, topic) => {
+ if (
+ topic === "account-sessions-changed" &&
+ subject.id === this.account.id
+ ) {
+ this.buildAccountSessionsList();
+ } else if (
+ topic === "account-encryption-status-changed" &&
+ subject.id === this.account.id
+ ) {
+ this.buildEncryptionStatus();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ Services.obs.addObserver(
+ this.encryptionObserver,
+ "account-sessions-changed",
+ true
+ );
+ Services.obs.addObserver(
+ this.encryptionObserver,
+ "account-encryption-status-changed",
+ true
+ );
+ }
+ }
+
+ let protoId = this.proto.id;
+ let canAutoJoin =
+ protoId == "prpl-irc" ||
+ protoId == "prpl-jabber" ||
+ protoId == "prpl-gtalk";
+ document.getElementById("autojoinBox").hidden = !canAutoJoin;
+ let autojoin = document.getElementById("server.autojoin");
+ if (canAutoJoin) {
+ autojoin.setAttribute("wsm_persist", "true");
+ } else {
+ autojoin.removeAttribute("wsm_persist");
+ }
+
+ this.prefs = Services.prefs.getBranch(
+ "messenger.account." + this.account.id + ".options."
+ );
+ this.populateProtoSpecificBox();
+ },
+
+ encryptionObserver: null,
+ buildEncryptionStatus() {
+ const encryptionStatus = document.querySelector(".chat-encryption-status");
+ if (this.account.encryptionStatus.length) {
+ encryptionStatus.replaceChildren(
+ ...this.account.encryptionStatus.map(status => {
+ const item = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "li"
+ );
+ item.textContent = status;
+ return item;
+ })
+ );
+ } else {
+ const placeholder = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "li"
+ );
+ document.l10n.setAttributes(placeholder, "chat-encryption-placeholder");
+ encryptionStatus.replaceChildren(placeholder);
+ }
+ },
+ buildAccountSessionsList() {
+ const sessions = this.account.getSessions();
+ document.querySelector(".chat-encryption-sessions-container").hidden =
+ sessions.length === 0;
+ const sessionList = document.querySelector(".chat-encryption-sessions");
+ sessionList.replaceChildren(
+ ...sessions.map(session => {
+ const button = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "button"
+ );
+ document.l10n.setAttributes(
+ button,
+ "chat-encryption-session-" + (session.trusted ? "trusted" : "verify")
+ );
+ button.disabled = session.trusted;
+ if (!button.disabled) {
+ button.addEventListener("click", async () => {
+ try {
+ const sessionInfo = await session.verify();
+ parent.gSubDialog.open(
+ "chrome://messenger/content/chat/verify.xhtml",
+ { features: "resizable=no" },
+ sessionInfo
+ );
+ } catch (error) {
+ // Verification was probably aborted by the other side.
+ this.account.prplAccount.wrappedJSObject.WARN(error);
+ }
+ });
+ }
+ const sessionLabel = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "span"
+ );
+ sessionLabel.textContent = session.id;
+ const row = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "li"
+ );
+ row.append(sessionLabel, button);
+ row.classList.toggle("chat-current-session", session.currentSession);
+ return row;
+ })
+ );
+ },
+
+ populateProtoSpecificBox() {
+ let attributes = {};
+ attributes[Ci.prplIPref.typeBool] = [
+ { name: "wsm_persist", value: "true" },
+ { name: "preftype", value: "bool" },
+ { name: "genericattr", value: "true" },
+ ];
+ attributes[Ci.prplIPref.typeInt] = [
+ { name: "wsm_persist", value: "true" },
+ { name: "preftype", value: "int" },
+ { name: "genericattr", value: "true" },
+ ];
+ attributes[Ci.prplIPref.typeString] = attributes[Ci.prplIPref.typeList] = [
+ { name: "wsm_persist", value: "true" },
+ { name: "preftype", value: "wstring" },
+ { name: "genericattr", value: "true" },
+ ];
+ let haveOptions = accountOptionsHelper.addOptions(
+ "server.",
+ this.proto.getOptions(),
+ attributes
+ );
+ let advanced = document.getElementById("advanced");
+ if (advanced.hidden && haveOptions) {
+ advanced.hidden = false;
+ // Force textbox XBL binding attachment by forcing layout,
+ // otherwise setFormElementValue from AccountManager.js sets
+ // properties that don't exist when restoring values.
+ document.getElementById("protoSpecific").getBoundingClientRect();
+ } else if (!haveOptions) {
+ advanced.hidden = true;
+ }
+ let inputElements = document.querySelectorAll(
+ "#protoSpecific :is(checkbox, input, menulist)"
+ );
+ // Because the elements are added after the document loaded we have to
+ // notify the parent document that there are prefs to save.
+ for (let input of inputElements) {
+ if (input.localName == "input" || input.localName == "textarea") {
+ input.addEventListener("change", event => {
+ document.dispatchEvent(new CustomEvent("prefchange"));
+ });
+ } else {
+ input.addEventListener("command", event => {
+ document.dispatchEvent(new CustomEvent("prefchange"));
+ });
+ }
+ }
+ },
+
+ viewFingerprintKeys() {
+ let otrAccount = { account: this.account };
+ parent.gSubDialog.open(
+ "chrome://chat/content/otr-finger.xhtml",
+ undefined,
+ otrAccount
+ );
+ },
+};
diff --git a/comm/mail/components/im/content/am-im.xhtml b/comm/mail/components/im/content/am-im.xhtml
new file mode 100644
index 0000000000..5455309da8
--- /dev/null
+++ b/comm/mail/components/im/content/am-im.xhtml
@@ -0,0 +1,235 @@
+<?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://messenger/skin/accountManage.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % imDTD SYSTEM "chrome://messenger/locale/am-im.dtd">
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%imDTD; %brandDTD; ]>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ id="account"
+ title="&accountWindow.title;"
+ buttons="accept,cancel"
+ onload="parent.onPanelLoaded('am-im.xhtml');"
+ onbeforeunload="onBeforeUnload();"
+>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://chat/content/imAccountOptionsHelper.js" />
+ <script src="chrome://messenger/content/am-im.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="messenger/preferences/am-im.ftl" />
+ <html:link rel="localization" href="messenger/otr/am-im-otr.ftl" />
+ </linkset>
+
+ <vbox flex="1" style="overflow: auto; padding: 0"
+ ><vbox id="containerBox" flex="1">
+ <hbox class="dialogheader">
+ <label
+ class="dialogheader-title"
+ defaultTitle="&accountWindow.title;"
+ />
+ </hbox>
+
+ <hbox align="center">
+ <html:img id="protocolIcon" alt="" />
+ <vbox flex="1">
+ <label id="accountName" crop="end" class="header" />
+ <label id="protocolName" class="tip-caption" />
+ </vbox>
+ </hbox>
+
+ <tabbox id="imTabbox" flex="1">
+ <tabs>
+ <tab id="imTabGeneral" label="&account.general;" />
+ <tab
+ id="imTabEncryption"
+ data-l10n-id="account-encryption"
+ hidden="true"
+ />
+ </tabs>
+ <tabpanels flex="1">
+ <tabpanel orient="vertical">
+ <label class="header" data-l10n-id="account-settings-title" />
+ <hbox id="passwordBox" align="baseline" class="input-container">
+ <label
+ value="&account.password;"
+ control="server.password"
+ class="label-inline"
+ />
+ <html:input
+ id="server.password"
+ type="password"
+ preftype="wstring"
+ genericattr="true"
+ class="input-inline"
+ />
+ </hbox>
+ <hbox id="aliasBox" align="baseline" class="input-container">
+ <label
+ value="&account.alias;"
+ control="server.alias"
+ class="label-inline"
+ />
+ <html:input
+ id="server.alias"
+ type="text"
+ preftype="wstring"
+ wsm_persist="true"
+ genericattr="true"
+ class="input-inline"
+ />
+ </hbox>
+ <vbox id="autologinBox">
+ <checkbox
+ id="server.autologin"
+ data-l10n-id="chat-autologin"
+ crop="end"
+ wsm_persist="true"
+ preftype="bool"
+ genericattr="true"
+ />
+ </vbox>
+ <separator class="thin" />
+
+ <vbox id="autojoinBox" hidden="true">
+ <label class="header" data-l10n-id="account-channel-title" />
+ <hbox class="input-container">
+ <label
+ class="label-inline"
+ value="&account.autojoin;"
+ control="server.autojoin"
+ />
+ <html:input
+ id="server.autojoin"
+ type="text"
+ preftype="wstring"
+ genericattr="true"
+ class="input-inline"
+ />
+ </hbox>
+ <separator class="thin" />
+ </vbox>
+ <vbox id="advanced">
+ <label class="header">&account.advanced;</label>
+ <html:div
+ id="protoSpecific"
+ class="grid-block-two-column-fr grid-items-baseline"
+ >
+ </html:div>
+ </vbox>
+ </tabpanel>
+
+ <tabpanel orient="vertical">
+ <html:div>
+ <html:h1 data-l10n-id="chat-encryption-generic" />
+ <separator class="thin" />
+
+ <vbox>
+ <checkbox
+ id="server.otrAllowMsgLog"
+ data-l10n-id="chat-encryption-log"
+ crop="end"
+ wsm_persist="true"
+ preftype="bool"
+ genericattr="true"
+ />
+ </vbox>
+ </html:div>
+ <separator />
+ <html:div class="chat-encryption-settings">
+ <html:h1 data-l10n-id="chat-encryption-label" />
+ <description id="chat-encryption-description" />
+
+ <separator class="thin" />
+
+ <label class="header" data-l10n-id="chat-encryption-status" />
+ <html:div class="indent">
+ <html:ul class="chat-encryption-status">
+ <html:li data-l10n-id="chat-encryption-placeholder" />
+ </html:ul>
+ </html:div>
+
+ <html:div class="chat-encryption-sessions-container">
+ <separator class="thin" />
+ <label class="header" data-l10n-id="chat-encryption-sessions" />
+ <description
+ data-l10n-id="chat-encryption-sessions-description"
+ />
+ <html:div class="indent">
+ <html:ul class="chat-encryption-sessions"></html:ul>
+ </html:div>
+ </html:div>
+ <separator />
+ </html:div>
+ <html:div class="otr-settings">
+ <html:h1 data-l10n-id="account-otr-label" />
+ <description data-l10n-id="account-otr-description2" />
+
+ <separator />
+
+ <vbox>
+ <label class="header" data-l10n-id="otr-settings-title" />
+ <checkbox
+ id="server.otrRequireEncryption"
+ data-l10n-id="otr-require-encryption"
+ crop="end"
+ wsm_persist="true"
+ preftype="bool"
+ genericattr="true"
+ />
+ <html:p
+ id="otrRequireEncryptionInfo"
+ class="option-description"
+ data-l10n-id="otr-require-encryption-info"
+ ></html:p>
+ <checkbox
+ id="server.otrVerifyNudge"
+ data-l10n-id="otr-verify-nudge"
+ crop="end"
+ wsm_persist="true"
+ preftype="bool"
+ genericattr="true"
+ />
+ </vbox>
+
+ <separator />
+
+ <vbox>
+ <label class="header" data-l10n-id="otr-encryption-title" />
+ <label data-l10n-id="otr-encryption-caption" />
+ <separator class="thin" />
+ <hbox align="center">
+ <label data-l10n-id="otr-fingerprint-label" />
+ <hbox class="input-container" flex="1">
+ <html:input
+ id="otrFingerprint"
+ type="text"
+ class="input-inline"
+ readonly="readonly"
+ />
+ </hbox>
+ </hbox>
+ <separator class="thin" />
+ <hbox pack="end">
+ <button
+ id="viewFingerprintButton"
+ data-l10n-id="view-fingerprint-button"
+ oncommand="account.viewFingerprintKeys();"
+ />
+ </hbox>
+ </vbox>
+ </html:div>
+ </tabpanel>
+ </tabpanels>
+ </tabbox> </vbox
+ ></vbox>
+</window>
diff --git a/comm/mail/components/im/content/chat-contact.js b/comm/mail/components/im/content/chat-contact.js
new file mode 100644
index 0000000000..d3e9baf974
--- /dev/null
+++ b/comm/mail/components/im/content/chat-contact.js
@@ -0,0 +1,282 @@
+/* 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 MozXULElement, MozElements, Status, chatHandler */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+ );
+ const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+
+ /**
+ * The MozChatContactRichlistitem widget displays contact information about user under
+ * chat-groups, online contacts and offline contacts: i.e. icon and username.
+ * On double clicking the element, it gets moved into the conversations.
+ *
+ * @augments {MozElements.MozRichlistitem}
+ */
+ class MozChatContactRichlistitem extends MozElements.MozRichlistitem {
+ static get inheritedAttributes() {
+ return {
+ ".box-line": "selected",
+ ".contactDisplayName": "value=displayname",
+ ".contactDisplayNameInput": "value=displayname",
+ ".contactStatusText": "value=statusTextWithDash",
+ };
+ }
+
+ static get markup() {
+ return `
+ <vbox class="box-line"></vbox>
+ <stack class="prplBuddyIcon">
+ <html:img class="protoIcon" alt="" />
+ <html:img class="smallStatusIcon" />
+ </stack>
+ <hbox flex="1" class="contact-hbox">
+ <stack>
+ <label crop="end"
+ class="contactDisplayName blistDisplayName">
+ </label>
+ <html:input type="text"
+ class="contactDisplayNameInput"
+ hidden="hidden"/>
+ </stack>
+ <label crop="end"
+ style="flex: 100000 100000;"
+ class="contactStatusText">
+ </label>
+ <button class="startChatBubble"
+ tooltiptext="&openConversationButton.tooltip;">
+ </button>
+ </hbox>
+ `;
+ }
+
+ static get entities() {
+ return ["chrome://messenger/locale/chat.dtd"];
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "chat-contact-richlistitem");
+
+ this.addEventListener("blur", event => {
+ if (!this.hasAttribute("aliasing")) {
+ return;
+ }
+
+ if (Services.focus.activeWindow == document.defaultView) {
+ this.finishAliasing(true);
+ }
+ });
+
+ this.addEventListener("mousedown", event => {
+ if (
+ !this.hasAttribute("aliasing") &&
+ this.canOpenConversation() &&
+ event.target.classList.contains("startChatBubble")
+ ) {
+ this.openConversation();
+ event.preventDefault();
+ }
+ });
+
+ this.addEventListener("click", event => {
+ if (
+ !this.hasAttribute("aliasing") &&
+ this.canOpenConversation() &&
+ event.detail == 2
+ ) {
+ this.openConversation();
+ }
+ });
+
+ this.parentNode.addEventListener("mousedown", event => {
+ event.preventDefault();
+ });
+
+ // @implements {nsIObserver}
+ this.observer = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+ observe: function (subject, topic, data) {
+ if (
+ topic == "contact-preferred-buddy-changed" ||
+ topic == "contact-display-name-changed" ||
+ topic == "contact-status-changed"
+ ) {
+ this.update();
+ }
+ if (
+ topic == "contact-availability-changed" ||
+ topic == "contact-display-name-changed"
+ ) {
+ this.group.updateContactPosition(subject);
+ }
+ }.bind(this),
+ };
+
+ this.appendChild(this.constructor.fragment);
+
+ this.initializeAttributeInheritance();
+ }
+
+ get displayName() {
+ return this.contact.displayName;
+ }
+
+ update() {
+ this.setAttribute("displayname", this.contact.displayName);
+
+ let statusText = this.contact.statusText;
+ if (statusText) {
+ statusText = " - " + statusText;
+ }
+ this.setAttribute("statusTextWithDash", statusText);
+ let statusType = this.contact.statusType;
+
+ let statusIcon = this.querySelector(".smallStatusIcon");
+ let statusName = Status.toAttribute(statusType);
+ statusIcon.setAttribute("src", ChatIcons.getStatusIconURI(statusName));
+ statusIcon.setAttribute("alt", Status.toLabel(statusType));
+
+ if (this.contact.canSendMessage) {
+ this.setAttribute("cansend", "true");
+ } else {
+ this.removeAttribute("cansend");
+ }
+
+ let protoIcon = this.querySelector(".protoIcon");
+ protoIcon.setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(this.contact.preferredBuddy.protocol)
+ );
+ ChatIcons.setProtocolIconOpacity(protoIcon, statusName);
+ }
+
+ build(contact) {
+ this.contact = contact;
+ this.contact.addObserver(this.observer);
+ this.update();
+ }
+
+ destroy() {
+ this.contact.removeObserver(this.observer);
+ delete this.contact;
+ this.remove();
+ }
+
+ startAliasing() {
+ if (this.hasAttribute("aliasing")) {
+ return; // prevent re-entry.
+ }
+
+ this.setAttribute("aliasing", "true");
+ let input = this.querySelector(".contactDisplayNameInput");
+ let label = this.querySelector(".contactDisplayName");
+ input.removeAttribute("hidden");
+ label.setAttribute("hidden", "true");
+ input.focus();
+
+ this._inputBlurListener = function (event) {
+ this.finishAliasing(true);
+ }.bind(this);
+ input.addEventListener("blur", this._inputBlurListener);
+
+ // Some keys (home/end for example) can make the selected item
+ // of the richlistbox change without producing a blur event on
+ // our textbox. Make sure we watch richlistbox selection changes.
+ this._parentSelectListener = function (event) {
+ if (event.target == this.parentNode) {
+ this.finishAliasing(true);
+ }
+ }.bind(this);
+ this.parentNode.addEventListener("select", this._parentSelectListener);
+ }
+
+ finishAliasing(save) {
+ // Cache the parentNode because when we change the contact alias, we
+ // trigger a re-order (and a removeContact call), which sets
+ // this.parentNode to undefined.
+ let listbox = this.parentNode;
+ let input = this.querySelector(".contactDisplayNameInput");
+ let label = this.querySelector(".contactDisplayName");
+ input.setAttribute("hidden", "hidden");
+ label.removeAttribute("hidden");
+ if (save) {
+ this.contact.alias = input.value;
+ }
+ this.removeAttribute("aliasing");
+ listbox.removeEventListener("select", this._parentSelectListener);
+ input.removeEventListener("blur", this._inputBlurListener);
+ delete this._parentSelectListener;
+ listbox.focus();
+ }
+
+ deleteContact() {
+ this.contact.remove();
+ }
+
+ canOpenConversation() {
+ return this.contact.canSendMessage;
+ }
+
+ openConversation() {
+ let prplConv = this.contact.createConversation();
+ let uiConv = IMServices.conversations.getUIConversation(prplConv);
+ chatHandler.focusConversation(uiConv);
+ }
+
+ keyPress(event) {
+ switch (event.keyCode) {
+ // If Enter or Return is pressed, open a new conversation
+ case event.DOM_VK_RETURN:
+ if (this.hasAttribute("aliasing")) {
+ this.finishAliasing(true);
+ } else if (this.canOpenConversation()) {
+ this.openConversation();
+ }
+ break;
+
+ case event.DOM_VK_F2:
+ if (!this.hasAttribute("aliasing")) {
+ this.startAliasing();
+ }
+ break;
+
+ case event.DOM_VK_ESCAPE:
+ if (this.hasAttribute("aliasing")) {
+ this.finishAliasing(false);
+ }
+ break;
+ }
+ }
+ disconnectedCallback() {
+ if (this.contact) {
+ this.contact.removeObserver(this.observer);
+ delete this.contact;
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozChatContactRichlistitem, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define(
+ "chat-contact-richlistitem",
+ MozChatContactRichlistitem,
+ {
+ extends: "richlistitem",
+ }
+ );
+}
diff --git a/comm/mail/components/im/content/chat-conversation-info.js b/comm/mail/components/im/content/chat-conversation-info.js
new file mode 100644
index 0000000000..a8004a4c3f
--- /dev/null
+++ b/comm/mail/components/im/content/chat-conversation-info.js
@@ -0,0 +1,353 @@
+/* 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";
+
+/* globals MozElements MozXULElement chatHandler */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+
+ ChromeUtils.defineESModuleGetters(this, {
+ OTR: "resource:///modules/OTR.sys.mjs",
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+ });
+
+ /**
+ * The MozChatConversationInfo widget displays information about a chat:
+ * e.g. the channel name and topic of an IRC channel, or nick, user image and
+ * status of a conversation partner.
+ * It is typically shown at the top right of the chat UI.
+ *
+ * @augments {MozXULElement}
+ */
+ class MozChatConversationInfo extends MozXULElement {
+ static get inheritedAttributes() {
+ return { ".displayName": "value=displayName" };
+ }
+
+ static get markup() {
+ return `
+ <linkset>
+ <html:link rel="localization" href="messenger/otr/chat.ftl"/>
+ </linkset>
+
+ <html:div class="displayUserAccount">
+ <stack>
+ <html:img class="userIcon" alt="" />
+ <html:img class="statusTypeIcon" alt="" />
+ </stack>
+ <html:div class="nameAndStatusGrid">
+ <description class="displayName" crop="end"></description>
+ <html:img class="protoIcon" alt="" />
+ <html:hr />
+ <description class="statusMessage" crop="end"></description>
+ <!-- FIXME: A keyboard user cannot focus the hidden input, nor
+ - click the above description box in order to reveal it. -->
+ <html:input class="statusMessageInput input-inline"
+ hidden="hidden"/>
+ </html:div>
+ </html:div>
+ <hbox class="encryption-container themeable-brighttext"
+ align="center"
+ hidden="true">
+ <label class="encryption-label"
+ crop="end"
+ data-l10n-id="state-label"
+ flex="1"/>
+ <toolbarbutton id="chatEncryptionButton"
+ mode="dialog"
+ class="encryption-button"
+ type="menu"
+ wantdropmarker="true"
+ label="Insecure"
+ data-l10n-id="start-tooltip">
+ <menupopup class="encryption-menu-popup">
+ <menuitem class="otr-start" data-l10n-id="start-label"
+ oncommand='this.closest("chat-conversation-info").onOtrStartClicked();'/>
+ <menuitem class="otr-end" data-l10n-id="end-label"
+ oncommand='this.closest("chat-conversation-info").onOtrEndClicked();'/>
+ <menuitem class="otr-auth" data-l10n-id="auth-label"
+ oncommand='this.closest("chat-conversation-info").onOtrAuthClicked();'/>
+ <menuitem class="protocol-encrypt" data-l10n-id="start-label"/>
+ </menupopup>
+ </toolbarbutton>
+ </hbox>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+ this.setAttribute("orient", "vertical");
+
+ this.appendChild(this.constructor.fragment);
+
+ this.topicEditable = false;
+ this.editingTopic = false;
+ this.noTopic = false;
+
+ this.topic.addEventListener("click", this.startEditTopic.bind(this));
+
+ this.querySelector(".protocol-encrypt").addEventListener("click", () =>
+ this.initializeEncryption()
+ );
+
+ let encryptionButton = this.querySelector(".encryption-button");
+ encryptionButton.addEventListener(
+ "command",
+ this.encryptionButtonClicked
+ );
+ if (Services.prefs.getBoolPref("chat.otr.enable")) {
+ OTRUI.setNotificationBox(chatHandler.msgNotificationBar);
+ }
+ this.initializeAttributeInheritance();
+ }
+
+ get topic() {
+ return this.querySelector(".statusMessage");
+ }
+
+ get topicInput() {
+ return this.querySelector(".statusMessageInput");
+ }
+
+ finishEditTopic(save) {
+ if (!this.editingTopic) {
+ return;
+ }
+
+ let panel = this.getSelectedPanel();
+ let topic = this.topic;
+ let topicInput = this.topicInput;
+ topic.removeAttribute("hidden");
+ topicInput.hidden = true;
+ if (save) {
+ // apply the new topic only if it is different from the current one
+ if (topicInput.value != topicInput.getAttribute("value")) {
+ panel._conv.topic = topicInput.value;
+ }
+ }
+ this.editingTopic = false;
+
+ topicInput.removeEventListener("keypress", this._topicKeyPress, true);
+ delete this._topicKeyPress;
+ topicInput.removeEventListener("blur", this._topicBlur);
+ delete this._topicBlur;
+
+ // After hiding the input, the focus is on an element that can't receive
+ // keyboard events, so move it to somewhere else.
+ // FIXME: jumping focus should be removed once editing the topic input
+ // becomes accessible to keyboard users.
+ panel.editor.focus();
+ }
+
+ topicKeyPress(event) {
+ switch (event.keyCode) {
+ case event.DOM_VK_RETURN:
+ this.finishEditTopic(true);
+ break;
+
+ case event.DOM_VK_ESCAPE:
+ this.finishEditTopic(false);
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ }
+ }
+
+ topicBlur(event) {
+ if (event.target == this.topicInput) {
+ this.finishEditTopic(true);
+ }
+ }
+
+ startEditTopic() {
+ let topic = this.topic;
+ let topicInput = this.topicInput;
+ if (!this.topicEditable || this.editingTopic) {
+ return;
+ }
+
+ this.editingTopic = true;
+
+ topicInput.hidden = false;
+ topic.setAttribute("hidden", "true");
+ this._topicKeyPress = this.topicKeyPress.bind(this);
+ topicInput.addEventListener("keypress", this._topicKeyPress);
+ this._topicBlur = this.topicBlur.bind(this);
+ topicInput.addEventListener("blur", this._topicBlur);
+ topicInput.getBoundingClientRect();
+ if (this.noTopic) {
+ topicInput.value = "";
+ } else {
+ topicInput.value = topic.value;
+ }
+ topicInput.select();
+ }
+
+ encryptionButtonClicked(aEvent) {
+ aEvent.preventDefault();
+ let encryptionMenu = this.querySelector(".encryption-menu-popup");
+ encryptionMenu.openPopup(encryptionMenu.parentNode, "after_start");
+ }
+
+ onOtrStartClicked() {
+ // check if start-menu-command is disabled, if yes exit
+ let convBinding = this.getSelectedPanel();
+ let uiConv = convBinding._conv;
+ let conv = uiConv.target;
+ let context = OTR.getContext(conv);
+ let bundleId =
+ "alert-" +
+ (context.msgstate === OTR.getMessageState().OTRL_MSGSTATE_ENCRYPTED
+ ? "refresh"
+ : "start");
+ OTRUI.sendSystemAlert(uiConv, conv, bundleId);
+ OTR.sendQueryMsg(conv);
+ }
+
+ onOtrEndClicked() {
+ let convBinding = this.getSelectedPanel();
+ let uiConv = convBinding._conv;
+ let conv = uiConv.target;
+ OTR.disconnect(conv, false);
+ let bundleId = "alert-gone-insecure";
+ OTRUI.sendSystemAlert(uiConv, conv, bundleId);
+ }
+
+ onOtrAuthClicked() {
+ let convBinding = this.getSelectedPanel();
+ let uiConv = convBinding._conv;
+ let conv = uiConv.target;
+ OTRUI.openAuth(window, conv.normalizedName, "start", uiConv);
+ }
+
+ initializeEncryption() {
+ const convBinding = this.getSelectedPanel();
+ const uiConv = convBinding._conv;
+ uiConv.initializeEncryption();
+ }
+
+ getSelectedPanel() {
+ for (let element of document.getElementById("conversationsBox")
+ .children) {
+ if (!element.hidden) {
+ return element;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Sets the shown protocol icon.
+ *
+ * @param {prplIProtocol} protocol - The protocol to show.
+ */
+ setProtocol(protocol) {
+ this.querySelector(".protoIcon").setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(protocol)
+ );
+ }
+
+ /**
+ * Sets the shown user icon.
+ *
+ * @param {string|null} iconURI - The image uri to show, or "" to use the
+ * fallback, or null to hide the icon.
+ * @param {boolean} useFallback - True if the "fallback" icon should be shown
+ * if iconUri isn't provided.
+ */
+ setUserIcon(iconURI, useFallback) {
+ ChatIcons.setUserIconSrc(
+ this.querySelector(".userIcon"),
+ iconURI,
+ useFallback
+ );
+ }
+
+ /**
+ * Sets the shown status icon.
+ *
+ * @param {string} statusName - The name of the status.
+ */
+ setStatusIcon(statusName) {
+ let statusIcon = this.querySelector(".statusTypeIcon");
+ if (statusName === null) {
+ statusIcon.hidden = true;
+ statusIcon.removeAttribute("src");
+ } else {
+ statusIcon.hidden = false;
+ let src = ChatIcons.getStatusIconURI(statusName);
+ if (src) {
+ statusIcon.setAttribute("src", src);
+ } else {
+ /* Unexpected missing icon. */
+ statusIcon.removeAttribute("src");
+ }
+ }
+ }
+
+ /**
+ * Sets the text for the status of a user, or the topic of a chat.
+ *
+ * @param {string} text - The text to display.
+ * @param {boolean} [noTopic=false] - Whether to stylize the status to
+ * indicate the status is some fallback text.
+ */
+ setStatusText(text, noTopic = false) {
+ let statusEl = this.topic;
+
+ statusEl.setAttribute("value", text);
+ statusEl.setAttribute("tooltiptext", text);
+ statusEl.toggleAttribute("noTopic", noTopic);
+ }
+
+ /**
+ * Sets the element to display a user status. The user icon needs to be set
+ * separately with setUserIcon.
+ *
+ * @param {string} statusName - The internal name for the status.
+ * @param {string} statusText - The text to display as the status.
+ */
+ setStatus(statusName, statusText) {
+ this.setStatusIcon(statusName);
+ this.setStatusText(statusText);
+ this.topicEditable = false;
+ }
+
+ /**
+ * Sets the element to display a chat status.
+ *
+ * @param {string} topicText - The topic text for the chat, or some fallback
+ * text used if the chat has no topic.
+ * @param {boolean} noTopic - Whether the chat has no topic.
+ * @param {boolean} topicEditable - Whether the topic can be set by the
+ * user.
+ */
+ setAsChat(topicText, noTopic, topicEditable) {
+ this.noTopic = noTopic;
+ this.topicEditable = topicEditable;
+ this.setStatusText(topicText, noTopic);
+ this.setStatusIcon("chat");
+ }
+
+ /**
+ * Empty the element's display.
+ */
+ clear() {
+ this.querySelector(".protoIcon").removeAttribute("src");
+ this.setStatusText("");
+ this.setStatusIcon(null);
+ this.setUserIcon("", false);
+ this.topicEditable = false;
+ }
+ }
+ customElements.define("chat-conversation-info", MozChatConversationInfo);
+}
diff --git a/comm/mail/components/im/content/chat-conversation.js b/comm/mail/components/im/content/chat-conversation.js
new file mode 100644
index 0000000000..9d0068ac6f
--- /dev/null
+++ b/comm/mail/components/im/content/chat-conversation.js
@@ -0,0 +1,1760 @@
+/* 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";
+
+/* globals MozElements, MozXULElement, chatHandler */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+ );
+ const { Status } = ChromeUtils.importESModule(
+ "resource:///modules/imStatusUtils.sys.mjs"
+ );
+ const { TextboxSize } = ChromeUtils.importESModule(
+ "resource:///modules/imTextboxUtils.sys.mjs"
+ );
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ const { InlineSpellChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+ );
+
+ /**
+ * The MozChatConversation widget displays the entire chat conversation
+ * including status notifications
+ *
+ * @augments {MozXULElement}
+ */
+ class MozChatConversation extends MozXULElement {
+ static get inheritedAttributes() {
+ return {
+ browser: "autoscrollpopup",
+ };
+ }
+
+ constructor() {
+ super();
+
+ ChromeUtils.defineESModuleGetters(this, {
+ ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs",
+ });
+
+ this.observer = {
+ // @see {nsIObserver}
+ observe: (subject, topic, data) => {
+ if (topic == "conversation-loaded") {
+ if (subject != this.convBrowser) {
+ return;
+ }
+
+ this.convBrowser.progressBar = this.progressBar;
+
+ // Display all queued messages. Use a timeout so that message text
+ // modifiers can be added with observers for this notification.
+ if (!this.loaded) {
+ setTimeout(this._showFirstMessages.bind(this), 0);
+ }
+
+ Services.obs.removeObserver(this.observer, "conversation-loaded");
+
+ // Report the active chat message theme via telemetry. This is not
+ // inside the conv browser itself, since the browser is also used
+ // for the theme preview in the settings.
+ Services.telemetry.scalarSet(
+ "tb.chat.active_message_theme",
+ `${this.convBrowser.theme.name}:${this.convBrowser.theme.variant}`
+ );
+
+ return;
+ }
+
+ switch (topic) {
+ case "new-text":
+ if (this.loaded && this.addMsg(subject)) {
+ // This will mark the conv as read, but also update the conv title
+ // with the new unread count etc.
+ this.tab.update();
+ }
+ break;
+
+ case "update-text":
+ if (this.loaded) {
+ this.updateMsg(subject);
+ }
+ break;
+
+ case "remove-text":
+ if (this.loaded) {
+ this.removeMsg(data);
+ }
+ break;
+
+ case "status-text-changed":
+ this._statusText = data || "";
+ this.displayStatusText();
+ break;
+
+ case "replying-to-prompt":
+ this.addPrompt(data);
+ break;
+
+ case "target-prpl-conversation-changed":
+ case "update-conv-title":
+ if (this.tab && this.conv) {
+ this.tab.setAttribute("label", this.conv.title);
+ }
+ break;
+
+ // Update the status too.
+ case "update-buddy-status":
+ case "update-buddy-icon":
+ case "update-conv-icon":
+ case "update-conv-chatleft":
+ if (this.tab && this._isConversationSelected) {
+ this.updateConvStatus();
+ }
+ break;
+
+ case "update-typing":
+ if (this.tab && this._isConversationSelected) {
+ this._currentTypingName = data;
+ this.updateConvStatus();
+ }
+ break;
+
+ case "chat-buddy-add":
+ if (!this._isConversationSelected) {
+ break;
+ }
+ for (let nick of subject.QueryInterface(Ci.nsISimpleEnumerator)) {
+ this.insertBuddy(this.createBuddy(nick));
+ }
+ this.updateParticipantCount();
+ break;
+
+ case "chat-buddy-remove":
+ if (!this._isConversationSelected) {
+ for (let nick of subject.QueryInterface(
+ Ci.nsISimpleEnumerator
+ )) {
+ let name = nick.toString();
+ if (this._isBuddyActive(name)) {
+ delete this._activeBuddies[name];
+ }
+ }
+ break;
+ }
+ for (let nick of subject.QueryInterface(Ci.nsISimpleEnumerator)) {
+ this.removeBuddy(nick.toString());
+ }
+ this.updateParticipantCount();
+ break;
+
+ case "chat-buddy-update":
+ this.updateBuddy(subject, data);
+ break;
+
+ case "chat-update-topic":
+ if (this._isConversationSelected) {
+ this.updateTopic();
+ }
+ break;
+ case "update-conv-encryption":
+ if (this._isConversationSelected) {
+ this.ChatEncryption.updateEncryptionButton(document, this.conv);
+ }
+ break;
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.loaded = false;
+ this._readCount = 0;
+ this._statusText = "";
+ this._pendingValueChangedCall = false;
+ this._nickEscape = /[[\]{}()*+?.\\^$|]/g;
+ this._currentTypingName = "";
+
+ // This value represents the difference between the deck's height and the
+ // textbox's content height (borders, margins, paddings).
+ // Differ according to the Operating System native theme.
+ this._TEXTBOX_VERTICAL_OVERHEAD = 0;
+
+ // Ratio textbox height / conversation height.
+ // 0.1 means that the textbox's height is 10% of the conversation's height.
+ this._TEXTBOX_RATIO = 0.1;
+
+ this.setAttribute("orient", "vertical");
+ this.setAttribute("flex", "1");
+ this.classList.add("convBox");
+
+ this.convTop = document.createXULElement("vbox");
+ this.convTop.setAttribute("flex", "1");
+ this.convTop.classList.add("conv-top");
+
+ this.notification = document.createXULElement("vbox");
+
+ this.convBrowser = document.createXULElement("browser", {
+ is: "conversation-browser",
+ });
+ this.convBrowser.setAttribute("flex", "1");
+ this.convBrowser.setAttribute("type", "content");
+ this.convBrowser.setAttribute("messagemanagergroup", "browsers");
+
+ this.progressBar = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "progress"
+ );
+ this.progressBar.setAttribute("hidden", "hidden");
+
+ this.findbar = document.createXULElement("findbar");
+ this.findbar.setAttribute("reversed", "true");
+
+ this.convTop.appendChild(this.notification);
+ this.convTop.appendChild(this.convBrowser);
+ this.convTop.appendChild(this.progressBar);
+ this.convTop.appendChild(this.findbar);
+
+ this.splitter = document.createXULElement("splitter");
+ this.splitter.setAttribute("orient", "vertical");
+ this.splitter.classList.add("splitter");
+
+ this.convStatusContainer = document.createXULElement("hbox");
+ this.convStatusContainer.setAttribute("hidden", "true");
+ this.convStatusContainer.classList.add("conv-status-container");
+
+ this.convStatus = document.createXULElement("description");
+ this.convStatus.classList.add("plain");
+ this.convStatus.classList.add("conv-status");
+ this.convStatus.setAttribute("crop", "end");
+
+ this.convStatusContainer.appendChild(this.convStatus);
+
+ this.convBottom = document.createXULElement("stack");
+ this.convBottom.classList.add("conv-bottom");
+
+ this.inputBox = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "textarea"
+ );
+ this.inputBox.classList.add("conv-textbox");
+
+ this.charCounter = document.createXULElement("description");
+ this.charCounter.classList.add("conv-counter");
+ this.convBottom.appendChild(this.inputBox);
+ this.convBottom.appendChild(this.charCounter);
+
+ this.appendChild(this.convTop);
+ this.appendChild(this.splitter);
+ this.appendChild(this.convStatusContainer);
+ this.appendChild(this.convBottom);
+
+ this.inputBox.addEventListener("keypress", this.inputKeyPress.bind(this));
+ this.inputBox.addEventListener(
+ "input",
+ this.inputValueChanged.bind(this)
+ );
+ this.inputBox.addEventListener(
+ "overflow",
+ this.inputExpand.bind(this),
+ true
+ );
+ this.inputBox.addEventListener(
+ "underflow",
+ this._onTextboxUnderflow,
+ true
+ );
+
+ new MutationObserver(
+ function (aMutations) {
+ for (let mutation of aMutations) {
+ if (mutation.oldValue == "dragging") {
+ this._onSplitterChange();
+ break;
+ }
+ }
+ }.bind(this)
+ ).observe(this.splitter, {
+ attributes: true,
+ attributeOldValue: true,
+ attributeFilter: ["state"],
+ });
+
+ this.convBrowser.addEventListener(
+ "keypress",
+ this.browserKeyPress.bind(this)
+ );
+ this.convBrowser.addEventListener(
+ "dblclick",
+ this.browserDblClick.bind(this)
+ );
+ Services.obs.addObserver(this.observer, "conversation-loaded");
+
+ // @implements {nsIObserver}
+ this.prefObserver = (subject, topic, data) => {
+ if (Services.prefs.getBoolPref("mail.spellcheck.inline")) {
+ this.inputBox.setAttribute("spellcheck", "true");
+ this.spellchecker.enabled = true;
+ } else {
+ this.inputBox.removeAttribute("spellcheck");
+ this.spellchecker.enabled = false;
+ }
+ };
+ Services.prefs.addObserver("mail.spellcheck.inline", this.prefObserver);
+
+ this.initializeAttributeInheritance();
+ }
+
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ this.notification.prepend(element);
+ });
+ }
+ return this._notificationBox;
+ }
+
+ destroy() {
+ if (this._conv) {
+ this._forgetConv();
+ }
+
+ Services.prefs.removeObserver(
+ "mail.spellcheck.inline",
+ this.prefObserver
+ );
+ }
+
+ _forgetConv(shouldClose) {
+ this._conv.removeObserver(this.observer);
+ delete this._conv;
+ this.convBrowser.destroy();
+ this.findbar.destroy();
+ }
+
+ close() {
+ this._forgetConv(true);
+ }
+
+ _showFirstMessages() {
+ this.loaded = true;
+ let messages = this._conv.getMessages();
+ this._readCount = messages.length - this._conv.unreadMessageCount;
+ if (this._readCount) {
+ this._writingContextMessages = true;
+ }
+ messages.forEach(this.addMsg.bind(this));
+ delete this._writingContextMessages;
+
+ if (this.tab && this.tab.selected && document.hasFocus()) {
+ // This will mark the conv as read, but also update the conv title
+ // with the new unread count etc.
+ this.tab.update();
+ }
+ }
+
+ displayStatusText() {
+ this.convStatus.value = this._statusText;
+ if (this._statusText) {
+ this.convStatusContainer.removeAttribute("hidden");
+ } else {
+ this.convStatusContainer.setAttribute("hidden", "true");
+ }
+ }
+
+ addMsg(aMsg) {
+ if (!this.loaded) {
+ throw new Error("Calling addMsg before the browser is ready?");
+ }
+
+ var conv = aMsg.conversation;
+ if (!conv) {
+ // The conversation has already been destroyed,
+ // probably because the window was closed.
+ // Return without doing anything.
+ return false;
+ }
+
+ // Ugly hack... :(
+ if (!aMsg.system && conv.isChat) {
+ let name = aMsg.who;
+ let color;
+ if (this.buddies.has(name)) {
+ let buddy = this.buddies.get(name);
+ color = buddy.color;
+ buddy.removeAttribute("inactive");
+ this._activeBuddies[name] = true;
+ } else {
+ // Buddy no longer in the room
+ color = this._computeColor(name);
+ }
+ aMsg.color = "color: hsl(" + color + ", 100%, 40%);";
+ }
+
+ // Porting note: In TB, this.tab points at the imconv richlistitem element.
+ let read = this._readCount > 0;
+ let isUnreadMessage = !read && aMsg.incoming && !aMsg.system;
+ let isTabFocused = this.tab && this.tab.selected && document.hasFocus();
+ let shouldSetUnreadFlag = this.tab && isUnreadMessage && !isTabFocused;
+ let firstUnread =
+ this.tab &&
+ !this.tab.hasAttribute("unread") &&
+ isUnreadMessage &&
+ this._isAfterFirstRealMessage &&
+ (!isTabFocused || this._writingContextMessages);
+
+ // Since the unread flag won't be set if the tab is focused,
+ // we need the following when showing the first messages to stop
+ // firstUnread being set for subsequent messages.
+ if (firstUnread) {
+ delete this._writingContextMessages;
+ }
+
+ this.convBrowser.appendMessage(aMsg, read, firstUnread);
+ if (!aMsg.system) {
+ this._isAfterFirstRealMessage = true;
+ }
+
+ if (read) {
+ --this._readCount;
+ if (!this._readCount && !this._isAfterFirstRealMessage) {
+ // If all the context messages were system messages, we don't want
+ // an unread ruler after the context messages, so we forget that
+ // we had context messages.
+ delete this._writingContextMessages;
+ }
+ return false;
+ }
+
+ if (isUnreadMessage && (!aMsg.conversation.isChat || aMsg.containsNick)) {
+ this._lastPing = aMsg.who;
+ this._lastPingTime = aMsg.time;
+ }
+
+ if (shouldSetUnreadFlag) {
+ if (conv.isChat && aMsg.containsNick) {
+ this.tab.setAttribute("attention", "true");
+ }
+ this.tab.setAttribute("unread", "true");
+ }
+
+ return isTabFocused;
+ }
+
+ /**
+ * Updates an existing message with the matching remote ID.
+ *
+ * @param {imIMessage} aMsg - Message to update.
+ */
+ updateMsg(aMsg) {
+ if (!this.loaded) {
+ throw new Error("Calling updateMsg before the browser is ready?");
+ }
+
+ var conv = aMsg.conversation;
+ if (!conv) {
+ // The conversation has already been destroyed,
+ // probably because the window was closed.
+ // Return without doing anything.
+ return;
+ }
+
+ // Update buddy color.
+ // Ugly hack... :(
+ if (!aMsg.system && conv.isChat) {
+ let name = aMsg.who;
+ let color;
+ if (this.buddies.has(name)) {
+ let buddy = this.buddies.get(name);
+ color = buddy.color;
+ buddy.removeAttribute("inactive");
+ this._activeBuddies[name] = true;
+ } else {
+ // Buddy no longer in the room
+ color = this._computeColor(name);
+ }
+ aMsg.color = "color: hsl(" + color + ", 100%, 40%);";
+ }
+
+ this.convBrowser.replaceMessage(aMsg);
+ }
+
+ /**
+ * Removes an existing message with matching remote ID.
+ *
+ * @param {string} remoteId - Remote ID of the message to remove.
+ */
+ removeMsg(remoteId) {
+ if (!this.loaded) {
+ throw new Error("Calling removeMsg before the browser is ready?");
+ }
+
+ this.convBrowser.removeMessage(remoteId);
+ }
+
+ sendMsg(aMsg) {
+ if (!aMsg) {
+ return;
+ }
+
+ let account = this._conv.account;
+
+ if (aMsg.startsWith("/")) {
+ let convToFocus = {};
+
+ // The /say command is used to bypass command processing
+ // (/say can be shortened to just /).
+ // "/say" or "/say " should be ignored, as should "/" and "/ ".
+ if (aMsg.match(/^\/(?:say)? ?$/)) {
+ this.resetInput();
+ return;
+ }
+
+ if (aMsg.match(/^\/(?:say)? .*/)) {
+ aMsg = aMsg.slice(aMsg.indexOf(" ") + 1);
+ } else if (
+ IMServices.cmd.executeCommand(aMsg, this._conv.target, convToFocus)
+ ) {
+ this._conv.sendTyping("");
+ this.resetInput();
+ if (convToFocus.value) {
+ chatHandler.focusConversation(convToFocus.value);
+ }
+ return;
+ }
+
+ if (account.protocol.slashCommandsNative && account.connected) {
+ let cmd = aMsg.match(/^\/[^ ]+/);
+ if (cmd && cmd != "/me") {
+ this._conv.systemMessage(
+ this.bundle.formatStringFromName("unknownCommand", [cmd], 1),
+ true
+ );
+ return;
+ }
+ }
+ }
+
+ this._conv.sendMsg(aMsg, false, false);
+
+ // reset the textbox to its original size
+ this.resetInput();
+ }
+
+ _onSplitterChange() {
+ // set the default height as the deck height (modified by the splitter)
+ this.inputBox.defaultHeight =
+ parseInt(this.inputBox.parentNode.getBoundingClientRect().height) -
+ this._TEXTBOX_VERTICAL_OVERHEAD;
+ }
+
+ calculateTextboxDefaultHeight() {
+ let totalSpace = parseInt(
+ window.getComputedStyle(this).getPropertyValue("height")
+ );
+ let textboxStyle = window.getComputedStyle(this.inputBox);
+ let lineHeight = textboxStyle.lineHeight;
+ if (lineHeight == "normal") {
+ lineHeight = parseFloat(textboxStyle.fontSize) * 1.2;
+ } else {
+ lineHeight = parseFloat(lineHeight);
+ }
+
+ // Compute the overhead size.
+ let textboxHeight = this.inputBox.clientHeight;
+ let deckHeight = this.inputBox.parentNode.getBoundingClientRect().height;
+ this._TEXTBOX_VERTICAL_OVERHEAD = deckHeight - textboxHeight;
+
+ // Calculate the number of lines to display.
+ let numberOfLines = Math.round(
+ (totalSpace * this._TEXTBOX_RATIO) / lineHeight
+ );
+ if (numberOfLines <= 0) {
+ numberOfLines = 1;
+ }
+ if (!this._maxEmptyLines) {
+ this._maxEmptyLines = Services.prefs.getIntPref(
+ "messenger.conversations.textbox.defaultMaxLines"
+ );
+ }
+
+ if (numberOfLines > this._maxEmptyLines) {
+ numberOfLines = this._maxEmptyLines;
+ }
+ this.inputBox.defaultHeight = numberOfLines * lineHeight;
+
+ // set minimum height (in case the user moves the splitter)
+ this.inputBox.parentNode.style.minHeight =
+ lineHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px";
+ }
+
+ initTextboxFormat() {
+ // Init the textbox size
+ this.calculateTextboxDefaultHeight();
+ this.inputBox.parentNode.style.height =
+ this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px";
+ this.inputBox.style.overflowY = "hidden";
+
+ this.spellchecker = new InlineSpellChecker(this.inputBox);
+ if (Services.prefs.getBoolPref("mail.spellcheck.inline")) {
+ this.inputBox.setAttribute("spellcheck", "true");
+ this.spellchecker.enabled = true;
+ } else {
+ this.inputBox.removeAttribute("spellcheck");
+ this.spellchecker.enabled = false;
+ }
+ }
+
+ // eslint-disable-next-line complexity
+ inputKeyPress(event) {
+ let text = this.inputBox.value;
+
+ const navKeyCodes = [
+ KeyEvent.DOM_VK_PAGE_UP,
+ KeyEvent.DOM_VK_PAGE_DOWN,
+ KeyEvent.DOM_VK_HOME,
+ KeyEvent.DOM_VK_END,
+ KeyEvent.DOM_VK_UP,
+ KeyEvent.DOM_VK_DOWN,
+ ];
+
+ // Pass navigation keys to the browser if
+ // 1) the textbox is empty or 2) it's an IB-specific key combination
+ if (
+ (!text && navKeyCodes.includes(event.keyCode)) ||
+ ((event.shiftKey || event.altKey) &&
+ (event.keyCode == KeyEvent.DOM_VK_PAGE_UP ||
+ event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN))
+ ) {
+ let newEvent = new KeyboardEvent("keypress", event);
+ event.preventDefault();
+ event.stopPropagation();
+ // Keyboard events must be sent to the focused element for bubbling to work.
+ this.convBrowser.focus();
+ this.convBrowser.dispatchEvent(newEvent);
+ this.inputBox.focus();
+ return;
+ }
+
+ // When attempting to copy an empty selection, copy the
+ // browser selection instead (see bug 693).
+ // The 'C' won't be lowercase if caps lock is enabled.
+ if (
+ (event.charCode == 99 /* 'c' */ ||
+ (event.charCode == 67 /* 'C' */ && !event.shiftKey)) &&
+ (navigator.platform.includes("Mac") ? event.metaKey : event.ctrlKey) &&
+ this.inputBox.selectionStart == this.inputBox.selectionEnd
+ ) {
+ this.convBrowser.doCommand();
+ return;
+ }
+
+ // We don't want to enable tab completion if the user has selected
+ // some text, as it's not clear what the user would expect
+ // to happen in that case.
+ let noSelection = !(
+ this.inputBox.selectionEnd - this.inputBox.selectionStart
+ );
+
+ // Undo tab complete.
+ if (
+ noSelection &&
+ this._completions &&
+ event.keyCode == KeyEvent.DOM_VK_BACK_SPACE &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey
+ ) {
+ if (text == this._beforeTabComplete) {
+ // Nothing to undo, so let backspace act normally.
+ delete this._completions;
+ } else {
+ event.preventDefault();
+
+ // First undo the comma separating multiple nicks or the suffix.
+ // More than one nick:
+ // "nick1, nick2: " -> "nick1: nick2"
+ // Single nick: remove the suffix
+ // "nick1: " -> "nick1"
+ let pos = this.inputBox.selectionStart;
+ const suffix = ": ";
+ if (
+ pos > suffix.length &&
+ text.substring(pos - suffix.length, pos) == suffix
+ ) {
+ let completions = Array.from(this.buddies.keys());
+ // Check if the preceding words are a sequence of nick completions.
+ let preceding = text.substring(0, pos - suffix.length).split(", ");
+ if (preceding.every(n => completions.includes(n))) {
+ let s = preceding.pop();
+ if (preceding.length) {
+ s = suffix + s;
+ }
+ this.inputBox.selectionStart -= s.length + suffix.length;
+ this.addString(s);
+ if (this._completions[0].slice(-suffix.length) == suffix) {
+ this._completions = this._completions.map(c =>
+ c.slice(0, -suffix.length)
+ );
+ }
+ if (
+ this._completions.length == 1 &&
+ this.inputBox.value == this._beforeTabComplete
+ ) {
+ // Nothing left to undo or to cycle through.
+ delete this._completions;
+ }
+ return;
+ }
+ }
+
+ // Full undo.
+ this.inputBox.selectionStart = 0;
+ this.addString(this._beforeTabComplete);
+ delete this._completions;
+ return;
+ }
+ }
+
+ // Tab complete.
+ // Keep the default behavior of the tab key if the input box
+ // is empty or a modifier is used.
+ if (
+ event.keyCode == KeyEvent.DOM_VK_TAB &&
+ text.length != 0 &&
+ noSelection &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ (!event.shiftKey || this._completions)
+ ) {
+ event.preventDefault();
+
+ if (this._completions) {
+ // Tab has been pressed more than once.
+ if (this._completions.length == 1) {
+ return;
+ }
+ if (this._shouldListCompletionsLater) {
+ this._conv.systemMessage(this._shouldListCompletionsLater);
+ delete this._shouldListCompletionsLater;
+ }
+
+ this.inputBox.selectionStart = this._completionsStart;
+ if (event.shiftKey) {
+ // Reverse cycle completions.
+ this._completionsIndex -= 2;
+ if (this._completionsIndex < 0) {
+ this._completionsIndex += this._completions.length;
+ }
+ }
+ this.addString(this._completions[this._completionsIndex++]);
+ this._completionsIndex %= this._completions.length;
+ return;
+ }
+
+ let completions = [];
+ let firstWordSuffix = " ";
+ let secondNick = false;
+
+ // Second regex result will contain word without leading special characters.
+ this._beforeTabComplete = text.substring(
+ 0,
+ this.inputBox.selectionStart
+ );
+ let words = this._beforeTabComplete.match(/\S*?([\w-]+)?$/);
+ let word = words[0];
+ if (!word) {
+ return;
+ }
+ let isFirstWord = this.inputBox.selectionStart == word.length;
+
+ // Check if we are completing a command.
+ let completingCommand = isFirstWord && word[0] == "/";
+ if (completingCommand) {
+ for (let cmd of IMServices.cmd.listCommandsForConversation(
+ this._conv
+ )) {
+ // It's possible to have a global and a protocol specific command
+ // with the same name. Avoid duplicates in the |completions| array.
+ let name = "/" + cmd.name;
+ if (!completions.includes(name)) {
+ completions.push(name);
+ }
+ }
+ } else {
+ // If it's not a command, the only thing we can complete is a nick.
+ if (!this._conv.isChat) {
+ return;
+ }
+
+ firstWordSuffix = ": ";
+ completions = Array.from(this.buddies.keys());
+
+ let outgoingNick = this._conv.nick;
+ completions = completions.filter(c => c != outgoingNick);
+
+ // Check if the preceding words are a sequence of nick completions.
+ let wordStart = this.inputBox.selectionStart - word.length;
+ if (wordStart > 2) {
+ let separator = text.substring(wordStart - 2, wordStart);
+ if (separator == ": " || separator == ", ") {
+ let preceding = text.substring(0, wordStart - 2).split(", ");
+ if (preceding.every(n => completions.includes(n))) {
+ secondNick = true;
+ isFirstWord = true;
+ // Remove preceding completions from possible completions.
+ completions = completions.filter(c => !preceding.includes(c));
+ }
+ }
+ }
+ }
+
+ // Keep only the completions that share |word| as a prefix.
+ // Be case insensitive only if |word| is entirely lower case.
+ let condition;
+ if (word.toLocaleLowerCase() == word) {
+ condition = c => c.toLocaleLowerCase().startsWith(word);
+ } else {
+ condition = c => c.startsWith(word);
+ }
+ let matchingCompletions = completions.filter(condition);
+ if (!matchingCompletions.length && words[1]) {
+ word = words[1];
+ firstWordSuffix = " ";
+ matchingCompletions = completions.filter(condition);
+ }
+ if (!matchingCompletions.length) {
+ return;
+ }
+
+ // If the cursor is in the middle of a word, and the word is a nick,
+ // there is no need to complete - just jump to the end of the nick.
+ let wholeWord = text.substring(
+ this.inputBox.selectionStart - word.length
+ );
+ for (let completion of matchingCompletions) {
+ if (wholeWord.lastIndexOf(completion, 0) == 0) {
+ let moveCursor = completion.length - word.length;
+ this.inputBox.selectionStart += moveCursor;
+ let separator = text.substring(
+ this.inputBox.selectionStart,
+ this.inputBox.selectionStart + 2
+ );
+ if (separator == ": " || separator == ", ") {
+ this.inputBox.selectionStart += 2;
+ } else if (!moveCursor) {
+ // If we're already at the end of a nick, carry on to display
+ // a list of possible alternatives and/or apply punctuation.
+ break;
+ }
+ return;
+ }
+ }
+
+ // We have possible completions!
+ this._completions = matchingCompletions.sort();
+ this._completionsIndex = 0;
+ // Save now the first and last completions in alphabetical order,
+ // as we will need them to find a common prefix. However they may
+ // not be the first and last completions in the list of completions
+ // actually exposed to the user, as if there are active nicks
+ // they will be moved to the beginning of the list.
+ let firstCompletion = this._completions[0];
+ let lastCompletion = this._completions.slice(-1)[0];
+
+ let preferredNick = false;
+ if (this._conv.isChat && !completingCommand) {
+ // If there are active nicks, prefer those.
+ let activeCompletions = this._completions.filter(
+ c =>
+ this.buddies.has(c) &&
+ !this.buddies.get(c).hasAttribute("inactive")
+ );
+ if (activeCompletions.length == 1) {
+ preferredNick = true;
+ }
+ if (activeCompletions.length) {
+ // Move active nicks to the front of the queue.
+ activeCompletions.reverse();
+ activeCompletions.forEach(function (c) {
+ this._completions.splice(this._completions.indexOf(c), 1);
+ this._completions.unshift(c);
+ }, this);
+ }
+
+ // If one of the completions is the sender of the last ping,
+ // take it, if it was less than an hour ago.
+ if (
+ this._lastPing &&
+ this.buddies.has(this._lastPing) &&
+ this._completions.includes(this._lastPing) &&
+ Date.now() / 1000 - this._lastPingTime < 3600
+ ) {
+ preferredNick = true;
+ this._completionsIndex = this._completions.indexOf(this._lastPing);
+ }
+ }
+
+ // Display the possible completions in a system message.
+ delete this._shouldListCompletionsLater;
+ if (this._completions.length > 1) {
+ let completionsList = this._completions.join(" ");
+ if (preferredNick) {
+ // If we have a preferred nick (which is completed as a whole
+ // even if there are alternatives), only show the list of
+ // completions on the next <tab> press.
+ this._shouldListCompletionsLater = completionsList;
+ } else {
+ this._conv.systemMessage(completionsList);
+ }
+ }
+
+ let suffix = isFirstWord ? firstWordSuffix : "";
+ this._completions = this._completions.map(c => c + suffix);
+
+ let completion;
+ if (this._completions.length == 1 || preferredNick) {
+ // Only one possible completion? Apply it! :-)
+ completion = this._completions[this._completionsIndex++];
+ this._completionsIndex %= this._completions.length;
+ } else {
+ // We have several possible completions, attempt to find a common prefix.
+ let maxLength = Math.min(
+ firstCompletion.length,
+ lastCompletion.length
+ );
+ let i = 0;
+ while (i < maxLength && firstCompletion[i] == lastCompletion[i]) {
+ ++i;
+ }
+
+ if (i) {
+ completion = firstCompletion.substring(0, i);
+ } else {
+ // Include this case so that secondNick is applied anyway,
+ // in case a completion is added by another tab press.
+ completion = word;
+ }
+ }
+
+ // Always replace what the user typed as its upper/lowercase may
+ // not be correct.
+ this.inputBox.selectionStart -= word.length;
+ this._completionsStart = this.inputBox.selectionStart;
+
+ if (secondNick) {
+ // Replace the trailing colon with a comma before the completed nick.
+ this.inputBox.selectionStart -= 2;
+ completion = ", " + completion;
+ }
+
+ this.addString(completion);
+ } else if (this._completions) {
+ delete this._completions;
+ }
+
+ if (event.keyCode != 13) {
+ return;
+ }
+
+ if (!event.ctrlKey && !event.shiftKey && !event.altKey) {
+ // Prevent the default action before calling sendMsg to avoid having
+ // a line break inserted in the textbox if sendMsg throws.
+ event.preventDefault();
+ this.sendMsg(text);
+ } else if (!event.shiftKey) {
+ this.addString("\n");
+ }
+ }
+
+ inputValueChanged() {
+ // Delaying typing notifications will avoid sending several updates in
+ // a row if the user is on a slow or overloaded machine that has
+ // trouble to handle keystrokes in a timely fashion.
+ // Make sure only one typing notification call can be pending.
+ if (this._pendingValueChangedCall) {
+ return;
+ }
+
+ this._pendingValueChangedCall = true;
+ Services.tm.mainThread.dispatch(
+ this.delayedInputValueChanged.bind(this),
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+ }
+
+ delayedInputValueChanged() {
+ this._pendingValueChangedCall = false;
+
+ // By the time this function is executed, the conversation may have
+ // been closed.
+ if (!this._conv) {
+ return;
+ }
+
+ let text = this.inputBox.value;
+
+ // Try to avoid sending typing notifications when the user is
+ // typing a command in the conversation.
+ // These checks are not perfect (especially if non-existing
+ // commands are sent as regular messages on the in-use prpl).
+ let left = Ci.prplIConversation.NO_TYPING_LIMIT;
+ if (!text.startsWith("/")) {
+ left = this._conv.sendTyping(text);
+ } else if (/^\/me /.test(text)) {
+ left = this._conv.sendTyping(text.slice(4));
+ }
+
+ // When the input box is cleared or there is no character limit,
+ // don't show the character limit.
+ if (left == Ci.prplIConversation.NO_TYPING_LIMIT || !text.length) {
+ this.charCounter.setAttribute("value", "");
+ this.inputBox.removeAttribute("invalidInput");
+ } else {
+ // 200 is a 'magic' constant to avoid showing big numbers.
+ this.charCounter.setAttribute("value", left < 200 ? left : "");
+
+ if (left >= 0) {
+ this.inputBox.removeAttribute("invalidInput");
+ } else if (left < 0) {
+ this.inputBox.setAttribute("invalidInput", "true");
+ }
+ }
+ }
+
+ resetInput() {
+ this.inputBox.value = "";
+ this.charCounter.setAttribute("value", "");
+ this.inputBox.removeAttribute("invalidInput");
+
+ this._statusText = "";
+ this.displayStatusText();
+
+ if (TextboxSize.autoResize) {
+ let currHeight = Math.round(
+ this.inputBox.parentNode.getBoundingClientRect().height
+ );
+ if (
+ this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD >
+ currHeight
+ ) {
+ this.inputBox.defaultHeight =
+ currHeight - this._TEXTBOX_VERTICAL_OVERHEAD;
+ }
+ this.convBottom.style.height =
+ this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px";
+ this.inputBox.style.overflowY = "hidden";
+ }
+ }
+
+ inputExpand(event) {
+ // This feature has been disabled, or the user is currently dragging
+ // the splitter and the textbox has received an overflow event
+ if (
+ !TextboxSize.autoResize ||
+ this.splitter.getAttribute("state") == "dragging"
+ ) {
+ this.inputBox.style.overflowY = "";
+ return;
+ }
+
+ // Check whether we can increase the height without hiding the status bar
+ // (ensure the min-height property on the top part of this dialog)
+ let topBoxStyle = window.getComputedStyle(this.convTop);
+ let topMinSize = parseInt(topBoxStyle.getPropertyValue("min-height"));
+ let topSize = parseInt(topBoxStyle.getPropertyValue("height"));
+ let deck = this.inputBox.parentNode;
+ let oldDeckHeight = Math.round(deck.getBoundingClientRect().height);
+ let newDeckHeight =
+ parseInt(this.inputBox.scrollHeight) + this._TEXTBOX_VERTICAL_OVERHEAD;
+
+ if (!topMinSize || topSize - topMinSize > newDeckHeight - oldDeckHeight) {
+ // Hide a possible vertical scrollbar.
+ this.inputBox.style.overflowY = "hidden";
+ deck.style.height = newDeckHeight + "px";
+ } else {
+ this.inputBox.style.overflowY = "";
+ // Set it to the maximum possible value.
+ deck.style.height = oldDeckHeight + (topSize - topMinSize) + "px";
+ }
+ }
+
+ onConvResize() {
+ if (!this.splitter.hasAttribute("state")) {
+ this.calculateTextboxDefaultHeight();
+ this.inputBox.parentNode.style.height =
+ this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px";
+ } else {
+ // Used in case the browser is already on its min-height, resize the
+ // textbox to avoid hiding the status bar.
+ let convTopStyle = window.getComputedStyle(this.convTop);
+ let convTopHeight = parseInt(convTopStyle.getPropertyValue("height"));
+ let convTopMinHeight = parseInt(
+ convTopStyle.getPropertyValue("min-height")
+ );
+
+ if (convTopHeight == convTopMinHeight) {
+ this.inputBox.parentNode.style.height =
+ this.inputBox.parentNode.style.minHeight;
+ convTopHeight = parseInt(convTopStyle.getPropertyValue("height"));
+ this.inputBox.parentNode.style.height =
+ parseInt(this.inputBox.parentNode.style.minHeight) +
+ (convTopHeight - convTopMinHeight) +
+ "px";
+ }
+ }
+ if (TextboxSize.autoResize) {
+ this.inputExpand();
+ }
+ }
+
+ _onTextboxUnderflow(event) {
+ if (TextboxSize.autoResize) {
+ this.style.overflowY = "hidden";
+ }
+ }
+
+ browserKeyPress(event) {
+ let accelKeyPressed =
+ AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey;
+
+ // 118 is the decimal code for "v" character, 13 keyCode for "return" key
+ if (
+ ((accelKeyPressed && event.charCode != 118) || event.altKey) &&
+ event.keyCode != 13
+ ) {
+ return;
+ }
+
+ if (
+ event.charCode == 0 && // it's not a character, it's a command key
+ event.keyCode != 13 && // Return
+ event.keyCode != 8 && // Backspace
+ event.keyCode != 46
+ ) {
+ // Delete
+ return;
+ }
+
+ if (
+ accelKeyPressed ||
+ !Services.prefs.getBoolPref("accessibility.typeaheadfind")
+ ) {
+ this.inputBox.focus();
+
+ // A common use case is to click somewhere in the conversation and
+ // start typing a command (often /me). If quick find is enabled, it
+ // will pick up the "/" keypress and open the findbar.
+ if (event.charCode == "/".charCodeAt(0)) {
+ event.preventDefault();
+ }
+ }
+
+ // Returns for Ctrl+V
+ if (accelKeyPressed) {
+ return;
+ }
+
+ // resend the event
+ let clonedEvent = new KeyboardEvent("keypress", event);
+ this.inputBox.dispatchEvent(clonedEvent);
+ }
+
+ browserDblClick(event) {
+ if (
+ !Services.prefs.getBoolPref(
+ "messenger.conversations.doubleClickToReply"
+ )
+ ) {
+ return;
+ }
+
+ for (let node = event.target; node; node = node.parentNode) {
+ if (node._originalMsg) {
+ let msg = node._originalMsg;
+ if (
+ msg.system ||
+ msg.outgoing ||
+ !msg.incoming ||
+ msg.error ||
+ !this._conv.isChat
+ ) {
+ return;
+ }
+ this.addPrompt(msg.who + ": ");
+ return;
+ }
+ }
+ }
+
+ /**
+ * Replace the current selection in the inputBox by the given string
+ *
+ * @param {string} aString
+ */
+ addString(aString) {
+ let cursorPosition = this.inputBox.selectionStart + aString.length;
+
+ this.inputBox.value =
+ this.inputBox.value.substr(0, this.inputBox.selectionStart) +
+ aString +
+ this.inputBox.value.substr(this.inputBox.selectionEnd);
+ this.inputBox.selectionStart = this.inputBox.selectionEnd =
+ cursorPosition;
+ this.inputValueChanged();
+ }
+
+ addPrompt(aPrompt) {
+ let currentEditorValue = this.inputBox.value;
+ if (!currentEditorValue.startsWith(aPrompt)) {
+ this.inputBox.value = aPrompt + currentEditorValue;
+ }
+
+ this.inputBox.focus();
+ this.inputValueChanged();
+ }
+
+ /**
+ * Update the participant count of a chat conversation
+ */
+ updateParticipantCount() {
+ document.getElementById("participantCount").value = this.buddies.size;
+ }
+
+ /**
+ * Set the attributes (flags) of a chat buddy
+ *
+ * @param {object} aItem
+ */
+ setBuddyAttributes(aItem) {
+ let buddy = aItem.chatBuddy;
+ let src;
+ let l10nId;
+ if (buddy.founder) {
+ src = "chrome://messenger/skin/icons/founder.png";
+ l10nId = "chat-participant-owner-role-icon2";
+ } else if (buddy.admin) {
+ src = "chrome://messenger/skin/icons/operator.png";
+ l10nId = "chat-participant-administrator-role-icon2";
+ } else if (buddy.moderator) {
+ src = "chrome://messenger/skin/icons/half-operator.png";
+ l10nId = "chat-participant-moderator-role-icon2";
+ } else if (buddy.voiced) {
+ src = "chrome://messenger/skin/icons/voice.png";
+ l10nId = "chat-participant-voiced-role-icon2";
+ }
+ let imageEl = aItem.querySelector(".conv-nicklist-image");
+ if (src) {
+ imageEl.setAttribute("src", src);
+ document.l10n.setAttributes(imageEl, l10nId);
+ } else {
+ imageEl.removeAttribute("src");
+ imageEl.removeAttribute("data-l10n-id");
+ imageEl.removeAttribute("alt");
+ }
+ }
+
+ /**
+ * Compute color for a nick
+ *
+ * @param {string} aName
+ */
+ _computeColor(aName) {
+ // Compute the color based on the nick
+ let nick = aName.match(/[a-zA-Z0-9]+/);
+ nick = nick ? nick[0].toLowerCase() : (nick = aName);
+ // We compute a hue value (between 0 and 359) based on the
+ // characters of the nick.
+ // The first character weights kInitialWeight, each following
+ // character weights kWeightReductionPerChar * the weight of the
+ // previous character.
+ const kInitialWeight = 10; // 10 = 360 hue values / 36 possible characters.
+ const kWeightReductionPerChar = 0.52; // arbitrary value
+ let weight = kInitialWeight;
+ let res = 0;
+ for (let i = 0; i < nick.length; ++i) {
+ let char = nick.charCodeAt(i) - 47;
+ if (char > 10) {
+ char -= 39;
+ }
+ // now char contains a value between 1 and 36
+ res += char * weight;
+ weight *= kWeightReductionPerChar;
+ }
+ return Math.round(res) % 360;
+ }
+
+ _isBuddyActive(aBuddyName) {
+ return Object.prototype.hasOwnProperty.call(
+ this._activeBuddies,
+ aBuddyName
+ );
+ }
+
+ /**
+ * Create a buddy item to add in the visible list of participants
+ *
+ * @param {object} aBuddy
+ */
+ createBuddy(aBuddy) {
+ let name = aBuddy.name;
+ if (!name) {
+ throw new Error("The empty string isn't a valid nick.");
+ }
+ if (this.buddies.has(name)) {
+ throw new Error("Adding chat buddy " + name + " twice?!");
+ }
+
+ this.trackNick(name);
+
+ let image = document.createElement("img");
+ image.classList.add("conv-nicklist-image");
+ let label = document.createXULElement("label");
+ label.classList.add("conv-nicklist-label");
+ label.setAttribute("value", name);
+ label.setAttribute("flex", "1");
+ label.setAttribute("crop", "end");
+
+ // Fix insertBuddy below if you change the DOM makeup!
+ let item = document.createXULElement("richlistitem");
+ item.chatBuddy = aBuddy;
+ item.appendChild(image);
+ item.appendChild(label);
+ this.setBuddyAttributes(item);
+
+ let color = this._computeColor(name);
+ let style = "color: hsl(" + color + ", 100%, 40%);";
+ item.colorStyle = style;
+ item.setAttribute("style", style);
+ item.setAttribute("align", "center");
+ if (!this._isBuddyActive(name)) {
+ item.setAttribute("inactive", "true");
+ }
+ item.color = color;
+ this.buddies.set(name, item);
+
+ return item;
+ }
+
+ /**
+ * Insert item at the right position
+ *
+ * @param {Node} aListItem
+ */
+ insertBuddy(aListItem) {
+ let nicklist = document.getElementById("nicklist");
+ let nick = aListItem.querySelector("label").value.toLowerCase();
+
+ // Look for the place of the nick in the list
+ let start = 0;
+ let end = nicklist.itemCount;
+ while (start < end) {
+ let middle = start + Math.floor((end - start) / 2);
+ if (
+ nick <
+ nicklist
+ .getItemAtIndex(middle)
+ .firstElementChild.nextElementSibling.getAttribute("value")
+ .toLowerCase()
+ ) {
+ end = middle;
+ } else {
+ start = middle + 1;
+ }
+ }
+
+ // Now insert the element
+ if (end == nicklist.itemCount) {
+ nicklist.appendChild(aListItem);
+ } else {
+ nicklist.insertBefore(aListItem, nicklist.getItemAtIndex(end));
+ }
+ }
+
+ /**
+ * Update a buddy in the visible list of participants
+ *
+ * @param {object} aBuddy
+ * @param {string} aOldName
+ */
+ updateBuddy(aBuddy, aOldName) {
+ let name = aBuddy.name;
+ if (!name) {
+ throw new Error("The empty string isn't a valid nick.");
+ }
+
+ if (!aOldName) {
+ if (!this._isConversationSelected) {
+ return;
+ }
+ // If aOldName is null, we are changing the flags of the buddy
+ let item = this.buddies.get(name);
+ item.chatBuddy = aBuddy;
+ this.setBuddyAttributes(item);
+ return;
+ }
+
+ if (this._isBuddyActive(aOldName)) {
+ delete this._activeBuddies[aOldName];
+ this._activeBuddies[aBuddy.name] = true;
+ }
+
+ this.trackNick(name);
+
+ if (!this._isConversationSelected) {
+ return;
+ }
+
+ // Is aOldName is not null, then we are renaming the buddy
+ if (!this.buddies.has(aOldName)) {
+ throw new Error(
+ "Updating a chat buddy that does not exist: " + aOldName
+ );
+ }
+
+ if (this.buddies.has(name)) {
+ throw new Error(
+ "Updating a chat buddy to an already existing one: " + name
+ );
+ }
+
+ let item = this.buddies.get(aOldName);
+ item.chatBuddy = aBuddy;
+ this.buddies.delete(aOldName);
+ this.buddies.set(name, item);
+ item.querySelector("label").value = name;
+
+ // Move this item to the right position if its name changed
+ item.remove();
+ this.insertBuddy(item);
+ }
+
+ removeBuddy(aName) {
+ if (!this.buddies.has(aName)) {
+ throw new Error("Cannot remove a buddy that was not in the room");
+ }
+ this.buddies.get(aName).remove();
+ this.buddies.delete(aName);
+ if (this._isBuddyActive(aName)) {
+ delete this._activeBuddies[aName];
+ }
+ }
+
+ trackNick(aNick) {
+ if ("_showNickList" in this) {
+ this._showNickList[aNick.replace(this._nickEscape, "\\$&")] = true;
+ delete this._showNickRegExp;
+ }
+ }
+
+ getShowNickModifier() {
+ return function (aNode) {
+ if (!("_showNickRegExp" in this)) {
+ if (!("_showNickList" in this)) {
+ this._showNickList = {};
+ for (let n of this.buddies.keys()) {
+ this._showNickList[n.replace(this._nickEscape, "\\$&")] = true;
+ }
+ }
+
+ // The reverse sort ensures that if we have "foo" and "foobar",
+ // "foobar" will be matched first by the regexp.
+ let nicks = Object.keys(this._showNickList)
+ .sort()
+ .reverse()
+ .join("|");
+ if (nicks) {
+ // We use \W to match for word-boundaries, as \b will not match the
+ // nick if it starts/ends with \W characters.
+ // XXX Ideally we would use unicode word boundaries:
+ // http://www.unicode.org/reports/tr29/#Word_Boundaries
+ this._showNickRegExp = new RegExp("\\W(?:" + nicks + ")\\W");
+ } else {
+ // nobody, disable...
+ this._showNickRegExp = { exec: () => null };
+ return 0;
+ }
+ }
+ let exp = this._showNickRegExp;
+ let result = 0;
+ let match;
+ // Add leading/trailing spaces to match at beginning and end of
+ // the string as well. (If we used regex ^ and $, match.index would
+ // not be reliable.)
+ while ((match = exp.exec(" " + aNode.data + " "))) {
+ // \W is not zero-length, but this is cancelled by the
+ // extra leading space here.
+ let nickNode = aNode.splitText(match.index);
+ // subtract the 2 \W's to get the length of the nick.
+ aNode = nickNode.splitText(match[0].length - 2);
+ // at this point, nickNode is a text node with only the text
+ // of the nick and aNode is a text node with the text after
+ // the nick. The text in aNode hasn't been processed yet.
+ let nick = nickNode.data;
+ let elt = aNode.ownerDocument.createElement("span");
+ elt.setAttribute("class", "ib-nick");
+ if (this.buddies.has(nick)) {
+ let buddy = this.buddies.get(nick);
+ elt.setAttribute("style", buddy.colorStyle);
+ elt.setAttribute("data-nickColor", buddy.color);
+ } else {
+ elt.setAttribute("data-left", "true");
+ }
+ nickNode.parentNode.replaceChild(elt, nickNode);
+ elt.textContent = nick;
+ result += 2;
+ }
+ return result;
+ }.bind(this);
+ }
+
+ /**
+ * Display the topic and topic editable flag for the current MUC in the
+ * conversation header.
+ */
+ updateTopic() {
+ let cti = document.getElementById("conv-top-info");
+ let editable = !!this._conv.topicSettable;
+
+ let topicText = this._conv.topic;
+ let noTopic = !topicText;
+ cti.setAsChat(topicText || this._conv.noTopicString, noTopic, editable);
+ }
+
+ focus() {
+ this.inputBox.focus();
+
+ if (!this.loaded) {
+ return;
+ }
+
+ if (this.tab) {
+ this.tab.removeAttribute("unread");
+ this.tab.removeAttribute("attention");
+ }
+ this._conv.markAsRead();
+ }
+
+ switchingToPanel() {
+ if (this._visibleTimer) {
+ return;
+ }
+
+ // Start a timer to detect if the tab has been visible to the
+ // user for long enough to actually be seen (as opposed to the
+ // tab only being visible "accidentally in passing").
+ delete this._wasVisible;
+ this._visibleTimer = setTimeout(() => {
+ this._wasVisible = true;
+ delete this._visibleTimer;
+
+ // Porting note: For TB, we also need to update the conv title
+ // and reset the unread flag. In IB, this is done by tabbrowser.
+ this.tab.update();
+ }, 1000);
+ this.convBrowser.isActive = true;
+ }
+
+ switchingAwayFromPanel(aHidden) {
+ if (this._visibleTimer) {
+ clearTimeout(this._visibleTimer);
+ delete this._visibleTimer;
+ }
+ // Remove the unread ruler if the tab has been visible without
+ // interruptions for sufficiently long.
+ if (this._wasVisible) {
+ this.convBrowser.removeUnreadRuler();
+ }
+
+ if (aHidden) {
+ this.convBrowser.isActive = false;
+ }
+ }
+
+ updateConvStatus() {
+ let cti = document.getElementById("conv-top-info");
+ cti.setProtocol(this._conv.account.protocol);
+
+ // Set the icon, potentially showing a fallback icon if this is an IM.
+ cti.setUserIcon(this._conv.convIconFilename, !this._conv.isChat);
+
+ if (this._conv.isChat) {
+ this.updateTopic();
+ cti.setAttribute("displayName", this._conv.title);
+ } else {
+ let displayName = this._conv.title;
+ let statusText = "";
+ let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN;
+
+ let buddy = this._conv.buddy;
+ if (buddy?.account.connected) {
+ displayName = buddy.displayName;
+ statusText = buddy.statusText;
+ statusType = buddy.statusType;
+ }
+ cti.setAttribute("displayName", displayName);
+
+ let statusName;
+
+ let typingState = this._conv.typingState;
+ let typingName = this._currentTypingName || this._conv.title;
+
+ switch (typingState) {
+ case Ci.prplIConvIM.TYPING:
+ statusName = "active-typing";
+ statusText = this.bundle.formatStringFromName(
+ "chat.contactIsTyping",
+ [typingName],
+ 1
+ );
+ break;
+ case Ci.prplIConvIM.TYPED:
+ statusName = "paused-typing";
+ statusText = this.bundle.formatStringFromName(
+ "chat.contactHasStoppedTyping",
+ [typingName],
+ 1
+ );
+ break;
+ default:
+ statusName = Status.toAttribute(statusType);
+ statusText = Status.toLabel(statusType, statusText);
+ break;
+ }
+ cti.setStatus(statusName, statusText);
+ }
+ }
+
+ showParticipants() {
+ if (this._conv.isChat) {
+ let nicklist = document.getElementById("nicklist");
+ while (nicklist.hasChildNodes()) {
+ nicklist.lastChild.remove();
+ }
+ // Populate the nicklist
+ this.buddies = new Map();
+ for (let n of this.conv.getParticipants()) {
+ this.createBuddy(n);
+ }
+ nicklist.append(
+ ...Array.from(this.buddies.keys())
+ .sort((a, b) => a.localeCompare(b))
+ .map(nick => this.buddies.get(nick))
+ );
+ this.updateParticipantCount();
+ }
+ }
+
+ /**
+ * Set up the shared conversation specific components (conversation browser
+ * references, status header, participants list, text input) for this
+ * conversation.
+ */
+ initConversationUI() {
+ this._activeBuddies = {};
+ if (this._conv.isChat) {
+ let cti = document.getElementById("conv-top-info");
+ cti.setAttribute("displayName", this._conv.title);
+
+ this.showParticipants();
+
+ if (Services.prefs.getBoolPref("messenger.conversations.showNicks")) {
+ this.convBrowser.addTextModifier(this.getShowNickModifier());
+ }
+ }
+
+ if (this.tab) {
+ this.tab.setAttribute("label", this._conv.title);
+ }
+
+ this.findbar.browser = this.convBrowser;
+
+ this.updateConvStatus();
+ this.initTextboxFormat();
+ }
+
+ /**
+ * Change the UI Conversation attached to this component and its browser.
+ * Does not clear any existing messages in the conversation browser.
+ *
+ * @param {imIConversation} conv
+ */
+ changeConversation(conv) {
+ this._conv.removeObserver(this.observer);
+ this._conv = conv;
+ this._conv.addObserver(this.observer);
+ this.convBrowser._conv = conv;
+ this.initConversationUI();
+ }
+
+ get editor() {
+ return this.inputBox;
+ }
+
+ get _isConversationSelected() {
+ // TB-only: returns true if the chat conversation element is the currently
+ // selected one, i.e if it has to maintain the participant list.
+ // The JS property this.tab.selected is always false when the chat tab
+ // is inactive, so we need to double-check to be sure.
+ return this.tab.selected || this.tab.hasAttribute("selected");
+ }
+
+ get convId() {
+ return this._conv.id;
+ }
+
+ get conv() {
+ return this._conv;
+ }
+
+ set conv(val) {
+ if (this._conv && val) {
+ throw new Error("chat-conversation already initialized");
+ }
+ if (!val) {
+ // this conversation has probably been moved to another
+ // tab. Forget the prplConversation so that it isn't
+ // closed when destroying this binding.
+ this._forgetConv();
+ return;
+ }
+ this._conv = val;
+ this._conv.addObserver(this.observer);
+ this.convBrowser.init(this._conv);
+ this.initConversationUI();
+ }
+
+ get contentWindow() {
+ return this.convBrowser.contentWindow;
+ }
+
+ get bundle() {
+ if (!this._bundle) {
+ this._bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ }
+ return this._bundle;
+ }
+ }
+
+ customElements.define("chat-conversation", MozChatConversation);
+}
diff --git a/comm/mail/components/im/content/chat-group.js b/comm/mail/components/im/content/chat-group.js
new file mode 100644
index 0000000000..80bf25159c
--- /dev/null
+++ b/comm/mail/components/im/content/chat-group.js
@@ -0,0 +1,255 @@
+/* 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 MozXULElement, MozElements */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozChatGroupRichlistitem widget displays chat group name and behave as a
+ * expansion twisty for groups such as "Conversations",
+ * "Online Contacts" and "Offline Contacts".
+ *
+ * @augments {MozElements.MozRichlistitem}
+ */
+ class MozChatGroupRichlistitem extends MozElements.MozRichlistitem {
+ static get inheritedAttributes() {
+ return {
+ label: "value=name",
+ };
+ }
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "chat-group-richlistitem");
+ this.setAttribute("collapsed", "true");
+
+ /* Here we use a div, rather than the usual img because the icon image
+ * relies on CSS -moz-locale-dir(rtl). The corresponding icon
+ * twisty-collapsed-rtl icon is not a simple mirror transformation of
+ * twisty-collapsed.
+ * Currently, CSS sets the background-image based on the "closed" state.
+ * The element is a visual decoration and does not require any alt text
+ * since the aria-expanded attribute describes its state.
+ */
+ this._image = document.createElement("div");
+ this._image.classList.add("twisty");
+
+ this._label = document.createXULElement("label");
+ this._label.setAttribute("flex", "1");
+ this._label.setAttribute("crop", "end");
+
+ this.appendChild(this._image);
+ this.appendChild(this._label);
+
+ this.contacts = [];
+
+ this.contactsById = {};
+
+ this.displayName = "";
+
+ this.addEventListener("click", event => {
+ // Check if there was 1 click on the image or 2 clicks on the label
+ if (
+ (event.detail == 1 && event.target.classList.contains("twisty")) ||
+ (event.detail == 2 && event.target.localName == "label")
+ ) {
+ this.toggleClosed();
+ } else if (event.target.localName == "button") {
+ this.hide();
+ }
+ });
+
+ this.addEventListener("contextmenu", event => {
+ event.preventDefault();
+ });
+
+ if (this.classList.contains("closed")) {
+ this.setAttribute("aria-expanded", "true");
+ } else {
+ this.setAttribute("aria-expanded", "false");
+ }
+
+ this.initializeAttributeInheritance();
+ }
+
+ /**
+ * Takes as input two contact elements (imIContact type) and compares
+ * their nicknames alphabetically (case insensitive). This method
+ * behaves as a callback that Array.prototype.sort accepts as a
+ * parameter.
+ */
+ sortComparator(contactA, contactB) {
+ if (contactA.statusType != contactB.statusType) {
+ return contactB.statusType - contactA.statusType;
+ }
+ let a = contactA.displayName.toLowerCase();
+ let b = contactB.displayName.toLowerCase();
+ return a.localeCompare(b);
+ }
+
+ addContact(contact, tagName) {
+ if (this.contactsById.hasOwnProperty(contact.id)) {
+ return null;
+ }
+
+ let contactElt;
+ if (tagName) {
+ contactElt = document.createXULElement("richlistitem", {
+ is: "chat-imconv-richlistitem",
+ });
+ } else {
+ contactElt = document.createXULElement("richlistitem", {
+ is: "chat-contact-richlistitem",
+ });
+ }
+ if (this.classList.contains("closed")) {
+ contactElt.setAttribute("collapsed", "true");
+ }
+
+ let end = this.contacts.length;
+ // Avoid the binary search loop if the contacts were already sorted.
+ if (
+ end != 0 &&
+ this.sortComparator(contact, this.contacts[end - 1].contact) < 0
+ ) {
+ let start = 0;
+ while (start < end) {
+ let middle = start + Math.floor((end - start) / 2);
+ if (this.sortComparator(contact, this.contacts[middle].contact) < 0) {
+ end = middle;
+ } else {
+ start = middle + 1;
+ }
+ }
+ }
+ let last = end == 0 ? this : this.contacts[end - 1];
+ this.parentNode.insertBefore(contactElt, last.nextElementSibling);
+ contactElt.build(contact);
+ contactElt.group = this;
+ this.contacts.splice(end, 0, contactElt);
+ this.contactsById[contact.id] = contactElt;
+ this.removeAttribute("collapsed");
+ this._updateGroupLabel();
+ return contactElt;
+ }
+
+ updateContactPosition(subject, tagName) {
+ let contactElt = this.contactsById[subject.id];
+ let index = this.contacts.indexOf(contactElt);
+ if (index == -1) {
+ // Sometimes we get a display-name-changed notification for
+ // an offline contact, if it's not in the list, just ignore it.
+ return;
+ }
+ // See if the position of the contact should be changed.
+ if (
+ (index != 0 &&
+ this.sortComparator(
+ contactElt.contact,
+ this.contacts[index - 1].contact
+ ) < 0) ||
+ (index != this.contacts.length - 1 &&
+ this.sortComparator(
+ contactElt.contact,
+ this.contacts[index + 1].contact
+ ) > 0)
+ ) {
+ let list = this.parentNode;
+ let selectedItem = list.selectedItem;
+ let oldItem = this.removeContact(subject);
+ let newItem = this.addContact(subject, tagName);
+ if (selectedItem == oldItem) {
+ list.selectedItem = newItem;
+ }
+ }
+ }
+
+ removeContact(contactForID) {
+ let contact = this.contactsById[contactForID.id];
+ if (!contact) {
+ throw new Error("Can't remove contact for id=" + contactForID.id);
+ }
+
+ // create a new array to remove without breaking for each loops.
+ this.contacts = this.contacts.filter(c => c !== contact);
+ delete this.contactsById[contact.contact.id];
+
+ contact.destroy();
+
+ // Check if some contacts remain in the group, if empty hide it.
+ if (!this.contacts.length) {
+ this.setAttribute("collapsed", "true");
+ } else {
+ this._updateGroupLabel();
+ }
+
+ return contact;
+ }
+
+ _updateClosedState(closed) {
+ for (let contact of this.contacts) {
+ contact.collapsed = closed;
+ }
+ }
+
+ toggleClosed() {
+ if (this.classList.contains("closed")) {
+ this.classList.remove("closed");
+ this.setAttribute("aria-expanded", "true");
+ this._updateClosedState(false);
+ } else {
+ this.classList.add("closed");
+ this.setAttribute("aria-expanded", "false");
+ this._updateClosedState(true);
+ }
+
+ this._updateGroupLabel();
+ }
+
+ _updateGroupLabel() {
+ if (!this.displayName) {
+ this.displayName = this.getAttribute("name");
+ }
+ let name = this.displayName;
+ if (this.classList.contains("closed")) {
+ name += " (" + this.contacts.length + ")";
+ }
+
+ this.setAttribute("name", name);
+ }
+
+ keyPress(event) {
+ switch (event.keyCode) {
+ case event.DOM_VK_RETURN:
+ this.toggleClosed();
+ break;
+
+ case event.DOM_VK_LEFT:
+ if (!this.classList.contains("closed")) {
+ this.toggleClosed();
+ }
+ break;
+
+ case event.DOM_VK_RIGHT:
+ if (this.classList.contains("closed")) {
+ this.toggleClosed();
+ }
+ break;
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozChatGroupRichlistitem, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define("chat-group-richlistitem", MozChatGroupRichlistitem, {
+ extends: "richlistitem",
+ });
+}
diff --git a/comm/mail/components/im/content/chat-imconv.js b/comm/mail/components/im/content/chat-imconv.js
new file mode 100644
index 0000000000..759a3ce78a
--- /dev/null
+++ b/comm/mail/components/im/content/chat-imconv.js
@@ -0,0 +1,366 @@
+/* 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 MozElements, MozXULElement, gChatTab, chatHandler */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { Status } = ChromeUtils.importESModule(
+ "resource:///modules/imStatusUtils.sys.mjs"
+ );
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+
+ /**
+ * The MozChatConvRichlistitem widget displays opened conversation information from the
+ * contacts: i.e name and icon. It gets displayed under conversation expansion
+ * twisty in the contactlist richlistbox.
+ *
+ * @augments {MozElements.MozRichlistitem}
+ */
+ class MozChatConvRichlistitem extends MozElements.MozRichlistitem {
+ static get inheritedAttributes() {
+ return {
+ ".box-line": "selected",
+ ".convDisplayName": "value=displayname,status",
+ ".convUnreadTargetedCount": "value=unreadTargetedCount",
+ ".convUnreadCount": "value=unreadCount",
+ ".convUnreadTargetedCountLabel": "value=unreadTargetedCount",
+ };
+ }
+
+ static get markup() {
+ return `
+ <vbox class="box-line"></vbox>
+ <button class="closeConversationButton close-icon"
+ tooltiptext="&closeConversationButton.tooltip;"></button>
+ <stack class="prplBuddyIcon">
+ <html:img class="protoIcon" alt="" />
+ <html:img class="smallStatusIcon" />
+ </stack>
+ <hbox flex="1" class="conv-hbox">
+ <label crop="end" class="convDisplayName blistDisplayName">
+ </label>
+ <label class="convUnreadCount" crop="end"></label>
+ <box class="convUnreadTargetedCount">
+ <label class="convUnreadTargetedCountLabel" crop="end"></label>
+ </box>
+ <spacer style="flex: 1000000 1000000;"></spacer>
+ </hbox>
+ `;
+ }
+
+ static get entities() {
+ return ["chrome://messenger/locale/chat.dtd"];
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "chat-imconv-richlistitem");
+
+ this.addEventListener(
+ "mousedown",
+ event => {
+ if (event.target.classList.contains("closeConversationButton")) {
+ this.closeConversation();
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ },
+ true
+ );
+
+ this.appendChild(this.constructor.fragment);
+
+ this.convView = null;
+
+ this.directedUnreadCount = 0;
+
+ new MutationObserver(mutations => {
+ if (!this.convView || !this.convView.loaded) {
+ return;
+ }
+ if (this.hasAttribute("selected")) {
+ this.convView.switchingToPanel();
+ } else {
+ this.convView.switchingAwayFromPanel(true);
+ }
+ }).observe(this, { attributes: true, attributeFilter: ["selected"] });
+
+ // @implements {nsIObserver}
+ this.observer = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+ observe: function (subject, topic, data) {
+ if (
+ topic == "target-prpl-conversation-changed" ||
+ topic == "unread-message-count-changed" ||
+ topic == "update-conv-title" ||
+ topic == "update-buddy-status" ||
+ topic == "update-buddy-status" ||
+ topic == "update-conv-chatleft" ||
+ topic == "update-conv-chatjoining" ||
+ topic == "chat-update-topic"
+ ) {
+ this.update();
+ }
+ if (topic == "update-conv-title") {
+ this.group.updateContactPosition(
+ this.conv,
+ "chat-imconv-richlistitem"
+ );
+ }
+ }.bind(this),
+ };
+
+ if (this.hasAttribute("is-search-result")) {
+ let icon = this.querySelector(".protoIcon");
+ icon.classList.add("searchProtoIcon");
+ icon.setAttribute("src", "chrome://global/skin/icons/search-glass.svg");
+ let statusIcon = this.querySelector(".smallStatusIcon");
+ statusIcon.hidden = true;
+ this.setAttribute("unreadCount", "0");
+ this.setAttribute("unreadTargetedCount", "0");
+ }
+
+ this.initializeAttributeInheritance();
+ }
+
+ get displayName() {
+ return this.conv.title;
+ }
+
+ /**
+ * This getter exists to provide compatibility with the imgroup sortComparator.
+ */
+ get contact() {
+ return this.conv;
+ }
+
+ set selected(val) {
+ if (val) {
+ this.setAttribute("selected", "true");
+ } else {
+ this.removeAttribute("selected");
+ }
+ }
+
+ get selected() {
+ return (
+ gChatTab &&
+ gChatTab.tabNode.selected &&
+ this.getAttribute("selected") == "true"
+ );
+ }
+
+ /**
+ * Set the conversation this item should represent. Updates appearance and
+ * adds observers to keep it up to date.
+ *
+ * @param {imIConversation} conv - Conversation this item represents.
+ */
+ build(conv) {
+ this.conv = conv;
+ this.conv.addObserver(this.observer);
+ this.update();
+ }
+
+ update() {
+ this.setAttribute("displayname", this.displayName);
+ if (this.selected && document.hasFocus()) {
+ if (this.convView && this.convView.loaded) {
+ this.conv.markAsRead();
+ this.directedUnreadCount = 0;
+ chatHandler.updateTitle();
+ chatHandler.updateChatButtonState();
+ }
+ this.setAttribute("unreadCount", "0");
+ this.setAttribute("unreadTargetedCount", "0");
+ this.removeAttribute("unread");
+ this.removeAttribute("attention");
+ } else {
+ let unreadCount =
+ this.conv.unreadIncomingMessageCount +
+ this.conv.unreadOTRNotificationCount;
+ let directedMessages = unreadCount;
+ if (unreadCount) {
+ this.setAttribute("unread", "true");
+ if (this.conv.isChat) {
+ directedMessages = this.conv.unreadTargetedMessageCount;
+ if (directedMessages) {
+ this.setAttribute("attention", "true");
+ }
+ }
+ unreadCount -= directedMessages;
+ if (directedMessages > this.directedUnreadCount) {
+ this.directedUnreadCount = directedMessages;
+ }
+ }
+ if (unreadCount) {
+ unreadCount = "(" + unreadCount + ")";
+ }
+ this.setAttribute("unreadCount", unreadCount);
+ if (
+ Services.prefs.getBoolPref(
+ "messenger.options.getAttentionOnNewMessages"
+ ) &&
+ directedMessages > parseInt(this.getAttribute("unreadTargetedCount"))
+ ) {
+ window.getAttention();
+ }
+ this.setAttribute("unreadTargetedCount", directedMessages);
+ chatHandler.updateTitle();
+ }
+
+ let statusIcon = this.querySelector(".smallStatusIcon");
+ let statusName;
+ statusIcon.hidden = false;
+ if (this.conv.isChat) {
+ if (this.conv.joining) {
+ statusName = "joining";
+ } else if (!this.conv.account.connected || this.conv.left) {
+ statusName = "left";
+ }
+ if (statusName) {
+ statusIcon.setAttribute(
+ "src",
+ ChatIcons.getStatusIconURI(statusName)
+ );
+ // Set alt using messenger/chat.ftl.
+ document.l10n.setAttributes(
+ statusIcon,
+ `chat-${statusName}-chat-icon2`
+ );
+ } else {
+ statusIcon.removeAttribute("src");
+ statusIcon.removeAttribute("data-l10n-id");
+ statusIcon.removeAttribute("alt");
+ statusIcon.hidden = true;
+ // Treat protoIcon as if connected.
+ statusName = "connected";
+ }
+ } else {
+ let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN;
+ let buddy = this.conv.buddy;
+ if (buddy && buddy.account.connected) {
+ statusType = buddy.statusType;
+ }
+ statusName = Status.toAttribute(statusType);
+ statusIcon.setAttribute("src", ChatIcons.getStatusIconURI(statusName));
+ statusIcon.removeAttribute("data-l10n-id");
+ statusIcon.setAttribute("alt", Status.toLabel(statusType));
+ }
+
+ if (!this.hasAttribute("is-search-result")) {
+ let protoIcon = this.querySelector(".protoIcon");
+ protoIcon.setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(this.conv.account.protocol)
+ );
+ ChatIcons.setProtocolIconOpacity(protoIcon, statusName);
+ }
+ }
+
+ destroy() {
+ if (this.conv) {
+ this.conv.removeObserver(this.observer);
+ }
+ if (this.convView) {
+ this.convView.destroy();
+ this.convView.remove();
+ }
+
+ // If the conversation we are destroying was selected, we should
+ // select something else, but the 'select' event handler of
+ // the listbox will choke while updating the Chat tab title if
+ // there are conversation nodes associated with a conversation
+ // that no longer exists from the chat core's point of view, so
+ // we do the actual selection change only after this conversation
+ // item is fully destroyed and removed from the list.
+ let newSelectedItem;
+ let list = this.parentNode;
+ if (list.selectedItem == this) {
+ newSelectedItem = this.previousElementSibling;
+ }
+
+ if (this.log) {
+ this.hidden = true;
+ delete this.log;
+ } else {
+ this.remove();
+ delete this.conv;
+ }
+ if (newSelectedItem) {
+ list.selectedItem = newSelectedItem;
+ }
+ }
+
+ closeConversation() {
+ if (this.conv) {
+ this.conv.close();
+ } else {
+ this.destroy();
+ }
+ }
+
+ keyPress(event) {
+ // If Enter or Return is pressed, focus the input box.
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.convView.focus();
+ return;
+ }
+
+ let accelKeyPressed =
+ AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey;
+ // If a character was typed or the accel+v copy shortcut was used,
+ // focus the input box and resend the key event.
+ if (
+ event.charCode != 0 &&
+ !event.altKey &&
+ ((accelKeyPressed && event.charCode == "v".charCodeAt(0)) ||
+ (!event.ctrlKey && !event.metaKey))
+ ) {
+ this.convView.focus();
+
+ let clonedEvent = new KeyboardEvent("keypress", event);
+ this.convView.editor.dispatchEvent(clonedEvent);
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * Replace the conversation that this item represents.
+ *
+ * @param {imIConversation} conv - Updated conversation this should
+ * represent.
+ */
+ changeConversation(conv) {
+ this.conv?.removeObserver(this.observer);
+ this.build(conv);
+ }
+
+ disconnectedCallback() {
+ if (this.conv) {
+ this.conv.removeObserver(this.observer);
+ delete this.conv;
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozChatConvRichlistitem, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define("chat-imconv-richlistitem", MozChatConvRichlistitem, {
+ extends: "richlistitem",
+ });
+}
diff --git a/comm/mail/components/im/content/chat-menu.inc.xhtml b/comm/mail/components/im/content/chat-menu.inc.xhtml
new file mode 100644
index 0000000000..8ded5e0edb
--- /dev/null
+++ b/comm/mail/components/im/content/chat-menu.inc.xhtml
@@ -0,0 +1,109 @@
+# 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/.
+
+ <tooltip is="chat-tooltip" id="imTooltip"/>
+
+ <menupopup id="buddyListContextMenu"
+ onpopupshowing="if (event.target != this) { return true; } gBuddyListContextMenu = new buddyListContextMenu(this); return gBuddyListContextMenu.shouldDisplay;"
+ onpopuphiding="if (event.target == this) { gBuddyListContextMenu = null; }">
+ <menuitem id="context-openconversation"
+ label="&openConversationCmd.label;"
+ accesskey="&openConversationCmd.accesskey;"
+ oncommand="gBuddyListContextMenu.openConversation();"/>
+ <menuitem id="context-close-conversation"
+ label="&closeConversationCmd.label;"
+ accesskey="&closeConversationCmd.accesskey;"
+ oncommand="gBuddyListContextMenu.closeConversation();"/>
+ <menuitem id="context-verifyBuddy"
+ data-l10n-id="chat-verify-identity"
+ oncommand="gBuddyListContextMenu.verifyIdentity();"/>
+ <menuseparator id="context-edit-buddy-separator"/>
+ <menuitem id="context-alias"
+ label="&aliasCmd.label;"
+ accesskey="&aliasCmd.accesskey;"
+ oncommand="gBuddyListContextMenu.alias();"/>
+ <menuitem id="context-delete"
+ data-l10n-id="text-action-delete"
+ oncommand="gBuddyListContextMenu.delete();"/>
+ </menupopup>
+
+ <menupopup id="chatConversationContextMenu"
+ onpopupshowing="if (event.target != this) { return true; } gChatContextMenu = new imContextMenu(this); return gChatContextMenu.shouldDisplay;"
+ onpopuphiding="if (event.target == this &amp;&amp; gChatContextMenu) { gChatContextMenu.cleanup(); gChatContextMenu = null; }">
+ <menuitem id="context-openlink"
+ label="&openLinkCmd.label;"
+ accesskey="&openLinkCmd.accesskey;"
+ oncommand="gChatContextMenu.openLink();"/>
+ <menuitem id="context-copyemail"
+ label="&copyEmailCmd.label;"
+ accesskey="&copyEmailCmd.accesskey;"
+ oncommand="gChatContextMenu.copyEmail();"/>
+ <menuitem id="context-copylink"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"
+ oncommand="goDoCommand('cmd_copyLink');"/>
+ <menuseparator id="context-sep-copylink"/>
+
+ <menuitem id="context-copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuitem id="context-selectall"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ <menuseparator id="context-sep-messageactions"/>
+ </menupopup>
+
+ <menupopup id="chat-toolbar-context-menu">
+ <menuitem id="CustomizeChatToolbar"
+ oncommand="CustomizeMailToolbar('chat-view-toolbox', 'CustomizeChatToolbar')"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"/>
+ </menupopup>
+
+ <menupopup id="chatContextMenu"
+ onpopupshowing="if (event.target != this) { return true; } openChatContextMenu(this);"
+ onpopuphiding="if (event.target == this) { clearChatContextMenu(this); }">
+
+ <!-- Spellchecking menu items -->
+ <menuitem id="spellCheckNoSuggestions"
+ data-l10n-id="text-action-spell-no-suggestions"
+ disabled="true"/>
+ <menuseparator id="spellCheckAddSep" />
+ <menuitem id="spellCheckAddToDictionary"
+ data-l10n-id="text-action-spell-add-to-dictionary"
+ oncommand="gChatSpellChecker.addToDictionary();"/>
+ <menuseparator id="spellCheckSuggestionsSeparator"/>
+
+ <menuitem data-l10n-id="text-action-undo" command="cmd_undo"/>
+ <menuitem data-l10n-id="text-action-cut" command="cmd_cut"/>
+ <menuitem data-l10n-id="text-action-copy" command="cmd_copy"/>
+ <menuitem data-l10n-id="text-action-paste" command="cmd_paste"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll"/>
+
+ <!-- Spellchecking general menu items (enable, add dictionaries...) -->
+ <menuseparator id="spellCheckSeparator"/>
+ <menuitem id="spellCheckEnable"
+ data-l10n-id="text-action-spell-check-toggle"
+ type="checkbox"
+ oncommand="enableInlineSpellCheck(!gChatSpellChecker.enabled);"/>
+ <menu id="spellCheckDictionaries"
+ data-l10n-id="text-action-spell-dictionaries">
+ <menupopup id="spellCheckDictionariesMenu">
+ <menuseparator id="spellCheckLanguageSeparator"/>
+ <menuitem id="spellCheckAddDictionaries"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="openDictionaryList();"/>
+ </menupopup>
+ </menu>
+
+ </menupopup>
+
+ <menupopup id="participantListContextMenu"
+ onpopupshowing="return showParticipantMenu(this);">
+ <menuitem id="context-verifyParticipant"
+ data-l10n-id="chat-verify-identity"
+ oncommand="verifyChatParticipant();"/>
+ </menupopup>
diff --git a/comm/mail/components/im/content/chat-messenger.inc.xhtml b/comm/mail/components/im/content/chat-messenger.inc.xhtml
new file mode 100644
index 0000000000..6b1fbb9f8f
--- /dev/null
+++ b/comm/mail/components/im/content/chat-messenger.inc.xhtml
@@ -0,0 +1,192 @@
+# 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/.
+
+ <vbox id="chatTabPanel">
+ <toolbox id="chat-view-toolbox" class="mail-toolbox"
+ mode="full" defaultmode="full"
+ labelalign="end" defaultlabelalign="end">
+ <toolbar is="customizable-toolbar" id="chat-toolbar"
+ class="inline-toolbar chromeclass-toolbar themeable-full"
+ fullscreentoolbar="true"
+ customizable="true"
+ context="chat-toolbar-context-menu"
+ mode="full"
+#ifdef XP_MACOSX
+ iconsize="small"
+#endif
+ defaultset="button-add-buddy,button-join-chat,spacer,chat-status-selector,button-chat-accounts,spacer,gloda-im-search"/>
+
+ <toolbarpalette id="ChatToolbarPalette">
+ <toolbarbutton id="button-add-buddy"
+ class="toolbarbutton-1"
+ label="&addBuddyButton.label;"
+ oncommand="chatHandler.addBuddy()"/>
+ <toolbarbutton id="button-join-chat"
+ class="toolbarbutton-1"
+ label="&joinChatButton.label;"
+ oncommand="chatHandler.joinChat()"/>
+ <toolbaritem id="chat-status-selector"
+ orient="horizontal"
+ align="center" flex="1">
+ <toolbarbutton id="statusTypeIcon"
+ type="menu"
+ wantdropmarker="true"
+ class="toolbarbutton-1"
+ status="available">
+ <menupopup id="setStatusTypeMenupopup"
+ oncommand="statusSelector.editStatus(event);">
+ <menuitem id="statusTypeAvailable" label="&status.available;"
+ status="available" class="menuitem-iconic"/>
+ <menuitem id="statusTypeUnavailable" label="&status.unavailable;"
+ status="unavailable" class="menuitem-iconic"/>
+ <menuseparator id="statusTypeOfflineSeparator"/>
+ <menuitem id="statusTypeOffline" label="&status.offline;"
+ status="offline" class="menuitem-iconic"/>
+ </menupopup>
+ </toolbarbutton>
+ <vbox flex="1"
+ orient="horizontal"
+ align="center"
+ class="input-container status-container">
+ <label id="statusMessageLabel"
+ flex="1"
+ value=""
+ class="statusMessageToolbarItem label-inline"
+ onclick="statusSelector.statusMessageClick();"/>
+ <html:input id="statusMessageInput"
+ value=""
+ class="statusMessageInput statusMessageToolbarItem status-message-input"
+ hidden="hidden"/>
+ </vbox>
+ </toolbaritem>
+ <toolbarbutton id="button-chat-accounts"
+ class="toolbarbutton-1"
+ label="&chatAccountsButton.label;"
+ oncommand="openIMAccountMgr()"/>
+ </toolbarpalette>
+ </toolbox>
+
+ <vbox flex="1">
+ <hbox id="chatPanel" flex="1">
+ <vbox id="listPaneBox" style="min-width:125px;" width="200" persist="width">
+ <richlistbox id="contactlistbox"
+ context="buddyListContextMenu"
+ tooltip="imTooltip" flex="1">
+ <richlistitem is="chat-group-richlistitem" id="conversationsGroup"
+ name="&conversationsHeader.label;"/>
+ <richlistitem is="chat-imconv-richlistitem"
+ id="searchResultConv"
+ displayname="&searchResultConversation.label;"
+ is-search-result=""
+ hidden="true"/>
+ <richlistitem is="chat-group-richlistitem" id="onlinecontactsGroup"
+ name="&onlineContactsHeader.label;"/>
+ <richlistitem is="chat-group-richlistitem" id="offlinecontactsGroup"
+ name="&offlineContactsHeader.label;"
+ class="closed"/>
+ </richlistbox>
+ </vbox>
+ <splitter id="listSplitter" collapse="before"/>
+ <vbox id="chat-notification-top" flex="1">
+ <!-- notificationbox will be added here lazily. -->
+ <vbox id="conversationsBox" flex="1">
+
+ <vbox flex="1" id="noConvScreen" class="im-placeholder-screen" align="center" pack="center">
+ <hbox id="noConvBox" class="im-placeholder-box" align="start">
+ <vbox id="noConvInnerBox" class="im-placeholder-innerbox" flex="1">
+ <label id="noConvTitle" class="im-placeholder-title">&chat.noConv.title;</label>
+ <description id="noConvDesc"
+ class="im-placeholder-desc">&chat.noConv.description;</description>
+ </vbox>
+ <vbox id="noAccountInnerBox" class="im-placeholder-innerbox" flex="1" hidden="true">
+ <label id="noAccountTitle" class="im-placeholder-title">&chat.noAccount.title;</label>
+ <description id="noAccountDesc"
+ class="im-placeholder-desc">&chat.noAccount.description;</description>
+ <hbox class="im-placeholder-button-box" flex="1">
+ <spacer flex="1"/>
+ <button id="openIMAccountWizardButton" label="&chat.accountWizard.button;"
+ oncommand="openIMAccountWizard();"/>
+ </hbox>
+ </vbox>
+ <vbox id="noConnectedAccountInnerBox" class="im-placeholder-innerbox" flex="1" hidden="true">
+ <label id="noConnectedAccountTitle"
+ class="im-placeholder-title">&chat.noConnectedAccount.title;</label>
+ <description id="noConnectedAccountDesc"
+ class="im-placeholder-desc">&chat.noConnectedAccount.description;</description>
+ <hbox class="im-placeholder-button-box" flex="1">
+ <spacer flex="1"/>
+ <button id="openIMAccountManagerButton" label="&chat.showAccountManager.button;"
+ oncommand="openIMAccountMgr();"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ </vbox>
+
+ <vbox id="logDisplay" flex="1" hidden="true">
+ <vbox flex="1">
+ <vbox flex="1" id="noPreviousConvScreen" class="im-placeholder-screen" align="center" pack="center">
+ <hbox id="noPreviousConvBox" class="im-placeholder-box" align="start">
+ <vbox id="noPreviousConvInnerBox" class="im-placeholder-innerbox" flex="1">
+ <description id="noPreviousConvDesc"
+ class="im-placeholder-desc">&chat.noPreviousConv.description;</description>
+ </vbox>
+ </hbox>
+ </vbox>
+ <vbox flex="1" id="logDisplayBrowserBox">
+ <browser id="conv-log-browser" is="conversation-browser" type="content"
+ contextmenu="chatConversationContextMenu" flex="1"
+ tooltip="imTooltip"
+ messagemanagergroup="browsers"/>
+ <html:progress id="log-browserProgress" max="100" hidden="true"/>
+ <findbar id="log-findbar" browserid="conv-log-browser"/>
+ </vbox>
+ </vbox>
+ <button id="goToConversation" hidden="true"
+ oncommand="chatHandler.showCurrentConversation();"/>
+ </vbox>
+
+ </vbox>
+ </vbox>
+ <splitter id="contextSplitter" hidden="true" collapse="after"/>
+ <vbox id="contextPane" hidden="true" width="250" persist="width">
+ <chat-conversation-info id="conv-top-info" class="conv-top-info"/>
+ <vbox id="contextPaneFlexibleBox" flex="1">
+ <vbox class="conv-chat" width="150">
+ <hbox align="baseline" class="conv-nicklist-header input-container">
+ <label class="conv-nicklist-header-label conv-header-label"
+ control="participantCount"
+ value="&chat.participants;"
+ crop="end"/>
+ <html:input id="participantCount" readonly="readonly" class="plain"/>
+ </hbox>
+ <richlistbox id="nicklist" class="conv-nicklist"
+ flex="1" seltype="multiple"
+ tooltip="imTooltip"
+ context="participantListContextMenu"
+ onclick="chatHandler.onNickClick(event);"
+ onkeypress="chatHandler.onNicklistKeyPress(event);"/>
+ </vbox>
+ <splitter id="logsSplitter" class="conv-chat" collapse="after" orient="vertical"/>
+ <vbox id="previousConversations" style="min-height: 200px;">
+ <label class="conv-logs-header-label conv-header-label"
+ crop="end"
+ value="&chat.previousConversations;"/>
+ <tree id="logTree" flex="1" hidecolumnpicker="true" seltype="single"
+ context="logTreeContext" onselect="chatHandler.onLogSelect();">
+ <treecols>
+ <treecol id="logCol"
+ style="flex: 1 auto"
+ primary="true"
+ hideheader="true"
+ crop="center"
+ ignoreincolumnpicker="true"/>
+ </treecols>
+ <treechildren/>
+ </tree>
+ </vbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ </vbox>
diff --git a/comm/mail/components/im/content/chat-messenger.js b/comm/mail/components/im/content/chat-messenger.js
new file mode 100644
index 0000000000..b3030bf9df
--- /dev/null
+++ b/comm/mail/components/im/content/chat-messenger.js
@@ -0,0 +1,2162 @@
+/* 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 MozElements MozXULElement */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+
+// This file is loaded in messenger.xhtml.
+/* globals MailToolboxCustomizeDone, openIMAccountMgr,
+ PROTO_TREE_VIEW, statusSelector, ZoomManager, gSpacesToolbar */
+
+var { Notifications } = ChromeUtils.importESModule(
+ "resource:///modules/chatNotifications.sys.mjs"
+);
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { Status } = ChromeUtils.importESModule(
+ "resource:///modules/imStatusUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs",
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+});
+
+var gChatSpellChecker;
+var gRangeParent;
+var gRangeOffset;
+
+var gBuddyListContextMenu = null;
+var gChatBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+);
+
+function openChatContextMenu(popup) {
+ let conv = chatHandler._getActiveConvView();
+ let spellchecker = conv.spellchecker;
+ let textbox = conv.editor;
+
+ // The context menu uses gChatSpellChecker, so set it here for the duration of the menu.
+ gChatSpellChecker = spellchecker;
+
+ spellchecker.init(textbox.editor);
+ spellchecker.initFromEvent(gRangeParent, gRangeOffset);
+ let onMisspelling = spellchecker.overMisspelling;
+ document.getElementById("spellCheckSuggestionsSeparator").hidden =
+ !onMisspelling;
+ document.getElementById("spellCheckAddToDictionary").hidden = !onMisspelling;
+ let separator = document.getElementById("spellCheckAddSep");
+ separator.hidden = !onMisspelling;
+ document.getElementById("spellCheckNoSuggestions").hidden =
+ !onMisspelling || spellchecker.addSuggestionsToMenu(popup, separator, 5);
+
+ let dictMenu = document.getElementById("spellCheckDictionariesMenu");
+ let dictSep = document.getElementById("spellCheckLanguageSeparator");
+ spellchecker.addDictionaryListToMenu(dictMenu, dictSep);
+
+ document
+ .getElementById("spellCheckEnable")
+ .setAttribute("checked", spellchecker.enabled);
+ document
+ .getElementById("spellCheckDictionaries")
+ .setAttribute("hidden", !spellchecker.enabled);
+
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_copy");
+ goUpdateCommand("cmd_cut");
+ goUpdateCommand("cmd_paste");
+ goUpdateCommand("cmd_selectAll");
+}
+
+function clearChatContextMenu(popup) {
+ let conv = chatHandler._getActiveConvView();
+ let spellchecker = conv.spellchecker;
+ spellchecker.clearDictionaryListFromMenu();
+ spellchecker.clearSuggestionsFromMenu();
+}
+
+function getSelectedPanel() {
+ for (let element of document.getElementById("conversationsBox").children) {
+ if (!element.hidden) {
+ return element;
+ }
+ }
+ return null;
+}
+
+/**
+ * Hide all the child elements in the conversations box. After hiding all the
+ * child elements, one element will be from chat conversation, chat log or
+ * no conversation screen.
+ */
+function hideConversationsBoxPanels() {
+ for (let element of document.getElementById("conversationsBox").children) {
+ element.hidden = true;
+ }
+}
+
+// This function modifies gChatSpellChecker and updates the UI accordingly. It's
+// called when the user clicks on context menu to toggle the spellcheck feature.
+function enableInlineSpellCheck(aEnableInlineSpellCheck) {
+ gChatSpellChecker.enabled = aEnableInlineSpellCheck;
+ document
+ .getElementById("spellCheckEnable")
+ .setAttribute("checked", aEnableInlineSpellCheck);
+ document
+ .getElementById("spellCheckDictionaries")
+ .setAttribute("hidden", !aEnableInlineSpellCheck);
+}
+
+function buddyListContextMenu(aXulMenu) {
+ // Clear the context menu from OTR related entries.
+ OTRUI.removeBuddyContextMenu(document);
+
+ this.target = aXulMenu.triggerNode.closest("richlistitem");
+ if (!this.target) {
+ this.shouldDisplay = false;
+ return;
+ }
+
+ this.menu = aXulMenu;
+ let localName = this.target.localName;
+ this.onContact =
+ localName == "richlistitem" &&
+ this.target.getAttribute("is") == "chat-contact-richlistitem";
+ this.onConv =
+ localName == "richlistitem" &&
+ this.target.getAttribute("is") == "chat-imconv-richlistitem";
+ this.shouldDisplay = this.onContact || this.onConv;
+
+ let hide = !this.onContact;
+ [
+ "context-openconversation",
+ "context-edit-buddy-separator",
+ "context-alias",
+ "context-delete",
+ ].forEach(function (aId) {
+ document.getElementById(aId).hidden = hide;
+ });
+
+ document.getElementById("context-close-conversation").hidden = !this.onConv;
+ document.getElementById("context-openconversation").disabled =
+ !hide && !this.target.canOpenConversation();
+
+ // Show OTR related context menu items if:
+ // - The OTR feature is currently enabled.
+ // - The target's status is not currently offline or unknown.
+ // - The target can send messages.
+ if (
+ ChatEncryption.otrEnabled &&
+ this.target.contact &&
+ this.target.contact.statusType != Ci.imIStatusInfo.STATUS_UNKNOWN &&
+ this.target.contact.statusType != Ci.imIStatusInfo.STATUS_OFFLINE &&
+ this.target.contact.canSendMessage
+ ) {
+ OTRUI.addBuddyContextMenu(this.menu, document, this.target.contact);
+ }
+
+ const accountBuddy = this._getAccountBuddy();
+ const canVerifyBuddy = accountBuddy?.canVerifyIdentity;
+ const verifyMenuItem = document.getElementById("context-verifyBuddy");
+ verifyMenuItem.hidden = !canVerifyBuddy;
+ if (canVerifyBuddy) {
+ const identityVerified = accountBuddy.identityVerified;
+ verifyMenuItem.disabled = identityVerified;
+ document.l10n.setAttributes(
+ verifyMenuItem,
+ identityVerified ? "chat-identity-verified" : "chat-verify-identity"
+ );
+ }
+}
+
+buddyListContextMenu.prototype = {
+ /**
+ * Get the prplIAccountBuddy instance that is related to the current context.
+ *
+ * @returns {prplIAccountBuddy?}
+ */
+ _getAccountBuddy() {
+ if (this.onConv && this.target.conv?.buddy) {
+ return this.target.conv.buddy;
+ }
+ return this.target.contact?.preferredBuddy?.preferredAccountBuddy;
+ },
+ openConversation() {
+ if (this.onContact || this.onConv) {
+ this.target.openConversation();
+ }
+ },
+ closeConversation() {
+ if (this.onConv) {
+ this.target.closeConversation();
+ }
+ },
+ alias() {
+ if (this.onContact) {
+ this.target.startAliasing();
+ }
+ },
+ delete() {
+ if (!this.onContact) {
+ return;
+ }
+
+ let buddy = this.target.contact.preferredBuddy;
+ let displayName = this.target.displayName;
+ let promptTitle = gChatBundle.formatStringFromName(
+ "buddy.deletePrompt.title",
+ [displayName]
+ );
+ let userName = buddy.userName;
+ if (displayName != userName) {
+ displayName = gChatBundle.formatStringFromName(
+ "buddy.deletePrompt.displayName",
+ [displayName, userName]
+ );
+ }
+ let proto = buddy.protocol.name; // FIXME build a list
+ let promptMessage = gChatBundle.formatStringFromName(
+ "buddy.deletePrompt.message",
+ [displayName, proto]
+ );
+ let deleteButton = gChatBundle.GetStringFromName(
+ "buddy.deletePrompt.button"
+ );
+ let prompts = Services.prompt;
+ let flags =
+ prompts.BUTTON_TITLE_IS_STRING * prompts.BUTTON_POS_0 +
+ prompts.BUTTON_TITLE_CANCEL * prompts.BUTTON_POS_1 +
+ prompts.BUTTON_POS_1_DEFAULT;
+ if (
+ prompts.confirmEx(
+ window,
+ promptTitle,
+ promptMessage,
+ flags,
+ deleteButton,
+ null,
+ null,
+ null,
+ {}
+ )
+ ) {
+ return;
+ }
+
+ this.target.deleteContact();
+ },
+ /**
+ * Command event handler to verify the identity of the buddy the context menu
+ * is currently opened for.
+ */
+ verifyIdentity() {
+ const accountBuddy = this._getAccountBuddy();
+ if (!accountBuddy) {
+ return;
+ }
+ ChatEncryption.verifyIdentity(window, accountBuddy);
+ },
+};
+
+var gChatTab = null;
+
+var chatTabType = {
+ name: "chat",
+ panelId: "chatTabPanel",
+ hasBeenOpened: false,
+ modes: {
+ chat: {
+ type: "chat",
+ },
+ },
+
+ tabMonitor: {
+ monitorName: "chattab",
+
+ // Unused, but needed functions
+ onTabTitleChanged() {},
+ onTabOpened(aTab) {},
+ onTabPersist() {},
+ onTabRestored() {},
+
+ onTabClosing() {
+ chatHandler._onTabDeactivated(true);
+ },
+ onTabSwitched(aNewTab, aOldTab) {
+ // aNewTab == chat is handled earlier by showTab() below.
+ if (aOldTab?.mode.name == "chat") {
+ chatHandler._onTabDeactivated(true);
+ }
+ },
+ },
+
+ _handleArgs(aArgs) {
+ if (
+ !aArgs ||
+ !("convType" in aArgs) ||
+ (aArgs.convType != "log" && aArgs.convType != "focus")
+ ) {
+ return;
+ }
+
+ if (aArgs.convType == "focus") {
+ chatHandler.focusConversation(aArgs.conv);
+ return;
+ }
+
+ let item = document.getElementById("searchResultConv");
+ item.log = aArgs.conv;
+ if (aArgs.searchTerm) {
+ item.searchTerm = aArgs.searchTerm;
+ } else {
+ delete item.searchTerm;
+ }
+ item.hidden = false;
+ if (item.getAttribute("selected")) {
+ chatHandler.onListItemSelected();
+ } else {
+ document.getElementById("contactlistbox").selectedItem = item;
+ }
+ },
+ _onWindowActivated() {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.currentTabInfo.mode.name == "chat") {
+ chatHandler._onTabActivated();
+ }
+ },
+ _onWindowDeactivated() {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.currentTabInfo.mode.name == "chat") {
+ chatHandler._onTabDeactivated(false);
+ }
+ },
+ openTab(aTab, aArgs) {
+ aTab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/chat.svg");
+ if (!this.hasBeenOpened) {
+ if (chatHandler.ChatCore && chatHandler.ChatCore.initialized) {
+ let convs = IMServices.conversations.getUIConversations();
+ if (convs.length != 0) {
+ convs.sort((a, b) =>
+ a.title.toLowerCase().localeCompare(b.title.toLowerCase())
+ );
+ for (let conv of convs) {
+ chatHandler._addConversation(conv);
+ }
+ }
+ }
+ this.hasBeenOpened = true;
+ }
+
+ // The tab monitor will inform us when a different tab is selected.
+ let tabmail = document.getElementById("tabmail");
+ tabmail.registerTabMonitor(this.tabMonitor);
+ window.addEventListener("deactivate", chatTabType._onWindowDeactivated);
+ window.addEventListener("activate", chatTabType._onWindowActivated);
+
+ gChatTab = aTab;
+ this._handleArgs(aArgs);
+ this.showTab(aTab);
+ chatHandler.updateTitle();
+ },
+ shouldSwitchTo(aArgs) {
+ if (!gChatTab) {
+ return -1;
+ }
+ this._handleArgs(aArgs);
+ return document.getElementById("tabmail").tabInfo.indexOf(gChatTab);
+ },
+ showTab(aTab) {
+ gChatTab = aTab;
+ chatHandler._onTabActivated();
+ // The next call may change the selected conversation, but that
+ // will be handled by the selected mutation observer of the chat-imconv-richlistitem.
+ chatHandler._updateSelectedConversation();
+ chatHandler._updateFocus();
+ },
+ closeTab(aTab) {
+ gChatTab = null;
+ let tabmail = document.getElementById("tabmail");
+ tabmail.unregisterTabMonitor(this.tabMonitor);
+ window.removeEventListener("deactivate", chatTabType._onWindowDeactivated);
+ window.removeEventListener("activate", chatTabType._onWindowActivated);
+ },
+ persistTab(aTab) {
+ return {};
+ },
+ restoreTab(aTabmail, aPersistedState) {
+ aTabmail.openTab("chat", {});
+ },
+
+ supportsCommand(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ return true;
+ default:
+ return false;
+ }
+ },
+ isCommandEnabled(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ return !!this.getBrowser();
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ return !!this.getFindbar();
+ default:
+ return false;
+ }
+ },
+ doCommand(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ ZoomManager.reduce();
+ break;
+ case "cmd_fullZoomEnlarge":
+ ZoomManager.enlarge();
+ break;
+ case "cmd_fullZoomReset":
+ ZoomManager.reset();
+ break;
+ case "cmd_fullZoomToggle":
+ ZoomManager.toggleZoom();
+ break;
+ case "cmd_find":
+ this.getFindbar().onFindCommand();
+ break;
+ case "cmd_findAgain":
+ this.getFindbar().onFindAgainCommand(false);
+ break;
+ case "cmd_findPrevious":
+ this.getFindbar().onFindAgainCommand(true);
+ break;
+ }
+ },
+ onEvent(aEvent, aTab) {},
+ getBrowser(aTab) {
+ let panel = getSelectedPanel();
+ if (panel == document.getElementById("logDisplay")) {
+ if (!document.getElementById("logDisplayBrowserBox").hidden) {
+ return document.getElementById("conv-log-browser");
+ }
+ } else if (panel && panel.localName == "chat-conversation") {
+ return panel.convBrowser;
+ }
+ return null;
+ },
+ getFindbar(aTab) {
+ let panel = getSelectedPanel();
+ if (panel == document.getElementById("logDisplay")) {
+ if (!document.getElementById("logDisplayBrowserBox").hidden) {
+ return document.getElementById("log-findbar");
+ }
+ } else if (panel && panel.localName == "chat-conversation") {
+ return panel.findbar;
+ }
+ return null;
+ },
+
+ saveTabState(aTab) {},
+};
+
+var chatHandler = {
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ document.getElementById("chat-notification-top").prepend(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ _addConversation(aConv) {
+ let list = document.getElementById("contactlistbox");
+ let convs = document.getElementById("conversationsGroup");
+ let selectedItem = list.selectedItem;
+ let shouldSelect =
+ gChatTab &&
+ gChatTab.tabNode.selected &&
+ (!selectedItem ||
+ (selectedItem == convs &&
+ convs.nextElementSibling.localName != "richlistitem" &&
+ convs.nextSibling.getAttribute("is") != "chat-imconv-richlistitem"));
+ let elt = convs.addContact(aConv, "imconv");
+ if (shouldSelect) {
+ list.selectedItem = elt;
+ }
+
+ if (aConv.isChat || !aConv.buddy) {
+ return;
+ }
+
+ let contact = aConv.buddy.buddy.contact;
+ elt.imContact = contact;
+ let groupName = (contact.online ? "on" : "off") + "linecontactsGroup";
+ let item = document.getElementById(groupName).removeContact(contact);
+ if (list.selectedItem == item) {
+ list.selectedItem = elt;
+ }
+ },
+
+ _hasConversationForContact(aContact) {
+ let convs = document.getElementById("conversationsGroup").contacts;
+ return convs.some(
+ aConversation =>
+ aConversation.hasOwnProperty("imContact") &&
+ aConversation.imContact.id == aContact.id
+ );
+ },
+
+ _chatButtonUpdatePending: false,
+ updateChatButtonState() {
+ if (this._chatButtonUpdatePending) {
+ return;
+ }
+ this._chatButtonUpdatePending = true;
+ Services.tm.mainThread.dispatch(
+ this._updateChatButtonState.bind(this),
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+ },
+ // This is the unread count that was part of the latest
+ // unread-im-count-changed notification.
+ _notifiedUnreadCount: 0,
+ _updateChatButtonState() {
+ delete this._chatButtonUpdatePending;
+
+ let [unreadTargetedCount, unreadTotalCount, unreadOTRNotificationCount] =
+ this.countUnreadMessages();
+ let unreadCount = unreadTargetedCount + unreadOTRNotificationCount;
+
+ let chatButton = document.getElementById("button-chat");
+ if (chatButton) {
+ chatButton.badgeCount = unreadCount;
+ if (unreadTotalCount || unreadOTRNotificationCount) {
+ chatButton.setAttribute("unreadMessages", "true");
+ } else {
+ chatButton.removeAttribute("unreadMessages");
+ }
+ }
+
+ let spacesChatButton = document.getElementById("chatButton");
+ if (spacesChatButton) {
+ spacesChatButton.classList.toggle("has-badge", unreadCount);
+ document.l10n.setAttributes(
+ spacesChatButton.querySelector(".spaces-badge-container"),
+ "chat-button-unread-messages",
+ {
+ count: unreadCount,
+ }
+ );
+ }
+ let spacesPopupButtonChat = document.getElementById(
+ "spacesPopupButtonChat"
+ );
+ if (spacesPopupButtonChat) {
+ spacesPopupButtonChat.classList.toggle("has-badge", unreadCount);
+ gSpacesToolbar.updatePinnedBadgeState();
+ }
+
+ let unifiedToolbarButtons = document.querySelectorAll(
+ "#unifiedToolbarContent .chat .unified-toolbar-button"
+ );
+ for (const button of unifiedToolbarButtons) {
+ if (unreadCount) {
+ button.badge = unreadCount;
+ continue;
+ }
+ button.badge = null;
+ }
+
+ if (unreadCount != this._notifiedUnreadCount) {
+ let unreadInt = Cc["@mozilla.org/supports-PRInt32;1"].createInstance(
+ Ci.nsISupportsPRInt32
+ );
+ unreadInt.data = unreadCount;
+ Services.obs.notifyObservers(
+ unreadInt,
+ "unread-im-count-changed",
+ unreadCount
+ );
+ this._notifiedUnreadCount = unreadCount;
+ }
+ },
+
+ countUnreadMessages() {
+ let convs = IMServices.conversations.getUIConversations();
+ let unreadTargetedCount = 0;
+ let unreadTotalCount = 0;
+ let unreadOTRNotificationCount = 0;
+ for (let conv of convs) {
+ unreadTargetedCount += conv.unreadTargetedMessageCount;
+ unreadTotalCount += conv.unreadIncomingMessageCount;
+ unreadOTRNotificationCount += conv.unreadOTRNotificationCount;
+ }
+ return [unreadTargetedCount, unreadTotalCount, unreadOTRNotificationCount];
+ },
+
+ updateTitle() {
+ if (!gChatTab) {
+ return;
+ }
+
+ let title = gChatBundle.GetStringFromName("chatTabTitle");
+ let [unreadTargetedCount] = this.countUnreadMessages();
+ if (unreadTargetedCount) {
+ title += " (" + unreadTargetedCount + ")";
+ } else {
+ let selectedItem = document.getElementById("contactlistbox").selectedItem;
+ if (
+ selectedItem &&
+ selectedItem.localName == "richlistitem" &&
+ selectedItem.getAttribute("is") == "chat-imconv-richlistitem" &&
+ !selectedItem.hidden
+ ) {
+ title += " - " + selectedItem.getAttribute("displayname");
+ }
+ }
+ gChatTab.title = title;
+ document.getElementById("tabmail").setTabTitle(gChatTab);
+ },
+
+ onConvResize() {
+ let panel = getSelectedPanel();
+ if (panel && panel.localName == "chat-conversation") {
+ panel.onConvResize();
+ }
+ },
+
+ setStatusMenupopupCommand(aEvent) {
+ let target = aEvent.target;
+ if (target.getAttribute("id") == "imStatusShowAccounts") {
+ openIMAccountMgr();
+ return;
+ }
+
+ let status = target.getAttribute("status");
+ if (!status) {
+ // Can status really be null? Maybe because of an add-on...
+ return;
+ }
+
+ let us = IMServices.core.globalUserStatus;
+ us.setStatus(Status.toFlag(status), us.statusText);
+ },
+
+ _pendingLogBrowserLoad: false,
+ _showLogPanel() {
+ hideConversationsBoxPanels();
+ document.getElementById("logDisplay").hidden = false;
+ document.getElementById("logDisplayBrowserBox").hidden = false;
+ document.getElementById("noPreviousConvScreen").hidden = true;
+ },
+ _showLog(aConversation, aSearchTerm) {
+ if (!aConversation) {
+ return;
+ }
+ this._showLogPanel();
+ let browser = document.getElementById("conv-log-browser");
+ browser._convScrollEnabled = false;
+ if (this._pendingLogBrowserLoad) {
+ browser._conv = aConversation;
+ return;
+ }
+ browser.init(aConversation);
+ this._pendingLogBrowserLoad = true;
+ if (aSearchTerm) {
+ this._pendingSearchTerm = aSearchTerm;
+ }
+ Services.obs.addObserver(this, "conversation-loaded");
+
+ // Conversation title may not be set yet if this is a search result.
+ let cti = document.getElementById("conv-top-info");
+ cti.setAttribute("displayName", aConversation.title);
+
+ // Find and display the contact for this log.
+ for (let account of IMServices.accounts.getAccounts()) {
+ if (
+ account.normalizedName == aConversation.account.normalizedName &&
+ account.protocol.normalizedName == aConversation.account.protocol.name
+ ) {
+ if (aConversation.isChat) {
+ // Display information for MUCs.
+ cti.setAsChat("", false, false);
+ cti.setProtocol(account.protocol);
+ return;
+ }
+ // Display information for contacts.
+ let accountBuddy = IMServices.contacts.getAccountBuddyByNameAndAccount(
+ aConversation.normalizedName,
+ account
+ );
+ if (!accountBuddy) {
+ return;
+ }
+ let contact = accountBuddy.buddy.contact;
+ if (!contact) {
+ return;
+ }
+ if (this.observedContact && this.observedContact.id == contact.id) {
+ return;
+ }
+ this.showContactInfo(contact);
+ this.observedContact = contact;
+ return;
+ }
+ }
+ },
+
+ /**
+ * Display a list of logs into a tree, and optionally handle a default selection.
+ *
+ * @param {imILog} aLogs - An array of imILog.
+ * @param {boolean|imILog} aShouldSelect - Either a boolean (true means select the first log
+ * of the list, false or undefined means don't mess with the selection) or a log
+ * item that needs to be selected.
+ * @returns {boolean} True if there's at least one log in the list, false if empty.
+ */
+ _showLogList(aLogs, aShouldSelect) {
+ let logTree = document.getElementById("logTree");
+ let treeView = (this._treeView = new chatLogTreeView(logTree, aLogs));
+ if (!treeView._rowMap.length) {
+ return false;
+ }
+ if (!aShouldSelect) {
+ return true;
+ }
+ if (aShouldSelect === true) {
+ // Select the first line.
+ let selectIndex = 0;
+ if (treeView.isContainer(selectIndex)) {
+ // If the first line is a group, open it and select the
+ // next line instead.
+ treeView.toggleOpenState(selectIndex++);
+ }
+ logTree.view.selection.select(selectIndex);
+ return true;
+ }
+ // Find the aShouldSelect log and select it.
+ let logTime = aShouldSelect.time;
+ for (let index = 0; index < treeView._rowMap.length; ++index) {
+ if (
+ !treeView.isContainer(index) &&
+ treeView._rowMap[index].log.time == logTime
+ ) {
+ logTree.view.selection.select(index);
+ logTree.ensureRowIsVisible(index);
+ return true;
+ }
+ if (!treeView._rowMap[index].children.some(i => i.log.time == logTime)) {
+ continue;
+ }
+ treeView.toggleOpenState(index);
+ ++index;
+ while (
+ index < treeView._rowMap.length &&
+ treeView._rowMap[index].log.time != logTime
+ ) {
+ ++index;
+ }
+ if (treeView._rowMap[index].log.time == logTime) {
+ logTree.view.selection.select(index);
+ logTree.ensureRowIsVisible(index);
+ }
+ return true;
+ }
+ throw new Error(
+ "Couldn't find the log to select among the set of logs passed."
+ );
+ },
+
+ onLogSelect() {
+ let selection = this._treeView.selection;
+ let currentIndex = selection.currentIndex;
+ // The current (focused) row may not be actually selected...
+ if (!selection.isSelected(currentIndex)) {
+ return;
+ }
+
+ let log = this._treeView._rowMap[currentIndex].log;
+ if (!log) {
+ return;
+ }
+
+ let list = document.getElementById("contactlistbox");
+ if (list.selectedItem.getAttribute("id") != "searchResultConv") {
+ document.getElementById("goToConversation").hidden = false;
+ }
+ log.getConversation().then(aLogConv => {
+ this._showLog(aLogConv);
+ });
+ },
+
+ _contactObserver: {
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic == "contact-status-changed" ||
+ aTopic == "contact-display-name-changed" ||
+ aTopic == "contact-icon-changed"
+ ) {
+ chatHandler.showContactInfo(aSubject);
+ }
+ },
+ },
+ _observedContact: null,
+ get observedContact() {
+ return this._observedContact;
+ },
+ set observedContact(aContact) {
+ if (aContact == this._observedContact) {
+ return;
+ }
+ if (this._observedContact) {
+ this._observedContact.removeObserver(this._contactObserver);
+ delete this._observedContact;
+ }
+ this._observedContact = aContact;
+ if (aContact) {
+ aContact.addObserver(this._contactObserver);
+ }
+ },
+ /**
+ * Callback for the button that closes the log view. Resets the shared UI
+ * elements to match the state of the active conversation. Hides the log
+ * browser.
+ */
+ showCurrentConversation() {
+ let item = document.getElementById("contactlistbox").selectedItem;
+ if (!item) {
+ return;
+ }
+ if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-imconv-richlistitem"
+ ) {
+ hideConversationsBoxPanels();
+ item.convView.hidden = false;
+ item.convView.querySelector(".conv-bottom").setAttribute("height", 90);
+ document.getElementById("logTree").view.selection.clearSelection();
+ if (item.conv.isChat) {
+ item.convView.updateTopic();
+ }
+ ChatEncryption.updateEncryptionButton(document, item.conv);
+ item.convView.focus();
+ } else if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-contact-richlistitem"
+ ) {
+ item.openConversation();
+ }
+ },
+ focusConversation(aUIConv) {
+ let conv =
+ document.getElementById("conversationsGroup").contactsById[aUIConv.id];
+ document.getElementById("contactlistbox").selectedItem = conv;
+ if (conv.convView) {
+ conv.convView.focus();
+ }
+ },
+ showContactInfo(aContact) {
+ let cti = document.getElementById("conv-top-info");
+ cti.setUserIcon(aContact.buddyIconFilename, true);
+ cti.setAttribute("displayName", aContact.displayName);
+ cti.setProtocol(aContact.preferredBuddy.protocol);
+
+ let statusText = aContact.statusText;
+ let statusType = aContact.statusType;
+ cti.setStatus(
+ Status.toAttribute(statusType),
+ Status.toLabel(statusType, statusText)
+ );
+
+ let button = document.getElementById("goToConversation");
+ button.label = gChatBundle.formatStringFromName(
+ "startAConversationWith.button",
+ [aContact.displayName]
+ );
+ button.disabled = !aContact.canSendMessage;
+ },
+ _hideContextPane(aHide) {
+ document.getElementById("contextSplitter").hidden = aHide;
+ document.getElementById("contextPane").hidden = aHide;
+ },
+ onListItemClick(aEvent) {
+ // We only care about single clicks of the left button.
+ if (aEvent.button != 0 || aEvent.detail != 1) {
+ return;
+ }
+ let item = document.getElementById("contactlistbox").selectedItem;
+ if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-imconv-richlistitem" &&
+ item.convView
+ ) {
+ item.convView.focus();
+ }
+ },
+ onListItemSelected() {
+ let contactlistbox = document.getElementById("contactlistbox");
+ let item = contactlistbox.selectedItem;
+ if (
+ !item ||
+ item.hidden ||
+ (item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-group-richlistitem")
+ ) {
+ this._hideContextPane(true);
+ hideConversationsBoxPanels();
+ document.getElementById("noConvScreen").hidden = false;
+ this.updateTitle();
+ this.observedContact = null;
+ ChatEncryption.hideEncryptionButton(document);
+ return;
+ }
+
+ this._hideContextPane(false);
+
+ if (item.getAttribute("id") == "searchResultConv") {
+ document.getElementById("goToConversation").hidden = true;
+ document.getElementById("contextPane").removeAttribute("chat");
+ let cti = document.getElementById("conv-top-info");
+ cti.clear();
+ this.observedContact = null;
+ // Always hide encryption options for search conv
+ ChatEncryption.hideEncryptionButton(document);
+
+ let path = "logs/" + item.log.path;
+ path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ ...path.split("/")
+ );
+ IMServices.logs.getLogFromFile(path, true).then(aLog => {
+ IMServices.logs.getSimilarLogs(aLog).then(aSimilarLogs => {
+ if (contactlistbox.selectedItem != item) {
+ return;
+ }
+ this._pendingSearchTerm = item.searchTerm || undefined;
+ this._showLogList(aSimilarLogs, aLog);
+ });
+ });
+ } else if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-imconv-richlistitem"
+ ) {
+ if (!item.convView) {
+ let convBox = document.getElementById("conversationsBox");
+ let conv = document.createXULElement("chat-conversation");
+ convBox.appendChild(conv);
+ conv.conv = item.conv;
+ conv.tab = item;
+ conv.convBrowser.setAttribute("context", "chatConversationContextMenu");
+ conv.setAttribute("tooltip", "imTooltip");
+ item.convView = conv;
+ document.getElementById("contextSplitter").hidden = false;
+ document.getElementById("contextPane").hidden = false;
+ conv.editor.addEventListener("contextmenu", e => {
+ // Stash away the original event's parent and range for later use.
+ gRangeParent = e.rangeParent;
+ gRangeOffset = e.rangeOffset;
+ let popup = document.getElementById("chatContextMenu");
+ popup.openPopupAtScreen(e.screenX, e.screenY, true);
+ e.preventDefault();
+ });
+
+ // Set "mail editor mask" so changing the language doesn't
+ // affect the global preference and multiple chats can have
+ // individual languages.
+ conv.editor.editor.flags |= Ci.nsIEditor.eEditorMailMask;
+
+ let preferredLanguages =
+ Services.prefs.getStringPref("spellchecker.dictionary")?.split(",") ??
+ [];
+ let initialLanguage = "";
+ if (preferredLanguages.length === 1) {
+ initialLanguage = preferredLanguages[0];
+ }
+ // Initialise language to the default.
+ conv.editor.setAttribute("lang", initialLanguage);
+
+ // Attach listener so we hear about language changes.
+ document.addEventListener("spellcheck-changed", e => {
+ let conv = chatHandler._getActiveConvView();
+ let activeLanguages = e.detail.dictionaries ?? [];
+ let languageToSet = "";
+ if (activeLanguages.length === 1) {
+ languageToSet = activeLanguages[0];
+ }
+ conv.editor.setAttribute("lang", languageToSet);
+ });
+ } else {
+ item.convView.onConvResize();
+ }
+
+ hideConversationsBoxPanels();
+ item.convView.hidden = false;
+ item.convView.querySelector(".conv-bottom").setAttribute("height", 90);
+ item.convView.updateConvStatus();
+ item.update();
+
+ ChatEncryption.updateEncryptionButton(document, item.conv);
+
+ IMServices.logs.getLogsForConversation(item.conv).then(aLogs => {
+ if (contactlistbox.selectedItem != item) {
+ return;
+ }
+ this._showLogList(aLogs);
+ });
+
+ document
+ .querySelectorAll("#contextPaneFlexibleBox .conv-chat")
+ .forEach(e => {
+ e.setAttribute("hidden", !item.conv.isChat);
+ });
+ if (item.conv.isChat) {
+ item.convView.showParticipants();
+ }
+
+ let button = document.getElementById("goToConversation");
+ button.label = gChatBundle.GetStringFromName(
+ "goBackToCurrentConversation.button"
+ );
+ button.disabled = false;
+ this.observedContact = null;
+ } else if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-contact-richlistitem"
+ ) {
+ ChatEncryption.hideEncryptionButton(document);
+ let contact = item.contact;
+ if (
+ this.observedContact &&
+ contact &&
+ this.observedContact.id == contact.id
+ ) {
+ return; // onselect has just been fired again because a status
+ // change caused the chat-contact-richlistitem to move.
+ // Return early to avoid flickering and changing the selected log.
+ }
+
+ this.showContactInfo(contact);
+ this.observedContact = contact;
+
+ document
+ .querySelectorAll("#contextPaneFlexibleBox .conv-chat")
+ .forEach(e => {
+ e.setAttribute("hidden", "true");
+ });
+
+ IMServices.logs.getLogsForContact(contact).then(aLogs => {
+ if (contactlistbox.selectedItem != item) {
+ return;
+ }
+ if (!this._showLogList(aLogs, true)) {
+ hideConversationsBoxPanels();
+ document.getElementById("logDisplay").hidden = false;
+ document.getElementById("logDisplayBrowserBox").hidden = false;
+ document.getElementById("noPreviousConvScreen").hidden = true;
+ }
+ });
+ }
+ this.updateTitle();
+ },
+
+ onNickClick(aEvent) {
+ // Open a private conversation only for a middle or double click.
+ if (aEvent.button != 1 && (aEvent.button != 0 || aEvent.detail != 2)) {
+ return;
+ }
+
+ let conv = document.getElementById("contactlistbox").selectedItem.conv;
+ let nick = aEvent.target.chatBuddy.name;
+ let name = conv.target.getNormalizedChatBuddyName(nick);
+ try {
+ let newconv = conv.account.createConversation(name);
+ this.focusConversation(newconv);
+ } catch (e) {}
+ },
+
+ onNicklistKeyPress(aEvent) {
+ if (aEvent.keyCode != aEvent.DOM_VK_RETURN) {
+ return;
+ }
+
+ let listbox = aEvent.target;
+ if (listbox.selectedCount == 0) {
+ return;
+ }
+
+ let conv = document.getElementById("contactlistbox").selectedItem.conv;
+ let newconv;
+ for (let i = 0; i < listbox.selectedCount; ++i) {
+ let nick = listbox.getSelectedItem(i).chatBuddy.name;
+ let name = conv.target.getNormalizedChatBuddyName(nick);
+ try {
+ newconv = conv.account.createConversation(name);
+ } catch (e) {}
+ }
+ // Only focus last of the opened conversations.
+ if (newconv) {
+ this.focusConversation(newconv);
+ }
+ },
+
+ addBuddy() {
+ window.openDialog(
+ "chrome://messenger/content/chat/addbuddy.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen"
+ );
+ },
+
+ joinChat() {
+ window.openDialog(
+ "chrome://messenger/content/chat/joinchat.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen"
+ );
+ },
+
+ _colorCache: {},
+ // Duplicated code from chat-conversation.js :-(
+ _computeColor(aName) {
+ if (Object.prototype.hasOwnProperty.call(this._colorCache, aName)) {
+ return this._colorCache[aName];
+ }
+
+ // Compute the color based on the nick
+ var nick = aName.match(/[a-zA-Z0-9]+/);
+ nick = nick ? nick[0].toLowerCase() : (nick = aName);
+ // We compute a hue value (between 0 and 359) based on the
+ // characters of the nick.
+ // The first character weights kInitialWeight, each following
+ // character weights kWeightReductionPerChar * the weight of the
+ // previous character.
+ const kInitialWeight = 10; // 10 = 360 hue values / 36 possible characters.
+ const kWeightReductionPerChar = 0.52; // arbitrary value
+ var weight = kInitialWeight;
+ var res = 0;
+ for (var i = 0; i < nick.length; ++i) {
+ var char = nick.charCodeAt(i) - 47;
+ if (char > 10) {
+ char -= 39;
+ }
+ // now char contains a value between 1 and 36
+ res += char * weight;
+ weight *= kWeightReductionPerChar;
+ }
+ return (this._colorCache[aName] = Math.round(res) % 360);
+ },
+
+ _placeHolderButtonId: "",
+ _updateNoConvPlaceHolder() {
+ let connected = false;
+ let hasAccount = false;
+ let canJoinChat = false;
+ for (let account of IMServices.accounts.getAccounts()) {
+ hasAccount = true;
+ if (account.connected) {
+ connected = true;
+ if (account.canJoinChat) {
+ canJoinChat = true;
+ break;
+ }
+ }
+ }
+ document.getElementById("noConvInnerBox").hidden = !connected;
+ document.getElementById("noAccountInnerBox").hidden = hasAccount;
+ document.getElementById("noConnectedAccountInnerBox").hidden =
+ connected || !hasAccount;
+ if (connected) {
+ delete this._placeHolderButtonId;
+ } else {
+ this._placeHolderButtonId = hasAccount
+ ? "openIMAccountManagerButton"
+ : "openIMAccountWizardButton";
+ }
+
+ for (let id of [
+ "statusTypeIcon",
+ "statusMessage",
+ "button-chat-accounts",
+ ]) {
+ let elt = document.getElementById(id);
+ if (elt) {
+ elt.disabled = !hasAccount;
+ }
+ }
+
+ let chatStatusCmd = document.getElementById("cmd_chatStatus");
+ if (chatStatusCmd) {
+ if (hasAccount) {
+ chatStatusCmd.removeAttribute("disabled");
+ } else {
+ chatStatusCmd.setAttribute("disabled", true);
+ }
+ }
+
+ let addBuddyButton = document.getElementById("button-add-buddy");
+ if (addBuddyButton) {
+ addBuddyButton.disabled = !connected;
+ }
+
+ let addBuddyCmd = document.getElementById("cmd_addChatBuddy");
+ if (addBuddyCmd) {
+ if (connected) {
+ addBuddyCmd.removeAttribute("disabled");
+ } else {
+ addBuddyCmd.setAttribute("disabled", true);
+ }
+ }
+
+ let joinChatButton = document.getElementById("button-join-chat");
+ if (joinChatButton) {
+ joinChatButton.disabled = !canJoinChat;
+ }
+
+ let joinChatCmd = document.getElementById("cmd_joinChat");
+ if (joinChatCmd) {
+ if (canJoinChat) {
+ joinChatCmd.removeAttribute("disabled");
+ } else {
+ joinChatCmd.setAttribute("disabled", true);
+ }
+ }
+
+ let groupIds = ["conversations", "onlinecontacts", "offlinecontacts"];
+ let contactlist = document.getElementById("contactlistbox");
+ if (
+ !hasAccount ||
+ (!connected &&
+ groupIds.every(
+ id => document.getElementById(id + "Group").contacts.length
+ ))
+ ) {
+ contactlist.disabled = true;
+ } else {
+ contactlist.disabled = false;
+ this._updateSelectedConversation();
+ }
+ },
+ _updateSelectedConversation() {
+ let list = document.getElementById("contactlistbox");
+ // We can't select anything if there's no account.
+ if (list.disabled) {
+ return;
+ }
+
+ // If the selection is already a conversation with unread messages, keep it.
+ let selectedItem = list.selectedItem;
+ if (
+ selectedItem &&
+ selectedItem.localName == "richlistitem" &&
+ selectedItem.getAttribute("is") == "chat-imconv-richlistitem" &&
+ selectedItem.directedUnreadCount
+ ) {
+ selectedItem.update();
+ return;
+ }
+
+ let firstConv;
+ let convs = document.getElementById("conversationsGroup");
+ let conv = convs.nextElementSibling;
+ while (conv.id != "searchResultConv") {
+ if (!firstConv) {
+ firstConv = conv;
+ }
+ // If there is a conversation with unread messages, select it.
+ if (conv.directedUnreadCount) {
+ list.selectedItem = conv;
+ return;
+ }
+ conv = conv.nextElementSibling;
+ }
+
+ // No unread messages, select the first conversation, but only if
+ // the existing selection is uninteresting (a section header).
+ if (firstConv) {
+ if (
+ !selectedItem ||
+ (selectedItem.localName == "richlistitem" &&
+ selectedItem.getAttribute("is") == "chat-group-richlistitem")
+ ) {
+ list.selectedItem = firstConv;
+ }
+ return;
+ }
+
+ // No conversation, if a visible item is selected, keep it.
+ if (selectedItem && !selectedItem.collapsed) {
+ return;
+ }
+
+ // Select the first visible group header.
+ let groupIds = ["conversations", "onlinecontacts", "offlinecontacts"];
+ for (let id of groupIds) {
+ let item = document.getElementById(id + "Group");
+ if (item.collapsed) {
+ continue;
+ }
+ list.selectedItem = item;
+ return;
+ }
+ },
+ _updateFocus() {
+ let focusId = this._placeHolderButtonId || "contactlistbox";
+ document.getElementById(focusId).focus();
+ },
+ _getActiveConvView() {
+ let list = document.getElementById("contactlistbox");
+ if (list.disabled) {
+ return null;
+ }
+ let selectedItem = list.selectedItem;
+ if (
+ !selectedItem ||
+ (selectedItem.localName != "richlistitem" &&
+ selectedItem.getAttribute("is") != "chat-imconv-richlistitem")
+ ) {
+ return null;
+ }
+ let convView = selectedItem.convView;
+ if (!convView || !convView.loaded) {
+ return null;
+ }
+ return convView;
+ },
+ _onTabActivated() {
+ let convView = chatHandler._getActiveConvView();
+ if (convView) {
+ convView.switchingToPanel();
+ }
+ },
+ _onTabDeactivated(aHidden) {
+ let convView = chatHandler._getActiveConvView();
+ if (convView) {
+ convView.switchingAwayFromPanel(aHidden);
+ }
+ },
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "chat-core-initialized") {
+ this.initAfterChatCore();
+ return;
+ }
+
+ if (aTopic == "conversation-loaded") {
+ let browser = document.getElementById("conv-log-browser");
+ if (aSubject != browser) {
+ return;
+ }
+
+ for (let msg of browser._conv.getMessages()) {
+ if (!msg.system) {
+ msg.color =
+ "color: hsl(" + this._computeColor(msg.who) + ", 100%, 40%);";
+ }
+ browser.appendMessage(msg);
+ }
+
+ if (this._pendingSearchTerm) {
+ let findbar = document.getElementById("log-findbar");
+ findbar._findField.value = this._pendingSearchTerm;
+ findbar.open();
+ browser.focus();
+ delete this._pendingSearchTerm;
+ let eventListener = function () {
+ findbar.onFindAgainCommand();
+ if (findbar._findFailedString && browser._messageDisplayPending) {
+ return;
+ }
+ // Search result found or all messages added, we're done.
+ browser.removeEventListener("MessagesDisplayed", eventListener);
+ };
+ browser.addEventListener("MessagesDisplayed", eventListener);
+ }
+ this._pendingLogBrowserLoad = false;
+ Services.obs.removeObserver(this, "conversation-loaded");
+ return;
+ }
+
+ if (
+ aTopic == "account-connected" ||
+ aTopic == "account-disconnected" ||
+ aTopic == "account-added" ||
+ aTopic == "account-removed"
+ ) {
+ this._updateNoConvPlaceHolder();
+ return;
+ }
+
+ if (aTopic == "contact-signed-on") {
+ if (!this._hasConversationForContact(aSubject)) {
+ document.getElementById("onlinecontactsGroup").addContact(aSubject);
+ document.getElementById("offlinecontactsGroup").removeContact(aSubject);
+ }
+ return;
+ }
+ if (aTopic == "contact-signed-off") {
+ if (!this._hasConversationForContact(aSubject)) {
+ document.getElementById("offlinecontactsGroup").addContact(aSubject);
+ document.getElementById("onlinecontactsGroup").removeContact(aSubject);
+ }
+ return;
+ }
+ if (aTopic == "contact-added") {
+ let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup";
+ document.getElementById(groupName).addContact(aSubject);
+ return;
+ }
+ if (aTopic == "contact-removed") {
+ let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup";
+ document.getElementById(groupName).removeContact(aSubject);
+ return;
+ }
+ if (aTopic == "contact-no-longer-dummy") {
+ let oldId = parseInt(aData);
+ let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup";
+ let group = document.getElementById(groupName);
+ if (group.contactsById.hasOwnProperty(oldId)) {
+ let contact = group.contactsById[oldId];
+ delete group.contactsById[oldId];
+ group.contactsById[contact.contact.id] = contact;
+ }
+ return;
+ }
+ if (aTopic == "new-text") {
+ this.updateChatButtonState();
+ return;
+ }
+ if (aTopic == "new-ui-conversation") {
+ if (chatTabType.hasBeenOpened) {
+ chatHandler._addConversation(aSubject);
+ }
+ return;
+ }
+ if (aTopic == "ui-conversation-closed") {
+ this.updateChatButtonState();
+ if (!chatTabType.hasBeenOpened) {
+ return;
+ }
+ let conv = document
+ .getElementById("conversationsGroup")
+ .removeContact(aSubject);
+ if (conv.imContact) {
+ let contact = conv.imContact;
+ let groupName = (contact.online ? "on" : "off") + "linecontactsGroup";
+ document.getElementById(groupName).addContact(contact);
+ }
+ return;
+ }
+
+ if (aTopic == "buddy-authorization-request") {
+ aSubject.QueryInterface(Ci.prplIBuddyRequest);
+ let authLabel = gChatBundle.formatStringFromName(
+ "buddy.authRequest.label",
+ [aSubject.userName]
+ );
+ let value =
+ "buddy-auth-request-" + aSubject.account.id + aSubject.userName;
+ let acceptButton = {
+ accessKey: gChatBundle.GetStringFromName(
+ "buddy.authRequest.allow.accesskey"
+ ),
+ label: gChatBundle.GetStringFromName("buddy.authRequest.allow.label"),
+ callback() {
+ aSubject.grant();
+ },
+ };
+ let denyButton = {
+ accessKey: gChatBundle.GetStringFromName(
+ "buddy.authRequest.deny.accesskey"
+ ),
+ label: gChatBundle.GetStringFromName("buddy.authRequest.deny.label"),
+ callback() {
+ aSubject.deny();
+ },
+ };
+ let box = this.msgNotificationBar;
+ let notification = box.appendNotification(
+ value,
+ {
+ label: authLabel,
+ priority: box.PRIORITY_INFO_HIGH,
+ },
+ [acceptButton, denyButton]
+ );
+ notification.removeAttribute("dismissable");
+ if (!gChatTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("chat", { background: true });
+ }
+ return;
+ }
+ if (aTopic == "buddy-authorization-request-canceled") {
+ aSubject.QueryInterface(Ci.prplIBuddyRequest);
+ let value =
+ "buddy-auth-request-" + aSubject.account.id + aSubject.userName;
+ let box = this.msgNotificationBar;
+ let notification = box.getNotificationWithValue(value);
+ if (notification) {
+ notification.close();
+ }
+ return;
+ }
+ if (aTopic == "buddy-verification-request") {
+ aSubject.QueryInterface(Ci.imIIncomingSessionVerification);
+ let barLabel = gChatBundle.formatStringFromName(
+ "buddy.verificationRequest.label",
+ [aSubject.subject]
+ );
+ let value =
+ "buddy-verification-request-" +
+ aSubject.account.id +
+ "-" +
+ aSubject.subject;
+ let acceptButton = {
+ accessKey: gChatBundle.GetStringFromName(
+ "buddy.verificationRequest.allow.accesskey"
+ ),
+ label: gChatBundle.GetStringFromName(
+ "buddy.verificationRequest.allow.label"
+ ),
+ callback() {
+ aSubject
+ .verify()
+ .then(() => {
+ window.openDialog(
+ "chrome://messenger/content/chat/verify.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen",
+ aSubject
+ );
+ })
+ .catch(error => {
+ aSubject.account.ERROR(error);
+ aSubject.cancel();
+ });
+ },
+ };
+ let denyButton = {
+ accessKey: gChatBundle.GetStringFromName(
+ "buddy.verificationRequest.deny.accesskey"
+ ),
+ label: gChatBundle.GetStringFromName(
+ "buddy.verificationRequest.deny.label"
+ ),
+ callback() {
+ aSubject.cancel();
+ },
+ };
+ let box = this.msgNotificationBar;
+ let notification = box.appendNotification(
+ value,
+ {
+ label: barLabel,
+ priority: box.PRIORITY_INFO_HIGH,
+ },
+ [acceptButton, denyButton]
+ );
+ notification.removeAttribute("dismissable");
+ if (!gChatTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("chat", { background: true });
+ }
+ return;
+ }
+ if (aTopic == "buddy-verification-request-canceled") {
+ aSubject.QueryInterface(Ci.imIIncomingSessionVerification);
+ let value =
+ "buddy-verification-request-" +
+ aSubject.account.id +
+ "-" +
+ aSubject.subject;
+ let box = this.msgNotificationBar;
+ let notification = box.getNotificationWithValue(value);
+ if (notification) {
+ notification.close();
+ }
+ return;
+ }
+ if (aTopic == "conv-authorization-request") {
+ aSubject.QueryInterface(Ci.prplIChatRequest);
+ let value =
+ "conv-auth-request-" + aSubject.account.id + aSubject.conversationName;
+ let buttons = [
+ {
+ "l10n-id": "chat-conv-invite-accept",
+ callback() {
+ aSubject.grant();
+ },
+ },
+ ];
+ if (aSubject.canDeny) {
+ buttons.push({
+ "l10n-id": "chat-conv-invite-deny",
+ callback() {
+ aSubject.deny();
+ },
+ });
+ }
+ let box = this.msgNotificationBar;
+ // Remove the notification when the request is cancelled.
+ aSubject.completePromise.catch(() => {
+ let notification = box.getNotificationWithValue(value);
+ if (notification) {
+ notification.close();
+ }
+ });
+ let notification = box.appendNotification(
+ value,
+ {
+ label: "",
+ priority: box.PRIORITY_INFO_HIGH,
+ },
+ buttons
+ );
+ document.l10n.setAttributes(
+ notification.messageText,
+ "chat-conv-invite-label",
+ {
+ conversation: aSubject.conversationName,
+ }
+ );
+ notification.removeAttribute("dismissable");
+ if (!gChatTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("chat", { background: true });
+ }
+ return;
+ }
+ if (aTopic == "conversation-update-type") {
+ // Find conversation in conversation list.
+ let contactlistbox = document.getElementById("contactlistbox");
+ let convs = document.getElementById("conversationsGroup");
+ let convItem = convs.nextElementSibling;
+ while (
+ convItem.conv.target.id !== aSubject.target.id &&
+ convItem.id != "searchResultConv"
+ ) {
+ convItem = convItem.nextElementSibling;
+ }
+ if (convItem.conv.target.id !== aSubject.target.id) {
+ // Could not find a matching conversation in the front end.
+ return;
+ }
+ // Update UI conversation associated with components
+ if (convItem.convView && convItem.convView.conv !== aSubject) {
+ convItem.convView.changeConversation(aSubject);
+ }
+ if (convItem.conv !== aSubject) {
+ convItem.changeConversation(aSubject);
+ } else {
+ convItem.update();
+ }
+ // If the changed conversation is the selected item, make sure
+ // we update the UI elements to match the conversation type.
+ let selectedItem = contactlistbox.selectedItem;
+ if (selectedItem === convItem && selectedItem.convView) {
+ this.onListItemSelected();
+ }
+ }
+ },
+ initAfterChatCore() {
+ let onGroup = document.getElementById("onlinecontactsGroup");
+ let offGroup = document.getElementById("offlinecontactsGroup");
+
+ for (let name in chatHandler.allContacts) {
+ let contact = chatHandler.allContacts[name];
+ let group = contact.online ? onGroup : offGroup;
+ group.addContact(contact);
+ }
+
+ onGroup._updateGroupLabel();
+ offGroup._updateGroupLabel();
+
+ [
+ "new-text",
+ "new-ui-conversation",
+ "ui-conversation-closed",
+ "contact-signed-on",
+ "contact-signed-off",
+ "contact-added",
+ "contact-removed",
+ "contact-no-longer-dummy",
+ "account-connected",
+ "account-disconnected",
+ "account-added",
+ "account-removed",
+ "conversation-update-type",
+ ].forEach(chatHandler._addObserver);
+
+ chatHandler._updateNoConvPlaceHolder();
+ statusSelector.init();
+ },
+ _observedTopics: [],
+ _addObserver(aTopic) {
+ Services.obs.addObserver(chatHandler, aTopic);
+ chatHandler._observedTopics.push(aTopic);
+ },
+ _removeObservers() {
+ for (let topic of this._observedTopics) {
+ Services.obs.removeObserver(this, topic);
+ }
+ },
+ // TODO move this function away from here and test it.
+ _getNextUnreadConversation(aConversations, aCurrent, aReverse) {
+ let convCount = aConversations.length;
+ if (!convCount) {
+ return -1;
+ }
+
+ let direction = aReverse ? -1 : 1;
+ let next = i => {
+ i += direction;
+ if (i < 0) {
+ return i + convCount;
+ }
+ if (i >= convCount) {
+ return i - convCount;
+ }
+ return i;
+ };
+
+ // Find starting point
+ let start = 0;
+ if (Number.isInteger(aCurrent)) {
+ start = next(aCurrent);
+ } else if (aReverse) {
+ start = convCount - 1;
+ }
+
+ // Cycle through all conversations until we are at the start again.
+ let i = start;
+ do {
+ // If there is a conversation with unread messages, select it.
+ if (aConversations[i].unreadIncomingMessageCount) {
+ return i;
+ }
+ i = next(i);
+ } while (i !== start && i !== aCurrent);
+ return -1;
+ },
+ _selectNextUnreadConversation(aReverse, aList) {
+ let conversations = document.getElementById("conversationsGroup").contacts;
+ if (!conversations.length) {
+ return;
+ }
+
+ let rawConversations = conversations.map(c => c.conv);
+ let current;
+ if (
+ aList.selectedItem.localName == "richlistitem" &&
+ aList.selectedItem.getAttribute("is") == "chat-imconv-richlistitem"
+ ) {
+ current = aList.selectedIndex - aList.getIndexOfItem(conversations[0]);
+ }
+ let newIndex = this._getNextUnreadConversation(
+ rawConversations,
+ current,
+ aReverse
+ );
+ if (newIndex !== -1) {
+ aList.selectedItem = conversations[newIndex];
+ }
+ },
+ /**
+ * Restores the width in pixels stored on the width attribute of an element as
+ * CSS width, so it is used for flex layout calculations. Useful for restoring
+ * elements that were sized by a XUL splitter.
+ *
+ * @param {Element} element - Element to transfer the width attribute to CSS for.
+ */
+ _restoreWidth: element =>
+ (element.style.width = `${element.getAttribute("width")}px`),
+ async init() {
+ Notifications.init();
+ if (!Services.prefs.getBoolPref("mail.chat.enabled")) {
+ [
+ "chatButton",
+ "spacesPopupButtonChat",
+ "button-chat",
+ "menu_goChat",
+ "goChatSeparator",
+ "imAccountsStatus",
+ "joinChatMenuItem",
+ "newIMAccountMenuItem",
+ "newIMContactMenuItem",
+ "appmenu_newIMAccountMenuItem",
+ "appmenu_newIMContactMenuItem",
+ ].forEach(function (aId) {
+ let elt = document.getElementById(aId);
+ if (elt) {
+ elt.hidden = true;
+ }
+ });
+ return;
+ }
+
+ window.addEventListener("unload", this._removeObservers.bind(this));
+
+ // initialize the customizeDone method on the customizeable toolbar
+ var toolbox = document.getElementById("chat-view-toolbox");
+ toolbox.customizeDone = function (aEvent) {
+ MailToolboxCustomizeDone(aEvent, "CustomizeChatToolbar");
+ };
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.registerTabType(chatTabType);
+ this._addObserver("buddy-authorization-request");
+ this._addObserver("buddy-authorization-request-canceled");
+ this._addObserver("buddy-verification-request");
+ this._addObserver("buddy-verification-request-canceled");
+ this._addObserver("conv-authorization-request");
+ let listbox = document.getElementById("contactlistbox");
+ listbox.addEventListener("keypress", function (aEvent) {
+ let item = listbox.selectedItem;
+ if (!item || !item.parentNode) {
+ // empty list or item no longer in the list
+ return;
+ }
+ item.keyPress(aEvent);
+ });
+ listbox.addEventListener("select", this.onListItemSelected.bind(this));
+ listbox.addEventListener("click", this.onListItemClick.bind(this));
+ document
+ .getElementById("chatTabPanel")
+ .addEventListener("keypress", function (aEvent) {
+ let accelKeyPressed =
+ AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey;
+ if (
+ !accelKeyPressed ||
+ (aEvent.keyCode != aEvent.DOM_VK_DOWN &&
+ aEvent.keyCode != aEvent.DOM_VK_UP)
+ ) {
+ return;
+ }
+ listbox._userSelecting = true;
+ let reverse = aEvent.keyCode != aEvent.DOM_VK_DOWN;
+ if (aEvent.shiftKey) {
+ chatHandler._selectNextUnreadConversation(reverse, listbox);
+ } else {
+ listbox.moveByOffset(reverse ? -1 : 1, true, false);
+ }
+ listbox._userSelecting = false;
+ let item = listbox.selectedItem;
+ if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-imconv-richlistitem" &&
+ item.convView
+ ) {
+ item.convView.focus();
+ } else {
+ listbox.focus();
+ }
+ });
+ window.addEventListener("resize", this.onConvResize.bind(this));
+ document.getElementById("conversationsGroup").sortComparator = (a, b) =>
+ a.title.toLowerCase().localeCompare(b.title.toLowerCase());
+
+ const { allContacts, onlineContacts, ChatCore } =
+ ChromeUtils.importESModule("resource:///modules/chatHandler.sys.mjs");
+ this.allContacts = allContacts;
+ this.onlineContacts = onlineContacts;
+ this.ChatCore = ChatCore;
+ if (this.ChatCore.initialized) {
+ this.initAfterChatCore();
+ } else {
+ this.ChatCore.init();
+ this._addObserver("chat-core-initialized");
+ }
+
+ if (ChatEncryption.otrEnabled) {
+ this._initOTR();
+ }
+
+ this._restoreWidth(document.getElementById("listPaneBox"));
+ this._restoreWidth(document.getElementById("contextPane"));
+ },
+
+ async _initOTR() {
+ if (!IMServices.core.initialized) {
+ await new Promise(resolve => {
+ function initObserver() {
+ Services.obs.removeObserver(initObserver, "prpl-init");
+ resolve();
+ }
+ Services.obs.addObserver(initObserver, "prpl-init");
+ });
+ }
+ // Avoid loading OTR until we have an im account set up.
+ if (IMServices.accounts.getAccounts().length === 0) {
+ await new Promise(resolve => {
+ function accountsObserver() {
+ if (IMServices.accounts.getAccounts().length > 0) {
+ Services.obs.removeObserver(accountsObserver, "account-added");
+ resolve();
+ }
+ }
+ Services.obs.addObserver(accountsObserver, "account-added");
+ });
+ }
+ await OTRUI.init();
+ },
+};
+
+function chatLogTreeGroupItem(aTitle, aLogItems) {
+ this._title = aTitle;
+ this._children = aLogItems;
+ for (let child of this._children) {
+ child._parent = this;
+ }
+ this._open = false;
+}
+chatLogTreeGroupItem.prototype = {
+ getText() {
+ return this._title;
+ },
+ get id() {
+ return this._title;
+ },
+ get open() {
+ return this._open;
+ },
+ get level() {
+ return 0;
+ },
+ get _parent() {
+ return null;
+ },
+ get children() {
+ return this._children;
+ },
+ getProperties() {
+ return "";
+ },
+};
+
+function chatLogTreeLogItem(aLog, aText, aLevel) {
+ this.log = aLog;
+ this._text = aText;
+ this._level = aLevel;
+}
+chatLogTreeLogItem.prototype = {
+ getText() {
+ return this._text;
+ },
+ get id() {
+ return this.log.title;
+ },
+ get open() {
+ return false;
+ },
+ get level() {
+ return this._level;
+ },
+ get children() {
+ return [];
+ },
+ getProperties() {
+ return "";
+ },
+};
+
+function chatLogTreeView(aTree, aLogs) {
+ this._tree = aTree;
+ this._logs = aLogs;
+ this._tree.view = this;
+ this._rebuild();
+}
+chatLogTreeView.prototype = {
+ __proto__: new PROTO_TREE_VIEW(),
+
+ _rebuild() {
+ // Some date helpers...
+ const kDayInMsecs = 24 * 60 * 60 * 1000;
+ const kWeekInMsecs = 7 * kDayInMsecs;
+ const kTwoWeeksInMsecs = 2 * kWeekInMsecs;
+
+ // Drop the old rowMap.
+ if (this._tree) {
+ this._tree.rowCountChanged(0, -this._rowMap.length);
+ }
+ this._rowMap = [];
+
+ let placesBundle = Services.strings.createBundle(
+ "chrome://places/locale/places.properties"
+ );
+ let dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "short" });
+ let monthYearFormat = new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ });
+ let monthFormat = new Intl.DateTimeFormat(undefined, { month: "long" });
+ let weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "long" });
+ let nowDate = new Date();
+ let todayDate = new Date(
+ nowDate.getFullYear(),
+ nowDate.getMonth(),
+ nowDate.getDate()
+ );
+
+ // The keys used in the 'firstgroups' object should match string ids.
+ // The order is the reverse of that in which they will appear
+ // in the logTree.
+ let firstgroups = {
+ previousWeek: [],
+ currentWeek: [],
+ };
+
+ // today and yesterday are treated differently, because for JSON logs they
+ // represent individual logs, and are not "groups".
+ let today = null,
+ yesterday = null;
+
+ // Build a chatLogTreeLogItem for each log, and put it in the right group.
+ let groups = {};
+ for (let log of this._logs) {
+ let logDate = new Date(log.time * 1000);
+ // Calculate elapsed time between the log and 00:00:00 today.
+ let timeFromToday = todayDate - logDate;
+ let title = dateFormat.format(logDate);
+ let group;
+ if (timeFromToday <= 0) {
+ today = new chatLogTreeLogItem(
+ log,
+ gChatBundle.GetStringFromName("log.today"),
+ 0
+ );
+ continue;
+ } else if (timeFromToday <= kDayInMsecs) {
+ yesterday = new chatLogTreeLogItem(
+ log,
+ gChatBundle.GetStringFromName("log.yesterday"),
+ 0
+ );
+ continue;
+ } else if (timeFromToday <= kWeekInMsecs - kDayInMsecs) {
+ // Note that the 7 days of the current week include today.
+ group = firstgroups.currentWeek;
+ title = weekdayFormat.format(logDate);
+ } else if (timeFromToday <= kTwoWeeksInMsecs - kDayInMsecs) {
+ group = firstgroups.previousWeek;
+ } else {
+ logDate.setHours(0);
+ logDate.setMinutes(0);
+ logDate.setSeconds(0);
+ logDate.setDate(1);
+ let groupID = logDate.toISOString();
+ if (!(groupID in groups)) {
+ let groupname;
+ if (logDate.getFullYear() == nowDate.getFullYear()) {
+ if (logDate.getMonth() == nowDate.getMonth()) {
+ groupname = placesBundle.GetStringFromName(
+ "finduri-AgeInMonths-is-0"
+ );
+ } else {
+ groupname = monthFormat.format(logDate);
+ }
+ } else {
+ groupname = monthYearFormat.format(logDate);
+ }
+ groups[groupID] = {
+ entries: [],
+ name: groupname,
+ };
+ }
+ group = groups[groupID].entries;
+ }
+ group.push(new chatLogTreeLogItem(log, title, 1));
+ }
+
+ let groupIDs = Object.keys(groups).sort().reverse();
+
+ // Add firstgroups to groups and groupIDs.
+ for (let groupID in firstgroups) {
+ let group = firstgroups[groupID];
+ if (!group.length) {
+ continue;
+ }
+ groupIDs.unshift(groupID);
+ groups[groupID] = {
+ entries: firstgroups[groupID],
+ name: gChatBundle.GetStringFromName("log." + groupID),
+ };
+ }
+
+ // Build tree.
+ if (today) {
+ this._rowMap.push(today);
+ }
+ if (yesterday) {
+ this._rowMap.push(yesterday);
+ }
+ groupIDs.forEach(function (aGroupID) {
+ let group = groups[aGroupID];
+ group.entries.sort((l1, l2) => l2.log.time - l1.log.time);
+ this._rowMap.push(new chatLogTreeGroupItem(group.name, group.entries));
+ }, this);
+
+ // Finally, notify the tree.
+ if (this._tree) {
+ this._tree.rowCountChanged(0, this._rowMap.length);
+ }
+ },
+};
+
+/**
+ * Handler for onpopupshowing event of the participantListContextMenu. Decides
+ * if the menu should be shown at all and manages the disabled state of its
+ * items.
+ *
+ * @param {XULMenuPopupElement} menu
+ * @returns {boolean} If the menu should be shown, currently decided based on
+ * if its only item has an action to perform.
+ */
+function showParticipantMenu(menu) {
+ const target = menu.triggerNode.closest("richlistitem");
+ if (!target?.chatBuddy?.canVerifyIdentity) {
+ return false;
+ }
+ const identityVerified = target.chatBuddy.identityVerified;
+ const verifyMenuItem = document.getElementById("context-verifyParticipant");
+ verifyMenuItem.disabled = identityVerified;
+ document.l10n.setAttributes(
+ verifyMenuItem,
+ identityVerified ? "chat-identity-verified" : "chat-verify-identity"
+ );
+ return true;
+}
+
+/**
+ * Command handler for the verify identity context menu item of the participant
+ * context menu. Initiates the verification for the participant the menu was
+ * opened on.
+ *
+ * @returns {undefined}
+ */
+function verifyChatParticipant() {
+ const target = document
+ .getElementById("participantListContextMenu")
+ .triggerNode.closest("richlistitem");
+ const buddy = target.chatBuddy;
+ if (!buddy) {
+ return;
+ }
+ ChatEncryption.verifyIdentity(window, buddy);
+}
+
+window.addEventListener("load", () => chatHandler.init());
diff --git a/comm/mail/components/im/content/imAccountWizard.js b/comm/mail/components/im/content/imAccountWizard.js
new file mode 100644
index 0000000000..128412aa5b
--- /dev/null
+++ b/comm/mail/components/im/content/imAccountWizard.js
@@ -0,0 +1,526 @@
+/* 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/. */
+
+// chat/content/imAccountOptionsHelper.js
+/* globals accountOptionsHelper */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+var PREF_EXTENSIONS_GETMOREPROTOCOLSURL = "extensions.getMoreProtocolsURL";
+
+var accountWizard = {
+ onload() {
+ document
+ .querySelector("wizard")
+ .addEventListener("wizardfinish", this.createAccount.bind(this));
+ let accountProtocolPage = document.getElementById("accountprotocol");
+ accountProtocolPage.addEventListener(
+ "pageadvanced",
+ this.selectProtocol.bind(this)
+ );
+ let accountUsernamePage = document.getElementById("accountusername");
+ accountUsernamePage.addEventListener(
+ "pageshow",
+ this.showUsernamePage.bind(this)
+ );
+ accountUsernamePage.addEventListener(
+ "pagehide",
+ this.hideUsernamePage.bind(this)
+ );
+ let accountAdvancedPage = document.getElementById("accountadvanced");
+ accountAdvancedPage.addEventListener(
+ "pageshow",
+ this.showAdvanced.bind(this)
+ );
+ let accountSummaryPage = document.getElementById("accountsummary");
+ accountSummaryPage.addEventListener(
+ "pageshow",
+ this.showSummary.bind(this)
+ );
+
+ // Ensure the im core is initialized before we get a list of protocols.
+ IMServices.core.init();
+
+ accountWizard.setGetMoreProtocols();
+
+ var protoList = document.getElementById("protolist");
+ var protos = IMServices.core.getProtocols();
+ protos.sort((a, b) => {
+ if (a.name < b.name) {
+ return -1;
+ }
+ return a.name > b.name ? 1 : 0;
+ });
+ protos.forEach(function (proto) {
+ let image = document.createElement("img");
+ image.setAttribute("src", ChatIcons.getProtocolIconURI(proto));
+ image.setAttribute("alt", "");
+ image.classList.add("protoIcon");
+
+ let label = document.createXULElement("label");
+ label.setAttribute("value", proto.name);
+
+ let item = document.createXULElement("richlistitem");
+ item.setAttribute("value", proto.id);
+ item.appendChild(image);
+ item.appendChild(label);
+ protoList.appendChild(item);
+ });
+
+ // there is a strange selection bug without this timeout
+ setTimeout(function () {
+ protoList.selectedIndex = 0;
+ }, 0);
+
+ Services.obs.addObserver(this, "prpl-quit");
+ window.addEventListener("unload", this.unload);
+ },
+ unload() {
+ Services.obs.removeObserver(accountWizard, "prpl-quit");
+ },
+ observe(aObject, aTopic, aData) {
+ if (aTopic == "prpl-quit") {
+ // libpurple is being uninitialized. We can't create any new
+ // account so keeping this wizard open would be pointless, close it.
+ window.close();
+ }
+ },
+
+ /**
+ * Builds the full username from the username boxes.
+ *
+ * @returns {string} assembled username
+ */
+ getUsername() {
+ let usernameBoxIndex = 0;
+ if (this.proto.usernamePrefix) {
+ usernameBoxIndex = 1;
+ }
+ // If the first username input is empty, make sure we return an empty
+ // string so that it blocks the 'next' button of the wizard.
+ if (!this.userNameBoxes[usernameBoxIndex].value) {
+ return "";
+ }
+
+ return this.userNameBoxes.reduce((prev, elt) => prev + elt.value, "");
+ },
+
+ /**
+ * Check that the username fields generate a new username, and if it is valid
+ * allow advancing the wizard.
+ */
+ checkUsername() {
+ var wizard = document.querySelector("wizard");
+ var name = accountWizard.getUsername();
+ var duplicateWarning = document.getElementById("duplicateAccount");
+ if (!name) {
+ wizard.canAdvance = false;
+ duplicateWarning.hidden = true;
+ return;
+ }
+
+ var exists = accountWizard.proto.accountExists(name);
+ wizard.canAdvance = !exists;
+ duplicateWarning.hidden = !exists;
+ },
+
+ /**
+ * Takes the value of the primary username field and splits it if the value
+ * matches the split field syntax.
+ */
+ splitUsername() {
+ let usernameBoxIndex = 0;
+ if (this.proto.usernamePrefix) {
+ usernameBoxIndex = 1;
+ }
+ let username = this.userNameBoxes[usernameBoxIndex].value;
+ let splitValues = this.proto.splitUsername(username);
+ if (!splitValues.length) {
+ return;
+ }
+ for (const box of this.userNameBoxes) {
+ if (Element.isInstance(box)) {
+ box.value = splitValues.shift();
+ }
+ }
+ this.checkUsername();
+ },
+
+ selectProtocol() {
+ var protoList = document.getElementById("protolist");
+ var id = protoList.selectedItem.value;
+ this.proto = IMServices.core.getProtocolById(id);
+ },
+
+ /**
+ * Create a new input field for receiving a username.
+ *
+ * @param {string} aName - The id for the input.
+ * @param {string} aLabel - The text for the username label.
+ * @param {Element} grid - A container with a two column grid display to
+ * append the new elements to.
+ * @param {string} [aDefaultValue] - The initial value for the username.
+ *
+ * @returns {HTMLInputElement} - The newly created username input.
+ */
+ insertUsernameField(aName, aLabel, grid, aDefaultValue) {
+ var label = document.createXULElement("label");
+ label.setAttribute("value", aLabel);
+ label.setAttribute("control", aName);
+ label.setAttribute("id", aName + "-label");
+ label.classList.add("label-inline");
+ grid.appendChild(label);
+
+ var input = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "input"
+ );
+ input.setAttribute("id", aName);
+ input.classList.add("input-inline");
+ if (aDefaultValue) {
+ input.setAttribute("value", aDefaultValue);
+ }
+ input.addEventListener("input", event => {
+ this.checkUsername();
+ });
+ // Only add the split logic to the first input field
+ if (!this.userNameBoxes) {
+ input.addEventListener("blur", event => {
+ this.splitUsername();
+ });
+ }
+ grid.appendChild(input);
+
+ return input;
+ },
+
+ /**
+ * Builds the username input boxes from the username split defined by the
+ * protocol.
+ */
+ showUsernamePage() {
+ var proto = this.proto.id;
+ if ("userNameBoxes" in this && this.userNameProto == proto) {
+ this.checkUsername();
+ return;
+ }
+
+ var bundle = document.getElementById("accountsBundle");
+ var usernameInfo;
+ var emptyText = this.proto.usernameEmptyText;
+ if (emptyText) {
+ usernameInfo = bundle.getFormattedString(
+ "accountUsernameInfoWithDescription",
+ [emptyText, this.proto.name]
+ );
+ } else {
+ usernameInfo = bundle.getFormattedString("accountUsernameInfo", [
+ this.proto.name,
+ ]);
+ }
+ document.getElementById("usernameInfo").textContent = usernameInfo;
+
+ var grid = document.getElementById("userNameBox");
+ // remove anything that may be there for another protocol
+ while (grid.hasChildNodes()) {
+ grid.lastChild.remove();
+ }
+ this.userNameBoxes = undefined;
+
+ var splits = this.proto.getUsernameSplit();
+
+ var label = bundle.getString("accountUsername");
+ this.userNameBoxes = [this.insertUsernameField("name", label, grid)];
+ this.userNameBoxes[0].emptyText = emptyText;
+ let usernameBoxIndex = 0;
+
+ if (this.proto.usernamePrefix) {
+ this.userNameBoxes.unshift({ value: this.proto.usernamePrefix });
+ usernameBoxIndex = 1;
+ }
+
+ for (let i = 0; i < splits.length; ++i) {
+ this.userNameBoxes.push({ value: splits[i].separator });
+ label = bundle.getFormattedString("accountColon", [splits[i].label]);
+ let defaultVal = splits[i].defaultValue;
+ this.userNameBoxes.push(
+ this.insertUsernameField("username-split-" + i, label, grid, defaultVal)
+ );
+ }
+ this.userNameBoxes[usernameBoxIndex].focus();
+ this.userNameProto = proto;
+ this.checkUsername();
+ },
+
+ hideUsernamePage() {
+ document.querySelector("wizard").canAdvance = true;
+ var next = "account" + (this.proto.noPassword ? "advanced" : "password");
+ document.getElementById("accountusername").next = next;
+ },
+
+ showAdvanced() {
+ // ensure we don't destroy user data if it's not necessary
+ var id = this.proto.id;
+ if ("protoSpecOptId" in this && this.protoSpecOptId == id) {
+ return;
+ }
+ this.protoSpecOptId = id;
+
+ this.populateProtoSpecificBox();
+
+ // Make sure the protocol specific options and wizard buttons are visible.
+ let wizard = document.querySelector("wizard");
+ if (wizard.scrollHeight > window.innerHeight) {
+ window.resizeBy(0, wizard.scrollHeight - window.innerHeight);
+ }
+
+ let alias = document.getElementById("alias");
+ alias.focus();
+ },
+
+ populateProtoSpecificBox() {
+ let haveOptions = accountOptionsHelper.addOptions(
+ this.proto.id + "-",
+ this.proto.getOptions()
+ );
+ document.getElementById("protoSpecificGroupbox").hidden = !haveOptions;
+ if (haveOptions) {
+ var bundle = document.getElementById("accountsBundle");
+ document.getElementById("protoSpecificCaption").textContent =
+ bundle.getFormattedString("protoOptions", [this.proto.name]);
+ }
+ },
+
+ /**
+ * Create new summary field and value elements.
+ *
+ * @param {string} aLabel - The name of the field being summarised.
+ * @param {string} aValue - The value of the field being summarised.
+ * @param {Element} grid - A container with a two column grid display to
+ * append the new elements to.
+ */
+ createSummaryRow(aLabel, aValue, grid) {
+ var label = document.createXULElement("label");
+ label.classList.add("header", "label-inline");
+ if (aLabel.length > 20) {
+ aLabel = aLabel.substring(0, 20);
+ aLabel += "…";
+ }
+
+ label.setAttribute("value", aLabel);
+ grid.appendChild(label);
+
+ var input = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "input"
+ );
+ input.setAttribute("value", aValue);
+ input.classList.add("plain", "input-inline");
+ input.setAttribute("readonly", true);
+ grid.appendChild(input);
+ },
+
+ showSummary() {
+ var rows = document.getElementById("summaryRows");
+ var bundle = document.getElementById("accountsBundle");
+ while (rows.hasChildNodes()) {
+ rows.lastChild.remove();
+ }
+
+ var label = document.getElementById("protoLabel").value;
+ this.createSummaryRow(label, this.proto.name, rows);
+ this.username = this.getUsername();
+ label = bundle.getString("accountUsername");
+ this.createSummaryRow(label, this.username, rows);
+ if (!this.proto.noPassword) {
+ this.password = this.getValue("password");
+ if (this.password) {
+ label = document.getElementById("passwordLabel").value;
+ var pass = "";
+ for (let i = 0; i < this.password.length; ++i) {
+ pass += "*";
+ }
+ this.createSummaryRow(label, pass, rows);
+ }
+ }
+ this.alias = this.getValue("alias");
+ if (this.alias) {
+ label = document.getElementById("aliasLabel").value;
+ this.createSummaryRow(label, this.alias, rows);
+ }
+
+ var id = this.proto.id;
+ this.prefs = [];
+ for (let opt of this.proto.getOptions()) {
+ let name = opt.name;
+ let eltName = id + "-" + name;
+ let val = this.getValue(eltName);
+ // The value will be undefined if the proto specific groupbox has never been opened
+ if (val === undefined) {
+ continue;
+ }
+ switch (opt.type) {
+ case Ci.prplIPref.typeBool:
+ if (val != opt.getBool()) {
+ this.prefs.push({ opt, name, value: !!val });
+ }
+ break;
+ case Ci.prplIPref.typeInt:
+ if (val != opt.getInt()) {
+ this.prefs.push({ opt, name, value: val });
+ }
+ break;
+ case Ci.prplIPref.typeString:
+ if (val != opt.getString()) {
+ this.prefs.push({ opt, name, value: val });
+ }
+ break;
+ case Ci.prplIPref.typeList:
+ if (val != opt.getListDefault()) {
+ this.prefs.push({ opt, name, value: val });
+ }
+ break;
+ default:
+ throw new Error("unknown preference type " + opt.type);
+ }
+ }
+
+ for (let i = 0; i < this.prefs.length; ++i) {
+ let opt = this.prefs[i];
+ let label = bundle.getFormattedString("accountColon", [opt.opt.label]);
+ this.createSummaryRow(label, opt.value, rows);
+ }
+ },
+
+ createAccount() {
+ var acc = IMServices.accounts.createAccount(this.username, this.proto.id);
+ if (!this.proto.noPassword && this.password) {
+ acc.password = this.password;
+ }
+ if (this.alias) {
+ acc.alias = this.alias;
+ }
+
+ for (let i = 0; i < this.prefs.length; ++i) {
+ let option = this.prefs[i];
+ let opt = option.opt;
+ switch (opt.type) {
+ case Ci.prplIPref.typeBool:
+ acc.setBool(option.name, option.value);
+ break;
+ case Ci.prplIPref.typeInt:
+ acc.setInt(option.name, option.value);
+ break;
+ case Ci.prplIPref.typeString:
+ case Ci.prplIPref.typeList:
+ acc.setString(option.name, option.value);
+ break;
+ default:
+ throw new Error("unknown type");
+ }
+ }
+ var autologin = this.getValue("connectNow");
+ acc.autoLogin = autologin;
+
+ acc.save();
+
+ try {
+ if (autologin) {
+ acc.connect();
+ }
+ } catch (e) {
+ // If the connection fails (for example if we are currently in
+ // offline mode), we still want to close the account wizard
+ }
+
+ if (window.opener) {
+ var am = window.opener.gAccountManager;
+ if (am) {
+ am.selectAccount(acc.id);
+ }
+ }
+
+ var inServer = MailServices.accounts.createIncomingServer(
+ this.username,
+ this.proto.id, // hostname
+ "im"
+ );
+ inServer.wrappedJSObject.imAccount = acc;
+
+ var account = MailServices.accounts.createAccount();
+ // Avoid new folder notifications.
+ inServer.valid = false;
+ account.incomingServer = inServer;
+ inServer.valid = true;
+ MailServices.accounts.notifyServerLoaded(inServer);
+
+ return true;
+ },
+
+ getValue(aId) {
+ var elt = document.getElementById(aId);
+ if ("selectedItem" in elt) {
+ return elt.selectedItem.value;
+ }
+ // Strangely various input types also have a "checked" property defined,
+ // so we check for the expected elements explicitly.
+ if (
+ ((elt.localName == "input" && elt.getAttribute("type") == "checkbox") ||
+ elt.localName == "checkbox") &&
+ "checked" in elt
+ ) {
+ return elt.checked;
+ }
+ if ("value" in elt) {
+ return elt.value;
+ }
+ // If the groupbox has never been opened, the binding isn't attached
+ // so the attributes don't exist. The calling code in showSummary
+ // has a special handling of the undefined value for this case.
+ return undefined;
+ },
+
+ *getIter(aEnumerator) {
+ for (let iter of aEnumerator) {
+ yield iter;
+ }
+ },
+
+ /* Check for correctness and set URL for the "Get more protocols..."-link
+ * Stripped down code from preferences/themes.js
+ */
+ setGetMoreProtocols() {
+ let prefURL = PREF_EXTENSIONS_GETMOREPROTOCOLSURL;
+ var getMore = document.getElementById("getMoreProtocols");
+ var showGetMore = false;
+ const nsIPrefBranch = Ci.nsIPrefBranch;
+
+ if (Services.prefs.getPrefType(prefURL) != nsIPrefBranch.PREF_INVALID) {
+ try {
+ var getMoreURL = Services.urlFormatter.formatURLPref(prefURL);
+ getMore.setAttribute("getMoreURL", getMoreURL);
+ showGetMore = getMoreURL != "about:blank";
+ } catch (e) {}
+ }
+ getMore.hidden = !showGetMore;
+ },
+
+ openURL(aURL) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(aURL));
+ },
+};
+
+window.addEventListener("load", event => {
+ accountWizard.onload();
+});
diff --git a/comm/mail/components/im/content/imAccountWizard.xhtml b/comm/mail/components/im/content/imAccountWizard.xhtml
new file mode 100644
index 0000000000..9ff3cf33ad
--- /dev/null
+++ b/comm/mail/components/im/content/imAccountWizard.xhtml
@@ -0,0 +1,180 @@
+<?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://messenger/skin/accountWizard.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % accountWizardDTD SYSTEM "chrome://messenger/locale/imAccountWizard.dtd">
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%accountWizardDTD; %brandDTD; ]>
+
+<html
+ id="accountWizard"
+ 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"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title>&windowTitle.label;</title>
+ <link rel="localization" href="toolkit/global/wizard.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://chat/content/imAccountOptionsHelper.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/imAccountWizard.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <stringbundle
+ id="accountsBundle"
+ src="chrome://messenger/locale/imAccounts.properties"
+ />
+
+ <wizard id="wizard">
+ <wizardpage
+ id="accountprotocol"
+ pageid="accountprotocol"
+ next="accountusername"
+ label="&accountProtocolTitle.label;"
+ >
+ <description>&accountProtocolInfo.label;</description>
+ <separator />
+ <label
+ value="&accountProtocolField.label;"
+ control="protolist"
+ id="protoLabel"
+ hidden="true"
+ />
+ <richlistbox
+ flex="1"
+ id="protolist"
+ ondblclick="document.getElementById('wizard').advance();"
+ />
+ <hbox pack="end">
+ <label
+ id="getMoreProtocols"
+ class="text-link"
+ value="&accountProtocolGetMore.label;"
+ onclick="if (event.button == 0) { accountWizard.openURL(this.getAttribute('getMoreURL')); }"
+ />
+ </hbox>
+ </wizardpage>
+
+ <wizardpage
+ id="accountusername"
+ pageid="accountusername"
+ next="accountpassword"
+ label="&accountUsernameTitle.label;"
+ >
+ <description id="usernameInfo" />
+ <separator />
+ <html:div
+ id="userNameBox"
+ class="grid-block-two-column-fr grid-items-center"
+ >
+ </html:div>
+ <separator />
+ <description id="duplicateAccount" hidden="true"
+ >&accountUsernameDuplicate.label;</description
+ >
+ </wizardpage>
+
+ <wizardpage
+ id="accountpassword"
+ pageid="accountpassword"
+ next="accountadvanced"
+ label="&accountPasswordTitle.label;"
+ >
+ <description>&accountPasswordInfo.label;</description>
+ <separator />
+ <hbox id="passwordBox" align="baseline" class="input-container">
+ <label
+ id="passwordLabel"
+ value="&accountPasswordField.label;"
+ class="label-inline"
+ control="password"
+ />
+ <html:input id="password" type="password" class="input-inline" />
+ </hbox>
+ <separator />
+ <description id="passwordManagerDescription"
+ >&accountPasswordManager.label;</description
+ >
+ </wizardpage>
+
+ <wizardpage
+ id="accountadvanced"
+ pageid="accountadvanced"
+ next="accountsummary"
+ label="&accountAdvancedTitle.label;"
+ >
+ <description>&accountAdvancedInfo.label;</description>
+ <separator class="thin" />
+ <html:fieldset id="aliasGroupbox">
+ <html:legend id="aliasGroupboxCaption"
+ >&accountAliasGroupbox.caption;</html:legend
+ >
+ <hbox id="aliasBox" align="baseline" class="input-container">
+ <label
+ id="aliasLabel"
+ value="&accountAliasField.label;"
+ class="label-inline"
+ control="alias"
+ />
+ <html:input id="alias" type="text" class="input-inline" />
+ </hbox>
+ <description>&accountAliasInfo.label;</description>
+ </html:fieldset>
+
+ <html:fieldset id="protoSpecificGroupbox">
+ <html:legend id="protoSpecificCaption"></html:legend>
+ <html:div
+ id="protoSpecific"
+ class="grid-block-two-column-fr grid-items-baseline"
+ >
+ </html:div>
+ </html:fieldset>
+ </wizardpage>
+
+ <wizardpage
+ id="accountsummary"
+ pageid="accountsummary"
+ label="&accountSummaryTitle.label;"
+ >
+ <description>&accountSummaryInfo.label;</description>
+ <separator />
+ <html:div
+ id="summaryRows"
+ class="grid-block-two-column-fr grid-items-baseline"
+ >
+ </html:div>
+ <separator />
+ <checkbox
+ id="connectNow"
+ label="&accountSummary.connectNow.label;"
+ checked="true"
+ />
+ </wizardpage>
+ </wizard>
+ </html:body>
+</html>
diff --git a/comm/mail/components/im/content/imAccounts.js b/comm/mail/components/im/content/imAccounts.js
new file mode 100644
index 0000000000..46bb72c197
--- /dev/null
+++ b/comm/mail/components/im/content/imAccounts.js
@@ -0,0 +1,663 @@
+/* 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 MozElements */
+/* globals statusSelector */
+/* globals MsgAccountManager */
+
+var { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+// This is the list of notifications that the account manager window observes
+var events = [
+ "prpl-quit",
+ "account-list-updated",
+ "account-added",
+ "account-updated",
+ "account-removed",
+ "account-connected",
+ "account-connecting",
+ "account-disconnected",
+ "account-disconnecting",
+ "account-connect-progress",
+ "account-connect-error",
+ "autologin-processed",
+ "status-changed",
+ "network:offline-status-changed",
+];
+
+var gAccountManager = {
+ // Sets the delay after connect() or disconnect() during which
+ // it is impossible to perform disconnect() and connect()
+ _disabledDelay: 500,
+ disableTimerID: 0,
+ _connectedLabelInterval: 0,
+
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ document.getElementById("accounts-notification-box").prepend(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ load() {
+ // Wait until the password service is ready before offering anything.
+ Services.logins.initializationPromise.then(
+ () => {
+ this.accountList = document.getElementById("accountlist");
+ let defaultID;
+ IMServices.core.init(); // ensure the imCore is initialized.
+ for (let acc of this.getAccounts()) {
+ let elt = document.createXULElement("richlistitem", {
+ is: "chat-account-richlistitem",
+ });
+ this.accountList.appendChild(elt);
+ elt.build(acc);
+ if (
+ !defaultID &&
+ acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED
+ ) {
+ defaultID = acc.id;
+ }
+ }
+ for (let event of events) {
+ Services.obs.addObserver(this, event);
+ }
+ if (!this.accountList.getRowCount()) {
+ // This is horrible, but it works. Otherwise (at least on mac)
+ // the wizard is not centered relatively to the account manager
+ setTimeout(function () {
+ gAccountManager.new();
+ }, 0);
+ } else {
+ // we have accounts, show the list
+ document.getElementById("noAccountScreen").hidden = true;
+ document.getElementById("accounts-notification-box").hidden = false;
+
+ // ensure an account is selected
+ if (defaultID) {
+ this.selectAccount(defaultID);
+ } else {
+ this.accountList.selectedIndex = 0;
+ }
+ }
+
+ this.setAutoLoginNotification();
+
+ this.accountList.addEventListener("keypress", this.onKeyPress, true);
+ window.addEventListener("unload", this.unload.bind(this));
+ this._connectedLabelInterval = setInterval(
+ this.updateConnectedLabels,
+ 60000
+ );
+ statusSelector.init();
+ },
+ () => {
+ this.close();
+ }
+ );
+ },
+ unload() {
+ clearInterval(this._connectedLabelInterval);
+ for (let event of events) {
+ Services.obs.removeObserver(this, event);
+ }
+ },
+ _updateAccountList() {
+ let accountList = this.accountList;
+ let i = 0;
+ for (let acc of this.getAccounts()) {
+ let oldItem = accountList.getItemAtIndex(i);
+ if (oldItem.id != acc.id) {
+ let accElt = document.getElementById(acc.id);
+ accountList.insertBefore(accElt, oldItem);
+ accElt.refreshState();
+ }
+ ++i;
+ }
+
+ if (accountList.itemCount == 0) {
+ // Focus the "New Account" button if there are no accounts left.
+ document.getElementById("newaccount").focus();
+ // Return early, otherwise we'll run into an 'undefined property' strict
+ // warning when trying to focus the buttons. Fixes bug 408.
+ return;
+ }
+
+ // The selected item is still selected
+ if (accountList.selectedItem) {
+ accountList.selectedItem.setFocus();
+ }
+ accountList.ensureSelectedElementIsVisible();
+
+ // We need to refresh the disabled menu items
+ this.disableCommandItems();
+ },
+ observe(aObject, aTopic, aData) {
+ if (aTopic == "prpl-quit") {
+ // libpurple is being uninitialized. We don't need the account
+ // manager window anymore, close it.
+ this.close();
+ return;
+ } else if (aTopic == "autologin-processed") {
+ let notification =
+ this.msgNotificationBar.getNotificationWithValue("autoLoginStatus");
+ if (notification) {
+ notification.close();
+ }
+ return;
+ } else if (aTopic == "network:offline-status-changed") {
+ this.setOffline(aData == "offline");
+ return;
+ } else if (aTopic == "status-changed") {
+ this.setOffline(aObject.statusType == Ci.imIStatusInfo.STATUS_OFFLINE);
+ return;
+ } else if (aTopic == "account-list-updated") {
+ this._updateAccountList();
+ return;
+ }
+
+ // The following notification handlers need an account.
+ let account = aObject.QueryInterface(Ci.imIAccount);
+
+ if (aTopic == "account-added") {
+ document.getElementById("noAccountScreen").hidden = true;
+ document.getElementById("accounts-notification-box").hidden = false;
+ let elt = document.createXULElement("richlistitem", {
+ is: "chat-account-richlistitem",
+ });
+ this.accountList.appendChild(elt);
+ elt.build(account);
+ if (this.accountList.getRowCount() == 1) {
+ this.accountList.selectedIndex = 0;
+ }
+ } else if (aTopic == "account-removed") {
+ let elt = document.getElementById(account.id);
+ elt.destroy();
+ if (!elt.selected) {
+ elt.remove();
+ return;
+ }
+ // The currently selected element is removed,
+ // ensure another element gets selected (if the list is not empty)
+ var selectedIndex = this.accountList.selectedIndex;
+ // Prevent errors if the timer is active and the account deleted
+ clearTimeout(this.disableTimerID);
+ this.disableTimerID = 0;
+ elt.remove();
+ var count = this.accountList.getRowCount();
+ if (!count) {
+ document.getElementById("noAccountScreen").hidden = false;
+ document.getElementById("accounts-notification-box").hidden = true;
+ return;
+ }
+ if (selectedIndex == count) {
+ --selectedIndex;
+ }
+ this.accountList.selectedIndex = selectedIndex;
+ } else if (aTopic == "account-updated") {
+ document.getElementById(account.id).build(account);
+ this.disableCommandItems();
+ } else if (aTopic == "account-connect-progress") {
+ document.getElementById(account.id).updateConnectingProgress();
+ } else if (aTopic == "account-connect-error") {
+ document.getElementById(account.id).updateConnectionError();
+ // See NSSErrorsService::ErrorIsOverridable.
+ if (
+ [
+ "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED",
+ "MOZILLA_PKIX_ERROR_CA_CERT_USED_AS_END_ENTITY",
+ "MOZILLA_PKIX_ERROR_EMPTY_ISSUER_NAME",
+ "MOZILLA_PKIX_ERROR_INADEQUATE_KEY_SIZE",
+ "MOZILLA_PKIX_ERROR_MITM_DETECTED",
+ "MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE",
+ "MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE",
+ "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT",
+ "MOZILLA_PKIX_ERROR_V1_CERT_USED_AS_CA",
+ "SEC_ERROR_CA_CERT_INVALID",
+ "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED",
+ "SEC_ERROR_EXPIRED_CERTIFICATE",
+ "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE",
+ "SEC_ERROR_INVALID_TIME",
+ "SEC_ERROR_UNKNOWN_ISSUER",
+ "SSL_ERROR_BAD_CERT_DOMAIN",
+ ].includes(account.prplAccount.securityInfo?.errorCodeString)
+ ) {
+ this.addException();
+ }
+ } else {
+ const stateEvents = {
+ "account-connected": "connected",
+ "account-connecting": "connecting",
+ "account-disconnected": "disconnected",
+ "account-disconnecting": "disconnecting",
+ };
+ if (aTopic in stateEvents) {
+ let elt = document.getElementById(account.id);
+ if (!elt) {
+ // Probably disconnecting a removed account.
+ return;
+ }
+ elt.refreshState(stateEvents[aTopic]);
+ }
+ }
+ },
+ cancelReconnection() {
+ this.accountList.selectedItem.cancelReconnection();
+ },
+ connect() {
+ let account = this.accountList.selectedItem.account;
+ if (account.disconnected) {
+ this.temporarilyDisableButtons();
+ account.connect();
+ }
+ },
+ disconnect() {
+ let account = this.accountList.selectedItem.account;
+ if (account.connected || account.connecting) {
+ this.temporarilyDisableButtons();
+ account.disconnect();
+ }
+ },
+ addException() {
+ let account = this.accountList.selectedItem.account;
+ let prplAccount = account.prplAccount;
+ if (!prplAccount.connectionTarget) {
+ return;
+ }
+
+ // Open the Gecko SSL exception dialog.
+ let params = {
+ exceptionAdded: false,
+ securityInfo: prplAccount.securityInfo,
+ prefetchCert: true,
+ location: prplAccount.connectionTarget,
+ };
+ window.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ // Reconnect the account if an exception was added.
+ if (params.exceptionAdded) {
+ account.disconnect();
+ account.connect();
+ }
+ },
+ copyDebugLog() {
+ let account = this.accountList.selectedItem.account;
+ let text = account
+ .getDebugMessages()
+ .map(function (dbgMsg) {
+ let m = dbgMsg.message;
+ let time = new Date(m.timeStamp);
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeStyle: "long",
+ });
+ time = dateTimeFormatter.format(time);
+ let level = dbgMsg.logLevel;
+ if (!level) {
+ return "(" + m.errorMessage + ")";
+ }
+ if (level == dbgMsg.LEVEL_ERROR) {
+ level = "ERROR";
+ } else if (level == dbgMsg.LEVEL_WARNING) {
+ level = "WARN.";
+ } else if (level == dbgMsg.LEVEL_LOG) {
+ level = "LOG ";
+ } else {
+ level = "DEBUG";
+ }
+ return (
+ "[" +
+ time +
+ "] " +
+ level +
+ " (@ " +
+ m.sourceLine +
+ " " +
+ m.sourceName +
+ ":" +
+ m.lineNumber +
+ ")\n" +
+ m.errorMessage
+ );
+ })
+ .join("\n");
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(text);
+ },
+ updateConnectedLabels() {
+ for (let i = 0; i < gAccountManager.accountList.itemCount; ++i) {
+ let item = gAccountManager.accountList.getItemAtIndex(i);
+ if (item.account.connected) {
+ item.refreshConnectedLabel();
+ }
+ }
+ },
+ /* This function disables the connect/disconnect buttons for
+ * `this._disabledDelay` ms before calling disableCommandItems to restore
+ * the state of the buttons.
+ */
+ temporarilyDisableButtons() {
+ document.getElementById("cmd_disconnect").setAttribute("disabled", "true");
+ document.getElementById("cmd_connect").setAttribute("disabled", "true");
+ clearTimeout(this.disableTimerID);
+ this.accountList.focus();
+ this.disableTimerID = setTimeout(
+ function (aItem) {
+ gAccountManager.disableTimerID = 0;
+ gAccountManager.disableCommandItems();
+ aItem.setFocus();
+ },
+ this._disabledDelay,
+ this.accountList.selectedItem
+ );
+ },
+
+ new() {
+ this.openDialog("chrome://messenger/content/chat/imAccountWizard.xhtml");
+ },
+ edit() {
+ // Find the nsIIncomingServer for the current imIAccount.
+ let server = null;
+ let imAccountId = this.accountList.selectedItem.account.numericId;
+ for (let account of MailServices.accounts.accounts) {
+ let incomingServer = account.incomingServer;
+ if (!incomingServer || incomingServer.type != "im") {
+ continue;
+ }
+ if (incomingServer.wrappedJSObject.imAccount.numericId == imAccountId) {
+ server = incomingServer;
+ break;
+ }
+ }
+
+ MsgAccountManager(null, server);
+ },
+ autologin() {
+ var elt = this.accountList.selectedItem;
+ elt.autoLogin = !elt.autoLogin;
+ },
+ close() {
+ // If a modal dialog is opened, we can't close this window now
+ if (this.modalDialog) {
+ setTimeout(function () {
+ window.close();
+ }, 0);
+ } else {
+ window.close();
+ }
+ },
+
+ /* This function disables or enables the currently selected button and
+ the corresponding context menu item */
+ disableCommandItems() {
+ let accountList = this.accountList;
+ let selectedItem = accountList.selectedItem;
+ // When opening the account manager, if accounts have errors, we
+ // can be called during build(), before any item is selected.
+ // In this case, just return early.
+ if (!selectedItem) {
+ return;
+ }
+
+ // If the timer that disables the button (for a short time) already exists,
+ // we don't want to interfere and set the button as enabled.
+ if (this.disableTimerID) {
+ return;
+ }
+
+ let account = selectedItem.account;
+ let isCommandDisabled =
+ this.isOffline ||
+ (account.disconnected &&
+ account.connectionErrorReason == Ci.imIAccount.ERROR_UNKNOWN_PRPL);
+
+ let disabledItems = ["connect", "disconnect"];
+ for (let name of disabledItems) {
+ let elt = document.getElementById("cmd_" + name);
+ if (isCommandDisabled) {
+ elt.setAttribute("disabled", "true");
+ } else {
+ elt.removeAttribute("disabled");
+ }
+ }
+ },
+ onContextMenuShowing(event) {
+ let targetElt = event.target.triggerNode.closest(
+ 'richlistitem[is="chat-account-richlistitem"]'
+ );
+ document.querySelectorAll(".im-context-account-item").forEach(e => {
+ e.hidden = !targetElt;
+ });
+ if (targetElt) {
+ let account = targetElt.account;
+ let hiddenItems = {
+ connect: !account.disconnected,
+ disconnect: account.disconnected || account.disconnecting,
+ cancelReconnection: !targetElt.hasAttribute("reconnectPending"),
+ accountsItemsSeparator: account.disconnecting,
+ };
+ for (let name in hiddenItems) {
+ document.getElementById("context_" + name).hidden = hiddenItems[name];
+ }
+ }
+ },
+
+ selectAccount(aAccountId) {
+ this.accountList.selectedItem = document.getElementById(aAccountId);
+ this.accountList.ensureSelectedElementIsVisible();
+ },
+ onAccountSelect() {
+ clearTimeout(this.disableTimerID);
+ this.disableTimerID = 0;
+ this.disableCommandItems();
+ // Horrible hack here too, see Bug 177
+ setTimeout(
+ function (aThis) {
+ try {
+ aThis.accountList.selectedItem.setFocus();
+ } catch (e) {
+ /* Sometimes if the user goes too fast with VK_UP or VK_DOWN, the
+ selectedItem doesn't have the expected binding attached */
+ }
+ },
+ 0,
+ this
+ );
+ },
+
+ onKeyPress(event) {
+ if (!this.selectedItem) {
+ return;
+ }
+ // As we stop propagation, the default action applies to the richlistbox
+ // so that the selected account is changed with this default action
+ if (event.keyCode == event.DOM_VK_DOWN) {
+ if (this.selectedIndex < this.itemCount - 1) {
+ this.ensureIndexIsVisible(this.selectedIndex + 1);
+ }
+ event.stopPropagation();
+ return;
+ }
+
+ if (event.keyCode == event.DOM_VK_UP) {
+ if (this.selectedIndex > 0) {
+ this.ensureIndexIsVisible(this.selectedIndex - 1);
+ }
+ event.stopPropagation();
+ return;
+ }
+
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ let target = event.target;
+ if (
+ target.localName != "checkbox" &&
+ (target.localName != "button" ||
+ /^(dis)?connect$/.test(target.getAttribute("anonid")))
+ ) {
+ this.selectedItem.buttons.proceedDefaultAction();
+ }
+ }
+ },
+
+ *getAccounts() {
+ for (let account of IMServices.accounts.getAccounts()) {
+ yield account;
+ }
+ },
+
+ openDialog(aUrl, aArgs) {
+ this.modalDialog = true;
+ window.openDialog(aUrl, "", "chrome,modal,titlebar,centerscreen", aArgs);
+ this.modalDialog = false;
+ },
+
+ setAutoLoginNotification() {
+ var as = IMServices.accounts;
+ var autoLoginStatus = as.autoLoginStatus;
+ let isOffline = false;
+ let crashCount = 0;
+ for (let acc of this.getAccounts()) {
+ if (
+ acc.autoLogin &&
+ acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED
+ ) {
+ ++crashCount;
+ }
+ }
+
+ if (autoLoginStatus == as.AUTOLOGIN_ENABLED && crashCount == 0) {
+ let status = IMServices.core.globalUserStatus.statusType;
+ this.setOffline(isOffline || status == Ci.imIStatusInfo.STATUS_OFFLINE);
+ return;
+ }
+
+ var bundle = document.getElementById("accountsBundle");
+ let box = this.msgNotificationBar;
+ var prio = box.PRIORITY_INFO_HIGH;
+ var connectNowButton = {
+ accessKey: bundle.getString(
+ "accountsManager.notification.button.accessKey"
+ ),
+ callback: this.processAutoLogin,
+ label: bundle.getString("accountsManager.notification.button.label"),
+ };
+ var barLabel;
+
+ switch (autoLoginStatus) {
+ case as.AUTOLOGIN_USER_DISABLED:
+ barLabel = bundle.getString(
+ "accountsManager.notification.userDisabled.label"
+ );
+ break;
+
+ case as.AUTOLOGIN_SAFE_MODE:
+ barLabel = bundle.getString(
+ "accountsManager.notification.safeMode.label"
+ );
+ break;
+
+ case as.AUTOLOGIN_START_OFFLINE:
+ barLabel = bundle.getString(
+ "accountsManager.notification.startOffline.label"
+ );
+ isOffline = true;
+ break;
+
+ case as.AUTOLOGIN_CRASH:
+ barLabel = bundle.getString("accountsManager.notification.crash.label");
+ prio = box.PRIORITY_WARNING_MEDIUM;
+ break;
+
+ /* One or more accounts made the application crash during their connection.
+ If none, this function has already returned */
+ case as.AUTOLOGIN_ENABLED:
+ barLabel = bundle.getString(
+ "accountsManager.notification.singleCrash.label"
+ );
+ barLabel = PluralForm.get(crashCount, barLabel).replace(
+ "#1",
+ crashCount
+ );
+ prio = box.PRIORITY_WARNING_MEDIUM;
+ connectNowButton.callback = this.processCrashedAccountsLogin;
+ break;
+
+ default:
+ barLabel = bundle.getString("accountsManager.notification.other.label");
+ }
+ let status = IMServices.core.globalUserStatus.statusType;
+ this.setOffline(isOffline || status == Ci.imIStatusInfo.STATUS_OFFLINE);
+
+ box.appendNotification(
+ "autologinStatus",
+ {
+ label: barLabel,
+ priority: prio,
+ },
+ [connectNowButton]
+ );
+ },
+ processAutoLogin() {
+ var ioService = Services.io;
+ if (ioService.offline) {
+ ioService.manageOfflineStatus = false;
+ ioService.offline = false;
+ }
+
+ IMServices.accounts.processAutoLogin();
+
+ gAccountManager.accountList.selectedItem.setFocus();
+ },
+ processCrashedAccountsLogin() {
+ for (let acc in gAccountManager.getAccounts()) {
+ if (
+ acc.disconnected &&
+ acc.autoLogin &&
+ acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED
+ ) {
+ acc.connect();
+ }
+ }
+
+ let notification =
+ this.msgNotificationBar.getNotificationWithValue("autoLoginStatus");
+ if (notification) {
+ notification.close();
+ }
+
+ gAccountManager.accountList.selectedItem.setFocus();
+ },
+ setOffline(aState) {
+ this.isOffline = aState;
+ if (aState) {
+ this.accountList.setAttribute("offline", "true");
+ } else {
+ this.accountList.removeAttribute("offline");
+ }
+ this.disableCommandItems();
+ },
+};
+
+window.addEventListener("DOMContentLoaded", () => {
+ gAccountManager.load();
+});
diff --git a/comm/mail/components/im/content/imAccounts.xhtml b/comm/mail/components/im/content/imAccounts.xhtml
new file mode 100644
index 0000000000..d123521be1
--- /dev/null
+++ b/comm/mail/components/im/content/imAccounts.xhtml
@@ -0,0 +1,250 @@
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<!-- 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://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/imRichlistbox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/imAccounts.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % accountsDTD SYSTEM "chrome://chat/locale/accounts.dtd">
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+<!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd">
+%accountsDTD; %brandDTD; %chatDTD; ]>
+
+<html
+ id="accountManager"
+ 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="Messenger:Accounts"
+ scrolling="false"
+ lightweightthemes="true"
+ persist="width height screenX screenY"
+>
+ <head>
+ <title>&accountsWindow.title;</title>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/imAccounts.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/accountUtils.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/imStatusSelector.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://chat/content/chat-account-richlistitem.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <stringbundle
+ id="accountsBundle"
+ src="chrome://messenger/locale/imAccounts.properties"
+ />
+
+ <commandset id="accountsCommands">
+ <command
+ id="cmd_connect"
+ accesskey="&account.connect.accesskey;"
+ label="&account.connect.label;"
+ oncommand="gAccountManager.connect()"
+ />
+ <command
+ id="cmd_disconnect"
+ label="&account.disconnect.label;"
+ accesskey="&account.disconnect.accesskey;"
+ oncommand="gAccountManager.disconnect()"
+ />
+ <command
+ id="cmd_cancelReconnection"
+ label="&account.cancelReconnection.label;"
+ accesskey="&account.cancelReconnection.accesskey;"
+ oncommand="gAccountManager.cancelReconnection()"
+ />
+ <command
+ id="cmd_copyDebugLog"
+ label="&account.copyDebugLog.label;"
+ accesskey="&account.copyDebugLog.accesskey;"
+ oncommand="gAccountManager.copyDebugLog();"
+ />
+ <command
+ id="cmd_edit"
+ label="&account.edit.label;"
+ accesskey="&account.edit.accesskey;"
+ oncommand="gAccountManager.edit()"
+ />
+ <command
+ id="cmd_new"
+ label="&accountManager.newAccount.label;"
+ accesskey="&accountManager.newAccount.accesskey;"
+ oncommand="gAccountManager.new()"
+ />
+ <command
+ id="cmd_close"
+ label="&accountManager.close.label;"
+ accesskey="&accountManager.close.accesskey;"
+ oncommand="gAccountManager.close()"
+ />
+ </commandset>
+
+ <keyset id="accountsKeys">
+ <key id="key_close1" key="w" modifiers="accel" command="cmd_close" />
+ <key id="key_close2" keycode="VK_ESCAPE" command="cmd_close" />
+ <key
+ id="key_close3"
+ command="cmd_close"
+ key="&accountManager.close.commandkey;"
+ modifiers="accel,shift"
+ />
+ </keyset>
+
+ <menupopup
+ id="accountsContextMenu"
+ onpopupshowing="gAccountManager.onContextMenuShowing(event)"
+ >
+ <menuitem
+ id="context_connect"
+ command="cmd_connect"
+ class="im-context-account-item"
+ />
+ <menuitem
+ id="context_disconnect"
+ command="cmd_disconnect"
+ class="im-context-account-item"
+ />
+ <menuitem
+ id="context_cancelReconnection"
+ command="cmd_cancelReconnection"
+ class="im-context-account-item"
+ />
+ <menuitem id="context_copyDebugLog" command="cmd_copyDebugLog" />
+ <menuseparator
+ id="context_accountsItemsSeparator"
+ class="im-context-account-item"
+ />
+ <menuitem command="cmd_new" />
+ <menuseparator class="im-context-account-item" />
+ <menuitem command="cmd_edit" class="im-context-account-item" />
+ </menupopup>
+
+ <html:div class="displayUserAccount">
+ <stack id="statusImageStack">
+ <html:img
+ id="userIcon"
+ class="userIcon"
+ alt=""
+ onclick="statusSelector.userIconClick();"
+ />
+ <button
+ type="menu"
+ id="statusTypeIcon"
+ class="statusTypeIcon"
+ status="available"
+ >
+ <menupopup
+ id="setStatusTypeMenupopup"
+ oncommand="statusSelector.editStatus(event);"
+ >
+ <menuitem
+ id="statusTypeAvailable"
+ label="&status.available;"
+ status="available"
+ class="menuitem-iconic"
+ />
+ <menuitem
+ id="statusTypeUnavailable"
+ label="&status.unavailable;"
+ status="unavailable"
+ class="menuitem-iconic"
+ />
+ <menuseparator id="statusTypeOfflineSeparator" />
+ <menuitem
+ id="statusTypeOffline"
+ label="&status.offline;"
+ status="offline"
+ class="menuitem-iconic"
+ />
+ </menupopup>
+ </button>
+ </stack>
+ <html:div id="displayNameAndstatusMessageGrid">
+ <label
+ id="displayName"
+ onclick="statusSelector.displayNameClick();"
+ align="center"
+ pack="center"
+ />
+ <!-- FIXME: A keyboard user cannot focus the hidden input, nor click
+ - the above label in order to reveal it. -->
+ <html:input
+ id="displayNameInput"
+ class="statusMessageInput input-inline"
+ hidden="hidden"
+ />
+ <html:hr />
+ <label
+ id="statusMessageLabel"
+ crop="end"
+ value=""
+ onclick="statusSelector.statusMessageClick();"
+ />
+ <html:input
+ id="statusMessageInput"
+ class="statusMessageInput input-inline"
+ value=""
+ hidden="hidden"
+ />
+ </html:div>
+ </html:div>
+
+ <hbox flex="1" ondblclick="gAccountManager.new();">
+ <vbox flex="1" id="noAccountScreen" align="center" pack="center">
+ <hbox id="noAccountBox" align="start">
+ <vbox id="noAccountInnerBox" flex="1">
+ <label
+ id="noAccountTitle"
+ value="&accountManager.noAccount.title;"
+ />
+ <description id="noAccountDesc"
+ >&accountManager.noAccount.description;</description
+ >
+ </vbox>
+ </hbox>
+ </vbox>
+
+ <vbox id="accounts-notification-box" flex="1">
+ <!-- notificationbox will be added here lazily. -->
+ <richlistbox
+ id="accountlist"
+ flex="1"
+ context="accountsContextMenu"
+ onselect="gAccountManager.onAccountSelect();"
+ />
+ </vbox>
+ </hbox>
+
+ <hbox id="bottombuttons" align="center">
+ <button id="newaccount" command="cmd_new" />
+ <spacer flex="1" />
+ <button id="close" command="cmd_close" />
+ </hbox>
+ </html:body>
+</html>
diff --git a/comm/mail/components/im/content/imContextMenu.js b/comm/mail/components/im/content/imContextMenu.js
new file mode 100644
index 0000000000..0d9ecf0763
--- /dev/null
+++ b/comm/mail/components/im/content/imContextMenu.js
@@ -0,0 +1,276 @@
+/* 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 loaded in messenger.xhtml.
+/* globals gatherTextUnder, goUpdateGlobalEditMenuItems, makeURLAbsolute, Services */
+/* import-globals-from ../../../base/content/widgets/browserPopups.js */
+
+var gChatContextMenu = null;
+
+function imContextMenu(aXulMenu) {
+ this.target = null;
+ this.menu = null;
+ this.onLink = false;
+ this.onMailtoLink = false;
+ this.onSaveableLink = false;
+ this.link = false;
+ this.linkURL = "";
+ this.linkURI = null;
+ this.linkProtocol = null;
+ this.isTextSelected = false;
+ this.isContentSelected = false;
+ this.shouldDisplay = true;
+ this.ellipsis = "\u2026";
+ this.initedActions = false;
+
+ try {
+ this.ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+
+ // Initialize new menu.
+ this.initMenu(aXulMenu);
+}
+
+// Prototype for nsContextMenu "class."
+imContextMenu.prototype = {
+ cleanup() {
+ nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal
+ ?.getActor("ChatAction")
+ .reportHide();
+ let elt = document.getElementById(
+ "context-sep-messageactions"
+ ).nextElementSibling;
+ // remove the action menuitems added last time we opened the popup
+ while (elt && elt.localName != "menuseparator") {
+ let tmp = elt.nextElementSibling;
+ elt.remove();
+ elt = tmp;
+ }
+ },
+
+ /**
+ * Initialize context menu. Shows/hides relevant items. Message actions are
+ * handled separately in |initActions| if the actor gets them after this is
+ * called.
+ *
+ * @param {XULMenuPopupElement} aPopup - The popup to initialize on.
+ */
+ initMenu(aPopup) {
+ this.menu = aPopup;
+
+ // Get contextual info.
+ this.setTarget();
+
+ this.isTextSelected = this.isTextSelection();
+ this.isContentSelected = this.isContentSelection();
+
+ // Initialize (disable/remove) menu items.
+ // Open/Save/Send link depends on whether we're in a link.
+ var shouldShow = this.onSaveableLink;
+ this.showItem("context-openlink", shouldShow);
+ this.showItem("context-sep-open", shouldShow);
+ this.showItem("context-savelink", shouldShow);
+
+ // Copy depends on whether there is selected text.
+ // Enabling this context menu item is now done through the global
+ // command updating system
+ goUpdateGlobalEditMenuItems();
+
+ this.showItem("context-copy", this.isContentSelected);
+ this.showItem("context-selectall", !this.onLink || this.isContentSelected);
+ if (!this.initedActions) {
+ let actor =
+ nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal?.getActor(
+ "ChatAction"
+ );
+ if (actor?.actions) {
+ this.initActions(actor.actions);
+ } else {
+ this.showItem("context-sep-messageactions", false);
+ }
+ }
+
+ // Copy email link depends on whether we're on an email link.
+ this.showItem("context-copyemail", this.onMailtoLink);
+
+ // Copy link location depends on whether we're on a non-mailto link.
+ this.showItem("context-copylink", this.onLink && !this.onMailtoLink);
+ this.showItem(
+ "context-sep-copylink",
+ this.onLink && this.isContentSelected
+ );
+ },
+
+ /**
+ * Adds the given message actions to the context menu.
+ *
+ * @param {Array<string>} actions - Array containing the labels for the
+ * available actions.
+ */
+ initActions(actions) {
+ this.showItem("context-sep-messageactions", actions.length > 0);
+
+ // Display action menu items.
+ let sep = document.getElementById("context-sep-messageactions");
+ for (let [index, label] of actions.entries()) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", label);
+ menuitem.addEventListener("command", () => {
+ nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal
+ ?.getActor("ChatAction")
+ .sendAsyncMessage("ChatAction:Run", { index });
+ });
+ sep.parentNode.appendChild(menuitem);
+ }
+ this.initedActions = true;
+ },
+
+ // Set various context menu attributes based on the state of the world.
+ setTarget() {
+ // Initialize contextual info.
+ this.onLink = nsContextMenu.contentData.context.onLink;
+ this.linkURL = nsContextMenu.contentData.context.linkURL;
+ this.linkURI = this.getLinkURI();
+ this.linkProtocol = nsContextMenu.contentData.context.linkProtocol;
+ this.linkText = nsContextMenu.contentData.context.linkTextStr;
+ this.onMailtoLink = nsContextMenu.contentData.context.onMailtoLink;
+ this.onSaveableLink = nsContextMenu.contentData.context.onSaveableLink;
+ },
+
+ // Open linked-to URL in a new window.
+ openLink(aURI) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(aURI || this.linkURI, nsContextMenu.contentData.principal);
+ },
+
+ // Generate email address and put it on clipboard.
+ copyEmail() {
+ // Copy the comma-separated list of email addresses only.
+ // There are other ways of embedding email addresses in a mailto:
+ // link, but such complex parsing is beyond us.
+ var url = this.linkURL;
+ var qmark = url.indexOf("?");
+ var addresses;
+
+ // 7 == length of "mailto:"
+ addresses = qmark > 7 ? url.substring(7, qmark) : url.substr(7);
+
+ // Let's try to unescape it using a character set
+ // in case the address is not ASCII.
+ try {
+ var characterSet = this.target.ownerDocument.characterSet;
+ addresses = Services.textToSubURI.unEscapeURIForUI(
+ characterSet,
+ addresses
+ );
+ } catch (ex) {
+ // Do nothing.
+ }
+
+ var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(addresses);
+ },
+
+ // ---------
+ // Utilities
+
+ // Show/hide one item (specified via name or the item element itself).
+ showItem(aItemOrId, aShow) {
+ var item =
+ aItemOrId.constructor == String
+ ? document.getElementById(aItemOrId)
+ : aItemOrId;
+ if (item) {
+ item.hidden = !aShow;
+ }
+ },
+
+ // Temporary workaround for DOM api not yet implemented by XUL nodes.
+ cloneNode(aItem) {
+ // Create another element like the one we're cloning.
+ var node = document.createXULElement(aItem.tagName);
+
+ // Copy attributes from argument item to the new one.
+ var attrs = aItem.attributes;
+ for (var i = 0; i < attrs.length; i++) {
+ var attr = attrs.item(i);
+ node.setAttribute(attr.nodeName, attr.nodeValue);
+ }
+
+ // Voila!
+ return node;
+ },
+
+ getLinkURI() {
+ try {
+ return Services.io.newURI(this.linkURL);
+ } catch (ex) {
+ // e.g. empty URL string
+ }
+
+ return null;
+ },
+
+ // Get selected text. Only display the first 15 chars.
+ isTextSelection() {
+ // Get 16 characters, so that we can trim the selection if it's greater
+ // than 15 chars
+ var selectedText = getBrowserSelection(16);
+
+ if (!selectedText) {
+ return false;
+ }
+
+ if (selectedText.length > 15) {
+ selectedText = selectedText.substr(0, 15) + this.ellipsis;
+ }
+
+ return true;
+ },
+
+ // Returns true if anything is selected.
+ isContentSelection() {
+ return !document.commandDispatcher.focusedWindow.getSelection().isCollapsed;
+ },
+};
+
+/**
+ * Gets the selected text in the active browser. Leading and trailing
+ * whitespace is removed, and consecutive whitespace is replaced by a single
+ * space. A maximum of 150 characters will be returned, regardless of the value
+ * of aCharLen.
+ *
+ * @param aCharLen
+ * The maximum number of characters to return.
+ */
+function getBrowserSelection(aCharLen) {
+ // selections of more than 150 characters aren't useful
+ const kMaxSelectionLen = 150;
+ const charLen = Math.min(aCharLen || kMaxSelectionLen, kMaxSelectionLen);
+
+ var focusedWindow = document.commandDispatcher.focusedWindow;
+ var selection = focusedWindow.getSelection().toString();
+
+ if (selection) {
+ if (selection.length > charLen) {
+ // only use the first charLen important chars. see bug 221361
+ var pattern = new RegExp("^(?:\\s*.){0," + charLen + "}");
+ pattern.test(selection);
+ selection = RegExp.lastMatch;
+ }
+
+ selection = selection.trim().replace(/\s+/g, " ");
+
+ if (selection.length > charLen) {
+ selection = selection.substr(0, charLen);
+ }
+ }
+ return selection;
+}
diff --git a/comm/mail/components/im/content/imStatusSelector.js b/comm/mail/components/im/content/imStatusSelector.js
new file mode 100644
index 0000000000..69bbc2776a
--- /dev/null
+++ b/comm/mail/components/im/content/imStatusSelector.js
@@ -0,0 +1,383 @@
+/* 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 { Status } = ChromeUtils.importESModule(
+ "resource:///modules/imStatusUtils.sys.mjs"
+);
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+var statusSelector = {
+ observe(aSubject, aTopic, aMsg) {
+ if (aTopic == "status-changed") {
+ this.displayCurrentStatus();
+ } else if (aTopic == "user-icon-changed") {
+ this.displayUserIcon();
+ } else if (aTopic == "user-display-name-changed") {
+ this.displayUserDisplayName();
+ }
+ },
+
+ displayUserIcon() {
+ let icon = IMServices.core.globalUserStatus.getUserIcon();
+ ChatIcons.setUserIconSrc(
+ document.getElementById("userIcon"),
+ icon?.spec,
+ true
+ );
+ },
+
+ displayUserDisplayName() {
+ let displayName = IMServices.core.globalUserStatus.displayName;
+ let elt = document.getElementById("displayName");
+ if (displayName) {
+ elt.removeAttribute("usingDefault");
+ } else {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ displayName = bundle.GetStringFromName("displayNameEmptyText");
+ elt.setAttribute("usingDefault", displayName);
+ }
+ elt.setAttribute("value", displayName);
+ },
+
+ displayStatusType(aStatusType) {
+ document
+ .getElementById("statusMessageLabel")
+ .setAttribute("statusType", aStatusType);
+ let statusString = Status.toLabel(aStatusType);
+ let statusTypeIcon = document.getElementById("statusTypeIcon");
+ statusTypeIcon.setAttribute("status", aStatusType);
+ statusTypeIcon.setAttribute("tooltiptext", statusString);
+ return statusString;
+ },
+
+ displayCurrentStatus() {
+ let us = IMServices.core.globalUserStatus;
+ let status = Status.toAttribute(us.statusType);
+ let message = status == "offline" ? "" : us.statusText;
+ let statusMessage = document.getElementById("statusMessageLabel");
+ if (!statusMessage) {
+ // Chat toolbar not in the DOM yet
+ return;
+ }
+ if (message) {
+ statusMessage.removeAttribute("usingDefault");
+ } else {
+ let statusString = this.displayStatusType(status);
+ statusMessage.setAttribute("usingDefault", statusString);
+ message = statusString;
+ }
+ statusMessage.setAttribute("value", message);
+ statusMessage.setAttribute("tooltiptext", message);
+ },
+
+ editStatus(aEvent) {
+ let status = aEvent.target.getAttribute("status");
+ if (status == "offline") {
+ IMServices.core.globalUserStatus.setStatus(
+ Ci.imIStatusInfo.STATUS_OFFLINE,
+ ""
+ );
+ } else if (status) {
+ this.startEditStatus(status);
+ }
+ },
+
+ startEditStatus(aStatusType) {
+ let currentStatusType = document
+ .getElementById("statusTypeIcon")
+ .getAttribute("status");
+ if (aStatusType != currentStatusType) {
+ this._statusTypeBeforeEditing = currentStatusType;
+ this._statusTypeEditing = aStatusType;
+ this.displayStatusType(aStatusType);
+ }
+ this.statusMessageClick();
+ },
+
+ statusMessageClick() {
+ let statusMessage = document.getElementById("statusMessageLabel");
+ let statusMessageInput = document.getElementById("statusMessageInput");
+ statusMessage.setAttribute("hidden", "true");
+ statusMessageInput.removeAttribute("hidden");
+ let statusType = document
+ .getElementById("statusTypeIcon")
+ .getAttribute("status");
+ if (statusType == "offline" || statusMessage.disabled) {
+ return;
+ }
+
+ if (!statusMessageInput.hasAttribute("editing")) {
+ statusMessageInput.setAttribute("editing", "true");
+ statusMessageInput.addEventListener("blur", event => {
+ this.finishEditStatusMessage(true);
+ });
+ if (statusMessage.hasAttribute("usingDefault")) {
+ if (
+ "_statusTypeBeforeEditing" in this &&
+ this._statusTypeBeforeEditing == "offline"
+ ) {
+ statusMessageInput.setAttribute(
+ "value",
+ IMServices.core.globalUserStatus.statusText
+ );
+ } else {
+ statusMessageInput.removeAttribute("value");
+ }
+ } else {
+ statusMessageInput.setAttribute(
+ "value",
+ statusMessage.getAttribute("value")
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mail.spellcheck.inline")) {
+ statusMessageInput.setAttribute("spellcheck", "true");
+ } else {
+ statusMessageInput.removeAttribute("spellcheck");
+ }
+
+ // force binding attachment by forcing layout
+ statusMessageInput.getBoundingClientRect();
+ statusMessageInput.select();
+ }
+
+ this.statusMessageRefreshTimer();
+ },
+
+ statusMessageRefreshTimer() {
+ const timeBeforeAutoValidate = 20 * 1000;
+ if ("_stopEditStatusTimeout" in this) {
+ clearTimeout(this._stopEditStatusTimeout);
+ }
+ this._stopEditStatusTimeout = setTimeout(
+ this.finishEditStatusMessage,
+ timeBeforeAutoValidate,
+ true
+ );
+ },
+
+ statusMessageKeyPress(aEvent) {
+ if (!this.hasAttribute("editing")) {
+ if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
+ let button = document.getElementById("statusTypeIcon");
+ document.getElementById("setStatusTypeMenupopup").openPopup(button);
+ }
+ return;
+ }
+
+ switch (aEvent.keyCode) {
+ case aEvent.DOM_VK_RETURN:
+ statusSelector.finishEditStatusMessage(true);
+ break;
+
+ case aEvent.DOM_VK_ESCAPE:
+ statusSelector.finishEditStatusMessage(false);
+ break;
+
+ default:
+ statusSelector.statusMessageRefreshTimer();
+ }
+ },
+
+ finishEditStatusMessage(aSave) {
+ clearTimeout(this._stopEditStatusTimeout);
+ delete this._stopEditStatusTimeout;
+ let statusMessage = document.getElementById("statusMessageLabel");
+ let statusMessageInput = document.getElementById("statusMessageInput");
+ statusMessage.removeAttribute("hidden");
+ statusMessageInput.toggleAttribute("hidden", "true");
+ if (aSave) {
+ let newStatus = Ci.imIStatusInfo.STATUS_UNKNOWN;
+ if ("_statusTypeEditing" in this) {
+ let statusType = this._statusTypeEditing;
+ if (statusType == "available") {
+ newStatus = Ci.imIStatusInfo.STATUS_AVAILABLE;
+ } else if (statusType == "unavailable") {
+ newStatus = Ci.imIStatusInfo.STATUS_UNAVAILABLE;
+ } else if (statusType == "offline") {
+ newStatus = Ci.imIStatusInfo.STATUS_OFFLINE;
+ }
+ delete this._statusTypeBeforeEditing;
+ delete this._statusTypeEditing;
+ }
+ // apply the new status only if it is different from the current one
+ if (
+ newStatus != Ci.imIStatusInfo.STATUS_UNKNOWN ||
+ statusMessageInput.value != statusMessageInput.getAttribute("value")
+ ) {
+ IMServices.core.globalUserStatus.setStatus(
+ newStatus,
+ statusMessageInput.value
+ );
+ }
+ } else if ("_statusTypeBeforeEditing" in this) {
+ this.displayStatusType(this._statusTypeBeforeEditing);
+ delete this._statusTypeBeforeEditing;
+ delete this._statusTypeEditing;
+ }
+
+ if (statusMessage.hasAttribute("usingDefault")) {
+ statusMessage.setAttribute(
+ "value",
+ statusMessage.getAttribute("usingDefault")
+ );
+ }
+
+ statusMessageInput.removeAttribute("editing");
+ statusMessageInput.removeEventListener("blur", event => {
+ this.finishEditStatusMessage(true);
+ });
+
+ // We need to put the focus back on the label after the textbox
+ // binding has been detached, otherwise the focus gets lost (it's
+ // on none of the elements in the document), but before that we
+ // need to flush the layout.
+ statusMessageInput.getBoundingClientRect();
+ statusMessageInput.focus();
+ },
+
+ userIconClick() {
+ const nsIFilePicker = Ci.nsIFilePicker;
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ fp.init(
+ window,
+ bundle.GetStringFromName("userIconFilePickerTitle"),
+ nsIFilePicker.modeOpen
+ );
+ fp.appendFilters(nsIFilePicker.filterImages);
+ fp.open(rv => {
+ if (rv != nsIFilePicker.returnOK || !fp.file) {
+ return;
+ }
+ IMServices.core.globalUserStatus.setUserIcon(fp.file);
+ });
+ },
+
+ displayNameClick() {
+ let displayName = document.getElementById("displayName");
+ let displayNameInput = document.getElementById("displayNameInput");
+ displayName.setAttribute("hidden", "true");
+ displayNameInput.removeAttribute("hidden");
+ if (!displayNameInput.hasAttribute("editing")) {
+ displayNameInput.setAttribute("editing", "true");
+ if (displayName.hasAttribute("usingDefault")) {
+ displayNameInput.removeAttribute("value");
+ } else {
+ displayNameInput.setAttribute(
+ "value",
+ displayName.getAttribute("value")
+ );
+ }
+ displayNameInput.addEventListener("keypress", this.displayNameKeyPress);
+ displayNameInput.addEventListener("blur", event => {
+ this.finishEditDisplayName(true);
+ });
+ // force binding attachment by forcing layout
+ displayNameInput.getBoundingClientRect();
+ displayNameInput.select();
+ }
+
+ this.displayNameRefreshTimer();
+ },
+
+ _stopEditDisplayNameTimeout: 0,
+ displayNameRefreshTimer() {
+ const timeBeforeAutoValidate = 20 * 1000;
+ clearTimeout(this._stopEditDisplayNameTimeout);
+ this._stopEditDisplayNameTimeout = setTimeout(
+ this.finishEditDisplayName,
+ timeBeforeAutoValidate,
+ true
+ );
+ },
+
+ displayNameKeyPress(aEvent) {
+ switch (aEvent.keyCode) {
+ case aEvent.DOM_VK_RETURN:
+ statusSelector.finishEditDisplayName(true);
+ break;
+
+ case aEvent.DOM_VK_ESCAPE:
+ statusSelector.finishEditDisplayName(false);
+ break;
+
+ default:
+ statusSelector.displayNameRefreshTimer();
+ }
+ },
+
+ finishEditDisplayName(aSave) {
+ clearTimeout(this._stopEditDisplayNameTimeout);
+ let displayName = document.getElementById("displayName");
+ let displayNameInput = document.getElementById("displayNameInput");
+ displayName.removeAttribute("hidden");
+ displayNameInput.toggleAttribute("hidden", "true");
+ // Apply the new display name only if it is different from the current one.
+ if (
+ aSave &&
+ displayNameInput.value != displayNameInput.getAttribute("value")
+ ) {
+ IMServices.core.globalUserStatus.displayName = displayNameInput.value;
+ } else if (displayName.hasAttribute("usingDefault")) {
+ displayName.setAttribute(
+ "value",
+ displayName.getAttribute("usingDefault")
+ );
+ }
+
+ displayNameInput.removeAttribute("editing");
+ displayNameInput.removeEventListener("keypress", this.displayNameKeyPress);
+ displayNameInput.removeEventListener("blur", event => {
+ this.finishEditDisplayName(true);
+ });
+ },
+
+ init() {
+ let events = ["status-changed"];
+ statusSelector.displayCurrentStatus();
+
+ if (document.getElementById("displayName")) {
+ events.push("user-display-name-changed");
+ statusSelector.displayUserDisplayName();
+ }
+
+ if (document.getElementById("userIcon")) {
+ events.push("user-icon-changed");
+ statusSelector.displayUserIcon();
+ }
+
+ let statusMessage = document.getElementById("statusMessageLabel");
+ let statusMessageInput = document.getElementById("statusMessageInput");
+ if (statusMessage && statusMessageInput) {
+ statusMessage.addEventListener("keypress", this.statusMessageKeyPress);
+ statusMessageInput.addEventListener(
+ "keypress",
+ this.statusMessageKeyPress
+ );
+ }
+
+ for (let event of events) {
+ Services.obs.addObserver(statusSelector, event);
+ }
+ statusSelector._events = events;
+
+ window.addEventListener("unload", statusSelector.unload);
+ },
+
+ unload() {
+ for (let event of statusSelector._events) {
+ Services.obs.removeObserver(statusSelector, event);
+ }
+ },
+};
diff --git a/comm/mail/components/im/content/joinchat.js b/comm/mail/components/im/content/joinchat.js
new file mode 100644
index 0000000000..ae4029eb5a
--- /dev/null
+++ b/comm/mail/components/im/content/joinchat.js
@@ -0,0 +1,195 @@
+/* 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 { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+var autoJoinPref = "autoJoin";
+
+var joinChat = {
+ onload() {
+ var accountList = document.getElementById("accountlist");
+ for (let acc of IMServices.accounts.getAccounts()) {
+ if (!acc.connected || !acc.canJoinChat) {
+ continue;
+ }
+ var proto = acc.protocol;
+ var item = accountList.appendItem(acc.name, acc.id, proto.name);
+ item.setAttribute("image", ChatIcons.getProtocolIconURI(proto));
+ item.setAttribute("class", "menuitem-iconic");
+ item.account = acc;
+ }
+ if (!accountList.itemCount) {
+ document
+ .getElementById("joinChatDialog")
+ .querySelector("dialog")
+ .cancelDialog();
+ throw new Error("No connected MUC enabled account!");
+ }
+ accountList.selectedIndex = 0;
+ },
+
+ onAccountSelect() {
+ let joinChatGrid = document.getElementById("joinChatGrid");
+ while (joinChatGrid.children.length > 3) {
+ // leave the first 3 cols
+ joinChatGrid.lastChild.remove();
+ }
+
+ let acc = document.getElementById("accountlist").selectedItem.account;
+ let defaultValues = acc.getChatRoomDefaultFieldValues();
+ joinChat._values = defaultValues;
+ joinChat._fields = [];
+ joinChat._account = acc;
+
+ let protoId = acc.protocol.id;
+ document.getElementById("autojoin").hidden = !(
+ protoId == "prpl-irc" ||
+ protoId == "prpl-jabber" ||
+ protoId == "prpl-gtalk"
+ );
+
+ for (let field of acc.getChatRoomFields()) {
+ let div1 = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ let label = document.createXULElement("label");
+ let text = field.label;
+ let match = /_(.)/.exec(text);
+ if (match) {
+ label.setAttribute("accesskey", match[1]);
+ text = text.replace(/_/, "");
+ }
+ label.setAttribute("value", text);
+ label.setAttribute("control", "field-" + field.identifier);
+ label.setAttribute("id", "field-" + field.identifier + "-label");
+ div1.appendChild(label);
+ joinChatGrid.appendChild(div1);
+
+ let div2 = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ let input = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "input"
+ );
+ input.classList.add("input-inline");
+ input.setAttribute("id", "field-" + field.identifier);
+ input.setAttribute(
+ "aria-labelledby",
+ "field-" + field.identifier + "-label"
+ );
+ let val = defaultValues.getValue(field.identifier);
+ if (val) {
+ input.setAttribute("value", val);
+ }
+ if (field.type == Ci.prplIChatRoomField.TYPE_PASSWORD) {
+ input.setAttribute("type", "password");
+ } else if (field.type == Ci.prplIChatRoomField.TYPE_INT) {
+ input.setAttribute("type", "number");
+ input.setAttribute("min", field.min);
+ input.setAttribute("max", field.max);
+ } else {
+ input.setAttribute("type", "text");
+ }
+ div2.appendChild(input);
+ joinChatGrid.appendChild(div2);
+
+ let div3 = document.querySelector(".optional-col").cloneNode(true);
+ div3.classList.toggle("required", field.required);
+ joinChatGrid.appendChild(div3);
+
+ joinChat._fields.push({ field, input });
+ }
+
+ window.sizeToContent();
+ },
+
+ join() {
+ let values = joinChat._values;
+ for (let field of joinChat._fields) {
+ let val = field.input.value.trim();
+ if (!val && field.field.required) {
+ field.input.focus();
+ // FIXME: why isn't the return false enough?
+ throw new Error("Some required fields are empty!");
+ // return false;
+ }
+ if (val) {
+ values.setValue(field.field.identifier, val);
+ }
+ }
+ let account = joinChat._account;
+ account.joinChat(values);
+
+ let protoId = account.protocol.id;
+ if (
+ protoId != "prpl-irc" &&
+ protoId != "prpl-jabber" &&
+ protoId != "prpl-gtalk"
+ ) {
+ return;
+ }
+
+ let name;
+ if (protoId == "prpl-irc") {
+ name = values.getValue("channel");
+ } else {
+ name = values.getValue("room") + "@" + values.getValue("server");
+ }
+
+ let conv = IMServices.conversations.getConversationByNameAndAccount(
+ name,
+ account,
+ true
+ );
+ if (conv) {
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mailWindow) {
+ mailWindow.focus();
+ let tabmail = mailWindow.document.getElementById("tabmail");
+ tabmail.openTab("chat", { convType: "focus", conv });
+ }
+ }
+
+ if (document.getElementById("autojoin").checked) {
+ // "nick" for JS-XMPP, "handle" for libpurple prpls.
+ let nick = values.getValue("nick") || values.getValue("handle");
+ if (nick) {
+ name += "/" + nick;
+ }
+
+ let prefBranch = Services.prefs.getBranch(
+ "messenger.account." + account.id + "."
+ );
+ let autojoin = [];
+ if (prefBranch.prefHasUserValue(autoJoinPref)) {
+ let prefValue = prefBranch.getStringPref(autoJoinPref);
+ if (prefValue) {
+ autojoin = prefValue.split(",");
+ }
+ }
+
+ if (!autojoin.includes(name)) {
+ autojoin.push(name);
+ prefBranch.setStringPref(autoJoinPref, autojoin.join(","));
+ }
+ }
+ },
+};
+
+document.addEventListener("dialogaccept", joinChat.join);
+
+window.addEventListener("DOMContentLoaded", event => {
+ joinChat.onload();
+});
+window.addEventListener("load", event => {
+ window.sizeToContent();
+});
diff --git a/comm/mail/components/im/content/joinchat.xhtml b/comm/mail/components/im/content/joinchat.xhtml
new file mode 100644
index 0000000000..8bd5753e91
--- /dev/null
+++ b/comm/mail/components/im/content/joinchat.xhtml
@@ -0,0 +1,58 @@
+<?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://messenger/skin/menulist.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/joinchat.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+
+<!DOCTYPE html SYSTEM "chrome://messenger/locale/joinChat.dtd">
+
+<html
+ id="joinChatDialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ scrolling="false"
+>
+ <head>
+ <title>&joinChatWindow.title;</title>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/joinchat.js"
+ ></script>
+ </head>
+ <body>
+ <xul:dialog buttons="accept,cancel">
+ <div id="joinChatGrid">
+ <div>
+ <xul:label value="&account.label;" control="accountlist" />
+ </div>
+ <div>
+ <xul:menulist
+ id="accountlist"
+ onselect="joinChat.onAccountSelect();"
+ />
+ </div>
+ <div class="optional-col required">&optional.label;</div>
+ </div>
+ <xul:hbox>
+ <xul:checkbox
+ id="autojoin"
+ label="&autojoin.label;"
+ accesskey="&autojoin.accesskey;"
+ />
+ </xul:hbox>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/components/im/content/toolbarbutton-badge-button.js b/comm/mail/components/im/content/toolbarbutton-badge-button.js
new file mode 100644
index 0000000000..def96faf27
--- /dev/null
+++ b/comm/mail/components/im/content/toolbarbutton-badge-button.js
@@ -0,0 +1,70 @@
+/* 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";
+
+/* globals MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozBadgebutton widget is used to display a chat toolbar button in
+ * the main Toolbox in the messenger window. It displays icon and label
+ * for the button. It also shows a badge on top of the chat icon with a number.
+ * That number is the count of unread messages in the chat.
+ *
+ * @augments MozToolbarbutton
+ */
+ class MozBadgebutton extends customElements.get("toolbarbutton") {
+ static get inheritedAttributes() {
+ return {
+ ".toolbarbutton-icon": "src=image",
+ ".toolbarbutton-text": "value=label,accesskey,crop",
+ };
+ }
+
+ static get markup() {
+ return `
+ <stack>
+ <html:img class="toolbarbutton-icon" alt="" />
+ <html:span class="badgeButton-badge" hidden="hidden"></html:span>
+ </stack>
+ <label class="toolbarbutton-text" crop="end" flex="1"></label>
+ `;
+ }
+
+ /**
+ * toolbarbutton overwrites the fragment getter from MozXULElement.
+ */
+ static get fragment() {
+ return Reflect.get(MozXULElement, "fragment", this);
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+ this.setAttribute("is", "toolbarbutton-badge-button");
+ this.appendChild(this.constructor.fragment);
+
+ this._badgeCount = 0;
+ this.initializeAttributeInheritance();
+ }
+
+ set badgeCount(count) {
+ this._badgeCount = count;
+ let badge = this.querySelector(".badgeButton-badge");
+ badge.textContent = count;
+ badge.hidden = count == 0;
+ }
+
+ get badgeCount() {
+ return this._badgeCount;
+ }
+ }
+
+ customElements.define("toolbarbutton-badge-button", MozBadgebutton, {
+ extends: "toolbarbutton",
+ });
+}
diff --git a/comm/mail/components/im/content/verify.js b/comm/mail/components/im/content/verify.js
new file mode 100644
index 0000000000..fbe39d6a50
--- /dev/null
+++ b/comm/mail/components/im/content/verify.js
@@ -0,0 +1,53 @@
+/* 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 verifySession = {
+ onload() {
+ this.sessionVerification =
+ window.arguments[0].wrappedJSObject || window.arguments[0];
+ if (
+ this.sessionVerification.challengeType !==
+ Ci.imISessionVerification.CHALLENGE_TEXT
+ ) {
+ throw new Error("Unsupported challenge type");
+ }
+ document.l10n.setAttributes(
+ document.querySelector("title"),
+ "verify-window-subject-title",
+ {
+ subject: this.sessionVerification.subject,
+ }
+ );
+ document.getElementById("challenge").textContent =
+ this.sessionVerification.challenge;
+ if (this.sessionVerification.challengeDescription) {
+ let description = document.getElementById("challengeDescription");
+ description.hidden = false;
+ description.textContent = this.sessionVerification.challengeDescription;
+ }
+ document.addEventListener("dialogaccept", () => {
+ this.sessionVerification.submitResponse(true);
+ });
+ document.addEventListener("dialogextra2", () => {
+ this.sessionVerification.submitResponse(false);
+ document
+ .getElementById("verifySessionDialog")
+ .querySelector("dialog")
+ .acceptDialog();
+ });
+ document.addEventListener("dialogcancel", () => {
+ this.sessionVerification.cancel();
+ });
+ this.sessionVerification.completePromise.catch(() => {
+ document
+ .getElementById("verifySessionDialog")
+ .querySelector("dialog")
+ .cancelDialog();
+ });
+ },
+};
+
+window.addEventListener("load", event => {
+ verifySession.onload();
+});
diff --git a/comm/mail/components/im/content/verify.xhtml b/comm/mail/components/im/content/verify.xhtml
new file mode 100644
index 0000000000..930ae81e5d
--- /dev/null
+++ b/comm/mail/components/im/content/verify.xhtml
@@ -0,0 +1,46 @@
+<?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/. -->
+<html
+ id="verifySessionDialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="verify-window-title"></title>
+ <link rel="localization" href="messenger/chat-verifySession.ftl" />
+ <link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/verifychat.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/shared/grid-layout.css"
+ />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/verify.js"
+ ></script>
+ </head>
+ <body>
+ <xul:dialog
+ buttons="accept,cancel,extra2"
+ data-l10n-id="verify-dialog"
+ data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept, buttonlabelextra2, buttonaccesskeyextra2"
+ >
+ <p data-l10n-id="challenge-label"></p>
+ <p id="challengePresentation">
+ <span id="challenge"></span>
+ <!-- Describes the text content of #challenge in an alternative way.
+ - E.g. if #challenge is a sequence of emojis, then
+ - #challengeDescription would be a sequence of emoji names. -->
+ <span id="challengeDescription" role="note" hidden="hidden"></span>
+ </p>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/components/im/jar.mn b/comm/mail/components/im/jar.mn
new file mode 100644
index 0000000000..98b7735afc
--- /dev/null
+++ b/comm/mail/components/im/jar.mn
@@ -0,0 +1,199 @@
+# 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/chat/chat-messenger.js (content/chat-messenger.js)
+ content/messenger/am-im.js (content/am-im.js)
+ content/messenger/am-im.xhtml (content/am-im.xhtml)
+ content/messenger/chat/addbuddy.js (content/addbuddy.js)
+ content/messenger/chat/addbuddy.xhtml (content/addbuddy.xhtml)
+ content/messenger/chat/joinchat.js (content/joinchat.js)
+ content/messenger/chat/joinchat.xhtml (content/joinchat.xhtml)
+ content/messenger/chat/imAccounts.js (content/imAccounts.js)
+ content/messenger/chat/imAccounts.xhtml (content/imAccounts.xhtml)
+ content/messenger/chat/imAccountWizard.xhtml (content/imAccountWizard.xhtml)
+ content/messenger/chat/imAccountWizard.js (content/imAccountWizard.js)
+ content/messenger/chat/imContextMenu.js (content/imContextMenu.js)
+ content/messenger/chat/chat-conversation.js (content/chat-conversation.js)
+ content/messenger/chat/imStatusSelector.js (content/imStatusSelector.js)
+ content/messenger/chat/chat-contact.js (content/chat-contact.js)
+ content/messenger/chat/chat-group.js (content/chat-group.js)
+ content/messenger/chat/chat-imconv.js (content/chat-imconv.js)
+ content/messenger/chat/chat-conversation-info.js (content/chat-conversation-info.js)
+ content/messenger/chat/toolbarbutton-badge-button.js (content/toolbarbutton-badge-button.js)
+ content/messenger/chat/verify.js (content/verify.js)
+ content/messenger/chat/verify.xhtml (content/verify.xhtml)
+% skin messenger-messagestyles classic/1.0 %skin/classic/messenger/messages/
+ skin/classic/messenger/messages/mail/inline.js (messages/mail/inline.js)
+ skin/classic/messenger/messages/mail/Incoming/buddy_icon.svg (messages/mail/Incoming/buddy_icon.svg)
+ skin/classic/messenger/messages/mail/Outgoing/buddy_icon.svg (messages/mail/Incoming/buddy_icon.svg)
+ skin/classic/messenger/messages/mail/Incoming/Content.html (messages/mail/Incoming/Content.html)
+ skin/classic/messenger/messages/mail/Incoming/Context.html (messages/mail/Incoming/Context.html)
+ skin/classic/messenger/messages/mail/Incoming/NextContent.html (messages/mail/Incoming/NextContent.html)
+ skin/classic/messenger/messages/mail/Incoming/NextContext.html (messages/mail/Incoming/NextContext.html)
+ skin/classic/messenger/messages/mail/Outgoing/Content.html (messages/mail/Outgoing/Content.html)
+ skin/classic/messenger/messages/mail/Outgoing/Context.html (messages/mail/Outgoing/Context.html)
+ skin/classic/messenger/messages/mail/Outgoing/NextContent.html (messages/mail/Outgoing/NextContent.html)
+ skin/classic/messenger/messages/mail/Outgoing/NextContext.html (messages/mail/Outgoing/NextContext.html)
+ skin/classic/messenger/messages/mail/Footer.html (messages/mail/Footer.html)
+ skin/classic/messenger/messages/mail/Header.html (messages/mail/Header.html)
+ skin/classic/messenger/messages/mail/Info.plist (messages/mail/Info.plist)
+ skin/classic/messenger/messages/mail/main.css (messages/mail/main.css)
+ skin/classic/messenger/messages/mail/NextStatus.html (messages/mail/NextStatus.html)
+ skin/classic/messenger/messages/mail/Status.html (messages/mail/Status.html)
+ skin/classic/messenger/messages/mail/Variants/Dark.css (messages/mail/Variants/Dark.css)
+ skin/classic/messenger/messages/mail/Variants/Light.css (messages/mail/Variants/Light.css)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_0.png (messages/bubbles/Bitmaps/indicator_0.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_0_alt.png (messages/bubbles/Bitmaps/indicator_0_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_10.png (messages/bubbles/Bitmaps/indicator_10.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_100.png (messages/bubbles/Bitmaps/indicator_100.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_100_alt.png (messages/bubbles/Bitmaps/indicator_100_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_10_alt.png (messages/bubbles/Bitmaps/indicator_10_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_110.png (messages/bubbles/Bitmaps/indicator_110.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_110_alt.png (messages/bubbles/Bitmaps/indicator_110_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_120.png (messages/bubbles/Bitmaps/indicator_120.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_120_alt.png (messages/bubbles/Bitmaps/indicator_120_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_130.png (messages/bubbles/Bitmaps/indicator_130.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_130_alt.png (messages/bubbles/Bitmaps/indicator_130_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_140.png (messages/bubbles/Bitmaps/indicator_140.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_140_alt.png (messages/bubbles/Bitmaps/indicator_140_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_150.png (messages/bubbles/Bitmaps/indicator_150.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_150_alt.png (messages/bubbles/Bitmaps/indicator_150_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_160.png (messages/bubbles/Bitmaps/indicator_160.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_160_alt.png (messages/bubbles/Bitmaps/indicator_160_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_170.png (messages/bubbles/Bitmaps/indicator_170.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_170_alt.png (messages/bubbles/Bitmaps/indicator_170_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_180.png (messages/bubbles/Bitmaps/indicator_180.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_180_alt.png (messages/bubbles/Bitmaps/indicator_180_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_190.png (messages/bubbles/Bitmaps/indicator_190.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_190_alt.png (messages/bubbles/Bitmaps/indicator_190_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_20.png (messages/bubbles/Bitmaps/indicator_20.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_200.png (messages/bubbles/Bitmaps/indicator_200.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_200_alt.png (messages/bubbles/Bitmaps/indicator_200_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_20_alt.png (messages/bubbles/Bitmaps/indicator_20_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_210.png (messages/bubbles/Bitmaps/indicator_210.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_210_alt.png (messages/bubbles/Bitmaps/indicator_210_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_220.png (messages/bubbles/Bitmaps/indicator_220.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_220_alt.png (messages/bubbles/Bitmaps/indicator_220_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_230.png (messages/bubbles/Bitmaps/indicator_230.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_230_alt.png (messages/bubbles/Bitmaps/indicator_230_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_240.png (messages/bubbles/Bitmaps/indicator_240.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_240_alt.png (messages/bubbles/Bitmaps/indicator_240_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_250.png (messages/bubbles/Bitmaps/indicator_250.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_250_alt.png (messages/bubbles/Bitmaps/indicator_250_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_260.png (messages/bubbles/Bitmaps/indicator_260.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_260_alt.png (messages/bubbles/Bitmaps/indicator_260_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_270.png (messages/bubbles/Bitmaps/indicator_270.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_270_alt.png (messages/bubbles/Bitmaps/indicator_270_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_280.png (messages/bubbles/Bitmaps/indicator_280.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_280_alt.png (messages/bubbles/Bitmaps/indicator_280_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_290.png (messages/bubbles/Bitmaps/indicator_290.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_290_alt.png (messages/bubbles/Bitmaps/indicator_290_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_30.png (messages/bubbles/Bitmaps/indicator_30.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_300.png (messages/bubbles/Bitmaps/indicator_300.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_300_alt.png (messages/bubbles/Bitmaps/indicator_300_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_30_alt.png (messages/bubbles/Bitmaps/indicator_30_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_310.png (messages/bubbles/Bitmaps/indicator_310.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_310_alt.png (messages/bubbles/Bitmaps/indicator_310_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_320.png (messages/bubbles/Bitmaps/indicator_320.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_320_alt.png (messages/bubbles/Bitmaps/indicator_320_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_330.png (messages/bubbles/Bitmaps/indicator_330.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_330_alt.png (messages/bubbles/Bitmaps/indicator_330_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_340.png (messages/bubbles/Bitmaps/indicator_340.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_340_alt.png (messages/bubbles/Bitmaps/indicator_340_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_350.png (messages/bubbles/Bitmaps/indicator_350.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_350_alt.png (messages/bubbles/Bitmaps/indicator_350_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_40.png (messages/bubbles/Bitmaps/indicator_40.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_40_alt.png (messages/bubbles/Bitmaps/indicator_40_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_50.png (messages/bubbles/Bitmaps/indicator_50.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_50_alt.png (messages/bubbles/Bitmaps/indicator_50_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_60.png (messages/bubbles/Bitmaps/indicator_60.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_60_alt.png (messages/bubbles/Bitmaps/indicator_60_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_70.png (messages/bubbles/Bitmaps/indicator_70.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_70_alt.png (messages/bubbles/Bitmaps/indicator_70_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_80.png (messages/bubbles/Bitmaps/indicator_80.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_80_alt.png (messages/bubbles/Bitmaps/indicator_80_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_90.png (messages/bubbles/Bitmaps/indicator_90.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_90_alt.png (messages/bubbles/Bitmaps/indicator_90_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_grey.png (messages/bubbles/Bitmaps/indicator_grey.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/minus-hover.png (messages/bubbles/Bitmaps/minus-hover.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/minus.png (messages/bubbles/Bitmaps/minus.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/plus-hover.png (messages/bubbles/Bitmaps/plus-hover.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/plus.png (messages/bubbles/Bitmaps/plus.png)
+ skin/classic/messenger/messages/bubbles/Footer.html (messages/bubbles/Footer.html)
+ skin/classic/messenger/messages/bubbles/inline.js (messages/bubbles/inline.js)
+ skin/classic/messenger/messages/bubbles/Incoming/Content.html (messages/bubbles/Incoming/Content.html)
+ skin/classic/messenger/messages/bubbles/Incoming/Context.html (messages/bubbles/Incoming/Context.html)
+ skin/classic/messenger/messages/bubbles/Incoming/NextContent.html (messages/bubbles/Incoming/NextContent.html)
+ skin/classic/messenger/messages/bubbles/Info.plist (messages/bubbles/Info.plist)
+ skin/classic/messenger/messages/bubbles/main.css (messages/bubbles/main.css)
+ skin/classic/messenger/messages/bubbles/NextStatus.html (messages/bubbles/NextStatus.html)
+ skin/classic/messenger/messages/bubbles/Status.html (messages/bubbles/Status.html)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Green_Alternating.css (messages/bubbles/Variants/Blue_-_Green_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Green.css (messages/bubbles/Variants/Blue_-_Green.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Pink_Alternating.css (messages/bubbles/Variants/Blue_-_Pink_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Pink.css (messages/bubbles/Variants/Blue_-_Pink.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Red_Alternating.css (messages/bubbles/Variants/Blue_-_Red_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Red.css (messages/bubbles/Variants/Blue_-_Red.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Blue_Alternating.css (messages/bubbles/Variants/Green_-_Blue_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Blue.css (messages/bubbles/Variants/Green_-_Blue.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Purple_Alternating.css (messages/bubbles/Variants/Green_-_Purple_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Purple.css (messages/bubbles/Variants/Green_-_Purple.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Red_Alternating.css (messages/bubbles/Variants/Green_-_Red_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Red.css (messages/bubbles/Variants/Green_-_Red.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Blue_Alternating.css (messages/bubbles/Variants/Grey_-_Blue_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Blue.css (messages/bubbles/Variants/Grey_-_Blue.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Pink_Alternating.css (messages/bubbles/Variants/Grey_-_Pink_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Pink.css (messages/bubbles/Variants/Grey_-_Pink.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Purple_Alternating.css (messages/bubbles/Variants/Grey_-_Purple_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Purple.css (messages/bubbles/Variants/Grey_-_Purple.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Red_Alternating.css (messages/bubbles/Variants/Grey_-_Red_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Red.css (messages/bubbles/Variants/Grey_-_Red.css)
+ skin/classic/messenger/messages/bubbles/Variants/Pink_-_Blue_Alternating.css (messages/bubbles/Variants/Pink_-_Blue_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Pink_-_Blue.css (messages/bubbles/Variants/Pink_-_Blue.css)
+ skin/classic/messenger/messages/bubbles/Variants/Pink_-_Purple_Alternating.css (messages/bubbles/Variants/Pink_-_Purple_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Pink_-_Purple.css (messages/bubbles/Variants/Pink_-_Purple.css)
+ skin/classic/messenger/messages/bubbles/Variants/Purple_-_Green_Alternating.css (messages/bubbles/Variants/Purple_-_Green_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Purple_-_Green.css (messages/bubbles/Variants/Purple_-_Green.css)
+ skin/classic/messenger/messages/bubbles/Variants/Purple_-_Pink_Alternating.css (messages/bubbles/Variants/Purple_-_Pink_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Purple_-_Pink.css (messages/bubbles/Variants/Purple_-_Pink.css)
+ skin/classic/messenger/messages/bubbles/Variants/Red_-_Blue_Alternating.css (messages/bubbles/Variants/Red_-_Blue_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Red_-_Blue.css (messages/bubbles/Variants/Red_-_Blue.css)
+ skin/classic/messenger/messages/bubbles/Variants/Red_-_Green_Alternating.css (messages/bubbles/Variants/Red_-_Green_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Red_-_Green.css (messages/bubbles/Variants/Red_-_Green.css)
+ skin/classic/messenger/messages/dark/inline.js (messages/dark/inline.js)
+ skin/classic/messenger/messages/dark/Incoming/Content.html (messages/dark/Incoming/Content.html)
+ skin/classic/messenger/messages/dark/Incoming/Context.html (messages/dark/Incoming/Context.html)
+ skin/classic/messenger/messages/dark/Incoming/NextContent.html (messages/dark/Incoming/NextContent.html)
+ skin/classic/messenger/messages/dark/Incoming/NextContext.html (messages/dark/Incoming/NextContext.html)
+ skin/classic/messenger/messages/dark/Info.plist (messages/dark/Info.plist)
+ skin/classic/messenger/messages/dark/main.css (messages/dark/main.css)
+ skin/classic/messenger/messages/dark/Status.html (messages/dark/Status.html)
+ skin/classic/messenger/messages/dark/Variants/Blue.css (messages/dark/Variants/Blue.css)
+ skin/classic/messenger/messages/dark/Variants/Green.css (messages/dark/Variants/Green.css)
+ skin/classic/messenger/messages/dark/Variants/Purple.css (messages/dark/Variants/Purple.css)
+ skin/classic/messenger/messages/dark/Variants/Red.css (messages/dark/Variants/Red.css)
+ skin/classic/messenger/messages/dark/Variants/Yellow.css (messages/dark/Variants/Yellow.css)
+ skin/classic/messenger/messages/papersheets/Bitmaps/information.png (messages/papersheets/Bitmaps/information.png)
+ skin/classic/messenger/messages/papersheets/Bitmaps/minus.png (messages/papersheets/Bitmaps/minus.png)
+ skin/classic/messenger/messages/papersheets/Bitmaps/plus.png (messages/papersheets/Bitmaps/plus.png)
+ skin/classic/messenger/messages/papersheets/inline.js (messages/papersheets/inline.js)
+ skin/classic/messenger/messages/papersheets/Incoming/Content.html (messages/papersheets/Incoming/Content.html)
+ skin/classic/messenger/messages/papersheets/Incoming/Context.html (messages/papersheets/Incoming/Context.html)
+ skin/classic/messenger/messages/papersheets/Incoming/NextContent.html (messages/papersheets/Incoming/NextContent.html)
+ skin/classic/messenger/messages/papersheets/Info.plist (messages/papersheets/Info.plist)
+ skin/classic/messenger/messages/papersheets/main.css (messages/papersheets/main.css)
+ skin/classic/messenger/messages/papersheets/NextStatus.html (messages/papersheets/NextStatus.html)
+ skin/classic/messenger/messages/papersheets/Status.html (messages/papersheets/Status.html)
+ skin/classic/messenger/messages/papersheets/Variants/White.css (messages/papersheets/Variants/White.css)
+ skin/classic/messenger/messages/simple/Incoming/Content.html (messages/simple/Incoming/Content.html)
+ skin/classic/messenger/messages/simple/Incoming/Context.html (messages/simple/Incoming/Context.html)
+ skin/classic/messenger/messages/simple/Incoming/NextContext.html (messages/simple/Incoming/NextContext.html)
+ skin/classic/messenger/messages/simple/Info.plist (messages/simple/Info.plist)
+ skin/classic/messenger/messages/simple/main.css (messages/simple/main.css)
+ skin/classic/messenger/messages/simple/Status.html (messages/simple/Status.html)
+ skin/classic/messenger/messages/simple/Variants/Normal.css (messages/simple/Variants/Normal.css)
+ skin/classic/messenger/messages/simple/Variants/Dark.css (messages/simple/Variants/Dark.css)
+% skin messenger-emoticons classic/1.0 %skin/classic/messenger/smileys/
+ skin/classic/messenger/smileys/theme.json (smileys/theme.json)
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png
new file mode 100644
index 0000000000..eb0051de34
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png
new file mode 100644
index 0000000000..9c5890b792
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png
new file mode 100644
index 0000000000..17295f5474
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png
new file mode 100644
index 0000000000..fc54959c86
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png
new file mode 100644
index 0000000000..218351534b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png
new file mode 100644
index 0000000000..4692e1cf92
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png
new file mode 100644
index 0000000000..bbd8c91b10
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png
new file mode 100644
index 0000000000..be6c4b2b08
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png
new file mode 100644
index 0000000000..de40ea9eba
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png
new file mode 100644
index 0000000000..d95237d37c
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png
new file mode 100644
index 0000000000..d6360fb7bd
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png
new file mode 100644
index 0000000000..5c10415912
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png
new file mode 100644
index 0000000000..2bc8b95efa
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png
new file mode 100644
index 0000000000..a0d8e59ce9
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png
new file mode 100644
index 0000000000..572333b2f6
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png
new file mode 100644
index 0000000000..f1e1740e91
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png
new file mode 100644
index 0000000000..f2ff22beae
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png
new file mode 100644
index 0000000000..ba4118844e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png
new file mode 100644
index 0000000000..391439be42
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png
new file mode 100644
index 0000000000..b3b2683090
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png
new file mode 100644
index 0000000000..b59ffae9b6
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png
new file mode 100644
index 0000000000..1a08183e18
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png
new file mode 100644
index 0000000000..8df7a9d569
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png
new file mode 100644
index 0000000000..327ed9be66
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png
new file mode 100644
index 0000000000..f5b2d08f2a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png
new file mode 100644
index 0000000000..fd5baf149f
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png
new file mode 100644
index 0000000000..a03b2d7a29
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png
new file mode 100644
index 0000000000..2dbb2241a2
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png
new file mode 100644
index 0000000000..8505ef0de8
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png
new file mode 100644
index 0000000000..18e3fac3af
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png
new file mode 100644
index 0000000000..02f82c3972
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png
new file mode 100644
index 0000000000..d14afacf6d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png
new file mode 100644
index 0000000000..f9fb364e28
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png
new file mode 100644
index 0000000000..13388613e5
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png
new file mode 100644
index 0000000000..8bb8757871
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png
new file mode 100644
index 0000000000..bd70b8d77a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png
new file mode 100644
index 0000000000..b55967823f
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png
new file mode 100644
index 0000000000..2b239c315b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png
new file mode 100644
index 0000000000..f9c0cee4fe
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png
new file mode 100644
index 0000000000..56839321e2
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png
new file mode 100644
index 0000000000..cec2e2817e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png
new file mode 100644
index 0000000000..ffcbe04eb8
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png
new file mode 100644
index 0000000000..a2e01b5dfa
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png
new file mode 100644
index 0000000000..6cf6949f78
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png
new file mode 100644
index 0000000000..b4acbf8631
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png
new file mode 100644
index 0000000000..0652f280ef
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png
new file mode 100644
index 0000000000..86b9ea0206
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png
new file mode 100644
index 0000000000..36788859bf
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png
new file mode 100644
index 0000000000..45e61fccb0
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png
new file mode 100644
index 0000000000..efd75314fa
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png
new file mode 100644
index 0000000000..69f590d967
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png
new file mode 100644
index 0000000000..77a2469399
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png
new file mode 100644
index 0000000000..9ad18a0dea
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png
new file mode 100644
index 0000000000..0e7a2e35c0
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png
new file mode 100644
index 0000000000..516e309aec
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png
new file mode 100644
index 0000000000..9981a24814
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png
new file mode 100644
index 0000000000..60cc155e03
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png
new file mode 100644
index 0000000000..cb2860cf66
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png
new file mode 100644
index 0000000000..cc5a303a75
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png
new file mode 100644
index 0000000000..dd0ef8da8a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png
new file mode 100644
index 0000000000..15f010224b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png
new file mode 100644
index 0000000000..8d40d43293
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png
new file mode 100644
index 0000000000..7281760571
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png
new file mode 100644
index 0000000000..bb4cc9044e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png
new file mode 100644
index 0000000000..f7d05aae55
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png
new file mode 100644
index 0000000000..a939ea98b9
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png
new file mode 100644
index 0000000000..823cd4f2b0
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png
new file mode 100644
index 0000000000..85b1781135
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png
new file mode 100644
index 0000000000..0cbff3ee35
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png
new file mode 100644
index 0000000000..e51a56935c
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png
new file mode 100644
index 0000000000..758a8f95e3
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png
new file mode 100644
index 0000000000..5e41f98397
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png
new file mode 100644
index 0000000000..b3c8e68eba
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png b/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png
new file mode 100644
index 0000000000..93a69cc789
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png b/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png
new file mode 100644
index 0000000000..72107d151f
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png b/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png
new file mode 100644
index 0000000000..4509b17c0e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png b/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png
new file mode 100644
index 0000000000..eaf364177d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Footer.html b/comm/mail/components/im/messages/bubbles/Footer.html
new file mode 100644
index 0000000000..b024066d50
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Footer.html
@@ -0,0 +1,5 @@
+<!-- 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/. -->
+
+<p id="lastMessage"/>
diff --git a/comm/mail/components/im/messages/bubbles/Incoming/Content.html b/comm/mail/components/im/messages/bubbles/Incoming/Content.html
new file mode 100644
index 0000000000..f37578f699
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Incoming/Content.html
@@ -0,0 +1,7 @@
+<div class="bubble %messageClasses%" data-senderColor="%senderColor%">
+<div class="indicator">
+<p class="pseudo">%sender%<span class="time"> - %time{%H:%M}%</span></p>
+<p class="%messageClasses%">%message%</p>
+<div id="insert"></div>
+</div>
+</div>
diff --git a/comm/mail/components/im/messages/bubbles/Incoming/Context.html b/comm/mail/components/im/messages/bubbles/Incoming/Context.html
new file mode 100644
index 0000000000..8d29cbefbe
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Incoming/Context.html
@@ -0,0 +1,7 @@
+<div class="bubble context %messageClasses%" data-senderColor="%senderColor%">
+<div class="indicator">
+<p class="pseudo">%sender%<span class="time"> - %time{%H:%M}%</span></p>
+<p class="%messageClasses%">%message%</p>
+<div id="insert"></div>
+</div>
+</div>
diff --git a/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html b/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html
new file mode 100644
index 0000000000..3c8aa904ba
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html
@@ -0,0 +1,3 @@
+<hr/>
+<p class="%messageClasses%">%message%</p>
+<div id="insert"></div>
diff --git a/comm/mail/components/im/messages/bubbles/Info.plist b/comm/mail/components/im/messages/bubbles/Info.plist
new file mode 100644
index 0000000000..0b26e9413b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Info.plist
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%sender% %message%</string>
+
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+
+ <key>CFBundleGetInfoString</key>
+ <string>Instantbird Bubbles Message Style</string>
+
+ <key>CFBundleIdentifier</key>
+ <string>org.instantbird.bubbles.message.style</string>
+
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+
+ <key>CFBundleName</key>
+ <string>Bubbles</string>
+
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+
+ <key>DefaultBackgroundColor</key>
+ <string>FFFFFF</string>
+
+ <key>DefaultVariant</key>
+ <string>Blue_-_Red_Alternating</string>
+
+ <key>DisableCustomBackground</key>
+ <false/>
+
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+
+ <key>ShowsUserIcons</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/bubbles/NextStatus.html b/comm/mail/components/im/messages/bubbles/NextStatus.html
new file mode 100644
index 0000000000..5aa62afb78
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/NextStatus.html
@@ -0,0 +1,3 @@
+<hr/>
+<p class="%messageClasses%">%time% - %message%</p>
+<div id="insert"></div>
diff --git a/comm/mail/components/im/messages/bubbles/Status.html b/comm/mail/components/im/messages/bubbles/Status.html
new file mode 100644
index 0000000000..5e5c927b47
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Status.html
@@ -0,0 +1,4 @@
+<div class="bubble %messageClasses%">
+<p class="%messageClasses%">%time% - %message%</p>
+<div id="insert"></div>
+</div>
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css
new file mode 100644
index 0000000000..456b4054ed
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css
new file mode 100644
index 0000000000..8b67d64b38
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css
new file mode 100644
index 0000000000..82c84545e9
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css
new file mode 100644
index 0000000000..813af66880
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css
new file mode 100644
index 0000000000..77e5082b15
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css
new file mode 100644
index 0000000000..9e91c0c21d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css
new file mode 100644
index 0000000000..336e241aea
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css
new file mode 100644
index 0000000000..1f9ab284e3
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css
new file mode 100644
index 0000000000..90a2fcb51d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css
new file mode 100644
index 0000000000..a3b835b49b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css
new file mode 100644
index 0000000000..30186fa0cd
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css
new file mode 100644
index 0000000000..ba999760b9
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css
new file mode 100644
index 0000000000..f2b1f89b62
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css
new file mode 100644
index 0000000000..f1c10ff4a4
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css
new file mode 100644
index 0000000000..84a8b04754
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css
new file mode 100644
index 0000000000..974e7b1698
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css
new file mode 100644
index 0000000000..7051e00d86
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css
new file mode 100644
index 0000000000..601158153c
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css
new file mode 100644
index 0000000000..81eaacf886
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css
new file mode 100644
index 0000000000..7c6c5ae5ef
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css
new file mode 100644
index 0000000000..70568ca0d5
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css
new file mode 100644
index 0000000000..605b051393
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css
new file mode 100644
index 0000000000..f04b8bd51d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css
new file mode 100644
index 0000000000..eb814bdcd3
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css
new file mode 100644
index 0000000000..3122ad8df3
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css
new file mode 100644
index 0000000000..dfd40e6335
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css
new file mode 100644
index 0000000000..beea02943e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css
new file mode 100644
index 0000000000..869ee36eb8
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css
new file mode 100644
index 0000000000..2fbe69c40b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css
new file mode 100644
index 0000000000..e0337a8d7f
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css
new file mode 100644
index 0000000000..cae44aa14a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css
new file mode 100644
index 0000000000..0cbe20430a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css
@@ -0,0 +1,36 @@
+/* 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/. */
+
+.bubble.outgoing {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/inline.js b/comm/mail/components/im/messages/bubbles/inline.js
new file mode 100644
index 0000000000..11bdec3f29
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/inline.js
@@ -0,0 +1,330 @@
+/* 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/. */
+
+// See chat/content/conversation-browser.js _exposeMethodsToContent
+/* globals convScrollEnabled, scrollToElement */
+
+/* [pseudo_color, pseudo_background, bubble_borders] */
+const elements_lightness = [
+ [75, 94, 80],
+ [75, 94, 80],
+ [70, 93, 75],
+ [65, 92, 70],
+ [55, 90, 65],
+ [48, 90, 60],
+ [44, 86, 50],
+ [44, 88, 60],
+ [45, 88, 70],
+ [45, 90, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [60, 92, 70],
+ [70, 93, 75],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+];
+
+const bubble_background = "hsl(#, 100%, 97%)";
+const bubble_borders = "hsl(#, 100%, #%)";
+const pseudo_color = "hsl(#, 100%, #%)";
+const pseudo_background = "hsl(#, 100%, #%)";
+
+var alternating = null;
+
+function setColors(target) {
+ var senderColor = target.getAttribute("data-senderColor");
+
+ if (!senderColor) {
+ return;
+ }
+
+ var regexp =
+ /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/;
+ var parsed = regexp.exec(senderColor);
+
+ if (!parsed) {
+ return;
+ }
+
+ var senderHue = (Math.round(parsed[1] / 10) * 10) % 360;
+ var lightness = elements_lightness[senderHue / 10];
+
+ target.style.backgroundColor = bubble_background.replace("#", senderHue);
+ target.style.borderColor = bubble_borders
+ .replace("#", senderHue)
+ .replace("#", lightness[2]);
+
+ var pseudo = target.getElementsByClassName("pseudo")[0];
+ pseudo.style.color = pseudo_color
+ .replace("#", senderHue)
+ .replace("#", lightness[0]);
+ pseudo.style.backgroundColor = pseudo_background
+ .replace("#", senderHue)
+ .replace("#", lightness[1]);
+
+ var div_indicator = target.getElementsByClassName("indicator")[0];
+ var imageURL = "url('Bitmaps/indicator_" + senderHue;
+ if (target.classList.contains("incoming")) {
+ // getComputedStyle is prohibitively expensive, and we need it only to
+ // know if we are using an alternating variant, so we cache the result.
+ if (alternating === null) {
+ alternating = document.defaultView
+ .getComputedStyle(div_indicator)
+ .backgroundImage.endsWith('_alt.png")')
+ ? "_alt"
+ : "";
+ }
+ imageURL += alternating;
+ }
+ div_indicator.style.backgroundImage = imageURL + ".png')";
+}
+
+function prettyPrintTime(aValue, aNoSeconds) {
+ if (aValue < 60 && aNoSeconds) {
+ return "";
+ }
+
+ if (aNoSeconds) {
+ aValue -= aValue % 60;
+ }
+
+ let valuesAndUnits = window.convertTimeUnits(aValue);
+ if (!valuesAndUnits[2]) {
+ valuesAndUnits.splice(2, 2);
+ }
+ return valuesAndUnits.join(" ");
+}
+
+// The "shadow" constant is the minimum acceptable margin-bottom for a bubble
+// with a shadow, and the minimum spacing between the bubbles of two messages
+// arriving in the same second. It should match the value of margin-bottom and
+// box-shadow-bottom for the "bubble" class.
+const shadow = 3;
+const coef = 3;
+const timebeforetextdisplay = 5 * 60;
+const kRulerMarginTop = 11;
+
+const kMsPerMinute = 60 * 1000;
+const kMsPerHour = 60 * kMsPerMinute;
+const kMsPerDay = 24 * kMsPerHour;
+
+function computeSpace(aInterval) {
+ return Math.round(coef * Math.log(aInterval + 1));
+}
+
+var lastMessageTimeout;
+var lastMessageTimeoutTime = -1;
+
+/* This function takes care of updating the amount of whitespace
+ * between the last message and the bottom of the conversation area.
+ * When the last message is more than timebeforetextdisplay old, we display
+ * the time in text. To avoid blinking Mac scrollbar and visual distractions
+ * for some very sensitive users, we update the whitespace only when a new
+ * message is displayed or when the user switches between tabs. While the
+ * conversation is visible, this function is called by timers, but we will
+ * only update the time displayed in text (this behavior is obtained by
+ * setting the aUpdateTextOnly parameter to true; otherwise it is omitted).
+ */
+function handleLastMessage(aUpdateTextOnly) {
+ if (window.messageInsertPending) {
+ return;
+ }
+
+ var intervalInMs = Date.now() - lastMsgTime * 1000;
+ var interval = Math.round(intervalInMs / 1000);
+ var p = document.getElementById("lastMessage");
+ var margin;
+ if (!aUpdateTextOnly) {
+ // Impose a minimum to ensure the last bubble doesn't touch the editbox.
+ margin = computeSpace(Math.max(intervalInMs, 5000) / 1000);
+ }
+ var text = "";
+ if (interval >= timebeforetextdisplay) {
+ if (!aUpdateTextOnly) {
+ p.style.lineHeight = margin + shadow + "px";
+ }
+ p.setAttribute("class", "interval");
+ text = prettyPrintTime(interval, true);
+ margin = 0;
+ }
+ p.textContent = text;
+ if (!aUpdateTextOnly) {
+ p.style.marginTop = margin - shadow + "px";
+ if (convScrollEnabled()) {
+ scrollToElement(p);
+ }
+ }
+
+ var next = timebeforetextdisplay * 1000 - intervalInMs;
+ if (next <= 0) {
+ if (intervalInMs > kMsPerDay) {
+ next = kMsPerHour - (intervalInMs % kMsPerHour);
+ } else {
+ next = kMsPerMinute - (intervalInMs % kMsPerMinute);
+ }
+ aUpdateTextOnly = true;
+ }
+
+ // The setTimeout callbacks are frequently called a few ms early,
+ // but our code prefers being called a little late, so add 20ms.
+ lastMessageTimeoutTime = next + 20;
+ lastMessageTimeout = setTimeout(
+ handleLastMessage,
+ lastMessageTimeoutTime,
+ aUpdateTextOnly
+ );
+}
+
+var lastMsgTime = 0;
+function updateLastMsgTime(aMsgTime) {
+ if (aMsgTime > lastMsgTime) {
+ lastMsgTime = aMsgTime;
+ }
+
+ if (lastMsgTime && lastMessageTimeoutTime != 0 && !document.hidden) {
+ clearTimeout(lastMessageTimeout);
+ setTimeout(handleLastMessage, 0);
+ lastMessageTimeoutTime = 0;
+ }
+}
+
+function visibilityChanged() {
+ if (document.hidden) {
+ clearTimeout(lastMessageTimeout);
+ lastMessageTimeoutTime = -1;
+ } else if (lastMsgTime) {
+ handleLastMessage();
+ }
+}
+
+function checkNewText(target) {
+ var nicks = target.getElementsByClassName("ib-nick");
+ for (var i = 0; i < nicks.length; ++i) {
+ var nick = nicks[i];
+ if (nick.hasAttribute("data-left")) {
+ continue;
+ }
+ var hue = nick.getAttribute("data-nickColor");
+ var senderHue = (Math.round(hue / 10) * 10) % 360;
+ var lightness = elements_lightness[senderHue / 10];
+ nick.style.backgroundColor = pseudo_background
+ .replace("#", senderHue)
+ .replace("#", lightness[1]);
+ nick.style.color = pseudo_color
+ .replace("#", senderHue)
+ .replace("#", lightness[0]);
+ nick.style.borderColor = bubble_borders
+ .replace("#", senderHue)
+ .replace("#", lightness[2]);
+ }
+
+ var msgTime = null;
+ if (target._originalMsg) {
+ msgTime = target._originalMsg.time;
+ }
+ if (target.tagName == "DIV" && target.classList.contains("bubble")) {
+ setColors(target);
+
+ var prev = target.previousElementSibling;
+ var shouldSetUnreadRuler = prev && prev.id && prev.id == "unread-ruler";
+ var shouldSetSessionRuler =
+ prev && prev.className && prev.className == "sessionstart-ruler";
+ // We need an extra pixel of margin at the top to make the margins appear
+ // to be of equal size, since the preceding bubble will have a shadow.
+ var rulerMarginBottom = kRulerMarginTop - 1;
+
+ if (lastMsgTime && msgTime >= lastMsgTime) {
+ var interval = msgTime - lastMsgTime;
+ var margin = computeSpace(interval);
+ let isTimetext = interval >= timebeforetextdisplay;
+ if (isTimetext) {
+ let p = document.createElement("p");
+ p.className = "interval";
+ if (shouldSetSessionRuler) {
+ // Hide the hr and style the time text accordingly instead.
+ prev.classList.remove("sessionstart-ruler");
+ prev.style.border = "none";
+ p.classList.add("sessionstart-ruler");
+ margin += 6;
+ prev = p;
+ }
+ p.style.lineHeight = margin + shadow + "px";
+ p.style.marginTop = -shadow + "px";
+ p.textContent = prettyPrintTime(interval);
+ target.parentNode.insertBefore(p, target);
+ margin = 0;
+ }
+ target.style.marginTop = margin + "px";
+ if (shouldSetUnreadRuler || shouldSetSessionRuler) {
+ if (margin > rulerMarginBottom) {
+ // Set the unread ruler margin so it is constant after margin collapse.
+ // See https://developer.mozilla.org/en/CSS/margin_collapsing
+ rulerMarginBottom -= margin;
+ }
+ if (isTimetext && shouldSetUnreadRuler) {
+ // If a text display follows, use the minimum bubble margin after the
+ // ruler, taking account of the absence of a shadow on the ruler.
+ rulerMarginBottom = shadow - 1;
+ }
+ }
+ }
+ if (shouldSetUnreadRuler || shouldSetSessionRuler) {
+ prev.style.marginBottom = rulerMarginBottom + "px";
+ prev.style.marginTop = kRulerMarginTop + "px";
+ }
+ } else if (target.tagName == "P" && target.className == "event") {
+ let parent = target.parentNode;
+ // We need to start a group with this element if there are at least 4
+ // system messages and they aren't already grouped.
+ if (!parent?.grouped && parent?.querySelector("p.event:nth-of-type(4)")) {
+ let p = document.createElement("p");
+ p.className = "eventToggle";
+ p.addEventListener("click", event =>
+ event.target.parentNode.classList.toggle("hide-children")
+ );
+ parent.insertBefore(p, parent.querySelector("p.event:nth-of-type(2)"));
+ parent.classList.add("hide-children");
+ parent.grouped = true;
+ }
+ }
+
+ if (msgTime) {
+ updateLastMsgTime(msgTime);
+ }
+}
+
+new MutationObserver(function (aMutations) {
+ for (let mutation of aMutations) {
+ for (let node of mutation.addedNodes) {
+ if (node instanceof HTMLElement) {
+ checkNewText(node);
+ }
+ }
+ }
+}).observe(document.getElementById("ibcontent"), {
+ childList: true,
+ subtree: true,
+});
+
+document.addEventListener("visibilitychange", visibilityChanged);
diff --git a/comm/mail/components/im/messages/bubbles/main.css b/comm/mail/components/im/messages/bubbles/main.css
new file mode 100644
index 0000000000..84e8c7b8d6
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/main.css
@@ -0,0 +1,210 @@
+/* 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/. */
+
+body {
+ margin: 0;
+ padding: 0;
+ background: -moz-linear-gradient(top, -moz-dialog, -moz-default-background-color) fixed;
+ color: #000;
+}
+
+p {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 0;
+}
+
+.bubble {
+ margin: 20px 20px 3px;
+ padding: 0;
+ border-width: 2px;
+ border-style: solid;
+ border-radius: 10px;
+ box-shadow: rgba(0, 0, 0, 0.3) 1px 1px 3px;
+}
+
+#ibcontent:not(.log) > #Chat > .bubble:not(.context,.event) {
+ -moz-animation-duration: 0.5s;
+ -moz-animation-name: fadein;
+ -moz-animation-iteration-count: 1;
+}
+
+@-moz-keyframes fadein {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1.0;
+ }
+}
+
+.bubble.context:not(:hover) {
+ filter: saturate(40%);
+}
+
+.indicator {
+ margin: 0;
+ padding: 9px 15px 10px 15px;
+}
+
+.bubble.event {
+ padding: 4px 15px 4px 15px;
+ background-color: hsl(0, 0%, 99%);
+ border-color: hsl(0, 0%, 85%);
+ box-shadow: rgba(0, 0, 0, 0.1) 1px 1px 3px;
+}
+
+.pseudo {
+ display: inline-block;
+ font-size: smaller;
+ font-weight: bold;
+ margin: -9px 0px 3px -15px;
+ padding: 0px 15px 1px 15px;
+ /* border-top-left-radius = (border-radius - border-width) of div.bubble,
+ see bug 1775 for an explanation */
+ border-top-left-radius: 8px;
+ border-bottom-right-radius: 10px;
+}
+
+.pseudo > .time {
+ display: none;
+}
+
+.bubble:hover > .indicator > .pseudo > .time {
+ display: inline;
+}
+
+.bubble > .indicator > hr,
+.bubble > hr {
+ margin: 3px 0px 1px 0px;
+ height: 2px;
+ border-style: none;
+ border-top: 1px solid rgba(0, 0, 0, 0.07);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.5);
+}
+
+.interval, #lastMessage {
+ text-align: center;
+ color: hsl(0, 0%, 60%);
+}
+
+#lastMessage {
+ line-height: 20px;
+}
+
+#ibcontent.log > #lastMessage {
+ display: none;
+}
+
+p.nick {
+ font-weight: bold;
+}
+
+p.action {
+ font-style: italic;
+}
+
+p.action::before {
+ content: "*** ";
+}
+
+p.event {
+ color: hsl(0, 0%, 60%);
+}
+
+p.event *:any-link:not(:hover) {
+ color: hsl(0, 0%, 60%);
+ text-decoration: none;
+}
+
+p.event *:any-link:hover {
+ color: hsl(0, 0%, 25%);
+}
+
+#Chat {
+ white-space: normal;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+#unread-ruler {
+ border-top: 1px solid rgba(0, 0, 0, 0.16) !important;
+ border-bottom: 1px solid rgb(255,255,255) !important;
+}
+
+.sessionstart-ruler {
+ margin: 0;
+ width: 100%;
+ border: none;
+ min-height: 13px;
+ background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(0,0,0,0.18));
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::after {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 10px;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-start: 4px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+/* used by javascript */
+.eventToggle {
+ cursor: pointer;
+ min-height: 20px;
+ margin-left: -24px;
+ padding-left: 24px;
+ background: url('Bitmaps/minus.png') no-repeat left top;
+ margin-bottom: -20px;
+ width: 0;
+}
+
+.eventToggle:hover {
+ background-image: url('Bitmaps/minus-hover.png');
+}
+
+.hide-children > .eventToggle {
+ width: 100%;
+ margin-bottom: -3px;
+ background-image: url('Bitmaps/plus.png');
+}
+
+.hide-children > .eventToggle:hover {
+ background-image: url('Bitmaps/plus-hover.png');
+}
+
+.hide-children > .eventToggle::after {
+ content: "\2026"; /* &hellip; */
+ color: hsl(0, 0%, 60%);
+}
+
+.hide-children > :is(p.event,hr):not(:first-of-type,:last-of-type,.no-collapse) {
+ display: none;
+}
+
+.ib-nick {
+ font-size: smaller;
+ border: 1px solid;
+ border-radius: 6px;
+ padding: 0 0.3em;
+}
+
+.ib-nick[left] {
+ color: hsl(0, 0%, 60%);
+ background-color: hsl(0, 0%, 99%);
+ border-color: hsl(0, 0%, 85%);
+}
diff --git a/comm/mail/components/im/messages/dark/Incoming/Content.html b/comm/mail/components/im/messages/dark/Incoming/Content.html
new file mode 100644
index 0000000000..3db2719441
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Incoming/Content.html
@@ -0,0 +1,2 @@
+<p class="%messageClasses%" data-senderColor="%senderColor%"><span class="pseudo">%sender%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/dark/Incoming/Context.html b/comm/mail/components/im/messages/dark/Incoming/Context.html
new file mode 100644
index 0000000000..0b8c7ec20f
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Incoming/Context.html
@@ -0,0 +1,2 @@
+<p class="context %messageClasses%" data-senderColor="%senderColor%"><span class="pseudo">%sender%</span><span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/dark/Incoming/NextContent.html b/comm/mail/components/im/messages/dark/Incoming/NextContent.html
new file mode 100644
index 0000000000..c62098d838
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Incoming/NextContent.html
@@ -0,0 +1,2 @@
+<p class="%messageClasses%" data-senderColor="%senderColor%"><span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/dark/Incoming/NextContext.html b/comm/mail/components/im/messages/dark/Incoming/NextContext.html
new file mode 100644
index 0000000000..d57fd3b1a6
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Incoming/NextContext.html
@@ -0,0 +1,2 @@
+<p class="context %messageClasses%" data-senderColor="%senderColor%"><span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/dark/Info.plist b/comm/mail/components/im/messages/dark/Info.plist
new file mode 100644
index 0000000000..3de1af0f4d
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Info.plist
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%sender% %message%</string>
+
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+
+ <key>CFBundleGetInfoString</key>
+ <string>Instantbird Dark Message Style</string>
+
+ <key>CFBundleIdentifier</key>
+ <string>org.instantbird.dark.message.style</string>
+
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+
+ <key>CFBundleName</key>
+ <string>Dark</string>
+
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+
+ <key>DefaultBackgroundColor</key>
+ <string>000000</string>
+
+ <key>DefaultVariant</key>
+ <string>Blue</string>
+
+ <key>DisableCustomBackground</key>
+ <false/>
+
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+
+ <key>ShowsUserIcons</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/dark/Status.html b/comm/mail/components/im/messages/dark/Status.html
new file mode 100644
index 0000000000..cb3bedf216
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Status.html
@@ -0,0 +1 @@
+<p class="event-messages">%time% - %message%</p>
diff --git a/comm/mail/components/im/messages/dark/Variants/Blue.css b/comm/mail/components/im/messages/dark/Variants/Blue.css
new file mode 100644
index 0000000000..d32a90406f
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Blue.css
@@ -0,0 +1,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/. */
+
+p.incoming {
+ border-top: 1px solid hsla(215, 100%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(215, 100%, 80%, 0.3), hsla(215, 100%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/Variants/Green.css b/comm/mail/components/im/messages/dark/Variants/Green.css
new file mode 100644
index 0000000000..d2a8ecca33
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Green.css
@@ -0,0 +1,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/. */
+
+p.incoming {
+ border-top: 1px solid hsla(150, 80%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(150, 80%, 80%, 0.3), hsla(150, 80%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/Variants/Purple.css b/comm/mail/components/im/messages/dark/Variants/Purple.css
new file mode 100644
index 0000000000..bf26f8d549
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Purple.css
@@ -0,0 +1,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/. */
+
+p.incoming {
+ border-top: 1px solid hsla(275, 100%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(275, 100%, 80%, 0.3), hsla(275, 100%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/Variants/Red.css b/comm/mail/components/im/messages/dark/Variants/Red.css
new file mode 100644
index 0000000000..5bb6dab2ed
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Red.css
@@ -0,0 +1,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/. */
+
+p.incoming {
+ border-top: 1px solid hsla(0, 100%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(0, 100%, 80%, 0.3), hsla(0, 100%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/Variants/Yellow.css b/comm/mail/components/im/messages/dark/Variants/Yellow.css
new file mode 100644
index 0000000000..aa493bfdc7
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Yellow.css
@@ -0,0 +1,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/. */
+
+p.incoming {
+ border-top: 1px solid hsla(50, 100%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(50, 100%, 80%, 0.3), hsla(50, 100%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/inline.js b/comm/mail/components/im/messages/dark/inline.js
new file mode 100644
index 0000000000..71cbd46475
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/inline.js
@@ -0,0 +1,60 @@
+/* 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 p_border_top = "1px solid hsla(#, 100%, 80%, 0.4)";
+const p_background =
+ "-moz-linear-gradient(top, hsla(#, 100%, 80%, 0.3), hsla(#, 100%, 80%, 0.1) 30px)";
+const nick_background =
+ "-moz-linear-gradient(top, hsla(#, 100%, 80%, 0.3), hsla(#, 100%, 80%, 0.1) 1em)";
+
+function setColors(target) {
+ var senderColor = target.getAttribute("data-senderColor");
+
+ if (!senderColor) {
+ return;
+ }
+
+ var regexp =
+ /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/;
+ var parsed = regexp.exec(senderColor);
+
+ if (!parsed) {
+ return;
+ }
+
+ var senderHue = parsed[1];
+
+ target.style.borderTop = p_border_top.replace("#", senderHue);
+ target.style.background = p_background.replace(/#/g, senderHue);
+}
+
+function checkNewText(target) {
+ if (target.tagName == "P" && target.className != "event-messages") {
+ setColors(target);
+ }
+
+ var nicks = target.getElementsByClassName("ib-nick");
+ for (var i = 0; i < nicks.length; ++i) {
+ var nick = nicks[i];
+ if (!nick.hasAttribute("data-left")) {
+ nick.style.background = nick_background.replace(
+ /#/g,
+ nick.getAttribute("data-nickColor")
+ );
+ }
+ }
+}
+
+new MutationObserver(function (aMutations) {
+ for (let mutation of aMutations) {
+ for (let node of mutation.addedNodes) {
+ if (node instanceof HTMLElement) {
+ checkNewText(node);
+ }
+ }
+ }
+}).observe(document.getElementById("ibcontent"), {
+ childList: true,
+ subtree: true,
+});
diff --git a/comm/mail/components/im/messages/dark/main.css b/comm/mail/components/im/messages/dark/main.css
new file mode 100644
index 0000000000..b3f94d9d2c
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/main.css
@@ -0,0 +1,127 @@
+/* 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/. */
+
+body {
+ margin: 0;
+ padding: 0;
+ background-color: black;
+}
+
+p {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 0;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+p.message {
+ margin: 0;
+ padding: 4px 15px 6px 15px;
+ border-bottom: 1px solid black;
+ border-top: 1px solid rgba(255, 255, 255, 0.3);
+ background: -moz-linear-gradient(top, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.07) 30px);
+}
+
+p.context:not(:hover) {
+ opacity: 0.5;
+ color: rgba(255, 255, 255, 1);
+}
+
+span.message-style,
+p.event-messages {
+ font-size: 90%;
+}
+
+p.event-messages {
+ margin: 5px 0px 5px 0px;
+ text-align: center;
+ opacity: 0.4;
+ -moz-transition-property: opacity;
+ -moz-transition-duration: 0.3s;
+}
+
+p.event-messages:hover {
+ opacity: 1;
+}
+
+.message-style {
+ display: block;
+}
+
+.pseudo {
+ margin-bottom: 3px;
+ font-weight: bold;
+ color: white;
+ display: block;
+}
+
+.nick > .message-style {
+ font-weight: bold;
+}
+
+.action > .message-style {
+ font-style: italic;
+}
+
+.action > .message-style::before {
+ content: "*** ";
+}
+
+a,
+a:hover {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+a:active {
+ color: rgba(255, 255, 255, 1);
+}
+
+a:visited {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+#Chat {
+ white-space: normal;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+.ib-nick {
+ color: white !important;
+ border-radius: 3px;
+ padding: 0 0.25em;
+}
+
+.ib-nick[left] {
+ color: white !important;
+ background-color: black;
+ opacity: 0.4;
+ -moz-transition-property: opacity;
+ -moz-transition-duration: 0.3s;
+}
+
+.ib-nick[left]:hover {
+ opacity: 1;
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::after {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 11px;
+ opacity: 0.5;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-start: 4px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
diff --git a/comm/mail/components/im/messages/mail/Footer.html b/comm/mail/components/im/messages/mail/Footer.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Footer.html
diff --git a/comm/mail/components/im/messages/mail/Header.html b/comm/mail/components/im/messages/mail/Header.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Header.html
diff --git a/comm/mail/components/im/messages/mail/Incoming/Content.html b/comm/mail/components/im/messages/mail/Incoming/Content.html
new file mode 100644
index 0000000000..cfc6270d37
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/Content.html
@@ -0,0 +1 @@
+<div class="%messageClasses%" data-prpl="%service%"><div class="sidebar"><img src="%userIconPath%" alt="" class="usericon"/><div class="date">%time{%H:%M}%</div></div><div class="body"><div class="pseudo" style="%senderColor%">%sender%</div>%message%</div></div>
diff --git a/comm/mail/components/im/messages/mail/Incoming/Context.html b/comm/mail/components/im/messages/mail/Incoming/Context.html
new file mode 100644
index 0000000000..6a297f0fba
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/Context.html
@@ -0,0 +1 @@
+<div class="context %messageClasses%" data-prpl="%service%"><div class="sidebar"><img src="%userIconPath%" alt="" class="usericon"/><div class="date">%time{%H:%M}%</div></div><div class="body"><div class="pseudo" style="%senderColor%">%sender%</div>%message%</div></div>
diff --git a/comm/mail/components/im/messages/mail/Incoming/NextContent.html b/comm/mail/components/im/messages/mail/Incoming/NextContent.html
new file mode 100644
index 0000000000..02c51fd70a
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/NextContent.html
@@ -0,0 +1 @@
+<div class="%messageClasses%" data-prpl="%service%"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body">%message%</div></div>
diff --git a/comm/mail/components/im/messages/mail/Incoming/NextContext.html b/comm/mail/components/im/messages/mail/Incoming/NextContext.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/NextContext.html
diff --git a/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg b/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg
new file mode 100644
index 0000000000..6f9e4e7b93
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="48" height="48" viewBox="0 0 48 48">
+ <path fill="context-fill" fill-opacity="0.25" d="M2 48v-8c-.06-7.74 15.71-6.56 16.01-11.12.1-1.33.34-1.66-.23-3.08-.98-.65-1.41-2.86-1.52-4.1 0-.97-.95-.24-1.01-1.39-.32-1.5-.46-2.91.14-4.37.55-.47.83.74.83-.13a8.1 8.1 0 01.64-4.52c1.27-4.73 11.16-4.57 13.54.36.7 1.98.61 2.86.76 4.84 0 .84.4-.61.81.1a7.9 7.9 0 01-.1 4.01c-.53 1.95-1.39.16-1.52 1.52-.6 1.24-.32 3.04-1.8 3.73-.46 1.13-.28 1.85-.14 2.99 0 4.38 15.1 4.14 15.59 11.16v7.86"/>
+</svg>
diff --git a/comm/mail/components/im/messages/mail/Info.plist b/comm/mail/components/im/messages/mail/Info.plist
new file mode 100644
index 0000000000..042b7b49bb
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Info.plist
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%message%</string>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+ <key>CFBundleGetInfoString</key>
+ <string>Thunderbird Message Style</string>
+ <key>CFBundleIdentifier</key>
+ <string>org.mozilla.thunderbird.message.style</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+ <key>CFBundleName</key>
+ <string>Minimal</string>
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+ <key>DefaultBackgroundColor</key>
+ <string>FFFFFF</string>
+ <key>DefaultVariant</key>
+ <string>Light</string>
+ <key>DisableCustomBackground</key>
+ <false/>
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+ <key>ShowsUserIcons</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/mail/NextStatus.html b/comm/mail/components/im/messages/mail/NextStatus.html
new file mode 100644
index 0000000000..26dd6fac41
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/NextStatus.html
@@ -0,0 +1 @@
+<div class="event-row"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body"><p class="event-paragraph">%message%</p></div></div><span id="insert"/>
diff --git a/comm/mail/components/im/messages/mail/Outgoing/Content.html b/comm/mail/components/im/messages/mail/Outgoing/Content.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Outgoing/Content.html
diff --git a/comm/mail/components/im/messages/mail/Outgoing/Context.html b/comm/mail/components/im/messages/mail/Outgoing/Context.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Outgoing/Context.html
diff --git a/comm/mail/components/im/messages/mail/Outgoing/NextContent.html b/comm/mail/components/im/messages/mail/Outgoing/NextContent.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Outgoing/NextContent.html
diff --git a/comm/mail/components/im/messages/mail/Outgoing/NextContext.html b/comm/mail/components/im/messages/mail/Outgoing/NextContext.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Outgoing/NextContext.html
diff --git a/comm/mail/components/im/messages/mail/Status.html b/comm/mail/components/im/messages/mail/Status.html
new file mode 100644
index 0000000000..a59a34e211
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Status.html
@@ -0,0 +1 @@
+<div aria-live="polite" class="%messageClasses%"><div class="event-row"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body"><p class="event-paragraph">%message%</p></div></div><span id="insert"/></div>
diff --git a/comm/mail/components/im/messages/mail/Variants/Dark.css b/comm/mail/components/im/messages/mail/Variants/Dark.css
new file mode 100644
index 0000000000..63044cc7fa
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Variants/Dark.css
@@ -0,0 +1,49 @@
+/* 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/. */
+
+body {
+ background-color: #18181a;
+ color: #f9f9fa;
+}
+
+#Chat .event p {
+ color: #999;
+}
+
+#Chat #unread-ruler {
+ border-top: 1px solid #30e60b;
+}
+
+.message:hover,
+.message:focus {
+ background-color: rgba(255, 255, 255, 0.03);
+}
+
+.outgoing .pseudo {
+ color: #007cff;
+}
+
+.incoming .pseudo {
+ color: #e5509f;
+}
+
+.date {
+ color: #999;
+}
+
+.ib-sender.message-encrypted::before {
+ fill: #fff;
+}
+
+.context {
+ color: #aeaeaf;
+}
+
+.sessionstart-ruler {
+ border-top: 1px solid #e9e9ea;
+}
+
+.eventToggle {
+ stroke: #fff;
+}
diff --git a/comm/mail/components/im/messages/mail/Variants/Light.css b/comm/mail/components/im/messages/mail/Variants/Light.css
new file mode 100644
index 0000000000..7f1404cf9c
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Variants/Light.css
@@ -0,0 +1,49 @@
+/* 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/. */
+
+body {
+ background-color: white;
+ color: black;
+}
+
+#Chat .event p {
+ color: GrayText;
+}
+
+#Chat #unread-ruler {
+ border-top: 1px solid #30e60b;
+}
+
+.message:hover,
+.message:focus {
+ background-color: rgba(0, 0, 0, 0.03);
+}
+
+.outgoing .pseudo {
+ color: #0060DF;
+}
+
+.incoming .pseudo {
+ color: #B5007F;
+}
+
+.date {
+ color: GrayText;
+}
+
+.ib-sender.message-encrypted::before {
+ fill: #000;
+}
+
+.context {
+ color: rgb(91, 91, 91);
+}
+
+.sessionstart-ruler {
+ border-top: 1px solid ThreeDDarkShadow;
+}
+
+.eventToggle {
+ stroke: #000;
+}
diff --git a/comm/mail/components/im/messages/mail/inline.js b/comm/mail/components/im/messages/mail/inline.js
new file mode 100644
index 0000000000..a6e7f72302
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/inline.js
@@ -0,0 +1,40 @@
+/* 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/. */
+
+function checkNewText(target) {
+ if (target.className == "event-row") {
+ let parent = target.closest(".event");
+ // We need to start a group with this element if there are at least 4
+ // system messages and they aren't already grouped.
+ if (
+ !parent?.grouped &&
+ parent?.querySelector(".event-row:nth-of-type(4)")
+ ) {
+ let toggle = document.createElement("div");
+ toggle.className = "eventToggle";
+ toggle.addEventListener("click", event => {
+ toggle.closest(".event").classList.toggle("hide-children");
+ });
+ parent.insertBefore(
+ toggle,
+ parent.querySelector(".event-row:nth-of-type(2)")
+ );
+ parent.classList.add("hide-children");
+ parent.grouped = true;
+ }
+ }
+}
+
+new MutationObserver(function (aMutations) {
+ for (let mutation of aMutations) {
+ for (let node of mutation.addedNodes) {
+ if (node instanceof HTMLElement) {
+ checkNewText(node);
+ }
+ }
+ }
+}).observe(document.getElementById("ibcontent"), {
+ childList: true,
+ subtree: true,
+});
diff --git a/comm/mail/components/im/messages/mail/main.css b/comm/mail/components/im/messages/mail/main.css
new file mode 100644
index 0000000000..1989b2e3d3
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/main.css
@@ -0,0 +1,155 @@
+/* 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/. */
+
+#Chat {
+ white-space: normal;
+}
+
+/* The "#chat " is required to override "#Chat *" from conv.css */
+
+.message {
+ display: flex;
+ align-items: flex-start;
+ margin-block: 5px;
+ padding: 5px 6px;
+ border-radius: 4px;
+}
+
+#Chat .event {
+ display: flex;
+ flex-direction: column;
+ margin-left: 0;
+ clear: none;
+ padding-inline: 6px;
+}
+
+.event-row {
+ display: flex;
+ align-items: start;
+}
+
+#Chat .event p {
+ margin: 0;
+ margin-block-end: 5px;
+}
+
+#Chat #unread-ruler {
+ margin: 4px;
+}
+
+.sidebar {
+ display: flex;
+ justify-content: end;
+ margin-inline-end: 10px;
+ margin-block-start: 2px;
+ width: 4.5em;
+ flex-wrap: wrap;
+ text-align: right;
+}
+
+.body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.pseudo {
+ font-size: 0.9em;
+ font-weight: bold;
+ letter-spacing: 0.01em;
+ margin-block-end: 0;
+}
+
+.message.outgoing + .message.outgoing,
+.message.incoming + .message.incoming {
+ margin-block: 0;
+}
+
+.message:not(.action) > .next {
+ visibility: hidden;
+}
+
+.date {
+ font-size: 0.75em;
+ text-transform: uppercase;
+ font-style: normal;
+ font-weight: normal;
+ white-space: nowrap;
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::before {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 11px;
+ opacity: 0.5;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-end: 4px;
+ -moz-context-properties: fill;
+}
+
+.usericon {
+ display: none;
+}
+
+.nick {
+ font-weight: bold;
+}
+
+.nick > .pseudo {
+ text-decoration: underline;
+}
+
+.action {
+ font-style: italic;
+}
+
+.context > .pseudo {
+ opacity: 0.7;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+.sessionstart-ruler {
+ margin: 8px 0 12px;
+ width: 100%;
+ border: none;
+}
+
+/* used by javascript */
+.eventToggle {
+ background: var(--icon-nav-down-sm) no-repeat left center;
+ margin-bottom: -22px;
+ cursor: pointer;
+ height: 22px;
+ width: 20px;
+ z-index: 1;
+ opacity: 0.5;
+ -moz-context-properties: stroke;
+}
+
+.eventToggle:hover {
+ opacity: 1;
+}
+
+.hide-children > :is(.event-row,hr):not(:first-of-type,:last-of-type,.no-collapse) {
+ display: none;
+}
+
+.hide-children .eventToggle {
+ background: var(--icon-nav-right-sm) no-repeat left center;
+}
+
+.hide-children .eventToggle:-moz-locale-dir(rtl) {
+ background: var(--icon-nav-left-sm) no-repeat right center;
+}
diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/information.png b/comm/mail/components/im/messages/papersheets/Bitmaps/information.png
new file mode 100644
index 0000000000..ff62c80758
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Bitmaps/information.png
Binary files differ
diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png b/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png
new file mode 100644
index 0000000000..f84a080807
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png
Binary files differ
diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png b/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png
new file mode 100644
index 0000000000..9f5e414f44
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png
Binary files differ
diff --git a/comm/mail/components/im/messages/papersheets/Incoming/Content.html b/comm/mail/components/im/messages/papersheets/Incoming/Content.html
new file mode 100644
index 0000000000..c395055382
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Incoming/Content.html
@@ -0,0 +1,4 @@
+<div class="messages-group %messageClasses%" data-senderColor="%senderColor%">
+<p class="%messageClasses%"><span class="date">%time%</span> <span class="pseudo" style="%senderColor%">%sender%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
+</div>
diff --git a/comm/mail/components/im/messages/papersheets/Incoming/Context.html b/comm/mail/components/im/messages/papersheets/Incoming/Context.html
new file mode 100644
index 0000000000..38c9bc0ee8
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Incoming/Context.html
@@ -0,0 +1,4 @@
+<div class="messages-group context %messageClasses%" data-senderColor="%senderColor%">
+<p class="%messageClasses%"><span class="date">%time%</span> <span class="pseudo" style="%senderColor%">%sender%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
+</div>
diff --git a/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html b/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html
new file mode 100644
index 0000000000..8bba392803
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html
@@ -0,0 +1,3 @@
+<hr/>
+<p class="%messageClasses%"><span class="date date-next">%time%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/papersheets/Info.plist b/comm/mail/components/im/messages/papersheets/Info.plist
new file mode 100644
index 0000000000..420ceb5498
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Info.plist
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%sender% %message%</string>
+
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+
+ <key>CFBundleGetInfoString</key>
+ <string>Instantbird PaperSheets Message Style</string>
+
+ <key>CFBundleIdentifier</key>
+ <string>org.instantbird.papersheets.message.style</string>
+
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+
+ <key>CFBundleName</key>
+ <string>PaperSheets</string>
+
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+
+ <key>DefaultBackgroundColor</key>
+ <string>FFFFFF</string>
+
+ <key>DisableCustomBackground</key>
+ <false/>
+
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+
+ <key>ShowsUserIcons</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/papersheets/NextStatus.html b/comm/mail/components/im/messages/papersheets/NextStatus.html
new file mode 100644
index 0000000000..b72b0f30ba
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/NextStatus.html
@@ -0,0 +1,2 @@
+<p class="%messageClasses%"><span class="date">%time%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/papersheets/Status.html b/comm/mail/components/im/messages/papersheets/Status.html
new file mode 100644
index 0000000000..2f1c524a51
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Status.html
@@ -0,0 +1,4 @@
+<div class="messages-group %messageClasses%">
+<p class="%messageClasses%"><span class="date">%time%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
+</div>
diff --git a/comm/mail/components/im/messages/papersheets/Variants/White.css b/comm/mail/components/im/messages/papersheets/Variants/White.css
new file mode 100644
index 0000000000..c0221a94fc
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Variants/White.css
@@ -0,0 +1,22 @@
+/* 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/. */
+
+div.outgoing {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 255, 1) 15px, rgba(255, 255, 255, 1)) !important;
+}
+
+div.incoming {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 255, 1) 15px, rgba(255, 255, 255, 1)) !important;
+}
+
+
+
+/* used by javascript */
+.outgoing-color {
+ background-color: rgb(255, 255, 255);
+}
+
+.incoming-color {
+ background-color: rgb(255, 255, 255);
+}
diff --git a/comm/mail/components/im/messages/papersheets/inline.js b/comm/mail/components/im/messages/papersheets/inline.js
new file mode 100644
index 0000000000..5c711a34fb
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/inline.js
@@ -0,0 +1,81 @@
+/* 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 bg_gradient =
+ "background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, hsla(#, 100%, 98%, 1) 15px, hsla(#, 100%, 98%, 1));";
+const bg_context_gradient =
+ "background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.05) 15px, hsla(#, 20%, 98%, 1) 15px, hsla(#, 20%, 98%, 1));";
+const bg_color = "background-color: hsl(#, 100%, 98%);";
+
+var body = document.getElementById("ibcontent");
+
+function setColors(target) {
+ var senderColor = target.getAttribute("data-senderColor");
+
+ if (senderColor) {
+ var regexp =
+ /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/;
+ var parsed = regexp.exec(senderColor);
+
+ if (parsed) {
+ var senderHue = parsed[1];
+ if (target.classList.contains("context")) {
+ target.setAttribute(
+ "style",
+ bg_context_gradient.replace(/#/g, senderHue)
+ );
+ } else {
+ target.setAttribute("style", bg_gradient.replace(/#/g, senderHue));
+ }
+ }
+ }
+
+ if (body.scrollHeight <= screen.height) {
+ if (senderHue) {
+ body.setAttribute("style", bg_color.replace("#", senderHue));
+ } else if (target.classList.contains("outgoing")) {
+ body.className = "outgoing-color";
+ body.removeAttribute("style");
+ } else if (target.classList.contains("incoming")) {
+ body.className = "incoming-color";
+ body.removeAttribute("style");
+ } else if (target.classList.contains("event")) {
+ body.className = "event-color";
+ body.removeAttribute("style");
+ }
+ }
+}
+
+function checkNewText(target) {
+ if (target.tagName == "DIV") {
+ setColors(target);
+ } else if (target.tagName == "P" && target.className == "event") {
+ let parent = target.parentNode;
+ // We need to start a group with this element if there are at least 3
+ // system messages and they aren't already grouped.
+ if (!parent?.grouped && parent?.querySelector("p.event:nth-of-type(3)")) {
+ var div = document.createElement("div");
+ div.className = "eventToggle";
+ div.addEventListener("click", event =>
+ event.target.parentNode.classList.toggle("hide-children")
+ );
+ parent.insertBefore(div, parent.querySelector("p.event:first-of-type"));
+ parent.classList.add("hide-children");
+ parent.grouped = true;
+ }
+ }
+}
+
+new MutationObserver(function (aMutations) {
+ for (let mutation of aMutations) {
+ for (let node of mutation.addedNodes) {
+ if (node instanceof HTMLElement) {
+ checkNewText(node);
+ }
+ }
+ }
+}).observe(document.getElementById("ibcontent"), {
+ childList: true,
+ subtree: true,
+});
diff --git a/comm/mail/components/im/messages/papersheets/main.css b/comm/mail/components/im/messages/papersheets/main.css
new file mode 100644
index 0000000000..af70637d4f
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/main.css
@@ -0,0 +1,208 @@
+/* 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/. */
+
+body {
+ margin: 0;
+ padding: 0;
+ color: #000;
+}
+
+p {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 0;
+}
+
+div.messages-group {
+ margin: -15px 0 0 0;
+ padding: 18px 5px 20px 5px;
+}
+
+div.outgoing {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(245, 245, 255, 1) 15px, rgba(245, 245, 255, 1));
+}
+
+div.incoming {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 245, 245, 1) 15px, rgba(255, 245, 245, 1));
+}
+
+div.event {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 240, 1) 15px, rgba(255, 255, 240, 1));
+}
+
+div.context+div.event {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.05) 15px, rgba(255, 255, 240, 1) 15px, rgba(255, 255, 240, 1));
+}
+
+div.context:not(:hover) > p {
+ opacity: 0.55;
+}
+
+div.messages-group:last-child {
+ padding-bottom: 10px;
+}
+
+div.messages-group > hr {
+ margin: 3px 50px 0px 20px;
+ background-color: rgba(0, 0, 0, 0.05);
+ height: 1px;
+ border: 0;
+}
+
+span.message-style {
+ margin: 2px 50px 0px 20px;
+ display: block;
+ float: none;
+}
+
+span.date {
+ color: rgba(0, 0, 0, 0.4);
+ font-size: smaller;
+ text-align: right;
+ float: inline-end;
+ display: block;
+}
+
+span.date-next {
+ opacity: 0.4;
+ margin-top: -6px;
+ -moz-transition-property: opacity;
+ -moz-transition-duration: 0.3s;
+}
+
+p:hover > span.date-next {
+ opacity: 1;
+}
+
+span.pseudo {
+ font-weight: bold;
+ float: none;
+ display: block;
+}
+
+p.outgoing > span.pseudo {
+ color: rgb(80,80,200);
+}
+
+p.incoming > span.pseudo {
+ color: rgb(200,80,80);
+}
+
+p.nick > span.message-style {
+ font-weight: bold;
+}
+
+p.action > span.message-style {
+ font-style: italic;
+}
+
+p.action > span.message-style::before {
+ content: "*** ";
+}
+
+p.event {
+ margin-left: 0px;
+ min-height: 16px;
+ background: url('Bitmaps/information.png') no-repeat top left;
+}
+
+p.event > span.message-style {
+ color: rgba(0, 0, 0, 0.4);
+}
+
+#Chat {
+ white-space: normal;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::after {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 11px;
+ opacity: 0.7;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-start: 4px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+/* used by javascript */
+.outgoing-color {
+ background-color: rgb(245, 245, 255);
+}
+
+.incoming-color {
+ background-color: rgb(255, 245, 245);
+}
+
+.event-color {
+ background-color: rgb(255, 255, 240);
+}
+
+.eventToggle {
+ margin-top: -2px;
+ margin-left: -4px;
+ height: 9px;
+ width: 9px;
+ cursor: pointer;
+ background: url('Bitmaps/minus.png') no-repeat left top;
+}
+
+.hide-children > .eventToggle {
+ background-image: url('Bitmaps/plus.png');
+}
+
+.hide-children > p.event:first-of-type > .message-style::after {
+ content: "[\2026]"; /* &hellip; */
+ margin-left: 1em;
+ color: #5a7ac6;
+ font-size: smaller;
+}
+
+.hide-children > p.event:not(:first-of-type,:last-of-type) {
+ display: none;
+}
+
+/* Adapt styles to narrow windows */
+@media all and (max-width: 400px) {
+ div.messages-group > hr {
+ margin-right: 0;
+ }
+
+ span.message-style {
+ margin-right: 0;
+ }
+
+ span.date-next {
+ display: none;
+ }
+}
+
+@media all and (max-width: 200px) {
+ span.date {
+ display: none;
+ }
+}
+
+/* Adapt styles when the window is very low */
+@media all and (max-height: 200px) {
+ div.messages-group {
+ padding-bottom: 8px;
+ }
+
+ div.messages-group:last-child {
+ padding-bottom: 8px;
+ }
+}
diff --git a/comm/mail/components/im/messages/simple/Incoming/Content.html b/comm/mail/components/im/messages/simple/Incoming/Content.html
new file mode 100644
index 0000000000..ed8630393a
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Incoming/Content.html
@@ -0,0 +1 @@
+<p class="%messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p>
diff --git a/comm/mail/components/im/messages/simple/Incoming/Context.html b/comm/mail/components/im/messages/simple/Incoming/Context.html
new file mode 100644
index 0000000000..8b0226d610
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Incoming/Context.html
@@ -0,0 +1 @@
+<p class="context %messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p>
diff --git a/comm/mail/components/im/messages/simple/Incoming/NextContext.html b/comm/mail/components/im/messages/simple/Incoming/NextContext.html
new file mode 100644
index 0000000000..8b0226d610
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Incoming/NextContext.html
@@ -0,0 +1 @@
+<p class="context %messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p>
diff --git a/comm/mail/components/im/messages/simple/Info.plist b/comm/mail/components/im/messages/simple/Info.plist
new file mode 100644
index 0000000000..f32f062d7d
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Info.plist
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%message%</string>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+ <key>CFBundleGetInfoString</key>
+ <string>Instantbird Minimal Message Style</string>
+ <key>CFBundleIdentifier</key>
+ <string>org.instantbird.minimal.message.style</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+ <key>CFBundleName</key>
+ <string>Minimal</string>
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+ <key>DefaultBackgroundColor</key>
+ <string>FFFFFF</string>
+ <key>DefaultVariant</key>
+ <string>Normal</string>
+ <key>DisableCustomBackground</key>
+ <false/>
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+ <key>ShowsUserIcons</key>
+ <true/>
+ <key>NoScript</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/simple/Status.html b/comm/mail/components/im/messages/simple/Status.html
new file mode 100644
index 0000000000..ce30b16cec
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Status.html
@@ -0,0 +1 @@
+<p aria-live="polite" class="%messageClasses%"><span class="date">%time%</span>%message%</p>
diff --git a/comm/mail/components/im/messages/simple/Variants/Dark.css b/comm/mail/components/im/messages/simple/Variants/Dark.css
new file mode 100644
index 0000000000..ea5f0b8f5b
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Variants/Dark.css
@@ -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/. */
+
+body {
+ background: #222;
+ color: #eee;
+}
+.outgoing .pseudo {
+ color: #7878dc;
+}
+.incoming .pseudo {
+ color: #dc7878;
+}
+.event {
+ color: #828282;
+}
+a {
+ color: #5497ea;
+}
+.context {
+ color: #b2b2b4;
+}
diff --git a/comm/mail/components/im/messages/simple/Variants/Normal.css b/comm/mail/components/im/messages/simple/Variants/Normal.css
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Variants/Normal.css
diff --git a/comm/mail/components/im/messages/simple/main.css b/comm/mail/components/im/messages/simple/main.css
new file mode 100644
index 0000000000..3baf44d1ab
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/main.css
@@ -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/. */
+
+#Chat {
+ white-space: normal;
+}
+
+.pseudo {
+ font-weight: bold;
+}
+
+.outgoing .pseudo {
+ color: rgb(80,80,200);
+}
+
+.incoming .pseudo {
+ color: rgb(200,80,80);
+}
+
+.date {
+ font-style: normal;
+ font-weight: normal;
+}
+
+span.date::after {
+ content: " - ";
+}
+
+.action > span.date::after {
+ content: " * ";
+}
+
+span.pseudo::after {
+ content: ": ";
+}
+
+.action > span.pseudo::after {
+ content: " ";
+}
+
+.event > span.pseudo::after {
+ content: none;
+}
+
+.event {
+ color: rgb(170,170,170);
+}
+
+.nick {
+ font-weight: bold;
+}
+
+.action {
+ font-style: italic;
+}
+
+.context {
+ color: rgb(91,91,91);
+}
+
+p.context > .pseudo,
+p.context .ib-nick {
+ opacity: 0.7;
+}
+
+p {
+ margin: 0px auto;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::before {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 10px;
+ opacity: 0.5;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-end: 4px;
+}
diff --git a/comm/mail/components/im/modules/ChatEncryption.sys.mjs b/comm/mail/components/im/modules/ChatEncryption.sys.mjs
new file mode 100644
index 0000000000..4206b3397d
--- /dev/null
+++ b/comm/mail/components/im/modules/ChatEncryption.sys.mjs
@@ -0,0 +1,157 @@
+/* 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 = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["messenger/otr/otrUI.ftl"], true)
+);
+
+function _str(id) {
+ return lazy.l10n.formatValueSync(id);
+}
+
+const STATE_STRING = {
+ [Ci.prplIConversation.ENCRYPTION_AVAILABLE]: "not-private",
+ [Ci.prplIConversation.ENCRYPTION_ENABLED]: "unverified",
+ [Ci.prplIConversation.ENCRYPTION_TRUSTED]: "private",
+};
+
+export const ChatEncryption = {
+ /**
+ * If OTR is enabled.
+ *
+ * @type {boolean}
+ */
+ get otrEnabled() {
+ if (!this.hasOwnProperty("_otrEnabled")) {
+ this._otrEnabled = Services.prefs.getBoolPref("chat.otr.enable");
+ }
+ return this._otrEnabled;
+ },
+ /**
+ * Check if the given protocol has encryption settings for accounts.
+ *
+ * @param {prplIProtocol} protocol - Protocol to check against.
+ * @returns {boolean} If encryption can be configured.
+ */
+ canConfigureEncryption(protocol) {
+ if (this.otrEnabled && lazy.OTRUI.enabled) {
+ return true;
+ }
+ return protocol.canEncrypt;
+ },
+ /**
+ * Check if the conversation should offer encryption settings.
+ *
+ * @param {prplIConversation} conversation
+ * @returns {boolean}
+ */
+ hasEncryptionActions(conversation) {
+ if (!conversation.isChat && this.otrEnabled && lazy.OTRUI.enabled) {
+ return true;
+ }
+ return (
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ );
+ },
+ /**
+ * Show and initialize the encryption selector in the conversation UI for the
+ * given conversation, if encryption is available.
+ *
+ * @param {DOMDocument} document
+ * @param {imIConversation} conversation
+ */
+ updateEncryptionButton(document, conversation) {
+ if (!this.hasEncryptionActions(conversation)) {
+ this.hideEncryptionButton(document);
+ }
+ if (
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ ) {
+ // OTR is not available if the conversation can natively encrypt
+ document.querySelector(".otr-start").hidden = true;
+ document.querySelector(".otr-end").hidden = true;
+ document.querySelector(".otr-auth").hidden = true;
+ lazy.OTRUI.hideAllOTRNotifications();
+
+ const actionsAvailable =
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_AVAILABLE;
+
+ document.querySelector(".protocol-encrypt").hidden = false;
+ document.querySelector(".protocol-encrypt").disabled = actionsAvailable;
+ document.querySelector(".encryption-container").hidden = false;
+
+ const trustStringLevel = STATE_STRING[conversation.encryptionState];
+ const otrButton = document.querySelector(".encryption-button");
+ otrButton.setAttribute(
+ "tooltiptext",
+ _str("state-generic-" + trustStringLevel)
+ );
+ otrButton.setAttribute(
+ "label",
+ _str("state-" + trustStringLevel + "-label")
+ );
+ otrButton.className = "encryption-button encryption-" + trustStringLevel;
+ } else if (!conversation.isChat && lazy.OTRUI.enabled) {
+ document.querySelector(".otr-start").hidden = false;
+ document.querySelector(".otr-end").hidden = false;
+ document.querySelector(".otr-auth").hidden = false;
+ lazy.OTRUI.updateOTRButton(conversation);
+ document.querySelector(".protocol-encrypt").hidden = true;
+ } else {
+ this.hideEncryptionButton(document);
+ }
+ },
+ /**
+ * Hide the encryption selector in the converstaion UI.
+ *
+ * @param {DOMDocument} document
+ */
+ hideEncryptionButton(document) {
+ document.querySelector(".encryption-container").hidden = true;
+ if (this.otrEnabled) {
+ lazy.OTRUI.hideOTRButton();
+ }
+ },
+ /**
+ * Verify identity of a participant of buddy.
+ *
+ * @param {DOMWindow} window - Window that the verification dialog attaches to.
+ * @param {prplIAccountBuddy|prplIConvChatBuddy} buddy - Buddy to verify.
+ */
+ verifyIdentity(window, buddy) {
+ if (!buddy.canVerifyIdentity) {
+ Promise.resolve();
+ }
+ buddy
+ .verifyIdentity()
+ .then(sessionVerification => {
+ window.openDialog(
+ "chrome://messenger/content/chat/verify.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen",
+ sessionVerification
+ );
+ })
+ .catch(error => {
+ // Only prplIAccountBuddy has a reference to the owner account.
+ if (buddy.account) {
+ buddy.account.prplAccount.wrappedJSObject.ERROR(error);
+ } else {
+ console.error(error);
+ }
+ });
+ },
+};
diff --git a/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs b/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs
new file mode 100644
index 0000000000..f97519ddea
--- /dev/null
+++ b/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs
@@ -0,0 +1,352 @@
+/* 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 { Gloda } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaPublic.jsm"
+);
+
+/**
+ * How much time boost should a 'score point' amount to? The authoritative,
+ * incontrivertible answer, across all time and space, is a week.
+ * Note that gloda stores conversation timestamps in seconds.
+ */
+// var FUZZSCORE_TIMESTAMP_FACTOR = 60 * 60 * 24 * 7;
+
+// var RANK_USAGE =
+// "glodaRank(matchinfo(imConversationsText), 1.0, 2.0, 2.0, 1.5, 1.5)";
+
+var DASCORE = "imConversations.time";
+// "(((" + RANK_USAGE + ") * " +
+// FUZZSCORE_TIMESTAMP_FACTOR +
+// ") + imConversations.time)";
+
+/**
+ * A new optimization decision we are making is that we do not want to carry
+ * around any data in our ephemeral tables that is not used for whittling the
+ * result set. The idea is that the btree page cache or OS cache is going to
+ * save us from the disk seeks and carrying around the extra data is just going
+ * to be CPU/memory churn that slows us down.
+ *
+ * Additionally, we try and avoid row lookups that would have their results
+ * discarded by the LIMIT. Because of limitations in FTS3 (which might
+ * be addressed in FTS4 by a feature request), we can't avoid the 'imConversations'
+ * lookup since that has the message's date and static notability but we can
+ * defer the 'imConversationsText' lookup.
+ *
+ * This is the access pattern we are after here:
+ * 1) Order the matches with minimized lookup and result storage costs.
+ * - The innermost MATCH does the doclist magic and provides us with
+ * matchinfo() support which does not require content row retrieval
+ * from imConversationsText. Unfortunately, this is not enough to whittle anything
+ * because we still need static interestingness, so...
+ * - Based on the match we retrieve the date and notability for that row from
+ * 'imConversations' using this in conjunction with matchinfo() to provide a score
+ * that we can then use to LIMIT our results.
+ * 2) We reissue the MATCH query so that we will be able to use offsets(), but
+ * we intersect the results of this MATCH against our LIMITed results from
+ * step 1.
+ * - We use 'docid IN (phase 1 query)' to accomplish this because it results in
+ * efficient lookup. If we just use a join, we get O(mn) performance because
+ * a cartesian join ends up being performed where either we end up performing
+ * the fulltext query M times and table scan intersect with the results from
+ * phase 1 or we do the fulltext once but traverse the entire result set from
+ * phase 1 N times.
+ * - We believe that the re-execution of the MATCH query should have no disk
+ * costs because it should still be cached by SQLite or the OS. In the case
+ * where memory is so constrained this is not true our behavior is still
+ * probably preferable than the old way because that would have caused lots
+ * of swapping.
+ * - This part of the query otherwise resembles the basic gloda query but with
+ * the inclusion of the offsets() invocation. The imConversations table lookup
+ * should not involve any disk traffic because the pages should still be
+ * cached (SQLite or OS) from phase 1. The imConversationsText lookup is new, and
+ * this is the major disk-seek reduction optimization we are making. (Since
+ * we avoid this lookup for all of the documents that were excluded by the
+ * LIMIT.) Since offsets() also needs to retrieve the row from imConversationsText
+ * there is a nice synergy there.
+ */
+var NUEVO_FULLTEXT_SQL =
+ "SELECT imConversations.*, imConversationsText.*, offsets(imConversationsText) AS osets " +
+ "FROM imConversationsText, imConversations " +
+ "WHERE" +
+ " imConversationsText MATCH ?1 " +
+ " AND imConversationsText.docid IN (" +
+ "SELECT docid " +
+ "FROM imConversationsText JOIN imConversations ON imConversationsText.docid = imConversations.id " +
+ "WHERE imConversationsText MATCH ?1 " +
+ "ORDER BY " +
+ DASCORE +
+ " DESC " +
+ "LIMIT ?2" +
+ " )" +
+ " AND imConversations.id = imConversationsText.docid";
+
+function identityFunc(x) {
+ return x;
+}
+
+function oneLessMaxZero(x) {
+ if (x <= 1) {
+ return 0;
+ }
+ return x - 1;
+}
+
+function reduceSum(accum, curValue) {
+ return accum + curValue;
+}
+
+/*
+ * Columns are: body, subject, attachment names, author, recipients
+ */
+
+/**
+ * Scores if all search terms match in a column. We bias against author
+ * slightly and recipient a bit more in this case because a search that
+ * entirely matches just on a person should give a mention of that person
+ * in the subject or attachment a fighting chance.
+ * Keep in mind that because of our indexing in the face of address book
+ * contacts (namely, we index the name used in the e-mail as well as the
+ * display name on the address book card associated with the e-mail address)
+ * a contact is going to bias towards matching multiple times.
+ */
+var COLUMN_ALL_MATCH_SCORES = [4, 20, 20, 16, 12];
+/**
+ * Score for each distinct term that matches in the column. This is capped
+ * by COLUMN_ALL_SCORES.
+ */
+var COLUMN_PARTIAL_PER_MATCH_SCORES = [1, 4, 4, 4, 3];
+/**
+ * If a term matches multiple times, what is the marginal score for each
+ * additional match. We count the total number of matches beyond the
+ * first match for each term. In other words, if we have 3 terms which
+ * matched 5, 3, and 0 times, then the total from our perspective is
+ * (5 - 1) + (3 - 1) + 0 = 4 + 2 + 0 = 6. We take the minimum of that value
+ * and the value in COLUMN_MULTIPLE_MATCH_LIMIT and multiply by the value in
+ * COLUMN_MULTIPLE_MATCH_SCORES.
+ */
+var COLUMN_MULTIPLE_MATCH_SCORES = [1, 0, 0, 0, 0];
+var COLUMN_MULTIPLE_MATCH_LIMIT = [10, 0, 0, 0, 0];
+
+/**
+ * Score the message on its offsets (from stashedColumns).
+ */
+function scoreOffsets(aMessage, aContext) {
+ let score = 0;
+
+ let termTemplate = aContext.terms.map(_ => 0);
+ // for each column, a list of the incidence of each term
+ let columnTermIncidence = [
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ ];
+
+ // we need a friendlyParseInt because otherwise the radix stuff happens
+ // because of the extra arguments map parses. curse you, map!
+ let offsetNums = aContext.stashedColumns[aMessage.id][0]
+ .split(" ")
+ .map(x => parseInt(x));
+ for (let i = 0; i < offsetNums.length; i += 4) {
+ let columnIndex = offsetNums[i];
+ let termIndex = offsetNums[i + 1];
+ columnTermIncidence[columnIndex][termIndex]++;
+ }
+
+ for (let iColumn = 0; iColumn < COLUMN_ALL_MATCH_SCORES.length; iColumn++) {
+ let termIncidence = columnTermIncidence[iColumn];
+ if (termIncidence.every(identityFunc)) {
+ // Bestow all match credit.
+ score += COLUMN_ALL_MATCH_SCORES[iColumn];
+ } else if (termIncidence.some(identityFunc)) {
+ // Bestow partial match credit.
+ score += Math.min(
+ COLUMN_ALL_MATCH_SCORES[iColumn],
+ COLUMN_PARTIAL_PER_MATCH_SCORES[iColumn] *
+ termIncidence.filter(identityFunc).length
+ );
+ }
+ // bestow multiple match credit
+ score +=
+ Math.min(
+ termIncidence.map(oneLessMaxZero).reduce(reduceSum, 0),
+ COLUMN_MULTIPLE_MATCH_LIMIT[iColumn]
+ ) * COLUMN_MULTIPLE_MATCH_SCORES[iColumn];
+ }
+
+ return score;
+}
+
+/**
+ * The searcher basically looks like a query, but is specialized for fulltext
+ * search against imConversations. Most of the explicit specialization involves
+ * crafting a SQL query that attempts to order the matches by likelihood that
+ * the user was looking for it. This is based on full-text matches combined
+ * with an explicit (generic) interest score value placed on the message at
+ * indexing time (TODO). This is followed by using the more generic gloda scoring
+ * mechanism to explicitly score the IM conversations given the search context in
+ * addition to the more generic score adjusting rules.
+ */
+export function GlodaIMSearcher(aListener, aSearchString, aAndTerms) {
+ this.listener = aListener;
+
+ this.searchString = aSearchString;
+ this.fulltextTerms = this.parseSearchString(aSearchString);
+ this.andTerms = aAndTerms != null ? aAndTerms : true;
+
+ this.query = null;
+ this.collection = null;
+
+ this.scores = null;
+}
+
+GlodaIMSearcher.prototype = {
+ /**
+ * Number of messages to retrieve initially.
+ */
+ get retrievalLimit() {
+ return Services.prefs.getIntPref(
+ "mailnews.database.global.search.im.limit"
+ );
+ },
+
+ /**
+ * Parse the string into terms/phrases by finding matching double-quotes.
+ */
+ parseSearchString(aSearchString) {
+ aSearchString = aSearchString.trim();
+ let terms = [];
+
+ /*
+ * Add the term as long as the trim on the way in didn't obliterate it.
+ *
+ * In the future this might have other helper logic; it did once before.
+ */
+ function addTerm(aTerm) {
+ if (aTerm) {
+ terms.push(aTerm);
+ }
+ }
+
+ while (aSearchString) {
+ if (aSearchString.startsWith('"')) {
+ let endIndex = aSearchString.indexOf(aSearchString[0], 1);
+ // eat the quote if it has no friend
+ if (endIndex == -1) {
+ aSearchString = aSearchString.substring(1);
+ continue;
+ }
+
+ addTerm(aSearchString.substring(1, endIndex).trim());
+ aSearchString = aSearchString.substring(endIndex + 1);
+ continue;
+ }
+
+ let spaceIndex = aSearchString.indexOf(" ");
+ if (spaceIndex == -1) {
+ addTerm(aSearchString);
+ break;
+ }
+
+ addTerm(aSearchString.substring(0, spaceIndex));
+ aSearchString = aSearchString.substring(spaceIndex + 1);
+ }
+
+ return terms;
+ },
+
+ buildFulltextQuery() {
+ let query = Gloda.newQuery(Gloda.lookupNoun("im-conversation"), {
+ noMagic: true,
+ explicitSQL: NUEVO_FULLTEXT_SQL,
+ limitClauseAlreadyIncluded: true,
+ // osets is 0-based column number 4 (volatile to column changes)
+ // save the offset column for extra analysis
+ stashColumns: [6],
+ });
+
+ let fulltextQueryString = "";
+
+ for (let [iTerm, term] of this.fulltextTerms.entries()) {
+ if (iTerm) {
+ fulltextQueryString += this.andTerms ? " " : " OR ";
+ }
+
+ // Put our term in quotes. This is needed for the tokenizer to be able
+ // to do useful things. The exception is people clever enough to use
+ // NEAR.
+ if (/^NEAR(\/\d+)?$/.test(term)) {
+ fulltextQueryString += term;
+ } else if (term.length == 1 && term.charCodeAt(0) >= 0x2000) {
+ // This is a single-character CJK search query, so add a wildcard.
+ // Our tokenizer treats anything at/above 0x2000 as CJK for now.
+ fulltextQueryString += term + "*";
+ } else if (
+ (term.length == 2 &&
+ term.charCodeAt(0) >= 0x2000 &&
+ term.charCodeAt(1) >= 0x2000) ||
+ term.length >= 3
+ ) {
+ fulltextQueryString += '"' + term + '"';
+ }
+ }
+
+ query.fulltextMatches(fulltextQueryString);
+ query.limit(this.retrievalLimit);
+
+ return query;
+ },
+
+ getCollection(aListenerOverride, aData) {
+ if (aListenerOverride) {
+ this.listener = aListenerOverride;
+ }
+
+ this.query = this.buildFulltextQuery();
+ this.collection = this.query.getCollection(this, aData);
+ this.completed = false;
+
+ return this.collection;
+ },
+
+ sortBy: "-dascore",
+
+ onItemsAdded(aItems, aCollection) {
+ let newScores = Gloda.scoreNounItems(
+ aItems,
+ {
+ terms: this.fulltextTerms,
+ stashedColumns: aCollection.stashedColumns,
+ },
+ [scoreOffsets]
+ );
+ if (this.scores) {
+ this.scores = this.scores.concat(newScores);
+ } else {
+ this.scores = newScores;
+ }
+
+ if (this.listener) {
+ this.listener.onItemsAdded(aItems, aCollection);
+ }
+ },
+ onItemsModified(aItems, aCollection) {
+ if (this.listener) {
+ this.listener.onItemsModified(aItems, aCollection);
+ }
+ },
+ onItemsRemoved(aItems, aCollection) {
+ if (this.listener) {
+ this.listener.onItemsRemoved(aItems, aCollection);
+ }
+ },
+ onQueryCompleted(aCollection) {
+ this.completed = true;
+ if (this.listener) {
+ this.listener.onQueryCompleted(aCollection);
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/chatHandler.sys.mjs b/comm/mail/components/im/modules/chatHandler.sys.mjs
new file mode 100644
index 0000000000..4b54535aa5
--- /dev/null
+++ b/comm/mail/components/im/modules/chatHandler.sys.mjs
@@ -0,0 +1,106 @@
+/* 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 { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+export var allContacts = {};
+export var onlineContacts = {};
+
+export var ChatCore = {
+ initialized: false,
+ _initializing: false,
+ init() {
+ if (this._initializing) {
+ return;
+ }
+ this._initializing = true;
+
+ Services.obs.addObserver(this, "browser-request");
+ Services.obs.addObserver(this, "contact-signed-on");
+ Services.obs.addObserver(this, "contact-signed-off");
+ Services.obs.addObserver(this, "contact-added");
+ Services.obs.addObserver(this, "contact-removed");
+ },
+ idleStart() {
+ IMServices.core.init();
+
+ // Find the accounts that exist in the im account service but
+ // not in nsMsgAccountManager. They have probably been lost if
+ // the user has used an older version of Thunderbird on a
+ // profile with IM accounts. See bug 736035.
+ let accountsById = {};
+ for (let account of IMServices.accounts.getAccounts()) {
+ accountsById[account.numericId] = account;
+ }
+ for (let account of MailServices.accounts.accounts) {
+ let incomingServer = account.incomingServer;
+ if (!incomingServer || incomingServer.type != "im") {
+ continue;
+ }
+ delete accountsById[incomingServer.wrappedJSObject.imAccount.numericId];
+ }
+ // Let's recreate each of them...
+ for (let id in accountsById) {
+ let account = accountsById[id];
+ let inServer = MailServices.accounts.createIncomingServer(
+ account.name,
+ account.protocol.id, // hostname
+ "im"
+ );
+ inServer.wrappedJSObject.imAccount = account;
+ let acc = MailServices.accounts.createAccount();
+ // Avoid new folder notifications.
+ inServer.valid = false;
+ acc.incomingServer = inServer;
+ inServer.valid = true;
+ MailServices.accounts.notifyServerLoaded(inServer);
+ }
+
+ IMServices.tags.getTags().forEach(function (aTag) {
+ aTag.getContacts().forEach(function (aContact) {
+ let name = aContact.preferredBuddy.normalizedName;
+ allContacts[name] = aContact;
+ });
+ });
+
+ ChatCore.initialized = true;
+ Services.obs.notifyObservers(null, "chat-core-initialized");
+ ChatCore._initializing = false;
+ },
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "browser-request") {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/browserRequest.xhtml",
+ null,
+ "chrome,private,centerscreen,width=980,height=750",
+ aSubject
+ );
+ return;
+ }
+
+ if (aTopic == "contact-signed-on") {
+ onlineContacts[aSubject.preferredBuddy.normalizedName] = aSubject;
+ return;
+ }
+
+ if (aTopic == "contact-signed-off") {
+ delete onlineContacts[aSubject.preferredBuddy.normalizedName];
+ return;
+ }
+
+ if (aTopic == "contact-added") {
+ allContacts[aSubject.preferredBuddy.normalizedName] = aSubject;
+ return;
+ }
+
+ if (aTopic == "contact-removed") {
+ delete allContacts[aSubject.preferredBuddy.normalizedName];
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/chatIcons.sys.mjs b/comm/mail/components/im/modules/chatIcons.sys.mjs
new file mode 100644
index 0000000000..e965c23183
--- /dev/null
+++ b/comm/mail/components/im/modules/chatIcons.sys.mjs
@@ -0,0 +1,106 @@
+/* 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/. */
+
+export var ChatIcons = {
+ /**
+ * Get the icon URI for the given protocol.
+ *
+ * @param {prplIProtocol} protocol - The protocol to get the icon URI for.
+ * @param {16|32|48} [size=16] - The width and height of the icon.
+ *
+ * @returns {string} - The icon's URI.
+ */
+ getProtocolIconURI(protocol, size = 16) {
+ return `${protocol.iconBaseURI}icon${size === 16 ? "" : size}.png`;
+ },
+
+ /**
+ * Sets the opacity of the given protocol icon depending on the given chat
+ * status (see getStatusIconURI).
+ *
+ * @param {HTMLImageElement} protoIconElement - The protocol icon.
+ * @param {string} statusName - The name for the chat status.
+ */
+ setProtocolIconOpacity(protoIconElement, statusName) {
+ switch (statusName) {
+ case "unknown":
+ case "offline":
+ case "left":
+ protoIconElement.classList.add("protoIconDimmed");
+ break;
+ default:
+ protoIconElement.classList.remove("protoIconDimmed");
+ }
+ },
+
+ fallbackUserIconURI: "chrome://messenger/skin/icons/userIcon.svg",
+
+ /**
+ * Set up the user icon to show the given uri, or a fallback.
+ *
+ * @param {HTMLImageElement} userIconElement - An icon with the "userIcon"
+ * class.
+ * @param {string|null} iconUri - The uri to set, or "" to use a fallback
+ * icon, or null to hide the icon.
+ * @param {boolean} useFallback - True if the "fallback" icon should be shown
+ * if iconUri isn't provided.
+ */
+ setUserIconSrc(userIconElement, iconUri, useFallback) {
+ if (iconUri) {
+ userIconElement.setAttribute("src", iconUri);
+ userIconElement.classList.remove("fillUserIcon");
+ } else if (useFallback) {
+ userIconElement.setAttribute("src", this.fallbackUserIconURI);
+ userIconElement.classList.add("fillUserIcon");
+ } else {
+ userIconElement.removeAttribute("src");
+ userIconElement.classList.remove("fillUserIcon");
+ }
+ },
+
+ /**
+ * Get the icon URI for the given chat status. Often given statusName would be
+ * the return of Status.toAttribute for a given status type. But a few more
+ * terms or aliases are supported.
+ *
+ * @param {string} statusName - The name for the chat status.
+ *
+ * @returns {string|null} - The icon URI for the given status, or null if none
+ * exists.
+ */
+ getStatusIconURI(statusName) {
+ switch (statusName) {
+ case "unknown":
+ return "chrome://chat/skin/unknown.svg";
+ case "available":
+ case "connected":
+ return "chrome://messenger/skin/icons/new/status-online.svg";
+ case "unavailable":
+ case "away":
+ return "chrome://messenger/skin/icons/new/status-away.svg";
+ case "offline":
+ case "disconnected":
+ case "invisible":
+ case "left":
+ return "chrome://messenger/skin/icons/new/status-offline.svg";
+ case "connecting":
+ case "disconnecting":
+ case "joining":
+ return "chrome://global/skin/icons/loading.png";
+ case "idle":
+ return "chrome://messenger/skin/icons/new/status-idle.svg";
+ case "mobile":
+ return "chrome://chat/skin/mobile.svg";
+ case "chat":
+ return "chrome://messenger/skin/icons/new/compact/chat.svg";
+ case "chat-left":
+ return "chrome://chat/skin/chat-left.svg";
+ case "active-typing":
+ return "chrome://chat/skin/typing.svg";
+ case "paused-typing":
+ return "chrome://chat/skin/typed.svg";
+ }
+ return null;
+ },
+};
diff --git a/comm/mail/components/im/modules/chatNotifications.sys.mjs b/comm/mail/components/im/modules/chatNotifications.sys.mjs
new file mode 100644
index 0000000000..664fe4e5ca
--- /dev/null
+++ b/comm/mail/components/im/modules/chatNotifications.sys.mjs
@@ -0,0 +1,262 @@
+/* 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 { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { PluralForm } from "resource://gre/modules/PluralForm.sys.mjs";
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { ChatIcons } from "resource:///modules/chatIcons.sys.mjs";
+
+// Time in seconds: it is the minimum time of inactivity
+// needed to show the bundled notification.
+var kTimeToWaitForMoreMsgs = 3;
+
+export var Notifications = {
+ get ellipsis() {
+ let ellipsis = "[\u2026]";
+
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ return ellipsis;
+ },
+
+ // Holds the first direct message of a bundle while we wait for further
+ // messages from the same sender to arrive.
+ _heldMessage: null,
+ // Number of messages to be bundled in the notification (excluding
+ // _heldMessage).
+ _msgCounter: 0,
+ // Time the last message was received.
+ _lastMessageTime: 0,
+ // Sender of the last message.
+ _lastMessageSender: null,
+ // timeout Id for the set timeout for showing notification.
+ _timeoutId: null,
+
+ _showMessageNotification(aMessage, aCounter = 0) {
+ // We are about to show the notification, so let's play the notification sound.
+ // We play the sound if the user is away from TB window or even away from chat tab.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (
+ !Services.focus.activeWindow ||
+ win.document.getElementById("tabmail").currentTabInfo.mode.name != "chat"
+ ) {
+ Services.obs.notifyObservers(aMessage, "play-chat-notification-sound");
+ }
+
+ // If TB window has focus, there's no need to show the notification..
+ if (win && win.document.hasFocus()) {
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ return;
+ }
+
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ let messageText, icon, name;
+ let notificationContent = Services.prefs.getIntPref(
+ "mail.chat.notification_info"
+ );
+ // 0 - show all the info,
+ // 1 - show only the sender not the message,
+ // 2 - show no details about the message being notified.
+ switch (notificationContent) {
+ case 0:
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(aMessage.displayMessage, "text/html");
+ let body = doc.querySelector("body");
+ let encoder = Cu.createDocumentEncoder("text/plain");
+ encoder.init(doc, "text/plain", 0);
+ encoder.setNode(body);
+ messageText = encoder.encodeToString().replace(/\s+/g, " ");
+
+ // Crop the end of the text if needed.
+ if (messageText.length > 50) {
+ messageText = messageText.substr(0, 50);
+ if (aCounter == 0) {
+ messageText = messageText + this.ellipsis;
+ }
+ }
+
+ // If there are more messages being bundled, add the count string.
+ // ellipsis is a part of bundledMessagePreview so we don't include it here.
+ if (aCounter > 0) {
+ let bundledMessage = bundle.formatStringFromName(
+ "bundledMessagePreview",
+ [messageText]
+ );
+ messageText = PluralForm.get(aCounter, bundledMessage).replace(
+ "#1",
+ aCounter
+ );
+ }
+ // Falls through
+ case 1:
+ // Use the buddy icon if available for the icon of the notification.
+ let conv = aMessage.conversation;
+ icon = conv.convIconFilename;
+ if (!icon && !conv.isChat) {
+ icon = conv.buddy?.buddyIconFilename;
+ }
+
+ // Handle third person messages
+ name = aMessage.alias || aMessage.who;
+ if (messageText && aMessage.action) {
+ messageText = name + " " + messageText;
+ }
+ // Falls through
+ case 2:
+ if (!icon) {
+ icon = ChatIcons.fallbackUserIconURI;
+ }
+
+ if (!messageText) {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ messageText = bundle.GetStringFromName("messagePreview");
+ }
+ }
+
+ let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(
+ Ci.nsIAlertNotification
+ );
+ alert.init(
+ "", // name
+ icon,
+ name, // title
+ messageText,
+ true // clickable
+ );
+ // Show the notification!
+ Cc["@mozilla.org/alerts-service;1"]
+ .getService(Ci.nsIAlertsService)
+ .showAlert(alert, (subject, topic, data) => {
+ if (topic != "alertclickcallback") {
+ return;
+ }
+
+ // If there is a timeout set, clear it.
+ clearTimeout(this._timeoutId);
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ this._lastMessageTime = 0;
+ this._lastMessageSender = null;
+ // Focus the conversation if the notification is clicked.
+ let uiConv = IMServices.conversations.getUIConversation(
+ aMessage.conversation
+ );
+ let mainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mainWindow) {
+ mainWindow.focus();
+ mainWindow.showChatTab();
+ mainWindow.chatHandler.focusConversation(uiConv);
+ } else {
+ Services.appShell.hiddenDOMWindow.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType: "chat",
+ tabParams: { convType: "focus", conv: uiConv },
+ }
+ );
+ }
+ if (AppConstants.platform == "macosx") {
+ Cc["@mozilla.org/widget/macdocksupport;1"]
+ .getService(Ci.nsIMacDockSupport)
+ .activateApplication(true);
+ }
+ });
+
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ },
+
+ init() {
+ Services.obs.addObserver(Notifications, "new-otr-verification-request");
+ Services.obs.addObserver(Notifications, "new-directed-incoming-message");
+ Services.obs.addObserver(Notifications, "alertclickcallback");
+ },
+
+ _notificationPrefName: "mail.chat.show_desktop_notifications",
+ observe(aSubject, aTopic, aData) {
+ if (!Services.prefs.getBoolPref(this._notificationPrefName)) {
+ return;
+ }
+
+ switch (aTopic) {
+ case "new-directed-incoming-message":
+ // If this is the first message, we show the notification and
+ // store the sender's name.
+ let sender = aSubject.who || aSubject.alias;
+ if (this._lastMessageSender == null) {
+ this._lastMessageSender = sender;
+ this._lastMessageTime = aSubject.time;
+ this._showMessageNotification(aSubject);
+ } else if (
+ this._lastMessageSender != sender ||
+ aSubject.time > this._lastMessageTime + kTimeToWaitForMoreMsgs
+ ) {
+ // If the sender is not the same as the previous sender or the
+ // time elapsed since the last message is greater than kTimeToWaitForMoreMsgs,
+ // we show the held notification and set timeout for the message just arrived.
+ if (this._heldMessage) {
+ // if the time for the current message is greater than _lastMessageTime by
+ // more than kTimeToWaitForMoreMsgs, this will not happen since the notification will
+ // have already been dispatched.
+ clearTimeout(this._timeoutId);
+ this._showMessageNotification(this._heldMessage, this._msgCounter);
+ }
+ this._lastMessageSender = sender;
+ this._lastMessageTime = aSubject.time;
+ this._showMessageNotification(aSubject);
+ } else if (
+ this._lastMessageSender == sender &&
+ this._lastMessageTime + kTimeToWaitForMoreMsgs >= aSubject.time
+ ) {
+ // If the sender is same as the previous sender and the time elapsed since the
+ // last held message is less than kTimeToWaitForMoreMsgs, we increase the held messages
+ // counter and update the last message's arrival time.
+ this._lastMessageTime = aSubject.time;
+ if (!this._heldMessage) {
+ this._heldMessage = aSubject;
+ } else {
+ this._msgCounter++;
+ }
+
+ clearTimeout(this._timeoutId);
+ this._timeoutId = setTimeout(() => {
+ this._showMessageNotification(this._heldMessage, this._msgCounter);
+ }, kTimeToWaitForMoreMsgs * 1000);
+ }
+ break;
+
+ case "new-otr-verification-request":
+ // If the Chat tab is not focused, play the sounds and update the icon
+ // counter, and show the counter in the buddy richlistitem.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (
+ !Services.focus.activeWindow ||
+ win.document.getElementById("tabmail").currentTabInfo.mode.name !=
+ "chat"
+ ) {
+ Services.obs.notifyObservers(
+ aSubject,
+ "play-chat-notification-sound"
+ );
+ }
+
+ break;
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/index_im.sys.mjs b/comm/mail/components/im/modules/index_im.sys.mjs
new file mode 100644
index 0000000000..bcea54e1ea
--- /dev/null
+++ b/comm/mail/components/im/modules/index_im.sys.mjs
@@ -0,0 +1,928 @@
+/* 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 CC = Components.Constructor;
+
+const { Gloda } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaPublic.jsm"
+);
+const { GlodaAccount } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaDataModel.jsm"
+);
+const { GlodaConstants } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+const { GlodaIndexer, IndexingJob } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaIndexer.jsm"
+);
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaDatastore",
+ "resource:///modules/gloda/GlodaDatastore.jsm"
+);
+
+var kCacheFileName = "indexedFiles.json";
+
+var FileInputStream = CC(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+var ScriptableInputStream = CC(
+ "@mozilla.org/scriptableinputstream;1",
+ "nsIScriptableInputStream",
+ "init"
+);
+
+// kIndexingDelay is how long we wait from the point of scheduling an indexing
+// job to actually carrying it out.
+var kIndexingDelay = 5000; // in milliseconds
+
+XPCOMUtils.defineLazyGetter(lazy, "MailFolder", () =>
+ Cc["@mozilla.org/mail/folder-factory;1?name=mailbox"].createInstance(
+ Ci.nsIMsgFolder
+ )
+);
+
+var gIMAccounts = {};
+
+function GlodaIMConversation(aTitle, aTime, aPath, aContent) {
+ // grokNounItem from Gloda.jsm puts automatically the values of all
+ // JS properties in the jsonAttributes magic attribute, except if
+ // they start with _, so we put the values in _-prefixed properties,
+ // and have getters in the prototype.
+ this._title = aTitle;
+ this._time = aTime;
+ this._path = aPath;
+ this._content = aContent;
+}
+GlodaIMConversation.prototype = {
+ get title() {
+ return this._title;
+ },
+ get time() {
+ return this._time;
+ },
+ get path() {
+ return this._path;
+ },
+ get content() {
+ return this._content;
+ },
+
+ // for glodaFacetBindings.xml compatibility (pretend we are a message object)
+ get account() {
+ let [protocol, username] = this._path.split("/", 2);
+
+ let cacheName = protocol + "/" + username;
+ if (cacheName in gIMAccounts) {
+ return gIMAccounts[cacheName];
+ }
+
+ // Find the nsIIncomingServer for the current imIAccount.
+ for (let account of MailServices.accounts.accounts) {
+ let incomingServer = account.incomingServer;
+ if (!incomingServer || incomingServer.type != "im") {
+ continue;
+ }
+ let imAccount = incomingServer.wrappedJSObject.imAccount;
+ if (
+ imAccount.protocol.normalizedName == protocol &&
+ imAccount.normalizedName == username
+ ) {
+ return (gIMAccounts[cacheName] = new GlodaAccount(incomingServer));
+ }
+ }
+ // The IM conversation is probably for an account that no longer exists.
+ return null;
+ },
+ get subject() {
+ return this._title;
+ },
+ get date() {
+ return new Date(this._time * 1000);
+ },
+ get involves() {
+ return GlodaConstants.IGNORE_FACET;
+ },
+ _recipients: null,
+ get recipients() {
+ if (!this._recipients) {
+ this._recipients = [{ contact: { name: this._path.split("/", 2)[1] } }];
+ }
+ return this._recipients;
+ },
+ _from: null,
+ get from() {
+ if (!this._from) {
+ let from = "";
+ let account = this.account;
+ if (account) {
+ from = account.incomingServer.wrappedJSObject.imAccount.protocol.name;
+ }
+ this._from = { value: "", contact: { name: from } };
+ }
+ return this._from;
+ },
+ get tags() {
+ return [];
+ },
+ get starred() {
+ return false;
+ },
+ get attachmentNames() {
+ return null;
+ },
+ get indexedBodyText() {
+ return this._content;
+ },
+ get read() {
+ return true;
+ },
+ get folder() {
+ return GlodaConstants.IGNORE_FACET;
+ },
+
+ // for glodaFacetView.js _removeDupes
+ get headerMessageID() {
+ return this.id;
+ },
+};
+
+// FIXME
+var WidgetProvider = {
+ providerName: "widget",
+ *process() {
+ // XXX What is this supposed to do?
+ yield GlodaConstants.kWorkDone;
+ },
+};
+
+var IMConversationNoun = {
+ name: "im-conversation",
+ clazz: GlodaIMConversation,
+ allowsArbitraryAttrs: true,
+ tableName: "imConversations",
+ schema: {
+ columns: [
+ ["id", "INTEGER PRIMARY KEY"],
+ ["title", "STRING"],
+ ["time", "NUMBER"],
+ ["path", "STRING"],
+ ],
+ fulltextColumns: [["content", "STRING"]],
+ },
+};
+Gloda.defineNoun(IMConversationNoun);
+
+// Needs to be set after calling defineNoun, otherwise it's replaced
+// by GlodaDatabind.jsm' implementation.
+IMConversationNoun.objFromRow = function (aRow) {
+ // Row columns are:
+ // 0 id
+ // 1 title
+ // 2 time
+ // 3 path
+ // 4 jsonAttributes
+ // 5 content
+ // 6 offsets
+ let conv = new GlodaIMConversation(
+ aRow.getString(1),
+ aRow.getInt64(2),
+ aRow.getString(3),
+ aRow.getString(5)
+ );
+ conv.id = aRow.getInt64(0); // handleResult will keep only our first result
+ // if the id property isn't set.
+ return conv;
+};
+
+var EXT_NAME = "im";
+
+// --- special (on-row) attributes
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "time",
+ singular: true,
+ special: GlodaConstants.kSpecialColumn,
+ specialColumnName: "time",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_NUMBER,
+ canQuery: true,
+});
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "title",
+ singular: true,
+ special: GlodaConstants.kSpecialString,
+ specialColumnName: "title",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_STRING,
+ canQuery: true,
+});
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "path",
+ singular: true,
+ special: GlodaConstants.kSpecialString,
+ specialColumnName: "path",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_STRING,
+ canQuery: true,
+});
+
+// --- fulltext attributes
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "content",
+ singular: true,
+ special: GlodaConstants.kSpecialFulltext,
+ specialColumnName: "content",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_FULLTEXT,
+ canQuery: true,
+});
+
+// -- fulltext search helper
+// fulltextMatches. Match over message subject, body, and attachments
+// @testpoint gloda.noun.message.attr.fulltextMatches
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrDerived,
+ attributeName: "fulltextMatches",
+ singular: true,
+ special: GlodaConstants.kSpecialFulltext,
+ specialColumnName: "imConversationsText",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_FULLTEXT,
+});
+// For Facet.jsm DateFaceter
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrDerived,
+ attributeName: "date",
+ singular: true,
+ special: GlodaConstants.kSpecialColumn,
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_NUMBER,
+ facet: {
+ type: "date",
+ },
+ canQuery: true,
+});
+
+var GlodaIMIndexer = {
+ name: "index_im",
+ cacheVersion: 1,
+ enable() {
+ Services.obs.addObserver(this, "conversation-closed");
+ Services.obs.addObserver(this, "new-ui-conversation");
+ Services.obs.addObserver(this, "conversation-update-type");
+ Services.obs.addObserver(this, "ui-conversation-closed");
+ Services.obs.addObserver(this, "ui-conversation-replaced");
+
+ // The shutdown blocker ensures pending saves happen even if the app
+ // gets shut down before the timer fires.
+ if (this._shutdownBlockerAdded) {
+ return;
+ }
+ this._shutdownBlockerAdded = true;
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "GlodaIMIndexer cache save",
+ () => {
+ if (!this._cacheSaveTimer) {
+ return Promise.resolve();
+ }
+ clearTimeout(this._cacheSaveTimer);
+ return this._saveCacheNow();
+ }
+ );
+
+ this._knownFiles = {};
+
+ let dir = FileUtils.getFile("ProfD", ["logs"]);
+ if (!dir.exists() || !dir.isDirectory()) {
+ return;
+ }
+ let cacheFile = dir.clone();
+ cacheFile.append(kCacheFileName);
+ if (!cacheFile.exists()) {
+ return;
+ }
+
+ const PR_RDONLY = 0x01;
+ let fis = new FileInputStream(
+ cacheFile,
+ PR_RDONLY,
+ parseInt("0444", 8),
+ Ci.nsIFileInputStream.CLOSE_ON_EOF
+ );
+ let sis = new ScriptableInputStream(fis);
+ let text = sis.read(sis.available());
+ sis.close();
+
+ let data = JSON.parse(text);
+
+ // Check to see if the Gloda datastore ID matches the one that we saved
+ // in the cache. If so, we can trust it. If not, that means that the
+ // cache is likely invalid now, so we ignore it (and eventually
+ // overwrite it).
+ if (
+ "datastoreID" in data &&
+ Gloda.datastoreID &&
+ data.datastoreID === Gloda.datastoreID
+ ) {
+ // Ok, the cache's datastoreID matches the one we expected, so it's
+ // still valid.
+ this._knownFiles = data.knownFiles;
+ }
+
+ this.cacheVersion = data.version;
+
+ // If there was no version set on the cache, there is a chance that the index
+ // is affected by bug 1069845. fixEntriesWithAbsolutePaths() sets the version to 1.
+ if (!this.cacheVersion) {
+ this.fixEntriesWithAbsolutePaths();
+ }
+ },
+ disable() {
+ Services.obs.removeObserver(this, "conversation-closed");
+ Services.obs.removeObserver(this, "new-ui-conversation");
+ Services.obs.removeObserver(this, "conversation-update-type");
+ Services.obs.removeObserver(this, "ui-conversation-closed");
+ Services.obs.removeObserver(this, "ui-conversation-replaced");
+ },
+
+ /* _knownFiles is a tree whose leaves are the last modified times of
+ * log files when they were last indexed.
+ * Each level of the tree is stored as an object. The root node is an
+ * object that maps a protocol name to an object representing the subtree
+ * for that protocol. The structure is:
+ * _knownFiles -> protoObj -> accountObj -> convObj
+ * The corresponding keys of the above objects are:
+ * protocol names -> account names -> conv names -> file names -> last modified time
+ * convObj maps ALL previously indexed log files of a chat buddy or MUC to
+ * their last modified times. Note that gloda knows nothing about log grouping
+ * done by logger.js.
+ */
+ _knownFiles: {},
+ _cacheSaveTimer: null,
+ _shutdownBlockerAdded: false,
+ _scheduleCacheSave() {
+ if (this._cacheSaveTimer) {
+ return;
+ }
+ this._cacheSaveTimer = setTimeout(this._saveCacheNow, 5000);
+ },
+ _saveCacheNow() {
+ GlodaIMIndexer._cacheSaveTimer = null;
+
+ let data = {
+ knownFiles: GlodaIMIndexer._knownFiles,
+ datastoreID: Gloda.datastoreID,
+ version: GlodaIMIndexer.cacheVersion,
+ };
+
+ // Asynchronously copy the data to the file.
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs",
+ kCacheFileName
+ );
+ return IOUtils.writeJSON(path, data, {
+ tmpPath: path + ".tmp",
+ }).catch(aError => console.error("Failed to write cache file: " + aError));
+ },
+
+ _knownConversations: {},
+ // Promise queue for indexing jobs. The next indexing job is queued using this
+ // promise's then() to ensure we only load logs for one conv at a time.
+ _indexingJobPromise: null,
+ // Maps a conv id to the function that resolves the promise representing the
+ // ongoing indexing job on it. This is called from indexIMConversation when it
+ // finishes and will trigger the next queued indexing job.
+ _indexingJobCallbacks: new Map(),
+
+ _scheduleIndexingJob(aConversation) {
+ let convId = aConversation.id;
+
+ // If we've already scheduled this conversation to be indexed, let's
+ // not repeat.
+ if (!(convId in this._knownConversations)) {
+ this._knownConversations[convId] = {
+ id: convId,
+ scheduledIndex: null,
+ logFileCount: null,
+ convObj: {},
+ };
+ }
+
+ if (!this._knownConversations[convId].scheduledIndex) {
+ // Ok, let's schedule the job.
+ this._knownConversations[convId].scheduledIndex = setTimeout(
+ this._beginIndexingJob.bind(this, aConversation),
+ kIndexingDelay
+ );
+ }
+ },
+
+ _beginIndexingJob(aConversation) {
+ let convId = aConversation.id;
+
+ // In the event that we're triggering this indexing job manually, without
+ // bothering to schedule it (for example, when a conversation is closed),
+ // we give the conversation an entry in _knownConversations, which would
+ // normally have been done in _scheduleIndexingJob.
+ if (!(convId in this._knownConversations)) {
+ this._knownConversations[convId] = {
+ id: convId,
+ scheduledIndex: null,
+ logFileCount: null,
+ convObj: {},
+ };
+ }
+
+ let conv = this._knownConversations[convId];
+ (async () => {
+ // We need to get the log files every time, because a new log file might
+ // have been started since we last got them.
+ let logFiles = await IMServices.logs.getLogPathsForConversation(
+ aConversation
+ );
+ if (!logFiles || !logFiles.length) {
+ // No log files exist yet, nothing to do!
+ return;
+ }
+
+ if (conv.logFileCount == undefined) {
+ // We initialize the _knownFiles tree path for the current files below in
+ // case it doesn't already exist.
+ let folder = PathUtils.parent(logFiles[0]);
+ let convName = PathUtils.filename(folder);
+ folder = PathUtils.parent(folder);
+ let accountName = PathUtils.filename(folder);
+ folder = PathUtils.parent(folder);
+ let protoName = PathUtils.filename(folder);
+ if (
+ !Object.prototype.hasOwnProperty.call(this._knownFiles, protoName)
+ ) {
+ this._knownFiles[protoName] = {};
+ }
+ let protoObj = this._knownFiles[protoName];
+ if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) {
+ protoObj[accountName] = {};
+ }
+ let accountObj = protoObj[accountName];
+ if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) {
+ accountObj[convName] = {};
+ }
+
+ // convObj is the penultimate level of the tree,
+ // maps file name -> last modified time
+ conv.convObj = accountObj[convName];
+ conv.logFileCount = 0;
+ }
+
+ // The last log file in the array is the one currently being written to.
+ // When new log files are started, we want to finish indexing the previous
+ // one as well as index the new ones. The index of the previous one is
+ // conv.logFiles.length - 1, so we slice from there. This gives us all new
+ // log files even if there are multiple new ones.
+ let currentLogFiles =
+ conv.logFileCount > 1
+ ? logFiles.slice(conv.logFileCount - 1)
+ : logFiles;
+ for (let logFile of currentLogFiles) {
+ let fileName = PathUtils.filename(logFile);
+ let lastModifiedTime = (await IOUtils.stat(logFile)).lastModified;
+ if (
+ Object.prototype.hasOwnProperty.call(conv.convObj, fileName) &&
+ conv.convObj[fileName] == lastModifiedTime
+ ) {
+ // The file hasn't changed since we last indexed it, so we're done.
+ continue;
+ }
+
+ if (this._indexingJobPromise) {
+ await this._indexingJobPromise;
+ }
+ this._indexingJobPromise = new Promise(aResolve => {
+ this._indexingJobCallbacks.set(convId, aResolve);
+ });
+
+ let job = new IndexingJob("indexIMConversation", null);
+ job.conversation = conv;
+ job.path = logFile;
+ job.lastModifiedTime = lastModifiedTime;
+ GlodaIndexer.indexJob(job);
+ }
+ conv.logFileCount = logFiles.length;
+ })().catch(console.error);
+
+ // Now clear the job, so we can index in the future.
+ this._knownConversations[convId].scheduledIndex = null;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic == "new-ui-conversation" ||
+ aTopic == "conversation-update-type"
+ ) {
+ // Add ourselves to the ui-conversation's list of observers for the
+ // unread-message-count-changed notification.
+ // For this notification, aSubject is the ui-conversation that is opened.
+ aSubject.addObserver(this);
+ return;
+ }
+
+ if (
+ aTopic == "ui-conversation-closed" ||
+ aTopic == "ui-conversation-replaced"
+ ) {
+ aSubject.removeObserver(this);
+ return;
+ }
+
+ if (aTopic == "unread-message-count-changed") {
+ // We get this notification by attaching observers to conversations
+ // directly (see the new-ui-conversation handler for when we attach).
+ if (aSubject.unreadIncomingMessageCount == 0) {
+ // The unread message count changed to 0, meaning that a conversation
+ // that had been in the background and receiving messages was suddenly
+ // moved to the foreground and displayed to the user. We schedule an
+ // indexing job on this conversation now, since we want to index messages
+ // that the user has seen.
+ this._scheduleIndexingJob(aSubject.target);
+ }
+ return;
+ }
+
+ if (aTopic == "conversation-closed") {
+ let convId = aSubject.id;
+ // If there's a scheduled indexing job, cancel it, because we're going
+ // to index now.
+ if (
+ convId in this._knownConversations &&
+ this._knownConversations[convId].scheduledIndex != null
+ ) {
+ clearTimeout(this._knownConversations[convId].scheduledIndex);
+ }
+
+ this._beginIndexingJob(aSubject);
+ delete this._knownConversations[convId];
+ return;
+ }
+
+ if (aTopic == "new-text" && !aSubject.noLog) {
+ // Ok, some new text is about to be put into a conversation. For this
+ // notification, aSubject is a prplIMessage.
+ let conv = aSubject.conversation;
+ let uiConv = IMServices.conversations.getUIConversation(conv);
+
+ // We only want to schedule an indexing job if this message is
+ // immediately visible to the user. We figure this out by finding
+ // the unread message count on the associated UIConversation for this
+ // message. If the unread count is 0, we know that the message has been
+ // displayed to the user.
+ if (uiConv.unreadIncomingMessageCount == 0) {
+ this._scheduleIndexingJob(conv);
+ }
+ }
+ },
+
+ /* If there is an existing gloda conversation for the given path,
+ * find its id.
+ */
+ _getIdFromPath(aPath) {
+ let selectStatement = lazy.GlodaDatastore._createAsyncStatement(
+ "SELECT id FROM imConversations WHERE path = ?1"
+ );
+ selectStatement.bindByIndex(0, aPath);
+ let id;
+ return new Promise((resolve, reject) => {
+ selectStatement.executeAsync({
+ handleResult: aResultSet => {
+ let row = aResultSet.getNextRow();
+ if (!row) {
+ return;
+ }
+ if (id || aResultSet.getNextRow()) {
+ console.error(
+ "Warning: found more than one gloda conv id for " + aPath + "\n"
+ );
+ }
+ id = id || row.getInt64(0); // We use the first found id.
+ },
+ handleError: aError =>
+ console.error("Error finding gloda id from path:\n" + aError),
+ handleCompletion: () => {
+ resolve(id);
+ },
+ });
+ });
+ },
+
+ // Get the path of a log file relative to the logs directory - the last 4
+ // components of the path.
+ _getRelativePath(aLogPath) {
+ return PathUtils.split(aLogPath).slice(-4).join("/");
+ },
+
+ /**
+ * @param {object} aCache - An object mapping file names to their last
+ * modified times at the time they were last indexed. The value for the file
+ * currently being indexed is updated to the aLastModifiedTime parameter's
+ * value once indexing is complete.
+ * @param {GlodaIMConversation} [aGlodaConv] - An optional in-out param that
+ * lets the caller save and reuse the GlodaIMConversation instance created
+ * when the conversation is indexed the first time. After a conversation is
+ * indexed for the first time, the GlodaIMConversation instance has its id
+ * property set to the row id of the conversation in the database. This id
+ * is required to later update the conversation in the database, so the
+ * caller dealing with ongoing conversation has to provide the aGlodaConv
+ * parameter, while the caller dealing with old conversations doesn't care.
+ */
+ async indexIMConversation(
+ aCallbackHandle,
+ aLogPath,
+ aLastModifiedTime,
+ aCache,
+ aGlodaConv
+ ) {
+ let log = await IMServices.logs.getLogFromFile(aLogPath);
+ let logConv = await log.getConversation();
+
+ // Ignore corrupted log files.
+ if (!logConv) {
+ return GlodaConstants.kWorkDone;
+ }
+
+ let fileName = PathUtils.filename(aLogPath);
+ let messages = logConv
+ .getMessages()
+ // Some messages returned, e.g. sessionstart messages,
+ // may have the noLog flag set. Ignore these.
+ .filter(m => !m.noLog);
+ let content = [];
+ while (messages.length > 0) {
+ await new Promise(resolve => {
+ ChromeUtils.idleDispatch(timing => {
+ while (timing.timeRemaining() > 5 && messages.length > 0) {
+ let m = messages.shift();
+ let who = m.alias || m.who;
+ // Messages like topic change notifications may not have a source.
+ let prefix = who ? who + ": " : "";
+ content.push(
+ prefix +
+ lazy.MailFolder.convertMsgSnippetToPlainText(
+ "<!DOCTYPE html>" + m.message
+ )
+ );
+ }
+ resolve();
+ });
+ });
+ }
+ content = content.join("\n\n");
+ let glodaConv;
+ if (aGlodaConv && aGlodaConv.value) {
+ glodaConv = aGlodaConv.value;
+ glodaConv._content = content;
+ } else {
+ let relativePath = this._getRelativePath(aLogPath);
+ glodaConv = new GlodaIMConversation(
+ logConv.title,
+ log.time,
+ relativePath,
+ content
+ );
+ // If we've indexed this file before, we need the id of the existing
+ // gloda conversation so that the existing entry gets updated. This can
+ // happen if the log sweep detects that the last messages in an open
+ // chat were not in fact indexed before that session was shut down.
+ let id = await this._getIdFromPath(relativePath);
+ if (id) {
+ glodaConv.id = id;
+ }
+ if (aGlodaConv) {
+ aGlodaConv.value = glodaConv;
+ }
+ }
+
+ if (!aCache) {
+ throw new Error("indexIMConversation called without aCache parameter.");
+ }
+ let isNew =
+ !Object.prototype.hasOwnProperty.call(aCache, fileName) && !glodaConv.id;
+ let rv = aCallbackHandle.pushAndGo(
+ Gloda.grokNounItem(glodaConv, {}, true, isNew, aCallbackHandle)
+ );
+
+ if (!aLastModifiedTime) {
+ console.error(
+ "indexIMConversation called without lastModifiedTime parameter."
+ );
+ }
+ aCache[fileName] = aLastModifiedTime || 1;
+ this._scheduleCacheSave();
+
+ return rv;
+ },
+
+ *_worker_indexIMConversation(aJob, aCallbackHandle) {
+ let glodaConv = {};
+ let existingGlodaConv = aJob.conversation.glodaConv;
+ if (
+ existingGlodaConv &&
+ existingGlodaConv.path == this._getRelativePath(aJob.path)
+ ) {
+ glodaConv.value = aJob.conversation.glodaConv;
+ }
+
+ // indexIMConversation may initiate an async grokNounItem sub-job.
+ this.indexIMConversation(
+ aCallbackHandle,
+ aJob.path,
+ aJob.lastModifiedTime,
+ aJob.conversation.convObj,
+ glodaConv
+ ).then(() => GlodaIndexer.callbackDriver());
+ // Tell the Indexer that we're doing async indexing. We'll be left alone
+ // until callbackDriver() is called above.
+ yield GlodaConstants.kWorkAsync;
+
+ // Resolve the promise for this job.
+ this._indexingJobCallbacks.get(aJob.conversation.id)();
+ this._indexingJobCallbacks.delete(aJob.conversation.id);
+ this._indexingJobPromise = null;
+ aJob.conversation.indexPending = false;
+ aJob.conversation.glodaConv = glodaConv.value;
+ yield GlodaConstants.kWorkDone;
+ },
+
+ *_worker_logsFolderSweep(aJob) {
+ let dir = FileUtils.getFile("ProfD", ["logs"]);
+ if (!dir.exists() || !dir.isDirectory()) {
+ // If the folder does not exist, then we are done.
+ yield GlodaConstants.kWorkDone;
+ }
+
+ // Sweep the logs directory for log files, adding any new entries to the
+ // _knownFiles tree as we traverse.
+ for (let proto of dir.directoryEntries) {
+ if (!proto.isDirectory()) {
+ continue;
+ }
+ let protoName = proto.leafName;
+ if (!Object.prototype.hasOwnProperty.call(this._knownFiles, protoName)) {
+ this._knownFiles[protoName] = {};
+ }
+ let protoObj = this._knownFiles[protoName];
+ let accounts = proto.directoryEntries;
+ for (let account of accounts) {
+ if (!account.isDirectory()) {
+ continue;
+ }
+ let accountName = account.leafName;
+ if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) {
+ protoObj[accountName] = {};
+ }
+ let accountObj = protoObj[accountName];
+ for (let conv of account.directoryEntries) {
+ let convName = conv.leafName;
+ if (!conv.isDirectory() || convName == ".system") {
+ continue;
+ }
+ if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) {
+ accountObj[convName] = {};
+ }
+ let job = new IndexingJob("convFolderSweep", null);
+ job.folder = conv;
+ job.convObj = accountObj[convName];
+ GlodaIndexer.indexJob(job);
+ }
+ }
+ }
+
+ yield GlodaConstants.kWorkDone;
+ },
+
+ *_worker_convFolderSweep(aJob, aCallbackHandle) {
+ let folder = aJob.folder;
+
+ for (let file of folder.directoryEntries) {
+ let fileName = file.leafName;
+ if (
+ !file.isFile() ||
+ !file.isReadable() ||
+ !fileName.endsWith(".json") ||
+ (Object.prototype.hasOwnProperty.call(aJob.convObj, fileName) &&
+ aJob.convObj[fileName] == file.lastModifiedTime)
+ ) {
+ continue;
+ }
+ // indexIMConversation may initiate an async grokNounItem sub-job.
+ this.indexIMConversation(
+ aCallbackHandle,
+ file.path,
+ file.lastModifiedTime,
+ aJob.convObj
+ ).then(() => GlodaIndexer.callbackDriver());
+ // Tell the Indexer that we're doing async indexing. We'll be left alone
+ // until callbackDriver() is called above.
+ yield GlodaConstants.kWorkAsync;
+ }
+ yield GlodaConstants.kWorkDone;
+ },
+
+ get workers() {
+ return [
+ ["indexIMConversation", { worker: this._worker_indexIMConversation }],
+ ["logsFolderSweep", { worker: this._worker_logsFolderSweep }],
+ ["convFolderSweep", { worker: this._worker_convFolderSweep }],
+ ];
+ },
+
+ initialSweep() {
+ let job = new IndexingJob("logsFolderSweep", null);
+ GlodaIndexer.indexJob(job);
+ },
+
+ // Due to bug 1069845, some logs were indexed against their full paths instead
+ // of their path relative to the logs directory. These entries are updated to
+ // use relative paths below.
+ fixEntriesWithAbsolutePaths() {
+ let store = lazy.GlodaDatastore;
+ let selectStatement = store._createAsyncStatement(
+ "SELECT id, path FROM imConversations"
+ );
+ let updateStatement = store._createAsyncStatement(
+ "UPDATE imConversations SET path = ?1 WHERE id = ?2"
+ );
+
+ store._beginTransaction();
+ selectStatement.executeAsync({
+ handleResult: aResultSet => {
+ let row;
+ while ((row = aResultSet.getNextRow())) {
+ // If the path has more than 4 components, it is not relative to
+ // the logs folder. Update it to use only the last 4 components.
+ // The absolute paths were stored as OS-specific paths, so we split
+ // them with PathUtils.split(). It's a safe assumption that nobody
+ // ported their profile folder to a different OS since the regression,
+ // so this should work.
+ let pathComponents = PathUtils.split(row.getString(1));
+ if (pathComponents.length > 4) {
+ updateStatement.bindByIndex(1, row.getInt64(0)); // id
+ updateStatement.bindByIndex(0, pathComponents.slice(-4).join("/")); // Last 4 path components
+ updateStatement.executeAsync({
+ handleResult: () => {},
+ handleError: aError =>
+ console.error("Error updating bad entry:\n" + aError),
+ handleCompletion: () => {},
+ });
+ }
+ }
+ },
+
+ handleError: aError =>
+ console.error("Error looking for bad entries:\n" + aError),
+
+ handleCompletion: () => {
+ store.runPostCommit(() => {
+ this.cacheVersion = 1;
+ this._scheduleCacheSave();
+ });
+ store._commitTransaction();
+ },
+ });
+ },
+};
+
+GlodaIndexer.registerIndexer(GlodaIMIndexer);
diff --git a/comm/mail/components/im/moz.build b/comm/mail/components/im/moz.build
new file mode 100644
index 0000000000..3780532058
--- /dev/null
+++ b/comm/mail/components/im/moz.build
@@ -0,0 +1,38 @@
+# vim: set filetype=python:
+# 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_JS_MODULES += [
+ "IMIncomingServer.sys.mjs",
+ "IMProtocolInfo.sys.mjs",
+ "modules/ChatEncryption.sys.mjs",
+ "modules/chatHandler.sys.mjs",
+ "modules/chatIcons.sys.mjs",
+ "modules/chatNotifications.sys.mjs",
+ "modules/GlodaIMSearcher.sys.mjs",
+ "modules/index_im.sys.mjs",
+]
+
+TESTING_JS_MODULES += [
+ "test/TestProtocol.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+JS_PREFERENCE_FILES += [
+ "all-im.js",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+if CONFIG["ENABLE_TESTS"]:
+ XPCOM_MANIFESTS += [
+ "test/components.conf",
+ ]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/im/smileys/theme.json b/comm/mail/components/im/smileys/theme.json
new file mode 100644
index 0000000000..bbe0001f64
--- /dev/null
+++ b/comm/mail/components/im/smileys/theme.json
@@ -0,0 +1,22 @@
+{
+ "smileys": [
+ { "glyph": "\uD83D\uDE01", "textCodes": [":-)", ":)", "(-:", "(:"] },
+ { "glyph": "\uD83D\uDE02", "textCodes": [":-D", ":D"] },
+ { "glyph": "\uD83D\uDE09", "textCodes": [";-)", ";)"] },
+ { "glyph": "\uD83D\uDE2D", "textCodes": [":'("] },
+ { "glyph": "\uD83D\uDE2D", "textCodes": [":-o", ":-O", "o_o", "O_O"] },
+ { "glyph": "\uD83D\uDE15", "textCodes": [":-S", ":S", ":-s", ":s"] },
+ { "glyph": "\uD83D\uDE1F", "textCodes": [":-/", ":-\\"] },
+ { "glyph": "\uD83D\uDE20", "textCodes": ["x-("] },
+ { "glyph": "\uD83D\uDE41", "textCodes": [":-(", ":(", ")-:", "):"] },
+ { "glyph": "\uD83D\uDE0E", "textCodes": ["B-)", "8-)"] },
+ { "glyph": "\uD83D\uDE1B", "textCodes": [":-P", ":P", ":-p", ":p"] },
+ { "glyph": "\uD83D\uDE05", "textCodes": [":-]", ":]", "^^'"] },
+ { "glyph": "♥", "textCodes": ["<3"] },
+ { "glyph": "\uD83D\uDE10", "textCodes": [":-|"] },
+ { "glyph": "☺", "textCodes": ["^^"] },
+ { "glyph": "\uD83D\uDE2B", "textCodes": ["-_-"] },
+ { "glyph": "\uD83D\uDE24", "textCodes": ["-_-'", "--'"] },
+ { "glyph": "\uD83E\uDD23", "textCodes": ["XD", "xD"] }
+ ]
+}
diff --git a/comm/mail/components/im/test/TestProtocol.sys.mjs b/comm/mail/components/im/test/TestProtocol.sys.mjs
new file mode 100644
index 0000000000..7fddbf176b
--- /dev/null
+++ b/comm/mail/components/im/test/TestProtocol.sys.mjs
@@ -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/. */
+
+import {
+ GenericAccountPrototype,
+ GenericConvChatPrototype,
+ GenericConvIMPrototype,
+ GenericConversationPrototype,
+ GenericProtocolPrototype,
+ GenericConvChatBuddyPrototype,
+ GenericMessagePrototype,
+ TooltipInfo,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+import { nsSimpleEnumerator } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+function Message(who, text, properties, conversation) {
+ this._init(who, text, properties, conversation);
+ this.displayed = new Promise(resolve => {
+ this._onDisplayed = resolve;
+ });
+ this.read = new Promise(resolve => {
+ this._onRead = resolve;
+ });
+ this.actionRan = new Promise(resolve => {
+ this._onAction = resolve;
+ });
+}
+
+Message.prototype = {
+ __proto__: GenericMessagePrototype,
+
+ whenDisplayed() {
+ this._onDisplayed();
+ },
+
+ whenRead() {
+ this._onRead();
+ },
+
+ getActions() {
+ return [
+ {
+ QueryInterface: ChromeUtils.generateQI(["prplIMessageAction"]),
+ label: "Test",
+ run: () => {
+ this._onAction();
+ },
+ },
+ ];
+ },
+};
+
+/**
+ *
+ * @param {string} who - Nick of the participant.
+ * @param {string} [alias] - Display name of the participant.
+ */
+function Participant(who, alias) {
+ this._name = who;
+ if (alias) {
+ this.alias = alias;
+ }
+}
+Participant.prototype = {
+ __proto__: GenericConvChatBuddyPrototype,
+};
+
+const SharedConversationPrototype = {
+ _disconnected: false,
+ /**
+ * Disconnect the conversation.
+ */
+ _setDisconnected() {
+ this._disconnected = true;
+ },
+ /**
+ * Close the conversation, including in the UI.
+ */
+ close() {
+ this._disconnected = true;
+ this._account._conversations.delete(this);
+ GenericConversationPrototype.close.call(this);
+ },
+ /**
+ * Send an outgoing message.
+ *
+ * @param {string} aMsg - Message to send.
+ * @returns
+ */
+ dispatchMessage(aMsg, aAction = false, aNotice = false) {
+ if (this._disconnected) {
+ return;
+ }
+ this.writeMessage("You", aMsg, { outgoing: true, notification: aNotice });
+ },
+
+ /**
+ *
+ * @param {Array<object>} messages - Array of messages to add to the
+ * conversation. Expects an object with a |who|, |content| and |options|
+ * properties, corresponding to the three params of |writeMessage|.
+ */
+ addMessages(messages) {
+ for (const message of messages) {
+ this.writeMessage(message.who, message.content, message.options);
+ }
+ },
+
+ /**
+ * Add a notice to the conversation.
+ */
+ addNotice() {
+ this.writeMessage("system", "test notice", { system: true });
+ },
+
+ createMessage(who, text, options) {
+ const message = new Message(who, text, options, this);
+ return message;
+ },
+};
+
+/**
+ *
+ * @param {prplIAccount} account
+ * @param {string} name - Name of the conversation.
+ */
+function MUC(account, name) {
+ this._init(account, name, "You");
+}
+MUC.prototype = {
+ __proto__: GenericConvChatPrototype,
+
+ /**
+ *
+ * @param {string} who - Nick of the user to add.
+ * @param {string} alias - Display name of the participant.
+ * @returns
+ */
+ addParticipant(who, alias) {
+ if (this._participants.has(who)) {
+ return;
+ }
+ const participant = new Participant(who, alias);
+ this._participants.set(who, participant);
+ },
+ ...SharedConversationPrototype,
+};
+
+/**
+ *
+ * @param {prplIAccount} account
+ * @param {string} name - Name of the conversation.
+ */
+function DM(account, name) {
+ this._init(account, name);
+}
+DM.prototype = {
+ __proto__: GenericConvIMPrototype,
+ ...SharedConversationPrototype,
+};
+
+function Account(aProtoInstance, aImAccount) {
+ this._init(aProtoInstance, aImAccount);
+ this._conversations = new Set();
+}
+Account.prototype = {
+ __proto__: GenericAccountPrototype,
+
+ /**
+ * @type {Set<GenericConversationPrototype>}
+ */
+ _conversations: null,
+
+ /**
+ *
+ * @param {string} name - Name of the conversation.
+ * @returns {MUC}
+ */
+ makeMUC(name) {
+ const conversation = new MUC(this, name);
+ this._conversations.add(conversation);
+ return conversation;
+ },
+
+ /**
+ *
+ * @param {string} name - Name of the conversation.
+ * @returns {DM}
+ */
+ makeDM(name) {
+ const conversation = new DM(this, name);
+ this._conversations.add(conversation);
+ return conversation;
+ },
+
+ connect() {
+ this.reportConnecting();
+ // do something here
+ this.reportConnected();
+ },
+ disconnect() {
+ this.reportDisconnecting(Ci.prplIAccount.NO_ERROR, "");
+ this.reportDisconnected();
+ },
+
+ requestBuddyInfo(who) {
+ const participant = Array.from(this._conversations)
+ .find(conv => conv.isChat && conv._participants.has(who))
+ ?._participants.get(who);
+ if (participant) {
+ const tooltipInfo = [new TooltipInfo("Display Name", participant.alias)];
+ Services.obs.notifyObservers(
+ new nsSimpleEnumerator(tooltipInfo),
+ "user-info-received",
+ who
+ );
+ }
+ },
+
+ get canJoinChat() {
+ return true;
+ },
+ chatRoomFields: {
+ channel: { label: "_Channel Field", required: true },
+ channelDefault: { label: "_Field with default", default: "Default Value" },
+ password: {
+ label: "_Password Field",
+ default: "",
+ isPassword: true,
+ required: false,
+ },
+ sampleIntField: {
+ label: "_Int Field",
+ default: 4,
+ min: 0,
+ max: 10,
+ required: true,
+ },
+ },
+
+ // Nothing to do.
+ unInit() {
+ for (const conversation of this._conversations) {
+ conversation.close();
+ }
+ },
+ remove() {},
+};
+
+export function TestProtocol() {}
+TestProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get id() {
+ return "prpl-mochitest";
+ },
+ get normalizedName() {
+ return "mochitest";
+ },
+ get name() {
+ return "Mochitest";
+ },
+ options: {
+ text: { label: "Text option", default: "foo" },
+ bool: { label: "Boolean option", default: true },
+ int: { label: "Integer option", default: 42 },
+ list: {
+ label: "Select option",
+ default: "option2",
+ listValues: {
+ option1: "First option",
+ option2: "Default option",
+ option3: "Other option",
+ },
+ },
+ },
+ usernameSplits: [
+ {
+ label: "Server",
+ separator: "@",
+ defaultValue: "default.server",
+ reverse: true,
+ },
+ ],
+ getAccount(aImAccount) {
+ return new Account(this, aImAccount);
+ },
+ classID: Components.ID("{a4617631-b8b8-4053-8afa-5c4c43498280}"),
+};
+
+export function registerTestProtocol() {
+ Services.catMan.addCategoryEntry(
+ "im-protocol-plugin",
+ TestProtocol.prototype.id,
+ "@mozilla.org/chat/mochitest;1",
+ false,
+ true
+ );
+}
+
+export function unregisterTestProtocol() {
+ Services.catMan.deleteCategoryEntry(
+ "im-protocol-plugin",
+ TestProtocol.prototype.id,
+ true
+ );
+}
diff --git a/comm/mail/components/im/test/browser/browser.ini b/comm/mail/components/im/test/browser/browser.ini
new file mode 100644
index 0000000000..5592953682
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser.ini
@@ -0,0 +1,26 @@
+[default]
+prefs =
+ ldap_2.servers.osx.description=
+ ldap_2.servers.osx.dirType=-1
+ ldap_2.servers.osx.uri=
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ chat.otr.enable=false
+subsuite = thunderbird
+head = head.js
+
+[browser_browserRequest.js]
+[browser_chatNotifications.js]
+[browser_chatTelemetry.js]
+[browser_contextMenu.js]
+[browser_logs.js]
+[browser_messagesMail.js]
+[browser_readMessage.js]
+[browser_removeMessage.js]
+[browser_requestNotifications.js]
+[browser_spacesToolbarChat.js]
+[browser_tooltips.js]
+[browser_updateMessage.js]
diff --git a/comm/mail/components/im/test/browser/browser_browserRequest.js b/comm/mail/components/im/test/browser/browser_browserRequest.js
new file mode 100644
index 0000000000..7ffdb1c725
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_browserRequest.js
@@ -0,0 +1,112 @@
+/* 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 { InteractiveBrowser, CancelledError } = ChromeUtils.importESModule(
+ "resource:///modules/InteractiveBrowser.sys.mjs"
+);
+const kBaseWindowUri = "chrome://messenger/content/browserRequest.xhtml";
+
+add_task(async function testBrowserRequestObserverNotification() {
+ const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.document.documentURI === kBaseWindowUri
+ );
+ let notifyLoaded;
+ const loadedPromise = new Promise(resolve => {
+ notifyLoaded = resolve;
+ });
+ const cancelledPromise = new Promise(resolve => {
+ Services.obs.notifyObservers(
+ {
+ promptText: "",
+ iconURI: "",
+ url: "about:blank",
+ cancelled() {
+ resolve();
+ },
+ loaded(window, webProgress) {
+ ok(webProgress);
+ notifyLoaded(window);
+ },
+ },
+ "browser-request"
+ );
+ });
+
+ const requestWindow = await windowPromise;
+ const loadedWindow = await loadedPromise;
+ ok(loadedWindow);
+ is(loadedWindow.document.documentURI, kBaseWindowUri);
+
+ const closeEvent = new Event("close");
+ requestWindow.dispatchEvent(closeEvent);
+ await BrowserTestUtils.closeWindow(requestWindow);
+
+ await cancelledPromise;
+});
+
+add_task(async function testWaitForRedirect() {
+ const initialUrl = "about:blank";
+ const promptText = "just testing";
+ const completionUrl = InteractiveBrowser.COMPLETION_URL + "/done?info=foo";
+ const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.document.documentURI === kBaseWindowUri
+ );
+ const request = InteractiveBrowser.waitForRedirect(initialUrl, promptText);
+ const requestWindow = await windowPromise;
+ is(requestWindow.document.title, promptText, "set window title");
+
+ const closedWindow = BrowserTestUtils.domWindowClosed(requestWindow);
+ const browser = requestWindow.document.getElementById("requestFrame");
+ await BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, completionUrl);
+ const result = await request;
+ is(result, completionUrl, "finished with correct URL");
+
+ await closedWindow;
+});
+
+add_task(async function testCancelWaitForRedirect() {
+ const initialUrl = "about:blank";
+ const promptText = "just testing";
+ const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.document.documentURI === kBaseWindowUri
+ );
+ const request = InteractiveBrowser.waitForRedirect(initialUrl, promptText);
+ const requestWindow = await windowPromise;
+ is(requestWindow.document.title, promptText, "set window title");
+
+ await new Promise(resolve => setTimeout(resolve));
+
+ const closeEvent = new Event("close");
+ requestWindow.dispatchEvent(closeEvent);
+ await BrowserTestUtils.closeWindow(requestWindow);
+
+ try {
+ await request;
+ ok(false, "request should be rejected");
+ } catch (error) {
+ ok(error instanceof CancelledError, "request was rejected");
+ }
+});
+
+add_task(async function testAlreadyComplete() {
+ const completionUrl = InteractiveBrowser.COMPLETION_URL + "/done?info=foo";
+ const promptText = "just testing";
+ const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.document.documentURI === kBaseWindowUri
+ );
+ const request = InteractiveBrowser.waitForRedirect(completionUrl, promptText);
+ const requestWindow = await windowPromise;
+ is(requestWindow.document.title, promptText, "set window title");
+
+ const closedWindow = BrowserTestUtils.domWindowClosed(requestWindow);
+ const result = await request;
+ is(result, completionUrl, "finished with correct URL");
+
+ await closedWindow;
+});
diff --git a/comm/mail/components/im/test/browser/browser_chatNotifications.js b/comm/mail/components/im/test/browser/browser_chatNotifications.js
new file mode 100644
index 0000000000..f902a9132b
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_chatNotifications.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";
+
+/* import-globals-from ../../content/chat-messenger.js */
+
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+let originalAlertsServiceCID;
+let alertShown;
+const reset = () => {
+ alertShown = false;
+};
+
+add_setup(async () => {
+ reset();
+ class MockAlertsService {
+ QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]);
+ showAlert(alertInfo, listener) {
+ alertShown = true;
+ }
+ }
+ originalAlertsServiceCID = MockRegistrar.register(
+ "@mozilla.org/alerts-service;1",
+ new MockAlertsService()
+ );
+});
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(originalAlertsServiceCID);
+});
+
+add_task(async function testNotificationsDisabled() {
+ Services.prefs.setBoolPref("mail.chat.show_desktop_notifications", false);
+
+ Services.obs.notifyObservers(
+ {
+ who: "notifier",
+ alias: "Notifier",
+ time: Date.now() / 1000 - 10,
+ displayMessage: "<strong>lorem ipsum</strong>",
+ action: false,
+ conversation: {
+ isChat: true,
+ },
+ },
+ "new-directed-incoming-message"
+ );
+
+ await TestUtils.waitForTick();
+ ok(!alertShown, "No alert shown when they are disabled");
+
+ Services.prefs.setBoolPref("mail.chat.show_desktop_notifications", true);
+ reset();
+
+ let soundPlayed = TestUtils.topicObserved("play-chat-notification-sound");
+ Services.obs.notifyObservers(
+ {
+ who: "notifier",
+ alias: "Notifier",
+ time: Date.now() / 1000 - 5,
+ displayMessage: "",
+ action: false,
+ conversation: {
+ isChat: true,
+ },
+ },
+ "new-directed-incoming-message"
+ );
+ await soundPlayed;
+ ok(!alertShown, "No alert shown with main window focused");
+
+ reset();
+
+ await openChatTab();
+
+ Services.obs.notifyObservers(
+ {
+ who: "notifier",
+ alias: "Notifier",
+ time: Date.now() / 1000,
+ displayMessage: "",
+ action: false,
+ conversation: {
+ isChat: true,
+ },
+ },
+ "new-directed-incoming-message"
+ );
+ await TestUtils.waitForTick();
+ ok(!alertShown, "No alert shown, no sound with chat tab focused");
+
+ await closeChatTab();
+});
diff --git a/comm/mail/components/im/test/browser/browser_chatTelemetry.js b/comm/mail/components/im/test/browser/browser_chatTelemetry.js
new file mode 100644
index 0000000000..4dbad87708
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_chatTelemetry.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 { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_task(async function testMessageThemeTelemetry() {
+ Services.telemetry.clearScalars();
+
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+ ok(
+ !scalars["tb.chat.active_message_theme"],
+ "Active chat theme not reported without open conversation."
+ );
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ const conversationLoaded = waitForConversationLoad(chatConv.convBrowser);
+ ok(chatConv, "found conversation");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+
+ await conversationLoaded;
+ scalars = TelemetryTestUtils.getProcessScalars("parent");
+ // NOTE: tb.chat.active_message_theme expires at v 117.
+ is(
+ scalars["tb.chat.active_message_theme"],
+ "mail:default",
+ "Active chat message theme and variant reported after opening conversation."
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_contextMenu.js b/comm/mail/components/im/test/browser/browser_contextMenu.js
new file mode 100644
index 0000000000..44afcb2a3b
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_contextMenu.js
@@ -0,0 +1,243 @@
+/* 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 testContextMenu() {
+ const account = IMServices.accounts.createAccount(
+ "context",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("context");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ const conversationLoaded = waitForConversationLoad();
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+
+ await conversationLoaded;
+
+ const contextMenu = document.getElementById("chatConversationContextMenu");
+ ok(BrowserTestUtils.is_hidden(contextMenu));
+
+ const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ "body",
+ 0,
+ 0,
+ { type: "contextmenu" },
+ chatConv.convBrowser,
+ true
+ );
+ await popupShown;
+
+ const popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ // Assume normal context menu semantics work and just close it directly.
+ contextMenu.hidePopup();
+ await popupHidden;
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testMessageContextMenuOnLink() {
+ const account = IMServices.accounts.createAccount(
+ "context",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("linker");
+
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ conversation.addMessages([
+ {
+ who: "linker",
+ content: "hi https://example.com/",
+ options: {
+ incoming: true,
+ },
+ },
+ {
+ who: "linker",
+ content: "hi mailto:test@example.com",
+ options: {
+ incoming: true,
+ },
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ const contextMenu = document.getElementById("chatConversationContextMenu");
+ ok(BrowserTestUtils.is_hidden(contextMenu));
+
+ const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ ".message:nth-child(1) a",
+ 0,
+ 0,
+ { type: "contextmenu", centered: true },
+ chatConv.convBrowser,
+ true
+ );
+ await popupShown;
+
+ ok(
+ BrowserTestUtils.is_visible(contextMenu.querySelector("#context-openlink")),
+ "open link"
+ );
+ ok(
+ BrowserTestUtils.is_visible(contextMenu.querySelector("#context-copylink")),
+ "copy link"
+ );
+
+ const popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ // Assume normal context menu semantics work and just close it directly.
+ contextMenu.hidePopup();
+ await popupHidden;
+
+ const popupShownAgain = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ BrowserTestUtils.synthesizeMouse(
+ ".message:nth-child(2) a",
+ 0,
+ 0,
+ { type: "contextmenu", centered: true },
+ chatConv.convBrowser,
+ true
+ );
+ await popupShownAgain;
+
+ ok(
+ BrowserTestUtils.is_visible(
+ contextMenu.querySelector("#context-copyemail")
+ ),
+ "copy mail"
+ );
+
+ const popupHiddenAgain = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ // Assume normal context menu semantics work and just close it directly.
+ contextMenu.hidePopup();
+ await popupHiddenAgain;
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testMessageAction() {
+ const account = IMServices.accounts.createAccount(
+ "context",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("context");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ const messagePromise = waitForNotification(conversation, "new-text");
+ const displayedPromise = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ conversation.writeMessage("context", "hello world", {
+ incoming: true,
+ });
+ const { subject: message } = await messagePromise;
+ await displayedPromise;
+
+ const contextMenu = document.getElementById("chatConversationContextMenu");
+ ok(BrowserTestUtils.is_hidden(contextMenu));
+
+ const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ ".message:nth-child(1)",
+ 0,
+ 0,
+ { type: "contextmenu", centered: true },
+ chatConv.convBrowser,
+ true
+ );
+ await popupShown;
+
+ const separator = contextMenu.querySelector("#context-sep-messageactions");
+ if (!BrowserTestUtils.is_visible(separator)) {
+ await BrowserTestUtils.waitForMutationCondition(
+ separator,
+ {
+ subtree: false,
+ childList: false,
+ attributes: true,
+ attributeFilter: ["hidden"],
+ },
+ () => BrowserTestUtils.is_visible(separator)
+ );
+ }
+ const item = contextMenu.querySelector(
+ "#context-sep-messageactions + menuitem"
+ );
+ ok(item, "Item for message action injected");
+ is(item.getAttribute("label"), "Test");
+
+ const popupHiddenAgain = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ item.click();
+ // Assume normal context menu semantics work and just close it.
+ contextMenu.hidePopup();
+ await Promise.all([message.actionRan, popupHiddenAgain]);
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_logs.js b/comm/mail/components/im/test/browser/browser_logs.js
new file mode 100644
index 0000000000..2f95a2accd
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_logs.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/. */
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+add_task(async function testTopicRestored() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation =
+ account.prplAccount.wrappedJSObject.makeMUC("logs topic");
+ let convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ let chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ const browserDisplayed = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ conversation.addParticipant("topic");
+ conversation.addMessages([
+ {
+ who: "topic",
+ content: "hi",
+ options: {
+ incoming: true,
+ },
+ },
+ ]);
+ await browserDisplayed;
+
+ // Close and re-open conversation to get logs
+ conversation.close();
+ const newConversation =
+ account.prplAccount.wrappedJSObject.makeMUC("logs topic");
+ convNode = getConversationItem(newConversation);
+ ok(convNode);
+
+ let conversationLoaded = waitForConversationLoad();
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ chatConv = getChatConversationElement(newConversation);
+ ok(chatConv, "found conversation");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ const topicChanged = waitForNotification(
+ newConversation,
+ "chat-update-topic"
+ );
+ newConversation.setTopic("foo bar", "topic");
+ await topicChanged;
+ const logTree = document.getElementById("logTree");
+ const chatTopInfo = document.querySelector("chat-conversation-info");
+
+ is(chatTopInfo.topic.value, "foo bar");
+
+ // Wait for log list to be populated, sadly there is no event and it is delayed by promises.
+ await TestUtils.waitForCondition(() => logTree.view.rowCount > 0);
+
+ await conversationLoaded;
+ const logBrowser = document.getElementById("conv-log-browser");
+ conversationLoaded = waitForConversationLoad(logBrowser);
+ mailTestUtils.treeClick(EventUtils, window, logTree, 0, 0, {
+ clickCount: 1,
+ });
+ await conversationLoaded;
+
+ ok(BrowserTestUtils.is_visible(logBrowser));
+ is(chatTopInfo.topic.value, "", "Topic is cleared when viewing logs");
+
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("goToConversation"),
+ {}
+ );
+
+ ok(BrowserTestUtils.is_hidden(logBrowser));
+ is(chatTopInfo.topic.value, "foo bar");
+
+ newConversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_messagesMail.js b/comm/mail/components/im/test/browser/browser_messagesMail.js
new file mode 100644
index 0000000000..6bc73c723c
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_messagesMail.js
@@ -0,0 +1,235 @@
+/* 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 testCollapse() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+
+ await addNotice(conversation, chatConv);
+
+ is(
+ messageParent.querySelector(".event-row:nth-child(1) .body").textContent,
+ "test notice",
+ "notice added to conv"
+ );
+
+ await addNotice(conversation, chatConv);
+ await addNotice(conversation, chatConv);
+ await addNotice(conversation, chatConv);
+ await Promise.all([
+ await addNotice(conversation, chatConv),
+ BrowserTestUtils.waitForMutationCondition(
+ messageParent,
+ {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ["class"],
+ },
+ () => messageParent.querySelector(".hide-children")
+ ),
+ ]);
+
+ const hiddenGroup = messageParent.querySelector(".hide-children");
+ const toggle = hiddenGroup.querySelector(".eventToggle");
+ ok(toggle);
+ ok(hiddenGroup.querySelectorAll(".event-row").length >= 5);
+
+ toggle.click();
+ await BrowserTestUtils.waitForMutationCondition(
+ hiddenGroup,
+ {
+ attributes: true,
+ attributeFilter: ["class"],
+ },
+ () => !hiddenGroup.classList.contains("hide-children")
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testGrouping() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(
+ BrowserTestUtils.is_visible(document.getElementById("chatPanel")),
+ "Chat tab is visible"
+ );
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("grouping");
+ const convNode = getConversationItem(conversation);
+ ok(convNode, "Conversation is in contacts list");
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "Found conversation element");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+
+ conversation.addMessages([
+ {
+ who: "grouping",
+ content: "system message",
+ options: {
+ system: true,
+ incoming: true,
+ },
+ },
+ {
+ who: "grouping",
+ content: "normal message",
+ options: {
+ incoming: true,
+ },
+ },
+ {
+ who: "grouping",
+ content: "another system message",
+ options: {
+ system: true,
+ incoming: true,
+ },
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ for (let child of messageParent.children) {
+ isnot(child.id, "insert", "Message element is not the insert point");
+ }
+ is(
+ messageParent.childElementCount,
+ 3,
+ "All three messages are their own top level element"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testSystemMessageReplacement() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(
+ BrowserTestUtils.is_visible(document.getElementById("chatPanel")),
+ "Chat tab is visible"
+ );
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("replacing");
+ const convNode = getConversationItem(conversation);
+ ok(convNode, "Conversation is in contacts list");
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "Found conversation element");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+
+ conversation.addMessages([
+ {
+ who: "replacing",
+ content: "system message",
+ options: {
+ system: true,
+ incoming: true,
+ remoteId: "foo",
+ },
+ },
+ {
+ who: "replacing",
+ content: "another system message",
+ options: {
+ system: true,
+ incoming: true,
+ remoteId: "bar",
+ },
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ const updateTextPromise = waitForNotification(conversation, "update-text");
+ conversation.updateMessage("replacing", "better system message", {
+ system: true,
+ incoming: true,
+ remoteId: "foo",
+ });
+ await updateTextPromise;
+ await TestUtils.waitForTick();
+
+ is(messageParent.childElementCount, 1, "Only one message group in browser");
+ is(
+ messageParent.firstElementChild.childElementCount,
+ 3,
+ "Has two messages plus insert inside group"
+ );
+ const firstMessage = messageParent.firstElementChild.firstElementChild;
+ ok(
+ firstMessage.classList.contains("event-row"),
+ "Replacement message is an event-row"
+ );
+ is(firstMessage.dataset.remoteId, "foo");
+ is(
+ firstMessage.querySelector(".body").textContent,
+ "better system message",
+ "Message content was updated"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+function addNotice(conversation, uiConversation) {
+ conversation.addNotice();
+ return BrowserTestUtils.waitForEvent(
+ uiConversation.convBrowser,
+ "MessagesDisplayed"
+ );
+}
diff --git a/comm/mail/components/im/test/browser/browser_readMessage.js b/comm/mail/components/im/test/browser/browser_readMessage.js
new file mode 100644
index 0000000000..e290cb36fb
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_readMessage.js
@@ -0,0 +1,49 @@
+/* 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 testDisplayed() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ ok(!convNode.hasAttribute("unread"), "No unread messages");
+
+ const messagePromise = waitForNotification(conversation, "new-text");
+ conversation.writeMessage("mochitest", "hello world", {
+ incoming: true,
+ });
+ const { subject: message } = await messagePromise;
+
+ ok(convNode.hasAttribute("unread"), "Unread message waiting");
+ is(convNode.getAttribute("unreadCount"), "(1)");
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ const browserDisplayed = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ await browserDisplayed;
+ await message.displayed;
+
+ ok(!convNode.hasAttribute("unread"), "Message read");
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_removeMessage.js b/comm/mail/components/im/test/browser/browser_removeMessage.js
new file mode 100644
index 0000000000..0d95bb77b5
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_removeMessage.js
@@ -0,0 +1,54 @@
+/* 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 testRemove() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ conversation.writeMessage("mochitest", "hello world", {
+ incoming: true,
+ remoteId: "foo",
+ });
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ const browserDisplayed = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+ await browserDisplayed;
+
+ is(
+ messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt")
+ .textContent,
+ "hello world",
+ "message added to conv"
+ );
+
+ const updateTextPromise = waitForNotification(conversation, "remove-text");
+ conversation.removeMessage("foo");
+ await updateTextPromise;
+ await TestUtils.waitForTick();
+
+ ok(!messageParent.querySelector(".message"));
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_requestNotifications.js b/comm/mail/components/im/test/browser/browser_requestNotifications.js
new file mode 100644
index 0000000000..62128add8b
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_requestNotifications.js
@@ -0,0 +1,350 @@
+/* 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 testGrantingBuddyRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const notificationTopic = TestUtils.topicObserved(
+ "buddy-authorization-request"
+ );
+ const requestPromise = new Promise((resolve, reject) => {
+ prplAccount.addBuddyRequest("test-user", resolve, reject);
+ });
+ const [request] = await notificationTopic;
+ is(request.userName, "test-user");
+ is(request.account.id, account.id);
+ await TestUtils.waitForTick();
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value = "buddy-auth-request-" + request.account.id + request.userName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ ok(
+ BrowserTestUtils.is_hidden(notification.closeButton),
+ "Can't dismiss without interacting"
+ );
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {}
+ );
+ await requestPromise;
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testCancellingBuddyRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const notificationTopic = TestUtils.topicObserved(
+ "buddy-authorization-request"
+ );
+ prplAccount.addBuddyRequest(
+ "test-user",
+ () => {
+ ok(false, "request was granted");
+ },
+ () => {
+ ok(false, "request was denied");
+ }
+ );
+ const [request] = await notificationTopic;
+ is(request.userName, "test-user");
+ is(request.account.id, account.id);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value = "buddy-auth-request-" + request.account.id + request.userName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ const cancelTopic = TestUtils.topicObserved(
+ "buddy-authorization-request-canceled"
+ );
+ prplAccount.cancelBuddyRequest("test-user");
+ const [canceledRequest] = await cancelTopic;
+ is(canceledRequest.userName, request.userName);
+ is(canceledRequest.account.id, request.account.id);
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testDenyingBuddyRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const notificationTopic = TestUtils.topicObserved(
+ "buddy-authorization-request"
+ );
+ const requestPromise = new Promise((resolve, reject) => {
+ prplAccount.addBuddyRequest("test-user", reject, resolve);
+ });
+ const [request] = await notificationTopic;
+ is(request.userName, "test-user");
+ is(request.account.id, account.id);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value = "buddy-auth-request-" + request.account.id + request.userName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.lastElementChild,
+ {}
+ );
+ await requestPromise;
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testGrantingChatRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const requestTopic = TestUtils.topicObserved("conv-authorization-request");
+ const requestPromise = new Promise((resolve, reject) => {
+ prplAccount.addChatRequest("test-chat", resolve, reject);
+ });
+ const [request] = await requestTopic;
+ is(request.conversationName, "test-chat");
+ is(request.account.id, account.id);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value =
+ "conv-auth-request-" + request.account.id + request.conversationName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ ok(
+ BrowserTestUtils.is_hidden(notification.closeButton),
+ "Can't dismiss without interacting"
+ );
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {}
+ );
+ await requestPromise;
+ const result = await request.completePromise;
+ ok(result);
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testCancellingChatRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(
+ BrowserTestUtils.is_visible(document.getElementById("chatPanel")),
+ "chat tab visible"
+ );
+
+ const requestTopic = TestUtils.topicObserved("conv-authorization-request");
+ prplAccount.addChatRequest(
+ "test-chat",
+ () => {
+ ok(false, "chat request was granted");
+ },
+ () => {
+ ok(false, "chat request was denied");
+ }
+ );
+ const [request] = await requestTopic;
+ is(request.conversationName, "test-chat", "conversation name matches");
+ is(request.account.id, account.id, "account id matches");
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value =
+ "conv-auth-request-" + request.account.id + request.conversationName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ prplAccount.cancelChatRequest("test-chat");
+ await Assert.rejects(
+ request.completePromise,
+ /Cancelled/,
+ "completePromise is rejected to indicate cancellation"
+ );
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testDenyingChatRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const requestTopic = TestUtils.topicObserved("conv-authorization-request");
+ const requestPromise = new Promise((resolve, reject) => {
+ prplAccount.addChatRequest("test-chat", reject, resolve);
+ });
+ const [request] = await requestTopic;
+ is(request.conversationName, "test-chat");
+ is(request.account.id, account.id);
+ ok(request.canDeny);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value =
+ "conv-auth-request-" + request.account.id + request.conversationName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.lastElementChild,
+ {}
+ );
+ await requestPromise;
+ const result = await request.completePromise;
+ ok(!result);
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testUndenyableChatRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const requestTopic = TestUtils.topicObserved("conv-authorization-request");
+ const requestPromise = new Promise(resolve => {
+ prplAccount.addChatRequest("test-chat", resolve);
+ });
+ const [request] = await requestTopic;
+ is(request.conversationName, "test-chat");
+ is(request.account.id, account.id);
+ ok(!request.canDeny);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value =
+ "conv-auth-request-" + request.account.id + request.conversationName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+ is(notification.buttonContainer.children.length, 1);
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {}
+ );
+ await requestPromise;
+ const result = await request.completePromise;
+ ok(result);
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js b/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js
new file mode 100644
index 0000000000..d95b5e48c0
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js
@@ -0,0 +1,255 @@
+/* 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_spacesToolbarChatBadgeMUC() {
+ window.gSpacesToolbar.toggleToolbar(false);
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ if (window.chatHandler._chatButtonUpdatePending) {
+ await TestUtils.waitForTick();
+ }
+
+ const chatButton = document.getElementById("chatButton");
+
+ ok(
+ !chatButton.classList.contains("has-badge"),
+ "Initially no unread chat messages"
+ );
+
+ // Send a new message in a MUC that is not currently open.
+ const conversation =
+ account.prplAccount.wrappedJSObject.makeMUC("noSpaceBadge");
+ const messagePromise = waitForNotification(conversation, "new-text");
+ conversation.writeMessage("spaceBadge", "just a normal message", {
+ incoming: true,
+ });
+ await messagePromise;
+ // Make sure nothing else was waiting to happen.
+ await TestUtils.waitForTick();
+
+ ok(
+ !chatButton.classList.contains("has-badge"),
+ "Untargeted MUC message doesn't change badge"
+ );
+
+ // Send a new targeted message in the conversation.
+ const unreadContainer = chatButton.querySelector(".spaces-badge-container");
+ const unreadContainerText = unreadContainer.textContent;
+ const unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ conversation.writeMessage("spaceBadge", "new direct message", {
+ incoming: true,
+ containsNick: true,
+ });
+ await unreadCountChanged;
+ ok(chatButton.classList.contains("has-badge"), "Unread badge is shown");
+
+ // Fluent doesn't immediately apply the translation, wait for it.
+ await TestUtils.waitForCondition(
+ () => unreadContainer.textContent !== unreadContainerText
+ );
+
+ is(unreadContainer.textContent, "1", "Unread count is in badge");
+ ok(unreadContainer.title);
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function test_spacesToolbarChatBadgeDM() {
+ window.gSpacesToolbar.toggleToolbar(false);
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ if (window.chatHandler._chatButtonUpdatePending) {
+ await TestUtils.waitForTick();
+ }
+
+ const chatButton = document.getElementById("chatButton");
+
+ ok(
+ !chatButton.classList.contains("has-badge"),
+ "Initially no unread chat messages"
+ );
+
+ const unreadContainer = chatButton.querySelector(".spaces-badge-container");
+ if (unreadContainer.textContent !== "0") {
+ await BrowserTestUtils.waitForMutationCondition(
+ unreadContainer,
+ {
+ subtree: true,
+ childList: true,
+ characterData: true,
+ },
+ () => unreadContainer.textContent === "0"
+ );
+ }
+
+ // Send a new message in a DM conversation that is not currently open.
+ const unreadContainerText = unreadContainer.textContent;
+ let unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("spaceBadge");
+ conversation.writeMessage("spaceBadge", "new direct message", {
+ incoming: true,
+ });
+ await unreadCountChanged;
+ ok(chatButton.classList.contains("has-badge"), "Unread badge is shown");
+
+ // Fluent doesn't immediately apply the translation, wait for it.
+ await TestUtils.waitForCondition(
+ () => unreadContainer.textContent !== unreadContainerText
+ );
+
+ is(unreadContainer.textContent, "1", "Unread count is in badge");
+ ok(unreadContainer.title);
+
+ // Display the DM conversation.
+ unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ await openChatTab();
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv);
+ ok(BrowserTestUtils.is_visible(chatConv));
+ await unreadCountChanged;
+
+ ok(
+ !chatButton.classList.contains("has-badge"),
+ "Unread badge is hidden again"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function test_spacesToolbarPinnedChatBadgeMUC() {
+ window.gSpacesToolbar.toggleToolbar(true);
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ if (window.chatHandler._chatButtonUpdatePending) {
+ await TestUtils.waitForTick();
+ }
+
+ const spacesPopupButtonChat = document.getElementById(
+ "spacesPopupButtonChat"
+ );
+
+ ok(
+ !spacesPopupButtonChat.classList.contains("has-badge"),
+ "Initially no unread chat messages"
+ );
+
+ // Send a new message in a MUC that is not currently open.
+ const conversation =
+ account.prplAccount.wrappedJSObject.makeMUC("noSpaceBadge");
+ const messagePromise = waitForNotification(conversation, "new-text");
+ conversation.writeMessage("spaceBadge", "just a normal message", {
+ incoming: true,
+ });
+ await messagePromise;
+ // Make sure nothing else was waiting to happen.
+ await TestUtils.waitForTick();
+
+ ok(
+ !spacesPopupButtonChat.classList.contains("has-badge"),
+ "Untargeted MUC message doesn't change badge"
+ );
+
+ // Send a new targeted message in the conversation.
+ const unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ conversation.writeMessage("spaceBadge", "new direct message", {
+ incoming: true,
+ containsNick: true,
+ });
+ await unreadCountChanged;
+ ok(
+ spacesPopupButtonChat.classList.contains("has-badge"),
+ "Unread badge is shown"
+ );
+ ok(
+ document
+ .getElementById("spacesPinnedButton")
+ .classList.contains("has-badge"),
+ "Unread state is propagated to pinned menu button"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function test_spacesToolbarPinnedChatBadgeDM() {
+ window.gSpacesToolbar.toggleToolbar(true);
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ if (window.chatHandler._chatButtonUpdatePending) {
+ await TestUtils.waitForTick();
+ }
+
+ const spacesPopupButtonChat = document.getElementById(
+ "spacesPopupButtonChat"
+ );
+ const spacesPinnedButton = document.getElementById("spacesPinnedButton");
+
+ ok(
+ !spacesPopupButtonChat.classList.contains("has-badge"),
+ "Initially no unread chat messages"
+ );
+ ok(!spacesPinnedButton.classList.contains("has-badge"));
+
+ // Send a new message in a DM conversation that is not currently open.
+ let unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("spaceBadge");
+ conversation.writeMessage("spaceBadge", "new direct message", {
+ incoming: true,
+ });
+ await unreadCountChanged;
+ ok(
+ spacesPopupButtonChat.classList.contains("has-badge"),
+ "Unread badge is shown"
+ );
+ ok(spacesPinnedButton.classList.contains("has-badge"));
+
+ // Display the DM conversation.
+ unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ await openChatTab();
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv);
+ ok(BrowserTestUtils.is_visible(chatConv));
+ await unreadCountChanged;
+
+ ok(
+ !spacesPopupButtonChat.classList.contains("has-badge"),
+ "Unread badge is hidden again"
+ );
+ ok(!spacesPinnedButton.classList.contains("has-badge"));
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_tooltips.js b/comm/mail/components/im/test/browser/browser_tooltips.js
new file mode 100644
index 0000000000..db8a7fd86b
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_tooltips.js
@@ -0,0 +1,194 @@
+/* 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 testMUCMessageSenderTooltip() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("tooltips");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv);
+ ok(BrowserTestUtils.is_visible(chatConv));
+ const messageParent = await getChatMessageParent(chatConv);
+
+ conversation.addParticipant("foo", "1");
+ conversation.addParticipant("bar", "2");
+ conversation.addParticipant("loremipsum", "3");
+ conversation.addMessages([
+ // Message without alias
+ {
+ who: "foo",
+ content: "hi",
+ options: {
+ incoming: true,
+ },
+ },
+ // Message with alias
+ {
+ who: "bar",
+ content: "o/",
+ options: {
+ incoming: true,
+ _alias: "Bar",
+ },
+ },
+ // Alias is not directly related to nick
+ {
+ who: "loremipsum",
+ content: "what's up?",
+ options: {
+ incoming: true,
+ _alias: "Dolor sit amet",
+ },
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ const tooltip = document.getElementById("imTooltip");
+ const tooltipTests = [
+ {
+ messageIndex: 1,
+ who: "foo",
+ alias: "1",
+ displayed: "foo",
+ },
+ {
+ messageIndex: 2,
+ who: "bar",
+ alias: "2",
+ displayed: "Bar",
+ },
+ {
+ messageIndex: 3,
+ who: "loremipsum",
+ alias: "3",
+ displayed: "Dolor sit amet",
+ },
+ ];
+ window.windowUtils.disableNonTestMouseEvents(true);
+ try {
+ for (const testInfo of tooltipTests) {
+ const usernameSelector = `.message:nth-child(${testInfo.messageIndex}) .ib-sender`;
+ const username = messageParent.querySelector(usernameSelector);
+ is(username.textContent, testInfo.displayed);
+
+ let buddyInfo = TestUtils.topicObserved(
+ "user-info-received",
+ (subject, data) => data === testInfo.who
+ );
+ await showTooltip(usernameSelector, tooltip, chatConv.convBrowser);
+
+ is(tooltip.getAttribute("displayname"), testInfo.who);
+ await buddyInfo;
+ is(tooltip.table.querySelector("td").textContent, testInfo.alias);
+ await hideTooltip(tooltip, chatConv.convBrowser);
+ }
+ } finally {
+ window.windowUtils.disableNonTestMouseEvents(false);
+ }
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testTimestampTooltip() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("tooltips");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv);
+ ok(BrowserTestUtils.is_visible(chatConv));
+
+ const messageTime = Math.floor(Date.now() / 1000);
+
+ conversation.addParticipant("foo", "1");
+ conversation.addMessages([
+ {
+ who: "foo",
+ content: "hi",
+ options: {
+ incoming: true,
+ },
+ time: messageTime,
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ const tooltip = document.getElementById("imTooltip");
+ window.windowUtils.disableNonTestMouseEvents(true);
+ try {
+ const messageSelector = ".message:nth-child(1)";
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ timeStyle: "medium",
+ });
+ const expectedText = dateTimeFormatter.format(new Date(messageTime * 1000));
+
+ await showTooltip(messageSelector, tooltip, chatConv.convBrowser);
+
+ const htmlTooltip = tooltip.querySelector(".htmlTooltip");
+ ok(BrowserTestUtils.is_visible(htmlTooltip));
+ is(htmlTooltip.textContent, expectedText);
+ await hideTooltip(tooltip, chatConv.convBrowser);
+ } finally {
+ window.windowUtils.disableNonTestMouseEvents(false);
+ }
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+async function showTooltip(elementSelector, tooltip, browser) {
+ const popupShown = BrowserTestUtils.waitForEvent(tooltip, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ elementSelector,
+ { type: "mousemove" },
+ browser
+ );
+ return popupShown;
+}
+
+async function hideTooltip(tooltip, browser) {
+ const popupHidden = BrowserTestUtils.waitForEvent(tooltip, "popuphidden");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ".message .body",
+ { type: "mousemove" },
+ browser
+ );
+ return popupHidden;
+}
diff --git a/comm/mail/components/im/test/browser/browser_updateMessage.js b/comm/mail/components/im/test/browser/browser_updateMessage.js
new file mode 100644
index 0000000000..1aa74a9c64
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_updateMessage.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/. */
+
+add_task(async function testUpdate() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ conversation.writeMessage("mochitest", "hello world", {
+ incoming: true,
+ remoteId: "foo",
+ });
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ const browserDisplayed = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+ await browserDisplayed;
+
+ is(
+ messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt")
+ .textContent,
+ "hello world",
+ "message added to conv"
+ );
+
+ const updateTextPromise = waitForNotification(conversation, "update-text");
+ conversation.updateMessage("mochitest", "bye world", {
+ incoming: true,
+ remoteId: "foo",
+ });
+ await updateTextPromise;
+ await TestUtils.waitForTick();
+
+ is(
+ messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt")
+ .textContent,
+ "bye world",
+ "message text updated"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/head.js b/comm/mail/components/im/test/browser/head.js
new file mode 100644
index 0000000000..b80d274149
--- /dev/null
+++ b/comm/mail/components/im/test/browser/head.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/. */
+
+var { registerTestProtocol, unregisterTestProtocol } =
+ ChromeUtils.importESModule("resource://testing-common/TestProtocol.sys.mjs");
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+async function openChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ let chatMode = tabmail.tabModes.chat;
+
+ if (chatMode.tabs.length == 1) {
+ tabmail.selectedTab = chatMode.tabs[0];
+ } else {
+ window.showChatTab();
+ }
+
+ is(chatMode.tabs.length, 1, "chat tab is open");
+ is(tabmail.selectedTab, chatMode.tabs[0], "chat tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ let chatMode = tabmail.tabModes.chat;
+
+ if (chatMode.tabs.length == 1) {
+ tabmail.closeTab(chatMode.tabs[0]);
+ }
+
+ is(chatMode.tabs.length, 0, "chat tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * @param {prplIConversation} conversation
+ * @returns {HTMLElement} The corresponding chat-imconv-richlistitem element.
+ */
+function getConversationItem(conversation) {
+ const convList = document.getElementById("contactlistbox");
+ const convNode = Array.from(convList.children).find(
+ element =>
+ element.getAttribute("is") === "chat-imconv-richlistitem" &&
+ element.getAttribute("displayname") === conversation.name
+ );
+ return convNode;
+}
+
+/**
+ * @param {prplIConversation} conversation
+ * @returns {HTMLElement} The corresponding chat-conversation element.
+ */
+function getChatConversationElement(conversation) {
+ const chatConv = Array.from(
+ document.querySelectorAll("chat-conversation")
+ ).find(element => element._conv.target.wrappedJSObject === conversation);
+ return chatConv;
+}
+
+/**
+ * @param {HTMLElement} chatConv - chat-conversation element.
+ * @returns {HTMLElement} The parent element to all chat messages.
+ */
+async function getChatMessageParent(chatConv) {
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+ const messageParent = chatConv.convBrowser.contentChatNode;
+ return messageParent;
+}
+
+/**
+ * @param {HTMLElement} [browser] - The conversation-browser element.
+ * @returns {Promise<void>}
+ */
+function waitForConversationLoad(browser) {
+ return TestUtils.topicObserved(
+ "conversation-loaded",
+ subject => !browser || subject === browser
+ );
+}
+
+function waitForNotification(target, expectedTopic) {
+ let observer;
+ let promise = new Promise(resolve => {
+ observer = {
+ observe(subject, topic, data) {
+ if (topic === expectedTopic) {
+ resolve({ subject, data });
+ target.removeObserver(observer);
+ }
+ },
+ };
+ });
+ target.addObserver(observer);
+ return promise;
+}
+
+registerTestProtocol();
+
+registerCleanupFunction(async () => {
+ // Make sure the chat state is clean
+ await closeChatTab();
+
+ const conversations = IMServices.conversations.getConversations();
+ is(conversations.length, 0, "All conversations were closed by their test");
+ for (const conversation of conversations) {
+ try {
+ conversation.close();
+ } catch (error) {
+ ok(false, error.message);
+ }
+ }
+
+ const accounts = IMServices.accounts.getAccounts();
+ is(accounts.length, 0, "All accounts were removed by their test");
+ for (const account of accounts) {
+ try {
+ if (account.connected || account.connecting) {
+ account.disconnect();
+ }
+ IMServices.accounts.deleteAccount(account.id);
+ } catch (error) {
+ ok(false, "Error deleting account " + account.id + ": " + error.message);
+ }
+ }
+
+ unregisterTestProtocol();
+});
diff --git a/comm/mail/components/im/test/components.conf b/comm/mail/components/im/test/components.conf
new file mode 100644
index 0000000000..3f8c09fc09
--- /dev/null
+++ b/comm/mail/components/im/test/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{a4617631-b8b8-4053-8afa-5c4c43498280}',
+ 'contract_ids': ['@mozilla.org/chat/mochitest;1'],
+ 'esModule': 'resource://testing-common/TestProtocol.sys.mjs',
+ 'constructor': 'TestProtocol',
+ },
+]
diff --git a/comm/mail/components/migration/content/migration.js b/comm/mail/components/migration/content/migration.js
new file mode 100644
index 0000000000..10e43600d1
--- /dev/null
+++ b/comm/mail/components/migration/content/migration.js
@@ -0,0 +1,464 @@
+/* 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 { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var kIMig = Ci.nsIMailProfileMigrator;
+var kIPStartup = Ci.nsIProfileStartup;
+var kProfileMigratorContractIDPrefix =
+ "@mozilla.org/profile/migrator;1?app=mail&type=";
+
+var MigrationWizard = {
+ _source: "", // Source Profile Migrator ContractID suffix
+ _itemsFlags: kIMig.ALL, // Selected Import Data Sources (16-bit bitfield)
+ _selectedProfile: null, // Selected Profile name to import from
+ _wiz: null,
+ _migrator: null,
+ _autoMigrate: null,
+
+ init() {
+ document
+ .querySelector("wizard")
+ .addEventListener("wizardback", this.onBack.bind(this));
+ document
+ .querySelector("wizard")
+ .addEventListener("wizardcancel", this.onCancel.bind(this));
+
+ let importSourcePage = document.getElementById("importSource");
+ importSourcePage.addEventListener(
+ "pageadvanced",
+ this.onImportSourcePageAdvanced.bind(this)
+ );
+
+ let selectProfilePage = document.getElementById("selectProfile");
+ selectProfilePage.addEventListener(
+ "pageshow",
+ this.onSelectProfilePageShow.bind(this)
+ );
+ selectProfilePage.addEventListener(
+ "pagerewound",
+ this.onSelectProfilePageRewound.bind(this)
+ );
+ selectProfilePage.addEventListener(
+ "pageadvanced",
+ this.onSelectProfilePageAdvanced.bind(this)
+ );
+
+ let importItemsPage = document.getElementById("importItems");
+ importItemsPage.addEventListener(
+ "pageshow",
+ this.onImportItemsPageShow.bind(this)
+ );
+ importItemsPage.addEventListener(
+ "pagerewound",
+ this.onImportItemsPageAdvanced.bind(this)
+ );
+ importItemsPage.addEventListener(
+ "pageadvanced",
+ this.onImportItemsPageAdvanced.bind(this)
+ );
+
+ let migratingPage = document.getElementById("migrating");
+ migratingPage.addEventListener(
+ "pageshow",
+ this.onMigratingPageShow.bind(this)
+ );
+
+ let donePage = document.getElementById("done");
+ donePage.addEventListener("pageshow", this.onDonePageShow.bind(this));
+
+ let failedPage = document.getElementById("failed");
+ failedPage.addEventListener("pageshow", () => (this._failed = true));
+ failedPage.addEventListener("pagerewound", () => (this._failed = false));
+
+ Services.obs.addObserver(this, "Migration:Started");
+ Services.obs.addObserver(this, "Migration:ItemBeforeMigrate");
+ Services.obs.addObserver(this, "Migration:ItemAfterMigrate");
+ Services.obs.addObserver(this, "Migration:Ended");
+ Services.obs.addObserver(this, "Migration:Progress");
+
+ this._wiz = document.querySelector("wizard");
+
+ if ("arguments" in window && !window.arguments[3]) {
+ this._source = window.arguments[0];
+ this._migrator = window.arguments[1]
+ ? window.arguments[1].QueryInterface(kIMig)
+ : null;
+ this._autoMigrate = window.arguments[2].QueryInterface(kIPStartup);
+
+ // Show the "nothing" option in the automigrate case to provide an
+ // easily identifiable way to avoid migration and create a new profile.
+ var nothing = document.getElementById("nothing");
+ nothing.hidden = false;
+ }
+
+ this.onImportSourcePageShow();
+
+ // Behavior alert! If we were given a migrator already, then we are going to perform migration
+ // with that migrator, skip the wizard screen where we show all of the migration sources and
+ // jump right into migration.
+ if (this._migrator) {
+ if (this._migrator.sourceHasMultipleProfiles) {
+ this._wiz.goTo("selectProfile");
+ } else {
+ var sourceProfiles = this._migrator.sourceProfiles;
+ this._selectedProfile = sourceProfiles[0];
+ this._wiz.goTo("migrating");
+ }
+ }
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "Migration:Started");
+ Services.obs.removeObserver(this, "Migration:ItemBeforeMigrate");
+ Services.obs.removeObserver(this, "Migration:ItemAfterMigrate");
+ Services.obs.removeObserver(this, "Migration:Ended");
+ Services.obs.removeObserver(this, "Migration:Progress");
+
+ // Imported accounts don't show up without restarting.
+ if (this._wiz.onLastPage && !this._failed) {
+ MailUtils.restartApplication();
+ }
+ },
+
+ // 1 - Import Source
+ onImportSourcePageShow() {
+ this._wiz.canRewind = false;
+ this._wiz.canAdvance = false;
+
+ // Figure out what source apps are are available to import from:
+ var group = document.getElementById("importSourceGroup");
+ for (let childNode of group.children) {
+ let suffix = childNode.id;
+ if (suffix != "nothing") {
+ var contractID =
+ kProfileMigratorContractIDPrefix + suffix.split("-")[0];
+ var migrator = Cc[contractID].createInstance(kIMig);
+ if (!migrator.sourceExists) {
+ childNode.hidden = true;
+ if (this._source == suffix) {
+ this._source = null;
+ }
+ }
+ }
+ }
+
+ var firstNonDisabled = null;
+ for (let childNode of group.children) {
+ if (!childNode.hidden && !childNode.disabled) {
+ firstNonDisabled = childNode;
+ break;
+ }
+ }
+ group.selectedItem =
+ this._source == ""
+ ? firstNonDisabled
+ : document.getElementById(this._source);
+
+ if (firstNonDisabled) {
+ this._wiz.canAdvance = true;
+ document.getElementById("importSourceFound").hidden = false;
+ return;
+ }
+ // If no usable import module was found, inform user and enable back button.
+ document.getElementById("importSourceNotFound").hidden = false;
+ this._wiz.canRewind = true;
+ this._wiz.getButton("back").setAttribute("hidden", "false");
+ },
+
+ onImportSourcePageAdvanced() {
+ var newSource =
+ document.getElementById("importSourceGroup").selectedItem.id;
+
+ if (newSource == "nothing") {
+ document.querySelector("wizard").cancel();
+ return;
+ }
+
+ if (!this._migrator || newSource != this._source) {
+ // Create the migrator for the selected source.
+ var contractID =
+ kProfileMigratorContractIDPrefix + newSource.split("-")[0];
+ this._migrator = Cc[contractID].createInstance(kIMig);
+
+ this._itemsFlags = kIMig.ALL;
+ this._selectedProfile = null;
+ }
+
+ this._source = newSource;
+
+ // check for more than one source profile
+ if (this._migrator.sourceHasMultipleProfiles) {
+ this._wiz.currentPage.next = "selectProfile";
+ } else {
+ this._wiz.currentPage.next = "migrating";
+ var sourceProfiles = this._migrator.sourceProfiles;
+ if (sourceProfiles && sourceProfiles.length == 1) {
+ this._selectedProfile = sourceProfiles[0];
+ } else {
+ this._selectedProfile = "";
+ }
+ }
+ },
+
+ // 2 - [Profile Selection]
+ onSelectProfilePageShow() {
+ // Disabling this for now, since we ask about import sources in automigration
+ // too and don't want to disable the back button
+ // if (this._autoMigrate)
+ // document.querySelector("wizard").getButton("back").disabled = true;
+
+ var profiles = document.getElementById("profiles");
+ while (profiles.hasChildNodes()) {
+ profiles.lastChild.remove();
+ }
+
+ if (!this._migrator) {
+ return;
+ }
+ var sourceProfiles = this._migrator.sourceProfiles;
+ var count = sourceProfiles.length;
+ for (var i = 0; i < count; ++i) {
+ var item = document.createXULElement("radio");
+ item.id = sourceProfiles[i];
+ item.setAttribute("label", item.id);
+ profiles.appendChild(item);
+ }
+
+ profiles.selectedItem = this._selectedProfile
+ ? document.getElementById(this._selectedProfile)
+ : profiles.firstElementChild;
+ },
+
+ onSelectProfilePageRewound() {
+ var profiles = document.getElementById("profiles");
+ this._selectedProfile = profiles.selectedItem.id;
+ },
+
+ onSelectProfilePageAdvanced() {
+ var profiles = document.getElementById("profiles");
+ this._selectedProfile = profiles.selectedItem.id;
+
+ // If we're automigrating, don't show the item selection page, just grab everything.
+ if (this._autoMigrate) {
+ this._wiz.currentPage.next = "migrating";
+ }
+ },
+
+ // 3 - ImportItems
+ onImportItemsPageShow() {
+ var dataSources = document.getElementById("dataSources");
+ while (dataSources.hasChildNodes()) {
+ dataSources.lastChild.remove();
+ }
+
+ var bundle = document.getElementById("bundle");
+
+ var items = this._migrator.getMigrateData(
+ this._selectedProfile,
+ this._autoMigrate
+ );
+ for (var i = 0; i < 16; ++i) {
+ var itemID = (items >> i) & 0x1 ? Math.pow(2, i) : 0;
+ if (itemID > 0) {
+ var checkbox = document.createXULElement("checkbox");
+ checkbox.id = itemID;
+ checkbox.setAttribute(
+ "label",
+ bundle.getString(itemID + "_" + this._source.split("-")[0])
+ );
+ dataSources.appendChild(checkbox);
+ if (!this._itemsFlags || this._itemsFlags & itemID) {
+ checkbox.checked = true;
+ }
+ }
+ }
+ },
+
+ onImportItemsPageAdvanced() {
+ var dataSources = document.getElementById("dataSources");
+ this._itemsFlags = 0;
+ for (var i = 0; i < dataSources.children.length; ++i) {
+ var checkbox = dataSources.children[i];
+ if (checkbox.localName == "checkbox" && checkbox.checked) {
+ this._itemsFlags |= parseInt(checkbox.id);
+ }
+ }
+ },
+
+ onImportItemCommand(aEvent) {
+ var items = document.getElementById("dataSources");
+ var checkboxes = items.getElementsByTagName("checkbox");
+
+ var oneChecked = false;
+ for (var i = 0; i < checkboxes.length; ++i) {
+ if (checkboxes[i].checked) {
+ oneChecked = true;
+ break;
+ }
+ }
+
+ this._wiz.canAdvance = oneChecked;
+ },
+
+ // 4 - Migrating
+ async onMigratingPageShow() {
+ this._wiz.getButton("cancel").disabled = true;
+ this._wiz.canRewind = false;
+ this._wiz.canAdvance = false;
+
+ // When automigrating or migrating all, show all of the data that can
+ // be received from this source.
+ if (this._autoMigrate || this._itemsFlags == kIMig.ALL) {
+ this._itemsFlags = this._migrator.getMigrateData(
+ this._selectedProfile,
+ this._autoMigrate
+ );
+ }
+
+ this._listItems("migratingItems");
+ try {
+ await this.onMigratingMigrate();
+ } catch (e) {
+ switch (e.message) {
+ case "file-picker-cancelled":
+ this._wiz.canRewind = true;
+ this._wiz.rewind();
+ this._wiz.canAdvance = true;
+ return;
+ case "zip-file-too-big":
+ this._wiz.canRewind = true;
+ this._wiz.rewind();
+ this._wiz.canAdvance = true;
+ let [zipFileTooBigTitle, zipFileTooBigMessage] =
+ await document.l10n.formatValues([
+ "zip-file-too-big-title",
+ "zip-file-too-big-message",
+ ]);
+ Services.prompt.alert(
+ window,
+ zipFileTooBigTitle,
+ zipFileTooBigMessage
+ );
+ document.getElementById("importSourceGroup").selectedItem =
+ document.getElementById("thunderbird-dir");
+ return;
+ default:
+ document.getElementById("failed-message-default").hidden = e.message;
+ document.getElementById("failed-message").hidden = !e.message;
+ document.getElementById("failed-message").textContent =
+ e.message || "";
+ this._wiz.canAdvance = true;
+ this._wiz.advance("failed");
+ throw e;
+ }
+ }
+ },
+
+ async onMigratingMigrate(aOuter) {
+ let [source, type] = this._source.split("-");
+ if (source == "thunderbird") {
+ // Ask user for the profile directory location.
+ await this._migrator.wrappedJSObject.getProfileDir(window, type);
+ await this._migrator.wrappedJSObject.asyncMigrate();
+ return;
+ }
+ this._migrator.migrate(
+ this._itemsFlags,
+ this._autoMigrate,
+ this._selectedProfile
+ );
+ },
+
+ _listItems(aID) {
+ var items = document.getElementById(aID);
+ while (items.hasChildNodes()) {
+ items.lastChild.remove();
+ }
+
+ var bundle = document.getElementById("bundle");
+ for (var i = 0; i < 16; ++i) {
+ var itemID = (this._itemsFlags >> i) & 0x1 ? Math.pow(2, i) : 0;
+ if (itemID > 0) {
+ var label = document.createXULElement("label");
+ label.id = itemID + "_migrated";
+ try {
+ label.setAttribute(
+ "value",
+ "- " + bundle.getString(itemID + "_" + this._source.split("-")[0])
+ );
+ items.appendChild(label);
+ } catch (e) {
+ // if the block above throws, we've enumerated all the import data types we
+ // currently support and are now just wasting time, break.
+ break;
+ }
+ }
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "Migration:Started":
+ dump("*** started\n");
+ break;
+ case "Migration:ItemBeforeMigrate": {
+ dump("*** before " + aData + "\n");
+ let label = document.getElementById(aData + "_migrated");
+ if (label) {
+ label.setAttribute("style", "font-weight: bold");
+ }
+ break;
+ }
+ case "Migration:ItemAfterMigrate": {
+ dump("*** after " + aData + "\n");
+ let label = document.getElementById(aData + "_migrated");
+ if (label) {
+ label.removeAttribute("style");
+ }
+ break;
+ }
+ case "Migration:Ended":
+ dump("*** done\n");
+ if (this._autoMigrate) {
+ // We're done now.
+ this._wiz.canAdvance = true;
+ this._wiz.advance();
+ setTimeout(window.close, 5000);
+ } else {
+ this._wiz.canAdvance = true;
+ var nextButton = this._wiz.getButton("next");
+ nextButton.click();
+ }
+ break;
+ case "Migration:Progress":
+ document.getElementById("progressBar").value = aData;
+ break;
+ }
+ },
+
+ onDonePageShow() {
+ this._wiz.getButton("cancel").disabled = true;
+ this._wiz.canRewind = false;
+ this._listItems("doneItems");
+ },
+
+ onBack(event) {
+ this._wiz.goTo("importSource");
+ this._wiz.canRewind = false;
+ event.preventDefault();
+ },
+
+ onCancel() {
+ // If .closeMigration is false, the user clicked Back button,
+ // then do not change its value.
+ if (
+ window.arguments[3] &&
+ "closeMigration" in window.arguments[3] &&
+ window.arguments[3].closeMigration !== false
+ ) {
+ window.arguments[3].closeMigration = true;
+ }
+ },
+};
diff --git a/comm/mail/components/migration/content/migration.xhtml b/comm/mail/components/migration/content/migration.xhtml
new file mode 100644
index 0000000000..4171d02f63
--- /dev/null
+++ b/comm/mail/components/migration/content/migration.xhtml
@@ -0,0 +1,89 @@
+<?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://messenger/skin/messenger.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/migration/migration.dtd" >
+
+<window id="migrationWizard"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&migrationWizard.title;"
+ onload="MigrationWizard.init()"
+ onunload="MigrationWizard.uninit()"
+ style="width: 40em;"
+ branded="true"
+ buttons="accept,cancel">
+ <linkset>
+ <html:link rel="localization" href="toolkit/global/wizard.ftl"/>
+ <html:link rel="localization" href="messenger/importDialog.ftl"/>
+ </linkset>
+
+ <script src="chrome://messenger/content/migration/migration.js"/>
+
+ <stringbundle id="bundle" src="chrome://messenger/locale/migration/migration.properties"/>
+
+ <wizard>
+ <wizardpage id="importSource" pageid="importSource" next="selectProfile"
+ label="&importSource.title;">
+ <vbox id="importSourceFound" hidden="true">
+#ifdef XP_WIN
+ <label control="importSourceGroup">&importFromWin.label;</label>
+#else
+ <label control="importSourceGroup">&importFromNonWin.label;</label>
+#endif
+ <radiogroup id="importSourceGroup">
+ <radio id="thunderbird-zip" data-l10n-id="import-from-thunderbird-zip"/>
+ <radio id="thunderbird-dir" data-l10n-id="import-from-thunderbird-dir"/>
+ <radio id="seamonkey" label="&importFromSeamonkey3.label;"
+ accesskey="&importFromSeamonkey3.accesskey;"/>
+#ifdef XP_WIN
+ <radio id="outlook" label="&importFromOutlook.label;"
+ accesskey="&importFromOutlook.accesskey;"/>
+#endif
+ <radio id="nothing" label="&importFromNothing.label;"
+ accesskey="&importFromNothing.accesskey;" hidden="true"/>
+ </radiogroup>
+ </vbox>
+ <label id="importSourceNotFound" hidden="true">&importSourceNotFound.label;</label>
+ </wizardpage>
+
+ <wizardpage id="selectProfile" pageid="selectProfile" label="&selectProfile.title;"
+ next="importItems">
+ <label control="profiles">&selectProfile.label;</label>
+ <radiogroup id="profiles" align="start"/>
+ </wizardpage>
+
+ <wizardpage id="importItems" pageid="importItems" label="&importItems.title;"
+ next="migrating"
+ oncommand="MigrationWizard.onImportItemCommand();">
+ <description>&importItems.label;</description>
+ <vbox id="dataSources"
+ style="overflow: auto; appearance: auto; -moz-default-appearance: listbox"
+ align="start" flex="1"/>
+ </wizardpage>
+
+ <wizardpage id="migrating" pageid="migrating" label="&migrating.title;"
+ next="done">
+ <description>&migrating.label;</description>
+ <separator class="thin"/>
+ <vbox id="migratingItems" class="indent" style="overflow: auto;" flex="1" align="start"/>
+ <separator class="thin"/>
+ <html:progress class="progressmeter-statusbar" id="progressBar" flex="1" value="0" max="100"/>
+ </wizardpage>
+
+ <wizardpage id="done" pageid="done" label="&done.title;">
+ <description>&done.label;</description>
+ <separator class="thin"/>
+ <vbox id="doneItems" class="indent" style="overflow: auto;" align="start"/>
+ </wizardpage>
+
+ <wizardpage id="failed" pageid="failed" data-l10n-id="wizardpage-failed">
+ <description id="failed-message-default"
+ data-l10n-id="wizardpage-failed-message"></description>
+ <description id="failed-message"></description>
+ </wizardpage>
+ </wizard>
+</window>
diff --git a/comm/mail/components/migration/jar.mn b/comm/mail/components/migration/jar.mn
new file mode 100644
index 0000000000..db93c8847e
--- /dev/null
+++ b/comm/mail/components/migration/jar.mn
@@ -0,0 +1,7 @@
+# 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/migration/migration.xhtml (content/migration.xhtml)
+ content/messenger/migration/migration.js (content/migration.js)
diff --git a/comm/mail/components/migration/moz.build b/comm/mail/components/migration/moz.build
new file mode 100644
index 0000000000..110c0645dd
--- /dev/null
+++ b/comm/mail/components/migration/moz.build
@@ -0,0 +1,11 @@
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+ "public",
+ "src",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/migration/public/moz.build b/comm/mail/components/migration/public/moz.build
new file mode 100644
index 0000000000..20c4c0e5c5
--- /dev/null
+++ b/comm/mail/components/migration/public/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# 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/.
+
+XPIDL_SOURCES += [
+ "nsIMailProfileMigrator.idl",
+]
+
+XPIDL_MODULE = "mailprofilemigration"
+
+EXPORTS += []
diff --git a/comm/mail/components/migration/public/nsIMailProfileMigrator.idl b/comm/mail/components/migration/public/nsIMailProfileMigrator.idl
new file mode 100644
index 0000000000..398857b459
--- /dev/null
+++ b/comm/mail/components/migration/public/nsIMailProfileMigrator.idl
@@ -0,0 +1,70 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsISupports.idl"
+
+interface nsIFile;
+interface nsIProfileStartup;
+
+[scriptable, uuid(fca38a7a-c43f-4b28-adbd-61e5cc942508)]
+interface nsIMailProfileMigrator : nsISupports
+{
+ /**
+ * profile items to migrate. use with migrate().
+ */
+ const unsigned short ALL = 0x0000;
+ const unsigned short SETTINGS = 0x0001;
+ const unsigned short ACCOUNT_SETTINGS = 0x0002;
+ const unsigned short ADDRESSBOOK_DATA = 0x0004;
+ const unsigned short JUNKTRAINING = 0x0008;
+ const unsigned short PASSWORDS = 0x0010;
+ const unsigned short OTHERDATA = 0x0020;
+ const unsigned short NEWSDATA = 0x0040;
+ const unsigned short MAILDATA = 0x0080;
+ const unsigned short FILTERS = 0x0100;
+
+ /**
+ * Copy user profile information to the current active profile.
+ * @param aItems list of data items to migrate. see above for values.
+ * @param aReplace replace or append current data where applicable.
+ * @param aProfile profile to migrate from, if there is more than one.
+ */
+ void migrate(in unsigned short aItems, in nsIProfileStartup aStartup, in wstring aProfile);
+
+ /**
+ * A bit field containing profile items that this migrator
+ * offers for import.
+ * @param aProfile the profile that we are looking for available data
+ * to import
+ * @param aStarting "true" if the profile is not currently being used.
+ * @returns bit field containing profile items (see above)
+ */
+ unsigned short getMigrateData(in wstring aProfile, in boolean aDoingStartup);
+
+ /**
+ * Whether or not there is any data that can be imported from this
+ * mailer (i.e. whether or not it is installed, and there exists
+ * a user profile)
+ */
+ readonly attribute boolean sourceExists;
+
+ /**
+ * Whether or not the import source implementing this interface
+ * has multiple user profiles configured.
+ */
+ readonly attribute boolean sourceHasMultipleProfiles;
+
+ /**
+ * An array of available profile names. If the import source does not support
+ * profiles, this attribute is empty.
+ */
+ readonly attribute Array<AString> sourceProfiles;
+
+ /**
+ * An array of available profile locations. If the import source does not
+ * support profiles, this attribute is empty.
+ */
+ readonly attribute Array<nsIFile> sourceProfileLocations;
+};
diff --git a/comm/mail/components/migration/src/ThunderbirdProfileMigrator.jsm b/comm/mail/components/migration/src/ThunderbirdProfileMigrator.jsm
new file mode 100644
index 0000000000..6827ea5523
--- /dev/null
+++ b/comm/mail/components/migration/src/ThunderbirdProfileMigrator.jsm
@@ -0,0 +1,869 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["ThunderbirdProfileMigrator"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const lazy = {};
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["messenger/importDialog.ftl"])
+);
+
+// Pref branches that need special handling.
+const MAIL_IDENTITY = "mail.identity.";
+const MAIL_SERVER = "mail.server.";
+const MAIL_ACCOUNT = "mail.account.";
+const IM_ACCOUNT = "messenger.account.";
+const SMTP_SERVER = "mail.smtpserver.";
+const ADDRESS_BOOK = "ldap_2.servers.";
+const LDAP_AUTO_COMPLETE = "ldap_2.autoComplete.";
+const CALENDAR = "calendar.registry.";
+
+// Prefs (branches) that we do not want to copy directly.
+const IGNORE_PREFS = [
+ "app.update.",
+ "browser.",
+ "calendar.list.sortOrder",
+ "calendar.timezone",
+ "devtools.",
+ "extensions.",
+ "mail.accountmanager.",
+ "mail.cloud_files.accounts.",
+ "mail.newsrc_root",
+ "mail.root.",
+ "mail.smtpservers",
+ "messenger.accounts",
+ "print.",
+ "services.",
+ "toolkit.telemetry.",
+];
+
+// When importing from a zip file, ignoring these folders.
+const IGNORE_DIRS = [
+ "chrome_debugger_profile",
+ "crashes",
+ "datareporting",
+ "extensions",
+ "extension-store",
+ "logs",
+ "minidumps",
+ "saved-telemetry-pings",
+ "security_state",
+ "storage",
+ "xulstore",
+];
+
+/**
+ * A pref is represented as [type, name, value].
+ *
+ * @typedef {["Bool"|"Char"|"Int", string, number|string|boolean]} PrefItem
+ *
+ * A map from source smtp server key to target smtp server key.
+ * @typedef {Map<string, string>} SmtpServerKeyMap
+ *
+ * A map from source identity key to target identity key.
+ * @typedef {Map<string, string>} IdentityKeyMap
+ *
+ * A map from source IM account key to target IM account key.
+ * @typedef {Map<string, string>} IMAccountKeyMap
+ *
+ * A map from source incoming server key to target incoming server key.
+ * @typedef {Map<string, string>} IncomingServerKeyMap
+ */
+
+/**
+ * A class to support importing from a Thunderbird profile directory.
+ *
+ * @implements {nsIMailProfileMigrator}
+ */
+class ThunderbirdProfileMigrator {
+ QueryInterface = ChromeUtils.generateQI(["nsIMailProfileMigrator"]);
+
+ get wrappedJSObject() {
+ return this;
+ }
+
+ _logger = console.createInstance({
+ prefix: "mail.import",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.import.loglevel",
+ });
+
+ get sourceExists() {
+ return true;
+ }
+
+ get sourceProfiles() {
+ return this._sourceProfileDir ? [this._sourceProfileDir.path] : [];
+ }
+
+ get sourceHasMultipleProfiles() {
+ return false;
+ }
+
+ /**
+ * Other profile migrators try known install directories to get a source
+ * profile dir. But in this class, we always ask user for the profile
+ * location.
+ */
+ async getProfileDir(window, type) {
+ let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ let [filePickerTitleZip, filePickerTitleDir] = await lazy.l10n.formatValues(
+ ["import-select-profile-zip", "import-select-profile-dir"]
+ );
+ switch (type) {
+ case "zip":
+ filePicker.init(window, filePickerTitleZip, filePicker.modeOpen);
+ filePicker.appendFilter("", "*.zip");
+ break;
+ case "dir":
+ filePicker.init(window, filePickerTitleDir, filePicker.modeGetFolder);
+ break;
+ default:
+ throw new Error(`Unsupported type: ${type}`);
+ }
+ let selectedFile = await new Promise((resolve, reject) => {
+ filePicker.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !filePicker.file) {
+ reject(new Error("file-picker-cancelled"));
+ return;
+ }
+ resolve(filePicker.file);
+ });
+ });
+ if (selectedFile.isDirectory()) {
+ this._sourceProfileDir = selectedFile;
+ } else {
+ if (selectedFile.fileSize > 2147483647) {
+ // nsIZipReader only supports zip file less than 2GB.
+ // throw new Error(zipFileTooBigMessage);
+ throw new Error("zip-file-too-big");
+ }
+ this._importingFromZip = true;
+ // Extract the zip file to a tmp dir.
+ let targetDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ targetDir.append("tmp-profile");
+ targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ let ZipReader = Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+ );
+ let zip = ZipReader(filePicker.file);
+ for (let entry of zip.findEntries(null)) {
+ let parts = entry.split("/");
+ if (IGNORE_DIRS.includes(parts[1]) || entry.endsWith("/")) {
+ continue;
+ }
+ // Folders can not be unzipped recursively, have to iterate and
+ // extract all file entries one by one.
+ let target = targetDir.clone();
+ for (let part of parts.slice(1)) {
+ // Drop the root folder name in the zip file.
+ target.append(part);
+ }
+ if (!target.parent.exists()) {
+ target.parent.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ try {
+ this._logger.debug(`Extracting ${entry} to ${target.path}`);
+ zip.extract(entry, target);
+ } catch (e) {
+ this._logger.error(e);
+ }
+ }
+ // Use the tmp dir as source profile dir.
+ this._sourceProfileDir = targetDir;
+ }
+ }
+
+ getMigrateData() {
+ return (
+ Ci.nsIMailProfileMigrator.ACCOUNT_SETTINGS |
+ Ci.nsIMailProfileMigrator.MAILDATA |
+ Ci.nsIMailProfileMigrator.NEWSDATA |
+ Ci.nsIMailProfileMigrator.ADDRESSBOOK_DATA |
+ Ci.nsIMailProfileMigrator.SETTINGS
+ );
+ }
+
+ migrate(items, startup, profile) {
+ throw new Error("migrate not implemented");
+ }
+
+ async asyncMigrate() {
+ Services.obs.notifyObservers(null, "Migration:Started");
+ try {
+ await this._importPreferences();
+ } finally {
+ if (this._importingFromZip) {
+ IOUtils.remove(this._sourceProfileDir.path, { recursive: true });
+ }
+ }
+ Services.obs.notifyObservers(null, "Migration:Ended");
+ }
+
+ /**
+ * Collect interested prefs from this._sourceProfileDir, then import them one
+ * by one.
+ */
+ async _importPreferences() {
+ // A Map to collect all prefs in interested pref branches.
+ // @type {Map<string, PrefItem[]>}
+ let branchPrefsMap = new Map([
+ [MAIL_IDENTITY, []],
+ [MAIL_SERVER, []],
+ [MAIL_ACCOUNT, []],
+ [IM_ACCOUNT, []],
+ [SMTP_SERVER, []],
+ [ADDRESS_BOOK, []],
+ [CALENDAR, []],
+ ]);
+ let accounts;
+ let defaultAccount;
+ let defaultSmtpServer;
+ let ldapAutoComplete = {};
+ let otherPrefs = [];
+
+ let sourcePrefsFile = this._sourceProfileDir.clone();
+ sourcePrefsFile.append("prefs.js");
+ let sourcePrefsBuffer = await IOUtils.read(sourcePrefsFile.path);
+
+ let savePref = (type, name, value) => {
+ for (let [branchName, branchPrefs] of branchPrefsMap) {
+ if (name.startsWith(branchName)) {
+ branchPrefs.push([type, name.slice(branchName.length), value]);
+ return;
+ }
+ }
+ if (name == "mail.accountmanager.accounts") {
+ accounts = value;
+ return;
+ }
+ if (name == "mail.accountmanager.defaultaccount") {
+ defaultAccount = value;
+ return;
+ }
+ if (name == "mail.smtp.defaultserver") {
+ defaultSmtpServer = value;
+ return;
+ }
+ if (name.startsWith(LDAP_AUTO_COMPLETE)) {
+ ldapAutoComplete[name.slice(LDAP_AUTO_COMPLETE.length)] = value;
+ return;
+ }
+ if (IGNORE_PREFS.some(ignore => name.startsWith(ignore))) {
+ return;
+ }
+ // Collect all the other prefs.
+ otherPrefs.push([type, name, value]);
+ };
+
+ Services.prefs.parsePrefsFromBuffer(sourcePrefsBuffer, {
+ onStringPref: (kind, name, value) => savePref("Char", name, value),
+ onIntPref: (kind, name, value) => savePref("Int", name, value),
+ onBoolPref: (kind, name, value) => savePref("Bool", name, value),
+ onError: msg => {
+ throw new Error(msg);
+ },
+ });
+
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemBeforeMigrate",
+ Ci.nsIMailProfileMigrator.ACCOUNT_SETTINGS
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ // Import SMTP servers first, the importing order is important.
+ let smtpServerKeyMap = this._importSmtpServers(
+ branchPrefsMap.get(SMTP_SERVER),
+ defaultSmtpServer
+ );
+ // mail.identity.idN.smtpServer depends on transformed smtp server key.
+ let identityKeyMap = this._importIdentities(
+ branchPrefsMap.get(MAIL_IDENTITY),
+ smtpServerKeyMap
+ );
+ let imAccountKeyMap = await this._importIMAccounts(
+ branchPrefsMap.get(IM_ACCOUNT)
+ );
+ // mail.server.serverN.imAccount depends on transformed im account key.
+ let incomingServerKeyMap = await this._importIncomingServers(
+ branchPrefsMap.get(MAIL_SERVER),
+ imAccountKeyMap
+ );
+ // mail.account.accountN.{identities, server} depends on previous steps.
+ this._importAccounts(
+ branchPrefsMap.get(MAIL_ACCOUNT),
+ accounts,
+ defaultAccount,
+ identityKeyMap,
+ incomingServerKeyMap
+ );
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemAfterMigrate",
+ Ci.nsIMailProfileMigrator.ACCOUNT_SETTINGS
+ );
+ Services.obs.notifyObservers(null, "Migration:Progress", "25");
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemBeforeMigrate",
+ Ci.nsIMailProfileMigrator.MAILDATA
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ await this._copyMailFolders(incomingServerKeyMap);
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemAfterMigrate",
+ Ci.nsIMailProfileMigrator.MAILDATA
+ );
+ Services.obs.notifyObservers(null, "Migration:Progress", "50");
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemBeforeMigrate",
+ Ci.nsIMailProfileMigrator.ADDRESSBOOK_DATA
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ this._importAddressBooks(
+ branchPrefsMap.get(ADDRESS_BOOK),
+ ldapAutoComplete
+ );
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemAfterMigrate",
+ Ci.nsIMailProfileMigrator.ADDRESSBOOK_DATA
+ );
+ Services.obs.notifyObservers(null, "Migration:Progress", "75");
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemBeforeMigrate",
+ Ci.nsIMailProfileMigrator.SETTINGS
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ this._importPasswords();
+ this._importOtherPrefs(otherPrefs);
+ this._importCalendars(branchPrefsMap.get(CALENDAR));
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemAfterMigrate",
+ Ci.nsIMailProfileMigrator.SETTINGS
+ );
+ Services.obs.notifyObservers(null, "Migration:Progress", "100");
+ }
+
+ /**
+ * Import SMTP servers.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the SMTP_SERVER branch.
+ * @param {string} sourceDefaultServer - The value of mail.smtp.defaultserver
+ * in the source profile.
+ * @returns {smtpServerKeyMap} A map from source server key to new server key.
+ */
+ _importSmtpServers(prefs, sourceDefaultServer) {
+ let smtpServerKeyMap = new Map();
+ let branch = Services.prefs.getBranch(SMTP_SERVER);
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ let newServerKey = smtpServerKeyMap.get(key);
+ if (!newServerKey) {
+ // For every smtp server, create a new one to avoid conflicts.
+ let server = MailServices.smtp.createServer();
+ newServerKey = server.key;
+ smtpServerKeyMap.set(key, newServerKey);
+ this._logger.debug(
+ `Mapping SMTP server from ${key} to ${newServerKey}`
+ );
+ }
+
+ let newName = `${newServerKey}${name.slice(key.length)}`;
+ branch[`set${type}Pref`](newName, value);
+ }
+
+ // Set defaultserver if it doesn't already exist.
+ let defaultServer = Services.prefs.getCharPref(
+ "mail.smtp.defaultserver",
+ ""
+ );
+ if (sourceDefaultServer && !defaultServer) {
+ Services.prefs.setCharPref(
+ "mail.smtp.defaultserver",
+ smtpServerKeyMap.get(sourceDefaultServer)
+ );
+ }
+ return smtpServerKeyMap;
+ }
+
+ /**
+ * Import mail identites.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the MAIL_IDENTITY branch.
+ * @param {SmtpServerKeyMap} smtpServerKeyMap - A map from the source SMTP
+ * server key to new SMTP server key.
+ * @returns {IdentityKeyMap} A map from the source identity key to new identity
+ * key.
+ */
+ _importIdentities(prefs, smtpServerKeyMap) {
+ let identityKeyMap = new Map();
+ let branch = Services.prefs.getBranch(MAIL_IDENTITY);
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ let newIdentityKey = identityKeyMap.get(key);
+ if (!newIdentityKey) {
+ // For every identity, create a new one to avoid conflicts.
+ let identity = MailServices.accounts.createIdentity();
+ newIdentityKey = identity.key;
+ identityKeyMap.set(key, newIdentityKey);
+ this._logger.debug(`Mapping identity from ${key} to ${newIdentityKey}`);
+ }
+
+ let newName = `${newIdentityKey}${name.slice(key.length)}`;
+ let newValue = value;
+ if (name.endsWith(".smtpServer")) {
+ newValue = smtpServerKeyMap.get(value) || newValue;
+ }
+ branch[`set${type}Pref`](newName, newValue);
+ }
+ return identityKeyMap;
+ }
+
+ /**
+ * Import IM accounts.
+ *
+ * @param {Array<[string, string, number|string|boolean]>} prefs - All source
+ * prefs in the IM_ACCOUNT branch.
+ * @returns {IMAccountKeyMap} A map from the source account key to new account
+ * key.
+ */
+ async _importIMAccounts(prefs) {
+ let imAccountKeyMap = new Map();
+ let branch = Services.prefs.getBranch(IM_ACCOUNT);
+
+ let lastKey = 1;
+ function _getUniqueAccountKey() {
+ let key = `account${lastKey++}`;
+ if (Services.prefs.getCharPref(`messenger.account.${key}.name`, "")) {
+ return _getUniqueAccountKey();
+ }
+ return key;
+ }
+
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ let newAccountKey = imAccountKeyMap.get(key);
+ if (!newAccountKey) {
+ // For every account, create a new one to avoid conflicts.
+ newAccountKey = _getUniqueAccountKey();
+ imAccountKeyMap.set(key, newAccountKey);
+ this._logger.debug(
+ `Mapping IM account from ${key} to ${newAccountKey}`
+ );
+ }
+
+ let newName = `${newAccountKey}${name.slice(key.length)}`;
+ branch[`set${type}Pref`](newName, value);
+ }
+
+ return imAccountKeyMap;
+ }
+
+ /**
+ * Import incoming servers.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the MAIL_SERVER branch.
+ * @param {IMAccountKeyMap} imAccountKeyMap - A map from the source account
+ * key to new account key.
+ * @returns {IncomingServerKeyMap} A map from the source server key to new
+ * server key.
+ */
+ async _importIncomingServers(prefs, imAccountKeyMap) {
+ let incomingServerKeyMap = new Map();
+ let branch = Services.prefs.getBranch(MAIL_SERVER);
+
+ let lastKey = 1;
+ function _getUniqueIncomingServerKey() {
+ let key = `server${lastKey++}`;
+ if (branch.getCharPref(`${key}.type`, "")) {
+ return _getUniqueIncomingServerKey();
+ }
+ return key;
+ }
+
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ let newServerKey = incomingServerKeyMap.get(key);
+ if (!newServerKey) {
+ // For every incoming server, create a new one to avoid conflicts.
+ newServerKey = _getUniqueIncomingServerKey();
+ incomingServerKeyMap.set(key, newServerKey);
+ this._logger.debug(`Mapping server from ${key} to ${newServerKey}`);
+ }
+
+ let newName = `${newServerKey}${name.slice(key.length)}`;
+ let newValue = value;
+ if (newName.endsWith(".imAccount")) {
+ newValue = imAccountKeyMap.get(value);
+ }
+ branch[`set${type}Pref`](newName, newValue || value);
+ }
+ return incomingServerKeyMap;
+ }
+
+ /**
+ * Copy mail folders from this._sourceProfileDir to the current profile dir.
+ *
+ * @param {PrefKeyMap} incomingServerKeyMap - A map from the source server key
+ * to new server key.
+ */
+ async _copyMailFolders(incomingServerKeyMap) {
+ for (let key of incomingServerKeyMap.values()) {
+ let branch = Services.prefs.getBranch(`${MAIL_SERVER}${key}.`);
+ if (!branch) {
+ continue;
+ }
+ let type = branch.getCharPref("type", "");
+ let hostname = branch.getCharPref("hostname", "");
+ if (!type || !hostname) {
+ continue;
+ }
+
+ // Use .directory-rel instead of .directory because .directory is an
+ // absolute path which may not exists.
+ let directoryRel = branch.getCharPref("directory-rel", "");
+ if (!directoryRel.startsWith("[ProfD]")) {
+ continue;
+ }
+ directoryRel = directoryRel.slice("[ProfD]".length);
+
+ let targetDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ if (type == "imap") {
+ targetDir.append("ImapMail");
+ } else if (type == "nntp") {
+ targetDir.append("News");
+ } else if (["none", "pop3", "rss"].includes(type)) {
+ targetDir.append("Mail");
+ } else {
+ continue;
+ }
+
+ // Use the hostname as mail folder name and ensure it's unique.
+ targetDir.append(hostname);
+ targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ // Remove the folder so that nsIFile.copyTo doesn't copy into targetDir.
+ targetDir.remove(false);
+
+ let sourceDir = this._sourceProfileDir.clone();
+ for (let part of directoryRel.split("/")) {
+ sourceDir.append(part);
+ }
+ if (
+ sourceDir.exists() &&
+ (type != "imap" || Services.appinfo.OS != "WINNT")
+ ) {
+ // For some reasons, if mail folders are copied on Windows,
+ // `errorGettingDB` is thrown after imported and restarted. IMAP folders
+ // will be downloaded automatically, better than a broken account.
+ this._logger.debug(`Copying ${sourceDir.path} to ${targetDir.path}`);
+ sourceDir.copyTo(targetDir.parent, targetDir.leafName);
+ }
+ branch.setCharPref("directory", targetDir.path);
+ // .directory-rel may be outdated, it will be created when first needed.
+ branch.clearUserPref("directory-rel");
+
+ if (type == "nntp") {
+ // Use .file-rel instead of .file because .file is an absolute path
+ // which may not exists.
+ let fileRel = branch.getCharPref("newsrc.file-rel", "");
+ if (!fileRel.startsWith("[ProfD]")) {
+ continue;
+ }
+ fileRel = fileRel.slice("[ProfD]".length);
+ let sourceNewsrc = this._sourceProfileDir.clone();
+ for (let part of fileRel.split("/")) {
+ sourceNewsrc.append(part);
+ }
+ let targetNewsrc = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ targetNewsrc.append("News");
+ targetNewsrc.append(`newsrc-${hostname}`);
+ targetNewsrc.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+ this._logger.debug(
+ `Copying ${sourceNewsrc.path} to ${targetNewsrc.path}`
+ );
+ sourceNewsrc.copyTo(targetNewsrc.parent, targetNewsrc.leafName);
+ branch.setCharPref("newsrc.file", targetNewsrc.path);
+ // .file-rel may be outdated, it will be created when first needed.
+ branch.clearUserPref("newsrc.file-rel");
+ }
+ }
+ }
+
+ /**
+ * Import mail accounts.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the MAIL_ACCOUNT branch.
+ * @param {string} sourceAccounts - The value of mail.accountmanager.accounts
+ * in the source profile.
+ * @param {string} sourceDefaultAccount - The value of
+ * mail.accountmanager.defaultaccount in the source profile.
+ * @param {IdentityKeyMap} identityKeyMap - A map from the source identity key
+ * to new identity key.
+ * @param {IncomingServerKeyMap} incomingServerKeyMap - A map from the source
+ * server key to new server key.
+ */
+ _importAccounts(
+ prefs,
+ sourceAccounts,
+ sourceDefaultAccount,
+ identityKeyMap,
+ incomingServerKeyMap
+ ) {
+ let accountKeyMap = new Map();
+ let branch = Services.prefs.getBranch(MAIL_ACCOUNT);
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ if (key == "lastKey") {
+ continue;
+ }
+ let newAccountKey = accountKeyMap.get(key);
+ if (!newAccountKey) {
+ // For every account, create a new one to avoid conflicts.
+ newAccountKey = MailServices.accounts.getUniqueAccountKey();
+ accountKeyMap.set(key, newAccountKey);
+ }
+
+ let newName = `${newAccountKey}${name.slice(key.length)}`;
+ let newValue = value;
+ if (name.endsWith(".identities")) {
+ newValue = identityKeyMap.get(value);
+ } else if (name.endsWith(".server")) {
+ newValue = incomingServerKeyMap.get(value);
+ }
+ branch[`set${type}Pref`](newName, newValue || value);
+ }
+
+ // Append newly create accounts to mail.accountmanager.accounts.
+ let accounts = Services.prefs
+ .getCharPref("mail.accountmanager.accounts", "")
+ .split(",");
+ if (accounts.length == 1 && accounts[0] == "") {
+ accounts.length = 0;
+ }
+ if (sourceAccounts) {
+ for (let sourceAccountKey of sourceAccounts.split(",")) {
+ accounts.push(accountKeyMap.get(sourceAccountKey));
+ }
+ Services.prefs.setCharPref(
+ "mail.accountmanager.accounts",
+ accounts.join(",")
+ );
+ }
+
+ // Set defaultaccount if it doesn't already exist.
+ let defaultAccount = Services.prefs.getCharPref(
+ "mail.accountmanager.defaultaccount",
+ ""
+ );
+ if (sourceDefaultAccount && !defaultAccount) {
+ Services.prefs.setCharPref(
+ "mail.accountmanager.defaultaccount",
+ accountKeyMap.get(sourceDefaultAccount)
+ );
+ }
+ }
+
+ /**
+ * Import address books.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the ADDRESS_BOOK branch.
+ * @param {object} ldapAutoComplete - Pref values of LDAP_AUTO_COMPLETE branch.
+ * @param {boolean} ldapAutoComplete.useDirectory
+ * @param {string} ldapAutoComplete.directoryServer
+ */
+ _importAddressBooks(prefs, ldapAutoComplete) {
+ let keyMap = new Map();
+ let branch = Services.prefs.getBranch(ADDRESS_BOOK);
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ if (["pab", "history"].includes(key)) {
+ continue;
+ }
+ let newKey = keyMap.get(key);
+ if (!newKey) {
+ // For every address book, create a new one to avoid conflicts.
+ let uniqueCount = 0;
+ newKey = key;
+ while (true) {
+ if (!branch.getCharPref(`${newKey}.filename`, "")) {
+ break;
+ }
+ newKey = `${key}${++uniqueCount}`;
+ }
+ keyMap.set(key, newKey);
+ }
+
+ let newName = `${newKey}${name.slice(key.length)}`;
+ branch[`set${type}Pref`](newName, value);
+ }
+
+ // Transform the value of ldap_2.autoComplete.directoryServer if needed.
+ if (
+ ldapAutoComplete.useDirectory &&
+ ldapAutoComplete.directoryServer &&
+ !Services.prefs.getBoolPref(`${LDAP_AUTO_COMPLETE}useDirectory`, false)
+ ) {
+ let key = ldapAutoComplete.directoryServer.split("/").slice(-1)[0];
+ let newKey = keyMap.get(key);
+ if (newKey) {
+ Services.prefs.setBoolPref(`${LDAP_AUTO_COMPLETE}useDirectory`, true);
+ Services.prefs.setCharPref(
+ `${LDAP_AUTO_COMPLETE}directoryServer`,
+ `ldap_2.servers.${newKey}`
+ );
+ }
+ }
+
+ this._copyAddressBookDatabases(keyMap);
+ }
+
+ /**
+ * Copy sqlite files from this._sourceProfileDir to the current profile dir.
+ *
+ * @param {Map<string, string>} keyMap - A map from the source address
+ * book key to new address book key.
+ */
+ _copyAddressBookDatabases(keyMap) {
+ // Copy user created address books.
+ for (let key of keyMap.values()) {
+ let branch = Services.prefs.getBranch(`${ADDRESS_BOOK}${key}.`);
+ let filename = branch.getCharPref("filename", "");
+ if (!filename) {
+ continue;
+ }
+ let sourceFile = this._sourceProfileDir.clone();
+ sourceFile.append(filename);
+ if (!sourceFile.exists()) {
+ this._logger.debug(
+ `Ignoring non-existing address boook file ${sourceFile.path}`
+ );
+ continue;
+ }
+
+ let targetFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ targetFile.append(sourceFile.leafName);
+ targetFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+ this._logger.debug(`Copying ${sourceFile.path} to ${targetFile.path}`);
+ sourceFile.copyTo(targetFile.parent, targetFile.leafName);
+
+ branch.setCharPref("filename", targetFile.leafName);
+ }
+
+ // Copy or import Personal Address Book.
+ this._importAddressBookDatabase("abook.sqlite");
+ // Copy or import Collected Addresses.
+ this._importAddressBookDatabase("history.sqlite");
+ }
+
+ /**
+ * Copy a sqlite file from this._sourceProfileDir to the current profile dir.
+ *
+ * @param {string} filename - The name of the sqlite file.
+ */
+ _importAddressBookDatabase(filename) {
+ let sourceFile = this._sourceProfileDir.clone();
+ sourceFile.append(filename);
+ let targetFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ targetFile.append(filename);
+
+ if (!sourceFile.exists()) {
+ return;
+ }
+
+ if (!targetFile.exists()) {
+ sourceFile.copyTo(targetFile.parent, "");
+ return;
+ }
+
+ let dirId = MailServices.ab.newAddressBook(
+ "tmp",
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let tmpDirectory = MailServices.ab.getDirectoryFromId(dirId);
+ sourceFile.copyTo(targetFile.parent, tmpDirectory.fileName);
+
+ let targetDirectory = MailServices.ab.getDirectory(
+ `jsaddrbook://${filename}`
+ );
+ for (let card of tmpDirectory.childCards) {
+ targetDirectory.addCard(card);
+ }
+
+ MailServices.ab.deleteAddressBook(tmpDirectory.URI);
+ }
+
+ /**
+ * Import logins.json and key4.db.
+ */
+ _importPasswords() {
+ let sourceLoginsJson = this._sourceProfileDir.clone();
+ sourceLoginsJson.append("logins.json");
+ let sourceKeyDb = this._sourceProfileDir.clone();
+ sourceKeyDb.append("key4.db");
+ let targetLoginsJson = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ targetLoginsJson.append("logins.json");
+
+ if (
+ sourceLoginsJson.exists() &&
+ sourceKeyDb.exists() &&
+ !targetLoginsJson.exists()
+ ) {
+ // Only copy if logins.json doesn't exist in the current profile.
+ sourceLoginsJson.copyTo(targetLoginsJson.parent, "");
+ sourceKeyDb.copyTo(targetLoginsJson.parent, "");
+ }
+ }
+
+ /**
+ * Import a pref from source only when this pref has no user value in the
+ * current profile.
+ *
+ * @param {PrefItem[]} prefs - All source prefs to try to import.
+ */
+ _importOtherPrefs(prefs) {
+ for (let [type, name, value] of prefs) {
+ if (!Services.prefs.prefHasUserValue(name)) {
+ Services.prefs[`set${type}Pref`](name, value);
+ }
+ }
+ }
+
+ /**
+ * Import calendars.
+ *
+ * For storage calendars, we need to import everything from the source
+ * local.sqlite to the target local.sqlite, which is not implemented yet, see
+ * bug 1719582.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the CALENDAR branch.
+ */
+ _importCalendars(prefs) {
+ let branch = Services.prefs.getBranch(CALENDAR);
+ for (let [type, name, value] of prefs) {
+ branch[`set${type}Pref`](name, value);
+ }
+ }
+}
diff --git a/comm/mail/components/migration/src/components.conf b/comm/mail/components/migration/src/components.conf
new file mode 100644
index 0000000000..85b754c645
--- /dev/null
+++ b/comm/mail/components/migration/src/components.conf
@@ -0,0 +1,38 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ "cid": "{adb2e3a7-8df4-484a-b787-6c2184eb9756}",
+ "contract_ids": ["@mozilla.org/profile/migrator;1?app=mail&type=thunderbird"],
+ "jsm": "resource:///modules/ThunderbirdProfileMigrator.jsm",
+ "constructor": "ThunderbirdProfileMigrator",
+ },
+ {
+ "cid": "{b3c78baf-3a52-41d2-9718-c319bef9affc}",
+ "contract_ids": ["@mozilla.org/toolkit/profile-migrator;1"],
+ "type": "nsProfileMigrator",
+ "headers": ["/comm/mail/components/migration/src/nsProfileMigrator.h"],
+ },
+ {
+ "cid": "{62c6e1f9-3dc3-4b68-9c39-ad2f6d471ac0}",
+ "contract_ids": ["@mozilla.org/profile/migrator;1?app=mail&type=seamonkey"],
+ "type": "nsSeamonkeyProfileMigrator",
+ "headers": ["/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h"],
+ },
+]
+
+if buildconfig.substs["OS_ARCH"] == "WINNT":
+ Classes += [
+ {
+ "cid": "{910b6453-0719-41e8-a4c9-0319bb34c8ff}",
+ "contract_ids": ["@mozilla.org/profile/migrator;1?app=mail&type=outlook"],
+ "type": "nsOutlookProfileMigrator",
+ "headers": [
+ "/comm/mail/components/migration/src/nsOutlookProfileMigrator.h"
+ ],
+ },
+ ]
diff --git a/comm/mail/components/migration/src/moz.build b/comm/mail/components/migration/src/moz.build
new file mode 100644
index 0000000000..cfcc8d8239
--- /dev/null
+++ b/comm/mail/components/migration/src/moz.build
@@ -0,0 +1,32 @@
+# vim: set filetype=python:
+# 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/.
+
+SOURCES += [
+ "nsMailProfileMigratorUtils.cpp",
+ "nsNetscapeProfileMigratorBase.cpp",
+ "nsProfileMigrator.cpp",
+ "nsSeamonkeyProfileMigrator.cpp",
+]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ SOURCES += [
+ "nsOutlookProfileMigrator.cpp",
+ "nsProfileMigratorBase.cpp",
+ ]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ SOURCES += [
+ "nsProfileMigratorBase.cpp",
+ ]
+
+FINAL_LIBRARY = "mailcomps"
+
+EXTRA_JS_MODULES += [
+ "ThunderbirdProfileMigrator.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/migration/src/nsMailProfileMigratorUtils.cpp b/comm/mail/components/migration/src/nsMailProfileMigratorUtils.cpp
new file mode 100644
index 0000000000..795cd514f4
--- /dev/null
+++ b/comm/mail/components/migration/src/nsMailProfileMigratorUtils.cpp
@@ -0,0 +1,86 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsMailProfileMigratorUtils.h"
+#include "nsIFile.h"
+#include "nsIProperties.h"
+#include "nsIProfileMigrator.h"
+
+#include "nsServiceManagerUtils.h"
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsXPCOMCID.h"
+
+void SetProxyPref(const nsACString& aHostPort, const char* aPref,
+ const char* aPortPref, nsIPrefBranch* aPrefs) {
+ nsAutoCString hostPort(aHostPort);
+ int32_t portDelimOffset = hostPort.RFindChar(':');
+ if (portDelimOffset > 0) {
+ nsAutoCString host(Substring(hostPort, 0, portDelimOffset));
+ nsAutoCString port(Substring(hostPort, portDelimOffset + 1,
+ hostPort.Length() - (portDelimOffset + 1)));
+
+ aPrefs->SetCharPref(aPref, host);
+ nsresult stringErr;
+ int32_t portValue = port.ToInteger(&stringErr);
+ aPrefs->SetIntPref(aPortPref, portValue);
+ } else
+ aPrefs->SetCharPref(aPref, hostPort);
+}
+
+void ParseOverrideServers(const char* aServers, nsIPrefBranch* aBranch) {
+ // Windows (and Opera) formats its proxy override list in the form:
+ // server;server;server where server is a server name or ip address,
+ // or "<local>". Mozilla's format is server,server,server, and <local>
+ // must be translated to "localhost,127.0.0.1"
+ nsAutoCString override(aServers);
+ int32_t left = 0, right = 0;
+ for (;;) {
+ right = override.FindChar(';', right);
+ const nsACString& host = Substring(
+ override, left, (right < 0 ? override.Length() : right) - left);
+ if (host.Equals("<local>"))
+ override.Replace(left, 7, "localhost,127.0.0.1"_ns);
+ if (right < 0) break;
+ left = right + 1;
+ override.Replace(right, 1, ","_ns);
+ }
+ aBranch->SetCharPref("network.proxy.no_proxies_on", override);
+}
+
+void GetMigrateDataFromArray(MigrationData* aDataArray,
+ int32_t aDataArrayLength, bool aReplace,
+ nsIFile* aSourceProfile, uint16_t* aResult) {
+ nsCOMPtr<nsIFile> sourceFile;
+ bool exists;
+ MigrationData* cursor;
+ MigrationData* end = aDataArray + aDataArrayLength;
+ for (cursor = aDataArray; cursor < end && cursor->fileName; ++cursor) {
+ // When in replace mode, all items can be imported.
+ // When in non-replace mode, only items that do not require file replacement
+ // can be imported.
+ if (aReplace || !cursor->replaceOnly) {
+ aSourceProfile->Clone(getter_AddRefs(sourceFile));
+ sourceFile->Append(nsDependentString(cursor->fileName));
+ sourceFile->Exists(&exists);
+ if (exists) *aResult |= cursor->sourceFlag;
+ }
+ free(cursor->fileName);
+ cursor->fileName = nullptr;
+ }
+}
+
+void GetProfilePath(nsIProfileStartup* aStartup,
+ nsCOMPtr<nsIFile>& aProfileDir) {
+ if (aStartup) {
+ aStartup->GetDirectory(getter_AddRefs(aProfileDir));
+ } else {
+ nsCOMPtr<nsIProperties> dirSvc(
+ do_GetService(NS_DIRECTORY_SERVICE_CONTRACTID));
+ if (dirSvc) {
+ dirSvc->Get(NS_APP_USER_PROFILE_50_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(aProfileDir));
+ }
+ }
+}
diff --git a/comm/mail/components/migration/src/nsMailProfileMigratorUtils.h b/comm/mail/components/migration/src/nsMailProfileMigratorUtils.h
new file mode 100644
index 0000000000..01f21d0d72
--- /dev/null
+++ b/comm/mail/components/migration/src/nsMailProfileMigratorUtils.h
@@ -0,0 +1,54 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef mailprofilemigratorutils___h___
+#define mailprofilemigratorutils___h___
+
+#define MIGRATION_ITEMBEFOREMIGRATE "Migration:ItemBeforeMigrate"
+#define MIGRATION_ITEMAFTERMIGRATE "Migration:ItemAfterMigrate"
+#define MIGRATION_STARTED "Migration:Started"
+#define MIGRATION_ENDED "Migration:Ended"
+#define MIGRATION_PROGRESS "Migration:Progress"
+
+#define NOTIFY_OBSERVERS(message, item) \
+ mObserverService->NotifyObservers(nullptr, message, item)
+
+#define COPY_DATA(func, replace, itemIndex) \
+ if (NS_SUCCEEDED(rv) && (aItems & itemIndex || !aItems)) { \
+ nsAutoString index; \
+ index.AppendInt(itemIndex); \
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get()); \
+ rv = func(replace); \
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get()); \
+ }
+
+#include "nsIPrefBranch.h"
+#include "nsIFile.h"
+#include "nsString.h"
+#include "nsCOMPtr.h"
+class nsIProfileStartup;
+
+// Proxy utilities shared by the Opera and IE migrators
+void ParseOverrideServers(const char* aServers, nsIPrefBranch* aBranch);
+void SetProxyPref(const nsACString& aHostPort, const char* aPref,
+ const char* aPortPref, nsIPrefBranch* aPrefs);
+
+struct MigrationData {
+ char16_t* fileName;
+ uint32_t sourceFlag;
+ bool replaceOnly;
+};
+
+class nsIFile;
+void GetMigrateDataFromArray(MigrationData* aDataArray,
+ int32_t aDataArrayLength, bool aReplace,
+ nsIFile* aSourceProfile, uint16_t* aResult);
+
+// get the base directory of the *target* profile
+// this is already cloned, modify it to your heart's content
+void GetProfilePath(nsIProfileStartup* aStartup,
+ nsCOMPtr<nsIFile>& aProfileDir);
+
+#endif
diff --git a/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.cpp b/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.cpp
new file mode 100644
index 0000000000..b4a7affe03
--- /dev/null
+++ b/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.cpp
@@ -0,0 +1,371 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsIFile.h"
+#include "nsIInputStream.h"
+#include "nsILineInputStream.h"
+#include "nsIPrefBranch.h"
+#include "nsIPrefLocalizedString.h"
+#include "nsIPrefService.h"
+#include "nsIServiceManager.h"
+#include "nsIURL.h"
+#include "nsNetscapeProfileMigratorBase.h"
+#include "nsNetUtil.h"
+#include "prtime.h"
+#include "prprf.h"
+#include "nsITimer.h"
+#include "nsINIParser.h"
+#include "nsMailProfileMigratorUtils.h"
+#include "nsIDirectoryEnumerator.h"
+#include "nsServiceManagerUtils.h"
+
+#define MIGRATION_BUNDLE \
+ "chrome://messenger/locale/migration/migration.properties"
+
+#define FILE_NAME_PREFS_5X u"prefs.js"_ns
+
+///////////////////////////////////////////////////////////////////////////////
+// nsNetscapeProfileMigratorBase
+nsNetscapeProfileMigratorBase::nsNetscapeProfileMigratorBase() {
+ mObserverService = do_GetService("@mozilla.org/observer-service;1");
+ mMaxProgress = 0;
+ mCurrentProgress = 0;
+ mFileCopyTransactionIndex = 0;
+}
+
+NS_IMPL_ISUPPORTS(nsNetscapeProfileMigratorBase, nsIMailProfileMigrator,
+ nsITimerCallback)
+
+nsresult nsNetscapeProfileMigratorBase::GetProfileDataFromProfilesIni(
+ nsIFile* aDataDir, nsTArray<nsString>& aProfileNames,
+ nsTArray<RefPtr<nsIFile>>& aProfileLocations) {
+ nsCOMPtr<nsIFile> profileIni;
+ nsresult rv = aDataDir->Clone(getter_AddRefs(profileIni));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ profileIni->Append(u"profiles.ini"_ns);
+
+ // Does it exist?
+ bool profileFileExists = false;
+ rv = profileIni->Exists(&profileFileExists);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!profileFileExists) return NS_ERROR_FILE_NOT_FOUND;
+
+ nsINIParser parser;
+ rv = parser.Init(profileIni);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoCString buffer, filePath;
+ bool isRelative;
+
+ // This is an infinite loop that is broken when we no longer find profiles
+ // for profileID with IsRelative option.
+ for (unsigned int c = 0; true; ++c) {
+ nsAutoCString profileID("Profile");
+ profileID.AppendInt(c);
+
+ if (NS_FAILED(parser.GetString(profileID.get(), "IsRelative", buffer)))
+ break;
+
+ isRelative = buffer.EqualsLiteral("1");
+
+ rv = parser.GetString(profileID.get(), "Path", filePath);
+ if (NS_FAILED(rv)) {
+ NS_ERROR("Malformed profiles.ini: Path= not found");
+ continue;
+ }
+
+ rv = parser.GetString(profileID.get(), "Name", buffer);
+ if (NS_FAILED(rv)) {
+ NS_ERROR("Malformed profiles.ini: Name= not found");
+ continue;
+ }
+
+ nsCOMPtr<nsIFile> rootDir;
+ rv = NS_NewNativeLocalFile(EmptyCString(), true, getter_AddRefs(rootDir));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = isRelative ? rootDir->SetRelativeDescriptor(aDataDir, filePath)
+ : rootDir->SetPersistentDescriptor(filePath);
+ if (NS_FAILED(rv)) continue;
+
+ bool exists = false;
+ rootDir->Exists(&exists);
+
+ if (exists) {
+ aProfileLocations.AppendElement(rootDir);
+ aProfileNames.AppendElement(NS_ConvertUTF8toUTF16(buffer));
+ }
+ }
+ return NS_OK;
+}
+
+#define GETPREF(xform, method, value) \
+ nsresult rv = aBranch->method(xform->sourcePrefName, value); \
+ if (NS_SUCCEEDED(rv)) xform->prefHasValue = true; \
+ return rv;
+
+#define SETPREF(xform, method, value) \
+ if (xform->prefHasValue) { \
+ return aBranch->method( \
+ xform->targetPrefName ? xform->targetPrefName : xform->sourcePrefName, \
+ value); \
+ } \
+ return NS_OK;
+
+nsresult nsNetscapeProfileMigratorBase::GetString(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ nsCString str;
+ nsresult rv = aBranch->GetCharPref(xform->sourcePrefName, str);
+ if (NS_SUCCEEDED(rv)) {
+ xform->prefHasValue = true;
+ xform->stringValue = moz_xstrdup(str.get());
+ }
+ return rv;
+}
+
+nsresult nsNetscapeProfileMigratorBase::SetString(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ SETPREF(xform, SetCharPref, nsDependentCString(xform->stringValue));
+}
+
+nsresult nsNetscapeProfileMigratorBase::GetBool(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ GETPREF(xform, GetBoolPref, &xform->boolValue);
+}
+
+nsresult nsNetscapeProfileMigratorBase::SetBool(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ SETPREF(xform, SetBoolPref, xform->boolValue);
+}
+
+nsresult nsNetscapeProfileMigratorBase::GetInt(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ GETPREF(xform, GetIntPref, &xform->intValue);
+}
+
+nsresult nsNetscapeProfileMigratorBase::SetInt(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ SETPREF(xform, SetIntPref, xform->intValue);
+}
+
+nsresult nsNetscapeProfileMigratorBase::CopyFile(
+ const nsAString& aSourceFileName, const nsAString& aTargetFileName) {
+ nsCOMPtr<nsIFile> sourceFile;
+ mSourceProfile->Clone(getter_AddRefs(sourceFile));
+
+ sourceFile->Append(aSourceFileName);
+ bool exists = false;
+ sourceFile->Exists(&exists);
+ if (!exists) return NS_OK;
+
+ nsCOMPtr<nsIFile> targetFile;
+ mTargetProfile->Clone(getter_AddRefs(targetFile));
+
+ targetFile->Append(aTargetFileName);
+ targetFile->Exists(&exists);
+ if (exists) targetFile->Remove(false);
+
+ return sourceFile->CopyTo(mTargetProfile, aTargetFileName);
+}
+
+nsresult nsNetscapeProfileMigratorBase::GetSignonFileName(
+ bool aReplace, nsACString& aFileName) {
+ nsresult rv;
+ if (aReplace) {
+ // Find out what the signons file was called, this is stored in a pref
+ // in Seamonkey.
+ nsCOMPtr<nsIPrefService> psvc(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ psvc->ResetPrefs();
+
+ nsCOMPtr<nsIFile> sourcePrefsName;
+ mSourceProfile->Clone(getter_AddRefs(sourcePrefsName));
+ sourcePrefsName->Append(FILE_NAME_PREFS_5X);
+ psvc->ReadUserPrefsFromFile(sourcePrefsName);
+
+ nsCOMPtr<nsIPrefBranch> branch(do_QueryInterface(psvc));
+ rv = branch->GetCharPref("signon.SignonFileName", aFileName);
+ } else
+ rv = LocateSignonsFile(aFileName);
+ return rv;
+}
+
+nsresult nsNetscapeProfileMigratorBase::LocateSignonsFile(nsACString& aResult) {
+ nsCOMPtr<nsIDirectoryEnumerator> entries;
+ nsresult rv = mSourceProfile->GetDirectoryEntries(getter_AddRefs(entries));
+ if (NS_FAILED(rv)) return rv;
+
+ nsAutoCString fileName;
+ bool hasMore = false;
+ while (NS_SUCCEEDED(entries->HasMoreElements(&hasMore)) && hasMore) {
+ nsCOMPtr<nsIFile> currFile;
+ rv = entries->GetNextFile(getter_AddRefs(currFile));
+ if (NS_FAILED(rv)) break;
+
+ nsCOMPtr<nsIURI> uri;
+ rv = NS_NewFileURI(getter_AddRefs(uri), currFile);
+ if (NS_FAILED(rv)) break;
+ nsCOMPtr<nsIURL> url(do_QueryInterface(uri));
+
+ nsAutoCString extn;
+ url->GetFileExtension(extn);
+
+ if (extn.EqualsIgnoreCase("s")) {
+ url->GetFileName(fileName);
+ break;
+ }
+ }
+
+ aResult = fileName;
+
+ return NS_OK;
+}
+
+// helper function, copies the contents of srcDir into destDir.
+// destDir will be created if it doesn't exist.
+
+nsresult nsNetscapeProfileMigratorBase::RecursiveCopy(nsIFile* srcDir,
+ nsIFile* destDir) {
+ nsresult rv;
+ bool isDir;
+
+ rv = srcDir->IsDirectory(&isDir);
+ if (NS_FAILED(rv)) return rv;
+ if (!isDir) return NS_ERROR_INVALID_ARG;
+
+ bool exists;
+ rv = destDir->Exists(&exists);
+ if (NS_SUCCEEDED(rv) && !exists)
+ rv = destDir->Create(nsIFile::DIRECTORY_TYPE, 0775);
+ if (NS_FAILED(rv)) return rv;
+
+ nsCOMPtr<nsIDirectoryEnumerator> dirIterator;
+ rv = srcDir->GetDirectoryEntries(getter_AddRefs(dirIterator));
+ if (NS_FAILED(rv)) return rv;
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(dirIterator->HasMoreElements(&hasMore)) && hasMore) {
+ nsCOMPtr<nsIFile> dirEntry;
+ rv = dirIterator->GetNextFile(getter_AddRefs(dirEntry));
+ if (NS_SUCCEEDED(rv) && dirEntry) {
+ rv = dirEntry->IsDirectory(&isDir);
+ if (NS_SUCCEEDED(rv)) {
+ if (isDir) {
+ nsCOMPtr<nsIFile> newChild;
+ rv = destDir->Clone(getter_AddRefs(newChild));
+ if (NS_SUCCEEDED(rv)) {
+ nsAutoString leafName;
+ dirEntry->GetLeafName(leafName);
+ newChild->AppendRelativePath(leafName);
+ rv = newChild->Exists(&exists);
+ if (NS_SUCCEEDED(rv) && !exists) {
+ rv = newChild->Create(nsIFile::DIRECTORY_TYPE, 0775);
+ if (NS_FAILED(rv)) return rv;
+ }
+ rv = RecursiveCopy(dirEntry, newChild);
+ }
+ } else {
+ // we aren't going to do any actual file copying here. Instead, add
+ // this to our file transaction list so we can copy files
+ // asynchronously...
+ fileTransactionEntry fileEntry;
+ fileEntry.srcFile = dirEntry;
+ fileEntry.destFile = destDir;
+
+ mFileCopyTransactions.AppendElement(fileEntry);
+ }
+ }
+ }
+ }
+
+ return rv;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// nsITimerCallback
+
+NS_IMETHODIMP
+nsNetscapeProfileMigratorBase::Notify(nsITimer* timer) {
+ CopyNextFolder();
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNetscapeProfileMigratorBase::GetName(nsACString& aName) {
+ aName.AssignLiteral("nsNetscapeProfileMigratorBase");
+ return NS_OK;
+}
+
+void nsNetscapeProfileMigratorBase::CopyNextFolder() {
+ if (mFileCopyTransactionIndex < mFileCopyTransactions.Length()) {
+ fileTransactionEntry fileTransaction =
+ mFileCopyTransactions.ElementAt(mFileCopyTransactionIndex++);
+
+ // copy the file
+ fileTransaction.srcFile->CopyTo(fileTransaction.destFile,
+ fileTransaction.newName);
+
+ // add to our current progress
+ int64_t fileSize;
+ fileTransaction.srcFile->GetFileSize(&fileSize);
+ mCurrentProgress += fileSize;
+
+ uint32_t percentage = (uint32_t)(mCurrentProgress * 100 / mMaxProgress);
+
+ nsAutoString index;
+ index.AppendInt(percentage);
+
+ NOTIFY_OBSERVERS(MIGRATION_PROGRESS, index.get());
+
+ // fire a timer to handle the next one.
+ nsresult rv = NS_NewTimerWithCallback(
+ getter_AddRefs(mFileIOTimer), static_cast<nsITimerCallback*>(this),
+ percentage == 100 ? 500 : 0, nsITimer::TYPE_ONE_SHOT, nullptr);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Could not start mFileIOTimer timer");
+ }
+ } else
+ EndCopyFolders();
+
+ return;
+}
+
+void nsNetscapeProfileMigratorBase::EndCopyFolders() {
+ mFileCopyTransactions.Clear();
+ mFileCopyTransactionIndex = 0;
+
+ // notify the UI that we are done with the migration process
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::MAILDATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+
+ NOTIFY_OBSERVERS(MIGRATION_ENDED, nullptr);
+}
+
+NS_IMETHODIMP
+nsNetscapeProfileMigratorBase::GetSourceHasMultipleProfiles(bool* aResult) {
+ nsTArray<nsString> profiles;
+ GetSourceProfiles(profiles);
+
+ *aResult = profiles.Length() > 1;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNetscapeProfileMigratorBase::GetSourceExists(bool* aResult) {
+ nsTArray<nsString> profiles;
+ GetSourceProfiles(profiles);
+
+ *aResult = profiles.Length() > 0;
+ return NS_OK;
+}
diff --git a/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.h b/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.h
new file mode 100644
index 0000000000..5227673532
--- /dev/null
+++ b/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.h
@@ -0,0 +1,121 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef netscapeprofilemigratorbase___h___
+#define netscapeprofilemigratorbase___h___
+
+#include "nsAttrValue.h"
+#include "nsIFile.h"
+#include "nsIStringBundle.h"
+#include "nsString.h"
+#include "nsTArray.h"
+#include "nsIObserverService.h"
+#include "nsITimer.h"
+#include "nsIMailProfileMigrator.h"
+
+class nsIPrefBranch;
+
+struct fileTransactionEntry {
+ nsCOMPtr<nsIFile> srcFile; // the src path including leaf name
+ nsCOMPtr<nsIFile> destFile; // the destination path
+ nsString
+ newName; // only valid if the file should be renamed after getting copied
+};
+
+#define F(a) nsNetscapeProfileMigratorBase::a
+
+#define MAKEPREFTRANSFORM(pref, newpref, getmethod, setmethod) \
+ { \
+ pref, newpref, F(Get##getmethod), F(Set##setmethod), false, { -1 } \
+ }
+
+#define MAKESAMETYPEPREFTRANSFORM(pref, method) \
+ { \
+ pref, 0, F(Get##method), F(Set##method), false, { -1 } \
+ }
+
+class nsNetscapeProfileMigratorBase : public nsIMailProfileMigrator,
+ public nsITimerCallback,
+ public nsINamed
+
+{
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSITIMERCALLBACK
+ NS_DECL_NSINAMED
+
+ nsNetscapeProfileMigratorBase();
+
+ NS_IMETHOD GetSourceHasMultipleProfiles(bool* aResult) override;
+ NS_IMETHOD GetSourceExists(bool* aResult) override;
+
+ struct PrefTransform;
+ typedef nsresult (*prefConverter)(PrefTransform*, nsIPrefBranch*);
+
+ struct PrefTransform {
+ const char* sourcePrefName;
+ const char* targetPrefName;
+ prefConverter prefGetterFunc;
+ prefConverter prefSetterFunc;
+ bool prefHasValue;
+ union {
+ int32_t intValue;
+ bool boolValue;
+ char* stringValue;
+ };
+ };
+
+ struct PrefBranchStruct {
+ char* prefName;
+ int32_t type;
+ union {
+ char* stringValue;
+ int32_t intValue;
+ bool boolValue;
+ };
+ };
+
+ typedef nsTArray<PrefBranchStruct*> PBStructArray;
+
+ static nsresult GetString(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult SetString(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult GetBool(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult SetBool(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult GetInt(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult SetInt(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+
+ nsresult RecursiveCopy(nsIFile* srcDir, nsIFile* destDir); // helper routine
+
+ protected:
+ virtual ~nsNetscapeProfileMigratorBase() {}
+ void CopyNextFolder();
+ void EndCopyFolders();
+
+ nsresult GetProfileDataFromProfilesIni(
+ nsIFile* aDataDir, nsTArray<nsString>& aProfileNames,
+ nsTArray<RefPtr<nsIFile>>& aProfileLocations);
+
+ nsresult CopyFile(const nsAString& aSourceFileName,
+ const nsAString& aTargetFileName);
+
+ nsresult GetSignonFileName(bool aReplace, nsACString& aFileName);
+ nsresult LocateSignonsFile(nsACString& aResult);
+
+ nsCOMPtr<nsIFile> mSourceProfile;
+ nsCOMPtr<nsIFile> mTargetProfile;
+
+ // List of src/destination files we still have to copy into the new profile
+ // directory.
+ nsTArray<fileTransactionEntry> mFileCopyTransactions;
+ uint32_t mFileCopyTransactionIndex;
+
+ int64_t mMaxProgress;
+ int64_t mCurrentProgress;
+
+ nsCOMPtr<nsIObserverService> mObserverService;
+ nsCOMPtr<nsITimer> mFileIOTimer;
+};
+
+#endif
diff --git a/comm/mail/components/migration/src/nsOutlookProfileMigrator.cpp b/comm/mail/components/migration/src/nsOutlookProfileMigrator.cpp
new file mode 100644
index 0000000000..dd7535e257
--- /dev/null
+++ b/comm/mail/components/migration/src/nsOutlookProfileMigrator.cpp
@@ -0,0 +1,135 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsMailProfileMigratorUtils.h"
+#include "nsIServiceManager.h"
+#include "nsOutlookProfileMigrator.h"
+#include "nsIProfileMigrator.h"
+#include "nsIImportSettings.h"
+#include "nsIFile.h"
+#include "nsITimer.h"
+#include "nsComponentManagerUtils.h"
+
+NS_IMPL_ISUPPORTS(nsOutlookProfileMigrator, nsIMailProfileMigrator,
+ nsITimerCallback)
+
+nsOutlookProfileMigrator::nsOutlookProfileMigrator() {
+ mProcessingMailFolders = false;
+ // get the import service
+ mImportModule = do_CreateInstance("@mozilla.org/import/import-outlook;1");
+}
+
+nsOutlookProfileMigrator::~nsOutlookProfileMigrator() {}
+
+nsresult nsOutlookProfileMigrator::ContinueImport() { return Notify(nullptr); }
+
+///////////////////////////////////////////////////////////////////////////////
+// nsITimerCallback
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::Notify(nsITimer* timer) {
+ int32_t progress;
+ mGenericImporter->GetProgress(&progress);
+
+ nsAutoString index;
+ index.AppendInt(progress);
+ NOTIFY_OBSERVERS(MIGRATION_PROGRESS, index.get());
+
+ if (progress == 100) // are we done yet?
+ {
+ if (mProcessingMailFolders)
+ return FinishCopyingMailFolders();
+ else
+ return FinishCopyingAddressBookData();
+ } else {
+ // fire a timer to handle the next one.
+ nsresult rv = NS_NewTimerWithCallback(
+ getter_AddRefs(mFileIOTimer), static_cast<nsITimerCallback*>(this), 100,
+ nsITimer::TYPE_ONE_SHOT, nullptr);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Could not start mFileIOTimer timer");
+ }
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetName(nsACString& aName) {
+ aName.AssignLiteral("nsOutlookProfileMigrator");
+ return NS_OK;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// nsIMailProfileMigrator
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::Migrate(uint16_t aItems, nsIProfileStartup* aStartup,
+ const char16_t* aProfile) {
+ nsresult rv = NS_OK;
+
+ if (aStartup) {
+ rv = aStartup->DoStartup();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ NOTIFY_OBSERVERS(MIGRATION_STARTED, nullptr);
+
+ rv = ImportSettings(mImportModule);
+
+ // now import address books
+ // this routine will asynchronously import address book data and it will then
+ // kick off the final migration step, copying the mail folders over.
+ rv = ImportAddressBook(mImportModule);
+
+ // don't broadcast an on end migration here. We aren't done until our asynch
+ // import process says we are done.
+ return rv;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetMigrateData(const char16_t* aProfile,
+ bool aReplace, uint16_t* aResult) {
+ // There's no harm in assuming everything is available.
+ *aResult = nsIMailProfileMigrator::ACCOUNT_SETTINGS |
+ nsIMailProfileMigrator::ADDRESSBOOK_DATA |
+ nsIMailProfileMigrator::MAILDATA;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetSourceExists(bool* aResult) {
+ *aResult = false;
+
+ nsCOMPtr<nsISupports> supports;
+ mImportModule->GetImportInterface(NS_IMPORT_SETTINGS_STR,
+ getter_AddRefs(supports));
+ nsCOMPtr<nsIImportSettings> importSettings = do_QueryInterface(supports);
+
+ if (importSettings) {
+ nsString description;
+ nsCOMPtr<nsIFile> location;
+ importSettings->AutoLocate(getter_Copies(description),
+ getter_AddRefs(location), aResult);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetSourceHasMultipleProfiles(bool* aResult) {
+ *aResult = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetSourceProfiles(nsTArray<nsString>& aResult) {
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetSourceProfileLocations(
+ nsTArray<RefPtr<nsIFile>>& aResult) {
+ return NS_OK;
+}
diff --git a/comm/mail/components/migration/src/nsOutlookProfileMigrator.h b/comm/mail/components/migration/src/nsOutlookProfileMigrator.h
new file mode 100644
index 0000000000..6de79f98dd
--- /dev/null
+++ b/comm/mail/components/migration/src/nsOutlookProfileMigrator.h
@@ -0,0 +1,30 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef outlookprofilemigrator___h___
+#define outlookprofilemigrator___h___
+
+#include "nsIMailProfileMigrator.h"
+#include "nsITimer.h"
+#include "nsProfileMigratorBase.h"
+
+class nsOutlookProfileMigrator : public nsIMailProfileMigrator,
+ public nsITimerCallback,
+ public nsProfileMigratorBase,
+ public nsINamed {
+ public:
+ NS_DECL_NSIMAILPROFILEMIGRATOR
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSITIMERCALLBACK
+ NS_DECL_NSINAMED
+
+ nsOutlookProfileMigrator();
+ virtual nsresult ContinueImport();
+
+ private:
+ virtual ~nsOutlookProfileMigrator();
+};
+
+#endif
diff --git a/comm/mail/components/migration/src/nsProfileMigrator.cpp b/comm/mail/components/migration/src/nsProfileMigrator.cpp
new file mode 100644
index 0000000000..c6fa2bc867
--- /dev/null
+++ b/comm/mail/components/migration/src/nsProfileMigrator.cpp
@@ -0,0 +1,121 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsIFile.h"
+#include "mozIDOMWindow.h"
+#include "nsIProfileMigrator.h"
+#include "nsIPrefService.h"
+#include "nsIServiceManager.h"
+#include "nsIToolkitProfile.h"
+#include "nsIToolkitProfileService.h"
+#include "nsIWindowWatcher.h"
+#include "nsISupportsPrimitives.h"
+#include "nsIMutableArray.h"
+#include "nsComponentManagerUtils.h"
+#include "nsServiceManagerUtils.h"
+#include "nsIProperties.h"
+#include "nsDirectoryServiceDefs.h"
+#include "nsProfileMigrator.h"
+
+#ifdef XP_WIN
+# include <windows.h>
+#else
+# include <limits.h>
+#endif
+
+NS_IMPL_ISUPPORTS(nsProfileMigrator, nsIProfileMigrator)
+
+#define MIGRATION_WIZARD_FE_URL \
+ "chrome://messenger/content/migration/migration.xhtml"_ns
+#define MIGRATION_WIZARD_FE_FEATURES "chrome,dialog,modal,centerscreen"_ns
+
+NS_IMETHODIMP
+nsProfileMigrator::Migrate(nsIProfileStartup* aStartup, const nsACString& aKey,
+ const nsACString& aProfileName) {
+ nsAutoCString key;
+ nsCOMPtr<nsIMailProfileMigrator> mailMigrator;
+ nsresult rv = GetDefaultMailMigratorKey(key, mailMigrator);
+ NS_ENSURE_SUCCESS(rv, rv); // abort migration if we failed to get a
+ // mailMigrator (if we were supposed to)
+
+ nsCOMPtr<nsISupportsCString> cstr(
+ do_CreateInstance("@mozilla.org/supports-cstring;1"));
+ NS_ENSURE_TRUE(cstr, NS_ERROR_OUT_OF_MEMORY);
+ cstr->SetData(key);
+
+ // By opening the Migration FE with a supplied mailMigrator, it will
+ // automatically migrate from it.
+ nsCOMPtr<nsIWindowWatcher> ww(do_GetService(NS_WINDOWWATCHER_CONTRACTID));
+ nsCOMPtr<nsIMutableArray> params(do_CreateInstance(NS_ARRAY_CONTRACTID));
+ if (!ww || !params) return NS_ERROR_FAILURE;
+
+ params->AppendElement(cstr);
+ params->AppendElement(mailMigrator);
+ params->AppendElement(aStartup);
+
+ nsCOMPtr<mozIDOMWindowProxy> migrateWizard;
+ return ww->OpenWindow(nullptr, MIGRATION_WIZARD_FE_URL, "_blank"_ns,
+ MIGRATION_WIZARD_FE_FEATURES, params,
+ getter_AddRefs(migrateWizard));
+}
+
+#ifdef XP_WIN
+typedef struct {
+ WORD wLanguage;
+ WORD wCodePage;
+} LANGANDCODEPAGE;
+
+# define INTERNAL_NAME_THUNDERBIRD "Thunderbird"
+# define INTERNAL_NAME_SEAMONKEY "Mozilla"
+#endif
+
+nsresult nsProfileMigrator::GetDefaultMailMigratorKey(
+ nsACString& aKey, nsCOMPtr<nsIMailProfileMigrator>& mailMigrator) {
+ // look up the value of profile.force.migration in case we are supposed to
+ // force migration using a particular migrator....
+ nsresult rv = NS_OK;
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID, &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCString forceMigrationType;
+ prefs->GetCharPref("profile.force.migration", forceMigrationType);
+
+ // if we are being forced to migrate to a particular migration type, then
+ // create an instance of that migrator and return it.
+ nsAutoCString migratorID;
+ if (!forceMigrationType.IsEmpty()) {
+ bool exists = false;
+ migratorID.AppendLiteral("@mozilla.org/messenger/server;1?type=");
+ migratorID.Append(forceMigrationType);
+ mailMigrator = do_CreateInstance(migratorID.get());
+ if (!mailMigrator) return NS_ERROR_NOT_AVAILABLE;
+
+ mailMigrator->GetSourceExists(&exists);
+ /* trying to force migration on a source which doesn't
+ * have any profiles.
+ */
+ if (!exists) return NS_ERROR_NOT_AVAILABLE;
+ aKey = forceMigrationType;
+ return NS_OK;
+ }
+
+#define MAX_SOURCE_LENGTH 10
+ const char sources[][MAX_SOURCE_LENGTH] = {"seamonkey", "outlook", ""};
+ for (uint32_t i = 0; sources[i][0]; ++i) {
+ migratorID.AssignLiteral("@mozilla.org/messenger/server;1?type=");
+ migratorID.Append(sources[i]);
+ mailMigrator = do_CreateInstance(migratorID.get());
+ if (!mailMigrator) continue;
+
+ bool exists = false;
+ mailMigrator->GetSourceExists(&exists);
+ if (exists) {
+ mailMigrator = nullptr;
+ return NS_OK;
+ }
+ }
+
+ return NS_ERROR_NOT_AVAILABLE;
+}
diff --git a/comm/mail/components/migration/src/nsProfileMigrator.h b/comm/mail/components/migration/src/nsProfileMigrator.h
new file mode 100644
index 0000000000..d25a9989e9
--- /dev/null
+++ b/comm/mail/components/migration/src/nsProfileMigrator.h
@@ -0,0 +1,36 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsIFile.h"
+#include "nsIProfileMigrator.h"
+#include "nsIMailProfileMigrator.h"
+#include "nsIServiceManager.h"
+#include "nsIToolkitProfile.h"
+#include "nsIToolkitProfileService.h"
+#include "nsCOMPtr.h"
+#include "nsDirectoryServiceDefs.h"
+
+#include "nsString.h"
+
+#define NS_THUNDERBIRD_PROFILEIMPORT_CID \
+ { \
+ 0xb3c78baf, 0x3a52, 0x41d2, { \
+ 0x97, 0x18, 0xc3, 0x19, 0xbe, 0xf9, 0xaf, 0xfc \
+ } \
+ }
+
+class nsProfileMigrator final : public nsIProfileMigrator {
+ public:
+ NS_DECL_NSIPROFILEMIGRATOR
+ NS_DECL_ISUPPORTS
+
+ nsProfileMigrator(){};
+
+ protected:
+ ~nsProfileMigrator(){};
+
+ nsresult GetDefaultMailMigratorKey(
+ nsACString& key, nsCOMPtr<nsIMailProfileMigrator>& mailMigrator);
+};
diff --git a/comm/mail/components/migration/src/nsProfileMigratorBase.cpp b/comm/mail/components/migration/src/nsProfileMigratorBase.cpp
new file mode 100644
index 0000000000..5ce067308c
--- /dev/null
+++ b/comm/mail/components/migration/src/nsProfileMigratorBase.cpp
@@ -0,0 +1,173 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsMailProfileMigratorUtils.h"
+#include "nsISupportsPrimitives.h"
+#include "nsProfileMigratorBase.h"
+#include "nsIMailProfileMigrator.h"
+
+#include "nsIImportSettings.h"
+#include "nsIImportFilters.h"
+#include "nsComponentManagerUtils.h"
+#include "nsServiceManagerUtils.h"
+
+#define kPersonalAddressbookUri "jsaddrbook://abook.sqlite"
+
+nsProfileMigratorBase::nsProfileMigratorBase() {
+ mObserverService = do_GetService("@mozilla.org/observer-service;1");
+ mProcessingMailFolders = false;
+}
+
+nsProfileMigratorBase::~nsProfileMigratorBase() {
+ if (mFileIOTimer) mFileIOTimer->Cancel();
+}
+
+nsresult nsProfileMigratorBase::ImportSettings(nsIImportModule* aImportModule) {
+ nsresult rv;
+
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::ACCOUNT_SETTINGS);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ nsCOMPtr<nsISupports> supports;
+ rv = aImportModule->GetImportInterface(NS_IMPORT_SETTINGS_STR,
+ getter_AddRefs(supports));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIImportSettings> importSettings = do_QueryInterface(supports);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool importedSettings = false;
+
+ rv = importSettings->Import(getter_AddRefs(mLocalFolderAccount),
+ &importedSettings);
+
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+
+ return rv;
+}
+
+nsresult nsProfileMigratorBase::ImportAddressBook(
+ nsIImportModule* aImportModule) {
+ nsresult rv;
+
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::ADDRESSBOOK_DATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ nsCOMPtr<nsISupports> supports;
+ rv = aImportModule->GetImportInterface(NS_IMPORT_ADDRESS_STR,
+ getter_AddRefs(supports));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mGenericImporter = do_QueryInterface(supports);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsISupportsCString> pabString =
+ do_CreateInstance(NS_SUPPORTS_CSTRING_CONTRACTID, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // We want to migrate the Outlook addressbook into our personal address book.
+ pabString->SetData(nsDependentCString(kPersonalAddressbookUri));
+ mGenericImporter->SetData("addressDestination", pabString);
+
+ bool importResult;
+ bool wantsProgress;
+ mGenericImporter->WantsProgress(&wantsProgress);
+ rv = mGenericImporter->BeginImport(nullptr, nullptr, &importResult);
+
+ if (wantsProgress)
+ ContinueImport();
+ else
+ FinishCopyingAddressBookData();
+
+ return rv;
+}
+
+nsresult nsProfileMigratorBase::FinishCopyingAddressBookData() {
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::ADDRESSBOOK_DATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+
+ // now kick off the mail migration code
+ ImportMailData(mImportModule);
+
+ return NS_OK;
+}
+
+nsresult nsProfileMigratorBase::ImportMailData(nsIImportModule* aImportModule) {
+ nsresult rv;
+
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::MAILDATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ nsCOMPtr<nsISupports> supports;
+ rv = aImportModule->GetImportInterface(NS_IMPORT_MAIL_STR,
+ getter_AddRefs(supports));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mGenericImporter = do_QueryInterface(supports);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsISupportsPRBool> migrating =
+ do_CreateInstance(NS_SUPPORTS_PRBOOL_CONTRACTID, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // by setting the migration flag, we force the import utility to install local
+ // folders from OE directly into Local Folders and not as a subfolder
+ migrating->SetData(true);
+ mGenericImporter->SetData("migration", migrating);
+
+ bool importResult;
+ bool wantsProgress;
+ mGenericImporter->WantsProgress(&wantsProgress);
+ rv = mGenericImporter->BeginImport(nullptr, nullptr, &importResult);
+
+ mProcessingMailFolders = true;
+
+ if (wantsProgress)
+ ContinueImport();
+ else
+ FinishCopyingMailFolders();
+
+ return rv;
+}
+
+nsresult nsProfileMigratorBase::FinishCopyingMailFolders() {
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::MAILDATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+
+ // now kick off the filters migration code
+ return ImportFilters(mImportModule);
+}
+
+nsresult nsProfileMigratorBase::ImportFilters(nsIImportModule* aImportModule) {
+ nsresult rv = NS_OK;
+
+ nsCOMPtr<nsISupports> supports;
+ nsresult rv2 = aImportModule->GetImportInterface(NS_IMPORT_FILTERS_STR,
+ getter_AddRefs(supports));
+ nsCOMPtr<nsIImportFilters> importFilters = do_QueryInterface(supports);
+
+ if (NS_SUCCEEDED(rv2) && importFilters) {
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::FILTERS);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ bool importedFilters = false;
+ char16_t* error;
+
+ rv = importFilters->Import(&error, &importedFilters);
+
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+ }
+
+ // migration is now done...notify the UI.
+ NOTIFY_OBSERVERS(MIGRATION_ENDED, nullptr);
+
+ return rv;
+}
diff --git a/comm/mail/components/migration/src/nsProfileMigratorBase.h b/comm/mail/components/migration/src/nsProfileMigratorBase.h
new file mode 100644
index 0000000000..6ca0d7fcb4
--- /dev/null
+++ b/comm/mail/components/migration/src/nsProfileMigratorBase.h
@@ -0,0 +1,40 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef profilemigratorbase___h___
+#define profilemigratorbase___h___
+
+#include "nsIFile.h"
+#include "nsIObserverService.h"
+#include "nsITimer.h"
+#include "nsIImportGeneric.h"
+#include "nsIImportModule.h"
+#include "nsIMsgAccount.h"
+
+class nsProfileMigratorBase {
+ public:
+ nsProfileMigratorBase();
+ virtual ~nsProfileMigratorBase();
+ virtual nsresult ContinueImport() = 0;
+
+ protected:
+ nsresult ImportSettings(nsIImportModule* aImportModule);
+ nsresult ImportAddressBook(nsIImportModule* aImportModule);
+ nsresult ImportMailData(nsIImportModule* aImportModule);
+ nsresult ImportFilters(nsIImportModule* aImportModule);
+ nsresult FinishCopyingAddressBookData();
+ nsresult FinishCopyingMailFolders();
+
+ nsCOMPtr<nsIObserverService> mObserverService;
+ nsCOMPtr<nsITimer> mFileIOTimer;
+ nsCOMPtr<nsIImportGeneric> mGenericImporter;
+ nsCOMPtr<nsIImportModule> mImportModule;
+ nsCOMPtr<nsIMsgAccount>
+ mLocalFolderAccount; // needed for nsIImportSettings::Import
+ bool mProcessingMailFolders; // we are either asynchronously parsing address
+ // books or mail folders
+};
+
+#endif
diff --git a/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.cpp b/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.cpp
new file mode 100644
index 0000000000..27251462c9
--- /dev/null
+++ b/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.cpp
@@ -0,0 +1,1175 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsMailProfileMigratorUtils.h"
+#include "nsDirectoryServiceDefs.h"
+#include "nsIMsgAccountManager.h"
+#include "nsISmtpServer.h"
+#include "nsISmtpService.h"
+#include "nsIPrefLocalizedString.h"
+#include "nsIPrefService.h"
+#include "nsISupportsPrimitives.h"
+#include "nsNetCID.h"
+#include "nsNetUtil.h"
+#include "nsSeamonkeyProfileMigrator.h"
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsComponentManagerUtils.h" // for do_CreateInstance
+#include "mozilla/ArrayUtils.h"
+#include "nsIFile.h"
+
+#include "nsIAbManager.h"
+#include "nsIAbDirectory.h"
+#include "../../../../mailnews/import/src/MorkImport.h"
+
+// Mail specific folder paths
+#define MAIL_DIR_50_NAME u"Mail"_ns
+#define IMAP_MAIL_DIR_50_NAME u"ImapMail"_ns
+#define NEWS_DIR_50_NAME u"News"_ns
+
+///////////////////////////////////////////////////////////////////////////////
+// nsSeamonkeyProfileMigrator
+#define FILE_NAME_JUNKTRAINING u"training.dat"_ns
+#define FILE_NAME_PERSONALDICTIONARY u"persdict.dat"_ns
+#define FILE_NAME_PERSONAL_ADDRESSBOOK u"abook.mab"_ns
+#define FILE_NAME_MAILVIEWS u"mailviews.dat"_ns
+#define FILE_NAME_CERT9DB u"cert9.db"_ns
+#define FILE_NAME_KEY4DB u"key4.db"_ns
+#define FILE_NAME_SECMODDB u"secmod.db"_ns
+#define FILE_NAME_PREFS u"prefs.js"_ns
+#define FILE_NAME_USER_PREFS u"user.js"_ns
+
+struct PrefBranchStruct {
+ char* prefName;
+ int32_t type;
+ union {
+ char* stringValue;
+ int32_t intValue;
+ bool boolValue;
+ char16_t* wstringValue;
+ };
+};
+
+NS_IMPL_ISUPPORTS(nsSeamonkeyProfileMigrator, nsIMailProfileMigrator,
+ nsITimerCallback)
+
+nsSeamonkeyProfileMigrator::nsSeamonkeyProfileMigrator() {}
+
+nsSeamonkeyProfileMigrator::~nsSeamonkeyProfileMigrator() {}
+
+///////////////////////////////////////////////////////////////////////////////
+// nsIMailProfileMigrator
+
+NS_IMETHODIMP
+nsSeamonkeyProfileMigrator::Migrate(uint16_t aItems,
+ nsIProfileStartup* aStartup,
+ const char16_t* aProfile) {
+ nsresult rv = NS_OK;
+ bool aReplace = aStartup ? true : false;
+
+ if (!mTargetProfile) {
+ GetProfilePath(aStartup, mTargetProfile);
+ if (!mTargetProfile) return NS_ERROR_FAILURE;
+ }
+ if (!mSourceProfile) {
+ GetSourceProfile(aProfile);
+ if (!mSourceProfile) return NS_ERROR_FAILURE;
+ }
+
+ NOTIFY_OBSERVERS(MIGRATION_STARTED, nullptr);
+
+ if (aReplace) {
+ CopyPreferences(aReplace);
+ } else {
+ ImportPreferences(aItems);
+ }
+
+ // fake notifications for things we've already imported as part of
+ // CopyPreferences
+ COPY_DATA(DummyCopyRoutine, aReplace,
+ nsIMailProfileMigrator::ACCOUNT_SETTINGS);
+ COPY_DATA(DummyCopyRoutine, aReplace, nsIMailProfileMigrator::NEWSDATA);
+
+ // copy junk mail training file
+ COPY_DATA(CopyJunkTraining, aReplace, nsIMailProfileMigrator::JUNKTRAINING);
+ COPY_DATA(CopyPasswords, aReplace, nsIMailProfileMigrator::PASSWORDS);
+
+ // the last thing to do is to actually copy over any mail folders we have
+ // marked for copying we want to do this last and it will be asynchronous so
+ // the UI doesn't freeze up while we perform this potentially very long
+ // operation.
+
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::MAILDATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ // Generate the max progress value now that we know all of the files we need
+ // to copy
+ uint32_t count = mFileCopyTransactions.Length();
+ for (uint32_t i = 0; i < count; ++i) {
+ fileTransactionEntry fileTransaction = mFileCopyTransactions.ElementAt(i);
+ int64_t fileSize;
+ fileTransaction.srcFile->GetFileSize(&fileSize);
+ mMaxProgress += fileSize;
+ }
+
+ CopyNextFolder();
+
+ return rv;
+}
+
+NS_IMETHODIMP
+nsSeamonkeyProfileMigrator::GetMigrateData(const char16_t* aProfile,
+ bool aReplace, uint16_t* aResult) {
+ *aResult = 0;
+
+ if (!mSourceProfile) {
+ GetSourceProfile(aProfile);
+ if (!mSourceProfile) return NS_ERROR_FILE_NOT_FOUND;
+ }
+
+ MigrationData data[] = {
+ {ToNewUnicode(FILE_NAME_PREFS), nsIMailProfileMigrator::SETTINGS, false},
+ {ToNewUnicode(FILE_NAME_JUNKTRAINING),
+ nsIMailProfileMigrator::JUNKTRAINING, true},
+ };
+
+ // Frees file name strings allocated above.
+ GetMigrateDataFromArray(data, sizeof(data) / sizeof(MigrationData), aReplace,
+ mSourceProfile, aResult);
+
+ // Now locate passwords
+ nsCString signonsFileName;
+ GetSignonFileName(aReplace, signonsFileName);
+
+ if (!signonsFileName.IsEmpty()) {
+ nsAutoString fileName;
+ CopyASCIItoUTF16(signonsFileName, fileName);
+ nsCOMPtr<nsIFile> sourcePasswordsFile;
+ mSourceProfile->Clone(getter_AddRefs(sourcePasswordsFile));
+ sourcePasswordsFile->Append(fileName);
+
+ bool exists;
+ sourcePasswordsFile->Exists(&exists);
+ if (exists) *aResult |= nsIMailProfileMigrator::PASSWORDS;
+ }
+
+ // add some extra migration fields for things we also migrate
+ *aResult |= nsIMailProfileMigrator::ACCOUNT_SETTINGS |
+ nsIMailProfileMigrator::MAILDATA |
+ nsIMailProfileMigrator::NEWSDATA |
+ nsIMailProfileMigrator::ADDRESSBOOK_DATA;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsSeamonkeyProfileMigrator::GetSourceProfiles(nsTArray<nsString>& aResult) {
+ if (mProfileNames.IsEmpty() && mProfileLocations.IsEmpty()) {
+ // Fills mProfileNames and mProfileLocations
+ FillProfileDataFromSeamonkeyRegistry();
+ }
+
+ aResult = mProfileNames.Clone();
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsSeamonkeyProfileMigrator::GetSourceProfileLocations(
+ nsTArray<RefPtr<nsIFile>>& aResult) {
+ if (mProfileNames.IsEmpty() && mProfileLocations.IsEmpty()) {
+ // Fills mProfileNames and mProfileLocations
+ FillProfileDataFromSeamonkeyRegistry();
+ }
+
+ aResult = mProfileLocations.Clone();
+ return NS_OK;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// nsSeamonkeyProfileMigrator
+
+nsresult nsSeamonkeyProfileMigrator::GetSourceProfile(
+ const char16_t* aProfile) {
+ uint32_t count = mProfileNames.Length();
+ for (uint32_t i = 0; i < count; ++i) {
+ nsString profileName = mProfileNames[i];
+ if (profileName.Equals(aProfile)) {
+ mSourceProfile = mProfileLocations[i];
+ break;
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::FillProfileDataFromSeamonkeyRegistry() {
+ // Find the Seamonkey Registry
+ nsCOMPtr<nsIProperties> fileLocator(
+ do_GetService("@mozilla.org/file/directory_service;1"));
+ nsCOMPtr<nsIFile> seamonkeyData;
+#undef EXTRA_PREPEND
+
+#ifdef XP_WIN
+# define NEW_FOLDER "SeaMonkey"
+# define EXTRA_PREPEND "Mozilla"
+
+ fileLocator->Get(NS_WIN_APPDATA_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(seamonkeyData));
+ NS_ENSURE_TRUE(seamonkeyData, NS_ERROR_FAILURE);
+
+#elif defined(XP_MACOSX)
+# define NEW_FOLDER "SeaMonkey"
+# define EXTRA_PREPEND "Application Support"
+ fileLocator->Get(NS_MAC_USER_LIB_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(seamonkeyData));
+ NS_ENSURE_TRUE(seamonkeyData, NS_ERROR_FAILURE);
+
+#elif defined(XP_UNIX)
+# define NEW_FOLDER "seamonkey"
+# define EXTRA_PREPEND ".mozilla"
+ fileLocator->Get(NS_UNIX_HOME_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(seamonkeyData));
+ NS_ENSURE_TRUE(seamonkeyData, NS_ERROR_FAILURE);
+
+#else
+ // On other OS just abort.
+ return NS_ERROR_FAILURE;
+#endif
+
+ nsCOMPtr<nsIFile> newSeamonkeyData;
+ seamonkeyData->Clone(getter_AddRefs(newSeamonkeyData));
+ NS_ENSURE_TRUE(newSeamonkeyData, NS_ERROR_FAILURE);
+
+#ifdef EXTRA_PREPEND
+ newSeamonkeyData->Append(NS_LITERAL_STRING_FROM_CSTRING(EXTRA_PREPEND));
+#endif
+ newSeamonkeyData->Append(NS_LITERAL_STRING_FROM_CSTRING(NEW_FOLDER));
+
+ nsresult rv = GetProfileDataFromProfilesIni(newSeamonkeyData, mProfileNames,
+ mProfileLocations);
+
+ return rv;
+}
+
+static nsSeamonkeyProfileMigrator::PrefTransform gTransforms[] = {
+
+ MAKESAMETYPEPREFTRANSFORM("signon.SignonFileName", String),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.headers.showUserAgent", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.headers.showOrganization", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.collect_addressbook", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.collect_email_address_outgoing", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.wrap_long_lines", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.customHeaders", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.default_html_action", Int),
+ MAKESAMETYPEPREFTRANSFORM("mail.forward_message_mode", Int),
+ MAKESAMETYPEPREFTRANSFORM("mail.SpellCheckBeforeSend", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.warn_on_send_accel_key", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.headers.showUserAgent", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.headers.showOrganization", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.biff.play_sound", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.biff.play_sound.type", Int),
+ MAKESAMETYPEPREFTRANSFORM("mail.biff.play_sound.url", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.biff.show_alert", Bool),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.type", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.http", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.http_port", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.ftp", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.ftp_port", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.ssl", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.ssl_port", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.socks", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.socks_port", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.no_proxies_on", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.autoconfig_url", String),
+
+ MAKESAMETYPEPREFTRANSFORM("mail.accountmanager.accounts", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.accountmanager.defaultaccount", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.accountmanager.localfoldersserver", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.smtp.defaultserver", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.smtpservers", String),
+
+ MAKESAMETYPEPREFTRANSFORM("msgcompose.font_face", String),
+ MAKESAMETYPEPREFTRANSFORM("msgcompose.font_size", String),
+ MAKESAMETYPEPREFTRANSFORM("msgcompose.text_color", String),
+ MAKESAMETYPEPREFTRANSFORM("msgcompose.background_color", String),
+
+ MAKEPREFTRANSFORM("mail.pane_config", "mail.pane_config.dynamic", Int,
+ Int)};
+
+/**
+ * Use the current Seamonkey's prefs.js as base, and transform some branches.
+ * Thunderbird's prefs.js is thrown away.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformPreferences(
+ const nsAString& aSourcePrefFileName,
+ const nsAString& aTargetPrefFileName) {
+ PrefTransform* transform;
+ PrefTransform* end =
+ gTransforms + sizeof(gTransforms) / sizeof(PrefTransform);
+
+ // Load the source pref file
+ nsCOMPtr<nsIPrefService> psvc(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ psvc->ResetPrefs();
+
+ nsCOMPtr<nsIFile> sourcePrefsFile;
+ mSourceProfile->Clone(getter_AddRefs(sourcePrefsFile));
+ sourcePrefsFile->Append(aSourcePrefFileName);
+ psvc->ReadUserPrefsFromFile(sourcePrefsFile);
+
+ nsCOMPtr<nsIPrefBranch> branch(do_QueryInterface(psvc));
+ for (transform = gTransforms; transform < end; ++transform)
+ transform->prefGetterFunc(transform, branch);
+
+ static const char* branchNames[] = {
+ // Keep the three below first, or change the indexes below
+ "mail.identity.", "mail.server.", "ldap_2.servers.",
+ "mail.account.", "mail.smtpserver.", "mailnews.labels.",
+ "mailnews.tags."};
+
+ // read in the various pref branch trees for accounts, identities, servers,
+ // etc.
+ PBStructArray branches[MOZ_ARRAY_LENGTH(branchNames)];
+ uint32_t i;
+ for (i = 0; i < MOZ_ARRAY_LENGTH(branchNames); ++i)
+ ReadBranch(branchNames[i], psvc, branches[i]);
+
+ // The signature file prefs may be paths to files in the seamonkey profile
+ // path so we need to copy them over and fix these paths up before we write
+ // them out to the new prefs.js.
+ CopySignatureFiles(branches[0], psvc);
+
+ // Certain mail prefs may actually be absolute paths instead of profile
+ // relative paths we need to fix these paths up before we write them out to
+ // the new prefs.js
+ CopyMailFolders(branches[1], psvc);
+
+ TransformAddressbooksForImport(psvc, branches[2], true);
+
+ // Now that we have all the pref data in memory, load the target pref file,
+ // and write it back out.
+ psvc->ResetPrefs();
+
+ // XXX Re-order this?
+
+ for (transform = gTransforms; transform < end; ++transform)
+ transform->prefSetterFunc(transform, branch);
+
+ for (i = 0; i < MOZ_ARRAY_LENGTH(branchNames); i++)
+ WriteBranch(branchNames[i], psvc, branches[i]);
+
+ nsCOMPtr<nsIFile> targetPrefsFile;
+ mTargetProfile->Clone(getter_AddRefs(targetPrefsFile));
+ targetPrefsFile->Append(aTargetPrefFileName);
+ psvc->SavePrefFile(targetPrefsFile);
+
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopySignatureFiles(
+ PBStructArray& aIdentities, nsIPrefService* aPrefService) {
+ nsresult rv = NS_OK;
+
+ uint32_t count = aIdentities.Length();
+ for (uint32_t i = 0; i < count; ++i) {
+ PrefBranchStruct* pref = aIdentities.ElementAt(i);
+ nsDependentCString prefName(pref->prefName);
+
+ // a partial fix for bug #255043
+ // if the user's signature file from seamonkey lives in the
+ // seamonkey profile root, we'll copy it over to the new
+ // thunderbird profile root and then set the pref to the new value
+ // note, this doesn't work for multiple signatures that live
+ // below the seamonkey profile root
+ if (StringEndsWith(prefName, ".sig_file"_ns)) {
+ // turn the pref into a nsIFile
+ nsCOMPtr<nsIFile> srcSigFile =
+ do_CreateInstance(NS_LOCAL_FILE_CONTRACTID);
+ rv = srcSigFile->SetPersistentDescriptor(
+ nsDependentCString(pref->stringValue));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIFile> targetSigFile;
+ rv = mTargetProfile->Clone(getter_AddRefs(targetSigFile));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // now make the copy
+ bool exists;
+ srcSigFile->Exists(&exists);
+ if (exists) {
+ nsAutoString leafName;
+ srcSigFile->GetLeafName(leafName);
+ srcSigFile->CopyTo(
+ targetSigFile,
+ leafName); // will fail if we've already copied a sig file here
+ targetSigFile->Append(leafName);
+
+ // now write out the new descriptor
+ nsAutoCString descriptorString;
+ rv = targetSigFile->GetPersistentDescriptor(descriptorString);
+ NS_ENSURE_SUCCESS(rv, rv);
+ free(pref->stringValue);
+ pref->stringValue = ToNewCString(descriptorString);
+ }
+ }
+ }
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopyMailFolders(
+ PBStructArray& aMailServers, nsIPrefService* aPrefService) {
+ // Each server has a .directory pref which points to the location of the mail
+ // data for that server. We need to do two things for that case...
+ // (1) Fix up the directory path for the new profile
+ // (2) copy the mail folder data from the source directory pref to the
+ // destination directory pref
+
+ nsresult rv;
+ uint32_t count = aMailServers.Length();
+ for (uint32_t i = 0; i < count; i++) {
+ PrefBranchStruct* pref = aMailServers.ElementAt(i);
+ nsDependentCString prefName(pref->prefName);
+
+ if (StringEndsWith(prefName, ".directory-rel"_ns)) {
+ // When the directories are modified below, we may change the .directory
+ // pref. As we don't have a pref branch to modify at this stage and set
+ // up the relative folders properly, we'll just remove all the
+ // *.directory-rel prefs. Mailnews will cope with this, creating them
+ // when it first needs them.
+ if (pref->type == nsIPrefBranch::PREF_STRING) free(pref->stringValue);
+
+ aMailServers.RemoveElementAt(i);
+ // Now decrease i and count to match the removed element
+ --i;
+ --count;
+ } else if (StringEndsWith(prefName, ".directory"_ns)) {
+ // let's try to get a branch for this particular server to simplify things
+ prefName.Cut(prefName.Length() - strlen("directory"),
+ strlen("directory"));
+ prefName.Insert("mail.server.", 0);
+
+ nsCOMPtr<nsIPrefBranch> serverBranch;
+ aPrefService->GetBranch(prefName.get(), getter_AddRefs(serverBranch));
+
+ if (!serverBranch)
+ break; // should we clear out this server pref from aMailServers?
+
+ nsCString serverType;
+ serverBranch->GetCharPref("type", serverType);
+
+ nsCOMPtr<nsIFile> sourceMailFolder;
+ serverBranch->GetComplexValue("directory", NS_GET_IID(nsIFile),
+ getter_AddRefs(sourceMailFolder));
+
+ // now based on type, we need to build a new destination path for the mail
+ // folders for this server
+ nsCOMPtr<nsIFile> targetMailFolder;
+ if (serverType.Equals("imap")) {
+ mTargetProfile->Clone(getter_AddRefs(targetMailFolder));
+ targetMailFolder->Append(IMAP_MAIL_DIR_50_NAME);
+ } else if (serverType.Equals("none") || serverType.Equals("pop3") ||
+ serverType.Equals("rss")) {
+ // local folders and POP3 servers go under <profile>\Mail
+ mTargetProfile->Clone(getter_AddRefs(targetMailFolder));
+ targetMailFolder->Append(MAIL_DIR_50_NAME);
+ } else if (serverType.Equals("nntp")) {
+ mTargetProfile->Clone(getter_AddRefs(targetMailFolder));
+ targetMailFolder->Append(NEWS_DIR_50_NAME);
+ }
+
+ if (targetMailFolder) {
+ // for all of our server types, append the host name to the directory as
+ // part of the new location
+ nsCString hostName;
+ serverBranch->GetCharPref("hostname", hostName);
+ targetMailFolder->Append(NS_ConvertASCIItoUTF16(hostName));
+
+ // we should make sure the host name based directory we are going to
+ // migrate the accounts into is unique. This protects against the case
+ // where the user has multiple servers with the same host name.
+ rv = targetMailFolder->CreateUnique(nsIFile::DIRECTORY_TYPE, 0777);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ (void)RecursiveCopy(sourceMailFolder, targetMailFolder);
+ // now we want to make sure the actual directory pref that gets
+ // transformed into the new profile's pref.js has the right file
+ // location.
+ nsAutoCString descriptorString;
+ rv = targetMailFolder->GetPersistentDescriptor(descriptorString);
+ NS_ENSURE_SUCCESS(rv, rv);
+ free(pref->stringValue);
+ pref->stringValue = ToNewCString(descriptorString);
+ }
+ } else if (StringEndsWith(prefName, ".newsrc.file"_ns)) {
+ // copy the news RC file into \News. this won't work if the user has
+ // different newsrc files for each account I don't know what to do in that
+ // situation.
+
+ nsCOMPtr<nsIFile> targetNewsRCFile;
+ mTargetProfile->Clone(getter_AddRefs(targetNewsRCFile));
+ targetNewsRCFile->Append(NEWS_DIR_50_NAME);
+
+ // turn the pref into a nsIFile
+ nsCOMPtr<nsIFile> srcNewsRCFile =
+ do_CreateInstance(NS_LOCAL_FILE_CONTRACTID);
+ rv = srcNewsRCFile->SetPersistentDescriptor(
+ nsDependentCString(pref->stringValue));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // now make the copy
+ bool exists;
+ srcNewsRCFile->Exists(&exists);
+ if (exists) {
+ nsAutoString leafName;
+ srcNewsRCFile->GetLeafName(leafName);
+ srcNewsRCFile->CopyTo(
+ targetNewsRCFile,
+ leafName); // will fail if we've already copied a newsrc file here
+ targetNewsRCFile->Append(leafName);
+
+ // now write out the new descriptor
+ nsAutoCString descriptorString;
+ rv = targetNewsRCFile->GetPersistentDescriptor(descriptorString);
+ NS_ENSURE_SUCCESS(rv, rv);
+ free(pref->stringValue);
+ pref->stringValue = ToNewCString(descriptorString);
+ }
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopyPreferences(bool aReplace) {
+ nsresult rv = NS_OK;
+ nsresult tmp;
+
+ tmp = TransformPreferences(FILE_NAME_PREFS, FILE_NAME_PREFS);
+
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ tmp = CopyFile(FILE_NAME_USER_PREFS, FILE_NAME_USER_PREFS);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+
+ // Security Stuff
+ tmp = CopyFile(FILE_NAME_CERT9DB, FILE_NAME_CERT9DB);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ tmp = CopyFile(FILE_NAME_KEY4DB, FILE_NAME_KEY4DB);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ tmp = CopyFile(FILE_NAME_SECMODDB, FILE_NAME_SECMODDB);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+
+ tmp = CopyFile(FILE_NAME_PERSONALDICTIONARY, FILE_NAME_PERSONALDICTIONARY);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ tmp = CopyFile(FILE_NAME_MAILVIEWS, FILE_NAME_MAILVIEWS);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ return rv;
+}
+
+/**
+ * Use the current Thunderbird's prefs.js as base, transform branches of
+ * Seamonkey's prefs.js so that those branches can be imported without conflicts
+ * or overwriting.
+ */
+nsresult nsSeamonkeyProfileMigrator::ImportPreferences(uint16_t aItems) {
+ nsresult rv;
+ nsCOMPtr<nsIPrefService> psvc(do_GetService(NS_PREFSERVICE_CONTRACTID, &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Because all operations on nsIPrefService or nsIPrefBranch will update
+ // prefs.js directly, we need to backup the current pref file to be used as a
+ // base later.
+ nsCOMPtr<nsIFile> targetPrefsFile;
+ mTargetProfile->Clone(getter_AddRefs(targetPrefsFile));
+ targetPrefsFile->Append(FILE_NAME_PREFS + u".orig"_ns);
+ rv = psvc->SavePrefFile(targetPrefsFile);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Load the source pref file.
+ rv = psvc->ResetPrefs();
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIFile> sourcePrefsFile;
+ mSourceProfile->Clone(getter_AddRefs(sourcePrefsFile));
+ sourcePrefsFile->Append(FILE_NAME_PREFS);
+ rv = psvc->ReadUserPrefsFromFile(sourcePrefsFile);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Read in the various pref branch trees for accounts, identities, servers,
+ // etc.
+ static const char* branchNames[] = {"mail.identity.", "mail.server.",
+ "mail.account.", "mail.smtpserver.",
+ "mailnews.labels.", "mailnews.tags.",
+ "ldap_2.servers."};
+ PBStructArray sourceBranches[MOZ_ARRAY_LENGTH(branchNames)];
+ for (uint32_t i = 0; i < MOZ_ARRAY_LENGTH(branchNames); i++) {
+ if ((!(aItems & nsIMailProfileMigrator::SETTINGS) && i <= 5) ||
+ (!(aItems & nsIMailProfileMigrator::ADDRESSBOOK_DATA) && i == 6)) {
+ continue;
+ }
+ ReadBranch(branchNames[i], psvc, sourceBranches[i]);
+ }
+
+ // Read back the original prefs.
+ rv = psvc->ResetPrefs();
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = psvc->ReadUserPrefsFromFile(targetPrefsFile);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIMsgAccountManager> accountManager(
+ do_GetService("@mozilla.org/messenger/account-manager;1", &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+ PrefKeyHashTable smtpServerKeyHashTable;
+ PrefKeyHashTable identityKeyHashTable;
+ PrefKeyHashTable serverKeyHashTable;
+
+ // Transforming order is important here.
+ TransformSmtpServersForImport(sourceBranches[3], smtpServerKeyHashTable);
+
+ // mail.identity.idN.smtpServer depends on previous step.
+ TransformIdentitiesForImport(sourceBranches[0], accountManager,
+ smtpServerKeyHashTable, identityKeyHashTable);
+
+ TransformMailServersForImport(branchNames[1], psvc, sourceBranches[1],
+ accountManager, serverKeyHashTable);
+
+ // mail.accountN.{identities,server} depends on previous steps.
+ TransformMailAccountsForImport(psvc, sourceBranches[2], accountManager,
+ identityKeyHashTable, serverKeyHashTable);
+
+ // CopyMailFolders requires mail.server.serverN branch exists.
+ WriteBranch(branchNames[1], psvc, sourceBranches[1], false);
+ CopyMailFolders(sourceBranches[1], psvc);
+
+ // TransformAddressbooksForImport writes the branch and migrates the files.
+ TransformAddressbooksForImport(psvc, sourceBranches[6], false);
+
+ for (uint32_t i = 0; i < MOZ_ARRAY_LENGTH(branchNames); i++)
+ WriteBranch(branchNames[i], psvc, sourceBranches[i]);
+
+ targetPrefsFile->Remove(false);
+ return rv;
+}
+
+/**
+ * Transform mail.identity branch.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformIdentitiesForImport(
+ PBStructArray& aIdentities, nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& smtpServerKeyHashTable, PrefKeyHashTable& keyHashTable) {
+ nsresult rv;
+ nsTArray<nsCString> newKeys;
+
+ for (auto pref : aIdentities) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ } else if (StringEndsWith(prefName, ".smtpServer"_ns)) {
+ nsDependentCString serverKey(pref->stringValue);
+ nsCString newServerKey;
+ if (smtpServerKeyHashTable.Get(serverKey, &newServerKey)) {
+ pref->stringValue = moz_xstrdup(newServerKey.get());
+ }
+ }
+
+ // For every seamonkey identity, create a new one to avoid conflicts.
+ nsCString newKey;
+ if (!keyHashTable.Get(key, &newKey)) {
+ nsCOMPtr<nsIMsgIdentity> identity;
+ rv = accountManager->CreateIdentity(getter_AddRefs(identity));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ identity->GetKey(newKey);
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+
+ // Replace the prefName with the new key.
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+ }
+ pref->prefName = moz_xstrdup(prefName.get());
+ }
+ return NS_OK;
+}
+
+/**
+ * Transform mail.account branch. Also update mail.accountmanager.accounts at
+ * the end.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformMailAccountsForImport(
+ nsIPrefService* aPrefService, PBStructArray& aAccounts,
+ nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& identityKeyHashTable,
+ PrefKeyHashTable& serverKeyHashTable) {
+ nsTHashMap<nsCStringHashKey, nsCString> keyHashTable;
+ nsTArray<nsCString> newKeys;
+
+ for (auto pref : aAccounts) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ } else if (StringEndsWith(prefName, ".identities"_ns)) {
+ nsDependentCString identityKey(pref->stringValue);
+ nsCString newIdentityKey;
+ if (identityKeyHashTable.Get(identityKey, &newIdentityKey)) {
+ pref->stringValue = moz_xstrdup(newIdentityKey.get());
+ }
+ } else if (StringEndsWith(prefName, ".server"_ns)) {
+ nsDependentCString serverKey(pref->stringValue);
+ nsCString newServerKey;
+ if (serverKeyHashTable.Get(serverKey, &newServerKey)) {
+ pref->stringValue = moz_xstrdup(newServerKey.get());
+ }
+ }
+
+ // For every seamonkey account, create a new one to avoid conflicts.
+ nsCString newKey;
+ if (!keyHashTable.Get(key, &newKey)) {
+ accountManager->GetUniqueAccountKey(newKey);
+ newKeys.AppendElement(newKey);
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+
+ // Replace the prefName with the new key.
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+ }
+ pref->prefName = moz_xstrdup(prefName.get());
+ }
+
+ // Append newly create accounts to mail.accountmanager.accounts.
+ nsCOMPtr<nsIPrefBranch> branch;
+ nsCString newAccounts;
+ uint32_t count = newKeys.Length();
+ if (count) {
+ nsresult rv =
+ aPrefService->GetBranch("mail.accountmanager.", getter_AddRefs(branch));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = branch->GetCharPref("accounts", newAccounts);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ for (uint32_t i = 0; i < count; i++) {
+ newAccounts.Append(',');
+ newAccounts.Append(newKeys[i]);
+ }
+ if (count) {
+ (void)branch->SetCharPref("accounts", newAccounts);
+ }
+
+ return NS_OK;
+}
+
+/**
+ * Transform mail.server branch.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformMailServersForImport(
+ const char* branchName, nsIPrefService* aPrefService,
+ PBStructArray& aMailServers, nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& keyHashTable) {
+ nsTArray<nsCString> newKeys;
+
+ for (auto pref : aMailServers) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ }
+ nsCString newKey;
+ bool exists = keyHashTable.Get(key, &newKey);
+ if (!exists) {
+ do {
+ // Since updating prefs.js is batched, GetUniqueServerKey may return the
+ // previous key. Sleep 500ms and check if the returned key already
+ // exists to workaround it.
+ PR_Sleep(PR_MillisecondsToInterval(500));
+ accountManager->GetUniqueServerKey(newKey);
+ } while (newKeys.Contains(newKey));
+ newKeys.AppendElement(newKey);
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+ }
+
+ pref->prefName = moz_xstrdup(prefName.get());
+
+ // Set `mail.server.serverN.type` so that GetUniqueServerKey next time will
+ // get a new key.
+ if (!exists) {
+ nsCOMPtr<nsIPrefBranch> branch;
+ nsAutoCString serverTypeKey;
+ serverTypeKey.Assign(newKey.get());
+ serverTypeKey.AppendLiteral(".type");
+ nsresult rv = aPrefService->GetBranch(branchName, getter_AddRefs(branch));
+ NS_ENSURE_SUCCESS(rv, rv);
+ (void)branch->SetCharPref(serverTypeKey.get(), "placeholder"_ns);
+ }
+ }
+ return NS_OK;
+}
+
+/**
+ * Transform mail.smtpserver branch.
+ * CreateServer will update mail.smtpservers for us.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformSmtpServersForImport(
+ PBStructArray& aServers, PrefKeyHashTable& keyHashTable) {
+ nsresult rv;
+ nsCOMPtr<nsISmtpService> smtpService(
+ do_GetService("@mozilla.org/messengercompose/smtp;1", &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsTArray<nsCString> newKeys;
+
+ for (auto pref : aServers) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ }
+
+ // For every seamonkey smtp server, create a new one to avoid conflicts.
+ nsCString newKey;
+ if (!keyHashTable.Get(key, &newKey)) {
+ nsCOMPtr<nsISmtpServer> server;
+ rv = smtpService->CreateServer(getter_AddRefs(server));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ char* str;
+ server->GetKey(&str);
+ newKey.Assign(str);
+ newKeys.AppendElement(newKey);
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+
+ // Replace the prefName with the new key.
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+ }
+ pref->prefName = moz_xstrdup(prefName.get());
+ }
+
+ return NS_OK;
+}
+
+/**
+ * Transform ldap_2.servers branch.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformAddressbooksForImport(
+ nsIPrefService* aPrefService, PBStructArray& aAddressbooks, bool aReplace) {
+ nsTHashMap<nsCStringHashKey, nsCString> keyHashTable;
+ nsTHashMap<nsCStringHashKey, nsCString> pendingMigrations;
+ nsTArray<nsCString> newKeys;
+ nsresult rv;
+
+ nsCOMPtr<nsIPrefBranch> branch;
+ rv = aPrefService->GetBranch("ldap_2.servers.", getter_AddRefs(branch));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ for (auto pref : aAddressbooks) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ }
+
+ nsCString newKey;
+ if (aReplace) {
+ newKey.Assign(key);
+ } else {
+ // For every addressbook, create a new one to avoid conflicts.
+ if (!keyHashTable.Get(key, &newKey)) {
+ uint32_t uniqueCount = 0;
+
+ while (true) {
+ nsAutoCString filenameKey;
+ nsAutoCString filename;
+ filenameKey.Assign(key);
+ filenameKey.AppendInt(++uniqueCount);
+ filenameKey.AppendLiteral(".filename");
+ nsresult rv = branch->GetCharPref(filenameKey.get(), filename);
+ if (NS_FAILED(rv)) {
+ newKey.Assign(key);
+ newKey.AppendInt(uniqueCount);
+ (void)branch->SetCharPref(filenameKey.get(), "placeholder"_ns);
+ break;
+ }
+ }
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+ }
+
+ // Replace the prefName with the new key.
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+
+ if (j == 1) {
+ if (keys[j].Equals("dirType")) {
+ // Make sure we have the right type of directory.
+ pref->intValue = 101;
+ } else if (!aReplace && keys[j].Equals("description") &&
+ !strcmp(pref->stringValue,
+ "chrome://messenger/locale/addressbook/"
+ "addressBook.properties")) {
+ // We're importing the default directories, which have localized
+ // names. The names are tied to the pref's name, which we are
+ // changing, so the localization will fail. Instead, do the
+ // localization here and assign it to the directory being copied.
+ nsCOMPtr<nsIPrefLocalizedString> localizedString;
+ rv = branch->GetComplexValue(pref->prefName,
+ NS_GET_IID(nsIPrefLocalizedString),
+ getter_AddRefs(localizedString));
+ if (NS_SUCCEEDED(rv)) {
+ nsString localizedValue;
+ localizedString->GetData(localizedValue);
+ pref->stringValue =
+ moz_xstrdup(NS_ConvertUTF16toUTF8(localizedValue).get());
+ }
+ } else if (keys[j].Equals("filename")) {
+ // Update the prefs for the new filename of the directory.
+ nsCString oldFileName(pref->stringValue);
+ nsCString newFileName(pref->stringValue);
+
+ if (StringEndsWith(newFileName, nsCString("mab"))) {
+ newFileName.Cut(newFileName.Length() - strlen("mab"),
+ strlen("mab"));
+ newFileName.Append("sqlite");
+ pref->stringValue = moz_xstrdup(newFileName.get());
+ }
+
+ if (!aReplace) {
+ // Find an unused filename in the destination directory.
+ nsCOMPtr<nsIFile> targetAddrbook;
+ mTargetProfile->Clone(getter_AddRefs(targetAddrbook));
+ targetAddrbook->Append(NS_ConvertUTF8toUTF16(newFileName));
+ nsresult rv =
+ targetAddrbook->CreateUnique(nsIFile::NORMAL_FILE_TYPE, 0600);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsString leafName;
+ targetAddrbook->GetLeafName(leafName);
+
+ pref->stringValue =
+ moz_xstrdup(NS_ConvertUTF16toUTF8(leafName).get());
+ }
+
+ if (StringEndsWith(oldFileName, nsCString("sqlite"))) {
+ nsCOMPtr<nsIFile> oldFile;
+ mSourceProfile->Clone(getter_AddRefs(oldFile));
+ oldFile->Append(NS_ConvertUTF8toUTF16(oldFileName));
+ bool exists = false;
+ oldFile->Exists(&exists);
+ if (exists) {
+ // The source directory already has SQLite directories.
+ // Just copy them.
+ CopyFile(NS_ConvertUTF8toUTF16(oldFileName),
+ NS_ConvertUTF8toUTF16(newFileName));
+ continue;
+ }
+
+ oldFileName.Cut(oldFileName.Length() - strlen("sqlite"),
+ strlen("sqlite"));
+ oldFileName.Append("mab");
+ }
+
+ // Store the directories to be migrated for later.
+ pendingMigrations.InsertOrUpdate(newKey, oldFileName);
+ }
+ }
+ }
+ pref->prefName = moz_xstrdup(prefName.get());
+ }
+
+ // Write out the preferences and ask the address book manager to reload.
+ // This initializes the directories using the new prefs we've just set up.
+ WriteBranch("ldap_2.servers.", aPrefService, aAddressbooks, false);
+ NOTIFY_OBSERVERS("addrbook-reload", nullptr);
+
+ // Do the migration.
+ for (auto iter = pendingMigrations.Iter(); !iter.Done(); iter.Next()) {
+ nsCString dirPrefId = "ldap_2.servers."_ns;
+ dirPrefId.Append(iter.Key());
+ MigrateMABFile(dirPrefId, iter.UserData());
+ }
+
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::MigrateMABFile(
+ const nsCString& aDirPrefId, const nsCString& aSourceFileName) {
+ nsCOMPtr<nsIFile> sourceFile;
+ mSourceProfile->Clone(getter_AddRefs(sourceFile));
+
+ sourceFile->Append(NS_ConvertUTF8toUTF16(aSourceFileName));
+ bool exists = false;
+ sourceFile->Exists(&exists);
+ if (!exists) return NS_OK;
+
+ nsresult rv;
+
+ nsCOMPtr<nsIAbManager> abManager(
+ do_GetService("@mozilla.org/abmanager;1", &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIAbDirectory> directory;
+ rv = abManager->GetDirectoryFromId(aDirPrefId, getter_AddRefs(directory));
+ NS_ENSURE_SUCCESS(rv, NS_OK);
+
+ rv = ReadMABToDirectory(sourceFile, directory);
+
+ return NS_OK;
+}
+
+void nsSeamonkeyProfileMigrator::ReadBranch(const char* branchName,
+ nsIPrefService* aPrefService,
+ PBStructArray& aPrefs) {
+ // Enumerate the branch
+ nsCOMPtr<nsIPrefBranch> branch;
+ aPrefService->GetBranch(branchName, getter_AddRefs(branch));
+
+ nsTArray<nsCString> prefs;
+ nsresult rv = branch->GetChildList("", prefs);
+ if (NS_FAILED(rv)) return;
+
+ for (auto& pref : prefs) {
+ // Save each pref's value into an array
+ char* currPref = moz_xstrdup(pref.get());
+ int32_t type;
+ branch->GetPrefType(currPref, &type);
+ PrefBranchStruct* prefBranch = new PrefBranchStruct;
+ prefBranch->prefName = currPref;
+ prefBranch->type = type;
+ switch (type) {
+ case nsIPrefBranch::PREF_STRING: {
+ nsCString str;
+ rv = branch->GetCharPref(currPref, str);
+ prefBranch->stringValue = moz_xstrdup(str.get());
+ break;
+ }
+ case nsIPrefBranch::PREF_BOOL:
+ rv = branch->GetBoolPref(currPref, &prefBranch->boolValue);
+ break;
+ case nsIPrefBranch::PREF_INT:
+ rv = branch->GetIntPref(currPref, &prefBranch->intValue);
+ break;
+ default:
+ NS_WARNING(
+ "Invalid Pref Type in "
+ "nsNetscapeProfileMigratorBase::ReadBranch");
+ break;
+ }
+ if (NS_SUCCEEDED(rv))
+ aPrefs.AppendElement(prefBranch);
+ else
+ delete prefBranch;
+ }
+}
+
+void nsSeamonkeyProfileMigrator::WriteBranch(const char* branchName,
+ nsIPrefService* aPrefService,
+ PBStructArray& aPrefs,
+ bool deallocate) {
+ // Enumerate the branch
+ nsCOMPtr<nsIPrefBranch> branch;
+ aPrefService->GetBranch(branchName, getter_AddRefs(branch));
+
+ uint32_t count = aPrefs.Length();
+ for (uint32_t i = 0; i < count; i++) {
+ PrefBranchStruct* pref = aPrefs.ElementAt(i);
+ switch (pref->type) {
+ case nsIPrefBranch::PREF_STRING:
+ (void)branch->SetCharPref(pref->prefName,
+ nsDependentCString(pref->stringValue));
+ if (deallocate) {
+ free(pref->stringValue);
+ pref->stringValue = nullptr;
+ }
+ break;
+ case nsIPrefBranch::PREF_BOOL:
+ (void)branch->SetBoolPref(pref->prefName, pref->boolValue);
+ break;
+ case nsIPrefBranch::PREF_INT:
+ (void)branch->SetIntPref(pref->prefName, pref->intValue);
+ break;
+ default:
+ NS_WARNING(
+ "Invalid Pref Type in "
+ "nsNetscapeProfileMigratorBase::WriteBranch");
+ break;
+ }
+ if (deallocate) {
+ free(pref->prefName);
+ pref->prefName = nullptr;
+ delete pref;
+ }
+ pref = nullptr;
+ }
+ if (deallocate) {
+ aPrefs.Clear();
+ }
+}
+
+nsresult nsSeamonkeyProfileMigrator::DummyCopyRoutine(bool aReplace) {
+ // place holder function only to fake the UI out into showing some migration
+ // process.
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopyJunkTraining(bool aReplace) {
+ return aReplace ? CopyFile(FILE_NAME_JUNKTRAINING, FILE_NAME_JUNKTRAINING)
+ : NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopyPasswords(bool aReplace) {
+ nsresult rv = NS_OK;
+
+ nsCString signonsFileName;
+ GetSignonFileName(aReplace, signonsFileName);
+
+ if (signonsFileName.IsEmpty()) return NS_ERROR_FILE_NOT_FOUND;
+
+ nsAutoString fileName;
+ CopyASCIItoUTF16(signonsFileName, fileName);
+ if (aReplace)
+ rv = CopyFile(fileName, fileName);
+ else {
+ // don't do anything right now
+ }
+ return rv;
+}
diff --git a/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h b/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h
new file mode 100644
index 0000000000..e085e1b2cd
--- /dev/null
+++ b/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h
@@ -0,0 +1,84 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef seamonkeyprofilemigrator___h___
+#define seamonkeyprofilemigrator___h___
+
+#include "nsTHashMap.h"
+#include "nsIMailProfileMigrator.h"
+#include "nsIMsgAccountManager.h"
+#include "nsNetscapeProfileMigratorBase.h"
+
+class nsIPrefBranch;
+class nsIPrefService;
+
+class nsSeamonkeyProfileMigrator : public nsNetscapeProfileMigratorBase {
+ public:
+ NS_DECL_ISUPPORTS_INHERITED
+
+ nsSeamonkeyProfileMigrator();
+
+ // nsIMailProfileMigrator methods
+ NS_IMETHOD Migrate(uint16_t aItems, nsIProfileStartup* aStartup,
+ const char16_t* aProfile) override;
+ NS_IMETHOD GetMigrateData(const char16_t* aProfile, bool aReplace,
+ uint16_t* aResult) override;
+ NS_IMETHOD GetSourceProfiles(nsTArray<nsString>& aResult) override;
+ NS_IMETHOD GetSourceProfileLocations(
+ nsTArray<RefPtr<nsIFile>>& aResult) override;
+
+ protected:
+ virtual ~nsSeamonkeyProfileMigrator();
+ nsresult FillProfileDataFromSeamonkeyRegistry();
+ nsresult GetSourceProfile(const char16_t* aProfile);
+
+ nsresult MigrateMABFile(const nsCString& aDirPrefId,
+ const nsCString& aSourceFileName);
+
+ nsresult CopyPreferences(bool aReplace);
+ nsresult ImportPreferences(uint16_t aItems);
+ nsresult TransformPreferences(const nsAString& aSourcePrefFileName,
+ const nsAString& aTargetPrefFileName);
+
+ nsresult DummyCopyRoutine(bool aReplace);
+ nsresult CopyJunkTraining(bool aReplace);
+ nsresult CopyPasswords(bool aReplace);
+ nsresult CopyMailFolders(PBStructArray& aMailServers,
+ nsIPrefService* aPrefBranch);
+ nsresult CopySignatureFiles(PBStructArray& aIdentities,
+ nsIPrefService* aPrefBranch);
+
+ typedef nsTHashMap<nsCStringHashKey, nsCString> PrefKeyHashTable;
+
+ nsresult TransformIdentitiesForImport(
+ PBStructArray& aIdentities, nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& smtpServerKeyHashTable, PrefKeyHashTable& keyHashTable);
+ nsresult TransformMailAccountsForImport(
+ nsIPrefService* aPrefService, PBStructArray& aAccounts,
+ nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& identityKeyHashTable,
+ PrefKeyHashTable& serverKeyHashTable);
+ nsresult TransformMailServersForImport(const char* branchName,
+ nsIPrefService* aPrefService,
+ PBStructArray& aMailServers,
+ nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& keyHashTable);
+ nsresult TransformSmtpServersForImport(PBStructArray& aServers,
+ PrefKeyHashTable& keyHashTable);
+ nsresult TransformAddressbooksForImport(nsIPrefService* aPrefService,
+ PBStructArray& aAddressbooks,
+ bool aReplace);
+
+ void ReadBranch(const char* branchName, nsIPrefService* aPrefService,
+ PBStructArray& aPrefs);
+ void WriteBranch(const char* branchName, nsIPrefService* aPrefService,
+ PBStructArray& aPrefs, bool deallocate = true);
+
+ private:
+ nsTArray<nsString> mProfileNames;
+ nsTArray<RefPtr<nsIFile>> mProfileLocations;
+};
+
+#endif
diff --git a/comm/mail/components/moz.build b/comm/mail/components/moz.build
new file mode 100644
index 0000000000..9e4a8c3fe4
--- /dev/null
+++ b/comm/mail/components/moz.build
@@ -0,0 +1,53 @@
+# vim: set filetype=python:
+# 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/.
+
+# Only Mac and Windows have search integration components, but we include at
+# least one module from search/ on all platforms
+DIRS += [
+ "about-support",
+ "accountcreation",
+ "activity",
+ "addrbook",
+ "cloudfile",
+ "compose",
+ "customizableui",
+ "devtools",
+ "downloads",
+ "enterprisepolicies",
+ "extensions",
+ "im",
+ "migration",
+ "newmailaccount",
+ "preferences",
+ "prompts",
+ "search",
+ "shell",
+ "unifiedtoolbar",
+]
+
+EXTRA_COMPONENTS += [
+ "MailComponents.manifest",
+]
+
+EXTRA_JS_MODULES += [
+ "AboutRedirector.jsm",
+ "AppIdleManager.jsm",
+ "MailGlue.jsm",
+ "MessengerContentHandler.jsm",
+]
+
+if CONFIG["MOZ_DEBUG"] or CONFIG["NIGHTLY_BUILD"]:
+ EXTRA_JS_MODULES += [
+ "StartupRecorder.jsm",
+ ]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
+
+Library("mailcomps")
+FINAL_LIBRARY = "xul"
diff --git a/comm/mail/components/newmailaccount/content/accountProvisioner.js b/comm/mail/components/newmailaccount/content/accountProvisioner.js
new file mode 100644
index 0000000000..14ba69c515
--- /dev/null
+++ b/comm/mail/components/newmailaccount/content/accountProvisioner.js
@@ -0,0 +1,892 @@
+/* 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";
+
+/* globals MsgAccountManager, MozElements */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AccountCreationUtils:
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm",
+
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+});
+
+var { gAccountSetupLogger } = AccountCreationUtils;
+
+// AbortController to handle timeouts and abort the fetch requests.
+var gAbortController;
+var RETRY_TIMEOUT = 5000; // 5 seconds
+var CONNECTION_TIMEOUT = 15000; // 15 seconds
+var MAX_SMALL_ADDRESSES = 2;
+
+// Keep track of the prefers-reduce-motion media query for JS based animations.
+var gReducedMotion;
+
+// The main 3 Pane Window that we need to define on load in order to properly
+// update the UI when a new account is created.
+var gMainWindow;
+
+// Define window event listeners.
+window.addEventListener("load", () => {
+ gAccountProvisioner.onLoad();
+});
+window.addEventListener("unload", () => {
+ gAccountProvisioner.onUnload();
+});
+
+// Object to collect all the extra providers attributes to be used when
+// building the URL for the API call to purchase an item.
+var storedData = {};
+
+/**
+ * Helper method to split a value based on its first available blank space.
+ *
+ * @param {string} str - The string to split.
+ * @returns {Array} - An array with the generated first and last name.
+ */
+function splitName(str) {
+ let i = str.lastIndexOf(" ");
+ if (i >= 1) {
+ return [str.substring(0, i), str.substring(i + 1)];
+ }
+ return [str, ""];
+}
+
+/**
+ * Quick and simple HTML sanitization.
+ *
+ * @param {string} inputID - The ID of the currently used input field.
+ * @returns {string} - The HTML sanitized input value.
+ */
+function sanitizeName(inputID) {
+ let div = document.createElement("div");
+ div.textContent = document.getElementById(inputID).value;
+ return div.innerHTML.trim();
+}
+
+/**
+ * Replace occurrences of placeholder with the given node
+ *
+ * @param aTextContainer {Node} - DOM node containing the text child
+ * @param aTextNode {Node} - Text node containing the text, child of the aTextContainer
+ * @param aPlaceholder {String} - String to look for in aTextNode's textContent
+ * @param aReplacement {Node} - DOM node to insert instead of the found replacement
+ */
+function insertHTMLReplacement(
+ aTextContainer,
+ aTextNode,
+ aPlaceholder,
+ aReplacement
+) {
+ if (aTextNode.textContent.includes(aPlaceholder)) {
+ let placeIndex = aTextNode.textContent.indexOf(aPlaceholder);
+ let restNode = aTextNode.splitText(placeIndex + aPlaceholder.length);
+ aTextContainer.insertBefore(aReplacement, restNode);
+ let placeholderNode = aTextNode.splitText(placeIndex);
+ placeholderNode.remove();
+ }
+}
+
+/**
+ * This is our controller for the entire account provisioner setup process.
+ */
+var gAccountProvisioner = {
+ // If the setup wizard has already been initialized.
+ _isInited: false,
+ // If the data fetching of the providers is currently in progress.
+ _isLoadingProviders: false,
+ // If the providers have already been loaded.
+ _isLoadedProviders: false,
+ // Store a timeout retry in case fetching the providers fails.
+ _loadProviderRetryId: null,
+ // Array containing all fetched providers.
+ allProviders: [],
+ // Array containing all fetched provider names that only offer email.
+ mailProviders: [],
+ // Array containing all fetched provider names that also offer custom domain.
+ domainProviders: [],
+ // Handle a timeout to abort the fetch requests.
+ timeoutId: null,
+
+ /**
+ * Returns the URL for retrieving suggested names from the selected providers.
+ */
+ get suggestFromName() {
+ return Services.prefs.getCharPref("mail.provider.suggestFromName");
+ },
+
+ /**
+ * Initialize the main notification box for the account setup process.
+ */
+ get notificationBox() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ document
+ .getElementById("accountProvisionerNotifications")
+ .append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ /**
+ * Clear currently running async fetches and reset important variables.
+ */
+ onUnload() {
+ this.clearAbortTimeout();
+ gAbortController.abort();
+ gAbortController = null;
+ },
+
+ async onLoad() {
+ // We can only init once, so bail out if we've been called again.
+ if (this._isInited) {
+ return;
+ }
+
+ gAccountSetupLogger.debug("Initializing provisioner wizard");
+ gReducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches;
+
+ // Store the main window.
+ gMainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ // Initialize the fetch abort controller.
+ gAbortController = new AbortController();
+
+ // If we have a name stored, populate the search field with it.
+ if ("@mozilla.org/userinfo;1" in Cc) {
+ let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo);
+ // Assume that it's a genuine full name if it includes a space.
+ if (userInfo.fullname.includes(" ")) {
+ document.getElementById("mailName").value = userInfo.fullname;
+ document.getElementById("domainName").value = userInfo.fullname;
+ }
+ }
+
+ this.setupEventListeners();
+ await this.tryToFetchProviderList();
+
+ gAccountSetupLogger.debug("Provisioner wizard init complete.");
+
+ // Move the focus on the first available field.
+ document.getElementById("mailName").focus();
+ this._isInited = true;
+
+ Services.telemetry.scalarAdd("tb.account.opened_account_provisioner", 1);
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+ },
+
+ /**
+ * Set up the event listeners for the static elements in the page.
+ */
+ setupEventListeners() {
+ document.getElementById("cancelButton").onclick = () => {
+ window.close();
+ };
+
+ document.getElementById("existingButton").onclick = () => {
+ window.close();
+ gMainWindow.postMessage("open-account-setup-tab", "*");
+ };
+
+ document.getElementById("backButton").onclick = () => {
+ this.backToSetupView();
+ };
+ },
+
+ /**
+ * Return to the initial view without resetting any existing data.
+ */
+ backToSetupView() {
+ this.clearAbortTimeout();
+ this.clearNotifications();
+
+ // Clear search results.
+ let mailResultsArea = document.getElementById("mailResultsArea");
+ while (mailResultsArea.hasChildNodes()) {
+ mailResultsArea.lastChild.remove();
+ }
+ let domainResultsArea = document.getElementById("domainResultsArea");
+ while (domainResultsArea.hasChildNodes()) {
+ domainResultsArea.lastChild.remove();
+ }
+
+ // Update the UI to show the initial view.
+ document.getElementById("mailSearch").hidden = false;
+ document.getElementById("domainSearch").hidden = false;
+ document.getElementById("mailSearchResults").hidden = true;
+ document.getElementById("domainSearchResults").hidden = true;
+
+ // Update the buttons visibility.
+ document.getElementById("backButton").hidden = true;
+ document.getElementById("cancelButton").hidden = false;
+ document.getElementById("existingButton").hidden = false;
+
+ // Move the focus back on the first available field.
+ document.getElementById("mailName").focus();
+ },
+
+ /**
+ * Show a loading notification.
+ */
+ async startLoadingState(stringName) {
+ this.clearNotifications();
+
+ let notificationMessage = await document.l10n.formatValue(stringName);
+
+ gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`);
+
+ let notification = this.notificationBox.appendNotification(
+ "accountSetupLoading",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_INFO_LOW,
+ },
+ null
+ );
+ notification.setAttribute("align", "center");
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ /**
+ * Show an error notification in case something went wrong.
+ *
+ * @param {string} stringName - The name of the fluent string that needs to
+ * be attached to the notification.
+ * @param {boolean} isMsgError - True if the message comes from a server error
+ * response or try/catch.
+ */
+ async showErrorNotification(stringName, isMsgError) {
+ gAccountSetupLogger.debug(`Status error: ${stringName}`);
+
+ // Always remove any leftover notification before creating a new one.
+ this.clearNotifications();
+
+ // Fetch the fluent string only if this is not an error message coming from
+ // a previous method.
+ let notificationMessage = isMsgError
+ ? stringName
+ : await document.l10n.formatValue(stringName);
+
+ let notification = this.notificationBox.appendNotification(
+ "accountProvisionerError",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ async showSuccessNotification(stringName) {
+ // Always remove any leftover notification before creating a new one.
+ this.clearNotifications();
+
+ let notification = this.notificationBox.appendNotification(
+ "accountProvisionerSuccess",
+ {
+ label: await document.l10n.formatValue(stringName),
+ priority: this.notificationBox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ notification.setAttribute("type", "success");
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ /**
+ * Clear all leftover notifications.
+ */
+ clearNotifications() {
+ this.notificationBox.removeAllNotifications();
+ },
+
+ /**
+ * Event handler for when the user selects an address by clicking on the price
+ * button for that address. This function spawns the content tab for the
+ * address order form, and then closes the Account Provisioner tab.
+ *
+ * @param {string} providerId - The ID of the chosen provider.
+ * @param {string} email - The chosen email address.
+ * @param {boolean} [isDomain=false] - If the fetched data comes from a domain
+ * search form.
+ */
+ onAddressSelected(providerId, email, isDomain = false) {
+ gAccountSetupLogger.debug("An address was selected by the user.");
+ let provider = this.allProviders.find(p => p.id == providerId);
+
+ let url = provider.api;
+ let inputID = isDomain ? "domainName" : "mailName";
+ let [firstName, lastName] = splitName(sanitizeName(inputID));
+ // Replace the variables in the API url.
+ url = url.replace("{firstname}", firstName);
+ url = url.replace("{lastname}", lastName);
+ url = url.replace("{email}", email);
+
+ // And add the extra data.
+ let data = storedData[providerId];
+ delete data.provider;
+ for (let name in data) {
+ url += `${!url.includes("?") ? "?" : "&"}${name}=${encodeURIComponent(
+ data[name]
+ )}`;
+ }
+
+ gAccountSetupLogger.debug("Opening up a contentTab with the order form.");
+ // Open the checkout content tab.
+ let mail3Pane = Services.wm.getMostRecentWindow("mail:3pane");
+ let tabmail = mail3Pane.document.getElementById("tabmail");
+ tabmail.openTab("provisionerCheckoutTab", {
+ url,
+ realName: (firstName + " " + lastName).trim(),
+ email,
+ });
+
+ let providerHostname = new URL(url).hostname;
+ // Collect telemetry on which provider was selected for a new email account.
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.selected_account_from_provisioner",
+ providerHostname,
+ 1
+ );
+
+ // The user has made a selection. Close the provisioner window and let the
+ // provider setup process take place in a dedicated tab.
+ window.close();
+ },
+
+ /**
+ * Attempt to fetch the provider list from the server.
+ */
+ async tryToFetchProviderList() {
+ // If we're already in the middle of getting the provider list, or we
+ // already got it before, bail out.
+ if (this._isLoadingProviders || this._isLoadedProviders) {
+ return;
+ }
+
+ this._isLoadingProviders = true;
+
+ // If there's a timeout ID for waking the account provisioner, clear it.
+ if (this._loadProviderRetryId) {
+ window.clearTimeout(this._loadProviderRetryId);
+ this._loadProviderRetryId = null;
+ }
+
+ await this.startLoadingState("account-provisioner-fetching-provisioners");
+
+ let providerListUrl = Services.prefs.getCharPref(
+ "mail.provider.providerList"
+ );
+
+ gAccountSetupLogger.debug(
+ `Trying to populate provider list from ${providerListUrl}…`
+ );
+
+ try {
+ let res = await fetch(providerListUrl, {
+ signal: gAbortController.signal,
+ });
+ this.startAbortTimeout();
+ let data = await res.json();
+ this.populateProvidersLists(data);
+ } catch (error) {
+ // Ugh, we couldn't get the JSON file. Maybe we're not online. Or maybe
+ // the server is down, or the file isn't being served. Regardless, if
+ // we get here, none of this stuff is going to work.
+ this._loadProviderRetryId = window.setTimeout(
+ () => this.tryToFetchProviderList(),
+ RETRY_TIMEOUT
+ );
+ this._isLoadingProviders = false;
+ this.showErrorNotification("account-provisioner-connection-issues");
+ gAccountSetupLogger.warn(`Failed to populate providers: ${error}`);
+ }
+ },
+
+ /**
+ * Validate a provider fetched during an API request to be sure we have all
+ * the necessary fields to complete a setup process.
+ *
+ * @param {object} provider - The fetched provider.
+ * @returns {boolean} - True if all the fields in the provider match the
+ * required fields.
+ */
+ providerHasCorrectFields(provider) {
+ let result = true;
+
+ let required = [
+ "id",
+ "label",
+ "paid",
+ "languages",
+ "api",
+ "tos_url",
+ "privacy_url",
+ "sells_domain",
+ ];
+
+ for (let field of required) {
+ let fieldExists = field in provider;
+ result &= fieldExists;
+
+ if (!fieldExists) {
+ gAccountSetupLogger.warn(
+ `A provider did not have the field ${field}, and will be skipped.`
+ );
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Take the fetched providers, create checkboxes, icons and labels, and insert
+ * them below the corresponding search input.
+ *
+ * @param {?object} data - The object containing all fetched providers.
+ */
+ populateProvidersLists(data) {
+ gAccountSetupLogger.debug("Populating the provider list");
+ this.clearAbortTimeout();
+
+ if (!data || !data.length) {
+ gAccountSetupLogger.warn(
+ "The provider list we got back from the server was empty!"
+ );
+ this.showErrorNotification("account-provisioner-connection-issues");
+ return;
+ }
+
+ let mailProviderList = document.getElementById("mailProvidersList");
+ let domainProviderList = document.getElementById("domainProvidersList");
+
+ this.allProviders = data;
+ this.mailProviders = [];
+ this.domainProviders = [];
+
+ for (let provider of data) {
+ if (!this.providerHasCorrectFields(provider)) {
+ gAccountSetupLogger.warn(
+ "A provider had incorrect fields, and has been skipped"
+ );
+ continue;
+ }
+
+ let entry = document.createElement("li");
+ entry.setAttribute("id", provider.id);
+
+ if (provider.icon) {
+ let icon = document.createElement("img");
+ icon.setAttribute("src", provider.icon);
+ icon.setAttribute("alt", "");
+ entry.appendChild(icon);
+ }
+
+ let name = document.createElement("span");
+ name.textContent = provider.label;
+ entry.appendChild(name);
+
+ if (provider.sells_domain) {
+ domainProviderList.appendChild(entry);
+ this.domainProviders.push(provider.id);
+ } else {
+ mailProviderList.appendChild(entry);
+ this.mailProviders.push(provider.id);
+ }
+ }
+
+ this._isLoadedProviders = true;
+ this.clearNotifications();
+ },
+
+ /**
+ * Enable or disable the form fields when a fetch request starts or ends.
+ *
+ * @param {boolean} state - True if a fetch request is in progress.
+ */
+ updateSearchingState(state) {
+ for (let element of document.querySelectorAll(".disable-on-submit")) {
+ element.disabled = state;
+ }
+ },
+
+ /**
+ * Search for available email accounts.
+ *
+ * @param {DOMEvent} event - The form submit event.
+ */
+ async onMailFormSubmit(event) {
+ // Always prevent the actual form submission.
+ event.preventDefault();
+
+ // Quick HTML sanitization.
+ let name = sanitizeName("mailName");
+
+ // Bail out if the user didn't type anything.
+ if (!name) {
+ return;
+ }
+
+ let resultsArea = document.getElementById("mailSearchResults");
+ resultsArea.hidden = true;
+
+ this.startLoadingState("account-provisioner-searching-email");
+ let data = await this.submitFormRequest(name, this.mailProviders.join(","));
+ this.clearAbortTimeout();
+
+ let count = this.populateSearchResults(data);
+ if (!count) {
+ // Bail out if we didn't get any usable data.
+ gAccountSetupLogger.warn(
+ "We got nothing back from the server for search results!"
+ );
+ this.showErrorNotification("account-provisioner-searching-error");
+ return;
+ }
+
+ let resultsTitle = document.getElementById("mailResultsTitle");
+ let resultsString = await document.l10n.formatValue(
+ "account-provisioner-results-title",
+ { count }
+ );
+ // Attach the sanitized search terms to avoid HTML conversion in fluent.
+ resultsTitle.textContent = `${resultsString} "${name}"`;
+
+ // Hide the domain section.
+ document.getElementById("domainSearch").hidden = true;
+ // Show the results area.
+ resultsArea.hidden = false;
+ // Update the buttons visibility.
+ document.getElementById("cancelButton").hidden = true;
+ document.getElementById("existingButton").hidden = true;
+ // Show the back button.
+ document.getElementById("backButton").hidden = false;
+ },
+
+ /**
+ * Search for available domain names.
+ *
+ * @param {DOMEvent} event - The form submit event.
+ */
+ async onDomainFormSubmit(event) {
+ // Always prevent the actual form submission.
+ event.preventDefault();
+
+ // Quick HTML sanitization.
+ let name = sanitizeName("domainName");
+
+ // Bail out if the user didn't type anything.
+ if (!name) {
+ return;
+ }
+
+ let resultsArea = document.getElementById("domainSearchResults");
+ resultsArea.hidden = true;
+
+ this.startLoadingState("account-provisioner-searching-domain");
+ let data = await this.submitFormRequest(
+ name,
+ this.domainProviders.join(",")
+ );
+ this.clearAbortTimeout();
+
+ let count = this.populateSearchResults(data, true);
+ if (!count) {
+ // Bail out if we didn't get any usable data.
+ gAccountSetupLogger.warn(
+ "We got nothing back from the server for search results!"
+ );
+ this.showErrorNotification("account-provisioner-searching-error");
+ return;
+ }
+
+ let resultsTitle = document.getElementById("domainResultsTitle");
+ let resultsString = await document.l10n.formatValue(
+ "account-provisioner-results-title",
+ { count }
+ );
+ // Attach the sanitized search terms to avoid HTML conversion in fluent.
+ resultsTitle.textContent = `${resultsString} "${name}"`;
+
+ // Hide the mail section.
+ document.getElementById("mailSearch").hidden = true;
+ // Show the results area.
+ resultsArea.hidden = false;
+ // Update the buttons visibility.
+ document.getElementById("cancelButton").hidden = true;
+ document.getElementById("existingButton").hidden = true;
+ // Show the back button.
+ document.getElementById("backButton").hidden = false;
+ },
+
+ /**
+ * Update the UI to show the fetched address data.
+ *
+ * @param {object} data - The fetched data from an email or domain search.
+ * @param {boolean} [isDomain=false] - If the fetched data comes from a domain
+ * search form.
+ */
+ populateSearchResults(data, isDomain = false) {
+ if (!data || !data.length) {
+ return 0;
+ }
+
+ this.clearNotifications();
+
+ let resultsArea = isDomain
+ ? document.getElementById("domainResultsArea")
+ : document.getElementById("mailResultsArea");
+ // Clear previously generated content.
+ while (resultsArea.hasChildNodes()) {
+ resultsArea.lastChild.remove();
+ }
+
+ // Filter out possible errors or empty lists.
+ let validData = data.filter(
+ result => result.succeeded && result.addresses.length
+ );
+
+ if (!validData || !validData.length) {
+ return 0;
+ }
+
+ let providersList = isDomain ? this.domainProviders : this.mailProviders;
+
+ let count = 0;
+ for (let provider of validData) {
+ count += provider.addresses.length;
+
+ // Don't add a provider header if only 1 is currently available.
+ if (providersList.length > 1) {
+ let header = document.createElement("h5");
+ header.classList.add("result-list-header");
+ header.textContent = this.allProviders.find(
+ p => p.id == provider.provider
+ ).label;
+ resultsArea.appendChild(header);
+ }
+
+ let list = document.createElement("ul");
+
+ // Only show a chink of addresses if we got a long list.
+ let isLongList = provider.addresses.length > 5;
+ let addresses = isLongList
+ ? provider.addresses.slice(0, 4)
+ : provider.addresses;
+
+ for (let address of addresses) {
+ list.appendChild(this.createAddressRow(address, provider, isDomain));
+ }
+
+ resultsArea.appendChild(list);
+
+ // If we got more than 5 addresses, create an hidden bug expandable list
+ // with the rest of the data.
+ if (isLongList) {
+ let hiddenList = document.createElement("ul");
+ hiddenList.hidden = true;
+
+ for (let address of provider.addresses.slice(5)) {
+ hiddenList.appendChild(
+ this.createAddressRow(address, provider, isDomain)
+ );
+ }
+
+ let button = document.createElement("button");
+ button.setAttribute("type", "button");
+ button.classList.add("btn-link", "self-center");
+ document.l10n.setAttributes(
+ button,
+ "account-provisioner-all-results-button"
+ );
+ button.onclick = () => {
+ hiddenList.hidden = false;
+ button.hidden = true;
+ };
+
+ resultsArea.appendChild(button);
+ resultsArea.appendChild(hiddenList);
+ }
+ }
+
+ for (let provider of data) {
+ delete provider.succeeded;
+ delete provider.addresses;
+ delete provider.price;
+ storedData[provider.provider] = provider;
+ }
+
+ return count;
+ },
+
+ /**
+ * Create the list item to show the suggested address returned from a search.
+ *
+ * @param {object} address - The address returned from the provider search.
+ * @param {object} provider - The provider from which the address is
+ * @param {boolean} [isDomain=false] - If the fetched data comes from a domain
+ * search form.
+ * available.
+ * @returns {HTMLLIElement}
+ */
+ createAddressRow(address, provider, isDomain = false) {
+ let row = document.createElement("li");
+ row.classList.add("result-item");
+
+ let suggestedAddress = address.address || address;
+
+ let button = document.createElement("button");
+ button.setAttribute("type", "button");
+ button.onclick = () => {
+ this.onAddressSelected(provider.provider, suggestedAddress, isDomain);
+ };
+
+ let leftArea = document.createElement("span");
+ leftArea.classList.add("result-data");
+
+ let name = document.createElement("span");
+ name.classList.add("result-name");
+ name.textContent = suggestedAddress;
+ leftArea.appendChild(name);
+ row.setAttribute("data-label", suggestedAddress);
+
+ let price = document.createElement("span");
+ price.classList.add("result-price");
+
+ // Build the pricing text and handle possible free trials.
+ if (address.price) {
+ if (address.price != 0) {
+ // Some pricing is defined.
+ document.l10n.setAttributes(price, "account-provision-price-per-year", {
+ price: address.price,
+ });
+ } else if (address.price == 0) {
+ // Price is defined by it's zero.
+ document.l10n.setAttributes(price, "account-provisioner-free-account");
+ }
+ } else if (provider.price && provider.price != 0) {
+ // We don't have a price for the current result so let's try to use
+ // the general Provider's price.
+ document.l10n.setAttributes(price, "account-provision-price-per-year", {
+ price: provider.price,
+ });
+ } else {
+ // No price was specified, let's return "Free".
+ document.l10n.setAttributes(price, "account-provisioner-free-account");
+ }
+ leftArea.appendChild(price);
+
+ button.appendChild(leftArea);
+
+ let img = document.createElement("img");
+ document.l10n.setAttributes(img, "account-provisioner-open-in-tab-img");
+ img.setAttribute("alt", "");
+ img.setAttribute("src", "chrome://global/skin/icons/open-in-new.svg");
+ button.appendChild(img);
+
+ row.appendChild(button);
+
+ return row;
+ },
+
+ /**
+ * Fetches a list of suggested email addresses or domain names from a list of
+ * selected providers.
+ *
+ * @param {string} name - The search value typed by the user.
+ * @param {Array} providers - Array of providers to search for.
+ * @returns {object} - A list of available emails or domains.
+ */
+ async submitFormRequest(name, providers) {
+ // If the focused element is disabled by `updateSearchingState`, focus is
+ // lost. Save the focused element to restore it later.
+ let activeElement = document.activeElement;
+ this.updateSearchingState(true);
+
+ let [firstName, lastName] = splitName(name);
+ let url = `${this.suggestFromName}?first_name=${encodeURIComponent(
+ firstName
+ )}&last_name=${encodeURIComponent(lastName)}&providers=${encodeURIComponent(
+ providers
+ )}&version=2`;
+
+ let data;
+ try {
+ let res = await fetch(url, { signal: gAbortController.signal });
+ this.startAbortTimeout();
+ data = await res.json();
+ } catch (error) {
+ gAccountSetupLogger.warn(`Failed to fetch address data: ${error}`);
+ }
+
+ this.updateSearchingState(false);
+ // Restore focus.
+ activeElement.focus();
+ return data;
+ },
+
+ /**
+ * Start a timeout to abort a fetch request based on a time limit.
+ */
+ startAbortTimeout() {
+ this.timeoutId = setTimeout(() => {
+ gAbortController.abort();
+ this.showErrorNotification("account-provisioner-connection-timeout");
+ gAccountSetupLogger.warn("Connection timed out");
+ }, CONNECTION_TIMEOUT);
+ },
+
+ /**
+ * Clear any leftover timeout to prevent an unnecessary fetch abort.
+ */
+ clearAbortTimeout() {
+ if (this.timeoutId) {
+ window.clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ }
+ },
+
+ /**
+ * Always ensure the notification area is visible when a new notification is
+ * created.
+ */
+ ensureVisibleNotification() {
+ document.getElementById("accountProvisionerNotifications").scrollIntoView({
+ behavior: gReducedMotion ? "auto" : "smooth",
+ block: "start",
+ inline: "nearest",
+ });
+ },
+};
diff --git a/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml b/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml
new file mode 100644
index 0000000000..37f4be1422
--- /dev/null
+++ b/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml
@@ -0,0 +1,226 @@
+<?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://messenger/skin/accountSetup.css" type="text/css"?>
+
+<!DOCTYPE html>
+
+<html id="accountProvisioner" xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <title data-l10n-id="account-provisioner-tab-title"></title>
+ <meta name="color-scheme" content="light dark" />
+ <link
+ rel="icon"
+ href="chrome://messenger/skin/icons/new/compact/new-mail.svg"
+ />
+
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/accountProvisioner.ftl" />
+
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/accountUtils.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/newmailaccount/accountProvisioner.js"
+ ></script>
+ </head>
+
+ <body>
+ <header>
+ <h1
+ id="accountProvisionerTitle"
+ data-l10n-id="account-provisioner-title"
+ class="title"
+ ></h1>
+ <p
+ id="accountProvisionerDescription"
+ data-l10n-id="account-provisioner-description"
+ class="description"
+ ></p>
+ </header>
+
+ <section class="main-container">
+ <aside id="setupView" class="column column-wide">
+ <section id="mailSearch">
+ <h3
+ class="service-title"
+ data-l10n-id="account-provisioner-mail-account-title"
+ ></h3>
+
+ <p
+ class="service-description"
+ data-l10n-id="account-provisioner-mail-account-description"
+ >
+ <a
+ href="https://mailfence.com/"
+ data-l10n-name="mailfence-home-link"
+ ></a>
+ </p>
+
+ <form
+ id="mailForm"
+ class="service-form"
+ onsubmit="gAccountProvisioner.onMailFormSubmit(event);"
+ >
+ <div class="service-form-container">
+ <input
+ id="mailName"
+ type="text"
+ data-l10n-id="account-provisioner-mail-input"
+ class="disable-on-submit"
+ autocomplete="off"
+ required="required"
+ />
+ <button
+ type="submit"
+ class="disable-on-submit"
+ data-l10n-id="account-provisioner-search-button"
+ ></button>
+ </div>
+
+ <ul id="mailProvidersList" class="providers-list">
+ <!-- This will be populated in JS. -->
+ </ul>
+ </form>
+
+ <div id="mailSearchResults" hidden="hidden">
+ <h4 id="mailResultsTitle" class="results-title"></h4>
+ <section class="provisioner-results-area">
+ <div id="mailResultsArea" class="results-list"></div>
+ </section>
+ <p
+ data-l10n-id="account-provisioner-mail-results-caption"
+ class="tip-caption"
+ ></p>
+ </div>
+ </section>
+
+ <section id="domainSearch">
+ <h3
+ class="service-title"
+ data-l10n-id="account-provisioner-domain-title"
+ ></h3>
+
+ <p
+ class="service-description"
+ data-l10n-id="account-provisioner-domain-description"
+ >
+ <a href="https://gandi.net/" data-l10n-name="gandi-home-link"></a>
+ </p>
+
+ <form
+ id="domainForm"
+ class="service-form"
+ onsubmit="gAccountProvisioner.onDomainFormSubmit(event);"
+ >
+ <div class="service-form-container">
+ <input
+ id="domainName"
+ type="text"
+ data-l10n-id="account-provisioner-domain-input"
+ class="disable-on-submit"
+ autocomplete="off"
+ required="required"
+ />
+ <button
+ type="submit"
+ class="disable-on-submit"
+ data-l10n-id="account-provisioner-search-button"
+ ></button>
+ </div>
+
+ <ul id="domainProvidersList" class="providers-list">
+ <!-- This will be populated in JS. -->
+ </ul>
+ </form>
+
+ <div id="domainSearchResults" hidden="hidden">
+ <h4 id="domainResultsTitle" class="results-title"></h4>
+ <section class="provisioner-results-area">
+ <div id="domainResultsArea" class="results-list"></div>
+ </section>
+ <p
+ data-l10n-id="account-provisioner-domain-results-caption"
+ class="tip-caption"
+ ></p>
+ </div>
+ </section>
+
+ <div
+ id="accountProvisionerNotifications"
+ class="account-setup-notifications"
+ >
+ <!-- Notifications will be lazily loaded here. -->
+ </div>
+
+ <section class="action-buttons-container provisioner-buttons">
+ <button
+ id="backButton"
+ type="button"
+ data-l10n-id="account-provisioner-button-back"
+ data-l10n-attrs="accesskey"
+ hidden="hidden"
+ ></button>
+ <button
+ id="cancelButton"
+ type="button"
+ data-l10n-id="account-provisioner-button-cancel"
+ data-l10n-attrs="accesskey"
+ ></button>
+ <button
+ id="existingButton"
+ type="button"
+ data-l10n-id="account-provisioner-button-existing"
+ data-l10n-attrs="accesskey"
+ ></button>
+ </section>
+ </aside>
+ <!-- END setupView column -->
+
+ <aside class="column second-column">
+ <article id="step1" class="tip-caption">
+ <img
+ src="chrome://messenger/skin/illustrations/octopus-setup.svg"
+ data-l10n-id="account-provisioner-step1-image"
+ alt=""
+ />
+ <p data-l10n-id="account-provisioner-start-help">
+ <a
+ href="https://www.mozilla.org/privacy/"
+ data-l10n-name="mozilla-privacy-link"
+ ></a>
+ <a
+ href="https://mailfence.com/en/privacy.jsp"
+ data-l10n-name="mailfence-privacy-link"
+ ></a>
+ <a
+ href="https://mailfence.com/en/terms.jsp"
+ data-l10n-name="mailfence-tou-link"
+ ></a>
+ <a
+ href="https://www.gandi.net/contracts/privacy-policy"
+ data-l10n-name="gandi-privacy-link"
+ ></a>
+ <a
+ href="https://www.gandi.net/contracts/terms-of-use"
+ data-l10n-name="gandi-tou-link"
+ ></a>
+ </p>
+ </article>
+ </aside>
+ <!-- END second column-->
+ </section>
+ </body>
+</html>
diff --git a/comm/mail/components/newmailaccount/content/provisionerCheckout.js b/comm/mail/components/newmailaccount/content/provisionerCheckout.js
new file mode 100644
index 0000000000..0fc38c87e5
--- /dev/null
+++ b/comm/mail/components/newmailaccount/content/provisionerCheckout.js
@@ -0,0 +1,157 @@
+/* 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/. */
+
+// mail/base/content/contentAreaClick.js
+/* globals hRefForClickEvent, openLinkExternally */
+// mail/base/content/specialTabs.js
+/* globals specialTabs */
+
+var { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+);
+
+/**
+ * A content tab for the account provisioner. We use Javascript-y magic to
+ * "subclass" specialTabs.contentTabType, and then override the appropriate
+ * members.
+ *
+ * Also note that provisionerCheckoutTab is a singleton (hence the maxTabs: 1).
+ */
+var provisionerCheckoutTabType = Object.create(specialTabs.contentTabType, {
+ name: { value: "provisionerCheckoutTab" },
+ modes: {
+ value: {
+ provisionerCheckoutTab: {
+ type: "provisionerCheckoutTab",
+ maxTabs: 1,
+ },
+ },
+ },
+ _log: {
+ value: new ConsoleAPI({
+ prefix: "mail.provider",
+ maxLogLevel: "warn",
+ maxLogLevelPref: "mail.provider.loglevel",
+ }),
+ },
+});
+
+/**
+ * Here, we're overriding openTab - first we call the openTab of contentTab
+ * (for the context of this provisionerCheckoutTab "aTab") and then passing
+ * special arguments "realName", "email" and "searchEngine" from the caller
+ * of openTab, and passing those to our _setMonitoring function.
+ */
+provisionerCheckoutTabType.openTab = function (aTab, aArgs) {
+ specialTabs.contentTabType.openTab.call(this, aTab, aArgs);
+
+ // Since there's only one tab of this type ever (see the mode definition),
+ // we're OK to stash this stuff here.
+ this._realName = aArgs.realName;
+ this._email = aArgs.email;
+ this._searchEngine = aArgs.searchEngine || "";
+
+ this._setMonitoring(
+ aTab.browser,
+ aArgs.realName,
+ aArgs.email,
+ aArgs.searchEngine
+ );
+};
+
+/**
+ * We're overriding closeTab - first, we call the closeTab of contentTab,
+ * (for the context of this provisionerCheckoutTab "aTab"), and then we
+ * unregister our observer that was registered in _setMonitoring.
+ */
+provisionerCheckoutTabType.closeTab = function (aTab) {
+ specialTabs.contentTabType.closeTab.call(this, aTab);
+ this._log.info("Performing account provisioner cleanup");
+ this._log.info("Removing httpRequestObserver");
+ Services.obs.removeObserver(this._observer, "http-on-examine-response");
+ Services.obs.removeObserver(
+ this._observer,
+ "http-on-examine-cached-response"
+ );
+ Services.obs.removeObserver(this.quitObserver, "mail-unloading-messenger");
+ delete this._observer;
+ this._log.info("Account provisioner cleanup is done.");
+};
+
+/**
+ * Serialize our tab into something we can restore later.
+ */
+provisionerCheckoutTabType.persistTab = function (aTab) {
+ return {
+ tabURI: aTab.browser.currentURI.spec,
+ realName: this._realName,
+ email: this._email,
+ searchEngine: this._searchEngine,
+ };
+};
+
+/**
+ * Re-open the provisionerCheckoutTab with all of the stuff we stashed in
+ * persistTab. This will automatically hook up our monitoring again.
+ */
+provisionerCheckoutTabType.restoreTab = function (aTabmail, aPersistedState) {
+ aTabmail.openTab("provisionerCheckoutTab", {
+ url: aPersistedState.tabURI,
+ realName: aPersistedState.realName,
+ email: aPersistedState.email,
+ searchEngine: aPersistedState.searchEngine,
+ background: true,
+ });
+};
+
+/**
+ * This function registers an observer to watch for HTTP requests where the
+ * contentType contains text/xml.
+ */
+provisionerCheckoutTabType._setMonitoring = function (
+ aBrowser,
+ aRealName,
+ aEmail,
+ aSearchEngine
+) {
+ let mail3Pane = Services.wm.getMostRecentWindow("mail:3pane");
+
+ // We'll construct our special observer (defined in urlListener.js)
+ // that will watch for requests where the contentType contains
+ // text/xml.
+ this._observer = new mail3Pane.httpRequestObserver(aBrowser, {
+ realName: aRealName,
+ email: aEmail,
+ searchEngine: aSearchEngine,
+ });
+
+ // Register our observer
+ Services.obs.addObserver(this._observer, "http-on-examine-response");
+ Services.obs.addObserver(this._observer, "http-on-examine-cached-response");
+ Services.obs.addObserver(this.quitObserver, "mail-unloading-messenger");
+
+ this._log.info("httpRequestObserver wired up.");
+};
+
+/**
+ * This observer listens for the mail-unloading-messenger event fired by each
+ * mail window before they unload. If the mail window is the same window that
+ * this provisionerCheckoutTab belongs to, then we stash a pref so that when
+ * the session restarts, we go straight to the tab, as opposed to showing the
+ * dialog again.
+ */
+provisionerCheckoutTabType.quitObserver = {
+ observe(aSubject, aTopic, aData) {
+ // Make sure we saw the right topic, and that the window that is closing
+ // is the 3pane window that the provisionerCheckoutTab belongs to.
+ if (aTopic == "mail-unloading-messenger" && aSubject === window) {
+ // We quit while the provisionerCheckoutTab was opened. Set our sneaky
+ // pref so that we suppress the dialog on startup.
+ Services.prefs.setBoolPref(
+ "mail.provider.suppress_dialog_on_startup",
+ true
+ );
+ }
+ },
+};
diff --git a/comm/mail/components/newmailaccount/content/uriListener.js b/comm/mail/components/newmailaccount/content/uriListener.js
new file mode 100644
index 0000000000..c4d9177ebe
--- /dev/null
+++ b/comm/mail/components/newmailaccount/content/uriListener.js
@@ -0,0 +1,281 @@
+/* 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 openAccountSetupTabWithAccount, openAccountProvisionerTab */
+
+/**
+ * This object takes care of intercepting page loads and creating the
+ * corresponding account if the page load turns out to be a text/xml file from
+ * one of our account providers.
+ */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
+
+/**
+ * This is an observer that watches all HTTP requests for one where the
+ * response contentType contains text/xml. Once that observation is
+ * made, we ensure that the associated window for that request matches
+ * the window belonging to the content tab for the account order form.
+ * If so, we attach an nsITraceableListener to read the contents of the
+ * request response, and react accordingly if the contents can be turned
+ * into an email account.
+ *
+ * @param aBrowser The XUL <browser> the request lives in.
+ * @param aParams An object containing various bits of information.
+ * @param aParams.realName The real name of the person
+ * @param aParams.email The email address the person picked.
+ * @param aParams.searchEngine The search engine associated to that provider.
+ */
+function httpRequestObserver(aBrowser, aParams) {
+ this.browser = aBrowser;
+ this.params = aParams;
+}
+
+httpRequestObserver.prototype = {
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic != "http-on-examine-response" &&
+ aTopic != "http-on-examine-cached-response"
+ ) {
+ return;
+ }
+
+ if (!(aSubject instanceof Ci.nsIHttpChannel)) {
+ console.error(
+ "Failed to get a nsIHttpChannel when " +
+ "observing http-on-examine-response"
+ );
+ return;
+ }
+ // Helper function to get header values.
+ let getHttpHeader = (httpChannel, header) => {
+ // getResponseHeader throws when header is not set.
+ try {
+ return httpChannel.getResponseHeader(header);
+ } catch (e) {
+ return null;
+ }
+ };
+
+ let contentType = getHttpHeader(aSubject, "Content-Type");
+ if (!contentType || !contentType.toLowerCase().startsWith("text/xml")) {
+ return;
+ }
+
+ // It's possible the account information changed during the setup at the
+ // provider. Check some headers and set them if needed.
+ let name = getHttpHeader(aSubject, "x-thunderbird-account-name");
+ if (name) {
+ this.params.realName = name;
+ }
+ let email = getHttpHeader(aSubject, "x-thunderbird-account-email");
+ if (email) {
+ this.params.email = email;
+ }
+
+ let requestWindow = this._getWindowForRequest(aSubject);
+ if (!requestWindow || requestWindow !== this.browser.innerWindowID) {
+ return;
+ }
+
+ // Ok, we've got a request that looks like a decent candidate.
+ // Let's attach our TracingListener.
+ if (aSubject instanceof Ci.nsITraceableChannel) {
+ let newListener = new TracingListener(this.browser, this.params);
+ newListener.oldListener = aSubject.setNewListener(newListener);
+ }
+ },
+
+ /**
+ * _getWindowForRequest is an internal function that takes an nsIRequest,
+ * and returns the associated window for that request. If it cannot find
+ * an associated window, the function returns null. On exception, the
+ * exception message is logged to the Error Console and null is returned.
+ *
+ * @param aRequest the nsIRequest to analyze
+ */
+ _getWindowForRequest(aRequest) {
+ try {
+ if (aRequest && aRequest.notificationCallbacks) {
+ return aRequest.notificationCallbacks.getInterface(Ci.nsILoadContext)
+ .currentWindowContext.innerWindowId;
+ }
+ if (
+ aRequest &&
+ aRequest.loadGroup &&
+ aRequest.loadGroup.notificationCallbacks
+ ) {
+ return aRequest.loadGroup.notificationCallbacks.getInterface(
+ Ci.nsILoadContext
+ ).currentWindowContext.innerWindowId;
+ }
+ } catch (e) {
+ console.error(
+ "Could not find an associated window " +
+ "for an HTTP request. Error: " +
+ e
+ );
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
+
+/**
+ * TracingListener is an nsITracableChannel implementation that copies
+ * an incoming stream of data from a request. The data flows through this
+ * nsITracableChannel transparently to the original listener. Once the
+ * response data is fully downloaded, an attempt is made to parse it
+ * as XML, and derive email account data from it.
+ *
+ * @param aBrowser The XUL <browser> the request lives in.
+ * @param aParams An object containing various bits of information.
+ * @param aParams.realName The real name of the person
+ * @param aParams.email The email address the person picked.
+ * @param aParams.searchEngine The search engine associated to that provider.
+ */
+function TracingListener(aBrowser, aParams) {
+ this.chunks = [];
+ this.browser = aBrowser;
+ this.params = aParams;
+ this.oldListener = null;
+}
+
+TracingListener.prototype = {
+ onStartRequest(/* nsIRequest */ aRequest) {
+ this.oldListener.onStartRequest(aRequest);
+ },
+
+ onStopRequest(/* nsIRequest */ aRequest, /* int */ aStatusCode) {
+ const { CreateInBackend } = ChromeUtils.import(
+ "resource:///modules/accountcreation/CreateInBackend.jsm"
+ );
+ const { readFromXML } = ChromeUtils.import(
+ "resource:///modules/accountcreation/readFromXML.jsm"
+ );
+ const { AccountConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+ );
+
+ let newAccount;
+ try {
+ // Construct the downloaded data (we'll assume UTF-8 bytes) into XML.
+ let xml = this.chunks.join("");
+ let bytes = new Uint8Array(xml.length);
+ for (let i = 0; i < xml.length; i++) {
+ bytes[i] = xml.charCodeAt(i);
+ }
+ xml = new TextDecoder().decode(bytes);
+
+ // Attempt to derive email account information.
+ let domParser = new DOMParser();
+ let accountConfig = readFromXML(
+ JXON.build(domParser.parseFromString(xml, "text/xml"))
+ );
+ AccountConfig.replaceVariables(
+ accountConfig,
+ this.params.realName,
+ this.params.email
+ );
+
+ let host = aRequest.getRequestHeader("Host");
+ let providerHostname = new URL("http://" + host).hostname;
+ // Collect telemetry on which provider the new address was purchased from.
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.new_account_from_provisioner",
+ providerHostname,
+ 1
+ );
+
+ // Create the new account in the back end.
+ newAccount = CreateInBackend.createAccountInBackend(accountConfig);
+
+ let tabmail = document.getElementById("tabmail");
+ // Find the tab associated with this browser, and close it.
+ let myTabInfo = tabmail.tabInfo.filter(
+ function (x) {
+ return "browser" in x && x.browser == this.browser;
+ }.bind(this)
+ )[0];
+ tabmail.closeTab(myTabInfo);
+
+ // Trigger the first login to download the folder structure and messages.
+ newAccount.incomingServer.getNewMessages(
+ newAccount.incomingServer.rootFolder,
+ this._msgWindow,
+ null
+ );
+ } catch (e) {
+ // Something went wrong with account set up. Dump the error out to the
+ // error console, reopen the account provisioner tab, and show an error
+ // dialog to the user.
+ console.error("Problem interpreting provider XML:" + e);
+ openAccountProvisionerTab();
+ Services.prompt.alert(window, null, e);
+
+ this.oldListener.onStopRequest(aRequest, aStatusCode);
+ return;
+ }
+
+ // Open the account setup tab and show the success view or an error if we
+ // weren't able to create the new account.
+ openAccountSetupTabWithAccount(
+ newAccount,
+ this.params.realName,
+ this.params.email
+ );
+
+ this.oldListener.onStopRequest(aRequest, aStatusCode);
+ },
+
+ onDataAvailable(
+ /* nsIRequest */ aRequest,
+ /* nsIInputStream */ aStream,
+ /* int */ aOffset,
+ /* int */ aCount
+ ) {
+ // We want to read the stream of incoming data, but we also want
+ // to make sure it gets passed to the original listener. We do this
+ // by passing the input stream through an nsIStorageStream, writing
+ // the data to that stream, and passing it along to the next listener.
+ let binaryInputStream = Cc[
+ "@mozilla.org/binaryinputstream;1"
+ ].createInstance(Ci.nsIBinaryInputStream);
+ let storageStream = Cc["@mozilla.org/storagestream;1"].createInstance(
+ Ci.nsIStorageStream
+ );
+ let outStream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(
+ Ci.nsIBinaryOutputStream
+ );
+
+ binaryInputStream.setInputStream(aStream);
+
+ // The segment size of 8192 is a little magical - more or less
+ // copied from nsITraceableChannel example code strewn about the
+ // web.
+ storageStream.init(8192, aCount, null);
+ outStream.setOutputStream(storageStream.getOutputStream(0));
+
+ let data = binaryInputStream.readBytes(aCount);
+ this.chunks.push(data);
+
+ outStream.writeBytes(data, aCount);
+ this.oldListener.onDataAvailable(
+ aRequest,
+ storageStream.newInputStream(0),
+ aOffset,
+ aCount
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+};
diff --git a/comm/mail/components/newmailaccount/jar.mn b/comm/mail/components/newmailaccount/jar.mn
new file mode 100644
index 0000000000..23554a9584
--- /dev/null
+++ b/comm/mail/components/newmailaccount/jar.mn
@@ -0,0 +1,9 @@
+# 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/newmailaccount/accountProvisioner.xhtml (content/accountProvisioner.xhtml)
+ content/messenger/newmailaccount/accountProvisioner.js (content/accountProvisioner.js)
+ content/messenger/newmailaccount/provisionerCheckout.js (content/provisionerCheckout.js)
+ content/messenger/newmailaccount/uriListener.js (content/uriListener.js)
diff --git a/comm/mail/components/newmailaccount/moz.build b/comm/mail/components/newmailaccount/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/mail/components/newmailaccount/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/preferences/actionsshared.js b/comm/mail/components/preferences/actionsshared.js
new file mode 100644
index 0000000000..aae709d957
--- /dev/null
+++ b/comm/mail/components/preferences/actionsshared.js
@@ -0,0 +1,23 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 FILEACTION_SAVE_TO_DISK = 1;
+var FILEACTION_OPEN_INTERNALLY = 2;
+var FILEACTION_OPEN_DEFAULT = 3;
+var FILEACTION_OPEN_CUSTOM = 4;
+function FileAction() {}
+FileAction.prototype = {
+ type: "",
+ extension: "",
+ hasExtension: true,
+ editable: true,
+ smallIcon: "",
+ bigIcon: "",
+ typeName: "",
+ action: "",
+ mimeInfo: null,
+ customHandler: "",
+ handleMode: false,
+};
diff --git a/comm/mail/components/preferences/applicationManager.js b/comm/mail/components/preferences/applicationManager.js
new file mode 100644
index 0000000000..5ebc0f077b
--- /dev/null
+++ b/comm/mail/components/preferences/applicationManager.js
@@ -0,0 +1,112 @@
+/* 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/. */
+
+// applications.js
+/* globals gGeneralPane */
+
+var gAppManagerDialog = {
+ _removed: [],
+
+ init() {
+ this.handlerInfo = window.arguments[0];
+ var bundle = document.getElementById("appManagerBundle");
+ gGeneralPane._prefsBundle = document.getElementById("bundlePreferences");
+ var description = this.handlerInfo.typeDescription;
+ var key =
+ this.handlerInfo.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo
+ ? "handleFile"
+ : "handleProtocol";
+ var contentText = bundle.getFormattedString(key, [description]);
+ contentText = bundle.getFormattedString("descriptionApplications", [
+ contentText,
+ ]);
+ document.getElementById("appDescription").textContent = contentText;
+
+ let list = document.getElementById("appList");
+ let listFragment = document.createDocumentFragment();
+ for (let app of this.handlerInfo.possibleApplicationHandlers.enumerate()) {
+ if (!gGeneralPane.isValidHandlerApp(app)) {
+ continue;
+ }
+
+ let item = document.createXULElement("richlistitem");
+ item.classList.add("typeLabel");
+ listFragment.append(item);
+ item.app = app;
+
+ let image = document.createElement("img");
+ image.classList.add("typeIcon");
+ image.setAttribute("src", gGeneralPane._getIconURLForHandlerApp(app));
+ image.setAttribute("alt", "");
+ item.appendChild(image);
+
+ let label = document.createElement("span");
+ label.classList.add("typeDescription");
+ label.textContent = app.name;
+ item.appendChild(label);
+ }
+ list.append(listFragment);
+
+ // Triggers onSelect which populates label.
+ list.selectedIndex = 0;
+ },
+
+ onOK() {
+ if (!this._removed.length) {
+ // return early to avoid calling the |store| method.
+ return;
+ }
+
+ for (var i = 0; i < this._removed.length; ++i) {
+ this.handlerInfo.removePossibleApplicationHandler(this._removed[i]);
+ }
+
+ this.handlerInfo.store();
+ },
+
+ remove() {
+ var list = document.getElementById("appList");
+ this._removed.push(list.selectedItem.app);
+ var index = list.selectedIndex;
+ list.selectedItem.remove();
+ if (list.getRowCount() == 0) {
+ // The list is now empty, make the bottom part disappear
+ document.getElementById("appDetails").hidden = true;
+ } else {
+ // Select the item at the same index, if we removed the last
+ // item of the list, select the previous item
+ if (index == list.getRowCount()) {
+ --index;
+ }
+ list.selectedIndex = index;
+ }
+ },
+
+ onSelect() {
+ var list = document.getElementById("appList");
+ if (!list.selectedItem) {
+ document.getElementById("remove").disabled = true;
+ return;
+ }
+ document.getElementById("remove").disabled = false;
+ var app = list.selectedItem.app;
+ var address = "";
+ if (app instanceof Ci.nsILocalHandlerApp) {
+ address = app.executable.path;
+ } else if (app instanceof Ci.nsIWebHandlerApp) {
+ address = app.uriTemplate;
+ } else if (app instanceof Ci.nsIWebContentHandlerInfo) {
+ address = app.uri;
+ }
+ document.getElementById("appLocation").value = address;
+ var bundle = document.getElementById("appManagerBundle");
+ var appType =
+ app instanceof Ci.nsILocalHandlerApp
+ ? "descriptionLocalApp"
+ : "descriptionWebApp";
+ document.getElementById("appType").value = bundle.getString(appType);
+ },
+};
+
+document.addEventListener("dialogaccept", () => gAppManagerDialog.onOK());
diff --git a/comm/mail/components/preferences/applicationManager.xhtml b/comm/mail/components/preferences/applicationManager.xhtml
new file mode 100644
index 0000000000..10c9346c0f
--- /dev/null
+++ b/comm/mail/components/preferences/applicationManager.xhtml
@@ -0,0 +1,76 @@
+<?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"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/preferences/applications.css"?>
+
+<!DOCTYPE window>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="gAppManagerDialog.init();"
+ data-l10n-id="app-manager-window-dialog2"
+ style="min-width: 30em"
+>
+ <dialog id="appManager" buttons="accept,cancel">
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/general.js" />
+ <script src="chrome://messenger/content/preferences/applicationManager.js" />
+
+ <commandset id="appManagerCommandSet">
+ <command
+ id="cmd_delete"
+ oncommand="gAppManagerDialog.remove();"
+ disabled="true"
+ />
+ </commandset>
+
+ <keyset id="appManagerKeyset">
+ <key id="delete" keycode="VK_DELETE" command="cmd_delete" />
+ </keyset>
+
+ <stringbundle
+ id="appManagerBundle"
+ src="chrome://messenger/locale/preferences/applicationManager.properties"
+ />
+ <stringbundle
+ id="bundlePreferences"
+ src="chrome://messenger/locale/preferences/preferences.properties"
+ />
+
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/preferences/application-manager.ftl"
+ />
+ </linkset>
+
+ <description id="appDescription" />
+ <separator class="thin" />
+ <hbox flex="1">
+ <richlistbox
+ id="appList"
+ onselect="gAppManagerDialog.onSelect();"
+ flex="1"
+ style="min-height: 150px"
+ />
+ <vbox>
+ <button
+ id="remove"
+ data-l10n-id="remove-app-button"
+ command="cmd_delete"
+ />
+ <spacer flex="1" />
+ </vbox>
+ </hbox>
+ <vbox id="appDetails">
+ <separator class="thin" />
+ <label id="appType" />
+ <html:input id="appLocation" type="text" readonly="readonly" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/attachmentReminder.js b/comm/mail/components/preferences/attachmentReminder.js
new file mode 100644
index 0000000000..da01364b4e
--- /dev/null
+++ b/comm/mail/components/preferences/attachmentReminder.js
@@ -0,0 +1,100 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 gAttachmentReminderOptionsDialog = {
+ keywordListBox: null,
+
+ init() {
+ this.keywordListBox = document.getElementById("keywordList");
+ this.buildKeywordList();
+ },
+
+ buildKeywordList() {
+ var keywordsInCsv = Services.prefs.getComplexValue(
+ "mail.compose.attachment_reminder_keywords",
+ Ci.nsIPrefLocalizedString
+ );
+ if (!keywordsInCsv) {
+ return;
+ }
+ keywordsInCsv = keywordsInCsv.data;
+ var keywordsInArr = keywordsInCsv.split(",");
+ for (let i = 0; i < keywordsInArr.length; i++) {
+ if (keywordsInArr[i]) {
+ this.keywordListBox.appendItem(keywordsInArr[i], keywordsInArr[i]);
+ }
+ }
+ if (keywordsInArr.length) {
+ this.keywordListBox.selectedIndex = 0;
+ }
+ },
+
+ async addKeyword() {
+ var input = { value: "" }; // Default to empty.
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: "new-keyword-title" },
+ { id: "new-keyword-label" },
+ ]);
+
+ var ok = Services.prompt.prompt(window, title, message, input, null, {
+ value: 0,
+ });
+ if (ok && input.value) {
+ let newKey = this.keywordListBox.appendItem(input.value, input.value);
+ this.keywordListBox.ensureElementIsVisible(newKey);
+ this.keywordListBox.selectItem(newKey);
+ }
+ },
+
+ async editKeyword() {
+ if (this.keywordListBox.selectedIndex < 0) {
+ return;
+ }
+ var keywordToEdit = this.keywordListBox.selectedItem;
+ var input = { value: keywordToEdit.getAttribute("value") };
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: "edit-keyword-title" },
+ { id: "edit-keyword-label" },
+ ]);
+
+ var ok = Services.prompt.prompt(window, title, message, input, null, {
+ value: 0,
+ });
+ if (ok && input.value) {
+ this.keywordListBox.selectedItem.value = input.value;
+ this.keywordListBox.selectedItem.label = input.value;
+ }
+ },
+
+ removeKeyword() {
+ if (this.keywordListBox.selectedIndex < 0) {
+ return;
+ }
+ this.keywordListBox.selectedItem.remove();
+ },
+
+ saveKeywords() {
+ var keywordList = "";
+ for (var i = 0; i < this.keywordListBox.getRowCount(); i++) {
+ keywordList += this.keywordListBox
+ .getItemAtIndex(i)
+ .getAttribute("value");
+ if (i != this.keywordListBox.getRowCount() - 1) {
+ keywordList += ",";
+ }
+ }
+
+ Services.prefs.setStringPref(
+ "mail.compose.attachment_reminder_keywords",
+ keywordList
+ );
+ },
+};
+
+document.addEventListener("dialogaccept", () =>
+ gAttachmentReminderOptionsDialog.saveKeywords()
+);
diff --git a/comm/mail/components/preferences/attachmentReminder.xhtml b/comm/mail/components/preferences/attachmentReminder.xhtml
new file mode 100644
index 0000000000..7f2f7b19ce
--- /dev/null
+++ b/comm/mail/components/preferences/attachmentReminder.xhtml
@@ -0,0 +1,54 @@
+<?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"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="attachment-reminder-window"
+ onload="gAttachmentReminderOptionsDialog.init();"
+ style="min-width: 38em"
+>
+ <dialog id="attachmentReminderOptionsDialog" dlgbuttons="accept,cancel">
+ <script src="chrome://messenger/content/preferences/attachmentReminder.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/attachment-reminder.ftl"
+ />
+ </linkset>
+
+ <vbox>
+ <label control="keywordList" data-l10n-id="attachment-reminder-label" />
+ <hbox>
+ <richlistbox
+ id="keywordList"
+ flex="1"
+ ondblclick="gAttachmentReminderOptionsDialog.editKeyword();"
+ />
+ <vbox>
+ <button
+ data-l10n-id="keyword-new-button"
+ oncommand="gAttachmentReminderOptionsDialog.addKeyword();"
+ />
+ <button
+ data-l10n-id="keyword-edit-button"
+ oncommand="gAttachmentReminderOptionsDialog.editKeyword();"
+ />
+ <button
+ data-l10n-id="keyword-remove-button"
+ oncommand="gAttachmentReminderOptionsDialog.removeKeyword();"
+ />
+ </vbox>
+ </hbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/chat.inc.xhtml b/comm/mail/components/preferences/chat.inc.xhtml
new file mode 100644
index 0000000000..22b0cd3c4a
--- /dev/null
+++ b/comm/mail/components/preferences/chat.inc.xhtml
@@ -0,0 +1,198 @@
+# 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/.
+ <script src="chrome://messenger/content/preferences/chat.js"/>
+ <script src="chrome://messenger/content/preferences/messagestyle.js"/>
+
+ <stringbundle id="themesBundle"
+ src="chrome://messenger/locale/preferences/messagestyle.properties"/>
+ <html:template id="paneChat">
+ <hbox id="chatPaneCategory"
+ class="subcategory"
+ data-category="paneChat">
+ <html:h1 data-l10n-id="chat-pane-header"/>
+ </hbox>
+
+ <html:div data-category="paneChat">
+ <html:fieldset data-category="paneChat">
+ <html:legend data-l10n-id="chat-status-title"></html:legend>
+ <!-- Startup -->
+ <hbox align="center">
+ <label id="chatStartupAction"
+ data-l10n-id="startup-label"
+ control="messengerStartupAction"/>
+ <hbox>
+ <menulist id="messengerStartupAction" preference="messenger.startup.action">
+ <menupopup>
+ <menuitem data-l10n-id="offline-label" value="0"/>
+ <menuitem data-l10n-id="auto-connect-label" value="1"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <separator/>
+
+ <!-- Status -->
+ <hbox align="center">
+ <checkbox id="reportIdle" data-l10n-id="idle-label"
+ preference="messenger.status.reportIdle"/>
+ <html:input id="timeBeforeAway" type="number"
+ class="size2 idle-reporting-enabled"
+ min="1" max="720"
+ preference="messenger.status.timeBeforeIdle"/>
+ <label data-l10n-id="idle-time-label" control="timeBeforeAway"/>
+ </hbox>
+ <vbox class="indent">
+ <hbox>
+ <checkbox id="autoAway"
+ data-l10n-id="away-message-label"
+ class="idle-reporting-enabled"
+ preference="messenger.status.awayWhenIdle"/>
+ <spacer flex="1"/>
+ </hbox>
+ <html:input id="defaultIdleAwayMessage"
+ type="text"
+ class="idle-reporting-enabled indent"
+ preference="messenger.status.defaultIdleAwayMessage"/>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneChat">
+ <html:fieldset data-category="paneChat">
+ <html:legend data-l10n-id="chat-notifications-title"></html:legend>
+ <hbox>
+ <checkbox id="sendTyping"
+ data-l10n-id="send-typing-label"
+ preference="purple.conversations.im.send_typing"/>
+ <spacer flex="1"/>
+ </hbox>
+
+ <separator/>
+
+ <hbox>
+ <label data-l10n-id="notification-label"/>
+ </hbox>
+ <hbox>
+ <checkbox id="desktopChatNotifications"
+ data-l10n-id="show-notification-label"
+ preference="mail.chat.show_desktop_notifications"/>
+ <hbox>
+ <menulist id="chatNotificationInfo" preference="mail.chat.notification_info">
+ <menupopup>
+ <menuitem data-l10n-id="notification-all" value="0"/>
+ <menuitem data-l10n-id="notification-name" value="1"/>
+ <menuitem data-l10n-id="notification-empty" value="2"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <checkbox id="getAttention"
+ preference="messenger.options.getAttentionOnNewMessages"
+ data-l10n-id="notification-type-label"/>
+ <hbox align="center">
+ <checkbox id="chatNotification"
+ data-l10n-id="chat-play-sound-label"
+ preference="mail.chat.play_sound"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="playChatSound"
+ data-l10n-id="chat-play-button"
+ oncommand="gChatPane.previewSound();"/>
+ </hbox>
+ <radiogroup id="chatSoundType"
+ class="indent"
+ orient="vertical"
+ preference="mail.chat.play_sound.type"
+ aria-labelledby="chatNotification">
+ <hbox>
+ <radio id="chatSoundSystemSound"
+ data-l10n-id="chat-system-sound-label"
+ value="0"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <radio id="chatSoundCustom"
+ data-l10n-id="chat-custom-sound-label"
+ value="1"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox align="center" class="input-container">
+ <html:input id="chatSoundUrlLocation"
+ type="text"
+ class="input-filefield indent"
+ readonly="readonly"
+ preference="mail.chat.play_sound.url"
+ preference-editable="true"
+ aria-labelledby="chatSoundCustom"/>
+ <button is="highlightable-button" id="browseForChatSound"
+ data-l10n-id="chat-browse-sound-button"
+ oncommand="gChatPane.browseForSoundFile();"/>
+ </hbox>
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="chatPaneStylingCategory"
+ class="subcategory"
+ data-category="paneChat">
+ <html:h1 data-l10n-id="chat-pane-styling-header"/>
+ </hbox>
+
+ <html:div data-category="paneChat">
+ <html:fieldset data-category="paneChat">
+ <separator/>
+ <hbox align="center">
+ <label data-l10n-id="theme-label" control="messagestyle-themename"/>
+ <hbox flex="1">
+ <menulist id="messagestyle-themename"
+ flex="1" crop="end"
+ preference="messenger.options.messagesStyle.theme"
+ onselect="previewObserver.currentThemeChanged();">
+ <menupopup id="theme-menupopup">
+ <menuitem id="mail-menuitem"
+ data-l10n-id="style-mail"
+ value="mail"/>
+ <menuitem id="bubbles-menuitem"
+ data-l10n-id="style-bubbles"
+ value="bubbles"/>
+ <menuitem id="dark-menuitem"
+ data-l10n-id="style-dark"
+ value="dark"/>
+ <menuitem id="papersheets-menuitem"
+ data-l10n-id="style-paper"
+ value="papersheets"/>
+ <menuitem id="simple-menuitem"
+ data-l10n-id="style-simple"
+ value="simple"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <separator class="thin"/>
+ <hbox align="start">
+ <label data-l10n-id="preview-label"/>
+ <tooltip id="aHTMLTooltip" page="true"/>
+ <vbox id="previewBox" flex="1">
+ <vbox id="noPreviewScreen" flex="1" align="center" pack="center">
+ <hbox id="noPreviewBox" align="start">
+ <vbox id="noPreviewInnerBox" flex="1">
+ <label id="noPreviewTitle" data-l10n-id="no-preview-label"/>
+ <description id="noAccountDesc"
+ data-l10n-id="no-preview-description"/>
+ </vbox>
+ </hbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ <hbox align="center">
+ <label data-l10n-id="chat-variant-label" control="themevariant"/>
+ <hbox>
+ <menulist id="themevariant"
+ preference="messenger.options.messagesStyle.variant"
+ onselect="previewObserver.currentVariantChanged();"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ </html:template>
diff --git a/comm/mail/components/preferences/chat.js b/comm/mail/components/preferences/chat.js
new file mode 100644
index 0000000000..e6e7b660c8
--- /dev/null
+++ b/comm/mail/components/preferences/chat.js
@@ -0,0 +1,193 @@
+/* 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";
+
+/* import-globals-from preferences.js */
+// messagestyle.js
+/* globals previewObserver */
+
+Preferences.addAll([
+ { id: "messenger.startup.action", type: "int" },
+ { id: "purple.conversations.im.send_typing", type: "bool" },
+ { id: "messenger.status.reportIdle", type: "bool" },
+ { id: "messenger.status.timeBeforeIdle", type: "int" },
+ { id: "messenger.status.awayWhenIdle", type: "bool" },
+ { id: "messenger.status.defaultIdleAwayMessage", type: "wstring" },
+ { id: "purple.logging.log_chats", type: "bool" },
+ { id: "purple.logging.log_ims", type: "bool" },
+ { id: "purple.logging.log_system", type: "bool" },
+ { id: "mail.chat.show_desktop_notifications", type: "bool" },
+ { id: "mail.chat.notification_info", type: "int" },
+ { id: "mail.chat.play_sound", type: "bool" },
+ { id: "mail.chat.play_sound.type", type: "int" },
+ { id: "mail.chat.play_sound.url", type: "string" },
+ { id: "messenger.options.getAttentionOnNewMessages", type: "bool" },
+ { id: "messenger.options.messagesStyle.theme", type: "string" },
+ { id: "messenger.options.messagesStyle.variant", type: "string" },
+]);
+
+var gChatPane = {
+ init() {
+ this.updateDisabledState();
+ this.updateMessageDisabledState();
+ this.updatePlaySound();
+ this.initPreview();
+
+ let element = document.getElementById("timeBeforeAway");
+ Preferences.addSyncFromPrefListener(
+ element,
+ () =>
+ Preferences.get("messenger.status.timeBeforeIdle")
+ .valueFromPreferences / 60
+ );
+ Preferences.addSyncToPrefListener(element, element => element.value * 60);
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("chatSoundUrlLocation"),
+ () => this.readSoundLocation()
+ );
+ },
+
+ initPreview() {
+ // We add this browser only when really necessary.
+ let previewBox = document.getElementById("previewBox");
+ if (previewBox.querySelector("browser")) {
+ return;
+ }
+
+ document.getElementById("noPreviewScreen").hidden = true;
+ let browser = document.createXULElement("browser", {
+ is: "conversation-browser",
+ });
+ browser.setAttribute("id", "previewbrowser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("flex", "1");
+ browser.setAttribute("tooltip", "aHTMLTooltip");
+ previewBox.appendChild(browser);
+ previewObserver.load();
+ },
+
+ updateDisabledState() {
+ let checked = Preferences.get("messenger.status.reportIdle").value;
+ document.querySelectorAll(".idle-reporting-enabled").forEach(e => {
+ e.disabled = !checked;
+ });
+ },
+
+ updateMessageDisabledState() {
+ let textbox = document.getElementById("defaultIdleAwayMessage");
+ textbox.toggleAttribute(
+ "disabled",
+ !Preferences.get("messenger.status.awayWhenIdle").value
+ );
+ },
+
+ convertURLToLocalFile(aFileURL) {
+ // convert the file url into a nsIFile
+ if (aFileURL) {
+ return Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getFileFromURLSpec(aFileURL);
+ }
+ return null;
+ },
+
+ readSoundLocation() {
+ let chatSoundUrlLocation = document.getElementById("chatSoundUrlLocation");
+ chatSoundUrlLocation.value = Preferences.get(
+ "mail.chat.play_sound.url"
+ ).value;
+ if (chatSoundUrlLocation.value) {
+ chatSoundUrlLocation.label = this.convertURLToLocalFile(
+ chatSoundUrlLocation.value
+ ).leafName;
+ chatSoundUrlLocation.style.backgroundImage =
+ "url(moz-icon://" + chatSoundUrlLocation.label + "?size=16)";
+ }
+ },
+
+ previewSound() {
+ let sound = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound);
+
+ let soundLocation =
+ document.getElementById("chatSoundType").value == 1
+ ? document.getElementById("chatSoundUrlLocation").value
+ : "";
+
+ // This should be in sync with the code in nsStatusBarBiffManager::PlayBiffSound.
+ if (!soundLocation.startsWith("file://")) {
+ if (Services.appinfo.OS == "Darwin") {
+ // OS X
+ sound.beep();
+ } else {
+ sound.playEventSound(Ci.nsISound.EVENT_NEW_MAIL_RECEIVED);
+ }
+ } else {
+ sound.play(Services.io.newURI(soundLocation));
+ }
+ },
+
+ browseForSoundFile() {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ // If we already have a sound file, then use the path for that sound file
+ // as the initial path in the dialog.
+ let localFile = this.convertURLToLocalFile(
+ document.getElementById("chatSoundUrlLocation").value
+ );
+ if (localFile) {
+ fp.displayDirectory = localFile.parent;
+ }
+
+ // XXX todo, persist the last sound directory and pass it in
+ fp.init(
+ window,
+ document
+ .getElementById("bundlePreferences")
+ .getString("soundFilePickerTitle"),
+ Ci.nsIFilePicker.modeOpen
+ );
+ fp.appendFilters(Ci.nsIFilePicker.filterAudio);
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK) {
+ return;
+ }
+
+ // convert the nsIFile into a nsIFile url
+ Preferences.get("mail.chat.play_sound.url").value = fp.fileURL.spec;
+ this.readSoundLocation(); // XXX We shouldn't have to be doing this by hand
+ this.updatePlaySound();
+ });
+ },
+
+ updatePlaySound() {
+ let soundsEnabled = Preferences.get("mail.chat.play_sound").value;
+ let soundTypeValue = Preferences.get("mail.chat.play_sound.type").value;
+ let soundUrlLocation = Preferences.get("mail.chat.play_sound.url").value;
+ let soundDisabled = !soundsEnabled || soundTypeValue != 1;
+
+ document.getElementById("chatSoundType").disabled = !soundsEnabled;
+ document.getElementById("chatSoundUrlLocation").disabled = soundDisabled;
+ document.getElementById("browseForChatSound").disabled = soundDisabled;
+ document.getElementById("playChatSound").disabled =
+ !soundsEnabled || (!soundUrlLocation && soundTypeValue != 0);
+ },
+};
+
+Preferences.get("messenger.status.reportIdle").on(
+ "change",
+ gChatPane.updateDisabledState
+);
+Preferences.get("messenger.status.awayWhenIdle").on(
+ "change",
+ gChatPane.updateMessageDisabledState
+);
+Preferences.get("mail.chat.play_sound").on("change", gChatPane.updatePlaySound);
+Preferences.get("mail.chat.play_sound.type").on(
+ "change",
+ gChatPane.updatePlaySound
+);
diff --git a/comm/mail/components/preferences/colors.js b/comm/mail/components/preferences/colors.js
new file mode 100644
index 0000000000..cd45f8ca44
--- /dev/null
+++ b/comm/mail/components/preferences/colors.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "browser.display.document_color_use", type: "int" },
+ { id: "browser.anchor_color", type: "string" },
+ { id: "browser.visited_color", type: "string" },
+ { id: "browser.underline_anchors", type: "bool" },
+ { id: "browser.display.foreground_color", type: "string" },
+ { id: "browser.display.background_color", type: "string" },
+ { id: "browser.display.use_system_colors", type: "bool" },
+]);
diff --git a/comm/mail/components/preferences/colors.xhtml b/comm/mail/components/preferences/colors.xhtml
new file mode 100644
index 0000000000..827078ba4c
--- /dev/null
+++ b/comm/mail/components/preferences/colors.xhtml
@@ -0,0 +1,90 @@
+<?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"?>
+
+<!DOCTYPE window>
+
+<window type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="colors-dialog-window2">
+ <dialog id="ColorsDialog"
+ dlgbuttons="accept,cancel">
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/colors.ftl"/>
+ </linkset>
+
+ <hbox>
+ <hbox flex="1">
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="colors-dialog-legend"></html:legend>
+ <hbox align="center">
+ <label data-l10n-id="text-color-label" control="foregroundtextmenu"/>
+ <spacer flex="1"/>
+ <html:input type="color" id="foregroundtextmenu" preference="browser.display.foreground_color"/>
+ </hbox>
+ <hbox align="center" style="margin-top: 5px">
+ <label data-l10n-id="background-color-label" control="backgroundmenu"/>
+ <spacer flex="1"/>
+ <html:input type="color" id="backgroundmenu" preference="browser.display.background_color"/>
+ </hbox>
+ <separator class="thin"/>
+ <hbox align="center">
+ <checkbox id="browserUseSystemColors" data-l10n-id="use-system-colors"
+ preference="browser.display.use_system_colors"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+ </hbox>
+ <hbox flex="1">
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="colors-link-legend"></html:legend>
+ <hbox align="center">
+ <label data-l10n-id="link-color-label" control="unvisitedlinkmenu"/>
+ <spacer flex="1"/>
+ <html:input type="color" id="unvisitedlinkmenu" preference="browser.anchor_color"/>
+ </hbox>
+ <hbox align="center" style="margin-top: 5px">
+ <label data-l10n-id="visited-link-color-label" control="visitedlinkmenu"/>
+ <spacer flex="1"/>
+ <html:input type="color" id="visitedlinkmenu" preference="browser.visited_color"/>
+ </hbox>
+ <separator class="thin"/>
+ <hbox align="center">
+ <checkbox id="browserUnderlineAnchors" data-l10n-id="underline-link-checkbox"
+ preference="browser.underline_anchors"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+ </hbox>
+ </hbox>
+#ifdef XP_WIN
+ <vbox align="start">
+#else
+ <vbox>
+#endif
+ <label data-l10n-id="override-color-label"
+ control="useDocumentColors"/>
+ <menulist id="useDocumentColors"
+ preference="browser.display.document_color_use">
+ <menupopup>
+ <menuitem data-l10n-id="override-color-always"
+ value="2" id="documentColorAlways"/>
+ <menuitem data-l10n-id="override-color-auto"
+ value="0" id="documentColorAutomatic"/>
+ <menuitem data-l10n-id="override-color-never"
+ value="1" id="documentColorNever"/>
+ </menupopup>
+ </menulist>
+ </vbox>
+
+ <script src="chrome://global/content/preferencesBindings.js"/>
+ <script src="chrome://messenger/content/preferences/colors.js"/>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/compose.inc.xhtml b/comm/mail/components/preferences/compose.inc.xhtml
new file mode 100644
index 0000000000..3ba7063084
--- /dev/null
+++ b/comm/mail/components/preferences/compose.inc.xhtml
@@ -0,0 +1,354 @@
+# 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/.
+ <script src="chrome://messenger/content/preferences/compose.js"/>
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://messenger/content/preferences/downloads.js"/>
+ <script src="chrome://communicator/content/utilityOverlay.js"/>
+
+ <stringbundle id="bundle_addressBook" src="chrome://messenger/locale/addressbook/addressBook.properties"/>
+ <html:template id="paneCompose">
+ <hbox id="compositionMainCategory"
+ class="subcategory"
+ data-category="paneCompose">
+ <html:h1 data-l10n-id="composition-category-header"/>
+ </hbox>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <separator class="thin"/>
+ <hbox align="center">
+ <label data-l10n-id="forward-label" control="forwardMessageMode"/>
+ <hbox>
+ <menulist id="forwardMessageMode" preference="mail.forward_message_mode">
+ <menupopup>
+ <menuitem value="2" data-l10n-id="inline-label"/>
+ <menuitem value="0" data-l10n-id="as-attachment-label"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <separator orient="vertical" class="thin"/>
+ <checkbox id="addExtension" preference="mail.forward_add_extension"
+ data-l10n-id="extension-label"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center" pack="start">
+ <checkbox id="autoSave" preference="mail.compose.autosave"
+ data-l10n-id="auto-save-label"/>
+ <html:input id="autoSaveInterval" type="number" class="size2"
+ min="1" max="35790"
+ preference="mail.compose.autosaveinterval"
+ aria-labelledby="autoSave autoSaveInterval autoSaveEnd"/>
+ <label id="autoSaveEnd" data-l10n-id="auto-save-end"/>
+ </hbox>
+ <hbox>
+ <checkbox id="mailWarnOnSendAccelKey"
+ data-l10n-id="warn-on-send-accel-key"
+ preference="mail.warn_on_send_accel_key"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <checkbox id="addLinkPreviews"
+ data-l10n-id="add-link-previews"
+ preference="mail.compose.add_link_preview"/>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <html:legend data-l10n-id="composition-spelling-title"></html:legend>
+ <hbox>
+ <checkbox id="spellCheckBeforeSend"
+ data-l10n-id="spellcheck-label"
+ preference="mail.SpellCheckBeforeSend"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <checkbox id="inlineSpellCheck"
+ data-l10n-id="spellcheck-inline-label"
+ preference="mail.spellcheck.inline"/>
+ <spacer flex="1"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <vbox flex="1">
+ <label data-l10n-id="language-popup-label" control="dictionaryList"/>
+
+ <html:ul id="dictionaryList" class="indent">
+ <html:template id="dictionaryListItem">
+ <html:li>
+ <html:label>
+ <html:input type="checkbox" />
+ <html:span class="checkbox-label"></html:span>
+ </html:label>
+ </html:li>
+ </html:template>
+ </html:ul>
+
+ <label id="downloadDictionaries" class="text-link"
+ onclick="if (event.button == 0) { openDictionaryList('tab'); }"
+ data-l10n-id="download-dictionaries-link"/>
+
+ <spacer flex="1"/>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <html:legend data-l10n-id="compose-html-style-title"></html:legend>
+ <hbox>
+ <vbox flex="1">
+ <hbox align="center">
+ <label control="FontSelect" data-l10n-id="font-label"/>
+ <hbox flex="1">
+ <menulist id="FontSelect" preference="msgcompose.font_face"
+ sizetopopup="pref" crop="center" flex="1">
+ <menupopup>
+ <menuitem value="" label="&fontVarWidth.label;"/>
+ <menuitem value="monospace" label="&fontFixedWidth.label;"/>
+ <menuseparator/>
+ <menuitem value="Helvetica, Arial, sans-serif" label="&fontHelvetica.label;"/>
+ <menuitem value="Times New Roman, Times, serif" label="&fontTimes.label;"/>
+ <menuitem value="Courier New, Courier, monospace" label="&fontCourier.label;"/>
+ <menuseparator/>
+ </menupopup>
+ </menulist>
+ </hbox>
+
+ <label control="fontSizeSelect" data-l10n-id="font-size-label"/>
+ <hbox>
+ <menulist id="fontSizeSelect" preference="msgcompose.font_size" value="3">
+ <menupopup>
+ <menuitem value="1" label="&size-tinyCmd.label;"/>
+ <menuitem value="2" label="&size-smallCmd.label;"/>
+ <menuitem value="3" label="&size-mediumCmd.label;"/>
+ <menuitem value="4" label="&size-largeCmd.label;"/>
+ <menuitem value="5" label="&size-extraLargeCmd.label;"/>
+ <menuitem value="6" label="&size-hugeCmd.label;"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center">
+ <checkbox id="useReaderDefaults"
+ data-l10n-id="default-colors-label"
+ preference="msgcompose.default_colors"/>
+ </hbox>
+ <hbox align="center" class="indent">
+ <label id="textColorLabel"
+ control="textColorButton"
+ data-l10n-id="font-color-label"/>
+ <html:input type="color" id="textColorButton" preference="msgcompose.text_color"/>
+ <separator orient="vertical" class="thin"/>
+ <label id="backgroundColorLabel"
+ control="backgroundColorButton"
+ data-l10n-id="bg-color-label"/>
+ <html:input type="color" id="backgroundColorButton" preference="msgcompose.background_color"/>
+ </hbox>
+ </vbox>
+ <vbox>
+ <spacer flex="1"/>
+ <button is="highlightable-button"
+ data-l10n-id="restore-html-label"
+ oncommand="gComposePane.restoreHTMLDefaults();"/>
+ </vbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center">
+ <checkbox id="defaultToParagraph"
+ data-l10n-id="default-format-label"
+ preference="mail.compose.default_to_paragraph"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <html:legend data-l10n-id="compose-send-format-title"></html:legend>
+ <radiogroup class="indent"
+ preference="mail.default_send_format">
+ <radio value="0"
+ aria-describedby="composeSendAutomaticDescription"
+ data-l10n-id="compose-send-automatic-option" />
+ <description id="composeSendAutomaticDescription"
+ class="indent tip-caption"
+ data-l10n-id="compose-send-automatic-description" />
+ <radio value="3"
+ aria-describedby="composeSendBothDescription"
+ data-l10n-id="compose-send-both-option" />
+ <description id="composeSendBothDescription"
+ class="indent tip-caption"
+ data-l10n-id="compose-send-both-description" />
+ <radio value="2"
+ aria-describedby="composeSendHTMLDescription"
+ data-l10n-id="compose-send-html-option" />
+ <description id="composeSendHTMLDescription"
+ class="indent tip-caption"
+ data-l10n-id="compose-send-html-description" />
+ <radio value="1"
+ aria-describedby="composeSendPlainDescription"
+ data-l10n-id="compose-send-plain-option" />
+ <description id="composeSendPlainDescription"
+ class="indent tip-caption"
+ data-l10n-id="compose-send-plain-description" />
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="compositionAddressingCategory"
+ class="subcategory"
+ data-category="paneCompose">
+ <html:h1 data-l10n-id="composition-addressing-header"/>
+ </hbox>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <!-- Address Autocomplete -->
+ <separator class="thin"/>
+
+ <description data-l10n-id="autocomplete-description"/>
+
+ <hbox align="center">
+ <checkbox id="addressingAutocomplete" data-l10n-id="ab-label"
+ preference="mail.enable_autocomplete"/>
+ </hbox>
+
+ <hbox align="center">
+ <checkbox id="autocompleteLDAP" data-l10n-id="directories-label"
+ preference="ldap_2.autoComplete.useDirectory"/>
+ <hbox flex="1">
+ <menulist is="menulist-addrbooks" id="directoriesList"
+ aria-labelledby="autocompleteLDAP"
+ preference="ldap_2.autoComplete.directoryServer"
+ data-l10n-id="directories-none-label"
+ data-l10n-attrs="none"
+ remoteonly="true"
+ flex="1"/>
+ </hbox>
+ <button is="highlightable-button" id="editButton"
+ data-l10n-id="edit-directories-label"
+ oncommand="gComposePane.editDirectories();"
+ preference="pref.ldap.disable_button.edit_directories"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center" pack="start">
+ <checkbox id="emailCollectionOutgoing" data-l10n-id="email-picker-label"
+ preference="mail.collect_email_address_outgoing"/>
+ <hbox flex="1">
+ <menulist is="menulist-addrbooks" id="localDirectoriesList"
+ aria-labelledby="emailCollectionOutgoing"
+ preference="mail.collect_addressbook"
+ localonly="true"
+ writable="true"
+ flex="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox align="center" pack="start">
+ <label data-l10n-id="default-directory-label"
+ control="defaultStartupDirList"/>
+ <hbox flex="1">
+ <menulist is="menulist-addrbooks" id="defaultStartupDirList"
+ oncommand="gComposePane.setDefaultStartupDir(this.value);"
+ data-l10n-id="default-last-label"
+ data-l10n-attrs="none"
+ alladdressbooks="true"
+ mailinglists="true"
+ flex="1"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="compositionAttachmentsCategory"
+ class="subcategory"
+ data-category="paneCompose">
+ <html:h1 data-l10n-id="composition-attachments-header"/>
+ </hbox>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <hbox align="center">
+ <checkbox id="attachment_reminder_label"
+ data-l10n-id="attachment-label"
+ preference="mail.compose.attachment_reminder"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="attachment_reminder_button"
+ data-l10n-id="attachment-options-label"
+ oncommand="gComposePane.attachmentReminderOptionsDialog();"
+ search-l10n-ids="
+ attachment-reminder-window.title,
+ attachment-reminder-label,
+ keyword-new-button.label,
+ keyword-edit-button.label,
+ keyword-remove-button.label,
+ new-keyword-title,
+ new-keyword-label,
+ edit-keyword-title,
+ edit-keyword-label"/>
+ </hbox>
+ </hbox>
+ <vbox id="cloudFileBox">
+ <hbox id="cloudFileToggleAndThreshold" align="center">
+ <checkbox id="enableThreshold"
+ data-l10n-id="enable-cloud-share"
+ preference="mail.compose.big_attachments.notify"/>
+ <html:input id="cloudFileThreshold" type="number" min="0" class="size3"
+ preference="mail.compose.big_attachments.threshold_kb"/>
+ <label control="cloudFileThreshold" data-l10n-id="cloud-share-size"/>
+ </hbox>
+ <hbox style="height: 480px; flex: 1 auto;">
+ <vbox id="provider-listing">
+ <richlistbox id="cloudFileView" orient="vertical"
+ seltype="single"
+ onoverflow="gCloudFile.onListOverflow();"
+ onselect="gCloudFile.onSelectionChanged(event);">
+ </richlistbox>
+ <vbox id="addCloudFileAccountButtons">
+ </vbox>
+ <hbox>
+ <menulist id="addCloudFileAccount"
+ hidden="true"
+ data-l10n-id="add-cloud-account"
+ data-l10n-attrs="defaultlabel"
+ oncommand="gCloudFile.addCloudFileAccount(this.value);">
+ <menupopup id="addCloudFileAccountListItems"/>
+ </menulist>
+ </hbox>
+ <button is="highlightable-button" id="removeCloudFileAccount"
+ disabled="true"
+ data-l10n-id="remove-cloud-account"
+ oncommand="gCloudFile.removeCloudFileAccount();"/>
+ <label is="text-link"
+ id="moreProvidersLink"
+ href="https://addons.thunderbird.net/thunderbird/tag/filelink"
+ data-l10n-id="find-cloud-providers"/>
+ </vbox>
+ <separator class="thin" orient="vertical"/>
+ <vbox flex="1">
+ <vbox id="cloudFileDefaultPanel" flex="1">
+ <description data-l10n-id="cloud-account-description"/>
+ </vbox>
+ <vbox id="cloudFileSettingsWrapper" flex="1">
+ </vbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ </html:template>
diff --git a/comm/mail/components/preferences/compose.js b/comm/mail/components/preferences/compose.js
new file mode 100644
index 0000000000..7d9acf0622
--- /dev/null
+++ b/comm/mail/components/preferences/compose.js
@@ -0,0 +1,776 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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";
+
+/* import-globals-from preferences.js */
+
+var { InlineSpellChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+);
+
+// CloudFile account tools used by gCloudFile.
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+var { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+Preferences.addAll([
+ { id: "mail.forward_message_mode", type: "int" },
+ { id: "mail.forward_add_extension", type: "bool" },
+ { id: "mail.SpellCheckBeforeSend", type: "bool" },
+ { id: "mail.spellcheck.inline", type: "bool" },
+ { id: "mail.warn_on_send_accel_key", type: "bool" },
+ { id: "mail.compose.autosave", type: "bool" },
+ { id: "mail.compose.autosaveinterval", type: "int" },
+ { id: "mail.enable_autocomplete", type: "bool" },
+ { id: "ldap_2.autoComplete.useDirectory", type: "bool" },
+ { id: "ldap_2.autoComplete.directoryServer", type: "string" },
+ { id: "pref.ldap.disable_button.edit_directories", type: "bool" },
+ { id: "mail.collect_email_address_outgoing", type: "bool" },
+ { id: "mail.collect_addressbook", type: "string" },
+ { id: "spellchecker.dictionary", type: "unichar" },
+ { id: "msgcompose.default_colors", type: "bool" },
+ { id: "msgcompose.font_face", type: "string" },
+ { id: "msgcompose.font_size", type: "string" },
+ { id: "msgcompose.text_color", type: "string" },
+ { id: "msgcompose.background_color", type: "string" },
+ { id: "mail.compose.attachment_reminder", type: "bool" },
+ { id: "mail.compose.default_to_paragraph", type: "bool" },
+ { id: "mail.compose.big_attachments.notify", type: "bool" },
+ { id: "mail.compose.big_attachments.threshold_kb", type: "int" },
+ { id: "mail.default_send_format", type: "int" },
+ { id: "mail.compose.add_link_preview", type: "bool" },
+]);
+
+var gComposePane = {
+ mSpellChecker: null,
+
+ init() {
+ this.enableAutocomplete();
+
+ this.initLanguages();
+
+ this.populateFonts();
+
+ this.updateAutosave();
+
+ this.updateUseReaderDefaults();
+
+ this.updateAttachmentCheck();
+
+ this.updateEmailCollection();
+
+ this.initAbDefaultStartupDir();
+
+ this.setButtonColors();
+
+ // If BigFiles is disabled, hide the "Outgoing" tab, and the tab
+ // selectors, and bail out.
+ if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) {
+ // Hide the tab selector
+ let cloudFileBox = document.getElementById("cloudFileBox");
+ cloudFileBox.hidden = true;
+ return;
+ }
+
+ gCloudFile.init();
+ },
+
+ attachmentReminderOptionsDialog() {
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/attachmentReminder.xhtml",
+ { features: "resizable=no" }
+ );
+ },
+
+ updateAutosave() {
+ gComposePane.enableElement(
+ document.getElementById("autoSaveInterval"),
+ Preferences.get("mail.compose.autosave").value
+ );
+ },
+
+ updateUseReaderDefaults() {
+ let useReaderDefaultsChecked = Preferences.get(
+ "msgcompose.default_colors"
+ ).value;
+ gComposePane.enableElement(
+ document.getElementById("textColorLabel"),
+ !useReaderDefaultsChecked
+ );
+ gComposePane.enableElement(
+ document.getElementById("backgroundColorLabel"),
+ !useReaderDefaultsChecked
+ );
+ gComposePane.enableElement(
+ document.getElementById("textColorButton"),
+ !useReaderDefaultsChecked
+ );
+ gComposePane.enableElement(
+ document.getElementById("backgroundColorButton"),
+ !useReaderDefaultsChecked
+ );
+ },
+
+ updateAttachmentCheck() {
+ gComposePane.enableElement(
+ document.getElementById("attachment_reminder_button"),
+ Preferences.get("mail.compose.attachment_reminder").value
+ );
+ },
+
+ updateEmailCollection() {
+ gComposePane.enableElement(
+ document.getElementById("localDirectoriesList"),
+ Preferences.get("mail.collect_email_address_outgoing").value
+ );
+ },
+
+ enableElement(aElement, aEnable) {
+ let pref = aElement.getAttribute("preference");
+ let prefIsLocked = pref ? Preferences.get(pref).locked : false;
+ aElement.disabled = !aEnable || prefIsLocked;
+ },
+
+ enableAutocomplete() {
+ let acLDAPPref = Preferences.get("ldap_2.autoComplete.useDirectory").value;
+ gComposePane.enableElement(
+ document.getElementById("directoriesList"),
+ acLDAPPref
+ );
+ gComposePane.enableElement(
+ document.getElementById("editButton"),
+ acLDAPPref
+ );
+ },
+
+ editDirectories() {
+ gSubDialog.open(
+ "chrome://messenger/content/addressbook/pref-editdirectories.xhtml"
+ );
+ },
+
+ initAbDefaultStartupDir() {
+ if (!this.startupDirListener.inited) {
+ this.startupDirListener.load();
+ }
+
+ let dirList = document.getElementById("defaultStartupDirList");
+ if (Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ // Some directory is the default.
+ let startupURI = Services.prefs.getCharPref(
+ "mail.addr_book.view.startupURI"
+ );
+ dirList.value = startupURI;
+ } else {
+ // Choose item meaning there is no default startup directory any more.
+ dirList.value = "";
+ }
+ },
+
+ setButtonColors() {
+ document.getElementById("textColorButton").value = Preferences.get(
+ "msgcompose.text_color"
+ ).value;
+ document.getElementById("backgroundColorButton").value = Preferences.get(
+ "msgcompose.background_color"
+ ).value;
+ },
+
+ setDefaultStartupDir(aDirURI) {
+ if (aDirURI) {
+ // Some AB directory was selected. Set prefs to make this directory
+ // the default view when starting up the main AB.
+ Services.prefs.setCharPref("mail.addr_book.view.startupURI", aDirURI);
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ true
+ );
+ } else {
+ // Set pref that there's no default startup view directory any more.
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ false
+ );
+ }
+ },
+
+ async initLanguages() {
+ let languageList = document.getElementById("dictionaryList");
+ this.mSpellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+
+ // Get the list of dictionaries from the spellchecker.
+
+ let dictList = this.mSpellChecker.getDictionaryList();
+
+ // HACK: calling sortDictionaryList may fail the first time due to
+ // synchronous loading of the .ftl files. If we load the files and wait
+ // for a known value asynchronously, no such failure will happen.
+ await new Localization([
+ "toolkit/intl/languageNames.ftl",
+ "toolkit/intl/regionNames.ftl",
+ ]).formatValue("language-name-en");
+ let sortedList = new InlineSpellChecker().sortDictionaryList(dictList);
+ let activeDictionaries = Services.prefs
+ .getCharPref("spellchecker.dictionary")
+ .split(",");
+ let template = document.getElementById("dictionaryListItem");
+ languageList.replaceChildren(
+ ...sortedList.map(({ displayName, localeCode }) => {
+ let item = template.content.cloneNode(true).firstElementChild;
+ item.querySelector(".checkbox-label").textContent = displayName;
+ let input = item.querySelector("input");
+ input.setAttribute("value", localeCode);
+ input.addEventListener("change", event => {
+ let language = event.target.value;
+ let dicts = Services.prefs
+ .getCharPref("spellchecker.dictionary")
+ .split(",")
+ .filter(Boolean);
+ if (!event.target.checked) {
+ dicts = dicts.filter(item => item != language);
+ } else {
+ dicts.push(language);
+ }
+ Services.prefs.setCharPref(
+ "spellchecker.dictionary",
+ dicts.join(",")
+ );
+ });
+ input.checked = activeDictionaries.includes(localeCode);
+ return item;
+ })
+ );
+ },
+
+ populateFonts() {
+ var fontsList = document.getElementById("FontSelect");
+ try {
+ var enumerator = Cc["@mozilla.org/gfx/fontenumerator;1"].getService(
+ Ci.nsIFontEnumerator
+ );
+ var localFonts = enumerator.EnumerateAllFonts();
+ for (let i = 0; i < localFonts.length; ++i) {
+ // Remove Linux system generic fonts that collide with CSS generic fonts.
+ if (
+ localFonts[i] != "" &&
+ localFonts[i] != "serif" &&
+ localFonts[i] != "sans-serif" &&
+ localFonts[i] != "monospace"
+ ) {
+ fontsList.appendItem(localFonts[i], localFonts[i]);
+ }
+ }
+ } catch (e) {}
+ // Choose the item after the list is completely generated.
+ var preference = Preferences.get(fontsList.getAttribute("preference"));
+ fontsList.value = preference.value;
+ },
+
+ restoreHTMLDefaults() {
+ // reset throws an exception if the pref value is already the default so
+ // work around that with some try/catch exception handling
+ try {
+ Preferences.get("msgcompose.font_face").reset();
+ } catch (ex) {}
+
+ try {
+ Preferences.get("msgcompose.font_size").reset();
+ } catch (ex) {}
+
+ try {
+ Preferences.get("msgcompose.text_color").reset();
+ } catch (ex) {}
+
+ try {
+ Preferences.get("msgcompose.background_color").reset();
+ } catch (ex) {}
+
+ try {
+ Preferences.get("msgcompose.default_colors").reset();
+ } catch (ex) {}
+
+ this.updateUseReaderDefaults();
+ this.setButtonColors();
+ },
+
+ startupDirListener: {
+ inited: false,
+ domain: "mail.addr_book.view.startupURI",
+ observe(subject, topic, prefName) {
+ if (topic != "nsPref:changed") {
+ return;
+ }
+
+ // If the default startup directory prefs have changed,
+ // reinitialize the default startup dir picker to show the new value.
+ gComposePane.initAbDefaultStartupDir();
+ },
+ load() {
+ // Observe changes of our prefs.
+ Services.prefs.addObserver(this.domain, this);
+ // Unload the pref observer when preferences window is closed.
+ window.addEventListener("unload", () => this.unload(), true);
+ this.inited = true;
+ },
+
+ unload(event) {
+ Services.prefs.removeObserver(
+ gComposePane.startupDirListener.domain,
+ gComposePane.startupDirListener
+ );
+ },
+ },
+};
+
+var gCloudFile = {
+ _initialized: false,
+ _list: null,
+ _buttonContainer: null,
+ _listContainer: null,
+ _settings: null,
+ _tabpanel: null,
+ _settingsPanelWrap: null,
+ _defaultPanel: null,
+
+ get _strings() {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/preferences/applications.properties"
+ );
+ },
+
+ init() {
+ this._list = document.getElementById("cloudFileView");
+ this._buttonContainer = document.getElementById(
+ "addCloudFileAccountButtons"
+ );
+ this._addAccountButton = document.getElementById("addCloudFileAccount");
+ this._listContainer = document.getElementById(
+ "addCloudFileAccountListItems"
+ );
+ this._removeAccountButton = document.getElementById(
+ "removeCloudFileAccount"
+ );
+ this._defaultPanel = document.getElementById("cloudFileDefaultPanel");
+ this._settingsPanelWrap = document.getElementById(
+ "cloudFileSettingsWrapper"
+ );
+
+ this.updateThreshold();
+ this.rebuildView();
+
+ window.addEventListener("unload", this, { capture: false, once: true });
+
+ this._onAccountConfigured = this._onAccountConfigured.bind(this);
+ this._onProviderRegistered = this._onProviderRegistered.bind(this);
+ this._onProviderUnregistered = this._onProviderUnregistered.bind(this);
+ cloudFileAccounts.on("accountConfigured", this._onAccountConfigured);
+ cloudFileAccounts.on("providerRegistered", this._onProviderRegistered);
+ cloudFileAccounts.on("providerUnregistered", this._onProviderUnregistered);
+
+ let element = document.getElementById("cloudFileThreshold");
+ Preferences.addSyncFromPrefListener(element, () => this.readThreshold());
+ Preferences.addSyncToPrefListener(element, () => this.writeThreshold());
+
+ this._initialized = true;
+ },
+
+ destroy() {
+ // Remove any controllers or observers here.
+ cloudFileAccounts.off("accountConfigured", this._onAccountConfigured);
+ cloudFileAccounts.off("providerRegistered", this._onProviderRegistered);
+ cloudFileAccounts.off("providerUnregistered", this._onProviderUnregistered);
+ },
+
+ _onAccountConfigured(event, account) {
+ for (let item of this._list.children) {
+ if (item.value == account.accountKey) {
+ item.querySelector(".configuredWarning").hidden = account.configured;
+ }
+ }
+ },
+
+ _onProviderRegistered(event, provider) {
+ let accounts = cloudFileAccounts.getAccountsForType(provider.type);
+ accounts.sort(this._sortDisplayNames);
+
+ // Always add newly-enabled accounts to the end of the list, this makes
+ // it clearer to users what's happening.
+ for (let account of accounts) {
+ let item = this.makeRichListItemForAccount(account);
+ this._list.appendChild(item);
+ }
+
+ this._buttonContainer.appendChild(this.makeButtonForProvider(provider));
+ this._listContainer.appendChild(this.makeListItemForProvider(provider));
+ },
+
+ _onProviderUnregistered(event, type) {
+ for (let item of [...this._list.children]) {
+ // If the provider is unregistered, getAccount returns null.
+ if (!cloudFileAccounts.getAccount(item.value)) {
+ if (item.hasAttribute("selected")) {
+ this._defaultPanel.hidden = false;
+ this._settingsPanelWrap.hidden = true;
+ if (this._settings) {
+ this._settings.remove();
+ }
+ this._removeAccountButton.disabled = true;
+ }
+ item.remove();
+ }
+ }
+
+ for (let button of this._buttonContainer.children) {
+ if (button.getAttribute("value") == type) {
+ button.remove();
+ }
+ }
+
+ for (let item of this._listContainer.children) {
+ if (item.getAttribute("value") == type) {
+ item.remove();
+ }
+ }
+
+ if (this._buttonContainer.childElementCount < 1) {
+ this._buttonContainer.hidden = false;
+ this._addAccountButton.hidden = true;
+ }
+ },
+
+ makeRichListItemForAccount(aAccount) {
+ let rli = document.createXULElement("richlistitem");
+ rli.setAttribute("align", "center");
+ rli.classList.add("cloudfileAccount", "input-container");
+ rli.setAttribute("value", aAccount.accountKey);
+
+ let icon = document.createElement("img");
+ icon.classList.add("typeIcon");
+ if (aAccount.iconURL) {
+ icon.setAttribute("src", aAccount.iconURL);
+ }
+ icon.setAttribute("alt", "");
+ rli.appendChild(icon);
+
+ let label = document.createXULElement("label");
+ label.setAttribute("crop", "end");
+ label.setAttribute("flex", "1");
+ label.setAttribute(
+ "value",
+ cloudFileAccounts.getDisplayName(aAccount.accountKey)
+ );
+ label.addEventListener("click", this, true);
+ rli.appendChild(label);
+
+ let input = document.createElement("input");
+ input.setAttribute("type", "text");
+ input.setAttribute("hidden", "hidden");
+ input.addEventListener("blur", this);
+ input.addEventListener("keypress", this);
+ rli.appendChild(input);
+
+ let warningIcon = document.createElement("img");
+ warningIcon.setAttribute("class", "configuredWarning typeIcon");
+ warningIcon.setAttribute("src", "chrome://global/skin/icons/warning.svg");
+ // "title" provides the accessible name, not "alt".
+ warningIcon.setAttribute(
+ "title",
+ this._strings.GetStringFromName("notConfiguredYet")
+ );
+ if (aAccount.configured) {
+ warningIcon.hidden = true;
+ }
+ rli.appendChild(warningIcon);
+
+ return rli;
+ },
+
+ makeButtonForProvider(provider) {
+ let button = document.createXULElement("button");
+ button.setAttribute("value", provider.type);
+ button.setAttribute(
+ "label",
+ this._strings.formatStringFromName("addProvider", [provider.displayName])
+ );
+ button.setAttribute(
+ "oncommand",
+ `gCloudFile.addCloudFileAccount("${provider.type}")`
+ );
+ button.style.listStyleImage = `url("${provider.iconURL}")`;
+ return button;
+ },
+
+ makeListItemForProvider(provider) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.classList.add("menuitem-iconic");
+ menuitem.setAttribute("value", provider.type);
+ menuitem.setAttribute("label", provider.displayName);
+ menuitem.setAttribute("image", provider.iconURL);
+ return menuitem;
+ },
+
+ // Sort the accounts by displayName.
+ _sortDisplayNames(a, b) {
+ let aName = a.displayName.toLowerCase();
+ let bName = b.displayName.toLowerCase();
+ return aName.localeCompare(bName);
+ },
+
+ rebuildView() {
+ // Clear the list of entries.
+ while (this._list.hasChildNodes()) {
+ this._list.lastChild.remove();
+ }
+
+ let accounts = cloudFileAccounts.accounts;
+ accounts.sort(this._sortDisplayNames);
+
+ for (let account of accounts) {
+ let rli = this.makeRichListItemForAccount(account);
+ this._list.appendChild(rli);
+ }
+
+ while (this._buttonContainer.hasChildNodes()) {
+ this._buttonContainer.lastChild.remove();
+ }
+
+ let providers = cloudFileAccounts.providers;
+ providers.sort(this._sortDisplayNames);
+ for (let provider of providers) {
+ this._buttonContainer.appendChild(this.makeButtonForProvider(provider));
+ this._listContainer.appendChild(this.makeListItemForProvider(provider));
+ }
+ },
+
+ onSelectionChanged(aEvent) {
+ if (!this._initialized || aEvent.target != this._list) {
+ return;
+ }
+
+ // Get the selected item
+ let selection = this._list.selectedItem;
+ this._removeAccountButton.disabled = !selection;
+ if (!selection) {
+ this._defaultPanel.hidden = false;
+ this._settingsPanelWrap.hidden = true;
+ if (this._settings) {
+ this._settings.remove();
+ }
+ return;
+ }
+
+ this._showAccountInfo(selection.value);
+ },
+
+ _showAccountInfo(aAccountKey) {
+ let account = cloudFileAccounts.getAccount(aAccountKey);
+ this._defaultPanel.hidden = true;
+ this._settingsPanelWrap.hidden = false;
+
+ let url = account.managementURL + `?accountId=${account.accountKey}`;
+
+ let browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
+ browser.setAttribute("forcemessagemanager", "true");
+ if (account.extension) {
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ account.extension.policy.browsingContextGroupId
+ );
+ }
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ browser.setAttribute("selectmenulist", "ContentSelectDropdown");
+
+ browser.setAttribute("flex", "1");
+ // Allows keeping dialog background color without hoops.
+ browser.setAttribute("transparent", "true");
+
+ // If we have a past browser, we replace it. Else append to the wrapper.
+ if (this._settings) {
+ this._settings.remove();
+ }
+
+ this._settingsPanelWrap.appendChild(browser);
+ this._settings = browser;
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+ browser.messageManager.loadFrameScript(
+ "chrome://extensions/content/ext-browser-content.js",
+ false,
+ true
+ );
+
+ let options = account.browserStyle
+ ? { stylesheets: ExtensionParent.extensionStylesheets }
+ : {};
+ browser.messageManager.sendAsyncMessage("Extension:InitBrowser", options);
+
+ browser.fixupAndLoadURIString(url, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ onListOverflow() {
+ if (this._buttonContainer.childElementCount > 1) {
+ this._buttonContainer.hidden = true;
+ this._addAccountButton.hidden = false;
+ }
+ },
+
+ addCloudFileAccount(aType) {
+ let account = cloudFileAccounts.createAccount(aType);
+ if (!account) {
+ return;
+ }
+
+ let rli = this.makeRichListItemForAccount(account);
+ this._list.appendChild(rli);
+ this._list.selectItem(rli);
+ this._addAccountButton.removeAttribute("image");
+ this._addAccountButton.setAttribute(
+ "label",
+ this._addAccountButton.getAttribute("defaultlabel")
+ );
+ this._removeAccountButton.disabled = false;
+ },
+
+ removeCloudFileAccount() {
+ // Get the selected account key
+ let selection = this._list.selectedItem;
+ if (!selection) {
+ return;
+ }
+
+ let accountKey = selection.value;
+ let accountName = cloudFileAccounts.getDisplayName(accountKey);
+ // Does the user really want to remove this account?
+ let confirmMessage = this._strings.formatStringFromName(
+ "dialog_removeAccount",
+ [accountName]
+ );
+
+ if (Services.prompt.confirm(null, "", confirmMessage)) {
+ this._list.clearSelection();
+ cloudFileAccounts.removeAccount(accountKey);
+ let rli = this._list.querySelector(
+ "richlistitem[value='" + accountKey + "']"
+ );
+ rli.remove();
+ this._defaultPanel.hidden = false;
+ this._settingsPanelWrap.hidden = true;
+ if (this._settings) {
+ this._settings.remove();
+ }
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "unload":
+ this.destroy();
+ break;
+ case "click": {
+ let label = aEvent.target;
+ let item = label.parentNode;
+ let input = item.querySelector("input");
+ if (!item.selected) {
+ return;
+ }
+ label.hidden = true;
+ input.value = label.value;
+ input.removeAttribute("hidden");
+ input.focus();
+ break;
+ }
+ case "blur": {
+ let input = aEvent.target;
+ let item = input.parentNode;
+ let label = item.querySelector("label");
+ cloudFileAccounts.setDisplayName(item.value, input.value);
+ label.value = input.value;
+ label.hidden = false;
+ input.setAttribute("hidden", "hidden");
+ break;
+ }
+ case "keypress": {
+ let input = aEvent.target;
+ let item = input.parentNode;
+ let label = item.querySelector("label");
+
+ if (aEvent.key == "Enter") {
+ cloudFileAccounts.setDisplayName(item.value, input.value);
+ label.value = input.value;
+ label.hidden = false;
+ input.setAttribute("hidden", "hidden");
+ gCloudFile._list.focus();
+
+ aEvent.preventDefault();
+ } else if (aEvent.key == "Escape") {
+ input.value = label.value;
+ label.hidden = false;
+ input.setAttribute("hidden", "hidden");
+ gCloudFile._list.focus();
+
+ aEvent.preventDefault();
+ }
+ }
+ }
+ },
+
+ readThreshold() {
+ let pref = Preferences.get("mail.compose.big_attachments.threshold_kb");
+ return pref.value / 1024;
+ },
+
+ writeThreshold() {
+ let threshold = document.getElementById("cloudFileThreshold");
+ let intValue = parseInt(threshold.value, 10);
+ return isNaN(intValue) ? 0 : intValue * 1024;
+ },
+
+ updateThreshold() {
+ document.getElementById("cloudFileThreshold").disabled = !Preferences.get(
+ "mail.compose.big_attachments.notify"
+ ).value;
+ },
+};
+
+Preferences.get("mail.compose.autosave").on(
+ "change",
+ gComposePane.updateAutosave
+);
+Preferences.get("mail.compose.attachment_reminder").on(
+ "change",
+ gComposePane.updateAttachmentCheck
+);
+Preferences.get("msgcompose.default_colors").on(
+ "change",
+ gComposePane.updateUseReaderDefaults
+);
+Preferences.get("ldap_2.autoComplete.useDirectory").on(
+ "change",
+ gComposePane.enableAutocomplete
+);
+Preferences.get("mail.collect_email_address_outgoing").on(
+ "change",
+ gComposePane.updateEmailCollection
+);
+Preferences.get("mail.compose.big_attachments.notify").on(
+ "change",
+ gCloudFile.updateThreshold
+);
diff --git a/comm/mail/components/preferences/connection.js b/comm/mail/components/preferences/connection.js
new file mode 100644
index 0000000000..686c2950cf
--- /dev/null
+++ b/comm/mail/components/preferences/connection.js
@@ -0,0 +1,597 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+/* import-globals-from ./extensionControlled.js */
+
+Preferences.addAll([
+ // Add network.proxy.autoconfig_url before network.proxy.type so they're
+ // both initialized when network.proxy.type initialization triggers a call to
+ // gConnectionsDialog.updateReloadButton().
+ { id: "network.proxy.autoconfig_url", type: "string" },
+ { id: "network.proxy.type", type: "int" },
+ { id: "network.proxy.http", type: "string" },
+ { id: "network.proxy.http_port", type: "int" },
+ { id: "network.proxy.ssl", type: "string" },
+ { id: "network.proxy.ssl_port", type: "int" },
+ { id: "network.proxy.socks", type: "string" },
+ { id: "network.proxy.socks_port", type: "int" },
+ { id: "network.proxy.socks_version", type: "int" },
+ { id: "network.proxy.socks_remote_dns", type: "bool" },
+ { id: "network.proxy.no_proxies_on", type: "string" },
+ { id: "network.proxy.share_proxy_settings", type: "bool" },
+ { id: "signon.autologin.proxy", type: "bool" },
+ { id: "pref.advanced.proxies.disable_button.reload", type: "bool" },
+ { id: "network.proxy.backup.ssl", type: "string" },
+ { id: "network.proxy.backup.ssl_port", type: "int" },
+ { id: "network.trr.mode", type: "int" },
+ { id: "network.trr.uri", type: "string" },
+ { id: "network.trr.resolvers", type: "string" },
+ { id: "network.trr.custom_uri", type: "string" },
+]);
+
+window.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ Preferences.get("network.proxy.type").on(
+ "change",
+ gConnectionsDialog.proxyTypeChanged.bind(gConnectionsDialog)
+ );
+ Preferences.get("network.proxy.socks_version").on(
+ "change",
+ gConnectionsDialog.updateDNSPref.bind(gConnectionsDialog)
+ );
+
+ Preferences.get("network.trr.uri").on("change", () => {
+ gConnectionsDialog.updateDnsOverHttpsUI();
+ });
+
+ Preferences.get("network.trr.resolvers").on("change", () => {
+ gConnectionsDialog.initDnsOverHttpsUI();
+ });
+
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxyType"),
+ () => gConnectionsDialog.readProxyType()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxyHTTP"),
+ () => gConnectionsDialog.readHTTPProxyServer()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxyHTTP_Port"),
+ () => gConnectionsDialog.readHTTPProxyPort()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("shareAllProxies"),
+ () => gConnectionsDialog.updateProtocolPrefs()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySSL"),
+ () => gConnectionsDialog.readProxyProtocolPref("ssl", false)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySSL_Port"),
+ () => gConnectionsDialog.readProxyProtocolPref("ssl", true)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySOCKS"),
+ () => gConnectionsDialog.readProxyProtocolPref("socks", false)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySOCKS_Port"),
+ () => gConnectionsDialog.readProxyProtocolPref("socks", true)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySOCKSVersion"),
+ () => gConnectionsDialog.updateDNSPref()
+ );
+
+ // XXX: We can't init the DNS-over-HTTPs UI until the syncfrompref for network.trr.mode
+ // has been called. The uiReady promise will be resolved after the first call to
+ // readDnsOverHttpsMode and the subsequent call to initDnsOverHttpsUI has happened.
+ gConnectionsDialog.uiReady = new Promise(resolve => {
+ gConnectionsDialog._areTrrPrefsReady = false;
+ gConnectionsDialog._handleTrrPrefsReady = resolve;
+ }).then(() => {
+ gConnectionsDialog.initDnsOverHttpsUI();
+ });
+
+ let element = document.getElementById("networkDnsOverHttps");
+ Preferences.addSyncFromPrefListener(element, () =>
+ gConnectionsDialog.readDnsOverHttpsMode()
+ );
+ Preferences.addSyncToPrefListener(element, () =>
+ gConnectionsDialog.writeDnsOverHttpsMode()
+ );
+ document.documentElement.addEventListener("beforeaccept", e =>
+ gConnectionsDialog.beforeAccept(e)
+ );
+
+ document
+ .getElementById("proxyExtensionDisable")
+ .addEventListener("click", disableControllingProxyExtension);
+ gConnectionsDialog.updateProxySettingsUI();
+ initializeProxyUI(gConnectionsDialog);
+ },
+ { once: true, capture: true }
+);
+
+var gConnectionsDialog = {
+ beforeAccept(event) {
+ let dnsOverHttpsResolverChoice = document.getElementById(
+ "networkDnsOverHttpsResolverChoices"
+ ).value;
+ if (dnsOverHttpsResolverChoice == "custom") {
+ let customValue = document
+ .getElementById("networkCustomDnsOverHttpsInput")
+ .value.trim();
+ if (customValue) {
+ Services.prefs.setStringPref("network.trr.uri", customValue);
+ } else {
+ Services.prefs.clearUserPref("network.trr.uri");
+ }
+ } else {
+ Services.prefs.setStringPref(
+ "network.trr.uri",
+ dnsOverHttpsResolverChoice
+ );
+ }
+
+ var proxyTypePref = Preferences.get("network.proxy.type");
+ if (proxyTypePref.value == 2) {
+ this.doAutoconfigURLFixup();
+ return;
+ }
+
+ if (proxyTypePref.value != 1) {
+ return;
+ }
+
+ var httpProxyURLPref = Preferences.get("network.proxy.http");
+ var httpProxyPortPref = Preferences.get("network.proxy.http_port");
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+
+ // If the port is 0 and the proxy server is specified, focus on the port and cancel submission.
+ for (let prefName of ["http", "ssl", "socks"]) {
+ let proxyPortPref = Preferences.get(
+ "network.proxy." + prefName + "_port"
+ );
+ let proxyPref = Preferences.get("network.proxy." + prefName);
+ // Only worry about ports which are currently active. If the share option is on, then ignore
+ // all ports except the HTTP and SOCKS port
+ if (
+ proxyPref.value != "" &&
+ proxyPortPref.value == 0 &&
+ (prefName == "http" || prefName == "socks" || !shareProxiesPref.value)
+ ) {
+ document
+ .getElementById("networkProxy" + prefName.toUpperCase() + "_Port")
+ .focus();
+ event.preventDefault();
+ return;
+ }
+ }
+
+ // In the case of a shared proxy preference, backup the current values and update with the HTTP value
+ if (shareProxiesPref.value) {
+ var proxyServerURLPref = Preferences.get("network.proxy.ssl");
+ var proxyPortPref = Preferences.get("network.proxy.ssl_port");
+ var backupServerURLPref = Preferences.get("network.proxy.backup.ssl");
+ var backupPortPref = Preferences.get("network.proxy.backup.ssl_port");
+ backupServerURLPref.value =
+ backupServerURLPref.value || proxyServerURLPref.value;
+ backupPortPref.value = backupPortPref.value || proxyPortPref.value;
+ proxyServerURLPref.value = httpProxyURLPref.value;
+ proxyPortPref.value = httpProxyPortPref.value;
+ }
+
+ this.sanitizeNoProxiesPref();
+ },
+
+ checkForSystemProxy() {
+ if ("@mozilla.org/system-proxy-settings;1" in Cc) {
+ document.getElementById("systemPref").removeAttribute("hidden");
+ }
+ },
+
+ proxyTypeChanged() {
+ var proxyTypePref = Preferences.get("network.proxy.type");
+
+ // Update http
+ var httpProxyURLPref = Preferences.get("network.proxy.http");
+ httpProxyURLPref.updateControlDisabledState(proxyTypePref.value != 1);
+ var httpProxyPortPref = Preferences.get("network.proxy.http_port");
+ httpProxyPortPref.updateControlDisabledState(proxyTypePref.value != 1);
+
+ // Now update the other protocols
+ this.updateProtocolPrefs();
+
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ shareProxiesPref.updateControlDisabledState(proxyTypePref.value != 1);
+ var autologinProxyPref = Preferences.get("signon.autologin.proxy");
+ autologinProxyPref.updateControlDisabledState(proxyTypePref.value == 0);
+ var noProxiesPref = Preferences.get("network.proxy.no_proxies_on");
+ noProxiesPref.updateControlDisabledState(proxyTypePref.value == 0);
+
+ var autoconfigURLPref = Preferences.get("network.proxy.autoconfig_url");
+ autoconfigURLPref.updateControlDisabledState(proxyTypePref.value != 2);
+
+ this.updateReloadButton();
+
+ document.getElementById("networkProxyNoneLocalhost").hidden =
+ Services.prefs.getBoolPref(
+ "network.proxy.allow_hijacking_localhost",
+ false
+ );
+ },
+
+ updateDNSPref() {
+ var socksVersionPref = Preferences.get("network.proxy.socks_version");
+ var socksDNSPref = Preferences.get("network.proxy.socks_remote_dns");
+ var proxyTypePref = Preferences.get("network.proxy.type");
+ var isDefinitelySocks4 =
+ proxyTypePref.value == 1 && socksVersionPref.value == 4;
+ socksDNSPref.updateControlDisabledState(
+ isDefinitelySocks4 || proxyTypePref.value == 0
+ );
+ return undefined;
+ },
+
+ updateReloadButton() {
+ // Disable the "Reload PAC" button if the selected proxy type is not PAC or
+ // if the current value of the PAC textbox does not match the value stored
+ // in prefs. Likewise, disable the reload button if PAC is not configured
+ // in prefs.
+
+ var typedURL = document.getElementById("networkProxyAutoconfigURL").value;
+ var proxyTypeCur = Preferences.get("network.proxy.type").value;
+
+ var pacURL = Services.prefs.getCharPref("network.proxy.autoconfig_url");
+ var proxyType = Services.prefs.getIntPref("network.proxy.type");
+
+ var disableReloadPref = Preferences.get(
+ "pref.advanced.proxies.disable_button.reload"
+ );
+ disableReloadPref.updateControlDisabledState(
+ proxyTypeCur != 2 || proxyType != 2 || typedURL != pacURL
+ );
+ },
+
+ readProxyType() {
+ this.proxyTypeChanged();
+ return undefined;
+ },
+
+ updateProtocolPrefs() {
+ var proxyTypePref = Preferences.get("network.proxy.type");
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ var proxyPrefs = ["ssl", "socks"];
+ for (var i = 0; i < proxyPrefs.length; ++i) {
+ var proxyServerURLPref = Preferences.get(
+ "network.proxy." + proxyPrefs[i]
+ );
+ var proxyPortPref = Preferences.get(
+ "network.proxy." + proxyPrefs[i] + "_port"
+ );
+
+ // Restore previous per-proxy custom settings, if present.
+ if (proxyPrefs[i] != "socks" && !shareProxiesPref.value) {
+ var backupServerURLPref = Preferences.get(
+ "network.proxy.backup." + proxyPrefs[i]
+ );
+ var backupPortPref = Preferences.get(
+ "network.proxy.backup." + proxyPrefs[i] + "_port"
+ );
+ if (backupServerURLPref.hasUserValue) {
+ proxyServerURLPref.value = backupServerURLPref.value;
+ backupServerURLPref.reset();
+ }
+ if (backupPortPref.hasUserValue) {
+ proxyPortPref.value = backupPortPref.value;
+ backupPortPref.reset();
+ }
+ }
+
+ proxyServerURLPref.updateElements();
+ proxyPortPref.updateElements();
+ let prefIsShared = proxyPrefs[i] != "socks" && shareProxiesPref.value;
+ proxyServerURLPref.updateControlDisabledState(
+ proxyTypePref.value != 1 || prefIsShared
+ );
+ proxyPortPref.updateControlDisabledState(
+ proxyTypePref.value != 1 || prefIsShared
+ );
+ }
+ var socksVersionPref = Preferences.get("network.proxy.socks_version");
+ socksVersionPref.updateControlDisabledState(proxyTypePref.value != 1);
+ this.updateDNSPref();
+ return undefined;
+ },
+
+ readProxyProtocolPref(aProtocol, aIsPort) {
+ if (aProtocol != "socks") {
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ if (shareProxiesPref.value) {
+ var pref = Preferences.get(
+ "network.proxy.http" + (aIsPort ? "_port" : "")
+ );
+ return pref.value;
+ }
+
+ var backupPref = Preferences.get(
+ "network.proxy.backup." + aProtocol + (aIsPort ? "_port" : "")
+ );
+ return backupPref.hasUserValue ? backupPref.value : undefined;
+ }
+ return undefined;
+ },
+
+ reloadPAC() {
+ Cc["@mozilla.org/network/protocol-proxy-service;1"]
+ .getService()
+ .reloadPAC();
+ },
+
+ doAutoconfigURLFixup() {
+ var autoURL = document.getElementById("networkProxyAutoconfigURL");
+ var autoURLPref = Preferences.get("network.proxy.autoconfig_url");
+ try {
+ autoURLPref.value = autoURL.value = Services.uriFixup.getFixupURIInfo(
+ autoURL.value,
+ 0
+ ).preferredURI.spec;
+ } catch (ex) {}
+ },
+
+ sanitizeNoProxiesPref() {
+ var noProxiesPref = Preferences.get("network.proxy.no_proxies_on");
+ // replace substrings of ; and \n with commas if they're neither immediately
+ // preceded nor followed by a valid separator character
+ noProxiesPref.value = noProxiesPref.value.replace(
+ /([^, \n;])[;\n]+(?![,\n;])/g,
+ "$1,"
+ );
+ // replace any remaining ; and \n since some may follow commas, etc.
+ noProxiesPref.value = noProxiesPref.value.replace(/[;\n]/g, "");
+ },
+
+ readHTTPProxyServer() {
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ if (shareProxiesPref.value) {
+ this.updateProtocolPrefs();
+ }
+ return undefined;
+ },
+
+ readHTTPProxyPort() {
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ if (shareProxiesPref.value) {
+ this.updateProtocolPrefs();
+ }
+ return undefined;
+ },
+
+ getProxyControls() {
+ let controlGroup = document.getElementById("networkProxyType");
+ return [
+ ...controlGroup.querySelectorAll(":scope > radio"),
+ ...controlGroup.querySelectorAll("label"),
+ ...controlGroup.querySelectorAll("input"),
+ ...controlGroup.querySelectorAll("checkbox"),
+ ...document.querySelectorAll("#networkProxySOCKSVersion > radio"),
+ ...document.querySelectorAll("#ConnectionsDialogPane > checkbox"),
+ ];
+ },
+
+ // Update the UI to show/hide the extension controlled message for
+ // proxy settings.
+ async updateProxySettingsUI() {
+ let isLocked = API_PROXY_PREFS.some(pref =>
+ Services.prefs.prefIsLocked(pref)
+ );
+
+ function setInputsDisabledState(isControlled) {
+ for (let element of gConnectionsDialog.getProxyControls()) {
+ element.disabled = isControlled;
+ }
+ gConnectionsDialog.proxyTypeChanged();
+ }
+
+ if (isLocked) {
+ // An extension can't control this setting if any pref is locked.
+ hideControllingProxyExtension();
+ } else {
+ handleControllingProxyExtension().then(setInputsDisabledState);
+ }
+ },
+
+ get dnsOverHttpsResolvers() {
+ let rawValue = Preferences.get("network.trr.resolvers", "").value;
+ // if there's no default, we'll hold its position with an empty string
+ let defaultURI = Preferences.get("network.trr.uri", "").defaultValue;
+ let providers = [];
+ if (rawValue) {
+ try {
+ providers = JSON.parse(rawValue);
+ } catch (ex) {
+ console.error(
+ `Bad JSON data in pref network.trr.resolvers: ${rawValue}`
+ );
+ }
+ }
+ if (!Array.isArray(providers)) {
+ console.error(
+ `Expected a JSON array in network.trr.resolvers: ${rawValue}`
+ );
+ providers = [];
+ }
+ let defaultIndex = providers.findIndex(p => p.url == defaultURI);
+ if (defaultIndex == -1 && defaultURI) {
+ // the default value for the pref isn't included in the resolvers list
+ // so we'll make a stub for it. Without an id, we'll have to use the url as the label
+ providers.unshift({ url: defaultURI });
+ }
+ return providers;
+ },
+
+ isDnsOverHttpsLocked() {
+ return Services.prefs.prefIsLocked("network.trr.mode");
+ },
+
+ isDnsOverHttpsEnabled() {
+ // values outside 1:4 are considered falsey/disabled in this context
+ let trrPref = Preferences.get("network.trr.mode");
+ let enabled = trrPref.value > 0 && trrPref.value < 5;
+ return enabled;
+ },
+
+ readDnsOverHttpsMode() {
+ // called to update checked element property to reflect current pref value
+ let enabled = this.isDnsOverHttpsEnabled();
+ let uriPref = Preferences.get("network.trr.uri");
+ uriPref.updateControlDisabledState(!enabled || this.isDnsOverHttpsLocked());
+ // this is the first signal we get when the prefs are available, so
+ // lazy-init if appropriate
+ if (!this._areTrrPrefsReady) {
+ this._areTrrPrefsReady = true;
+ this._handleTrrPrefsReady();
+ } else {
+ this.updateDnsOverHttpsUI();
+ }
+ return enabled;
+ },
+
+ writeDnsOverHttpsMode() {
+ // called to update pref with user change
+ let trrModeCheckbox = document.getElementById("networkDnsOverHttps");
+ // we treat checked/enabled as mode 2
+ return trrModeCheckbox.checked ? 2 : 0;
+ },
+
+ updateDnsOverHttpsUI() {
+ // init and update of the UI must wait until the pref values are ready
+ if (!this._areTrrPrefsReady) {
+ return;
+ }
+ let [menu, customInput] = this.getDnsOverHttpsControls();
+ let customContainer = document.getElementById(
+ "customDnsOverHttpsContainer"
+ );
+ let customURI = Preferences.get("network.trr.custom_uri").value;
+ let currentURI = Preferences.get("network.trr.uri").value;
+ let resolvers = this.dnsOverHttpsResolvers;
+ let isCustom = menu.value == "custom";
+
+ if (this.isDnsOverHttpsEnabled()) {
+ this.toggleDnsOverHttpsUI(false);
+ if (isCustom) {
+ // if the current and custom_uri values mismatch, update the uri pref
+ if (
+ currentURI &&
+ !customURI &&
+ !resolvers.find(r => r.url == currentURI)
+ ) {
+ Services.prefs.setStringPref("network.trr.custom_uri", currentURI);
+ }
+ }
+ } else {
+ this.toggleDnsOverHttpsUI(true);
+ }
+
+ if (!menu.disabled && isCustom) {
+ customContainer.hidden = false;
+ customInput.disabled = false;
+ } else {
+ customContainer.hidden = true;
+ customInput.disabled = true;
+ }
+
+ // The height has likely changed, find our SubDialog and tell it to resize.
+ requestAnimationFrame(() => {
+ let dialogs = window.opener.gSubDialog._dialogs;
+ let dialog = dialogs.find(d => d._frame.contentDocument == document);
+ if (dialog) {
+ dialog.resizeVertically();
+ }
+ });
+ },
+
+ getDnsOverHttpsControls() {
+ return [
+ document.getElementById("networkDnsOverHttpsResolverChoices"),
+ document.getElementById("networkCustomDnsOverHttpsInput"),
+ document.getElementById("networkDnsOverHttpsResolverChoicesLabel"),
+ document.getElementById("networkCustomDnsOverHttpsInputLabel"),
+ ];
+ },
+
+ toggleDnsOverHttpsUI(disabled) {
+ for (let element of this.getDnsOverHttpsControls()) {
+ element.disabled = disabled;
+ }
+ },
+
+ initDnsOverHttpsUI() {
+ let resolvers = this.dnsOverHttpsResolvers;
+ let defaultURI = Preferences.get("network.trr.uri").defaultValue;
+ let currentURI = Preferences.get("network.trr.uri").value;
+ let menu = document.getElementById("networkDnsOverHttpsResolverChoices");
+
+ // populate the DNS-Over-HTTPs resolver list
+ menu.removeAllItems();
+ for (let resolver of resolvers) {
+ let item = menu.appendItem(undefined, resolver.url);
+ if (resolver.url == defaultURI) {
+ document.l10n.setAttributes(
+ item,
+ "connection-dns-over-https-url-item-default",
+ {
+ name: resolver.name || resolver.url,
+ }
+ );
+ } else {
+ item.label = resolver.name || resolver.url;
+ }
+ }
+ let lastItem = menu.appendItem(undefined, "custom");
+ document.l10n.setAttributes(
+ lastItem,
+ "connection-dns-over-https-url-custom"
+ );
+
+ // set initial selection in the resolver provider picker
+ let selectedIndex = currentURI
+ ? resolvers.findIndex(r => r.url == currentURI)
+ : 0;
+ if (selectedIndex == -1) {
+ // select the last "Custom" item
+ selectedIndex = menu.itemCount - 1;
+ }
+ menu.selectedIndex = selectedIndex;
+
+ if (this.isDnsOverHttpsLocked()) {
+ // disable all the options and the checkbox itself to disallow enabling them
+ this.toggleDnsOverHttpsUI(true);
+ document.getElementById("networkDnsOverHttps").disabled = true;
+ } else {
+ this.toggleDnsOverHttpsUI(false);
+ this.updateDnsOverHttpsUI();
+ document.getElementById("networkDnsOverHttps").disabled = false;
+ }
+ },
+};
diff --git a/comm/mail/components/preferences/connection.xhtml b/comm/mail/components/preferences/connection.xhtml
new file mode 100644
index 0000000000..1bbb822f66
--- /dev/null
+++ b/comm/mail/components/preferences/connection.xhtml
@@ -0,0 +1,264 @@
+<?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/. -->
+
+<!DOCTYPE window>
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="connection-dialog-window2"
+ style="min-width: 49em"
+ onload="gConnectionsDialog.checkForSystemProxy();"
+>
+ <dialog id="ConnectionsDialog" dlgbuttons="accept,cancel">
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/preferences/connection.ftl"
+ />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/preferences.ftl"
+ />
+ <html:link rel="localization" href="branding/brand.ftl" />
+ </linkset>
+
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/extensionControlled.js" />
+ <script src="chrome://messenger/content/preferences/connection.js" />
+
+ <!-- Need a wrapper div within the xul:dialog, which otherwise does not give
+ - enough height for the flex display.
+ - REMOVE when we use HTML only. -->
+ <html:div>
+ <html:div id="proxyExtensionContent" hidden="hidden">
+ <html:p id="proxyExtensionDescription">
+ <html:img data-l10n-name="extension-icon" />
+ </html:p>
+ <html:button
+ id="proxyExtensionDisable"
+ data-l10n-id="disable-extension-button"
+ >
+ </html:button>
+ </html:div>
+ </html:div>
+
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="connection-proxy-legend"></html:legend>
+
+ <radiogroup id="networkProxyType" preference="network.proxy.type">
+ <radio value="0" data-l10n-id="proxy-type-no" />
+ <radio value="4" data-l10n-id="proxy-type-wpad" />
+ <radio
+ value="5"
+ data-l10n-id="proxy-type-system"
+ id="systemPref"
+ hidden="true"
+ />
+ <radio value="1" data-l10n-id="proxy-type-manual" />
+ <box id="proxy-grid" class="indent" flex="1">
+ <html:div class="proxy-grid-row">
+ <hbox pack="end">
+ <label
+ data-l10n-id="proxy-http-label"
+ control="networkProxyHTTP"
+ />
+ </hbox>
+ <hbox align="center" class="input-container">
+ <html:input
+ id="networkProxyHTTP"
+ type="text"
+ preference="network.proxy.http"
+ />
+ <label
+ data-l10n-id="http-port-label"
+ control="networkProxyHTTP_Port"
+ />
+ <html:input
+ id="networkProxyHTTP_Port"
+ type="number"
+ class="size5"
+ max="65535"
+ preference="network.proxy.http_port"
+ />
+ </hbox>
+ </html:div>
+ <html:div class="proxy-grid-row">
+ <hbox />
+ <hbox>
+ <checkbox
+ id="shareAllProxies"
+ data-l10n-id="proxy-http-sharing"
+ preference="network.proxy.share_proxy_settings"
+ class="align-no-label"
+ />
+ </hbox>
+ </html:div>
+ <html:div class="proxy-grid-row">
+ <hbox pack="end">
+ <label
+ data-l10n-id="proxy-https-label"
+ control="networkProxySSL"
+ />
+ </hbox>
+ <hbox align="center" class="input-container">
+ <html:input
+ id="networkProxySSL"
+ type="text"
+ preference="network.proxy.ssl"
+ />
+ <label
+ data-l10n-id="ssl-port-label"
+ control="networkProxySSL_Port"
+ />
+ <html:input
+ id="networkProxySSL_Port"
+ type="number"
+ class="size5"
+ max="65535"
+ preference="network.proxy.ssl_port"
+ />
+ </hbox>
+ </html:div>
+ <separator class="thin" />
+ <html:div class="proxy-grid-row">
+ <hbox pack="end">
+ <label
+ data-l10n-id="proxy-socks-label"
+ control="networkProxySOCKS"
+ />
+ </hbox>
+ <hbox align="center" class="input-container">
+ <html:input
+ id="networkProxySOCKS"
+ type="text"
+ preference="network.proxy.socks"
+ />
+ <label
+ data-l10n-id="socks-port-label"
+ control="networkProxySOCKS_Port"
+ />
+ <html:input
+ id="networkProxySOCKS_Port"
+ type="number"
+ class="size5"
+ max="65535"
+ preference="network.proxy.socks_port"
+ />
+ </hbox>
+ </html:div>
+ <html:div class="proxy-grid-row">
+ <spacer />
+ <radiogroup
+ id="networkProxySOCKSVersion"
+ orient="horizontal"
+ class="align-no-label"
+ preference="network.proxy.socks_version"
+ >
+ <radio
+ id="networkProxySOCKSVersion4"
+ value="4"
+ data-l10n-id="proxy-socks4-label"
+ />
+ <radio
+ id="networkProxySOCKSVersion5"
+ value="5"
+ data-l10n-id="proxy-socks5-label"
+ />
+ </radiogroup>
+ </html:div>
+ </box>
+ <radio value="2" data-l10n-id="proxy-type-auto" />
+ <hbox class="indent input-container" flex="1" align="center">
+ <html:input
+ id="networkProxyAutoconfigURL"
+ type="url"
+ preference="network.proxy.autoconfig_url"
+ oninput="gConnectionsDialog.updateReloadButton();"
+ />
+ <button
+ id="autoReload"
+ data-l10n-id="proxy-reload-label"
+ oncommand="gConnectionsDialog.reloadPAC();"
+ preference="pref.advanced.proxies.disable_button.reload"
+ />
+ </hbox>
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+ <separator class="thin" />
+ <label data-l10n-id="no-proxy-label" control="networkProxyNone" />
+ <html:textarea
+ id="networkProxyNone"
+ rows="2"
+ preference="network.proxy.no_proxies_on"
+ />
+ <label data-l10n-id="no-proxy-example" control="networkProxyNone" />
+ <label
+ id="networkProxyNoneLocalhost"
+ control="networkProxyNone"
+ data-l10n-id="connection-proxy-noproxy-localhost-desc-2"
+ />
+ <separator class="thin" />
+ <checkbox
+ id="autologinProxy"
+ data-l10n-id="proxy-password-prompt"
+ preference="signon.autologin.proxy"
+ />
+ <checkbox
+ id="networkProxySOCKSRemoteDNS"
+ preference="network.proxy.socks_remote_dns"
+ data-l10n-id="proxy-remote-dns"
+ />
+ <separator class="thin" />
+ <checkbox
+ id="networkDnsOverHttps"
+ data-l10n-id="proxy-enable-doh"
+ preference="network.trr.mode"
+ />
+ <box id="dnsOverHttps-grid" class="indent" flex="1">
+ <html:div class="dnsOverHttps-grid-row">
+ <hbox pack="end">
+ <label
+ id="networkDnsOverHttpsResolverChoicesLabel"
+ data-l10n-id="connection-dns-over-https-url-resolver"
+ control="networkDnsOverHttpsResolverChoices"
+ />
+ </hbox>
+ <menulist
+ id="networkDnsOverHttpsResolverChoices"
+ flex="1"
+ oncommand="gConnectionsDialog.updateDnsOverHttpsUI()"
+ />
+ </html:div>
+ <html:div
+ class="dnsOverHttps-grid-row"
+ id="customDnsOverHttpsContainer"
+ hidden="hidden"
+ >
+ <hbox>
+ <label
+ id="networkCustomDnsOverHttpsInputLabel"
+ data-l10n-id="connection-dns-over-https-custom-label"
+ control="networkCustomDnsOverHttpsInput"
+ />
+ </hbox>
+ <html:input
+ id="networkCustomDnsOverHttpsInput"
+ type="url"
+ style="flex: 1"
+ preference="network.trr.custom_uri"
+ />
+ </html:div>
+ </box>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/cookies.js b/comm/mail/components/preferences/cookies.js
new file mode 100644
index 0000000000..da06eb7e5a
--- /dev/null
+++ b/comm/mail/components/preferences/cookies.js
@@ -0,0 +1,993 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+});
+
+var gCookiesWindow = {
+ _hosts: {},
+ _hostOrder: [],
+ _tree: null,
+ _bundle: null,
+
+ init() {
+ Services.obs.addObserver(this, "cookie-changed");
+ Services.obs.addObserver(this, "perm-changed");
+
+ this._bundle = document.getElementById("bundlePreferences");
+ this._tree = document.getElementById("cookiesList");
+
+ this._populateList(true);
+
+ document.getElementById("filter").focus();
+
+ if (!Services.prefs.getBoolPref("privacy.userContext.enabled")) {
+ document.getElementById("userContext").hidden = true;
+ document.getElementById("userContextLabel").hidden = true;
+ }
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "cookie-changed");
+ Services.obs.removeObserver(this, "perm-changed");
+ },
+
+ _populateList(aInitialLoad) {
+ this._loadCookies();
+ this._tree.view = this._view;
+ if (aInitialLoad) {
+ this.sort("rawHost");
+ }
+ if (this._view.rowCount > 0) {
+ this._tree.view.selection.select(0);
+ }
+
+ if (aInitialLoad) {
+ if (
+ "arguments" in window &&
+ window.arguments[2] &&
+ window.arguments[2].filterString
+ ) {
+ this.setFilter(window.arguments[2].filterString);
+ }
+ } else if (document.getElementById("filter").value != "") {
+ this.filter();
+ }
+
+ this._saveState();
+ },
+
+ _cookieEquals(aCookieA, aCookieB, aStrippedHost) {
+ return (
+ aCookieA.rawHost == aStrippedHost &&
+ aCookieA.name == aCookieB.name &&
+ aCookieA.path == aCookieB.path &&
+ ChromeUtils.isOriginAttributesEqual(
+ aCookieA.originAttributes,
+ aCookieB.originAttributes
+ )
+ );
+ },
+
+ observe(aCookie, aTopic, aData) {
+ if (aTopic != "cookie-changed") {
+ return;
+ }
+
+ if (aCookie instanceof Ci.nsICookie) {
+ var strippedHost = this._makeStrippedHost(aCookie.host);
+ if (aData == "changed") {
+ this._handleCookieChanged(aCookie, strippedHost);
+ } else if (aData == "added") {
+ this._handleCookieAdded(aCookie, strippedHost);
+ }
+ } else if (aData == "cleared") {
+ this._hosts = {};
+ this._hostOrder = [];
+
+ var oldRowCount = this._view._rowCount;
+ this._view._rowCount = 0;
+ this._tree.rowCountChanged(0, -oldRowCount);
+ this._view.selection.clearSelection();
+ } else if (aData == "reload") {
+ // first, clear any existing entries
+ this.observe(aCookie, aTopic, "cleared");
+
+ // then, reload the list
+ this._populateList(false);
+ }
+
+ // We don't yet handle aData == "deleted" - it's a less common case
+ // and is rather complicated as selection tracking is difficult
+ },
+
+ _handleCookieChanged(changedCookie, strippedHost) {
+ var rowIndex = 0;
+ var cookieItem = null;
+ if (!this._view._filtered) {
+ for (var i = 0; i < this._hostOrder.length; ++i) {
+ // (var host in this._hosts) {
+ ++rowIndex;
+ var hostItem = this._hosts[this._hostOrder[i]]; // var hostItem = this._hosts[host];
+ if (this._hostOrder[i] == strippedHost) {
+ // host == strippedHost) {
+ // Host matches, look for the cookie within this Host collection
+ // and update its data
+ for (var j = 0; j < hostItem.cookies.length; ++j) {
+ ++rowIndex;
+ var currCookie = hostItem.cookies[j];
+ if (this._cookieEquals(currCookie, changedCookie, strippedHost)) {
+ currCookie.value = changedCookie.value;
+ currCookie.isSecure = changedCookie.isSecure;
+ currCookie.isDomain = changedCookie.isDomain;
+ currCookie.expires = changedCookie.expires;
+ cookieItem = currCookie;
+ break;
+ }
+ }
+ } else if (hostItem.open) {
+ rowIndex += hostItem.cookies.length;
+ }
+ }
+ } else {
+ // Just walk the filter list to find the item. It doesn't matter that
+ // we don't update the main Host collection when we do this, because
+ // when the filter is reset the Host collection is rebuilt anyway.
+ for (rowIndex = 0; rowIndex < this._view._filterSet.length; ++rowIndex) {
+ currCookie = this._view._filterSet[rowIndex];
+ if (this._cookieEquals(currCookie, changedCookie, strippedHost)) {
+ currCookie.value = changedCookie.value;
+ currCookie.isSecure = changedCookie.isSecure;
+ currCookie.isDomain = changedCookie.isDomain;
+ currCookie.expires = changedCookie.expires;
+ cookieItem = currCookie;
+ break;
+ }
+ }
+ }
+
+ // Make sure the tree display is up to date...
+ this._tree.invalidateRow(rowIndex);
+ // ... and if the cookie is selected, update the displayed metadata too
+ if (cookieItem != null && this._view.selection.currentIndex == rowIndex) {
+ this._updateCookieData(cookieItem);
+ }
+ },
+
+ _handleCookieAdded(changedCookie, strippedHost) {
+ var rowCountImpact = 0;
+ var addedHost = { value: 0 };
+ this._addCookie(strippedHost, changedCookie, addedHost);
+ if (!this._view._filtered) {
+ // The Host collection for this cookie already exists, and it's not open,
+ // so don't increment the rowCountImpact because the user is not going to
+ // see the additional rows as they're hidden.
+ if (addedHost.value || this._hosts[strippedHost].open) {
+ ++rowCountImpact;
+ }
+ } else {
+ // We're in search mode, and the cookie being added matches
+ // the search condition, so add it to the list.
+ var c = this._makeCookieObject(strippedHost, changedCookie);
+ if (this._cookieMatchesFilter(c)) {
+ this._view._filterSet.push(
+ this._makeCookieObject(strippedHost, changedCookie)
+ );
+ ++rowCountImpact;
+ }
+ }
+ // Now update the tree display at the end (we could/should re run the sort
+ // if any to get the position correct.)
+ var oldRowCount = this._rowCount;
+ this._view._rowCount += rowCountImpact;
+ this._tree.rowCountChanged(oldRowCount - 1, rowCountImpact);
+
+ document.getElementById("removeAllCookies").disabled = this._view._filtered;
+ },
+
+ _view: {
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+ _filtered: false,
+ _filterSet: [],
+ _filterValue: "",
+ _rowCount: 0,
+ _cacheValid: 0,
+ _cacheItems: [],
+ get rowCount() {
+ return this._rowCount;
+ },
+
+ _getItemAtIndex(aIndex) {
+ if (this._filtered) {
+ return this._filterSet[aIndex];
+ }
+
+ var start = 0;
+ var count = 0,
+ hostIndex = 0;
+
+ var cacheIndex = Math.min(this._cacheValid, aIndex);
+ if (cacheIndex > 0) {
+ var cacheItem = this._cacheItems[cacheIndex];
+ start = cacheItem.start;
+ count = hostIndex = cacheItem.count;
+ }
+
+ for (let i = start; i < gCookiesWindow._hostOrder.length; ++i) {
+ let currHost = gCookiesWindow._hosts[gCookiesWindow._hostOrder[i]];
+ if (!currHost) {
+ continue;
+ }
+ if (count == aIndex) {
+ return currHost;
+ }
+ hostIndex = count;
+
+ let cacheEntry = { start: i, count };
+ var cacheStart = count;
+
+ if (currHost.open) {
+ if (count < aIndex && aIndex <= count + currHost.cookies.length) {
+ // We are looking for an entry within this host's children,
+ // enumerate them looking for the index.
+ ++count;
+ for (let j = 0; j < currHost.cookies.length; ++j) {
+ if (count == aIndex) {
+ let cookie = currHost.cookies[j];
+ cookie.parentIndex = hostIndex;
+ return cookie;
+ }
+ ++count;
+ }
+ } else {
+ // A host entry was open, but we weren't looking for an index
+ // within that host entry's children, so skip forward over the
+ // entry's children. We need to add one to increment for the
+ // host value too.
+ count += currHost.cookies.length + 1;
+ }
+ } else {
+ ++count;
+ }
+
+ for (let j = cacheStart; j < count; j++) {
+ this._cacheItems[j] = cacheEntry;
+ }
+ this._cacheValid = count - 1;
+ }
+ return null;
+ },
+
+ _removeItemAtIndex(aIndex, aCount) {
+ var removeCount = aCount === undefined ? 1 : aCount;
+ if (this._filtered) {
+ // remove the cookies from the unfiltered set so that they
+ // don't reappear when the filter is changed. See bug 410863.
+ for (let i = aIndex; i < aIndex + removeCount; ++i) {
+ let item = this._filterSet[i];
+ let parent = gCookiesWindow._hosts[item.rawHost];
+ for (var j = 0; j < parent.cookies.length; ++j) {
+ if (item == parent.cookies[j]) {
+ parent.cookies.splice(j, 1);
+ break;
+ }
+ }
+ }
+ this._filterSet.splice(aIndex, removeCount);
+ return;
+ }
+
+ let item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return;
+ }
+ this._invalidateCache(aIndex - 1);
+ if (item.container) {
+ gCookiesWindow._hosts[item.rawHost] = null;
+ } else {
+ let parent = this._getItemAtIndex(item.parentIndex);
+ for (let i = 0; i < parent.cookies.length; ++i) {
+ var cookie = parent.cookies[i];
+ if (
+ item.rawHost == cookie.rawHost &&
+ item.name == cookie.name &&
+ item.path == cookie.path &&
+ ChromeUtils.isOriginAttributesEqual(
+ item.originAttributes,
+ cookie.originAttributes
+ )
+ ) {
+ parent.cookies.splice(i, removeCount);
+ }
+ }
+ }
+ },
+
+ _invalidateCache(aIndex) {
+ this._cacheValid = Math.min(this._cacheValid, aIndex);
+ },
+
+ getCellText(aIndex, aColumn) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return "";
+ }
+ if (aColumn.id == "domainCol") {
+ return item.rawHost;
+ }
+ if (aColumn.id == "nameCol") {
+ return "name" in item ? item.name : "";
+ }
+ } else if (aColumn.id == "domainCol") {
+ return this._filterSet[aIndex].rawHost;
+ } else if (aColumn.id == "nameCol") {
+ return "name" in this._filterSet[aIndex]
+ ? this._filterSet[aIndex].name
+ : "";
+ }
+ return "";
+ },
+
+ _selection: null,
+ get selection() {
+ return this._selection;
+ },
+ set selection(val) {
+ this._selection = val;
+ },
+ getRowProperties(aRow) {
+ return "";
+ },
+ getCellProperties(aRow, aColumn) {
+ return "";
+ },
+ getColumnProperties(aColumn) {
+ return "";
+ },
+ isContainer(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return false;
+ }
+ return item.container;
+ }
+ return false;
+ },
+ isContainerOpen(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return false;
+ }
+ return item.open;
+ }
+ return false;
+ },
+ isContainerEmpty(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return false;
+ }
+ return item.cookies.length == 0;
+ }
+ return false;
+ },
+ isSeparator(aIndex) {
+ return false;
+ },
+ isSorted(aIndex) {
+ return false;
+ },
+ canDrop(aIndex, aOrientation) {
+ return false;
+ },
+ drop(aIndex, aOrientation) {},
+ getParentIndex(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ // If an item has no parent index (i.e. it is at the top level) this
+ // function MUST return -1 otherwise we will go into an infinite loop.
+ // Containers are always top level items in the cookies tree, so make
+ // sure to return the appropriate value here.
+ if (!item || item.container) {
+ return -1;
+ }
+ return item.parentIndex;
+ }
+ return -1;
+ },
+ hasNextSibling(aParentIndex, aIndex) {
+ if (!this._filtered) {
+ // |aParentIndex| appears to be bogus, but we can get the real
+ // parent index by getting the entry for |aIndex| and reading the
+ // parentIndex field.
+ // The index of the last item in this host collection is the
+ // index of the parent + the size of the host collection, and
+ // aIndex has a next sibling if it is less than this value.
+ var item = this._getItemAtIndex(aIndex);
+ if (item) {
+ if (item.container) {
+ for (var i = aIndex + 1; i < this.rowCount; ++i) {
+ var subsequent = this._getItemAtIndex(i);
+ if (subsequent.container) {
+ return true;
+ }
+ }
+ return false;
+ }
+ let parent = this._getItemAtIndex(item.parentIndex);
+ if (parent && parent.container) {
+ return aIndex < item.parentIndex + parent.cookies.length;
+ }
+ }
+ }
+ return aIndex < this.rowCount - 1;
+ },
+ hasPreviousSibling(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return false;
+ }
+ var parent = this._getItemAtIndex(item.parentIndex);
+ if (parent && parent.container) {
+ return aIndex > item.parentIndex + 1;
+ }
+ }
+ return aIndex > 0;
+ },
+ getLevel(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return 0;
+ }
+ return item.level;
+ }
+ return 0;
+ },
+ getImageSrc(aIndex, aColumn) {},
+ getProgressMode(aIndex, aColumn) {},
+ getCellValue(aIndex, aColumn) {},
+ setTree(aTree) {},
+ toggleOpenState(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return;
+ }
+ this._invalidateCache(aIndex);
+ var multiplier = item.open ? -1 : 1;
+ var delta = multiplier * item.cookies.length;
+ this._rowCount += delta;
+ item.open = !item.open;
+ gCookiesWindow._tree.rowCountChanged(aIndex + 1, delta);
+ gCookiesWindow._tree.invalidateRow(aIndex);
+ }
+ },
+ cycleHeader(aColumn) {},
+ selectionChanged() {},
+ cycleCell(aIndex, aColumn) {},
+ isEditable(aIndex, aColumn) {
+ return false;
+ },
+ setCellValue(aIndex, aColumn, aValue) {},
+ setCellText(aIndex, aColumn, aValue) {},
+ },
+
+ _makeStrippedHost(aHost) {
+ let formattedHost = aHost.startsWith(".")
+ ? aHost.substring(1, aHost.length)
+ : aHost;
+ return formattedHost.startsWith("www.")
+ ? formattedHost.substring(4, formattedHost.length)
+ : formattedHost;
+ },
+
+ _addCookie(aStrippedHost, aCookie, aHostCount) {
+ if (!(aStrippedHost in this._hosts) || !this._hosts[aStrippedHost]) {
+ this._hosts[aStrippedHost] = {
+ cookies: [],
+ rawHost: aStrippedHost,
+ level: 0,
+ open: false,
+ container: true,
+ };
+ this._hostOrder.push(aStrippedHost);
+ ++aHostCount.value;
+ }
+
+ var c = this._makeCookieObject(aStrippedHost, aCookie);
+ this._hosts[aStrippedHost].cookies.push(c);
+ },
+
+ _makeCookieObject(aStrippedHost, aCookie) {
+ let c = {
+ name: aCookie.name,
+ value: aCookie.value,
+ isDomain: aCookie.isDomain,
+ host: aCookie.host,
+ rawHost: aStrippedHost,
+ path: aCookie.path,
+ isSecure: aCookie.isSecure,
+ expires: aCookie.expires,
+ level: 1,
+ container: false,
+ originAttributes: aCookie.originAttributes,
+ };
+ return c;
+ },
+
+ _loadCookies() {
+ var hostCount = { value: 0 };
+ this._hosts = {};
+ this._hostOrder = [];
+ for (let cookie of Services.cookies.cookies) {
+ var strippedHost = this._makeStrippedHost(cookie.host);
+ this._addCookie(strippedHost, cookie, hostCount);
+ }
+ this._view._rowCount = hostCount.value;
+ },
+
+ formatExpiresString(aExpires) {
+ if (aExpires) {
+ var date = new Date(1000 * aExpires);
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "long",
+ timeStyle: "long",
+ });
+ return dateTimeFormatter.format(date);
+ }
+ return this._bundle.getString("expireAtEndOfSession");
+ },
+
+ _getUserContextString(aUserContextId) {
+ if (parseInt(aUserContextId, 10) == 0) {
+ return this._bundle.getString("defaultUserContextLabel");
+ }
+
+ return ContextualIdentityService.getUserContextLabel(aUserContextId);
+ },
+
+ _updateCookieData(aItem) {
+ var seln = this._view.selection;
+ var ids = [
+ "name",
+ "value",
+ "host",
+ "path",
+ "isSecure",
+ "expires",
+ "userContext",
+ ];
+ var properties;
+
+ if (aItem && !aItem.container && seln.count > 0) {
+ properties = {
+ name: aItem.name,
+ value: aItem.value,
+ host: aItem.host,
+ path: aItem.path,
+ expires: this.formatExpiresString(aItem.expires),
+ isDomain: aItem.isDomain
+ ? this._bundle.getString("domainColon")
+ : this._bundle.getString("hostColon"),
+ isSecure: aItem.isSecure
+ ? this._bundle.getString("forSecureOnly")
+ : this._bundle.getString("forAnyConnection"),
+ userContext: this._getUserContextString(
+ aItem.originAttributes.userContextId
+ ),
+ };
+ for (var i = 0; i < ids.length; ++i) {
+ document.getElementById(ids[i]).disabled = false;
+ }
+ } else {
+ var noneSelected = this._bundle.getString("noCookieSelected");
+ properties = {
+ name: noneSelected,
+ value: noneSelected,
+ host: noneSelected,
+ path: noneSelected,
+ expires: noneSelected,
+ isSecure: noneSelected,
+ userContext: noneSelected,
+ };
+ for (i = 0; i < ids.length; ++i) {
+ document.getElementById(ids[i]).disabled = true;
+ }
+ }
+ for (var property in properties) {
+ document.getElementById(property).value = properties[property];
+ }
+ },
+
+ onCookieSelected() {
+ var item;
+ var seln = this._tree.view.selection;
+ if (!this._view._filtered) {
+ item = this._view._getItemAtIndex(seln.currentIndex);
+ } else {
+ item = this._view._filterSet[seln.currentIndex];
+ }
+
+ this._updateCookieData(item);
+
+ var rangeCount = seln.getRangeCount();
+ var selectedCookieCount = 0;
+ for (var i = 0; i < rangeCount; ++i) {
+ var min = {};
+ var max = {};
+ seln.getRangeAt(i, min, max);
+ for (var j = min.value; j <= max.value; ++j) {
+ item = this._view._getItemAtIndex(j);
+ if (!item) {
+ continue;
+ }
+ if (item.container && !item.open) {
+ selectedCookieCount += item.cookies.length;
+ } else if (!item.container) {
+ ++selectedCookieCount;
+ }
+ }
+ }
+ item = this._view._getItemAtIndex(seln.currentIndex);
+ if (item && seln.count == 1 && item.container && item.open) {
+ selectedCookieCount += 2;
+ }
+
+ let buttonLabel = this._bundle.getString("removeSelectedCookies");
+ let removeSelectedCookies = document.getElementById(
+ "removeSelectedCookies"
+ );
+ removeSelectedCookies.label = PluralForm.get(
+ selectedCookieCount,
+ buttonLabel
+ ).replace("#1", selectedCookieCount);
+
+ removeSelectedCookies.disabled = !(seln.count > 0);
+ document.getElementById("removeAllCookies").disabled = this._view._filtered;
+ },
+
+ deleteCookie() {
+ // Selection Notes
+ // - Selection always moves to *NEXT* adjacent item unless item
+ // is last child at a given level in which case it moves to *PREVIOUS*
+ // item
+ //
+ // Selection Cases (Somewhat Complicated)
+ //
+ // 1) Single cookie selected, host has single child
+ // v cnn.com
+ // //// cnn.com ///////////// goksdjf@ ////
+ // > atwola.com
+ //
+ // Before SelectedIndex: 1 Before RowCount: 3
+ // After SelectedIndex: 0 After RowCount: 1
+ //
+ // 2) Host selected, host open
+ // v goats.com ////////////////////////////
+ // goats.com sldkkfjl
+ // goat.scom flksj133
+ // > atwola.com
+ //
+ // Before SelectedIndex: 0 Before RowCount: 4
+ // After SelectedIndex: 0 After RowCount: 1
+ //
+ // 3) Host selected, host closed
+ // > goats.com ////////////////////////////
+ // > atwola.com
+ //
+ // Before SelectedIndex: 0 Before RowCount: 2
+ // After SelectedIndex: 0 After RowCount: 1
+ //
+ // 4) Single cookie selected, host has many children
+ // v goats.com
+ // goats.com sldkkfjl
+ // //// goats.com /////////// flksjl33 ////
+ // > atwola.com
+ //
+ // Before SelectedIndex: 2 Before RowCount: 4
+ // After SelectedIndex: 1 After RowCount: 3
+ //
+ // 5) Single cookie selected, host has many children
+ // v goats.com
+ // //// goats.com /////////// flksjl33 ////
+ // goats.com sldkkfjl
+ // > atwola.com
+ //
+ // Before SelectedIndex: 1 Before RowCount: 4
+ // After SelectedIndex: 1 After RowCount: 3
+ var seln = this._view.selection;
+ var tbo = this._tree;
+
+ if (seln.count < 1) {
+ return;
+ }
+
+ var nextSelected = 0;
+ var rowCountImpact = 0;
+ var deleteItems = [];
+ if (!this._view._filtered) {
+ var ci = seln.currentIndex;
+ nextSelected = ci;
+ var invalidateRow = -1;
+ let item = this._view._getItemAtIndex(ci);
+ if (item.container) {
+ rowCountImpact -= (item.open ? item.cookies.length : 0) + 1;
+ deleteItems = deleteItems.concat(item.cookies);
+ if (!this._view.hasNextSibling(-1, ci)) {
+ --nextSelected;
+ }
+ this._view._removeItemAtIndex(ci);
+ } else {
+ var parent = this._view._getItemAtIndex(item.parentIndex);
+ --rowCountImpact;
+ if (parent.cookies.length == 1) {
+ --rowCountImpact;
+ deleteItems.push(item);
+ if (!this._view.hasNextSibling(-1, ci)) {
+ --nextSelected;
+ }
+ if (!this._view.hasNextSibling(-1, item.parentIndex)) {
+ --nextSelected;
+ }
+ this._view._removeItemAtIndex(item.parentIndex);
+ invalidateRow = item.parentIndex;
+ } else {
+ deleteItems.push(item);
+ if (!this._view.hasNextSibling(-1, ci)) {
+ --nextSelected;
+ }
+ this._view._removeItemAtIndex(ci);
+ }
+ }
+ this._view._rowCount += rowCountImpact;
+ tbo.rowCountChanged(ci, rowCountImpact);
+ if (invalidateRow != -1) {
+ tbo.invalidateRow(invalidateRow);
+ }
+ } else {
+ var rangeCount = seln.getRangeCount();
+ for (var i = 0; i < rangeCount; ++i) {
+ var min = {};
+ var max = {};
+ seln.getRangeAt(i, min, max);
+ nextSelected = min.value;
+ for (var j = min.value; j <= max.value; ++j) {
+ deleteItems.push(this._view._getItemAtIndex(j));
+ if (!this._view.hasNextSibling(-1, max.value)) {
+ --nextSelected;
+ }
+ }
+ var delta = max.value - min.value + 1;
+ this._view._removeItemAtIndex(min.value, delta);
+ rowCountImpact = -1 * delta;
+ this._view._rowCount += rowCountImpact;
+ tbo.rowCountChanged(min.value, rowCountImpact);
+ }
+ }
+
+ for (let item of deleteItems) {
+ Services.cookies.remove(
+ item.host,
+ item.name,
+ item.path,
+ item.originAttributes
+ );
+ }
+
+ if (nextSelected < 0) {
+ seln.clearSelection();
+ } else {
+ seln.select(nextSelected);
+ this._tree.focus();
+ }
+ },
+
+ deleteAllCookies() {
+ Services.cookies.removeAll();
+ this._tree.focus();
+ },
+
+ onCookieKeyPress(aEvent) {
+ if (aEvent.keyCode == 46) {
+ this.deleteCookie();
+ }
+ },
+
+ _lastSortProperty: "",
+ _lastSortAscending: false,
+ sort(aProperty) {
+ var ascending =
+ aProperty == this._lastSortProperty ? !this._lastSortAscending : true;
+
+ function sortByHost(a, b) {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ }
+
+ // Sort the Non-Filtered Host Collections
+ if (aProperty == "rawHost") {
+ this._hostOrder.sort(sortByHost);
+ if (!ascending) {
+ this._hostOrder.reverse();
+ }
+ }
+
+ function sortByProperty(a, b) {
+ return a[aProperty]
+ .toLowerCase()
+ .localeCompare(b[aProperty].toLowerCase());
+ }
+ for (var host in this._hosts) {
+ var cookies = this._hosts[host].cookies;
+ cookies.sort(sortByProperty);
+ if (!ascending) {
+ cookies.reverse();
+ }
+ }
+ // Sort the Filtered List, if in Filtered mode
+ if (this._view._filtered) {
+ this._view._filterSet.sort(sortByProperty);
+ if (!ascending) {
+ this._view._filterSet.reverse();
+ }
+ }
+
+ this._view._invalidateCache(0);
+ this._view.selection.clearSelection();
+ this._view.selection.select(0);
+ this._tree.invalidate();
+ this._tree.ensureRowIsVisible(0);
+
+ this._lastSortAscending = ascending;
+ this._lastSortProperty = aProperty;
+ },
+
+ clearFilter() {
+ // Revert to single-select in the tree
+ this._tree.setAttribute("seltype", "single");
+
+ // Clear the Tree Display
+ this._view._filtered = false;
+ this._view._rowCount = 0;
+ this._tree.rowCountChanged(0, -this._view._filterSet.length);
+ this._view._filterSet = [];
+
+ // Just reload the list to make sure deletions are respected
+ this._loadCookies();
+ this._tree.view = this._view;
+
+ // Restore sort order
+ var sortby = this._lastSortProperty;
+ if (sortby == "") {
+ this._lastSortAscending = false;
+ this.sort("rawHost");
+ } else {
+ this._lastSortAscending = !this._lastSortAscending;
+ this.sort(sortby);
+ }
+
+ // Restore open state
+ for (var i = 0; i < this._openIndices.length; ++i) {
+ this._view.toggleOpenState(this._openIndices[i]);
+ }
+ this._openIndices = [];
+
+ // Restore selection
+ this._view.selection.clearSelection();
+ for (i = 0; i < this._lastSelectedRanges.length; ++i) {
+ var range = this._lastSelectedRanges[i];
+ this._view.selection.rangedSelect(range.min, range.max, true);
+ }
+ this._lastSelectedRanges = [];
+
+ document.getElementById("cookiesIntro").value =
+ this._bundle.getString("cookiesAll");
+ },
+
+ _cookieMatchesFilter(aCookie) {
+ return (
+ aCookie.rawHost.includes(this._view._filterValue) ||
+ aCookie.name.includes(this._view._filterValue) ||
+ aCookie.value.includes(this._view._filterValue)
+ );
+ },
+
+ _filterCookies(aFilterValue) {
+ this._view._filterValue = aFilterValue;
+ var cookies = [];
+ for (let i = 0; i < gCookiesWindow._hostOrder.length; ++i) {
+ let currHost = gCookiesWindow._hosts[gCookiesWindow._hostOrder[i]];
+ if (!currHost) {
+ continue;
+ }
+ for (var j = 0; j < currHost.cookies.length; ++j) {
+ var cookie = currHost.cookies[j];
+ if (this._cookieMatchesFilter(cookie)) {
+ cookies.push(cookie);
+ }
+ }
+ }
+ return cookies;
+ },
+
+ _lastSelectedRanges: [],
+ _openIndices: [],
+ _saveState() {
+ // Save selection
+ var seln = this._view.selection;
+ this._lastSelectedRanges = [];
+ var rangeCount = seln.getRangeCount();
+ for (var i = 0; i < rangeCount; ++i) {
+ var min = {};
+ var max = {};
+ seln.getRangeAt(i, min, max);
+ this._lastSelectedRanges.push({ min: min.value, max: max.value });
+ }
+
+ // Save open states
+ this._openIndices = [];
+ for (i = 0; i < this._view.rowCount; ++i) {
+ var item = this._view._getItemAtIndex(i);
+ if (item && item.container && item.open) {
+ this._openIndices.push(i);
+ }
+ }
+ },
+
+ filter() {
+ var filter = document.getElementById("filter").value;
+ if (filter == "") {
+ gCookiesWindow.clearFilter();
+ return;
+ }
+ var view = gCookiesWindow._view;
+ view._filterSet = gCookiesWindow._filterCookies(filter);
+ if (!view._filtered) {
+ // Save Display Info for the Non-Filtered mode when we first
+ // enter Filtered mode.
+ gCookiesWindow._saveState();
+ view._filtered = true;
+ }
+ // Move to multi-select in the tree
+ gCookiesWindow._tree.setAttribute("seltype", "multiple");
+
+ // Clear the display
+ var oldCount = view._rowCount;
+ view._rowCount = 0;
+ gCookiesWindow._tree.rowCountChanged(0, -oldCount);
+ // Set up the filtered display
+ view._rowCount = view._filterSet.length;
+ gCookiesWindow._tree.rowCountChanged(0, view.rowCount);
+
+ // if the view is not empty then select the first item
+ if (view.rowCount > 0) {
+ view.selection.select(0);
+ }
+
+ document.getElementById("cookiesIntro").value =
+ gCookiesWindow._bundle.getString("cookiesFiltered");
+ },
+
+ setFilter(aFilterString) {
+ document.getElementById("filter").value = aFilterString;
+ this.filter();
+ },
+
+ focusFilterBox() {
+ var filter = document.getElementById("filter");
+ filter.focus();
+ filter.select();
+ },
+};
diff --git a/comm/mail/components/preferences/cookies.xhtml b/comm/mail/components/preferences/cookies.xhtml
new file mode 100644
index 0000000000..63b6ba5a64
--- /dev/null
+++ b/comm/mail/components/preferences/cookies.xhtml
@@ -0,0 +1,117 @@
+<?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"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+
+<!DOCTYPE dialog>
+
+<window id="CookiesDialog"
+ class="windowDialog"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="cookies-window-dialog2"
+ onload="gCookiesWindow.init();"
+ onunload="gCookiesWindow.uninit();"
+ persist="width height">
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://messenger/content/preferences/cookies.js"/>
+
+ <stringbundle id="bundlePreferences"
+ src="chrome://messenger/locale/preferences/preferences.properties"/>
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/cookies.ftl"/>
+ </linkset>
+
+ <keyset>
+ <key data-l10n-id="window-close-key" data-l10n-attrs="key"
+ modifiers="accel" oncommand="window.close();"/>
+ <key data-l10n-id="window-focus-search-key" data-l10n-attrs="key"
+ modifiers="accel" oncommand="gCookiesWindow.focusFilterBox();"/>
+ <key data-l10n-id="window-focus-search-alt-key" data-l10n-attrs="key"
+ modifiers="accel" oncommand="gCookiesWindow.focusFilterBox();"/>
+ </keyset>
+
+ <vbox flex="1" class="contentPane largeDialogContainer">
+ <hbox align="center">
+ <label data-l10n-id="filter-search-label" control="filter"/>
+ <search-textbox id="filter"
+ flex="1"
+ aria-controls="cookiesList"
+ oncommand="gCookiesWindow.filter();"/>
+ </hbox>
+ <separator class="thin"/>
+ <label control="cookiesList" id="cookiesIntro" data-l10n-id="cookies-on-system-label"/>
+ <separator class="thin"/>
+ <tree id="cookiesList" flex="1" style="height: 10em;"
+ onkeypress="gCookiesWindow.onCookieKeyPress(event)"
+ onselect="gCookiesWindow.onCookieSelected();"
+ hidecolumnpicker="true" seltype="single">
+ <treecols>
+ <treecol id="domainCol" data-l10n-id="treecol-site-header"
+ primary="true"
+ persist="width"
+ onclick="gCookiesWindow.sort('rawHost');"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="nameCol" data-l10n-id="treecol-name-header"
+ persist="width"
+ onclick="gCookiesWindow.sort('name');"/>
+ </treecols>
+ <treechildren id="cookiesChildren"/>
+ </tree>
+ <hbox id="cookieInfoSettings" flex="1">
+ <vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="nameLabel" control="name" data-l10n-id="props-name-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="valueLabel" control="value" data-l10n-id="props-value-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="isDomain" control="host" data-l10n-id="props-domain-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="pathLabel" control="path" data-l10n-id="props-path-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="isSecureLabel" control="isSecure" data-l10n-id="props-secure-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="expiresLabel" control="expires" data-l10n-id="props-expires-label"/>
+ </vbox>
+ <vbox id="userContextLabel" flex="1" pack="center" align="end">
+ <label control="userContext" data-l10n-id="props-container-label"/>
+ </vbox>
+ </vbox>
+ <vbox flex="1">
+ <html:input id="name" type="text" readonly="readonly"/>
+ <html:input id="value" type="text" readonly="readonly"/>
+ <html:input id="host" type="text" readonly="readonly"/>
+ <html:input id="path" type="text" readonly="readonly"/>
+ <html:input id="isSecure" type="text" readonly="readonly"/>
+ <html:input id="expires" type="text" readonly="readonly"/>
+ <html:input id="userContext" type="text" readonly="readonly"/>
+ </vbox>
+ </hbox>
+ </vbox>
+ <hbox align="end">
+ <hbox class="actionButtons" flex="1">
+ <button id="removeSelectedCookies" disabled="true"
+ data-l10n-id="remove-cookie-button"
+ oncommand="gCookiesWindow.deleteCookie();"/>
+ <button id="removeAllCookies" disabled="true"
+ data-l10n-id="remove-all-cookies-button"
+ oncommand="gCookiesWindow.deleteAllCookies();"/>
+ <spacer flex="1"/>
+#ifndef XP_MACOSX
+ <button oncommand="window.close();"
+ data-l10n-id="cookie-close-button"/>
+#endif
+ </hbox>
+ </hbox>
+</window>
diff --git a/comm/mail/components/preferences/dockoptions.js b/comm/mail/components/preferences/dockoptions.js
new file mode 100644
index 0000000000..d518150551
--- /dev/null
+++ b/comm/mail/components/preferences/dockoptions.js
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "mail.biff.animate_dock_icon", type: "bool" },
+ { id: "mail.biff.show_badge", type: "bool" },
+ { id: "mail.biff.use_new_count_in_badge", type: "bool" },
+]);
diff --git a/comm/mail/components/preferences/dockoptions.xhtml b/comm/mail/components/preferences/dockoptions.xhtml
new file mode 100644
index 0000000000..978cbe1e2d
--- /dev/null
+++ b/comm/mail/components/preferences/dockoptions.xhtml
@@ -0,0 +1,59 @@
+<?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"?>
+
+<!DOCTYPE window>
+
+<window type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="dock-options-window-dialog2"
+ style="min-width: 33em;">
+ <dialog id="DockOptionsDialog"
+ dlgbuttons="accept,cancel">
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/dock-options.ftl"/>
+ </linkset>
+ <hbox orient="vertical">
+#ifdef XP_MACOSX
+ <checkbox id="newMailNotificationBounce"
+ data-l10n-id="bounce-system-dock-icon"
+ preference="mail.biff.animate_dock_icon"/>
+#endif
+#ifdef XP_WIN
+ <checkbox id="newMailBadge"
+ data-l10n-id="dock-options-show-badge"
+ preference="mail.biff.show_badge"/>
+#endif
+ <separator class="thin"/>
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="dock-icon-legend"></html:legend>
+ <vbox>
+ <separator class="thin"/>
+ <label data-l10n-id="dock-icon-show-label"/>
+ <radiogroup id="dockCount"
+ preference="mail.biff.use_new_count_in_badge"
+ class="indent" orient="vertical">
+ <radio id="dockCountAll" value="false"
+ data-l10n-id="count-unread-messages-radio"/>
+ <radio id="dockCountNew" value="true"
+ data-l10n-id="count-new-messages-radio"/>
+ </radiogroup>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+#ifdef XP_MACOSX
+ <separator/>
+ <description class="bold" data-l10n-id="notification-settings-info2"/>
+#endif
+ </hbox>
+
+ <script src="chrome://global/content/preferencesBindings.js"/>
+ <script src="chrome://messenger/content/preferences/dockoptions.js"/>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/downloads.js b/comm/mail/components/preferences/downloads.js
new file mode 100644
index 0000000000..ede1543492
--- /dev/null
+++ b/comm/mail/components/preferences/downloads.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/. */
+
+/* import-globals-from preferences.js */
+
+var { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+
+Preferences.addAll([
+ { id: "browser.download.useDownloadDir", type: "bool" },
+ { id: "browser.download.folderList", type: "int" },
+ { id: "browser.download.downloadDir", type: "file" },
+ { id: "browser.download.dir", type: "file" },
+ { id: "pref.downloads.disable_button.edit_actions", type: "bool" },
+]);
+
+var gDownloadDirSection = {
+ async chooseFolder() {
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ var bundlePreferences = document.getElementById("bundlePreferences");
+ var title = bundlePreferences.getString("chooseAttachmentsFolderTitle");
+ fp.init(window, title, Ci.nsIFilePicker.modeGetFolder);
+
+ var customDirPref = Preferences.get("browser.download.dir");
+ if (customDirPref.value) {
+ fp.displayDirectory = customDirPref.value;
+ }
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ let rv = await new Promise(resolve => fp.open(resolve));
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ return;
+ }
+
+ let file = fp.file.QueryInterface(Ci.nsIFile);
+ let currentDirPref = Preferences.get("browser.download.downloadDir");
+ customDirPref.value = currentDirPref.value = file;
+ let folderListPref = Preferences.get("browser.download.folderList");
+ folderListPref.value = await this._fileToIndex(file);
+ },
+
+ onReadUseDownloadDir() {
+ this.readDownloadDirPref();
+ var downloadFolder = document.getElementById("downloadFolder");
+ var chooseFolder = document.getElementById("chooseFolder");
+ var preference = Preferences.get("browser.download.useDownloadDir");
+ var dirPreference = Preferences.get("browser.download.dir");
+ downloadFolder.disabled = !preference.value || dirPreference.locked;
+ chooseFolder.disabled = !preference.value || dirPreference.locked;
+ return undefined;
+ },
+
+ async _fileToIndex(aFile) {
+ if (!aFile || aFile.equals(await this._getDownloadsFolder("Desktop"))) {
+ return 0;
+ } else if (aFile.equals(await this._getDownloadsFolder("Downloads"))) {
+ return 1;
+ }
+ return 2;
+ },
+
+ async _indexToFile(aIndex) {
+ switch (aIndex) {
+ case 0:
+ return this._getDownloadsFolder("Desktop");
+ case 1:
+ return this._getDownloadsFolder("Downloads");
+ }
+ var customDirPref = Preferences.get("browser.download.dir");
+ return customDirPref.value;
+ },
+
+ async _getDownloadsFolder(aFolder) {
+ switch (aFolder) {
+ case "Desktop":
+ return Services.dirsvc.get("Desk", Ci.nsIFile);
+ case "Downloads":
+ let downloadsDir = await Downloads.getSystemDownloadsDirectory();
+ return new FileUtils.File(downloadsDir);
+ }
+ throw new Error(
+ "ASSERTION FAILED: folder type should be 'Desktop' or 'Downloads'"
+ );
+ },
+
+ async readDownloadDirPref() {
+ var folderListPref = Preferences.get("browser.download.folderList");
+ var bundlePreferences = document.getElementById("bundlePreferences");
+ var downloadFolder = document.getElementById("downloadFolder");
+
+ var customDirPref = Preferences.get("browser.download.dir");
+ var customIndex = customDirPref.value
+ ? await this._fileToIndex(customDirPref.value)
+ : 0;
+ if (customIndex == 0) {
+ downloadFolder.value = bundlePreferences.getString("desktopFolderName");
+ } else if (customIndex == 1) {
+ downloadFolder.value = bundlePreferences.getString(
+ "myDownloadsFolderName"
+ );
+ } else {
+ downloadFolder.value = customDirPref.value
+ ? customDirPref.value.path
+ : "";
+ }
+
+ var currentDirPref = Preferences.get("browser.download.downloadDir");
+ var downloadDir =
+ currentDirPref.value || (await this._indexToFile(folderListPref.value));
+ if (downloadDir) {
+ let urlSpec = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getURLSpecFromDir(downloadDir);
+
+ downloadFolder.style.backgroundImage =
+ "url(moz-icon://" + urlSpec + "?size=16)";
+ }
+
+ return undefined;
+ },
+};
+
+Preferences.get("browser.download.dir").on(
+ "change",
+ gDownloadDirSection.readDownloadDirPref.bind(gDownloadDirSection)
+);
diff --git a/comm/mail/components/preferences/extensionControlled.js b/comm/mail/components/preferences/extensionControlled.js
new file mode 100644
index 0000000000..5dccb348bc
--- /dev/null
+++ b/comm/mail/components/preferences/extensionControlled.js
@@ -0,0 +1,129 @@
+/* - 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 preferences.js */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+
+const API_PROXY_PREFS = [
+ "network.proxy.type",
+ "network.proxy.http",
+ "network.proxy.http_port",
+ "network.proxy.share_proxy_settings",
+ "network.proxy.ssl",
+ "network.proxy.ssl_port",
+ "network.proxy.socks",
+ "network.proxy.socks_port",
+ "network.proxy.socks_version",
+ "network.proxy.socks_remote_dns",
+ "network.proxy.no_proxies_on",
+ "network.proxy.autoconfig_url",
+ "signon.autologin.proxy",
+];
+
+/**
+ * Check if a pref is being managed by an extension.
+ *
+ * NOTE: We only currently handle proxy.settings.
+ */
+/**
+ * Get the addon extension that is controlling the proxy settings.
+ *
+ * @returns - The found addon, or undefined if none was found.
+ */
+async function getControllingProxyExtensionAddon() {
+ await ExtensionSettingsStore.initialize();
+ let id = ExtensionSettingsStore.getSetting("prefs", "proxy.settings")?.id;
+ if (id) {
+ return AddonManager.getAddonByID(id);
+ }
+ return undefined;
+}
+
+/**
+ * Show or hide the proxy extension message depending on whether or not the
+ * proxy settings are controlled by an extension.
+ *
+ * @returns {boolean} - Whether the proxy settings are controlled by an
+ * extension.
+ */
+async function handleControllingProxyExtension() {
+ let addon = await getControllingProxyExtensionAddon();
+ if (addon) {
+ showControllingProxyExtension(addon);
+ } else {
+ hideControllingProxyExtension();
+ }
+ return !!addon;
+}
+
+/**
+ * Show the proxy extension message.
+ *
+ * @param {object} addon - The addon extension that is currently controlling the
+ * proxy settings.
+ * @param {string} addon.name - The addon name.
+ * @param {string} [addon.iconUrl] - The addon icon source.
+ */
+function showControllingProxyExtension(addon) {
+ let description = document.getElementById("proxyExtensionDescription");
+ description
+ .querySelector("img")
+ .setAttribute(
+ "src",
+ addon.iconUrl || "chrome://mozapps/skin/extensions/extensionGeneric.svg"
+ );
+ document.l10n.setAttributes(
+ description,
+ "proxy-settings-controlled-by-extension",
+ { name: addon.name }
+ );
+
+ document.getElementById("proxyExtensionContent").hidden = false;
+}
+
+/**
+ * Hide the proxy extension message.
+ */
+function hideControllingProxyExtension() {
+ document.getElementById("proxyExtensionContent").hidden = true;
+}
+
+/**
+ * Disable the addon extension that is currently controlling the proxy settings.
+ */
+function disableControllingProxyExtension() {
+ getControllingProxyExtensionAddon().then(addon => addon?.disable());
+}
+
+/**
+ * Start listening to the proxy settings, and update the UI accordingly.
+ *
+ * @param {object} container - The proxy container.
+ * @param {Function} container.updateProxySettingsUI - A callback to call
+ * whenever the proxy settings change.
+ */
+function initializeProxyUI(container) {
+ let deferredUpdate = new DeferredTask(() => {
+ container.updateProxySettingsUI();
+ }, 10);
+ let proxyObserver = {
+ observe: (subject, topic, data) => {
+ if (API_PROXY_PREFS.includes(data)) {
+ deferredUpdate.arm();
+ }
+ },
+ };
+ Services.prefs.addObserver("", proxyObserver);
+ window.addEventListener("unload", () => {
+ Services.prefs.removeObserver("", proxyObserver);
+ });
+}
diff --git a/comm/mail/components/preferences/findInPage.js b/comm/mail/components/preferences/findInPage.js
new file mode 100644
index 0000000000..c69e8b50b6
--- /dev/null
+++ b/comm/mail/components/preferences/findInPage.js
@@ -0,0 +1,641 @@
+/* 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 extensionControlled.js */
+/* import-globals-from preferences.js */
+
+// A tweak to the standard <button> CE to use textContent on the <label>
+// inside the button, which allows the text to be highlighted when the user
+// is searching.
+
+const MozButton = customElements.get("button");
+class HighlightableButton extends MozButton {
+ static get inheritedAttributes() {
+ return Object.assign({}, super.inheritedAttributes, {
+ ".button-text": "text=label,accesskey,crop",
+ });
+ }
+}
+customElements.define("highlightable-button", HighlightableButton, {
+ extends: "button",
+});
+
+var gSearchResultsPane = {
+ listSearchTooltips: new Set(),
+ listSearchMenuitemIndicators: new Set(),
+ searchInput: null,
+ // A map of DOM Elements to a string of keywords used in search.
+ // XXX: We should invalidate this cache on `intl:app-locales-changed`.
+ searchKeywords: new WeakMap(),
+ inited: false,
+
+ init() {
+ if (this.inited) {
+ return;
+ }
+ this.inited = true;
+ this.searchInput = document.getElementById("searchInput");
+ this.searchInput.hidden = !Services.prefs.getBoolPref(
+ "browser.preferences.search"
+ );
+ if (!this.searchInput.hidden) {
+ this.searchInput.addEventListener("input", this);
+ this.searchInput.addEventListener("command", this);
+ window.addEventListener("DOMContentLoaded", () => {
+ this.searchInput.focus();
+ });
+ // Initialize other panes in an idle callback.
+ window.requestIdleCallback(() => this.initializeCategories());
+ }
+ let helpUrl =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "preferences";
+ let helpContainer = document.getElementById("need-help");
+ helpContainer.querySelector("a").href = helpUrl;
+ },
+
+ async handleEvent(event) {
+ // Ensure categories are initialized if idle callback didn't run soon enough.
+ await this.initializeCategories();
+ this.searchFunction(event);
+ },
+
+ /**
+ * Check that the text content contains the query string.
+ *
+ * @param {string} content the text content to be searched.
+ * @param {string} query the query string.
+ *
+ * @returns {boolean} true when the text content contains the query string else false.
+ */
+ queryMatchesContent(content, query) {
+ if (!content || !query) {
+ return false;
+ }
+ return content.toLowerCase().includes(query.toLowerCase());
+ },
+
+ categoriesInitialized: false,
+
+ /**
+ * Will attempt to initialize all uninitialized categories.
+ */
+ async initializeCategories() {
+ // Initializing all the JS for all the tabs.
+ if (!this.categoriesInitialized) {
+ this.categoriesInitialized = true;
+ // Each element of gCategoryInits is a name.
+ for (let [name, category] of gCategoryInits) {
+ if (
+ (name != "paneCalendar" && !category.inited) ||
+ (calendarDeactivator.isCalendarActivated && !category.inited)
+ ) {
+ await category.init();
+ }
+ }
+ let lastSelected = Services.xulStore.getValue(
+ "about:preferences",
+ "paneDeck",
+ "lastSelected"
+ );
+ search(lastSelected, "data-category");
+ }
+ },
+
+ /**
+ * Finds and returns text nodes within node and all descendants.
+ * Iterates through all the sibilings of the node object and adds the sibilings
+ * to an array if sibling is a TEXT_NODE else checks the text nodes with in current node.
+ * Source - http://stackoverflow.com/questions/10730309/find-all-text-nodes-in-html-page
+ *
+ * @param {Node} node DOM element.
+ *
+ * @returns {Node[]} array of text nodes.
+ */
+ textNodeDescendants(node) {
+ if (!node) {
+ return [];
+ }
+ let all = [];
+ for (node = node.firstChild; node; node = node.nextSibling) {
+ if (node.nodeType === node.TEXT_NODE) {
+ all.push(node);
+ } else {
+ all = all.concat(this.textNodeDescendants(node));
+ }
+ }
+ return all;
+ },
+
+ /**
+ * This function is used to find words contained within the text nodes.
+ * We pass in the textNodes because they contain the text to be highlighted.
+ * We pass in the nodeSizes to tell exactly where highlighting need be done.
+ * When creating the range for highlighting, if the nodes are section is split
+ * by an access key, it is important to have the size of each of the nodes summed.
+ *
+ * @param {Node[]} textNodes List of DOM elements.
+ * @param {Node[]} nodeSizes Running size of text nodes. This will contain the same
+ * number of elements as textNodes. The first element is the size of first textNode element.
+ * For any nodes after, they will contain the summation of the nodes thus far in the array.
+ * Example:
+ * textNodes = [[This is ], [a], [n example]]
+ * nodeSizes = [[8], [9], [18]]
+ * This is used to determine the offset when highlighting.
+ * @param {string} textSearch Concatenation of textNodes's text content.
+ * Example:
+ * textNodes = [[This is ], [a], [n example]]
+ * nodeSizes = "This is an example"
+ * This is used when executing the regular expression.
+ * @param {string} searchPhrase word or words to search for.
+ *
+ * @returns {boolean} Returns true when atleast one instance of search phrase is found, otherwise false.
+ */
+ highlightMatches(textNodes, nodeSizes, textSearch, searchPhrase) {
+ if (!searchPhrase) {
+ return false;
+ }
+
+ let indices = [];
+ let i = -1;
+ while ((i = textSearch.indexOf(searchPhrase, i + 1)) >= 0) {
+ indices.push(i);
+ }
+
+ // Looping through each spot the searchPhrase is found in the concatenated string.dom-mutation-list.
+ for (let startValue of indices) {
+ let endValue = startValue + searchPhrase.length;
+ let startNode = null;
+ let endNode = null;
+ let nodeStartIndex = null;
+
+ // Determining the start and end node to highlight from.
+ for (let index = 0; index < nodeSizes.length; index++) {
+ let lengthNodes = nodeSizes[index];
+ // Determining the start node.
+ if (!startNode && lengthNodes >= startValue) {
+ startNode = textNodes[index];
+ nodeStartIndex = index;
+ // Calculating the offset when found query is not in the first node.
+ if (index > 0) {
+ startValue -= nodeSizes[index - 1];
+ }
+ }
+ // Determining the end node.
+ if (!endNode && lengthNodes >= endValue) {
+ endNode = textNodes[index];
+ // Calculating the offset when endNode is different from startNode
+ // or when endNode is not the first node.
+ if (index != nodeStartIndex || index > 0) {
+ endValue -= nodeSizes[index - 1];
+ }
+ }
+ }
+ let range = document.createRange();
+ range.setStart(startNode, startValue);
+ range.setEnd(endNode, endValue);
+ this.getFindSelection(startNode.ownerGlobal).addRange(range);
+ }
+
+ return !!indices.length;
+ },
+
+ /**
+ * Get the selection instance from given window.
+ *
+ * @param {object} win The window object points to frame's window.
+ */
+ getFindSelection(win) {
+ // Yuck. See bug 138068.
+ let docShell = win.docShell;
+
+ let controller = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsISelectionDisplay)
+ .QueryInterface(Ci.nsISelectionController);
+
+ let selection = controller.getSelection(
+ Ci.nsISelectionController.SELECTION_FIND
+ );
+ selection.setColors("currentColor", "#ffe900", "currentColor", "#003eaa");
+
+ return selection;
+ },
+
+ /**
+ * Shows or hides content according to search input.
+ *
+ * @param {object} event to search for filted query in.
+ */
+ async searchFunction(event) {
+ let query = event.target.value.trim().toLowerCase();
+ if (this.query == query) {
+ return;
+ }
+
+ let subQuery = this.query && query.includes(this.query);
+ this.query = query;
+
+ this.getFindSelection(window).removeAllRanges();
+ this.removeAllSearchTooltips();
+ this.removeAllSearchMenuitemIndicators();
+
+ let srHeader = document.getElementById("header-searchResults");
+ let noResultsEl = document.getElementById("no-results-message");
+ if (this.query) {
+ // Showing the Search Results Tag.
+ await gotoPref("paneSearchResults");
+ srHeader.hidden = false;
+
+ let resultsFound = false;
+
+ // Building the range for highlighted areas.
+ let rootPreferencesChildren = [
+ ...document.querySelectorAll(
+ "#paneDeck > *:not([data-hidden-from-search],script,stringbundle,commandset,keyset,linkset)"
+ ),
+ ];
+
+ if (subQuery) {
+ // Since the previous query is a subset of the current query,
+ // there is no need to check elements that is hidden already.
+ rootPreferencesChildren = rootPreferencesChildren.filter(
+ el => !el.hidden
+ );
+ }
+
+ // Attach the bindings for all children if they were not already visible.
+ for (let child of rootPreferencesChildren) {
+ if (child.hidden) {
+ child.classList.add("visually-hidden");
+ child.hidden = false;
+ }
+ }
+
+ let ts = performance.now();
+ let FRAME_THRESHOLD = 10;
+
+ // Showing or Hiding specific section depending on if words in query are found.
+ for (let child of rootPreferencesChildren) {
+ if (performance.now() - ts > FRAME_THRESHOLD) {
+ // Creating tooltips for all the instances found.
+ for (let anchorNode of this.listSearchTooltips) {
+ this.createSearchTooltip(anchorNode, this.query);
+ }
+ ts = await new Promise(resolve =>
+ window.requestAnimationFrame(resolve)
+ );
+ if (query !== this.query) {
+ return;
+ }
+ }
+
+ if (
+ !child.classList.contains("header") &&
+ !child.classList.contains("subcategory") &&
+ (await this.searchWithinNode(child, this.query))
+ ) {
+ child.classList.remove("visually-hidden");
+
+ // Show the preceding search-header if one exists.
+ let groupbox = child.closest("groupbox");
+ let groupHeader =
+ groupbox && groupbox.querySelector(".search-header");
+ if (groupHeader) {
+ groupHeader.hidden = false;
+ }
+
+ resultsFound = true;
+ } else {
+ child.classList.add("visually-hidden");
+ }
+ }
+
+ noResultsEl.hidden = !!resultsFound;
+ noResultsEl.setAttribute("query", this.query);
+ // XXX: This is potentially racy in case where Fluent retranslates the
+ // message and ereases the query within.
+ // The feature is not yet supported, but we should fix for it before
+ // we enable it. See bug 1446389 for details.
+ let msgQueryElem = document.getElementById("sorry-message-query");
+ msgQueryElem.textContent = this.query;
+ if (resultsFound) {
+ // Creating tooltips for all the instances found.
+ for (let anchorNode of this.listSearchTooltips) {
+ this.createSearchTooltip(anchorNode, this.query);
+ }
+ }
+ } else {
+ noResultsEl.hidden = true;
+ document.getElementById("sorry-message-query").textContent = "";
+ // Going back to General when cleared.
+ await gotoPref("paneGeneral");
+ srHeader.hidden = true;
+
+ // Hide some special second level headers in normal view.
+ for (let element of document.querySelectorAll(".search-header")) {
+ element.hidden = true;
+ }
+ }
+
+ window.dispatchEvent(
+ new CustomEvent("PreferencesSearchCompleted", { detail: query })
+ );
+ },
+
+ /**
+ * Finding leaf nodes and checking their content for words to search,
+ * It is a recursive function.
+ *
+ * @param {Node} nodeObject DOM Element.
+ * @param {string} searchPhrase
+ *
+ * @returns {boolean} Returns true when found in at least one childNode, false otherwise.
+ */
+ async searchWithinNode(nodeObject, searchPhrase) {
+ let matchesFound = false;
+ if (
+ nodeObject.childElementCount == 0 ||
+ nodeObject.tagName == "button" ||
+ nodeObject.tagName == "label" ||
+ nodeObject.tagName == "description" ||
+ nodeObject.tagName == "menulist" ||
+ nodeObject.tagName == "menuitem"
+ ) {
+ let simpleTextNodes = this.textNodeDescendants(nodeObject);
+ for (let node of simpleTextNodes) {
+ let result = this.highlightMatches(
+ [node],
+ [node.length],
+ node.textContent.toLowerCase(),
+ searchPhrase
+ );
+ matchesFound = matchesFound || result;
+ }
+
+ // Collecting data from anonymous content / label / description.
+ let nodeSizes = [];
+ let allNodeText = "";
+ let runningSize = 0;
+
+ let accessKeyTextNodes = [];
+
+ if (
+ nodeObject.tagName == "label" ||
+ nodeObject.tagName == "description"
+ ) {
+ accessKeyTextNodes.push(...simpleTextNodes);
+ }
+
+ for (let node of accessKeyTextNodes) {
+ runningSize += node.textContent.length;
+ allNodeText += node.textContent;
+ nodeSizes.push(runningSize);
+ }
+
+ // Access key are presented.
+ let complexTextNodesResult = this.highlightMatches(
+ accessKeyTextNodes,
+ nodeSizes,
+ allNodeText.toLowerCase(),
+ searchPhrase
+ );
+
+ // Searching some elements, such as xul:button, have a 'label' attribute
+ // that contains the user-visible text.
+ let labelResult = this.queryMatchesContent(
+ nodeObject.getAttribute("label"),
+ searchPhrase
+ );
+
+ // Searching some elements, such as xul:label, store their user-visible
+ // text in a "value" attribute. Value will be skipped for menuitem since
+ // value in menuitem could represent index number to distinct each item.
+ let valueResult =
+ nodeObject.tagName !== "menuitem" && nodeObject.tagName !== "radio"
+ ? this.queryMatchesContent(
+ nodeObject.getAttribute("value"),
+ searchPhrase
+ )
+ : false;
+
+ // Searching some elements, such as xul:button, buttons to open subdialogs
+ // using l10n ids.
+ let keywordsResult =
+ nodeObject.hasAttribute("search-l10n-ids") &&
+ (await this.matchesSearchL10nIDs(nodeObject, searchPhrase));
+
+ if (!keywordsResult) {
+ // Searching some elements, such as xul:button, buttons to open subdialogs
+ // using searchkeywords attribute.
+ keywordsResult =
+ !keywordsResult &&
+ nodeObject.hasAttribute("searchkeywords") &&
+ this.queryMatchesContent(
+ nodeObject.getAttribute("searchkeywords"),
+ searchPhrase
+ );
+ }
+
+ // Creating tooltips for buttons.
+ if (
+ keywordsResult &&
+ (nodeObject.tagName === "button" || nodeObject.tagName == "menulist")
+ ) {
+ this.listSearchTooltips.add(nodeObject);
+ }
+
+ if (keywordsResult && nodeObject.tagName === "menuitem") {
+ nodeObject.setAttribute("indicator", "true");
+ this.listSearchMenuitemIndicators.add(nodeObject);
+ let menulist = nodeObject.closest("menulist");
+
+ menulist.setAttribute("indicator", "true");
+ this.listSearchMenuitemIndicators.add(menulist);
+ }
+
+ if (
+ (nodeObject.tagName == "menulist" ||
+ nodeObject.tagName == "menuitem") &&
+ (labelResult || valueResult || keywordsResult)
+ ) {
+ nodeObject.setAttribute("highlightable", "true");
+ }
+
+ matchesFound =
+ matchesFound ||
+ complexTextNodesResult ||
+ labelResult ||
+ valueResult ||
+ keywordsResult;
+ }
+
+ for (let i = 0; i < nodeObject.childNodes.length; i++) {
+ let result = await this.searchChildNodeIfVisible(
+ nodeObject,
+ i,
+ searchPhrase
+ );
+ matchesFound = matchesFound || result;
+ }
+ return matchesFound;
+ },
+
+ /**
+ * Search for a phrase within a child node if it is visible.
+ *
+ * @param {Node} nodeObject The parent DOM Element.
+ * @param {number} index The index for the childNode.
+ * @param {string} searchPhrase
+ *
+ * @returns {boolean} Returns true when found the specific childNode, false otherwise
+ */
+ async searchChildNodeIfVisible(nodeObject, index, searchPhrase) {
+ let result = false;
+ if (
+ !nodeObject.childNodes[index].hidden &&
+ nodeObject.getAttribute("data-hidden-from-search") !== "true"
+ ) {
+ result = await this.searchWithinNode(
+ nodeObject.childNodes[index],
+ searchPhrase
+ );
+ // Creating tooltips for menulist element.
+ if (result && nodeObject.tagName === "menulist") {
+ this.listSearchTooltips.add(nodeObject);
+ }
+ }
+ return result;
+ },
+
+ /**
+ * Search for a phrase in l10n messages associated with the element.
+ *
+ * @param {Node} nodeObject The parent DOM Element.
+ * @param {string} searchPhrase.
+ * @returns {boolean} true when the text content contains the query string else false.
+ */
+ async matchesSearchL10nIDs(nodeObject, searchPhrase) {
+ if (!this.searchKeywords.has(nodeObject)) {
+ // The `search-l10n-ids` attribute is a comma-separated list of
+ // l10n ids. It may also uses a dot notation to specify an attribute
+ // of the message to be used.
+ //
+ // Example: "containers-add-button.label, user-context-personal".
+ //
+ // The result is an array of arrays of l10n ids and optionally attribute names.
+ //
+ // Example: [["containers-add-button", "label"], ["user-context-personal"]]
+ const refs = nodeObject
+ .getAttribute("search-l10n-ids")
+ .split(",")
+ .map(s => s.trim().split("."))
+ .filter(s => !!s[0].length);
+
+ const messages = await document.l10n.formatMessages(
+ refs.map(ref => ({ id: ref[0] }))
+ );
+
+ // Map the localized messages taking value or a selected attribute and
+ // building a string of concatenated translated strings out of it.
+ let keywords = messages
+ .map((msg, i) => {
+ let [refId, refAttr] = refs[i];
+ if (!msg) {
+ console.error(`Missing search l10n id "${refId}"`);
+ return null;
+ }
+ if (refAttr) {
+ let attr =
+ msg.attributes && msg.attributes.find(a => a.name === refAttr);
+ if (!attr) {
+ console.error(`Missing search l10n id "${refId}.${refAttr}"`);
+ return null;
+ }
+ if (attr.value === "") {
+ console.error(
+ `Empty value added to search-l10n-ids "${refId}.${refAttr}"`
+ );
+ }
+ return attr.value;
+ }
+ if (msg.value === "") {
+ console.error(`Empty value added to search-l10n-ids "${refId}"`);
+ }
+ return msg.value;
+ })
+ .filter(keyword => keyword !== null)
+ .join(" ");
+
+ this.searchKeywords.set(nodeObject, keywords);
+ return this.queryMatchesContent(keywords, searchPhrase);
+ }
+
+ return this.queryMatchesContent(
+ this.searchKeywords.get(nodeObject),
+ searchPhrase
+ );
+ },
+
+ /**
+ * Inserting a div structure infront of the DOM element matched textContent.
+ * Then calculation the offsets to position the tooltip in the correct place.
+ *
+ * @param {Node} anchorNode DOM Element.
+ * @param {string} query Word or words that are being searched for.
+ */
+ createSearchTooltip(anchorNode, query) {
+ if (anchorNode.tooltipNode) {
+ return;
+ }
+ let searchTooltip = anchorNode.ownerDocument.createElement("span");
+ let searchTooltipText = anchorNode.ownerDocument.createElement("span");
+ searchTooltip.className = "search-tooltip";
+ searchTooltipText.textContent = query;
+ searchTooltip.appendChild(searchTooltipText);
+
+ // Set tooltipNode property to track corresponded tooltip node.
+ anchorNode.tooltipNode = searchTooltip;
+ anchorNode.parentElement.classList.add("search-tooltip-parent");
+ anchorNode.parentElement.appendChild(searchTooltip);
+
+ this.calculateTooltipPosition(anchorNode);
+ },
+
+ calculateTooltipPosition(anchorNode) {
+ let searchTooltip = anchorNode.tooltipNode;
+ // In order to get the up-to-date position of each of the nodes that we're
+ // putting tooltips on, we have to flush layout intentionally, and that
+ // this is the result of a XUL limitation (bug 1363730).
+ let tooltipRect = searchTooltip.getBoundingClientRect();
+ searchTooltip.style.setProperty(
+ "left",
+ `calc(50% - ${tooltipRect.width / 2}px)`
+ );
+ },
+
+ /**
+ * Remove all search tooltips.
+ */
+ removeAllSearchTooltips() {
+ for (let anchorNode of this.listSearchTooltips) {
+ anchorNode.parentElement.classList.remove("search-tooltip-parent");
+ if (anchorNode.tooltipNode) {
+ anchorNode.tooltipNode.remove();
+ }
+ anchorNode.tooltipNode = null;
+ }
+ this.listSearchTooltips.clear();
+ },
+
+ /**
+ * Remove all indicators on menuitem.
+ */
+ removeAllSearchMenuitemIndicators() {
+ for (let node of this.listSearchMenuitemIndicators) {
+ node.removeAttribute("indicator");
+ }
+ this.listSearchMenuitemIndicators.clear();
+ },
+};
diff --git a/comm/mail/components/preferences/fonts.js b/comm/mail/components/preferences/fonts.js
new file mode 100644
index 0000000000..d6a4c1308e
--- /dev/null
+++ b/comm/mail/components/preferences/fonts.js
@@ -0,0 +1,196 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+// toolkit/content/preferencesBindings.js
+/* globals Preferences */
+// toolkit/mozapps/preferences/fontbuilder.js
+/* globals FontBuilder */
+
+var kDefaultFontType = "font.default.%LANG%";
+var kFontNameFmtSerif = "font.name.serif.%LANG%";
+var kFontNameFmtSansSerif = "font.name.sans-serif.%LANG%";
+var kFontNameFmtMonospace = "font.name.monospace.%LANG%";
+var kFontNameListFmtSerif = "font.name-list.serif.%LANG%";
+var kFontNameListFmtSansSerif = "font.name-list.sans-serif.%LANG%";
+var kFontNameListFmtMonospace = "font.name-list.monospace.%LANG%";
+var kFontSizeFmtVariable = "font.size.variable.%LANG%";
+var kFontSizeFmtFixed = "font.size.monospace.%LANG%";
+var kFontMinSizeFmt = "font.minimum-size.%LANG%";
+
+Preferences.addAll([
+ { id: "font.language.group", type: "wstring" },
+ { id: "browser.display.use_document_fonts", type: "int" },
+ { id: "mail.fixed_width_messages", type: "bool" },
+]);
+
+var gFontsDialog = {
+ _selectLanguageGroupPromise: Promise.resolve(),
+
+ init() {
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("selectLangs"),
+ () => gFontsDialog.readFontLanguageGroup()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("serif"),
+ element => FontBuilder.readFontSelection(element)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("sans-serif"),
+ element => FontBuilder.readFontSelection(element)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("monospace"),
+ element => FontBuilder.readFontSelection(element)
+ );
+
+ let element = document.getElementById("useDocumentFonts");
+ Preferences.addSyncFromPrefListener(element, () =>
+ gFontsDialog.readUseDocumentFonts()
+ );
+ Preferences.addSyncToPrefListener(element, () =>
+ gFontsDialog.writeUseDocumentFonts()
+ );
+
+ element = document.getElementById("mailFixedWidthMessages");
+ Preferences.addSyncFromPrefListener(element, () =>
+ gFontsDialog.readFixedWidthForPlainText()
+ );
+ Preferences.addSyncToPrefListener(element, () =>
+ gFontsDialog.writeFixedWidthForPlainText()
+ );
+ },
+
+ _selectLanguageGroup(aLanguageGroup) {
+ this._selectLanguageGroupPromise = (async () => {
+ // Avoid overlapping language group selections by awaiting the resolution
+ // of the previous one. We do this because this function is re-entrant,
+ // as inserting <preference> elements into the DOM sometimes triggers a call
+ // back into this function. And since this function is also asynchronous,
+ // that call can enter this function before the previous run has completed,
+ // which would corrupt the font menulists. Awaiting the previous call's
+ // resolution avoids that fate.
+ await this._selectLanguageGroupPromise;
+
+ var prefs = [
+ {
+ format: kDefaultFontType,
+ type: "string",
+ element: "defaultFontType",
+ fonttype: null,
+ },
+ {
+ format: kFontNameFmtSerif,
+ type: "fontname",
+ element: "serif",
+ fonttype: "serif",
+ },
+ {
+ format: kFontNameFmtSansSerif,
+ type: "fontname",
+ element: "sans-serif",
+ fonttype: "sans-serif",
+ },
+ {
+ format: kFontNameFmtMonospace,
+ type: "fontname",
+ element: "monospace",
+ fonttype: "monospace",
+ },
+ {
+ format: kFontNameListFmtSerif,
+ type: "unichar",
+ element: null,
+ fonttype: "serif",
+ },
+ {
+ format: kFontNameListFmtSansSerif,
+ type: "unichar",
+ element: null,
+ fonttype: "sans-serif",
+ },
+ {
+ format: kFontNameListFmtMonospace,
+ type: "unichar",
+ element: null,
+ fonttype: "monospace",
+ },
+ {
+ format: kFontSizeFmtVariable,
+ type: "int",
+ element: "sizeVar",
+ fonttype: null,
+ },
+ {
+ format: kFontSizeFmtFixed,
+ type: "int",
+ element: "sizeMono",
+ fonttype: null,
+ },
+ {
+ format: kFontMinSizeFmt,
+ type: "int",
+ element: "minSize",
+ fonttype: null,
+ },
+ ];
+ for (var i = 0; i < prefs.length; ++i) {
+ var name = prefs[i].format.replace(/%LANG%/, aLanguageGroup);
+ var preference = Preferences.get(name);
+ if (!preference) {
+ preference = Preferences.add({ id: name, type: prefs[i].type });
+ }
+
+ if (!prefs[i].element) {
+ continue;
+ }
+
+ var element = document.getElementById(prefs[i].element);
+ if (element) {
+ element.setAttribute("preference", preference.id);
+
+ if (prefs[i].fonttype) {
+ await FontBuilder.buildFontList(
+ aLanguageGroup,
+ prefs[i].fonttype,
+ element
+ );
+ }
+ preference.setElementValue(element);
+ }
+ }
+ })().catch(console.error);
+ },
+
+ readFontLanguageGroup() {
+ var languagePref = Preferences.get("font.language.group");
+ this._selectLanguageGroup(languagePref.value);
+ return undefined;
+ },
+
+ readUseDocumentFonts() {
+ var preference = Preferences.get("browser.display.use_document_fonts");
+ return preference.value == 1;
+ },
+
+ writeUseDocumentFonts() {
+ var useDocumentFonts = document.getElementById("useDocumentFonts");
+ return useDocumentFonts.checked ? 1 : 0;
+ },
+
+ readFixedWidthForPlainText() {
+ var preference = Preferences.get("mail.fixed_width_messages");
+ return preference.value == 1;
+ },
+
+ writeFixedWidthForPlainText() {
+ var mailFixedWidthMessages = document.getElementById(
+ "mailFixedWidthMessages"
+ );
+ return mailFixedWidthMessages.checked;
+ },
+};
+
+window.addEventListener("load", () => gFontsDialog.init());
diff --git a/comm/mail/components/preferences/fonts.xhtml b/comm/mail/components/preferences/fonts.xhtml
new file mode 100644
index 0000000000..4bb793a04d
--- /dev/null
+++ b/comm/mail/components/preferences/fonts.xhtml
@@ -0,0 +1,337 @@
+<?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 https://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE html>
+<html
+ id="FontsDialog"
+ 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"
+ type="child"
+ persist="lastSelected"
+ scrolling="false"
+ style="min-width: 60ch"
+>
+ <head>
+ <title data-l10n-id="fonts-dialog-title"></title>
+ <link rel="localization" href="messenger/preferences/fonts.ftl" />
+ <script
+ defer="defer"
+ src="chrome://global/content/preferencesBindings.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://mozapps/content/preferences/fontbuilder.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/menulist-charsetpicker.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/preferences/fonts.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog buttons="accept,cancel" style="min-height: 100vh">
+ <keyset>
+ <key
+ data-l10n-id="fonts-window-close"
+ modifiers="accel"
+ oncommand="Preferences.close(event)"
+ />
+ </keyset>
+
+ <html:fieldset>
+ <!-- title row -->
+ <hbox>
+ <hbox align="center" pack="end">
+ <label
+ id="fontsTitle"
+ control="selectLangs"
+ data-l10n-id="fonts-language-legend"
+ />
+ </hbox>
+ <menulist id="selectLangs" flex="1" preference="font.language.group">
+ <menupopup>
+ <menuitem value="ar" data-l10n-id="font-language-group-arabic" />
+ <menuitem
+ value="x-armn"
+ data-l10n-id="font-language-group-armenian"
+ />
+ <menuitem
+ value="x-beng"
+ data-l10n-id="font-language-group-bengali"
+ />
+ <menuitem
+ value="zh-CN"
+ data-l10n-id="font-language-group-simpl-chinese"
+ />
+ <menuitem
+ value="zh-HK"
+ data-l10n-id="font-language-group-trad-chinese-hk"
+ />
+ <menuitem
+ value="zh-TW"
+ data-l10n-id="font-language-group-trad-chinese"
+ />
+ <menuitem
+ value="x-cyrillic"
+ data-l10n-id="font-language-group-cyrillic"
+ />
+ <menuitem
+ value="x-devanagari"
+ data-l10n-id="font-language-group-devanagari"
+ />
+ <menuitem
+ value="x-ethi"
+ data-l10n-id="font-language-group-ethiopic"
+ />
+ <menuitem
+ value="x-geor"
+ data-l10n-id="font-language-group-georgian"
+ />
+ <menuitem value="el" data-l10n-id="font-language-group-el" />
+ <menuitem
+ value="x-gujr"
+ data-l10n-id="font-language-group-gujarati"
+ />
+ <menuitem
+ value="x-guru"
+ data-l10n-id="font-language-group-gurmukhi"
+ />
+ <menuitem value="he" data-l10n-id="font-language-group-hebrew" />
+ <menuitem
+ value="ja"
+ data-l10n-id="font-language-group-japanese"
+ />
+ <menuitem
+ value="x-knda"
+ data-l10n-id="font-language-group-kannada"
+ />
+ <menuitem
+ value="x-khmr"
+ data-l10n-id="font-language-group-khmer"
+ />
+ <menuitem value="ko" data-l10n-id="font-language-group-korean" />
+ <menuitem
+ value="x-western"
+ data-l10n-id="font-language-group-latin"
+ />
+ <menuitem
+ value="x-mlym"
+ data-l10n-id="font-language-group-malayalam"
+ />
+ <menuitem
+ value="x-math"
+ data-l10n-id="font-language-group-math"
+ />
+ <menuitem
+ value="x-orya"
+ data-l10n-id="font-language-group-odia"
+ />
+ <menuitem
+ value="x-sinh"
+ data-l10n-id="font-language-group-sinhala"
+ />
+ <menuitem
+ value="x-tamil"
+ data-l10n-id="font-language-group-tamil"
+ />
+ <menuitem
+ value="x-telu"
+ data-l10n-id="font-language-group-telugu"
+ />
+ <menuitem value="th" data-l10n-id="font-language-group-thai" />
+ <menuitem
+ value="x-tibt"
+ data-l10n-id="font-language-group-tibetan"
+ />
+ <menuitem
+ value="x-cans"
+ data-l10n-id="font-language-group-canadian"
+ />
+ <menuitem
+ value="x-unicode"
+ data-l10n-id="font-language-group-other"
+ />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <separator />
+ <box id="font-chooser-group">
+ <!-- proportional row -->
+ <hbox align="center" pack="end">
+ <label data-l10n-id="fonts-proportional-label" />
+ </hbox>
+ <menulist id="defaultFontType">
+ <menupopup>
+ <menuitem value="serif" data-l10n-id="default-font-serif" />
+ <menuitem
+ value="sans-serif"
+ data-l10n-id="default-font-sans-serif"
+ />
+ </menupopup>
+ </menulist>
+ <hbox align="center" pack="end">
+ <label
+ control="sizeVar"
+ data-l10n-id="font-size-proportional-label"
+ class="startSpacing"
+ />
+ </hbox>
+ <menulist id="sizeVar" delayprefsave="true">
+ <menupopup>
+ <menuitem value="9" label="9" />
+ <menuitem value="10" label="10" />
+ <menuitem value="11" label="11" />
+ <menuitem value="12" label="12" />
+ <menuitem value="13" label="13" />
+ <menuitem value="14" label="14" />
+ <menuitem value="15" label="15" />
+ <menuitem value="16" label="16" />
+ <menuitem value="17" label="17" />
+ <menuitem value="18" label="18" />
+ <menuitem value="20" label="20" />
+ <menuitem value="22" label="22" />
+ <menuitem value="24" label="24" />
+ <menuitem value="26" label="26" />
+ <menuitem value="28" label="28" />
+ <menuitem value="30" label="30" />
+ <menuitem value="32" label="32" />
+ <menuitem value="34" label="34" />
+ <menuitem value="36" label="36" />
+ <menuitem value="40" label="40" />
+ <menuitem value="44" label="44" />
+ <menuitem value="48" label="48" />
+ <menuitem value="56" label="56" />
+ <menuitem value="64" label="64" />
+ <menuitem value="72" label="72" />
+ </menupopup>
+ </menulist>
+
+ <!-- serif row -->
+ <hbox align="center" pack="end">
+ <label control="serif" data-l10n-id="font-serif-label" />
+ </hbox>
+ <menulist id="serif" delayprefsave="true" />
+ <spacer />
+ <spacer />
+
+ <!-- sans-serif row -->
+ <hbox align="center" pack="end">
+ <label control="sans-serif" data-l10n-id="font-sans-serif-label" />
+ </hbox>
+ <menulist id="sans-serif" delayprefsave="true" />
+ <spacer />
+ <spacer />
+
+ <!-- monospace row -->
+ <hbox align="center" pack="end">
+ <label control="monospace" data-l10n-id="font-monospace-label" />
+ </hbox>
+ <menulist id="monospace" crop="end" delayprefsave="true" />
+ <hbox align="center" pack="end">
+ <label
+ control="sizeMono"
+ data-l10n-id="font-size-monospace-label"
+ class="startSpacing"
+ />
+ </hbox>
+ <menulist id="sizeMono" delayprefsave="true">
+ <menupopup>
+ <menuitem value="9" label="9" />
+ <menuitem value="10" label="10" />
+ <menuitem value="11" label="11" />
+ <menuitem value="12" label="12" />
+ <menuitem value="13" label="13" />
+ <menuitem value="14" label="14" />
+ <menuitem value="15" label="15" />
+ <menuitem value="16" label="16" />
+ <menuitem value="17" label="17" />
+ <menuitem value="18" label="18" />
+ <menuitem value="20" label="20" />
+ <menuitem value="22" label="22" />
+ <menuitem value="24" label="24" />
+ <menuitem value="26" label="26" />
+ <menuitem value="28" label="28" />
+ <menuitem value="30" label="30" />
+ <menuitem value="32" label="32" />
+ <menuitem value="34" label="34" />
+ <menuitem value="36" label="36" />
+ <menuitem value="40" label="40" />
+ <menuitem value="44" label="44" />
+ <menuitem value="48" label="48" />
+ <menuitem value="56" label="56" />
+ <menuitem value="64" label="64" />
+ <menuitem value="72" label="72" />
+ </menupopup>
+ </menulist>
+ </box>
+
+ <separator class="thin" />
+
+ <hbox flex="1">
+ <spacer flex="1" />
+ <hbox align="center" pack="end">
+ <label data-l10n-id="font-min-size-label" control="minSize" />
+ <menulist id="minSize">
+ <menupopup>
+ <menuitem value="0" data-l10n-id="min-size-none" />
+ <menuitem value="9" label="9" />
+ <menuitem value="10" label="10" />
+ <menuitem value="11" label="11" />
+ <menuitem value="12" label="12" />
+ <menuitem value="13" label="13" />
+ <menuitem value="14" label="14" />
+ <menuitem value="15" label="15" />
+ <menuitem value="16" label="16" />
+ <menuitem value="17" label="17" />
+ <menuitem value="18" label="18" />
+ <menuitem value="20" label="20" />
+ <menuitem value="22" label="22" />
+ <menuitem value="24" label="24" />
+ <menuitem value="26" label="26" />
+ <menuitem value="28" label="28" />
+ <menuitem value="30" label="30" />
+ <menuitem value="32" label="32" />
+ <menuitem value="34" label="34" />
+ <menuitem value="36" label="36" />
+ <menuitem value="40" label="40" />
+ <menuitem value="44" label="44" />
+ <menuitem value="48" label="48" />
+ <menuitem value="56" label="56" />
+ <menuitem value="64" label="64" />
+ <menuitem value="72" label="72" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+
+ <html:fieldset>
+ <html:legend data-l10n-id="font-control-legend"></html:legend>
+ <hbox>
+ <checkbox
+ id="useDocumentFonts"
+ data-l10n-id="use-document-fonts-checkbox"
+ preference="browser.display.use_document_fonts"
+ />
+ </hbox>
+ <hbox>
+ <checkbox
+ id="mailFixedWidthMessages"
+ data-l10n-id="use-fixed-width-plain-checkbox"
+ preference="mail.fixed_width_messages"
+ />
+ </hbox>
+ </html:fieldset>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/mail/components/preferences/general.inc.xhtml b/comm/mail/components/preferences/general.inc.xhtml
new file mode 100644
index 0000000000..438624649b
--- /dev/null
+++ b/comm/mail/components/preferences/general.inc.xhtml
@@ -0,0 +1,1096 @@
+# 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/.
+ <script src="chrome://communicator/content/utilityOverlay.js" />
+ <script src="chrome://messenger/content/preferences/general.js"/>
+ <script src="chrome://mozapps/content/preferences/fontbuilder.js"/>
+
+ <commandset id="appPaneCommandSet">
+ <command id="cmd_delete"
+ oncommand="gGeneralPane.onDelete();"/>
+ </commandset>
+
+ <keyset id="appPaneKeyset">
+ <key keycode="VK_BACK" modifiers="any" command="cmd_delete"/>
+ <key keycode="VK_DELETE" modifiers="any" command="cmd_delete"/>
+ </keyset>
+
+ <keyset>
+ <key data-l10n-id="focus-search-shortcut" modifiers="accel"
+ oncommand="gGeneralPane.focusFilterBox();"/>
+ <key data-l10n-id="focus-search-shortcut-alt" modifiers="accel"
+ oncommand="gGeneralPane.focusFilterBox();"/>
+ </keyset>
+
+ <stringbundle id="bundlePreferences" src="chrome://messenger/locale/preferences/preferences.properties"/>
+#ifdef HAVE_SHELL_SERVICE
+ <stringbundle id="bundleBrand" src="chrome://branding/locale/brand.properties"/>
+#endif
+ <html:template id="paneGeneral">
+ <hbox id="generalCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="pane-general-title"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="general-legend"></html:legend>
+ <vbox>
+ <hbox align="start">
+ <checkbox id="mailnewsStartPageEnabled"
+ preference="mailnews.start_page.enabled"
+ data-l10n-id="start-page-label"/>
+ </hbox>
+ <hbox align="center" class="input-container">
+ <label data-l10n-id="location-label" control="mailnewsStartPageUrl"/>
+ <html:input id="mailnewsStartPageUrl"
+ type="url"
+ preference="mailnews.start_page.url"/>
+ <button is="highlightable-button" id="browseForStartPageUrl"
+ data-l10n-id="restore-default-label"
+ oncommand="gGeneralPane.restoreDefaultStartPage();">
+ </button>
+ </hbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="default-search-engine"></html:legend>
+ <hbox align="center">
+ <hbox>
+ <menulist id="defaultWebSearch">
+ <menupopup id="defaultWebSearchPopup"/>
+ </menulist>
+ </hbox>
+ <button is="highlightable-button" id="addSearchEngine"
+ data-l10n-id="add-web-search-engine"
+ oncommand="gGeneralPane.addSearchEngine();"/>
+ <button is="highlightable-button" id="removeSearchEngine"
+ data-l10n-id="remove-search-engine"
+ oncommand="gGeneralPane.removeSearchEngine();"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+#ifdef HAVE_SHELL_SERVICE
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="systemDefaultsGroup" data-category="paneGeneral">
+ <html:legend data-l10n-id="system-integration-legend"></html:legend>
+ <vbox>
+ <hbox id="checkDefaultBox" align="center">
+ <checkbox id="alwaysCheckDefault"
+ preference="mail.shell.checkDefaultClient"
+ data-l10n-id="always-check-default"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="checkDefaultButton"
+ data-l10n-id="check-default-button"
+ oncommand="gGeneralPane.checkDefaultNow();"
+ preference="pref.general.disable_button.default_mail"
+ search-l10n-ids="
+ system-integration-title.title,
+ system-integration-dialog.buttonlabelaccept,
+ system-integration-dialog.buttonlabelcancel,
+ system-integration-dialog.buttonlabelcancel2,
+ default-client-intro,
+ unset-default-tooltip,
+ checkbox-email-label.label,
+ checkbox-newsgroups-label.label,
+ checkbox-feeds-label.label,
+ system-search-integration-label.label,
+ check-on-startup-label.label"/>
+ </hbox>
+ </hbox>
+#ifdef XP_WIN
+ <hbox align="start">
+ <checkbox data-l10n-id="minimize-to-tray-label"
+ preference="mail.minimizeToTray"/>
+ </hbox>
+#endif
+ <hbox id="searchIntegrationContainer">
+ <checkbox id="searchIntegration"
+ preference="searchintegration.enable"
+ data-l10n-id="search-integration-label"/>
+ </hbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+#endif
+
+ <hbox id="languageAndAppearanceCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-language-and-appearance-header"/>
+ </hbox>
+
+ <!-- Window layout -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="layoutGroup" data-category="paneGeneral">
+ <html:legend data-l10n-id="window-layout-legend"></html:legend>
+ <hbox>
+ <checkbox id="drawInTitlebar"
+ data-l10n-id="draw-in-titlebar-label"
+ preference="mail.tabs.drawInTitlebar"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <vbox>
+ <checkbox id="autoHideTabbar"
+ data-l10n-id="auto-hide-tabbar-label"
+ preference="mail.tabs.autoHide"/>
+ <description data-l10n-id="auto-hide-tabbar-description"
+ class="tip-caption indent"/>
+ </vbox>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Fonts and Colors -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="fontsGroup" data-category="paneGeneral">
+ <html:legend data-l10n-id="fonts-legend"></html:legend>
+
+ <hbox id="fontSettings" flex="1">
+ <vbox id="fontRow" flex="1">
+ <hbox align="center">
+ <label data-l10n-id="default-font-label" control="defaultFont"/>
+ <hbox flex="1">
+ <menulist id="defaultFont" flex="1" sizetopopup="pref" crop="center">
+ <menupopup crop="center"/>
+ </menulist>
+ </hbox>
+ <label data-l10n-id="default-size-label" control="defaultFontSize"/>
+ <hbox flex="1">
+ <menulist id="defaultFontSize" flex="1">
+ <menupopup crop="center">
+ <menuitem value="9" label="9"/>
+ <menuitem value="10" label="10"/>
+ <menuitem value="11" label="11"/>
+ <menuitem value="12" label="12"/>
+ <menuitem value="13" label="13"/>
+ <menuitem value="14" label="14"/>
+ <menuitem value="15" label="15"/>
+ <menuitem value="16" label="16"/>
+ <menuitem value="17" label="17"/>
+ <menuitem value="18" label="18"/>
+ <menuitem value="20" label="20"/>
+ <menuitem value="22" label="22"/>
+ <menuitem value="24" label="24"/>
+ <menuitem value="26" label="26"/>
+ <menuitem value="28" label="28"/>
+ <menuitem value="30" label="30"/>
+ <menuitem value="32" label="32"/>
+ <menuitem value="34" label="34"/>
+ <menuitem value="36" label="36"/>
+ <menuitem value="40" label="40"/>
+ <menuitem value="44" label="44"/>
+ <menuitem value="48" label="48"/>
+ <menuitem value="56" label="56"/>
+ <menuitem value="64" label="64"/>
+ <menuitem value="72" label="72"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </vbox>
+ <vbox id="colorsRow">
+ <hbox flex="1">
+ <button is="highlightable-button" id="advancedFonts"
+ data-l10n-id="font-options-button"
+ oncommand="gGeneralPane.configureFonts();"
+ flex="1"
+ search-l10n-ids="
+ fonts-label-default-unnamed.label,
+ fonts-dialog-title,
+ fonts-language-legend.value,
+ fonts-proportional-label.value,
+ font-language-group-latin.label,
+ font-language-group-japanese.label,
+ font-language-group-trad-chinese.label,
+ font-language-group-simpl-chinese.label,
+ font-language-group-trad-chinese-hk.label,
+ font-language-group-korean.label,
+ font-language-group-cyrillic.label,
+ font-language-group-el.label,
+ font-language-group-other.label,
+ font-language-group-thai.label,
+ font-language-group-hebrew.label,
+ font-language-group-arabic.label,
+ font-language-group-devanagari.label,
+ font-language-group-tamil.label,
+ font-language-group-armenian.label,
+ font-language-group-bengali.label,
+ font-language-group-canadian.label,
+ font-language-group-ethiopic.label,
+ font-language-group-georgian.label,
+ font-language-group-gujarati.label,
+ font-language-group-gurmukhi.label,
+ font-language-group-khmer.label,
+ font-language-group-malayalam.label,
+ font-language-group-math.label,
+ font-language-group-odia.label,
+ font-language-group-telugu.label,
+ font-language-group-kannada.label,
+ font-language-group-sinhala.label,
+ font-language-group-tibetan.label,
+ default-font-serif.label,
+ default-font-sans-serif.label,
+ font-size-label.value,
+ font-size-monospace-label.value,
+ font-serif-label.value,
+ font-sans-serif-label.value,
+ font-monospace-label.value,
+ font-min-size-label.value,
+ min-size-none.label,
+ font-control-legend,
+ use-document-fonts-checkbox.label,
+ use-fixed-width-plain-checkbox.label,
+ text-encoding-legend,
+ text-encoding-description,
+ font-outgoing-email-label.value,
+ font-incoming-email-label.value,
+ default-font-reply-checkbox.label"/>
+ </hbox>
+ <hbox flex="1">
+ <button is="highlightable-button" id="colors"
+ data-l10n-id="color-options-button"
+ oncommand="gGeneralPane.configureColors();"
+ flex="1"
+ search-l10n-ids="
+ colors-dialog-window2.title,
+ colors-dialog-legend,
+ text-color-label.value,
+ background-color-label.value,
+ use-system-colors.label,
+ colors-link-legend,
+ link-color-label.value,
+ visited-link-color-label.value,
+ underline-link-checkbox.label,
+ override-color-label.value,
+ override-color-always.label,
+ override-color-auto.label,
+ override-color-never.label"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ <hbox>
+ <html:legend data-l10n-id="display-width-legend"></html:legend>
+ </hbox>
+ <hbox>
+ <checkbox id="displayGlyph"
+ preference="mail.display_glyph"
+ data-l10n-id="convert-emoticons-label"/>
+ <spacer flex="1"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <label control="displayText" data-l10n-id="display-text-label"/>
+ <hbox id="displayText" class="indent" align="center" role="group">
+ <label data-l10n-id="style-label" control="mailQuotedStyle"/>
+ <hbox>
+ <menulist id="mailQuotedStyle" preference="mail.quoted_style">
+ <menupopup>
+ <menuitem value="0" data-l10n-id="regular-style-item"/>
+ <menuitem value="1" data-l10n-id="bold-style-item"/>
+ <menuitem value="2" data-l10n-id="italic-style-item"/>
+ <menuitem value="3" data-l10n-id="bold-italic-style-item"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <label data-l10n-id="size-label" control="mailQuotedSize"/>
+ <hbox>
+ <menulist id="mailQuotedSize" preference="mail.quoted_size">
+ <menupopup>
+ <menuitem value="0" data-l10n-id="regular-size-item"/>
+ <menuitem value="1" data-l10n-id="bigger-size-item"/>
+ <menuitem value="2" data-l10n-id="smaller-size-item"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <label data-l10n-id="quoted-text-color" control="citationmenu"/>
+ <html:input type="color" id="citationmenu" preference="mail.citation_color"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Date and time formatting -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="datetime-formatting-legend"></html:legend>
+ <radiogroup id="formatLocale" align="start"
+ preference="intl.regional_prefs.use_os_locales"
+ orient="vertical">
+ <radio id="appLocale"
+ value="false"/>
+ <!-- label and accesskey will be set dynamically -->
+ <radio id="rsLocale"
+ value="true"/>
+ <!-- label and accesskey will be set dynamically -->
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="messengerLanguagesBox" data-category="paneGeneral" hidden="hidden">
+ <html:legend data-l10n-id="language-selector-legend"></html:legend>
+ <vbox align="start">
+ <description flex="1"
+ controls="chooseMessengerLanguage"
+ data-l10n-id="choose-messenger-language-description"/>
+ <hbox>
+ <hbox>
+ <menulist id="primaryMessengerLocale"
+ oncommand="gGeneralPane.onPrimaryMessengerLanguageMenuChange(event)">
+ <menupopup/>
+ </menulist>
+ </hbox>
+ <hbox>
+ <button is="highlightable-button" id="manageMessengerLanguagesButton"
+ class="accessory-button"
+ data-l10n-id="manage-messenger-languages-button"
+ oncommand="gGeneralPane.showMessengerLanguagesSubDialog({search: false})"
+ search-l10n-ids="
+ languages-customize-moveup.label,
+ languages-customize-movedown.label,
+ languages-customize-remove.label,
+ languages-customize-select-language.placeholder,
+ languages-customize-add.label,
+ messenger-languages-window2.title,
+ messenger-languages-description,
+ messenger-languages-search,
+ messenger-languages-searching.label,
+ messenger-languages-downloading.label,
+ messenger-languages-select-language.label,
+ messenger-languages-installed-label,
+ messenger-languages-available-label,
+ messenger-languages-error"/>
+ </hbox>
+ </hbox>
+ </vbox>
+ <hbox id="confirmMessengerLanguage"
+ class="message-bar"
+ align="center"
+ hidden="true">
+ <html:img class="message-bar-icon"
+ src="chrome://global/skin/icons/info.svg" alt="" />
+ <vbox class="message-bar-content-container" align="stretch" flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Scrolling -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="scrollingGroup" data-category="paneGeneral">
+ <html:legend data-l10n-id="scrolling-legend"></html:legend>
+ <hbox>
+ <checkbox id="useAutoScroll"
+ data-l10n-id="autoscroll-label"
+ preference="general.autoScroll"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <checkbox id="useSmoothScrolling"
+ data-l10n-id="smooth-scrolling-label"
+ preference="general.smoothScroll"/>
+ <spacer flex="1"/>
+ </hbox>
+#ifdef MOZ_WIDGET_GTK
+ <hbox>
+ <checkbox id="useOverlayScrollbars"
+ data-l10n-id="browsing-gtk-use-non-overlay-scrollbars"
+ preference="widget.gtk.overlay-scrollbars.enabled"/>
+ <spacer flex="1"/>
+ </hbox>
+#endif
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="incomingMailCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-incoming-mail-header"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="new-message-arrival"></html:legend>
+#if defined(XP_MACOSX) || defined(XP_WIN)
+ <hbox align="center">
+ <description flex="1" data-l10n-id="change-dock-icon"/>
+ <hbox>
+ <button is="highlightable-button" id="dockOptions"
+ oncommand="gGeneralPane.configureDockOptions();"
+ data-l10n-id="app-icon-options"
+ search-l10n-ids="
+ dock-options-window-dialog2.title,
+ bounce-system-dock-icon.label,
+ dock-icon-legend,
+ dock-icon-show-label.value,
+ count-unread-messages-radio.label,
+ count-new-messages-radio.label,
+ notification-settings-info2"/>
+ </hbox>
+ </hbox>
+#endif
+#ifdef XP_MACOSX
+ <description class="bold" data-l10n-id="notification-settings2"/>
+#else
+ <hbox align="center">
+ <checkbox id="newMailNotificationAlert"
+ data-l10n-id="animated-alert-label"
+ preference="mail.biff.show_alert"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="customizeMailAlert"
+ oncommand="gGeneralPane.customizeMailAlert();"
+ data-l10n-id="customize-alert-label"
+ search-l10n-ids="
+ notifications-dialog-window.title,
+ customize-alert-description,
+ preview-text-checkbox.label,
+ subject-checkbox.label,
+ sender-checkbox.label,
+ open-time-label-before.value,
+ open-time-label-after.value"/>
+ </hbox>
+ </hbox>
+ <hbox align="center" class="indent">
+ <checkbox id="useSystemNotificationAlert"
+ data-l10n-id="biff-use-system-alert"
+ preference="mail.biff.use_system_alert"/>
+ </hbox>
+#ifdef XP_WIN
+ <vbox>
+ <checkbox id="newMailNotificationTrayIcon"
+ preference="mail.biff.show_tray_icon"
+ data-l10n-id="tray-icon-unread-label"/>
+ <description class="indent tip-caption"
+ flex="1"
+ data-l10n-id="tray-icon-unread-description"/>
+ </vbox>
+#endif
+#endif
+
+ <hbox align="center">
+ <checkbox id="newMailNotification"
+ preference="mail.biff.play_sound"
+ data-l10n-id="mail-play-sound-label"
+ oncommand="gGeneralPane.updatePlaySound();"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="playSound"
+ data-l10n-id="mail-play-button"
+ oncommand="gGeneralPane.previewSound();"/>
+ </hbox>
+
+#ifndef XP_MACOSX
+ <radiogroup id="soundType"
+ class="indent"
+ preference="mail.biff.play_sound.type"
+ orient="vertical"
+ oncommand="gGeneralPane.updatePlaySound();"
+ aria-labelledby="newMailNotification">
+ <hbox>
+ <radio id="system"
+ value="0"
+ data-l10n-id="mail-system-sound-label"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <radio id="custom"
+ value="1"
+ data-l10n-id="mail-custom-sound-label"/>
+ <spacer flex="1"/>
+ </hbox>
+ </radiogroup>
+#endif
+ <hbox align="center" class="input-container">
+ <html:input id="soundUrlLocation"
+ type="text"
+ class="input-filefield indent"
+ readonly="readonly"
+ preference="mail.biff.play_sound.url"
+ preference-editable="true"
+ aria-labelledby="custom"/>
+ <button is="highlightable-button" id="browseForSound"
+ data-l10n-id="mail-browse-sound-button"
+ oncommand="gGeneralPane.browseForSoundFile();"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="filesAttachmentCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-files-and-attachment-header"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <search-textbox id="filter"
+ data-l10n-id="search-handler-table"
+ data-l10n-attrs="placeholder"
+ aria-controls="handlersTable"
+ oncommand="gGeneralPane._rebuildView();"/>
+ <separator class="thin"/>
+ <html:div id="handlersView"
+ preference="pref.downloads.disable_button.edit_actions">
+ <html:table id="handlersTable">
+ <html:thead>
+ <html:tr>
+ <html:th scope="col"
+ sort-type="type">
+ <html:button class="handlerHeaderButton">
+ <html:span class="handlerHeader"
+ data-l10n-id="type-column-header">
+ </html:span>
+ <html:img class="handlerSortHeaderIcon"
+ alt=""/>
+ </html:button>
+ </html:th>
+ <html:th scope="col"
+ sort-type="action">
+ <html:button class="handlerHeaderButton">
+ <html:span class="handlerHeader"
+ data-l10n-id="action-column-header">
+ </html:span>
+ <html:img class="handlerSortHeaderIcon"
+ alt=""/>
+ </html:button>
+ </html:th>
+ </html:tr>
+ </html:thead>
+ <html:tbody>
+ </html:tbody>
+ </html:table>
+ </html:div>
+
+ <separator class="thin"/>
+
+ <vbox align="start">
+ <radiogroup id="saveWhere" flex="1"
+ preference="browser.download.useDownloadDir">
+ <hbox id="saveToRow" align="center" class="input-container">
+ <radio id="saveTo" value="true"
+ data-l10n-id="save-to-label"
+ aria-labelledby="saveTo downloadFolder"/>
+ <html:input id="downloadFolder"
+ class="input-filefield"
+ type="text"
+ readonly="readonly"
+ aria-labelledby="saveTo"/>
+ <button is="highlightable-button" id="chooseFolder"
+ oncommand="gDownloadDirSection.chooseFolder();"
+ data-l10n-id="choose-folder-label"/>
+ </hbox>
+ <hbox>
+ <radio id="alwaysAsk"
+ value="false"
+ data-l10n-id="always-ask-label"/>
+ </hbox>
+ </radiogroup>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="tagsCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-tags-header"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <label control="tagList" data-l10n-id="display-tags-text"/>
+ <hbox>
+ <richlistbox id="tagList"
+ flex="1"
+ ondblclick="gGeneralPane.editTag();"
+ onselect="gGeneralPane.onSelectTag();"/>
+ <vbox id="tagButtons">
+ <hbox>
+ <button is="highlightable-button" id="newTagButton"
+ data-l10n-id="new-tag-button"
+ oncommand="gGeneralPane.addTag();"
+ search-l10n-ids="
+ tag-dialog-window.title,
+ tag-name-label.value"/>
+ </hbox>
+ <hbox>
+ <button is="highlightable-button" id="editTagButton"
+ disabled="true"
+ data-l10n-id="edit-tag-button"
+ oncommand="gGeneralPane.editTag();"
+ search-l10n-ids="
+ tag-dialog-window.title,
+ tag-name-label.value"/>
+ </hbox>
+ <button is="highlightable-button" id="removeTagButton"
+ disabled="true"
+ data-l10n-id="delete-tag-button"
+ oncommand="gGeneralPane.removeTag();"/>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="readingAndDisplayCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-reading-and-display-header"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <vbox>
+ <hbox>
+ <checkbox id="viewAttachmentsInline"
+ data-l10n-id="view-attachments-inline"
+ preference="mail.inline_attachments"/>
+ </hbox>
+
+ <hbox>
+ <checkbox id="automaticallyMarkAsRead"
+ preference="mailnews.mark_message_read.auto"
+ data-l10n-id="auto-mark-as-read"/>
+ </hbox>
+
+ <radiogroup id="markAsReadAutoPreferences" orient="vertical"
+ class="indent"
+ align="start"
+ preference="mailnews.mark_message_read.delay">
+ <radio id="mark_read_immediately"
+ data-l10n-id="mark-read-no-delay"
+ value="false"/>
+ <hbox align="center">
+ <radio id="markAsReadAfterDelay" value="true"
+ data-l10n-id="mark-read-delay"/>
+ <html:input id="markAsReadDelay" type="number" class="size3"
+ min="1" max="2147483"
+ preference="mailnews.mark_message_read.delay.interval"
+ aria-labelledby="markAsReadAfterDelay markAsReadDelay secondsLabel"/>
+ <label id="secondsLabel" data-l10n-id="seconds-label"/>
+ </hbox>
+ </radiogroup>
+ </vbox>
+
+ <separator/>
+
+ <vbox>
+ <hbox>
+ <label data-l10n-id="open-msg-label"
+ control="mailOpenMessageBehavior"/>
+ </hbox>
+ <hbox>
+ <radiogroup id="mailOpenMessageBehavior" class="indent"
+ preference="mail.openMessageBehavior"
+ orient="horizontal">
+ <radio id="newTab" value="2" data-l10n-id="open-msg-tab"/>
+ <radio id="newWindow" value="0" data-l10n-id="open-msg-window"/>
+ <radio id="existingWindow" value="1"
+ data-l10n-id="open-msg-ex-window"/>
+ </radiogroup>
+ </hbox>
+ <hbox>
+ <checkbox id="closeMsgOnMoveOrDelete"
+ data-l10n-id="close-move-delete"
+ preference="mail.close_message_window.on_delete"/>
+ </hbox>
+ </vbox>
+
+ <separator/>
+
+ <hbox>
+ <label data-l10n-id="display-name-label"/>
+ </hbox>
+ <hbox>
+ <checkbox id="showCondensedAddresses"
+ data-l10n-id="condensed-addresses-label"
+ preference="mail.showCondensedAddresses"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center">
+ <description flex="1" data-l10n-id="return-receipts-description"/>
+ <hbox>
+ <button is="highlightable-button" id="showReturnReceipts"
+ data-l10n-id="return-receipts-button"
+ oncommand="gGeneralPane.showReturnReceipts();"
+ search-l10n-ids="
+ receipts-dialog-window.title,
+ return-receipt-checkbox-control.label,
+ receipt-arrive-label,
+ receipt-leave-radio-control.label,
+ receipt-move-radio-control.label,
+ receipt-request-label,
+ receipt-return-never-radio-control.label,
+ receipt-return-some-radio-control.label,
+ receipt-not-to-cc-label.value,
+ receipt-send-never-label.label,
+ receipt-send-always-label.label,
+ receipt-send-ask-label.label,
+ sender-outside-domain-label.value,
+ other-cases-text-label.value"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+#ifdef MOZ_UPDATER
+ <hbox id="updatesCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-updates-header"/>
+ </hbox>
+
+ <!-- Update -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="updateApp" data-category="paneGeneral">
+ <html:legend data-l10n-id="update-app-legend"></html:legend>
+ <hbox align="center">
+ <vbox>
+ <description>
+ <label id="version"/>
+ <label is="text-link" id="releasenotes" hidden="true" data-l10n-id="release-notes-link"></label>
+ </description>
+ <description id="distribution" class="text-blurb" hidden="true"/>
+ <description id="distributionId" class="text-blurb" hidden="true"/>
+ </vbox>
+ <spacer flex="1"/>
+ <vbox>
+ <hbox>
+ <button is="highlightable-button" id="showUpdateHistory"
+ data-l10n-id="update-history-button"
+ preference="app.update.disable_button.showUpdateHistory"
+ oncommand="gGeneralPane.showUpdates();"
+ search-l10n-ids="
+ history-title,
+ history-intro,
+ close-button-label.buttonlabelcancel,
+ close-button-label.title,
+ no-updates-label,
+ name-header,
+ date-header,
+ type-header,
+ state-header"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ <hbox id="updateBox">
+ <deck id="updateDeck" orient="vertical" flex="1">
+ <html:div id="checkForUpdates" class="update-deck-container">
+ <html:button id="checkForUpdatesButton"
+ data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="downloadAndInstall" class="update-deck-container">
+ <html:button id="downloadAndInstallButton"
+ onclick="gAppUpdater.startDownload();">
+ </html:button>
+ </html:div>
+ <html:div id="apply" class="update-deck-container">
+ <html:button id="updateButton"
+ data-l10n-id="update-update-button"
+ onclick="gAppUpdater.buttonRestartAfterDownload();">
+ </html:button>
+ </html:div>
+ <html:div id="checkingForUpdates" class="update-deck-container">
+ <html:img class="update-throbber" alt="" />
+ <html:span data-l10n-id="update-checking-for-updates"></html:span>
+ <!-- Button is only used for presentation in reference to the actual
+ - button that triggered this action, which would now be
+ - invisible -->
+ <html:button data-l10n-id="update-check-for-updates-button"
+ disabled="true">
+ </html:button>
+ </html:div>
+ <html:div id="downloading" class="update-deck-container"
+ data-l10n-id="update-downloading">
+ <html:img class="update-throbber" data-l10n-name="icon"></html:img>
+ <!-- Group within a single span to center align with icon. -->
+ <html:span id="downloadStatus" data-l10n-name="download-status"></html:span>
+ </html:div>
+ <html:div id="applying" class="update-deck-container">
+ <html:img class="update-throbber"/>
+ <html:span data-l10n-id="update-applying"></html:span>
+ </html:div>
+ <html:div id="downloadFailed" class="update-deck-container"
+ data-l10n-id="update-failed">
+ <html:a id="failedLink" class="text-link download-link"
+ data-l10n-name="failed-link"></html:a>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="policyDisabled" class="update-deck-container">
+ <html:span data-l10n-id="update-admin-disabled"></html:span>
+ </html:div>
+ <html:div id="noUpdatesFound" class="update-deck-container">
+ <html:span data-l10n-id="update-no-updates-found"></html:span>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="checkingFailed" class="update-deck-container">
+ <html:span data-l10n-id="aboutdialog-update-checking-failed"></html:span>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="otherInstanceHandlingUpdates"
+ class="update-deck-container">
+ <html:span data-l10n-id="update-other-instance-handling-updates"></html:span>
+ </html:div>
+ <html:div id="manualUpdate" class="update-deck-container"
+ data-l10n-id="update-manual">
+ <html:a id="manualLink" class="manualLink text-link download-link"
+ data-l10n-name="manual-link"></html:a>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="unsupportedSystem" class="update-deck-container" data-l10n-id="update-unsupported">
+ <html:a id="unsupportedLink" class="text-link download-link"
+ data-l10n-name="unsupported-link"></html:a>
+ </html:div>
+ <html:div id="restarting" class="update-deck-container">
+ <html:span class="update-throbber"></html:span>
+ <html:span data-l10n-id="update-restarting"></html:span>
+ </html:div>
+ <html:div id="internalError" class="update-deck-container"
+ data-l10n-id="update-internal-error">
+ <html:a id="internalErrorLink" class="manualLink text-link download-link"
+ data-l10n-name="manual-link"></html:a>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ </deck>
+ </hbox>
+ <separator/>
+ <description id="updateAllowDescription" data-l10n-id="allow-description"/>
+ <vbox id="updateSettingsContainer">
+ <radiogroup id="updateRadioGroup"
+ align="start">
+ <radio id="autoDesktop"
+ value="true"
+ data-l10n-id="automatic-updates-label"/>
+ <radio id="manualDesktop"
+ value="false"
+ data-l10n-id="check-updates-label"/>
+ </radiogroup>
+ <description id="updateSettingCrossUserWarning"
+ data-l10n-id="cross-user-udpate-warning"
+ hidden="true"/>
+ </vbox>
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+ <separator class="thin"/>
+ <checkbox id="useService"
+ data-l10n-id="use-service"
+ preference="app.update.service.enabled"/>
+#endif
+ </html:fieldset>
+ </html:div>
+#endif
+
+ <hbox id="networkAndDiskspaceCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-network-and-diskspace-header"/>
+ </hbox>
+
+ <!-- Networking & Disk Space -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="networking-legend"></html:legend>
+ <hbox align="center">
+ <description control="catProxiesButton"
+ data-l10n-id="proxy-config-description"
+ flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="catProxiesButton"
+ data-l10n-id="network-settings-button"
+ oncommand="gGeneralPane.showConnections();"
+ search-l10n-ids="
+ connection-dns-over-https-url-resolver,
+ connection-dns-over-https-url-custom.label,
+ connection-dns-over-https-custom-label,
+ connection-proxy-legend,
+ proxy-type-no.label,
+ proxy-type-wpad.label,
+ proxy-type-system.label,
+ proxy-type-manual.label,
+ proxy-http-label.value,
+ http-port-label.value,
+ proxy-http-sharing.label,
+ proxy-https-label.value,
+ ssl-port-label.value,
+ proxy-socks-label.value,
+ socks-port-label.value,
+ proxy-socks4-label.label,
+ proxy-socks5-label.label,
+ proxy-type-auto.label,
+ proxy-reload-label.label,
+ no-proxy-label.value,
+ no-proxy-example,
+ connection-proxy-noproxy-localhost-desc-2,
+ proxy-password-prompt.label,
+ proxy-remote-dns.label,
+ proxy-enable-doh.label"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="offline-legend"></html:legend>
+ <hbox align="center">
+ <description data-l10n-id="offline-settings"
+ control="offlineSettingsButton"
+ flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="offlineSettingsButton"
+ data-l10n-id="offline-settings-button"
+ oncommand="gGeneralPane.showOffline();"
+ search-l10n-ids="
+ offline-dialog-window.title,
+ autodetect-online-label.label,
+ startup-label,
+ status-radio-remember.label,
+ status-radio-ask.label,
+ status-radio-always-online.label,
+ status-radio-always-offline.label,
+ going-online-label,
+ going-online-auto.label,
+ going-online-not.label,
+ going-online-ask.label,
+ going-offline-label,
+ going-offline-auto.label,
+ going-offline-not.label,
+ going-offline-ask.label"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="diskspace-legend"></html:legend>
+ <hbox align="center">
+ <label id="actualDiskCacheSize" flex="1"/>
+ <button is="highlightable-button" id="clearCacheButton"
+ data-l10n-id="clear-cache-button"
+ oncommand="gGeneralPane.clearCache();"/>
+ </hbox>
+ <hbox>
+ <checkbox id="clearCacheOnShutdown"
+ preference="privacy.clearOnShutdown.cache"
+ data-l10n-id="clear-cache-shutdown-label"/>
+ </hbox>
+ <hbox>
+ <checkbox id="allowSmartSize"
+ preference="browser.cache.disk.smart_size.enabled"
+ data-l10n-id="smart-cache-label"/>
+ </hbox>
+ <hbox align="center" class="indent">
+ <label id="useCacheBefore" control="cacheSize"
+ data-l10n-id="use-cache-before"/>
+ <html:input id="cacheSize" type="number" class="size4" max="1024"
+ preference="browser.cache.disk.capacity"
+ aria-labelledby="useCacheBefore cacheSize useCacheAfter"/>
+ <label id="useCacheAfter" data-l10n-id="use-cache-after" flex="1"/>
+ </hbox>
+ <hbox align="center">
+ <checkbox id="offlineCompactFolder"
+ data-l10n-id="offline-compact-folder"
+ aria-labelledby="offlineCompactFolder offlineCompactFolderMin compactFolderMB"
+ preference="mail.prompt_purge_threshhold"
+ oncommand="gGeneralPane.updateCompactOptions();"/>
+ <html:input id="offlineCompactFolderMin" type="number" class="size4"
+ min="1" max="2048" value="200"
+ preference="mail.purge_threshhold_mb"
+ aria-labelledby="offlineCompactFolder offlineCompactFolderMin compactFolderMB"/>
+ <label id="compactFolderMB" data-l10n-id="compact-folder-size" value=""/>
+ </hbox>
+ <hbox align="center" class="indent">
+ <checkbox id="offlineCompactFolderAutomatically"
+ data-l10n-id="offline-compact-folder-automatically"
+ preference="mail.purge.ask"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="general-indexing-label"></html:legend>
+ <vbox>
+ <hbox>
+ <checkbox id="enableGloda"
+ preference="mailnews.database.global.indexer.enabled"
+ data-l10n-id="enable-gloda-search-label"/>
+ </hbox>
+ <hbox align="center">
+ <label control="storeTypeMenulist" data-l10n-id="store-type-label"/>
+ <hbox>
+ <menulist id="storeTypeMenulist"
+ oncommand="gGeneralPane.updateDefaultStore(this.selectedItem.value)">
+ <menupopup id="storeTypeMenupopup">
+ <menuitem id="mboxStore"
+ data-l10n-id="mbox-store-label"
+ value="@mozilla.org/msgstore/berkeleystore;1"/>
+ <menuitem id="maildirStore"
+ data-l10n-id="maildir-store-label"
+ value="@mozilla.org/msgstore/maildirstore;1"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <hbox>
+ <checkbox id="allowHWAccel"
+ preference="layers.acceleration.disabled"
+ data-l10n-id="allow-hw-accel"/>
+ </hbox>
+ </vbox>
+ <vbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <separator class="thin" data-category="paneGeneral"/>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <hbox pack="end">
+ <hbox>
+ <button is="highlightable-button" id="configEditor"
+ data-l10n-id="config-editor-button"
+ oncommand="gGeneralPane.showConfigEdit();"
+ searchkeywords="about:config"
+ search-l10n-ids="
+ about-config-page-title,
+ about-config-search-input1.placeholder,
+ about-config-show-all,
+ about-config-pref-add-button.title,
+ about-config-pref-toggle-button.title,
+ about-config-pref-edit-button.title,
+ about-config-pref-save-button.title,
+ about-config-pref-reset-button.title,
+ about-config-pref-delete-button.title,
+ about-config-pref-add-type-boolean,
+ about-config-pref-add-type-number,
+ about-config-pref-add-type-string
+ "/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ </html:template>
diff --git a/comm/mail/components/preferences/general.js b/comm/mail/components/preferences/general.js
new file mode 100644
index 0000000000..96e8aaad2d
--- /dev/null
+++ b/comm/mail/components/preferences/general.js
@@ -0,0 +1,2962 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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";
+
+/* import-globals-from ../../base/content/aboutDialog-appUpdater.js */
+/* import-globals-from ../../../../toolkit/mozapps/preferences/fontbuilder.js */
+/* import-globals-from preferences.js */
+
+// ------------------------------
+// Constants & Enumeration Values
+
+var { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { UpdateUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateUtils.sys.mjs"
+);
+var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+
+XPCOMUtils.defineLazyServiceGetters(this, {
+ gHandlerService: [
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService",
+ ],
+ gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"],
+});
+
+XPCOMUtils.defineLazyGetter(this, "gIsPackagedApp", () => {
+ return Services.sysinfo.getProperty("isPackagedApp");
+});
+
+const TYPE_PDF = "application/pdf";
+
+const PREF_PDFJS_DISABLED = "pdfjs.disabled";
+
+const AUTO_UPDATE_CHANGED_TOPIC = "auto-update-config-change";
+
+Preferences.addAll([
+ { id: "mail.pane_config.dynamic", type: "int" },
+ { id: "mailnews.reuse_message_window", type: "bool" },
+ { id: "mailnews.start_page.enabled", type: "bool" },
+ { id: "mailnews.start_page.url", type: "string" },
+ { id: "mail.biff.show_tray_icon", type: "bool" },
+ { id: "mail.biff.play_sound", type: "bool" },
+ { id: "mail.biff.play_sound.type", type: "int" },
+ { id: "mail.biff.play_sound.url", type: "string" },
+ { id: "mail.biff.use_system_alert", type: "bool" },
+ { id: "general.autoScroll", type: "bool" },
+ { id: "general.smoothScroll", type: "bool" },
+ { id: "widget.gtk.overlay-scrollbars.enabled", type: "bool", inverted: true },
+ { id: "mail.fixed_width_messages", type: "bool" },
+ { id: "mail.inline_attachments", type: "bool" },
+ { id: "mail.quoted_style", type: "int" },
+ { id: "mail.quoted_size", type: "int" },
+ { id: "mail.citation_color", type: "string" },
+ { id: "mail.display_glyph", type: "bool" },
+ { id: "font.language.group", type: "wstring" },
+ { id: "intl.regional_prefs.use_os_locales", type: "bool" },
+ { id: "mailnews.database.global.indexer.enabled", type: "bool" },
+ { id: "mailnews.labels.description.1", type: "wstring" },
+ { id: "mailnews.labels.color.1", type: "string" },
+ { id: "mailnews.labels.description.2", type: "wstring" },
+ { id: "mailnews.labels.color.2", type: "string" },
+ { id: "mailnews.labels.description.3", type: "wstring" },
+ { id: "mailnews.labels.color.3", type: "string" },
+ { id: "mailnews.labels.description.4", type: "wstring" },
+ { id: "mailnews.labels.color.4", type: "string" },
+ { id: "mailnews.labels.description.5", type: "wstring" },
+ { id: "mailnews.labels.color.5", type: "string" },
+ { id: "mail.showCondensedAddresses", type: "bool" },
+ { id: "mailnews.mark_message_read.auto", type: "bool" },
+ { id: "mailnews.mark_message_read.delay", type: "bool" },
+ { id: "mailnews.mark_message_read.delay.interval", type: "int" },
+ { id: "mail.openMessageBehavior", type: "int" },
+ { id: "mail.close_message_window.on_delete", type: "bool" },
+ { id: "mail.prompt_purge_threshhold", type: "bool" },
+ { id: "mail.purge.ask", type: "bool" },
+ { id: "mail.purge_threshhold_mb", type: "int" },
+ { id: "browser.cache.disk.capacity", type: "int" },
+ { id: "browser.cache.disk.smart_size.enabled", inverted: true, type: "bool" },
+ { id: "privacy.clearOnShutdown.cache", type: "bool" },
+ { id: "layers.acceleration.disabled", type: "bool", inverted: true },
+ { id: "searchintegration.enable", type: "bool" },
+ { id: "mail.tabs.drawInTitlebar", type: "bool" },
+ { id: "mail.tabs.autoHide", type: "bool" },
+]);
+if (AppConstants.platform == "win") {
+ Preferences.add({ id: "mail.minimizeToTray", type: "bool" });
+}
+if (AppConstants.platform != "macosx") {
+ Preferences.add({ id: "mail.biff.show_alert", type: "bool" });
+}
+
+var ICON_URL_APP = "";
+
+if (AppConstants.MOZ_WIDGET_GTK) {
+ ICON_URL_APP = "moz-icon://dummy.exe?size=16";
+} else {
+ ICON_URL_APP = "chrome://messenger/skin/preferences/application.png";
+}
+
+if (AppConstants.HAVE_SHELL_SERVICE) {
+ Preferences.addAll([
+ { id: "mail.shell.checkDefaultClient", type: "bool" },
+ { id: "pref.general.disable_button.default_mail", type: "bool" },
+ ]);
+}
+
+if (AppConstants.MOZ_UPDATER) {
+ Preferences.add({
+ id: "app.update.disable_button.showUpdateHistory",
+ type: "bool",
+ });
+ if (AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ Preferences.add({ id: "app.update.service.enabled", type: "bool" });
+ }
+}
+
+var gGeneralPane = {
+ // The set of types the app knows how to handle. A map of HandlerInfoWrapper
+ // objects, indexed by type.
+ _handledTypes: new Map(),
+ // Map from a handlerInfoWrapper to the corresponding table HandlerRow.
+ _handlerRows: new Map(),
+ _handlerMenuId: 0,
+
+ // The list of types we can show, sorted by the sort column/direction.
+ // An array of HandlerInfoWrapper objects. We build this list when we first
+ // load the data and then rebuild it when users change a pref that affects
+ // what types we can show or change the sort column/direction.
+ // Note: this isn't necessarily the list of types we *will* show; if the user
+ // provides a filter string, we'll only show the subset of types in this list
+ // that match that string.
+ _visibleTypes: [],
+
+ // Map whose keys are string descriptions and values are references to the
+ // first visible HandlerInfoWrapper that has this description. We use this
+ // to determine whether or not to annotate descriptions with their types to
+ // distinguish duplicate descriptions from each other.
+ _visibleDescriptions: new Map(),
+
+ // -----------------------------------
+ // Convenience & Performance Shortcuts
+
+ // These get defined by init().
+ _brandShortName: null,
+ _handlerTbody: null,
+ _filter: null,
+ _prefsBundle: null,
+ mPane: null,
+ mStartPageUrl: "",
+ mShellServiceWorking: false,
+ mTagListBox: null,
+ requestingLocales: null,
+
+ async init() {
+ function setEventListener(aId, aEventType, aCallback) {
+ document
+ .getElementById(aId)
+ .addEventListener(aEventType, aCallback.bind(gGeneralPane));
+ }
+
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("saveWhere"),
+ () => gDownloadDirSection.onReadUseDownloadDir()
+ );
+
+ this.mPane = document.getElementById("paneGeneral");
+ this._prefsBundle = document.getElementById("bundlePreferences");
+ this._brandShortName = document
+ .getElementById("bundleBrand")
+ .getString("brandShortName");
+ this._handlerTbody = document.querySelector("#handlersTable > tbody");
+ this._filter = document.getElementById("filter");
+
+ this._handlerSort = { type: "type", descending: false };
+ this._handlerSortHeaders = document.querySelectorAll(
+ "#handlersTable > thead th[sort-type]"
+ );
+ for (let header of this._handlerSortHeaders) {
+ let button = header.querySelector("button");
+ button.addEventListener(
+ "click",
+ this.sort.bind(this, header.getAttribute("sort-type"))
+ );
+ }
+
+ this.updateStartPage();
+ this.updatePlaySound(
+ !Preferences.get("mail.biff.play_sound").value,
+ Preferences.get("mail.biff.play_sound.url").value,
+ Preferences.get("mail.biff.play_sound.type").value
+ );
+ if (AppConstants.platform != "macosx") {
+ this.updateShowAlert();
+ }
+ this.updateWebSearch();
+
+ // Search integration -- check whether we should hide or disable integration
+ let hideSearchUI = false;
+ let disableSearchUI = false;
+ const { SearchIntegration } = ChromeUtils.import(
+ "resource:///modules/SearchIntegration.jsm"
+ );
+ if (SearchIntegration) {
+ if (SearchIntegration.osVersionTooLow) {
+ hideSearchUI = true;
+ } else if (SearchIntegration.osComponentsNotRunning) {
+ disableSearchUI = true;
+ }
+ } else {
+ hideSearchUI = true;
+ }
+
+ if (hideSearchUI) {
+ document.getElementById("searchIntegrationContainer").hidden = true;
+ } else if (disableSearchUI) {
+ let searchCheckbox = document.getElementById("searchIntegration");
+ searchCheckbox.checked = false;
+ Preferences.get("searchintegration.enable").disabled = true;
+ }
+
+ // If the shell service is not working, disable the "Check now" button
+ // and "perform check at startup" checkbox.
+ try {
+ Cc["@mozilla.org/mail/shell-service;1"].getService(Ci.nsIShellService);
+ this.mShellServiceWorking = true;
+ } catch (ex) {
+ // The elements may not exist if HAVE_SHELL_SERVICE is off.
+ if (document.getElementById("alwaysCheckDefault")) {
+ document.getElementById("alwaysCheckDefault").disabled = true;
+ document.getElementById("alwaysCheckDefault").checked = false;
+ }
+ if (document.getElementById("checkDefaultButton")) {
+ document.getElementById("checkDefaultButton").disabled = true;
+ }
+ this.mShellServiceWorking = false;
+ }
+ this._rebuildFonts();
+
+ var menulist = document.getElementById("defaultFont");
+ if (menulist.selectedIndex == -1) {
+ // Prepend menuitem with empty name and value.
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", "");
+ item.setAttribute("value", "");
+ menulist.menupopup.insertBefore(
+ item,
+ menulist.menupopup.firstElementChild
+ );
+ menulist.selectedIndex = 0;
+ }
+
+ this.formatLocaleSetLabels();
+
+ if (Services.prefs.getBoolPref("intl.multilingual.enabled")) {
+ this.initPrimaryMessengerLanguageUI();
+ }
+
+ this.mTagListBox = document.getElementById("tagList");
+ this.buildTagList();
+ this.updateMarkAsReadOptions();
+
+ document.getElementById("citationmenu").value = Preferences.get(
+ "mail.citation_color"
+ ).value;
+
+ // By doing this in a timeout, we let the preferences dialog resize itself
+ // to an appropriate size before we add a bunch of items to the list.
+ // Otherwise, if there are many items, and the Applications prefpane
+ // is the one that gets displayed when the user first opens the dialog,
+ // the dialog might stretch too much in an attempt to fit them all in.
+ // XXX Shouldn't we perhaps just set a max-height on the richlistbox?
+ var _delayedPaneLoad = function (self) {
+ self._loadAppHandlerData();
+ self._rebuildVisibleTypes();
+ self._sortVisibleTypes();
+ self._rebuildView();
+
+ // Notify observers that the UI is now ready
+ Services.obs.notifyObservers(window, "app-handler-pane-loaded");
+ };
+ this.updateActualCacheSize();
+ this.updateCompactOptions();
+
+ // Default store type initialization.
+ let storeTypeElement = document.getElementById("storeTypeMenulist");
+ // set the menuitem to match the account
+ let defaultStoreID = Services.prefs.getCharPref(
+ "mail.serverDefaultStoreContractID"
+ );
+ let targetItem = storeTypeElement.getElementsByAttribute(
+ "value",
+ defaultStoreID
+ );
+ storeTypeElement.selectedItem = targetItem[0];
+ setTimeout(_delayedPaneLoad, 0, this);
+
+ if (AppConstants.MOZ_UPDATER) {
+ this.updateReadPrefs();
+ gAppUpdater = new appUpdater(); // eslint-disable-line no-global-assign
+ let updateDisabled =
+ Services.policies && !Services.policies.isAllowed("appUpdate");
+
+ if (gIsPackagedApp) {
+ // When we're running inside an app package, there's no point in
+ // displaying any update content here, and it would get confusing if we
+ // did, because our updater is not enabled.
+ // We can't rely on the hidden attribute for the toplevel elements,
+ // because of the pane hiding/showing code interfering.
+ document
+ .getElementById("updatesCategory")
+ .setAttribute("style", "display: none !important");
+ document
+ .getElementById("updateApp")
+ .setAttribute("style", "display: none !important");
+ } else if (updateDisabled || UpdateUtils.appUpdateAutoSettingIsLocked()) {
+ document.getElementById("updateAllowDescription").hidden = true;
+ document.getElementById("updateSettingsContainer").hidden = true;
+ if (updateDisabled && AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ document.getElementById("useService").hidden = true;
+ }
+ } else {
+ // Start with no option selected since we are still reading the value
+ document.getElementById("autoDesktop").removeAttribute("selected");
+ document.getElementById("manualDesktop").removeAttribute("selected");
+ // Start reading the correct value from the disk
+ this.updateReadPrefs();
+ setEventListener(
+ "updateRadioGroup",
+ "command",
+ gGeneralPane.updateWritePrefs
+ );
+ }
+
+ let defaults = Services.prefs.getDefaultBranch(null);
+ let distroId = defaults.getCharPref("distribution.id", "");
+ if (distroId) {
+ let distroVersion = defaults.getCharPref("distribution.version", "");
+
+ let distroIdField = document.getElementById("distributionId");
+ distroIdField.value = distroId + " - " + distroVersion;
+ distroIdField.style.display = "block";
+
+ let distroAbout = defaults.getStringPref("distribution.about", "");
+ if (distroAbout) {
+ let distroField = document.getElementById("distribution");
+ distroField.value = distroAbout;
+ distroField.style.display = "block";
+ }
+ }
+
+ if (AppConstants.platform == "win") {
+ // On Windows, the Application Update setting is an installation-
+ // specific preference, not a profile-specific one. Show a warning to
+ // inform users of this.
+ let updateContainer = document.getElementById(
+ "updateSettingsContainer"
+ );
+ updateContainer.classList.add("updateSettingCrossUserWarningContainer");
+ document.getElementById("updateSettingCrossUserWarning").hidden = false;
+ }
+
+ if (AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ // Check to see if the maintenance service is installed.
+ // If it isn't installed, don't show the preference at all.
+ let installed;
+ try {
+ let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ wrk.open(
+ wrk.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\MaintenanceService",
+ wrk.ACCESS_READ | wrk.WOW64_64
+ );
+ installed = wrk.readIntValue("Installed");
+ wrk.close();
+ } catch (e) {}
+ if (installed != 1) {
+ document.getElementById("useService").hidden = true;
+ }
+ }
+
+ let version = AppConstants.MOZ_APP_VERSION_DISPLAY;
+
+ // Include the build ID and display warning if this is an "a#" (nightly) build
+ if (/a\d+$/.test(version)) {
+ let buildID = Services.appinfo.appBuildID;
+ let year = buildID.slice(0, 4);
+ let month = buildID.slice(4, 6);
+ let day = buildID.slice(6, 8);
+ version += ` (${year}-${month}-${day})`;
+ }
+
+ // Append "(32-bit)" or "(64-bit)" build architecture to the version number:
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let archResource = Services.appinfo.is64Bit
+ ? "aboutDialog.architecture.sixtyFourBit"
+ : "aboutDialog.architecture.thirtyTwoBit";
+ let arch = bundle.GetStringFromName(archResource);
+ version += ` (${arch})`;
+
+ document.l10n.setAttributes(
+ document.getElementById("version"),
+ "update-app-version",
+ { version }
+ );
+
+ if (!AppConstants.NIGHTLY_BUILD) {
+ // Show a release notes link if we have a URL.
+ let relNotesLink = document.getElementById("releasenotes");
+ let relNotesPrefType = Services.prefs.getPrefType(
+ "app.releaseNotesURL"
+ );
+ if (relNotesPrefType != Services.prefs.PREF_INVALID) {
+ let relNotesURL = Services.urlFormatter.formatURLPref(
+ "app.releaseNotesURL"
+ );
+ if (relNotesURL != "about:blank") {
+ relNotesLink.href = relNotesURL;
+ relNotesLink.hidden = false;
+ }
+ }
+ }
+ // Initialize Application section.
+
+ // Listen for window unload so we can remove our preference observers.
+ window.addEventListener("unload", this);
+
+ Services.obs.addObserver(this, AUTO_UPDATE_CHANGED_TOPIC);
+ Services.prefs.addObserver("mailnews.tags.", this);
+ }
+
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("allowSmartSize"),
+ () => this.readSmartSizeEnabled()
+ );
+
+ let element = document.getElementById("cacheSize");
+ Preferences.addSyncFromPrefListener(element, () => this.readCacheSize());
+ Preferences.addSyncToPrefListener(element, () => this.writeCacheSize());
+ Preferences.addSyncFromPrefListener(menulist, () =>
+ this.readFontSelection()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("soundUrlLocation"),
+ () => this.readSoundLocation()
+ );
+
+ if (!Services.policies.isAllowed("about:config")) {
+ document.getElementById("configEditor").disabled = true;
+ }
+ },
+
+ /**
+ * Restores the default start page as the user's start page
+ */
+ restoreDefaultStartPage() {
+ var startPage = Preferences.get("mailnews.start_page.url");
+ startPage.value = startPage.defaultValue;
+ },
+
+ /**
+ * Returns a formatted url corresponding to the value of mailnews.start_page.url
+ * Stores the original value of mailnews.start_page.url
+ */
+ readStartPageUrl() {
+ var pref = Preferences.get("mailnews.start_page.url");
+ this.mStartPageUrl = pref.value;
+ return Services.urlFormatter.formatURL(this.mStartPageUrl);
+ },
+
+ /**
+ * Returns the value of the mailnews start page url represented by the UI.
+ * If the url matches the formatted version of our stored value, then
+ * return the unformatted url.
+ */
+ writeStartPageUrl() {
+ var startPage = document.getElementById("mailnewsStartPageUrl");
+ return Services.urlFormatter.formatURL(this.mStartPageUrl) ==
+ startPage.value
+ ? this.mStartPageUrl
+ : startPage.value;
+ },
+
+ customizeMailAlert() {
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/notifications.xhtml",
+ { features: "resizable=no" }
+ );
+ },
+
+ configureDockOptions() {
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/dockoptions.xhtml",
+ { features: "resizable=no" }
+ );
+ },
+
+ convertURLToLocalFile(aFileURL) {
+ // convert the file url into a nsIFile
+ if (aFileURL) {
+ return Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getFileFromURLSpec(aFileURL);
+ }
+ return null;
+ },
+
+ readSoundLocation() {
+ var soundUrlLocation = document.getElementById("soundUrlLocation");
+ soundUrlLocation.value = Preferences.get("mail.biff.play_sound.url").value;
+ if (soundUrlLocation.value) {
+ soundUrlLocation.label = this.convertURLToLocalFile(
+ soundUrlLocation.value
+ ).leafName;
+ soundUrlLocation.style.backgroundImage =
+ "url(moz-icon://" + soundUrlLocation.label + "?size=16)";
+ }
+ },
+
+ previewSound() {
+ let sound = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound);
+
+ let soundLocation;
+ // soundType radio-group isn't used for macOS so it is not in the XUL file
+ // for the platform.
+ soundLocation =
+ AppConstants.platform == "macosx" ||
+ document.getElementById("soundType").value == 1
+ ? document.getElementById("soundUrlLocation").value
+ : "";
+
+ if (!soundLocation.includes("file://")) {
+ // User has not set any custom sound file to be played
+ sound.playEventSound(Ci.nsISound.EVENT_NEW_MAIL_RECEIVED);
+ } else {
+ // User has set a custom audio file to be played along the alert.
+ sound.play(Services.io.newURI(soundLocation));
+ }
+ },
+
+ browseForSoundFile() {
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ // if we already have a sound file, then use the path for that sound file
+ // as the initial path in the dialog.
+ var localFile = this.convertURLToLocalFile(
+ document.getElementById("soundUrlLocation").value
+ );
+ if (localFile) {
+ fp.displayDirectory = localFile.parent;
+ }
+
+ // XXX todo, persist the last sound directory and pass it in
+ fp.init(
+ window,
+ document
+ .getElementById("bundlePreferences")
+ .getString("soundFilePickerTitle"),
+ Ci.nsIFilePicker.modeOpen
+ );
+ fp.appendFilters(Ci.nsIFilePicker.filterAudio);
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ return;
+ }
+ // convert the nsIFile into a nsIFile url
+ Preferences.get("mail.biff.play_sound.url").value = fp.fileURL.spec;
+ this.readSoundLocation(); // XXX We shouldn't have to be doing this by hand
+ this.updatePlaySound();
+ });
+ },
+
+ updatePlaySound(soundsDisabled, soundUrlLocation, soundType) {
+ // Update the sound type radio buttons based on the state of the
+ // play sound checkbox.
+ if (soundsDisabled === undefined) {
+ soundsDisabled = !document.getElementById("newMailNotification").checked;
+ soundUrlLocation = document.getElementById("soundUrlLocation").value;
+ }
+
+ // The UI is different on OS X as the user can only choose between letting
+ // the system play a default sound or setting a custom one. Therefore,
+ // "soundTypeEl" does not exist on OS X.
+ if (AppConstants.platform != "macosx") {
+ var soundTypeEl = document.getElementById("soundType");
+ if (soundType === undefined) {
+ soundType = soundTypeEl.value;
+ }
+
+ soundTypeEl.disabled = soundsDisabled;
+ document.getElementById("soundUrlLocation").disabled =
+ soundsDisabled || soundType != 1;
+ document.getElementById("browseForSound").disabled =
+ soundsDisabled || soundType != 1;
+ document.getElementById("playSound").disabled =
+ soundsDisabled || (!soundUrlLocation && soundType != 0);
+ } else {
+ // On OS X, if there is no selected custom sound then default one will
+ // be played. We keep consistency by disabling the "Play sound" checkbox
+ // if the user hasn't selected a custom sound file yet.
+ document.getElementById("newMailNotification").disabled =
+ !soundUrlLocation;
+ document.getElementById("playSound").disabled = !soundUrlLocation;
+ // The sound type radiogroup is hidden, but we have to keep the
+ // play_sound.type pref set appropriately.
+ Preferences.get("mail.biff.play_sound.type").value =
+ !soundsDisabled && soundUrlLocation ? 1 : 0;
+ }
+ },
+
+ updateStartPage() {
+ document.getElementById("mailnewsStartPageUrl").disabled = !Preferences.get(
+ "mailnews.start_page.enabled"
+ ).value;
+ document.getElementById("browseForStartPageUrl").disabled =
+ !Preferences.get("mailnews.start_page.enabled").value;
+ },
+
+ updateShowAlert() {
+ // The button does not exist on all platforms.
+ let customizeAlertButton = document.getElementById("customizeMailAlert");
+ if (customizeAlertButton) {
+ customizeAlertButton.disabled = !Preferences.get("mail.biff.show_alert")
+ .value;
+ }
+ // The checkmark does not exist on all platforms.
+ let systemNotification = document.getElementById(
+ "useSystemNotificationAlert"
+ );
+ if (systemNotification) {
+ systemNotification.disabled = !Preferences.get("mail.biff.show_alert")
+ .value;
+ }
+ },
+
+ updateWebSearch() {
+ let self = this;
+ Services.search.init().then(async () => {
+ let defaultEngine = await Services.search.getDefault();
+ let engineList = document.getElementById("defaultWebSearch");
+ for (let engine of await Services.search.getVisibleEngines()) {
+ let item = engineList.appendItem(engine.name);
+ item.engine = engine;
+ item.className = "menuitem-iconic";
+ item.setAttribute(
+ "image",
+ engine.iconURI
+ ? engine.iconURI.spec
+ : "resource://gre-resources/broken-image.png"
+ );
+ if (engine == defaultEngine) {
+ engineList.selectedItem = item;
+ }
+ }
+ self.defaultEngines = await Services.search.getAppProvidedEngines();
+ self.updateRemoveButton();
+
+ engineList.addEventListener("command", async () => {
+ await Services.search.setDefault(
+ engineList.selectedItem.engine,
+ Ci.nsISearchService.CHANGE_REASON_USER
+ );
+ self.updateRemoveButton();
+ });
+ });
+ },
+
+ // Caches the default engines so we only retrieve them once.
+ defaultEngines: null,
+
+ async updateRemoveButton() {
+ let engineList = document.getElementById("defaultWebSearch");
+ let removeButton = document.getElementById("removeSearchEngine");
+ if (this.defaultEngines.includes(await Services.search.getDefault())) {
+ // Don't allow deletion of a default engine (saves us having a 'restore' button).
+ removeButton.disabled = true;
+ } else {
+ // Don't allow removal of last engine. This shouldn't happen since there should
+ // always be default engines.
+ removeButton.disabled = engineList.itemCount <= 1;
+ }
+ },
+
+ /**
+ * Look up OpenSearch Description URL.
+ *
+ * @param url - the url to use as basis for discovery
+ */
+ async lookupOpenSearch(url) {
+ let response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Bad response for url=${url}`);
+ }
+ let contentType = response.headers.get("Content-Type")?.toLowerCase();
+ if (
+ contentType == "application/opensearchdescription+xml" ||
+ contentType == "application/xml" ||
+ contentType == "text/xml"
+ ) {
+ return url;
+ }
+ let doc = new DOMParser().parseFromString(
+ await response.text(),
+ "text/html"
+ );
+ let auto = doc.querySelector(
+ "link[rel='search'][type='application/opensearchdescription+xml']"
+ );
+ if (!auto) {
+ throw new Error(`No provider discovered for url=${url}`);
+ }
+ return /^https?:/.test(auto.href)
+ ? auto.href
+ : new URL(url).origin + auto.href;
+ },
+
+ async addSearchEngine() {
+ let input = { value: "https://" };
+ let [title, text] = await document.l10n.formatValues([
+ "add-opensearch-provider-title",
+ "add-opensearch-provider-text",
+ ]);
+ let result = Services.prompt.prompt(window, title, text, input, null, {
+ value: false,
+ });
+ input.value = input.value.trim();
+ if (!result || !input.value || input.value == "https://") {
+ return;
+ }
+ let url = input.value;
+ let engine;
+ try {
+ url = await this.lookupOpenSearch(url);
+ engine = await Services.search.addOpenSearchEngine(url, null);
+ } catch (reason) {
+ let [title, text] = await document.l10n.formatValues([
+ { id: "adding-opensearch-provider-failed-title" },
+ { id: "adding-opensearch-provider-failed-text", args: { url } },
+ ]);
+ Services.prompt.alert(window, title, text);
+ return;
+ }
+ // Wait a bit, so the engine iconURI has time to be fetched.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 500));
+
+ // Add new engine to the list, make the added engine the default.
+ let engineList = document.getElementById("defaultWebSearch");
+ let item = engineList.appendItem(engine.name);
+ item.engine = engine;
+ item.className = "menuitem-iconic";
+ item.setAttribute(
+ "image",
+ engine.iconURI
+ ? engine.iconURI.spec
+ : "resource://gre-resources/broken-image.png"
+ );
+ engineList.selectedIndex =
+ engineList.firstElementChild.childElementCount - 1;
+ await Services.search.setDefault(
+ engineList.selectedItem.engine,
+ Ci.nsISearchService.CHANGE_REASON_USER
+ );
+ this.updateRemoveButton();
+ },
+
+ async removeSearchEngine() {
+ // Deletes the current engine. Firefox does a better job since it
+ // shows all the engines in the list. But better than nothing.
+ let defaultEngine = await Services.search.getDefault();
+ let engineList = document.getElementById("defaultWebSearch");
+ for (let i = 0; i < engineList.itemCount; i++) {
+ let item = engineList.getItemAtIndex(i);
+ if (item.engine == defaultEngine) {
+ await Services.search.removeEngine(item.engine);
+ item.remove();
+ engineList.selectedIndex = 0;
+ await Services.search.setDefault(
+ engineList.selectedItem.engine,
+ Ci.nsISearchService.CHANGE_REASON_USER
+ );
+ this.updateRemoveButton();
+ break;
+ }
+ }
+ },
+
+ /**
+ * Checks whether Thunderbird is currently registered with the operating
+ * system as the default app for mail, rss and news. If Thunderbird is not
+ * currently the default app, the user is given the option of making it the
+ * default for each type; otherwise, the user is informed that Thunderbird is
+ * already the default.
+ */
+ checkDefaultNow(aAppType) {
+ if (!this.mShellServiceWorking) {
+ return;
+ }
+
+ // otherwise, bring up the default client dialog
+ gSubDialog.open(
+ "chrome://messenger/content/systemIntegrationDialog.xhtml",
+ { features: "resizable=no" },
+ "calledFromPrefs"
+ );
+ },
+
+ // FONTS
+
+ /**
+ * Populates the default font list in UI.
+ */
+ _rebuildFonts() {
+ var langGroupPref = Preferences.get("font.language.group");
+ var isSerif =
+ gGeneralPane._readDefaultFontTypeForLanguage(langGroupPref.value) ==
+ "serif";
+ gGeneralPane._selectDefaultLanguageGroup(langGroupPref.value, isSerif);
+ },
+
+ /**
+ * Select the default language group.
+ */
+ _selectDefaultLanguageGroupPromise: Promise.resolve(),
+
+ _selectDefaultLanguageGroup(aLanguageGroup, aIsSerif) {
+ this._selectDefaultLanguageGroupPromise = (async () => {
+ // Avoid overlapping language group selections by awaiting the resolution
+ // of the previous one. We do this because this function is re-entrant,
+ // as inserting <preference> elements into the DOM sometimes triggers a call
+ // back into this function. And since this function is also asynchronous,
+ // that call can enter this function before the previous run has completed,
+ // which would corrupt the font menulists. Awaiting the previous call's
+ // resolution avoids that fate.
+ await this._selectDefaultLanguageGroupPromise;
+
+ const kFontNameFmtSerif = "font.name.serif.%LANG%";
+ const kFontNameFmtSansSerif = "font.name.sans-serif.%LANG%";
+ const kFontNameListFmtSerif = "font.name-list.serif.%LANG%";
+ const kFontNameListFmtSansSerif = "font.name-list.sans-serif.%LANG%";
+ const kFontSizeFmtVariable = "font.size.variable.%LANG%";
+
+ // Make sure font.name-list is created before font.name so that it's
+ // available at the time readFontSelection below is called.
+ var prefs = [
+ {
+ format: aIsSerif ? kFontNameListFmtSerif : kFontNameListFmtSansSerif,
+ type: "unichar",
+ element: null,
+ fonttype: aIsSerif ? "serif" : "sans-serif",
+ },
+ {
+ format: aIsSerif ? kFontNameFmtSerif : kFontNameFmtSansSerif,
+ type: "fontname",
+ element: "defaultFont",
+ fonttype: aIsSerif ? "serif" : "sans-serif",
+ },
+ {
+ format: kFontSizeFmtVariable,
+ type: "int",
+ element: "defaultFontSize",
+ fonttype: null,
+ },
+ ];
+
+ for (var i = 0; i < prefs.length; ++i) {
+ var preference = Preferences.get(
+ prefs[i].format.replace(/%LANG%/, aLanguageGroup)
+ );
+ if (!preference) {
+ preference = Preferences.add({
+ id: prefs[i].format.replace(/%LANG%/, aLanguageGroup),
+ type: prefs[i].type,
+ });
+ }
+
+ if (!prefs[i].element) {
+ continue;
+ }
+
+ var element = document.getElementById(prefs[i].element);
+ if (element) {
+ if (prefs[i].fonttype) {
+ await FontBuilder.buildFontList(
+ aLanguageGroup,
+ prefs[i].fonttype,
+ element
+ );
+ }
+
+ element.setAttribute("preference", preference.id);
+
+ preference.setElementValue(element);
+ }
+ }
+ })().catch(console.error);
+ },
+
+ /**
+ * Displays the fonts dialog, where web page font names and sizes can be
+ * configured.
+ */
+ configureFonts() {
+ gSubDialog.open("chrome://messenger/content/preferences/fonts.xhtml", {
+ features: "resizable=no",
+ });
+ },
+
+ /**
+ * Displays the colors dialog, where default web page/link/etc. colors can be
+ * configured.
+ */
+ configureColors() {
+ gSubDialog.open("chrome://messenger/content/preferences/colors.xhtml", {
+ features: "resizable=no",
+ });
+ },
+
+ /**
+ * Returns the type of the current default font for the language denoted by
+ * aLanguageGroup.
+ */
+ _readDefaultFontTypeForLanguage(aLanguageGroup) {
+ const kDefaultFontType = "font.default.%LANG%";
+ var defaultFontTypePref = kDefaultFontType.replace(
+ /%LANG%/,
+ aLanguageGroup
+ );
+ var preference = Preferences.get(defaultFontTypePref);
+ if (!preference) {
+ Preferences.add({
+ id: defaultFontTypePref,
+ type: "string",
+ name: defaultFontTypePref,
+ }).on("change", gGeneralPane._rebuildFonts);
+ }
+
+ // We should return preference.value here, but we can't wait for the binding to load,
+ // or things get really messy. Fortunately this will give the same answer.
+ return Services.prefs.getCharPref(defaultFontTypePref);
+ },
+
+ /**
+ * Determine the appropriate value to select for defaultFont, for the
+ * following cases:
+ * - there is no setting
+ * - the font selected by the user is no longer present (e.g. deleted from
+ * fonts folder)
+ */
+ readFontSelection() {
+ let element = document.getElementById("defaultFont");
+ let preference = Preferences.get(element.getAttribute("preference"));
+ if (preference.value) {
+ let fontItem = element.querySelector(
+ '[value="' + preference.value + '"]'
+ );
+
+ // There is a setting that actually is in the list. Respect it.
+ if (fontItem) {
+ return undefined;
+ }
+ }
+
+ let defaultValue =
+ element.firstElementChild.firstElementChild.getAttribute("value");
+ let languagePref = Preferences.get("font.language.group");
+ let defaultType = this._readDefaultFontTypeForLanguage(languagePref.value);
+ let listPref = Preferences.get(
+ "font.name-list." + defaultType + "." + languagePref.value
+ );
+ if (!listPref) {
+ return defaultValue;
+ }
+
+ let fontNames = listPref.value.split(",");
+
+ for (let fontName of fontNames) {
+ let fontItem = element.querySelector('[value="' + fontName.trim() + '"]');
+ if (fontItem) {
+ return fontItem.getAttribute("value");
+ }
+ }
+ return defaultValue;
+ },
+
+ async formatLocaleSetLabels() {
+ // HACK: calling getLocaleDisplayNames may fail the first time due to
+ // synchronous loading of the .ftl files. If we load the files and wait
+ // for a known value asynchronously, no such failure will happen.
+ await new Localization([
+ "toolkit/intl/languageNames.ftl",
+ "toolkit/intl/regionNames.ftl",
+ ]).formatValue("language-name-en");
+
+ const osprefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(
+ Ci.mozIOSPreferences
+ );
+ let appLocale = Services.locale.appLocalesAsBCP47[0];
+ let rsLocale = osprefs.regionalPrefsLocales[0];
+ let names = Services.intl.getLocaleDisplayNames(undefined, [
+ appLocale,
+ rsLocale,
+ ]);
+ let appLocaleRadio = document.getElementById("appLocale");
+ let rsLocaleRadio = document.getElementById("rsLocale");
+ let appLocaleLabel = this._prefsBundle.getFormattedString(
+ "appLocale.label",
+ [names[0]]
+ );
+ let rsLocaleLabel = this._prefsBundle.getFormattedString("rsLocale.label", [
+ names[1],
+ ]);
+ appLocaleRadio.setAttribute("label", appLocaleLabel);
+ rsLocaleRadio.setAttribute("label", rsLocaleLabel);
+ appLocaleRadio.accessKey = this._prefsBundle.getString(
+ "appLocale.accesskey"
+ );
+ rsLocaleRadio.accessKey = this._prefsBundle.getString("rsLocale.accesskey");
+ },
+
+ // Load the preferences string bundle for other locales with fallbacks.
+ getBundleForLocales(newLocales) {
+ let locales = Array.from(
+ new Set([
+ ...newLocales,
+ ...Services.locale.requestedLocales,
+ Services.locale.lastFallbackLocale,
+ ])
+ );
+ return new Localization(
+ ["messenger/preferences/preferences.ftl", "branding/brand.ftl"],
+ false,
+ undefined,
+ locales
+ );
+ },
+
+ initPrimaryMessengerLanguageUI() {
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.requestedLocale
+ );
+ },
+
+ /**
+ * Update the available list of locales and select the locale that the user
+ * is "selecting". This could be the currently requested locale or a locale
+ * that the user would like to switch to after confirmation.
+ *
+ * @param {string} selected - The selected BCP 47 locale.
+ */
+ async updatePrimaryMessengerLanguageUI(selected) {
+ // HACK: calling getLocaleDisplayNames may fail the first time due to
+ // synchronous loading of the .ftl files. If we load the files and wait
+ // for a known value asynchronously, no such failure will happen.
+ await new Localization([
+ "toolkit/intl/languageNames.ftl",
+ "toolkit/intl/regionNames.ftl",
+ ]).formatValue("language-name-en");
+
+ let available = await getAvailableLocales();
+ let localeNames = Services.intl.getLocaleDisplayNames(
+ undefined,
+ available,
+ { preferNative: true }
+ );
+ let locales = available.map((code, i) => ({ code, name: localeNames[i] }));
+ locales.sort((a, b) => a.name > b.name);
+
+ let fragment = document.createDocumentFragment();
+ for (let { code, name } of locales) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("value", code);
+ menuitem.setAttribute("label", name);
+ fragment.appendChild(menuitem);
+ }
+
+ // Add an option to search for more languages if downloading is supported.
+ if (Services.prefs.getBoolPref("intl.multilingual.downloadEnabled")) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.id = "primaryMessengerLocaleSearch";
+ menuitem.setAttribute(
+ "label",
+ await document.l10n.formatValue("messenger-languages-search")
+ );
+ menuitem.setAttribute("value", "search");
+ menuitem.addEventListener("command", () => {
+ gGeneralPane.showMessengerLanguagesSubDialog({ search: true });
+ });
+ fragment.appendChild(menuitem);
+ }
+
+ let menulist = document.getElementById("primaryMessengerLocale");
+ let menupopup = menulist.querySelector("menupopup");
+ menupopup.textContent = "";
+ menupopup.appendChild(fragment);
+ menulist.value = selected;
+
+ document.getElementById("messengerLanguagesBox").hidden = false;
+ },
+
+ /**
+ * Open the messenger languages sub dialog in either the normal mode, or search mode.
+ * The search mode is only available from the menu to change the primary browser
+ * language.
+ *
+ * @param {{ search: boolean }}
+ */
+ showMessengerLanguagesSubDialog({ search }) {
+ let opts = {
+ selectedLocalesForRestart: gGeneralPane.selectedLocalesForRestart,
+ search,
+ };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/messengerLanguages.xhtml",
+ { closingCallback: this.messengerLanguagesClosed },
+ opts
+ );
+ },
+
+ /**
+ * Returns the assumed script directionality for known Firefox locales. This is
+ * somewhat crude, but should work until Bug 1750781 lands.
+ *
+ * TODO (Bug 1750781) - This should use Intl.LocaleInfo once it is standardized (see
+ * Bug 1693576), rather than maintaining a hardcoded list of RTL locales.
+ *
+ * @param {string} locale
+ * @returns {"ltr" | "rtl"}
+ */
+ getLocaleDirection(locale) {
+ if (
+ locale == "ar" ||
+ locale == "ckb" ||
+ locale == "fa" ||
+ locale == "he" ||
+ locale == "ur"
+ ) {
+ return "rtl";
+ }
+ return "ltr";
+ },
+
+ /**
+ * Determine the transition strategy for switching the locale based on prefs
+ * and the switched locales.
+ *
+ * @param {Array<string>} newLocales - List of BCP 47 locale identifiers.
+ * @returns {"locales-match" | "requires-restart" | "live-reload"}
+ */
+ getLanguageSwitchTransitionType(newLocales) {
+ const { appLocalesAsBCP47 } = Services.locale;
+ if (appLocalesAsBCP47.join(",") === newLocales.join(",")) {
+ // The selected locales match, the order matters.
+ return "locales-match";
+ }
+
+ if (Services.prefs.getBoolPref("intl.multilingual.liveReload")) {
+ if (
+ gGeneralPane.getLocaleDirection(newLocales[0]) !==
+ gGeneralPane.getLocaleDirection(appLocalesAsBCP47[0]) &&
+ !Services.prefs.getBoolPref("intl.multilingual.liveReloadBidirectional")
+ ) {
+ // Bug 1750852: The directionality of the text changed, which requires a restart
+ // until the quality of the switch can be improved.
+ return "requires-restart";
+ }
+
+ return "live-reload";
+ }
+
+ return "requires-restart";
+ },
+
+ /* Show or hide the confirm change message bar based on the updated ordering. */
+ messengerLanguagesClosed() {
+ // When the subdialog is closed, settings are stored on gMessengerLanguagesDialog.
+ // The next time the dialog is opened, a new gMessengerLanguagesDialog is created.
+ let { selected } = this.gMessengerLanguagesDialog;
+
+ if (!selected) {
+ // No locales were selected. Cancel the operation.
+ return;
+ }
+
+ switch (gGeneralPane.getLanguageSwitchTransitionType(selected)) {
+ case "requires-restart":
+ gGeneralPane.showConfirmLanguageChangeMessageBar(selected);
+ gGeneralPane.updatePrimaryMessengerLanguageUI(selected[0]);
+ break;
+ case "live-reload":
+ Services.locale.requestedLocales = selected;
+
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.appLocaleAsBCP47
+ );
+ gGeneralPane.hideConfirmLanguageChangeMessageBar();
+ break;
+ case "locales-match":
+ // They matched, so we can reset the UI.
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.appLocaleAsBCP47
+ );
+ gGeneralPane.hideConfirmLanguageChangeMessageBar();
+ break;
+ default:
+ throw new Error("Unhandled transition type.");
+ }
+ },
+
+ /* Show the confirmation message bar to allow a restart into the new locales. */
+ async showConfirmLanguageChangeMessageBar(locales) {
+ let messageBar = document.getElementById("confirmMessengerLanguage");
+
+ // Get the bundle for the new locale.
+ let newBundle = this.getBundleForLocales(locales);
+
+ // Find the messages and labels.
+ let messages = await Promise.all(
+ [newBundle, document.l10n].map(async bundle =>
+ bundle.formatValue("confirm-messenger-language-change-description")
+ )
+ );
+ let buttonLabels = await Promise.all(
+ [newBundle, document.l10n].map(async bundle =>
+ bundle.formatValue("confirm-messenger-language-change-button")
+ )
+ );
+
+ // If both the message and label are the same, just include one row.
+ if (messages[0] == messages[1] && buttonLabels[0] == buttonLabels[1]) {
+ messages.pop();
+ buttonLabels.pop();
+ }
+
+ let contentContainer = messageBar.querySelector(
+ ".message-bar-content-container"
+ );
+ contentContainer.textContent = "";
+
+ for (let i = 0; i < messages.length; i++) {
+ let messageContainer = document.createXULElement("hbox");
+ messageContainer.classList.add("message-bar-content");
+ messageContainer.setAttribute("flex", "1");
+ messageContainer.setAttribute("align", "center");
+
+ let description = document.createXULElement("description");
+ description.classList.add("message-bar-description");
+
+ if (i == 0 && gGeneralPane.getLocaleDirection(locales[0]) === "rtl") {
+ description.classList.add("rtl-locale");
+ }
+
+ description.setAttribute("flex", "1");
+ description.textContent = messages[i];
+ messageContainer.appendChild(description);
+
+ let button = document.createXULElement("button");
+ button.addEventListener("command", gGeneralPane.confirmLanguageChange);
+ button.classList.add("message-bar-button");
+ button.setAttribute("locales", locales.join(","));
+ button.setAttribute("label", buttonLabels[i]);
+ messageContainer.appendChild(button);
+
+ contentContainer.appendChild(messageContainer);
+ }
+
+ messageBar.hidden = false;
+ this.selectedLocalesForRestart = locales;
+ },
+
+ hideConfirmLanguageChangeMessageBar() {
+ let messageBar = document.getElementById("confirmMessengerLanguage");
+ messageBar.hidden = true;
+ let contentContainer = messageBar.querySelector(
+ ".message-bar-content-container"
+ );
+ contentContainer.textContent = "";
+ this.requestingLocales = null;
+ },
+
+ /* Confirm the locale change and restart the Thunderbird in the new locale. */
+ confirmLanguageChange(event) {
+ let localesString = (event.target.getAttribute("locales") || "").trim();
+ if (!localesString || localesString.length == 0) {
+ return;
+ }
+ let locales = localesString.split(",");
+ Services.locale.requestedLocales = locales;
+
+ // Restart with the new locale.
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+ if (!cancelQuit.data) {
+ Services.startup.quit(
+ Services.startup.eAttemptQuit | Services.startup.eRestart
+ );
+ }
+ },
+
+ /* Show or hide the confirm change message bar based on the new locale. */
+ onPrimaryMessengerLanguageMenuChange(event) {
+ let locale = event.target.value;
+
+ if (locale == "search") {
+ return;
+ } else if (locale == Services.locale.appLocaleAsBCP47) {
+ this.hideConfirmLanguageChangeMessageBar();
+ return;
+ }
+
+ let newLocales = Array.from(
+ new Set([locale, ...Services.locale.requestedLocales]).values()
+ );
+
+ switch (gGeneralPane.getLanguageSwitchTransitionType(newLocales)) {
+ case "requires-restart":
+ // Prepare to change the locales, as they were different.
+ gGeneralPane.showConfirmLanguageChangeMessageBar(newLocales);
+ gGeneralPane.updatePrimaryMessengerLanguageUI(newLocales[0]);
+ break;
+ case "live-reload":
+ Services.locale.requestedLocales = newLocales;
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.appLocaleAsBCP47
+ );
+ gGeneralPane.hideConfirmLanguageChangeMessageBar();
+ break;
+ case "locales-match":
+ // They matched, so we can reset the UI.
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.appLocaleAsBCP47
+ );
+ gGeneralPane.hideConfirmLanguageChangeMessageBar();
+ break;
+ default:
+ throw new Error("Unhandled transition type.");
+ }
+ },
+
+ // appends the tag to the tag list box
+ appendTagItem(aTagName, aKey, aColor) {
+ let item = this.mTagListBox.appendItem(aTagName, aKey);
+ item.style.color = aColor;
+ return item;
+ },
+
+ buildTagList() {
+ let tagArray = MailServices.tags.getAllTags();
+ for (let i = 0; i < tagArray.length; ++i) {
+ let taginfo = tagArray[i];
+ this.appendTagItem(taginfo.tag, taginfo.key, taginfo.color);
+ }
+ },
+
+ removeTag() {
+ var index = this.mTagListBox.selectedIndex;
+ if (index >= 0) {
+ var itemToRemove = this.mTagListBox.getItemAtIndex(index);
+ MailServices.tags.deleteKey(itemToRemove.getAttribute("value"));
+ }
+ },
+
+ /**
+ * Open the edit tag dialog
+ */
+ editTag() {
+ var index = this.mTagListBox.selectedIndex;
+ if (index >= 0) {
+ var tagElToEdit = this.mTagListBox.getItemAtIndex(index);
+ var args = {
+ result: "",
+ keyToEdit: tagElToEdit.getAttribute("value"),
+ };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/tagDialog.xhtml",
+ { features: "resizable=no" },
+ args
+ );
+ }
+ },
+
+ addTag() {
+ var args = { result: "", okCallback: addTagCallback };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/tagDialog.xhtml",
+ { features: "resizable=no" },
+ args
+ );
+ },
+
+ onSelectTag() {
+ let btnEdit = document.getElementById("editTagButton");
+ let listBox = document.getElementById("tagList");
+
+ if (listBox.selectedCount > 0) {
+ btnEdit.disabled = false;
+ } else {
+ btnEdit.disabled = true;
+ }
+
+ document.getElementById("removeTagButton").disabled = btnEdit.disabled;
+ },
+
+ /**
+ * Enable/disable the options of automatic marking as read depending on the
+ * state of the automatic marking feature.
+ */
+ updateMarkAsReadOptions() {
+ let enableRadioGroup = Preferences.get(
+ "mailnews.mark_message_read.auto"
+ ).value;
+ let autoMarkAsPref = Preferences.get("mailnews.mark_message_read.delay");
+ let autoMarkDisabled = !enableRadioGroup || autoMarkAsPref.locked;
+ document.getElementById("markAsReadAutoPreferences").disabled =
+ autoMarkDisabled;
+ document.getElementById("secondsLabel").disabled = autoMarkDisabled;
+ gGeneralPane.updateMarkAsReadTextbox();
+ },
+
+ /**
+ * Automatically enable/disable delay textbox depending on state of the
+ * Mark As Read On Delay feature.
+ */
+ updateMarkAsReadTextbox() {
+ let radioGroupEnabled = Preferences.get(
+ "mailnews.mark_message_read.auto"
+ ).value;
+ let textBoxEnabled = Preferences.get(
+ "mailnews.mark_message_read.delay"
+ ).value;
+ let intervalPref = Preferences.get(
+ "mailnews.mark_message_read.delay.interval"
+ );
+
+ let delayTextbox = document.getElementById("markAsReadDelay");
+ delayTextbox.disabled =
+ !radioGroupEnabled || !textBoxEnabled || intervalPref.locked;
+ if (document.activeElement.id == "markAsReadAutoPreferences") {
+ delayTextbox.focus();
+ }
+ },
+
+ /**
+ * Display the return receipts configuration dialog.
+ */
+ showReturnReceipts() {
+ gSubDialog.open("chrome://messenger/content/preferences/receipts.xhtml", {
+ features: "resizable=no",
+ });
+ },
+
+ /**
+ * Show the about:config page in a tab.
+ */
+ showConfigEdit() {
+ // If the about:config tab is already open, switch to the tab.
+ let mainWin = Services.wm.getMostRecentWindow("mail:3pane");
+ let tabmail = mainWin.document.getElementById("tabmail");
+ for (let tabInfo of tabmail.tabInfo) {
+ let tab = tabmail.getTabForBrowser(tabInfo.browser);
+ if (tab?.urlbar?.value == "about:config") {
+ tabmail.switchToTab(tabInfo);
+ return;
+ }
+ }
+ // Wasn't open already. Open in a new tab.
+ tabmail.openTab("contentTab", { url: "about:config" });
+ },
+
+ /**
+ * Display the the connection settings dialog.
+ */
+ showConnections() {
+ gSubDialog.open("chrome://messenger/content/preferences/connection.xhtml");
+ },
+
+ /**
+ * Display the the offline settings dialog.
+ */
+ showOffline() {
+ gSubDialog.open("chrome://messenger/content/preferences/offline.xhtml", {
+ features: "resizable=no",
+ });
+ },
+
+ /*
+ * browser.cache.disk.capacity
+ * - the size of the browser cache in KB
+ */
+
+ // Retrieves the amount of space currently used by disk cache
+ updateActualCacheSize() {
+ let actualSizeLabel = document.getElementById("actualDiskCacheSize");
+ let prefStrBundle = document.getElementById("bundlePreferences");
+
+ // Needs to root the observer since cache service keeps only a weak reference.
+ this.observer = {
+ onNetworkCacheDiskConsumption(consumption) {
+ let size = DownloadUtils.convertByteUnits(consumption);
+ // The XBL binding for the string bundle may have been destroyed if
+ // the page was closed before this callback was executed.
+ if (!prefStrBundle.getFormattedString) {
+ return;
+ }
+ actualSizeLabel.value = prefStrBundle.getFormattedString(
+ "actualDiskCacheSize",
+ size
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsICacheStorageConsumptionObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ actualSizeLabel.value = prefStrBundle.getString(
+ "actualDiskCacheSizeCalculated"
+ );
+
+ try {
+ Services.cache2.asyncGetDiskConsumption(this.observer);
+ } catch (e) {}
+ },
+
+ updateCacheSizeUI(smartSizeEnabled) {
+ document.getElementById("useCacheBefore").disabled = smartSizeEnabled;
+ document.getElementById("cacheSize").disabled = smartSizeEnabled;
+ document.getElementById("useCacheAfter").disabled = smartSizeEnabled;
+ },
+
+ readSmartSizeEnabled() {
+ // The smart_size.enabled preference element is inverted="true", so its
+ // value is the opposite of the actual pref value
+ var disabled = Preferences.get(
+ "browser.cache.disk.smart_size.enabled"
+ ).value;
+ this.updateCacheSizeUI(!disabled);
+ },
+
+ /**
+ * Converts the cache size from units of KB to units of MB and returns that
+ * value.
+ */
+ readCacheSize() {
+ var preference = Preferences.get("browser.cache.disk.capacity");
+ return preference.value / 1024;
+ },
+
+ /**
+ * Converts the cache size as specified in UI (in MB) to KB and returns that
+ * value.
+ */
+ writeCacheSize() {
+ var cacheSize = document.getElementById("cacheSize");
+ var intValue = parseInt(cacheSize.value, 10);
+ return isNaN(intValue) ? 0 : intValue * 1024;
+ },
+
+ /**
+ * Clears the cache.
+ */
+ clearCache() {
+ try {
+ Services.cache2.clear();
+ } catch (ex) {}
+ this.updateActualCacheSize();
+ },
+
+ updateCompactOptions() {
+ let disabled =
+ !Preferences.get("mail.prompt_purge_threshhold").value ||
+ Preferences.get("mail.purge_threshhold_mb").locked;
+
+ document.getElementById("offlineCompactFolderMin").disabled = disabled;
+ document.getElementById("offlineCompactFolderAutomatically").disabled =
+ disabled;
+ },
+
+ /**
+ * Set the default store contract ID.
+ */
+ updateDefaultStore(storeID) {
+ Services.prefs.setCharPref("mail.serverDefaultStoreContractID", storeID);
+ },
+
+ /**
+ * When the user toggles the layers.acceleration.disabled pref,
+ * sync its new value to the gfx.direct2d.disabled pref too.
+ * Note that layers.acceleration.disabled is inverted.
+ */
+ updateHardwareAcceleration() {
+ if (AppConstants.platform == "win") {
+ let preference = Preferences.get("layers.acceleration.disabled");
+ Services.prefs.setBoolPref("gfx.direct2d.disabled", !preference.value);
+ }
+ },
+
+ /**
+ * Selects the correct item in the update radio group
+ */
+ async updateReadPrefs() {
+ if (
+ AppConstants.MOZ_UPDATER &&
+ (!Services.policies || Services.policies.isAllowed("appUpdate")) &&
+ !gIsPackagedApp
+ ) {
+ let radiogroup = document.getElementById("updateRadioGroup");
+ radiogroup.disabled = true;
+ try {
+ let enabled = await UpdateUtils.getAppUpdateAutoEnabled();
+ radiogroup.value = enabled;
+ radiogroup.disabled = false;
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ },
+
+ /**
+ * Writes the value of the update radio group to the disk
+ */
+ async updateWritePrefs() {
+ if (
+ AppConstants.MOZ_UPDATER &&
+ (!Services.policies || Services.policies.isAllowed("appUpdate")) &&
+ !gIsPackagedApp
+ ) {
+ let radiogroup = document.getElementById("updateRadioGroup");
+ let updateAutoValue = radiogroup.value == "true";
+ radiogroup.disabled = true;
+ try {
+ await UpdateUtils.setAppUpdateAutoEnabled(updateAutoValue);
+ radiogroup.disabled = false;
+ } catch (error) {
+ console.error(error);
+ await this.updateReadPrefs();
+ await this.reportUpdatePrefWriteError();
+ return;
+ }
+
+ // If the value was changed to false the user should be given the option
+ // to discard an update if there is one.
+ if (!updateAutoValue) {
+ await this.checkUpdateInProgress();
+ }
+ }
+ },
+
+ async reportUpdatePrefWriteError() {
+ let [title, message] = await document.l10n.formatValues([
+ { id: "update-setting-write-failure-title" },
+ {
+ id: "update-setting-write-failure-message",
+ args: { path: UpdateUtils.configFilePath },
+ },
+ ]);
+
+ // Set up the Ok Button
+ let buttonFlags =
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_OK;
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ buttonFlags,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ },
+
+ async checkUpdateInProgress() {
+ let um = Cc["@mozilla.org/updates/update-manager;1"].getService(
+ Ci.nsIUpdateManager
+ );
+ if (!um.readyUpdate && !um.downloadingUpdate) {
+ return;
+ }
+
+ let [title, message, okButton, cancelButton] =
+ await document.l10n.formatValues([
+ { id: "update-in-progress-title" },
+ { id: "update-in-progress-message" },
+ { id: "update-in-progress-ok-button" },
+ { id: "update-in-progress-cancel-button" },
+ ]);
+
+ // Continue is the cancel button which is BUTTON_POS_1 and is set as the
+ // default so pressing escape or using a platform standard method of closing
+ // the UI will not discard the update.
+ let buttonFlags =
+ Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 +
+ Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_1 +
+ Ci.nsIPrompt.BUTTON_POS_1_DEFAULT;
+
+ let rv = Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ buttonFlags,
+ okButton,
+ cancelButton,
+ null,
+ null,
+ {}
+ );
+ if (rv != 1) {
+ let aus = Cc["@mozilla.org/updates/update-service;1"].getService(
+ Ci.nsIApplicationUpdateService
+ );
+ aus.stopDownload();
+ um.cleanupReadyUpdate();
+ um.cleanupDownloadingUpdate();
+ }
+ },
+
+ showUpdates() {
+ gSubDialog.open("chrome://mozapps/content/update/history.xhtml");
+ },
+
+ _loadAppHandlerData() {
+ this._loadInternalHandlers();
+ this._loadApplicationHandlers();
+ },
+
+ _loadInternalHandlers() {
+ const internalHandlers = [new PDFHandlerInfoWrapper()];
+ for (const internalHandler of internalHandlers) {
+ if (internalHandler.enabled) {
+ this._handledTypes.set(internalHandler.type, internalHandler);
+ }
+ }
+ },
+
+ /**
+ * Load the set of handlers defined by the application datastore.
+ */
+ _loadApplicationHandlers() {
+ for (let wrappedHandlerInfo of gHandlerService.enumerate()) {
+ let type = wrappedHandlerInfo.type;
+
+ let handlerInfoWrapper;
+ if (this._handledTypes.has(type)) {
+ handlerInfoWrapper = this._handledTypes.get(type);
+ } else {
+ handlerInfoWrapper = new HandlerInfoWrapper(type, wrappedHandlerInfo);
+ this._handledTypes.set(type, handlerInfoWrapper);
+ }
+ }
+ },
+
+ // -----------------
+ // View Construction
+
+ _rebuildVisibleTypes() {
+ // Reset the list of visible types and the visible type description.
+ this._visibleTypes.length = 0;
+ this._visibleDescriptions.clear();
+
+ for (let handlerInfo of this._handledTypes.values()) {
+ // We couldn't find any reason to exclude the type, so include it.
+ this._visibleTypes.push(handlerInfo);
+
+ let otherHandlerInfo = this._visibleDescriptions.get(
+ handlerInfo.description
+ );
+ if (!otherHandlerInfo) {
+ // This is the first type with this description that we encountered
+ // while rebuilding the _visibleTypes array this time. Make sure the
+ // flag is reset so we won't add the type to the description.
+ handlerInfo.disambiguateDescription = false;
+ this._visibleDescriptions.set(handlerInfo.description, handlerInfo);
+ } else {
+ // There is at least another type with this description. Make sure we
+ // add the type to the description on both HandlerInfoWrapper objects.
+ handlerInfo.disambiguateDescription = true;
+ otherHandlerInfo.disambiguateDescription = true;
+ }
+ }
+ },
+
+ _rebuildView() {
+ // Clear the list of entries.
+ let tbody = this._handlerTbody;
+ while (tbody.hasChildNodes()) {
+ // Rows kept alive by the _handlerRows map.
+ tbody.removeChild(tbody.lastChild);
+ }
+
+ let sort = this._handlerSort;
+ for (let header of this._handlerSortHeaders) {
+ let icon = header.querySelector("img");
+ if (sort.type === header.getAttribute("sort-type")) {
+ icon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/nav-down-sm.svg"
+ );
+ if (sort.descending) {
+ /* Rotates the src image to point up. */
+ icon.setAttribute("descending", "");
+ header.setAttribute("aria-sort", "descending");
+ } else {
+ icon.removeAttribute("descending");
+ header.setAttribute("aria-sort", "ascending");
+ }
+ } else {
+ icon.removeAttribute("src");
+ header.setAttribute("aria-sort", "none");
+ }
+ }
+
+ let visibleTypes = this._visibleTypes;
+
+ // If the user is filtering the list, then only show matching types.
+ if (this._filter.value) {
+ visibleTypes = visibleTypes.filter(this._matchesFilter, this);
+ }
+
+ for (let handlerInfo of visibleTypes) {
+ let row = this._handlerRows.get(handlerInfo);
+ if (row) {
+ tbody.appendChild(row.node);
+ } else {
+ row = new HandlerRow(handlerInfo, this.onDelete.bind(this));
+ row.constructNodeAndAppend(tbody, this._handlerMenuId);
+ this._handlerMenuId++;
+ this._handlerRows.set(handlerInfo, row);
+ }
+ }
+ },
+
+ _matchesFilter(aType) {
+ var filterValue = this._filter.value.toLowerCase();
+ return (
+ aType.typeDescription.toLowerCase().includes(filterValue) ||
+ aType.actionDescription.toLowerCase().includes(filterValue)
+ );
+ },
+
+ /**
+ * Get the details for the type represented by the given handler info
+ * object.
+ *
+ * @param aHandlerInfo {nsIHandlerInfo} the type to get the extensions for.
+ * @returns {string} the extensions for the type
+ */
+ _typeDetails(aHandlerInfo) {
+ let exts = [];
+ if (aHandlerInfo.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) {
+ for (let extName of aHandlerInfo.wrappedHandlerInfo.getFileExtensions()) {
+ let ext = "." + extName;
+ if (!exts.includes(ext)) {
+ exts.push(ext);
+ }
+ }
+ }
+ exts.sort();
+ exts = exts.join(", ");
+ if (this._visibleDescriptions.has(aHandlerInfo.description)) {
+ if (exts) {
+ return this._prefsBundle.getFormattedString(
+ "typeDetailsWithTypeAndExt",
+ [aHandlerInfo.type, exts]
+ );
+ }
+ return this._prefsBundle.getFormattedString("typeDetailsWithTypeOrExt", [
+ aHandlerInfo.type,
+ ]);
+ }
+ if (exts) {
+ return this._prefsBundle.getFormattedString("typeDetailsWithTypeOrExt", [
+ exts,
+ ]);
+ }
+ return exts;
+ },
+
+ /**
+ * Whether or not the given handler app is valid.
+ *
+ * @param aHandlerApp {nsIHandlerApp} the handler app in question
+ * @returns {boolean} whether or not it's valid
+ */
+ isValidHandlerApp(aHandlerApp) {
+ if (!aHandlerApp) {
+ return false;
+ }
+
+ if (aHandlerApp instanceof Ci.nsILocalHandlerApp) {
+ return this._isValidHandlerExecutable(aHandlerApp.executable);
+ }
+
+ if (aHandlerApp instanceof Ci.nsIWebHandlerApp) {
+ return aHandlerApp.uriTemplate;
+ }
+
+ if (aHandlerApp instanceof Ci.nsIWebContentHandlerInfo) {
+ return aHandlerApp.uri;
+ }
+
+ return false;
+ },
+
+ _isValidHandlerExecutable(aExecutable) {
+ let isExecutable =
+ aExecutable && aExecutable.exists() && aExecutable.isExecutable();
+ // XXXben - we need to compare this with the running instance executable
+ // just don't know how to do that via script...
+ // XXXmano TBD: can probably add this to nsIShellService
+ if (AppConstants.platform == "win") {
+ return (
+ isExecutable &&
+ aExecutable.leafName != AppConstants.MOZ_APP_NAME + ".exe"
+ );
+ }
+
+ if (AppConstants.platform == "macosx") {
+ return (
+ isExecutable && aExecutable.leafName != AppConstants.MOZ_MACBUNDLE_NAME
+ );
+ }
+
+ return (
+ isExecutable && aExecutable.leafName != AppConstants.MOZ_APP_NAME + "-bin"
+ );
+ },
+
+ // -------------------
+ // Sorting & Filtering
+
+ /**
+ * Sort the list when the user clicks on a column header. If sortType is
+ * different than the last sort, the sort direction is toggled. Otherwise, the
+ * sort is changed to the new sortType with ascending direction.
+ *
+ * @param {string} sortType - The sort type associated with the column header.
+ */
+ sort(sortType) {
+ let sort = this._handlerSort;
+ if (sort.type === sortType) {
+ sort.descending = !sort.descending;
+ } else {
+ sort.type = sortType;
+ sort.descending = false;
+ }
+ this._sortVisibleTypes();
+ this._rebuildView();
+ },
+
+ /**
+ * Sort the list of visible types by the current sort column/direction.
+ */
+ _sortVisibleTypes() {
+ function sortByType(a, b) {
+ return a.typeDescription
+ .toLowerCase()
+ .localeCompare(b.typeDescription.toLowerCase());
+ }
+
+ function sortByAction(a, b) {
+ return a.actionDescription
+ .toLowerCase()
+ .localeCompare(b.actionDescription.toLowerCase());
+ }
+
+ let sort = this._handlerSort;
+ if (sort.type === "action") {
+ this._visibleTypes.sort(sortByAction);
+ } else {
+ this._visibleTypes.sort(sortByType);
+ }
+ if (sort.descending) {
+ this._visibleTypes.reverse();
+ }
+ },
+
+ focusFilterBox() {
+ this._filter.focus();
+ this._filter.select();
+ },
+
+ onDelete(handlerRow) {
+ let handlerInfo = handlerRow.handlerInfoWrapper;
+ let index = this._visibleTypes.indexOf(handlerInfo);
+ if (index != -1) {
+ this._visibleTypes.splice(index, 1);
+ }
+
+ let tbody = this._handlerTbody;
+ if (handlerRow.node.parentNode === tbody) {
+ tbody.removeChild(handlerRow.node);
+ }
+
+ this._handledTypes.delete(handlerInfo.type);
+ this._handlerRows.delete(handlerInfo);
+
+ handlerInfo.remove();
+ },
+
+ _getIconURLForHandlerApp(aHandlerApp) {
+ if (aHandlerApp instanceof Ci.nsILocalHandlerApp) {
+ return this._getIconURLForFile(aHandlerApp.executable);
+ }
+
+ if (aHandlerApp instanceof Ci.nsIWebHandlerApp) {
+ return this._getIconURLForWebApp(aHandlerApp.uriTemplate);
+ }
+
+ if (aHandlerApp instanceof Ci.nsIWebContentHandlerInfo) {
+ return this._getIconURLForWebApp(aHandlerApp.uri);
+ }
+
+ // We know nothing about other kinds of handler apps.
+ return "";
+ },
+
+ _getIconURLForFile(aFile) {
+ let urlSpec = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getURLSpecFromActualFile(aFile);
+
+ return "moz-icon://" + urlSpec + "?size=16";
+ },
+
+ _getIconURLForWebApp(aWebAppURITemplate) {
+ var uri = Services.io.newURI(aWebAppURITemplate);
+
+ // Unfortunately we can't use the favicon service to get the favicon,
+ // because the service looks in the annotations table for a record with
+ // the exact URL we give it, and users won't have such records for URLs
+ // they don't visit, and users won't visit the web app's URL template,
+ // they'll only visit URLs derived from that template (i.e. with %s
+ // in the template replaced by the URL of the content being handled).
+
+ if (/^https?/.test(uri.scheme)) {
+ return uri.prePath + "/favicon.ico";
+ }
+
+ return /^https?/.test(uri.scheme) ? uri.resolve("/favicon.ico") : "";
+ },
+
+ destroy() {
+ window.removeEventListener("unload", this);
+
+ Services.obs.removeObserver(this, AUTO_UPDATE_CHANGED_TOPIC);
+ Services.prefs.removeObserver("mailnews.tags.", this);
+ },
+
+ // nsISupports
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ // nsIObserver
+
+ async observe(subject, topic, data) {
+ if (topic == AUTO_UPDATE_CHANGED_TOPIC) {
+ if (data != "true" && data != "false") {
+ throw new Error(`Invalid value for app.update.auto ${data}`);
+ }
+ document.getElementById("updateRadioGroup").value = data;
+ } else if (topic == "nsPref:changed" && data.startsWith("mailnews.tags.")) {
+ let selIndex = this.mTagListBox.selectedIndex;
+ this.mTagListBox.replaceChildren();
+ this.buildTagList();
+ let numItemsInListBox = this.mTagListBox.getRowCount();
+ this.mTagListBox.selectedIndex =
+ selIndex < numItemsInListBox ? selIndex : numItemsInListBox - 1;
+ if (data.endsWith(".color") && Services.prefs.prefHasUserValue(data)) {
+ let key = data.replace(/^mailnews\.tags\./, "").replace(/\.color$/, "");
+ let color = Services.prefs.getCharPref(`mailnews.tags.${key}.color`);
+ // Add to style sheet. We simply add the new color, the rule is added
+ // at the end and will overrule the previous rule.
+ TagUtils.addTagToAllDocumentSheets(key, color);
+ }
+ }
+ },
+
+ // EventListener
+
+ handleEvent(aEvent) {
+ if (aEvent.type == "unload") {
+ this.destroy();
+ if (AppConstants.MOZ_UPDATER) {
+ onUnload();
+ }
+ }
+ },
+};
+
+function getDisplayNameForFile(aFile) {
+ if (AppConstants.platform == "win") {
+ if (aFile instanceof Ci.nsILocalFileWin) {
+ try {
+ return aFile.getVersionInfoField("FileDescription");
+ } catch (ex) {
+ // fall through to the file name
+ }
+ }
+ } else if (AppConstants.platform == "macosx") {
+ if (aFile instanceof Ci.nsILocalFileMac) {
+ try {
+ return aFile.bundleDisplayName;
+ } catch (ex) {
+ // fall through to the file name
+ }
+ }
+ }
+
+ return aFile.leafName;
+}
+
+function getLocalHandlerApp(aFile) {
+ var localHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandlerApp.name = getDisplayNameForFile(aFile);
+ localHandlerApp.executable = aFile;
+
+ return localHandlerApp;
+}
+
+// eslint-disable-next-line no-undef
+let gHandlerRowFragment = MozXULElement.parseXULToFragment(`
+ <html:tr>
+ <html:td class="typeCell">
+ <html:div class="typeLabel">
+ <html:img class="typeIcon" alt=""/>
+ <label class="typeDescription" crop="end"/>
+ </html:div>
+ </html:td>
+ <html:td class="actionCell">
+ <menulist class="actionsMenu" crop="end" selectedIndex="1">
+ <menupopup/>
+ </menulist>
+ </html:td>
+ </html:tr>
+`);
+
+/**
+ * This is associated to rows in the handlers table.
+ */
+class HandlerRow {
+ constructor(handlerInfoWrapper, onDeleteCallback) {
+ this.handlerInfoWrapper = handlerInfoWrapper;
+ this.previousSelectedItem = null;
+ this.deleteCallback = onDeleteCallback;
+ }
+
+ constructNodeAndAppend(tbody, id) {
+ tbody.appendChild(document.importNode(gHandlerRowFragment, true));
+ this.node = tbody.lastChild;
+
+ this.menu = this.node.querySelector(".actionsMenu");
+ id = `action-menu-${id}`;
+ this.menu.setAttribute("id", id);
+ this.menu.addEventListener("command", event =>
+ this.onSelectAction(event.originalTarget)
+ );
+
+ let typeDescription = this.node.querySelector(".typeDescription");
+ typeDescription.setAttribute(
+ "value",
+ this.handlerInfoWrapper.typeDescription
+ );
+ // NOTE: Control only works for a XUL <label>. Using a HTML <label> and the
+ // corresponding "for" attribute would not currently work with the XUL
+ // <menulist> because a XUL <menulist> is technically not a labelable
+ // element, as required for the html:label "for" attribute.
+ typeDescription.setAttribute("control", id);
+ // Spoof the HTML label "for" attribute focus behaviour on the whole cell.
+ this.node
+ .querySelector(".typeCell")
+ .addEventListener("click", () => this.menu.focus());
+
+ this.node
+ .querySelector(".typeIcon")
+ .setAttribute("src", this.handlerInfoWrapper.smallIcon);
+
+ this.rebuildActionsMenu();
+ }
+
+ rebuildActionsMenu() {
+ let menu = this.menu;
+ let menuPopup = menu.menupopup;
+ let handlerInfo = this.handlerInfoWrapper;
+
+ // Clear out existing items.
+ while (menuPopup.hasChildNodes()) {
+ menuPopup.removeChild(menuPopup.lastChild);
+ }
+
+ let internalMenuItem;
+ // Add the "Preview in Thunderbird" option for optional internal handlers.
+ if (handlerInfo instanceof InternalHandlerInfoWrapper) {
+ internalMenuItem = document.createXULElement("menuitem");
+ internalMenuItem.setAttribute(
+ "action",
+ Ci.nsIHandlerInfo.handleInternally
+ );
+ let label = gGeneralPane._prefsBundle.getFormattedString("previewInApp", [
+ gGeneralPane._brandShortName,
+ ]);
+ internalMenuItem.setAttribute("label", label);
+ internalMenuItem.setAttribute("tooltiptext", label);
+ internalMenuItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/preferences/alwaysAsk.png"
+ );
+ menuPopup.appendChild(internalMenuItem);
+ }
+
+ let askMenuItem = document.createXULElement("menuitem");
+ askMenuItem.setAttribute("alwaysAsk", "true");
+ {
+ let label = gGeneralPane._prefsBundle.getString("alwaysAsk");
+ askMenuItem.setAttribute("label", label);
+ askMenuItem.setAttribute("tooltiptext", label);
+ askMenuItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/preferences/alwaysAsk.png"
+ );
+ menuPopup.appendChild(askMenuItem);
+ }
+
+ // Create a menu item for saving to disk.
+ // Note: this option isn't available to protocol types, since we don't know
+ // what it means to save a URL having a certain scheme to disk.
+ let saveMenuItem;
+ if (handlerInfo.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) {
+ saveMenuItem = document.createXULElement("menuitem");
+ saveMenuItem.setAttribute("action", Ci.nsIHandlerInfo.saveToDisk);
+ let label = gGeneralPane._prefsBundle.getString("saveFile");
+ saveMenuItem.setAttribute("label", label);
+ saveMenuItem.setAttribute("tooltiptext", label);
+ saveMenuItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/preferences/saveFile.png"
+ );
+ menuPopup.appendChild(saveMenuItem);
+ }
+
+ // Add a separator to distinguish these items from the helper app items
+ // that follow them.
+ let menuItem = document.createXULElement("menuseparator");
+ menuPopup.appendChild(menuItem);
+
+ // Create a menu item for the OS default application, if any.
+ let defaultMenuItem;
+ if (handlerInfo.hasDefaultHandler) {
+ defaultMenuItem = document.createXULElement("menuitem");
+ defaultMenuItem.setAttribute(
+ "action",
+ Ci.nsIHandlerInfo.useSystemDefault
+ );
+ let label = gGeneralPane._prefsBundle.getFormattedString("useDefault", [
+ handlerInfo.defaultDescription,
+ ]);
+ defaultMenuItem.setAttribute("label", label);
+ defaultMenuItem.setAttribute(
+ "tooltiptext",
+ handlerInfo.defaultDescription
+ );
+ defaultMenuItem.setAttribute(
+ "image",
+ handlerInfo.iconURLForSystemDefault
+ );
+
+ menuPopup.appendChild(defaultMenuItem);
+ }
+
+ // Create menu items for possible handlers.
+ let preferredApp = handlerInfo.preferredApplicationHandler;
+ let possibleAppMenuItems = [];
+ for (let possibleApp of handlerInfo.possibleApplicationHandlers.enumerate()) {
+ if (!gGeneralPane.isValidHandlerApp(possibleApp)) {
+ continue;
+ }
+
+ let menuItem = document.createXULElement("menuitem");
+ menuItem.setAttribute("action", Ci.nsIHandlerInfo.useHelperApp);
+ let label;
+ if (possibleApp instanceof Ci.nsILocalHandlerApp) {
+ label = getDisplayNameForFile(possibleApp.executable);
+ } else {
+ label = possibleApp.name;
+ }
+ label = gGeneralPane._prefsBundle.getFormattedString("useApp", [label]);
+ menuItem.setAttribute("label", label);
+ menuItem.setAttribute("tooltiptext", label);
+ menuItem.setAttribute(
+ "image",
+ gGeneralPane._getIconURLForHandlerApp(possibleApp)
+ );
+
+ // Attach the handler app object to the menu item so we can use it
+ // to make changes to the datastore when the user selects the item.
+ menuItem.handlerApp = possibleApp;
+
+ menuPopup.appendChild(menuItem);
+ possibleAppMenuItems.push(menuItem);
+ }
+
+ // Create a menu item for selecting a local application.
+ let createItem = true;
+ if (AppConstants.platform == "win") {
+ // On Windows, selecting an application to open another application
+ // would be meaningless so we special case executables.
+ let executableType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromExtension("exe");
+ if (handlerInfo.type == executableType) {
+ createItem = false;
+ }
+ }
+
+ if (createItem) {
+ let menuItem = document.createXULElement("menuitem");
+ menuItem.addEventListener("command", this.chooseApp.bind(this));
+ let label = gGeneralPane._prefsBundle.getString("useOtherApp");
+ menuItem.setAttribute("label", label);
+ menuItem.setAttribute("tooltiptext", label);
+ menuPopup.appendChild(menuItem);
+ }
+
+ // Create a menu item for managing applications.
+ if (possibleAppMenuItems.length) {
+ let menuItem = document.createXULElement("menuseparator");
+ menuPopup.appendChild(menuItem);
+ menuItem = document.createXULElement("menuitem");
+ menuItem.addEventListener("command", this.manageApp.bind(this));
+ menuItem.setAttribute(
+ "label",
+ gGeneralPane._prefsBundle.getString("manageApp")
+ );
+ menuPopup.appendChild(menuItem);
+ }
+
+ menuItem = document.createXULElement("menuseparator");
+ menuPopup.appendChild(menuItem);
+ menuItem = document.createXULElement("menuitem");
+ menuItem.addEventListener("command", this.confirmDelete.bind(this));
+ menuItem.setAttribute(
+ "label",
+ gGeneralPane._prefsBundle.getString("delete")
+ );
+ menuPopup.appendChild(menuItem);
+
+ // Select the item corresponding to the preferred action. If the always
+ // ask flag is set, it overrides the preferred action. Otherwise we pick
+ // the item identified by the preferred action (when the preferred action
+ // is to use a helper app, we have to pick the specific helper app item).
+ if (handlerInfo.alwaysAskBeforeHandling) {
+ menu.selectedItem = askMenuItem;
+ } else {
+ switch (handlerInfo.preferredAction) {
+ case Ci.nsIHandlerInfo.handleInternally:
+ if (internalMenuItem) {
+ menu.selectedItem = internalMenuItem;
+ } else {
+ console.error("No menu item defined to set!");
+ }
+ break;
+ case Ci.nsIHandlerInfo.useSystemDefault:
+ menu.selectedItem = defaultMenuItem;
+ break;
+ case Ci.nsIHandlerInfo.useHelperApp:
+ if (preferredApp) {
+ menu.selectedItem = possibleAppMenuItems.filter(v =>
+ v.handlerApp.equals(preferredApp)
+ )[0];
+ }
+ break;
+ case Ci.nsIHandlerInfo.saveToDisk:
+ menu.selectedItem = saveMenuItem;
+ break;
+ }
+ }
+ // menu.selectedItem may be null if the preferredAction is
+ // useSystemDefault, but handlerInfo.hasDefaultHandler returns false.
+ // For now, we'll just use the askMenuItem to avoid ugly exceptions.
+ this.previousSelectedItem = this.menu.selectedItem || askMenuItem;
+ }
+
+ manageApp(aEvent) {
+ // Don't let the normal "on select action" handler get this event,
+ // as we handle it specially ourselves.
+ aEvent.stopPropagation();
+
+ var handlerInfo = this.handlerInfoWrapper;
+
+ let onComplete = () => {
+ // Rebuild the actions menu so that we revert to the previous selection,
+ // or "Always ask" if the previous default application has been removed.
+ this.rebuildActionsMenu();
+ };
+
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/applicationManager.xhtml",
+ { features: "resizable=no", closingCallback: onComplete },
+ handlerInfo
+ );
+ }
+
+ chooseApp(aEvent) {
+ // Don't let the normal "on select action" handler get this event,
+ // as we handle it specially ourselves.
+ aEvent.stopPropagation();
+
+ var handlerApp;
+ let onSelectionDone = function () {
+ // Rebuild the actions menu whether the user picked an app or canceled.
+ // If they picked an app, we want to add the app to the menu and select it.
+ // If they canceled, we want to go back to their previous selection.
+ this.rebuildActionsMenu();
+
+ // If the user picked a new app from the menu, select it.
+ if (handlerApp) {
+ let menuItems = this.menu.menupopup.children;
+ for (let i = 0; i < menuItems.length; i++) {
+ let menuItem = menuItems[i];
+ if (menuItem.handlerApp && menuItem.handlerApp.equals(handlerApp)) {
+ this.menu.selectedIndex = i;
+ this.onSelectAction(menuItem);
+ break;
+ }
+ }
+ }
+ }.bind(this);
+
+ if (AppConstants.platform == "win") {
+ let params = {};
+ let handlerInfo = this.handlerInfoWrapper;
+
+ params.mimeInfo = handlerInfo.wrappedHandlerInfo;
+
+ params.title = gGeneralPane._prefsBundle.getString("fpTitleChooseApp");
+ params.description = handlerInfo.description;
+ params.filename = null;
+ params.handlerApp = null;
+
+ let onAppSelected = () => {
+ if (gGeneralPane.isValidHandlerApp(params.handlerApp)) {
+ handlerApp = params.handlerApp;
+
+ // Add the app to the type's list of possible handlers.
+ handlerInfo.addPossibleApplicationHandler(handlerApp);
+ }
+ onSelectionDone();
+ };
+
+ gSubDialog.open(
+ "chrome://global/content/appPicker.xhtml",
+ { features: "resizable=no", closingCallback: onAppSelected },
+ params
+ );
+ } else {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ let winTitle = gGeneralPane._prefsBundle.getString("fpTitleChooseApp");
+ fp.init(window, winTitle, Ci.nsIFilePicker.modeOpen);
+ fp.appendFilters(Ci.nsIFilePicker.filterApps);
+
+ // Prompt the user to pick an app. If they pick one, and it's a valid
+ // selection, then add it to the list of possible handlers.
+
+ fp.open(rv => {
+ if (
+ rv == Ci.nsIFilePicker.returnOK &&
+ fp.file &&
+ gGeneralPane._isValidHandlerExecutable(fp.file)
+ ) {
+ handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.name = getDisplayNameForFile(fp.file);
+ handlerApp.executable = fp.file;
+
+ // Add the app to the type's list of possible handlers.
+ let handlerInfo = this.handlerInfoWrapper;
+ handlerInfo.addPossibleApplicationHandler(handlerApp);
+ }
+ onSelectionDone();
+ });
+ }
+ }
+
+ confirmDelete(aEvent) {
+ aEvent.stopPropagation();
+ if (
+ Services.prompt.confirm(
+ null,
+ gGeneralPane._prefsBundle.getString("confirmDeleteTitle"),
+ gGeneralPane._prefsBundle.getString("confirmDeleteText")
+ )
+ ) {
+ // Deletes self.
+ this.deleteCallback(this);
+ } else {
+ // They hit cancel, so return them to the previously selected item.
+ this.menu.selectedItem = this.previousSelectedItem;
+ }
+ }
+
+ onSelectAction(aActionItem) {
+ this.previousSelectedItem = aActionItem;
+ this._storeAction(aActionItem);
+ }
+
+ _storeAction(aActionItem) {
+ var handlerInfo = this.handlerInfoWrapper;
+
+ if (aActionItem.hasAttribute("alwaysAsk")) {
+ handlerInfo.alwaysAskBeforeHandling = true;
+ } else if (aActionItem.hasAttribute("action")) {
+ let action = parseInt(aActionItem.getAttribute("action"));
+
+ // Set the preferred application handler.
+ // We leave the existing preferred app in the list when we set
+ // the preferred action to something other than useHelperApp so that
+ // legacy datastores that don't have the preferred app in the list
+ // of possible apps still include the preferred app in the list of apps
+ // the user can choose to handle the type.
+ if (action == Ci.nsIHandlerInfo.useHelperApp) {
+ handlerInfo.preferredApplicationHandler = aActionItem.handlerApp;
+ }
+
+ // Set the "always ask" flag.
+ handlerInfo.alwaysAskBeforeHandling = false;
+
+ // Set the preferred action.
+ handlerInfo.preferredAction = action;
+ }
+
+ handlerInfo.store();
+ }
+}
+
+/**
+ * This object wraps nsIHandlerInfo with some additional functionality
+ * the Applications prefpane needs to display and allow modification of
+ * the list of handled types.
+ *
+ * We create an instance of this wrapper for each entry we might display
+ * in the prefpane, and we compose the instances from various sources,
+ * including the handler service.
+ *
+ * We don't implement all the original nsIHandlerInfo functionality,
+ * just the stuff that the prefpane needs.
+ */
+class HandlerInfoWrapper {
+ constructor(type, handlerInfo) {
+ this.type = type;
+ this.wrappedHandlerInfo = handlerInfo;
+ this.disambiguateDescription = false;
+ }
+
+ get description() {
+ if (this.wrappedHandlerInfo.description) {
+ return this.wrappedHandlerInfo.description;
+ }
+
+ if (this.primaryExtension) {
+ var extension = this.primaryExtension.toUpperCase();
+ return document
+ .getElementById("bundlePreferences")
+ .getFormattedString("fileEnding", [extension]);
+ }
+ return this.type;
+ }
+
+ /**
+ * Describe, in a human-readable fashion, the type represented by the given
+ * handler info object. Normally this is just the description, but if more
+ * than one object presents the same description, "disambiguateDescription"
+ * is set and we annotate the duplicate descriptions with the type itself
+ * to help users distinguish between those types.
+ */
+ get typeDescription() {
+ if (this.disambiguateDescription) {
+ return gGeneralPane._prefsBundle.getFormattedString(
+ "typeDetailsWithTypeAndExt",
+ [this.description, this.type]
+ );
+ }
+
+ return this.description;
+ }
+
+ /**
+ * Describe, in a human-readable fashion, the preferred action to take on
+ * the type represented by the given handler info object.
+ */
+ get actionDescription() {
+ // alwaysAskBeforeHandling overrides the preferred action, so if that flag
+ // is set, then describe that behavior instead. For most types, this is
+ // the "alwaysAsk" string, but for the feed type we show something special.
+ if (this.alwaysAskBeforeHandling) {
+ return gGeneralPane._prefsBundle.getString("alwaysAsk");
+ }
+
+ switch (this.preferredAction) {
+ case Ci.nsIHandlerInfo.saveToDisk:
+ return gGeneralPane._prefsBundle.getString("saveFile");
+
+ case Ci.nsIHandlerInfo.useHelperApp:
+ var preferredApp = this.preferredApplicationHandler;
+ var name;
+ if (preferredApp instanceof Ci.nsILocalHandlerApp) {
+ name = getDisplayNameForFile(preferredApp.executable);
+ } else {
+ name = preferredApp.name;
+ }
+ return gGeneralPane._prefsBundle.getFormattedString("useApp", [name]);
+
+ case Ci.nsIHandlerInfo.handleInternally:
+ if (this instanceof InternalHandlerInfoWrapper) {
+ return gGeneralPane._prefsBundle.getFormattedString("previewInApp", [
+ gGeneralPane._brandShortName,
+ ]);
+ }
+
+ // For other types, handleInternally looks like either useHelperApp
+ // or useSystemDefault depending on whether or not there's a preferred
+ // handler app.
+ if (gGeneralPane.isValidHandlerApp(this.preferredApplicationHandler)) {
+ return this.preferredApplicationHandler.name;
+ }
+
+ return this.defaultDescription;
+
+ // XXX Why don't we say the app will handle the type internally?
+ // Is it because the app can't actually do that? But if that's true,
+ // then why would a preferredAction ever get set to this value
+ // in the first place?
+
+ case Ci.nsIHandlerInfo.useSystemDefault:
+ return gGeneralPane._prefsBundle.getFormattedString("useDefault", [
+ this.defaultDescription,
+ ]);
+
+ default:
+ throw new Error(`Unexpected preferredAction: ${this.preferredAction}`);
+ }
+ }
+
+ get actionIconClass() {
+ if (this.alwaysAskBeforeHandling) {
+ return "ask";
+ }
+
+ switch (this.preferredAction) {
+ case Ci.nsIHandlerInfo.saveToDisk:
+ return "save";
+
+ case Ci.nsIHandlerInfo.handleInternally:
+ if (this instanceof InternalHandlerInfoWrapper) {
+ return "ask";
+ }
+ }
+
+ return "";
+ }
+
+ get actionIcon() {
+ switch (this.preferredAction) {
+ case Ci.nsIHandlerInfo.useSystemDefault:
+ return this.iconURLForSystemDefault;
+
+ case Ci.nsIHandlerInfo.useHelperApp:
+ let preferredApp = this.preferredApplicationHandler;
+ if (gGeneralPane.isValidHandlerApp(preferredApp)) {
+ return gGeneralPane._getIconURLForHandlerApp(preferredApp);
+ }
+ // This should never happen, but if preferredAction is set to some weird
+ // value, then fall back to the generic application icon.
+
+ // Explicit fall-through
+ default:
+ return ICON_URL_APP;
+ }
+ }
+
+ get iconURLForSystemDefault() {
+ // Handler info objects for MIME types on some OSes implement a property bag
+ // interface from which we can get an icon for the default app, so if we're
+ // dealing with a MIME type on one of those OSes, then try to get the icon.
+ if (
+ this.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo &&
+ this.wrappedHandlerInfo instanceof Ci.nsIPropertyBag
+ ) {
+ try {
+ let url = this.wrappedHandlerInfo.getProperty(
+ "defaultApplicationIconURL"
+ );
+ if (url) {
+ return url + "?size=16";
+ }
+ } catch (ex) {}
+ }
+
+ // If this isn't a MIME type object on an OS that supports retrieving
+ // the icon, or if we couldn't retrieve the icon for some other reason,
+ // then use a generic icon.
+ return ICON_URL_APP;
+ }
+
+ get preferredApplicationHandler() {
+ return this.wrappedHandlerInfo.preferredApplicationHandler;
+ }
+
+ set preferredApplicationHandler(aNewValue) {
+ this.wrappedHandlerInfo.preferredApplicationHandler = aNewValue;
+
+ // Make sure the preferred handler is in the set of possible handlers.
+ if (aNewValue) {
+ this.addPossibleApplicationHandler(aNewValue);
+ }
+ }
+
+ get possibleApplicationHandlers() {
+ return this.wrappedHandlerInfo.possibleApplicationHandlers;
+ }
+
+ addPossibleApplicationHandler(aNewHandler) {
+ for (let possibleApp of this.possibleApplicationHandlers.enumerate()) {
+ if (possibleApp.equals(aNewHandler)) {
+ return;
+ }
+ }
+ this.possibleApplicationHandlers.appendElement(aNewHandler);
+ }
+
+ removePossibleApplicationHandler(aHandler) {
+ var defaultApp = this.preferredApplicationHandler;
+ if (defaultApp && aHandler.equals(defaultApp)) {
+ // If the app we remove was the default app, we must make sure
+ // it won't be used anymore
+ this.alwaysAskBeforeHandling = true;
+ this.preferredApplicationHandler = null;
+ }
+
+ var handlers = this.possibleApplicationHandlers;
+ for (var i = 0; i < handlers.length; ++i) {
+ var handler = handlers.queryElementAt(i, Ci.nsIHandlerApp);
+ if (handler.equals(aHandler)) {
+ handlers.removeElementAt(i);
+ break;
+ }
+ }
+ }
+
+ get hasDefaultHandler() {
+ return this.wrappedHandlerInfo.hasDefaultHandler;
+ }
+
+ get defaultDescription() {
+ return this.wrappedHandlerInfo.defaultDescription;
+ }
+
+ // What to do with content of this type.
+ get preferredAction() {
+ // If the action is to use a helper app, but we don't have a preferred
+ // handler app, then switch to using the system default, if any; otherwise
+ // fall back to saving to disk, which is the default action in nsMIMEInfo.
+ // Note: "save to disk" is an invalid value for protocol info objects,
+ // but the alwaysAskBeforeHandling getter will detect that situation
+ // and always return true in that case to override this invalid value.
+ if (
+ this.wrappedHandlerInfo.preferredAction ==
+ Ci.nsIHandlerInfo.useHelperApp &&
+ !gGeneralPane.isValidHandlerApp(this.preferredApplicationHandler)
+ ) {
+ if (this.wrappedHandlerInfo.hasDefaultHandler) {
+ return Ci.nsIHandlerInfo.useSystemDefault;
+ }
+ return Ci.nsIHandlerInfo.saveToDisk;
+ }
+
+ return this.wrappedHandlerInfo.preferredAction;
+ }
+
+ set preferredAction(aNewValue) {
+ this.wrappedHandlerInfo.preferredAction = aNewValue;
+ }
+
+ get alwaysAskBeforeHandling() {
+ // If this is a protocol type and the preferred action is "save to disk",
+ // which is invalid for such types, then return true here to override that
+ // action. This could happen when the preferred action is to use a helper
+ // app, but the preferredApplicationHandler is invalid, and there isn't
+ // a default handler, so the preferredAction getter returns save to disk
+ // instead.
+ if (
+ !(this.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) &&
+ this.preferredAction == Ci.nsIHandlerInfo.saveToDisk
+ ) {
+ return true;
+ }
+
+ return this.wrappedHandlerInfo.alwaysAskBeforeHandling;
+ }
+
+ set alwaysAskBeforeHandling(aNewValue) {
+ this.wrappedHandlerInfo.alwaysAskBeforeHandling = aNewValue;
+ }
+
+ // The primary file extension associated with this type, if any.
+ get primaryExtension() {
+ try {
+ if (
+ this.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo &&
+ this.wrappedHandlerInfo.primaryExtension
+ ) {
+ return this.wrappedHandlerInfo.primaryExtension;
+ }
+ } catch (ex) {}
+
+ return null;
+ }
+
+ // -------
+ // Storage
+
+ store() {
+ gHandlerService.store(this.wrappedHandlerInfo);
+ }
+
+ remove() {
+ gHandlerService.remove(this.wrappedHandlerInfo);
+ }
+
+ // -----
+ // Icons
+
+ get smallIcon() {
+ return this._getIcon(16);
+ }
+
+ get largeIcon() {
+ return this._getIcon(32);
+ }
+
+ _getIcon(aSize) {
+ if (this.primaryExtension) {
+ return "moz-icon://goat." + this.primaryExtension + "?size=" + aSize;
+ }
+
+ if (this.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) {
+ return "moz-icon://goat?size=" + aSize + "&contentType=" + this.type;
+ }
+
+ // FIXME: consider returning some generic icon when we can't get a URL for
+ // one (for example in the case of protocol schemes). Filed as bug 395141.
+ return null;
+ }
+}
+
+/**
+ * InternalHandlerInfoWrapper provides a basic mechanism to create an internal
+ * mime type handler that can be enabled/disabled in the applications preference
+ * menu.
+ */
+class InternalHandlerInfoWrapper extends HandlerInfoWrapper {
+ constructor(mimeType) {
+ super(mimeType, gMIMEService.getFromTypeAndExtension(mimeType, null));
+ }
+
+ // Override store so we so we can notify any code listening for registration
+ // or unregistration of this handler.
+ store() {
+ super.store();
+ }
+
+ get enabled() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ get description() {
+ return gGeneralPane._prefsBundle.getString(this._appPrefLabel);
+ }
+}
+
+class PDFHandlerInfoWrapper extends InternalHandlerInfoWrapper {
+ constructor() {
+ super(TYPE_PDF);
+ }
+
+ get _appPrefLabel() {
+ return "applications-type-pdf";
+ }
+
+ get enabled() {
+ return !Services.prefs.getBoolPref(PREF_PDFJS_DISABLED);
+ }
+}
+
+function addTagCallback(aName, aColor) {
+ MailServices.tags.addTag(aName, aColor, "");
+
+ // Add to style sheet.
+ let key = MailServices.tags.getKeyForTag(aName);
+ let tagListBox = document.getElementById("tagList");
+ let item = tagListBox.querySelector(`richlistitem[value=${key}]`);
+ tagListBox.ensureElementIsVisible(item);
+ tagListBox.selectItem(item);
+ tagListBox.focus();
+ return true;
+}
+
+Preferences.get("mailnews.start_page.enabled").on(
+ "change",
+ gGeneralPane.updateStartPage
+);
+Preferences.get("font.language.group").on("change", gGeneralPane._rebuildFonts);
+Preferences.get("mailnews.mark_message_read.auto").on(
+ "change",
+ gGeneralPane.updateMarkAsReadOptions
+);
+Preferences.get("mailnews.mark_message_read.delay").on(
+ "change",
+ gGeneralPane.updateMarkAsReadTextbox
+);
+Preferences.get("mail.prompt_purge_threshhold").on(
+ "change",
+ gGeneralPane.updateCompactOptions
+);
+Preferences.get("layers.acceleration.disabled").on(
+ "change",
+ gGeneralPane.updateHardwareAcceleration
+);
+if (AppConstants.platform != "macosx") {
+ Preferences.get("mail.biff.show_alert").on(
+ "change",
+ gGeneralPane.updateShowAlert
+ );
+}
diff --git a/comm/mail/components/preferences/jar.mn b/comm/mail/components/preferences/jar.mn
new file mode 100644
index 0000000000..fc9b56184e
--- /dev/null
+++ b/comm/mail/components/preferences/jar.mn
@@ -0,0 +1,55 @@
+# 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/preferences/preferences.xhtml
+ content/messenger/preferences/preferences.js
+ content/messenger/preferences/preferencesTab.js
+ content/messenger/preferences/general.js
+#if defined(XP_MACOSX) || defined(XP_WIN)
+ content/messenger/preferences/dockoptions.js
+* content/messenger/preferences/dockoptions.xhtml
+#endif
+ content/messenger/preferences/chat.js
+#ifdef NIGHTLY_BUILD
+ content/messenger/preferences/sync.js
+#endif
+ content/messenger/preferences/messagestyle.js
+ content/messenger/preferences/messengerLanguages.js
+ content/messenger/preferences/messengerLanguages.xhtml
+ content/messenger/preferences/colors.js
+* content/messenger/preferences/colors.xhtml
+ content/messenger/preferences/compose.js
+ content/messenger/preferences/extensionControlled.js
+ content/messenger/preferences/privacy.js
+ content/messenger/preferences/receipts.js
+ content/messenger/preferences/receipts.xhtml
+ content/messenger/preferences/connection.js
+ content/messenger/preferences/connection.xhtml
+ content/messenger/preferences/downloads.js
+ content/messenger/preferences/attachmentReminder.js
+ content/messenger/preferences/attachmentReminder.xhtml
+ content/messenger/preferences/applicationManager.xhtml
+ content/messenger/preferences/applicationManager.js
+ content/messenger/preferences/actionsshared.js
+ content/messenger/preferences/findInPage.js
+ content/messenger/preferences/fonts.js
+ content/messenger/preferences/fonts.xhtml
+#ifndef XP_MACOSX
+ content/messenger/preferences/notifications.js
+ content/messenger/preferences/notifications.xhtml
+#endif
+ content/messenger/preferences/offline.js
+ content/messenger/preferences/offline.xhtml
+ content/messenger/preferences/cookies.js
+* content/messenger/preferences/cookies.xhtml
+ content/messenger/preferences/passwordManager.js
+ content/messenger/preferences/passwordManager.xhtml
+ content/messenger/preferences/permissions.js
+ content/messenger/preferences/permissions.xhtml
+#ifdef NIGHTLY_BUILD
+ content/messenger/preferences/syncDialog.js
+ content/messenger/preferences/syncDialog.xhtml
+#endif
+* content/messenger/preferences/tagDialog.xhtml
diff --git a/comm/mail/components/preferences/messagestyle.js b/comm/mail/components/preferences/messagestyle.js
new file mode 100644
index 0000000000..7d10553296
--- /dev/null
+++ b/comm/mail/components/preferences/messagestyle.js
@@ -0,0 +1,259 @@
+/* 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 preferences.js */
+
+var { GenericConvIMPrototype, GenericMessagePrototype } =
+ ChromeUtils.importESModule("resource:///modules/jsProtoHelper.sys.mjs");
+var { getThemeByName, getThemeVariants } = ChromeUtils.importESModule(
+ "resource:///modules/imThemes.sys.mjs"
+);
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+function Conversation(aName) {
+ this._name = aName;
+ this._observers = [];
+ let now = new Date();
+ this._date =
+ new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10, 42, 22) *
+ 1000;
+}
+Conversation.prototype = {
+ __proto__: GenericConvIMPrototype,
+ account: {
+ protocol: { name: "Fake Protocol" },
+ alias: "",
+ name: "Fake Account",
+ get statusInfo() {
+ return IMServices.core.globalUserStatus;
+ },
+ },
+};
+
+function Message(aWho, aMessage, aObject, aConversation) {
+ this._init(aWho, aMessage, aObject, aConversation);
+}
+Message.prototype = {
+ __proto__: GenericMessagePrototype,
+ get displayMessage() {
+ return this.originalMessage;
+ },
+};
+
+// Message style tooltips use this.
+function getBrowser() {
+ return document.getElementById("previewbrowser");
+}
+
+var previewObserver = {
+ _loaded: false,
+ load() {
+ let makeDate = function (aDateString) {
+ let array = aDateString.split(":");
+ let now = new Date();
+ return (
+ new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ array[0],
+ array[1],
+ array[2]
+ ) / 1000
+ );
+ };
+ let bundle = document.getElementById("themesBundle");
+ let msg = {};
+ [
+ "nick1",
+ "buddy1",
+ "nick2",
+ "buddy2",
+ "message1",
+ "message2",
+ "message3",
+ ].forEach(function (aText) {
+ msg[aText] = bundle.getString(aText);
+ });
+ let conv = new Conversation(msg.nick2);
+ conv.messages = [
+ new Message(
+ msg.buddy1,
+ msg.message1,
+ {
+ outgoing: true,
+ _alias: msg.nick1,
+ time: makeDate("10:42:22"),
+ },
+ conv
+ ),
+ new Message(
+ msg.buddy1,
+ msg.message2,
+ {
+ outgoing: true,
+ _alias: msg.nick1,
+ time: makeDate("10:42:25"),
+ },
+ conv
+ ),
+ new Message(
+ msg.buddy2,
+ msg.message3,
+ {
+ incoming: true,
+ _alias: msg.nick2,
+ time: makeDate("10:43:01"),
+ },
+ conv
+ ),
+ ];
+ previewObserver.conv = conv;
+
+ let themeName = document.getElementById("messagestyle-themename");
+ previewObserver.browser = document.getElementById("previewbrowser");
+
+ // If the preferences tab is opened straight to the message styles,
+ // loading the preview fails. Pushing this to back of the event queue
+ // prevents that failure.
+ setTimeout(() => {
+ previewObserver.displayTheme(themeName.value);
+ this._loaded = true;
+ });
+ },
+
+ currentThemeChanged() {
+ if (!this._loaded) {
+ return;
+ }
+
+ let currentTheme = document.getElementById("messagestyle-themename").value;
+ if (!currentTheme) {
+ return;
+ }
+
+ this.displayTheme(currentTheme);
+ },
+
+ _ignoreVariantChange: false,
+ currentVariantChanged() {
+ if (!this._loaded || this._ignoreVariantChange) {
+ return;
+ }
+
+ let variant = document.getElementById("themevariant").value;
+ if (!variant) {
+ return;
+ }
+
+ this.theme.variant = variant;
+ this.reloadPreview();
+ },
+
+ displayTheme(aTheme) {
+ try {
+ this.theme = getThemeByName(aTheme);
+ } catch (e) {
+ let previewBoxBrowser = document
+ .getElementById("previewBox")
+ .querySelector("browser");
+ if (previewBoxBrowser) {
+ previewBoxBrowser.hidden = true;
+ }
+ document.getElementById("noPreviewScreen").hidden = false;
+ return;
+ }
+
+ let menulist = document.getElementById("themevariant");
+ if (menulist.menupopup) {
+ menulist.menupopup.remove();
+ }
+ let popup = menulist.appendChild(document.createXULElement("menupopup"));
+ let variants = getThemeVariants(this.theme);
+
+ let defaultVariant = "";
+ if (
+ "DefaultVariant" in this.theme.metadata &&
+ variants.includes(this.theme.metadata.DefaultVariant)
+ ) {
+ defaultVariant = this.theme.metadata.DefaultVariant.replace(/_/g, " ");
+ }
+
+ let defaultText = defaultVariant;
+ if (!defaultText && "DisplayNameForNoVariant" in this.theme.metadata) {
+ defaultText = this.theme.metadata.DisplayNameForNoVariant;
+ }
+ // if the name in the metadata is 'Default', use the localized version
+ if (!defaultText || defaultText.toLowerCase() == "default") {
+ defaultText = document
+ .getElementById("themesBundle")
+ .getString("default");
+ }
+
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", defaultText);
+ menuitem.setAttribute("value", "default");
+ popup.appendChild(menuitem);
+ popup.appendChild(document.createXULElement("menuseparator"));
+
+ variants.sort().forEach(function (aVariantName) {
+ let displayName = aVariantName.replace(/_/g, " ");
+ if (displayName != defaultVariant) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", displayName);
+ menuitem.setAttribute("value", aVariantName);
+ popup.appendChild(menuitem);
+ }
+ });
+ this._ignoreVariantChange = true;
+ if (!this._loaded) {
+ menulist.value = this.theme.variant = menulist.value;
+ } else {
+ menulist.value = this.theme.variant; // (reset to "default")
+ Preferences.userChangedValue(menulist);
+ }
+ this._ignoreVariantChange = false;
+
+ // disable the variant menulist if there's no variant, or only one
+ // which is the default
+ menulist.disabled =
+ variants.length == 0 || (variants.length == 1 && defaultVariant);
+
+ this.reloadPreview();
+ document.getElementById("noPreviewScreen").hidden = true;
+ },
+
+ reloadPreview() {
+ this.browser.init(this.conv);
+ this.browser._theme = this.theme;
+ Services.obs.addObserver(this, "conversation-loaded");
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "conversation-loaded" || aSubject != this.browser) {
+ return;
+ }
+
+ // We want to avoid the convbrowser trying to scroll to the last
+ // added message, as that causes the entire pref pane to jump up
+ // (bug 1179943). Therefore, we override the method convbrowser
+ // uses to determine if it should scroll, as well as its
+ // mirror in the contentWindow (that messagestyle JS can call).
+ this.browser.convScrollEnabled = () => false;
+ this.browser.contentWindow.convScrollEnabled = () => false;
+
+ // Display all queued messages. Use a timeout so that message text
+ // modifiers can be added with observers for this notification.
+ setTimeout(function () {
+ for (let message of previewObserver.conv.messages) {
+ aSubject.appendMessage(message, false);
+ }
+ }, 0);
+
+ Services.obs.removeObserver(this, "conversation-loaded");
+ },
+};
diff --git a/comm/mail/components/preferences/messengerLanguages.js b/comm/mail/components/preferences/messengerLanguages.js
new file mode 100644
index 0000000000..e66f5f7fd0
--- /dev/null
+++ b/comm/mail/components/preferences/messengerLanguages.js
@@ -0,0 +1,632 @@
+/* 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 is exported by preferences.js but we can't import that in a subdialog.
+let { getAvailableLocales } = window.top;
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+});
+
+/* This dialog provides an interface for managing what language the messenger is
+ * displayed in.
+ *
+ * There is a list of "requested" locales and a list of "available" locales. The
+ * requested locales must be installed and enabled. Available locales could be
+ * installed and enabled, or fetched from the AMO language tools API.
+ *
+ * If a langpack is disabled, there is no way to determine what locale it is for and
+ * it will only be listed as available if that locale is also available on AMO and
+ * the user has opted to search for more languages.
+ */
+
+class OrderedListBox {
+ constructor({ richlistbox, upButton, downButton, removeButton, onRemove }) {
+ this.richlistbox = richlistbox;
+ this.upButton = upButton;
+ this.downButton = downButton;
+ this.removeButton = removeButton;
+ this.onRemove = onRemove;
+
+ this.items = [];
+
+ this.richlistbox.addEventListener("select", () => this.setButtonState());
+ this.upButton.addEventListener("command", () => this.moveUp());
+ this.downButton.addEventListener("command", () => this.moveDown());
+ this.removeButton.addEventListener("command", () => this.removeItem());
+ }
+
+ get selectedItem() {
+ return this.items[this.richlistbox.selectedIndex];
+ }
+
+ setButtonState() {
+ let { upButton, downButton, removeButton } = this;
+ let { selectedIndex, itemCount } = this.richlistbox;
+ upButton.disabled = selectedIndex <= 0;
+ downButton.disabled = selectedIndex == itemCount - 1;
+ removeButton.disabled = itemCount <= 1 || !this.selectedItem.canRemove;
+ }
+
+ moveUp() {
+ let { selectedIndex } = this.richlistbox;
+ if (selectedIndex == 0) {
+ return;
+ }
+ let { items } = this;
+ let selectedItem = items[selectedIndex];
+ let prevItem = items[selectedIndex - 1];
+ items[selectedIndex - 1] = items[selectedIndex];
+ items[selectedIndex] = prevItem;
+ let prevEl = document.getElementById(prevItem.id);
+ let selectedEl = document.getElementById(selectedItem.id);
+ this.richlistbox.insertBefore(selectedEl, prevEl);
+ this.richlistbox.ensureElementIsVisible(selectedEl);
+ this.setButtonState();
+ }
+
+ moveDown() {
+ let { selectedIndex } = this.richlistbox;
+ if (selectedIndex == this.items.length - 1) {
+ return;
+ }
+ let { items } = this;
+ let selectedItem = items[selectedIndex];
+ let nextItem = items[selectedIndex + 1];
+ items[selectedIndex + 1] = items[selectedIndex];
+ items[selectedIndex] = nextItem;
+ let nextEl = document.getElementById(nextItem.id);
+ let selectedEl = document.getElementById(selectedItem.id);
+ this.richlistbox.insertBefore(nextEl, selectedEl);
+ this.richlistbox.ensureElementIsVisible(selectedEl);
+ this.setButtonState();
+ }
+
+ removeItem() {
+ let { selectedIndex } = this.richlistbox;
+
+ if (selectedIndex == -1) {
+ return;
+ }
+
+ let [item] = this.items.splice(selectedIndex, 1);
+ this.richlistbox.selectedItem.remove();
+ this.richlistbox.selectedIndex = Math.min(
+ selectedIndex,
+ this.richlistbox.itemCount - 1
+ );
+ this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
+ this.onRemove(item);
+ }
+
+ setItems(items) {
+ this.items = items;
+ this.populate();
+ this.setButtonState();
+ }
+
+ /**
+ * Add an item to the top of the ordered list.
+ *
+ * @param {object} item The item to insert.
+ */
+ addItem(item) {
+ this.items.unshift(item);
+ this.richlistbox.insertBefore(
+ this.createItem(item),
+ this.richlistbox.firstElementChild
+ );
+ this.richlistbox.selectedIndex = 0;
+ this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
+ }
+
+ populate() {
+ this.richlistbox.textContent = "";
+
+ let frag = document.createDocumentFragment();
+ for (let item of this.items) {
+ frag.appendChild(this.createItem(item));
+ }
+ this.richlistbox.appendChild(frag);
+
+ this.richlistbox.selectedIndex = 0;
+ this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
+ }
+
+ createItem({ id, label, value }) {
+ let listitem = document.createXULElement("richlistitem");
+ listitem.id = id;
+ listitem.setAttribute("value", value);
+
+ let labelEl = document.createXULElement("label");
+ labelEl.textContent = label;
+ listitem.appendChild(labelEl);
+
+ return listitem;
+ }
+}
+
+/**
+ * The sorted select list of Locales available for the app.
+ */
+class SortedItemSelectList {
+ constructor({ menulist, button, onSelect, onChange, compareFn }) {
+ /** @type {XULElement} */
+ this.menulist = menulist;
+
+ /** @type {XULElement} */
+ this.popup = menulist.menupopup;
+
+ /** @type {XULElement} */
+ this.button = button;
+
+ /** @type {(a: LocaleDisplayInfo, b: LocaleDisplayInfo) => number} */
+ this.compareFn = compareFn;
+
+ /** @type {Array<LocaleDisplayInfo>} */
+ this.items = [];
+
+ menulist.addEventListener("command", () => {
+ button.disabled = !menulist.selectedItem;
+ if (menulist.selectedItem) {
+ onChange(this.items[menulist.selectedIndex]);
+ }
+ });
+ button.addEventListener("command", () => {
+ if (!menulist.selectedItem) {
+ return;
+ }
+
+ let [item] = this.items.splice(menulist.selectedIndex, 1);
+ menulist.selectedItem.remove();
+ menulist.setAttribute("label", menulist.getAttribute("placeholder"));
+ button.disabled = true;
+ menulist.disabled = menulist.itemCount == 0;
+ menulist.selectedIndex = -1;
+
+ onSelect(item);
+ });
+ }
+
+ /**
+ * @param {Array<LocaleDisplayInfo>} items
+ */
+ setItems(items) {
+ this.items = items.sort(this.compareFn);
+ this.populate();
+ }
+
+ populate() {
+ let { button, items, menulist, popup } = this;
+ popup.textContent = "";
+
+ let frag = document.createDocumentFragment();
+ for (let item of items) {
+ frag.appendChild(this.createItem(item));
+ }
+ popup.appendChild(frag);
+
+ menulist.setAttribute("label", menulist.getAttribute("placeholder"));
+ menulist.disabled = menulist.itemCount == 0;
+ menulist.selectedIndex = -1;
+ button.disabled = true;
+ }
+
+ /**
+ * Add an item to the list sorted by the label.
+ *
+ * @param {object} item The item to insert.
+ */
+ addItem(item) {
+ let { compareFn, items, menulist, popup } = this;
+
+ // Find the index of the item to insert before.
+ let i = items.findIndex(el => compareFn(el, item) >= 0);
+ items.splice(i, 0, item);
+ popup.insertBefore(this.createItem(item), menulist.getItemAtIndex(i));
+ menulist.disabled = menulist.itemCount == 0;
+ }
+
+ createItem({ label, value, className, disabled }) {
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", label);
+ if (value) {
+ item.value = value;
+ }
+ if (className) {
+ item.classList.add(className);
+ }
+ if (disabled) {
+ item.setAttribute("disabled", "true");
+ }
+ return item;
+ }
+
+ /**
+ * Disable the inputs and set a data-l10n-id on the menulist. This can be
+ * reverted with `enableWithMessageId()`.
+ */
+ disableWithMessageId(messageId) {
+ this.menulist.setAttribute("data-l10n-id", messageId);
+ this.menulist.setAttribute(
+ "image",
+ "chrome://global/skin/icons/loading.png"
+ );
+ this.menulist.disabled = true;
+ this.button.disabled = true;
+ }
+
+ /**
+ * Enable the inputs and set a data-l10n-id on the menulist. This can be
+ * reverted with `disableWithMessageId()`.
+ */
+ enableWithMessageId(messageId) {
+ this.menulist.setAttribute("data-l10n-id", messageId);
+ this.menulist.removeAttribute("image");
+ this.menulist.disabled = this.menulist.itemCount == 0;
+ this.button.disabled = !this.menulist.selectedItem;
+ }
+}
+
+/**
+ * @typedef LocaleDisplayInfo
+ * @type {object}
+ * @property {string} id - A unique ID.
+ * @property {string} label - The localized display name.
+ * @property {string} value - The BCP 47 locale identifier or the word "search".
+ * @property {boolean} canRemove - Locales that are part of the packaged locales cannot be
+ * removed.
+ * @property {boolean} installed - Whether or not the locale is installed.
+ */
+
+/**
+ * @param {Array<string>} localeCodes - List of BCP 47 locale identifiers.
+ * @returns {Array<LocaleDisplayInfo>}
+ */
+async function getLocaleDisplayInfo(localeCodes) {
+ let availableLocales = new Set(await getAvailableLocales());
+ let packagedLocales = new Set(Services.locale.packagedLocales);
+ let localeNames = Services.intl.getLocaleDisplayNames(
+ undefined,
+ localeCodes,
+ { preferNative: true }
+ );
+ return localeCodes.map((code, i) => {
+ return {
+ id: "locale-" + code,
+ label: localeNames[i],
+ value: code,
+ canRemove: !packagedLocales.has(code),
+ installed: availableLocales.has(code),
+ };
+ });
+}
+
+/**
+ * @param {LocaleDisplayInfo} a
+ * @param {LocaleDisplayInfo} b
+ * @returns {number}
+ */
+function compareItems(a, b) {
+ // Sort by installed.
+ if (a.installed != b.installed) {
+ return a.installed ? -1 : 1;
+
+ // The search label is always last.
+ } else if (a.value == "search") {
+ return 1;
+ } else if (b.value == "search") {
+ return -1;
+
+ // If both items are locales, sort by label.
+ } else if (a.value && b.value) {
+ return a.label.localeCompare(b.label);
+
+ // One of them is a label, put it first.
+ } else if (a.value) {
+ return 1;
+ }
+ return -1;
+}
+
+var gMessengerLanguagesDialog = {
+ /**
+ * The publicly readable list of selected locales. It is only set when the dialog is
+ * accepted, and can be retrieved elsewhere by directly reading the property
+ * on gMessengerLanguagesDialog.
+ *
+ * let { selected } = gMessengerLanguagesDialog;
+ *
+ * @type {null | Array<string>}
+ */
+ selected: null,
+
+ /**
+ * @type {SortedItemSelectList}
+ */
+ _availableLocalesUI: null,
+
+ /**
+ * @type {OrderedListBox}
+ */
+ _selectedLocalesUI: null,
+
+ get downloadEnabled() {
+ // Downloading langpacks isn't always supported, check the pref.
+ return Services.prefs.getBoolPref("intl.multilingual.downloadEnabled");
+ },
+
+ async onLoad() {
+ /**
+ * @typedef {object} Options - Options passed in to configure the subdialog.
+ * @property {Array<string>} [selectedLocalesForRestart] The optional list of
+ * previously selected locales for when a restart is required. This list is
+ * preserved between openings of the dialog.
+ * @property {boolean} search Whether the user opened this from "Search for more
+ * languages" option.
+ */
+
+ /** @type {Options} */
+ let { selectedLocalesForRestart, search } = window.arguments[0];
+
+ // This is a list of available locales that the user selected. It's more
+ // restricted than the Intl notion of `requested` as it only contains
+ // locale codes for which we have matching locales available.
+ // The first time this dialog is opened, populate with appLocalesAsBCP47.
+ let selectedLocales =
+ selectedLocalesForRestart || Services.locale.appLocalesAsBCP47;
+ let selectedLocaleSet = new Set(selectedLocales);
+ let available = await getAvailableLocales();
+ let availableSet = new Set(available);
+
+ // Filter selectedLocales since the user may select a locale when it is
+ // available and then disable it.
+ selectedLocales = selectedLocales.filter(locale =>
+ availableSet.has(locale)
+ );
+ // Nothing in available should be in selectedSet.
+ available = available.filter(locale => !selectedLocaleSet.has(locale));
+
+ await this.initSelectedLocales(selectedLocales);
+ await this.initAvailableLocales(available, search);
+
+ this.initialized = true;
+
+ // Now the component is initialized, it's safe to accept the results.
+ document
+ .getElementById("MessengerLanguagesDialog")
+ .addEventListener("beforeaccept", () => {
+ this.selected = this._selectedLocalesUI.items.map(item => item.value);
+ });
+ },
+
+ /**
+ * @param {string[]} selectedLocales - BCP 47 locale identifiers
+ */
+ async initSelectedLocales(selectedLocales) {
+ this._selectedLocalesUI = new OrderedListBox({
+ richlistbox: document.getElementById("selectedLocales"),
+ upButton: document.getElementById("up"),
+ downButton: document.getElementById("down"),
+ removeButton: document.getElementById("remove"),
+ onRemove: item => this.selectedLocaleRemoved(item),
+ });
+ this._selectedLocalesUI.setItems(
+ await getLocaleDisplayInfo(selectedLocales)
+ );
+ },
+
+ /**
+ * @param {Set<string>} available - The set of available BCP 47 locale identifiers.
+ * @param {boolean} search - Whether the user opened this from "Search for more
+ * languages" option.
+ */
+ async initAvailableLocales(available, search) {
+ this._availableLocalesUI = new SortedItemSelectList({
+ menulist: document.getElementById("availableLocales"),
+ button: document.getElementById("add"),
+ compareFn: compareItems,
+ onSelect: item => this.availableLanguageSelected(item),
+ onChange: item => {
+ this.hideError();
+ if (item.value == "search") {
+ this.loadLocalesFromAMO();
+ }
+ },
+ });
+
+ // Populate the list with the installed locales even if the user is
+ // searching in case the download fails.
+ await this.loadLocalesFromInstalled(available);
+
+ // If the user opened this from the "Search for more languages" option,
+ // search AMO for available locales.
+ if (search) {
+ return this.loadLocalesFromAMO();
+ }
+
+ return undefined;
+ },
+
+ async loadLocalesFromAMO() {
+ if (!this.downloadEnabled) {
+ return;
+ }
+
+ // Disable the dropdown while we hit the network.
+ this._availableLocalesUI.disableWithMessageId(
+ "messenger-languages-searching"
+ );
+
+ // Fetch the available langpacks from AMO.
+ let availableLangpacks;
+ try {
+ availableLangpacks = await AddonRepository.getAvailableLangpacks();
+ } catch (e) {
+ this.showError();
+ return;
+ }
+
+ // Store the available langpack info for later use.
+ this.availableLangpacks = new Map();
+ for (let { target_locale, url, hash } of availableLangpacks) {
+ this.availableLangpacks.set(target_locale, { url, hash });
+ }
+
+ // Remove the installed locales from the available ones.
+ let installedLocales = new Set(await getAvailableLocales());
+ let notInstalledLocales = availableLangpacks
+ .filter(({ target_locale }) => !installedLocales.has(target_locale))
+ .map(lang => lang.target_locale);
+
+ // Create the rows for the remote locales.
+ let availableItems = await getLocaleDisplayInfo(notInstalledLocales);
+ availableItems.push({
+ label: await document.l10n.formatValue(
+ "messenger-languages-available-label"
+ ),
+ className: "label-item",
+ disabled: true,
+ installed: false,
+ });
+
+ // Remove the search option and add the remote locales.
+ let items = this._availableLocalesUI.items;
+ items.pop();
+ items = items.concat(availableItems);
+
+ // Update the dropdown and enable it again.
+ this._availableLocalesUI.setItems(items);
+ this._availableLocalesUI.enableWithMessageId(
+ "messenger-languages-select-language"
+ );
+ },
+
+ /**
+ * @param {Set<string>} available - The set of available (BCP 47) locales.
+ */
+ async loadLocalesFromInstalled(available) {
+ let items;
+ if (available.length > 0) {
+ items = await getLocaleDisplayInfo(available);
+ items.push(await this.createInstalledLabel());
+ } else {
+ items = [];
+ }
+ if (this.downloadEnabled) {
+ items.push({
+ label: await document.l10n.formatValue("messenger-languages-search"),
+ value: "search",
+ });
+ }
+ this._availableLocalesUI.setItems(items);
+ },
+
+ /**
+ * @param {LocaleDisplayInfo} item
+ */
+ async availableLanguageSelected(item) {
+ if ((await getAvailableLocales()).includes(item.value)) {
+ await this.requestLocalLanguage(item);
+ } else if (this.availableLangpacks.has(item.value)) {
+ await this.requestRemoteLanguage(item);
+ } else {
+ this.showError();
+ }
+ },
+
+ /**
+ * @param {LocaleDisplayInfo} item
+ */
+ async requestLocalLanguage(item) {
+ this._selectedLocalesUI.addItem(item);
+ let selectedCount = this._selectedLocalesUI.items.length;
+ let availableCount = (await getAvailableLocales()).length;
+ if (selectedCount == availableCount) {
+ // Remove the installed label, they're all installed.
+ this._availableLocalesUI.items.shift();
+ this._availableLocalesUI.setItems(this._availableLocalesUI.items);
+ }
+
+ // The label isn't always reset when the selected item is removed, so set it again.
+ this._availableLocalesUI.enableWithMessageId(
+ "messenger-languages-select-language"
+ );
+ },
+
+ /**
+ * @param {LocaleDisplayInfo} item
+ */
+ async requestRemoteLanguage(item) {
+ this._availableLocalesUI.disableWithMessageId(
+ "messenger-languages-downloading"
+ );
+
+ let { url, hash } = this.availableLangpacks.get(item.value);
+ let addon;
+
+ try {
+ addon = await AddonManager.getInstallForURL(url, { hash });
+ await addon.install();
+ } catch (e) {
+ this.showError();
+ return;
+ }
+
+ // If the add-on was previously installed, it might be disabled still.
+ if (addon.userDisabled) {
+ await addon.enable();
+ }
+
+ item.installed = true;
+ this._selectedLocalesUI.addItem(item);
+ this._availableLocalesUI.enableWithMessageId(
+ "messenger-languages-select-language"
+ );
+ },
+
+ showError() {
+ document.getElementById("warning-message").hidden = false;
+ this._availableLocalesUI.enableWithMessageId(
+ "messenger-languages-select-language"
+ );
+
+ // The height has likely changed, find our SubDialog and tell it to resize.
+ requestAnimationFrame(() => {
+ let dialogs = window.opener.gSubDialog._dialogs;
+ let index = dialogs.findIndex(d => d._frame.contentDocument == document);
+ if (index != -1) {
+ dialogs[index].resizeDialog();
+ }
+ });
+ },
+
+ hideError() {
+ document.getElementById("warning-message").hidden = true;
+ },
+
+ /**
+ * @param {LocaleDisplayInfo} item
+ */
+ async selectedLocaleRemoved(item) {
+ this._availableLocalesUI.addItem(item);
+
+ // If the item we added is at the top of the list, it needs the label.
+ if (this._availableLocalesUI.items[0] == item) {
+ this._availableLocalesUI.addItem(await this.createInstalledLabel());
+ }
+ },
+
+ async createInstalledLabel() {
+ return {
+ label: await document.l10n.formatValue(
+ "messenger-languages-installed-label"
+ ),
+ className: "label-item",
+ disabled: true,
+ installed: true,
+ };
+ },
+};
diff --git a/comm/mail/components/preferences/messengerLanguages.xhtml b/comm/mail/components/preferences/messengerLanguages.xhtml
new file mode 100644
index 0000000000..116ee4e5b2
--- /dev/null
+++ b/comm/mail/components/preferences/messengerLanguages.xhtml
@@ -0,0 +1,93 @@
+<?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"?>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="messenger-languages-window2"
+ onload="gMessengerLanguagesDialog.onLoad();"
+>
+ <dialog id="MessengerLanguagesDialog" buttons="accept,cancel">
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/languages.ftl"
+ />
+ </linkset>
+
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/messengerLanguages.js" />
+
+ <vbox
+ id="messengerLanguagesDialogPane"
+ class="prefpane largeDialogContainer"
+ flex="1"
+ >
+ <description data-l10n-id="messenger-languages-description" />
+ <hbox flex="1">
+ <vbox flex="1">
+ <richlistbox id="selectedLocales" flex="1" />
+ <menulist
+ id="availableLocales"
+ class="available-locales-list"
+ data-l10n-id="messenger-languages-select-language"
+ data-l10n-attrs="placeholder,label"
+ >
+ <menupopup />
+ </menulist>
+ </vbox>
+ <vbox>
+ <button
+ id="up"
+ class="action-button"
+ disabled="true"
+ data-l10n-id="languages-customize-moveup"
+ />
+ <button
+ id="down"
+ class="action-button"
+ disabled="true"
+ data-l10n-id="languages-customize-movedown"
+ />
+ <button
+ id="remove"
+ class="action-button"
+ disabled="true"
+ data-l10n-id="languages-customize-remove"
+ />
+ <vbox flex="1" pack="end">
+ <button
+ id="add"
+ class="add-messenger-language action-button"
+ data-l10n-id="languages-customize-add"
+ disabled="true"
+ />
+ </vbox>
+ </vbox>
+ </hbox>
+ <hbox
+ id="warning-message"
+ class="message-bar message-bar-warning"
+ hidden="true"
+ >
+ <html:img
+ class="message-bar-icon"
+ src="chrome://global/skin/icons/warning.svg"
+ alt=""
+ />
+ <description
+ class="message-bar-description"
+ data-l10n-id="messenger-languages-error"
+ />
+ </hbox>
+ <separator class="thin" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/moz.build b/comm/mail/components/preferences/moz.build
new file mode 100644
index 0000000000..4eff028aeb
--- /dev/null
+++ b/comm/mail/components/preferences/moz.build
@@ -0,0 +1,18 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+DEFINES["MOZ_MACBUNDLE_NAME"] = CONFIG["MOZ_MACBUNDLE_NAME"]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] in ("windows", "gtk", "cocoa"):
+ DEFINES["HAVE_SHELL_SERVICE"] = 1
+
+if CONFIG["MOZ_UPDATER"]:
+ DEFINES["MOZ_UPDATER"] = 1
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/preferences/notifications.js b/comm/mail/components/preferences/notifications.js
new file mode 100644
index 0000000000..0970a944bd
--- /dev/null
+++ b/comm/mail/components/preferences/notifications.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "mail.biff.alert.show_preview", type: "bool" },
+ { id: "mail.biff.alert.show_subject", type: "bool" },
+ { id: "mail.biff.alert.show_sender", type: "bool" },
+ { id: "alerts.totalOpenTime", type: "int" },
+]);
+
+var gNotificationsDialog = {
+ init() {
+ let element = document.getElementById("totalOpenTime");
+ Preferences.addSyncFromPrefListener(
+ element,
+ () => Preferences.get("alerts.totalOpenTime").value / 1000
+ );
+ Preferences.addSyncToPrefListener(element, element => element.value * 1000);
+ },
+};
+
+window.addEventListener("load", () => gNotificationsDialog.init());
diff --git a/comm/mail/components/preferences/notifications.xhtml b/comm/mail/components/preferences/notifications.xhtml
new file mode 100644
index 0000000000..d9abb47e4e
--- /dev/null
+++ b/comm/mail/components/preferences/notifications.xhtml
@@ -0,0 +1,71 @@
+<?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"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="notifications-dialog-window"
+>
+ <dialog id="NotificationsDialog" dlgbuttons="accept,cancel">
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/preferences/notifications.ftl"
+ />
+ </linkset>
+ <description data-l10n-id="customize-alert-description" />
+
+ <checkbox
+ id="previewText"
+ class="indent"
+ data-l10n-id="preview-text-checkbox"
+ preference="mail.biff.alert.show_preview"
+ />
+ <checkbox
+ id="subject"
+ class="indent"
+ data-l10n-id="subject-checkbox"
+ preference="mail.biff.alert.show_subject"
+ />
+ <checkbox
+ id="sender"
+ class="indent"
+ data-l10n-id="sender-checkbox"
+ preference="mail.biff.alert.show_sender"
+ />
+
+ <separator />
+
+ <hbox align="center">
+ <label
+ id="totalOpenTimeBefore"
+ control="totalOpenTime"
+ data-l10n-id="open-time-label-before"
+ />
+ <html:input
+ id="totalOpenTime"
+ type="number"
+ class="size3"
+ min="1"
+ max="3600"
+ preference="alerts.totalOpenTime"
+ />
+ <label
+ id="totalOpenTimeEnd"
+ data-l10n-id="open-time-label-after"
+ class="startSpacing"
+ />
+ </hbox>
+ <separator />
+
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/notifications.js" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/offline.js b/comm/mail/components/preferences/offline.js
new file mode 100644
index 0000000000..ae33950cf1
--- /dev/null
+++ b/comm/mail/components/preferences/offline.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "offline.autoDetect", type: "bool" },
+ { id: "offline.startup_state", type: "int" },
+ { id: "offline.send.unsent_messages", type: "int" },
+ { id: "offline.download.download_messages", type: "int" },
+]);
+
+var kAutomatic = 4;
+var kRememberLastState = 0;
+
+var gOfflineDialog = {
+ dialogSetup() {
+ let offlineAutoDetection = Preferences.get("offline.autoDetect");
+ let offlineStartupStatePref = Preferences.get("offline.startup_state");
+
+ offlineStartupStatePref.disabled = offlineAutoDetection.value;
+ if (offlineStartupStatePref.disabled) {
+ offlineStartupStatePref.value = kAutomatic;
+ } else if (offlineStartupStatePref.value == kAutomatic) {
+ offlineStartupStatePref.value = kRememberLastState;
+ }
+ },
+};
+
+Preferences.get("offline.autoDetect").on("change", gOfflineDialog.dialogSetup);
diff --git a/comm/mail/components/preferences/offline.xhtml b/comm/mail/components/preferences/offline.xhtml
new file mode 100644
index 0000000000..77a86cfa86
--- /dev/null
+++ b/comm/mail/components/preferences/offline.xhtml
@@ -0,0 +1,77 @@
+<?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"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="gOfflineDialog.dialogSetup();"
+ data-l10n-id="offline-dialog-window"
+>
+ <dialog id="OfflineSettingsDialog" dlgbuttons="accept,cancel">
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/offline.js" />
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/offline.ftl" />
+ </linkset>
+
+ <checkbox
+ data-l10n-id="autodetect-online-label"
+ preference="offline.autoDetect"
+ />
+
+ <separator class="thin" />
+
+ <label
+ data-l10n-id="offline-preference-startup-label"
+ control="whenStartingUp"
+ />
+ <radiogroup
+ id="whenStartingUp"
+ class="indent"
+ preference="offline.startup_state"
+ >
+ <radio value="0" data-l10n-id="status-radio-remember" />
+ <radio value="1" data-l10n-id="status-radio-ask" />
+ <radio value="2" data-l10n-id="status-radio-always-online" />
+ <radio value="3" data-l10n-id="status-radio-always-offline" />
+ <radio value="4" hidden="true" />
+ </radiogroup>
+
+ <separator />
+
+ <label data-l10n-id="going-online-label" control="whengoingOnlinestate" />
+ <radiogroup
+ id="whengoingOnlinestate"
+ orient="horizontal"
+ class="indent"
+ preference="offline.send.unsent_messages"
+ >
+ <radio value="1" data-l10n-id="going-online-auto" />
+ <radio value="2" data-l10n-id="going-online-not" />
+ <radio value="0" data-l10n-id="going-online-ask" />
+ </radiogroup>
+
+ <separator class="thin" />
+
+ <label data-l10n-id="going-offline-label" control="whengoingOfflinestate" />
+ <radiogroup
+ id="whengoingOfflinestate"
+ orient="horizontal"
+ class="indent"
+ preference="offline.download.download_messages"
+ >
+ <radio value="1" data-l10n-id="going-offline-auto" />
+ <radio value="2" data-l10n-id="going-offline-not" />
+ <radio value="0" data-l10n-id="going-offline-ask" />
+ </radiogroup>
+ <separator />
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/passwordManager.js b/comm/mail/components/preferences/passwordManager.js
new file mode 100644
index 0000000000..67f08767d3
--- /dev/null
+++ b/comm/mail/components/preferences/passwordManager.js
@@ -0,0 +1,819 @@
+/* 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/. */
+
+/** * =================== SAVED SIGNONS CODE =================== */
+/* eslint-disable-next-line no-var */
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+/* eslint-disable-next-line no-var */
+/* eslint-disable-next-line no-var */
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+// Default value for signon table sorting
+let lastSignonSortColumn = "origin";
+let lastSignonSortAscending = true;
+
+let showingPasswords = false;
+
+// password-manager lists
+let signons = [];
+let deletedSignons = [];
+
+// Elements that would be used frequently
+let filterField;
+let togglePasswordsButton;
+let signonsIntro;
+let removeButton;
+let removeAllButton;
+let signonsTree;
+
+let signonReloadDisplay = {
+ observe(subject, topic, data) {
+ if (topic == "passwordmgr-storage-changed") {
+ switch (data) {
+ case "addLogin":
+ case "modifyLogin":
+ case "removeLogin":
+ case "removeAllLogins":
+ if (!signonsTree) {
+ return;
+ }
+ signons.length = 0;
+ LoadSignons();
+ // apply the filter if needed
+ if (filterField && filterField.value != "") {
+ FilterPasswords();
+ }
+ signonsTree.ensureRowIsVisible(
+ signonsTree.view.selection.currentIndex
+ );
+ break;
+ }
+ Services.obs.notifyObservers(null, "passwordmgr-dialog-updated");
+ }
+ },
+};
+
+// Formatter for localization.
+let dateFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+});
+let dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+});
+
+function Startup() {
+ // be prepared to reload the display if anything changes
+ Services.obs.addObserver(signonReloadDisplay, "passwordmgr-storage-changed");
+
+ signonsTree = document.getElementById("signonsTree");
+ filterField = document.getElementById("filter");
+ togglePasswordsButton = document.getElementById("togglePasswords");
+ signonsIntro = document.getElementById("signonsIntro");
+ removeButton = document.getElementById("removeSignon");
+ removeAllButton = document.getElementById("removeAllSignons");
+
+ document.l10n.setAttributes(togglePasswordsButton, "show-passwords");
+ document.l10n.setAttributes(signonsIntro, "logins-description-all");
+ document.l10n.setAttributes(removeAllButton, "remove-all");
+
+ document
+ .getElementsByTagName("treecols")[0]
+ .addEventListener("click", event => {
+ let { target, button } = event;
+ let sortField = target.getAttribute("data-field-name");
+
+ if (target.nodeName != "treecol" || button != 0 || !sortField) {
+ return;
+ }
+
+ SignonColumnSort(sortField);
+ });
+
+ LoadSignons();
+
+ // filter the table if requested by caller
+ if (
+ window.arguments &&
+ window.arguments[0] &&
+ window.arguments[0].filterString
+ ) {
+ setFilter(window.arguments[0].filterString);
+ }
+
+ FocusFilterBox();
+ document.l10n
+ .translateElements(document.querySelectorAll("[data-l10n-id]"))
+ .then(() => window.sizeToContent());
+}
+
+function Shutdown() {
+ Services.obs.removeObserver(
+ signonReloadDisplay,
+ "passwordmgr-storage-changed"
+ );
+}
+
+function setFilter(aFilterString) {
+ filterField.value = aFilterString;
+ FilterPasswords();
+}
+
+let signonsTreeView = {
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+ _filterSet: [],
+ _lastSelectedRanges: [],
+ selection: null,
+
+ rowCount: 0,
+ setTree(tree) {},
+ getImageSrc(row, column) {
+ if (column.element.getAttribute("id") !== "providerCol") {
+ return "";
+ }
+
+ const signon = GetVisibleLogins()[row];
+
+ return PlacesUtils.urlWithSizeRef(window, "page-icon:" + signon.origin, 16);
+ },
+ getCellValue(row, column) {},
+ getCellText(row, column) {
+ let time;
+ let signon = GetVisibleLogins()[row];
+ switch (column.id) {
+ case "providerCol":
+ return signon.httpRealm
+ ? signon.origin + " (" + signon.httpRealm + ")"
+ : signon.origin;
+ case "userCol":
+ return signon.username || "";
+ case "passwordCol":
+ return signon.password || "";
+ case "timeCreatedCol":
+ time = new Date(signon.timeCreated);
+ return dateFormatter.format(time);
+ case "timeLastUsedCol":
+ time = new Date(signon.timeLastUsed);
+ return dateAndTimeFormatter.format(time);
+ case "timePasswordChangedCol":
+ time = new Date(signon.timePasswordChanged);
+ return dateFormatter.format(time);
+ case "timesUsedCol":
+ return signon.timesUsed;
+ default:
+ return "";
+ }
+ },
+ isEditable(row, col) {
+ if (col.id == "userCol" || col.id == "passwordCol") {
+ return true;
+ }
+ return false;
+ },
+ isSeparator(index) {
+ return false;
+ },
+ isSorted() {
+ return false;
+ },
+ isContainer(index) {
+ return false;
+ },
+ cycleHeader(column) {},
+ getRowProperties(row) {
+ return "";
+ },
+ getColumnProperties(column) {
+ return "";
+ },
+ getCellProperties(row, column) {
+ if (column.element.getAttribute("id") == "providerCol") {
+ return "ltr";
+ }
+
+ return "";
+ },
+ setCellText(row, col, value) {
+ let table = GetVisibleLogins();
+ function _editLogin(field) {
+ if (value == table[row][field]) {
+ return;
+ }
+ let existingLogin = table[row].clone();
+ table[row][field] = value;
+ table[row].timePasswordChanged = Date.now();
+ Services.logins.modifyLogin(existingLogin, table[row]);
+ signonsTree.invalidateRow(row);
+ }
+
+ if (col.id == "userCol") {
+ _editLogin("username");
+ } else if (col.id == "passwordCol") {
+ if (!value) {
+ return;
+ }
+ _editLogin("password");
+ }
+ },
+};
+
+function SortTree(column, ascending) {
+ let table = GetVisibleLogins();
+ // remember which item was selected so we can restore it after the sort
+ let selections = GetTreeSelections();
+ let selectedNumber = selections.length ? table[selections[0]].number : -1;
+ function compareFunc(a, b) {
+ let valA, valB;
+ switch (column) {
+ case "origin":
+ let realmA = a.httpRealm;
+ let realmB = b.httpRealm;
+ realmA = realmA == null ? "" : realmA.toLowerCase();
+ realmB = realmB == null ? "" : realmB.toLowerCase();
+
+ valA = a[column].toLowerCase() + realmA;
+ valB = b[column].toLowerCase() + realmB;
+ break;
+ case "username":
+ case "password":
+ valA = a[column].toLowerCase();
+ valB = b[column].toLowerCase();
+ break;
+
+ default:
+ valA = a[column];
+ valB = b[column];
+ }
+
+ if (valA < valB) {
+ return -1;
+ }
+ if (valA > valB) {
+ return 1;
+ }
+ return 0;
+ }
+
+ // do the sort
+ table.sort(compareFunc);
+ if (!ascending) {
+ table.reverse();
+ }
+
+ // restore the selection
+ let selectedRow = -1;
+ if (selectedNumber >= 0 && false) {
+ for (let s = 0; s < table.length; s++) {
+ if (table[s].number == selectedNumber) {
+ // update selection
+ // note: we need to deselect before reselecting in order to trigger ...Selected()
+ signonsTree.view.selection.select(-1);
+ signonsTree.view.selection.select(s);
+ selectedRow = s;
+ break;
+ }
+ }
+ }
+
+ // display the results
+ signonsTree.invalidate();
+ if (selectedRow >= 0) {
+ signonsTree.ensureRowIsVisible(selectedRow);
+ }
+}
+
+function LoadSignons() {
+ // loads signons into table
+ try {
+ signons = Services.logins.getAllLogins();
+ } catch (e) {
+ signons = [];
+ }
+ signons.forEach(login => login.QueryInterface(Ci.nsILoginMetaInfo));
+ signonsTreeView.rowCount = signons.length;
+
+ // sort and display the table
+ signonsTree.view = signonsTreeView;
+ // The sort column didn't change. SortTree (called by
+ // SignonColumnSort) assumes we want to toggle the sort
+ // direction but here we don't so we have to trick it
+ lastSignonSortAscending = !lastSignonSortAscending;
+ SignonColumnSort(lastSignonSortColumn);
+
+ // disable "remove all signons" button if there are no signons
+ if (signons.length == 0) {
+ removeAllButton.setAttribute("disabled", "true");
+ togglePasswordsButton.setAttribute("disabled", "true");
+ } else {
+ removeAllButton.removeAttribute("disabled");
+ togglePasswordsButton.removeAttribute("disabled");
+ }
+
+ return true;
+}
+
+function GetVisibleLogins() {
+ return signonsTreeView._filterSet.length
+ ? signonsTreeView._filterSet
+ : signons;
+}
+
+function GetTreeSelections() {
+ let selections = [];
+ let select = signonsTree.view.selection;
+ if (select) {
+ let count = select.getRangeCount();
+ let min = {};
+ let max = {};
+ for (let i = 0; i < count; i++) {
+ select.getRangeAt(i, min, max);
+ for (let k = min.value; k <= max.value; k++) {
+ if (k != -1) {
+ selections[selections.length] = k;
+ }
+ }
+ }
+ }
+ return selections;
+}
+
+function SignonSelected() {
+ let selections = GetTreeSelections();
+ if (selections.length) {
+ removeButton.removeAttribute("disabled");
+ } else {
+ removeButton.setAttribute("disabled", true);
+ }
+}
+
+function DeleteSignon() {
+ let syncNeeded = signonsTreeView._filterSet.length != 0;
+ let tree = signonsTree;
+ let view = signonsTreeView;
+ let table = GetVisibleLogins();
+
+ // Turn off tree selection notifications during the deletion
+ tree.view.selection.selectEventsSuppressed = true;
+
+ // remove selected items from list (by setting them to null) and place in deleted list
+ let selections = GetTreeSelections();
+ for (let s = selections.length - 1; s >= 0; s--) {
+ let i = selections[s];
+ deletedSignons.push(table[i]);
+ table[i] = null;
+ }
+
+ // collapse list by removing all the null entries
+ for (let j = 0; j < table.length; j++) {
+ if (table[j] == null) {
+ let k = j;
+ while (k < table.length && table[k] == null) {
+ k++;
+ }
+ table.splice(j, k - j);
+ view.rowCount -= k - j;
+ tree.rowCountChanged(j, j - k);
+ }
+ }
+
+ // update selection and/or buttons
+ if (table.length) {
+ // update selection
+ let nextSelection =
+ selections[0] < table.length ? selections[0] : table.length - 1;
+ tree.view.selection.select(nextSelection);
+ } else {
+ // disable buttons
+ removeButton.setAttribute("disabled", "true");
+ removeAllButton.setAttribute("disabled", "true");
+ }
+ tree.view.selection.selectEventsSuppressed = false;
+ FinalizeSignonDeletions(syncNeeded);
+}
+
+async function DeleteAllSignons() {
+ // Confirm the user wants to remove all passwords
+ let dummy = { value: false };
+ let [title, message] = await document.l10n.formatValues([
+ { id: "remove-all-passwords-title" },
+ { id: "remove-all-passwords-prompt" },
+ ]);
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Services.prompt.STD_YES_NO_BUTTONS + Services.prompt.BUTTON_POS_1_DEFAULT,
+ null,
+ null,
+ null,
+ null,
+ dummy
+ ) == 1
+ ) {
+ // 1 == "No" button
+ return;
+ }
+
+ let syncNeeded = signonsTreeView._filterSet.length != 0;
+ let view = signonsTreeView;
+ let table = GetVisibleLogins();
+
+ // remove all items from table and place in deleted table
+ for (let i = 0; i < table.length; i++) {
+ deletedSignons.push(table[i]);
+ }
+ table.length = 0;
+
+ // clear out selections
+ view.selection.select(-1);
+
+ // update the tree view and notify the tree
+ view.rowCount = 0;
+
+ signonsTree.rowCountChanged(0, -deletedSignons.length);
+ signonsTree.invalidate();
+
+ // disable buttons
+ removeButton.setAttribute("disabled", "true");
+ removeAllButton.setAttribute("disabled", "true");
+ FinalizeSignonDeletions(syncNeeded);
+}
+
+async function TogglePasswordVisible() {
+ if (showingPasswords || (await masterPasswordLogin(AskUserShowPasswords))) {
+ showingPasswords = !showingPasswords;
+ document.l10n.setAttributes(
+ togglePasswordsButton,
+ showingPasswords ? "hide-passwords" : "show-passwords"
+ );
+ document.getElementById("passwordCol").hidden = !showingPasswords;
+ FilterPasswords();
+ }
+
+ // Notify observers that the password visibility toggling is
+ // completed. (Mostly useful for tests)
+ Services.obs.notifyObservers(null, "passwordmgr-password-toggle-complete");
+}
+
+async function AskUserShowPasswords() {
+ let dummy = { value: false };
+
+ // Confirm the user wants to display passwords
+ return (
+ Services.prompt.confirmEx(
+ window,
+ null,
+ await document.l10n.formatValue("no-master-password-prompt"),
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ dummy
+ ) == 0
+ ); // 0=="Yes" button
+}
+
+function FinalizeSignonDeletions(syncNeeded) {
+ for (let s = 0; s < deletedSignons.length; s++) {
+ Services.logins.removeLogin(deletedSignons[s]);
+ }
+ // If the deletion has been performed in a filtered view, reflect the deletion in the unfiltered table.
+ // See bug 405389.
+ if (syncNeeded) {
+ try {
+ signons = Services.logins.getAllLogins();
+ } catch (e) {
+ signons = [];
+ }
+ }
+ deletedSignons.length = 0;
+}
+
+function HandleSignonKeyPress(e) {
+ // If editing is currently performed, don't do anything.
+ if (signonsTree.getAttribute("editing")) {
+ return;
+ }
+ if (
+ e.keyCode == KeyboardEvent.DOM_VK_DELETE ||
+ (AppConstants.platform == "macosx" &&
+ e.keyCode == KeyboardEvent.DOM_VK_BACK_SPACE)
+ ) {
+ DeleteSignon();
+ e.preventDefault();
+ }
+}
+
+function getColumnByName(column) {
+ switch (column) {
+ case "origin":
+ return document.getElementById("providerCol");
+ case "username":
+ return document.getElementById("userCol");
+ case "password":
+ return document.getElementById("passwordCol");
+ case "timeCreated":
+ return document.getElementById("timeCreatedCol");
+ case "timeLastUsed":
+ return document.getElementById("timeLastUsedCol");
+ case "timePasswordChanged":
+ return document.getElementById("timePasswordChangedCol");
+ case "timesUsed":
+ return document.getElementById("timesUsedCol");
+ }
+ return undefined;
+}
+
+function SignonColumnSort(column) {
+ let sortedCol = getColumnByName(column);
+ let lastSortedCol = getColumnByName(lastSignonSortColumn);
+
+ // clear out the sortDirection attribute on the old column
+ lastSortedCol.removeAttribute("sortDirection");
+
+ // determine if sort is to be ascending or descending
+ lastSignonSortAscending =
+ column == lastSignonSortColumn ? !lastSignonSortAscending : true;
+
+ // sort
+ lastSignonSortColumn = column;
+ SortTree(lastSignonSortColumn, lastSignonSortAscending);
+
+ // set the sortDirection attribute to get the styling going
+ // first we need to get the right element
+ sortedCol.setAttribute(
+ "sortDirection",
+ lastSignonSortAscending ? "ascending" : "descending"
+ );
+}
+
+function SignonClearFilter() {
+ let singleSelection = signonsTreeView.selection.count == 1;
+
+ // Clear the Tree Display
+ signonsTreeView.rowCount = 0;
+ signonsTree.rowCountChanged(0, -signonsTreeView._filterSet.length);
+ signonsTreeView._filterSet = [];
+
+ // Just reload the list to make sure deletions are respected
+ LoadSignons();
+
+ // Restore selection
+ if (singleSelection) {
+ signonsTreeView.selection.clearSelection();
+ for (let i = 0; i < signonsTreeView._lastSelectedRanges.length; ++i) {
+ let range = signonsTreeView._lastSelectedRanges[i];
+ signonsTreeView.selection.rangedSelect(range.min, range.max, true);
+ }
+ } else {
+ signonsTreeView.selection.select(0);
+ }
+ signonsTreeView._lastSelectedRanges = [];
+
+ document.l10n.setAttributes(signonsIntro, "logins-description-all");
+ document.l10n.setAttributes(removeAllButton, "remove-all");
+}
+
+function FocusFilterBox() {
+ if (filterField.getAttribute("focused") != "true") {
+ filterField.focus();
+ }
+}
+
+function SignonMatchesFilter(aSignon, aFilterValue) {
+ if (aSignon.origin.toLowerCase().includes(aFilterValue)) {
+ return true;
+ }
+ if (
+ aSignon.username &&
+ aSignon.username.toLowerCase().includes(aFilterValue)
+ ) {
+ return true;
+ }
+ if (
+ aSignon.httpRealm &&
+ aSignon.httpRealm.toLowerCase().includes(aFilterValue)
+ ) {
+ return true;
+ }
+ if (
+ showingPasswords &&
+ aSignon.password &&
+ aSignon.password.toLowerCase().includes(aFilterValue)
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function _filterPasswords(aFilterValue, view) {
+ aFilterValue = aFilterValue.toLowerCase();
+ return signons.filter(s => SignonMatchesFilter(s, aFilterValue));
+}
+
+function SignonSaveState() {
+ // Save selection
+ let seln = signonsTreeView.selection;
+ signonsTreeView._lastSelectedRanges = [];
+ let rangeCount = seln.getRangeCount();
+ for (let i = 0; i < rangeCount; ++i) {
+ let min = {};
+ let max = {};
+ seln.getRangeAt(i, min, max);
+ signonsTreeView._lastSelectedRanges.push({
+ min: min.value,
+ max: max.value,
+ });
+ }
+}
+
+function FilterPasswords() {
+ if (filterField.value == "") {
+ SignonClearFilter();
+ return;
+ }
+
+ let newFilterSet = _filterPasswords(filterField.value, signonsTreeView);
+ if (!signonsTreeView._filterSet.length) {
+ // Save Display Info for the Non-Filtered mode when we first
+ // enter Filtered mode.
+ SignonSaveState();
+ }
+ signonsTreeView._filterSet = newFilterSet;
+
+ // Clear the display
+ let oldRowCount = signonsTreeView.rowCount;
+ signonsTreeView.rowCount = 0;
+ signonsTree.rowCountChanged(0, -oldRowCount);
+ // Set up the filtered display
+ signonsTreeView.rowCount = signonsTreeView._filterSet.length;
+ signonsTree.rowCountChanged(0, signonsTreeView.rowCount);
+
+ // if the view is not empty then select the first item
+ if (signonsTreeView.rowCount > 0) {
+ signonsTreeView.selection.select(0);
+ }
+
+ document.l10n.setAttributes(signonsIntro, "logins-description-filtered");
+ document.l10n.setAttributes(removeAllButton, "remove-all-shown");
+}
+
+function CopyProviderUrl() {
+ // Copy selected provider url to clipboard
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ let row = signonsTree.currentIndex;
+ let url = signonsTreeView.getCellText(row, { id: "providerCol" });
+ clipboard.copyString(url);
+}
+
+async function CopyPassword() {
+ // Don't copy passwords if we aren't already showing the passwords & a master
+ // password hasn't been entered.
+ if (!showingPasswords && !(await masterPasswordLogin())) {
+ return;
+ }
+ // Copy selected signon's password to clipboard
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ let row = signonsTree.currentIndex;
+ let password = signonsTreeView.getCellText(row, { id: "passwordCol" });
+ clipboard.copyString(password);
+}
+
+function CopyUsername() {
+ // Copy selected signon's username to clipboard
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ let row = signonsTree.currentIndex;
+ let username = signonsTreeView.getCellText(row, { id: "userCol" });
+ clipboard.copyString(username);
+}
+
+function EditCellInSelectedRow(columnName) {
+ let row = signonsTree.currentIndex;
+ let columnElement = getColumnByName(columnName);
+ signonsTree.startEditing(
+ row,
+ signonsTree.columns.getColumnFor(columnElement)
+ );
+}
+
+function UpdateContextMenu() {
+ let singleSelection = signonsTreeView.selection.count == 1;
+ let menuItems = new Map();
+ let menupopup = document.getElementById("signonsTreeContextMenu");
+ for (let menuItem of menupopup.querySelectorAll("menuitem")) {
+ menuItems.set(menuItem.id, menuItem);
+ }
+
+ if (!singleSelection) {
+ for (let menuItem of menuItems.values()) {
+ menuItem.setAttribute("disabled", "true");
+ }
+ return;
+ }
+
+ let selectedRow = signonsTree.currentIndex;
+
+ // Disable "Copy Username" if the username is empty.
+ if (signonsTreeView.getCellText(selectedRow, { id: "userCol" }) != "") {
+ menuItems.get("context-copyusername").removeAttribute("disabled");
+ } else {
+ menuItems.get("context-copyusername").setAttribute("disabled", "true");
+ }
+
+ menuItems.get("context-copyproviderurl").removeAttribute("disabled");
+ menuItems.get("context-editusername").removeAttribute("disabled");
+ menuItems.get("context-copypassword").removeAttribute("disabled");
+
+ // Disable "Edit Password" if the password column isn't showing.
+ if (!document.getElementById("passwordCol").hidden) {
+ menuItems.get("context-editpassword").removeAttribute("disabled");
+ } else {
+ menuItems.get("context-editpassword").setAttribute("disabled", "true");
+ }
+}
+
+async function masterPasswordLogin(noPasswordCallback) {
+ // This doesn't harm if passwords are not encrypted
+ let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance(
+ Ci.nsIPK11TokenDB
+ );
+ let token = tokendb.getInternalKeyToken();
+
+ // If there is no primary password, still give the user a chance to opt-out of displaying passwords
+ if (token.checkPassword("")) {
+ // The OS re-authentication on Linux isn't working (Bug 1527745),
+ // still add the confirm dialog for Linux.
+ if (
+ Services.prefs.getBoolPref("signon.management.page.os-auth.enabled") &&
+ AppConstants.platform !== "linux"
+ ) {
+ // Require OS authentication before the user can show the passwords or copy them.
+ let messageId = "password-os-auth-dialog-message";
+ if (AppConstants.platform == "macosx") {
+ // MacOS requires a special format of this dialog string.
+ // See preferences.ftl for more information.
+ messageId += "-macosx";
+ }
+ let [messageText, captionText] = await document.l10n.formatMessages([
+ {
+ id: messageId,
+ },
+ {
+ id: "password-os-auth-dialog-caption",
+ },
+ ]);
+ let win = Services.wm.getMostRecentWindow("");
+ let loggedIn = await OSKeyStore.ensureLoggedIn(
+ messageText.value,
+ captionText.value,
+ win,
+ false
+ );
+ if (!loggedIn.authenticated) {
+ return false;
+ }
+ return true;
+ }
+ return noPasswordCallback ? noPasswordCallback() : true;
+ }
+
+ // So there's a primary password. But since checkPassword didn't succeed, we're logged out (per nsIPK11Token.idl).
+ try {
+ // Relogin and ask for the primary password.
+ token.login(true); // 'true' means always prompt for token password. User will be prompted until
+ // clicking 'Cancel' or entering the correct password.
+ } catch (e) {
+ // An exception will be thrown if the user cancels the login prompt dialog.
+ // User is also logged out of Software Security Device.
+ }
+
+ return token.isLoggedIn();
+}
+
+function escapeKeyHandler() {
+ // If editing is currently performed, don't do anything.
+ if (signonsTree.getAttribute("editing")) {
+ return;
+ }
+ window.close();
+}
diff --git a/comm/mail/components/preferences/passwordManager.xhtml b/comm/mail/components/preferences/passwordManager.xhtml
new file mode 100644
index 0000000000..594f2da8d8
--- /dev/null
+++ b/comm/mail/components/preferences/passwordManager.xhtml
@@ -0,0 +1,186 @@
+<?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 https://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/preferences/passwordmgr.css"?>
+
+<window
+ id="SignonViewerDialog"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="Startup();"
+ onunload="Shutdown();"
+ data-l10n-id="saved-logins"
+ persist="width height"
+>
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/passwordManager.ftl"
+ />
+ </linkset>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/preferences/passwordManager.js" />
+
+ <keyset>
+ <key keycode="VK_ESCAPE" oncommand="escapeKeyHandler();" />
+ <key
+ data-l10n-id="window-close"
+ modifiers="accel"
+ oncommand="escapeKeyHandler();"
+ />
+ <key
+ data-l10n-id="focus-search-primary-shortcut"
+ modifiers="accel"
+ oncommand="FocusFilterBox();"
+ />
+ <key
+ data-l10n-id="focus-search-alt-shortcut"
+ modifiers="accel"
+ oncommand="FocusFilterBox();"
+ />
+ </keyset>
+
+ <popupset id="signonsTreeContextSet">
+ <menupopup id="signonsTreeContextMenu" onpopupshowing="UpdateContextMenu()">
+ <menuitem
+ id="context-copyproviderurl"
+ data-l10n-id="copy-provider-url-cmd"
+ oncommand="CopyProviderUrl()"
+ />
+ <menuseparator />
+ <menuitem
+ id="context-copyusername"
+ data-l10n-id="copy-username-cmd"
+ oncommand="CopyUsername()"
+ />
+ <menuitem
+ id="context-editusername"
+ data-l10n-id="edit-username-cmd"
+ oncommand="EditCellInSelectedRow('username')"
+ />
+ <menuseparator />
+ <menuitem
+ id="context-copypassword"
+ data-l10n-id="copy-password-cmd"
+ oncommand="CopyPassword()"
+ />
+ <menuitem
+ id="context-editpassword"
+ data-l10n-id="edit-password-cmd"
+ oncommand="EditCellInSelectedRow('password')"
+ />
+ </menupopup>
+ </popupset>
+
+ <!-- saved signons -->
+ <vbox id="savedsignons" class="contentPane" flex="1">
+ <!-- filter -->
+ <hbox align="center">
+ <search-textbox
+ id="filter"
+ flex="1"
+ aria-controls="signonsTree"
+ oncommand="FilterPasswords();"
+ data-l10n-id="search-filter"
+ />
+ </hbox>
+
+ <label control="signonsTree" id="signonsIntro" />
+ <separator class="thin" />
+ <tree
+ id="signonsTree"
+ flex="1"
+ onkeypress="HandleSignonKeyPress(event)"
+ onselect="SignonSelected();"
+ editable="true"
+ context="signonsTreeContextMenu"
+ >
+ <treecols>
+ <treecol
+ id="providerCol"
+ data-l10n-id="column-heading-provider"
+ data-field-name="origin"
+ persist="width"
+ ignoreincolumnpicker="true"
+ sortDirection="ascending"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="userCol"
+ data-l10n-id="column-heading-username"
+ ignoreincolumnpicker="true"
+ data-field-name="username"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="passwordCol"
+ data-l10n-id="column-heading-password"
+ ignoreincolumnpicker="true"
+ data-field-name="password"
+ persist="width"
+ hidden="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="timeCreatedCol"
+ data-l10n-id="column-heading-time-created"
+ data-field-name="timeCreated"
+ persist="width hidden"
+ hidden="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="timeLastUsedCol"
+ data-l10n-id="column-heading-time-last-used"
+ data-field-name="timeLastUsed"
+ persist="width hidden"
+ hidden="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="timePasswordChangedCol"
+ data-l10n-id="column-heading-time-password-changed"
+ data-field-name="timePasswordChanged"
+ persist="width hidden"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="timesUsedCol"
+ data-l10n-id="column-heading-times-used"
+ data-field-name="timesUsed"
+ persist="width hidden"
+ hidden="true"
+ />
+ <splitter class="tree-splitter" />
+ </treecols>
+ <treechildren />
+ </tree>
+ <separator class="thin" />
+ <hbox id="SignonViewerButtons">
+ <button
+ id="removeSignon"
+ disabled="true"
+ data-l10n-id="remove"
+ oncommand="DeleteSignon();"
+ />
+ <button id="removeAllSignons" oncommand="DeleteAllSignons();" />
+ <spacer flex="1" />
+ <button id="togglePasswords" oncommand="TogglePasswordVisible();" />
+ </hbox>
+ </vbox>
+ <hbox align="end">
+ <hbox class="actionButtons" flex="1">
+ <spacer flex="1" />
+ <button
+ oncommand="window.close();"
+ data-l10n-id="password-close-button"
+ />
+ </hbox>
+ </hbox>
+</window>
diff --git a/comm/mail/components/preferences/permissions.js b/comm/mail/components/preferences/permissions.js
new file mode 100644
index 0000000000..3a75bc0ec6
--- /dev/null
+++ b/comm/mail/components/preferences/permissions.js
@@ -0,0 +1,501 @@
+/* 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/. */
+
+// toolkit/content/treeUtils.js
+/* globals gTreeUtils */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+var NOTIFICATION_FLUSH_PERMISSIONS = "flush-pending-permissions";
+
+/**
+ * Magic URI base used so the permission manager can store
+ * remote content permissions for a given email address.
+ */
+var MAILURI_BASE = "chrome://messenger/content/email=";
+
+function Permission(principal, type, capability) {
+ this.principal = principal;
+ this.origin = principal.origin;
+ this.type = type;
+ this.capability = capability;
+}
+
+var gPermissionManager = {
+ _type: "",
+ _permissions: [],
+ _permissionsToAdd: new Map(),
+ _permissionsToDelete: new Map(),
+ _tree: null,
+ _observerRemoved: false,
+
+ _view: {
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+ _rowCount: 0,
+ get rowCount() {
+ return this._rowCount;
+ },
+ getCellText(aRow, aColumn) {
+ if (aColumn.id == "siteCol") {
+ return gPermissionManager._permissions[aRow].origin.replace(
+ MAILURI_BASE,
+ ""
+ );
+ } else if (aColumn.id == "statusCol") {
+ return gPermissionManager._permissions[aRow].capability;
+ }
+ return "";
+ },
+
+ isSeparator(aIndex) {
+ return false;
+ },
+ isSorted() {
+ return false;
+ },
+ isContainer(aIndex) {
+ return false;
+ },
+ setTree(aTree) {},
+ getImageSrc(aRow, aColumn) {},
+ getProgressMode(aRow, aColumn) {},
+ getCellValue(aRow, aColumn) {},
+ cycleHeader(column) {},
+ getRowProperties(row) {
+ return "";
+ },
+ getColumnProperties(column) {
+ return "";
+ },
+ getCellProperties(row, column) {
+ if (column.element.getAttribute("id") == "siteCol") {
+ return "ltr";
+ }
+ return "";
+ },
+ },
+
+ async _getCapabilityString(aCapability) {
+ var stringKey = null;
+ switch (aCapability) {
+ case Ci.nsIPermissionManager.ALLOW_ACTION:
+ stringKey = "permission-can-label";
+ break;
+ case Ci.nsIPermissionManager.DENY_ACTION:
+ stringKey = "permission-cannot-label";
+ break;
+ case Ci.nsICookiePermission.ACCESS_ALLOW_FIRST_PARTY_ONLY:
+ stringKey = "permission-can-access-first-party-label";
+ break;
+ case Ci.nsICookiePermission.ACCESS_SESSION:
+ stringKey = "permission-can-session-label";
+ break;
+ }
+ let string = await document.l10n.formatValue(stringKey);
+ return string;
+ },
+
+ async addPermission(aCapability) {
+ var textbox = document.getElementById("url");
+ var input_url = textbox.value.trim();
+ let principal;
+ try {
+ // The origin accessor on the principal object will throw if the
+ // principal doesn't have a canonical origin representation. This will
+ // help catch cases where the URI parser parsed something like
+ // `localhost:8080` as having the scheme `localhost`, rather than being
+ // an invalid URI. A canonical origin representation is required by the
+ // permission manager for storage, so this won't prevent any valid
+ // permissions from being entered by the user.
+ let uri;
+ try {
+ uri = Services.io.newURI(input_url);
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ // If we have ended up with an unknown scheme, the following will throw.
+ principal.origin;
+ } catch (ex) {
+ let scheme =
+ this._type != "image" || !input_url.includes("@")
+ ? "http://"
+ : MAILURI_BASE;
+ uri = Services.io.newURI(scheme + input_url);
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ // If we have ended up with an unknown scheme, the following will throw.
+ principal.origin;
+ }
+ } catch (ex) {
+ let [title, message] = await document.l10n.formatValues([
+ { id: "invalid-uri-title" },
+ { id: "invalid-uri-message" },
+ ]);
+ Services.prompt.alert(window, title, message);
+ return;
+ }
+
+ var capabilityString = await this._getCapabilityString(aCapability);
+
+ // check whether the permission already exists, if not, add it
+ let permissionExists = false;
+ let capabilityExists = false;
+ for (var i = 0; i < this._permissions.length; ++i) {
+ // Thunderbird compares origins, not principals here.
+ if (this._permissions[i].principal.origin == principal.origin) {
+ permissionExists = true;
+ capabilityExists = this._permissions[i].capability == capabilityString;
+ if (!capabilityExists) {
+ this._permissions[i].capability = capabilityString;
+ }
+ break;
+ }
+ }
+
+ let permissionParams = {
+ principal,
+ type: this._type,
+ capability: aCapability,
+ };
+ if (!permissionExists) {
+ this._permissionsToAdd.set(principal.origin, permissionParams);
+ this._addPermission(permissionParams);
+ } else if (!capabilityExists) {
+ this._permissionsToAdd.set(principal.origin, permissionParams);
+ this._handleCapabilityChange();
+ }
+
+ textbox.value = "";
+ textbox.focus();
+
+ // covers a case where the site exists already, so the buttons don't disable
+ this.onHostInput(textbox);
+
+ // enable "remove all" button as needed
+ document.getElementById("removeAllPermissions").disabled =
+ this._permissions.length == 0;
+ },
+
+ _removePermission(aPermission) {
+ this._removePermissionFromList(aPermission.principal);
+
+ // If this permission was added during this session, let's remove
+ // it from the pending adds list to prevent calls to the
+ // permission manager.
+ let isNewPermission = this._permissionsToAdd.delete(
+ aPermission.principal.origin
+ );
+
+ if (!isNewPermission) {
+ this._permissionsToDelete.set(aPermission.principal.origin, aPermission);
+ }
+ },
+
+ _handleCapabilityChange() {
+ // Re-do the sort, if the status changed from Block to Allow
+ // or vice versa, since if we're sorted on status, we may no
+ // longer be in order.
+ if (this._lastPermissionSortColumn == "statusCol") {
+ this._resortPermissions();
+ }
+ this._tree.invalidate();
+ },
+
+ _addPermission(aPermission) {
+ this._addPermissionToList(aPermission);
+ ++this._view._rowCount;
+ this._tree.rowCountChanged(this._view.rowCount - 1, 1);
+ // Re-do the sort, since we inserted this new item at the end.
+ this._resortPermissions();
+ },
+
+ _resortPermissions() {
+ gTreeUtils.sort(
+ this._tree,
+ this._view,
+ this._permissions,
+ this._lastPermissionSortColumn,
+ this._permissionsComparator,
+ this._lastPermissionSortColumn,
+ !this._lastPermissionSortAscending
+ ); // keep sort direction
+ },
+
+ onHostInput(aSiteField) {
+ document.getElementById("btnSession").disabled = !aSiteField.value;
+ document.getElementById("btnBlock").disabled = !aSiteField.value;
+ document.getElementById("btnAllow").disabled = !aSiteField.value;
+ },
+
+ onWindowKeyPress(aEvent) {
+ if (aEvent.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ window.close();
+ }
+ },
+
+ onHostKeyPress(aEvent) {
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ document.getElementById("btnAllow").click();
+ }
+ },
+
+ onLoad() {
+ var params = window.arguments[0];
+ this.init(params);
+ },
+
+ init(aParams) {
+ if (this._type) {
+ // reusing an open dialog, clear the old observer
+ this.uninit();
+ }
+
+ this._type = aParams.permissionType;
+ this._manageCapability = aParams.manageCapability;
+
+ var permissionsText = document.getElementById("permissionsText");
+ while (permissionsText.hasChildNodes()) {
+ permissionsText.lastChild.remove();
+ }
+ permissionsText.appendChild(document.createTextNode(aParams.introText));
+
+ document.title = aParams.windowTitle;
+
+ document.getElementById("btnBlock").hidden = !aParams.blockVisible;
+ document.getElementById("btnSession").hidden = !aParams.sessionVisible;
+ document.getElementById("btnAllow").hidden = !aParams.allowVisible;
+
+ var urlFieldVisible =
+ aParams.blockVisible || aParams.sessionVisible || aParams.allowVisible;
+
+ var urlField = document.getElementById("url");
+ urlField.value = aParams.prefilledHost;
+ urlField.hidden = !urlFieldVisible;
+
+ this.onHostInput(urlField);
+
+ var urlLabel = document.getElementById("urlLabel");
+ urlLabel.hidden = !urlFieldVisible;
+
+ let treecols = document.getElementsByTagName("treecols")[0];
+ treecols.addEventListener("click", event => {
+ if (event.target.nodeName != "treecol" || event.button != 0) {
+ return;
+ }
+
+ let sortField = event.target.getAttribute("data-field-name");
+ if (!sortField) {
+ return;
+ }
+
+ gPermissionManager.onPermissionSort(sortField);
+ });
+
+ Services.obs.notifyObservers(
+ null,
+ NOTIFICATION_FLUSH_PERMISSIONS,
+ this._type
+ );
+ Services.obs.addObserver(this, "perm-changed");
+
+ this._loadPermissions().then(() => urlField.focus());
+ },
+
+ uninit() {
+ if (!this._observerRemoved) {
+ Services.obs.removeObserver(this, "perm-changed");
+
+ this._observerRemoved = true;
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "perm-changed") {
+ var permission = aSubject.QueryInterface(Ci.nsIPermission);
+
+ // Ignore unrelated permission types.
+ if (permission.type != this._type) {
+ return;
+ }
+
+ if (aData == "added") {
+ this._addPermission(permission);
+ } else if (aData == "changed") {
+ for (var i = 0; i < this._permissions.length; ++i) {
+ if (permission.matches(this._permissions[i].principal, true)) {
+ this._permissions[i].capability = this._getCapabilityString(
+ permission.capability
+ );
+ break;
+ }
+ }
+ this._handleCapabilityChange();
+ } else if (aData == "deleted") {
+ this._removePermissionFromList(permission);
+ }
+ }
+ },
+
+ onPermissionSelected() {
+ var hasSelection = this._tree.view.selection.count > 0;
+ var hasRows = this._tree.view.rowCount > 0;
+ document.getElementById("removePermission").disabled =
+ !hasRows || !hasSelection;
+ document.getElementById("removeAllPermissions").disabled = !hasRows;
+ },
+
+ onPermissionDeleted() {
+ if (!this._view.rowCount) {
+ return;
+ }
+ var removedPermissions = [];
+ gTreeUtils.deleteSelectedItems(
+ this._tree,
+ this._view,
+ this._permissions,
+ removedPermissions
+ );
+ for (var i = 0; i < removedPermissions.length; ++i) {
+ var p = removedPermissions[i];
+ this._removePermission(p);
+ }
+ document.getElementById("removePermission").disabled =
+ !this._permissions.length;
+ document.getElementById("removeAllPermissions").disabled =
+ !this._permissions.length;
+ },
+
+ onAllPermissionsDeleted() {
+ if (!this._view.rowCount) {
+ return;
+ }
+ var removedPermissions = [];
+ gTreeUtils.deleteAll(
+ this._tree,
+ this._view,
+ this._permissions,
+ removedPermissions
+ );
+ for (var i = 0; i < removedPermissions.length; ++i) {
+ var p = removedPermissions[i];
+ this._removePermission(p);
+ }
+ document.getElementById("removePermission").disabled = true;
+ document.getElementById("removeAllPermissions").disabled = true;
+ },
+
+ onPermissionKeyPress(aEvent) {
+ if (
+ aEvent.keyCode == KeyEvent.DOM_VK_DELETE ||
+ (AppConstants.platform == "macosx" &&
+ aEvent.keyCode == KeyEvent.DOM_VK_BACK_SPACE)
+ ) {
+ this.onPermissionDeleted();
+ }
+ },
+
+ _lastPermissionSortColumn: "",
+ _lastPermissionSortAscending: false,
+ _permissionsComparator(a, b) {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ },
+
+ onPermissionSort(aColumn) {
+ this._lastPermissionSortAscending = gTreeUtils.sort(
+ this._tree,
+ this._view,
+ this._permissions,
+ aColumn,
+ this._permissionsComparator,
+ this._lastPermissionSortColumn,
+ this._lastPermissionSortAscending
+ );
+ this._lastPermissionSortColumn = aColumn;
+ },
+
+ onApplyChanges() {
+ // Stop observing permission changes since we are about
+ // to write out the pending adds/deletes and don't need
+ // to update the UI
+ this.uninit();
+
+ for (let permissionParams of this._permissionsToAdd.values()) {
+ Services.perms.addFromPrincipal(
+ permissionParams.principal,
+ permissionParams.type,
+ permissionParams.capability
+ );
+ }
+
+ for (let p of this._permissionsToDelete.values()) {
+ Services.perms.removeFromPrincipal(p.principal, p.type);
+ }
+
+ window.close();
+ },
+
+ async _loadPermissions() {
+ this._tree = document.getElementById("permissionsTree");
+ this._permissions = [];
+
+ for (let perm of Services.perms.all) {
+ await this._addPermissionToList(perm);
+ }
+
+ this._view._rowCount = this._permissions.length;
+
+ // sort and display the table
+ this._tree.view = this._view;
+ this.onPermissionSort("origin");
+
+ // disable "remove all" button if there are none
+ document.getElementById("removeAllPermissions").disabled =
+ this._permissions.length == 0;
+ },
+
+ async _addPermissionToList(aPermission) {
+ if (
+ aPermission.type == this._type &&
+ (!this._manageCapability ||
+ aPermission.capability == this._manageCapability)
+ ) {
+ var principal = aPermission.principal;
+ var capabilityString = await this._getCapabilityString(
+ aPermission.capability
+ );
+ var p = new Permission(principal, aPermission.type, capabilityString);
+ this._permissions.push(p);
+ }
+ },
+
+ _removePermissionFromList(aPrincipal) {
+ for (let i = 0; i < this._permissions.length; ++i) {
+ // Thunderbird compares origins, not principals here.
+ if (this._permissions[i].principal.origin == aPrincipal.origin) {
+ this._permissions.splice(i, 1);
+ this._view._rowCount--;
+ this._tree.rowCountChanged(this._view.rowCount - 1, -1);
+ this._tree.invalidate();
+ break;
+ }
+ }
+ },
+
+ setOrigin(aOrigin) {
+ document.getElementById("url").value = aOrigin;
+ },
+};
+
+function setOrigin(aOrigin) {
+ gPermissionManager.setOrigin(aOrigin);
+}
+
+function initWithParams(aParams) {
+ gPermissionManager.init(aParams);
+}
diff --git a/comm/mail/components/preferences/permissions.xhtml b/comm/mail/components/preferences/permissions.xhtml
new file mode 100644
index 0000000000..33340d3f63
--- /dev/null
+++ b/comm/mail/components/preferences/permissions.xhtml
@@ -0,0 +1,128 @@
+<?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"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+
+<!DOCTYPE dialog>
+
+<window
+ id="PermissionsDialog"
+ class="windowDialog"
+ data-l10n-id="permissions-reminder-window2"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="gPermissionManager.onLoad();"
+ onunload="gPermissionManager.uninit();"
+ persist="width height"
+ onkeypress="gPermissionManager.onWindowKeyPress(event);"
+>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://global/content/treeUtils.js" />
+ <script src="chrome://messenger/content/preferences/permissions.js" />
+
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/preferences/permissions.ftl"
+ />
+ </linkset>
+
+ <keyset>
+ <key
+ data-l10n-id="permission-preferences-close-window"
+ data-l10n-attrs="key"
+ modifiers="accel"
+ oncommand="window.close();"
+ />
+ </keyset>
+
+ <vbox class="contentPane largeDialogContainer" flex="1">
+ <description id="permissionsText" control="url" />
+ <separator class="thin" />
+ <label id="urlLabel" control="url" data-l10n-id="website-address-label" />
+ <hbox align="start" class="input-container">
+ <html:input
+ id="url"
+ type="text"
+ oninput="gPermissionManager.onHostInput(event.target);"
+ onkeypress="gPermissionManager.onHostKeyPress(event);"
+ />
+ </hbox>
+ <hbox pack="end">
+ <button
+ id="btnBlock"
+ disabled="true"
+ data-l10n-id="block-button"
+ oncommand="gPermissionManager.addPermission(Ci.nsIPermissionManager.DENY_ACTION);"
+ />
+ <button
+ id="btnSession"
+ disabled="true"
+ data-l10n-id="allow-session-button"
+ oncommand="gPermissionManager.addPermission(Ci.nsICookiePermission.ACCESS_SESSION);"
+ />
+ <button
+ id="btnAllow"
+ disabled="true"
+ data-l10n-id="allow-button"
+ default="true"
+ oncommand="gPermissionManager.addPermission(Ci.nsIPermissionManager.ALLOW_ACTION);"
+ />
+ </hbox>
+ <separator class="thin" />
+ <tree
+ id="permissionsTree"
+ flex="1"
+ style="height: 18em"
+ hidecolumnpicker="true"
+ onkeypress="gPermissionManager.onPermissionKeyPress(event)"
+ onselect="gPermissionManager.onPermissionSelected();"
+ >
+ <treecols>
+ <treecol
+ id="siteCol"
+ data-l10n-id="treehead-sitename-label"
+ data-field-name="rawHost"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="statusCol"
+ data-l10n-id="treehead-status-label"
+ data-field-name="capability"
+ persist="width"
+ />
+ </treecols>
+ <treechildren />
+ </tree>
+ </vbox>
+ <vbox>
+ <hbox class="actionButtons" flex="1">
+ <button
+ id="removePermission"
+ disabled="true"
+ data-l10n-id="remove-site-button"
+ oncommand="gPermissionManager.onPermissionDeleted();"
+ />
+ <button
+ id="removeAllPermissions"
+ data-l10n-id="remove-all-site-button"
+ oncommand="gPermissionManager.onAllPermissionsDeleted();"
+ />
+ </hbox>
+ <spacer flex="1" />
+ <hbox class="actionButtons" pack="end" flex="1">
+ <button oncommand="window.close();" data-l10n-id="cancel-button" />
+ <button
+ id="btnApplyChanges"
+ oncommand="gPermissionManager.onApplyChanges();"
+ data-l10n-id="save-button"
+ />
+ </hbox>
+ </vbox>
+</window>
diff --git a/comm/mail/components/preferences/preferences.js b/comm/mail/components/preferences/preferences.js
new file mode 100644
index 0000000000..1a123527ca
--- /dev/null
+++ b/comm/mail/components/preferences/preferences.js
@@ -0,0 +1,453 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+/* import-globals-from general.js */
+/* import-globals-from compose.js */
+/* import-globals-from downloads.js */
+/* import-globals-from privacy.js */
+/* import-globals-from chat.js */
+/* import-globals-from sync.js */
+/* import-globals-from findInPage.js */
+/* globals gCalendarPane */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { calendarDeactivator } = ChromeUtils.import(
+ "resource:///modules/calendar/calCalendarDeactivator.jsm"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+
+var paneDeck = document.getElementById("paneDeck");
+var defaultPane = "paneGeneral";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(this, "gSubDialog", function () {
+ const { SubDialogManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/SubDialog.sys.mjs"
+ );
+ return new SubDialogManager({
+ dialogStack: document.getElementById("dialogStack"),
+ dialogTemplate: document.getElementById("dialogTemplate"),
+ dialogOptions: {
+ styleSheets: [
+ "chrome://messenger/skin/preferences/dialog.css",
+ "chrome://messenger/skin/preferences/preferences.css",
+ ],
+ resizeCallback: ({ title, frame }) => {
+ UIFontSize.registerWindow(frame.contentWindow);
+
+ // Search within main document and highlight matched keyword.
+ gSearchResultsPane.searchWithinNode(title, gSearchResultsPane.query);
+
+ // Search within sub-dialog document and highlight matched keyword.
+ gSearchResultsPane.searchWithinNode(
+ frame.contentDocument.firstElementChild,
+ gSearchResultsPane.query
+ );
+
+ // Creating tooltips for all the instances found
+ for (let node of gSearchResultsPane.listSearchTooltips) {
+ if (!node.tooltipNode) {
+ gSearchResultsPane.createSearchTooltip(
+ node,
+ gSearchResultsPane.query
+ );
+ }
+ }
+
+ // Resize the dialog to fit the content with edited font size.
+ requestAnimationFrame(() => {
+ let dialogs = frame.ownerGlobal.gSubDialog._dialogs;
+ let dialog = dialogs.find(
+ d => d._frame.contentDocument == frame.contentDocument
+ );
+ if (dialog) {
+ UIFontSize.resizeSubDialog(dialog);
+ }
+ });
+ },
+ },
+ });
+});
+
+document.addEventListener("DOMContentLoaded", init, { once: true });
+
+var gCategoryInits = new Map();
+var gLastCategory = { category: undefined, subcategory: undefined };
+
+function init_category_if_required(category) {
+ let categoryInfo = gCategoryInits.get(category);
+ if (!categoryInfo) {
+ throw new Error(
+ "Unknown in-content prefs category! Can't init " + category
+ );
+ }
+ if (categoryInfo.inited) {
+ return null;
+ }
+ return categoryInfo.init();
+}
+
+function register_module(categoryName, categoryObject) {
+ gCategoryInits.set(categoryName, {
+ inited: false,
+ async init() {
+ let template = document.getElementById(categoryName);
+ if (template) {
+ // Replace the template element with the nodes inside of it.
+ let frag = template.content;
+ await document.l10n.translateFragment(frag);
+
+ // Actually insert them into the DOM.
+ document.l10n.pauseObserving();
+ template.replaceWith(frag);
+ document.l10n.resumeObserving();
+
+ // Asks Preferences to update the attribute value of the entire
+ // document again (this can be simplified if we could separate the
+ // preferences of each pane.)
+ Preferences.queueUpdateOfAllElements();
+ }
+ categoryObject.init();
+ this.inited = true;
+ },
+ });
+}
+
+function init() {
+ register_module("paneGeneral", gGeneralPane);
+ register_module("paneCompose", gComposePane);
+ register_module("panePrivacy", gPrivacyPane);
+ register_module("paneCalendar", gCalendarPane);
+ if (AppConstants.NIGHTLY_BUILD) {
+ register_module("paneSync", gSyncPane);
+ }
+ register_module("paneSearchResults", gSearchResultsPane);
+ if (Services.prefs.getBoolPref("mail.chat.enabled")) {
+ register_module("paneChat", gChatPane);
+ } else {
+ // Remove the pane from the DOM so it doesn't get incorrectly included in
+ // the search results.
+ document.getElementById("paneChat").remove();
+ }
+
+ // If no calendar is currently enabled remove it from the DOM so it doesn't
+ // get incorrectly included in the search results.
+ if (!calendarDeactivator.isCalendarActivated) {
+ document.getElementById("paneCalendar").remove();
+ document.getElementById("category-calendar").remove();
+ }
+ gSearchResultsPane.init();
+
+ let categories = document.getElementById("categories");
+ categories.addEventListener("select", event => gotoPref(event.target.value));
+
+ document.documentElement.addEventListener("keydown", event => {
+ if (event.key == "Tab") {
+ categories.setAttribute("keyboard-navigation", "true");
+ } else if ((event.ctrlKey || event.metaKey) && event.key == "f") {
+ document.getElementById("searchInput").focus();
+ event.preventDefault();
+ }
+ });
+
+ categories.addEventListener("mousedown", function () {
+ this.removeAttribute("keyboard-navigation");
+ });
+
+ window.addEventListener("hashchange", onHashChange);
+ let lastSelected = Services.xulStore.getValue(
+ "about:preferences",
+ "paneDeck",
+ "lastSelected"
+ );
+ gotoPref(lastSelected);
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+}
+
+function onHashChange() {
+ gotoPref();
+}
+
+async function gotoPref(aCategory) {
+ let categories = document.getElementById("categories");
+ const kDefaultCategoryInternalName = "paneGeneral";
+ const kDefaultCategory = "general";
+ let hash = document.location.hash;
+
+ let category = aCategory || hash.substr(1) || kDefaultCategoryInternalName;
+ let breakIndex = category.indexOf("-");
+ // Subcategories allow for selecting smaller sections of the preferences
+ // until proper search support is enabled (bug 1353954).
+ let subcategory = breakIndex != -1 && category.substring(breakIndex + 1);
+ if (subcategory) {
+ category = category.substring(0, breakIndex);
+ }
+ category = friendlyPrefCategoryNameToInternalName(category);
+ if (category != "paneSearchResults") {
+ gSearchResultsPane.query = null;
+ gSearchResultsPane.searchInput.value = "";
+ gSearchResultsPane.getFindSelection(window).removeAllRanges();
+ gSearchResultsPane.removeAllSearchTooltips();
+ gSearchResultsPane.removeAllSearchMenuitemIndicators();
+ } else if (!gSearchResultsPane.searchInput.value) {
+ // Something tried to send us to the search results pane without
+ // a query string. Default to the General pane instead.
+ category = kDefaultCategoryInternalName;
+ document.location.hash = kDefaultCategory;
+ gSearchResultsPane.query = null;
+ }
+
+ // Updating the hash (below) or changing the selected category
+ // will re-enter gotoPref.
+ if (gLastCategory.category == category && !subcategory) {
+ return;
+ }
+
+ let item;
+ if (category != "paneSearchResults") {
+ // Hide second level headers in normal view
+ for (let element of document.querySelectorAll(".search-header")) {
+ element.hidden = true;
+ }
+
+ item = categories.querySelector(".category[value=" + category + "]");
+ if (!item) {
+ category = kDefaultCategoryInternalName;
+ item = categories.querySelector(".category[value=" + category + "]");
+ }
+ }
+
+ if (
+ gLastCategory.category ||
+ category != kDefaultCategoryInternalName ||
+ subcategory
+ ) {
+ let friendlyName = internalPrefCategoryNameToFriendlyName(category);
+ document.location.hash = friendlyName;
+ }
+ // Need to set the gLastCategory before setting categories.selectedItem since
+ // the categories 'select' event will re-enter the gotoPref codepath.
+ gLastCategory.category = category;
+ gLastCategory.subcategory = subcategory;
+ if (item) {
+ categories.selectedItem = item;
+ } else {
+ categories.clearSelection();
+ }
+ window.history.replaceState(category, document.title);
+
+ try {
+ await init_category_if_required(category);
+ } catch (ex) {
+ console.error(
+ new Error(
+ "Error initializing preference category " + category + ": " + ex
+ )
+ );
+ throw ex;
+ }
+
+ // Bail out of this goToPref if the category
+ // or subcategory changed during async operation.
+ if (
+ gLastCategory.category !== category ||
+ gLastCategory.subcategory !== subcategory
+ ) {
+ return;
+ }
+
+ search(category, "data-category");
+
+ let mainContent = document.querySelector(".main-content");
+ mainContent.scrollTop = 0;
+
+ spotlight(subcategory, category);
+
+ document.dispatchEvent(new CustomEvent("paneSelected", { bubbles: true }));
+ document.getElementById("preferencesContainer").scrollTo(0, 0);
+ document.getElementById("paneDeck").setAttribute("lastSelected", category);
+ Services.xulStore.setValue(
+ "about:preferences",
+ "paneDeck",
+ "lastSelected",
+ category
+ );
+}
+
+function friendlyPrefCategoryNameToInternalName(aName) {
+ if (aName.startsWith("pane")) {
+ return aName;
+ }
+ return "pane" + aName.substring(0, 1).toUpperCase() + aName.substr(1);
+}
+
+// This function is duplicated inside of utilityOverlay.js's openPreferences.
+function internalPrefCategoryNameToFriendlyName(aName) {
+ return (aName || "").replace(/^pane./, function (toReplace) {
+ return toReplace[4].toLowerCase();
+ });
+}
+
+function search(aQuery, aAttribute) {
+ let paneDeck = document.getElementById("paneDeck");
+ let elements = paneDeck.children;
+ for (let element of elements) {
+ // If the "data-hidden-from-search" is "true", the
+ // element will not get considered during search.
+ if (
+ element.getAttribute("data-hidden-from-search") != "true" ||
+ element.getAttribute("data-subpanel") == "true"
+ ) {
+ let attributeValue = element.getAttribute(aAttribute);
+ if (attributeValue == aQuery) {
+ element.hidden = false;
+ } else {
+ element.hidden = true;
+ }
+ } else if (
+ element.getAttribute("data-hidden-from-search") == "true" &&
+ !element.hidden
+ ) {
+ element.hidden = true;
+ }
+ element.classList.remove("visually-hidden");
+ }
+
+ let keysets = paneDeck.getElementsByTagName("keyset");
+ for (let element of keysets) {
+ let attributeValue = element.getAttribute(aAttribute);
+ if (attributeValue == aQuery) {
+ element.removeAttribute("disabled");
+ } else {
+ element.setAttribute("disabled", true);
+ }
+ }
+}
+
+async function spotlight(subcategory, category) {
+ let highlightedElements = document.querySelectorAll(".spotlight");
+ if (highlightedElements.length) {
+ for (let element of highlightedElements) {
+ element.classList.remove("spotlight");
+ }
+ }
+ if (subcategory) {
+ scrollAndHighlight(subcategory, category);
+ }
+}
+
+async function scrollAndHighlight(subcategory, category) {
+ let element = document.querySelector(`[data-subcategory="${subcategory}"]`);
+ if (!element) {
+ return;
+ }
+ let header = getClosestDisplayedHeader(element);
+
+ scrollContentTo(header);
+ element.classList.add("spotlight");
+}
+
+/**
+ * If there is no visible second level header it will return first level header,
+ * otherwise return second level header.
+ *
+ * @returns {Element} The closest displayed header.
+ */
+function getClosestDisplayedHeader(element) {
+ let header = element.closest("groupbox");
+ let searchHeader = header.querySelector(".search-header");
+ if (
+ searchHeader &&
+ searchHeader.hidden &&
+ header.previousElementSibling.classList.contains("subcategory")
+ ) {
+ header = header.previousElementSibling;
+ }
+ return header;
+}
+
+function scrollContentTo(element) {
+ const STICKY_CONTAINER_HEIGHT =
+ document.querySelector(".sticky-container").clientHeight;
+ let mainContent = document.querySelector(".main-content");
+ let top = element.getBoundingClientRect().top - STICKY_CONTAINER_HEIGHT;
+ mainContent.scroll({
+ top,
+ behavior: "smooth",
+ });
+}
+
+/**
+ * Selects the specified preferences pane
+ *
+ * @param paneID ID of prefpane to select
+ * @param scrollPaneTo ID of the element to scroll into view
+ * @param otherArgs.subdialog ID of button to activate, opening a subdialog
+ */
+function selectPrefPane(paneID, scrollPaneTo, otherArgs) {
+ if (paneID) {
+ if (gLastCategory.category != paneID) {
+ gotoPref(paneID);
+ }
+ if (scrollPaneTo) {
+ showTab(scrollPaneTo, otherArgs ? otherArgs.subdialog : undefined);
+ }
+ }
+}
+
+/**
+ * Select the specified tab
+ *
+ * @param scrollPaneTo ID of the element to scroll into view
+ * @param subdialogID ID of button to activate, opening a subdialog
+ */
+function showTab(scrollPaneTo, subdialogID) {
+ setTimeout(function () {
+ let scrollTarget = document.getElementById(scrollPaneTo);
+ if (scrollTarget.closest("groupbox")) {
+ scrollTarget = scrollTarget.closest("groupbox");
+ }
+ scrollTarget.scrollIntoView();
+ if (subdialogID) {
+ document.getElementById(subdialogID).click();
+ }
+ });
+}
+
+/**
+ * Filter the lastFallbackLocale from availableLocales if it doesn't have all
+ * of the needed strings.
+ *
+ * When the lastFallbackLocale isn't the defaultLocale, then by default only
+ * fluent strings are included. To fully use that locale you need the langpack
+ * to be installed, so if it isn't installed remove it from availableLocales.
+ */
+async function getAvailableLocales() {
+ let { availableLocales, defaultLocale, lastFallbackLocale } = Services.locale;
+ // If defaultLocale isn't lastFallbackLocale, then we still need the langpack
+ // for lastFallbackLocale for it to be useful.
+ if (defaultLocale != lastFallbackLocale) {
+ let lastFallbackId = `langpack-${lastFallbackLocale}@thunderbird.mozilla.org`;
+ let lastFallbackInstalled = await AddonManager.getAddonByID(lastFallbackId);
+ if (!lastFallbackInstalled) {
+ return availableLocales.filter(locale => locale != lastFallbackLocale);
+ }
+ }
+ return availableLocales;
+}
diff --git a/comm/mail/components/preferences/preferences.xhtml b/comm/mail/components/preferences/preferences.xhtml
new file mode 100644
index 0000000000..8092f1af97
--- /dev/null
+++ b/comm/mail/components/preferences/preferences.xhtml
@@ -0,0 +1,256 @@
+<?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"?>
+<?xml-stylesheet href="chrome://global/skin/popup.css"?>
+<?xml-stylesheet href="chrome://global/skin/autocomplete.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/preferences/applications.css"?>
+<?xml-stylesheet href="chrome://global/skin/in-content/common.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/preferences/preferences.css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-preferences.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar.css"?>
+
+<!DOCTYPE html [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % editorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/editorOverlay.dtd">
+%editorOverlayDTD;
+<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd">
+%lightningDTD;
+<!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd">
+%globalDTD;
+<!ENTITY % eventDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd">
+%eventDTD;
+]>
+
+<html id="MailPreferences" xmlns="http://www.w3.org/1999/xhtml"
+ role="document"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false">
+<head>
+ <title data-l10n-id="preferences-doc-title2"></title>
+ <meta http-equiv="Content-Security-Policy" content="default-src chrome:; connect-src *; script-src chrome: 'unsafe-inline'; img-src chrome: moz-icon: https: data:; style-src chrome: data: 'unsafe-inline'; object-src 'none'" />
+ <meta name="color-scheme" content="light dark" />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/preferences/preferences.ftl" />
+ <link rel="localization" href="messenger/preferences/fonts.ftl" />
+ <link rel="localization" href="messenger/preferences/languages.ftl" />
+ <link rel="localization" href="messenger/aboutDialog.ftl"/>
+
+ <!-- Links below are only used for search-l10n-ids into subdialogs -->
+ <link rel="localization" href="messenger/preferences/receipts.ftl" />
+ <link rel="localization" href="messenger/preferences/permissions.ftl" />
+ <link rel="localization" href="messenger/preferences/cookies.ftl" />
+ <link rel="localization" href="messenger/preferences/system-integration.ftl" />
+ <link rel="localization" href="messenger/preferences/colors.ftl" />
+ <link rel="localization" href="messenger/preferences/dock-options.ftl" />
+ <link rel="localization" href="messenger/preferences/notifications.ftl" />
+ <link rel="localization" href="messenger/preferences/new-tag.ftl" />
+ <link rel="localization" href="toolkit/updates/history.ftl" />
+ <link rel="localization" href="messenger/preferences/connection.ftl" />
+ <link rel="localization" href="messenger/preferences/offline.ftl" />
+ <link rel="localization" href="toolkit/about/config.ftl" />
+ <link rel="localization" href="messenger/preferences/attachment-reminder.ftl" />
+ <link rel="localization" href="messenger/preferences/passwordManager.ftl" />
+ <link rel="localization" href="security/certificates/certManager.ftl" />
+ <link rel="localization" href="security/certificates/deviceManager.ftl" />
+#ifdef NIGHTLY_BUILD
+ <link rel="localization" href="messenger/preferences/sync-dialog.ftl" />
+ <link rel="localization" href="messenger/firefoxAccounts.ftl" />
+#endif
+
+ <script defer="defer" src="chrome://global/content/preferencesBindings.js"></script>
+#ifdef MOZ_UPDATER
+ <script defer="defer" src="chrome://messenger/content/aboutDialog-appUpdater.js"></script>
+#endif
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/accountUtils.js"></script>
+
+ <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script>
+ <script defer="defer" src="chrome://messenger/content/preferences/preferences.js"></script>
+ <script defer="defer" src="chrome://messenger/content/preferences/findInPage.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/general.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/alarms.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-notifications-setting.js"/>
+ <script defer="defer" src="chrome://calendar/content/preferences/notifications.js"/>
+ <script defer="defer" src="chrome://calendar/content/preferences/categories.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/views.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/calendar-preferences.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <stack id="preferences-stack" flex="1">
+ <hbox id="prefBox" class="main-content" flex="1">
+
+ <vbox id="pref-category-box">
+
+ <!-- category list -->
+ <richlistbox id="categories"
+ data-l10n-id="category-list"
+ data-l10n-attrs="aria-label">
+ <richlistitem id="category-general"
+ class="category"
+ value="paneGeneral"
+ data-l10n-id="category-general"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/settings.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-general-title"/>
+ </richlistitem>
+
+ <richlistitem id="category-compose"
+ class="category"
+ value="paneCompose"
+ data-l10n-id="category-compose"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/pencil.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-compose-title"/>
+ </richlistitem>
+
+ <richlistitem id="category-privacy"
+ class="category"
+ value="panePrivacy"
+ data-l10n-id="category-privacy"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/lock.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-privacy-title"/>
+ </richlistitem>
+
+ <richlistitem id="category-chat"
+ class="category"
+ value="paneChat"
+ data-l10n-id="category-chat"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/chat.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-chat-title"/>
+ </richlistitem>
+
+ <richlistitem id="category-calendar"
+ class="category"
+ value="paneCalendar"
+ data-l10n-id="category-calendar"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/calendar.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-calendar-title"/>
+ </richlistitem>
+#ifdef NIGHTLY_BUILD
+ <richlistitem id="category-sync"
+ class="category"
+ value="paneSync"
+ data-l10n-id="category-sync"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/sync.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-sync-title"/>
+ </richlistitem>
+#endif
+ </richlistbox>
+
+ <spacer flex="1"/>
+
+ <vbox class="sidebar-footer-list">
+ <html:a id="accountButton" class="sidebar-footer-link"
+ onclick="MsgAccountManager(null);">
+ <html:img class="sidebar-footer-icon account-icon"
+ src="chrome://messenger/skin/icons/new/compact/account-settings.svg"
+ alt="" />
+ <label data-l10n-id="account-button" class="sidebar-footer-label" flex="1"/>
+ </html:a>
+
+ <html:a id="addonsButton" class="sidebar-footer-link"
+ onclick="window.browsingContext.topChromeWindow.openAddonsMgr();">
+ <html:img class="sidebar-footer-icon"
+ src="chrome://messenger/skin/icons/new/compact/extension.svg"
+ alt="" />
+ <label class="sidebar-footer-label"
+ data-l10n-id="open-addons-sidebar-button"
+ flex="1"/>
+ </html:a>
+ </vbox>
+
+ </vbox>
+
+ <vbox id="preferencesContainer" flex="1" align="start">
+ <vbox class="paneDeckContainer">
+ <hbox class="sticky-container" pack="end" align="top" flex="1">
+ <search-textbox id="searchInput"
+ data-l10n-id="search-preferences-input2"
+ data-l10n-attrs="placeholder, style"
+ hidden="true"/>
+ </hbox>
+ <vbox id="paneDeck">
+#include searchResults.inc.xhtml
+#include general.inc.xhtml
+#include compose.inc.xhtml
+#include privacy.inc.xhtml
+#include chat.inc.xhtml
+#include ../../../calendar/base/content/preferences/calendar-preferences.inc.xhtml
+#ifdef NIGHTLY_BUILD
+#include sync.inc.xhtml
+#endif
+ </vbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ <stack id="dialogStack" hidden="true"/>
+ <vbox id="dialogTemplate"
+ class="dialogOverlay"
+ align="center"
+ pack="center"
+ topmost="true"
+ hidden="true">
+ <vbox class="dialogBox"
+ pack="end"
+ role="dialog"
+ aria-labelledby="dialogTitle">
+ <hbox class="dialogTitleBar" align="center">
+ <label class="dialogTitle" flex="1"/>
+ <button class="dialogClose close-icon" data-l10n-id="close-button"/>
+ </hbox>
+ <browser class="dialogFrame"
+ autoscroll="false"
+ disablehistory="true"/>
+ </vbox>
+ </vbox>
+ </stack>
+
+ <!-- Helpers for the FileLink options browser select and autocomplete. -->
+ <popupset>
+ <!-- For select dropdowns. The menupopup is what shows the list of options,
+ and the popuponly menulist makes things like the menuactive attributes
+ work correctly on the menupopup. ContentSelectDropdown expects the
+ popuponly menulist to be its immediate parent. -->
+ <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
+ <menupopup rolluponmousewheel="true"
+ activateontab="true"
+ position="after_start"
+ level="parent"
+#ifdef XP_WIN
+ consumeoutsideclicks="false"
+ ignorekeys="shortcuts"
+#endif
+ />
+ </menulist>
+ <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete"
+ type="autocomplete"
+ role="group"
+ noautofocus="true"/>
+ </popupset>
+</html:body>
+</html>
diff --git a/comm/mail/components/preferences/preferencesTab.js b/comm/mail/components/preferences/preferencesTab.js
new file mode 100644
index 0000000000..0a59fda382
--- /dev/null
+++ b/comm/mail/components/preferences/preferencesTab.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/. */
+
+// mail/base/content/specialTabs.js
+/* globals contentTabBaseType, DOMLinkHandler */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+/**
+ * A tab to show Preferences.
+ */
+var preferencesTabType = {
+ __proto__: contentTabBaseType,
+ name: "preferencesTab",
+ perTabPanel: "vbox",
+ lastBrowserId: 0,
+ bundle: Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ ),
+ protoSvc: Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService(
+ Ci.nsIExternalProtocolService
+ ),
+
+ get loadingTabString() {
+ delete this.loadingTabString;
+ return (this.loadingTabString = document
+ .getElementById("bundle_messenger")
+ .getString("loadingTab"));
+ },
+
+ modes: {
+ preferencesTab: {
+ type: "preferencesTab",
+ },
+ },
+
+ shouldSwitchTo(aArgs) {
+ if (!this.tab) {
+ return -1;
+ }
+ this.tab.browser.contentWindow.selectPrefPane(
+ aArgs.paneID,
+ aArgs.scrollPaneTo,
+ aArgs.otherArgs
+ );
+ return document.getElementById("tabmail").tabInfo.indexOf(this.tab);
+ },
+
+ closeTab(aTab) {
+ this.tab = null;
+ },
+
+ openTab(aTab, aArgs) {
+ aTab.tabNode.setIcon(
+ "chrome://messenger/skin/icons/new/compact/settings.svg"
+ );
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("preferencesTab")
+ .firstElementChild.cloneNode(true);
+
+ clone.setAttribute("id", "preferencesTab" + this.lastBrowserId);
+ clone.setAttribute("collapsed", false);
+
+ aTab.panel.setAttribute("id", "preferencesTabWrapper" + this.lastBrowserId);
+ aTab.panel.appendChild(clone);
+
+ // Start setting up the browser.
+ aTab.browser = aTab.panel.querySelector("browser");
+ aTab.browser.setAttribute(
+ "id",
+ "preferencesTabBrowser" + this.lastBrowserId
+ );
+ aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler);
+
+ aTab.findbar = document.createXULElement("findbar");
+ aTab.findbar.setAttribute(
+ "browserid",
+ "preferencesTabBrowser" + this.lastBrowserId
+ );
+ aTab.panel.appendChild(aTab.findbar);
+
+ // Default to reload being disabled.
+ aTab.reloadEnabled = false;
+
+ aTab.url = "about:preferences";
+ aTab.paneID = aArgs.paneID;
+ aTab.scrollPaneTo = aArgs.scrollPaneTo;
+ aTab.otherArgs = aArgs.otherArgs;
+
+ // Now set up the listeners.
+ this._setUpTitleListener(aTab);
+ this._setUpCloseWindowListener(aTab);
+
+ // Wait for full loading of the tab and the automatic selecting of last tab.
+ // Then run the given onload code.
+ aTab.browser.addEventListener(
+ "paneSelected",
+ function (event) {
+ aTab.pageLoading = false;
+ aTab.pageLoaded = true;
+
+ if ("onLoad" in aArgs) {
+ // Let selection of the initial pane complete before selecting another.
+ // Otherwise we can end up with two panes selected at once.
+ aTab.browser.contentWindow.setTimeout(() => {
+ // By now, the tab could already be closed. Check that it isn't.
+ if (aTab.panel) {
+ aArgs.onLoad(event, aTab.browser);
+ }
+ }, 0);
+ }
+ },
+ { once: true }
+ );
+
+ // Initialize our unit testing variables.
+ aTab.pageLoading = true;
+ aTab.pageLoaded = false;
+
+ // Now start loading the content.
+ aTab.title = this.loadingTabString;
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", aTab.browser);
+ let params = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ postData: aArgs.postData || null,
+ };
+ aTab.browser.loadURI(Services.io.newURI("about:preferences"), params);
+
+ this.tab = aTab;
+ this.lastBrowserId++;
+ },
+
+ persistTab(aTab) {
+ if (aTab.browser.currentURI.spec == "about:blank") {
+ return null;
+ }
+
+ return {
+ paneID: aTab.paneID,
+ scrollPaneTo: aTab.scrollPaneTo,
+ otherArgs: aTab.otherArgs,
+ };
+ },
+
+ restoreTab(aTabmail, aPersistedState) {
+ aTabmail.openTab("preferencesTab", {
+ paneID: aPersistedState.paneID,
+ scrollPaneTo: aPersistedState.scrollPaneTo,
+ otherArgs: aPersistedState.otherArgs,
+ });
+ },
+};
diff --git a/comm/mail/components/preferences/privacy.inc.xhtml b/comm/mail/components/preferences/privacy.inc.xhtml
new file mode 100644
index 0000000000..6afa5bb840
--- /dev/null
+++ b/comm/mail/components/preferences/privacy.inc.xhtml
@@ -0,0 +1,597 @@
+# 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/.
+ <script src="chrome://messenger/content/preferences/privacy.js"/>
+
+ <stringbundle id="bundlePreferences" src="chrome://messenger/locale/preferences/preferences.properties"/>
+ <html:template id="panePrivacy">
+ <hbox id="privacyCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="privacy-main-header"></html:h1>
+ </hbox>
+
+ <!-- Mail Content -->
+ <html:div data-category="panePrivacy">
+ <html:fieldset id="mailContentGroup" data-category="panePrivacy">
+ <html:legend data-l10n-id="mail-content"></html:legend>
+ <hbox id="remoteContentBox">
+ <checkbox id="acceptRemoteContent"
+ preference="mailnews.message_display.disable_remote_image"
+ data-l10n-id="remote-content-label"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="remoteContentExceptions"
+ oncommand="gPrivacyPane.showRemoteContentExceptions();"
+ data-l10n-id="exceptions-button"
+ search-l10n-ids="
+ permissions-reminder-window2.title,
+ website-address-label.value,
+ block-button.label,
+ allow-session-button.label,
+ allow-button.label,
+ treehead-sitename-label.label,
+ treehead-status-label.label,
+ remove-site-button.label,
+ remove-all-site-button.label,
+ cancel-button.label,
+ save-button.label,
+ permission-can-label,
+ permission-can-access-first-party-label,
+ permission-can-session-label,
+ permission-cannot-label,
+ invalid-uri-message,
+ invalid-uri-title"/>
+ </hbox>
+ </hbox>
+ <hbox>
+ <label is="text-link" id="acceptRemoteContentInfo"
+ href="https://support.mozilla.org/kb/remote-content-in-messages"
+ data-l10n-id="remote-content-info"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Web Content -->
+ <html:div data-category="panePrivacy">
+ <html:fieldset id="webContentGroup" data-category="panePrivacy">
+ <html:legend data-l10n-id="web-content"></html:legend>
+ <checkbox id="keepHistory"
+ preference="places.history.enabled"
+ data-l10n-id="history-label"/>
+ <hbox id="cookiesBox">
+ <checkbox id="acceptCookies"
+ preference="network.cookie.cookieBehavior"
+ data-l10n-id="cookies-label"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="cookieExceptions"
+ oncommand="gPrivacyPane.showCookieExceptions();"
+ data-l10n-id="exceptions-button"
+ preference="pref.privacy.disable_button.cookie_exceptions"
+ search-l10n-ids="
+ permissions-reminder-window2.title,
+ website-address-label.value,
+ block-button.label,
+ allow-session-button.label,
+ allow-button.label,
+ treehead-sitename-label.label,
+ treehead-status-label.label,
+ remove-site-button.label,
+ remove-all-site-button.label,
+ cancel-button.label,
+ save-button.label,
+ permission-can-label,
+ permission-can-access-first-party-label,
+ permission-can-session-label,
+ permission-cannot-label,
+ invalid-uri-message,
+ invalid-uri-title"/>
+ </hbox>
+ </hbox>
+ <hbox id="acceptThirdPartyRow" class="indent">
+ <hbox id="acceptThirdPartyBox" align="center">
+ <label id="acceptThirdPartyLabel" control="acceptThirdPartyMenu"
+ data-l10n-id="third-party-label"/>
+ <hbox>
+ <menulist id="acceptThirdPartyMenu" preference="network.cookie.cookieBehavior">
+ <menupopup>
+ <menuitem data-l10n-id="third-party-always" value="always"/>
+ <menuitem data-l10n-id="third-party-visited" value="visited"/>
+ <menuitem data-l10n-id="third-party-never" value="never"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <hbox flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="showCookiesButton"
+ data-l10n-id="cookies-button"
+ oncommand="gPrivacyPane.showCookies();"
+ preference="pref.privacy.disable_button.view_cookies"
+ search-l10n-ids="
+ cookies-window-dialog2.title,
+ filter-search-label.value,
+ cookies-on-system-label,
+ treecol-site-header.label,
+ treecol-name-header.label,
+ props-name-label.value,
+ props-value-label.value,
+ props-domain-label.value,
+ props-path-label.value,
+ props-secure-label.value,
+ props-expires-label.value,
+ props-container-label.value,
+ remove-cookie-button.label,
+ remove-all-cookies-button.label,
+ cookie-close-button.label"/>
+ </hbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center">
+ <checkbox id="privacyDoNotTrackCheckbox"
+ class="tail-with-learn-more"
+ data-l10n-id="do-not-track-label"
+ preference="privacy.donottrackheader.enabled"/>
+ <label is="text-link" id="doNotTrackInfo"
+ href="https://www.mozilla.org/dnt"
+ data-l10n-id="dnt-learn-more-button"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="privacyPasswordsCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="privacy-passwords-header"></html:h1>
+ </hbox>
+
+ <separator data-category="panePrivacy"/>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <hbox align="center">
+ <description data-l10n-id="passwords-description"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="showPasswords"
+ data-l10n-id="passwords-button"
+ oncommand="gPrivacyPane.showPasswords();"
+ preference="pref.privacy.disable_button.view_passwords"
+ search-l10n-ids="
+ saved-logins.title,
+ copy-provider-url-cmd.label,
+ copy-username-cmd.label,
+ edit-username-cmd.label,
+ copy-password-cmd.label,
+ edit-password-cmd.label,
+ search-filter.placeholder,
+ column-heading-provider.label,
+ column-heading-username.label,
+ column-heading-password.label,
+ column-heading-time-created.label,
+ column-heading-time-last-used.label,
+ column-heading-time-password-changed.label,
+ column-heading-times-used.label,
+ remove.label,
+ import.label,
+ show-passwords.label,
+ hide-passwords.label,
+ logins-description-all,
+ logins-description-filtered,
+ remove-all.label,
+ remove-all-shown.label,
+ remove-all-passwords-prompt,
+ remove-all-passwords-title,
+ no-master-password-prompt,
+ password-os-auth-dialog-message,
+ password-os-auth-dialog-message-macosx,
+ password-os-auth-dialog-caption"/>
+ </hbox>
+ </hbox>
+ <!-- XXX button to do a showExceptions()? -->
+
+ <separator class="thin"/>
+
+ <description data-l10n-id="primary-password-description"/>
+ <hbox>
+ <checkbox id="useMasterPassword"
+ data-l10n-id="primary-password-label"
+ oncommand="gPrivacyPane.updateMasterPasswordButton();"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="changeMasterPassword"
+ data-l10n-id="primary-password-button"
+ oncommand="gPrivacyPane.changeMasterPassword();"/>
+ </hbox>
+ <!--
+ Those two strings are meant to be invisible and will be used exclusively to provide
+ localization for an alert window.
+ -->
+ <label id="fips-title" hidden="true" data-l10n-id="forms-primary-pw-fips-title"></label>
+ <label id="fips-desc" hidden="true" data-l10n-id="forms-master-pw-fips-desc"></label>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="privacyJunkCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="privacy-junk-header"></html:h1>
+ </hbox>
+
+ <separator data-category="panePrivacy"/>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <description data-l10n-id="junk-description"/>
+ <separator class="thin"/>
+ <hbox>
+ <checkbox id="manualMark"
+ data-l10n-id="junk-label"
+ preference="mail.spam.manualMark"
+ oncommand="gPrivacyPane.updateManualMarkMode(this.checked);"/>
+ <spacer flex="1"/>
+ </hbox>
+ <radiogroup id="manualMarkMode"
+ class="indent"
+ preference="mail.spam.manualMarkMode"
+ aria-labelledby="manualMark">
+ <hbox>
+ <radio id="manualMarkMode0"
+ value="0"
+ data-l10n-id="junk-move-label"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <radio id="manualMarkMode1"
+ value="1"
+ data-l10n-id="junk-delete-label"/>
+ <spacer flex="1"/>
+ </hbox>
+ </radiogroup>
+ <hbox>
+ <checkbox id="markAsReadOnSpam"
+ data-l10n-id="junk-read-label"
+ preference="mail.spam.markAsReadOnSpam"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox align="start">
+ <checkbox id="enableJunkLogging" data-l10n-id="junk-log-label"
+ oncommand="gPrivacyPane.updateJunkLogButton(this.checked);"
+ preference="mail.spam.logging.enabled"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="openJunkLogButton"
+ data-l10n-id="junk-log-button"
+ oncommand="gPrivacyPane.openJunkLog();"/>
+ </hbox>
+ <hbox align="start">
+ <spacer flex="1"/>
+ <button is="highlightable-button"
+ data-l10n-id="reset-junk-button"
+ oncommand="gPrivacyPane.resetTrainingData()"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+#ifdef MOZ_DATA_REPORTING
+ <hbox id="privacyDataCollectionCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="collection-header"></html:h1>
+ </hbox>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <description>
+ <label class="tail-with-learn-more" data-l10n-id="collection-description"/>
+ <label id="dataCollectionPrivacyNotice"
+ class="learnMore" is="text-link"
+ data-l10n-id="collection-privacy-notice"/>
+ </description>
+ <description>
+ <html:div id="telemetry-container" hidden="hidden">
+ <html:img id="telemetryInfoIcon" alt=""
+ src="chrome://global/skin/icons/info.svg" />
+ <html:span id="telemetryDisabledDescription"
+ class="tail-with-learn-more"
+ data-l10n-id="collection-health-report-telemetry-disabled">
+ </html:span>
+ <button id="telemetryDataDeletionLearnMore"
+ class="learnMore" is="text-link"
+ data-l10n-id="collection-health-report-telemetry-disabled-link"/>
+ </html:div>
+ </description>
+ <vbox data-subcategory="reports">
+ <hbox align="center">
+ <checkbox id="submitHealthReportBox"
+ data-l10n-id="collection-health-report"
+ class="tail-with-learn-more"/>
+ <label id="FHRLearnMore"
+ class="learnMore" is="text-link"
+ data-l10n-id="collection-health-report-link"/>
+ </hbox>
+#ifndef MOZ_TELEMETRY_REPORTING
+ <description id="TelemetryDisabledDesc"
+ class="indent tip-caption" control="telemetryGroup"
+ data-l10n-id="collection-health-report-disabled"/>
+#endif
+
+#ifdef MOZ_CRASHREPORTER
+ <hbox align="center">
+ <checkbox id="automaticallySubmitCrashesBox"
+ class="tail-with-learn-more"
+ preference="browser.crashReports.unsubmittedCheck.autoSubmit2"
+ data-l10n-id="collection-backlogged-crash-reports"/>
+ <label id="crashReporterLearnMore"
+ class="learnMore" is="text-link" data-l10n-id="collection-backlogged-crash-reports-link"/>
+ </hbox>
+#endif
+ </vbox>
+ </html:fieldset>
+ </html:div>
+#endif
+
+ <hbox id="privacySecurityCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="privacy-security-header"></html:h1>
+ </hbox>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <html:legend data-l10n-id="privacy-scam-detection-title"></html:legend>
+ <description data-l10n-id="phishing-description"/>
+ <separator class="thin"/>
+ <hbox>
+ <checkbox id="enablePhishingDetector"
+ data-l10n-id="phishing-label"
+ preference="mail.phishing.detection.enabled"/>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Anti Virus -->
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <html:legend data-l10n-id="privacy-anti-virus-title"></html:legend>
+ <description data-l10n-id="antivirus-description"/>
+ <separator class="thin"/>
+ <hbox>
+ <checkbox id="enableAntiVirusQuarantine"
+ data-l10n-id="antivirus-label"
+ preference="mailnews.downloadToTempFile"/>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <html:legend data-l10n-id="privacy-certificates-title"></html:legend>
+ <description id="CertSelectionDesc" control="certSelection"
+ data-l10n-id="certificate-description"/>
+
+ <!--
+ The values on these radio buttons may look like l12y issues, but
+ they're not - this preference uses *those strings* as its values.
+ I KID YOU NOT.
+ -->
+ <radiogroup id="certSelection" class="indent"
+ orient="horizontal" preftype="string"
+ preference="security.default_personal_cert"
+ aria-labelledby="CertGroupCaption CertSelectionDesc">
+ <radio id="certSelectionAuto"
+ data-l10n-id="certificate-auto"
+ value="Select Automatically"/>
+ <radio id="certSelectionAsk"
+ data-l10n-id="certificate-ask"
+ value="Ask Every Time"/>
+ </radiogroup>
+
+ <separator/>
+
+ <hbox align="start">
+ <checkbox id="enableOCSP"
+ data-l10n-id="ocsp-label"
+ preference="security.OCSP.enabled"
+ flex="1"/>
+ <spacer flex="1"/>
+ <vbox>
+ <hbox flex="1">
+ <button is="highlightable-button" id="manageCertificatesButton"
+ data-l10n-id="certificate-button"
+ flex="1"
+ oncommand="gPrivacyPane.showCertificates();"
+ preference="security.disable_button.openCertManager"
+ search-l10n-ids="
+ certmgr-title.title,
+ certmgr-tab-mine.label,
+ certmgr-tab-remembered.label,
+ certmgr-tab-people.label,
+ certmgr-tab-servers.label,
+ certmgr-tab-ca.label,
+ certmgr-mine,
+ certmgr-remembered,
+ certmgr-people,
+ certmgr-ca,
+ certmgr-server,
+ certmgr-edit-ca-cert2.title,
+ certmgr-edit-cert-edit-trust,
+ certmgr-edit-cert-trust-ssl.label,
+ certmgr-edit-cert-trust-email.label,
+ certmgr-delete-cert2.title,
+ certmgr-cert-host.label,
+ certmgr-cert-name.label,
+ certmgr-cert-server.label,
+ certmgr-token-name.label,
+ certmgr-begins-label.label,
+ certmgr-expires-label.label,
+ certmgr-email.label,
+ certmgr-serial.label,
+ certmgr-fingerprint-sha-256.label,
+ certmgr-view.label,
+ certmgr-edit.label,
+ certmgr-export.label,
+ certmgr-delete.label,
+ certmgr-delete-builtin.label,
+ certmgr-backup.label,
+ certmgr-backup-all.label,
+ certmgr-restore.label,
+ certmgr-add-exception.label,
+ exception-mgr.title,
+ exception-mgr-extra-button.label,
+ exception-mgr-supplemental-warning,
+ exception-mgr-cert-location-url.value,
+ exception-mgr-cert-location-download.label,
+ exception-mgr-cert-status-view-cert.label,
+ exception-mgr-permanent.label,
+ pk11-bad-password,
+ pkcs12-decode-err,
+ pkcs12-unknown-err-restore,
+ pkcs12-unknown-err-backup,
+ pkcs12-unknown-err,
+ pkcs12-info-no-smartcard-backup,
+ pkcs12-dup-data,
+ choose-p12-backup-file-dialog,
+ file-browse-pkcs12-spec,
+ choose-p12-restore-file-dialog,
+ file-browse-certificate-spec,
+ import-ca-certs-prompt,
+ import-email-cert-prompt,
+ delete-user-cert-title.title,
+ delete-user-cert-confirm,
+ delete-user-cert-impact,
+ delete-ca-cert-title.title,
+ delete-ca-cert-confirm,
+ delete-ca-cert-impact,
+ delete-ssl-override-title.title,
+ delete-ssl-override-confirm,
+ delete-ssl-override-impact,
+ delete-email-cert-title.title,
+ delete-email-cert-confirm,
+ delete-email-cert-impact,
+ send-no-client-certificate,
+ no-cert-stored-for-override,
+ permanent-override,
+ temporary-override,
+ add-exception-branded-warning,
+ add-exception-invalid-header,
+ add-exception-domain-mismatch-short,
+ add-exception-domain-mismatch-long,
+ add-exception-expired-short,
+ add-exception-expired-long,
+ add-exception-unverified-or-bad-signature-short,
+ add-exception-unverified-or-bad-signature-long,
+ add-exception-valid-short,
+ add-exception-valid-long,
+ add-exception-checking-short,
+ add-exception-checking-long,
+ add-exception-no-cert-short,
+ add-exception-no-cert-long,
+ save-cert-as,
+ cert-format-base64,
+ cert-format-base64-chain,
+ write-file-failure,
+ cert-format-der,
+ cert-format-pkcs7,
+ cert-format-pkcs7-chain"/>
+ </hbox>
+ <hbox flex="1">
+ <button is="highlightable-button" id="viewSecurityDevicesButton"
+ data-l10n-id="security-devices-button"
+ flex="1"
+ oncommand="gPrivacyPane.showSecurityDevices();"
+ preference="security.disable_button.openDeviceManager"
+ search-l10n-ids="
+ devmgr-window.title,
+ devmgr-devlist.label,
+ devmgr-header-details.label,
+ devmgr-header-value.label,
+ devmgr-button-login.label,
+ devmgr-button-logout.label,
+ devmgr-button-changepw.label,
+ devmgr-button-load.label,
+ devmgr-button-unload.label,
+ devmgr-button-enable-fips.label,
+ devmgr-button-disable-fips.label,
+ load-device.title,
+ load-device-info,
+ load-device-modname.value,
+ load-device-modname-default.value,
+ load-device-filename.value,
+ load-device-browse.label,
+ devinfo-status.label,
+ devinfo-status-disabled.label,
+ devinfo-status-not-present.label,
+ devinfo-status-uninitialized.label,
+ devinfo-status-not-logged-in.label,
+ devinfo-status-logged-in.label,
+ devinfo-status-ready.label,
+ devinfo-desc.label,
+ unable-to-toggle-fips,
+ load-pk11-module-file-picker-title,
+ fips-nonempty-primary-password-required,
+ load-module-help-root-certs-module-name.value,
+ add-module-failure,
+ del-module-warning,
+ del-module-error,
+ devinfo-man-id.label,
+ devinfo-hwversion.label,
+ load-module-help-empty-module-name.value,
+ devinfo-fwversion.label,
+ devinfo-modname.label,
+ devinfo-modpath.label,
+ login-failed,
+ devinfo-label.label,
+ devinfo-serialnum"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Email End-To-End Encryption -->
+ <hbox id="privacyCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="email-e2ee-header"></html:h1>
+ </hbox>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset id="emailE2eeGroupPreparation" data-category="panePrivacy">
+ <description data-l10n-id="email-e2ee-enable-info"/>
+ <html:button id="settingsButton"
+ type="button"
+ data-l10n-id="account-button"
+ class="button button-flat"
+ onclick="window.browsingContext.topChromeWindow.MsgAccountManager(null);">
+ </html:button>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset id="emailE2eeGroupAutomatism" data-category="panePrivacy">
+ <html:legend data-l10n-id="email-e2ee-automatism"></html:legend>
+ <description data-l10n-id="email-e2ee-automatism-pre"/>
+ <separator class="thin"/>
+
+ <checkbox id="emailE2eeAutoEnable"
+ preference="mail.e2ee.auto_enable"
+ data-l10n-id="email-e2ee-auto-on"
+ oncommand="gPrivacyPane.updateE2eeCheckboxes();"/>
+ <checkbox id="emailE2eeAutoDisable"
+ preference="mail.e2ee.auto_disable"
+ data-l10n-id="email-e2ee-auto-off"
+ oncommand="gPrivacyPane.updateE2eeCheckboxes();"/>
+ <checkbox id="emailE2eeAutoDisableNotify"
+ preference="mail.e2ee.notify_on_auto_disable"
+ data-l10n-id="email-e2ee-auto-off-notify"
+ oncommand="gPrivacyPane.updateE2eeCheckboxes();"/>
+
+ <separator class="thin"/>
+ <description data-l10n-id="email-e2ee-automatism-post"/>
+ </html:fieldset>
+ </html:div>
+ </html:template>
diff --git a/comm/mail/components/preferences/privacy.js b/comm/mail/components/preferences/privacy.js
new file mode 100644
index 0000000000..2bb8cba6ad
--- /dev/null
+++ b/comm/mail/components/preferences/privacy.js
@@ -0,0 +1,562 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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";
+
+/* import-globals-from preferences.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+const PREF_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";
+
+Preferences.addAll([
+ { id: "mail.spam.manualMark", type: "bool" },
+ { id: "mail.spam.manualMarkMode", type: "int" },
+ { id: "mail.spam.markAsReadOnSpam", type: "bool" },
+ { id: "mail.spam.logging.enabled", type: "bool" },
+ { id: "mail.phishing.detection.enabled", type: "bool" },
+ { id: "browser.safebrowsing.enabled", type: "bool" },
+ { id: "mailnews.downloadToTempFile", type: "bool" },
+ { id: "pref.privacy.disable_button.view_passwords", type: "bool" },
+ { id: "pref.privacy.disable_button.cookie_exceptions", type: "bool" },
+ { id: "pref.privacy.disable_button.view_cookies", type: "bool" },
+ {
+ id: "mailnews.message_display.disable_remote_image",
+ type: "bool",
+ inverted: "true",
+ },
+ { id: "places.history.enabled", type: "bool" },
+ { id: "network.cookie.cookieBehavior", type: "int" },
+ { id: "network.cookie.blockFutureCookies", type: "bool" },
+ { id: "privacy.donottrackheader.enabled", type: "bool" },
+ { id: "security.default_personal_cert", type: "string" },
+ { id: "security.disable_button.openCertManager", type: "bool" },
+ { id: "security.disable_button.openDeviceManager", type: "bool" },
+ { id: "security.OCSP.enabled", type: "int" },
+ { id: "mail.e2ee.auto_enable", type: "bool" },
+ { id: "mail.e2ee.auto_disable", type: "bool" },
+ { id: "mail.e2ee.notify_on_auto_disable", type: "bool" },
+]);
+
+if (AppConstants.MOZ_DATA_REPORTING) {
+ Preferences.addAll([
+ // Preference instances for prefs that we need to monitor while the page is open.
+ { id: PREF_UPLOAD_ENABLED, type: "bool" },
+ ]);
+}
+
+// Data Choices tab
+if (AppConstants.MOZ_CRASHREPORTER) {
+ Preferences.add({
+ id: "browser.crashReports.unsubmittedCheck.autoSubmit2",
+ type: "bool",
+ });
+}
+
+function setEventListener(aId, aEventType, aCallback) {
+ document
+ .getElementById(aId)
+ .addEventListener(aEventType, aCallback.bind(gPrivacyPane));
+}
+
+var gPrivacyPane = {
+ init() {
+ this.updateManualMarkMode(Preferences.get("mail.spam.manualMark").value);
+ this.updateJunkLogButton(
+ Preferences.get("mail.spam.logging.enabled").value
+ );
+
+ this._initMasterPasswordUI();
+
+ if (AppConstants.MOZ_DATA_REPORTING) {
+ this.initDataCollection();
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ this.initSubmitCrashes();
+ }
+ this.initSubmitHealthReport();
+ setEventListener(
+ "submitHealthReportBox",
+ "command",
+ gPrivacyPane.updateSubmitHealthReport
+ );
+ setEventListener(
+ "telemetryDataDeletionLearnMore",
+ "command",
+ gPrivacyPane.showDataDeletion
+ );
+ }
+
+ this.readAcceptCookies();
+ let element = document.getElementById("acceptCookies");
+ Preferences.addSyncFromPrefListener(element, () =>
+ this.readAcceptCookies()
+ );
+ Preferences.addSyncToPrefListener(element, () => this.writeAcceptCookies());
+
+ element = document.getElementById("acceptThirdPartyMenu");
+ Preferences.addSyncFromPrefListener(element, () =>
+ this.readAcceptThirdPartyCookies()
+ );
+ Preferences.addSyncToPrefListener(element, () =>
+ this.writeAcceptThirdPartyCookies()
+ );
+
+ element = document.getElementById("enableOCSP");
+ Preferences.addSyncFromPrefListener(element, () => this.readEnableOCSP());
+ Preferences.addSyncToPrefListener(element, () => this.writeEnableOCSP());
+
+ this.initE2eeCheckboxes();
+ },
+
+ /**
+ * Reload the current message after a preference affecting the view
+ * has been changed.
+ */
+ reloadMessageInOpener() {
+ if (window.opener && typeof window.opener.ReloadMessage == "function") {
+ window.opener.ReloadMessage();
+ }
+ },
+
+ /**
+ * Reads the network.cookie.cookieBehavior preference value and
+ * enables/disables the rest of the cookie UI accordingly, returning true
+ * if cookies are enabled.
+ */
+ readAcceptCookies() {
+ let pref = Preferences.get("network.cookie.cookieBehavior");
+ let exceptionsButton = document.getElementById("cookieExceptions");
+ let acceptThirdPartyLabel = document.getElementById(
+ "acceptThirdPartyLabel"
+ );
+ let acceptThirdPartyMenu = document.getElementById("acceptThirdPartyMenu");
+ let showCookiesButton = document.getElementById("showCookiesButton");
+
+ // enable the rest of the UI for anything other than "disable all cookies"
+ let acceptCookies = pref.value != 2;
+ let cookieBehaviorLocked = Services.prefs.prefIsLocked(
+ "network.cookie.cookieBehavior"
+ );
+
+ exceptionsButton.disabled = cookieBehaviorLocked;
+ acceptThirdPartyLabel.disabled = acceptThirdPartyMenu.disabled =
+ !acceptCookies || cookieBehaviorLocked;
+ showCookiesButton.disabled = cookieBehaviorLocked;
+
+ return acceptCookies;
+ },
+
+ /**
+ * Enables/disables the "keep until" label and menulist in response to the
+ * "accept cookies" checkbox being checked or unchecked.
+ *
+ * @returns 0 if cookies are accepted, 2 if they are not;
+ * the value network.cookie.cookieBehavior should get
+ */
+ writeAcceptCookies() {
+ let accept = document.getElementById("acceptCookies");
+ let acceptThirdPartyMenu = document.getElementById("acceptThirdPartyMenu");
+ // if we're enabling cookies, automatically select 'accept third party always'
+ if (accept.checked) {
+ acceptThirdPartyMenu.selectedIndex = 0;
+ }
+
+ return accept.checked ? 0 : 2;
+ },
+
+ /**
+ * Displays fine-grained, per-site preferences for cookies.
+ */
+ showCookieExceptions() {
+ let bundle = document.getElementById("bundlePreferences");
+ let params = {
+ blockVisible: true,
+ sessionVisible: true,
+ allowVisible: true,
+ prefilledHost: "",
+ permissionType: "cookie",
+ windowTitle: bundle.getString("cookiepermissionstitle"),
+ introText: bundle.getString("cookiepermissionstext"),
+ };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ undefined,
+ params
+ );
+ },
+
+ /**
+ * Displays all the user's cookies in a dialog.
+ */
+ showCookies(aCategory) {
+ gSubDialog.open("chrome://messenger/content/preferences/cookies.xhtml");
+ },
+
+ /**
+ * Converts between network.cookie.cookieBehavior and the third-party cookie UI
+ */
+ readAcceptThirdPartyCookies() {
+ let pref = Preferences.get("network.cookie.cookieBehavior");
+ switch (pref.value) {
+ case 0:
+ return "always";
+ case 1:
+ return "never";
+ case 2:
+ return "never";
+ case 3:
+ return "visited";
+ default:
+ return undefined;
+ }
+ },
+
+ writeAcceptThirdPartyCookies() {
+ let accept = document.getElementById("acceptThirdPartyMenu").selectedItem;
+ switch (accept.value) {
+ case "always":
+ return 0;
+ case "visited":
+ return 3;
+ case "never":
+ return 1;
+ default:
+ return undefined;
+ }
+ },
+
+ /**
+ * Displays fine-grained, per-site preferences for remote content.
+ * We use the "image" type for that, but it can also be stylesheets or
+ * iframes.
+ */
+ showRemoteContentExceptions() {
+ let bundle = document.getElementById("bundlePreferences");
+ let params = {
+ blockVisible: true,
+ sessionVisible: false,
+ allowVisible: true,
+ prefilledHost: "",
+ permissionType: "image",
+ windowTitle: bundle.getString("imagepermissionstitle"),
+ introText: bundle.getString("imagepermissionstext"),
+ };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ undefined,
+ params
+ );
+ },
+ updateManualMarkMode(aEnableRadioGroup) {
+ document.getElementById("manualMarkMode").disabled = !aEnableRadioGroup;
+ },
+
+ updateJunkLogButton(aEnableButton) {
+ document.getElementById("openJunkLogButton").disabled = !aEnableButton;
+ },
+
+ openJunkLog() {
+ // The junk log dialog can't work as a sub-dialog, because that means
+ // loading it in a browser, and we can't load a chrome: page containing a
+ // file: page in a browser. Open it as a real dialog instead.
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://messenger/content/junkLog.xhtml"
+ );
+ },
+
+ resetTrainingData() {
+ // make sure the user really wants to do this
+ var bundle = document.getElementById("bundlePreferences");
+ var title = bundle.getString("confirmResetJunkTrainingTitle");
+ var text = bundle.getString("confirmResetJunkTrainingText");
+
+ // if the user says no, then just fall out
+ if (!Services.prompt.confirm(window, title, text)) {
+ return;
+ }
+
+ // otherwise go ahead and remove the training data
+ MailServices.junk.resetTrainingData();
+ },
+
+ /**
+ * Initializes primary password UI: the "use primary password" checkbox, selects
+ * the primary password button to show, and enables/disables it as necessary.
+ * The primary password is controlled by various bits of NSS functionality,
+ * so the UI for it can't be controlled by the normal preference bindings.
+ */
+ _initMasterPasswordUI() {
+ var noMP = !LoginHelper.isPrimaryPasswordSet();
+
+ var button = document.getElementById("changeMasterPassword");
+ button.disabled = noMP;
+
+ var checkbox = document.getElementById("useMasterPassword");
+ checkbox.checked = !noMP;
+ checkbox.disabled =
+ (noMP && !Services.policies.isAllowed("createMasterPassword")) ||
+ (!noMP && !Services.policies.isAllowed("removeMasterPassword"));
+ },
+
+ /**
+ * Enables/disables the primary password button depending on the state of the
+ * "use primary password" checkbox, and prompts for primary password removal
+ * if one is set.
+ */
+ async updateMasterPasswordButton() {
+ var checkbox = document.getElementById("useMasterPassword");
+ var button = document.getElementById("changeMasterPassword");
+ button.disabled = !checkbox.checked;
+
+ // unchecking the checkbox should try to immediately remove the master
+ // password, because it's impossible to non-destructively remove the master
+ // password used to encrypt all the passwords without providing it (by
+ // design), and it would be extremely odd to pop up that dialog when the
+ // user closes the prefwindow and saves his settings
+ if (!checkbox.checked) {
+ await this._removeMasterPassword();
+ } else {
+ await this.changeMasterPassword();
+ }
+
+ this._initMasterPasswordUI();
+ },
+
+ /**
+ * Displays the "remove primary password" dialog to allow the user to remove
+ * the current primary password. When the dialog is dismissed, primary password
+ * UI is automatically updated.
+ */
+ async _removeMasterPassword() {
+ var secmodDB = Cc["@mozilla.org/security/pkcs11moduledb;1"].getService(
+ Ci.nsIPKCS11ModuleDB
+ );
+ if (secmodDB.isFIPSEnabled) {
+ let title = document.getElementById("fips-title").textContent;
+ let desc = document.getElementById("fips-desc").textContent;
+ Services.prompt.alert(window, title, desc);
+ this._initMasterPasswordUI();
+ } else {
+ gSubDialog.open("chrome://mozapps/content/preferences/removemp.xhtml", {
+ closingCallback: this._initMasterPasswordUI.bind(this),
+ });
+ }
+ this._initMasterPasswordUI();
+ },
+
+ /**
+ * Displays a dialog in which the primary password may be changed.
+ */
+ async changeMasterPassword() {
+ // OS reauthenticate functionality is not available on Linux yet (bug 1527745)
+ if (
+ !LoginHelper.isPrimaryPasswordSet() &&
+ Services.prefs.getBoolPref("signon.management.page.os-auth.enabled") &&
+ AppConstants.platform != "linux"
+ ) {
+ let messageId =
+ "primary-password-os-auth-dialog-message-" + AppConstants.platform;
+ let [messageText, captionText] = await document.l10n.formatMessages([
+ {
+ id: messageId,
+ },
+ {
+ id: "master-password-os-auth-dialog-caption",
+ },
+ ]);
+ let win = Services.wm.getMostRecentWindow("");
+ let loggedIn = await OSKeyStore.ensureLoggedIn(
+ messageText.value,
+ captionText.value,
+ win,
+ false
+ );
+ if (!loggedIn.authenticated) {
+ return;
+ }
+ }
+
+ gSubDialog.open("chrome://mozapps/content/preferences/changemp.xhtml", {
+ closingCallback: this._initMasterPasswordUI.bind(this),
+ });
+ },
+
+ /**
+ * Shows the sites where the user has saved passwords and the associated
+ * login information.
+ */
+ showPasswords() {
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/passwordManager.xhtml"
+ );
+ },
+
+ updateDownloadedPhishingListState() {
+ document.getElementById("useDownloadedList").disabled =
+ !document.getElementById("enablePhishingDetector").checked;
+ },
+
+ /**
+ * Display the user's certificates and associated options.
+ */
+ showCertificates() {
+ gSubDialog.open("chrome://pippki/content/certManager.xhtml");
+ },
+
+ /**
+ * security.OCSP.enabled is an integer value for legacy reasons.
+ * A value of 1 means OCSP is enabled. Any other value means it is disabled.
+ */
+ readEnableOCSP() {
+ var preference = Preferences.get("security.OCSP.enabled");
+ // This is the case if the preference is the default value.
+ if (preference.value === undefined) {
+ return true;
+ }
+ return preference.value == 1;
+ },
+
+ /**
+ * See documentation for readEnableOCSP.
+ */
+ writeEnableOCSP() {
+ var checkbox = document.getElementById("enableOCSP");
+ return checkbox.checked ? 1 : 0;
+ },
+
+ /**
+ * Display a dialog from which the user can manage his security devices.
+ */
+ showSecurityDevices() {
+ gSubDialog.open("chrome://pippki/content/device_manager.xhtml");
+ },
+
+ /**
+ * Displays the learn more health report page when a user opts out of data collection.
+ */
+ showDataDeletion() {
+ let url =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "telemetry-clientid";
+ window.open(url, "_blank");
+ },
+
+ initDataCollection() {
+ this._setupLearnMoreLink(
+ "toolkit.datacollection.infoURL",
+ "dataCollectionPrivacyNotice"
+ );
+ },
+
+ initSubmitCrashes() {
+ this._setupLearnMoreLink(
+ "toolkit.crashreporter.infoURL",
+ "crashReporterLearnMore"
+ );
+ },
+
+ /**
+ * Set up or hide the Learn More links for various data collection options
+ */
+ _setupLearnMoreLink(pref, element) {
+ // set up the Learn More link with the correct URL
+ let url = Services.urlFormatter.formatURLPref(pref);
+ let el = document.getElementById(element);
+
+ if (url) {
+ el.setAttribute("href", url);
+ } else {
+ el.setAttribute("hidden", "true");
+ }
+ },
+
+ /**
+ * Initialize the health report service reference and checkbox.
+ */
+ initSubmitHealthReport() {
+ this._setupLearnMoreLink(
+ "datareporting.healthreport.infoURL",
+ "FHRLearnMore"
+ );
+
+ let checkbox = document.getElementById("submitHealthReportBox");
+
+ // Telemetry is only sending data if MOZ_TELEMETRY_REPORTING is defined.
+ // We still want to display the preferences panel if that's not the case, but
+ // we want it to be disabled and unchecked.
+ if (
+ Services.prefs.prefIsLocked(PREF_UPLOAD_ENABLED) ||
+ !AppConstants.MOZ_TELEMETRY_REPORTING
+ ) {
+ checkbox.setAttribute("disabled", "true");
+ return;
+ }
+
+ checkbox.checked =
+ Services.prefs.getBoolPref(PREF_UPLOAD_ENABLED) &&
+ AppConstants.MOZ_TELEMETRY_REPORTING;
+ },
+
+ /**
+ * Update the health report preference with state from checkbox.
+ */
+ updateSubmitHealthReport() {
+ let checkbox = document.getElementById("submitHealthReportBox");
+
+ Services.prefs.setBoolPref(PREF_UPLOAD_ENABLED, checkbox.checked);
+
+ // If allow telemetry is checked, hide the box saying you're no longer
+ // allowing it.
+ document.getElementById("telemetry-container").hidden = checkbox.checked;
+ },
+
+ initE2eeCheckboxes() {
+ let on = document.getElementById("emailE2eeAutoEnable");
+ let off = document.getElementById("emailE2eeAutoDisable");
+ let notify = document.getElementById("emailE2eeAutoDisableNotify");
+
+ on.checked = Preferences.get("mail.e2ee.auto_enable").value;
+ off.checked = Preferences.get("mail.e2ee.auto_disable").value;
+ notify.checked = Preferences.get("mail.e2ee.notify_on_auto_disable").value;
+
+ if (!on.checked) {
+ off.disabled = true;
+ notify.disabled = true;
+ } else {
+ off.disabled = false;
+ notify.disabled = !off.checked;
+ }
+ },
+
+ updateE2eeCheckboxes() {
+ let on = document.getElementById("emailE2eeAutoEnable");
+ let off = document.getElementById("emailE2eeAutoDisable");
+ let notify = document.getElementById("emailE2eeAutoDisableNotify");
+
+ if (!on.checked) {
+ off.disabled = true;
+ notify.disabled = true;
+ } else {
+ off.disabled = false;
+ notify.disabled = !off.checked;
+ }
+ },
+};
+
+Preferences.get("mailnews.message_display.disable_remote_image").on(
+ "change",
+ gPrivacyPane.reloadMessageInOpener
+);
+Preferences.get("mail.phishing.detection.enabled").on(
+ "change",
+ gPrivacyPane.reloadMessageInOpener
+);
diff --git a/comm/mail/components/preferences/receipts.js b/comm/mail/components/preferences/receipts.js
new file mode 100644
index 0000000000..748a65d127
--- /dev/null
+++ b/comm/mail/components/preferences/receipts.js
@@ -0,0 +1,38 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "mail.receipt.request_return_receipt_on", type: "bool" },
+ { id: "mail.incorporate.return_receipt", type: "int" },
+ { id: "mail.mdn.report.enabled", type: "bool" },
+ { id: "mail.mdn.report.not_in_to_cc", type: "int" },
+ { id: "mail.mdn.report.outside_domain", type: "int" },
+ { id: "mail.mdn.report.other", type: "int" },
+]);
+
+/**
+ * Enables/disables the labels and menulists depending whether
+ * sending of return receipts is enabled.
+ */
+function enableDisableAllowedReceipts() {
+ let enable = document.getElementById("receiptSend").value === "true";
+ enableElement(document.getElementById("notInToCcLabel"), enable);
+ enableElement(document.getElementById("notInToCcPref"), enable);
+ enableElement(document.getElementById("outsideDomainLabel"), enable);
+ enableElement(document.getElementById("outsideDomainPref"), enable);
+ enableElement(document.getElementById("otherCasesLabel"), enable);
+ enableElement(document.getElementById("otherCasesPref"), enable);
+}
+
+/**
+ * Set disabled state of aElement, unless its associated pref is locked.
+ */
+function enableElement(aElement, aEnable) {
+ let pref = aElement.getAttribute("preference");
+ let prefIsLocked = pref ? Preferences.get(pref).locked : false;
+ aElement.disabled = !aEnable || prefIsLocked;
+}
diff --git a/comm/mail/components/preferences/receipts.xhtml b/comm/mail/components/preferences/receipts.xhtml
new file mode 100644
index 0000000000..32f8a7237d
--- /dev/null
+++ b/comm/mail/components/preferences/receipts.xhtml
@@ -0,0 +1,120 @@
+<?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"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="receipts-dialog-window"
+ onload="enableDisableAllowedReceipts();"
+>
+ <dialog id="ReturnReceiptsDialog" dlgbuttons="accept,cancel">
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/receipts.js" />
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/receipts.ftl" />
+ </linkset>
+
+ <vbox id="returnReceiptSettings" align="start">
+ <checkbox
+ id="alwaysRequest"
+ data-l10n-id="return-receipt-checkbox-control"
+ preference="mail.receipt.request_return_receipt_on"
+ />
+ </vbox>
+
+ <separator class="thin" />
+ <separator class="groove" />
+ <separator class="thin" />
+
+ <label control="receiptFolder" data-l10n-id="receipt-arrive-label" />
+ <radiogroup
+ id="receiptFolder"
+ class="indent"
+ preference="mail.incorporate.return_receipt"
+ >
+ <radio value="0" data-l10n-id="receipt-leave-radio-control" />
+ <radio value="1" data-l10n-id="receipt-move-radio-control" />
+ </radiogroup>
+
+ <separator class="thin" />
+ <separator class="groove" />
+ <separator class="thin" />
+
+ <label control="receiptSend" data-l10n-id="receipt-request-label" />
+ <radiogroup
+ id="receiptSend"
+ class="indent"
+ preference="mail.mdn.report.enabled"
+ oncommand="enableDisableAllowedReceipts();"
+ >
+ <radio value="false" data-l10n-id="receipt-return-never-radio-control" />
+ <radio value="true" data-l10n-id="receipt-return-some-radio-control" />
+
+ <vbox class="indent">
+ <hbox align="center">
+ <hbox flex="1">
+ <label
+ id="notInToCcLabel"
+ data-l10n-id="receipt-not-to-cc-label"
+ control="notInToCcPref"
+ />
+ </hbox>
+ <menulist
+ id="notInToCcPref"
+ preference="mail.mdn.report.not_in_to_cc"
+ >
+ <menupopup>
+ <menuitem value="0" data-l10n-id="receipt-send-never-label" />
+ <menuitem value="1" data-l10n-id="receipt-send-always-label" />
+ <menuitem value="2" data-l10n-id="receipt-send-ask-label" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <hbox align="center">
+ <hbox flex="1">
+ <label
+ id="outsideDomainLabel"
+ data-l10n-id="sender-outside-domain-label"
+ control="outsideDomainPref"
+ />
+ </hbox>
+ <menulist
+ id="outsideDomainPref"
+ preference="mail.mdn.report.outside_domain"
+ >
+ <menupopup>
+ <menuitem value="0" data-l10n-id="receipt-send-never-label" />
+ <menuitem value="1" data-l10n-id="receipt-send-always-label" />
+ <menuitem value="2" data-l10n-id="receipt-send-ask-label" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <hbox align="center">
+ <hbox flex="1">
+ <label
+ id="otherCasesLabel"
+ data-l10n-id="other-cases-text-label"
+ control="otherCasesPref"
+ />
+ </hbox>
+ <menulist id="otherCasesPref" preference="mail.mdn.report.other">
+ <menupopup>
+ <menuitem value="0" data-l10n-id="receipt-send-never-label" />
+ <menuitem value="1" data-l10n-id="receipt-send-always-label" />
+ <menuitem value="2" data-l10n-id="receipt-send-ask-label" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ </vbox>
+ </radiogroup>
+ <separator />
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/searchResults.inc.xhtml b/comm/mail/components/preferences/searchResults.inc.xhtml
new file mode 100644
index 0000000000..9fa66a27c5
--- /dev/null
+++ b/comm/mail/components/preferences/searchResults.inc.xhtml
@@ -0,0 +1,24 @@
+<!-- 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/. -->
+
+ <hbox id="header-searchResults"
+ class="subcategory"
+ hidden="true"
+ data-hidden-from-search="true"
+ data-category="paneSearchResults">
+ <html:h1 data-l10n-id="search-results-header"/>
+ </hbox>
+ <groupbox id="no-results-message"
+ data-hidden-from-search="true"
+ data-category="paneSearchResults"
+ hidden="true">
+ <vbox class="no-results-container">
+ <label id="sorry-message" data-l10n-id="search-results-empty-message2">
+ <html:span data-l10n-name="query" id="sorry-message-query"/>
+ </label>
+ <label id="need-help" data-l10n-id="search-results-help-link">
+ <html:a class="text-link" data-l10n-name="url" target="_blank"></html:a>
+ </label>
+ </vbox>
+ </groupbox>
diff --git a/comm/mail/components/preferences/sync.inc.xhtml b/comm/mail/components/preferences/sync.inc.xhtml
new file mode 100644
index 0000000000..a101d4359b
--- /dev/null
+++ b/comm/mail/components/preferences/sync.inc.xhtml
@@ -0,0 +1,239 @@
+# 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/.
+<script src="chrome://messenger/content/preferences/sync.js"/>
+
+<html:template id="paneSync">
+ <html:div id="syncPaneCategory"
+ class="subcategory"
+ data-category="paneSync"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <h1 data-l10n-id="sync-pane-header"></h1>
+
+ <section id="noFxaAccount" hidden="hidden">
+ <div id="noFxaInfo">
+ <h2 id="noFxaCaption" data-l10n-id="sync-signedout-caption"></h2>
+ <p id="noFxaDescription"
+ data-l10n-id="sync-signedout-description">
+ </p>
+ <button id="noFxaSignIn"
+ class="primary"
+ type="button"
+ data-l10n-id="sync-signedout-account-signin-btn">
+ </button>
+ </div>
+ <img id="noFxaSyncIllustration"
+ src="chrome://messenger/skin/illustrations/sync-devices.svg"
+ alt=""/>
+ </section>
+
+ <section id="hasFxaAccount" hidden="hidden">
+ <!-- Account NOT Verified -->
+ <section id="fxaLoginUnverified"
+ class="sync-account-section"
+ hidden="hidden">
+ <div id="photoDisplay">
+ <img class="contact-photo" src="" alt="" />
+ </div>
+ <div id="fxaLoginUnverifiedInfo">
+ <span id="fxaAccountMailNotVerified"
+ data-l10n-id="sync-pane-email-not-verified"
+ data-l10n-args='{"userEmail": ""}'>
+ </span>
+ <div id="fxaUnverifiedButtonOptions">
+ <button id="fxaResendVerification"
+ class="place-self-end primary"
+ data-l10n-id="sync-pane-resend-verification"
+ type="button">
+ </button>
+ <button id="fxaUnverifiedRemoveAccount"
+ class="place-self-end"
+ data-l10n-id="sync-pane-remove-account"
+ type="button">
+ </button>
+ </div>
+ </div>
+ </section>
+
+ <!-- Server Rejected Credientials -->
+ <section id="fxaLoginRejected"
+ class="sync-account-section"
+ hidden="hidden">
+ <div id="photoDisplay">
+ <img class="contact-photo" src="" alt="" />
+ </div>
+ <div id="fxaLoginRejectedInfo">
+ <span id="fxaAccountLoginRejected"
+ data-l10n-id="sync-signedin-login-failure"
+ data-l10n-args='{"userEmail": ""}'>
+ </span>
+ <div id="fxaRejectedButtonOptions">
+ <button id="fxaRejectedSignIn"
+ class="place-self-end primary"
+ data-l10n-id="sync-pane-sign-in"
+ type="button">
+ </button>
+ <button id="fxaRejectedRemoveAccount"
+ class="place-self-end"
+ data-l10n-id="sync-pane-remove-account"
+ type="button">
+ </button>
+ </div>
+ </div>
+ </section>
+
+ <!-- Account Verified -->
+ <section id="fxaLoginVerified"
+ class="sync-account-section"
+ hidden="hidden">
+ <div id="photoInput">
+ <button type="button" id="photoButton"
+ class="plain-button"
+ data-l10n-id="sync-pane-edit-photo">
+ <img id="fxaAvatar" class="contact-photo" src="" alt="" />
+ <div id="photoOverlay"></div>
+ </button>
+ </div>
+ <div id="fxaAccountInfo">
+ <span id="fxaDisplayName"></span>
+ <span id="fxaEmailAddress"></span>
+ <a id="verifiedManage" href="#" data-l10n-id="sync-pane-manage-account"></a>
+ </div>
+ <button id="fxaAccountSignOut"
+ class="place-self-end"
+ data-l10n-id="sync-pane-sign-out"
+ type="button">
+ </button>
+ </section>
+
+ <fieldset id="fxaDeviceInfo" hidden="hidden">
+ <legend data-l10n-id="sync-pane-device-name-title"></legend>
+ <input id="fxaDeviceNameInput" type="text" readonly="readonly"/>
+ <!-- Hidden by default, shown if #fxaDeviceNameChangeDeviceName is pressed -->
+ <button id="fxaDeviceNameCancel"
+ class="place-self-end"
+ data-l10n-id="sync-pane-cancel"
+ type="button"
+ hidden="hidden">
+ </button>
+ <button id="fxaDeviceNameSave"
+ class="place-self-end"
+ data-l10n-id="sync-pane-save"
+ type="button"
+ hidden="hidden">
+ </button>
+ <!-- Disappear once pressed to allow the previous two buttons to take
+ - its place, reappears once cancel or save is pressed -->
+ <button id="fxaDeviceNameChangeDeviceName"
+ class="place-self-end needs-account-ready"
+ data-l10n-id="sync-pane-change-device-name"
+ type="button">
+ </button>
+ </fieldset>
+
+ <div id="syncConnected" class="sync-section" hidden="hidden">
+ <div id="showSyncedHeader">
+ <h2 class="sync-header"
+ data-l10n-id="sync-pane-show-synced-header-on">
+ </h2>
+ <button id="syncShowSyncedSyncNow"
+ class="place-self-end needs-account-ready"
+ data-l10n-id="sync-pane-sync-now"
+ type="button">
+ </button>
+ </div>
+
+ <div class="sync-panel">
+ <div id="showSyncedListHeader">
+ <h3 id="showSyncedListHeading" data-l10n-id="show-synced-list-heading"></h3>
+ <a id="enginesLearnMore" href="#" data-l10n-id="show-synced-learn-more"></a>
+ </div>
+
+ <ul id="showSyncedList" class="synced-list">
+ <!-- For when we get per-account controls: -->
+ <!-- <li id="showSyncAccount">
+ <span id="showSyncAccountLabel"
+ class="synced-item"
+ data-l10n-id="show-synced-item-account">
+ </span>
+ <div id="syncedAccounts">
+
+ <div class="synced-account">
+ <h4 class="synced-account-name">nemo@thunderbird.net</h4>
+ <ul class="synced-list">
+ <li class="synced-item synced-account-server-config"
+ data-l10n-id="synced-acount-item-server-config">
+ </li>
+ <li class="synced-item synced-account-filters"
+ data-l10n-id="synced-acount-item-filters">
+ </li>
+ <li class="synced-item synced-account-keys"
+ data-l10n-id="synced-acount-item-keys">
+ </li>
+ </ul>
+ </div>
+
+ <div class="synced-account">
+ <h4 class="synced-account-name">example@example.com</h4>
+ <ul class="synced-list">
+ <li class="synced-item synced-account-server-config"
+ data-l10n-id="synced-acount-item-server-config">
+ </li>
+ <li class="synced-item synced-account-filters"
+ data-l10n-id="synced-acount-item-filters">
+ </li>
+ <li class="synced-item synced-account-keys"
+ data-l10n-id="synced-acount-item-keys">
+ </li>
+ </ul>
+ </div>
+
+ </div>
+ </li> -->
+ <li id="showSyncAccount"
+ class="synced-item"
+ data-l10n-id="show-synced-item-account">
+ </li>
+ <li id="showSyncIdentity"
+ class="synced-item"
+ data-l10n-id="show-synced-item-identity">
+ </li>
+ <li id="showSyncAddress"
+ class="synced-item"
+ data-l10n-id="show-synced-item-address">
+ </li>
+ <li id="showSyncCalendar"
+ class="synced-item"
+ data-l10n-id="show-synced-item-calendar">
+ </li>
+ <li id="showSyncPasswords"
+ class="synced-item"
+ data-l10n-id="show-synced-item-passwords">
+ </li>
+ </ul>
+
+ <button id="syncChangeOptions"
+ class="place-self-end primary"
+ data-l10n-id="show-synced-change"
+ type="button">
+ </button>
+ </div>
+ </div>
+
+ <div id="syncDisconnected" class="sync-section" hidden="hidden">
+ <h2 class="sync-header"
+ data-l10n-id="sync-pane-show-synced-header-off">
+ </h2>
+
+ <div class="sync-panel">
+ <p data-l10n-id="sync-disconnected-text"></p>
+ <button id="syncSetup"
+ class="place-self-start needs-account-ready"
+ data-l10n-id="sync-disconnected-turn-on-sync"
+ type="button">
+ </button>
+ </div>
+ </div>
+ </section>
+ </html:div>
+</html:template>
diff --git a/comm/mail/components/preferences/sync.js b/comm/mail/components/preferences/sync.js
new file mode 100644
index 0000000000..d8336c6e23
--- /dev/null
+++ b/comm/mail/components/preferences/sync.js
@@ -0,0 +1,377 @@
+/* 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 preferences.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ UIState: "resource://services-sync/UIState.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+});
+
+var { FxAccounts, getFxAccountsSingleton } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+var fxAccounts = getFxAccountsSingleton();
+
+var gSyncPane = {
+ init() {
+ this._setupEventListeners();
+ this.setupEnginesUI();
+
+ Weave.Svc.Obs.add(UIState.ON_UPDATE, this.updateWeavePrefs, this);
+
+ window.addEventListener("unload", () => {
+ Weave.Svc.Obs.remove(UIState.ON_UPDATE, this.updateWeavePrefs, this);
+ });
+
+ let cachedComputerName = Services.prefs.getStringPref(
+ "identity.fxaccounts.account.device.name",
+ ""
+ );
+ if (cachedComputerName) {
+ this._populateComputerName(cachedComputerName);
+ }
+
+ this.updateWeavePrefs();
+ },
+
+ /**
+ * Update the UI based on the current state.
+ */
+ updateWeavePrefs() {
+ let state = UIState.get();
+
+ let noFxaAccount = document.getElementById("noFxaAccount");
+ let hasFxaAccount = document.getElementById("hasFxaAccount");
+ if (state.status == UIState.STATUS_NOT_CONFIGURED) {
+ noFxaAccount.hidden = false;
+ hasFxaAccount.hidden = true;
+ return;
+ }
+ noFxaAccount.hidden = true;
+ hasFxaAccount.hidden = false;
+
+ let syncReady = false; // Is sync able to actually sync?
+ let fxaLoginUnverified = document.getElementById("fxaLoginUnverified");
+ let fxaLoginRejected = document.getElementById("fxaLoginRejected");
+ let fxaLoginVerified = document.getElementById("fxaLoginVerified");
+ if (state.status == UIState.STATUS_LOGIN_FAILED) {
+ fxaLoginUnverified.hidden = true;
+ fxaLoginRejected.hidden = false;
+ fxaLoginVerified.hidden = true;
+ } else if (state.status == UIState.STATUS_NOT_VERIFIED) {
+ fxaLoginUnverified.hidden = false;
+ fxaLoginRejected.hidden = true;
+ fxaLoginVerified.hidden = true;
+ } else {
+ fxaLoginUnverified.hidden = true;
+ fxaLoginRejected.hidden = true;
+ fxaLoginVerified.hidden = false;
+ syncReady = true;
+ }
+
+ this._populateComputerName(Weave.Service.clientsEngine.localName);
+ for (let elt of document.querySelectorAll(".needs-account-ready")) {
+ elt.disabled = !syncReady;
+ }
+
+ let syncConnected = document.getElementById("syncConnected");
+ let syncDisconnected = document.getElementById("syncDisconnected");
+ syncConnected.hidden = !syncReady || !state.syncEnabled;
+ syncDisconnected.hidden = !syncReady || state.syncEnabled;
+
+ document.l10n.setAttributes(
+ document.getElementById("fxaAccountMailNotVerified"),
+ "sync-pane-email-not-verified",
+ { userEmail: state.email }
+ );
+ document.l10n.setAttributes(
+ document.getElementById("fxaAccountLoginRejected"),
+ "sync-signedin-login-failure",
+ { userEmail: state.email }
+ );
+
+ document.getElementById("fxaAvatar").src =
+ state.avatarURL && !state.avatarIsDefault ? state.avatarURL : "";
+ document.getElementById("fxaDisplayName").textContent = state.displayName;
+ document.getElementById("fxaEmailAddress").textContent = state.email;
+
+ this._updateSyncNow(state.syncing);
+ },
+
+ _toggleComputerNameControls(editMode) {
+ let textbox = document.getElementById("fxaDeviceNameInput");
+ textbox.readOnly = !editMode;
+ document.getElementById("fxaDeviceNameChangeDeviceName").hidden = editMode;
+ document.getElementById("fxaDeviceNameCancel").hidden = !editMode;
+ document.getElementById("fxaDeviceNameSave").hidden = !editMode;
+ },
+
+ _focusComputerNameTextbox() {
+ let textbox = document.getElementById("fxaDeviceNameInput");
+ let valLength = textbox.value.length;
+ textbox.focus();
+ textbox.setSelectionRange(valLength, valLength);
+ },
+
+ _blurComputerNameTextbox() {
+ document.getElementById("fxaDeviceNameInput").blur();
+ },
+
+ _focusAfterComputerNameTextbox() {
+ // Focus the most appropriate element that's *not* the "computer name" box.
+ Services.focus.moveFocus(
+ window,
+ document.getElementById("fxaDeviceNameInput"),
+ Services.focus.MOVEFOCUS_FORWARD,
+ 0
+ );
+ },
+
+ _updateComputerNameValue(save) {
+ if (save) {
+ let textbox = document.getElementById("fxaDeviceNameInput");
+ Weave.Service.clientsEngine.localName = textbox.value;
+ }
+ this._populateComputerName(Weave.Service.clientsEngine.localName);
+ },
+
+ _setupEventListeners() {
+ function setEventListener(id, eventType, callback) {
+ document
+ .getElementById(id)
+ .addEventListener(eventType, callback.bind(gSyncPane));
+ }
+
+ setEventListener("noFxaSignIn", "click", function () {
+ window.browsingContext.topChromeWindow.gSync.initFxA();
+ return false;
+ });
+ setEventListener(
+ "fxaResendVerification",
+ "click",
+ gSyncPane.verifyFirefoxAccount
+ );
+ setEventListener("fxaUnverifiedRemoveAccount", "click", function () {
+ /* No warning as account can't have previously synced. */
+ gSyncPane.unlinkFirefoxAccount(false);
+ });
+ setEventListener("fxaRejectedSignIn", "click", gSyncPane.reSignIn);
+ setEventListener("fxaRejectedRemoveAccount", "click", function () {
+ gSyncPane.unlinkFirefoxAccount(true);
+ });
+ setEventListener("photoButton", "click", function (event) {
+ window.browsingContext.topChromeWindow.gSync.openFxAAvatarPage(
+ "preferences"
+ );
+ });
+ setEventListener("verifiedManage", "click", function (event) {
+ window.browsingContext.topChromeWindow.gSync.openFxAManagePage(
+ "preferences"
+ );
+ event.preventDefault();
+ // Stop attempts to open this link in an external browser.
+ event.stopPropagation();
+ });
+ setEventListener("fxaAccountSignOut", "click", function () {
+ gSyncPane.unlinkFirefoxAccount(true);
+ });
+ setEventListener("fxaDeviceNameCancel", "click", function () {
+ // We explicitly blur the textbox because of bug 75324, then after
+ // changing the state of the buttons, force focus to whatever the focus
+ // manager thinks should be next (which on the mac, depends on an OSX
+ // keyboard access preference)
+ this._blurComputerNameTextbox();
+ this._toggleComputerNameControls(false);
+ this._updateComputerNameValue(false);
+ this._focusAfterComputerNameTextbox();
+ });
+ setEventListener("fxaDeviceNameSave", "click", function () {
+ // Work around bug 75324 - see above.
+ this._blurComputerNameTextbox();
+ this._toggleComputerNameControls(false);
+ this._updateComputerNameValue(true);
+ this._focusAfterComputerNameTextbox();
+ });
+ setEventListener("fxaDeviceNameChangeDeviceName", "click", function () {
+ this._toggleComputerNameControls(true);
+ this._focusComputerNameTextbox();
+ });
+ setEventListener("syncShowSyncedSyncNow", "click", function () {
+ // syncing can take a little time to send the "started" notification, so
+ // pretend we already got it.
+ this._updateSyncNow(true);
+ Weave.Service.sync({ why: "aboutprefs" });
+ });
+ setEventListener("enginesLearnMore", "click", function (event) {
+ // TODO: A real page.
+ window.browsingContext.topChromeWindow.openContentTab(
+ "https://example.org/?page=learnMore"
+ );
+ event.preventDefault();
+ // Stop attempts to open this link in an external browser.
+ event.stopPropagation();
+ });
+ setEventListener("syncChangeOptions", "click", function () {
+ gSyncPane._chooseWhatToSync(true);
+ });
+ setEventListener("syncSetup", "click", function () {
+ gSyncPane._chooseWhatToSync(false);
+ });
+ },
+
+ async _chooseWhatToSync(isAlreadySyncing) {
+ // Assuming another device is syncing and we're not, we update the engines
+ // selection so the correct checkboxes are pre-filled.
+ if (!isAlreadySyncing) {
+ try {
+ await Weave.Service.updateLocalEnginesState();
+ } catch (err) {
+ console.error("Error updating the local engines state", err);
+ }
+ }
+ let params = {};
+ if (isAlreadySyncing) {
+ // If we are already syncing then we also offer to disconnect.
+ params.disconnectFun = () => this.disconnectSync();
+ }
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/syncDialog.xhtml",
+ {
+ features: "resizable=no",
+ closingCallback: event => {
+ if (!isAlreadySyncing && event.detail.button == "accept") {
+ // We weren't syncing but the user has accepted the dialog - so we
+ // want to start!
+ fxAccounts.telemetry
+ .recordConnection(["sync"], "ui")
+ .then(() => {
+ return Weave.Service.configure();
+ })
+ .catch(err => {
+ console.error("Failed to enable sync", err);
+ });
+ }
+ },
+ },
+ params
+ );
+ },
+
+ _updateSyncNow(syncing) {
+ let button = document.getElementById("syncShowSyncedSyncNow");
+ if (syncing) {
+ document.l10n.setAttributes(button, "sync-panel-sync-now-syncing");
+ button.disabled = true;
+ } else {
+ document.l10n.setAttributes(button, "sync-pane-sync-now");
+ button.disabled = false;
+ }
+ },
+
+ /**
+ * If connecting to Firefox Accounts failed, try again.
+ */
+ async reSignIn() {
+ // There's a bit of an edge-case here - we might be forcing reauth when we've
+ // lost the FxA account data - in which case we'll not get a URL as the re-auth
+ // URL embeds account info and the server endpoint complains if we don't
+ // supply it - so we just use the regular "sign in" URL in that case.
+ if (!(await FxAccounts.canConnectAccount())) {
+ return;
+ }
+
+ const url =
+ (await FxAccounts.config.promiseForceSigninURI("preferences")) ||
+ (await FxAccounts.config.promiseConnectAccountURI("preferences"));
+ window.browsingContext.topChromeWindow.openContentTab(url);
+ },
+
+ /**
+ * Send a confirmation email to the account's email address.
+ */
+ verifyFirefoxAccount() {
+ let onError = async () => {
+ let [title, body] = await document.l10n.formatValues([
+ "fxa-verification-not-sent-title",
+ "fxa-verification-not-sent-body",
+ ]);
+ new Notification(title, { body });
+ };
+
+ let onSuccess = async data => {
+ if (data) {
+ let [title, body] = await document.l10n.formatValues([
+ "fxa-verification-sent-title",
+ { id: "fxa-verification-sent-body", args: { userEmail: data.email } },
+ ]);
+ new Notification(title, { body });
+ } else {
+ onError();
+ }
+ };
+
+ fxAccounts
+ .resendVerificationEmail()
+ .then(() => fxAccounts.getSignedInUser(), onError)
+ .then(onSuccess, onError);
+ },
+
+ /**
+ * Disconnect the account, including everything linked.
+ *
+ * @param {boolean} confirm - If true, asks the user if they're sure.
+ */
+ unlinkFirefoxAccount(confirm) {
+ window.browsingContext.topChromeWindow.gSync.disconnect({ confirm });
+ },
+
+ /**
+ * Disconnect sync, leaving the FxA account connected.
+ */
+ disconnectSync() {
+ return window.browsingContext.topChromeWindow.gSync.disconnect({
+ confirm: true,
+ disconnectAccount: false,
+ });
+ },
+
+ _populateComputerName(value) {
+ let textbox = document.getElementById("fxaDeviceNameInput");
+ if (!textbox.hasAttribute("placeholder")) {
+ textbox.setAttribute(
+ "placeholder",
+ fxAccounts.device.getDefaultLocalName()
+ );
+ }
+ textbox.value = value;
+ },
+
+ /**
+ * Arranges to dynamically show or hide sync engine name elements based on
+ * the preferences used for the engines.
+ */
+ setupEnginesUI() {
+ let observe = (element, prefName) => {
+ element.hidden = !Services.prefs.getBoolPref(prefName, false);
+ };
+
+ let engineItems = {
+ showSyncAccount: "services.sync.engine.accounts",
+ showSyncIdentity: "services.sync.engine.identities",
+ showSyncAddress: "services.sync.engine.addressbooks",
+ showSyncCalendar: "services.sync.engine.calendars",
+ showSyncPasswords: "services.sync.engine.passwords",
+ };
+
+ for (let [id, prefName] of Object.entries(engineItems)) {
+ let obs = observe.bind(null, document.getElementById(id), prefName);
+ obs();
+ Services.prefs.addObserver(prefName, obs);
+ window.addEventListener("unload", () => {
+ Services.prefs.removeObserver(prefName, obs);
+ });
+ }
+ },
+};
diff --git a/comm/mail/components/preferences/syncDialog.js b/comm/mail/components/preferences/syncDialog.js
new file mode 100644
index 0000000000..6920a8aa59
--- /dev/null
+++ b/comm/mail/components/preferences/syncDialog.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/. */
+
+const engineItems = {
+ configSyncAccount: "services.sync.engine.accounts",
+ configSyncAddress: "services.sync.engine.addressbooks",
+ configSyncCalendar: "services.sync.engine.calendars",
+ configSyncIdentity: "services.sync.engine.identities",
+ configSyncPasswords: "services.sync.engine.passwords",
+};
+
+window.addEventListener("load", function () {
+ for (let [id, prefName] of Object.entries(engineItems)) {
+ let element = document.getElementById(id);
+ element.checked = Services.prefs.getBoolPref(prefName, false);
+ }
+
+ let options = window.arguments[0];
+ if (options.disconnectFun) {
+ window.addEventListener("dialogextra2", function () {
+ options.disconnectFun().then(disconnected => {
+ if (disconnected) {
+ window.close();
+ }
+ });
+ });
+ } else {
+ document.querySelector("dialog").getButton("extra2").hidden = true;
+ }
+});
+
+window.addEventListener("dialogaccept", function () {
+ for (let [id, prefName] of Object.entries(engineItems)) {
+ let element = document.getElementById(id);
+ Services.prefs.setBoolPref(prefName, element.checked);
+ }
+});
diff --git a/comm/mail/components/preferences/syncDialog.xhtml b/comm/mail/components/preferences/syncDialog.xhtml
new file mode 100644
index 0000000000..ce035245ea
--- /dev/null
+++ b/comm/mail/components/preferences/syncDialog.xhtml
@@ -0,0 +1,210 @@
+<?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/. -->
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+>
+ <head>
+ <title data-l10n-id="config-sync-dailog-title"></title>
+
+ <link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/preferences/preferences.css"
+ />
+
+ <link rel="localization" href="messenger/preferences/preferences.ftl" />
+ <link rel="localization" href="messenger/preferences/sync-dialog.ftl" />
+
+ <script src="chrome://messenger/content/preferences/syncDialog.js"></script>
+ </head>
+ <body>
+ <xul:dialog
+ id="configSyncDialog"
+ buttons="accept,cancel,extra2"
+ style="min-width: 49em"
+ data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept, buttonlabelextra2, buttonaccesskeyextra2"
+ data-l10n-id="sync-dialog"
+ >
+ <div id="configSyncDialogContent">
+ <ul id="configSyncList" class="config-list">
+ <li id="configAccountsContainer">
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncAccount"
+ name="configSynced"
+ />
+ <label
+ for="configSyncAccount"
+ id="configSyncAccountLabel"
+ data-l10n-id="show-synced-item-account"
+ >
+ </label>
+ </div>
+ <!-- For when we get per-account controls: -->
+ <!-- <div id="configAccounts">
+
+ <div class="synced-account">
+ <div class="config-item synced-account-name">
+ <input type="checkbox"
+ id="configSyncAccount_1"
+ name="configSyncedAccounts"/>
+ <label for="configSyncAccount_1">
+ nemo@thunderbird.net
+ </label>
+ </div>
+ <ul class="config-list">
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncServer_1"
+ name="configSyncedAccount_1"/>
+ <label for="configSyncServer_1"
+ class="synced-account-server-config"
+ data-l10n-id="synced-acount-item-server-config">
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncFilters_1"
+ name="configSyncedAccount_1"/>
+ <label for="configSyncFilters_1"
+ class="synced-account-filters"
+ data-l10n-id="synced-acount-item-filters">
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncKeys_1"
+ name="configSyncedAccount_1"/>
+ <label for="configSyncKeys_1"
+ class="synced-account-keys"
+ data-l10n-id="synced-acount-item-keys">
+ </label>
+ </div>
+ </li>
+ </ul>
+ </div>
+
+ <div class="synced-account">
+ <div class="config-item synced-account-name">
+ <input type="checkbox"
+ id="configSyncAccount_2"
+ name="configSyncedAccounts"/>
+ <label for="configSyncAccount_2">
+ example@example.com
+ </label>
+ </div>
+ <ul class="config-list">
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncServer_2"
+ name="configSyncedAccount_2"/>
+ <label for="configSyncServer_2"
+ class="synced-account-server-config"
+ data-l10n-id="synced-acount-item-server-config">
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncFilters_2"
+ name="configSyncedAccount_2"/>
+ <label for="configSyncFilters_2"
+ class="synced-account-filters"
+ data-l10n-id="synced-acount-item-filters">
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncKeys_2"
+ name="configSyncedAccount_2"/>
+ <label for="configSyncKeys_2"
+ class="synced-account-keys"
+ data-l10n-id="synced-acount-item-keys">
+ </label>
+ </div>
+ </li>
+ </ul>
+ </div>
+
+ </div> -->
+ </li>
+ <li>
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncIdentity"
+ name="configSynced"
+ />
+ <label
+ id="configSyncIdentityLabel"
+ for="configSyncIdentity"
+ data-l10n-id="show-synced-item-identity"
+ >
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncAddress"
+ name="configSynced"
+ />
+ <label
+ id="configSyncAddressLabel"
+ for="configSyncAddress"
+ data-l10n-id="show-synced-item-address"
+ >
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncCalendar"
+ name="configSynced"
+ />
+ <label
+ id="configSyncCalendarLabel"
+ for="configSyncCalendar"
+ data-l10n-id="show-synced-item-calendar"
+ >
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncPasswords"
+ name="configSynced"
+ />
+ <label
+ id="configSyncPasswordsLabel"
+ for="configSyncPasswords"
+ data-l10n-id="show-synced-item-passwords"
+ >
+ </label>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/components/preferences/tagDialog.xhtml b/comm/mail/components/preferences/tagDialog.xhtml
new file mode 100644
index 0000000000..32dd72268d
--- /dev/null
+++ b/comm/mail/components/preferences/tagDialog.xhtml
@@ -0,0 +1,26 @@
+<?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"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="tag-dialog-window"
+ style="min-width: 25em;"
+ onload="onLoad();">
+<dialog>
+
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://messenger/content/newTagDialog.js"/>
+#include ../../base/content/tagDialog.inc.xhtml
+</dialog>
+</window>
diff --git a/comm/mail/components/preferences/test/browser/browser.ini b/comm/mail/components/preferences/test/browser/browser.ini
new file mode 100644
index 0000000000..44b7d97a31
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser.ini
@@ -0,0 +1,20 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_chat.js]
+[browser_cloudfile.js]
+support-files = files/icon.svg files/management.html
+[browser_compose.js]
+[browser_general.js]
+[browser_openPreferences.js]
+[browser_privacy.js]
+[browser_sync.js]
+skip-if = !nightly_build
+support-files = files/avatar.png
diff --git a/comm/mail/components/preferences/test/browser/browser_chat.js b/comm/mail/components/preferences/test/browser/browser_chat.js
new file mode 100644
index 0000000000..009f2a9211
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_chat.js
@@ -0,0 +1,74 @@
+/* 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 () => {
+ await testCheckboxes(
+ "paneChat",
+ "chatPaneCategory",
+ {
+ checkboxID: "reportIdle",
+ pref: "messenger.status.reportIdle",
+ enabledElements: ["#autoAway", "#timeBeforeAway"],
+ },
+ {
+ checkboxID: "sendTyping",
+ pref: "purple.conversations.im.send_typing",
+ },
+ {
+ checkboxID: "desktopChatNotifications",
+ pref: "mail.chat.show_desktop_notifications",
+ },
+ {
+ checkboxID: "getAttention",
+ pref: "messenger.options.getAttentionOnNewMessages",
+ },
+ {
+ checkboxID: "chatNotification",
+ pref: "mail.chat.play_sound",
+ enabledElements: ["#chatSoundType radio"],
+ }
+ );
+
+ Services.prefs.setBoolPref("messenger.status.reportIdle", true);
+ await testCheckboxes("paneChat", "chatPaneCategory", {
+ checkboxID: "autoAway",
+ pref: "messenger.status.awayWhenIdle",
+ enabledElements: ["#defaultIdleAwayMessage"],
+ });
+
+ Services.prefs.setBoolPref("mail.chat.play_sound", true);
+ await testRadioButtons("paneChat", "chatPaneCategory", {
+ pref: "mail.chat.play_sound.type",
+ states: [
+ {
+ id: "chatSoundSystemSound",
+ prefValue: 0,
+ },
+ {
+ id: "chatSoundCustom",
+ prefValue: 1,
+ enabledElements: ["#chatSoundUrlLocation", "#browseForChatSound"],
+ },
+ ],
+ });
+});
+
+add_task(async function testMessageStylePreview() {
+ await openNewPrefsTab("paneChat", "chatPaneCategory");
+ const conversationLoad = TestUtils.topicObserved("conversation-loaded");
+ const [subject] = await conversationLoad;
+ do {
+ await BrowserTestUtils.waitForEvent(subject, "MessagesDisplayed");
+ } while (subject.getPendingMessagesCount() > 0);
+ const messageParent = subject.contentChatNode;
+ let message = messageParent.firstElementChild;
+ const messages = new Set();
+ while (message) {
+ ok(message._originalMsg);
+ messages.add(message._originalMsg);
+ message = message.nextElementSibling;
+ }
+ is(messages.size, 3, "All 3 messages displayed");
+ await closePrefsTab();
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_cloudfile.js b/comm/mail/components/preferences/test/browser/browser_cloudfile.js
new file mode 100644
index 0000000000..9f395f9ab8
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_cloudfile.js
@@ -0,0 +1,796 @@
+/* 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/. */
+
+/* eslint-env webextensions */
+
+let { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+function ManagementScript() {
+ browser.test.onMessage.addListener((message, assertMessage, browserStyle) => {
+ if (message !== "check-style") {
+ return;
+ }
+ function verifyButton(buttonElement, expected) {
+ let buttonStyle = window.getComputedStyle(buttonElement);
+ let buttonBackgroundColor = buttonStyle.backgroundColor;
+ if (browserStyle && expected.hasBrowserStyleClass) {
+ browser.test.assertEq(
+ "rgb(9, 150, 248)",
+ buttonBackgroundColor,
+ assertMessage
+ );
+ } else {
+ browser.test.assertTrue(
+ buttonBackgroundColor !== "rgb(9, 150, 248)",
+ assertMessage
+ );
+ }
+ }
+
+ function verifyCheckboxOrRadio(element, expected) {
+ let style = window.getComputedStyle(element);
+ let styledBackground = element.checked
+ ? "rgb(9, 150, 248)"
+ : "rgb(255, 255, 255)";
+ if (browserStyle && expected.hasBrowserStyleClass) {
+ browser.test.assertEq(
+ styledBackground,
+ style.backgroundColor,
+ assertMessage
+ );
+ } else {
+ browser.test.assertTrue(
+ style.backgroundColor != styledBackground,
+ assertMessage
+ );
+ }
+ }
+
+ let normalButton = document.getElementById("normalButton");
+ let browserStyleButton = document.getElementById("browserStyleButton");
+ verifyButton(normalButton, { hasBrowserStyleClass: false });
+ verifyButton(browserStyleButton, { hasBrowserStyleClass: true });
+
+ let normalCheckbox1 = document.getElementById("normalCheckbox1");
+ let normalCheckbox2 = document.getElementById("normalCheckbox2");
+ let browserStyleCheckbox = document.getElementById("browserStyleCheckbox");
+ verifyCheckboxOrRadio(normalCheckbox1, { hasBrowserStyleClass: false });
+ verifyCheckboxOrRadio(normalCheckbox2, { hasBrowserStyleClass: false });
+ verifyCheckboxOrRadio(browserStyleCheckbox, {
+ hasBrowserStyleClass: true,
+ });
+
+ let normalRadio1 = document.getElementById("normalRadio1");
+ let normalRadio2 = document.getElementById("normalRadio2");
+ let browserStyleRadio = document.getElementById("browserStyleRadio");
+ verifyCheckboxOrRadio(normalRadio1, { hasBrowserStyleClass: false });
+ verifyCheckboxOrRadio(normalRadio2, { hasBrowserStyleClass: false });
+ verifyCheckboxOrRadio(browserStyleRadio, { hasBrowserStyleClass: true });
+
+ browser.test.notifyPass("management-ui-browser_style");
+ });
+ browser.test.sendMessage("management-ui-ready");
+}
+
+let extension;
+async function startExtension(browser_style) {
+ let cloud_file = {
+ name: "Mochitest",
+ management_url: "management.html",
+ };
+
+ switch (browser_style) {
+ case "true":
+ cloud_file.browser_style = true;
+ break;
+ case "false":
+ cloud_file.browser_style = false;
+ break;
+ }
+
+ extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ browser.test.onMessage.addListener(async message => {
+ if (message != "set-configured") {
+ return;
+ }
+ let accounts = await browser.cloudFile.getAllAccounts();
+ for (let account of accounts) {
+ await browser.cloudFile.updateAccount(account.id, {
+ configured: true,
+ });
+ }
+ browser.test.sendMessage("ready");
+ });
+ },
+ files: {
+ "management.html": `<html>
+ <body>
+ <a id="a" href="https://www.example.com/">Click me!</a>
+ <button id="normalButton" name="button" class="default">Default</button>
+ <button id="browserStyleButton" name="button" class="browser-style default">Default</button>
+
+ <input id="normalCheckbox1" type="checkbox"/>
+ <input id="normalCheckbox2" type="checkbox"/><label>Checkbox</label>
+ <div class="browser-style">
+ <input id="browserStyleCheckbox" type="checkbox"><label for="browserStyleCheckbox">Checkbox</label>
+ </div>
+
+ <input id="normalRadio1" type="radio"/>
+ <input id="normalRadio2" type="radio"/><label>Radio</label>
+ <div class="browser-style">
+ <input id="browserStyleRadio" checked="" type="radio"><label for="browserStyleRadio">Radio</label>
+ </div>
+ </body>
+ <script src="management.js" type="text/javascript"></script>
+ </html>`,
+ "management.js": ManagementScript,
+ },
+ manifest: {
+ cloud_file,
+ applications: { gecko: { id: "cloudfile@mochitest" } },
+ },
+ });
+
+ info("Starting extension");
+ await extension.startup();
+
+ if (accountIsConfigured) {
+ extension.sendMessage("set-configured");
+ await extension.awaitMessage("ready");
+ }
+}
+
+add_task(async () => {
+ // Register a fake provider representing a built-in provider. We don't
+ // currently ship any built-in providers, but if we did, we should check
+ // if they are present before doing this. Built-in providers can be
+ // problematic for artifact builds.
+ cloudFileAccounts.registerProvider("Fake-Test", {
+ displayName: "XYZ Fake",
+ type: "ext-fake@extensions.thunderbird.net",
+ });
+ registerCleanupFunction(() => {
+ cloudFileAccounts.unregisterProvider("Fake-Test");
+ });
+});
+
+let accountIsConfigured = false;
+
+// Mock the prompt service. We're going to be asked if we're sure
+// we want to remove an account, so let's say yes.
+
+/** @implements {nsIPromptService} */
+let mockPromptService = {
+ confirmCount: 0,
+ confirm() {
+ this.confirmCount++;
+ return true;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+};
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(aProtocolScheme) {},
+ getApplicationDescription(aScheme) {},
+ getProtocolHandlerInfo(aProtocolScheme) {},
+ getProtocolHandlerInfoFromOS(aProtocolScheme, aFound) {},
+ isExposedProtocol(aProtocolScheme) {},
+ loadURI(aURI, aWindowContext) {
+ this._loadedURLs.push(aURI.spec);
+ },
+ setProtocolHandlerDefaults(aHandlerInfo, aOSHandlerExists) {},
+ urlLoaded(aURL) {
+ return this._loadedURLs.includes(aURL);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+let originalPromptService = Services.prompt;
+Services.prompt = mockPromptService;
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ Services.prompt = originalPromptService;
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+add_task(async function addRemoveAccounts() {
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Load the preferences tab.
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ "paneCompose",
+ "compositionAttachmentsCategory"
+ );
+
+ // Check everything is as it should be.
+
+ let accountList = prefsDocument.getElementById("cloudFileView");
+ is(accountList.itemCount, 0);
+
+ let buttonList = prefsDocument.getElementById("addCloudFileAccountButtons");
+ ok(!buttonList.hidden);
+ is(buttonList.childElementCount, 1);
+ is(
+ buttonList.children[0].getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+
+ let menuButton = prefsDocument.getElementById("addCloudFileAccount");
+ ok(menuButton.hidden);
+ is(menuButton.itemCount, 1);
+ is(
+ menuButton.getItemAtIndex(0).getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+
+ let removeButton = prefsDocument.getElementById("removeCloudFileAccount");
+ ok(removeButton.disabled);
+
+ let cloudFileDefaultPanel = prefsDocument.getElementById(
+ "cloudFileDefaultPanel"
+ );
+ ok(!cloudFileDefaultPanel.hidden);
+
+ let browserWrapper = prefsDocument.getElementById("cloudFileSettingsWrapper");
+ is(browserWrapper.childElementCount, 0);
+
+ // Register our test provider.
+
+ await startExtension();
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(buttonList.childElementCount, 2);
+ is(
+ buttonList.children[0].getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(buttonList.children[1].getAttribute("value"), "ext-cloudfile@mochitest");
+ is(
+ buttonList.children[1].style.listStyleImage,
+ `url("chrome://messenger/content/extension.svg")`
+ );
+
+ is(menuButton.itemCount, 2);
+ is(
+ menuButton.getItemAtIndex(0).getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(
+ menuButton.getItemAtIndex(1).getAttribute("value"),
+ "ext-cloudfile@mochitest"
+ );
+ is(
+ menuButton.getItemAtIndex(1).getAttribute("image"),
+ "chrome://messenger/content/extension.svg"
+ );
+
+ // Create a new account.
+
+ EventUtils.synthesizeMouseAtCenter(
+ buttonList.children[1],
+ { clickCount: 1 },
+ prefsWindow
+ );
+ is(cloudFileAccounts.accounts.length, 1);
+ is(cloudFileAccounts.configuredAccounts.length, 0);
+
+ let account = cloudFileAccounts.accounts[0];
+ let accountKey = account.accountKey;
+ is(cloudFileAccounts.accounts[0].type, "ext-cloudfile@mochitest");
+
+ // Check prefs were updated.
+
+ is(
+ Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ ),
+ "Mochitest"
+ );
+ is(
+ Services.prefs.getCharPref(`mail.cloud_files.accounts.${accountKey}.type`),
+ "ext-cloudfile@mochitest"
+ );
+
+ // Check UI was updated.
+
+ is(accountList.itemCount, 1);
+ is(accountList.selectedIndex, 0);
+ ok(!removeButton.disabled);
+
+ let accountListItem = accountList.selectedItem;
+ is(accountListItem.getAttribute("value"), accountKey);
+ is(
+ accountListItem.querySelector(".typeIcon:not(.configuredWarning)").src,
+ "chrome://messenger/content/extension.svg"
+ );
+ is(accountListItem.querySelector("label").value, "Mochitest");
+ is(accountListItem.querySelector(".configuredWarning").hidden, false);
+
+ ok(cloudFileDefaultPanel.hidden);
+ is(browserWrapper.childElementCount, 1);
+
+ let browser = browserWrapper.firstElementChild;
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(browser);
+ }
+ is(
+ browser.currentURI.pathQueryRef,
+ `/management.html?accountId=${accountKey}`
+ );
+ await extension.awaitMessage("management-ui-ready");
+
+ let tabmail = document.getElementById("tabmail");
+ let tabCount = tabmail.tabInfo.length;
+ BrowserTestUtils.synthesizeMouseAtCenter("a", {}, browser);
+ // It might take a moment to get to the external protocol service.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ ok(
+ mockExternalProtocolService.urlLoaded("https://www.example.com/"),
+ "Link click sent to external protocol service."
+ );
+ is(tabmail.tabInfo.length, tabCount, "No new tab opened");
+
+ // Rename the account.
+
+ EventUtils.synthesizeMouseAtCenter(
+ accountListItem,
+ { clickCount: 1 },
+ prefsWindow
+ );
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(
+ prefsDocument.activeElement.closest("input"),
+ accountListItem.querySelector("input")
+ );
+ ok(accountListItem.querySelector("label").hidden);
+ ok(!accountListItem.querySelector("input").hidden);
+ is(accountListItem.querySelector("input").value, "Mochitest");
+ EventUtils.synthesizeKey("VK_RIGHT", undefined, prefsWindow);
+ EventUtils.synthesizeKey("!", undefined, prefsWindow);
+ EventUtils.synthesizeKey("VK_RETURN", undefined, prefsWindow);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(prefsDocument.activeElement, accountList);
+ ok(!accountListItem.querySelector("label").hidden);
+ is(accountListItem.querySelector("label").value, "Mochitest!");
+ ok(accountListItem.querySelector("input").hidden);
+ is(
+ Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ ),
+ "Mochitest!"
+ );
+
+ // Start to rename the account, but bail out.
+
+ EventUtils.synthesizeMouseAtCenter(
+ accountListItem,
+ { clickCount: 1 },
+ prefsWindow
+ );
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(
+ prefsDocument.activeElement.closest("input"),
+ accountListItem.querySelector("input")
+ );
+ EventUtils.synthesizeKey("O", undefined, prefsWindow);
+ EventUtils.synthesizeKey("o", undefined, prefsWindow);
+ EventUtils.synthesizeKey("p", undefined, prefsWindow);
+ EventUtils.synthesizeKey("s", undefined, prefsWindow);
+ EventUtils.synthesizeKey("VK_ESCAPE", undefined, prefsWindow);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(prefsDocument.activeElement, accountList);
+ ok(!accountListItem.querySelector("label").hidden);
+ is(accountListItem.querySelector("label").value, "Mochitest!");
+ ok(accountListItem.querySelector("input").hidden);
+ is(
+ Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ ),
+ "Mochitest!"
+ );
+
+ // Configure the account.
+
+ account.configured = true;
+ accountIsConfigured = true;
+ cloudFileAccounts.emit("accountConfigured", account);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(accountListItem.querySelector(".configuredWarning").hidden, true);
+ is(cloudFileAccounts.accounts.length, 1);
+ is(cloudFileAccounts.configuredAccounts.length, 1);
+
+ // Remove the test provider. The list item, button, and browser should disappear.
+
+ info("Stopping extension");
+ await extension.unload();
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(buttonList.childElementCount, 1);
+ is(
+ buttonList.children[0].getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(menuButton.itemCount, 1);
+ is(
+ menuButton.getItemAtIndex(0).getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(accountList.itemCount, 0);
+ ok(!cloudFileDefaultPanel.hidden);
+ is(browserWrapper.childElementCount, 0);
+
+ // Re-add the test provider.
+
+ await startExtension();
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 1);
+ is(cloudFileAccounts.configuredAccounts.length, 1);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(buttonList.childElementCount, 2);
+ is(
+ buttonList.children[0].getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(buttonList.children[1].getAttribute("value"), "ext-cloudfile@mochitest");
+
+ is(menuButton.itemCount, 2);
+ is(
+ menuButton.getItemAtIndex(0).getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(
+ menuButton.getItemAtIndex(1).getAttribute("value"),
+ "ext-cloudfile@mochitest"
+ );
+
+ is(accountList.itemCount, 1);
+ is(accountList.selectedIndex, -1);
+ ok(removeButton.disabled);
+
+ accountListItem = accountList.getItemAtIndex(0);
+ is(
+ Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ ),
+ "Mochitest!"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ accountList.getItemAtIndex(0),
+ { clickCount: 1 },
+ prefsWindow
+ );
+ ok(!removeButton.disabled);
+ EventUtils.synthesizeMouseAtCenter(
+ removeButton,
+ { clickCount: 1 },
+ prefsWindow
+ );
+ is(mockPromptService.confirmCount, 1);
+
+ ok(
+ !Services.prefs.prefHasUserValue(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ )
+ );
+ ok(
+ !Services.prefs.prefHasUserValue(
+ `mail.cloud_files.accounts.${accountKey}.type`
+ )
+ );
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ info("Stopping extension");
+ await extension.unload();
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Close the preferences tab.
+
+ await closePrefsTab();
+});
+
+async function subtestBrowserStyle(assertMessage, expected) {
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Load the preferences tab.
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ "paneCompose",
+ "compositionAttachmentsCategory"
+ );
+
+ // Minimal check everything is as it should be.
+
+ let accountList = prefsDocument.getElementById("cloudFileView");
+ is(accountList.itemCount, 0);
+
+ let buttonList = prefsDocument.getElementById("addCloudFileAccountButtons");
+ ok(!buttonList.hidden);
+
+ let browserWrapper = prefsDocument.getElementById("cloudFileSettingsWrapper");
+ is(browserWrapper.childElementCount, 0);
+
+ // Register our test provider.
+
+ await startExtension(expected.browser_style);
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(buttonList.childElementCount, 2);
+ is(buttonList.children[1].getAttribute("value"), "ext-cloudfile@mochitest");
+
+ // Create a new account.
+
+ EventUtils.synthesizeMouseAtCenter(
+ buttonList.children[1],
+ { clickCount: 1 },
+ prefsWindow
+ );
+ is(cloudFileAccounts.accounts.length, 1);
+ is(cloudFileAccounts.configuredAccounts.length, 0);
+
+ let account = cloudFileAccounts.accounts[0];
+ let accountKey = account.accountKey;
+ is(cloudFileAccounts.accounts[0].type, "ext-cloudfile@mochitest");
+
+ // Minimal check UI was updated.
+
+ is(accountList.itemCount, 1);
+ is(accountList.selectedIndex, 0);
+
+ let accountListItem = accountList.selectedItem;
+ is(accountListItem.getAttribute("value"), accountKey);
+
+ is(browserWrapper.childElementCount, 1);
+ let browser = browserWrapper.firstElementChild;
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(browser);
+ }
+ is(
+ browser.currentURI.pathQueryRef,
+ `/management.html?accountId=${accountKey}`
+ );
+ await extension.awaitMessage("management-ui-ready");
+
+ // Test browser_style
+
+ extension.sendMessage(
+ "check-style",
+ assertMessage,
+ expected.browser_style == "true"
+ );
+ await extension.awaitFinish("management-ui-browser_style");
+
+ // Remove the account
+
+ accountListItem = accountList.getItemAtIndex(0);
+ EventUtils.synthesizeMouseAtCenter(
+ accountList.getItemAtIndex(0),
+ { clickCount: 1 },
+ prefsWindow
+ );
+
+ let removeButton = prefsDocument.getElementById("removeCloudFileAccount");
+ ok(!removeButton.disabled);
+ EventUtils.synthesizeMouseAtCenter(
+ removeButton,
+ { clickCount: 1 },
+ prefsWindow
+ );
+ is(mockPromptService.confirmCount, expected.confirmCount);
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ info("Stopping extension");
+ await extension.unload();
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Close the preferences tab.
+
+ await closePrefsTab();
+}
+
+add_task(async function test_without_setting_browser_style() {
+ await subtestBrowserStyle(
+ "Expected correct style when browser_style is excluded",
+ {
+ confirmCount: 2,
+ browser_style: "default",
+ }
+ );
+});
+
+add_task(async function test_with_browser_style_set_to_true() {
+ await subtestBrowserStyle(
+ "Expected correct style when browser_style is set to `true`",
+ {
+ confirmCount: 3,
+ browser_style: "true",
+ }
+ );
+});
+
+add_task(async function test_with_browser_style_set_to_false() {
+ await subtestBrowserStyle(
+ "Expected no style when browser_style is set to `false`",
+ {
+ confirmCount: 4,
+ browser_style: "false",
+ }
+ );
+});
+
+add_task(async function accountListOverflow() {
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Register our test provider.
+
+ await startExtension();
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Load the preferences tab.
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ "paneCompose",
+ "compositionAttachmentsCategory"
+ );
+
+ let accountList = prefsDocument.getElementById("cloudFileView");
+ is(accountList.itemCount, 0);
+
+ let buttonList = prefsDocument.getElementById("addCloudFileAccountButtons");
+ ok(!buttonList.hidden);
+ is(buttonList.childElementCount, 2);
+ is(buttonList.children[0].getAttribute("value"), "ext-cloudfile@mochitest");
+
+ let menuButton = prefsDocument.getElementById("addCloudFileAccount");
+ ok(menuButton.hidden);
+
+ // Add new accounts until the list overflows. The list of buttons should be hidden
+ // and the button with the drop-down should appear.
+
+ let count = 0;
+ do {
+ let readyPromise = extension.awaitMessage("management-ui-ready");
+ EventUtils.synthesizeMouseAtCenter(
+ buttonList.children[0],
+ { clickCount: 1 },
+ prefsWindow
+ );
+ await readyPromise;
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 500));
+ if (buttonList.hidden) {
+ break;
+ }
+ } while (++count < 25);
+
+ ok(count < 24); // If count reaches 25, we have a problem.
+ ok(!menuButton.hidden);
+
+ // Remove the added accounts. The list of buttons should not reappear and the
+ // button with the drop-down should remain.
+
+ let removeButton = prefsDocument.getElementById("removeCloudFileAccount");
+ do {
+ EventUtils.synthesizeMouseAtCenter(
+ accountList.getItemAtIndex(0),
+ { clickCount: 1 },
+ prefsWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ removeButton,
+ { clickCount: 1 },
+ prefsWindow
+ );
+ await new Promise(resolve => setTimeout(resolve));
+ } while (--count > 0);
+
+ ok(buttonList.hidden);
+ ok(!menuButton.hidden);
+
+ // Close the preferences tab.
+
+ await closePrefsTab();
+ info("Stopping extension");
+ await extension.unload();
+ Services.prefs.deleteBranch("mail.cloud_files.accounts");
+});
+
+add_task(async function accountListOrder() {
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ for (let [key, displayName] of [
+ ["someKey1", "carl's Account"],
+ ["someKey2", "Amber's Account"],
+ ["someKey3", "alice's Account"],
+ ["someKey4", "Bob's Account"],
+ ]) {
+ Services.prefs.setCharPref(
+ `mail.cloud_files.accounts.${key}.type`,
+ "ext-cloudfile@mochitest"
+ );
+ Services.prefs.setCharPref(
+ `mail.cloud_files.accounts.${key}.displayName`,
+ displayName
+ );
+ }
+
+ // Register our test provider.
+
+ await startExtension();
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 4);
+
+ let { prefsDocument } = await openNewPrefsTab(
+ "paneCompose",
+ "compositionAttachmentsCategory"
+ );
+
+ let accountList = prefsDocument.getElementById("cloudFileView");
+ is(accountList.itemCount, 4);
+
+ is(accountList.getItemAtIndex(0).value, "someKey3");
+ is(accountList.getItemAtIndex(1).value, "someKey2");
+ is(accountList.getItemAtIndex(2).value, "someKey4");
+ is(accountList.getItemAtIndex(3).value, "someKey1");
+
+ await closePrefsTab();
+ info("Stopping extension");
+ await extension.unload();
+ Services.prefs.deleteBranch("mail.cloud_files.accounts");
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_compose.js b/comm/mail/components/preferences/test/browser/browser_compose.js
new file mode 100644
index 0000000000..ea253cb555
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_compose.js
@@ -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/. */
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneCompose",
+ "compositionMainCategory",
+ {
+ checkboxID: "addExtension",
+ pref: "mail.forward_add_extension",
+ },
+ {
+ checkboxID: "autoSave",
+ pref: "mail.compose.autosave",
+ enabledElements: ["#autoSaveInterval"],
+ },
+ {
+ checkboxID: "mailWarnOnSendAccelKey",
+ pref: "mail.warn_on_send_accel_key",
+ },
+ {
+ checkboxID: "spellCheckBeforeSend",
+ pref: "mail.SpellCheckBeforeSend",
+ },
+ {
+ checkboxID: "inlineSpellCheck",
+ pref: "mail.spellcheck.inline",
+ }
+ );
+
+ await testCheckboxes(
+ "paneCompose",
+ "FontSelect",
+ {
+ checkboxID: "useReaderDefaults",
+ pref: "msgcompose.default_colors",
+ enabledInverted: true,
+ enabledElements: [
+ "#textColorLabel",
+ "#textColorButton",
+ "#backgroundColorLabel",
+ "#backgroundColorButton",
+ ],
+ },
+ {
+ checkboxID: "defaultToParagraph",
+ pref: "mail.compose.default_to_paragraph",
+ }
+ );
+
+ await testCheckboxes(
+ "paneCompose",
+ "compositionAddressingCategory",
+ {
+ checkboxID: "addressingAutocomplete",
+ pref: "mail.enable_autocomplete",
+ },
+ {
+ checkboxID: "autocompleteLDAP",
+ pref: "ldap_2.autoComplete.useDirectory",
+ enabledElements: ["#directoriesList", "#editButton"],
+ },
+ {
+ checkboxID: "emailCollectionOutgoing",
+ pref: "mail.collect_email_address_outgoing",
+ enabledElements: ["#localDirectoriesList"],
+ }
+ );
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneCompose",
+ "compositionAttachmentsCategory",
+ {
+ checkboxID: "attachment_reminder_label",
+ pref: "mail.compose.attachment_reminder",
+ enabledElements: ["#attachment_reminder_button"],
+ },
+ {
+ checkboxID: "enableThreshold",
+ pref: "mail.compose.big_attachments.notify",
+ enabledElements: ["#cloudFileThreshold"],
+ }
+ );
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_general.js b/comm/mail/components/preferences/test/browser/browser_general.js
new file mode 100644
index 0000000000..f011be1e38
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_general.js
@@ -0,0 +1,380 @@
+/* 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_task(async () => {
+ requestLongerTimeout(2);
+
+ // Temporarily disable `Once` StaticPrefs check for this test so that we
+ // can change layers.acceleration.disabled without debug builds failing.
+ await SpecialPowers.pushPrefEnv({
+ set: [["preferences.force-disable.check.once.policy", true]],
+ });
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneGeneral",
+ "generalCategory",
+ {
+ checkboxID: "mailnewsStartPageEnabled",
+ pref: "mailnews.start_page.enabled",
+ enabledElements: [
+ "#mailnewsStartPageUrl",
+ "#mailnewsStartPageUrl + button",
+ ],
+ },
+ {
+ checkboxID: "alwaysCheckDefault",
+ pref: "mail.shell.checkDefaultClient",
+ }
+ );
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneGeneral",
+ "scrollingGroup",
+ {
+ checkboxID: "useAutoScroll",
+ pref: "general.autoScroll",
+ },
+ {
+ checkboxID: "useSmoothScrolling",
+ pref: "general.smoothScroll",
+ }
+ );
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneGeneral",
+ "enableGloda",
+ {
+ checkboxID: "enableGloda",
+ pref: "mailnews.database.global.indexer.enabled",
+ },
+ {
+ checkboxID: "allowHWAccel",
+ pref: "layers.acceleration.disabled",
+ prefValues: [true, false],
+ }
+ );
+});
+
+add_task(async () => {
+ if (AppConstants.platform != "macosx") {
+ await testCheckboxes(
+ "paneGeneral",
+ "incomingMailCategory",
+ {
+ checkboxID: "newMailNotification",
+ pref: "mail.biff.play_sound",
+ enabledElements: ["#soundType radio"],
+ },
+ {
+ checkboxID: "newMailNotificationAlert",
+ pref: "mail.biff.show_alert",
+ enabledElements: ["#customizeMailAlert"],
+ }
+ );
+ }
+});
+
+add_task(async () => {
+ if (AppConstants.platform == "macosx") {
+ return;
+ }
+
+ Services.prefs.setBoolPref("mail.biff.play_sound", true);
+
+ await testRadioButtons("paneGeneral", "incomingMailCategory", {
+ pref: "mail.biff.play_sound.type",
+ states: [
+ {
+ id: "system",
+ prefValue: 0,
+ },
+ {
+ id: "custom",
+ prefValue: 1,
+ enabledElements: ["#soundUrlLocation", "#browseForSound"],
+ },
+ ],
+ });
+});
+
+add_task(async () => {
+ await testCheckboxes("paneGeneral", "fontsGroup", {
+ checkboxID: "displayGlyph",
+ pref: "mail.display_glyph",
+ });
+
+ await testCheckboxes(
+ "paneGeneral",
+ "readingAndDisplayCategory",
+ {
+ checkboxID: "automaticallyMarkAsRead",
+ pref: "mailnews.mark_message_read.auto",
+ enabledElements: ["#markAsReadAutoPreferences radio"],
+ },
+ {
+ checkboxID: "closeMsgOnMoveOrDelete",
+ pref: "mail.close_message_window.on_delete",
+ },
+ {
+ checkboxID: "showCondensedAddresses",
+ pref: "mail.showCondensedAddresses",
+ }
+ );
+});
+
+add_task(async () => {
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", true);
+
+ await testRadioButtons(
+ "paneGeneral",
+ "mark_read_immediately",
+ {
+ pref: "mailnews.mark_message_read.delay",
+ states: [
+ {
+ id: "mark_read_immediately",
+ prefValue: false,
+ },
+ {
+ id: "markAsReadAfterDelay",
+ prefValue: true,
+ enabledElements: ["#markAsReadDelay"],
+ },
+ ],
+ },
+ {
+ pref: "mail.openMessageBehavior",
+ states: [
+ {
+ id: "newTab",
+ prefValue: 2,
+ },
+ {
+ id: "newWindow",
+ prefValue: 0,
+ },
+ {
+ id: "existingWindow",
+ prefValue: 1,
+ },
+ ],
+ }
+ );
+});
+
+add_task(async () => {
+ // We don't want to wake up the platform search for this test.
+ // if (AppConstants.platform == "macosx") {
+ // tests.push({
+ // checkboxID: "searchIntegration",
+ // pref: "mail.spotlight.enable",
+ // });
+ // } else if (AppConstants.platform == "win") {
+ // tests.push({
+ // checkboxID: "searchIntegration",
+ // pref: "mail.winsearch.enable",
+ // });
+ // }
+
+ await testCheckboxes(
+ "paneGeneral",
+ "allowSmartSize",
+ {
+ checkboxID: "allowSmartSize",
+ pref: "browser.cache.disk.smart_size.enabled",
+ prefValues: [true, false],
+ enabledElements: ["#cacheSize"],
+ },
+ {
+ checkboxID: "offlineCompactFolder",
+ pref: "mail.prompt_purge_threshhold",
+ enabledElements: [
+ "#offlineCompactFolderMin",
+ "#offlineCompactFolderAutomatically",
+ ],
+ }
+ );
+});
+
+add_task(async () => {
+ await testRadioButtons("paneGeneral", "formatLocale", {
+ pref: "intl.regional_prefs.use_os_locales",
+ states: [
+ {
+ id: "appLocale",
+ prefValue: false,
+ },
+ {
+ id: "rsLocale",
+ prefValue: true,
+ },
+ ],
+ });
+});
+
+add_task(async () => {
+ await testRadioButtons("paneGeneral", "filesAttachmentCategory", {
+ pref: "browser.download.useDownloadDir",
+ states: [
+ {
+ id: "saveTo",
+ prefValue: true,
+ enabledElements: ["#downloadFolder", "#chooseFolder"],
+ },
+ {
+ id: "alwaysAsk",
+ prefValue: false,
+ },
+ ],
+ });
+});
+
+add_task(async function testTagDialog() {
+ const { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ "paneGeneral",
+ "tagsCategory"
+ );
+
+ let newTagDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/tagDialog.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+
+ EventUtils.sendString("tbird", dialogWindow);
+ // "#000080" == rgb(0, 0, 128);
+ dialogDocument.getElementById("tagColorPicker").value = "#000080";
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("dialog").getButton("accept"),
+ {},
+ dialogWindow
+ );
+ await new Promise(r => setTimeout(r));
+ },
+ }
+ );
+
+ let newTagButton = prefsDocument.getElementById("newTagButton");
+ EventUtils.synthesizeMouseAtCenter(newTagButton, {}, prefsWindow);
+ await newTagDialogPromise;
+
+ let tagList = prefsDocument.getElementById("tagList");
+
+ Assert.ok(
+ tagList.querySelector('richlistitem[value="tbird"]'),
+ "new tbird tag should be in the list"
+ );
+ Assert.equal(
+ tagList.querySelector('richlistitem[value="tbird"]').style.color,
+ "rgb(0, 0, 128)",
+ "tbird tag color should be correct"
+ );
+ Assert.equal(
+ tagList.querySelectorAll('richlistitem[value="tbird"]').length,
+ 1,
+ "new tbird tag should be in the list exactly once"
+ );
+
+ Assert.equal(
+ tagList.querySelector('richlistitem[value="tbird"]'),
+ tagList.selectedItem,
+ "tbird tag should be selected"
+ );
+
+ // Now edit the tag. The key should stay the same, name and color will change.
+
+ let editTagDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/tagDialog.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+
+ Assert.equal(
+ dialogDocument.getElementById("name").value,
+ "tbird",
+ "should have existing tbird tag name prefilled"
+ );
+ Assert.equal(
+ dialogDocument.getElementById("tagColorPicker").value,
+ "#000080",
+ "should have existing tbird tag color prefilled"
+ );
+
+ EventUtils.sendString("-xx", dialogWindow); // => tbird-xx
+ // "#FFD700" == rgb(255, 215, 0);
+ dialogDocument.getElementById("tagColorPicker").value = "#FFD700";
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("dialog").getButton("accept"),
+ {},
+ dialogWindow
+ );
+ await new Promise(r => setTimeout(r));
+ },
+ }
+ );
+
+ let editTagButton = prefsDocument.getElementById("editTagButton");
+ EventUtils.synthesizeMouseAtCenter(editTagButton, {}, prefsWindow);
+ await editTagDialogPromise;
+
+ Assert.ok(
+ tagList.querySelector(
+ 'richlistitem[value="tbird"] > label[value="tbird-xx"]'
+ ),
+ "tbird-xx tag should be in the list"
+ );
+ Assert.equal(
+ tagList.querySelector('richlistitem[value="tbird"]').style.color,
+ "rgb(255, 215, 0)",
+ "tbird-xx tag color should be correct"
+ );
+ Assert.equal(
+ tagList.querySelectorAll('richlistitem[value="tbird"]').length,
+ 1,
+ "tbird-xx tag should be in the list exactly once"
+ );
+
+ // And remove it.
+
+ EventUtils.synthesizeMouseAtCenter(
+ prefsDocument.getElementById("removeTagButton"),
+ {},
+ prefsWindow
+ );
+ await new Promise(r => setTimeout(r));
+
+ Assert.equal(
+ tagList.querySelector('richlistitem[value="tbird"]'),
+ null,
+ "tbird-xx (with key tbird) tag should have been removed from the list"
+ );
+
+ await closePrefsTab();
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_openPreferences.js b/comm/mail/components/preferences/test/browser/browser_openPreferences.js
new file mode 100644
index 0000000000..01aceb085a
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_openPreferences.js
@@ -0,0 +1,37 @@
+/* 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/. */
+
+function getStoredLastSelected() {
+ return Services.xulStore.getValue(
+ "about:preferences",
+ "paneDeck",
+ "lastSelected"
+ );
+}
+
+add_task(async () => {
+ // Check that openPreferencesTab with no arguments and no stored value opens the first pane.
+ Services.xulStore.removeDocument("about:preferences");
+
+ let { prefsWindow } = await openNewPrefsTab();
+ is(prefsWindow.gLastCategory.category, "paneGeneral");
+
+ await closePrefsTab();
+});
+
+add_task(async () => {
+ // Check that openPreferencesTab with one argument opens the right pane…
+ Services.xulStore.removeDocument("about:preferences");
+
+ await openNewPrefsTab("panePrivacy");
+ is(getStoredLastSelected(), "panePrivacy");
+
+ await closePrefsTab();
+
+ // … even with a value in the XULStore.
+ await openNewPrefsTab("paneCompose");
+ is(getStoredLastSelected(), "paneCompose");
+
+ await closePrefsTab();
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_privacy.js b/comm/mail/components/preferences/test/browser/browser_privacy.js
new file mode 100644
index 0000000000..1b91ec35e8
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_privacy.js
@@ -0,0 +1,454 @@
+/* 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 () => {
+ await testCheckboxes(
+ "panePrivacy",
+ "privacyCategory",
+ {
+ checkboxID: "acceptRemoteContent",
+ pref: "mailnews.message_display.disable_remote_image",
+ prefValues: [true, false],
+ },
+ {
+ checkboxID: "keepHistory",
+ pref: "places.history.enabled",
+ },
+ {
+ checkboxID: "acceptCookies",
+ pref: "network.cookie.cookieBehavior",
+ prefValues: [2, 0],
+ enabledElements: ["#acceptThirdPartyMenu"],
+ unaffectedElements: ["#cookieExceptions"],
+ },
+ {
+ checkboxID: "privacyDoNotTrackCheckbox",
+ pref: "privacy.donottrackheader.enabled",
+ }
+ );
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "panePrivacy",
+ "privacyJunkCategory",
+ {
+ checkboxID: "manualMark",
+ pref: "mail.spam.manualMark",
+ enabledElements: ["#manualMarkMode radio"],
+ },
+ {
+ checkboxID: "markAsReadOnSpam",
+ pref: "mail.spam.markAsReadOnSpam",
+ },
+ {
+ checkboxID: "enableJunkLogging",
+ pref: "mail.spam.logging.enabled",
+ enabledElements: ["#openJunkLogButton"],
+ }
+ );
+
+ await testCheckboxes("panePrivacy", "privacySecurityCategory", {
+ checkboxID: "enablePhishingDetector",
+ pref: "mail.phishing.detection.enabled",
+ });
+
+ await testCheckboxes("panePrivacy", "enableAntiVirusQuarantine", {
+ checkboxID: "enableAntiVirusQuarantine",
+ pref: "mailnews.downloadToTempFile",
+ });
+});
+
+add_task(async () => {
+ Services.prefs.setBoolPref("mail.spam.manualMark", true);
+
+ await testRadioButtons("panePrivacy", "privacyJunkCategory", {
+ pref: "mail.spam.manualMarkMode",
+ states: [
+ {
+ id: "manualMarkMode0",
+ prefValue: 0,
+ },
+ {
+ id: "manualMarkMode1",
+ prefValue: 1,
+ },
+ ],
+ });
+});
+
+add_task(async () => {
+ // Telemetry pref is locked.
+ // await testCheckboxes("paneAdvanced", undefined, {
+ // checkboxID: "submitTelemetryBox",
+ // pref: "toolkit.telemetry.enabled",
+ // });
+
+ await testCheckboxes("panePrivacy", "enableOCSP", {
+ checkboxID: "enableOCSP",
+ pref: "security.OCSP.enabled",
+ prefValues: [0, 1],
+ });
+});
+
+// Here we'd test the update choices, but I don't want to go near that.
+add_task(async () => {
+ await testRadioButtons("panePrivacy", "enableOCSP", {
+ pref: "security.default_personal_cert",
+ states: [
+ {
+ id: "certSelectionAuto",
+ prefValue: "Select Automatically",
+ },
+ {
+ id: "certSelectionAsk",
+ prefValue: "Ask Every Time",
+ },
+ ],
+ });
+});
+
+add_task(async function testRemoteContentDialog() {
+ const { prefsDocument, prefsWindow } = await openNewPrefsTab("panePrivacy");
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+ const url = dialogDocument.getElementById("url");
+ const permissionsTree =
+ dialogDocument.getElementById("permissionsTree");
+
+ EventUtils.sendString("accept.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnAllow"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 1,
+ "new entry should be added to list"
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ dialogDocument.getElementById("btnSession")
+ ),
+ "session button should be hidden"
+ );
+
+ EventUtils.sendString("block.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnBlock"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 2,
+ "new entry should be added to list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnApplyChanges"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ let remoteContentExceptions = prefsDocument.getElementById(
+ "remoteContentExceptions"
+ );
+ EventUtils.synthesizeMouseAtCenter(remoteContentExceptions, {}, prefsWindow);
+ await dialogPromise;
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let acceptURI = Services.io.newURI("http://accept.invalid/");
+ let acceptPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ acceptURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(acceptPrincipal, "image"),
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "accept permission should exist for accept.invalid"
+ );
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let blockURI = Services.io.newURI("http://block.invalid/");
+ let blockPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ blockURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(blockPrincipal, "image"),
+ Ci.nsIPermissionManager.DENY_ACTION,
+ "block permission should exist for block.invalid"
+ );
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+ const permissionsTree =
+ dialogDocument.getElementById("permissionsTree");
+
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 2,
+ "list should be populated"
+ );
+
+ permissionsTree.view.selection.select(0);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("removePermission"),
+ {},
+ dialogWindow
+ );
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 1,
+ "row should be removed from list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("removeAllPermissions"),
+ {},
+ dialogWindow
+ );
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 0,
+ "row should be removed from list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnApplyChanges"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(remoteContentExceptions, {}, prefsWindow);
+ await dialogPromise;
+
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(acceptPrincipal, "image"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for accept.invalid"
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(blockPrincipal, "image"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for block.invalid"
+ );
+
+ await closePrefsTab();
+});
+
+add_task(async function testCookiesDialog() {
+ const { prefsDocument, prefsWindow } = await openNewPrefsTab("panePrivacy");
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+ const url = dialogDocument.getElementById("url");
+ const permissionsTree =
+ dialogDocument.getElementById("permissionsTree");
+
+ EventUtils.sendString("accept.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnAllow"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 1,
+ "new entry should be added to list"
+ );
+
+ EventUtils.sendString("session.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnSession"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 2,
+ "new entry should be added to list"
+ );
+
+ EventUtils.sendString("block.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnBlock"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 3,
+ "new entry should be added to list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnApplyChanges"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ let cookieExceptions = prefsDocument.getElementById("cookieExceptions");
+ EventUtils.synthesizeMouseAtCenter(cookieExceptions, {}, prefsWindow);
+ await dialogPromise;
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let acceptURI = Services.io.newURI("http://accept.invalid/");
+ let acceptPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ acceptURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(acceptPrincipal, "cookie"),
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "accept permission should exist for accept.invalid"
+ );
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let sessionURI = Services.io.newURI("http://session.invalid/");
+ let sessionPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ sessionURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(sessionPrincipal, "cookie"),
+ Ci.nsICookiePermission.ACCESS_SESSION,
+ "session permission should exist for session.invalid"
+ );
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let blockURI = Services.io.newURI("http://block.invalid/");
+ let blockPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ blockURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(blockPrincipal, "cookie"),
+ Ci.nsIPermissionManager.DENY_ACTION,
+ "block permission should exist for block.invalid"
+ );
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+ const permissionsTree =
+ dialogDocument.getElementById("permissionsTree");
+
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 3,
+ "list should be populated"
+ );
+
+ permissionsTree.view.selection.select(0);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("removePermission"),
+ {},
+ dialogWindow
+ );
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 2,
+ "row should be removed from list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("removeAllPermissions"),
+ {},
+ dialogWindow
+ );
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 0,
+ "row should be removed from list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnApplyChanges"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(cookieExceptions, {}, prefsWindow);
+ await dialogPromise;
+
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(acceptPrincipal, "cookie"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for accept.invalid"
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(sessionPrincipal, "cookie"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for session.invalid"
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(blockPrincipal, "cookie"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for block.invalid"
+ );
+
+ await closePrefsTab();
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_sync.js b/comm/mail/components/preferences/test/browser/browser_sync.js
new file mode 100644
index 0000000000..108695ebb5
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_sync.js
@@ -0,0 +1,419 @@
+/* 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 { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+FxAccounts.config.promiseConnectAccountURI = entryPoint =>
+ `https://example.org/?page=connect&entryPoint=${entryPoint}`;
+FxAccounts.config.promiseManageURI = entryPoint =>
+ `https://example.org/?page=manage&entryPoint=${entryPoint}`;
+FxAccounts.config.promiseChangeAvatarURI = entryPoint =>
+ `https://example.org/?page=avatar&entryPoint=${entryPoint}`;
+
+const ALL_ENGINES = [
+ "accounts",
+ "identities",
+ "addressbooks",
+ "calendars",
+ "passwords",
+];
+const PREF_PREFIX = "services.sync.engine";
+
+let prefsWindow, prefsDocument, tabmail;
+
+add_setup(async function () {
+ for (let engine of ALL_ENGINES) {
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.${engine}`, true);
+ }
+
+ ({ prefsWindow, prefsDocument } = await openNewPrefsTab("paneSync"));
+ tabmail = document.getElementById("tabmail");
+
+ /** @implements {nsIExternalProtocolService} */
+ let mockExternalProtocolService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+ externalProtocolHandlerExists(protocolScheme) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `should not be opening ${uri.spec} in an external browser`
+ );
+ },
+ };
+
+ let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+ });
+});
+
+add_task(async function testSectionStates() {
+ let noFxaAccount = prefsDocument.getElementById("noFxaAccount");
+ let hasFxaAccount = prefsDocument.getElementById("hasFxaAccount");
+ let accountStates = [noFxaAccount, hasFxaAccount];
+
+ let fxaLoginUnverified = prefsDocument.getElementById("fxaLoginUnverified");
+ let fxaLoginRejected = prefsDocument.getElementById("fxaLoginRejected");
+ let fxaLoginVerified = prefsDocument.getElementById("fxaLoginVerified");
+ let loginStates = [fxaLoginUnverified, fxaLoginRejected, fxaLoginVerified];
+
+ let fxaDeviceInfo = prefsDocument.getElementById("fxaDeviceInfo");
+ let syncConnected = prefsDocument.getElementById("syncConnected");
+ let syncDisconnected = prefsDocument.getElementById("syncDisconnected");
+ let syncStates = [syncConnected, syncDisconnected];
+
+ function assertStateVisible(states, visibleState) {
+ for (let state of states) {
+ let visible = BrowserTestUtils.is_visible(state);
+ Assert.equal(
+ visible,
+ state == visibleState,
+ `${state.id} should be ${state == visibleState ? "visible" : "hidden"}`
+ );
+ }
+ }
+
+ function checkStates({
+ accountState,
+ loginState = null,
+ deviceInfoVisible = false,
+ syncState = null,
+ }) {
+ prefsWindow.gSyncPane.updateWeavePrefs();
+ assertStateVisible(accountStates, accountState);
+ assertStateVisible(loginStates, loginState);
+ Assert.equal(
+ BrowserTestUtils.is_visible(fxaDeviceInfo),
+ deviceInfoVisible,
+ `fxaDeviceInfo should be ${deviceInfoVisible ? "visible" : "hidden"}`
+ );
+ assertStateVisible(syncStates, syncState);
+ }
+
+ async function assertTabOpens(target, expectedURL) {
+ if (typeof target == "string") {
+ target = prefsDocument.getElementById(target);
+ }
+
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ EventUtils.synthesizeMouseAtCenter(target, {}, prefsWindow);
+ await tabPromise;
+ let tab = tabmail.currentTabInfo;
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ Assert.equal(
+ tab.browser.currentURI.spec,
+ `https://example.org/${expectedURL}`,
+ "a tab opened to the correct URL"
+ );
+ tabmail.closeTab(tab);
+ }
+
+ info("No account");
+ Assert.equal(prefsWindow.UIState.get().status, "not_configured");
+ checkStates({ accountState: noFxaAccount });
+
+ // Check clicking the Sign In button opens the connect page in a tab.
+ await assertTabOpens("noFxaSignIn", "?page=connect&entryPoint=");
+
+ // Override the window's UIState object with mock values.
+ let baseState = {
+ email: "test@invalid",
+ displayName: "Testy McTest",
+ avatarURL:
+ "https://example.org/browser/comm/mail/components/preferences/test/browser/files/avatar.png",
+ avatarIsDefault: false,
+ };
+ let mockState;
+ prefsWindow.UIState = {
+ ON_UPDATE: "sync-ui-state:update",
+ STATUS_LOGIN_FAILED: "login_failed",
+ STATUS_NOT_CONFIGURED: "not_configured",
+ STATUS_NOT_VERIFIED: "not_verified",
+ STATUS_SIGNED_IN: "signed_in",
+ get() {
+ return mockState;
+ },
+ };
+
+ info("Login not verified");
+ mockState = { ...baseState, status: "not_verified" };
+ checkStates({
+ accountState: hasFxaAccount,
+ loginState: fxaLoginUnverified,
+ deviceInfoVisible: true,
+ });
+ Assert.deepEqual(
+ await prefsDocument.l10n.getAttributes(
+ prefsDocument.getElementById("fxaAccountMailNotVerified")
+ ),
+ {
+ id: "sync-pane-email-not-verified",
+ args: { userEmail: "test@invalid" },
+ },
+ "email address set correctly"
+ );
+
+ // Untested: Resend and remove account buttons.
+
+ info("Login rejected");
+ mockState = { ...baseState, status: "login_failed" };
+ checkStates({
+ accountState: hasFxaAccount,
+ loginState: fxaLoginRejected,
+ deviceInfoVisible: true,
+ });
+ Assert.deepEqual(
+ await prefsDocument.l10n.getAttributes(
+ prefsDocument.getElementById("fxaAccountLoginRejected")
+ ),
+ {
+ id: "sync-signedin-login-failure",
+ args: { userEmail: "test@invalid" },
+ },
+ "email address set correctly"
+ );
+
+ // Untested: Sign in and remove account buttons.
+
+ info("Logged in, sync disabled");
+ mockState = { ...baseState, status: "verified", syncEnabled: false };
+ checkStates({
+ accountState: hasFxaAccount,
+ loginState: fxaLoginVerified,
+ deviceInfoVisible: true,
+ syncState: syncDisconnected,
+ });
+ let photo = fxaLoginVerified.querySelector(".contact-photo");
+ Assert.equal(
+ photo.src,
+ "https://example.org/browser/comm/mail/components/preferences/test/browser/files/avatar.png",
+ "avatar image set correctly"
+ );
+
+ // Check clicking the avatar image opens the avatar page in a tab.
+ await assertTabOpens(photo, "?page=avatar&entryPoint=preferences");
+
+ Assert.equal(
+ prefsDocument.getElementById("fxaDisplayName").textContent,
+ "Testy McTest",
+ "display name set correctly"
+ );
+ Assert.equal(
+ prefsDocument.getElementById("fxaEmailAddress").textContent,
+ "test@invalid",
+ "email address set correctly"
+ );
+
+ // Check clicking the management link opens the management page in a tab.
+ await assertTabOpens("verifiedManage", "?page=manage&entryPoint=preferences");
+
+ // Untested: Sign out button.
+
+ info("Device name section");
+ let deviceNameInput = prefsDocument.getElementById("fxaDeviceNameInput");
+ let deviceNameCancel = prefsDocument.getElementById("fxaDeviceNameCancel");
+ let deviceNameSave = prefsDocument.getElementById("fxaDeviceNameSave");
+ let deviceNameChange = prefsDocument.getElementById(
+ "fxaDeviceNameChangeDeviceName"
+ );
+ Assert.ok(deviceNameInput.readOnly, "input is read-only");
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameCancel),
+ "cancel button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameSave),
+ "save button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(deviceNameChange),
+ "change button is visible"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(deviceNameChange, {}, prefsWindow);
+ Assert.ok(!deviceNameInput.readOnly, "input is writeable");
+ Assert.equal(prefsDocument.activeElement, deviceNameInput, "input is active");
+ Assert.ok(
+ BrowserTestUtils.is_visible(deviceNameCancel),
+ "cancel button is visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(deviceNameSave),
+ "save button is visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameChange),
+ "change button is hidden"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(deviceNameCancel, {}, prefsWindow);
+ Assert.ok(deviceNameInput.readOnly, "input is read-only");
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameCancel),
+ "cancel button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameSave),
+ "save button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(deviceNameChange),
+ "change button is visible"
+ );
+
+ // Check the turn on sync button works.
+ await openEngineDialog({ expectEngines: ALL_ENGINES, button: "syncSetup" });
+
+ info("Logged in, sync enabled");
+ mockState = { ...baseState, status: "verified", syncEnabled: true };
+ checkStates({
+ accountState: hasFxaAccount,
+ loginState: fxaLoginVerified,
+ deviceInfoVisible: true,
+ syncState: syncConnected,
+ });
+
+ // Untested: Sync now button.
+
+ // Check the learn more link opens a tab.
+ await assertTabOpens("enginesLearnMore", "?page=learnMore");
+
+ // Untested: Disconnect button.
+});
+
+add_task(async function testEngines() {
+ function assertEnginesEnabled(...expectedEnabled) {
+ for (let engine of ALL_ENGINES) {
+ let enabled = Services.prefs.getBoolPref(`${PREF_PREFIX}.${engine}`);
+ Assert.equal(
+ enabled,
+ expectedEnabled.includes(engine),
+ `${engine} should be ${
+ expectedEnabled.includes(engine) ? "enabled" : "disabled"
+ }`
+ );
+ }
+ }
+
+ function assertEnginesShown(...expectEngines) {
+ let ENGINES_TO_ITEMS = {
+ accounts: "showSyncAccount",
+ identities: "showSyncIdentity",
+ addressbooks: "showSyncAddress",
+ calendars: "showSyncCalendar",
+ passwords: "showSyncPasswords",
+ };
+ let expectItems = expectEngines.map(engine => ENGINES_TO_ITEMS[engine]);
+ let items = Array.from(
+ prefsDocument.querySelectorAll("#showSyncedList > li:not([hidden])"),
+ li => li.id
+ );
+ Assert.deepEqual(items, expectItems, "enabled engines shown correctly");
+ }
+
+ assertEnginesShown(...ALL_ENGINES);
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.accounts`, false);
+ assertEnginesShown("identities", "addressbooks", "calendars", "passwords");
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.identities`, false);
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.addressbooks`, false);
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.calendars`, false);
+ assertEnginesShown("passwords");
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.passwords`, false);
+ assertEnginesShown();
+
+ info("Checking the engine selection dialog");
+ await openEngineDialog({
+ toggleEngines: ["accounts", "identities", "passwords"],
+ });
+
+ assertEnginesEnabled("accounts", "identities", "passwords");
+ assertEnginesShown("accounts", "identities", "passwords");
+
+ await openEngineDialog({
+ expectEngines: ["accounts", "identities", "passwords"],
+ toggleEngines: ["calendars", "passwords"],
+ action: "cancel",
+ });
+
+ assertEnginesEnabled("accounts", "identities", "passwords");
+ assertEnginesShown("accounts", "identities", "passwords");
+
+ await openEngineDialog({
+ expectEngines: ["accounts", "identities", "passwords"],
+ toggleEngines: ["calendars", "passwords"],
+ action: "accept",
+ });
+
+ assertEnginesEnabled("accounts", "identities", "calendars");
+ assertEnginesShown("accounts", "identities", "calendars");
+
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.addressbooks`, true);
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.passwords`, true);
+ assertEnginesShown(...ALL_ENGINES);
+});
+
+async function openEngineDialog({
+ expectEngines = [],
+ toggleEngines = [],
+ action = "accept",
+ button = "syncChangeOptions",
+}) {
+ const ENGINES_TO_CHECKBOXES = {
+ accounts: "configSyncAccount",
+ identities: "configSyncIdentity",
+ addressbooks: "configSyncAddress",
+ calendars: "configSyncCalendar",
+ passwords: "configSyncPasswords",
+ };
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/syncDialog.xhtml",
+ { isSubDialog: true }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ prefsDocument.getElementById(button),
+ {},
+ prefsWindow
+ );
+ let dialogWindow = await dialogPromise;
+ let dialogDocument = dialogWindow.document;
+ await new Promise(resolve => dialogWindow.setTimeout(resolve));
+
+ let expectItems = expectEngines.map(engine => ENGINES_TO_CHECKBOXES[engine]);
+
+ let checkedItems = Array.from(
+ dialogDocument.querySelectorAll(`input[type="checkbox"]`)
+ )
+ .filter(cb => cb.checked)
+ .map(cb => cb.id);
+ Assert.deepEqual(
+ checkedItems,
+ expectItems,
+ "enabled engines checked correctly"
+ );
+
+ for (let toggleItem of toggleEngines) {
+ let checkbox = dialogDocument.getElementById(
+ ENGINES_TO_CHECKBOXES[toggleItem]
+ );
+ checkbox.checked = !checkbox.checked;
+ }
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("dialog").getButton(action),
+ {},
+ dialogWindow
+ );
+}
diff --git a/comm/mail/components/preferences/test/browser/files/avatar.png b/comm/mail/components/preferences/test/browser/files/avatar.png
new file mode 100644
index 0000000000..ca0894316a
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/files/avatar.png
Binary files differ
diff --git a/comm/mail/components/preferences/test/browser/files/icon.svg b/comm/mail/components/preferences/test/browser/files/icon.svg
new file mode 100644
index 0000000000..6c1a552445
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/files/icon.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+ <circle cx="8" cy="8" r="7.5" fill="#ffffff" stroke="#00aa00" stroke-width="1.5"/>
+ <circle cx="5" cy="6" r="1.5" fill="#00aa00"/>
+ <circle cx="11" cy="6" r="1.5" fill="#00aa00"/>
+ <path d="M 12.83,9.30 C 12.24,11.48 10.26,13 8,13 5.75,13 3.74,11.48 3.17,9.29" fill="none" stroke="#00aa00" stroke-width="1.5"/>
+</svg>
diff --git a/comm/mail/components/preferences/test/browser/files/management.html b/comm/mail/components/preferences/test/browser/files/management.html
new file mode 100644
index 0000000000..7e3561d823
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/files/management.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title></title>
+</head>
+<body>
+ <a id="a" href="https://www.example.com/">Click me!</a>
+</body>
+</html>
diff --git a/comm/mail/components/preferences/test/browser/head.js b/comm/mail/components/preferences/test/browser/head.js
new file mode 100644
index 0000000000..12cbdb17f1
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/head.js
@@ -0,0 +1,314 @@
+/* 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 ../../../../base/content/utilityOverlay.js */
+
+async function openNewPrefsTab(paneID, scrollPaneTo, otherArgs) {
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ is(prefsTabMode.tabs.length, 0, "Prefs tab is not open");
+
+ let prefsDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL.startsWith("about:preferences")) {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ subject.ownerGlobal.setTimeout(() => resolve(subject));
+ }
+ }, "chrome-document-loaded");
+ openPreferencesTab(paneID, scrollPaneTo, otherArgs);
+ });
+ ok(prefsDocument.URL.startsWith("about:preferences"), "Prefs tab is open");
+
+ prefsDocument = prefsTabMode.tabs[0].browser.contentDocument;
+ let prefsWindow = prefsDocument.ownerGlobal;
+ prefsWindow.resizeTo(screen.availWidth, screen.availHeight);
+
+ if (paneID) {
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ is(
+ prefsWindow.gLastCategory.category,
+ paneID,
+ `Selected pane is ${paneID}`
+ );
+ } else {
+ // If we don't wait here for other scripts to run, they
+ // could be in a bad state if our test closes the tab.
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ }
+
+ registerCleanupOnce();
+
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ let container = prefsDocument.getElementById("preferencesContainer");
+ if (scrollPaneTo && container.scrollHeight > container.clientHeight) {
+ Assert.greater(
+ container.scrollTop,
+ 0,
+ "Prefs page did scroll when it was supposed to"
+ );
+ }
+ return { prefsDocument, prefsWindow };
+}
+
+async function openExistingPrefsTab(paneID, scrollPaneTo, otherArgs) {
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ is(prefsTabMode.tabs.length, 1, "Prefs tab is open");
+
+ let prefsDocument = prefsTabMode.tabs[0].browser.contentDocument;
+ let prefsWindow = prefsDocument.ownerGlobal;
+ prefsWindow.resizeTo(screen.availWidth, screen.availHeight);
+
+ if (paneID && prefsWindow.gLastCategory.category != paneID) {
+ openPreferencesTab(paneID, scrollPaneTo, otherArgs);
+ }
+
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ is(prefsWindow.gLastCategory.category, paneID, `Selected pane is ${paneID}`);
+
+ if (scrollPaneTo) {
+ Assert.greater(
+ prefsDocument.getElementById("preferencesContainer").scrollTop,
+ 0,
+ "Prefs page did scroll when it was supposed to"
+ );
+ }
+
+ registerCleanupOnce();
+ return { prefsDocument, prefsWindow };
+}
+
+function registerCleanupOnce() {
+ if (registerCleanupOnce.alreadyRegistered) {
+ return;
+ }
+ registerCleanupFunction(closePrefsTab);
+ registerCleanupOnce.alreadyRegistered = true;
+}
+
+async function closePrefsTab() {
+ info("Closing prefs tab");
+ let tabmail = document.getElementById("tabmail");
+ let prefsTab = tabmail.tabModes.preferencesTab.tabs[0];
+ if (prefsTab) {
+ tabmail.closeTab(prefsTab);
+ }
+}
+
+/**
+ * Tests a checkbox sets the preference is set in the right state when the preferences tab opens,
+ * that the preference it relates to is set properly, and any UI elements that should be disabled
+ * by it are disabled.
+ *
+ * Each of the tests arguments is an object describing a test, containing:
+ * checkboxID - the ID of the checkbox to test
+ * pref - the name of a preference,
+ * prefValues - an array of two values: pref value when not checked, pref value when checked
+ * (optional, defaults to [false, true])
+ * enabledElements - an array of CSS selectors (optional)
+ * enabledInverted - if the elements should be disabled when the checkbox is checked (optional)
+ * unaffectedElements - array of CSS selectors that should not be affected by
+ * the toggling of the checkbox.
+ */
+async function testCheckboxes(paneID, scrollPaneTo, ...tests) {
+ for (let initiallyChecked of [true, false]) {
+ info(`Opening ${paneID} with prefs set to ${initiallyChecked}`);
+
+ for (let test of tests) {
+ let wantedValue = initiallyChecked;
+ if (test.prefValues) {
+ wantedValue = wantedValue ? test.prefValues[1] : test.prefValues[0];
+ }
+ if (typeof wantedValue == "number") {
+ Services.prefs.setIntPref(test.pref, wantedValue);
+ } else {
+ Services.prefs.setBoolPref(test.pref, wantedValue);
+ }
+ }
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ paneID,
+ scrollPaneTo
+ );
+
+ let testUIState = function (test, checked) {
+ let wantedValue = checked;
+ if (test.prefValues) {
+ wantedValue = wantedValue ? test.prefValues[1] : test.prefValues[0];
+ }
+ let checkbox = prefsDocument.getElementById(test.checkboxID);
+ is(
+ checkbox.checked,
+ checked,
+ wantedValue,
+ "Checkbox " + (checked ? "is" : "isn't") + " checked"
+ );
+ if (typeof wantedValue == "number") {
+ is(
+ Services.prefs.getIntPref(test.pref, -999),
+ wantedValue,
+ `Pref is ${wantedValue}`
+ );
+ } else {
+ is(
+ Services.prefs.getBoolPref(test.pref),
+ wantedValue,
+ `Pref is ${wantedValue}`
+ );
+ }
+
+ if (test.enabledElements) {
+ let disabled = checked;
+ if (test.enabledInverted) {
+ disabled = !disabled;
+ }
+ for (let selector of test.enabledElements) {
+ let elements = prefsDocument.querySelectorAll(selector);
+ ok(
+ elements.length >= 1,
+ `At least one element matched '${selector}'`
+ );
+ for (let element of elements) {
+ is(
+ element.disabled,
+ !disabled,
+ "Element " + (disabled ? "isn't" : "is") + " disabled"
+ );
+ }
+ }
+ }
+ };
+
+ let testUnaffected = function (ids, states) {
+ ids.forEach((sel, index) => {
+ let isOk = prefsDocument.querySelector(sel).disabled === states[index];
+ is(isOk, true, `Element "${sel}" is unaffected`);
+ });
+ };
+
+ for (let test of tests) {
+ info(`Checking ${test.checkboxID}`);
+
+ let unaffectedSelectors = test.unaffectedElements || [];
+ let unaffectedStates = unaffectedSelectors.map(
+ sel => prefsDocument.querySelector(sel).disabled
+ );
+
+ let checkbox = prefsDocument.getElementById(test.checkboxID);
+ checkbox.scrollIntoView(false);
+ testUIState(test, initiallyChecked);
+
+ EventUtils.synthesizeMouseAtCenter(checkbox, {}, prefsWindow);
+ testUIState(test, !initiallyChecked);
+ testUnaffected(unaffectedSelectors, unaffectedStates);
+
+ EventUtils.synthesizeMouseAtCenter(checkbox, {}, prefsWindow);
+ testUIState(test, initiallyChecked);
+ testUnaffected(unaffectedSelectors, unaffectedStates);
+ }
+
+ await closePrefsTab();
+ }
+}
+
+/**
+ * Tests a set of radio buttons is in the right state when the preferences tab opens, and when
+ * the selected button changes that the preference it relates to is set properly, and any related
+ * UI elements that should be disabled are disabled.
+ *
+ * Each of the tests arguments is an object describing a test, containing:
+ * pref - the name of an integer preference,
+ * states - an array with each element describing a radio button:
+ * id - the ID of the button to test,
+ * prefValue - the value the pref should be set to
+ * enabledElements - an array of CSS selectors to elements that should be enabled when this
+ * radio button is selected (optional)
+ */
+async function testRadioButtons(paneID, scrollPaneTo, ...tests) {
+ for (let { pref, states } of tests) {
+ for (let initialState of states) {
+ info(`Opening ${paneID} with ${pref} set to ${initialState.prefValue}`);
+
+ if (typeof initialState.prefValue == "number") {
+ Services.prefs.setIntPref(pref, initialState.prefValue);
+ } else if (typeof initialState.prefValue == "boolean") {
+ Services.prefs.setBoolPref(pref, initialState.prefValue);
+ } else {
+ Services.prefs.setCharPref(pref, initialState.prefValue);
+ }
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ paneID,
+ scrollPaneTo
+ );
+
+ let testUIState = function (currentState) {
+ info(`Testing with ${pref} set to ${currentState.prefValue}`);
+ for (let state of states) {
+ let isCurrentState = state == currentState;
+ let radio = prefsDocument.getElementById(state.id);
+ is(radio.selected, isCurrentState, `${state.id}.selected`);
+
+ if (state.enabledElements) {
+ for (let selector of state.enabledElements) {
+ let elements = prefsDocument.querySelectorAll(selector);
+ ok(
+ elements.length >= 1,
+ `At least one element matched '${selector}'`
+ );
+ for (let element of elements) {
+ is(
+ element.disabled,
+ !isCurrentState,
+ "Element " + (isCurrentState ? "isn't" : "is") + " disabled"
+ );
+ }
+ }
+ }
+ }
+ if (typeof initialState.prefValue == "number") {
+ is(
+ Services.prefs.getIntPref(pref, -999),
+ currentState.prefValue,
+ `Pref is ${currentState.prefValue}`
+ );
+ } else if (typeof initialState.prefValue == "boolean") {
+ is(
+ Services.prefs.getBoolPref(pref),
+ currentState.prefValue,
+ `Pref is ${currentState.prefValue}`
+ );
+ } else {
+ is(
+ Services.prefs.getCharPref(pref, "FAKE VALUE"),
+ currentState.prefValue,
+ `Pref is ${currentState.prefValue}`
+ );
+ }
+ };
+
+ // Check the initial setup is correct.
+ testUIState(initialState);
+ // Cycle through possible values, checking each one.
+ for (let state of states) {
+ if (state == initialState) {
+ continue;
+ }
+ let radio = prefsDocument.getElementById(state.id);
+ radio.scrollIntoView(false);
+ EventUtils.synthesizeMouseAtCenter(radio, {}, prefsWindow);
+ testUIState(state);
+ }
+ // Go back to the initial value.
+ let initialRadio = prefsDocument.getElementById(initialState.id);
+ initialRadio.scrollIntoView(false);
+ EventUtils.synthesizeMouseAtCenter(initialRadio, {}, prefsWindow);
+ testUIState(initialState);
+
+ await closePrefsTab();
+ }
+ }
+}
diff --git a/comm/mail/components/prompts/PromptCollection.jsm b/comm/mail/components/prompts/PromptCollection.jsm
new file mode 100644
index 0000000000..ddb413de6f
--- /dev/null
+++ b/comm/mail/components/prompts/PromptCollection.jsm
@@ -0,0 +1,100 @@
+/* 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 EXPORTED_SYMBOLS = ["PromptCollection"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+/**
+ * Implements nsIPromptCollection
+ *
+ * @class PromptCollection
+ */
+class PromptCollection {
+ asyncBeforeUnloadCheck(browsingContext) {
+ let title;
+ let message;
+ let leaveLabel;
+ let stayLabel;
+
+ try {
+ title = this.domBundle.GetStringFromName("OnBeforeUnloadTitle");
+ message = this.domBundle.GetStringFromName("OnBeforeUnloadMessage2");
+ leaveLabel = this.domBundle.GetStringFromName(
+ "OnBeforeUnloadLeaveButton"
+ );
+ stayLabel = this.domBundle.GetStringFromName("OnBeforeUnloadStayButton");
+ } catch (exception) {
+ console.error("Failed to get strings from dom.properties");
+ return false;
+ }
+
+ let contentViewer = browsingContext?.docShell?.contentViewer;
+
+ // TODO: Do we really want to allow modal dialogs from inactive
+ // content viewers at all, particularly for permit unload prompts?
+ let modalAllowed = contentViewer
+ ? contentViewer.isTabModalPromptAllowed
+ : browsingContext.ancestorsAreCurrent;
+
+ let modalType =
+ Ci.nsIPromptService[
+ modalAllowed ? "MODAL_TYPE_CONTENT" : "MODAL_TYPE_WINDOW"
+ ];
+
+ let buttonFlags =
+ Ci.nsIPromptService.BUTTON_POS_0_DEFAULT |
+ (Ci.nsIPromptService.BUTTON_TITLE_IS_STRING *
+ Ci.nsIPromptService.BUTTON_POS_0) |
+ (Ci.nsIPromptService.BUTTON_TITLE_IS_STRING *
+ Ci.nsIPromptService.BUTTON_POS_1);
+
+ return Services.prompt
+ .asyncConfirmEx(
+ browsingContext,
+ modalType,
+ title,
+ message,
+ buttonFlags,
+ leaveLabel,
+ stayLabel,
+ null,
+ null,
+ false,
+ // Tell the prompt service that this is a permit unload prompt
+ // so that it can set the appropriate flag on the detail object
+ // of the events it dispatches.
+ { inPermitUnload: true }
+ )
+ .then(
+ result =>
+ result.QueryInterface(Ci.nsIPropertyBag2).get("buttonNumClicked") == 0
+ );
+ }
+}
+
+XPCOMUtils.defineLazyGetter(
+ PromptCollection.prototype,
+ "domBundle",
+ function () {
+ let bundle = Services.strings.createBundle(
+ "chrome://global/locale/dom/dom.properties"
+ );
+ if (!bundle) {
+ throw new Error("String bundle for dom not present!");
+ }
+ return bundle;
+ }
+);
+
+PromptCollection.prototype.classID = Components.ID(
+ "{7913837c-9623-11ea-bb37-0242ac130002}"
+);
+PromptCollection.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIPromptCollection",
+]);
diff --git a/comm/mail/components/prompts/components.conf b/comm/mail/components/prompts/components.conf
new file mode 100644
index 0000000000..0c00e72d67
--- /dev/null
+++ b/comm/mail/components/prompts/components.conf
@@ -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/.
+
+Classes = [
+ {
+ 'cid': '{7913837c-9623-11ea-bb37-0242ac130002}',
+ 'contract_ids': ['@mozilla.org/embedcomp/prompt-collection;1'],
+ 'jsm': 'resource:///modules/PromptCollection.jsm',
+ 'constructor': 'PromptCollection',
+ },
+]
diff --git a/comm/mail/components/prompts/moz.build b/comm/mail/components/prompts/moz.build
new file mode 100644
index 0000000000..143c3dcd8d
--- /dev/null
+++ b/comm/mail/components/prompts/moz.build
@@ -0,0 +1,11 @@
+# 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_JS_MODULES += [
+ "PromptCollection.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/search/SearchIntegration.jsm b/comm/mail/components/search/SearchIntegration.jsm
new file mode 100644
index 0000000000..4bedb50f58
--- /dev/null
+++ b/comm/mail/components/search/SearchIntegration.jsm
@@ -0,0 +1,871 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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, useful functions for desktop search integration components.
+ *
+ * The following symbols have to be defined for each component that includes this:
+ * - gHdrIndexedProperty: the property in the database that indicates whether a message
+ * has been indexed
+ * - gFileExt: the file extension to be used for support files
+ * - gPrefBase: the base for preferences that are stored
+ * - gStreamListener: an nsIStreamListener to read message text
+ */
+
+/* exported SearchSupport */
+
+var EXPORTED_SYMBOLS = ["SearchIntegration"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var PERM_DIRECTORY = 0o755;
+var PERM_FILE = 0o644;
+
+var SearchIntegration = null;
+
+var SearchSupport = {
+ /**
+ * URI of last folder indexed. Kept in sync with the pref
+ */
+ __lastFolderIndexedUri: null,
+ set _lastFolderIndexedUri(uri) {
+ this._prefBranch.setStringPref("lastFolderIndexedUri", uri);
+ this.__lastFolderIndexedUri = uri;
+ },
+ get _lastFolderIndexedUri() {
+ // If we don't know about it, get it from the pref branch
+ if (this.__lastFolderIndexedUri === null) {
+ this.__lastFolderIndexedUri = this._prefBranch.getStringPref(
+ "lastFolderIndexedUri",
+ ""
+ );
+ }
+ return this.__lastFolderIndexedUri;
+ },
+
+ /**
+ * Queue of message headers to index, along with reindex times for each header
+ */
+ _msgHdrsToIndex: [],
+
+ /**
+ * Messenger object, used primarily to get message URIs
+ */
+ __messenger: null,
+ get _messenger() {
+ if (!this.__messenger) {
+ this.__messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ }
+ return this.__messenger;
+ },
+
+ // The preferences branch to use
+ __prefBranch: null,
+ get _prefBranch() {
+ if (!this.__prefBranch) {
+ this.__prefBranch = Services.prefs.getBranch(this._prefBase);
+ }
+ return this.__prefBranch;
+ },
+
+ /**
+ * If this is true, we won't show any UI because the OS doesn't have the
+ * support we need
+ */
+ osVersionTooLow: false,
+
+ /**
+ * If this is true, we'll show disabled UI, because while the OS does have
+ * the support we need, not all the OS components we need are running
+ */
+ osComponentsNotRunning: false,
+
+ /**
+ * Whether the preference is enabled. The module might be in a state where
+ * the preference is on but "enabled" is false, so take care of that.
+ */
+ get prefEnabled() {
+ // Don't cache the value
+ return this._prefBranch.getBoolPref("enable");
+ },
+ set prefEnabled(aEnabled) {
+ if (this.prefEnabled != aEnabled) {
+ this._prefBranch.setBoolPref("enable", aEnabled);
+ }
+ },
+
+ /**
+ * Whether the first run has occurred. This will be used to determine if
+ * a dialog box needs to be displayed.
+ */
+ get firstRunDone() {
+ // Don't cache this value either
+ return this._prefBranch.getBoolPref("firstRunDone");
+ },
+ set firstRunDone(aAlwaysTrue) {
+ this._prefBranch.setBoolPref("firstRunDone", true);
+ },
+
+ /**
+ * Last global reindex time, used to check if reindexing is required.
+ * Kept in sync with the pref
+ */
+ _globalReindexTime: null,
+ set globalReindexTime(aTime) {
+ this._globalReindexTime = aTime;
+ // Set the pref as well
+ this._prefBranch.setCharPref("global_reindex_time", "" + aTime);
+ },
+ get globalReindexTime() {
+ if (!this._globalReindexTime) {
+ // Try getting the time from the preferences
+ try {
+ this._globalReindexTime = parseInt(
+ this._prefBranch.getCharPref("global_reindex_time")
+ );
+ } catch (e) {
+ // We don't have it defined, so set it (Unix time, in seconds)
+ this._globalReindexTime = parseInt(Date.now() / 1000);
+ this._prefBranch.setCharPref(
+ "global_reindex_time",
+ "" + this._globalReindexTime
+ );
+ }
+ }
+ return this._globalReindexTime;
+ },
+
+ /**
+ * Amount of time the user is idle before we (re)start an indexing sweep
+ */
+ _idleThresholdSecs: 30,
+
+ /**
+ * Reference to timer object
+ */
+ __timer: null,
+ get _timer() {
+ if (!this.__timer) {
+ this.__timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ }
+ return this.__timer;
+ },
+
+ _cancelTimer() {
+ try {
+ this._timer.cancel();
+ } catch (ex) {}
+ },
+
+ /**
+ * Enabled status.
+ *
+ * When we're enabled, then we get notifications about every message or folder
+ * operation, including "message displayed" operations which we bump up in
+ * priority. We also have a background sweep which we do on idle.
+ *
+ * We aren't fully disabled when we're "disabled", though. We still observe
+ * message and folder moves and deletes, as we don't want to have support
+ * files for non-existent messages.
+ */
+ _enabled: null,
+ set enabled(aEnable) {
+ // Nothing to do if there's no change in state
+ if (this._enabled == aEnable) {
+ return;
+ }
+
+ this._log.info(
+ "Enabled status changing from " + this._enabled + " to " + aEnable
+ );
+
+ this._removeObservers();
+
+ if (aEnable) {
+ // This stuff we always need to do.
+ // This code pre-dates msgsClassified.
+ // Some events intentionally omitted.
+ MailServices.mfn.addListener(
+ this._msgFolderListener,
+ MailServices.mfn.msgAdded |
+ MailServices.mfn.msgsDeleted |
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed
+ );
+ Services.obs.addObserver(this, "MsgMsgDisplayed");
+ let idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(
+ Ci.nsIUserIdleService
+ );
+ idleService.addIdleObserver(this, this._idleThresholdSecs);
+ } else {
+ // We want to observe moves, deletes and renames in case we're disabled
+ // If we don't, we'll have no idea the support files exist later
+ MailServices.mfn.addListener(
+ this._msgFolderListener,
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.msgsDeleted |
+ // folderAdded intentionally omitted
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed
+ );
+ }
+
+ this._enabled = aEnable;
+ },
+ get enabled() {
+ return this._enabled;
+ },
+
+ /**
+ * Remove whatever observers are present. This is done while switching states
+ */
+ _removeObservers() {
+ if (this.enabled === null) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this._msgFolderListener);
+
+ if (this.enabled) {
+ Services.obs.removeObserver(this, "MsgMsgDisplayed");
+ let idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(
+ Ci.nsIUserIdleService
+ );
+ idleService.removeIdleObserver(this, this._idleThresholdSecs);
+
+ // in case there's a background sweep going on
+ this._cancelTimer();
+ }
+ // We don't need to do anything extra if we're disabled
+ },
+
+ /**
+ * Init function -- this should be called from the component's init function
+ */
+ _initSupport(enabled) {
+ this._log.info(
+ "Search integration running in " +
+ (enabled ? "active" : "backoff") +
+ " mode"
+ );
+ this.enabled = enabled;
+
+ // Set up a pref observer
+ this._prefBranch.addObserver("enable", this);
+ },
+
+ /**
+ * Current folder being indexed
+ */
+ _currentFolderToIndex: null,
+
+ /**
+ * For the current folder being indexed, an enumerator for all the headers in
+ * the folder
+ */
+ _headerEnumerator: null,
+
+ /*
+ * These functions are to index already existing messages
+ */
+
+ /**
+ * Generator to look for the next folder to index, and return it
+ *
+ * This first looks for folders that have their corresponding search results
+ * folders missing. If it finds such a folder first, it'll yield return that
+ * folder.
+ *
+ * Next, it looks for the next folder after the lastFolderIndexedUri. If it is
+ * in such a folder, it'll yield return that folder, then set the
+ * lastFolderIndexedUrl to the URI of that folder.
+ *
+ * It resets lastFolderIndexedUri to an empty string, then yield returns null
+ * once iteration across all folders is complete.
+ */
+ *_foldersToIndexGenerator() {
+ // Stores whether we're after the last folder indexed or before that --
+ // if the last folder indexed is empty, this needs to be true initially
+ let afterLastFolderIndexed = this._lastFolderIndexedUri.length == 0;
+
+ for (let server of MailServices.accounts.allServers) {
+ this._log.debug(
+ "in find next folder, lastFolderIndexedUri = " +
+ this._lastFolderIndexedUri
+ );
+
+ for (var folder of server.rootFolder.descendants) {
+ let searchPath = this._getSearchPathForFolder(folder);
+ searchPath.leafName = searchPath.leafName + ".mozmsgs";
+ // If after the last folder indexed, definitely index this
+ if (afterLastFolderIndexed) {
+ // Create the folder if it doesn't exist, so that we don't hit the
+ // condition below later
+ if (!searchPath.exists()) {
+ searchPath.create(Ci.nsIFile.DIRECTORY_TYPE, PERM_DIRECTORY);
+ }
+
+ yield folder;
+ // We're back after yielding -- set the last folder indexed
+ this._lastFolderIndexedUri = folder.URI;
+ } else {
+ // If a folder's entire corresponding search results folder is
+ // missing, we need to index it, and force a reindex of all the
+ // messages in it
+ if (!searchPath.exists()) {
+ this._log.debug(
+ "using folder " +
+ folder.URI +
+ " because " +
+ "corresponding search folder does not exist"
+ );
+ // Create the folder, so that next time we're checking we don't hit
+ // this
+ searchPath.create(Ci.nsIFile.DIRECTORY_TYPE, PERM_DIRECTORY);
+ folder.setStringProperty(
+ this._hdrIndexedProperty,
+ "" + Date.now() / 1000
+ );
+ yield folder;
+ } else if (this._pathNeedsReindexing(searchPath)) {
+ // folder may need reindexing for other reasons
+ folder.setStringProperty(
+ this._hdrIndexedProperty,
+ "" + Date.now() / 1000
+ );
+ yield folder;
+ }
+
+ // Even if we yielded above, check if this is the last folder
+ // indexed
+ if (this._lastFolderIndexedUri == folder.URI) {
+ afterLastFolderIndexed = true;
+ }
+ }
+ }
+ }
+ // We're done with one iteration of all the folders; time to reset the
+ // lastFolderIndexedUri
+ this._lastFolderIndexedUri = "";
+ yield null;
+ },
+
+ __foldersToIndex: null,
+ get _foldersToIndex() {
+ if (!this.__foldersToIndex) {
+ this.__foldersToIndex = this._foldersToIndexGenerator();
+ }
+ return this.__foldersToIndex;
+ },
+
+ _findNextHdrToIndex() {
+ try {
+ let reindexTime = this._getLastReindexTime(this._currentFolderToIndex);
+ this._log.debug("Reindex time for this folder is " + reindexTime);
+ if (!this._headerEnumerator) {
+ // we need to create search terms for messages to index
+ let searchSession = Cc[
+ "@mozilla.org/messenger/searchSession;1"
+ ].createInstance(Ci.nsIMsgSearchSession);
+ let searchTerms = [];
+
+ searchSession.addScopeTerm(
+ Ci.nsMsgSearchScope.offlineMail,
+ this._currentFolderToIndex
+ );
+ let nsMsgSearchAttrib = Ci.nsMsgSearchAttrib;
+ let nsMsgSearchOp = Ci.nsMsgSearchOp;
+ // first term: (_hdrIndexProperty < reindexTime)
+ let searchTerm = searchSession.createTerm();
+ searchTerm.booleanAnd = false; // actually don't care here
+ searchTerm.attrib = nsMsgSearchAttrib.Uint32HdrProperty;
+ searchTerm.op = nsMsgSearchOp.IsLessThan;
+ let value = searchTerm.value;
+ value.attrib = searchTerm.attrib;
+ searchTerm.hdrProperty = this._hdrIndexedProperty;
+ value.status = reindexTime;
+ searchTerm.value = value;
+ searchTerms.push(searchTerm);
+ this._headerEnumerator =
+ this._currentFolderToIndex.msgDatabase.getFilterEnumerator(
+ searchTerms
+ );
+ }
+
+ // iterate over the folder finding the next message to index
+ for (let msgHdr of this._headerEnumerator) {
+ // Check if the file exists. If it does, then assume indexing to be
+ // complete for this file
+ if (this._getSupportFile(msgHdr).exists()) {
+ this._log.debug(
+ "Message time not set but file exists; setting " +
+ "time to " +
+ reindexTime
+ );
+ msgHdr.setUint32Property(this._hdrIndexedProperty, reindexTime);
+ } else {
+ return [msgHdr, reindexTime];
+ }
+ }
+ } catch (ex) {
+ this._log.debug("Error while finding next header: " + ex);
+ }
+
+ // If we couldn't find any headers to index, null out the enumerator
+ this._headerEnumerator = null;
+ if (!(this._currentFolderToIndex.flags & Ci.nsMsgFolderFlags.Inbox)) {
+ this._currentFolderToIndex.msgDatabase = null;
+ }
+ return null;
+ },
+
+ /**
+ * Get the last reindex time for this folder. This will be whichever's
+ * greater, the global reindex time or the folder reindex time
+ */
+ _getLastReindexTime(aFolder) {
+ let reindexTime = this.globalReindexTime;
+
+ // Check if this folder has a separate string property set
+ let folderReindexTime;
+ try {
+ folderReindexTime = this._currentFolderToIndex.getStringProperty(
+ this._hdrIndexedProperty
+ );
+ } catch (e) {
+ folderReindexTime = "";
+ }
+
+ if (folderReindexTime.length > 0) {
+ let folderReindexTimeInt = parseInt(folderReindexTime);
+ if (folderReindexTimeInt > reindexTime) {
+ reindexTime = folderReindexTimeInt;
+ }
+ }
+ return reindexTime;
+ },
+
+ /**
+ * Whether background indexing has been completed
+ */
+ __backgroundIndexingDone: false,
+
+ /**
+ * The main background sweeping function. It first looks for a folder to
+ * start or continue indexing in, then for a header. If it can't find anything
+ * to index, it resets the last folder indexed URI so that the sweep can
+ * be restarted
+ */
+ _continueSweep() {
+ let msgHdrAndReindexTime = null;
+
+ if (this.__backgroundIndexingDone) {
+ return;
+ }
+
+ // find the current folder we're working on
+ if (!this._currentFolderToIndex) {
+ this._currentFolderToIndex = this._foldersToIndex.next().value;
+ }
+
+ // we'd like to index more than one message on each timer fire,
+ // but since streaming is async, it's hard to know how long
+ // it's going to take to stream any particular message.
+ if (this._currentFolderToIndex) {
+ msgHdrAndReindexTime = this._findNextHdrToIndex();
+ } else {
+ // We've cycled through all the folders. We should take a break
+ // from indexing of existing messages.
+ this.__backgroundIndexingDone = true;
+ }
+
+ if (!msgHdrAndReindexTime) {
+ this._log.debug("reached end of folder");
+ if (this._currentFolderToIndex) {
+ this._currentFolderToIndex = null;
+ }
+ } else {
+ this._queueMessage(msgHdrAndReindexTime[0], msgHdrAndReindexTime[1]);
+ }
+
+ // Restart the timer, and call ourselves
+ this._cancelTimer();
+ this._timer.initWithCallback(
+ this._wrapContinueSweep,
+ this._msgHdrsToIndex.length > 1 ? 5000 : 1000,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ /**
+ * A simple wrapper to make "this" be right for _continueSweep
+ */
+ _wrapContinueSweep() {
+ SearchIntegration._continueSweep();
+ },
+
+ /**
+ * Observer implementation. Consists of
+ * - idle observer; starts running through folders when it receives an "idle"
+ * notification, and cancels any timers when it receives a "back" notification
+ * - msg displayed observer, queues the message if necessary
+ * - pref observer, to see if the preference has been poked
+ */
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "idle") {
+ this._log.debug("Idle detected, continuing sweep");
+ this._continueSweep();
+ } else if (aTopic == "back") {
+ this._log.debug("Non-idle, so suspending sweep");
+ this._cancelTimer();
+ } else if (aTopic == "MsgMsgDisplayed") {
+ this._log.debug("topic = " + aTopic + " uri = " + aData);
+ let msgHdr = this._messenger.msgHdrFromURI(aData);
+ let reindexTime = this._getLastReindexTime(msgHdr.folder);
+ this._log.debug("Reindex time for this folder is " + reindexTime);
+ if (msgHdr.getUint32Property(this._hdrIndexedProperty) < reindexTime) {
+ // Check if the file exists. If it does, then assume indexing to be
+ // complete for this file
+ if (this._getSupportFile(msgHdr).exists()) {
+ this._log.debug(
+ "Message time not set but file exists; setting " +
+ " time to " +
+ reindexTime
+ );
+ msgHdr.setUint32Property(this._hdrIndexedProperty, reindexTime);
+ } else {
+ this._queueMessage(msgHdr, reindexTime);
+ }
+ }
+ } else if (aTopic == "nsPref:changed" && aData == "enable") {
+ let prefEnabled = this.prefEnabled;
+ // Search integration turned on
+ if (prefEnabled && this.register()) {
+ this.enabled = true;
+ } else if (!prefEnabled && this.deregister()) {
+ // Search integration turned off
+ this.enabled = false;
+ } else {
+ // The call to register or deregister has failed.
+ // This is a hack to handle this case
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(
+ function () {
+ SearchIntegration._handleRegisterFailure(!prefEnabled);
+ },
+ 200,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ }
+ }
+ },
+
+ // Handle failure to register or deregister
+ _handleRegisterFailure(enabled) {
+ // Remove ourselves from the observer list, flip the pref,
+ // and add ourselves back
+ this._prefBranch.removeObserver("enable", this);
+ this.prefEnabled = enabled;
+ this._prefBranch.addObserver("enable", this);
+ },
+
+ /**
+ * This object gets notifications for new/moved/copied/deleted messages/folders
+ */
+ _msgFolderListener: {
+ msgAdded(aMsg) {
+ SearchIntegration._log.info("in msgAdded");
+ // The message already being there is an expected case
+ let file = SearchIntegration._getSupportFile(aMsg);
+ if (!file.exists()) {
+ SearchIntegration._queueMessage(
+ aMsg,
+ SearchIntegration._getLastReindexTime(aMsg.folder)
+ );
+ }
+ },
+
+ msgsDeleted(aMsgs) {
+ SearchIntegration._log.info("in msgsDeleted");
+ for (let msgHdr of aMsgs) {
+ let file = SearchIntegration._getSupportFile(msgHdr);
+ if (file.exists()) {
+ file.remove(false);
+ }
+ }
+ },
+
+ msgsMoveCopyCompleted(aMove, aSrcMsgs, aDestFolder) {
+ SearchIntegration._log.info("in msgsMoveCopyCompleted, aMove = " + aMove);
+ // Forget about copies if disabled
+ if (!aMove && !this.enabled) {
+ return;
+ }
+
+ let count = aSrcMsgs.length;
+ for (let i = 0; i < count; i++) {
+ let srcFile = SearchIntegration._getSupportFile(aSrcMsgs[i]);
+ if (srcFile && srcFile.exists()) {
+ let destFile = SearchIntegration._getSearchPathForFolder(aDestFolder);
+ destFile.leafName = destFile.leafName + ".mozmsgs";
+ if (!destFile.exists()) {
+ try {
+ // create the directory, if it doesn't exist
+ destFile.create(Ci.nsIFile.DIRECTORY_TYPE, PERM_DIRECTORY);
+ } catch (ex) {
+ SearchIntegration._log.warn(ex);
+ }
+ }
+ SearchIntegration._log.debug("dst file path = " + destFile.path);
+ SearchIntegration._log.debug("src file path = " + srcFile.path);
+ // We're not going to copy in case we're not in active mode
+ if (destFile.exists()) {
+ if (aMove) {
+ srcFile.moveTo(destFile, "");
+ } else {
+ srcFile.copyTo(destFile, "");
+ }
+ }
+ }
+ }
+ },
+
+ folderDeleted(aFolder) {
+ SearchIntegration._log.info(
+ "in folderDeleted, folder name = " + aFolder.prettyName
+ );
+ let srcFile = SearchIntegration._getSearchPathForFolder(aFolder);
+ srcFile.leafName = srcFile.leafName + ".mozmsgs";
+ if (srcFile.exists()) {
+ srcFile.remove(true);
+ }
+ },
+
+ folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) {
+ SearchIntegration._log.info(
+ "in folderMoveCopyCompleted, aMove = " + aMove
+ );
+
+ // Forget about copies if disabled
+ if (!aMove && !this.enabled) {
+ return;
+ }
+
+ let srcFile = SearchIntegration._getSearchPathForFolder(aSrcFolder);
+ let destFile = SearchIntegration._getSearchPathForFolder(aDestFolder);
+ srcFile.leafName = srcFile.leafName + ".mozmsgs";
+ destFile.leafName += ".sbd";
+ SearchIntegration._log.debug("src file path = " + srcFile.path);
+ SearchIntegration._log.debug("dst file path = " + destFile.path);
+ if (srcFile.exists()) {
+ // We're not going to copy if we aren't in active mode
+ if (aMove) {
+ srcFile.moveTo(destFile, "");
+ } else {
+ srcFile.copyTo(destFile, "");
+ }
+ }
+ },
+
+ folderRenamed(aOrigFolder, aNewFolder) {
+ SearchIntegration._log.info(
+ "in folderRenamed, aOrigFolder = " +
+ aOrigFolder.prettyName +
+ ", aNewFolder = " +
+ aNewFolder.prettyName
+ );
+ let srcFile = SearchIntegration._getSearchPathForFolder(aOrigFolder);
+ srcFile.leafName = srcFile.leafName + ".mozmsgs";
+ let destName = aNewFolder.name + ".mozmsgs";
+ SearchIntegration._log.debug("src file path = " + srcFile.path);
+ SearchIntegration._log.debug("dst name = " + destName);
+ if (srcFile.exists()) {
+ srcFile.moveTo(null, destName);
+ }
+ },
+ },
+
+ /*
+ * Support functions to queue/generate files
+ */
+ _queueMessage(msgHdr, reindexTime) {
+ if (this._msgHdrsToIndex.push([msgHdr, reindexTime]) == 1) {
+ this._log.info("generating support file for id = " + msgHdr.messageId);
+ this._streamListener.startStreaming(msgHdr, reindexTime);
+ } else {
+ this._log.info(
+ "queueing support file generation for id = " + msgHdr.messageId
+ );
+ }
+ },
+
+ /**
+ * Handle results from the command line. This method is the inverse of the
+ * _getSupportFile method below.
+ *
+ * @param aFile the file passed in by the command line
+ * @returns the nsIMsgDBHdr corresponding to the file passed in
+ */
+ handleResult(aFile) {
+ // The file path has two components -- the search path, which needs to be
+ // converted into a folder, and the message ID.
+ let searchPath = aFile.parent;
+ // Strip off ".mozmsgs" from the end (8 characters)
+ searchPath.leafName = searchPath.leafName.slice(0, -8);
+
+ let folder = this._getFolderForSearchPath(searchPath);
+
+ // Get rid of the file extension at the end (7 characters), and unescape
+ let messageID = decodeURIComponent(aFile.leafName.slice(0, -7));
+
+ // Look for the message ID in the folder
+ return folder.msgDatabase.getMsgHdrForMessageID(messageID);
+ },
+
+ _getSupportFile(msgHdr) {
+ let folder = msgHdr.folder;
+ if (folder) {
+ let messageId = encodeURIComponent(msgHdr.messageId);
+ this._log.debug("encoded message id = " + messageId);
+ let file = this._getSearchPathForFolder(folder);
+ file.leafName = file.leafName + ".mozmsgs";
+ file.appendRelativePath(messageId + this._fileExt);
+ this._log.debug("getting support file path = " + file.path);
+ return file;
+ }
+ return null;
+ },
+
+ /**
+ * Base to use for stream listeners, extended by the respective
+ * implementations
+ */
+ _streamListenerBase: {
+ // Output file
+ _outputFile: null,
+
+ // Stream to use to write to the output file
+ __outputStream: null,
+ set _outputStream(stream) {
+ if (this.__outputStream) {
+ this.__outputStream.close();
+ }
+ this.__outputStream = stream;
+ },
+ get _outputStream() {
+ return this.__outputStream;
+ },
+
+ // Reference to message header
+ _msgHdr: null,
+
+ // Reindex time for this message header
+ _reindexTime: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ // "Finish" function, cleans up behind itself if unsuccessful
+ _onDoneStreaming(successful) {
+ this._outputStream = null;
+ if (!successful && this._msgHdr) {
+ let file = SearchIntegration._getSupportFile(this._msgHdr);
+ if (file && file.exists()) {
+ file.remove(false);
+ }
+ }
+ // should we try to delete the file on disk in case not successful?
+ SearchIntegration._msgHdrsToIndex.shift();
+
+ if (SearchIntegration._msgHdrsToIndex.length > 0) {
+ let [msgHdr, reindexTime] = SearchIntegration._msgHdrsToIndex[0];
+ this.startStreaming(msgHdr, reindexTime);
+ }
+ },
+
+ // "Start" function
+ startStreaming(msgHdr, reindexTime) {
+ try {
+ let folder = msgHdr.folder;
+ if (folder) {
+ let messageId = encodeURIComponent(msgHdr.messageId);
+ SearchIntegration._log.info(
+ "generating support file, id = " + messageId
+ );
+ let file = SearchIntegration._getSearchPathForFolder(folder);
+
+ file.leafName = file.leafName + ".mozmsgs";
+ SearchIntegration._log.debug("file leafname = " + file.leafName);
+ if (!file.exists()) {
+ try {
+ // create the directory, if it doesn't exist
+ file.create(Ci.nsIFile.DIRECTORY_TYPE, PERM_DIRECTORY);
+ } catch (ex) {
+ this._log.error(ex);
+ }
+ }
+
+ file.appendRelativePath(messageId + SearchIntegration._fileExt);
+ SearchIntegration._log.debug("file path = " + file.path);
+ file.create(0, PERM_FILE);
+ let uri = folder.getUriForMsg(msgHdr);
+ let msgService = MailServices.messageServiceFromURI(uri);
+ this._msgHdr = msgHdr;
+ this._outputFile = file;
+ this._reindexTime = reindexTime;
+ try {
+ // XXX For now, try getting the messages from the server. This has
+ // to be improved so that we don't generate any excess network
+ // traffic
+ msgService.streamMessage(uri, this, null, null, false, "", false);
+ } catch (ex) {
+ // This is an expected case, in case we're offline
+ SearchIntegration._log.warn(
+ "StreamMessage unsuccessful for id = " + messageId
+ );
+ this._onDoneStreaming(false);
+ }
+ }
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ }
+ },
+ },
+
+ /**
+ * Logging functionality, shamelessly ripped from gloda
+ * If enabled, warnings and above are logged to the error console, while dump
+ * gets everything
+ */
+ _log: null,
+ _initLogging() {
+ this._log = console.createInstance({
+ prefix: this._prefBase.slice(0, -1),
+ maxLogLevel: "Warn",
+ maxLogLevelPref: `${this._prefBase}loglevel`,
+ });
+ this._log.info("Logging initialized");
+ },
+};
+
+if (AppConstants.platform == "win") {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/WinSearchIntegration.js"
+ );
+} else if (AppConstants.platform == "macosx") {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/SpotlightIntegration.js"
+ );
+}
diff --git a/comm/mail/components/search/components.conf b/comm/mail/components/search/components.conf
new file mode 100644
index 0000000000..f34fbdc04d
--- /dev/null
+++ b/comm/mail/components/search/components.conf
@@ -0,0 +1,16 @@
+# 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/.
+
+Classes = []
+
+if buildconfig.substs["OS_ARCH"] == "WINNT":
+ Classes += [
+ {
+ "cid": "{5dd31c99-08c7-4a3b-aeb3-d2e60665a31a}",
+ "contract_ids": ["@mozilla.org/mail/windows-search-helper;1"],
+ "type": "nsMailWinSearchHelper",
+ "init_method": "Init",
+ "headers": ["/comm/mail/components/search/nsMailWinSearchHelper.h"],
+ },
+ ]
diff --git a/comm/mail/components/search/content/SpotlightIntegration.js b/comm/mail/components/search/content/SpotlightIntegration.js
new file mode 100644
index 0000000000..0757800ee6
--- /dev/null
+++ b/comm/mail/components/search/content/SpotlightIntegration.js
@@ -0,0 +1,240 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+// SearchIntegration.jsm
+/* globals SearchIntegration, SearchSupport, Services */
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var MSG_DB_LARGE_COMMIT = 1;
+var gFileHeader =
+ '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.\ncom/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>';
+
+// eslint-disable-next-line no-global-assign
+SearchIntegration = {
+ __proto__: SearchSupport,
+
+ // The property of the header and (sometimes) folders that's used to check
+ // if a message is indexed
+ _hdrIndexedProperty: "spotlight_reindex_time",
+
+ // The file extension that is used for support files of this component
+ _fileExt: ".mozeml",
+
+ // The Spotlight pref base
+ _prefBase: "mail.spotlight.",
+
+ // The user's profile dir, which we'll cache and use a lot for path clean-up
+ get _profileDir() {
+ delete this._profileDir;
+ return (this._profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile));
+ },
+
+ get _metadataDir() {
+ delete this._metadataDir;
+ let metadataDir = Services.dirsvc.get("Home", Ci.nsIFile);
+ metadataDir.append("Library");
+ metadataDir.append("Caches");
+ metadataDir.append("Metadata");
+ metadataDir.append("Thunderbird");
+ return (this._metadataDir = metadataDir);
+ },
+
+ // Spotlight won't index files in the profile dir, but will use ~/Library/Caches/Metadata
+ _getSearchPathForFolder(aFolder) {
+ // Swap the metadata dir for the profile dir prefix in the folder's path
+ let folderPath = aFolder.filePath.path;
+ let fixedPath = folderPath.replace(
+ this._profileDir.path,
+ this._metadataDir.path
+ );
+ let searchPath = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ searchPath.initWithPath(fixedPath);
+ return searchPath;
+ },
+
+ // Replace ~/Library/Caches/Metadata with the profile directory, then convert
+ _getFolderForSearchPath(aPath) {
+ let folderPath = aPath.path.replace(
+ this._metadataDir.path,
+ this._profileDir.path
+ );
+ let folderFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ folderFile.initWithPath(folderPath);
+ return MailUtils.getFolderForFileInProfile(folderFile);
+ },
+
+ _pathNeedsReindexing(aPath) {
+ // We used to set permissions incorrectly (see bug 670566).
+ const PERM_DIRECTORY = parseInt("0755", 8);
+ if (aPath.permissions != PERM_DIRECTORY) {
+ aPath.permissions = PERM_DIRECTORY;
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * These two functions won't do anything, as Spotlight integration is handled
+ * using Info.plist files
+ */
+ register() {
+ return true;
+ },
+
+ deregister() {
+ return true;
+ },
+
+ _init() {
+ this._initLogging();
+
+ let enabled = this._prefBranch.getBoolPref("enable", false);
+ if (enabled) {
+ this._log.info("Initializing Spotlight integration");
+ }
+ this._initSupport(enabled);
+ },
+
+ // The stream listener to read messages
+ _streamListener: {
+ __proto__: SearchSupport._streamListenerBase,
+
+ // Buffer to store the message
+ _message: null,
+
+ // Encodes reserved XML characters
+ _xmlEscapeString(s) {
+ return s.replace(/[<>&]/g, function (s) {
+ switch (s) {
+ case "<":
+ return "&lt;";
+ case ">":
+ return "&gt;";
+ case "&":
+ return "&amp;";
+ default:
+ throw new Error("Unexpected match");
+ }
+ });
+ },
+
+ onStartRequest(request) {
+ try {
+ let outputFileStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ outputFileStream.init(this._outputFile, -1, -1, 0);
+ this._outputStream = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ this._outputStream.init(outputFileStream, "UTF-8");
+
+ this._outputStream.writeString(gFileHeader);
+ this._outputStream.writeString(
+ "<key>kMDItemLastUsedDate</key><string>"
+ );
+ // need to write the date as a string
+ let curTimeStr = new Date().toLocaleString();
+ this._outputStream.writeString(curTimeStr);
+
+ // need to write the subject in utf8 as the title
+ this._outputStream.writeString(
+ "</string>\n<key>kMDItemTitle</key>\n<string>"
+ );
+
+ let escapedSubject = this._xmlEscapeString(
+ this._msgHdr.mime2DecodedSubject
+ );
+ this._outputStream.writeString(escapedSubject);
+
+ this._outputStream.writeString(
+ "</string>\n<key>kMDItemDisplayName</key>\n<string>"
+ );
+ this._outputStream.writeString(escapedSubject);
+
+ this._outputStream.writeString(
+ "</string>\n<key>kMDItemTextContent</key>\n<string>"
+ );
+ this._outputStream.writeString(
+ this._xmlEscapeString(this._msgHdr.mime2DecodedAuthor)
+ );
+ this._outputStream.writeString(
+ this._xmlEscapeString(this._msgHdr.mime2DecodedRecipients)
+ );
+
+ this._outputStream.writeString(escapedSubject);
+ this._outputStream.writeString(" ");
+ } catch (ex) {
+ this._onDoneStreaming(false);
+ }
+ },
+
+ onStopRequest(request, status) {
+ try {
+ // we want to write out the from, to, cc, and subject headers into the
+ // Text Content value, so they'll be indexed.
+ let stringStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ stringStream.setData(this._message, this._message.length);
+ let folder = this._msgHdr.folder;
+ let text = folder.getMsgTextFromStream(
+ stringStream,
+ this._msgHdr.charset,
+ 20000,
+ 20000,
+ false,
+ true,
+ {}
+ );
+ text = this._xmlEscapeString(text);
+ SearchIntegration._log.debug(
+ "escaped text = *****************\n" + text
+ );
+ this._outputStream.writeString(text);
+ // close out the content, dict, and plist
+ this._outputStream.writeString("</string>\n</dict>\n</plist>\n");
+
+ this._msgHdr.setUint32Property(
+ SearchIntegration._hdrIndexedProperty,
+ this._reindexTime
+ );
+ folder.msgDatabase.commit(MSG_DB_LARGE_COMMIT);
+
+ this._message = "";
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ return;
+ }
+ this._onDoneStreaming(true);
+ },
+
+ onDataAvailable(request, inputStream, offset, count) {
+ try {
+ let inStream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ inStream.init(inputStream);
+
+ // It is necessary to read in data from the input stream
+ let inData = inStream.read(count);
+
+ // ignore stuff after the first 20K or so
+ if (this._message && this._message.length > 20000) {
+ return;
+ }
+
+ this._message += inData;
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ }
+ },
+ },
+};
+
+SearchIntegration._init();
diff --git a/comm/mail/components/search/content/WinSearchIntegration.js b/comm/mail/components/search/content/WinSearchIntegration.js
new file mode 100644
index 0000000000..2f4c51b0e3
--- /dev/null
+++ b/comm/mail/components/search/content/WinSearchIntegration.js
@@ -0,0 +1,346 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+// SearchIntegration.jsm
+/* globals SearchIntegration, SearchSupport, Services */
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var MSG_DB_LARGE_COMMIT = 1;
+var CRLF = "\r\n";
+
+/**
+ * Required to access the 64-bit registry, even though we're probably a 32-bit
+ * program
+ */
+var ACCESS_WOW64_64KEY = 0x0100;
+
+/**
+ * The contract ID for the helper service.
+ */
+var WINSEARCHHELPER_CONTRACTID = "@mozilla.org/mail/windows-search-helper;1";
+
+/**
+ * All the registry keys required for integration
+ */
+var gRegKeys = [
+ // This is the property handler
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ key: "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\PropertySystem\\PropertyHandlers\\.wdseml",
+ name: "",
+ value: "{5FA29220-36A1-40f9-89C6-F4B384B7642E}",
+ },
+ // These two are the association with the MIME IFilter
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT,
+ key: ".wdseml",
+ name: "Content Type",
+ value: "message/rfc822",
+ },
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT,
+ key: ".wdseml\\PersistentHandler",
+ name: "",
+ value: "{5645c8c4-e277-11cf-8fda-00aa00a14f93}",
+ },
+ // This is the association with the Windows mail preview handler
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT,
+ key: ".wdseml\\shellex\\{8895B1C6-B41F-4C1C-A562-0D564250836F}",
+ name: "",
+ value: "{b9815375-5d7f-4ce2-9245-c9d4da436930}",
+ },
+ // This is the association made to display results under email
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ key: "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\explorer\\KindMap",
+ name: ".wdseml",
+ value: "email;communication",
+ },
+];
+
+/**
+ * @namespace Windows Search-specific desktop search integration functionality
+ */
+// eslint-disable-next-line no-global-assign
+SearchIntegration = {
+ __proto__: SearchSupport,
+
+ // The property of the header and (sometimes) folders that's used to check
+ // if a message is indexed
+ _hdrIndexedProperty: "winsearch_reindex_time",
+
+ // The file extension that is used for support files of this component
+ _fileExt: ".wdseml",
+
+ // The Windows Search pref base
+ _prefBase: "mail.winsearch.",
+
+ // Helper (native) component
+ __winSearchHelper: null,
+ get _winSearchHelper() {
+ if (!this.__winSearchHelper) {
+ this.__winSearchHelper = Cc[WINSEARCHHELPER_CONTRACTID].getService(
+ Ci.nsIMailWinSearchHelper
+ );
+ }
+ return this.__winSearchHelper;
+ },
+
+ // Whether the folders are already in the crawl scope
+ get _foldersInCrawlScope() {
+ return this._winSearchHelper.foldersInCrawlScope;
+ },
+
+ /**
+ * Whether all the required registry keys are present
+ * We'll be optimistic here and assume that once the registry keys have been
+ * added, they won't be removed, at least while Thunderbird is open
+ */
+ __regKeysPresent: false,
+ get _regKeysPresent() {
+ if (!this.__regKeysPresent) {
+ for (let i = 0; i < gRegKeys.length; i++) {
+ let regKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ try {
+ regKey.open(
+ gRegKeys[i].root,
+ gRegKeys[i].key,
+ regKey.ACCESS_READ | ACCESS_WOW64_64KEY
+ );
+ } catch (e) {
+ return false;
+ }
+ let valuePresent =
+ regKey.hasValue(gRegKeys[i].name) &&
+ regKey.readStringValue(gRegKeys[i].name) == gRegKeys[i].value;
+ regKey.close();
+ if (!valuePresent) {
+ return false;
+ }
+ }
+ this.__regKeysPresent = true;
+ }
+ return true;
+ },
+
+ // Use the folder's path (i.e., in profile dir) as is
+ _getSearchPathForFolder(aFolder) {
+ return aFolder.filePath;
+ },
+
+ // Use the search path as is
+ _getFolderForSearchPath(aDir) {
+ return MailUtils.getFolderForFileInProfile(aDir);
+ },
+
+ _pathNeedsReindexing(aPath) {
+ // only needed on MacOSX (see bug 670566).
+ return false;
+ },
+
+ _init() {
+ this._initLogging();
+ // If the helper service isn't present, we weren't compiled with the needed
+ // support. Mark ourselves null and return
+ if (!(WINSEARCHHELPER_CONTRACTID in Cc)) {
+ SearchIntegration = null; // eslint-disable-line no-global-assign
+ return;
+ }
+
+ // The search module is currently only enabled on Vista and above,
+ // and the app can only be installed on Windows 7 and above.
+ this.osVersionTooLow = false;
+
+ let serviceRunning = false;
+ try {
+ serviceRunning = this._winSearchHelper.serviceRunning;
+ } catch (e) {}
+ // If the service isn't running, then we should stay in backoff mode
+ if (!serviceRunning) {
+ this._log.info("Windows Search service not running");
+ this.osComponentsNotRunning = true;
+ this._initSupport(false);
+ return;
+ }
+
+ let enabled = this.prefEnabled;
+
+ if (enabled) {
+ this._log.info("Initializing Windows Search integration");
+ }
+ this._initSupport(enabled);
+ },
+
+ /**
+ * Add necessary hooks to Windows
+ *
+ * @returns false if registration did not succeed, because the elevation
+ * request was denied
+ */
+ register() {
+ // If any of the two are not present, we need to elevate.
+ if (!this._foldersInCrawlScope || !this._regKeysPresent) {
+ try {
+ this._winSearchHelper.runSetup(true);
+ } catch (e) {
+ return false;
+ }
+ }
+
+ if (!this._winSearchHelper.isFileAssociationSet) {
+ try {
+ this._winSearchHelper.setFileAssociation();
+ } catch (e) {
+ this._log.warn("File association not set");
+ }
+ }
+ // Also set the FANCI bit to 0 for the profile directory
+ let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ this._winSearchHelper.setFANCIBit(profD, false, true);
+
+ return true;
+ },
+
+ /**
+ * Remove integration from Windows. The only thing removed is the directory
+ * from the index list. This will ask for elevation.
+ *
+ * @returns false if deregistration did not succeed, because the elevation
+ * request was denied
+ */
+ deregister() {
+ try {
+ this._winSearchHelper.runSetup(false);
+ } catch (e) {
+ return false;
+ }
+
+ return true;
+ },
+
+ // The stream listener to read messages
+ _streamListener: {
+ __proto__: SearchSupport._streamListenerBase,
+
+ // Buffer to store the message
+ _message: "",
+
+ onStartRequest(request) {
+ try {
+ let outputFileStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ outputFileStream.init(this._outputFile, -1, -1, 0);
+ this._outputStream = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ this._outputStream.init(outputFileStream, "UTF-8");
+ } catch (ex) {
+ this._onDoneStreaming(false);
+ }
+ },
+
+ onStopRequest(request, status) {
+ try {
+ // XXX Once the JS emitter gets checked in, this code should probably be
+ // switched over to use that
+ // Decode using getMsgTextFromStream
+ let stringStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ stringStream.setData(this._message, this._message.length);
+ let contentType = {};
+ let folder = this._msgHdr.folder;
+ let text = folder.getMsgTextFromStream(
+ stringStream,
+ this._msgHdr.charset,
+ 65536,
+ 50000,
+ false,
+ false,
+ contentType
+ );
+
+ // To get the Received header, we need to parse the message headers.
+ // We only need the first header, which contains the latest received
+ // date
+ let headers = this._message.split(/\r\n\r\n|\r\r|\n\n/, 1)[0];
+ let mimeHeaders = Cc[
+ "@mozilla.org/messenger/mimeheaders;1"
+ ].createInstance(Ci.nsIMimeHeaders);
+ mimeHeaders.initialize(headers);
+ let receivedHeader = mimeHeaders.extractHeader("Received", false);
+
+ this._outputStream.writeString("From: " + this._msgHdr.author + CRLF);
+ // If we're a newsgroup, then add the name of the folder as the
+ // newsgroups header
+ if (folder instanceof Ci.nsIMsgNewsFolder) {
+ this._outputStream.writeString("Newsgroups: " + folder.name + CRLF);
+ } else {
+ this._outputStream.writeString(
+ "To: " + this._msgHdr.recipients + CRLF
+ );
+ }
+ this._outputStream.writeString("CC: " + this._msgHdr.ccList + CRLF);
+ this._outputStream.writeString(
+ "Subject: " + this._msgHdr.subject + CRLF
+ );
+ if (receivedHeader) {
+ this._outputStream.writeString("Received: " + receivedHeader + CRLF);
+ }
+ this._outputStream.writeString(
+ "Date: " + new Date(this._msgHdr.date / 1000).toUTCString() + CRLF
+ );
+ this._outputStream.writeString(
+ "Content-Type: " + contentType.value + "; charset=utf-8" + CRLF + CRLF
+ );
+
+ this._outputStream.writeString(text + CRLF + CRLF);
+
+ this._msgHdr.setUint32Property(
+ SearchIntegration._hdrIndexedProperty,
+ this._reindexTime
+ );
+ folder.msgDatabase.commit(MSG_DB_LARGE_COMMIT);
+
+ this._message = "";
+ SearchIntegration._log.info("Successfully written file");
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ return;
+ }
+ this._onDoneStreaming(true);
+ },
+
+ onDataAvailable(request, inputStream, offset, count) {
+ try {
+ let inStream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ inStream.init(inputStream);
+
+ // It is necessary to read in data from the input stream
+ let inData = inStream.read(count);
+
+ // Ignore stuff after the first 50K or so
+ if (this._message && this._message.length > 50000) {
+ return;
+ }
+
+ this._message += inData;
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ }
+ },
+ },
+};
+
+SearchIntegration._init();
diff --git a/comm/mail/components/search/extensions/allaannonser-sv-SE/favicon.ico b/comm/mail/components/search/extensions/allaannonser-sv-SE/favicon.ico
new file mode 100644
index 0000000000..3917eaec10
--- /dev/null
+++ b/comm/mail/components/search/extensions/allaannonser-sv-SE/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/allaannonser-sv-SE/manifest.json b/comm/mail/components/search/extensions/allaannonser-sv-SE/manifest.json
new file mode 100644
index 0000000000..a5b5914634
--- /dev/null
+++ b/comm/mail/components/search/extensions/allaannonser-sv-SE/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Allaannonser",
+ "description": "Allaannonser",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "allaannonser-sv-SE@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Allaannonser",
+ "encoding": "ISO-8859-1",
+ "search_url": "https://www.allaannonser.se/hitlist.php",
+ "search_form": "https://www.allaannonser.se",
+ "search_url_get_params": "sourceid=Mozilla-search&keyword={searchTerms}&order=date&desc=1"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/allegro-pl/favicon.ico b/comm/mail/components/search/extensions/allegro-pl/favicon.ico
new file mode 100644
index 0000000000..42b4f90149
--- /dev/null
+++ b/comm/mail/components/search/extensions/allegro-pl/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/allegro-pl/manifest.json b/comm/mail/components/search/extensions/allegro-pl/manifest.json
new file mode 100644
index 0000000000..ad38f187dc
--- /dev/null
+++ b/comm/mail/components/search/extensions/allegro-pl/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Allegro",
+ "description": "Wyszukiwanie w aukcjach Allegro",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "allegro-pl@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Allegro",
+ "search_url": "https://allegro.pl/listing/listing.php",
+ "search_form": "https://allegro.pl",
+ "search_url_get_params": "string={searchTerms}&sourceid=Mozilla-search"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/au/messages.json b/comm/mail/components/search/extensions/amazon/_locales/au/messages.json
new file mode 100644
index 0000000000..c8fbcbcb69
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/au/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.com.au"
+ },
+ "extensionDescription": {
+ "message": "Amazon.com.au Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.com.au/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.com.au/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/ca/messages.json b/comm/mail/components/search/extensions/amazon/_locales/ca/messages.json
new file mode 100644
index 0000000000..cb54e55658
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/ca/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.ca"
+ },
+ "extensionDescription": {
+ "message": "Amazon.ca Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.ca/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.ca/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/de/messages.json b/comm/mail/components/search/extensions/amazon/_locales/de/messages.json
new file mode 100644
index 0000000000..e9eebaf229
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/de/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.de"
+ },
+ "extensionDescription": {
+ "message": "Amazon.de Suche"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.de/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.de/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/en-GB/messages.json b/comm/mail/components/search/extensions/amazon/_locales/en-GB/messages.json
new file mode 100644
index 0000000000..596283dd0d
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/en-GB/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.co.uk"
+ },
+ "extensionDescription": {
+ "message": "Amazon.co.uk Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.co.uk/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.co.uk/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/france/messages.json b/comm/mail/components/search/extensions/amazon/_locales/france/messages.json
new file mode 100644
index 0000000000..77730b1a40
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/france/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.fr"
+ },
+ "extensionDescription": {
+ "message": "Recherche Amazon.fr"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.fr/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.fr/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/in/messages.json b/comm/mail/components/search/extensions/amazon/_locales/in/messages.json
new file mode 100644
index 0000000000..d4f912cc96
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/in/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.in"
+ },
+ "extensionDescription": {
+ "message": "Amazon.in Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.in/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.in/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/it/messages.json b/comm/mail/components/search/extensions/amazon/_locales/it/messages.json
new file mode 100644
index 0000000000..07382eec95
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/it/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.it"
+ },
+ "extensionDescription": {
+ "message": "Ricerca Amazon.it"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.it/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.it/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/jp/messages.json b/comm/mail/components/search/extensions/amazon/_locales/jp/messages.json
new file mode 100644
index 0000000000..b3bebb43c2
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/jp/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.co.jp"
+ },
+ "extensionDescription": {
+ "message": "Amazon.co.jp Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.co.jp/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.co.jp/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/mx/messages.json b/comm/mail/components/search/extensions/amazon/_locales/mx/messages.json
new file mode 100644
index 0000000000..a70ed76634
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/mx/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.com.mx"
+ },
+ "extensionDescription": {
+ "message": "Amazon.com.mx Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.com.mx/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.com.mx/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/nl/messages.json b/comm/mail/components/search/extensions/amazon/_locales/nl/messages.json
new file mode 100644
index 0000000000..5d5e62e637
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/nl/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.nl"
+ },
+ "extensionDescription": {
+ "message": "Amazon.nl Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.nl/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.nl/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/favicon.ico b/comm/mail/components/search/extensions/amazon/favicon.ico
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/amazon/manifest.json b/comm/mail/components/search/extensions/amazon/manifest.json
new file mode 100644
index 0000000000..fd44cf29cb
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.1",
+ "applications": {
+ "gecko": {
+ "id": "amazon@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "au",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazondotcn/favicon.ico b/comm/mail/components/search/extensions/amazondotcn/favicon.ico
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcn/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/amazondotcn/manifest.json b/comm/mail/components/search/extensions/amazondotcn/manifest.json
new file mode 100644
index 0000000000..e6215d9660
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcn/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "亚马逊",
+ "description": "亚马逊æœç´¢",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "amazondotcn@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "亚马逊",
+ "search_url": "https://www.amazon.cn/mn/searchApp",
+ "search_form": "https://www.amazon.cn/",
+ "search_url_get_params": "keywords={searchTerms}&ix=sunray&pageletid=headsearch&searchType=&Go.x=0&Go.y=0&bestSaleNum=0"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazondotcom/_locales/en/messages.json b/comm/mail/components/search/extensions/amazondotcom/_locales/en/messages.json
new file mode 100644
index 0000000000..e1f3405dab
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcom/_locales/en/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Amazon.com"
+ },
+ "extensionDescription": {
+ "message": "Amazon.com Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.com/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.com/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://completion.amazon.com/search/complete?q={searchTerms}&search-alias=aps&mkt=1"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazondotcom/favicon.ico b/comm/mail/components/search/extensions/amazondotcom/favicon.ico
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcom/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/amazondotcom/manifest.json b/comm/mail/components/search/extensions/amazondotcom/manifest.json
new file mode 100644
index 0000000000..5def9f413d
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcom/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.1",
+ "applications": {
+ "gecko": {
+ "id": "amazondotcom@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "en",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "suggest_url": "__MSG_suggestUrl__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/atlas-sk/favicon.ico b/comm/mail/components/search/extensions/atlas-sk/favicon.ico
new file mode 100644
index 0000000000..eb4d3ec31a
--- /dev/null
+++ b/comm/mail/components/search/extensions/atlas-sk/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/atlas-sk/manifest.json b/comm/mail/components/search/extensions/atlas-sk/manifest.json
new file mode 100644
index 0000000000..774c021f91
--- /dev/null
+++ b/comm/mail/components/search/extensions/atlas-sk/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Atlas",
+ "description": "Internetovy portal - Atlas.sk",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "atlas-sk@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Atlas",
+ "search_url": "https://www.atlas.sk/search.php",
+ "search_form": "https://www.atlas.sk/",
+ "search_url_get_params": "phrase={searchTerms}&sourceid=firefox"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/azerdict/favicon.ico b/comm/mail/components/search/extensions/azerdict/favicon.ico
new file mode 100644
index 0000000000..ba687ca8e7
--- /dev/null
+++ b/comm/mail/components/search/extensions/azerdict/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/azerdict/manifest.json b/comm/mail/components/search/extensions/azerdict/manifest.json
new file mode 100644
index 0000000000..9454d14600
--- /dev/null
+++ b/comm/mail/components/search/extensions/azerdict/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Azerdict",
+ "description": "Azərbaycanın Online Lüğəti",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "azerdict@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Azerdict",
+ "search_url": "https://azerdict.com/english/",
+ "search_form": "https://azerdict.com/",
+ "search_url_get_params": "word={searchTerms}",
+ "suggest_url": "https://api.azerdict.com/english/autocomplete",
+ "suggest_url_get_params": "action=opensearch&query={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/azet-sk/favicon.ico b/comm/mail/components/search/extensions/azet-sk/favicon.ico
new file mode 100644
index 0000000000..39ab78bbd9
--- /dev/null
+++ b/comm/mail/components/search/extensions/azet-sk/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/azet-sk/manifest.json b/comm/mail/components/search/extensions/azet-sk/manifest.json
new file mode 100644
index 0000000000..84b6faa38d
--- /dev/null
+++ b/comm/mail/components/search/extensions/azet-sk/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Azet",
+ "description": "Azet - portal, kde je vzdy najviac ludi",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "azet-sk@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Azet",
+ "search_url": "https://www.azet.sk/katalog/vyhladavanie/firmy/",
+ "search_form": "https://www.azet.sk/katalog/",
+ "search_url_get_params": "q={searchTerms}&k="
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/baidu/favicon.ico b/comm/mail/components/search/extensions/baidu/favicon.ico
new file mode 100644
index 0000000000..6c27b018c8
--- /dev/null
+++ b/comm/mail/components/search/extensions/baidu/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/baidu/manifest.json b/comm/mail/components/search/extensions/baidu/manifest.json
new file mode 100644
index 0000000000..a53f905716
--- /dev/null
+++ b/comm/mail/components/search/extensions/baidu/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "百度",
+ "description": "百度网页æœç´¢",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "baidu@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "百度",
+ "search_url": "https://www.baidu.com/baidu",
+ "search_form": "https://www.baidu.com/",
+ "search_url_get_params": "wd={searchTerms}&ie=utf-8",
+ "suggest_url": "https://www.baidu.com/su",
+ "suggest_url_get_params": "wd={searchTerms}&ie=utf-8&action=opensearch"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/bbc-alba/favicon.ico b/comm/mail/components/search/extensions/bbc-alba/favicon.ico
new file mode 100644
index 0000000000..8f62b07af8
--- /dev/null
+++ b/comm/mail/components/search/extensions/bbc-alba/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/bbc-alba/manifest.json b/comm/mail/components/search/extensions/bbc-alba/manifest.json
new file mode 100644
index 0000000000..6de91c53c9
--- /dev/null
+++ b/comm/mail/components/search/extensions/bbc-alba/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "BBC â” BBC Alba",
+ "description": "Lorg BBC â” BBC Alba",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "bbc-alba@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "BBC â” BBC Alba",
+ "search_url": "https://search.bbc.co.uk/search",
+ "search_form": "https://www.bbc.co.uk/alba/",
+ "search_url_get_params": "opensearch=all-1&q={searchTerms}",
+ "suggest_url": "https://search.bbc.co.uk/suggest",
+ "suggest_url_get_params": "format=opensearch&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/bing/favicon.ico b/comm/mail/components/search/extensions/bing/favicon.ico
new file mode 100644
index 0000000000..1e90a10d6e
--- /dev/null
+++ b/comm/mail/components/search/extensions/bing/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/bing/manifest.json b/comm/mail/components/search/extensions/bing/manifest.json
new file mode 100644
index 0000000000..bc0a1060ab
--- /dev/null
+++ b/comm/mail/components/search/extensions/bing/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Bing",
+ "description": "Bing. Search by Microsoft.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "bing@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Bing",
+ "search_url": "https://www.bing.com/search",
+ "search_form": "https://www.bing.com/search?q={searchTerms}",
+ "search_url_get_params": "q={searchTerms}",
+ "suggest_url": "https://www.bing.com/osjson.aspx",
+ "suggest_url_get_params": "query={searchTerms}&form=OSDJAS&language={moz:locale}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/bok-NO/favicon.png b/comm/mail/components/search/extensions/bok-NO/favicon.png
new file mode 100644
index 0000000000..c2d46117ef
--- /dev/null
+++ b/comm/mail/components/search/extensions/bok-NO/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/bok-NO/manifest.json b/comm/mail/components/search/extensions/bok-NO/manifest.json
new file mode 100644
index 0000000000..fb0138103b
--- /dev/null
+++ b/comm/mail/components/search/extensions/bok-NO/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Ordbok",
+ "description": "Norske ordbøker",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "bok-NO@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Ordbok",
+ "search_url": "https://ordbok.uib.no/perl/ordbok.cgi",
+ "search_form": "https://ordbok.uib.no/",
+ "search_url_get_params": "OPP={searchTerms}&sourceid=Mozilla-search"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/bolcom/_locales/fy-NL/messages.json b/comm/mail/components/search/extensions/bolcom/_locales/fy-NL/messages.json
new file mode 100644
index 0000000000..252afad896
--- /dev/null
+++ b/comm/mail/components/search/extensions/bolcom/_locales/fy-NL/messages.json
@@ -0,0 +1,14 @@
+{
+ "extensionName": {
+ "message": "bol.com"
+ },
+ "extensionDescription": {
+ "message": "Sykje by bol.com"
+ },
+ "searchUrl": {
+ "message": "https://www.bol.com/nl/s/algemeen/zoekresultaten/Ntt/{searchTerms}/Ntk/media_all/Nty/1/suggestedFor/{searchTerms}/N/0/Ne/0/search/true/searchType/qck/index.html"
+ },
+ "searchForm": {
+ "message": "https://www.bol.com/"
+ }
+}
diff --git a/comm/mail/components/search/extensions/bolcom/_locales/nl/messages.json b/comm/mail/components/search/extensions/bolcom/_locales/nl/messages.json
new file mode 100644
index 0000000000..7e9baa8b3b
--- /dev/null
+++ b/comm/mail/components/search/extensions/bolcom/_locales/nl/messages.json
@@ -0,0 +1,14 @@
+{
+ "extensionName": {
+ "message": "bol.com"
+ },
+ "extensionDescription": {
+ "message": "Zoeken bij bol.com"
+ },
+ "searchUrl": {
+ "message": "https://www.bol.com/nl/s/algemeen/zoekresultaten/Ntt/{searchTerms}/Ntk/media_all/Nty/1/suggestedFor/{searchTerms}/N/0/Ne/0/search/true/searchType/qck/index.html"
+ },
+ "searchForm": {
+ "message": "https://www.bol.com/"
+ }
+}
diff --git a/comm/mail/components/search/extensions/bolcom/favicon.ico b/comm/mail/components/search/extensions/bolcom/favicon.ico
new file mode 100644
index 0000000000..0f0db9b990
--- /dev/null
+++ b/comm/mail/components/search/extensions/bolcom/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/bolcom/manifest.json b/comm/mail/components/search/extensions/bolcom/manifest.json
new file mode 100644
index 0000000000..25a08232e7
--- /dev/null
+++ b/comm/mail/components/search/extensions/bolcom/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "bolcom@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "fy-NL",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/ceneji/favicon.png b/comm/mail/components/search/extensions/ceneji/favicon.png
new file mode 100644
index 0000000000..3c77b64d3c
--- /dev/null
+++ b/comm/mail/components/search/extensions/ceneji/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/ceneji/manifest.json b/comm/mail/components/search/extensions/ceneji/manifest.json
new file mode 100644
index 0000000000..3c915c8b15
--- /dev/null
+++ b/comm/mail/components/search/extensions/ceneji/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Ceneje.si",
+ "description": "Iskalnik Ceneje.si",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "ceneji@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Ceneje.si",
+ "search_url": "https://www.ceneje.si/search_new.aspx",
+ "search_form": "https://www.ceneje.si",
+ "search_url_get_params": "q={searchTerms}&FF-SearchBox=1"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/chambers-en-GB/favicon.ico b/comm/mail/components/search/extensions/chambers-en-GB/favicon.ico
new file mode 100644
index 0000000000..ecea4aac74
--- /dev/null
+++ b/comm/mail/components/search/extensions/chambers-en-GB/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/chambers-en-GB/manifest.json b/comm/mail/components/search/extensions/chambers-en-GB/manifest.json
new file mode 100644
index 0000000000..17ce83d616
--- /dev/null
+++ b/comm/mail/components/search/extensions/chambers-en-GB/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Chambers (UK)",
+ "description": "Chambers 21st Century Dictionary Search",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "chambers-en-GB@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Chambers (UK)",
+ "search_url": "https://chambers.co.uk/search/",
+ "search_form": "https://chambers.co.uk/search/?query={searchTerms}&title=21st&sourceid=Mozilla-search",
+ "search_url_get_params": "query={searchTerms}&title=21st&sourceid=Mozilla-search"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/coccoc/favicon.ico b/comm/mail/components/search/extensions/coccoc/favicon.ico
new file mode 100644
index 0000000000..e6e82d938e
--- /dev/null
+++ b/comm/mail/components/search/extensions/coccoc/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/coccoc/manifest.json b/comm/mail/components/search/extensions/coccoc/manifest.json
new file mode 100644
index 0000000000..790d56badd
--- /dev/null
+++ b/comm/mail/components/search/extensions/coccoc/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Cốc Cốc",
+ "description": "Use Cốc Cốc to search on coccoc.com",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "coccoc@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Cốc Cốc",
+ "search_url": "https://coccoc.com/search",
+ "search_url_get_params": "query={searchTerms}&s=ff&utm_source=firefox",
+ "suggest_url": "https://coccoc.com/composer/autocomplete",
+ "suggest_url_get_params": "of=b&q={searchTerms}&s=ff"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/daum-kr/favicon.ico b/comm/mail/components/search/extensions/daum-kr/favicon.ico
new file mode 100644
index 0000000000..ed803f50e2
--- /dev/null
+++ b/comm/mail/components/search/extensions/daum-kr/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/daum-kr/manifest.json b/comm/mail/components/search/extensions/daum-kr/manifest.json
new file mode 100644
index 0000000000..1e0946be7a
--- /dev/null
+++ b/comm/mail/components/search/extensions/daum-kr/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "다ìŒ",
+ "description": "ë‹¤ìŒ ê²€ìƒ‰",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "daum-kr@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "다ìŒ",
+ "search_url": "https://search.daum.net/search",
+ "search_form": "https://search.daum.net",
+ "search_url_get_params": "q={searchTerms}&w=tot&nil_ch=ffsr",
+ "suggest_url": "https://sug.search.daum.net/search_nsuggest",
+ "suggest_url_get_params": "mod=fxjson&code=utf_in_out&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/ddg/favicon.ico b/comm/mail/components/search/extensions/ddg/favicon.ico
new file mode 100644
index 0000000000..dda80dfd88
--- /dev/null
+++ b/comm/mail/components/search/extensions/ddg/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/ddg/manifest.json b/comm/mail/components/search/extensions/ddg/manifest.json
new file mode 100644
index 0000000000..402a305684
--- /dev/null
+++ b/comm/mail/components/search/extensions/ddg/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "DuckDuckGo",
+ "description": "Search DuckDuckGo",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "ddg@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "DuckDuckGo",
+ "search_url": "https://duckduckgo.com/",
+ "search_form": "https://duckduckgo.com/?q={searchTerms}",
+ "search_url_get_params": "q={searchTerms}",
+ "suggest_url": "https://ac.duckduckgo.com/ac/",
+ "suggest_url_get_params": "q={searchTerms}&type=list"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/diec2/favicon.png b/comm/mail/components/search/extensions/diec2/favicon.png
new file mode 100644
index 0000000000..fa0fb8f1ff
--- /dev/null
+++ b/comm/mail/components/search/extensions/diec2/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/diec2/manifest.json b/comm/mail/components/search/extensions/diec2/manifest.json
new file mode 100644
index 0000000000..c4a11a0c36
--- /dev/null
+++ b/comm/mail/components/search/extensions/diec2/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "DIEC2",
+ "description": "Diccionari de l'Institut d'Estudis Catalans",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "diec2@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "DIEC2",
+ "encoding": "ISO-8859-1",
+ "search_url": "https://dlc.iec.cat/results.asp",
+ "search_form": "https://dlc.iec.cat",
+ "search_url_get_params": "txtEntrada={searchTerms}&OperEntrada=0"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/drae/favicon.ico b/comm/mail/components/search/extensions/drae/favicon.ico
new file mode 100644
index 0000000000..6b3a278678
--- /dev/null
+++ b/comm/mail/components/search/extensions/drae/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/drae/manifest.json b/comm/mail/components/search/extensions/drae/manifest.json
new file mode 100644
index 0000000000..e181545da1
--- /dev/null
+++ b/comm/mail/components/search/extensions/drae/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Diccionario RAE",
+ "description": "Real Academia Española. Diccionario Usual.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "drae@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Diccionario RAE",
+ "search_url": "https://dle.rae.es/",
+ "search_form": "https://dle.rae.es/?w={searchTerms}",
+ "search_url_get_params": "w={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/ecosia/favicon.ico b/comm/mail/components/search/extensions/ecosia/favicon.ico
new file mode 100644
index 0000000000..cc72d09d6d
--- /dev/null
+++ b/comm/mail/components/search/extensions/ecosia/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/ecosia/manifest.json b/comm/mail/components/search/extensions/ecosia/manifest.json
new file mode 100644
index 0000000000..a1dc0cf385
--- /dev/null
+++ b/comm/mail/components/search/extensions/ecosia/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Ecosia",
+ "description": "Search Ecosia",
+ "manifest_version": 2,
+ "version": "1.1",
+ "applications": {
+ "gecko": {
+ "id": "ecosia@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Ecosia",
+ "search_url": "https://www.ecosia.org/search",
+ "search_form": "https://www.ecosia.org/",
+ "search_url_get_params": "tt=mzl&q={searchTerms}",
+ "suggest_url": "https://ac.ecosia.org/autocomplete",
+ "suggest_url_get_params": "type=list&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/eki-ee/favicon.ico b/comm/mail/components/search/extensions/eki-ee/favicon.ico
new file mode 100644
index 0000000000..537829c30f
--- /dev/null
+++ b/comm/mail/components/search/extensions/eki-ee/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/eki-ee/manifest.json b/comm/mail/components/search/extensions/eki-ee/manifest.json
new file mode 100644
index 0000000000..c0c9ee7175
--- /dev/null
+++ b/comm/mail/components/search/extensions/eki-ee/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Õigekeelsussõnaraamat",
+ "description": "EKI.ee Eesti õigekeelsussõnaraamat ÕS 2013",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "eki-ee@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Õigekeelsussõnaraamat",
+ "search_url": "https://www.eki.ee/dict/qs/index.cgi",
+ "search_form": "https://www.eki.ee/dict/qs/",
+ "search_url_get_params": "F=M&Q={searchTerms}",
+ "suggest_url": "https://www.eki.ee/dict/soovita.cgi",
+ "suggest_url_get_params": "D=qs&Q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/eudict/favicon.ico b/comm/mail/components/search/extensions/eudict/favicon.ico
new file mode 100644
index 0000000000..20750d0c19
--- /dev/null
+++ b/comm/mail/components/search/extensions/eudict/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/eudict/manifest.json b/comm/mail/components/search/extensions/eudict/manifest.json
new file mode 100644
index 0000000000..3b0e881291
--- /dev/null
+++ b/comm/mail/components/search/extensions/eudict/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "EUdict Eng->Cro",
+ "description": "EUdict - englesko-hrvatski rjeÄnik",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "eudict@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "EUdict Eng->Cro",
+ "search_url": "https://eudict.com",
+ "search_form": "https://eudict.com?lang=engcro&word={searchTerms}",
+ "search_url_get_params": "lang=engcro&word={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/faclair-beag/favicon.ico b/comm/mail/components/search/extensions/faclair-beag/favicon.ico
new file mode 100644
index 0000000000..990cf93298
--- /dev/null
+++ b/comm/mail/components/search/extensions/faclair-beag/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/faclair-beag/manifest.json b/comm/mail/components/search/extensions/faclair-beag/manifest.json
new file mode 100644
index 0000000000..d183697b78
--- /dev/null
+++ b/comm/mail/components/search/extensions/faclair-beag/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "Am Faclair Beag",
+ "description": "Lorg Am Faclair Beag",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "faclair-beag@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Am Faclair Beag",
+ "search_url": "https://www.faclair.com/",
+ "search_url_get_params": "txtSearch={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/flip/favicon.png b/comm/mail/components/search/extensions/flip/favicon.png
new file mode 100644
index 0000000000..96fc159dbf
--- /dev/null
+++ b/comm/mail/components/search/extensions/flip/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/flip/manifest.json b/comm/mail/components/search/extensions/flip/manifest.json
new file mode 100644
index 0000000000..75a2d0abaa
--- /dev/null
+++ b/comm/mail/components/search/extensions/flip/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "flip.kz",
+ "description": "ҚазақÑтандық интернет-дүкенде іздеу",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "flip@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "flip.kz",
+ "search_url": "https://www.flip.kz/search",
+ "search_form": "https://flip.kz/",
+ "search_url_get_params": "search={searchTerms}",
+ "suggest_url": "https://www.flip.kz/ajax/search_keyword.php",
+ "suggest_url_get_params": "q={searchTerms}&type=os"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/freelang/favicon.ico b/comm/mail/components/search/extensions/freelang/favicon.ico
new file mode 100644
index 0000000000..510a0379f8
--- /dev/null
+++ b/comm/mail/components/search/extensions/freelang/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/freelang/manifest.json b/comm/mail/components/search/extensions/freelang/manifest.json
new file mode 100644
index 0000000000..3f49d83fbc
--- /dev/null
+++ b/comm/mail/components/search/extensions/freelang/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Freelang (br)",
+ "description": "Geriadur Freelang",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "freelang@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Freelang (br)",
+ "search_url": "https://www.freelang.com/enligne/breton.php",
+ "search_form": "https://www.freelang.com/enligne/breton.php",
+ "search_url_post_params": "dico=fr_bre_fra&lg=fr&mot1={searchTerms}&mot2=&entier=on"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/google/favicon.ico b/comm/mail/components/search/extensions/google/favicon.ico
new file mode 100644
index 0000000000..82339b3b1d
--- /dev/null
+++ b/comm/mail/components/search/extensions/google/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/google/manifest.json b/comm/mail/components/search/extensions/google/manifest.json
new file mode 100644
index 0000000000..98d177e465
--- /dev/null
+++ b/comm/mail/components/search/extensions/google/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Google",
+ "description": "Google Search",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "google@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Google",
+ "search_url": "https://www.google.com/search",
+ "search_form": "https://www.google.com/search?q={searchTerms}",
+ "search_url_get_params": "q={searchTerms}",
+ "suggest_url": "https://www.google.com/complete/search?client=firefox&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/gulesider-NO/favicon.ico b/comm/mail/components/search/extensions/gulesider-NO/favicon.ico
new file mode 100644
index 0000000000..e35572a557
--- /dev/null
+++ b/comm/mail/components/search/extensions/gulesider-NO/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/gulesider-NO/manifest.json b/comm/mail/components/search/extensions/gulesider-NO/manifest.json
new file mode 100644
index 0000000000..37e7461e22
--- /dev/null
+++ b/comm/mail/components/search/extensions/gulesider-NO/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Gule sider",
+ "description": "Gule sider person og firmasøk",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "gulesider-NO@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Gule sider",
+ "search_url": "https://www.gulesider.no/query",
+ "search_form": "https://www.gulesider.no/",
+ "search_url_get_params": "what=all&search_word={searchTerms}&cmpid=fre_partner_fire_gssbtop"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/heureka-cz/favicon.ico b/comm/mail/components/search/extensions/heureka-cz/favicon.ico
new file mode 100644
index 0000000000..95ceff009d
--- /dev/null
+++ b/comm/mail/components/search/extensions/heureka-cz/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/heureka-cz/manifest.json b/comm/mail/components/search/extensions/heureka-cz/manifest.json
new file mode 100644
index 0000000000..023d3455d9
--- /dev/null
+++ b/comm/mail/components/search/extensions/heureka-cz/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Heureka",
+ "description": "Vyhledávání na Heureka.cz",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "heureka-cz@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Heureka",
+ "search_url": "https://www.heureka.cz/",
+ "search_form": "https://www.heureka.cz/",
+ "search_url_get_params": "h[fraze]={searchTerms}&utm_source=firefox-search",
+ "suggest_url": "https://www.heureka.cz/direct/firefox/autocompleter.php",
+ "suggest_url_get_params": "query={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/hotline-ua/favicon.ico b/comm/mail/components/search/extensions/hotline-ua/favicon.ico
new file mode 100644
index 0000000000..53d8fc3bac
--- /dev/null
+++ b/comm/mail/components/search/extensions/hotline-ua/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/hotline-ua/manifest.json b/comm/mail/components/search/extensions/hotline-ua/manifest.json
new file mode 100644
index 0000000000..9f642d18a5
--- /dev/null
+++ b/comm/mail/components/search/extensions/hotline-ua/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Hotline",
+ "description": "Hotline - порівнÑти ціни в інтернет-магазинах України",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "hotline-ua@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Hotline",
+ "search_url": "https://hotline.ua/sr/",
+ "search_form": "https://hotline.ua/",
+ "search_url_get_params": "q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/kannadastore/favicon.png b/comm/mail/components/search/extensions/kannadastore/favicon.png
new file mode 100644
index 0000000000..8c96fe851f
--- /dev/null
+++ b/comm/mail/components/search/extensions/kannadastore/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/kannadastore/manifest.json b/comm/mail/components/search/extensions/kannadastore/manifest.json
new file mode 100644
index 0000000000..e87aeea130
--- /dev/null
+++ b/comm/mail/components/search/extensions/kannadastore/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Kannada Store",
+ "description": "Kanada Store, Online store",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "kannadastore@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Kannada Store",
+ "encoding": "ISO-8859-1",
+ "search_url": "https://www.kannadastore.com/advanced_search_result.php",
+ "search_form": "https://www.kannadastore.com/advanced_search_result.php",
+ "search_url_get_params": "keywords={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/leo_ende_de/favicon.png b/comm/mail/components/search/extensions/leo_ende_de/favicon.png
new file mode 100644
index 0000000000..04e5e344ef
--- /dev/null
+++ b/comm/mail/components/search/extensions/leo_ende_de/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/leo_ende_de/manifest.json b/comm/mail/components/search/extensions/leo_ende_de/manifest.json
new file mode 100644
index 0000000000..6f1b77a581
--- /dev/null
+++ b/comm/mail/components/search/extensions/leo_ende_de/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "LEO Eng-Deu",
+ "description": "Deutsch-Englisch Wörterbuch von LEO",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "leo_ende_de@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "LEO Eng-Deu",
+ "search_url": "https://dict.leo.org/englisch-deutsch/{searchTerms}",
+ "search_form": "https://dict.leo.org",
+ "suggest_url": "https://dict.leo.org/dictQuery/m-query/conf/ende/query.conf/strlist.json",
+ "suggest_url_get_params": "q={searchTerms}&sort=PLa&shortQuery=undefined&noDescription=undefined&noQueryURLs=undefined"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/list-am/favicon.gif b/comm/mail/components/search/extensions/list-am/favicon.gif
new file mode 100644
index 0000000000..031afb5dfc
--- /dev/null
+++ b/comm/mail/components/search/extensions/list-am/favicon.gif
Binary files differ
diff --git a/comm/mail/components/search/extensions/list-am/manifest.json b/comm/mail/components/search/extensions/list-am/manifest.json
new file mode 100644
index 0000000000..e2dd32304b
--- /dev/null
+++ b/comm/mail/components/search/extensions/list-am/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "List.am",
+ "description": "Õ€Õ¡Õ¶Ö€Õ¡ÕµÕ«Õ¶ Õ¦Õ¥Ö€Õ® ads Õ´Õ¡Õ½Õ«Õ¶: ÕŽÕ¡Õ³Õ¡Õ¼Ö„ Õ¥Ö‚ Õ£Õ¶Õ´Õ¡Õ¶ Õ¢Õ¶Õ¡Õ¯Õ¡Ö€Õ¡Õ¶Õ¶Õ¥Ö€, Õ¯Õ¥Õ¶ÖÕ¡Õ²Õ¡ÕµÕ«Õ¶ Õ«Ö€Õ¥Ö€, Õ¸Ö€Õ¸Õ¶Õ¥Õ¬ Õ¡Õ·Õ­Õ¡Õ¿Õ¡Õ¶Ö„Õ«.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "list-am@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.gif"
+ },
+ "web_accessible_resources": ["favicon.gif"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "List.am",
+ "search_url": "https://www.list.am/category",
+ "search_form": "https://www.list.am/category?q=",
+ "search_url_get_params": "q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/list.json b/comm/mail/components/search/extensions/list.json
new file mode 100644
index 0000000000..b3b440481c
--- /dev/null
+++ b/comm/mail/components/search/extensions/list.json
@@ -0,0 +1,1223 @@
+{
+ "default": {
+ "searchDefault": "Google",
+ "searchOrder": ["Google", "Bing"],
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "bing",
+ "ddg",
+ "wikipedia"
+ ]
+ },
+ "regionOverrides": {
+ "CA": {
+ "amazondotcom": "amazon-ca",
+ "amazon-france": "amazon-ca"
+ },
+ "AU": {
+ "amazondotcom": "amazon-au",
+ "amazon-en-GB": "amazon-au"
+ },
+ "FR": {
+ "amazondotcom": "amazon-france"
+ },
+ "GB": {
+ "amazondotcom": "amazon-en-GB"
+ }
+ },
+ "locales": {
+ "en-US": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "bing",
+ "ddg",
+ "wikipedia"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": [
+ "amazon-ca",
+ "amazon-au",
+ "yandex-en",
+ "google"
+ ]
+ }
+ },
+ "ach": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia"
+ ]
+ }
+ },
+ "af": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-af"
+ ]
+ }
+ },
+ "an": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "wikipedia-an", "ddg"]
+ }
+ },
+ "ar": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ar"
+ ]
+ }
+ },
+ "as": {
+ "default": {
+ "visibleDefaultEngines": ["google", "amazon-in", "ddg", "wikipedia-as"]
+ }
+ },
+ "ast": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-ast"]
+ }
+ },
+ "az": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "azerdict",
+ "bing",
+ "ddg",
+ "wikipedia-az",
+ "yandex-az"
+ ]
+ }
+ },
+ "be": {
+ "default": {
+ "visibleDefaultEngines": [
+ "yandex-by",
+ "google",
+ "ddg",
+ "wikipedia-be",
+ "wikipedia-be-tarask"
+ ]
+ },
+ "BY": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "KZ": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "RU": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "TR": {
+ "searchDefault": "ЯндекÑ"
+ }
+ },
+ "bg": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "ddg",
+ "pazaruvaj",
+ "wikipedia-bg"
+ ]
+ }
+ },
+ "bn": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-in",
+ "bing",
+ "ddg",
+ "wikipedia-bn"
+ ]
+ }
+ },
+ "bn-BD": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-bn"]
+ }
+ },
+ "bn-IN": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-in",
+ "bing",
+ "ddg",
+ "wikipedia-bn"
+ ]
+ }
+ },
+ "br": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-france",
+ "ddg",
+ "freelang",
+ "wikipedia-br"
+ ]
+ }
+ },
+ "bs": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "olx", "wikipedia-bs"]
+ }
+ },
+ "ca": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "diec2",
+ "ddg",
+ "wikipedia-ca"
+ ]
+ }
+ },
+ "cak": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "crh": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "wikipedia-crh"]
+ }
+ },
+ "cs": {
+ "default": {
+ "searchOrder": ["Google", "Seznam"],
+ "visibleDefaultEngines": [
+ "google",
+ "seznam-cz",
+ "ddg",
+ "heureka-cz",
+ "mapy-cz",
+ "wikipedia-cz"
+ ]
+ }
+ },
+ "cy": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "ddg",
+ "palasprint",
+ "wikipedia-cy"
+ ]
+ }
+ },
+ "da": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "ddg",
+ "wikipedia-da"
+ ]
+ }
+ },
+ "de": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-de",
+ "bing",
+ "ddg",
+ "ecosia",
+ "leo_ende_de",
+ "wikipedia-de"
+ ]
+ }
+ },
+ "dsb": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-de",
+ "ddg",
+ "leo_ende_de",
+ "wikipedia-dsb"
+ ]
+ }
+ },
+ "el": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bing",
+ "ddg",
+ "wikipedia-el"
+ ]
+ }
+ },
+ "en-CA": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-ca",
+ "bing",
+ "ddg",
+ "wikipedia"
+ ]
+ }
+ },
+ "en-GB": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "chambers-en-GB",
+ "ddg",
+ "wikipedia"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["yandex-en"]
+ }
+ },
+ "en-ZA": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia"
+ ]
+ }
+ },
+ "eo": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-eo"
+ ]
+ }
+ },
+ "es-AR": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "drae",
+ "ddg",
+ "mercadolibre-ar",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "es-CL": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "drae",
+ "ddg",
+ "mercadolibre-cl",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "es-ES": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "drae",
+ "ddg",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "es-MX": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "mercadolibre-mx",
+ "wikipedia-es"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["amazon-mx"]
+ }
+ },
+ "et": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "neti-ee",
+ "ddg",
+ "osta-ee",
+ "wikipedia-et",
+ "eki-ee"
+ ]
+ }
+ },
+ "eu": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "ddg",
+ "wikipedia-eu"
+ ]
+ }
+ },
+ "fa": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "bing",
+ "ddg",
+ "wikipedia-fa"
+ ]
+ }
+ },
+ "ff": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-france",
+ "ddg",
+ "wikipedia-fr"
+ ]
+ }
+ },
+ "fi": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-fi"]
+ }
+ },
+ "fr": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-france",
+ "ddg",
+ "qwant",
+ "wikipedia-fr"
+ ]
+ }
+ },
+ "fy-NL": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "bolcom-fy-NL",
+ "ddg",
+ "marktplaats-fy-NL",
+ "wikipedia-fy-NL"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["amazon-nl"]
+ }
+ },
+ "ga-IE": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "ddg",
+ "tearma",
+ "wikipedia-ga-IE"
+ ]
+ }
+ },
+ "gd": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bbc-alba",
+ "ddg",
+ "faclair-beag",
+ "wikipedia-gd"
+ ]
+ }
+ },
+ "gl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "ddg",
+ "wikipedia-gl"
+ ]
+ }
+ },
+ "gn": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-gn"
+ ]
+ }
+ },
+ "gu-IN": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-gu"
+ ]
+ }
+ },
+ "he": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "wikipedia-he", "morfix-dic"]
+ }
+ },
+ "hi-IN": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-hi"]
+ }
+ },
+ "hr": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bing",
+ "ddg",
+ "eudict",
+ "wikipedia-hr"
+ ]
+ }
+ },
+ "hsb": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-de",
+ "ddg",
+ "leo_ende_de",
+ "wikipedia-hsb"
+ ]
+ }
+ },
+ "hu": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "vatera", "wikipedia-hu"]
+ }
+ },
+ "hy-AM": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "ddg",
+ "list-am",
+ "wikipedia-hy"
+ ]
+ }
+ },
+ "ia": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ia"
+ ]
+ }
+ },
+ "id": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "wikipedia-id"]
+ }
+ },
+ "is": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-is"
+ ]
+ }
+ },
+ "it": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-it",
+ "ddg",
+ "wikipedia-it"
+ ]
+ }
+ },
+ "ja-JP-macos": {
+ "default": {
+ "searchOrder": [
+ "Google",
+ "Yahoo! JAPAN",
+ "Bing",
+ "Amazon.co.jp",
+ "楽天市場",
+ "ヤフオク!",
+ "æ•™ãˆã¦ï¼goo",
+ "Wikipedia (ja)"
+ ],
+ "visibleDefaultEngines": [
+ "google",
+ "yahoo-jp",
+ "bing",
+ "amazon-jp",
+ "rakuten",
+ "yahoo-jp-auctions",
+ "oshiete-goo",
+ "wikipedia-ja",
+ "ddg"
+ ]
+ }
+ },
+ "ja": {
+ "default": {
+ "searchOrder": [
+ "Google",
+ "Yahoo! JAPAN",
+ "Bing",
+ "Amazon.co.jp",
+ "楽天市場",
+ "ヤフオク!",
+ "æ•™ãˆã¦ï¼goo",
+ "Wikipedia (ja)"
+ ],
+ "visibleDefaultEngines": [
+ "google",
+ "yahoo-jp",
+ "bing",
+ "amazon-jp",
+ "rakuten",
+ "yahoo-jp-auctions",
+ "oshiete-goo",
+ "wikipedia-ja",
+ "ddg"
+ ]
+ }
+ },
+ "ka": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ka"
+ ]
+ }
+ },
+ "kab": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-kab"]
+ }
+ },
+ "kk": {
+ "default": {
+ "visibleDefaultEngines": [
+ "yandex-kk",
+ "google",
+ "ddg",
+ "flip",
+ "wikipedia-kk"
+ ]
+ },
+ "KZ": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "BY": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "RU": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "TR": {
+ "searchDefault": "ЯндекÑ"
+ }
+ },
+ "km": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-km"
+ ]
+ }
+ },
+ "kn": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "kannadastore",
+ "wikipedia-kn"
+ ]
+ }
+ },
+ "ko": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "ddg",
+ "naver-kr",
+ "daum-kr",
+ "wikipedia-kr"
+ ]
+ }
+ },
+ "lij": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-it",
+ "ddg",
+ "wikipedia-lij"
+ ]
+ }
+ },
+ "lo": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-lo"]
+ }
+ },
+ "lt": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "wikipedia-lt",
+ "bing",
+ "amazondotcom",
+ "ddg"
+ ]
+ }
+ },
+ "ltg": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "ddg",
+ "salidzinilv",
+ "sslv",
+ "wikipedia-ltg"
+ ]
+ }
+ },
+ "lv": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "ddg",
+ "salidzinilv",
+ "sslv",
+ "wikipedia-lv"
+ ]
+ }
+ },
+ "mai": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-hi"
+ ]
+ }
+ },
+ "mk": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-mk"
+ ]
+ }
+ },
+ "ml": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia",
+ "wikipedia-ml"
+ ]
+ }
+ },
+ "mr": {
+ "default": {
+ "visibleDefaultEngines": ["google", "amazon-in", "ddg", "wikipedia-mr"]
+ }
+ },
+ "ms": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ms"
+ ]
+ }
+ },
+ "my": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-my"
+ ]
+ }
+ },
+ "nb-NO": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bing",
+ "ddg",
+ "gulesider-NO",
+ "bok-NO",
+ "qxl-NO",
+ "wikipedia-NO"
+ ]
+ }
+ },
+ "ne-NP": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-ne"]
+ }
+ },
+ "nl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "bolcom-nl",
+ "ddg",
+ "marktplaats-nl",
+ "wikipedia-nl"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["amazon-nl"]
+ }
+ },
+ "nn-NO": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "ddg",
+ "gulesider-NO",
+ "bok-NO",
+ "qxl-NO",
+ "wikipedia-NN"
+ ]
+ }
+ },
+ "oc": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "wikipedia-oc",
+ "wiktionary-oc"
+ ]
+ }
+ },
+ "or": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-or"
+ ]
+ }
+ },
+ "pa-IN": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-pa"
+ ]
+ }
+ },
+ "pl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "allegro-pl",
+ "ddg",
+ "pwn-pl",
+ "wikipedia-pl",
+ "wolnelektury-pl"
+ ]
+ }
+ },
+ "pt-BR": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "mercadolivre",
+ "wikipedia-pt"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["amazon-br"]
+ }
+ },
+ "pt-PT": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "ddg",
+ "priberam",
+ "wikipedia-pt"
+ ]
+ }
+ },
+ "rm": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "leo_ende_de",
+ "wikipedia-rm"
+ ]
+ }
+ },
+ "ro": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ro"
+ ]
+ }
+ },
+ "ru": {
+ "default": {
+ "visibleDefaultEngines": [
+ "yandex-ru",
+ "google",
+ "ddg",
+ "ozonru",
+ "priceru",
+ "wikipedia-ru",
+ "mailru"
+ ]
+ },
+ "RU": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "BY": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "KZ": {
+ "searchDefault": "ЯндекÑ"
+ },
+ "TR": {
+ "searchDefault": "ЯндекÑ"
+ }
+ },
+ "si": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-si"
+ ]
+ }
+ },
+ "sk": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "azet-sk",
+ "atlas-sk",
+ "ddg",
+ "wikipedia-sk",
+ "zoznam-sk"
+ ]
+ }
+ },
+ "sl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "ceneji",
+ "ddg",
+ "najdi-si",
+ "odpiralni",
+ "wikipedia-sl"
+ ]
+ }
+ },
+ "son": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-france",
+ "ddg",
+ "wikipedia-fr"
+ ]
+ }
+ },
+ "sq": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "ddg",
+ "wikipedia-sq"
+ ]
+ }
+ },
+ "sr": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bing",
+ "ddg",
+ "wikipedia-sr",
+ "pogodak"
+ ]
+ }
+ },
+ "sv-SE": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "allaannonser-sv-SE",
+ "ddg",
+ "prisjakt-sv-SE",
+ "tyda-sv-SE",
+ "wikipedia-sv-SE"
+ ]
+ }
+ },
+ "ta": {
+ "default": {
+ "visibleDefaultEngines": ["google", "amazon-in", "ddg", "wikipedia-ta"]
+ }
+ },
+ "te": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-in",
+ "ddg",
+ "wikipedia-te",
+ "wiktionary-te"
+ ]
+ }
+ },
+ "th": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "bing",
+ "ddg",
+ "longdo",
+ "wikipedia-th"
+ ]
+ }
+ },
+ "tl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-tl"
+ ]
+ }
+ },
+ "tr": {
+ "default": {
+ "visibleDefaultEngines": ["yandex-tr", "google", "ddg", "wikipedia-tr"]
+ },
+ "TR": {
+ "searchDefault": "Yandex"
+ },
+ "BY": {
+ "searchDefault": "Yandex"
+ },
+ "KZ": {
+ "searchDefault": "Yandex"
+ },
+ "RU": {
+ "searchDefault": "Yandex"
+ }
+ },
+ "trs": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "uk": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "wikipedia-uk",
+ "hotline-ua"
+ ]
+ }
+ },
+ "ur": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-ur"
+ ]
+ }
+ },
+ "uz": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-uz"
+ ]
+ }
+ },
+ "vi": {
+ "default": {
+ "visibleDefaultEngines": ["google", "coccoc", "ddg", "wikipedia-vi"]
+ }
+ },
+ "wo": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-france",
+ "ddg",
+ "wikipedia-wo"
+ ]
+ }
+ },
+ "xh": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia"]
+ }
+ },
+ "zh-CN": {
+ "default": {
+ "visibleDefaultEngines": [
+ "baidu",
+ "google",
+ "bing",
+ "ddg",
+ "wikipedia-zh-CN",
+ "amazondotcn"
+ ]
+ },
+ "CN": {
+ "searchDefault": "百度"
+ }
+ },
+ "zh-TW": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "readmoo", "wikipedia-zh-TW"]
+ }
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/longdo/favicon.ico b/comm/mail/components/search/extensions/longdo/favicon.ico
new file mode 100644
index 0000000000..aa42cda97f
--- /dev/null
+++ b/comm/mail/components/search/extensions/longdo/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/longdo/manifest.json b/comm/mail/components/search/extensions/longdo/manifest.json
new file mode 100644
index 0000000000..1b86c951d5
--- /dev/null
+++ b/comm/mail/components/search/extensions/longdo/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "พจนานุà¸à¸£à¸¡ ลองดู",
+ "description": "พจนานุà¸à¸£à¸¡ ลองดู",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "longdo@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "พจนานุà¸à¸£à¸¡ ลองดู",
+ "search_url": "https://dict.longdo.org/",
+ "search_form": "https://dict.longdo.org/",
+ "search_url_get_params": "search={searchTerms}&src=moz",
+ "suggest_url": "https://search.longdo.com/Suggest/HeadSearch",
+ "suggest_url_get_params": "ds=head&fxjson=1&key={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/mailru/favicon.ico b/comm/mail/components/search/extensions/mailru/favicon.ico
new file mode 100644
index 0000000000..a2d3a48883
--- /dev/null
+++ b/comm/mail/components/search/extensions/mailru/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/mailru/manifest.json b/comm/mail/components/search/extensions/mailru/manifest.json
new file mode 100644
index 0000000000..9d5e799296
--- /dev/null
+++ b/comm/mail/components/search/extensions/mailru/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "ПоиÑк Mail.Ru",
+ "description": "Search with ПоиÑк Mail.Ru",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "mailru@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "ПоиÑк Mail.Ru",
+ "search_url": "https://go.mail.ru/search",
+ "search_form": "https://go.mail.ru/?gp=900200",
+ "search_url_get_params": "q={searchTerms}&fr=osmi&gp=900200&frc=900200",
+ "suggest_url": "https://suggests.go.mail.ru/ff3",
+ "suggest_url_get_params": "q={searchTerms}&gp=900200"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/mapy-cz/favicon.ico b/comm/mail/components/search/extensions/mapy-cz/favicon.ico
new file mode 100644
index 0000000000..051204c35c
--- /dev/null
+++ b/comm/mail/components/search/extensions/mapy-cz/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/mapy-cz/manifest.json b/comm/mail/components/search/extensions/mapy-cz/manifest.json
new file mode 100644
index 0000000000..2f8fdc32f1
--- /dev/null
+++ b/comm/mail/components/search/extensions/mapy-cz/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Mapy.cz",
+ "description": "Vyhledávání na Mapy.cz",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "mapy-cz@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Mapy.cz",
+ "search_url": "https://www.mapy.cz/",
+ "search_form": "https://www.mapy.cz/",
+ "search_url_get_params": "query={searchTerms}&sourceid=Searchmodule_3"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/marktplaats/_locales/fy-NL/messages.json b/comm/mail/components/search/extensions/marktplaats/_locales/fy-NL/messages.json
new file mode 100644
index 0000000000..4d7f884b17
--- /dev/null
+++ b/comm/mail/components/search/extensions/marktplaats/_locales/fy-NL/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Marktplaats.nl"
+ },
+ "extensionDescription": {
+ "message": "Sykje yn alle kategoryen op Marktplaats.nl"
+ },
+ "searchUrl": {
+ "message": "https://www.marktplaats.nl/z.html"
+ },
+ "searchForm": {
+ "message": "https://www.marktplaats.nl"
+ },
+ "searchUrlGetParams": {
+ "message": "query={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/marktplaats/_locales/nl/messages.json b/comm/mail/components/search/extensions/marktplaats/_locales/nl/messages.json
new file mode 100644
index 0000000000..c44a0a25cc
--- /dev/null
+++ b/comm/mail/components/search/extensions/marktplaats/_locales/nl/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Marktplaats.nl"
+ },
+ "extensionDescription": {
+ "message": "Zoeken in alle categorieën op Marktplaats.nl"
+ },
+ "searchUrl": {
+ "message": "https://www.marktplaats.nl/z.html"
+ },
+ "searchForm": {
+ "message": "https://www.marktplaats.nl"
+ },
+ "searchUrlGetParams": {
+ "message": "query={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/marktplaats/favicon.ico b/comm/mail/components/search/extensions/marktplaats/favicon.ico
new file mode 100644
index 0000000000..ed0ff305a6
--- /dev/null
+++ b/comm/mail/components/search/extensions/marktplaats/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/marktplaats/manifest.json b/comm/mail/components/search/extensions/marktplaats/manifest.json
new file mode 100644
index 0000000000..b1a41bbd02
--- /dev/null
+++ b/comm/mail/components/search/extensions/marktplaats/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "marktplaats@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "fy-NL",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolibre/_locales/ar/messages.json b/comm/mail/components/search/extensions/mercadolibre/_locales/ar/messages.json
new file mode 100644
index 0000000000..b83f37c6fc
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/_locales/ar/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "MercadoLibre Argentina"
+ },
+ "extensionDescription": {
+ "message": "MercadoLibre Argentina"
+ },
+ "searchUrl": {
+ "message": "https://www.mercadolibre.com.ar/jm/search"
+ },
+ "searchForm": {
+ "message": "https://www.mercadolibre.com.ar/"
+ },
+ "searchUrlGetParams": {
+ "message": "as_word={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolibre/_locales/cl/messages.json b/comm/mail/components/search/extensions/mercadolibre/_locales/cl/messages.json
new file mode 100644
index 0000000000..3c37756464
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/_locales/cl/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "MercadoLibre Chile"
+ },
+ "extensionDescription": {
+ "message": "MercadoLibre Chile"
+ },
+ "searchUrl": {
+ "message": "https://www.mercadolibre.cl/jm/search"
+ },
+ "searchForm": {
+ "message": "https://www.mercadolibre.cl/"
+ },
+ "searchUrlGetParams": {
+ "message": "as_word={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolibre/_locales/mx/messages.json b/comm/mail/components/search/extensions/mercadolibre/_locales/mx/messages.json
new file mode 100644
index 0000000000..cb4d2b4b79
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/_locales/mx/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "MercadoLibre Mexico"
+ },
+ "extensionDescription": {
+ "message": "MercadoLibre Mexico"
+ },
+ "searchUrl": {
+ "message": "https://www.mercadolibre.com.mx/jm/search"
+ },
+ "searchForm": {
+ "message": "https://www.mercadolibre.com.mx/"
+ },
+ "searchUrlGetParams": {
+ "message": "as_word={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolibre/favicon.ico b/comm/mail/components/search/extensions/mercadolibre/favicon.ico
new file mode 100644
index 0000000000..dc9ad5b2a9
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/mercadolibre/manifest.json b/comm/mail/components/search/extensions/mercadolibre/manifest.json
new file mode 100644
index 0000000000..7af5ecc3cf
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "mercadolibre@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "ar",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolivre/favicon.ico b/comm/mail/components/search/extensions/mercadolivre/favicon.ico
new file mode 100644
index 0000000000..dc9ad5b2a9
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolivre/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/mercadolivre/manifest.json b/comm/mail/components/search/extensions/mercadolivre/manifest.json
new file mode 100644
index 0000000000..d70f94573f
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolivre/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "MercadoLivre",
+ "description": "Onde comprar e vender de Tudo.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "mercadolivre@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "MercadoLivre",
+ "search_url": "https://www.mercadolivre.com.br/jm/search",
+ "search_form": "https://www.mercadolivre.com.br/",
+ "search_url_get_params": "as_word={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/morfix-dic/favicon.ico b/comm/mail/components/search/extensions/morfix-dic/favicon.ico
new file mode 100644
index 0000000000..6a3231b172
--- /dev/null
+++ b/comm/mail/components/search/extensions/morfix-dic/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/morfix-dic/manifest.json b/comm/mail/components/search/extensions/morfix-dic/manifest.json
new file mode 100644
index 0000000000..cacaad9ccf
--- /dev/null
+++ b/comm/mail/components/search/extensions/morfix-dic/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "מילון מורפיקס",
+ "description": "",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "morfix-dic@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "מילון מורפיקס",
+ "search_url": "https://milon.morfix.co.il/default.aspx",
+ "search_form": "https://milon.morfix.co.il/",
+ "search_url_get_params": "q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/najdi-si/favicon.png b/comm/mail/components/search/extensions/najdi-si/favicon.png
new file mode 100644
index 0000000000..b470991648
--- /dev/null
+++ b/comm/mail/components/search/extensions/najdi-si/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/najdi-si/manifest.json b/comm/mail/components/search/extensions/najdi-si/manifest.json
new file mode 100644
index 0000000000..2d7e0caaad
--- /dev/null
+++ b/comm/mail/components/search/extensions/najdi-si/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Najdi.si",
+ "description": "Iskalnik Najdi.si",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "najdi-si@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Najdi.si",
+ "search_url": "https://www.najdi.si/search.jsp",
+ "search_form": "https://www.najdi.si/",
+ "search_url_get_params": "q={searchTerms}&o=0&foxsbar=ff"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/naver-kr/favicon.ico b/comm/mail/components/search/extensions/naver-kr/favicon.ico
new file mode 100644
index 0000000000..eed93a92cb
--- /dev/null
+++ b/comm/mail/components/search/extensions/naver-kr/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/naver-kr/manifest.json b/comm/mail/components/search/extensions/naver-kr/manifest.json
new file mode 100644
index 0000000000..240fc2e0d3
--- /dev/null
+++ b/comm/mail/components/search/extensions/naver-kr/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "네ì´ë²„",
+ "description": "네ì´ë²„ 검색",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "naver-kr@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "네ì´ë²„",
+ "search_url": "https://search.naver.com/search.naver",
+ "search_form": "https://search.naver.com",
+ "search_url_get_params": "where=nexearch&frm=ff&sm=oss&ie=utf8&query={searchTerms}",
+ "suggest_url": "https://ac.search.naver.com/nx/ac",
+ "suggest_url_get_params": "of=os&ie=utf-8&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/neti-ee/favicon.ico b/comm/mail/components/search/extensions/neti-ee/favicon.ico
new file mode 100644
index 0000000000..1bc10ea7fb
--- /dev/null
+++ b/comm/mail/components/search/extensions/neti-ee/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/neti-ee/manifest.json b/comm/mail/components/search/extensions/neti-ee/manifest.json
new file mode 100644
index 0000000000..900789fceb
--- /dev/null
+++ b/comm/mail/components/search/extensions/neti-ee/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Neti",
+ "description": "Neti",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "neti-ee@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Neti",
+ "search_url": "https://www.neti.ee/cgi-bin/otsing",
+ "search_form": "https://www.neti.ee/",
+ "search_url_get_params": "query={searchTerms}&src=web"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/odpiralni/favicon.png b/comm/mail/components/search/extensions/odpiralni/favicon.png
new file mode 100644
index 0000000000..044d4f13d4
--- /dev/null
+++ b/comm/mail/components/search/extensions/odpiralni/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/odpiralni/manifest.json b/comm/mail/components/search/extensions/odpiralni/manifest.json
new file mode 100644
index 0000000000..cee70b5cb6
--- /dev/null
+++ b/comm/mail/components/search/extensions/odpiralni/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "Odpiralni ÄŒasi",
+ "description": "Odpiralni ÄŒasi v Sloveniji",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "odpiralni@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Odpiralni ÄŒasi",
+ "search_url": "https://www.odpiralnicasi.com/spots",
+ "search_url_get_params": "q={searchTerms}&source=1"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/olx/favicon.ico b/comm/mail/components/search/extensions/olx/favicon.ico
new file mode 100644
index 0000000000..22e472190b
--- /dev/null
+++ b/comm/mail/components/search/extensions/olx/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/olx/manifest.json b/comm/mail/components/search/extensions/olx/manifest.json
new file mode 100644
index 0000000000..da80367b2f
--- /dev/null
+++ b/comm/mail/components/search/extensions/olx/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "OLX.ba",
+ "description": "OLX.ba pretraživaÄ",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "olx@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "OLX.ba",
+ "search_url": "https://www.olx.ba/pretraga",
+ "search_form": "https://www.olx.ba/",
+ "search_url_get_params": "trazilica={searchTerms}",
+ "suggest_url": "https://www.olx.ba/sugestije/firefox_pojmovi",
+ "suggest_url_get_params": "sta={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/oshiete-goo/favicon.ico b/comm/mail/components/search/extensions/oshiete-goo/favicon.ico
new file mode 100644
index 0000000000..ee454036dd
--- /dev/null
+++ b/comm/mail/components/search/extensions/oshiete-goo/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/oshiete-goo/manifest.json b/comm/mail/components/search/extensions/oshiete-goo/manifest.json
new file mode 100644
index 0000000000..be6d6fb8e5
--- /dev/null
+++ b/comm/mail/components/search/extensions/oshiete-goo/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "æ•™ãˆã¦ï¼goo",
+ "description": "æ•™ãˆã¦ï¼goo",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "oshiete-goo@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "æ•™ãˆã¦ï¼goo",
+ "search_url": "https://oshiete.goo.ne.jp/search_goo/result/",
+ "search_url_get_params": "MT={searchTerms}&from=Firefox30&PT=Firefox30"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/osta-ee/favicon.png b/comm/mail/components/search/extensions/osta-ee/favicon.png
new file mode 100644
index 0000000000..e67b9c3abf
--- /dev/null
+++ b/comm/mail/components/search/extensions/osta-ee/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/osta-ee/manifest.json b/comm/mail/components/search/extensions/osta-ee/manifest.json
new file mode 100644
index 0000000000..7940471355
--- /dev/null
+++ b/comm/mail/components/search/extensions/osta-ee/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Osta",
+ "description": "Osta",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "osta-ee@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Osta",
+ "search_url": "https://www.osta.ee/firefox/",
+ "search_form": "https://www.osta.ee/",
+ "search_url_get_params": "keyword={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/ozonru/favicon.ico b/comm/mail/components/search/extensions/ozonru/favicon.ico
new file mode 100644
index 0000000000..eecb97a330
--- /dev/null
+++ b/comm/mail/components/search/extensions/ozonru/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/ozonru/manifest.json b/comm/mail/components/search/extensions/ozonru/manifest.json
new file mode 100644
index 0000000000..35d0d3f1a5
--- /dev/null
+++ b/comm/mail/components/search/extensions/ozonru/manifest.json
@@ -0,0 +1,27 @@
+{
+ "name": "OZON.ru",
+ "description": "OZON.ru provider",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "ozonru@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "OZON.ru",
+ "encoding": "WINDOWS-1251",
+ "search_url": "https://www.ozon.ru/?",
+ "search_form": "https://www.ozon.ru/",
+ "search_url_get_params": "context=search&text={searchTerms}&from=firefox",
+ "suggest_url": "https://www.ozon.ru/JSONSuggestionHandler.ashx",
+ "suggest_url_get_params": "text={searchTerms}&from=firefox"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/palasprint/favicon.ico b/comm/mail/components/search/extensions/palasprint/favicon.ico
new file mode 100644
index 0000000000..afa4eef392
--- /dev/null
+++ b/comm/mail/components/search/extensions/palasprint/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/palasprint/manifest.json b/comm/mail/components/search/extensions/palasprint/manifest.json
new file mode 100644
index 0000000000..9786e0679c
--- /dev/null
+++ b/comm/mail/components/search/extensions/palasprint/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Palas Print",
+ "description": "Palas Print - Heb Ffiniau",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "palasprint@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Palas Print",
+ "search_url": "https://palasprint.com/siopa/search_all.php",
+ "search_form": "https://palasprint.com/siopa/advanced_search.php",
+ "search_url_get_params": "keywords={searchTerms}&source=mozilla"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/pazaruvaj/favicon.ico b/comm/mail/components/search/extensions/pazaruvaj/favicon.ico
new file mode 100644
index 0000000000..36f0cff233
--- /dev/null
+++ b/comm/mail/components/search/extensions/pazaruvaj/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/pazaruvaj/manifest.json b/comm/mail/components/search/extensions/pazaruvaj/manifest.json
new file mode 100644
index 0000000000..b94dce2668
--- /dev/null
+++ b/comm/mail/components/search/extensions/pazaruvaj/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Pazaruvaj",
+ "description": "Ðадежден помощник за покупки, Ñравнение на цени, онлайн магазини, опиÑаниÑ, мнениÑ, видеоклипове",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "pazaruvaj@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Pazaruvaj",
+ "search_url": "https://www.pazaruvaj.com/CategorySearch.php",
+ "search_form": "https://www.pazaruvaj.com/",
+ "search_url_get_params": "st={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/pogodak/favicon.ico b/comm/mail/components/search/extensions/pogodak/favicon.ico
new file mode 100644
index 0000000000..1bae4f838d
--- /dev/null
+++ b/comm/mail/components/search/extensions/pogodak/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/pogodak/manifest.json b/comm/mail/components/search/extensions/pogodak/manifest.json
new file mode 100644
index 0000000000..e17e175620
--- /dev/null
+++ b/comm/mail/components/search/extensions/pogodak/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Погодак",
+ "description": "Погодак: претраживач Интернета",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "pogodak@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Погодак",
+ "search_url": "https://www.pogodak.rs/pretraga",
+ "search_form": "https://www.pogodak.rs",
+ "search_url_get_params": "q={searchTerms}&foxsbar=ff"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/priberam/favicon.png b/comm/mail/components/search/extensions/priberam/favicon.png
new file mode 100644
index 0000000000..98924439d5
--- /dev/null
+++ b/comm/mail/components/search/extensions/priberam/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/priberam/manifest.json b/comm/mail/components/search/extensions/priberam/manifest.json
new file mode 100644
index 0000000000..f344d4def4
--- /dev/null
+++ b/comm/mail/components/search/extensions/priberam/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Priberam",
+ "description": "Dicionário Priberam",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "priberam@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Priberam",
+ "encoding": "ISO-8859-15",
+ "search_url": "https://www.priberam.pt/dlpo/firefox.aspx",
+ "search_form": "https://www.priberam.pt/dlpo/",
+ "search_url_get_params": "pal={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/priceru/favicon.ico b/comm/mail/components/search/extensions/priceru/favicon.ico
new file mode 100644
index 0000000000..ee4ca656ca
--- /dev/null
+++ b/comm/mail/components/search/extensions/priceru/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/priceru/manifest.json b/comm/mail/components/search/extensions/priceru/manifest.json
new file mode 100644
index 0000000000..1eff4b2e47
--- /dev/null
+++ b/comm/mail/components/search/extensions/priceru/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Price.ru",
+ "description": "ПоиÑк предложений и цен",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "priceru@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Price.ru",
+ "search_url": "https://price.ru/search",
+ "search_form": "https://price.ru/index.html",
+ "search_url_get_params": "query={searchTerms}&from=fx3"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/prisjakt-sv-SE/favicon.ico b/comm/mail/components/search/extensions/prisjakt-sv-SE/favicon.ico
new file mode 100644
index 0000000000..feac665f71
--- /dev/null
+++ b/comm/mail/components/search/extensions/prisjakt-sv-SE/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/prisjakt-sv-SE/manifest.json b/comm/mail/components/search/extensions/prisjakt-sv-SE/manifest.json
new file mode 100644
index 0000000000..442cb0414e
--- /dev/null
+++ b/comm/mail/components/search/extensions/prisjakt-sv-SE/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Prisjakt",
+ "description": "Prisjakt - jämför priser och produkter",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "prisjakt-sv-SE@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Prisjakt",
+ "search_url": "https://www.prisjakt.nu/#rparams=ss={searchTerms}",
+ "search_form": "https://www.prisjakt.nu/#rparams=ss={searchTerms}",
+ "suggest_url": "https://www.prisjakt.nu/plugins/opensearch/suggestions.php",
+ "suggest_url_get_params": "search={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/pwn-pl/favicon.png b/comm/mail/components/search/extensions/pwn-pl/favicon.png
new file mode 100644
index 0000000000..3cbae12d48
--- /dev/null
+++ b/comm/mail/components/search/extensions/pwn-pl/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/pwn-pl/manifest.json b/comm/mail/components/search/extensions/pwn-pl/manifest.json
new file mode 100644
index 0000000000..46375e9983
--- /dev/null
+++ b/comm/mail/components/search/extensions/pwn-pl/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "Encyklopedia PWN",
+ "description": "Wyszukiwanie w Encyklopedii PWN",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "pwn-pl@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Encyklopedia PWN",
+ "search_url": "https://encyklopedia.pwn.pl/szukaj/{searchTerms}",
+ "search_form": "https://encyklopedia.pwn.pl/szukaj/"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/qwant/favicon.ico b/comm/mail/components/search/extensions/qwant/favicon.ico
new file mode 100644
index 0000000000..d43d1d5aa6
--- /dev/null
+++ b/comm/mail/components/search/extensions/qwant/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/qwant/manifest.json b/comm/mail/components/search/extensions/qwant/manifest.json
new file mode 100644
index 0000000000..07fbf85cce
--- /dev/null
+++ b/comm/mail/components/search/extensions/qwant/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Qwant",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "qwant@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Qwant",
+ "search_url": "https://www.qwant.com/",
+ "search_url_get_params": "client=brz-moz&q={searchTerms}",
+ "suggest_url": "https://api.qwant.com/api/suggest/",
+ "suggest_url_get_params": "client=opensearch&q={searchTerms}",
+ "search_form": "https://www.qwant.com/"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/qxl-NO/favicon.ico b/comm/mail/components/search/extensions/qxl-NO/favicon.ico
new file mode 100644
index 0000000000..02ee1fc283
--- /dev/null
+++ b/comm/mail/components/search/extensions/qxl-NO/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/qxl-NO/manifest.json b/comm/mail/components/search/extensions/qxl-NO/manifest.json
new file mode 100644
index 0000000000..8f98d766ce
--- /dev/null
+++ b/comm/mail/components/search/extensions/qxl-NO/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "QXL",
+ "description": "QXL søk",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "qxl-NO@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "QXL",
+ "search_url": "https://www.qxl.no/search/search.asp",
+ "search_form": "https://www.qxl.no/",
+ "search_url_get_params": "txtSearch={searchTerms}&InTitleAndDesc=1&sourceid=Mozilla-search"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/rakuten/favicon.ico b/comm/mail/components/search/extensions/rakuten/favicon.ico
new file mode 100644
index 0000000000..66afe98469
--- /dev/null
+++ b/comm/mail/components/search/extensions/rakuten/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/rakuten/manifest.json b/comm/mail/components/search/extensions/rakuten/manifest.json
new file mode 100644
index 0000000000..ba590dba76
--- /dev/null
+++ b/comm/mail/components/search/extensions/rakuten/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "楽天市場",
+ "description": "楽天市場 商å“検索",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "rakuten@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "楽天市場",
+ "encoding": "EUC-JP",
+ "search_url": "https://pt.afl.rakuten.co.jp/c/013ca98b.cd7c5f0c/",
+ "search_form": "https://www.rakuten.co.jp/",
+ "search_url_get_params": "sitem={searchTerms}&sv=2&p=0"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/readmoo/favicon.ico b/comm/mail/components/search/extensions/readmoo/favicon.ico
new file mode 100644
index 0000000000..75396dc9ca
--- /dev/null
+++ b/comm/mail/components/search/extensions/readmoo/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/readmoo/manifest.json b/comm/mail/components/search/extensions/readmoo/manifest.json
new file mode 100644
index 0000000000..f9d8a24f3c
--- /dev/null
+++ b/comm/mail/components/search/extensions/readmoo/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Readmoo 讀墨電å­æ›¸",
+ "description": "Readmoo 讀墨電å­æ›¸",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "readmoo@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Readmoo 讀墨電å­æ›¸",
+ "search_url": "https://readmoo.com/search/keyword",
+ "search_form": "https://readmoo.com/search/keyword?pi=0&q={searchTerms}&st=true",
+ "search_url_get_params": "pi=0&q={searchTerms}&st=true"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/salidzinilv/favicon.ico b/comm/mail/components/search/extensions/salidzinilv/favicon.ico
new file mode 100644
index 0000000000..0a7d01cae8
--- /dev/null
+++ b/comm/mail/components/search/extensions/salidzinilv/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/salidzinilv/manifest.json b/comm/mail/components/search/extensions/salidzinilv/manifest.json
new file mode 100644
index 0000000000..322a378eae
--- /dev/null
+++ b/comm/mail/components/search/extensions/salidzinilv/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Salidzini.lv",
+ "description": "Salidzini.lv - Latvijas interneta veikalu mekletajs",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "salidzinilv@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Salidzini.lv",
+ "search_url": "https://www.salidzini.lv/search.php",
+ "search_form": "https://salidzini.lv",
+ "search_url_get_params": "q={searchTerms}&utm_source=firefox-plugin",
+ "suggest_url": "https://www.salidzini.lv/suggested_search.php",
+ "suggest_url_get_params": "q={searchTerms}&utm_source=firefox-plugin"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/seznam-cz/favicon.ico b/comm/mail/components/search/extensions/seznam-cz/favicon.ico
new file mode 100644
index 0000000000..f3e078a107
--- /dev/null
+++ b/comm/mail/components/search/extensions/seznam-cz/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/seznam-cz/manifest.json b/comm/mail/components/search/extensions/seznam-cz/manifest.json
new file mode 100644
index 0000000000..838cbb497f
--- /dev/null
+++ b/comm/mail/components/search/extensions/seznam-cz/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Seznam",
+ "description": "Vyhledávání na Seznam.cz",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "seznam-cz@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Seznam",
+ "search_url": "https://search.seznam.cz/",
+ "search_form": "https://search.seznam.cz/?q={searchTerms}&sourceid=firefox",
+ "search_url_get_params": "q={searchTerms}&sourceid=firefox",
+ "suggest_url": "https://suggest.seznam.cz/fulltext_ff",
+ "suggest_url_get_params": "phrase={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/sslv/favicon.ico b/comm/mail/components/search/extensions/sslv/favicon.ico
new file mode 100644
index 0000000000..c6869229a2
--- /dev/null
+++ b/comm/mail/components/search/extensions/sslv/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/sslv/manifest.json b/comm/mail/components/search/extensions/sslv/manifest.json
new file mode 100644
index 0000000000..3de8483fae
--- /dev/null
+++ b/comm/mail/components/search/extensions/sslv/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "SS.lv",
+ "description": "SS.lv - LielÄkais sludinÄjumu serviss LatvijÄ",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "sslv@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "SS.lv",
+ "search_url": "https://www.ss.lv/lv/search_result/index.html",
+ "search_form": "https://www.ss.lv",
+ "search_url_post_params": "txt={searchTerms}&from=firefox-plugin"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/tearma/favicon.ico b/comm/mail/components/search/extensions/tearma/favicon.ico
new file mode 100644
index 0000000000..23866521ea
--- /dev/null
+++ b/comm/mail/components/search/extensions/tearma/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/tearma/manifest.json b/comm/mail/components/search/extensions/tearma/manifest.json
new file mode 100644
index 0000000000..ae081f5927
--- /dev/null
+++ b/comm/mail/components/search/extensions/tearma/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "tearma.ie",
+ "description": "tearma.ie: Cuardach Comhtháite",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "tearma@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "tearma.ie",
+ "search_url": "https://www.tearma.ie/Search.aspx",
+ "search_form": "https://www.tearma.ie/Home.aspx",
+ "search_url_get_params": "term={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/tyda-sv-SE/favicon.ico b/comm/mail/components/search/extensions/tyda-sv-SE/favicon.ico
new file mode 100644
index 0000000000..7415cbb160
--- /dev/null
+++ b/comm/mail/components/search/extensions/tyda-sv-SE/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/tyda-sv-SE/manifest.json b/comm/mail/components/search/extensions/tyda-sv-SE/manifest.json
new file mode 100644
index 0000000000..2e13ba2a52
--- /dev/null
+++ b/comm/mail/components/search/extensions/tyda-sv-SE/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Tyda.se",
+ "description": "Tyda.se, lexikon, ordlista och översättning.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "tyda-sv-SE@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Tyda.se",
+ "search_url": "https://tyda.se",
+ "search_form": "https://tyda.se",
+ "search_url_get_params": "w={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/vatera/favicon.ico b/comm/mail/components/search/extensions/vatera/favicon.ico
new file mode 100644
index 0000000000..5b02f16cb9
--- /dev/null
+++ b/comm/mail/components/search/extensions/vatera/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/vatera/manifest.json b/comm/mail/components/search/extensions/vatera/manifest.json
new file mode 100644
index 0000000000..af690f27e0
--- /dev/null
+++ b/comm/mail/components/search/extensions/vatera/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Vatera.hu",
+ "description": "Keresés a Vatera.hu piacterén",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "vatera@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Vatera.hu",
+ "encoding": "ISO-8859-2",
+ "search_url": "https://www.vatera.hu/listings/index.php",
+ "search_form": "https://www.vatera.hu/",
+ "search_url_get_params": "q={searchTerms}&c=0&td=on"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/NN/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/NN/messages.json
new file mode 100644
index 0000000000..04f669eed9
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/NN/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (nn)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, det frie oppslagsverket"
+ },
+ "searchUrl": {
+ "message": "https://nn.wikipedia.org/wiki/Spesial:Søk"
+ },
+ "searchForm": {
+ "message": "https://nn.wikipedia.org/wiki/Spesial:Søk?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://nn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/NO/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/NO/messages.json
new file mode 100644
index 0000000000..243806e727
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/NO/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (no)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, den frie encyklopedi"
+ },
+ "searchUrl": {
+ "message": "https://no.wikipedia.org/wiki/Spesial:Søk"
+ },
+ "searchForm": {
+ "message": "https://no.wikipedia.org/wiki/Spesial:Søk?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://no.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/af/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/af/messages.json
new file mode 100644
index 0000000000..9314533645
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/af/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (af)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, die vrye ensiklopedie"
+ },
+ "searchUrl": {
+ "message": "https://af.wikipedia.org/wiki/Spesiaal:Soek"
+ },
+ "searchForm": {
+ "message": "https://af.wikipedia.org/wiki/Spesiaal:Soek?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://af.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/an/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/an/messages.json
new file mode 100644
index 0000000000..ec6fdbf01e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/an/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Biquipedia (an)"
+ },
+ "extensionDescription": {
+ "message": "A enciclopedia Libre"
+ },
+ "searchUrl": {
+ "message": "https://an.wikipedia.org/wiki/Especial:Mirar"
+ },
+ "searchForm": {
+ "message": "https://an.wikipedia.org/wiki/Especial:Mirar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://an.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ar/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ar/messages.json
new file mode 100644
index 0000000000..7b5b0a2cab
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ar/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ويكيبيديا (ar)"
+ },
+ "extensionDescription": {
+ "message": "ويكيبيديا (ar)"
+ },
+ "searchUrl": {
+ "message": "https://ar.wikipedia.org/wiki/خاص:بحث"
+ },
+ "searchForm": {
+ "message": "https://ar.wikipedia.org/wiki/خاص:بحث?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ar.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/as/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/as/messages.json
new file mode 100644
index 0000000000..ecdffd517e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/as/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (as)"
+ },
+ "extensionDescription": {
+ "message": "ৱিকিপিডিয়া, à¦à¦–ন মà§à¦•à§à¦¤ বিশà§à¦¬à¦•à§‹à¦·"
+ },
+ "searchUrl": {
+ "message": "https://as.wikipedia.org/wiki/বিশেষ:সনà§à¦§à¦¾à¦¨"
+ },
+ "searchForm": {
+ "message": "https://as.wikipedia.org/wiki/বিশেষ:সনà§à¦§à¦¾à¦¨?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://as.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ast/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ast/messages.json
new file mode 100644
index 0000000000..c6b9c18c88
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ast/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Uiquipedia (ast)"
+ },
+ "extensionDescription": {
+ "message": "La enciclopedia llibre"
+ },
+ "searchUrl": {
+ "message": "https://ast.wikipedia.org/wiki/Especial:Gueta"
+ },
+ "searchForm": {
+ "message": "https://ast.wikipedia.org/wiki/Especial:Gueta?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ast.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/az/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/az/messages.json
new file mode 100644
index 0000000000..638af3d874
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/az/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipediya (az)"
+ },
+ "extensionDescription": {
+ "message": "Vikipediya, açıq ensiklopediya"
+ },
+ "searchUrl": {
+ "message": "https://az.wikipedia.org/wiki/Xüsusi:Axtar"
+ },
+ "searchForm": {
+ "message": "https://az.wikipedia.org/wiki/Xüsusi:Axtar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://az.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/be-tarask/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/be-tarask/messages.json
new file mode 100644
index 0000000000..6a399c8268
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/be-tarask/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ВікіпÑÐ´Ñ‹Ñ (be-tarask)"
+ },
+ "extensionDescription": {
+ "message": "ВікіпÑдыÑ, Ð²Ð¾Ð»ÑŒÐ½Ð°Ñ ÑнцыклÑпÑдыÑ"
+ },
+ "searchUrl": {
+ "message": "https://be-tarask.wikipedia.org/wiki/СпÑцыÑльныÑ:Пошук"
+ },
+ "searchForm": {
+ "message": "https://be-tarask.wikipedia.org/wiki/СпÑцыÑльныÑ:Пошук?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://be-tarask.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/be/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/be/messages.json
new file mode 100644
index 0000000000..651ecfa2aa
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/be/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Ð’Ñ–ÐºÑ–Ð¿ÐµÐ´Ñ‹Ñ (be)"
+ },
+ "extensionDescription": {
+ "message": "ВікіпедыÑ, ÑÐ²Ð°Ð±Ð¾Ð´Ð½Ð°Ñ ÑнцыклапедыÑ"
+ },
+ "searchUrl": {
+ "message": "https://be.wikipedia.org/wiki/ÐдмыÑловае:Search"
+ },
+ "searchForm": {
+ "message": "https://be.wikipedia.org/wiki/ÐдмыÑловае:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://be.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/bg/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/bg/messages.json
new file mode 100644
index 0000000000..4061aa8e94
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/bg/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Ð£Ð¸ÐºÐ¸Ð¿ÐµÐ´Ð¸Ñ (bg)"
+ },
+ "extensionDescription": {
+ "message": "УикипедиÑ, Ñвободната енциклоподиÑ"
+ },
+ "searchUrl": {
+ "message": "https://bg.wikipedia.org/wiki/Специални:ТърÑене"
+ },
+ "searchForm": {
+ "message": "https://bg.wikipedia.org/wiki/Специални:ТърÑене?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://bg.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/bn/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/bn/messages.json
new file mode 100644
index 0000000000..c41a811f81
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/bn/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "উইকিপিডিয়া (bn)"
+ },
+ "extensionDescription": {
+ "message": "উইকিপিডিয়া, মà§à¦•à§à¦¤ বিশà§à¦¬à¦•à§‹à¦·"
+ },
+ "searchUrl": {
+ "message": "https://bn.wikipedia.org/wiki/বিশেষ:Search"
+ },
+ "searchForm": {
+ "message": "https://bn.wikipedia.org/wiki/বিশেষ:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://bn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/br/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/br/messages.json
new file mode 100644
index 0000000000..7f59a6dd26
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/br/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (br)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, an holloueziadur digor"
+ },
+ "searchUrl": {
+ "message": "https://br.wikipedia.org/wiki/Dibar:Klask"
+ },
+ "searchForm": {
+ "message": "https://br.wikipedia.org/wiki/Dibar:Klask?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://br.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/bs/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/bs/messages.json
new file mode 100644
index 0000000000..06e27829f3
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/bs/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (bs)"
+ },
+ "extensionDescription": {
+ "message": "Slobodna enciklopedija"
+ },
+ "searchUrl": {
+ "message": "https://bs.wikipedia.org/wiki/Posebno:Pretraga"
+ },
+ "searchForm": {
+ "message": "https://bs.wikipedia.org/wiki/Posebno:Pretraga?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://bs.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ca/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ca/messages.json
new file mode 100644
index 0000000000..22ab489096
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ca/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Viquipèdia (ca)"
+ },
+ "extensionDescription": {
+ "message": "L'enciclopèdia lliure"
+ },
+ "searchUrl": {
+ "message": "https://ca.wikipedia.org/wiki/Especial:Cerca"
+ },
+ "searchForm": {
+ "message": "https://ca.wikipedia.org/wiki/Especial:Cerca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ca.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/crh/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/crh/messages.json
new file mode 100644
index 0000000000..1e1a76452c
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/crh/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipediya (crh)"
+ },
+ "extensionDescription": {
+ "message": "Vikipediya, Azat Entsiklopediya"
+ },
+ "searchUrl": {
+ "message": "https://crh.wikipedia.org/wiki/Mahsus:Search"
+ },
+ "searchForm": {
+ "message": "https://crh.wikipedia.org/wiki/Mahsus:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://crh.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/cy/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/cy/messages.json
new file mode 100644
index 0000000000..62915e52a5
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/cy/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wicipedia (cy)"
+ },
+ "extensionDescription": {
+ "message": "Wicipedia, Y Gwyddioniadur Rhydd"
+ },
+ "searchUrl": {
+ "message": "https://cy.wikipedia.org/wiki/Arbennig:Search"
+ },
+ "searchForm": {
+ "message": "https://cy.wikipedia.org/wiki/Arbennig:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://cy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/cz/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/cz/messages.json
new file mode 100644
index 0000000000..53b17f885a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/cz/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedie (cs)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, svobodná encyclopedie"
+ },
+ "searchUrl": {
+ "message": "https://cs.wikipedia.org/wiki/Speciální:Hledání"
+ },
+ "searchForm": {
+ "message": "https://cs.wikipedia.org/wiki/Speciální:Hledání?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://cs.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/da/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/da/messages.json
new file mode 100644
index 0000000000..5deb79d125
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/da/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (da)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, den frie encyklopædi"
+ },
+ "searchUrl": {
+ "message": "https://da.wikipedia.org/wiki/Speciel:Søgning"
+ },
+ "searchForm": {
+ "message": "https://da.wikipedia.org/wiki/Speciel:Søgning?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://da.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/de/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/de/messages.json
new file mode 100644
index 0000000000..c4d2dd558c
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/de/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (de)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, die freie Enzyklopädie"
+ },
+ "searchUrl": {
+ "message": "https://de.wikipedia.org/wiki/Spezial:Suche"
+ },
+ "searchForm": {
+ "message": "https://de.wikipedia.org/wiki/Spezial:Suche?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://de.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/dsb/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/dsb/messages.json
new file mode 100644
index 0000000000..e8cb687084
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/dsb/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedija (dsb)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedija, lichotna encyklopedija"
+ },
+ "searchUrl": {
+ "message": "https://dsb.wikipedia.org/wiki/Specialne:PytaÅ›"
+ },
+ "searchForm": {
+ "message": "https://dsb.wikipedia.org/wiki/Specialne:PytaÅ›?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://dsb.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/el/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/el/messages.json
new file mode 100644
index 0000000000..5b8c494f98
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/el/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (el)"
+ },
+ "extensionDescription": {
+ "message": "Βικιπαίδεια, η ελεÏθεÏη εγκυκλοπαίδεια"
+ },
+ "searchUrl": {
+ "message": "https://el.wikipedia.org/wiki/Ειδικό:Αναζήτηση"
+ },
+ "searchForm": {
+ "message": "https://el.wikipedia.org/wiki/Ειδικό:Αναζήτηση?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://el.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/en/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/en/messages.json
new file mode 100644
index 0000000000..56c69e5c32
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/en/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (en)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the Free Encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://en.wikipedia.org/wiki/Special:Search"
+ },
+ "searchForm": {
+ "message": "https://en.wikipedia.org/wiki/Special:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://en.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/eo/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/eo/messages.json
new file mode 100644
index 0000000000..66b3212e5b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/eo/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipedio (eo)"
+ },
+ "extensionDescription": {
+ "message": "Vikipedio, la libera enciklopedio"
+ },
+ "searchUrl": {
+ "message": "https://eo.wikipedia.org/wiki/Specialaĵo:Serĉi"
+ },
+ "searchForm": {
+ "message": "https://eo.wikipedia.org/wiki/Specialaĵo:Serĉi?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://eo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/es/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/es/messages.json
new file mode 100644
index 0000000000..ff797d0834
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/es/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (es)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, la enciclopedia libre"
+ },
+ "searchUrl": {
+ "message": "https://es.wikipedia.org/wiki/Especial:Buscar"
+ },
+ "searchForm": {
+ "message": "https://es.wikipedia.org/wiki/Especial:Buscar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://es.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/et/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/et/messages.json
new file mode 100644
index 0000000000..25cf22893b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/et/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipeedia (et)"
+ },
+ "extensionDescription": {
+ "message": "Vikipeedia, vaba entsüklopeedia"
+ },
+ "searchUrl": {
+ "message": "https://et.wikipedia.org/wiki/Eri:Otsimine"
+ },
+ "searchForm": {
+ "message": "https://et.wikipedia.org/wiki/Eri:Otsimine?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://et.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/eu/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/eu/messages.json
new file mode 100644
index 0000000000..eea1c365d8
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/eu/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (eu)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, entziklopedia askea"
+ },
+ "searchUrl": {
+ "message": "https://eu.wikipedia.org/wiki/Berezi:Bilatu"
+ },
+ "searchForm": {
+ "message": "https://eu.wikipedia.org/wiki/Berezi:Bilatu?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://eu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/fa/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/fa/messages.json
new file mode 100644
index 0000000000..a48eca478f
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/fa/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ویکی‌پدیا (fa)"
+ },
+ "extensionDescription": {
+ "message": "ویکی‌پدیا، دانشنامهٔ آزاد"
+ },
+ "searchUrl": {
+ "message": "https://fa.wikipedia.org/wiki/ویژه:جستجو"
+ },
+ "searchForm": {
+ "message": "https://fa.wikipedia.org/wiki/ویژه:جستجو?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://fa.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/fi/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/fi/messages.json
new file mode 100644
index 0000000000..696a953ca1
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/fi/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (fi)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia (fi), vapaa tietosanakirja"
+ },
+ "searchUrl": {
+ "message": "https://fi.wikipedia.org/wiki/Toiminnot:Haku"
+ },
+ "searchForm": {
+ "message": "https://fi.wikipedia.org/wiki/Toiminnot:Haku?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://fi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/fr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/fr/messages.json
new file mode 100644
index 0000000000..80612181ad
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/fr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipédia (fr)"
+ },
+ "extensionDescription": {
+ "message": "Wikipédia, l'encyclopédie libre"
+ },
+ "searchUrl": {
+ "message": "https://fr.wikipedia.org/wiki/Spécial:Recherche"
+ },
+ "searchForm": {
+ "message": "https://fr.wikipedia.org/wiki/Spécial:Recherche?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://fr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/fy-NL/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/fy-NL/messages.json
new file mode 100644
index 0000000000..4767aad436
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/fy-NL/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedy (fy)"
+ },
+ "extensionDescription": {
+ "message": "De fergese ensyklopedy"
+ },
+ "searchUrl": {
+ "message": "https://fy.wikipedia.org/wiki/Wiki:Sykje"
+ },
+ "searchForm": {
+ "message": "https://fy.wikipedia.org/wiki/Wiki:Sykje?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://fy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ga-IE/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ga-IE/messages.json
new file mode 100644
index 0000000000..a9330bb066
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ga-IE/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vicipéid (ga)"
+ },
+ "extensionDescription": {
+ "message": "Vicipéid, an Chiclipéid Shaor"
+ },
+ "searchUrl": {
+ "message": "https://ga.wikipedia.org/wiki/Speisialta:Search"
+ },
+ "searchForm": {
+ "message": "https://ga.wikipedia.org/wiki/Speisialta:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ga.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/gd/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/gd/messages.json
new file mode 100644
index 0000000000..3b389e12c8
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/gd/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Uicipeid (gd)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, An leabhar mòr-eòlais"
+ },
+ "searchUrl": {
+ "message": "https://gd.wikipedia.org/wiki/Sònraichte:Search"
+ },
+ "searchForm": {
+ "message": "https://gd.wikipedia.org/wiki/Sònraichte:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://gd.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/gl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/gl/messages.json
new file mode 100644
index 0000000000..18cc3d73f6
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/gl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (gl)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, a enciclopedia libre"
+ },
+ "searchUrl": {
+ "message": "https://gl.wikipedia.org/wiki/Especial:Procurar"
+ },
+ "searchForm": {
+ "message": "https://gl.wikipedia.org/wiki/Especial:Procurar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://gl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/gn/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/gn/messages.json
new file mode 100644
index 0000000000..e914aa66bb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/gn/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipetã (gn)"
+ },
+ "extensionDescription": {
+ "message": "Vikipetã, opaite tembikuaa hekosãsóva renda"
+ },
+ "searchUrl": {
+ "message": "https://gn.wikipedia.org/wiki/Mba'echĩchĩ:Buscar"
+ },
+ "searchForm": {
+ "message": "https://gn.wikipedia.org/wiki/Mba'echĩchĩ:Buscar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://gn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/gu/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/gu/messages.json
new file mode 100644
index 0000000000..f268ccb6af
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/gu/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "વિકિપીડિયા (gu)"
+ },
+ "extensionDescription": {
+ "message": "વીકીપીડિયા, મà«àª•à«àª¤ àªàª¨àª¸àª¾àª¯àª•à«àª²à«‹àªªà«€àª¡àª¿àª¯àª¾"
+ },
+ "searchUrl": {
+ "message": "https://gu.wikipedia.org/wiki/વિશેષ:શોધ"
+ },
+ "searchForm": {
+ "message": "https://gu.wikipedia.org/wiki/વિશેષ:શોધ?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://gu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/he/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/he/messages.json
new file mode 100644
index 0000000000..f8a548f72c
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/he/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ויקיפדיה"
+ },
+ "extensionDescription": {
+ "message": "ויקיפדיה"
+ },
+ "searchUrl": {
+ "message": "https://he.wikipedia.org/wiki/מיוחד:חיפוש"
+ },
+ "searchForm": {
+ "message": "https://he.wikipedia.org/wiki/מיוחד:חיפוש?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://he.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hi/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hi/messages.json
new file mode 100644
index 0000000000..7db4ed195f
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hi/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "विकिपीडिया (hi)"
+ },
+ "extensionDescription": {
+ "message": "विकिपीडिया (हिनà¥à¤¦à¥€)"
+ },
+ "searchUrl": {
+ "message": "https://hi.wikipedia.org/wiki/विशेष:खोज"
+ },
+ "searchForm": {
+ "message": "https://hi.wikipedia.org/wiki/विशेष:खोज?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hr/messages.json
new file mode 100644
index 0000000000..f02f694c9a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedija (hr)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedija, slobodna enciklopedija"
+ },
+ "searchUrl": {
+ "message": "https://hr.wikipedia.org/wiki/Posebno:Traži"
+ },
+ "searchForm": {
+ "message": "https://hr.wikipedia.org/wiki/Posebno:Traži?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hsb/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hsb/messages.json
new file mode 100644
index 0000000000..a459ffbe46
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hsb/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedija (hsb)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedija, swobodna encyklopedija"
+ },
+ "searchUrl": {
+ "message": "https://hsb.wikipedia.org/wiki/Specialnje:Pytać"
+ },
+ "searchForm": {
+ "message": "https://hsb.wikipedia.org/wiki/Specialnje:Pytać?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hsb.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hu/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hu/messages.json
new file mode 100644
index 0000000000..37e725d687
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hu/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipédia (hu)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, a szabad enciklopédia"
+ },
+ "searchUrl": {
+ "message": "https://hu.wikipedia.org/wiki/Speciális:Keresés"
+ },
+ "searchForm": {
+ "message": "https://hu.wikipedia.org/wiki/Speciális:Keresés?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hy/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hy/messages.json
new file mode 100644
index 0000000000..a6d527cc4e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hy/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (hy)"
+ },
+ "extensionDescription": {
+ "message": "ÕŽÕ«Ö„Õ«ÖƒÕ¥Õ¤Õ«Õ¡Õ Õ¡Õ¦Õ¡Õ¿ Õ°Õ¡Õ¶Ö€Õ¡Õ£Õ«Õ¿Õ¡Ö€Õ¡Õ¶"
+ },
+ "searchUrl": {
+ "message": "https://hy.wikipedia.org/wiki/ÕÕºÕ¡Õ½Õ¡Ö€Õ¯Õ¸Õ²:ÕˆÖ€Õ¸Õ¶Õ¥Õ¬"
+ },
+ "searchForm": {
+ "message": "https://hy.wikipedia.org/wiki/ÕÕºÕ¡Õ½Õ¡Ö€Õ¯Õ¸Õ²:ÕˆÖ€Õ¸Õ¶Õ¥Õ¬?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ia/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ia/messages.json
new file mode 100644
index 0000000000..fbca585c30
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ia/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (ia)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, le encyclopedia libere"
+ },
+ "searchUrl": {
+ "message": "https://ia.wikipedia.org/wiki/Special:Recerca"
+ },
+ "searchForm": {
+ "message": "https://ia.wikipedia.org/wiki/Special:Recerca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ia.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/id/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/id/messages.json
new file mode 100644
index 0000000000..608dad05b7
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/id/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (id)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, ensiklopedia bebas"
+ },
+ "searchUrl": {
+ "message": "https://id.wikipedia.org/wiki/Istimewa:Pencarian"
+ },
+ "searchForm": {
+ "message": "https://id.wikipedia.org/wiki/Istimewa:Pencarian?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://id.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/is/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/is/messages.json
new file mode 100644
index 0000000000..f33e0dd015
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/is/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (is)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the free encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://is.wikipedia.org/wiki/Kerfissíða:Leit"
+ },
+ "searchForm": {
+ "message": "https://is.wikipedia.org/wiki/Kerfissíða:Leit?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://is.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/it/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/it/messages.json
new file mode 100644
index 0000000000..27cff3c07b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/it/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (it)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, l'enciclopedia libera"
+ },
+ "searchUrl": {
+ "message": "https://it.wikipedia.org/wiki/Speciale:Ricerca"
+ },
+ "searchForm": {
+ "message": "https://it.wikipedia.org/wiki/Speciale:Ricerca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://it.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ja/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ja/messages.json
new file mode 100644
index 0000000000..84df085d54
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ja/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (ja)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia - フリー百科事典"
+ },
+ "searchUrl": {
+ "message": "https://ja.wikipedia.org/wiki/特別:検索"
+ },
+ "searchForm": {
+ "message": "https://ja.wikipedia.org/wiki/特別:検索?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ja.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ka/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ka/messages.json
new file mode 100644
index 0000000000..efe876cded
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ka/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ვიკიპედირ(ka)"
+ },
+ "extensionDescription": {
+ "message": "ვიკიპედიáƒ, თáƒáƒ•áƒ˜áƒ¡áƒ£áƒ¤áƒáƒšáƒ˜ ენციკლáƒáƒžáƒ”დიáƒ"
+ },
+ "searchUrl": {
+ "message": "https://ka.wikipedia.org/wiki/სპეციáƒáƒšáƒ£áƒ áƒ˜:ძიებáƒ"
+ },
+ "searchForm": {
+ "message": "https://ka.wikipedia.org/wiki/სპეციáƒáƒšáƒ£áƒ áƒ˜:ძიებáƒ?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ka.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/kab/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/kab/messages.json
new file mode 100644
index 0000000000..375b69d098
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/kab/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (kab)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, tasanayt tilellit"
+ },
+ "searchUrl": {
+ "message": "https://kab.wikipedia.org/wiki/Uslig:Search"
+ },
+ "searchForm": {
+ "message": "https://kab.wikipedia.org/wiki/Uslig:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://kab.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/kk/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/kk/messages.json
new file mode 100644
index 0000000000..ae3ce7cc1a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/kk/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Ð£Ð¸ÐºÐ¸Ð¿ÐµÐ´Ð¸Ñ (kk)"
+ },
+ "extensionDescription": {
+ "message": "Ð£Ð¸ÐºÐ¸Ð¿ÐµÐ´Ð¸Ñ (kk)"
+ },
+ "searchUrl": {
+ "message": "https://kk.wikipedia.org/wiki/Ðрнайы:Іздеу"
+ },
+ "searchForm": {
+ "message": "https://kk.wikipedia.org/wiki/Ðрнайы:Іздеу?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://kk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/km/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/km/messages.json
new file mode 100644
index 0000000000..ca51223b15
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/km/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "វីគីភីឌា (km)"
+ },
+ "extensionDescription": {
+ "message": "វីគីភីឌា សព្វ​វចនា​ធិប្បាយ​សáŸážšáž¸"
+ },
+ "searchUrl": {
+ "message": "https://km.wikipedia.org/wiki/ពិសáŸážŸ:ស្វែងរក"
+ },
+ "searchForm": {
+ "message": "https://km.wikipedia.org/wiki/ពិសáŸážŸ:ស្វែងរក?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://km.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/kn/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/kn/messages.json
new file mode 100644
index 0000000000..20958768cb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/kn/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (kn)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the free encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://kn.wikipedia.org/wiki/ವಿಶೇಷ:Search"
+ },
+ "searchForm": {
+ "message": "https://kn.wikipedia.org/wiki/ವಿಶೇಷ:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://kn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/kr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/kr/messages.json
new file mode 100644
index 0000000000..9504c3ffb9
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/kr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "위키백과 (ko)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the free encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://ko.wikipedia.org/wiki/특수기능:찾기"
+ },
+ "searchForm": {
+ "message": "https://ko.wikipedia.org/wiki/특수기능:찾기?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ko.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/lij/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/lij/messages.json
new file mode 100644
index 0000000000..83a20b3776
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/lij/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (lij)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, l'enciclopedia libera"
+ },
+ "searchUrl": {
+ "message": "https://lij.wikipedia.org/wiki/Speçiale:Riçerca"
+ },
+ "searchForm": {
+ "message": "https://lij.wikipedia.org/wiki/Speçiale:Riçerca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://lij.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/lo/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/lo/messages.json
new file mode 100644
index 0000000000..30f9b0ff26
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/lo/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ວິàºàº´àºžàºµà»€àº”ຠ(lo)"
+ },
+ "extensionDescription": {
+ "message": "ວິàºàº´àºžàºµà»€àº”àº, ສາລານຸàºàº»àº¡à»€àºªàº¥àºµ"
+ },
+ "searchUrl": {
+ "message": "https://lo.wikipedia.org/wiki/ພິເສດ:ຊອàºàº«àº²"
+ },
+ "searchForm": {
+ "message": "https://lo.wikipedia.org/wiki/ພິເສດ:ຊອàºàº«àº²?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://lo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/lt/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/lt/messages.json
new file mode 100644
index 0000000000..93ce604144
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/lt/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (lt)"
+ },
+ "extensionDescription": {
+ "message": "Vikipedija, laisvoji enciklopedija"
+ },
+ "searchUrl": {
+ "message": "https://lt.wikipedia.org/wiki/Specialus:Paieška"
+ },
+ "searchForm": {
+ "message": "https://lt.wikipedia.org/wiki/Specialus:Paieška?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://lt.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ltg/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ltg/messages.json
new file mode 100644
index 0000000000..0456f31dba
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ltg/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipedeja (ltg)"
+ },
+ "extensionDescription": {
+ "message": "Vikipēdija, breivuo eņciklopedeja"
+ },
+ "searchUrl": {
+ "message": "https://ltg.wikipedia.org/wiki/Seviškuo:Search"
+ },
+ "searchForm": {
+ "message": "https://ltg.wikipedia.org/wiki/Seviškuo:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ltg.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/lv/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/lv/messages.json
new file mode 100644
index 0000000000..90b542f4b4
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/lv/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipēdija"
+ },
+ "extensionDescription": {
+ "message": "VikipÄ“dija, brÄ«vÄ enciklopÄ“dija"
+ },
+ "searchUrl": {
+ "message": "https://lv.wikipedia.org/wiki/Special:Search"
+ },
+ "searchForm": {
+ "message": "https://lv.wikipedia.org/wiki/Special:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://lv.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/mk/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/mk/messages.json
new file mode 100644
index 0000000000..ae826197ff
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/mk/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Википедија (mk)"
+ },
+ "extensionDescription": {
+ "message": "Википедија, Ñлободната енциклопедија"
+ },
+ "searchUrl": {
+ "message": "https://mk.wikipedia.org/wiki/Специјална:Барај"
+ },
+ "searchForm": {
+ "message": "https://mk.wikipedia.org/wiki/Специјална:Барај?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://mk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ml/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ml/messages.json
new file mode 100644
index 0000000000..9f4b397904
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ml/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "വികàµà´•à´¿à´ªàµ€à´¡à´¿à´¯ (ml)"
+ },
+ "extensionDescription": {
+ "message": "വികàµà´•à´¿à´ªàµ€à´¡à´¿à´¯, à´¸àµà´µà´¤à´¨àµà´¤àµà´° സരàµâ€à´µàµà´µà´µà´¿à´œàµà´žà´¾à´¨à´•àµ‹à´¶à´‚ "
+ },
+ "searchUrl": {
+ "message": "https://ml.wikipedia.org/wiki/à´ªàµà´°à´¤àµà´¯àµ‡à´•à´‚:à´…à´¨àµà´µàµ‡à´·à´£à´‚"
+ },
+ "searchForm": {
+ "message": "https://ml.wikipedia.org/wiki/à´ªàµà´°à´¤àµà´¯àµ‡à´•à´‚:à´…à´¨àµà´µàµ‡à´·à´£à´‚?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ml.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/mr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/mr/messages.json
new file mode 100644
index 0000000000..42bd8e2426
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/mr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "विकिपीडिया (mr)"
+ },
+ "extensionDescription": {
+ "message": "विकिपीडिया, मोफत माहितीकोष"
+ },
+ "searchUrl": {
+ "message": "https://mr.wikipedia.org/wiki/विशेष:शोधा"
+ },
+ "searchForm": {
+ "message": "https://mr.wikipedia.org/wiki/विशेष:शोधा?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://mr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ms/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ms/messages.json
new file mode 100644
index 0000000000..e804b1796a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ms/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (ms)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, ensiklopedia bebas"
+ },
+ "searchUrl": {
+ "message": "https://ms.wikipedia.org/wiki/Khas:Gelintar"
+ },
+ "searchForm": {
+ "message": "https://ms.wikipedia.org/wiki/Khas:Gelintar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ms.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/my/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/my/messages.json
new file mode 100644
index 0000000000..e0fda1a466
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/my/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (my)"
+ },
+ "extensionDescription": {
+ "message": "အá€á€™á€²á€·á€œá€½á€á€ºá€œá€•á€ºá€…ွယ်စုံကျမ်း"
+ },
+ "searchUrl": {
+ "message": "https://my.wikipedia.org/wiki/Special:Search"
+ },
+ "searchForm": {
+ "message": "https://my.wikipedia.org/wiki/Special:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://my.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ne/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ne/messages.json
new file mode 100644
index 0000000000..1d549a943e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ne/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "विकिपीडिया (ne)"
+ },
+ "extensionDescription": {
+ "message": "विकिपिडिया à¤à¤• सà¥à¤µà¤¤à¤¨à¥à¤¤à¥à¤° विशà¥à¤µà¤•à¥‹à¤·"
+ },
+ "searchUrl": {
+ "message": "https://ne.wikipedia.org/wiki/विशेष:Search"
+ },
+ "searchForm": {
+ "message": "https://ne.wikipedia.org/wiki/विशेष:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ne.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/nl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/nl/messages.json
new file mode 100644
index 0000000000..2dda1f6deb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/nl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (nl)"
+ },
+ "extensionDescription": {
+ "message": "De vrije encyclopedie"
+ },
+ "searchUrl": {
+ "message": "https://nl.wikipedia.org/wiki/Speciaal:Zoeken"
+ },
+ "searchForm": {
+ "message": "https://nl.wikipedia.org/wiki/Speciaal:Zoeken?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://nl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/oc/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/oc/messages.json
new file mode 100644
index 0000000000..b341752c63
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/oc/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipèdia (oc)"
+ },
+ "extensionDescription": {
+ "message": "Wikipèdia, l'enciclopèdia liura"
+ },
+ "searchUrl": {
+ "message": "https://oc.wikipedia.org/wiki/Especial:Recèrca"
+ },
+ "searchForm": {
+ "message": "https://oc.wikipedia.org/wiki/Especial:Recèrca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://oc.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/or/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/or/messages.json
new file mode 100644
index 0000000000..b07957a4b7
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/or/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (or)"
+ },
+ "extensionDescription": {
+ "message": "ୱିକିପିଡ଼ିଆ (ଓଡ଼ିଆ)"
+ },
+ "searchUrl": {
+ "message": "https://or.wikipedia.org/wiki/ବିଶେଷ:ଖୋଜନà­à¬¤à­"
+ },
+ "searchForm": {
+ "message": "https://or.wikipedia.org/wiki/ବିଶେଷ:ଖୋଜନà­à¬¤à­?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://or.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/pa/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/pa/messages.json
new file mode 100644
index 0000000000..07804ca265
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/pa/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (pa)"
+ },
+ "extensionDescription": {
+ "message": "ਵਿਕਿਪੀਡਿਆ, ਮà©à¨«à¨¼à¨¤/ਮà©à¨•à¨¤ ਸ਼ਬਦਕੋਸ਼"
+ },
+ "searchUrl": {
+ "message": "https://pa.wikipedia.org/wiki/ਖ਼ਾਸ:ਖੋਜੋ"
+ },
+ "searchForm": {
+ "message": "https://pa.wikipedia.org/wiki/ਖ਼ਾਸ:ਖੋਜੋ?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://pa.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/pl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/pl/messages.json
new file mode 100644
index 0000000000..3e7704b21a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/pl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (pl)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, wolna encyklopedia"
+ },
+ "searchUrl": {
+ "message": "https://pl.wikipedia.org/wiki/Specjalna:Szukaj"
+ },
+ "searchForm": {
+ "message": "https://pl.wikipedia.org/wiki/Specjalna:Szukaj?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://pl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/pt/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/pt/messages.json
new file mode 100644
index 0000000000..8e9bc20c77
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/pt/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (pt)"
+ },
+ "extensionDescription": {
+ "message": "Wikipédia, a enciclopédia livre"
+ },
+ "searchUrl": {
+ "message": "https://pt.wikipedia.org/wiki/Especial:Pesquisar"
+ },
+ "searchForm": {
+ "message": "https://pt.wikipedia.org/wiki/Especial:Pesquisar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://pt.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/rm/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/rm/messages.json
new file mode 100644
index 0000000000..889657ad7e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/rm/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (rm)"
+ },
+ "extensionDescription": {
+ "message": "Vichipedia, l'enciclopedia libra"
+ },
+ "searchUrl": {
+ "message": "https://rm.wikipedia.org/wiki/Spezial:Search"
+ },
+ "searchForm": {
+ "message": "https://rm.wikipedia.org/wiki/Spezial:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://rm.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ro/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ro/messages.json
new file mode 100644
index 0000000000..8aac1e2244
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ro/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (ro)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, enciclopedia liberă"
+ },
+ "searchUrl": {
+ "message": "https://ro.wikipedia.org/wiki/Special:Căutare"
+ },
+ "searchForm": {
+ "message": "https://ro.wikipedia.org/wiki/Special:Căutare?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ro.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ru/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ru/messages.json
new file mode 100644
index 0000000000..49243790a6
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ru/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Ð’Ð¸ÐºÐ¸Ð¿ÐµÐ´Ð¸Ñ (ru)"
+ },
+ "extensionDescription": {
+ "message": "ВикипедиÑ, ÑÐ²Ð¾Ð±Ð¾Ð´Ð½Ð°Ñ ÑнциклопедиÑ"
+ },
+ "searchUrl": {
+ "message": "https://ru.wikipedia.org/wiki/СлужебнаÑ:ПоиÑк"
+ },
+ "searchForm": {
+ "message": "https://ru.wikipedia.org/wiki/СлужебнаÑ:ПоиÑк?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ru.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/si/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/si/messages.json
new file mode 100644
index 0000000000..dea75a5308
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/si/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (si)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the free encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://si.wikipedia.org/wiki/විà·à·šà·‚:ගවේෂණය"
+ },
+ "searchForm": {
+ "message": "https://si.wikipedia.org/wiki/විà·à·šà·‚:ගවේෂණය?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://si.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sk/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sk/messages.json
new file mode 100644
index 0000000000..3cfa642df7
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sk/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipédia (sk)"
+ },
+ "extensionDescription": {
+ "message": "Wikipédia, slobodná a otvorená encyklopédia"
+ },
+ "searchUrl": {
+ "message": "https://sk.wikipedia.org/wiki/Špeciálne:Hľadanie"
+ },
+ "searchForm": {
+ "message": "https://sk.wikipedia.org/wiki/Špeciálne:Hľadanie?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sl/messages.json
new file mode 100644
index 0000000000..0e9b8433a4
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedija (sl)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedija, prosta enciklopedija"
+ },
+ "searchUrl": {
+ "message": "https://sl.wikipedia.org/wiki/Posebno:Iskanje"
+ },
+ "searchForm": {
+ "message": "https://sl.wikipedia.org/wiki/Posebno:Iskanje?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sq/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sq/messages.json
new file mode 100644
index 0000000000..6d6240b54f
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sq/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (sq)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, enciklopedia e lirë"
+ },
+ "searchUrl": {
+ "message": "https://sq.wikipedia.org/wiki/Speciale:Kërkim"
+ },
+ "searchForm": {
+ "message": "https://sq.wikipedia.org/wiki/Speciale:Kërkim?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sq.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sr/messages.json
new file mode 100644
index 0000000000..cf1558ffe9
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Википедија (sr)"
+ },
+ "extensionDescription": {
+ "message": "Претрага Википедије на ÑрпÑком језику"
+ },
+ "searchUrl": {
+ "message": "https://sr.wikipedia.org/wiki/ПоÑебно:Претражи"
+ },
+ "searchForm": {
+ "message": "https://sr.wikipedia.org/wiki/ПоÑебно:Претражи?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sv-SE/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sv-SE/messages.json
new file mode 100644
index 0000000000..4912d25364
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sv-SE/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (sv)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, den fria encyklopedin"
+ },
+ "searchUrl": {
+ "message": "https://sv.wikipedia.org/wiki/Special:Sök"
+ },
+ "searchForm": {
+ "message": "https://sv.wikipedia.org/wiki/Special:Sök?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sv.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ta/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ta/messages.json
new file mode 100644
index 0000000000..b6de753a1f
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ta/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "விகà¯à®•à®¿à®ªà¯à®ªà¯€à®Ÿà®¿à®¯à®¾ (ta)"
+ },
+ "extensionDescription": {
+ "message": "விகà¯à®•à®¿à®ªà¯à®ªà¯€à®Ÿà®¿à®¯à®¾ (ta)"
+ },
+ "searchUrl": {
+ "message": "https://ta.wikipedia.org/wiki/சிறபà¯à®ªà¯:Search"
+ },
+ "searchForm": {
+ "message": "https://ta.wikipedia.org/wiki/சிறபà¯à®ªà¯:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ta.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/te/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/te/messages.json
new file mode 100644
index 0000000000..f9352844d1
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/te/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "వికీపీడియా (te)"
+ },
+ "extensionDescription": {
+ "message": "వికీపీడియా (te)"
+ },
+ "searchUrl": {
+ "message": "https://te.wikipedia.org/wiki/à°ªà±à°°à°¤à±à°¯à±‡à°•:à°…à°¨à±à°µà±‡à°·à°£"
+ },
+ "searchForm": {
+ "message": "https://te.wikipedia.org/wiki/à°ªà±à°°à°¤à±à°¯à±‡à°•:à°…à°¨à±à°µà±‡à°·à°£?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://te.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/th/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/th/messages.json
new file mode 100644
index 0000000000..0176b84b7b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/th/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "วิà¸à¸´à¸žà¸µà¹€à¸”ีย"
+ },
+ "extensionDescription": {
+ "message": "วิà¸à¸´à¸žà¸µà¹€à¸”ีย สารานุà¸à¸£à¸¡à¹€à¸ªà¸£à¸µ"
+ },
+ "searchUrl": {
+ "message": "https://th.wikipedia.org/wiki/พิเศษ:ค้นหา"
+ },
+ "searchForm": {
+ "message": "https://th.wikipedia.org/wiki/พิเศษ:ค้นหา?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://th.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/tl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/tl/messages.json
new file mode 100644
index 0000000000..a5faae07cd
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/tl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (tl)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, ang malayang ensiklopedya"
+ },
+ "searchUrl": {
+ "message": "https://tl.wikipedia.org/wiki/Natatangi:Maghanap"
+ },
+ "searchForm": {
+ "message": "https://tl.wikipedia.org/wiki/Natatangi:Maghanap?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://tl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/tr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/tr/messages.json
new file mode 100644
index 0000000000..7551d615f0
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/tr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (tr)"
+ },
+ "extensionDescription": {
+ "message": "Vikipedi, özgür ansiklopedi"
+ },
+ "searchUrl": {
+ "message": "https://tr.wikipedia.org/wiki/Özel:Ara"
+ },
+ "searchForm": {
+ "message": "https://tr.wikipedia.org/wiki/Özel:Ara?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://tr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/uk/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/uk/messages.json
new file mode 100644
index 0000000000..c81490394b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/uk/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Ð’Ñ–ÐºÑ–Ð¿ÐµÐ´Ñ–Ñ (uk)"
+ },
+ "extensionDescription": {
+ "message": "ВікіпедіÑ, вільна енциклопедіÑ"
+ },
+ "searchUrl": {
+ "message": "https://uk.wikipedia.org/wiki/Спеціальна:Пошук"
+ },
+ "searchForm": {
+ "message": "https://uk.wikipedia.org/wiki/Спеціальна:Пошук?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://uk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ur/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ur/messages.json
new file mode 100644
index 0000000000..f27436e3a4
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ur/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ویکیپیڈیا (ur)"
+ },
+ "extensionDescription": {
+ "message": "ویکیپیڈیا آزاد دائرۃ المعارÙ"
+ },
+ "searchUrl": {
+ "message": "https://ur.wikipedia.org/wiki/خاص:تلاش"
+ },
+ "searchForm": {
+ "message": "https://ur.wikipedia.org/wiki/خاص:تلاش?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ur.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/uz/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/uz/messages.json
new file mode 100644
index 0000000000..eff11c31e2
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/uz/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipediya (uz)"
+ },
+ "extensionDescription": {
+ "message": "Vikipediya, ochiq ensiklopediya"
+ },
+ "searchUrl": {
+ "message": "https://uz.wikipedia.org/wiki/Maxsus:Search"
+ },
+ "searchForm": {
+ "message": "https://uz.wikipedia.org/wiki/Maxsus:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://uz.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/vi/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/vi/messages.json
new file mode 100644
index 0000000000..cfab54090e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/vi/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (vi)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, bách khoa toàn thư mở"
+ },
+ "searchUrl": {
+ "message": "https://vi.wikipedia.org/wiki/Äặc_biệt:Tìm_kiếm"
+ },
+ "searchForm": {
+ "message": "https://vi.wikipedia.org/wiki/Äặc_biệt:Tìm_kiếm?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://vi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/wo/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/wo/messages.json
new file mode 100644
index 0000000000..43f133c54b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/wo/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (wo)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, Jimbulang bu Ubbeeku bi"
+ },
+ "searchUrl": {
+ "message": "https://wo.wikipedia.org/wiki/Jagleel:Ceet"
+ },
+ "searchForm": {
+ "message": "https://wo.wikipedia.org/wiki/Jagleel:Ceet?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://wo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/zh-CN/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/zh-CN/messages.json
new file mode 100644
index 0000000000..840677e3dd
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/zh-CN/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "维基百科"
+ },
+ "extensionDescription": {
+ "message": "维基百科,自由的百科全书"
+ },
+ "searchUrl": {
+ "message": "https://zh.wikipedia.org/wiki/Special:æœç´¢"
+ },
+ "searchForm": {
+ "message": "https://zh.wikipedia.org/wiki/Special:æœç´¢?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://zh.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/zh-TW/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/zh-TW/messages.json
new file mode 100644
index 0000000000..60151f4265
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/zh-TW/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (zh)"
+ },
+ "extensionDescription": {
+ "message": "維基百科,自由的百科全書"
+ },
+ "searchUrl": {
+ "message": "https://zh.wikipedia.org/wiki/Special:æœç´¢"
+ },
+ "searchForm": {
+ "message": "https://zh.wikipedia.org/wiki/Special:æœç´¢?search={searchTerms}&sourceid=Mozilla-search&variant=zh-tw"
+ },
+ "suggestUrl": {
+ "message": "https://zh.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search&variant=zh-tw"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/favicon.ico b/comm/mail/components/search/extensions/wikipedia/favicon.ico
new file mode 100644
index 0000000000..4314071e24
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/wikipedia/manifest.json b/comm/mail/components/search/extensions/wikipedia/manifest.json
new file mode 100644
index 0000000000..f26061aadb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "wikipedia@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "en",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "suggest_url": "__MSG_suggestUrl__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/wiktionary/_locales/oc/messages.json b/comm/mail/components/search/extensions/wiktionary/_locales/oc/messages.json
new file mode 100644
index 0000000000..612ef8c44a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wiktionary/_locales/oc/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikiccionari (oc)"
+ },
+ "extensionDescription": {
+ "message": "Wikiccionari, lo diccionari liure"
+ },
+ "searchUrl": {
+ "message": "https://oc.wiktionary.org/wiki/Especial:Recèrca"
+ },
+ "searchForm": {
+ "message": "https://oc.wiktionary.org/wiki/Especial:Recèrca?search={searchTerms}"
+ },
+ "suggestUrl": {
+ "message": "https://oc.wiktionary.org/w/api.php?action=opensearch&search={searchTerms}&namespace=0"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wiktionary/_locales/te/messages.json b/comm/mail/components/search/extensions/wiktionary/_locales/te/messages.json
new file mode 100644
index 0000000000..7bca94e025
--- /dev/null
+++ b/comm/mail/components/search/extensions/wiktionary/_locales/te/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "వికà±à°·à°¨à°°à±€ (te)"
+ },
+ "extensionDescription": {
+ "message": "వికà±à°·à°¨à°°à±€ (te)"
+ },
+ "searchUrl": {
+ "message": "https://te.wiktionary.org/wiki/à°ªà±à°°à°¤à±à°¯à±‡à°•:à°…à°¨à±à°µà±‡à°·à°£"
+ },
+ "searchForm": {
+ "message": "https://te.wiktionary.org/wiki/à°ªà±à°°à°¤à±à°¯à±‡à°•:à°…à°¨à±à°µà±‡à°·à°£?search={searchTerms}"
+ },
+ "suggestUrl": {
+ "message": "https://te.wiktionary.org/w/api.php?action=opensearch&search={searchTerms}&namespace=0"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wiktionary/favicon.ico b/comm/mail/components/search/extensions/wiktionary/favicon.ico
new file mode 100644
index 0000000000..31b0e38092
--- /dev/null
+++ b/comm/mail/components/search/extensions/wiktionary/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/wiktionary/manifest.json b/comm/mail/components/search/extensions/wiktionary/manifest.json
new file mode 100644
index 0000000000..2206a2f8bb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wiktionary/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "wiktionary@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "oc",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "suggest_url": "__MSG_suggestUrl__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/wolnelektury-pl/favicon.png b/comm/mail/components/search/extensions/wolnelektury-pl/favicon.png
new file mode 100644
index 0000000000..77f6db5322
--- /dev/null
+++ b/comm/mail/components/search/extensions/wolnelektury-pl/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/wolnelektury-pl/manifest.json b/comm/mail/components/search/extensions/wolnelektury-pl/manifest.json
new file mode 100644
index 0000000000..25d93be370
--- /dev/null
+++ b/comm/mail/components/search/extensions/wolnelektury-pl/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Wolne Lektury",
+ "description": "Biblioteka internetowa WolneLektury.pl",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "wolnelektury-pl@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Wolne Lektury",
+ "search_url": "https://wolnelektury.pl/szukaj/?q={searchTerms}",
+ "search_form": "https://wolnelektury.pl",
+ "suggest_url": "https://wolnelektury.pl/katalog/jtags/?mozhint=1&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/yahoo-jp-auctions/favicon.ico b/comm/mail/components/search/extensions/yahoo-jp-auctions/favicon.ico
new file mode 100644
index 0000000000..4401c7a40e
--- /dev/null
+++ b/comm/mail/components/search/extensions/yahoo-jp-auctions/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/yahoo-jp-auctions/manifest.json b/comm/mail/components/search/extensions/yahoo-jp-auctions/manifest.json
new file mode 100644
index 0000000000..62f5fe20b4
--- /dev/null
+++ b/comm/mail/components/search/extensions/yahoo-jp-auctions/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "ヤフオク!",
+ "description": "ヤフオク! 検索",
+ "manifest_version": 2,
+ "version": "1.3",
+ "applications": {
+ "gecko": {
+ "id": "yahoo-jp-auctions@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "ヤフオク!",
+ "encoding": "EUC-JP",
+ "search_url": "https://auctions.yahoo.co.jp/search/search",
+ "search_form": "https://auctions.yahoo.co.jp/",
+ "search_url_get_params": "p={searchTerms}&ei=EUC-JP"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/yahoo-jp/favicon.ico b/comm/mail/components/search/extensions/yahoo-jp/favicon.ico
new file mode 100644
index 0000000000..34a916ccde
--- /dev/null
+++ b/comm/mail/components/search/extensions/yahoo-jp/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/yahoo-jp/manifest.json b/comm/mail/components/search/extensions/yahoo-jp/manifest.json
new file mode 100644
index 0000000000..6f0c339fbd
--- /dev/null
+++ b/comm/mail/components/search/extensions/yahoo-jp/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Yahoo! JAPAN",
+ "description": "Yahoo Search",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "yahoo-jp@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Yahoo! JAPAN",
+ "search_url": "https://search.yahoo.co.jp/search",
+ "search_form": "https://search.yahoo.co.jp/",
+ "search_url_get_params": "p={searchTerms}&ei=UTF-8"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/az/messages.json b/comm/mail/components/search/extensions/yandex/_locales/az/messages.json
new file mode 100644
index 0000000000..a26b49f041
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/az/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Yandex"
+ },
+ "extensionDescription": {
+ "message": "İnternetdə axtarış üçün Yandexdən istifadə edin."
+ },
+ "searchUrl": {
+ "message": "https://yandex.az/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.az/"
+ },
+ "suggestUrl": {
+ "message": "https://yandex.az/suggest/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-ru.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/by/messages.json b/comm/mail/components/search/extensions/yandex/_locales/by/messages.json
new file mode 100644
index 0000000000..440c062610
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/by/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "ЯндекÑ"
+ },
+ "extensionDescription": {
+ "message": "Пошук з дапамогаю ЯндекÑ"
+ },
+ "searchUrl": {
+ "message": "https://yandex.by/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.by/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.by/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-ru.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/en/messages.json b/comm/mail/components/search/extensions/yandex/_locales/en/messages.json
new file mode 100644
index 0000000000..593c0c93e7
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/en/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Yandex"
+ },
+ "extensionDescription": {
+ "message": "Use Yandex to search the Internet."
+ },
+ "searchUrl": {
+ "message": "https://www.yandex.com/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.com/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.com/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-en.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/kk/messages.json b/comm/mail/components/search/extensions/yandex/_locales/kk/messages.json
new file mode 100644
index 0000000000..2796cd93ec
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/kk/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "ЯндекÑ"
+ },
+ "extensionDescription": {
+ "message": "ВоÑпользуйтеÑÑŒ ЯндекÑом Ð´Ð»Ñ Ð¿Ð¾Ð¸Ñка в Интернете."
+ },
+ "searchUrl": {
+ "message": "https://yandex.kz/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.kz/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.kz/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-ru.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/ru/messages.json b/comm/mail/components/search/extensions/yandex/_locales/ru/messages.json
new file mode 100644
index 0000000000..cdc5ff13be
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/ru/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "ЯндекÑ"
+ },
+ "extensionDescription": {
+ "message": "ВоÑпользуйтеÑÑŒ ЯндекÑом Ð´Ð»Ñ Ð¿Ð¾Ð¸Ñка в Интернете."
+ },
+ "searchUrl": {
+ "message": "https://yandex.ru/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.ru/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.ru/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-ru.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/tr/messages.json b/comm/mail/components/search/extensions/yandex/_locales/tr/messages.json
new file mode 100644
index 0000000000..55d16d9956
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/tr/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Yandex"
+ },
+ "extensionDescription": {
+ "message": "Yandex Türkiye arama motoru"
+ },
+ "searchUrl": {
+ "message": "https://yandex.com.tr/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.com.tr/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.com.tr/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-en.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/manifest.json b/comm/mail/components/search/extensions/yandex/manifest.json
new file mode 100644
index 0000000000..d5885f5c29
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "yandex@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "en",
+ "icons": {
+ "16": "__MSG_extensionIcon__"
+ },
+ "web_accessible_resources": ["yandex-en.ico", "yandex-ru.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "suggest_url": "__MSG_suggestUrl__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/yandex-en.ico b/comm/mail/components/search/extensions/yandex/yandex-en.ico
new file mode 100644
index 0000000000..6398f30e9d
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/yandex-en.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/yandex/yandex-ru.ico b/comm/mail/components/search/extensions/yandex/yandex-ru.ico
new file mode 100644
index 0000000000..226fc0a071
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/yandex-ru.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/zoznam-sk/favicon.png b/comm/mail/components/search/extensions/zoznam-sk/favicon.png
new file mode 100644
index 0000000000..5774c6ff52
--- /dev/null
+++ b/comm/mail/components/search/extensions/zoznam-sk/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/zoznam-sk/manifest.json b/comm/mail/components/search/extensions/zoznam-sk/manifest.json
new file mode 100644
index 0000000000..118cd1ba7f
--- /dev/null
+++ b/comm/mail/components/search/extensions/zoznam-sk/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Zoznam",
+ "description": "Zoznam slovenskeho internetu",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "zoznam-sk@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Zoznam",
+ "encoding": "WINDOWS-1250",
+ "search_url": "https://www.zoznam.sk/hladaj.fcgi",
+ "search_form": "https://www.zoznam.sk/",
+ "search_url_get_params": "co=odkazy&s={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/jar.mn b/comm/mail/components/search/jar.mn
new file mode 100644
index 0000000000..b8d36a114b
--- /dev/null
+++ b/comm/mail/components/search/jar.mn
@@ -0,0 +1,15 @@
+# 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:
+ search-extensions/ (extensions/**)
+#ifdef XP_MACOSX
+ content/messenger/SpotlightIntegration.js (content/SpotlightIntegration.js)
+#endif
+#ifdef XP_WIN
+ content/messenger/WinSearchIntegration.js (content/WinSearchIntegration.js)
+#endif
+
+% resource search-plugins %searchplugins/ contentaccessible=yes
+% resource search-extensions %search-extensions/ contentaccessible=yes
diff --git a/comm/mail/components/search/mdimporter/English.lproj/InfoPlist.strings b/comm/mail/components/search/mdimporter/English.lproj/InfoPlist.strings
new file mode 100644
index 0000000000..ca96e65b7e
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/English.lproj/InfoPlist.strings
Binary files differ
diff --git a/comm/mail/components/search/mdimporter/English.lproj/schema.strings b/comm/mail/components/search/mdimporter/English.lproj/schema.strings
new file mode 100644
index 0000000000..0f7b8d6297
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/English.lproj/schema.strings
Binary files differ
diff --git a/comm/mail/components/search/mdimporter/GetMetadataForFile.c b/comm/mail/components/search/mdimporter/GetMetadataForFile.c
new file mode 100644
index 0000000000..cafb3f05ee
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/GetMetadataForFile.c
@@ -0,0 +1,76 @@
+/* -*- Mode: C; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include <CoreFoundation/CoreFoundation.h>
+#include <CoreServices/CoreServices.h>
+
+/* -----------------------------------------------------------------------------
+ Get metadata attributes from file
+
+ This function's job is to extract useful information from the .mozeml file
+ and return it as a dictionary
+ -----------------------------------------------------------------------------
+ */
+
+Boolean GetMetadataForFile(void* thisInterface,
+ CFMutableDictionaryRef attributes,
+ CFStringRef contentTypeUTI, CFStringRef pathToFile) {
+ /* Pull any available metadata from the file at the specified path */
+ /* Return the attribute keys and attribute values in the dict */
+ /* Return TRUE if successful, FALSE if there was no data provided */
+ Boolean success;
+ CFURLRef fileURL = CFURLCreateWithFileSystemPath(
+ kCFAllocatorDefault, pathToFile, kCFURLPOSIXPathStyle, false);
+ CFReadStreamRef stream =
+ CFReadStreamCreateWithFile(kCFAllocatorDefault, fileURL);
+ CFReadStreamOpen(stream);
+
+ CFErrorRef err = NULL;
+ CFPropertyListRef ticket = CFPropertyListCreateWithStream(
+ kCFAllocatorDefault, stream,
+ /*streamLength*/ 0, kCFPropertyListImmutable, NULL, &err);
+ if (err != NULL) {
+ CFStringRef errorString = CFErrorCopyDescription(err);
+ if (errorString != NULL) {
+ printf("failed creating property list from stream\n");
+ printf("error = %s\n", (const char*)errorString);
+ }
+ CFRelease(err);
+ success = FALSE;
+ } else {
+ CFTypeRef value;
+ value = CFDictionaryGetValue(ticket, kMDItemTitle);
+ if (value) {
+ CFDictionarySetValue(attributes, kMDItemTitle, value);
+ }
+ value = CFDictionaryGetValue(ticket, kMDItemTextContent);
+ if (value) {
+ CFDictionarySetValue(attributes, kMDItemTextContent, value);
+ }
+ value = CFDictionaryGetValue(ticket, kMDItemDisplayName);
+ if (value) CFDictionarySetValue(attributes, kMDItemDisplayName, value);
+
+ CFDateFormatterRef dateFormatter = CFDateFormatterCreate(
+ NULL, NULL, kCFDateFormatterLongStyle, kCFDateFormatterLongStyle);
+
+ value = CFDictionaryGetValue(ticket, kMDItemLastUsedDate);
+
+ if (value && dateFormatter) {
+ printf("trying to parse date \n");
+ CFDateRef curDate =
+ CFDateFormatterCreateDateFromString(NULL, dateFormatter, value, NULL);
+ printf("got cur date\n");
+ if (curDate)
+ CFDictionarySetValue(attributes, kMDItemLastUsedDate, curDate);
+ }
+ success = TRUE;
+ }
+ // contents are kMDItemTextContent
+
+ CFReadStreamClose(stream);
+ CFRelease(stream);
+ CFRelease(fileURL);
+ return success;
+}
diff --git a/comm/mail/components/search/mdimporter/Info.plist b/comm/mail/components/search/mdimporter/Info.plist
new file mode 100644
index 0000000000..ccb2f3cd10
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/Info.plist
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+ <key>CFBundleDocumentTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeOSTypes</key>
+ <array>
+ <string>TBMZ</string>
+ </array>
+ <key>CFBundleTypeRole</key>
+ <string>MDImporter</string>
+ <key>LSItemContentTypes</key>
+ <array>
+ <string>com.mozilla.thunderbird.mozeml</string>
+ </array>
+ </dict>
+ </array>
+ <key>CFBundleExecutable</key>
+ <string>thunderbird-mdimport</string>
+ <key>CFBundleIconFile</key>
+ <string>thunderbird-mdimport</string>
+ <key>CFBundleIdentifier</key>
+ <string>org.mozilla.MDImporter.Thunderbird</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>Thunderbird-MDImport</string>
+ <key>CFBundleVersion</key>
+ <string>1.0</string>
+ <key>CFPlugInDynamicRegisterFunction</key>
+ <string></string>
+ <key>CFPlugInDynamicRegistration</key>
+ <string>NO</string>
+ <key>CFPlugInFactories</key>
+ <dict>
+ <key>37401ADE-1058-42DB-BBE5-F2AAB9D7C13E</key>
+ <string>MetadataImporterPluginFactory</string>
+ </dict>
+ <key>CFPlugInTypes</key>
+ <dict>
+ <key>8B08C4BF-415B-11D8-B3F9-0003936726FC</key>
+ <array>
+ <string>37401ADE-1058-42DB-BBE5-F2AAB9D7C13E</string>
+ </array>
+ </dict>
+ <key>CFPlugInUnloadFunction</key>
+ <string></string>
+</dict>
+</plist>
diff --git a/comm/mail/components/search/mdimporter/Makefile.in b/comm/mail/components/search/mdimporter/Makefile.in
new file mode 100644
index 0000000000..fbc6d27034
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/Makefile.in
@@ -0,0 +1,26 @@
+# 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 directory is producing a framework as a target. The output of this
+# framework will be located here.
+FRAMEWORK_DIR := $(DIST)/package/thunderbird.mdimporter
+
+STRING_FILES := $(srcdir)/English.lproj/InfoPlist.strings $(srcdir)/English.lproj/schema.strings
+STRING_DEST := $(FRAMEWORK_DIR)/Contents/Resources/English.lproj
+INSTALL_TARGETS += STRING
+
+SCHEMA_FILES := $(srcdir)/schema.xml
+SCHEMA_DEST := $(FRAMEWORK_DIR)/Contents/Resources
+INSTALL_TARGETS += SCHEMA
+
+PLIST_FILES := $(srcdir)/Info.plist
+PLIST_DEST := $(FRAMEWORK_DIR)/Contents
+INSTALL_TARGETS += PLIST
+
+CFLAGS += -mmacosx-version-min=$(MACOSX_DEPLOYMENT_TARGET)
+# We don't need mozglue
+WRAP_LDFLAGS :=
+
+include $(topsrcdir)/config/rules.mk
+
diff --git a/comm/mail/components/search/mdimporter/main.c b/comm/mail/components/search/mdimporter/main.c
new file mode 100644
index 0000000000..20afc65351
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/main.c
@@ -0,0 +1,208 @@
+/* -*- Mode: C; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+//==============================================================================
+//
+// DO NO MODIFY THE CONTENT OF THIS FILE
+//
+// This file contains the generic CFPlug-in code necessary for TB Spotlight
+// The actual importer is implemented in GetMetadataForFile.c
+//
+//==============================================================================
+
+#include <CoreFoundation/CoreFoundation.h>
+#include <CoreFoundation/CFPlugInCOM.h>
+#include <CoreServices/CoreServices.h>
+
+// -----------------------------------------------------------------------------
+// constants
+// -----------------------------------------------------------------------------
+
+#define PLUGIN_ID "37401ADE-1058-42DB-BBE5-F2AAB9D7C13E"
+
+//
+// Below is the generic glue code for all plug-ins.
+//
+// You should not have to modify this code aside from changing
+// names if you decide to change the names defined in the Info.plist
+//
+
+// -----------------------------------------------------------------------------
+// typedefs
+// -----------------------------------------------------------------------------
+
+// The import function to be implemented in GetMetadataForFile.c
+Boolean GetMetadataForFile(void* thisInterface,
+ CFMutableDictionaryRef attributes,
+ CFStringRef contentTypeUTI, CFStringRef pathToFile);
+
+// The layout for an instance of MetaDataImporterPlugIn
+typedef struct __MetadataImporterPluginType {
+ MDImporterInterfaceStruct* conduitInterface;
+ CFUUIDRef factoryID;
+ UInt32 refCount;
+} MetadataImporterPluginType;
+
+// -----------------------------------------------------------------------------
+// prototypes
+// -----------------------------------------------------------------------------
+// Forward declaration for the IUnknown implementation.
+//
+
+MetadataImporterPluginType* AllocMetadataImporterPluginType(
+ CFUUIDRef inFactoryID);
+void DeallocMetadataImporterPluginType(
+ MetadataImporterPluginType* thisInstance);
+HRESULT MetadataImporterQueryInterface(void* thisInstance, REFIID iid,
+ LPVOID* ppv);
+void* MetadataImporterPluginFactory(CFAllocatorRef allocator, CFUUIDRef typeID);
+ULONG MetadataImporterPluginAddRef(void* thisInstance);
+ULONG MetadataImporterPluginRelease(void* thisInstance);
+// -----------------------------------------------------------------------------
+// testInterfaceFtbl definition
+// -----------------------------------------------------------------------------
+// The TestInterface function table.
+//
+
+static MDImporterInterfaceStruct testInterfaceFtbl = {
+ NULL, MetadataImporterQueryInterface, MetadataImporterPluginAddRef,
+ MetadataImporterPluginRelease, GetMetadataForFile};
+
+// -----------------------------------------------------------------------------
+// AllocMetadataImporterPluginType
+// -----------------------------------------------------------------------------
+// Utility function that allocates a new instance.
+// You can do some initial setup for the importer here if you wish
+// like allocating globals etc...
+//
+MetadataImporterPluginType* AllocMetadataImporterPluginType(
+ CFUUIDRef inFactoryID) {
+ MetadataImporterPluginType* theNewInstance;
+
+ theNewInstance =
+ (MetadataImporterPluginType*)malloc(sizeof(MetadataImporterPluginType));
+ memset(theNewInstance, 0, sizeof(MetadataImporterPluginType));
+
+ /* Point to the function table */
+ theNewInstance->conduitInterface = &testInterfaceFtbl;
+
+ /* Retain and keep an open instance refcount for each factory. */
+ theNewInstance->factoryID = CFRetain(inFactoryID);
+ CFPlugInAddInstanceForFactory(inFactoryID);
+
+ /* This function returns the IUnknown interface so set the refCount to one. */
+ theNewInstance->refCount = 1;
+ return theNewInstance;
+}
+
+// -----------------------------------------------------------------------------
+// DeallocTBSpotlightMDImporterPluginType
+// -----------------------------------------------------------------------------
+// Utility function that deallocates the instance when
+// the refCount goes to zero.
+// In the current implementation importer interfaces are never deallocated
+// but implement this as this might change in the future
+//
+void DeallocMetadataImporterPluginType(
+ MetadataImporterPluginType* thisInstance) {
+ CFUUIDRef theFactoryID;
+
+ theFactoryID = thisInstance->factoryID;
+ free(thisInstance);
+ if (theFactoryID) {
+ CFPlugInRemoveInstanceForFactory(theFactoryID);
+ CFRelease(theFactoryID);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// MetadataImporterQueryInterface
+// -----------------------------------------------------------------------------
+// Implementation of the IUnknown QueryInterface function.
+//
+HRESULT MetadataImporterQueryInterface(void* thisInstance, REFIID iid,
+ LPVOID* ppv) {
+ CFUUIDRef interfaceID;
+
+ interfaceID = CFUUIDCreateFromUUIDBytes(kCFAllocatorDefault, iid);
+
+ if (CFEqual(interfaceID, kMDImporterInterfaceID)) {
+ /* If the Right interface was requested, bump the ref count,
+ * set the ppv parameter equal to the instance, and
+ * return good status.
+ */
+ ((MetadataImporterPluginType*)thisInstance)
+ ->conduitInterface->AddRef(thisInstance);
+ *ppv = thisInstance;
+ CFRelease(interfaceID);
+ return S_OK;
+ } else {
+ if (CFEqual(interfaceID, IUnknownUUID)) {
+ /* If the IUnknown interface was requested, same as above. */
+ ((MetadataImporterPluginType*)thisInstance)
+ ->conduitInterface->AddRef(thisInstance);
+ *ppv = thisInstance;
+ CFRelease(interfaceID);
+ return S_OK;
+ } else {
+ /* Requested interface unknown, bail with error. */
+ *ppv = NULL;
+ CFRelease(interfaceID);
+ return E_NOINTERFACE;
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------
+// MetadataImporterPluginAddRef
+// -----------------------------------------------------------------------------
+// Implementation of reference counting for this type. Whenever an interface
+// is requested, bump the refCount for the instance. NOTE: returning the
+// refcount is a convention but is not required so don't rely on it.
+//
+ULONG MetadataImporterPluginAddRef(void* thisInstance) {
+ ((MetadataImporterPluginType*)thisInstance)->refCount += 1;
+ return ((MetadataImporterPluginType*)thisInstance)->refCount;
+}
+
+// -----------------------------------------------------------------------------
+// SampleCMPluginRelease
+// -----------------------------------------------------------------------------
+// When an interface is released, decrement the refCount.
+// If the refCount goes to zero, deallocate the instance.
+//
+ULONG MetadataImporterPluginRelease(void* thisInstance) {
+ ((MetadataImporterPluginType*)thisInstance)->refCount -= 1;
+ if (((MetadataImporterPluginType*)thisInstance)->refCount == 0) {
+ DeallocMetadataImporterPluginType(
+ (MetadataImporterPluginType*)thisInstance);
+ return 0;
+ } else {
+ return ((MetadataImporterPluginType*)thisInstance)->refCount;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// TBSpotlightMDImporterPluginFactory
+// -----------------------------------------------------------------------------
+// Implementation of the factory function for this type.
+//
+void* MetadataImporterPluginFactory(CFAllocatorRef allocator,
+ CFUUIDRef typeID) {
+ MetadataImporterPluginType* result;
+ CFUUIDRef uuid;
+
+ /* If correct type is being requested, allocate an
+ * instance of TestType and return the IUnknown interface.
+ */
+ if (CFEqual(typeID, kMDImporterTypeID)) {
+ uuid = CFUUIDCreateFromString(kCFAllocatorDefault, CFSTR(PLUGIN_ID));
+ result = AllocMetadataImporterPluginType(uuid);
+ CFRelease(uuid);
+ return result;
+ }
+ /* If the requested type is incorrect, return NULL. */
+ return NULL;
+}
diff --git a/comm/mail/components/search/mdimporter/moz.build b/comm/mail/components/search/mdimporter/moz.build
new file mode 100644
index 0000000000..8bfc3e6c80
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/moz.build
@@ -0,0 +1,22 @@
+# vim: set filetype=python:
+# 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/.
+
+SOURCES = [
+ "GetMetadataForFile.c",
+ "main.c",
+]
+
+Program("thunderbird-mdimport")
+# This directory is producing a framework as a target. The output of this
+# framework will be located here.
+FINAL_TARGET = "dist/package/thunderbird.mdimporter/Contents/MacOS"
+
+OS_LIBS += [
+ "-framework CoreFoundation",
+ "-framework CoreServices",
+]
+
+# We're also a bundle.
+LDFLAGS += ["-bundle"]
diff --git a/comm/mail/components/search/mdimporter/schema.xml b/comm/mail/components/search/mdimporter/schema.xml
new file mode 100644
index 0000000000..f450209378
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/schema.xml
@@ -0,0 +1,32 @@
+<?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/. -->
+
+
+<schema version="1.0" xmlns="http://www.apple.com/metadata"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.apple.com/metadata file:///System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Resources/MetadataSchema.xsd">
+ <note>
+ Start off by supporting the same attributes as Mail.importer
+ </note>
+ <attributes>
+ <attribute name="kMDItemAuthorEmailAddresses" multivalued="true" type="CFString"/>
+ <attribute name="kMDItemRecipientEmailAddresses" multivalued="true" type="CFString"/>
+ </attributes>
+
+ <types>
+ <type name="com.mozilla.thunderbird.mozeml">
+ <note>
+ The keys that this metadata importer handles.
+ </note>
+ <allattrs>
+ kMDItemAuthorEmailAddresses
+ kMDItemRecipientEmailAddresses
+ </allattrs>
+ <displayattrs>
+ </displayattrs>
+ </type>
+ </types>
+</schema>
+
diff --git a/comm/mail/components/search/moz.build b/comm/mail/components/search/moz.build
new file mode 100644
index 0000000000..219ea99355
--- /dev/null
+++ b/comm/mail/components/search/moz.build
@@ -0,0 +1,23 @@
+# vim: set filetype=python:
+# 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/.
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ DIRS += ["mdimporter"]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ DIRS += ["wsenable"]
+ SOURCES += ["nsMailWinSearchHelper.cpp"]
+ FINAL_LIBRARY = "mailcomps"
+
+DIRS += ["public"]
+
+EXTRA_JS_MODULES += [
+ "SearchIntegration.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/search/nsMailWinSearchHelper.cpp b/comm/mail/components/search/nsMailWinSearchHelper.cpp
new file mode 100644
index 0000000000..9cfdc40cc0
--- /dev/null
+++ b/comm/mail/components/search/nsMailWinSearchHelper.cpp
@@ -0,0 +1,254 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsMailWinSearchHelper.h"
+#include "nsDirectoryServiceUtils.h"
+#include "nsString.h"
+#include "nsIDirectoryEnumerator.h"
+#include "mozilla/ArrayUtils.h"
+
+#ifdef _WIN32_WINNT
+# undef _WIN32_WINNT
+#endif
+#define _WIN32_WINNT 0x0600
+#include <SearchAPI.h>
+#include <winsvc.h>
+#include <ShellAPI.h>
+#include <shlobj.h>
+
+static const CLSID CLSID_CSearchManager = {
+ 0x7d096c5f,
+ 0xac08,
+ 0x4f1f,
+ {0xbe, 0xb7, 0x5c, 0x22, 0xc5, 0x17, 0xce, 0x39}};
+static const IID IID_ISearchManager = {
+ 0xab310581,
+ 0xac80,
+ 0x11d1,
+ {0x8d, 0xf3, 0x00, 0xc0, 0x4f, 0xb6, 0xef, 0x69}};
+
+static const char* const sFoldersToIndex[] = {"Mail", "ImapMail", "News"};
+
+// APP_REG_NAME_MAIL should be kept in synch with AppRegNameMail
+// in the installer file: defines.nsi.in
+#define APP_REG_NAME_MAIL L"Thunderbird"
+
+nsMailWinSearchHelper::nsMailWinSearchHelper() {}
+
+nsresult nsMailWinSearchHelper::Init() {
+ CoInitialize(NULL);
+ return NS_GetSpecialDirectory("ProfD", getter_AddRefs(mProfD));
+}
+
+nsMailWinSearchHelper::~nsMailWinSearchHelper() { CoUninitialize(); }
+
+NS_IMPL_ISUPPORTS(nsMailWinSearchHelper, nsIMailWinSearchHelper)
+
+NS_IMETHODIMP nsMailWinSearchHelper::GetFoldersInCrawlScope(bool* aResult) {
+ *aResult = false;
+ NS_ENSURE_ARG_POINTER(mProfD);
+
+ // If the service isn't present or running, we shouldn't proceed.
+ bool serviceRunning;
+ nsresult rv = GetServiceRunning(&serviceRunning);
+ if (!serviceRunning || NS_FAILED(rv)) return rv;
+
+ // We need to do this every time so that we have the latest data
+ RefPtr<ISearchManager> searchManager;
+ HRESULT hr =
+ CoCreateInstance(CLSID_CSearchManager, NULL, CLSCTX_ALL,
+ IID_ISearchManager, getter_AddRefs(searchManager));
+ if (FAILED(hr)) return NS_ERROR_FAILURE;
+
+ RefPtr<ISearchCatalogManager> catalogManager;
+ hr =
+ searchManager->GetCatalog(L"SystemIndex", getter_AddRefs(catalogManager));
+ if (FAILED(hr)) return NS_ERROR_FAILURE;
+
+ RefPtr<ISearchCrawlScopeManager> crawlScopeManager;
+ hr = catalogManager->GetCrawlScopeManager(getter_AddRefs(crawlScopeManager));
+ if (FAILED(hr)) return NS_ERROR_FAILURE;
+
+ // We need to create appropriate URLs to check with the crawl scope manager.
+ for (uint32_t i = 0; i < MOZ_ARRAY_LENGTH(sFoldersToIndex); i++) {
+ nsCOMPtr<nsIFile> subdir;
+ rv = mProfD->Clone(getter_AddRefs(subdir));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsDependentCString relativeStr(sFoldersToIndex[i]);
+ rv = subdir->AppendNative(relativeStr);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsString subdirPath;
+ rv = subdir->GetPath(subdirPath);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Form a URL as required by the crawl scope manager
+ nsString subdirURL(u"file:///"_ns);
+ subdirURL.Append(subdirPath);
+ subdirURL.Append('\\');
+
+ BOOL included;
+ if (FAILED(crawlScopeManager->IncludedInCrawlScope(subdirURL.get(),
+ &included)))
+ return NS_ERROR_FAILURE;
+
+ // If even one of the folders isn't there, we return false
+ if (!included) return NS_OK;
+ }
+ *aResult = true;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::GetServiceRunning(bool* aResult) {
+ *aResult = false;
+ SC_HANDLE hSCManager =
+ OpenSCManagerW(nullptr, SERVICES_ACTIVE_DATABASEW, SERVICE_QUERY_STATUS);
+ if (!hSCManager) return NS_ERROR_FAILURE;
+
+ SC_HANDLE hService =
+ OpenServiceW(hSCManager, L"wsearch", SERVICE_QUERY_STATUS);
+ CloseServiceHandle(hSCManager);
+ if (!hService)
+ // The service isn't present. Never mind.
+ return NS_ERROR_NOT_AVAILABLE;
+
+ SERVICE_STATUS status;
+ if (!QueryServiceStatus(hService, &status)) {
+ CloseServiceHandle(hService);
+ return NS_ERROR_FAILURE;
+ }
+
+ *aResult = (status.dwCurrentState == SERVICE_RUNNING);
+ CloseServiceHandle(hService);
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::SetFANCIBit(nsIFile* aFile, bool aBit,
+ bool aRecurse) {
+ NS_ENSURE_ARG_POINTER(aFile);
+
+ bool exists;
+ nsresult rv = aFile->Exists(&exists);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!exists) return NS_ERROR_FILE_NOT_FOUND;
+
+ nsString filePath;
+ rv = aFile->GetPath(filePath);
+ NS_ENSURE_SUCCESS(rv, rv);
+ LPCWSTR pathStr = filePath.get();
+
+ // We should set the file attribute only if it isn't already set.
+ DWORD dwAttrs = GetFileAttributesW(pathStr);
+ if (dwAttrs == INVALID_FILE_ATTRIBUTES) return NS_ERROR_FAILURE;
+
+ if (aBit) {
+ if (!(dwAttrs & FILE_ATTRIBUTE_NOT_CONTENT_INDEXED))
+ SetFileAttributesW(pathStr, dwAttrs | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED);
+ } else {
+ if (dwAttrs & FILE_ATTRIBUTE_NOT_CONTENT_INDEXED)
+ SetFileAttributesW(pathStr,
+ dwAttrs & ~FILE_ATTRIBUTE_NOT_CONTENT_INDEXED);
+ }
+
+ // We should only try to recurse if it's a directory
+ bool isDirectory;
+ rv = aFile->IsDirectory(&isDirectory);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aRecurse && isDirectory) {
+ nsCOMPtr<nsIDirectoryEnumerator> children;
+ rv = aFile->GetDirectoryEntries(getter_AddRefs(children));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(children->HasMoreElements(&hasMore)) && hasMore) {
+ nsCOMPtr<nsIFile> childFile;
+ rv = children->GetNextFile(getter_AddRefs(childFile));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetFANCIBit(childFile, aBit, aRecurse);
+ }
+ }
+ return rv;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::GetIsFileAssociationSet(bool* aResult) {
+ NS_ENSURE_ARG_POINTER(aResult);
+
+ // We'll use the Vista method here
+ RefPtr<IApplicationAssociationRegistration> pAAR;
+ HRESULT hr = CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, NULL, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, getter_AddRefs(pAAR));
+
+ BOOL res = false;
+ if (SUCCEEDED(hr))
+ pAAR->QueryAppIsDefault(L".wdseml", AT_FILEEXTENSION, AL_EFFECTIVE,
+ APP_REG_NAME_MAIL, &res);
+ *aResult = res;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::SetFileAssociation() {
+ RefPtr<IApplicationAssociationRegistration> pAAR;
+ HRESULT hr = CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, NULL, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, getter_AddRefs(pAAR));
+ if (SUCCEEDED(hr))
+ hr = pAAR->SetAppAsDefault(APP_REG_NAME_MAIL, L".wdseml", AT_FILEEXTENSION);
+
+ return SUCCEEDED(hr) ? NS_OK : NS_ERROR_FAILURE;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::RunSetup(bool aEnable) {
+ nsresult rv;
+ if (!mCurProcD) {
+ rv = NS_GetSpecialDirectory("CurProcD", getter_AddRefs(mCurProcD));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mCurProcD->Append(u"WSEnable.exe"_ns);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsAutoString filePath;
+ rv = mCurProcD->GetPath(filePath);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // The parameters are of the format "1 <path>" for enabling and "0 <path>" for
+ // disabling
+ nsAutoString params(aEnable ? u"1 \""_ns : u"0 \""_ns);
+ nsAutoString profDPath;
+ rv = mProfD->GetPath(profDPath);
+ NS_ENSURE_SUCCESS(rv, rv);
+ params.Append(profDPath);
+ params.Append(u"\""_ns);
+
+ // We need an hWnd to cause UAC to pop up immediately
+ // If GetForegroundWindow returns NULL, then the UAC prompt will still appear,
+ // but minimized.
+ HWND hWnd = GetForegroundWindow();
+
+ SHELLEXECUTEINFOW executeInfo = {0};
+
+ executeInfo.cbSize = sizeof(SHELLEXECUTEINFOW);
+ executeInfo.hwnd = hWnd;
+ executeInfo.fMask = SEE_MASK_NOCLOSEPROCESS;
+ executeInfo.lpDirectory = NULL;
+ executeInfo.lpFile = filePath.get();
+ executeInfo.lpParameters = params.get();
+ executeInfo.nShow = SW_SHOWNORMAL;
+
+ DWORD dwRet = ERROR_SUCCESS;
+
+ if (ShellExecuteExW(&executeInfo)) {
+ // We want to block until the program exits
+ DWORD dwSignaled = WaitForSingleObject(executeInfo.hProcess, INFINITE);
+ if (dwSignaled == WAIT_OBJECT_0)
+ if (!GetExitCodeProcess(executeInfo.hProcess, &dwRet))
+ dwRet = GetLastError();
+ } else
+ return NS_ERROR_ABORT;
+
+ return SUCCEEDED(HRESULT_FROM_WIN32(dwRet)) ? NS_OK : NS_ERROR_FAILURE;
+}
diff --git a/comm/mail/components/search/nsMailWinSearchHelper.h b/comm/mail/components/search/nsMailWinSearchHelper.h
new file mode 100644
index 0000000000..ee313e6acf
--- /dev/null
+++ b/comm/mail/components/search/nsMailWinSearchHelper.h
@@ -0,0 +1,34 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef nsMailWinSearchHelper_h_
+#define nsMailWinSearchHelper_h_
+
+#include "nsIMailWinSearchHelper.h"
+#include "nsIFile.h"
+#include "nsCOMPtr.h"
+
+#define NS_MAILWINSEARCHHELPER_CID \
+ { \
+ 0x5dd31c99, 0x8c7, 0x4a3b, { \
+ 0xae, 0xb3, 0xd2, 0xe6, 0x6, 0x65, 0xa3, 0x1a \
+ } \
+ }
+
+class nsMailWinSearchHelper : public nsIMailWinSearchHelper {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIMAILWINSEARCHHELPER
+
+ nsresult Init();
+ nsMailWinSearchHelper();
+
+ private:
+ virtual ~nsMailWinSearchHelper();
+ nsCOMPtr<nsIFile> mProfD;
+ nsCOMPtr<nsIFile> mCurProcD;
+};
+
+#endif
diff --git a/comm/mail/components/search/public/moz.build b/comm/mail/components/search/public/moz.build
new file mode 100644
index 0000000000..e920335704
--- /dev/null
+++ b/comm/mail/components/search/public/moz.build
@@ -0,0 +1,10 @@
+# vim: set filetype=python:
+# 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/.
+
+XPIDL_SOURCES += [
+ "nsIMailWinSearchHelper.idl",
+]
+
+XPIDL_MODULE = "mailwinsearch"
diff --git a/comm/mail/components/search/public/nsIMailWinSearchHelper.idl b/comm/mail/components/search/public/nsIMailWinSearchHelper.idl
new file mode 100644
index 0000000000..1c1d03a07f
--- /dev/null
+++ b/comm/mail/components/search/public/nsIMailWinSearchHelper.idl
@@ -0,0 +1,58 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+
+#include "nsISupports.idl"
+
+interface nsIFile;
+
+[scriptable, uuid(a65307b3-64f8-49fc-96a7-2cfc7d1f18ee)]
+interface nsIMailWinSearchHelper : nsISupports
+{
+ /**
+ * Whether the Windows Search service is installed and running.
+ *
+ * @exception NS_ERROR_NOT_AVAILABLE if the Windows Search service is
+ * not installed
+ */
+ readonly attribute boolean serviceRunning;
+
+ /**
+ * Whether the Mail, ImapMail, and News folders are in the crawl scope.
+ *
+ * @exception NS_ERROR_NOT_AVAILABLE if the Windows Search service is not
+ * installed or running
+ */
+ readonly attribute boolean foldersInCrawlScope;
+
+ /**
+ * Sets the File Attribute Not Content Indexed bit. For proper operation
+ * of the indexer, this bit must be set to 0/false.
+ *
+ * @param aFile the file or directory for which this bit is supposed to be set
+ * @param aBit false if the content is to be indexed, true if not
+ * @param aRecurse whether this bit is to be set recursively for all subdirectories
+ * and files inside a directory
+ */
+ void setFANCIBit(in nsIFile aFile, in boolean aBit, in boolean aRecurse);
+
+ /**
+ * Returns whether the .wdseml file association has been set to Thunderbird or not.
+ */
+ readonly attribute boolean isFileAssociationSet;
+
+ /**
+ * Sets the .wdseml file association.
+ */
+ void setFileAssociation();
+
+ /**
+ * Runs the setup application using ShellExecute, passing the profile directory as
+ * a parameter.
+ *
+ * @param aEnable true to enable, false to disable
+ */
+ void runSetup(in boolean aEnable);
+};
diff --git a/comm/mail/components/search/wsenable/Makefile.in b/comm/mail/components/search/wsenable/Makefile.in
new file mode 100644
index 0000000000..254509ee77
--- /dev/null
+++ b/comm/mail/components/search/wsenable/Makefile.in
@@ -0,0 +1,6 @@
+#
+# 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/.
+
+MOZ_WINCONSOLE = 0
diff --git a/comm/mail/components/search/wsenable/WSEnable.cpp b/comm/mail/components/search/wsenable/WSEnable.cpp
new file mode 100644
index 0000000000..825e0c4fe4
--- /dev/null
+++ b/comm/mail/components/search/wsenable/WSEnable.cpp
@@ -0,0 +1,141 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include <SearchAPI.h>
+#include <shellapi.h>
+#include <objbase.h>
+#include <string>
+
+static const CLSID CLSID_CSearchManager = {
+ 0x7d096c5f,
+ 0xac08,
+ 0x4f1f,
+ {0xbe, 0xb7, 0x5c, 0x22, 0xc5, 0x17, 0xce, 0x39}};
+static const IID IID_ISearchManager = {
+ 0xab310581,
+ 0xac80,
+ 0x11d1,
+ {0x8d, 0xf3, 0x00, 0xc0, 0x4f, 0xb6, 0xef, 0x69}};
+
+static const WCHAR* const sFoldersToIndex[] = {L"\\Mail\\", L"\\ImapMail\\",
+ L"\\News\\"};
+
+struct RegKey {
+ HKEY mRoot;
+ LPCWSTR mSubKey;
+ LPCWSTR mName;
+ LPCWSTR mValue;
+
+ RegKey(HKEY aRoot, LPCWSTR aSubKey, LPCWSTR aName, LPCWSTR aValue)
+ : mRoot(aRoot), mSubKey(aSubKey), mName(aName), mValue(aValue) {}
+};
+
+static const RegKey* const sRegKeys[] = {
+ new RegKey(HKEY_LOCAL_MACHINE,
+ L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\PropertySystem\\"
+ L"PropertyHandlers\\.wdseml",
+ L"", L"{5FA29220-36A1-40f9-89C6-F4B384B7642E}"),
+ new RegKey(HKEY_CLASSES_ROOT, L".wdseml", L"Content Type",
+ L"message/rfc822"),
+ new RegKey(HKEY_CLASSES_ROOT, L".wdseml\\PersistentHandler", L"",
+ L"{5645c8c4-e277-11cf-8fda-00aa00a14f93}"),
+ new RegKey(HKEY_CLASSES_ROOT,
+ L".wdseml\\shellex\\{8895B1C6-B41F-4C1C-A562-0D564250836F}", L"",
+ L"{b9815375-5d7f-4ce2-9245-c9d4da436930}"),
+ new RegKey(
+ HKEY_LOCAL_MACHINE,
+ L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\explorer\\KindMap",
+ L".wdseml", L"email;communication")};
+
+HRESULT GetCrawlScopeManager(ISearchCrawlScopeManager** aCrawlScopeManager) {
+ *aCrawlScopeManager = NULL;
+
+ ISearchManager* searchManager;
+ HRESULT hr = CoCreateInstance(CLSID_CSearchManager, NULL, CLSCTX_ALL,
+ IID_ISearchManager, (void**)&searchManager);
+ if (SUCCEEDED(hr)) {
+ ISearchCatalogManager* catalogManager;
+ hr = searchManager->GetCatalog(L"SystemIndex", &catalogManager);
+ if (SUCCEEDED(hr)) {
+ hr = catalogManager->GetCrawlScopeManager(aCrawlScopeManager);
+ catalogManager->Release();
+ }
+ searchManager->Release();
+ }
+ return hr;
+}
+
+LSTATUS SetRegistryKeys() {
+ LSTATUS rv = ERROR_SUCCESS;
+ for (uint32_t i = 0; rv == ERROR_SUCCESS && i < _countof(sRegKeys); i++) {
+ const RegKey* key = sRegKeys[i];
+ HKEY subKey;
+ // Since we're administrator, we should be able to do this just fine
+ rv = RegCreateKeyExW(key->mRoot, key->mSubKey, 0, NULL,
+ REG_OPTION_NON_VOLATILE,
+ KEY_ALL_ACCESS | KEY_WOW64_64KEY, NULL, &subKey, NULL);
+ if (rv == ERROR_SUCCESS)
+ rv = RegSetValueExW(subKey, key->mName, 0, REG_SZ, (LPBYTE)key->mValue,
+ (lstrlenW(key->mValue) + 1) * sizeof(WCHAR));
+ RegCloseKey(subKey);
+ }
+
+ return rv;
+}
+
+int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
+ LPWSTR lpCmdLine, int nCmdShow) {
+ UNREFERENCED_PARAMETER(lpCmdLine);
+
+ HRESULT hr =
+ CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
+ if (SUCCEEDED(hr)) {
+ int argc;
+ LPWSTR* argv = CommandLineToArgvW(lpCmdLine, &argc);
+ if (argc != 2) hr = E_INVALIDARG;
+ if (SUCCEEDED(hr)) {
+ ISearchCrawlScopeManager* crawlScopeManager;
+ hr = GetCrawlScopeManager(&crawlScopeManager);
+ if (SUCCEEDED(hr)) {
+ if (*argv[0] == L'1') {
+ // We first add the required registry entries
+ LSTATUS rv = SetRegistryKeys();
+ if (rv != ERROR_SUCCESS) hr = E_FAIL;
+
+ // Next, we add rules for each of the three folders
+ for (uint32_t i = 0; SUCCEEDED(hr) && i < _countof(sFoldersToIndex);
+ i++) {
+ std::wstring path = L"file:///";
+ path.append(argv[1]);
+ path.append(sFoldersToIndex[i]);
+ // Add only if the rule isn't already there
+ BOOL isIncluded = FALSE;
+ hr = crawlScopeManager->IncludedInCrawlScope(path.c_str(),
+ &isIncluded);
+ if (SUCCEEDED(hr) && !isIncluded)
+ hr = crawlScopeManager->AddUserScopeRule(path.c_str(), TRUE, TRUE,
+ TRUE);
+ }
+ } else if (*argv[0] == L'0') {
+ // This is simple, we just exclude the profile dir and override
+ // children
+ std::wstring path = L"file:///";
+ path.append(argv[1]);
+ hr = crawlScopeManager->AddUserScopeRule(path.c_str(), FALSE, TRUE,
+ TRUE);
+ } else
+ hr = E_INVALIDARG;
+
+ if (SUCCEEDED(hr)) {
+ hr = crawlScopeManager->SaveAll();
+ }
+ crawlScopeManager->Release();
+ }
+ }
+ LocalFree(argv);
+ }
+
+ return hr;
+}
diff --git a/comm/mail/components/search/wsenable/WSEnable.exe.manifest b/comm/mail/components/search/wsenable/WSEnable.exe.manifest
new file mode 100644
index 0000000000..1c5ebf8e57
--- /dev/null
+++ b/comm/mail/components/search/wsenable/WSEnable.exe.manifest
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+<assemblyIdentity
+ version="1.0.0.0"
+ processorArchitecture="*"
+ name="Mozilla.Thunderbird"
+ type="win32"
+/>
+<description>Mozilla Thunderbird Windows Search Integration Handler</description>
+<dependency>
+ <dependentAssembly>
+ <assemblyIdentity
+ type="win32"
+ name="Microsoft.Windows.Common-Controls"
+ version="6.0.0.0"
+ processorArchitecture="*"
+ publicKeyToken="6595b64144ccf1df"
+ language="*"
+ />
+ </dependentAssembly>
+</dependency>
+<ms_asmv3:trustInfo xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:security>
+ <ms_asmv3:requestedPrivileges>
+ <ms_asmv3:requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
+ </ms_asmv3:requestedPrivileges>
+ </ms_asmv3:security>
+</ms_asmv3:trustInfo>
+ <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
+ <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
+ <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
+ <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
+ </application>
+ </compatibility>
+</assembly>
diff --git a/comm/mail/components/search/wsenable/WSEnable.rc b/comm/mail/components/search/wsenable/WSEnable.rc
new file mode 100644
index 0000000000..857298e160
--- /dev/null
+++ b/comm/mail/components/search/wsenable/WSEnable.rc
@@ -0,0 +1,6 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+1 24 "WSEnable.exe.manifest"
diff --git a/comm/mail/components/search/wsenable/module.ver b/comm/mail/components/search/wsenable/module.ver
new file mode 100644
index 0000000000..bd6f7f1c84
--- /dev/null
+++ b/comm/mail/components/search/wsenable/module.ver
@@ -0,0 +1 @@
+WIN32_MODULE_DESCRIPTION=@MOZ_APP_DISPLAYNAME@ Windows Search Integration Handler
diff --git a/comm/mail/components/search/wsenable/moz.build b/comm/mail/components/search/wsenable/moz.build
new file mode 100644
index 0000000000..bf9cdab1dc
--- /dev/null
+++ b/comm/mail/components/search/wsenable/moz.build
@@ -0,0 +1,21 @@
+# vim: set filetype=python:
+# 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/.
+
+Program("WSEnable")
+
+SOURCES += [
+ "WSEnable.cpp",
+]
+
+OS_LIBS += [
+ "advapi32",
+ "ole32",
+ "shell32",
+]
+
+RCINCLUDE = "WSEnable.rc"
+
+# This isn't XPCOM code, but it wants to use STL so disable STL wrappers
+DisableStlWrapping()
diff --git a/comm/mail/components/shell/components.conf b/comm/mail/components/shell/components.conf
new file mode 100644
index 0000000000..7b7712522a
--- /dev/null
+++ b/comm/mail/components/shell/components.conf
@@ -0,0 +1,37 @@
+# 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/.
+
+Classes = []
+
+if buildconfig.substs["OS_ARCH"] == "WINNT":
+ Classes += [
+ {
+ "cid": "{02ebbe84-c179-4598-af18-1bf2c4bc1df9}",
+ "contract_ids": ["@mozilla.org/mail/shell-service;1"],
+ "type": "nsWindowsShellService",
+ "init_method": "Init",
+ "headers": ["/comm/mail/components/shell/nsWindowsShellService.h"],
+ },
+ ]
+
+if buildconfig.substs["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ Classes += [
+ {
+ "cid": "{bddef0f4-5e2d-4846-bdec-86d0781d8ded}",
+ "contract_ids": ["@mozilla.org/mail/shell-service;1"],
+ "type": "nsGNOMEShellService",
+ "init_method": "Init",
+ "headers": ["/comm/mail/components/shell/nsGNOMEShellService.h"],
+ },
+ ]
+
+if buildconfig.substs["OS_ARCH"] == "Darwin":
+ Classes += [
+ {
+ "cid": "{85a27035-b970-4079-b9d2-e21f69e6b21f}",
+ "contract_ids": ["@mozilla.org/mail/shell-service;1"],
+ "type": "nsMacShellService",
+ "headers": ["/comm/mail/components/shell/nsMacShellService.h"],
+ },
+ ]
diff --git a/comm/mail/components/shell/moz.build b/comm/mail/components/shell/moz.build
new file mode 100644
index 0000000000..6687759226
--- /dev/null
+++ b/comm/mail/components/shell/moz.build
@@ -0,0 +1,43 @@
+# vim: set filetype=python:
+# 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/.
+
+DEFINES["MOZ_APP_NAME"] = '"%s"' % CONFIG["MOZ_APP_NAME"]
+
+XPIDL_SOURCES += [
+ "nsIShellService.idl",
+]
+
+XPIDL_MODULE = "shellservice"
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ SOURCES += [
+ "nsWindowsShellService.cpp",
+ ]
+ LOCAL_INCLUDES += [
+ "/other-licenses/nsis/Contrib/CityHash/cityhash",
+ ]
+
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ SOURCES += [
+ "nsMacShellService.cpp",
+ ]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ SOURCES += [
+ "nsGNOMEShellService.cpp",
+ ]
+
+if SOURCES:
+ FINAL_LIBRARY = "mailcomps"
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/unit/xpcshell.ini",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/shell/nsGNOMEShellService.cpp b/comm/mail/components/shell/nsGNOMEShellService.cpp
new file mode 100644
index 0000000000..f7381b2adc
--- /dev/null
+++ b/comm/mail/components/shell/nsGNOMEShellService.cpp
@@ -0,0 +1,341 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsGNOMEShellService.h"
+#include "nsIGIOService.h"
+#include "nsCOMPtr.h"
+#include "nsIServiceManager.h"
+#include "prenv.h"
+#include "nsIFile.h"
+#include "nsIStringBundle.h"
+#include "nsIPromptService.h"
+#include "nsIPrefService.h"
+#include "nsIPrefBranch.h"
+#include "nsDirectoryServiceDefs.h"
+#include "nsDirectoryServiceUtils.h"
+#include "nsEmbedCID.h"
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/Components.h"
+
+#include <glib.h>
+#include <limits.h>
+#include <stdlib.h>
+
+using mozilla::ArrayLength;
+
+static const char* const sMailProtocols[] = {"mailto", "mid"};
+
+static const char* const sNewsProtocols[] = {"news", "snews", "nntp"};
+
+static const char* const sFeedProtocols[] = {"feed"};
+
+static const char* const sCalendarProtocols[] = {"webcal", "webcals"};
+
+struct AppTypeAssociation {
+ uint16_t type;
+ const char* const* protocols;
+ unsigned int protocolsLength;
+ const char* mimeType;
+ const char* extensions;
+};
+
+static bool IsRunningAsASnap() {
+ // SNAP holds the path to the snap, use SNAP_NAME
+ // which is easier to parse.
+ const char* snap_name = PR_GetEnv("SNAP_NAME");
+
+ // return early if not set.
+ if (snap_name == nullptr) {
+ return false;
+ }
+
+ // snap_name as defined on https://snapcraft.io/thunderbird
+ return (strcmp(snap_name, "thunderbird") == 0);
+}
+
+static const AppTypeAssociation sAppTypes[] = {
+ {
+ nsIShellService::MAIL, sMailProtocols, ArrayLength(sMailProtocols),
+ "message/rfc822",
+ nullptr // don't associate .eml extension, as that breaks printing
+ // those
+ },
+ {nsIShellService::NEWS, sNewsProtocols, ArrayLength(sNewsProtocols),
+ nullptr, nullptr},
+ {nsIShellService::RSS, sFeedProtocols, ArrayLength(sFeedProtocols),
+ "application/rss+xml", "rss"},
+ {nsIShellService::CALENDAR, sCalendarProtocols,
+ ArrayLength(sCalendarProtocols), "text/calendar", "ics"}};
+
+nsGNOMEShellService::nsGNOMEShellService()
+ : mUseLocaleFilenames(false),
+ mCheckedThisSession(false),
+ mAppIsInPath(false) {}
+
+nsresult nsGNOMEShellService::Init() {
+ nsresult rv;
+
+ nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID);
+
+ if (!giovfs) return NS_ERROR_NOT_AVAILABLE;
+
+ // Check G_BROKEN_FILENAMES. If it's set, then filenames in glib use
+ // the locale encoding. If it's not set, they use UTF-8.
+ mUseLocaleFilenames = PR_GetEnv("G_BROKEN_FILENAMES") != nullptr;
+
+ if (GetAppPathFromLauncher()) return NS_OK;
+
+ nsCOMPtr<nsIFile> appPath;
+ rv = NS_GetSpecialDirectory(NS_XPCOM_CURRENT_PROCESS_DIR,
+ getter_AddRefs(appPath));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = appPath->AppendNative(nsLiteralCString(MOZ_APP_NAME));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = appPath->GetNativePath(mAppPath);
+ return rv;
+}
+
+NS_IMPL_ISUPPORTS(nsGNOMEShellService, nsIShellService, nsIToolkitShellService)
+
+bool nsGNOMEShellService::GetAppPathFromLauncher() {
+ gchar* tmp;
+
+ const char* launcher = PR_GetEnv("MOZ_APP_LAUNCHER");
+ if (!launcher) return false;
+
+ if (g_path_is_absolute(launcher)) {
+ mAppPath = launcher;
+ tmp = g_path_get_basename(launcher);
+ gchar* fullpath = g_find_program_in_path(tmp);
+ if (fullpath && mAppPath.Equals(fullpath)) {
+ mAppIsInPath = true;
+ }
+ g_free(fullpath);
+ } else {
+ tmp = g_find_program_in_path(launcher);
+ if (!tmp) return false;
+ mAppPath = tmp;
+ mAppIsInPath = true;
+ }
+
+ g_free(tmp);
+ return true;
+}
+
+NS_IMETHODIMP
+nsGNOMEShellService::IsDefaultClient(bool aStartupCheck, uint16_t aApps,
+ bool* aIsDefaultClient) {
+ *aIsDefaultClient = true;
+
+ for (unsigned int i = 0; i < MOZ_ARRAY_LENGTH(sAppTypes); i++) {
+ if (aApps & sAppTypes[i].type)
+ *aIsDefaultClient &=
+ checkDefault(sAppTypes[i].protocols, sAppTypes[i].protocolsLength);
+ }
+
+ // If this is the first mail window, maintain internal state that we've
+ // checked this session (so that subsequent window opens don't show the
+ // default client dialog).
+ if (aStartupCheck) mCheckedThisSession = true;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsGNOMEShellService::SetDefaultClient(bool aForAllUsers, uint16_t aApps) {
+ nsresult rv = NS_OK;
+ for (unsigned int i = 0; i < MOZ_ARRAY_LENGTH(sAppTypes); i++) {
+ if (aApps & sAppTypes[i].type) {
+ nsresult tmp =
+ MakeDefault(sAppTypes[i].protocols, sAppTypes[i].protocolsLength,
+ sAppTypes[i].mimeType, sAppTypes[i].extensions);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ }
+ }
+
+ return rv;
+}
+
+NS_IMETHODIMP
+nsGNOMEShellService::GetShouldCheckDefaultClient(bool* aResult) {
+ if (mCheckedThisSession) {
+ *aResult = false;
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->GetBoolPref("mail.shell.checkDefaultClient", aResult);
+}
+
+NS_IMETHODIMP
+nsGNOMEShellService::SetShouldCheckDefaultClient(bool aShouldCheck) {
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->SetBoolPref("mail.shell.checkDefaultClient", aShouldCheck);
+}
+
+bool nsGNOMEShellService::KeyMatchesAppName(const char* aKeyValue) const {
+ gchar* commandPath;
+ if (mUseLocaleFilenames) {
+ gchar* nativePath = g_filename_from_utf8(aKeyValue, -1, NULL, NULL, NULL);
+ if (!nativePath) {
+ NS_ERROR("Error converting path to filesystem encoding");
+ return false;
+ }
+
+ commandPath = g_find_program_in_path(nativePath);
+ g_free(nativePath);
+ } else {
+ commandPath = g_find_program_in_path(aKeyValue);
+ }
+
+ if (!commandPath) return false;
+
+ bool matches = mAppPath.Equals(commandPath);
+ g_free(commandPath);
+ return matches;
+}
+
+bool nsGNOMEShellService::CheckHandlerMatchesAppName(
+ const nsACString& handler) const {
+ gint argc;
+ gchar** argv;
+ nsAutoCString command(handler);
+
+ if (g_shell_parse_argv(command.get(), &argc, &argv, NULL)) {
+ command.Assign(argv[0]);
+ g_strfreev(argv);
+ } else {
+ return false;
+ }
+
+ return KeyMatchesAppName(command.get());
+}
+
+bool nsGNOMEShellService::checkDefault(const char* const* aProtocols,
+ unsigned int aLength) {
+ nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID);
+
+ nsAutoCString handler;
+ nsresult rv;
+
+ for (unsigned int i = 0; i < aLength; ++i) {
+ if (IsRunningAsASnap()) {
+ const gchar* argv[] = {"xdg-settings", "get",
+ "default-url-scheme-handler", aProtocols[i],
+ nullptr};
+ GSpawnFlags flags = static_cast<GSpawnFlags>(G_SPAWN_SEARCH_PATH |
+ G_SPAWN_STDERR_TO_DEV_NULL);
+ gchar* output = nullptr;
+ gint exit_status = 0;
+ if (!g_spawn_sync(nullptr, (gchar**)argv, nullptr, flags, nullptr,
+ nullptr, &output, nullptr, &exit_status, nullptr)) {
+ return false;
+ }
+ if (exit_status != 0) {
+ g_free(output);
+ return false;
+ }
+ if (strcmp(output, "thunderbird.desktop\n") == 0) {
+ g_free(output);
+ return true;
+ }
+ g_free(output);
+ return false;
+ }
+
+ if (giovfs) {
+ handler.Truncate();
+ nsCOMPtr<nsIHandlerApp> handlerApp;
+ rv = giovfs->GetAppForURIScheme(nsDependentCString(aProtocols[i]),
+ getter_AddRefs(handlerApp));
+ if (NS_FAILED(rv) || !handlerApp) {
+ return false;
+ }
+ nsCOMPtr<nsIGIOMimeApp> app = do_QueryInterface(handlerApp, &rv);
+ if (NS_FAILED(rv) || !app) {
+ return false;
+ }
+ rv = app->GetCommand(handler);
+ if (NS_SUCCEEDED(rv) && !CheckHandlerMatchesAppName(handler)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+nsresult nsGNOMEShellService::MakeDefault(const char* const* aProtocols,
+ unsigned int aProtocolsLength,
+ const char* aMimeType,
+ const char* aExtensions) {
+ nsAutoCString appKeyValue;
+ nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID);
+ if (mAppIsInPath) {
+ // mAppPath is in the users path, so use only the basename as the launcher
+ gchar* tmp = g_path_get_basename(mAppPath.get());
+ appKeyValue = tmp;
+ g_free(tmp);
+ } else {
+ appKeyValue = mAppPath;
+ }
+
+ appKeyValue.AppendLiteral(" %s");
+
+ if (IsRunningAsASnap()) {
+ for (unsigned int i = 0; i < aProtocolsLength; ++i) {
+ const gchar* argv[] = {"xdg-settings",
+ "set",
+ "default-url-scheme-handler",
+ aProtocols[i],
+ "thunderbird.desktop",
+ nullptr};
+ GSpawnFlags flags = static_cast<GSpawnFlags>(G_SPAWN_SEARCH_PATH |
+ G_SPAWN_STDOUT_TO_DEV_NULL |
+ G_SPAWN_STDERR_TO_DEV_NULL);
+ g_spawn_sync(nullptr, (gchar**)argv, nullptr, flags, nullptr, nullptr,
+ nullptr, nullptr, nullptr, nullptr);
+ }
+ }
+
+ nsresult rv;
+ if (giovfs) {
+ nsCOMPtr<nsIStringBundleService> bundleService =
+ mozilla::components::StringBundle::Service();
+ NS_ENSURE_TRUE(bundleService, NS_ERROR_UNEXPECTED);
+
+ nsCOMPtr<nsIStringBundle> brandBundle;
+ rv = bundleService->CreateBundle(BRAND_PROPERTIES,
+ getter_AddRefs(brandBundle));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsString brandShortName;
+ brandBundle->GetStringFromName("brandShortName", brandShortName);
+
+ // use brandShortName as the application id.
+ NS_ConvertUTF16toUTF8 id(brandShortName);
+
+ nsCOMPtr<nsIGIOMimeApp> app;
+ rv = giovfs->CreateAppFromCommand(mAppPath, id, getter_AddRefs(app));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ for (unsigned int i = 0; i < aProtocolsLength; ++i) {
+ rv = app->SetAsDefaultForURIScheme(nsDependentCString(aProtocols[i]));
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aMimeType)
+ rv = app->SetAsDefaultForMimeType(nsDependentCString(aMimeType));
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aExtensions)
+ rv =
+ app->SetAsDefaultForFileExtensions(nsDependentCString(aExtensions));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ return NS_OK;
+}
diff --git a/comm/mail/components/shell/nsGNOMEShellService.h b/comm/mail/components/shell/nsGNOMEShellService.h
new file mode 100644
index 0000000000..402eb31f41
--- /dev/null
+++ b/comm/mail/components/shell/nsGNOMEShellService.h
@@ -0,0 +1,49 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef nsGNOMEShellService_h_
+#define nsGNOMEShellService_h_
+
+#include "nsIShellService.h"
+#include "nsString.h"
+#include "nsToolkitShellService.h"
+
+#define BRAND_PROPERTIES "chrome://branding/locale/brand.properties"
+
+#define NS_MAILGNOMEINTEGRATION_CID \
+ { \
+ 0xbddef0f4, 0x5e2d, 0x4846, { \
+ 0xbd, 0xec, 0x86, 0xd0, 0x78, 0x1d, 0x8d, 0xed \
+ } \
+ }
+
+class nsGNOMEShellService : public nsIShellService,
+ public nsToolkitShellService {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSISHELLSERVICE
+
+ nsresult Init();
+ nsGNOMEShellService();
+
+ protected:
+ virtual ~nsGNOMEShellService(){};
+
+ bool KeyMatchesAppName(const char* aKeyValue) const;
+ bool checkDefault(const char* const* aProtocols, unsigned int aLength);
+ nsresult MakeDefault(const char* const* aProtocols,
+ unsigned int aProtocolsLength, const char* mimeType,
+ const char* extensions);
+
+ private:
+ bool GetAppPathFromLauncher();
+ bool CheckHandlerMatchesAppName(const nsACString& handler) const;
+ bool mUseLocaleFilenames;
+ bool mCheckedThisSession;
+ nsCString mAppPath;
+ bool mAppIsInPath;
+};
+
+#endif
diff --git a/comm/mail/components/shell/nsIShellService.idl b/comm/mail/components/shell/nsIShellService.idl
new file mode 100644
index 0000000000..307cf9e48d
--- /dev/null
+++ b/comm/mail/components/shell/nsIShellService.idl
@@ -0,0 +1,52 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+
+#include "nsISupports.idl"
+
+[scriptable, uuid(95F53544-F445-48d1-B3A2-D54AA020BC3D)]
+interface nsIShellService : nsISupports
+{
+ /**
+ * app types we can be registered to handle
+ */
+ const unsigned short MAIL = 0x0001;
+ const unsigned short NEWS = 0x0002;
+ const unsigned short RSS = 0x0004;
+ const unsigned short CALENDAR = 0x0008;
+
+ /**
+ * Determines whether or not Thunderbird is the "Default Client" for the
+ * passed in app type.
+ *
+ * This is simply whether or not Thunderbid is registered to handle
+ * the url scheme associated with the app.
+ *
+ * @param aStartupCheck true if this is the check being performed
+ * by the first mail window at startup,
+ * false otherwise.
+ * @param aApps the application types being tested (Mail, News, RSS, etc.)
+ */
+ boolean isDefaultClient(in boolean aStartupCheck, in unsigned short aApps);
+
+ /**
+ * Registers Thunderbird as the "Default Mail Client" for the
+ * passed in app type.
+ *
+ * @param aForAllUsers Whether or not Thunderbird should attempt
+ * to become the default client for all
+ * users on a multi-user system.
+ * @param aApps the application types being tested (Mail, News, RSS, etc.)
+ */
+ void setDefaultClient(in boolean aForAllUsers, in unsigned short aApps);
+
+ /**
+ * Used to determine whether or not to show a "Set Default Client"
+ * query dialog. This attribute is true if the application is starting
+ * up and "mail.shell.checkDefaultClient" is true, otherwise it
+ * is false.
+ */
+ attribute boolean shouldCheckDefaultClient;
+};
diff --git a/comm/mail/components/shell/nsMacShellService.cpp b/comm/mail/components/shell/nsMacShellService.cpp
new file mode 100644
index 0000000000..383e3a2896
--- /dev/null
+++ b/comm/mail/components/shell/nsMacShellService.cpp
@@ -0,0 +1,156 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsMacShellService.h"
+#include "nsCOMPtr.h"
+#include "nsIServiceManager.h"
+#include "nsIStringBundle.h"
+#include "nsIPromptService.h"
+#include "nsIPrefService.h"
+#include "nsIPrefBranch.h"
+#include "nsString.h"
+#include "nsEmbedCID.h"
+
+// These Launch Services functions are undocumented. We're using them since
+// they're the only way to set the default opener for URLs
+extern "C" {
+// Returns the CFURL for application currently set as the default opener for
+// the given URL scheme. appURL must be released by the caller.
+extern OSStatus _LSCopyDefaultSchemeHandlerURL(CFStringRef scheme,
+ CFURLRef* appURL);
+extern OSStatus _LSSetDefaultSchemeHandlerURL(CFStringRef scheme,
+ CFURLRef appURL);
+extern OSStatus _LSSaveAndRefresh(void);
+}
+
+NS_IMPL_ISUPPORTS(nsMacShellService, nsIShellService, nsIToolkitShellService)
+
+nsMacShellService::nsMacShellService() : mCheckedThisSession(false) {}
+
+NS_IMETHODIMP
+nsMacShellService::IsDefaultClient(bool aStartupCheck, uint16_t aApps,
+ bool* aIsDefaultClient) {
+ *aIsDefaultClient = true;
+ if (aApps & nsIShellService::MAIL)
+ *aIsDefaultClient &= isDefaultHandlerForProtocol(CFSTR("mailto"));
+ if (aApps & nsIShellService::NEWS)
+ *aIsDefaultClient &= isDefaultHandlerForProtocol(CFSTR("news"));
+ if (aApps & nsIShellService::RSS)
+ *aIsDefaultClient &= isDefaultHandlerForProtocol(CFSTR("feed"));
+ if (aApps & nsIShellService::CALENDAR)
+ *aIsDefaultClient &= isDefaultHandlerForProtocol(CFSTR("webcal"));
+
+ // if this is the first mail window, maintain internal state that we've
+ // checked this session (so that subsequent window opens don't show the
+ // default client dialog.
+
+ if (aStartupCheck) mCheckedThisSession = true;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsMacShellService::SetDefaultClient(bool aForAllUsers, uint16_t aApps) {
+ nsresult rv = NS_OK;
+ if (aApps & nsIShellService::MAIL) {
+ rv = setAsDefaultHandlerForProtocol(CFSTR("mailto"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = setAsDefaultHandlerForProtocol(CFSTR("mid"));
+ }
+ if (NS_SUCCEEDED(rv) && aApps & nsIShellService::NEWS)
+ rv = setAsDefaultHandlerForProtocol(CFSTR("news"));
+ if (NS_SUCCEEDED(rv) && aApps & nsIShellService::RSS)
+ rv = setAsDefaultHandlerForProtocol(CFSTR("feed"));
+ if (NS_SUCCEEDED(rv) && aApps & nsIShellService::CALENDAR) {
+ rv = setAsDefaultHandlerForProtocol(CFSTR("webcal"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = setAsDefaultHandlerForProtocol(CFSTR("webcals"));
+ }
+
+ return rv;
+}
+
+NS_IMETHODIMP
+nsMacShellService::GetShouldCheckDefaultClient(bool* aResult) {
+ if (mCheckedThisSession) {
+ *aResult = false;
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->GetBoolPref("mail.shell.checkDefaultClient", aResult);
+}
+
+NS_IMETHODIMP
+nsMacShellService::SetShouldCheckDefaultClient(bool aShouldCheck) {
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->SetBoolPref("mail.shell.checkDefaultClient", aShouldCheck);
+}
+
+bool nsMacShellService::isDefaultHandlerForProtocol(CFStringRef aScheme) {
+ bool isDefault = false;
+ // Since neither Launch Services nor Internet Config actually differ between
+ // bundles which have the same bundle identifier (That is, if we set our
+ // URL of our bundle as the default handler for the given protocol,
+ // Launch Service might return the URL of another thunderbird bundle as the
+ // default handler for that protocol), we are comparing the identifiers of the
+ // bundles rather than their URLs.
+
+ CFStringRef tbirdID = ::CFBundleGetIdentifier(CFBundleGetMainBundle());
+ if (!tbirdID) {
+ // CFBundleGetIdentifier is expected to return NULL only if the specified
+ // bundle doesn't have a bundle identifier in its dictionary. In this case,
+ // that means a failure, since our bundle does have an identifier.
+ return isDefault;
+ }
+
+ ::CFRetain(tbirdID);
+
+ // Get the default handler URL of the given protocol
+ CFURLRef defaultHandlerURL;
+ OSStatus err = ::_LSCopyDefaultSchemeHandlerURL(aScheme, &defaultHandlerURL);
+
+ if (err == noErr) {
+ // Get a reference to the bundle (based on its URL)
+ CFBundleRef defaultHandlerBundle =
+ ::CFBundleCreate(NULL, defaultHandlerURL);
+ if (defaultHandlerBundle) {
+ CFStringRef defaultHandlerID =
+ ::CFBundleGetIdentifier(defaultHandlerBundle);
+ if (defaultHandlerID) {
+ ::CFRetain(defaultHandlerID);
+ // and compare it to our bundle identifier
+ isDefault = ::CFStringCompare(tbirdID, defaultHandlerID, 0) ==
+ kCFCompareEqualTo;
+ ::CFRelease(defaultHandlerID);
+ } else {
+ // If the bundle doesn't have an identifier in its info property list,
+ // it's not our bundle.
+ isDefault = false;
+ }
+
+ ::CFRelease(defaultHandlerBundle);
+ }
+
+ ::CFRelease(defaultHandlerURL);
+ } else {
+ // If |_LSCopyDefaultSchemeHandlerURL| failed, there's no default
+ // handler for the given protocol
+ isDefault = false;
+ }
+
+ ::CFRelease(tbirdID);
+ return isDefault;
+}
+
+nsresult nsMacShellService::setAsDefaultHandlerForProtocol(
+ CFStringRef aScheme) {
+ CFURLRef tbirdURL = ::CFBundleCopyBundleURL(CFBundleGetMainBundle());
+
+ ::_LSSetDefaultSchemeHandlerURL(aScheme, tbirdURL);
+ ::_LSSaveAndRefresh();
+ ::CFRelease(tbirdURL);
+
+ return NS_OK;
+}
diff --git a/comm/mail/components/shell/nsMacShellService.h b/comm/mail/components/shell/nsMacShellService.h
new file mode 100644
index 0000000000..7a301cb2fb
--- /dev/null
+++ b/comm/mail/components/shell/nsMacShellService.h
@@ -0,0 +1,36 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef nsMacShellService_h_
+#define nsMacShellService_h_
+
+#include "nsIShellService.h"
+#include "nsString.h"
+#include "nsToolkitShellService.h"
+
+#include <CoreFoundation/CoreFoundation.h>
+
+#define NS_MAILMACINTEGRATION_CID \
+ { \
+ 0x85a27035, 0xb970, 0x4079, { \
+ 0xb9, 0xd2, 0xe2, 0x1f, 0x69, 0xe6, 0xb2, 0x1f \
+ } \
+ }
+
+class nsMacShellService : public nsIShellService, public nsToolkitShellService {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSISHELLSERVICE
+ nsMacShellService();
+
+ protected:
+ bool isDefaultHandlerForProtocol(CFStringRef aScheme);
+ nsresult setAsDefaultHandlerForProtocol(CFStringRef aScheme);
+
+ private:
+ virtual ~nsMacShellService(){};
+ bool mCheckedThisSession;
+};
+#endif
diff --git a/comm/mail/components/shell/nsToolkitShellService.h b/comm/mail/components/shell/nsToolkitShellService.h
new file mode 100644
index 0000000000..160f9d7cbe
--- /dev/null
+++ b/comm/mail/components/shell/nsToolkitShellService.h
@@ -0,0 +1,23 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef nstoolkitshellservice_h____
+#define nstoolkitshellservice_h____
+
+#include "nsIToolkitShellService.h"
+
+class nsToolkitShellService : public nsIToolkitShellService {
+ public:
+ NS_IMETHOD IsDefaultClient(bool aStartupCheck, uint16_t aApps,
+ bool* aIsDefaultClient) = 0;
+
+ NS_IMETHODIMP IsDefaultApplication(bool* aIsDefaultClient) {
+ // This does some OS-specific checking: GConf on Linux, mailto/news protocol
+ // handler on Mac, registry and application association checks on Windows.
+ return IsDefaultClient(false, nsIShellService::MAIL, aIsDefaultClient);
+ }
+};
+
+#endif // nstoolkitshellservice_h____
diff --git a/comm/mail/components/shell/nsWindowsShellService.cpp b/comm/mail/components/shell/nsWindowsShellService.cpp
new file mode 100644
index 0000000000..e82cf0ed0e
--- /dev/null
+++ b/comm/mail/components/shell/nsWindowsShellService.cpp
@@ -0,0 +1,329 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsWindowsShellService.h"
+#include "nsIServiceManager.h"
+#include "nsICategoryManager.h"
+#include "nsNativeCharsetUtils.h"
+#include "nsIPrefService.h"
+#include "windows.h"
+#include "shellapi.h"
+#include "nsIFile.h"
+#include "nsDirectoryServiceDefs.h"
+#include "nsUnicharUtils.h"
+#include "nsComponentManagerUtils.h"
+#include "nsServiceManagerUtils.h"
+#include "nsIProperties.h"
+#include "nsString.h"
+
+#ifdef _WIN32_WINNT
+# undef _WIN32_WINNT
+#endif
+#define _WIN32_WINNT 0x0600
+#define INITGUID
+#include <shlobj.h>
+
+#include <mbstring.h>
+
+#ifndef MAX_BUF
+# define MAX_BUF 4096
+#endif
+
+#define REG_FAILED(val) (val != ERROR_SUCCESS)
+
+NS_IMPL_ISUPPORTS(nsWindowsShellService, nsIShellService,
+ nsIToolkitShellService)
+
+static nsresult OpenKeyForReading(HKEY aKeyRoot, const nsAString& aKeyName,
+ HKEY* aKey) {
+ const nsString& flatName = PromiseFlatString(aKeyName);
+
+ DWORD res = ::RegOpenKeyExW(aKeyRoot, flatName.get(), 0, KEY_READ, aKey);
+ switch (res) {
+ case ERROR_SUCCESS:
+ break;
+ case ERROR_ACCESS_DENIED:
+ return NS_ERROR_FILE_ACCESS_DENIED;
+ case ERROR_FILE_NOT_FOUND:
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ return NS_OK;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Default Mail Registry Settings
+///////////////////////////////////////////////////////////////////////////////
+
+typedef enum {
+ NO_SUBSTITUTION = 0x00,
+ APP_PATH_SUBSTITUTION = 0x01
+} SettingFlags;
+
+// APP_REG_NAME_MAIL and APP_REG_NAME_NEWS should be kept in synch with
+// AppRegNameMail and AppRegNameNews in the installer file: defines.nsi.in
+#define APP_REG_NAME_MAIL L"Thunderbird"
+#define APP_REG_NAME_NEWS L"Thunderbird (News)"
+#define APP_REG_NAME_CALENDAR L"Thunderbird (Calendar)"
+#define CLS_EML "ThunderbirdEML"
+#define CLS_MAILTOURL "Thunderbird.Url.mailto"
+#define CLS_MIDURL "Thunderbird.Url.mid"
+#define CLS_NEWSURL "Thunderbird.Url.news"
+#define CLS_FEEDURL "Thunderbird.Url.feed"
+#define CLS_WEBCALURL "Thunderbird.Url.webcal"
+#define CLS_ICS "ThunderbirdICS"
+#define SOP "\\shell\\open\\command"
+#define VAL_OPEN "\"%APPPATH%\" \"%1\""
+#define VAL_MAIL_OPEN "\"%APPPATH%\" -osint -mail \"%1\""
+#define VAL_COMPOSE_OPEN "\"%APPPATH%\" -osint -compose \"%1\""
+
+#define MAKE_KEY_NAME1(PREFIX, MID) PREFIX MID
+
+static SETTING gMailSettings[] = {
+ // File Extension Class
+ {".eml", "", CLS_EML, NO_SUBSTITUTION},
+
+ // File Extension Class
+ {MAKE_KEY_NAME1(CLS_EML, SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+
+ // Protocol Handler Class - for Vista and above
+ {MAKE_KEY_NAME1(CLS_MAILTOURL, SOP), "", VAL_COMPOSE_OPEN,
+ APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1(CLS_MIDURL, SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+
+ // Protocol Handlers
+ {MAKE_KEY_NAME1("mailto", SOP), "", VAL_COMPOSE_OPEN,
+ APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1("mid", SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+};
+
+static SETTING gNewsSettings[] = {
+ // Protocol Handler Class - for Vista and above
+ {MAKE_KEY_NAME1(CLS_NEWSURL, SOP), "", VAL_MAIL_OPEN,
+ APP_PATH_SUBSTITUTION},
+
+ // Protocol Handlers
+ {MAKE_KEY_NAME1("news", SOP), "", VAL_MAIL_OPEN, APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1("nntp", SOP), "", VAL_MAIL_OPEN, APP_PATH_SUBSTITUTION},
+};
+
+static SETTING gCalendarSettings[] = {
+ // File Extension Class
+ {".ics", "", CLS_ICS, NO_SUBSTITUTION},
+
+ // File Extension Class
+ {MAKE_KEY_NAME1(CLS_ICS, SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+
+ // Protocol Handlers
+ {MAKE_KEY_NAME1(CLS_WEBCALURL, SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1("webcal", SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1("webcals", SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+};
+
+nsresult GetHelperPath(nsAutoString& aPath) {
+ nsresult rv;
+ nsCOMPtr<nsIProperties> directoryService =
+ do_GetService(NS_DIRECTORY_SERVICE_CONTRACTID, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIFile> appHelper;
+ rv = directoryService->Get(NS_XPCOM_CURRENT_PROCESS_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(appHelper));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = appHelper->Append(u"uninstall"_ns);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = appHelper->Append(u"helper.exe"_ns);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return appHelper->GetPath(aPath);
+}
+
+nsresult LaunchHelper(nsAutoString& aPath, nsAutoString& aParams) {
+ SHELLEXECUTEINFOW executeInfo = {0};
+
+ executeInfo.cbSize = sizeof(SHELLEXECUTEINFOW);
+ executeInfo.hwnd = NULL;
+ executeInfo.fMask = SEE_MASK_NOCLOSEPROCESS;
+ executeInfo.lpDirectory = NULL;
+ executeInfo.lpFile = aPath.get();
+ executeInfo.lpParameters = aParams.get();
+ executeInfo.nShow = SW_SHOWNORMAL;
+
+ if (ShellExecuteExW(&executeInfo))
+ // Block until the program exits
+ WaitForSingleObject(executeInfo.hProcess, INFINITE);
+ else
+ return NS_ERROR_ABORT;
+
+ // We're going to ignore errors here since there's nothing we can do about
+ // them, and helper.exe seems to return non-zero ret on success.
+ return NS_OK;
+}
+
+nsresult nsWindowsShellService::Init() {
+ WCHAR appPath[MAX_BUF];
+ if (!::GetModuleFileNameW(0, appPath, MAX_BUF)) return NS_ERROR_FAILURE;
+
+ // Convert the path to a long path since GetModuleFileNameW returns the path
+ // that was used to launch the app which is not necessarily a long path.
+ if (!::GetLongPathNameW(appPath, appPath, MAX_BUF)) return NS_ERROR_FAILURE;
+
+ mAppLongPath = appPath;
+
+ return NS_OK;
+}
+
+nsWindowsShellService::nsWindowsShellService() : mCheckedThisSession(false) {}
+
+NS_IMETHODIMP
+nsWindowsShellService::IsDefaultClient(bool aStartupCheck, uint16_t aApps,
+ bool* aIsDefaultClient) {
+ // If this is the first mail window, maintain internal state that we've
+ // checked this session (so that subsequent window opens don't show the
+ // default client dialog).
+ if (aStartupCheck) mCheckedThisSession = true;
+
+ *aIsDefaultClient = true;
+
+ // for each type,
+ if (aApps & nsIShellService::MAIL) {
+ *aIsDefaultClient &=
+ TestForDefault(gMailSettings, sizeof(gMailSettings) / sizeof(SETTING));
+ // Only check if this app is default on Vista if the previous checks
+ // indicate that this app is the default.
+ if (*aIsDefaultClient)
+ IsDefaultClientVista(nsIShellService::MAIL, aIsDefaultClient);
+ }
+ if (aApps & nsIShellService::NEWS) {
+ *aIsDefaultClient &=
+ TestForDefault(gNewsSettings, sizeof(gNewsSettings) / sizeof(SETTING));
+ // Only check if this app is default on Vista if the previous checks
+ // indicate that this app is the default.
+ if (*aIsDefaultClient)
+ IsDefaultClientVista(nsIShellService::NEWS, aIsDefaultClient);
+ }
+ if (aApps & nsIShellService::CALENDAR) {
+ *aIsDefaultClient &= TestForDefault(
+ gCalendarSettings, sizeof(gCalendarSettings) / sizeof(SETTING));
+ // Only check if this app is default on Vista if the previous checks
+ // indicate that this app is the default.
+ if (*aIsDefaultClient)
+ IsDefaultClientVista(nsIShellService::CALENDAR, aIsDefaultClient);
+ }
+ // RSS / feed protocol shell integration is not working so return true
+ // until it is fixed (bug 445823).
+ if (aApps & nsIShellService::RSS) *aIsDefaultClient &= true;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsWindowsShellService::SetDefaultClient(bool aForAllUsers, uint16_t aApps) {
+ nsAutoString appHelperPath;
+ if (NS_FAILED(GetHelperPath(appHelperPath))) return NS_ERROR_FAILURE;
+
+ nsAutoString params;
+ if (aForAllUsers) {
+ params.AppendLiteral(" /SetAsDefaultAppGlobal");
+ } else {
+ params.AppendLiteral(" /SetAsDefaultAppUser");
+ if (aApps & nsIShellService::MAIL) params.AppendLiteral(" Mail");
+
+ if (aApps & nsIShellService::NEWS) params.AppendLiteral(" News");
+
+ if (aApps & nsIShellService::CALENDAR) params.AppendLiteral(" Calendar");
+ }
+
+ return LaunchHelper(appHelperPath, params);
+}
+
+NS_IMETHODIMP
+nsWindowsShellService::GetShouldCheckDefaultClient(bool* aResult) {
+ if (mCheckedThisSession) {
+ *aResult = false;
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->GetBoolPref("mail.shell.checkDefaultClient", aResult);
+}
+
+NS_IMETHODIMP
+nsWindowsShellService::SetShouldCheckDefaultClient(bool aShouldCheck) {
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->SetBoolPref("mail.shell.checkDefaultClient", aShouldCheck);
+}
+
+/* helper routine. Iterate over the passed in settings object. */
+bool nsWindowsShellService::TestForDefault(SETTING aSettings[], int32_t aSize) {
+ bool isDefault = true;
+ char16_t currValue[MAX_BUF];
+ SETTING* end = aSettings + aSize;
+ for (SETTING* settings = aSettings; settings < end; ++settings) {
+ NS_ConvertUTF8toUTF16 dataLongPath(settings->valueData);
+ NS_ConvertUTF8toUTF16 key(settings->keyName);
+ NS_ConvertUTF8toUTF16 value(settings->valueName);
+ if (settings->flags & APP_PATH_SUBSTITUTION) {
+ int32_t offset = dataLongPath.Find(u"%APPPATH%");
+ dataLongPath.Replace(offset, 9, mAppLongPath);
+ }
+
+ ::ZeroMemory(currValue, sizeof(currValue));
+ HKEY theKey;
+ nsresult rv = OpenKeyForReading(HKEY_CLASSES_ROOT, key, &theKey);
+ if (NS_FAILED(rv)) {
+ // Key doesn't exist
+ isDefault = false;
+ break;
+ }
+
+ DWORD len = sizeof currValue;
+ DWORD result = ::RegQueryValueExW(theKey, value.get(), NULL, NULL,
+ (LPBYTE)currValue, &len);
+ // Close the key we opened.
+ ::RegCloseKey(theKey);
+ if (REG_FAILED(result) ||
+ !dataLongPath.Equals(currValue, nsCaseInsensitiveStringComparator)) {
+ // Key wasn't set, or was set to something else (something else became the
+ // default client)
+ isDefault = false;
+ break;
+ }
+ } // for each registry key we want to look at
+
+ return isDefault;
+}
+
+bool nsWindowsShellService::IsDefaultClientVista(uint16_t aApps,
+ bool* aIsDefaultClient) {
+ IApplicationAssociationRegistration* pAAR;
+
+ HRESULT hr = CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, NULL, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, (void**)&pAAR);
+
+ if (SUCCEEDED(hr)) {
+ BOOL isDefaultMail = true;
+ BOOL isDefaultNews = true;
+ BOOL isDefaultCalendar = true;
+ if (aApps & nsIShellService::MAIL)
+ pAAR->QueryAppIsDefaultAll(AL_EFFECTIVE, APP_REG_NAME_MAIL,
+ &isDefaultMail);
+ if (aApps & nsIShellService::NEWS)
+ pAAR->QueryAppIsDefaultAll(AL_EFFECTIVE, APP_REG_NAME_NEWS,
+ &isDefaultNews);
+ if (aApps & nsIShellService::CALENDAR)
+ pAAR->QueryAppIsDefaultAll(AL_EFFECTIVE, APP_REG_NAME_CALENDAR,
+ &isDefaultCalendar);
+
+ *aIsDefaultClient = isDefaultNews && isDefaultMail && isDefaultCalendar;
+
+ pAAR->Release();
+ return true;
+ }
+ return false;
+}
diff --git a/comm/mail/components/shell/nsWindowsShellService.h b/comm/mail/components/shell/nsWindowsShellService.h
new file mode 100644
index 0000000000..0dd1f760a8
--- /dev/null
+++ b/comm/mail/components/shell/nsWindowsShellService.h
@@ -0,0 +1,51 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef nsWindowsShellService_h_
+#define nsWindowsShellService_h_
+
+#include "nsIShellService.h"
+#include "nsIObserver.h"
+#include "nsString.h"
+#include "nsToolkitShellService.h"
+
+#include <ole2.h>
+#include <windows.h>
+
+#define NS_MAILWININTEGRATION_CID \
+ { \
+ 0x2ebbe84, 0xc179, 0x4598, { \
+ 0xaf, 0x18, 0x1b, 0xf2, 0xc4, 0xbc, 0x1d, 0xf9 \
+ } \
+ }
+
+typedef struct {
+ const char* keyName;
+ const char* valueName;
+ const char* valueData;
+
+ int32_t flags;
+} SETTING;
+
+class nsWindowsShellService : public nsIShellService,
+ public nsToolkitShellService {
+ public:
+ nsWindowsShellService();
+ nsresult Init();
+
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSISHELLSERVICE
+
+ protected:
+ bool TestForDefault(SETTING aSettings[], int32_t aSize);
+ bool IsDefaultClientVista(uint16_t aApps, bool* aIsDefaultClient);
+
+ private:
+ virtual ~nsWindowsShellService(){};
+ bool mCheckedThisSession;
+ nsAutoString mAppLongPath;
+};
+
+#endif
diff --git a/comm/mail/components/shell/test/unit/test_shellService.js b/comm/mail/components/shell/test/unit/test_shellService.js
new file mode 100644
index 0000000000..ebd9f85532
--- /dev/null
+++ b/comm/mail/components/shell/test/unit/test_shellService.js
@@ -0,0 +1,22 @@
+/* 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 setDefaultClient works for all supported types.
+ */
+add_task(function test_setDefaultClient() {
+ let shellSvc = Cc["@mozilla.org/mail/shell-service;1"].getService(
+ Ci.nsIShellService
+ );
+
+ let types = ["MAIL", "NEWS", "RSS", "CALENDAR"];
+
+ for (let type of types) {
+ shellSvc.setDefaultClient(false, shellSvc[type]);
+ ok(
+ shellSvc.isDefaultClient(false, shellSvc[type]),
+ `setDefaultClient works for type ${type}`
+ );
+ }
+});
diff --git a/comm/mail/components/shell/test/unit/xpcshell.ini b/comm/mail/components/shell/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..0983e89525
--- /dev/null
+++ b/comm/mail/components/shell/test/unit/xpcshell.ini
@@ -0,0 +1,2 @@
+[test_shellService.js]
+skip-if = os == 'win' # setDefaultClient requires user confirmation on Windows.
diff --git a/comm/mail/components/storybook/.storybook/main.js b/comm/mail/components/storybook/.storybook/main.js
new file mode 100644
index 0000000000..743243b4a0
--- /dev/null
+++ b/comm/mail/components/storybook/.storybook/main.js
@@ -0,0 +1,47 @@
+/* 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/. */
+/* eslint-env node */
+
+const path = require("path");
+
+// ./mach environment --format json
+// topobjdir should be the build location
+
+module.exports = {
+ stories: [
+ "../stories/**/*.stories.mdx",
+ "../stories/**/*.stories.@(mjs|jsx|ts|tsx)",
+ ],
+ addons: ["@storybook/addon-links", "@storybook/addon-essentials"],
+ framework: "@storybook/web-components",
+ webpackFinal: async (config, { configType }) => {
+ // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
+ // You can change the configuration based on that.
+ // 'PRODUCTION' is used when building the static version of storybook.
+
+ // Make whatever fine-grained changes you need
+ const projectRoot = path.resolve(__dirname, "../../../../");
+ config.resolve.alias.mail = `${projectRoot}/mail`;
+
+ config.module.rules.push({
+ test: /\.ftl$/,
+ type: "asset/source",
+ });
+
+ config.optimization = {
+ splitChunks: false,
+ runtimeChunk: false,
+ sideEffects: false,
+ usedExports: false,
+ concatenateModules: false,
+ minimizer: [],
+ };
+
+ // Return the altered config
+ return config;
+ },
+ core: {
+ builder: "webpack5",
+ },
+};
diff --git a/comm/mail/components/storybook/.storybook/preview-head.html b/comm/mail/components/storybook/.storybook/preview-head.html
new file mode 100644
index 0000000000..90b30870b9
--- /dev/null
+++ b/comm/mail/components/storybook/.storybook/preview-head.html
@@ -0,0 +1,5 @@
+<!-- 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/. -->
+
+<link rel="stylesheet" href="chrome://global/skin/global.css">
diff --git a/comm/mail/components/storybook/.storybook/preview.mjs b/comm/mail/components/storybook/.storybook/preview.mjs
new file mode 100644
index 0000000000..6b654c6049
--- /dev/null
+++ b/comm/mail/components/storybook/.storybook/preview.mjs
@@ -0,0 +1,43 @@
+/* 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 { DOMLocalization } from "@fluent/dom";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+
+// Base Fluent set up.
+let storybookBundle = new FluentBundle("en-US");
+let loadedResources = new Set();
+function* generateBundles() {
+ yield* [storybookBundle];
+}
+document.l10n = new DOMLocalization([], generateBundles);
+document.l10n.connectRoot(document.documentElement);
+
+// Any fluent imports should go through MozXULElement.insertFTLIfNeeded.
+window.MozXULElement = {
+ async insertFTLIfNeeded(name) {
+ if (loadedResources.has(name)) {
+ return;
+ }
+ //TODO might have to dynamically change this depending on where a component
+ // lives in the tree (for example for calendar or mailnews).
+ // eslint-disable-next-line no-unsanitized/method
+ let imported = await import(
+ /* webpackInclude: /.*[\/\\].*\.ftl/ */
+ `mail/locales/en-US/${name}`
+ );
+ let ftlContents = imported.default;
+
+ if (loadedResources.has(name)) {
+ // Seems possible we've attempted to load this twice before the first call
+ // resolves, so once the first load is complete we can abandon the others.
+ return;
+ }
+
+ let ftlResource = new FluentResource(ftlContents);
+ storybookBundle.addResource(ftlResource);
+ loadedResources.add(name);
+ document.l10n.translateRoots();
+ },
+};
diff --git a/comm/mail/components/storybook/README.md b/comm/mail/components/storybook/README.md
new file mode 100644
index 0000000000..4fe739c07a
--- /dev/null
+++ b/comm/mail/components/storybook/README.md
@@ -0,0 +1,39 @@
+= Storybook for Thunderbird
+
+Storybook is a component library to document our design system, reusable
+components and any specific components you might want to test with dummy data.
+
+== Background
+
+The storybook will list components that can be reused, and will help document
+what common elements we have. It can also list implementation specific
+components, but they should not be added to the "Design System" section.
+
+Changes to files directly referenced from the storybook (so basically
+non-chrome:// paths) should automatically reflect changes in the opened tab.
+If you make a change to a chrome:// referenced file then you'll need to do a
+hard refresh (Cmd+Shift+R/Ctrl+Shift+R) to notice the changes.
+
+=== Running storybook
+
+First time around, you will have to install the npm dependencies for storybook.
+There is a mach command to do so using the mach-provided `npm`:
+
+```
+# Working directory is your comm-central checkout root directory.
+../mach tb-storybook install
+```
+
+Once the npm dependencies are installed, you can run storybook by executing
+
+```
+# Working directory is your comm-central checkout root directory.
+../mach tb-storybook
+```
+
+Now storybook should be running at `http://localhost:5703`. To use storybook, run
+the following command in your Thunderbird developer console:
+
+```js
+tabmail.openTab("contentTab", { url: "http://localhost:5703" })
+```
diff --git a/comm/mail/components/storybook/mach_commands.py b/comm/mail/components/storybook/mach_commands.py
new file mode 100644
index 0000000000..568e9c08b2
--- /dev/null
+++ b/comm/mail/components/storybook/mach_commands.py
@@ -0,0 +1,42 @@
+# 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/.
+
+from mach.decorators import Command, SubCommand
+
+
+def run_mach(command_context, cmd, **kwargs):
+ return command_context._mach_context.commands.dispatch(
+ cmd, command_context._mach_context, **kwargs
+ )
+
+
+def run_npm(command_context, args):
+ return run_mach(command_context, "npm", args=[*args, "--prefix=mail/components/storybook"])
+
+
+@Command(
+ "tb-storybook",
+ category="misc",
+ description="Start the Storybook server",
+)
+def storybook_run(command_context):
+ return run_npm(command_context, args=["run", "storybook"])
+
+
+@SubCommand(
+ "tb-storybook",
+ "install",
+ description="Install Storybook node dependencies.",
+)
+def storybook_install(command_context):
+ return run_npm(command_context, args=["ci"])
+
+
+@SubCommand(
+ "tb-storybook",
+ "build",
+ description="Build the Storybook for export.",
+)
+def storybook_build(command_context):
+ return run_npm(command_context, args=["run", "build-storybook"])
diff --git a/comm/mail/components/storybook/package-lock.json b/comm/mail/components/storybook/package-lock.json
new file mode 100644
index 0000000000..02ca970af4
--- /dev/null
+++ b/comm/mail/components/storybook/package-lock.json
@@ -0,0 +1,37747 @@
+{
+ "name": "mail-storybook",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mail-storybook",
+ "version": "1.0.0",
+ "license": "MPL-2.0",
+ "devDependencies": {
+ "@babel/core": "^7.19.3",
+ "@fluent/bundle": "^0.17.1",
+ "@fluent/dom": "^0.8.1",
+ "@storybook/addon-actions": "^6.5.12",
+ "@storybook/addon-essentials": "^6.5.12",
+ "@storybook/addon-links": "^6.5.12",
+ "@storybook/builder-webpack5": "^6.5.12",
+ "@storybook/manager-webpack5": "^6.5.12",
+ "@storybook/web-components": "^6.5.12",
+ "babel-loader": "^8.2.5",
+ "lit": "^2.3.1"
+ }
+ },
+ "../../..": {
+ "extraneous": true
+ },
+ "../../../..": {
+ "extraneous": true,
+ "license": "MPL-2.0",
+ "devDependencies": {
+ "@babel/core": "7.19.1",
+ "@babel/eslint-parser": "7.19.1",
+ "@babel/eslint-plugin": "7.19.1",
+ "@babel/plugin-syntax-jsx": "7.18.6",
+ "@microsoft/eslint-plugin-sdl": "github:mozfreddyb/eslint-plugin-sdl#17b22cd527682108af7a1a4edacf69cb7a9b4a06",
+ "eslint": "8.24.0",
+ "eslint-config-prettier": "8.5.0",
+ "eslint-plugin-fetch-options": "0.0.5",
+ "eslint-plugin-file-header": "0.0.1",
+ "eslint-plugin-html": "7.1.0",
+ "eslint-plugin-import": "2.26.0",
+ "eslint-plugin-jest": "23.20.0",
+ "eslint-plugin-jsdoc": "39.3.6",
+ "eslint-plugin-jsx-a11y": "6.6.1",
+ "eslint-plugin-mozilla": "file:tools/lint/eslint/eslint-plugin-mozilla",
+ "eslint-plugin-no-unsanitized": "4.0.1",
+ "eslint-plugin-prettier": "3.4.0",
+ "eslint-plugin-react": "7.29.4",
+ "eslint-plugin-react-hooks": "4.6.0",
+ "eslint-plugin-spidermonkey-js": "file:tools/lint/eslint/eslint-plugin-spidermonkey-js",
+ "jsdoc": "3.6.11",
+ "prettier": "1.19.1",
+ "yarn": "1.22.19"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+ "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.1.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+ "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/highlight": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.3.tgz",
+ "integrity": "sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz",
+ "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==",
+ "dev": true,
+ "dependencies": {
+ "@ampproject/remapping": "^2.1.0",
+ "@babel/code-frame": "^7.18.6",
+ "@babel/generator": "^7.19.3",
+ "@babel/helper-compilation-targets": "^7.19.3",
+ "@babel/helper-module-transforms": "^7.19.0",
+ "@babel/helpers": "^7.19.0",
+ "@babel/parser": "^7.19.3",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.3",
+ "@babel/types": "^7.19.3",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.1",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.3.tgz",
+ "integrity": "sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.19.3",
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "jsesc": "^2.5.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+ "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
+ "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz",
+ "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-explode-assignable-expression": "^7.18.6",
+ "@babel/types": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz",
+ "integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.19.3",
+ "@babel/helper-validator-option": "^7.18.6",
+ "browserslist": "^4.21.3",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz",
+ "integrity": "sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-member-expression-to-functions": "^7.18.9",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/helper-replace-supers": "^7.18.9",
+ "@babel/helper-split-export-declaration": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-regexp-features-plugin": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz",
+ "integrity": "sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "regexpu-core": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz",
+ "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.17.7",
+ "@babel/helper-plugin-utils": "^7.16.7",
+ "debug": "^4.1.1",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.14.2",
+ "semver": "^6.1.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0-0"
+ }
+ },
+ "node_modules/@babel/helper-environment-visitor": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+ "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-explode-assignable-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz",
+ "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-function-name": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
+ "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.18.10",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-hoist-variables": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+ "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz",
+ "integrity": "sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+ "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz",
+ "integrity": "sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-simple-access": "^7.18.6",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz",
+ "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz",
+ "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-remap-async-to-generator": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz",
+ "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-wrap-function": "^7.18.9",
+ "@babel/types": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz",
+ "integrity": "sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-member-expression-to-functions": "^7.18.9",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/traverse": "^7.19.1",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-simple-access": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz",
+ "integrity": "sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz",
+ "integrity": "sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-split-export-declaration": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+ "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz",
+ "integrity": "sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+ "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
+ "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-wrap-function": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz",
+ "integrity": "sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.19.0.tgz",
+ "integrity": "sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/highlight": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+ "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "chalk": "^2.0.0",
+ "js-tokens": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz",
+ "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==",
+ "dev": true,
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz",
+ "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz",
+ "integrity": "sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9",
+ "@babel/plugin-proposal-optional-chaining": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.13.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-async-generator-functions": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz",
+ "integrity": "sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-remap-async-to-generator": "^7.18.9",
+ "@babel/plugin-syntax-async-generators": "^7.8.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-class-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz",
+ "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-class-static-block": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz",
+ "integrity": "sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.12.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-decorators": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.19.3.tgz",
+ "integrity": "sha512-MbgXtNXqo7RTKYIXVchVJGPvaVufQH3pxvQyfbGvNw1DObIhph+PesYXJTcd8J4DdWibvf6Z2eanOyItX8WnJg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-replace-supers": "^7.19.1",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/plugin-syntax-decorators": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-dynamic-import": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz",
+ "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-export-default-from": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.18.10.tgz",
+ "integrity": "sha512-5H2N3R2aQFxkV4PIBUR/i7PUSwgTZjouJKzI8eKswfIjT0PhvzkPn0t0wIS5zn6maQuvtT0t1oHtMUz61LOuow==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-export-default-from": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-export-namespace-from": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz",
+ "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-export-namespace-from": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-json-strings": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz",
+ "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-json-strings": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-logical-assignment-operators": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz",
+ "integrity": "sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz",
+ "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-numeric-separator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz",
+ "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz",
+ "integrity": "sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.18.8",
+ "@babel/helper-compilation-targets": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-transform-parameters": "^7.18.8"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-optional-catch-binding": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz",
+ "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-optional-chaining": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz",
+ "integrity": "sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-methods": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
+ "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz",
+ "integrity": "sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-unicode-property-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz",
+ "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-async-generators": {
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.12.13"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-class-static-block": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-decorators": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz",
+ "integrity": "sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-dynamic-import": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
+ "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-export-default-from": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.18.6.tgz",
+ "integrity": "sha512-Kr//z3ujSVNx6E9z9ih5xXXMqK07VVTuqPmqGe6Mss/zW5XPeLZeSDZoP9ab/hT4wPKqAgjl2PnhPrcpk8Seew==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-export-namespace-from": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz",
+ "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.3"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz",
+ "integrity": "sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-meta": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-json-strings": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz",
+ "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-numeric-separator": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-object-rest-spread": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-optional-catch-binding": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-optional-chaining": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-private-property-in-object": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-top-level-await": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz",
+ "integrity": "sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz",
+ "integrity": "sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-to-generator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz",
+ "integrity": "sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-remap-async-to-generator": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz",
+ "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoping": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz",
+ "integrity": "sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-classes": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz",
+ "integrity": "sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-compilation-targets": "^7.19.0",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-replace-supers": "^7.18.9",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-computed-properties": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz",
+ "integrity": "sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-destructuring": {
+ "version": "7.18.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz",
+ "integrity": "sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dotall-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz",
+ "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-keys": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz",
+ "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz",
+ "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-for-of": {
+ "version": "7.18.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz",
+ "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-function-name": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz",
+ "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.18.9",
+ "@babel/helper-function-name": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-literals": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz",
+ "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-member-expression-literals": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz",
+ "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-amd": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz",
+ "integrity": "sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz",
+ "integrity": "sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-simple-access": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-systemjs": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz",
+ "integrity": "sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-hoist-variables": "^7.18.6",
+ "@babel/helper-module-transforms": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-umd": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz",
+ "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz",
+ "integrity": "sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-new-target": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz",
+ "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-super": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz",
+ "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-replace-supers": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-parameters": {
+ "version": "7.18.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz",
+ "integrity": "sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-property-literals": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz",
+ "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-display-name": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz",
+ "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.19.0.tgz",
+ "integrity": "sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/plugin-syntax-jsx": "^7.18.6",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-development": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz",
+ "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-pure-annotations": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz",
+ "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regenerator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz",
+ "integrity": "sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "regenerator-transform": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-reserved-words": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz",
+ "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-shorthand-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz",
+ "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-spread": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz",
+ "integrity": "sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-sticky-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz",
+ "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-template-literals": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz",
+ "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-typeof-symbol": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz",
+ "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-typescript": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz",
+ "integrity": "sha512-z6fnuK9ve9u/0X0rRvI9MY0xg+DOUaABDYOe+/SQTxtlptaBB/V9JIUxJn6xp3lMBeb9qe8xSFmHU35oZDXD+w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/plugin-syntax-typescript": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-escapes": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz",
+ "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz",
+ "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-env": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.3.tgz",
+ "integrity": "sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.19.3",
+ "@babel/helper-compilation-targets": "^7.19.3",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9",
+ "@babel/plugin-proposal-async-generator-functions": "^7.19.1",
+ "@babel/plugin-proposal-class-properties": "^7.18.6",
+ "@babel/plugin-proposal-class-static-block": "^7.18.6",
+ "@babel/plugin-proposal-dynamic-import": "^7.18.6",
+ "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
+ "@babel/plugin-proposal-json-strings": "^7.18.6",
+ "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
+ "@babel/plugin-proposal-numeric-separator": "^7.18.6",
+ "@babel/plugin-proposal-object-rest-spread": "^7.18.9",
+ "@babel/plugin-proposal-optional-catch-binding": "^7.18.6",
+ "@babel/plugin-proposal-optional-chaining": "^7.18.9",
+ "@babel/plugin-proposal-private-methods": "^7.18.6",
+ "@babel/plugin-proposal-private-property-in-object": "^7.18.6",
+ "@babel/plugin-proposal-unicode-property-regex": "^7.18.6",
+ "@babel/plugin-syntax-async-generators": "^7.8.4",
+ "@babel/plugin-syntax-class-properties": "^7.12.13",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-export-namespace-from": "^7.8.3",
+ "@babel/plugin-syntax-import-assertions": "^7.18.6",
+ "@babel/plugin-syntax-json-strings": "^7.8.3",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+ "@babel/plugin-syntax-top-level-await": "^7.14.5",
+ "@babel/plugin-transform-arrow-functions": "^7.18.6",
+ "@babel/plugin-transform-async-to-generator": "^7.18.6",
+ "@babel/plugin-transform-block-scoped-functions": "^7.18.6",
+ "@babel/plugin-transform-block-scoping": "^7.18.9",
+ "@babel/plugin-transform-classes": "^7.19.0",
+ "@babel/plugin-transform-computed-properties": "^7.18.9",
+ "@babel/plugin-transform-destructuring": "^7.18.13",
+ "@babel/plugin-transform-dotall-regex": "^7.18.6",
+ "@babel/plugin-transform-duplicate-keys": "^7.18.9",
+ "@babel/plugin-transform-exponentiation-operator": "^7.18.6",
+ "@babel/plugin-transform-for-of": "^7.18.8",
+ "@babel/plugin-transform-function-name": "^7.18.9",
+ "@babel/plugin-transform-literals": "^7.18.9",
+ "@babel/plugin-transform-member-expression-literals": "^7.18.6",
+ "@babel/plugin-transform-modules-amd": "^7.18.6",
+ "@babel/plugin-transform-modules-commonjs": "^7.18.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.19.0",
+ "@babel/plugin-transform-modules-umd": "^7.18.6",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1",
+ "@babel/plugin-transform-new-target": "^7.18.6",
+ "@babel/plugin-transform-object-super": "^7.18.6",
+ "@babel/plugin-transform-parameters": "^7.18.8",
+ "@babel/plugin-transform-property-literals": "^7.18.6",
+ "@babel/plugin-transform-regenerator": "^7.18.6",
+ "@babel/plugin-transform-reserved-words": "^7.18.6",
+ "@babel/plugin-transform-shorthand-properties": "^7.18.6",
+ "@babel/plugin-transform-spread": "^7.19.0",
+ "@babel/plugin-transform-sticky-regex": "^7.18.6",
+ "@babel/plugin-transform-template-literals": "^7.18.9",
+ "@babel/plugin-transform-typeof-symbol": "^7.18.9",
+ "@babel/plugin-transform-unicode-escapes": "^7.18.10",
+ "@babel/plugin-transform-unicode-regex": "^7.18.6",
+ "@babel/preset-modules": "^0.1.5",
+ "@babel/types": "^7.19.3",
+ "babel-plugin-polyfill-corejs2": "^0.3.3",
+ "babel-plugin-polyfill-corejs3": "^0.6.0",
+ "babel-plugin-polyfill-regenerator": "^0.4.1",
+ "core-js-compat": "^3.25.1",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-modules": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz",
+ "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/plugin-proposal-unicode-property-regex": "^7.4.4",
+ "@babel/plugin-transform-dotall-regex": "^7.4.4",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-react": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz",
+ "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-transform-react-display-name": "^7.18.6",
+ "@babel/plugin-transform-react-jsx": "^7.18.6",
+ "@babel/plugin-transform-react-jsx-development": "^7.18.6",
+ "@babel/plugin-transform-react-pure-annotations": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-typescript": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz",
+ "integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-transform-typescript": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/register": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.18.9.tgz",
+ "integrity": "sha512-ZlbnXDcNYHMR25ITwwNKT88JiaukkdVj/nG7r3wnuXkOTHc60Uy05PwMCPre0hSkY68E6zK3xz+vUJSP2jWmcw==",
+ "dev": true,
+ "dependencies": {
+ "clone-deep": "^4.0.1",
+ "find-cache-dir": "^2.0.0",
+ "make-dir": "^2.1.0",
+ "pirates": "^4.0.5",
+ "source-map-support": "^0.5.16"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
+ "integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
+ "dev": true,
+ "dependencies": {
+ "regenerator-runtime": "^0.13.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
+ "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.18.6",
+ "@babel/parser": "^7.18.10",
+ "@babel/types": "^7.18.10"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.3.tgz",
+ "integrity": "sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.18.6",
+ "@babel/generator": "^7.19.3",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-hoist-variables": "^7.18.6",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/parser": "^7.19.3",
+ "@babel/types": "^7.19.3",
+ "debug": "^4.1.0",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.3.tgz",
+ "integrity": "sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.18.10",
+ "@babel/helper-validator-identifier": "^7.19.1",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@cnakazawa/watch": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz",
+ "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==",
+ "dev": true,
+ "dependencies": {
+ "exec-sh": "^0.3.2",
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "watch": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.1.95"
+ }
+ },
+ "node_modules/@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
+ "node_modules/@discoveryjs/json-ext": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
+ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@fluent/bundle": {
+ "version": "0.17.1",
+ "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.17.1.tgz",
+ "integrity": "sha512-CRFNT9QcSFAeFDneTF59eyv3JXFGhIIN4boUO2y22YmsuuKLyDk+N1I/NQUYz9Ab63e6V7T6vItoZIG/2oOOuw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/@fluent/dom": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@fluent/dom/-/dom-0.8.1.tgz",
+ "integrity": "sha512-wlQ3vHgioDL8dC0wcZ9AyCSpOgor0OREKXJMvvnx6bzk/PT2SZNA5frslmSdbEaiBQIVy2MhVvAIDtbKbdoVCg==",
+ "dev": true,
+ "dependencies": {
+ "cached-iterable": "^0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/@gar/promisify": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
+ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
+ "dev": true
+ },
+ "node_modules/@istanbuljs/load-nyc-config": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "find-up": "^4.1.0",
+ "get-package-type": "^0.1.0",
+ "js-yaml": "^3.13.1",
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/transform": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz",
+ "integrity": "sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.1.0",
+ "@jest/types": "^26.6.2",
+ "babel-plugin-istanbul": "^6.0.0",
+ "chalk": "^4.0.0",
+ "convert-source-map": "^1.4.0",
+ "fast-json-stable-stringify": "^2.0.0",
+ "graceful-fs": "^4.2.4",
+ "jest-haste-map": "^26.6.2",
+ "jest-regex-util": "^26.0.0",
+ "jest-util": "^26.6.2",
+ "micromatch": "^4.0.2",
+ "pirates": "^4.0.1",
+ "slash": "^3.0.0",
+ "source-map": "^0.6.1",
+ "write-file-atomic": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@jest/transform/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/types": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
+ "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^15.0.0",
+ "chalk": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/@jest/types/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@jest/types/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@jest/types/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@jest/types/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@jest/types/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/types/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+ "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.0",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+ "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+ "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ },
+ "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+ "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.4.14",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz",
+ "integrity": "sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@lit/reactive-element": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.4.1.tgz",
+ "integrity": "sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==",
+ "dev": true
+ },
+ "node_modules/@mdx-js/mdx": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz",
+ "integrity": "sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "7.12.9",
+ "@babel/plugin-syntax-jsx": "7.12.1",
+ "@babel/plugin-syntax-object-rest-spread": "7.8.3",
+ "@mdx-js/util": "1.6.22",
+ "babel-plugin-apply-mdx-type-prop": "1.6.22",
+ "babel-plugin-extract-import-names": "1.6.22",
+ "camelcase-css": "2.0.1",
+ "detab": "2.0.4",
+ "hast-util-raw": "6.0.1",
+ "lodash.uniq": "4.5.0",
+ "mdast-util-to-hast": "10.0.1",
+ "remark-footnotes": "2.0.0",
+ "remark-mdx": "1.6.22",
+ "remark-parse": "8.0.3",
+ "remark-squeeze-paragraphs": "4.0.0",
+ "style-to-object": "0.3.0",
+ "unified": "9.2.0",
+ "unist-builder": "2.0.3",
+ "unist-util-visit": "2.0.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/@mdx-js/mdx/node_modules/@babel/core": {
+ "version": "7.12.9",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz",
+ "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/generator": "^7.12.5",
+ "@babel/helper-module-transforms": "^7.12.1",
+ "@babel/helpers": "^7.12.5",
+ "@babel/parser": "^7.12.7",
+ "@babel/template": "^7.12.7",
+ "@babel/traverse": "^7.12.9",
+ "@babel/types": "^7.12.7",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.1",
+ "json5": "^2.1.2",
+ "lodash": "^4.17.19",
+ "resolve": "^1.3.2",
+ "semver": "^5.4.1",
+ "source-map": "^0.5.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@mdx-js/mdx/node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz",
+ "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@mdx-js/mdx/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/@mdx-js/mdx/node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@mdx-js/util": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz",
+ "integrity": "sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/@mrmlnc/readdir-enhanced": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
+ "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==",
+ "dev": true,
+ "dependencies": {
+ "call-me-maybe": "^1.0.1",
+ "glob-to-regexp": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@mrmlnc/readdir-enhanced/node_modules/glob-to-regexp": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz",
+ "integrity": "sha512-Iozmtbqv0noj0uDDqoL0zNq0VBEfK2YFoMAZoxJe4cwphvLR+JskfF30QhXHOR4m3KrE6NLRYw+U9MRXvifyig==",
+ "dev": true
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
+ "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==",
+ "dev": true,
+ "dependencies": {
+ "@gar/promisify": "^1.0.1",
+ "semver": "^7.3.5"
+ }
+ },
+ "node_modules/@npmcli/fs/node_modules/semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@npmcli/move-file": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
+ "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==",
+ "dev": true,
+ "dependencies": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@storybook/addon-actions": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.5.12.tgz",
+ "integrity": "sha512-yEbyKjBsSRUr61SlS+SOTqQwdumO8Wa3GoHO3AfmvoKfzdGrM7w8G5Zs9Iev16khWg/7bQvoH3KZsg/hQuKnNg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "polished": "^4.2.2",
+ "prop-types": "^15.7.2",
+ "react-inspector": "^5.1.0",
+ "regenerator-runtime": "^0.13.7",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "uuid-browser": "^3.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-actions/node_modules/react-inspector": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz",
+ "integrity": "sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.0.0",
+ "is-dom": "^1.0.0",
+ "prop-types": "^15.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.4 || ^17.0.0"
+ }
+ },
+ "node_modules/@storybook/addon-backgrounds": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-6.5.12.tgz",
+ "integrity": "sha512-S0QThY1jnU7Q+HY+g9JgpAJszzNmNkigZ4+X/4qlUXE0WYYn9i2YG5H6me1+57QmIXYddcWWqqgF9HUXl667NA==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-controls": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-6.5.12.tgz",
+ "integrity": "sha512-UoaamkGgAQXplr0kixkPhROdzkY+ZJQpG7VFDU6kmZsIgPRNfX/QoJFR5vV6TpDArBIjWaUUqWII+GHgPRzLgQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "lodash": "^4.17.21",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-docs": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-6.5.12.tgz",
+ "integrity": "sha512-T+QTkmF7QlMVfXHXEberP8CYti/XMTo9oi6VEbZLx+a2N3qY4GZl7X2g26Sf5V4Za+xnapYKBMEIiJ5SvH9weQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.12.12",
+ "@babel/preset-env": "^7.12.11",
+ "@jest/transform": "^26.6.2",
+ "@mdx-js/react": "^1.6.22",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/docs-tools": "6.5.12",
+ "@storybook/mdx1-csf": "^0.0.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/postinstall": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/source-loader": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "babel-loader": "^8.0.0",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "regenerator-runtime": "^0.13.7",
+ "remark-external-links": "^8.0.0",
+ "remark-slug": "^6.0.0",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "@storybook/mdx2-csf": "^0.0.3",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/mdx2-csf": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-docs/node_modules/@mdx-js/react": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz",
+ "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0"
+ }
+ },
+ "node_modules/@storybook/addon-essentials": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-6.5.12.tgz",
+ "integrity": "sha512-4AAV0/mQPSk3V0Pie1NIqqgBgScUc0VtBEXDm8BgPeuDNVhPEupnaZgVt+I3GkzzPPo6JjdCsp2L11f3bBSEjw==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addon-actions": "6.5.12",
+ "@storybook/addon-backgrounds": "6.5.12",
+ "@storybook/addon-controls": "6.5.12",
+ "@storybook/addon-docs": "6.5.12",
+ "@storybook/addon-measure": "6.5.12",
+ "@storybook/addon-outline": "6.5.12",
+ "@storybook/addon-toolbars": "6.5.12",
+ "@storybook/addon-viewport": "6.5.12",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.9.6"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/angular": {
+ "optional": true
+ },
+ "@storybook/builder-manager4": {
+ "optional": true
+ },
+ "@storybook/builder-manager5": {
+ "optional": true
+ },
+ "@storybook/builder-webpack4": {
+ "optional": true
+ },
+ "@storybook/builder-webpack5": {
+ "optional": true
+ },
+ "@storybook/html": {
+ "optional": true
+ },
+ "@storybook/vue": {
+ "optional": true
+ },
+ "@storybook/vue3": {
+ "optional": true
+ },
+ "@storybook/web-components": {
+ "optional": true
+ },
+ "lit": {
+ "optional": true
+ },
+ "lit-html": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
+ },
+ "sveltedoc-parser": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ },
+ "webpack": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-links": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.5.12.tgz",
+ "integrity": "sha512-Dyt922J5nTBwM/9KtuuDIt3sX8xdTkKh+aXSoOX6OzT04Xwm5NumFOvuQ2YA00EM+3Ihn7Ayc3urvxnHTixmKg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@types/qs": "^6.9.5",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "prop-types": "^15.7.2",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-measure": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-6.5.12.tgz",
+ "integrity": "sha512-zmolO6+VG4ov2620G7f1myqLQLztfU+ykN+U5y52GXMFsCOyB7fMoVWIMrZwsNlinDu+CnUvelXHUNbqqnjPRg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-outline": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-6.5.12.tgz",
+ "integrity": "sha512-jXwLz2rF/CZt6Cgy+QUTa+pNW0IevSONYwS3D533E9z5h0T5ZKJbbxG5jxM+oC+FpZ/nFk5mEmUaYNkxgIVdpw==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-toolbars": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-6.5.12.tgz",
+ "integrity": "sha512-+QjoEHkekz4wTy8zqxYdV9ijDJ5YcjDc/qdnV8wx22zkoVU93FQlo0CHHVjpyvc3ilQliZbdQDJx62BcHXw30Q==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-viewport": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-6.5.12.tgz",
+ "integrity": "sha512-eQ1UrmbiMiPmWe+fdMWIc0F6brh/S2z4ADfwFz0tTd+vOLWRZp1xw8JYQ9P2ZasE+PM3WFOVT9jvNjZj/cHnfw==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "memoizerific": "^1.11.3",
+ "prop-types": "^15.7.2",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addons": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.5.12.tgz",
+ "integrity": "sha512-y3cgxZq41YGnuIlBJEuJjSFdMsm8wnvlNOGUP9Q+Er2dgfx8rJz4Q22o4hPjpvpaj4XdBtxCJXI2NeFpN59+Cw==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/api": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@types/webpack-env": "^1.16.0",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/api": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.5.12.tgz",
+ "integrity": "sha512-DuUZmMlQxkFNU9Vgkp9aNfCkAongU76VVmygvCuSpMVDI9HQ2lG0ydL+ppL4XKoSMCCoXTY6+rg4hJANnH+1AQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "store2": "^2.12.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.5.12.tgz",
+ "integrity": "sha512-TsthT5jm9ZxQPNOZJbF5AV24me3i+jjYD7gbdKdSHrOVn1r3ydX4Z8aD6+BjLCtTn3T+e8NMvUkL4dInEo1x6g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/webpack": "^4.41.26",
+ "autoprefixer": "^9.8.6",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^3.6.0",
+ "file-loader": "^6.2.0",
+ "find-up": "^5.0.0",
+ "fork-ts-checker-webpack-plugin": "^4.1.6",
+ "glob": "^7.1.6",
+ "glob-promise": "^3.4.0",
+ "global": "^4.4.0",
+ "html-webpack-plugin": "^4.0.0",
+ "pnp-webpack-plugin": "1.6.4",
+ "postcss": "^7.0.36",
+ "postcss-flexbugs-fixes": "^4.2.1",
+ "postcss-loader": "^4.2.0",
+ "raw-loader": "^4.0.2",
+ "stable": "^0.1.8",
+ "style-loader": "^1.3.0",
+ "terser-webpack-plugin": "^4.2.3",
+ "ts-dedent": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4",
+ "webpack-dev-middleware": "^3.7.3",
+ "webpack-filter-warnings-plugin": "^1.2.1",
+ "webpack-hot-middleware": "^2.25.1",
+ "webpack-virtual-modules": "^0.2.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@types/html-minifier-terser": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
+ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/clean-css": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+ "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "~0.6.0"
+ },
+ "engines": {
+ "node": ">= 4.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/css-loader": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
+ "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "cssesc": "^3.0.0",
+ "icss-utils": "^4.1.1",
+ "loader-utils": "^1.2.3",
+ "normalize-path": "^3.0.0",
+ "postcss": "^7.0.32",
+ "postcss-modules-extract-imports": "^2.0.0",
+ "postcss-modules-local-by-default": "^3.0.2",
+ "postcss-modules-scope": "^2.2.0",
+ "postcss-modules-values": "^3.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^2.7.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/css-loader/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/enhanced-resolve/node_modules/memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/fork-ts-checker-webpack-plugin": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz",
+ "integrity": "sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.5.5",
+ "chalk": "^2.4.1",
+ "micromatch": "^3.1.10",
+ "minimatch": "^3.0.4",
+ "semver": "^5.6.0",
+ "tapable": "^1.0.0",
+ "worker-rpc": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6.11.5",
+ "yarn": ">=1.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-minifier-terser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+ "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
+ "dev": true,
+ "dependencies": {
+ "camel-case": "^4.1.1",
+ "clean-css": "^4.2.3",
+ "commander": "^4.1.1",
+ "he": "^1.2.0",
+ "param-case": "^3.0.3",
+ "relateurl": "^0.2.7",
+ "terser": "^4.6.3"
+ },
+ "bin": {
+ "html-minifier-terser": "cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-minifier-terser/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-webpack-plugin": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz",
+ "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==",
+ "dev": true,
+ "dependencies": {
+ "@types/html-minifier-terser": "^5.0.0",
+ "@types/tapable": "^1.0.5",
+ "@types/webpack": "^4.41.8",
+ "html-minifier-terser": "^5.0.1",
+ "loader-utils": "^1.2.3",
+ "lodash": "^4.17.20",
+ "pretty-error": "^2.1.1",
+ "tapable": "^1.1.3",
+ "util.promisify": "1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-webpack-plugin/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true,
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/p-locate/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/pkg-dir/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/pretty-error": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz",
+ "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.20",
+ "renderkid": "^2.0.4"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/renderkid": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz",
+ "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==",
+ "dev": true,
+ "dependencies": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^3.0.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/serialize-javascript": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
+ "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "dependencies": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/style-loader": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz",
+ "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/terser-webpack-plugin": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz",
+ "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^15.0.5",
+ "find-cache-dir": "^3.3.1",
+ "jest-worker": "^26.5.0",
+ "p-limit": "^3.0.2",
+ "schema-utils": "^3.0.0",
+ "serialize-javascript": "^5.0.1",
+ "source-map": "^0.6.1",
+ "terser": "^5.3.4",
+ "webpack-sources": "^1.4.3"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/terser-webpack-plugin/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0"
+ },
+ "optionalDependencies": {
+ "chokidar": "^3.4.1",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=6.11.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ },
+ "webpack-command": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack-dev-middleware": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz",
+ "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==",
+ "dev": true,
+ "dependencies": {
+ "memory-fs": "^0.4.1",
+ "mime": "^2.4.4",
+ "mkdirp": "^0.5.1",
+ "range-parser": "^1.2.1",
+ "webpack-log": "^2.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack-filter-warnings-plugin": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz",
+ "integrity": "sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4.3 < 5.0.0 || >= 5.10"
+ },
+ "peerDependencies": {
+ "webpack": "^2.0.0 || ^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "dependencies": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack-virtual-modules": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz",
+ "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^3.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ },
+ "engines": {
+ "node": ">= 6.9.0"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack5": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-6.5.12.tgz",
+ "integrity": "sha512-jK5jWxhSbMAM/onPB6WN7xVqwZnAmzJljOG24InO/YIjW8pQof7MeAXCYBM4rYM+BbK61gkZ/RKxwlkqXBWv+Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-named-exports-order": "^0.0.2",
+ "browser-assert": "^1.2.1",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^5.0.1",
+ "fork-ts-checker-webpack-plugin": "^6.0.4",
+ "glob": "^7.1.6",
+ "glob-promise": "^3.4.0",
+ "html-webpack-plugin": "^5.0.0",
+ "path-browserify": "^1.0.1",
+ "process": "^0.11.10",
+ "stable": "^0.1.8",
+ "style-loader": "^2.0.0",
+ "terser-webpack-plugin": "^5.0.3",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "^5.9.0",
+ "webpack-dev-middleware": "^4.1.0",
+ "webpack-hot-middleware": "^2.25.1",
+ "webpack-virtual-modules": "^0.4.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/builder-webpack5/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/channel-postmessage": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.5.12.tgz",
+ "integrity": "sha512-SL/tJBLOdDlbUAAxhiZWOEYd5HI4y8rN50r6jeed5nD8PlocZjxJ6mO0IxnePqIL9Yu3nSrQRHrtp8AJvPX0Yg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "qs": "^6.10.0",
+ "telejson": "^6.0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/channel-websocket": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channel-websocket/-/channel-websocket-6.5.12.tgz",
+ "integrity": "sha512-0t5dLselHVKTRYaphxx1dRh4pmOFCfR7h8oNJlOvJ29Qy5eNyVujDG9nhwWbqU6IKayuP4nZrAbe9Req9YZYlQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "telejson": "^6.0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/channels": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-6.5.12.tgz",
+ "integrity": "sha512-X5XaKbe4b7LXJ4sUakBo00x6pXnW78JkOonHoaKoWsccHLlEzwfBZpVVekhVZnqtCoLT23dB8wjKgA71RYWoiw==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.8.2",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/client-api": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.5.12.tgz",
+ "integrity": "sha512-+JiRSgiU829KPc25nG/k0+Ao2nUelHUe8Y/9cRoKWbCAGzi4xd0JLhHAOr9Oi2szWx/OI1L08lxVv1+WTveAeA==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "@types/qs": "^6.9.5",
+ "@types/webpack-env": "^1.16.0",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "store2": "^2.12.0",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/client-logger": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.5.12.tgz",
+ "integrity": "sha512-IrkMr5KZcudX935/C2balFbxLHhkvQnJ78rbVThHDVckQ7l3oIXTh66IMzldeOabVFDZEMiW8AWuGEYof+JtLw==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.8.2",
+ "global": "^4.4.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/components": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.5.12.tgz",
+ "integrity": "sha512-NAAGl5PDXaHdVLd6hA+ttmLwH3zAVGXeUmEubzKZ9bJzb+duhFKxDa9blM4YEkI+palumvgAMm0UgS7ou680Ig==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/core": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core/-/core-6.5.12.tgz",
+ "integrity": "sha512-+o3psAVWL+5LSwyJmEbvhgxKO1Et5uOX8ujNVt/f1fgwJBIf6BypxyPKu9YGQDRzcRssESQQZWNrZCCAZlFeuQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-server": "6.5.12"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "webpack": "*"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/builder-webpack5": {
+ "optional": true
+ },
+ "@storybook/manager-webpack5": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-client": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.5.12.tgz",
+ "integrity": "sha512-jyAd0ud6zO+flpLv0lEHbbt1Bv9Ms225M6WTQLrfe7kN/7j1pVKZEoeVCLZwkJUtSKcNiWQxZbS15h31pcYwqg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channel-websocket": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "airbnb-js-shims": "^2.2.1",
+ "ansi-to-html": "^0.6.11",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0",
+ "unfetch": "^4.2.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "webpack": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-common": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-6.5.12.tgz",
+ "integrity": "sha512-gG20+eYdIhwQNu6Xs805FLrOCWtkoc8Rt8gJiRt8yXzZh9EZkU4xgCRoCxrrJ03ys/gTiCFbBOfRi749uM3z4w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-proposal-class-properties": "^7.12.1",
+ "@babel/plugin-proposal-decorators": "^7.12.12",
+ "@babel/plugin-proposal-export-default-from": "^7.12.1",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
+ "@babel/plugin-proposal-object-rest-spread": "^7.12.1",
+ "@babel/plugin-proposal-optional-chaining": "^7.12.7",
+ "@babel/plugin-proposal-private-methods": "^7.12.1",
+ "@babel/plugin-proposal-private-property-in-object": "^7.12.1",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-transform-arrow-functions": "^7.12.1",
+ "@babel/plugin-transform-block-scoping": "^7.12.12",
+ "@babel/plugin-transform-classes": "^7.12.1",
+ "@babel/plugin-transform-destructuring": "^7.12.1",
+ "@babel/plugin-transform-for-of": "^7.12.1",
+ "@babel/plugin-transform-parameters": "^7.12.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.12.1",
+ "@babel/plugin-transform-spread": "^7.12.1",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/preset-react": "^7.12.10",
+ "@babel/preset-typescript": "^7.12.7",
+ "@babel/register": "^7.12.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/pretty-hrtime": "^1.0.0",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-macros": "^3.0.1",
+ "babel-plugin-polyfill-corejs3": "^0.1.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "express": "^4.17.1",
+ "file-system-cache": "^1.0.5",
+ "find-up": "^5.0.0",
+ "fork-ts-checker-webpack-plugin": "^6.0.4",
+ "fs-extra": "^9.0.1",
+ "glob": "^7.1.6",
+ "handlebars": "^4.7.7",
+ "interpret": "^2.2.0",
+ "json5": "^2.1.3",
+ "lazy-universal-dotenv": "^3.0.1",
+ "picomatch": "^2.3.0",
+ "pkg-dir": "^5.0.0",
+ "pretty-hrtime": "^1.0.3",
+ "resolve-from": "^5.0.0",
+ "slash": "^3.0.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz",
+ "integrity": "sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.13.0",
+ "@babel/helper-module-imports": "^7.12.13",
+ "@babel/helper-plugin-utils": "^7.13.0",
+ "@babel/traverse": "^7.13.0",
+ "debug": "^4.1.1",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.14.2",
+ "semver": "^6.1.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0-0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz",
+ "integrity": "sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.1.5",
+ "core-js-compat": "^3.8.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/enhanced-resolve/node_modules/memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/loader-utils/node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "dependencies": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ },
+ "engines": {
+ "node": ">= 6.9.0"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0"
+ },
+ "optionalDependencies": {
+ "chokidar": "^3.4.1",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=6.11.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ },
+ "webpack-command": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "dependencies": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-events": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.5.12.tgz",
+ "integrity": "sha512-0AMyMM19R/lHsYRfWqM8zZTXthasTAK2ExkSRzYi2GkIaVMxRKtM33YRwxKIpJ6KmIKIs8Ru3QCXu1mfCmGzNg==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.8.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/core-server": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.5.12.tgz",
+ "integrity": "sha512-q1b/XKwoLUcCoCQ+8ndPD5THkEwXZYJ9ROv16i2VGUjjjAuSqpEYBq5GMGQUgxlWp1bkxtdGL2Jz+6pZfvldzA==",
+ "dev": true,
+ "dependencies": {
+ "@discoveryjs/json-ext": "^0.5.3",
+ "@storybook/builder-webpack4": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/csf-tools": "6.5.12",
+ "@storybook/manager-webpack4": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/telemetry": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/node-fetch": "^2.5.7",
+ "@types/pretty-hrtime": "^1.0.0",
+ "@types/webpack": "^4.41.26",
+ "better-opn": "^2.1.1",
+ "boxen": "^5.1.2",
+ "chalk": "^4.1.0",
+ "cli-table3": "^0.6.1",
+ "commander": "^6.2.1",
+ "compression": "^1.7.4",
+ "core-js": "^3.8.2",
+ "cpy": "^8.1.2",
+ "detect-port": "^1.3.0",
+ "express": "^4.17.1",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "globby": "^11.0.2",
+ "ip": "^2.0.0",
+ "lodash": "^4.17.21",
+ "node-fetch": "^2.6.7",
+ "open": "^8.4.0",
+ "pretty-hrtime": "^1.0.3",
+ "prompts": "^2.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "serve-favicon": "^2.5.0",
+ "slash": "^3.0.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "watchpack": "^2.2.0",
+ "webpack": "4",
+ "ws": "^8.2.3",
+ "x-default-browser": "^0.4.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/builder-webpack5": {
+ "optional": true
+ },
+ "@storybook/manager-webpack5": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/enhanced-resolve/node_modules/memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "dependencies": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ },
+ "engines": {
+ "node": ">= 6.9.0"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=6.11.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ },
+ "webpack-command": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "dependencies": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/webpack/node_modules/watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0"
+ },
+ "optionalDependencies": {
+ "chokidar": "^3.4.1",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/@storybook/csf": {
+ "version": "0.0.2--canary.4566f4d.1",
+ "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.4566f4d.1.tgz",
+ "integrity": "sha512-9OVvMVh3t9znYZwb0Svf/YQoxX2gVOeQTGe2bses2yj+a3+OJnCrUF3/hGv6Em7KujtOdL2LL+JnG49oMVGFgQ==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.15"
+ }
+ },
+ "node_modules/@storybook/csf-tools": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-6.5.12.tgz",
+ "integrity": "sha512-BPhnB1xJtBVOzXuCURzQRdXcstE27ht4qoTgQkbwUTy4MEtUZ/f1AnHSYRdzrgukXdUFWseNIK4RkNdJpfOfNQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/generator": "^7.12.11",
+ "@babel/parser": "^7.12.11",
+ "@babel/plugin-transform-react-jsx": "^7.12.12",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/traverse": "^7.12.11",
+ "@babel/types": "^7.12.11",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/mdx1-csf": "^0.0.1",
+ "core-js": "^3.8.2",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "@storybook/mdx2-csf": "^0.0.3"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/mdx2-csf": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/docs-tools": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/docs-tools/-/docs-tools-6.5.12.tgz",
+ "integrity": "sha512-8brf8W89KVk95flVqW0sYEqkL+FBwb5W9CnwI+Ggd6r2cqXe9jyg+0vDZFdYp6kYNQKrPr4fbXGrGVXQG18/QQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "core-js": "^3.8.2",
+ "doctrine": "^3.0.0",
+ "lodash": "^4.17.21",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.5.12.tgz",
+ "integrity": "sha512-LH3e6qfvq2znEdxe2kaWtmdDPTnvSkufzoC9iwOgNvo3YrTGrYNyUTDegvW293TOTVfUn7j6TBcsOxIgRnt28g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-transform-template-literals": "^7.12.1",
+ "@babel/preset-react": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/webpack": "^4.41.26",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^3.6.0",
+ "express": "^4.17.1",
+ "file-loader": "^6.2.0",
+ "find-up": "^5.0.0",
+ "fs-extra": "^9.0.1",
+ "html-webpack-plugin": "^4.0.0",
+ "node-fetch": "^2.6.7",
+ "pnp-webpack-plugin": "1.6.4",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0",
+ "style-loader": "^1.3.0",
+ "telejson": "^6.0.8",
+ "terser-webpack-plugin": "^4.2.3",
+ "ts-dedent": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4",
+ "webpack-dev-middleware": "^3.7.3",
+ "webpack-virtual-modules": "^0.2.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@types/html-minifier-terser": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
+ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/clean-css": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+ "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "~0.6.0"
+ },
+ "engines": {
+ "node": ">= 4.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/css-loader": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
+ "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "cssesc": "^3.0.0",
+ "icss-utils": "^4.1.1",
+ "loader-utils": "^1.2.3",
+ "normalize-path": "^3.0.0",
+ "postcss": "^7.0.32",
+ "postcss-modules-extract-imports": "^2.0.0",
+ "postcss-modules-local-by-default": "^3.0.2",
+ "postcss-modules-scope": "^2.2.0",
+ "postcss-modules-values": "^3.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^2.7.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/css-loader/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/enhanced-resolve/node_modules/memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-minifier-terser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+ "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
+ "dev": true,
+ "dependencies": {
+ "camel-case": "^4.1.1",
+ "clean-css": "^4.2.3",
+ "commander": "^4.1.1",
+ "he": "^1.2.0",
+ "param-case": "^3.0.3",
+ "relateurl": "^0.2.7",
+ "terser": "^4.6.3"
+ },
+ "bin": {
+ "html-minifier-terser": "cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-minifier-terser/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-webpack-plugin": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz",
+ "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==",
+ "dev": true,
+ "dependencies": {
+ "@types/html-minifier-terser": "^5.0.0",
+ "@types/tapable": "^1.0.5",
+ "@types/webpack": "^4.41.8",
+ "html-minifier-terser": "^5.0.1",
+ "loader-utils": "^1.2.3",
+ "lodash": "^4.17.20",
+ "pretty-error": "^2.1.1",
+ "tapable": "^1.1.3",
+ "util.promisify": "1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-webpack-plugin/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true,
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/p-locate/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/pkg-dir/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/pretty-error": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz",
+ "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.20",
+ "renderkid": "^2.0.4"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/renderkid": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz",
+ "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==",
+ "dev": true,
+ "dependencies": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^3.0.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/serialize-javascript": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
+ "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "dependencies": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/style-loader": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz",
+ "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/terser-webpack-plugin": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz",
+ "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^15.0.5",
+ "find-cache-dir": "^3.3.1",
+ "jest-worker": "^26.5.0",
+ "p-limit": "^3.0.2",
+ "schema-utils": "^3.0.0",
+ "serialize-javascript": "^5.0.1",
+ "source-map": "^0.6.1",
+ "terser": "^5.3.4",
+ "webpack-sources": "^1.4.3"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/terser-webpack-plugin/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0"
+ },
+ "optionalDependencies": {
+ "chokidar": "^3.4.1",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=6.11.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ },
+ "webpack-command": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack-dev-middleware": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz",
+ "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==",
+ "dev": true,
+ "dependencies": {
+ "memory-fs": "^0.4.1",
+ "mime": "^2.4.4",
+ "mkdirp": "^0.5.1",
+ "range-parser": "^1.2.1",
+ "webpack-log": "^2.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "dependencies": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack-virtual-modules": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz",
+ "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^3.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ },
+ "engines": {
+ "node": ">= 6.9.0"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack5": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-webpack5/-/manager-webpack5-6.5.12.tgz",
+ "integrity": "sha512-F+KgoINhfo1ArbirCc9L+EyADYD8Z4t0LyZYDVcBiZ8DlRIMIoUSye6tDsnyEm+OPloLVAcGwRMYgFhuHB70Lg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-transform-template-literals": "^7.12.1",
+ "@babel/preset-react": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^5.0.1",
+ "express": "^4.17.1",
+ "find-up": "^5.0.0",
+ "fs-extra": "^9.0.1",
+ "html-webpack-plugin": "^5.0.0",
+ "node-fetch": "^2.6.7",
+ "process": "^0.11.10",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0",
+ "style-loader": "^2.0.0",
+ "telejson": "^6.0.8",
+ "terser-webpack-plugin": "^5.0.3",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "^5.9.0",
+ "webpack-dev-middleware": "^4.1.0",
+ "webpack-virtual-modules": "^0.4.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/mdx1-csf": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@storybook/mdx1-csf/-/mdx1-csf-0.0.1.tgz",
+ "integrity": "sha512-4biZIWWzoWlCarMZmTpqcJNgo/RBesYZwGFbQeXiGYsswuvfWARZnW9RE9aUEMZ4XPn7B1N3EKkWcdcWe/K2tg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/generator": "^7.12.11",
+ "@babel/parser": "^7.12.11",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/types": "^7.12.11",
+ "@mdx-js/mdx": "^1.6.22",
+ "@types/lodash": "^4.14.167",
+ "js-string-escape": "^1.0.1",
+ "loader-utils": "^2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": ">=2.2.1 <=2.3.0",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "node_modules/@storybook/node-logger": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.5.12.tgz",
+ "integrity": "sha512-jdLtT3mX5GQKa+0LuX0q0sprKxtCGf6HdXlKZGD5FEuz4MgJUGaaiN0Hgi+U7Z4tVNOtSoIbYBYXHqfUgJrVZw==",
+ "dev": true,
+ "dependencies": {
+ "@types/npmlog": "^4.1.2",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "npmlog": "^5.0.1",
+ "pretty-hrtime": "^1.0.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/node-logger/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/postinstall": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/postinstall/-/postinstall-6.5.12.tgz",
+ "integrity": "sha512-6K73f9c2UO+w4Wtyo2BxEpEsnhPvMgqHSaJ9Yt6Tc90LaDGUbcVgy6PNibsRyuJ/KQ543WeiRO5rSZfm2uJU9A==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.8.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/preview-web": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/preview-web/-/preview-web-6.5.12.tgz",
+ "integrity": "sha512-Q5mduCJsY9zhmlsrhHvtOBA3Jt2n45bhfVkiUEqtj8fDit45/GW+eLoffv8GaVTGjV96/Y1JFwDZUwU6mEfgGQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "ansi-to-html": "^0.6.11",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "unfetch": "^4.2.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/router": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.5.12.tgz",
+ "integrity": "sha512-xHubde9YnBbpkDY5+zGO4Pr6VPxP8H9J2v4OTF3H82uaxCIKR0PKG0utS9pFKIsEiP3aM62Hb9qB8nU+v1nj3w==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/semver": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz",
+ "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.6.5",
+ "find-up": "^4.1.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@storybook/semver/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/semver/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/semver/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/semver/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/source-loader": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.5.12.tgz",
+ "integrity": "sha512-4iuILFsKNV70sEyjzIkOqgzgQx7CJ8kTEFz590vkmWXQNKz7YQzjgISIwL7GBw/myJgeb04bl5psVgY0cbG5vg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "estraverse": "^5.2.0",
+ "global": "^4.4.0",
+ "loader-utils": "^2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": ">=2.2.1 <=2.3.0",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/store": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/store/-/store-6.5.12.tgz",
+ "integrity": "sha512-SMQOr0XvV0mhTuqj3XOwGGc4kTPVjh3xqrG1fqkj9RGs+2jRdmO6mnwzda5gPwUmWNTorZ7FxZ1iEoyfYNtuiQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "slash": "^3.0.0",
+ "stable": "^0.1.8",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/telemetry": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/telemetry/-/telemetry-6.5.12.tgz",
+ "integrity": "sha512-mCHxx7NmQ3n7gx0nmblNlZE5ZgrjQm6B08mYeWg6Y7r4GZnqS6wZbvAwVhZZ3Gg/9fdqaBApHsdAXp0d5BrlxA==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "detect-package-manager": "^2.0.1",
+ "fetch-retry": "^5.0.2",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "isomorphic-unfetch": "^3.1.0",
+ "nanoid": "^3.3.1",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/telemetry/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/theming": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.5.12.tgz",
+ "integrity": "sha512-uWOo84qMQ2R6c1C0faZ4Q0nY01uNaX7nXoJKieoiJ6ZqY9PSYxJl1kZLi3uPYnrxLZjzjVyXX8MgdxzbppYItA==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/ui": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/ui/-/ui-6.5.12.tgz",
+ "integrity": "sha512-P7+ARI5NvaEYkrbIciT/UMgy3kxMt4WCtHMXss2T01UMCIWh1Ws4BJaDNqtQSpKuwjjS4eqZL3aQWhlUpYAUEg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/web-components": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-6.5.12.tgz",
+ "integrity": "sha512-SkaLdaCYNXiKKtXoDcZ7sDvONkIB/NNfe39Kkijm/sotymi+7iDzbywwyAZY6tFxSy9DUkVZWQgexpmFpogWkw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-import-meta": "^7.10.4",
+ "@babel/preset-env": "^7.12.11",
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/docs-tools": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@types/node": "^14.14.20 || ^16.0.0",
+ "@types/webpack-env": "^1.16.0",
+ "babel-plugin-bundled-import-meta": "^0.3.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "react": "16.14.0",
+ "react-dom": "16.14.0",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "bin": {
+ "build-storybook": "bin/build.js",
+ "start-storybook": "bin/index.js",
+ "storybook-server": "bin/index.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "lit-html": "^1.4.1 || ^2.0.0"
+ }
+ },
+ "node_modules/@storybook/web-components/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/web-components/node_modules/react": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
+ "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/web-components/node_modules/react-dom": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
+ "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2",
+ "scheduler": "^0.19.1"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0"
+ }
+ },
+ "node_modules/@storybook/web-components/node_modules/scheduler": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
+ "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1"
+ }
+ },
+ "node_modules/@types/eslint": {
+ "version": "8.4.6",
+ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz",
+ "integrity": "sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "*",
+ "@types/json-schema": "*"
+ }
+ },
+ "node_modules/@types/eslint-scope": {
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz",
+ "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==",
+ "dev": true,
+ "dependencies": {
+ "@types/eslint": "*",
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "0.0.51",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
+ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
+ "dev": true
+ },
+ "node_modules/@types/glob": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.0.0.tgz",
+ "integrity": "sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==",
+ "dev": true,
+ "dependencies": {
+ "@types/minimatch": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/graceful-fs": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
+ "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz",
+ "integrity": "sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/html-minifier-terser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
+ "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==",
+ "dev": true
+ },
+ "node_modules/@types/is-function": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.1.tgz",
+ "integrity": "sha512-A79HEEiwXTFtfY+Bcbo58M2GRYzCr9itHWzbzHVFNEYCcoU/MMGwYYf721gBrnhpj1s6RGVVha/IgNFnR0Iw/Q==",
+ "dev": true
+ },
+ "node_modules/@types/istanbul-lib-coverage": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
+ "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==",
+ "dev": true
+ },
+ "node_modules/@types/istanbul-lib-report": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+ "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
+ "dev": true,
+ "dependencies": {
+ "@types/istanbul-lib-coverage": "*"
+ }
+ },
+ "node_modules/@types/istanbul-reports": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz",
+ "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==",
+ "dev": true,
+ "dependencies": {
+ "@types/istanbul-lib-report": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
+ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
+ "dev": true
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.14.186",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz",
+ "integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==",
+ "dev": true
+ },
+ "node_modules/@types/mdast": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
+ "integrity": "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/minimatch": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
+ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "18.8.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.1.tgz",
+ "integrity": "sha512-vuYaNuEIbOYLTLUAJh50ezEbvxrD43iby+lpUA2aa148Nh5kX/AVO/9m1Ahmbux2iU5uxJTNF9g2Y+31uml7RQ==",
+ "dev": true
+ },
+ "node_modules/@types/node-fetch": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz",
+ "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "form-data": "^3.0.0"
+ }
+ },
+ "node_modules/@types/normalize-package-data": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz",
+ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
+ "dev": true
+ },
+ "node_modules/@types/npmlog": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@types/npmlog/-/npmlog-4.1.4.tgz",
+ "integrity": "sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==",
+ "dev": true
+ },
+ "node_modules/@types/parse-json": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
+ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
+ "dev": true
+ },
+ "node_modules/@types/parse5": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz",
+ "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==",
+ "dev": true
+ },
+ "node_modules/@types/pretty-hrtime": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz",
+ "integrity": "sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==",
+ "dev": true
+ },
+ "node_modules/@types/qs": {
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
+ "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==",
+ "dev": true
+ },
+ "node_modules/@types/source-list-map": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
+ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==",
+ "dev": true
+ },
+ "node_modules/@types/tapable": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz",
+ "integrity": "sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==",
+ "dev": true
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
+ "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==",
+ "dev": true
+ },
+ "node_modules/@types/uglify-js": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.0.tgz",
+ "integrity": "sha512-3HO6rm0y+/cqvOyA8xcYLweF0TKXlAxmQASjbOi49Co51A1N4nR4bEwBgRoD9kNM+rqFGArjKr654SLp2CoGmQ==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "^0.6.1"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
+ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
+ "dev": true
+ },
+ "node_modules/@types/webpack": {
+ "version": "4.41.32",
+ "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz",
+ "integrity": "sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/tapable": "^1",
+ "@types/uglify-js": "*",
+ "@types/webpack-sources": "*",
+ "anymatch": "^3.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/@types/webpack-env": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.0.tgz",
+ "integrity": "sha512-56/MAlX5WMsPVbOg7tAxnYvNYMMWr/QJiIp6BxVSW3JJXUVzzOn64qW8TzQyMSqSUFM2+PVI4aUHcHOzIz/1tg==",
+ "dev": true
+ },
+ "node_modules/@types/webpack-sources": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.0.tgz",
+ "integrity": "sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/source-list-map": "*",
+ "source-map": "^0.7.3"
+ }
+ },
+ "node_modules/@types/webpack-sources/node_modules/source-map": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
+ "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@types/yargs": {
+ "version": "15.0.14",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz",
+ "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "node_modules/@types/yargs-parser": {
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz",
+ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/ast": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
+ "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-numbers": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz",
+ "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz",
+ "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz",
+ "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-code-frame": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz",
+ "integrity": "sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-code-frame/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-code-frame/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-code-frame/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-fsm": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz",
+ "integrity": "sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-module-context": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz",
+ "integrity": "sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-module-context/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-module-context/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-numbers": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz",
+ "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/floating-point-hex-parser": "1.11.1",
+ "@webassemblyjs/helper-api-error": "1.11.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz",
+ "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz",
+ "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/ieee754": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz",
+ "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@webassemblyjs/leb128": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz",
+ "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/utf8": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz",
+ "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz",
+ "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/helper-wasm-section": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1",
+ "@webassemblyjs/wasm-opt": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1",
+ "@webassemblyjs/wast-printer": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz",
+ "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/ieee754": "1.11.1",
+ "@webassemblyjs/leb128": "1.11.1",
+ "@webassemblyjs/utf8": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
+ "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz",
+ "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-api-error": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/ieee754": "1.11.1",
+ "@webassemblyjs/leb128": "1.11.1",
+ "@webassemblyjs/utf8": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wast-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz",
+ "integrity": "sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/floating-point-hex-parser": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-code-frame": "1.9.0",
+ "@webassemblyjs/helper-fsm": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/wast-parser/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/wast-parser/node_modules/@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz",
+ "integrity": "sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wast-parser/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wast-parser/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz",
+ "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@xtuc/ieee754": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
+ "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+ "dev": true
+ },
+ "node_modules/@xtuc/long": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
+ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+ "dev": true
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dev": true,
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
+ "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-import-assertions": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz",
+ "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^8"
+ }
+ },
+ "node_modules/address": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/address/-/address-1.2.1.tgz",
+ "integrity": "sha512-B+6bi5D34+fDYENiH5qOlA0cV2rAGKuWZ9LeyUUehbXy8e0VS9e498yO0Jeeh+iM+6KbfudHTFjXw2MmJD4QRA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "dev": true,
+ "dependencies": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/airbnb-js-shims": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/airbnb-js-shims/-/airbnb-js-shims-2.2.1.tgz",
+ "integrity": "sha512-wJNXPH66U2xjgo1Zwyjf9EydvJ2Si94+vSdk6EERcBfB2VZkeltpqIats0cqIZMLCXP3zcyaUKGYQeIBT6XjsQ==",
+ "dev": true,
+ "dependencies": {
+ "array-includes": "^3.0.3",
+ "array.prototype.flat": "^1.2.1",
+ "array.prototype.flatmap": "^1.2.1",
+ "es5-shim": "^4.5.13",
+ "es6-shim": "^0.35.5",
+ "function.prototype.name": "^1.1.0",
+ "globalthis": "^1.0.0",
+ "object.entries": "^1.1.0",
+ "object.fromentries": "^2.0.0 || ^1.0.0",
+ "object.getownpropertydescriptors": "^2.0.3",
+ "object.values": "^1.1.0",
+ "promise.allsettled": "^1.0.0",
+ "promise.prototype.finally": "^3.1.0",
+ "string.prototype.matchall": "^4.0.0 || ^3.0.1",
+ "string.prototype.padend": "^3.0.0",
+ "string.prototype.padstart": "^3.0.0",
+ "symbol.prototype.description": "^1.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-errors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
+ "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
+ "dev": true,
+ "peerDependencies": {
+ "ajv": ">=5.0.0"
+ }
+ },
+ "node_modules/ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true,
+ "peerDependencies": {
+ "ajv": "^6.9.1"
+ }
+ },
+ "node_modules/ansi-align": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
+ "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.1.0"
+ }
+ },
+ "node_modules/ansi-colors": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz",
+ "integrity": "sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ansi-html-community": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
+ "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==",
+ "dev": true,
+ "engines": [
+ "node >= 0.8.0"
+ ],
+ "bin": {
+ "ansi-html": "bin/ansi-html"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ansi-to-html": {
+ "version": "0.6.15",
+ "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.15.tgz",
+ "integrity": "sha512-28ijx2aHJGdzbs+O5SNQF65r6rrKYnkuwTYm8lZlChuoJ9P1vVzIpWO20sQTqTPDXYp6NFwk326vApTtLVFXpQ==",
+ "dev": true,
+ "dependencies": {
+ "entities": "^2.0.0"
+ },
+ "bin": {
+ "ansi-to-html": "bin/ansi-to-html"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+ "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/app-root-dir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz",
+ "integrity": "sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==",
+ "dev": true
+ },
+ "node_modules/aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+ "dev": true
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "dev": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/arr-diff": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
+ "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/arr-flatten": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz",
+ "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/arr-union": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
+ "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/array-find-index": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz",
+ "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.5.tgz",
+ "integrity": "sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5",
+ "get-intrinsic": "^1.1.1",
+ "is-string": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/array-uniq": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
+ "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/array-unique": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
+ "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz",
+ "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz",
+ "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.map": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.4.tgz",
+ "integrity": "sha512-Qds9QnX7A0qISY7JT5WuJO0NJPE9CMlC6JzHQfhpqAAQQzufVRoeH7EzUY5GcPTx72voG8LV/5eo+b8Qi8hmhA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.0",
+ "es-array-method-boxes-properly": "^1.0.0",
+ "is-string": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.reduce": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.4.tgz",
+ "integrity": "sha512-WnM+AjG/DvLRLo4DDl+r+SvCzYtD2Jd9oeBYMcEaI7t3fFrHY9M53/wdLcTvmZNQ70IU6Htj0emFkZ5TS+lrdw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-array-method-boxes-properly": "^1.0.0",
+ "is-string": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/arrify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
+ "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/asn1.js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
+ "node_modules/asn1.js/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/assert": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz",
+ "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==",
+ "dev": true,
+ "dependencies": {
+ "object-assign": "^4.1.1",
+ "util": "0.10.3"
+ }
+ },
+ "node_modules/assert/node_modules/inherits": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz",
+ "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==",
+ "dev": true
+ },
+ "node_modules/assert/node_modules/util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
+ "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "2.0.1"
+ }
+ },
+ "node_modules/assign-symbols": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
+ "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/async-each": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz",
+ "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true
+ },
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+ "dev": true,
+ "bin": {
+ "atob": "bin/atob.js"
+ },
+ "engines": {
+ "node": ">= 4.5.0"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "9.8.8",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.8.tgz",
+ "integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
+ "dev": true,
+ "dependencies": {
+ "browserslist": "^4.12.0",
+ "caniuse-lite": "^1.0.30001109",
+ "normalize-range": "^0.1.2",
+ "num2fraction": "^1.2.2",
+ "picocolors": "^0.2.1",
+ "postcss": "^7.0.32",
+ "postcss-value-parser": "^4.1.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ }
+ },
+ "node_modules/babel-loader": {
+ "version": "8.2.5",
+ "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz",
+ "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==",
+ "dev": true,
+ "dependencies": {
+ "find-cache-dir": "^3.3.1",
+ "loader-utils": "^2.0.0",
+ "make-dir": "^3.1.0",
+ "schema-utils": "^2.6.5"
+ },
+ "engines": {
+ "node": ">= 8.9"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "webpack": ">=2"
+ }
+ },
+ "node_modules/babel-loader/node_modules/find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+ }
+ },
+ "node_modules/babel-loader/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-loader/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-loader/node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/babel-loader/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/babel-loader/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-loader/node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-plugin-apply-mdx-type-prop": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz",
+ "integrity": "sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "7.10.4",
+ "@mdx-js/util": "1.6.22"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.11.6"
+ }
+ },
+ "node_modules/babel-plugin-apply-mdx-type-prop/node_modules/@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ },
+ "node_modules/babel-plugin-bundled-import-meta": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-bundled-import-meta/-/babel-plugin-bundled-import-meta-0.3.2.tgz",
+ "integrity": "sha512-RMXzsnWoFHDSUc1X/QiejEwQBtQ0Y68HQZ542JQ4voFa5Sgl5f/D4T7+EOocUeSbiT4XIDbrhfxbH5OmcV8Ibw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-syntax-import-meta": "^7.2.0",
+ "@babel/template": "^7.7.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.7.0"
+ }
+ },
+ "node_modules/babel-plugin-dynamic-import-node": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz",
+ "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==",
+ "dev": true,
+ "dependencies": {
+ "object.assign": "^4.1.0"
+ }
+ },
+ "node_modules/babel-plugin-extract-import-names": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz",
+ "integrity": "sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "7.10.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/babel-plugin-extract-import-names/node_modules/@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ },
+ "node_modules/babel-plugin-istanbul": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
+ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@istanbuljs/load-nyc-config": "^1.0.0",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-instrument": "^5.0.4",
+ "test-exclude": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-plugin-macros": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/babel-plugin-named-exports-order": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-named-exports-order/-/babel-plugin-named-exports-order-0.0.2.tgz",
+ "integrity": "sha512-OgOYHOLoRK+/mvXU9imKHlG6GkPLYrUCvFXG/CM93R/aNNO8pOOF4aS+S8CCHMDQoNSeiOYEZb/G6RwL95Jktw==",
+ "dev": true
+ },
+ "node_modules/babel-plugin-polyfill-corejs2": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz",
+ "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.17.7",
+ "@babel/helper-define-polyfill-provider": "^0.3.3",
+ "semver": "^6.1.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz",
+ "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.3.3",
+ "core-js-compat": "^3.25.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz",
+ "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.3.3"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/bail": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz",
+ "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/base": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz",
+ "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==",
+ "dev": true,
+ "dependencies": {
+ "cache-base": "^1.0.1",
+ "class-utils": "^0.3.5",
+ "component-emitter": "^1.2.1",
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.1",
+ "mixin-deep": "^1.2.0",
+ "pascalcase": "^0.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/base/node_modules/define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/better-opn": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz",
+ "integrity": "sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA==",
+ "dev": true,
+ "dependencies": {
+ "open": "^7.0.3"
+ },
+ "engines": {
+ "node": ">8.0.0"
+ }
+ },
+ "node_modules/better-opn/node_modules/open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "dependencies": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/big-integer": {
+ "version": "1.6.51",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
+ "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/big.js": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
+ "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "dev": true
+ },
+ "node_modules/bn.js": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
+ "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==",
+ "dev": true
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz",
+ "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.4",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.10.3",
+ "raw-body": "2.5.1",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/body-parser/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/body-parser/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
+ "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true
+ },
+ "node_modules/boxen": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
+ "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-align": "^3.0.0",
+ "camelcase": "^6.2.0",
+ "chalk": "^4.1.0",
+ "cli-boxes": "^2.2.1",
+ "string-width": "^4.2.2",
+ "type-fest": "^0.20.2",
+ "widest-line": "^3.1.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/boxen/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/boxen/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/boxen/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bplist-parser": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.1.1.tgz",
+ "integrity": "sha512-2AEM0FXy8ZxVLBuqX0hqt1gDwcnz2zygEkQ6zaD5Wko/sB9paUNwlpawrFtKeHUAQUOzjVy9AO4oeonqIHKA9Q==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "big-integer": "^1.6.7"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/brorand": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
+ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
+ "dev": true
+ },
+ "node_modules/browser-assert": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz",
+ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==",
+ "dev": true
+ },
+ "node_modules/browserify-aes": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
+ "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
+ "dev": true,
+ "dependencies": {
+ "buffer-xor": "^1.0.3",
+ "cipher-base": "^1.0.0",
+ "create-hash": "^1.1.0",
+ "evp_bytestokey": "^1.0.3",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/browserify-cipher": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
+ "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
+ "dev": true,
+ "dependencies": {
+ "browserify-aes": "^1.0.4",
+ "browserify-des": "^1.0.0",
+ "evp_bytestokey": "^1.0.0"
+ }
+ },
+ "node_modules/browserify-des": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
+ "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "des.js": "^1.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/browserify-rsa": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz",
+ "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^5.0.0",
+ "randombytes": "^2.0.1"
+ }
+ },
+ "node_modules/browserify-sign": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz",
+ "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^5.1.1",
+ "browserify-rsa": "^4.0.1",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "elliptic": "^6.5.3",
+ "inherits": "^2.0.4",
+ "parse-asn1": "^5.1.5",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ }
+ },
+ "node_modules/browserify-sign/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "dev": true,
+ "dependencies": {
+ "pako": "~1.0.5"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.21.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
+ "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ }
+ ],
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001400",
+ "electron-to-chromium": "^1.4.251",
+ "node-releases": "^2.0.6",
+ "update-browserslist-db": "^1.0.9"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/bser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+ "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+ "dev": true,
+ "dependencies": {
+ "node-int64": "^0.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "4.9.2",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
+ "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
+ "dev": true,
+ "dependencies": {
+ "base64-js": "^1.0.2",
+ "ieee754": "^1.1.4",
+ "isarray": "^1.0.0"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true
+ },
+ "node_modules/buffer-xor": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
+ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==",
+ "dev": true
+ },
+ "node_modules/buffer/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/builtin-status-codes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
+ "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==",
+ "dev": true
+ },
+ "node_modules/bytes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+ "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cacache": {
+ "version": "15.3.0",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz",
+ "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==",
+ "dev": true,
+ "dependencies": {
+ "@npmcli/fs": "^1.0.0",
+ "@npmcli/move-file": "^1.0.1",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "glob": "^7.1.4",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.1",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.2",
+ "mkdirp": "^1.0.3",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^8.0.1",
+ "tar": "^6.0.2",
+ "unique-filename": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/cacache/node_modules/p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "dev": true,
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cache-base": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
+ "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==",
+ "dev": true,
+ "dependencies": {
+ "collection-visit": "^1.0.0",
+ "component-emitter": "^1.2.1",
+ "get-value": "^2.0.6",
+ "has-value": "^1.0.0",
+ "isobject": "^3.0.1",
+ "set-value": "^2.0.0",
+ "to-object-path": "^0.3.0",
+ "union-value": "^1.0.0",
+ "unset-value": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cached-iterable": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/cached-iterable/-/cached-iterable-0.3.0.tgz",
+ "integrity": "sha512-MDqM6TpBVebZD4UDtmlFp8EjVtRcsB6xt9aRdWymjk0fWVUUGgmt/V7o0H0gkI2Tkvv8B0ucjidZm4mLosdlWw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.9.0"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+ "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-me-maybe": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz",
+ "integrity": "sha512-wCyFsDQkKPwwF8BDwOiWNx/9K45L/hvggQiDbve+viMNMQnWhrlYIuBk09offfwCRtCO9P6XwUttufzU11WCVw==",
+ "dev": true
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camel-case": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
+ "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
+ "dev": true,
+ "dependencies": {
+ "pascal-case": "^3.1.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/camelcase-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
+ "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "camelcase": "^2.0.0",
+ "map-obj": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/camelcase-keys/node_modules/camelcase": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz",
+ "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001415",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001415.tgz",
+ "integrity": "sha512-ER+PfgCJUe8BqunLGWd/1EY4g8AzQcsDAVzdtMGKVtQEmKAwaFfU6vb7EAVIqTMYsqxBorYZi2+22Iouj/y7GQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ }
+ ]
+ },
+ "node_modules/capture-exit": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz",
+ "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==",
+ "dev": true,
+ "dependencies": {
+ "rsvp": "^4.8.4"
+ },
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/case-sensitive-paths-webpack-plugin": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz",
+ "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ccount": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz",
+ "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
+ "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
+ "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
+ "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ],
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/chrome-trace-event": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
+ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
+ "node_modules/ci-info": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
+ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
+ "dev": true
+ },
+ "node_modules/cipher-base": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
+ "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/class-utils": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz",
+ "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==",
+ "dev": true,
+ "dependencies": {
+ "arr-union": "^3.1.0",
+ "define-property": "^0.2.5",
+ "isobject": "^3.0.0",
+ "static-extend": "^0.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-accessor-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/class-utils/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-data-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/clean-css": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.1.tgz",
+ "integrity": "sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "~0.6.0"
+ },
+ "engines": {
+ "node": ">= 10.0"
+ }
+ },
+ "node_modules/clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cli-boxes": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
+ "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-table3": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz",
+ "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0"
+ },
+ "engines": {
+ "node": "10.* || >= 12.*"
+ },
+ "optionalDependencies": {
+ "@colors/colors": "1.5.0"
+ }
+ },
+ "node_modules/clone-deep": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
+ "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
+ "dev": true,
+ "dependencies": {
+ "is-plain-object": "^2.0.4",
+ "kind-of": "^6.0.2",
+ "shallow-clone": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/collapse-white-space": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz",
+ "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/collection-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
+ "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==",
+ "dev": true,
+ "dependencies": {
+ "map-visit": "^1.0.0",
+ "object-visit": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "dev": true,
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
+ "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/commander": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+ "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "dev": true
+ },
+ "node_modules/component-emitter": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
+ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
+ "dev": true
+ },
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "dev": true,
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
+ "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
+ "dev": true,
+ "dependencies": {
+ "accepts": "~1.3.5",
+ "bytes": "3.0.0",
+ "compressible": "~2.0.16",
+ "debug": "2.6.9",
+ "on-headers": "~1.0.2",
+ "safe-buffer": "5.1.2",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/compression/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/compression/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/concat-stream": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "dev": true,
+ "engines": [
+ "node >= 0.8"
+ ],
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.2.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/concat-stream/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/concat-stream/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/concat-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/console-browserify": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
+ "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==",
+ "dev": true
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "dev": true
+ },
+ "node_modules/constants-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
+ "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==",
+ "dev": true
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-disposition/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/content-type": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz",
+ "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.1"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
+ "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "dev": true
+ },
+ "node_modules/copy-concurrently": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
+ "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.1.1",
+ "fs-write-stream-atomic": "^1.0.8",
+ "iferr": "^0.1.5",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.4",
+ "run-queue": "^1.0.0"
+ }
+ },
+ "node_modules/copy-concurrently/node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "node_modules/copy-concurrently/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/copy-concurrently/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/copy-descriptor": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
+ "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/core-js": {
+ "version": "3.25.5",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz",
+ "integrity": "sha512-nbm6eZSjm+ZuBQxCUPQKQCoUEfFOXjUZ8dTTyikyKaWrTYmAVbykQfwsKE5dBK88u3QCkCrzsx/PPlKfhsvgpw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/core-js-compat": {
+ "version": "3.25.5",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.5.tgz",
+ "integrity": "sha512-ovcyhs2DEBUIE0MGEKHP4olCUW/XYte3Vroyxuh38rD1wAO4dHohsovUC4eAOuzFxE6b+RXvBU3UZ9o0YhUTkA==",
+ "dev": true,
+ "dependencies": {
+ "browserslist": "^4.21.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true
+ },
+ "node_modules/cosmiconfig": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
+ "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cp-file": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-7.0.0.tgz",
+ "integrity": "sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "make-dir": "^3.0.0",
+ "nested-error-stacks": "^2.0.0",
+ "p-event": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cp-file/node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cpy": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/cpy/-/cpy-8.1.2.tgz",
+ "integrity": "sha512-dmC4mUesv0OYH2kNFEidtf/skUwv4zePmGeepjyyJ0qTo5+8KhA1o99oIAwVVLzQMAeDJml74d6wPPKb6EZUTg==",
+ "dev": true,
+ "dependencies": {
+ "arrify": "^2.0.1",
+ "cp-file": "^7.0.0",
+ "globby": "^9.2.0",
+ "has-glob": "^1.0.0",
+ "junk": "^3.1.0",
+ "nested-error-stacks": "^2.1.0",
+ "p-all": "^2.1.0",
+ "p-filter": "^2.1.0",
+ "p-map": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cpy/node_modules/@nodelib/fs.stat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
+ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cpy/node_modules/@types/glob": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz",
+ "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==",
+ "dev": true,
+ "dependencies": {
+ "@types/minimatch": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/cpy/node_modules/array-union": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
+ "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==",
+ "dev": true,
+ "dependencies": {
+ "array-uniq": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/dir-glob": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz",
+ "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==",
+ "dev": true,
+ "dependencies": {
+ "path-type": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cpy/node_modules/fast-glob": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.7.tgz",
+ "integrity": "sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==",
+ "dev": true,
+ "dependencies": {
+ "@mrmlnc/readdir-enhanced": "^2.2.1",
+ "@nodelib/fs.stat": "^1.1.2",
+ "glob-parent": "^3.1.0",
+ "is-glob": "^4.0.0",
+ "merge2": "^1.2.3",
+ "micromatch": "^3.1.10"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/cpy/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^3.1.0",
+ "path-dirname": "^1.0.0"
+ }
+ },
+ "node_modules/cpy/node_modules/glob-parent/node_modules/is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/globby": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz",
+ "integrity": "sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==",
+ "dev": true,
+ "dependencies": {
+ "@types/glob": "^7.1.1",
+ "array-union": "^1.0.2",
+ "dir-glob": "^2.2.2",
+ "fast-glob": "^2.2.6",
+ "glob": "^7.1.3",
+ "ignore": "^4.0.3",
+ "pify": "^4.0.1",
+ "slash": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cpy/node_modules/ignore": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/cpy/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/cpy/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/path-type": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
+ "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cpy/node_modules/path-type/node_modules/pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cpy/node_modules/slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cpy/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/create-ecdh": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
+ "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "elliptic": "^6.5.3"
+ }
+ },
+ "node_modules/create-ecdh/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/create-hash": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+ "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "inherits": "^2.0.1",
+ "md5.js": "^1.3.4",
+ "ripemd160": "^2.0.1",
+ "sha.js": "^2.4.0"
+ }
+ },
+ "node_modules/create-hmac": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
+ "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.3",
+ "create-hash": "^1.1.0",
+ "inherits": "^2.0.1",
+ "ripemd160": "^2.0.0",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/crypto-browserify": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
+ "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==",
+ "dev": true,
+ "dependencies": {
+ "browserify-cipher": "^1.0.0",
+ "browserify-sign": "^4.0.0",
+ "create-ecdh": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "create-hmac": "^1.1.0",
+ "diffie-hellman": "^5.0.0",
+ "inherits": "^2.0.1",
+ "pbkdf2": "^3.0.3",
+ "public-encrypt": "^4.0.0",
+ "randombytes": "^2.0.0",
+ "randomfill": "^1.0.3"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/css-loader": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz",
+ "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^5.1.0",
+ "loader-utils": "^2.0.0",
+ "postcss": "^8.2.15",
+ "postcss-modules-extract-imports": "^3.0.0",
+ "postcss-modules-local-by-default": "^4.0.0",
+ "postcss-modules-scope": "^3.0.0",
+ "postcss-modules-values": "^4.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^3.0.0",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.27.0 || ^5.0.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/icss-utils": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+ "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+ "dev": true,
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "node_modules/css-loader/node_modules/postcss": {
+ "version": "8.4.17",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.17.tgz",
+ "integrity": "sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.4",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/css-loader/node_modules/postcss-modules-extract-imports": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+ "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+ "dev": true,
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/postcss-modules-local-by-default": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+ "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^5.0.0",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/postcss-modules-scope": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+ "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
+ "dev": true,
+ "dependencies": {
+ "postcss-selector-parser": "^6.0.4"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/postcss-modules-values": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+ "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^5.0.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/css-loader/node_modules/semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/css-select": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
+ "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==",
+ "dev": true,
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.0.1",
+ "domhandler": "^4.3.1",
+ "domutils": "^2.8.0",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
+ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/currently-unhandled": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
+ "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "array-find-index": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cyclist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
+ "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==",
+ "dev": true
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decode-uri-component": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+ "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-1.0.4.tgz",
+ "integrity": "sha512-qPy925qewwul9Hifs+3sx1ZYn14obHxpkX+mPD369w4Rzg+YkJBgi3SOvwUq81nWSjqGUegIgEPwD8u+HUnxlw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "bplist-parser": "^0.1.0",
+ "meow": "^3.1.0",
+ "untildify": "^2.0.0"
+ },
+ "bin": {
+ "default-browser-id": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
+ "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
+ "dev": true,
+ "dependencies": {
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-property": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz",
+ "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^1.0.2",
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "dev": true
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/des.js": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz",
+ "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detab": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/detab/-/detab-2.0.4.tgz",
+ "integrity": "sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==",
+ "dev": true,
+ "dependencies": {
+ "repeat-string": "^1.5.4"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/detect-package-manager": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/detect-package-manager/-/detect-package-manager-2.0.1.tgz",
+ "integrity": "sha512-j/lJHyoLlWi6G1LDdLgvUtz60Zo5GEj+sVYtTVXnYLDPuzgC3llMxonXym9zIwhhUII8vjdw0LXxavpLqTbl1A==",
+ "dev": true,
+ "dependencies": {
+ "execa": "^5.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/detect-port": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.5.1.tgz",
+ "integrity": "sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==",
+ "dev": true,
+ "dependencies": {
+ "address": "^1.0.1",
+ "debug": "4"
+ },
+ "bin": {
+ "detect": "bin/detect-port.js",
+ "detect-port": "bin/detect-port.js"
+ }
+ },
+ "node_modules/diffie-hellman": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
+ "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "miller-rabin": "^4.0.0",
+ "randombytes": "^2.0.0"
+ }
+ },
+ "node_modules/diffie-hellman/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dom-converter": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
+ "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==",
+ "dev": true,
+ "dependencies": {
+ "utila": "~0.4"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
+ "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
+ "dev": true,
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.2.0",
+ "entities": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-walk": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
+ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==",
+ "dev": true
+ },
+ "node_modules/domain-browser": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
+ "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4",
+ "npm": ">=1.2"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ]
+ },
+ "node_modules/domhandler": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
+ "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
+ "dev": true,
+ "dependencies": {
+ "domelementtype": "^2.2.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
+ "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
+ "dev": true,
+ "dependencies": {
+ "dom-serializer": "^1.0.1",
+ "domelementtype": "^2.2.0",
+ "domhandler": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dot-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+ "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz",
+ "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/dotenv-expand": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
+ "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==",
+ "dev": true
+ },
+ "node_modules/duplexify": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
+ "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.0",
+ "stream-shift": "^1.0.0"
+ }
+ },
+ "node_modules/duplexify/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/duplexify/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/duplexify/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.4.271",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.271.tgz",
+ "integrity": "sha512-BCPBtK07xR1/uY2HFDtl3wK2De66AW4MSiPlLrnPNxKC/Qhccxd59W73654S3y6Rb/k3hmuGJOBnhjfoutetXA==",
+ "dev": true
+ },
+ "node_modules/elliptic": {
+ "version": "6.5.4",
+ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
+ "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.11.9",
+ "brorand": "^1.1.0",
+ "hash.js": "^1.0.0",
+ "hmac-drbg": "^1.0.1",
+ "inherits": "^2.0.4",
+ "minimalistic-assert": "^1.0.1",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "node_modules/elliptic/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/emojis-list": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
+ "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz",
+ "integrity": "sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/enhanced-resolve/node_modules/tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/entities": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/errno": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
+ "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+ "dev": true,
+ "dependencies": {
+ "prr": "~1.0.1"
+ },
+ "bin": {
+ "errno": "cli.js"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dev": true,
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.3.tgz",
+ "integrity": "sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "function.prototype.name": "^1.1.5",
+ "get-intrinsic": "^1.1.3",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-property-descriptors": "^1.0.0",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.6",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.2",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.4",
+ "regexp.prototype.flags": "^1.4.3",
+ "safe-regex-test": "^1.0.0",
+ "string.prototype.trimend": "^1.0.5",
+ "string.prototype.trimstart": "^1.0.5",
+ "unbox-primitive": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-array-method-boxes-properly": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz",
+ "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==",
+ "dev": true
+ },
+ "node_modules/es-get-iterator": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz",
+ "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.0",
+ "has-symbols": "^1.0.1",
+ "is-arguments": "^1.1.0",
+ "is-map": "^2.0.2",
+ "is-set": "^2.0.2",
+ "is-string": "^1.0.5",
+ "isarray": "^2.0.5"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz",
+ "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
+ "dev": true
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz",
+ "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==",
+ "dev": true,
+ "dependencies": {
+ "has": "^1.0.3"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+ "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+ "dev": true,
+ "dependencies": {
+ "is-callable": "^1.1.4",
+ "is-date-object": "^1.0.1",
+ "is-symbol": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es5-shim": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.7.tgz",
+ "integrity": "sha512-jg21/dmlrNQI7JyyA2w7n+yifSxBng0ZralnSfVZjoCawgNTCnS+yBCyVM9DL5itm7SUnDGgv7hcq2XCZX4iRQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/es6-shim": {
+ "version": "0.35.6",
+ "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz",
+ "integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==",
+ "dev": true
+ },
+ "node_modules/escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/eslint-scope/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/evp_bytestokey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
+ "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
+ "dev": true,
+ "dependencies": {
+ "md5.js": "^1.3.4",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "node_modules/exec-sh": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz",
+ "integrity": "sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==",
+ "dev": true
+ },
+ "node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/expand-brackets": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
+ "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^2.3.3",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "posix-character-classes": "^0.1.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-accessor-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/expand-brackets/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-data-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/express": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz",
+ "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==",
+ "dev": true,
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.0",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.5.0",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.2.0",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.1",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.7",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.10.3",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.18.0",
+ "serve-static": "1.15.0",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/express/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/express/node_modules/qs": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
+ "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/express/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "dev": true
+ },
+ "node_modules/extend-shallow": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
+ "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
+ "dev": true,
+ "dependencies": {
+ "assign-symbols": "^1.0.0",
+ "is-extendable": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extglob": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
+ "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==",
+ "dev": true,
+ "dependencies": {
+ "array-unique": "^0.3.2",
+ "define-property": "^1.0.0",
+ "expand-brackets": "^2.1.4",
+ "extend-shallow": "^2.0.1",
+ "fragment-cache": "^0.2.1",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extglob/node_modules/define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extglob/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extglob/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-glob": {
+ "version": "3.2.12",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
+ "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "node_modules/fastq": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+ "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fb-watchman": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
+ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
+ "dev": true,
+ "dependencies": {
+ "bser": "2.1.1"
+ }
+ },
+ "node_modules/fetch-retry": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-5.0.3.tgz",
+ "integrity": "sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw==",
+ "dev": true
+ },
+ "node_modules/figgy-pudding": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
+ "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==",
+ "dev": true
+ },
+ "node_modules/file-loader": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
+ "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/file-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/file-system-cache": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.1.0.tgz",
+ "integrity": "sha512-IzF5MBq+5CR0jXx5RxPe4BICl/oEhBSXKaL9fLhAXrIfIUS77Hr4vzrYyqYMHN6uTt+BOqi3fDCTjjEBCjERKw==",
+ "dev": true,
+ "dependencies": {
+ "fs-extra": "^10.1.0",
+ "ramda": "^0.28.0"
+ }
+ },
+ "node_modules/file-system-cache/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
+ "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/finalhandler/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/finalhandler/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flush-write-stream": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
+ "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.3.6"
+ }
+ },
+ "node_modules/flush-write-stream/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/flush-write-stream/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/flush-write-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/for-in": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
+ "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz",
+ "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.8.3",
+ "@types/json-schema": "^7.0.5",
+ "chalk": "^4.1.0",
+ "chokidar": "^3.4.2",
+ "cosmiconfig": "^6.0.0",
+ "deepmerge": "^4.2.2",
+ "fs-extra": "^9.0.0",
+ "glob": "^7.1.6",
+ "memfs": "^3.1.2",
+ "minimatch": "^3.0.4",
+ "schema-utils": "2.7.0",
+ "semver": "^7.3.2",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "yarn": ">=1.0.0"
+ },
+ "peerDependencies": {
+ "eslint": ">= 6",
+ "typescript": ">= 2.7",
+ "vue-template-compiler": "*",
+ "webpack": ">= 4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ },
+ "vue-template-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz",
+ "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==",
+ "dev": true,
+ "dependencies": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.1.0",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.7.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
+ "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.4",
+ "ajv": "^6.12.2",
+ "ajv-keywords": "^3.4.1"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
+ "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+ "dev": true,
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fragment-cache": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
+ "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==",
+ "dev": true,
+ "dependencies": {
+ "map-cache": "^0.2.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/from2": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz",
+ "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.0"
+ }
+ },
+ "node_modules/from2/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/from2/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/from2/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-monkey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz",
+ "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==",
+ "dev": true
+ },
+ "node_modules/fs-write-stream-atomic": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
+ "integrity": "sha512-gehEzmPn2nAwr39eay+x3X34Ra+M2QlVUTLhkXPjWdeO8RF9kszk116avgBJM3ZyNHgHXBNx+VmPaFC36k0PzA==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "iferr": "^0.1.5",
+ "imurmurhash": "^0.1.4",
+ "readable-stream": "1 || 2"
+ }
+ },
+ "node_modules/fs-write-stream-atomic/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/fs-write-stream-atomic/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/fs-write-stream-atomic/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz",
+ "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.0",
+ "functions-have-names": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
+ "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.1",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/get-stdin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz",
+ "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+ "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-value": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
+ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/github-slugger": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.4.0.tgz",
+ "integrity": "sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==",
+ "dev": true
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/glob-promise": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/glob-promise/-/glob-promise-3.4.0.tgz",
+ "integrity": "sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw==",
+ "dev": true,
+ "dependencies": {
+ "@types/glob": "*"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "glob": "*"
+ }
+ },
+ "node_modules/glob-to-regexp": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "dev": true
+ },
+ "node_modules/global": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
+ "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+ "dev": true,
+ "dependencies": {
+ "min-document": "^2.19.0",
+ "process": "^0.11.10"
+ }
+ },
+ "node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
+ "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==",
+ "dev": true,
+ "dependencies": {
+ "define-properties": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.10",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
+ "dev": true
+ },
+ "node_modules/handlebars": {
+ "version": "4.7.7",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+ "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.0",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
+ "node_modules/has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/has-bigints": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
+ "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-glob": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-glob/-/has-glob-1.0.0.tgz",
+ "integrity": "sha512-D+8A457fBShSEI3tFCj65PAbT++5sKiFtdCdOam0gnfBgw9D277OERk+HM9qYJXmdVLZ/znez10SqHN0BBQ50g==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-glob/node_modules/is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
+ "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
+ "dev": true,
+ "dependencies": {
+ "get-intrinsic": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+ "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+ "dev": true,
+ "dependencies": {
+ "has-symbols": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "dev": true
+ },
+ "node_modules/has-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz",
+ "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==",
+ "dev": true,
+ "dependencies": {
+ "get-value": "^2.0.6",
+ "has-values": "^1.0.0",
+ "isobject": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-values": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz",
+ "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "kind-of": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-values/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/has-values/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-values/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-values/node_modules/kind-of": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz",
+ "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/hash-base": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz",
+ "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/hash-base/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
+ "node_modules/hast-to-hyperscript": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz",
+ "integrity": "sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.3",
+ "comma-separated-tokens": "^1.0.0",
+ "property-information": "^5.3.0",
+ "space-separated-tokens": "^1.0.0",
+ "style-to-object": "^0.3.0",
+ "unist-util-is": "^4.0.0",
+ "web-namespaces": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-6.0.1.tgz",
+ "integrity": "sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==",
+ "dev": true,
+ "dependencies": {
+ "@types/parse5": "^5.0.0",
+ "hastscript": "^6.0.0",
+ "property-information": "^5.0.0",
+ "vfile": "^4.0.0",
+ "vfile-location": "^3.2.0",
+ "web-namespaces": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
+ "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-raw": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-6.0.1.tgz",
+ "integrity": "sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig==",
+ "dev": true,
+ "dependencies": {
+ "@types/hast": "^2.0.0",
+ "hast-util-from-parse5": "^6.0.0",
+ "hast-util-to-parse5": "^6.0.0",
+ "html-void-elements": "^1.0.0",
+ "parse5": "^6.0.0",
+ "unist-util-position": "^3.0.0",
+ "vfile": "^4.0.0",
+ "web-namespaces": "^1.0.0",
+ "xtend": "^4.0.0",
+ "zwitch": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-parse5": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz",
+ "integrity": "sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==",
+ "dev": true,
+ "dependencies": {
+ "hast-to-hyperscript": "^9.0.0",
+ "property-information": "^5.0.0",
+ "web-namespaces": "^1.0.0",
+ "xtend": "^4.0.0",
+ "zwitch": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
+ "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
+ "dev": true,
+ "dependencies": {
+ "@types/hast": "^2.0.0",
+ "comma-separated-tokens": "^1.0.0",
+ "hast-util-parse-selector": "^2.0.0",
+ "property-information": "^5.0.0",
+ "space-separated-tokens": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/hmac-drbg": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+ "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
+ "dev": true,
+ "dependencies": {
+ "hash.js": "^1.0.3",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "node_modules/hosted-git-info": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+ "dev": true
+ },
+ "node_modules/html-entities": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
+ "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==",
+ "dev": true
+ },
+ "node_modules/html-minifier-terser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
+ "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
+ "dev": true,
+ "dependencies": {
+ "camel-case": "^4.1.2",
+ "clean-css": "^5.2.2",
+ "commander": "^8.3.0",
+ "he": "^1.2.0",
+ "param-case": "^3.0.4",
+ "relateurl": "^0.2.7",
+ "terser": "^5.10.0"
+ },
+ "bin": {
+ "html-minifier-terser": "cli.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/html-minifier-terser/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "dev": true,
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/html-void-elements": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz",
+ "integrity": "sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/html-webpack-plugin": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz",
+ "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==",
+ "dev": true,
+ "dependencies": {
+ "@types/html-minifier-terser": "^6.0.0",
+ "html-minifier-terser": "^6.0.2",
+ "lodash": "^4.17.21",
+ "pretty-error": "^4.0.0",
+ "tapable": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/html-webpack-plugin"
+ },
+ "peerDependencies": {
+ "webpack": "^5.20.0"
+ }
+ },
+ "node_modules/html-webpack-plugin/node_modules/tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
+ "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
+ "dev": true,
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.0.0",
+ "domutils": "^2.5.2",
+ "entities": "^2.0.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dev": true,
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/https-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
+ "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==",
+ "dev": true
+ },
+ "node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.17.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/icss-utils": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
+ "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==",
+ "dev": true,
+ "dependencies": {
+ "postcss": "^7.0.14"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/iferr": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz",
+ "integrity": "sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA==",
+ "dev": true
+ },
+ "node_modules/ignore": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
+ "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/import-fresh/node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/infer-owner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
+ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
+ "dev": true
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
+ "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
+ "dev": true
+ },
+ "node_modules/internal-slot": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+ "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+ "dev": true,
+ "dependencies": {
+ "get-intrinsic": "^1.1.0",
+ "has": "^1.0.3",
+ "side-channel": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/interpret": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
+ "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/ip": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
+ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
+ "dev": true
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-absolute-url": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz",
+ "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-accessor-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
+ "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-alphabetical": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
+ "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
+ "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
+ "dev": true,
+ "dependencies": {
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-arguments": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+ "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true
+ },
+ "node_modules/is-bigint": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+ "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+ "dev": true,
+ "dependencies": {
+ "has-bigints": "^1.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+ "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-buffer": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
+ "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-ci": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz",
+ "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==",
+ "dev": true,
+ "dependencies": {
+ "ci-info": "^2.0.0"
+ },
+ "bin": {
+ "is-ci": "bin.js"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz",
+ "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==",
+ "dev": true,
+ "dependencies": {
+ "has": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
+ "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+ "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+ "dev": true,
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
+ "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-descriptor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
+ "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^1.0.0",
+ "is-data-descriptor": "^1.0.0",
+ "kind-of": "^6.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-dom": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-dom/-/is-dom-1.1.0.tgz",
+ "integrity": "sha512-u82f6mvhYxRPKpw8V1N0W8ce1xXwOrQtgGcxl6UCL5zBmZu3is/18K0rR7uFCnMDuAsS/3W54mGL4vsaFUQlEQ==",
+ "dev": true,
+ "dependencies": {
+ "is-object": "^1.0.1",
+ "is-window": "^1.0.2"
+ }
+ },
+ "node_modules/is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "dev": true,
+ "dependencies": {
+ "is-plain-object": "^2.0.4"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finite": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz",
+ "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-function": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
+ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
+ "dev": true
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
+ "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
+ "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz",
+ "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==",
+ "dev": true,
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-object": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
+ "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dev": true,
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
+ "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz",
+ "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+ "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+ "dev": true,
+ "dependencies": {
+ "has-symbols": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typedarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+ "dev": true
+ },
+ "node_modules/is-utf8": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz",
+ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/is-weakref": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+ "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-whitespace-character": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz",
+ "integrity": "sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-window": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz",
+ "integrity": "sha512-uj00kdXyZb9t9RcAUAwMZAnkBUwdYGhYlt7djMXhfyhUCzwNba50tIiBKR7q0l7tdoBtFVw/3JmLY6fI3rmZmg==",
+ "dev": true
+ },
+ "node_modules/is-windows": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-word-character": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz",
+ "integrity": "sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isomorphic-unfetch": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz",
+ "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==",
+ "dev": true,
+ "dependencies": {
+ "node-fetch": "^2.6.1",
+ "unfetch": "^4.2.0"
+ }
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+ "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-instrument": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz",
+ "integrity": "sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.3",
+ "@babel/parser": "^7.14.7",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/iterate-iterator": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.2.tgz",
+ "integrity": "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/iterate-value": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz",
+ "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==",
+ "dev": true,
+ "dependencies": {
+ "es-get-iterator": "^1.0.2",
+ "iterate-iterator": "^1.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/jest-haste-map": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz",
+ "integrity": "sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==",
+ "dev": true,
+ "dependencies": {
+ "@jest/types": "^26.6.2",
+ "@types/graceful-fs": "^4.1.2",
+ "@types/node": "*",
+ "anymatch": "^3.0.3",
+ "fb-watchman": "^2.0.0",
+ "graceful-fs": "^4.2.4",
+ "jest-regex-util": "^26.0.0",
+ "jest-serializer": "^26.6.2",
+ "jest-util": "^26.6.2",
+ "jest-worker": "^26.6.2",
+ "micromatch": "^4.0.2",
+ "sane": "^4.0.3",
+ "walker": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ },
+ "optionalDependencies": {
+ "fsevents": "^2.1.2"
+ }
+ },
+ "node_modules/jest-regex-util": {
+ "version": "26.0.0",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz",
+ "integrity": "sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/jest-serializer": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz",
+ "integrity": "sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "graceful-fs": "^4.2.4"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/jest-util": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz",
+ "integrity": "sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==",
+ "dev": true,
+ "dependencies": {
+ "@jest/types": "^26.6.2",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.4",
+ "is-ci": "^2.0.0",
+ "micromatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/jest-util/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/jest-util/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/jest-util/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/jest-util/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/jest-util/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jest-util/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jest-worker": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
+ "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/jest-worker/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jest-worker/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-string-escape": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz",
+ "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/json-parse-better-errors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+ "dev": true
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/json5": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
+ "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
+ "dev": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+ "dev": true,
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/junk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
+ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/klona": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
+ "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/lazy-universal-dotenv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lazy-universal-dotenv/-/lazy-universal-dotenv-3.0.1.tgz",
+ "integrity": "sha512-prXSYk799h3GY3iOWnC6ZigYzMPjxN2svgjJ9shk7oMadSNX3wXy0B6F32PMJv7qtMnrIbUxoEHzbutvxR2LBQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.5.0",
+ "app-root-dir": "^1.0.2",
+ "core-js": "^3.0.4",
+ "dotenv": "^8.0.0",
+ "dotenv-expand": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=6.0.0",
+ "npm": ">=6.0.0",
+ "yarn": ">=1.0.0"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true
+ },
+ "node_modules/lit": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-2.3.1.tgz",
+ "integrity": "sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw==",
+ "dev": true,
+ "dependencies": {
+ "@lit/reactive-element": "^1.4.0",
+ "lit-element": "^3.2.0",
+ "lit-html": "^2.3.0"
+ }
+ },
+ "node_modules/lit-element": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.2.tgz",
+ "integrity": "sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==",
+ "dev": true,
+ "dependencies": {
+ "@lit/reactive-element": "^1.3.0",
+ "lit-html": "^2.2.0"
+ }
+ },
+ "node_modules/lit-html": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.3.1.tgz",
+ "integrity": "sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA==",
+ "dev": true,
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
+ "node_modules/load-json-file": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
+ "integrity": "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "parse-json": "^2.2.0",
+ "pify": "^2.0.0",
+ "pinkie-promise": "^2.0.0",
+ "strip-bom": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/load-json-file/node_modules/parse-json": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
+ "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "error-ex": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/load-json-file/node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/loader-runner": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
+ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.11.5"
+ }
+ },
+ "node_modules/loader-utils": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
+ "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=8.9.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true
+ },
+ "node_modules/lodash.uniq": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+ "dev": true
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/loud-rejection": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz",
+ "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "currently-unhandled": "^0.4.1",
+ "signal-exit": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/lower-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+ "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/makeerror": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
+ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+ "dev": true,
+ "dependencies": {
+ "tmpl": "1.0.5"
+ }
+ },
+ "node_modules/map-age-cleaner": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz",
+ "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==",
+ "dev": true,
+ "dependencies": {
+ "p-defer": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/map-cache": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
+ "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/map-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
+ "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/map-or-similar": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz",
+ "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==",
+ "dev": true
+ },
+ "node_modules/map-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz",
+ "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==",
+ "dev": true,
+ "dependencies": {
+ "object-visit": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/markdown-escapes": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz",
+ "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/md5.js": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
+ "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
+ "dev": true,
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/mdast-squeeze-paragraphs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz",
+ "integrity": "sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ==",
+ "dev": true,
+ "dependencies": {
+ "unist-util-remove": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-definitions": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz",
+ "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==",
+ "dev": true,
+ "dependencies": {
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz",
+ "integrity": "sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA==",
+ "dev": true,
+ "dependencies": {
+ "@types/mdast": "^3.0.0",
+ "@types/unist": "^2.0.0",
+ "mdast-util-definitions": "^4.0.0",
+ "mdurl": "^1.0.0",
+ "unist-builder": "^2.0.0",
+ "unist-util-generated": "^1.0.0",
+ "unist-util-position": "^3.0.0",
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz",
+ "integrity": "sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
+ "dev": true
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mem": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz",
+ "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==",
+ "dev": true,
+ "dependencies": {
+ "map-age-cleaner": "^0.1.3",
+ "mimic-fn": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/mem?sponsor=1"
+ }
+ },
+ "node_modules/mem/node_modules/mimic-fn": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
+ "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/memfs": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.7.tgz",
+ "integrity": "sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==",
+ "dev": true,
+ "dependencies": {
+ "fs-monkey": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/memoizerific": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz",
+ "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==",
+ "dev": true,
+ "dependencies": {
+ "map-or-similar": "^1.5.0"
+ }
+ },
+ "node_modules/memory-fs": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
+ "integrity": "sha512-cda4JKCxReDXFXRqOHPQscuIYg1PvxbE2S2GP45rnwfEK+vZaXC8C1OFvdHIbgw0DLzowXGVoxLaAmlgRy14GQ==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ },
+ "node_modules/memory-fs/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/memory-fs/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/memory-fs/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/meow": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
+ "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "camelcase-keys": "^2.0.0",
+ "decamelize": "^1.1.2",
+ "loud-rejection": "^1.0.0",
+ "map-obj": "^1.0.1",
+ "minimist": "^1.1.3",
+ "normalize-package-data": "^2.3.4",
+ "object-assign": "^4.0.1",
+ "read-pkg-up": "^1.0.1",
+ "redent": "^1.0.0",
+ "trim-newlines": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/find-up": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz",
+ "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "path-exists": "^2.0.0",
+ "pinkie-promise": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/path-exists": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz",
+ "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "pinkie-promise": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/path-type": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz",
+ "integrity": "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "pify": "^2.0.0",
+ "pinkie-promise": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/read-pkg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",
+ "integrity": "sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "load-json-file": "^1.0.0",
+ "normalize-package-data": "^2.3.2",
+ "path-type": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/read-pkg-up": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz",
+ "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "find-up": "^1.0.0",
+ "read-pkg": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+ "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
+ "dev": true
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/microevent.ts": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/microevent.ts/-/microevent.ts-0.1.1.tgz",
+ "integrity": "sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g==",
+ "dev": true
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+ "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+ "dev": true,
+ "dependencies": {
+ "braces": "^3.0.2",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/miller-rabin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
+ "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "brorand": "^1.0.1"
+ },
+ "bin": {
+ "miller-rabin": "bin/miller-rabin"
+ }
+ },
+ "node_modules/miller-rabin/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true,
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/min-document": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+ "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
+ "dev": true,
+ "dependencies": {
+ "dom-walk": "^0.1.0"
+ }
+ },
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "dev": true
+ },
+ "node_modules/minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
+ "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
+ "dev": true
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
+ "dev": true
+ },
+ "node_modules/minipass": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.4.tgz",
+ "integrity": "sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz",
+ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+ "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/mississippi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz",
+ "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==",
+ "dev": true,
+ "dependencies": {
+ "concat-stream": "^1.5.0",
+ "duplexify": "^3.4.2",
+ "end-of-stream": "^1.1.0",
+ "flush-write-stream": "^1.0.0",
+ "from2": "^2.1.0",
+ "parallel-transform": "^1.1.0",
+ "pump": "^3.0.0",
+ "pumpify": "^1.3.3",
+ "stream-each": "^1.1.0",
+ "through2": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/mixin-deep": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
+ "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
+ "dev": true,
+ "dependencies": {
+ "for-in": "^1.0.2",
+ "is-extendable": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/move-concurrently": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
+ "integrity": "sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.1.1",
+ "copy-concurrently": "^1.0.0",
+ "fs-write-stream-atomic": "^1.0.8",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.4",
+ "run-queue": "^1.0.3"
+ }
+ },
+ "node_modules/move-concurrently/node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "node_modules/move-concurrently/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/move-concurrently/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/nan": {
+ "version": "2.16.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.16.0.tgz",
+ "integrity": "sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
+ "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
+ "dev": true,
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/nanomatch": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
+ "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "fragment-cache": "^0.2.1",
+ "is-windows": "^1.0.2",
+ "kind-of": "^6.0.2",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true
+ },
+ "node_modules/nested-error-stacks": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz",
+ "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==",
+ "dev": true
+ },
+ "node_modules/nice-try": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
+ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
+ "dev": true
+ },
+ "node_modules/no-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+ "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+ "dev": true,
+ "dependencies": {
+ "lower-case": "^2.0.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
+ "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
+ "dev": true,
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "dev": true
+ },
+ "node_modules/node-libs-browser": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
+ "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==",
+ "dev": true,
+ "dependencies": {
+ "assert": "^1.1.1",
+ "browserify-zlib": "^0.2.0",
+ "buffer": "^4.3.0",
+ "console-browserify": "^1.1.0",
+ "constants-browserify": "^1.0.0",
+ "crypto-browserify": "^3.11.0",
+ "domain-browser": "^1.1.1",
+ "events": "^3.0.0",
+ "https-browserify": "^1.0.0",
+ "os-browserify": "^0.3.0",
+ "path-browserify": "0.0.1",
+ "process": "^0.11.10",
+ "punycode": "^1.2.4",
+ "querystring-es3": "^0.2.0",
+ "readable-stream": "^2.3.3",
+ "stream-browserify": "^2.0.1",
+ "stream-http": "^2.7.2",
+ "string_decoder": "^1.0.0",
+ "timers-browserify": "^2.0.4",
+ "tty-browserify": "0.0.0",
+ "url": "^0.11.0",
+ "util": "^0.11.0",
+ "vm-browserify": "^1.0.1"
+ }
+ },
+ "node_modules/node-libs-browser/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/node-libs-browser/node_modules/path-browserify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz",
+ "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==",
+ "dev": true
+ },
+ "node_modules/node-libs-browser/node_modules/punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
+ "dev": true
+ },
+ "node_modules/node-libs-browser/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/node-libs-browser/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz",
+ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
+ "dev": true
+ },
+ "node_modules/normalize-package-data": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+ "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+ "dev": true,
+ "dependencies": {
+ "hosted-git-info": "^2.1.4",
+ "resolve": "^1.10.0",
+ "semver": "2 || 3 || 4 || 5",
+ "validate-npm-package-license": "^3.0.1"
+ }
+ },
+ "node_modules/normalize-package-data/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "dev": true,
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/num2fraction": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
+ "integrity": "sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==",
+ "dev": true
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
+ "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==",
+ "dev": true,
+ "dependencies": {
+ "copy-descriptor": "^0.1.0",
+ "define-property": "^0.2.5",
+ "kind-of": "^3.0.3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/object-copy/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/is-descriptor/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz",
+ "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object-visit": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
+ "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==",
+ "dev": true,
+ "dependencies": {
+ "isobject": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz",
+ "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "has-symbols": "^1.0.3",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz",
+ "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz",
+ "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.getownpropertydescriptors": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.4.tgz",
+ "integrity": "sha512-sccv3L/pMModT6dJAYF3fzGMVcb38ysQ0tEE6ixv2yXJDtEIPph268OlAdJj5/qZMZDq2g/jqvwppt36uS/uQQ==",
+ "dev": true,
+ "dependencies": {
+ "array.prototype.reduce": "^1.0.4",
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.20.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.pick": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
+ "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
+ "dev": true,
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+ "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
+ "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/open": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz",
+ "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==",
+ "dev": true,
+ "dependencies": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/os-browserify": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
+ "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==",
+ "dev": true
+ },
+ "node_modules/os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/p-all": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-all/-/p-all-2.1.0.tgz",
+ "integrity": "sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA==",
+ "dev": true,
+ "dependencies": {
+ "p-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/p-all/node_modules/p-map": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/p-defer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
+ "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/p-event": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
+ "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==",
+ "dev": true,
+ "dependencies": {
+ "p-timeout": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-filter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz",
+ "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==",
+ "dev": true,
+ "dependencies": {
+ "p-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-filter/node_modules/p-map": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+ "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+ "dev": true,
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+ "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+ "dev": true,
+ "dependencies": {
+ "p-finally": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true
+ },
+ "node_modules/parallel-transform": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",
+ "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==",
+ "dev": true,
+ "dependencies": {
+ "cyclist": "^1.0.1",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.1.5"
+ }
+ },
+ "node_modules/parallel-transform/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/parallel-transform/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/parallel-transform/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/param-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
+ "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
+ "dev": true,
+ "dependencies": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-asn1": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz",
+ "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==",
+ "dev": true,
+ "dependencies": {
+ "asn1.js": "^5.2.0",
+ "browserify-aes": "^1.0.0",
+ "evp_bytestokey": "^1.0.0",
+ "pbkdf2": "^3.0.3",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
+ "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
+ "dev": true,
+ "dependencies": {
+ "character-entities": "^1.0.0",
+ "character-entities-legacy": "^1.0.0",
+ "character-reference-invalid": "^1.0.0",
+ "is-alphanumerical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-hexadecimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+ "dev": true
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/pascal-case": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
+ "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/pascalcase": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
+ "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true
+ },
+ "node_modules/path-dirname": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz",
+ "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==",
+ "dev": true
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
+ "dev": true
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pbkdf2": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
+ "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==",
+ "dev": true,
+ "dependencies": {
+ "create-hash": "^1.1.2",
+ "create-hmac": "^1.1.4",
+ "ripemd160": "^2.0.1",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
+ "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pinkie": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
+ "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pinkie-promise": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+ "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "pinkie": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz",
+ "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkg-dir": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz",
+ "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pnp-webpack-plugin": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz",
+ "integrity": "sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==",
+ "dev": true,
+ "dependencies": {
+ "ts-pnp": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/polished": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz",
+ "integrity": "sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.17.8"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/posix-character-classes": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
+ "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "7.0.39",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
+ "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
+ "dev": true,
+ "dependencies": {
+ "picocolors": "^0.2.1",
+ "source-map": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ }
+ },
+ "node_modules/postcss-flexbugs-fixes": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-4.2.1.tgz",
+ "integrity": "sha512-9SiofaZ9CWpQWxOwRh1b/r85KD5y7GgvsNt1056k6OYLvWUun0czCvogfJgylC22uJTwW1KzY3Gz65NZRlvoiQ==",
+ "dev": true,
+ "dependencies": {
+ "postcss": "^7.0.26"
+ }
+ },
+ "node_modules/postcss-loader": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-4.3.0.tgz",
+ "integrity": "sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==",
+ "dev": true,
+ "dependencies": {
+ "cosmiconfig": "^7.0.0",
+ "klona": "^2.0.4",
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0",
+ "semver": "^7.3.4"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "postcss": "^7.0.0 || ^8.0.1",
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/postcss-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/postcss-loader/node_modules/semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/postcss-modules-extract-imports": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz",
+ "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==",
+ "dev": true,
+ "dependencies": {
+ "postcss": "^7.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss-modules-local-by-default": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz",
+ "integrity": "sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^4.1.1",
+ "postcss": "^7.0.32",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss-modules-scope": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz",
+ "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==",
+ "dev": true,
+ "dependencies": {
+ "postcss": "^7.0.6",
+ "postcss-selector-parser": "^6.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss-modules-values": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz",
+ "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^4.0.0",
+ "postcss": "^7.0.6"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true
+ },
+ "node_modules/prettier": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.0.tgz",
+ "integrity": "sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w==",
+ "dev": true,
+ "bin": {
+ "prettier": "bin-prettier.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/pretty-error": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
+ "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.20",
+ "renderkid": "^3.0.0"
+ }
+ },
+ "node_modules/pretty-hrtime": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
+ "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true
+ },
+ "node_modules/promise-inflight": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+ "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+ "dev": true
+ },
+ "node_modules/promise.allsettled": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.5.tgz",
+ "integrity": "sha512-tVDqeZPoBC0SlzJHzWGZ2NKAguVq2oiYj7gbggbiTvH2itHohijTp7njOUA0aQ/nl+0lr/r6egmhoYu63UZ/pQ==",
+ "dev": true,
+ "dependencies": {
+ "array.prototype.map": "^1.0.4",
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "iterate-value": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/promise.prototype.finally": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/promise.prototype.finally/-/promise.prototype.finally-3.1.3.tgz",
+ "integrity": "sha512-EXRF3fC9/0gz4qkt/f5EP5iW4kj9oFpBICNpCNOb/52+8nlHIX07FPLbi/q4qYBQ1xZqivMzTpNQSnArVASolQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "dependencies": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
+ "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
+ "dev": true,
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/prr": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
+ "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
+ "dev": true
+ },
+ "node_modules/public-encrypt": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
+ "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "browserify-rsa": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "parse-asn1": "^5.0.0",
+ "randombytes": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/public-encrypt/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/pump": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+ "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/pumpify": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz",
+ "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==",
+ "dev": true,
+ "dependencies": {
+ "duplexify": "^3.6.0",
+ "inherits": "^2.0.3",
+ "pump": "^2.0.0"
+ }
+ },
+ "node_modules/pumpify/node_modules/pump": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz",
+ "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.11.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+ "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/querystring": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+ "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
+ "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/querystring-es3": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz",
+ "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/ramda": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.28.0.tgz",
+ "integrity": "sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ramda"
+ }
+ },
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/randomfill": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
+ "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.0.5",
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
+ "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-body/node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-loader": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+ "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/raw-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.0"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true
+ },
+ "node_modules/read-pkg": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
+ "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
+ "dev": true,
+ "dependencies": {
+ "@types/normalize-package-data": "^2.4.0",
+ "normalize-package-data": "^2.5.0",
+ "parse-json": "^5.0.0",
+ "type-fest": "^0.6.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg-up": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
+ "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.1.0",
+ "read-pkg": "^5.2.0",
+ "type-fest": "^0.8.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/type-fest": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+ "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg/node_modules/type-fest": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
+ "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+ "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/redent": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz",
+ "integrity": "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "indent-string": "^2.1.0",
+ "strip-indent": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/redent/node_modules/indent-string": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz",
+ "integrity": "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "repeating": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true
+ },
+ "node_modules/regenerate-unicode-properties": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz",
+ "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==",
+ "dev": true,
+ "dependencies": {
+ "regenerate": "^1.4.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.9",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
+ "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==",
+ "dev": true
+ },
+ "node_modules/regenerator-transform": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz",
+ "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.8.4"
+ }
+ },
+ "node_modules/regex-not": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
+ "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^3.0.2",
+ "safe-regex": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
+ "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "functions-have-names": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexpu-core": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.1.tgz",
+ "integrity": "sha512-HrnlNtpvqP1Xkb28tMhBUO2EbyUHdQlsnlAhzWcwHy8WJR53UWr7/MAvqrsQKMbV4qdpv03oTMG8iIhfsPFktQ==",
+ "dev": true,
+ "dependencies": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.1.0",
+ "regjsgen": "^0.7.1",
+ "regjsparser": "^0.9.1",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regjsgen": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz",
+ "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==",
+ "dev": true
+ },
+ "node_modules/regjsparser": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz",
+ "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==",
+ "dev": true,
+ "dependencies": {
+ "jsesc": "~0.5.0"
+ },
+ "bin": {
+ "regjsparser": "bin/parser"
+ }
+ },
+ "node_modules/regjsparser/node_modules/jsesc": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+ "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ }
+ },
+ "node_modules/relateurl": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
+ "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/remark-external-links": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz",
+ "integrity": "sha512-5vPSX0kHoSsqtdftSHhIYofVINC8qmp0nctkeU9YoJwV3YfiBRiI6cbFRJ0oI/1F9xS+bopXG0m2KS8VFscuKA==",
+ "dev": true,
+ "dependencies": {
+ "extend": "^3.0.0",
+ "is-absolute-url": "^3.0.0",
+ "mdast-util-definitions": "^4.0.0",
+ "space-separated-tokens": "^1.0.0",
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-footnotes": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/remark-footnotes/-/remark-footnotes-2.0.0.tgz",
+ "integrity": "sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-mdx": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-1.6.22.tgz",
+ "integrity": "sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "7.12.9",
+ "@babel/helper-plugin-utils": "7.10.4",
+ "@babel/plugin-proposal-object-rest-spread": "7.12.1",
+ "@babel/plugin-syntax-jsx": "7.12.1",
+ "@mdx-js/util": "1.6.22",
+ "is-alphabetical": "1.0.4",
+ "remark-parse": "8.0.3",
+ "unified": "9.2.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/@babel/core": {
+ "version": "7.12.9",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz",
+ "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/generator": "^7.12.5",
+ "@babel/helper-module-transforms": "^7.12.1",
+ "@babel/helpers": "^7.12.5",
+ "@babel/parser": "^7.12.7",
+ "@babel/template": "^7.12.7",
+ "@babel/traverse": "^7.12.9",
+ "@babel/types": "^7.12.7",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.1",
+ "json5": "^2.1.2",
+ "lodash": "^4.17.19",
+ "resolve": "^1.3.2",
+ "semver": "^5.4.1",
+ "source-map": "^0.5.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ },
+ "node_modules/remark-mdx/node_modules/@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz",
+ "integrity": "sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.0",
+ "@babel/plugin-transform-parameters": "^7.12.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz",
+ "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz",
+ "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==",
+ "dev": true,
+ "dependencies": {
+ "ccount": "^1.0.0",
+ "collapse-white-space": "^1.0.2",
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-whitespace-character": "^1.0.0",
+ "is-word-character": "^1.0.0",
+ "markdown-escapes": "^1.0.0",
+ "parse-entities": "^2.0.0",
+ "repeat-string": "^1.5.4",
+ "state-toggle": "^1.0.0",
+ "trim": "0.0.1",
+ "trim-trailing-lines": "^1.0.0",
+ "unherit": "^1.0.4",
+ "unist-util-remove-position": "^2.0.0",
+ "vfile-location": "^3.0.0",
+ "xtend": "^4.0.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-slug": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/remark-slug/-/remark-slug-6.1.0.tgz",
+ "integrity": "sha512-oGCxDF9deA8phWvxFuyr3oSJsdyUAxMFbA0mZ7Y1Sas+emILtO+e5WutF9564gDsEN4IXaQXm5pFo6MLH+YmwQ==",
+ "dev": true,
+ "dependencies": {
+ "github-slugger": "^1.0.0",
+ "mdast-util-to-string": "^1.0.0",
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-squeeze-paragraphs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz",
+ "integrity": "sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw==",
+ "dev": true,
+ "dependencies": {
+ "mdast-squeeze-paragraphs": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remove-trailing-separator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
+ "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==",
+ "dev": true
+ },
+ "node_modules/renderkid": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
+ "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==",
+ "dev": true,
+ "dependencies": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "node_modules/repeat-element": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
+ "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/repeat-string": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
+ "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/repeating": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
+ "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-finite": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.1",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
+ "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+ "dev": true,
+ "dependencies": {
+ "is-core-module": "^2.9.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/resolve-url": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+ "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==",
+ "deprecated": "https://github.com/lydell/resolve-url#deprecated",
+ "dev": true
+ },
+ "node_modules/ret": {
+ "version": "0.1.15",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ripemd160": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
+ "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
+ "dev": true,
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1"
+ }
+ },
+ "node_modules/rsvp": {
+ "version": "4.8.5",
+ "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
+ "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==",
+ "dev": true,
+ "engines": {
+ "node": "6.* || >= 7.*"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/run-queue": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz",
+ "integrity": "sha512-ntymy489o0/QQplUDnpYAYUsO50K9SBrIVaKCWDOJzYJts0f9WH9RFJkyagebkw5+y1oi00R7ynNW/d12GBumg==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.1.1"
+ }
+ },
+ "node_modules/run-queue/node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "node_modules/safe-regex": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+ "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==",
+ "dev": true,
+ "dependencies": {
+ "ret": "~0.1.10"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",
+ "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.3",
+ "is-regex": "^1.1.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true
+ },
+ "node_modules/sane": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz",
+ "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==",
+ "deprecated": "some dependency vulnerabilities fixed, support for node < 10 dropped, and newer ECMAScript syntax/features added",
+ "dev": true,
+ "dependencies": {
+ "@cnakazawa/watch": "^1.0.3",
+ "anymatch": "^2.0.0",
+ "capture-exit": "^2.0.0",
+ "exec-sh": "^0.3.2",
+ "execa": "^1.0.0",
+ "fb-watchman": "^2.0.0",
+ "micromatch": "^3.1.4",
+ "minimist": "^1.1.1",
+ "walker": "~1.0.5"
+ },
+ "bin": {
+ "sane": "src/cli.js"
+ },
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/sane/node_modules/anymatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
+ "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
+ "dev": true,
+ "dependencies": {
+ "micromatch": "^3.1.4",
+ "normalize-path": "^2.1.1"
+ }
+ },
+ "node_modules/sane/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/cross-spawn": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
+ "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
+ "dev": true,
+ "dependencies": {
+ "nice-try": "^1.0.4",
+ "path-key": "^2.0.1",
+ "semver": "^5.5.0",
+ "shebang-command": "^1.2.0",
+ "which": "^1.2.9"
+ },
+ "engines": {
+ "node": ">=4.8"
+ }
+ },
+ "node_modules/sane/node_modules/execa": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
+ "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^6.0.0",
+ "get-stream": "^4.0.0",
+ "is-stream": "^1.1.0",
+ "npm-run-path": "^2.0.0",
+ "p-finally": "^1.0.0",
+ "signal-exit": "^3.0.0",
+ "strip-eof": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/sane/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/get-stream": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+ "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+ "dev": true,
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/sane/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/sane/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/is-stream": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+ "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "dependencies": {
+ "remove-trailing-separator": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/npm-run-path": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
+ "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/sane/node_modules/path-key": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+ "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/sane/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/sane/node_modules/shebang-command": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+ "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/shebang-regex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+ "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+ "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/schema-utils": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
+ "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.5",
+ "ajv": "^6.12.4",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
+ "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/send/node_modules/debug/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/serialize-javascript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+ "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/serve-favicon": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz",
+ "integrity": "sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==",
+ "dev": true,
+ "dependencies": {
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "ms": "2.1.1",
+ "parseurl": "~1.3.2",
+ "safe-buffer": "5.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/serve-favicon/node_modules/ms": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+ "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
+ "dev": true
+ },
+ "node_modules/serve-favicon/node_modules/safe-buffer": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
+ "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
+ "dev": true
+ },
+ "node_modules/serve-static": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
+ "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
+ "dev": true,
+ "dependencies": {
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.18.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "dev": true
+ },
+ "node_modules/set-value": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
+ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-extendable": "^0.1.1",
+ "is-plain-object": "^2.0.3",
+ "split-string": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/set-value/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/set-value/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "dev": true
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true
+ },
+ "node_modules/sha.js": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+ "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ },
+ "bin": {
+ "sha.js": "bin.js"
+ }
+ },
+ "node_modules/shallow-clone": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
+ "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^6.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+ "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true
+ },
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/snapdragon": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
+ "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==",
+ "dev": true,
+ "dependencies": {
+ "base": "^0.11.1",
+ "debug": "^2.2.0",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "map-cache": "^0.2.2",
+ "source-map": "^0.5.6",
+ "source-map-resolve": "^0.5.0",
+ "use": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon-node": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz",
+ "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==",
+ "dev": true,
+ "dependencies": {
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.0",
+ "snapdragon-util": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon-node/node_modules/define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon-util": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz",
+ "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon-util/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/snapdragon-util/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-accessor-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/snapdragon/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-data-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/snapdragon/node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-list-map": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
+ "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==",
+ "dev": true
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-resolve": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
+ "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
+ "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated",
+ "dev": true,
+ "dependencies": {
+ "atob": "^2.1.2",
+ "decode-uri-component": "^0.2.0",
+ "resolve-url": "^0.2.1",
+ "source-map-url": "^0.4.0",
+ "urix": "^0.1.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/source-map-url": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
+ "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
+ "deprecated": "See https://github.com/lydell/source-map-url#deprecated",
+ "dev": true
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
+ "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/spdx-correct": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+ "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+ "dev": true,
+ "dependencies": {
+ "spdx-expression-parse": "^3.0.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/spdx-exceptions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+ "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+ "dev": true
+ },
+ "node_modules/spdx-expression-parse": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+ "dev": true,
+ "dependencies": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/spdx-license-ids": {
+ "version": "3.0.12",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz",
+ "integrity": "sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==",
+ "dev": true
+ },
+ "node_modules/split-string": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true
+ },
+ "node_modules/ssri": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
+ "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/stable": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
+ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
+ "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility",
+ "dev": true
+ },
+ "node_modules/state-toggle": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz",
+ "integrity": "sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/static-extend": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
+ "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==",
+ "dev": true,
+ "dependencies": {
+ "define-property": "^0.2.5",
+ "object-copy": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-accessor-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/static-extend/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-data-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/store2": {
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.2.tgz",
+ "integrity": "sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==",
+ "dev": true
+ },
+ "node_modules/stream-browserify": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
+ "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "~2.0.1",
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "node_modules/stream-browserify/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/stream-browserify/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/stream-browserify/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/stream-each": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz",
+ "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "stream-shift": "^1.0.0"
+ }
+ },
+ "node_modules/stream-http": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz",
+ "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==",
+ "dev": true,
+ "dependencies": {
+ "builtin-status-codes": "^3.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.3.6",
+ "to-arraybuffer": "^1.0.0",
+ "xtend": "^4.0.0"
+ }
+ },
+ "node_modules/stream-http/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/stream-http/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/stream-http/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/stream-shift": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
+ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==",
+ "dev": true
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz",
+ "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "regexp.prototype.flags": "^1.4.1",
+ "side-channel": "^1.0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.padend": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz",
+ "integrity": "sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.padstart": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/string.prototype.padstart/-/string.prototype.padstart-3.1.3.tgz",
+ "integrity": "sha512-NZydyOMtYxpTjGqp0VN5PYUF/tsU15yDMZnUdj16qRUIUiMJkHHSDElYyQFrMu+/WloTpA7MQSiADhBicDfaoA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz",
+ "integrity": "sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz",
+ "integrity": "sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz",
+ "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-utf8": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-eof": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+ "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz",
+ "integrity": "sha512-I5iQq6aFMM62fBEAIB/hXzwJD6EEZ0xEGCX2t7oXqaKPIRgt4WruAQ285BISgdkP+HLGWyeGmNJcpIwFeRYRUA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "get-stdin": "^4.0.1"
+ },
+ "bin": {
+ "strip-indent": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/style-loader": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
+ "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/style-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz",
+ "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==",
+ "dev": true,
+ "dependencies": {
+ "inline-style-parser": "0.1.1"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/symbol.prototype.description": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/symbol.prototype.description/-/symbol.prototype.description-1.0.5.tgz",
+ "integrity": "sha512-x738iXRYsrAt9WBhRCVG5BtIC3B7CUkFwbHW2zOvGtwM33s7JjrCDyq8V0zgMYVb5ymsL8+qkzzpANH63CPQaQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-symbol-description": "^1.0.0",
+ "has-symbols": "^1.0.2",
+ "object.getownpropertydescriptors": "^2.1.2"
+ },
+ "engines": {
+ "node": ">= 0.11.15"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/synchronous-promise": {
+ "version": "2.0.16",
+ "resolved": "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.16.tgz",
+ "integrity": "sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A==",
+ "dev": true
+ },
+ "node_modules/tapable": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",
+ "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.1.11",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
+ "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
+ "dev": true,
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^3.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/telejson": {
+ "version": "6.0.8",
+ "resolved": "https://registry.npmjs.org/telejson/-/telejson-6.0.8.tgz",
+ "integrity": "sha512-nerNXi+j8NK1QEfBHtZUN/aLdDcyupA//9kAboYLrtzZlPLpUfqbVGWb9zz91f/mIjRbAYhbgtnJHY8I1b5MBg==",
+ "dev": true,
+ "dependencies": {
+ "@types/is-function": "^1.0.0",
+ "global": "^4.4.0",
+ "is-function": "^1.0.2",
+ "is-regex": "^1.1.2",
+ "is-symbol": "^1.0.3",
+ "isobject": "^4.0.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3"
+ }
+ },
+ "node_modules/telejson/node_modules/isobject": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz",
+ "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.15.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.0.tgz",
+ "integrity": "sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.2",
+ "acorn": "^8.5.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/terser-webpack-plugin": {
+ "version": "5.3.6",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz",
+ "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.14",
+ "jest-worker": "^27.4.5",
+ "schema-utils": "^3.1.1",
+ "serialize-javascript": "^6.0.0",
+ "terser": "^5.14.1"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "uglify-js": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/jest-worker": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
+ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/test-exclude": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+ "dev": true,
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^7.1.4",
+ "minimatch": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/through2": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
+ "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
+ "dev": true,
+ "dependencies": {
+ "readable-stream": "~2.3.6",
+ "xtend": "~4.0.1"
+ }
+ },
+ "node_modules/through2/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/through2/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/through2/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/timers-browserify": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz",
+ "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==",
+ "dev": true,
+ "dependencies": {
+ "setimmediate": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
+ "node_modules/tmpl": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
+ "dev": true
+ },
+ "node_modules/to-arraybuffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
+ "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==",
+ "dev": true
+ },
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/to-object-path": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
+ "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/to-object-path/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/to-object-path/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/to-regex": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz",
+ "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==",
+ "dev": true,
+ "dependencies": {
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "regex-not": "^1.0.2",
+ "safe-regex": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "dev": true
+ },
+ "node_modules/trim": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz",
+ "integrity": "sha512-YzQV+TZg4AxpKxaTHK3c3D+kRDCGVEE7LemdlQZoQXn0iennk10RsIoY6ikzAqJTc9Xjl9C1/waHom/J86ziAQ==",
+ "dev": true
+ },
+ "node_modules/trim-newlines": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
+ "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/trim-trailing-lines": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz",
+ "integrity": "sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz",
+ "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ts-dedent": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
+ "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.10"
+ }
+ },
+ "node_modules/ts-pnp": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
+ "integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
+ "dev": true
+ },
+ "node_modules/tty-browserify": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
+ "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==",
+ "dev": true
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "dev": true
+ },
+ "node_modules/typedarray-to-buffer": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+ "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+ "dev": true,
+ "dependencies": {
+ "is-typedarray": "^1.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
+ "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+ "dev": true,
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=4.2.0"
+ }
+ },
+ "node_modules/uglify-js": {
+ "version": "3.17.2",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.2.tgz",
+ "integrity": "sha512-bbxglRjsGQMchfvXZNusUcYgiB9Hx2K4AHYXQy2DITZ9Rd+JzhX7+hoocE5Winr7z2oHvPsekkBwXtigvxevXg==",
+ "dev": true,
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
+ "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.0.3",
+ "which-boxed-primitive": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/unfetch": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
+ "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
+ "dev": true
+ },
+ "node_modules/unherit": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz",
+ "integrity": "sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.0",
+ "xtend": "^4.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "dependencies": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-value-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-property-aliases-ecmascript": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
+ "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unified": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz",
+ "integrity": "sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==",
+ "dev": true,
+ "dependencies": {
+ "bail": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-buffer": "^2.0.0",
+ "is-plain-obj": "^2.0.0",
+ "trough": "^1.0.0",
+ "vfile": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/union-value": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
+ "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
+ "dev": true,
+ "dependencies": {
+ "arr-union": "^3.1.0",
+ "get-value": "^2.0.6",
+ "is-extendable": "^0.1.1",
+ "set-value": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/union-value/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unique-filename": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
+ "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==",
+ "dev": true,
+ "dependencies": {
+ "unique-slug": "^2.0.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz",
+ "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==",
+ "dev": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ }
+ },
+ "node_modules/unist-builder": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz",
+ "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-generated": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz",
+ "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz",
+ "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz",
+ "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-remove": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-2.1.0.tgz",
+ "integrity": "sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q==",
+ "dev": true,
+ "dependencies": {
+ "unist-util-is": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-remove-position": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz",
+ "integrity": "sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==",
+ "dev": true,
+ "dependencies": {
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
+ "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz",
+ "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "unist-util-is": "^4.0.0",
+ "unist-util-visit-parents": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz",
+ "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "unist-util-is": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+ "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/unset-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",
+ "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==",
+ "dev": true,
+ "dependencies": {
+ "has-value": "^0.3.1",
+ "isobject": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unset-value/node_modules/has-value": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz",
+ "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==",
+ "dev": true,
+ "dependencies": {
+ "get-value": "^2.0.3",
+ "has-values": "^0.1.4",
+ "isobject": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unset-value/node_modules/has-value/node_modules/isobject": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
+ "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==",
+ "dev": true,
+ "dependencies": {
+ "isarray": "1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unset-value/node_modules/has-values": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz",
+ "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unset-value/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/untildify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/untildify/-/untildify-2.1.0.tgz",
+ "integrity": "sha512-sJjbDp2GodvkB0FZZcn7k6afVisqX5BZD7Yq3xp4nN2O15BBK0cLm3Vwn2vQaF7UDS0UUsrQMkkplmDI5fskig==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "os-homedir": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/upath": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=4",
+ "yarn": "*"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz",
+ "integrity": "sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.1.1",
+ "picocolors": "^1.0.0"
+ },
+ "bin": {
+ "browserslist-lint": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/update-browserslist-db/node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/urix": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
+ "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==",
+ "deprecated": "Please see https://github.com/lydell/urix#deprecated",
+ "dev": true
+ },
+ "node_modules/url": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+ "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "1.3.2",
+ "querystring": "0.2.0"
+ }
+ },
+ "node_modules/url-loader": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
+ "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "mime-types": "^2.1.27",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "file-loader": "*",
+ "webpack": "^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "file-loader": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/url-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/url/node_modules/punycode": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+ "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
+ "dev": true
+ },
+ "node_modules/use": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
+ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/util": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
+ "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "2.0.3"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true
+ },
+ "node_modules/util.promisify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz",
+ "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==",
+ "dev": true,
+ "dependencies": {
+ "define-properties": "^1.1.2",
+ "object.getownpropertydescriptors": "^2.0.3"
+ }
+ },
+ "node_modules/util/node_modules/inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "dev": true
+ },
+ "node_modules/utila": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz",
+ "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==",
+ "dev": true
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
+ "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
+ "dev": true,
+ "bin": {
+ "uuid": "bin/uuid"
+ }
+ },
+ "node_modules/uuid-browser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/uuid-browser/-/uuid-browser-3.1.0.tgz",
+ "integrity": "sha512-dsNgbLaTrd6l3MMxTtouOCFw4CBFc/3a+GgYA2YyrJvyQ1u6q4pcu3ktLoUZ/VN/Aw9WsauazbgsgdfVWgAKQg==",
+ "dev": true
+ },
+ "node_modules/validate-npm-package-license": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+ "dev": true,
+ "dependencies": {
+ "spdx-correct": "^3.0.0",
+ "spdx-expression-parse": "^3.0.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz",
+ "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "is-buffer": "^2.0.0",
+ "unist-util-stringify-position": "^2.0.0",
+ "vfile-message": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-location": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-3.2.0.tgz",
+ "integrity": "sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz",
+ "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "unist-util-stringify-position": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vm-browserify": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
+ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
+ "dev": true
+ },
+ "node_modules/walker": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
+ "dev": true,
+ "dependencies": {
+ "makeerror": "1.0.12"
+ }
+ },
+ "node_modules/watchpack": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
+ "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
+ "dev": true,
+ "dependencies": {
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.1.2"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz",
+ "integrity": "sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "chokidar": "^2.1.8"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/anymatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
+ "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "micromatch": "^3.1.4",
+ "normalize-path": "^2.1.1"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/anymatch/node_modules/normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "remove-trailing-separator": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/binary-extensions": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz",
+ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/chokidar": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz",
+ "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==",
+ "deprecated": "Chokidar 2 does not receive security updates since 2019. Upgrade to chokidar 3 with 15x fewer dependencies",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "anymatch": "^2.0.0",
+ "async-each": "^1.0.1",
+ "braces": "^2.3.2",
+ "glob-parent": "^3.1.0",
+ "inherits": "^2.0.3",
+ "is-binary-path": "^1.0.0",
+ "is-glob": "^4.0.0",
+ "normalize-path": "^3.0.0",
+ "path-is-absolute": "^1.0.0",
+ "readdirp": "^2.2.1",
+ "upath": "^1.1.1"
+ },
+ "optionalDependencies": {
+ "fsevents": "^1.2.7"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/fsevents": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
+ "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
+ "deprecated": "fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fsevents 2.",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ },
+ "engines": {
+ "node": ">= 4.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-glob": "^3.1.0",
+ "path-dirname": "^1.0.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/glob-parent/node_modules/is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-extglob": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-binary-path": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz",
+ "integrity": "sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "binary-extensions": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/watchpack-chokidar2/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/readdirp": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz",
+ "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.11",
+ "micromatch": "^3.1.10",
+ "readable-stream": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/web-namespaces": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz",
+ "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "dev": true
+ },
+ "node_modules/webpack": {
+ "version": "5.74.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz",
+ "integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==",
+ "dev": true,
+ "dependencies": {
+ "@types/eslint-scope": "^3.7.3",
+ "@types/estree": "^0.0.51",
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/wasm-edit": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1",
+ "acorn": "^8.7.1",
+ "acorn-import-assertions": "^1.7.6",
+ "browserslist": "^4.14.5",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^5.10.0",
+ "es-module-lexer": "^0.9.0",
+ "eslint-scope": "5.1.1",
+ "events": "^3.2.0",
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.2.9",
+ "json-parse-even-better-errors": "^2.3.1",
+ "loader-runner": "^4.2.0",
+ "mime-types": "^2.1.27",
+ "neo-async": "^2.6.2",
+ "schema-utils": "^3.1.0",
+ "tapable": "^2.1.1",
+ "terser-webpack-plugin": "^5.1.3",
+ "watchpack": "^2.4.0",
+ "webpack-sources": "^3.2.3"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webpack-dev-middleware": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-4.3.0.tgz",
+ "integrity": "sha512-PjwyVY95/bhBh6VUqt6z4THplYcsvQ8YNNBTBM873xLVmw8FLeALn0qurHbs9EmcfhzQis/eoqypSnZeuUz26w==",
+ "dev": true,
+ "dependencies": {
+ "colorette": "^1.2.2",
+ "mem": "^8.1.1",
+ "memfs": "^3.2.2",
+ "mime-types": "^2.1.30",
+ "range-parser": "^1.2.1",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= v10.23.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/webpack-dev-middleware/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/webpack-hot-middleware": {
+ "version": "2.25.2",
+ "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.2.tgz",
+ "integrity": "sha512-CVgm3NAQyfdIonRvXisRwPTUYuSbyZ6BY7782tMeUzWOO7RmVI2NaBYuCp41qyD4gYCkJyTneAJdK69A13B0+A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-html-community": "0.0.8",
+ "html-entities": "^2.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ },
+ "node_modules/webpack-log": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz",
+ "integrity": "sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-colors": "^3.0.0",
+ "uuid": "^3.3.2"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/webpack-sources": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
+ "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.4.5.tgz",
+ "integrity": "sha512-8bWq0Iluiv9lVf9YaqWQ9+liNgXSHICm+rg544yRgGYaR8yXZTVBaHZkINZSB2yZSWo4b0F6MIxqJezVfOEAlg==",
+ "dev": true
+ },
+ "node_modules/webpack/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/webpack/node_modules/tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dev": true,
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+ "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+ "dev": true,
+ "dependencies": {
+ "is-bigint": "^1.0.1",
+ "is-boolean-object": "^1.1.0",
+ "is-number-object": "^1.0.4",
+ "is-string": "^1.0.5",
+ "is-symbol": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/widest-line": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
+ "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true
+ },
+ "node_modules/worker-farm": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz",
+ "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==",
+ "dev": true,
+ "dependencies": {
+ "errno": "~0.1.7"
+ }
+ },
+ "node_modules/worker-rpc": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/worker-rpc/-/worker-rpc-0.1.1.tgz",
+ "integrity": "sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg==",
+ "dev": true,
+ "dependencies": {
+ "microevent.ts": "~0.1.1"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "node_modules/write-file-atomic": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+ "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+ "dev": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "is-typedarray": "^1.0.0",
+ "signal-exit": "^3.0.2",
+ "typedarray-to-buffer": "^3.1.5"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz",
+ "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/x-default-browser": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/x-default-browser/-/x-default-browser-0.4.0.tgz",
+ "integrity": "sha512-7LKo7RtWfoFN/rHx1UELv/2zHGMx8MkZKDq1xENmOCTkfIqZJ0zZ26NEJX8czhnPXVcqS0ARjjfJB+eJ0/5Cvw==",
+ "dev": true,
+ "bin": {
+ "x-default-browser": "bin/x-default-browser.js"
+ },
+ "optionalDependencies": {
+ "default-browser-id": "^1.0.4"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "dev": true
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
+ "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ },
+ "dependencies": {
+ "@ampproject/remapping": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+ "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/gen-mapping": "^0.1.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ },
+ "@babel/code-frame": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+ "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+ "dev": true,
+ "requires": {
+ "@babel/highlight": "^7.18.6"
+ }
+ },
+ "@babel/compat-data": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.3.tgz",
+ "integrity": "sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw==",
+ "dev": true
+ },
+ "@babel/core": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz",
+ "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==",
+ "dev": true,
+ "requires": {
+ "@ampproject/remapping": "^2.1.0",
+ "@babel/code-frame": "^7.18.6",
+ "@babel/generator": "^7.19.3",
+ "@babel/helper-compilation-targets": "^7.19.3",
+ "@babel/helper-module-transforms": "^7.19.0",
+ "@babel/helpers": "^7.19.0",
+ "@babel/parser": "^7.19.3",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.3",
+ "@babel/types": "^7.19.3",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.1",
+ "semver": "^6.3.0"
+ }
+ },
+ "@babel/generator": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.3.tgz",
+ "integrity": "sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.19.3",
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "jsesc": "^2.5.1"
+ },
+ "dependencies": {
+ "@jridgewell/gen-mapping": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+ "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ }
+ }
+ },
+ "@babel/helper-annotate-as-pure": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
+ "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-builder-binary-assignment-operator-visitor": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz",
+ "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-explode-assignable-expression": "^7.18.6",
+ "@babel/types": "^7.18.9"
+ }
+ },
+ "@babel/helper-compilation-targets": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz",
+ "integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==",
+ "dev": true,
+ "requires": {
+ "@babel/compat-data": "^7.19.3",
+ "@babel/helper-validator-option": "^7.18.6",
+ "browserslist": "^4.21.3",
+ "semver": "^6.3.0"
+ }
+ },
+ "@babel/helper-create-class-features-plugin": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz",
+ "integrity": "sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-member-expression-to-functions": "^7.18.9",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/helper-replace-supers": "^7.18.9",
+ "@babel/helper-split-export-declaration": "^7.18.6"
+ }
+ },
+ "@babel/helper-create-regexp-features-plugin": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz",
+ "integrity": "sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "regexpu-core": "^5.1.0"
+ }
+ },
+ "@babel/helper-define-polyfill-provider": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz",
+ "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-compilation-targets": "^7.17.7",
+ "@babel/helper-plugin-utils": "^7.16.7",
+ "debug": "^4.1.1",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.14.2",
+ "semver": "^6.1.2"
+ }
+ },
+ "@babel/helper-environment-visitor": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+ "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+ "dev": true
+ },
+ "@babel/helper-explode-assignable-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz",
+ "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-function-name": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
+ "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+ "dev": true,
+ "requires": {
+ "@babel/template": "^7.18.10",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/helper-hoist-variables": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+ "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-member-expression-to-functions": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz",
+ "integrity": "sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.9"
+ }
+ },
+ "@babel/helper-module-imports": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+ "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-module-transforms": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz",
+ "integrity": "sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-simple-access": "^7.18.6",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/helper-optimise-call-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz",
+ "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-plugin-utils": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz",
+ "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==",
+ "dev": true
+ },
+ "@babel/helper-remap-async-to-generator": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz",
+ "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-wrap-function": "^7.18.9",
+ "@babel/types": "^7.18.9"
+ }
+ },
+ "@babel/helper-replace-supers": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz",
+ "integrity": "sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-member-expression-to-functions": "^7.18.9",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/traverse": "^7.19.1",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/helper-simple-access": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz",
+ "integrity": "sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz",
+ "integrity": "sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.9"
+ }
+ },
+ "@babel/helper-split-export-declaration": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+ "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-string-parser": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz",
+ "integrity": "sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==",
+ "dev": true
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+ "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+ "dev": true
+ },
+ "@babel/helper-validator-option": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
+ "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+ "dev": true
+ },
+ "@babel/helper-wrap-function": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz",
+ "integrity": "sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/helpers": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.19.0.tgz",
+ "integrity": "sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg==",
+ "dev": true,
+ "requires": {
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/highlight": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+ "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "chalk": "^2.0.0",
+ "js-tokens": "^4.0.0"
+ }
+ },
+ "@babel/parser": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz",
+ "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==",
+ "dev": true
+ },
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz",
+ "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz",
+ "integrity": "sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9",
+ "@babel/plugin-proposal-optional-chaining": "^7.18.9"
+ }
+ },
+ "@babel/plugin-proposal-async-generator-functions": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz",
+ "integrity": "sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-remap-async-to-generator": "^7.18.9",
+ "@babel/plugin-syntax-async-generators": "^7.8.4"
+ }
+ },
+ "@babel/plugin-proposal-class-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz",
+ "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-proposal-class-static-block": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz",
+ "integrity": "sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5"
+ }
+ },
+ "@babel/plugin-proposal-decorators": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.19.3.tgz",
+ "integrity": "sha512-MbgXtNXqo7RTKYIXVchVJGPvaVufQH3pxvQyfbGvNw1DObIhph+PesYXJTcd8J4DdWibvf6Z2eanOyItX8WnJg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-replace-supers": "^7.19.1",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/plugin-syntax-decorators": "^7.19.0"
+ }
+ },
+ "@babel/plugin-proposal-dynamic-import": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz",
+ "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-export-default-from": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.18.10.tgz",
+ "integrity": "sha512-5H2N3R2aQFxkV4PIBUR/i7PUSwgTZjouJKzI8eKswfIjT0PhvzkPn0t0wIS5zn6maQuvtT0t1oHtMUz61LOuow==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-export-default-from": "^7.18.6"
+ }
+ },
+ "@babel/plugin-proposal-export-namespace-from": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz",
+ "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-export-namespace-from": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-json-strings": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz",
+ "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-json-strings": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-logical-assignment-operators": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz",
+ "integrity": "sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4"
+ }
+ },
+ "@babel/plugin-proposal-nullish-coalescing-operator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz",
+ "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-numeric-separator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz",
+ "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4"
+ }
+ },
+ "@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz",
+ "integrity": "sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==",
+ "dev": true,
+ "requires": {
+ "@babel/compat-data": "^7.18.8",
+ "@babel/helper-compilation-targets": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-transform-parameters": "^7.18.8"
+ }
+ },
+ "@babel/plugin-proposal-optional-catch-binding": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz",
+ "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-optional-chaining": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz",
+ "integrity": "sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-private-methods": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
+ "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz",
+ "integrity": "sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+ }
+ },
+ "@babel/plugin-proposal-unicode-property-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz",
+ "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-syntax-async-generators": {
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.12.13"
+ }
+ },
+ "@babel/plugin-syntax-class-static-block": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ }
+ },
+ "@babel/plugin-syntax-decorators": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz",
+ "integrity": "sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.19.0"
+ }
+ },
+ "@babel/plugin-syntax-dynamic-import": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
+ "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-export-default-from": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.18.6.tgz",
+ "integrity": "sha512-Kr//z3ujSVNx6E9z9ih5xXXMqK07VVTuqPmqGe6Mss/zW5XPeLZeSDZoP9ab/hT4wPKqAgjl2PnhPrcpk8Seew==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-syntax-export-namespace-from": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz",
+ "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.3"
+ }
+ },
+ "@babel/plugin-syntax-import-assertions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz",
+ "integrity": "sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-syntax-import-meta": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "@babel/plugin-syntax-json-strings": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-jsx": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz",
+ "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-syntax-logical-assignment-operators": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "@babel/plugin-syntax-nullish-coalescing-operator": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-numeric-separator": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "@babel/plugin-syntax-object-rest-spread": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-optional-catch-binding": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-optional-chaining": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-private-property-in-object": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ }
+ },
+ "@babel/plugin-syntax-top-level-await": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ }
+ },
+ "@babel/plugin-syntax-typescript": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz",
+ "integrity": "sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-arrow-functions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz",
+ "integrity": "sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-async-to-generator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz",
+ "integrity": "sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-remap-async-to-generator": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz",
+ "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-block-scoping": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz",
+ "integrity": "sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-classes": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz",
+ "integrity": "sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-compilation-targets": "^7.19.0",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-replace-supers": "^7.18.9",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "globals": "^11.1.0"
+ }
+ },
+ "@babel/plugin-transform-computed-properties": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz",
+ "integrity": "sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-destructuring": {
+ "version": "7.18.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz",
+ "integrity": "sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-dotall-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz",
+ "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-duplicate-keys": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz",
+ "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz",
+ "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-for-of": {
+ "version": "7.18.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz",
+ "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-function-name": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz",
+ "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-compilation-targets": "^7.18.9",
+ "@babel/helper-function-name": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-literals": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz",
+ "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-member-expression-literals": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz",
+ "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-modules-amd": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz",
+ "integrity": "sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ }
+ },
+ "@babel/plugin-transform-modules-commonjs": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz",
+ "integrity": "sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-simple-access": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ }
+ },
+ "@babel/plugin-transform-modules-systemjs": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz",
+ "integrity": "sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-hoist-variables": "^7.18.6",
+ "@babel/helper-module-transforms": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ }
+ },
+ "@babel/plugin-transform-modules-umd": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz",
+ "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz",
+ "integrity": "sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-regexp-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0"
+ }
+ },
+ "@babel/plugin-transform-new-target": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz",
+ "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-object-super": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz",
+ "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-replace-supers": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-parameters": {
+ "version": "7.18.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz",
+ "integrity": "sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-property-literals": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz",
+ "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-react-display-name": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz",
+ "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-react-jsx": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.19.0.tgz",
+ "integrity": "sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/plugin-syntax-jsx": "^7.18.6",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/plugin-transform-react-jsx-development": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz",
+ "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-transform-react-jsx": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-react-pure-annotations": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz",
+ "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-regenerator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz",
+ "integrity": "sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "regenerator-transform": "^0.15.0"
+ }
+ },
+ "@babel/plugin-transform-reserved-words": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz",
+ "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-shorthand-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz",
+ "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-spread": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz",
+ "integrity": "sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-sticky-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz",
+ "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-template-literals": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz",
+ "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-typeof-symbol": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz",
+ "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-typescript": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz",
+ "integrity": "sha512-z6fnuK9ve9u/0X0rRvI9MY0xg+DOUaABDYOe+/SQTxtlptaBB/V9JIUxJn6xp3lMBeb9qe8xSFmHU35oZDXD+w==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/plugin-syntax-typescript": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-unicode-escapes": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz",
+ "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-unicode-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz",
+ "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/preset-env": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.3.tgz",
+ "integrity": "sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w==",
+ "dev": true,
+ "requires": {
+ "@babel/compat-data": "^7.19.3",
+ "@babel/helper-compilation-targets": "^7.19.3",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9",
+ "@babel/plugin-proposal-async-generator-functions": "^7.19.1",
+ "@babel/plugin-proposal-class-properties": "^7.18.6",
+ "@babel/plugin-proposal-class-static-block": "^7.18.6",
+ "@babel/plugin-proposal-dynamic-import": "^7.18.6",
+ "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
+ "@babel/plugin-proposal-json-strings": "^7.18.6",
+ "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
+ "@babel/plugin-proposal-numeric-separator": "^7.18.6",
+ "@babel/plugin-proposal-object-rest-spread": "^7.18.9",
+ "@babel/plugin-proposal-optional-catch-binding": "^7.18.6",
+ "@babel/plugin-proposal-optional-chaining": "^7.18.9",
+ "@babel/plugin-proposal-private-methods": "^7.18.6",
+ "@babel/plugin-proposal-private-property-in-object": "^7.18.6",
+ "@babel/plugin-proposal-unicode-property-regex": "^7.18.6",
+ "@babel/plugin-syntax-async-generators": "^7.8.4",
+ "@babel/plugin-syntax-class-properties": "^7.12.13",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-export-namespace-from": "^7.8.3",
+ "@babel/plugin-syntax-import-assertions": "^7.18.6",
+ "@babel/plugin-syntax-json-strings": "^7.8.3",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+ "@babel/plugin-syntax-top-level-await": "^7.14.5",
+ "@babel/plugin-transform-arrow-functions": "^7.18.6",
+ "@babel/plugin-transform-async-to-generator": "^7.18.6",
+ "@babel/plugin-transform-block-scoped-functions": "^7.18.6",
+ "@babel/plugin-transform-block-scoping": "^7.18.9",
+ "@babel/plugin-transform-classes": "^7.19.0",
+ "@babel/plugin-transform-computed-properties": "^7.18.9",
+ "@babel/plugin-transform-destructuring": "^7.18.13",
+ "@babel/plugin-transform-dotall-regex": "^7.18.6",
+ "@babel/plugin-transform-duplicate-keys": "^7.18.9",
+ "@babel/plugin-transform-exponentiation-operator": "^7.18.6",
+ "@babel/plugin-transform-for-of": "^7.18.8",
+ "@babel/plugin-transform-function-name": "^7.18.9",
+ "@babel/plugin-transform-literals": "^7.18.9",
+ "@babel/plugin-transform-member-expression-literals": "^7.18.6",
+ "@babel/plugin-transform-modules-amd": "^7.18.6",
+ "@babel/plugin-transform-modules-commonjs": "^7.18.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.19.0",
+ "@babel/plugin-transform-modules-umd": "^7.18.6",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1",
+ "@babel/plugin-transform-new-target": "^7.18.6",
+ "@babel/plugin-transform-object-super": "^7.18.6",
+ "@babel/plugin-transform-parameters": "^7.18.8",
+ "@babel/plugin-transform-property-literals": "^7.18.6",
+ "@babel/plugin-transform-regenerator": "^7.18.6",
+ "@babel/plugin-transform-reserved-words": "^7.18.6",
+ "@babel/plugin-transform-shorthand-properties": "^7.18.6",
+ "@babel/plugin-transform-spread": "^7.19.0",
+ "@babel/plugin-transform-sticky-regex": "^7.18.6",
+ "@babel/plugin-transform-template-literals": "^7.18.9",
+ "@babel/plugin-transform-typeof-symbol": "^7.18.9",
+ "@babel/plugin-transform-unicode-escapes": "^7.18.10",
+ "@babel/plugin-transform-unicode-regex": "^7.18.6",
+ "@babel/preset-modules": "^0.1.5",
+ "@babel/types": "^7.19.3",
+ "babel-plugin-polyfill-corejs2": "^0.3.3",
+ "babel-plugin-polyfill-corejs3": "^0.6.0",
+ "babel-plugin-polyfill-regenerator": "^0.4.1",
+ "core-js-compat": "^3.25.1",
+ "semver": "^6.3.0"
+ }
+ },
+ "@babel/preset-modules": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz",
+ "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/plugin-proposal-unicode-property-regex": "^7.4.4",
+ "@babel/plugin-transform-dotall-regex": "^7.4.4",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ }
+ },
+ "@babel/preset-react": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz",
+ "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-transform-react-display-name": "^7.18.6",
+ "@babel/plugin-transform-react-jsx": "^7.18.6",
+ "@babel/plugin-transform-react-jsx-development": "^7.18.6",
+ "@babel/plugin-transform-react-pure-annotations": "^7.18.6"
+ }
+ },
+ "@babel/preset-typescript": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz",
+ "integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-transform-typescript": "^7.18.6"
+ }
+ },
+ "@babel/register": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.18.9.tgz",
+ "integrity": "sha512-ZlbnXDcNYHMR25ITwwNKT88JiaukkdVj/nG7r3wnuXkOTHc60Uy05PwMCPre0hSkY68E6zK3xz+vUJSP2jWmcw==",
+ "dev": true,
+ "requires": {
+ "clone-deep": "^4.0.1",
+ "find-cache-dir": "^2.0.0",
+ "make-dir": "^2.1.0",
+ "pirates": "^4.0.5",
+ "source-map-support": "^0.5.16"
+ }
+ },
+ "@babel/runtime": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
+ "integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
+ "dev": true,
+ "requires": {
+ "regenerator-runtime": "^0.13.4"
+ }
+ },
+ "@babel/template": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
+ "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.18.6",
+ "@babel/parser": "^7.18.10",
+ "@babel/types": "^7.18.10"
+ }
+ },
+ "@babel/traverse": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.3.tgz",
+ "integrity": "sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.18.6",
+ "@babel/generator": "^7.19.3",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-hoist-variables": "^7.18.6",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/parser": "^7.19.3",
+ "@babel/types": "^7.19.3",
+ "debug": "^4.1.0",
+ "globals": "^11.1.0"
+ }
+ },
+ "@babel/types": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.3.tgz",
+ "integrity": "sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-string-parser": "^7.18.10",
+ "@babel/helper-validator-identifier": "^7.19.1",
+ "to-fast-properties": "^2.0.0"
+ }
+ },
+ "@cnakazawa/watch": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz",
+ "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==",
+ "dev": true,
+ "requires": {
+ "exec-sh": "^0.3.2",
+ "minimist": "^1.2.0"
+ }
+ },
+ "@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
+ "dev": true,
+ "optional": true
+ },
+ "@discoveryjs/json-ext": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
+ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
+ "dev": true
+ },
+ "@fluent/bundle": {
+ "version": "0.17.1",
+ "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.17.1.tgz",
+ "integrity": "sha512-CRFNT9QcSFAeFDneTF59eyv3JXFGhIIN4boUO2y22YmsuuKLyDk+N1I/NQUYz9Ab63e6V7T6vItoZIG/2oOOuw==",
+ "dev": true
+ },
+ "@fluent/dom": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@fluent/dom/-/dom-0.8.1.tgz",
+ "integrity": "sha512-wlQ3vHgioDL8dC0wcZ9AyCSpOgor0OREKXJMvvnx6bzk/PT2SZNA5frslmSdbEaiBQIVy2MhVvAIDtbKbdoVCg==",
+ "dev": true,
+ "requires": {
+ "cached-iterable": "^0.3"
+ }
+ },
+ "@gar/promisify": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
+ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
+ "dev": true
+ },
+ "@istanbuljs/load-nyc-config": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+ "dev": true,
+ "requires": {
+ "camelcase": "^5.3.1",
+ "find-up": "^4.1.0",
+ "get-package-type": "^0.1.0",
+ "js-yaml": "^3.13.1",
+ "resolve-from": "^5.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ }
+ }
+ },
+ "@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true
+ },
+ "@jest/transform": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz",
+ "integrity": "sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.1.0",
+ "@jest/types": "^26.6.2",
+ "babel-plugin-istanbul": "^6.0.0",
+ "chalk": "^4.0.0",
+ "convert-source-map": "^1.4.0",
+ "fast-json-stable-stringify": "^2.0.0",
+ "graceful-fs": "^4.2.4",
+ "jest-haste-map": "^26.6.2",
+ "jest-regex-util": "^26.0.0",
+ "jest-util": "^26.6.2",
+ "micromatch": "^4.0.2",
+ "pirates": "^4.0.1",
+ "slash": "^3.0.0",
+ "source-map": "^0.6.1",
+ "write-file-atomic": "^3.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@jest/types": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
+ "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^15.0.0",
+ "chalk": "^4.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@jridgewell/gen-mapping": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+ "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/set-array": "^1.0.0",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "@jridgewell/resolve-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+ "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+ "dev": true
+ },
+ "@jridgewell/set-array": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+ "dev": true
+ },
+ "@jridgewell/source-map": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+ "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/gen-mapping": "^0.3.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "dependencies": {
+ "@jridgewell/gen-mapping": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+ "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ }
+ }
+ },
+ "@jridgewell/sourcemap-codec": {
+ "version": "1.4.14",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+ "dev": true
+ },
+ "@jridgewell/trace-mapping": {
+ "version": "0.3.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz",
+ "integrity": "sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "@lit/reactive-element": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.4.1.tgz",
+ "integrity": "sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==",
+ "dev": true
+ },
+ "@mdx-js/mdx": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz",
+ "integrity": "sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "7.12.9",
+ "@babel/plugin-syntax-jsx": "7.12.1",
+ "@babel/plugin-syntax-object-rest-spread": "7.8.3",
+ "@mdx-js/util": "1.6.22",
+ "babel-plugin-apply-mdx-type-prop": "1.6.22",
+ "babel-plugin-extract-import-names": "1.6.22",
+ "camelcase-css": "2.0.1",
+ "detab": "2.0.4",
+ "hast-util-raw": "6.0.1",
+ "lodash.uniq": "4.5.0",
+ "mdast-util-to-hast": "10.0.1",
+ "remark-footnotes": "2.0.0",
+ "remark-mdx": "1.6.22",
+ "remark-parse": "8.0.3",
+ "remark-squeeze-paragraphs": "4.0.0",
+ "style-to-object": "0.3.0",
+ "unified": "9.2.0",
+ "unist-builder": "2.0.3",
+ "unist-util-visit": "2.0.3"
+ },
+ "dependencies": {
+ "@babel/core": {
+ "version": "7.12.9",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz",
+ "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/generator": "^7.12.5",
+ "@babel/helper-module-transforms": "^7.12.1",
+ "@babel/helpers": "^7.12.5",
+ "@babel/parser": "^7.12.7",
+ "@babel/template": "^7.12.7",
+ "@babel/traverse": "^7.12.9",
+ "@babel/types": "^7.12.7",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.1",
+ "json5": "^2.1.2",
+ "lodash": "^4.17.19",
+ "resolve": "^1.3.2",
+ "semver": "^5.4.1",
+ "source-map": "^0.5.0"
+ }
+ },
+ "@babel/plugin-syntax-jsx": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz",
+ "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true
+ }
+ }
+ },
+ "@mdx-js/util": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz",
+ "integrity": "sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==",
+ "dev": true
+ },
+ "@mrmlnc/readdir-enhanced": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
+ "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==",
+ "dev": true,
+ "requires": {
+ "call-me-maybe": "^1.0.1",
+ "glob-to-regexp": "^0.3.0"
+ },
+ "dependencies": {
+ "glob-to-regexp": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz",
+ "integrity": "sha512-Iozmtbqv0noj0uDDqoL0zNq0VBEfK2YFoMAZoxJe4cwphvLR+JskfF30QhXHOR4m3KrE6NLRYw+U9MRXvifyig==",
+ "dev": true
+ }
+ }
+ },
+ "@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "requires": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ }
+ },
+ "@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true
+ },
+ "@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "requires": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ }
+ },
+ "@npmcli/fs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
+ "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==",
+ "dev": true,
+ "requires": {
+ "@gar/promisify": "^1.0.1",
+ "semver": "^7.3.5"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ }
+ }
+ },
+ "@npmcli/move-file": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
+ "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==",
+ "dev": true,
+ "requires": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ }
+ },
+ "@storybook/addon-actions": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.5.12.tgz",
+ "integrity": "sha512-yEbyKjBsSRUr61SlS+SOTqQwdumO8Wa3GoHO3AfmvoKfzdGrM7w8G5Zs9Iev16khWg/7bQvoH3KZsg/hQuKnNg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "polished": "^4.2.2",
+ "prop-types": "^15.7.2",
+ "react-inspector": "^5.1.0",
+ "regenerator-runtime": "^0.13.7",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "uuid-browser": "^3.1.0"
+ },
+ "dependencies": {
+ "react-inspector": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz",
+ "integrity": "sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.0.0",
+ "is-dom": "^1.0.0",
+ "prop-types": "^15.0.0"
+ }
+ }
+ }
+ },
+ "@storybook/addon-backgrounds": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-6.5.12.tgz",
+ "integrity": "sha512-S0QThY1jnU7Q+HY+g9JgpAJszzNmNkigZ4+X/4qlUXE0WYYn9i2YG5H6me1+57QmIXYddcWWqqgF9HUXl667NA==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/addon-controls": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-6.5.12.tgz",
+ "integrity": "sha512-UoaamkGgAQXplr0kixkPhROdzkY+ZJQpG7VFDU6kmZsIgPRNfX/QoJFR5vV6TpDArBIjWaUUqWII+GHgPRzLgQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "lodash": "^4.17.21",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/addon-docs": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-6.5.12.tgz",
+ "integrity": "sha512-T+QTkmF7QlMVfXHXEberP8CYti/XMTo9oi6VEbZLx+a2N3qY4GZl7X2g26Sf5V4Za+xnapYKBMEIiJ5SvH9weQ==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-transform-react-jsx": "^7.12.12",
+ "@babel/preset-env": "^7.12.11",
+ "@jest/transform": "^26.6.2",
+ "@mdx-js/react": "^1.6.22",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/docs-tools": "6.5.12",
+ "@storybook/mdx1-csf": "^0.0.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/postinstall": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/source-loader": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "babel-loader": "^8.0.0",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "regenerator-runtime": "^0.13.7",
+ "remark-external-links": "^8.0.0",
+ "remark-slug": "^6.0.0",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "dependencies": {
+ "@mdx-js/react": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz",
+ "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==",
+ "dev": true,
+ "requires": {}
+ }
+ }
+ },
+ "@storybook/addon-essentials": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-6.5.12.tgz",
+ "integrity": "sha512-4AAV0/mQPSk3V0Pie1NIqqgBgScUc0VtBEXDm8BgPeuDNVhPEupnaZgVt+I3GkzzPPo6JjdCsp2L11f3bBSEjw==",
+ "dev": true,
+ "requires": {
+ "@storybook/addon-actions": "6.5.12",
+ "@storybook/addon-backgrounds": "6.5.12",
+ "@storybook/addon-controls": "6.5.12",
+ "@storybook/addon-docs": "6.5.12",
+ "@storybook/addon-measure": "6.5.12",
+ "@storybook/addon-outline": "6.5.12",
+ "@storybook/addon-toolbars": "6.5.12",
+ "@storybook/addon-viewport": "6.5.12",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/addon-links": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.5.12.tgz",
+ "integrity": "sha512-Dyt922J5nTBwM/9KtuuDIt3sX8xdTkKh+aXSoOX6OzT04Xwm5NumFOvuQ2YA00EM+3Ihn7Ayc3urvxnHTixmKg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@types/qs": "^6.9.5",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "prop-types": "^15.7.2",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/addon-measure": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-6.5.12.tgz",
+ "integrity": "sha512-zmolO6+VG4ov2620G7f1myqLQLztfU+ykN+U5y52GXMFsCOyB7fMoVWIMrZwsNlinDu+CnUvelXHUNbqqnjPRg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0"
+ }
+ },
+ "@storybook/addon-outline": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-6.5.12.tgz",
+ "integrity": "sha512-jXwLz2rF/CZt6Cgy+QUTa+pNW0IevSONYwS3D533E9z5h0T5ZKJbbxG5jxM+oC+FpZ/nFk5mEmUaYNkxgIVdpw==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/addon-toolbars": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-6.5.12.tgz",
+ "integrity": "sha512-+QjoEHkekz4wTy8zqxYdV9ijDJ5YcjDc/qdnV8wx22zkoVU93FQlo0CHHVjpyvc3ilQliZbdQDJx62BcHXw30Q==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/addon-viewport": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-6.5.12.tgz",
+ "integrity": "sha512-eQ1UrmbiMiPmWe+fdMWIc0F6brh/S2z4ADfwFz0tTd+vOLWRZp1xw8JYQ9P2ZasE+PM3WFOVT9jvNjZj/cHnfw==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "memoizerific": "^1.11.3",
+ "prop-types": "^15.7.2",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/addons": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.5.12.tgz",
+ "integrity": "sha512-y3cgxZq41YGnuIlBJEuJjSFdMsm8wnvlNOGUP9Q+Er2dgfx8rJz4Q22o4hPjpvpaj4XdBtxCJXI2NeFpN59+Cw==",
+ "dev": true,
+ "requires": {
+ "@storybook/api": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@types/webpack-env": "^1.16.0",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/api": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.5.12.tgz",
+ "integrity": "sha512-DuUZmMlQxkFNU9Vgkp9aNfCkAongU76VVmygvCuSpMVDI9HQ2lG0ydL+ppL4XKoSMCCoXTY6+rg4hJANnH+1AQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "store2": "^2.12.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/builder-webpack4": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.5.12.tgz",
+ "integrity": "sha512-TsthT5jm9ZxQPNOZJbF5AV24me3i+jjYD7gbdKdSHrOVn1r3ydX4Z8aD6+BjLCtTn3T+e8NMvUkL4dInEo1x6g==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/webpack": "^4.41.26",
+ "autoprefixer": "^9.8.6",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^3.6.0",
+ "file-loader": "^6.2.0",
+ "find-up": "^5.0.0",
+ "fork-ts-checker-webpack-plugin": "^4.1.6",
+ "glob": "^7.1.6",
+ "glob-promise": "^3.4.0",
+ "global": "^4.4.0",
+ "html-webpack-plugin": "^4.0.0",
+ "pnp-webpack-plugin": "1.6.4",
+ "postcss": "^7.0.36",
+ "postcss-flexbugs-fixes": "^4.2.1",
+ "postcss-loader": "^4.2.0",
+ "raw-loader": "^4.0.2",
+ "stable": "^0.1.8",
+ "style-loader": "^1.3.0",
+ "terser-webpack-plugin": "^4.2.3",
+ "ts-dedent": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4",
+ "webpack-dev-middleware": "^3.7.3",
+ "webpack-filter-warnings-plugin": "^1.2.1",
+ "webpack-hot-middleware": "^2.25.1",
+ "webpack-virtual-modules": "^0.2.2"
+ },
+ "dependencies": {
+ "@types/html-minifier-terser": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
+ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==",
+ "dev": true
+ },
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true
+ },
+ "ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "clean-css": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+ "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+ "dev": true,
+ "requires": {
+ "source-map": "~0.6.0"
+ }
+ },
+ "commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true
+ },
+ "css-loader": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
+ "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
+ "dev": true,
+ "requires": {
+ "camelcase": "^5.3.1",
+ "cssesc": "^3.0.0",
+ "icss-utils": "^4.1.1",
+ "loader-utils": "^1.2.3",
+ "normalize-path": "^3.0.0",
+ "postcss": "^7.0.32",
+ "postcss-modules-extract-imports": "^2.0.0",
+ "postcss-modules-local-by-default": "^3.0.2",
+ "postcss-modules-scope": "^2.2.0",
+ "postcss-modules-values": "^3.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^2.7.0",
+ "semver": "^6.3.0"
+ },
+ "dependencies": {
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ }
+ }
+ },
+ "debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ }
+ },
+ "fork-ts-checker-webpack-plugin": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz",
+ "integrity": "sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.5.5",
+ "chalk": "^2.4.1",
+ "micromatch": "^3.1.10",
+ "minimatch": "^3.0.4",
+ "semver": "^5.6.0",
+ "tapable": "^1.0.0",
+ "worker-rpc": "^0.1.0"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ }
+ }
+ },
+ "html-minifier-terser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+ "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
+ "dev": true,
+ "requires": {
+ "camel-case": "^4.1.1",
+ "clean-css": "^4.2.3",
+ "commander": "^4.1.1",
+ "he": "^1.2.0",
+ "param-case": "^3.0.3",
+ "relateurl": "^0.2.7",
+ "terser": "^4.6.3"
+ },
+ "dependencies": {
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ }
+ }
+ }
+ }
+ },
+ "html-webpack-plugin": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz",
+ "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==",
+ "dev": true,
+ "requires": {
+ "@types/html-minifier-terser": "^5.0.0",
+ "@types/tapable": "^1.0.5",
+ "@types/webpack": "^4.41.8",
+ "html-minifier-terser": "^5.0.1",
+ "loader-utils": "^1.2.3",
+ "lodash": "^4.17.20",
+ "pretty-error": "^2.1.1",
+ "tapable": "^1.1.3",
+ "util.promisify": "1.0.0"
+ },
+ "dependencies": {
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "requires": {
+ "semver": "^6.0.0"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ },
+ "dependencies": {
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ }
+ }
+ },
+ "pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "requires": {
+ "find-up": "^4.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ }
+ }
+ },
+ "pretty-error": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz",
+ "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.20",
+ "renderkid": "^2.0.4"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "renderkid": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz",
+ "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==",
+ "dev": true,
+ "requires": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^3.0.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "serialize-javascript": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
+ "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "requires": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^2.0.0"
+ }
+ },
+ "style-loader": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz",
+ "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz",
+ "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==",
+ "dev": true,
+ "requires": {
+ "cacache": "^15.0.5",
+ "find-cache-dir": "^3.3.1",
+ "jest-worker": "^26.5.0",
+ "p-limit": "^3.0.2",
+ "schema-utils": "^3.0.0",
+ "serialize-javascript": "^5.0.1",
+ "source-map": "^0.6.1",
+ "terser": "^5.3.4",
+ "webpack-sources": "^1.4.3"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.4.1",
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "dependencies": {
+ "cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ }
+ },
+ "find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^3.0.0"
+ }
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ },
+ "locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ }
+ },
+ "make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "requires": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "requires": {
+ "find-up": "^3.0.0"
+ }
+ },
+ "schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "requires": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ }
+ }
+ }
+ },
+ "webpack-dev-middleware": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz",
+ "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==",
+ "dev": true,
+ "requires": {
+ "memory-fs": "^0.4.1",
+ "mime": "^2.4.4",
+ "mkdirp": "^0.5.1",
+ "range-parser": "^1.2.1",
+ "webpack-log": "^2.0.0"
+ }
+ },
+ "webpack-filter-warnings-plugin": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz",
+ "integrity": "sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg==",
+ "dev": true,
+ "requires": {}
+ },
+ "webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "requires": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "webpack-virtual-modules": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz",
+ "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==",
+ "dev": true,
+ "requires": {
+ "debug": "^3.0.0"
+ }
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/builder-webpack5": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-6.5.12.tgz",
+ "integrity": "sha512-jK5jWxhSbMAM/onPB6WN7xVqwZnAmzJljOG24InO/YIjW8pQof7MeAXCYBM4rYM+BbK61gkZ/RKxwlkqXBWv+Q==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-named-exports-order": "^0.0.2",
+ "browser-assert": "^1.2.1",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^5.0.1",
+ "fork-ts-checker-webpack-plugin": "^6.0.4",
+ "glob": "^7.1.6",
+ "glob-promise": "^3.4.0",
+ "html-webpack-plugin": "^5.0.0",
+ "path-browserify": "^1.0.1",
+ "process": "^0.11.10",
+ "stable": "^0.1.8",
+ "style-loader": "^2.0.0",
+ "terser-webpack-plugin": "^5.0.3",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "^5.9.0",
+ "webpack-dev-middleware": "^4.1.0",
+ "webpack-hot-middleware": "^2.25.1",
+ "webpack-virtual-modules": "^0.4.1"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/channel-postmessage": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.5.12.tgz",
+ "integrity": "sha512-SL/tJBLOdDlbUAAxhiZWOEYd5HI4y8rN50r6jeed5nD8PlocZjxJ6mO0IxnePqIL9Yu3nSrQRHrtp8AJvPX0Yg==",
+ "dev": true,
+ "requires": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "qs": "^6.10.0",
+ "telejson": "^6.0.8"
+ }
+ },
+ "@storybook/channel-websocket": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channel-websocket/-/channel-websocket-6.5.12.tgz",
+ "integrity": "sha512-0t5dLselHVKTRYaphxx1dRh4pmOFCfR7h8oNJlOvJ29Qy5eNyVujDG9nhwWbqU6IKayuP4nZrAbe9Req9YZYlQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "telejson": "^6.0.8"
+ }
+ },
+ "@storybook/channels": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-6.5.12.tgz",
+ "integrity": "sha512-X5XaKbe4b7LXJ4sUakBo00x6pXnW78JkOonHoaKoWsccHLlEzwfBZpVVekhVZnqtCoLT23dB8wjKgA71RYWoiw==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.8.2",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/client-api": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.5.12.tgz",
+ "integrity": "sha512-+JiRSgiU829KPc25nG/k0+Ao2nUelHUe8Y/9cRoKWbCAGzi4xd0JLhHAOr9Oi2szWx/OI1L08lxVv1+WTveAeA==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "@types/qs": "^6.9.5",
+ "@types/webpack-env": "^1.16.0",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "store2": "^2.12.0",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/client-logger": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.5.12.tgz",
+ "integrity": "sha512-IrkMr5KZcudX935/C2balFbxLHhkvQnJ78rbVThHDVckQ7l3oIXTh66IMzldeOabVFDZEMiW8AWuGEYof+JtLw==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.8.2",
+ "global": "^4.4.0"
+ }
+ },
+ "@storybook/components": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.5.12.tgz",
+ "integrity": "sha512-NAAGl5PDXaHdVLd6hA+ttmLwH3zAVGXeUmEubzKZ9bJzb+duhFKxDa9blM4YEkI+palumvgAMm0UgS7ou680Ig==",
+ "dev": true,
+ "requires": {
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/core": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core/-/core-6.5.12.tgz",
+ "integrity": "sha512-+o3psAVWL+5LSwyJmEbvhgxKO1Et5uOX8ujNVt/f1fgwJBIf6BypxyPKu9YGQDRzcRssESQQZWNrZCCAZlFeuQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-server": "6.5.12"
+ }
+ },
+ "@storybook/core-client": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.5.12.tgz",
+ "integrity": "sha512-jyAd0ud6zO+flpLv0lEHbbt1Bv9Ms225M6WTQLrfe7kN/7j1pVKZEoeVCLZwkJUtSKcNiWQxZbS15h31pcYwqg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channel-websocket": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "airbnb-js-shims": "^2.2.1",
+ "ansi-to-html": "^0.6.11",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0",
+ "unfetch": "^4.2.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/core-common": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-6.5.12.tgz",
+ "integrity": "sha512-gG20+eYdIhwQNu6Xs805FLrOCWtkoc8Rt8gJiRt8yXzZh9EZkU4xgCRoCxrrJ03ys/gTiCFbBOfRi749uM3z4w==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-proposal-class-properties": "^7.12.1",
+ "@babel/plugin-proposal-decorators": "^7.12.12",
+ "@babel/plugin-proposal-export-default-from": "^7.12.1",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
+ "@babel/plugin-proposal-object-rest-spread": "^7.12.1",
+ "@babel/plugin-proposal-optional-chaining": "^7.12.7",
+ "@babel/plugin-proposal-private-methods": "^7.12.1",
+ "@babel/plugin-proposal-private-property-in-object": "^7.12.1",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-transform-arrow-functions": "^7.12.1",
+ "@babel/plugin-transform-block-scoping": "^7.12.12",
+ "@babel/plugin-transform-classes": "^7.12.1",
+ "@babel/plugin-transform-destructuring": "^7.12.1",
+ "@babel/plugin-transform-for-of": "^7.12.1",
+ "@babel/plugin-transform-parameters": "^7.12.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.12.1",
+ "@babel/plugin-transform-spread": "^7.12.1",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/preset-react": "^7.12.10",
+ "@babel/preset-typescript": "^7.12.7",
+ "@babel/register": "^7.12.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/pretty-hrtime": "^1.0.0",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-macros": "^3.0.1",
+ "babel-plugin-polyfill-corejs3": "^0.1.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "express": "^4.17.1",
+ "file-system-cache": "^1.0.5",
+ "find-up": "^5.0.0",
+ "fork-ts-checker-webpack-plugin": "^6.0.4",
+ "fs-extra": "^9.0.1",
+ "glob": "^7.1.6",
+ "handlebars": "^4.7.7",
+ "interpret": "^2.2.0",
+ "json5": "^2.1.3",
+ "lazy-universal-dotenv": "^3.0.1",
+ "picomatch": "^2.3.0",
+ "pkg-dir": "^5.0.0",
+ "pretty-hrtime": "^1.0.3",
+ "resolve-from": "^5.0.0",
+ "slash": "^3.0.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4"
+ },
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz",
+ "integrity": "sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-compilation-targets": "^7.13.0",
+ "@babel/helper-module-imports": "^7.12.13",
+ "@babel/helper-plugin-utils": "^7.13.0",
+ "@babel/traverse": "^7.13.0",
+ "debug": "^4.1.1",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.14.2",
+ "semver": "^6.1.2"
+ }
+ },
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "babel-plugin-polyfill-corejs3": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz",
+ "integrity": "sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-define-polyfill-provider": "^0.1.5",
+ "core-js-compat": "^3.8.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "dependencies": {
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ }
+ }
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ }
+ },
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "requires": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "requires": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.4.1",
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ }
+ },
+ "webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "requires": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/core-events": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.5.12.tgz",
+ "integrity": "sha512-0AMyMM19R/lHsYRfWqM8zZTXthasTAK2ExkSRzYi2GkIaVMxRKtM33YRwxKIpJ6KmIKIs8Ru3QCXu1mfCmGzNg==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.8.2"
+ }
+ },
+ "@storybook/core-server": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.5.12.tgz",
+ "integrity": "sha512-q1b/XKwoLUcCoCQ+8ndPD5THkEwXZYJ9ROv16i2VGUjjjAuSqpEYBq5GMGQUgxlWp1bkxtdGL2Jz+6pZfvldzA==",
+ "dev": true,
+ "requires": {
+ "@discoveryjs/json-ext": "^0.5.3",
+ "@storybook/builder-webpack4": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/csf-tools": "6.5.12",
+ "@storybook/manager-webpack4": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/telemetry": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/node-fetch": "^2.5.7",
+ "@types/pretty-hrtime": "^1.0.0",
+ "@types/webpack": "^4.41.26",
+ "better-opn": "^2.1.1",
+ "boxen": "^5.1.2",
+ "chalk": "^4.1.0",
+ "cli-table3": "^0.6.1",
+ "commander": "^6.2.1",
+ "compression": "^1.7.4",
+ "core-js": "^3.8.2",
+ "cpy": "^8.1.2",
+ "detect-port": "^1.3.0",
+ "express": "^4.17.1",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "globby": "^11.0.2",
+ "ip": "^2.0.0",
+ "lodash": "^4.17.21",
+ "node-fetch": "^2.6.7",
+ "open": "^8.4.0",
+ "pretty-hrtime": "^1.0.3",
+ "prompts": "^2.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "serve-favicon": "^2.5.0",
+ "slash": "^3.0.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "watchpack": "^2.2.0",
+ "webpack": "4",
+ "ws": "^8.2.3",
+ "x-default-browser": "^0.4.0"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ }
+ },
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "requires": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ }
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "requires": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "dependencies": {
+ "watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.4.1",
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ }
+ }
+ },
+ "webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "requires": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/csf": {
+ "version": "0.0.2--canary.4566f4d.1",
+ "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.4566f4d.1.tgz",
+ "integrity": "sha512-9OVvMVh3t9znYZwb0Svf/YQoxX2gVOeQTGe2bses2yj+a3+OJnCrUF3/hGv6Em7KujtOdL2LL+JnG49oMVGFgQ==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.15"
+ }
+ },
+ "@storybook/csf-tools": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-6.5.12.tgz",
+ "integrity": "sha512-BPhnB1xJtBVOzXuCURzQRdXcstE27ht4qoTgQkbwUTy4MEtUZ/f1AnHSYRdzrgukXdUFWseNIK4RkNdJpfOfNQ==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@babel/generator": "^7.12.11",
+ "@babel/parser": "^7.12.11",
+ "@babel/plugin-transform-react-jsx": "^7.12.12",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/traverse": "^7.12.11",
+ "@babel/types": "^7.12.11",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/mdx1-csf": "^0.0.1",
+ "core-js": "^3.8.2",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/docs-tools": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/docs-tools/-/docs-tools-6.5.12.tgz",
+ "integrity": "sha512-8brf8W89KVk95flVqW0sYEqkL+FBwb5W9CnwI+Ggd6r2cqXe9jyg+0vDZFdYp6kYNQKrPr4fbXGrGVXQG18/QQ==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "core-js": "^3.8.2",
+ "doctrine": "^3.0.0",
+ "lodash": "^4.17.21",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/manager-webpack4": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.5.12.tgz",
+ "integrity": "sha512-LH3e6qfvq2znEdxe2kaWtmdDPTnvSkufzoC9iwOgNvo3YrTGrYNyUTDegvW293TOTVfUn7j6TBcsOxIgRnt28g==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-transform-template-literals": "^7.12.1",
+ "@babel/preset-react": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/webpack": "^4.41.26",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^3.6.0",
+ "express": "^4.17.1",
+ "file-loader": "^6.2.0",
+ "find-up": "^5.0.0",
+ "fs-extra": "^9.0.1",
+ "html-webpack-plugin": "^4.0.0",
+ "node-fetch": "^2.6.7",
+ "pnp-webpack-plugin": "1.6.4",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0",
+ "style-loader": "^1.3.0",
+ "telejson": "^6.0.8",
+ "terser-webpack-plugin": "^4.2.3",
+ "ts-dedent": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4",
+ "webpack-dev-middleware": "^3.7.3",
+ "webpack-virtual-modules": "^0.2.2"
+ },
+ "dependencies": {
+ "@types/html-minifier-terser": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
+ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==",
+ "dev": true
+ },
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true
+ },
+ "ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "clean-css": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+ "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+ "dev": true,
+ "requires": {
+ "source-map": "~0.6.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true
+ },
+ "css-loader": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
+ "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
+ "dev": true,
+ "requires": {
+ "camelcase": "^5.3.1",
+ "cssesc": "^3.0.0",
+ "icss-utils": "^4.1.1",
+ "loader-utils": "^1.2.3",
+ "normalize-path": "^3.0.0",
+ "postcss": "^7.0.32",
+ "postcss-modules-extract-imports": "^2.0.0",
+ "postcss-modules-local-by-default": "^3.0.2",
+ "postcss-modules-scope": "^2.2.0",
+ "postcss-modules-values": "^3.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^2.7.0",
+ "semver": "^6.3.0"
+ },
+ "dependencies": {
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ }
+ }
+ },
+ "debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "html-minifier-terser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+ "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
+ "dev": true,
+ "requires": {
+ "camel-case": "^4.1.1",
+ "clean-css": "^4.2.3",
+ "commander": "^4.1.1",
+ "he": "^1.2.0",
+ "param-case": "^3.0.3",
+ "relateurl": "^0.2.7",
+ "terser": "^4.6.3"
+ },
+ "dependencies": {
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ }
+ }
+ }
+ }
+ },
+ "html-webpack-plugin": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz",
+ "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==",
+ "dev": true,
+ "requires": {
+ "@types/html-minifier-terser": "^5.0.0",
+ "@types/tapable": "^1.0.5",
+ "@types/webpack": "^4.41.8",
+ "html-minifier-terser": "^5.0.1",
+ "loader-utils": "^1.2.3",
+ "lodash": "^4.17.20",
+ "pretty-error": "^2.1.1",
+ "tapable": "^1.1.3",
+ "util.promisify": "1.0.0"
+ },
+ "dependencies": {
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "requires": {
+ "semver": "^6.0.0"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ },
+ "dependencies": {
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ }
+ }
+ },
+ "pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "requires": {
+ "find-up": "^4.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ }
+ }
+ },
+ "pretty-error": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz",
+ "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.20",
+ "renderkid": "^2.0.4"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "renderkid": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz",
+ "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==",
+ "dev": true,
+ "requires": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^3.0.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "serialize-javascript": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
+ "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "requires": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^2.0.0"
+ }
+ },
+ "style-loader": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz",
+ "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz",
+ "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==",
+ "dev": true,
+ "requires": {
+ "cacache": "^15.0.5",
+ "find-cache-dir": "^3.3.1",
+ "jest-worker": "^26.5.0",
+ "p-limit": "^3.0.2",
+ "schema-utils": "^3.0.0",
+ "serialize-javascript": "^5.0.1",
+ "source-map": "^0.6.1",
+ "terser": "^5.3.4",
+ "webpack-sources": "^1.4.3"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.4.1",
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "dependencies": {
+ "cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ }
+ },
+ "find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^3.0.0"
+ }
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ },
+ "locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ }
+ },
+ "make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "requires": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "requires": {
+ "find-up": "^3.0.0"
+ }
+ },
+ "schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "requires": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ }
+ }
+ }
+ },
+ "webpack-dev-middleware": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz",
+ "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==",
+ "dev": true,
+ "requires": {
+ "memory-fs": "^0.4.1",
+ "mime": "^2.4.4",
+ "mkdirp": "^0.5.1",
+ "range-parser": "^1.2.1",
+ "webpack-log": "^2.0.0"
+ }
+ },
+ "webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "requires": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "webpack-virtual-modules": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz",
+ "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==",
+ "dev": true,
+ "requires": {
+ "debug": "^3.0.0"
+ }
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/manager-webpack5": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-webpack5/-/manager-webpack5-6.5.12.tgz",
+ "integrity": "sha512-F+KgoINhfo1ArbirCc9L+EyADYD8Z4t0LyZYDVcBiZ8DlRIMIoUSye6tDsnyEm+OPloLVAcGwRMYgFhuHB70Lg==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-transform-template-literals": "^7.12.1",
+ "@babel/preset-react": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^5.0.1",
+ "express": "^4.17.1",
+ "find-up": "^5.0.0",
+ "fs-extra": "^9.0.1",
+ "html-webpack-plugin": "^5.0.0",
+ "node-fetch": "^2.6.7",
+ "process": "^0.11.10",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0",
+ "style-loader": "^2.0.0",
+ "telejson": "^6.0.8",
+ "terser-webpack-plugin": "^5.0.3",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "^5.9.0",
+ "webpack-dev-middleware": "^4.1.0",
+ "webpack-virtual-modules": "^0.4.1"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@storybook/mdx1-csf": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@storybook/mdx1-csf/-/mdx1-csf-0.0.1.tgz",
+ "integrity": "sha512-4biZIWWzoWlCarMZmTpqcJNgo/RBesYZwGFbQeXiGYsswuvfWARZnW9RE9aUEMZ4XPn7B1N3EKkWcdcWe/K2tg==",
+ "dev": true,
+ "requires": {
+ "@babel/generator": "^7.12.11",
+ "@babel/parser": "^7.12.11",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/types": "^7.12.11",
+ "@mdx-js/mdx": "^1.6.22",
+ "@types/lodash": "^4.14.167",
+ "js-string-escape": "^1.0.1",
+ "loader-utils": "^2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": ">=2.2.1 <=2.3.0",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/node-logger": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.5.12.tgz",
+ "integrity": "sha512-jdLtT3mX5GQKa+0LuX0q0sprKxtCGf6HdXlKZGD5FEuz4MgJUGaaiN0Hgi+U7Z4tVNOtSoIbYBYXHqfUgJrVZw==",
+ "dev": true,
+ "requires": {
+ "@types/npmlog": "^4.1.2",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "npmlog": "^5.0.1",
+ "pretty-hrtime": "^1.0.3"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@storybook/postinstall": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/postinstall/-/postinstall-6.5.12.tgz",
+ "integrity": "sha512-6K73f9c2UO+w4Wtyo2BxEpEsnhPvMgqHSaJ9Yt6Tc90LaDGUbcVgy6PNibsRyuJ/KQ543WeiRO5rSZfm2uJU9A==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.8.2"
+ }
+ },
+ "@storybook/preview-web": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/preview-web/-/preview-web-6.5.12.tgz",
+ "integrity": "sha512-Q5mduCJsY9zhmlsrhHvtOBA3Jt2n45bhfVkiUEqtj8fDit45/GW+eLoffv8GaVTGjV96/Y1JFwDZUwU6mEfgGQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "ansi-to-html": "^0.6.11",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "unfetch": "^4.2.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/router": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.5.12.tgz",
+ "integrity": "sha512-xHubde9YnBbpkDY5+zGO4Pr6VPxP8H9J2v4OTF3H82uaxCIKR0PKG0utS9pFKIsEiP3aM62Hb9qB8nU+v1nj3w==",
+ "dev": true,
+ "requires": {
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/semver": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz",
+ "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.6.5",
+ "find-up": "^4.1.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ }
+ }
+ },
+ "@storybook/source-loader": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.5.12.tgz",
+ "integrity": "sha512-4iuILFsKNV70sEyjzIkOqgzgQx7CJ8kTEFz590vkmWXQNKz7YQzjgISIwL7GBw/myJgeb04bl5psVgY0cbG5vg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "estraverse": "^5.2.0",
+ "global": "^4.4.0",
+ "loader-utils": "^2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": ">=2.2.1 <=2.3.0",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/store": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/store/-/store-6.5.12.tgz",
+ "integrity": "sha512-SMQOr0XvV0mhTuqj3XOwGGc4kTPVjh3xqrG1fqkj9RGs+2jRdmO6mnwzda5gPwUmWNTorZ7FxZ1iEoyfYNtuiQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "slash": "^3.0.0",
+ "stable": "^0.1.8",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/telemetry": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/telemetry/-/telemetry-6.5.12.tgz",
+ "integrity": "sha512-mCHxx7NmQ3n7gx0nmblNlZE5ZgrjQm6B08mYeWg6Y7r4GZnqS6wZbvAwVhZZ3Gg/9fdqaBApHsdAXp0d5BrlxA==",
+ "dev": true,
+ "requires": {
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "detect-package-manager": "^2.0.1",
+ "fetch-retry": "^5.0.2",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "isomorphic-unfetch": "^3.1.0",
+ "nanoid": "^3.3.1",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@storybook/theming": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.5.12.tgz",
+ "integrity": "sha512-uWOo84qMQ2R6c1C0faZ4Q0nY01uNaX7nXoJKieoiJ6ZqY9PSYxJl1kZLi3uPYnrxLZjzjVyXX8MgdxzbppYItA==",
+ "dev": true,
+ "requires": {
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/ui": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/ui/-/ui-6.5.12.tgz",
+ "integrity": "sha512-P7+ARI5NvaEYkrbIciT/UMgy3kxMt4WCtHMXss2T01UMCIWh1Ws4BJaDNqtQSpKuwjjS4eqZL3aQWhlUpYAUEg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0"
+ }
+ },
+ "@storybook/web-components": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-6.5.12.tgz",
+ "integrity": "sha512-SkaLdaCYNXiKKtXoDcZ7sDvONkIB/NNfe39Kkijm/sotymi+7iDzbywwyAZY6tFxSy9DUkVZWQgexpmFpogWkw==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-import-meta": "^7.10.4",
+ "@babel/preset-env": "^7.12.11",
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/docs-tools": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@types/node": "^14.14.20 || ^16.0.0",
+ "@types/webpack-env": "^1.16.0",
+ "babel-plugin-bundled-import-meta": "^0.3.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "react": "16.14.0",
+ "react-dom": "16.14.0",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "react": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
+ "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
+ "dev": true,
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2"
+ }
+ },
+ "react-dom": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
+ "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
+ "dev": true,
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2",
+ "scheduler": "^0.19.1"
+ }
+ },
+ "scheduler": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
+ "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
+ "dev": true,
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1"
+ }
+ }
+ }
+ },
+ "@types/eslint": {
+ "version": "8.4.6",
+ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz",
+ "integrity": "sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==",
+ "dev": true,
+ "requires": {
+ "@types/estree": "*",
+ "@types/json-schema": "*"
+ }
+ },
+ "@types/eslint-scope": {
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz",
+ "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==",
+ "dev": true,
+ "requires": {
+ "@types/eslint": "*",
+ "@types/estree": "*"
+ }
+ },
+ "@types/estree": {
+ "version": "0.0.51",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
+ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
+ "dev": true
+ },
+ "@types/glob": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.0.0.tgz",
+ "integrity": "sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==",
+ "dev": true,
+ "requires": {
+ "@types/minimatch": "*",
+ "@types/node": "*"
+ }
+ },
+ "@types/graceful-fs": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
+ "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*"
+ }
+ },
+ "@types/hast": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz",
+ "integrity": "sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "*"
+ }
+ },
+ "@types/html-minifier-terser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
+ "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==",
+ "dev": true
+ },
+ "@types/is-function": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.1.tgz",
+ "integrity": "sha512-A79HEEiwXTFtfY+Bcbo58M2GRYzCr9itHWzbzHVFNEYCcoU/MMGwYYf721gBrnhpj1s6RGVVha/IgNFnR0Iw/Q==",
+ "dev": true
+ },
+ "@types/istanbul-lib-coverage": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
+ "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==",
+ "dev": true
+ },
+ "@types/istanbul-lib-report": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+ "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "*"
+ }
+ },
+ "@types/istanbul-reports": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz",
+ "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-report": "*"
+ }
+ },
+ "@types/json-schema": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
+ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
+ "dev": true
+ },
+ "@types/lodash": {
+ "version": "4.14.186",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz",
+ "integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==",
+ "dev": true
+ },
+ "@types/mdast": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
+ "integrity": "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "*"
+ }
+ },
+ "@types/minimatch": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
+ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
+ "dev": true
+ },
+ "@types/node": {
+ "version": "18.8.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.1.tgz",
+ "integrity": "sha512-vuYaNuEIbOYLTLUAJh50ezEbvxrD43iby+lpUA2aa148Nh5kX/AVO/9m1Ahmbux2iU5uxJTNF9g2Y+31uml7RQ==",
+ "dev": true
+ },
+ "@types/node-fetch": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz",
+ "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "form-data": "^3.0.0"
+ }
+ },
+ "@types/normalize-package-data": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz",
+ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
+ "dev": true
+ },
+ "@types/npmlog": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@types/npmlog/-/npmlog-4.1.4.tgz",
+ "integrity": "sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==",
+ "dev": true
+ },
+ "@types/parse-json": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
+ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
+ "dev": true
+ },
+ "@types/parse5": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz",
+ "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==",
+ "dev": true
+ },
+ "@types/pretty-hrtime": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz",
+ "integrity": "sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==",
+ "dev": true
+ },
+ "@types/qs": {
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
+ "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==",
+ "dev": true
+ },
+ "@types/source-list-map": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
+ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==",
+ "dev": true
+ },
+ "@types/tapable": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz",
+ "integrity": "sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==",
+ "dev": true
+ },
+ "@types/trusted-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
+ "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==",
+ "dev": true
+ },
+ "@types/uglify-js": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.0.tgz",
+ "integrity": "sha512-3HO6rm0y+/cqvOyA8xcYLweF0TKXlAxmQASjbOi49Co51A1N4nR4bEwBgRoD9kNM+rqFGArjKr654SLp2CoGmQ==",
+ "dev": true,
+ "requires": {
+ "source-map": "^0.6.1"
+ }
+ },
+ "@types/unist": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
+ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
+ "dev": true
+ },
+ "@types/webpack": {
+ "version": "4.41.32",
+ "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz",
+ "integrity": "sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "@types/tapable": "^1",
+ "@types/uglify-js": "*",
+ "@types/webpack-sources": "*",
+ "anymatch": "^3.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "@types/webpack-env": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.0.tgz",
+ "integrity": "sha512-56/MAlX5WMsPVbOg7tAxnYvNYMMWr/QJiIp6BxVSW3JJXUVzzOn64qW8TzQyMSqSUFM2+PVI4aUHcHOzIz/1tg==",
+ "dev": true
+ },
+ "@types/webpack-sources": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.0.tgz",
+ "integrity": "sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "@types/source-list-map": "*",
+ "source-map": "^0.7.3"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
+ "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+ "dev": true
+ }
+ }
+ },
+ "@types/yargs": {
+ "version": "15.0.14",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz",
+ "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==",
+ "dev": true,
+ "requires": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "@types/yargs-parser": {
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz",
+ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
+ "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-numbers": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1"
+ }
+ },
+ "@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz",
+ "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz",
+ "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz",
+ "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-code-frame": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz",
+ "integrity": "sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/wast-printer": "1.9.0"
+ },
+ "dependencies": {
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ }
+ }
+ },
+ "@webassemblyjs/helper-fsm": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz",
+ "integrity": "sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-module-context": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz",
+ "integrity": "sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0"
+ },
+ "dependencies": {
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ }
+ }
+ },
+ "@webassemblyjs/helper-numbers": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz",
+ "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/floating-point-hex-parser": "1.11.1",
+ "@webassemblyjs/helper-api-error": "1.11.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz",
+ "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz",
+ "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz",
+ "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz",
+ "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz",
+ "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz",
+ "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/helper-wasm-section": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1",
+ "@webassemblyjs/wasm-opt": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1",
+ "@webassemblyjs/wast-printer": "1.11.1"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz",
+ "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/ieee754": "1.11.1",
+ "@webassemblyjs/leb128": "1.11.1",
+ "@webassemblyjs/utf8": "1.11.1"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
+ "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz",
+ "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-api-error": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/ieee754": "1.11.1",
+ "@webassemblyjs/leb128": "1.11.1",
+ "@webassemblyjs/utf8": "1.11.1"
+ }
+ },
+ "@webassemblyjs/wast-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz",
+ "integrity": "sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/floating-point-hex-parser": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-code-frame": "1.9.0",
+ "@webassemblyjs/helper-fsm": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ },
+ "dependencies": {
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz",
+ "integrity": "sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ }
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz",
+ "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@xtuc/ieee754": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
+ "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+ "dev": true
+ },
+ "@xtuc/long": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
+ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+ "dev": true
+ },
+ "accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dev": true,
+ "requires": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ }
+ },
+ "acorn": {
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
+ "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
+ "dev": true
+ },
+ "acorn-import-assertions": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz",
+ "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==",
+ "dev": true,
+ "requires": {}
+ },
+ "address": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/address/-/address-1.2.1.tgz",
+ "integrity": "sha512-B+6bi5D34+fDYENiH5qOlA0cV2rAGKuWZ9LeyUUehbXy8e0VS9e498yO0Jeeh+iM+6KbfudHTFjXw2MmJD4QRA==",
+ "dev": true
+ },
+ "aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "dev": true,
+ "requires": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ }
+ },
+ "airbnb-js-shims": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/airbnb-js-shims/-/airbnb-js-shims-2.2.1.tgz",
+ "integrity": "sha512-wJNXPH66U2xjgo1Zwyjf9EydvJ2Si94+vSdk6EERcBfB2VZkeltpqIats0cqIZMLCXP3zcyaUKGYQeIBT6XjsQ==",
+ "dev": true,
+ "requires": {
+ "array-includes": "^3.0.3",
+ "array.prototype.flat": "^1.2.1",
+ "array.prototype.flatmap": "^1.2.1",
+ "es5-shim": "^4.5.13",
+ "es6-shim": "^0.35.5",
+ "function.prototype.name": "^1.1.0",
+ "globalthis": "^1.0.0",
+ "object.entries": "^1.1.0",
+ "object.fromentries": "^2.0.0 || ^1.0.0",
+ "object.getownpropertydescriptors": "^2.0.3",
+ "object.values": "^1.1.0",
+ "promise.allsettled": "^1.0.0",
+ "promise.prototype.finally": "^3.1.0",
+ "string.prototype.matchall": "^4.0.0 || ^3.0.1",
+ "string.prototype.padend": "^3.0.0",
+ "string.prototype.padstart": "^3.0.0",
+ "symbol.prototype.description": "^1.0.0"
+ }
+ },
+ "ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ajv-errors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
+ "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
+ "dev": true,
+ "requires": {}
+ },
+ "ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true,
+ "requires": {}
+ },
+ "ansi-align": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
+ "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+ "dev": true,
+ "requires": {
+ "string-width": "^4.1.0"
+ }
+ },
+ "ansi-colors": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz",
+ "integrity": "sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA==",
+ "dev": true
+ },
+ "ansi-html-community": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
+ "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==",
+ "dev": true
+ },
+ "ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^1.9.0"
+ }
+ },
+ "ansi-to-html": {
+ "version": "0.6.15",
+ "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.15.tgz",
+ "integrity": "sha512-28ijx2aHJGdzbs+O5SNQF65r6rrKYnkuwTYm8lZlChuoJ9P1vVzIpWO20sQTqTPDXYp6NFwk326vApTtLVFXpQ==",
+ "dev": true,
+ "requires": {
+ "entities": "^2.0.0"
+ }
+ },
+ "anymatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+ "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "dev": true,
+ "requires": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ }
+ },
+ "app-root-dir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz",
+ "integrity": "sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==",
+ "dev": true
+ },
+ "aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+ "dev": true
+ },
+ "are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "dev": true,
+ "requires": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ }
+ },
+ "argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "requires": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "arr-diff": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
+ "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==",
+ "dev": true
+ },
+ "arr-flatten": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz",
+ "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==",
+ "dev": true
+ },
+ "arr-union": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
+ "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
+ "dev": true
+ },
+ "array-find-index": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz",
+ "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==",
+ "dev": true,
+ "optional": true
+ },
+ "array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true
+ },
+ "array-includes": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.5.tgz",
+ "integrity": "sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5",
+ "get-intrinsic": "^1.1.1",
+ "is-string": "^1.0.7"
+ }
+ },
+ "array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true
+ },
+ "array-uniq": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
+ "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==",
+ "dev": true
+ },
+ "array-unique": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
+ "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==",
+ "dev": true
+ },
+ "array.prototype.flat": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz",
+ "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ }
+ },
+ "array.prototype.flatmap": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz",
+ "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ }
+ },
+ "array.prototype.map": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.4.tgz",
+ "integrity": "sha512-Qds9QnX7A0qISY7JT5WuJO0NJPE9CMlC6JzHQfhpqAAQQzufVRoeH7EzUY5GcPTx72voG8LV/5eo+b8Qi8hmhA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.0",
+ "es-array-method-boxes-properly": "^1.0.0",
+ "is-string": "^1.0.7"
+ }
+ },
+ "array.prototype.reduce": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.4.tgz",
+ "integrity": "sha512-WnM+AjG/DvLRLo4DDl+r+SvCzYtD2Jd9oeBYMcEaI7t3fFrHY9M53/wdLcTvmZNQ70IU6Htj0emFkZ5TS+lrdw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-array-method-boxes-properly": "^1.0.0",
+ "is-string": "^1.0.7"
+ }
+ },
+ "arrify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
+ "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
+ "dev": true
+ },
+ "asn1.js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "safer-buffer": "^2.1.0"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "assert": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz",
+ "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==",
+ "dev": true,
+ "requires": {
+ "object-assign": "^4.1.1",
+ "util": "0.10.3"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz",
+ "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==",
+ "dev": true
+ },
+ "util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
+ "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==",
+ "dev": true,
+ "requires": {
+ "inherits": "2.0.1"
+ }
+ }
+ }
+ },
+ "assign-symbols": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
+ "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
+ "dev": true
+ },
+ "async-each": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz",
+ "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==",
+ "dev": true,
+ "optional": true
+ },
+ "asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true
+ },
+ "at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true
+ },
+ "atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+ "dev": true
+ },
+ "autoprefixer": {
+ "version": "9.8.8",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.8.tgz",
+ "integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
+ "dev": true,
+ "requires": {
+ "browserslist": "^4.12.0",
+ "caniuse-lite": "^1.0.30001109",
+ "normalize-range": "^0.1.2",
+ "num2fraction": "^1.2.2",
+ "picocolors": "^0.2.1",
+ "postcss": "^7.0.32",
+ "postcss-value-parser": "^4.1.0"
+ }
+ },
+ "babel-loader": {
+ "version": "8.2.5",
+ "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz",
+ "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==",
+ "dev": true,
+ "requires": {
+ "find-cache-dir": "^3.3.1",
+ "loader-utils": "^2.0.0",
+ "make-dir": "^3.1.0",
+ "schema-utils": "^2.6.5"
+ },
+ "dependencies": {
+ "find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ }
+ },
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "requires": {
+ "semver": "^6.0.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ },
+ "pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "requires": {
+ "find-up": "^4.0.0"
+ }
+ }
+ }
+ },
+ "babel-plugin-apply-mdx-type-prop": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz",
+ "integrity": "sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "7.10.4",
+ "@mdx-js/util": "1.6.22"
+ },
+ "dependencies": {
+ "@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ }
+ }
+ },
+ "babel-plugin-bundled-import-meta": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-bundled-import-meta/-/babel-plugin-bundled-import-meta-0.3.2.tgz",
+ "integrity": "sha512-RMXzsnWoFHDSUc1X/QiejEwQBtQ0Y68HQZ542JQ4voFa5Sgl5f/D4T7+EOocUeSbiT4XIDbrhfxbH5OmcV8Ibw==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-syntax-import-meta": "^7.2.0",
+ "@babel/template": "^7.7.0"
+ }
+ },
+ "babel-plugin-dynamic-import-node": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz",
+ "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==",
+ "dev": true,
+ "requires": {
+ "object.assign": "^4.1.0"
+ }
+ },
+ "babel-plugin-extract-import-names": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz",
+ "integrity": "sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "7.10.4"
+ },
+ "dependencies": {
+ "@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ }
+ }
+ },
+ "babel-plugin-istanbul": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
+ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@istanbuljs/load-nyc-config": "^1.0.0",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-instrument": "^5.0.4",
+ "test-exclude": "^6.0.0"
+ }
+ },
+ "babel-plugin-macros": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ }
+ },
+ "babel-plugin-named-exports-order": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-named-exports-order/-/babel-plugin-named-exports-order-0.0.2.tgz",
+ "integrity": "sha512-OgOYHOLoRK+/mvXU9imKHlG6GkPLYrUCvFXG/CM93R/aNNO8pOOF4aS+S8CCHMDQoNSeiOYEZb/G6RwL95Jktw==",
+ "dev": true
+ },
+ "babel-plugin-polyfill-corejs2": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz",
+ "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==",
+ "dev": true,
+ "requires": {
+ "@babel/compat-data": "^7.17.7",
+ "@babel/helper-define-polyfill-provider": "^0.3.3",
+ "semver": "^6.1.1"
+ }
+ },
+ "babel-plugin-polyfill-corejs3": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz",
+ "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-define-polyfill-provider": "^0.3.3",
+ "core-js-compat": "^3.25.1"
+ }
+ },
+ "babel-plugin-polyfill-regenerator": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz",
+ "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-define-polyfill-provider": "^0.3.3"
+ }
+ },
+ "bail": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz",
+ "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==",
+ "dev": true
+ },
+ "balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "base": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz",
+ "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==",
+ "dev": true,
+ "requires": {
+ "cache-base": "^1.0.1",
+ "class-utils": "^0.3.5",
+ "component-emitter": "^1.2.1",
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.1",
+ "mixin-deep": "^1.2.0",
+ "pascalcase": "^0.1.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ }
+ }
+ },
+ "base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true
+ },
+ "better-opn": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz",
+ "integrity": "sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA==",
+ "dev": true,
+ "requires": {
+ "open": "^7.0.3"
+ },
+ "dependencies": {
+ "open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "requires": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ }
+ }
+ }
+ },
+ "big-integer": {
+ "version": "1.6.51",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
+ "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
+ "dev": true,
+ "optional": true
+ },
+ "big.js": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
+ "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
+ "dev": true
+ },
+ "binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true
+ },
+ "bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "dev": true
+ },
+ "bn.js": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
+ "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==",
+ "dev": true
+ },
+ "body-parser": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz",
+ "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==",
+ "dev": true,
+ "requires": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.4",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.10.3",
+ "raw-body": "2.5.1",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "dependencies": {
+ "bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true
+ },
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "qs": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
+ "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
+ "dev": true,
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
+ }
+ }
+ },
+ "boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true
+ },
+ "boxen": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
+ "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==",
+ "dev": true,
+ "requires": {
+ "ansi-align": "^3.0.0",
+ "camelcase": "^6.2.0",
+ "chalk": "^4.1.0",
+ "cli-boxes": "^2.2.1",
+ "string-width": "^4.2.2",
+ "type-fest": "^0.20.2",
+ "widest-line": "^3.1.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "bplist-parser": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.1.1.tgz",
+ "integrity": "sha512-2AEM0FXy8ZxVLBuqX0hqt1gDwcnz2zygEkQ6zaD5Wko/sB9paUNwlpawrFtKeHUAQUOzjVy9AO4oeonqIHKA9Q==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "big-integer": "^1.6.7"
+ }
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "requires": {
+ "fill-range": "^7.0.1"
+ }
+ },
+ "brorand": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
+ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
+ "dev": true
+ },
+ "browser-assert": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz",
+ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==",
+ "dev": true
+ },
+ "browserify-aes": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
+ "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
+ "dev": true,
+ "requires": {
+ "buffer-xor": "^1.0.3",
+ "cipher-base": "^1.0.0",
+ "create-hash": "^1.1.0",
+ "evp_bytestokey": "^1.0.3",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "browserify-cipher": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
+ "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
+ "dev": true,
+ "requires": {
+ "browserify-aes": "^1.0.4",
+ "browserify-des": "^1.0.0",
+ "evp_bytestokey": "^1.0.0"
+ }
+ },
+ "browserify-des": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
+ "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
+ "dev": true,
+ "requires": {
+ "cipher-base": "^1.0.1",
+ "des.js": "^1.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "browserify-rsa": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz",
+ "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^5.0.0",
+ "randombytes": "^2.0.1"
+ }
+ },
+ "browserify-sign": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz",
+ "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^5.1.1",
+ "browserify-rsa": "^4.0.1",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "elliptic": "^6.5.3",
+ "inherits": "^2.0.4",
+ "parse-asn1": "^5.1.5",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "dev": true,
+ "requires": {
+ "pako": "~1.0.5"
+ }
+ },
+ "browserslist": {
+ "version": "4.21.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
+ "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==",
+ "dev": true,
+ "requires": {
+ "caniuse-lite": "^1.0.30001400",
+ "electron-to-chromium": "^1.4.251",
+ "node-releases": "^2.0.6",
+ "update-browserslist-db": "^1.0.9"
+ }
+ },
+ "bser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+ "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+ "dev": true,
+ "requires": {
+ "node-int64": "^0.4.0"
+ }
+ },
+ "buffer": {
+ "version": "4.9.2",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
+ "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
+ "dev": true,
+ "requires": {
+ "base64-js": "^1.0.2",
+ "ieee754": "^1.1.4",
+ "isarray": "^1.0.0"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ }
+ }
+ },
+ "buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true
+ },
+ "buffer-xor": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
+ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==",
+ "dev": true
+ },
+ "builtin-status-codes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
+ "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==",
+ "dev": true
+ },
+ "bytes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+ "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
+ "dev": true
+ },
+ "cacache": {
+ "version": "15.3.0",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz",
+ "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==",
+ "dev": true,
+ "requires": {
+ "@npmcli/fs": "^1.0.0",
+ "@npmcli/move-file": "^1.0.1",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "glob": "^7.1.4",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.1",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.2",
+ "mkdirp": "^1.0.3",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^8.0.1",
+ "tar": "^6.0.2",
+ "unique-filename": "^1.1.1"
+ },
+ "dependencies": {
+ "p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "dev": true,
+ "requires": {
+ "aggregate-error": "^3.0.0"
+ }
+ }
+ }
+ },
+ "cache-base": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
+ "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==",
+ "dev": true,
+ "requires": {
+ "collection-visit": "^1.0.0",
+ "component-emitter": "^1.2.1",
+ "get-value": "^2.0.6",
+ "has-value": "^1.0.0",
+ "isobject": "^3.0.1",
+ "set-value": "^2.0.0",
+ "to-object-path": "^0.3.0",
+ "union-value": "^1.0.0",
+ "unset-value": "^1.0.0"
+ }
+ },
+ "cached-iterable": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/cached-iterable/-/cached-iterable-0.3.0.tgz",
+ "integrity": "sha512-MDqM6TpBVebZD4UDtmlFp8EjVtRcsB6xt9aRdWymjk0fWVUUGgmt/V7o0H0gkI2Tkvv8B0ucjidZm4mLosdlWw==",
+ "dev": true
+ },
+ "call-bind": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+ "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.0.2"
+ }
+ },
+ "call-me-maybe": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz",
+ "integrity": "sha512-wCyFsDQkKPwwF8BDwOiWNx/9K45L/hvggQiDbve+viMNMQnWhrlYIuBk09offfwCRtCO9P6XwUttufzU11WCVw==",
+ "dev": true
+ },
+ "callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true
+ },
+ "camel-case": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
+ "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
+ "dev": true,
+ "requires": {
+ "pascal-case": "^3.1.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true
+ },
+ "camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true
+ },
+ "camelcase-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
+ "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "camelcase": "^2.0.0",
+ "map-obj": "^1.0.0"
+ },
+ "dependencies": {
+ "camelcase": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz",
+ "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==",
+ "dev": true,
+ "optional": true
+ }
+ }
+ },
+ "caniuse-lite": {
+ "version": "1.0.30001415",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001415.tgz",
+ "integrity": "sha512-ER+PfgCJUe8BqunLGWd/1EY4g8AzQcsDAVzdtMGKVtQEmKAwaFfU6vb7EAVIqTMYsqxBorYZi2+22Iouj/y7GQ==",
+ "dev": true
+ },
+ "capture-exit": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz",
+ "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==",
+ "dev": true,
+ "requires": {
+ "rsvp": "^4.8.4"
+ }
+ },
+ "case-sensitive-paths-webpack-plugin": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz",
+ "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==",
+ "dev": true
+ },
+ "ccount": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz",
+ "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==",
+ "dev": true
+ },
+ "chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ }
+ },
+ "character-entities": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
+ "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
+ "dev": true
+ },
+ "character-entities-legacy": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
+ "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
+ "dev": true
+ },
+ "character-reference-invalid": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
+ "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
+ "dev": true
+ },
+ "chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "requires": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "fsevents": "~2.3.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ }
+ },
+ "chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "dev": true
+ },
+ "chrome-trace-event": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
+ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
+ "dev": true
+ },
+ "ci-info": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
+ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
+ "dev": true
+ },
+ "cipher-base": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
+ "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "class-utils": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz",
+ "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==",
+ "dev": true,
+ "requires": {
+ "arr-union": "^3.1.0",
+ "define-property": "^0.2.5",
+ "isobject": "^3.0.0",
+ "static-extend": "^0.1.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ }
+ },
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ }
+ }
+ },
+ "clean-css": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.1.tgz",
+ "integrity": "sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==",
+ "dev": true,
+ "requires": {
+ "source-map": "~0.6.0"
+ }
+ },
+ "clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "dev": true
+ },
+ "cli-boxes": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
+ "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
+ "dev": true
+ },
+ "cli-table3": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz",
+ "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==",
+ "dev": true,
+ "requires": {
+ "@colors/colors": "1.5.0",
+ "string-width": "^4.2.0"
+ }
+ },
+ "clone-deep": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
+ "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
+ "dev": true,
+ "requires": {
+ "is-plain-object": "^2.0.4",
+ "kind-of": "^6.0.2",
+ "shallow-clone": "^3.0.0"
+ }
+ },
+ "collapse-white-space": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz",
+ "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==",
+ "dev": true
+ },
+ "collection-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
+ "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==",
+ "dev": true,
+ "requires": {
+ "map-visit": "^1.0.0",
+ "object-visit": "^1.0.0"
+ }
+ },
+ "color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "requires": {
+ "color-name": "1.1.3"
+ }
+ },
+ "color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true
+ },
+ "color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "dev": true
+ },
+ "colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true
+ },
+ "combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "requires": {
+ "delayed-stream": "~1.0.0"
+ }
+ },
+ "comma-separated-tokens": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
+ "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
+ "dev": true
+ },
+ "commander": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+ "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
+ "dev": true
+ },
+ "commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "dev": true
+ },
+ "component-emitter": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
+ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
+ "dev": true
+ },
+ "compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "dev": true,
+ "requires": {
+ "mime-db": ">= 1.43.0 < 2"
+ }
+ },
+ "compression": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
+ "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
+ "dev": true,
+ "requires": {
+ "accepts": "~1.3.5",
+ "bytes": "3.0.0",
+ "compressible": "~2.0.16",
+ "debug": "2.6.9",
+ "on-headers": "~1.0.2",
+ "safe-buffer": "5.1.2",
+ "vary": "~1.1.2"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ }
+ }
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "concat-stream": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "dev": true,
+ "requires": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.2.2",
+ "typedarray": "^0.0.6"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "console-browserify": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
+ "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==",
+ "dev": true
+ },
+ "console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "dev": true
+ },
+ "constants-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
+ "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==",
+ "dev": true
+ },
+ "content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "5.2.1"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "content-type": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
+ "dev": true
+ },
+ "convert-source-map": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz",
+ "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.1"
+ }
+ },
+ "cookie": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
+ "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
+ "dev": true
+ },
+ "cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "dev": true
+ },
+ "copy-concurrently": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
+ "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==",
+ "dev": true,
+ "requires": {
+ "aproba": "^1.1.1",
+ "fs-write-stream-atomic": "^1.0.8",
+ "iferr": "^0.1.5",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.4",
+ "run-queue": "^1.0.0"
+ },
+ "dependencies": {
+ "aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ }
+ }
+ },
+ "copy-descriptor": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
+ "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==",
+ "dev": true
+ },
+ "core-js": {
+ "version": "3.25.5",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz",
+ "integrity": "sha512-nbm6eZSjm+ZuBQxCUPQKQCoUEfFOXjUZ8dTTyikyKaWrTYmAVbykQfwsKE5dBK88u3QCkCrzsx/PPlKfhsvgpw==",
+ "dev": true
+ },
+ "core-js-compat": {
+ "version": "3.25.5",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.5.tgz",
+ "integrity": "sha512-ovcyhs2DEBUIE0MGEKHP4olCUW/XYte3Vroyxuh38rD1wAO4dHohsovUC4eAOuzFxE6b+RXvBU3UZ9o0YhUTkA==",
+ "dev": true,
+ "requires": {
+ "browserslist": "^4.21.4"
+ }
+ },
+ "core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true
+ },
+ "cosmiconfig": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
+ "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
+ "dev": true,
+ "requires": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ }
+ },
+ "cp-file": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-7.0.0.tgz",
+ "integrity": "sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "make-dir": "^3.0.0",
+ "nested-error-stacks": "^2.0.0",
+ "p-event": "^4.1.0"
+ },
+ "dependencies": {
+ "make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "requires": {
+ "semver": "^6.0.0"
+ }
+ }
+ }
+ },
+ "cpy": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/cpy/-/cpy-8.1.2.tgz",
+ "integrity": "sha512-dmC4mUesv0OYH2kNFEidtf/skUwv4zePmGeepjyyJ0qTo5+8KhA1o99oIAwVVLzQMAeDJml74d6wPPKb6EZUTg==",
+ "dev": true,
+ "requires": {
+ "arrify": "^2.0.1",
+ "cp-file": "^7.0.0",
+ "globby": "^9.2.0",
+ "has-glob": "^1.0.0",
+ "junk": "^3.1.0",
+ "nested-error-stacks": "^2.1.0",
+ "p-all": "^2.1.0",
+ "p-filter": "^2.1.0",
+ "p-map": "^3.0.0"
+ },
+ "dependencies": {
+ "@nodelib/fs.stat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
+ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==",
+ "dev": true
+ },
+ "@types/glob": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz",
+ "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==",
+ "dev": true,
+ "requires": {
+ "@types/minimatch": "*",
+ "@types/node": "*"
+ }
+ },
+ "array-union": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
+ "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==",
+ "dev": true,
+ "requires": {
+ "array-uniq": "^1.0.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "dir-glob": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz",
+ "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==",
+ "dev": true,
+ "requires": {
+ "path-type": "^3.0.0"
+ }
+ },
+ "fast-glob": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.7.tgz",
+ "integrity": "sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==",
+ "dev": true,
+ "requires": {
+ "@mrmlnc/readdir-enhanced": "^2.2.1",
+ "@nodelib/fs.stat": "^1.1.2",
+ "glob-parent": "^3.1.0",
+ "is-glob": "^4.0.0",
+ "merge2": "^1.2.3",
+ "micromatch": "^3.1.10"
+ }
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^3.1.0",
+ "path-dirname": "^1.0.0"
+ },
+ "dependencies": {
+ "is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.0"
+ }
+ }
+ }
+ },
+ "globby": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz",
+ "integrity": "sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==",
+ "dev": true,
+ "requires": {
+ "@types/glob": "^7.1.1",
+ "array-union": "^1.0.2",
+ "dir-glob": "^2.2.2",
+ "fast-glob": "^2.2.6",
+ "glob": "^7.1.3",
+ "ignore": "^4.0.3",
+ "pify": "^4.0.1",
+ "slash": "^2.0.0"
+ }
+ },
+ "ignore": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+ "dev": true
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "path-type": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
+ "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==",
+ "dev": true,
+ "requires": {
+ "pify": "^3.0.0"
+ },
+ "dependencies": {
+ "pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
+ "dev": true
+ }
+ }
+ },
+ "slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ }
+ }
+ },
+ "create-ecdh": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
+ "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "elliptic": "^6.5.3"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "create-hash": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+ "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
+ "dev": true,
+ "requires": {
+ "cipher-base": "^1.0.1",
+ "inherits": "^2.0.1",
+ "md5.js": "^1.3.4",
+ "ripemd160": "^2.0.1",
+ "sha.js": "^2.4.0"
+ }
+ },
+ "create-hmac": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
+ "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
+ "dev": true,
+ "requires": {
+ "cipher-base": "^1.0.3",
+ "create-hash": "^1.1.0",
+ "inherits": "^2.0.1",
+ "ripemd160": "^2.0.0",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ }
+ },
+ "crypto-browserify": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
+ "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==",
+ "dev": true,
+ "requires": {
+ "browserify-cipher": "^1.0.0",
+ "browserify-sign": "^4.0.0",
+ "create-ecdh": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "create-hmac": "^1.1.0",
+ "diffie-hellman": "^5.0.0",
+ "inherits": "^2.0.1",
+ "pbkdf2": "^3.0.3",
+ "public-encrypt": "^4.0.0",
+ "randombytes": "^2.0.0",
+ "randomfill": "^1.0.3"
+ }
+ },
+ "css-loader": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz",
+ "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^5.1.0",
+ "loader-utils": "^2.0.0",
+ "postcss": "^8.2.15",
+ "postcss-modules-extract-imports": "^3.0.0",
+ "postcss-modules-local-by-default": "^4.0.0",
+ "postcss-modules-scope": "^3.0.0",
+ "postcss-modules-values": "^4.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^3.0.0",
+ "semver": "^7.3.5"
+ },
+ "dependencies": {
+ "icss-utils": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+ "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+ "dev": true,
+ "requires": {}
+ },
+ "picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "postcss": {
+ "version": "8.4.17",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.17.tgz",
+ "integrity": "sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==",
+ "dev": true,
+ "requires": {
+ "nanoid": "^3.3.4",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ }
+ },
+ "postcss-modules-extract-imports": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+ "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+ "dev": true,
+ "requires": {}
+ },
+ "postcss-modules-local-by-default": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+ "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^5.0.0",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ }
+ },
+ "postcss-modules-scope": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+ "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
+ "dev": true,
+ "requires": {
+ "postcss-selector-parser": "^6.0.4"
+ }
+ },
+ "postcss-modules-values": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+ "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^5.0.0"
+ }
+ },
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ }
+ }
+ },
+ "css-select": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
+ "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==",
+ "dev": true,
+ "requires": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.0.1",
+ "domhandler": "^4.3.1",
+ "domutils": "^2.8.0",
+ "nth-check": "^2.0.1"
+ }
+ },
+ "css-what": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
+ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
+ "dev": true
+ },
+ "cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true
+ },
+ "currently-unhandled": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
+ "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "array-find-index": "^1.0.1"
+ }
+ },
+ "cyclist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
+ "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==",
+ "dev": true
+ },
+ "debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "dev": true,
+ "optional": true
+ },
+ "decode-uri-component": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+ "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==",
+ "dev": true
+ },
+ "deepmerge": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
+ "dev": true
+ },
+ "default-browser-id": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-1.0.4.tgz",
+ "integrity": "sha512-qPy925qewwul9Hifs+3sx1ZYn14obHxpkX+mPD369w4Rzg+YkJBgi3SOvwUq81nWSjqGUegIgEPwD8u+HUnxlw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "bplist-parser": "^0.1.0",
+ "meow": "^3.1.0",
+ "untildify": "^2.0.0"
+ }
+ },
+ "define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true
+ },
+ "define-properties": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
+ "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
+ "dev": true,
+ "requires": {
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ }
+ },
+ "define-property": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz",
+ "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^1.0.2",
+ "isobject": "^3.0.1"
+ }
+ },
+ "delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true
+ },
+ "delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "dev": true
+ },
+ "depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true
+ },
+ "des.js": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz",
+ "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true
+ },
+ "detab": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/detab/-/detab-2.0.4.tgz",
+ "integrity": "sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==",
+ "dev": true,
+ "requires": {
+ "repeat-string": "^1.5.4"
+ }
+ },
+ "detect-package-manager": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/detect-package-manager/-/detect-package-manager-2.0.1.tgz",
+ "integrity": "sha512-j/lJHyoLlWi6G1LDdLgvUtz60Zo5GEj+sVYtTVXnYLDPuzgC3llMxonXym9zIwhhUII8vjdw0LXxavpLqTbl1A==",
+ "dev": true,
+ "requires": {
+ "execa": "^5.1.1"
+ }
+ },
+ "detect-port": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.5.1.tgz",
+ "integrity": "sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==",
+ "dev": true,
+ "requires": {
+ "address": "^1.0.1",
+ "debug": "4"
+ }
+ },
+ "diffie-hellman": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
+ "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "miller-rabin": "^4.0.0",
+ "randombytes": "^2.0.0"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "requires": {
+ "path-type": "^4.0.0"
+ }
+ },
+ "doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "dom-converter": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
+ "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==",
+ "dev": true,
+ "requires": {
+ "utila": "~0.4"
+ }
+ },
+ "dom-serializer": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
+ "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.2.0",
+ "entities": "^2.0.0"
+ }
+ },
+ "dom-walk": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
+ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==",
+ "dev": true
+ },
+ "domain-browser": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
+ "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==",
+ "dev": true
+ },
+ "domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true
+ },
+ "domhandler": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
+ "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.2.0"
+ }
+ },
+ "domutils": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
+ "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
+ "dev": true,
+ "requires": {
+ "dom-serializer": "^1.0.1",
+ "domelementtype": "^2.2.0",
+ "domhandler": "^4.2.0"
+ }
+ },
+ "dot-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+ "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+ "dev": true,
+ "requires": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "dotenv": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz",
+ "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==",
+ "dev": true
+ },
+ "dotenv-expand": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
+ "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==",
+ "dev": true
+ },
+ "duplexify": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
+ "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.0",
+ "stream-shift": "^1.0.0"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true
+ },
+ "electron-to-chromium": {
+ "version": "1.4.271",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.271.tgz",
+ "integrity": "sha512-BCPBtK07xR1/uY2HFDtl3wK2De66AW4MSiPlLrnPNxKC/Qhccxd59W73654S3y6Rb/k3hmuGJOBnhjfoutetXA==",
+ "dev": true
+ },
+ "elliptic": {
+ "version": "6.5.4",
+ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
+ "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.11.9",
+ "brorand": "^1.1.0",
+ "hash.js": "^1.0.0",
+ "hmac-drbg": "^1.0.1",
+ "inherits": "^2.0.4",
+ "minimalistic-assert": "^1.0.1",
+ "minimalistic-crypto-utils": "^1.0.1"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "emojis-list": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
+ "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
+ "dev": true
+ },
+ "encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "dev": true
+ },
+ "end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "dev": true,
+ "requires": {
+ "once": "^1.4.0"
+ }
+ },
+ "enhanced-resolve": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz",
+ "integrity": "sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "dependencies": {
+ "tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true
+ }
+ }
+ },
+ "entities": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
+ "dev": true
+ },
+ "errno": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
+ "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+ "dev": true,
+ "requires": {
+ "prr": "~1.0.1"
+ }
+ },
+ "error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dev": true,
+ "requires": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "es-abstract": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.3.tgz",
+ "integrity": "sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "function.prototype.name": "^1.1.5",
+ "get-intrinsic": "^1.1.3",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-property-descriptors": "^1.0.0",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.6",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.2",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.4",
+ "regexp.prototype.flags": "^1.4.3",
+ "safe-regex-test": "^1.0.0",
+ "string.prototype.trimend": "^1.0.5",
+ "string.prototype.trimstart": "^1.0.5",
+ "unbox-primitive": "^1.0.2"
+ }
+ },
+ "es-array-method-boxes-properly": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz",
+ "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==",
+ "dev": true
+ },
+ "es-get-iterator": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz",
+ "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.0",
+ "has-symbols": "^1.0.1",
+ "is-arguments": "^1.1.0",
+ "is-map": "^2.0.2",
+ "is-set": "^2.0.2",
+ "is-string": "^1.0.5",
+ "isarray": "^2.0.5"
+ }
+ },
+ "es-module-lexer": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz",
+ "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
+ "dev": true
+ },
+ "es-shim-unscopables": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz",
+ "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==",
+ "dev": true,
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "es-to-primitive": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+ "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+ "dev": true,
+ "requires": {
+ "is-callable": "^1.1.4",
+ "is-date-object": "^1.0.1",
+ "is-symbol": "^1.0.2"
+ }
+ },
+ "es5-shim": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.7.tgz",
+ "integrity": "sha512-jg21/dmlrNQI7JyyA2w7n+yifSxBng0ZralnSfVZjoCawgNTCnS+yBCyVM9DL5itm7SUnDGgv7hcq2XCZX4iRQ==",
+ "dev": true
+ },
+ "es6-shim": {
+ "version": "0.35.6",
+ "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz",
+ "integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==",
+ "dev": true
+ },
+ "escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "dev": true
+ },
+ "escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true
+ },
+ "eslint-scope": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^4.1.1"
+ },
+ "dependencies": {
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ }
+ }
+ },
+ "esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true
+ },
+ "esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.2.0"
+ }
+ },
+ "estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true
+ },
+ "esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true
+ },
+ "etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true
+ },
+ "events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "dev": true
+ },
+ "evp_bytestokey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
+ "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
+ "dev": true,
+ "requires": {
+ "md5.js": "^1.3.4",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "exec-sh": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz",
+ "integrity": "sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==",
+ "dev": true
+ },
+ "execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "requires": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ }
+ },
+ "expand-brackets": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
+ "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==",
+ "dev": true,
+ "requires": {
+ "debug": "^2.3.3",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "posix-character-classes": "^0.1.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ }
+ }
+ },
+ "express": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz",
+ "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==",
+ "dev": true,
+ "requires": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.0",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.5.0",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.2.0",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.1",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.7",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.10.3",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.18.0",
+ "serve-static": "1.15.0",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "qs": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
+ "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
+ "dev": true,
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "dev": true
+ },
+ "extend-shallow": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
+ "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
+ "dev": true,
+ "requires": {
+ "assign-symbols": "^1.0.0",
+ "is-extendable": "^1.0.1"
+ }
+ },
+ "extglob": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
+ "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==",
+ "dev": true,
+ "requires": {
+ "array-unique": "^0.3.2",
+ "define-property": "^1.0.0",
+ "expand-brackets": "^2.1.4",
+ "extend-shallow": "^2.0.1",
+ "fragment-cache": "^0.2.1",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ }
+ }
+ },
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "fast-glob": {
+ "version": "3.2.12",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
+ "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
+ "dev": true,
+ "requires": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ }
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "fastq": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+ "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+ "dev": true,
+ "requires": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "fb-watchman": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
+ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
+ "dev": true,
+ "requires": {
+ "bser": "2.1.1"
+ }
+ },
+ "fetch-retry": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-5.0.3.tgz",
+ "integrity": "sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw==",
+ "dev": true
+ },
+ "figgy-pudding": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
+ "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==",
+ "dev": true
+ },
+ "file-loader": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
+ "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "file-system-cache": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.1.0.tgz",
+ "integrity": "sha512-IzF5MBq+5CR0jXx5RxPe4BICl/oEhBSXKaL9fLhAXrIfIUS77Hr4vzrYyqYMHN6uTt+BOqi3fDCTjjEBCjERKw==",
+ "dev": true,
+ "requires": {
+ "fs-extra": "^10.1.0",
+ "ramda": "^0.28.0"
+ },
+ "dependencies": {
+ "fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ }
+ }
+ }
+ },
+ "file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "dev": true,
+ "optional": true
+ },
+ "fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "finalhandler": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
+ "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
+ "dev": true,
+ "requires": {
+ "debug": "2.6.9",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ }
+ }
+ },
+ "find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^3.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "requires": {
+ "find-up": "^3.0.0"
+ }
+ }
+ }
+ },
+ "find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "flush-write-stream": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
+ "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.3.6"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "for-in": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
+ "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
+ "dev": true
+ },
+ "fork-ts-checker-webpack-plugin": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz",
+ "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.8.3",
+ "@types/json-schema": "^7.0.5",
+ "chalk": "^4.1.0",
+ "chokidar": "^3.4.2",
+ "cosmiconfig": "^6.0.0",
+ "deepmerge": "^4.2.2",
+ "fs-extra": "^9.0.0",
+ "glob": "^7.1.6",
+ "memfs": "^3.1.2",
+ "minimatch": "^3.0.4",
+ "schema-utils": "2.7.0",
+ "semver": "^7.3.2",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "cosmiconfig": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz",
+ "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==",
+ "dev": true,
+ "requires": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.1.0",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.7.2"
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "schema-utils": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
+ "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.4",
+ "ajv": "^6.12.2",
+ "ajv-keywords": "^3.4.1"
+ }
+ },
+ "semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "form-data": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
+ "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+ "dev": true,
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ }
+ },
+ "forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true
+ },
+ "fragment-cache": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
+ "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==",
+ "dev": true,
+ "requires": {
+ "map-cache": "^0.2.2"
+ }
+ },
+ "fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true
+ },
+ "from2": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz",
+ "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.0"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "requires": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ }
+ },
+ "fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0"
+ }
+ },
+ "fs-monkey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz",
+ "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==",
+ "dev": true
+ },
+ "fs-write-stream-atomic": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
+ "integrity": "sha512-gehEzmPn2nAwr39eay+x3X34Ra+M2QlVUTLhkXPjWdeO8RF9kszk116avgBJM3ZyNHgHXBNx+VmPaFC36k0PzA==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "iferr": "^0.1.5",
+ "imurmurhash": "^0.1.4",
+ "readable-stream": "1 || 2"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "optional": true
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "function.prototype.name": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz",
+ "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.0",
+ "functions-have-names": "^1.2.2"
+ }
+ },
+ "functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true
+ },
+ "gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "dev": true,
+ "requires": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ }
+ },
+ "gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true
+ },
+ "get-intrinsic": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
+ "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3"
+ }
+ },
+ "get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+ "dev": true
+ },
+ "get-stdin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz",
+ "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==",
+ "dev": true,
+ "optional": true
+ },
+ "get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true
+ },
+ "get-symbol-description": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+ "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.1"
+ }
+ },
+ "get-value": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
+ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
+ "dev": true
+ },
+ "github-slugger": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.4.0.tgz",
+ "integrity": "sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==",
+ "dev": true
+ },
+ "glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "glob-promise": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/glob-promise/-/glob-promise-3.4.0.tgz",
+ "integrity": "sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw==",
+ "dev": true,
+ "requires": {
+ "@types/glob": "*"
+ }
+ },
+ "glob-to-regexp": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "dev": true
+ },
+ "global": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
+ "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+ "dev": true,
+ "requires": {
+ "min-document": "^2.19.0",
+ "process": "^0.11.10"
+ }
+ },
+ "globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true
+ },
+ "globalthis": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
+ "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==",
+ "dev": true,
+ "requires": {
+ "define-properties": "^1.1.3"
+ }
+ },
+ "globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "requires": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ }
+ },
+ "graceful-fs": {
+ "version": "4.2.10",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
+ "dev": true
+ },
+ "handlebars": {
+ "version": "4.7.7",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+ "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.0",
+ "source-map": "^0.6.1",
+ "uglify-js": "^3.1.4",
+ "wordwrap": "^1.0.0"
+ }
+ },
+ "has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1"
+ }
+ },
+ "has-bigints": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
+ "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true
+ },
+ "has-glob": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-glob/-/has-glob-1.0.0.tgz",
+ "integrity": "sha512-D+8A457fBShSEI3tFCj65PAbT++5sKiFtdCdOam0gnfBgw9D277OERk+HM9qYJXmdVLZ/znez10SqHN0BBQ50g==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^3.0.0"
+ },
+ "dependencies": {
+ "is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.0"
+ }
+ }
+ }
+ },
+ "has-property-descriptors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
+ "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
+ "dev": true,
+ "requires": {
+ "get-intrinsic": "^1.1.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "has-tostringtag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+ "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+ "dev": true,
+ "requires": {
+ "has-symbols": "^1.0.2"
+ }
+ },
+ "has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "dev": true
+ },
+ "has-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz",
+ "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==",
+ "dev": true,
+ "requires": {
+ "get-value": "^2.0.6",
+ "has-values": "^1.0.0",
+ "isobject": "^3.0.0"
+ }
+ },
+ "has-values": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz",
+ "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "kind-of": "^4.0.0"
+ },
+ "dependencies": {
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "kind-of": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz",
+ "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "hash-base": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz",
+ "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
+ "hast-to-hyperscript": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz",
+ "integrity": "sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.3",
+ "comma-separated-tokens": "^1.0.0",
+ "property-information": "^5.3.0",
+ "space-separated-tokens": "^1.0.0",
+ "style-to-object": "^0.3.0",
+ "unist-util-is": "^4.0.0",
+ "web-namespaces": "^1.0.0"
+ }
+ },
+ "hast-util-from-parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-6.0.1.tgz",
+ "integrity": "sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==",
+ "dev": true,
+ "requires": {
+ "@types/parse5": "^5.0.0",
+ "hastscript": "^6.0.0",
+ "property-information": "^5.0.0",
+ "vfile": "^4.0.0",
+ "vfile-location": "^3.2.0",
+ "web-namespaces": "^1.0.0"
+ }
+ },
+ "hast-util-parse-selector": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
+ "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
+ "dev": true
+ },
+ "hast-util-raw": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-6.0.1.tgz",
+ "integrity": "sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig==",
+ "dev": true,
+ "requires": {
+ "@types/hast": "^2.0.0",
+ "hast-util-from-parse5": "^6.0.0",
+ "hast-util-to-parse5": "^6.0.0",
+ "html-void-elements": "^1.0.0",
+ "parse5": "^6.0.0",
+ "unist-util-position": "^3.0.0",
+ "vfile": "^4.0.0",
+ "web-namespaces": "^1.0.0",
+ "xtend": "^4.0.0",
+ "zwitch": "^1.0.0"
+ }
+ },
+ "hast-util-to-parse5": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz",
+ "integrity": "sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==",
+ "dev": true,
+ "requires": {
+ "hast-to-hyperscript": "^9.0.0",
+ "property-information": "^5.0.0",
+ "web-namespaces": "^1.0.0",
+ "xtend": "^4.0.0",
+ "zwitch": "^1.0.0"
+ }
+ },
+ "hastscript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
+ "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
+ "dev": true,
+ "requires": {
+ "@types/hast": "^2.0.0",
+ "comma-separated-tokens": "^1.0.0",
+ "hast-util-parse-selector": "^2.0.0",
+ "property-information": "^5.0.0",
+ "space-separated-tokens": "^1.0.0"
+ }
+ },
+ "he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true
+ },
+ "hmac-drbg": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+ "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
+ "dev": true,
+ "requires": {
+ "hash.js": "^1.0.3",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "hosted-git-info": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+ "dev": true
+ },
+ "html-entities": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
+ "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==",
+ "dev": true
+ },
+ "html-minifier-terser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
+ "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
+ "dev": true,
+ "requires": {
+ "camel-case": "^4.1.2",
+ "clean-css": "^5.2.2",
+ "commander": "^8.3.0",
+ "he": "^1.2.0",
+ "param-case": "^3.0.4",
+ "relateurl": "^0.2.7",
+ "terser": "^5.10.0"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "dev": true
+ }
+ }
+ },
+ "html-void-elements": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz",
+ "integrity": "sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==",
+ "dev": true
+ },
+ "html-webpack-plugin": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz",
+ "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==",
+ "dev": true,
+ "requires": {
+ "@types/html-minifier-terser": "^6.0.0",
+ "html-minifier-terser": "^6.0.2",
+ "lodash": "^4.17.21",
+ "pretty-error": "^4.0.0",
+ "tapable": "^2.0.0"
+ },
+ "dependencies": {
+ "tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true
+ }
+ }
+ },
+ "htmlparser2": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
+ "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.0.0",
+ "domutils": "^2.5.2",
+ "entities": "^2.0.0"
+ }
+ },
+ "http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dev": true,
+ "requires": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ }
+ },
+ "https-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
+ "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==",
+ "dev": true
+ },
+ "human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true
+ },
+ "iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "requires": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ }
+ },
+ "icss-utils": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
+ "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.14"
+ }
+ },
+ "ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true
+ },
+ "iferr": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz",
+ "integrity": "sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA==",
+ "dev": true
+ },
+ "ignore": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
+ "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+ "dev": true
+ },
+ "import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "requires": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "dependencies": {
+ "resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true
+ }
+ }
+ },
+ "imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true
+ },
+ "indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true
+ },
+ "infer-owner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
+ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
+ "dev": true
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "dev": true,
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "inline-style-parser": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
+ "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
+ "dev": true
+ },
+ "internal-slot": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+ "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+ "dev": true,
+ "requires": {
+ "get-intrinsic": "^1.1.0",
+ "has": "^1.0.3",
+ "side-channel": "^1.0.4"
+ }
+ },
+ "interpret": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
+ "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
+ "dev": true
+ },
+ "ip": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
+ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
+ "dev": true
+ },
+ "ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true
+ },
+ "is-absolute-url": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz",
+ "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==",
+ "dev": true
+ },
+ "is-accessor-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
+ "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-alphabetical": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
+ "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
+ "dev": true
+ },
+ "is-alphanumerical": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
+ "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
+ "dev": true,
+ "requires": {
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0"
+ }
+ },
+ "is-arguments": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+ "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true
+ },
+ "is-bigint": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+ "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+ "dev": true,
+ "requires": {
+ "has-bigints": "^1.0.1"
+ }
+ },
+ "is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "requires": {
+ "binary-extensions": "^2.0.0"
+ }
+ },
+ "is-boolean-object": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+ "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-buffer": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
+ "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true
+ },
+ "is-ci": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz",
+ "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==",
+ "dev": true,
+ "requires": {
+ "ci-info": "^2.0.0"
+ }
+ },
+ "is-core-module": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz",
+ "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==",
+ "dev": true,
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "is-data-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
+ "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-date-object": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+ "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-decimal": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
+ "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
+ "dev": true
+ },
+ "is-descriptor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
+ "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^1.0.0",
+ "is-data-descriptor": "^1.0.0",
+ "kind-of": "^6.0.2"
+ }
+ },
+ "is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true
+ },
+ "is-dom": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-dom/-/is-dom-1.1.0.tgz",
+ "integrity": "sha512-u82f6mvhYxRPKpw8V1N0W8ce1xXwOrQtgGcxl6UCL5zBmZu3is/18K0rR7uFCnMDuAsS/3W54mGL4vsaFUQlEQ==",
+ "dev": true,
+ "requires": {
+ "is-object": "^1.0.1",
+ "is-window": "^1.0.2"
+ }
+ },
+ "is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "dev": true,
+ "requires": {
+ "is-plain-object": "^2.0.4"
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true
+ },
+ "is-finite": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz",
+ "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==",
+ "dev": true,
+ "optional": true
+ },
+ "is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true
+ },
+ "is-function": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
+ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
+ "dev": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-hexadecimal": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
+ "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
+ "dev": true
+ },
+ "is-map": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
+ "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true
+ },
+ "is-number-object": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz",
+ "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-object": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
+ "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
+ "dev": true
+ },
+ "is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "dev": true
+ },
+ "is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dev": true,
+ "requires": {
+ "isobject": "^3.0.1"
+ }
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-set": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
+ "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+ "dev": true
+ },
+ "is-shared-array-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz",
+ "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2"
+ }
+ },
+ "is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-symbol": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+ "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+ "dev": true,
+ "requires": {
+ "has-symbols": "^1.0.2"
+ }
+ },
+ "is-typedarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+ "dev": true
+ },
+ "is-utf8": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz",
+ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==",
+ "dev": true,
+ "optional": true
+ },
+ "is-weakref": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+ "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2"
+ }
+ },
+ "is-whitespace-character": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz",
+ "integrity": "sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==",
+ "dev": true
+ },
+ "is-window": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz",
+ "integrity": "sha512-uj00kdXyZb9t9RcAUAwMZAnkBUwdYGhYlt7djMXhfyhUCzwNba50tIiBKR7q0l7tdoBtFVw/3JmLY6fI3rmZmg==",
+ "dev": true
+ },
+ "is-windows": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+ "dev": true
+ },
+ "is-word-character": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz",
+ "integrity": "sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==",
+ "dev": true
+ },
+ "is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "requires": {
+ "is-docker": "^2.0.0"
+ }
+ },
+ "isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true
+ },
+ "isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "dev": true
+ },
+ "isomorphic-unfetch": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz",
+ "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==",
+ "dev": true,
+ "requires": {
+ "node-fetch": "^2.6.1",
+ "unfetch": "^4.2.0"
+ }
+ },
+ "istanbul-lib-coverage": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+ "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+ "dev": true
+ },
+ "istanbul-lib-instrument": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz",
+ "integrity": "sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.3",
+ "@babel/parser": "^7.14.7",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^6.3.0"
+ }
+ },
+ "iterate-iterator": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.2.tgz",
+ "integrity": "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==",
+ "dev": true
+ },
+ "iterate-value": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz",
+ "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==",
+ "dev": true,
+ "requires": {
+ "es-get-iterator": "^1.0.2",
+ "iterate-iterator": "^1.0.1"
+ }
+ },
+ "jest-haste-map": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz",
+ "integrity": "sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==",
+ "dev": true,
+ "requires": {
+ "@jest/types": "^26.6.2",
+ "@types/graceful-fs": "^4.1.2",
+ "@types/node": "*",
+ "anymatch": "^3.0.3",
+ "fb-watchman": "^2.0.0",
+ "fsevents": "^2.1.2",
+ "graceful-fs": "^4.2.4",
+ "jest-regex-util": "^26.0.0",
+ "jest-serializer": "^26.6.2",
+ "jest-util": "^26.6.2",
+ "jest-worker": "^26.6.2",
+ "micromatch": "^4.0.2",
+ "sane": "^4.0.3",
+ "walker": "^1.0.7"
+ }
+ },
+ "jest-regex-util": {
+ "version": "26.0.0",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz",
+ "integrity": "sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==",
+ "dev": true
+ },
+ "jest-serializer": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz",
+ "integrity": "sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "graceful-fs": "^4.2.4"
+ }
+ },
+ "jest-util": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz",
+ "integrity": "sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==",
+ "dev": true,
+ "requires": {
+ "@jest/types": "^26.6.2",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.4",
+ "is-ci": "^2.0.0",
+ "micromatch": "^4.0.2"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "jest-worker": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
+ "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^7.0.0"
+ },
+ "dependencies": {
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "js-string-escape": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz",
+ "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==",
+ "dev": true
+ },
+ "js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "requires": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ }
+ },
+ "jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "dev": true
+ },
+ "json-parse-better-errors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+ "dev": true
+ },
+ "json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "json5": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
+ "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
+ "dev": true
+ },
+ "jsonfile": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.6",
+ "universalify": "^2.0.0"
+ }
+ },
+ "junk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
+ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true
+ },
+ "kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true
+ },
+ "klona": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
+ "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
+ "dev": true
+ },
+ "lazy-universal-dotenv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lazy-universal-dotenv/-/lazy-universal-dotenv-3.0.1.tgz",
+ "integrity": "sha512-prXSYk799h3GY3iOWnC6ZigYzMPjxN2svgjJ9shk7oMadSNX3wXy0B6F32PMJv7qtMnrIbUxoEHzbutvxR2LBQ==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.5.0",
+ "app-root-dir": "^1.0.2",
+ "core-js": "^3.0.4",
+ "dotenv": "^8.0.0",
+ "dotenv-expand": "^5.1.0"
+ }
+ },
+ "lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true
+ },
+ "lit": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-2.3.1.tgz",
+ "integrity": "sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw==",
+ "dev": true,
+ "requires": {
+ "@lit/reactive-element": "^1.4.0",
+ "lit-element": "^3.2.0",
+ "lit-html": "^2.3.0"
+ }
+ },
+ "lit-element": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.2.tgz",
+ "integrity": "sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==",
+ "dev": true,
+ "requires": {
+ "@lit/reactive-element": "^1.3.0",
+ "lit-html": "^2.2.0"
+ }
+ },
+ "lit-html": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.3.1.tgz",
+ "integrity": "sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA==",
+ "dev": true,
+ "requires": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
+ "load-json-file": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
+ "integrity": "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "parse-json": "^2.2.0",
+ "pify": "^2.0.0",
+ "pinkie-promise": "^2.0.0",
+ "strip-bom": "^2.0.0"
+ },
+ "dependencies": {
+ "parse-json": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
+ "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "error-ex": "^1.2.0"
+ }
+ },
+ "pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "optional": true
+ }
+ }
+ },
+ "loader-runner": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
+ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
+ "dev": true
+ },
+ "loader-utils": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
+ "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^2.1.2"
+ }
+ },
+ "locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^5.0.0"
+ }
+ },
+ "lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true
+ },
+ "lodash.uniq": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+ "dev": true
+ },
+ "loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
+ "requires": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "loud-rejection": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz",
+ "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "currently-unhandled": "^0.4.1",
+ "signal-exit": "^3.0.0"
+ }
+ },
+ "lower-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+ "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+ "dev": true,
+ "requires": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
+ "make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "requires": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ }
+ }
+ },
+ "makeerror": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
+ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+ "dev": true,
+ "requires": {
+ "tmpl": "1.0.5"
+ }
+ },
+ "map-age-cleaner": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz",
+ "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==",
+ "dev": true,
+ "requires": {
+ "p-defer": "^1.0.0"
+ }
+ },
+ "map-cache": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
+ "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
+ "dev": true
+ },
+ "map-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
+ "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
+ "dev": true,
+ "optional": true
+ },
+ "map-or-similar": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz",
+ "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==",
+ "dev": true
+ },
+ "map-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz",
+ "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==",
+ "dev": true,
+ "requires": {
+ "object-visit": "^1.0.0"
+ }
+ },
+ "markdown-escapes": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz",
+ "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==",
+ "dev": true
+ },
+ "md5.js": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
+ "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
+ "dev": true,
+ "requires": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "mdast-squeeze-paragraphs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz",
+ "integrity": "sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ==",
+ "dev": true,
+ "requires": {
+ "unist-util-remove": "^2.0.0"
+ }
+ },
+ "mdast-util-definitions": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz",
+ "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==",
+ "dev": true,
+ "requires": {
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "mdast-util-to-hast": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz",
+ "integrity": "sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA==",
+ "dev": true,
+ "requires": {
+ "@types/mdast": "^3.0.0",
+ "@types/unist": "^2.0.0",
+ "mdast-util-definitions": "^4.0.0",
+ "mdurl": "^1.0.0",
+ "unist-builder": "^2.0.0",
+ "unist-util-generated": "^1.0.0",
+ "unist-util-position": "^3.0.0",
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "mdast-util-to-string": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz",
+ "integrity": "sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A==",
+ "dev": true
+ },
+ "mdurl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
+ "dev": true
+ },
+ "media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true
+ },
+ "mem": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz",
+ "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==",
+ "dev": true,
+ "requires": {
+ "map-age-cleaner": "^0.1.3",
+ "mimic-fn": "^3.1.0"
+ },
+ "dependencies": {
+ "mimic-fn": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
+ "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
+ "dev": true
+ }
+ }
+ },
+ "memfs": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.7.tgz",
+ "integrity": "sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==",
+ "dev": true,
+ "requires": {
+ "fs-monkey": "^1.0.3"
+ }
+ },
+ "memoizerific": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz",
+ "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==",
+ "dev": true,
+ "requires": {
+ "map-or-similar": "^1.5.0"
+ }
+ },
+ "memory-fs": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
+ "integrity": "sha512-cda4JKCxReDXFXRqOHPQscuIYg1PvxbE2S2GP45rnwfEK+vZaXC8C1OFvdHIbgw0DLzowXGVoxLaAmlgRy14GQ==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "meow": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
+ "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "camelcase-keys": "^2.0.0",
+ "decamelize": "^1.1.2",
+ "loud-rejection": "^1.0.0",
+ "map-obj": "^1.0.1",
+ "minimist": "^1.1.3",
+ "normalize-package-data": "^2.3.4",
+ "object-assign": "^4.0.1",
+ "read-pkg-up": "^1.0.1",
+ "redent": "^1.0.0",
+ "trim-newlines": "^1.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz",
+ "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "path-exists": "^2.0.0",
+ "pinkie-promise": "^2.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz",
+ "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "pinkie-promise": "^2.0.0"
+ }
+ },
+ "path-type": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz",
+ "integrity": "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "pify": "^2.0.0",
+ "pinkie-promise": "^2.0.0"
+ }
+ },
+ "pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "optional": true
+ },
+ "read-pkg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",
+ "integrity": "sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "load-json-file": "^1.0.0",
+ "normalize-package-data": "^2.3.2",
+ "path-type": "^1.0.0"
+ }
+ },
+ "read-pkg-up": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz",
+ "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "find-up": "^1.0.0",
+ "read-pkg": "^1.0.0"
+ }
+ }
+ }
+ },
+ "merge-descriptors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+ "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
+ "dev": true
+ },
+ "merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true
+ },
+ "methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true
+ },
+ "microevent.ts": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/microevent.ts/-/microevent.ts-0.1.1.tgz",
+ "integrity": "sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g==",
+ "dev": true
+ },
+ "micromatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+ "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+ "dev": true,
+ "requires": {
+ "braces": "^3.0.2",
+ "picomatch": "^2.3.1"
+ }
+ },
+ "miller-rabin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
+ "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.0.0",
+ "brorand": "^1.0.1"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true
+ },
+ "mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true
+ },
+ "mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "requires": {
+ "mime-db": "1.52.0"
+ }
+ },
+ "mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true
+ },
+ "min-document": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+ "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
+ "dev": true,
+ "requires": {
+ "dom-walk": "^0.1.0"
+ }
+ },
+ "minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "dev": true
+ },
+ "minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
+ "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
+ "dev": true
+ },
+ "minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "minimist": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
+ "dev": true
+ },
+ "minipass": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.4.tgz",
+ "integrity": "sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==",
+ "dev": true,
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
+ "minipass-collect": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz",
+ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0"
+ }
+ },
+ "minipass-flush": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+ "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0"
+ }
+ },
+ "minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0"
+ }
+ },
+ "minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ }
+ },
+ "mississippi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz",
+ "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==",
+ "dev": true,
+ "requires": {
+ "concat-stream": "^1.5.0",
+ "duplexify": "^3.4.2",
+ "end-of-stream": "^1.1.0",
+ "flush-write-stream": "^1.0.0",
+ "from2": "^2.1.0",
+ "parallel-transform": "^1.1.0",
+ "pump": "^3.0.0",
+ "pumpify": "^1.3.3",
+ "stream-each": "^1.1.0",
+ "through2": "^2.0.0"
+ }
+ },
+ "mixin-deep": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
+ "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
+ "dev": true,
+ "requires": {
+ "for-in": "^1.0.2",
+ "is-extendable": "^1.0.1"
+ }
+ },
+ "mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true
+ },
+ "move-concurrently": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
+ "integrity": "sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==",
+ "dev": true,
+ "requires": {
+ "aproba": "^1.1.1",
+ "copy-concurrently": "^1.0.0",
+ "fs-write-stream-atomic": "^1.0.8",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.4",
+ "run-queue": "^1.0.3"
+ },
+ "dependencies": {
+ "aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ }
+ }
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "nan": {
+ "version": "2.16.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.16.0.tgz",
+ "integrity": "sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==",
+ "dev": true,
+ "optional": true
+ },
+ "nanoid": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
+ "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
+ "dev": true
+ },
+ "nanomatch": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
+ "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "fragment-cache": "^0.2.1",
+ "is-windows": "^1.0.2",
+ "kind-of": "^6.0.2",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ }
+ },
+ "negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "dev": true
+ },
+ "neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true
+ },
+ "nested-error-stacks": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz",
+ "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==",
+ "dev": true
+ },
+ "nice-try": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
+ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
+ "dev": true
+ },
+ "no-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+ "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+ "dev": true,
+ "requires": {
+ "lower-case": "^2.0.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node-fetch": {
+ "version": "2.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
+ "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
+ "dev": true,
+ "requires": {
+ "whatwg-url": "^5.0.0"
+ }
+ },
+ "node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "dev": true
+ },
+ "node-libs-browser": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
+ "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==",
+ "dev": true,
+ "requires": {
+ "assert": "^1.1.1",
+ "browserify-zlib": "^0.2.0",
+ "buffer": "^4.3.0",
+ "console-browserify": "^1.1.0",
+ "constants-browserify": "^1.0.0",
+ "crypto-browserify": "^3.11.0",
+ "domain-browser": "^1.1.1",
+ "events": "^3.0.0",
+ "https-browserify": "^1.0.0",
+ "os-browserify": "^0.3.0",
+ "path-browserify": "0.0.1",
+ "process": "^0.11.10",
+ "punycode": "^1.2.4",
+ "querystring-es3": "^0.2.0",
+ "readable-stream": "^2.3.3",
+ "stream-browserify": "^2.0.1",
+ "stream-http": "^2.7.2",
+ "string_decoder": "^1.0.0",
+ "timers-browserify": "^2.0.4",
+ "tty-browserify": "0.0.0",
+ "url": "^0.11.0",
+ "util": "^0.11.0",
+ "vm-browserify": "^1.0.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "path-browserify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz",
+ "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==",
+ "dev": true
+ },
+ "punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "node-releases": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz",
+ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
+ "dev": true
+ },
+ "normalize-package-data": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+ "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+ "dev": true,
+ "requires": {
+ "hosted-git-info": "^2.1.4",
+ "resolve": "^1.10.0",
+ "semver": "2 || 3 || 4 || 5",
+ "validate-npm-package-license": "^3.0.1"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ }
+ }
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true
+ },
+ "normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true
+ },
+ "npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.0.0"
+ }
+ },
+ "npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "dev": true,
+ "requires": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
+ "nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "requires": {
+ "boolbase": "^1.0.0"
+ }
+ },
+ "num2fraction": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
+ "integrity": "sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==",
+ "dev": true
+ },
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true
+ },
+ "object-copy": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
+ "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==",
+ "dev": true,
+ "requires": {
+ "copy-descriptor": "^0.1.0",
+ "define-property": "^0.2.5",
+ "kind-of": "^3.0.3"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ }
+ }
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz",
+ "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==",
+ "dev": true
+ },
+ "object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true
+ },
+ "object-visit": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
+ "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==",
+ "dev": true,
+ "requires": {
+ "isobject": "^3.0.0"
+ }
+ },
+ "object.assign": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz",
+ "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "has-symbols": "^1.0.3",
+ "object-keys": "^1.1.1"
+ }
+ },
+ "object.entries": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz",
+ "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "object.fromentries": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz",
+ "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "object.getownpropertydescriptors": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.4.tgz",
+ "integrity": "sha512-sccv3L/pMModT6dJAYF3fzGMVcb38ysQ0tEE6ixv2yXJDtEIPph268OlAdJj5/qZMZDq2g/jqvwppt36uS/uQQ==",
+ "dev": true,
+ "requires": {
+ "array.prototype.reduce": "^1.0.4",
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.20.1"
+ }
+ },
+ "object.pick": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
+ "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
+ "dev": true,
+ "requires": {
+ "isobject": "^3.0.1"
+ }
+ },
+ "object.values": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+ "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "requires": {
+ "ee-first": "1.1.1"
+ }
+ },
+ "on-headers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
+ "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+ "dev": true
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "requires": {
+ "mimic-fn": "^2.1.0"
+ }
+ },
+ "open": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz",
+ "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==",
+ "dev": true,
+ "requires": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ }
+ },
+ "os-browserify": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
+ "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==",
+ "dev": true
+ },
+ "os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
+ "dev": true,
+ "optional": true
+ },
+ "p-all": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-all/-/p-all-2.1.0.tgz",
+ "integrity": "sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA==",
+ "dev": true,
+ "requires": {
+ "p-map": "^2.0.0"
+ },
+ "dependencies": {
+ "p-map": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+ "dev": true
+ }
+ }
+ },
+ "p-defer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
+ "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==",
+ "dev": true
+ },
+ "p-event": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
+ "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==",
+ "dev": true,
+ "requires": {
+ "p-timeout": "^3.1.0"
+ }
+ },
+ "p-filter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz",
+ "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==",
+ "dev": true,
+ "requires": {
+ "p-map": "^2.0.0"
+ },
+ "dependencies": {
+ "p-map": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+ "dev": true
+ }
+ }
+ },
+ "p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
+ "dev": true
+ },
+ "p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "requires": {
+ "yocto-queue": "^0.1.0"
+ }
+ },
+ "p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^3.0.2"
+ }
+ },
+ "p-map": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+ "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+ "dev": true,
+ "requires": {
+ "aggregate-error": "^3.0.0"
+ }
+ },
+ "p-timeout": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+ "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+ "dev": true,
+ "requires": {
+ "p-finally": "^1.0.0"
+ }
+ },
+ "p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true
+ },
+ "pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true
+ },
+ "parallel-transform": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",
+ "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==",
+ "dev": true,
+ "requires": {
+ "cyclist": "^1.0.1",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.1.5"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "param-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
+ "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
+ "dev": true,
+ "requires": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "requires": {
+ "callsites": "^3.0.0"
+ }
+ },
+ "parse-asn1": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz",
+ "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==",
+ "dev": true,
+ "requires": {
+ "asn1.js": "^5.2.0",
+ "browserify-aes": "^1.0.0",
+ "evp_bytestokey": "^1.0.0",
+ "pbkdf2": "^3.0.3",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "parse-entities": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
+ "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
+ "dev": true,
+ "requires": {
+ "character-entities": "^1.0.0",
+ "character-entities-legacy": "^1.0.0",
+ "character-reference-invalid": "^1.0.0",
+ "is-alphanumerical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-hexadecimal": "^1.0.0"
+ }
+ },
+ "parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ }
+ },
+ "parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+ "dev": true
+ },
+ "parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true
+ },
+ "pascal-case": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
+ "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
+ "dev": true,
+ "requires": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "pascalcase": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
+ "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==",
+ "dev": true
+ },
+ "path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true
+ },
+ "path-dirname": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz",
+ "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==",
+ "dev": true
+ },
+ "path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true
+ },
+ "path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true
+ },
+ "path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "path-to-regexp": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
+ "dev": true
+ },
+ "path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true
+ },
+ "pbkdf2": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
+ "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==",
+ "dev": true,
+ "requires": {
+ "create-hash": "^1.1.2",
+ "create-hmac": "^1.1.4",
+ "ripemd160": "^2.0.1",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "picocolors": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
+ "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
+ "dev": true
+ },
+ "picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true
+ },
+ "pify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+ "dev": true
+ },
+ "pinkie": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
+ "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==",
+ "dev": true,
+ "optional": true
+ },
+ "pinkie-promise": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+ "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "pinkie": "^2.0.0"
+ }
+ },
+ "pirates": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz",
+ "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz",
+ "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==",
+ "dev": true,
+ "requires": {
+ "find-up": "^5.0.0"
+ }
+ },
+ "pnp-webpack-plugin": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz",
+ "integrity": "sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==",
+ "dev": true,
+ "requires": {
+ "ts-pnp": "^1.1.6"
+ }
+ },
+ "polished": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz",
+ "integrity": "sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.17.8"
+ }
+ },
+ "posix-character-classes": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
+ "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==",
+ "dev": true
+ },
+ "postcss": {
+ "version": "7.0.39",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
+ "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
+ "dev": true,
+ "requires": {
+ "picocolors": "^0.2.1",
+ "source-map": "^0.6.1"
+ }
+ },
+ "postcss-flexbugs-fixes": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-4.2.1.tgz",
+ "integrity": "sha512-9SiofaZ9CWpQWxOwRh1b/r85KD5y7GgvsNt1056k6OYLvWUun0czCvogfJgylC22uJTwW1KzY3Gz65NZRlvoiQ==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.26"
+ }
+ },
+ "postcss-loader": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-4.3.0.tgz",
+ "integrity": "sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==",
+ "dev": true,
+ "requires": {
+ "cosmiconfig": "^7.0.0",
+ "klona": "^2.0.4",
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0",
+ "semver": "^7.3.4"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ }
+ }
+ },
+ "postcss-modules-extract-imports": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz",
+ "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.5"
+ }
+ },
+ "postcss-modules-local-by-default": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz",
+ "integrity": "sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^4.1.1",
+ "postcss": "^7.0.32",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ }
+ },
+ "postcss-modules-scope": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz",
+ "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.6",
+ "postcss-selector-parser": "^6.0.0"
+ }
+ },
+ "postcss-modules-values": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz",
+ "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^4.0.0",
+ "postcss": "^7.0.6"
+ }
+ },
+ "postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "dev": true,
+ "requires": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true
+ },
+ "prettier": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.0.tgz",
+ "integrity": "sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w==",
+ "dev": true
+ },
+ "pretty-error": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
+ "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.20",
+ "renderkid": "^3.0.0"
+ }
+ },
+ "pretty-hrtime": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
+ "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==",
+ "dev": true
+ },
+ "process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "dev": true
+ },
+ "process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true
+ },
+ "promise-inflight": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+ "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+ "dev": true
+ },
+ "promise.allsettled": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.5.tgz",
+ "integrity": "sha512-tVDqeZPoBC0SlzJHzWGZ2NKAguVq2oiYj7gbggbiTvH2itHohijTp7njOUA0aQ/nl+0lr/r6egmhoYu63UZ/pQ==",
+ "dev": true,
+ "requires": {
+ "array.prototype.map": "^1.0.4",
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "iterate-value": "^1.0.2"
+ }
+ },
+ "promise.prototype.finally": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/promise.prototype.finally/-/promise.prototype.finally-3.1.3.tgz",
+ "integrity": "sha512-EXRF3fC9/0gz4qkt/f5EP5iW4kj9oFpBICNpCNOb/52+8nlHIX07FPLbi/q4qYBQ1xZqivMzTpNQSnArVASolQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "requires": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ }
+ },
+ "prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
+ "requires": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "property-information": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
+ "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
+ "dev": true,
+ "requires": {
+ "xtend": "^4.0.0"
+ }
+ },
+ "proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "requires": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ }
+ },
+ "prr": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
+ "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
+ "dev": true
+ },
+ "public-encrypt": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
+ "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "browserify-rsa": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "parse-asn1": "^5.0.0",
+ "randombytes": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "pump": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+ "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "pumpify": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz",
+ "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==",
+ "dev": true,
+ "requires": {
+ "duplexify": "^3.6.0",
+ "inherits": "^2.0.3",
+ "pump": "^2.0.0"
+ },
+ "dependencies": {
+ "pump": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz",
+ "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ }
+ }
+ },
+ "punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+ "dev": true
+ },
+ "qs": {
+ "version": "6.11.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+ "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
+ "dev": true,
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
+ },
+ "querystring": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+ "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
+ "dev": true
+ },
+ "querystring-es3": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz",
+ "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==",
+ "dev": true
+ },
+ "queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true
+ },
+ "ramda": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.28.0.tgz",
+ "integrity": "sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==",
+ "dev": true
+ },
+ "randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "randomfill": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
+ "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.0.5",
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true
+ },
+ "raw-body": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
+ "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
+ "dev": true,
+ "requires": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "dependencies": {
+ "bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true
+ }
+ }
+ },
+ "raw-loader": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+ "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "react": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+ "dev": true,
+ "peer": true,
+ "requires": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "react-dom": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+ "dev": true,
+ "peer": true,
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.0"
+ }
+ },
+ "react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true
+ },
+ "read-pkg": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
+ "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
+ "dev": true,
+ "requires": {
+ "@types/normalize-package-data": "^2.4.0",
+ "normalize-package-data": "^2.5.0",
+ "parse-json": "^5.0.0",
+ "type-fest": "^0.6.0"
+ },
+ "dependencies": {
+ "type-fest": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
+ "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
+ "dev": true
+ }
+ }
+ },
+ "read-pkg-up": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
+ "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
+ "dev": true,
+ "requires": {
+ "find-up": "^4.1.0",
+ "read-pkg": "^5.2.0",
+ "type-fest": "^0.8.1"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ },
+ "type-fest": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+ "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+ "dev": true
+ }
+ }
+ },
+ "readable-stream": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+ "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ }
+ },
+ "readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "requires": {
+ "picomatch": "^2.2.1"
+ }
+ },
+ "redent": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz",
+ "integrity": "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "indent-string": "^2.1.0",
+ "strip-indent": "^1.0.1"
+ },
+ "dependencies": {
+ "indent-string": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz",
+ "integrity": "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "repeating": "^2.0.0"
+ }
+ }
+ }
+ },
+ "regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true
+ },
+ "regenerate-unicode-properties": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz",
+ "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==",
+ "dev": true,
+ "requires": {
+ "regenerate": "^1.4.2"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.9",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
+ "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==",
+ "dev": true
+ },
+ "regenerator-transform": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz",
+ "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.8.4"
+ }
+ },
+ "regex-not": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
+ "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^3.0.2",
+ "safe-regex": "^1.1.0"
+ }
+ },
+ "regexp.prototype.flags": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
+ "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "functions-have-names": "^1.2.2"
+ }
+ },
+ "regexpu-core": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.1.tgz",
+ "integrity": "sha512-HrnlNtpvqP1Xkb28tMhBUO2EbyUHdQlsnlAhzWcwHy8WJR53UWr7/MAvqrsQKMbV4qdpv03oTMG8iIhfsPFktQ==",
+ "dev": true,
+ "requires": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.1.0",
+ "regjsgen": "^0.7.1",
+ "regjsparser": "^0.9.1",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.0.0"
+ }
+ },
+ "regjsgen": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz",
+ "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==",
+ "dev": true
+ },
+ "regjsparser": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz",
+ "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==",
+ "dev": true,
+ "requires": {
+ "jsesc": "~0.5.0"
+ },
+ "dependencies": {
+ "jsesc": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+ "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
+ "dev": true
+ }
+ }
+ },
+ "relateurl": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
+ "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
+ "dev": true
+ },
+ "remark-external-links": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz",
+ "integrity": "sha512-5vPSX0kHoSsqtdftSHhIYofVINC8qmp0nctkeU9YoJwV3YfiBRiI6cbFRJ0oI/1F9xS+bopXG0m2KS8VFscuKA==",
+ "dev": true,
+ "requires": {
+ "extend": "^3.0.0",
+ "is-absolute-url": "^3.0.0",
+ "mdast-util-definitions": "^4.0.0",
+ "space-separated-tokens": "^1.0.0",
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "remark-footnotes": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/remark-footnotes/-/remark-footnotes-2.0.0.tgz",
+ "integrity": "sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ==",
+ "dev": true
+ },
+ "remark-mdx": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-1.6.22.tgz",
+ "integrity": "sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "7.12.9",
+ "@babel/helper-plugin-utils": "7.10.4",
+ "@babel/plugin-proposal-object-rest-spread": "7.12.1",
+ "@babel/plugin-syntax-jsx": "7.12.1",
+ "@mdx-js/util": "1.6.22",
+ "is-alphabetical": "1.0.4",
+ "remark-parse": "8.0.3",
+ "unified": "9.2.0"
+ },
+ "dependencies": {
+ "@babel/core": {
+ "version": "7.12.9",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz",
+ "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/generator": "^7.12.5",
+ "@babel/helper-module-transforms": "^7.12.1",
+ "@babel/helpers": "^7.12.5",
+ "@babel/parser": "^7.12.7",
+ "@babel/template": "^7.12.7",
+ "@babel/traverse": "^7.12.9",
+ "@babel/types": "^7.12.7",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.1",
+ "json5": "^2.1.2",
+ "lodash": "^4.17.19",
+ "resolve": "^1.3.2",
+ "semver": "^5.4.1",
+ "source-map": "^0.5.0"
+ }
+ },
+ "@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ },
+ "@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz",
+ "integrity": "sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.0",
+ "@babel/plugin-transform-parameters": "^7.12.1"
+ }
+ },
+ "@babel/plugin-syntax-jsx": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz",
+ "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true
+ }
+ }
+ },
+ "remark-parse": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz",
+ "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==",
+ "dev": true,
+ "requires": {
+ "ccount": "^1.0.0",
+ "collapse-white-space": "^1.0.2",
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-whitespace-character": "^1.0.0",
+ "is-word-character": "^1.0.0",
+ "markdown-escapes": "^1.0.0",
+ "parse-entities": "^2.0.0",
+ "repeat-string": "^1.5.4",
+ "state-toggle": "^1.0.0",
+ "trim": "0.0.1",
+ "trim-trailing-lines": "^1.0.0",
+ "unherit": "^1.0.4",
+ "unist-util-remove-position": "^2.0.0",
+ "vfile-location": "^3.0.0",
+ "xtend": "^4.0.1"
+ }
+ },
+ "remark-slug": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/remark-slug/-/remark-slug-6.1.0.tgz",
+ "integrity": "sha512-oGCxDF9deA8phWvxFuyr3oSJsdyUAxMFbA0mZ7Y1Sas+emILtO+e5WutF9564gDsEN4IXaQXm5pFo6MLH+YmwQ==",
+ "dev": true,
+ "requires": {
+ "github-slugger": "^1.0.0",
+ "mdast-util-to-string": "^1.0.0",
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "remark-squeeze-paragraphs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz",
+ "integrity": "sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw==",
+ "dev": true,
+ "requires": {
+ "mdast-squeeze-paragraphs": "^4.0.0"
+ }
+ },
+ "remove-trailing-separator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
+ "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==",
+ "dev": true
+ },
+ "renderkid": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
+ "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==",
+ "dev": true,
+ "requires": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "repeat-element": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
+ "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==",
+ "dev": true
+ },
+ "repeat-string": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
+ "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
+ "dev": true
+ },
+ "repeating": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
+ "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-finite": "^1.0.0"
+ }
+ },
+ "resolve": {
+ "version": "1.22.1",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
+ "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+ "dev": true,
+ "requires": {
+ "is-core-module": "^2.9.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ }
+ },
+ "resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true
+ },
+ "resolve-url": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+ "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==",
+ "dev": true
+ },
+ "ret": {
+ "version": "0.1.15",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
+ "dev": true
+ },
+ "reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true
+ },
+ "rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "ripemd160": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
+ "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
+ "dev": true,
+ "requires": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1"
+ }
+ },
+ "rsvp": {
+ "version": "4.8.5",
+ "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
+ "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==",
+ "dev": true
+ },
+ "run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "requires": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "run-queue": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz",
+ "integrity": "sha512-ntymy489o0/QQplUDnpYAYUsO50K9SBrIVaKCWDOJzYJts0f9WH9RFJkyagebkw5+y1oi00R7ynNW/d12GBumg==",
+ "dev": true,
+ "requires": {
+ "aproba": "^1.1.1"
+ },
+ "dependencies": {
+ "aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ }
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "safe-regex": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+ "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==",
+ "dev": true,
+ "requires": {
+ "ret": "~0.1.10"
+ }
+ },
+ "safe-regex-test": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",
+ "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.3",
+ "is-regex": "^1.1.4"
+ }
+ },
+ "safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true
+ },
+ "sane": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz",
+ "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==",
+ "dev": true,
+ "requires": {
+ "@cnakazawa/watch": "^1.0.3",
+ "anymatch": "^2.0.0",
+ "capture-exit": "^2.0.0",
+ "exec-sh": "^0.3.2",
+ "execa": "^1.0.0",
+ "fb-watchman": "^2.0.0",
+ "micromatch": "^3.1.4",
+ "minimist": "^1.1.1",
+ "walker": "~1.0.5"
+ },
+ "dependencies": {
+ "anymatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
+ "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
+ "dev": true,
+ "requires": {
+ "micromatch": "^3.1.4",
+ "normalize-path": "^2.1.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "cross-spawn": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
+ "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
+ "dev": true,
+ "requires": {
+ "nice-try": "^1.0.4",
+ "path-key": "^2.0.1",
+ "semver": "^5.5.0",
+ "shebang-command": "^1.2.0",
+ "which": "^1.2.9"
+ }
+ },
+ "execa": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
+ "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
+ "dev": true,
+ "requires": {
+ "cross-spawn": "^6.0.0",
+ "get-stream": "^4.0.0",
+ "is-stream": "^1.1.0",
+ "npm-run-path": "^2.0.0",
+ "p-finally": "^1.0.0",
+ "signal-exit": "^3.0.0",
+ "strip-eof": "^1.0.0"
+ }
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "get-stream": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+ "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+ "dev": true,
+ "requires": {
+ "pump": "^3.0.0"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-stream": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+ "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==",
+ "dev": true
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "requires": {
+ "remove-trailing-separator": "^1.0.1"
+ }
+ },
+ "npm-run-path": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
+ "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==",
+ "dev": true,
+ "requires": {
+ "path-key": "^2.0.0"
+ }
+ },
+ "path-key": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+ "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==",
+ "dev": true
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "shebang-command": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+ "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^1.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+ "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ }
+ }
+ },
+ "scheduler": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+ "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+ "dev": true,
+ "peer": true,
+ "requires": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "schema-utils": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
+ "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.5",
+ "ajv": "^6.12.4",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "semver": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "dev": true
+ },
+ "send": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
+ "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
+ "dev": true,
+ "requires": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ },
+ "dependencies": {
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ }
+ }
+ },
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ }
+ }
+ },
+ "serialize-javascript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+ "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "serve-favicon": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz",
+ "integrity": "sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==",
+ "dev": true,
+ "requires": {
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "ms": "2.1.1",
+ "parseurl": "~1.3.2",
+ "safe-buffer": "5.1.1"
+ },
+ "dependencies": {
+ "ms": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+ "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
+ "dev": true
+ },
+ "safe-buffer": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
+ "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
+ "dev": true
+ }
+ }
+ },
+ "serve-static": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
+ "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
+ "dev": true,
+ "requires": {
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.18.0"
+ }
+ },
+ "set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "dev": true
+ },
+ "set-value": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
+ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-extendable": "^0.1.1",
+ "is-plain-object": "^2.0.3",
+ "split-string": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ }
+ }
+ },
+ "setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "dev": true
+ },
+ "setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true
+ },
+ "sha.js": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+ "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "shallow-clone": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
+ "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^6.0.2"
+ }
+ },
+ "shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^3.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true
+ },
+ "side-channel": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+ "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ }
+ },
+ "signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ },
+ "sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true
+ },
+ "slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true
+ },
+ "snapdragon": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
+ "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==",
+ "dev": true,
+ "requires": {
+ "base": "^0.11.1",
+ "debug": "^2.2.0",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "map-cache": "^0.2.2",
+ "source-map": "^0.5.6",
+ "source-map-resolve": "^0.5.0",
+ "use": "^3.1.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true
+ }
+ }
+ },
+ "snapdragon-node": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz",
+ "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==",
+ "dev": true,
+ "requires": {
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.0",
+ "snapdragon-util": "^3.0.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ }
+ }
+ },
+ "snapdragon-util": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz",
+ "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.2.0"
+ },
+ "dependencies": {
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "source-list-map": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
+ "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true
+ },
+ "source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true
+ },
+ "source-map-resolve": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
+ "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
+ "dev": true,
+ "requires": {
+ "atob": "^2.1.2",
+ "decode-uri-component": "^0.2.0",
+ "resolve-url": "^0.2.1",
+ "source-map-url": "^0.4.0",
+ "urix": "^0.1.0"
+ }
+ },
+ "source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "requires": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "source-map-url": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
+ "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
+ "dev": true
+ },
+ "space-separated-tokens": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
+ "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
+ "dev": true
+ },
+ "spdx-correct": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+ "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+ "dev": true,
+ "requires": {
+ "spdx-expression-parse": "^3.0.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "spdx-exceptions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+ "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+ "dev": true
+ },
+ "spdx-expression-parse": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+ "dev": true,
+ "requires": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "spdx-license-ids": {
+ "version": "3.0.12",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz",
+ "integrity": "sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==",
+ "dev": true
+ },
+ "split-string": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^3.0.0"
+ }
+ },
+ "sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true
+ },
+ "ssri": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
+ "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.1.1"
+ }
+ },
+ "stable": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
+ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
+ "dev": true
+ },
+ "state-toggle": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz",
+ "integrity": "sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==",
+ "dev": true
+ },
+ "static-extend": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
+ "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==",
+ "dev": true,
+ "requires": {
+ "define-property": "^0.2.5",
+ "object-copy": "^0.1.0"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ }
+ },
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ }
+ }
+ },
+ "statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "dev": true
+ },
+ "store2": {
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.2.tgz",
+ "integrity": "sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==",
+ "dev": true
+ },
+ "stream-browserify": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
+ "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==",
+ "dev": true,
+ "requires": {
+ "inherits": "~2.0.1",
+ "readable-stream": "^2.0.2"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "stream-each": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz",
+ "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.1.0",
+ "stream-shift": "^1.0.0"
+ }
+ },
+ "stream-http": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz",
+ "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==",
+ "dev": true,
+ "requires": {
+ "builtin-status-codes": "^3.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.3.6",
+ "to-arraybuffer": "^1.0.0",
+ "xtend": "^4.0.0"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "stream-shift": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
+ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==",
+ "dev": true
+ },
+ "string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.2.0"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "requires": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "string.prototype.matchall": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz",
+ "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "regexp.prototype.flags": "^1.4.1",
+ "side-channel": "^1.0.4"
+ }
+ },
+ "string.prototype.padend": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz",
+ "integrity": "sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "string.prototype.padstart": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/string.prototype.padstart/-/string.prototype.padstart-3.1.3.tgz",
+ "integrity": "sha512-NZydyOMtYxpTjGqp0VN5PYUF/tsU15yDMZnUdj16qRUIUiMJkHHSDElYyQFrMu+/WloTpA7MQSiADhBicDfaoA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "string.prototype.trimend": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz",
+ "integrity": "sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5"
+ }
+ },
+ "string.prototype.trimstart": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz",
+ "integrity": "sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5"
+ }
+ },
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ },
+ "strip-bom": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz",
+ "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-utf8": "^0.2.0"
+ }
+ },
+ "strip-eof": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+ "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==",
+ "dev": true
+ },
+ "strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true
+ },
+ "strip-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz",
+ "integrity": "sha512-I5iQq6aFMM62fBEAIB/hXzwJD6EEZ0xEGCX2t7oXqaKPIRgt4WruAQ285BISgdkP+HLGWyeGmNJcpIwFeRYRUA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "get-stdin": "^4.0.1"
+ }
+ },
+ "style-loader": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
+ "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "style-to-object": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz",
+ "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==",
+ "dev": true,
+ "requires": {
+ "inline-style-parser": "0.1.1"
+ }
+ },
+ "supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ },
+ "supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true
+ },
+ "symbol.prototype.description": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/symbol.prototype.description/-/symbol.prototype.description-1.0.5.tgz",
+ "integrity": "sha512-x738iXRYsrAt9WBhRCVG5BtIC3B7CUkFwbHW2zOvGtwM33s7JjrCDyq8V0zgMYVb5ymsL8+qkzzpANH63CPQaQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-symbol-description": "^1.0.0",
+ "has-symbols": "^1.0.2",
+ "object.getownpropertydescriptors": "^2.1.2"
+ }
+ },
+ "synchronous-promise": {
+ "version": "2.0.16",
+ "resolved": "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.16.tgz",
+ "integrity": "sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A==",
+ "dev": true
+ },
+ "tapable": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",
+ "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==",
+ "dev": true
+ },
+ "tar": {
+ "version": "6.1.11",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
+ "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
+ "dev": true,
+ "requires": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^3.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ }
+ },
+ "telejson": {
+ "version": "6.0.8",
+ "resolved": "https://registry.npmjs.org/telejson/-/telejson-6.0.8.tgz",
+ "integrity": "sha512-nerNXi+j8NK1QEfBHtZUN/aLdDcyupA//9kAboYLrtzZlPLpUfqbVGWb9zz91f/mIjRbAYhbgtnJHY8I1b5MBg==",
+ "dev": true,
+ "requires": {
+ "@types/is-function": "^1.0.0",
+ "global": "^4.4.0",
+ "is-function": "^1.0.2",
+ "is-regex": "^1.1.2",
+ "is-symbol": "^1.0.3",
+ "isobject": "^4.0.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3"
+ },
+ "dependencies": {
+ "isobject": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz",
+ "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==",
+ "dev": true
+ }
+ }
+ },
+ "terser": {
+ "version": "5.15.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.0.tgz",
+ "integrity": "sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/source-map": "^0.3.2",
+ "acorn": "^8.5.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ }
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "5.3.6",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz",
+ "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/trace-mapping": "^0.3.14",
+ "jest-worker": "^27.4.5",
+ "schema-utils": "^3.1.1",
+ "serialize-javascript": "^6.0.0",
+ "terser": "^5.14.1"
+ },
+ "dependencies": {
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "jest-worker": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
+ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^8.0.0"
+ }
+ },
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "test-exclude": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+ "dev": true,
+ "requires": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^7.1.4",
+ "minimatch": "^3.0.4"
+ }
+ },
+ "through2": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
+ "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
+ "dev": true,
+ "requires": {
+ "readable-stream": "~2.3.6",
+ "xtend": "~4.0.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "timers-browserify": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz",
+ "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==",
+ "dev": true,
+ "requires": {
+ "setimmediate": "^1.0.4"
+ }
+ },
+ "tmpl": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
+ "dev": true
+ },
+ "to-arraybuffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
+ "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==",
+ "dev": true
+ },
+ "to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true
+ },
+ "to-object-path": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
+ "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "to-regex": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz",
+ "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==",
+ "dev": true,
+ "requires": {
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "regex-not": "^1.0.2",
+ "safe-regex": "^1.1.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ },
+ "toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true
+ },
+ "tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "dev": true
+ },
+ "trim": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz",
+ "integrity": "sha512-YzQV+TZg4AxpKxaTHK3c3D+kRDCGVEE7LemdlQZoQXn0iennk10RsIoY6ikzAqJTc9Xjl9C1/waHom/J86ziAQ==",
+ "dev": true
+ },
+ "trim-newlines": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
+ "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==",
+ "dev": true,
+ "optional": true
+ },
+ "trim-trailing-lines": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz",
+ "integrity": "sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==",
+ "dev": true
+ },
+ "trough": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz",
+ "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==",
+ "dev": true
+ },
+ "ts-dedent": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
+ "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
+ "dev": true
+ },
+ "ts-pnp": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
+ "integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==",
+ "dev": true
+ },
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
+ "dev": true
+ },
+ "tty-browserify": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
+ "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==",
+ "dev": true
+ },
+ "type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true
+ },
+ "type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "requires": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ }
+ },
+ "typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "dev": true
+ },
+ "typedarray-to-buffer": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+ "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+ "dev": true,
+ "requires": {
+ "is-typedarray": "^1.0.0"
+ }
+ },
+ "typescript": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
+ "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+ "dev": true,
+ "peer": true
+ },
+ "uglify-js": {
+ "version": "3.17.2",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.2.tgz",
+ "integrity": "sha512-bbxglRjsGQMchfvXZNusUcYgiB9Hx2K4AHYXQy2DITZ9Rd+JzhX7+hoocE5Winr7z2oHvPsekkBwXtigvxevXg==",
+ "dev": true,
+ "optional": true
+ },
+ "unbox-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
+ "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.0.3",
+ "which-boxed-primitive": "^1.0.2"
+ }
+ },
+ "unfetch": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
+ "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
+ "dev": true
+ },
+ "unherit": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz",
+ "integrity": "sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.0",
+ "xtend": "^4.0.0"
+ }
+ },
+ "unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==",
+ "dev": true
+ },
+ "unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "requires": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ }
+ },
+ "unicode-match-property-value-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==",
+ "dev": true
+ },
+ "unicode-property-aliases-ecmascript": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
+ "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
+ "dev": true
+ },
+ "unified": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz",
+ "integrity": "sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==",
+ "dev": true,
+ "requires": {
+ "bail": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-buffer": "^2.0.0",
+ "is-plain-obj": "^2.0.0",
+ "trough": "^1.0.0",
+ "vfile": "^4.0.0"
+ }
+ },
+ "union-value": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
+ "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
+ "dev": true,
+ "requires": {
+ "arr-union": "^3.1.0",
+ "get-value": "^2.0.6",
+ "is-extendable": "^0.1.1",
+ "set-value": "^2.0.1"
+ },
+ "dependencies": {
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ }
+ }
+ },
+ "unique-filename": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
+ "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==",
+ "dev": true,
+ "requires": {
+ "unique-slug": "^2.0.0"
+ }
+ },
+ "unique-slug": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz",
+ "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==",
+ "dev": true,
+ "requires": {
+ "imurmurhash": "^0.1.4"
+ }
+ },
+ "unist-builder": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz",
+ "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==",
+ "dev": true
+ },
+ "unist-util-generated": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz",
+ "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==",
+ "dev": true
+ },
+ "unist-util-is": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz",
+ "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==",
+ "dev": true
+ },
+ "unist-util-position": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz",
+ "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==",
+ "dev": true
+ },
+ "unist-util-remove": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-2.1.0.tgz",
+ "integrity": "sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q==",
+ "dev": true,
+ "requires": {
+ "unist-util-is": "^4.0.0"
+ }
+ },
+ "unist-util-remove-position": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz",
+ "integrity": "sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==",
+ "dev": true,
+ "requires": {
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "unist-util-stringify-position": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
+ "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.2"
+ }
+ },
+ "unist-util-visit": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz",
+ "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.0",
+ "unist-util-is": "^4.0.0",
+ "unist-util-visit-parents": "^3.0.0"
+ }
+ },
+ "unist-util-visit-parents": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz",
+ "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.0",
+ "unist-util-is": "^4.0.0"
+ }
+ },
+ "universalify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+ "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+ "dev": true
+ },
+ "unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true
+ },
+ "unset-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",
+ "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==",
+ "dev": true,
+ "requires": {
+ "has-value": "^0.3.1",
+ "isobject": "^3.0.0"
+ },
+ "dependencies": {
+ "has-value": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz",
+ "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==",
+ "dev": true,
+ "requires": {
+ "get-value": "^2.0.3",
+ "has-values": "^0.1.4",
+ "isobject": "^2.0.0"
+ },
+ "dependencies": {
+ "isobject": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
+ "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==",
+ "dev": true,
+ "requires": {
+ "isarray": "1.0.0"
+ }
+ }
+ }
+ },
+ "has-values": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz",
+ "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ }
+ }
+ },
+ "untildify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/untildify/-/untildify-2.1.0.tgz",
+ "integrity": "sha512-sJjbDp2GodvkB0FZZcn7k6afVisqX5BZD7Yq3xp4nN2O15BBK0cLm3Vwn2vQaF7UDS0UUsrQMkkplmDI5fskig==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "os-homedir": "^1.0.0"
+ }
+ },
+ "upath": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+ "dev": true,
+ "optional": true
+ },
+ "update-browserslist-db": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz",
+ "integrity": "sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==",
+ "dev": true,
+ "requires": {
+ "escalade": "^3.1.1",
+ "picocolors": "^1.0.0"
+ },
+ "dependencies": {
+ "picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ }
+ }
+ },
+ "uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "urix": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
+ "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==",
+ "dev": true
+ },
+ "url": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+ "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
+ "dev": true,
+ "requires": {
+ "punycode": "1.3.2",
+ "querystring": "0.2.0"
+ },
+ "dependencies": {
+ "punycode": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+ "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
+ "dev": true
+ }
+ }
+ },
+ "url-loader": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
+ "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "mime-types": "^2.1.27",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "use": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
+ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
+ "dev": true
+ },
+ "util": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
+ "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==",
+ "dev": true,
+ "requires": {
+ "inherits": "2.0.3"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "dev": true
+ }
+ }
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true
+ },
+ "util.promisify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz",
+ "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==",
+ "dev": true,
+ "requires": {
+ "define-properties": "^1.1.2",
+ "object.getownpropertydescriptors": "^2.0.3"
+ }
+ },
+ "utila": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz",
+ "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==",
+ "dev": true
+ },
+ "utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true
+ },
+ "uuid": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
+ "dev": true
+ },
+ "uuid-browser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/uuid-browser/-/uuid-browser-3.1.0.tgz",
+ "integrity": "sha512-dsNgbLaTrd6l3MMxTtouOCFw4CBFc/3a+GgYA2YyrJvyQ1u6q4pcu3ktLoUZ/VN/Aw9WsauazbgsgdfVWgAKQg==",
+ "dev": true
+ },
+ "validate-npm-package-license": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+ "dev": true,
+ "requires": {
+ "spdx-correct": "^3.0.0",
+ "spdx-expression-parse": "^3.0.0"
+ }
+ },
+ "vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true
+ },
+ "vfile": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz",
+ "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.0",
+ "is-buffer": "^2.0.0",
+ "unist-util-stringify-position": "^2.0.0",
+ "vfile-message": "^2.0.0"
+ }
+ },
+ "vfile-location": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-3.2.0.tgz",
+ "integrity": "sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==",
+ "dev": true
+ },
+ "vfile-message": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz",
+ "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.0",
+ "unist-util-stringify-position": "^2.0.0"
+ }
+ },
+ "vm-browserify": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
+ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
+ "dev": true
+ },
+ "walker": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
+ "dev": true,
+ "requires": {
+ "makeerror": "1.0.12"
+ }
+ },
+ "watchpack": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
+ "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
+ "dev": true,
+ "requires": {
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.1.2"
+ }
+ },
+ "watchpack-chokidar2": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz",
+ "integrity": "sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "chokidar": "^2.1.8"
+ },
+ "dependencies": {
+ "anymatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
+ "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "micromatch": "^3.1.4",
+ "normalize-path": "^2.1.1"
+ },
+ "dependencies": {
+ "normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "remove-trailing-separator": "^1.0.1"
+ }
+ }
+ }
+ },
+ "binary-extensions": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz",
+ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==",
+ "dev": true,
+ "optional": true
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "chokidar": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz",
+ "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "anymatch": "^2.0.0",
+ "async-each": "^1.0.1",
+ "braces": "^2.3.2",
+ "fsevents": "^1.2.7",
+ "glob-parent": "^3.1.0",
+ "inherits": "^2.0.3",
+ "is-binary-path": "^1.0.0",
+ "is-glob": "^4.0.0",
+ "normalize-path": "^3.0.0",
+ "path-is-absolute": "^1.0.0",
+ "readdirp": "^2.2.1",
+ "upath": "^1.1.1"
+ }
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "fsevents": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
+ "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ }
+ },
+ "glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-glob": "^3.1.0",
+ "path-dirname": "^1.0.0"
+ },
+ "dependencies": {
+ "is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-extglob": "^2.1.0"
+ }
+ }
+ }
+ },
+ "is-binary-path": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz",
+ "integrity": "sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "binary-extensions": "^1.0.0"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true,
+ "optional": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "optional": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "optional": true
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "readdirp": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz",
+ "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.11",
+ "micromatch": "^3.1.10",
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ }
+ }
+ },
+ "web-namespaces": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz",
+ "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==",
+ "dev": true
+ },
+ "webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "dev": true
+ },
+ "webpack": {
+ "version": "5.74.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz",
+ "integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==",
+ "dev": true,
+ "requires": {
+ "@types/eslint-scope": "^3.7.3",
+ "@types/estree": "^0.0.51",
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/wasm-edit": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1",
+ "acorn": "^8.7.1",
+ "acorn-import-assertions": "^1.7.6",
+ "browserslist": "^4.14.5",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^5.10.0",
+ "es-module-lexer": "^0.9.0",
+ "eslint-scope": "5.1.1",
+ "events": "^3.2.0",
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.2.9",
+ "json-parse-even-better-errors": "^2.3.1",
+ "loader-runner": "^4.2.0",
+ "mime-types": "^2.1.27",
+ "neo-async": "^2.6.2",
+ "schema-utils": "^3.1.0",
+ "tapable": "^2.1.1",
+ "terser-webpack-plugin": "^5.1.3",
+ "watchpack": "^2.4.0",
+ "webpack-sources": "^3.2.3"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true
+ }
+ }
+ },
+ "webpack-dev-middleware": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-4.3.0.tgz",
+ "integrity": "sha512-PjwyVY95/bhBh6VUqt6z4THplYcsvQ8YNNBTBM873xLVmw8FLeALn0qurHbs9EmcfhzQis/eoqypSnZeuUz26w==",
+ "dev": true,
+ "requires": {
+ "colorette": "^1.2.2",
+ "mem": "^8.1.1",
+ "memfs": "^3.2.2",
+ "mime-types": "^2.1.30",
+ "range-parser": "^1.2.1",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "webpack-hot-middleware": {
+ "version": "2.25.2",
+ "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.2.tgz",
+ "integrity": "sha512-CVgm3NAQyfdIonRvXisRwPTUYuSbyZ6BY7782tMeUzWOO7RmVI2NaBYuCp41qyD4gYCkJyTneAJdK69A13B0+A==",
+ "dev": true,
+ "requires": {
+ "ansi-html-community": "0.0.8",
+ "html-entities": "^2.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ },
+ "webpack-log": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz",
+ "integrity": "sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg==",
+ "dev": true,
+ "requires": {
+ "ansi-colors": "^3.0.0",
+ "uuid": "^3.3.2"
+ }
+ },
+ "webpack-sources": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
+ "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
+ "dev": true
+ },
+ "webpack-virtual-modules": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.4.5.tgz",
+ "integrity": "sha512-8bWq0Iluiv9lVf9YaqWQ9+liNgXSHICm+rg544yRgGYaR8yXZTVBaHZkINZSB2yZSWo4b0F6MIxqJezVfOEAlg==",
+ "dev": true
+ },
+ "whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dev": true,
+ "requires": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ },
+ "which-boxed-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+ "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+ "dev": true,
+ "requires": {
+ "is-bigint": "^1.0.1",
+ "is-boolean-object": "^1.1.0",
+ "is-number-object": "^1.0.4",
+ "is-string": "^1.0.5",
+ "is-symbol": "^1.0.3"
+ }
+ },
+ "wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "dev": true,
+ "requires": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "widest-line": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
+ "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
+ "dev": true,
+ "requires": {
+ "string-width": "^4.0.0"
+ }
+ },
+ "wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true
+ },
+ "worker-farm": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz",
+ "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==",
+ "dev": true,
+ "requires": {
+ "errno": "~0.1.7"
+ }
+ },
+ "worker-rpc": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/worker-rpc/-/worker-rpc-0.1.1.tgz",
+ "integrity": "sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg==",
+ "dev": true,
+ "requires": {
+ "microevent.ts": "~0.1.1"
+ }
+ },
+ "wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ }
+ }
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "write-file-atomic": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+ "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+ "dev": true,
+ "requires": {
+ "imurmurhash": "^0.1.4",
+ "is-typedarray": "^1.0.0",
+ "signal-exit": "^3.0.2",
+ "typedarray-to-buffer": "^3.1.5"
+ }
+ },
+ "ws": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz",
+ "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==",
+ "dev": true,
+ "requires": {}
+ },
+ "x-default-browser": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/x-default-browser/-/x-default-browser-0.4.0.tgz",
+ "integrity": "sha512-7LKo7RtWfoFN/rHx1UELv/2zHGMx8MkZKDq1xENmOCTkfIqZJ0zZ26NEJX8czhnPXVcqS0ARjjfJB+eJ0/5Cvw==",
+ "dev": true,
+ "requires": {
+ "default-browser-id": "^1.0.4"
+ }
+ },
+ "xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "dev": true
+ },
+ "y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "dev": true
+ },
+ "yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "dev": true
+ },
+ "yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true
+ },
+ "zwitch": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
+ "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==",
+ "dev": true
+ }
+ }
+}
diff --git a/comm/mail/components/storybook/package.json b/comm/mail/components/storybook/package.json
new file mode 100644
index 0000000000..a414da8101
--- /dev/null
+++ b/comm/mail/components/storybook/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "mail-storybook",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "build-storybook": "build-storybook",
+ "storybook": "start-storybook -p 5703 --no-open"
+ },
+ "author": "",
+ "license": "MPL-2.0",
+ "private": true,
+ "dependencies": {},
+ "devDependencies": {
+ "@babel/core": "^7.19.3",
+ "@fluent/bundle": "^0.17.1",
+ "@fluent/dom": "^0.8.1",
+ "@storybook/addon-actions": "^6.5.12",
+ "@storybook/addon-essentials": "^6.5.12",
+ "@storybook/addon-links": "^6.5.12",
+ "@storybook/builder-webpack5": "^6.5.12",
+ "@storybook/manager-webpack5": "^6.5.12",
+ "@storybook/web-components": "^6.5.12",
+ "babel-loader": "^8.2.5",
+ "lit": "^2.3.1"
+ }
+}
diff --git a/comm/mail/components/storybook/stories/colors.stories.mjs b/comm/mail/components/storybook/stories/colors.stories.mjs
new file mode 100644
index 0000000000..a61c49a560
--- /dev/null
+++ b/comm/mail/components/storybook/stories/colors.stories.mjs
@@ -0,0 +1,89 @@
+/* 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 { html } from "lit";
+import "mail/themes/shared/mail/colors.css"; //eslint-disable-line import/no-unassigned-import
+
+const FORMATTER = new Intl.NumberFormat("en", {
+ numberingSystem: "latn",
+ style: "decimal",
+ minimumIntegerDigits: 2,
+ maximumFractionDigits: 0,
+});
+
+const VARIANT_RANGE = {
+ white: [],
+ gray: [10, 90],
+ red: [30, 90],
+ orange: [30, 90],
+ amber: [30, 90],
+ yellow: [30, 90],
+ green: [30, 90],
+ teal: [30, 90],
+ blue: [0, 90],
+ purple: [0, 90],
+ magenta: [30, 90],
+ brown: [30, 90],
+ ink: [30, 90],
+};
+
+const ALL_COLORS = Object.entries(VARIANT_RANGE).flatMap(([color, range]) => {
+ if (!range.length) {
+ return [color];
+ }
+ const colors = [];
+ for (let variant = range[0]; variant <= range[1]; variant += 10) {
+ colors.push(`${color}-${FORMATTER.format(variant)}`);
+ }
+ return colors;
+});
+
+export default {
+ title: "Design System/Colors",
+ argTypes: {
+ color1: {
+ options: ALL_COLORS,
+ control: { type: "select" },
+ },
+ color2: {
+ options: ALL_COLORS,
+ control: { type: "select" },
+ },
+ },
+};
+
+function createColor(colorName) {
+ const cssVariableName = `--color-${colorName}`;
+ const color = document.createElement("div");
+ color.style.padding = "0.5em";
+ const preview = document.createElement("div");
+ preview.style.width = "200px";
+ preview.style.height = "50px";
+ preview.style.background = `var(${cssVariableName})`;
+ const legend = document.createElement("span");
+ legend.textContent = cssVariableName;
+ color.append(preview, legend);
+ return color;
+}
+
+export const Colors = {
+ render: () => {
+ const container = document.createElement("div");
+ container.append(...ALL_COLORS.map(createColor));
+ return container;
+ },
+};
+
+const Template = ({ color1, color2 }) => html`
+ <div style="display: grid">
+ <div style="height: 40vh; background: var(--color-${color1})"></div>
+ <div style="height: 40vh; background: var(--color-${color2})"></div>
+ </div>
+`;
+
+export const CompareColors = Template.bind({});
+CompareColors.args = {
+ color1: "white",
+ color2: "ink-90",
+};
diff --git a/comm/mail/components/storybook/stories/pane-splitter.stories.mjs b/comm/mail/components/storybook/stories/pane-splitter.stories.mjs
new file mode 100644
index 0000000000..6cc0418e06
--- /dev/null
+++ b/comm/mail/components/storybook/stories/pane-splitter.stories.mjs
@@ -0,0 +1,60 @@
+/* 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 { html } from "lit";
+import "mail/base/content/widgets/pane-splitter.js"; //eslint-disable-line import/no-unassigned-import
+
+export default {
+ title: "Widgets/Pane Splitter",
+ argTypes: {
+ resizeDirection: {
+ options: ["", "vertical", "horizontal"],
+ control: { type: "radio" },
+ },
+ },
+};
+
+const Template = ({ resizeDirection, collapseWidth, collapseHeight }) => html`
+ <style>
+ hr[is="pane-splitter"] {
+ border: none;
+ z-index: 1;
+ margin: ${resizeDirection === "horizontal" ? "0 -3px" : "-3px 0"};
+ opacity: .4;
+ background-color: red;
+ }
+
+ .wrapper {
+ display: inline-grid;
+ grid-template-${
+ resizeDirection === "horizontal" ? "columns" : "rows"
+ }: minmax(auto, var(--splitter-${
+ resizeDirection === "horizontal" ? "width" : "height"
+})) 0 auto;
+ width: 500px;
+ height: 500px;
+ margin: 1em;
+ --splitter-width: 200px;
+ --splitter-height: 200px;
+ }
+ </style>
+ <div class="wrapper">
+ <div id="resizeme" style="background: lightblue"></div>
+ <hr is="pane-splitter"
+ resize-direction="${resizeDirection}"
+ resize-id="resizeme"
+ collapse-width="${collapseWidth}"
+ collapse-height="${collapseHeight}"
+ id="splitter"
+ ></hr>
+ <div id="fill" style="background: lightslategrey"></div>
+ </div>
+`;
+
+export const PaneSplitter = Template.bind({});
+PaneSplitter.args = {
+ resizeDirection: "",
+ collapseWidth: 0,
+ collapseHeight: 0,
+};
diff --git a/comm/mail/components/storybook/stories/search-bar.stories.mjs b/comm/mail/components/storybook/stories/search-bar.stories.mjs
new file mode 100644
index 0000000000..40bf74f28f
--- /dev/null
+++ b/comm/mail/components/storybook/stories/search-bar.stories.mjs
@@ -0,0 +1,37 @@
+/* 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 { html } from "lit";
+import { action } from "@storybook/addon-actions";
+import "mail/components/unifiedtoolbar/content/search-bar.mjs"; //eslint-disable-line import/no-unassigned-import
+
+export default {
+ title: "Widgets/Search Bar",
+};
+
+export const SearchBar = () => html`
+ <template id="searchBarTemplate">
+ <form>
+ <input type="search" placeholder="" required="required" />
+ <div aria-hidden="true"><slot name="placeholder"></slot></div>
+ <button class="button button-flat icon-button">
+ <slot name="button"></slot>
+ </button>
+ </form>
+ </template>
+ <search-bar
+ @search="${action("search")}"
+ @autocomplete="${action("autocomplete")}"
+ >
+ <span slot="placeholder"
+ >Search Field Placeholder <kbd>Ctrl</kbd> + <kbd>K</kbd>
+ </span>
+ <img
+ alt="Search"
+ slot="button"
+ class="search-button"
+ src="chrome://messenger/skin/icons/new/compact/search.svg"
+ />
+ </search-bar>
+`;
diff --git a/comm/mail/components/telemetry/Events.yaml b/comm/mail/components/telemetry/Events.yaml
new file mode 100644
index 0000000000..bc3772ee46
--- /dev/null
+++ b/comm/mail/components/telemetry/Events.yaml
@@ -0,0 +1,25 @@
+# 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 contains Thunderbird-specific telemetry Event definitions, which
+# are added on top of the Firefox ones (in /toolkit/components/telemetry).
+# To avoid name clashes, all the Thunderbird events will be under a "tb"
+# category.
+
+# A category used for unit tests.
+# Under normal operation, these won't be invoked.
+tb.test:
+ test:
+ objects: ["object1", "object2", "object3"]
+ bug_numbers: [1427877]
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes: ["main"]
+ description: This is a test entry for Telemetry.
+ expiry_version: never
+ extra_keys:
+ key1: This is just a test description.
+ products:
+ - thunderbird
+
diff --git a/comm/mail/components/telemetry/Histograms.json b/comm/mail/components/telemetry/Histograms.json
new file mode 100644
index 0000000000..568887be1b
--- /dev/null
+++ b/comm/mail/components/telemetry/Histograms.json
@@ -0,0 +1,40 @@
+{
+ "TELEMETRY_TEST_TB_CATEGORICAL": {
+ "record_in_processes": ["main", "content"],
+ "products": ["thunderbird"],
+ "alert_emails": ["telemetry-client-dev@thunderbird.net"],
+ "bug_numbers": [1427877],
+ "expires_in_version": "never",
+ "kind": "categorical",
+ "labels": ["CommonLabel", "Label2", "Label3"],
+ "description": "A testing histogram; not meant to be touched"
+ },
+ "TB_COMPOSE_TYPE": {
+ "record_in_processes": ["main"],
+ "products": ["thunderbird"],
+ "alert_emails": ["telemetry-client-dev@thunderbird.net"],
+ "bug_numbers": [1615996],
+ "expires_in_version": "never",
+ "kind": "categorical",
+ "labels": [
+ "New",
+ "Reply",
+ "ReplyAll",
+ "ForwardAsAttachment",
+ "ForwardInline",
+ "NewsPost",
+ "ReplyToSender",
+ "ReplyToGroup",
+ "ReplyToSenderGroup",
+ "Draft",
+ "Template",
+ "MailToUrl",
+ "ReplyWithTemplate",
+ "ReplyToList",
+ "Redirect",
+ "EditAsNew",
+ "EditTemplate"
+ ],
+ "description": "Histogram of different message compose types used"
+ }
+}
diff --git a/comm/mail/components/telemetry/README.md b/comm/mail/components/telemetry/README.md
new file mode 100644
index 0000000000..45b7dda9fe
--- /dev/null
+++ b/comm/mail/components/telemetry/README.md
@@ -0,0 +1,175 @@
+# Notes on telemetry in Thunderbird
+
+## Hooking into the build process
+
+The comm-central probe definitions in this directory (`Events.yaml`,
+`Scalars.yaml` and `Histograms.json`) are used _in addition_ to
+their mozilla-central counterparts (in `toolkit/components/telemetry/`).
+
+As part of the mozilla-central telemetry build process, scripts are used to
+generate the C++ files which define the probe registry (enums, string tables
+etc).
+
+Because of this code generation, the extra comm-central probe definitions
+need to be included when the mozilla-central telemetry is built.
+
+This is done by setting `MOZ_TELEMETRY_EXTRA_*` config values. You can
+see these in `comm/mail/moz.configure`.
+These config values are used by `toolkit/components/telemetry/moz.build`
+(mozilla-central) to pass the extra probe definitions to the code
+generation scripts.
+
+The build scripts can be found under `toolkit/components/telemetry/build_scripts`.
+They are written in Python.
+
+## Naming probes
+
+To avoid clashing with the mozilla-central probes, we'll be pretty liberal
+about slapping on prefixes to our definitions.
+
+For Events and Scalars, we keep everything under `tb.`.
+
+For Histograms, we use a `TB_` or `TELEMETRY_TEST_TB_` prefix.
+
+(Why not just `TB_`? Because the telemetry test helper functions
+`getSnapshotForHistograms()`/`getSnapshotForKeyedHistograms()` have an option
+to filter out histograms with a `TELEMETRY_TEST_` prefix).
+
+## Compile-time switches
+
+Telemetry is not compiled in by default. You need to add the following line
+to your mozconfig:
+
+ export MOZ_TELEMETRY_REPORTING=1
+
+The nightly and release configs have this setting already (`$ grep -r MOZ_TELEMETRY_ mail/config/mozconfigs`).
+
+## Runtime prefs for testing
+
+There are a few `user.js` settings you'll want to set up for enabling telemetry local builds:
+
+### Send telemetry to a local server
+
+You'll want to set the telemetry end point to a locally-running http server, eg:
+```
+user_pref("toolkit.telemetry.server", "http://localhost:12345");
+user_pref("toolkit.telemetry.server_owner", "TimmyTestfish");
+user_pref("datareporting.healthreport.uploadEnabled",true);
+```
+
+For a simple test server, try https://github.com/mozilla/gzipServer
+(or alternatively https://github.com/bcampbell/webhole).
+
+### Override the official-build-only check
+
+```
+user_pref("toolkit.telemetry.send.overrideOfficialCheck", true);
+```
+
+Without toolkit.telemetry.send.overrideOfficialCheck set, telemetry is only sent for official builds.
+
+### Bypass data policy checks
+
+The data policy checks make sure the user has been shown and
+has accepted the data policy. Bypass them with:
+
+```
+user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification",true);
+user_pref("datareporting.policy.dataSubmissionEnabled", true);
+```
+
+### Enable telemetry tracing
+
+```
+user_pref("toolkit.telemetry.log.level", "Trace");
+```
+
+The output will show up on the DevTools console:
+
+ Menu => "Tools" => "Developer Tools" => "Error Console" (CTRL+SHIFT+J)
+
+If pings aren't showing up, look there for clues.
+
+To log to stdout as well as the console:
+```
+user_pref("toolkit.telemetry.log.dump", true);
+```
+
+### Reduce submission interval
+
+For testing it can be handy to reduce down the submission interval (it's
+usually on the order of hours), eg:
+```
+user_pref("services.sync.telemetry.submissionInterval", 20); // in seconds
+```
+
+### Example user.js file
+
+All the above suggestions in one go, for `$PROFILE/user.js`:
+
+```
+user_pref("toolkit.telemetry.server", "http://localhost:12345");
+user_pref("toolkit.telemetry.server_owner", "TimmyTestfish");
+user_pref("toolkit.telemetry.log.level", "Trace");
+user_pref("toolkit.telemetry.log.dump", true);
+user_pref("toolkit.telemetry.send.overrideOfficialCheck", true);
+user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification",true);
+user_pref("services.sync.telemetry.submissionInterval", 20);
+user_pref("datareporting.policy.dataSubmissionEnabled", true);
+user_pref("datareporting.healthreport.uploadEnabled",true);
+```
+
+## Troubleshooting
+
+### Sending test pings
+
+From the DevTools console, you can send an immediate test ping:
+
+```
+const { TelemetrySession } = ChromeUtils.import(
+ "resource://gre/modules/TelemetrySession.jsm"
+);
+TelemetrySession.testPing();
+```
+
+### Trace message: "Telemetry is not allowed to send pings"
+
+This indicates `TelemetrySend.sendingEnabled()` is returning false;
+
+Fails if not an official build (override using `toolkit.telemetry.send.overrideOfficialCheck`).
+
+If `toolkit.telemetry.unified` and `datareporting.healthreport.uploadEnabled` are true, then
+`sendingEnabled()` returns true;
+
+If `toolkit.telemetry.unified` is false, then the intended-to-be-deprecated `toolkit.telemetry.enabled` controls the result.
+We're using unified telemetry, so this shouldn't be an issue.
+
+### Trace message: "can't send ping now, persisting to disk"
+
+Trace shows:
+```
+TelemetrySend::submitPing - can't send ping now, persisting to disk - canSendNow: false
+```
+
+This means `TelemetryReportingPolicy.canUpload()` is returning false.
+
+Requirements for `canUpload()`:
+
+`datareporting.policy.dataSubmissionEnabled` must be true.
+AND
+`datareporting.policy.dataSubmissionPolicyNotifiedTime` has a sane timestamp (and is > `OLDEST_ALLOWED_ACCEPTANCE_YEAR`).
+AND
+`datareporting.policy.dataSubmissionPolicyAcceptedVersion` >= `datareporting.policy.minimumPolicyVersion`
+
+Or the notification policy can be bypassed by setting:
+`datareporting.policy.dataSubmissionPolicyBypassNotification` to true.
+
+## Further documentation
+
+The Telemetry documentation index is at:
+
+https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/index.html
+
+There's a good summary of settings (both compile-time and run-time prefs):
+
+https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/internals/preferences.html
diff --git a/comm/mail/components/telemetry/Scalars.yaml b/comm/mail/components/telemetry/Scalars.yaml
new file mode 100644
index 0000000000..13ac19fcdd
--- /dev/null
+++ b/comm/mail/components/telemetry/Scalars.yaml
@@ -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/.
+
+# This file contains Thunderbird-specific telemetry Scalar definitions, which
+# are added on top of the Firefox ones (in /toolkit/components/telemetry).
+# To avoid name clashes, all the TB scalars will be under a "tb" section.
+# They are submitted with the "main" pings and can be inspected in about:telemetry.
+
+# The following section is for probes testing the Telemetry system.
+# Under normal operation, these won't be invoked.
+tb.test:
+ unsigned_int_kind:
+ bug_numbers:
+ - 1427877
+ description: >
+ This is a test uint type with a really long description, maybe spanning even multiple
+ lines, to just prove a point: everything works just fine.
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ string_kind:
+ bug_numbers:
+ - 1427877
+ description: A string test type with a one line comment that works just fine!
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: string
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ boolean_kind:
+ bug_numbers:
+ - 1427877
+ description: A boolean test type with a one line comment that works just fine!
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: boolean
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.account:
+ count:
+ bug_numbers:
+ - 1615981
+ description: Count of how many accounts were set up, keyed by account type.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ successful_email_account_setup:
+ bug_numbers:
+ - 1615987
+ - 1644311
+ description: How many times email accounts setup succeeded, keyed by account config source.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ failed_email_account_setup:
+ bug_numbers:
+ - 1615987
+ - 1644311
+ description: How many times email accounts setup failed, keyed by account config source.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ size_on_disk:
+ bug_numbers:
+ - 1615983
+ description: How many bytes does each type of folder take on disk.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ total_messages:
+ bug_numbers:
+ - 1615983
+ description: How many messages does each type of folder have.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ opened_account_provisioner:
+ bug_numbers:
+ - 1734484
+ description: How many times the user access the account provisioner tab.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ selected_account_from_provisioner:
+ bug_numbers:
+ - 1734484
+ description:
+ How many times the user clicks on a suggested email address from the
+ account provisioner tab, keyed by the provider hostname.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ new_account_from_provisioner:
+ bug_numbers:
+ - 1734484
+ description:
+ How many times a new email address was successfully created from the
+ account provisioner tab, keyed by the provider hostname.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ oauth2_provider_count:
+ bug_numbers:
+ - 1799726
+ description:
+ A count of incoming mail accounts using OAuth2 for authentication, keyed
+ broadly by account provider.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.compose:
+ format_html:
+ bug_numbers:
+ - 1584889
+ description: How many times messages were written in HTML composition mode.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ format_plain_text:
+ bug_numbers:
+ - 1584889
+ description: How many times messages were written in plain text composition mode.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.filelink:
+ uploaded_size:
+ bug_numbers:
+ - 1615984
+ description: Accumulated file size (bytes) uploaded to filelink services, keyed by filelink provider type.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ ignored:
+ bug_numbers:
+ - 1615984
+ description: How many times filelink suggestion are ignored.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.mails:
+ sent:
+ bug_numbers:
+ - 1615989
+ description: How many emails are sent.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ read:
+ bug_numbers:
+ - 1615990
+ description: How many emails are read.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ read_secure:
+ bug_numbers:
+ - 1615994
+ description: How many times different kinds of secure emails are read (for the first time).
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ keys:
+ - 'signed-smime'
+ - 'signed-openpgp'
+ - 'encrypted-smime'
+ - 'encrypted-openpgp'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ folder_opened:
+ bug_numbers:
+ - 1800775
+ description: How many times folders of each type are opened.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ keyed: true
+ keys:
+ - Inbox
+ - Drafts
+ - Trash
+ - SentMail
+ - Templates
+ - Junk
+ - Archive
+ - Queue
+ - Virtual
+ - Other
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.preferences:
+ boolean:
+ bug_numbers:
+ - 1757993
+ description: Values of boolean preferences.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: boolean
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ integer:
+ bug_numbers:
+ - 1800775
+ description: Values of integer preferences.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.websearch:
+ usage:
+ bug_numbers:
+ - 1641773
+ description: How many times search the web was used, keyed by search engine name.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.addressbook:
+ addressbook_count:
+ bug_numbers:
+ - 1615986
+ description: How many addressbooks were set up, keyed by addressbook directory URI scheme.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ contact_count:
+ bug_numbers:
+ - 1615986
+ description: Count of contacts in all addressbooks, keyed by addressbook directory URI scheme.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.calendar:
+ calendar_count:
+ bug_numbers:
+ - 1615985
+ description: How many calendars were set up, keyed by calendar type.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ read_only_calendar_count:
+ bug_numbers:
+ - 1615985
+ description: How many read only calendars were set up, keyed by calendar type.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.ui.configuration:
+ folder_tree_modes:
+ bug_numbers:
+ - 1800775
+ description: Configuration of the folder tree.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: string
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ pane_visibility:
+ bug_numbers:
+ - 1800775
+ description: Configuration of the folder and message panes.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: boolean
+ keyed: true
+ keys:
+ - folderPane
+ - messagePane
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ message_header:
+ bug_numbers:
+ - 1800775
+ description: Configuration of the message header display.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ keyed: true
+ keys:
+ - showAvatar
+ - showBigAvatar
+ - showFullAddress
+ - hideLabels
+ - subjectLarge
+ - buttonStyle
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.ui.interaction:
+ calendar:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with items in the calendar.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+ chat:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with items in chat.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+ keyboard:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with keyboard shortcuts.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+ message_display:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with items in the message display.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+ toolbox:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with items in the main window toolbox.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+tb.chat:
+ active_message_theme:
+ bug_numbers:
+ - 1767004
+ description: >
+ Records the currently active chat message theme and variant.
+ expires: "117"
+ kind: string
+ keyed: false
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
diff --git a/comm/mail/components/test/unit/head_mailcomponents.js b/comm/mail/components/test/unit/head_mailcomponents.js
new file mode 100644
index 0000000000..0c275d8abb
--- /dev/null
+++ b/comm/mail/components/test/unit/head_mailcomponents.js
@@ -0,0 +1,20 @@
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+var CC = Components.Constructor;
+
+// Ensure the profile directory is set up
+do_get_profile();
+
+var gDEPTH = "../../../../";
+
+registerCleanupFunction(function () {
+ load(gDEPTH + "mailnews/resources/mailShutdown.js");
+});
diff --git a/comm/mail/components/test/unit/test_about_support.js b/comm/mail/components/test/unit/test_about_support.js
new file mode 100644
index 0000000000..5626cbbe83
--- /dev/null
+++ b/comm/mail/components/test/unit/test_about_support.js
@@ -0,0 +1,219 @@
+/* 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/. */
+
+// mail/components/about-support/content/accounts.js
+/* globals AboutSupport, AboutSupportPlatform */
+
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+
+/*
+ * Test the about:support module.
+ */
+
+var gAccountList = [
+ {
+ type: "pop3",
+ port: 1234,
+ user: "pop3user",
+ password: "pop3password",
+ socketType: Ci.nsMsgSocketType.plain,
+ authMethod: Ci.nsMsgAuthMethod.old,
+ smtpServers: [],
+ },
+ {
+ type: "imap",
+ port: 2345,
+ user: "imapuser",
+ password: "imappassword",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: true,
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ {
+ type: "nntp",
+ port: 4567,
+ user: null,
+ password: null,
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.GSSAPI,
+ smtpServers: [
+ {
+ port: 5678,
+ user: "newsout1",
+ password: "newsoutpassword1",
+ isDefault: true,
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.NTLM,
+ },
+ {
+ port: 6789,
+ user: "newsout2",
+ password: "newsoutpassword2",
+ isDefault: false,
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.External,
+ },
+ ],
+ },
+];
+
+// A map of account keys to servers. Populated by setup_accounts.
+var gAccountMap = new Map();
+// A map of SMTP server names to SMTP servers. Populated by setup_accounts.
+var gSMTPMap = new Map();
+
+/**
+ * A list of sensitive data: it shouldn't be present in the account
+ * details. Populated by setup_accounts.
+ */
+var gSensitiveData = [];
+
+/**
+ * Set up accounts based on the given data.
+ */
+function setup_accounts() {
+ // First make sure the local folders account is set up.
+ localAccountUtils.loadLocalMailAccount();
+
+ // Now run through the details and set up accounts accordingly.
+ for (let details of gAccountList) {
+ let server = localAccountUtils.create_incoming_server(
+ details.type,
+ details.port,
+ details.user,
+ details.password
+ );
+ server.socketType = details.socketType;
+ server.authMethod = details.authMethod;
+ gSensitiveData.push(details.password);
+ let account = MailServices.accounts.FindAccountForServer(server);
+ for (let smtpDetails of details.smtpServers) {
+ let outgoing = localAccountUtils.create_outgoing_server(
+ smtpDetails.port,
+ smtpDetails.user,
+ smtpDetails.password
+ );
+ outgoing.socketType = smtpDetails.socketType;
+ outgoing.authMethod = smtpDetails.authMethod;
+ localAccountUtils.associate_servers(
+ account,
+ outgoing,
+ smtpDetails.isDefault
+ );
+ gSensitiveData.push(smtpDetails.password);
+
+ // Add the SMTP server to our server name -> server map
+ gSMTPMap.set("localhost:" + smtpDetails.port, smtpDetails);
+ }
+
+ // Add the server to our account -> server map
+ gAccountMap.set(account.key, details);
+ }
+}
+
+/**
+ * Verify that the given account's details match our details for the key.
+ */
+function verify_account_details(aDetails) {
+ let expectedDetails = gAccountMap.get(aDetails.key);
+ // All our servers are at localhost
+ let expectedHostDetails =
+ "(" + expectedDetails.type + ") localhost:" + expectedDetails.port;
+ Assert.equal(aDetails.hostDetails, expectedHostDetails);
+ Assert.equal(aDetails.socketType, expectedDetails.socketType);
+ Assert.equal(aDetails.authMethod, expectedDetails.authMethod);
+
+ let smtpToSee = expectedDetails.smtpServers.map(
+ smtpDetails => "localhost:" + smtpDetails.port
+ );
+
+ for (let smtpDetails of aDetails.smtpServers) {
+ // Check that we're expecting to see this server
+ let toSeeIndex = smtpToSee.indexOf(smtpDetails.name);
+ Assert.notEqual(toSeeIndex, -1);
+ smtpToSee.splice(toSeeIndex, 1);
+
+ let expectedSMTPDetails = gSMTPMap.get(smtpDetails.name);
+ Assert.equal(smtpDetails.socketType, expectedSMTPDetails.socketType);
+ Assert.equal(smtpDetails.authMethod, expectedSMTPDetails.authMethod);
+ Assert.equal(smtpDetails.isDefault, expectedSMTPDetails.isDefault);
+ }
+
+ // Check that we saw all the SMTP servers we wanted to see
+ Assert.equal(smtpToSee.length, 0);
+}
+
+/**
+ * Tests the getFileSystemType function. This is more a check to make sure the
+ * function returns something meaningful and doesn't throw an exception, since
+ * we don't have any information about what sort of file system we're running
+ * on.
+ */
+function test_get_file_system_type() {
+ let fsType = AboutSupportPlatform.getFileSystemType(do_get_cwd());
+ if ("nsILocalFileMac" in Ci) {
+ // Mac should return null
+ Assert.equal(fsType, null);
+ } else {
+ // Windows and Linux should return a string
+ Assert.ok(["local", "network", "unknown"].includes(fsType));
+ }
+}
+
+/**
+ * Test the getAccountDetails function.
+ */
+function test_get_account_details() {
+ let accountDetails = AboutSupport.getAccountDetails();
+ let accountDetailsText = uneval(accountDetails);
+ // The list of accounts we are looking for
+ let accountsToSee = [...gAccountMap.keys()];
+
+ // Our first check is to see that no sensitive data has crept in
+ for (let data of gSensitiveData) {
+ Assert.ok(!accountDetailsText.includes(data));
+ }
+
+ for (let details of accountDetails) {
+ // We're going to make one exception: for the local folders server. We don't
+ // care too much about its details.
+ if (details.key == localAccountUtils.msgAccount.key) {
+ continue;
+ }
+
+ // Check that we're expecting to see this server
+ let toSeeIndex = accountsToSee.indexOf(details.key);
+ Assert.notEqual(toSeeIndex, -1);
+ accountsToSee.splice(toSeeIndex, 1);
+
+ verify_account_details(details);
+ }
+ // Check that we got all the accounts we wanted to see
+ Assert.equal(accountsToSee.length, 0);
+}
+
+var tests = [test_get_file_system_type, test_get_account_details];
+
+function run_test() {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/about-support/accounts.js"
+ );
+
+ setup_accounts();
+
+ for (let test of tests) {
+ test();
+ }
+}
diff --git a/comm/mail/components/test/unit/test_telemetry_buildconfig.js b/comm/mail/components/test/unit/test_telemetry_buildconfig.js
new file mode 100644
index 0000000000..aa8d20bbb8
--- /dev/null
+++ b/comm/mail/components/test/unit/test_telemetry_buildconfig.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test is a copy of parts of the following tests:
+//
+// * toolkit/components/telemetry/tests/unit/test_TelemetryEvents.js
+// * toolkit/components/telemetry/tests/unit/test_TelemetryHistograms.js
+// * toolkit/components/telemetry/tests/unit/test_TelemetryScalars.js
+//
+// The probe names have been changed to probes that only exist in a Thunderbird build.
+// If this test begins to fail, check for recent changes in toolkit/components/telemetry.
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const Telemetry = Services.telemetry;
+
+const UINT_SCALAR = "tb.test.unsigned_int_kind";
+const STRING_SCALAR = "tb.test.string_kind";
+const BOOLEAN_SCALAR = "tb.test.boolean_kind";
+
+/**
+ * Check that stored events correspond to expectations.
+ *
+ * @param {Array} summaries - Summary of the expected events.
+ * @param {boolean} clearScalars - Whether to clear out data after snapshotting.
+ */
+function checkEventSummary(summaries, clearScalars) {
+ let scalars = Telemetry.getSnapshotForKeyedScalars("main", clearScalars);
+
+ for (let [process, [category, eObject, method], count] of summaries) {
+ let uniqueEventName = `${category}#${eObject}#${method}`;
+ let summaryCount;
+ if (process === "dynamic") {
+ summaryCount =
+ scalars.dynamic["telemetry.dynamic_event_counts"][uniqueEventName];
+ } else {
+ summaryCount =
+ scalars[process]["telemetry.event_counts"][uniqueEventName];
+ }
+ Assert.equal(
+ summaryCount,
+ count,
+ `${uniqueEventName} had wrong summary count`
+ );
+ }
+}
+
+/**
+ * Test Thunderbird events are included in the build.
+ */
+add_task(async function test_recording_state() {
+ Telemetry.clearEvents();
+ Telemetry.clearScalars();
+
+ const events = [["tb.test", "test", "object1"]];
+
+ // Recording off by default.
+ events.forEach(e => Telemetry.recordEvent(...e));
+ TelemetryTestUtils.assertEvents([]);
+ // But still expect a non-zero summary count.
+ checkEventSummary(
+ events.map(e => ["parent", e, 1]),
+ true
+ );
+
+ // Once again, with recording on.
+ Telemetry.setEventRecordingEnabled("tb.test", true);
+ events.forEach(e => Telemetry.recordEvent(...e));
+ TelemetryTestUtils.assertEvents(events);
+ checkEventSummary(
+ events.map(e => ["parent", e, 1]),
+ true
+ );
+});
+
+/**
+ * Test Thunderbird histograms are included in the build.
+ */
+add_task(async function test_categorical_histogram() {
+ let h1 = Telemetry.getHistogramById("TELEMETRY_TEST_TB_CATEGORICAL");
+ for (let v of ["CommonLabel", "CommonLabel", "Label2", "Label3"]) {
+ h1.add(v);
+ }
+ for (let s of ["", "Label4", "1234"]) {
+ // The |add| method should not throw for unexpected values, but rather
+ // print an error message in the console.
+ h1.add(s);
+ }
+
+ let snapshot = h1.snapshot();
+ Assert.deepEqual(snapshot.values, { 0: 2, 1: 1, 2: 1, 3: 0 });
+ // sum is a little meaningless for categorical histograms, but hey.
+ // (CommonLabel is 0, Label2 is 1, Label3 is 2)
+ Assert.equal(snapshot.sum, 0 * 2 + 1 * 1 + 2 * 1);
+ Assert.deepEqual(snapshot.range, [1, 50]);
+});
+
+/**
+ * Test Thunderbird scalars are included in the build.
+ */
+add_task(async function test_serializationFormat() {
+ Telemetry.clearScalars();
+
+ // Set the scalars to a known value.
+ const expectedUint = 3785;
+ const expectedString = "some value";
+ Telemetry.scalarSet(UINT_SCALAR, expectedUint);
+ Telemetry.scalarSet(STRING_SCALAR, expectedString);
+ Telemetry.scalarSet(BOOLEAN_SCALAR, true);
+
+ // Get a snapshot of the scalars for the main process (internally called "default").
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ // Check that they are serialized to the correct format.
+ Assert.equal(
+ typeof scalars[UINT_SCALAR],
+ "number",
+ UINT_SCALAR + " must be serialized to the correct format."
+ );
+ Assert.ok(
+ Number.isInteger(scalars[UINT_SCALAR]),
+ UINT_SCALAR + " must be a finite integer."
+ );
+ Assert.equal(
+ scalars[UINT_SCALAR],
+ expectedUint,
+ UINT_SCALAR + " must have the correct value."
+ );
+ Assert.equal(
+ typeof scalars[STRING_SCALAR],
+ "string",
+ STRING_SCALAR + " must be serialized to the correct format."
+ );
+ Assert.equal(
+ scalars[STRING_SCALAR],
+ expectedString,
+ STRING_SCALAR + " must have the correct value."
+ );
+ Assert.equal(
+ typeof scalars[BOOLEAN_SCALAR],
+ "boolean",
+ BOOLEAN_SCALAR + " must be serialized to the correct format."
+ );
+ Assert.equal(
+ scalars[BOOLEAN_SCALAR],
+ true,
+ BOOLEAN_SCALAR + " must have the correct value."
+ );
+});
diff --git a/comm/mail/components/test/unit/xpcshell.ini b/comm/mail/components/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..1b37e9a3d9
--- /dev/null
+++ b/comm/mail/components/test/unit/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head_mailcomponents.js
+tail =
+
+[test_about_support.js]
+[test_telemetry_buildconfig.js]
diff --git a/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs b/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs
new file mode 100644
index 0000000000..e503306fde
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs
@@ -0,0 +1,299 @@
+/* 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 CUSTOMIZABLE_ITEMS from "resource:///modules/CustomizableItemsDetails.mjs";
+
+const { EXTENSION_PREFIX } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+const browserActionFor = extensionId =>
+ lazy.ExtensionParent.apiManager.global.browserActionFor?.(
+ lazy.ExtensionParent.GlobalManager.getExtension(extensionId)
+ );
+
+/**
+ * Wrapper element for elements whose position can be customized.
+ *
+ * Template ID: #unifiedToolbarCustomizableElementTemplate
+ * Attributes:
+ * - item-id: ID of the customizable item this represents. Not observed.
+ * - disabled: Gets passed on to the live content.
+ */
+export default class CustomizableElement extends HTMLLIElement {
+ static get observedAttributes() {
+ return ["disabled", "tabindex"];
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "customizable-element");
+
+ const template = document
+ .getElementById("unifiedToolbarCustomizableElementTemplate")
+ .content.cloneNode(true);
+
+ const itemId = this.getAttribute("item-id");
+
+ if (itemId.startsWith(EXTENSION_PREFIX)) {
+ const extensionId = itemId.slice(EXTENSION_PREFIX.length);
+ this.append(template);
+ this.#initializeForExtension(extensionId);
+ return;
+ }
+
+ const details = CUSTOMIZABLE_ITEMS.find(item => item.id === itemId);
+ if (!details) {
+ throw new Error(`Could not find definition for ${itemId}`);
+ }
+ this.append(template);
+ this.#initializeFromDetails(details).catch(console.error);
+ }
+
+ attributeChangedCallback(attribute) {
+ switch (attribute) {
+ case "disabled": {
+ const isDisabled = this.disabled;
+ for (const child of this.querySelector(".live-content")?.children ??
+ []) {
+ child.toggleAttribute("disabled", isDisabled);
+ }
+ break;
+ }
+ case "tabindex": {
+ const tabIndex = this.getAttribute("tabindex");
+ if (tabIndex === null) {
+ return;
+ }
+ if (this.details?.skipFocus && tabIndex !== "-1") {
+ this.removeAttribute("tabindex");
+ // Let the container know that an element that shouldn't be focused is
+ // currently marked with a tabindex instruction.
+ if (this.hasConnected) {
+ this.dispatchEvent(new CustomEvent("buttondisabled"));
+ }
+ return;
+ }
+ const tabIndexNumber = parseInt(tabIndex, 10);
+ for (const child of this.querySelector(".live-content")?.children ??
+ []) {
+ child.tabIndex = tabIndexNumber;
+ }
+ if (tabIndex !== "-1") {
+ this.removeAttribute("tabindex");
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Initialize the template contents from item details. Can't operate on the
+ * template directly due to being async.
+ *
+ * @param {CustomizableItemDetails} itemDetails
+ */
+ async #initializeFromDetails(itemDetails) {
+ if (this.details) {
+ return;
+ }
+ this.details = itemDetails;
+ this.classList.add(itemDetails.id);
+ if (Array.isArray(itemDetails.requiredModules)) {
+ await Promise.all(
+ itemDetails.requiredModules.map(module => {
+ return import(module); // eslint-disable-line no-unsanitized/method
+ })
+ );
+ }
+ if (itemDetails.templateId) {
+ const contentTemplate = document.getElementById(itemDetails.templateId);
+ this.querySelector(".live-content").append(
+ contentTemplate.content.cloneNode(true)
+ );
+ if (this.disabled) {
+ this.attributeChangedCallback("disabled");
+ }
+ }
+ if (itemDetails.skipFocus) {
+ this.classList.add("skip-focus");
+ }
+ if (this.hasAttribute("tabindex")) {
+ this.attributeChangedCallback("tabindex");
+ }
+ // We need to manually re-emit this event, since it might've been emitted
+ // after we cloned the template.
+ if (this.querySelector(".live-content button[disabled]")) {
+ this.dispatchEvent(new CustomEvent("buttondisabled"));
+ }
+ document.l10n.setAttributes(
+ this.querySelector(".preview-label"),
+ `${itemDetails.labelId}-label`
+ );
+ }
+
+ /**
+ * Initialize the contents of this customizable element for a button from an
+ * extension.
+ *
+ * @param {string} extensionId - ID of the extension the button is from.
+ */
+ async #initializeForExtension(extensionId) {
+ const extensionAction = browserActionFor(extensionId);
+ if (!extensionAction?.extension) {
+ return;
+ }
+ this.details = {
+ allowMultiple: false,
+ spaces: extensionAction.allowedSpaces ?? ["mail"],
+ };
+ if (!customElements.get("extension-action-button")) {
+ await import("./extension-action-button.mjs");
+ }
+ const { extension } = extensionAction;
+ this.classList.add("extension-action");
+ const extensionButton = document.createElement("button", {
+ is: "extension-action-button",
+ });
+ extensionButton.setAttribute("extension", extensionId);
+ this.querySelector(".live-content").append(extensionButton);
+ if (this.disabled) {
+ this.attributeChangedCallback("disabled");
+ }
+ if (this.hasAttribute("tabindex")) {
+ this.attributeChangedCallback("tabindex");
+ }
+ // We need to manually re-emit this event, since it might've been emitted
+ // before the button was attached to the DOM.
+ if (this.querySelector(".live-content button[disabled]")) {
+ this.dispatchEvent(new CustomEvent("buttondisabled"));
+ }
+ const previewLabel = this.querySelector(".preview-label");
+ const labelText = extension.name || extensionId;
+ previewLabel.textContent = labelText;
+ previewLabel.title = labelText;
+ const { IconDetails } = lazy.ExtensionParent;
+ if (extension.manifest.icons) {
+ let { icon } = IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 16
+ );
+ let { icon: icon2x } = IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ this.style.setProperty(
+ "--webextension-icon",
+ `url("${lazy.ExtensionParent.IconDetails.escapeUrl(icon)}")`
+ );
+ this.style.setProperty(
+ "--webextension-icon-2x",
+ `url("${lazy.ExtensionParent.IconDetails.escapeUrl(icon2x)}")`
+ );
+ }
+ }
+
+ /**
+ * Holds a reference to the palette this element belongs to.
+ *
+ * @type {CustomizationPalette}
+ */
+ get palette() {
+ const paletteClass = this.details.spaces?.length
+ ? "space-specific-palette"
+ : "generic-palette";
+ return this.getRootNode().querySelector(`.${paletteClass}`);
+ }
+
+ /**
+ * If multiple instances of this element are allowed in the same space.
+ *
+ * @type {boolean}
+ */
+ get allowMultiple() {
+ return Boolean(this.details?.allowMultiple);
+ }
+
+ /**
+ * Human readable label for the widget.
+ *
+ * @type {string}
+ */
+ get label() {
+ return this.querySelector(".preview-label").textContent;
+ }
+
+ /**
+ * Calls onTabSwitched on the first button contained in the live content.
+ * No-op if this item is disabled. Called by unified-toolbar's tab monitor.
+ *
+ * @param {TabInfo} tab - Tab that is now selected.
+ * @param {TabInfo} oldTab - Tab that was selected before.
+ */
+ onTabSwitched(tab, oldTab) {
+ if (this.disabled) {
+ return;
+ }
+ this.querySelector(".live-content button")?.onTabSwitched?.(tab, oldTab);
+ }
+
+ /**
+ * Calls onTabClosing on the first button contained in the live content.
+ * No-op if this item is disabled. Called by unified-toolbar's tab monitor.
+ *
+ * @param {TabInfo} tab - Tab that was closed.
+ */
+ onTabClosing(tab) {
+ if (this.disabled) {
+ return;
+ }
+ this.querySelector(".live-content button")?.onTabClosing?.(tab);
+ }
+
+ /**
+ * If this item can be added to all spaces.
+ *
+ * @type {boolean}
+ */
+ get allSpaces() {
+ return !this.details.spaces?.length;
+ }
+
+ /**
+ * If this item wants to provide its own context menu.
+ *
+ * @type {boolean}
+ */
+ get hasContextMenu() {
+ return Boolean(this.details?.hasContextMenu);
+ }
+
+ /**
+ * @type {boolean}
+ */
+ get disabled() {
+ return this.hasAttribute("disabled");
+ }
+
+ set disabled(value) {
+ this.toggleAttribute("disabled", value);
+ }
+
+ focus() {
+ this.querySelector(".live-content *:first-child")?.focus();
+ }
+}
+customElements.define("customizable-element", CustomizableElement, {
+ extends: "li",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs b/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs
new file mode 100644
index 0000000000..d3d0417f7e
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs
@@ -0,0 +1,243 @@
+/* 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 ListBoxSelection from "./list-box-selection.mjs";
+import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import
+
+const { getAvailableItemIdsForSpace, MULTIPLE_ALLOWED_ITEM_IDS } =
+ ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");
+
+/**
+ * Customization palette containing items that can be added to a customization
+ * target.
+ * Attributes:
+ * - space: ID of the space the widgets are for. "all" for space agnostic
+ * widgets. Not observed.
+ * - items-in-use: Comma-separated IDs of items that are in a target at the time
+ * this is initialized. When changed, initialize should be called.
+ */
+class CustomizationPalette extends ListBoxSelection {
+ contextMenuId = "customizationPaletteMenu";
+
+ /**
+ * If this palette contains items (even if those items are currently all in a
+ * target).
+ *
+ * @type {boolean}
+ */
+ isEmpty = false;
+
+ /**
+ * Array of item IDs allowed to be in this palette.
+ *
+ * @type {string[]}
+ */
+ #allAvailableItems = [];
+
+ /**
+ * If this palette contains items that can be added to all spaces.
+ *
+ * @type {boolean}
+ */
+ #allSpaces = false;
+
+ connectedCallback() {
+ if (super.connectedCallback()) {
+ return;
+ }
+
+ this.#allSpaces = this.getAttribute("space") === "all";
+
+ if (this.#allSpaces) {
+ document
+ .getElementById("customizationPaletteAddEverywhere")
+ .addEventListener("command", this.#handleMenuAddEverywhere);
+ }
+
+ this.initialize();
+ }
+
+ /**
+ * Initializes the contents of the palette from the current state. The
+ * relevant state is defined by the space and items-in-use attributes.
+ */
+ initialize() {
+ const itemIds = this.getAttribute("items-in-use").split(",");
+ this.setItems(itemIds);
+ }
+
+ /**
+ * Update the items currently removed from the palette with an array of item
+ * IDs.
+ *
+ * @param {string[]} itemIds - Array of item IDs currently being used in a
+ * target.
+ */
+ setItems(itemIds) {
+ let space = this.getAttribute("space");
+ if (space === "all") {
+ space = undefined;
+ }
+ const itemsInUse = new Set(itemIds);
+ this.#allAvailableItems = getAvailableItemIdsForSpace(space);
+ this.isEmpty = !this.#allAvailableItems.length;
+ const items = this.#allAvailableItems.filter(
+ itemId => !itemsInUse.has(itemId) || MULTIPLE_ALLOWED_ITEM_IDS.has(itemId)
+ );
+ this.replaceChildren(
+ ...items.map(itemId => {
+ const element = document.createElement("li", {
+ is: "customizable-element",
+ });
+ element.setAttribute("item-id", itemId);
+ element.draggable = true;
+ return element;
+ })
+ );
+ }
+
+ /**
+ * Overwritten context menu handler. Before showing the menu, initializes the
+ * menu with items for all the target areas available.
+ *
+ * @param {MouseEvent} event
+ */
+ handleContextMenu = event => {
+ const menu = document.getElementById(this.contextMenuId);
+ const targets = this.getRootNode().querySelectorAll(
+ '[is="customization-target"]'
+ );
+ const addEverywhereItem = document.getElementById(
+ "customizationPaletteAddEverywhere"
+ );
+ addEverywhereItem.setAttribute("hidden", (!this.#allSpaces).toString());
+ const menuItems = Array.from(targets, target => {
+ const menuItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(menuItem, "customize-palette-add-to", {
+ target: target.name,
+ });
+ menuItem.addEventListener(
+ "command",
+ this.#makeAddToTargetHandler(target)
+ );
+ return menuItem;
+ });
+ menuItems.push(addEverywhereItem);
+ menu.replaceChildren(...menuItems);
+ this.initializeContextMenu(event);
+ };
+
+ #handleMenuAddEverywhere = () => {
+ if (this.contextMenuFor) {
+ this.primaryAction(this.contextMenuFor);
+ this.dispatchEvent(
+ new CustomEvent("additem", {
+ detail: {
+ itemId: this.contextMenuFor.getAttribute("item-id"),
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ };
+
+ /**
+ * Generate a context menu item event handler that will add the right clicked
+ * item to the target.
+ *
+ * @param {CustomizationTarget} target
+ * @returns {function} Context menu item event handler curried with the given
+ * target.
+ */
+ #makeAddToTargetHandler(target) {
+ return () => {
+ if (this.contextMenuFor) {
+ this.primaryAction(this.contextMenuFor, target);
+ }
+ };
+ }
+
+ handleDragSuccess(item) {
+ if (item.allowMultiple) {
+ return;
+ }
+ super.handleDragSuccess(item);
+ }
+
+ handleDrop(itemId, sibling, afterSibling) {
+ if (this.querySelector(`li[item-id="${itemId}"]`)?.allowMultiple) {
+ return;
+ }
+ super.handleDrop(itemId, sibling, afterSibling);
+ }
+
+ canAddElement(itemId) {
+ return (
+ this.#allAvailableItems.includes(itemId) &&
+ (super.canAddElement(itemId) ||
+ this.querySelector(`li[item-id="${itemId}"]`).allowMultiple)
+ );
+ }
+
+ /**
+ * The primary action for the palette is to add the item to a customization
+ * target. Will pick the first target if none is provided.
+ *
+ * @param {CustomizableElement} item - Item to move to a target.
+ * @param {CustomizationTarget} [target] - The target to move the item to.
+ * Defaults to the first target in the root.
+ */
+ primaryAction(item, target) {
+ if (!target) {
+ target = this.getRootNode().querySelector('[is="customization-target"]');
+ }
+ if (item?.allowMultiple) {
+ target.addItem(item.cloneNode(true));
+ return;
+ }
+ if (super.primaryAction(item)) {
+ return;
+ }
+ target.addItem(item);
+ }
+
+ /**
+ * Returns the item to this palette from some other place.
+ *
+ * @param {CustomizableElement} item - Item to return to this palette.
+ */
+ returnItem(item) {
+ if (item.allowMultiple) {
+ item.remove();
+ return;
+ }
+ this.append(item);
+ }
+
+ /**
+ * Filter the items in the palette for the given string based on their label.
+ * The comparison is done on the lower cased label, and the filter string is
+ * lower cased as well.
+ *
+ * @param {string} filterString - String to filter the items by.
+ */
+ filterItems(filterString) {
+ const lowerFilterString = filterString.toLowerCase();
+ for (const item of this.children) {
+ item.hidden = !item.label.toLowerCase().includes(lowerFilterString);
+ }
+ }
+
+ addItemById(itemId) {
+ const item = this.querySelector(`[item-id="${itemId}"]`);
+ if (!item) {
+ return;
+ }
+ this.primaryAction(item);
+ }
+}
+customElements.define("customization-palette", CustomizationPalette, {
+ extends: "ul",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/customization-target.mjs b/comm/mail/components/unifiedtoolbar/content/customization-target.mjs
new file mode 100644
index 0000000000..1ea5f67160
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/customization-target.mjs
@@ -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/. */
+
+import ListBoxSelection from "./list-box-selection.mjs";
+import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import
+
+const { getAvailableItemIdsForSpace } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+
+/**
+ * Customization target where items can be placed, rearranged and removed.
+ * Attributes:
+ * - aria-label: Name of the target area.
+ * - current-items: Comma separated item IDs currently in this area. When
+ * changed initialize should be called.
+ * Events:
+ * - itemchange: Fired whenever the items inside the toolbar are added, moved or
+ * removed.
+ * - space: The space this target is in.
+ */
+class CustomizationTarget extends ListBoxSelection {
+ contextMenuId = "customizationTargetMenu";
+ actionKey = "Delete";
+ canMoveItems = true;
+
+ connectedCallback() {
+ if (super.connectedCallback()) {
+ return;
+ }
+
+ document
+ .getElementById("customizationTargetForward")
+ .addEventListener("command", this.#handleMenuForward);
+ document
+ .getElementById("customizationTargetBackward")
+ .addEventListener("command", this.#handleMenuBackward);
+ document
+ .getElementById("customizationTargetRemove")
+ .addEventListener("command", this.#handleMenuRemove);
+ document
+ .getElementById("customizationTargetRemoveEverywhere")
+ .addEventListener("command", this.#handleMenuRemoveEverywhere);
+ document
+ .getElementById("customizationTargetAddEverywhere")
+ .addEventListener("command", this.#handleMenuAddEverywhere);
+ document
+ .getElementById("customizationTargetStart")
+ .addEventListener("command", this.#handleMenuStart);
+ document
+ .getElementById("customizationTargetEnd")
+ .addEventListener("command", this.#handleMenuEnd);
+
+ this.initialize();
+ }
+
+ /**
+ * Initialize the contents of the target from the current state. The relevant
+ * state is passed in via the current-items attribute.
+ */
+ initialize() {
+ const itemIds = this.getAttribute("current-items").split(",");
+ this.setItems(itemIds);
+ }
+
+ /**
+ * Update the items in the target from an array of item IDs.
+ *
+ * @param {string[]} itemIds - ordered array of IDs of the items currently in
+ * the target
+ */
+ setItems(itemIds) {
+ const childCount = this.children.length;
+ const availableItems = getAvailableItemIdsForSpace(
+ this.getAttribute("space"),
+ true
+ );
+ this.replaceChildren(
+ ...itemIds.map(itemId => {
+ const element = document.createElement("li", {
+ is: "customizable-element",
+ });
+ element.setAttribute("item-id", itemId);
+ element.setAttribute("disabled", "disabled");
+ element.classList.toggle("collapsed", !availableItems.includes(itemId));
+ element.draggable = true;
+ return element;
+ })
+ );
+ if (childCount) {
+ this.#onChange();
+ }
+ }
+
+ /**
+ * Human-readable name of the customization target area.
+ *
+ * @type {string}
+ */
+ get name() {
+ return this.getAttribute("aria-label");
+ }
+
+ handleContextMenu = event => {
+ this.initializeContextMenu(event);
+ const notForAllSpaces = !this.contextMenuFor.allSpaces;
+ const removeEverywhereItem = document.getElementById(
+ "customizationTargetRemoveEverywhere"
+ );
+ const addEverywhereItem = document.getElementById(
+ "customizationTargetAddEverywhere"
+ );
+ addEverywhereItem.setAttribute("hidden", notForAllSpaces.toString());
+ removeEverywhereItem.setAttribute("hidden", notForAllSpaces.toString());
+ if (!notForAllSpaces) {
+ const customization = this.getRootNode().host.closest(
+ "unified-toolbar-customization"
+ );
+ const itemId = this.contextMenuFor.getAttribute("item-id");
+ addEverywhereItem.disabled =
+ !this.contextMenuFor.allowMultiple &&
+ customization.activeInAllSpaces(itemId);
+ removeEverywhereItem.disabled =
+ this.contextMenuFor.allowMultiple ||
+ !customization.activeInMultipleSpaces(itemId);
+ }
+ const isFirstElement = this.contextMenuFor === this.firstElementChild;
+ const isLastElement = this.contextMenuFor === this.lastElementChild;
+ document.getElementById("customizationTargetBackward").disabled =
+ isFirstElement;
+ document.getElementById("customizationTargetForward").disabled =
+ isLastElement;
+ document.getElementById("customizationTargetStart").disabled =
+ isFirstElement;
+ document.getElementById("customizationTargetEnd").disabled = isLastElement;
+ };
+
+ /**
+ * Event handler when the context menu item to move the item forward is
+ * selected.
+ */
+ #handleMenuForward = () => {
+ if (this.contextMenuFor) {
+ this.moveItemForward(this.contextMenuFor);
+ }
+ };
+
+ /**
+ * Event handler when the context menu item to move the item backward is
+ * selected.
+ */
+ #handleMenuBackward = () => {
+ if (this.contextMenuFor) {
+ this.moveItemBackward(this.contextMenuFor);
+ }
+ };
+
+ /**
+ * Event handler when the context menu item to remove the item is selected.
+ */
+ #handleMenuRemove = () => {
+ if (this.contextMenuFor) {
+ this.primaryAction(this.contextMenuFor);
+ }
+ };
+
+ #handleMenuRemoveEverywhere = () => {
+ if (this.contextMenuFor) {
+ this.primaryAction(this.contextMenuFor);
+ this.dispatchEvent(
+ new CustomEvent("removeitem", {
+ detail: {
+ itemId: this.contextMenuFor.getAttribute("item-id"),
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ };
+
+ #handleMenuAddEverywhere = () => {
+ if (this.contextMenuFor) {
+ this.dispatchEvent(
+ new CustomEvent("additem", {
+ detail: {
+ itemId: this.contextMenuFor.getAttribute("item-id"),
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ };
+
+ #handleMenuStart = () => {
+ if (this.contextMenuFor) {
+ this.moveItemToStart(this.contextMenuFor);
+ }
+ };
+
+ #handleMenuEnd = () => {
+ if (this.contextMenuFor) {
+ this.moveItemToEnd(this.contextMenuFor);
+ }
+ };
+
+ /**
+ * Emit a change event. Should be called whenever items are added, moved or
+ * removed from the target.
+ */
+ #onChange() {
+ const changeEvent = new Event("itemchange", {
+ bubbles: true,
+ // Make sure this bubbles out of the pane shadow root.
+ composed: true,
+ });
+ this.dispatchEvent(changeEvent);
+ }
+
+ /**
+ * Adopt an item from another list into this one.
+ *
+ * @param {?CustomizableElement} item - Item from another list.
+ */
+ #adoptItem(item) {
+ item?.setAttribute("disabled", "disabled");
+ }
+
+ moveItemForward(...args) {
+ super.moveItemForward(...args);
+ this.#onChange();
+ }
+
+ moveItemBackward(...args) {
+ super.moveItemBackward(...args);
+ this.#onChange();
+ }
+
+ moveItemToStart(...args) {
+ super.moveItemToStart(...args);
+ this.#onChange();
+ }
+
+ moveItemToEnd(...args) {
+ super.moveItemToEnd(...args);
+ this.#onChange();
+ }
+
+ handleDrop(itemId, sibling, afterSibling) {
+ const item = super.handleDrop(itemId, sibling, afterSibling);
+ if (item) {
+ this.#adoptItem(item);
+ this.#onChange();
+ }
+ }
+
+ handleDragSuccess(item) {
+ super.handleDragSuccess(item);
+ this.#onChange();
+ }
+
+ /**
+ * Return the item to its palette, removing it from this target.
+ *
+ * @param {CustomizableElement} item - The item to remove.
+ */
+ primaryAction(item) {
+ if (super.primaryAction(item)) {
+ return;
+ }
+ item.palette.returnItem(item);
+ this.#onChange();
+ }
+
+ /**
+ * Add an item to the end of this customization target.
+ *
+ * @param {CustomizableElement} item - The item to add.
+ */
+ addItem(item) {
+ if (!item) {
+ return;
+ }
+ this.#adoptItem(item);
+ this.append(item);
+ this.#onChange();
+ }
+
+ removeItemById(itemId) {
+ const item = this.querySelector(`[item-id="${itemId}"]`);
+ if (!item) {
+ return;
+ }
+ this.primaryAction(item);
+ }
+
+ /**
+ * Check if an item is currently used in this target.
+ *
+ * @param {string} itemId - Item ID of the item to check for.
+ * @returns {boolean} If the item is currently used in this target.
+ */
+ hasItem(itemId) {
+ return Boolean(this.querySelector(`[item-id="${itemId}"]`));
+ }
+
+ /**
+ * IDs of the items currently in this target, in correct order including
+ * duplicates.
+ *
+ * @type {string[]}
+ */
+ get itemIds() {
+ return Array.from(this.children, element =>
+ element.getAttribute("item-id")
+ );
+ }
+
+ /**
+ * If the contents of this target differ from the currently saved
+ * configuration.
+ *
+ * @type {boolean}
+ */
+ get hasChanges() {
+ return this.itemIds.join(",") !== this.getAttribute("current-items");
+ }
+}
+customElements.define("customization-target", CustomizationTarget, {
+ extends: "ul",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs b/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs
new file mode 100644
index 0000000000..cc833aae62
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs
@@ -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/. */
+
+import { UnifiedToolbarButton } from "./unified-toolbar-button.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+let browserActionFor = extensionId => {
+ const extension =
+ lazy.ExtensionParent.GlobalManager.getExtension(extensionId);
+ if (!extension) {
+ return null;
+ }
+ return lazy.ExtensionParent.apiManager.global.browserActionFor(extension);
+};
+
+const BADGE_BACKGROUND_COLOR = "--toolbar-button-badge-bg-color";
+
+/**
+ * Attributes:
+ * - extension: ID of the extension this button is for.
+ * - open: true if the popup is currently open. Gets redirected to aria-pressed.
+ */
+class ExtensionActionButton extends UnifiedToolbarButton {
+ static get observedAttributes() {
+ return super.observedAttributes.concat("open");
+ }
+
+ /**
+ * ext-browserAction instance for this button.
+ *
+ * @type {?ToolbarButtonAPI}
+ */
+ #action = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ super.connectedCallback();
+ if (this.#action?.extension?.hasPermission("menus")) {
+ document.addEventListener("popupshowing", this.#action);
+ }
+ return;
+ }
+ super.connectedCallback();
+ this.#action = browserActionFor(this.getAttribute("extension"));
+ if (!this.#action) {
+ return;
+ }
+ const contextData = this.#action.getContextData(
+ this.#action.getTargetFromWindow(window)
+ );
+ this.applyTabData(contextData);
+ if (this.#action.extension.hasPermission("menus")) {
+ document.addEventListener("popupshowing", this.#action);
+ if (this.#action.defaults.type == "menu") {
+ let menupopup = document.createXULElement("menupopup");
+ menupopup.dataset.actionMenu = this.#action.manifestName;
+ menupopup.dataset.extensionId = this.#action.extension.id;
+ menupopup.addEventListener("popuphiding", event => {
+ if (event.target.state === "open") {
+ return;
+ }
+ this.removeAttribute("aria-pressed");
+ });
+ this.appendChild(menupopup);
+ }
+ }
+ }
+
+ disconnectedCallback() {
+ if (this.#action?.extension?.hasPermission("menus")) {
+ document.removeEventListener("popupshowing", this.#action);
+ }
+ }
+
+ attributeChangedCallback(attribute) {
+ super.attributeChangedCallback(attribute);
+ if (attribute === "open") {
+ if (this.getAttribute("open") === "true") {
+ this.setAttribute("aria-pressed", "true");
+ } else {
+ this.removeAttribute("aria-pressed");
+ }
+ }
+ }
+
+ /**
+ * Apply the data for the current tab to the extension button. Updates title,
+ * label, icon, badge, disabled and popup.
+ *
+ * @param {object} tabData - Properties for the button in the current tab. See
+ * ExtensionToolbarButtons.jsm for more details.
+ */
+ applyTabData(tabData) {
+ if (!this.#action) {
+ this.#action = browserActionFor(this.getAttribute("extension"));
+ }
+ this.title = tabData.title || this.#action.extension.name;
+ this.setAttribute("label", tabData.label || this.title);
+ this.classList.toggle("prefer-icon-only", tabData.label == "");
+ this.badge = tabData.badgeText;
+ this.disabled = !tabData.enabled;
+ const { style } = this.#action.iconData.get(tabData.icon);
+ for (const [propName, value] of style) {
+ this.style.setProperty(propName, value);
+ }
+ if (tabData.badgeText && tabData.badgeBackgroundColor) {
+ const bgColor = tabData.badgeBackgroundColor;
+ this.style.setProperty(
+ BADGE_BACKGROUND_COLOR,
+ `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})`
+ );
+ } else {
+ this.style.removeProperty(BADGE_BACKGROUND_COLOR);
+ }
+ this.toggleAttribute("popup", tabData.popup || tabData.type == "menu");
+ if (!tabData.popup) {
+ this.removeAttribute("aria-pressed");
+ }
+ }
+
+ handleClick = event => {
+ // If there is a menupopup associated with this button, open it, instead of
+ // executing the click action.
+ const menupopup = this.querySelector("menupopup");
+ if (menupopup) {
+ event.preventDefault();
+ event.stopPropagation();
+ menupopup.openPopup(this, {
+ position: "after_start",
+ triggerEvent: event,
+ });
+ this.setAttribute("aria-pressed", "true");
+ return;
+ }
+ this.#action?.handleEvent(event);
+ };
+
+ handlePopupShowing(event) {
+ this.#action.handleEvent(event);
+ }
+}
+customElements.define("extension-action-button", ExtensionActionButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs
new file mode 100644
index 0000000000..9fe0aef11d
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs
@@ -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/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/* import-globals-from ../../../../../calendar/base/content/calendar-extract.js */
+
+/**
+ * Unified toolbar button to add the selected message to a calendar as event or
+ * task.
+ * Attributes:
+ * - type: "event" or "task", specifying the target type to create.
+ */
+class AddToCalendarButton extends MailTabButton {
+ onCommandContextChange() {
+ const about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ this.disabled =
+ (about3Pane && !about3Pane.gDBView) ||
+ (about3Pane?.gDBView?.numSelected ?? -1) === 0;
+ }
+
+ handleClick = event => {
+ const tabmail = document.getElementById("tabmail");
+ const about3Pane = tabmail.currentAbout3Pane;
+ const type = this.getAttribute("type");
+ calendarExtract.extractFromEmail(
+ tabmail.currentAboutMessage?.gMessage ||
+ about3Pane.gDBView.hdrForFirstSelectedMessage,
+ type !== "task"
+ );
+ event.preventDefault();
+ event.stopPropagation();
+ };
+}
+customElements.define("add-to-calendar-button", AddToCalendarButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs
new file mode 100644
index 0000000000..593513cd35
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs
@@ -0,0 +1,19 @@
+/* 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 { UnifiedToolbarButton } from "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs";
+
+/**
+ * Unified toolbar button that opens the add-ons manager.
+ */
+class AddonsButton extends UnifiedToolbarButton {
+ handleClick = event => {
+ window.openAddonsMgr();
+ event.preventDefault();
+ event.stopPropagation();
+ };
+}
+customElements.define("addons-button", AddonsButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs
new file mode 100644
index 0000000000..78abbaef3a
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs
@@ -0,0 +1,40 @@
+/* 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 { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/**
+ * Unified toolbar button for compacting the current folder.
+ */
+class CompactFolderButton extends MailTabButton {
+ observed3PaneEvents = ["folderURIChanged"];
+ observedAboutMessageEvents = [];
+
+ onCommandContextChange() {
+ const { gFolder } =
+ document.getElementById("tabmail").currentAbout3Pane ?? {};
+ if (!gFolder) {
+ this.disabled = true;
+ return;
+ }
+ try {
+ this.disabled = !gFolder.isCommandEnabled("cmd_compactFolder");
+ } catch {
+ this.disabled = true;
+ }
+ }
+
+ handleClick = event => {
+ const about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ if (!about3Pane) {
+ return;
+ }
+ about3Pane.folderPane.compactFolder(about3Pane.gFolder);
+ event.preventDefault();
+ event.stopPropagation();
+ };
+}
+customElements.define("compact-folder-button", CompactFolderButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs
new file mode 100644
index 0000000000..02b8bb8035
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs
@@ -0,0 +1,44 @@
+/* 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 { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/* import-globals-from ../../../../base/content/globalOverlay.js */
+
+/**
+ * Unified toolbar button that deletes the selected message or folder.
+ */
+class DeleteButton extends MailTabButton {
+ onCommandContextChange() {
+ const tabmail = document.getElementById("tabmail");
+ try {
+ const controller = getEnabledControllerForCommand("cmd_deleteMessage");
+ const tab = tabmail.currentTabInfo;
+ const message = tab.message;
+
+ this.disabled = !controller || !message;
+
+ if (!this.disabled && message.flags & Ci.nsMsgMessageFlags.IMAPDeleted) {
+ this.setAttribute("label-id", "toolbar-undelete-label");
+ document.l10n.setAttributes(this, "toolbar-undelete");
+ } else {
+ this.setAttribute("label-id", "toolbar-delete-label");
+ document.l10n.setAttributes(this, "toolbar-delete-title");
+ }
+ } catch {
+ this.disabled = true;
+ }
+ }
+
+ handleClick(event) {
+ goDoCommand(
+ event.shiftKey ? "cmd_shiftDeleteMessage" : "cmd_deleteMessage"
+ );
+ event.preventDefault();
+ event.stopPropagation();
+ }
+}
+customElements.define("delete-button", DeleteButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs
new file mode 100644
index 0000000000..9d99dbbf30
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs
@@ -0,0 +1,81 @@
+/* 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 { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FolderUtils",
+ "resource:///modules/FolderUtils.jsm"
+);
+
+class FolderLocationButton extends MailTabButton {
+ /**
+ * Image element displaying the icon on the button.
+ *
+ * @type {Image?}
+ */
+ #icon = null;
+
+ /**
+ * If we've added our event listeners, especially to the current about3pane.
+ *
+ * @type {boolean}
+ */
+ #addedListeners = false;
+
+ observed3PaneEvents = ["folderURIChanged"];
+
+ observedAboutMessageEvents = [];
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.#addedListeners) {
+ return;
+ }
+ this.#icon = this.querySelector(".button-icon");
+ this.onCommandContextChange();
+ this.#addedListeners = true;
+ const popup = document.getElementById(this.getAttribute("popup"));
+ popup.addEventListener("command", this.#handlePopupCommand);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this.#addedListeners) {
+ const popup = document.getElementById(this.getAttribute("popup"));
+ popup.removeEventListener("command", this.#handlePopupCommand);
+ }
+ }
+
+ #handlePopupCommand = event => {
+ const about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(event.target._folder.URI);
+ };
+
+ /**
+ * Update the label and icon of the button from the currently selected folder
+ * in the local 3pane.
+ */
+ onCommandContextChange() {
+ if (!this.#icon) {
+ return;
+ }
+ const { gFolder } =
+ document.getElementById("tabmail").currentAbout3Pane ?? {};
+ if (!gFolder) {
+ this.disabled = true;
+ return;
+ }
+ this.disabled = false;
+ this.label.textContent = gFolder.name;
+ this.#icon.style = `content: url(${lazy.FolderUtils.getFolderIcon(
+ gFolder
+ )});`;
+ }
+}
+customElements.define("folder-location-button", FolderLocationButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs b/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs
new file mode 100644
index 0000000000..924955c895
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs
@@ -0,0 +1,223 @@
+/* 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 { SearchBar } from "chrome://messenger/content/unifiedtoolbar/search-bar.mjs";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ GlodaIMSearcher: "resource:///modules/GlodaIMSearcher.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaMsgSearcher",
+ "resource:///modules/gloda/GlodaMsgSearcher.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaConstants",
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Gloda",
+ "resource:///modules/gloda/GlodaPublic.jsm"
+);
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "glodaCompleter",
+ () =>
+ Cc["@mozilla.org/autocomplete/search;1?name=gloda"].getService(
+ Ci.nsIAutoCompleteSearch
+ ).wrappedJSObject
+);
+
+/**
+ * Unified toolbar global search bar.
+ */
+class GlobalSearchBar extends SearchBar {
+ // Fields required for the auto complete popup to work.
+
+ get popup() {
+ return document.getElementById("PopupGlodaAutocomplete");
+ }
+
+ controller = {
+ matchCount: 0,
+ searchString: "",
+ stopSearch() {
+ lazy.glodaCompleter.stopSearch();
+ },
+ handleEnter: (isAutocomplete, event) => {
+ if (!isAutocomplete) {
+ return;
+ }
+ this.#handleSearch({ detail: this.controller.searchString });
+ this.reset();
+ },
+ };
+
+ _focus() {
+ this.focus();
+ }
+
+ #searchResultListener = {
+ onSearchResult: (result, search) => {
+ this.controller.matchCount = search.matchCount;
+ if (this.controller.matchCount < 1) {
+ this.popup.closePopup();
+ return;
+ }
+ if (!this.popup.mPopupOpen) {
+ this.popup.openAutocompletePopup(
+ this,
+ this.shadowRoot.querySelector("input")
+ );
+ return;
+ }
+ this.popup.invalidate();
+ },
+ };
+
+ // Normal custom element stuff
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ if (
+ !Services.prefs.getBoolPref(
+ "mailnews.database.global.indexer.enabled",
+ true
+ )
+ ) {
+ return;
+ }
+ // Need to call this after the shadow root test, since this will always set
+ // up a shadow root.
+ super.connectedCallback();
+ this.addEventListener("search", this.#handleSearch);
+ this.addEventListener("autocomplete", this.#handleAutocomplete);
+ // Capturing to avoid the default cursor movements inside the input.
+ this.addEventListener("keydown", this.#handleKeydown, {
+ capture: true,
+ });
+ this.addEventListener("focus", this.#handleFocus);
+ this.addEventListener("blur", this);
+ this.addEventListener("drop", this.#handleDrop, { capture: true });
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "blur":
+ if (this.popup.mPopupOpen) {
+ this.popup.closePopup();
+ }
+ break;
+ }
+ }
+
+ #handleSearch = event => {
+ let tabmail = document.getElementById("tabmail");
+ let args;
+ // Build the query from the autocomplete result.
+ const selectedIndex = this.popup.selectedIndex;
+ if (selectedIndex > -1) {
+ const curResult = lazy.glodaCompleter.curResult;
+ if (curResult) {
+ const row = curResult.getObjectAt(selectedIndex);
+ if (row && !row.fullText && row.nounDef) {
+ let query = lazy.Gloda.newQuery(lazy.GlodaConstants.NOUN_MESSAGE);
+ switch (row.nounDef.name) {
+ case "tag":
+ query = query.tags(row.item);
+ break;
+ case "identity":
+ query = query.involves(row.item);
+ break;
+ }
+ query.orderBy("-date");
+ args = { query };
+ }
+ }
+ }
+ // Or just do a normal full text search.
+ if (!args) {
+ let searchString = event.detail;
+ args = {
+ searcher: new lazy.GlodaMsgSearcher(null, searchString),
+ };
+ if (Services.prefs.getBoolPref("mail.chat.enabled")) {
+ args.IMSearcher = new lazy.GlodaIMSearcher(null, searchString);
+ }
+ }
+ tabmail.openTab("glodaFacet", args);
+ this.popup.closePopup();
+ this.controller.matchCount = 0;
+ this.controller.searchString = "";
+ };
+
+ #handleAutocomplete = event => {
+ this.controller.searchString = event.detail;
+ if (!event.detail) {
+ this.popup.closePopup();
+ this.controller.matchCount = 0;
+ return;
+ }
+ lazy.glodaCompleter.startSearch(
+ this.controller.searchString,
+ "global",
+ null,
+ this.#searchResultListener
+ );
+ };
+
+ #handleKeydown = event => {
+ if (event.ctrlKey) {
+ return;
+ }
+ if (event.key == "ArrowDown") {
+ if (this.popup.selectedIndex < this.controller.matchCount - 1) {
+ ++this.popup.selectedIndex;
+ event.preventDefault();
+ return;
+ }
+ this.popup.selectedIndex = -1;
+ event.preventDefault();
+ return;
+ }
+ if (event.key == "ArrowUp") {
+ if (this.popup.selectedIndex > -1) {
+ --this.popup.selectedIndex;
+ event.preventDefault();
+ return;
+ }
+ this.popup.selectedIndex = this.controller.matchCount - 1;
+ event.preventDefault();
+ }
+ };
+
+ #handleFocus = event => {
+ if (this.controller.searchString && this.controller.matchCount >= 1) {
+ this.popup.openAutocompletePopup(
+ this,
+ this.shadowRoot.querySelector("input")
+ );
+ }
+ };
+
+ #handleDrop = event => {
+ if (event.dataTransfer.types.includes("text/x-moz-address")) {
+ const searchTerm = event.dataTransfer.getData("text/plain");
+ this.#handleSearch({ detail: searchTerm });
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ };
+}
+customElements.define("global-search-bar", GlobalSearchBar);
diff --git a/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs
new file mode 100644
index 0000000000..df9266d077
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs
@@ -0,0 +1,183 @@
+/* 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 { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Map from the direction attribute value to the command the button executes on
+ * click.
+ *
+ * @type {{[string]: string}}
+ */
+const COMMAND_FOR_DIRECTION = {
+ forward: "cmd_goForward",
+ back: "cmd_goBack",
+};
+
+/**
+ * Unified toolbar button to add the selected message to a calendar as event or
+ * task.
+ * Attributes:
+ * - direction: "forward" or "back".
+ */
+class MailGoButton extends MailTabButton {
+ /**
+ * @type {?XULPopupElement}
+ */
+ #contextMenu = null;
+
+ connectedCallback() {
+ if (!this.hasConnected) {
+ const command = COMMAND_FOR_DIRECTION[this.getAttribute("direction")];
+ if (!command) {
+ throw new Error(
+ `Unknown direction "${this.getAttribute("direction")}"`
+ );
+ }
+ this.setAttribute("command", command);
+ this.#contextMenu = document.getElementById("messageHistoryPopup");
+ this.addEventListener("contextmenu", this.#handleContextMenu, true);
+ }
+ super.connectedCallback();
+ }
+
+ /**
+ * Build and show the history popup containing a list of messages to navigate
+ * to. Messages that can't be found or that were in folders we can't find are
+ * ignored. The currently displayed message is marked.
+ *
+ * @param {MouseEvent} event - Event triggering the context menu.
+ */
+ #handleContextMenu = event => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const tabmail = document.getElementById("tabmail");
+ const currentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ const { messageHistory } = tabmail.currentAboutMessage;
+ const { entries, currentIndex } = messageHistory.getHistory();
+
+ // For populating the back menu, we want the most recently visited
+ // messages first in the menu. So we go backward from curPos to 0.
+ // For the forward menu, we want to go forward from curPos to the end.
+ const items = [];
+ const relativePositionBase = entries.length - 1 - currentIndex;
+ for (const [index, entry] of entries.reverse().entries()) {
+ const folder = MailServices.folderLookup.getFolderForURL(entry.folderURI);
+ if (!folder) {
+ // Where did the folder go?
+ continue;
+ }
+
+ let menuText = "";
+ let msgHdr;
+ try {
+ msgHdr = MailServices.messageServiceFromURI(
+ entry.messageURI
+ ).messageURIToMsgHdr(entry.messageURI);
+ } catch (ex) {
+ // Let's just ignore this history entry.
+ continue;
+ }
+ const messageSubject = msgHdr.mime2DecodedSubject;
+ const messageAuthor = msgHdr.mime2DecodedAuthor;
+
+ if (!messageAuthor && !messageSubject) {
+ // Avoid empty entries in the menu. The message was most likely (re)moved.
+ continue;
+ }
+
+ // If the message was not being displayed via the current folder, prepend
+ // the folder name. We do not need to check underlying folders for
+ // virtual folders because 'folder' is the display folder, not the
+ // underlying one.
+ if (folder != currentWindow.gFolder) {
+ menuText = folder.prettyName + " - ";
+ }
+
+ let subject = "";
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ subject = "Re: ";
+ }
+ if (messageSubject) {
+ subject += messageSubject;
+ }
+ if (subject) {
+ menuText += subject + " - ";
+ }
+
+ menuText += messageAuthor;
+ const newMenuItem = document.createXULElement("menuitem");
+ newMenuItem.setAttribute("label", menuText);
+ const relativePosition = relativePositionBase - index;
+ newMenuItem.setAttribute("value", relativePosition);
+ newMenuItem.addEventListener("command", commandEvent => {
+ this.#navigateToUri(commandEvent.target);
+ commandEvent.stopPropagation();
+ });
+ if (relativePosition === 0 && !messageHistory.canPop(0)) {
+ newMenuItem.setAttribute("checked", true);
+ newMenuItem.setAttribute("type", "radio");
+ }
+ items.push(newMenuItem);
+ }
+ this.#contextMenu.replaceChildren(...items);
+
+ this.#contextMenu.openPopupAtScreen(
+ event.screenX,
+ event.screenY,
+ true,
+ event
+ );
+ };
+
+ /**
+ * Select the message in the appropriate folder for the history popup entry.
+ * Finds the message based on the value of the item, which is the relative
+ * index of the item in the message history.
+ *
+ * @param {Element} target
+ */
+ #navigateToUri(target) {
+ const nsMsgViewIndex_None = 0xffffffff;
+ const historyIndex = Number.parseInt(target.getAttribute("value"), 10);
+ const tabmail = document.getElementById("tabmail");
+ const currentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ const messageHistory = tabmail.currentAboutMessage.messageHistory;
+ if (!messageHistory || !messageHistory.canPop(historyIndex)) {
+ return;
+ }
+ const item = messageHistory.pop(historyIndex);
+
+ if (
+ currentWindow.displayFolder &&
+ currentWindow.gFolder?.URI !== item.folderURI
+ ) {
+ const folder = MailServices.folderLookup.getFolderForURL(item.folderURI);
+ currentWindow.displayFolder(folder);
+ }
+ const msgHdr = MailServices.messageServiceFromURI(
+ item.messageURI
+ ).messageURIToMsgHdr(item.messageURI);
+ const index = currentWindow.gDBView.findIndexOfMsgHdr(msgHdr, true);
+ if (index != nsMsgViewIndex_None) {
+ if (currentWindow.threadTree) {
+ currentWindow.threadTree.selectedIndex = index;
+ currentWindow.threadTree.table.body.focus();
+ } else {
+ currentWindow.gViewWrapper.dbView.selection.select(index);
+ currentWindow.displayMessage(
+ currentWindow.gViewWrapper.dbView.URIForFirstSelectedMessage
+ );
+ }
+ }
+ }
+}
+customElements.define("mail-go-button", MailGoButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs b/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs
new file mode 100644
index 0000000000..651502a934
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs
@@ -0,0 +1,32 @@
+/* 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 { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/**
+ * Unified toolbar button for toggling the quick filter bar.
+ */
+class QuickFilterBarToggle extends MailTabButton {
+ observed3PaneEvents = ["folderURIChanged", "select", "qfbtoggle"];
+ observedAboutMessageEvents = [];
+
+ onCommandContextChange() {
+ super.onCommandContextChange();
+ const tabmail = document.getElementById("tabmail");
+ const about3Pane = tabmail.currentAbout3Pane;
+ if (
+ !about3Pane?.paneLayout ||
+ about3Pane.paneLayout.accountCentralVisible
+ ) {
+ this.disabled = true;
+ this.setAttribute("aria-pressed", "false");
+ return;
+ }
+ const active = about3Pane.quickFilterBar.filterer.visible;
+ this.setAttribute("aria-pressed", active.toString());
+ }
+}
+customElements.define("quick-filter-bar-toggle", QuickFilterBarToggle, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs
new file mode 100644
index 0000000000..e3ce55e05e
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs
@@ -0,0 +1,15 @@
+/* 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 { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/**
+ * Unified toolbar button for replying to a mailing list..
+ */
+class ReplyListButton extends MailTabButton {
+ observedAboutMessageEvents = ["load", "MsgLoaded"];
+}
+customElements.define("reply-list-button", ReplyListButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs
new file mode 100644
index 0000000000..75c23592bf
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs
@@ -0,0 +1,41 @@
+/* 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 { UnifiedToolbarButton } from "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs";
+
+/* import-globals-from ../../../../base/content/spacesToolbar.js */
+
+/**
+ * Unified toolbar button that opens a specific space.
+ * Attributes:
+ * - space: Space to open when the button is activated
+ */
+class SpaceButton extends UnifiedToolbarButton {
+ connectedCallback() {
+ super.connectedCallback();
+ const spaceId = this.getAttribute("space");
+ const space = gSpacesToolbar.spaces.find(
+ spaceDetails => spaceDetails.name == spaceId
+ );
+ if (space.button.classList.contains("has-badge")) {
+ const badgeContainer = space.button.querySelector(
+ ".spaces-badge-container"
+ );
+ this.badge = badgeContainer.textContent;
+ }
+ }
+
+ handleClick = event => {
+ const spaceId = this.getAttribute("space");
+ const space = gSpacesToolbar.spaces.find(
+ spaceDetails => spaceDetails.name == spaceId
+ );
+ gSpacesToolbar.openSpace(document.getElementById("tabmail"), space);
+ event.preventDefault();
+ event.stopPropagation();
+ };
+}
+customElements.define("space-button", SpaceButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs
new file mode 100644
index 0000000000..3cd7686b5e
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs
@@ -0,0 +1,40 @@
+/* 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 { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+class ViewPickerButton extends MailTabButton {
+ observed3PaneEvents = ["folderURIChanged", "MailViewChanged"];
+
+ observedAboutMessageEvents = [];
+
+ /**
+ * Update the label and icon of the button from the currently selected folder
+ * in the local 3pane.
+ */
+ onCommandContextChange() {
+ const { gViewWrapper } =
+ document.getElementById("tabmail").currentAbout3Pane ?? {};
+ if (!gViewWrapper) {
+ this.disabled = true;
+ return;
+ }
+ this.disabled = false;
+ const viewPickerPopup = document.getElementById(this.getAttribute("popup"));
+ const value = window.ViewPickerBinding.currentViewValue;
+ let selectedItem = viewPickerPopup.querySelector(`[value="${value}"]`);
+ if (!selectedItem) {
+ // We may have a new item, so refresh to make it show up.
+ window.RefreshAllViewPopups(viewPickerPopup, true);
+ selectedItem = viewPickerPopup.querySelector(`[value="${value}"]`);
+ }
+ this.label.textContent = selectedItem?.getAttribute("label");
+ if (!this.label.textContent) {
+ document.l10n.setAttributes(this.label, "toolbar-view-picker-label");
+ }
+ }
+}
+customElements.define("view-picker-button", ViewPickerButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs b/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs
new file mode 100644
index 0000000000..afe84921dd
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs
@@ -0,0 +1,549 @@
+/* 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 "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import
+
+/**
+ * Shared implementation for a list box used as both a palette of items to add
+ * to a toolbar and a toolbar of items.
+ */
+export default class ListBoxSelection extends HTMLUListElement {
+ /**
+ * The currently selected item for keyboard operations.
+ *
+ * @type {?CustomizableElement}
+ */
+ selectedItem = null;
+
+ /**
+ * The item the context menu is opened for.
+ *
+ * @type {?CustomizableElement}
+ */
+ contextMenuFor = null;
+
+ /**
+ * Key name the primary action is executed on.
+ *
+ * @type {string}
+ */
+ actionKey = "Enter";
+
+ /**
+ * The ID of the menu to show as context menu.
+ *
+ * @type {string}
+ */
+ contextMenuId = "";
+
+ /**
+ * If items can be reordered in this list box.
+ *
+ * @type {boolean}
+ */
+ canMoveItems = false;
+
+ /**
+ * @returns {boolean} If the widget has connected previously.
+ */
+ connectedCallback() {
+ if (this.hasConnected) {
+ return true;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("role", "listbox");
+ this.setAttribute("tabindex", "0");
+
+ this.addEventListener("contextmenu", this.handleContextMenu, {
+ capture: true,
+ });
+ document
+ .getElementById(this.contextMenuId)
+ .addEventListener("popuphiding", this.#handleContextMenuClose);
+ this.addEventListener("keydown", this.#handleKey, { capture: true });
+ this.addEventListener("click", this.#handleClick, { capture: true });
+ this.addEventListener("focus", this.#handleFocus);
+ this.addEventListener("dragstart", this.#handleDragstart);
+ this.addEventListener("dragenter", this.#handleDragenter);
+ this.addEventListener("dragover", this.#handleDragover);
+ this.addEventListener("dragleave", this.#handleDragleave);
+ this.addEventListener("drop", this.#handleDrop);
+ this.addEventListener("dragend", this.#handleDragend);
+ return false;
+ }
+
+ disconnectedCallback() {
+ this.contextMenuFor = null;
+ this.selectedItem = null;
+ }
+
+ /**
+ * Default context menu event handler. Simply forwards the call to
+ * initializeContextMenu.
+ *
+ * @param {MouseEvent} event - The contextmenu mouse click event.
+ */
+ handleContextMenu = event => {
+ this.initializeContextMenu(event);
+ };
+
+ /**
+ * Store the clicked item and open the context menu.
+ *
+ * @param {MouseEvent} event - The contextmenu mouse click event.
+ */
+ initializeContextMenu(event) {
+ // If the context menu was opened by keyboard, we already have the item.
+ if (!this.contextMenuFor) {
+ this.contextMenuFor = event.target.closest("li");
+ this.#clearSelection();
+ }
+ document
+ .getElementById(this.contextMenuId)
+ .openPopupAtScreen(event.screenX, event.screenY, true);
+ }
+
+ /**
+ * Discard the reference to the item the context menu is triggered on when the
+ * menu is closed.
+ */
+ #handleContextMenuClose = () => {
+ this.contextMenuFor = null;
+ };
+
+ /**
+ * Make sure some element is selected when focus enters the element.
+ */
+ #handleFocus = () => {
+ if (!this.selectedItem) {
+ this.selectItem(this.firstElementChild);
+ }
+ };
+
+ /**
+ * Handles basic list box keyboard interactions.
+ *
+ * @param {KeyboardEvent} event - The event for the key down.
+ */
+ #handleKey = event => {
+ // Clicking into the list might clear the selection while retaining focus,
+ // so we need to make sure we have a selected item here.
+ if (!this.selectedItem) {
+ this.selectItem(this.firstElementChild);
+ }
+ const rightIsForward = document.dir === "ltr";
+ switch (event.key) {
+ case this.actionKey:
+ this.primaryAction(this.selectedItem);
+ break;
+ case "Home":
+ if (this.canMoveItems && event.altKey) {
+ this.moveItemToStart(this.selectedItem);
+ break;
+ }
+ this.selectItem(this.firstElementChild);
+ break;
+ case "End":
+ if (this.canMoveItems && event.altKey) {
+ this.moveItemToEnd(this.selectedItem);
+ break;
+ }
+ this.selectItem(this.lastElementChild);
+ break;
+ case "ArrowLeft":
+ if (this.canMoveItems && event.altKey) {
+ if (rightIsForward) {
+ this.moveItemBackward(this.selectedItem);
+ break;
+ }
+ this.moveItemForward(this.selectedItem);
+ break;
+ }
+ if (rightIsForward) {
+ this.selectItem(this.selectedItem?.previousElementSibling);
+ break;
+ }
+ this.selectItem(this.selectedItem?.nextElementSibling);
+ break;
+ case "ArrowRight":
+ if (this.canMoveItems && event.altKey) {
+ if (rightIsForward) {
+ this.moveItemForward(this.selectedItem);
+ break;
+ }
+ this.moveItemBackward(this.selectedItem);
+ break;
+ }
+ if (rightIsForward) {
+ this.selectItem(this.selectedItem?.nextElementSibling);
+ break;
+ }
+ this.selectItem(this.selectedItem?.previousElementSibling);
+ break;
+ case "ContextMenu":
+ this.contextMenuFor = this.selectedItem;
+ return;
+ default:
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ /**
+ * Handles the click event on an item in the list box. Marks the item as
+ * selected.
+ *
+ * @param {MouseEvent} event - The event for the mouse click.
+ */
+ #handleClick = event => {
+ const item = event.target.closest("li");
+ if (item) {
+ this.selectItem(item);
+ } else {
+ this.#clearSelection();
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ /**
+ * Set up the drag data transfer.
+ *
+ * @param {DragEvent} event - Drag start event.
+ */
+ #handleDragstart = event => {
+ // Only allow dragging the customizable elements themeselves.
+ if (event.target.getAttribute("is") !== "customizable-element") {
+ event.preventDefault();
+ return;
+ }
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData(
+ "text/tb-item-id",
+ event.target.getAttribute("item-id")
+ );
+ const customizableItem = event.target;
+ window.requestAnimationFrame(() => {
+ customizableItem.classList.add("dragging");
+ });
+ };
+
+ /**
+ * Calculate the drop position's closest sibling and the relative drop point.
+ * Assumes the list is laid out horizontally if canMoveItems is true. Else
+ * the sibling will be the event target and afterSibling will always be true.
+ *
+ * @param {DragEvent} event - The event the sibling being dragged over should
+ * be found in.
+ * @returns {{sibling: CustomizableElement, afterSibling: boolean}}
+ */
+ #dragSiblingInfo(event) {
+ let sibling = event.target;
+ let afterSibling = true;
+ if (this.canMoveItems) {
+ const listBoundingRect = this.getBoundingClientRect();
+ const listY = listBoundingRect.y + listBoundingRect.height / 2;
+ const element = this.getRootNode().elementFromPoint(event.x, listY);
+ sibling = element.closest('li[is="customizable-element"]');
+ if (!sibling) {
+ if (!this.children.length) {
+ return {};
+ }
+ sibling = this.lastElementChild;
+ }
+ const boundingRect = sibling.getBoundingClientRect();
+ if (event.x < boundingRect.x + boundingRect.width / 2) {
+ afterSibling = false;
+ }
+ if (document.dir === "rtl") {
+ afterSibling = !afterSibling;
+ }
+ }
+ return { sibling, afterSibling };
+ }
+
+ /**
+ * Shared logic for when a drag event happens over a new part of the list.
+ *
+ * @param {DragEvent} event - Drag event.
+ */
+ #dragIn(event) {
+ const itemId = event.dataTransfer.getData("text/tb-item-id");
+ if (!itemId || !this.canAddElement(itemId)) {
+ event.dataTransfer.dropEffect = "none";
+ event.preventDefault();
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ event.dataTransfer.dropEffect = "move";
+ if (!this.canMoveItems) {
+ return;
+ }
+ const { sibling, afterSibling } = this.#dragSiblingInfo(event);
+ if (!sibling) {
+ return;
+ }
+ sibling.classList.toggle("drop-before", !afterSibling);
+ sibling.classList.toggle("drop-after", afterSibling);
+ sibling.nextElementSibling?.classList.remove("drop-before", "drop-after");
+ sibling.previousElementSibling?.classList.remove(
+ "drop-before",
+ "drop-after"
+ );
+ }
+
+ /**
+ * Shared logic for when a drag leaves an element.
+ *
+ * @param {Element} element - Element the drag has left.
+ */
+ #dragOut(element) {
+ element.classList.remove("drop-after", "drop-before");
+ if (element !== this) {
+ return;
+ }
+ for (const child of this.querySelectorAll(".drop-after,.drop-before")) {
+ child.classList.remove("drop-after", "drop-before");
+ }
+ }
+
+ /**
+ * Prevents the default action for the dragenter event to enable dropping
+ * items on this list. Shows a drag position placeholder in the target if
+ * applicable.
+ *
+ * @param {DragEvent} event - Drag enter event.
+ */
+ #handleDragenter = event => {
+ this.#dragIn(event);
+ };
+
+ /**
+ * Prevents the default for the dragover event to enable dropping items on
+ * this list. Shows a drag position placeholder in the target if applicable.
+ *
+ * @param {DragEvent} event - Drag over event.
+ */
+ #handleDragover = event => {
+ this.#dragIn(event);
+ };
+
+ /**
+ * Hide the drag position placeholder.
+ *
+ * @param {DragEvent} event - Drag leave event.
+ */
+ #handleDragleave = event => {
+ if (!this.canMoveItems) {
+ return;
+ }
+ this.#dragOut(event.target);
+ };
+
+ /**
+ * Move the item to the dragged into given position. Possibly moving adopting
+ * it from another list.
+ *
+ * @param {DragEvent} event - Drop event.
+ */
+ #handleDrop = event => {
+ const itemId = event.dataTransfer.getData("text/tb-item-id");
+ if (
+ event.dataTransfer.dropEffect !== "move" ||
+ !itemId ||
+ !this.canAddElement(itemId)
+ ) {
+ return;
+ }
+
+ const { sibling, afterSibling } = this.#dragSiblingInfo(event);
+
+ event.preventDefault();
+ this.#dragOut(sibling ?? this);
+ this.handleDrop(itemId, sibling, afterSibling);
+ };
+
+ /**
+ * Remove the item from this list if it was dropped into another list. Return
+ * it to its palette if dropped outside a valid target.
+ *
+ * @param {DragEvent} event - Drag end event.
+ */
+ #handleDragend = event => {
+ event.target.classList.remove("dragging");
+ if (event.dataTransfer.dropEffect === "move") {
+ this.handleDragSuccess(event.target);
+ return;
+ }
+ // If we can't move the item to the drop location, return it to its palette.
+ const palette = event.target.palette;
+ if (event.dataTransfer.dropEffect === "none" && palette !== this) {
+ event.preventDefault();
+ this.handleDragSuccess(event.target);
+ palette.returnItem(event.target);
+ }
+ };
+
+ /**
+ * Handle an item from a drag operation being added to the list. The drag
+ * origin could be this list or another list.
+ *
+ * @param {string} itemId - Item ID to add to this list from a drop.
+ * @param {CustomizableElement} sibling - Sibling this item should end up next
+ * to.
+ * @param {boolean} afterSibling - If the item should be inserted after the
+ * sibling.
+ * @return {CustomizableElement} The dropped customizable element created by
+ * this handler.
+ */
+ handleDrop(itemId, sibling, afterSibling) {
+ const item = document.createElement("li", {
+ is: "customizable-element",
+ });
+ item.setAttribute("item-id", itemId);
+ item.draggable = true;
+ if (!this.canMoveItems || !sibling) {
+ this.appendChild(item);
+ return item;
+ }
+ if (afterSibling) {
+ sibling.after(item);
+ return item;
+ }
+ sibling.before(item);
+ return item;
+ }
+
+ /**
+ * Handle an item from this list having been dragged somewhere else.
+ *
+ * @param {CustomizableElement} item - Item dragged somewhere else.
+ */
+ handleDragSuccess(item) {
+ item.remove();
+ }
+
+ /**
+ * Check if a given item is allowed to be added to this list. Is false if the
+ * item is already in the list and moving around is not allowed.
+ *
+ * @param {string} itemId - The item ID of the item that wants to be added to
+ * this list.
+ * @returns {boolean} If this item can be added to this list.
+ */
+ canAddElement(itemId) {
+ return this.canMoveItems || !this.querySelector(`li[item-id="${itemId}"]`);
+ }
+
+ /**
+ * Move the item forward in the list box. Only works if canMoveItems is true.
+ *
+ * @param {CustomizableElement} item - The item to move forward.
+ */
+ moveItemForward(item) {
+ if (!this.canMoveItems) {
+ return;
+ }
+ item.nextElementSibling?.after(item);
+ }
+
+ /**
+ * Move the item backward in the list box. Only works if canMoveItems is true.
+ *
+ * @param {CustomizableElement} item - The item to move backward.
+ */
+ moveItemBackward(item) {
+ if (!this.canMoveItems) {
+ return;
+ }
+ item.previousElementSibling?.before(item);
+ }
+
+ /**
+ * Move the item to the start of the list. Only works if canMoveItems is
+ * true.
+ *
+ * @param {CustomizableElement} item - The item to move to the start.
+ */
+ moveItemToStart(item) {
+ if (!this.canMoveItems || item === this.firstElementChild) {
+ return;
+ }
+ this.prepend(item);
+ }
+
+ /**
+ * Move the item to the end of the list. Only works if canMoveItems is true.
+ *
+ * @param {CustomizableElement} item - The item to move to the end.
+ */
+ moveItemToEnd(item) {
+ if (!this.canMoveItems || item === this.lastElementChild) {
+ return;
+ }
+ this.appendChild(item);
+ }
+
+ /**
+ * Select the item. Removes the selection of the previous item. No-op if no
+ * item is passed.
+ *
+ * @param {CustomizableElement} item - The item to select.
+ */
+ selectItem(item) {
+ if (item) {
+ this.selectedItem?.removeAttribute("aria-selected");
+ item.setAttribute("aria-selected", "true");
+ this.selectedItem = item;
+ this.setAttribute("aria-activedescendant", item.id);
+ }
+ }
+
+ /**
+ * Clear the selection inside the list box.
+ */
+ #clearSelection() {
+ this.selectedItem?.removeAttribute("aria-selected");
+ this.selectedItem = null;
+ this.removeAttribute("aria-activedescendant");
+ }
+
+ /**
+ * Select the next item in the list. If there are no more items in either
+ * direction, the selection state is reset.
+ *
+ * @param {CustomizableElement} item - The item of which the next sibling
+ * should be the new selection.
+ */
+ #selectNextItem(item) {
+ const nextItem = item.nextElementSibling || item.previousElementSibling;
+ if (nextItem) {
+ this.selectItem(nextItem);
+ return;
+ }
+ this.#clearSelection();
+ }
+
+ /**
+ * Execute the primary action on the item after it has been deselected and the
+ * next item was selected. Implementations are expected to override this
+ * method and call it as the first step, aborting if it returns true.
+ *
+ * @param {CustomizableElement} item - The item the primary action should be
+ * executed on.
+ * @returns {boolean} If the action should be aborted.
+ */
+ primaryAction(item) {
+ if (!item) {
+ return true;
+ }
+ item.removeAttribute("aria-selected");
+ this.#selectNextItem(item);
+ return false;
+ }
+}
diff --git a/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs b/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs
new file mode 100644
index 0000000000..a0eeee2279
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs
@@ -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/. */
+
+import { UnifiedToolbarButton } from "./unified-toolbar-button.mjs";
+
+/* import-globals-from ../../../base/content/globalOverlay.js */
+
+/**
+ * Mail tab specific unified toolbar button. Instead of tracking a global
+ * command, its state gets re-evaluated every time the state of about:3pane or
+ * about:message tab changes in a relevant way.
+ */
+export class MailTabButton extends UnifiedToolbarButton {
+ /**
+ * Array of events to listen for on the about:3pane document.
+ *
+ * @type {string[]}
+ */
+ observed3PaneEvents = ["folderURIChanged", "select"];
+
+ /**
+ * Array of events to listen for on the message browser.
+ *
+ * @type {string[]}
+ */
+ observedAboutMessageEvents = ["load"];
+
+ /**
+ * Listeners we've added in tabs.
+ *
+ * @type {{tabId: any, target: EventTarget, event: string, callback: function}[]}
+ */
+ #listeners = [];
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.#addTabListeners();
+ this.onCommandContextChange();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ for (const listener of this.#listeners) {
+ listener.target.removeEventListener(listener.event, listener.callback);
+ }
+ this.#listeners.length = 0;
+ }
+
+ /**
+ * Callback for customizable-element when the current tab is switched while
+ * this button is visible.
+ */
+ onTabSwitched() {
+ this.#addTabListeners();
+ this.onCommandContextChange();
+ }
+
+ /**
+ * Callback for customizable-element when a tab is closed.
+ *
+ * @param {TabInfo} tab
+ */
+ onTabClosing(tab) {
+ this.#removeListenersForTab(tab.tabId);
+ }
+
+ /**
+ * Remove all event listeners this button has for a given tab.
+ *
+ * @param {*} tabId - ID of the tab to remove listeners for.
+ */
+ #removeListenersForTab(tabId) {
+ for (const listener of this.#listeners) {
+ if (listener.tabId === tabId) {
+ listener.target.removeEventListener(listener.event, listener.callback);
+ }
+ }
+ this.#listeners = this.#listeners.filter(
+ listener => listener.tabId !== tabId
+ );
+ }
+
+ /**
+ * Add missing event listeners for the current tab.
+ */
+ #addTabListeners() {
+ const tabmail = document.getElementById("tabmail");
+ const tabId = tabmail.currentTabInfo.tabId;
+ const existingListeners = this.#listeners.filter(
+ listener => listener.tabId === tabId
+ );
+ let expectedEventListeners = [];
+ switch (tabmail.currentTabInfo.mode.name) {
+ case "mail3PaneTab":
+ expectedEventListeners = this.observed3PaneEvents.concat(
+ this.observedAboutMessageEvents
+ );
+ break;
+ case "mailMessageTab":
+ expectedEventListeners = this.observedAboutMessageEvents.concat();
+ break;
+ }
+ const missingListeners = expectedEventListeners.filter(event =>
+ existingListeners.every(listener => listener.event !== event)
+ );
+ if (!missingListeners.length) {
+ return;
+ }
+ const contentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ for (const event of missingListeners) {
+ const listener = {
+ event,
+ tabId,
+ callback: this.#handle3PaneChange,
+ target: contentWindow,
+ };
+ if (
+ this.observedAboutMessageEvents.includes(event) &&
+ contentWindow.messageBrowser
+ ) {
+ listener.target = contentWindow.messageBrowser.contentWindow;
+ }
+ listener.target.addEventListener(listener.event, listener.callback);
+ this.#listeners.push(listener);
+ }
+ }
+
+ /**
+ * Event handling callback when an event by a tab is fired.
+ */
+ #handle3PaneChange = () => {
+ this.onCommandContextChange();
+ };
+
+ /**
+ * Handle the context changing, updating the disabled state for the button
+ * etc.
+ */
+ onCommandContextChange() {
+ if (!this.observedCommand) {
+ return;
+ }
+ try {
+ this.disabled = !getEnabledControllerForCommand(this.observedCommand);
+ } catch {
+ this.disabled = true;
+ }
+ }
+}
+customElements.define("mail-tab-button", MailTabButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/search-bar.mjs b/comm/mail/components/unifiedtoolbar/content/search-bar.mjs
new file mode 100644
index 0000000000..a450f7349f
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/search-bar.mjs
@@ -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/. */
+
+/**
+ * Search input with customizable search button and placeholder.
+ * Attributes:
+ * - label: Search field label for accessibility tree.
+ * - disabled: When present, disable the search field and button.
+ * Slots in template (#searchBarTemplate):
+ * - placeholder: Content displayed as placeholder. When not provided, the value
+ * of the label attribute is shown as placeholder.
+ * - button: Content displayed on the search button.
+ *
+ * @emits search: Event when a search should be executed. detail holds the
+ * search term.
+ * @emits autocomplte: Auto complete update. detail holds the current search
+ * term.
+ */
+export class SearchBar extends HTMLElement {
+ static get observedAttributes() {
+ return ["label", "disabled"];
+ }
+
+ /**
+ * Reference to the input field in the form.
+ *
+ * @type {?HTMLInputElement}
+ */
+ #input = null;
+
+ /**
+ * Reference to the search button in the form.
+ *
+ * @type {?HTMLButtonElement}
+ */
+ #button = null;
+
+ #onSubmit = event => {
+ event.preventDefault();
+ if (!this.#input.value) {
+ return;
+ }
+
+ const searchEvent = new CustomEvent("search", {
+ detail: this.#input.value,
+ cancelable: true,
+ });
+ if (this.dispatchEvent(searchEvent)) {
+ this.reset();
+ }
+ };
+
+ #onInput = () => {
+ const autocompleteEvent = new CustomEvent("autocomplete", {
+ detail: this.#input.value,
+ });
+ this.dispatchEvent(autocompleteEvent);
+ };
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+
+ const shadowRoot = this.attachShadow({ mode: "open" });
+
+ const template = document
+ .getElementById("searchBarTemplate")
+ .content.cloneNode(true);
+ this.#input = template.querySelector("input");
+ this.#button = template.querySelector("button");
+
+ template.querySelector("form").addEventListener("submit", this.#onSubmit, {
+ passive: false,
+ });
+
+ this.#input.setAttribute("aria-label", this.getAttribute("label"));
+ template.querySelector("slot[name=placeholder]").textContent =
+ this.getAttribute("label");
+ this.#input.addEventListener("input", this.#onInput);
+
+ const styles = document.createElement("link");
+ styles.setAttribute("rel", "stylesheet");
+ styles.setAttribute(
+ "href",
+ "chrome://messenger/skin/shared/search-bar.css"
+ );
+ shadowRoot.append(styles, template);
+ }
+
+ attributeChangedCallback(attributeName, oldValue, newValue) {
+ if (!this.#input) {
+ return;
+ }
+ switch (attributeName) {
+ case "label":
+ this.#input.setAttribute("aria-label", newValue);
+ this.shadowRoot.querySelector("slot[name=placeholder]").textContent =
+ newValue;
+ break;
+ case "disabled": {
+ const isDisabled = this.hasAttribute("disabled");
+ this.#input.disabled = isDisabled;
+ this.#button.disabled = isDisabled;
+ }
+ }
+ }
+
+ focus() {
+ this.#input.focus();
+ }
+
+ /**
+ * Reset the search bar to its empty state.
+ */
+ reset() {
+ this.#input.value = "";
+ }
+}
+customElements.define("search-bar", SearchBar);
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs
new file mode 100644
index 0000000000..466a83f0c1
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs
@@ -0,0 +1,240 @@
+/* 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/. */
+
+//TODO keyboard handling, keyboard + commands
+
+/* import-globals-from ../../../base/content/globalOverlay.js */
+
+/**
+ * Toolbar button implementation for the unified toolbar.
+ * Template ID: unifiedToolbarButtonTemplate
+ * Attributes:
+ * - command: ID string of the command to execute when the button is pressed.
+ * - observes: ID of command to observe for disabled state. Defaults to value of
+ * command attribute.
+ * - popup: ID of the popup to open when the button is pressed. The popup is
+ * anchored to the button. Overrides any other click handling.
+ * - disabled: When set the button is disabled.
+ * - title: Tooltip to show on the button.
+ * - label: Label text of the button. Observed for changes.
+ * - label-id: A fluent ID for the label instead of the label attribute.
+ * Observed for changes.
+ * - badge: When set, the value of the attribute is shown as badge.
+ * - aria-pressed: set to "false" to make the button behave like a toggle.
+ * Events:
+ * - buttondisabled: Fired when the button gets disabled while it is keyboard
+ * navigable.
+ * - buttonenabled: Fired when the button gets enabled again but isn't marked to
+ * be keyboard navigable.
+ */
+export class UnifiedToolbarButton extends HTMLButtonElement {
+ static get observedAttributes() {
+ return ["label", "label-id", "disabled"];
+ }
+
+ /**
+ * Container for the button label.
+ *
+ * @type {?HTMLSpanElement}
+ */
+ label = null;
+
+ /**
+ * Name of the command this button follows the disabled (and if it is a toggle
+ * button the checked) state of.
+ *
+ * @type {string?}
+ */
+ observedCommand;
+
+ /**
+ * The mutation observer observing the command this button follows the state
+ * of.
+ *
+ * @type {MutationObserver?}
+ */
+ #observer = null;
+
+ connectedCallback() {
+ // We remove the mutation overserver when the element is disconnected, thus
+ // we have to add it every time the element is connected.
+ this.observedCommand =
+ this.getAttribute("observes") || this.getAttribute("command");
+ if (this.observedCommand) {
+ const command = document.getElementById(this.observedCommand);
+ if (command) {
+ if (!this.#observer) {
+ this.#observer = new MutationObserver(this.#handleCommandMutation);
+ }
+ const observedAttributes = ["disabled"];
+ if (this.hasAttribute("aria-pressed")) {
+ observedAttributes.push("checked");
+
+ // Update the pressed state from the command
+ this.setAttribute(
+ "aria-pressed",
+ command.getAttribute("checked") ?? "false"
+ );
+ }
+ this.#observer.observe(command, {
+ attributes: true,
+ attributeFilter: observedAttributes,
+ });
+ }
+ // Update the disabled state to match the current state of the command.
+ try {
+ this.disabled = !getEnabledControllerForCommand(this.observedCommand);
+ } catch {
+ this.disabled = true;
+ }
+ }
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+ this.classList.add("unified-toolbar-button", "button");
+
+ const template = document
+ .getElementById("unifiedToolbarButtonTemplate")
+ .content.cloneNode(true);
+ this.label = template.querySelector("span");
+ this.#updateLabel();
+ this.appendChild(template);
+ this.addEventListener("click", event => this.handleClick(event));
+ }
+
+ disconnectedCallback() {
+ if (this.#observer) {
+ this.#observer.disconnect();
+ }
+ }
+
+ attributeChangedCallback(attribute) {
+ switch (attribute) {
+ case "label":
+ case "label-id":
+ this.#updateLabel();
+ break;
+ case "disabled":
+ if (!this.hasConnected) {
+ return;
+ }
+ if (this.disabled && this.tabIndex !== -1) {
+ this.tabIndex = -1;
+ this.dispatchEvent(new CustomEvent("buttondisabled"));
+ } else if (!this.disabled && this.tabIndex === -1) {
+ this.dispatchEvent(new CustomEvent("buttonenabled"));
+ }
+ break;
+ }
+ }
+
+ /**
+ * Default handling for clicks on the button. Shows the associated popup,
+ * executes the given command and toggles the button state.
+ *
+ * @param {MouseEvent} event - Click event.
+ */
+ handleClick(event) {
+ if (this.hasAttribute("popup")) {
+ event.preventDefault();
+ event.stopPropagation();
+ const popup = document.getElementById(this.getAttribute("popup"));
+ popup.openPopup(this, {
+ position: "after_start",
+ triggerEvent: event,
+ });
+ this.setAttribute("aria-pressed", "true");
+ const hideListener = () => {
+ if (popup.state === "open") {
+ return;
+ }
+ this.removeAttribute("aria-pressed");
+ popup.removeEventListener("popuphiding", hideListener);
+ };
+ popup.addEventListener("popuphiding", hideListener);
+ return;
+ }
+ if (this.hasAttribute("aria-pressed")) {
+ const isPressed = this.getAttribute("aria-pressed") === "true";
+ this.setAttribute("aria-pressed", (!isPressed).toString());
+ }
+ if (this.hasAttribute("command")) {
+ const command = this.getAttribute("command");
+ let controller = getEnabledControllerForCommand(command);
+ if (controller) {
+ event.preventDefault();
+ event.stopPropagation();
+ controller = controller.wrappedJSObject ?? controller;
+ controller.doCommand(command, event);
+ return;
+ }
+ const commandElement = document.getElementById(command);
+ if (!commandElement) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ commandElement.doCommand();
+ }
+ }
+
+ /**
+ * Callback for the mutation observer on the command this button follows.
+ *
+ * @param {Mutation[]} mutationList - List of mutations the observer saw.
+ */
+ #handleCommandMutation = mutationList => {
+ for (const mutation of mutationList) {
+ if (mutation.type !== "attributes") {
+ continue;
+ }
+ if (mutation.attributeName === "disabled") {
+ this.disabled = mutation.target.getAttribute("disabled") === "true";
+ } else if (mutation.attributeName === "checked") {
+ this.setAttribute(
+ "aria-pressed",
+ mutation.target.getAttribute("checked")
+ );
+ }
+ }
+ };
+
+ /**
+ * Update the contents of the label from the attributes of this element.
+ */
+ #updateLabel() {
+ if (!this.label) {
+ return;
+ }
+ if (this.hasAttribute("label")) {
+ this.label.textContent = this.getAttribute("label");
+ return;
+ }
+ if (this.hasAttribute("label-id")) {
+ document.l10n.setAttributes(this.label, this.getAttribute("label-id"));
+ }
+ }
+
+ /**
+ * Badge displayed on the button. To clear the badge, set to empty string or
+ * nullish value.
+ *
+ * @type {string}
+ */
+ set badge(badgeText) {
+ if (badgeText === "" || badgeText == null) {
+ this.removeAttribute("badge");
+ return;
+ }
+ this.setAttribute("badge", badgeText);
+ }
+
+ get badge() {
+ return this.getAttribute("badge");
+ }
+}
+customElements.define("unified-toolbar-button", UnifiedToolbarButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs
new file mode 100644
index 0000000000..a43b7c6005
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs
@@ -0,0 +1,264 @@
+/* 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 "./search-bar.mjs"; // eslint-disable-line import/no-unassigned-import
+import "./customization-palette.mjs"; // eslint-disable-line import/no-unassigned-import
+import "./customization-target.mjs"; // eslint-disable-line import/no-unassigned-import
+import {
+ BUTTON_STYLE_MAP,
+ BUTTON_STYLE_PREF,
+} from "resource:///modules/ButtonStyle.mjs";
+
+const { getDefaultItemIdsForSpace } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+
+/**
+ * Template ID: unifiedToolbarCustomizationPaneTemplate
+ * Attributes:
+ * - space: Identifier of the space this pane is for. Changes are not observed.
+ * - current-items: Currently used items in this space.
+ * - builtin-space: Boolean indicating if the space is a built in space (true) or an
+ * extension provided space (false).
+ */
+class UnifiedToolbarCustomizationPane extends HTMLElement {
+ /**
+ * Reference to the customization target for the main toolbar area.
+ *
+ * @type {CustomizationTarget?}
+ */
+ #toolbarTarget = null;
+
+ /**
+ * Reference to the title of the space specific palette.
+ *
+ * @type {?HTMLHeadingElement}
+ */
+ #spaceSpecificTitle = null;
+
+ /**
+ * Reference to the palette for items only available in the current space.
+ *
+ * @type {?CustomizationPalette}
+ */
+ #spaceSpecificPalette = null;
+
+ /**
+ * Reference to the palette for items available in all spaces.
+ *
+ * @type {?CustomizationPalette}
+ */
+ #genericPalette = null;
+
+ /**
+ * List of the item IDs that are in the toolbar by default in this area.
+ *
+ * @type {string[]}
+ */
+ #defaultItemIds = [];
+
+ /**
+ * The search bar used to filter the items in the palettes.
+ *
+ * @type {?SearchBar}
+ */
+ #searchBar = null;
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ document.l10n.connectRoot(this.shadowRoot);
+ return;
+ }
+ this.setAttribute("role", "tabpanel");
+ const shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+
+ const space = this.getAttribute("space");
+
+ const template = document
+ .getElementById("unifiedToolbarCustomizationPaneTemplate")
+ .content.cloneNode(true);
+ const styles = document.createElement("link");
+ styles.setAttribute("rel", "stylesheet");
+ styles.setAttribute(
+ "href",
+ "chrome://messenger/skin/shared/unifiedToolbarCustomizationPane.css"
+ );
+
+ this.#toolbarTarget = template.querySelector(".toolbar-target");
+ this.#toolbarTarget.setAttribute("space", space);
+
+ this.#spaceSpecificTitle = template.querySelector(".space-specific-title");
+ document.l10n.setAttributes(
+ this.#spaceSpecificTitle,
+ this.hasAttribute("builtin-space")
+ ? `customize-palette-${space}-specific-title`
+ : "customize-palette-extension-specific-title"
+ );
+ this.#spaceSpecificTitle.id = `${space}PaletteTitle`;
+ this.#spaceSpecificPalette = template.querySelector(
+ ".space-specific-palette"
+ );
+ this.#spaceSpecificPalette.id = `${space}Palette`;
+ this.#spaceSpecificPalette.setAttribute(
+ "aria-labelledby",
+ this.#spaceSpecificTitle.id
+ );
+ this.#spaceSpecificPalette.setAttribute("space", space);
+ const genericTitle = template.querySelector(".generic-palette-title");
+ genericTitle.id = `${space}GenericPaletteTitle`;
+ this.#genericPalette = template.querySelector(".generic-palette");
+ this.#genericPalette.id = `${space}GenericPalette`;
+ this.#genericPalette.setAttribute("aria-labelledby", genericTitle.id);
+
+ this.#searchBar = template.querySelector("search-bar");
+ this.#searchBar.addEventListener("search", this.#handleSearch);
+ this.#searchBar.addEventListener("autocomplete", this.#handleFilter);
+
+ this.initialize();
+
+ shadowRoot.append(styles, template);
+
+ this.addEventListener("dragover", this.#handleDragover);
+ }
+
+ disconnectedCallback() {
+ document.l10n.disconnectRoot(this.shadowRoot);
+ }
+
+ #handleFilter = event => {
+ this.#spaceSpecificPalette.filterItems(event.detail);
+ this.#genericPalette.filterItems(event.detail);
+ };
+
+ #handleSearch = event => {
+ // Don't clear the search bar.
+ event.preventDefault();
+ };
+
+ /**
+ * Default handler to indicate nothing can be dropped in the customization,
+ * except for the dragging and dropping in the palettes and targets.
+ *
+ * @param {DragEvent} event - Drag over event.
+ */
+ #handleDragover = event => {
+ event.dataTransfer.dropEffect = "none";
+ event.preventDefault();
+ };
+
+ /**
+ * Initialize the contents of this element from the state. The relevant state
+ * for this element are the items currently in the toolbar for this space.
+ *
+ * @param {boolean} [deep = false] - If true calls initialize on all the
+ * targets and palettes.
+ */
+ initialize(deep = false) {
+ const space = this.getAttribute("space");
+ this.#defaultItemIds = getDefaultItemIdsForSpace(space);
+ const currentItems = this.hasAttribute("current-items")
+ ? this.getAttribute("current-items")
+ : this.#defaultItemIds.join(",");
+ this.#toolbarTarget.setAttribute("current-items", currentItems);
+ this.#spaceSpecificPalette.setAttribute("items-in-use", currentItems);
+ this.#genericPalette.setAttribute("items-in-use", currentItems);
+
+ if (deep) {
+ this.#searchBar.reset();
+ this.#toolbarTarget.initialize();
+ this.#spaceSpecificPalette.initialize();
+ this.#genericPalette.initialize();
+ this.#spaceSpecificTitle.hidden = this.#spaceSpecificPalette.isEmpty;
+ this.#spaceSpecificPalette.hidden = this.#spaceSpecificPalette.isEmpty;
+ }
+
+ this.updateButtonStyle(
+ BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)]
+ );
+ }
+
+ /**
+ * Reset the items in the targets to the defaults.
+ */
+ reset() {
+ this.#toolbarTarget.setItems(this.#defaultItemIds);
+ this.#spaceSpecificPalette.setItems(this.#defaultItemIds);
+ this.#genericPalette.setItems(this.#defaultItemIds);
+ }
+
+ /**
+ * Add an item to the default target in this space. Can only add items that
+ * are available in all spaces.
+ *
+ * @param {string} itemId - Item ID of the item to add to the default target.
+ */
+ addItem(itemId) {
+ this.#genericPalette.addItemById(itemId);
+ }
+
+ /**
+ * Remove an item from all targets in this space.
+ *
+ * @param {string} itemId - Item ID of the item to remove from this pane's
+ * targets.
+ */
+ removeItem(itemId) {
+ this.#toolbarTarget.removeItemById(itemId);
+ }
+
+ /**
+ * Check if an item is currently in a target in this pane.
+ *
+ * @param {string} itemId - Item ID of the item to check for.
+ * @returns {boolean} If the item is currently used in this pane.
+ */
+ hasItem(itemId) {
+ return Boolean(this.#toolbarTarget.hasItem(itemId));
+ }
+
+ /**
+ * If the customization state of this space matches its default state.
+ *
+ * @type {boolean}
+ */
+ get matchesDefaultState() {
+ const itemsInToolbar = this.#toolbarTarget.itemIds;
+ return itemsInToolbar.join(",") === this.#defaultItemIds.join(",");
+ }
+
+ /**
+ * If the customization state of this space matches the currently saved
+ * configuration.
+ *
+ * @type {boolean}
+ */
+ get hasChanges() {
+ return this.#toolbarTarget.hasChanges;
+ }
+
+ /**
+ * Current customization state for this space.
+ *
+ * @type {string[]}
+ */
+ get itemIds() {
+ return this.#toolbarTarget.itemIds;
+ }
+
+ /**
+ * Update the class of the toolbar preview to reflect the selected button
+ * style.
+ *
+ * @param {string} value - The class to apply.
+ */
+ updateButtonStyle(value) {
+ this.#toolbarTarget.classList.remove(...BUTTON_STYLE_MAP);
+ this.#toolbarTarget.classList.add(value);
+ }
+}
+customElements.define(
+ "unified-toolbar-customization-pane",
+ UnifiedToolbarCustomizationPane
+);
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs
new file mode 100644
index 0000000000..1acdf85b57
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs
@@ -0,0 +1,414 @@
+/* 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 ../../../base/content/spacesToolbar.js */
+/* import-globals-from ../../../base/content/utilityOverlay.js */
+
+import {
+ storeState,
+ getState,
+} from "resource:///modules/CustomizationState.mjs";
+import "./unified-toolbar-tab.mjs"; // eslint-disable-line import/no-unassigned-import
+import "./unified-toolbar-customization-pane.mjs"; // eslint-disable-line import/no-unassigned-import
+import {
+ BUTTON_STYLE_MAP,
+ BUTTON_STYLE_PREF,
+} from "resource:///modules/ButtonStyle.mjs";
+
+/**
+ * Set of names of the built in spaces.
+ *
+ * @type {Set<string>}
+ */
+const BUILTIN_SPACES = new Set([
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+]);
+
+/**
+ * Customization palette container for the unified toolbar. Contained in a
+ * custom element for state management. When visible, the document should have
+ * the customizingUnifiedToolbar class.
+ * Template: #unifiedToolbarCustomizationTemplate.
+ */
+class UnifiedToolbarCustomization extends HTMLElement {
+ /**
+ * Reference to the container where the space tabs go in. The tab panels will
+ * be placed after this element.
+ *
+ * @type {?HTMLDivElement}
+ */
+ #tabList = null;
+
+ #buttonStyle = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ const template = document
+ .getElementById("unifiedToolbarCustomizationTemplate")
+ .content.cloneNode(true);
+ const form = template.querySelector("form");
+ form.addEventListener(
+ "submit",
+ event => {
+ event.preventDefault();
+ this.#save();
+ },
+ {
+ passive: false,
+ }
+ );
+ form.addEventListener("reset", event => {
+ this.#reset();
+ });
+ template
+ .querySelector("#unifiedToolbarCustomizationCancel")
+ .addEventListener("click", () => {
+ this.toggle(false);
+ });
+ this.#buttonStyle = template.querySelector("#buttonStyle");
+ this.#buttonStyle.addEventListener("change", this.#handleButtonStyleChange);
+ this.addEventListener("itemchange", this.#handleItemChange, {
+ capture: true,
+ });
+ this.addEventListener("additem", this.#handleAddItem, {
+ capture: true,
+ });
+ this.addEventListener("removeitem", this.#handleRemoveItem, {
+ capture: true,
+ });
+ this.#tabList = template.querySelector("#customizationTabs");
+ this.#tabList.addEventListener("tabswitch", this.#handleTabSwitch, {
+ capture: true,
+ });
+ template
+ .querySelector("#customizationToSettingsButton")
+ .addEventListener("click", this.#handleSettingsButton);
+ this.initialize();
+ this.append(template);
+ this.#updateResetToDefault();
+ this.addEventListener("keyup", this.#handleKeyboard);
+ this.addEventListener("keyup", this.#closeByKeyboard);
+ this.addEventListener("keypress", this.#handleKeyboard);
+ this.addEventListener("keydown", this.#handleKeyboard);
+ }
+
+ #handleItemChange = event => {
+ event.stopPropagation();
+ this.#updateResetToDefault();
+ this.#updateUnsavedChangesState();
+ };
+
+ #handleTabSwitch = event => {
+ event.stopPropagation();
+ this.#updateUnsavedChangesState();
+ };
+
+ #handleButtonStyleChange = event => {
+ for (const pane of this.querySelectorAll(
+ "unified-toolbar-customization-pane"
+ )) {
+ pane.updateButtonStyle(event.target.value);
+ }
+ this.#updateUnsavedChangesState();
+ };
+
+ #handleSettingsButton = event => {
+ event.preventDefault();
+ openPreferencesTab("paneGeneral", "layoutGroup");
+ this.toggle(false);
+ };
+
+ #handleAddItem = event => {
+ event.stopPropagation();
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ for (const pane of tabPanes) {
+ pane.addItem(event.detail.itemId);
+ }
+ };
+
+ #handleRemoveItem = event => {
+ event.stopPropagation();
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ for (const pane of tabPanes) {
+ pane.removeItem(event.detail.itemId);
+ }
+ };
+
+ /**
+ * Close the customisation pane when Escape is released
+ *
+ * @param {KeyboardEvent} event - The keyboard event
+ */
+ #closeByKeyboard = event => {
+ if (event.key == "Escape") {
+ event.preventDefault();
+ this.toggle(false);
+ }
+ };
+
+ /**
+ * Ensure keyboard events are not propagated outside the customization dialog.
+ *
+ * @param {KeyboardEvent} event - The keyboard event.
+ */
+ #handleKeyboard = event => {
+ event.stopPropagation();
+ };
+
+ /**
+ * Update state of reset to default button.
+ */
+ #updateResetToDefault() {
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ const isDefault = tabPanes.every(pane => pane.matchesDefaultState);
+ this.querySelector('button[type="reset"]').disabled = isDefault;
+ }
+
+ #updateUnsavedChangesState() {
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ const unsavedChanges =
+ tabPanes.some(tabPane => tabPane.hasChanges) ||
+ this.#buttonStyle.value !=
+ BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)];
+ const otherSpacesHaveUnsavedChanges =
+ unsavedChanges &&
+ tabPanes.some(tabPane => tabPane.hidden && tabPane.hasChanges);
+ this.querySelector('button[type="submit"]').disabled = !unsavedChanges;
+ document.getElementById(
+ "unifiedToolbarCustomizationUnsavedChanges"
+ ).hidden = !otherSpacesHaveUnsavedChanges;
+ }
+
+ /**
+ * Generate a tab and tab pane that are linked together for the given space.
+ * If the space is the current space, the tab is marked as active.
+ *
+ * @param {SpaceInfo} space
+ * @returns {{tab: UnifiedToolbarTab, tabPane: UnifiedToolbarCustomizationPane}}
+ */
+ #makeSpaceTab(space) {
+ const activeSpace = space === gSpacesToolbar.currentSpace;
+ const tabId = `unified-toolbar-customization-tab-${space.name}`;
+ const paneId = `unified-toolbar-customization-pane-${space.name}`;
+ const tab = document.createElement("unified-toolbar-tab");
+ tab.id = tabId;
+ tab.setAttribute("aria-controls", paneId);
+ if (activeSpace) {
+ tab.setAttribute("selected", true);
+ }
+ const isBuiltinSpace = BUILTIN_SPACES.has(space.name);
+ if (isBuiltinSpace) {
+ document.l10n.setAttributes(tab, `customize-space-tab-${space.name}`);
+ } else {
+ const title = space.button.title;
+ tab.textContent = title;
+ tab.title = title;
+ tab.style = space.button.querySelector("img").style.cssText;
+ }
+ const tabPane = document.createElement(
+ "unified-toolbar-customization-pane"
+ );
+ tabPane.id = paneId;
+ tabPane.setAttribute("space", space.name);
+ tabPane.setAttribute("aria-labelledby", tabId);
+ tabPane.toggleAttribute("builtin-space", isBuiltinSpace);
+ tabPane.hidden = !activeSpace;
+ return { tab, tabPane };
+ }
+
+ /**
+ * Reset all the spaces to their default customization state.
+ */
+ #reset() {
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ for (const pane of tabPanes) {
+ pane.reset();
+ }
+ }
+
+ /**
+ * Save the current state of the toolbar and hide the customization.
+ */
+ #save() {
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ const state = Object.fromEntries(
+ tabPanes
+ .filter(pane => !pane.matchesDefaultState)
+ .map(pane => [pane.getAttribute("space"), pane.itemIds])
+ );
+ Services.prefs.setIntPref(
+ BUTTON_STYLE_PREF,
+ BUTTON_STYLE_MAP.indexOf(this.#buttonStyle.value)
+ );
+ // Toggle happens before saving, so the newly restored buttons don't have to
+ // be updated when the globalOverlay flag on tabmail goes away.
+ this.toggle(false);
+ storeState(state);
+ }
+
+ /**
+ * Initialize the contents of this from the current state. Specifically makes
+ * sure all the spaces have a tab, and all tabs still have a space.
+ *
+ * @param {boolean} [deep = false] - If true calls initialize on all tab
+ * panes.
+ */
+ initialize(deep = false) {
+ const state = getState();
+ const existingTabs = Array.from(this.#tabList.children);
+ const tabSpaces = existingTabs.map(tab => tab.id.split("-").pop());
+ const spaceNames = new Set(gSpacesToolbar.spaces.map(space => space.name));
+ const removedTabs = existingTabs.filter(
+ (tab, index) => !spaceNames.has(tabSpaces[index])
+ );
+ for (const tab of removedTabs) {
+ tab.pane.remove();
+ tab.remove();
+ }
+ const newTabs = gSpacesToolbar.spaces.map(space => {
+ if (tabSpaces.includes(space.name)) {
+ const tab = existingTabs[tabSpaces.indexOf(space.name)];
+ if (!BUILTIN_SPACES.has(space.name)) {
+ const title = space.button.title;
+ tab.textContent = title;
+ tab.title = title;
+ tab.style = space.button.querySelector("img").style.cssText;
+ }
+ return [tab, tab.pane];
+ }
+ const { tab, tabPane } = this.#makeSpaceTab(space);
+ return [tab, tabPane];
+ });
+ this.#tabList.replaceChildren(...newTabs.map(([tab]) => tab));
+ let previousNode = this.#tabList;
+ for (const [, tabPane] of newTabs) {
+ previousNode.after(tabPane);
+ const space = tabPane.getAttribute("space");
+ if (state.hasOwnProperty(space)) {
+ tabPane.setAttribute("current-items", state[space].join(","));
+ } else {
+ tabPane.removeAttribute("current-items");
+ }
+ previousNode = tabPane;
+ if (deep) {
+ tabPane.initialize(deep);
+ }
+ }
+ this.#buttonStyle.value =
+ BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)];
+ // Update state of reset to default button only when updating tab panes too.
+ if (deep) {
+ this.#updateResetToDefault();
+ this.#updateUnsavedChangesState();
+ }
+ }
+
+ /**
+ * Toggle unified toolbar customization.
+ *
+ * @param {boolean} [visible] - If passed, defines if customization should
+ * be active.
+ */
+ toggle(visible) {
+ if (visible) {
+ this.initialize(true);
+ let tabToSelect;
+ if (gSpacesToolbar.currentSpace) {
+ tabToSelect = document.getElementById(
+ `unified-toolbar-customization-tab-${gSpacesToolbar.currentSpace.name}`
+ );
+ }
+ if (
+ !tabToSelect &&
+ !this.querySelector(`unified-toolbar-tab[selected="true"]`)
+ ) {
+ tabToSelect = this.querySelector("unified-toolbar-tab");
+ }
+ if (tabToSelect) {
+ tabToSelect.select();
+ }
+ }
+
+ document.getElementById("tabmail").globalOverlay = visible;
+ document.documentElement.classList.toggle(
+ "customizingUnifiedToolbar",
+ visible
+ );
+
+ // Make sure focus is where it belongs.
+ if (visible) {
+ if (
+ document.activeElement !== this &&
+ !this.contains(document.activeElement)
+ ) {
+ Services.focus.moveFocus(
+ window,
+ this,
+ Services.focus.MOVEFOCUS_FIRST,
+ 0
+ );
+ }
+ } else {
+ Services.focus.moveFocus(
+ window,
+ document.body,
+ Services.focus.MOVEFOCUS_ROOT,
+ 0
+ );
+ }
+ }
+
+ /**
+ * Check if an item is active in all spaces.
+ *
+ * @param {string} itemId - Item ID of the item to check for.
+ * @returns {boolean} If the given item is found active in all spaces.
+ */
+ activeInAllSpaces(itemId) {
+ return Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane"),
+ pane => pane.hasItem(itemId)
+ ).every(hasItem => hasItem);
+ }
+
+ /**
+ * Check if an item is active in two or more spaces.
+ *
+ * @param {string} itemId - Item ID of the item to check for.
+ * @returns {boolean} If the given item is active in at least two spaces.
+ */
+ activeInMultipleSpaces(itemId) {
+ return (
+ Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane"),
+ pane => pane.hasItem(itemId)
+ ).filter(Boolean).length > 1
+ );
+ }
+}
+customElements.define(
+ "unified-toolbar-customization",
+ UnifiedToolbarCustomization
+);
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs
new file mode 100644
index 0000000000..134aec6cf1
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs
@@ -0,0 +1,119 @@
+/* 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/. */
+
+/**
+ * Template ID: unifiedToolbarTabTemplate
+ * Attributes:
+ * - selected: If the tab is active.
+ * - aria-controls: The ID of the tab pane this controls.
+ * Events:
+ * - tabswitch: When the active tab is changed.
+ */
+class UnifiedToolbarTab extends HTMLElement {
+ /**
+ * @type {?HTMLButtonElement}
+ */
+ #tab = null;
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ this.setAttribute("role", "presentation");
+ const shadowRoot = this.attachShadow({ mode: "open" });
+
+ const template = document
+ .getElementById("unifiedToolbarTabTemplate")
+ .content.cloneNode(true);
+ this.#tab = template.querySelector("button");
+ this.#tab.tabIndex = this.hasAttribute("selected") ? 0 : -1;
+ if (this.hasAttribute("selected")) {
+ this.#tab.setAttribute("aria-selected", "true");
+ }
+ this.#tab.setAttribute("aria-controls", this.getAttribute("aria-controls"));
+ this.removeAttribute("aria-controls");
+
+ const styles = document.createElement("link");
+ styles.setAttribute("rel", "stylesheet");
+ styles.setAttribute(
+ "href",
+ "chrome://messenger/skin/shared/unifiedToolbarTab.css"
+ );
+
+ shadowRoot.append(styles, template);
+
+ this.#tab.addEventListener("click", () => {
+ this.select();
+ });
+ this.#tab.addEventListener("keydown", this.#handleKey);
+ }
+
+ #handleKey = event => {
+ const rightIsForward = document.dir === "ltr";
+ const rightSibling =
+ (rightIsForward ? "next" : "previous") + "ElementSibling";
+ const leftSibling =
+ (rightIsForward ? "previous" : "next") + "ElementSibling";
+ switch (event.key) {
+ case "ArrowLeft":
+ this[leftSibling]?.focus();
+ break;
+ case "ArrowRight":
+ this[rightSibling]?.focus();
+ break;
+ case "Home":
+ this.parentNode.firstElementChild?.focus();
+ break;
+ case "End":
+ this.parentNode.lastElementChild?.focus();
+ break;
+ default:
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ #toggleTabPane(visible) {
+ this.pane.hidden = !visible;
+ }
+
+ /**
+ * Select this tab. Deselects the previously selected tab and shows the tab
+ * pane for this tab.
+ */
+ select() {
+ this.parentElement
+ .querySelector("unified-toolbar-tab[selected]")
+ ?.unselect();
+ this.#tab.setAttribute("aria-selected", "true");
+ this.#tab.tabIndex = 0;
+ this.setAttribute("selected", true);
+ this.#toggleTabPane(true);
+ const tabSwitchEvent = new Event("tabswitch", {
+ bubbles: true,
+ });
+ this.dispatchEvent(tabSwitchEvent);
+ }
+
+ /**
+ * Remove the selection for this tab and hide the associated tab pane.
+ */
+ unselect() {
+ this.#tab.removeAttribute("aria-selected");
+ this.#tab.tabIndex = -1;
+ this.removeAttribute("selected");
+ this.#toggleTabPane(false);
+ }
+
+ focus() {
+ this.#tab.focus();
+ }
+
+ get pane() {
+ return document.getElementById(this.#tab.getAttribute("aria-controls"));
+ }
+}
+customElements.define("unified-toolbar-tab", UnifiedToolbarTab);
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs
new file mode 100644
index 0000000000..e8624750af
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs
@@ -0,0 +1,540 @@
+/* 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 gSpacesToolbar, ToolbarContextMenu */
+
+import { getState } from "resource:///modules/CustomizationState.mjs";
+import {
+ BUTTON_STYLE_MAP,
+ BUTTON_STYLE_PREF,
+} from "resource:///modules/ButtonStyle.mjs";
+import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ getDefaultItemIdsForSpace: "resource:///modules/CustomizableItems.sys.mjs",
+ getAvailableItemIdsForSpace: "resource:///modules/CustomizableItems.sys.mjs",
+ SKIP_FOCUS_ITEM_IDS: "resource:///modules/CustomizableItems.sys.mjs",
+});
+
+/**
+ * Unified toolbar container custom element. Used to contain the state
+ * management and interaction logic. Template: #unifiedToolbarTemplate.
+ * Requires unifiedToolbarPopups.inc.xhtml to be in a popupset of the same
+ * document.
+ */
+class UnifiedToolbar extends HTMLElement {
+ constructor() {
+ super();
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "buttonStyle",
+ BUTTON_STYLE_PREF,
+ 0,
+ (preference, prevVal, newVal) => {
+ if (preference !== BUTTON_STYLE_PREF) {
+ return;
+ }
+ this.classList.remove(prevVal);
+ this.classList.add(newVal);
+ },
+ value => BUTTON_STYLE_MAP[value]
+ );
+ }
+
+ /**
+ * List containing the customizable content of the unified toolbar.
+ *
+ * @type {?HTMLUListElement}
+ */
+ #toolbarContent = null;
+
+ /**
+ * The current customization state of the unified toolbar.
+ *
+ * @type {?UnifiedToolbarCustomizationState}
+ */
+ #state = null;
+
+ /**
+ * Arrays of item IDs available in a given space.
+ *
+ * @type {object}
+ */
+ #itemsAvailableInSpace = {};
+
+ /**
+ * Observer triggered when the state for the unified toolbar is changed.
+ *
+ * @type {nsIObserver}
+ */
+ #stateObserver = {
+ observe: (subject, topic) => {
+ if (topic === "unified-toolbar-state-change") {
+ this.initialize();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ /**
+ * A MozTabmail tab monitor to listen for tab switch and close events. Calls
+ * onTabSwitched on currently visible toolbar content and onTabClosing on
+ * all toolbar content.
+ *
+ * @type {object}
+ */
+ #tabMonitor = {
+ monitorName: "UnifiedToolbar",
+ onTabTitleChanged() {},
+ onTabSwitched: (tab, oldTab) => {
+ for (const element of this.#toolbarContent.children) {
+ if (!element.hidden) {
+ element.onTabSwitched(tab, oldTab);
+ }
+ }
+ },
+ onTabOpened() {},
+ onTabClosing: tab => {
+ for (const element of this.#toolbarContent.children) {
+ element.onTabClosing(tab);
+ }
+ },
+ onTabPersist() {},
+ onTabRestored() {},
+ };
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ // No shadow root so other stylesheets can style the contents of the
+ // toolbar, like the window controls.
+ this.hasConnected = true;
+ this.classList.add(this.buttonStyle);
+ const template = document
+ .getElementById("unifiedToolbarTemplate")
+ .content.cloneNode(true);
+
+ // TODO Don't show context menu when there is a native one, like for example
+ // in a search field.
+ template
+ .querySelector("#unifiedToolbarContainer")
+ .addEventListener("contextmenu", this.#handleContextMenu);
+ this.#toolbarContent = template.querySelector("#unifiedToolbarContent");
+
+ this.#toolbarContent.addEventListener("keydown", this.#handleKey, {
+ capture: true,
+ });
+ this.#toolbarContent.addEventListener(
+ "buttondisabled",
+ this.#handleButtonDisabled,
+ { capture: true }
+ );
+ this.#toolbarContent.addEventListener(
+ "buttonenabled",
+ this.#handleButtonEnabled,
+ { capture: true }
+ );
+
+ if (gSpacesToolbar.isLoaded) {
+ this.initialize();
+ } else {
+ window.addEventListener("spaces-toolbar-ready", () => this.initialize(), {
+ once: true,
+ });
+ document
+ .getElementById("cmd_CustomizeMailToolbar")
+ .setAttribute("disabled", true);
+ }
+
+ this.append(template);
+
+ document
+ .getElementById("unifiedToolbarCustomize")
+ .addEventListener("command", this.#handleCustomizeCommand);
+
+ document
+ .getElementById("menuBarToggleVisible")
+ .addEventListener("command", this.#handleMenuBarCommand);
+
+ document
+ .getElementById("spacesToolbar")
+ .addEventListener("spacechange", this.#handleSpaceChange);
+
+ Services.obs.addObserver(
+ this.#stateObserver,
+ "unified-toolbar-state-change",
+ true
+ );
+
+ if (document.readyState === "complete") {
+ document.getElementById("tabmail").registerTabMonitor(this.#tabMonitor);
+ return;
+ }
+ window.addEventListener(
+ "load",
+ () => {
+ document.getElementById("tabmail").registerTabMonitor(this.#tabMonitor);
+ },
+ { once: true }
+ );
+ }
+
+ disconnectedCallback() {
+ Services.obs.removeObserver(
+ this.#stateObserver,
+ "unified-toolbar-state-change"
+ );
+
+ document
+ .getElementById("unifiedToolbarCustomize")
+ .removeEventListener("command", this.#handleCustomizeCommand);
+
+ document
+ .getElementById("spacesToolbar")
+ .removeEventListener("spacechange", this.#handleSpaceChange);
+
+ document.getElementById("tabmail").unregisterTabMonitor(this.#tabMonitor);
+ }
+
+ #handleContextMenu = event => {
+ if (!event.target.closest("#unifiedToolbarContent")) {
+ return;
+ }
+ const customizableElement = event.target.closest(
+ '[is="customizable-element"]'
+ );
+ if (customizableElement?.hasContextMenu) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ const popup = document.getElementById("unifiedToolbarMenu");
+
+ // If not Mac OS, set checked attribute for menu item, otherwise remove item.
+ const menuBarMenuItem = document.getElementById("menuBarToggleVisible");
+ if (AppConstants.platform != "macosx") {
+ const menubarToolbar = document.getElementById("toolbar-menubar");
+ menuBarMenuItem.setAttribute(
+ "checked",
+ menubarToolbar.getAttribute("autohide") != "true"
+ );
+ } else if (menuBarMenuItem) {
+ menuBarMenuItem.remove();
+ // Remove the menubar separator as well.
+ const menuBarSeparator = document.getElementById(
+ "menuBarToggleMenuSeparator"
+ );
+ menuBarSeparator.remove();
+ }
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true, event);
+ if (gSpacesToolbar.isLoaded) {
+ document
+ .getElementById("unifiedToolbarCustomize")
+ .removeAttribute("disabled");
+ } else {
+ document
+ .getElementById("unifiedToolbarCustomize")
+ .setAttribute("disabled", true);
+ }
+ ToolbarContextMenu.updateExtension(popup);
+ };
+
+ #handleCustomizeCommand = () => {
+ this.showCustomization();
+ };
+
+ #handleMenuBarCommand = () => {
+ const menubarToolbar = document.getElementById("toolbar-menubar");
+ const menuItem = document.getElementById("menuBarToggleVisible");
+
+ if (menubarToolbar.getAttribute("autohide") != "true") {
+ menubarToolbar.setAttribute("autohide", "true");
+ menuItem.removeAttribute("checked");
+ } else {
+ menuItem.setAttribute("checked", true);
+ menubarToolbar.removeAttribute("autohide");
+ }
+ Services.xulStore.persist(menubarToolbar, "autohide");
+ };
+
+ #handleSpaceChange = event => {
+ // Switch to the current space or show a generic default state toolbar.
+ this.#showToolbarForSpace(event.detail?.name ?? "default");
+ };
+
+ #handleKey = event => {
+ // Don't handle any key events within menupopups that are children of the
+ // toolbar contents.
+ if (event.target.closest("menupopup")) {
+ return;
+ }
+ switch (event.key) {
+ case "ArrowLeft":
+ case "ArrowRight": {
+ event.preventDefault();
+ event.stopPropagation();
+ const rightIsForward = document.dir !== "rtl";
+ //TODO groups split by search bar.
+ const focusableChildren = Array.from(
+ this.querySelectorAll(
+ `li[is="customizable-element"]:not([disabled], .skip-focus)`
+ )
+ ).filter(
+ element => !element.querySelector(".live-content button[disabled]")
+ );
+ if (!focusableChildren.length) {
+ return;
+ }
+ const activeItem = document.activeElement.closest(
+ 'li[is="customizable-element"]'
+ );
+ const activeIndex = focusableChildren.indexOf(activeItem);
+ if (activeIndex === -1) {
+ return;
+ }
+ if (!activeItem) {
+ focusableChildren[0].focus();
+ return;
+ }
+ const isForward = rightIsForward === (event.key === "ArrowRight");
+ const delta = isForward ? 1 : -1;
+ const focusableSibling = focusableChildren.at(activeIndex + delta);
+ if (focusableSibling) {
+ focusableSibling.tabIndex = 0;
+ focusableSibling.focus();
+ } else if (isForward) {
+ focusableChildren[0].tabIndex = 0;
+ focusableChildren[0].focus();
+ } else {
+ focusableChildren.at(-1).tabIndex = 0;
+ focusableChildren.at(-1).focus();
+ }
+ activeItem.tabIndex = -1;
+ }
+ }
+ };
+
+ #handleButtonDisabled = () => {
+ if (
+ this.#toolbarContent.querySelector(
+ 'li[is="customizable-element"]:not(.skip-focus) .live-content button[tabindex="0"]'
+ )
+ ) {
+ return;
+ }
+ const newItem = this.#toolbarContent
+ .querySelector(
+ 'li[is="customizable-element"]:not([disabled], .skip-focus) .live-content button:not([disabled])'
+ )
+ ?.closest('li[is="customizable-element"]');
+ if (newItem) {
+ newItem.tabIndex = 0;
+ }
+ };
+
+ #handleButtonEnabled = event => {
+ if (
+ this.#toolbarContent.querySelector(
+ 'li[is="customizable-element"]:not(.skip-focus) .live-content button[tabindex="0"]'
+ )
+ ) {
+ return;
+ }
+ // If there is currently no focusable button, make the button triggering the
+ // event available.
+ const newItem = event.target.closest('li[is="customizable-element"]');
+ if (newItem) {
+ newItem.tabIndex = 0;
+ }
+ };
+
+ /**
+ * Make sure the customization for unified toolbar is injected into the
+ * document.
+ *
+ * @returns {Promise<void>}
+ */
+ async #ensureCustomizationInserted() {
+ if (document.querySelector("unified-toolbar-customization")) {
+ return;
+ }
+ await import("./unified-toolbar-customization.mjs");
+ const customization = document.createElement(
+ "unified-toolbar-customization"
+ );
+ document.body.appendChild(customization);
+ }
+
+ /**
+ * Get the items currently visible in a given space. Filters out items that
+ * are part of the state but not visible.
+ *
+ * @param {string} space - Name of the space to get the active items for. May
+ * be "default" to indicate a generic default item set should be produced.
+ * @returns {string[]} Array of item IDs visible in the given space.
+ */
+ #getItemsForSpace(space) {
+ if (!this.#state[space]) {
+ this.#state[space] = lazy.getDefaultItemIdsForSpace(space);
+ }
+ if (!this.#itemsAvailableInSpace[space]) {
+ this.#itemsAvailableInSpace[space] = new Set(
+ lazy.getAvailableItemIdsForSpace(space, true)
+ );
+ }
+ return this.#state[space].filter(itemId =>
+ this.#itemsAvailableInSpace[space].has(itemId)
+ );
+ }
+
+ /**
+ * Show the items for the specified space in the toolbar. Only creates
+ * missing elements when not already created for another space.
+ *
+ * @param {string} space - Name of the space to make visible. May be "default"
+ * to indicate that a generic default state should be shown instead.
+ */
+ #showToolbarForSpace(space) {
+ if (!this.#state) {
+ return;
+ }
+ const itemIds = this.#getItemsForSpace(space);
+ // Handling elements which might occur more than once requires us to keep
+ // track which existing elements we've already used.
+ const elementTypeOffset = {};
+ let focusableElementSet = false;
+ const wantedElements = itemIds.map(itemId => {
+ // We want to re-use existing elements to reduce flicker when switching
+ // spaces and to preserve widget specific state, like a search string.
+ const existingElements = this.#toolbarContent.querySelectorAll(
+ `[item-id="${CSS.escape(itemId)}"]`
+ );
+ const nthChild = elementTypeOffset[itemId] ?? 0;
+ if (existingElements.length > nthChild) {
+ const existingElement = existingElements[nthChild];
+ elementTypeOffset[itemId] = nthChild + 1;
+ existingElement.hidden = false;
+ if (
+ !(
+ existingElement.details?.skipFocus ||
+ lazy.SKIP_FOCUS_ITEM_IDS.has(itemId)
+ ) &&
+ existingElement.querySelector(".live-content button:not([disabled])")
+ ) {
+ if (focusableElementSet) {
+ existingElement.tabIndex = -1;
+ } else {
+ existingElement.tabIndex = 0;
+ focusableElementSet = true;
+ }
+ }
+ return existingElement;
+ }
+ const element = document.createElement("li", {
+ is: "customizable-element",
+ });
+ element.setAttribute("item-id", itemId);
+ if (!lazy.SKIP_FOCUS_ITEM_IDS.has(itemId)) {
+ if (focusableElementSet) {
+ element.tabIndex = -1;
+ } else {
+ element.tabIndex = 0;
+ focusableElementSet = true;
+ }
+ }
+ return element;
+ });
+ for (const element of this.#toolbarContent.children) {
+ if (!wantedElements.includes(element)) {
+ element.hidden = true;
+ }
+ }
+ this.#toolbarContent.append(...wantedElements);
+ }
+
+ /**
+ * Initialize the unified toolbar contents.
+ */
+ initialize() {
+ this.#state = getState();
+ this.#itemsAvailableInSpace = {};
+ // Remove unused items from the toolbar.
+ const currentElements = this.#toolbarContent.children;
+ if (currentElements.length) {
+ const filledOutState = Object.fromEntries(
+ (gSpacesToolbar.spaces ?? Object.keys(this.#state)).map(space => [
+ space.name,
+ this.#getItemsForSpace(space.name),
+ ])
+ );
+ const allItems = new Set(Object.values(filledOutState).flat());
+ const spaceCounts = Object.keys(filledOutState).map(space =>
+ filledOutState[space].reduce((counts, itemId) => {
+ if (counts[itemId]) {
+ ++counts[itemId];
+ } else {
+ counts[itemId] = 1;
+ }
+ return counts;
+ }, {})
+ );
+ const elementCounts = Object.fromEntries(
+ Array.from(allItems, itemId => [
+ itemId,
+ Math.max(...spaceCounts.map(spaceCount => spaceCount[itemId])),
+ ])
+ );
+ const encounteredElements = {};
+ for (const element of currentElements) {
+ const itemId = element.getAttribute("item-id");
+ if (
+ allItems.has(itemId) &&
+ (!encounteredElements[itemId] ||
+ encounteredElements[itemId] < elementCounts[itemId])
+ ) {
+ encounteredElements[itemId] = encounteredElements[itemId]
+ ? encounteredElements[itemId] + 1
+ : 1;
+ continue;
+ }
+ // We don't need that many of this item.
+ element.remove();
+ }
+ }
+ this.#showToolbarForSpace(gSpacesToolbar.currentSpace?.name ?? "default");
+ document
+ .getElementById("cmd_CustomizeMailToolbar")
+ .removeAttribute("disabled");
+ }
+
+ /**
+ * Opens the customization UI for the unified toolbar.
+ */
+ async showCustomization() {
+ if (!gSpacesToolbar.isLoaded) {
+ return;
+ }
+ await this.#ensureCustomizationInserted();
+ document.querySelector("unified-toolbar-customization").toggle(true);
+ }
+
+ focus() {
+ this.firstElementChild.focus();
+ }
+}
+customElements.define("unified-toolbar", UnifiedToolbar);
diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml
new file mode 100644
index 0000000000..88cb8b0c62
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml
@@ -0,0 +1,366 @@
+# 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/.
+<html:template id="searchBarItemTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <global-search-bar data-l10n-id="search-bar-item"
+ data-l10n-attrs="label"
+ aria-keyshortcuts="Control+K">
+ <span slot="placeholder" data-l10n-id="search-bar-placeholder-with-key2"></span>
+ <img data-l10n-id="search-bar-button"
+ slot="button"
+ class="search-button-icon"
+ src="" />
+ </global-search-bar>
+</html:template>
+
+<html:template id="writeMessageTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="cmd_newMessage"
+ label-id="toolbar-write-message-label"
+ data-l10n-id="toolbar-write-message"></button>
+</html:template>
+
+<html:template id="moveToTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ popup="toolbarMoveToPopup"
+ observes="cmd_moveMessage"
+ label-id="toolbar-move-to-label"
+ data-l10n-id="toolbar-move-to"></button>
+</html:template>
+
+<html:template id="calendarUnifinderTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_show_unifinder_command"
+ aria-pressed="false"
+ class="check-button"
+ label-id="toolbar-unifinder-label"
+ data-l10n-id="toolbar-unifinder"></button>
+</html:template>
+
+<html:template id="folderLocationTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="folder-location-button"
+ popup="toolbarFolderLocationPopup"
+ data-l10n-id="toolbar-folder-location"></button>
+</html:template>
+
+<html:template id="editEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_modify_focused_item_command"
+ label-id="toolbar-edit-event-label"
+ data-l10n-id="toolbar-edit-event"></button>
+</html:template>
+
+<html:template id="getMessagesTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="cmd_getMsgsForAuthAccounts"
+ label-id="toolbar-get-messages-label"
+ data-l10n-id="toolbar-get-messages"></button>
+</html:template>
+
+<html:template id="replyTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_reply"
+ label-id="toolbar-reply-label"
+ data-l10n-id="toolbar-reply"></button>
+</html:template>
+
+<html:template id="replyAllTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_replyall"
+ label-id="toolbar-reply-all-label"
+ data-l10n-id="toolbar-reply-all"></button>
+</html:template>
+
+<html:template id="replyToListTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="reply-list-button"
+ command="cmd_replylist"
+ label-id="toolbar-reply-to-list-label"
+ data-l10n-id="toolbar-reply-to-list"></button>
+</html:template>
+
+<html:template id="redirectTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_redirect"
+ label-id="toolbar-redirect-label"
+ data-l10n-id="toolbar-redirect"></button>
+</html:template>
+
+<html:template id="archiveTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_archive"
+ label-id="toolbar-archive-label"
+ data-l10n-id="toolbar-archive"></button>
+</html:template>
+
+<html:template id="conversationTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_openConversation"
+ label-id="toolbar-conversation-label"
+ data-l10n-id="toolbar-conversation"></button>
+</html:template>
+
+<html:template id="previousUnreadTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_previousUnreadMsg"
+ label-id="toolbar-previous-unread-label"
+ data-l10n-id="toolbar-previous-unread"></button>
+</html:template>
+
+<html:template id="previousTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_previousMsg"
+ label-id="toolbar-previous-label"
+ data-l10n-id="toolbar-previous"></button>
+</html:template>
+
+<html:template id="nextUnreadTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_nextUnreadMsg"
+ label-id="toolbar-next-unread-label"
+ data-l10n-id="toolbar-next-unread"></button>
+</html:template>
+
+<html:template id="nextTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_nextMsg"
+ label-id="toolbar-next-label"
+ data-l10n-id="toolbar-next"></button>
+</html:template>
+
+<html:template id="junkTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_markAsJunk"
+ label-id="toolbar-junk-label"
+ data-l10n-id="toolbar-junk"></button>
+</html:template>
+
+<html:template id="deleteTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="delete-button"
+ label-id="toolbar-delete-label"
+ data-l10n-id="toolbar-delete-title"></button>
+</html:template>
+
+<html:template id="compactTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="compact-folder-button"
+ label-id="toolbar-compact-label"
+ data-l10n-id="toolbar-compact"></button>
+</html:template>
+
+<html:template id="addAsEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="add-to-calendar-button"
+ type="event"
+ label-id="toolbar-add-as-event-label"
+ data-l10n-id="toolbar-add-as-event"></button>
+</html:template>
+
+<html:template id="addAsTaskTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="add-to-calendar-button"
+ type="task"
+ label-id="toolbar-add-as-task-label"
+ data-l10n-id="toolbar-add-as-task"></button>
+</html:template>
+
+<html:template id="tagMessageTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ popup="toolbarTagPopup"
+ observes="cmd_tag"
+ label-id="toolbar-tag-message-label"
+ data-l10n-id="toolbar-tag-message"></button>
+</html:template>
+
+<html:template id="forwardInlineTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_forwardInline"
+ label-id="toolbar-forward-inline-label"
+ data-l10n-id="toolbar-forward-inline"></button>
+</html:template>
+
+<html:template id="forwardAttachmentTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_forwardAttachment"
+ label-id="toolbar-forward-attachment-label"
+ data-l10n-id="toolbar-forward-attachment"></button>
+</html:template>
+
+<html:template id="markAsTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ popup="toolbarMarkPopup"
+ observes="cmd_tag"
+ label-id="toolbar-mark-as-label"
+ data-l10n-id="toolbar-mark-as"></button>
+</html:template>
+
+<html:template id="viewPickerTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="view-picker-button"
+ popup="toolbarViewPickerPopup"
+ data-l10n-id="toolbar-view-picker"></button>
+</html:template>
+
+<html:template id="addressBookTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="addressbook"
+ label-id="toolbar-address-book-label"
+ data-l10n-id="toolbar-address-book"></button>
+</html:template>
+
+<html:template id="chatTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="chat"
+ label-id="toolbar-chat-label"
+ data-l10n-id="toolbar-chat"></button>
+</html:template>
+
+<html:template id="addOnsAndThemesTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="addons-button"
+ label-id="toolbar-add-ons-and-themes-label"
+ data-l10n-id="toolbar-add-ons-and-themes"></button>
+</html:template>
+
+<html:template id="calendarTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="calendar"
+ label-id="toolbar-calendar-label"
+ data-l10n-id="toolbar-calendar"></button>
+</html:template>
+
+<html:template id="tasksTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="tasks"
+ label-id="toolbar-tasks-label"
+ data-l10n-id="toolbar-tasks"></button>
+</html:template>
+
+<html:template id="mailTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="mail"
+ label-id="toolbar-mail-label"
+ data-l10n-id="toolbar-mail"></button>
+</html:template>
+
+<html:template id="printTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_print"
+ label-id="toolbar-print-label"
+ data-l10n-id="toolbar-print"></button>
+</html:template>
+
+<html:template id="quickFilterBarTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="quick-filter-bar-toggle"
+ command="cmd_toggleQuickFilterBar"
+ class="check-button"
+ label-id="toolbar-quick-filter-bar-label"
+ data-l10n-id="toolbar-quick-filter-bar"></button>
+</html:template>
+
+<html:template id="synchronizeTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_reload_remote_calendars"
+ label-id="toolbar-synchronize-label"
+ data-l10n-id="toolbar-synchronize"></button>
+</html:template>
+
+<html:template id="newEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_new_event_command"
+ label-id="toolbar-new-event-label"
+ data-l10n-id="toolbar-new-event"></button>
+</html:template>
+
+<html:template id="newTaskTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_new_todo_command"
+ label-id="toolbar-new-task-label"
+ data-l10n-id="toolbar-new-task"></button>
+</html:template>
+
+<html:template id="goToTodayTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_go_to_today_command"
+ observes="calendar_mode_calendar"
+ label-id="toolbar-go-to-today-label"
+ data-l10n-id="toolbar-go-to-today"></button>
+</html:template>
+
+<html:template id="deleteEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_delete_focused_item_command"
+ label-id="toolbar-delete-event-label"
+ data-l10n-id="toolbar-delete-event"></button>
+</html:template>
+
+<html:template id="printEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="cmd_print"
+ label-id="toolbar-print-event-label"
+ data-l10n-id="toolbar-print-event"></button>
+</html:template>
+
+<html:template id="goBackTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-go-button"
+ direction="back"
+ label-id="toolbar-go-back-label"
+ data-l10n-id="toolbar-go-back"></button>
+</html:template>
+
+<html:template id="goForwardTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-go-button"
+ direction="forward"
+ label-id="toolbar-go-forward-label"
+ data-l10n-id="toolbar-go-forward"></button>
+</html:template>
+
+<html:template id="stopTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_stop"
+ label-id="toolbar-stop-label"
+ data-l10n-id="toolbar-stop"></button>
+</html:template>
+
+<html:template id="throbberTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <img class="throbber-icon" alt="" data-l10n-id="toolbar-throbber" />
+</html:template>
diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml
new file mode 100644
index 0000000000..b0edb1b67f
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml
@@ -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/.
+<menupopup id="unifiedToolbarMenu">
+ <menuitem id="menuBarToggleVisible"
+ type="checkbox"
+ label="&menubarCmd.label;"
+ accesskey="&menubarCmd.accesskey;"/>
+ <menuseparator id="menuBarToggleMenuSeparator"/>
+ <menuitem id="unifiedToolbarCustomize" data-l10n-id="customize-menu-customize" />
+ <menuseparator id="extensionsMailToolbarMenuSeparator"/>
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
+<menupopup id="customizationTargetMenu">
+ <menuitem id="customizationTargetEnd" data-l10n-id="customize-target-end" />
+ <menuitem id="customizationTargetForward" data-l10n-id="customize-target-forward" />
+ <menuitem id="customizationTargetBackward" data-l10n-id="customize-target-backward" />
+ <menuitem id="customizationTargetStart" data-l10n-id="customize-target-start" />
+ <menuitem id="customizationTargetRemove" data-l10n-id="customize-target-remove" />
+ <menuitem id="customizationTargetRemoveEverywhere"
+ data-l10n-id="customize-target-remove-everywhere"
+ hidden="true" />
+ <menuitem id="customizationTargetAddEverywhere"
+ data-l10n-id="customize-target-add-everywhere"
+ hidden="true" />
+</menupopup>
+<menupopup id="customizationPaletteMenu">
+ <menuitem id="customizationPaletteAddEverywhere"
+ data-l10n-id="customize-palette-add-everywhere"
+ hidden="true" />
+</menupopup>
+<menupopup is="folder-menupopup" id="toolbarMoveToPopup"
+ mode="filing"
+ showRecent="true"
+ showFileHereLabel="true"
+ recentLabel="&moveCopyMsgRecentMenu.label;"
+ recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"
+ oncommand="goDoCommand('cmd_moveMessage', event.target._folder);event.stopPropagation()"/>
+<menupopup is="folder-menupopup" id="toolbarFolderLocationPopup"
+ class="menulist-menupopup"
+ mode="notDeferred"
+ showFileHereLabel="true"/>
+<menupopup id="toolbarTagPopup"
+ onpopupshowing="InitMessageTags(this);">
+ <menuitem id="button-addNewTag"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"
+ command="cmd_addTag"/>
+ <menuitem id="button-manageTags"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"
+ command="cmd_manageTags"/>
+ <menuseparator id="button-tagpopup-sep-afterTagAddNew"/>
+ <menuitem id="button-tagRemoveAll"
+ command="cmd_removeTags"/>
+ <menuseparator id="button-afterTagRemoveAllSeparator"/>
+</menupopup>
+<menupopup id="toolbarMarkPopup" onpopupshowing="InitMessageMark()">
+ <menuitem id="markReadToolbarItem"
+ label="&markAsReadCmd.label;"
+ accesskey="&markAsReadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsRead"/>
+ <menuitem id="markUnreadToolbarItem"
+ label="&markAsUnreadCmd.label;"
+ accesskey="&markAsUnreadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsUnread"/>
+ <menuitem id="button-markThreadAsRead"
+ label="&markThreadAsReadCmd.label;"
+ key="key_markThreadAsRead"
+ accesskey="&markThreadAsReadCmd.accesskey;"
+ command="cmd_markThreadAsRead"/>
+ <menuitem id="button-markReadByDate"
+ label="&markReadByDateCmd.label;"
+ key="key_markReadByDate"
+ accesskey="&markReadByDateCmd.accesskey;"
+ command="cmd_markReadByDate"/>
+ <menuitem id="button-markAllRead"
+ label="&markAllReadCmd.label;"
+ key="key_markAllRead"
+ accesskey="&markAllReadCmd.accesskey;"
+ command="cmd_markAllRead"/>
+ <menuseparator id="button-markAllReadSeparator"/>
+ <menuitem id="markFlaggedToolbarItem"
+ type="checkbox"
+ label="&markStarredCmd.label;"
+ accesskey="&markStarredCmd.accesskey;"
+ key="key_toggleFlagged"
+ command="cmd_markAsFlagged"/>
+</menupopup>
+<menupopup id="toolbarViewPickerPopup"
+ onpopupshowing="RefreshViewPopup(this);">
+ <menuitem id="viewPickerAll" value="0"
+ label="&viewAll.label;"
+ type="radio"
+ oncommand="ViewChangeByMenuitem(this);"/>
+ <menuitem id="viewPickerUnread" value="1"
+ label="&viewUnread.label;"
+ type="radio"
+ oncommand="ViewChangeByMenuitem(this);"/>
+ <menuitem id="viewPickerNotDeleted" value="3"
+ label="&viewNotDeleted.label;"
+ type="radio"
+ oncommand="ViewChangeByMenuitem(this);"/>
+ <menuseparator id="afterViewPickerUnreadSeparator"/>
+ <menu id="viewPickerTags" label="&viewTags.label;">
+ <menupopup id="viewPickerTagsPopup"
+ class="menulist-menupopup"
+ onpopupshowing="RefreshTagsPopup(this);"/>
+ </menu>
+ <menu id="viewPickerCustomViews" label="&viewCustomViews.label;">
+ <menupopup id="viewPickerCustomViewsPopup"
+ class="menulist-menupopup"
+ onpopupshowing="RefreshCustomViewsPopup(this);"/>
+ </menu>
+ <menuseparator id="afterViewPickerCustomViewsSeparator"/>
+ <menuitem id="viewPickerVirtualFolder"
+ value="7"
+ label="&viewVirtualFolder.label;"
+ oncommand="ViewChangeByMenuitem(this);"/>
+ <menuitem id="viewPickerCustomize"
+ value="8"
+ label="&viewCustomizeView.label;"
+ oncommand="ViewChangeByMenuitem(this);"/>
+</menupopup>
+<menupopup id="messageHistoryPopup">
+</menupopup>
diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml
new file mode 100644
index 0000000000..3953ed8871
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml
@@ -0,0 +1,137 @@
+# 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/.
+
+#include ./unifiedToolbarCustomizableItems.inc.xhtml
+
+<html:template id="searchBarTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <form>
+ <input type="search" placeholder="" required="required" />
+ <div aria-hidden="true"><slot name="placeholder"></slot></div>
+ <button class="button button-flat icon-button"><slot name="button"></slot></button>
+ </form>
+</html:template>
+
+<html:template id="unifiedToolbarTemplate">
+# Required for placing the window controls in the proper place without having
+# them inside the toolbar.
+ <html:div id="unifiedToolbarContainer">
+ <html:div id="unifiedToolbar" role="toolbar">
+#include ../../../base/content/spacesToolbarPin.inc.xhtml
+ <html:ul id="unifiedToolbarContent" class="unified-toolbar">
+ </html:ul>
+ <html:div id="notification-popup-box" hidden="true">
+ <html:img id="addons-notification-icon"
+ src="chrome://messenger/skin/icons/new/compact/extension.svg"
+ alt=""
+ class="notification-anchor-icon"
+ role="button" />
+ </html:div>
+ <toolbarbutton id="button-appmenu"
+ type="menu"
+ badged="true"
+ class="button toolbar-button button-appmenu"
+ label="&appmenuButton.label;"
+ tooltiptext="&appmenuButton1.tooltip;"
+ tabindex="0" />
+ </html:div>
+#include ../../../base/content/messenger-titlebar-items.inc.xhtml
+ </html:div>
+</html:template>
+
+<html:template id="unifiedToolbarCustomizationTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <form id="unifiedToolbarCustomizationContainer"
+ aria-labelledby="customizationHeading">
+ <h1 id="customizationHeading" data-l10n-id="customize-title"></h1>
+ <div role="tablist" id="customizationTabs" data-l10n-id="customize-spaces-tabs"></div>
+ <div id="customizationFooter">
+ <div>
+ <button type="reset"
+ class="button"
+ data-l10n-id="customize-restore-default"></button>
+ <button id="customizationToSettingsButton"
+ type="button"
+ class="button link-button"
+ data-l10n-id="customize-change-appearance"></button>
+ </div>
+ <div>
+ <label id="buttonStyleLabel"
+ for="buttonStyle"
+ data-l10n-id="customize-button-style-label"></label>
+ <select id="buttonStyle" class="select">
+ <option value="icons-beside-text"
+ data-l10n-id="customize-button-style-icons-beside-text-option"
+ selected="selected"></option>
+ <option value="icons-above-text"
+ data-l10n-id="customize-button-style-icons-above-text-option"></option>
+ <option value="icons-only"
+ data-l10n-id="customize-button-style-icons-only-option"></option>
+ <option value="text-only"
+ data-l10n-id="customize-button-style-text-only-option"></option>
+ </select>
+ </div>
+ <div>
+ <button id="unifiedToolbarCustomizationCancel"
+ type="button"
+ class="button"
+ data-l10n-id="customize-cancel"></button>
+ <button type="submit"
+ class="button button-primary"
+ data-l10n-id="customize-save"
+ disabled="disabled"></button>
+ </div>
+ </div>
+ <small id="unifiedToolbarCustomizationUnsavedChanges"
+ data-l10n-id="customize-unsaved-changes"
+ hidden="hidden"></small>
+ </form>
+</html:template>
+
+<html:template id="unifiedToolbarTabTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button role="tab">
+ <img alt="" src="" part="icon" />
+ <span><slot></slot></span>
+ </button>
+</html:template>
+
+<html:template id="unifiedToolbarCustomizationPaneTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <ul is="customization-target"
+ data-l10n-id="customize-main-toolbar-target"
+ class="toolbar-target unified-toolbar"></ul>
+ <search-bar data-l10n-id="customize-search-bar"
+ data-l10n-attrs="label"
+ class="palette-search">
+ <img data-l10n-id="search-bar-button"
+ slot="button"
+ src=""
+ class="search-button-icon" />
+ </search-bar>
+ <div class="customization-palettes">
+ <h2 class="space-specific-title"></h2>
+ <ul is="customization-palette" class="space-specific-palette">
+ </ul>
+ <h2 data-l10n-id="customize-palette-generic-title"
+ class="generic-palette-title"></h2>
+ <ul is="customization-palette" space="all" class="generic-palette">
+ </ul>
+ </div>
+</html:template>
+
+<html:template id="unifiedToolbarCustomizableElementTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <div class="live-content"></div>
+ <div class="preview">
+ <img src="" alt="" class="preview-icon" />
+ <span class="preview-label"></span>
+ </div>
+</html:template>
+
+<html:template id="unifiedToolbarButtonTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <img class="button-icon" alt="" src="" />
+ <span class="button-label"></span>
+</html:template>
diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css
new file mode 100644
index 0000000000..3fcafe9c48
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css
@@ -0,0 +1,53 @@
+/* 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 needs to be in content so it can load the moz-extension:// images. */
+
+.unified-toolbar .extension-action .button-icon {
+ height: 16px;
+ width: 16px;
+ margin-inline: 1px;
+ content: var(--webextension-toolbar-image, inherit);
+}
+
+:is(.icons-only, .icons-above-text, .icons-beside-text) .extension-action .prefer-icon-only .button-label {
+ display: none;
+}
+
+.unified-toolbar .extension-action .button-icon:-moz-lwtheme {
+ content: var(--webextension-toolbar-image-dark, inherit);
+}
+
+.extension-action .preview-icon {
+ content: var(--webextension-icon, inherit);
+}
+
+@media (prefers-color-scheme: dark) {
+ .unified-toolbar .extension-action .button-icon,
+ :root[lwt-tree-brighttext] .unified-toolbar .extension-action .button-icon {
+ content: var(--webextension-toolbar-image-light, inherit) !important;
+ }
+}
+
+
+@media (min-resolution: 1.1dppx) {
+ .unified-toolbar .extension-action .button-icon {
+ content: var(--webextension-toolbar-image-2x, inherit);
+ }
+
+ .unified-toolbar .extension-action .button-icon:-moz-lwtheme {
+ content: var(--webextension-toolbar-image-2x-dark, inherit);
+ }
+
+ .extension-action .preview-icon {
+ content: var(--webextension-icon-2x, inherit);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ .unified-toolbar .extension-action .button-icon,
+ :root[lwt-tree-brighttext] .unified-toolbar .extension-action .button-icon {
+ content: var(--webextension-toolbar-image-2x-light, inherit) !important;
+ }
+ }
+}
diff --git a/comm/mail/components/unifiedtoolbar/jar.mn b/comm/mail/components/unifiedtoolbar/jar.mn
new file mode 100644
index 0000000000..ad4478e170
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/jar.mn
@@ -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/.
+
+messenger.jar:
+ content/messenger/unifiedtoolbar/customizable-element.mjs (content/customizable-element.mjs)
+ content/messenger/unifiedtoolbar/customization-palette.mjs (content/customization-palette.mjs)
+ content/messenger/unifiedtoolbar/customization-target.mjs (content/customization-target.mjs)
+ content/messenger/unifiedtoolbar/add-to-calendar-button.mjs (content/items/add-to-calendar-button.mjs)
+ content/messenger/unifiedtoolbar/addons-button.mjs (content/items/addons-button.mjs)
+ content/messenger/unifiedtoolbar/compact-folder-button.mjs (content/items/compact-folder-button.mjs)
+ content/messenger/unifiedtoolbar/delete-button.mjs (content/items/delete-button.mjs)
+ content/messenger/unifiedtoolbar/folder-location-button.mjs (content/items/folder-location-button.mjs)
+ content/messenger/unifiedtoolbar/global-search-bar.mjs (content/items/global-search-bar.mjs)
+ content/messenger/unifiedtoolbar/mail-go-button.mjs (content/items/mail-go-button.mjs)
+ content/messenger/unifiedtoolbar/quick-filter-bar-toggle.mjs (content/items/quick-filter-bar-toggle.mjs)
+ content/messenger/unifiedtoolbar/space-button.mjs (content/items/space-button.mjs)
+ content/messenger/unifiedtoolbar/view-picker-button.mjs (content/items/view-picker-button.mjs)
+ content/messenger/unifiedtoolbar/extension-action-button.mjs (content/extension-action-button.mjs)
+ content/messenger/unifiedtoolbar/list-box-selection.mjs (content/list-box-selection.mjs)
+ content/messenger/unifiedtoolbar/mail-tab-button.mjs (content/mail-tab-button.mjs)
+ content/messenger/unifiedtoolbar/reply-list-button.mjs (content/items/reply-list-button.mjs)
+ content/messenger/unifiedtoolbar/search-bar.mjs (content/search-bar.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar.mjs (content/unified-toolbar.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar-button.mjs (content/unified-toolbar-button.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar-customization.mjs (content/unified-toolbar-customization.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar-customization-pane.mjs (content/unified-toolbar-customization-pane.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar-tab.mjs (content/unified-toolbar-tab.mjs)
+ content/messenger/unifiedtoolbar/unifiedToolbarWebextensions.css (content/unifiedToolbarWebextensions.css)
diff --git a/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs b/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs
new file mode 100644
index 0000000000..c65f3ed16a
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs
@@ -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/. */
+
+/**
+ * Array of button styles with the class name at the index of the corresponding
+ * button style pref integer value.
+ *
+ * @type {Array<string>}
+ */
+export const BUTTON_STYLE_MAP = [
+ "icons-beside-text",
+ "icons-above-text",
+ "icons-only",
+ "text-only",
+];
+
+/**
+ * Name of preference that stores the button style as an integer.
+ *
+ * @type {string}
+ */
+export const BUTTON_STYLE_PREF = "toolbar.unifiedtoolbar.buttonstyle";
diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs
new file mode 100644
index 0000000000..eb9ccee46f
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs
@@ -0,0 +1,134 @@
+/* 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 CUSTOMIZABLE_ITEMS from "resource:///modules/CustomizableItemsDetails.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "glodaEnabled",
+ "mailnews.database.global.indexer.enabled",
+ true,
+ () => Services.obs.notifyObservers(null, "unified-toolbar-state-change")
+);
+
+const DEFAULT_ITEMS = ["spacer", "search-bar", "spacer"];
+const DEFAULT_ITEMS_WITHOUT_SEARCH = ["spacer"];
+
+/**
+ * @type {{id: string, spaces: string[], installDate: Date}[]}
+ */
+const EXTENSIONS = [];
+
+export const EXTENSION_PREFIX = "ext-";
+
+/**
+ * Add an extension button that is available in the given spaces. Defaults to
+ * making the button only available in the mail space. To provide it in all
+ * spaces, pass an empty array for the spaces.
+ *
+ * @param {string} id - Extension ID to add the button for.
+ * @param {string[]} [spaces=["mail"]] - Array of spaces the button can be used
+ * in.
+ */
+export async function registerExtension(id, spaces = ["mail"]) {
+ if (EXTENSIONS.some(extension => extension.id === id)) {
+ return;
+ }
+ const addon = await lazy.AddonManager.getAddonByID(id);
+ EXTENSIONS.push({
+ id,
+ spaces,
+ installDate: addon?.installDate ?? new Date(),
+ });
+ EXTENSIONS.sort(
+ (extA, extB) => extA.installDate.valueOf() - extB.installDate.valueOf()
+ );
+}
+
+/**
+ * Remove the extension from the palette of available items.
+ *
+ * @param {string} id - Extension ID to remove.
+ */
+export function unregisterExtension(id) {
+ const index = EXTENSIONS.findIndex(extension => extension.id === id);
+ EXTENSIONS.splice(index, 1);
+}
+
+/**
+ * Get the IDs for the extension buttons available in a given space.
+ *
+ * @param {string} [space] - Space name, "default" or falsy value to specify the
+ * space the extension items should be returned for. For default, extensions
+ * explicitly available in the default space are returned. With a falsy value,
+ * extensions available in all spaces are returned.
+ * @param {boolean} [includeSpaceAgnostic=false] - When set, extensions that are
+ * available for all spaces and the provided space are returned. Only has an
+ * effect if space is not falsy.
+ * @returns {string[]} Array of item IDs for extensions in the given space.
+ */
+function getExtensionsForSpace(space, includeSpaceAgnostic = false) {
+ return EXTENSIONS.filter(
+ extension =>
+ (space && extension.spaces?.includes(space)) ||
+ ((!space || includeSpaceAgnostic) && !extension.spaces?.length)
+ ).map(extension => `${EXTENSION_PREFIX}${extension.id}`);
+}
+
+/**
+ * Get the items available for the unified toolbar in a given space.
+ *
+ * @param {string} [space] - ID of the space to get the available exclusive
+ * items of. When omitted only items allowed in all spaces are returned.
+ * @param {boolean} [includeSpaceAgnostic=false] - When set, extensions that are
+ * available for all spaces and the provided space are returned. Only has an
+ * effect if space is not falsy.
+ * @returns {string[]} Array of item IDs available in the space.
+ */
+export function getAvailableItemIdsForSpace(
+ space,
+ includeSpaceAgnostic = false
+) {
+ return CUSTOMIZABLE_ITEMS.filter(
+ item =>
+ ((space && item.spaces?.includes(space)) ||
+ ((!space || includeSpaceAgnostic) &&
+ (!item.spaces || item.spaces.length === 0))) &&
+ (item.id !== "search-bar" || lazy.glodaEnabled)
+ )
+ .map(item => item.id)
+ .concat(getExtensionsForSpace(space, includeSpaceAgnostic));
+}
+
+/**
+ * Retrieve the set of items that are in the default configuration of the
+ * toolbar for a given space.
+ *
+ * @param {string} space - ID of the space to get the default items for.
+ * "default" is passed to indicate a default state without any active space.
+ * @returns {string[]} Array of item IDs to show by default in the space.
+ */
+export function getDefaultItemIdsForSpace(space) {
+ return (
+ lazy.glodaEnabled ? DEFAULT_ITEMS : DEFAULT_ITEMS_WITHOUT_SEARCH
+ ).concat(getExtensionsForSpace(space, true));
+}
+
+/**
+ * Set of item IDs that can occur more than once in the targets of a space.
+ *
+ * @type {Set<string>}
+ */
+export const MULTIPLE_ALLOWED_ITEM_IDS = new Set(
+ CUSTOMIZABLE_ITEMS.filter(item => item.allowMultiple).map(item => item.id)
+);
+
+export const SKIP_FOCUS_ITEM_IDS = new Set(
+ CUSTOMIZABLE_ITEMS.filter(item => item.skipFocus).map(item => item.id)
+);
diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs
new file mode 100644
index 0000000000..1e3900d6c3
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs
@@ -0,0 +1,445 @@
+/* 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 has the following companion definition files:
+ * - unifiedToolbarCustomizableItems.css for the preview icons based on the id.
+ * - unifiedToolbarItems.ftl for the labels associated with the labelId.
+ * - unifiedToolbarCustomizableItems.inc.xhtml for the templates referenced with
+ * templateId.
+ * - unifiedToolbarShared.css contains styles for the template contents shared
+ * between the customization preview and the actual toolbar.
+ * - unifiedtoolbar/content/items contains all item specific custom elements.
+ */
+
+/**
+ * @typedef {object} CustomizableItemDetails
+ * @property {string} id - The ID of the item. Will be set as a class on the
+ * outer wrapper. May not contain commas.
+ * @property {string} labelId - Fluent ID for the label shown while in the
+ * palette.
+ * @property {boolean} [allowMultiple] - If this item can be added more than
+ * once to a space.
+ * @property {string[]} [spaces] - If empty or omitted, item is allowed in all
+ * spaces.
+ * @property {string} [templateId] - ID of template defining the "live" markup.
+ * @property {string[]} [requiredModules] - List of modules that must be loaded
+ * for the template of this item.
+ * @property {boolean} [hasContextMenu] - Indicates that this item has its own
+ * context menu, and the global unified toolbar one shouldn't be shown.
+ * @property {boolean} [skipFocus] - If this item should be skipped in keyboard
+ * focus navigation.
+ */
+
+/**
+ * @type {CustomizableItemDetails[]}
+ */
+export default [
+ // Universal items (all spaces)
+ {
+ id: "spacer",
+ labelId: "spacer",
+ allowMultiple: true,
+ skipFocus: true,
+ },
+ {
+ // This item gets filtered out when gloda is disabled.
+ id: "search-bar",
+ labelId: "search-bar",
+ templateId: "searchBarItemTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/global-search-bar.mjs",
+ ],
+ hasContextMenu: true,
+ skipFocus: true,
+ },
+ {
+ id: "write-message",
+ labelId: "toolbar-write-message",
+ templateId: "writeMessageTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "get-messages",
+ labelId: "toolbar-get-messages",
+ templateId: "getMessagesTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "address-book",
+ labelId: "toolbar-address-book",
+ templateId: "addressBookTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "chat",
+ labelId: "toolbar-chat",
+ templateId: "chatTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "add-ons-and-themes",
+ labelId: "toolbar-add-ons-and-themes",
+ templateId: "addOnsAndThemesTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/addons-button.mjs",
+ ],
+ },
+ {
+ id: "calendar",
+ labelId: "toolbar-calendar",
+ templateId: "calendarTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "tasks",
+ labelId: "toolbar-tasks",
+ templateId: "tasksTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "mail",
+ labelId: "toolbar-mail",
+ templateId: "mailTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "new-event",
+ labelId: "toolbar-new-event",
+ templateId: "newEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "new-task",
+ labelId: "toolbar-new-task",
+ templateId: "newTaskTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ // Mail space
+ {
+ id: "move-to",
+ labelId: "toolbar-move-to",
+ spaces: ["mail"],
+ templateId: "moveToTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "reply",
+ labelId: "toolbar-reply",
+ spaces: ["mail"],
+ templateId: "replyTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "reply-all",
+ labelId: "toolbar-reply-all",
+ spaces: ["mail"],
+ templateId: "replyAllTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "reply-to-list",
+ labelId: "toolbar-reply-to-list",
+ spaces: ["mail"],
+ templateId: "replyToListTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/reply-list-button.mjs",
+ ],
+ },
+ {
+ id: "redirect",
+ labelId: "toolbar-redirect",
+ spaces: ["mail"],
+ templateId: "redirectTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "archive",
+ labelId: "toolbar-archive",
+ spaces: ["mail"],
+ templateId: "archiveTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "conversation",
+ labelId: "toolbar-conversation",
+ spaces: ["mail"],
+ templateId: "conversationTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "previous-unread",
+ labelId: "toolbar-previous-unread",
+ spaces: ["mail"],
+ templateId: "previousUnreadTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "previous",
+ labelId: "toolbar-previous",
+ spaces: ["mail"],
+ templateId: "previousTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "next-unread",
+ labelId: "toolbar-next-unread",
+ spaces: ["mail"],
+ templateId: "nextUnreadTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "next",
+ labelId: "toolbar-next",
+ spaces: ["mail"],
+ templateId: "nextTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "junk",
+ labelId: "toolbar-junk",
+ spaces: ["mail"],
+ templateId: "junkTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "delete",
+ labelId: "toolbar-delete",
+ spaces: ["mail"],
+ templateId: "deleteTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/delete-button.mjs",
+ ],
+ },
+ {
+ id: "compact",
+ labelId: "toolbar-compact",
+ spaces: ["mail"],
+ templateId: "compactTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/compact-folder-button.mjs",
+ ],
+ },
+ {
+ id: "add-as-event",
+ labelId: "toolbar-add-as-event",
+ spaces: ["mail"],
+ templateId: "addAsEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/add-to-calendar-button.mjs",
+ ],
+ },
+ {
+ id: "add-as-task",
+ labelId: "toolbar-add-as-task",
+ spaces: ["mail"],
+ templateId: "addAsTaskTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/add-to-calendar-button.mjs",
+ ],
+ },
+ {
+ id: "folder-location",
+ labelId: "toolbar-folder-location",
+ spaces: ["mail"],
+ templateId: "folderLocationTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/folder-location-button.mjs",
+ ],
+ },
+ {
+ id: "tag-message",
+ labelId: "toolbar-tag-message",
+ spaces: ["mail"],
+ templateId: "tagMessageTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "forward-inline",
+ labelId: "toolbar-forward-inline",
+ spaces: ["mail"],
+ templateId: "forwardInlineTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "forward-attachment",
+ labelId: "toolbar-forward-attachment",
+ spaces: ["mail"],
+ templateId: "forwardAttachmentTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "mark-as",
+ labelId: "toolbar-mark-as",
+ spaces: ["mail"],
+ templateId: "markAsTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "view-picker",
+ labelId: "toolbar-view-picker",
+ spaces: ["mail"],
+ templateId: "viewPickerTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/view-picker-button.mjs",
+ ],
+ },
+ {
+ id: "print",
+ labelId: "toolbar-print",
+ spaces: ["mail"],
+ templateId: "printTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "quick-filter-bar",
+ labelId: "toolbar-quick-filter-bar",
+ spaces: ["mail"],
+ templateId: "quickFilterBarTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/quick-filter-bar-toggle.mjs",
+ ],
+ },
+ {
+ id: "go-back",
+ labelId: "toolbar-go-back",
+ spaces: ["mail"],
+ templateId: "goBackTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-go-button.mjs",
+ ],
+ hasContextMenu: true,
+ },
+ {
+ id: "go-forward",
+ labelId: "toolbar-go-forward",
+ spaces: ["mail"],
+ templateId: "goForwardTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-go-button.mjs",
+ ],
+ hasContextMenu: true,
+ },
+ {
+ id: "stop",
+ labelId: "toolbar-stop",
+ spaces: ["mail"],
+ templateId: "stopTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "throbber",
+ labelId: "toolbar-throbber",
+ spaces: ["mail"],
+ templateId: "throbberTemplate",
+ skipFocus: true,
+ },
+ // Calendar & Tasks space
+ {
+ id: "edit-event",
+ labelId: "toolbar-edit-event",
+ spaces: ["calendar", "tasks"],
+ templateId: "editEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "synchronize",
+ labelId: "toolbar-synchronize",
+ spaces: ["calendar", "tasks"],
+ templateId: "synchronizeTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "delete-event",
+ labelId: "toolbar-delete-event",
+ spaces: ["calendar", "tasks"],
+ templateId: "deleteEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "print-event",
+ labelId: "toolbar-print-event",
+ spaces: ["calendar", "tasks"],
+ templateId: "printEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ // Calendar space
+ {
+ id: "go-to-today",
+ labelId: "toolbar-go-to-today",
+ spaces: ["calendar"],
+ templateId: "goToTodayTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "unifinder",
+ labelId: "toolbar-unifinder",
+ spaces: ["calendar"],
+ templateId: "calendarUnifinderTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+];
diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs
new file mode 100644
index 0000000000..75c0b390be
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs
@@ -0,0 +1,55 @@
+/* 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 MAIN_WINDOW_DOCUMENT = "chrome://messenger/content/messenger.xhtml";
+const UNIFIED_TOOLBAR_ID = "unifiedToolbar";
+const CUSTOMIZATION_ATTRIBUTE_NAME = "state";
+
+/**
+ * @typedef {object} UnifiedToolbarCustomizationState
+ * @property {string[]} (spaceName) - Each space has a key on the object,
+ * containing an ordered array of item IDs.
+ */
+
+/**
+ * Store the customization state for the unified toolbar. Sends a global
+ * observer notification.
+ *
+ * @param {UnifiedToolbarCustomizationState} state
+ */
+export function storeState(state) {
+ Services.xulStore.setValue(
+ MAIN_WINDOW_DOCUMENT,
+ UNIFIED_TOOLBAR_ID,
+ CUSTOMIZATION_ATTRIBUTE_NAME,
+ JSON.stringify(state)
+ );
+ Services.obs.notifyObservers(null, "unified-toolbar-state-change");
+}
+
+/**
+ * Retrieve the customization state of the unified toolbar.
+ *
+ * @returns {UnifiedToolbarCustomizationState} A partial representation of the
+ * customization state of the unified toolbar. Missing spaces are in their
+ * default states.
+ */
+export function getState() {
+ let state = {};
+ if (
+ Services.xulStore.hasValue(
+ MAIN_WINDOW_DOCUMENT,
+ UNIFIED_TOOLBAR_ID,
+ CUSTOMIZATION_ATTRIBUTE_NAME
+ )
+ ) {
+ const rawState = Services.xulStore.getValue(
+ MAIN_WINDOW_DOCUMENT,
+ UNIFIED_TOOLBAR_ID,
+ CUSTOMIZATION_ATTRIBUTE_NAME
+ );
+ state = JSON.parse(rawState);
+ }
+ return state;
+}
diff --git a/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs b/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs
new file mode 100644
index 0000000000..117fb774ba
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs
@@ -0,0 +1,419 @@
+/* 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 {
+ getState,
+ storeState,
+} from "resource:///modules/CustomizationState.mjs";
+import {
+ MULTIPLE_ALLOWED_ITEM_IDS,
+ EXTENSION_PREFIX,
+ getAvailableItemIdsForSpace,
+ getDefaultItemIdsForSpace,
+} from "resource:///modules/CustomizableItems.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ getCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+ setCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+});
+
+/**
+ * Maps XUL toolbar item IDs to unified toolbar item IDs. If null, the item is
+ * not available in the unified toolbar.
+ */
+const MIGRATION_MAP = {
+ separator: null,
+ spacer: "spacer",
+ spring: "spacer",
+ "button-getmsg": "get-messages",
+ "button-newmsg": "write-message",
+ "button-reply": "reply",
+ "button-replyall": "reply-all",
+ "button-replylist": "reply-list",
+ "button-forward": "forward-inline",
+ "button-redirect": "redirect",
+ "button-file": "move-to",
+ "button-archive": "archive",
+ "button-showconversation": "conversation",
+ "button-goback": "go-back",
+ "button-goforward": "go-forward",
+ "button-previous": "previous-unread",
+ "button-previousMsg": "previous",
+ "button-next": "next-unread",
+ "button-nextMsg": "next",
+ "button-junk": "junk",
+ "button-delete": "delete",
+ "button-print": "print",
+ "button-mark": "mark-as",
+ "button-tag": "tag-message",
+ "qfb-show-filter-bar": "quick-filter-bar",
+ "button-address": "address-book",
+ "button-chat": "chat",
+ "throbber-box": "throbber",
+ "button-stop": "stop",
+ "button-compact": "compact",
+ "folder-location-container": "folder-location",
+ "mailviews-container": "view-picker",
+ "button-addons": "add-ons-and-themes",
+ "button-appmenu": null,
+ "gloda-search": "search-bar",
+ "lightning-button-calendar": "calendar",
+ "lightning-button-tasks": "tasks",
+ extractEventButton: "add-as-event",
+ extractTaskButton: "add-as-task",
+ "menubar-items": null,
+ "calendar-synchronize-button": "synchronize",
+ "calendar-newevent-button": "new-event",
+ "calendar-newtask-button": "new-task",
+ "calendar-goto-today-button": "go-to-today",
+ "calendar-edit-button": "edit-event",
+ "calendar-delete-button": "delete-event",
+ "calendar-print-button": "print-event",
+ "calendar-unifinder-button": "unifinder",
+ "calendar-appmenu-button": null,
+ "task-synchronize-button": "synchronize",
+ "task-newevent-button": "new-event",
+ "task-newtask-button": "new-task",
+ "task-edit-button": "edit-event",
+ "task-delete-button": "delete-event",
+ "task-print-button": "print-event",
+ "task-appmenu-button": null,
+};
+
+/**
+ * Maps space names to the ID of the toolbar in the messenger window.
+ */
+const TOOLBAR_FOR_SPACE = {
+ mail: "mail-bar3",
+ calendar: "calendar-toolbar2",
+ tasks: "task-toolbar2",
+};
+
+/**
+ * XUL toolbars store a special value when there are no items in the toolbar.
+ */
+const EMPTY_SET = "__empty";
+/**
+ * Map from the XUL toolbar id to its default set. Since toolbars we're
+ * migrating were removed from the DOM. The value should be the value of the
+ * defaultset attribute of the respective element in the markup.
+ *
+ * @type {{[string]: string}}
+ */
+const XUL_TOOLBAR_DEFAULT_SET = {
+ "mail-bar3":
+ AppConstants.platform == "macosx"
+ ? "button-getmsg,button-newmsg,button-tag,qfb-show-filter-bar,spring,gloda-search,button-appmenu"
+ : "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,button-appmenu",
+ "tabbar-toolbar": "",
+ "toolbar-menubar": "menubar-items,spring",
+ "calendar-toolbar2":
+ "calendar-synchronize-button,calendar-newevent-button,calendar-newtask-button,calendar-edit-button,calendar-delete-button,spring,calendar-appmenu-button",
+ "task-toolbar2":
+ "task-synchronize-button,task-newevent-button,task-newtask-button,task-edit-button,task-delete-button,spring,task-appmenu-button",
+};
+const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml";
+const EXTENSION_WIDGET_SUFFIX = "-browserAction-toolbarbutton";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "extensionIds",
+ "extensions.webextensions.uuids",
+ "{}",
+ null,
+ value => Object.keys(JSON.parse(value))
+);
+
+/**
+ * Get the extension ID from a XUL toolbar button ID of an extension.
+ *
+ * @param {string} buttonId - ID of the XUL toolbar button.
+ * @returns {?string} ID of the extension the button belonged to.
+ */
+function getExtensionIdFromExtensionButton(buttonId) {
+ const widgetId = buttonId.slice(0, -EXTENSION_WIDGET_SUFFIX.length);
+ return lazy.extensionIds.find(
+ extensionId => lazy.ExtensionCommon.makeWidgetId(extensionId) === widgetId
+ );
+}
+
+/**
+ * Convert the string contents of an old toolbar *set attribute to an array of
+ * item IDs.
+ *
+ * @param {string} setString - Contents of the set attribute.
+ * @returns {string[]} Array of items in the set.
+ */
+function toolbarSetAttributeToArray(setString) {
+ if (!setString || setString === EMPTY_SET) {
+ return [];
+ }
+ return setString.split(",").filter(Boolean);
+}
+
+/**
+ * Get the default set (without extensions) of a XUL toolbar.
+ *
+ * @param {string} toolbarId - ID of the XUL toolbar element.
+ * @param {string} window - URI of the window the toolbar is in.
+ * @returns {string} defaultset attribute of the given XUL toolbar.
+ */
+function getOldToolbarDefaultContents(toolbarId, window = MESSENGER_WINDOW) {
+ let setString = Services.xulStore.getValue(window, toolbarId, "defaultset");
+ if (!setString) {
+ setString = XUL_TOOLBAR_DEFAULT_SET[toolbarId];
+ }
+ return setString;
+}
+
+/**
+ * Get the items in a XUL toolbar area. Will return defaults if the area is not
+ * customized.
+ *
+ * @param {string} toolbarId - ID of the XUL toolbar element.
+ * @param {string} window - URI of the window the toolbar is in.
+ * @returns {string[]} Item IDs in the given XUL toolbar.
+ */
+function getOldToolbarContents(toolbarId, window = MESSENGER_WINDOW) {
+ let setString = Services.xulStore.getValue(window, toolbarId, "currentset");
+ if (!setString) {
+ setString = getOldToolbarDefaultContents(toolbarId, window);
+ }
+ return toolbarSetAttributeToArray(setString);
+}
+
+/**
+ * Converts XUL toolbar item IDs to unified toolbar item IDs, filtering out
+ * items that are not supported in the unified toolbar.
+ *
+ * @param {string[]} items - XUL toolbar item IDs to convert.
+ * @returns {string[]} Unified toolbar item IDs.
+ */
+function convertContents(items) {
+ return items
+ .map(itemId => {
+ if (MIGRATION_MAP.hasOwnProperty(itemId)) {
+ return MIGRATION_MAP[itemId];
+ }
+ if (itemId.endsWith(EXTENSION_WIDGET_SUFFIX)) {
+ const extensionId = getExtensionIdFromExtensionButton(itemId);
+ if (extensionId) {
+ return `${EXTENSION_PREFIX}${extensionId}`;
+ }
+ }
+ return null;
+ })
+ .filter(Boolean);
+}
+
+/**
+ * Get the unified toolbar item IDs for items that were in the tab bar and the
+ * menu bar areas.
+ *
+ * @returns {string[]} Item IDs that were available in any tab in the XUL
+ * toolbars.
+ */
+function getGlobalItems() {
+ const tabsContent = convertContents(getOldToolbarContents("tabbar-toolbar"));
+ const menubarContent = convertContents(
+ getOldToolbarContents("toolbar-menubar")
+ );
+ return [...menubarContent, ...tabsContent];
+}
+
+/**
+ * Converts the items in the old xul toolbar of a given space and the tab bar
+ * and menu bar areas to unified toolbar item IDs.
+ *
+ * Filters out any items not available and items that appear multiple times, if
+ * they can't be repeated. The first instance is kept.
+ *
+ * If there is no old toolbar for the given space, only the global items are
+ * returned.
+ *
+ * @param {string} space - Name of the space to get the items for.
+ * @returns {string[]} Unified toolbar item IDs based on the old contents of the
+ * xul toolbar of the space.
+ */
+function getItemsForSpace(space) {
+ let spaceContent = [];
+ if (TOOLBAR_FOR_SPACE.hasOwnProperty(space)) {
+ spaceContent = convertContents(
+ getOldToolbarContents(TOOLBAR_FOR_SPACE[space])
+ );
+ } else {
+ spaceContent = getDefaultItemIdsForSpace(space);
+ }
+ const newContents = [...spaceContent, ...getGlobalItems()];
+ const availableItems = getAvailableItemIdsForSpace(space, true).concat(
+ lazy.extensionIds.map(id => `${EXTENSION_PREFIX}${id}`)
+ );
+ const encounteredItems = new Set();
+ const finalItems = newContents.filter((itemId, index, items) => {
+ if (
+ (encounteredItems.has(itemId) &&
+ !MULTIPLE_ALLOWED_ITEM_IDS.has(itemId)) ||
+ !availableItems.includes(itemId) ||
+ (itemId === "spacer" && index > 0 && items[index - 1] === itemId)
+ ) {
+ return false;
+ }
+ encounteredItems.add(itemId);
+ return true;
+ });
+ return finalItems;
+}
+
+/**
+ * Convert the persisted extensions from the old extensionset to the new space
+ * specific store for extensions.
+ *
+ * @param {string} space - Name of the migrated space.
+ */
+function convertExtensionState(space) {
+ if (
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ TOOLBAR_FOR_SPACE[space],
+ "extensionset"
+ )
+ ) {
+ return;
+ }
+ const extensionSet = Services.xulStore
+ .getValue(MESSENGER_WINDOW, TOOLBAR_FOR_SPACE[space], "extensionset")
+ .split(",")
+ .filter(Boolean);
+ const extensionsInExtensionSet = extensionSet.map(buttonId =>
+ getExtensionIdFromExtensionButton(buttonId)
+ );
+ const cachedAllowedSpaces = lazy.getCachedAllowedSpaces();
+ for (const extensionId of extensionsInExtensionSet) {
+ const allowedSpaces = cachedAllowedSpaces.get(extensionId) ?? [];
+ if (!allowedSpaces.includes(space)) {
+ allowedSpaces.push(space);
+ }
+ cachedAllowedSpaces.set(extensionId, allowedSpaces);
+ }
+ lazy.setCachedAllowedSpaces(cachedAllowedSpaces);
+}
+
+/**
+ * Check if the XUL toolbar matches the default state.
+ *
+ * @param {string} toolbarId - ID of the old XUL toolbar element to check the
+ * state of.
+ * @returns {boolean} If the toolbar with the given ID has a currentset matching
+ * the default state for that toolbar.
+ */
+function oldToolbarContainsDefaultItems(toolbarId) {
+ // Fast path: if there is no current set, the contents of the toolbar were
+ // never modified.
+ if (!Services.xulStore.hasValue(MESSENGER_WINDOW, toolbarId, "currentset")) {
+ return true;
+ }
+ const toolbarContents = getOldToolbarContents(toolbarId);
+ let defaultContents = toolbarSetAttributeToArray(
+ getOldToolbarDefaultContents(toolbarId)
+ );
+ const extensionContents = toolbarSetAttributeToArray(
+ Services.xulStore.getValue(MESSENGER_WINDOW, toolbarId, "extensionset")
+ );
+ // Extensions are inserted before the appmenu button, which is usually at the
+ // end of the default set.
+ if (extensionContents.length) {
+ const appmenuIndex = defaultContents.findIndex(
+ itemId => itemId === "button-appmenu"
+ );
+ if (appmenuIndex !== -1) {
+ defaultContents.splice(appmenuIndex, 0, ...extensionContents);
+ } else {
+ defaultContents = defaultContents.concat(extensionContents);
+ }
+ }
+ return (
+ toolbarContents.length === defaultContents.length &&
+ toolbarContents.every((itemId, index) => itemId === defaultContents[index])
+ );
+}
+
+/**
+ * Check if the XUL toolbar customization state is equivalent to its default set
+ * for a given space.
+ *
+ * @param {string} space - Name of the space to check the default set for.
+ * @returns {boolean} If the state of the old XUL toolbars matches the default
+ * set for that space. True if we don't know any toolbar for the given space.
+ */
+function stateMatchesDefault(space) {
+ if (!TOOLBAR_FOR_SPACE.hasOwnProperty(space)) {
+ return true;
+ }
+ if (!oldToolbarContainsDefaultItems(TOOLBAR_FOR_SPACE[space])) {
+ return false;
+ }
+ if (space === "mail") {
+ if (!oldToolbarContainsDefaultItems("tabbar-toolbar")) {
+ return false;
+ }
+ if (!oldToolbarContainsDefaultItems("toolbar-menubar")) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Remove all the persisted state of a XUL toolbar from the XUL store.
+ *
+ * @param {string} toolbarId - Element ID of the XUL toolbar to clear the state
+ * of.
+ */
+export function clearXULToolbarState(toolbarId) {
+ Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "currentset");
+ Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "defaultset");
+ Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "extensionset");
+}
+
+/**
+ * Migrate the old xul toolbar contents for a given space to the unified toolbar
+ * if the unified toolbar has not yet been customized.
+ *
+ * Adds both the contents of the space specific toolbar and the tab bar and menu
+ * bar areas to the unified toolbar, if the items are available.
+ *
+ * When the migration is complete, the old XUL store values for the XUL toolbar
+ * area are deleted.
+ *
+ * @param {string} space - Name of the space to migrate.
+ */
+export function migrateToolbarForSpace(space) {
+ const state = getState();
+ // If the mail toolbar areas are all in their default state, we don't want to
+ // migrate their contents.
+ const mailToolbarInDefaultState =
+ space === "mail" && stateMatchesDefault(space);
+ // Don't migrate contents if the state of the space is already customized.
+ if (state[space] || mailToolbarInDefaultState) {
+ if (mailToolbarInDefaultState && TOOLBAR_FOR_SPACE.hasOwnProperty(space)) {
+ clearXULToolbarState(TOOLBAR_FOR_SPACE[space]);
+ }
+ return;
+ }
+ state[space] = getItemsForSpace(space);
+ storeState(state);
+ if (TOOLBAR_FOR_SPACE.hasOwnProperty(space)) {
+ convertExtensionState(space);
+ // Remove all the state for the old toolbar of the space.
+ clearXULToolbarState(TOOLBAR_FOR_SPACE[space]);
+ }
+}
diff --git a/comm/mail/components/unifiedtoolbar/moz.build b/comm/mail/components/unifiedtoolbar/moz.build
new file mode 100644
index 0000000000..106b510067
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/moz.build
@@ -0,0 +1,22 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "modules/ButtonStyle.mjs",
+ "modules/CustomizableItems.sys.mjs",
+ "modules/CustomizableItemsDetails.mjs",
+ "modules/CustomizationState.mjs",
+ "modules/ToolbarMigration.sys.mjs",
+]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/unit/xpcshell.ini",
+]
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser.ini b/comm/mail/components/unifiedtoolbar/test/browser/browser.ini
new file mode 100644
index 0000000000..072a4571ef
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser.ini
@@ -0,0 +1,16 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = files/**
+
+[browser_customizableItems.js]
+[browser_searchBar.js]
+[browser_toolbarMigration.js]
+[browser_unifiedToolbarTab.js]
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js
new file mode 100644
index 0000000000..152ade47f3
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js
@@ -0,0 +1,173 @@
+/* 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 {
+ getAvailableItemIdsForSpace,
+ getDefaultItemIdsForSpace,
+ registerExtension,
+ unregisterExtension,
+} = ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");
+
+add_task(async function test_extensionRegisterUnregisterDefault() {
+ const extensionId = "thunderbird-compact-light@mozilla.org";
+ await registerExtension(extensionId);
+
+ const itemId = `ext-${extensionId}`;
+ ok(
+ getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item available in mail space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item in mail space by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item not available in all spaces"
+ );
+
+ unregisterExtension(extensionId);
+
+ ok(
+ !getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item no longer available in mail space"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item not in mail space by default"
+ );
+});
+
+add_task(async function test_extensionRegisterAllSpaces() {
+ const extensionId = "thunderbird-compact-light@mozilla.org";
+ await registerExtension(extensionId, []);
+
+ const itemId = `ext-${extensionId}`;
+ ok(
+ getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item available in all spaces"
+ );
+ ok(
+ getDefaultItemIdsForSpace("default").includes(itemId),
+ "Extension item in all spaces by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item not available in mail space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item in mail space by default"
+ );
+
+ unregisterExtension(extensionId);
+
+ ok(
+ !getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item no longer available in all spaces"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("default").includes(itemId),
+ "Extension item not in any space by default"
+ );
+});
+
+add_task(async function test_extensionRegisterMultipleSpaces() {
+ const extensionId = "thunderbird-compact-light@mozilla.org";
+ await registerExtension(extensionId, ["mail", "calendar", "default"]);
+
+ const itemId = `ext-${extensionId}`;
+ ok(
+ getAvailableItemIdsForSpace("calendar").includes(itemId),
+ "Extension item available in calendar space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("calendar").includes(itemId),
+ "Extension item in calendar space by default"
+ );
+ ok(
+ getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item available in mail space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item in mail space by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item not available in all spaces"
+ );
+ ok(
+ getAvailableItemIdsForSpace("default").includes(itemId),
+ "Extension item available in default space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("default").includes(itemId),
+ "Extension item in default space"
+ );
+
+ unregisterExtension(extensionId);
+
+ ok(
+ !getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item no longer available in mail space"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item not in mail space by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace("calendar").includes(itemId),
+ "Extension item no longer available in calendar space"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("calendar").includes(itemId),
+ "Extension item not in calendar space by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item not available in all spaces"
+ );
+ ok(
+ !getAvailableItemIdsForSpace("default").includes(itemId),
+ "Extension item not available in default space"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("default").includes(itemId),
+ "Extension item not in default space"
+ );
+});
+
+add_task(async function test_extensionRegisterStableOrder() {
+ const extension1Id = "thunderbird-compact-light@mozilla.org";
+ const extension2Id = "thunderbird-compact-dark@mozilla.org";
+ await registerExtension(extension1Id);
+ await registerExtension(extension2Id);
+
+ const defaultItems = getDefaultItemIdsForSpace("mail");
+
+ const firstExtensionId = defaultItems
+ .find(itemId => itemId.startsWith("ext-"))
+ .slice(4);
+
+ unregisterExtension(firstExtensionId);
+
+ ok(
+ !getDefaultItemIdsForSpace("mail").includes(`ext-${firstExtensionId}`),
+ "Extension that was the first in the default set not in default set"
+ );
+
+ await registerExtension(firstExtensionId);
+
+ Assert.deepEqual(
+ getDefaultItemIdsForSpace("mail"),
+ defaultItems,
+ "Default items order stable for extensions"
+ );
+
+ unregisterExtension(extension1Id);
+ unregisterExtension(extension2Id);
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js
new file mode 100644
index 0000000000..b88c16f684
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js
@@ -0,0 +1,263 @@
+/* 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 tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+let browser;
+let searchBar;
+
+const waitForRender = () => {
+ return new Promise(resolve => {
+ window.requestAnimationFrame(resolve);
+ });
+};
+
+/* These are shadow-root safe variants of the methods in BrowserTestUtils. */
+
+/**
+ * Checks if a DOM element is hidden.
+ *
+ * @param {Element} element
+ * The element which is to be checked.
+ *
+ * @return {boolean}
+ */
+function is_hidden(element) {
+ var style = element.ownerGlobal.getComputedStyle(element);
+ if (style.display == "none") {
+ return true;
+ }
+ if (style.visibility != "visible") {
+ return true;
+ }
+ if (style.display == "-moz-popup") {
+ return ["hiding", "closed"].includes(element.state);
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument && element.parentElement) {
+ return is_hidden(element.parentElement);
+ }
+
+ return false;
+}
+
+/**
+ * Checks if a DOM element is visible.
+ *
+ * @param {Element} element
+ * The element which is to be checked.
+ *
+ * @return {boolean}
+ */
+function is_visible(element) {
+ var style = element.ownerGlobal.getComputedStyle(element);
+ if (style.display == "none") {
+ return false;
+ }
+ if (style.visibility != "visible") {
+ return false;
+ }
+ if (style.display == "-moz-popup" && element.state != "open") {
+ return false;
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument && element.parentElement) {
+ return is_visible(element.parentElement);
+ }
+
+ return true;
+}
+
+add_setup(async function () {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml",
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+ browser = tab.browser;
+ searchBar = tab.browser.contentWindow.document.querySelector("search-bar");
+});
+
+add_task(async function test_initialState() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ is(
+ input.getAttribute("aria-label"),
+ searchBar.getAttribute("label"),
+ "Label forwarded to aria-label on input"
+ );
+});
+
+add_task(async function test_labelUpdate() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ searchBar.setAttribute("label", "foo");
+ await waitForRender();
+ is(
+ input.getAttribute("aria-label"),
+ "foo",
+ "Updated label applied to content"
+ );
+});
+
+add_task(async function test_focus() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ searchBar.focus();
+ is(
+ searchBar.shadowRoot.activeElement,
+ input,
+ "Input is focused when search bar is focused"
+ );
+});
+
+add_task(async function test_autocompleteEvent() {
+ const typeAndWaitForAutocomplete = async key => {
+ const eventPromise = BrowserTestUtils.waitForEvent(
+ searchBar,
+ "autocomplete"
+ );
+ await BrowserTestUtils.synthesizeKey(key, {}, browser);
+ return eventPromise;
+ };
+ searchBar.focus();
+ let event = await typeAndWaitForAutocomplete("T");
+ is(event.detail, "T", "Autocomplete for T");
+
+ event = await typeAndWaitForAutocomplete("e");
+ is(event.detail, "Te", "Autocomplete for e");
+
+ event = await typeAndWaitForAutocomplete("KEY_Backspace");
+ is(event.detail, "T", "Autocomplete for backspace");
+
+ await BrowserTestUtils.synthesizeKey("KEY_Backspace", {}, browser);
+});
+
+add_task(async function test_searchEventFromEnter() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ input.value = "Lorem ipsum";
+ searchBar.focus();
+
+ const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search");
+ await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser);
+ const event = await eventPromise;
+
+ is(event.detail, "Lorem ipsum", "Event contains search query");
+ await waitForRender();
+ is(input.value, "", "Input was cleared");
+});
+
+add_task(async function test_searchEventFromButton() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ input.value = "Lorem ipsum";
+
+ const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search");
+ searchBar.shadowRoot.querySelector("button").click();
+ const event = await eventPromise;
+
+ is(event.detail, "Lorem ipsum", "Event contains search query");
+ await waitForRender();
+ is(input.value, "", "Input was cleared");
+});
+
+add_task(async function test_searchEventPreventDefault() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ input.value = "Lorem ipsum";
+
+ searchBar.addEventListener(
+ "search",
+ event => {
+ event.preventDefault();
+ },
+ {
+ once: true,
+ passive: false,
+ }
+ );
+
+ const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search");
+ searchBar.shadowRoot.querySelector("button").click();
+ await eventPromise;
+ await waitForRender();
+
+ is(input.value, "Lorem ipsum");
+
+ input.value = "";
+});
+
+add_task(async function test_placeholderVisibility() {
+ const placeholder = searchBar.shadowRoot.querySelector("div");
+ const input = searchBar.shadowRoot.querySelector("input");
+
+ input.value = "";
+ await waitForRender();
+ ok(is_visible(placeholder), "Placeholder is visible initially");
+
+ input.value = "some input";
+ await waitForRender();
+ ok(is_hidden(placeholder), "Placeholder is hidden after text is entered");
+
+ input.value = "";
+ await waitForRender();
+ ok(
+ is_visible(placeholder),
+ "Placeholder is visible again after input is cleared"
+ );
+});
+
+add_task(async function test_placeholderFallbackToLabel() {
+ const placeholder = searchBar.querySelector("span");
+ placeholder.remove();
+
+ const shadowedPlaceholder = searchBar.shadowRoot.querySelector("div");
+ const label = searchBar.getAttribute("label");
+
+ is(
+ shadowedPlaceholder.textContent,
+ label,
+ "Falls back to label if no placeholder slot contents provided"
+ );
+
+ searchBar.setAttribute("label", "Foo bar");
+ is(
+ shadowedPlaceholder.textContent,
+ "Foo bar",
+ "Placeholder contents get updated with label attribute"
+ );
+
+ searchBar.prepend(placeholder);
+ searchBar.setAttribute("label", label);
+});
+
+add_task(async function test_reset() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ const placeholder = searchBar.shadowRoot.querySelector("div");
+ input.value = "Lorem ipsum";
+
+ searchBar.reset();
+
+ is(input.value, "", "Input empty after reset");
+ await waitForRender();
+ ok(is_visible(placeholder), "Placeholder visible");
+});
+
+add_task(async function test_disabled() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ const button = searchBar.shadowRoot.querySelector("button");
+
+ ok(!input.disabled, "Input enabled");
+ ok(!button.disabled, "Button enabled");
+
+ searchBar.setAttribute("disabled", true);
+
+ ok(input.disabled, "Disabled propagated to input");
+ ok(button.disabled, "Disabled propagated to button");
+
+ searchBar.removeAttribute("disabled");
+
+ ok(!input.disabled, "Input enabled again");
+ ok(!button.disabled, "Button enabled again");
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js
new file mode 100644
index 0000000000..c2ca1147fd
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js
@@ -0,0 +1,99 @@
+/* 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 { migrateToolbarForSpace } = ChromeUtils.importESModule(
+ "resource:///modules/ToolbarMigration.sys.mjs"
+);
+const { getState, storeState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+const { EXTENSION_PREFIX } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+const { getCachedAllowedSpaces, setCachedAllowedSpaces } = ChromeUtils.import(
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml";
+const EXTENSION_ID = "thunderbird-compact-light@mozilla.org";
+
+add_setup(() => {
+ storeState({});
+});
+
+add_task(async function test_migrate_extension() {
+ Services.xulStore.setValue(MESSENGER_WINDOW, "mail-bar3", "currentset", "");
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ "mail-bar3",
+ "defaultset",
+ "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,thunderbird-compact-light_mozilla_org-browserAction-toolbarbutton,button-appmenu"
+ );
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ "mail-bar3",
+ "extensionset",
+ "thunderbird-compact-light_mozilla_org-browserAction-toolbarbutton"
+ );
+ const extensionPref = Services.prefs.getStringPref(
+ "extensions.webextensions.uuids",
+ ""
+ );
+ const parsedPref = JSON.parse(extensionPref || "{}");
+ if (!parsedPref.hasOwnProperty(EXTENSION_ID)) {
+ parsedPref[EXTENSION_ID] = "foo";
+ Services.prefs.setStringPref(
+ "extensions.webextensions.uuids",
+ JSON.stringify(parsedPref)
+ );
+ }
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.mail,
+ [
+ "get-messages",
+ "write-message",
+ "tag-message",
+ "quick-filter-bar",
+ "spacer",
+ `${EXTENSION_PREFIX}${EXTENSION_ID}`,
+ "spacer",
+ ],
+ "Extension button was converted to new ID format"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "extensionset"),
+ "Old toolbar extension state is cleared"
+ );
+ Assert.deepEqual(
+ Object.fromEntries(getCachedAllowedSpaces()),
+ { [EXTENSION_ID]: ["mail"] },
+ "Extension set migrated to new persistent extension state"
+ );
+
+ storeState({});
+ setCachedAllowedSpaces(new Map());
+ if (extensionPref) {
+ Services.prefs.setStringPref(
+ "extensions.webextensions.uuids",
+ extensionPref
+ );
+ } else {
+ Services.prefs.clearUserPref("extensions.webextensions.uuids");
+ }
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js
new file mode 100644
index 0000000000..336199ee51
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js
@@ -0,0 +1,285 @@
+/* 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 tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+let browser;
+let testDocument;
+
+const waitForRender = () => {
+ return new Promise(resolve => {
+ window.requestAnimationFrame(resolve);
+ });
+};
+const getTabButton = tab => tab.shadowRoot.querySelector("button");
+/**
+ * Get the relevant elements for the tab at the given index.
+ *
+ * @param {number} tabIndex
+ * @returns {{tab: UnifiedToolbarTab, button: HTMLButtonElement, pane: HTMLElement}}
+ */
+const getTabElements = tabIndex => {
+ const tab = testDocument.querySelector(
+ `unified-toolbar-tab:nth-child(${tabIndex})`
+ );
+ const button = getTabButton(tab);
+ const pane = tab.pane;
+ return { tab, button, pane };
+};
+
+add_setup(async function () {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml",
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+ browser = tab.browser;
+ testDocument = tab.browser.contentWindow.document;
+});
+
+add_task(function test_tabElementInitialization() {
+ const activeTab = testDocument.querySelector("unified-toolbar-tab[selected]");
+ is(
+ activeTab.getAttribute("role"),
+ "presentation",
+ "The custom element is just for show"
+ );
+ ok(
+ !activeTab.hasAttribute("aria-controls"),
+ "aria-controls removed from custom element"
+ );
+ ok(activeTab.hasAttribute("selected"), "Active tab kept itself selected");
+ const tabButton = getTabButton(activeTab);
+ is(tabButton.getAttribute("role"), "tab", "Active tab is marked as tab");
+ is(tabButton.tabIndex, 0, "Active tab is in the focus ring");
+ is(
+ tabButton.getAttribute("aria-selected"),
+ "true",
+ "Tab is marked as selected"
+ );
+ ok(
+ tabButton.hasAttribute("aria-controls"),
+ "aria-controls got given to button"
+ );
+
+ const otherTab = testDocument.querySelector(
+ "unified-toolbar-tab:not([selected])"
+ );
+ is(
+ otherTab.getAttribute("role"),
+ "presentation",
+ "The custom element is just for show on the other tab"
+ );
+ ok(
+ !otherTab.hasAttribute("aria-controls"),
+ "aria-controls removed from the other tab"
+ );
+ ok(!otherTab.hasAttribute("selected"), "Other tab didn't select itself");
+ const otherButton = getTabButton(otherTab);
+ is(otherButton.getAttribute("role"), "tab", "Other tab is marked as tab");
+ is(otherButton.tabIndex, -1, "Other tab is not in the focus ring");
+ ok(
+ !otherButton.hasAttribute("aria-selected"),
+ "Other tab isn't marked as selected"
+ );
+ ok(
+ otherButton.hasAttribute("aria-controls"),
+ "aria-controls got given to other button"
+ );
+});
+
+add_task(async function test_paneGetter() {
+ const tab1 = getTabElements(1);
+ const tabPane = testDocument.getElementById("tabPane");
+ const tab2 = getTabElements(2);
+ const otherTabPane = testDocument.getElementById("otherTabPane");
+
+ is(
+ tab1.button.getAttribute("aria-controls"),
+ tabPane.id,
+ "Tab 1 controls tab 1 pane"
+ );
+ is(
+ tab2.button.getAttribute("aria-controls"),
+ otherTabPane.id,
+ "Tab 2 controls tab 2 pane"
+ );
+
+ Assert.strictEqual(
+ tab1.tab.pane,
+ tabPane,
+ "Tab 1 pane getter returns #tabPane"
+ );
+ Assert.strictEqual(
+ tab2.tab.pane,
+ otherTabPane,
+ "Tab 2 pane getter returns #otherTabPane"
+ );
+});
+
+add_task(async function test_unselect() {
+ const tab = getTabElements(1);
+
+ tab.tab.unselect();
+
+ ok(!tab.button.hasAttribute("aria-selected"), "Tab not marked as selected");
+ is(tab.button.tabIndex, -1, "Tab not in focus ring");
+ ok(!tab.tab.hasAttribute("selected"), "Tab not marked selected");
+ ok(tab.pane.hidden, "Tab pane hidden");
+});
+
+add_task(async function test_select() {
+ const tab1 = getTabElements(1);
+ const tab2 = getTabElements(2);
+
+ let tabswitchPromise = BrowserTestUtils.waitForEvent(
+ testDocument.body,
+ "tabswitch"
+ );
+ tab1.tab.select();
+
+ await tabswitchPromise;
+ ok(tab1.tab.hasAttribute("selected"), "Tab 1 selected");
+ is(
+ tab1.button.getAttribute("aria-selected"),
+ "true",
+ "Tab 1 marked as selected"
+ );
+ is(tab1.button.tabIndex, 0, "Tab 1 keyboard selectable");
+ ok(!tab1.pane.hidden, "Tab pane for tab 1 visible");
+
+ tabswitchPromise = BrowserTestUtils.waitForEvent(tab2.tab, "tabswitch");
+ tab2.tab.select();
+
+ await tabswitchPromise;
+ ok(tab2.tab.hasAttribute("selected"), "Tab 2 selected");
+ is(
+ tab2.button.getAttribute("aria-selected"),
+ "true",
+ "Tab 2 has a11y selection"
+ );
+ is(tab2.button.tabIndex, 0, "Tab 2 keyboard selectable");
+ ok(!tab2.pane.hidden, "Tab pane for tab 2 visible");
+
+ ok(!tab1.tab.hasAttribute("selected"), "Tab 1 unselected");
+ ok(!tab1.button.hasAttribute("aria-selected"), "Tab 1 marked as unselected");
+ is(tab1.button.tabIndex, -1, "Tab 1 not in focus ring");
+ ok(tab1.pane.hidden, "Tab pane for tab 1 hidden");
+});
+
+add_task(async function test_switchingTabWithMouse() {
+ const tab1 = getTabElements(1);
+ const tab2 = getTabElements(2);
+
+ tab2.button.click();
+ ok(tab2.tab.hasAttribute("selected"), "Other tab is selected");
+ is(tab2.button.tabIndex, 0, "Other tab is in focus ring");
+ ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected");
+ is(tab1.button.tabIndex, -1, "First tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab2.pane),
+ "Tab pane for selected tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden");
+
+ tab1.button.click();
+ ok(tab1.tab.hasAttribute("selected"), "First tab is selected");
+ is(tab1.button.tabIndex, 0, "First tab is in focus ring");
+ ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected");
+ is(tab2.button.tabIndex, -1, "Other tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab1.pane),
+ "Tab pane for first tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden");
+});
+
+add_task(async function test_switchingTabWithKeyboard() {
+ const tab1 = getTabElements(1);
+ const tab2 = getTabElements(2);
+
+ tab1.tab.focus();
+ is(testDocument.activeElement, tab1.tab, "Initially first tab is active");
+
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, browser);
+ is(testDocument.activeElement, tab2.tab, "Second tab is focused");
+ is(
+ tab2.tab.shadowRoot.activeElement,
+ tab2.button,
+ "Button within tab is focused"
+ );
+ await BrowserTestUtils.synthesizeKey(" ", {}, browser);
+ ok(tab2.tab.hasAttribute("selected"), "Other tab is selected");
+ is(tab2.button.tabIndex, 0, "Other tab is in focus ring");
+ ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected");
+ is(tab1.button.tabIndex, -1, "First tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab2.pane),
+ "Tab pane for selected tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden");
+
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", {}, browser);
+ is(testDocument.activeElement, tab1.tab, "Previous tab is selected");
+ await BrowserTestUtils.synthesizeKey("KEY_End", {}, browser);
+ is(testDocument.activeElement, tab2.tab, "Last tab is selected");
+ await BrowserTestUtils.synthesizeKey("KEY_Home", {}, browser);
+ is(testDocument.activeElement, tab1.tab, "First tab is selected");
+ await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser);
+ ok(tab1.tab.hasAttribute("selected"), "First tab is selected");
+ is(tab1.button.tabIndex, 0, "First tab is in focus ring");
+ ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected");
+ is(tab2.button.tabIndex, -1, "Other tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab1.pane),
+ "Tab pane for first tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden");
+});
+
+add_task(async function test_switchingTabWithKeyboardRTL() {
+ testDocument.dir = "rtl";
+ await waitForRender();
+ const tab1 = getTabElements(1);
+ const tab2 = getTabElements(2);
+
+ tab1.tab.focus();
+ is(testDocument.activeElement, tab1.tab, "Initially first tab is active");
+
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", {}, browser);
+ is(testDocument.activeElement, tab2.tab, "Second tab is selected");
+ is(
+ tab2.tab.shadowRoot.activeElement,
+ tab2.button,
+ "Button within tab is focused"
+ );
+ await BrowserTestUtils.synthesizeKey(" ", {}, browser);
+ ok(tab2.tab.hasAttribute("selected"), "Other tab is selected");
+ is(tab2.button.tabIndex, 0, "Other tab is in focus ring");
+ ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected");
+ is(tab1.button.tabIndex, -1, "First tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab2.pane),
+ "Tab pane for selected tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden");
+
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, browser);
+ is(testDocument.activeElement, tab1.tab, "Previous tab is selected");
+ await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser);
+ ok(tab1.tab.hasAttribute("selected"), "First tab is selected");
+ is(tab1.button.tabIndex, 0, "First tab is in focus ring");
+ ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected");
+ is(tab2.button.tabIndex, -1, "Other tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab1.pane),
+ "Tab pane for first tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden");
+
+ testDocument.dir = "ltr";
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml b/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml
new file mode 100644
index 0000000000..33000135b4
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta charset="utf-8" />
+ <title>Search bar element test</title>
+ <script type="module" src="chrome://messenger/content/unifiedtoolbar/search-bar.mjs"></script>
+ </head>
+ <body>
+ <template id="searchBarTemplate">
+ <form>
+ <input type="search" placeholder="" required="required"/>
+ <div aria-hidden="true"><slot name="placeholder"></slot></div>
+ <button class="button button-flat icon-button"><slot name="button"></slot></button>
+ </form>
+ </template>
+ <search-bar label="Search">
+ <span slot="placeholder">Placeholder</span>
+ <img slot="button" src="chrome://messenger/skin/icons/new/compact/search.svg" alt="Search"/>
+ </search-bar>
+ </body>
+</html>
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml b/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml
new file mode 100644
index 0000000000..f30f2b7d8b
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">
+ <head>
+ <meta charset="utf-8" />
+ <title>Search bar element test</title>
+ <script type="module" src="chrome://messenger/content/unifiedtoolbar/unified-toolbar-tab.mjs"></script>
+ </head>
+ <body>
+ <template id="unifiedToolbarTabTemplate">
+ <button role="tab">
+ <img alt="" src="" />
+ <slot></slot>
+ </button>
+ </template>
+ <div role="tablist">
+ <unified-toolbar-tab selected="true" aria-controls="tabPane">Tab Title</unified-toolbar-tab>
+ <unified-toolbar-tab aria-controls="otherTabPane">Other Tab</unified-toolbar-tab>
+ </div>
+ <div id="tabPane" role="tabpanel">Panel 1</div>
+ <div id="otherTabPane" role="tabpanel" hidden="hidden">Panel 2</div>
+ </body>
+</html>
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js b/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js
new file mode 100644
index 0000000000..0ffeefa00b
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js
@@ -0,0 +1,40 @@
+/* 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 { BUTTON_STYLE_MAP, BUTTON_STYLE_PREF } = ChromeUtils.importESModule(
+ "resource:///modules/ButtonStyle.mjs"
+);
+
+add_task(function test_buttonStyleMap() {
+ Assert.ok(Array.isArray(BUTTON_STYLE_MAP), "BUTTON_STYLE_MAP is an array");
+ Assert.ok(
+ BUTTON_STYLE_MAP.every(style => typeof style === "string"),
+ "All entries in the style map should be strings"
+ );
+ for (const style of BUTTON_STYLE_MAP) {
+ Assert.stringMatches(
+ style,
+ /[a-z-]/,
+ "Button style class should be formatted in kebab case"
+ );
+ }
+});
+
+add_task(function test_buttonStylePref() {
+ Assert.equal(
+ typeof BUTTON_STYLE_PREF,
+ "string",
+ "BUTTON_STYLE_PREF is a string"
+ );
+ const prefValue = Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0);
+ Assert.ok(
+ Number.isInteger(prefValue),
+ "BUTTON_STYLE_PREF pref should hold an integer"
+ );
+ Assert.less(
+ prefValue,
+ BUTTON_STYLE_MAP.length,
+ "Value of BUTTON_STYLE_PREF should be within map"
+ );
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js
new file mode 100644
index 0000000000..55d4f5ba91
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.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/. */
+
+const {
+ getAvailableItemIdsForSpace,
+ getDefaultItemIdsForSpace,
+ MULTIPLE_ALLOWED_ITEM_IDS,
+ SKIP_FOCUS_ITEM_IDS,
+} = ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");
+
+const { default: CUSTOMIZABLE_ITEMS } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItemsDetails.mjs"
+);
+
+add_task(function test_getAvailableItemIdsForSpace_anySpace() {
+ const itemsForAnySpace = getAvailableItemIdsForSpace();
+ Assert.ok(Array.isArray(itemsForAnySpace), "returns an array");
+ for (const itemId of itemsForAnySpace) {
+ Assert.equal(typeof itemId, "string", `item ID "${itemId}" is string`);
+ Assert.greater(itemId.length, 0, `item ID is not empty`);
+ }
+});
+
+add_task(function test_getAvailableItemIdsForSpace_emptySpace() {
+ const itemsForEmptySpace = getAvailableItemIdsForSpace("test");
+ Assert.deepEqual(itemsForEmptySpace, [], "Empty array for empty space");
+});
+
+add_task(function test_getAvailableItemIdsForSpace_includingAgnostic() {
+ const items = getAvailableItemIdsForSpace("mail", true);
+ const itemsForAnySpace = getAvailableItemIdsForSpace();
+ const itemsForMailSpace = getAvailableItemIdsForSpace("mail");
+
+ Assert.ok(
+ itemsForAnySpace.every(itemId => items.includes(itemId)),
+ "All space agnostic items are included"
+ );
+
+ Assert.ok(
+ itemsForMailSpace.every(itemId => items.includes(itemId)),
+ "All mail space items are included"
+ );
+});
+
+add_task(function test_getDefaultItemIdsForSpace_default() {
+ const items = getDefaultItemIdsForSpace("default");
+
+ Assert.ok(Array.isArray(items), "Should return an array");
+ Assert.deepEqual(
+ items,
+ ["spacer", "search-bar", "spacer"],
+ "Default space should contain the default item set"
+ );
+});
+
+add_task(function test_getDefaultItemIdsForSpace_cloningArray() {
+ const items1 = getDefaultItemIdsForSpace("default");
+ const items2 = getDefaultItemIdsForSpace("default");
+ const items3 = getDefaultItemIdsForSpace("mail");
+
+ Assert.notStrictEqual(
+ items1,
+ items2,
+ "The default sets should be different array instances"
+ );
+ Assert.notStrictEqual(
+ items2,
+ items3,
+ "The second default set an mail space should be different array instances"
+ );
+ Assert.notStrictEqual(
+ items3,
+ items1,
+ "The mail space and first default set should be different array instances"
+ );
+
+ Assert.deepEqual(
+ items1,
+ items2,
+ "The two default pseudospace sets should contain the same items"
+ );
+});
+
+add_task(function test_multipleAllowedItemIds() {
+ Assert.equal(
+ typeof MULTIPLE_ALLOWED_ITEM_IDS.has,
+ "function",
+ "Multiple allowed item IDs should be set-like"
+ );
+ Assert.ok(
+ Array.from(MULTIPLE_ALLOWED_ITEM_IDS).every(
+ itemId => typeof itemId === "string"
+ ),
+ "Every item in the set should be a string"
+ );
+ for (const item of CUSTOMIZABLE_ITEMS) {
+ Assert.equal(
+ MULTIPLE_ALLOWED_ITEM_IDS.has(item.id),
+ Boolean(item.allowMultiple),
+ `Set's state should matche the allowMultiple value of ${item.allowMultiple} for ${item.id}`
+ );
+ }
+});
+
+add_task(function test_skipFocusItemIds() {
+ Assert.equal(
+ typeof SKIP_FOCUS_ITEM_IDS.has,
+ "function",
+ "Skip focus item IDs should be set-like"
+ );
+ Assert.ok(
+ Array.from(SKIP_FOCUS_ITEM_IDS).every(itemId => typeof itemId === "string"),
+ "Every item in the set should be a string"
+ );
+ for (const item of CUSTOMIZABLE_ITEMS) {
+ Assert.equal(
+ SKIP_FOCUS_ITEM_IDS.has(item.id),
+ Boolean(item.skipFocus),
+ `Set's state should match the skipFocus value of ${item.skipFocus} for ${item.id}`
+ );
+ }
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js
new file mode 100644
index 0000000000..474e5483ce
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js
@@ -0,0 +1,103 @@
+/* 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 { default: CUSTOMIZABLE_ITEMS } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItemsDetails.mjs"
+);
+
+add_task(function test_format() {
+ for (const item of CUSTOMIZABLE_ITEMS) {
+ Assert.equal(typeof item, "object", "Customizable item is an object");
+ Assert.equal(typeof item.id, "string", `id "${item.id}" is a string`);
+ Assert.ok(!item.id.includes(","), `id "${item.id}" may not contain commas`);
+ Assert.greater(item.id.length, 0, `id "${item.id}" is not empty`);
+ Assert.equal(
+ typeof item.labelId,
+ "string",
+ `labelId is a string for ${item.id}`
+ );
+ Assert.greater(
+ item.labelId.length,
+ 0,
+ `labelId is not empty for ${item.id}`
+ );
+ Assert.ok(
+ !item.allowMultiple || item.allowMultiple === true,
+ `allowMultiple is falsy or boolean for ${item.id}`
+ );
+ Assert.ok(
+ item.spaces === undefined || Array.isArray(item.spaces),
+ `spaces is undefined or an array for ${item.id}`
+ );
+ if (item.spaces) {
+ for (const space of item.spaces) {
+ Assert.equal(
+ typeof space,
+ "string",
+ `space "${space}" expected to be string for ${item.id}`
+ );
+ Assert.greater(
+ space.length,
+ 0,
+ `space is not empty in ${item.id} spaces`
+ );
+ }
+ }
+ Assert.ok(
+ item.templateId === undefined || typeof item.templateId === "string",
+ `templateId must be undefined or a string for ${item.id}`
+ );
+ if (item.templateId !== undefined) {
+ Assert.greater(
+ item.templateId.length,
+ 0,
+ `templateId is not empty for ${item.id}`
+ );
+ Assert.ok(
+ item.requiredModules === undefined ||
+ Array.isArray(item.requiredModules),
+ `requiredModules is undefined or an array for ${item.id}`
+ );
+ if (item.requiredModules) {
+ for (const module of item.requiredModules) {
+ Assert.equal(
+ typeof module,
+ "string",
+ `module "${module}" expected to be string for ${item.id}`
+ );
+ Assert.greater(
+ module.length,
+ 0,
+ `module is not empty in ${item.id} requiredModules`
+ );
+ }
+ }
+ } else {
+ Assert.strictEqual(
+ item.requiredModules,
+ undefined,
+ `requiredModules must not be set because there is no template for item ${item.id}`
+ );
+ }
+ Assert.ok(
+ item.hasContextMenu === undefined ||
+ typeof item.hasContextMenu === "boolean",
+ `hasContextMenu must be undefined or a boolean for ${item.id}`
+ );
+ Assert.ok(
+ item.skipFocus === undefined || typeof item.skipFocus === "boolean",
+ `skipFocus must be undefined or a boolean for ${item.id}`
+ );
+ }
+});
+
+add_task(function test_idsUnique() {
+ const allIds = CUSTOMIZABLE_ITEMS.map(item => item.id);
+ const idCounts = allIds.reduce((counts, id) => {
+ counts[id] = counts[id] ? counts[id] + 1 : 1;
+ return counts;
+ }, {});
+ const duplicateIds = Object.keys(idCounts).filter(id => idCounts[id] > 1);
+ Assert.deepEqual(duplicateIds, [], "All IDs should only be used once");
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js
new file mode 100644
index 0000000000..048b5c5cde
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js
@@ -0,0 +1,64 @@
+/* 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 { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+const { storeState, getState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+
+add_setup(function () {
+ // Ensure xulStore has a profile to refer to.
+ do_get_profile();
+});
+
+add_task(function test_getState_empty() {
+ const state = getState();
+ Assert.equal(typeof state, "object", "State should be an object");
+ Assert.deepEqual(state, {}, "Empty state should be an empty object");
+});
+
+add_task(async function test_storeState_observer() {
+ const stateChangeObserved = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ storeState({
+ mail: ["write-message", "spacer", "search-bar", "spacer"],
+ });
+ await stateChangeObserved;
+});
+
+add_task(function test_storeState_getState() {
+ const state = {
+ mail: ["write-message", "spacer", "search-bar", "spacer"],
+ calendar: [],
+ };
+ const previousState = getState();
+ Assert.notDeepEqual(
+ previousState,
+ state,
+ "Current state should be different from the state to write"
+ );
+ storeState(state);
+ const newState = getState();
+ Assert.deepEqual(
+ newState,
+ state,
+ "State loaded should matche the stored state"
+ );
+ Assert.notStrictEqual(
+ newState,
+ state,
+ "State loaded should not be the same object as what was saved"
+ );
+});
+
+registerCleanupFunction(() => {
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "unifiedToolbar",
+ "state"
+ );
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js b/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js
new file mode 100644
index 0000000000..637d40e066
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js
@@ -0,0 +1,431 @@
+/* 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 { migrateToolbarForSpace, clearXULToolbarState } =
+ ChromeUtils.importESModule("resource:///modules/ToolbarMigration.sys.mjs");
+const { getState, storeState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml";
+
+function setXULToolbarState(
+ currentSet = "",
+ defaultSet = "",
+ toolbarId = "mail-bar3"
+) {
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ toolbarId,
+ "currentset",
+ currentSet
+ );
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ toolbarId,
+ "defaultset",
+ defaultSet
+ );
+}
+
+add_setup(() => {
+ do_get_profile();
+ storeState({});
+});
+
+add_task(function test_migration_customized() {
+ setXULToolbarState(
+ "button-getmsg,button-newmsg,button-reply,spacer,qfb-show-filter-bar,button-file,folder-location-container,spring,gloda-search,button-appmenu"
+ );
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "",
+ "toolbar-menubar"
+ );
+ setXULToolbarState("button-delete", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.mail,
+ [
+ "get-messages",
+ "write-message",
+ "reply",
+ "spacer",
+ "quick-filter-bar",
+ "move-to",
+ "folder-location",
+ "spacer",
+ "search-bar",
+ "spacer",
+ "add-ons-and-themes",
+ "delete",
+ ],
+ "Items were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_migration_defaults() {
+ setXULToolbarState();
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.ok(!newState.mail, "New default state was preserved");
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_migration_empty() {
+ setXULToolbarState("__empty");
+ setXULToolbarState("__empty", "menubar-items,spring", "toolbar-menubar");
+ setXULToolbarState("__empty", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.deepEqual(newState.mail, [], "The toolbar contents were emptied");
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_migration_noop() {
+ const state = { mail: ["spacer", "search-bar", "spacer"] };
+ storeState(state);
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.deepEqual(newState, state, "Customization state is not modified");
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_calendar_migration() {
+ setXULToolbarState(
+ "calendar-synchronize-button,calendar-newevent-button,separator,calendar-edit-button,calendar-delete-button,spring,calendar-unifinder-button,calendar-appmenu-button",
+ "",
+ "calendar-toolbar2"
+ );
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "",
+ "toolbar-menubar"
+ );
+ setXULToolbarState("button-delete", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("calendar");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.calendar,
+ [
+ "synchronize",
+ "new-event",
+ "edit-event",
+ "delete-event",
+ "spacer",
+ "unifinder",
+ "spacer",
+ "add-ons-and-themes",
+ ],
+ "Items were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "calendar-toolbar2",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "calendar-toolbar2",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_calendar_migration_defaults() {
+ setXULToolbarState("", "", "calendar-toolbar2");
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("calendar");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.calendar,
+ [
+ "synchronize",
+ "new-event",
+ "new-task",
+ "edit-event",
+ "delete-event",
+ "spacer",
+ ],
+ "Default states were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "calendar-toolbar2",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "calendar-toolbar2",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_tasks_migration() {
+ setXULToolbarState(
+ "task-synchronize-button,task-newtask-button,task-edit-button,task-delete-button,task-print-button,spring,task-appmenu-button",
+ "",
+ "task-toolbar2"
+ );
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "",
+ "toolbar-menubar"
+ );
+ setXULToolbarState("button-delete", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("tasks");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.tasks,
+ [
+ "synchronize",
+ "new-task",
+ "edit-event",
+ "delete-event",
+ "print-event",
+ "spacer",
+ "add-ons-and-themes",
+ ],
+ "Items were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "task-toolbar2",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "task-toolbar2",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_tasks_migration_defaults() {
+ setXULToolbarState("", "", "task-toolbar2");
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("tasks");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.tasks,
+ [
+ "synchronize",
+ "new-event",
+ "new-task",
+ "edit-event",
+ "delete-event",
+ "spacer",
+ ],
+ "Default states were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "task-toolbar2",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "task-toolbar2",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_global_items_migration() {
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "",
+ "toolbar-menubar"
+ );
+ setXULToolbarState("button-delete", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("settings");
+
+ const newState = getState();
+
+ Assert.deepEqual(newState.settings, [
+ "spacer",
+ "search-bar",
+ "spacer",
+ "add-ons-and-themes",
+ ]);
+
+ storeState({});
+});
+
+add_task(function test_global_items_migration_defaults() {
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("settings");
+
+ const newState = getState();
+
+ Assert.deepEqual(newState.settings, ["spacer", "search-bar", "spacer"]);
+
+ storeState({});
+});
+
+add_task(function test_clear_xul_toolbar_state() {
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "menubar-items,spring",
+ "toolbar-menubar"
+ );
+
+ clearXULToolbarState("toolbar-menubar");
+
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "toolbar-menubar",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "toolbar-menubar",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+});
+
+add_task(function test_migration_defaults_with_extension() {
+ setXULToolbarState(
+ AppConstants.platform == "macosx"
+ ? "button-getmsg,button-newmsg,button-tag,qfb-show-filter-bar,spring,gloda-search,extension1,extension2,button-appmenu"
+ : "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,extension1,extension2,button-appmenu"
+ );
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ "mail-bar3",
+ "extensionset",
+ "extension1,extension2"
+ );
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.ok(!newState.mail, "New default state was preserved");
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "extensionset"),
+ "Old toolbar extension state is cleared"
+ );
+
+ storeState({});
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini b/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..ec9807b399
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+head =
+
+[test_buttonStyle.js]
+[test_customizableItems.js]
+[test_customizableItemsDetails.js]
+[test_customizationState.js]
+[test_toolbarMigration.js]
diff --git a/comm/mail/config/mozconfigs/common b/comm/mail/config/mozconfigs/common
new file mode 100644
index 0000000000..3017815b5a
--- /dev/null
+++ b/comm/mail/config/mozconfigs/common
@@ -0,0 +1,3 @@
+ac_add_options --enable-project=comm/mail
+
+. "$topsrcdir/build/mozconfig.common.override"
diff --git a/comm/mail/config/mozconfigs/l10n-common b/comm/mail/config/mozconfigs/l10n-common
new file mode 100644
index 0000000000..151ac3f02d
--- /dev/null
+++ b/comm/mail/config/mozconfigs/l10n-common
@@ -0,0 +1,12 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+
+ac_add_options --enable-official-branding
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --disable-nodejs
+. "$topsrcdir/build/mozconfig.no-compile"
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+ac_add_options --with-l10n-base="${MOZ_FETCHES_DIR}/l10n-central"
diff --git a/comm/mail/config/mozconfigs/linux32/common-linux32 b/comm/mail/config/mozconfigs/linux32/common-linux32
new file mode 100644
index 0000000000..a95b77b46f
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux32/common-linux32
@@ -0,0 +1,12 @@
+# This file is sourced by the nightly, debug, and release mozconfigs.
+. "$topsrcdir/build/unix/mozconfig.linux32"
+
+ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL}
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell.
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/linux32/debug b/comm/mail/config/mozconfigs/linux32/debug
new file mode 100644
index 0000000000..08f3610baf
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux32/debug
@@ -0,0 +1,4 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/linux32/common-linux32"
+
+ac_add_options --enable-debug
diff --git a/comm/mail/config/mozconfigs/linux32/l10n-mozconfig b/comm/mail/config/mozconfigs/linux32/l10n-mozconfig
new file mode 100644
index 0000000000..56ebfc01a8
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux32/l10n-mozconfig
@@ -0,0 +1,6 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/l10n-common"
+
+if test `uname -m` = "x86_64"; then
+ ac_add_options --target=i686-pc-linux
+ ac_add_options --host=i686-pc-linux
+fi
diff --git a/comm/mail/config/mozconfigs/linux32/nightly b/comm/mail/config/mozconfigs/linux32/nightly
new file mode 100644
index 0000000000..213649c860
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux32/nightly
@@ -0,0 +1,2 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/linux32/common-linux32"
diff --git a/comm/mail/config/mozconfigs/linux32/release b/comm/mail/config/mozconfigs/linux32/release
new file mode 100644
index 0000000000..17ac4c3276
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux32/release
@@ -0,0 +1,3 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/linux32/common-linux32"
+
+ac_add_options --enable-official-branding
diff --git a/comm/mail/config/mozconfigs/linux64-aarch64/common b/comm/mail/config/mozconfigs/linux64-aarch64/common
new file mode 100644
index 0000000000..8d7f17fef7
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64-aarch64/common
@@ -0,0 +1,19 @@
+# This file is sourced by the nightly, debug, and release mozconfigs.
+. "$topsrcdir/build/unix/mozconfig.linux"
+
+ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL}
+
+. $topsrcdir/build/unix/mozconfig.linux
+
+unset NASM
+ac_add_options --target=aarch64
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell.
+export MOZ_PACKAGE_JSSHELL=1
+
+unset MOZ_STDCXX_COMPAT
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/linux64-aarch64/opt b/comm/mail/config/mozconfigs/linux64-aarch64/opt
new file mode 100644
index 0000000000..5579142578
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64-aarch64/opt
@@ -0,0 +1,2 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64-aarch64/common"
diff --git a/comm/mail/config/mozconfigs/linux64/code-coverage b/comm/mail/config/mozconfigs/linux64/code-coverage
new file mode 100644
index 0000000000..d072916ff5
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/code-coverage
@@ -0,0 +1,16 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/nightly"
+
+ac_add_options --disable-install-strip
+ac_add_options --disable-elf-hack
+ac_add_options --disable-sandbox
+ac_add_options --disable-dmd
+ac_add_options --disable-profiling
+ac_add_options --disable-warnings-as-errors
+ac_add_options --without-wasm-sandboxed-libraries
+ac_add_options --enable-coverage
+
+CLANG_LIB_DIR="$(cd $MOZ_FETCHES_DIR/clang/lib/clang/* && cd lib/linux && pwd)"
+export LDFLAGS="--coverage -L$CLANG_LIB_DIR"
+export LIBS="-lclang_rt.profile-x86_64"
+export RUSTFLAGS="-Ccodegen-units=1 -Zprofile -Cpanic=abort -Zpanic_abort_tests -Clink-dead-code -Coverflow-checks=off"
+export RUSTDOCFLAGS="-Cpanic=abort"
diff --git a/comm/mail/config/mozconfigs/linux64/code-coverage-debug b/comm/mail/config/mozconfigs/linux64/code-coverage-debug
new file mode 100644
index 0000000000..f362f2ba41
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/code-coverage-debug
@@ -0,0 +1,9 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/code-coverage"
+
+ac_add_options --enable-debug=-g1
+ac_add_options --enable-rust-tests
+
+# https://bugs.llvm.org/show_bug.cgi?id=49226
+# LLD 12.0.0 had a behavior change that breaks coverage builds.
+# Passing --no-fortran-common restores the old behavior.
+export LDFLAGS="$LDFLAGS -Wl,--no-fortran-common"
diff --git a/comm/mail/config/mozconfigs/linux64/code-coverage-opt b/comm/mail/config/mozconfigs/linux64/code-coverage-opt
new file mode 100644
index 0000000000..427eeaaf3b
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/code-coverage-opt
@@ -0,0 +1,9 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/code-coverage"
+
+ac_add_options --enable-debug-symbols=-g1
+ac_add_options --enable-rust-tests
+
+# https://bugs.llvm.org/show_bug.cgi?id=49226
+# LLD 12.0.0 had a behavior change that breaks coverage builds.
+# Passing --no-fortran-common restores the old behavior.
+export LDFLAGS="$LDFLAGS -Wl,--no-fortran-common"
diff --git a/comm/mail/config/mozconfigs/linux64/common-linux64 b/comm/mail/config/mozconfigs/linux64/common-linux64
new file mode 100644
index 0000000000..14f08de4d4
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/common-linux64
@@ -0,0 +1,12 @@
+# This file is sourced by the nightly, debug, and release mozconfigs.
+. "$topsrcdir/build/unix/mozconfig.linux"
+
+ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL}
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell.
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/linux64/debug b/comm/mail/config/mozconfigs/linux64/debug
new file mode 100644
index 0000000000..ce0b53d48d
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/debug
@@ -0,0 +1,4 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/common-linux64"
+
+ac_add_options --enable-debug
diff --git a/comm/mail/config/mozconfigs/linux64/debug-searchfox-clang b/comm/mail/config/mozconfigs/linux64/debug-searchfox-clang
new file mode 100644
index 0000000000..d106cf76fa
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/debug-searchfox-clang
@@ -0,0 +1,14 @@
+MOZ_AUTOMATION_BUILD_SYMBOLS=0
+MOZ_AUTOMATION_CHECK=0
+
+. "$topsrcdir/build/unix/mozconfig.linux"
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+
+ac_add_options --enable-debug
+
+# Add the static checker
+ac_add_options --enable-clang-plugin
+ac_add_options --enable-mozsearch-plugin
+
+# Disable enforcing that add-ons are signed by the trusted root
+MOZ_REQUIRE_ADDON_SIGNING=0
diff --git a/comm/mail/config/mozconfigs/linux64/l10n-mozconfig b/comm/mail/config/mozconfigs/linux64/l10n-mozconfig
new file mode 100644
index 0000000000..a370f145f0
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/l10n-mozconfig
@@ -0,0 +1 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/l10n-common"
diff --git a/comm/mail/config/mozconfigs/linux64/nightly b/comm/mail/config/mozconfigs/linux64/nightly
new file mode 100644
index 0000000000..25616f9ff8
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/nightly
@@ -0,0 +1,2 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/common-linux64"
diff --git a/comm/mail/config/mozconfigs/linux64/nightly-asan b/comm/mail/config/mozconfigs/linux64/nightly-asan
new file mode 100644
index 0000000000..1e2faf5767
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/nightly-asan
@@ -0,0 +1,23 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/common-linux64"
+
+# We still need to build with debug symbols
+ac_add_options --disable-debug
+ac_add_options --enable-optimize="-O2 -gline-tables-only"
+
+# ASan specific options on Linux
+ac_add_options --enable-valgrind
+
+. "$topsrcdir/build/unix/mozconfig.asan"
+ac_add_options --disable-elf-hack
+
+# Piggybacking UBSan for now since only a small subset of checks are enabled.
+# A new build can be created when appropriate.
+ac_add_options --enable-undefined-sanitizer
+
+# Need this to prevent name conflicts with the normal nightly build packages
+export MOZ_PKG_SPECIAL=asan
+
+# Disable telemetry
+ac_add_options MOZ_TELEMETRY_REPORTING=
+
diff --git a/comm/mail/config/mozconfigs/linux64/nightly-asan-reporter b/comm/mail/config/mozconfigs/linux64/nightly-asan-reporter
new file mode 100644
index 0000000000..c38fa607a4
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/nightly-asan-reporter
@@ -0,0 +1,18 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/common-linux64"
+
+# We still need to build with debug symbols
+ac_add_options --disable-debug
+ac_add_options --enable-optimize="-O2 -gline-tables-only"
+
+# ASan specific options on Linux
+ac_add_options --enable-valgrind
+
+. $topsrcdir/build/unix/mozconfig.asan
+ac_add_options --disable-elf-hack
+
+ac_add_options --enable-address-sanitizer-reporter
+
+# Need this to prevent name conflicts with the normal nightly build packages
+export MOZ_PKG_SPECIAL=asan-reporter
+
+. "$topsrcdir/build/mozconfig.common.override"
diff --git a/comm/mail/config/mozconfigs/linux64/release b/comm/mail/config/mozconfigs/linux64/release
new file mode 100644
index 0000000000..65ab47554f
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/release
@@ -0,0 +1,3 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/common-linux64"
+
+ac_add_options --enable-official-branding
diff --git a/comm/mail/config/mozconfigs/linux64/source b/comm/mail/config/mozconfigs/linux64/source
new file mode 100644
index 0000000000..e4095057f0
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/source
@@ -0,0 +1,11 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+
+# The source "build" only needs a mozconfig because we use the build system as
+# our script for generating it. This allows us to run configure without any
+# extra dependencies on specific toolchains, e.g. gtk3.
+. "$topsrcdir/build/mozconfig.no-compile"
+
+# mozconfig.no-compile does not remove nodejs requirement.
+# The source mozconfig for FF includes the following:
+ac_add_options --disable-nodejs
+unset NODEJS
diff --git a/comm/mail/config/mozconfigs/linux64/tsan b/comm/mail/config/mozconfigs/linux64/tsan
new file mode 100644
index 0000000000..2373188558
--- /dev/null
+++ b/comm/mail/config/mozconfigs/linux64/tsan
@@ -0,0 +1,22 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/linux64/common-linux64"
+
+# We still need to build with debug symbols
+ac_add_options --disable-debug
+ac_add_options --enable-optimize="-O2 -gline-tables-only"
+
+. $topsrcdir/build/unix/mozconfig.linux
+. $topsrcdir/build/unix/mozconfig.tsan
+ac_add_options --disable-elf-hack
+
+# Need this to prevent name conflicts with the normal nightly build packages
+export MOZ_PKG_SPECIAL=tsan
+
+# Disable telemetry
+ac_add_options MOZ_TELEMETRY_REPORTING=
+
+# rustfmt is currently missing in Rust nightly
+unset RUSTFMT
+
+# Current Rust Nightly has warnings
+ac_add_options --disable-warnings-as-errors
diff --git a/comm/mail/config/mozconfigs/macosx64-aarch64/common-opt b/comm/mail/config/mozconfigs/macosx64-aarch64/common-opt
new file mode 100644
index 0000000000..f1f39b7c53
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64-aarch64/common-opt
@@ -0,0 +1,21 @@
+# This file is sourced by the nightly and release mozconfigs.
+
+. $topsrcdir/build/macosx/mozconfig.common
+
+ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL}
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell.
+export MOZ_PACKAGE_JSSHELL=1
+
+ac_add_options --target=aarch64-apple-darwin
+
+# As of Clang 13, the default is -mcpu=apple-m1 when using a aarch64-apple-macos target,
+# but we're using apple64-apple-darwin, which defaults to -mcpu=apple-a7, which disables
+# a bunch of # performance-enabling CPU features.
+# TODO: We'll want to switch to aarch64-apple-macos eventually.
+export CFLAGS="$CFLAGS -mcpu=apple-m1"
+export CXXFLAGS="$CXXFLAGS -mcpu=apple-m1"
+
diff --git a/comm/mail/config/mozconfigs/macosx64-aarch64/nightly b/comm/mail/config/mozconfigs/macosx64-aarch64/nightly
new file mode 100644
index 0000000000..deaffb107e
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64-aarch64/nightly
@@ -0,0 +1,12 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/macosx64-aarch64/common-opt"
+
+# Cross-compiled builds fail when dtrace is enabled
+if test `uname -s` != Linux; then
+ ac_add_options --enable-dtrace
+fi
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --enable-profiling
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/macosx64-aarch64/release b/comm/mail/config/mozconfigs/macosx64-aarch64/release
new file mode 100644
index 0000000000..934b4afd01
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64-aarch64/release
@@ -0,0 +1,7 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/macosx64-aarch64/common-opt"
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --enable-official-branding
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/macosx64/common-opt b/comm/mail/config/mozconfigs/macosx64/common-opt
new file mode 100644
index 0000000000..2983e69a64
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64/common-opt
@@ -0,0 +1,9 @@
+# This file is sourced by the nightly, and release mozconfigs.
+
+. $topsrcdir/build/macosx/mozconfig.common
+
+ # Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell.
+export MOZ_PACKAGE_JSSHELL=1
diff --git a/comm/mail/config/mozconfigs/macosx64/debug b/comm/mail/config/mozconfigs/macosx64/debug
new file mode 100644
index 0000000000..3e1e712183
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64/debug
@@ -0,0 +1,12 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/build/macosx/mozconfig.common"
+
+ac_add_options --enable-debug
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/macosx64/debug-searchfox b/comm/mail/config/mozconfigs/macosx64/debug-searchfox
new file mode 100644
index 0000000000..dc24864212
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64/debug-searchfox
@@ -0,0 +1,13 @@
+MOZ_AUTOMATION_BUILD_SYMBOLS=0
+MOZ_AUTOMATION_CHECK=0
+
+. "$topsrcdir/build/macosx/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+
+ac_add_options --enable-debug
+
+ac_add_options --enable-clang-plugin
+ac_add_options --enable-mozsearch-plugin
+
+# Disable enforcing that add-ons are signed by the trusted root
+MOZ_REQUIRE_ADDON_SIGNING=0
diff --git a/comm/mail/config/mozconfigs/macosx64/l10n-mozconfig b/comm/mail/config/mozconfigs/macosx64/l10n-mozconfig
new file mode 100644
index 0000000000..d4fe5a2b15
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64/l10n-mozconfig
@@ -0,0 +1,9 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/l10n-common"
+
+if test `uname -s` = "Linux"; then
+ # We need to indicate the target for cross builds
+ ac_add_options --target=x86_64-apple-darwin
+ export MKFSHFS=$MOZ_FETCHES_DIR/hfsplus/newfs_hfs
+ export DMG_TOOL=$MOZ_FETCHES_DIR/dmg/dmg
+ export HFS_TOOL=$MOZ_FETCHES_DIR/dmg/hfsplus
+fi
diff --git a/comm/mail/config/mozconfigs/macosx64/nightly b/comm/mail/config/mozconfigs/macosx64/nightly
new file mode 100644
index 0000000000..9923e20766
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64/nightly
@@ -0,0 +1,12 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/macosx64/common-opt"
+
+# Cross-compiled builds fail when dtrace is enabled
+if test `uname -s` != Linux; then
+ ac_add_options --enable-dtrace
+fi
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --enable-profiling
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/macosx64/release b/comm/mail/config/mozconfigs/macosx64/release
new file mode 100644
index 0000000000..d4c59231ff
--- /dev/null
+++ b/comm/mail/config/mozconfigs/macosx64/release
@@ -0,0 +1,7 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/comm/mail/config/mozconfigs/macosx64/common-opt"
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --enable-official-branding
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/win32/common-win32 b/comm/mail/config/mozconfigs/win32/common-win32
new file mode 100644
index 0000000000..9bdeff6470
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win32/common-win32
@@ -0,0 +1,19 @@
+# This file is used by all Win32 builds
+
+ac_add_options --target=i686-pc-windows-msvc
+
+. $topsrcdir/build/win32/mozconfig.vs-latest
+
+if test `uname -s` = Linux; then
+
+# Configure expects executables for check_prog, so set the relevant files
+# as executable on the first evaluation of the mozconfig where they exist.
+export UPX="${MOZ_FETCHES_DIR}/upx-3.95-win64/upx.exe"
+if [ -f "${UPX}" ]; then
+ chmod +x "${UPX}"
+fi
+mk_add_options "export PATH=${VC_PATH}/bin/hostx64/x86:${PATH}"
+
+unset VC_PATH
+
+fi
diff --git a/comm/mail/config/mozconfigs/win32/debug b/comm/mail/config/mozconfigs/win32/debug
new file mode 100644
index 0000000000..6d62458c30
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win32/debug
@@ -0,0 +1,18 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win32/common-win32"
+
+ac_add_options --enable-debug
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+
+if test -n "$USE_ARTIFACT"; then
+ac_add_options --disable-mapi
+fi
diff --git a/comm/mail/config/mozconfigs/win32/l10n-mozconfig b/comm/mail/config/mozconfigs/win32/l10n-mozconfig
new file mode 100644
index 0000000000..df5dac68da
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win32/l10n-mozconfig
@@ -0,0 +1,2 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/win32/common-win32"
+. "$topsrcdir/comm/mail/config/mozconfigs/l10n-common"
diff --git a/comm/mail/config/mozconfigs/win32/nightly b/comm/mail/config/mozconfigs/win32/nightly
new file mode 100644
index 0000000000..cee1678539
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win32/nightly
@@ -0,0 +1,19 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win32/common-win32"
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --enable-profiling
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+
+if test -n "$USE_ARTIFACT"; then
+ac_add_options --disable-mapi
+fi
diff --git a/comm/mail/config/mozconfigs/win32/release b/comm/mail/config/mozconfigs/win32/release
new file mode 100644
index 0000000000..db9d4c80e8
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win32/release
@@ -0,0 +1,15 @@
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win32/common-win32"
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --enable-official-branding
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+
+# Package js shell
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/win64/common-win64 b/comm/mail/config/mozconfigs/win64/common-win64
new file mode 100644
index 0000000000..1ee612909c
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/common-win64
@@ -0,0 +1,19 @@
+# This file is used by all Win64 builds
+
+ac_add_options --target=x86_64-pc-windows-msvc
+
+. $topsrcdir/build/win64/mozconfig.vs-latest
+
+if test `uname -s` = Linux; then
+
+# Configure expects executables for check_prog, so set the relevant files
+# as executable on the first evaluation of the mozconfig where they exist.
+export UPX="${MOZ_FETCHES_DIR}/upx-3.95-win64/upx.exe"
+if [ -f "${UPX}" ]; then
+ chmod +x "${UPX}"
+fi
+mk_add_options "export PATH=${VC_PATH}/bin/hostx64/x64:${PATH}"
+
+unset VC_PATH
+
+fi
diff --git a/comm/mail/config/mozconfigs/win64/debug b/comm/mail/config/mozconfigs/win64/debug
new file mode 100644
index 0000000000..113aac0d70
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/debug
@@ -0,0 +1,18 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win64/common-win64"
+
+ac_add_options --enable-debug
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+
+if test -n "$USE_ARTIFACT"; then
+ac_add_options --disable-mapi
+fi
diff --git a/comm/mail/config/mozconfigs/win64/debug-searchfox b/comm/mail/config/mozconfigs/win64/debug-searchfox
new file mode 100644
index 0000000000..d8ca3d7582
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/debug-searchfox
@@ -0,0 +1,16 @@
+MOZ_AUTOMATION_BUILD_SYMBOLS=0
+MOZ_AUTOMATION_CHECK=0
+
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win64/common-win64"
+
+ac_add_options --enable-optimize
+ac_add_options --enable-debug
+
+ac_add_options --enable-clang-plugin
+ac_add_options --enable-mozsearch-plugin
+
+# Disable enforcing that add-ons are signed by the trusted root
+MOZ_REQUIRE_ADDON_SIGNING=0
diff --git a/comm/mail/config/mozconfigs/win64/l10n-mozconfig b/comm/mail/config/mozconfigs/win64/l10n-mozconfig
new file mode 100644
index 0000000000..317f72a3e1
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/l10n-mozconfig
@@ -0,0 +1,2 @@
+. "$topsrcdir/comm/mail/config/mozconfigs/win64/common-win64"
+. "$topsrcdir/comm/mail/config/mozconfigs/l10n-common"
diff --git a/comm/mail/config/mozconfigs/win64/nightly b/comm/mail/config/mozconfigs/win64/nightly
new file mode 100644
index 0000000000..230d6d291e
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/nightly
@@ -0,0 +1,20 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win64/common-win64"
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --enable-profiling
+
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
+
+if test -n "$USE_ARTIFACT"; then
+ac_add_options --disable-mapi
+fi
diff --git a/comm/mail/config/mozconfigs/win64/nightly-asan b/comm/mail/config/mozconfigs/win64/nightly-asan
new file mode 100644
index 0000000000..8928b540fb
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/nightly-asan
@@ -0,0 +1,17 @@
+. "$topsrcdir/comm/build/mozconfig.comm-sccache"
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win64/common-win64"
+
+ac_add_options --disable-debug
+ac_add_options --enable-optimize="-O2 -gline-tables-only"
+
+. "$topsrcdir/build/win64/mozconfig.asan"
+
+export MOZ_PACKAGE_JSSHELL=1
+export MOZ_PKG_SPECIAL=asan
+
+# Disable telemetry
+ac_add_options MOZ_TELEMETRY_REPORTING=
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/win64/nightly-asan-reporter b/comm/mail/config/mozconfigs/win64/nightly-asan-reporter
new file mode 100644
index 0000000000..9ee02c58f4
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/nightly-asan-reporter
@@ -0,0 +1,20 @@
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win64/common-win64"
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+
+export MOZILLA_OFFICIAL=1
+
+ac_add_options --disable-debug
+ac_add_options --enable-optimize="-O2 -gline-tables-only"
+ac_add_options --enable-address-sanitizer-reporter
+
+. "$topsrcdir/build/win64/mozconfig.asan"
+
+export MOZ_PKG_SPECIAL=asan-reporter
+
+# Sandboxing is currently not compatible with the way the ASan reporter works
+ac_add_options --disable-sandbox
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/win64/plain-debug b/comm/mail/config/mozconfigs/win64/plain-debug
new file mode 100644
index 0000000000..248d7e832d
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/plain-debug
@@ -0,0 +1,3 @@
+. $topsrcdir/comm/mail/config/mozconfigs/win64/plain-opt
+
+ac_add_options --enable-debug
diff --git a/comm/mail/config/mozconfigs/win64/plain-opt b/comm/mail/config/mozconfigs/win64/plain-opt
new file mode 100644
index 0000000000..d63c6c2c87
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/plain-opt
@@ -0,0 +1,6 @@
+ac_add_options --disable-release
+ac_add_options --target=x86_64-pc-windows-msvc
+
+. $topsrcdir/build/win64/mozconfig.vs-latest
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/mozconfigs/win64/release b/comm/mail/config/mozconfigs/win64/release
new file mode 100644
index 0000000000..3fad30b351
--- /dev/null
+++ b/comm/mail/config/mozconfigs/win64/release
@@ -0,0 +1,14 @@
+. "$topsrcdir/build/mozconfig.win-common"
+. "$topsrcdir/build/mozconfig.common"
+. "$topsrcdir/comm/mail/config/mozconfigs/win64/common-win64"
+
+ac_add_options --enable-update-channel="${MOZ_UPDATE_CHANNEL}"
+ac_add_options --enable-official-branding
+
+# Needed to enable breakpad in application.ini
+export MOZILLA_OFFICIAL=1
+
+# Package js shell
+export MOZ_PACKAGE_JSSHELL=1
+
+. "$topsrcdir/comm/mail/config/mozconfigs/common"
diff --git a/comm/mail/config/version.txt b/comm/mail/config/version.txt
new file mode 100644
index 0000000000..fe6fbc7f02
--- /dev/null
+++ b/comm/mail/config/version.txt
@@ -0,0 +1 @@
+115.7.0
diff --git a/comm/mail/config/version_display.txt b/comm/mail/config/version_display.txt
new file mode 100644
index 0000000000..fe6fbc7f02
--- /dev/null
+++ b/comm/mail/config/version_display.txt
@@ -0,0 +1 @@
+115.7.0
diff --git a/comm/mail/config/whats_new_page.yml b/comm/mail/config/whats_new_page.yml
new file mode 100644
index 0000000000..0bea15e390
--- /dev/null
+++ b/comm/mail/config/whats_new_page.yml
@@ -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/.
+---
+- type: product-details
+ # yamllint disable-line rule:line-length
+ url: https://live.thunderbird.net/thunderbird/releasenotes?locale=%LOCALE%&version={version}&channel={release-type}
+- type: show-url
+ # yamllint disable-line rule:line-length
+ url: https://live.thunderbird.net/thunderbird/whatsnew?locale=%LOCALE%&version={version}&channel={release-type}&oldversion=%OLD_VERSION%
+ conditions:
+ blob-types: [wnp]
+ release-types: [release]
+ products: [thunderbird]
+ update-channel: release
+ # e.g.: ["<78.0"] for the current release. {version.major_number} reflects
+ # the current version and compares to the version the client is updating from
+ # on Balrog
+ versions: ["<{version.major_number}.0"]
+- type: show-url
+ # yamllint disable-line rule:line-length
+ url: https://live.thunderbird.net/thunderbird/whatsnew?locale=%LOCALE%&version={version}&channel={release-type}&oldversion=%OLD_VERSION%
+ conditions:
+ blob-types: [wnp]
+ release-types: [beta]
+ products: [thunderbird]
+ update-channel: beta
+ versions: ["<{version.major_number}.0"]
diff --git a/comm/mail/confvars.sh b/comm/mail/confvars.sh
new file mode 100755
index 0000000000..4218f73116
--- /dev/null
+++ b/comm/mail/confvars.sh
@@ -0,0 +1,30 @@
+#! /bin/sh
+# 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/.
+
+MOZ_APP_NAME=thunderbird
+
+if test "$OS_ARCH" = "WINNT"; then
+ if ! test "$HAVE_64BIT_BUILD"; then
+ MOZ_VERIFY_MAR_SIGNATURE=1
+ fi
+fi
+
+BROWSER_CHROME_URL=chrome://messenger/content/extensionPopup.xhtml
+
+MOZ_BRANDING_DIRECTORY=comm/mail/branding/nightly
+MOZ_OFFICIAL_BRANDING_DIRECTORY=comm/mail/branding/thunderbird
+
+MOZ_APP_ID={3550f703-e582-4d05-9a08-453d09bdfdc6}
+
+MOZ_PROFILE_MIGRATOR=1
+MOZ_BINARY_EXTENSIONS=1
+MOZ_SEPARATE_MANIFEST_FOR_THEME_OVERRIDES=1
+
+# Enable building ./signmar and running libmar signature tests
+MOZ_ENABLE_SIGNMAR=1
+
+MOZ_DEVTOOLS=all
+
+NSS_EXTRA_SYMBOLS_FILE=../comm/mailnews/nss-extra.symbols
diff --git a/comm/mail/defs.mk b/comm/mail/defs.mk
new file mode 100644
index 0000000000..f836d05fb2
--- /dev/null
+++ b/comm/mail/defs.mk
@@ -0,0 +1,9 @@
+# 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 variables defined here will be set for all files within the mail/
+# subdirectory.
+
+XPI_ROOT_APPID=$(MOZ_APP_ID)
+MOZ_BUILT_FROM_FILE = built_from.json
diff --git a/comm/mail/extensions/am-e2e/AME2E.jsm b/comm/mail/extensions/am-e2e/AME2E.jsm
new file mode 100644
index 0000000000..86237d3e0e
--- /dev/null
+++ b/comm/mail/extensions/am-e2e/AME2E.jsm
@@ -0,0 +1,24 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+ * 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 EXPORTED_SYMBOLS = ["E2EService"];
+
+function E2EService() {}
+
+E2EService.prototype = {
+ name: "e2e",
+ chromePackageName: "messenger",
+ showPanel(server) {
+ // don't show the panel for news, rss, or local accounts
+ return (
+ server.type != "nntp" &&
+ server.type != "rss" &&
+ server.type != "im" &&
+ server.type != "none"
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgAccountManagerExtension"]),
+};
diff --git a/comm/mail/extensions/am-e2e/am-e2e.inc.xhtml b/comm/mail/extensions/am-e2e/am-e2e.inc.xhtml
new file mode 100644
index 0000000000..76841870aa
--- /dev/null
+++ b/comm/mail/extensions/am-e2e/am-e2e.inc.xhtml
@@ -0,0 +1,237 @@
+# 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/.
+
+ <vbox id="e2eEditing">
+
+ <stringbundle id="bundle_e2e" src="chrome://messenger/locale/am-smime.properties"/>
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="messenger/openpgp/openpgp.ftl"/>
+ </linkset>
+
+ <vbox flex="1">
+ <html:p class="intro-paragraph">&e2eEnc.description;</html:p>
+ <html:p class="intro-paragraph"
+ data-l10n-id="e2e-intro-description"></html:p>
+ <html:span class="tail-with-learn-more"
+ data-l10n-id="e2e-intro-description-more"></html:span>
+ <label is="text-link" id="acceptLearnMoreE2E"
+ href="https://support.mozilla.org/kb/thunderbird-help-setup-account-e2ee"
+ value="&e2eLearnMore.label;"/>
+ </vbox>
+
+ <html:div>
+ <html:fieldset id="openpgpOptions"
+ aria-describedby="openPgpDescription">
+ <html:legend>&openpgpKeys.label;</html:legend>
+
+ <vbox data-subcategory="openpgp" class="openpgp-container">
+ <hbox align="center" class="opengpg-intro-section">
+ <html:img id="openPgpKey"
+ src="chrome://messenger/skin/icons/new/compact/key.svg"
+ alt="" />
+ <vbox flex="1">
+ <description class="description-with-side-element openpgp-description">
+ <html:p id="openPgpDescription"></html:p>
+ <html:img id="openPgpStatusImage" class="openpgp-status"
+ alt="" hidden="hidden"/>
+ <html:span id="openPgpSelectionStatus"
+ class="tail-with-learn-more"
+ hidden="hidden"></html:span>
+ <label is="text-link" id="openPgpLearnMore"
+ href="https://support.mozilla.org/kb/introduction-to-e2e-encryption"
+ data-l10n-id="e2e-learn-more"
+ class="learnMore"
+ hidden="true"/>
+ </description>
+ </vbox>
+ <vbox>
+ <!-- Please don't remove the wrapping hbox/vbox/box for these elements. It's used to properly compute the search tooltip position. -->
+ <hbox>
+ <button id="addOpenPgpButton"
+ data-l10n-id="openpgp-add-key-button"
+ oncommand="openKeyWizard();"
+ class="accessory-button openpgp-add-key-button openpgp-image-btn"
+ flex="1"/>
+ </hbox>
+ </vbox>
+ </hbox>
+
+ <hbox id="openPgpNotification"
+ class="inline-notification-container success-container"
+ collapsed="true">
+ <hbox class="inline-notification-wrapper align-center">
+ <html:img class="notification-image"
+ src="chrome://global/skin/icons/check.svg"
+ alt="" />
+ <description id="openPgpNotificationDescription"/>
+ <button class="close-icon" oncommand="closeNotification()"/>
+ </hbox>
+ </hbox>
+
+ <vbox id="openPgpKeyList">
+ <radiogroup id="openPgpKeyListRadio">
+ <vbox id="openPgpOptionNone" class="content-blocking-category">
+ <hbox>
+ <radio id="openPgpNone"
+ value=""
+ data-l10n-id="openpgp-radio-none"
+ flex="1"/>
+ </hbox>
+ <vbox class="indent">
+ <description data-l10n-id="openpgp-radio-none-desc"/>
+ </vbox>
+ </vbox>
+ <!-- All available keys will be appended here. -->
+ </radiogroup>
+ </vbox>
+ </vbox>
+
+ <separator/>
+
+ <description flex="1" data-l10n-id="openpgp-manager-description"/>
+
+ <separator class="thin"/>
+
+ <hbox>
+ <button id="openOpenPGPKeyManagerButton"
+ data-l10n-id="openpgp-manager-button"
+ class="first-element"
+ oncommand="openKeyManager()"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <separator/>
+
+ <html:div>
+ <html:fieldset id="smimeOptions">
+ <html:legend>&certificates2.label;</html:legend>
+
+ <label id="identity_signing_cert_nameLabel"
+ value="&signingCert2.message;" control="identity_signing_cert_name"/>
+
+ <hbox align="center" class="input-container">
+ <html:input id="identity_signing_cert_name" type="text"
+ class="input-inline"
+ readonly="readonly"
+ disabled="disabled"
+ aria-labelledby="identity_signing_cert_nameLabel"
+ wsm_persist="true"
+ prefstring="mail.identity.%identitykey%.signing_cert_name"/>
+
+ <button id="signingCertSelectButton"
+ label="&digitalSign.certificate.button;"
+ accesskey="&digitalSign.certificate.accesskey;"
+ oncommand="smimeSelectCert('identity_signing_cert_name')"/>
+
+ <button id="signingCertClearButton"
+ label="&digitalSign.certificate_clear.button;"
+ accesskey="&digitalSign.certificate_clear.accesskey;"
+ oncommand="smimeClearCert('identity_signing_cert_name')"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <label value="&encryptionCert2.message;"
+ control="identity_encryption_cert_name"/>
+
+ <hbox align="center" class="input-container">
+ <html:input id="identity_encryption_cert_name" type="text"
+ class="input-inline"
+ readonly="readonly"
+ disabled="disabled"
+ wsm_persist="true"
+ prefstring="mail.identity.%identitykey%.encryption_cert_name"/>
+
+ <button id="encryptionCertSelectButton"
+ label="&encryption.certificate.button;"
+ accesskey="&encryption.certificate.accesskey;"
+ oncommand="smimeSelectCert('identity_encryption_cert_name')"/>
+
+ <button id="encryptionCertClearButton"
+ label="&encryption.certificate_clear.button;"
+ accesskey="&encryption.certificate_clear.accesskey;"
+ oncommand="smimeClearCert('identity_encryption_cert_name')"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="right">
+ <button id="openCertManagerButton" oncommand="openCertManager();"
+ label="&manageCerts3.label;" accesskey="&manageCerts3.accesskey;"/>
+ <button id="openDeviceManagerButton" oncommand="openDeviceManager();"
+ label="&manageDevices2.label;" accesskey="&manageDevices2.accesskey;"/>
+ </hbox>
+
+ </html:fieldset>
+ </html:div>
+
+ <separator/>
+
+ <html:legend>&sendingDefaults.label;</html:legend>
+
+ <html:div>
+ <html:fieldset>
+ <radiogroup id="encryptionChoices" class="indent" hidden="true">
+ <radio id="disable_encryption" wsm_persist="true" value="0"
+ data-l10n-id="e2e-disable-enc"/>
+ <radio id="enable_encryption" wsm_persist="true" value="2"
+ data-l10n-id="e2e-enable-enc"/>
+ <hbox>
+ <description flex="1" data-l10n-id="e2e-enable-description"
+ class="option-description tip-caption"/>
+ </hbox>
+ </radiogroup>
+
+ <separator/>
+
+ <description flex="1" data-l10n-id="e2e-signing-description"></description>
+ <checkbox id="identity_sign_mail" class="indent" wsm_persist="true"
+ prefstring="mail.identity.%identitykey%.sign_mail"
+ data-l10n-id="e2e-sign-message"/>
+
+ </html:fieldset>
+ </html:div>
+
+ <separator/>
+
+ <html:legend data-l10n-id="e2e-advanced-section"/>
+
+ <html:div>
+ <html:fieldset>
+ <checkbox id="identity_attach_key" wsm_persist="true"
+ prefstring="mail.identity.%identitykey%.attachPgpKey"
+ data-l10n-id="e2e-attach-key"/>
+
+ <checkbox id="identity_autocrypt_headers" wsm_persist="true"
+ prefstring="mail.identity.%identitykey%.sendAutocryptHeaders"
+ data-l10n-id="e2e-autocrypt-headers"/>
+
+ <checkbox id="identity_encrypt_subject" wsm_persist="true"
+ prefstring="mail.identity.%identitykey%.protectSubject"
+ data-l10n-id="e2e-encrypt-subject"/>
+
+ <checkbox id="identity_encrypt_drafts" wsm_persist="true"
+ prefstring="mail.identity.%identitykey%.autoEncryptDrafts"
+ data-l10n-id="e2e-encrypt-drafts"/>
+
+ <separator/>
+
+ <description>&e2eTechPref.description;</description>
+ <radiogroup id="technologyChoices" class="indent">
+ <radio id="technology_automatic" wsm_persist="true" value="0"
+ label="&technologyAutomatic.label;"/>
+
+ <radio id="technology_prefer_openpgp" wsm_persist="true" value="2"
+ label="&technologyOpenPGP.label;"/>
+
+ <radio id="technology_prefer_smime" wsm_persist="true" value="1"
+ label="&technologySMIME.label;"/>
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+ </vbox>
diff --git a/comm/mail/extensions/am-e2e/am-e2e.js b/comm/mail/extensions/am-e2e/am-e2e.js
new file mode 100644
index 0000000000..1926d38d32
--- /dev/null
+++ b/comm/mail/extensions/am-e2e/am-e2e.js
@@ -0,0 +1,1591 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+/* import-globals-from ../../../mailnews/base/prefs/content/am-identity-edit.js */
+
+/* global GetEnigmailSvc, EnigRevokeKey */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { RNP } = ChromeUtils.import("chrome://openpgp/content/modules/RNP.jsm");
+var { EnigmailKey } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/key.jsm"
+);
+var { EnigmailDialog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/dialog.jsm"
+);
+var { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+var { EnigmailKeyserverURIs } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyserverUris.jsm"
+);
+var { EnigmailKeyServer } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyserver.jsm"
+);
+var { EnigmailCryptoAPI } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/cryptoAPI.jsm"
+);
+var { PgpSqliteDb2 } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/sqliteDb.jsm"
+);
+
+var email_signing_cert_usage = 4; // SECCertUsage.certUsageEmailSigner
+var email_recipient_cert_usage = 5; // SECCertUsage.certUsageEmailRecipient
+
+var gIdentity;
+var gEncryptionCertName = null;
+var gEncryptionChoices = null;
+var gSignCertName = null;
+var gTechChoices = null;
+var gSignMessages = null;
+var gRequireEncrypt = null;
+var gDoNotEncrypt = null;
+var gAttachKey = null;
+var gSendAutocryptHeaders = null;
+var gEncryptSubject = null;
+var gEncryptDrafts = null;
+
+var gKeyId = null; // "" will denote selection 'None'.
+var gBundle = null;
+var gBrandBundle;
+var gSmimePrefbranch;
+var kEncryptionCertPref = "identity_encryption_cert_name";
+var kSigningCertPref = "identity_signing_cert_name";
+
+var gTechAuto = null;
+var gTechPrefOpenPGP = null;
+var gTechPrefSMIME = null;
+
+function onInit() {
+ initE2EEncryption(gIdentity);
+ Services.prefs.addObserver("mail.e2ee.auto_enable", autoEncryptPrefObserver);
+ Services.prefs.addObserver("mail.e2ee.auto_disable", autoEncryptPrefObserver);
+}
+
+window.addEventListener("unload", function () {
+ Services.prefs.removeObserver(
+ "mail.e2ee.auto_enable",
+ autoEncryptPrefObserver
+ );
+ Services.prefs.removeObserver(
+ "mail.e2ee.auto_disable",
+ autoEncryptPrefObserver
+ );
+});
+
+let gDisableEncryption;
+let gEnableEncryption;
+
+var autoEncryptPrefObserver = {
+ observe(subject, topic, prefName) {
+ if (topic == "nsPref:changed") {
+ if (
+ prefName == "mail.e2ee.auto_enable" ||
+ prefName == "mail.e2ee.auto_disable"
+ ) {
+ updateAutoEncryptRelated();
+ }
+ }
+ },
+};
+
+function updateAutoEncryptRelated() {
+ if (Services.prefs.getBoolPref("mail.e2ee.auto_enable")) {
+ document.getElementById("encryptionChoices").hidden = true;
+ } else {
+ document.getElementById("encryptionChoices").hidden = false;
+ }
+}
+
+async function initE2EEncryption(identity) {
+ // Initialize all of our elements based on the current identity values...
+ gEncryptionCertName = document.getElementById(kEncryptionCertPref);
+ gEncryptionChoices = document.getElementById("encryptionChoices");
+ gSignCertName = document.getElementById(kSigningCertPref);
+ gSignMessages = document.getElementById("identity_sign_mail");
+ gDisableEncryption = document.getElementById("disable_encryption");
+ gEnableEncryption = document.getElementById("enable_encryption");
+ gAttachKey = document.getElementById("identity_attach_key");
+ gSendAutocryptHeaders = document.getElementById("identity_autocrypt_headers");
+ gEncryptSubject = document.getElementById("identity_encrypt_subject");
+ gEncryptDrafts = document.getElementById("identity_encrypt_drafts");
+
+ gBundle = document.getElementById("bundle_e2e");
+ gBrandBundle = document.getElementById("bundle_brand");
+
+ gTechChoices = document.getElementById("technologyChoices");
+ gTechAuto = document.getElementById("technology_automatic");
+ gTechPrefOpenPGP = document.getElementById("technology_prefer_openpgp");
+ gTechPrefSMIME = document.getElementById("technology_prefer_smime");
+
+ if (!identity) {
+ // We're setting up a new identity. Set most prefs to default values.
+ // Only take selected values from gAccount.defaultIdentity
+ // as the new identity is going to have a different mail address.
+
+ gEncryptionCertName.value = "";
+ gEncryptionCertName.displayName = "";
+ gEncryptionCertName.dbKey = "";
+
+ gSignCertName.value = "";
+ gSignCertName.displayName = "";
+ gSignCertName.dbKey = "";
+
+ gDisableEncryption.disabled = true;
+ gEnableEncryption.disabled = true;
+ gEncryptSubject.disabled = true;
+ gEncryptDrafts.disabled = true;
+ gSignMessages.disabled = true;
+
+ gAttachKey.checked = gAccount.defaultIdentity.attachPgpKey;
+ gSendAutocryptHeaders.checked =
+ gAccount.defaultIdentity.sendAutocryptHeaders;
+ gEncryptSubject.checked = gAccount.defaultIdentity.protectSubject;
+ gEncryptDrafts.checked = gAccount.defaultIdentity.autoEncryptDrafts;
+ gSignMessages.checked = gAccount.defaultIdentity.signMail;
+ gEncryptionChoices.value = gAccount.defaultIdentity.encryptionPolicy;
+
+ gTechChoices.value = 0;
+ } else {
+ // We're editing an existing identity.
+
+ initSMIMESettings();
+ await initOpenPgpSettings();
+
+ let enableEnc = !!gEncryptionCertName.value;
+ enableEnc = enableEnc || !!gKeyId;
+ enableEncryptionControls(enableEnc);
+
+ gSignMessages.checked = identity.signMail;
+ gAttachKey.checked = identity.attachPgpKey;
+ gSendAutocryptHeaders.checked = identity.sendAutocryptHeaders;
+ gEncryptSubject.checked = identity.protectSubject;
+ gEncryptDrafts.checked = identity.autoEncryptDrafts;
+
+ let enableSig = gSignCertName.value;
+ enableSig = enableSig || !!gKeyId;
+ enableSigningControls(enableSig);
+ }
+
+ updateAutoEncryptRelated();
+
+ // Always start with enabling select buttons.
+ // This will keep the visibility of buttons in a sane state as user
+ // jumps from security panel of one account to another.
+ enableSelectButtons();
+ updateTechPref();
+}
+
+/**
+ * Initialize the S/MIME settings based on identity preferences.
+ */
+function initSMIMESettings() {
+ let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+
+ gEncryptionCertName.value = gIdentity.getUnicharAttribute(
+ "encryption_cert_name"
+ );
+ gEncryptionCertName.dbKey = gIdentity.getCharAttribute(
+ "encryption_cert_dbkey"
+ );
+ // If we succeed in looking up the certificate by the dbkey pref, then
+ // append the serial number " [...]" to the display value, and remember the
+ // displayName in a separate property.
+ try {
+ let x509cert = null;
+ if (
+ gEncryptionCertName.dbKey &&
+ (x509cert = certdb.findCertByDBKey(gEncryptionCertName.dbKey))
+ ) {
+ gEncryptionCertName.value =
+ x509cert.displayName + " [" + x509cert.serialNumber + "]";
+ gEncryptionCertName.displayName = x509cert.displayName;
+ }
+ } catch (e) {}
+
+ gEncryptionChoices.value = gIdentity.encryptionPolicy;
+ gTechChoices.value = gIdentity.getIntAttribute("e2etechpref");
+
+ gSignCertName.value = gIdentity.getUnicharAttribute("signing_cert_name");
+ gSignCertName.dbKey = gIdentity.getCharAttribute("signing_cert_dbkey");
+
+ // same procedure as with gEncryptionCertName (see above)
+ try {
+ let x509cert = null;
+ if (
+ gSignCertName.dbKey &&
+ (x509cert = certdb.findCertByDBKey(gSignCertName.dbKey))
+ ) {
+ gSignCertName.value =
+ x509cert.displayName + " [" + x509cert.serialNumber + "]";
+ gSignCertName.displayName = x509cert.displayName;
+ }
+ } catch (e) {}
+}
+
+/**
+ * Initialize the OpenPGP settings, apply strings, and load the key radio UI.
+ */
+async function initOpenPgpSettings() {
+ let result = {};
+ await EnigmailKeyRing.getAllSecretKeysByEmail(gIdentity.email, result, true);
+
+ let externalKey = gIdentity.getUnicharAttribute(
+ "last_entered_external_gnupg_key_id"
+ );
+
+ let keyCount = result.all.length + (externalKey ? 1 : 0);
+ if (keyCount) {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpDescription"),
+ "openpgp-description-has-keys",
+ {
+ count: keyCount,
+ identity: gIdentity.email,
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpDescription"),
+ "openpgp-description-no-key",
+ {
+ identity: gIdentity.email,
+ }
+ );
+ }
+
+ closeNotification();
+
+ let keyId = gIdentity.getUnicharAttribute("openpgp_key_id");
+ useOpenPGPKey(keyId);
+
+ // When key changes, update settings.
+ let openPgpKeyListRadio = document.getElementById("openPgpKeyListRadio");
+ openPgpKeyListRadio.addEventListener("command", event => {
+ closeNotification();
+ useOpenPGPKey(event.target.value);
+ });
+}
+
+function onPreInit(account, accountValues) {
+ gIdentity = account.defaultIdentity;
+}
+
+// NOTE: AccountManager.js checks and calls "onSave" in savePage.
+function onSave() {
+ saveE2EEncryptionSettings(gIdentity);
+}
+
+function saveE2EEncryptionSettings(identity) {
+ // Find out which radio for the encryption radio group is selected and set
+ // that on our hidden encryptionChoice pref.
+ let newValue = gEncryptionChoices.value;
+ identity.encryptionPolicy = newValue;
+
+ newValue = gTechChoices.value;
+ identity.setIntAttribute("e2etechpref", newValue);
+
+ identity.setUnicharAttribute(
+ "encryption_cert_name",
+ gEncryptionCertName.displayName || gEncryptionCertName.value
+ );
+ identity.setCharAttribute("encryption_cert_dbkey", gEncryptionCertName.dbKey);
+
+ identity.signMail = gSignMessages.checked;
+ identity.setUnicharAttribute(
+ "signing_cert_name",
+ gSignCertName.displayName || gSignCertName.value
+ );
+ identity.setCharAttribute("signing_cert_dbkey", gSignCertName.dbKey);
+
+ identity.attachPgpKey = gAttachKey.checked;
+ identity.sendAutocryptHeaders = gSendAutocryptHeaders.checked;
+ identity.protectSubject = gEncryptSubject.checked;
+ identity.autoEncryptDrafts = gEncryptDrafts.checked;
+}
+
+function alertUser(message) {
+ Services.prompt.alert(
+ window,
+ gBrandBundle.getString("brandShortName"),
+ message
+ );
+}
+
+function askUser(message) {
+ let button = Services.prompt.confirmEx(
+ window,
+ gBrandBundle.getString("brandShortName"),
+ message,
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ // confirmEx returns button index:
+ return button == 0;
+}
+
+function checkOtherCert(
+ cert,
+ pref,
+ usage,
+ msgNeedCertWantSame,
+ msgWantSame,
+ msgNeedCertWantToSelect,
+ enabler
+) {
+ var otherCertInfo = document.getElementById(pref);
+ if (otherCertInfo.dbKey == cert.dbKey) {
+ // All is fine, same cert is now selected for both purposes.
+ return;
+ }
+
+ var secMsg = Cc["@mozilla.org/nsCMSSecureMessage;1"].getService(
+ Ci.nsICMSSecureMessage
+ );
+
+ var matchingOtherCert;
+ if (email_recipient_cert_usage == usage) {
+ if (secMsg.canBeUsedForEmailEncryption(cert)) {
+ matchingOtherCert = cert;
+ }
+ } else if (email_signing_cert_usage == usage) {
+ if (secMsg.canBeUsedForEmailSigning(cert)) {
+ matchingOtherCert = cert;
+ }
+ } else {
+ throw new Error("Unexpected SECCertUsage: " + usage);
+ }
+
+ var userWantsSameCert = false;
+ if (!otherCertInfo.value) {
+ if (matchingOtherCert) {
+ userWantsSameCert = askUser(gBundle.getString(msgNeedCertWantSame));
+ } else if (askUser(gBundle.getString(msgNeedCertWantToSelect))) {
+ smimeSelectCert(pref);
+ }
+ } else if (matchingOtherCert) {
+ userWantsSameCert = askUser(gBundle.getString(msgWantSame));
+ }
+
+ if (userWantsSameCert) {
+ otherCertInfo.value = cert.displayName + " [" + cert.serialNumber + "]";
+ otherCertInfo.displayName = cert.displayName;
+ otherCertInfo.dbKey = cert.dbKey;
+ enabler(true);
+ }
+}
+
+function smimeSelectCert(smime_cert) {
+ var certInfo = document.getElementById(smime_cert);
+ if (!certInfo) {
+ return;
+ }
+
+ var picker = Cc["@mozilla.org/user_cert_picker;1"].createInstance(
+ Ci.nsIUserCertPicker
+ );
+ var canceled = {};
+ var x509cert;
+ var certUsage;
+ var selectEncryptionCert;
+
+ if (smime_cert == kEncryptionCertPref) {
+ selectEncryptionCert = true;
+ certUsage = email_recipient_cert_usage;
+ } else if (smime_cert == kSigningCertPref) {
+ selectEncryptionCert = false;
+ certUsage = email_signing_cert_usage;
+ }
+
+ try {
+ x509cert = picker.pickByUsage(
+ window,
+ certInfo.value,
+ certUsage, // this is from enum SECCertUsage
+ false,
+ true,
+ gIdentity.email,
+ canceled
+ );
+ } catch (e) {
+ canceled.value = false;
+ x509cert = null;
+ }
+
+ if (!canceled.value) {
+ if (!x509cert) {
+ if (gIdentity.email) {
+ alertUser(
+ gBundle.getFormattedString(
+ selectEncryptionCert
+ ? "NoEncryptionCertForThisAddress"
+ : "NoSigningCertForThisAddress",
+ [gIdentity.email]
+ )
+ );
+ } else {
+ alertUser(
+ gBundle.getString(
+ selectEncryptionCert ? "NoEncryptionCert" : "NoSigningCert"
+ )
+ );
+ }
+ } else {
+ certInfo.disabled = false;
+ certInfo.value =
+ x509cert.displayName + " [" + x509cert.serialNumber + "]";
+ certInfo.displayName = x509cert.displayName;
+ certInfo.dbKey = x509cert.dbKey;
+
+ if (selectEncryptionCert) {
+ enableEncryptionControls(true);
+
+ checkOtherCert(
+ x509cert,
+ kSigningCertPref,
+ email_signing_cert_usage,
+ "signing_needCertWantSame",
+ "signing_wantSame",
+ "signing_needCertWantToSelect",
+ enableSigningControls
+ );
+ } else {
+ enableSigningControls(true);
+
+ checkOtherCert(
+ x509cert,
+ kEncryptionCertPref,
+ email_recipient_cert_usage,
+ "encryption_needCertWantSame",
+ "encryption_wantSame",
+ "encryption_needCertWantToSelect",
+ enableEncryptionControls
+ );
+ }
+ }
+ }
+
+ updateTechPref();
+ enableSelectButtons();
+ onSave();
+}
+
+function enableEncryptionControls(do_enable) {
+ gDisableEncryption.disabled = !do_enable;
+ gEnableEncryption.disabled = !do_enable;
+ if (!do_enable) {
+ gEncryptionChoices.value = 0;
+ }
+ // If we have a certificate or key configured that allows encryption,
+ // then we are able to encrypt drafts, too.
+ gEncryptDrafts.disabled = !do_enable;
+}
+
+function enableSigningControls(do_enable) {
+ gSignMessages.disabled = !do_enable;
+ if (!do_enable) {
+ gSignMessages.checked = false;
+ }
+}
+
+function enableSelectButtons() {
+ gSignCertName.disabled = !gSignCertName.value;
+ document.getElementById("signingCertClearButton").disabled =
+ !gSignCertName.value;
+
+ gEncryptionCertName.disabled = !gEncryptionCertName.value;
+ document.getElementById("encryptionCertClearButton").disabled =
+ !gEncryptionCertName.value;
+}
+
+function smimeClearCert(smime_cert) {
+ var certInfo = document.getElementById(smime_cert);
+ if (!certInfo) {
+ return;
+ }
+
+ certInfo.disabled = true;
+ certInfo.value = "";
+ certInfo.displayName = "";
+ certInfo.dbKey = "";
+
+ let stillHaveOther = false;
+ stillHaveOther = gKeyId != "";
+
+ if (!stillHaveOther) {
+ if (smime_cert == kEncryptionCertPref) {
+ enableEncryptionControls(false);
+ } else if (smime_cert == kSigningCertPref) {
+ enableSigningControls(false);
+ }
+ }
+
+ updateTechPref();
+ enableSelectButtons();
+ onSave();
+}
+
+function updateTechPref() {
+ let haveSigCert = gSignCertName && gSignCertName.value;
+ let haveEncCert = gEncryptionCertName && gEncryptionCertName.value;
+ let havePgpkey = !!gKeyId;
+
+ let enable = (haveSigCert || haveEncCert) && havePgpkey;
+
+ gTechAuto.disabled = !enable;
+ gTechPrefOpenPGP.disabled = !enable;
+ gTechPrefSMIME.disabled = !enable;
+
+ if (!enable) {
+ gTechChoices.value = 0;
+ }
+}
+
+function openCertManager() {
+ parent.gSubDialog.open("chrome://pippki/content/certManager.xhtml");
+}
+
+function openDeviceManager() {
+ parent.gSubDialog.open("chrome://pippki/content/device_manager.xhtml");
+}
+
+/**
+ * Open the OpenPGP Key Manager.
+ */
+function openKeyManager() {
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://openpgp/content/ui/enigmailKeyManager.xhtml",
+ "enigmail:KeyManager",
+ "dialog,centerscreen,resizable",
+ {
+ cancelCallback: reloadOpenPgpUI,
+ okCallback: reloadOpenPgpUI,
+ }
+ );
+}
+
+/**
+ * Open the subdialog to create or import an OpenPGP key.
+ */
+function openKeyWizard() {
+ let args = {
+ identity: gIdentity,
+ gSubDialog: parent.gSubDialog,
+ cancelCallback: reloadOpenPgpUI,
+ okCallback: keyWizardSuccess,
+ okImportCallback: keyImportSuccess,
+ okExternalCallback: keyExternalSuccess,
+ keyDetailsDialog: enigmailKeyDetails,
+ };
+
+ parent.gSubDialog.open(
+ "chrome://openpgp/content/ui/keyWizard.xhtml",
+ undefined,
+ args
+ );
+}
+
+/**
+ * Show a successful notification after a new OpenPGP key was created, and
+ * trigger the reload of the key listing UI.
+ *
+ * @param {string} keyId - Id of key that the key wizard set up.
+ */
+async function keyWizardSuccess(keyId) {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpNotificationDescription"),
+ "openpgp-keygen-success"
+ );
+ document.getElementById("openPgpNotification").collapsed = false;
+
+ useOpenPGPKey(keyId);
+}
+
+/**
+ * Show a successful notification after an external key was saved, and trigger
+ * the reload of the key listing UI.
+ *
+ * @param {string} keyId - Id of key that the key wizard set up.
+ */
+async function keyExternalSuccess(keyId) {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpNotificationDescription"),
+ "openpgp-keygen-external-success"
+ );
+ document.getElementById("openPgpNotification").collapsed = false;
+
+ gIdentity.setUnicharAttribute("last_entered_external_gnupg_key_id", keyId);
+ useOpenPGPKey(keyId);
+}
+
+/**
+ * Adjust the key listing to account for newly created keys. Then set
+ * the current identity to start using this key and adjust the UI elements
+ * to be enabled now that there's a key to use.
+ *
+ * NOTE! Please always go through this to change gKeyId!
+ *
+ * @param {string} keyId - Id of key that the key wizard set up.
+ */
+function useOpenPGPKey(keyId) {
+ // Rebuild the UI so that any new keys are listed.
+ gKeyId = keyId.toUpperCase();
+
+ // Update the identity with the key obtained from the key wizard.
+ gIdentity.setUnicharAttribute("openpgp_key_id", keyId || "");
+
+ // Always update the GnuPG boolean pref to be sure the currently used key is
+ // internal or external.
+ gIdentity.setBoolAttribute(
+ "is_gnupg_key_id",
+ gKeyId ==
+ gIdentity.getUnicharAttribute("last_entered_external_gnupg_key_id")
+ );
+
+ reloadOpenPgpUI();
+}
+
+/**
+ * Show a successful notification after an import of keys, and trigger the
+ * reload of the key listing UI.
+ */
+async function keyImportSuccess() {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpNotificationDescription"),
+ "openpgp-keygen-import-success"
+ );
+ document.getElementById("openPgpNotification").collapsed = false;
+
+ reloadOpenPgpUI();
+}
+
+/**
+ * Collapse the inline notification.
+ */
+function closeNotification() {
+ document.getElementById("openPgpNotification").collapsed = true;
+}
+
+/**
+ * Refresh the UI on init or after a successful OpenPGP key generation.
+ */
+async function reloadOpenPgpUI() {
+ let result = {};
+ await EnigmailKeyRing.getAllSecretKeysByEmail(gIdentity.email, result, true);
+ let keyCount = result.all.length;
+
+ let externalKey = null;
+ if (Services.prefs.getBoolPref("mail.openpgp.allow_external_gnupg")) {
+ externalKey = gIdentity.getUnicharAttribute(
+ "last_entered_external_gnupg_key_id"
+ );
+ if (externalKey) {
+ keyCount++;
+ }
+ }
+
+ // Show the radiogroup container only if the current identity has keys.
+ // But still show it if a key (missing or unusable) is configured.
+ document.getElementById("openPgpKeyList").hidden = keyCount == 0 && !gKeyId;
+
+ // Update the OpenPGP intro description with the current key count.
+ if (keyCount) {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpDescription"),
+ "openpgp-description-has-keys",
+ {
+ count: keyCount,
+ identity: gIdentity.email,
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpDescription"),
+ "openpgp-description-no-key",
+ {
+ identity: gIdentity.email,
+ }
+ );
+ }
+
+ let radiogroup = document.getElementById("openPgpKeyListRadio");
+
+ if (!gKeyId) {
+ radiogroup.selectedIndex = 0; // None
+ }
+
+ // Remove all the previously generated radio options, except the first.
+ while (radiogroup.lastChild.id != "openPgpOptionNone") {
+ radiogroup.removeChild(radiogroup.lastChild);
+ }
+
+ // Currently configured key is not in available, maybe deleted by the user?
+ if (gKeyId && !externalKey && !result.all.find(key => key.keyId == gKeyId)) {
+ let container = document.createXULElement("vbox");
+ container.id = `openPgpOption${gKeyId}`;
+ container.classList.add("content-blocking-category");
+
+ let box = document.createXULElement("hbox");
+ let radio = document.createXULElement("radio");
+ radio.setAttribute("flex", "1");
+ radio.disabled = true;
+ radio.id = `openPgp${gKeyId}`;
+ radio.value = gKeyId;
+ radio.label = `0x${gKeyId}`;
+ box.appendChild(radio);
+
+ let box2 = document.createXULElement("vbox");
+ box2.classList.add("indent");
+ let desc = document.createXULElement("description");
+ box2.appendChild(desc);
+
+ let key = EnigmailKeyRing.getKeyById(gKeyId);
+ if (key && !key.secretAvailable) {
+ document.l10n.setAttributes(desc, "openpgp-radio-key-not-usable");
+ } else if (key && !(await PgpSqliteDb2.isAcceptedAsPersonalKey(key.fpr))) {
+ document.l10n.setAttributes(desc, "openpgp-radio-key-not-accepted");
+ let btnContainer = document.createXULElement("hbox");
+ btnContainer.setAttribute("pack", "end");
+ btnContainer.style.width = "100%";
+ let info = document.createXULElement("button");
+ info.classList.add("openpgp-image-btn", "openpgp-props-btn");
+ document.l10n.setAttributes(info, "openpgp-key-man-key-props");
+ info.addEventListener("command", event => {
+ event.stopPropagation();
+ enigmailKeyDetails(key.keyId);
+ });
+ btnContainer.appendChild(info);
+ box2.appendChild(btnContainer);
+ } else {
+ document.l10n.setAttributes(desc, "openpgp-radio-key-not-found");
+ }
+
+ container.appendChild(box);
+ container.appendChild(box2);
+ radiogroup.appendChild(container);
+ }
+
+ // Sort keys by create date from newest to oldest.
+ result.all.sort((a, b) => {
+ return b.keyCreated - a.keyCreated;
+ });
+
+ // If the user has an external key saved, and the allow_external_gnupg
+ // pref is true, we show it on top of the list.
+ if (externalKey) {
+ let container = document.createXULElement("vbox");
+ container.id = `openPgpOption${externalKey}`;
+ container.classList.add("content-blocking-category");
+
+ let box = document.createXULElement("hbox");
+
+ let radio = document.createXULElement("radio");
+ radio.setAttribute("flex", "1");
+ radio.id = `openPgp${externalKey}`;
+ radio.value = externalKey;
+ radio.label = `0x${externalKey}`;
+
+ let remove = document.createXULElement("button");
+ document.l10n.setAttributes(remove, "openpgp-key-remove-external");
+ remove.addEventListener("command", removeExternalKey);
+ remove.classList.add("button-small");
+
+ box.appendChild(radio);
+ box.appendChild(remove);
+
+ let indent = document.createXULElement("vbox");
+ indent.classList.add("indent");
+
+ let dateContainer = document.createXULElement("hbox");
+ dateContainer.classList.add("expiration-date-container");
+ dateContainer.setAttribute("align", "center");
+
+ let external = document.createXULElement("description");
+ external.classList.add("external-pill");
+ document.l10n.setAttributes(external, "key-external-label");
+
+ dateContainer.appendChild(external);
+ indent.appendChild(dateContainer);
+
+ container.appendChild(box);
+ container.appendChild(indent);
+
+ radiogroup.appendChild(container);
+ }
+
+ // List all the available keys.
+ for (let key of result.all) {
+ let container = document.createXULElement("vbox");
+ container.id = `openPgpOption${key.keyId}`;
+ container.classList.add("content-blocking-category");
+
+ let box = document.createXULElement("hbox");
+
+ let radio = document.createXULElement("radio");
+ radio.setAttribute("flex", "1");
+ radio.id = `openPgp${key.keyId}`;
+ radio.value = key.keyId;
+ radio.label = `0x${key.keyId}`;
+
+ let toggle = document.createXULElement("button");
+ toggle.classList.add("arrowhead");
+ toggle.setAttribute("aria-expanded", "false");
+ document.l10n.setAttributes(toggle, "openpgp-key-expand-section");
+ toggle.addEventListener("command", toggleExpansion);
+
+ box.appendChild(radio);
+ box.appendChild(toggle);
+
+ let indent = document.createXULElement("vbox");
+ indent.classList.add("indent");
+
+ let dateContainer = document.createXULElement("hbox");
+ dateContainer.classList.add("expiration-date-container");
+ dateContainer.setAttribute("align", "center");
+
+ let dateIcon = document.createElement("img");
+ dateIcon.classList.add("expiration-date-icon");
+
+ let dateButton = document.createXULElement("button");
+ document.l10n.setAttributes(dateButton, "openpgp-key-man-change-expiry");
+ dateButton.addEventListener("command", event => {
+ event.stopPropagation();
+ enigmailEditKeyDate(key);
+ });
+ dateButton.setAttribute("hidden", "true");
+ dateButton.classList.add("button-small");
+
+ let description = document.createXULElement("description");
+
+ if (key.expiryTime) {
+ if (Math.round(Date.now() / 1000) > key.expiryTime) {
+ // Has expired.
+ dateContainer.classList.add("key-expired");
+ dateIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/compact/warning.svg"
+ );
+ // Sets the title attribute.
+ // The alt attribute is not set because the accessible name is already
+ // set by the title.
+ document.l10n.setAttributes(dateIcon, "openpgp-key-has-expired-icon");
+
+ document.l10n.setAttributes(description, "openpgp-radio-key-expired", {
+ date: key.expiry,
+ });
+
+ dateButton.removeAttribute("hidden");
+ // This key is expired, so make it unselectable.
+ radio.setAttribute("disabled", "true");
+ } else {
+ // If the key expires in less than 6 months.
+ let sixMonths = new Date();
+ sixMonths.setMonth(sixMonths.getMonth() + 6);
+ if (Math.round(Date.parse(sixMonths) / 1000) > key.expiryTime) {
+ dateContainer.classList.add("key-is-expiring");
+ dateIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/compact/info.svg"
+ );
+ // Sets the title attribute.
+ // The alt attribute is not set because the accessible name is already
+ // set by the title.
+ document.l10n.setAttributes(
+ dateIcon,
+ "openpgp-key-expires-within-6-months-icon"
+ );
+ dateButton.removeAttribute("hidden");
+ }
+
+ document.l10n.setAttributes(description, "openpgp-radio-key-expires", {
+ date: key.expiry,
+ });
+ }
+ } else {
+ document.l10n.setAttributes(description, "key-does-not-expire");
+ }
+
+ dateContainer.appendChild(dateIcon);
+ dateContainer.appendChild(description);
+ dateContainer.appendChild(dateButton);
+
+ let publishContainer = null;
+
+ // If this key is the currently selected key, suggest publishing.
+ if (key.keyId == gKeyId) {
+ publishContainer = document.createXULElement("hbox");
+ publishContainer.setAttribute("align", "center");
+
+ let publishButton = document.createElement("button");
+ document.l10n.setAttributes(publishButton, "openpgp-key-publish");
+ publishButton.addEventListener("click", () => {
+ amE2eUploadKey(key);
+ });
+ publishButton.classList.add("button-small");
+
+ let description = document.createXULElement("description");
+ document.l10n.setAttributes(
+ description,
+ "openpgp-suggest-publishing-key"
+ );
+
+ publishContainer.appendChild(description);
+ publishContainer.appendChild(publishButton);
+ }
+
+ let hiddenContainer = document.createXULElement("vbox");
+ hiddenContainer.classList.add(
+ "content-blocking-extra-information",
+ "indent"
+ );
+
+ // Start key info section.
+ let grid = document.createXULElement("hbox");
+ grid.classList.add("extra-information-label");
+
+ // Key fingerprint.
+ let fingerprintImage = document.createElement("img");
+ fingerprintImage.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/compact/fingerprint.svg"
+ );
+ fingerprintImage.setAttribute("alt", "");
+
+ let fingerprintLabel = document.createXULElement("label");
+ document.l10n.setAttributes(
+ fingerprintLabel,
+ "openpgp-key-details-fingerprint-label"
+ );
+ fingerprintLabel.classList.add("extra-information-label-type");
+
+ let fgrInputContainer = document.createXULElement("hbox");
+ fgrInputContainer.classList.add("input-container");
+ fgrInputContainer.setAttribute("flex", "1");
+
+ let fingerprintInput = document.createElement("input");
+ fingerprintInput.setAttribute("type", "text");
+ fingerprintInput.classList.add("plain");
+ fingerprintInput.setAttribute("readonly", "readonly");
+ fingerprintInput.value = EnigmailKey.formatFpr(key.fpr);
+
+ fgrInputContainer.appendChild(fingerprintInput);
+
+ grid.appendChild(fingerprintImage);
+ grid.appendChild(fingerprintLabel);
+ grid.appendChild(fgrInputContainer);
+
+ // Key creation date.
+ let createdImage = document.createElement("img");
+ createdImage.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/compact/calendar.svg"
+ );
+ createdImage.setAttribute("alt", "");
+
+ let createdLabel = document.createXULElement("label");
+ document.l10n.setAttributes(
+ createdLabel,
+ "openpgp-key-details-created-header"
+ );
+ createdLabel.classList.add("extra-information-label-type");
+
+ let createdValueContainer = document.createXULElement("hbox");
+ createdValueContainer.classList.add("input-container");
+ createdValueContainer.setAttribute("flex", "1");
+
+ let createdValue = document.createElement("input");
+ createdValue.setAttribute("type", "text");
+ createdValue.classList.add("plain");
+ createdValue.setAttribute("readonly", "readonly");
+ createdValue.value = key.created;
+
+ createdValueContainer.appendChild(createdValue);
+
+ grid.appendChild(createdImage);
+ grid.appendChild(createdLabel);
+ grid.appendChild(createdValueContainer);
+ // End key info section.
+
+ hiddenContainer.appendChild(grid);
+
+ // Action buttons.
+ let btnContainer = document.createXULElement("hbox");
+ btnContainer.setAttribute("pack", "end");
+
+ let info = document.createXULElement("button");
+ info.classList.add("openpgp-image-btn", "openpgp-props-btn");
+ document.l10n.setAttributes(info, "openpgp-key-man-key-props");
+ info.addEventListener("command", event => {
+ event.stopPropagation();
+ enigmailKeyDetails(key.keyId);
+ });
+
+ let more = document.createXULElement("button");
+ more.setAttribute("type", "menu");
+ more.classList.add("openpgp-more-btn", "last-element");
+ document.l10n.setAttributes(more, "openpgp-key-man-key-more");
+
+ let menupopup = document.createXULElement("menupopup");
+
+ let copyItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(copyItem, "openpgp-key-copy-key");
+ copyItem.addEventListener("command", event => {
+ event.stopPropagation();
+ openPgpCopyToClipboard(`0x${key.keyId}`);
+ });
+
+ let sendItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(sendItem, "openpgp-key-send-key");
+ sendItem.addEventListener("command", event => {
+ event.stopPropagation();
+ openPgpSendKeyEmail(`0x${key.keyId}`);
+ });
+
+ let exportItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(exportItem, "openpgp-key-export-key");
+ exportItem.addEventListener("command", event => {
+ event.stopPropagation();
+ openPgpExportPublicKey(`0x${key.keyId}`);
+ });
+
+ let backupItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(backupItem, "openpgp-key-backup-key");
+ backupItem.addEventListener("command", event => {
+ event.stopPropagation();
+ openPgpExportSecretKey(`0x${key.keyId}`, `${key.fpr}`);
+ });
+
+ let revokeItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(revokeItem, "openpgp-key-man-revoke-key");
+ revokeItem.addEventListener("command", event => {
+ event.stopPropagation();
+ openPgpRevokeKey(key);
+ });
+
+ let deleteItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(deleteItem, "openpgp-delete-key");
+ deleteItem.addEventListener("command", event => {
+ event.stopPropagation();
+ enigmailDeleteKey(key);
+ });
+
+ menupopup.appendChild(copyItem);
+ menupopup.appendChild(sendItem);
+ menupopup.appendChild(exportItem);
+ menupopup.appendChild(document.createXULElement("menuseparator"));
+ menupopup.appendChild(backupItem);
+ menupopup.appendChild(document.createXULElement("menuseparator"));
+ menupopup.appendChild(revokeItem);
+ menupopup.appendChild(deleteItem);
+
+ more.appendChild(menupopup);
+
+ btnContainer.appendChild(info);
+ btnContainer.appendChild(more);
+
+ hiddenContainer.appendChild(btnContainer);
+
+ indent.appendChild(dateContainer);
+ if (publishContainer) {
+ indent.appendChild(publishContainer);
+ }
+ indent.appendChild(hiddenContainer);
+
+ container.appendChild(box);
+ container.appendChild(indent);
+
+ radiogroup.appendChild(container);
+ }
+
+ // Reflect the selected key in the UI.
+ radiogroup.selectedItem = radiogroup.querySelector(
+ `radio[value="${gKeyId}"]`
+ );
+
+ // Update all the encryption options based on the selected OpenPGP key.
+ if (gKeyId) {
+ enableEncryptionControls(true);
+ enableSigningControls(true);
+ } else {
+ let stillHaveOtherEncryption =
+ gEncryptionCertName && gEncryptionCertName.value;
+ if (!stillHaveOtherEncryption) {
+ enableEncryptionControls(false);
+ }
+ let stillHaveOtherSigning = gSignCertName && gSignCertName.value;
+ if (!stillHaveOtherSigning) {
+ enableSigningControls(false);
+ }
+ }
+
+ updateTechPref();
+ enableSelectButtons();
+ updateUIForSelectedOpenPgpKey();
+
+ gAttachKey.disabled = !gKeyId;
+ gEncryptSubject.disabled = !gKeyId;
+ gSendAutocryptHeaders.disabled = !gKeyId;
+}
+
+/**
+ * Open the Key Properties subdialog.
+ *
+ * @param {string} keyId - The ID of the selected OpenPGP Key.
+ */
+function enigmailKeyDetails(keyId) {
+ keyId = keyId.replace(/^0x/, "");
+
+ parent.gSubDialog.open(
+ "chrome://openpgp/content/ui/keyDetailsDlg.xhtml",
+ undefined,
+ {
+ keyId,
+ modified: onDataModified,
+ }
+ );
+}
+
+/**
+ * Delete an OpenPGP Key.
+ *
+ * @param {object} key - The selected OpenPGP Key.
+ */
+async function enigmailDeleteKey(key) {
+ // Interrupt if the selected key is currently being used.
+ if (key.keyId == gIdentity.getUnicharAttribute("openpgp_key_id")) {
+ let [alertTitle, alertDescription] = await document.l10n.formatValues([
+ { id: "key-in-use-title" },
+ { id: "delete-key-in-use-description" },
+ ]);
+
+ Services.prompt.alert(null, alertTitle, alertDescription);
+ return;
+ }
+
+ let l10nKey = key.secretAvailable ? "delete-secret-key" : "delete-pub-key";
+ let [title, description] = await document.l10n.formatValues([
+ { id: "delete-key-title" },
+ { id: l10nKey, args: { userId: key.userId } },
+ ]);
+
+ // Ask for confirmation before proceeding.
+ if (!Services.prompt.confirm(null, title, description)) {
+ return;
+ }
+
+ let cApi = EnigmailCryptoAPI();
+ await cApi.deleteKey(key.fpr, key.secretAvailable);
+ await PgpSqliteDb2.deleteAcceptance(key.fpr);
+
+ EnigmailKeyRing.clearCache();
+ reloadOpenPgpUI();
+}
+
+/**
+ * Revoke the selected OpenPGP Key.
+ *
+ * @param {object} key - The selected OpenPGP Key.
+ */
+async function openPgpRevokeKey(key) {
+ // Interrupt if the selected key is currently being used.
+ if (key.keyId == gIdentity.getUnicharAttribute("openpgp_key_id")) {
+ let [alertTitle, alertDescription] = await document.l10n.formatValues([
+ { id: "key-in-use-title" },
+ { id: "revoke-key-in-use-description" },
+ ]);
+
+ Services.prompt.alert(null, alertTitle, alertDescription);
+ return;
+ }
+
+ EnigRevokeKey(key, function (success) {
+ if (success) {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpNotificationDescription"),
+ "openpgp-key-revoke-success"
+ );
+ document.getElementById("openPgpNotification").collapsed = false;
+
+ EnigmailKeyRing.clearCache();
+ reloadOpenPgpUI();
+ }
+ });
+}
+
+async function amE2eUploadKey(key) {
+ let ks = EnigmailKeyserverURIs.getUploadKeyServer();
+
+ let ok = await EnigmailKeyServer.upload(key.keyId, ks);
+ let msg = await document.l10n.formatValue(
+ ok ? "openpgp-key-publish-ok" : "openpgp-key-publish-fail",
+ {
+ keyserver: ks,
+ }
+ );
+
+ EnigmailDialog.alert(null, msg);
+}
+
+/**
+ * Open the subdialog to enable the user to edit the expiration date of the
+ * selected OpenPGP Key.
+ *
+ * @param {object} key - The selected OpenPGP Key.
+ */
+async function enigmailEditKeyDate(key) {
+ if (!key.iSimpleOneSubkeySameExpiry()) {
+ Services.prompt.alert(
+ null,
+ document.title,
+ await document.l10n.formatValue("openpgp-cannot-change-expiry")
+ );
+ return;
+ }
+
+ let args = {
+ keyId: key.keyId,
+ modified: onDataModified,
+ };
+
+ parent.gSubDialog.open(
+ "chrome://openpgp/content/ui/changeExpiryDlg.xhtml",
+ undefined,
+ args
+ );
+}
+
+function onDataModified() {
+ EnigmailKeyRing.clearCache();
+ reloadOpenPgpUI();
+}
+
+/**
+ * Toggle the visibility of the OpenPgp Key radio container.
+ *
+ * @param {Event} event - The DOM event.
+ */
+function toggleExpansion(event) {
+ let carat = event.target;
+ carat.classList.toggle("up");
+ carat.closest(".content-blocking-category").classList.toggle("expanded");
+ carat.setAttribute(
+ "aria-expanded",
+ carat.getAttribute("aria-expanded") === "false"
+ );
+ event.stopPropagation();
+}
+
+/**
+ * Apply a .selected class to the radio container of the currently selected
+ * OpenPGP Key.
+ * Also update UI strings describing the status of current selection.
+ */
+function updateUIForSelectedOpenPgpKey() {
+ // Remove a previously selected container, if any.
+ let current = document.querySelector(".content-blocking-category.selected");
+
+ if (current) {
+ current.classList.remove("selected");
+ }
+
+ // Highlight the parent container of the currently selected radio button.
+ // The condition needs to be sure the key is not null as a selection of "None"
+ // returns a value of "".
+ if (gKeyId !== null) {
+ let radio = document.querySelector(`radio[value="${gKeyId}"]`);
+
+ // If the currently used key was deleted, we might not have the
+ // corresponding radio element.
+ if (radio) {
+ radio.closest(".content-blocking-category").classList.add("selected");
+ }
+ }
+
+ // Reset the image in case of async reload of the list.
+ let statusLabel = document.getElementById("openPgpSelectionStatus");
+ let image = document.getElementById("openPgpStatusImage");
+ image.classList.remove("status-success", "status-error");
+
+ // Check if the currently selected key has expired.
+ if (gKeyId) {
+ let key = EnigmailKeyRing.getKeyById(gKeyId, true);
+ if (key?.expiryTime && Math.round(Date.now() / 1000) > key.expiryTime) {
+ image.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/compact/close.svg"
+ );
+ image.classList.add("status-error");
+ document.l10n.setAttributes(
+ statusLabel,
+ "openpgp-selection-status-error",
+ { key: `0x${gKeyId}` }
+ );
+ } else {
+ image.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/compact/check.svg"
+ );
+ image.classList.add("status-success");
+ document.l10n.setAttributes(
+ statusLabel,
+ "openpgp-selection-status-have-key",
+ { key: `0x${gKeyId}` }
+ );
+ }
+ }
+
+ let hide = !gKeyId;
+ statusLabel.hidden = hide;
+ document.getElementById("openPgpLearnMore").hidden = hide;
+ image.hidden = hide;
+}
+
+/**
+ * Generic method to copy a string in the user's clipboard.
+ *
+ * @param {string} val - The formatted string to be copied in the clipboard.
+ */
+async function openPgpCopyToClipboard(keyId) {
+ let exitCodeObj = {};
+
+ let keyData = await EnigmailKeyRing.extractPublicKeys(
+ [keyId], // full
+ null,
+ null,
+ null,
+ exitCodeObj,
+ {}
+ );
+
+ // Alert the user if the copy failed.
+ if (exitCodeObj.value !== 0) {
+ alertUser(await document.l10n.formatValue("copy-to-clipbrd-failed"));
+ return;
+ }
+
+ navigator.clipboard
+ .writeText(keyData)
+ .then(async () => {
+ alertUser(await document.l10n.formatValue("copy-to-clipbrd-ok"));
+ })
+ .catch(async () => {
+ alertUser(await document.l10n.formatValue("copy-to-clipbrd-failed"));
+ });
+}
+
+/**
+ * Create an attachment with the currently selected OpenPgp public Key and open
+ * a new message compose window.
+ *
+ * @param {string} keyId - The formatted OpenPgp Key ID.
+ */
+async function openPgpSendKeyEmail(keyId) {
+ let tmpFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tmpFile.append("key.asc");
+ tmpFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+
+ let exitCodeObj = {};
+ let errorMsgObj = {};
+ let keyIdArray = [keyId];
+
+ await EnigmailKeyRing.extractPublicKeys(
+ keyIdArray, // full
+ null,
+ null,
+ tmpFile,
+ exitCodeObj,
+ errorMsgObj
+ );
+
+ if (exitCodeObj.value !== 0) {
+ alertUser(errorMsgObj.value);
+ return;
+ }
+
+ // Create the key attachment.
+ let tmpFileURI = Services.io.newFileURI(tmpFile);
+ let keyAttachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+
+ keyAttachment.url = tmpFileURI.spec;
+ keyAttachment.name = `${keyId}.asc`;
+ keyAttachment.temporary = true;
+ keyAttachment.contentType = "application/pgp-keys";
+
+ // Create the new message.
+ let msgCompFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ msgCompFields.addAttachment(keyAttachment);
+
+ let msgCompParam = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ msgCompParam.composeFields = msgCompFields;
+ msgCompParam.identity = gIdentity;
+ msgCompParam.type = Ci.nsIMsgCompType.New;
+ msgCompParam.format = Ci.nsIMsgCompFormat.Default;
+ msgCompParam.originalMsgURI = "";
+
+ MailServices.compose.OpenComposeWindowWithParams("", msgCompParam);
+}
+
+/**
+ * Export the selected OpenPGP public key to a file.
+ *
+ * @param {string} keyId - The ID of the selected OpenPGP Key.
+ */
+async function openPgpExportPublicKey(keyId) {
+ let outFile = EnigmailKeyRing.promptKeyExport2AsciiFilename(
+ window,
+ await document.l10n.formatValue("export-to-file"),
+ `${gIdentity.fullName}_${gIdentity.email}-${keyId}-pub.asc`
+ );
+
+ if (!outFile) {
+ return;
+ }
+
+ let exitCodeObj = {};
+ let errorMsgObj = {};
+ await EnigmailKeyRing.extractPublicKeys(
+ [keyId], // full
+ null,
+ null,
+ outFile,
+ exitCodeObj,
+ errorMsgObj
+ );
+
+ // Alert the user if the save process failed.
+ if (exitCodeObj.value !== 0) {
+ document.l10n.formatValue("openpgp-export-public-fail").then(value => {
+ alertUser(value);
+ });
+ return;
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("openPgpNotificationDescription"),
+ "openpgp-export-public-success"
+ );
+ document.getElementById("openPgpNotification").collapsed = false;
+}
+
+/**
+ * Ask the user to pick a file location and choose a password before proceeding
+ * with the backup of a secret key.
+ *
+ * @param {string} keyId - The ID of the selected OpenPGP Key.
+ * @param {string} keyFpr - The fingerprint of the selected OpenPGP Key.
+ */
+async function openPgpExportSecretKey(keyId, keyFpr) {
+ let outFile = EnigmailKeyRing.promptKeyExport2AsciiFilename(
+ window,
+ await document.l10n.formatValue("export-keypair-to-file"),
+ `${gIdentity.fullName}_${gIdentity.email}-${keyId}-secret.asc`
+ );
+
+ if (!outFile) {
+ return;
+ }
+
+ let args = {
+ okCallback: exportSecretKey,
+ file: outFile,
+ fprArray: [keyFpr],
+ };
+
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://openpgp/content/ui/backupKeyPassword.xhtml",
+ "",
+ "dialog,modal,centerscreen,resizable",
+ args
+ );
+}
+
+/**
+ * Export the secret key after a successful password setup.
+ *
+ * @param {string} password - The declared password to protect the keys.
+ * @param {Array} fprArray - The array of fingerprint of the selected keys.
+ * @param {object} file - The file where the keys should be saved.
+ * @param {boolean} confirmed - If the password was properly typed in the prompt.
+ */
+async function exportSecretKey(password, fprArray, file, confirmed = false) {
+ // Interrupt in case this method has been called directly without confirming
+ // the input password through the password prompt.
+ if (!confirmed) {
+ return;
+ }
+
+ let backupKeyBlock = await RNP.backupSecretKeys(fprArray, password);
+ if (!backupKeyBlock) {
+ Services.prompt.alert(
+ null,
+ await document.l10n.formatValue("save-keys-failed")
+ );
+ return;
+ }
+
+ await IOUtils.writeUTF8(file.path, backupKeyBlock)
+ .then(() => {
+ document.l10n.setAttributes(
+ document.getElementById("openPgpNotificationDescription"),
+ "openpgp-export-secret-success"
+ );
+ document.getElementById("openPgpNotification").collapsed = false;
+ })
+ .catch(async err => {
+ alertUser(await document.l10n.formatValue("openpgp-export-secret-fail"));
+ });
+}
+
+/**
+ * Remove the saved external GnuPG Key.
+ */
+async function removeExternalKey() {
+ if (!GetEnigmailSvc()) {
+ return;
+ }
+
+ // Interrupt if the external key is currently being used.
+ if (
+ gIdentity.getUnicharAttribute("last_entered_external_gnupg_key_id") ==
+ gIdentity.getUnicharAttribute("openpgp_key_id")
+ ) {
+ let [alertTitle, alertDescription] = await document.l10n.formatValues([
+ { id: "key-in-use-title" },
+ { id: "delete-key-in-use-description" },
+ ]);
+
+ Services.prompt.alert(null, alertTitle, alertDescription);
+ return;
+ }
+
+ let [title, description] = await document.l10n.formatValues([
+ { id: "delete-external-key-title" },
+ { id: "delete-external-key-description" },
+ ]);
+
+ // Ask for confirmation before proceeding.
+ if (!Services.prompt.confirm(null, title, description)) {
+ return;
+ }
+
+ gIdentity.setBoolAttribute("is_gnupg_key_id", false);
+ gIdentity.setUnicharAttribute("last_entered_external_gnupg_key_id", "");
+
+ reloadOpenPgpUI();
+}
diff --git a/comm/mail/extensions/am-e2e/am-e2e.xhtml b/comm/mail/extensions/am-e2e/am-e2e.xhtml
new file mode 100644
index 0000000000..8c5620dd2f
--- /dev/null
+++ b/comm/mail/extensions/am-e2e/am-e2e.xhtml
@@ -0,0 +1,32 @@
+<?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://messenger/skin/accountManage.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/am-smime.dtd">
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="parent.onPanelLoaded('am-e2e.xhtml');">
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://global/content/preferencesBindings.js"/>
+ <script src="chrome://messenger/content/AccountManager.js"/>
+ <script src="chrome://openpgp/content/ui/enigmailCommon.js"/>
+ <script src="chrome://messenger/content/am-e2e.js"/>
+
+ <vbox flex="1" style="overflow: auto;"><vbox id="containerBox" flex="1">
+ <hbox class="dialogheader">
+ <label class="dialogheader-title" value="&e2eTitle.label;"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+#include am-e2e.inc.xhtml
+ </vbox></vbox>
+
+</window>
diff --git a/comm/mail/extensions/am-e2e/components.conf b/comm/mail/extensions/am-e2e/components.conf
new file mode 100644
index 0000000000..f2399f06c2
--- /dev/null
+++ b/comm/mail/extensions/am-e2e/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{d7aad508-991c-401a-8b3f-7e4e055e1e2b}',
+ 'contract_ids': ['@mozilla.org/accountmanager/extension;1?name=e2e'],
+ 'jsm': 'resource:///modules/AME2E.jsm',
+ 'constructor': 'E2EService',
+ 'categories': {'mailnews-accountmanager-extensions': 'e2e-account-manager-extension'},
+ },
+]
diff --git a/comm/mail/extensions/am-e2e/moz.build b/comm/mail/extensions/am-e2e/moz.build
new file mode 100644
index 0000000000..5e89391d9a
--- /dev/null
+++ b/comm/mail/extensions/am-e2e/moz.build
@@ -0,0 +1,16 @@
+# vim: set filetype=python:
+# 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_JS_MODULES += [
+ "AME2E.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+JS_PREFERENCE_PP_FILES += [
+ "prefs/e2e-prefs.js",
+]
diff --git a/comm/mail/extensions/am-e2e/prefs/e2e-prefs.js b/comm/mail/extensions/am-e2e/prefs/e2e-prefs.js
new file mode 100644
index 0000000000..1a88003a75
--- /dev/null
+++ b/comm/mail/extensions/am-e2e/prefs/e2e-prefs.js
@@ -0,0 +1,285 @@
+#filter dumbComments emptyLines substitution
+
+// 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/.
+
+//
+// Prefs shared by OpenPGP and S/MIME
+//
+
+pref("mail.identity.default.encryptionpolicy", 0);
+pref("mail.identity.default.sign_mail", false);
+
+//
+// S/MIME prefs
+//
+
+pref("mail.identity.default.encryption_cert_name", "");
+pref("mail.identity.default.signing_cert_name", "");
+
+//
+// OpenPGP prefs
+//
+
+pref("openpgp.loglevel", "Warn");
+
+// If true, we allow the use of GnuPG for OpenPGP secret key operations
+pref("mail.openpgp.allow_external_gnupg", false);
+// If allow_external_gnupg is true: Optionally use a different gpg executable
+pref("mail.openpgp.alternative_gpg_path", "");
+// The hexadecimal OpenPGP key ID used for an identity.
+pref("mail.identity.default.openpgp_key_id", "");
+// If true, then openpgp_key_id is managed externally by GnuPG
+pref("mail.identity.default.is_gnupg_key_id", false);
+// The hexadecimal OpenPGP key ID externally configured by GnuPG used for an identity.
+pref("mail.identity.default.last_entered_external_gnupg_key_id", "");
+// When using external GnuPG, also load public keys from GnuPG keyring
+pref("mail.openpgp.fetch_pubkeys_from_gnupg", false);
+
+// When sending an OpenPGP message that is both signed and encrypted,
+// it's possible to use one combined MIME layer, or separate layers.
+pref("mail.openpgp.separate_mime_layers", false);
+
+// Load a JSON file that contains recipient key alias rules. See bug 1644085.
+// Suggested filename: openpgp-alias-rules.json
+// Simple filenames (without path) are loaded from the profile directory.
+// If you need to specify a path, use a file:// URL
+pref("mail.openpgp.alias_rules_file", "");
+
+pref("mail.openpgp.key_assistant.enable", true);
+
+// If set to true, enable user interface that allows the user to optionally set
+// and manage individual, user-defined passphrases for OpenPGP secret keys.
+// If set to false, the respective user interface will be hidden.
+// Even when set to true, the user may decide to use the original approach
+// for OpenPGP key protection (using the global primary password or none),
+// by selecting the respective choices in the user interface.
+// Note, if a user sets an user-defined passphrase while this this setting
+// is true, and then switches this setting to false, the keys will keep
+// the user-defined passphrase protection. The application will still prompt
+// to unlock the key using the user-defined passphrase whenever necessary.
+pref("mail.openpgp.passphrases.enabled", false);
+
+// Automatically enable encryption if S/MIME certificates or OpenPGP keys are
+// available for all recipients, and thus encryption is possible.
+// This pref is only about enabling, and doesn't control automatic disabling.
+pref("mail.e2ee.auto_enable", false);
+
+// If end-to-end encryption with S/MIME or OpenPGP is enabled,
+// and the user adds another recipient with unavailable certificate or key,
+// and this preference is true, then automatically disable encryption.
+// This pref is dangerous, and it is recommended to always keep it at false.
+// If you change the pref to true, the user might assume that encryption
+// is still enabled, and might not notice that encryption gets disabled.
+// There is an exception: If encryption was enabled, because the message
+// refers to an existing encrypted conversation (e.g. replying to an
+// encrypted message), this preference is ignored, encryption will
+// remain on. It isn't possible to override that behavior.
+// Note that encryption will never be disabled automatically on sending,
+// only when the list of recipients is changed.
+// If mail.e2ee.auto_enable is false, then mail.e2ee.auto_disable
+// will be ignored.
+pref("mail.e2ee.auto_disable", false);
+
+// If end-to-end encryption gets automatically disabled, inform the user
+// using a prompt.
+pref("mail.e2ee.notify_on_auto_disable", true);
+
+// If false, disable the reminder in composer, whether email could be
+// sent with OpenPGP encryption (without further user actions/decisions).
+pref("mail.openpgp.remind_encryption_possible", true);
+
+// If false, disable the reminder in composer, whether email could be
+// sent with S/MIME encryption (without further user actions/decisions).
+pref("mail.smime.remind_encryption_possible", true);
+
+pref("mail.smime.accept_insecure_sha1_message_signatures", false);
+
+// When sending, encrypt to this additional key. Not available in release channel builds.
+pref("mail.openpgp.debug.extra_encryption_key", "");
+
+// Hide prefs and menu entries from non-advanced users
+pref("temp.openpgp.advancedUser", false);
+
+// ** enigmail keySel preferences:
+// use rules to assign keys
+pref("temp.openpgp.assignKeysByRules", true);
+// use email addresses to assign keys
+pref("temp.openpgp.assignKeysByEmailAddr", true);
+// use manual dialog to assign missing keys
+pref("temp.openpgp.assignKeysManuallyIfMissing", true);
+// always srats manual dialog for keys
+pref("temp.openpgp.assignKeysManuallyAlways", false);
+
+// enable automatically decrypt/verify
+pref("temp.openpgp.autoDecrypt", true);
+
+// countdown for alerts when composing inline PGP HTML msgs
+pref("temp.openpgp.composeHtmlAlertCount", 3);
+
+// show warning message when clicking on sign icon
+pref("temp.openpgp.displaySignWarn", true);
+
+// try to match secondary uid to from address
+pref("temp.openpgp.displaySecondaryUid", true);
+
+// treat '-- ' as signature separator
+pref("temp.openpgp.doubleDashSeparator", true);
+
+// skip the attachments dialog
+pref("temp.openpgp.encryptAttachmentsSkipDlg", 0);
+
+// Encrypt to self
+pref("temp.openpgp.encryptToSelf", true);
+
+// enable 'Decrypt & open' for double click on attachment (if possible)
+pref("temp.openpgp.handleDoubleClick", true);
+
+// disable '<' and '>' around email addresses
+pref("temp.openpgp.hushMailSupport", false);
+
+// use -a for encrypting attachments for inline PGP
+pref("temp.openpgp.inlineAttachAsciiArmor", false);
+
+// extension to append for inline-encrypted attachments
+pref("temp.openpgp.inlineAttachExt", ".pgp");
+
+// extension to append for inline-signed attachments
+pref("temp.openpgp.inlineSigAttachExt", ".sig");
+
+// debug log directory (if set, also enabled debugging)
+pref("temp.openpgp.logDirectory", "");
+
+// List of key servers to use (comma separated list), ordered by priority.
+// Only the first supported keyserver will be used for uploading keys.
+pref("mail.openpgp.keyserver_list", "vks://keys.openpgp.org, hkps://keys.mailvelope.com");
+
+// keep passphrase for ... minutes
+pref("temp.openpgp.maxIdleMinutes", 5);
+
+// maximum number of parallel decrypt processes that Enigmaik will handle
+// (requests above the threshold are ignored)
+pref("temp.openpgp.maxNumProcesses", 3);
+
+// GnuPG hash algorithm
+// 0: automatic seletion (i.e. let GnuPG choose)
+// 1: SHA1, 2: RIPEMD160, 3: SHA256, 4: SHA384, 5: SHA512, 6: SHA224
+pref("temp.openpgp.mimeHashAlgorithm", 0);
+
+// no passphrase for GnuPG key needed
+pref("temp.openpgp.noPassphrase", false);
+
+// show quoted printable warning message (and remember selected state)
+pref("temp.openpgp.quotedPrintableWarn", 0);
+
+// use http proxy settings as set in Mozilla/Thunderbird
+pref("temp.openpgp.respectHttpProxy", true);
+
+// selection for which encryption model to prefer
+// 0: convenient encryption settings DEFAULT
+// 1: manual encryption settings
+pref("temp.openpgp.encryptionModel", 0);
+
+// enable encryption for replies to encrypted mails
+pref("temp.openpgp.keepSettingsForReply", true);
+
+// holds the last result of the dayily key expiry check
+pref("temp.openpgp.keyCheckResult", "");
+
+// selection for automatic send encrypted if all keys valid
+// 0: never
+// 1: if all keys found and accepted DEFAULT
+pref("temp.openpgp.autoSendEncrypted", 1);
+
+// enable automatic lookup of keys using Web Key Directory (WKD)
+// (see https://tools.ietf.org/html/draft-koch-openpgp-webkey-service)
+// 0: no
+// 1: yes DEFAULT
+pref("temp.openpgp.autoWkdLookup", 1);
+
+// ask to confirm before sending
+// 0: never DEFAULT
+// 1: always
+// 2: if send encrypted
+// 3: if send unencrypted
+// 4: if send (un)encrypted due to rules
+pref("temp.openpgp.confirmBeforeSending", 0);
+
+// show "Missing Trust in own keys" message (and remember selected state)
+pref("temp.openpgp.warnOnMissingOwnerTrust", true);
+
+// use GnuPG's default instead of Enigmail/Mozilla comment of for signed messages
+pref("temp.openpgp.useDefaultComment", true);
+
+// allow encryption to newsgroups
+pref("temp.openpgp.encryptToNews", false);
+pref("temp.openpgp.warnOnSendingNewsgroups", true);
+
+// holds the timestamp of the last check for GnuPG updates
+pref("temp.openpgp.gpgLastUpdate", "0");
+
+// set locale for GnuPG calls to en-US (Windows only)
+pref("temp.openpgp.gpgLocaleEn", true);
+
+// use PGP/MIME (0=never, 1=allow, 2=always)
+// pref("temp.openpgp.usePGPMimeOption",1); -- OBSOLETE, see mail.identity.default.pgpMimeMode
+
+// show "conflicting rules" message (and remember selected state)
+pref("temp.openpgp.warnOnRulesConflict", 0);
+
+// display a warning when the passphrase is cleared
+pref("temp.openpgp.warnClearPassphrase", true);
+
+// display a warning if the GnuPG version is deprecated
+pref("temp.openpgp.warnDeprecatedGnuPG", true);
+
+// warn if gpg-agent is found and "remember passphrase for X minutes is active"
+pref("temp.openpgp.warnGpgAgentAndIdleTime", true);
+
+// display a warning when the keys for all contacts are downloaded
+pref("temp.openpgp.warnDownloadContactKeys", true);
+
+// wrap HTML messages before sending inline PGP messages
+pref("temp.openpgp.wrapHtmlBeforeSend", true);
+
+// do reset the "references" and "in-reply-to" headers?
+pref("temp.openpgp.protectReferencesHdr", false);
+
+// tor configuration
+pref("temp.openpgp.torIpAddr", "127.0.0.1");
+pref("temp.openpgp.torServicePort", "9050");
+pref("temp.openpgp.torBrowserBundlePort", "9150");
+
+// gpg tor actions
+pref("temp.openpgp.downloadKeyWithTor", false);
+pref("temp.openpgp.downloadKeyRequireTor", false);
+pref("temp.openpgp.searchKeyWithTor", false);
+pref("temp.openpgp.searchKeyRequireTor", false);
+pref("temp.openpgp.uploadKeyWithTor", false);
+pref("temp.openpgp.uploadKeyRequireTor", false);
+
+// enable experimental features.
+// WARNING: such features may unfinished functions or tests that can break
+// existing functionality in Enigmail and Thunderbird!
+pref("temp.openpgp.enableExperiments", false);
+
+
+// Default pref values for the enigmail per-identity
+// settings
+
+pref("mail.identity.default.sendAutocryptHeaders", true);
+pref("mail.identity.default.attachPgpKey", true);
+pref("mail.identity.default.autoEncryptDrafts", true);
+pref("mail.identity.default.protectSubject", true);
+
+// 0 selected automatically, 1 prefer S/MIME, 2 prefer OpenPGP
+pref("mail.identity.default.e2etechpref", 0);
+
+//
+// Other settings (change Mozilla behaviour)
+//
+
+// disable flowed text by default
+// TODO: pref("mailnews.send_plaintext_flowed", false);
+
diff --git a/comm/mail/extensions/jar.mn b/comm/mail/extensions/jar.mn
new file mode 100644
index 0000000000..ea5bd38e80
--- /dev/null
+++ b/comm/mail/extensions/jar.mn
@@ -0,0 +1,7 @@
+# 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/am-e2e.xhtml (am-e2e/am-e2e.xhtml)
+ content/messenger/am-e2e.js (am-e2e/am-e2e.js)
diff --git a/comm/mail/extensions/mailviews/content/mailViewList.js b/comm/mail/extensions/mailviews/content/mailViewList.js
new file mode 100644
index 0000000000..277c74e553
--- /dev/null
+++ b/comm/mail/extensions/mailviews/content/mailViewList.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 gMailListView;
+var gListBox;
+var gEditButton;
+var gDeleteButton;
+
+function mailViewListOnLoad() {
+ gMailListView = Cc["@mozilla.org/messenger/mailviewlist;1"].getService(
+ Ci.nsIMsgMailViewList
+ );
+ gListBox = document.getElementById("mailViewList");
+
+ // Construct list view based on current mail view list data
+ refreshListView(null);
+ gEditButton = document.getElementById("editButton");
+ gDeleteButton = document.getElementById("deleteButton");
+
+ updateButtons();
+}
+
+function refreshListView(aSelectedMailView) {
+ // remove any existing items in the view...
+ for (var index = gListBox.getRowCount(); index > 0; index--) {
+ gListBox.getItemAtIndex(index - 1).remove();
+ }
+
+ var numItems = gMailListView.mailViewCount;
+ var mailView;
+ for (index = 0; index < numItems; index++) {
+ mailView = gMailListView.getMailViewAt(index);
+ gListBox.appendItem(mailView.prettyName, index);
+ if (
+ aSelectedMailView &&
+ mailView.prettyName == aSelectedMailView.prettyName
+ ) {
+ gListBox.selectedIndex = index;
+ }
+ }
+}
+
+function onNewMailView() {
+ window.openDialog(
+ "chrome://messenger/content/mailViewSetup.xhtml",
+ "",
+ "centerscreen,resizable,modal,titlebar,chrome",
+ { onOkCallback: refreshListView }
+ );
+}
+
+function onDeleteMailView() {
+ var bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+
+ if (
+ !Services.prompt.confirm(
+ window,
+ bundle.GetStringFromName("confirmViewDeleteTitle"),
+ bundle.GetStringFromName("confirmViewDeleteMessage")
+ )
+ ) {
+ return;
+ }
+
+ // get the selected index
+ var selectedIndex = gListBox.selectedIndex;
+ if (selectedIndex >= 0) {
+ var mailView = gMailListView.getMailViewAt(selectedIndex);
+ if (mailView) {
+ gMailListView.removeMailView(mailView);
+ // now remove it from the view...
+ gListBox.selectedItem.remove();
+
+ // select the next item in the list..
+ if (selectedIndex < gListBox.getRowCount()) {
+ gListBox.selectedIndex = selectedIndex;
+ } else {
+ gListBox.selectedIndex = gListBox.getRowCount() - 1;
+ }
+
+ gMailListView.save();
+ }
+ }
+}
+
+function onEditMailView() {
+ // get the selected index
+ var selectedIndex = gListBox.selectedIndex;
+ if (selectedIndex >= 0) {
+ var selMailView = gMailListView.getMailViewAt(selectedIndex);
+ // open up the mail view setup dialog passing in the mail view as an argument....
+
+ var args = { mailView: selMailView, onOkCallback: refreshListView };
+
+ window.openDialog(
+ "chrome://messenger/content/mailViewSetup.xhtml",
+ "",
+ "centerscreen,modal,resizable,titlebar,chrome",
+ args
+ );
+ }
+}
+
+function onMailViewSelect(event) {
+ updateButtons();
+}
+
+function updateButtons() {
+ var selectedIndex = gListBox.selectedIndex;
+ // "edit" and "delete" only enabled when one filter selected
+ gEditButton.disabled = selectedIndex < 0;
+ gDeleteButton.disabled = selectedIndex < 0;
+}
diff --git a/comm/mail/extensions/mailviews/content/mailViewList.xhtml b/comm/mail/extensions/mailviews/content/mailViewList.xhtml
new file mode 100644
index 0000000000..2e1115403b
--- /dev/null
+++ b/comm/mail/extensions/mailviews/content/mailViewList.xhtml
@@ -0,0 +1,63 @@
+<?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://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % mailViewLisDTD SYSTEM "chrome://messenger/locale/mailViewList.dtd">
+%mailViewLisDTD;
+<!ENTITY % FilterListDialogDTD SYSTEM "chrome://messenger/locale/FilterListDialog.dtd">
+%FilterListDialogDTD; ]>
+
+<window
+ id="mailViewListDialog"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="mailViewListOnLoad();"
+ windowtype="mailnews:mailviewlist"
+ lightweightthemes="true"
+ title="&mailViewListTitle.label;"
+ width="400"
+ height="340"
+ persist="screenX screenY width height"
+>
+ <dialog buttons="accept">
+ <script src="chrome://messenger/content/mailViewList.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <vbox flex="1">
+ <hbox flex="1">
+ <richlistbox
+ id="mailViewList"
+ flex="1"
+ onselect="onMailViewSelect(event);"
+ />
+
+ <vbox>
+ <button
+ id="newButton"
+ label="&newButton.label;"
+ accesskey="&newButton.accesskey;"
+ oncommand="onNewMailView();"
+ />
+ <button
+ id="editButton"
+ label="&editButton.label;"
+ accesskey="&editButton.accesskey;"
+ oncommand="onEditMailView();"
+ />
+ <button
+ id="deleteButton"
+ label="&deleteButton.label;"
+ accesskey="&deleteButton.accesskey;"
+ oncommand="onDeleteMailView();"
+ />
+ </vbox>
+ </hbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/extensions/mailviews/content/mailViewSetup.js b/comm/mail/extensions/mailviews/content/mailViewSetup.js
new file mode 100644
index 0000000000..b10213c5a7
--- /dev/null
+++ b/comm/mail/extensions/mailviews/content/mailViewSetup.js
@@ -0,0 +1,108 @@
+/* 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 ../../../../mailnews/search/content/searchTerm.js */
+
+document.addEventListener("dialogaccept", onOK);
+
+var gMailView = null;
+
+var dialog;
+
+function mailViewOnLoad() {
+ initializeSearchWidgets();
+ initializeMailViewOverrides();
+ dialog = {};
+
+ if ("arguments" in window && window.arguments[0]) {
+ var args = window.arguments[0];
+ if ("mailView" in args) {
+ gMailView = window.arguments[0].mailView;
+ }
+ if ("onOkCallback" in args) {
+ dialog.okCallback = window.arguments[0].onOkCallback;
+ }
+ }
+
+ dialog.OKButton = document.querySelector("dialog").getButton("accept");
+ dialog.nameField = document.getElementById("name");
+ dialog.nameField.focus();
+
+ setSearchScope(Ci.nsMsgSearchScope.offlineMail);
+
+ if (gMailView) {
+ dialog.nameField.value = gMailView.prettyName;
+ initializeSearchRows(
+ Ci.nsMsgSearchScope.offlineMail,
+ gMailView.searchTerms
+ );
+ } else {
+ onMore(null);
+ }
+
+ doEnabling();
+}
+
+function mailViewOnUnLoad() {}
+
+function onOK() {
+ var mailViewList = Cc["@mozilla.org/messenger/mailviewlist;1"].getService(
+ Ci.nsIMsgMailViewList
+ );
+
+ // reflect the search widgets back into the search session
+ var newMailView = null;
+ if (gMailView) {
+ gMailView.searchTerms = saveSearchTerms(gMailView.searchTerms, gMailView);
+ // if the name of the view has been changed...
+ if (gMailView.prettyName != dialog.nameField.value) {
+ gMailView.mailViewName = dialog.nameField.value;
+ }
+ } else {
+ // otherwise, create a new mail view
+ newMailView = mailViewList.createMailView();
+
+ newMailView.searchTerms = saveSearchTerms(
+ newMailView.searchTerms,
+ newMailView
+ );
+ newMailView.mailViewName = dialog.nameField.value;
+ // now add the mail view to our mail view list
+ mailViewList.addMailView(newMailView);
+ }
+
+ mailViewList.save();
+
+ if (dialog.okCallback) {
+ dialog.okCallback(gMailView ? gMailView : newMailView);
+ }
+}
+
+function initializeMailViewOverrides() {
+ // replace some text with something we want. Need to add some ids to searchOverlay.js
+ // var orButton = document.getElementById('or');
+ // orButton.setAttribute('label', 'Any of the following');
+ // var andButton = document.getElementById('and');
+ // andButton.setAttribute('label', 'All of the following');
+ // matchAll doesn't make sense for views, since views are a single folder
+ hideMatchAllItem();
+}
+
+function UpdateAfterCustomHeaderChange() {
+ updateSearchAttributes();
+}
+
+function doEnabling() {
+ if (dialog.nameField.value) {
+ if (dialog.OKButton.disabled) {
+ dialog.OKButton.disabled = false;
+ }
+ } else if (!dialog.OKButton.disabled) {
+ dialog.OKButton.disabled = true;
+ }
+}
+
+function onEnterInSearchTerm() {
+ // no-op for us...
+}
diff --git a/comm/mail/extensions/mailviews/content/mailViewSetup.xhtml b/comm/mail/extensions/mailviews/content/mailViewSetup.xhtml
new file mode 100644
index 0000000000..a9ff2ef5c9
--- /dev/null
+++ b/comm/mail/extensions/mailviews/content/mailViewSetup.xhtml
@@ -0,0 +1,61 @@
+<?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://messenger/skin/shared/input-fields.css" type="text/css" ?>
+<?xml-stylesheet href="chrome://messenger/skin/searchDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [
+ <!ENTITY % mailViewDTD SYSTEM "chrome://messenger/locale/mailViewSetup.dtd">
+ %mailViewDTD;
+ <!ENTITY % searchTermDTD SYSTEM "chrome://messenger/locale/searchTermOverlay.dtd">
+ %searchTermDTD;
+]>
+
+<window id="mailViewSetupDialog"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="mailViewOnLoad();"
+ onunload="mailViewOnUnLoad();"
+ windowtype="mailnews:mailview"
+ title="&mailViewSetupTitle.label;"
+ style="min-width: 52em; min-height: 22em;"
+ persist="screenX screenY width height">
+<dialog buttons="accept,cancel">
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://messenger/content/mailViewSetup.js"/>
+ <script src="chrome://messenger/content/searchTerm.js"/>
+ <script src="chrome://messenger/content/searchWidgets.js"/>
+ <script src="chrome://messenger/content/dateFormat.js"/>
+ <script src="chrome://messenger/content/dialogShadowDom.js"/>
+
+ <vbox flex="1">
+ <separator class="thin"/>
+ <vbox>
+ <hbox align="center">
+ <label value="&mailViewHeading.label;"
+ accesskey="&mailViewHeading.accesskey;"
+ control="name"/>
+ <html:input id="name" type="text"
+ class="input-inline"
+ tabindex="0"
+ oninput="doEnabling();"/>
+ </hbox>
+ </vbox>
+
+ <separator/>
+ <label value="&searchTermCaption.label;"/>
+ <hbox flex="1">
+ <vbox id="searchTermListBox" flex="1">
+#include ../../../../mailnews/search/content/searchTerm.inc.xhtml
+ </hbox>
+ </vbox>
+
+</dialog>
+</window>
diff --git a/comm/mail/extensions/mailviews/content/msgViewPickerOverlay.js b/comm/mail/extensions/mailviews/content/msgViewPickerOverlay.js
new file mode 100644
index 0000000000..4412be2ffe
--- /dev/null
+++ b/comm/mail/extensions/mailviews/content/msgViewPickerOverlay.js
@@ -0,0 +1,282 @@
+/* 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 OpenOrFocusWindow */ // From mailWindowOverlay.js
+/* globals GetSelectedMsgFolders */ // From messenger.js
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MailViewConstants } = ChromeUtils.import(
+ "resource:///modules/MailViewManager.jsm"
+);
+
+// these constants are now authoritatively defined in MailViewManager.jsm (above)
+// tag views have kViewTagMarker + their key as value
+var kViewItemAll = MailViewConstants.kViewItemAll;
+var kViewItemUnread = MailViewConstants.kViewItemUnread;
+var kViewItemTags = MailViewConstants.kViewItemTags; // former labels used values 2-6
+var kViewItemNotDeleted = MailViewConstants.kViewItemNotDeleted;
+// not a real view! a sentinel value to pop up a dialog
+var kViewItemVirtual = MailViewConstants.kViewItemVirtual;
+// not a real view! a sentinel value to pop up a dialog
+var kViewItemCustomize = MailViewConstants.kViewItemCustomize;
+var kViewItemFirstCustom = MailViewConstants.kViewItemFirstCustom;
+
+var kViewCurrent = MailViewConstants.kViewCurrent;
+var kViewCurrentTag = MailViewConstants.kViewCurrentTag;
+var kViewTagMarker = MailViewConstants.kViewTagMarker;
+
+/**
+ * A reference to the nsIMsgMailViewList service that tracks custom mail views.
+ */
+var gMailViewList = null;
+
+// perform the view/action requested by the aValue string
+// and set the view picker label to the aLabel string
+function ViewChange(aValue) {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let viewWrapper = about3Pane.gViewWrapper;
+ if (!viewWrapper) {
+ return;
+ }
+
+ if (aValue == kViewItemCustomize || aValue == kViewItemVirtual) {
+ // restore to the previous view value, in case they cancel
+ if (aValue == kViewItemCustomize) {
+ LaunchCustomizeDialog();
+ } else {
+ about3Pane.folderPane.newVirtualFolder(
+ ViewPickerBinding.currentViewLabel,
+ viewWrapper.search.viewTerms,
+ about3Pane.gFolder
+ );
+ }
+ return;
+ }
+
+ // tag menuitem values are of the form :<keyword>
+ if (isNaN(aValue)) {
+ // split off the tag key
+ var tagkey = aValue.substr(kViewTagMarker.length);
+ viewWrapper.setMailView(kViewItemTags, tagkey);
+ } else {
+ var numval = Number(aValue);
+ viewWrapper.setMailView(numval, null);
+ }
+}
+
+function ViewChangeByMenuitem(aMenuitem) {
+ // Mac View menu menuitems don't have XBL bindings
+ ViewChange(aMenuitem.getAttribute("value"));
+}
+
+/**
+ * Mediates interaction with the #viewPickerPopup. In theory this should be
+ * an XBL binding, but for the insanity where the view picker may not be
+ * visible at all times (or ever). No view picker widget, no binding.
+ */
+var ViewPickerBinding = {
+ /**
+ * Return true if the view picker is visible. This is used by the
+ * FolderDisplayWidget to know whether or not to actually use mailviews. (The
+ * idea is that if we are not visible, then it would be confusing to the user
+ * if we filtered their mail since they would have no feedback about this and
+ * no way to change it.)
+ */
+ get isVisible() {
+ return !!document.querySelector("#unifiedToolbarContent .view-picker");
+ },
+
+ /**
+ * Return the string value representing the current mail view value as
+ * understood by the view picker widgets. The value is the index for
+ * everything but tags. for tags it's the ":"-prefixed tagname.
+ */
+ get currentViewValue() {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let viewWrapper = about3Pane.gViewWrapper;
+ if (!viewWrapper) {
+ return "";
+ }
+ if (viewWrapper.mailViewIndex == kViewItemTags) {
+ return kViewTagMarker + viewWrapper.mailViewData;
+ }
+ return viewWrapper.mailViewIndex + "";
+ },
+
+ /**
+ * @returns The label for the current mail view value.
+ */
+ get currentViewLabel() {
+ return document.querySelector(
+ `#toolbarViewPickerPopup [value="${this.currentViewValue}"]`
+ )?.label;
+ },
+};
+
+function LaunchCustomizeDialog() {
+ OpenOrFocusWindow(
+ {},
+ "mailnews:mailviewlist",
+ "chrome://messenger/content/mailViewList.xhtml"
+ );
+}
+
+/**
+ * All of these Refresh*ViewPopup* methods have to deal with several menu
+ * instances. For example, the "View... Messages" menu, the view picker menu
+ * list in the toolbar, in appmenu/View/Messages, etc.
+ *
+ * @param {Element} viewPopup - A menu popup element.
+ */
+function RefreshAllViewPopups(viewPopup) {
+ RefreshViewPopup(viewPopup);
+ let menupopups = viewPopup.getElementsByTagName("menupopup");
+ if (menupopups.length > 1) {
+ // When we have menupopups, we assume both tags and custom views are there.
+ RefreshTagsPopup(menupopups[0]);
+ RefreshCustomViewsPopup(menupopups[1]);
+ }
+}
+
+/**
+ * Refresh the view messages popup menu/panel. For example set checked and
+ * hidden state on menu items. Used for example for appmenu/View/Messages panel.
+ *
+ * @param {Element} viewPopup - A menu popup element.
+ */
+function RefreshViewPopup(viewPopup) {
+ // Mark default views if selected.
+ let currentViewValue = ViewPickerBinding.currentViewValue;
+
+ let viewAll = viewPopup.querySelector('[value="' + kViewItemAll + '"]');
+ viewAll.setAttribute("checked", currentViewValue == kViewItemAll);
+
+ let viewUnread = viewPopup.querySelector('[value="' + kViewItemUnread + '"]');
+ viewUnread.setAttribute("checked", currentViewValue == kViewItemUnread);
+
+ let viewNotDeleted = viewPopup.querySelector(
+ '[value="' + kViewItemNotDeleted + '"]'
+ );
+
+ let folderArray = GetSelectedMsgFolders();
+ if (folderArray.length == 0) {
+ return;
+ }
+
+ // Only show the "Not Deleted" item for IMAP servers that are using the IMAP
+ // delete model.
+ viewNotDeleted.setAttribute("hidden", true);
+ var msgFolder = folderArray[0];
+ var server = msgFolder.server;
+ if (server.type == "imap") {
+ let imapServer = server.QueryInterface(Ci.nsIImapIncomingServer);
+
+ if (imapServer.deleteModel == Ci.nsMsgImapDeleteModels.IMAPDelete) {
+ viewNotDeleted.setAttribute("hidden", false);
+ viewNotDeleted.setAttribute(
+ "checked",
+ currentViewValue == kViewItemNotDeleted
+ );
+ }
+ }
+}
+
+/**
+ * Refresh the contents of the custom views popup menu/panel.
+ * Used for example for appmenu/View/Messages/CustomViews panel.
+ *
+ * @param {Element} parent - Parent element that will receive the menu items.
+ * @param {string} [elementName] - Type of menu items to create (e.g. "menuitem", "toolbarbutton").
+ * @param {string} [classes] - Classes to set on the menu items.
+ */
+function RefreshCustomViewsPopup(parent, elementName = "menuitem", classes) {
+ if (!gMailViewList) {
+ gMailViewList = Cc["@mozilla.org/messenger/mailviewlist;1"].getService(
+ Ci.nsIMsgMailViewList
+ );
+ }
+
+ // Remove all menu items.
+ while (parent.hasChildNodes()) {
+ parent.lastChild.remove();
+ }
+
+ // Rebuild the list.
+ const currentView = ViewPickerBinding.currentViewValue;
+ const numItems = gMailViewList.mailViewCount;
+
+ for (let i = 0; i < numItems; ++i) {
+ const viewInfo = gMailViewList.getMailViewAt(i);
+ const item = document.createXULElement(elementName);
+
+ item.setAttribute("label", viewInfo.prettyName);
+ item.setAttribute("value", kViewItemFirstCustom + i);
+ item.setAttribute("type", "radio");
+
+ if (classes) {
+ item.setAttribute("class", classes);
+ }
+ if (kViewItemFirstCustom + i == currentView) {
+ item.setAttribute("checked", true);
+ }
+
+ item.addEventListener("command", () =>
+ ViewChange(kViewItemFirstCustom + i)
+ );
+
+ parent.appendChild(item);
+ }
+}
+
+/**
+ * Refresh the contents of the tags popup menu/panel. For example, used for
+ * appmenu/View/Messages/Tags.
+ *
+ * @param {Element} parent - Parent element that will receive the menu items.
+ * @param {string} [elementName] - Type of menu items to create (e.g. "menuitem", "toolbarbutton").
+ * @param {string} [classes] - Classes to set on the menu items.
+ */
+function RefreshTagsPopup(parent, elementName = "menuitem", classes) {
+ // Remove all pre-existing menu items.
+ while (parent.hasChildNodes()) {
+ parent.lastChild.remove();
+ }
+
+ // Create tag menu items.
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let viewWrapper = about3Pane.gViewWrapper;
+ if (!viewWrapper) {
+ return;
+ }
+ const currentTagKey =
+ viewWrapper.mailViewIndex == kViewItemTags ? viewWrapper.mailViewData : "";
+
+ const tagArray = MailServices.tags.getAllTags();
+
+ tagArray.forEach(tagInfo => {
+ const item = document.createXULElement(elementName);
+
+ item.setAttribute("label", tagInfo.tag);
+ item.setAttribute("value", kViewTagMarker + tagInfo.key);
+ item.setAttribute("type", "radio");
+
+ if (tagInfo.key == currentTagKey) {
+ item.setAttribute("checked", true);
+ }
+ if (tagInfo.color) {
+ item.setAttribute("style", `color: ${tagInfo.color};`);
+ }
+ if (classes) {
+ item.setAttribute("class", classes);
+ }
+
+ item.addEventListener("command", () =>
+ ViewChange(kViewTagMarker + tagInfo.key)
+ );
+
+ parent.appendChild(item);
+ });
+}
diff --git a/comm/mail/extensions/mailviews/jar.mn b/comm/mail/extensions/mailviews/jar.mn
new file mode 100644
index 0000000000..4050fe704c
--- /dev/null
+++ b/comm/mail/extensions/mailviews/jar.mn
@@ -0,0 +1,10 @@
+# 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/msgViewPickerOverlay.js (content/msgViewPickerOverlay.js)
+ content/messenger/mailViewSetup.js (content/mailViewSetup.js)
+* content/messenger/mailViewSetup.xhtml (content/mailViewSetup.xhtml)
+ content/messenger/mailViewList.xhtml (content/mailViewList.xhtml)
+ content/messenger/mailViewList.js (content/mailViewList.js)
diff --git a/comm/mail/extensions/mailviews/moz.build b/comm/mail/extensions/mailviews/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/mail/extensions/mailviews/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/extensions/moz.build b/comm/mail/extensions/moz.build
new file mode 100644
index 0000000000..edd6b18b40
--- /dev/null
+++ b/comm/mail/extensions/moz.build
@@ -0,0 +1,16 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+DIRS += [
+ "mailviews",
+ "smime",
+]
+
+DIRS += [
+ "am-e2e",
+ "openpgp",
+]
diff --git a/comm/mail/extensions/openpgp/README.md b/comm/mail/extensions/openpgp/README.md
new file mode 100644
index 0000000000..5e7ae35fe2
--- /dev/null
+++ b/comm/mail/extensions/openpgp/README.md
@@ -0,0 +1,22 @@
+This directory contains an incomplete OpenPGP email integration,
+which is based on an initial import of Enigmail Add-on code.
+
+- The code is disabled by default, and can be enabled using
+ build time configuration --enable-openpgp
+
+- Care must be taken that any changes to this directory have no
+ functional effect on the default behavior of TB.
+
+- Any commits to this directory that accidentally cause the automated
+ tests of TB to break may be backed out immediately.
+
+- All commits will be done with DONTBUILD in the commit comment,
+ to avoid unnecessary load on the infrastructure.
+
+- For questions or changes, consult:
+ Kai Engert, Patrick Brunschwig, Magnus Melin
+
+- Prior to enabling this code, all code must be enabled for
+ eslint and must be fully reviewd, as tracked in:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1595319
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1595325
diff --git a/comm/mail/extensions/openpgp/content/BondOpenPGP.jsm b/comm/mail/extensions/openpgp/content/BondOpenPGP.jsm
new file mode 100644
index 0000000000..e9d1086cc7
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/BondOpenPGP.jsm
@@ -0,0 +1,86 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+/* This file is a thin interface on top of the rest of the OpenPGP
+ * integration ot minimize the amount of code that must be
+ * included in files outside the extensions/openpgp directory. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["BondOpenPGP"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailVerify: "chrome://openpgp/content/modules/mimeVerify.jsm",
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+ RNP: "chrome://openpgp/content/modules/RNP.jsm",
+ GPGME: "chrome://openpgp/content/modules/GPGME.jsm",
+});
+
+/*
+// Enable this block to view syntax errors in these files, which are
+// difficult to see when lazy loading.
+var { GPGME } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/GPGME.jsm"
+);
+var { RNP } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/RNP.jsm"
+);
+var { GPGMELibLoader } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/GPGMELib.jsm"
+);
+var { RNPLibLoader } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/RNPLib.jsm"
+);
+*/
+
+var BondOpenPGP = {
+ logException(exc) {
+ try {
+ Services.console.logStringMessage(exc.toString() + "\n" + exc.stack);
+ } catch (x) {}
+ },
+
+ _alreadyTriedInit: false, // if already true, we will not try again
+
+ async init() {
+ if (this._alreadyTriedInit) {
+ // We have previously attempted to init, don't try again.
+ return;
+ }
+
+ this._alreadyTriedInit = true;
+
+ lazy.EnigmailKeyRing.init();
+ lazy.EnigmailVerify.init();
+
+ let initDone = await lazy.RNP.init({});
+ if (!initDone) {
+ let { error } = this.getRNPLibStatus();
+ throw new Error(error);
+ }
+
+ if (Services.prefs.getBoolPref("mail.openpgp.allow_external_gnupg")) {
+ lazy.GPGME.init({});
+ }
+
+ // trigger service init
+ await lazy.EnigmailCore.getService();
+ },
+
+ getRNPLibStatus() {
+ return lazy.RNP.getRNPLibStatus();
+ },
+
+ openKeyManager(window) {
+ lazy.EnigmailWindows.openKeyManager(window);
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/CollectedKeysDB.jsm b/comm/mail/extensions/openpgp/content/modules/CollectedKeysDB.jsm
new file mode 100644
index 0000000000..0c4581e9f1
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/CollectedKeysDB.jsm
@@ -0,0 +1,355 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ RNP: "chrome://openpgp/content/modules/RNP.jsm",
+});
+
+/**
+ * Database of collected OpenPGP keys.
+ */
+const EXPORTED_SYMBOLS = ["CollectedKeysDB"];
+
+var log = console.createInstance({
+ prefix: "openpgp",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "openpgp.loglevel",
+});
+
+/**
+ * Class that handles storage of OpenPGP keys that were found through various
+ * sources.
+ */
+class CollectedKeysDB {
+ /**
+ * @param {IDBDatabase} database
+ */
+ constructor(db) {
+ this.db = db;
+ this.db.onclose = () => {
+ log.debug("DB closed!");
+ };
+ this.db.onabort = () => {
+ log.debug("DB operation aborted!");
+ };
+ }
+
+ /**
+ * Get a database instance.
+ *
+ * @returns {CollectedKeysDB} a instance.
+ */
+ static async getInstance() {
+ return new Promise((resolve, reject) => {
+ const VERSION = 1;
+ let DBOpenRequest = indexedDB.open("openpgp_cache", VERSION);
+ DBOpenRequest.onupgradeneeded = event => {
+ let db = event.target.result;
+ if (event.oldVersion < 1) {
+ // Create an objectStore for this database
+ let objectStore = db.createObjectStore("seen_keys", {
+ keyPath: "fingerprint",
+ });
+ objectStore.createIndex("emails", "emails", {
+ unique: false,
+ multiEntry: true,
+ });
+ objectStore.createIndex("created", "created");
+ objectStore.createIndex("expires", "expires");
+ objectStore.createIndex("timestamp", "timestamp");
+ }
+ log.debug(`Database ready at version ${VERSION}`);
+ };
+ DBOpenRequest.onerror = event => {
+ log.debug(`Error loading database: ${DBOpenRequest.error.message}`);
+ reject(DBOpenRequest.error);
+ };
+ DBOpenRequest.onsuccess = event => {
+ let keyDb = new CollectedKeysDB(DBOpenRequest.result);
+ resolve(keyDb);
+ };
+ });
+ }
+
+ /**
+ * @typedef {object} CollectedKey - Key details.
+ * @property {string[]} emails - Lowercase email addresses associated with this key
+ * @property {string} fingerprint - Key fingerprint.
+ * @property {string[]} userIds - UserIds for this key.
+ * @property {string} id - Key ID with a 0x prefix.
+ * @property {string} pubKey - The public key data.
+ * @property {Date} created - Key creation date.
+ * @property {Date} expires - Key expiry date.
+ * @property {Date} timestamp - Timestamp of last time this key was saved/updated.
+ * @property {object[]} sources - List of sources we saw this key.
+ * @property {string} sources.uri - URI of the source.
+ * @property {string} sources.type - Type of source (e.g. attachment, wkd, keyserver)
+ * @property {string} sources.description - Description of the source, if any. E.g. the attachment name.
+ */
+
+ /**
+ * Store a key.
+ *
+ * @param {CollectedKey} key - the key to store.
+ */
+ async storeKey(key) {
+ if (key.fingerprint?.length != 40) {
+ throw new Error(`Invalid fingerprint: ${key.fingerprint}`);
+ }
+ return new Promise((resolve, reject) => {
+ let transaction = this.db.transaction(["seen_keys"], "readwrite");
+ transaction.oncomplete = () => {
+ log.debug(`Stored key 0x${key.id} for ${key.emails}`);
+ let window = Services.wm.getMostRecentWindow("mail:3pane");
+ window.dispatchEvent(
+ new CustomEvent("keycollected", { detail: { key } })
+ );
+ resolve();
+ };
+ transaction.onerror = () => {
+ reject(transaction.error);
+ };
+ // log.debug(`Storing key: ${JSON.stringify(key, null, 2)}`);
+ key.timestamp = new Date();
+ transaction.objectStore("seen_keys").put(key);
+ transaction.commit();
+ });
+ }
+
+ /**
+ * Find key for fingerprint.
+ *
+ * @param {string} fingerprint - Fingerprint to find key for.
+ * @returns {CollectedKey} the key found, or null.
+ */
+ async findKeyForFingerprint(fingerprint) {
+ if (fingerprint?.length != 40) {
+ throw new Error(`Invalid fingerprint: ${fingerprint}`);
+ }
+ return new Promise((resolve, reject) => {
+ let request = this.db
+ .transaction("seen_keys")
+ .objectStore("seen_keys")
+ .get(fingerprint);
+
+ request.onsuccess = event => {
+ // If we didn't find anything, result is undefined. If so return null
+ // so that we make it clear we found "something", but it was nothing.
+ resolve(request.result || null);
+ };
+ request.onerror = event => {
+ log.debug(`Find key failed: ${request.error.message}`);
+ reject(request.error);
+ };
+ });
+ }
+
+ /**
+ * Find keys for email.
+ *
+ * @param {string} email - Email to find keys for.
+ * @returns {CollectedKey[]} the keys found.
+ */
+ async findKeysForEmail(email) {
+ email = email.toLowerCase();
+ return new Promise((resolve, reject) => {
+ let keys = [];
+ let index = this.db
+ .transaction("seen_keys")
+ .objectStore("seen_keys")
+ .index("emails");
+ index.openCursor(IDBKeyRange.only(email)).onsuccess = function (event) {
+ let cursor = event.target.result;
+ if (!cursor) {
+ // All results done.
+ resolve(keys);
+ return;
+ }
+ keys.push(cursor.value);
+ cursor.continue();
+ };
+ });
+ }
+
+ /**
+ * Find existing key in the database, and use RNP to merge such a key
+ * with the passed in keyBlock.
+ * Merging will always use the email addresses and user IDs of the merged key,
+ * which causes old revoked entries to be removed.
+ * We keep the list of previously seen source locations.
+ *
+ * @param {EnigmailKeyOb} - key object
+ * @param {string} keyBlock - public key to merge
+ * @param {object} source - source of the information
+ * @param {string} source.type - source type
+ * @param {string} source.uri - source uri
+ * @param {string?} source.description - source description
+ * @returns {CollectedKey} merged key - not yet stored in the database
+ */
+ async mergeExisting(keyobj, keyBlock, source) {
+ let fpr = keyobj.fpr;
+ let existing = await this.findKeyForFingerprint(fpr);
+ let newKey;
+ let pubKey;
+ if (existing) {
+ pubKey = await lazy.RNP.mergePublicKeyBlocks(
+ fpr,
+ existing.pubKey,
+ keyBlock
+ );
+ // Don't use EnigmailKey.getKeyListFromKeyBlock interactive.
+ // Use low level API for obtaining key list, we don't want to
+ // poison the app key cache.
+ // We also don't want to obtain any additional revocation certs.
+ let keys = await lazy.RNP.getKeyListFromKeyBlockImpl(
+ pubKey,
+ true,
+ false,
+ false,
+ false
+ );
+ if (!keys || !keys.length) {
+ throw new Error("Error getting keys from block");
+ }
+ if (keys.length != 1) {
+ throw new Error(`Got ${keys.length} keys for fpr=${fpr}`);
+ }
+ newKey = keys[0];
+ } else {
+ pubKey = keyBlock;
+ newKey = keyobj;
+ }
+
+ let key = {
+ emails: newKey.userIds.map(uid =>
+ MailServices.headerParser
+ .makeFromDisplayAddress(uid.userId)[0]
+ ?.email.toLowerCase()
+ .trim()
+ ),
+ fingerprint: newKey.fpr,
+ userIds: newKey.userIds.map(uid => uid.userId),
+ id: newKey.keyId,
+ pubKey,
+ created: new Date(newKey.keyCreated * 1000),
+ expires: newKey.expiryTime ? new Date(newKey.expiryTime * 1000) : null,
+ sources: [source],
+ };
+ if (existing) {
+ // Keep existing sources meta information.
+ let sourceType = source.type;
+ let sourceURI = source.uri;
+ for (let oldSource of existing.sources.filter(
+ s => !(s.type == sourceType && s.uri == sourceURI)
+ )) {
+ key.sources.push(oldSource);
+ }
+ }
+ return key;
+ }
+
+ /**
+ * Delete keys for email.
+ *
+ * @param {string} email - Email to delete keys for.
+ */
+ async deleteKeysForEmail(email) {
+ email = email.toLowerCase();
+ return new Promise((resolve, reject) => {
+ let transaction = this.db.transaction(["seen_keys"], "readwrite");
+ let objectStore = transaction.objectStore("seen_keys");
+ let request = objectStore.index("emails").openKeyCursor();
+ request.onsuccess = event => {
+ let cursor = request.result;
+ if (cursor) {
+ objectStore.delete(cursor.primaryKey);
+ cursor.continue();
+ } else {
+ log.debug(`Deleted all keys for ${email}.`);
+ }
+ };
+ transaction.oncomplete = () => {
+ log.debug(`Keys gone for email ${email}.`);
+ resolve(email);
+ };
+ transaction.onerror = event => {
+ log.debug(
+ `Could not delete keys for email ${email}: ${transaction.error.message}`
+ );
+ reject(transaction.error);
+ };
+ });
+ }
+
+ /**
+ * Delete key by fingerprint.
+ *
+ * @param {string} fingerprint - fingerprint of key to delete.
+ */
+ async deleteKey(fingerprint) {
+ if (fingerprint.length != 40) {
+ throw new Error(`Invalid fingerprint: ${fingerprint}`);
+ }
+ return new Promise((resolve, reject) => {
+ let transaction = this.db.transaction(["seen_keys"], "readwrite");
+ let request = transaction.objectStore("seen_keys").delete(fingerprint);
+ request.onsuccess = () => {
+ log.debug(`Keys gone for fingerprint ${fingerprint}.`);
+ resolve(fingerprint);
+ };
+ request.onerror = event => {
+ log.debug(
+ `Could not delete keys for fingerprint ${fingerprint}: ${transaction.error.message}`
+ );
+ reject(transaction.error);
+ };
+ });
+ }
+
+ /**
+ * Clear out data from the database.
+ */
+ async reset() {
+ return new Promise((resolve, reject) => {
+ let transaction = this.db.transaction(["seen_keys"], "readwrite");
+ let objectStore = transaction.objectStore("seen_keys");
+ transaction.oncomplete = () => {
+ log.debug(`Objectstore cleared.`);
+ resolve();
+ };
+ transaction.onerror = () => {
+ log.debug(`Could not clear objectstore: ${transaction.error.message}`);
+ reject(transaction.error);
+ };
+ objectStore.clear();
+ transaction.commit();
+ });
+ }
+
+ /**
+ * Delete database.
+ */
+ static async deleteDb() {
+ return new Promise((resolve, reject) => {
+ let DBOpenRequest = indexedDB.deleteDatabase("seen_keys");
+ DBOpenRequest.onsuccess = () => {
+ log.debug(`Success deleting database.`);
+ resolve();
+ };
+ DBOpenRequest.onerror = () => {
+ log.debug(`Error deleting database: ${DBOpenRequest.error.message}`);
+ reject(DBOpenRequest.error);
+ };
+ });
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/GPGME.jsm b/comm/mail/extensions/openpgp/content/modules/GPGME.jsm
new file mode 100644
index 0000000000..899b5ebd35
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/GPGME.jsm
@@ -0,0 +1,338 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["GPGME"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ctypes: "resource://gre/modules/ctypes.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ GPGMELibLoader: "chrome://openpgp/content/modules/GPGMELib.jsm",
+});
+
+var GPGMELib;
+
+var GPGME = {
+ hasRan: false,
+ libLoaded: false,
+ once() {
+ this.hasRan = true;
+ try {
+ GPGMELib = lazy.GPGMELibLoader.init();
+ if (!GPGMELib) {
+ return;
+ }
+ if (GPGMELib && GPGMELib.init()) {
+ GPGME.libLoaded = true;
+ }
+ } catch (e) {
+ console.log(e);
+ }
+ },
+
+ init(opts) {
+ opts = opts || {};
+
+ if (!this.hasRan) {
+ this.once();
+ }
+
+ return GPGME.libLoaded;
+ },
+
+ allDependenciesLoaded() {
+ return GPGME.libLoaded;
+ },
+
+ /**
+ * High level interface to retrieve public keys from GnuPG that
+ * contain a user ID that matches the given email address.
+ *
+ * @param {string} email - The email address to search for.
+ *
+ * @returns {Map} - a Map that contains ASCII armored key blocks
+ * indexed by fingerprint.
+ */
+ getPublicKeysForEmail(email) {
+ function keyFilterFunction(key) {
+ if (
+ key.contents.bitfield & GPGMELib.gpgme_key_t_revoked ||
+ key.contents.bitfield & GPGMELib.gpgme_key_t_expired ||
+ key.contents.bitfield & GPGMELib.gpgme_key_t_disabled ||
+ key.contents.bitfield & GPGMELib.gpgme_key_t_invalid ||
+ !(key.contents.bitfield & GPGMELib.gpgme_key_t_can_encrypt)
+ ) {
+ return false;
+ }
+
+ let matchesEmail = false;
+ let nextUid = key.contents.uids;
+ while (nextUid && !nextUid.isNull()) {
+ let uidEmail = nextUid.contents.email.readString();
+ // Variable email is provided by the outer scope.
+ if (uidEmail == email) {
+ matchesEmail = true;
+ break;
+ }
+ nextUid = nextUid.contents.next;
+ }
+ return matchesEmail;
+ }
+
+ return GPGMELib.exportKeys(email, false, keyFilterFunction);
+ },
+
+ async decrypt(encrypted, enArmorCB) {
+ let result = {};
+ result.decryptedData = "";
+
+ let arr = encrypted.split("").map(e => e.charCodeAt());
+ let encrypted_array = lazy.ctypes.uint8_t.array()(arr);
+ let tmp_array = lazy.ctypes.cast(
+ encrypted_array,
+ lazy.ctypes.char.array(encrypted_array.length)
+ );
+
+ let data_ciphertext = new GPGMELib.gpgme_data_t();
+ if (
+ GPGMELib.gpgme_data_new_from_mem(
+ data_ciphertext.address(),
+ tmp_array,
+ tmp_array.length,
+ 0
+ )
+ ) {
+ throw new Error("gpgme_data_new_from_mem failed");
+ }
+
+ let data_plain = new GPGMELib.gpgme_data_t();
+ if (GPGMELib.gpgme_data_new(data_plain.address())) {
+ throw new Error("gpgme_data_new failed");
+ }
+
+ let c1 = new GPGMELib.gpgme_ctx_t();
+ if (GPGMELib.gpgme_new(c1.address())) {
+ throw new Error("gpgme_new failed");
+ }
+
+ GPGMELib.gpgme_set_armor(c1, 1);
+
+ result.exitCode = GPGMELib.gpgme_op_decrypt_ext(
+ c1,
+ GPGMELib.GPGME_DECRYPT_UNWRAP,
+ data_ciphertext,
+ data_plain
+ );
+
+ if (GPGMELib.gpgme_data_release(data_ciphertext)) {
+ throw new Error("gpgme_data_release failed");
+ }
+
+ let result_len = new lazy.ctypes.size_t();
+ let result_buf = GPGMELib.gpgme_data_release_and_get_mem(
+ data_plain,
+ result_len.address()
+ );
+
+ if (!result_buf.isNull()) {
+ let unwrapped = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(result_len.value).ptr
+ ).contents;
+
+ // The result of decrypt(GPGME_DECRYPT_UNWRAP) is an OpenPGP message.
+ // Because old versions of GPGME (e.g. 1.12.0) may return the
+ // results as a binary encoding (despite gpgme_set_armor),
+ // we check if the result looks like an armored message.
+ // If it doesn't we apply armoring ourselves.
+
+ let armor_head = "-----BEGIN PGP MESSAGE-----";
+
+ let head_of_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(armor_head.length).ptr
+ ).contents;
+
+ let isArmored = false;
+
+ try {
+ // If this is binary, which usually isn't a valid UTF-8
+ // encoding, it will throw an error.
+ let head_of_array_string = head_of_array.readString();
+ if (head_of_array_string == armor_head) {
+ isArmored = true;
+ }
+ } catch (ex) {}
+
+ if (isArmored) {
+ result.decryptedData = unwrapped.readString();
+ } else {
+ result.decryptedData = enArmorCB(unwrapped, result_len.value);
+ }
+
+ GPGMELib.gpgme_free(result_buf);
+ }
+
+ GPGMELib.gpgme_release(c1);
+
+ return result;
+ },
+
+ async signDetached(plaintext, args, resultStatus) {
+ resultStatus.exitCode = -1;
+ resultStatus.statusFlags = 0;
+ resultStatus.statusMsg = "";
+ resultStatus.errorMsg = "";
+
+ if (args.encrypt || !args.sign || !args.sigTypeDetached) {
+ throw new Error("invalid encrypt/sign parameters");
+ }
+ if (!plaintext) {
+ throw new Error("cannot sign empty data");
+ }
+
+ let result = null;
+ //args.sender must be keyId
+ let keyId = args.sender.replace(/^0x/, "").toUpperCase();
+
+ let ctx = new GPGMELib.gpgme_ctx_t();
+ if (GPGMELib.gpgme_new(ctx.address())) {
+ throw new Error("gpgme_new failed");
+ }
+ GPGMELib.gpgme_set_armor(ctx, 1);
+ GPGMELib.gpgme_set_textmode(ctx, 1);
+ let keyHandle = new GPGMELib.gpgme_key_t();
+ if (!GPGMELib.gpgme_get_key(ctx, keyId, keyHandle.address(), 1)) {
+ if (!GPGMELib.gpgme_signers_add(ctx, keyHandle)) {
+ var tmp_array = lazy.ctypes.char.array()(plaintext);
+ let data_plaintext = new GPGMELib.gpgme_data_t();
+
+ // The tmp_array will have one additional byte to store the
+ // trailing null character, we don't want to sign it, thus -1.
+ if (
+ !GPGMELib.gpgme_data_new_from_mem(
+ data_plaintext.address(),
+ tmp_array,
+ tmp_array.length - 1,
+ 0
+ )
+ ) {
+ let data_signed = new GPGMELib.gpgme_data_t();
+ if (!GPGMELib.gpgme_data_new(data_signed.address())) {
+ let exitCode = GPGMELib.gpgme_op_sign(
+ ctx,
+ data_plaintext,
+ data_signed,
+ GPGMELib.GPGME_SIG_MODE_DETACH
+ );
+ if (exitCode != GPGMELib.GPG_ERR_NO_ERROR) {
+ GPGMELib.gpgme_data_release(data_signed);
+ } else {
+ let result_len = new lazy.ctypes.size_t();
+ let result_buf = GPGMELib.gpgme_data_release_and_get_mem(
+ data_signed,
+ result_len.address()
+ );
+ if (!result_buf.isNull()) {
+ let unwrapped = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(result_len.value).ptr
+ ).contents;
+ result = unwrapped.readString();
+ resultStatus.exitCode = 0;
+ resultStatus.statusFlags |= lazy.EnigmailConstants.SIG_CREATED;
+ GPGMELib.gpgme_free(result_buf);
+ }
+ }
+ }
+ }
+ }
+ GPGMELib.gpgme_key_release(keyHandle);
+ }
+ GPGMELib.gpgme_release(ctx);
+ return result;
+ },
+
+ async sign(plaintext, args, resultStatus) {
+ resultStatus.exitCode = -1;
+ resultStatus.statusFlags = 0;
+ resultStatus.statusMsg = "";
+ resultStatus.errorMsg = "";
+
+ if (args.encrypt || !args.sign) {
+ throw new Error("invalid encrypt/sign parameters");
+ }
+ if (!plaintext) {
+ throw new Error("cannot sign empty data");
+ }
+
+ let result = null;
+ //args.sender must be keyId
+ let keyId = args.sender.replace(/^0x/, "").toUpperCase();
+
+ let ctx = new GPGMELib.gpgme_ctx_t();
+ if (GPGMELib.gpgme_new(ctx.address())) {
+ throw new Error("gpgme_new failed");
+ }
+ let keyHandle = new GPGMELib.gpgme_key_t();
+ if (!GPGMELib.gpgme_get_key(ctx, keyId, keyHandle.address(), 1)) {
+ if (!GPGMELib.gpgme_signers_add(ctx, keyHandle)) {
+ var tmp_array = lazy.ctypes.char.array()(plaintext);
+ let data_plaintext = new GPGMELib.gpgme_data_t();
+
+ // The tmp_array will have one additional byte to store the
+ // trailing null character, we don't want to sign it, thus -1.
+ if (
+ !GPGMELib.gpgme_data_new_from_mem(
+ data_plaintext.address(),
+ tmp_array,
+ tmp_array.length - 1,
+ 0
+ )
+ ) {
+ let data_signed = new GPGMELib.gpgme_data_t();
+ if (!GPGMELib.gpgme_data_new(data_signed.address())) {
+ let exitCode = GPGMELib.gpgme_op_sign(
+ ctx,
+ data_plaintext,
+ data_signed,
+ GPGMELib.GPGME_SIG_MODE_NORMAL
+ );
+ if (exitCode != GPGMELib.GPG_ERR_NO_ERROR) {
+ GPGMELib.gpgme_data_release(data_signed);
+ } else {
+ let result_len = new lazy.ctypes.size_t();
+ let result_buf = GPGMELib.gpgme_data_release_and_get_mem(
+ data_signed,
+ result_len.address()
+ );
+ if (!result_buf.isNull()) {
+ let unwrapped = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.uint8_t.array(result_len.value).ptr
+ ).contents;
+
+ result = unwrapped.readTypedArray();
+ resultStatus.exitCode = 0;
+ resultStatus.statusFlags |= lazy.EnigmailConstants.SIG_CREATED;
+ GPGMELib.gpgme_free(result_buf);
+ }
+ }
+ }
+ }
+ }
+ GPGMELib.gpgme_key_release(keyHandle);
+ }
+ GPGMELib.gpgme_release(ctx);
+ return result;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/GPGMELib.jsm b/comm/mail/extensions/openpgp/content/modules/GPGMELib.jsm
new file mode 100644
index 0000000000..c58181da37
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/GPGMELib.jsm
@@ -0,0 +1,584 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["GPGMELibLoader"];
+
+const { ctypes } = ChromeUtils.importESModule(
+ "resource://gre/modules/ctypes.sys.mjs"
+);
+
+var systemOS = Services.appinfo.OS.toLowerCase();
+var abi = ctypes.default_abi;
+
+// Default library paths to look for on macOS
+const ADDITIONAL_LIB_PATHS = [
+ "/usr/local/lib",
+ "/opt/local/lib",
+ "/opt/homebrew/lib",
+];
+
+// Open libgpgme. Determine the path to the chrome directory and look for it
+// there first. If not, fallback to searching the standard locations.
+var libgpgme, libgpgmePath;
+
+function tryLoadGPGME(name, suffix) {
+ let filename = ctypes.libraryName(name) + suffix;
+ let binPath = Services.dirsvc.get("XpcomLib", Ci.nsIFile).path;
+ let binDir = PathUtils.parent(binPath);
+ libgpgmePath = PathUtils.join(binDir, filename);
+
+ let loadFromInfo;
+
+ try {
+ loadFromInfo = libgpgmePath;
+ libgpgme = ctypes.open(libgpgmePath);
+ } catch (e) {}
+
+ if (!libgpgme) {
+ try {
+ loadFromInfo = "system's standard library locations";
+ // look in standard locations
+ libgpgmePath = filename;
+ libgpgme = ctypes.open(libgpgmePath);
+ } catch (e) {}
+ }
+
+ if (!libgpgme && systemOS !== "winnt") {
+ // try specific additional directories
+
+ for (let tryPath of ADDITIONAL_LIB_PATHS) {
+ try {
+ loadFromInfo = "additional standard locations";
+ libgpgmePath = tryPath + "/" + filename;
+ libgpgme = ctypes.open(libgpgmePath);
+
+ if (libgpgme) {
+ break;
+ }
+ } catch (e) {}
+ }
+ }
+
+ if (libgpgme) {
+ console.debug(
+ "Successfully loaded optional OpenPGP library " +
+ filename +
+ " from " +
+ loadFromInfo
+ );
+ }
+}
+
+function loadExternalGPGMELib() {
+ if (!libgpgme) {
+ if (systemOS === "winnt") {
+ tryLoadGPGME("libgpgme6-11", "");
+
+ if (!libgpgme) {
+ tryLoadGPGME("libgpgme-11", "");
+ }
+
+ if (!libgpgme) {
+ tryLoadGPGME("gpgme-11", "");
+ }
+ }
+
+ if (!libgpgme) {
+ tryLoadGPGME("gpgme", "");
+ }
+
+ if (!libgpgme) {
+ tryLoadGPGME("gpgme", ".11");
+ }
+
+ if (!libgpgme) {
+ tryLoadGPGME("gpgme.11");
+ }
+ }
+
+ return !!libgpgme;
+}
+
+var GPGMELibLoader = {
+ init() {
+ if (!loadExternalGPGMELib()) {
+ return null;
+ }
+ if (libgpgme) {
+ enableGPGMELibJS();
+ }
+ return GPGMELib;
+ },
+};
+
+const gpgme_error_t = ctypes.unsigned_int;
+const gpgme_ctx_t = ctypes.void_t.ptr;
+const gpgme_data_t = ctypes.void_t.ptr;
+const gpgme_validity_t = ctypes.int;
+const gpgme_keylist_mode_t = ctypes.unsigned_int;
+const gpgme_protocol_t = ctypes.int;
+const gpgme_pubkey_algo_t = ctypes.int;
+const gpgme_sig_notation_flags_t = ctypes.unsigned_int;
+const gpgme_export_mode_t = ctypes.unsigned_int;
+const gpgme_decrypt_flags_t = ctypes.unsigned_int;
+const gpgme_data_encoding_t = ctypes.unsigned_int;
+const gpgme_sig_mode_t = ctypes.int; // it's an enum, risk of wrong type.
+
+let _gpgme_subkey = ctypes.StructType("_gpgme_subkey");
+_gpgme_subkey.define([
+ { next: _gpgme_subkey.ptr },
+ { bitfield: ctypes.unsigned_int },
+ { pubkey_algo: gpgme_pubkey_algo_t },
+ { length: ctypes.unsigned_int },
+ { keyid: ctypes.char.ptr },
+ { _keyid: ctypes.char.array(17) },
+ { fpr: ctypes.char.ptr },
+ { timestamp: ctypes.long },
+ { expires: ctypes.long },
+ { card_number: ctypes.char.ptr },
+ { curve: ctypes.char.ptr },
+ { keygrip: ctypes.char.ptr },
+]);
+let gpgme_subkey_t = _gpgme_subkey.ptr;
+
+let _gpgme_sig_notation = ctypes.StructType("_gpgme_sig_notation");
+_gpgme_sig_notation.define([
+ { next: _gpgme_sig_notation.ptr },
+ { name: ctypes.char.ptr },
+ { value: ctypes.char.ptr },
+ { name_len: ctypes.int },
+ { value_len: ctypes.int },
+ { flags: gpgme_sig_notation_flags_t },
+ { bitfield: ctypes.unsigned_int },
+]);
+let gpgme_sig_notation_t = _gpgme_sig_notation.ptr;
+
+let _gpgme_key_sig = ctypes.StructType("_gpgme_key_sig");
+_gpgme_key_sig.define([
+ { next: _gpgme_key_sig.ptr },
+ { bitfield: ctypes.unsigned_int },
+ { pubkey_algo: gpgme_pubkey_algo_t },
+ { keyid: ctypes.char.ptr },
+ { _keyid: ctypes.char.array(17) },
+ { timestamp: ctypes.long },
+ { expires: ctypes.long },
+ { status: gpgme_error_t },
+ { class_: ctypes.unsigned_int },
+ { uid: ctypes.char.ptr },
+ { name: ctypes.char.ptr },
+ { email: ctypes.char.ptr },
+ { comment: ctypes.char.ptr },
+ { sig_class: ctypes.unsigned_int },
+ { notations: gpgme_sig_notation_t },
+ { last_notation: gpgme_sig_notation_t },
+]);
+let gpgme_key_sig_t = _gpgme_key_sig.ptr;
+
+let _gpgme_tofu_info = ctypes.StructType("_gpgme_tofu_info");
+_gpgme_tofu_info.define([
+ { next: _gpgme_tofu_info.ptr },
+ { bitfield: ctypes.unsigned_int },
+ { signcount: ctypes.unsigned_short },
+ { encrcount: ctypes.unsigned_short },
+ { signfirst: ctypes.unsigned_short },
+ { signlast: ctypes.unsigned_short },
+ { encrfirst: ctypes.unsigned_short },
+ { encrlast: ctypes.unsigned_short },
+ { description: ctypes.char.ptr },
+]);
+let gpgme_tofu_info_t = _gpgme_tofu_info.ptr;
+
+let _gpgme_user_id = ctypes.StructType("_gpgme_user_id");
+_gpgme_user_id.define([
+ { next: _gpgme_user_id.ptr },
+ { bitfield: ctypes.unsigned_int },
+ { validity: gpgme_validity_t },
+ { uid: ctypes.char.ptr },
+ { name: ctypes.char.ptr },
+ { email: ctypes.char.ptr },
+ { comment: ctypes.char.ptr },
+ { signatures: gpgme_key_sig_t },
+ { _last_keysig: gpgme_key_sig_t },
+ { address: ctypes.char.ptr },
+ { tofu: gpgme_tofu_info_t },
+ { last_update: ctypes.unsigned_long },
+]);
+let gpgme_user_id_t = _gpgme_user_id.ptr;
+
+let _gpgme_key = ctypes.StructType("gpgme_key_t", [
+ { _refs: ctypes.unsigned_int },
+ { bitfield: ctypes.unsigned_int },
+ { protocol: gpgme_protocol_t },
+ { issuer_serial: ctypes.char.ptr },
+ { issuer_name: ctypes.char.ptr },
+ { chain_id: ctypes.char.ptr },
+ { owner_trust: gpgme_validity_t },
+ { subkeys: gpgme_subkey_t },
+ { uids: gpgme_user_id_t },
+ { _last_subkey: gpgme_subkey_t },
+ { _last_uid: gpgme_user_id_t },
+ { keylist_mode: gpgme_keylist_mode_t },
+ { fpr: ctypes.char.ptr },
+ { last_update: ctypes.unsigned_long },
+]);
+let gpgme_key_t = _gpgme_key.ptr;
+
+var GPGMELib;
+
+function enableGPGMELibJS() {
+ // this must be delayed until after "libgpgme" is initialized
+
+ GPGMELib = {
+ path: libgpgmePath,
+
+ init() {
+ // GPGME 1.9.0 released 2017-03-28 is the first version that
+ // supports GPGME_DECRYPT_UNWRAP, requiring >= gpg 2.1.12
+ let versionPtr = this.gpgme_check_version("1.9.0");
+ let version = versionPtr.readString();
+ console.debug("gpgme version: " + version);
+
+ let gpgExe = Services.prefs.getStringPref(
+ "mail.openpgp.alternative_gpg_path"
+ );
+ if (!gpgExe) {
+ return true;
+ }
+
+ let extResult = this.gpgme_set_engine_info(
+ this.GPGME_PROTOCOL_OpenPGP,
+ gpgExe,
+ null
+ );
+ let success = extResult === this.GPG_ERR_NO_ERROR;
+ let info = success ? "success" : "failure: " + extResult;
+ console.debug(
+ "configuring GPGME to use an external OpenPGP engine " +
+ gpgExe +
+ " - " +
+ info
+ );
+ return success;
+ },
+
+ /**
+ * Export key blocks from GnuPG that match the given paramters.
+ *
+ * @param {string} pattern - A pattern given to GnuPG for listing keys.
+ * @param {boolean} secret - If true, retrieve secret keys.
+ * If false, retrieve public keys.
+ * @param {function} keyFilterFunction - An optional test function that
+ * will be called for each candidate key that GnuPG lists for the
+ * given pattern. Allows the caller to decide whether a candidate
+ * key is wanted or not. Function will be called with a
+ * {gpgme_key_t} parameter, the candidate key returned by GPGME.
+ *
+ * @returns {Map} - a Map that contains ASCII armored key blocks
+ * indexed by fingerprint.
+ */
+ exportKeys(pattern, secret = false, keyFilterFunction = undefined) {
+ let resultMap = new Map();
+ let allFingerprints = [];
+
+ let c1 = new gpgme_ctx_t();
+ if (this.gpgme_new(c1.address())) {
+ throw new Error("gpgme_new failed");
+ }
+
+ if (this.gpgme_op_keylist_start(c1, pattern, secret ? 1 : 0)) {
+ throw new Error("gpgme_op_keylist_start failed");
+ }
+
+ do {
+ let key = new gpgme_key_t();
+ let rv = this.gpgme_op_keylist_next(c1, key.address());
+ if (rv & GPGMELib.GPG_ERR_EOF) {
+ break;
+ } else if (rv) {
+ throw new Error("gpgme_op_keylist_next failed: " + rv);
+ }
+
+ if (key.contents.protocol == GPGMELib.GPGME_PROTOCOL_OpenPGP) {
+ if (!keyFilterFunction || keyFilterFunction(key)) {
+ let fpr = key.contents.fpr.readString();
+ allFingerprints.push(fpr);
+ }
+ }
+ this.gpgme_key_release(key);
+ } while (true);
+
+ if (this.gpgme_op_keylist_end(c1)) {
+ throw new Error("gpgme_op_keylist_end failed");
+ }
+
+ this.gpgme_release(c1);
+
+ for (let aFpr of allFingerprints) {
+ let c2 = new gpgme_ctx_t();
+ if (this.gpgme_new(c2.address())) {
+ throw new Error("gpgme_new failed");
+ }
+
+ this.gpgme_set_armor(c2, 1);
+
+ let data = new gpgme_data_t();
+ let rv = this.gpgme_data_new(data.address());
+ if (rv) {
+ throw new Error("gpgme_op_keylist_next gpgme_data_new: " + rv);
+ }
+
+ rv = this.gpgme_op_export(
+ c2,
+ aFpr,
+ secret ? GPGMELib.GPGME_EXPORT_MODE_SECRET : 0,
+ data
+ );
+ if (rv) {
+ throw new Error("gpgme_op_export gpgme_data_new: " + rv);
+ }
+
+ let result_len = new ctypes.size_t();
+ let result_buf = this.gpgme_data_release_and_get_mem(
+ data,
+ result_len.address()
+ );
+
+ let keyData = ctypes.cast(
+ result_buf,
+ ctypes.char.array(result_len.value).ptr
+ ).contents;
+
+ resultMap.set(aFpr, keyData.readString());
+
+ this.gpgme_free(result_buf);
+ this.gpgme_release(c2);
+ }
+ return resultMap;
+ },
+
+ gpgme_check_version: libgpgme.declare(
+ "gpgme_check_version",
+ abi,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ gpgme_set_engine_info: libgpgme.declare(
+ "gpgme_set_engine_info",
+ abi,
+ gpgme_error_t,
+ gpgme_protocol_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ gpgme_new: libgpgme.declare("gpgme_new", abi, gpgme_error_t, gpgme_ctx_t),
+
+ gpgme_release: libgpgme.declare(
+ "gpgme_release",
+ abi,
+ ctypes.void_t,
+ gpgme_ctx_t
+ ),
+
+ gpgme_key_release: libgpgme.declare(
+ "gpgme_key_release",
+ abi,
+ ctypes.void_t,
+ gpgme_key_t
+ ),
+
+ gpgme_op_keylist_start: libgpgme.declare(
+ "gpgme_op_keylist_start",
+ abi,
+ gpgme_error_t,
+ gpgme_ctx_t,
+ ctypes.char.ptr,
+ ctypes.int
+ ),
+
+ gpgme_op_keylist_next: libgpgme.declare(
+ "gpgme_op_keylist_next",
+ abi,
+ gpgme_error_t,
+ gpgme_ctx_t,
+ gpgme_key_t.ptr
+ ),
+
+ gpgme_op_keylist_end: libgpgme.declare(
+ "gpgme_op_keylist_end",
+ abi,
+ gpgme_error_t,
+ gpgme_ctx_t
+ ),
+
+ gpgme_op_export: libgpgme.declare(
+ "gpgme_op_export",
+ abi,
+ gpgme_error_t,
+ gpgme_ctx_t,
+ ctypes.char.ptr,
+ gpgme_export_mode_t,
+ gpgme_data_t
+ ),
+
+ gpgme_set_armor: libgpgme.declare(
+ "gpgme_set_armor",
+ abi,
+ ctypes.void_t,
+ gpgme_ctx_t,
+ ctypes.int
+ ),
+
+ gpgme_data_new: libgpgme.declare(
+ "gpgme_data_new",
+ abi,
+ gpgme_error_t,
+ gpgme_data_t.ptr
+ ),
+
+ gpgme_data_release: libgpgme.declare(
+ "gpgme_data_release",
+ abi,
+ ctypes.void_t,
+ gpgme_data_t
+ ),
+
+ gpgme_data_release_and_get_mem: libgpgme.declare(
+ "gpgme_data_release_and_get_mem",
+ abi,
+ ctypes.char.ptr,
+ gpgme_data_t,
+ ctypes.size_t.ptr
+ ),
+
+ gpgme_free: libgpgme.declare(
+ "gpgme_free",
+ abi,
+ ctypes.void_t,
+ ctypes.void_t.ptr
+ ),
+
+ gpgme_op_decrypt_ext: libgpgme.declare(
+ "gpgme_op_decrypt_ext",
+ abi,
+ gpgme_error_t,
+ gpgme_ctx_t,
+ gpgme_decrypt_flags_t,
+ gpgme_data_t,
+ gpgme_data_t
+ ),
+
+ gpgme_data_new_from_mem: libgpgme.declare(
+ "gpgme_data_new_from_mem",
+ abi,
+ gpgme_error_t,
+ gpgme_data_t.ptr,
+ ctypes.char.ptr,
+ ctypes.size_t,
+ ctypes.int
+ ),
+
+ gpgme_data_read: libgpgme.declare(
+ "gpgme_data_read",
+ abi,
+ ctypes.ssize_t,
+ gpgme_data_t,
+ ctypes.void_t.ptr,
+ ctypes.size_t
+ ),
+
+ gpgme_data_rewind: libgpgme.declare(
+ "gpgme_data_rewind",
+ abi,
+ gpgme_error_t,
+ gpgme_data_t
+ ),
+
+ gpgme_data_get_encoding: libgpgme.declare(
+ "gpgme_data_get_encoding",
+ abi,
+ gpgme_data_encoding_t,
+ gpgme_data_t
+ ),
+
+ gpgme_data_set_encoding: libgpgme.declare(
+ "gpgme_data_set_encoding",
+ abi,
+ gpgme_error_t,
+ gpgme_data_t,
+ gpgme_data_encoding_t
+ ),
+
+ gpgme_op_sign: libgpgme.declare(
+ "gpgme_op_sign",
+ abi,
+ gpgme_error_t,
+ gpgme_ctx_t,
+ gpgme_data_t,
+ gpgme_data_t,
+ gpgme_sig_mode_t
+ ),
+
+ gpgme_signers_add: libgpgme.declare(
+ "gpgme_signers_add",
+ abi,
+ gpgme_error_t,
+ gpgme_ctx_t,
+ gpgme_key_t
+ ),
+
+ gpgme_get_key: libgpgme.declare(
+ "gpgme_get_key",
+ abi,
+ gpgme_error_t,
+ gpgme_ctx_t,
+ ctypes.char.ptr,
+ gpgme_key_t.ptr,
+ ctypes.int
+ ),
+
+ gpgme_set_textmode: libgpgme.declare(
+ "gpgme_set_textmode",
+ abi,
+ ctypes.void_t,
+ gpgme_ctx_t,
+ ctypes.int
+ ),
+
+ gpgme_error_t,
+ gpgme_ctx_t,
+ gpgme_data_t,
+ gpgme_validity_t,
+ gpgme_keylist_mode_t,
+ gpgme_pubkey_algo_t,
+ gpgme_sig_notation_flags_t,
+ gpgme_export_mode_t,
+ gpgme_decrypt_flags_t,
+ gpgme_data_encoding_t,
+
+ gpgme_protocol_t,
+ gpgme_subkey_t,
+ gpgme_sig_notation_t,
+ gpgme_key_sig_t,
+ gpgme_tofu_info_t,
+ gpgme_user_id_t,
+ gpgme_key_t,
+
+ GPG_ERR_NO_ERROR: 0x00000000,
+ GPG_ERR_EOF: 16383,
+ GPGME_PROTOCOL_OpenPGP: 0,
+ GPGME_EXPORT_MODE_SECRET: 16,
+ GPGME_DECRYPT_UNWRAP: 128,
+ GPGME_DATA_ENCODING_ARMOR: 3,
+ GPGME_SIG_MODE_DETACH: 1,
+ GPGME_SIG_MODE_NORMAL: 0,
+
+ gpgme_key_t_revoked: 1,
+ gpgme_key_t_expired: 2,
+ gpgme_key_t_disabled: 4,
+ gpgme_key_t_invalid: 8,
+ gpgme_key_t_can_encrypt: 16,
+ };
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/OpenPGPAlias.jsm b/comm/mail/extensions/openpgp/content/modules/OpenPGPAlias.jsm
new file mode 100644
index 0000000000..f480b727f6
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/OpenPGPAlias.jsm
@@ -0,0 +1,173 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["OpenPGPAlias"];
+
+var OpenPGPAlias = {
+ _aliasDomains: null,
+ _aliasEmails: null,
+
+ _loaded() {
+ return this._aliasDomains && this._aliasEmails;
+ },
+
+ async load() {
+ let path = Services.prefs.getStringPref(
+ "mail.openpgp.alias_rules_file",
+ ""
+ );
+
+ if (!path) {
+ this._clear();
+ return;
+ }
+
+ await this._loadFromFile(path);
+ },
+
+ _clear() {
+ this._aliasDomains = new Map();
+ this._aliasEmails = new Map();
+ },
+
+ _hasExpectedKeysStructure(keys) {
+ try {
+ for (let entry of keys) {
+ if (!("id" in entry) && !("fingerprint" in entry)) {
+ return false;
+ }
+ }
+ // all entries passed the test
+ return true;
+ } catch (ex) {
+ return false;
+ }
+ },
+
+ async _loadFromFile(src) {
+ this._clear();
+
+ let aliasRules;
+ let jsonData;
+ if (src.startsWith("file://")) {
+ let response = await fetch(src);
+ jsonData = await response.json();
+ } else if (src.includes("/") || src.includes("\\")) {
+ throw new Error(`Invalid alias rules src: ${src}`);
+ } else {
+ let spec = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ src
+ );
+ let response = await fetch(PathUtils.toFileURI(spec));
+ jsonData = await response.json();
+ }
+ if (!("rules" in jsonData)) {
+ throw new Error(
+ "alias file contains invalid JSON data, no rules element found"
+ );
+ }
+ aliasRules = jsonData.rules;
+
+ for (let entry of aliasRules) {
+ if (!("keys" in entry) || !entry.keys || !entry.keys.length) {
+ console.log("Ignoring invalid alias rule without keys");
+ continue;
+ }
+ if ("email" in entry && "domain" in entry) {
+ console.log("Ignoring invalid alias rule with both email and domain");
+ continue;
+ }
+ // Ignore duplicate rules, only use first rule per key.
+ // Require email address contains @, and domain doesn't contain @.
+ if ("email" in entry) {
+ let email = entry.email.toLowerCase();
+ if (!email.includes("@")) {
+ console.log("Ignoring invalid email alias rule: " + email);
+ continue;
+ }
+ if (this._aliasEmails.get(email)) {
+ console.log("Ignoring duplicate email alias rule: " + email);
+ continue;
+ }
+ if (!this._hasExpectedKeysStructure(entry.keys)) {
+ console.log(
+ "Ignoring alias rule with invalid key entries for email " + email
+ );
+ continue;
+ }
+ this._aliasEmails.set(email, entry.keys);
+ } else if ("domain" in entry) {
+ let domain = entry.domain.toLowerCase();
+ if (domain.includes("@")) {
+ console.log("Ignoring invalid domain alias rule: " + domain);
+ continue;
+ }
+ if (this._aliasDomains.get(domain)) {
+ console.log("Ignoring duplicate domain alias rule: " + domain);
+ continue;
+ }
+ if (!this._hasExpectedKeysStructure(entry.keys)) {
+ console.log(
+ "Ignoring alias rule with invalid key entries for domain " + domain
+ );
+ continue;
+ }
+ this._aliasDomains.set(domain, entry.keys);
+ } else {
+ console.log(
+ "Ignoring invalid alias rule without domain and without email"
+ );
+ }
+ }
+ },
+
+ getDomainAliasKeyList(email) {
+ if (!this._loaded()) {
+ return null;
+ }
+
+ let lastAt = email.lastIndexOf("@");
+ if (lastAt == -1) {
+ return null;
+ }
+
+ let domain = email.substr(lastAt + 1);
+ if (!domain) {
+ return null;
+ }
+
+ return this._aliasDomains.get(domain.toLowerCase());
+ },
+
+ getEmailAliasKeyList(email) {
+ if (!this._loaded()) {
+ return null;
+ }
+ return this._aliasEmails.get(email.toLowerCase());
+ },
+
+ hasAliasDefinition(email) {
+ if (!this._loaded()) {
+ return false;
+ }
+ email = email.toLowerCase();
+ let hasEmail = this._aliasEmails.has(email);
+ if (hasEmail) {
+ return true;
+ }
+
+ let lastAt = email.lastIndexOf("@");
+ if (lastAt == -1) {
+ return false;
+ }
+
+ let domain = email.substr(lastAt + 1);
+ if (!domain) {
+ return false;
+ }
+
+ return this._aliasDomains.has(domain);
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/RNP.jsm b/comm/mail/extensions/openpgp/content/modules/RNP.jsm
new file mode 100644
index 0000000000..c6969842c8
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/RNP.jsm
@@ -0,0 +1,4787 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["RNP", "RnpPrivateKeyUnlockTracker"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ctypes: "resource://gre/modules/ctypes.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ GPGME: "chrome://openpgp/content/modules/GPGME.jsm",
+ OpenPGPMasterpass: "chrome://openpgp/content/modules/masterpass.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+ RNPLibLoader: "chrome://openpgp/content/modules/RNPLib.jsm",
+});
+
+var l10n = new Localization(["messenger/openpgp/openpgp.ftl"]);
+
+const str_encrypt = "encrypt";
+const str_sign = "sign";
+const str_certify = "certify";
+const str_authenticate = "authenticate";
+const RNP_PHOTO_USERID_ID = "(photo)"; // string is hardcoded inside RNP
+
+var RNPLib;
+
+/**
+ * Opens a prompt, asking the user to enter passphrase for given key id.
+
+ * @param {?nsIWindow} win - Parent window, may be null
+ * @param {string} promptString - This message will be shown to the user
+ * @param {object} resultFlags - Attribute .canceled is set to true
+ * if the user clicked cancel, other it's set to false.
+ * @returns {string} - The passphrase the user entered
+ */
+function passphrasePromptCallback(win, promptString, resultFlags) {
+ let password = { value: "" };
+ if (!Services.prompt.promptPassword(win, "", promptString, password)) {
+ resultFlags.canceled = true;
+ return "";
+ }
+
+ resultFlags.canceled = false;
+ return password.value;
+}
+
+/**
+ * Helper class to track resources related to a private/secret key,
+ * holding the key handle obtained from RNP, and offering services
+ * related to that key and its handle, including releasing the handle
+ * when done. Tracking a null handle is allowed.
+ */
+class RnpPrivateKeyUnlockTracker {
+ #rnpKeyHandle = null;
+ #wasUnlocked = false;
+ #allowPromptingUserForPassword = false;
+ #allowAutoUnlockWithCachedPasswords = false;
+ #passwordCache = null;
+ #fingerprint = "";
+ #passphraseCallback = null;
+ #rememberUnlockPasswordForUnprotect = false;
+ #unlockPassword = null;
+ #isLocked = true;
+
+ /**
+ * Initialize this object as a tracker for the private key identified
+ * by the given fingerprint. The fingerprint will be looked up in an
+ * RNP space (FFI) and the resulting handle will be tracked. The
+ * default FFI is used for performing the lookup, unless a specific
+ * FFI is given. If no key can be found, the object is initialized
+ * with a null handle. If a handle was found, the handle and any
+ * additional resources can be freed by calling the object's release()
+ * method.
+ *
+ * @param {string} fingerprint - the fingerprint of a key to look up.
+ * @param {rnp_ffi_t} ffi - An optional specific FFI.
+ * @returns {RnpPrivateKeyUnlockTracker} - a new instance, which was
+ * either initialized with a found key handle, or with null-
+ */
+ static constructFromFingerprint(fingerprint, ffi = RNPLib.ffi) {
+ if (fingerprint.startsWith("0x")) {
+ throw new Error("fingerprint must not start with 0x");
+ }
+
+ let handle = RNP.getKeyHandleByKeyIdOrFingerprint(ffi, `0x${fingerprint}`);
+
+ return new RnpPrivateKeyUnlockTracker(handle);
+ }
+
+ /**
+ * Construct this object as a tracker for the private key referenced
+ * by the given handle. The object may also be initialized
+ * with null, if no key was found. A valid handle and any additional
+ * resources can be freed by calling the object's release() method.
+ *
+ * @param {?rnp_key_handle_t} handle - the handle of a RNP key, or null
+ */
+ constructor(handle) {
+ if (this.#rnpKeyHandle) {
+ throw new Error("Instance already initialized");
+ }
+ if (!handle) {
+ return;
+ }
+ this.#rnpKeyHandle = handle;
+
+ if (!this.available()) {
+ // Not a private key. We tolerate this use to enable automatic
+ // handle releasing, for code that sometimes needs to track a
+ // secret key, and sometimes only a public key.
+ // The only functionality that is allowed on such a key is to
+ // call the .available() and the .release() methods.
+ this.#isLocked = false;
+ } else {
+ let is_locked = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_is_locked(this.#rnpKeyHandle, is_locked.address())) {
+ throw new Error("rnp_key_is_locked failed");
+ }
+ this.#isLocked = is_locked.value;
+ }
+
+ if (!this.#fingerprint) {
+ let fingerprint = new lazy.ctypes.char.ptr();
+ if (
+ RNPLib.rnp_key_get_fprint(this.#rnpKeyHandle, fingerprint.address())
+ ) {
+ throw new Error("rnp_key_get_fprint failed");
+ }
+ this.#fingerprint = fingerprint.readString();
+ RNPLib.rnp_buffer_destroy(fingerprint);
+ }
+ }
+
+ /**
+ * @param {Function} cb - Override the callback function that this
+ * object will call to obtain the passphrase to unlock the private
+ * key for tracked key handle, if the object needs to unlock
+ * the key and prompting the user is allowed.
+ * If no alternative callback is set, the global
+ * passphrasePromptCallback function will be used.
+ */
+ setPassphraseCallback(cb) {
+ this.#passphraseCallback = cb;
+ }
+
+ /**
+ * Allow or forbid prompting the user for a passphrase.
+ *
+ * @param {boolean} isAllowed - True if allowed, false if forbidden
+ */
+ setAllowPromptingUserForPassword(isAllowed) {
+ this.#allowPromptingUserForPassword = isAllowed;
+ }
+
+ /**
+ * Allow or forbid automatically using passphrases from a configured
+ * cache of passphrase, if it's necessary to obtain a passphrase
+ * for unlocking.
+ *
+ * @param {boolean} isAllowed - True if allowed, false if forbidden
+ */
+ setAllowAutoUnlockWithCachedPasswords(isAllowed) {
+ this.#allowAutoUnlockWithCachedPasswords = isAllowed;
+ }
+
+ /**
+ * Allow or forbid this object to remember the passphrase that was
+ * successfully used to to unlock it. This is necessary when intending
+ * to subsequently call the unprotect() function to remove the key's
+ * passphrase protection. Care should be taken that a tracker object
+ * with a remembered passphrase is held in memory only for a short
+ * amount of time, and should be released as soon as a task has
+ * completed.
+ *
+ * @param {boolean} isAllowed - True if allowed, false if forbidden
+ */
+ setRememberUnlockPassword(isAllowed) {
+ this.#rememberUnlockPasswordForUnprotect = isAllowed;
+ }
+
+ /**
+ * Registers a reference to shared object that implements an optional
+ * password cache. Will be used to look up passwords if
+ * #allowAutoUnlockWithCachedPasswords is set to true. Will be used
+ * to store additional passwords that are found to unlock a key.
+ */
+ setPasswordCache(cacheObj) {
+ this.#passwordCache = cacheObj;
+ }
+
+ /**
+ * Completely remove the encryption layer that protects the private
+ * key. Requires that setRememberUnlockPassword(true) was already
+ * called on this object, prior to unlocking the key, because this
+ * code requires that the unlock/unprotect passphrase has been cached
+ * in this object already, and that the tracked key has already been
+ * unlocked.
+ */
+ unprotect() {
+ if (!this.#rnpKeyHandle) {
+ return;
+ }
+
+ const is_protected = new lazy.ctypes.bool();
+ if (
+ RNPLib.rnp_key_is_protected(this.#rnpKeyHandle, is_protected.address())
+ ) {
+ throw new Error("rnp_key_is_protected failed");
+ }
+ if (!is_protected.value) {
+ return;
+ }
+
+ if (!this.#wasUnlocked || !this.#rememberUnlockPasswordForUnprotect) {
+ // This precondition ensures we have the correct password cached.
+ throw new Error("Key should have been unlocked already.");
+ }
+
+ if (RNPLib.rnp_key_unprotect(this.#rnpKeyHandle, this.#unlockPassword)) {
+ throw new Error(`Failed to unprotect private key ${this.#fingerprint}`);
+ }
+ }
+
+ /**
+ * Attempt to unlock the tracked key with the given passphrase,
+ * can also be used with the empty string, which will unlock the key
+ * if no passphrase is set.
+ *
+ * @param {string} pass - try to unlock the key using this passphrase
+ */
+ unlockWithPassword(pass) {
+ if (!this.#rnpKeyHandle || !this.#isLocked) {
+ return;
+ }
+ this.#wasUnlocked = false;
+
+ if (!RNPLib.rnp_key_unlock(this.#rnpKeyHandle, pass)) {
+ this.#isLocked = false;
+ this.#wasUnlocked = true;
+ if (this.#rememberUnlockPasswordForUnprotect) {
+ this.#unlockPassword = pass;
+ }
+ }
+ }
+
+ /**
+ * Attempt to unlock the tracked key, using the allowed unlock
+ * mechanisms that have been configured/allowed for this tracker,
+ * which must been configured as desired prior to calling this function.
+ * Attempts will potentially be made to unlock using the automatic
+ * passphrase, or using password available in the password cache,
+ * or by prompting the user for a password, repeatedly prompting
+ * until the user enters the correct password or cancels.
+ * When prompting the user for a passphrase, and the key is a subkey,
+ * it might be necessary to lookup the primary key. A RNP FFI handle
+ * is necessary for that potential lookup.
+ * Unless a ffi parameter is provided, the default ffi is used.
+ *
+ * @param {rnp_ffi_t} ffi - An optional specific FFI.
+ */
+ async unlock(ffi = RNPLib.ffi) {
+ if (!this.#rnpKeyHandle || !this.#isLocked) {
+ return;
+ }
+ this.#wasUnlocked = false;
+ let autoPassword = await lazy.OpenPGPMasterpass.retrieveOpenPGPPassword();
+
+ if (!RNPLib.rnp_key_unlock(this.#rnpKeyHandle, autoPassword)) {
+ this.#isLocked = false;
+ this.#wasUnlocked = true;
+ if (this.#rememberUnlockPasswordForUnprotect) {
+ this.#unlockPassword = autoPassword;
+ }
+ return;
+ }
+
+ if (this.#allowAutoUnlockWithCachedPasswords && this.#passwordCache) {
+ for (let pw of this.#passwordCache.passwords) {
+ if (!RNPLib.rnp_key_unlock(this.#rnpKeyHandle, pw)) {
+ this.#isLocked = false;
+ this.#wasUnlocked = true;
+ if (this.#rememberUnlockPasswordForUnprotect) {
+ this.#unlockPassword = pw;
+ }
+ return;
+ }
+ }
+ }
+
+ if (!this.#allowPromptingUserForPassword) {
+ return;
+ }
+
+ let promptString = await RNP.getPassphrasePrompt(this.#rnpKeyHandle, ffi);
+
+ while (true) {
+ let userFlags = { canceled: false };
+ let pass;
+ if (this.#passphraseCallback) {
+ pass = this.#passphraseCallback(null, promptString, userFlags);
+ } else {
+ pass = passphrasePromptCallback(null, promptString, userFlags);
+ }
+ if (userFlags.canceled) {
+ return;
+ }
+
+ if (!RNPLib.rnp_key_unlock(this.#rnpKeyHandle, pass)) {
+ this.#isLocked = false;
+ this.#wasUnlocked = true;
+ if (this.#rememberUnlockPasswordForUnprotect) {
+ this.#unlockPassword = pass;
+ }
+
+ if (this.#passwordCache) {
+ this.#passwordCache.passwords.push(pass);
+ }
+ return;
+ }
+ }
+ }
+
+ /**
+ * Check that this tracker has a reference to a private key.
+ *
+ * @returns {boolean} - true if the tracked key is a secret/private
+ */
+ isSecret() {
+ return (
+ this.#rnpKeyHandle &&
+ RNPLib.getSecretAvailableFromHandle(this.#rnpKeyHandle)
+ );
+ }
+
+ /**
+ * Check that this tracker has a reference to a valid private key.
+ * The check will fail e.g. for offline secret keys, where a
+ * primary key is marked as being a secret key, but not having
+ * the raw key data available. (In that scenario, the raw key data
+ * for subkeys is usually available.)
+ *
+ * @returns {boolean} - true if the tracked key is a secret/private
+ * key with its key material available.
+ */
+ available() {
+ return (
+ this.#rnpKeyHandle &&
+ RNPLib.getSecretAvailableFromHandle(this.#rnpKeyHandle) &&
+ RNPLib.isSecretKeyMaterialAvailable(this.#rnpKeyHandle)
+ );
+ }
+
+ /**
+ * Obtain the raw RNP key handle managed by this tracker.
+ * The returned handle may be temporarily used by the caller,
+ * but the caller must not destroy the handle. The returned handle
+ * will become invalid as soon as the release() function is called
+ * on this tracker object.
+ *
+ * @returns {rnp_key_handle_t} - the handle of the tracked private key
+ * or null, if no key is tracked by this tracker.
+ */
+ getHandle() {
+ return this.#rnpKeyHandle;
+ }
+
+ /**
+ * @returns {string} - key fingerprint of the tracked key, or the
+ * empty string.
+ */
+ getFingerprint() {
+ return this.#fingerprint;
+ }
+
+ /**
+ * @returns {boolean} - true if the tracked key is currently unlocked.
+ */
+ isUnlocked() {
+ return !this.#isLocked;
+ }
+
+ /**
+ * Protect the key with the automatic passphrase mechanism, that is,
+ * using the classic mechanism that uses an automatically generated
+ * passphrase, which is either unprotected, or protected by the
+ * primary password.
+ * Requires that the key is unlocked already.
+ */
+ async setAutoPassphrase() {
+ if (!this.#rnpKeyHandle) {
+ return;
+ }
+
+ let autoPassword = await lazy.OpenPGPMasterpass.retrieveOpenPGPPassword();
+ if (
+ RNPLib.rnp_key_protect(
+ this.#rnpKeyHandle,
+ autoPassword,
+ null,
+ null,
+ null,
+ 0
+ )
+ ) {
+ throw new Error(`rnp_key_protect failed for ${this.#fingerprint}`);
+ }
+ }
+
+ /**
+ * Protect the key with the given passphrase.
+ * Requires that the key is unlocked already.
+ *
+ * @param {string} pass - protect the key with this passphrase
+ */
+ setPassphrase(pass) {
+ if (!this.#rnpKeyHandle) {
+ return;
+ }
+
+ if (RNPLib.rnp_key_protect(this.#rnpKeyHandle, pass, null, null, null, 0)) {
+ throw new Error(`rnp_key_protect failed for ${this.#fingerprint}`);
+ }
+ }
+
+ /**
+ * Release all data managed by this tracker, if necessary locking the
+ * tracked private key, forgetting the remembered unlock password,
+ * and destroying the handle.
+ * Note that data passed on to a password cache isn't released.
+ */
+ release() {
+ if (!this.#rnpKeyHandle) {
+ return;
+ }
+
+ this.#unlockPassword = null;
+ if (!this.#isLocked && this.#wasUnlocked) {
+ RNPLib.rnp_key_lock(this.#rnpKeyHandle);
+ this.#isLocked = true;
+ }
+
+ RNPLib.rnp_key_handle_destroy(this.#rnpKeyHandle);
+ this.#rnpKeyHandle = null;
+ }
+}
+
+var RNP = {
+ hasRan: false,
+ libLoaded: false,
+ async once() {
+ this.hasRan = true;
+ try {
+ RNPLib = lazy.RNPLibLoader.init();
+ if (!RNPLib || !RNPLib.loaded) {
+ return;
+ }
+ if (await RNPLib.init()) {
+ //this.initUiOps();
+ RNP.libLoaded = true;
+ }
+ await lazy.OpenPGPMasterpass.ensurePasswordIsCached();
+ } catch (e) {
+ console.log(e);
+ }
+ },
+
+ getRNPLibStatus() {
+ return RNPLib.getRNPLibStatus();
+ },
+
+ async init(opts) {
+ opts = opts || {};
+
+ if (!this.hasRan) {
+ await this.once();
+ }
+
+ return RNP.libLoaded;
+ },
+
+ isAllowedPublicKeyAlgo(algo) {
+ // see rnp/src/lib/rnp.cpp pubkey_alg_map
+ switch (algo) {
+ case "SM2":
+ return false;
+
+ default:
+ return true;
+ }
+ },
+
+ /**
+ * returns {integer} - the raw value of the key's creation date
+ */
+ getKeyCreatedValueFromHandle(handle) {
+ let key_creation = new lazy.ctypes.uint32_t();
+ if (RNPLib.rnp_key_get_creation(handle, key_creation.address())) {
+ throw new Error("rnp_key_get_creation failed");
+ }
+ return key_creation.value;
+ },
+
+ addKeyAttributes(handle, meta, keyObj, is_subkey, forListing) {
+ let algo = new lazy.ctypes.char.ptr();
+ let bits = new lazy.ctypes.uint32_t();
+ let key_expiration = new lazy.ctypes.uint32_t();
+ let allowed = new lazy.ctypes.bool();
+
+ keyObj.secretAvailable = this.getSecretAvailableFromHandle(handle);
+
+ if (keyObj.secretAvailable) {
+ keyObj.secretMaterial = RNPLib.isSecretKeyMaterialAvailable(handle);
+ } else {
+ keyObj.secretMaterial = false;
+ }
+
+ if (is_subkey) {
+ keyObj.type = "sub";
+ } else {
+ keyObj.type = "pub";
+ }
+
+ keyObj.keyId = this.getKeyIDFromHandle(handle);
+ if (forListing) {
+ keyObj.id = keyObj.keyId;
+ }
+
+ keyObj.fpr = this.getFingerprintFromHandle(handle);
+
+ if (RNPLib.rnp_key_get_alg(handle, algo.address())) {
+ throw new Error("rnp_key_get_alg failed");
+ }
+ keyObj.algoSym = algo.readString();
+ RNPLib.rnp_buffer_destroy(algo);
+
+ if (RNPLib.rnp_key_get_bits(handle, bits.address())) {
+ throw new Error("rnp_key_get_bits failed");
+ }
+ keyObj.keySize = bits.value;
+
+ keyObj.keyCreated = this.getKeyCreatedValueFromHandle(handle);
+ keyObj.created = new Services.intl.DateTimeFormat().format(
+ new Date(keyObj.keyCreated * 1000)
+ );
+
+ if (RNPLib.rnp_key_get_expiration(handle, key_expiration.address())) {
+ throw new Error("rnp_key_get_expiration failed");
+ }
+ if (key_expiration.value > 0) {
+ keyObj.expiryTime = keyObj.keyCreated + key_expiration.value;
+ } else {
+ keyObj.expiryTime = 0;
+ }
+ keyObj.expiry = keyObj.expiryTime
+ ? new Services.intl.DateTimeFormat().format(
+ new Date(keyObj.expiryTime * 1000)
+ )
+ : "";
+ keyObj.keyUseFor = "";
+
+ if (!this.isAllowedPublicKeyAlgo(keyObj.algoSym)) {
+ return;
+ }
+
+ let key_revoked = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_is_revoked(handle, key_revoked.address())) {
+ throw new Error("rnp_key_is_revoked failed");
+ }
+
+ if (key_revoked.value) {
+ keyObj.keyTrust = "r";
+ if (forListing) {
+ keyObj.revoke = true;
+ }
+ } else if (this.isExpiredTime(keyObj.expiryTime)) {
+ keyObj.keyTrust = "e";
+ } else if (keyObj.secretAvailable) {
+ keyObj.keyTrust = "u";
+ } else {
+ keyObj.keyTrust = "o";
+ }
+
+ if (RNPLib.rnp_key_allows_usage(handle, str_encrypt, allowed.address())) {
+ throw new Error("rnp_key_allows_usage failed");
+ }
+ if (allowed.value) {
+ keyObj.keyUseFor += "e";
+ meta.e = true;
+ }
+ if (RNPLib.rnp_key_allows_usage(handle, str_sign, allowed.address())) {
+ throw new Error("rnp_key_allows_usage failed");
+ }
+ if (allowed.value) {
+ keyObj.keyUseFor += "s";
+ meta.s = true;
+ }
+ if (RNPLib.rnp_key_allows_usage(handle, str_certify, allowed.address())) {
+ throw new Error("rnp_key_allows_usage failed");
+ }
+ if (allowed.value) {
+ keyObj.keyUseFor += "c";
+ meta.c = true;
+ }
+ if (
+ RNPLib.rnp_key_allows_usage(handle, str_authenticate, allowed.address())
+ ) {
+ throw new Error("rnp_key_allows_usage failed");
+ }
+ if (allowed.value) {
+ keyObj.keyUseFor += "a";
+ meta.a = true;
+ }
+ },
+
+ async getKeys(onlyKeys = null) {
+ return this.getKeysFromFFI(RNPLib.ffi, false, onlyKeys, false);
+ },
+
+ async getSecretKeys(onlyKeys = null) {
+ return this.getKeysFromFFI(RNPLib.ffi, false, onlyKeys, true);
+ },
+
+ getProtectedKeysCount() {
+ return RNPLib.getProtectedKeysCount();
+ },
+
+ async protectUnprotectedKeys() {
+ return RNPLib.protectUnprotectedKeys();
+ },
+
+ /**
+ * This function inspects the keys contained in the RNP space "ffi",
+ * and returns objects of type KeyObj that describe the keys.
+ *
+ * Some consumers want a different listing of keys, and expect
+ * slightly different attribute names.
+ * If forListing is true, we'll set those additional attributes.
+ * If onlyKeys is given: only returns keys in that array.
+ *
+ * @param {rnp_ffi_t} ffi - RNP library handle to key storage area
+ * @param {boolean} forListing - Request additional attributes
+ * in the returned objects, for backwards compatibility.
+ * @param {string[]} onlyKeys - An array of key IDs or fingerprints.
+ * If non-null, only the given elements will be returned.
+ * If null, all elements are returned.
+ * @param {boolean} onlySecret - If true, only information for
+ * available secret keys is returned.
+ * @param {boolean} withPubKey - If true, an additional attribute
+ * "pubKey" will be added to each returned KeyObj, which will
+ * contain an ascii armor copy of the public key.
+ * @returns {KeyObj[]} - An array of KeyObj objects that describe the
+ * available keys.
+ */
+ async getKeysFromFFI(
+ ffi,
+ forListing,
+ onlyKeys = null,
+ onlySecret = false,
+ withPubKey = false
+ ) {
+ if (!!onlyKeys && onlySecret) {
+ throw new Error(
+ "filtering by both white list and only secret keys isn't supported"
+ );
+ }
+
+ let keys = [];
+
+ if (onlyKeys) {
+ for (let ki = 0; ki < onlyKeys.length; ki++) {
+ let handle = await this.getKeyHandleByIdentifier(ffi, onlyKeys[ki]);
+ if (!handle || handle.isNull()) {
+ continue;
+ }
+
+ let keyObj = {};
+ try {
+ // Skip if it is a sub key, it will be processed together with primary key later.
+ let ok = this.getKeyInfoFromHandle(
+ ffi,
+ handle,
+ keyObj,
+ false,
+ forListing,
+ false
+ );
+ if (!ok) {
+ continue;
+ }
+ } catch (ex) {
+ console.log(ex);
+ } finally {
+ RNPLib.rnp_key_handle_destroy(handle);
+ }
+
+ if (keyObj) {
+ if (withPubKey) {
+ let pubKey = await this.getPublicKey("0x" + keyObj.id, ffi);
+ if (pubKey) {
+ keyObj.pubKey = pubKey;
+ }
+ }
+ keys.push(keyObj);
+ }
+ }
+ } else {
+ let rv;
+
+ let iter = new RNPLib.rnp_identifier_iterator_t();
+ let grip = new lazy.ctypes.char.ptr();
+
+ rv = RNPLib.rnp_identifier_iterator_create(ffi, iter.address(), "grip");
+ if (rv) {
+ return null;
+ }
+
+ while (!RNPLib.rnp_identifier_iterator_next(iter, grip.address())) {
+ if (grip.isNull()) {
+ break;
+ }
+
+ let handle = new RNPLib.rnp_key_handle_t();
+
+ if (RNPLib.rnp_locate_key(ffi, "grip", grip, handle.address())) {
+ throw new Error("rnp_locate_key failed");
+ }
+
+ let keyObj = {};
+ try {
+ if (RNP.isBadKey(handle, null, ffi)) {
+ continue;
+ }
+
+ // Skip if it is a sub key, it will be processed together with primary key later.
+ if (
+ !this.getKeyInfoFromHandle(
+ ffi,
+ handle,
+ keyObj,
+ false,
+ forListing,
+ onlySecret
+ )
+ ) {
+ continue;
+ }
+ } catch (ex) {
+ console.log(ex);
+ } finally {
+ RNPLib.rnp_key_handle_destroy(handle);
+ }
+
+ if (keyObj) {
+ if (withPubKey) {
+ let pubKey = await this.getPublicKey("0x" + keyObj.id, ffi);
+ if (pubKey) {
+ keyObj.pubKey = pubKey;
+ }
+ }
+ keys.push(keyObj);
+ }
+ }
+ RNPLib.rnp_identifier_iterator_destroy(iter);
+ }
+ return keys;
+ },
+
+ getFingerprintFromHandle(handle) {
+ let fingerprint = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_fprint(handle, fingerprint.address())) {
+ throw new Error("rnp_key_get_fprint failed");
+ }
+ let result = fingerprint.readString();
+ RNPLib.rnp_buffer_destroy(fingerprint);
+ return result;
+ },
+
+ getKeyIDFromHandle(handle) {
+ let ctypes_key_id = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_keyid(handle, ctypes_key_id.address())) {
+ throw new Error("rnp_key_get_keyid failed");
+ }
+ let result = ctypes_key_id.readString();
+ RNPLib.rnp_buffer_destroy(ctypes_key_id);
+ return result;
+ },
+
+ getSecretAvailableFromHandle(handle) {
+ return RNPLib.getSecretAvailableFromHandle(handle);
+ },
+
+ // We already know sub_handle is a subkey
+ getPrimaryKeyHandleFromSub(ffi, sub_handle) {
+ let newHandle = new RNPLib.rnp_key_handle_t();
+ // test my expectation is correct
+ if (!newHandle.isNull()) {
+ throw new Error("unexpected, new handle isn't null");
+ }
+ let primary_grip = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_primary_grip(sub_handle, primary_grip.address())) {
+ throw new Error("rnp_key_get_primary_grip failed");
+ }
+ if (primary_grip.isNull()) {
+ return newHandle;
+ }
+ if (RNPLib.rnp_locate_key(ffi, "grip", primary_grip, newHandle.address())) {
+ throw new Error("rnp_locate_key failed");
+ }
+ return newHandle;
+ },
+
+ // We don't know if handle is a subkey. If it's not, return null handle
+ getPrimaryKeyHandleIfSub(ffi, handle) {
+ let is_subkey = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_is_sub(handle, is_subkey.address())) {
+ throw new Error("rnp_key_is_sub failed");
+ }
+ if (!is_subkey.value) {
+ let nullHandle = new RNPLib.rnp_key_handle_t();
+ // test my expectation is correct
+ if (!nullHandle.isNull()) {
+ throw new Error("unexpected, new handle isn't null");
+ }
+ return nullHandle;
+ }
+ return this.getPrimaryKeyHandleFromSub(ffi, handle);
+ },
+
+ hasKeyWeakSelfSignature(selfId, handle) {
+ let sig_count = new lazy.ctypes.size_t();
+ if (RNPLib.rnp_key_get_signature_count(handle, sig_count.address())) {
+ throw new Error("rnp_key_get_signature_count failed");
+ }
+
+ let hasWeak = false;
+ for (let i = 0; !hasWeak && i < sig_count.value; i++) {
+ let sig_handle = new RNPLib.rnp_signature_handle_t();
+
+ if (RNPLib.rnp_key_get_signature_at(handle, i, sig_handle.address())) {
+ throw new Error("rnp_key_get_signature_at failed");
+ }
+
+ hasWeak = RNP.isWeakSelfSignature(selfId, sig_handle);
+ RNPLib.rnp_signature_handle_destroy(sig_handle);
+ }
+ return hasWeak;
+ },
+
+ isWeakSelfSignature(selfId, sig_handle) {
+ let sig_id_str = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_signature_get_keyid(sig_handle, sig_id_str.address())) {
+ throw new Error("rnp_signature_get_keyid failed");
+ }
+
+ let sigId = sig_id_str.readString();
+ RNPLib.rnp_buffer_destroy(sig_id_str);
+
+ // Is it a self-signature?
+ if (sigId != selfId) {
+ return false;
+ }
+
+ let creation = new lazy.ctypes.uint32_t();
+ if (RNPLib.rnp_signature_get_creation(sig_handle, creation.address())) {
+ throw new Error("rnp_signature_get_creation failed");
+ }
+
+ let hash_str = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_signature_get_hash_alg(sig_handle, hash_str.address())) {
+ throw new Error("rnp_signature_get_hash_alg failed");
+ }
+
+ let creation64 = new lazy.ctypes.uint64_t();
+ creation64.value = creation.value;
+
+ let level = new lazy.ctypes.uint32_t();
+
+ if (
+ RNPLib.rnp_get_security_rule(
+ RNPLib.ffi,
+ RNPLib.RNP_FEATURE_HASH_ALG,
+ hash_str,
+ creation64,
+ null,
+ null,
+ level.address()
+ )
+ ) {
+ throw new Error("rnp_get_security_rule failed");
+ }
+
+ RNPLib.rnp_buffer_destroy(hash_str);
+ return level.value < RNPLib.RNP_SECURITY_DEFAULT;
+ },
+
+ // return false if handle refers to subkey and should be ignored
+ getKeyInfoFromHandle(
+ ffi,
+ handle,
+ keyObj,
+ usePrimaryIfSubkey,
+ forListing,
+ onlyIfSecret
+ ) {
+ keyObj.ownerTrust = null;
+ keyObj.userId = null;
+ keyObj.userIds = [];
+ keyObj.subKeys = [];
+ keyObj.photoAvailable = false;
+ keyObj.hasIgnoredAttributes = false;
+
+ let is_subkey = new lazy.ctypes.bool();
+ let sub_count = new lazy.ctypes.size_t();
+ let uid_count = new lazy.ctypes.size_t();
+
+ if (RNPLib.rnp_key_is_sub(handle, is_subkey.address())) {
+ throw new Error("rnp_key_is_sub failed");
+ }
+ if (is_subkey.value) {
+ if (!usePrimaryIfSubkey) {
+ return false;
+ }
+ let rv = false;
+ let newHandle = this.getPrimaryKeyHandleFromSub(ffi, handle);
+ if (!newHandle.isNull()) {
+ // recursively call ourselves to get primary key info
+ rv = this.getKeyInfoFromHandle(
+ ffi,
+ newHandle,
+ keyObj,
+ false,
+ forListing,
+ onlyIfSecret
+ );
+ RNPLib.rnp_key_handle_destroy(newHandle);
+ }
+ return rv;
+ }
+
+ if (onlyIfSecret) {
+ let have_secret = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_have_secret(handle, have_secret.address())) {
+ throw new Error("rnp_key_have_secret failed");
+ }
+ if (!have_secret.value) {
+ return false;
+ }
+ }
+
+ let meta = {
+ a: false,
+ s: false,
+ c: false,
+ e: false,
+ };
+ this.addKeyAttributes(handle, meta, keyObj, false, forListing);
+
+ let hasAnySecretKey = keyObj.secretAvailable;
+
+ /* The remaining actions are done for primary keys, only. */
+ if (!is_subkey.value) {
+ if (RNPLib.rnp_key_get_uid_count(handle, uid_count.address())) {
+ throw new Error("rnp_key_get_uid_count failed");
+ }
+ let firstValidUid = null;
+ for (let i = 0; i < uid_count.value; i++) {
+ let uid_handle = new RNPLib.rnp_uid_handle_t();
+
+ if (RNPLib.rnp_key_get_uid_handle_at(handle, i, uid_handle.address())) {
+ throw new Error("rnp_key_get_uid_handle_at failed");
+ }
+
+ // Never allow revoked user IDs
+ let uidOkToUse = !this.isRevokedUid(uid_handle);
+ if (uidOkToUse) {
+ // Usually, we don't allow user IDs reported as not valid
+ uidOkToUse = !this.isBadUid(uid_handle);
+
+ let { hasGoodSignature, hasWeakSignature } =
+ this.getUidSignatureQuality(keyObj.keyId, uid_handle);
+
+ if (hasWeakSignature) {
+ keyObj.hasIgnoredAttributes = true;
+ }
+
+ if (!uidOkToUse && keyObj.keyTrust == "e") {
+ // However, a user might be not valid, because it has
+ // expired. If the primary key has expired, we should show
+ // some user ID, even if all user IDs have expired,
+ // otherwise the user cannot see any text description.
+ // We allow showing user IDs with a good self-signature.
+ uidOkToUse = hasGoodSignature;
+ }
+ }
+
+ if (uidOkToUse) {
+ let uid_str = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_uid_at(handle, i, uid_str.address())) {
+ throw new Error("rnp_key_get_uid_at failed");
+ }
+ let userIdStr = uid_str.readStringReplaceMalformed();
+ RNPLib.rnp_buffer_destroy(uid_str);
+
+ if (userIdStr !== RNP_PHOTO_USERID_ID) {
+ if (!firstValidUid) {
+ firstValidUid = userIdStr;
+ }
+
+ if (!keyObj.userId && this.isPrimaryUid(uid_handle)) {
+ keyObj.userId = userIdStr;
+ }
+
+ let uidObj = {};
+ uidObj.userId = userIdStr;
+ uidObj.type = "uid";
+ uidObj.keyTrust = keyObj.keyTrust;
+ uidObj.uidFpr = "??fpr??";
+
+ keyObj.userIds.push(uidObj);
+ }
+ }
+
+ RNPLib.rnp_uid_handle_destroy(uid_handle);
+ }
+
+ if (!keyObj.userId && firstValidUid) {
+ // No user ID marked as primary, so let's use the first valid.
+ keyObj.userId = firstValidUid;
+ }
+
+ if (!keyObj.userId) {
+ keyObj.userId = "?";
+ }
+
+ if (forListing) {
+ keyObj.name = keyObj.userId;
+ }
+
+ if (RNPLib.rnp_key_get_subkey_count(handle, sub_count.address())) {
+ throw new Error("rnp_key_get_subkey_count failed");
+ }
+ for (let i = 0; i < sub_count.value; i++) {
+ let sub_handle = new RNPLib.rnp_key_handle_t();
+ if (RNPLib.rnp_key_get_subkey_at(handle, i, sub_handle.address())) {
+ throw new Error("rnp_key_get_subkey_at failed");
+ }
+
+ if (RNP.hasKeyWeakSelfSignature(keyObj.keyId, sub_handle)) {
+ keyObj.hasIgnoredAttributes = true;
+ }
+
+ if (!RNP.isBadKey(sub_handle, handle, null)) {
+ let subKeyObj = {};
+ this.addKeyAttributes(sub_handle, meta, subKeyObj, true, forListing);
+ keyObj.subKeys.push(subKeyObj);
+ hasAnySecretKey = hasAnySecretKey || subKeyObj.secretAvailable;
+ }
+
+ RNPLib.rnp_key_handle_destroy(sub_handle);
+ }
+
+ let haveNonExpiringEncryptionKey = false;
+ let haveNonExpiringSigningKey = false;
+
+ let effectiveEncryptionExpiry = keyObj.expiry;
+ let effectiveSigningExpiry = keyObj.expiry;
+ let effectiveEncryptionExpiryTime = keyObj.expiryTime;
+ let effectiveSigningExpiryTime = keyObj.expiryTime;
+
+ if (keyObj.keyUseFor.match(/e/) && !keyObj.expiryTime) {
+ haveNonExpiringEncryptionKey = true;
+ }
+
+ if (keyObj.keyUseFor.match(/s/) && !keyObj.expiryTime) {
+ haveNonExpiringSigningKey = true;
+ }
+
+ let mostFutureEncExpiryTime = 0;
+ let mostFutureSigExpiryTime = 0;
+ let mostFutureEncExpiry = "";
+ let mostFutureSigExpiry = "";
+
+ for (let aSub of keyObj.subKeys) {
+ if (aSub.keyTrust == "r") {
+ continue;
+ }
+
+ // Expiring subkeys may shorten the effective expiry,
+ // unless the primary key is non-expiring and can be used
+ // for a purpose.
+ // Subkeys cannot extend the expiry beyond the primary key's.
+
+ // Strategy: If we don't have a non-expiring usable primary key,
+ // then find the usable subkey that has the most future
+ // expiration date. Stop searching is a non-expiring subkey
+ // is found. Then compare with primary key expiry.
+
+ if (!haveNonExpiringEncryptionKey && aSub.keyUseFor.match(/e/)) {
+ if (!aSub.expiryTime) {
+ haveNonExpiringEncryptionKey = true;
+ } else if (!mostFutureEncExpiryTime) {
+ mostFutureEncExpiryTime = aSub.expiryTime;
+ mostFutureEncExpiry = aSub.expiry;
+ } else if (aSub.expiryTime > mostFutureEncExpiryTime) {
+ mostFutureEncExpiryTime = aSub.expiryTime;
+ mostFutureEncExpiry = aSub.expiry;
+ }
+ }
+
+ // We only need to calculate the effective signing expiration
+ // if it's about a personal key (we require both signing and
+ // encryption capability).
+ if (
+ hasAnySecretKey &&
+ !haveNonExpiringSigningKey &&
+ aSub.keyUseFor.match(/s/)
+ ) {
+ if (!aSub.expiryTime) {
+ haveNonExpiringSigningKey = true;
+ } else if (!mostFutureSigExpiryTime) {
+ mostFutureSigExpiryTime = aSub.expiryTime;
+ mostFutureSigExpiry = aSub.expiry;
+ } else if (aSub.expiryTime > mostFutureEncExpiryTime) {
+ mostFutureSigExpiryTime = aSub.expiryTime;
+ mostFutureSigExpiry = aSub.expiry;
+ }
+ }
+ }
+
+ if (
+ !haveNonExpiringEncryptionKey &&
+ mostFutureEncExpiryTime &&
+ (!keyObj.expiryTime || mostFutureEncExpiryTime < keyObj.expiryTime)
+ ) {
+ effectiveEncryptionExpiryTime = mostFutureEncExpiryTime;
+ effectiveEncryptionExpiry = mostFutureEncExpiry;
+ }
+
+ if (
+ !haveNonExpiringSigningKey &&
+ mostFutureSigExpiryTime &&
+ (!keyObj.expiryTime || mostFutureSigExpiryTime < keyObj.expiryTime)
+ ) {
+ effectiveSigningExpiryTime = mostFutureSigExpiryTime;
+ effectiveSigningExpiry = mostFutureSigExpiry;
+ }
+
+ if (!hasAnySecretKey) {
+ keyObj.effectiveExpiryTime = effectiveEncryptionExpiryTime;
+ keyObj.effectiveExpiry = effectiveEncryptionExpiry;
+ } else {
+ let effectiveSignOrEncExpiry = "";
+ let effectiveSignOrEncExpiryTime = 0;
+
+ if (!effectiveEncryptionExpiryTime) {
+ if (effectiveSigningExpiryTime) {
+ effectiveSignOrEncExpiryTime = effectiveSigningExpiryTime;
+ effectiveSignOrEncExpiry = effectiveSigningExpiry;
+ }
+ } else if (!effectiveSigningExpiryTime) {
+ effectiveSignOrEncExpiryTime = effectiveEncryptionExpiryTime;
+ effectiveSignOrEncExpiry = effectiveEncryptionExpiry;
+ } else if (effectiveSigningExpiryTime < effectiveEncryptionExpiryTime) {
+ effectiveSignOrEncExpiryTime = effectiveSigningExpiryTime;
+ effectiveSignOrEncExpiry = effectiveSigningExpiry;
+ } else {
+ effectiveSignOrEncExpiryTime = effectiveEncryptionExpiryTime;
+ effectiveSignOrEncExpiry = effectiveEncryptionExpiry;
+ }
+
+ keyObj.effectiveExpiryTime = effectiveSignOrEncExpiryTime;
+ keyObj.effectiveExpiry = effectiveSignOrEncExpiry;
+ }
+
+ if (meta.s) {
+ keyObj.keyUseFor += "S";
+ }
+ if (meta.a) {
+ keyObj.keyUseFor += "A";
+ }
+ if (meta.c) {
+ keyObj.keyUseFor += "C";
+ }
+ if (meta.e) {
+ keyObj.keyUseFor += "E";
+ }
+
+ if (RNP.hasKeyWeakSelfSignature(keyObj.keyId, handle)) {
+ keyObj.hasIgnoredAttributes = true;
+ }
+ }
+
+ return true;
+ },
+
+ /*
+ // We don't need these functions currently, but it's helpful
+ // information that I'd like to keep around as documentation.
+
+ isUInt64WithinBounds(val) {
+ // JS integers are limited to 53 bits precision.
+ // Numbers smaller than 2^53 -1 are safe to use.
+ // (For comparison, that's 8192 TB or 8388608 GB).
+ const num53BitsMinus1 = ctypes.UInt64("0x1fffffffffffff");
+ return ctypes.UInt64.compare(val, num53BitsMinus1) < 0;
+ },
+
+ isUInt64Max(val) {
+ // 2^64-1, 18446744073709551615
+ const max = ctypes.UInt64("0xffffffffffffffff");
+ return ctypes.UInt64.compare(val, max) == 0;
+ },
+ */
+
+ isBadKey(handle, knownPrimaryKey, knownContextFFI) {
+ let validTill64 = new lazy.ctypes.uint64_t();
+ if (RNPLib.rnp_key_valid_till64(handle, validTill64.address())) {
+ throw new Error("rnp_key_valid_till64 failed");
+ }
+
+ // For the purpose of this function, we define bad as: there isn't
+ // any valid self-signature on the key, and thus the key should
+ // be completely avoided.
+ // In this scenario, zero is returned. In other words,
+ // if a non-zero value is returned, we know the key isn't completely
+ // bad according to our definition.
+
+ // ctypes.uint64_t().value is of type ctypes.UInt64
+
+ if (
+ lazy.ctypes.UInt64.compare(validTill64.value, lazy.ctypes.UInt64("0")) > 0
+ ) {
+ return false;
+ }
+
+ // If zero was returned, it could potentially have been revoked.
+ // If it was revoked, we don't treat is as generally bad,
+ // to allow importing it and to consume the revocation information.
+ // If the key was not revoked, then treat it as a bad key.
+ let key_revoked = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_is_revoked(handle, key_revoked.address())) {
+ throw new Error("rnp_key_is_revoked failed");
+ }
+
+ if (!key_revoked.value) {
+ // Also check if the primary key was revoked. If the primary key
+ // is revoked, the subkey is considered revoked, too.
+ if (knownPrimaryKey) {
+ if (RNPLib.rnp_key_is_revoked(knownPrimaryKey, key_revoked.address())) {
+ throw new Error("rnp_key_is_revoked failed");
+ }
+ } else if (knownContextFFI) {
+ let primaryHandle = this.getPrimaryKeyHandleIfSub(
+ knownContextFFI,
+ handle
+ );
+ if (!primaryHandle.isNull()) {
+ if (RNPLib.rnp_key_is_revoked(primaryHandle, key_revoked.address())) {
+ throw new Error("rnp_key_is_revoked failed");
+ }
+ RNPLib.rnp_key_handle_destroy(primaryHandle);
+ }
+ }
+ }
+
+ return !key_revoked.value;
+ },
+
+ isPrimaryUid(uid_handle) {
+ let is_primary = new lazy.ctypes.bool();
+
+ if (RNPLib.rnp_uid_is_primary(uid_handle, is_primary.address())) {
+ throw new Error("rnp_uid_is_primary failed");
+ }
+
+ return is_primary.value;
+ },
+
+ getUidSignatureQuality(self_key_id, uid_handle) {
+ let result = {
+ hasGoodSignature: false,
+ hasWeakSignature: false,
+ };
+
+ let sig_count = new lazy.ctypes.size_t();
+ if (RNPLib.rnp_uid_get_signature_count(uid_handle, sig_count.address())) {
+ throw new Error("rnp_uid_get_signature_count failed");
+ }
+
+ for (let i = 0; i < sig_count.value; i++) {
+ let sig_handle = new RNPLib.rnp_signature_handle_t();
+
+ if (
+ RNPLib.rnp_uid_get_signature_at(uid_handle, i, sig_handle.address())
+ ) {
+ throw new Error("rnp_uid_get_signature_at failed");
+ }
+
+ let sig_id_str = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_signature_get_keyid(sig_handle, sig_id_str.address())) {
+ throw new Error("rnp_signature_get_keyid failed");
+ }
+
+ if (sig_id_str.readString() == self_key_id) {
+ if (!result.hasGoodSignature) {
+ let sig_validity = RNPLib.rnp_signature_is_valid(sig_handle, 0);
+ result.hasGoodSignature =
+ sig_validity == RNPLib.RNP_SUCCESS ||
+ sig_validity == RNPLib.RNP_ERROR_SIGNATURE_EXPIRED;
+ }
+
+ if (!result.hasWeakSignature) {
+ result.hasWeakSignature = RNP.isWeakSelfSignature(
+ self_key_id,
+ sig_handle
+ );
+ }
+ }
+
+ RNPLib.rnp_buffer_destroy(sig_id_str);
+ RNPLib.rnp_signature_handle_destroy(sig_handle);
+ }
+
+ return result;
+ },
+
+ isBadUid(uid_handle) {
+ let is_valid = new lazy.ctypes.bool();
+
+ if (RNPLib.rnp_uid_is_valid(uid_handle, is_valid.address())) {
+ throw new Error("rnp_uid_is_valid failed");
+ }
+
+ return !is_valid.value;
+ },
+
+ isRevokedUid(uid_handle) {
+ let is_revoked = new lazy.ctypes.bool();
+
+ if (RNPLib.rnp_uid_is_revoked(uid_handle, is_revoked.address())) {
+ throw new Error("rnp_uid_is_revoked failed");
+ }
+
+ return is_revoked.value;
+ },
+
+ getKeySignatures(keyId, ignoreUnknownUid) {
+ let handle = this.getKeyHandleByKeyIdOrFingerprint(
+ RNPLib.ffi,
+ "0x" + keyId
+ );
+ if (handle.isNull()) {
+ return null;
+ }
+
+ let mainKeyObj = {};
+ this.getKeyInfoFromHandle(
+ RNPLib.ffi,
+ handle,
+ mainKeyObj,
+ false,
+ true,
+ false
+ );
+
+ let result = RNP._getSignatures(mainKeyObj, handle, ignoreUnknownUid);
+ RNPLib.rnp_key_handle_destroy(handle);
+ return result;
+ },
+
+ getKeyObjSignatures(keyObj, ignoreUnknownUid) {
+ let handle = this.getKeyHandleByKeyIdOrFingerprint(
+ RNPLib.ffi,
+ "0x" + keyObj.keyId
+ );
+ if (handle.isNull()) {
+ return null;
+ }
+
+ let result = RNP._getSignatures(keyObj, handle, ignoreUnknownUid);
+ RNPLib.rnp_key_handle_destroy(handle);
+ return result;
+ },
+
+ _getSignatures(keyObj, handle, ignoreUnknownUid) {
+ let rList = [];
+
+ try {
+ let uid_count = new lazy.ctypes.size_t();
+ if (RNPLib.rnp_key_get_uid_count(handle, uid_count.address())) {
+ throw new Error("rnp_key_get_uid_count failed");
+ }
+ let outputIndex = 0;
+ for (let i = 0; i < uid_count.value; i++) {
+ let uid_handle = new RNPLib.rnp_uid_handle_t();
+
+ if (RNPLib.rnp_key_get_uid_handle_at(handle, i, uid_handle.address())) {
+ throw new Error("rnp_key_get_uid_handle_at failed");
+ }
+
+ if (!this.isBadUid(uid_handle) && !this.isRevokedUid(uid_handle)) {
+ let uid_str = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_uid_at(handle, i, uid_str.address())) {
+ throw new Error("rnp_key_get_uid_at failed");
+ }
+ let userIdStr = uid_str.readStringReplaceMalformed();
+ RNPLib.rnp_buffer_destroy(uid_str);
+
+ if (userIdStr !== RNP_PHOTO_USERID_ID) {
+ let id = outputIndex;
+ ++outputIndex;
+
+ let subList = {};
+
+ subList = {};
+ subList.keyCreated = keyObj.keyCreated;
+ subList.created = keyObj.created;
+ subList.fpr = keyObj.fpr;
+ subList.keyId = keyObj.keyId;
+
+ subList.userId = userIdStr;
+ subList.sigList = [];
+
+ let sig_count = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_uid_get_signature_count(
+ uid_handle,
+ sig_count.address()
+ )
+ ) {
+ throw new Error("rnp_uid_get_signature_count failed");
+ }
+ for (let j = 0; j < sig_count.value; j++) {
+ let sigObj = {};
+
+ let sig_handle = new RNPLib.rnp_signature_handle_t();
+ if (
+ RNPLib.rnp_uid_get_signature_at(
+ uid_handle,
+ j,
+ sig_handle.address()
+ )
+ ) {
+ throw new Error("rnp_uid_get_signature_at failed");
+ }
+
+ let creation = new lazy.ctypes.uint32_t();
+ if (
+ RNPLib.rnp_signature_get_creation(
+ sig_handle,
+ creation.address()
+ )
+ ) {
+ throw new Error("rnp_signature_get_creation failed");
+ }
+ sigObj.keyCreated = creation.value;
+ sigObj.created = new Services.intl.DateTimeFormat().format(
+ new Date(sigObj.keyCreated * 1000)
+ );
+ sigObj.sigType = "?";
+
+ let sig_id_str = new lazy.ctypes.char.ptr();
+ if (
+ RNPLib.rnp_signature_get_keyid(sig_handle, sig_id_str.address())
+ ) {
+ throw new Error("rnp_signature_get_keyid failed");
+ }
+
+ let sigIdStr = sig_id_str.readString();
+ sigObj.signerKeyId = sigIdStr;
+ RNPLib.rnp_buffer_destroy(sig_id_str);
+
+ let signerHandle = new RNPLib.rnp_key_handle_t();
+
+ if (
+ RNPLib.rnp_signature_get_signer(
+ sig_handle,
+ signerHandle.address()
+ )
+ ) {
+ throw new Error("rnp_signature_get_signer failed");
+ }
+
+ if (
+ signerHandle.isNull() ||
+ this.isBadKey(signerHandle, null, RNPLib.ffi)
+ ) {
+ if (!ignoreUnknownUid) {
+ sigObj.userId = "?";
+ sigObj.sigKnown = false;
+ subList.sigList.push(sigObj);
+ }
+ } else {
+ let signer_uid_str = new lazy.ctypes.char.ptr();
+ if (
+ RNPLib.rnp_key_get_primary_uid(
+ signerHandle,
+ signer_uid_str.address()
+ )
+ ) {
+ throw new Error("rnp_key_get_primary_uid failed");
+ }
+ sigObj.userId = signer_uid_str.readStringReplaceMalformed();
+ RNPLib.rnp_buffer_destroy(signer_uid_str);
+ sigObj.sigKnown = true;
+ subList.sigList.push(sigObj);
+ RNPLib.rnp_key_handle_destroy(signerHandle);
+ }
+ RNPLib.rnp_signature_handle_destroy(sig_handle);
+ }
+ rList[id] = subList;
+ }
+ }
+
+ RNPLib.rnp_uid_handle_destroy(uid_handle);
+ }
+ } catch (ex) {
+ console.log(ex);
+ }
+ return rList;
+ },
+
+ policyForbidsAlg(alg) {
+ // TODO: implement policy
+ // Currently, all algorithms are allowed
+ return false;
+ },
+
+ getKeyIdsFromRecipHandle(recip_handle, resultRecipAndPrimary) {
+ resultRecipAndPrimary.keyId = "";
+ resultRecipAndPrimary.primaryKeyId = "";
+
+ let c_key_id = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_recipient_get_keyid(recip_handle, c_key_id.address())) {
+ throw new Error("rnp_recipient_get_keyid failed");
+ }
+ let recip_key_id = c_key_id.readString();
+ resultRecipAndPrimary.keyId = recip_key_id;
+ RNPLib.rnp_buffer_destroy(c_key_id);
+
+ let recip_key_handle = this.getKeyHandleByKeyIdOrFingerprint(
+ RNPLib.ffi,
+ "0x" + recip_key_id
+ );
+ if (!recip_key_handle.isNull()) {
+ let primary_signer_handle = this.getPrimaryKeyHandleIfSub(
+ RNPLib.ffi,
+ recip_key_handle
+ );
+ if (!primary_signer_handle.isNull()) {
+ resultRecipAndPrimary.primaryKeyId = this.getKeyIDFromHandle(
+ primary_signer_handle
+ );
+ RNPLib.rnp_key_handle_destroy(primary_signer_handle);
+ }
+ RNPLib.rnp_key_handle_destroy(recip_key_handle);
+ }
+ },
+
+ getCharCodeArray(pgpData) {
+ return pgpData.split("").map(e => e.charCodeAt());
+ },
+
+ is8Bit(charCodeArray) {
+ for (let i = 0; i < charCodeArray.length; i++) {
+ if (charCodeArray[i] > 255) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ removeCommentLines(str) {
+ const commentLine = /^Comment:.*(\r?\n|\r)/gm;
+ return str.replace(commentLine, "");
+ },
+
+ /**
+ * This function analyzes an encrypted message. It will check if one
+ * of the available secret keys can be used to decrypt a message,
+ * without actually performing the decryption.
+ * This is done by performing a decryption attempt in an empty
+ * environment, which doesn't have any keys available. The decryption
+ * attempt allows us to use the RNP APIs that list the key IDs of
+ * keys that would be able to decrypt the object.
+ * If a matching available secret ID is found, then the handle to that
+ * available secret key is returned.
+ *
+ * @param {rnp_input_t} - A prepared RNP input object that contains
+ * the encrypted message that should be analyzed.
+ * @returns {rnp_key_handle_t} - the handle of a private key that can
+ * be used to decrypt the message, or null, if no usable key was
+ * found.
+ */
+ getFirstAvailableDecryptionKeyHandle(encrypted_rnp_input_from_memory) {
+ let resultKey = null;
+
+ let dummyFfi = RNPLib.prepare_ffi();
+ if (!dummyFfi) {
+ return null;
+ }
+
+ const dummy_max_output_size = 1;
+ let dummy_output_to_memory = new RNPLib.rnp_output_t();
+ RNPLib.rnp_output_to_memory(
+ dummy_output_to_memory.address(),
+ dummy_max_output_size
+ );
+
+ let dummy_verify_op = new RNPLib.rnp_op_verify_t();
+ RNPLib.rnp_op_verify_create(
+ dummy_verify_op.address(),
+ dummyFfi,
+ encrypted_rnp_input_from_memory,
+ dummy_output_to_memory
+ );
+
+ // It's expected and ok that this function returns an error,
+ // e.r. RNP_ERROR_NO_SUITABLE_KEY, we'll query detailed results.
+ RNPLib.rnp_op_verify_execute(dummy_verify_op);
+
+ let all_recip_count = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_op_verify_get_recipient_count(
+ dummy_verify_op,
+ all_recip_count.address()
+ )
+ ) {
+ throw new Error("rnp_op_verify_get_recipient_count failed");
+ }
+
+ // Loop is skipped if all_recip_count is zero.
+ for (
+ let recip_i = 0;
+ recip_i < all_recip_count.value && !resultKey;
+ recip_i++
+ ) {
+ let recip_handle = new RNPLib.rnp_recipient_handle_t();
+ if (
+ RNPLib.rnp_op_verify_get_recipient_at(
+ dummy_verify_op,
+ recip_i,
+ recip_handle.address()
+ )
+ ) {
+ throw new Error("rnp_op_verify_get_recipient_at failed");
+ }
+
+ let c_key_id = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_recipient_get_keyid(recip_handle, c_key_id.address())) {
+ throw new Error("rnp_recipient_get_keyid failed");
+ }
+ let recip_key_id = c_key_id.readString();
+ RNPLib.rnp_buffer_destroy(c_key_id);
+
+ let recip_key_handle = this.getKeyHandleByKeyIdOrFingerprint(
+ RNPLib.ffi,
+ "0x" + recip_key_id
+ );
+ if (!recip_key_handle.isNull()) {
+ if (
+ RNPLib.getSecretAvailableFromHandle(recip_key_handle) &&
+ RNPLib.isSecretKeyMaterialAvailable(recip_key_handle)
+ ) {
+ resultKey = recip_key_handle;
+ } else {
+ RNPLib.rnp_key_handle_destroy(recip_key_handle);
+ }
+ }
+ }
+
+ RNPLib.rnp_output_destroy(dummy_output_to_memory);
+ RNPLib.rnp_op_verify_destroy(dummy_verify_op);
+ RNPLib.rnp_ffi_destroy(dummyFfi);
+
+ return resultKey;
+ },
+
+ async decrypt(encrypted, options, alreadyDecrypted = false) {
+ let arr = encrypted.split("").map(e => e.charCodeAt());
+ var encrypted_array = lazy.ctypes.uint8_t.array()(arr);
+
+ let result = {};
+ result.decryptedData = "";
+ result.statusFlags = 0;
+ result.extStatusFlags = 0;
+
+ result.userId = "";
+ result.keyId = "";
+ result.encToDetails = {};
+ result.encToDetails.myRecipKey = {};
+ result.encToDetails.allRecipKeys = [];
+ result.sigDetails = {};
+ result.sigDetails.sigDate = null;
+
+ if (alreadyDecrypted) {
+ result.encToDetails = options.encToDetails;
+ }
+
+ // We cannot reuse the same rnp_input_t for both the dummy operation
+ // and the real decryption operation, as the rnp_input_t object
+ // apparently becomes unusable after operating on it.
+ // That's why we produce a separate rnp_input_t based on the same
+ // data for the dummy operation.
+ let dummy_input_from_memory = new RNPLib.rnp_input_t();
+ RNPLib.rnp_input_from_memory(
+ dummy_input_from_memory.address(),
+ encrypted_array,
+ encrypted_array.length,
+ false
+ );
+
+ let rnpCannotDecrypt = true;
+
+ let decryptKey = new RnpPrivateKeyUnlockTracker(
+ this.getFirstAvailableDecryptionKeyHandle(dummy_input_from_memory)
+ );
+
+ decryptKey.setAllowPromptingUserForPassword(true);
+ decryptKey.setAllowAutoUnlockWithCachedPasswords(true);
+
+ if (decryptKey.available()) {
+ // If the key cannot be automatically unlocked, we'll rely on
+ // the password prompt callback from RNP, and on the user to unlock.
+ await decryptKey.unlock();
+ }
+
+ // Even if we don't have a matching decryption key, run
+ // through full processing, to obtain all the various status flags,
+ // and because decryption might not be necessary.
+ try {
+ let input_from_memory = new RNPLib.rnp_input_t();
+ RNPLib.rnp_input_from_memory(
+ input_from_memory.address(),
+ encrypted_array,
+ encrypted_array.length,
+ false
+ );
+
+ // Allow compressed encrypted messages, max factor 1200, up to 100 MiB.
+ const max_decrypted_message_size = 100 * 1024 * 1024;
+ let max_out = Math.min(
+ encrypted.length * 1200,
+ max_decrypted_message_size
+ );
+
+ let output_to_memory = new RNPLib.rnp_output_t();
+ RNPLib.rnp_output_to_memory(output_to_memory.address(), max_out);
+
+ let verify_op = new RNPLib.rnp_op_verify_t();
+ // Apparently the exit code here is ignored (replaced below)
+ result.exitCode = RNPLib.rnp_op_verify_create(
+ verify_op.address(),
+ RNPLib.ffi,
+ input_from_memory,
+ output_to_memory
+ );
+
+ result.exitCode = RNPLib.rnp_op_verify_execute(verify_op);
+
+ rnpCannotDecrypt = false;
+ let queryAllEncryptionRecipients = false;
+ let stillUndecidedIfSignatureIsBad = false;
+
+ let useDecodedData;
+ let processSignature;
+ switch (result.exitCode) {
+ case RNPLib.RNP_SUCCESS:
+ useDecodedData = true;
+ processSignature = true;
+ break;
+ case RNPLib.RNP_ERROR_SIGNATURE_INVALID:
+ // Either the signing key is unavailable, or the signature is
+ // indeed bad. Must check signature status below.
+ stillUndecidedIfSignatureIsBad = true;
+ useDecodedData = true;
+ processSignature = true;
+ break;
+ case RNPLib.RNP_ERROR_SIGNATURE_EXPIRED:
+ useDecodedData = true;
+ processSignature = false;
+ result.statusFlags |= lazy.EnigmailConstants.EXPIRED_SIGNATURE;
+ break;
+ case RNPLib.RNP_ERROR_DECRYPT_FAILED:
+ rnpCannotDecrypt = true;
+ useDecodedData = false;
+ processSignature = false;
+ queryAllEncryptionRecipients = true;
+ result.statusFlags |= lazy.EnigmailConstants.DECRYPTION_FAILED;
+ break;
+ case RNPLib.RNP_ERROR_NO_SUITABLE_KEY:
+ rnpCannotDecrypt = true;
+ useDecodedData = false;
+ processSignature = false;
+ queryAllEncryptionRecipients = true;
+ result.statusFlags |=
+ lazy.EnigmailConstants.DECRYPTION_FAILED |
+ lazy.EnigmailConstants.NO_SECKEY;
+ break;
+ default:
+ useDecodedData = false;
+ processSignature = false;
+ console.debug(
+ "rnp_op_verify_execute returned unexpected: " + result.exitCode
+ );
+ break;
+ }
+
+ if (useDecodedData && alreadyDecrypted) {
+ result.statusFlags |= lazy.EnigmailConstants.DECRYPTION_OKAY;
+ } else if (useDecodedData && !alreadyDecrypted) {
+ let prot_mode_str = new lazy.ctypes.char.ptr();
+ let prot_cipher_str = new lazy.ctypes.char.ptr();
+ let prot_is_valid = new lazy.ctypes.bool();
+
+ if (
+ RNPLib.rnp_op_verify_get_protection_info(
+ verify_op,
+ prot_mode_str.address(),
+ prot_cipher_str.address(),
+ prot_is_valid.address()
+ )
+ ) {
+ throw new Error("rnp_op_verify_get_protection_info failed");
+ }
+ let mode = prot_mode_str.readString();
+ let cipher = prot_cipher_str.readString();
+ let validIntegrityProtection = prot_is_valid.value;
+
+ if (mode != "none") {
+ if (!validIntegrityProtection) {
+ useDecodedData = false;
+ result.statusFlags |=
+ lazy.EnigmailConstants.MISSING_MDC |
+ lazy.EnigmailConstants.DECRYPTION_FAILED;
+ } else if (mode == "null" || this.policyForbidsAlg(cipher)) {
+ // don't indicate decryption, because a non-protecting or insecure cipher was used
+ result.statusFlags |= lazy.EnigmailConstants.UNKNOWN_ALGO;
+ } else {
+ queryAllEncryptionRecipients = true;
+
+ let recip_handle = new RNPLib.rnp_recipient_handle_t();
+ let rv = RNPLib.rnp_op_verify_get_used_recipient(
+ verify_op,
+ recip_handle.address()
+ );
+ if (rv) {
+ throw new Error("rnp_op_verify_get_used_recipient failed");
+ }
+
+ let c_alg = new lazy.ctypes.char.ptr();
+ rv = RNPLib.rnp_recipient_get_alg(recip_handle, c_alg.address());
+ if (rv) {
+ throw new Error("rnp_recipient_get_alg failed");
+ }
+
+ if (this.policyForbidsAlg(c_alg.readString())) {
+ result.statusFlags |= lazy.EnigmailConstants.UNKNOWN_ALGO;
+ } else {
+ this.getKeyIdsFromRecipHandle(
+ recip_handle,
+ result.encToDetails.myRecipKey
+ );
+ result.statusFlags |= lazy.EnigmailConstants.DECRYPTION_OKAY;
+ }
+ }
+ }
+ }
+
+ if (queryAllEncryptionRecipients) {
+ let all_recip_count = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_op_verify_get_recipient_count(
+ verify_op,
+ all_recip_count.address()
+ )
+ ) {
+ throw new Error("rnp_op_verify_get_recipient_count failed");
+ }
+ if (all_recip_count.value > 1) {
+ for (let recip_i = 0; recip_i < all_recip_count.value; recip_i++) {
+ let other_recip_handle = new RNPLib.rnp_recipient_handle_t();
+ if (
+ RNPLib.rnp_op_verify_get_recipient_at(
+ verify_op,
+ recip_i,
+ other_recip_handle.address()
+ )
+ ) {
+ throw new Error("rnp_op_verify_get_recipient_at failed");
+ }
+ let encTo = {};
+ this.getKeyIdsFromRecipHandle(other_recip_handle, encTo);
+ result.encToDetails.allRecipKeys.push(encTo);
+ }
+ }
+ }
+
+ if (useDecodedData) {
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ let rv = RNPLib.rnp_output_memory_get_buf(
+ output_to_memory,
+ result_buf.address(),
+ result_len.address(),
+ false
+ );
+
+ // result_len is of type UInt64, I don't know of a better way
+ // to convert it to an integer.
+ let b_len = parseInt(result_len.value.toString());
+
+ if (!rv) {
+ // type casting the pointer type to an array type allows us to
+ // access the elements by index.
+ let uint8_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.uint8_t.array(result_len.value).ptr
+ ).contents;
+
+ let str = "";
+ for (let i = 0; i < b_len; i++) {
+ str += String.fromCharCode(uint8_array[i]);
+ }
+
+ result.decryptedData = str;
+ }
+
+ if (processSignature) {
+ // ignore "no signature" result, that's ok
+ await this.getVerifyDetails(
+ RNPLib.ffi,
+ options.fromAddr,
+ options.msgDate,
+ verify_op,
+ result
+ );
+
+ if (
+ (result.statusFlags &
+ (lazy.EnigmailConstants.GOOD_SIGNATURE |
+ lazy.EnigmailConstants.UNCERTAIN_SIGNATURE |
+ lazy.EnigmailConstants.EXPIRED_SIGNATURE |
+ lazy.EnigmailConstants.BAD_SIGNATURE)) !=
+ 0
+ ) {
+ // A decision was already made.
+ stillUndecidedIfSignatureIsBad = false;
+ }
+ }
+ }
+
+ if (stillUndecidedIfSignatureIsBad) {
+ // We didn't find more details above, so conclude it's bad.
+ result.statusFlags |= lazy.EnigmailConstants.BAD_SIGNATURE;
+ }
+
+ RNPLib.rnp_input_destroy(input_from_memory);
+ RNPLib.rnp_output_destroy(output_to_memory);
+ RNPLib.rnp_op_verify_destroy(verify_op);
+ } finally {
+ decryptKey.release();
+ RNPLib.rnp_input_destroy(dummy_input_from_memory);
+ }
+
+ if (
+ rnpCannotDecrypt &&
+ !alreadyDecrypted &&
+ Services.prefs.getBoolPref("mail.openpgp.allow_external_gnupg") &&
+ lazy.GPGME.allDependenciesLoaded()
+ ) {
+ // failure processing with RNP, attempt decryption with GPGME
+ let r2 = await lazy.GPGME.decrypt(
+ encrypted,
+ this.enArmorCDataMessage.bind(this)
+ );
+ if (!r2.exitCode && r2.decryptedData) {
+ // TODO: obtain info which key ID was used for decryption
+ // and set result.decryptKey*
+ // It isn't obvious how to do that with GPGME, because
+ // gpgme_op_decrypt_result provides the list of all the
+ // encryption keys, only.
+
+ // The result may still contain wrapping like compression,
+ // and optional signature data. Recursively call ourselves
+ // to perform the remaining processing.
+ options.encToDetails = result.encToDetails;
+ return RNP.decrypt(r2.decryptedData, options, true);
+ }
+ }
+
+ return result;
+ },
+
+ async getVerifyDetails(ffi, fromAddr, msgDate, verify_op, result) {
+ if (!fromAddr) {
+ // We cannot correctly verify without knowing the fromAddr.
+ // This scenario is reached when quoting an encrypted MIME part.
+ return false;
+ }
+
+ let sig_count = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_op_verify_get_signature_count(verify_op, sig_count.address())
+ ) {
+ throw new Error("rnp_op_verify_get_signature_count failed");
+ }
+
+ // TODO: How should handle (sig_count.value > 1) ?
+ if (sig_count.value == 0) {
+ // !sig_count.value didn't work, === also doesn't work
+ return false;
+ }
+
+ let sig = new RNPLib.rnp_op_verify_signature_t();
+ if (RNPLib.rnp_op_verify_get_signature_at(verify_op, 0, sig.address())) {
+ throw new Error("rnp_op_verify_get_signature_at failed");
+ }
+
+ let sig_handle = new RNPLib.rnp_signature_handle_t();
+ if (RNPLib.rnp_op_verify_signature_get_handle(sig, sig_handle.address())) {
+ throw new Error("rnp_op_verify_signature_get_handle failed");
+ }
+
+ let sig_id_str = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_signature_get_keyid(sig_handle, sig_id_str.address())) {
+ throw new Error("rnp_signature_get_keyid failed");
+ }
+ result.keyId = sig_id_str.readString();
+ RNPLib.rnp_buffer_destroy(sig_id_str);
+ RNPLib.rnp_signature_handle_destroy(sig_handle);
+
+ let sig_status = RNPLib.rnp_op_verify_signature_get_status(sig);
+ if (sig_status != RNPLib.RNP_SUCCESS && !result.exitCode) {
+ /* Don't allow a good exit code. Keep existing bad code. */
+ result.exitCode = -1;
+ }
+
+ let query_signer = true;
+
+ switch (sig_status) {
+ case RNPLib.RNP_SUCCESS:
+ result.statusFlags |= lazy.EnigmailConstants.GOOD_SIGNATURE;
+ break;
+ case RNPLib.RNP_ERROR_KEY_NOT_FOUND:
+ result.statusFlags |=
+ lazy.EnigmailConstants.UNCERTAIN_SIGNATURE |
+ lazy.EnigmailConstants.NO_PUBKEY;
+ query_signer = false;
+ break;
+ case RNPLib.RNP_ERROR_SIGNATURE_EXPIRED:
+ result.statusFlags |= lazy.EnigmailConstants.EXPIRED_SIGNATURE;
+ break;
+ case RNPLib.RNP_ERROR_SIGNATURE_INVALID:
+ result.statusFlags |= lazy.EnigmailConstants.BAD_SIGNATURE;
+ break;
+ default:
+ result.statusFlags |= lazy.EnigmailConstants.BAD_SIGNATURE;
+ query_signer = false;
+ break;
+ }
+
+ if (msgDate && result.statusFlags & lazy.EnigmailConstants.GOOD_SIGNATURE) {
+ let created = new lazy.ctypes.uint32_t();
+ let expires = new lazy.ctypes.uint32_t(); //relative
+
+ if (
+ RNPLib.rnp_op_verify_signature_get_times(
+ sig,
+ created.address(),
+ expires.address()
+ )
+ ) {
+ throw new Error("rnp_op_verify_signature_get_times failed");
+ }
+
+ result.sigDetails.sigDate = new Date(created.value * 1000);
+
+ let timeDelta;
+ if (result.sigDetails.sigDate > msgDate) {
+ timeDelta = result.sigDetails.sigDate - msgDate;
+ } else {
+ timeDelta = msgDate - result.sigDetails.sigDate;
+ }
+
+ if (timeDelta > 1000 * 60 * 60 * 1) {
+ result.statusFlags &= ~lazy.EnigmailConstants.GOOD_SIGNATURE;
+ result.statusFlags |= lazy.EnigmailConstants.MSG_SIG_INVALID;
+ }
+ }
+
+ let signer_key = new RNPLib.rnp_key_handle_t();
+ let have_signer_key = false;
+ let use_signer_key = false;
+
+ if (query_signer) {
+ if (RNPLib.rnp_op_verify_signature_get_key(sig, signer_key.address())) {
+ // If sig_status isn't RNP_ERROR_KEY_NOT_FOUND then we must
+ // be able to obtain the signer key.
+ throw new Error("rnp_op_verify_signature_get_key");
+ }
+
+ have_signer_key = true;
+ use_signer_key = !this.isBadKey(signer_key, null, RNPLib.ffi);
+ }
+
+ if (use_signer_key) {
+ let keyInfo = {};
+ let ok = this.getKeyInfoFromHandle(
+ ffi,
+ signer_key,
+ keyInfo,
+ true,
+ false,
+ false
+ );
+ if (!ok) {
+ throw new Error("getKeyInfoFromHandle failed");
+ }
+
+ let fromMatchesAnyUid = false;
+ let fromLower = fromAddr ? fromAddr.toLowerCase() : "";
+
+ for (let uid of keyInfo.userIds) {
+ if (uid.type !== "uid") {
+ continue;
+ }
+
+ if (
+ lazy.EnigmailFuncs.getEmailFromUserID(uid.userId).toLowerCase() ===
+ fromLower
+ ) {
+ fromMatchesAnyUid = true;
+ break;
+ }
+ }
+
+ let useUndecided = true;
+
+ if (keyInfo.secretAvailable) {
+ let isPersonal = await lazy.PgpSqliteDb2.isAcceptedAsPersonalKey(
+ keyInfo.fpr
+ );
+ if (isPersonal && fromMatchesAnyUid) {
+ result.extStatusFlags |= lazy.EnigmailConstants.EXT_SELF_IDENTITY;
+ useUndecided = false;
+ } else {
+ result.statusFlags |= lazy.EnigmailConstants.INVALID_RECIPIENT;
+ useUndecided = true;
+ }
+ } else if (result.statusFlags & lazy.EnigmailConstants.GOOD_SIGNATURE) {
+ if (!fromMatchesAnyUid) {
+ /* At the time the user had accepted the key,
+ * a different set of email addresses might have been
+ * contained inside the key. In the meantime, we might
+ * have refreshed the key, a email addresses
+ * might have been removed or revoked.
+ * If the current from was removed/revoked, we'd still
+ * get an acceptance match, but the from is no longer found
+ * in the key's UID list. That should get "undecided".
+ */
+ result.statusFlags |= lazy.EnigmailConstants.INVALID_RECIPIENT;
+ useUndecided = true;
+ } else {
+ let acceptanceResult = {};
+ try {
+ await lazy.PgpSqliteDb2.getAcceptance(
+ keyInfo.fpr,
+ fromLower,
+ acceptanceResult
+ );
+ } catch (ex) {
+ console.debug("getAcceptance failed: " + ex);
+ }
+
+ // unverified key acceptance means, we consider the signature OK,
+ // but it's not a trusted identity.
+ // unverified signature means, we cannot decide if the signature
+ // is ok.
+
+ if (
+ "emailDecided" in acceptanceResult &&
+ acceptanceResult.emailDecided &&
+ "fingerprintAcceptance" in acceptanceResult &&
+ acceptanceResult.fingerprintAcceptance.length &&
+ acceptanceResult.fingerprintAcceptance != "undecided"
+ ) {
+ if (acceptanceResult.fingerprintAcceptance == "rejected") {
+ result.statusFlags &= ~lazy.EnigmailConstants.GOOD_SIGNATURE;
+ result.statusFlags |=
+ lazy.EnigmailConstants.BAD_SIGNATURE |
+ lazy.EnigmailConstants.INVALID_RECIPIENT;
+ useUndecided = false;
+ } else if (acceptanceResult.fingerprintAcceptance == "verified") {
+ result.statusFlags |= lazy.EnigmailConstants.TRUSTED_IDENTITY;
+ useUndecided = false;
+ } else if (acceptanceResult.fingerprintAcceptance == "unverified") {
+ useUndecided = false;
+ }
+ }
+ }
+ }
+
+ if (useUndecided) {
+ result.statusFlags &= ~lazy.EnigmailConstants.GOOD_SIGNATURE;
+ result.statusFlags |= lazy.EnigmailConstants.UNCERTAIN_SIGNATURE;
+ }
+ }
+
+ if (have_signer_key) {
+ RNPLib.rnp_key_handle_destroy(signer_key);
+ }
+
+ return true;
+ },
+
+ async verifyDetached(data, options) {
+ let result = {};
+ result.decryptedData = "";
+ result.statusFlags = 0;
+ result.exitCode = -1;
+ result.extStatusFlags = 0;
+ result.userId = "";
+ result.keyId = "";
+ result.sigDetails = {};
+ result.sigDetails.sigDate = null;
+
+ let sig_arr = options.mimeSignatureData.split("").map(e => e.charCodeAt());
+ var sig_array = lazy.ctypes.uint8_t.array()(sig_arr);
+
+ let input_sig = new RNPLib.rnp_input_t();
+ RNPLib.rnp_input_from_memory(
+ input_sig.address(),
+ sig_array,
+ sig_array.length,
+ false
+ );
+
+ let input_from_memory = new RNPLib.rnp_input_t();
+
+ let arr = data.split("").map(e => e.charCodeAt());
+ var data_array = lazy.ctypes.uint8_t.array()(arr);
+
+ RNPLib.rnp_input_from_memory(
+ input_from_memory.address(),
+ data_array,
+ data_array.length,
+ false
+ );
+
+ let verify_op = new RNPLib.rnp_op_verify_t();
+ if (
+ RNPLib.rnp_op_verify_detached_create(
+ verify_op.address(),
+ RNPLib.ffi,
+ input_from_memory,
+ input_sig
+ )
+ ) {
+ throw new Error("rnp_op_verify_detached_create failed");
+ }
+
+ result.exitCode = RNPLib.rnp_op_verify_execute(verify_op);
+
+ let haveSignature = await this.getVerifyDetails(
+ RNPLib.ffi,
+ options.fromAddr,
+ options.msgDate,
+ verify_op,
+ result
+ );
+ if (!haveSignature) {
+ if (!result.exitCode) {
+ /* Don't allow a good exit code. Keep existing bad code. */
+ result.exitCode = -1;
+ }
+ result.statusFlags |= lazy.EnigmailConstants.BAD_SIGNATURE;
+ }
+
+ RNPLib.rnp_input_destroy(input_from_memory);
+ RNPLib.rnp_input_destroy(input_sig);
+ RNPLib.rnp_op_verify_destroy(verify_op);
+
+ return result;
+ },
+
+ async genKey(userId, keyType, keyBits, expiryDays, passphrase) {
+ let newKeyId = "";
+ let newKeyFingerprint = "";
+
+ let primaryKeyType;
+ let primaryKeyBits = 0;
+ let subKeyType;
+ let subKeyBits = 0;
+ let primaryKeyCurve = null;
+ let subKeyCurve = null;
+ let expireSeconds = 0;
+
+ if (keyType == "RSA") {
+ primaryKeyType = subKeyType = "rsa";
+ primaryKeyBits = subKeyBits = keyBits;
+ } else if (keyType == "ECC") {
+ primaryKeyType = "eddsa";
+ subKeyType = "ecdh";
+ subKeyCurve = "Curve25519";
+ } else {
+ return null;
+ }
+
+ if (expiryDays != 0) {
+ expireSeconds = expiryDays * 24 * 60 * 60;
+ }
+
+ let genOp = new RNPLib.rnp_op_generate_t();
+ if (
+ RNPLib.rnp_op_generate_create(genOp.address(), RNPLib.ffi, primaryKeyType)
+ ) {
+ throw new Error("rnp_op_generate_create primary failed");
+ }
+
+ if (RNPLib.rnp_op_generate_set_userid(genOp, userId)) {
+ throw new Error("rnp_op_generate_set_userid failed");
+ }
+
+ if (passphrase != null && passphrase.length != 0) {
+ if (RNPLib.rnp_op_generate_set_protection_password(genOp, passphrase)) {
+ throw new Error("rnp_op_generate_set_protection_password failed");
+ }
+ }
+
+ if (primaryKeyBits != 0) {
+ if (RNPLib.rnp_op_generate_set_bits(genOp, primaryKeyBits)) {
+ throw new Error("rnp_op_generate_set_bits primary failed");
+ }
+ }
+
+ if (primaryKeyCurve != null) {
+ if (RNPLib.rnp_op_generate_set_curve(genOp, primaryKeyCurve)) {
+ throw new Error("rnp_op_generate_set_curve primary failed");
+ }
+ }
+
+ if (RNPLib.rnp_op_generate_set_expiration(genOp, expireSeconds)) {
+ throw new Error("rnp_op_generate_set_expiration primary failed");
+ }
+
+ if (RNPLib.rnp_op_generate_execute(genOp)) {
+ throw new Error("rnp_op_generate_execute primary failed");
+ }
+
+ let primaryKey = new RNPLib.rnp_key_handle_t();
+ if (RNPLib.rnp_op_generate_get_key(genOp, primaryKey.address())) {
+ throw new Error("rnp_op_generate_get_key primary failed");
+ }
+
+ RNPLib.rnp_op_generate_destroy(genOp);
+
+ newKeyFingerprint = this.getFingerprintFromHandle(primaryKey);
+ newKeyId = this.getKeyIDFromHandle(primaryKey);
+
+ if (
+ RNPLib.rnp_op_generate_subkey_create(
+ genOp.address(),
+ RNPLib.ffi,
+ primaryKey,
+ subKeyType
+ )
+ ) {
+ throw new Error("rnp_op_generate_subkey_create primary failed");
+ }
+
+ if (passphrase != null && passphrase.length != 0) {
+ if (RNPLib.rnp_op_generate_set_protection_password(genOp, passphrase)) {
+ throw new Error("rnp_op_generate_set_protection_password failed");
+ }
+ }
+
+ if (subKeyBits != 0) {
+ if (RNPLib.rnp_op_generate_set_bits(genOp, subKeyBits)) {
+ throw new Error("rnp_op_generate_set_bits sub failed");
+ }
+ }
+
+ if (subKeyCurve != null) {
+ if (RNPLib.rnp_op_generate_set_curve(genOp, subKeyCurve)) {
+ throw new Error("rnp_op_generate_set_curve sub failed");
+ }
+ }
+
+ if (RNPLib.rnp_op_generate_set_expiration(genOp, expireSeconds)) {
+ throw new Error("rnp_op_generate_set_expiration sub failed");
+ }
+
+ let unlocked = false;
+ try {
+ if (passphrase != null && passphrase.length != 0) {
+ if (RNPLib.rnp_key_unlock(primaryKey, passphrase)) {
+ throw new Error("rnp_key_unlock failed");
+ }
+ unlocked = true;
+ }
+
+ if (RNPLib.rnp_op_generate_execute(genOp)) {
+ throw new Error("rnp_op_generate_execute sub failed");
+ }
+ } finally {
+ if (unlocked) {
+ RNPLib.rnp_key_lock(primaryKey);
+ }
+ }
+
+ RNPLib.rnp_op_generate_destroy(genOp);
+ RNPLib.rnp_key_handle_destroy(primaryKey);
+
+ await lazy.PgpSqliteDb2.acceptAsPersonalKey(newKeyFingerprint);
+
+ return newKeyId;
+ },
+
+ async saveKeyRings() {
+ RNPLib.saveKeys();
+ Services.obs.notifyObservers(null, "openpgp-key-change");
+ },
+
+ importToFFI(ffi, keyBlockStr, usePublic, useSecret, permissive) {
+ let input_from_memory = new RNPLib.rnp_input_t();
+
+ if (!keyBlockStr) {
+ throw new Error("no keyBlockStr parameter in importToFFI");
+ }
+
+ if (typeof keyBlockStr != "string") {
+ throw new Error(
+ "keyBlockStr of unepected type importToFFI: %o",
+ keyBlockStr
+ );
+ }
+
+ // Input might be either plain text or binary data.
+ // If the input is binary, do not modify it.
+ // If the input contains characters with a multi-byte char code value,
+ // we know the input doesn't consist of binary 8-bit values. Rather,
+ // it contains text with multi-byte characters. The only scenario
+ // in which we can tolerate those are comment lines, which we can
+ // filter out.
+
+ let arr = this.getCharCodeArray(keyBlockStr);
+ if (!this.is8Bit(arr)) {
+ let trimmed = this.removeCommentLines(keyBlockStr);
+ arr = this.getCharCodeArray(trimmed);
+ if (!this.is8Bit(arr)) {
+ throw new Error(`Non-ascii key block: ${keyBlockStr}`);
+ }
+ }
+ var key_array = lazy.ctypes.uint8_t.array()(arr);
+
+ if (
+ RNPLib.rnp_input_from_memory(
+ input_from_memory.address(),
+ key_array,
+ key_array.length,
+ false
+ )
+ ) {
+ throw new Error("rnp_input_from_memory failed");
+ }
+
+ let jsonInfo = new lazy.ctypes.char.ptr();
+
+ let flags = 0;
+ if (usePublic) {
+ flags |= RNPLib.RNP_LOAD_SAVE_PUBLIC_KEYS;
+ }
+ if (useSecret) {
+ flags |= RNPLib.RNP_LOAD_SAVE_SECRET_KEYS;
+ }
+
+ if (permissive) {
+ flags |= RNPLib.RNP_LOAD_SAVE_PERMISSIVE;
+ }
+
+ let rv = RNPLib.rnp_import_keys(
+ ffi,
+ input_from_memory,
+ flags,
+ jsonInfo.address()
+ );
+
+ // TODO: parse jsonInfo and return a list of keys,
+ // as seen in keyRing.importKeyAsync.
+ // (should prevent the incorrect popup "no keys imported".)
+
+ if (rv) {
+ console.debug("rnp_import_keys failed with rv: " + rv);
+ }
+
+ RNPLib.rnp_buffer_destroy(jsonInfo);
+ RNPLib.rnp_input_destroy(input_from_memory);
+
+ return rv;
+ },
+
+ maxImportKeyBlockSize: 5000000,
+
+ async getOnePubKeyFromKeyBlock(keyBlockStr, fpr, permissive = true) {
+ if (!keyBlockStr) {
+ throw new Error(`Invalid parameter; keyblock: ${keyBlockStr}`);
+ }
+
+ if (keyBlockStr.length > RNP.maxImportKeyBlockSize) {
+ throw new Error("rejecting big keyblock");
+ }
+
+ let tempFFI = RNPLib.prepare_ffi();
+ if (!tempFFI) {
+ throw new Error("Couldn't initialize librnp.");
+ }
+
+ let pubKey;
+ if (!this.importToFFI(tempFFI, keyBlockStr, true, false, permissive)) {
+ pubKey = await this.getPublicKey("0x" + fpr, tempFFI);
+ }
+
+ RNPLib.rnp_ffi_destroy(tempFFI);
+ return pubKey;
+ },
+
+ async getKeyListFromKeyBlockImpl(
+ keyBlockStr,
+ pubkey = true,
+ seckey = false,
+ permissive = true,
+ withPubKey = false
+ ) {
+ if (!keyBlockStr) {
+ throw new Error(`Invalid parameter; keyblock: ${keyBlockStr}`);
+ }
+
+ if (keyBlockStr.length > RNP.maxImportKeyBlockSize) {
+ throw new Error("rejecting big keyblock");
+ }
+
+ let tempFFI = RNPLib.prepare_ffi();
+ if (!tempFFI) {
+ throw new Error("Couldn't initialize librnp.");
+ }
+
+ let keyList = null;
+ if (!this.importToFFI(tempFFI, keyBlockStr, pubkey, seckey, permissive)) {
+ keyList = await this.getKeysFromFFI(
+ tempFFI,
+ true,
+ null,
+ false,
+ withPubKey
+ );
+ }
+
+ RNPLib.rnp_ffi_destroy(tempFFI);
+ return keyList;
+ },
+
+ /**
+ * Take two or more ASCII armored key blocks and import them into memory,
+ * and return the merged public key for the given fingerprint.
+ * (Other keys included in the key blocks are ignored.)
+ * The intention is to use it to combine keys obtained from different places,
+ * possibly with updated/different expiration date and userIds etc. to
+ * a canonical representation of them.
+ *
+ * @param {string} fingerprint - Key fingerprint.
+ * @param {...string} - Key blocks.
+ * @returns {string} the resulting public key of the blocks
+ */
+ async mergePublicKeyBlocks(fingerprint, ...keyBlocks) {
+ if (keyBlocks.some(b => b.length > RNP.maxImportKeyBlockSize)) {
+ throw new Error("keyBlock too big");
+ }
+
+ let tempFFI = RNPLib.prepare_ffi();
+ if (!tempFFI) {
+ throw new Error("Couldn't initialize librnp.");
+ }
+
+ const pubkey = true;
+ const seckey = false;
+ const permissive = false;
+ for (let block of new Set(keyBlocks)) {
+ if (this.importToFFI(tempFFI, block, pubkey, seckey, permissive)) {
+ throw new Error("Merging public keys failed");
+ }
+ }
+ let pubKey = await this.getPublicKey(`0x${fingerprint}`, tempFFI);
+
+ RNPLib.rnp_ffi_destroy(tempFFI);
+ return pubKey;
+ },
+
+ async importRevImpl(data) {
+ if (!data || typeof data != "string") {
+ throw new Error("invalid data parameter");
+ }
+
+ let arr = data.split("").map(e => e.charCodeAt());
+ var key_array = lazy.ctypes.uint8_t.array()(arr);
+
+ let input_from_memory = new RNPLib.rnp_input_t();
+ if (
+ RNPLib.rnp_input_from_memory(
+ input_from_memory.address(),
+ key_array,
+ key_array.length,
+ false
+ )
+ ) {
+ throw new Error("rnp_input_from_memory failed");
+ }
+
+ let jsonInfo = new lazy.ctypes.char.ptr();
+
+ let flags = 0;
+ let rv = RNPLib.rnp_import_signatures(
+ RNPLib.ffi,
+ input_from_memory,
+ flags,
+ jsonInfo.address()
+ );
+
+ // TODO: parse jsonInfo
+
+ if (rv) {
+ console.debug("rnp_import_signatures failed with rv: " + rv);
+ }
+
+ RNPLib.rnp_buffer_destroy(jsonInfo);
+ RNPLib.rnp_input_destroy(input_from_memory);
+ await this.saveKeyRings();
+
+ return rv;
+ },
+
+ async importSecKeyBlockImpl(
+ win,
+ passCB,
+ keepPassphrases,
+ keyBlockStr,
+ permissive = false,
+ limitedFPRs = []
+ ) {
+ return this._importKeyBlockWithAutoAccept(
+ win,
+ passCB,
+ keepPassphrases,
+ keyBlockStr,
+ false,
+ true,
+ null,
+ permissive,
+ limitedFPRs
+ );
+ },
+
+ async importPubkeyBlockAutoAcceptImpl(
+ win,
+ keyBlockStr,
+ acceptance,
+ permissive = false,
+ limitedFPRs = []
+ ) {
+ return this._importKeyBlockWithAutoAccept(
+ win,
+ null,
+ false,
+ keyBlockStr,
+ true,
+ false,
+ acceptance,
+ permissive,
+ limitedFPRs
+ );
+ },
+
+ /**
+ * Import either a public key or a secret key.
+ * Importing both at the same time isn't supported by this API.
+ *
+ * @param {?nsIWindow} win - Parent window, may be null
+ * @param {Function} passCB - a callback function that will be called if the user needs
+ * to enter a passphrase to unlock a secret key. See passphrasePromptCallback
+ * for the function signature.
+ * @param {boolean} keepPassphrases - controls which passphrase will
+ * be used to protect imported secret keys. If true, the existing
+ * passphrase will be kept. If false, (of if currently there's no
+ * passphrase set), passphrase protection will be changed to use
+ * our automatic passphrase (to allow automatic protection by
+ * primary password, whether's it's currently enabled or not).
+ * @param {string} keyBlockStr - An block of OpenPGP key data. See
+ * implementation of function importToFFI for allowed contents.
+ * TODO: Write better documentation for this parameter.
+ * @param {boolean} pubkey - If true, import the public keys found in
+ * keyBlockStr.
+ * @param {boolean} seckey - If true, import the secret keys found in
+ * keyBlockStr.
+ * @param {string} acceptance - The key acceptance level that should
+ * be assigned to imported public keys.
+ * TODO: Write better documentation for the allowed values.
+ * @param {boolean} permissive - Whether it's allowed to fall back
+ * to a permissive import, if strict import fails.
+ * (See RNP documentation for RNP_LOAD_SAVE_PERMISSIVE.)
+ * @param {string[]} limitedFPRs - This is a filtering parameter.
+ * If the array is empty, all keys will be imported.
+ * If the array contains at least one entry, a key will be imported
+ * only if its fingerprint (of the primary key) is listed in this
+ * array.
+ */
+ async _importKeyBlockWithAutoAccept(
+ win,
+ passCB,
+ keepPassphrases,
+ keyBlockStr,
+ pubkey,
+ seckey,
+ acceptance,
+ permissive = false,
+ limitedFPRs = []
+ ) {
+ if (keyBlockStr.length > RNP.maxImportKeyBlockSize) {
+ throw new Error("rejecting big keyblock");
+ }
+ if (pubkey && seckey) {
+ // Currently no caller needs to import both at the save time,
+ // and the implementation hasn't been reviewed, whether it
+ // supports it or not, so we refuse this request.
+ throw new Error("Cannot import public and secret keys at the same time");
+ }
+
+ /*
+ * Import strategy:
+ * - import file into a temporary space, in-memory only (ffi)
+ * - if we failed to decrypt the secret keys, return null
+ * - set the password of secret keys that don't have one yet
+ * - get the key listing of all keys from the temporary space,
+ * which is want we want to return as the import report
+ * - export all keys from the temporary space, and import them
+ * into our permanent space.
+ */
+ let userFlags = { canceled: false };
+
+ let result = {};
+ result.exitCode = -1;
+ result.importedKeys = [];
+ result.errorMsg = "";
+
+ let tempFFI = RNPLib.prepare_ffi();
+ if (!tempFFI) {
+ throw new Error("Couldn't initialize librnp.");
+ }
+
+ // TODO: check result
+ if (this.importToFFI(tempFFI, keyBlockStr, pubkey, seckey, permissive)) {
+ result.errorMsg = "RNP.importToFFI failed";
+ return result;
+ }
+
+ let keys = await this.getKeysFromFFI(tempFFI, true);
+ let pwCache = {
+ passwords: [],
+ };
+
+ // Prior to importing, ensure the user is able to unlock all keys
+
+ // If anything goes wrong during our attempt to unlock keys,
+ // we don't want to keep key material remain unprotected in memory,
+ // that's why we remember the trackers, including the respective
+ // unlock passphrase, temporarily in memory, and we'll minimize
+ // the period of time during which the key remains unprotected.
+ let secretKeyTrackers = new Map();
+
+ let unableToUnlockId = null;
+
+ for (let k of keys) {
+ let fprStr = "0x" + k.fpr;
+ if (limitedFPRs.length && !limitedFPRs.includes(fprStr)) {
+ continue;
+ }
+
+ let impKey = await this.getKeyHandleByIdentifier(tempFFI, fprStr);
+ if (impKey.isNull()) {
+ throw new Error("cannot get key handle for imported key: " + k.fpr);
+ }
+
+ if (!k.secretAvailable) {
+ RNPLib.rnp_key_handle_destroy(impKey);
+ impKey = null;
+ } else {
+ let primaryKey = new RnpPrivateKeyUnlockTracker(impKey);
+ impKey = null;
+
+ // Don't attempt to unlock secret keys that are unavailable.
+ if (primaryKey.available()) {
+ // Is it unprotected?
+ primaryKey.unlockWithPassword("");
+ if (primaryKey.isUnlocked()) {
+ // yes, it's unprotected (empty passphrase)
+ await primaryKey.setAutoPassphrase();
+ } else {
+ // try to unlock with the recently entered passwords,
+ // or ask the user, if allowed
+ primaryKey.setPasswordCache(pwCache);
+ primaryKey.setAllowAutoUnlockWithCachedPasswords(true);
+ primaryKey.setAllowPromptingUserForPassword(!!passCB);
+ primaryKey.setPassphraseCallback(passCB);
+ primaryKey.setRememberUnlockPassword(true);
+ await primaryKey.unlock(tempFFI);
+ if (!primaryKey.isUnlocked()) {
+ userFlags.canceled = true;
+ unableToUnlockId = RNP.getKeyIDFromHandle(primaryKey.getHandle());
+ } else {
+ secretKeyTrackers.set(fprStr, primaryKey);
+ }
+ }
+ }
+
+ if (!userFlags.canceled) {
+ let sub_count = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_key_get_subkey_count(
+ primaryKey.getHandle(),
+ sub_count.address()
+ )
+ ) {
+ throw new Error("rnp_key_get_subkey_count failed");
+ }
+
+ for (let i = 0; i < sub_count.value && !userFlags.canceled; i++) {
+ let sub_handle = new RNPLib.rnp_key_handle_t();
+ if (
+ RNPLib.rnp_key_get_subkey_at(
+ primaryKey.getHandle(),
+ i,
+ sub_handle.address()
+ )
+ ) {
+ throw new Error("rnp_key_get_subkey_at failed");
+ }
+
+ let subTracker = new RnpPrivateKeyUnlockTracker(sub_handle);
+ sub_handle = null;
+
+ if (subTracker.available()) {
+ // Is it unprotected?
+ subTracker.unlockWithPassword("");
+ if (subTracker.isUnlocked()) {
+ // yes, it's unprotected (empty passphrase)
+ await subTracker.setAutoPassphrase();
+ } else {
+ // try to unlock with the recently entered passwords,
+ // or ask the user, if allowed
+ subTracker.setPasswordCache(pwCache);
+ subTracker.setAllowAutoUnlockWithCachedPasswords(true);
+ subTracker.setAllowPromptingUserForPassword(!!passCB);
+ subTracker.setPassphraseCallback(passCB);
+ subTracker.setRememberUnlockPassword(true);
+ await subTracker.unlock(tempFFI);
+ if (!subTracker.isUnlocked()) {
+ userFlags.canceled = true;
+ unableToUnlockId = RNP.getKeyIDFromHandle(
+ subTracker.getHandle()
+ );
+ break;
+ } else {
+ secretKeyTrackers.set(
+ this.getFingerprintFromHandle(subTracker.getHandle()),
+ subTracker
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (userFlags.canceled) {
+ break;
+ }
+ }
+
+ if (unableToUnlockId) {
+ result.errorMsg = "Cannot unlock key " + unableToUnlockId;
+ }
+
+ if (!userFlags.canceled) {
+ for (let k of keys) {
+ let fprStr = "0x" + k.fpr;
+ if (limitedFPRs.length && !limitedFPRs.includes(fprStr)) {
+ continue;
+ }
+
+ // We allow importing, if any of the following is true
+ // - it contains a secret key
+ // - it contains at least one user ID
+ // - it is an update for an existing key (possibly new validity/revocation)
+
+ if (k.userIds.length == 0 && !k.secretAvailable) {
+ let existingKey = await this.getKeyHandleByIdentifier(
+ RNPLib.ffi,
+ "0x" + k.fpr
+ );
+ if (existingKey.isNull()) {
+ continue;
+ }
+ RNPLib.rnp_key_handle_destroy(existingKey);
+ }
+
+ let impKeyPub;
+ let impKeySecTracker = secretKeyTrackers.get(fprStr);
+ if (!impKeySecTracker) {
+ impKeyPub = await this.getKeyHandleByIdentifier(tempFFI, fprStr);
+ }
+
+ if (!keepPassphrases) {
+ // It's possible that the primary key doesn't come with a
+ // secret key (only public key of primary key was imported).
+ // In that scenario, we must still process subkeys that come
+ // with a secret key.
+
+ if (impKeySecTracker) {
+ impKeySecTracker.unprotect();
+ await impKeySecTracker.setAutoPassphrase();
+ }
+
+ let sub_count = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_key_get_subkey_count(
+ impKeySecTracker ? impKeySecTracker.getHandle() : impKeyPub,
+ sub_count.address()
+ )
+ ) {
+ throw new Error("rnp_key_get_subkey_count failed");
+ }
+
+ for (let i = 0; i < sub_count.value; i++) {
+ let sub_handle = new RNPLib.rnp_key_handle_t();
+ if (
+ RNPLib.rnp_key_get_subkey_at(
+ impKeySecTracker ? impKeySecTracker.getHandle() : impKeyPub,
+ i,
+ sub_handle.address()
+ )
+ ) {
+ throw new Error("rnp_key_get_subkey_at failed");
+ }
+
+ let subTracker = secretKeyTrackers.get(
+ this.getFingerprintFromHandle(sub_handle)
+ );
+ if (!subTracker) {
+ // There is no secret key material for this subkey available,
+ // that's why no tracker was created, we can skip it.
+ continue;
+ }
+ subTracker.unprotect();
+ await subTracker.setAutoPassphrase();
+ }
+ }
+
+ let exportFlags =
+ RNPLib.RNP_KEY_EXPORT_ARMORED | RNPLib.RNP_KEY_EXPORT_SUBKEYS;
+
+ if (pubkey) {
+ exportFlags |= RNPLib.RNP_KEY_EXPORT_PUBLIC;
+ }
+ if (seckey) {
+ exportFlags |= RNPLib.RNP_KEY_EXPORT_SECRET;
+ }
+
+ let output_to_memory = new RNPLib.rnp_output_t();
+ if (RNPLib.rnp_output_to_memory(output_to_memory.address(), 0)) {
+ throw new Error("rnp_output_to_memory failed");
+ }
+
+ if (
+ RNPLib.rnp_key_export(
+ impKeySecTracker ? impKeySecTracker.getHandle() : impKeyPub,
+ output_to_memory,
+ exportFlags
+ )
+ ) {
+ throw new Error("rnp_key_export failed");
+ }
+
+ if (impKeyPub) {
+ RNPLib.rnp_key_handle_destroy(impKeyPub);
+ impKeyPub = null;
+ }
+
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_output_memory_get_buf(
+ output_to_memory,
+ result_buf.address(),
+ result_len.address(),
+ false
+ )
+ ) {
+ throw new Error("rnp_output_memory_get_buf failed");
+ }
+
+ let input_from_memory = new RNPLib.rnp_input_t();
+
+ if (
+ RNPLib.rnp_input_from_memory(
+ input_from_memory.address(),
+ result_buf,
+ result_len,
+ false
+ )
+ ) {
+ throw new Error("rnp_input_from_memory failed");
+ }
+
+ let importFlags = 0;
+ if (pubkey) {
+ importFlags |= RNPLib.RNP_LOAD_SAVE_PUBLIC_KEYS;
+ }
+ if (seckey) {
+ importFlags |= RNPLib.RNP_LOAD_SAVE_SECRET_KEYS;
+ }
+ if (permissive) {
+ importFlags |= RNPLib.RNP_LOAD_SAVE_PERMISSIVE;
+ }
+
+ if (
+ RNPLib.rnp_import_keys(
+ RNPLib.ffi,
+ input_from_memory,
+ importFlags,
+ null
+ )
+ ) {
+ throw new Error("rnp_import_keys failed");
+ }
+
+ result.importedKeys.push("0x" + k.id);
+
+ RNPLib.rnp_input_destroy(input_from_memory);
+ RNPLib.rnp_output_destroy(output_to_memory);
+
+ // For acceptance "undecided", we don't store it, because that's
+ // the default if no value is stored.
+ let actionableAcceptances = ["rejected", "unverified", "verified"];
+
+ if (
+ pubkey &&
+ !k.secretAvailable &&
+ actionableAcceptances.includes(acceptance)
+ ) {
+ // For each imported public key and associated email address,
+ // update the acceptance to unverified, but only if it's only
+ // currently undecided. In other words, we keep the acceptance
+ // if it's rejected or verified.
+
+ let currentAcceptance =
+ await lazy.PgpSqliteDb2.getFingerprintAcceptance(null, k.fpr);
+
+ if (!currentAcceptance || currentAcceptance == "undecided") {
+ // Currently undecided, allowed to change.
+ let allEmails = [];
+
+ for (let uid of k.userIds) {
+ if (uid.type != "uid") {
+ continue;
+ }
+
+ let uidEmail = lazy.EnigmailFuncs.getEmailFromUserID(uid.userId);
+ if (uidEmail) {
+ allEmails.push(uidEmail);
+ }
+ }
+ await lazy.PgpSqliteDb2.updateAcceptance(
+ k.fpr,
+ allEmails,
+ acceptance
+ );
+ }
+ }
+ }
+
+ result.exitCode = 0;
+ await this.saveKeyRings();
+ }
+
+ for (let valTracker of secretKeyTrackers.values()) {
+ valTracker.release();
+ }
+
+ RNPLib.rnp_ffi_destroy(tempFFI);
+ return result;
+ },
+
+ async deleteKey(keyFingerprint, deleteSecret) {
+ let handle = new RNPLib.rnp_key_handle_t();
+ if (
+ RNPLib.rnp_locate_key(
+ RNPLib.ffi,
+ "fingerprint",
+ keyFingerprint,
+ handle.address()
+ )
+ ) {
+ throw new Error("rnp_locate_key failed");
+ }
+
+ let flags = RNPLib.RNP_KEY_REMOVE_PUBLIC | RNPLib.RNP_KEY_REMOVE_SUBKEYS;
+ if (deleteSecret) {
+ flags |= RNPLib.RNP_KEY_REMOVE_SECRET;
+ }
+
+ if (RNPLib.rnp_key_remove(handle, flags)) {
+ throw new Error("rnp_key_remove failed");
+ }
+
+ RNPLib.rnp_key_handle_destroy(handle);
+ await this.saveKeyRings();
+ },
+
+ async revokeKey(keyFingerprint) {
+ let tracker =
+ RnpPrivateKeyUnlockTracker.constructFromFingerprint(keyFingerprint);
+ if (!tracker.available()) {
+ return;
+ }
+ tracker.setAllowPromptingUserForPassword(true);
+ tracker.setAllowAutoUnlockWithCachedPasswords(true);
+ await tracker.unlock();
+ if (!tracker.isUnlocked()) {
+ return;
+ }
+
+ let flags = 0;
+ let revokeResult = RNPLib.rnp_key_revoke(
+ tracker.getHandle(),
+ flags,
+ null,
+ null,
+ null
+ );
+ tracker.release();
+ if (revokeResult) {
+ throw new Error(
+ `rnp_key_revoke failed for fingerprint=${keyFingerprint}`
+ );
+ }
+ await this.saveKeyRings();
+ },
+
+ _getKeyHandleByKeyIdOrFingerprint(ffi, id, findPrimary) {
+ if (!id.startsWith("0x")) {
+ throw new Error("unexpected identifier " + id);
+ } else {
+ // remove 0x
+ id = id.substring(2);
+ }
+
+ let type = null;
+ if (id.length == 16) {
+ type = "keyid";
+ } else if (id.length == 40 || id.length == 32) {
+ type = "fingerprint";
+ } else {
+ throw new Error("key/fingerprint identifier of unexpected length: " + id);
+ }
+
+ let key = new RNPLib.rnp_key_handle_t();
+ if (RNPLib.rnp_locate_key(ffi, type, id, key.address())) {
+ throw new Error("rnp_locate_key failed, " + type + ", " + id);
+ }
+
+ if (!key.isNull() && findPrimary) {
+ let is_subkey = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_is_sub(key, is_subkey.address())) {
+ throw new Error("rnp_key_is_sub failed");
+ }
+ if (is_subkey.value) {
+ let primaryKey = this.getPrimaryKeyHandleFromSub(ffi, key);
+ RNPLib.rnp_key_handle_destroy(key);
+ key = primaryKey;
+ }
+ }
+
+ if (!key.isNull() && this.isBadKey(key, null, ffi)) {
+ RNPLib.rnp_key_handle_destroy(key);
+ key = new RNPLib.rnp_key_handle_t();
+ }
+
+ return key;
+ },
+
+ getPrimaryKeyHandleByKeyIdOrFingerprint(ffi, id) {
+ return this._getKeyHandleByKeyIdOrFingerprint(ffi, id, true);
+ },
+
+ getKeyHandleByKeyIdOrFingerprint(ffi, id) {
+ return this._getKeyHandleByKeyIdOrFingerprint(ffi, id, false);
+ },
+
+ async getKeyHandleByIdentifier(ffi, id) {
+ let key = null;
+
+ if (id.startsWith("<")) {
+ //throw new Error("search by email address not yet implemented: " + id);
+ if (!id.endsWith(">")) {
+ throw new Error(
+ "if search identifier starts with < then it must end with > : " + id
+ );
+ }
+ key = await this.findKeyByEmail(id);
+ } else {
+ key = this.getKeyHandleByKeyIdOrFingerprint(ffi, id);
+ }
+ return key;
+ },
+
+ isKeyUsableFor(key, usage) {
+ let allowed = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_allows_usage(key, usage, allowed.address())) {
+ throw new Error("rnp_key_allows_usage failed");
+ }
+ if (!allowed.value) {
+ return false;
+ }
+
+ if (usage != str_sign) {
+ return true;
+ }
+
+ return (
+ RNPLib.getSecretAvailableFromHandle(key) &&
+ RNPLib.isSecretKeyMaterialAvailable(key)
+ );
+ },
+
+ getSuitableSubkey(primary, usage) {
+ let sub_count = new lazy.ctypes.size_t();
+ if (RNPLib.rnp_key_get_subkey_count(primary, sub_count.address())) {
+ throw new Error("rnp_key_get_subkey_count failed");
+ }
+
+ // For compatibility with GnuPG, when encrypting to a single subkey,
+ // encrypt to the most recently created subkey. (Bug 1665281)
+ let newest_created = null;
+ let newest_handle = null;
+
+ for (let i = 0; i < sub_count.value; i++) {
+ let sub_handle = new RNPLib.rnp_key_handle_t();
+ if (RNPLib.rnp_key_get_subkey_at(primary, i, sub_handle.address())) {
+ throw new Error("rnp_key_get_subkey_at failed");
+ }
+ let skip =
+ this.isBadKey(sub_handle, primary, null) ||
+ this.isKeyExpired(sub_handle);
+ if (!skip) {
+ let key_revoked = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_is_revoked(sub_handle, key_revoked.address())) {
+ throw new Error("rnp_key_is_revoked failed");
+ }
+ if (key_revoked.value) {
+ skip = true;
+ }
+ }
+ if (!skip) {
+ if (!this.isKeyUsableFor(sub_handle, usage)) {
+ skip = true;
+ }
+ }
+
+ if (!skip) {
+ let created = this.getKeyCreatedValueFromHandle(sub_handle);
+ if (!newest_handle || created > newest_created) {
+ if (newest_handle) {
+ RNPLib.rnp_key_handle_destroy(newest_handle);
+ }
+ newest_handle = sub_handle;
+ sub_handle = null;
+ newest_created = created;
+ }
+ }
+
+ if (sub_handle) {
+ RNPLib.rnp_key_handle_destroy(sub_handle);
+ }
+ }
+
+ return newest_handle;
+ },
+
+ /**
+ * Get a minimal Autocrypt-compatible public key, for the given key
+ * that exactly matches the given userId.
+ *
+ * @param {rnp_key_handle_t} key - RNP key handle.
+ * @param {string} uidString - The userID to include.
+ * @returns {string} The encoded key, or the empty string on failure.
+ */
+ getSuitableEncryptKeyAsAutocrypt(key, userId) {
+ // Prefer usable subkeys, because they are always newer
+ // (or same age) as primary key.
+
+ let use_sub = this.getSuitableSubkey(key, str_encrypt);
+ if (!use_sub && !this.isKeyUsableFor(key, str_encrypt)) {
+ return "";
+ }
+
+ let result = this.getAutocryptKeyB64ByHandle(key, use_sub, userId);
+
+ if (use_sub) {
+ RNPLib.rnp_key_handle_destroy(use_sub);
+ }
+ return result;
+ },
+
+ addSuitableEncryptKey(key, op) {
+ // Prefer usable subkeys, because they are always newer
+ // (or same age) as primary key.
+
+ let use_sub = this.getSuitableSubkey(key, str_encrypt);
+ if (!use_sub && !this.isKeyUsableFor(key, str_encrypt)) {
+ throw new Error("no suitable subkey found for " + str_encrypt);
+ }
+
+ if (
+ RNPLib.rnp_op_encrypt_add_recipient(op, use_sub != null ? use_sub : key)
+ ) {
+ throw new Error("rnp_op_encrypt_add_recipient sender failed");
+ }
+ if (use_sub) {
+ RNPLib.rnp_key_handle_destroy(use_sub);
+ }
+ },
+
+ addAliasKeys(aliasKeys, op) {
+ for (let ak of aliasKeys) {
+ let key = this.getKeyHandleByKeyIdOrFingerprint(RNPLib.ffi, "0x" + ak);
+ if (!key || key.isNull()) {
+ console.debug(
+ "addAliasKeys: cannot find key used by alias rule: " + ak
+ );
+ return false;
+ }
+ this.addSuitableEncryptKey(key, op);
+ RNPLib.rnp_key_handle_destroy(key);
+ }
+ return true;
+ },
+
+ /**
+ * Get a minimal Autocrypt-compatible public key, for the given email
+ * address.
+ *
+ * @param {string} email - Use a userID with this email address.
+ * @returns {string} The encoded key, or the empty string on failure.
+ */
+ async getRecipientAutocryptKeyForEmail(email) {
+ email = email.toLowerCase();
+
+ let key = await this.findKeyByEmail("<" + email + ">", true);
+ if (!key || key.isNull()) {
+ return "";
+ }
+
+ let keyInfo = {};
+ let ok = this.getKeyInfoFromHandle(
+ RNPLib.ffi,
+ key,
+ keyInfo,
+ false,
+ false,
+ false
+ );
+ if (!ok) {
+ throw new Error("getKeyInfoFromHandle failed");
+ }
+
+ let result = "";
+ let userId = keyInfo.userIds.find(
+ uid =>
+ uid.type == "uid" &&
+ lazy.EnigmailFuncs.getEmailFromUserID(uid.userId).toLowerCase() == email
+ );
+ if (userId) {
+ result = this.getSuitableEncryptKeyAsAutocrypt(key, userId.userId);
+ }
+ RNPLib.rnp_key_handle_destroy(key);
+ return result;
+ },
+
+ async addEncryptionKeyForEmail(email, op) {
+ let key = await this.findKeyByEmail(email, true);
+ if (!key || key.isNull()) {
+ return false;
+ }
+ this.addSuitableEncryptKey(key, op);
+ RNPLib.rnp_key_handle_destroy(key);
+ return true;
+ },
+
+ getEmailWithoutBrackets(email) {
+ if (email.startsWith("<") && email.endsWith(">")) {
+ return email.substring(1, email.length - 1);
+ }
+ return email;
+ },
+
+ async encryptAndOrSign(plaintext, args, resultStatus) {
+ let signedInner;
+
+ if (args.sign && args.senderKeyIsExternal) {
+ if (!lazy.GPGME.allDependenciesLoaded()) {
+ throw new Error(
+ "invalid configuration, request to use external GnuPG key, but GPGME isn't working"
+ );
+ }
+ if (args.sigTypeClear) {
+ throw new Error(
+ "unexpected signing request with external GnuPG key configuration"
+ );
+ }
+
+ if (args.encrypt) {
+ // If we are asked to encrypt and sign at the same time, it
+ // means we're asked to produce the combined OpenPGP encoding.
+ // We ask GPG to produce a regular signature, and will then
+ // combine it with the encryption produced by RNP.
+ let orgEncrypt = args.encrypt;
+ args.encrypt = false;
+ signedInner = await lazy.GPGME.sign(plaintext, args, resultStatus);
+ args.encrypt = orgEncrypt;
+ } else {
+ // We aren't asked to encrypt, but sign only. That means the
+ // caller needs the detatched signature, either for MIME
+ // mime encoding with separate signature part, or for the nested
+ // approach with separate signing and encryption layers.
+ return lazy.GPGME.signDetached(plaintext, args, resultStatus);
+ }
+ }
+
+ resultStatus.exitCode = -1;
+ resultStatus.statusFlags = 0;
+ resultStatus.statusMsg = "";
+ resultStatus.errorMsg = "";
+
+ let data_array;
+ if (args.sign && args.senderKeyIsExternal) {
+ data_array = lazy.ctypes.uint8_t.array()(signedInner);
+ } else {
+ let arr = plaintext.split("").map(e => e.charCodeAt());
+ data_array = lazy.ctypes.uint8_t.array()(arr);
+ }
+
+ let input = new RNPLib.rnp_input_t();
+ if (
+ RNPLib.rnp_input_from_memory(
+ input.address(),
+ data_array,
+ data_array.length,
+ false
+ )
+ ) {
+ throw new Error("rnp_input_from_memory failed");
+ }
+
+ let output = new RNPLib.rnp_output_t();
+ if (RNPLib.rnp_output_to_memory(output.address(), 0)) {
+ throw new Error("rnp_output_to_memory failed");
+ }
+
+ let op;
+ if (args.encrypt) {
+ op = new RNPLib.rnp_op_encrypt_t();
+ if (
+ RNPLib.rnp_op_encrypt_create(op.address(), RNPLib.ffi, input, output)
+ ) {
+ throw new Error("rnp_op_encrypt_create failed");
+ }
+ } else if (args.sign && !args.senderKeyIsExternal) {
+ op = new RNPLib.rnp_op_sign_t();
+ if (args.sigTypeClear) {
+ if (
+ RNPLib.rnp_op_sign_cleartext_create(
+ op.address(),
+ RNPLib.ffi,
+ input,
+ output
+ )
+ ) {
+ throw new Error("rnp_op_sign_cleartext_create failed");
+ }
+ } else if (args.sigTypeDetached) {
+ if (
+ RNPLib.rnp_op_sign_detached_create(
+ op.address(),
+ RNPLib.ffi,
+ input,
+ output
+ )
+ ) {
+ throw new Error("rnp_op_sign_detached_create failed");
+ }
+ } else {
+ throw new Error(
+ "not yet implemented scenario: signing, neither clear nor encrypt, without encryption"
+ );
+ }
+ } else {
+ throw new Error("invalid parameters, neither encrypt nor sign");
+ }
+
+ let senderKeyTracker = null;
+ let subKeyTracker = null;
+
+ try {
+ if ((args.sign && !args.senderKeyIsExternal) || args.encryptToSender) {
+ {
+ // Use a temporary scope to ensure the senderKey variable
+ // cannot be accessed later on.
+ let senderKey = await this.getKeyHandleByIdentifier(
+ RNPLib.ffi,
+ args.sender
+ );
+ if (!senderKey || senderKey.isNull()) {
+ return null;
+ }
+
+ senderKeyTracker = new RnpPrivateKeyUnlockTracker(senderKey);
+ senderKeyTracker.setAllowPromptingUserForPassword(true);
+ senderKeyTracker.setAllowAutoUnlockWithCachedPasswords(true);
+ }
+
+ // Manually configured external key overrides the check for
+ // a valid personal key.
+ if (!args.senderKeyIsExternal) {
+ if (!senderKeyTracker.isSecret()) {
+ throw new Error(
+ `configured sender key ${args.sender} isn't available`
+ );
+ }
+ if (
+ !(await lazy.PgpSqliteDb2.isAcceptedAsPersonalKey(
+ senderKeyTracker.getFingerprint()
+ ))
+ ) {
+ throw new Error(
+ `configured sender key ${args.sender} isn't accepted as a personal key`
+ );
+ }
+ }
+
+ if (args.encryptToSender) {
+ this.addSuitableEncryptKey(senderKeyTracker.getHandle(), op);
+ }
+
+ if (args.sign && !args.senderKeyIsExternal) {
+ let signingKeyTrackerReference = senderKeyTracker;
+
+ // Prefer usable subkeys, because they are always newer
+ // (or same age) as primary key.
+ let usableSubKeyHandle = this.getSuitableSubkey(
+ senderKeyTracker.getHandle(),
+ str_sign
+ );
+ if (
+ !usableSubKeyHandle &&
+ !this.isKeyUsableFor(senderKeyTracker.getHandle(), str_sign)
+ ) {
+ throw new Error("no suitable (sub)key found for " + str_sign);
+ }
+ if (usableSubKeyHandle) {
+ subKeyTracker = new RnpPrivateKeyUnlockTracker(usableSubKeyHandle);
+ subKeyTracker.setAllowPromptingUserForPassword(true);
+ subKeyTracker.setAllowAutoUnlockWithCachedPasswords(true);
+ if (subKeyTracker.available()) {
+ signingKeyTrackerReference = subKeyTracker;
+ }
+ }
+
+ await signingKeyTrackerReference.unlock();
+
+ if (args.encrypt) {
+ if (
+ RNPLib.rnp_op_encrypt_add_signature(
+ op,
+ signingKeyTrackerReference.getHandle(),
+ null
+ )
+ ) {
+ throw new Error("rnp_op_encrypt_add_signature failed");
+ }
+ } else if (
+ RNPLib.rnp_op_sign_add_signature(
+ op,
+ signingKeyTrackerReference.getHandle(),
+ null
+ )
+ ) {
+ throw new Error("rnp_op_sign_add_signature failed");
+ }
+ // This was just a reference, no ownership.
+ signingKeyTrackerReference = null;
+ }
+ }
+
+ if (args.encrypt) {
+ // If we have an alias definition, it will be used, and the usual
+ // lookup by email address will be skipped. Earlier code should
+ // have already checked that alias keys are available and usable
+ // for encryption, so we fail if a problem is found.
+
+ for (let rcpList of [args.to, args.bcc]) {
+ for (let rcpEmail of rcpList) {
+ rcpEmail = rcpEmail.toLowerCase();
+ let aliasKeys = args.aliasKeys.get(
+ this.getEmailWithoutBrackets(rcpEmail)
+ );
+ if (aliasKeys) {
+ if (!this.addAliasKeys(aliasKeys, op)) {
+ resultStatus.statusFlags |=
+ lazy.EnigmailConstants.INVALID_RECIPIENT;
+ return null;
+ }
+ } else if (!(await this.addEncryptionKeyForEmail(rcpEmail, op))) {
+ resultStatus.statusFlags |=
+ lazy.EnigmailConstants.INVALID_RECIPIENT;
+ return null;
+ }
+ }
+ }
+
+ if (AppConstants.MOZ_UPDATE_CHANNEL != "release") {
+ let debugKey = Services.prefs.getStringPref(
+ "mail.openpgp.debug.extra_encryption_key"
+ );
+ if (debugKey) {
+ let handle = this.getKeyHandleByKeyIdOrFingerprint(
+ RNPLib.ffi,
+ debugKey
+ );
+ if (!handle.isNull()) {
+ console.debug("encrypting to debug key " + debugKey);
+ this.addSuitableEncryptKey(handle, op);
+ RNPLib.rnp_key_handle_destroy(handle);
+ }
+ }
+ }
+
+ // Don't use AEAD as long as RNP uses v5 packets which aren't
+ // widely compatible with other clients.
+ if (RNPLib.rnp_op_encrypt_set_aead(op, "NONE")) {
+ throw new Error("rnp_op_encrypt_set_aead failed");
+ }
+
+ if (RNPLib.rnp_op_encrypt_set_cipher(op, "AES256")) {
+ throw new Error("rnp_op_encrypt_set_cipher failed");
+ }
+
+ // TODO, map args.signatureHash string to RNP and call
+ // rnp_op_encrypt_set_hash
+ if (RNPLib.rnp_op_encrypt_set_hash(op, "SHA256")) {
+ throw new Error("rnp_op_encrypt_set_hash failed");
+ }
+
+ if (RNPLib.rnp_op_encrypt_set_armor(op, args.armor)) {
+ throw new Error("rnp_op_encrypt_set_armor failed");
+ }
+
+ if (args.sign && args.senderKeyIsExternal) {
+ if (RNPLib.rnp_op_encrypt_set_flags(op, RNPLib.RNP_ENCRYPT_NOWRAP)) {
+ throw new Error("rnp_op_encrypt_set_flags failed");
+ }
+ }
+
+ let rv = RNPLib.rnp_op_encrypt_execute(op);
+ if (rv) {
+ throw new Error("rnp_op_encrypt_execute failed: " + rv);
+ }
+ RNPLib.rnp_op_encrypt_destroy(op);
+ } else if (args.sign && !args.senderKeyIsExternal) {
+ if (RNPLib.rnp_op_sign_set_hash(op, "SHA256")) {
+ throw new Error("rnp_op_sign_set_hash failed");
+ }
+ // TODO, map args.signatureHash string to RNP and call
+ // rnp_op_encrypt_set_hash
+
+ if (RNPLib.rnp_op_sign_set_armor(op, args.armor)) {
+ throw new Error("rnp_op_sign_set_armor failed");
+ }
+
+ if (RNPLib.rnp_op_sign_execute(op)) {
+ throw new Error("rnp_op_sign_execute failed");
+ }
+ RNPLib.rnp_op_sign_destroy(op);
+ }
+ } finally {
+ if (subKeyTracker) {
+ subKeyTracker.release();
+ }
+ if (senderKeyTracker) {
+ senderKeyTracker.release();
+ }
+ }
+
+ RNPLib.rnp_input_destroy(input);
+
+ let result = null;
+
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ if (
+ !RNPLib.rnp_output_memory_get_buf(
+ output,
+ result_buf.address(),
+ result_len.address(),
+ false
+ )
+ ) {
+ let char_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(result_len.value).ptr
+ ).contents;
+
+ result = char_array.readString();
+ }
+
+ RNPLib.rnp_output_destroy(output);
+
+ resultStatus.exitCode = 0;
+
+ if (args.encrypt) {
+ resultStatus.statusFlags |= lazy.EnigmailConstants.END_ENCRYPTION;
+ }
+
+ if (args.sign) {
+ resultStatus.statusFlags |= lazy.EnigmailConstants.SIG_CREATED;
+ }
+
+ return result;
+ },
+
+ /**
+ * @param {number} expiryTime - Time to check, in seconds from the epoch.
+ * @returns {boolean} - true if the given time is after now.
+ */
+ isExpiredTime(expiryTime) {
+ if (!expiryTime) {
+ return false;
+ }
+ let nowSeconds = Math.floor(Date.now() / 1000);
+ return nowSeconds > expiryTime;
+ },
+
+ isKeyExpired(handle) {
+ let expiration = new lazy.ctypes.uint32_t();
+ if (RNPLib.rnp_key_get_expiration(handle, expiration.address())) {
+ throw new Error("rnp_key_get_expiration failed");
+ }
+ if (!expiration.value) {
+ return false;
+ }
+
+ let created = this.getKeyCreatedValueFromHandle(handle);
+ let expirationSeconds = created + expiration.value;
+ return this.isExpiredTime(expirationSeconds);
+ },
+
+ async findKeyByEmail(id, onlyIfAcceptableAsRecipientKey = false) {
+ if (!id.startsWith("<") || !id.endsWith(">") || id.includes(" ")) {
+ throw new Error(`Invalid argument; id=${id}`);
+ }
+
+ let emailWithoutBrackets = id.substring(1, id.length - 1);
+
+ let iter = new RNPLib.rnp_identifier_iterator_t();
+ let grip = new lazy.ctypes.char.ptr();
+
+ if (
+ RNPLib.rnp_identifier_iterator_create(RNPLib.ffi, iter.address(), "grip")
+ ) {
+ throw new Error("rnp_identifier_iterator_create failed");
+ }
+
+ let foundHandle = null;
+ let tentativeUnverifiedHandle = null;
+
+ while (
+ !foundHandle &&
+ !RNPLib.rnp_identifier_iterator_next(iter, grip.address())
+ ) {
+ if (grip.isNull()) {
+ break;
+ }
+
+ let have_handle = false;
+ let handle = new RNPLib.rnp_key_handle_t();
+
+ try {
+ let is_subkey = new lazy.ctypes.bool();
+ let uid_count = new lazy.ctypes.size_t();
+
+ if (RNPLib.rnp_locate_key(RNPLib.ffi, "grip", grip, handle.address())) {
+ throw new Error("rnp_locate_key failed");
+ }
+ have_handle = true;
+ if (RNPLib.rnp_key_is_sub(handle, is_subkey.address())) {
+ throw new Error("rnp_key_is_sub failed");
+ }
+ if (is_subkey.value) {
+ continue;
+ }
+ if (this.isBadKey(handle, null, RNPLib.ffi)) {
+ continue;
+ }
+ let key_revoked = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_is_revoked(handle, key_revoked.address())) {
+ throw new Error("rnp_key_is_revoked failed");
+ }
+
+ if (key_revoked.value) {
+ continue;
+ }
+
+ if (this.isKeyExpired(handle)) {
+ continue;
+ }
+
+ if (RNPLib.rnp_key_get_uid_count(handle, uid_count.address())) {
+ throw new Error("rnp_key_get_uid_count failed");
+ }
+
+ let foundUid = false;
+ for (let i = 0; i < uid_count.value && !foundUid; i++) {
+ let uid_handle = new RNPLib.rnp_uid_handle_t();
+
+ if (
+ RNPLib.rnp_key_get_uid_handle_at(handle, i, uid_handle.address())
+ ) {
+ throw new Error("rnp_key_get_uid_handle_at failed");
+ }
+
+ if (!this.isBadUid(uid_handle) && !this.isRevokedUid(uid_handle)) {
+ let uid_str = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_uid_at(handle, i, uid_str.address())) {
+ throw new Error("rnp_key_get_uid_at failed");
+ }
+
+ let userId = uid_str.readStringReplaceMalformed();
+ RNPLib.rnp_buffer_destroy(uid_str);
+
+ if (
+ lazy.EnigmailFuncs.getEmailFromUserID(userId).toLowerCase() ==
+ emailWithoutBrackets
+ ) {
+ foundUid = true;
+
+ if (onlyIfAcceptableAsRecipientKey) {
+ // a key is acceptable, either:
+ // - without secret key, it's accepted verified or unverified
+ // - with secret key, must be marked as personal
+
+ let have_secret = new lazy.ctypes.bool();
+ if (RNPLib.rnp_key_have_secret(handle, have_secret.address())) {
+ throw new Error("rnp_key_have_secret failed");
+ }
+
+ let fingerprint = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_fprint(handle, fingerprint.address())) {
+ throw new Error("rnp_key_get_fprint failed");
+ }
+ let fpr = fingerprint.readString();
+ RNPLib.rnp_buffer_destroy(fingerprint);
+
+ if (have_secret.value) {
+ let isAccepted =
+ await lazy.PgpSqliteDb2.isAcceptedAsPersonalKey(fpr);
+ if (isAccepted) {
+ foundHandle = handle;
+ have_handle = false;
+ if (tentativeUnverifiedHandle) {
+ RNPLib.rnp_key_handle_destroy(tentativeUnverifiedHandle);
+ tentativeUnverifiedHandle = null;
+ }
+ }
+ } else {
+ let acceptanceResult = {};
+ try {
+ await lazy.PgpSqliteDb2.getAcceptance(
+ fpr,
+ emailWithoutBrackets,
+ acceptanceResult
+ );
+ } catch (ex) {
+ console.debug("getAcceptance failed: " + ex);
+ }
+
+ if (!acceptanceResult.emailDecided) {
+ continue;
+ }
+ if (acceptanceResult.fingerprintAcceptance == "unverified") {
+ /* keep searching for a better, verified key */
+ if (!tentativeUnverifiedHandle) {
+ tentativeUnverifiedHandle = handle;
+ have_handle = false;
+ }
+ } else if (
+ acceptanceResult.fingerprintAcceptance == "verified"
+ ) {
+ foundHandle = handle;
+ have_handle = false;
+ if (tentativeUnverifiedHandle) {
+ RNPLib.rnp_key_handle_destroy(tentativeUnverifiedHandle);
+ tentativeUnverifiedHandle = null;
+ }
+ }
+ }
+ } else {
+ foundHandle = handle;
+ have_handle = false;
+ }
+ }
+ }
+ RNPLib.rnp_uid_handle_destroy(uid_handle);
+ }
+ } catch (ex) {
+ console.log(ex);
+ } finally {
+ if (have_handle) {
+ RNPLib.rnp_key_handle_destroy(handle);
+ }
+ }
+ }
+
+ if (!foundHandle && tentativeUnverifiedHandle) {
+ foundHandle = tentativeUnverifiedHandle;
+ tentativeUnverifiedHandle = null;
+ }
+
+ RNPLib.rnp_identifier_iterator_destroy(iter);
+ return foundHandle;
+ },
+
+ async getPublicKey(id, store = RNPLib.ffi) {
+ let result = "";
+ let key = await this.getKeyHandleByIdentifier(store, id);
+
+ if (key.isNull()) {
+ return result;
+ }
+
+ let flags =
+ RNPLib.RNP_KEY_EXPORT_ARMORED |
+ RNPLib.RNP_KEY_EXPORT_PUBLIC |
+ RNPLib.RNP_KEY_EXPORT_SUBKEYS;
+
+ let output_to_memory = new RNPLib.rnp_output_t();
+ RNPLib.rnp_output_to_memory(output_to_memory.address(), 0);
+
+ if (RNPLib.rnp_key_export(key, output_to_memory, flags)) {
+ throw new Error("rnp_key_export failed");
+ }
+
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ let exitCode = RNPLib.rnp_output_memory_get_buf(
+ output_to_memory,
+ result_buf.address(),
+ result_len.address(),
+ false
+ );
+
+ if (!exitCode) {
+ let char_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(result_len.value).ptr
+ ).contents;
+
+ result = char_array.readString();
+ }
+
+ RNPLib.rnp_output_destroy(output_to_memory);
+ RNPLib.rnp_key_handle_destroy(key);
+ return result;
+ },
+
+ /**
+ * Exports a public key, strips all signatures added by others,
+ * and optionally also strips user IDs. Self-signatures are kept.
+ * The given key handle will not be modified. The input key will be
+ * copied to a temporary area, only the temporary copy will be
+ * modified. The result key will be streamed to the given output.
+ *
+ * @param {rnp_key_handle_t} expKey - RNP key handle
+ * @param {boolean} keepUserIDs - if true keep users IDs
+ * @param {rnp_output_t} out_binary - output stream handle
+ *
+ */
+ export_pubkey_strip_sigs_uids(expKey, keepUserIDs, out_binary) {
+ let expKeyId = this.getKeyIDFromHandle(expKey);
+
+ let tempFFI = RNPLib.prepare_ffi();
+ if (!tempFFI) {
+ throw new Error("Couldn't initialize librnp.");
+ }
+
+ let exportFlags =
+ RNPLib.RNP_KEY_EXPORT_SUBKEYS | RNPLib.RNP_KEY_EXPORT_PUBLIC;
+ let importFlags = RNPLib.RNP_LOAD_SAVE_PUBLIC_KEYS;
+
+ let output_to_memory = new RNPLib.rnp_output_t();
+ if (RNPLib.rnp_output_to_memory(output_to_memory.address(), 0)) {
+ throw new Error("rnp_output_to_memory failed");
+ }
+
+ if (RNPLib.rnp_key_export(expKey, output_to_memory, exportFlags)) {
+ throw new Error("rnp_key_export failed");
+ }
+
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_output_memory_get_buf(
+ output_to_memory,
+ result_buf.address(),
+ result_len.address(),
+ false
+ )
+ ) {
+ throw new Error("rnp_output_memory_get_buf failed");
+ }
+
+ let input_from_memory = new RNPLib.rnp_input_t();
+
+ if (
+ RNPLib.rnp_input_from_memory(
+ input_from_memory.address(),
+ result_buf,
+ result_len,
+ false
+ )
+ ) {
+ throw new Error("rnp_input_from_memory failed");
+ }
+
+ if (RNPLib.rnp_import_keys(tempFFI, input_from_memory, importFlags, null)) {
+ throw new Error("rnp_import_keys failed");
+ }
+
+ let tempKey = this.getKeyHandleByKeyIdOrFingerprint(
+ tempFFI,
+ "0x" + expKeyId
+ );
+
+ // Strip
+
+ if (!keepUserIDs) {
+ let uid_count = new lazy.ctypes.size_t();
+ if (RNPLib.rnp_key_get_uid_count(tempKey, uid_count.address())) {
+ throw new Error("rnp_key_get_uid_count failed");
+ }
+ for (let i = uid_count.value; i > 0; i--) {
+ let uid_handle = new RNPLib.rnp_uid_handle_t();
+ if (
+ RNPLib.rnp_key_get_uid_handle_at(tempKey, i - 1, uid_handle.address())
+ ) {
+ throw new Error("rnp_key_get_uid_handle_at failed");
+ }
+ if (RNPLib.rnp_uid_remove(tempKey, uid_handle)) {
+ throw new Error("rnp_uid_remove failed");
+ }
+ RNPLib.rnp_uid_handle_destroy(uid_handle);
+ }
+ }
+
+ if (
+ RNPLib.rnp_key_remove_signatures(
+ tempKey,
+ RNPLib.RNP_KEY_SIGNATURE_NON_SELF_SIG,
+ null,
+ null
+ )
+ ) {
+ throw new Error("rnp_key_remove_signatures failed");
+ }
+
+ if (RNPLib.rnp_key_export(tempKey, out_binary, exportFlags)) {
+ throw new Error("rnp_key_export failed");
+ }
+ RNPLib.rnp_key_handle_destroy(tempKey);
+
+ RNPLib.rnp_input_destroy(input_from_memory);
+ RNPLib.rnp_output_destroy(output_to_memory);
+ RNPLib.rnp_ffi_destroy(tempFFI);
+ },
+
+ /**
+ * Export one or multiple public keys.
+ *
+ * @param {string[]} idArrayFull - an array of key IDs or fingerprints
+ * that should be exported as full keys including all attributes.
+ * @param {string[]} idArrayReduced - an array of key IDs or
+ * fingerprints that should be exported with all self-signatures,
+ * but without signatures from others.
+ * @param {string[]} idArrayMinimal - an array of key IDs or
+ * fingerprints that should be exported as minimized keys.
+ * @returns {string} - An ascii armored key block containing all
+ * requested (available) keys.
+ */
+ getMultiplePublicKeys(idArrayFull, idArrayReduced, idArrayMinimal) {
+ let out_final = new RNPLib.rnp_output_t();
+ RNPLib.rnp_output_to_memory(out_final.address(), 0);
+
+ let out_binary = new RNPLib.rnp_output_t();
+ let rv;
+ if (
+ (rv = RNPLib.rnp_output_to_armor(
+ out_final,
+ out_binary.address(),
+ "public key"
+ ))
+ ) {
+ throw new Error("rnp_output_to_armor failed:" + rv);
+ }
+
+ if ((rv = RNPLib.rnp_output_armor_set_line_length(out_binary, 64))) {
+ throw new Error("rnp_output_armor_set_line_length failed:" + rv);
+ }
+
+ let flags = RNPLib.RNP_KEY_EXPORT_PUBLIC | RNPLib.RNP_KEY_EXPORT_SUBKEYS;
+
+ if (idArrayFull) {
+ for (let id of idArrayFull) {
+ let key = this.getKeyHandleByKeyIdOrFingerprint(RNPLib.ffi, id);
+ if (key.isNull()) {
+ continue;
+ }
+
+ if (RNPLib.rnp_key_export(key, out_binary, flags)) {
+ throw new Error("rnp_key_export failed");
+ }
+
+ RNPLib.rnp_key_handle_destroy(key);
+ }
+ }
+
+ if (idArrayReduced) {
+ for (let id of idArrayReduced) {
+ let key = this.getPrimaryKeyHandleByKeyIdOrFingerprint(RNPLib.ffi, id);
+ if (key.isNull()) {
+ continue;
+ }
+
+ this.export_pubkey_strip_sigs_uids(key, true, out_binary);
+
+ RNPLib.rnp_key_handle_destroy(key);
+ }
+ }
+
+ if (idArrayMinimal) {
+ for (let id of idArrayMinimal) {
+ let key = this.getPrimaryKeyHandleByKeyIdOrFingerprint(RNPLib.ffi, id);
+ if (key.isNull()) {
+ continue;
+ }
+
+ this.export_pubkey_strip_sigs_uids(key, false, out_binary);
+
+ RNPLib.rnp_key_handle_destroy(key);
+ }
+ }
+
+ if ((rv = RNPLib.rnp_output_finish(out_binary))) {
+ throw new Error("rnp_output_finish failed: " + rv);
+ }
+
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ let exitCode = RNPLib.rnp_output_memory_get_buf(
+ out_final,
+ result_buf.address(),
+ result_len.address(),
+ false
+ );
+
+ let result = "";
+ if (!exitCode) {
+ let char_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(result_len.value).ptr
+ ).contents;
+ result = char_array.readString();
+ }
+
+ RNPLib.rnp_output_destroy(out_binary);
+ RNPLib.rnp_output_destroy(out_final);
+
+ return result;
+ },
+
+ /**
+ * The RNP library may store keys in a format that isn't compatible
+ * with GnuPG, see bug 1713621 for an example where this happened.
+ *
+ * This function modifies the input key to make it compatible.
+ *
+ * The caller must ensure that the key is unprotected when calling
+ * this function, and must apply the desired protection afterwards.
+ */
+ ensureECCSubkeyIsGnuPGCompatible(tempKey) {
+ let algo = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_alg(tempKey, algo.address())) {
+ throw new Error("rnp_key_get_alg failed");
+ }
+ let algoStr = algo.readString();
+ RNPLib.rnp_buffer_destroy(algo);
+
+ if (algoStr.toLowerCase() != "ecdh") {
+ return;
+ }
+
+ let curve = new lazy.ctypes.char.ptr();
+ if (RNPLib.rnp_key_get_curve(tempKey, curve.address())) {
+ throw new Error("rnp_key_get_curve failed");
+ }
+ let curveStr = curve.readString();
+ RNPLib.rnp_buffer_destroy(curve);
+
+ if (curveStr.toLowerCase() != "curve25519") {
+ return;
+ }
+
+ let tweak_status = new lazy.ctypes.bool();
+ let rc = RNPLib.rnp_key_25519_bits_tweaked(tempKey, tweak_status.address());
+ if (rc) {
+ throw new Error("rnp_key_25519_bits_tweaked failed: " + rc);
+ }
+
+ // If it's not tweaked yet, then tweak to make it compatible.
+ if (!tweak_status.value) {
+ rc = RNPLib.rnp_key_25519_bits_tweak(tempKey);
+ if (rc) {
+ throw new Error("rnp_key_25519_bits_tweak failed: " + rc);
+ }
+ }
+ },
+
+ async backupSecretKeys(fprs, backupPassword) {
+ if (!fprs.length) {
+ throw new Error("invalid fprs parameter");
+ }
+
+ /*
+ * Strategy:
+ * - copy keys to a temporary space, in-memory only (ffi)
+ * - if we failed to decrypt the secret keys, return null
+ * - change the password of all secret keys in the temporary space
+ * - export from the temporary space
+ */
+
+ let out_final = new RNPLib.rnp_output_t();
+ RNPLib.rnp_output_to_memory(out_final.address(), 0);
+
+ let out_binary = new RNPLib.rnp_output_t();
+ let rv;
+ if (
+ (rv = RNPLib.rnp_output_to_armor(
+ out_final,
+ out_binary.address(),
+ "secret key"
+ ))
+ ) {
+ throw new Error("rnp_output_to_armor failed:" + rv);
+ }
+
+ let tempFFI = RNPLib.prepare_ffi();
+ if (!tempFFI) {
+ throw new Error("Couldn't initialize librnp.");
+ }
+
+ let exportFlags =
+ RNPLib.RNP_KEY_EXPORT_SUBKEYS | RNPLib.RNP_KEY_EXPORT_SECRET;
+ let importFlags =
+ RNPLib.RNP_LOAD_SAVE_PUBLIC_KEYS | RNPLib.RNP_LOAD_SAVE_SECRET_KEYS;
+
+ let unlockFailed = false;
+ let pwCache = {
+ passwords: [],
+ };
+
+ for (let fpr of fprs) {
+ let fprStr = fpr;
+ let expKey = await this.getKeyHandleByIdentifier(
+ RNPLib.ffi,
+ "0x" + fprStr
+ );
+
+ let output_to_memory = new RNPLib.rnp_output_t();
+ if (RNPLib.rnp_output_to_memory(output_to_memory.address(), 0)) {
+ throw new Error("rnp_output_to_memory failed");
+ }
+
+ if (RNPLib.rnp_key_export(expKey, output_to_memory, exportFlags)) {
+ throw new Error("rnp_key_export failed");
+ }
+ RNPLib.rnp_key_handle_destroy(expKey);
+ expKey = null;
+
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_output_memory_get_buf(
+ output_to_memory,
+ result_buf.address(),
+ result_len.address(),
+ false
+ )
+ ) {
+ throw new Error("rnp_output_memory_get_buf failed");
+ }
+
+ let input_from_memory = new RNPLib.rnp_input_t();
+ if (
+ RNPLib.rnp_input_from_memory(
+ input_from_memory.address(),
+ result_buf,
+ result_len,
+ false
+ )
+ ) {
+ throw new Error("rnp_input_from_memory failed");
+ }
+
+ if (
+ RNPLib.rnp_import_keys(tempFFI, input_from_memory, importFlags, null)
+ ) {
+ throw new Error("rnp_import_keys failed");
+ }
+
+ RNPLib.rnp_input_destroy(input_from_memory);
+ RNPLib.rnp_output_destroy(output_to_memory);
+ input_from_memory = null;
+ output_to_memory = null;
+ result_buf = null;
+
+ let tracker = RnpPrivateKeyUnlockTracker.constructFromFingerprint(
+ fprStr,
+ tempFFI
+ );
+ if (!tracker.available()) {
+ tracker.release();
+ continue;
+ }
+
+ tracker.setAllowPromptingUserForPassword(true);
+ tracker.setAllowAutoUnlockWithCachedPasswords(true);
+ tracker.setPasswordCache(pwCache);
+ tracker.setRememberUnlockPassword(true);
+
+ await tracker.unlock();
+ if (!tracker.isUnlocked()) {
+ unlockFailed = true;
+ tracker.release();
+ break;
+ }
+
+ tracker.unprotect();
+ tracker.setPassphrase(backupPassword);
+
+ let sub_count = new lazy.ctypes.size_t();
+ if (
+ RNPLib.rnp_key_get_subkey_count(
+ tracker.getHandle(),
+ sub_count.address()
+ )
+ ) {
+ throw new Error("rnp_key_get_subkey_count failed");
+ }
+ for (let i = 0; i < sub_count.value; i++) {
+ let sub_handle = new RNPLib.rnp_key_handle_t();
+ if (
+ RNPLib.rnp_key_get_subkey_at(
+ tracker.getHandle(),
+ i,
+ sub_handle.address()
+ )
+ ) {
+ throw new Error("rnp_key_get_subkey_at failed");
+ }
+
+ let subTracker = new RnpPrivateKeyUnlockTracker(sub_handle);
+ if (subTracker.available()) {
+ subTracker.setAllowPromptingUserForPassword(true);
+ subTracker.setAllowAutoUnlockWithCachedPasswords(true);
+ subTracker.setPasswordCache(pwCache);
+ subTracker.setRememberUnlockPassword(true);
+
+ await subTracker.unlock();
+ if (!subTracker.isUnlocked()) {
+ unlockFailed = true;
+ } else {
+ subTracker.unprotect();
+ this.ensureECCSubkeyIsGnuPGCompatible(subTracker.getHandle());
+ subTracker.setPassphrase(backupPassword);
+ }
+ }
+ subTracker.release();
+ if (unlockFailed) {
+ break;
+ }
+ }
+
+ if (
+ !unlockFailed &&
+ RNPLib.rnp_key_export(tracker.getHandle(), out_binary, exportFlags)
+ ) {
+ throw new Error("rnp_key_export failed");
+ }
+
+ tracker.release();
+ if (unlockFailed) {
+ break;
+ }
+ }
+ RNPLib.rnp_ffi_destroy(tempFFI);
+
+ let result = "";
+ if (!unlockFailed) {
+ if ((rv = RNPLib.rnp_output_finish(out_binary))) {
+ throw new Error("rnp_output_finish failed: " + rv);
+ }
+
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ let exitCode = RNPLib.rnp_output_memory_get_buf(
+ out_final,
+ result_buf.address(),
+ result_len.address(),
+ false
+ );
+
+ if (!exitCode) {
+ let char_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(result_len.value).ptr
+ ).contents;
+ result = char_array.readString();
+ }
+ }
+
+ RNPLib.rnp_output_destroy(out_binary);
+ RNPLib.rnp_output_destroy(out_final);
+
+ return result;
+ },
+
+ async unlockAndGetNewRevocation(id, pass) {
+ let result = "";
+ let key = await this.getKeyHandleByIdentifier(RNPLib.ffi, id);
+
+ if (key.isNull()) {
+ return result;
+ }
+
+ let tracker = new RnpPrivateKeyUnlockTracker(key);
+ tracker.setAllowPromptingUserForPassword(false);
+ tracker.setAllowAutoUnlockWithCachedPasswords(false);
+ tracker.unlockWithPassword(pass);
+ if (!tracker.isUnlocked()) {
+ throw new Error(`Couldn't unlock key ${key.fpr}`);
+ }
+
+ let out_final = new RNPLib.rnp_output_t();
+ RNPLib.rnp_output_to_memory(out_final.address(), 0);
+
+ let out_binary = new RNPLib.rnp_output_t();
+ let rv;
+ if (
+ (rv = RNPLib.rnp_output_to_armor(
+ out_final,
+ out_binary.address(),
+ "public key"
+ ))
+ ) {
+ throw new Error("rnp_output_to_armor failed:" + rv);
+ }
+
+ if (
+ (rv = RNPLib.rnp_key_export_revocation(
+ key,
+ out_binary,
+ 0,
+ null,
+ null,
+ null
+ ))
+ ) {
+ throw new Error("rnp_key_export_revocation failed: " + rv);
+ }
+
+ if ((rv = RNPLib.rnp_output_finish(out_binary))) {
+ throw new Error("rnp_output_finish failed: " + rv);
+ }
+
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ let exitCode = RNPLib.rnp_output_memory_get_buf(
+ out_final,
+ result_buf.address(),
+ result_len.address(),
+ false
+ );
+
+ if (!exitCode) {
+ let char_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(result_len.value).ptr
+ ).contents;
+ result = char_array.readString();
+ }
+
+ RNPLib.rnp_output_destroy(out_binary);
+ RNPLib.rnp_output_destroy(out_final);
+ tracker.release();
+ return result;
+ },
+
+ enArmorString(input, type) {
+ let arr = input.split("").map(e => e.charCodeAt());
+ let input_array = lazy.ctypes.uint8_t.array()(arr);
+
+ return this.enArmorCData(input_array, input_array.length, type);
+ },
+
+ enArmorCDataMessage(buf, len) {
+ return this.enArmorCData(buf, len, "message");
+ },
+
+ enArmorCData(buf, len, type) {
+ let input_array = lazy.ctypes.cast(buf, lazy.ctypes.uint8_t.array(len));
+
+ let input_from_memory = new RNPLib.rnp_input_t();
+ RNPLib.rnp_input_from_memory(
+ input_from_memory.address(),
+ input_array,
+ len,
+ false
+ );
+
+ let max_out = len * 2 + 150; // extra bytes for head/tail/hash lines
+
+ let output_to_memory = new RNPLib.rnp_output_t();
+ RNPLib.rnp_output_to_memory(output_to_memory.address(), max_out);
+
+ if (RNPLib.rnp_enarmor(input_from_memory, output_to_memory, type)) {
+ throw new Error("rnp_enarmor failed");
+ }
+
+ let result = "";
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ if (
+ !RNPLib.rnp_output_memory_get_buf(
+ output_to_memory,
+ result_buf.address(),
+ result_len.address(),
+ false
+ )
+ ) {
+ let char_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.char.array(result_len.value).ptr
+ ).contents;
+
+ result = char_array.readString();
+ }
+
+ RNPLib.rnp_input_destroy(input_from_memory);
+ RNPLib.rnp_output_destroy(output_to_memory);
+
+ return result;
+ },
+
+ // Will change the expiration date of all given keys to newExpiry.
+ // fingerprintArray is an array, containing fingerprints, both
+ // primary key fingerprints and subkey fingerprints are allowed.
+ // The function assumes that all involved keys have already been
+ // unlocked. We shouldn't rely on password callbacks for unlocking,
+ // as it would be confusing if only some keys are changed.
+ async changeExpirationDate(fingerprintArray, newExpiry) {
+ for (let fingerprint of fingerprintArray) {
+ let handle = this.getKeyHandleByKeyIdOrFingerprint(
+ RNPLib.ffi,
+ "0x" + fingerprint
+ );
+
+ if (handle.isNull()) {
+ continue;
+ }
+
+ if (RNPLib.rnp_key_set_expiration(handle, newExpiry)) {
+ throw new Error(`rnp_key_set_expiration failed for ${fingerprint}`);
+ }
+ RNPLib.rnp_key_handle_destroy(handle);
+ }
+
+ await this.saveKeyRings();
+ return true;
+ },
+
+ /**
+ * Get a minimal Autocrypt-compatible key for the given key handles.
+ * If subkey is given, it must refer to an existing encryption subkey.
+ * This is a wrapper around RNP function rnp_key_export_autocrypt.
+ *
+ * @param {rnp_key_handle_t} primHandle - The handle of a primary key.
+ * @param {?rnp_key_handle_t} subHandle - The handle of an encryption subkey or null.
+ * @param {string} uidString - The userID to include.
+ * @returns {string} The encoded key, or the empty string on failure.
+ */
+ getAutocryptKeyB64ByHandle(primHandle, subHandle, userId) {
+ if (primHandle.isNull()) {
+ throw new Error("getAutocryptKeyB64ByHandle invalid parameter");
+ }
+
+ let output_to_memory = new RNPLib.rnp_output_t();
+ if (RNPLib.rnp_output_to_memory(output_to_memory.address(), 0)) {
+ throw new Error("rnp_output_to_memory failed");
+ }
+
+ let result = "";
+
+ if (
+ RNPLib.rnp_key_export_autocrypt(
+ primHandle,
+ subHandle,
+ userId,
+ output_to_memory,
+ 0
+ )
+ ) {
+ console.debug("rnp_key_export_autocrypt failed");
+ } else {
+ let result_buf = new lazy.ctypes.uint8_t.ptr();
+ let result_len = new lazy.ctypes.size_t();
+ let rv = RNPLib.rnp_output_memory_get_buf(
+ output_to_memory,
+ result_buf.address(),
+ result_len.address(),
+ false
+ );
+
+ if (!rv) {
+ // result_len is of type UInt64, I don't know of a better way
+ // to convert it to an integer.
+ let b_len = parseInt(result_len.value.toString());
+
+ // type casting the pointer type to an array type allows us to
+ // access the elements by index.
+ let uint8_array = lazy.ctypes.cast(
+ result_buf,
+ lazy.ctypes.uint8_t.array(result_len.value).ptr
+ ).contents;
+
+ let str = "";
+ for (let i = 0; i < b_len; i++) {
+ str += String.fromCharCode(uint8_array[i]);
+ }
+
+ result = btoa(str);
+ }
+ }
+
+ RNPLib.rnp_output_destroy(output_to_memory);
+
+ return result;
+ },
+
+ /**
+ * Get a minimal Autocrypt-compatible key for the given key ID.
+ * If subKeyId is given, it must refer to an existing encryption subkey.
+ * This is a wrapper around RNP function rnp_key_export_autocrypt.
+ *
+ * @param {string} primaryKeyId - The ID of a primary key.
+ * @param {?string} subKeyId - The ID of an encryption subkey or null.
+ * @param {string} uidString - The userID to include.
+ * @returns {string} The encoded key, or the empty string on failure.
+ */
+ getAutocryptKeyB64(primaryKeyId, subKeyId, uidString) {
+ let subHandle = null;
+
+ if (subKeyId) {
+ subHandle = this.getKeyHandleByKeyIdOrFingerprint(RNPLib.ffi, subKeyId);
+ if (subHandle.isNull()) {
+ // Although subKeyId is optional, if it's given, it must be valid.
+ return "";
+ }
+ }
+
+ let primHandle = this.getKeyHandleByKeyIdOrFingerprint(
+ RNPLib.ffi,
+ primaryKeyId
+ );
+
+ let result = this.getAutocryptKeyB64ByHandle(
+ primHandle,
+ subHandle,
+ uidString
+ );
+
+ if (!primHandle.isNull()) {
+ RNPLib.rnp_key_handle_destroy(primHandle);
+ }
+ if (subHandle) {
+ RNPLib.rnp_key_handle_destroy(subHandle);
+ }
+ return result;
+ },
+
+ /**
+ * Helper function to produce the string that will be shown to the
+ * user, when the user is asked to unlock a key. If the key is a
+ * subkey, it might help to user to identify the respective key by
+ * also mentioning the key ID of the primary key, so both IDs are
+ * shown when prompting to unlock a subkey.
+ * Parameter nonDefaultFFI is required, if the prompt is related to
+ * a key that isn't (yet) stored in the global storage, for example
+ * a key that is being prepared for import or export in a temporary
+ * ffi space.
+ *
+ * @param {rnp_key_handle_t} handle - produce a passphrase prompt
+ * string based on the properties of this key.
+ * @param {rnp_ffi_t} ffi - the RNP FFI that relates the handle
+ * @returns {String} - a string that asks the user to enter the
+ * passphrase for the given string parameter, including details
+ * that allow the user to identify the key.
+ */
+ async getPassphrasePrompt(handle, ffi) {
+ let parentOfHandle = this.getPrimaryKeyHandleIfSub(ffi, handle);
+ let useThisHandle = !parentOfHandle.isNull() ? parentOfHandle : handle;
+
+ let keyObj = {};
+ if (
+ !this.getKeyInfoFromHandle(ffi, useThisHandle, keyObj, false, true, true)
+ ) {
+ return "";
+ }
+
+ let mainKeyId = keyObj.keyId;
+ let subKeyId;
+ if (!parentOfHandle.isNull()) {
+ subKeyId = this.getKeyIDFromHandle(handle);
+ }
+
+ if (subKeyId) {
+ return l10n.formatValue("passphrase-prompt2-sub", {
+ subkey: subKeyId,
+ key: mainKeyId,
+ date: keyObj.created,
+ username_and_email: keyObj.userId,
+ });
+ }
+ return l10n.formatValue("passphrase-prompt2", {
+ key: mainKeyId,
+ date: keyObj.created,
+ username_and_email: keyObj.userId,
+ });
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/RNPLib.jsm b/comm/mail/extensions/openpgp/content/modules/RNPLib.jsm
new file mode 100644
index 0000000000..58bcb383b5
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/RNPLib.jsm
@@ -0,0 +1,2109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["RNPLibLoader"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+const { ctypes } = ChromeUtils.importESModule(
+ "resource://gre/modules/ctypes.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ OpenPGPMasterpass: "chrome://openpgp/content/modules/masterpass.jsm",
+});
+
+const MIN_RNP_VERSION = [0, 17, 0];
+
+var systemOS = Services.appinfo.OS.toLowerCase();
+var abi = ctypes.default_abi;
+
+// Open librnp. Determine the path to the chrome directory and look for it
+// there first. If not, fallback to searching the standard locations.
+var librnp, librnpPath;
+
+function tryLoadRNP(name, suffix) {
+ let filename = ctypes.libraryName(name) + suffix;
+ let binPath = Services.dirsvc.get("XpcomLib", Ci.nsIFile).path;
+ let binDir = PathUtils.parent(binPath);
+ librnpPath = PathUtils.join(binDir, filename);
+
+ try {
+ librnp = ctypes.open(librnpPath);
+ } catch (e) {}
+
+ if (!librnp) {
+ try {
+ // look in standard locations
+ librnpPath = filename;
+ librnp = ctypes.open(librnpPath);
+ } catch (e) {}
+ }
+}
+
+function loadExternalRNPLib() {
+ if (!librnp) {
+ // Try loading librnp.so, librnp.dylib, or rnp.dll first
+ tryLoadRNP("rnp", "");
+ }
+
+ if (!librnp && (systemOS === "winnt" || systemOS === "darwin")) {
+ // rnp.0.dll or rnp.0.dylib
+ tryLoadRNP("rnp.0", "");
+ }
+
+ if (!librnp) {
+ tryLoadRNP("rnp-0", "");
+ }
+
+ if (!librnp && systemOS === "winnt") {
+ // librnp-0.dll
+ tryLoadRNP("librnp-0", "");
+ }
+
+ if (!librnp && !(systemOS === "winnt") && !(systemOS === "darwin")) {
+ // librnp.so.0
+ tryLoadRNP("rnp", ".0");
+ }
+}
+
+var RNPLibLoader = {
+ init() {
+ const required_version_str = `${MIN_RNP_VERSION[0]}.${MIN_RNP_VERSION[1]}.${MIN_RNP_VERSION[2]}`;
+
+ let dummyRNPLib = {
+ loaded: false,
+ loadedOfficial: false,
+ loadStatus: "libs-rnp-status-load-failed",
+ loadErrorReason: "RNP/OpenPGP library failed to load",
+ path: "",
+
+ getRNPLibStatus() {
+ return {
+ min_version: required_version_str,
+ loaded_version: "-",
+ status: this.loadStatus,
+ error: this.loadErrorReason,
+ path: this.path,
+ };
+ },
+ };
+
+ loadExternalRNPLib();
+ if (!librnp) {
+ return dummyRNPLib;
+ }
+
+ try {
+ enableRNPLibJS();
+ } catch (e) {
+ console.log(e);
+ return dummyRNPLib;
+ }
+
+ const rnp_version_str =
+ RNPLib.rnp_version_string_full().readStringReplaceMalformed();
+ RNPLib.loadedVersion = rnp_version_str;
+ RNPLib.expectedVersion = required_version_str;
+
+ let hasRequiredVersion = RNPLib.check_required_version();
+
+ if (!hasRequiredVersion) {
+ RNPLib.loadErrorReason = `RNP version ${rnp_version_str} does not meet minimum required ${required_version_str}.`;
+ RNPLib.loadStatus = "libs-rnp-status-incompatible";
+ return RNPLib;
+ }
+
+ RNPLib.loaded = true;
+
+ let hasOfficialVersion =
+ rnp_version_str.includes(".MZLA") ||
+ rnp_version_str.match("^[0-9]+.[0-9]+.[0-9]+(.[0-9]+)?$");
+ if (!hasOfficialVersion) {
+ RNPLib.loadErrorReason = `RNP reports unexpected version information, it's considered an unofficial version with unknown capabilities.`;
+ RNPLib.loadStatus = "libs-rnp-status-unofficial";
+ } else {
+ RNPLib.loadedOfficial = true;
+ }
+
+ return RNPLib;
+ },
+};
+
+const rnp_result_t = ctypes.uint32_t;
+const rnp_ffi_t = ctypes.void_t.ptr;
+const rnp_input_t = ctypes.void_t.ptr;
+const rnp_output_t = ctypes.void_t.ptr;
+const rnp_key_handle_t = ctypes.void_t.ptr;
+const rnp_uid_handle_t = ctypes.void_t.ptr;
+const rnp_identifier_iterator_t = ctypes.void_t.ptr;
+const rnp_op_generate_t = ctypes.void_t.ptr;
+const rnp_op_encrypt_t = ctypes.void_t.ptr;
+const rnp_op_sign_t = ctypes.void_t.ptr;
+const rnp_op_sign_signature_t = ctypes.void_t.ptr;
+const rnp_op_verify_t = ctypes.void_t.ptr;
+const rnp_op_verify_signature_t = ctypes.void_t.ptr;
+const rnp_signature_handle_t = ctypes.void_t.ptr;
+const rnp_recipient_handle_t = ctypes.void_t.ptr;
+const rnp_symenc_handle_t = ctypes.void_t.ptr;
+
+const rnp_password_cb_t = ctypes.FunctionType(abi, ctypes.bool, [
+ rnp_ffi_t,
+ ctypes.void_t.ptr,
+ rnp_key_handle_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.size_t,
+]).ptr;
+
+const rnp_key_signatures_cb = ctypes.FunctionType(abi, ctypes.void_t, [
+ rnp_ffi_t,
+ ctypes.void_t.ptr,
+ rnp_signature_handle_t,
+ ctypes.uint32_t.ptr,
+]).ptr;
+
+var RNPLib;
+
+function enableRNPLibJS() {
+ // this must be delayed until after "librnp" is initialized
+
+ RNPLib = {
+ loaded: false,
+ loadedOfficial: false,
+ loadStatus: "",
+ loadErrorReason: "",
+ expectedVersion: "",
+ loadedVersion: "",
+
+ getRNPLibStatus() {
+ return {
+ min_version: this.expectedVersion,
+ loaded_version: this.loadedVersion,
+ status:
+ this.loaded && this.loadedOfficial
+ ? "libs-rnp-status-ok"
+ : this.loadStatus,
+ error: this.loadErrorReason,
+ path: this.path,
+ };
+ },
+
+ path: librnpPath,
+
+ // Handle to the RNP library and primary key data store.
+ // Kept at null if init fails.
+ ffi: null,
+
+ // returns rnp_input_t, destroy using rnp_input_destroy
+ async createInputFromPath(path) {
+ // IOUtils.read always returns an array.
+ let u8 = await IOUtils.read(path);
+ if (!u8.length) {
+ return null;
+ }
+
+ let input_from_memory = new this.rnp_input_t();
+ try {
+ this.rnp_input_from_memory(
+ input_from_memory.address(),
+ u8,
+ u8.length,
+ false
+ );
+ } catch (ex) {
+ throw new Error("rnp_input_from_memory for file " + path + " failed");
+ }
+ return input_from_memory;
+ },
+
+ getFilenames() {
+ let secFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ secFile.append("secring.gpg");
+ let pubFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ pubFile.append("pubring.gpg");
+
+ let secRingPath = secFile.path;
+ let pubRingPath = pubFile.path;
+
+ return { pubRingPath, secRingPath };
+ },
+
+ /**
+ * Load a keyring file into the global ffi context.
+ *
+ * @param {string} filename - The file to load
+ * @param keyringFlag - either RNP_LOAD_SAVE_PUBLIC_KEYS
+ * or RNP_LOAD_SAVE_SECRET_KEYS
+ */
+ async loadFile(filename, keyringFlag) {
+ let in_file = await this.createInputFromPath(filename);
+ if (in_file) {
+ this.rnp_load_keys(this.ffi, "GPG", in_file, keyringFlag);
+ this.rnp_input_destroy(in_file);
+ }
+ },
+
+ /**
+ * Load a keyring file into the global ffi context.
+ * If the file couldn't be opened, fall back to a backup file,
+ * by appending ".old" to filename.
+ *
+ * @param {string} filename - The file to load
+ * @param keyringFlag - either RNP_LOAD_SAVE_PUBLIC_KEYS
+ * or RNP_LOAD_SAVE_SECRET_KEYS
+ */
+ async loadWithFallback(filename, keyringFlag) {
+ let loadBackup = false;
+ try {
+ await this.loadFile(filename, keyringFlag);
+ } catch (ex) {
+ if (DOMException.isInstance(ex)) {
+ loadBackup = true;
+ }
+ }
+ if (loadBackup) {
+ filename += ".old";
+ try {
+ await this.loadFile(filename, keyringFlag);
+ } catch (ex) {}
+ }
+ },
+
+ async _fixUnprotectedKeys() {
+ // Bug 1710290, protect all unprotected keys.
+ // To do so, we require that the user has already unlocked
+ // by entering the global primary password, if it is set.
+ // Ensure that other repairing is done first, if necessary,
+ // as handled by masterpass.jsm (OpenPGP automatic password).
+
+ // Note we have two failure scenarios, either a failure, or
+ // retrieveOpenPGPPassword() returning null (that function
+ // might fail because of inconsistencies or corruption).
+ let canRepair = false;
+ try {
+ console.log("Trying to automatically protect the unprotected keys.");
+ let mp = await lazy.OpenPGPMasterpass.retrieveOpenPGPPassword();
+ if (mp) {
+ await RNPLib.protectUnprotectedKeys();
+ await RNPLib.saveKeys();
+ canRepair = true;
+ console.log("Successfully protected the unprotected keys.");
+ let [prot, unprot] = RNPLib.getProtectedKeysCount();
+ console.debug(
+ `Found (${prot} protected and ${unprot} unprotected secret keys.`
+ );
+ }
+ } catch (ex) {
+ console.log(ex);
+ }
+
+ if (!canRepair) {
+ console.log("Cannot protect the unprotected keys at this time.");
+ }
+ },
+
+ check_required_version() {
+ const min_version = this.rnp_version_for(...MIN_RNP_VERSION);
+ const this_version = this.rnp_version();
+ return Boolean(this_version >= min_version);
+ },
+
+ /**
+ * Prepare an RNP library handle, and in addition set all the
+ * application's preferences for library behavior.
+ *
+ * Other application code should NOT call rnp_ffi_create directly,
+ * but obtain an RNP library handle from this function.
+ */
+ prepare_ffi() {
+ let ffi = new rnp_ffi_t();
+ if (this._rnp_ffi_create(ffi.address(), "GPG", "GPG")) {
+ return null;
+ }
+
+ // Treat MD5 as insecure.
+ if (
+ this.rnp_add_security_rule(
+ ffi,
+ this.RNP_FEATURE_HASH_ALG,
+ this.RNP_ALGNAME_MD5,
+ this.RNP_SECURITY_OVERRIDE,
+ 0,
+ this.RNP_SECURITY_INSECURE
+ )
+ ) {
+ return null;
+ }
+
+ // Use RNP's default rule for SHA1 used with data signatures,
+ // and use our override to allow it for key signatures.
+ if (
+ this.rnp_add_security_rule(
+ ffi,
+ this.RNP_FEATURE_HASH_ALG,
+ this.RNP_ALGNAME_SHA1,
+ this.RNP_SECURITY_VERIFY_KEY | this.RNP_SECURITY_OVERRIDE,
+ 0,
+ this.RNP_SECURITY_DEFAULT
+ )
+ ) {
+ return null;
+ }
+
+ /*
+ // Security rules API does not yet support PK and SYMM algs.
+ //
+ // If a hash algorithm is already disabled at build time,
+ // and an attempt is made to set a security rule for that
+ // algorithm, then RNP returns a failure.
+ //
+ // Ideally, RNP should allow these calls (regardless of build time
+ // settings) to define an application security rule, that is
+ // independent of the configuration used for building the
+ // RNP library.
+
+ if (
+ this.rnp_add_security_rule(
+ ffi,
+ this.RNP_FEATURE_HASH_ALG,
+ this.RNP_ALGNAME_SM3,
+ this.RNP_SECURITY_OVERRIDE,
+ 0,
+ this.RNP_SECURITY_PROHIBITED
+ )
+ ) {
+ return null;
+ }
+
+ if (
+ this.rnp_add_security_rule(
+ ffi,
+ this.RNP_FEATURE_PK_ALG,
+ this.RNP_ALGNAME_SM2,
+ this.RNP_SECURITY_OVERRIDE,
+ 0,
+ this.RNP_SECURITY_PROHIBITED
+ )
+ ) {
+ return null;
+ }
+
+ if (
+ this.rnp_add_security_rule(
+ ffi,
+ this.RNP_FEATURE_SYMM_ALG,
+ this.RNP_ALGNAME_SM4,
+ this.RNP_SECURITY_OVERRIDE,
+ 0,
+ this.RNP_SECURITY_PROHIBITED
+ )
+ ) {
+ return null;
+ }
+ */
+
+ return ffi;
+ },
+
+ /**
+ * Test the correctness of security rules, in particular, test
+ * if the given hash algorithm is allowed at the given time.
+ *
+ * This is an application consistency test. If the behavior isn't
+ * according to the expectation, the function throws an error.
+ *
+ * @param {string} hashAlg - Test this hash algorithm
+ * @param {time_t} time - Test status at this timestamp
+ * @param {boolean} keySigAllowed - Test if using the hash algorithm
+ * is allowed for signatures found inside OpenPGP keys.
+ * @param {boolean} dataSigAllowed - Test if using the hash algorithm
+ * is allowed for signatures on data.
+ */
+ _confirmSecurityRule(hashAlg, time, keySigAllowed, dataSigAllowed) {
+ let level = new ctypes.uint32_t();
+ let flag = new ctypes.uint32_t();
+
+ flag.value = this.RNP_SECURITY_VERIFY_DATA;
+ let testDataSuccess = false;
+ if (
+ !RNPLib.rnp_get_security_rule(
+ this.ffi,
+ this.RNP_FEATURE_HASH_ALG,
+ hashAlg,
+ time,
+ flag.address(),
+ null,
+ level.address()
+ )
+ ) {
+ if (dataSigAllowed) {
+ testDataSuccess = level.value == RNPLib.RNP_SECURITY_DEFAULT;
+ } else {
+ testDataSuccess = level.value < RNPLib.RNP_SECURITY_DEFAULT;
+ }
+ }
+
+ if (!testDataSuccess) {
+ throw new Error("security configuration for data signatures failed");
+ }
+
+ flag.value = this.RNP_SECURITY_VERIFY_KEY;
+ let testKeySuccess = false;
+ if (
+ !RNPLib.rnp_get_security_rule(
+ this.ffi,
+ this.RNP_FEATURE_HASH_ALG,
+ hashAlg,
+ time,
+ flag.address(),
+ null,
+ level.address()
+ )
+ ) {
+ if (keySigAllowed) {
+ testKeySuccess = level.value == RNPLib.RNP_SECURITY_DEFAULT;
+ } else {
+ testKeySuccess = level.value < RNPLib.RNP_SECURITY_DEFAULT;
+ }
+ }
+
+ if (!testKeySuccess) {
+ throw new Error("security configuration for key signatures failed");
+ }
+ },
+
+ /**
+ * Perform tests that the RNP library behaves according to the
+ * defined security rules.
+ * If a problem is found, the function throws an error.
+ */
+ _sanityCheckSecurityRules() {
+ let time_t_now = Math.round(Date.now() / 1000);
+ let ten_years_in_seconds = 10 * 365 * 24 * 60 * 60;
+ let ten_years_future = time_t_now + ten_years_in_seconds;
+
+ this._confirmSecurityRule(this.RNP_ALGNAME_MD5, time_t_now, false, false);
+ this._confirmSecurityRule(
+ this.RNP_ALGNAME_MD5,
+ ten_years_future,
+ false,
+ false
+ );
+
+ this._confirmSecurityRule(this.RNP_ALGNAME_SHA1, time_t_now, true, false);
+ this._confirmSecurityRule(
+ this.RNP_ALGNAME_SHA1,
+ ten_years_future,
+ true,
+ false
+ );
+ },
+
+ async init() {
+ this.ffi = this.prepare_ffi();
+ if (!this.ffi) {
+ throw new Error("Couldn't initialize librnp.");
+ }
+
+ this.rnp_ffi_set_log_fd(this.ffi, 2); // stderr
+
+ this.keep_password_cb_alive = rnp_password_cb_t(
+ this.password_cb,
+ this, // this value used while executing callback
+ false // callback return value if exception is thrown
+ );
+ this.rnp_ffi_set_pass_provider(
+ this.ffi,
+ this.keep_password_cb_alive,
+ null
+ );
+
+ let { pubRingPath, secRingPath } = this.getFilenames();
+
+ try {
+ this._sanityCheckSecurityRules();
+ } catch (e) {
+ // Disable all RNP operation
+ this.ffi = null;
+ throw e;
+ }
+
+ await this.loadWithFallback(pubRingPath, this.RNP_LOAD_SAVE_PUBLIC_KEYS);
+ await this.loadWithFallback(secRingPath, this.RNP_LOAD_SAVE_SECRET_KEYS);
+
+ let pubnum = new ctypes.size_t();
+ this.rnp_get_public_key_count(this.ffi, pubnum.address());
+
+ let secnum = new ctypes.size_t();
+ this.rnp_get_secret_key_count(this.ffi, secnum.address());
+
+ let [prot, unprot] = this.getProtectedKeysCount();
+ console.debug(
+ `Found ${pubnum.value} public keys and ${secnum.value} secret keys (${prot} protected, ${unprot} unprotected)`
+ );
+
+ if (unprot) {
+ // We need automatic repair, which can involve a primary password
+ // prompt. Let's use a short timer, so we keep it out of the
+ // early startup code.
+ console.log(
+ "Will attempt to automatically protect the unprotected keys in 30 seconds"
+ );
+ lazy.setTimeout(RNPLib._fixUnprotectedKeys, 30000);
+ }
+ return true;
+ },
+
+ /**
+ * Returns two numbers, the number of protected and unprotected keys.
+ * Because we use an automatic password for all secret keys
+ * (regardless of a primary password being used),
+ * the number of unprotected keys should be zero.
+ */
+ getProtectedKeysCount() {
+ let prot = 0;
+ let unprot = 0;
+
+ let iter = new RNPLib.rnp_identifier_iterator_t();
+ let grip = new ctypes.char.ptr();
+
+ if (
+ RNPLib.rnp_identifier_iterator_create(
+ RNPLib.ffi,
+ iter.address(),
+ "grip"
+ )
+ ) {
+ throw new Error("rnp_identifier_iterator_create failed");
+ }
+
+ while (
+ !RNPLib.rnp_identifier_iterator_next(iter, grip.address()) &&
+ !grip.isNull()
+ ) {
+ let handle = new RNPLib.rnp_key_handle_t();
+ if (RNPLib.rnp_locate_key(RNPLib.ffi, "grip", grip, handle.address())) {
+ throw new Error("rnp_locate_key failed");
+ }
+
+ if (this.getSecretAvailableFromHandle(handle)) {
+ let is_protected = new ctypes.bool();
+ if (RNPLib.rnp_key_is_protected(handle, is_protected.address())) {
+ throw new Error("rnp_key_is_protected failed");
+ }
+ if (is_protected.value) {
+ prot++;
+ } else {
+ unprot++;
+ }
+ }
+
+ RNPLib.rnp_key_handle_destroy(handle);
+ }
+
+ RNPLib.rnp_identifier_iterator_destroy(iter);
+ return [prot, unprot];
+ },
+
+ getSecretAvailableFromHandle(handle) {
+ let have_secret = new ctypes.bool();
+ if (RNPLib.rnp_key_have_secret(handle, have_secret.address())) {
+ throw new Error("rnp_key_have_secret failed");
+ }
+ return have_secret.value;
+ },
+
+ /**
+ * If the given secret key is a pseudo secret key, which doesn't
+ * contain the underlying key material, then return false.
+ *
+ * Only call this function if getSecretAvailableFromHandle returns
+ * true for the given handle (which means it claims to contain a
+ * secret key).
+ *
+ * @param {rnp_key_handle_t} handle - handle of the key to query
+ * @returns {boolean} - true if secret key material is available
+ *
+ */
+ isSecretKeyMaterialAvailable(handle) {
+ let protection_type = new ctypes.char.ptr();
+ if (
+ RNPLib.rnp_key_get_protection_type(handle, protection_type.address())
+ ) {
+ throw new Error("rnp_key_get_protection_type failed");
+ }
+ let result;
+ switch (protection_type.readString()) {
+ case "GPG-None":
+ case "GPG-Smartcard":
+ case "Unknown":
+ result = false;
+ break;
+ default:
+ result = true;
+ break;
+ }
+ RNPLib.rnp_buffer_destroy(protection_type);
+ return result;
+ },
+
+ async protectUnprotectedKeys() {
+ let iter = new RNPLib.rnp_identifier_iterator_t();
+ let grip = new ctypes.char.ptr();
+
+ let newPass = await lazy.OpenPGPMasterpass.retrieveOpenPGPPassword();
+
+ if (
+ RNPLib.rnp_identifier_iterator_create(
+ RNPLib.ffi,
+ iter.address(),
+ "grip"
+ )
+ ) {
+ throw new Error("rnp_identifier_iterator_create failed");
+ }
+
+ while (
+ !RNPLib.rnp_identifier_iterator_next(iter, grip.address()) &&
+ !grip.isNull()
+ ) {
+ let handle = new RNPLib.rnp_key_handle_t();
+ if (RNPLib.rnp_locate_key(RNPLib.ffi, "grip", grip, handle.address())) {
+ throw new Error("rnp_locate_key failed");
+ }
+
+ if (RNPLib.getSecretAvailableFromHandle(handle)) {
+ let is_protected = new ctypes.bool();
+ if (RNPLib.rnp_key_is_protected(handle, is_protected.address())) {
+ throw new Error("rnp_key_is_protected failed");
+ }
+ if (!is_protected.value) {
+ RNPLib.protectKeyWithSubKeys(handle, newPass);
+ }
+ }
+
+ RNPLib.rnp_key_handle_destroy(handle);
+ }
+
+ RNPLib.rnp_identifier_iterator_destroy(iter);
+ },
+
+ protectKeyWithSubKeys(handle, newPass) {
+ if (RNPLib.isSecretKeyMaterialAvailable(handle)) {
+ if (RNPLib.rnp_key_protect(handle, newPass, null, null, null, 0)) {
+ throw new Error("rnp_key_protect failed");
+ }
+ }
+
+ let sub_count = new ctypes.size_t();
+ if (RNPLib.rnp_key_get_subkey_count(handle, sub_count.address())) {
+ throw new Error("rnp_key_get_subkey_count failed");
+ }
+
+ for (let i = 0; i < sub_count.value; i++) {
+ let sub_handle = new RNPLib.rnp_key_handle_t();
+ if (RNPLib.rnp_key_get_subkey_at(handle, i, sub_handle.address())) {
+ throw new Error("rnp_key_get_subkey_at failed");
+ }
+ if (
+ RNPLib.getSecretAvailableFromHandle(sub_handle) &&
+ RNPLib.isSecretKeyMaterialAvailable(sub_handle)
+ ) {
+ if (
+ RNPLib.rnp_key_protect(sub_handle, newPass, null, null, null, 0)
+ ) {
+ throw new Error("rnp_key_protect failed");
+ }
+ }
+ RNPLib.rnp_key_handle_destroy(sub_handle);
+ }
+ },
+
+ /**
+ * Save keyring file to the given path.
+ *
+ * @param {string} path - The file path to save to.
+ * @param {number} keyRingFlag - RNP_LOAD_SAVE_PUBLIC_KEYS or
+ * RNP_LOAD_SAVE_SECRET_KEYS.
+ */
+ async saveKeyRing(path, keyRingFlag) {
+ if (!this.ffi) {
+ return;
+ }
+
+ let oldPath = path + ".old";
+
+ // Ignore failure, oldPath might not exist yet.
+ await IOUtils.copy(path, oldPath).catch(() => {});
+
+ let u8 = null;
+ let keyCount = new ctypes.size_t();
+
+ if (keyRingFlag == this.RNP_LOAD_SAVE_SECRET_KEYS) {
+ this.rnp_get_secret_key_count(this.ffi, keyCount.address());
+ } else {
+ this.rnp_get_public_key_count(this.ffi, keyCount.address());
+ }
+
+ let keyCountNum = parseInt(keyCount.value.toString());
+ if (keyCountNum) {
+ let rnp_out = new this.rnp_output_t();
+ if (this.rnp_output_to_memory(rnp_out.address(), 0)) {
+ throw new Error("rnp_output_to_memory failed");
+ }
+ if (this.rnp_save_keys(this.ffi, "GPG", rnp_out, keyRingFlag)) {
+ throw new Error("rnp_save_keys failed");
+ }
+
+ let result_buf = new ctypes.uint8_t.ptr();
+ let result_len = new ctypes.size_t();
+
+ // Parameter false means "don't copy rnp_out to result_buf",
+ // rather a reference to the memory is used. Be careful to
+ // destroy rnp_out after we're done with the data.
+ if (
+ this.rnp_output_memory_get_buf(
+ rnp_out,
+ result_buf.address(),
+ result_len.address(),
+ false
+ )
+ ) {
+ throw new Error("rnp_output_memory_get_buf failed");
+ } else {
+ let uint8_array = ctypes.cast(
+ result_buf,
+ ctypes.uint8_t.array(result_len.value).ptr
+ ).contents;
+ // This call creates a copy of the data, it should be
+ // safe to destroy rnp_out afterwards.
+ u8 = uint8_array.readTypedArray();
+ }
+ this.rnp_output_destroy(rnp_out);
+ }
+
+ u8 = u8 || new Uint8Array();
+
+ await IOUtils.write(path, u8, {
+ tmpPath: path + ".tmp-new",
+ });
+ },
+
+ async saveKeys() {
+ if (!this.ffi) {
+ return;
+ }
+ let { pubRingPath, secRingPath } = this.getFilenames();
+
+ let saveThem = async () => {
+ await this.saveKeyRing(pubRingPath, this.RNP_LOAD_SAVE_PUBLIC_KEYS);
+ await this.saveKeyRing(secRingPath, this.RNP_LOAD_SAVE_SECRET_KEYS);
+ };
+ let saveBlocker = saveThem();
+ IOUtils.profileBeforeChange.addBlocker(
+ "OpenPGP: writing out keyring",
+ saveBlocker
+ );
+ await saveBlocker;
+ IOUtils.profileBeforeChange.removeBlocker(saveBlocker);
+ },
+
+ keep_password_cb_alive: null,
+
+ cached_pw: null,
+
+ /**
+ * Past versions of Thunderbird used this callback to provide
+ * the automatically managed passphrase to RNP, which was used
+ * for all OpenPGP. Nowadays, Thunderbird supports the definition
+ * of used-defined passphrase. To better control the unlocking of
+ * keys, Thunderbird no longer uses this callback.
+ * The application is designed to unlock secret keys as needed,
+ * prior to calling the respective RNP APIs.
+ * If this callback is reached anyway, it's an internal error,
+ * it means that some Thunderbird code hasn't properly unlocked
+ * the required key yet.
+ *
+ * This is a C callback from an external library, so we cannot
+ * rely on the usual JS throw mechanism to abort this operation.
+ */
+ password_cb(ffi, app_ctx, key, pgp_context, buf, buf_len) {
+ let fingerprint = new ctypes.char.ptr();
+ let fpStr;
+ if (!RNPLib.rnp_key_get_fprint(key, fingerprint.address())) {
+ fpStr = "Fingerprint: " + fingerprint.readString();
+ }
+ RNPLib.rnp_buffer_destroy(fingerprint);
+
+ console.debug(
+ `Internal error, RNP password callback called unexpectedly. ${fpStr}.`
+ );
+ return false;
+ },
+
+ // For comparing version numbers
+ rnp_version_for: librnp.declare(
+ "rnp_version_for",
+ abi,
+ ctypes.uint32_t,
+ ctypes.uint32_t, // major
+ ctypes.uint32_t, // minor
+ ctypes.uint32_t // patch
+ ),
+
+ // Get the library version.
+ rnp_version: librnp.declare("rnp_version", abi, ctypes.uint32_t),
+
+ rnp_version_string_full: librnp.declare(
+ "rnp_version_string_full",
+ abi,
+ ctypes.char.ptr
+ ),
+
+ // Get a RNP library handle.
+ // Mark with leading underscore, to clarify that this function
+ // shouldn't be called directly - you should call prepare_ffi().
+ _rnp_ffi_create: librnp.declare(
+ "rnp_ffi_create",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ rnp_ffi_destroy: librnp.declare(
+ "rnp_ffi_destroy",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t
+ ),
+
+ rnp_ffi_set_log_fd: librnp.declare(
+ "rnp_ffi_set_log_fd",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ ctypes.int
+ ),
+
+ rnp_get_public_key_count: librnp.declare(
+ "rnp_get_public_key_count",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_get_secret_key_count: librnp.declare(
+ "rnp_get_secret_key_count",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_input_from_path: librnp.declare(
+ "rnp_input_from_path",
+ abi,
+ rnp_result_t,
+ rnp_input_t.ptr,
+ ctypes.char.ptr
+ ),
+
+ rnp_input_from_memory: librnp.declare(
+ "rnp_input_from_memory",
+ abi,
+ rnp_result_t,
+ rnp_input_t.ptr,
+ ctypes.uint8_t.ptr,
+ ctypes.size_t,
+ ctypes.bool
+ ),
+
+ rnp_output_to_memory: librnp.declare(
+ "rnp_output_to_memory",
+ abi,
+ rnp_result_t,
+ rnp_output_t.ptr,
+ ctypes.size_t
+ ),
+
+ rnp_output_to_path: librnp.declare(
+ "rnp_output_to_path",
+ abi,
+ rnp_result_t,
+ rnp_output_t.ptr,
+ ctypes.char.ptr
+ ),
+
+ rnp_decrypt: librnp.declare(
+ "rnp_decrypt",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ rnp_input_t,
+ rnp_output_t
+ ),
+
+ rnp_output_memory_get_buf: librnp.declare(
+ "rnp_output_memory_get_buf",
+ abi,
+ rnp_result_t,
+ rnp_output_t,
+ ctypes.uint8_t.ptr.ptr,
+ ctypes.size_t.ptr,
+ ctypes.bool
+ ),
+
+ rnp_input_destroy: librnp.declare(
+ "rnp_input_destroy",
+ abi,
+ rnp_result_t,
+ rnp_input_t
+ ),
+
+ rnp_output_destroy: librnp.declare(
+ "rnp_output_destroy",
+ abi,
+ rnp_result_t,
+ rnp_output_t
+ ),
+
+ rnp_load_keys: librnp.declare(
+ "rnp_load_keys",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ ctypes.char.ptr,
+ rnp_input_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_save_keys: librnp.declare(
+ "rnp_save_keys",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ ctypes.char.ptr,
+ rnp_output_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_ffi_set_pass_provider: librnp.declare(
+ "rnp_ffi_set_pass_provider",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ rnp_password_cb_t,
+ ctypes.void_t.ptr
+ ),
+
+ rnp_identifier_iterator_create: librnp.declare(
+ "rnp_identifier_iterator_create",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ rnp_identifier_iterator_t.ptr,
+ ctypes.char.ptr
+ ),
+
+ rnp_identifier_iterator_next: librnp.declare(
+ "rnp_identifier_iterator_next",
+ abi,
+ rnp_result_t,
+ rnp_identifier_iterator_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_identifier_iterator_destroy: librnp.declare(
+ "rnp_identifier_iterator_destroy",
+ abi,
+ rnp_result_t,
+ rnp_identifier_iterator_t
+ ),
+
+ rnp_locate_key: librnp.declare(
+ "rnp_locate_key",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ rnp_key_handle_t.ptr
+ ),
+
+ rnp_key_handle_destroy: librnp.declare(
+ "rnp_key_handle_destroy",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t
+ ),
+
+ rnp_key_allows_usage: librnp.declare(
+ "rnp_key_allows_usage",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr,
+ ctypes.bool.ptr
+ ),
+
+ rnp_key_is_sub: librnp.declare(
+ "rnp_key_is_sub",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_key_is_primary: librnp.declare(
+ "rnp_key_is_primary",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_key_have_secret: librnp.declare(
+ "rnp_key_have_secret",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_key_have_public: librnp.declare(
+ "rnp_key_have_public",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_key_get_fprint: librnp.declare(
+ "rnp_key_get_fprint",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_key_get_keyid: librnp.declare(
+ "rnp_key_get_keyid",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_key_get_alg: librnp.declare(
+ "rnp_key_get_alg",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_key_get_grip: librnp.declare(
+ "rnp_key_get_grip",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_key_get_primary_grip: librnp.declare(
+ "rnp_key_get_primary_grip",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_key_is_revoked: librnp.declare(
+ "rnp_key_is_revoked",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_buffer_destroy: librnp.declare(
+ "rnp_buffer_destroy",
+ abi,
+ ctypes.void_t,
+ ctypes.void_t.ptr
+ ),
+
+ rnp_key_get_subkey_count: librnp.declare(
+ "rnp_key_get_subkey_count",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_key_get_subkey_at: librnp.declare(
+ "rnp_key_get_subkey_at",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.size_t,
+ rnp_key_handle_t.ptr
+ ),
+
+ rnp_key_get_creation: librnp.declare(
+ "rnp_key_get_creation",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint32_t.ptr
+ ),
+
+ rnp_key_get_expiration: librnp.declare(
+ "rnp_key_get_expiration",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint32_t.ptr
+ ),
+
+ rnp_key_get_bits: librnp.declare(
+ "rnp_key_get_bits",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint32_t.ptr
+ ),
+
+ rnp_key_get_uid_count: librnp.declare(
+ "rnp_key_get_uid_count",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_key_get_primary_uid: librnp.declare(
+ "rnp_key_get_primary_uid",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_key_get_uid_at: librnp.declare(
+ "rnp_key_get_uid_at",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.size_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_key_get_uid_handle_at: librnp.declare(
+ "rnp_key_get_uid_handle_at",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.size_t,
+ rnp_uid_handle_t.ptr
+ ),
+
+ rnp_uid_handle_destroy: librnp.declare(
+ "rnp_uid_handle_destroy",
+ abi,
+ rnp_result_t,
+ rnp_uid_handle_t
+ ),
+
+ rnp_uid_is_revoked: librnp.declare(
+ "rnp_uid_is_revoked",
+ abi,
+ rnp_result_t,
+ rnp_uid_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_key_unlock: librnp.declare(
+ "rnp_key_unlock",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_key_lock: librnp.declare(
+ "rnp_key_lock",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t
+ ),
+
+ rnp_key_unprotect: librnp.declare(
+ "rnp_key_unprotect",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_key_protect: librnp.declare(
+ "rnp_key_protect",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.size_t
+ ),
+
+ rnp_key_is_protected: librnp.declare(
+ "rnp_key_is_protected",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_key_is_locked: librnp.declare(
+ "rnp_key_is_locked",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_op_generate_create: librnp.declare(
+ "rnp_op_generate_create",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t.ptr,
+ rnp_ffi_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_generate_subkey_create: librnp.declare(
+ "rnp_op_generate_subkey_create",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t.ptr,
+ rnp_ffi_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_generate_set_bits: librnp.declare(
+ "rnp_op_generate_set_bits",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_op_generate_set_curve: librnp.declare(
+ "rnp_op_generate_set_curve",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_generate_set_protection_password: librnp.declare(
+ "rnp_op_generate_set_protection_password",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_generate_set_userid: librnp.declare(
+ "rnp_op_generate_set_userid",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_generate_set_expiration: librnp.declare(
+ "rnp_op_generate_set_expiration",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_op_generate_execute: librnp.declare(
+ "rnp_op_generate_execute",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t
+ ),
+
+ rnp_op_generate_get_key: librnp.declare(
+ "rnp_op_generate_get_key",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t,
+ rnp_key_handle_t.ptr
+ ),
+
+ rnp_op_generate_destroy: librnp.declare(
+ "rnp_op_generate_destroy",
+ abi,
+ rnp_result_t,
+ rnp_op_generate_t
+ ),
+
+ rnp_guess_contents: librnp.declare(
+ "rnp_guess_contents",
+ abi,
+ rnp_result_t,
+ rnp_input_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_import_signatures: librnp.declare(
+ "rnp_import_signatures",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ rnp_input_t,
+ ctypes.uint32_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_import_keys: librnp.declare(
+ "rnp_import_keys",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ rnp_input_t,
+ ctypes.uint32_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_key_remove: librnp.declare(
+ "rnp_key_remove",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_uid_remove: librnp.declare(
+ "rnp_uid_remove",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ rnp_uid_handle_t
+ ),
+
+ rnp_key_remove_signatures: librnp.declare(
+ "rnp_key_remove_signatures",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint32_t,
+ rnp_key_signatures_cb,
+ ctypes.void_t.ptr
+ ),
+
+ rnp_op_encrypt_create: librnp.declare(
+ "rnp_op_encrypt_create",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t.ptr,
+ rnp_ffi_t,
+ rnp_input_t,
+ rnp_output_t
+ ),
+
+ rnp_op_sign_cleartext_create: librnp.declare(
+ "rnp_op_sign_cleartext_create",
+ abi,
+ rnp_result_t,
+ rnp_op_sign_t.ptr,
+ rnp_ffi_t,
+ rnp_input_t,
+ rnp_output_t
+ ),
+
+ rnp_op_sign_detached_create: librnp.declare(
+ "rnp_op_sign_detached_create",
+ abi,
+ rnp_result_t,
+ rnp_op_sign_t.ptr,
+ rnp_ffi_t,
+ rnp_input_t,
+ rnp_output_t
+ ),
+
+ rnp_op_encrypt_add_recipient: librnp.declare(
+ "rnp_op_encrypt_add_recipient",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t,
+ rnp_key_handle_t
+ ),
+
+ rnp_op_encrypt_add_signature: librnp.declare(
+ "rnp_op_encrypt_add_signature",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t,
+ rnp_key_handle_t,
+ rnp_op_sign_signature_t.ptr
+ ),
+
+ rnp_op_sign_add_signature: librnp.declare(
+ "rnp_op_sign_add_signature",
+ abi,
+ rnp_result_t,
+ rnp_op_sign_t,
+ rnp_key_handle_t,
+ rnp_op_sign_signature_t.ptr
+ ),
+
+ rnp_op_encrypt_set_armor: librnp.declare(
+ "rnp_op_encrypt_set_armor",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t,
+ ctypes.bool
+ ),
+
+ rnp_op_sign_set_armor: librnp.declare(
+ "rnp_op_sign_set_armor",
+ abi,
+ rnp_result_t,
+ rnp_op_sign_t,
+ ctypes.bool
+ ),
+
+ rnp_op_encrypt_set_hash: librnp.declare(
+ "rnp_op_encrypt_set_hash",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_sign_set_hash: librnp.declare(
+ "rnp_op_sign_set_hash",
+ abi,
+ rnp_result_t,
+ rnp_op_sign_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_encrypt_set_cipher: librnp.declare(
+ "rnp_op_encrypt_set_cipher",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_sign_execute: librnp.declare(
+ "rnp_op_sign_execute",
+ abi,
+ rnp_result_t,
+ rnp_op_sign_t
+ ),
+
+ rnp_op_sign_destroy: librnp.declare(
+ "rnp_op_sign_destroy",
+ abi,
+ rnp_result_t,
+ rnp_op_sign_t
+ ),
+
+ rnp_op_encrypt_execute: librnp.declare(
+ "rnp_op_encrypt_execute",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t
+ ),
+
+ rnp_op_encrypt_destroy: librnp.declare(
+ "rnp_op_encrypt_destroy",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t
+ ),
+
+ rnp_key_export: librnp.declare(
+ "rnp_key_export",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ rnp_output_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_key_export_revocation: librnp.declare(
+ "rnp_key_export_revocation",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ rnp_output_t,
+ ctypes.uint32_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ rnp_output_to_armor: librnp.declare(
+ "rnp_output_to_armor",
+ abi,
+ rnp_result_t,
+ rnp_output_t,
+ rnp_output_t.ptr,
+ ctypes.char.ptr
+ ),
+
+ rnp_output_finish: librnp.declare(
+ "rnp_output_finish",
+ abi,
+ rnp_result_t,
+ rnp_output_t
+ ),
+
+ rnp_op_verify_create: librnp.declare(
+ "rnp_op_verify_create",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t.ptr,
+ rnp_ffi_t,
+ rnp_input_t,
+ rnp_output_t
+ ),
+
+ rnp_op_verify_detached_create: librnp.declare(
+ "rnp_op_verify_detached_create",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t.ptr,
+ rnp_ffi_t,
+ rnp_input_t,
+ rnp_input_t
+ ),
+
+ rnp_op_verify_execute: librnp.declare(
+ "rnp_op_verify_execute",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t
+ ),
+
+ rnp_op_verify_destroy: librnp.declare(
+ "rnp_op_verify_destroy",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t
+ ),
+
+ rnp_op_verify_get_signature_count: librnp.declare(
+ "rnp_op_verify_get_signature_count",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_op_verify_get_signature_at: librnp.declare(
+ "rnp_op_verify_get_signature_at",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ ctypes.size_t,
+ rnp_op_verify_signature_t.ptr
+ ),
+
+ rnp_op_verify_signature_get_handle: librnp.declare(
+ "rnp_op_verify_signature_get_handle",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_signature_t,
+ rnp_signature_handle_t.ptr
+ ),
+
+ rnp_op_verify_signature_get_status: librnp.declare(
+ "rnp_op_verify_signature_get_status",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_signature_t
+ ),
+
+ rnp_op_verify_signature_get_key: librnp.declare(
+ "rnp_op_verify_signature_get_key",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_signature_t,
+ rnp_key_handle_t.ptr
+ ),
+
+ rnp_op_verify_signature_get_times: librnp.declare(
+ "rnp_op_verify_signature_get_times",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_signature_t,
+ ctypes.uint32_t.ptr,
+ ctypes.uint32_t.ptr
+ ),
+
+ rnp_uid_get_signature_count: librnp.declare(
+ "rnp_uid_get_signature_count",
+ abi,
+ rnp_result_t,
+ rnp_uid_handle_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_uid_get_signature_at: librnp.declare(
+ "rnp_uid_get_signature_at",
+ abi,
+ rnp_result_t,
+ rnp_uid_handle_t,
+ ctypes.size_t,
+ rnp_signature_handle_t.ptr
+ ),
+
+ rnp_key_get_signature_count: librnp.declare(
+ "rnp_key_get_signature_count",
+ abi,
+ rnp_result_t,
+ rnp_uid_handle_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_key_get_signature_at: librnp.declare(
+ "rnp_key_get_signature_at",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.size_t,
+ rnp_signature_handle_t.ptr
+ ),
+
+ rnp_signature_get_hash_alg: librnp.declare(
+ "rnp_signature_get_hash_alg",
+ abi,
+ rnp_result_t,
+ rnp_signature_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_signature_get_creation: librnp.declare(
+ "rnp_signature_get_creation",
+ abi,
+ rnp_result_t,
+ rnp_signature_handle_t,
+ ctypes.uint32_t.ptr
+ ),
+
+ rnp_signature_get_keyid: librnp.declare(
+ "rnp_signature_get_keyid",
+ abi,
+ rnp_result_t,
+ rnp_signature_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_signature_get_signer: librnp.declare(
+ "rnp_signature_get_signer",
+ abi,
+ rnp_result_t,
+ rnp_signature_handle_t,
+ rnp_key_handle_t.ptr
+ ),
+
+ rnp_signature_handle_destroy: librnp.declare(
+ "rnp_signature_handle_destroy",
+ abi,
+ rnp_result_t,
+ rnp_signature_handle_t
+ ),
+
+ rnp_enarmor: librnp.declare(
+ "rnp_enarmor",
+ abi,
+ rnp_result_t,
+ rnp_input_t,
+ rnp_output_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_verify_get_protection_info: librnp.declare(
+ "rnp_op_verify_get_protection_info",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ ctypes.char.ptr.ptr,
+ ctypes.char.ptr.ptr,
+ ctypes.bool.ptr
+ ),
+
+ rnp_op_verify_get_recipient_count: librnp.declare(
+ "rnp_op_verify_get_recipient_count",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_op_verify_get_used_recipient: librnp.declare(
+ "rnp_op_verify_get_used_recipient",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ rnp_recipient_handle_t.ptr
+ ),
+
+ rnp_op_verify_get_recipient_at: librnp.declare(
+ "rnp_op_verify_get_recipient_at",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ ctypes.size_t,
+ rnp_recipient_handle_t.ptr
+ ),
+
+ rnp_recipient_get_keyid: librnp.declare(
+ "rnp_recipient_get_keyid",
+ abi,
+ rnp_result_t,
+ rnp_recipient_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_recipient_get_alg: librnp.declare(
+ "rnp_recipient_get_alg",
+ abi,
+ rnp_result_t,
+ rnp_recipient_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_op_verify_get_symenc_count: librnp.declare(
+ "rnp_op_verify_get_symenc_count",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ ctypes.size_t.ptr
+ ),
+
+ rnp_op_verify_get_used_symenc: librnp.declare(
+ "rnp_op_verify_get_used_symenc",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ rnp_symenc_handle_t.ptr
+ ),
+
+ rnp_op_verify_get_symenc_at: librnp.declare(
+ "rnp_op_verify_get_symenc_at",
+ abi,
+ rnp_result_t,
+ rnp_op_verify_t,
+ ctypes.size_t,
+ rnp_symenc_handle_t.ptr
+ ),
+
+ rnp_symenc_get_cipher: librnp.declare(
+ "rnp_symenc_get_cipher",
+ abi,
+ rnp_result_t,
+ rnp_symenc_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_symenc_get_aead_alg: librnp.declare(
+ "rnp_symenc_get_aead_alg",
+ abi,
+ rnp_result_t,
+ rnp_symenc_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_symenc_get_hash_alg: librnp.declare(
+ "rnp_symenc_get_hash_alg",
+ abi,
+ rnp_result_t,
+ rnp_symenc_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_symenc_get_s2k_type: librnp.declare(
+ "rnp_symenc_get_s2k_type",
+ abi,
+ rnp_result_t,
+ rnp_symenc_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_symenc_get_s2k_iterations: librnp.declare(
+ "rnp_symenc_get_s2k_iterations",
+ abi,
+ rnp_result_t,
+ rnp_symenc_handle_t,
+ ctypes.uint32_t.ptr
+ ),
+
+ rnp_key_set_expiration: librnp.declare(
+ "rnp_key_set_expiration",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_key_revoke: librnp.declare(
+ "rnp_key_revoke",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint32_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ rnp_key_export_autocrypt: librnp.declare(
+ "rnp_key_export_autocrypt",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr,
+ rnp_output_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_key_valid_till: librnp.declare(
+ "rnp_key_valid_till",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint32_t.ptr
+ ),
+
+ rnp_key_valid_till64: librnp.declare(
+ "rnp_key_valid_till64",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.uint64_t.ptr
+ ),
+
+ rnp_uid_is_valid: librnp.declare(
+ "rnp_uid_is_valid",
+ abi,
+ rnp_result_t,
+ rnp_uid_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_uid_is_primary: librnp.declare(
+ "rnp_uid_is_primary",
+ abi,
+ rnp_result_t,
+ rnp_uid_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_signature_is_valid: librnp.declare(
+ "rnp_signature_is_valid",
+ abi,
+ rnp_result_t,
+ rnp_signature_handle_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_key_get_protection_type: librnp.declare(
+ "rnp_key_get_protection_type",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_output_armor_set_line_length: librnp.declare(
+ "rnp_output_armor_set_line_length",
+ abi,
+ rnp_result_t,
+ rnp_output_t,
+ ctypes.size_t
+ ),
+
+ rnp_key_25519_bits_tweaked: librnp.declare(
+ "rnp_key_25519_bits_tweaked",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.bool.ptr
+ ),
+
+ rnp_key_25519_bits_tweak: librnp.declare(
+ "rnp_key_25519_bits_tweak",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t
+ ),
+
+ rnp_key_get_curve: librnp.declare(
+ "rnp_key_get_curve",
+ abi,
+ rnp_result_t,
+ rnp_key_handle_t,
+ ctypes.char.ptr.ptr
+ ),
+
+ rnp_get_security_rule: librnp.declare(
+ "rnp_get_security_rule",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.uint64_t,
+ ctypes.uint32_t.ptr,
+ ctypes.uint64_t.ptr,
+ ctypes.uint32_t.ptr
+ ),
+
+ rnp_add_security_rule: librnp.declare(
+ "rnp_add_security_rule",
+ abi,
+ rnp_result_t,
+ rnp_ffi_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.uint32_t,
+ ctypes.uint64_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_op_encrypt_set_aead: librnp.declare(
+ "rnp_op_encrypt_set_aead",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t,
+ ctypes.char.ptr
+ ),
+
+ rnp_op_encrypt_set_flags: librnp.declare(
+ "rnp_op_encrypt_set_flags",
+ abi,
+ rnp_result_t,
+ rnp_op_encrypt_t,
+ ctypes.uint32_t
+ ),
+
+ rnp_result_t,
+ rnp_ffi_t,
+ rnp_password_cb_t,
+ rnp_input_t,
+ rnp_output_t,
+ rnp_key_handle_t,
+ rnp_uid_handle_t,
+ rnp_identifier_iterator_t,
+ rnp_op_generate_t,
+ rnp_op_encrypt_t,
+ rnp_op_sign_t,
+ rnp_op_sign_signature_t,
+ rnp_op_verify_t,
+ rnp_op_verify_signature_t,
+ rnp_signature_handle_t,
+ rnp_recipient_handle_t,
+ rnp_symenc_handle_t,
+
+ RNP_LOAD_SAVE_PUBLIC_KEYS: 1,
+ RNP_LOAD_SAVE_SECRET_KEYS: 2,
+ RNP_LOAD_SAVE_PERMISSIVE: 256,
+
+ RNP_KEY_REMOVE_PUBLIC: 1,
+ RNP_KEY_REMOVE_SECRET: 2,
+ RNP_KEY_REMOVE_SUBKEYS: 4,
+
+ RNP_KEY_EXPORT_ARMORED: 1,
+ RNP_KEY_EXPORT_PUBLIC: 2,
+ RNP_KEY_EXPORT_SECRET: 4,
+ RNP_KEY_EXPORT_SUBKEYS: 8,
+
+ RNP_KEY_SIGNATURE_NON_SELF_SIG: 4,
+
+ RNP_SUCCESS: 0x00000000,
+
+ RNP_FEATURE_SYMM_ALG: "symmetric algorithm",
+ RNP_FEATURE_HASH_ALG: "hash algorithm",
+ RNP_FEATURE_PK_ALG: "public key algorithm",
+ RNP_ALGNAME_MD5: "MD5",
+ RNP_ALGNAME_SHA1: "SHA1",
+ RNP_ALGNAME_SM2: "SM2",
+ RNP_ALGNAME_SM3: "SM3",
+ RNP_ALGNAME_SM4: "SM4",
+
+ RNP_SECURITY_OVERRIDE: 1,
+ RNP_SECURITY_VERIFY_KEY: 2,
+ RNP_SECURITY_VERIFY_DATA: 4,
+ RNP_SECURITY_REMOVE_ALL: 65536,
+
+ RNP_SECURITY_PROHIBITED: 0,
+ RNP_SECURITY_INSECURE: 1,
+ RNP_SECURITY_DEFAULT: 2,
+
+ RNP_ENCRYPT_NOWRAP: 1,
+
+ /* Common error codes */
+ RNP_ERROR_GENERIC: 0x10000000, // 268435456
+ RNP_ERROR_BAD_FORMAT: 0x10000001, // 268435457
+ RNP_ERROR_BAD_PARAMETERS: 0x10000002, // 268435458
+ RNP_ERROR_NOT_IMPLEMENTED: 0x10000003, // 268435459
+ RNP_ERROR_NOT_SUPPORTED: 0x10000004, // 268435460
+ RNP_ERROR_OUT_OF_MEMORY: 0x10000005, // 268435461
+ RNP_ERROR_SHORT_BUFFER: 0x10000006, // 268435462
+ RNP_ERROR_NULL_POINTER: 0x10000007, // 268435463
+
+ /* Storage */
+ RNP_ERROR_ACCESS: 0x11000000, // 285212672
+ RNP_ERROR_READ: 0x11000001, // 285212673
+ RNP_ERROR_WRITE: 0x11000002, // 285212674
+
+ /* Crypto */
+ RNP_ERROR_BAD_STATE: 0x12000000, // 301989888
+ RNP_ERROR_MAC_INVALID: 0x12000001, // 301989889
+ RNP_ERROR_SIGNATURE_INVALID: 0x12000002, // 301989890
+ RNP_ERROR_KEY_GENERATION: 0x12000003, // 301989891
+ RNP_ERROR_BAD_PASSWORD: 0x12000004, // 301989892
+ RNP_ERROR_KEY_NOT_FOUND: 0x12000005, // 301989893
+ RNP_ERROR_NO_SUITABLE_KEY: 0x12000006, // 301989894
+ RNP_ERROR_DECRYPT_FAILED: 0x12000007, // 301989895
+ RNP_ERROR_RNG: 0x12000008, // 301989896
+ RNP_ERROR_SIGNING_FAILED: 0x12000009, // 301989897
+ RNP_ERROR_NO_SIGNATURES_FOUND: 0x1200000a, // 301989898
+
+ RNP_ERROR_SIGNATURE_EXPIRED: 0x1200000b, // 301989899
+
+ /* Parsing */
+ RNP_ERROR_NOT_ENOUGH_DATA: 0x13000000, // 318767104
+ RNP_ERROR_UNKNOWN_TAG: 0x13000001, // 318767105
+ RNP_ERROR_PACKET_NOT_CONSUMED: 0x13000002, // 318767106
+ RNP_ERROR_NO_USERID: 0x13000003, // 318767107
+ RNP_ERROR_EOF: 0x13000004, // 318767108
+ };
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/armor.jsm b/comm/mail/extensions/openpgp/content/modules/armor.jsm
new file mode 100644
index 0000000000..023a68158c
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/armor.jsm
@@ -0,0 +1,367 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailArmor"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+});
+
+// Locates STRing in TEXT occurring only at the beginning of a line
+function indexOfArmorDelimiter(text, str, offset) {
+ let currentOffset = offset;
+
+ while (currentOffset < text.length) {
+ let loc = text.indexOf(str, currentOffset);
+
+ if (loc === -1 || loc === 0 || text.charAt(loc - 1) == "\n") {
+ return loc;
+ }
+
+ currentOffset = loc + str.length;
+ }
+
+ return -1;
+}
+
+function searchBlankLine(str, then) {
+ var offset = str.search(/\n\s*\r?\n/);
+ if (offset === -1) {
+ return "";
+ }
+ return then(offset);
+}
+
+function indexOfNewline(str, off, then) {
+ var offset = str.indexOf("\n", off);
+ if (offset === -1) {
+ return "";
+ }
+ return then(offset);
+}
+
+var EnigmailArmor = {
+ /**
+ * Locates offsets bracketing PGP armored block in text,
+ * starting from given offset, and returns block type string.
+ *
+ * @param text: String - ASCII armored text
+ * @param offset: Number - offset to start looking for block
+ * @param indentStr: String - prefix that is used for all lines (such as "> ")
+ * @param beginIndexObj: Object - o.value will contain offset of first character of block
+ * @param endIndexObj: Object - o.value will contain offset of last character of block (newline)
+ * @param indentStrObj: Object - o.value will contain indent of 1st line
+ *
+ * @returns String - type of block found (e.g. MESSAGE, PUBLIC KEY)
+ * If no block is found, an empty String is returned;
+ */
+ locateArmoredBlock(
+ text,
+ offset,
+ indentStr,
+ beginIndexObj,
+ endIndexObj,
+ indentStrObj
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "armor.jsm: Enigmail.locateArmoredBlock: " +
+ offset +
+ ", '" +
+ indentStr +
+ "'\n"
+ );
+
+ beginIndexObj.value = -1;
+ endIndexObj.value = -1;
+
+ var beginIndex = indexOfArmorDelimiter(
+ text,
+ indentStr + "-----BEGIN PGP ",
+ offset
+ );
+
+ if (beginIndex == -1) {
+ var blockStart = text.indexOf("-----BEGIN PGP ");
+ if (blockStart >= 0) {
+ var indentStart = text.search(/\n.*-----BEGIN PGP /) + 1;
+ indentStrObj.value = text.substring(indentStart, blockStart);
+ indentStr = indentStrObj.value;
+ beginIndex = indexOfArmorDelimiter(
+ text,
+ indentStr + "-----BEGIN PGP ",
+ offset
+ );
+ }
+ }
+
+ if (beginIndex == -1) {
+ return "";
+ }
+
+ // Locate newline at end of armor header
+ offset = text.indexOf("\n", beginIndex);
+
+ if (offset == -1) {
+ return "";
+ }
+
+ var endIndex = indexOfArmorDelimiter(
+ text,
+ indentStr + "-----END PGP ",
+ offset
+ );
+
+ if (endIndex == -1) {
+ return "";
+ }
+
+ // Locate newline at end of PGP block
+ endIndex = text.indexOf("\n", endIndex);
+
+ if (endIndex == -1) {
+ // No terminating newline
+ endIndex = text.length - 1;
+ }
+
+ var blockHeader = text.substr(beginIndex, offset - beginIndex + 1);
+
+ let escapedIndentStr = indentStr.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
+ var blockRegex = new RegExp(
+ "^" + escapedIndentStr + "-----BEGIN PGP (.{1,30})-----\\s*\\r?\\n"
+ );
+
+ var matches = blockHeader.match(blockRegex);
+
+ var blockType = "";
+ if (matches && matches.length > 1) {
+ blockType = matches[1];
+ lazy.EnigmailLog.DEBUG(
+ "armor.jsm: Enigmail.locateArmoredBlock: blockType=" + blockType + "\n"
+ );
+ }
+
+ if (blockType == "UNVERIFIED MESSAGE") {
+ // Skip any unverified message block
+ return EnigmailArmor.locateArmoredBlock(
+ text,
+ endIndex + 1,
+ indentStr,
+ beginIndexObj,
+ endIndexObj,
+ indentStrObj
+ );
+ }
+
+ beginIndexObj.value = beginIndex;
+ endIndexObj.value = endIndex;
+
+ return blockType;
+ },
+
+ /**
+ * locateArmoredBlocks returns an array of ASCII Armor block positions
+ *
+ * @param text: String - text containing ASCII armored block(s)
+ *
+ * @returns Array of objects with the following structure:
+ * obj.begin: Number
+ * obj.end: Number
+ * obj.indent: String
+ * obj.blocktype: String
+ *
+ * if no block was found, an empty array is returned
+ */
+ locateArmoredBlocks(text) {
+ var beginObj = {};
+ var endObj = {};
+ var indentStrObj = {};
+ var blocks = [];
+ var i = 0;
+ var b;
+
+ while (
+ (b = EnigmailArmor.locateArmoredBlock(
+ text,
+ i,
+ "",
+ beginObj,
+ endObj,
+ indentStrObj
+ )) !== ""
+ ) {
+ blocks.push({
+ begin: beginObj.value,
+ end: endObj.value,
+ indent: indentStrObj.value ? indentStrObj.value : "",
+ blocktype: b,
+ });
+
+ i = endObj.value;
+ }
+
+ lazy.EnigmailLog.DEBUG(
+ "armor.jsm: locateArmorBlocks: Found " + blocks.length + " Blocks\n"
+ );
+ return blocks;
+ },
+
+ extractSignaturePart(signatureBlock, part) {
+ lazy.EnigmailLog.DEBUG(
+ "armor.jsm: Enigmail.extractSignaturePart: part=" + part + "\n"
+ );
+
+ return searchBlankLine(signatureBlock, function (offset) {
+ return indexOfNewline(signatureBlock, offset + 1, function (offset) {
+ var beginIndex = signatureBlock.indexOf(
+ "-----BEGIN PGP SIGNATURE-----",
+ offset + 1
+ );
+ if (beginIndex == -1) {
+ return "";
+ }
+
+ if (part === lazy.EnigmailConstants.SIGNATURE_TEXT) {
+ return signatureBlock
+ .substr(offset + 1, beginIndex - offset - 1)
+ .replace(/^- -/, "-")
+ .replace(/\n- -/g, "\n-")
+ .replace(/\r- -/g, "\r-");
+ }
+
+ return indexOfNewline(signatureBlock, beginIndex, function (offset) {
+ var endIndex = signatureBlock.indexOf(
+ "-----END PGP SIGNATURE-----",
+ offset
+ );
+ if (endIndex == -1) {
+ return "";
+ }
+
+ var signBlock = signatureBlock.substr(offset, endIndex - offset);
+
+ return searchBlankLine(signBlock, function (armorIndex) {
+ if (part == lazy.EnigmailConstants.SIGNATURE_HEADERS) {
+ return signBlock.substr(1, armorIndex);
+ }
+
+ return indexOfNewline(
+ signBlock,
+ armorIndex + 1,
+ function (armorIndex) {
+ if (part == lazy.EnigmailConstants.SIGNATURE_ARMOR) {
+ return signBlock
+ .substr(armorIndex, endIndex - armorIndex)
+ .replace(/\s*/g, "");
+ }
+ return "";
+ }
+ );
+ });
+ });
+ });
+ });
+ },
+
+ /**
+ * Remove all headers from an OpenPGP Armored message and replace them
+ * with a set of new headers.
+ *
+ * @param armorText: String - ASCII armored message
+ * @param headers: Object - key/value pairs of new headers to insert
+ *
+ * @returns String - new armored message
+ */
+ replaceArmorHeaders(armorText, headers) {
+ let text = armorText.replace(/\r\n/g, "\n");
+ let i = text.search(/\n/);
+
+ if (i < 0) {
+ return armorText;
+ }
+ let m = text.substr(0, i + 1);
+
+ for (let j in headers) {
+ m += j + ": " + headers[j] + "\n";
+ }
+
+ i = text.search(/\n\n/);
+ if (i < 0) {
+ return armorText;
+ }
+ m += text.substr(i + 1);
+
+ return m;
+ },
+
+ /**
+ * Get a list of all headers found in an armor message
+ *
+ * @param text String - ASCII armored message
+ *
+ * @returns Object: key/value pairs of headers. All keys are in lowercase.
+ */
+ getArmorHeaders(text) {
+ let headers = {};
+ let b = this.locateArmoredBlocks(text);
+
+ if (b.length === 0) {
+ return headers;
+ }
+
+ let msg = text.substr(b[0].begin);
+
+ // Escape regex chars.
+ let indent = b[0].indent.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
+ let lx = new RegExp("\\n" + indent + "\\r?\\n");
+ let hdrEnd = msg.search(lx);
+ if (hdrEnd < 0) {
+ return headers;
+ }
+
+ let lines = msg.substr(0, hdrEnd).split(/\r?\n/);
+
+ let rx = new RegExp("^" + b[0].indent + "([^: ]+)(: )(.*)");
+ // skip 1st line (ARMOR-line)
+ for (let i = 1; i < lines.length; i++) {
+ let m = lines[i].match(rx);
+ if (m && m.length >= 4) {
+ headers[m[1].toLowerCase()] = m[3];
+ }
+ }
+
+ return headers;
+ },
+
+ /**
+ * Split armored blocks into an array of strings
+ */
+ splitArmoredBlocks(keyBlockStr) {
+ let myRe = /-----BEGIN PGP (PUBLIC|PRIVATE) KEY BLOCK-----/g;
+ let myArray;
+ let retArr = [];
+ let startIndex = -1;
+ while ((myArray = myRe.exec(keyBlockStr)) !== null) {
+ if (startIndex >= 0) {
+ let s = keyBlockStr.substring(startIndex, myArray.index);
+ retArr.push(s);
+ }
+ startIndex = myArray.index;
+ }
+
+ retArr.push(keyBlockStr.substring(startIndex));
+
+ return retArr;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/constants.jsm b/comm/mail/extensions/openpgp/content/modules/constants.jsm
new file mode 100644
index 0000000000..f04f249536
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/constants.jsm
@@ -0,0 +1,183 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailConstants"];
+
+var EnigmailConstants = {
+ POSSIBLE_PGPMIME: -2081,
+
+ // possible values for
+ // - encryptByRule, signByRules, pgpmimeByRules
+ // - encryptForced, signForced, pgpmimeForced (except CONFLICT)
+ // NOTE:
+ // - values 0/1/2 are used with this fixed semantics in the persistent rules
+ // - see also enigmailEncryptionDlg.xhtml
+ ENIG_FORCE_SMIME: 3,
+ ENIG_AUTO_ALWAYS: 22,
+ ENIG_CONFLICT: 99,
+
+ ENIG_FINAL_UNDEF: -1,
+ ENIG_FINAL_NO: 0,
+ ENIG_FINAL_YES: 1,
+ ENIG_FINAL_FORCENO: 10,
+ ENIG_FINAL_FORCEYES: 11,
+ ENIG_FINAL_SMIME: 97, // use S/MIME (automatically chosen)
+ ENIG_FINAL_FORCESMIME: 98, // use S/MIME (forced by user)
+ ENIG_FINAL_CONFLICT: 99,
+
+ MIME_HANDLER_UNDEF: 0,
+ MIME_HANDLER_SMIME: 1,
+ MIME_HANDLER_PGPMIME: 2,
+
+ ICONTYPE_INFO: 1,
+ ICONTYPE_QUESTION: 2,
+ ICONTYPE_ALERT: 3,
+ ICONTYPE_ERROR: 4,
+
+ FILTER_MOVE_DECRYPT: "enigmail@enigmail.net#filterActionMoveDecrypt",
+ FILTER_COPY_DECRYPT: "enigmail@enigmail.net#filterActionCopyDecrypt",
+ FILTER_ENCRYPT: "enigmail@enigmail.net#filterActionEncrypt",
+ FILTER_TERM_PGP_ENCRYPTED: "enigmail@enigmail.net#filterTermPGPEncrypted",
+
+ /* taken over from old nsIEnigmail */
+
+ /* Cleartext signature parts */
+ SIGNATURE_TEXT: 1,
+ SIGNATURE_HEADERS: 2,
+ SIGNATURE_ARMOR: 3,
+
+ /* User interaction flags */
+ UI_INTERACTIVE: 0x01,
+ UI_ALLOW_KEY_IMPORT: 0x02,
+ UI_UNVERIFIED_ENC_OK: 0x04,
+ UI_PGP_MIME: 0x08,
+ UI_TEST: 0x10,
+ UI_RESTORE_STRICTLY_MIME: 0x20,
+ UI_IGNORE_MDC_ERROR: 0x40, // force decryption, even if we got an MDC error
+
+ /* Send message flags */
+ SEND_SIGNED: 0x0001, // 1
+ SEND_ENCRYPTED: 0x0002, // 2
+ SEND_DEFAULT: 0x0004, // 4
+ SEND_LATER: 0x0008, // 8
+ SEND_WITH_CHECK: 0x0010, // 16
+ SEND_ALWAYS_TRUST: 0x0020, // 32
+ SEND_ENCRYPT_TO_SELF: 0x0040, // 64
+ SEND_PGP_MIME: 0x0080, // 128
+ SEND_TEST: 0x0100, // 256
+ SAVE_MESSAGE: 0x0200, // 512
+ SEND_STRIP_WHITESPACE: 0x0400, // 1024
+ SEND_ATTACHMENT: 0x0800, // 2048
+ ENCRYPT_SUBJECT: 0x1000, // 4096
+ SEND_VERBATIM: 0x2000, // 8192
+ SEND_TWO_MIME_LAYERS: 0x4000, // 16384
+ SEND_SENDER_KEY_EXTERNAL: 0x8000, // 32768
+
+ /* Status flags */
+ GOOD_SIGNATURE: 0x00000001,
+ BAD_SIGNATURE: 0x00000002,
+ UNCERTAIN_SIGNATURE: 0x00000004,
+ EXPIRED_SIGNATURE: 0x00000008,
+ EXPIRED_KEY_SIGNATURE: 0x00000010,
+ EXPIRED_KEY: 0x00000020,
+ REVOKED_KEY: 0x00000040,
+ NO_PUBKEY: 0x00000080,
+ NO_SECKEY: 0x00000100,
+ IMPORTED_KEY: 0x00000200,
+ INVALID_RECIPIENT: 0x00000400,
+ MISSING_PASSPHRASE: 0x00000800,
+ BAD_PASSPHRASE: 0x00001000,
+ BAD_ARMOR: 0x00002000,
+ NODATA: 0x00004000,
+ DECRYPTION_INCOMPLETE: 0x00008000,
+ DECRYPTION_FAILED: 0x00010000,
+ DECRYPTION_OKAY: 0x00020000,
+ MISSING_MDC: 0x00040000,
+ TRUSTED_IDENTITY: 0x00080000,
+ PGP_MIME_SIGNED: 0x00100000,
+ PGP_MIME_ENCRYPTED: 0x00200000,
+ DISPLAY_MESSAGE: 0x00400000,
+ INLINE_KEY: 0x00800000,
+ PARTIALLY_PGP: 0x01000000,
+ PHOTO_AVAILABLE: 0x02000000,
+ OVERFLOWED: 0x04000000,
+ CARDCTRL: 0x08000000,
+ SC_OP_FAILURE: 0x10000000,
+ UNKNOWN_ALGO: 0x20000000,
+ SIG_CREATED: 0x40000000,
+ END_ENCRYPTION: 0x80000000,
+
+ /* Extended status flags */
+ EXT_SELF_IDENTITY: 0x00000001,
+
+ /* UI message status flags */
+ MSG_SIG_NONE: 0,
+ MSG_SIG_VALID_SELF: 1,
+ MSG_SIG_VALID_KEY_VERIFIED: 2,
+ MSG_SIG_VALID_KEY_UNVERIFIED: 3,
+ MSG_SIG_UNCERTAIN_KEY_UNAVAILABLE: 4,
+ MSG_SIG_UNCERTAIN_UID_MISMATCH: 5,
+ MSG_SIG_UNCERTAIN_KEY_NOT_ACCEPTED: 6,
+ MSG_SIG_INVALID: 7,
+ MSG_SIG_INVALID_KEY_REJECTED: 8,
+
+ MSG_ENC_NONE: 0,
+ MSG_ENC_OK: 1,
+ MSG_ENC_FAILURE: 2,
+ MSG_ENC_NO_SECRET_KEY: 3,
+
+ /*** key handling functions ***/
+
+ EXTRACT_SECRET_KEY: 0x01,
+
+ /* Keyserver Action Flags */
+ SEARCH_KEY: 1,
+ DOWNLOAD_KEY: 2,
+ UPLOAD_KEY: 3,
+ REFRESH_KEY: 4,
+ UPLOAD_WKD: 6,
+ GET_CONFIRMATION_LINK: 7,
+ DOWNLOAD_KEY_NO_IMPORT: 8,
+
+ /* attachment handling */
+
+ /* per-recipient rules */
+ AC_RULE_PREFIX: "autocrypt://",
+
+ CARD_PIN_CHANGE: 1,
+ CARD_PIN_UNBLOCK: 2,
+ CARD_ADMIN_PIN_CHANGE: 3,
+
+ /* Keyserver error codes (in keyserver.jsm) */
+ KEYSERVER_ERR_ABORTED: 1,
+ KEYSERVER_ERR_SERVER_ERROR: 2,
+ KEYSERVER_ERR_SECURITY_ERROR: 3,
+ KEYSERVER_ERR_CERTIFICATE_ERROR: 4,
+ KEYSERVER_ERR_SERVER_UNAVAILABLE: 5,
+ KEYSERVER_ERR_IMPORT_ERROR: 6,
+ KEYSERVER_ERR_UNKNOWN: 7,
+
+ /* AutocryptSeup Setup Type */
+ AUTOSETUP_NOT_INITIALIZED: 0,
+ AUTOSETUP_AC_SETUP_MSG: 1,
+ AUTOSETUP_AC_HEADER: 2,
+ AUTOSETUP_PEP_HEADER: 3,
+ AUTOSETUP_ENCRYPTED_MSG: 4,
+ AUTOSETUP_NO_HEADER: 5,
+ AUTOSETUP_NO_ACCOUNT: 6,
+
+ /* Bootstrapped Addon constants */
+ APP_STARTUP: 1, // The application is starting up.
+ APP_SHUTDOWN: 2, // The application is shutting down.
+ ADDON_ENABLE: 3, // The add-on is being enabled.
+ ADDON_DISABLE: 4, // The add-on is being disabled. (Also sent during uninstallation)
+ ADDON_INSTALL: 5, // The add-on is being installed.
+ ADDON_UNINSTALL: 6, // The add-on is being uninstalled.
+ ADDON_UPGRADE: 7, // The add-on is being upgraded.
+ ADDON_DOWNGRADE: 8, // The add-on is being downgraded.
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/core.jsm b/comm/mail/extensions/openpgp/content/modules/core.jsm
new file mode 100644
index 0000000000..a18d07415f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/core.jsm
@@ -0,0 +1,189 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailVerify: "chrome://openpgp/content/modules/mimeVerify.jsm",
+ EnigmailMimeEncrypt: "chrome://openpgp/content/modules/mimeEncrypt.jsm",
+ EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+ EnigmailPgpmimeHander: "chrome://openpgp/content/modules/pgpmimeHandler.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["EnigmailCore"];
+
+var gEnigmailService = null; // Global Enigmail Service
+
+var EnigmailCore = {
+ /**
+ * Create a new instance of Enigmail, or return the already existing one
+ */
+ createInstance() {
+ if (!gEnigmailService) {
+ gEnigmailService = new Enigmail();
+ }
+ return gEnigmailService;
+ },
+
+ async startup(reason) {
+ initializeLogDirectory();
+
+ lazy.EnigmailLog.DEBUG("core.jsm: startup()\n");
+
+ await lazy.PgpSqliteDb2.checkDatabaseStructure();
+
+ this.factories = [];
+
+ lazy.EnigmailVerify.registerPGPMimeHandler();
+ //EnigmailWksMimeHandler.registerContentTypeHandler();
+ //EnigmailFiltersWrapper.onStartup();
+
+ lazy.EnigmailMimeEncrypt.startup(reason);
+ //EnigmailOverlays.startup();
+ this.factories.push(new Factory(lazy.EnigmailMimeEncrypt.Handler));
+ },
+
+ shutdown(reason) {
+ if (this.factories) {
+ for (let fct of this.factories) {
+ fct.unregister();
+ }
+ }
+
+ //EnigmailFiltersWrapper.onShutdown();
+ lazy.EnigmailVerify.unregisterPGPMimeHandler();
+
+ lazy.EnigmailLog.onShutdown();
+
+ lazy.EnigmailLog.setLogLevel(3);
+ gEnigmailService = null;
+ },
+
+ /**
+ * get and or initialize the Enigmail service,
+ * including the handling for upgrading old preferences to new versions
+ *
+ * @win: - nsIWindow: parent window (optional)
+ * @startingPreferences - Boolean: true - called while switching to new preferences
+ * (to avoid re-check for preferences)
+ * @returns {Promise<Enigmail|null>}
+ */
+ async getService(win, startingPreferences) {
+ // Lazy initialization of Enigmail JS component (for efficiency)
+
+ if (gEnigmailService) {
+ return gEnigmailService.initialized ? gEnigmailService : null;
+ }
+
+ try {
+ this.createInstance();
+ return gEnigmailService.getService(win, startingPreferences);
+ } catch (ex) {
+ return null;
+ }
+ },
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// Enigmail encryption/decryption service
+///////////////////////////////////////////////////////////////////////////////
+
+function initializeLogDirectory() {
+ let dir = Services.prefs.getCharPref("temp.openpgp.logDirectory", "");
+ if (!dir) {
+ return;
+ }
+
+ lazy.EnigmailLog.setLogLevel(5);
+ lazy.EnigmailLog.setLogDirectory(dir);
+ lazy.EnigmailLog.DEBUG(
+ "core.jsm: Logging debug output to " + dir + "/enigdbug.txt\n"
+ );
+}
+
+function Enigmail() {
+ this.wrappedJSObject = this;
+}
+
+Enigmail.prototype = {
+ initialized: false,
+ initializationAttempted: false,
+
+ initialize(domWindow) {
+ this.initializationAttempted = true;
+
+ lazy.EnigmailLog.DEBUG("core.jsm: Enigmail.initialize: START\n");
+
+ if (this.initialized) {
+ return;
+ }
+
+ this.initialized = true;
+
+ lazy.EnigmailLog.DEBUG("core.jsm: Enigmail.initialize: END\n");
+ },
+
+ reinitialize() {
+ lazy.EnigmailLog.DEBUG("core.jsm: Enigmail.reinitialize:\n");
+ this.initialized = false;
+ this.initializationAttempted = true;
+
+ this.initialized = true;
+ },
+
+ async getService(win, startingPreferences) {
+ if (!win) {
+ win = lazy.EnigmailWindows.getBestParentWin();
+ }
+
+ lazy.EnigmailLog.DEBUG("core.jsm: svc = " + this + "\n");
+
+ if (!this.initialized) {
+ // Initialize enigmail
+ this.initialize(win);
+ }
+ await EnigmailCore.startup(0);
+ lazy.EnigmailPgpmimeHander.startup(0);
+ return this.initialized ? this : null;
+ },
+}; // Enigmail.prototype
+
+class Factory {
+ constructor(component) {
+ this.component = component;
+ this.register();
+ Object.freeze(this);
+ }
+
+ createInstance(iid) {
+ return new this.component();
+ }
+
+ register() {
+ Components.manager
+ .QueryInterface(Ci.nsIComponentRegistrar)
+ .registerFactory(
+ this.component.prototype.classID,
+ this.component.prototype.classDescription,
+ this.component.prototype.contractID,
+ this
+ );
+ }
+
+ unregister() {
+ Components.manager
+ .QueryInterface(Ci.nsIComponentRegistrar)
+ .unregisterFactory(this.component.prototype.classID, this);
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/cryptoAPI.jsm b/comm/mail/extensions/openpgp/content/modules/cryptoAPI.jsm
new file mode 100644
index 0000000000..b722d4ee7d
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/cryptoAPI.jsm
@@ -0,0 +1,32 @@
+/*
+ * 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 EXPORTED_SYMBOLS = ["EnigmailCryptoAPI", "EnigmailGnuPGAPI"];
+
+var gCurrentApi = null;
+var gGnuPGApi = null;
+
+function EnigmailCryptoAPI() {
+ if (!gCurrentApi) {
+ const { getRNPAPI } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/cryptoAPI/RNPCryptoAPI.jsm"
+ );
+ gCurrentApi = getRNPAPI();
+ }
+ return gCurrentApi;
+}
+
+function EnigmailGnuPGAPI() {
+ if (!gGnuPGApi) {
+ const { getGnuPGAPI } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/cryptoAPI/GnuPGCryptoAPI.jsm"
+ );
+ gGnuPGApi = getGnuPGAPI();
+ }
+ return gGnuPGApi;
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/cryptoAPI/GnuPGCryptoAPI.jsm b/comm/mail/extensions/openpgp/content/modules/cryptoAPI/GnuPGCryptoAPI.jsm
new file mode 100644
index 0000000000..475108292d
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/cryptoAPI/GnuPGCryptoAPI.jsm
@@ -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 EXPORTED_SYMBOLS = ["getGnuPGAPI"];
+
+Services.scriptloader.loadSubScript(
+ "chrome://openpgp/content/modules/cryptoAPI/interface.js",
+ null,
+ "UTF-8"
+); /* global CryptoAPI */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+});
+
+/**
+ * GnuPG implementation of CryptoAPI
+ */
+
+class GnuPGCryptoAPI extends CryptoAPI {
+ constructor() {
+ super();
+ this.api_name = "GnuPG";
+ }
+
+ /**
+ * Get the list of all knwn keys (including their secret keys)
+ *
+ * @param {Array of String} onlyKeys: [optional] only load data for specified key IDs
+ *
+ * @returns {Promise<Array of Object>}
+ */
+ async getKeys(onlyKeys = null) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Obtain signatures for a given set of key IDs.
+ *
+ * @param {string} keyId: space-separated list of key IDs
+ * @param {boolean} ignoreUnknownUid: if true, filter out unknown signer's UIDs
+ *
+ * @returns {Promise<Array of Object>} - see extractSignatures()
+ */
+ async getKeySignatures(keyId, ignoreUnknownUid = false) {
+ lazy.EnigmailLog.DEBUG(`gnupg.js: getKeySignatures: ${keyId}\n`);
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Obtain signatures for a given key.
+ *
+ * @param {KeyObj} keyObj: the signatures of this key will be returned
+ * @param {boolean} ignoreUnknownUid: if true, filter out unknown signer's UIDs
+ *
+ * @returns {Promise<Array of Object>} - see extractSignatures()
+ */
+ async getKeyObjSignatures(keyObj, ignoreUnknownUid = false) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Export the minimum key for the public key object:
+ * public key, primary user ID, newest encryption subkey
+ *
+ * @param {string} fpr: - a single FPR
+ * @param {string} email: - [optional] the email address of the desired user ID.
+ * If the desired user ID cannot be found or is not valid, use the primary UID instead
+ * @param {Array<number>} subkeyDates: [optional] remove subkeys with specific creation Dates
+ *
+ * @returns {Promise<object>}:
+ * - exitCode (0 = success)
+ * - errorMsg (if exitCode != 0)
+ * - keyData: BASE64-encded string of key data
+ */
+ async getMinimalPubKey(fpr, email, subkeyDates) {
+ lazy.EnigmailLog.DEBUG(`gnupg.js: getMinimalPubKey: ${fpr}\n`);
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Export secret key(s) to a file
+ *
+ * @param {string} keyId Specification by fingerprint or keyID
+ * @param {boolean} minimalKey - if true, reduce key to minimum required
+ *
+ * @returns {object}:
+ * - {Number} exitCode: result code (0: OK)
+ * - {String} keyData: ASCII armored key data material
+ * - {String} errorMsg: error message in case exitCode !== 0
+ */
+
+ async extractSecretKey(keyId, minimalKey) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ *
+ * @param {byte} byteData - The encrypted data
+ *
+ * @returns {String or null} - the name of the attached file
+ */
+
+ async getFileName(byteData) {
+ lazy.EnigmailLog.DEBUG(`gnupg.js: getFileName()\n`);
+ throw new Error("Not implemented");
+ }
+
+ /**
+ *
+ * @param {Path} filePath - The signed file
+ * @param {Path} sigPath - The signature to verify
+ *
+ * @returns {Promise<string>} - A message from the verification.
+ *
+ * Use Promise.catch to handle failed verifications.
+ * The message will be an error message in this case.
+ */
+
+ async verifyAttachment(filePath, sigPath) {
+ lazy.EnigmailLog.DEBUG(`gnupg.js: verifyAttachment()\n`);
+ throw new Error("Not implemented");
+ }
+
+ /**
+ *
+ * @param {Bytes} encrypted The encrypted data
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async decryptAttachment(encrypted) {
+ lazy.EnigmailLog.DEBUG(`gnupg.js: decryptAttachment()\n`);
+ throw new Error("Not implemented");
+ }
+
+ /**
+ *
+ * @param {string} encrypted - The encrypted data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async decrypt(encrypted, options) {
+ lazy.EnigmailLog.DEBUG(`gnupg.js: decrypt()\n`);
+ throw new Error("Not implemented");
+ }
+
+ /**
+ *
+ * @param {string} encrypted - The encrypted data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async decryptMime(encrypted, options) {
+ lazy.EnigmailLog.DEBUG(`gnupg.js: decryptMime()\n`);
+
+ // write something to gpg such that the process doesn't get stuck
+ if (encrypted.length === 0) {
+ encrypted = "NO DATA\n";
+ }
+
+ options.noOutput = false;
+ options.verifyOnly = false;
+ options.uiFlags = lazy.EnigmailConstants.UI_PGP_MIME;
+
+ return this.decrypt(encrypted, options);
+ }
+
+ /**
+ *
+ * @param {string} signed - The signed data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async verifyMime(signed, options) {
+ lazy.EnigmailLog.DEBUG(`gnupg.js: verifyMime()\n`);
+
+ options.noOutput = true;
+ options.verifyOnly = true;
+ options.uiFlags = lazy.EnigmailConstants.UI_PGP_MIME;
+
+ return this.decrypt(signed, options);
+ }
+
+ async getKeyListFromKeyBlockAPI(keyBlockStr) {
+ throw new Error("Not implemented");
+ }
+
+ async genKey(userId, keyType, keySize, expiryTime, passphrase) {
+ throw new Error("GnuPG genKey() not implemented");
+ }
+
+ async deleteKey(keyFingerprint, deleteSecret) {
+ return null;
+ }
+
+ async encryptAndOrSign(plaintext, args, resultStatus) {
+ return null;
+ }
+}
+
+function getGnuPGAPI() {
+ return new GnuPGCryptoAPI();
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/cryptoAPI/RNPCryptoAPI.jsm b/comm/mail/extensions/openpgp/content/modules/cryptoAPI/RNPCryptoAPI.jsm
new file mode 100644
index 0000000000..6b03bf3c6f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/cryptoAPI/RNPCryptoAPI.jsm
@@ -0,0 +1,282 @@
+/*
+ * 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 EXPORTED_SYMBOLS = ["getRNPAPI"];
+
+const { RNP } = ChromeUtils.import("chrome://openpgp/content/modules/RNP.jsm");
+
+Services.scriptloader.loadSubScript(
+ "chrome://openpgp/content/modules/cryptoAPI/interface.js",
+ null,
+ "UTF-8"
+); /* global CryptoAPI */
+
+const { EnigmailLog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/log.jsm"
+);
+const { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+
+/**
+ * RNP implementation of CryptoAPI
+ */
+class RNPCryptoAPI extends CryptoAPI {
+ constructor() {
+ super();
+ this.api_name = "RNP";
+ }
+
+ /**
+ * Get the list of all knwn keys (including their secret keys)
+ *
+ * @param {Array of String} onlyKeys: [optional] only load data for specified key IDs
+ *
+ * @returns {Promise<Array of Object>}
+ */
+ async getKeys(onlyKeys = null) {
+ return RNP.getKeys(onlyKeys);
+ }
+
+ /**
+ * Obtain signatures for a given set of key IDs.
+ *
+ * @param {string} keyId: space-separated list of key IDs
+ * @param {boolean} ignoreUnknownUid: if true, filter out unknown signer's UIDs
+ *
+ * @returns {Promise<Array of Object>} - see extractSignatures()
+ */
+ async getKeySignatures(keyId, ignoreUnknownUid = false) {
+ return RNP.getKeySignatures(keyId, ignoreUnknownUid);
+ }
+
+ /**
+ * Obtain signatures for a given key.
+ *
+ * @param {KeyObj} keyObj: the signatures of this key will be returned
+ * @param {boolean} ignoreUnknownUid: if true, filter out unknown signer's UIDs
+ *
+ * @returns {Promise<Array of Object>} - see extractSignatures()
+ */
+ async getKeyObjSignatures(keyId, ignoreUnknownUid = false) {
+ return RNP.getKeyObjSignatures(keyId, ignoreUnknownUid);
+ }
+
+ /**
+ * Export the minimum key for the public key object:
+ * public key, primary user ID, newest encryption subkey
+ *
+ * @param {string} fpr: - a single FPR
+ * @param {string} email: - [optional] the email address of the desired user ID.
+ * If the desired user ID cannot be found or is not valid, use the primary UID instead
+ * @param {Array<number>} subkeyDates: [optional] remove subkeys with specific creation Dates
+ *
+ * @returns {Promise<object>}:
+ * - exitCode (0 = success)
+ * - errorMsg (if exitCode != 0)
+ * - keyData: BASE64-encded string of key data
+ */
+ async getMinimalPubKey(fpr, email, subkeyDates) {
+ throw new Error("Not implemented");
+ }
+
+ async importPubkeyBlockAutoAcceptAPI(
+ win,
+ keyBlock,
+ acceptance,
+ permissive,
+ limitedFPRs = []
+ ) {
+ let res = await RNP.importPubkeyBlockAutoAcceptImpl(
+ win,
+ keyBlock,
+ acceptance,
+ permissive,
+ limitedFPRs
+ );
+ return res;
+ }
+
+ async importRevBlockAPI(data) {
+ return RNP.importRevImpl(data);
+ }
+
+ /**
+ * Export secret key(s) to a file
+ *
+ * @param {string} keyId Specification by fingerprint or keyID
+ * @param {boolean} minimalKey - if true, reduce key to minimum required
+ *
+ * @returns {object}:
+ * - {Number} exitCode: result code (0: OK)
+ * - {String} keyData: ASCII armored key data material
+ * - {String} errorMsg: error message in case exitCode !== 0
+ */
+
+ async extractSecretKey(keyId, minimalKey) {
+ throw new Error("extractSecretKey not implemented");
+ }
+
+ /**
+ *
+ * @param {byte} byteData - The encrypted data
+ *
+ * @returns {String or null} - the name of the attached file
+ */
+
+ async getFileName(byteData) {
+ throw new Error("getFileName not implemented");
+ }
+
+ /**
+ *
+ * @param {Path} filePath - The signed file
+ * @param {Path} sigPath - The signature to verify
+ *
+ * @returns {Promise<string>} - A message from the verification.
+ *
+ * Use Promise.catch to handle failed verifications.
+ * The message will be an error message in this case.
+ */
+
+ async verifyAttachment(filePath, sigPath) {
+ throw new Error("verifyAttachment not implemented");
+ }
+
+ /**
+ *
+ * @param {Bytes} encrypted The encrypted data
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async decryptAttachment(encrypted) {
+ let options = {};
+ options.fromAddr = "";
+ options.msgDate = null;
+ return RNP.decrypt(encrypted, options);
+ }
+
+ /**
+ *
+ * @param {string} encrypted - The encrypted data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ * XXX: it's not... ^^^ This should be changed to always reject
+ * by throwing an Error (subclass?) for failures to decrypt.
+ */
+
+ async decrypt(encrypted, options) {
+ EnigmailLog.DEBUG(`rnp-cryptoAPI.js: decrypt()\n`);
+
+ return RNP.decrypt(encrypted, options);
+ }
+
+ /**
+ *
+ * @param {string} encrypted - The encrypted data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async decryptMime(encrypted, options) {
+ EnigmailLog.DEBUG(`rnp-cryptoAPI.js: decryptMime()\n`);
+
+ // write something to gpg such that the process doesn't get stuck
+ if (encrypted.length === 0) {
+ encrypted = "NO DATA\n";
+ }
+
+ options.noOutput = false;
+ options.verifyOnly = false;
+ options.uiFlags = EnigmailConstants.UI_PGP_MIME;
+
+ return this.decrypt(encrypted, options);
+ }
+
+ /**
+ *
+ * @param {string} signed - The signed data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async verifyMime(signed, options) {
+ EnigmailLog.DEBUG(`rnp-cryptoAPI.js: verifyMime()\n`);
+
+ //options.noOutput = true;
+ //options.verifyOnly = true;
+ //options.uiFlags = EnigmailConstants.UI_PGP_MIME;
+
+ if (!options.mimeSignatureData) {
+ throw new Error("inline verify not yet implemented");
+ }
+ return RNP.verifyDetached(signed, options);
+ }
+
+ async getKeyListFromKeyBlockAPI(
+ keyBlockStr,
+ pubkey,
+ seckey,
+ permissive,
+ withPubKey
+ ) {
+ return RNP.getKeyListFromKeyBlockImpl(
+ keyBlockStr,
+ pubkey,
+ seckey,
+ permissive,
+ withPubKey
+ );
+ }
+
+ async genKey(userId, keyType, keySize, expiryTime, passphrase) {
+ let id = RNP.genKey(userId, keyType, keySize, expiryTime, passphrase);
+ await RNP.saveKeyRings();
+ return id;
+ }
+
+ async deleteKey(keyFingerprint, deleteSecret) {
+ return RNP.deleteKey(keyFingerprint, deleteSecret);
+ }
+
+ async encryptAndOrSign(plaintext, args, resultStatus) {
+ return RNP.encryptAndOrSign(plaintext, args, resultStatus);
+ }
+
+ async unlockAndGetNewRevocation(id, pass) {
+ return RNP.unlockAndGetNewRevocation(id, pass);
+ }
+
+ async getPublicKey(id) {
+ return RNP.getPublicKey(id);
+ }
+}
+
+function getRNPAPI() {
+ return new RNPCryptoAPI();
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/cryptoAPI/interface.js b/comm/mail/extensions/openpgp/content/modules/cryptoAPI/interface.js
new file mode 100644
index 0000000000..eb2419a2e1
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/cryptoAPI/interface.js
@@ -0,0 +1,288 @@
+/*
+ * 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";
+
+/**
+ * CryptoAPI - abstract interface
+ */
+
+var inspector;
+
+class CryptoAPI {
+ constructor() {
+ this.api_name = "null";
+ }
+
+ get apiName() {
+ return this.api_name;
+ }
+
+ /**
+ * Synchronize a promise: wait synchonously until a promise has completed and return
+ * the value that the promise returned.
+ *
+ * @param {Promise} promise: the promise to wait for
+ *
+ * @returns {Variant} whatever the promise returns
+ */
+ sync(promise) {
+ if (!inspector) {
+ inspector = Cc["@mozilla.org/jsinspector;1"].createInstance(
+ Ci.nsIJSInspector
+ );
+ }
+
+ let res = null;
+ promise
+ .then(gotResult => {
+ res = gotResult;
+ inspector.exitNestedEventLoop();
+ })
+ .catch(gotResult => {
+ console.log("CryptoAPI.sync() failed result: %o", gotResult);
+ if (gotResult instanceof Error) {
+ inspector.exitNestedEventLoop();
+ throw gotResult;
+ }
+
+ res = gotResult;
+ inspector.exitNestedEventLoop();
+ });
+
+ inspector.enterNestedEventLoop(0);
+ return res;
+ }
+
+ /**
+ * Obtain signatures for a given set of key IDs.
+ *
+ * @param {string} keyId: space-separated list of key IDs
+ * @param {boolean} ignoreUnknownUid: if true, filter out unknown signer's UIDs
+ *
+ * @returns {Promise<Array of Object>} - see extractSignatures()
+ */
+ async getKeySignatures(keyId, ignoreUnknownUid = false) {
+ return null;
+ }
+
+ /**
+ * Obtain signatures for a given key.
+ *
+ * @param {KeyObj} keyObj: the signatures of this key will be returned
+ * @param {boolean} ignoreUnknownUid: if true, filter out unknown signer's UIDs
+ *
+ * @returns {Promise<Array of Object>} - see extractSignatures()
+ */
+ async getKeyObjSignatures(keyObj, ignoreUnknownUid = false) {
+ return null;
+ }
+
+ /**
+ * Export the minimum key for the public key object:
+ * public key, user ID, newest encryption subkey
+ *
+ * @param {string} fpr - : a single FPR
+ * @param {string} email: [optional] the email address of the desired user ID.
+ * If the desired user ID cannot be found or is not valid, use the primary UID instead
+ *
+ * @returns {Promise<object>}:
+ * - exitCode (0 = success)
+ * - errorMsg (if exitCode != 0)
+ * - keyData: BASE64-encded string of key data
+ */
+ async getMinimalPubKey(fpr, email) {
+ return {
+ exitCode: -1,
+ errorMsg: "",
+ keyData: "",
+ };
+ }
+
+ /**
+ * Get the list of all known keys (including their secret keys)
+ *
+ * @param {Array of String} onlyKeys: [optional] only load data for specified key IDs
+ *
+ * @returns {Promise<Array of Object>}
+ */
+ async getKeys(onlyKeys = null) {
+ return [];
+ }
+
+ async importPubkeyBlockAutoAccept(keyBlock) {
+ return null;
+ }
+
+ // return bool success
+ async importRevBlockAPI(data) {
+ return null;
+ }
+
+ /**
+ * Export secret key(s) to a file
+ *
+ * @param {string} keyId Specification by fingerprint or keyID
+ * @param {boolean} minimalKey - if true, reduce key to minimum required
+ *
+ * @returns {object}:
+ * - {Number} exitCode: result code (0: OK)
+ * - {String} keyData: ASCII armored key data material
+ * - {String} errorMsg: error message in case exitCode !== 0
+ */
+
+ async extractSecretKey(keyId, minimalKey) {
+ return null;
+ }
+
+ /**
+ * Determine the file name from OpenPGP data.
+ *
+ * @param {byte} byteData - The encrypted data
+ *
+ * @returns {string} - the name of the attached file
+ */
+
+ async getFileName(byteData) {
+ return null;
+ }
+
+ /**
+ * Verify the detached signature of an attachment (or in other words,
+ * check the signature of a file, given the file and the signature).
+ *
+ * @param {Path} filePath - The signed file
+ * @param {Path} sigPath - The signature to verify
+ *
+ * @returns {Promise<string>} - A message from the verification.
+ *
+ * Use Promise.catch to handle failed verifications.
+ * The message will be an error message in this case.
+ */
+
+ async verifyAttachment(filePath, sigPath) {
+ return null;
+ }
+
+ /**
+ * Decrypt an attachment.
+ *
+ * @param {Bytes} encrypted The encrypted data
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async decryptAttachment(encrypted) {
+ return null;
+ }
+
+ /**
+ * Generic function to decrypt and/or verify an OpenPGP message.
+ *
+ * @param {string} encrypted - The encrypted data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async decrypt(encrypted, options) {
+ return null;
+ }
+
+ /**
+ * Decrypt a PGP/MIME-encrypted message
+ *
+ * @param {string} encrypted - The encrypted data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async decryptMime(encrypted, options) {
+ return null;
+ }
+
+ /**
+ * Verify a PGP/MIME-signed message
+ *
+ * @param {string} signed - The signed data
+ * @param {object} options - Decryption options
+ *
+ * @returns {Promise<object>} - Return object with decryptedData and
+ * status information
+ *
+ * Use Promise.catch to handle failed decryption.
+ * retObj.errorMsg will be an error message in this case.
+ */
+
+ async verifyMime(signed, options) {
+ return null;
+ }
+
+ /**
+ * Get details (key ID, UID) of the data contained in a OpenPGP key block
+ *
+ * @param {string} keyBlockStr - String: the contents of one or more public keys
+ *
+ * @returns {Promise<Array>}: array of objects with the following structure:
+ * - id (key ID)
+ * - fpr
+ * - name (the UID of the key)
+ */
+
+ async getKeyListFromKeyBlockAPI(keyBlockStr) {
+ return null;
+ }
+
+ /**
+ * Create a new private key pair, including appropriate sub key pair,
+ * and store the new keys in the default keyrings.
+ *
+ * @param {string} userId - User ID string, with name and email.
+ * @param {string} keyType - "RSA" or "ECC".
+ * ECC uses EDDSA and ECDH/Curve25519.
+ * @param {number} keySize - RSA key size. Ignored for ECC.
+ * @param {number} expiryTime The number of days the key will remain valid
+ * (after the creation date).
+ * Set to zero for no expiration.
+ * @param {string} passphrase The passphrase to protect the new key.
+ * Set to null to use an empty passphrase.
+ *
+ * @returns {Promise<string>} - The new KeyID
+ */
+
+ async genKey(userId, keyType, keySize, expiryTime, passphrase) {
+ return null;
+ }
+
+ async deleteKey(keyFingerprint, deleteSecret) {
+ return null;
+ }
+
+ async encryptAndOrSign(plaintext, args, resultStatus) {
+ return null;
+ }
+
+ async unlockAndGetNewRevocation(id, pass) {
+ return null;
+ }
+
+ async getPublicKey(id) {
+ return null;
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/data.jsm b/comm/mail/extensions/openpgp/content/modules/data.jsm
new file mode 100644
index 0000000000..0dd5cf451f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/data.jsm
@@ -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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailData"];
+
+const SCRIPTABLEUNICODECONVERTER_CONTRACTID =
+ "@mozilla.org/intl/scriptableunicodeconverter";
+
+const HEX_TABLE = "0123456789abcdef";
+
+function converter(charset) {
+ let unicodeConv = Cc[SCRIPTABLEUNICODECONVERTER_CONTRACTID].getService(
+ Ci.nsIScriptableUnicodeConverter
+ );
+ unicodeConv.charset = charset || "utf-8";
+ return unicodeConv;
+}
+
+var EnigmailData = {
+ getUnicodeData(data) {
+ if (!data) {
+ throw new Error("EnigmailData.getUnicodeData invalid parameter");
+ }
+ // convert output to Unicode
+ var tmpStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ tmpStream.setData(data, data.length);
+ var inStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ inStream.init(tmpStream);
+ return inStream.read(tmpStream.available());
+ },
+
+ decodeQuotedPrintable(str) {
+ return unescape(
+ str.replace(/%/g, "=25").replace(new RegExp("=", "g"), "%")
+ );
+ },
+
+ decodeBase64(str) {
+ return atob(str.replace(/[\s\r\n]*/g, ""));
+ },
+
+ /***
+ * Encode a string in base64, with a max. line length of 72 characters
+ */
+ encodeBase64(str) {
+ return btoa(str).replace(/(.{72})/g, "$1\r\n");
+ },
+
+ convertToUnicode(text, charset) {
+ if (!text || (charset && charset.toLowerCase() == "iso-8859-1")) {
+ return text;
+ }
+
+ // Encode plaintext
+ let buffer = Uint8Array.from(text, c => c.charCodeAt(0));
+ return new TextDecoder(charset).decode(buffer);
+ },
+
+ convertFromUnicode(text, charset) {
+ if (!text) {
+ return "";
+ }
+
+ let conv = converter(charset);
+ let result = conv.ConvertFromUnicode(text);
+ result += conv.Finish();
+ return result;
+ },
+
+ convertGpgToUnicode(text) {
+ if (typeof text === "string") {
+ text = text.replace(/\\x3a/gi, "\\e3A");
+ var a = text.search(/\\x[0-9a-fA-F]{2}/);
+ while (a >= 0) {
+ var ch = unescape("%" + text.substr(a + 2, 2));
+ var r = new RegExp("\\" + text.substr(a, 4));
+ text = text.replace(r, ch);
+
+ a = text.search(/\\x[0-9a-fA-F]{2}/);
+ }
+
+ text = EnigmailData.convertToUnicode(text, "utf-8").replace(
+ /\\e3A/g,
+ ":"
+ );
+ }
+
+ return text;
+ },
+
+ pack(value, bytes) {
+ let str = "";
+ let mask = 0xff;
+ for (let j = 0; j < bytes; j++) {
+ str = String.fromCharCode((value & mask) >> (j * 8)) + str;
+ mask <<= 8;
+ }
+
+ return str;
+ },
+
+ unpack(str) {
+ let len = str.length;
+ let value = 0;
+
+ for (let j = 0; j < len; j++) {
+ value <<= 8;
+ value |= str.charCodeAt(j);
+ }
+
+ return value;
+ },
+
+ bytesToHex(str) {
+ let len = str.length;
+
+ let hex = "";
+ for (let j = 0; j < len; j++) {
+ let charCode = str.charCodeAt(j);
+ hex +=
+ HEX_TABLE.charAt((charCode & 0xf0) >> 4) +
+ HEX_TABLE.charAt(charCode & 0x0f);
+ }
+
+ return hex;
+ },
+
+ /**
+ * Convert an ArrayBuffer (or Uint8Array) object into a string
+ */
+ arrayBufferToString(buffer) {
+ const MAXLEN = 102400;
+
+ let uArr = new Uint8Array(buffer);
+ let ret = "";
+ let len = buffer.byteLength;
+
+ for (let j = 0; j < Math.floor(len / MAXLEN) + 1; j++) {
+ ret += String.fromCharCode.apply(
+ null,
+ uArr.subarray(j * MAXLEN, (j + 1) * MAXLEN)
+ );
+ }
+
+ return ret;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/decryption.jsm b/comm/mail/extensions/openpgp/content/modules/decryption.jsm
new file mode 100644
index 0000000000..c7f6188eeb
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/decryption.jsm
@@ -0,0 +1,639 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+/* eslint-disable complexity */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailDecryption"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailArmor: "chrome://openpgp/content/modules/armor.jsm",
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKey: "chrome://openpgp/content/modules/key.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+function statusObjectFrom(
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj,
+ blockSeparationObj,
+ encToDetailsObj
+) {
+ return {
+ signature: signatureObj,
+ exitCode: exitCodeObj,
+ statusFlags: statusFlagsObj,
+ keyId: keyIdObj,
+ userId: userIdObj,
+ sigDetails: sigDetailsObj,
+ message: errorMsgObj,
+ blockSeparation: blockSeparationObj,
+ encToDetails: encToDetailsObj,
+ };
+}
+
+function newStatusObject() {
+ return statusObjectFrom(
+ {
+ value: "",
+ },
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {}
+ );
+}
+
+var EnigmailDecryption = {
+ isReady(win) {
+ // this used to return false while generating a key. still necessary?
+ return lazy.EnigmailCore.getService(win);
+ },
+
+ getFromAddr(win) {
+ var fromAddr;
+ if (win?.gMessage) {
+ fromAddr = win.gMessage.author;
+ }
+ if (fromAddr) {
+ try {
+ fromAddr = lazy.EnigmailFuncs.stripEmail(fromAddr);
+ if (fromAddr.search(/[a-zA-Z0-9]@.*[\(\)]/) >= 0) {
+ fromAddr = false;
+ }
+ } catch (ex) {
+ fromAddr = false;
+ }
+ }
+
+ return fromAddr;
+ },
+
+ getMsgDate(win) {
+ // Sometimes the "dateInSeconds" attribute is missing.
+ // "date" appears to be available more reliably, and it appears
+ // to be in microseconds (1/1000000 second). Convert
+ // to milliseconds (1/1000 of a second) for conversion to Date.
+ if (win?.gMessage) {
+ return new Date(win.gMessage.date / 1000);
+ }
+ return null;
+ },
+
+ /**
+ * Decrypts a PGP ciphertext and returns the the plaintext
+ *
+ *in @parent a window object
+ *in @uiFlags see flag options in EnigmailConstants, UI_INTERACTIVE, UI_ALLOW_KEY_IMPORT
+ *in @cipherText a string containing a PGP Block
+ *out @signatureObj
+ *out @exitCodeObj contains the exit code
+ *out @statusFlagsObj see status flags in nslEnigmail.idl, GOOD_SIGNATURE, BAD_SIGNATURE
+ *out @keyIdObj holds the key id
+ *out @userIdObj holds the user id
+ *out @sigDetailsObj
+ *out @errorMsgObj error string
+ *out @blockSeparationObj
+ *out @encToDetailsObj returns in details, which keys the message was encrypted for (ENC_TO entries)
+ *
+ * @returns string plaintext ("" if error)
+ *
+ */
+ decryptMessage(
+ parent,
+ uiFlags,
+ cipherText,
+ msgDate,
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj,
+ blockSeparationObj,
+ encToDetailsObj
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "decryption.jsm: decryptMessage(" +
+ cipherText.length +
+ " bytes, " +
+ uiFlags +
+ ")\n"
+ );
+
+ if (!cipherText) {
+ return "";
+ }
+
+ //var interactive = uiFlags & EnigmailConstants.UI_INTERACTIVE;
+ var allowImport = false; // uiFlags & EnigmailConstants.UI_ALLOW_KEY_IMPORT;
+ var unverifiedEncryptedOK =
+ uiFlags & lazy.EnigmailConstants.UI_UNVERIFIED_ENC_OK;
+ var oldSignature = signatureObj.value;
+
+ lazy.EnigmailLog.DEBUG(
+ "decryption.jsm: decryptMessage: oldSignature=" + oldSignature + "\n"
+ );
+
+ signatureObj.value = "";
+ exitCodeObj.value = -1;
+ statusFlagsObj.value = 0;
+ statusFlagsObj.ext = 0;
+ keyIdObj.value = "";
+ userIdObj.value = "";
+ errorMsgObj.value = "";
+
+ var beginIndexObj = {};
+ var endIndexObj = {};
+ var indentStrObj = {};
+ var blockType = lazy.EnigmailArmor.locateArmoredBlock(
+ cipherText,
+ 0,
+ "",
+ beginIndexObj,
+ endIndexObj,
+ indentStrObj
+ );
+ if (!blockType || blockType == "SIGNATURE") {
+ // return without displaying a message
+ return "";
+ }
+
+ var publicKey = blockType == "PUBLIC KEY BLOCK";
+
+ var verifyOnly = blockType == "SIGNED MESSAGE";
+ var isEncrypted = blockType == "MESSAGE";
+
+ if (verifyOnly) {
+ statusFlagsObj.value |= lazy.EnigmailConstants.PGP_MIME_SIGNED;
+ }
+ if (isEncrypted) {
+ statusFlagsObj.value |= lazy.EnigmailConstants.PGP_MIME_ENCRYPTED;
+ }
+
+ var pgpBlock = cipherText.substr(
+ beginIndexObj.value,
+ endIndexObj.value - beginIndexObj.value + 1
+ );
+
+ if (indentStrObj.value) {
+ // Escape regex chars.
+ indentStrObj.value = indentStrObj.value.replace(
+ /[.*+\-?^${}()|[\]\\]/g,
+ "\\$&"
+ );
+ var indentRegexp = new RegExp("^" + indentStrObj.value, "gm");
+ pgpBlock = pgpBlock.replace(indentRegexp, "");
+ if (indentStrObj.value.substr(-1) == " ") {
+ var indentRegexpStr = "^" + indentStrObj.value.replace(/ $/m, "$");
+ indentRegexp = new RegExp(indentRegexpStr, "gm");
+ pgpBlock = pgpBlock.replace(indentRegexp, "");
+ }
+ }
+
+ // HACK to better support messages from Outlook: if there are empty lines, drop them
+ if (pgpBlock.search(/MESSAGE-----\r?\n\r?\nVersion/) >= 0) {
+ lazy.EnigmailLog.DEBUG(
+ "decryption.jsm: decryptMessage: apply Outlook empty line workaround\n"
+ );
+ pgpBlock = pgpBlock.replace(/\r?\n\r?\n/g, "\n");
+ }
+
+ var tail = cipherText.substr(
+ endIndexObj.value + 1,
+ cipherText.length - endIndexObj.value - 1
+ );
+
+ if (publicKey) {
+ // TODO: import key into our scratch area for new, unknown keys
+ if (!allowImport) {
+ errorMsgObj.value = lazy.l10n.formatValueSync("key-in-message-body");
+ statusFlagsObj.value |= lazy.EnigmailConstants.DISPLAY_MESSAGE;
+ statusFlagsObj.value |= lazy.EnigmailConstants.INLINE_KEY;
+
+ return "";
+ }
+
+ // Import public key
+ exitCodeObj.value = lazy.EnigmailKeyRing.importKey(
+ parent,
+ true,
+ pgpBlock,
+ false,
+ "",
+ errorMsgObj,
+ {}, // importedKeysObj
+ false,
+ [],
+ false // don't use prompt for permissive
+ );
+ if (exitCodeObj.value === 0) {
+ statusFlagsObj.value |= lazy.EnigmailConstants.IMPORTED_KEY;
+ }
+ return "";
+ }
+
+ var newSignature = "";
+
+ if (verifyOnly) {
+ newSignature = lazy.EnigmailArmor.extractSignaturePart(
+ pgpBlock,
+ lazy.EnigmailConstants.SIGNATURE_ARMOR
+ );
+ if (oldSignature && newSignature != oldSignature) {
+ lazy.EnigmailLog.ERROR(
+ "enigmail.js: Enigmail.decryptMessage: Error - signature mismatch " +
+ newSignature +
+ "\n"
+ );
+ errorMsgObj.value = lazy.l10n.formatValueSync("sig-mismatch");
+ statusFlagsObj.value |= lazy.EnigmailConstants.DISPLAY_MESSAGE;
+
+ return "";
+ }
+ }
+
+ if (!lazy.EnigmailCore.getService(parent)) {
+ statusFlagsObj.value |= lazy.EnigmailConstants.DISPLAY_MESSAGE;
+ throw new Error("decryption.jsm: decryptMessage: not yet initialized");
+ //return "";
+ }
+
+ /*
+ if (EnigmailKeyRing.isGeneratingKey()) {
+ errorMsgObj.value = "Error - key generation not yet completed";
+ statusFlagsObj.value |= EnigmailConstants.DISPLAY_MESSAGE;
+ return "";
+ }
+ */
+
+ // limit output to 100 times message size to avoid DoS attack
+ var maxOutput = pgpBlock.length * 100;
+ let options = {
+ fromAddr: EnigmailDecryption.getFromAddr(parent),
+ verifyOnly,
+ noOutput: false,
+ maxOutputLength: maxOutput,
+ uiFlags,
+ msgDate,
+ };
+ const cApi = lazy.EnigmailCryptoAPI();
+ let result = cApi.sync(cApi.decrypt(pgpBlock, options));
+ lazy.EnigmailLog.DEBUG(
+ "decryption.jsm: decryptMessage: decryption finished\n"
+ );
+ if (!result) {
+ console.debug("EnigmailCryptoAPI.decrypt() failed with empty result");
+ return "";
+ }
+
+ let plainText = this.getPlaintextFromDecryptResult(result);
+ exitCodeObj.value = result.exitCode;
+ statusFlagsObj.value = result.statusFlags;
+ errorMsgObj.value = result.errorMsg;
+
+ userIdObj.value = result.userId;
+ keyIdObj.value = result.keyId;
+ sigDetailsObj.value = result.sigDetails;
+ if (encToDetailsObj) {
+ encToDetailsObj.value = result.encToDetails;
+ }
+ blockSeparationObj.value = result.blockSeparation;
+
+ if (tail.search(/\S/) >= 0) {
+ statusFlagsObj.value |= lazy.EnigmailConstants.PARTIALLY_PGP;
+ }
+
+ if (exitCodeObj.value === 0) {
+ // Normal return
+
+ let doubleDashSeparator = Services.prefs.getBoolPref(
+ "doubleDashSeparator",
+ false
+ );
+
+ if (doubleDashSeparator && plainText.search(/(\r|\n)-- +(\r|\n)/) < 0) {
+ // Workaround for MsgCompose stripping trailing spaces from sig separator
+ plainText = plainText.replace(/(\r|\n)--(\r|\n)/, "$1-- $2");
+ }
+
+ statusFlagsObj.value |= lazy.EnigmailConstants.DISPLAY_MESSAGE;
+
+ if (verifyOnly && indentStrObj.value) {
+ plainText = plainText.replace(/^/gm, indentStrObj.value);
+ }
+
+ return EnigmailDecryption.inlineInnerVerification(
+ parent,
+ uiFlags,
+ plainText,
+ statusObjectFrom(
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj,
+ blockSeparationObj,
+ encToDetailsObj
+ )
+ );
+ }
+
+ var pubKeyId = keyIdObj.value;
+
+ if (statusFlagsObj.value & lazy.EnigmailConstants.BAD_SIGNATURE) {
+ if (verifyOnly && indentStrObj.value) {
+ // Probably replied message that could not be verified
+ errorMsgObj.value =
+ lazy.l10n.formatValueSync("unverified-reply") +
+ "\n\n" +
+ errorMsgObj.value;
+ return "";
+ }
+
+ // Return bad signature (for checking later)
+ signatureObj.value = newSignature;
+ } else if (
+ pubKeyId &&
+ statusFlagsObj.value & lazy.EnigmailConstants.UNCERTAIN_SIGNATURE
+ ) {
+ // TODO: import into scratch area
+ /*
+ var innerKeyBlock;
+ if (verifyOnly) {
+ // Search for indented public key block in signed message
+ var innerBlockType = EnigmailArmor.locateArmoredBlock(
+ pgpBlock,
+ 0,
+ "- ",
+ beginIndexObj,
+ endIndexObj,
+ indentStrObj
+ );
+ if (innerBlockType == "PUBLIC KEY BLOCK") {
+ innerKeyBlock = pgpBlock.substr(
+ beginIndexObj.value,
+ endIndexObj.value - beginIndexObj.value + 1
+ );
+
+ innerKeyBlock = innerKeyBlock.replace(/- -----/g, "-----");
+
+ statusFlagsObj.value |= EnigmailConstants.INLINE_KEY;
+ EnigmailLog.DEBUG(
+ "decryption.jsm: decryptMessage: innerKeyBlock found\n"
+ );
+ }
+ }
+
+ var importedKey = false;
+
+ if (innerKeyBlock) {
+ var importErrorMsgObj = {};
+ var exitStatus = EnigmailKeyRing.importKey(
+ parent,
+ true,
+ innerKeyBlock,
+ false,
+ pubKeyId,
+ importErrorMsgObj
+ );
+
+ importedKey = exitStatus === 0;
+
+ if (exitStatus > 0) {
+ l10n.formatValue("cant-import").then(value => {
+ EnigmailDialog.alert(
+ parent,
+ value + "\n" + importErrorMsgObj.value
+ );
+ });
+ }
+ }
+
+ if (importedKey) {
+ // Recursive call; note that EnigmailConstants.UI_ALLOW_KEY_IMPORT is unset
+ // to break the recursion
+ var uiFlagsDeep = interactive ? EnigmailConstants.UI_INTERACTIVE : 0;
+ signatureObj.value = "";
+ return EnigmailDecryption.decryptMessage(
+ parent,
+ uiFlagsDeep,
+ pgpBlock,
+ null, // date
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj
+ );
+ }
+ */
+
+ if (plainText && !unverifiedEncryptedOK) {
+ // Append original PGP block to unverified message
+ plainText =
+ "-----BEGIN PGP UNVERIFIED MESSAGE-----\r\n" +
+ plainText +
+ "-----END PGP UNVERIFIED MESSAGE-----\r\n\r\n" +
+ pgpBlock;
+ }
+ }
+
+ return verifyOnly ? "" : plainText;
+ },
+
+ inlineInnerVerification(parent, uiFlags, text, statusObject) {
+ lazy.EnigmailLog.DEBUG("decryption.jsm: inlineInnerVerification()\n");
+
+ if (text && text.indexOf("-----BEGIN PGP SIGNED MESSAGE-----") === 0) {
+ var status = newStatusObject();
+ var newText = EnigmailDecryption.decryptMessage(
+ parent,
+ uiFlags,
+ text,
+ null, // date
+ status.signature,
+ status.exitCode,
+ status.statusFlags,
+ status.keyId,
+ status.userId,
+ status.sigDetails,
+ status.message,
+ status.blockSeparation,
+ status.encToDetails
+ );
+ if (status.exitCode.value === 0) {
+ text = newText;
+ // merge status into status object:
+ statusObject.statusFlags.value =
+ statusObject.statusFlags.value | status.statusFlags.value;
+ statusObject.keyId.value = status.keyId.value;
+ statusObject.userId.value = status.userId.value;
+ statusObject.sigDetails.value = status.sigDetails.value;
+ statusObject.message.value = status.message.value;
+ // we don't merge encToDetails
+ }
+ }
+
+ return text;
+ },
+
+ isDecryptFailureResult(result) {
+ if (result.statusFlags & lazy.EnigmailConstants.MISSING_MDC) {
+ console.log("bad message, missing MDC");
+ } else if (result.statusFlags & lazy.EnigmailConstants.DECRYPTION_FAILED) {
+ console.log("cannot decrypt message");
+ } else if (result.decryptedData) {
+ return false;
+ }
+ return true;
+ },
+
+ getPlaintextFromDecryptResult(result) {
+ if (this.isDecryptFailureResult(result)) {
+ return "";
+ }
+ return lazy.EnigmailData.getUnicodeData(result.decryptedData);
+ },
+
+ async decryptAttachment(
+ parent,
+ outFile,
+ displayName,
+ byteData,
+ exitCodeObj,
+ statusFlagsObj,
+ errorMsgObj
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "decryption.jsm: decryptAttachment(parent=" +
+ parent +
+ ", outFileName=" +
+ outFile.path +
+ ")\n"
+ );
+
+ let attachmentHead = byteData.substr(0, 200);
+ if (attachmentHead.match(/-----BEGIN PGP \w{5,10} KEY BLOCK-----/)) {
+ // attachment appears to be a PGP key file
+
+ if (
+ lazy.EnigmailDialog.confirmDlg(
+ parent,
+ lazy.l10n.formatValueSync("attachment-pgp-key", {
+ name: displayName,
+ }),
+ lazy.l10n.formatValueSync("key-man-button-import"),
+ lazy.l10n.formatValueSync("dlg-button-view")
+ )
+ ) {
+ let preview = await lazy.EnigmailKey.getKeyListFromKeyBlock(
+ byteData,
+ errorMsgObj,
+ true,
+ true,
+ false
+ );
+ exitCodeObj.keyList = preview;
+ if (preview && errorMsgObj.value === "") {
+ if (preview.length > 0) {
+ let confirmImport = false;
+ let outParam = {};
+ confirmImport = lazy.EnigmailDialog.confirmPubkeyImport(
+ parent,
+ preview,
+ outParam
+ );
+ if (confirmImport) {
+ exitCodeObj.value = lazy.EnigmailKeyRing.importKey(
+ parent,
+ false,
+ byteData,
+ false,
+ "",
+ errorMsgObj,
+ null,
+ false,
+ [],
+ false, // don't use prompt for permissive
+ outParam.acceptance
+ );
+ statusFlagsObj.value = lazy.EnigmailConstants.IMPORTED_KEY;
+ } else {
+ exitCodeObj.value = 0;
+ statusFlagsObj.value = lazy.EnigmailConstants.DISPLAY_MESSAGE;
+ }
+ }
+ } else {
+ console.debug(
+ "Failed to obtain key list from key block in decrypted attachment. " +
+ errorMsgObj.value
+ );
+ }
+ } else {
+ exitCodeObj.value = 0;
+ statusFlagsObj.value = lazy.EnigmailConstants.DISPLAY_MESSAGE;
+ }
+ statusFlagsObj.ext = 0;
+ return true;
+ }
+
+ const cApi = lazy.EnigmailCryptoAPI();
+ let result = await cApi.decryptAttachment(byteData);
+ if (!result) {
+ console.debug(
+ "EnigmailCryptoAPI.decryptAttachment() failed with empty result"
+ );
+ return false;
+ }
+
+ exitCodeObj.value = result.exitCode;
+ statusFlagsObj.value = result.statusFlags;
+ errorMsgObj.value = result.errorMsg;
+
+ if (!this.isDecryptFailureResult(result)) {
+ await IOUtils.write(
+ outFile.path,
+ lazy.MailStringUtils.byteStringToUint8Array(result.decryptedData)
+ );
+ return true;
+ }
+
+ return false;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/dialog.jsm b/comm/mail/extensions/openpgp/content/modules/dialog.jsm
new file mode 100644
index 0000000000..a97db6094f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/dialog.jsm
@@ -0,0 +1,481 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailDialog"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+var EnigmailDialog = {
+ /***
+ * Confirmation dialog with OK / Cancel buttons (both customizable)
+ *
+ * @win: nsIWindow - parent window to display modal dialog; can be null
+ * @mesg: String - message text
+ * @okLabel: String - OPTIONAL label for OK button
+ * @cancelLabel: String - OPTIONAL label for cancel button
+ *
+ * @return: Boolean - true: OK pressed / false: Cancel or ESC pressed
+ */
+ confirmDlg(win, mesg, okLabel, cancelLabel) {
+ let buttonPressed = EnigmailDialog.msgBox(
+ win,
+ {
+ msgtext: mesg,
+ button1: okLabel ? okLabel : lazy.l10n.formatValueSync("dlg-button-ok"),
+ cancelButton: cancelLabel
+ ? cancelLabel
+ : lazy.l10n.formatValueSync("dlg-button-cancel"),
+ iconType: lazy.EnigmailConstants.ICONTYPE_QUESTION,
+ dialogTitle: lazy.l10n.formatValueSync("enig-confirm"),
+ },
+ null
+ );
+
+ return buttonPressed === 0;
+ },
+
+ /**
+ * Displays an alert dialog.
+ *
+ * @win: nsIWindow - parent window to display modal dialog; can be null
+ * @mesg: String - message text
+ *
+ * no return value
+ */
+ alert(win, mesg) {
+ EnigmailDialog.msgBox(
+ win,
+ {
+ msgtext: mesg,
+ button1: lazy.l10n.formatValueSync("dlg-button-close"),
+ iconType: lazy.EnigmailConstants.ICONTYPE_ALERT,
+ dialogTitle: lazy.l10n.formatValueSync("enig-alert"),
+ },
+ null
+ );
+ },
+
+ /**
+ * Displays an information dialog.
+ *
+ * @win: nsIWindow - parent window to display modal dialog; can be null
+ * @mesg: String - message text
+ *
+ * no return value
+ */
+ info(win, mesg) {
+ EnigmailDialog.msgBox(
+ win,
+ {
+ msgtext: mesg,
+ button1: lazy.l10n.formatValueSync("dlg-button-close"),
+ iconType: lazy.EnigmailConstants.ICONTYPE_INFO,
+ dialogTitle: lazy.l10n.formatValueSync("enig-info"),
+ },
+ null
+ );
+ },
+
+ /**
+ * Displays a message box with 1-3 optional buttons.
+ *
+ * @win: nsIWindow - parent window to display modal dialog; can be null
+ * @argsObj: Object:
+ * - msgtext: String - message text
+ * - dialogTitle: String - title of the dialog
+ * - checkboxLabel: String - if not null, display checkbox with text; the
+ * checkbox state is returned in checkedObj.value
+ * - iconType: Number - Icon type: 1=Message / 2=Question / 3=Alert / 4=Error
+ *
+ * - buttonX: String - Button label (button 1-3) [button1 = "accept" button]
+ * use "&" to indicate access key
+ * - cancelButton String - Label for cancel button
+ * use "buttonType:label" or ":buttonType" to indicate special button types
+ * (buttonType is one of cancel, help, extra1, extra2)
+ * if no button is provided, OK will be displayed
+ *
+ * @checkedObj: Object - holding the checkbox value
+ *
+ * @return: 0-2: button Number pressed
+ * -1: cancel button, ESC or close window button pressed
+ *
+ */
+ msgBox(win, argsObj, checkedObj) {
+ var result = {
+ value: -1,
+ checked: false,
+ };
+
+ if (!win) {
+ win = lazy.EnigmailWindows.getBestParentWin();
+ }
+
+ win.openDialog(
+ "chrome://openpgp/content/ui/enigmailMsgBox.xhtml",
+ "",
+ "chrome,dialog,modal,centerscreen,resizable",
+ argsObj,
+ result
+ );
+
+ if (argsObj.checkboxLabel) {
+ checkedObj.value = result.checked;
+ }
+ return result.value;
+ },
+
+ /**
+ * Display an alert message with an OK button and a checkbox to hide
+ * the message in the future.
+ * In case the checkbox was pressed in the past, the dialog is skipped
+ *
+ * @win: nsIWindow - the parent window to hold the modal dialog
+ * @mesg: String - the localized message to display
+ * @prefText: String - the name of the Enigmail preference to read/store the
+ * the future display status
+ */
+ alertPref(win, mesg, prefText) {
+ let prefValue = Services.prefs.getBoolPref("temp.openpgp." + prefText);
+ if (prefValue) {
+ let checkBoxObj = {
+ value: false,
+ };
+
+ let buttonPressed = EnigmailDialog.msgBox(
+ win,
+ {
+ msgtext: mesg,
+ dialogTitle: lazy.l10n.formatValueSync("enig-info"),
+ iconType: lazy.EnigmailConstants.ICONTYPE_INFO,
+ checkboxLabel: lazy.l10n.formatValueSync("dlg-no-prompt"),
+ },
+ checkBoxObj
+ );
+
+ if (checkBoxObj.value && buttonPressed === 0) {
+ Services.prefs.setBoolPref(prefText, false);
+ }
+ }
+ },
+
+ /**
+ * Display an alert dialog together with the message "this dialog will be
+ * displayed |counter| more times".
+ * If |counter| is 0, the dialog is not displayed.
+ *
+ * @win: nsIWindow - the parent window to hold the modal dialog
+ * @countPrefName: String - the name of the Enigmail preference to read/store the
+ * the |counter| value
+ * @mesg: String - the localized message to display
+ *
+ */
+ alertCount(win, countPrefName, mesg) {
+ let alertCount = Services.prefs.getIntPref("temp.openpgp." + countPrefName);
+ if (alertCount <= 0) {
+ return;
+ }
+
+ alertCount--;
+ Services.prefs.setIntPref(countPrefName, alertCount);
+
+ if (alertCount > 0) {
+ mesg +=
+ "\n" +
+ lazy.l10n.formatValueSync("repeat-prefix", { count: alertCount }) +
+ " ";
+ mesg +=
+ alertCount == 1
+ ? lazy.l10n.formatValueSync("repeat-suffix-singular")
+ : lazy.l10n.formatValueSync("repeat-suffix-plural");
+ } else {
+ mesg += "\n" + lazy.l10n.formatValueSync("no-repeat");
+ }
+
+ EnigmailDialog.alert(win, mesg);
+ },
+
+ /**
+ * Display a confirmation dialog with OK / Cancel buttons (both customizable) and
+ * a checkbox to remember the selected choice.
+ *
+ *
+ * @param {nsIWindow} win - Parent window to display modal dialog; can be null
+ * @param {mesg} - Mssage text
+ * @param {pref} - Full name of preference to read/store the future display status.
+ *
+ * @param {string} [okLabel] - Label for Ok button.
+ * @param {string} [cancelLabel] - Label for Cancel button.
+ *
+ * @returns {integer} 1: Ok pressed / 0: Cancel pressed / -1: ESC pressed
+ *
+ * If the dialog is not displayed:
+ * - if @prefText is type Boolean: return 1
+ * - if @prefText is type Number: return the last choice of the user
+ */
+ confirmBoolPref(win, mesg, pref, okLabel, cancelLabel) {
+ var prefValue = Services.prefs.getBoolPref(pref);
+ // boolean: "do not show this dialog anymore" (and return default)
+ switch (prefValue) {
+ case true: {
+ // display
+ let checkBoxObj = {
+ value: false,
+ };
+ let buttonPressed = EnigmailDialog.msgBox(
+ win,
+ {
+ msgtext: mesg,
+ button1: okLabel
+ ? okLabel
+ : lazy.l10n.formatValueSync("dlg-button-ok"),
+ cancelButton: cancelLabel
+ ? cancelLabel
+ : lazy.l10n.formatValueSync("dlg-button-cancel"),
+ checkboxLabel: lazy.l10n.formatValueSync("dlg-no-prompt"),
+ iconType: lazy.EnigmailConstants.ICONTYPE_QUESTION,
+ dialogTitle: lazy.l10n.formatValueSync("enig-confirm"),
+ },
+ checkBoxObj
+ );
+
+ if (checkBoxObj.value) {
+ Services.prefs.setBoolPref(pref, false);
+ }
+ return buttonPressed === 0 ? 1 : 0;
+ }
+ case false: // don't display
+ return 1;
+ default:
+ return -1;
+ }
+ },
+
+ confirmIntPref(win, mesg, pref, okLabel, cancelLabel) {
+ let prefValue = Services.prefs.getIntPref(pref);
+ // number: remember user's choice
+ switch (prefValue) {
+ case 0: {
+ // not set
+ let checkBoxObj = {
+ value: false,
+ };
+ let buttonPressed = EnigmailDialog.msgBox(
+ win,
+ {
+ msgtext: mesg,
+ button1: okLabel
+ ? okLabel
+ : lazy.l10n.formatValueSync("dlg-button-ok"),
+ cancelButton: cancelLabel
+ ? cancelLabel
+ : lazy.l10n.formatValueSync("dlg-button-cancel"),
+ checkboxLabel: lazy.l10n.formatValueSync("dlg-keep-setting"),
+ iconType: lazy.EnigmailConstants.ICONTYPE_QUESTION,
+ dialogTitle: lazy.l10n.formatValueSync("enig-confirm"),
+ },
+ checkBoxObj
+ );
+
+ if (checkBoxObj.value) {
+ Services.prefs.setIntPref(pref, buttonPressed === 0 ? 1 : 0);
+ }
+ return buttonPressed === 0 ? 1 : 0;
+ }
+ case 1: // yes
+ return 1;
+ case 2: // no
+ return 0;
+ }
+ return -1;
+ },
+
+ /**
+ * Display a "open file" or "save file" dialog
+ *
+ * win: nsIWindow - parent window
+ * title: String - window title
+ * displayDir: String - optional: directory to be displayed
+ * save: Boolean - true = Save file / false = Open file
+ * multiple: Boolean - true = Select multiple files / false = Select single file
+ * defaultExtension: String - optional: extension for the type of files to work with, e.g. "asc"
+ * defaultName: String - optional: filename, incl. extension, that should be suggested to
+ * the user as default, e.g. "keys.asc"
+ * filterPairs: Array - optional: [title, extension], e.g. ["Pictures", "*.jpg; *.png"]
+ *
+ * return value: nsIFile object, or array of nsIFile objects,
+ * representing the file(s) to load or save
+ */
+ filePicker(
+ win,
+ title,
+ displayDir,
+ save,
+ multiple,
+ defaultExtension,
+ defaultName,
+ filterPairs
+ ) {
+ let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance();
+ filePicker = filePicker.QueryInterface(Ci.nsIFilePicker);
+
+ let open = multiple
+ ? Ci.nsIFilePicker.modeOpenMultiple
+ : Ci.nsIFilePicker.modeOpen;
+ let mode = save ? Ci.nsIFilePicker.modeSave : open;
+
+ filePicker.init(win, title, mode);
+ if (displayDir) {
+ var localFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+
+ try {
+ localFile.initWithPath(displayDir);
+ filePicker.displayDirectory = localFile;
+ } catch (ex) {}
+ }
+
+ if (defaultExtension) {
+ filePicker.defaultExtension = defaultExtension;
+ }
+
+ if (defaultName) {
+ filePicker.defaultString = defaultName;
+ }
+
+ let nfilters = 0;
+ if (filterPairs && filterPairs.length) {
+ nfilters = filterPairs.length / 2;
+ }
+
+ for (let index = 0; index < nfilters; index++) {
+ filePicker.appendFilter(
+ filterPairs[2 * index],
+ filterPairs[2 * index + 1]
+ );
+ }
+
+ filePicker.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ let inspector = Cc["@mozilla.org/jsinspector;1"].createInstance(
+ Ci.nsIJSInspector
+ );
+ let files = [];
+ filePicker.open(res => {
+ if (
+ res != Ci.nsIFilePicker.returnOK &&
+ res != Ci.nsIFilePicker.returnReplace
+ ) {
+ inspector.exitNestedEventLoop();
+ return;
+ }
+
+ // Loop through multiple selected files only if the dialog was triggered
+ // to open files and the `multiple` boolean variable is true.
+ if (!save && multiple) {
+ for (let file of filePicker.files) {
+ // XXX: for some reason QI is needed on Mac.
+ files.push(file.QueryInterface(Ci.nsIFile));
+ }
+ } else {
+ files.push(filePicker.file);
+ }
+
+ inspector.exitNestedEventLoop();
+ });
+
+ inspector.enterNestedEventLoop(0); // wait for async process to terminate
+
+ return multiple ? files : files[0];
+ },
+
+ /**
+ * Displays a dialog with success/failure information after importing
+ * keys.
+ *
+ * @param win: nsIWindow - parent window to display modal dialog; can be null
+ * @param keyList: Array of String - imported keyIDs
+ *
+ * @return: 0-2: button Number pressed
+ * -1: ESC or close window button pressed
+ *
+ */
+ keyImportDlg(win, keyList) {
+ var result = {
+ value: -1,
+ checked: false,
+ };
+
+ if (!win) {
+ win = lazy.EnigmailWindows.getBestParentWin();
+ }
+
+ win.openDialog(
+ "chrome://openpgp/content/ui/enigmailKeyImportInfo.xhtml",
+ "",
+ "chrome,dialog,modal,centerscreen,resizable",
+ {
+ keyList,
+ },
+ result
+ );
+
+ return result.value;
+ },
+ /**
+ * return a pre-initialized prompt service
+ */
+ getPromptSvc() {
+ return Services.prompt;
+ },
+
+ /**
+ * Asks user to confirm the import of the given public keys.
+ * User is allowed to automatically accept new/undecided keys.
+ *
+ * @param {nsIDOMWindow} parentWindow - Parent window.
+ * @param {object[]} keyPreview - Key details. See EnigmailKey.getKeyListFromKeyBlock().
+ * @param {EnigmailKeyObj[]} - Array of key objects.
+ * @param {object} outputParams - Out parameters.
+ * @param {string} outputParams.acceptance contains the decision. If confirmed.
+ * @returns {boolean} true if user confirms import
+ *
+ */
+ confirmPubkeyImport(parentWindow, keyPreview, outputParams) {
+ let args = {
+ keys: keyPreview,
+ confirmed: false,
+ acceptance: "",
+ };
+
+ parentWindow.browsingContext.topChromeWindow.openDialog(
+ "chrome://openpgp/content/ui/confirmPubkeyImport.xhtml",
+ "",
+ "dialog,modal,centerscreen,resizable",
+ args
+ );
+
+ if (args.confirmed && outputParams) {
+ outputParams.acceptance = args.acceptance;
+ }
+ return args.confirmed;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/encryption.jsm b/comm/mail/extensions/openpgp/content/modules/encryption.jsm
new file mode 100644
index 0000000000..b02336bb91
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/encryption.jsm
@@ -0,0 +1,564 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailEncryption"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+const gMimeHashAlgorithms = [
+ null,
+ "sha1",
+ "ripemd160",
+ "sha256",
+ "sha384",
+ "sha512",
+ "sha224",
+ "md5",
+];
+
+const ENC_TYPE_MSG = 0;
+const ENC_TYPE_ATTACH_BINARY = 1;
+
+var EnigmailEncryption = {
+ // return object on success, null on failure
+ getCryptParams(
+ fromMailAddr,
+ toMailAddr,
+ bccMailAddr,
+ hashAlgorithm,
+ sendFlags,
+ isAscii,
+ errorMsgObj,
+ logFileObj
+ ) {
+ let result = {};
+ result.sender = "";
+ result.sign = false;
+ result.signatureHash = "";
+ result.sigTypeClear = false;
+ result.sigTypeDetached = false;
+ result.encrypt = false;
+ result.encryptToSender = false;
+ result.armor = false;
+ result.senderKeyIsExternal = false;
+
+ lazy.EnigmailLog.DEBUG(
+ "encryption.jsm: getCryptParams: hashAlgorithm=" + hashAlgorithm + "\n"
+ );
+
+ try {
+ fromMailAddr = lazy.EnigmailFuncs.stripEmail(fromMailAddr);
+ toMailAddr = lazy.EnigmailFuncs.stripEmail(toMailAddr);
+ bccMailAddr = lazy.EnigmailFuncs.stripEmail(bccMailAddr);
+ } catch (ex) {
+ errorMsgObj.value = lazy.l10n.formatValueSync("invalid-email");
+ return null;
+ }
+
+ var signMsg = sendFlags & lazy.EnigmailConstants.SEND_SIGNED;
+ var encryptMsg = sendFlags & lazy.EnigmailConstants.SEND_ENCRYPTED;
+ var usePgpMime = sendFlags & lazy.EnigmailConstants.SEND_PGP_MIME;
+
+ if (sendFlags & lazy.EnigmailConstants.SEND_SENDER_KEY_EXTERNAL) {
+ result.senderKeyIsExternal = true;
+ }
+
+ // Some day we might need to look at flag SEND_TWO_MIME_LAYERS here,
+ // to decide which detached signature flag needs to be passed on
+ // to the RNP or GPGME layers. However, today those layers can
+ // derive their necessary behavior from being asked to do combined
+ // or single encryption/signing. This is because today we always
+ // create signed messages using the detached signature, and we never
+ // need the OpenPGP signature encoding that includes the message
+ // except when combining GPG signing with RNP encryption.
+
+ var detachedSig =
+ (usePgpMime || sendFlags & lazy.EnigmailConstants.SEND_ATTACHMENT) &&
+ signMsg &&
+ !encryptMsg;
+
+ result.to = toMailAddr.split(/\s*,\s*/);
+ result.bcc = bccMailAddr.split(/\s*,\s*/);
+ result.aliasKeys = new Map();
+
+ if (result.to.length == 1 && result.to[0].length == 0) {
+ result.to.splice(0, 1); // remove the single empty entry
+ }
+
+ if (result.bcc.length == 1 && result.bcc[0].length == 0) {
+ result.bcc.splice(0, 1); // remove the single empty entry
+ }
+
+ if (/^0x[0-9a-f]+$/i.test(fromMailAddr)) {
+ result.sender = fromMailAddr;
+ } else {
+ result.sender = "<" + fromMailAddr + ">";
+ }
+ result.sender = result.sender.replace(/(["'`])/g, "\\$1");
+
+ if (signMsg && hashAlgorithm) {
+ result.signatureHash = hashAlgorithm;
+ }
+
+ if (encryptMsg) {
+ if (isAscii != ENC_TYPE_ATTACH_BINARY) {
+ result.armor = true;
+ }
+ result.encrypt = true;
+
+ if (signMsg) {
+ result.sign = true;
+ }
+
+ if (
+ sendFlags & lazy.EnigmailConstants.SEND_ENCRYPT_TO_SELF &&
+ fromMailAddr
+ ) {
+ result.encryptToSender = true;
+ }
+
+ let recipArrays = ["to", "bcc"];
+ for (let recipArray of recipArrays) {
+ let kMax = recipArray == "to" ? result.to.length : result.bcc.length;
+ for (let k = 0; k < kMax; k++) {
+ let email = recipArray == "to" ? result.to[k] : result.bcc[k];
+ if (!email) {
+ continue;
+ }
+ email = email.toLowerCase();
+ if (/^0x[0-9a-f]+$/i.test(email)) {
+ throw new Error(`Recipient should not be a key ID: ${email}`);
+ }
+ if (recipArray == "to") {
+ result.to[k] = "<" + email + ">";
+ } else {
+ result.bcc[k] = "<" + email + ">";
+ }
+
+ let aliasKeyList = lazy.EnigmailKeyRing.getAliasKeyList(email);
+ if (aliasKeyList) {
+ // We have an alias definition.
+
+ let aliasKeys = lazy.EnigmailKeyRing.getAliasKeys(aliasKeyList);
+ if (!aliasKeys.length) {
+ // An empty result means there was a failure obtaining the
+ // defined keys, this happens if at least one key is missing
+ // or unusable.
+ // We don't allow composing an email that involves a
+ // bad alias definition, return null to signal that
+ // sending should be aborted.
+ errorMsgObj.value = "bad alias definition for " + email;
+ return null;
+ }
+
+ result.aliasKeys.set(email, aliasKeys);
+ }
+ }
+ }
+ } else if (detachedSig) {
+ result.sigTypeDetached = true;
+ result.sign = true;
+
+ if (isAscii != ENC_TYPE_ATTACH_BINARY) {
+ result.armor = true;
+ }
+ } else if (signMsg) {
+ result.sigTypeClear = true;
+ result.sign = true;
+ }
+
+ return result;
+ },
+
+ /**
+ * Determine why a given key cannot be used for signing.
+ *
+ * @param {string} keyId - key ID
+ *
+ * @returns {string} The reason(s) as message to display to the user, or
+ * an empty string in case the key is valid.
+ */
+ determineInvSignReason(keyId) {
+ lazy.EnigmailLog.DEBUG(
+ "errorHandling.jsm: determineInvSignReason: keyId: " + keyId + "\n"
+ );
+
+ let key = lazy.EnigmailKeyRing.getKeyById(keyId);
+ if (!key) {
+ return lazy.l10n.formatValueSync("key-error-key-id-not-found", {
+ keySpec: keyId,
+ });
+ }
+ let r = key.getSigningValidity();
+ if (!r.keyValid) {
+ return r.reason;
+ }
+
+ return "";
+ },
+
+ /**
+ * Determine why a given key cannot be used for encryption.
+ *
+ * @param {string} keyId - key ID
+ *
+ * @returns {string} The reason(s) as message to display to the user, or
+ * an empty string in case the key is valid.
+ */
+ determineInvRcptReason(keyId) {
+ lazy.EnigmailLog.DEBUG(
+ "errorHandling.jsm: determineInvRcptReason: keyId: " + keyId + "\n"
+ );
+
+ let key = lazy.EnigmailKeyRing.getKeyById(keyId);
+ if (!key) {
+ return lazy.l10n.formatValueSync("key-error-key-id-not-found", {
+ keySpec: keyId,
+ });
+ }
+ let r = key.getEncryptionValidity(false);
+ if (!r.keyValid) {
+ return r.reason;
+ }
+
+ return "";
+ },
+
+ /**
+ * Determine if the sender key ID or user ID can be used for signing and/or
+ * encryption
+ *
+ * @param {integer} sendFlags - The send Flags; need to contain SEND_SIGNED and/or SEND_ENCRYPTED
+ * @param {string} fromKeyId - The sender key ID
+ *
+ * @returns {object} object
+ * - keyId: String - the found key ID, or null if fromMailAddr is not valid
+ * - errorMsg: String - the error message if key not valid, or null if key is valid
+ */
+ async determineOwnKeyUsability(sendFlags, fromKeyId, isExternalGnuPG) {
+ lazy.EnigmailLog.DEBUG(
+ "encryption.jsm: determineOwnKeyUsability: sendFlags=" +
+ sendFlags +
+ ", sender=" +
+ fromKeyId +
+ "\n"
+ );
+
+ let foundKey = null;
+ let ret = {
+ errorMsg: null,
+ };
+
+ if (!fromKeyId) {
+ return ret;
+ }
+
+ let sign = !!(sendFlags & lazy.EnigmailConstants.SEND_SIGNED);
+ let encrypt = !!(sendFlags & lazy.EnigmailConstants.SEND_ENCRYPTED);
+
+ if (/^(0x)?[0-9a-f]+$/i.test(fromKeyId)) {
+ // key ID specified
+ foundKey = lazy.EnigmailKeyRing.getKeyById(fromKeyId);
+ }
+
+ // even for isExternalGnuPG we require that the public key is available
+ if (!foundKey) {
+ ret.errorMsg = this.determineInvSignReason(fromKeyId);
+ return ret;
+ }
+
+ if (!isExternalGnuPG && foundKey.secretAvailable) {
+ let isPersonal = await lazy.PgpSqliteDb2.isAcceptedAsPersonalKey(
+ foundKey.fpr
+ );
+ if (!isPersonal) {
+ ret.errorMsg = lazy.l10n.formatValueSync(
+ "key-error-not-accepted-as-personal",
+ {
+ keySpec: fromKeyId,
+ }
+ );
+ return ret;
+ }
+ }
+
+ let canSign = false;
+ let canEncrypt = false;
+
+ if (isExternalGnuPG) {
+ canSign = true;
+ } else if (sign && foundKey) {
+ let v = foundKey.getSigningValidity();
+ if (v.keyValid) {
+ canSign = true;
+ } else {
+ // If we already have a reason for the key not being valid,
+ // use that as error message.
+ ret.errorMsg = v.reason;
+ }
+ }
+
+ if (encrypt && foundKey) {
+ let v;
+ if (lazy.EnigmailKeyRing.isSubkeyId(fromKeyId)) {
+ // If the configured own key ID points to a subkey, check
+ // specifically that this subkey is a valid encryption key.
+
+ let id = fromKeyId.replace(/^0x/, "");
+ v = foundKey.getEncryptionValidity(false, null, id);
+ } else {
+ // Use parameter "false", because for isExternalGnuPG we cannot
+ // confirm that the user has the secret key.
+ // And for users of internal encryption code, we don't need to
+ // check that here either, public key is sufficient for encryption.
+ v = foundKey.getEncryptionValidity(false);
+ }
+
+ if (v.keyValid) {
+ canEncrypt = true;
+ } else {
+ // If we already have a reason for the key not being valid,
+ // use that as error message.
+ ret.errorMsg = v.reason;
+ }
+ }
+
+ if (sign && !canSign) {
+ if (!ret.errorMsg) {
+ // Only if we don't have an error message yet.
+ ret.errorMsg = this.determineInvSignReason(fromKeyId);
+ }
+ } else if (encrypt && !canEncrypt) {
+ if (!ret.errorMsg) {
+ // Only if we don't have an error message yet.
+ ret.errorMsg = this.determineInvRcptReason(fromKeyId);
+ }
+ }
+
+ return ret;
+ },
+
+ // return 0 on success, non-zero on failure
+ encryptMessageStart(
+ win,
+ uiFlags,
+ fromMailAddr,
+ toMailAddr,
+ bccMailAddr,
+ hashAlgorithm,
+ sendFlags,
+ listener,
+ statusFlagsObj,
+ errorMsgObj
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "encryption.jsm: encryptMessageStart: uiFlags=" +
+ uiFlags +
+ ", from " +
+ fromMailAddr +
+ " to " +
+ toMailAddr +
+ ", hashAlgorithm=" +
+ hashAlgorithm +
+ " (" +
+ lazy.EnigmailData.bytesToHex(lazy.EnigmailData.pack(sendFlags, 4)) +
+ ")\n"
+ );
+
+ // This code used to call determineOwnKeyUsability, and return on
+ // failure. But now determineOwnKeyUsability is an async function,
+ // and calling it from here with await results in a deadlock.
+ // Instead we perform this check in Enigmail.msg.prepareSendMsg.
+
+ var hashAlgo =
+ gMimeHashAlgorithms[
+ Services.prefs.getIntPref("temp.openpgp.mimeHashAlgorithm")
+ ];
+
+ if (hashAlgorithm) {
+ hashAlgo = hashAlgorithm;
+ }
+
+ errorMsgObj.value = "";
+
+ if (!sendFlags) {
+ lazy.EnigmailLog.DEBUG(
+ "encryption.jsm: encryptMessageStart: NO ENCRYPTION!\n"
+ );
+ errorMsgObj.value = lazy.l10n.formatValueSync("not-required");
+ return 0;
+ }
+
+ if (!lazy.EnigmailCore.getService(win)) {
+ throw new Error(
+ "encryption.jsm: encryptMessageStart: not yet initialized"
+ );
+ }
+
+ let logFileObj = {};
+
+ let encryptArgs = EnigmailEncryption.getCryptParams(
+ fromMailAddr,
+ toMailAddr,
+ bccMailAddr,
+ hashAlgo,
+ sendFlags,
+ ENC_TYPE_MSG,
+ errorMsgObj,
+ logFileObj
+ );
+
+ if (!encryptArgs) {
+ return -1;
+ }
+
+ if (!listener) {
+ throw new Error("unexpected no listener");
+ }
+
+ let resultStatus = {};
+ const cApi = lazy.EnigmailCryptoAPI();
+ let encrypted = cApi.sync(
+ cApi.encryptAndOrSign(
+ listener.getInputForCrypto(),
+ encryptArgs,
+ resultStatus
+ )
+ );
+
+ if (resultStatus.exitCode) {
+ if (resultStatus.errorMsg.length) {
+ lazy.EnigmailDialog.alert(win, resultStatus.errorMsg);
+ }
+ } else if (encrypted) {
+ listener.addCryptoOutput(encrypted);
+ }
+
+ if (resultStatus.exitCode === 0 && !listener.getCryptoOutputLength()) {
+ resultStatus.exitCode = -1;
+ }
+ return resultStatus.exitCode;
+ },
+
+ encryptMessage(
+ parent,
+ uiFlags,
+ plainText,
+ fromMailAddr,
+ toMailAddr,
+ bccMailAddr,
+ sendFlags,
+ exitCodeObj,
+ statusFlagsObj,
+ errorMsgObj
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "enigmail.js: Enigmail.encryptMessage: " +
+ plainText.length +
+ " bytes from " +
+ fromMailAddr +
+ " to " +
+ toMailAddr +
+ " (" +
+ sendFlags +
+ ")\n"
+ );
+ throw new Error("Not implemented");
+
+ /*
+ exitCodeObj.value = -1;
+ statusFlagsObj.value = 0;
+ errorMsgObj.value = "";
+
+ if (!plainText) {
+ EnigmailLog.DEBUG("enigmail.js: Enigmail.encryptMessage: NO ENCRYPTION!\n");
+ exitCodeObj.value = 0;
+ EnigmailLog.DEBUG(" <=== encryptMessage()\n");
+ return plainText;
+ }
+
+ var defaultSend = sendFlags & EnigmailConstants.SEND_DEFAULT;
+ var signMsg = sendFlags & EnigmailConstants.SEND_SIGNED;
+ var encryptMsg = sendFlags & EnigmailConstants.SEND_ENCRYPTED;
+
+ if (encryptMsg) {
+ // First convert all linebreaks to newlines
+ plainText = plainText.replace(/\r\n/g, "\n");
+ plainText = plainText.replace(/\r/g, "\n");
+
+ // we need all data in CRLF according to RFC 4880
+ plainText = plainText.replace(/\n/g, "\r\n");
+ }
+
+ var listener = EnigmailExecution.newSimpleListener(
+ function _stdin(pipe) {
+ pipe.write(plainText);
+ pipe.close();
+ },
+ function _done(exitCode) {});
+
+
+ var proc = EnigmailEncryption.encryptMessageStart(parent, uiFlags,
+ fromMailAddr, toMailAddr, bccMailAddr,
+ null, sendFlags,
+ listener, statusFlagsObj, errorMsgObj);
+ if (!proc) {
+ exitCodeObj.value = -1;
+ EnigmailLog.DEBUG(" <=== encryptMessage()\n");
+ return "";
+ }
+
+ // Wait for child pipes to close
+ proc.wait();
+
+ var retStatusObj = {};
+ exitCodeObj.value = EnigmailEncryption.encryptMessageEnd(fromMailAddr, EnigmailData.getUnicodeData(listener.stderrData), listener.exitCode,
+ uiFlags, sendFlags,
+ listener.stdoutData.length,
+ retStatusObj);
+
+ statusFlagsObj.value = retStatusObj.statusFlags;
+ statusFlagsObj.statusMsg = retStatusObj.statusMsg;
+ errorMsgObj.value = retStatusObj.errorMsg;
+
+
+ if ((exitCodeObj.value === 0) && listener.stdoutData.length === 0)
+ exitCodeObj.value = -1;
+
+ if (exitCodeObj.value === 0) {
+ // Normal return
+ EnigmailLog.DEBUG(" <=== encryptMessage()\n");
+ return EnigmailData.getUnicodeData(listener.stdoutData);
+ }
+
+ // Error processing
+ EnigmailLog.DEBUG("enigmail.js: Enigmail.encryptMessage: command execution exit code: " + exitCodeObj.value + "\n");
+ return "";
+ */
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/filters.jsm b/comm/mail/extensions/openpgp/content/modules/filters.jsm
new file mode 100644
index 0000000000..09abd448e5
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/filters.jsm
@@ -0,0 +1,598 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailFilters"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+
+ EnigmailPersistentCrypto:
+ "chrome://openpgp/content/modules/persistentCrypto.jsm",
+
+ EnigmailStreams: "chrome://openpgp/content/modules/streams.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ jsmime: "resource:///modules/jsmime.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+let l10n = new Localization(["messenger/openpgp/openpgp.ftl"], true);
+
+var gNewMailListenerInitiated = false;
+
+/**
+ * filter action for creating a decrypted version of the mail and
+ * deleting the original mail at the same time
+ */
+
+const filterActionMoveDecrypt = {
+ async applyAction(aMsgHdrs, aActionValue, aListener, aType, aMsgWindow) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: filterActionMoveDecrypt: Move to: " + aActionValue + "\n"
+ );
+
+ for (let msgHdr of aMsgHdrs) {
+ await lazy.EnigmailPersistentCrypto.cryptMessage(
+ msgHdr,
+ aActionValue,
+ true,
+ null
+ );
+ }
+ },
+
+ isValidForType(type, scope) {
+ return true;
+ },
+
+ validateActionValue(value, folder, type) {
+ l10n.formatValue("filter-decrypt-move-warn-experimental").then(value => {
+ lazy.EnigmailDialog.alert(null, value);
+ });
+
+ if (value === "") {
+ return l10n.formatValueSync("filter-folder-required");
+ }
+
+ return null;
+ },
+};
+
+/**
+ * filter action for creating a decrypted copy of the mail, leaving the original
+ * message untouched
+ */
+const filterActionCopyDecrypt = {
+ async applyAction(aMsgHdrs, aActionValue, aListener, aType, aMsgWindow) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: filterActionCopyDecrypt: Copy to: " + aActionValue + "\n"
+ );
+
+ for (let msgHdr of aMsgHdrs) {
+ await lazy.EnigmailPersistentCrypto.cryptMessage(
+ msgHdr,
+ aActionValue,
+ false,
+ null
+ );
+ }
+ },
+
+ isValidForType(type, scope) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: filterActionCopyDecrypt.isValidForType(" + type + ")\n"
+ );
+
+ let r = true;
+ return r;
+ },
+
+ validateActionValue(value, folder, type) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: filterActionCopyDecrypt.validateActionValue(" +
+ value +
+ ")\n"
+ );
+
+ if (value === "") {
+ return l10n.formatValueSync("filter-folder-required");
+ }
+
+ return null;
+ },
+};
+
+/**
+ * filter action for to encrypt a mail to a specific key
+ */
+const filterActionEncrypt = {
+ async applyAction(aMsgHdrs, aActionValue, aListener, aType, aMsgWindow) {
+ // Ensure KeyRing is loaded.
+ if (aMsgWindow) {
+ lazy.EnigmailCore.getService(aMsgWindow.domWindow);
+ } else {
+ lazy.EnigmailCore.getService();
+ }
+ lazy.EnigmailKeyRing.getAllKeys();
+
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: filterActionEncrypt: Encrypt to: " + aActionValue + "\n"
+ );
+ let keyObj = lazy.EnigmailKeyRing.getKeyById(aActionValue);
+
+ if (keyObj === null) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: failed to find key by id: " + aActionValue + "\n"
+ );
+ let keyId = lazy.EnigmailKeyRing.getValidKeyForRecipient(aActionValue);
+ if (keyId) {
+ keyObj = lazy.EnigmailKeyRing.getKeyById(keyId);
+ }
+ }
+
+ if (keyObj === null && aListener) {
+ lazy.EnigmailLog.DEBUG("filters.jsm: no valid key - aborting\n");
+
+ aListener.OnStartCopy();
+ aListener.OnStopCopy(1);
+
+ return;
+ }
+
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: key to encrypt to: " +
+ JSON.stringify(keyObj) +
+ ", userId: " +
+ keyObj.userId +
+ "\n"
+ );
+
+ // Maybe skip messages here if they are already encrypted to
+ // the target key? There might be some use case for unconditionally
+ // encrypting here. E.g. to use the local preferences and remove all
+ // other recipients.
+ // Also not encrypting to already encrypted messages would make the
+ // behavior less transparent as it's not obvious.
+
+ for (let msgHdr of aMsgHdrs) {
+ await lazy.EnigmailPersistentCrypto.cryptMessage(
+ msgHdr,
+ null /* same folder */,
+ true /* move */,
+ keyObj /* target key */
+ );
+ }
+ },
+
+ isValidForType(type, scope) {
+ return true;
+ },
+
+ validateActionValue(value, folder, type) {
+ // Initialize KeyRing. Ugly as it blocks the GUI but
+ // we need it.
+ lazy.EnigmailCore.getService();
+ lazy.EnigmailKeyRing.getAllKeys();
+
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: validateActionValue: Encrypt to: " + value + "\n"
+ );
+ if (value === "") {
+ return l10n.formatValueSync("filter-key-required");
+ }
+
+ let keyObj = lazy.EnigmailKeyRing.getKeyById(value);
+
+ if (keyObj === null) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: failed to find key by id. Looking for uid.\n"
+ );
+ let keyId = lazy.EnigmailKeyRing.getValidKeyForRecipient(value);
+ if (keyId) {
+ keyObj = lazy.EnigmailKeyRing.getKeyById(keyId);
+ }
+ }
+
+ if (keyObj === null) {
+ return l10n.formatValueSync("filter-key-not-found", {
+ desc: value,
+ });
+ }
+
+ if (!keyObj.secretAvailable) {
+ // We warn but we allow it. There might be use cases where
+ // thunderbird + enigmail is used as a gateway filter with
+ // the secret not available on one machine and the decryption
+ // is intended to happen on different systems.
+ l10n
+ .formatValue("filter-warn-key-not-secret", {
+ desc: value,
+ })
+ .then(value => {
+ lazy.EnigmailDialog.alert(null, value);
+ });
+ }
+
+ return null;
+ },
+};
+
+function isPGPEncrypted(data) {
+ // We only check the first mime subpart for application/pgp-encrypted.
+ // If it is text/plain or text/html we look into that for the
+ // message marker.
+ // If there are no subparts we just look in the body.
+ //
+ // This intentionally does not match more complex cases
+ // with sub parts being encrypted etc. as auto processing
+ // these kinds of mails will be error prone and better not
+ // done through a filter
+
+ var mimeTree = lazy.EnigmailMime.getMimeTree(data, true);
+ if (!mimeTree.subParts.length) {
+ // No subParts. Check for PGP Marker in Body
+ return mimeTree.body.includes("-----BEGIN PGP MESSAGE-----");
+ }
+
+ // Check the type of the first subpart.
+ var firstPart = mimeTree.subParts[0];
+ var ct = firstPart.fullContentType;
+ if (typeof ct == "string") {
+ ct = ct.replace(/[\r\n]/g, " ");
+ // Proper PGP/MIME ?
+ if (ct.search(/application\/pgp-encrypted/i) >= 0) {
+ return true;
+ }
+ // Look into text/plain pgp messages and text/html messages.
+ if (ct.search(/text\/plain/i) >= 0 || ct.search(/text\/html/i) >= 0) {
+ return firstPart.body.includes("-----BEGIN PGP MESSAGE-----");
+ }
+ }
+ return false;
+}
+
+/**
+ * filter term for OpenPGP Encrypted mail
+ */
+const filterTermPGPEncrypted = {
+ id: EnigmailConstants.FILTER_TERM_PGP_ENCRYPTED,
+ name: l10n.formatValueSync("filter-term-pgpencrypted-label"),
+ needsBody: true,
+ match(aMsgHdr, searchValue, searchOp) {
+ var folder = aMsgHdr.folder;
+ var stream = folder.getMsgInputStream(aMsgHdr, {});
+
+ var messageSize = folder.hasMsgOffline(aMsgHdr.messageKey)
+ ? aMsgHdr.offlineMessageSize
+ : aMsgHdr.messageSize;
+ var data;
+ try {
+ data = lazy.NetUtil.readInputStreamToString(stream, messageSize);
+ } catch (ex) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: filterTermPGPEncrypted: failed to get data.\n"
+ );
+ // If we don't know better to return false.
+ stream.close();
+ return false;
+ }
+
+ var isPGP = isPGPEncrypted(data);
+
+ stream.close();
+
+ return (
+ (searchOp == Ci.nsMsgSearchOp.Is && isPGP) ||
+ (searchOp == Ci.nsMsgSearchOp.Isnt && !isPGP)
+ );
+ },
+
+ getEnabled(scope, op) {
+ return true;
+ },
+
+ getAvailable(scope, op) {
+ return true;
+ },
+
+ getAvailableOperators(scope, length) {
+ length.value = 2;
+ return [Ci.nsMsgSearchOp.Is, Ci.nsMsgSearchOp.Isnt];
+ },
+};
+
+function initNewMailListener() {
+ lazy.EnigmailLog.DEBUG("filters.jsm: initNewMailListener()\n");
+
+ if (!gNewMailListenerInitiated) {
+ let notificationService = Cc[
+ "@mozilla.org/messenger/msgnotificationservice;1"
+ ].getService(Ci.nsIMsgFolderNotificationService);
+ notificationService.addListener(
+ newMailListener,
+ notificationService.msgAdded
+ );
+ }
+ gNewMailListenerInitiated = true;
+}
+
+function shutdownNewMailListener() {
+ lazy.EnigmailLog.DEBUG("filters.jsm: shutdownNewMailListener()\n");
+
+ if (gNewMailListenerInitiated) {
+ let notificationService = Cc[
+ "@mozilla.org/messenger/msgnotificationservice;1"
+ ].getService(Ci.nsIMsgFolderNotificationService);
+ notificationService.removeListener(newMailListener);
+ gNewMailListenerInitiated = false;
+ }
+}
+
+function getIdentityForSender(senderEmail, msgServer) {
+ let identities = MailServices.accounts.getIdentitiesForServer(msgServer);
+ return identities.find(
+ id => id.email.toLowerCase() === senderEmail.toLowerCase()
+ );
+}
+
+var consumerList = [];
+
+function JsmimeEmitter(requireBody) {
+ this.requireBody = requireBody;
+ this.mimeTree = {
+ partNum: "",
+ headers: null,
+ body: "",
+ parent: null,
+ subParts: [],
+ };
+ this.stack = [];
+ this.currPartNum = "";
+}
+
+JsmimeEmitter.prototype = {
+ createPartObj(partNum, headers, parent) {
+ return {
+ partNum,
+ headers,
+ body: "",
+ parent,
+ subParts: [],
+ };
+ },
+
+ getMimeTree() {
+ return this.mimeTree.subParts[0];
+ },
+
+ /** JSMime API */
+ startMessage() {
+ this.currentPart = this.mimeTree;
+ },
+ endMessage() {},
+
+ startPart(partNum, headers) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: JsmimeEmitter.startPart: partNum=" + partNum + "\n"
+ );
+ //this.stack.push(partNum);
+ let newPart = this.createPartObj(partNum, headers, this.currentPart);
+
+ if (partNum.indexOf(this.currPartNum) === 0) {
+ // found sub-part
+ this.currentPart.subParts.push(newPart);
+ } else {
+ // found same or higher level
+ this.currentPart.subParts.push(newPart);
+ }
+ this.currPartNum = partNum;
+ this.currentPart = newPart;
+ },
+
+ endPart(partNum) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: JsmimeEmitter.startPart: partNum=" + partNum + "\n"
+ );
+ this.currentPart = this.currentPart.parent;
+ },
+
+ deliverPartData(partNum, data) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: JsmimeEmitter.deliverPartData: partNum=" + partNum + "\n"
+ );
+ if (this.requireBody) {
+ if (typeof data === "string") {
+ this.currentPart.body += data;
+ } else {
+ this.currentPart.body += lazy.EnigmailData.arrayBufferToString(data);
+ }
+ }
+ },
+};
+
+function processIncomingMail(url, requireBody, aMsgHdr) {
+ lazy.EnigmailLog.DEBUG("filters.jsm: processIncomingMail()\n");
+
+ let inputStream = lazy.EnigmailStreams.newStringStreamListener(msgData => {
+ let opt = {
+ strformat: "unicode",
+ bodyformat: "decode",
+ };
+
+ try {
+ let e = new JsmimeEmitter(requireBody);
+ let p = new lazy.jsmime.MimeParser(e, opt);
+ p.deliverData(msgData);
+
+ for (let c of consumerList) {
+ try {
+ c.consumeMessage(e.getMimeTree(), msgData, aMsgHdr);
+ } catch (ex) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: processIncomingMail: exception: " +
+ ex.toString() +
+ "\n"
+ );
+ }
+ }
+ } catch (ex) {}
+ });
+
+ try {
+ let channel = lazy.EnigmailStreams.createChannel(url);
+ channel.asyncOpen(inputStream, null);
+ } catch (e) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: processIncomingMail: open stream exception " +
+ e.toString() +
+ "\n"
+ );
+ }
+}
+
+function getRequireMessageProcessing(aMsgHdr) {
+ let isInbox =
+ aMsgHdr.folder.getFlag(Ci.nsMsgFolderFlags.CheckNew) ||
+ aMsgHdr.folder.getFlag(Ci.nsMsgFolderFlags.Inbox);
+ let requireBody = false;
+ let inboxOnly = true;
+ let selfSentOnly = false;
+ let processReadMail = false;
+
+ for (let c of consumerList) {
+ if (!c.incomingMailOnly) {
+ inboxOnly = false;
+ }
+ if (!c.unreadOnly) {
+ processReadMail = true;
+ }
+ if (!c.headersOnly) {
+ requireBody = true;
+ }
+ if (c.selfSentOnly) {
+ selfSentOnly = true;
+ }
+ }
+
+ if (!processReadMail && aMsgHdr.isRead) {
+ return null;
+ }
+ if (inboxOnly && !isInbox) {
+ return null;
+ }
+ if (selfSentOnly) {
+ let sender = lazy.EnigmailFuncs.parseEmails(aMsgHdr.author, true);
+ let id = null;
+ if (sender && sender[0]) {
+ id = getIdentityForSender(sender[0].email, aMsgHdr.folder.server);
+ }
+
+ if (!id) {
+ return null;
+ }
+ }
+
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: getRequireMessageProcessing: author: " + aMsgHdr.author + "\n"
+ );
+
+ let u = lazy.EnigmailFuncs.getUrlFromUriSpec(
+ aMsgHdr.folder.getUriForMsg(aMsgHdr)
+ );
+
+ if (!u) {
+ return null;
+ }
+
+ let op = u.spec.indexOf("?") > 0 ? "&" : "?";
+ let url = u.spec + op + "header=enigmailFilter";
+
+ return {
+ url,
+ requireBody,
+ };
+}
+
+const newMailListener = {
+ msgAdded(aMsgHdr) {
+ lazy.EnigmailLog.DEBUG(
+ "filters.jsm: newMailListener.msgAdded() - got new mail in " +
+ aMsgHdr.folder.prettiestName +
+ "\n"
+ );
+
+ if (consumerList.length === 0) {
+ return;
+ }
+
+ let ret = getRequireMessageProcessing(aMsgHdr);
+ if (ret) {
+ processIncomingMail(ret.url, ret.requireBody, aMsgHdr);
+ }
+ },
+};
+
+/**
+ messageStructure - Object:
+ - partNum: String - MIME part number
+ - headers: Object(nsIStructuredHeaders) - MIME part headers
+ - body: String or typedarray - the body part
+ - parent: Object(messageStructure) - link to the parent part
+ - subParts: Array of Object(messageStructure) - array of the sub-parts
+ */
+
+var EnigmailFilters = {
+ onStartup() {
+ let filterService = Cc[
+ "@mozilla.org/messenger/services/filters;1"
+ ].getService(Ci.nsIMsgFilterService);
+ filterService.addCustomTerm(filterTermPGPEncrypted);
+ initNewMailListener();
+ },
+
+ onShutdown() {
+ shutdownNewMailListener();
+ },
+
+ /**
+ * add a new consumer to listen to new mails
+ *
+ * @param consumer - Object
+ * - headersOnly: Boolean - needs full message body? [FUTURE]
+ * - incomingMailOnly: Boolean - only work on folder(s) that obtain new mail
+ * (Inbox and folders that listen to new mail)
+ * - unreadOnly: Boolean - only process unread mails
+ * - selfSentOnly: Boolean - only process mails with sender Email == Account Email
+ * - consumeMessage: function(messageStructure, rawMessageData, nsIMsgHdr)
+ */
+ addNewMailConsumer(consumer) {
+ lazy.EnigmailLog.DEBUG("filters.jsm: addNewMailConsumer()\n");
+ consumerList.push(consumer);
+ },
+
+ removeNewMailConsumer(consumer) {},
+
+ moveDecrypt: filterActionMoveDecrypt,
+ copyDecrypt: filterActionCopyDecrypt,
+ encrypt: filterActionEncrypt,
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/filtersWrapper.jsm b/comm/mail/extensions/openpgp/content/modules/filtersWrapper.jsm
new file mode 100644
index 0000000000..a43fb29e87
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/filtersWrapper.jsm
@@ -0,0 +1,186 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailFiltersWrapper"];
+
+const { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+
+var gEnigmailFilters = null;
+
+var l10n = new Localization(["messenger/openpgp/openpgp.ftl"], true);
+
+/**
+ * filter action for creating a decrypted version of the mail and
+ * deleting the original mail at the same time
+ */
+const filterActionMoveDecrypt = {
+ id: EnigmailConstants.FILTER_MOVE_DECRYPT,
+ name: l10n.formatValueSync("filter-decrypt-move-label"),
+ value: "movemessage",
+ applyAction(aMsgHdrs, aActionValue, aListener, aType, aMsgWindow) {
+ if (gEnigmailFilters) {
+ gEnigmailFilters.moveDecrypt.applyAction(
+ aMsgHdrs,
+ aActionValue,
+ aListener,
+ aType,
+ aMsgWindow
+ );
+ } else {
+ aListener.OnStartCopy();
+ aListener.OnStopCopy(0);
+ }
+ },
+
+ isValidForType(type, scope) {
+ return gEnigmailFilters
+ ? gEnigmailFilters.moveDecrypt.isValidForType(type, scope)
+ : false;
+ },
+
+ validateActionValue(value, folder, type) {
+ if (gEnigmailFilters) {
+ return gEnigmailFilters.moveDecrypt.validateActionValue(
+ value,
+ folder,
+ type
+ );
+ }
+ return null;
+ },
+
+ allowDuplicates: false,
+ isAsync: true,
+ needsBody: true,
+};
+
+/**
+ * filter action for creating a decrypted copy of the mail, leaving the original
+ * message untouched
+ */
+const filterActionCopyDecrypt = {
+ id: EnigmailConstants.FILTER_COPY_DECRYPT,
+ name: l10n.formatValueSync("filter-decrypt-copy-label"),
+ value: "copymessage",
+ applyAction(aMsgHdrs, aActionValue, aListener, aType, aMsgWindow) {
+ if (gEnigmailFilters) {
+ gEnigmailFilters.copyDecrypt.applyAction(
+ aMsgHdrs,
+ aActionValue,
+ aListener,
+ aType,
+ aMsgWindow
+ );
+ } else {
+ aListener.OnStartCopy();
+ aListener.OnStopCopy(0);
+ }
+ },
+
+ isValidForType(type, scope) {
+ return gEnigmailFilters
+ ? gEnigmailFilters.copyDecrypt.isValidForType(type, scope)
+ : false;
+ },
+
+ validateActionValue(value, folder, type) {
+ if (gEnigmailFilters) {
+ return gEnigmailFilters.copyDecrypt.validateActionValue(
+ value,
+ folder,
+ type
+ );
+ }
+ return null;
+ },
+
+ allowDuplicates: false,
+ isAsync: true,
+ needsBody: true,
+};
+
+/**
+ * filter action for to encrypt a mail to a specific key
+ */
+const filterActionEncrypt = {
+ id: EnigmailConstants.FILTER_ENCRYPT,
+ name: l10n.formatValueSync("filter-encrypt-label"),
+ value: "encryptto",
+ applyAction(aMsgHdrs, aActionValue, aListener, aType, aMsgWindow) {
+ if (gEnigmailFilters) {
+ gEnigmailFilters.encrypt.applyAction(
+ aMsgHdrs,
+ aActionValue,
+ aListener,
+ aType,
+ aMsgWindow
+ );
+ } else {
+ aListener.OnStartCopy();
+ aListener.OnStopCopy(0);
+ }
+ },
+
+ isValidForType(type, scope) {
+ return gEnigmailFilters ? gEnigmailFilters.encrypt.isValidForType() : false;
+ },
+
+ validateActionValue(value, folder, type) {
+ if (gEnigmailFilters) {
+ return gEnigmailFilters.encrypt.validateActionValue(value, folder, type);
+ }
+ return null;
+ },
+
+ allowDuplicates: false,
+ isAsync: true,
+ needsBody: true,
+};
+
+/**
+ * Add a custom filter action. If the filter already exists, do nothing
+ * (for example, if addon is disabled and re-enabled)
+ *
+ * @param filterObj - nsIMsgFilterCustomAction
+ */
+function addFilterIfNotExists(filterObj) {
+ let filterService = Cc[
+ "@mozilla.org/messenger/services/filters;1"
+ ].getService(Ci.nsIMsgFilterService);
+
+ let foundFilter = null;
+ try {
+ foundFilter = filterService.getCustomAction(filterObj.id);
+ } catch (ex) {}
+
+ if (!foundFilter) {
+ filterService.addCustomAction(filterObj);
+ }
+}
+
+var EnigmailFiltersWrapper = {
+ onStartup() {
+ let { EnigmailFilters } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/filters.jsm"
+ );
+ gEnigmailFilters = EnigmailFilters;
+
+ addFilterIfNotExists(filterActionMoveDecrypt);
+ addFilterIfNotExists(filterActionCopyDecrypt);
+ addFilterIfNotExists(filterActionEncrypt);
+
+ gEnigmailFilters.onStartup();
+ },
+
+ onShutdown() {
+ gEnigmailFilters.onShutdown();
+ gEnigmailFilters = null;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/fixExchangeMsg.jsm b/comm/mail/extensions/openpgp/content/modules/fixExchangeMsg.jsm
new file mode 100644
index 0000000000..753041cf1c
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/fixExchangeMsg.jsm
@@ -0,0 +1,433 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailFixExchangeMsg"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+ EnigmailStreams: "chrome://openpgp/content/modules/streams.jsm",
+ EnigmailPersistentCrypto:
+ "chrome://openpgp/content/modules/persistentCrypto.jsm",
+});
+
+var EnigmailFixExchangeMsg = {
+ /*
+ * Fix a broken message from MS-Exchange and replace it with the original message
+ *
+ * @param {nsIMsgDBHdr} hdr - Header of the message to fix (= pointer to message)
+ * @param {string} brokenByApp - Type of app that created the message. Currently one of
+ * exchange, iPGMail
+ * @param {string} [destFolderUri] optional destination Folder URI
+ *
+ * @return {nsMsgKey} upon success, the promise returns the messageKey
+ */
+ async fixExchangeMessage(hdr, brokenByApp, destFolderUri = null) {
+ let msgUriSpec = hdr.folder.getUriForMsg(hdr);
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: fixExchangeMessage: msgUriSpec: " + msgUriSpec + "\n"
+ );
+
+ this.hdr = hdr;
+ this.brokenByApp = brokenByApp;
+ this.destFolderUri = destFolderUri;
+
+ this.msgSvc = MailServices.messageServiceFromURI(msgUriSpec);
+
+ let fixedMsgData = await this.getMessageBody();
+
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: fixExchangeMessage: got fixedMsgData\n"
+ );
+ this.ensureExpectedStructure(fixedMsgData);
+ return lazy.EnigmailPersistentCrypto.copyMessageToFolder(
+ this.hdr,
+ this.destFolderUri,
+ true,
+ fixedMsgData,
+ null
+ );
+ },
+
+ getMessageBody() {
+ lazy.EnigmailLog.DEBUG("fixExchangeMsg.jsm: getMessageBody:\n");
+
+ var self = this;
+
+ return new Promise(function (resolve, reject) {
+ let url = lazy.EnigmailFuncs.getUrlFromUriSpec(
+ self.hdr.folder.getUriForMsg(self.hdr)
+ );
+
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getting data from URL " + url + "\n"
+ );
+
+ let s = lazy.EnigmailStreams.newStringStreamListener(function (data) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: analyzeDecryptedData: got " +
+ data.length +
+ " bytes\n"
+ );
+
+ if (lazy.EnigmailLog.getLogLevel() > 5) {
+ lazy.EnigmailLog.DEBUG(
+ "*** start data ***\n'" + data + "'\n***end data***\n"
+ );
+ }
+
+ let [good, errorCode, msg] = self.getRepairedMessage(data);
+
+ if (!good) {
+ reject(errorCode);
+ } else {
+ resolve(msg);
+ }
+ });
+
+ try {
+ let channel = lazy.EnigmailStreams.createChannel(url);
+ channel.asyncOpen(s, null);
+ } catch (e) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getMessageBody: exception " + e + "\n"
+ );
+ }
+ });
+ },
+
+ getRepairedMessage(data) {
+ this.determineCreatorApp(data);
+
+ let hdrEnd = data.search(/\r?\n\r?\n/);
+
+ if (hdrEnd <= 0) {
+ // cannot find end of header data
+ return [false, 0, ""];
+ }
+
+ let hdrLines = data.substr(0, hdrEnd).split(/\r?\n/);
+ let hdrObj = this.getFixedHeaderData(hdrLines);
+
+ if (hdrObj.headers.length === 0 || hdrObj.boundary.length === 0) {
+ return [false, 1, ""];
+ }
+
+ let boundary = hdrObj.boundary;
+ let body;
+
+ switch (this.brokenByApp) {
+ case "exchange":
+ body = this.getCorrectedExchangeBodyData(
+ data.substr(hdrEnd + 2),
+ boundary
+ );
+ break;
+ case "iPGMail":
+ body = this.getCorrectediPGMailBodyData(
+ data.substr(hdrEnd + 2),
+ boundary
+ );
+ break;
+ default:
+ lazy.EnigmailLog.ERROR(
+ "fixExchangeMsg.jsm: getRepairedMessage: unknown appType " +
+ this.brokenByApp +
+ "\n"
+ );
+ return [false, 99, ""];
+ }
+
+ if (body) {
+ return [true, 0, hdrObj.headers + "\r\n" + body];
+ }
+ return [false, 22, ""];
+ },
+
+ determineCreatorApp(msgData) {
+ // perform extra testing if iPGMail is assumed
+ if (this.brokenByApp === "exchange") {
+ return;
+ }
+
+ let msgTree = lazy.EnigmailMime.getMimeTree(msgData, false);
+
+ try {
+ let isIPGMail =
+ msgTree.subParts.length === 3 &&
+ (msgTree.subParts[0].headers.get("content-type").type.toLowerCase() ===
+ "text/plain" ||
+ msgTree.subParts[0].headers.get("content-type").type.toLowerCase() ===
+ "multipart/alternative") &&
+ msgTree.subParts[1].headers.get("content-type").type.toLowerCase() ===
+ "application/pgp-encrypted" &&
+ msgTree.subParts[2].headers.get("content-type").type.toLowerCase() ===
+ "text/plain";
+
+ if (!isIPGMail) {
+ this.brokenByApp = "exchange";
+ }
+ } catch (x) {}
+ },
+
+ /**
+ * repair header data, such that they are working for PGP/MIME
+ *
+ * @return: object: {
+ * headers: String - all headers ready for appending to message
+ * boundary: String - MIME part boundary (incl. surrounding "" or '')
+ * }
+ */
+ getFixedHeaderData(hdrLines) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getFixedHeaderData: hdrLines[]:'" +
+ hdrLines.length +
+ "'\n"
+ );
+ let r = {
+ headers: "",
+ boundary: "",
+ };
+
+ for (let i = 0; i < hdrLines.length; i++) {
+ if (hdrLines[i].search(/^content-type:/i) >= 0) {
+ // Join the rest of the content type lines together.
+ // See RFC 2425, section 5.8.1
+ let contentTypeLine = hdrLines[i];
+ i++;
+ while (i < hdrLines.length) {
+ let endOfCTL = false;
+ // Does the line start with a space or a tab, followed by something else?
+ if (hdrLines[i].search(/^[ \t]+?/) === 0) {
+ contentTypeLine += hdrLines[i];
+ i++;
+ if (i == hdrLines.length) {
+ endOfCTL = true;
+ }
+ } else {
+ endOfCTL = true;
+ }
+ if (endOfCTL) {
+ // we got the complete content-type header
+ contentTypeLine = contentTypeLine.replace(/[\r\n]/g, "");
+ let h = lazy.EnigmailFuncs.getHeaderData(contentTypeLine);
+ r.boundary = h.boundary || "";
+ break;
+ }
+ }
+ } else {
+ r.headers += hdrLines[i] + "\r\n";
+ }
+ }
+
+ r.boundary = r.boundary.replace(/^(['"])(.*)(\1)$/, "$2");
+
+ r.headers +=
+ "Content-Type: multipart/encrypted;\r\n" +
+ ' protocol="application/pgp-encrypted";\r\n' +
+ ' boundary="' +
+ r.boundary +
+ '"\r\n' +
+ "X-Enigmail-Info: Fixed broken PGP/MIME message\r\n";
+
+ return r;
+ },
+
+ /**
+ * Get corrected body for MS-Exchange messages
+ */
+ getCorrectedExchangeBodyData(bodyData, boundary) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectedExchangeBodyData: boundary='" +
+ boundary +
+ "'\n"
+ );
+ // Escape regex chars in the boundary.
+ boundary = boundary.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
+ let boundRx = new RegExp("^--" + boundary, "gm");
+ let match = boundRx.exec(bodyData);
+
+ if (match.index < 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectedExchangeBodyData: did not find index of mime type to skip\n"
+ );
+ return null;
+ }
+
+ let skipStart = match.index;
+ // found first instance -- that's the message part to ignore
+ match = boundRx.exec(bodyData);
+ if (match.index <= 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectedExchangeBodyData: did not find boundary of PGP/MIME version identification\n"
+ );
+ return null;
+ }
+
+ let versionIdent = match.index;
+
+ if (
+ bodyData
+ .substring(skipStart, versionIdent)
+ .search(
+ /^content-type:[ \t]*(text\/(plain|html)|multipart\/alternative)/im
+ ) < 0
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectedExchangeBodyData: first MIME part is not content-type text/plain or text/html\n"
+ );
+ return null;
+ }
+
+ match = boundRx.exec(bodyData);
+ if (match.index < 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectedExchangeBodyData: did not find boundary of PGP/MIME encrypted data\n"
+ );
+ return null;
+ }
+
+ let encData = match.index;
+ let mimeHdr = Cc["@mozilla.org/messenger/mimeheaders;1"].createInstance(
+ Ci.nsIMimeHeaders
+ );
+ mimeHdr.initialize(bodyData.substring(versionIdent, encData));
+ let ct = mimeHdr.extractHeader("content-type", false);
+
+ if (!ct || ct.search(/application\/pgp-encrypted/i) < 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectedExchangeBodyData: wrong content-type of version-identification\n"
+ );
+ lazy.EnigmailLog.DEBUG(" ct = '" + ct + "'\n");
+ return null;
+ }
+
+ mimeHdr.initialize(bodyData.substr(encData, 5000));
+ ct = mimeHdr.extractHeader("content-type", false);
+ if (!ct || ct.search(/application\/octet-stream/i) < 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectedExchangeBodyData: wrong content-type of PGP/MIME data\n"
+ );
+ lazy.EnigmailLog.DEBUG(" ct = '" + ct + "'\n");
+ return null;
+ }
+
+ return bodyData.substr(versionIdent);
+ },
+
+ /**
+ * Get corrected body for iPGMail messages
+ */
+ getCorrectediPGMailBodyData(bodyData, boundary) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectediPGMailBodyData: boundary='" +
+ boundary +
+ "'\n"
+ );
+ // Escape regex chars.
+ boundary = boundary.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
+ let boundRx = new RegExp("^--" + boundary, "gm");
+ let match = boundRx.exec(bodyData);
+
+ if (match.index < 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectediPGMailBodyData: did not find index of mime type to skip\n"
+ );
+ return null;
+ }
+
+ // found first instance -- that's the message part to ignore
+ match = boundRx.exec(bodyData);
+ if (match.index <= 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectediPGMailBodyData: did not find boundary of text/plain msg part\n"
+ );
+ return null;
+ }
+
+ let encData = match.index;
+
+ match = boundRx.exec(bodyData);
+ if (match.index < 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectediPGMailBodyData: did not find end boundary of PGP/MIME encrypted data\n"
+ );
+ return null;
+ }
+
+ let mimeHdr = Cc["@mozilla.org/messenger/mimeheaders;1"].createInstance(
+ Ci.nsIMimeHeaders
+ );
+
+ mimeHdr.initialize(bodyData.substr(encData, 5000));
+ let ct = mimeHdr.extractHeader("content-type", false);
+ if (!ct || ct.search(/application\/pgp-encrypted/i) < 0) {
+ lazy.EnigmailLog.DEBUG(
+ "fixExchangeMsg.jsm: getCorrectediPGMailBodyData: wrong content-type of PGP/MIME data\n"
+ );
+ lazy.EnigmailLog.DEBUG(" ct = '" + ct + "'\n");
+ return null;
+ }
+
+ return (
+ "--" +
+ boundary +
+ "\r\n" +
+ "Content-Type: application/pgp-encrypted\r\n" +
+ "Content-Description: PGP/MIME version identification\r\n\r\n" +
+ "Version: 1\r\n\r\n" +
+ bodyData
+ .substring(encData, match.index)
+ .replace(
+ /^Content-Type: +application\/pgp-encrypted/im,
+ "Content-Type: application/octet-stream"
+ ) +
+ "--" +
+ boundary +
+ "--\r\n"
+ );
+ },
+
+ ensureExpectedStructure(msgData) {
+ let msgTree = lazy.EnigmailMime.getMimeTree(msgData, true);
+
+ // check message structure
+ let ok =
+ msgTree.headers.get("content-type").type.toLowerCase() ===
+ "multipart/encrypted" &&
+ msgTree.headers.get("content-type").get("protocol").toLowerCase() ===
+ "application/pgp-encrypted" &&
+ msgTree.subParts.length === 2 &&
+ msgTree.subParts[0].headers.get("content-type").type.toLowerCase() ===
+ "application/pgp-encrypted" &&
+ msgTree.subParts[1].headers.get("content-type").type.toLowerCase() ===
+ "application/octet-stream";
+
+ if (ok) {
+ // check for existence of PGP Armor
+ let body = msgTree.subParts[1].body;
+ let p0 = body.search(/^-----BEGIN PGP MESSAGE-----$/m);
+ let p1 = body.search(/^-----END PGP MESSAGE-----$/m);
+
+ ok = p0 >= 0 && p1 > p0 + 32;
+ }
+ if (!ok) {
+ throw new Error("unexpected MIME structure");
+ }
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/funcs.jsm b/comm/mail/extensions/openpgp/content/modules/funcs.jsm
new file mode 100644
index 0000000000..469b71e71c
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/funcs.jsm
@@ -0,0 +1,561 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+/*
+ * Common Enigmail crypto-related GUI functionality
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailFuncs"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+});
+
+var gTxtConverter = null;
+
+var EnigmailFuncs = {
+ /**
+ * get a list of plain email addresses without name or surrounding <>
+ *
+ * @param mailAddrs |string| - address-list encdoded in Unicode as specified in RFC 2822, 3.4
+ * separated by , or ;
+ *
+ * @returns |string| - list of pure email addresses separated by ","
+ */
+ stripEmail(mailAddresses) {
+ // EnigmailLog.DEBUG("funcs.jsm: stripEmail(): mailAddresses=" + mailAddresses + "\n");
+
+ const SIMPLE = "[^<>,]+"; // RegExp for a simple email address (e.g. a@b.c)
+ const COMPLEX = "[^<>,]*<[^<>, ]+>"; // RegExp for an address containing <...> (e.g. Name <a@b.c>)
+ const MatchAddr = new RegExp(
+ "^(" + SIMPLE + "|" + COMPLEX + ")(," + SIMPLE + "|," + COMPLEX + ")*$"
+ );
+
+ let mailAddrs = mailAddresses;
+
+ let qStart, qEnd;
+ while ((qStart = mailAddrs.indexOf('"')) >= 0) {
+ qEnd = mailAddrs.indexOf('"', qStart + 1);
+ if (qEnd < 0) {
+ lazy.EnigmailLog.ERROR(
+ "funcs.jsm: stripEmail: Unmatched quote in mail address: '" +
+ mailAddresses +
+ "'\n"
+ );
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ mailAddrs =
+ mailAddrs.substring(0, qStart) + mailAddrs.substring(qEnd + 1);
+ }
+
+ // replace any ";" by ","; remove leading/trailing ","
+ mailAddrs = mailAddrs
+ .replace(/[,;]+/g, ",")
+ .replace(/^,/, "")
+ .replace(/,$/, "");
+
+ if (mailAddrs.length === 0) {
+ return "";
+ }
+
+ // having two <..> <..> in one email, or things like <a@b.c,><d@e.f> is an error
+ if (mailAddrs.search(MatchAddr) < 0) {
+ lazy.EnigmailLog.ERROR(
+ "funcs.jsm: stripEmail: Invalid <..> brackets in mail address: '" +
+ mailAddresses +
+ "'\n"
+ );
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ // We know that the "," and the < > are at the right places, thus we can split by ","
+ let addrList = mailAddrs.split(/,/);
+
+ for (let i in addrList) {
+ // Extract pure e-mail address list (strip out anything before angle brackets and any whitespace)
+ addrList[i] = addrList[i]
+ .replace(/^([^<>]*<)([^<>]+)(>)$/, "$2")
+ .replace(/\s/g, "");
+ }
+
+ // remove repeated, trailing and leading "," (again, as there may be empty addresses)
+ mailAddrs = addrList
+ .join(",")
+ .replace(/,,/g, ",")
+ .replace(/^,/, "")
+ .replace(/,$/, "");
+
+ return mailAddrs;
+ },
+
+ /**
+ * get an array of email object (email, name) from an address string
+ *
+ * @param mailAddrs |string| - address-list as specified in RFC 2822, 3.4
+ * separated by ","; encoded according to RFC 2047
+ *
+ * @returns |array| of msgIAddressObject
+ */
+ parseEmails(mailAddrs, encoded = true) {
+ try {
+ let hdr = Cc["@mozilla.org/messenger/headerparser;1"].createInstance(
+ Ci.nsIMsgHeaderParser
+ );
+ if (encoded) {
+ return hdr.parseEncodedHeader(mailAddrs, "utf-8");
+ }
+ return hdr.parseDecodedHeader(mailAddrs);
+ } catch (ex) {}
+
+ return [];
+ },
+
+ /**
+ * Hide all menu entries and other XUL elements that are considered for
+ * advanced users. The XUL items must contain 'advanced="true"' or
+ * 'advanced="reverse"'.
+ *
+ * @obj: |object| - XUL tree element
+ * @attribute: |string| - attribute to set or remove (i.e. "hidden" or "collapsed")
+ * @dummy: |object| - anything
+ *
+ * no return value
+ */
+
+ collapseAdvanced(obj, attribute, dummy) {
+ lazy.EnigmailLog.DEBUG("funcs.jsm: collapseAdvanced:\n");
+
+ var advancedUser = Services.prefs.getBoolPref("temp.openpgp.advancedUser");
+
+ obj = obj.firstChild;
+ while (obj) {
+ if ("getAttribute" in obj) {
+ if (obj.getAttribute("advanced") == "true") {
+ if (advancedUser) {
+ obj.removeAttribute(attribute);
+ } else {
+ obj.setAttribute(attribute, "true");
+ }
+ } else if (obj.getAttribute("advanced") == "reverse") {
+ if (advancedUser) {
+ obj.setAttribute(attribute, "true");
+ } else {
+ obj.removeAttribute(attribute);
+ }
+ }
+ }
+
+ obj = obj.nextSibling;
+ }
+ },
+
+ /**
+ * this function tries to mimic the Thunderbird plaintext viewer
+ *
+ * @plainTxt - |string| containing the plain text data
+ *
+ * @ return HTML markup to display mssage
+ */
+
+ formatPlaintextMsg(plainTxt) {
+ if (!gTxtConverter) {
+ gTxtConverter = Cc["@mozilla.org/txttohtmlconv;1"].createInstance(
+ Ci.mozITXTToHTMLConv
+ );
+ }
+
+ var fontStyle = "";
+
+ // set the style stuff according to preferences
+
+ switch (Services.prefs.getIntPref("mail.quoted_style")) {
+ case 1:
+ fontStyle = "font-weight: bold; ";
+ break;
+ case 2:
+ fontStyle = "font-style: italic; ";
+ break;
+ case 3:
+ fontStyle = "font-weight: bold; font-style: italic; ";
+ break;
+ }
+
+ switch (Services.prefs.getIntPref("mail.quoted_size")) {
+ case 1:
+ fontStyle += "font-size: large; ";
+ break;
+ case 2:
+ fontStyle += "font-size: small; ";
+ break;
+ }
+
+ fontStyle +=
+ "color: " + Services.prefs.getCharPref("mail.citation_color") + ";";
+
+ var convFlags = Ci.mozITXTToHTMLConv.kURLs;
+ if (Services.prefs.getBoolPref("mail.display_glyph")) {
+ convFlags |= Ci.mozITXTToHTMLConv.kGlyphSubstitution;
+ }
+ if (Services.prefs.getBoolPref("mail.display_struct")) {
+ convFlags |= Ci.mozITXTToHTMLConv.kStructPhrase;
+ }
+
+ // start processing the message
+
+ plainTxt = plainTxt.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+ var lines = plainTxt.split(/\n/);
+ var oldCiteLevel = 0;
+ var citeLevel = 0;
+ var preface = "";
+ var logLineStart = {
+ value: 0,
+ };
+ var isSignature = false;
+
+ for (var i = 0; i < lines.length; i++) {
+ preface = "";
+ oldCiteLevel = citeLevel;
+ if (lines[i].search(/^[> \t]*>$/) === 0) {
+ lines[i] += " ";
+ }
+
+ citeLevel = gTxtConverter.citeLevelTXT(lines[i], logLineStart);
+
+ if (citeLevel > oldCiteLevel) {
+ preface = "</pre>";
+ for (let j = 0; j < citeLevel - oldCiteLevel; j++) {
+ preface += '<blockquote type="cite" style="' + fontStyle + '">';
+ }
+ preface += '<pre wrap="">\n';
+ } else if (citeLevel < oldCiteLevel) {
+ preface = "</pre>";
+ for (let j = 0; j < oldCiteLevel - citeLevel; j++) {
+ preface += "</blockquote>";
+ }
+
+ preface += '<pre wrap="">\n';
+ }
+
+ if (logLineStart.value > 0) {
+ preface +=
+ '<span class="moz-txt-citetags">' +
+ gTxtConverter.scanTXT(
+ lines[i].substr(0, logLineStart.value),
+ convFlags
+ ) +
+ "</span>";
+ } else if (lines[i] == "-- ") {
+ preface += '<div class="moz-txt-sig">';
+ isSignature = true;
+ }
+ lines[i] =
+ preface +
+ gTxtConverter.scanTXT(lines[i].substr(logLineStart.value), convFlags);
+ }
+
+ var r =
+ '<pre wrap="">' +
+ lines.join("\n") +
+ (isSignature ? "</div>" : "") +
+ "</pre>";
+ //EnigmailLog.DEBUG("funcs.jsm: r='"+r+"'\n");
+ return r;
+ },
+
+ /**
+ * extract the data fields following a header.
+ * e.g. ContentType: xyz; Aa=b; cc=d
+ *
+ * @data: |string| containing a single header
+ *
+ * @returns |array| of |arrays| containing pairs of aa/b and cc/d
+ */
+ getHeaderData(data) {
+ lazy.EnigmailLog.DEBUG(
+ "funcs.jsm: getHeaderData: " + data.substr(0, 100) + "\n"
+ );
+ var a = data.split(/\n/);
+ var res = [];
+ for (let i = 0; i < a.length; i++) {
+ if (a[i].length === 0) {
+ break;
+ }
+ let b = a[i].split(/;/);
+
+ // extract "abc = xyz" tuples
+ for (let j = 0; j < b.length; j++) {
+ let m = b[j].match(/^(\s*)([^=\s;]+)(\s*)(=)(\s*)(.*)(\s*)$/);
+ if (m) {
+ // m[2]: identifier / m[6]: data
+ res[m[2].toLowerCase()] = m[6].replace(/\s*$/, "");
+ lazy.EnigmailLog.DEBUG(
+ "funcs.jsm: getHeaderData: " +
+ m[2].toLowerCase() +
+ " = " +
+ res[m[2].toLowerCase()] +
+ "\n"
+ );
+ }
+ }
+ if (i === 0 && !a[i].includes(";")) {
+ break;
+ }
+ if (i > 0 && a[i].search(/^\s/) < 0) {
+ break;
+ }
+ }
+ return res;
+ },
+
+ /***
+ * Get the text for the encrypted subject (either configured by user or default)
+ */
+ getProtectedSubjectText() {
+ return "...";
+ },
+
+ cloneObj(orig) {
+ let newObj;
+
+ if (typeof orig !== "object" || orig === null || orig === undefined) {
+ return orig;
+ }
+
+ if ("clone" in orig && typeof orig.clone === "function") {
+ return orig.clone();
+ }
+
+ if (Array.isArray(orig) && orig.length > 0) {
+ newObj = [];
+ for (let i in orig) {
+ if (typeof orig[i] === "object") {
+ newObj.push(this.cloneObj(orig[i]));
+ } else {
+ newObj.push(orig[i]);
+ }
+ }
+ } else {
+ newObj = {};
+ for (let i in orig) {
+ if (typeof orig[i] === "object") {
+ newObj[i] = this.cloneObj(orig[i]);
+ } else {
+ newObj[i] = orig[i];
+ }
+ }
+ }
+
+ return newObj;
+ },
+
+ /**
+ * Compare two MIME part numbers to determine which of the two is earlier in the tree
+ * MIME part numbers have the structure "x.y.z...", e.g 1, 1.2, 2.3.1.4.5.1.2
+ *
+ * @param mime1, mime2 - String the two mime part numbers to compare.
+ *
+ * @returns Number (one of -2, -1, 0, 1 , 2)
+ * - Negative number if mime1 is before mime2
+ * - Positive number if mime1 is after mime2
+ * - 0 if mime1 and mime2 are equal
+ * - if mime1 is a parent of mime2 the return value is -2
+ * - if mime2 is a parent of mime1 the return value is 2
+ *
+ * Throws an error if mime1 or mime2 do not comply to the required format
+ */
+ compareMimePartLevel(mime1, mime2) {
+ let s = new RegExp("^[0-9]+(\\.[0-9]+)*$");
+ if (mime1.search(s) < 0) {
+ throw new Error("Invalid mime1");
+ }
+ if (mime2.search(s) < 0) {
+ throw new Error("Invalid mime2");
+ }
+
+ let a1 = mime1.split(/\./);
+ let a2 = mime2.split(/\./);
+
+ for (let i = 0; i < Math.min(a1.length, a2.length); i++) {
+ if (Number(a1[i]) < Number(a2[i])) {
+ return -1;
+ }
+ if (Number(a1[i]) > Number(a2[i])) {
+ return 1;
+ }
+ }
+
+ if (a2.length > a1.length) {
+ return -2;
+ }
+ if (a2.length < a1.length) {
+ return 2;
+ }
+ return 0;
+ },
+
+ /**
+ * Get the nsIMsgAccount associated with a given nsIMsgIdentity
+ */
+ getAccountForIdentity(identity) {
+ for (let ac of MailServices.accounts.accounts) {
+ for (let id of ac.identities) {
+ if (id.key === identity.key) {
+ return ac;
+ }
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Get the default identity of the default account
+ */
+ getDefaultIdentity() {
+ try {
+ let ac;
+ if (MailServices.accounts.defaultAccount) {
+ ac = MailServices.accounts.defaultAccount;
+ } else {
+ for (ac of MailServices.accounts.accounts) {
+ if (
+ ac.incomingServer.type === "imap" ||
+ ac.incomingServer.type === "pop3"
+ ) {
+ break;
+ }
+ }
+ }
+
+ if (ac.defaultIdentity) {
+ return ac.defaultIdentity;
+ }
+ return ac.identities[0];
+ } catch (x) {
+ return null;
+ }
+ },
+
+ /**
+ * Get a list of all own email addresses, taken from all identities
+ * and all reply-to addresses
+ */
+ getOwnEmailAddresses() {
+ let ownEmails = {};
+
+ // Determine all sorts of own email addresses
+ for (let id of MailServices.accounts.allIdentities) {
+ if (id.email && id.email.length > 0) {
+ ownEmails[id.email.toLowerCase()] = 1;
+ }
+ if (id.replyTo && id.replyTo.length > 0) {
+ try {
+ let replyEmails = this.stripEmail(id.replyTo)
+ .toLowerCase()
+ .split(/,/);
+ for (let j in replyEmails) {
+ ownEmails[replyEmails[j]] = 1;
+ }
+ } catch (ex) {}
+ }
+ }
+
+ return ownEmails;
+ },
+
+ /**
+ * Determine the distinct number of non-self recipients of a message.
+ * Only To: and Cc: fields are considered.
+ */
+ getNumberOfRecipients(msgCompField) {
+ let recipients = {},
+ ownEmails = this.getOwnEmailAddresses();
+
+ let allAddr = (
+ this.stripEmail(msgCompField.to) +
+ "," +
+ this.stripEmail(msgCompField.cc)
+ ).toLowerCase();
+ let emails = allAddr.split(/,+/);
+
+ for (let i = 0; i < emails.length; i++) {
+ let r = emails[i];
+ if (r && !(r in ownEmails)) {
+ recipients[r] = 1;
+ }
+ }
+
+ return recipients.length;
+ },
+
+ /**
+ * Get a mail URL from a uriSpec.
+ *
+ * @param {string} uriSpec - URL spec of the desired message.
+ *
+ * @returns {nsIURL|nsIMsgMailNewsUrl|null} The necko url.
+ */
+ getUrlFromUriSpec(uriSpec) {
+ try {
+ if (!uriSpec) {
+ return null;
+ }
+
+ let msgService = MailServices.messageServiceFromURI(uriSpec);
+ let url = msgService.getUrlForUri(uriSpec);
+
+ if (url.scheme == "file") {
+ return url;
+ }
+
+ return url.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ } catch (ex) {
+ return null;
+ }
+ },
+
+ /**
+ * Test if the given string looks roughly like an email address,
+ * returns true or false.
+ */
+ stringLooksLikeEmailAddress(str) {
+ return /^[^ @]+@[^ @]+$/.test(str);
+ },
+
+ /**
+ * Extract an email address from the given string, using MailServices.
+ * However, be more strict, and avoid strings that appear to be
+ * invalid addresses.
+ *
+ * If more than one email address is found, only return the first.
+ *
+ * If we fail to extract an email address from the given string,
+ * because the given string doesn't conform to expectations,
+ * an empty string is returned.
+ */
+ getEmailFromUserID(uid) {
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(uid);
+ if (
+ !addresses[0] ||
+ !EnigmailFuncs.stringLooksLikeEmailAddress(addresses[0].email)
+ ) {
+ console.debug("failed to extract email address from: " + uid);
+ return "";
+ }
+
+ return addresses[0].email.trim();
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/key.jsm b/comm/mail/extensions/openpgp/content/modules/key.jsm
new file mode 100644
index 0000000000..06f9779b0f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/key.jsm
@@ -0,0 +1,285 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailKey"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+var EnigmailKey = {
+ /**
+ * Format a key fingerprint
+ *
+ * @fingerprint |string| - unformatted OpenPGP fingerprint
+ *
+ * @returns |string| - formatted string
+ */
+ formatFpr(fingerprint) {
+ //EnigmailLog.DEBUG("key.jsm: EnigmailKey.formatFpr(" + fingerprint + ")\n");
+ // format key fingerprint
+ let r = "";
+ const fpr = fingerprint.match(
+ /(....)(....)(....)(....)(....)(....)(....)(....)(....)?(....)?/
+ );
+ if (fpr && fpr.length > 2) {
+ fpr.shift();
+ r = fpr.join(" ");
+ }
+
+ return r;
+ },
+
+ // Extract public key from Status Message
+ extractPubkey(statusMsg) {
+ const matchb = statusMsg.match(/(^|\n)NO_PUBKEY (\w{8})(\w{8})/);
+ if (matchb && matchb.length > 3) {
+ lazy.EnigmailLog.DEBUG(
+ "Enigmail.extractPubkey: NO_PUBKEY 0x" + matchb[3] + "\n"
+ );
+ return matchb[2] + matchb[3];
+ }
+ return null;
+ },
+
+ /**
+ * import a revocation certificate form a given keyblock string.
+ * Ask the user before importing the cert, and display an error
+ * message in case of failures.
+ */
+ importRevocationCert(keyId, keyBlockStr) {
+ let key = lazy.EnigmailKeyRing.getKeyById(keyId);
+
+ if (key) {
+ if (key.keyTrust === "r") {
+ // Key has already been revoked
+ lazy.l10n
+ .formatValue("revoke-key-already-revoked", {
+ keyId,
+ })
+ .then(value => {
+ lazy.EnigmailDialog.info(null, value);
+ });
+ } else {
+ let userId = key.userId + " - 0x" + key.keyId;
+ if (
+ !lazy.EnigmailDialog.confirmDlg(
+ null,
+ lazy.l10n.formatValueSync("revoke-key-question", { userId }),
+ lazy.l10n.formatValueSync("key-man-button-revoke-key")
+ )
+ ) {
+ return;
+ }
+
+ let errorMsgObj = {};
+ // TODO this will certainly not work yet, because RNP requires
+ // calling a different function for importing revocation
+ // signatures, see RNP.importRevImpl
+ if (
+ lazy.EnigmailKeyRing.importKey(
+ null,
+ false,
+ keyBlockStr,
+ false,
+ keyId,
+ errorMsgObj
+ ) > 0
+ ) {
+ lazy.EnigmailDialog.alert(null, errorMsgObj.value);
+ }
+ }
+ } else {
+ // Suitable key for revocation certificate is not present in keyring
+ lazy.l10n
+ .formatValue("revoke-key-not-present", {
+ keyId,
+ })
+ .then(value => {
+ lazy.EnigmailDialog.alert(null, value);
+ });
+ }
+ },
+
+ _keyListCache: new Map(),
+ _keyListCacheMaxEntries: 50,
+ _keyListCacheMaxKeySize: 30720,
+
+ /**
+ * Get details (key ID, UID) of the data contained in a OpenPGP key block
+ *
+ * @param {string} keyBlockStr - the contents of one or more public keys
+ * @param {object} errorMsgObj - obj.value will contain an error message in case of failures
+ * @param {boolean} interactive - if in interactive mode, may display dialogs (default: true)
+ * @param {boolean} pubkey - load public keys from the given block
+ * @param {boolean} seckey - load secret keys from the given block
+ *
+ * @returns {object[]} an array of objects with the following structure:
+ * - id (key ID)
+ * - fpr
+ * - name (the UID of the key)
+ * - state (one of "old" [existing key], "new" [new key], "invalid" [key cannot not be imported])
+ */
+ async getKeyListFromKeyBlock(
+ keyBlockStr,
+ errorMsgObj,
+ interactive,
+ pubkey,
+ seckey,
+ withPubKey = false
+ ) {
+ lazy.EnigmailLog.DEBUG("key.jsm: getKeyListFromKeyBlock\n");
+ errorMsgObj.value = "";
+
+ let cacheEntry = this._keyListCache.get(keyBlockStr);
+ if (cacheEntry) {
+ // Remove and re-insert to move entry to the end of insertion order,
+ // so we know which entry was used least recently.
+ this._keyListCache.delete(keyBlockStr);
+ this._keyListCache.set(keyBlockStr, cacheEntry);
+
+ if (cacheEntry.error) {
+ errorMsgObj.value = cacheEntry.error;
+ return null;
+ }
+ return cacheEntry.data;
+ }
+
+ // We primarily want to cache single keys that are found in email
+ // attachments. We shouldn't attempt to cache larger key blocks
+ // that are likely arriving from explicit import attempts.
+ let updateCache = keyBlockStr.length < this._keyListCacheMaxKeySize;
+
+ if (
+ updateCache &&
+ this._keyListCache.size >= this._keyListCacheMaxEntries
+ ) {
+ // Remove oldest entry, make room for new entry.
+ this._keyListCache.delete(this._keyListCache.keys().next().value);
+ }
+
+ const cApi = lazy.EnigmailCryptoAPI();
+ let keyList;
+ let key = {};
+ let blocks;
+ errorMsgObj.value = "";
+
+ try {
+ keyList = await cApi.getKeyListFromKeyBlockAPI(
+ keyBlockStr,
+ pubkey,
+ seckey,
+ true,
+ withPubKey
+ );
+ } catch (ex) {
+ errorMsgObj.value = ex.toString();
+ if (updateCache && !withPubKey) {
+ this._keyListCache.set(keyBlockStr, {
+ error: errorMsgObj.value,
+ data: null,
+ });
+ }
+ return null;
+ }
+
+ if (!keyList) {
+ if (updateCache) {
+ this._keyListCache.set(keyBlockStr, { error: undefined, data: null });
+ }
+ return null;
+ }
+
+ if (interactive && keyList.length === 1) {
+ // TODO: not yet tested
+ key = keyList[0];
+ if ("revoke" in key && !("name" in key)) {
+ if (updateCache) {
+ this._keyListCache.set(keyBlockStr, { error: undefined, data: [] });
+ }
+ this.importRevocationCert(key.id, blocks.join("\n"));
+ return [];
+ }
+ }
+
+ if (updateCache) {
+ this._keyListCache.set(keyBlockStr, { error: undefined, data: keyList });
+ }
+ return keyList;
+ },
+
+ /**
+ * Get details of a key block to import. Works identically as getKeyListFromKeyBlock();
+ * except that the input is a file instead of a string
+ *
+ * @param {nsIFile} file - The file to read.
+ * @param {object} errorMsgObj - Object; obj.value will contain error message.
+ *
+ * @returns {object[]} An array of objects; see getKeyListFromKeyBlock()
+ */
+ async getKeyListFromKeyFile(
+ file,
+ errorMsgObj,
+ pubkey,
+ seckey,
+ withPubKey = false
+ ) {
+ let data = await IOUtils.read(file.path);
+ let contents = lazy.MailStringUtils.uint8ArrayToByteString(data);
+ return this.getKeyListFromKeyBlock(
+ contents,
+ errorMsgObj,
+ true,
+ pubkey,
+ seckey,
+ withPubKey
+ );
+ },
+
+ /**
+ * Compare 2 KeyIds of possible different length (short, long, FPR-length, with or without prefixed
+ * 0x are accepted)
+ *
+ * @param keyId1 string
+ * @param keyId2 string
+ *
+ * @returns true or false, given the comparison of the last minimum-length characters.
+ */
+ compareKeyIds(keyId1, keyId2) {
+ var keyId1Raw = keyId1.replace(/^0x/, "").toUpperCase();
+ var keyId2Raw = keyId2.replace(/^0x/, "").toUpperCase();
+
+ var minlength = Math.min(keyId1Raw.length, keyId2Raw.length);
+
+ if (minlength < keyId1Raw.length) {
+ // Limit keyId1 to minlength
+ keyId1Raw = keyId1Raw.substr(-minlength, minlength);
+ }
+
+ if (minlength < keyId2Raw.length) {
+ // Limit keyId2 to minlength
+ keyId2Raw = keyId2Raw.substr(-minlength, minlength);
+ }
+
+ return keyId1Raw === keyId2Raw;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/keyLookupHelper.jsm b/comm/mail/extensions/openpgp/content/modules/keyLookupHelper.jsm
new file mode 100644
index 0000000000..621b61b2ae
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/keyLookupHelper.jsm
@@ -0,0 +1,380 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["KeyLookupHelper"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CollectedKeysDB: "chrome://openpgp/content/modules/CollectedKeysDB.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ EnigmailKey: "chrome://openpgp/content/modules/key.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailKeyServer: "chrome://openpgp/content/modules/keyserver.jsm",
+ EnigmailKeyserverURIs: "chrome://openpgp/content/modules/keyserverUris.jsm",
+ EnigmailWkdLookup: "chrome://openpgp/content/modules/wkdLookup.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+var KeyLookupHelper = {
+ /**
+ * Internal helper function, search for keys by either keyID
+ * or email address on a keyserver.
+ * Returns additional flags regarding lookup and import.
+ * Will never show feedback prompts.
+ *
+ * @param {string} mode - "interactive-import" or "silent-collection"
+ * In interactive-import mode, the user will be asked to confirm
+ * import of keys into the permanent keyring.
+ * In silent-collection mode, only updates to existing keys will
+ * be imported. New keys will only be added to CollectedKeysDB.
+ * @param {nsIWindow} window - parent window
+ * @param {string} identifier - search value, either key ID or fingerprint or email address.
+ * @returns {object} flags
+ * @returns {boolean} flags.keyImported - At least one key was imported.
+ * @returns {boolean} flags.foundUpdated - At least one update for a local existing key was found and imported.
+ * @returns {boolean} flags.foundUnchanged - All found keys are identical to already existing local keys.
+ * @returns {boolean} flags.collectedForLater - At least one key was added to CollectedKeysDB.
+ */
+
+ isExpiredOrRevoked(keyTrust) {
+ return keyTrust.match(/e/i) || keyTrust.match(/r/i);
+ },
+
+ async _lookupAndImportOnKeyserver(mode, window, identifier) {
+ let keyImported = false;
+ let foundUpdated = false;
+ let foundUnchanged = false;
+ let collectedForLater = false;
+
+ let ksArray = lazy.EnigmailKeyserverURIs.getKeyServers();
+ if (!ksArray.length) {
+ return false;
+ }
+
+ let continueSearching = true;
+ for (let ks of ksArray) {
+ let foundKey;
+ if (ks.startsWith("vks://")) {
+ foundKey = await lazy.EnigmailKeyServer.downloadNoImport(
+ identifier,
+ ks
+ );
+ } else if (ks.startsWith("hkp://") || ks.startsWith("hkps://")) {
+ foundKey =
+ await lazy.EnigmailKeyServer.searchAndDownloadSingleResultNoImport(
+ identifier,
+ ks
+ );
+ }
+ if (foundKey && "keyData" in foundKey) {
+ let errorInfo = {};
+ let keyList = await lazy.EnigmailKey.getKeyListFromKeyBlock(
+ foundKey.keyData,
+ errorInfo,
+ false,
+ true,
+ false
+ );
+ // We might get a zero length keyList, if we refuse to use the key
+ // that we received because of its properties.
+ if (keyList && keyList.length == 1) {
+ let oldKey = lazy.EnigmailKeyRing.getKeyById(keyList[0].fpr);
+ if (oldKey) {
+ await lazy.EnigmailKeyRing.importKeyDataSilent(
+ window,
+ foundKey.keyData,
+ true,
+ "0x" + keyList[0].fpr
+ );
+
+ let updatedKey = lazy.EnigmailKeyRing.getKeyById(keyList[0].fpr);
+ // If new imported/merged key is equal to old key,
+ // don't notify about new keys details.
+ if (JSON.stringify(oldKey) !== JSON.stringify(updatedKey)) {
+ foundUpdated = true;
+ keyImported = true;
+ if (mode == "interactive-import") {
+ lazy.EnigmailDialog.keyImportDlg(
+ window,
+ keyList.map(a => a.id)
+ );
+ }
+ } else {
+ foundUnchanged = true;
+ }
+ } else {
+ keyList = keyList.filter(k => k.userIds.length);
+ keyList = keyList.filter(k => !this.isExpiredOrRevoked(k.keyTrust));
+ if (keyList.length && mode == "interactive-import") {
+ keyImported =
+ await lazy.EnigmailKeyRing.importKeyDataWithConfirmation(
+ window,
+ keyList,
+ foundKey.keyData,
+ true
+ );
+ if (keyImported) {
+ // In interactive mode, don't offer the user to import keys multiple times.
+ // When silently collecting keys, it's fine to discover everything we can.
+ continueSearching = false;
+ }
+ }
+ if (!keyImported) {
+ collectedForLater = true;
+ let db = await lazy.CollectedKeysDB.getInstance();
+ for (let newKey of keyList) {
+ // If key is known in the db: merge + update.
+ let key = await db.mergeExisting(newKey, foundKey.keyData, {
+ uri: lazy.EnigmailKeyServer.serverReqURL(
+ `0x${newKey.fpr}`,
+ ks
+ ),
+ type: "keyserver",
+ });
+ await db.storeKey(key);
+ }
+ }
+ }
+ } else {
+ if (keyList && keyList.length > 1) {
+ throw new Error("Unexpected multiple results from keyserver " + ks);
+ }
+ console.log(
+ "failed to process data retrieved from keyserver " +
+ ks +
+ ": " +
+ errorInfo.value
+ );
+ }
+ }
+ if (!continueSearching) {
+ break;
+ }
+ }
+
+ return { keyImported, foundUpdated, foundUnchanged, collectedForLater };
+ },
+
+ /**
+ * Search online for keys by key ID on keyserver.
+ *
+ * @param {string} mode - "interactive-import" or "silent-collection"
+ * In interactive-import mode, the user will be asked to confirm
+ * import of keys into the permanent keyring.
+ * In silent-collection mode, only updates to existing keys will
+ * be imported. New keys will only be added to CollectedKeysDB.
+ * @param {nsIWindow} window - parent window
+ * @param {string} keyId - the key ID to search for.
+ * @param {boolean} giveFeedbackToUser - false to be silent,
+ * true to show feedback to user after search and import is complete.
+ * @returns {boolean} - true if at least one key was imported.
+ */
+ async lookupAndImportByKeyID(mode, window, keyId, giveFeedbackToUser) {
+ if (!/^0x/i.test(keyId)) {
+ keyId = "0x" + keyId;
+ }
+ let importResult = await this._lookupAndImportOnKeyserver(
+ mode,
+ window,
+ keyId
+ );
+ if (
+ mode == "interactive-import" &&
+ giveFeedbackToUser &&
+ !importResult.keyImported
+ ) {
+ let msgId;
+ if (importResult.foundUnchanged) {
+ msgId = "no-update-found";
+ } else {
+ msgId = "no-key-found2";
+ }
+ let value = await lazy.l10n.formatValue(msgId);
+ lazy.EnigmailDialog.alert(window, value);
+ }
+ return importResult.keyImported;
+ },
+
+ /**
+ * Search online for keys by email address.
+ * Will search both WKD and keyserver.
+ *
+ * @param {string} mode - "interactive-import" or "silent-collection"
+ * In interactive-import mode, the user will be asked to confirm
+ * import of keys into the permanent keyring.
+ * In silent-collection mode, only updates to existing keys will
+ * be imported. New keys will only be added to CollectedKeysDB.
+ * @param {nsIWindow} window - parent window
+ * @param {string} email - the email address to search for.
+ * @param {boolean} giveFeedbackToUser - false to be silent,
+ * true to show feedback to user after search and import is complete.
+ * @returns {boolean} - true if at least one key was imported.
+ */
+ async lookupAndImportByEmail(mode, window, email, giveFeedbackToUser) {
+ let resultKeyImported = false;
+
+ let wkdKeyImported = false;
+ let wkdFoundUnchanged = false;
+
+ let wkdResult;
+ let wkdUrl;
+ if (lazy.EnigmailWkdLookup.isWkdAvailable(email)) {
+ wkdUrl = await lazy.EnigmailWkdLookup.getDownloadUrlFromEmail(
+ email,
+ true
+ );
+ wkdResult = await lazy.EnigmailWkdLookup.downloadKey(wkdUrl);
+ if (!wkdResult) {
+ wkdUrl = await lazy.EnigmailWkdLookup.getDownloadUrlFromEmail(
+ email,
+ false
+ );
+ wkdResult = await lazy.EnigmailWkdLookup.downloadKey(wkdUrl);
+ }
+ }
+
+ if (!wkdResult) {
+ console.debug("searchKeysOnInternet no wkd data for " + email);
+ } else {
+ let errorInfo = {};
+ let keyList = await lazy.EnigmailKey.getKeyListFromKeyBlock(
+ wkdResult,
+ errorInfo,
+ false,
+ true,
+ false,
+ true
+ );
+ if (!keyList) {
+ console.debug(
+ "failed to process data retrieved from WKD server: " + errorInfo.value
+ );
+ } else {
+ let existingKeys = [];
+ let newKeys = [];
+
+ for (let wkdKey of keyList) {
+ let oldKey = lazy.EnigmailKeyRing.getKeyById(wkdKey.fpr);
+ if (oldKey) {
+ await lazy.EnigmailKeyRing.importKeyDataSilent(
+ window,
+ wkdKey.pubKey,
+ true,
+ "0x" + wkdKey.fpr
+ );
+
+ let updatedKey = lazy.EnigmailKeyRing.getKeyById(wkdKey.fpr);
+ // If new imported/merged key is equal to old key,
+ // don't notify about new keys details.
+ if (JSON.stringify(oldKey) !== JSON.stringify(updatedKey)) {
+ // If a caller ever needs information what we found,
+ // this is the place to set: wkdFoundUpdated = true
+ existingKeys.push(wkdKey.id);
+ } else {
+ wkdFoundUnchanged = true;
+ }
+ } else if (wkdKey.userIds.length) {
+ newKeys.push(wkdKey);
+ }
+ }
+
+ if (existingKeys.length) {
+ if (mode == "interactive-import") {
+ lazy.EnigmailDialog.keyImportDlg(window, existingKeys);
+ }
+ wkdKeyImported = true;
+ }
+
+ newKeys = newKeys.filter(k => !this.isExpiredOrRevoked(k.keyTrust));
+ if (newKeys.length && mode == "interactive-import") {
+ wkdKeyImported =
+ wkdKeyImported ||
+ (await lazy.EnigmailKeyRing.importKeyArrayWithConfirmation(
+ window,
+ newKeys,
+ true
+ ));
+ }
+ if (!wkdKeyImported) {
+ // If a caller ever needs information what we found,
+ // this is the place to set: wkdCollectedForLater = true
+ let db = await lazy.CollectedKeysDB.getInstance();
+ for (let newKey of newKeys) {
+ // If key is known in the db: merge + update.
+ let key = await db.mergeExisting(newKey, newKey.pubKey, {
+ uri: wkdUrl,
+ type: "wkd",
+ });
+ await db.storeKey(key);
+ }
+ }
+ }
+ }
+
+ let { keyImported, foundUnchanged } =
+ await this._lookupAndImportOnKeyserver(mode, window, email);
+ resultKeyImported = wkdKeyImported || keyImported;
+
+ if (
+ mode == "interactive-import" &&
+ giveFeedbackToUser &&
+ !resultKeyImported &&
+ !keyImported
+ ) {
+ let msgId;
+ if (wkdFoundUnchanged || foundUnchanged) {
+ msgId = "no-update-found";
+ } else {
+ msgId = "no-key-found2";
+ }
+ let value = await lazy.l10n.formatValue(msgId);
+ lazy.EnigmailDialog.alert(window, value);
+ }
+
+ return resultKeyImported;
+ },
+
+ /**
+ * This function will perform discovery of new or updated OpenPGP
+ * keys using various mechanisms.
+ *
+ * @param {string} mode - "interactive-import" or "silent-collection"
+ * @param {string} email - search for keys for this email address,
+ * (parameter allowed to be null or empty)
+ * @param {string[]} keyIds - KeyIDs that should be updated.
+ * (parameter allowed to be null or empty)
+ *
+ * @returns {boolean} - Returns true if at least one key was imported.
+ */
+ async fullOnlineDiscovery(mode, window, email, keyIds) {
+ // Try to get updates for all existing keys from keyserver,
+ // by key ID, to get updated validy/revocation info.
+ // (A revoked key on the keyserver might have no user ID.)
+ let atLeastoneImport = false;
+ if (keyIds) {
+ for (let keyId of keyIds) {
+ // Ensure the function call goes first in the logic or expression,
+ // to ensure it's always called, even if atLeastoneImport is already true.
+ let rv = await this.lookupAndImportByKeyID(mode, window, keyId, false);
+ atLeastoneImport = rv || atLeastoneImport;
+ }
+ }
+ // Now check for updated or new keys by email address
+ let rv2 = await this.lookupAndImportByEmail(mode, window, email, false);
+ atLeastoneImport = rv2 || atLeastoneImport;
+ return atLeastoneImport;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/keyObj.jsm b/comm/mail/extensions/openpgp/content/modules/keyObj.jsm
new file mode 100644
index 0000000000..ed9137cb3a
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/keyObj.jsm
@@ -0,0 +1,679 @@
+/*
+ * 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 EXPORTED_SYMBOLS = ["newEnigmailKeyObj"];
+
+/**
+ This module implements the EnigmailKeyObj class with the following members:
+
+ - keyId - 16 digits (8-byte) public key ID (/not/ preceded with 0x)
+ - userId - main user ID
+ - fpr - fingerprint
+ - fprFormatted - a formatted version of the fingerprint following the scheme .... .... ....
+ - expiry - Expiry date as printable string
+ - expiryTime - Expiry time as seconds after 01/01/1970
+ - created - Key creation date as printable string
+ - keyCreated - Key creation date/time as number
+ - keyTrust - key trust code as provided by GnuPG (calculated key validity)
+ - keyUseFor - key usage type as provided by GnuPG (key capabilities)
+ - ownerTrust - owner trust as provided by GnuPG
+ - photoAvailable - [Boolean] true if photo is available
+ - secretAvailable - [Boolean] true if secret key is available
+ - algoSym - public key algorithm type (String, e.g. RSA)
+ - keySize - size of public key
+ - type - "pub" or "grp"
+ - userIds - [Array]: - Contains ALL UIDs (including the primary UID)
+ * userId - User ID
+ * keyTrust - trust level of user ID
+ * uidFpr - fingerprint of the user ID
+ * type - one of "uid" (regular user ID), "uat" (photo)
+ * uatNum - photo number (starting with 0 for each key)
+ - subKeys - [Array]:
+ * keyId - subkey ID (16 digits (8-byte))
+ * expiry - Expiry date as printable string
+ * expiryTime - Expiry time as seconds after 01/01/1970
+ * created - Subkey creation date as printable string
+ * keyCreated - Subkey creation date/time as number
+ * keyTrust - key trust code as provided by GnuPG
+ * keyUseFor - key usage type as provided by GnuPG
+ * algoSym - subkey algorithm type (String, e.g. RSA)
+ * keySize - subkey size
+ * type - "sub"
+
+ - methods:
+ * hasSubUserIds
+ * getKeyExpiry
+ * getEncryptionValidity
+ * getSigningValidity
+ * getPubKeyValidity
+ * clone
+ * getMinimalPubKey
+ * getVirtualKeySize
+*/
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKey: "chrome://openpgp/content/modules/key.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+function newEnigmailKeyObj(keyData) {
+ return new EnigmailKeyObj(keyData);
+}
+
+class EnigmailKeyObj {
+ constructor(keyData) {
+ this.keyId = "";
+ this.expiry = "";
+ this.expiryTime = 0;
+ this.created = "";
+ this.keyTrust = "";
+ this.keyUseFor = "";
+ this.ownerTrust = "";
+ this.algoSym = "";
+ this.keySize = "";
+ this.userId = "";
+ this.userIds = [];
+ this.subKeys = [];
+ this.fpr = "";
+ this.minimalKeyBlock = [];
+ this.photoAvailable = false;
+ this.secretAvailable = false;
+ this.secretMaterial = false;
+
+ this.type = keyData.type;
+ if ("keyId" in keyData) {
+ this.keyId = keyData.keyId;
+ }
+ if ("expiryTime" in keyData) {
+ this.expiryTime = keyData.expiryTime;
+ this.expiry = keyData.expiryTime
+ ? new Services.intl.DateTimeFormat().format(
+ new Date(keyData.expiryTime * 1000)
+ )
+ : "";
+ }
+ if ("effectiveExpiryTime" in keyData) {
+ this.effectiveExpiryTime = keyData.effectiveExpiryTime;
+ this.effectiveExpiry = keyData.effectiveExpiryTime
+ ? new Services.intl.DateTimeFormat().format(
+ new Date(keyData.effectiveExpiryTime * 1000)
+ )
+ : "";
+ }
+
+ const ATTRS = [
+ "created",
+ "keyCreated",
+ "keyTrust",
+ "keyUseFor",
+ "ownerTrust",
+ "algoSym",
+ "keySize",
+ "userIds",
+ "subKeys",
+ "fpr",
+ "secretAvailable",
+ "secretMaterial",
+ "photoAvailable",
+ "userId",
+ "hasIgnoredAttributes",
+ ];
+ for (let i of ATTRS) {
+ if (i in keyData) {
+ this[i] = keyData[i];
+ }
+ }
+ }
+
+ /**
+ * create a copy of the object
+ */
+ clone() {
+ let cp = new EnigmailKeyObj(["copy"]);
+ for (let i in this) {
+ if (i !== "fprFormatted") {
+ if (typeof this[i] !== "function") {
+ if (typeof this[i] === "object") {
+ cp[i] = lazy.EnigmailFuncs.cloneObj(this[i]);
+ } else {
+ cp[i] = this[i];
+ }
+ }
+ }
+ }
+
+ return cp;
+ }
+
+ /**
+ * Does the key have secondary user IDs?
+ *
+ * @return: Boolean - true if yes; false if no
+ */
+ hasSubUserIds() {
+ let nUid = 0;
+ for (let i in this.userIds) {
+ if (this.userIds[i].type === "uid") {
+ ++nUid;
+ }
+ }
+
+ return nUid >= 2;
+ }
+
+ /**
+ * Get a formatted version of the fingerprint:
+ * 1234 5678 90AB CDEF .... ....
+ *
+ * @returns String - the formatted fingerprint
+ */
+ get fprFormatted() {
+ let f = lazy.EnigmailKey.formatFpr(this.fpr);
+ if (f.length === 0) {
+ f = this.fpr;
+ }
+ return f;
+ }
+
+ /**
+ * Determine if the public key is valid. If not, return a description why it's not
+ *
+ * @returns Object:
+ * - keyValid: Boolean (true if key is valid)
+ * - reason: String (explanation of invalidity)
+ */
+ getPubKeyValidity(exceptionReason = null) {
+ let retVal = {
+ keyValid: false,
+ reason: "",
+ };
+ if (this.keyTrust.search(/r/i) >= 0) {
+ // public key revoked
+ retVal.reason = lazy.l10n.formatValueSync("key-ring-pub-key-revoked", {
+ userId: this.userId,
+ keyId: "0x" + this.keyId,
+ });
+ } else if (
+ exceptionReason != "ignoreExpired" &&
+ this.keyTrust.search(/e/i) >= 0
+ ) {
+ // public key expired
+ retVal.reason = lazy.l10n.formatValueSync("key-ring-pub-key-expired", {
+ userId: this.userId,
+ keyId: "0x" + this.keyId,
+ });
+ } else {
+ retVal.keyValid = true;
+ }
+
+ return retVal;
+ }
+
+ /**
+ * Check whether a key can be used for signing and return a description of why not
+ *
+ * @returns Object:
+ * - keyValid: Boolean (true if key is valid)
+ * - reason: String (explanation of invalidity)
+ */
+ getSigningValidity(exceptionReason = null) {
+ let retVal = this.getPubKeyValidity(exceptionReason);
+
+ if (!retVal.keyValid) {
+ return retVal;
+ }
+
+ if (!this.secretAvailable) {
+ retVal.keyValid = false;
+ retVal.reason = lazy.l10n.formatValueSync("key-ring-no-secret-key", {
+ userId: this.userId,
+ keyId: "0x" + this.keyId,
+ });
+ return retVal;
+ }
+
+ if (/s/.test(this.keyUseFor) && this.secretMaterial) {
+ return retVal;
+ }
+
+ retVal.keyValid = false;
+ let expired = 0;
+ let revoked = 0;
+ let found = 0;
+ let noSecret = 0;
+
+ for (let sk in this.subKeys) {
+ if (this.subKeys[sk].keyUseFor.search(/s/) >= 0) {
+ if (
+ this.subKeys[sk].keyTrust.search(/e/i) >= 0 &&
+ exceptionReason != "ignoreExpired"
+ ) {
+ ++expired;
+ } else if (this.subKeys[sk].keyTrust.search(/r/i) >= 0) {
+ ++revoked;
+ } else if (!this.subKeys[sk].secretMaterial) {
+ ++noSecret;
+ } else {
+ // found subkey usable
+ ++found;
+ }
+ }
+ }
+
+ if (!found) {
+ if (exceptionReason != "ignoreExpired" && expired) {
+ retVal.reason = lazy.l10n.formatValueSync(
+ "key-ring-sign-sub-keys-expired",
+ {
+ userId: this.userId,
+ keyId: "0x" + this.keyId,
+ }
+ );
+ } else if (revoked) {
+ retVal.reason = lazy.l10n.formatValueSync(
+ "key-ring-sign-sub-keys-revoked",
+ {
+ userId: this.userId,
+ keyId: "0x" + this.keyId,
+ }
+ );
+ } else if (noSecret) {
+ retVal.reason = lazy.l10n.formatValueSync("key-ring-no-secret-key", {
+ userId: this.userId,
+ keyId: "0x" + this.keyId,
+ });
+ } else {
+ retVal.reason = lazy.l10n.formatValueSync(
+ "key-ring-pub-key-not-for-signing",
+ {
+ userId: this.userId,
+ keyId: "0x" + this.keyId,
+ }
+ );
+ }
+ } else {
+ retVal.keyValid = true;
+ }
+
+ return retVal;
+ }
+
+ /**
+ * Check whether a key can be used for encryption and return a description of why not
+ *
+ * @param {boolean} requireDecryptionKey:
+ * If true, require secret key material to be available
+ * for at least one encryption key.
+ * @param {string} exceptionReason:
+ * Can be used to override the requirement to check for
+ * full validity, and accept certain scenarios as valid.
+ * If value is set to "ignoreExpired",
+ * then an expired key isn't treated as invalid.
+ * Set to null to get the default behavior.
+ * @param {string} subId:
+ * A key ID of a subkey or null.
+ * If subId is null, any part of the key will be
+ * considered when looking for a valid encryption key.
+ * If subId is non-null, only this subkey will be
+ * checked.
+ *
+ * @returns Object:
+ * - keyValid: Boolean (true if key is valid)
+ * - reason: String (explanation of invalidity)
+ */
+ getEncryptionValidity(
+ requireDecryptionKey,
+ exceptionReason = null,
+ subId = null
+ ) {
+ let retVal = this.getPubKeyValidity(exceptionReason);
+ if (!retVal.keyValid) {
+ return retVal;
+ }
+
+ if (
+ !subId &&
+ this.keyUseFor.search(/e/) >= 0 &&
+ (!requireDecryptionKey || this.secretMaterial)
+ ) {
+ // We can stop and return the result we already found,
+ // because we aren't looking at a specific subkey (!subId),
+ // and the primary key is usable for encryption.
+ // If we must own secret key material (requireDecryptionKey),
+ // in this scenario it's sufficient to have secret material for
+ // the primary key.
+ return retVal;
+ }
+
+ retVal.keyValid = false;
+
+ let expired = 0;
+ let revoked = 0;
+ let found = 0;
+ let noSecret = 0;
+
+ for (let sk of this.subKeys) {
+ if (subId && subId != sk.keyId) {
+ continue;
+ }
+
+ if (sk.keyUseFor.search(/e/) >= 0) {
+ if (
+ sk.keyTrust.search(/e/i) >= 0 &&
+ exceptionReason != "ignoreExpired"
+ ) {
+ ++expired;
+ } else if (sk.keyTrust.search(/r/i) >= 0) {
+ ++revoked;
+ } else if (requireDecryptionKey && !sk.secretMaterial) {
+ ++noSecret;
+ } else {
+ // found subkey usable
+ ++found;
+ }
+ }
+ }
+
+ if (!found) {
+ let idToShow = subId ? subId : this.keyId;
+
+ if (exceptionReason != "ignoreExpired" && expired) {
+ retVal.reason = lazy.l10n.formatValueSync(
+ "key-ring-enc-sub-keys-expired",
+ {
+ userId: this.userId,
+ keyId: "0x" + idToShow,
+ }
+ );
+ } else if (revoked) {
+ retVal.reason = lazy.l10n.formatValueSync(
+ "key-ring-enc-sub-keys-revoked",
+ {
+ userId: this.userId,
+ keyId: "0x" + idToShow,
+ }
+ );
+ } else if (noSecret) {
+ retVal.reason = lazy.l10n.formatValueSync("key-ring-no-secret-key", {
+ userId: this.userId,
+ keyId: "0x" + idToShow,
+ });
+ } else {
+ retVal.reason = lazy.l10n.formatValueSync(
+ "key-ring-pub-key-not-for-encryption",
+ {
+ userId: this.userId,
+ keyId: "0x" + idToShow,
+ }
+ );
+ }
+ } else {
+ retVal.keyValid = true;
+ }
+
+ return retVal;
+ }
+
+ /**
+ * Determine the next expiry date of the key. This is either the public key expiry date,
+ * or the maximum expiry date of a signing or encryption subkey. I.e. this returns the next
+ * date at which the key cannot be used for signing and/or encryption anymore
+ *
+ * @returns Number - The expiry date as seconds after 01/01/1970
+ */
+ getKeyExpiry() {
+ let expiryDate = Number.MAX_VALUE;
+ let encryption = -1;
+ let signing = -1;
+
+ // check public key expiry date
+ if (this.expiryTime > 0) {
+ expiryDate = this.expiryTime;
+ }
+
+ for (let sk in this.subKeys) {
+ if (this.subKeys[sk].keyUseFor.search(/[eE]/) >= 0) {
+ let expiry = this.subKeys[sk].expiryTime;
+ if (expiry === 0) {
+ expiry = Number.MAX_VALUE;
+ }
+ encryption = Math.max(encryption, expiry);
+ } else if (this.subKeys[sk].keyUseFor.search(/[sS]/) >= 0) {
+ let expiry = this.subKeys[sk].expiryTime;
+ if (expiry === 0) {
+ expiry = Number.MAX_VALUE;
+ }
+ signing = Math.max(signing, expiry);
+ }
+ }
+
+ if (expiryDate > encryption) {
+ if (this.keyUseFor.search(/[eE]/) < 0) {
+ expiryDate = encryption;
+ }
+ }
+
+ if (expiryDate > signing) {
+ if (this.keyUseFor.search(/[Ss]/) < 0) {
+ expiryDate = signing;
+ }
+ }
+
+ return expiryDate;
+ }
+
+ /**
+ * Export the minimum key for the public key object:
+ * public key, desired UID, newest signing/encryption subkey
+ *
+ * @param {string} emailAddr: [optional] email address of UID to extract. Use primary UID if null .
+ *
+ * @returns Object:
+ * - exitCode (0 = success)
+ * - errorMsg (if exitCode != 0)
+ * - keyData: BASE64-encded string of key data
+ */
+ getMinimalPubKey(emailAddr) {
+ lazy.EnigmailLog.DEBUG(
+ "keyObj.jsm: EnigmailKeyObj.getMinimalPubKey: " + this.keyId + "\n"
+ );
+
+ if (emailAddr) {
+ try {
+ emailAddr = lazy.EnigmailFuncs.stripEmail(emailAddr.toLowerCase());
+ } catch (x) {
+ emailAddr = emailAddr.toLowerCase();
+ }
+
+ let foundUid = false,
+ uid = "";
+ for (let i in this.userIds) {
+ try {
+ uid = lazy.EnigmailFuncs.stripEmail(
+ this.userIds[i].userId.toLowerCase()
+ );
+ } catch (x) {
+ uid = this.userIds[i].userId.toLowerCase();
+ }
+
+ if (uid == emailAddr) {
+ foundUid = true;
+ break;
+ }
+ }
+ if (!foundUid) {
+ emailAddr = false;
+ }
+ }
+
+ if (!emailAddr) {
+ emailAddr = this.userId;
+ }
+
+ try {
+ emailAddr = lazy.EnigmailFuncs.stripEmail(emailAddr.toLowerCase());
+ } catch (x) {
+ emailAddr = emailAddr.toLowerCase();
+ }
+
+ let newestSigningKey = 0,
+ newestEncryptionKey = 0,
+ subkeysArr = null;
+
+ // search for valid subkeys
+ for (let sk in this.subKeys) {
+ if (!"indDre".includes(this.subKeys[sk].keyTrust)) {
+ if (this.subKeys[sk].keyUseFor.search(/[sS]/) >= 0) {
+ // found signing subkey
+ if (this.subKeys[sk].keyCreated > newestSigningKey) {
+ newestSigningKey = this.subKeys[sk].keyCreated;
+ }
+ }
+ if (this.subKeys[sk].keyUseFor.search(/[eE]/) >= 0) {
+ // found encryption subkey
+ if (this.subKeys[sk].keyCreated > newestEncryptionKey) {
+ newestEncryptionKey = this.subKeys[sk].keyCreated;
+ }
+ }
+ }
+ }
+
+ if (newestSigningKey > 0 && newestEncryptionKey > 0) {
+ subkeysArr = [newestEncryptionKey, newestSigningKey];
+ }
+
+ if (!(emailAddr in this.minimalKeyBlock)) {
+ const cApi = lazy.EnigmailCryptoAPI();
+ this.minimalKeyBlock[emailAddr] = cApi.sync(
+ cApi.getMinimalPubKey(this.fpr, emailAddr, subkeysArr)
+ );
+ }
+ return this.minimalKeyBlock[emailAddr];
+ }
+
+ /**
+ * Obtain a "virtual" key size that allows to compare different algorithms with each other
+ * e.g. elliptic curve keys have small key sizes with high cryptographic strength
+ *
+ *
+ * @returns Number: a virtual size
+ */
+ getVirtualKeySize() {
+ lazy.EnigmailLog.DEBUG(
+ "keyObj.jsm: EnigmailKeyObj.getVirtualKeySize: " + this.keyId + "\n"
+ );
+
+ switch (this.algoSym) {
+ case "DSA":
+ return this.keySize / 2;
+ case "ECDSA":
+ return this.keySize * 8;
+ case "EDDSA":
+ return this.keySize * 32;
+ default:
+ return this.keySize;
+ }
+ }
+
+ /**
+ * @param {boolean} minimalKey - if true, reduce key to minimum required
+ *
+ * @returns {object}:
+ * - {Number} exitCode: result code (0: OK)
+ * - {String} keyData: ASCII armored key data material
+ * - {String} errorMsg: error message in case exitCode !== 0
+ */
+ getSecretKey(minimalKey) {
+ const cApi = lazy.EnigmailCryptoAPI();
+ return cApi.sync(cApi.extractSecretKey(this.fpr, minimalKey));
+ }
+
+ iSimpleOneSubkeySameExpiry() {
+ if (this.subKeys.length == 0) {
+ return true;
+ }
+
+ if (this.subKeys.length > 1) {
+ return false;
+ }
+
+ let subKey = this.subKeys[0];
+
+ if (!this.expiryTime && !subKey.expiryTime) {
+ return true;
+ }
+
+ let deltaSeconds = this.expiryTime - subKey.expiryTime;
+ if (deltaSeconds < 0) {
+ deltaSeconds *= -1;
+ }
+
+ // If expiry dates differ by less than a half day, then we
+ // treat it as having roughly the same expiry date.
+ return deltaSeconds < 12 * 60 * 60;
+ }
+
+ /**
+ * Obtain the list of alternative email addresses, except the one
+ * that is given as the parameter.
+ *
+ * @param {boolean} exceptThisEmail - an email address that will
+ * be excluded in the result array.
+ * @returns {string[]} - an array of all email addresses found in all
+ * of the key's user IDs, excluding exceptThisEmail.
+ */
+ getAlternativeEmails(exceptThisEmail) {
+ let result = [];
+
+ for (let u of this.userIds) {
+ let email;
+ try {
+ email = lazy.EnigmailFuncs.stripEmail(u.userId.toLowerCase());
+ } catch (x) {
+ email = u.userId.toLowerCase();
+ }
+
+ if (email == exceptThisEmail) {
+ continue;
+ }
+
+ result.push(email);
+ }
+
+ return result;
+ }
+
+ getUserIdWithEmail(email) {
+ for (let u of this.userIds) {
+ let e;
+ try {
+ e = lazy.EnigmailFuncs.stripEmail(u.userId.toLowerCase());
+ } catch (x) {
+ e = u.userId.toLowerCase();
+ }
+
+ if (email == e) {
+ return u;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/keyRing.jsm b/comm/mail/extensions/openpgp/content/modules/keyRing.jsm
new file mode 100644
index 0000000000..07b5c36991
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/keyRing.jsm
@@ -0,0 +1,2202 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailKeyRing"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CollectedKeysDB: "chrome://openpgp/content/modules/CollectedKeysDB.jsm",
+ OpenPGPAlias: "chrome://openpgp/content/modules/OpenPGPAlias.jsm",
+ EnigmailArmor: "chrome://openpgp/content/modules/armor.jsm",
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailTrust: "chrome://openpgp/content/modules/trust.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+ GPGME: "chrome://openpgp/content/modules/GPGME.jsm",
+ newEnigmailKeyObj: "chrome://openpgp/content/modules/keyObj.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+ RNP: "chrome://openpgp/content/modules/RNP.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+let gKeyListObj = null;
+let gKeyIndex = [];
+let gSubkeyIndex = [];
+let gLoadingKeys = false;
+
+/*
+
+ This module operates with a Key Store (array) containing objects with the following properties:
+
+ * keyList [Array] of EnigmailKeyObj
+
+ * keySortList [Array]: used for quickly sorting the keys
+ - userId (in lower case)
+ - keyId
+ - keyNum
+ * trustModel: [String]. One of:
+ - p: pgp/classical
+ - t: always trust
+ - a: auto (:0) (default, currently pgp/classical)
+ - T: TOFU
+ - TP: TOFU+PGP
+
+*/
+
+var EnigmailKeyRing = {
+ _initialized: false,
+
+ init() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+ this.clearCache();
+ },
+
+ /**
+ * Get the complete list of all public keys, optionally sorted by a column
+ *
+ * @param win - optional |object| holding the parent window for displaying error messages
+ * @param sortColumn - optional |string| containing the column name for sorting. One of:
+ * userid, keyid, keyidshort, fpr, keytype, validity, trust, created, expiry
+ * @param sortDirection - |number| 1 = ascending / -1 = descending
+ *
+ * @returns keyListObj - |object| { keyList, keySortList } (see above)
+ */
+ getAllKeys(win, sortColumn, sortDirection) {
+ if (gKeyListObj.keySortList.length === 0) {
+ loadKeyList(win, sortColumn, sortDirection);
+ //EnigmailWindows.keyManReloadKeys();
+ /* TODO: do we need something similar with TB's future trust behavior?
+ if (!gKeyCheckDone) {
+ gKeyCheckDone = true;
+ runKeyUsabilityCheck();
+ }
+ */
+ } else if (sortColumn) {
+ gKeyListObj.keySortList.sort(
+ getSortFunction(sortColumn.toLowerCase(), gKeyListObj, sortDirection)
+ );
+ }
+
+ return gKeyListObj;
+ },
+
+ /**
+ * get 1st key object that matches a given key ID or subkey ID
+ *
+ * @param keyId - String: key Id with 16 characters (preferred) or 8 characters),
+ * or fingerprint (40 or 32 characters).
+ * Optionally preceded with "0x"
+ * @param noLoadKeys - Boolean [optional]: do not try to load the key list first
+ *
+ * @returns Object - found KeyObject or null if key not found
+ */
+ getKeyById(keyId, noLoadKeys) {
+ lazy.EnigmailLog.DEBUG("keyRing.jsm: getKeyById: " + keyId + "\n");
+
+ if (!keyId) {
+ return null;
+ }
+
+ if (keyId.search(/^0x/) === 0) {
+ keyId = keyId.substr(2);
+ }
+ keyId = keyId.toUpperCase();
+
+ if (!noLoadKeys) {
+ this.getAllKeys(); // ensure keylist is loaded;
+ }
+
+ let keyObj = gKeyIndex[keyId];
+
+ if (keyObj === undefined) {
+ keyObj = gSubkeyIndex[keyId];
+ }
+
+ return keyObj !== undefined ? keyObj : null;
+ },
+
+ isSubkeyId(keyId) {
+ if (!keyId) {
+ throw new Error("keyId parameter not set");
+ }
+
+ keyId = keyId.replace(/^0x/, "").toUpperCase();
+
+ let keyObj = gSubkeyIndex[keyId];
+
+ return keyObj !== undefined;
+ },
+
+ /**
+ * get all key objects that match a given email address
+ *
+ * @param searchTerm - String: an email address to match against all UIDs of the keys.
+ * An empty string will return no result
+ * @param onlyValidUid - Boolean: if true (default), invalid (e.g. revoked) UIDs are not matched
+ *
+ * @param allowExpired - Boolean: if true, expired keys are matched.
+ *
+ * @returns Array of KeyObjects with the found keys (array length is 0 if no key found)
+ */
+ getKeysByEmail(email, onlyValidUid = true, allowExpired = false) {
+ lazy.EnigmailLog.DEBUG("keyRing.jsm: getKeysByEmail: '" + email + "'\n");
+
+ let res = [];
+ if (!email) {
+ return res;
+ }
+
+ this.getAllKeys(); // ensure keylist is loaded;
+ email = email.toLowerCase();
+
+ for (let key of gKeyListObj.keyList) {
+ if (!allowExpired && key.keyTrust == "e") {
+ continue;
+ }
+
+ for (let userId of key.userIds) {
+ if (userId.type !== "uid") {
+ continue;
+ }
+
+ // Skip test if it's expired. If expired isn't allowed, we
+ // already skipped it above.
+ if (
+ onlyValidUid &&
+ userId.keyTrust != "e" &&
+ lazy.EnigmailTrust.isInvalid(userId.keyTrust)
+ ) {
+ continue;
+ }
+
+ if (
+ lazy.EnigmailFuncs.getEmailFromUserID(userId.userId).toLowerCase() ===
+ email
+ ) {
+ res.push(key);
+ break;
+ }
+ }
+ }
+ return res;
+ },
+
+ emailAddressesWithSecretKey: null,
+
+ async _populateEmailHasSecretKeyCache() {
+ this.emailAddressesWithSecretKey = new Set();
+
+ this.getAllKeys(); // ensure keylist is loaded;
+
+ for (let key of gKeyListObj.keyList) {
+ if (!key.secretAvailable) {
+ continue;
+ }
+ let isPersonal = await lazy.PgpSqliteDb2.isAcceptedAsPersonalKey(key.fpr);
+ if (!isPersonal) {
+ continue;
+ }
+ for (let userId of key.userIds) {
+ if (userId.type !== "uid") {
+ continue;
+ }
+ if (lazy.EnigmailTrust.isInvalid(userId.keyTrust)) {
+ continue;
+ }
+ this.emailAddressesWithSecretKey.add(
+ lazy.EnigmailFuncs.getEmailFromUserID(userId.userId).toLowerCase()
+ );
+ }
+ }
+ },
+
+ /**
+ * This API uses a cache. It helps when making lookups from multiple
+ * places, during a longer transaction.
+ * Currently, the cache isn't refreshed automatically.
+ * Set this.emailAddressesWithSecretKey to null when starting a new
+ * operation that needs fresh information.
+ */
+ async hasSecretKeyForEmail(emailAddr) {
+ if (!this.emailAddressesWithSecretKey) {
+ await this._populateEmailHasSecretKeyCache();
+ }
+
+ return this.emailAddressesWithSecretKey.has(emailAddr);
+ },
+
+ /**
+ * Specialized function that takes into account
+ * the specifics of email addresses in UIDs.
+ *
+ * @param emailAddr: String - email address to search for without any angulars
+ * or names
+ *
+ * @returns KeyObject with the found key, or null if no key found
+ */
+ async getSecretKeyByEmail(emailAddr) {
+ let result = {};
+ await this.getAllSecretKeysByEmail(emailAddr, result, true);
+ return result.best;
+ },
+
+ async getAllSecretKeysByEmail(emailAddr, result, allowExpired) {
+ lazy.EnigmailLog.DEBUG(
+ "keyRing.jsm: getAllSecretKeysByEmail: '" + emailAddr + "'\n"
+ );
+ let keyList = this.getKeysByEmail(emailAddr, true, true);
+
+ result.all = [];
+ result.best = null;
+
+ var nowDate = new Date();
+ var nowSecondsSinceEpoch = nowDate.valueOf() / 1000;
+ let bestIsExpired = false;
+
+ for (let key of keyList) {
+ if (!key.secretAvailable) {
+ continue;
+ }
+ let isPersonal = await lazy.PgpSqliteDb2.isAcceptedAsPersonalKey(key.fpr);
+ if (!isPersonal) {
+ continue;
+ }
+ if (
+ key.getEncryptionValidity(true, "ignoreExpired").keyValid &&
+ key.getSigningValidity("ignoreExpired").keyValid
+ ) {
+ let thisIsExpired =
+ key.expiryTime != 0 && key.expiryTime < nowSecondsSinceEpoch;
+ if (!allowExpired && thisIsExpired) {
+ continue;
+ }
+ result.all.push(key);
+ if (!result.best) {
+ result.best = key;
+ bestIsExpired = thisIsExpired;
+ } else if (
+ result.best.algoSym === key.algoSym &&
+ result.best.keySize === key.keySize
+ ) {
+ if (!key.expiryTime || key.expiryTime > result.best.expiryTime) {
+ result.best = key;
+ }
+ } else if (bestIsExpired && !thisIsExpired) {
+ if (
+ result.best.algoSym.search(/^(DSA|RSA)$/) < 0 &&
+ key.algoSym.search(/^(DSA|RSA)$/) === 0
+ ) {
+ // prefer RSA or DSA over ECC (long-term: change this once ECC keys are widely supported)
+ result.best = key;
+ bestIsExpired = thisIsExpired;
+ } else if (
+ key.getVirtualKeySize() > result.best.getVirtualKeySize()
+ ) {
+ result.best = key;
+ bestIsExpired = thisIsExpired;
+ }
+ }
+ }
+ }
+ },
+
+ /**
+ * get a list of keys for a given set of (sub-) key IDs
+ *
+ * @param keyIdList: Array of key IDs
+ OR String, with space-separated list of key IDs
+ */
+ getKeyListById(keyIdList) {
+ lazy.EnigmailLog.DEBUG(
+ "keyRing.jsm: getKeyListById: '" + keyIdList + "'\n"
+ );
+ let keyArr;
+ if (typeof keyIdList === "string") {
+ keyArr = keyIdList.split(/ +/);
+ } else {
+ keyArr = keyIdList;
+ }
+
+ let ret = [];
+ for (let i in keyArr) {
+ let r = this.getKeyById(keyArr[i]);
+ if (r) {
+ ret.push(r);
+ }
+ }
+
+ return ret;
+ },
+
+ /**
+ * @param {nsIFile} file - ASCII armored file containing the revocation.
+ */
+ async importRevFromFile(file) {
+ let contents = await IOUtils.readUTF8(file.path);
+
+ const beginIndexObj = {};
+ const endIndexObj = {};
+ const blockType = lazy.EnigmailArmor.locateArmoredBlock(
+ contents,
+ 0,
+ "",
+ beginIndexObj,
+ endIndexObj,
+ {}
+ );
+ if (!blockType) {
+ return;
+ }
+
+ if (blockType.search(/^(PUBLIC|PRIVATE) KEY BLOCK$/) !== 0) {
+ return;
+ }
+
+ let pgpBlock = contents.substr(
+ beginIndexObj.value,
+ endIndexObj.value - beginIndexObj.value + 1
+ );
+
+ const cApi = lazy.EnigmailCryptoAPI();
+ let res = await cApi.importRevBlockAPI(pgpBlock);
+ if (res.exitCode) {
+ return;
+ }
+
+ EnigmailKeyRing.clearCache();
+ lazy.EnigmailWindows.keyManReloadKeys();
+ },
+
+ /**
+ * Import a secret key from the given file.
+ *
+ * @param {nsIFile} file - ASCII armored file containing the revocation.
+ * @param {nsIWindow} win - parent window
+ * @param {Function} passCB - a callback function that will be called if the user needs
+ * to enter a passphrase to unlock a secret key. See passphrasePromptCallback
+ * for the function signature.
+ * @param {object} errorMsgObj - errorMsgObj.value will contain an error
+ * message in case of failures
+ * @param {object} importedKeysObj - importedKeysObj.value will contain
+ * an array of the FPRs imported
+ */
+ async importSecKeyFromFile(
+ win,
+ passCB,
+ keepPassphrases,
+ inputFile,
+ errorMsgObj,
+ importedKeysObj
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "keyRing.jsm: EnigmailKeyRing.importSecKeyFromFile: fileName=" +
+ inputFile.path +
+ "\n"
+ );
+
+ let data = await IOUtils.read(inputFile.path);
+ let contents = MailStringUtils.uint8ArrayToByteString(data);
+ let res;
+ let tryAgain;
+ let permissive = false;
+ do {
+ tryAgain = false;
+ let failed = true;
+
+ try {
+ // strict on first attempt, permissive on optional second attempt
+ res = await lazy.RNP.importSecKeyBlockImpl(
+ win,
+ passCB,
+ keepPassphrases,
+ contents,
+ permissive
+ );
+ failed =
+ !res || res.exitCode || !res.importedKeys || !res.importedKeys.length;
+ } catch (ex) {
+ lazy.EnigmailDialog.alert(win, ex);
+ }
+
+ if (failed) {
+ if (!permissive) {
+ let agreed = lazy.EnigmailDialog.confirmDlg(
+ win,
+ lazy.l10n.formatValueSync("confirm-permissive-import")
+ );
+ if (agreed) {
+ permissive = true;
+ tryAgain = true;
+ }
+ } else {
+ lazy.EnigmailDialog.alert(
+ win,
+ lazy.l10n.formatValueSync("import-keys-failed")
+ );
+ }
+ }
+ } while (tryAgain);
+
+ if (!res || !res.importedKeys) {
+ return 1;
+ }
+
+ if (importedKeysObj) {
+ importedKeysObj.keys = res.importedKeys;
+ }
+ if (res.importedKeys.length > 0) {
+ EnigmailKeyRing.updateKeys(res.importedKeys);
+ }
+ EnigmailKeyRing.clearCache();
+
+ return res.exitCode;
+ },
+
+ /**
+ * empty the key cache, such that it will get loaded next time it is accessed
+ *
+ * no input or return values
+ */
+ clearCache() {
+ lazy.EnigmailLog.DEBUG("keyRing.jsm: EnigmailKeyRing.clearCache\n");
+ gKeyListObj = {
+ keyList: [],
+ keySortList: [],
+ };
+
+ gKeyIndex = [];
+ gSubkeyIndex = [];
+ },
+
+ /**
+ * Check if the cache is empty
+ *
+ * @returns Boolean: true: cache cleared
+ */
+ getCacheEmpty() {
+ return gKeyIndex.length === 0;
+ },
+
+ /**
+ * Get a list of UserIds for a given key.
+ * Only the Only UIDs with highest trust level are returned.
+ *
+ * @param String keyId key, optionally preceded with 0x
+ *
+ * @returns Array of String: list of UserIds
+ */
+ getValidUids(keyId) {
+ let keyObj = this.getKeyById(keyId);
+ if (keyObj) {
+ return this.getValidUidsFromKeyObj(keyObj);
+ }
+ return [];
+ },
+
+ getValidUidsFromKeyObj(keyObj) {
+ let r = [];
+ if (keyObj) {
+ const TRUSTLEVELS_SORTED = lazy.EnigmailTrust.trustLevelsSorted();
+ let hideInvalidUid = true;
+ let maxTrustLevel = TRUSTLEVELS_SORTED.indexOf(keyObj.keyTrust);
+
+ if (lazy.EnigmailTrust.isInvalid(keyObj.keyTrust)) {
+ // pub key not valid (anymore)-> display all UID's
+ hideInvalidUid = false;
+ }
+
+ for (let i in keyObj.userIds) {
+ if (keyObj.userIds[i].type !== "uat") {
+ if (hideInvalidUid) {
+ let thisTrust = TRUSTLEVELS_SORTED.indexOf(
+ keyObj.userIds[i].keyTrust
+ );
+ if (thisTrust > maxTrustLevel) {
+ r = [keyObj.userIds[i].userId];
+ maxTrustLevel = thisTrust;
+ } else if (thisTrust === maxTrustLevel) {
+ r.push(keyObj.userIds[i].userId);
+ }
+ // else do not add uid
+ } else if (
+ !lazy.EnigmailTrust.isInvalid(keyObj.userIds[i].keyTrust) ||
+ !hideInvalidUid
+ ) {
+ // UID valid OR key not valid, but invalid keys allowed
+ r.push(keyObj.userIds[i].userId);
+ }
+ }
+ }
+ }
+
+ return r;
+ },
+
+ /**
+ * Export public key(s) to a file
+ *
+ * @param {string[]} idArrayFull - array of key IDs or fingerprints
+ * to export (full keys).
+ * @param {string[]} idArrayReduced - array of key IDs or fingerprints
+ * to export (reduced keys, non-self signatures stripped).
+ * @param {String[]] idArrayMinimal - array of key IDs or fingerprints
+ * to export (minimal keys, user IDs and non-self signatures stripped).
+ * @param {String or nsIFile} outputFile - output file name or Object - or NULL
+ * @param {object} exitCodeObj - o.value will contain exit code
+ * @param {object} errorMsgObj - o.value will contain error message
+ *
+ * @returns String - if outputFile is NULL, the key block data; "" if a file is written
+ */
+ async extractPublicKeys(
+ idArrayFull,
+ idArrayReduced,
+ idArrayMinimal,
+ outputFile,
+ exitCodeObj,
+ errorMsgObj
+ ) {
+ // At least one array must have valid input
+ if (
+ (!idArrayFull || !Array.isArray(idArrayFull) || !idArrayFull.length) &&
+ (!idArrayReduced ||
+ !Array.isArray(idArrayReduced) ||
+ !idArrayReduced.length) &&
+ (!idArrayMinimal ||
+ !Array.isArray(idArrayMinimal) ||
+ !idArrayMinimal.length)
+ ) {
+ throw new Error("invalid parameter given to EnigmailKeyRing.extractKey");
+ }
+
+ exitCodeObj.value = -1;
+
+ let keyBlock = lazy.RNP.getMultiplePublicKeys(
+ idArrayFull,
+ idArrayReduced,
+ idArrayMinimal
+ );
+ if (!keyBlock) {
+ errorMsgObj.value = lazy.l10n.formatValueSync("fail-key-extract");
+ return "";
+ }
+
+ exitCodeObj.value = 0;
+ if (outputFile) {
+ return IOUtils.writeUTF8(outputFile.path, keyBlock)
+ .then(() => {
+ return "";
+ })
+ .catch(async () => {
+ exitCodeObj.value = -1;
+ errorMsgObj.value = await lazy.l10n.formatValue("file-write-failed", {
+ output: outputFile.path,
+ });
+ return null;
+ });
+ }
+ return keyBlock;
+ },
+
+ promptKeyExport2AsciiFilename(window, label, defaultFilename) {
+ return lazy.EnigmailDialog.filePicker(
+ window,
+ label,
+ "",
+ true,
+ false,
+ "*.asc",
+ defaultFilename,
+ [lazy.l10n.formatValueSync("ascii-armor-file"), "*.asc"]
+ );
+ },
+
+ async exportPublicKeysInteractive(window, defaultFileName, keyIdArray) {
+ let label = lazy.l10n.formatValueSync("export-to-file");
+ let outFile = EnigmailKeyRing.promptKeyExport2AsciiFilename(
+ window,
+ label,
+ defaultFileName
+ );
+ if (!outFile) {
+ return;
+ }
+
+ var exitCodeObj = {};
+ var errorMsgObj = {};
+
+ await EnigmailKeyRing.extractPublicKeys(
+ keyIdArray, // full
+ null,
+ null,
+ outFile,
+ exitCodeObj,
+ errorMsgObj
+ );
+ if (exitCodeObj.value !== 0) {
+ lazy.EnigmailDialog.alert(
+ window,
+ lazy.l10n.formatValueSync("save-keys-failed")
+ );
+ return;
+ }
+ lazy.EnigmailDialog.info(window, lazy.l10n.formatValueSync("save-keys-ok"));
+ },
+
+ backupSecretKeysInteractive(window, defaultFileName, fprArray) {
+ let label = lazy.l10n.formatValueSync("export-keypair-to-file");
+ let outFile = EnigmailKeyRing.promptKeyExport2AsciiFilename(
+ window,
+ label,
+ defaultFileName
+ );
+
+ if (!outFile) {
+ return;
+ }
+
+ window.openDialog(
+ "chrome://openpgp/content/ui/backupKeyPassword.xhtml",
+ "",
+ "dialog,modal,centerscreen,resizable",
+ {
+ okCallback: EnigmailKeyRing.exportSecretKey,
+ file: outFile,
+ fprArray,
+ }
+ );
+ },
+
+ /**
+ * Export the secret key after a successful password setup.
+ *
+ * @param {string} password - The declared password to protect the keys.
+ * @param {Array} fprArray - The array of fingerprint of the selected keys.
+ * @param {object} file - The file where the keys should be saved.
+ * @param {boolean} confirmed - If the password was properly typed in the
+ * prompt.
+ */
+ async exportSecretKey(password, fprArray, file, confirmed = false) {
+ // Interrupt in case this method has been called directly without confirming
+ // the input password through the password prompt.
+ if (!confirmed) {
+ return;
+ }
+
+ let backupKeyBlock = await lazy.RNP.backupSecretKeys(fprArray, password);
+ if (!backupKeyBlock) {
+ Services.prompt.alert(
+ null,
+ lazy.l10n.formatValueSync("save-keys-failed")
+ );
+ return;
+ }
+
+ await IOUtils.writeUTF8(file.path, backupKeyBlock)
+ .then(async () => {
+ lazy.EnigmailDialog.info(
+ null,
+ await lazy.l10n.formatValue("save-keys-ok")
+ );
+ })
+ .catch(async () => {
+ Services.prompt.alert(
+ null,
+ await lazy.l10n.formatValue("file-write-failed", {
+ output: file.path,
+ })
+ );
+ });
+ },
+
+ /**
+ * import key from provided key data (synchronous)
+ *
+ * @param parent nsIWindow
+ * @param askToConfirm Boolean - if true, display confirmation dialog
+ * @param keyBlock String - data containing key
+ * @param isBinary Boolean
+ * @param keyId String - key ID expected to import (no meaning)
+ * @param errorMsgObj Object - o.value will contain error message from GnuPG
+ * @param importedKeysObj Object - [OPTIONAL] o.value will contain an array of the FPRs imported
+ * @param minimizeKey Boolean - [OPTIONAL] minimize key for importing
+ * @param limitedUids Array<String> - [OPTIONAL] restrict importing the key(s) to a given set of UIDs
+ * @param allowPermissiveFallbackWithPrompt Boolean - If true, and regular import attempt fails,
+ * the user is asked to allow an optional
+ * permissive import attempt.
+ * @param {string} acceptance - Acceptance for the keys to import,
+ * which are new, or still have acceptance "undecided".
+ *
+ * @returns Integer - exit code:
+ * ExitCode == 0 => success
+ * ExitCode > 0 => error
+ * ExitCode == -1 => Cancelled by user
+ */
+ importKey(
+ parent,
+ askToConfirm,
+ keyBlock,
+ isBinary,
+ keyId,
+ errorMsgObj,
+ importedKeysObj,
+ minimizeKey = false,
+ limitedUids = [],
+ allowPermissiveFallbackWithPrompt = true,
+ acceptance = null
+ ) {
+ const cApi = lazy.EnigmailCryptoAPI();
+ return cApi.sync(
+ this.importKeyAsync(
+ parent,
+ askToConfirm,
+ keyBlock,
+ isBinary,
+ keyId,
+ errorMsgObj,
+ importedKeysObj,
+ minimizeKey,
+ limitedUids,
+ allowPermissiveFallbackWithPrompt,
+ acceptance
+ )
+ );
+ },
+
+ /**
+ * import key from provided key data
+ *
+ * @param parent nsIWindow
+ * @param askToConfirm Boolean - if true, display confirmation dialog
+ * @param keyBlock String - data containing key
+ * @param isBinary Boolean
+ * @param keyId String - key ID expected to import (no meaning)
+ * @param errorMsgObj Object - o.value will contain error message from GnuPG
+ * @param importedKeysObj Object - [OPTIONAL] o.value will contain an array of the FPRs imported
+ * @param minimizeKey Boolean - [OPTIONAL] minimize key for importing
+ * @param limitedUids Array<String> - [OPTIONAL] restrict importing the key(s) to a given set of UIDs
+ * @param allowPermissiveFallbackWithPrompt Boolean - If true, and regular import attempt fails,
+ * the user is asked to allow an optional
+ * permissive import attempt.
+ * @param acceptance String - The new acceptance value for the imported keys,
+ * which are new, or still have acceptance "undecided".
+ *
+ * @returns Integer - exit code:
+ * ExitCode == 0 => success
+ * ExitCode > 0 => error
+ * ExitCode == -1 => Cancelled by user
+ */
+ async importKeyAsync(
+ parent,
+ askToConfirm,
+ keyBlock,
+ isBinary,
+ keyId, // ignored
+ errorMsgObj,
+ importedKeysObj,
+ minimizeKey = false,
+ limitedUids = [],
+ allowPermissiveFallbackWithPrompt = true,
+ acceptance = null
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ `keyRing.jsm: EnigmailKeyRing.importKeyAsync('${keyId}', ${askToConfirm}, ${minimizeKey})\n`
+ );
+
+ var pgpBlock;
+ if (!isBinary) {
+ const beginIndexObj = {};
+ const endIndexObj = {};
+ const blockType = lazy.EnigmailArmor.locateArmoredBlock(
+ keyBlock,
+ 0,
+ "",
+ beginIndexObj,
+ endIndexObj,
+ {}
+ );
+ if (!blockType) {
+ errorMsgObj.value = lazy.l10n.formatValueSync("no-pgp-block");
+ return 1;
+ }
+
+ if (blockType.search(/^(PUBLIC|PRIVATE) KEY BLOCK$/) !== 0) {
+ errorMsgObj.value = lazy.l10n.formatValueSync("not-first-block");
+ return 1;
+ }
+
+ pgpBlock = keyBlock.substr(
+ beginIndexObj.value,
+ endIndexObj.value - beginIndexObj.value + 1
+ );
+ }
+
+ if (askToConfirm) {
+ if (
+ !lazy.EnigmailDialog.confirmDlg(
+ parent,
+ lazy.l10n.formatValueSync("import-key-confirm"),
+ lazy.l10n.formatValueSync("key-man-button-import")
+ )
+ ) {
+ errorMsgObj.value = lazy.l10n.formatValueSync("fail-cancel");
+ return -1;
+ }
+ }
+
+ if (minimizeKey) {
+ throw new Error("importKeyAsync with minimizeKey: not implemented");
+ }
+
+ const cApi = lazy.EnigmailCryptoAPI();
+ let result = undefined;
+ let tryAgain;
+ let permissive = false;
+ do {
+ // strict on first attempt, permissive on optional second attempt
+ let blockParam = isBinary ? keyBlock : pgpBlock;
+
+ result = await cApi.importPubkeyBlockAutoAcceptAPI(
+ parent,
+ blockParam,
+ acceptance,
+ permissive,
+ limitedUids
+ );
+
+ tryAgain = false;
+ let failed =
+ !result ||
+ result.exitCode ||
+ !result.importedKeys ||
+ !result.importedKeys.length;
+ if (failed) {
+ if (allowPermissiveFallbackWithPrompt && !permissive) {
+ let agreed = lazy.EnigmailDialog.confirmDlg(
+ parent,
+ lazy.l10n.formatValueSync("confirm-permissive-import")
+ );
+ if (agreed) {
+ permissive = true;
+ tryAgain = true;
+ }
+ } else if (askToConfirm) {
+ // if !askToConfirm the caller is responsible to handle the error
+ lazy.EnigmailDialog.alert(
+ parent,
+ lazy.l10n.formatValueSync("import-keys-failed")
+ );
+ }
+ }
+ } while (tryAgain);
+
+ if (!result) {
+ result = {};
+ result.exitCode = -1;
+ } else if (result.importedKeys) {
+ if (importedKeysObj) {
+ importedKeysObj.value = result.importedKeys;
+ }
+ if (result.importedKeys.length > 0) {
+ EnigmailKeyRing.updateKeys(result.importedKeys);
+ }
+ }
+
+ EnigmailKeyRing.clearCache();
+ return result.exitCode;
+ },
+
+ async importKeyDataWithConfirmation(
+ window,
+ preview,
+ keyData,
+ isBinary,
+ limitedUids = []
+ ) {
+ let somethingWasImported = false;
+ if (preview.length > 0) {
+ let outParam = {};
+ if (lazy.EnigmailDialog.confirmPubkeyImport(window, preview, outParam)) {
+ let exitStatus;
+ let errorMsgObj = {};
+ try {
+ exitStatus = await EnigmailKeyRing.importKeyAsync(
+ window,
+ false,
+ keyData,
+ isBinary,
+ "",
+ errorMsgObj,
+ null,
+ false,
+ limitedUids,
+ true,
+ outParam.acceptance
+ );
+ } catch (ex) {
+ console.debug(ex);
+ }
+
+ if (exitStatus === 0) {
+ let keyList = preview.map(a => a.id);
+ lazy.EnigmailDialog.keyImportDlg(window, keyList);
+ somethingWasImported = true;
+ } else {
+ lazy.l10n.formatValue("fail-key-import").then(value => {
+ lazy.EnigmailDialog.alert(window, value + "\n" + errorMsgObj.value);
+ });
+ }
+ }
+ } else {
+ lazy.l10n.formatValue("no-key-found2").then(value => {
+ lazy.EnigmailDialog.alert(window, value);
+ });
+ }
+ return somethingWasImported;
+ },
+
+ async importKeyArrayWithConfirmation(
+ window,
+ keyArray,
+ isBinary,
+ limitedUids = []
+ ) {
+ let somethingWasImported = false;
+ if (keyArray.length > 0) {
+ let outParam = {};
+ if (lazy.EnigmailDialog.confirmPubkeyImport(window, keyArray, outParam)) {
+ let importedKeys = [];
+ let allErrors = "";
+ for (let key of keyArray) {
+ let exitStatus;
+ let errorMsgObj = {};
+ try {
+ exitStatus = await EnigmailKeyRing.importKeyAsync(
+ window,
+ false,
+ key.pubKey,
+ isBinary,
+ "",
+ errorMsgObj,
+ null,
+ false,
+ limitedUids,
+ true,
+ outParam.acceptance
+ );
+ } catch (ex) {
+ console.debug(ex);
+ }
+
+ if (exitStatus === 0) {
+ importedKeys.push(key.id);
+ } else {
+ allErrors += "\n" + errorMsgObj.value;
+ }
+ }
+
+ if (importedKeys.length) {
+ lazy.EnigmailDialog.keyImportDlg(window, importedKeys);
+ somethingWasImported = true;
+ } else {
+ lazy.l10n.formatValue("fail-key-import").then(value => {
+ lazy.EnigmailDialog.alert(window, value + allErrors);
+ });
+ }
+ }
+ } else {
+ lazy.l10n.formatValue("no-key-found2").then(value => {
+ lazy.EnigmailDialog.alert(window, value);
+ });
+ }
+ return somethingWasImported;
+ },
+
+ async importKeyDataSilent(window, keyData, isBinary, onlyFingerprint = "") {
+ let errorMsgObj = {};
+ let exitStatus = -1;
+ try {
+ exitStatus = await EnigmailKeyRing.importKeyAsync(
+ window,
+ false,
+ keyData,
+ isBinary,
+ "",
+ errorMsgObj,
+ undefined,
+ false,
+ onlyFingerprint ? [onlyFingerprint] : []
+ );
+ this.clearCache();
+ } catch (ex) {
+ console.debug(ex);
+ }
+ return exitStatus === 0;
+ },
+
+ /**
+ * Generate a new key pair with GnuPG
+ *
+ * @name: String - name part of UID
+ * @comment: String - comment part of UID (brackets are added)
+ * @comment: String - email part of UID (<> will be added)
+ * @expiryDate: Number - Unix timestamp of key expiry date; 0 if no expiry
+ * @keyLength: Number - size of key in bytes (e.g 4096)
+ * @keyType: String - RSA or ECC
+ * @passphrase: String - password; null if no password
+ * @listener: Object - {
+ * function onDataAvailable(data) {...},
+ * function onStopRequest(exitCode) {...}
+ * }
+ *
+ * @return: handle to process
+ */
+ generateKey(
+ name,
+ comment,
+ email,
+ expiryDate,
+ keyLength,
+ keyType,
+ passphrase,
+ listener
+ ) {
+ lazy.EnigmailLog.WRITE("keyRing.jsm: generateKey:\n");
+ throw new Error("Not implemented");
+ },
+
+ isValidForEncryption(keyObj) {
+ return this._getValidityLevelIgnoringAcceptance(keyObj, null, false) == 0;
+ },
+
+ // returns an acceptanceLevel from -1 to 3,
+ // or -2 for "doesn't match email" or "not usable"
+ async isValidKeyForRecipient(keyObj, emailAddr, allowExpired) {
+ if (!emailAddr) {
+ return -2;
+ }
+
+ let level = this._getValidityLevelIgnoringAcceptance(
+ keyObj,
+ emailAddr,
+ allowExpired
+ );
+ if (level < 0) {
+ return level;
+ }
+ return this._getAcceptanceLevelForEmail(keyObj, emailAddr);
+ },
+
+ /**
+ * This function checks that given key is not expired, not revoked,
+ * and that a (related) encryption (sub-)key is available.
+ * If an email address is provided by the caller, the function
+ * also requires that a matching user id is available.
+ *
+ * @param {object} keyObj - the key to check
+ * @param {string} [emailAddr] - optional email address
+ * @returns {Integer} - validity level, negative for invalid,
+ * 0 if no problem were found (neutral)
+ */
+ _getValidityLevelIgnoringAcceptance(keyObj, emailAddr, allowExpired) {
+ if (keyObj.keyTrust == "r") {
+ return -2;
+ }
+
+ if (keyObj.keyTrust == "e" && !allowExpired) {
+ return -2;
+ }
+
+ if (emailAddr) {
+ let uidMatch = false;
+ for (let uid of keyObj.userIds) {
+ if (uid.type !== "uid") {
+ continue;
+ }
+
+ if (
+ lazy.EnigmailFuncs.getEmailFromUserID(uid.userId).toLowerCase() ===
+ emailAddr
+ ) {
+ uidMatch = true;
+ break;
+ }
+ }
+ if (!uidMatch) {
+ return -2;
+ }
+ }
+
+ // key valid for encryption?
+ if (!keyObj.keyUseFor.includes("E")) {
+ return -2;
+ }
+
+ // Ensure we have at least one key usable for encryption
+ // that is not expired/revoked.
+
+ // We already checked above, the primary key is not revoked/expired
+ let foundGoodEnc = keyObj.keyUseFor.match(/e/);
+ if (!foundGoodEnc) {
+ for (let aSub of keyObj.subKeys) {
+ if (aSub.keyTrust == "r") {
+ continue;
+ }
+ if (aSub.keyTrust == "e" && !allowExpired) {
+ continue;
+ }
+ if (aSub.keyUseFor.match(/e/)) {
+ foundGoodEnc = true;
+ break;
+ }
+ }
+ }
+
+ if (!foundGoodEnc) {
+ return -2;
+ }
+
+ return 0; // no problem found
+ },
+
+ async _getAcceptanceLevelForEmail(keyObj, emailAddr) {
+ let acceptanceLevel;
+ if (keyObj.secretAvailable) {
+ let isPersonal = await lazy.PgpSqliteDb2.isAcceptedAsPersonalKey(
+ keyObj.fpr
+ );
+ if (isPersonal) {
+ acceptanceLevel = 3;
+ } else {
+ acceptanceLevel = -1; // rejected
+ }
+ } else {
+ acceptanceLevel = await this.getKeyAcceptanceLevelForEmail(
+ keyObj,
+ emailAddr
+ );
+ }
+
+ return acceptanceLevel;
+ },
+
+ /**
+ * try to find valid key for encryption to passed email address
+ *
+ * @param details if not null returns error in details.msg
+ *
+ * @return: found key ID (without leading "0x") or null
+ */
+ async getValidKeyForRecipient(emailAddr, details) {
+ lazy.EnigmailLog.DEBUG(
+ 'keyRing.jsm: getValidKeyForRecipient(): emailAddr="' + emailAddr + '"\n'
+ );
+ const FULLTRUSTLEVEL = 2;
+
+ emailAddr = emailAddr.toLowerCase();
+
+ var foundKeyId = null;
+ var foundAcceptanceLevel = null;
+
+ let k = this.getAllKeys(null, null);
+ let keyList = k.keyList;
+
+ for (let keyObj of keyList) {
+ let acceptanceLevel = await this.isValidKeyForRecipient(
+ keyObj,
+ emailAddr,
+ false
+ );
+
+ // immediately return as best match, if a fully or ultimately
+ // trusted key is found
+ if (acceptanceLevel >= FULLTRUSTLEVEL) {
+ return keyObj.keyId;
+ }
+
+ if (acceptanceLevel < 1) {
+ continue;
+ }
+
+ if (foundKeyId != keyObj.keyId) {
+ // different matching key found
+ if (
+ !foundKeyId ||
+ (foundKeyId && acceptanceLevel > foundAcceptanceLevel)
+ ) {
+ foundKeyId = keyObj.keyId;
+ foundAcceptanceLevel = acceptanceLevel;
+ }
+ }
+ }
+
+ if (!foundKeyId) {
+ if (details) {
+ details.msg = "ProblemNoKey";
+ }
+ let msg =
+ "no valid encryption key with enough trust level for '" +
+ emailAddr +
+ "' found";
+ lazy.EnigmailLog.DEBUG(
+ "keyRing.jsm: getValidKeyForRecipient(): " + msg + "\n"
+ );
+ } else {
+ lazy.EnigmailLog.DEBUG(
+ "keyRing.jsm: getValidKeyForRecipient(): key=" +
+ foundKeyId +
+ '" found\n'
+ );
+ }
+ return foundKeyId;
+ },
+
+ getAcceptanceStringFromAcceptanceLevel(level) {
+ switch (level) {
+ case 3:
+ return "personal";
+ case 2:
+ return "verified";
+ case 1:
+ return "unverified";
+ case -1:
+ return "rejected";
+ case 0:
+ default:
+ return "undecided";
+ }
+ },
+
+ async getKeyAcceptanceLevelForEmail(keyObj, email) {
+ if (keyObj.secretAvailable) {
+ throw new Error(
+ `Unexpected private key parameter; keyObj.fpr=${keyObj.fpr}`
+ );
+ }
+
+ let acceptanceLevel = 0;
+
+ let acceptanceResult = {};
+ try {
+ await lazy.PgpSqliteDb2.getAcceptance(
+ keyObj.fpr,
+ email,
+ acceptanceResult
+ );
+ } catch (ex) {
+ console.debug("getAcceptance failed: " + ex);
+ return null;
+ }
+
+ if (acceptanceResult.fingerprintAcceptance == "rejected") {
+ // rejecting is always global for all email addresses
+ return -1;
+ }
+
+ if (acceptanceResult.emailDecided) {
+ switch (acceptanceResult.fingerprintAcceptance) {
+ case "verified":
+ acceptanceLevel = 2;
+ break;
+ case "unverified":
+ acceptanceLevel = 1;
+ break;
+ default:
+ case "undecided":
+ acceptanceLevel = 0;
+ break;
+ }
+ }
+ return acceptanceLevel;
+ },
+
+ async getKeyAcceptanceForEmail(keyObj, email) {
+ let acceptanceResult = {};
+
+ try {
+ await lazy.PgpSqliteDb2.getAcceptance(
+ keyObj.fpr,
+ email,
+ acceptanceResult
+ );
+ } catch (ex) {
+ console.debug("getAcceptance failed: " + ex);
+ return null;
+ }
+
+ if (acceptanceResult.fingerprintAcceptance == "rejected") {
+ // rejecting is always global for all email addresses
+ return acceptanceResult.fingerprintAcceptance;
+ }
+
+ if (acceptanceResult.emailDecided) {
+ switch (acceptanceResult.fingerprintAcceptance) {
+ case "verified":
+ case "unverified":
+ case "undecided":
+ return acceptanceResult.fingerprintAcceptance;
+ }
+ }
+
+ return "undecided";
+ },
+
+ /**
+ * Determine the key ID for a set of given addresses
+ *
+ * @param {Array<string>} addresses: email addresses
+ * @param {object} details: - holds details for invalid keys:
+ * - errArray: {
+ * addr {String}: email addresses
+ * msg {String}: related error
+ * }
+ *
+ * @returns {boolean}: true if at least one key missing; false otherwise
+ */
+ async getValidKeysForAllRecipients(addresses, details) {
+ if (!addresses) {
+ return null;
+ }
+ // check whether each address is or has a key:
+ let keyMissing = false;
+ if (details) {
+ details.errArray = [];
+ }
+ for (let i = 0; i < addresses.length; i++) {
+ let addr = addresses[i];
+ if (!addr) {
+ continue;
+ }
+ // try to find current address in key list:
+ var errMsg = null;
+ addr = addr.toLowerCase();
+ if (!addr.includes("@")) {
+ throw new Error(
+ "getValidKeysForAllRecipients unexpected lookup for non-email addr: " +
+ addr
+ );
+ }
+
+ let aliasKeyList = this.getAliasKeyList(addr);
+ if (aliasKeyList) {
+ for (let entry of aliasKeyList) {
+ let foundError = true;
+
+ let key;
+ if ("fingerprint" in entry) {
+ key = this.getKeyById(entry.fingerprint);
+ } else if ("id" in entry) {
+ key = this.getKeyById(entry.id);
+ }
+ if (key && this.isValidForEncryption(key)) {
+ let acceptanceResult =
+ await lazy.PgpSqliteDb2.getFingerprintAcceptance(null, key.fpr);
+ // If we don't have acceptance info for the key yet,
+ // or, we have it and it isn't rejected,
+ // then we accept the key for using it in alias definitions.
+ if (!acceptanceResult || acceptanceResult != "rejected") {
+ foundError = false;
+ }
+ }
+
+ if (foundError) {
+ keyMissing = true;
+ if (details) {
+ let detEl = {};
+ detEl.addr = addr;
+ detEl.msg = "alias problem";
+ details.errArray.push(detEl);
+ }
+ console.debug(
+ 'keyRing.jsm: getValidKeysForAllRecipients(): alias key list for="' +
+ addr +
+ ' refers to missing or unusable key"\n'
+ );
+ }
+ }
+
+ // skip the lookup for direct matching keys by email
+ continue;
+ }
+
+ // try email match:
+ var addrErrDetails = {};
+ let foundKeyId = await this.getValidKeyForRecipient(addr, addrErrDetails);
+ if (details && addrErrDetails.msg) {
+ errMsg = addrErrDetails.msg;
+ }
+ if (!foundKeyId) {
+ // no key for this address found
+ keyMissing = true;
+ if (details) {
+ if (!errMsg) {
+ errMsg = "ProblemNoKey";
+ }
+ var detailsElem = {};
+ detailsElem.addr = addr;
+ detailsElem.msg = errMsg;
+ details.errArray.push(detailsElem);
+ }
+ lazy.EnigmailLog.DEBUG(
+ 'keyRing.jsm: getValidKeysForAllRecipients(): no single valid key found for="' +
+ addr +
+ '"\n'
+ );
+ }
+ }
+ return keyMissing;
+ },
+
+ async getMultValidKeysForOneRecipient(emailAddr, allowExpired = false) {
+ lazy.EnigmailLog.DEBUG(
+ 'keyRing.jsm: getMultValidKeysForOneRecipient(): emailAddr="' +
+ emailAddr +
+ '"\n'
+ );
+ emailAddr = emailAddr.toLowerCase();
+ if (emailAddr.startsWith("<") && emailAddr.endsWith(">")) {
+ emailAddr = emailAddr.substr(1, emailAddr.length - 2);
+ }
+
+ let found = [];
+
+ let k = this.getAllKeys(null, null);
+ let keyList = k.keyList;
+
+ for (let keyObj of keyList) {
+ let acceptanceLevel = await this.isValidKeyForRecipient(
+ keyObj,
+ emailAddr,
+ allowExpired
+ );
+ if (acceptanceLevel < -1) {
+ continue;
+ }
+ if (!keyObj.secretAvailable) {
+ keyObj.acceptance =
+ this.getAcceptanceStringFromAcceptanceLevel(acceptanceLevel);
+ }
+ found.push(keyObj);
+ }
+ return found;
+ },
+
+ /**
+ * If the given email address has an alias definition, return its
+ * list of key identifiers.
+ *
+ * The function will prefer a match to an exact email alias.
+ * If no email alias could be found, the function will search for
+ * an alias rule that matches the domain.
+ *
+ * @param {string} email - The email address to look up
+ * @returns {[]} - An array with alias key identifiers found for the
+ * input, or null if no alias matches the address.
+ */
+ getAliasKeyList(email) {
+ let ekl = lazy.OpenPGPAlias.getEmailAliasKeyList(email);
+ if (ekl) {
+ return ekl;
+ }
+
+ return lazy.OpenPGPAlias.getDomainAliasKeyList(email);
+ },
+
+ /**
+ * Return the fingerprint of each usable alias key for the given
+ * email address.
+ *
+ * @param {string[]} keyList - Array of key identifiers
+ * @returns {string[]} An array with fingerprints of all alias keys,
+ * or an empty array on failure.
+ */
+ getAliasKeys(keyList) {
+ let keys = [];
+
+ for (let entry of keyList) {
+ let key;
+ let lookupId;
+ if ("fingerprint" in entry) {
+ lookupId = entry.fingerprint;
+ key = this.getKeyById(entry.fingerprint);
+ } else if ("id" in entry) {
+ lookupId = entry.id;
+ key = this.getKeyById(entry.id);
+ }
+ if (key && this.isValidForEncryption(key)) {
+ keys.push(key.fpr);
+ } else {
+ let reason = key ? "not usable" : "missing";
+ console.debug(
+ "getAliasKeys: key for identifier: " + lookupId + " is " + reason
+ );
+ return [];
+ }
+ }
+
+ return keys;
+ },
+
+ /**
+ * Rebuild the quick access search indexes after the key list was loaded
+ */
+ rebuildKeyIndex() {
+ gKeyIndex = [];
+ gSubkeyIndex = [];
+
+ for (let i in gKeyListObj.keyList) {
+ let k = gKeyListObj.keyList[i];
+ gKeyIndex[k.keyId] = k;
+ gKeyIndex[k.fpr] = k;
+ gKeyIndex[k.keyId.substr(-8, 8)] = k;
+
+ // add subkeys
+ for (let j in k.subKeys) {
+ gSubkeyIndex[k.subKeys[j].keyId] = k;
+ }
+ }
+ },
+
+ /**
+ * Update specific keys in the key cache. If the key objects don't exist yet,
+ * they will be created
+ *
+ * @param keys: Array of String - key IDs or fingerprints
+ */
+ updateKeys(keys) {
+ lazy.EnigmailLog.DEBUG("keyRing.jsm: updateKeys(" + keys.join(",") + ")\n");
+ let uniqueKeys = [...new Set(keys)]; // make key IDs unique
+
+ deleteKeysFromCache(uniqueKeys);
+
+ if (gKeyListObj.keyList.length > 0) {
+ loadKeyList(null, null, 1, uniqueKeys);
+ } else {
+ loadKeyList(null, null, 1);
+ }
+
+ lazy.EnigmailWindows.keyManReloadKeys();
+ },
+
+ findRevokedPersonalKeysByEmail(email) {
+ let res = [];
+ if (email === "") {
+ return res;
+ }
+ email = email.toLowerCase();
+ this.getAllKeys(); // ensure keylist is loaded;
+ for (let k of gKeyListObj.keyList) {
+ if (k.keyTrust != "r") {
+ continue;
+ }
+ let hasAdditionalEmail = false;
+ let isMatch = false;
+
+ for (let userId of k.userIds) {
+ if (userId.type !== "uid") {
+ continue;
+ }
+
+ let emailInUid = lazy.EnigmailFuncs.getEmailFromUserID(
+ userId.userId
+ ).toLowerCase();
+ if (emailInUid == email) {
+ isMatch = true;
+ } else {
+ // For privacy reasons, exclude revoked keys that point to
+ // other email addresses.
+ hasAdditionalEmail = true;
+ break;
+ }
+ }
+
+ if (isMatch && !hasAdditionalEmail) {
+ res.push("0x" + k.fpr);
+ }
+ }
+ return res;
+ },
+
+ // Forward to RNP, to avoid that other modules depend on RNP
+ async getRecipientAutocryptKeyForEmail(email) {
+ return lazy.RNP.getRecipientAutocryptKeyForEmail(email);
+ },
+
+ getAutocryptKey(keyId, email) {
+ let keyObj = this.getKeyById(keyId);
+ if (
+ !keyObj ||
+ !keyObj.subKeys.length ||
+ !keyObj.userIds.length ||
+ !keyObj.keyUseFor.includes("s")
+ ) {
+ return null;
+ }
+ let uid = keyObj.getUserIdWithEmail(email);
+ if (!uid) {
+ return null;
+ }
+ return lazy.RNP.getAutocryptKeyB64(keyId, null, uid.userId);
+ },
+
+ alreadyCheckedGnuPG: new Set(),
+
+ /**
+ * @typedef {object} EncryptionKeyMeta
+ * @property {string} readiness - one of
+ * "accepted", "expiredAccepted",
+ * "otherAccepted", "expiredOtherAccepted",
+ * "undecided", "expiredUndecided",
+ * "rejected", "expiredRejected",
+ * "collected", "rejectedPersonal", "revoked", "alias"
+ *
+ * The meaning of "otherAccepted" is: the key is undecided for this
+ * email address, but accepted for at least on other address.
+ *
+ * @property {KeyObj} keyObj -
+ * undefined if an alias
+ * @property {CollectedKey} collectedKey -
+ * undefined if not a collected key or an alias
+ */
+
+ /**
+ * Obtain information on the availability of recipient keys
+ * for the given email address, and the status of the keys.
+ *
+ * No key details are returned for alias keys.
+ *
+ * If readiness is "collected" it's an unexpired key that hasn't
+ * been imported into permanent storage (keyring) yet.
+ *
+ * @param {string} email - email address
+ *
+ * @returns {EncryptionKeyMeta[]} - meta information for an encryption key
+ *
+ * Callers can filter it keys according to needs, like
+ *
+ * let meta = getEncryptionKeyMeta("foo@example.com");
+ * let readyToUse = meta.filter(k => k.readiness == "accepted" || k.readiness == "alias");
+ * let hasAlias = meta.filter(k => k.readiness == "alias");
+ * let accepted = meta.filter(k => k.readiness == "accepted");
+ * let expiredAccepted = meta.filter(k => k.readiness == "expiredAccepted");
+ * let unaccepted = meta.filter(k => k.readiness == "undecided" || k.readiness == "rejected" );
+ * let expiredUnaccepted = meta.filter(k => k.readiness == "expiredUndecided" || k.readiness == "expiredRejected");
+ * let unacceptedNotYetImported = meta.filter(k => k.readiness == "collected");
+ * let invalidKeys = meta.some(k => k.readiness == "revoked" || k.readiness == "rejectedPersonal" || );
+ *
+ * let keyReadiness = meta.groupBy(({readiness}) => readiness);
+ */
+ async getEncryptionKeyMeta(email) {
+ email = email.toLowerCase();
+
+ let result = [];
+
+ result.hasAliasRule = lazy.OpenPGPAlias.hasAliasDefinition(email);
+ if (result.hasAliasRule) {
+ let keyMeta = {};
+ keyMeta.readiness = "alias";
+ result.push(keyMeta);
+ return result;
+ }
+
+ let fingerprintsInKeyring = new Set();
+
+ for (let keyObj of this.getAllKeys(null, null).keyList) {
+ let keyMeta = {};
+ keyMeta.keyObj = keyObj;
+
+ let uidMatch = false;
+ for (let uid of keyObj.userIds) {
+ if (uid.type !== "uid") {
+ continue;
+ }
+ // key valid for encryption?
+ if (!keyObj.keyUseFor.includes("E")) {
+ continue;
+ }
+
+ if (
+ lazy.EnigmailFuncs.getEmailFromUserID(uid.userId).toLowerCase() ===
+ email
+ ) {
+ uidMatch = true;
+ break;
+ }
+ }
+ if (!uidMatch) {
+ continue;
+ }
+ fingerprintsInKeyring.add(keyObj.fpr);
+
+ if (keyObj.keyTrust == "r") {
+ keyMeta.readiness = "revoked";
+ result.push(keyMeta);
+ continue;
+ }
+ let isExpired = keyObj.keyTrust == "e";
+
+ // Ensure we have at least one primary key or subkey usable for
+ // encryption that is not expired/revoked.
+ // We already checked above, the primary key is not revoked.
+ // If the primary key is good for encryption, we don't need to
+ // check subkeys.
+ if (!keyObj.keyUseFor.match(/e/)) {
+ let hasExpiredSubkey = false;
+ let hasRevokedSubkey = false;
+ let hasUsableSubkey = false;
+
+ for (let aSub of keyObj.subKeys) {
+ if (!aSub.keyUseFor.match(/e/)) {
+ continue;
+ }
+ if (aSub.keyTrust == "e") {
+ hasExpiredSubkey = true;
+ } else if (aSub.keyTrust == "r") {
+ hasRevokedSubkey = true;
+ } else {
+ hasUsableSubkey = true;
+ }
+ }
+
+ if (!hasUsableSubkey) {
+ if (hasExpiredSubkey) {
+ isExpired = true;
+ } else if (hasRevokedSubkey) {
+ keyMeta.readiness = "revoked";
+ result.push(keyMeta);
+ continue;
+ }
+ }
+ }
+
+ if (keyObj.secretAvailable) {
+ let isPersonal = await lazy.PgpSqliteDb2.isAcceptedAsPersonalKey(
+ keyObj.fpr
+ );
+ if (isPersonal) {
+ keyMeta.readiness = "accepted";
+ } else {
+ // We don't allow encrypting to rejected secret/personal keys.
+ keyMeta.readiness = "rejectedPersonal";
+ result.push(keyMeta);
+ continue;
+ }
+ } else {
+ let acceptanceLevel = await this.getKeyAcceptanceLevelForEmail(
+ keyObj,
+ email
+ );
+ switch (acceptanceLevel) {
+ case 1:
+ case 2:
+ keyMeta.readiness = isExpired ? "expiredAccepted" : "accepted";
+ break;
+ case -1:
+ keyMeta.readiness = isExpired ? "expiredRejected" : "rejected";
+ break;
+ case 0:
+ default:
+ let other = await lazy.PgpSqliteDb2.getFingerprintAcceptance(
+ null,
+ keyObj.fpr
+ );
+ if (other == "verified" || other == "unverified") {
+ // If the check for the email returned undecided, but
+ // overall the key is marked as accepted, it means that
+ // the key is only accepted for another email address.
+ keyMeta.readiness = isExpired
+ ? "expiredOtherAccepted"
+ : "otherAccepted";
+ } else {
+ keyMeta.readiness = isExpired ? "expiredUndecided" : "undecided";
+ }
+ break;
+ }
+ }
+ result.push(keyMeta);
+ }
+
+ if (
+ Services.prefs.getBoolPref("mail.openpgp.allow_external_gnupg") &&
+ Services.prefs.getBoolPref("mail.openpgp.fetch_pubkeys_from_gnupg") &&
+ !this.alreadyCheckedGnuPG.has(email)
+ ) {
+ this.alreadyCheckedGnuPG.add(email);
+ let keysFromGnuPGMap = lazy.GPGME.getPublicKeysForEmail(email);
+ for (let aFpr of keysFromGnuPGMap.keys()) {
+ let oldKey = this.getKeyById(aFpr);
+ let gpgKeyData = keysFromGnuPGMap.get(aFpr);
+ if (oldKey) {
+ await this.importKeyDataSilent(null, gpgKeyData, false);
+ } else {
+ let k = await lazy.RNP.getKeyListFromKeyBlockImpl(gpgKeyData);
+ if (!k) {
+ continue;
+ }
+ if (k.length != 1) {
+ continue;
+ }
+ let db = await lazy.CollectedKeysDB.getInstance();
+ // If key is known in the db: merge + update.
+ let key = await db.mergeExisting(k[0], gpgKeyData, {
+ uri: "",
+ type: "gnupg",
+ });
+ await db.storeKey(key);
+ }
+ }
+ }
+
+ let collDB = await lazy.CollectedKeysDB.getInstance();
+ let coll = await collDB.findKeysForEmail(email);
+ for (let c of coll) {
+ let k = await lazy.RNP.getKeyListFromKeyBlockImpl(c.pubKey);
+ if (!k) {
+ continue;
+ }
+ if (k.length != 1) {
+ // Past code could have store key blocks that contained
+ // multiple entries. Ignore and delete.
+ collDB.deleteKey(k[0].fpr);
+ continue;
+ }
+
+ let deleteFromCollected = false;
+
+ if (fingerprintsInKeyring.has(k[0].fpr)) {
+ deleteFromCollected = true;
+ } else {
+ let trust = k[0].keyTrust;
+ if (trust == "r" || trust == "e") {
+ deleteFromCollected = true;
+ }
+ }
+
+ if (!deleteFromCollected) {
+ // Ensure we have at least one primary key or subkey usable for
+ // encryption that is not expired/revoked.
+ // If the primary key is good for encryption, we don't need to
+ // check subkeys.
+
+ if (!k[0].keyUseFor.match(/e/)) {
+ let hasUsableSubkey = false;
+
+ for (let aSub of k[0].subKeys) {
+ if (!aSub.keyUseFor.match(/e/)) {
+ continue;
+ }
+ if (aSub.keyTrust != "e" && aSub.keyTrust != "r") {
+ hasUsableSubkey = true;
+ break;
+ }
+ }
+
+ if (!hasUsableSubkey) {
+ deleteFromCollected = true;
+ }
+ }
+ }
+
+ if (deleteFromCollected) {
+ collDB.deleteKey(k[0].fpr);
+ continue;
+ }
+
+ let keyMeta = {};
+ keyMeta.readiness = "collected";
+ keyMeta.keyObj = k[0];
+ keyMeta.collectedKey = c;
+
+ result.push(keyMeta);
+ }
+
+ return result;
+ },
+}; // EnigmailKeyRing
+
+/************************ INTERNAL FUNCTIONS ************************/
+
+function sortByUserId(keyListObj, sortDirection) {
+ return function (a, b) {
+ return a.userId < b.userId ? -sortDirection : sortDirection;
+ };
+}
+
+const sortFunctions = {
+ keyid(keyListObj, sortDirection) {
+ return function (a, b) {
+ return a.keyId < b.keyId ? -sortDirection : sortDirection;
+ };
+ },
+
+ keyidshort(keyListObj, sortDirection) {
+ return function (a, b) {
+ return a.keyId.substr(-8, 8) < b.keyId.substr(-8, 8)
+ ? -sortDirection
+ : sortDirection;
+ };
+ },
+
+ fpr(keyListObj, sortDirection) {
+ return function (a, b) {
+ return keyListObj.keyList[a.keyNum].fpr < keyListObj.keyList[b.keyNum].fpr
+ ? -sortDirection
+ : sortDirection;
+ };
+ },
+
+ keytype(keyListObj, sortDirection) {
+ return function (a, b) {
+ return keyListObj.keyList[a.keyNum].secretAvailable <
+ keyListObj.keyList[b.keyNum].secretAvailable
+ ? -sortDirection
+ : sortDirection;
+ };
+ },
+
+ validity(keyListObj, sortDirection) {
+ return function (a, b) {
+ return lazy.EnigmailTrust.trustLevelsSorted().indexOf(
+ lazy.EnigmailTrust.getTrustCode(keyListObj.keyList[a.keyNum])
+ ) <
+ lazy.EnigmailTrust.trustLevelsSorted().indexOf(
+ lazy.EnigmailTrust.getTrustCode(keyListObj.keyList[b.keyNum])
+ )
+ ? -sortDirection
+ : sortDirection;
+ };
+ },
+
+ trust(keyListObj, sortDirection) {
+ return function (a, b) {
+ return lazy.EnigmailTrust.trustLevelsSorted().indexOf(
+ keyListObj.keyList[a.keyNum].ownerTrust
+ ) <
+ lazy.EnigmailTrust.trustLevelsSorted().indexOf(
+ keyListObj.keyList[b.keyNum].ownerTrust
+ )
+ ? -sortDirection
+ : sortDirection;
+ };
+ },
+
+ created(keyListObj, sortDirection) {
+ return function (a, b) {
+ return keyListObj.keyList[a.keyNum].keyCreated <
+ keyListObj.keyList[b.keyNum].keyCreated
+ ? -sortDirection
+ : sortDirection;
+ };
+ },
+
+ expiry(keyListObj, sortDirection) {
+ return function (a, b) {
+ return keyListObj.keyList[a.keyNum].expiryTime <
+ keyListObj.keyList[b.keyNum].expiryTime
+ ? -sortDirection
+ : sortDirection;
+ };
+ },
+};
+
+function getSortFunction(type, keyListObj, sortDirection) {
+ return (sortFunctions[type] || sortByUserId)(keyListObj, sortDirection);
+}
+
+/**
+ * Load the key list into memory and return it sorted by a specified column
+ *
+ * @param win - |object| holding the parent window for displaying error messages
+ * @param sortColumn - |string| containing the column name for sorting. One of:
+ * userid, keyid, keyidshort, fpr, keytype, validity, trust, created, expiry.
+ * Null will sort by userid.
+ * @param sortDirection - |number| 1 = ascending / -1 = descending
+ * @param onlyKeys - |array| of Strings: if defined, only (re-)load selected key IDs
+ *
+ * no return value
+ */
+function loadKeyList(win, sortColumn, sortDirection, onlyKeys = null) {
+ lazy.EnigmailLog.DEBUG("keyRing.jsm: loadKeyList( " + onlyKeys + ")\n");
+
+ if (gLoadingKeys) {
+ waitForKeyList();
+ return;
+ }
+ gLoadingKeys = true;
+
+ try {
+ const cApi = lazy.EnigmailCryptoAPI();
+ cApi
+ .getKeys(onlyKeys)
+ .then(keyList => {
+ createAndSortKeyList(
+ keyList,
+ sortColumn,
+ sortDirection,
+ onlyKeys === null
+ );
+ gLoadingKeys = false;
+ })
+ .catch(e => {
+ lazy.EnigmailLog.ERROR(`keyRing.jsm: loadKeyList: error ${e}
+`);
+ gLoadingKeys = false;
+ });
+ waitForKeyList();
+ } catch (ex) {
+ lazy.EnigmailLog.ERROR(
+ "keyRing.jsm: loadKeyList: exception: " + ex.toString()
+ );
+ }
+}
+
+/**
+ * Update the global key sort-list (quick index to keys)
+ *
+ * no return value
+ */
+function updateSortList() {
+ gKeyListObj.keySortList = [];
+ for (let i = 0; i < gKeyListObj.keyList.length; i++) {
+ let keyObj = gKeyListObj.keyList[i];
+ gKeyListObj.keySortList.push({
+ userId: keyObj.userId ? keyObj.userId.toLowerCase() : "",
+ keyId: keyObj.keyId,
+ fpr: keyObj.fpr,
+ keyNum: i,
+ });
+ }
+}
+
+/**
+ * Delete a set of keys from the key cache. Does not rebuild key indexes.
+ * Not found keys are skipped.
+ *
+ * @param keyList: Array of Strings: key IDs (or fpr) to delete
+ *
+ * @returns Array of deleted key objects
+ */
+
+function deleteKeysFromCache(keyList) {
+ lazy.EnigmailLog.DEBUG(
+ "keyRing.jsm: deleteKeysFromCache(" + keyList.join(",") + ")\n"
+ );
+
+ let deleted = [];
+ let foundKeys = [];
+ for (let keyId of keyList) {
+ let k = EnigmailKeyRing.getKeyById(keyId, true);
+ if (k) {
+ foundKeys.push(k);
+ }
+ }
+
+ for (let k of foundKeys) {
+ let foundIndex = -1;
+ for (let i = 0; i < gKeyListObj.keyList.length; i++) {
+ if (gKeyListObj.keyList[i].fpr == k.fpr) {
+ foundIndex = i;
+ break;
+ }
+ }
+ if (foundIndex >= 0) {
+ gKeyListObj.keyList.splice(foundIndex, 1);
+ deleted.push(k);
+ }
+ }
+
+ return deleted;
+}
+
+function createAndSortKeyList(
+ keyList,
+ sortColumn,
+ sortDirection,
+ resetKeyCache
+) {
+ lazy.EnigmailLog.DEBUG("keyRing.jsm: createAndSortKeyList()\n");
+
+ if (typeof sortColumn !== "string") {
+ sortColumn = "userid";
+ }
+ if (!sortDirection) {
+ sortDirection = 1;
+ }
+
+ if (!("keyList" in gKeyListObj) || resetKeyCache) {
+ gKeyListObj.keyList = [];
+ gKeyListObj.keySortList = [];
+ gKeyListObj.trustModel = "?";
+ }
+
+ gKeyListObj.keyList = gKeyListObj.keyList.concat(
+ keyList.map(k => {
+ return lazy.newEnigmailKeyObj(k);
+ })
+ );
+
+ // update the quick index for sorting keys
+ updateSortList();
+
+ // create a hash-index on key ID (8 and 16 characters and fingerprint)
+ // in a single array
+
+ EnigmailKeyRing.rebuildKeyIndex();
+
+ gKeyListObj.keySortList.sort(
+ getSortFunction(sortColumn.toLowerCase(), gKeyListObj, sortDirection)
+ );
+}
+
+/*
+function runKeyUsabilityCheck() {
+ EnigmailLog.DEBUG("keyRing.jsm: runKeyUsabilityCheck()\n");
+
+ setTimeout(function() {
+ try {
+ let msg = getKeyUsability().keyExpiryCheck();
+
+ if (msg && msg.length > 0) {
+ EnigmailDialog.info(null, msg);
+ } else {
+ getKeyUsability().checkOwnertrust();
+ }
+ } catch (ex) {
+ EnigmailLog.DEBUG(
+ "keyRing.jsm: runKeyUsabilityCheck: exception " +
+ ex.message +
+ "\n" +
+ ex.stack +
+ "\n"
+ );
+ }
+ }, 60 * 1000); // 1 minute
+}
+*/
+
+function waitForKeyList() {
+ let mainThread = Services.tm.mainThread;
+ while (gLoadingKeys) {
+ mainThread.processNextEvent(true);
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/keyserver.jsm b/comm/mail/extensions/openpgp/content/modules/keyserver.jsm
new file mode 100644
index 0000000000..a2c66ade63
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/keyserver.jsm
@@ -0,0 +1,1549 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailKeyServer"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ FeedUtils: "resource:///modules/FeedUtils.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+const ENIG_DEFAULT_HKP_PORT = "11371";
+const ENIG_DEFAULT_HKPS_PORT = "443";
+const ENIG_DEFAULT_LDAP_PORT = "389";
+
+/**
+ KeySrvListener API
+ Object implementing:
+ - onProgress: function(percentComplete) [only implemented for download()]
+ - onCancel: function() - the body will be set by the callee
+*/
+
+function createError(errId) {
+ let msg = "";
+
+ switch (errId) {
+ case lazy.EnigmailConstants.KEYSERVER_ERR_ABORTED:
+ msg = lazy.l10n.formatValueSync("keyserver-error-aborted");
+ break;
+ case lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR:
+ msg = lazy.l10n.formatValueSync("keyserver-error-server-error");
+ break;
+ case lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_UNAVAILABLE:
+ msg = lazy.l10n.formatValueSync("keyserver-error-unavailable");
+ break;
+ case lazy.EnigmailConstants.KEYSERVER_ERR_SECURITY_ERROR:
+ msg = lazy.l10n.formatValueSync("keyserver-error-security-error");
+ break;
+ case lazy.EnigmailConstants.KEYSERVER_ERR_CERTIFICATE_ERROR:
+ msg = lazy.l10n.formatValueSync("keyserver-error-certificate-error");
+ break;
+ case lazy.EnigmailConstants.KEYSERVER_ERR_IMPORT_ERROR:
+ msg = lazy.l10n.formatValueSync("keyserver-error-import-error");
+ break;
+ case lazy.EnigmailConstants.KEYSERVER_ERR_UNKNOWN:
+ msg = lazy.l10n.formatValueSync("keyserver-error-unknown");
+ break;
+ }
+
+ return {
+ result: errId,
+ errorDetails: msg,
+ };
+}
+
+/**
+ * parse a keyserver specification and return host, protocol and port
+ *
+ * @param keyserver: String - name of keyserver with optional protocol and port.
+ * E.g. keys.gnupg.net, hkps://keys.gnupg.net:443
+ *
+ * @returns Object: {port, host, protocol} (all Strings)
+ */
+function parseKeyserverUrl(keyserver) {
+ if (keyserver.length > 1024) {
+ // insane length of keyserver is forbidden
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ keyserver = keyserver.toLowerCase().trim();
+ let protocol = "";
+ if (keyserver.search(/^[a-zA-Z0-9_.-]+:\/\//) === 0) {
+ protocol = keyserver.replace(/^([a-zA-Z0-9_.-]+)(:\/\/.*)/, "$1");
+ keyserver = keyserver.replace(/^[a-zA-Z0-9_.-]+:\/\//, "");
+ } else {
+ protocol = "hkp";
+ }
+
+ let port = "";
+ switch (protocol) {
+ case "hkp":
+ port = ENIG_DEFAULT_HKP_PORT;
+ break;
+ case "https":
+ case "hkps":
+ port = ENIG_DEFAULT_HKPS_PORT;
+ break;
+ case "ldap":
+ port = ENIG_DEFAULT_LDAP_PORT;
+ break;
+ }
+
+ let m = keyserver.match(/^(.+)(:)(\d+)$/);
+ if (m && m.length == 4) {
+ keyserver = m[1];
+ port = m[3];
+ }
+
+ if (keyserver.search(/^(keys\.mailvelope\.com|api\.protonmail\.ch)$/) === 0) {
+ protocol = "hkps";
+ port = ENIG_DEFAULT_HKPS_PORT;
+ }
+ if (keyserver.search(/^(keybase\.io)$/) === 0) {
+ protocol = "keybase";
+ port = ENIG_DEFAULT_HKPS_PORT;
+ }
+
+ return {
+ protocol,
+ host: keyserver,
+ port,
+ };
+}
+
+/**
+ Object to handle HKP/HKPS requests via builtin XMLHttpRequest()
+ */
+const accessHkpInternal = {
+ /**
+ * Create the payload of hkp requests (upload only)
+ *
+ */
+ async buildHkpPayload(actionFlag, searchTerms) {
+ switch (actionFlag) {
+ case lazy.EnigmailConstants.UPLOAD_KEY:
+ let exitCodeObj = {};
+ let keyData = await lazy.EnigmailKeyRing.extractPublicKeys(
+ ["0x" + searchTerms], // TODO: confirm input is ID or fingerprint
+ null,
+ null,
+ null,
+ exitCodeObj,
+ {}
+ );
+ if (exitCodeObj.value !== 0 || keyData.length === 0) {
+ return null;
+ }
+ return 'keytext="' + encodeURIComponent(keyData) + '"';
+
+ case lazy.EnigmailConstants.DOWNLOAD_KEY:
+ case lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT:
+ case lazy.EnigmailConstants.SEARCH_KEY:
+ return "";
+ }
+
+ // other actions are not yet implemented
+ return null;
+ },
+
+ /**
+ * return the URL and the HTTP access method for a given action
+ */
+ createRequestUrl(keyserver, actionFlag, searchTerm) {
+ let keySrv = parseKeyserverUrl(keyserver);
+
+ let method = "GET";
+ let protocol;
+
+ switch (keySrv.protocol) {
+ case "hkp":
+ protocol = "http";
+ break;
+ case "ldap":
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ default:
+ // equals to hkps
+ protocol = "https";
+ }
+
+ let url = protocol + "://" + keySrv.host + ":" + keySrv.port;
+
+ if (actionFlag === lazy.EnigmailConstants.UPLOAD_KEY) {
+ url += "/pks/add";
+ method = "POST";
+ } else if (
+ actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY ||
+ actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT
+ ) {
+ if (searchTerm.indexOf("0x") !== 0) {
+ searchTerm = "0x" + searchTerm;
+ }
+ url += "/pks/lookup?search=" + searchTerm + "&op=get&options=mr";
+ } else if (actionFlag === lazy.EnigmailConstants.SEARCH_KEY) {
+ url +=
+ "/pks/lookup?search=" +
+ escape(searchTerm) +
+ "&fingerprint=on&op=index&options=mr&exact=on";
+ }
+
+ return {
+ url,
+ host: keySrv.host,
+ method,
+ };
+ },
+
+ /**
+ * Upload, search or download keys from a keyserver
+ *
+ * @param actionFlag: Number - Keyserver Action Flags: from EnigmailConstants
+ * @param keyId: String - space-separated list of search terms or key IDs
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<Number (Status-ID)>
+ */
+ async accessKeyServer(actionFlag, keyserver, keyId, listener) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessHkpInternal.accessKeyServer(${keyserver})\n`
+ );
+ if (!keyserver) {
+ throw new Error("accessKeyServer requires explicit keyserver parameter");
+ }
+
+ let payLoad = await this.buildHkpPayload(actionFlag, keyId);
+
+ return new Promise((resolve, reject) => {
+ let xmlReq = null;
+ if (listener && typeof listener === "object") {
+ listener.onCancel = function () {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessHkpInternal.accessKeyServer - onCancel() called\n`
+ );
+ if (xmlReq) {
+ xmlReq.abort();
+ }
+ reject(createError(lazy.EnigmailConstants.KEYSERVER_ERR_ABORTED));
+ };
+ }
+ if (actionFlag === lazy.EnigmailConstants.REFRESH_KEY) {
+ // we don't (need to) distinguish between refresh and download for our internal protocol
+ actionFlag = lazy.EnigmailConstants.DOWNLOAD_KEY;
+ }
+
+ if (payLoad === null) {
+ reject(createError(lazy.EnigmailConstants.KEYSERVER_ERR_UNKNOWN));
+ return;
+ }
+
+ xmlReq = new XMLHttpRequest();
+
+ xmlReq.onload = function () {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessHkpInternal: onload(): status=" +
+ xmlReq.status +
+ "\n"
+ );
+ switch (actionFlag) {
+ case lazy.EnigmailConstants.UPLOAD_KEY:
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessHkpInternal: onload: " +
+ xmlReq.responseText +
+ "\n"
+ );
+ if (xmlReq.status >= 400) {
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR)
+ );
+ } else {
+ resolve(0);
+ }
+ return;
+
+ case lazy.EnigmailConstants.SEARCH_KEY:
+ if (xmlReq.status === 404) {
+ // key not found
+ resolve("");
+ } else if (xmlReq.status >= 400) {
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR)
+ );
+ } else {
+ resolve(xmlReq.responseText);
+ }
+ return;
+
+ case lazy.EnigmailConstants.DOWNLOAD_KEY:
+ case lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT:
+ if (xmlReq.status >= 400 && xmlReq.status < 500) {
+ // key not found
+ resolve(1);
+ } else if (xmlReq.status >= 500) {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessHkpInternal: onload: " +
+ xmlReq.responseText +
+ "\n"
+ );
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR)
+ );
+ } else {
+ let errorMsgObj = {},
+ importedKeysObj = {};
+
+ if (actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY) {
+ let importMinimal = false;
+ let r = lazy.EnigmailKeyRing.importKey(
+ null,
+ false,
+ xmlReq.responseText,
+ false,
+ "",
+ errorMsgObj,
+ importedKeysObj,
+ importMinimal
+ );
+ if (r === 0) {
+ resolve(importedKeysObj.value);
+ } else {
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_IMPORT_ERROR
+ )
+ );
+ }
+ } else {
+ // DOWNLOAD_KEY_NO_IMPORT
+ resolve(xmlReq.responseText);
+ }
+ }
+ return;
+ }
+ resolve(-1);
+ };
+
+ xmlReq.onerror = function (e) {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessHkpInternal.accessKeyServer: onerror: " +
+ e +
+ "\n"
+ );
+ let err = lazy.FeedUtils.createTCPErrorFromFailedXHR(e.target);
+ switch (err.type) {
+ case "SecurityCertificate":
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_CERTIFICATE_ERROR
+ )
+ );
+ break;
+ case "SecurityProtocol":
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SECURITY_ERROR)
+ );
+ break;
+ case "Network":
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_UNAVAILABLE
+ )
+ );
+ break;
+ }
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_UNAVAILABLE)
+ );
+ };
+
+ xmlReq.onloadend = function () {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessHkpInternal.accessKeyServer: loadEnd\n"
+ );
+ };
+
+ let { url, method } = this.createRequestUrl(keyserver, actionFlag, keyId);
+
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessHkpInternal.accessKeyServer: requesting ${url}\n`
+ );
+ xmlReq.open(method, url);
+ xmlReq.send(payLoad);
+ });
+ },
+
+ /**
+ * Download keys from a keyserver
+ *
+ * @param keyIDs: String - space-separated list of search terms or key IDs
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<...>
+ */
+ async download(autoImport, keyIDs, keyserver, listener = null) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessHkpInternal.download(${keyIDs})\n`
+ );
+ let keyIdArr = keyIDs.split(/ +/);
+ let retObj = {
+ result: 0,
+ errorDetails: "",
+ keyList: [],
+ };
+
+ for (let i = 0; i < keyIdArr.length; i++) {
+ try {
+ let r = await this.accessKeyServer(
+ autoImport
+ ? lazy.EnigmailConstants.DOWNLOAD_KEY
+ : lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT,
+ keyserver,
+ keyIdArr[i],
+ listener
+ );
+ if (autoImport) {
+ if (Array.isArray(r)) {
+ retObj.keyList = retObj.keyList.concat(r);
+ }
+ } else if (typeof r == "string") {
+ retObj.keyData = r;
+ } else {
+ retObj.result = r;
+ }
+ } catch (ex) {
+ retObj.result = ex.result;
+ retObj.errorDetails = ex.errorDetails;
+ throw retObj;
+ }
+
+ if (listener && "onProgress" in listener) {
+ listener.onProgress(((i + 1) / keyIdArr.length) * 100);
+ }
+ }
+
+ return retObj;
+ },
+
+ refresh(keyServer, listener = null) {
+ let keyList = lazy.EnigmailKeyRing.getAllKeys()
+ .keyList.map(keyObj => {
+ return "0x" + keyObj.fpr;
+ })
+ .join(" ");
+
+ return this.download(true, keyList, keyServer, listener);
+ },
+
+ /**
+ * Upload keys to a keyserver
+ *
+ * @param keyIDs: String - space-separated list of search terms or key IDs
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return {boolean} - Returns true if the key was sent successfully
+ */
+ async upload(keyIDs, keyserver, listener = null) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessHkpInternal.upload(${keyIDs})\n`
+ );
+ let keyIdArr = keyIDs.split(/ +/);
+ let rv = false;
+
+ for (let i = 0; i < keyIdArr.length; i++) {
+ try {
+ let r = await this.accessKeyServer(
+ lazy.EnigmailConstants.UPLOAD_KEY,
+ keyserver,
+ keyIdArr[i],
+ listener
+ );
+ if (r === 0) {
+ rv = true;
+ } else {
+ rv = false;
+ break;
+ }
+ } catch (ex) {
+ console.log(ex.errorDetails);
+ rv = false;
+ break;
+ }
+
+ if (listener && "onProgress" in listener) {
+ listener.onProgress(((i + 1) / keyIdArr.length) * 100);
+ }
+ }
+
+ return rv;
+ },
+
+ /**
+ * Search for keys on a keyserver
+ *
+ * @param searchTerm: String - search term
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<Object>
+ * - result: Number
+ * - pubKeys: Array of Object:
+ * PubKeys: Object with:
+ * - keyId: String
+ * - keyLen: String
+ * - keyType: String
+ * - created: String (YYYY-MM-DD)
+ * - status: String: one of ''=valid, r=revoked, e=expired
+ * - uid: Array of Strings with UIDs
+ */
+ async searchKeyserver(searchTerm, keyserver, listener = null) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessHkpInternal.search(${searchTerm})\n`
+ );
+ let retObj = {
+ result: 0,
+ errorDetails: "",
+ pubKeys: [],
+ };
+ let key = null;
+
+ let searchArr = searchTerm.split(/ +/);
+
+ for (let k in searchArr) {
+ let r = await this.accessKeyServer(
+ lazy.EnigmailConstants.SEARCH_KEY,
+ keyserver,
+ searchArr[k],
+ listener
+ );
+
+ let lines = r.split(/\r?\n/);
+
+ for (var i = 0; i < lines.length; i++) {
+ let line = lines[i].split(/:/).map(unescape);
+ if (line.length <= 1) {
+ continue;
+ }
+
+ switch (line[0]) {
+ case "info":
+ if (line[1] !== "1") {
+ // protocol version not supported
+ retObj.result = 7;
+ retObj.errorDetails = await lazy.l10n.formatValue(
+ "keyserver-error-unsupported"
+ );
+ retObj.pubKeys = [];
+ return retObj;
+ }
+ break;
+ case "pub":
+ if (line.length >= 6) {
+ if (key) {
+ retObj.pubKeys.push(key);
+ key = null;
+ }
+ let dat = new Date(line[4] * 1000);
+ let month = String(dat.getMonth() + 101).substr(1);
+ let day = String(dat.getDate() + 100).substr(1);
+ key = {
+ keyId: line[1],
+ keyLen: line[3],
+ keyType: line[2],
+ created: dat.getFullYear() + "-" + month + "-" + day,
+ uid: [],
+ status: line[6],
+ };
+ }
+ break;
+ case "uid":
+ key.uid.push(
+ lazy.EnigmailData.convertToUnicode(line[1].trim(), "utf-8")
+ );
+ }
+ }
+
+ if (key) {
+ retObj.pubKeys.push(key);
+ }
+ }
+
+ return retObj;
+ },
+};
+
+/**
+ Object to handle KeyBase requests (search & download only)
+ */
+const accessKeyBase = {
+ /**
+ * return the URL and the HTTP access method for a given action
+ */
+ createRequestUrl(actionFlag, searchTerm) {
+ let url = "https://keybase.io/_/api/1.0/user/";
+
+ if (actionFlag === lazy.EnigmailConstants.UPLOAD_KEY) {
+ // not supported
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ } else if (
+ actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY ||
+ actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT
+ ) {
+ if (searchTerm.indexOf("0x") === 0) {
+ searchTerm = searchTerm.substr(0, 40);
+ }
+ url +=
+ "lookup.json?key_fingerprint=" +
+ escape(searchTerm) +
+ "&fields=public_keys";
+ } else if (actionFlag === lazy.EnigmailConstants.SEARCH_KEY) {
+ url += "autocomplete.json?q=" + escape(searchTerm);
+ }
+
+ return {
+ url,
+ method: "GET",
+ };
+ },
+
+ /**
+ * Upload, search or download keys from a keyserver
+ *
+ * @param actionFlag: Number - Keyserver Action Flags: from EnigmailConstants
+ * @param keyId: String - space-separated list of search terms or key IDs
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<Number (Status-ID)>
+ */
+ async accessKeyServer(actionFlag, keyId, listener) {
+ lazy.EnigmailLog.DEBUG(`keyserver.jsm: accessKeyBase: accessKeyServer()\n`);
+
+ return new Promise((resolve, reject) => {
+ let xmlReq = null;
+ if (listener && typeof listener === "object") {
+ listener.onCancel = function () {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessKeyBase: accessKeyServer - onCancel() called\n`
+ );
+ if (xmlReq) {
+ xmlReq.abort();
+ }
+ reject(createError(lazy.EnigmailConstants.KEYSERVER_ERR_ABORTED));
+ };
+ }
+ if (actionFlag === lazy.EnigmailConstants.REFRESH_KEY) {
+ // we don't (need to) distinguish between refresh and download for our internal protocol
+ actionFlag = lazy.EnigmailConstants.DOWNLOAD_KEY;
+ }
+
+ xmlReq = new XMLHttpRequest();
+
+ xmlReq.onload = function () {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: onload(): status=" + xmlReq.status + "\n"
+ );
+ switch (actionFlag) {
+ case lazy.EnigmailConstants.SEARCH_KEY:
+ if (xmlReq.status >= 400) {
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR)
+ );
+ } else {
+ resolve(xmlReq.responseText);
+ }
+ return;
+
+ case lazy.EnigmailConstants.DOWNLOAD_KEY:
+ case lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT:
+ if (xmlReq.status >= 400 && xmlReq.status < 500) {
+ // key not found
+ resolve(1);
+ } else if (xmlReq.status >= 500) {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: onload: " + xmlReq.responseText + "\n"
+ );
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR)
+ );
+ } else {
+ try {
+ let resp = JSON.parse(xmlReq.responseText);
+ if (resp.status.code === 0) {
+ for (let hit in resp.them) {
+ lazy.EnigmailLog.DEBUG(
+ JSON.stringify(resp.them[hit].public_keys.primary) + "\n"
+ );
+
+ if (resp.them[hit] !== null) {
+ let errorMsgObj = {},
+ importedKeysObj = {};
+
+ if (actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY) {
+ let r = lazy.EnigmailKeyRing.importKey(
+ null,
+ false,
+ resp.them[hit].public_keys.primary.bundle,
+ false,
+ "",
+ errorMsgObj,
+ importedKeysObj
+ );
+ if (r === 0) {
+ resolve(importedKeysObj.value);
+ } else {
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_IMPORT_ERROR
+ )
+ );
+ }
+ } else {
+ // DOWNLOAD_KEY_NO_IMPORT
+ resolve(resp.them[hit].public_keys.primary.bundle);
+ }
+ }
+ }
+ }
+ } catch (ex) {
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_UNKNOWN)
+ );
+ }
+ }
+ return;
+ }
+ resolve(-1);
+ };
+
+ xmlReq.onerror = function (e) {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessKeyBase: onerror: " + e + "\n"
+ );
+ let err = lazy.FeedUtils.createTCPErrorFromFailedXHR(e.target);
+ switch (err.type) {
+ case "SecurityCertificate":
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_CERTIFICATE_ERROR
+ )
+ );
+ break;
+ case "SecurityProtocol":
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SECURITY_ERROR)
+ );
+ break;
+ case "Network":
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_UNAVAILABLE
+ )
+ );
+ break;
+ }
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_UNAVAILABLE)
+ );
+ };
+
+ xmlReq.onloadend = function () {
+ lazy.EnigmailLog.DEBUG("keyserver.jsm: accessKeyBase: loadEnd\n");
+ };
+
+ let { url, method } = this.createRequestUrl(actionFlag, keyId);
+
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessKeyBase: requesting ${url}\n`
+ );
+ xmlReq.open(method, url);
+ xmlReq.send("");
+ });
+ },
+
+ /**
+ * Download keys from a KeyBase
+ *
+ * @param keyIDs: String - space-separated list of search terms or key IDs
+ * @param keyserver: (not used for keybase)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<...>
+ */
+ async download(autoImport, keyIDs, keyserver, listener = null) {
+ lazy.EnigmailLog.DEBUG(`keyserver.jsm: accessKeyBase: download()\n`);
+ let keyIdArr = keyIDs.split(/ +/);
+ let retObj = {
+ result: 0,
+ errorDetails: "",
+ keyList: [],
+ };
+
+ for (let i = 0; i < keyIdArr.length; i++) {
+ try {
+ let r = await this.accessKeyServer(
+ autoImport
+ ? lazy.EnigmailConstants.DOWNLOAD_KEY
+ : lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT,
+ keyIdArr[i],
+ listener
+ );
+ if (r.length > 0) {
+ retObj.keyList = retObj.keyList.concat(r);
+ }
+ } catch (ex) {
+ retObj.result = ex.result;
+ retObj.errorDetails = ex.result;
+ throw retObj;
+ }
+
+ if (listener && "onProgress" in listener) {
+ listener.onProgress(i / keyIdArr.length);
+ }
+ }
+
+ return retObj;
+ },
+
+ /**
+ * Search for keys on a keyserver
+ *
+ * @param searchTerm: String - search term
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<Object>
+ * - result: Number
+ * - pubKeys: Array of Object:
+ * PubKeys: Object with:
+ * - keyId: String
+ * - keyLen: String
+ * - keyType: String
+ * - created: String (YYYY-MM-DD)
+ * - status: String: one of ''=valid, r=revoked, e=expired
+ * - uid: Array of Strings with UIDs
+
+ */
+ async searchKeyserver(searchTerm, keyserver, listener = null) {
+ lazy.EnigmailLog.DEBUG(`keyserver.jsm: accessKeyBase: search()\n`);
+ let retObj = {
+ result: 0,
+ errorDetails: "",
+ pubKeys: [],
+ };
+
+ try {
+ let r = await this.accessKeyServer(
+ lazy.EnigmailConstants.SEARCH_KEY,
+ searchTerm,
+ listener
+ );
+
+ let res = JSON.parse(r);
+ let completions = res.completions;
+
+ for (let hit in completions) {
+ if (
+ completions[hit] &&
+ completions[hit].components.key_fingerprint !== undefined
+ ) {
+ let uid = completions[hit].components.username.val;
+ if ("full_name" in completions[hit].components) {
+ uid += " (" + completions[hit].components.full_name.val + ")";
+ }
+ let key = {
+ keyId:
+ completions[hit].components.key_fingerprint.val.toUpperCase(),
+ keyLen:
+ completions[hit].components.key_fingerprint.nbits.toString(),
+ keyType:
+ completions[hit].components.key_fingerprint.algo.toString(),
+ created: 0, //date.toDateString(),
+ uid: [uid],
+ status: "",
+ };
+ retObj.pubKeys.push(key);
+ }
+ }
+ } catch (ex) {
+ retObj.result = ex.result;
+ retObj.errorDetails = ex.errorDetails;
+ throw retObj;
+ }
+
+ return retObj;
+ },
+
+ upload() {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ },
+
+ refresh(keyServer, listener = null) {
+ lazy.EnigmailLog.DEBUG(`keyserver.jsm: accessKeyBase: refresh()\n`);
+ let keyList = lazy.EnigmailKeyRing.getAllKeys()
+ .keyList.map(keyObj => {
+ return "0x" + keyObj.fpr;
+ })
+ .join(" ");
+
+ return this.download(true, keyList, keyServer, listener);
+ },
+};
+
+function getAccessType(keyserver) {
+ if (!keyserver) {
+ throw new Error("getAccessType requires explicit keyserver parameter");
+ }
+
+ let srv = parseKeyserverUrl(keyserver);
+ switch (srv.protocol) {
+ case "keybase":
+ return accessKeyBase;
+ case "vks":
+ return accessVksServer;
+ }
+
+ if (srv.host.search(/keys.openpgp.org$/i) >= 0) {
+ return accessVksServer;
+ }
+
+ return accessHkpInternal;
+}
+
+/**
+ Object to handle VKS requests (for example keys.openpgp.org)
+ */
+const accessVksServer = {
+ /**
+ * Create the payload of VKS requests (currently upload only)
+ *
+ */
+ async buildJsonPayload(actionFlag, searchTerms, locale) {
+ switch (actionFlag) {
+ case lazy.EnigmailConstants.UPLOAD_KEY:
+ let exitCodeObj = {};
+ let keyData = await lazy.EnigmailKeyRing.extractPublicKeys(
+ ["0x" + searchTerms], // must be id or fingerprint
+ null,
+ null,
+ null,
+ exitCodeObj,
+ {}
+ );
+ if (exitCodeObj.value !== 0 || keyData.length === 0) {
+ return null;
+ }
+
+ return JSON.stringify({
+ keytext: keyData,
+ });
+
+ case lazy.EnigmailConstants.GET_CONFIRMATION_LINK:
+ return JSON.stringify({
+ token: searchTerms.token,
+ addresses: searchTerms.addresses,
+ locale: [locale],
+ });
+
+ case lazy.EnigmailConstants.DOWNLOAD_KEY:
+ case lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT:
+ case lazy.EnigmailConstants.SEARCH_KEY:
+ return "";
+ }
+
+ // other actions are not yet implemented
+ return null;
+ },
+
+ /**
+ * return the URL and the HTTP access method for a given action
+ */
+ createRequestUrl(keyserver, actionFlag, searchTerm) {
+ let keySrv = parseKeyserverUrl(keyserver);
+ let contentType = "text/plain;charset=UTF-8";
+
+ let method = "GET";
+
+ let url = "https://" + keySrv.host;
+
+ if (actionFlag === lazy.EnigmailConstants.UPLOAD_KEY) {
+ url += "/vks/v1/upload";
+ method = "POST";
+ contentType = "application/json";
+ } else if (actionFlag === lazy.EnigmailConstants.GET_CONFIRMATION_LINK) {
+ url += "/vks/v1/request-verify";
+ method = "POST";
+ contentType = "application/json";
+ } else if (
+ actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY ||
+ actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT ||
+ actionFlag === lazy.EnigmailConstants.SEARCH_KEY
+ ) {
+ if (searchTerm) {
+ let lookup = "/vks/";
+ if (searchTerm.indexOf("0x") === 0) {
+ searchTerm = searchTerm.substr(2);
+ if (
+ searchTerm.length == 16 &&
+ searchTerm.search(/^[A-F0-9]+$/) === 0
+ ) {
+ lookup = "/vks/v1/by-keyid/" + searchTerm;
+ } else if (
+ searchTerm.length == 40 &&
+ searchTerm.search(/^[A-F0-9]+$/) === 0
+ ) {
+ lookup = "/vks/v1/by-fingerprint/" + searchTerm;
+ }
+ } else {
+ try {
+ searchTerm = lazy.EnigmailFuncs.stripEmail(searchTerm);
+ } catch (x) {}
+ lookup = "/vks/v1/by-email/" + searchTerm;
+ }
+ url += lookup;
+ }
+ }
+
+ return {
+ url,
+ method,
+ contentType,
+ };
+ },
+
+ /**
+ * Upload, search or download keys from a keyserver
+ *
+ * @param actionFlag: Number - Keyserver Action Flags: from EnigmailConstants
+ * @param keyId: String - space-separated list of search terms or key IDs
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<Number (Status-ID)>
+ */
+ async accessKeyServer(actionFlag, keyserver, keyId, listener) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessVksServer.accessKeyServer(${keyserver})\n`
+ );
+ if (keyserver === null) {
+ keyserver = "keys.openpgp.org";
+ }
+
+ let uiLocale = Services.locale.appLocalesAsBCP47[0];
+ let payLoad = await this.buildJsonPayload(actionFlag, keyId, uiLocale);
+
+ return new Promise((resolve, reject) => {
+ let xmlReq = null;
+ if (listener && typeof listener === "object") {
+ listener.onCancel = function () {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessVksServer.accessKeyServer - onCancel() called\n`
+ );
+ if (xmlReq) {
+ xmlReq.abort();
+ }
+ reject(createError(lazy.EnigmailConstants.KEYSERVER_ERR_ABORTED));
+ };
+ }
+ if (actionFlag === lazy.EnigmailConstants.REFRESH_KEY) {
+ // we don't (need to) distinguish between refresh and download for our internal protocol
+ actionFlag = lazy.EnigmailConstants.DOWNLOAD_KEY;
+ }
+
+ if (payLoad === null) {
+ reject(createError(lazy.EnigmailConstants.KEYSERVER_ERR_UNKNOWN));
+ return;
+ }
+
+ xmlReq = new XMLHttpRequest();
+
+ xmlReq.onload = function () {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessVksServer.onload(): status=" +
+ xmlReq.status +
+ "\n"
+ );
+ switch (actionFlag) {
+ case lazy.EnigmailConstants.UPLOAD_KEY:
+ case lazy.EnigmailConstants.GET_CONFIRMATION_LINK:
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessVksServer.onload: " +
+ xmlReq.responseText +
+ "\n"
+ );
+ if (xmlReq.status >= 400) {
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR)
+ );
+ } else {
+ resolve(xmlReq.responseText);
+ }
+ return;
+
+ case lazy.EnigmailConstants.SEARCH_KEY:
+ if (xmlReq.status === 404) {
+ // key not found
+ resolve("");
+ } else if (xmlReq.status >= 400) {
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR)
+ );
+ } else {
+ resolve(xmlReq.responseText);
+ }
+ return;
+
+ case lazy.EnigmailConstants.DOWNLOAD_KEY:
+ case lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT:
+ if (xmlReq.status >= 400 && xmlReq.status < 500) {
+ // key not found
+ resolve(1);
+ } else if (xmlReq.status >= 500) {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessVksServer.onload: " +
+ xmlReq.responseText +
+ "\n"
+ );
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_ERROR)
+ );
+ } else {
+ let errorMsgObj = {},
+ importedKeysObj = {};
+ if (actionFlag === lazy.EnigmailConstants.DOWNLOAD_KEY) {
+ let r = lazy.EnigmailKeyRing.importKey(
+ null,
+ false,
+ xmlReq.responseText,
+ false,
+ "",
+ errorMsgObj,
+ importedKeysObj
+ );
+ if (r === 0) {
+ resolve(importedKeysObj.value);
+ } else {
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_IMPORT_ERROR
+ )
+ );
+ }
+ } else {
+ // DOWNLOAD_KEY_NO_IMPORT
+ resolve(xmlReq.responseText);
+ }
+ }
+ return;
+ }
+ resolve(-1);
+ };
+
+ xmlReq.onerror = function (e) {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessVksServer.accessKeyServer: onerror: " + e + "\n"
+ );
+ let err = lazy.FeedUtils.createTCPErrorFromFailedXHR(e.target);
+ switch (err.type) {
+ case "SecurityCertificate":
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_CERTIFICATE_ERROR
+ )
+ );
+ break;
+ case "SecurityProtocol":
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SECURITY_ERROR)
+ );
+ break;
+ case "Network":
+ reject(
+ createError(
+ lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_UNAVAILABLE
+ )
+ );
+ break;
+ }
+ reject(
+ createError(lazy.EnigmailConstants.KEYSERVER_ERR_SERVER_UNAVAILABLE)
+ );
+ };
+
+ xmlReq.onloadend = function () {
+ lazy.EnigmailLog.DEBUG(
+ "keyserver.jsm: accessVksServer.accessKeyServer: loadEnd\n"
+ );
+ };
+
+ let { url, method, contentType } = this.createRequestUrl(
+ keyserver,
+ actionFlag,
+ keyId
+ );
+
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessVksServer.accessKeyServer: requesting ${method} for ${url}\n`
+ );
+ xmlReq.open(method, url);
+ xmlReq.setRequestHeader("Content-Type", contentType);
+ xmlReq.send(payLoad);
+ });
+ },
+
+ /**
+ * Download keys from a keyserver
+ *
+ * @param keyIDs: String - space-separated list of search terms or key IDs
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<...>
+ */
+ async download(autoImport, keyIDs, keyserver, listener = null) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessVksServer.download(${keyIDs})\n`
+ );
+ let keyIdArr = keyIDs.split(/ +/);
+ let retObj = {
+ result: 0,
+ errorDetails: "",
+ keyList: [],
+ };
+
+ for (let i = 0; i < keyIdArr.length; i++) {
+ try {
+ let r = await this.accessKeyServer(
+ autoImport
+ ? lazy.EnigmailConstants.DOWNLOAD_KEY
+ : lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT,
+ keyserver,
+ keyIdArr[i],
+ listener
+ );
+ if (autoImport) {
+ if (Array.isArray(r)) {
+ retObj.keyList = retObj.keyList.concat(r);
+ }
+ } else if (typeof r == "string") {
+ retObj.keyData = r;
+ } else {
+ retObj.result = r;
+ }
+ } catch (ex) {
+ retObj.result = ex.result;
+ retObj.errorDetails = ex.errorDetails;
+ throw retObj;
+ }
+
+ if (listener && "onProgress" in listener) {
+ listener.onProgress(((i + 1) / keyIdArr.length) * 100);
+ }
+ }
+
+ return retObj;
+ },
+
+ refresh(keyServer, listener = null) {
+ let keyList = lazy.EnigmailKeyRing.getAllKeys()
+ .keyList.map(keyObj => {
+ return "0x" + keyObj.fpr;
+ })
+ .join(" ");
+
+ return this.download(true, keyList, keyServer, listener);
+ },
+
+ async requestConfirmationLink(keyserver, jsonFragment) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessVksServer.requestConfirmationLink()\n`
+ );
+
+ let response = JSON.parse(jsonFragment);
+
+ let addr = [];
+
+ for (let email in response.status) {
+ if (response.status[email] !== "published") {
+ addr.push(email);
+ }
+ }
+
+ if (addr.length > 0) {
+ let r = await this.accessKeyServer(
+ lazy.EnigmailConstants.GET_CONFIRMATION_LINK,
+ keyserver,
+ {
+ token: response.token,
+ addresses: addr,
+ },
+ null
+ );
+
+ if (typeof r === "string") {
+ return addr.length;
+ }
+ }
+
+ return 0;
+ },
+
+ /**
+ * Upload keys to a keyserver
+ *
+ * @param keyIDs: String - space-separated list of search terms or key IDs
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return {boolean} - Returns true if the key was sent successfully
+ */
+ async upload(keyIDs, keyserver, listener = null) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessVksServer.upload(${keyIDs})\n`
+ );
+ let keyIdArr = keyIDs.split(/ +/);
+ let rv = false;
+
+ for (let i = 0; i < keyIdArr.length; i++) {
+ let keyObj = lazy.EnigmailKeyRing.getKeyById(keyIdArr[i]);
+
+ if (!keyObj.secretAvailable) {
+ throw new Error(
+ "public keyserver uploading supported only for user's own keys"
+ );
+ }
+
+ try {
+ let r = await this.accessKeyServer(
+ lazy.EnigmailConstants.UPLOAD_KEY,
+ keyserver,
+ keyIdArr[i],
+ listener
+ );
+ if (typeof r === "string") {
+ let req = await this.requestConfirmationLink(keyserver, r);
+ if (req >= 0) {
+ rv = true;
+ }
+ } else {
+ rv = false;
+ break;
+ }
+ } catch (ex) {
+ console.log(ex.errorDetails);
+ rv = false;
+ break;
+ }
+
+ if (listener && "onProgress" in listener) {
+ listener.onProgress(((i + 1) / keyIdArr.length) * 100);
+ }
+ }
+
+ return rv;
+ },
+
+ /**
+ * Search for keys on a keyserver
+ *
+ * @param searchTerm: String - search term
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<Object>
+ * - result: Number
+ * - pubKeys: Array of Object:
+ * PubKeys: Object with:
+ * - keyId: String
+ * - keyLen: String
+ * - keyType: String
+ * - created: String (YYYY-MM-DD)
+ * - status: String: one of ''=valid, r=revoked, e=expired
+ * - uid: Array of Strings with UIDs
+ */
+ async searchKeyserver(searchTerm, keyserver, listener = null) {
+ lazy.EnigmailLog.DEBUG(
+ `keyserver.jsm: accessVksServer.search(${searchTerm})\n`
+ );
+ let retObj = {
+ result: 0,
+ errorDetails: "",
+ pubKeys: [],
+ };
+ let key = null;
+
+ let searchArr = searchTerm.split(/ +/);
+
+ try {
+ for (let i in searchArr) {
+ let r = await this.accessKeyServer(
+ lazy.EnigmailConstants.SEARCH_KEY,
+ keyserver,
+ searchArr[i],
+ listener
+ );
+
+ const cApi = lazy.EnigmailCryptoAPI();
+ let keyList = await cApi.getKeyListFromKeyBlockAPI(
+ r,
+ true,
+ false,
+ true,
+ false
+ );
+ if (!keyList) {
+ retObj.result = -1;
+ // TODO: should we set retObj.errorDetails to a string?
+ return retObj;
+ }
+
+ for (let k in keyList) {
+ key = {
+ keyId: keyList[k].fpr,
+ keyLen: "0",
+ keyType: "",
+ created: keyList[k].created,
+ uid: [keyList[k].name],
+ status: keyList[k].revoke ? "r" : "",
+ };
+
+ for (let uid of keyList[k].uids) {
+ key.uid.push(uid);
+ }
+
+ retObj.pubKeys.push(key);
+ }
+ }
+ } catch (ex) {
+ retObj.result = ex.result;
+ retObj.errorDetails = ex.errorDetails;
+ throw retObj;
+ }
+
+ return retObj;
+ },
+};
+
+var EnigmailKeyServer = {
+ /**
+ * Download keys from a keyserver
+ *
+ * @param keyIDs: String - space-separated list of FPRs or key IDs
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<Object>
+ * Object: - result: Number - result Code (0 = OK),
+ * - keyList: Array of String - imported key FPR
+ */
+ async download(keyIDs, keyserver = null, listener) {
+ let acc = getAccessType(keyserver);
+ return acc.download(true, keyIDs, keyserver, listener);
+ },
+
+ async downloadNoImport(keyIDs, keyserver = null, listener) {
+ let acc = getAccessType(keyserver);
+ return acc.download(false, keyIDs, keyserver, listener);
+ },
+
+ serverReqURL(keyIDs, keyserver) {
+ let acc = getAccessType(keyserver);
+ let { url } = acc.createRequestUrl(
+ keyserver,
+ lazy.EnigmailConstants.DOWNLOAD_KEY_NO_IMPORT,
+ keyIDs
+ );
+ return url;
+ },
+
+ /**
+ * Upload keys to a keyserver
+ *
+ * @param keyIDs: String - space-separated list of key IDs or FPR
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return {boolean} - Returns true if the key was sent successfully
+ */
+
+ async upload(keyIDs, keyserver = null, listener) {
+ let acc = getAccessType(keyserver);
+ return acc.upload(keyIDs, keyserver, listener);
+ },
+
+ /**
+ * Search keys on a keyserver
+ *
+ * @param searchString: String - search term. Multiple email addresses can be search by spaces
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<Object>
+ * - result: Number
+ * - pubKeys: Array of Object:
+ * PubKeys: Object with:
+ * - keyId: String
+ * - keyLen: String
+ * - keyType: String
+ * - created: String (YYYY-MM-DD)
+ * - status: String: one of ''=valid, r=revoked, e=expired
+ * - uid: Array of Strings with UIDs
+ */
+ async searchKeyserver(searchString, keyserver = null, listener) {
+ let acc = getAccessType(keyserver);
+ return acc.search(searchString, keyserver, listener);
+ },
+
+ async searchAndDownloadSingleResultNoImport(
+ searchString,
+ keyserver = null,
+ listener
+ ) {
+ let acc = getAccessType(keyserver);
+ let searchResult = await acc.searchKeyserver(
+ searchString,
+ keyserver,
+ listener
+ );
+ if (searchResult.result != 0 || searchResult.pubKeys.length != 1) {
+ return null;
+ }
+ return this.downloadNoImport(
+ searchResult.pubKeys[0].keyId,
+ keyserver,
+ listener
+ );
+ },
+
+ /**
+ * Refresh all keys
+ *
+ * @param keyserver: String - keyserver URL (optionally incl. protocol)
+ * @param listener: optional Object implementing the KeySrvListener API (above)
+ *
+ * @return: Promise<resultStatus> (identical to download)
+ */
+ refresh(keyserver = null, listener) {
+ let acc = getAccessType(keyserver);
+ return acc.refresh(keyserver, listener);
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/keyserverUris.jsm b/comm/mail/extensions/openpgp/content/modules/keyserverUris.jsm
new file mode 100644
index 0000000000..e3746f730d
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/keyserverUris.jsm
@@ -0,0 +1,43 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailKeyserverURIs"];
+
+function getKeyServers() {
+ let keyservers = Services.prefs
+ .getCharPref("mail.openpgp.keyserver_list")
+ .split(/\s*[,;]\s*/g);
+ return keyservers.filter(
+ ks =>
+ ks.startsWith("vks://") ||
+ ks.startsWith("hkp://") ||
+ ks.startsWith("hkps://")
+ );
+}
+
+function getUploadKeyServer() {
+ let keyservers = Services.prefs
+ .getCharPref("mail.openpgp.keyserver_list")
+ .split(/\s*[,;]\s*/g);
+ for (let ks of keyservers) {
+ if (
+ !ks.startsWith("vks://") &&
+ !ks.startsWith("hkp://") &&
+ !ks.startsWith("hkps://")
+ ) {
+ continue;
+ }
+ return ks;
+ }
+ return null;
+}
+
+var EnigmailKeyserverURIs = {
+ getKeyServers,
+ getUploadKeyServer,
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/log.jsm b/comm/mail/extensions/openpgp/content/modules/log.jsm
new file mode 100644
index 0000000000..5c2829017c
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/log.jsm
@@ -0,0 +1,151 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailLog"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+var EnigmailLog = {
+ level: 3,
+ directory: null,
+ fileStream: null,
+
+ setLogLevel(newLogLevel) {
+ EnigmailLog.level = newLogLevel;
+ },
+
+ getLogLevel() {
+ return EnigmailLog.level;
+ },
+
+ setLogDirectory(newLogDirectory) {
+ EnigmailLog.directory =
+ newLogDirectory + (AppConstants.platform == "win" ? "\\" : "/");
+ EnigmailLog.createLogFiles();
+ },
+
+ createLogFiles() {
+ if (
+ EnigmailLog.directory &&
+ !EnigmailLog.fileStream &&
+ EnigmailLog.level >= 5
+ ) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(EnigmailLog.directory + "enigdbug.txt");
+ let ofStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ ofStream.init(file, -1, -1, 0);
+
+ EnigmailLog.fileStream = ofStream;
+ }
+ },
+
+ onShutdown() {
+ if (EnigmailLog.fileStream) {
+ EnigmailLog.fileStream.close();
+ }
+ EnigmailLog.fileStream = null;
+ },
+
+ WRITE(str) {
+ function withZeroes(val, digits) {
+ return ("0000" + val.toString()).substr(-digits);
+ }
+
+ var d = new Date();
+ var datStr =
+ d.getFullYear() +
+ "-" +
+ withZeroes(d.getMonth() + 1, 2) +
+ "-" +
+ withZeroes(d.getDate(), 2) +
+ " " +
+ withZeroes(d.getHours(), 2) +
+ ":" +
+ withZeroes(d.getMinutes(), 2) +
+ ":" +
+ withZeroes(d.getSeconds(), 2) +
+ "." +
+ withZeroes(d.getMilliseconds(), 3) +
+ " ";
+ if (EnigmailLog.level >= 4) {
+ dump(datStr + str);
+ }
+
+ if (EnigmailLog.fileStream) {
+ EnigmailLog.fileStream.write(datStr, datStr.length);
+ EnigmailLog.fileStream.write(str, str.length);
+ }
+ },
+
+ DEBUG(str) {
+ try {
+ EnigmailLog.WRITE("[DEBUG] " + str);
+ } catch (ex) {}
+ },
+
+ WARNING(str) {
+ EnigmailLog.WRITE("[WARN] " + str);
+ },
+
+ ERROR(str) {
+ try {
+ var consoleSvc = Services.console;
+ var scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ scriptError.init(
+ str,
+ null,
+ null,
+ 0,
+ 0,
+ scriptError.errorFlag,
+ "Enigmail"
+ );
+ consoleSvc.logMessage(scriptError);
+ } catch (ex) {}
+
+ EnigmailLog.WRITE("[ERROR] " + str);
+ },
+
+ CONSOLE(str) {
+ if (EnigmailLog.level >= 3) {
+ EnigmailLog.WRITE("[CONSOLE] " + str);
+ }
+ },
+
+ /**
+ * Log an exception including the stack trace
+ *
+ * referenceInfo: String - arbitrary text to write before the exception is logged
+ * ex: exception object
+ */
+ writeException(referenceInfo, ex) {
+ EnigmailLog.ERROR(
+ referenceInfo +
+ ": caught exception: " +
+ ex.name +
+ "\n" +
+ "Message: '" +
+ ex.message +
+ "'\n" +
+ "File: " +
+ ex.fileName +
+ "\n" +
+ "Line: " +
+ ex.lineNumber +
+ "\n" +
+ "Stack: " +
+ ex.stack +
+ "\n"
+ );
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/masterpass.jsm b/comm/mail/extensions/openpgp/content/modules/masterpass.jsm
new file mode 100644
index 0000000000..49e535ebf7
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/masterpass.jsm
@@ -0,0 +1,332 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["OpenPGPMasterpass"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ RNP: "chrome://openpgp/content/modules/RNP.jsm",
+});
+
+var OpenPGPMasterpass = {
+ _initDone: false,
+ _sdr: null,
+
+ getSDR() {
+ if (!this._sdr) {
+ try {
+ this._sdr = Cc["@mozilla.org/security/sdr;1"].getService(
+ Ci.nsISecretDecoderRing
+ );
+ } catch (ex) {
+ lazy.EnigmailLog.writeException("masterpass.jsm", ex);
+ }
+ }
+ return this._sdr;
+ },
+
+ filename: "encrypted-openpgp-passphrase.txt",
+ secringFilename: "secring.gpg",
+
+ getPassPath() {
+ let path = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ path.append(this.filename);
+ return path;
+ },
+
+ getSecretKeyRingFile() {
+ let path = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ path.append(this.secringFilename);
+ return path;
+ },
+
+ getOpenPGPSecretRingAlreadyExists() {
+ return this.getSecretKeyRingFile().exists();
+ },
+
+ async _repairOrWarn() {
+ let [prot, unprot] = lazy.RNP.getProtectedKeysCount();
+ let haveAtLeastOneSecretKey = prot || unprot;
+
+ if (
+ !(await IOUtils.exists(this.getPassPath().path)) &&
+ haveAtLeastOneSecretKey
+ ) {
+ // We couldn't read the OpenPGP password from file.
+ // This could either mean the file doesn't exist, which indicates
+ // either a corruption, or the condition after a failed migration
+ // from early Enigmail migrator versions (bug 1656287).
+ // Or it could mean the user has a primary password set,
+ // but the user failed to enter it correctly,
+ // or we are facing the consequences of multiple password prompts.
+
+ let secFileName = this.getSecretKeyRingFile().path;
+ let title = "OpenPGP corruption detected";
+
+ if (prot) {
+ let info;
+ if (!unprot) {
+ info =
+ "Your Thunderbird Profile contains inconsistent or corrupted OpenPGP data. You have secret keys that were previously protected with an automatic passphrase, " +
+ "but file encrypted-openpgp-passphrase.txt is missing. File " +
+ secFileName +
+ " that contains your secret keys cannot be accessed. " +
+ "You must manually repair this corruption by moving the file to a different folder. Then restart, then import your secret keys from a backup. " +
+ "The OpenPGP functionality will be disabled until repaired. ";
+ } else {
+ info =
+ "Your Thunderbird Profile contains inconsistent or corrupted OpenPGP data. You have secret keys that were previously protected with an automatic passphrase, " +
+ "but file encrypted-openpgp-passphrase.txt is missing. File " +
+ secFileName +
+ " contains secret keys cannot be accessed. However, it also contains unprotected keys, which you may continue to access. " +
+ "You must manually repair this corruption by moving the file to a different folder. Then restart, then import your secret keys from a backup. You may also try to import the corrupted file, to import the unprotected keys. " +
+ "The OpenPGP functionality will be disabled until repaired. ";
+ }
+ Services.prompt.alert(null, title, info);
+ throw new Error(
+ "Error, secring.gpg exists, but cannot obtain password from encrypted-openpgp-passphrase.txt"
+ );
+ } else {
+ // only unprotected keys
+ // maybe https://bugzilla.mozilla.org/show_bug.cgi?id=1656287
+ let info =
+ "Your Thunderbird Profile contains inconsistent or corrupted OpenPGP data. You have secret keys, " +
+ "but file encrypted-openpgp-passphrase.txt is missing. " +
+ "If you have recently used Enigmail version 2.2 to migrate your old keys, an incomplete migration is probably the cause of the corruption. " +
+ "An automatic repair can be attempted. " +
+ "The OpenPGP functionality will be disabled until repaired. " +
+ "Before repairing, you should make a backup of file " +
+ secFileName +
+ " that contains your secret keys. " +
+ "After repairing, you may run the Enigmail migration again, or use OpenPGP Key Manager to accept your keys as personal keys.";
+
+ let button = "I confirm I created a backup. Perform automatic repair.";
+
+ let promptFlags =
+ Services.prompt.BUTTON_POS_0 *
+ Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL +
+ Services.prompt.BUTTON_POS_1_DEFAULT;
+
+ let confirm = Services.prompt.confirmEx(
+ null, // window
+ title,
+ info,
+ promptFlags,
+ button,
+ null,
+ null,
+ null,
+ {}
+ );
+
+ if (confirm != 0) {
+ throw new Error(
+ "Error, secring.gpg exists, but cannot obtain password from encrypted-openpgp-passphrase.txt"
+ );
+ }
+
+ await this._ensurePasswordCreatedAndCached();
+ await lazy.RNP.protectUnprotectedKeys();
+ await lazy.RNP.saveKeyRings();
+ }
+ }
+ },
+
+ async _ensurePasswordCreatedAndCached() {
+ if (this.cachedPassword) {
+ return;
+ }
+
+ let sdr = this.getSDR();
+ if (!sdr) {
+ throw new Error("Failed to obtain the SDR service.");
+ }
+
+ if (await IOUtils.exists(this.getPassPath().path)) {
+ let encryptedPass = await IOUtils.readUTF8(this.getPassPath().path);
+ encryptedPass = encryptedPass.trim();
+ if (!encryptedPass) {
+ throw new Error(
+ "Failed to obtain encrypted password data from file " +
+ this.getPassPath().path
+ );
+ }
+
+ try {
+ this.cachedPassword = sdr.decryptString(encryptedPass);
+ // This is the success scenario, in which we return early.
+ return;
+ } catch (e) {
+ // This code handles the corruption described in bug 1790610.
+
+ // Failure to decrypt should be the only scenario that
+ // reaches this code path.
+
+ // Is a primary password set?
+ let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService(
+ Ci.nsIPK11TokenDB
+ );
+ let token = tokenDB.getInternalKeyToken();
+ if (token.hasPassword && !token.isLoggedIn()) {
+ // Yes, primary password is set, but user is not logged in.
+ // Let's throw now, a future action will result in trying again.
+ throw e;
+ }
+
+ // No. We have profile corruption: key4.db doesn't contain the
+ // key to decrypt file encrypted-openpgp-passphrase.txt
+ // Move to backup file and create a fresh file to fix the situation.
+
+ let backup = await IOUtils.createUniqueFile(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this.filename + ".corrupt"
+ );
+
+ try {
+ await IOUtils.move(this.getPassPath().path, backup);
+ console.warn(
+ `${this.filename} corruption fixed. Corrupted file moved to ${backup}`
+ );
+ } catch (e2) {
+ console.warn(
+ `Cannot move corrupted file ${this.filename} to backup name ${backup}`
+ );
+ // We cannot repair, so restarting doesn't help, keep running,
+ // and hope the user notices this error in console.
+ throw e2;
+ }
+
+ let secRingFile = this.getSecretKeyRingFile();
+ if (secRingFile.exists() && secRingFile.fileSize > 0) {
+ // We have secret keys that can no longer be accessed.
+
+ try {
+ let backupOld = await IOUtils.createUniqueFile(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this.secringFilename + ".old.corrupt"
+ );
+ await IOUtils.move(secRingFile.path + ".old", backupOld);
+ } catch (eOld) {}
+
+ let backup2 = await IOUtils.createUniqueFile(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this.secringFilename + ".corrupt"
+ );
+
+ try {
+ await IOUtils.move(secRingFile.path, backup2);
+ console.warn(
+ `secring.gpg corruption fixed. Corrupted file moved to ${backup}`
+ );
+ await IOUtils.write(secRingFile.path, new Uint8Array());
+ } catch (e3) {
+ console.warn(
+ `Cannot move corrupted file ${this.filename} to backup name ${backup}`
+ );
+ // We cannot repair, so restarting doesn't help, keep running,
+ // and hope the user notices this error in console.
+ throw e3;
+ }
+
+ // RNP might have already read the old file, we cannot easily
+ // trigger rereading of the file, so let's restart.
+ lazy.MailUtils.restartApplication();
+ return;
+ }
+
+ // If we arrive here, we have successfully repaired, and
+ // can proceed with the code below to create a fresh file.
+ }
+ }
+
+ if (await IOUtils.exists(this.getPassPath().path)) {
+ // This check is an additional precaution, to prevent against
+ // logic errors, or unexpected filesystem behavior.
+ // If this file already exists, we MUST NOT create it again.
+ // The code below is executed if the file does not exist yet,
+ // or if the file was deleted or moved, after automatic repairing.
+ throw new Error("File " + this.getPassPath().path + " already exists");
+ }
+
+ // Make sure we don't use the new password unless we're successful
+ // in encrypting and storing it to disk.
+ // (This may fail if the user has a primary password set,
+ // but refuses to enter it.)
+ let newPass = this.generatePassword();
+ let encryptedPass = sdr.encryptString(newPass);
+ if (!encryptedPass) {
+ throw new Error("cannot create OpenPGP password");
+ }
+ await IOUtils.writeUTF8(this.getPassPath().path, encryptedPass);
+
+ this.cachedPassword = newPass;
+ },
+
+ generatePassword() {
+ // TODO: Patrick suggested to replace with
+ // EnigmailRNG.getRandomString(numChars)
+ const random_bytes = new Uint8Array(32);
+ crypto.getRandomValues(random_bytes);
+ let result = "";
+ for (let i = 0; i < 32; i++) {
+ result += (random_bytes[i] % 16).toString(16);
+ }
+ return result;
+ },
+
+ cachedPassword: null,
+
+ // This function requires the password to already exist and be cached.
+ retrieveCachedPassword() {
+ if (!this.cachedPassword) {
+ // Obviously some functionality requires the password, but we
+ // don't have it yet.
+ // The best we can do is spawn reading and caching asynchronously,
+ // this will cause the password to be available once the user
+ // retries the current operation.
+ this.ensurePasswordIsCached();
+ throw new Error("no cached password");
+ }
+ return this.cachedPassword;
+ },
+
+ async ensurePasswordIsCached() {
+ if (this.cachedPassword) {
+ return;
+ }
+
+ if (!this._initDone) {
+ // set flag immediately, to avoid any potential recursion
+ // causing us to repair twice in parallel.
+ this._initDone = true;
+ await this._repairOrWarn();
+ }
+
+ if (this.cachedPassword) {
+ return;
+ }
+
+ await this._ensurePasswordCreatedAndCached();
+ },
+
+ // This function may trigger password creation, if necessary
+ async retrieveOpenPGPPassword() {
+ lazy.EnigmailLog.DEBUG("masterpass.jsm: retrieveMasterPassword()\n");
+
+ await this.ensurePasswordIsCached();
+ return this.cachedPassword;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/mime.jsm b/comm/mail/extensions/openpgp/content/modules/mime.jsm
new file mode 100644
index 0000000000..9b514b9387
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/mime.jsm
@@ -0,0 +1,571 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailMime"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailStreams: "chrome://openpgp/content/modules/streams.jsm",
+ jsmime: "resource:///modules/jsmime.jsm",
+ MsgUtils: "resource:///modules/MimeMessageUtils.jsm",
+});
+
+var EnigmailMime = {
+ /***
+ * create a string of random characters suitable to use for a boundary in a
+ * MIME message following RFC 2045
+ *
+ * @return: string to use as MIME boundary
+ * @see {MimeMultiPart._makePartSeparator}
+ */
+ createBoundary() {
+ return "------------" + lazy.MsgUtils.randomString(24);
+ },
+
+ /***
+ * determine the "boundary" part of a mail content type.
+ *
+ * @contentTypeStr: the string containing all parts of a content-type.
+ * (e.g. multipart/mixed; boundary="xyz") --> returns "xyz"
+ *
+ * @return: String containing the boundary parameter; or ""
+ */
+
+ getBoundary(contentTypeStr) {
+ return EnigmailMime.getParameter(contentTypeStr, "boundary");
+ },
+
+ /***
+ * determine the "protocol" part of a mail content type.
+ *
+ * @contentTypeStr: the string containing all parts of a content-type.
+ * (e.g. multipart/signed; protocol="xyz") --> returns "xyz"
+ *
+ * @return: String containing the protocol parameter; or ""
+ */
+
+ getProtocol(contentTypeStr) {
+ return EnigmailMime.getParameter(contentTypeStr, "protocol");
+ },
+
+ /***
+ * determine an arbitrary "parameter" part of a mail header.
+ *
+ * @param headerStr: the string containing all parts of the header.
+ * @param parameter: the parameter we are looking for
+ *
+ *
+ * 'multipart/signed; protocol="xyz"', 'protocol' --> returns "xyz"
+ *
+ * @return: String containing the parameter; or ""
+ */
+
+ getParameter(headerStr, parameter) {
+ let paramsArr = EnigmailMime.getAllParameters(headerStr);
+ parameter = parameter.toLowerCase();
+ if (parameter in paramsArr) {
+ return paramsArr[parameter];
+ }
+ return "";
+ },
+
+ /***
+ * get all parameter attributes of a mail header.
+ *
+ * @param headerStr: the string containing all parts of the header.
+ *
+ * @return: Array of Object containing the key value pairs
+ *
+ * 'multipart/signed; protocol="xyz"'; boundary="xxx"
+ * --> returns [ ["protocol": "xyz"], ["boundary": "xxx"] ]
+ */
+
+ getAllParameters(headerStr) {
+ headerStr = headerStr.replace(/[\r\n]+[ \t]+/g, "");
+ let hdrMap = lazy.jsmime.headerparser.parseParameterHeader(
+ ";" + headerStr,
+ true,
+ true
+ );
+
+ let paramArr = [];
+ let i = hdrMap.entries();
+ let p = i.next();
+ while (p.value) {
+ paramArr[p.value[0].toLowerCase()] = p.value[1];
+ p = i.next();
+ }
+
+ return paramArr;
+ },
+
+ /***
+ * determine the "charset" part of a mail content type.
+ *
+ * @contentTypeStr: the string containing all parts of a content-type.
+ * (e.g. multipart/mixed; charset="utf-8") --> returns "utf-8"
+ *
+ * @return: String containing the charset parameter; or null
+ */
+
+ getCharset(contentTypeStr) {
+ return EnigmailMime.getParameter(contentTypeStr, "charset");
+ },
+
+ /**
+ * Convert a MIME header value into a UTF-8 encoded representation following RFC 2047
+ */
+ encodeHeaderValue(aStr) {
+ let ret = "";
+
+ let exp = /[^\x01-\x7F]/; // eslint-disable-line no-control-regex
+ if (aStr.search(exp) >= 0) {
+ let s = lazy.EnigmailData.convertFromUnicode(aStr, "utf-8");
+ ret = "=?UTF-8?B?" + btoa(s) + "?=";
+ } else {
+ ret = aStr;
+ }
+
+ return ret;
+ },
+
+ /**
+ * format MIME header with maximum length of 72 characters.
+ */
+ formatHeaderData(hdrValue) {
+ let header;
+ if (Array.isArray(hdrValue)) {
+ header = hdrValue.join("").split(" ");
+ } else {
+ header = hdrValue.split(/ +/);
+ }
+
+ let line = "";
+ let lines = [];
+
+ for (let i = 0; i < header.length; i++) {
+ if (line.length + header[i].length >= 72) {
+ lines.push(line + "\r\n");
+ line = " " + header[i];
+ } else {
+ line += " " + header[i];
+ }
+ }
+
+ lines.push(line);
+
+ return lines.join("").trim();
+ },
+
+ /**
+ * Correctly encode and format a set of email addresses for RFC 2047
+ */
+ formatEmailAddress(addressData) {
+ const adrArr = addressData.split(/, */);
+
+ for (let i in adrArr) {
+ try {
+ const m = adrArr[i].match(
+ /(.*[\w\s]+?)<([\w-][\w.-]+@[\w-][\w.-]+[a-zA-Z]{1,4})>/
+ );
+ if (m && m.length == 3) {
+ adrArr[i] = this.encodeHeaderValue(m[1]) + " <" + m[2] + ">";
+ }
+ } catch (ex) {}
+ }
+
+ return adrArr.join(", ");
+ },
+
+ /**
+ * Extract the subject from the 1st line of the message body, if the message body starts
+ * with: "Subject: ...\r?\n\r?\n".
+ *
+ * @param msgBody - String: message body
+ *
+ * @returns
+ * if subject is found:
+ * Object:
+ * - messageBody - String: message body without subject
+ * - subject - String: extracted subject
+ *
+ * if subject not found: null
+ */
+ extractSubjectFromBody(msgBody) {
+ let m = msgBody.match(/^(\r?\n?Subject: [^\r\n]+\r?\n\r?\n)/i);
+ if (m && m.length > 0) {
+ let subject = m[0].replace(/[\r\n]/g, "");
+ subject = subject.substr(9);
+ msgBody = msgBody.substr(m[0].length);
+
+ return {
+ messageBody: msgBody,
+ subject,
+ };
+ }
+
+ return null;
+ },
+
+ /***
+ * determine if the message data contains a first mime part with content-type = "text/rfc822-headers"
+ * if so, extract the corresponding field(s)
+ */
+
+ extractProtectedHeaders(contentData) {
+ // find first MIME delimiter. Anything before that delimiter is the top MIME structure
+ let m = contentData.search(/^--/m);
+
+ let protectedHdr = [
+ "subject",
+ "date",
+ "from",
+ "to",
+ "cc",
+ "reply-to",
+ "references",
+ "newsgroups",
+ "followup-to",
+ "message-id",
+ ];
+ let newHeaders = {};
+
+ // read headers of first MIME part and extract the boundary parameter
+ let outerHdr = Cc["@mozilla.org/messenger/mimeheaders;1"].createInstance(
+ Ci.nsIMimeHeaders
+ );
+ outerHdr.initialize(contentData.substr(0, m));
+
+ let ct = outerHdr.extractHeader("content-type", false) || "";
+ if (ct === "") {
+ return null;
+ }
+
+ let startPos = -1,
+ endPos = -1,
+ bound = "";
+
+ if (ct.search(/^multipart\//i) === 0) {
+ // multipart/xyz message type
+ if (m < 5) {
+ return null;
+ }
+
+ bound = EnigmailMime.getBoundary(ct);
+ if (bound === "") {
+ return null;
+ }
+
+ // Escape regex chars in the boundary.
+ bound = bound.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
+ // search for "outer" MIME delimiter(s)
+ let r = new RegExp("^--" + bound, "mg");
+
+ startPos = -1;
+ endPos = -1;
+
+ // 1st match: start of 1st MIME-subpart
+ let match = r.exec(contentData);
+ if (match && match.index) {
+ startPos = match.index;
+ }
+
+ // 2nd match: end of 1st MIME-subpart
+ match = r.exec(contentData);
+ if (match && match.index) {
+ endPos = match.index;
+ }
+
+ if (startPos < 0 || endPos < 0) {
+ return null;
+ }
+ } else {
+ startPos = contentData.length;
+ endPos = 0;
+ }
+
+ let headers = Cc["@mozilla.org/messenger/mimeheaders;1"].createInstance(
+ Ci.nsIMimeHeaders
+ );
+ headers.initialize(contentData.substring(0, startPos));
+
+ // we got a potentially protected header. Let's check ...
+ ct = headers.extractHeader("content-type", false) || "";
+ if (this.getParameter(ct, "protected-headers").search(/^v1$/i) !== 0) {
+ return null;
+ }
+
+ for (let i in protectedHdr) {
+ if (headers.hasHeader(protectedHdr[i])) {
+ let extracted = headers.extractHeader(protectedHdr[i], true);
+ newHeaders[protectedHdr[i]] =
+ lazy.jsmime.headerparser.decodeRFC2047Words(extracted) || undefined;
+ }
+ }
+
+ // contentBody holds the complete 1st MIME part
+ let contentBody = contentData.substring(
+ startPos + bound.length + 3,
+ endPos
+ );
+ let i = contentBody.search(/^[A-Za-z]/m); // skip empty lines
+ if (i > 0) {
+ contentBody = contentBody.substr(i);
+ }
+
+ headers.initialize(contentBody);
+
+ let innerCt = headers.extractHeader("content-type", false) || "";
+
+ if (innerCt.search(/^text\/rfc822-headers/i) === 0) {
+ let charset = EnigmailMime.getCharset(innerCt);
+ let ctt = headers.extractHeader("content-transfer-encoding", false) || "";
+
+ // determine where the headers end and the MIME-subpart body starts
+ let bodyStartPos = contentBody.search(/\r?\n\s*\r?\n/) + 1;
+
+ if (bodyStartPos < 10) {
+ return null;
+ }
+
+ bodyStartPos += contentBody.substr(bodyStartPos).search(/^[A-Za-z]/m);
+
+ let ctBodyData = contentBody.substr(bodyStartPos);
+
+ if (ctt.search(/^base64/i) === 0) {
+ ctBodyData = lazy.EnigmailData.decodeBase64(ctBodyData) + "\n";
+ } else if (ctt.search(/^quoted-printable/i) === 0) {
+ ctBodyData = lazy.EnigmailData.decodeQuotedPrintable(ctBodyData) + "\n";
+ }
+
+ if (charset) {
+ ctBodyData = lazy.EnigmailData.convertToUnicode(ctBodyData, charset);
+ }
+
+ // get the headers of the MIME-subpart body --> that's the ones we need
+ let bodyHdr = Cc["@mozilla.org/messenger/mimeheaders;1"].createInstance(
+ Ci.nsIMimeHeaders
+ );
+ bodyHdr.initialize(ctBodyData);
+
+ for (let i in protectedHdr) {
+ let extracted = bodyHdr.extractHeader(protectedHdr[i], true);
+ if (bodyHdr.hasHeader(protectedHdr[i])) {
+ newHeaders[protectedHdr[i]] =
+ lazy.jsmime.headerparser.decodeRFC2047Words(extracted) || undefined;
+ }
+ }
+ } else {
+ startPos = -1;
+ endPos = -1;
+ }
+
+ return {
+ newHeaders,
+ startPos,
+ endPos,
+ securityLevel: 0,
+ };
+ },
+
+ /**
+ * Get the part number from a URI spec (e.g. mailbox:///folder/xyz?part=1.2.3.5)
+ *
+ * @param spec: String - the URI spec to inspect
+ *
+ * @returns String: the mime part number (or "" if none found)
+ */
+ getMimePartNumber(spec) {
+ let m = spec.match(/([\?&]part=)(\d+(\.\d+)*)/);
+
+ if (m && m.length >= 3) {
+ return m[2];
+ }
+
+ return "";
+ },
+
+ /**
+ * Try to determine if the message structure is a known MIME structure,
+ * based on the MIME part number and the uriSpec.
+ *
+ * @param mimePartNumber: String - the MIME part we are requested to decrypt
+ * @param uriSpec: String - the URI spec of the message (or msg part) loaded by TB
+ *
+ * @returns Boolean: true: regular message structure, MIME part is safe to be decrypted
+ * false: otherwise
+ */
+ isRegularMimeStructure(mimePartNumber, uriSpec, acceptSubParts = false) {
+ if (mimePartNumber.length === 0) {
+ return true;
+ }
+
+ if (acceptSubParts && mimePartNumber.search(/^1(\.1)*$/) === 0) {
+ return true;
+ }
+ if (mimePartNumber === "1") {
+ return true;
+ }
+
+ if (!uriSpec) {
+ return true;
+ }
+
+ // is the message a subpart of a complete attachment?
+ let msgPart = this.getMimePartNumber(uriSpec);
+ if (msgPart.length > 0) {
+ // load attached messages
+ if (
+ mimePartNumber.indexOf(msgPart) === 0 &&
+ mimePartNumber.substr(msgPart.length).search(/^(\.1)+$/) === 0
+ ) {
+ return true;
+ }
+
+ // load attachments of attached messages
+ if (
+ msgPart.indexOf(mimePartNumber) === 0 &&
+ uriSpec.search(/[\?&]filename=/) > 0
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Parse a MIME message and return a tree structure of TreeObject
+ *
+ * @param url: String - the URL to load and parse
+ * @param getBody: Boolean - if true, delivers the body text of each MIME part
+ * @param callbackFunc Function - the callback function that is called asynchronously
+ * when parsing is complete.
+ * Function signature: callBackFunc(TreeObject)
+ *
+ * @returns undefined
+ */
+ getMimeTreeFromUrl(url, getBody = false, callbackFunc) {
+ function onData(data) {
+ let tree = getMimeTree(data, getBody);
+ callbackFunc(tree);
+ }
+
+ let chan = lazy.EnigmailStreams.createChannel(url);
+ let bufferListener = lazy.EnigmailStreams.newStringStreamListener(onData);
+ chan.asyncOpen(bufferListener, null);
+ },
+
+ getMimeTree,
+};
+
+/**
+ * Parse a MIME message and return a tree structure of TreeObject.
+ *
+ * TreeObject contains the following main parts:
+ * - partNum: String
+ * - headers: Map, containing all headers.
+ * Special headers for contentType and charset
+ * - body: String, if getBody == true
+ * - subParts: Array of TreeObject
+ *
+ * @param mimeStr: String - a MIME structure to parse
+ * @param getBody: Boolean - if true, delivers the body text of each MIME part
+ *
+ * @returns TreeObject, or NULL in case of failure
+ */
+function getMimeTree(mimeStr, getBody = false) {
+ let mimeTree = {
+ partNum: "",
+ headers: null,
+ body: "",
+ parent: null,
+ subParts: [],
+ },
+ currentPart = "",
+ currPartNum = "";
+
+ const jsmimeEmitter = {
+ createPartObj(partNum, headers, parent) {
+ let ct;
+
+ if (headers.has("content-type")) {
+ ct = headers.contentType.type;
+ let it = headers.get("content-type").entries();
+ for (let i of it) {
+ ct += "; " + i[0] + '="' + i[1] + '"';
+ }
+ }
+
+ return {
+ partNum,
+ headers,
+ fullContentType: ct,
+ body: "",
+ parent,
+ subParts: [],
+ };
+ },
+
+ /** JSMime API */
+ startMessage() {
+ currentPart = mimeTree;
+ },
+
+ endMessage() {},
+
+ startPart(partNum, headers) {
+ //dump("mime.jsm: jsmimeEmitter.startPart: partNum=" + partNum + "\n");
+ partNum = "1" + (partNum !== "" ? "." : "") + partNum;
+ let newPart = this.createPartObj(partNum, headers, currentPart);
+
+ if (partNum.indexOf(currPartNum) === 0) {
+ // found sub-part
+ currentPart.subParts.push(newPart);
+ } else {
+ // found same or higher level
+ currentPart.subParts.push(newPart);
+ }
+ currPartNum = partNum;
+ currentPart = newPart;
+ },
+ endPart(partNum) {
+ //dump("mime.jsm: jsmimeEmitter.startPart: partNum=" + partNum + "\n");
+ currentPart = currentPart.parent;
+ },
+
+ deliverPartData(partNum, data) {
+ //dump("mime.jsm: jsmimeEmitter.deliverPartData: partNum=" + partNum + " / " + typeof data + "\n");
+ if (typeof data === "string") {
+ currentPart.body += data;
+ } else {
+ currentPart.body += lazy.EnigmailData.arrayBufferToString(data);
+ }
+ },
+ };
+
+ let opt = {
+ strformat: "unicode",
+ bodyformat: getBody ? "decode" : "none",
+ stripcontinuations: false,
+ };
+
+ try {
+ let p = new lazy.jsmime.MimeParser(jsmimeEmitter, opt);
+ p.deliverData(mimeStr);
+ return mimeTree.subParts[0];
+ } catch (ex) {
+ return null;
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/mimeDecrypt.jsm b/comm/mail/extensions/openpgp/content/modules/mimeDecrypt.jsm
new file mode 100644
index 0000000000..c927df5fba
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/mimeDecrypt.jsm
@@ -0,0 +1,933 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailMimeDecrypt"];
+
+/**
+ * Module for handling PGP/MIME encrypted messages
+ * implemented as an XPCOM object
+ */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { EnigmailSingletons } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/singletons.jsm"
+);
+const { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailDecryption: "chrome://openpgp/content/modules/decryption.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+ EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm",
+ EnigmailVerify: "chrome://openpgp/content/modules/mimeVerify.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+const ENCODING_DEFAULT = 0;
+const ENCODING_BASE64 = 1;
+const ENCODING_QP = 2;
+
+const LAST_MSG = EnigmailSingletons.lastDecryptedMessage;
+
+var gDebugLogLevel = 3;
+
+var gNumProc = 0;
+
+var EnigmailMimeDecrypt = {
+ /**
+ * create a new instance of a PGP/MIME decryption handler
+ */
+ newPgpMimeHandler() {
+ return new MimeDecryptHandler();
+ },
+
+ /**
+ * Wrap the decrypted output into a message/rfc822 attachment
+ *
+ * @param {string} decryptingMimePartNum: requested MIME part number
+ * @param {object} uri: nsIURI object of the decrypted message
+ *
+ * @returns {string}: prefix for message data
+ */
+ pretendAttachment(decryptingMimePartNum, uri) {
+ if (decryptingMimePartNum === "1" || !uri) {
+ return "";
+ }
+
+ let msg = "";
+ let mimePartNumber = lazy.EnigmailMime.getMimePartNumber(uri.spec);
+
+ if (mimePartNumber === decryptingMimePartNum + ".1") {
+ msg =
+ 'Content-Type: message/rfc822; name="attachment.eml"\r\n' +
+ "Content-Transfer-Encoding: 7bit\r\n" +
+ 'Content-Disposition: attachment; filename="attachment.eml"\r\n\r\n';
+
+ try {
+ let dbHdr = uri.QueryInterface(Ci.nsIMsgMessageUrl).messageHeader;
+ if (dbHdr.subject) {
+ msg += `Subject: ${dbHdr.subject}\r\n`;
+ }
+ if (dbHdr.author) {
+ msg += `From: ${dbHdr.author}\r\n`;
+ }
+ if (dbHdr.recipients) {
+ msg += `To: ${dbHdr.recipients}\r\n`;
+ }
+ if (dbHdr.ccList) {
+ msg += `Cc: ${dbHdr.ccList}\r\n`;
+ }
+ } catch (x) {
+ console.debug(x);
+ }
+ }
+
+ return msg;
+ },
+};
+
+////////////////////////////////////////////////////////////////////
+// handler for PGP/MIME encrypted messages
+// data is processed from libmime -> nsPgpMimeProxy
+
+function MimeDecryptHandler() {
+ lazy.EnigmailLog.DEBUG("mimeDecrypt.jsm: MimeDecryptHandler()\n"); // always log this one
+ this.mimeSvc = null;
+ this.initOk = false;
+ this.boundary = "";
+ this.pipe = null;
+ this.closePipe = false;
+ this.statusStr = "";
+ this.outQueue = "";
+ this.dataLength = 0;
+ this.bytesWritten = 0;
+ this.mimePartCount = 0;
+ this.headerMode = 0;
+ this.xferEncoding = ENCODING_DEFAULT;
+ this.matchedPgpDelimiter = 0;
+ this.exitCode = null;
+ this.msgWindow = null;
+ this.msgUriSpec = null;
+ this.returnStatus = null;
+ this.proc = null;
+ this.statusDisplayed = false;
+ this.uri = null;
+ this.backgroundJob = false;
+ this.decryptedHeaders = {};
+ this.mimePartNumber = "";
+ this.allowNestedDecrypt = false;
+ this.dataIsBase64 = null;
+ this.base64Cache = "";
+}
+
+MimeDecryptHandler.prototype = {
+ inStream: Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ ),
+
+ onStartRequest(request, uri) {
+ if (!lazy.EnigmailCore.getService()) {
+ // Ensure Enigmail is initialized
+ return;
+ }
+ lazy.EnigmailLog.DEBUG("mimeDecrypt.jsm: onStartRequest\n"); // always log this one
+
+ ++gNumProc;
+ if (gNumProc > Services.prefs.getIntPref("temp.openpgp.maxNumProcesses")) {
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: number of parallel requests above threshold - ignoring request\n"
+ );
+ return;
+ }
+
+ this.initOk = true;
+ this.mimeSvc = request.QueryInterface(Ci.nsIPgpMimeProxy);
+ if ("mimePart" in this.mimeSvc) {
+ this.mimePartNumber = this.mimeSvc.mimePart;
+ } else {
+ this.mimePartNumber = "";
+ }
+
+ if ("allowNestedDecrypt" in this.mimeSvc) {
+ this.allowNestedDecrypt = this.mimeSvc.allowNestedDecrypt;
+ }
+
+ if (this.allowNestedDecrypt) {
+ // We want to ignore signature status of the top level part "1".
+ // Unfortunately, because of our streaming approach to process
+ // MIME content, the parent MIME part was already processed,
+ // and it could have already called into the header sink to set
+ // the signature status. Or, an async job could be currently
+ // running, and the call into the header sink could happen in
+ // the near future.
+ // That means, we must inform the header sink to forget status
+ // information it might have already received for MIME part "1",
+ // an in addition, remember that future information for "1" should
+ // be ignored.
+
+ EnigmailSingletons.messageReader.ignoreStatusFrom("1");
+ }
+
+ if ("messageURI" in this.mimeSvc) {
+ this.uri = this.mimeSvc.messageURI;
+ if (this.uri) {
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: onStartRequest: uri='" + this.uri.spec + "'\n"
+ );
+ } else {
+ lazy.EnigmailLog.DEBUG("mimeDecrypt.jsm: onStartRequest: uri=null\n");
+ }
+ } else if (uri) {
+ this.uri = uri.QueryInterface(Ci.nsIURI);
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: onStartRequest: uri='" + this.uri.spec + "'\n"
+ );
+ }
+ this.pipe = null;
+ this.closePipe = false;
+ this.exitCode = null;
+ this.msgWindow = lazy.EnigmailVerify.lastWindow;
+ this.msgUriSpec = lazy.EnigmailVerify.lastMsgUri;
+
+ this.statusDisplayed = false;
+ this.returnStatus = null;
+ this.dataLength = 0;
+ this.decryptedData = "";
+ this.mimePartCount = 0;
+ this.bytesWritten = 0;
+ this.matchedPgpDelimiter = 0;
+ this.dataIsBase64 = null;
+ this.base64Cache = "";
+ this.outQueue = "";
+ this.statusStr = "";
+ this.headerMode = 0;
+ this.decryptedHeaders = {};
+ this.xferEncoding = ENCODING_DEFAULT;
+ this.boundary = lazy.EnigmailMime.getBoundary(this.mimeSvc.contentType);
+
+ let now = Date.now();
+ let timeoutReached =
+ EnigmailSingletons.lastMessageDecryptTime &&
+ now - EnigmailSingletons.lastMessageDecryptTime > 10000;
+ if (timeoutReached || !this.isReloadingLastMessage()) {
+ EnigmailSingletons.clearLastDecryptedMessage();
+ EnigmailSingletons.lastMessageDecryptTime = now;
+ }
+ },
+
+ processData(data) {
+ // detect MIME part boundary
+ if (data.includes(this.boundary)) {
+ LOCAL_DEBUG("mimeDecrypt.jsm: processData: found boundary\n");
+ ++this.mimePartCount;
+ this.headerMode = 1;
+ return;
+ }
+
+ // found PGP/MIME "body"
+ if (this.mimePartCount == 2) {
+ if (this.headerMode == 1) {
+ // we are in PGP/MIME main part headers
+ if (data.search(/\r|\n/) === 0) {
+ // end of Mime-part headers reached
+ this.headerMode = 2;
+ } else if (data.search(/^content-transfer-encoding:\s*/i) >= 0) {
+ // extract content-transfer-encoding
+ data = data.replace(/^content-transfer-encoding:\s*/i, "");
+ data = data.replace(/;.*/, "").toLowerCase().trim();
+ if (data.search(/base64/i) >= 0) {
+ this.xferEncoding = ENCODING_BASE64;
+ } else if (data.search(/quoted-printable/i) >= 0) {
+ this.xferEncoding = ENCODING_QP;
+ }
+ }
+ // else: PGP/MIME main part body
+ } else if (this.xferEncoding == ENCODING_QP) {
+ this.cacheData(lazy.EnigmailData.decodeQuotedPrintable(data));
+ } else {
+ this.cacheData(data);
+ }
+ }
+ },
+
+ onDataAvailable(req, stream, offset, count) {
+ // get data from libmime
+ if (!this.initOk) {
+ return;
+ }
+ this.inStream.init(stream);
+
+ if (count > 0) {
+ var data = this.inStream.read(count);
+
+ if (this.mimePartCount == 0 && this.dataIsBase64 === null) {
+ // try to determine if this could be a base64 encoded message part
+ this.dataIsBase64 = this.isBase64Encoding(data);
+ }
+
+ if (!this.dataIsBase64) {
+ if (data.search(/[\r\n][^\r\n]+[\r\n]/) >= 0) {
+ // process multi-line data line by line
+ let lines = data.replace(/\r\n/g, "\n").split(/\n/);
+
+ for (let i = 0; i < lines.length; i++) {
+ this.processData(lines[i] + "\r\n");
+ }
+ } else {
+ this.processData(data);
+ }
+ } else {
+ this.base64Cache += data;
+ }
+ }
+ },
+
+ /**
+ * Try to determine if data is base64 endoded
+ */
+ isBase64Encoding(str) {
+ let ret = false;
+
+ str = str.replace(/[\r\n]/, "");
+ if (str.search(/^[A-Za-z0-9+/=]+$/) === 0) {
+ let excess = str.length % 4;
+ str = str.substring(0, str.length - excess);
+
+ try {
+ atob(str);
+ // if the conversion succeeds, we have a base64 encoded message
+ ret = true;
+ } catch (ex) {
+ // not a base64 encoded
+ console.debug(ex);
+ }
+ }
+
+ return ret;
+ },
+
+ // cache encrypted data
+ cacheData(str) {
+ if (gDebugLogLevel > 4) {
+ LOCAL_DEBUG("mimeDecrypt.jsm: cacheData: " + str.length + "\n");
+ }
+
+ this.outQueue += str;
+ },
+
+ processBase64Message() {
+ LOCAL_DEBUG("mimeDecrypt.jsm: processBase64Message\n");
+
+ try {
+ this.base64Cache = lazy.EnigmailData.decodeBase64(this.base64Cache);
+ } catch (ex) {
+ // if decoding failed, try non-encoded version
+ console.debug(ex);
+ }
+
+ let lines = this.base64Cache.replace(/\r\n/g, "\n").split(/\n/);
+
+ for (let i = 0; i < lines.length; i++) {
+ this.processData(lines[i] + "\r\n");
+ }
+ },
+
+ /**
+ * Determine if we are reloading the same message as the previous one
+ *
+ * @returns Boolean
+ */
+ isReloadingLastMessage() {
+ if (!this.uri) {
+ return false;
+ }
+ if (!LAST_MSG.lastMessageURI) {
+ return false;
+ }
+ if ("lastMessageData" in LAST_MSG && LAST_MSG.lastMessageData === "") {
+ return false;
+ }
+
+ let currMsg = lazy.EnigmailURIs.msgIdentificationFromUrl(this.uri);
+
+ if (
+ LAST_MSG.lastMessageURI.folder === currMsg.folder &&
+ LAST_MSG.lastMessageURI.msgNum === currMsg.msgNum
+ ) {
+ return true;
+ }
+
+ return false;
+ },
+
+ onStopRequest(request, status, dummy) {
+ LOCAL_DEBUG("mimeDecrypt.jsm: onStopRequest\n");
+ --gNumProc;
+ if (!this.initOk) {
+ return;
+ }
+
+ if (this.dataIsBase64) {
+ this.processBase64Message();
+ }
+
+ this.msgWindow = lazy.EnigmailVerify.lastWindow;
+ this.msgUriSpec = lazy.EnigmailVerify.lastMsgUri;
+
+ let href = Services.wm.getMostRecentWindow(null)?.document?.location.href;
+
+ if (
+ href == "about:blank" ||
+ href == "chrome://messenger/content/viewSource.xhtml"
+ ) {
+ return;
+ }
+
+ let url = {};
+ let currMsg = lazy.EnigmailURIs.msgIdentificationFromUrl(this.uri);
+
+ this.backgroundJob = false;
+
+ if (this.uri) {
+ // return if not decrypting currently displayed message (except if
+ // printing, replying, etc)
+
+ this.backgroundJob =
+ this.uri.spec.search(/[&?]header=(print|quotebody)/) >= 0;
+
+ try {
+ if (!Services.prefs.getBoolPref("temp.openpgp.autoDecrypt")) {
+ // "decrypt manually" mode
+ let manUrl = {};
+
+ if (lazy.EnigmailVerify.getManualUri()) {
+ manUrl.value = lazy.EnigmailFuncs.getUrlFromUriSpec(
+ lazy.EnigmailVerify.getManualUri()
+ );
+ }
+
+ // print a message if not message explicitly decrypted
+ let currUrlSpec = this.uri.spec.replace(
+ /(\?.*)(number=[0-9]*)(&.*)?$/,
+ "?$2"
+ );
+ let manUrlSpec = manUrl.value.spec.replace(
+ /(\?.*)(number=[0-9]*)(&.*)?$/,
+ "?$2"
+ );
+
+ if (!this.backgroundJob && currUrlSpec.indexOf(manUrlSpec) !== 0) {
+ this.handleManualDecrypt();
+ return;
+ }
+ }
+
+ if (this.msgUriSpec) {
+ url.value = lazy.EnigmailFuncs.getUrlFromUriSpec(this.msgUriSpec);
+ }
+
+ if (
+ this.uri.spec.search(/[&?]header=[^&]+/) > 0 &&
+ this.uri.spec.search(/[&?]examineEncryptedParts=true/) < 0
+ ) {
+ if (
+ this.uri.spec.search(/[&?]header=(filter|enigmailFilter)(&.*)?$/) >
+ 0
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: onStopRequest: detected incoming message processing\n"
+ );
+ return;
+ }
+ }
+
+ if (
+ this.uri.spec.search(/[&?]header=[^&]+/) < 0 &&
+ this.uri.spec.search(/[&?]part=[.0-9]+/) < 0 &&
+ this.uri.spec.search(/[&?]examineEncryptedParts=true/) < 0
+ ) {
+ if (this.uri && url && url.value) {
+ let fixedQueryRef = this.uri.pathQueryRef.replace(/&number=0$/, "");
+ if (
+ url.value.host !== this.uri.host ||
+ url.value.pathQueryRef !== fixedQueryRef
+ ) {
+ return;
+ }
+ }
+ }
+ } catch (ex) {
+ console.debug(ex);
+ lazy.EnigmailLog.writeException("mimeDecrypt.js", ex);
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: error while processing " + this.msgUriSpec + "\n"
+ );
+ }
+ }
+
+ let spec = this.uri ? this.uri.spec : null;
+ lazy.EnigmailLog.DEBUG(
+ `mimeDecrypt.jsm: checking MIME structure for ${this.mimePartNumber} / ${spec}\n`
+ );
+
+ if (
+ !this.allowNestedDecrypt &&
+ !lazy.EnigmailMime.isRegularMimeStructure(
+ this.mimePartNumber,
+ spec,
+ false
+ )
+ ) {
+ EnigmailSingletons.addUriWithNestedEncryptedPart(this.msgUriSpec);
+ // ignore, do not display
+ return;
+ }
+
+ if (!this.isReloadingLastMessage()) {
+ if (this.xferEncoding == ENCODING_BASE64) {
+ this.outQueue = lazy.EnigmailData.decodeBase64(this.outQueue) + "\n";
+ }
+
+ let win = this.msgWindow;
+
+ if (!lazy.EnigmailDecryption.isReady(win)) {
+ return;
+ }
+
+ // limit output to 100 times message size to avoid DoS attack
+ let maxOutput = this.outQueue.length * 100;
+
+ lazy.EnigmailLog.DEBUG("mimeDecryp.jsm: starting decryption\n");
+ //EnigmailLog.DEBUG(this.outQueue + "\n");
+
+ let options = {
+ fromAddr: lazy.EnigmailDecryption.getFromAddr(win),
+ maxOutputLength: maxOutput,
+ };
+
+ if (!options.fromAddr) {
+ var win2 = Services.wm.getMostRecentWindow(null);
+ options.fromAddr = lazy.EnigmailDecryption.getFromAddr(win2);
+ }
+
+ const cApi = lazy.EnigmailCryptoAPI();
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: got API: " + cApi.api_name + "\n"
+ );
+
+ // The processing of a contained signed message must be able to
+ // check that this parent object is encrypted. We set the msg ID
+ // early, despite the full results not yet being available.
+ LAST_MSG.lastMessageURI = currMsg;
+ LAST_MSG.mimePartNumber = this.mimePartNumber;
+
+ this.returnStatus = cApi.sync(cApi.decryptMime(this.outQueue, options));
+
+ if (!this.returnStatus) {
+ this.returnStatus = {
+ decryptedData: "",
+ exitCode: -1,
+ statusFlags: lazy.EnigmailConstants.DECRYPTION_FAILED,
+ };
+ }
+
+ if (
+ this.returnStatus.statusFlags & lazy.EnigmailConstants.DECRYPTION_OKAY
+ ) {
+ this.returnStatus.statusFlags |=
+ lazy.EnigmailConstants.PGP_MIME_ENCRYPTED;
+ }
+
+ if (this.returnStatus.exitCode) {
+ // Failure
+ if (this.returnStatus.decryptedData.length) {
+ // However, we got decrypted data.
+ // Did we get any verification failure flags?
+ // If yes, then conclude only verification failed.
+ if (
+ this.returnStatus.statusFlags &
+ (lazy.EnigmailConstants.BAD_SIGNATURE |
+ lazy.EnigmailConstants.UNCERTAIN_SIGNATURE |
+ lazy.EnigmailConstants.EXPIRED_SIGNATURE |
+ lazy.EnigmailConstants.EXPIRED_KEY_SIGNATURE)
+ ) {
+ this.returnStatus.statusFlags |=
+ lazy.EnigmailConstants.DECRYPTION_OKAY;
+ } else {
+ this.returnStatus.statusFlags |=
+ lazy.EnigmailConstants.DECRYPTION_FAILED;
+ }
+ } else {
+ // no data
+ this.returnStatus.statusFlags |=
+ lazy.EnigmailConstants.DECRYPTION_FAILED;
+ }
+ }
+
+ this.decryptedData = this.returnStatus.decryptedData;
+ this.handleResult(this.returnStatus.exitCode);
+
+ let decError =
+ this.returnStatus.statusFlags &
+ lazy.EnigmailConstants.DECRYPTION_FAILED;
+
+ // don't return decrypted data if decryption failed (because it's likely an MDC error),
+ // unless we are called for permanent decryption
+ if (decError) {
+ this.decryptedData = "";
+ }
+
+ this.displayStatus();
+
+ // HACK: remove filename from 1st HTML and plaintext parts to make TB display message without attachment
+ this.decryptedData = this.decryptedData.replace(
+ /^Content-Disposition: inline; filename="msg.txt"/m,
+ "Content-Disposition: inline"
+ );
+ this.decryptedData = this.decryptedData.replace(
+ /^Content-Disposition: inline; filename="msg.html"/m,
+ "Content-Disposition: inline"
+ );
+
+ let prefix = EnigmailMimeDecrypt.pretendAttachment(
+ this.mimePartNumber,
+ this.uri
+ );
+ this.returnData(prefix + this.decryptedData);
+
+ // don't remember the last message if it contains an embedded PGP/MIME message
+ // to avoid ending up in a loop
+ if (
+ this.mimePartNumber === "1" &&
+ this.decryptedData.search(
+ /^Content-Type:[\t ]+multipart\/encrypted/im
+ ) < 0 &&
+ !decError
+ ) {
+ LAST_MSG.lastMessageData = this.decryptedData;
+ LAST_MSG.lastStatus = this.returnStatus;
+ LAST_MSG.lastStatus.decryptedHeaders = this.decryptedHeaders;
+ } else {
+ LAST_MSG.lastMessageURI = null;
+ LAST_MSG.lastMessageData = "";
+ }
+
+ this.decryptedData = "";
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: onStopRequest: process terminated\n"
+ ); // always log this one
+ this.proc = null;
+ } else {
+ this.returnStatus = LAST_MSG.lastStatus;
+ this.decryptedHeaders = LAST_MSG.lastStatus.decryptedHeaders;
+ this.mimePartNumber = LAST_MSG.mimePartNumber;
+ this.exitCode = 0;
+ this.displayStatus();
+ this.returnData(LAST_MSG.lastMessageData);
+ }
+ },
+
+ displayStatus() {
+ lazy.EnigmailLog.DEBUG("mimeDecrypt.jsm: displayStatus()\n");
+
+ if (
+ this.exitCode === null ||
+ this.msgWindow === null ||
+ this.statusDisplayed
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: displayStatus: nothing to display\n"
+ );
+ return;
+ }
+
+ let uriSpec = this.uri ? this.uri.spec : null;
+
+ try {
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: displayStatus for uri " + uriSpec + "\n"
+ );
+ let headerSink = EnigmailSingletons.messageReader;
+
+ if (headerSink && this.uri && !this.backgroundJob) {
+ headerSink.processDecryptionResult(
+ this.uri,
+ "modifyMessageHeaders",
+ JSON.stringify(this.decryptedHeaders),
+ this.mimePartNumber
+ );
+
+ headerSink.updateSecurityStatus(
+ this.msgUriSpec,
+ this.exitCode,
+ this.returnStatus.statusFlags,
+ this.returnStatus.extStatusFlags,
+ this.returnStatus.keyId,
+ this.returnStatus.userId,
+ this.returnStatus.sigDetails,
+ this.returnStatus.errorMsg,
+ this.returnStatus.blockSeparation,
+ this.uri,
+ JSON.stringify({
+ encryptedTo: this.returnStatus.encToDetails,
+ }),
+ this.mimePartNumber
+ );
+ } else {
+ this.updateHeadersInMsgDb();
+ }
+ this.statusDisplayed = true;
+ } catch (ex) {
+ console.debug(ex);
+ lazy.EnigmailLog.writeException("mimeDecrypt.jsm", ex);
+ }
+ LOCAL_DEBUG("mimeDecrypt.jsm: displayStatus done\n");
+ },
+
+ handleResult(exitCode) {
+ LOCAL_DEBUG("mimeDecrypt.jsm: done: " + exitCode + "\n");
+
+ if (gDebugLogLevel > 4) {
+ LOCAL_DEBUG(
+ "mimeDecrypt.jsm: done: decrypted data='" + this.decryptedData + "'\n"
+ );
+ }
+
+ // ensure newline at the end of the stream
+ if (!this.decryptedData.endsWith("\n")) {
+ this.decryptedData += "\r\n";
+ }
+
+ try {
+ this.extractEncryptedHeaders();
+ this.extractAutocryptGossip();
+ } catch (ex) {
+ console.debug(ex);
+ }
+
+ let mightNeedWrapper = true;
+
+ // It's unclear which scenario this check is supposed to fix.
+ // Based on a comment in mimeVerify (which seems code seems to be
+ // derived from), it might be intended to fix forwarding of empty
+ // messages. Not sure this is still necessary. We should check if
+ // this code can be removed.
+ let i = this.decryptedData.search(/\n\r?\n/);
+ // It's unknown why this test checks for > instead of >=
+ if (i > 0) {
+ var hdr = this.decryptedData.substr(0, i).split(/\r?\n/);
+ for (let j = 0; j < hdr.length; j++) {
+ if (hdr[j].search(/^\s*content-type:\s+text\/(plain|html)/i) >= 0) {
+ LOCAL_DEBUG(
+ "mimeDecrypt.jsm: done: adding multipart/mixed around " +
+ hdr[j] +
+ "\n"
+ );
+
+ this.addWrapperToDecryptedResult();
+ mightNeedWrapper = false;
+ break;
+ }
+ }
+ }
+
+ if (mightNeedWrapper) {
+ let headerBoundaryPosition = this.decryptedData.search(/\n\r?\n/);
+ if (
+ headerBoundaryPosition >= 0 &&
+ !/^Content-Type:/im.test(
+ this.decryptedData.substr(0, headerBoundaryPosition)
+ )
+ ) {
+ this.decryptedData =
+ "Content-Type: text/plain; charset=utf-8\r\n\r\n" +
+ this.decryptedData;
+ }
+ }
+
+ this.exitCode = exitCode;
+ },
+
+ addWrapperToDecryptedResult() {
+ let wrapper = lazy.EnigmailMime.createBoundary();
+
+ this.decryptedData =
+ 'Content-Type: multipart/mixed; boundary="' +
+ wrapper +
+ '"\r\n' +
+ "Content-Disposition: inline\r\n\r\n" +
+ "--" +
+ wrapper +
+ "\r\n" +
+ this.decryptedData +
+ "\r\n" +
+ "--" +
+ wrapper +
+ "--\r\n";
+ },
+
+ extractContentType(data) {
+ let i = data.search(/\n\r?\n/);
+ if (i <= 0) {
+ return null;
+ }
+
+ let headers = Cc["@mozilla.org/messenger/mimeheaders;1"].createInstance(
+ Ci.nsIMimeHeaders
+ );
+ headers.initialize(data.substr(0, i));
+ return headers.extractHeader("content-type", false);
+ },
+
+ // return data to libMime
+ returnData(data) {
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: returnData: " + data.length + " bytes\n"
+ );
+
+ let proto = null;
+ let ct = this.extractContentType(data);
+ if (ct && ct.search(/multipart\/signed/i) >= 0) {
+ proto = lazy.EnigmailMime.getProtocol(ct);
+ }
+
+ if (
+ proto &&
+ proto.search(/application\/(pgp|pkcs7|x-pkcs7)-signature/i) >= 0
+ ) {
+ try {
+ lazy.EnigmailLog.DEBUG(
+ "mimeDecrypt.jsm: returnData: using direct verification\n"
+ );
+ this.mimeSvc.contentType = ct;
+ if ("mimePart" in this.mimeSvc) {
+ this.mimeSvc.mimePart = this.mimeSvc.mimePart + ".1";
+ }
+ let veri = lazy.EnigmailVerify.newVerifier(proto);
+ veri.onStartRequest(this.mimeSvc, this.uri);
+ veri.onTextData(data);
+ veri.onStopRequest(null, 0);
+ } catch (ex) {
+ console.debug(ex);
+ lazy.EnigmailLog.ERROR(
+ "mimeDecrypt.jsm: returnData(): mimeSvc.onDataAvailable failed:\n" +
+ ex.toString()
+ );
+ }
+ } else {
+ try {
+ this.mimeSvc.outputDecryptedData(data, data.length);
+ } catch (ex) {
+ console.debug(ex);
+ lazy.EnigmailLog.ERROR(
+ "mimeDecrypt.jsm: returnData(): cannot send decrypted data to MIME processing:\n" +
+ ex.toString()
+ );
+ }
+ }
+ },
+
+ handleManualDecrypt() {
+ try {
+ let headerSink = EnigmailSingletons.messageReader;
+
+ if (headerSink && this.uri && !this.backgroundJob) {
+ headerSink.updateSecurityStatus(
+ this.msgUriSpec,
+ lazy.EnigmailConstants.POSSIBLE_PGPMIME,
+ 0,
+ 0,
+ "",
+ "",
+ "",
+ lazy.l10n.formatValueSync("possibly-pgp-mime"),
+ "",
+ this.uri,
+ null,
+ ""
+ );
+ }
+ } catch (ex) {
+ console.debug(ex);
+ }
+
+ return 0;
+ },
+
+ updateHeadersInMsgDb() {
+ if (this.mimePartNumber !== "1") {
+ return;
+ }
+ if (!this.uri) {
+ return;
+ }
+
+ if (this.decryptedHeaders && "subject" in this.decryptedHeaders) {
+ try {
+ let msgDbHdr = this.uri.QueryInterface(
+ Ci.nsIMsgMessageUrl
+ ).messageHeader;
+ msgDbHdr.subject = this.decryptedHeaders.subject;
+ } catch (x) {
+ console.debug(x);
+ }
+ }
+ },
+
+ extractEncryptedHeaders() {
+ let r = lazy.EnigmailMime.extractProtectedHeaders(this.decryptedData);
+ if (!r) {
+ return;
+ }
+
+ this.decryptedHeaders = r.newHeaders;
+ if (r.startPos >= 0 && r.endPos > r.startPos) {
+ this.decryptedData =
+ this.decryptedData.substr(0, r.startPos) +
+ this.decryptedData.substr(r.endPos);
+ }
+ },
+
+ /**
+ * Process the Autocrypt-Gossip header lines.
+ */
+ async extractAutocryptGossip() {
+ let gossipHeaders =
+ MimeParser.extractHeaders(this.decryptedData).get("autocrypt-gossip") ||
+ [];
+ for (let h of gossipHeaders) {
+ try {
+ let keyData = atob(
+ MimeParser.getParameter(h.replace(/ /g, ""), "keydata")
+ );
+ if (keyData) {
+ LAST_MSG.gossip.push(keyData);
+ }
+ } catch {}
+ }
+ },
+};
+
+////////////////////////////////////////////////////////////////////
+// General-purpose functions, not exported
+
+function LOCAL_DEBUG(str) {
+ if (gDebugLogLevel) {
+ lazy.EnigmailLog.DEBUG(str);
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/mimeEncrypt.jsm b/comm/mail/extensions/openpgp/content/modules/mimeEncrypt.jsm
new file mode 100644
index 0000000000..dd4018d704
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/mimeEncrypt.jsm
@@ -0,0 +1,760 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Module for creating PGP/MIME signed and/or encrypted messages
+ * implemented as XPCOM component
+ */
+
+const EXPORTED_SYMBOLS = ["EnigmailMimeEncrypt"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailEncryption: "chrome://openpgp/content/modules/encryption.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+ jsmime: "resource:///modules/jsmime.jsm",
+});
+
+// our own contract IDs
+const PGPMIME_ENCRYPT_CID = Components.ID(
+ "{96fe88f9-d2cd-466f-93e0-3a351df4c6d2}"
+);
+const PGPMIME_ENCRYPT_CONTRACTID = "@enigmail.net/compose/mimeencrypt;1";
+
+const maxBufferLen = 102400;
+const MIME_SIGNED = 1; // only one MIME layer
+const MIME_ENCRYPTED = 2; // only one MIME layer, combined enc/sig data
+const MIME_OUTER_ENC_INNER_SIG = 3; // use two MIME layers
+
+var gDebugLogLevel = 1;
+
+function PgpMimeEncrypt(sMimeSecurityInfo) {
+ this.wrappedJSObject = this;
+
+ this.signMessage = false;
+ this.requireEncryptMessage = false;
+
+ // "securityInfo" variables
+ this.sendFlags = 0;
+ this.UIFlags = 0;
+ this.senderEmailAddr = "";
+ this.recipients = "";
+ this.bccRecipients = "";
+ this.originalSubject = null;
+ this.autocryptGossipHeaders = "";
+
+ try {
+ if (sMimeSecurityInfo) {
+ this.signMessage = sMimeSecurityInfo.signMessage;
+ this.requireEncryptMessage = sMimeSecurityInfo.requireEncryptMessage;
+ }
+ } catch (ex) {}
+}
+
+PgpMimeEncrypt.prototype = {
+ classDescription: "Enigmail JS Encryption Handler",
+ classID: PGPMIME_ENCRYPT_CID,
+ get contractID() {
+ return PGPMIME_ENCRYPT_CONTRACTID;
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMsgComposeSecure",
+ "nsIStreamListener",
+ ]),
+
+ signMessage: false,
+ requireEncryptMessage: false,
+
+ // private variables
+
+ inStream: Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ ),
+ msgCompFields: null,
+ outStringStream: null,
+
+ // 0: processing headers
+ // 1: processing body
+ // 2: skipping header
+ inputMode: 0,
+ headerData: "",
+ encapsulate: null,
+ encHeader: null,
+ outerBoundary: null,
+ innerBoundary: null,
+ win: null,
+ //statusStr: "",
+ cryptoOutputLength: 0,
+ cryptoOutput: "",
+ hashAlgorithm: "SHA256", // TODO: coordinate with RNP.jsm
+ cryptoInputBuffer: "",
+ outgoingMessageBuffer: "",
+ mimeStructure: 0,
+ exitCode: -1,
+ inspector: null,
+
+ // nsIStreamListener interface
+ onStartRequest(request) {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.js: onStartRequest\n");
+ this.encHeader = null;
+ },
+
+ onDataAvailable(req, stream, offset, count) {
+ LOCAL_DEBUG("mimeEncrypt.js: onDataAvailable\n");
+ this.inStream.init(stream);
+ //var data = this.inStream.read(count);
+ //LOCAL_DEBUG("mimeEncrypt.js: >"+data+"<\n");
+ },
+
+ onStopRequest(request, status) {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.js: onStopRequest\n");
+ },
+
+ // nsIMsgComposeSecure interface
+ requiresCryptoEncapsulation(msgIdentity, msgCompFields) {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.js: requiresCryptoEncapsulation\n");
+ return (
+ (this.sendFlags &
+ (lazy.EnigmailConstants.SEND_SIGNED |
+ lazy.EnigmailConstants.SEND_ENCRYPTED |
+ lazy.EnigmailConstants.SEND_VERBATIM)) !==
+ 0
+ );
+ },
+
+ beginCryptoEncapsulation(
+ outStream,
+ recipientList,
+ msgCompFields,
+ msgIdentity,
+ sendReport,
+ isDraft
+ ) {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.js: beginCryptoEncapsulation\n");
+
+ if (!outStream) {
+ throw Components.Exception("", Cr.NS_ERROR_NULL_POINTER);
+ }
+
+ try {
+ this.outStream = outStream;
+ this.isDraft = isDraft;
+
+ this.msgCompFields = msgCompFields;
+ this.outStringStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+
+ var windowManager = Services.wm;
+ this.win = windowManager.getMostRecentWindow(null);
+
+ if (this.sendFlags & lazy.EnigmailConstants.SEND_VERBATIM) {
+ this.recipientList = recipientList;
+ this.msgIdentity = msgIdentity;
+ this.msgCompFields = msgCompFields;
+ this.inputMode = 2;
+ return null;
+ }
+
+ if (this.sendFlags & lazy.EnigmailConstants.SEND_PGP_MIME) {
+ if (this.sendFlags & lazy.EnigmailConstants.SEND_ENCRYPTED) {
+ // applies to encrypted and signed & encrypted
+ if (this.sendFlags & lazy.EnigmailConstants.SEND_TWO_MIME_LAYERS) {
+ this.mimeStructure = MIME_OUTER_ENC_INNER_SIG;
+ this.innerBoundary = lazy.EnigmailMime.createBoundary();
+ } else {
+ this.mimeStructure = MIME_ENCRYPTED;
+ }
+ } else if (this.sendFlags & lazy.EnigmailConstants.SEND_SIGNED) {
+ this.mimeStructure = MIME_SIGNED;
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ this.outerBoundary = lazy.EnigmailMime.createBoundary();
+ this.startCryptoHeaders();
+ } catch (ex) {
+ console.debug(ex);
+ lazy.EnigmailLog.writeException("mimeEncrypt.js", ex);
+ throw ex;
+ }
+
+ return null;
+ },
+
+ startCryptoHeaders() {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.js: startCryptoHeaders\n");
+
+ switch (this.mimeStructure) {
+ case MIME_SIGNED:
+ this.signedHeaders1(false);
+ break;
+ case MIME_ENCRYPTED:
+ case MIME_OUTER_ENC_INNER_SIG:
+ this.encryptedHeaders();
+ break;
+ }
+
+ this.writeSecureHeaders();
+ },
+
+ writeSecureHeaders() {
+ this.encHeader = lazy.EnigmailMime.createBoundary();
+
+ let allHdr = "";
+
+ let addrParser = lazy.jsmime.headerparser.parseAddressingHeader;
+ let newsParser = function (s) {
+ return lazy.jsmime.headerparser.parseStructuredHeader("Newsgroups", s);
+ };
+ let noParser = function (s) {
+ return s;
+ };
+
+ let h = {
+ from: {
+ field: "From",
+ parser: addrParser,
+ },
+ replyTo: {
+ field: "Reply-To",
+ parser: addrParser,
+ },
+ to: {
+ field: "To",
+ parser: addrParser,
+ },
+ cc: {
+ field: "Cc",
+ parser: addrParser,
+ },
+ newsgroups: {
+ field: "Newsgroups",
+ parser: newsParser,
+ },
+ followupTo: {
+ field: "Followup-To",
+ parser: addrParser,
+ },
+ messageId: {
+ field: "Message-Id",
+ parser: noParser,
+ },
+ subject: {
+ field: "Subject",
+ parser: noParser,
+ },
+ };
+
+ let alreadyAddedSubject = false;
+
+ if (
+ (this.mimeStructure == MIME_ENCRYPTED ||
+ this.mimeStructure == MIME_OUTER_ENC_INNER_SIG) &&
+ this.originalSubject &&
+ this.originalSubject.length > 0
+ ) {
+ alreadyAddedSubject = true;
+ allHdr += lazy.jsmime.headeremitter.emitStructuredHeader(
+ "subject",
+ this.originalSubject,
+ {}
+ );
+ }
+
+ for (let i in h) {
+ if (h[i].field == "Subject" && alreadyAddedSubject) {
+ continue;
+ }
+ if (this.msgCompFields[i] && this.msgCompFields[i].length > 0) {
+ allHdr += lazy.jsmime.headeremitter.emitStructuredHeader(
+ h[i].field,
+ h[i].parser(this.msgCompFields[i]),
+ {}
+ );
+ }
+ }
+
+ // special handling for references and in-reply-to
+
+ if (this.originalReferences && this.originalReferences.length > 0) {
+ allHdr += lazy.jsmime.headeremitter.emitStructuredHeader(
+ "references",
+ this.originalReferences,
+ {}
+ );
+
+ let bracket = this.originalReferences.lastIndexOf("<");
+ if (bracket >= 0) {
+ allHdr += lazy.jsmime.headeremitter.emitStructuredHeader(
+ "in-reply-to",
+ this.originalReferences.substr(bracket),
+ {}
+ );
+ }
+ }
+
+ let w = `Content-Type: multipart/mixed; boundary="${this.encHeader}"`;
+
+ if (allHdr.length > 0) {
+ w += `;\r\n protected-headers="v1"\r\n${allHdr}`;
+ } else {
+ w += "\r\n";
+ }
+
+ if (this.autocryptGossipHeaders) {
+ w += this.autocryptGossipHeaders;
+ }
+
+ w += `\r\n--${this.encHeader}\r\n`;
+ this.appendToCryptoInput(w);
+
+ if (this.mimeStructure == MIME_SIGNED) {
+ this.appendToMessage(w);
+ }
+ },
+
+ encryptedHeaders(isEightBit = false) {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.js: encryptedHeaders\n");
+ let subj = "";
+
+ if (this.sendFlags & lazy.EnigmailConstants.ENCRYPT_SUBJECT) {
+ subj = lazy.jsmime.headeremitter.emitStructuredHeader(
+ "subject",
+ lazy.EnigmailFuncs.getProtectedSubjectText(),
+ {}
+ );
+ }
+ this.appendToMessage(
+ subj +
+ "Content-Type: multipart/encrypted;\r\n" +
+ ' protocol="application/pgp-encrypted";\r\n' +
+ ' boundary="' +
+ this.outerBoundary +
+ '"\r\n' +
+ "\r\n" +
+ "This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)\r\n" +
+ "--" +
+ this.outerBoundary +
+ "\r\n" +
+ "Content-Type: application/pgp-encrypted\r\n" +
+ "Content-Description: PGP/MIME version identification\r\n" +
+ "\r\n" +
+ "Version: 1\r\n" +
+ "\r\n" +
+ "--" +
+ this.outerBoundary +
+ "\r\n" +
+ 'Content-Type: application/octet-stream; name="encrypted.asc"\r\n' +
+ "Content-Description: OpenPGP encrypted message\r\n" +
+ 'Content-Disposition: inline; filename="encrypted.asc"\r\n' +
+ "\r\n"
+ );
+ },
+
+ signedHeaders1(isEightBit = false) {
+ LOCAL_DEBUG("mimeEncrypt.js: signedHeaders1\n");
+ let boundary;
+ if (this.mimeStructure == MIME_OUTER_ENC_INNER_SIG) {
+ boundary = this.innerBoundary;
+ } else {
+ boundary = this.outerBoundary;
+ }
+ let sigHeader =
+ "Content-Type: multipart/signed; micalg=pgp-" +
+ this.hashAlgorithm.toLowerCase() +
+ ";\r\n" +
+ ' protocol="application/pgp-signature";\r\n' +
+ ' boundary="' +
+ boundary +
+ '"\r\n' +
+ (isEightBit ? "Content-Transfer-Encoding: 8bit\r\n\r\n" : "\r\n") +
+ "This is an OpenPGP/MIME signed message (RFC 4880 and 3156)\r\n" +
+ "--" +
+ boundary +
+ "\r\n";
+ if (this.mimeStructure == MIME_OUTER_ENC_INNER_SIG) {
+ this.appendToCryptoInput(sigHeader);
+ } else {
+ this.appendToMessage(sigHeader);
+ }
+ },
+
+ signedHeaders2() {
+ LOCAL_DEBUG("mimeEncrypt.js: signedHeaders2\n");
+ let boundary;
+ if (this.mimeStructure == MIME_OUTER_ENC_INNER_SIG) {
+ boundary = this.innerBoundary;
+ } else {
+ boundary = this.outerBoundary;
+ }
+ let sigHeader =
+ "\r\n--" +
+ boundary +
+ "\r\n" +
+ 'Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"\r\n' +
+ "Content-Description: OpenPGP digital signature\r\n" +
+ 'Content-Disposition: attachment; filename="OpenPGP_signature.asc"\r\n\r\n';
+ if (this.mimeStructure == MIME_OUTER_ENC_INNER_SIG) {
+ this.appendToCryptoInput(sigHeader);
+ } else {
+ this.appendToMessage(sigHeader);
+ }
+ },
+
+ finishCryptoHeaders() {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.js: finishCryptoHeaders\n");
+
+ this.appendToMessage("\r\n--" + this.outerBoundary + "--\r\n");
+ },
+
+ finishCryptoEncapsulation(abort, sendReport) {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.js: finishCryptoEncapsulation\n");
+
+ if ((this.sendFlags & lazy.EnigmailConstants.SEND_VERBATIM) !== 0) {
+ this.flushOutput();
+ return;
+ }
+
+ if (this.encapsulate) {
+ this.appendToCryptoInput("--" + this.encapsulate + "--\r\n");
+ }
+
+ if (this.encHeader) {
+ this.appendToCryptoInput("\r\n--" + this.encHeader + "--\r\n");
+ if (this.mimeStructure == MIME_SIGNED) {
+ this.appendToMessage("\r\n--" + this.encHeader + "--\r\n");
+ }
+ }
+
+ let statusFlagsObj = {};
+ let errorMsgObj = {};
+ this.exitCode = 0;
+
+ if (this.mimeStructure == MIME_OUTER_ENC_INNER_SIG) {
+ // prepare the inner crypto layer (the signature)
+ let sendFlagsWithoutEncrypt =
+ this.sendFlags & ~lazy.EnigmailConstants.SEND_ENCRYPTED;
+
+ this.exitCode = lazy.EnigmailEncryption.encryptMessageStart(
+ this.win,
+ this.UIFlags,
+ this.senderEmailAddr,
+ this.recipients,
+ this.bccRecipients,
+ this.hashAlgorithm,
+ sendFlagsWithoutEncrypt,
+ this,
+ statusFlagsObj,
+ errorMsgObj
+ );
+ if (!this.exitCode) {
+ // success
+ let innerSignedMessage = this.cryptoInputBuffer;
+ this.cryptoInputBuffer = "";
+
+ this.signedHeaders1(false);
+ this.appendToCryptoInput(innerSignedMessage);
+ this.signedHeaders2();
+ this.cryptoOutput = this.cryptoOutput
+ .replace(/\r/g, "")
+ .replace(/\n/g, "\r\n"); // force CRLF
+ this.appendToCryptoInput(this.cryptoOutput);
+ this.appendToCryptoInput("\r\n--" + this.innerBoundary + "--\r\n");
+ this.cryptoOutput = "";
+ }
+ }
+
+ if (!this.exitCode) {
+ // no failure yet
+ let encryptionFlags = this.sendFlags;
+ if (this.mimeStructure == MIME_OUTER_ENC_INNER_SIG) {
+ // remove signature flag, because we already signed
+ encryptionFlags = encryptionFlags & ~lazy.EnigmailConstants.SEND_SIGNED;
+ }
+ this.exitCode = lazy.EnigmailEncryption.encryptMessageStart(
+ this.win,
+ this.UIFlags,
+ this.senderEmailAddr,
+ this.recipients,
+ this.bccRecipients,
+ this.hashAlgorithm,
+ encryptionFlags,
+ this,
+ statusFlagsObj,
+ errorMsgObj
+ );
+ }
+
+ try {
+ LOCAL_DEBUG(
+ "mimeEncrypt.js: finishCryptoEncapsulation: exitCode = " +
+ this.exitCode +
+ "\n"
+ );
+ if (this.exitCode !== 0) {
+ throw new Error(
+ "failure in finishCryptoEncapsulation, exitCode: " + this.exitCode
+ );
+ }
+
+ if (this.mimeStructure == MIME_SIGNED) {
+ this.signedHeaders2();
+ }
+
+ this.cryptoOutput = this.cryptoOutput
+ .replace(/\r/g, "")
+ .replace(/\n/g, "\r\n"); // force CRLF
+
+ this.appendToMessage(this.cryptoOutput);
+ this.finishCryptoHeaders();
+ this.flushOutput();
+ } catch (ex) {
+ console.debug(ex);
+ lazy.EnigmailLog.writeException("mimeEncrypt.js", ex);
+ throw ex;
+ }
+ },
+
+ mimeCryptoWriteBlock(buffer, length) {
+ if (gDebugLogLevel > 4) {
+ LOCAL_DEBUG("mimeEncrypt.js: mimeCryptoWriteBlock: " + length + "\n");
+ }
+
+ try {
+ let line = buffer.substr(0, length);
+ if (this.inputMode === 0) {
+ if ((this.sendFlags & lazy.EnigmailConstants.SEND_VERBATIM) !== 0) {
+ line = lazy.EnigmailData.decodeQuotedPrintable(
+ line.replace("=\r\n", "")
+ );
+ }
+
+ if (
+ (this.sendFlags & lazy.EnigmailConstants.SEND_VERBATIM) === 0 ||
+ line.match(
+ /^(From|To|Subject|Message-ID|Date|User-Agent|MIME-Version):/i
+ ) === null
+ ) {
+ this.headerData += line;
+ }
+
+ if (line.replace(/[\r\n]/g, "").length === 0) {
+ this.inputMode = 1;
+
+ if (
+ this.mimeStructure == MIME_ENCRYPTED ||
+ this.mimeStructure == MIME_OUTER_ENC_INNER_SIG
+ ) {
+ if (!this.encHeader) {
+ let ct = this.getHeader("content-type", false);
+ if (
+ ct.search(/text\/plain/i) === 0 ||
+ ct.search(/text\/html/i) === 0
+ ) {
+ this.encapsulate = lazy.EnigmailMime.createBoundary();
+ this.appendToCryptoInput(
+ 'Content-Type: multipart/mixed; boundary="' +
+ this.encapsulate +
+ '"\r\n\r\n'
+ );
+ this.appendToCryptoInput("--" + this.encapsulate + "\r\n");
+ }
+ }
+ } else if (this.mimeStructure == MIME_SIGNED) {
+ let ct = this.getHeader("content-type", true);
+ let hdr = lazy.EnigmailFuncs.getHeaderData(ct);
+ hdr.boundary = hdr.boundary || "";
+ hdr.boundary = hdr.boundary.replace(/^(['"])(.*)(\1)$/, "$2");
+ }
+
+ this.appendToCryptoInput(this.headerData);
+ if (
+ this.mimeStructure == MIME_SIGNED ||
+ (this.sendFlags & lazy.EnigmailConstants.SEND_VERBATIM) !== 0
+ ) {
+ this.appendToMessage(this.headerData);
+ }
+ }
+ } else if (this.inputMode == 1) {
+ if (this.mimeStructure == MIME_SIGNED) {
+ // special treatments for various special cases with PGP/MIME signed messages
+ if (line.substr(0, 5) == "From ") {
+ LOCAL_DEBUG("mimeEncrypt.js: added >From\n");
+ this.appendToCryptoInput(">");
+ }
+ }
+
+ this.appendToCryptoInput(line);
+ if (this.mimeStructure == MIME_SIGNED) {
+ this.appendToMessage(line);
+ } else if (
+ (this.sendFlags & lazy.EnigmailConstants.SEND_VERBATIM) !==
+ 0
+ ) {
+ this.appendToMessage(
+ lazy.EnigmailData.decodeQuotedPrintable(line.replace("=\r\n", ""))
+ );
+ }
+ } else if (this.inputMode == 2) {
+ if (line.replace(/[\r\n]/g, "").length === 0) {
+ this.inputMode = 0;
+ }
+ }
+ } catch (ex) {
+ console.debug(ex);
+ lazy.EnigmailLog.writeException("mimeEncrypt.js", ex);
+ throw ex;
+ }
+
+ return null;
+ },
+
+ appendToMessage(str) {
+ if (gDebugLogLevel > 4) {
+ LOCAL_DEBUG("mimeEncrypt.js: appendToMessage: " + str.length + "\n");
+ }
+
+ this.outgoingMessageBuffer += str;
+
+ if (this.outgoingMessageBuffer.length > maxBufferLen) {
+ this.flushOutput();
+ }
+ },
+
+ flushOutput() {
+ LOCAL_DEBUG(
+ "mimeEncrypt.js: flushOutput: " + this.outgoingMessageBuffer.length + "\n"
+ );
+
+ this.outStringStream.setData(
+ this.outgoingMessageBuffer,
+ this.outgoingMessageBuffer.length
+ );
+ var writeCount = this.outStream.writeFrom(
+ this.outStringStream,
+ this.outgoingMessageBuffer.length
+ );
+ if (writeCount < this.outgoingMessageBuffer.length) {
+ LOCAL_DEBUG(
+ "mimeEncrypt.js: flushOutput: wrote " +
+ writeCount +
+ " instead of " +
+ this.outgoingMessageBuffer.length +
+ " bytes\n"
+ );
+ }
+ this.outgoingMessageBuffer = "";
+ },
+
+ appendToCryptoInput(str) {
+ if (gDebugLogLevel > 4) {
+ LOCAL_DEBUG("mimeEncrypt.js: appendToCryptoInput: " + str.length + "\n");
+ }
+
+ this.cryptoInputBuffer += str;
+ },
+
+ getHeader(hdrStr, fullHeader) {
+ var res = "";
+ var hdrLines = this.headerData.split(/[\r\n]+/);
+ for (let i = 0; i < hdrLines.length; i++) {
+ if (hdrLines[i].length > 0) {
+ if (fullHeader && res !== "") {
+ if (hdrLines[i].search(/^\s+/) === 0) {
+ res += hdrLines[i].replace(/\s*[\r\n]*$/, "");
+ } else {
+ return res;
+ }
+ } else {
+ let j = hdrLines[i].indexOf(":");
+ if (j > 0) {
+ let h = hdrLines[i].substr(0, j).replace(/\s*$/, "");
+ if (h.toLowerCase() == hdrStr.toLowerCase()) {
+ res = hdrLines[i].substr(j + 1).replace(/^\s*/, "");
+ if (!fullHeader) {
+ return res;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return res;
+ },
+
+ getInputForCrypto() {
+ return this.cryptoInputBuffer;
+ },
+
+ addCryptoOutput(s) {
+ LOCAL_DEBUG("mimeEncrypt.js: addCryptoOutput:" + s.length + "\n");
+ this.cryptoOutput += s;
+ this.cryptoOutputLength += s.length;
+ },
+
+ getCryptoOutputLength() {
+ return this.cryptoOutputLength;
+ },
+
+ // API for decryptMessage Listener
+ stdin(pipe) {
+ throw new Error("unexpected");
+ },
+
+ stderr(s) {
+ throw new Error("unexpected");
+ //this.statusStr += s;
+ },
+};
+
+////////////////////////////////////////////////////////////////////
+// General-purpose functions, not exported
+
+function LOCAL_DEBUG(str) {
+ if (gDebugLogLevel) {
+ lazy.EnigmailLog.DEBUG(str);
+ }
+}
+
+function initModule() {
+ lazy.EnigmailLog.DEBUG("mimeEncrypt.jsm: initModule()\n");
+ let nspr_log_modules = Services.env.get("NSPR_LOG_MODULES");
+ let matches = nspr_log_modules.match(/mimeEncrypt:(\d+)/);
+
+ if (matches && matches.length > 1) {
+ gDebugLogLevel = matches[1];
+ LOCAL_DEBUG("mimeEncrypt.js: enabled debug logging\n");
+ }
+}
+
+var EnigmailMimeEncrypt = {
+ Handler: PgpMimeEncrypt,
+
+ startup(reason) {
+ initModule();
+ },
+ shutdown(reason) {},
+
+ createMimeEncrypt(sMimeSecurityInfo) {
+ return new PgpMimeEncrypt(sMimeSecurityInfo);
+ },
+
+ isEnigmailCompField(obj) {
+ return obj instanceof PgpMimeEncrypt;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/mimeVerify.jsm b/comm/mail/extensions/openpgp/content/modules/mimeVerify.jsm
new file mode 100644
index 0000000000..6ec7a615c2
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/mimeVerify.jsm
@@ -0,0 +1,716 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailVerify"];
+
+/**
+ * Module for handling PGP/MIME signed messages implemented as JS module.
+ */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+const { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailDecryption: "chrome://openpgp/content/modules/decryption.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+ EnigmailSingletons: "chrome://openpgp/content/modules/singletons.jsm",
+ EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm",
+});
+
+const PGPMIME_PROTO = "application/pgp-signature";
+
+var gDebugLog = false;
+
+// MimeVerify Constructor
+function MimeVerify(protocol) {
+ if (!protocol) {
+ protocol = PGPMIME_PROTO;
+ }
+
+ this.protocol = protocol;
+ this.verifyEmbedded = false;
+ this.partiallySigned = false;
+ this.exitCode = null;
+ this.inStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+}
+
+var EnigmailVerify = {
+ _initialized: false,
+ lastWindow: null,
+ lastMsgUri: null,
+ manualMsgUri: null,
+
+ currentCtHandler: EnigmailConstants.MIME_HANDLER_UNDEF,
+
+ init() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+ let nspr_log_modules = Services.env.get("NSPR_LOG_MODULES");
+ let matches = nspr_log_modules.match(/mimeVerify:(\d+)/);
+
+ if (matches && matches.length > 1) {
+ if (matches[1] > 2) {
+ gDebugLog = true;
+ }
+ }
+ },
+
+ setWindow(window, msgUriSpec) {
+ LOCAL_DEBUG("mimeVerify.jsm: setWindow: " + msgUriSpec + "\n");
+
+ this.lastWindow = window;
+ this.lastMsgUri = msgUriSpec;
+ },
+
+ newVerifier(protocol) {
+ lazy.EnigmailLog.DEBUG(
+ "mimeVerify.jsm: newVerifier: " + (protocol || "null") + "\n"
+ );
+
+ let v = new MimeVerify(protocol);
+ return v;
+ },
+
+ setManualUri(msgUriSpec) {
+ LOCAL_DEBUG("mimeVerify.jsm: setManualUri: " + msgUriSpec + "\n");
+ this.manualMsgUri = msgUriSpec;
+ },
+
+ getManualUri() {
+ lazy.EnigmailLog.DEBUG("mimeVerify.jsm: getManualUri\n");
+ return this.manualMsgUri;
+ },
+
+ pgpMimeFactory: {
+ classID: Components.ID("{4f4400a8-9bcc-4b9d-9d53-d2437b377e29}"),
+ createInstance(iid) {
+ return Cc[
+ "@mozilla.org/mimecth;1?type=multipart/encrypted"
+ ].createInstance(iid);
+ },
+ },
+
+ /**
+ * Sets the PGPMime content type handler as the registered handler.
+ */
+ registerPGPMimeHandler() {
+ lazy.EnigmailLog.DEBUG("mimeVerify.jsm: registerPGPMimeHandler\n");
+
+ if (this.currentCtHandler == EnigmailConstants.MIME_HANDLER_PGPMIME) {
+ return;
+ }
+
+ let reg = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ reg.registerFactory(
+ this.pgpMimeFactory.classID,
+ "PGP/MIME verification",
+ "@mozilla.org/mimecth;1?type=multipart/signed",
+ this.pgpMimeFactory
+ );
+
+ this.currentCtHandler = EnigmailConstants.MIME_HANDLER_PGPMIME;
+ },
+
+ /**
+ * Clears the PGPMime content type handler registration. If no factory is
+ * registered, S/MIME works.
+ */
+ unregisterPGPMimeHandler() {
+ lazy.EnigmailLog.DEBUG("mimeVerify.jsm: unregisterPGPMimeHandler\n");
+
+ let reg = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ if (this.currentCtHandler == EnigmailConstants.MIME_HANDLER_PGPMIME) {
+ reg.unregisterFactory(this.pgpMimeFactory.classID, this.pgpMimeFactory);
+ }
+
+ this.currentCtHandler = EnigmailConstants.MIME_HANDLER_SMIME;
+ },
+};
+
+// MimeVerify implementation
+// verify the signature of PGP/MIME signed messages
+MimeVerify.prototype = {
+ dataCount: 0,
+ foundMsg: false,
+ startMsgStr: "",
+ window: null,
+ msgUriSpec: null,
+ statusDisplayed: false,
+ inStream: null,
+ sigFile: null,
+ sigData: "",
+ mimePartNumber: "",
+
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ parseContentType() {
+ let contentTypeLine = this.mimeSvc.contentType;
+
+ // Eat up CRLF's.
+ contentTypeLine = contentTypeLine.replace(/[\r\n]/g, "");
+ lazy.EnigmailLog.DEBUG(
+ "mimeVerify.jsm: parseContentType: " + contentTypeLine + "\n"
+ );
+
+ let protoRx = RegExp(
+ "protocol\\s*=\\s*[\\'\\\"]" + this.protocol + "[\\\"\\']",
+ "i"
+ );
+
+ if (
+ contentTypeLine.search(/multipart\/signed/i) >= 0 &&
+ contentTypeLine.search(protoRx) > 0
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "mimeVerify.jsm: parseContentType: found MIME signed message\n"
+ );
+ this.foundMsg = true;
+ let hdr = lazy.EnigmailFuncs.getHeaderData(contentTypeLine);
+ hdr.boundary = hdr.boundary || "";
+ hdr.micalg = hdr.micalg || "";
+ this.boundary = hdr.boundary.replace(/^(['"])(.*)(\1)$/, "$2");
+ }
+ },
+
+ onStartRequest(request, uri) {
+ lazy.EnigmailLog.DEBUG("mimeVerify.jsm: onStartRequest\n"); // always log this one
+
+ this.mimeSvc = request.QueryInterface(Ci.nsIPgpMimeProxy);
+ this.msgUriSpec = EnigmailVerify.lastMsgUri;
+
+ if ("mimePart" in this.mimeSvc) {
+ this.mimePartNumber = this.mimeSvc.mimePart;
+ } else {
+ this.mimePartNumber = "";
+ }
+
+ if ("messageURI" in this.mimeSvc) {
+ this.uri = this.mimeSvc.messageURI;
+ } else if (uri) {
+ this.uri = uri.QueryInterface(Ci.nsIURI);
+ }
+
+ this.dataCount = 0;
+ this.foundMsg = false;
+ this.backgroundJob = false;
+ this.startMsgStr = "";
+ this.boundary = "";
+ this.proc = null;
+ this.closePipe = false;
+ this.pipe = null;
+ this.readMode = 0;
+ this.keepData = "";
+ this.last80Chars = "";
+ this.signedData = "";
+ this.statusStr = "";
+ this.returnStatus = null;
+ this.statusDisplayed = false;
+ this.protectedHeaders = null;
+ this.parseContentType();
+ },
+
+ onDataAvailable(req, stream, offset, count) {
+ LOCAL_DEBUG("mimeVerify.jsm: onDataAvailable: " + count + "\n");
+ if (count > 0) {
+ this.inStream.init(stream);
+ var data = this.inStream.read(count);
+ this.onTextData(data);
+ }
+ },
+
+ onTextData(data) {
+ LOCAL_DEBUG("mimeVerify.jsm: onTextData\n");
+
+ this.dataCount += data.length;
+
+ this.keepData += data;
+ if (this.readMode === 0) {
+ // header data
+ let i = this.findNextMimePart();
+ if (i >= 0) {
+ i += 2 + this.boundary.length;
+ if (this.keepData[i] == "\n") {
+ ++i;
+ } else if (this.keepData[i] == "\r") {
+ ++i;
+ if (this.keepData[i] == "\n") {
+ ++i;
+ }
+ }
+
+ this.keepData = this.keepData.substr(i);
+ data = this.keepData;
+ this.readMode = 1;
+ } else {
+ this.keepData = data.substr(-this.boundary.length - 3);
+ }
+ }
+
+ if (this.readMode === 1) {
+ // "real data"
+ if (data.includes("-")) {
+ // only check current line for speed reasons
+ let i = this.findNextMimePart();
+ if (i >= 0) {
+ // end of "read data found"
+ if (this.keepData[i - 2] == "\r" && this.keepData[i - 1] == "\n") {
+ --i;
+ }
+
+ this.signedData = this.keepData.substr(0, i - 1);
+ this.keepData = this.keepData.substr(i);
+ this.readMode = 2;
+ }
+ } else {
+ return;
+ }
+ }
+
+ if (this.readMode === 2) {
+ let i = this.keepData.indexOf("--" + this.boundary + "--");
+ if (i >= 0) {
+ // ensure that we keep everything until we got the "end" boundary
+ if (this.keepData[i - 2] == "\r" && this.keepData[i - 1] == "\n") {
+ --i;
+ }
+ this.keepData = this.keepData.substr(0, i - 1);
+ this.readMode = 3;
+ }
+ }
+
+ if (this.readMode === 3) {
+ // signature data
+ if (this.protocol === PGPMIME_PROTO) {
+ let xferEnc = this.getContentTransferEncoding();
+ if (xferEnc.search(/base64/i) >= 0) {
+ let bound = this.getBodyPart();
+ this.keepData =
+ lazy.EnigmailData.decodeBase64(
+ this.keepData.substring(bound.start, bound.end)
+ ) + "\n";
+ } else if (xferEnc.search(/quoted-printable/i) >= 0) {
+ let bound = this.getBodyPart();
+ let qp = this.keepData.substring(bound.start, bound.end);
+ this.keepData = lazy.EnigmailData.decodeQuotedPrintable(qp) + "\n";
+ }
+
+ // extract signature data
+ let s = Math.max(this.keepData.search(/^-----BEGIN PGP /m), 0);
+ let e = Math.max(
+ this.keepData.search(/^-----END PGP /m),
+ this.keepData.length - 30
+ );
+ this.sigData = this.keepData.substring(s, e + 30);
+ } else {
+ this.sigData = "";
+ }
+
+ this.keepData = "";
+ this.readMode = 4; // ignore any further data
+ }
+ },
+
+ getBodyPart() {
+ let start = this.keepData.search(/(\n\n|\r\n\r\n)/);
+ if (start < 0) {
+ start = 0;
+ }
+ let end = this.keepData.indexOf("--" + this.boundary + "--") - 1;
+ if (end < 0) {
+ end = this.keepData.length;
+ }
+
+ return {
+ start,
+ end,
+ };
+ },
+
+ // determine content-transfer encoding of mime part, assuming that whole
+ // message is in this.keepData
+ getContentTransferEncoding() {
+ let enc = "7bit";
+ let m = this.keepData.match(/^(content-transfer-encoding:)(.*)$/im);
+ if (m && m.length > 2) {
+ enc = m[2].trim().toLowerCase();
+ }
+
+ return enc;
+ },
+
+ findNextMimePart() {
+ let startOk = false;
+ let endOk = false;
+
+ let i = this.keepData.indexOf("--" + this.boundary);
+ if (i === 0) {
+ startOk = true;
+ }
+ if (i > 0) {
+ if (this.keepData[i - 1] == "\r" || this.keepData[i - 1] == "\n") {
+ startOk = true;
+ }
+ }
+
+ if (!startOk) {
+ return -1;
+ }
+
+ if (i + this.boundary.length + 2 < this.keepData.length) {
+ if (
+ this.keepData[i + this.boundary.length + 2] == "\r" ||
+ this.keepData[i + this.boundary.length + 2] == "\n" ||
+ this.keepData.substr(i + this.boundary.length + 2, 2) == "--"
+ ) {
+ endOk = true;
+ }
+ }
+ // else
+ // endOk = true;
+
+ if (i >= 0 && startOk && endOk) {
+ return i;
+ }
+ return -1;
+ },
+
+ isAllowedSigPart(queryMimePartNumber, loadedUriSpec) {
+ // allowed are:
+ // - the top part 1
+ // - the child 1.1 if 1 is an encryption layer
+ // - a part that is the one we are loading
+ // - a part that is the first child of the one we are loading,
+ // and the child we are loading is an encryption layer
+
+ if (queryMimePartNumber.length === 0) {
+ return false;
+ }
+
+ if (queryMimePartNumber === "1") {
+ return true;
+ }
+
+ if (queryMimePartNumber == "1.1" || queryMimePartNumber == "1.1.1") {
+ if (!this.uri) {
+ // We aren't loading in message displaying, but some other
+ // context, could be e.g. forwarding.
+ return false;
+ }
+
+ // If we are processing "1.1", it means we're the child of the
+ // top mime part. Don't process the signature unless the top
+ // level mime part is an encryption layer.
+ // If we are processing "1.1.1", then potentially the top level
+ // mime part was a signature and has been ignored, and "1.1"
+ // might be an encrypted part that was allowed.
+
+ let currMsg = lazy.EnigmailURIs.msgIdentificationFromUrl(this.uri);
+ let parentToCheck = queryMimePartNumber == "1.1.1" ? "1.1" : "1";
+ if (
+ lazy.EnigmailSingletons.isLastDecryptedMessagePart(
+ currMsg.folder,
+ currMsg.msgNum,
+ parentToCheck
+ )
+ ) {
+ return true;
+ }
+ }
+
+ if (!loadedUriSpec) {
+ return false;
+ }
+
+ // is the message a subpart of a complete attachment?
+ let msgPart = lazy.EnigmailMime.getMimePartNumber(loadedUriSpec);
+
+ if (msgPart.length > 0) {
+ if (queryMimePartNumber === msgPart + ".1") {
+ return true;
+ }
+
+ let currMsg = lazy.EnigmailURIs.msgIdentificationFromUrl(this.uri);
+ if (
+ queryMimePartNumber === msgPart + ".1.1" &&
+ lazy.EnigmailSingletons.isLastDecryptedMessagePart(
+ currMsg.folder,
+ currMsg.msgNum,
+ msgPart + ".1"
+ )
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ onStopRequest() {
+ lazy.EnigmailLog.DEBUG("mimeVerify.jsm: onStopRequest\n");
+
+ this.window = EnigmailVerify.lastWindow;
+ this.msgUriSpec = EnigmailVerify.lastMsgUri;
+
+ this.backgroundJob = false;
+
+ // don't try to verify if no message found
+ // if (this.verifyEmbedded && (!this.foundMsg)) return; // TODO - check
+
+ let href = Services.wm.getMostRecentWindow(null)?.document?.location.href;
+
+ if (
+ href == "about:blank" ||
+ href == "chrome://messenger/content/viewSource.xhtml"
+ ) {
+ return;
+ }
+
+ if (this.readMode < 4) {
+ // we got incomplete data; simply return what we got
+ this.returnData(
+ this.signedData.length > 0 ? this.signedData : this.keepData
+ );
+
+ return;
+ }
+
+ this.protectedHeaders = lazy.EnigmailMime.extractProtectedHeaders(
+ this.signedData
+ );
+
+ if (
+ this.protectedHeaders &&
+ this.protectedHeaders.startPos >= 0 &&
+ this.protectedHeaders.endPos > this.protectedHeaders.startPos
+ ) {
+ let r =
+ this.signedData.substr(0, this.protectedHeaders.startPos) +
+ this.signedData.substr(this.protectedHeaders.endPos);
+ this.returnData(r);
+ } else {
+ this.returnData(this.signedData);
+ }
+
+ if (!this.isAllowedSigPart(this.mimePartNumber, this.msgUriSpec)) {
+ return;
+ }
+
+ if (this.uri) {
+ // return if not decrypting currently displayed message (except if
+ // printing, replying, etc)
+
+ this.backgroundJob =
+ this.uri.spec.search(/[&?]header=(print|quotebody)/) >= 0;
+
+ try {
+ if (!Services.prefs.getBoolPref("temp.openpgp.autoDecrypt")) {
+ // "decrypt manually" mode
+ let manUrl = {};
+
+ if (EnigmailVerify.getManualUri()) {
+ manUrl = lazy.EnigmailFuncs.getUrlFromUriSpec(
+ EnigmailVerify.getManualUri()
+ );
+ }
+
+ // print a message if not message explicitly decrypted
+ let currUrlSpec = this.uri.spec.replace(
+ /(\?.*)(number=[0-9]*)(&.*)?$/,
+ "?$2"
+ );
+ let manUrlSpec = manUrl.spec.replace(
+ /(\?.*)(number=[0-9]*)(&.*)?$/,
+ "?$2"
+ );
+
+ if (!this.backgroundJob && currUrlSpec != manUrlSpec) {
+ return; // this.handleManualDecrypt();
+ }
+ }
+
+ if (
+ this.uri.spec.search(/[&?]header=[a-zA-Z0-9]*$/) < 0 &&
+ this.uri.spec.search(/[&?]part=[.0-9]+/) < 0 &&
+ this.uri.spec.search(/[&?]examineEncryptedParts=true/) < 0
+ ) {
+ if (this.uri.spec.search(/[&?]header=filter&.*$/) > 0) {
+ return;
+ }
+
+ let url = this.msgUriSpec
+ ? lazy.EnigmailFuncs.getUrlFromUriSpec(this.msgUriSpec)
+ : null;
+
+ if (url) {
+ let otherId = lazy.EnigmailURIs.msgIdentificationFromUrl(url);
+ let thisId = lazy.EnigmailURIs.msgIdentificationFromUrl(this.uri);
+
+ if (
+ url.host !== this.uri.host ||
+ otherId.folder !== thisId.folder ||
+ otherId.msgNum !== thisId.msgNum
+ ) {
+ return;
+ }
+ }
+ }
+ } catch (ex) {
+ lazy.EnigmailLog.writeException("mimeVerify.jsm", ex);
+ lazy.EnigmailLog.DEBUG(
+ "mimeVerify.jsm: error while processing " + this.msgUriSpec + "\n"
+ );
+ }
+ }
+
+ if (this.protocol === PGPMIME_PROTO) {
+ let win = this.window;
+
+ if (!lazy.EnigmailDecryption.isReady(win)) {
+ return;
+ }
+
+ let options = {
+ fromAddr: lazy.EnigmailDecryption.getFromAddr(win),
+ mimeSignatureData: this.sigData,
+ msgDate: lazy.EnigmailDecryption.getMsgDate(win),
+ };
+ const cApi = lazy.EnigmailCryptoAPI();
+
+ // ensure all lines end with CRLF as specified in RFC 3156, section 5
+ if (this.signedData.search(/[^\r]\n/) >= 0) {
+ this.signedData = this.signedData
+ .replace(/\r\n/g, "\n")
+ .replace(/\n/g, "\r\n");
+ }
+
+ this.returnStatus = cApi.sync(cApi.verifyMime(this.signedData, options));
+
+ if (!this.returnStatus) {
+ this.exitCode = -1;
+ } else {
+ this.exitCode = this.returnStatus.exitCode;
+
+ this.returnStatus.statusFlags |= EnigmailConstants.PGP_MIME_SIGNED;
+
+ if (this.partiallySigned) {
+ this.returnStatus.statusFlags |= EnigmailConstants.PARTIALLY_PGP;
+ }
+
+ this.displayStatus();
+ }
+ }
+ },
+
+ // return data to libMime
+ returnData(data) {
+ lazy.EnigmailLog.DEBUG(
+ "mimeVerify.jsm: returnData: " + data.length + " bytes\n"
+ );
+
+ let m = data.match(/^(content-type: +)([\w/]+)/im);
+ if (m && m.length >= 3) {
+ let contentType = m[2];
+ if (contentType.search(/^text/i) === 0) {
+ // add multipart/mixed boundary to work around TB bug (empty forwarded message)
+ let bound = lazy.EnigmailMime.createBoundary();
+ data =
+ 'Content-Type: multipart/mixed; boundary="' +
+ bound +
+ '"\n' +
+ "Content-Disposition: inline\n\n--" +
+ bound +
+ "\n" +
+ data +
+ "\n--" +
+ bound +
+ "--\n";
+ }
+ }
+
+ this.mimeSvc.outputDecryptedData(data, data.length);
+ },
+
+ setWindow(window, msgUriSpec) {
+ lazy.EnigmailLog.DEBUG("mimeVerify.jsm: setWindow: " + msgUriSpec + "\n");
+
+ if (!this.window) {
+ this.window = window;
+ this.msgUriSpec = msgUriSpec;
+ }
+ },
+
+ displayStatus() {
+ lazy.EnigmailLog.DEBUG("mimeVerify.jsm: displayStatus\n");
+ if (
+ this.exitCode === null ||
+ this.window === null ||
+ this.statusDisplayed ||
+ this.backgroundJob
+ ) {
+ return;
+ }
+
+ try {
+ LOCAL_DEBUG("mimeVerify.jsm: displayStatus displaying result\n");
+ let headerSink = lazy.EnigmailSingletons.messageReader;
+
+ if (this.protectedHeaders) {
+ headerSink.processDecryptionResult(
+ this.uri,
+ "modifyMessageHeaders",
+ JSON.stringify(this.protectedHeaders.newHeaders),
+ this.mimePartNumber
+ );
+ }
+
+ if (headerSink) {
+ headerSink.updateSecurityStatus(
+ this.lastMsgUri,
+ this.exitCode,
+ this.returnStatus.statusFlags,
+ this.returnStatus.extStatusFlags,
+ this.returnStatus.keyId,
+ this.returnStatus.userId,
+ this.returnStatus.sigDetails,
+ this.returnStatus.errorMsg,
+ this.returnStatus.blockSeparation,
+ this.uri,
+ JSON.stringify({
+ encryptedTo: this.returnStatus.encToDetails,
+ }),
+ this.mimePartNumber
+ );
+ }
+ this.statusDisplayed = true;
+ } catch (ex) {
+ lazy.EnigmailLog.writeException("mimeVerify.jsm", ex);
+ }
+ },
+};
+
+////////////////////////////////////////////////////////////////////
+// General-purpose functions, not exported
+
+function LOCAL_DEBUG(str) {
+ if (gDebugLog) {
+ lazy.EnigmailLog.DEBUG(str);
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/msgRead.jsm b/comm/mail/extensions/openpgp/content/modules/msgRead.jsm
new file mode 100644
index 0000000000..04f38bf602
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/msgRead.jsm
@@ -0,0 +1,289 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailMsgRead"];
+
+/**
+ * Message-reading related functions
+ */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+});
+
+var EnigmailMsgRead = {
+ /**
+ * Ensure that Thunderbird prepares certain headers during message reading
+ */
+ ensureExtraAddonHeaders() {
+ let hdr = Services.prefs.getCharPref("mailnews.headers.extraAddonHeaders");
+
+ if (hdr !== "*") {
+ // do nothing if extraAddonHeaders is "*" (all headers)
+ for (let h of ["autocrypt", "openpgp"]) {
+ if (hdr.search(h) < 0) {
+ if (hdr.length > 0) {
+ hdr += " ";
+ }
+ hdr += h;
+ }
+ }
+ Services.prefs.setCharPref("mailnews.headers.extraAddonHeaders", hdr);
+ }
+ },
+
+ /**
+ * Get a mail URL from a uriSpec
+ *
+ * @param uriSpec: String - URI of the desired message
+ *
+ * @returns Object: nsIURL or nsIMsgMailNewsUrl object
+ */
+ getUrlFromUriSpec(uriSpec) {
+ return lazy.EnigmailFuncs.getUrlFromUriSpec(uriSpec);
+ },
+
+ /**
+ * Determine if an attachment is possibly signed
+ */
+ checkSignedAttachment(attachmentObj, index, currentAttachments) {
+ function escapeRegex(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
+ }
+
+ var attachmentList;
+ if (index !== null) {
+ attachmentList = attachmentObj;
+ } else {
+ attachmentList = currentAttachments;
+ for (let i = 0; i < attachmentList.length; i++) {
+ if (attachmentList[i].url == attachmentObj.url) {
+ index = i;
+ break;
+ }
+ }
+ if (index === null) {
+ return false;
+ }
+ }
+
+ var signed = false;
+ var findFile;
+
+ var attName = this.getAttachmentName(attachmentList[index])
+ .toLowerCase()
+ .replace(/\+/g, "\\+");
+
+ // check if filename is a signature
+ if (
+ this.getAttachmentName(attachmentList[index]).search(/\.(sig|asc)$/i) >
+ 0 ||
+ attachmentList[index].contentType.match(/^application\/pgp-signature/i)
+ ) {
+ findFile = new RegExp(escapeRegex(attName.replace(/\.(sig|asc)$/, "")));
+ } else if (attName.search(/\.pgp$/i) > 0) {
+ findFile = new RegExp(
+ escapeRegex(attName.replace(/\.pgp$/, "")) + "(\\.pgp)?\\.(sig|asc)$"
+ );
+ } else {
+ findFile = new RegExp(escapeRegex(attName) + "\\.(sig|asc)$");
+ }
+
+ for (let i in attachmentList) {
+ if (
+ i != index &&
+ this.getAttachmentName(attachmentList[i])
+ .toLowerCase()
+ .search(findFile) === 0
+ ) {
+ signed = true;
+ }
+ }
+
+ return signed;
+ },
+
+ /**
+ * Get the name of an attachment from the attachment object
+ */
+ getAttachmentName(attachment) {
+ return attachment.name;
+ },
+
+ /**
+ * Escape text such that it can be used as HTML text
+ */
+ escapeTextForHTML(text, hyperlink) {
+ // Escape special characters
+ if (text.indexOf("&") > -1) {
+ text = text.replace(/&/g, "&amp;");
+ }
+
+ if (text.indexOf("<") > -1) {
+ text = text.replace(/</g, "&lt;");
+ }
+
+ if (text.indexOf(">") > -1) {
+ text = text.replace(/>/g, "&gt;");
+ }
+
+ if (text.indexOf('"') > -1) {
+ text = text.replace(/"/g, "&quot;");
+ }
+
+ if (!hyperlink) {
+ return text;
+ }
+
+ // Hyperlink email addresses (we accept at most 1024 characters before and after the @)
+ var addrs = text.match(
+ /\b[A-Za-z0-9_+.-]{1,1024}@[A-Za-z0-9.-]{1,1024}\b/g
+ );
+
+ var newText, offset, loc;
+ if (addrs && addrs.length) {
+ newText = "";
+ offset = 0;
+
+ for (var j = 0; j < addrs.length; j++) {
+ var addr = addrs[j];
+
+ loc = text.indexOf(addr, offset);
+ if (loc < offset) {
+ break;
+ }
+
+ if (loc > offset) {
+ newText += text.substr(offset, loc - offset);
+ }
+
+ // Strip any period off the end of address
+ addr = addr.replace(/[.]$/, "");
+
+ if (!addr.length) {
+ continue;
+ }
+
+ newText += '<a href="mailto:' + addr + '">' + addr + "</a>";
+
+ offset = loc + addr.length;
+ }
+
+ newText += text.substr(offset, text.length - offset);
+
+ text = newText;
+ }
+
+ // Hyperlink URLs (we don't accept URLS or more than 1024 characters length)
+ var urls = text.match(/\b(http|https|ftp):\S{1,1024}\s/g);
+
+ if (urls && urls.length) {
+ newText = "";
+ offset = 0;
+
+ for (var k = 0; k < urls.length; k++) {
+ var url = urls[k];
+
+ loc = text.indexOf(url, offset);
+ if (loc < offset) {
+ break;
+ }
+
+ if (loc > offset) {
+ newText += text.substr(offset, loc - offset);
+ }
+
+ // Strip delimiters off the end of URL
+ url = url.replace(/\s$/, "");
+ url = url.replace(/([),.']|&gt;|&quot;)$/, "");
+
+ if (!url.length) {
+ continue;
+ }
+
+ newText += '<a href="' + url + '">' + url + "</a>";
+
+ offset = loc + url.length;
+ }
+
+ newText += text.substr(offset, text.length - offset);
+
+ text = newText;
+ }
+
+ return text;
+ },
+
+ /**
+ * Match the key to the sender's from address
+ *
+ * @param {string} keyId: signing key ID
+ * @param {string} fromAddr: sender's email address
+ *
+ * @returns Promise<String>: matching email address
+ */
+ matchUidToSender(keyId, fromAddr) {
+ if (!fromAddr || !keyId) {
+ return null;
+ }
+
+ try {
+ fromAddr = lazy.EnigmailFuncs.stripEmail(fromAddr).toLowerCase();
+ } catch (ex) {
+ console.debug(ex);
+ }
+
+ let keyObj = lazy.EnigmailKeyRing.getKeyById(keyId);
+ if (!keyObj) {
+ return null;
+ }
+
+ let userIdList = keyObj.userIds;
+
+ try {
+ for (let i = 0; i < userIdList.length; i++) {
+ if (
+ fromAddr ==
+ lazy.EnigmailFuncs.stripEmail(userIdList[i].userId).toLowerCase()
+ ) {
+ let result = lazy.EnigmailFuncs.stripEmail(userIdList[i].userId);
+ return result;
+ }
+ }
+ } catch (ex) {
+ console.debug(ex);
+ }
+ return null;
+ },
+
+ searchQuotedPgp(node) {
+ if (
+ node.nodeName.toLowerCase() === "blockquote" &&
+ node.textContent.includes("-----BEGIN PGP ")
+ ) {
+ return true;
+ }
+
+ if (node.firstChild && this.searchQuotedPgp(node.firstChild)) {
+ return true;
+ }
+
+ if (node.nextSibling && this.searchQuotedPgp(node.nextSibling)) {
+ return true;
+ }
+
+ return false;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/persistentCrypto.jsm b/comm/mail/extensions/openpgp/content/modules/persistentCrypto.jsm
new file mode 100644
index 0000000000..17de2e3246
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/persistentCrypto.jsm
@@ -0,0 +1,1338 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var EXPORTED_SYMBOLS = ["EnigmailPersistentCrypto"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailArmor: "chrome://openpgp/content/modules/armor.jsm",
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailEncryption: "chrome://openpgp/content/modules/encryption.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+ EnigmailFixExchangeMsg:
+ "chrome://openpgp/content/modules/fixExchangeMessage.jsm",
+ EnigmailDecryption: "chrome://openpgp/content/modules/decryption.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm",
+ jsmime: "resource:///modules/jsmime.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MailCryptoUtils: "resource:///modules/MailCryptoUtils.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+var EnigmailPersistentCrypto = {
+ /***
+ * cryptMessage
+ *
+ * Decrypts a message and copy it to a folder. If targetKey is
+ * not null, it encrypts a message to the target key afterwards.
+ *
+ * @param {nsIMsgDBHdr} hdr - message to process
+ * @param {string} destFolder - target folder URI
+ * @param {boolean} move - true for move, false for copy
+ * @param {KeyObject} targetKey - target key if encryption is requested
+ *
+ * @returns {nsMsgKey} Message key of the new message
+ **/
+ async cryptMessage(hdr, destFolder, move, targetKey) {
+ return new Promise(function (resolve, reject) {
+ let msgUriSpec = hdr.folder.getUriForMsg(hdr);
+ let msgUrl = lazy.EnigmailFuncs.getUrlFromUriSpec(msgUriSpec);
+
+ const crypt = new CryptMessageIntoFolder(destFolder, move, targetKey);
+
+ lazy.EnigmailMime.getMimeTreeFromUrl(msgUrl, true, async function (mime) {
+ try {
+ let newMsgKey = await crypt.messageParseCallback(mime, hdr);
+ resolve(newMsgKey);
+ } catch (ex) {
+ reject(ex);
+ }
+ });
+ });
+ },
+
+ changeMessageId(content, newMessageIdPrefix) {
+ let [headerData, body] = MimeParser.extractHeadersAndBody(content);
+ content = "";
+
+ let newHeaders = headerData.rawHeaderText;
+ if (!newHeaders.endsWith("\r\n")) {
+ newHeaders += "\r\n";
+ }
+
+ headerData = undefined;
+
+ let regExpMsgId = new RegExp("^message-id: <(.*)>", "mi");
+ let msgId;
+ let match = newHeaders.match(regExpMsgId);
+
+ if (match) {
+ msgId = match[1];
+ newHeaders = newHeaders.replace(
+ regExpMsgId,
+ "Message-Id: <" + newMessageIdPrefix + "-$1>"
+ );
+
+ // Match the references header across multiple lines
+ // eslint-disable-next-line no-control-regex
+ let regExpReferences = new RegExp("^references: .*([\r\n]*^ .*$)*", "mi");
+ let refLines = newHeaders.match(regExpReferences);
+ if (refLines) {
+ // Take the full match of the existing header
+ let newRef = refLines[0] + " <" + msgId + ">";
+ newHeaders = newHeaders.replace(regExpReferences, newRef);
+ } else {
+ newHeaders += "References: <" + msgId + ">\r\n";
+ }
+ }
+
+ return newHeaders + "\r\n" + body;
+ },
+
+ /*
+ * Copies an email message to a folder, which is a modified copy of an
+ * existing message, optionally creating a new message ID.
+ *
+ * @param {nsIMsgDBHdr} originalMsgHdr - Header of the original message
+ * @param {string} targetFolderUri - Target folder URI
+ * @param {boolean} deleteOrigMsg - Should the original message be deleted?
+ * @param {string} content - New message content
+ * @param {string} newMessageIdPrefix - If this is non-null, create a new message ID
+ * by adding this prefix.
+ *
+ * @returns {nsMsgKey} Message key of the new message
+ */
+ async copyMessageToFolder(
+ originalMsgHdr,
+ targetFolderUri,
+ deleteOrigMsg,
+ content,
+ newMessageIdPrefix
+ ) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: copyMessageToFolder()\n");
+ return new Promise((resolve, reject) => {
+ if (newMessageIdPrefix) {
+ content = this.changeMessageId(content, newMessageIdPrefix);
+ }
+
+ // Create the temporary file where the new message will be stored.
+ let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempFile.append("message.eml");
+ tempFile.createUnique(0, 0o600);
+
+ let outputStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ outputStream.init(tempFile, 2, 0x200, false); // open as "write only"
+ outputStream.write(content, content.length);
+ outputStream.close();
+
+ // Delete file on exit, because Windows locks the file
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+
+ let msgFolder = originalMsgHdr.folder;
+
+ // The following technique was copied from AttachmentDeleter in Thunderbird's
+ // nsMessenger.cpp. There is a "unified" listener which serves as copy and delete
+ // listener. In all cases, the `OnStopCopy()` of the delete listener selects the
+ // replacement message.
+ // The deletion happens in `OnStopCopy()` of the copy listener for local messages
+ // and in `OnStopRunningUrl()` for IMAP messages if the folder is displayed since
+ // otherwise `OnStopRunningUrl()` doesn't run.
+
+ let copyListener, newKey;
+ let statusCode = 0;
+ let destFolder = targetFolderUri
+ ? lazy.MailUtils.getExistingFolder(targetFolderUri)
+ : msgFolder;
+
+ copyListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMsgCopyServiceListener",
+ "nsIUrlListener",
+ ]),
+ GetMessageId(messageId) {
+ // Maybe enable this later. Most of the Thunderbird code does not supply this.
+ // messageId = { value: msgHdr.messageId };
+ },
+ SetMessageKey(key) {
+ lazy.EnigmailLog.DEBUG(
+ `persistentCrypto.jsm: copyMessageToFolder: Result of CopyFileMessage() is new message with key ${key}\n`
+ );
+ newKey = key;
+ },
+ applyFlags() {
+ let newHdr = destFolder.GetMessageHeader(newKey);
+ newHdr.markRead(originalMsgHdr.isRead);
+ newHdr.markFlagged(originalMsgHdr.isFlagged);
+ newHdr.subject = originalMsgHdr.subject;
+ },
+ OnStartCopy() {},
+ OnStopCopy(status) {
+ statusCode = status;
+ if (statusCode !== 0) {
+ lazy.EnigmailLog.ERROR(
+ `persistentCrypto.jsm: ${statusCode} replacing message, folder="${msgFolder.name}", key=${originalMsgHdr.messageKey}/${newKey}\n`
+ );
+ reject();
+ return;
+ }
+
+ try {
+ tempFile.remove();
+ } catch (ex) {}
+
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: copyMessageToFolder: Triggering deletion from OnStopCopy()\n"
+ );
+ this.applyFlags();
+
+ if (deleteOrigMsg) {
+ lazy.EnigmailLog.DEBUG(
+ `persistentCrypto.jsm: copyMessageToFolder: Deleting old message with key ${originalMsgHdr.messageKey}\n`
+ );
+ msgFolder.deleteMessages(
+ [originalMsgHdr],
+ null,
+ true,
+ false,
+ null,
+ false
+ );
+ }
+ resolve(newKey);
+ },
+ };
+
+ MailServices.copy.copyFileMessage(
+ tempFile,
+ destFolder,
+ null,
+ false,
+ originalMsgHdr.flags,
+ "",
+ copyListener,
+ null
+ );
+ });
+ },
+};
+
+function CryptMessageIntoFolder(destFolder, move, targetKey) {
+ this.destFolder = destFolder;
+ this.move = move;
+ this.targetKey = targetKey;
+ this.cryptoChanged = false;
+ this.decryptFailure = false;
+
+ this.mimeTree = null;
+ this.decryptionTasks = [];
+ this.subject = "";
+}
+
+CryptMessageIntoFolder.prototype = {
+ /** Here is the effective action of a call to cryptMessage.
+ * If no failure is seen when attempting to decrypt (!decryptFailure),
+ * then we copy. (This includes plain messages that didn't need
+ * decryption.)
+ * The cryptoChanged flag is set only after we have successfully
+ * completed a decryption (or encryption) operation, it's used to
+ * decide whether we need a new message ID.
+ */
+ async messageParseCallback(mimeTree, msgHdr) {
+ this.mimeTree = mimeTree;
+ this.hdr = msgHdr;
+
+ if (mimeTree.headers.has("subject")) {
+ this.subject = mimeTree.headers.get("subject");
+ }
+
+ await this.decryptMimeTree(mimeTree);
+
+ let msg = "";
+
+ // Encrypt the message if a target key is given.
+ if (this.targetKey) {
+ msg = this.encryptToKey(mimeTree);
+ if (!msg) {
+ throw new Error("Failure to encrypt message");
+ }
+ this.cryptoChanged = true;
+ } else {
+ msg = this.mimeToString(mimeTree, true);
+ }
+
+ if (this.decryptFailure) {
+ throw new Error("Failure to decrypt message");
+ }
+ return EnigmailPersistentCrypto.copyMessageToFolder(
+ this.hdr,
+ this.destFolder,
+ this.move,
+ msg,
+ this.cryptoChanged ? "decrypted-" + new Date().valueOf() : null
+ );
+ },
+
+ encryptToKey(mimeTree) {
+ let exitCodeObj = {};
+ let statusFlagsObj = {};
+ let errorMsgObj = {};
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: Encrypting message.\n");
+
+ let inputMsg = this.mimeToString(mimeTree, false);
+
+ let encmsg = "";
+ try {
+ encmsg = lazy.EnigmailEncryption.encryptMessage(
+ null,
+ 0,
+ inputMsg,
+ "0x" + this.targetKey.fpr,
+ "0x" + this.targetKey.fpr,
+ "",
+ lazy.EnigmailConstants.SEND_ENCRYPTED |
+ lazy.EnigmailConstants.SEND_ALWAYS_TRUST,
+ exitCodeObj,
+ statusFlagsObj,
+ errorMsgObj
+ );
+ } catch (ex) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: Encryption failed: " + ex + "\n"
+ );
+ return null;
+ }
+
+ // Build the pgp-encrypted mime structure
+ let msg = "";
+
+ let rfc822Headers = []; // FIXME
+
+ // First the original headers
+ for (let header in rfc822Headers) {
+ if (
+ header != "content-type" &&
+ header != "content-transfer-encoding" &&
+ header != "content-disposition"
+ ) {
+ msg += prettyPrintHeader(header, rfc822Headers[header]) + "\n";
+ }
+ }
+ // Then multipart/encrypted ct
+ let boundary = lazy.EnigmailMime.createBoundary();
+ msg += "Content-Transfer-Encoding: 7Bit\n";
+ msg += "Content-Type: multipart/encrypted; ";
+ msg +=
+ 'boundary="' + boundary + '"; protocol="application/pgp-encrypted"\n\n';
+ msg += "This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)\n";
+
+ // pgp-encrypted part
+ msg += "--" + boundary + "\n";
+ msg += "Content-Type: application/pgp-encrypted\n";
+ msg += "Content-Disposition: attachment\n";
+ msg += "Content-Transfer-Encoding: 7Bit\n\n";
+ msg += "Version: 1\n\n";
+
+ // the octet stream
+ msg += "--" + boundary + "\n";
+ msg += 'Content-Type: application/octet-stream; name="encrypted.asc"\n';
+ msg += "Content-Description: OpenPGP encrypted message\n";
+ msg += 'Content-Disposition: inline; filename="encrypted.asc"\n';
+ msg += "Content-Transfer-Encoding: 7Bit\n\n";
+ msg += encmsg;
+
+ // Bottom boundary
+ msg += "\n--" + boundary + "--\n";
+
+ // Fix up the line endings to be a proper dosish mail
+ msg = msg.replace(/\r/gi, "").replace(/\n/gi, "\r\n");
+
+ return msg;
+ },
+
+ /**
+ * Walk through the MIME message structure and decrypt the body if there is something to decrypt
+ */
+ async decryptMimeTree(mimePart) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: decryptMimeTree:\n");
+
+ if (this.isBrokenByExchange(mimePart)) {
+ this.fixExchangeMessage(mimePart);
+ }
+
+ if (this.isSMIME(mimePart)) {
+ this.decryptSMIME(mimePart);
+ } else if (this.isPgpMime(mimePart)) {
+ this.decryptPGPMIME(mimePart);
+ } else if (isAttachment(mimePart)) {
+ this.pgpDecryptAttachment(mimePart);
+ } else {
+ this.decryptINLINE(mimePart);
+ }
+
+ for (let i in mimePart.subParts) {
+ await this.decryptMimeTree(mimePart.subParts[i]);
+ }
+ },
+
+ /***
+ *
+ * Detect if mime part is PGP/MIME message that got modified by MS-Exchange:
+ *
+ * - multipart/mixed Container with
+ * - application/pgp-encrypted Attachment with name "PGPMIME Version Identification"
+ * - application/octet-stream Attachment with name "encrypted.asc" having the encrypted content in base64
+ * - see:
+ * - https://doesnotexist-openpgp-integration.thunderbird/forum/viewtopic.php?f=4&t=425
+ * - https://sourceforge.net/p/enigmail/forum/support/thread/4add2b69/
+ */
+
+ isBrokenByExchange(mime) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: isBrokenByExchange:\n");
+
+ try {
+ if (
+ mime.subParts &&
+ mime.subParts.length === 3 &&
+ mime.fullContentType.toLowerCase().includes("multipart/mixed") &&
+ mime.subParts[0].subParts.length === 0 &&
+ mime.subParts[0].fullContentType.search(/multipart\/encrypted/i) < 0 &&
+ mime.subParts[0].fullContentType.toLowerCase().includes("text/plain") &&
+ mime.subParts[1].fullContentType
+ .toLowerCase()
+ .includes("application/pgp-encrypted") &&
+ mime.subParts[1].fullContentType
+ .toLowerCase()
+ .search(/multipart\/encrypted/i) < 0 &&
+ mime.subParts[1].fullContentType
+ .toLowerCase()
+ .search(/PGPMIME Versions? Identification/i) >= 0 &&
+ mime.subParts[2].fullContentType
+ .toLowerCase()
+ .includes("application/octet-stream") &&
+ mime.subParts[2].fullContentType.toLowerCase().includes("encrypted.asc")
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: isBrokenByExchange: found message broken by MS-Exchange\n"
+ );
+ return true;
+ }
+ } catch (ex) {}
+
+ return false;
+ },
+
+ decryptSMIME(mimePart) {
+ let encrypted = lazy.MailCryptoUtils.binaryStringToTypedArray(
+ mimePart.body
+ );
+
+ let cmsDecoderJS = Cc["@mozilla.org/nsCMSDecoderJS;1"].createInstance(
+ Ci.nsICMSDecoderJS
+ );
+ let decrypted = cmsDecoderJS.decrypt(encrypted);
+
+ if (decrypted.length === 0) {
+ // fail if no data found
+ this.decryptFailure = true;
+ return;
+ }
+
+ let data = "";
+ for (let c of decrypted) {
+ data += String.fromCharCode(c);
+ }
+
+ if (lazy.EnigmailLog.getLogLevel() > 5) {
+ lazy.EnigmailLog.DEBUG(
+ "*** start data ***\n'" + data + "'\n***end data***\n"
+ );
+ }
+
+ // Search for the separator between headers and message body.
+ let bodyIndex = data.search(/\n\s*\r?\n/);
+ if (bodyIndex < 0) {
+ // not found, body starts at beginning.
+ bodyIndex = 0;
+ } else {
+ // found, body starts after the headers.
+ let wsSize = data.match(/\n\s*\r?\n/);
+ bodyIndex += wsSize[0].length;
+ }
+
+ if (data.substr(bodyIndex).search(/\r?\n$/) === 0) {
+ return;
+ }
+
+ let m = Cc["@mozilla.org/messenger/mimeheaders;1"].createInstance(
+ Ci.nsIMimeHeaders
+ );
+ // headers are found from the beginning up to the start of the body
+ m.initialize(data.substr(0, bodyIndex));
+
+ mimePart.headers._rawHeaders.set("content-type", [
+ m.extractHeader("content-type", false) || "",
+ ]);
+
+ mimePart.headers._rawHeaders.delete("content-transfer-encoding");
+ mimePart.headers._rawHeaders.delete("content-disposition");
+ mimePart.headers._rawHeaders.delete("content-description");
+
+ mimePart.subParts = [];
+ mimePart.body = data.substr(bodyIndex);
+
+ this.cryptoChanged = true;
+ },
+
+ isSMIME(mimePart) {
+ if (!mimePart.headers.has("content-type")) {
+ return false;
+ }
+
+ return (
+ mimePart.headers.get("content-type").type.toLowerCase() ===
+ "application/pkcs7-mime" &&
+ mimePart.headers.get("content-type").get("smime-type").toLowerCase() ===
+ "enveloped-data" &&
+ mimePart.subParts.length === 0
+ );
+ },
+
+ isPgpMime(mimePart) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: isPgpMime()\n");
+
+ try {
+ if (mimePart.headers.has("content-type")) {
+ if (
+ mimePart.headers.get("content-type").type.toLowerCase() ===
+ "multipart/encrypted" &&
+ mimePart.headers.get("content-type").get("protocol").toLowerCase() ===
+ "application/pgp-encrypted" &&
+ mimePart.subParts.length === 2
+ ) {
+ return true;
+ }
+ }
+ } catch (x) {}
+ return false;
+ },
+
+ async decryptPGPMIME(mimePart) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: decryptPGPMIME(" + mimePart.partNum + ")\n"
+ );
+
+ if (!mimePart.subParts[1]) {
+ throw new Error("Not a correct PGP/MIME message");
+ }
+
+ const uiFlags =
+ lazy.EnigmailConstants.UI_INTERACTIVE |
+ lazy.EnigmailConstants.UI_UNVERIFIED_ENC_OK |
+ lazy.EnigmailConstants.UI_IGNORE_MDC_ERROR;
+ let exitCodeObj = {};
+ let statusFlagsObj = {};
+ let userIdObj = {};
+ let sigDetailsObj = {};
+ let errorMsgObj = {};
+ let keyIdObj = {};
+ let blockSeparationObj = {
+ value: "",
+ };
+ let encToDetailsObj = {};
+ var signatureObj = {};
+ signatureObj.value = "";
+
+ let data = lazy.EnigmailDecryption.decryptMessage(
+ null,
+ uiFlags,
+ mimePart.subParts[1].body,
+ null, // date
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj,
+ blockSeparationObj,
+ encToDetailsObj
+ );
+
+ if (!data || data.length === 0) {
+ if (statusFlagsObj.value & lazy.EnigmailConstants.DISPLAY_MESSAGE) {
+ lazy.EnigmailDialog.alert(null, errorMsgObj.value);
+ throw new Error("Decryption impossible");
+ }
+ }
+
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: analyzeDecryptedData: got " +
+ data.length +
+ " bytes\n"
+ );
+
+ if (lazy.EnigmailLog.getLogLevel() > 5) {
+ lazy.EnigmailLog.DEBUG(
+ "*** start data ***\n'" + data + "'\n***end data***\n"
+ );
+ }
+
+ if (data.length === 0) {
+ // fail if no data found
+ this.decryptFailure = true;
+ return;
+ }
+
+ let bodyIndex = data.search(/\n\s*\r?\n/);
+ if (bodyIndex < 0) {
+ bodyIndex = 0;
+ } else {
+ let wsSize = data.match(/\n\s*\r?\n/);
+ bodyIndex += wsSize[0].length;
+ }
+
+ if (data.substr(bodyIndex).search(/\r?\n$/) === 0) {
+ return;
+ }
+
+ let m = Cc["@mozilla.org/messenger/mimeheaders;1"].createInstance(
+ Ci.nsIMimeHeaders
+ );
+ m.initialize(data.substr(0, bodyIndex));
+ let ct = m.extractHeader("content-type", false) || "";
+ let part = mimePart.partNum;
+
+ if (part.length > 0 && part.search(/[^01.]/) < 0) {
+ if (ct.search(/protected-headers/i) >= 0) {
+ if (m.hasHeader("subject")) {
+ let subject = m.extractHeader("subject", false) || "";
+ subject = subject.replace(/^(Re: )+/, "Re: ");
+ this.mimeTree.headers._rawHeaders.set("subject", [subject]);
+ }
+ } else if (this.mimeTree.headers.get("subject") === "p≡p") {
+ let subject = getPepSubject(data);
+ if (subject) {
+ subject = subject.replace(/^(Re: )+/, "Re: ");
+ this.mimeTree.headers._rawHeaders.set("subject", [subject]);
+ }
+ } else if (
+ !(statusFlagsObj.value & lazy.EnigmailConstants.GOOD_SIGNATURE) &&
+ /^multipart\/signed/i.test(ct)
+ ) {
+ // RFC 3156, Section 6.1 message
+ let innerMsg = lazy.EnigmailMime.getMimeTree(data, false);
+ if (innerMsg.subParts.length > 0) {
+ ct = innerMsg.subParts[0].fullContentType;
+ let hdrMap = innerMsg.subParts[0].headers._rawHeaders;
+ if (ct.search(/protected-headers/i) >= 0 && hdrMap.has("subject")) {
+ let subject = innerMsg.subParts[0].headers._rawHeaders
+ .get("subject")
+ .join("");
+ subject = subject.replace(/^(Re: )+/, "Re: ");
+ this.mimeTree.headers._rawHeaders.set("subject", [subject]);
+ }
+ }
+ }
+ }
+
+ let boundary = getBoundary(mimePart);
+ if (!boundary) {
+ boundary = lazy.EnigmailMime.createBoundary();
+ }
+
+ // append relevant headers
+ mimePart.headers.get("content-type").type = "multipart/mixed";
+ mimePart.headers._rawHeaders.set("content-type", [
+ 'multipart/mixed; boundary="' + boundary + '"',
+ ]);
+ mimePart.subParts = [
+ {
+ body: data,
+ decryptedPgpMime: true,
+ partNum: mimePart.partNum + ".1",
+ headers: {
+ _rawHeaders: new Map(),
+ get() {
+ return null;
+ },
+ has() {
+ return false;
+ },
+ },
+ subParts: [],
+ },
+ ];
+
+ this.cryptoChanged = true;
+ },
+
+ pgpDecryptAttachment(mimePart) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: pgpDecryptAttachment()\n");
+ let attachmentHead = mimePart.body.substr(0, 30);
+ if (attachmentHead.search(/-----BEGIN PGP \w{5,10} KEY BLOCK-----/) >= 0) {
+ // attachment appears to be a PGP key file, skip
+ return;
+ }
+
+ const uiFlags =
+ lazy.EnigmailConstants.UI_INTERACTIVE |
+ lazy.EnigmailConstants.UI_UNVERIFIED_ENC_OK |
+ lazy.EnigmailConstants.UI_IGNORE_MDC_ERROR;
+ let exitCodeObj = {};
+ let statusFlagsObj = {};
+ let userIdObj = {};
+ let sigDetailsObj = {};
+ let errorMsgObj = {};
+ let keyIdObj = {};
+ let blockSeparationObj = {
+ value: "",
+ };
+ let encToDetailsObj = {};
+ var signatureObj = {};
+ signatureObj.value = "";
+
+ let attachmentName = getAttachmentName(mimePart);
+ attachmentName = attachmentName
+ ? attachmentName.replace(/\.(pgp|asc|gpg)$/, "")
+ : "";
+
+ let data = lazy.EnigmailDecryption.decryptMessage(
+ null,
+ uiFlags,
+ mimePart.body,
+ null, // date
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj,
+ blockSeparationObj,
+ encToDetailsObj
+ );
+
+ if (data || statusFlagsObj.value & lazy.EnigmailConstants.DECRYPTION_OKAY) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: pgpDecryptAttachment: decryption OK\n"
+ );
+ } else if (
+ statusFlagsObj.value &
+ (lazy.EnigmailConstants.DECRYPTION_FAILED |
+ lazy.EnigmailConstants.MISSING_MDC)
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: pgpDecryptAttachment: decryption without MDC protection\n"
+ );
+ } else if (
+ statusFlagsObj.value & lazy.EnigmailConstants.DECRYPTION_FAILED
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: pgpDecryptAttachment: decryption failed\n"
+ );
+ // Enigmail prompts the user here, but we just keep going.
+ } else if (
+ statusFlagsObj.value & lazy.EnigmailConstants.DECRYPTION_INCOMPLETE
+ ) {
+ // failure; message not complete
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: pgpDecryptAttachment: decryption incomplete\n"
+ );
+ return;
+ } else {
+ // there is nothing to be decrypted
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: pgpDecryptAttachment: no decryption required\n"
+ );
+ return;
+ }
+
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: pgpDecryptAttachment: decrypted to " +
+ data.length +
+ " bytes\n"
+ );
+ if (statusFlagsObj.encryptedFileName) {
+ attachmentName = statusFlagsObj.encryptedFileName;
+ }
+
+ this.decryptedMessage = true;
+ mimePart.body = data;
+ mimePart.headers._rawHeaders.set(
+ "content-disposition",
+ `attachment; filename="${attachmentName}"`
+ );
+ mimePart.headers._rawHeaders.set("content-transfer-encoding", ["base64"]);
+ let origCt = mimePart.headers.get("content-type");
+ let ct = origCt.type;
+
+ for (let i of origCt.entries()) {
+ if (i[0].toLowerCase() === "name") {
+ i[1] = i[1].replace(/\.(pgp|asc|gpg)$/, "");
+ }
+ ct += `; ${i[0]}="${i[1]}"`;
+ }
+
+ mimePart.headers._rawHeaders.set("content-type", [ct]);
+ },
+
+ async decryptINLINE(mimePart) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: decryptINLINE()\n");
+
+ if ("decryptedPgpMime" in mimePart && mimePart.decryptedPgpMime) {
+ return 0;
+ }
+
+ if ("body" in mimePart && mimePart.body.length > 0) {
+ let ct = getContentType(mimePart);
+
+ if (ct === "text/html") {
+ mimePart.body = this.stripHTMLFromArmoredBlocks(mimePart.body);
+ }
+
+ var exitCodeObj = {};
+ var statusFlagsObj = {};
+ var userIdObj = {};
+ var sigDetailsObj = {};
+ var errorMsgObj = {};
+ var keyIdObj = {};
+ var blockSeparationObj = {
+ value: "",
+ };
+ var encToDetailsObj = {};
+ var signatureObj = {};
+ signatureObj.value = "";
+
+ const uiFlags =
+ lazy.EnigmailConstants.UI_INTERACTIVE |
+ lazy.EnigmailConstants.UI_UNVERIFIED_ENC_OK |
+ lazy.EnigmailConstants.UI_IGNORE_MDC_ERROR;
+
+ var plaintexts = [];
+ var blocks = lazy.EnigmailArmor.locateArmoredBlocks(mimePart.body);
+ var tmp = [];
+
+ for (let i = 0; i < blocks.length; i++) {
+ if (blocks[i].blocktype == "MESSAGE") {
+ tmp.push(blocks[i]);
+ }
+ }
+
+ blocks = tmp;
+
+ if (blocks.length < 1) {
+ return 0;
+ }
+
+ let charset = "utf-8";
+
+ for (let i = 0; i < blocks.length; i++) {
+ let plaintext = null;
+ do {
+ let ciphertext = mimePart.body.substring(
+ blocks[i].begin,
+ blocks[i].end + 1
+ );
+
+ if (ciphertext.length === 0) {
+ break;
+ }
+
+ let hdr = ciphertext.search(/(\r\r|\n\n|\r\n\r\n)/);
+ if (hdr > 0) {
+ let chset = ciphertext.substr(0, hdr).match(/^(charset:)(.*)$/im);
+ if (chset && chset.length == 3) {
+ charset = chset[2].trim();
+ }
+ }
+ plaintext = lazy.EnigmailDecryption.decryptMessage(
+ null,
+ uiFlags,
+ ciphertext,
+ null, // date
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj,
+ blockSeparationObj,
+ encToDetailsObj
+ );
+ if (!plaintext || plaintext.length === 0) {
+ if (statusFlagsObj.value & lazy.EnigmailConstants.DISPLAY_MESSAGE) {
+ lazy.EnigmailDialog.alert(null, errorMsgObj.value);
+ this.cryptoChanged = false;
+ this.decryptFailure = true;
+ return -1;
+ }
+
+ if (
+ statusFlagsObj.value &
+ (lazy.EnigmailConstants.DECRYPTION_FAILED |
+ lazy.EnigmailConstants.MISSING_MDC)
+ ) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: decryptINLINE: no MDC protection, decrypting anyway\n"
+ );
+ }
+ if (
+ statusFlagsObj.value & lazy.EnigmailConstants.DECRYPTION_FAILED
+ ) {
+ // since we cannot find out if the user wants to cancel
+ // we should ask
+ let msg = await lazy.l10n.formatValue(
+ "converter-decrypt-body-failed",
+ {
+ subject: this.subject,
+ }
+ );
+
+ if (
+ !lazy.EnigmailDialog.confirmDlg(
+ null,
+ msg,
+ lazy.l10n.formatValueSync("dlg-button-retry"),
+ lazy.l10n.formatValueSync("dlg-button-skip")
+ )
+ ) {
+ this.cryptoChanged = false;
+ this.decryptFailure = true;
+ return -1;
+ }
+ } else if (
+ statusFlagsObj.value &
+ lazy.EnigmailConstants.DECRYPTION_INCOMPLETE
+ ) {
+ this.cryptoChanged = false;
+ this.decryptFailure = true;
+ return -1;
+ } else {
+ plaintext = " ";
+ }
+ }
+
+ if (ct === "text/html") {
+ plaintext = plaintext.replace(/\n/gi, "<br/>\n");
+ }
+
+ let subject = "";
+ if (this.mimeTree.headers.has("subject")) {
+ subject = this.mimeTree.headers.get("subject");
+ }
+
+ if (
+ i == 0 &&
+ subject === "pEp" &&
+ mimePart.partNum.length > 0 &&
+ mimePart.partNum.search(/[^01.]/) < 0
+ ) {
+ let m = lazy.EnigmailMime.extractSubjectFromBody(plaintext);
+ if (m) {
+ plaintext = m.messageBody;
+ this.mimeTree.headers._rawHeaders.set("subject", [m.subject]);
+ }
+ }
+
+ if (plaintext) {
+ plaintexts.push(plaintext);
+ }
+ } while (!plaintext || plaintext === "");
+ }
+
+ var decryptedMessage =
+ mimePart.body.substring(0, blocks[0].begin) + plaintexts[0];
+ for (let i = 1; i < blocks.length; i++) {
+ decryptedMessage +=
+ mimePart.body.substring(blocks[i - 1].end + 1, blocks[i].begin + 1) +
+ plaintexts[i];
+ }
+
+ decryptedMessage += mimePart.body.substring(
+ blocks[blocks.length - 1].end + 1
+ );
+
+ // enable base64 encoding if non-ASCII character(s) found
+ let j = decryptedMessage.search(/[^\x01-\x7F]/); // eslint-disable-line no-control-regex
+ if (j >= 0) {
+ mimePart.headers._rawHeaders.set("content-transfer-encoding", [
+ "base64",
+ ]);
+ } else {
+ mimePart.headers._rawHeaders.set("content-transfer-encoding", ["8bit"]);
+ }
+ mimePart.body = decryptedMessage;
+
+ let origCharset = getCharset(mimePart, "content-type");
+ if (origCharset) {
+ mimePart.headers_rawHeaders.set(
+ "content-type",
+ getHeaderValue(mimePart, "content-type").replace(origCharset, charset)
+ );
+ } else {
+ mimePart.headers._rawHeaders.set(
+ "content-type",
+ getHeaderValue(mimePart, "content-type") + "; charset=" + charset
+ );
+ }
+
+ this.cryptoChanged = true;
+ return 1;
+ }
+
+ let ct = getContentType(mimePart);
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: Decryption skipped: " + ct + "\n"
+ );
+
+ return 0;
+ },
+
+ stripHTMLFromArmoredBlocks(text) {
+ var index = 0;
+ var begin = text.indexOf("-----BEGIN PGP");
+ var end = text.indexOf("-----END PGP");
+
+ while (begin > -1 && end > -1) {
+ let sub = text.substring(begin, end);
+
+ sub = sub.replace(/(<([^>]+)>)/gi, "");
+ sub = sub.replace(/&[A-z]+;/gi, "");
+
+ text = text.substring(0, begin) + sub + text.substring(end);
+
+ index = end + 10;
+ begin = text.indexOf("-----BEGIN PGP", index);
+ end = text.indexOf("-----END PGP", index);
+ }
+
+ return text;
+ },
+
+ /******
+ *
+ * We have the technology we can rebuild.
+ *
+ * Function to reassemble the message from the MIME Tree
+ * into a String.
+ *
+ ******/
+
+ mimeToString(mimePart, includeHeaders) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: mimeToString: part: '" + mimePart.partNum + "'\n"
+ );
+
+ let msg = "";
+ let rawHdr = mimePart.headers._rawHeaders;
+
+ if (includeHeaders && rawHdr.size > 0) {
+ for (let hdr of rawHdr.keys()) {
+ let formatted = formatMimeHeader(hdr, rawHdr.get(hdr));
+ msg += formatted;
+ if (!formatted.endsWith("\r\n")) {
+ msg += "\r\n";
+ }
+ }
+
+ msg += "\r\n";
+ }
+
+ if (mimePart.body.length > 0) {
+ let encoding = getTransferEncoding(mimePart);
+ if (!encoding) {
+ encoding = "8bit";
+ }
+
+ if (encoding === "base64") {
+ msg += lazy.EnigmailData.encodeBase64(mimePart.body);
+ } else {
+ let charset = getCharset(mimePart, "content-type");
+ if (charset) {
+ msg += lazy.EnigmailData.convertFromUnicode(mimePart.body, charset);
+ } else {
+ msg += mimePart.body;
+ }
+ }
+ }
+
+ if (mimePart.subParts.length > 0) {
+ let boundary = lazy.EnigmailMime.getBoundary(
+ rawHdr.get("content-type").join("")
+ );
+
+ for (let i in mimePart.subParts) {
+ msg += `--${boundary}\r\n`;
+ msg += this.mimeToString(mimePart.subParts[i], true);
+ if (msg.search(/[\r\n]$/) < 0) {
+ msg += "\r\n";
+ }
+ msg += "\r\n";
+ }
+
+ msg += `--${boundary}--\r\n`;
+ }
+ return msg;
+ },
+
+ fixExchangeMessage(mimePart) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: fixExchangeMessage()\n");
+
+ let msg = this.mimeToString(mimePart, true);
+
+ try {
+ let fixedMsg = lazy.EnigmailFixExchangeMsg.getRepairedMessage(msg);
+ let replacement = lazy.EnigmailMime.getMimeTree(fixedMsg, true);
+
+ for (let i in replacement) {
+ mimePart[i] = replacement[i];
+ }
+ } catch (ex) {}
+ },
+};
+
+/**
+ * Format a mime header
+ *
+ * e.g. content-type -> Content-Type
+ */
+
+function formatHeader(headerLabel) {
+ return headerLabel.replace(/^.|(-.)/g, function (match) {
+ return match.toUpperCase();
+ });
+}
+
+function formatMimeHeader(headerLabel, headerValue) {
+ if (Array.isArray(headerValue)) {
+ return headerValue
+ .map(v => formatHeader(headerLabel) + ": " + v)
+ .join("\r\n");
+ }
+ return formatHeader(headerLabel) + ": " + headerValue + "\r\n";
+}
+
+function prettyPrintHeader(headerLabel, headerData) {
+ if (Array.isArray(headerData)) {
+ let h = [];
+ for (let i in headerData) {
+ h.push(
+ formatMimeHeader(headerLabel, lazy.GlodaUtils.deMime(headerData[i]))
+ );
+ }
+ return h.join("\r\n");
+ }
+ return formatMimeHeader(
+ headerLabel,
+ lazy.GlodaUtils.deMime(String(headerData))
+ );
+}
+
+function getHeaderValue(mimeStruct, header) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: getHeaderValue: '" + header + "'\n"
+ );
+
+ try {
+ if (mimeStruct.headers.has(header)) {
+ let hdrVal = mimeStruct.headers.get(header);
+ if (typeof hdrVal == "string") {
+ return hdrVal;
+ }
+ return mimeStruct.headers[header].join(" ");
+ }
+ return "";
+ } catch (ex) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: getHeaderValue: header not present\n"
+ );
+ return "";
+ }
+}
+
+function getContentType(mime) {
+ try {
+ if (mime && "headers" in mime && mime.headers.has("content-type")) {
+ return mime.headers.get("content-type").type.toLowerCase();
+ }
+ } catch (e) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: getContentType: " + e + "\n");
+ }
+ return null;
+}
+
+// return the content of the boundary parameter
+function getBoundary(mime) {
+ try {
+ if (mime && "headers" in mime && mime.headers.has("content-type")) {
+ return mime.headers.get("content-type").get("boundary");
+ }
+ } catch (e) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: getBoundary: " + e + "\n");
+ }
+ return null;
+}
+
+function getCharset(mime) {
+ try {
+ if (mime && "headers" in mime && mime.headers.has("content-type")) {
+ let c = mime.headers.get("content-type").get("charset");
+ if (c) {
+ return c.toLowerCase();
+ }
+ }
+ } catch (e) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: getCharset: " + e + "\n");
+ }
+ return null;
+}
+
+function getTransferEncoding(mime) {
+ try {
+ if (
+ mime &&
+ "headers" in mime &&
+ mime.headers._rawHeaders.has("content-transfer-encoding")
+ ) {
+ let c = mime.headers._rawHeaders.get("content-transfer-encoding")[0];
+ if (c) {
+ return c.toLowerCase();
+ }
+ }
+ } catch (e) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: getTransferEncoding: " + e + "\n"
+ );
+ }
+ return "8Bit";
+}
+
+function isAttachment(mime) {
+ try {
+ if (mime && "headers" in mime) {
+ if (mime.fullContentType.search(/^multipart\//i) === 0) {
+ return false;
+ }
+ if (mime.fullContentType.search(/^text\//i) < 0) {
+ return true;
+ }
+
+ if (mime.headers.has("content-disposition")) {
+ let c = mime.headers.get("content-disposition")[0];
+ if (c) {
+ if (c.search(/^attachment/i) === 0) {
+ return true;
+ }
+ }
+ }
+ }
+ } catch (x) {}
+ return false;
+}
+
+/**
+ * If the given MIME part is an attachment, return its filename.
+ *
+ * @param mime: a MIME part
+ * @return: the filename or null
+ */
+function getAttachmentName(mime) {
+ if ("headers" in mime && mime.headers.has("content-disposition")) {
+ let c = mime.headers.get("content-disposition")[0];
+ if (/^attachment/i.test(c)) {
+ return lazy.EnigmailMime.getParameter(c, "filename");
+ }
+ }
+ return null;
+}
+
+function getPepSubject(mimeString) {
+ lazy.EnigmailLog.DEBUG("persistentCrypto.jsm: getPepSubject()\n");
+
+ let subject = null;
+
+ let emitter = {
+ ct: "",
+ firstPlainText: false,
+ startPart(partNum, headers) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: getPepSubject.startPart: partNum=" +
+ partNum +
+ "\n"
+ );
+ try {
+ this.ct = String(headers.getRawHeader("content-type")).toLowerCase();
+ if (!subject && !this.firstPlainText) {
+ let s = headers.getRawHeader("subject");
+ if (s) {
+ subject = String(s);
+ this.firstPlainText = true;
+ }
+ }
+ } catch (ex) {
+ this.ct = "";
+ }
+ },
+
+ endPart(partNum) {},
+
+ deliverPartData(partNum, data) {
+ lazy.EnigmailLog.DEBUG(
+ "persistentCrypto.jsm: getPepSubject.deliverPartData: partNum=" +
+ partNum +
+ " ct=" +
+ this.ct +
+ "\n"
+ );
+ if (!this.firstPlainText && this.ct.search(/^text\/plain/) === 0) {
+ // check data
+ this.firstPlainText = true;
+
+ let o = lazy.EnigmailMime.extractSubjectFromBody(data);
+ if (o) {
+ subject = o.subject;
+ }
+ }
+ },
+ };
+
+ let opt = {
+ strformat: "unicode",
+ bodyformat: "decode",
+ };
+
+ try {
+ let p = new lazy.jsmime.MimeParser(emitter, opt);
+ p.deliverData(mimeString);
+ } catch (ex) {}
+
+ return subject;
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/pgpmimeHandler.jsm b/comm/mail/extensions/openpgp/content/modules/pgpmimeHandler.jsm
new file mode 100644
index 0000000000..7c16489aa5
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/pgpmimeHandler.jsm
@@ -0,0 +1,299 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Module for handling PGP/MIME encrypted and/or signed messages
+ * implemented as an XPCOM object
+ */
+
+const EXPORTED_SYMBOLS = ["EnigmailPgpmimeHander"];
+
+const { manager: Cm } = Components;
+Cm.QueryInterface(Ci.nsIComponentRegistrar);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+ EnigmailMimeDecrypt: "chrome://openpgp/content/modules/mimeDecrypt.jsm",
+ EnigmailSingletons: "chrome://openpgp/content/modules/singletons.jsm",
+ EnigmailVerify: "chrome://openpgp/content/modules/mimeVerify.jsm",
+ EnigmailWksMimeHandler: "chrome://openpgp/content/modules/wksMimeHandler.jsm",
+});
+
+const PGPMIME_JS_DECRYPTOR_CONTRACTID =
+ "@mozilla.org/mime/pgp-mime-js-decrypt;1";
+const PGPMIME_JS_DECRYPTOR_CID = Components.ID(
+ "{7514cbeb-2bfd-4b2c-829b-1a4691fa0ac8}"
+);
+
+////////////////////////////////////////////////////////////////////
+// handler for PGP/MIME encrypted and PGP/MIME signed messages
+// data is processed from libmime -> nsPgpMimeProxy
+
+var gConv;
+var inStream;
+
+var gLastEncryptedUri = "";
+
+const throwErrors = {
+ onDataAvailable() {
+ throw new Error("error");
+ },
+ onStartRequest() {
+ throw new Error("error");
+ },
+ onStopRequest() {
+ throw new Error("error");
+ },
+};
+
+/**
+ * UnknownProtoHandler is a default handler for unknown protocols. It ensures that the
+ * signed message part is always displayed without any further action.
+ */
+function UnknownProtoHandler() {
+ if (!gConv) {
+ gConv = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ }
+
+ if (!inStream) {
+ inStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ }
+}
+
+UnknownProtoHandler.prototype = {
+ onStartRequest(request, ctxt) {
+ this.mimeSvc = request.QueryInterface(Ci.nsIPgpMimeProxy);
+ if (!("outputDecryptedData" in this.mimeSvc)) {
+ this.mimeSvc.onStartRequest(null, ctxt);
+ }
+ this.bound = lazy.EnigmailMime.getBoundary(this.mimeSvc.contentType);
+ /*
+ readMode:
+ 0: before message
+ 1: inside message
+ 2: after message
+ */
+ this.readMode = 0;
+ },
+
+ onDataAvailable(p1, p2, p3, p4) {
+ this.processData(p1, p2, p3, p4);
+ },
+
+ processData(req, stream, offset, count) {
+ if (count > 0) {
+ inStream.init(stream);
+ let data = inStream.read(count);
+ let l = data.replace(/\r\n/g, "\n").split(/\n/);
+
+ if (data.search(/\n$/) >= 0) {
+ l.pop();
+ }
+
+ let startIndex = 0;
+ let endIndex = l.length;
+
+ if (this.readMode < 2) {
+ for (let i = 0; i < l.length; i++) {
+ if (l[i].indexOf("--") === 0 && l[i].indexOf(this.bound) === 2) {
+ ++this.readMode;
+ if (this.readMode === 1) {
+ startIndex = i + 1;
+ } else if (this.readMode === 2) {
+ endIndex = i - 1;
+ }
+ }
+ }
+
+ if (this.readMode >= 1 && startIndex < l.length) {
+ let out = l.slice(startIndex, endIndex).join("\n") + "\n";
+
+ if ("outputDecryptedData" in this.mimeSvc) {
+ // TB >= 57
+ this.mimeSvc.outputDecryptedData(out, out.length);
+ } else {
+ gConv.setData(out, out.length);
+ this.mimeSvc.onDataAvailable(null, null, gConv, 0, out.length);
+ }
+ }
+ }
+ }
+ },
+
+ onStopRequest() {
+ if (!("outputDecryptedData" in this.mimeSvc)) {
+ this.mimeSvc.onStopRequest(null, 0);
+ }
+ },
+};
+
+function PgpMimeHandler() {
+ lazy.EnigmailLog.DEBUG("pgpmimeHandler.js: PgpMimeHandler()\n"); // always log this one
+}
+
+PgpMimeHandler.prototype = {
+ classDescription: "Enigmail JS Decryption Handler",
+ classID: PGPMIME_JS_DECRYPTOR_CID,
+ contractID: PGPMIME_JS_DECRYPTOR_CONTRACTID,
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+ inStream: Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ ),
+
+ onStartRequest(request, ctxt) {
+ let mimeSvc = request.QueryInterface(Ci.nsIPgpMimeProxy);
+ let ct = mimeSvc.contentType;
+
+ let uri = null;
+ if ("messageURI" in mimeSvc) {
+ uri = mimeSvc.messageURI;
+ } else {
+ uri = ctxt;
+ }
+
+ if (!lazy.EnigmailCore.getService()) {
+ // Ensure Enigmail is initialized
+ if (ct.search(/application\/(x-)?pkcs7-signature/i) > 0) {
+ return this.handleSmime(uri);
+ }
+ return null;
+ }
+
+ lazy.EnigmailLog.DEBUG("pgpmimeHandler.js: onStartRequest\n");
+ lazy.EnigmailLog.DEBUG("pgpmimeHandler.js: ct= " + ct + "\n");
+
+ let cth = null;
+
+ if (ct.search(/^multipart\/encrypted/i) === 0) {
+ if (uri) {
+ let u = uri.QueryInterface(Ci.nsIURI);
+ gLastEncryptedUri = u.spec;
+ }
+ // PGP/MIME encrypted message
+
+ cth = lazy.EnigmailMimeDecrypt.newPgpMimeHandler();
+ } else if (ct.search(/^multipart\/signed/i) === 0) {
+ if (ct.search(/application\/pgp-signature/i) > 0) {
+ // PGP/MIME signed message
+ cth = lazy.EnigmailVerify.newVerifier();
+ } else if (ct.search(/application\/(x-)?pkcs7-signature/i) > 0) {
+ let lastUriSpec = "";
+ if (uri) {
+ let u = uri.QueryInterface(Ci.nsIURI);
+ lastUriSpec = u.spec;
+ }
+ // S/MIME signed message
+ if (
+ lastUriSpec !== gLastEncryptedUri &&
+ lazy.EnigmailVerify.lastWindow
+ ) {
+ // if message is displayed then handle like S/MIME message
+ return this.handleSmime(uri);
+ }
+
+ // otherwise just make sure message body is returned
+ cth = lazy.EnigmailVerify.newVerifier(
+ "application/(x-)?pkcs7-signature"
+ );
+ }
+ } else if (ct.search(/application\/vnd.gnupg.wks/i) === 0) {
+ cth = lazy.EnigmailWksMimeHandler.newHandler();
+ }
+
+ if (!cth) {
+ lazy.EnigmailLog.ERROR(
+ "pgpmimeHandler.js: unknown protocol for content-type: " + ct + "\n"
+ );
+ cth = new UnknownProtoHandler();
+ }
+
+ if (cth) {
+ this.onDataAvailable = cth.onDataAvailable.bind(cth);
+ this.onStopRequest = cth.onStopRequest.bind(cth);
+ return cth.onStartRequest(request, uri);
+ }
+
+ return null;
+ },
+
+ onDataAvailable(req, stream, offset, count) {},
+
+ onStopRequest(request, status) {},
+
+ handleSmime(uri) {
+ this.contentHandler = throwErrors;
+
+ if (uri) {
+ uri = uri.QueryInterface(Ci.nsIURI);
+ }
+
+ let headerSink = lazy.EnigmailSingletons.messageReader;
+ headerSink?.handleSMimeMessage(uri);
+ },
+
+ getMessengerWindow() {
+ let windowManager = Services.wm;
+
+ for (let win of windowManager.getEnumerator(null)) {
+ if (win.location.href.search(/\/messenger.xhtml$/) > 0) {
+ return win;
+ }
+ }
+
+ return null;
+ },
+};
+
+class Factory {
+ constructor(component) {
+ this.component = component;
+ this.register();
+ Object.freeze(this);
+ }
+
+ createInstance(iid) {
+ return new this.component();
+ }
+
+ register() {
+ Cm.registerFactory(
+ this.component.prototype.classID,
+ this.component.prototype.classDescription,
+ this.component.prototype.contractID,
+ this
+ );
+ }
+
+ unregister() {
+ Cm.unregisterFactory(this.component.prototype.classID, this);
+ }
+}
+
+var EnigmailPgpmimeHander = {
+ startup(reason) {
+ try {
+ this.factory = new Factory(PgpMimeHandler);
+ } catch (ex) {}
+ },
+
+ shutdown(reason) {
+ if (this.factory) {
+ this.factory.unregister();
+ }
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/singletons.jsm b/comm/mail/extensions/openpgp/content/modules/singletons.jsm
new file mode 100644
index 0000000000..eb1d6f45df
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/singletons.jsm
@@ -0,0 +1,54 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailSingletons"];
+
+var EnigmailSingletons = {
+ // handle to most recent message reader window
+ messageReader: null,
+
+ // information about the last PGP/MIME decrypted message (mimeDecrypt)
+ lastDecryptedMessage: {},
+ lastMessageDecryptTime: 0,
+
+ clearLastDecryptedMessage() {
+ let lm = this.lastDecryptedMessage;
+ lm.lastMessageData = "";
+ lm.lastMessageURI = null;
+ lm.mimePartNumber = "";
+ lm.lastStatus = {};
+ lm.gossip = [];
+ },
+
+ isLastDecryptedMessagePart(folder, msgNum, mimePartNumber) {
+ let reval =
+ this.lastDecryptedMessage.lastMessageURI &&
+ this.lastDecryptedMessage.lastMessageURI.folder == folder &&
+ this.lastDecryptedMessage.lastMessageURI.msgNum == msgNum &&
+ this.lastDecryptedMessage.mimePartNumber == mimePartNumber;
+ return reval;
+ },
+
+ urisWithNestedEncryptedParts: [],
+
+ maxRecentSubEncryptionUrisToRemember: 10,
+
+ addUriWithNestedEncryptedPart(uri) {
+ if (
+ this.urisWithNestedEncryptedParts.length >
+ this.maxRecentSubEncryptionUrisToRemember
+ ) {
+ this.urisWithNestedEncryptedParts.shift(); // remove oldest
+ }
+ this.urisWithNestedEncryptedParts.push(uri);
+ },
+
+ isRecentUriWithNestedEncryptedPart(uri) {
+ return this.urisWithNestedEncryptedParts.includes(uri);
+ },
+};
+
+EnigmailSingletons.clearLastDecryptedMessage();
diff --git a/comm/mail/extensions/openpgp/content/modules/sqliteDb.jsm b/comm/mail/extensions/openpgp/content/modules/sqliteDb.jsm
new file mode 100644
index 0000000000..29f8b9c0b8
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/sqliteDb.jsm
@@ -0,0 +1,477 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Module that provides generic functions for the Enigmail SQLite database
+ */
+
+const EXPORTED_SYMBOLS = ["PgpSqliteDb2"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+});
+
+var PgpSqliteDb2 = {
+ openDatabase() {
+ lazy.EnigmailLog.DEBUG("sqliteDb.jsm: PgpSqliteDb2 openDatabase()\n");
+ return new Promise((resolve, reject) => {
+ openDatabaseConn(
+ "openpgp.sqlite",
+ resolve,
+ reject,
+ 100,
+ Date.now() + 10000
+ );
+ });
+ },
+
+ async checkDatabaseStructure() {
+ lazy.EnigmailLog.DEBUG(
+ `sqliteDb.jsm: PgpSqliteDb2 checkDatabaseStructure()\n`
+ );
+ let conn;
+ try {
+ conn = await this.openDatabase();
+ await checkAcceptanceTable(conn);
+ await conn.close();
+ lazy.EnigmailLog.DEBUG(
+ `sqliteDb.jsm: PgpSqliteDb2 checkDatabaseStructure - success\n`
+ );
+ } catch (ex) {
+ lazy.EnigmailLog.ERROR(
+ `sqliteDb.jsm: PgpSqliteDb2 checkDatabaseStructure: ERROR: ${ex}\n`
+ );
+ if (conn) {
+ await conn.close();
+ }
+ throw ex;
+ }
+ },
+
+ accCacheFingerprint: "",
+ accCacheValue: "",
+ accCacheEmails: null,
+
+ async getFingerprintAcceptance(conn, fingerprint) {
+ // 40 is for modern fingerprints, 32 for older fingerprints.
+ if (fingerprint.length != 40 && fingerprint.length != 32) {
+ throw new Error(
+ "internal error, invalid fingerprint value: " + fingerprint
+ );
+ }
+
+ fingerprint = fingerprint.toLowerCase();
+ if (fingerprint == this.accCacheFingerprint) {
+ return this.accCacheValue;
+ }
+
+ let myConn = false;
+ let rv = "";
+
+ try {
+ if (!conn) {
+ myConn = true;
+ conn = await this.openDatabase();
+ }
+
+ await conn
+ .execute("select decision from acceptance_decision where fpr = :fpr", {
+ fpr: fingerprint,
+ })
+ .then(result => {
+ if (result.length) {
+ rv = result[0].getResultByName("decision");
+ }
+ });
+ } catch (ex) {
+ console.debug(ex);
+ }
+
+ if (myConn && conn) {
+ await conn.close();
+ }
+ return rv;
+ },
+
+ async hasAnyPositivelyAcceptedKeyForEmail(email) {
+ email = email.toLowerCase();
+ let count = 0;
+
+ let conn;
+ try {
+ conn = await this.openDatabase();
+
+ let result = await conn.execute(
+ "select count(decision) as hits from acceptance_email" +
+ " inner join acceptance_decision on" +
+ " acceptance_decision.fpr = acceptance_email.fpr" +
+ " where (decision = 'verified' or decision = 'unverified')" +
+ " and lower(email) = :email",
+ { email }
+ );
+ if (result.length) {
+ count = result[0].getResultByName("hits");
+ }
+ await conn.close();
+ } catch (ex) {
+ if (conn) {
+ await conn.close();
+ }
+ throw ex;
+ }
+
+ if (!count) {
+ return Boolean(await lazy.EnigmailKeyRing.getSecretKeyByEmail(email));
+ }
+ return true;
+ },
+
+ async getAcceptance(fingerprint, email, rv) {
+ fingerprint = fingerprint.toLowerCase();
+ email = email.toLowerCase();
+
+ rv.emailDecided = false;
+ rv.fingerprintAcceptance = "";
+
+ if (fingerprint == this.accCacheFingerprint) {
+ if (
+ this.accCacheValue.length &&
+ this.accCacheValue != "undecided" &&
+ this.accCacheEmails &&
+ this.accCacheEmails.has(email)
+ ) {
+ rv.emailDecided = true;
+ rv.fingerprintAcceptance = this.accCacheValue;
+ }
+ return;
+ }
+
+ let conn;
+ try {
+ conn = await this.openDatabase();
+
+ rv.fingerprintAcceptance = await this.getFingerprintAcceptance(
+ conn,
+ fingerprint
+ );
+
+ if (rv.fingerprintAcceptance) {
+ await conn
+ .execute(
+ "select count(*) from acceptance_email where fpr = :fpr and email = :email",
+ {
+ fpr: fingerprint,
+ email,
+ }
+ )
+ .then(result => {
+ if (result.length) {
+ let count = result[0].getResultByName("count(*)");
+ rv.emailDecided = count > 0;
+ }
+ });
+ }
+ await conn.close();
+ } catch (ex) {
+ if (conn) {
+ await conn.close();
+ }
+ throw ex;
+ }
+ },
+
+ // fingerprint must be lowercase already
+ async internalDeleteAcceptanceNoTransaction(conn, fingerprint) {
+ let delObj = { fpr: fingerprint };
+ await conn.execute(
+ "delete from acceptance_decision where fpr = :fpr",
+ delObj
+ );
+ await conn.execute("delete from acceptance_email where fpr = :fpr", delObj);
+ },
+
+ async deleteAcceptance(fingerprint) {
+ fingerprint = fingerprint.toLowerCase();
+ this.accCacheFingerprint = fingerprint;
+ this.accCacheValue = "";
+ this.accCacheEmails = null;
+ let conn;
+ try {
+ conn = await this.openDatabase();
+ await conn.execute("begin transaction");
+ await this.internalDeleteAcceptanceNoTransaction(conn, fingerprint);
+ await conn.execute("commit transaction");
+ await conn.close();
+ Services.obs.notifyObservers(null, "openpgp-acceptance-change");
+ } catch (ex) {
+ if (conn) {
+ await conn.close();
+ }
+ throw ex;
+ }
+ },
+
+ /**
+ * Convenience function that will add one accepted email address,
+ * either to an already accepted key, or as unverified to an undecided
+ * key. It is an error to call this API for a rejected key, or for
+ * an already accepted email address.
+ */
+ async addAcceptedEmail(fingerprint, email) {
+ fingerprint = fingerprint.toLowerCase();
+ email = email.toLowerCase();
+ let conn;
+ try {
+ conn = await this.openDatabase();
+
+ let fingerprintAcceptance = await this.getFingerprintAcceptance(
+ conn,
+ fingerprint
+ );
+
+ let fprAlreadyAccepted = false;
+
+ switch (fingerprintAcceptance) {
+ case "undecided":
+ case "":
+ case undefined:
+ break;
+
+ case "unverified":
+ case "verified":
+ fprAlreadyAccepted = true;
+ break;
+
+ default:
+ throw new Error(
+ "invalid use of addAcceptedEmail() with existing acceptance " +
+ fingerprintAcceptance
+ );
+ }
+
+ this.accCacheFingerprint = "";
+ this.accCacheValue = "";
+ this.accCacheEmails = null;
+
+ if (!fprAlreadyAccepted) {
+ await conn.execute("begin transaction");
+ // start fresh, clean up old potential email decisions
+ this.internalDeleteAcceptanceNoTransaction(conn, fingerprint);
+
+ await conn.execute(
+ "insert into acceptance_decision values (:fpr, :decision)",
+ {
+ fpr: fingerprint,
+ decision: "unverified",
+ }
+ );
+ } else {
+ await conn
+ .execute(
+ "select count(*) from acceptance_email where fpr = :fpr and email = :email",
+ {
+ fpr: fingerprint,
+ email,
+ }
+ )
+ .then(result => {
+ if (result.length && result[0].getResultByName("count(*)") > 0) {
+ throw new Error(
+ `${email} already has acceptance for ${fingerprint}`
+ );
+ }
+ });
+
+ await conn.execute("begin transaction");
+ }
+
+ await conn.execute("insert into acceptance_email values (:fpr, :email)", {
+ fpr: fingerprint,
+ email,
+ });
+
+ await conn.execute("commit transaction");
+ await conn.close();
+ Services.obs.notifyObservers(null, "openpgp-acceptance-change");
+ } catch (ex) {
+ if (conn) {
+ await conn.close();
+ }
+ throw ex;
+ }
+ },
+
+ async updateAcceptance(fingerprint, emailArray, decision) {
+ fingerprint = fingerprint.toLowerCase();
+ let conn;
+ try {
+ let uniqueEmails = new Set();
+ if (decision !== "undecided") {
+ if (emailArray) {
+ for (let email of emailArray) {
+ if (!email) {
+ continue;
+ }
+ email = email.toLowerCase();
+ if (uniqueEmails.has(email)) {
+ continue;
+ }
+ uniqueEmails.add(email);
+ }
+ }
+ }
+
+ this.accCacheFingerprint = fingerprint;
+ this.accCacheValue = decision;
+ this.accCacheEmails = uniqueEmails;
+
+ conn = await this.openDatabase();
+ await conn.execute("begin transaction");
+ await this.internalDeleteAcceptanceNoTransaction(conn, fingerprint);
+
+ if (decision !== "undecided") {
+ let decisionObj = {
+ fpr: fingerprint,
+ decision,
+ };
+ await conn.execute(
+ "insert into acceptance_decision values (:fpr, :decision)",
+ decisionObj
+ );
+
+ // Rejection is global for a fingerprint, don't need to
+ // store email address records.
+
+ if (decision !== "rejected") {
+ // A key might contain multiple user IDs with the same email
+ // address. We add each email only once.
+ for (let email of uniqueEmails) {
+ await conn.execute(
+ "insert into acceptance_email values (:fpr, :email)",
+ {
+ fpr: fingerprint,
+ email,
+ }
+ );
+ }
+ }
+ }
+ await conn.execute("commit transaction");
+ await conn.close();
+ Services.obs.notifyObservers(null, "openpgp-acceptance-change");
+ } catch (ex) {
+ if (conn) {
+ await conn.close();
+ }
+ throw ex;
+ }
+ },
+
+ async acceptAsPersonalKey(fingerprint) {
+ this.updateAcceptance(fingerprint, null, "personal");
+ },
+
+ async deletePersonalKeyAcceptance(fingerprint) {
+ this.deleteAcceptance(fingerprint);
+ },
+
+ async isAcceptedAsPersonalKey(fingerprint) {
+ let result = await this.getFingerprintAcceptance(null, fingerprint);
+ return result === "personal";
+ },
+};
+
+/**
+ * use a promise to open the Enigmail database.
+ *
+ * it's possible that there will be an NS_ERROR_STORAGE_BUSY
+ * so we're willing to retry for a little while.
+ *
+ * @param {Function} resolve: function to call when promise succeeds
+ * @param {Function} reject: - function to call when promise fails
+ * @param {number} waitms: Integer - number of milliseconds to wait before trying again in case of NS_ERROR_STORAGE_BUSY
+ * @param {number} maxtime: Integer - unix epoch (in milliseconds) of the point at which we should give up.
+ */
+function openDatabaseConn(filename, resolve, reject, waitms, maxtime) {
+ lazy.EnigmailLog.DEBUG("sqliteDb.jsm: openDatabaseConn()\n");
+ lazy.Sqlite.openConnection({
+ path: filename,
+ sharedMemoryCache: false,
+ })
+ .then(connection => {
+ resolve(connection);
+ })
+ .catch(error => {
+ let now = Date.now();
+ if (now > maxtime) {
+ reject(error);
+ return;
+ }
+ lazy.setTimeout(function () {
+ openDatabaseConn(filename, resolve, reject, waitms, maxtime);
+ }, waitms);
+ });
+}
+
+async function checkAcceptanceTable(connection) {
+ try {
+ let exists = await connection.tableExists("acceptance_email");
+ let exists2 = await connection.tableExists("acceptance_decision");
+ lazy.EnigmailLog.DEBUG("sqliteDB.jsm: checkAcceptanceTable - success\n");
+ if (!exists || !exists2) {
+ await createAcceptanceTable(connection);
+ }
+ } catch (error) {
+ lazy.EnigmailLog.DEBUG(
+ `sqliteDB.jsm: checkAcceptanceTable - error ${error}\n`
+ );
+ throw error;
+ }
+
+ return true;
+}
+
+async function createAcceptanceTable(connection) {
+ lazy.EnigmailLog.DEBUG("sqliteDB.jsm: createAcceptanceTable()\n");
+
+ await connection.execute(
+ "create table acceptance_email (" +
+ "fpr text not null, " +
+ "email text not null, " +
+ "unique(fpr, email));"
+ );
+
+ await connection.execute(
+ "create table acceptance_decision (" +
+ "fpr text not null, " +
+ "decision text not null, " +
+ "unique(fpr));"
+ );
+
+ lazy.EnigmailLog.DEBUG("sqliteDB.jsm: createAcceptanceTable - index1\n");
+ await connection.execute(
+ "create unique index acceptance_email_i1 on acceptance_email(fpr, email);"
+ );
+
+ lazy.EnigmailLog.DEBUG("sqliteDB.jsm: createAcceptanceTable - index2\n");
+ await connection.execute(
+ "create unique index acceptance__decision_i1 on acceptance_decision(fpr);"
+ );
+
+ return null;
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/streams.jsm b/comm/mail/extensions/openpgp/content/modules/streams.jsm
new file mode 100644
index 0000000000..e5c40224d7
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/streams.jsm
@@ -0,0 +1,155 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailStreams"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+var EnigmailStreams = {
+ /**
+ * Create a new channel from a URL or URI.
+ *
+ * @param url: String, nsIURI or nsIFile - URL specification
+ *
+ * @return: channel
+ */
+ createChannel(url) {
+ let c = lazy.NetUtil.newChannel({
+ uri: url,
+ loadUsingSystemPrincipal: true,
+ });
+
+ return c;
+ },
+
+ /**
+ * create an nsIStreamListener object to read String data from an nsIInputStream
+ *
+ * @onStopCallback: Function - function(data) that is called when the stream has stopped
+ * string data is passed as |data|
+ *
+ * @return: the nsIStreamListener to pass to the stream
+ */
+ newStringStreamListener(onStopCallback) {
+ let listener = {
+ data: "",
+ inStream: Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ ),
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+
+ onStartRequest(channel) {},
+
+ onStopRequest(channel, status) {
+ this.inStream = null;
+ onStopCallback(this.data);
+ },
+ };
+
+ listener.onDataAvailable = function (req, stream, offset, count) {
+ this.inStream.setInputStream(stream);
+ this.data += this.inStream.readBytes(count);
+ };
+
+ return listener;
+ },
+
+ /**
+ * create a nsIInputStream object that is fed with string data
+ *
+ * @uri: nsIURI - object representing the URI that will deliver the data
+ * @contentType: String - the content type as specified in nsIChannel
+ * @contentCharset: String - the character set; automatically determined if null
+ * @data: String - the data to feed to the stream
+ * @loadInfo nsILoadInfo - loadInfo (optional)
+ *
+ * @returns nsIChannel object
+ */
+ newStringChannel(uri, contentType, contentCharset, data, loadInfo) {
+ if (!loadInfo) {
+ loadInfo = createLoadInfo();
+ }
+
+ let inputStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ inputStream.setData(data, -1);
+
+ if (!contentCharset || contentCharset.length === 0) {
+ let netUtil = Services.io.QueryInterface(Ci.nsINetUtil);
+ const newCharset = {};
+ const hadCharset = {};
+ netUtil.parseResponseContentType(contentType, newCharset, hadCharset);
+ contentCharset = newCharset.value;
+ }
+
+ let isc = Cc["@mozilla.org/network/input-stream-channel;1"].createInstance(
+ Ci.nsIInputStreamChannel
+ );
+ isc.QueryInterface(Ci.nsIChannel);
+ isc.setURI(uri);
+ isc.loadInfo = loadInfo;
+ isc.contentStream = inputStream;
+
+ if (contentType && contentType.length) {
+ isc.contentType = contentType;
+ }
+ if (contentCharset && contentCharset.length) {
+ isc.contentCharset = contentCharset;
+ }
+
+ return isc;
+ },
+
+ newFileChannel(uri, file, contentType, deleteOnClose) {
+ let inputStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ let behaviorFlags = Ci.nsIFileInputStream.CLOSE_ON_EOF;
+ if (deleteOnClose) {
+ behaviorFlags |= Ci.nsIFileInputStream.DELETE_ON_CLOSE;
+ }
+ const ioFlags = 0x01; // readonly
+ const perm = 0;
+ inputStream.init(file, ioFlags, perm, behaviorFlags);
+
+ let isc = Cc["@mozilla.org/network/input-stream-channel;1"].createInstance(
+ Ci.nsIInputStreamChannel
+ );
+ isc.QueryInterface(Ci.nsIChannel);
+ isc.contentDisposition = Ci.nsIChannel.DISPOSITION_ATTACHMENT;
+ isc.loadInfo = createLoadInfo();
+ isc.setURI(uri);
+ isc.contentStream = inputStream;
+
+ if (contentType && contentType.length) {
+ isc.contentType = contentType;
+ }
+ return isc;
+ },
+};
+
+function createLoadInfo() {
+ let c = lazy.NetUtil.newChannel({
+ uri: "chrome://openpgp/content/",
+ loadUsingSystemPrincipal: true,
+ });
+
+ return c.loadInfo;
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/trust.jsm b/comm/mail/extensions/openpgp/content/modules/trust.jsm
new file mode 100644
index 0000000000..37e0014b59
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/trust.jsm
@@ -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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailTrust"];
+
+var l10n;
+
+// trust flags according to GPG documentation:
+// - https://www.gnupg.org/documentation/manuals/gnupg.pdf
+// - sources: doc/DETAILS
+// In the order of trustworthy:
+// ---------------------------------------------------------
+// i = The key is invalid (e.g. due to a missing self-signature)
+// n = The key is not valid / Never trust this key
+// d/D = The key has been disabled
+// r = The key has been revoked
+// e = The key has expired
+// g = group (???)
+// ---------------------------------------------------------
+// ? = INTERNAL VALUE to separate invalid from unknown keys
+// ---------------------------------------------------------
+// o = Unknown (this key is new to the system)
+// - = Unknown validity (i.e. no value assigned)
+// q = Undefined validity (Not enough information for calculation)
+// '-' and 'q' may safely be treated as the same value for most purposes
+// ---------------------------------------------------------
+// m = Marginally trusted
+// ---------------------------------------------------------
+// f = Fully trusted / valid key
+// u = Ultimately trusted
+// ---------------------------------------------------------
+const TRUSTLEVELS_SORTED = "indDreg?o-qmfu";
+const TRUSTLEVELS_SORTED_IDX_UNKNOWN = 7; // index of '?'
+
+var EnigmailTrust = {
+ /**
+ * @returns - |string| containing the order of trust/validity values
+ */
+ trustLevelsSorted() {
+ return TRUSTLEVELS_SORTED;
+ },
+
+ /**
+ * @returns - |boolean| whether the flag is invalid (neither unknown nor valid)
+ */
+ isInvalid(flag) {
+ return TRUSTLEVELS_SORTED.indexOf(flag) < TRUSTLEVELS_SORTED_IDX_UNKNOWN;
+ },
+
+ getTrustCode(keyObj) {
+ return keyObj.keyTrust;
+ },
+
+ getTrustLabel(trustCode) {
+ if (!l10n) {
+ l10n = new Localization(["messenger/openpgp/openpgp.ftl"], true);
+ }
+ let keyTrust;
+ switch (trustCode) {
+ case "q":
+ return l10n.formatValueSync("key-valid-unknown");
+ case "i":
+ return l10n.formatValueSync("key-valid-invalid");
+ case "d":
+ case "D":
+ return l10n.formatValueSync("key-valid-disabled");
+ case "r":
+ return l10n.formatValueSync("key-valid-revoked");
+ case "e":
+ return l10n.formatValueSync("key-valid-expired");
+ case "n":
+ return l10n.formatValueSync("key-trust-untrusted");
+ case "m":
+ return l10n.formatValueSync("key-trust-marginal");
+ case "f":
+ return l10n.formatValueSync("key-trust-full");
+ case "u":
+ return l10n.formatValueSync("key-trust-ultimate");
+ case "g":
+ return l10n.formatValueSync("key-trust-group");
+ case "-":
+ keyTrust = "-";
+ break;
+ default:
+ keyTrust = "";
+ }
+ return keyTrust;
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/uris.jsm b/comm/mail/extensions/openpgp/content/modules/uris.jsm
new file mode 100644
index 0000000000..f579195d03
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/uris.jsm
@@ -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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailURIs"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "EnigmailLog",
+ "chrome://openpgp/content/modules/log.jsm"
+);
+
+const encryptedUris = [];
+
+var EnigmailURIs = {
+ /*
+ * remember the fact a URI is encrypted
+ *
+ * @param String msgUri
+ *
+ * @return null
+ */
+ rememberEncryptedUri(uri) {
+ lazy.EnigmailLog.DEBUG("uris.jsm: rememberEncryptedUri: uri=" + uri + "\n");
+ if (!encryptedUris.includes(uri)) {
+ encryptedUris.push(uri);
+ }
+ },
+
+ /*
+ * unremember the fact a URI is encrypted
+ *
+ * @param String msgUri
+ *
+ * @return null
+ */
+ forgetEncryptedUri(uri) {
+ lazy.EnigmailLog.DEBUG("uris.jsm: forgetEncryptedUri: uri=" + uri + "\n");
+ const pos = encryptedUris.indexOf(uri);
+ if (pos >= 0) {
+ encryptedUris.splice(pos, 1);
+ }
+ },
+
+ /*
+ * determine if a URI was remembered as encrypted
+ *
+ * @param String msgUri
+ *
+ * @return: Boolean true if yes, false otherwise
+ */
+ isEncryptedUri(uri) {
+ lazy.EnigmailLog.DEBUG("uris.jsm: isEncryptedUri: uri=" + uri + "\n");
+ return encryptedUris.includes(uri);
+ },
+
+ /**
+ * Determine message number and folder from mailnews URI
+ *
+ * @param url - nsIURI object
+ *
+ * @returns Object:
+ * - msgNum: String - the message number, or "" if no URI Scheme fits
+ * - folder: String - the folder (or newsgroup) name
+ */
+ msgIdentificationFromUrl(url) {
+ // sample URLs in Thunderbird
+ // Local folder: mailbox:///some/path/to/folder?number=359360
+ // IMAP: imap://user@host:port/fetch>some>path>111
+ // NNTP: news://some.host/some.service.com?group=some.group.name&key=3510
+ // also seen: e.g. mailbox:///some/path/to/folder?number=4455522&part=1.1.2&filename=test.eml
+ // mailbox:///...?number=4455522&part=1.1.2&filename=test.eml&type=application/x-message-display&filename=test.eml
+ // imap://user@host:port>UID>some>path>10?header=filter&emitter=js&examineEncryptedParts=true
+
+ if (!url) {
+ return null;
+ }
+
+ lazy.EnigmailLog.DEBUG(
+ "uris.jsm: msgIdentificationFromUrl: url.pathQueryRef=" +
+ ("path" in url ? url.path : url.pathQueryRef) +
+ "\n"
+ );
+
+ let msgNum = "";
+ let msgFolder = "";
+
+ let pathQueryRef = "path" in url ? url.path : url.pathQueryRef;
+
+ if (url.schemeIs("mailbox")) {
+ msgNum = pathQueryRef.replace(/(.*[?&]number=)([0-9]+)([^0-9].*)?/, "$2");
+ msgFolder = pathQueryRef.replace(/\?.*/, "");
+ } else if (url.schemeIs("file")) {
+ msgNum = "0";
+ msgFolder = pathQueryRef.replace(/\?.*/, "");
+ } else if (url.schemeIs("imap")) {
+ let p = unescape(pathQueryRef);
+ msgNum = p.replace(/(.*>)([0-9]+)([^0-9].*)?/, "$2");
+ msgFolder = p.replace(/\?.*$/, "").replace(/>[^>]+$/, "");
+ } else if (url.schemeIs("news")) {
+ msgNum = pathQueryRef.replace(/(.*[?&]key=)([0-9]+)([^0-9].*)?/, "$2");
+ msgFolder = pathQueryRef.replace(/(.*[?&]group=)([^&]+)(&.*)?/, "$2");
+ }
+
+ lazy.EnigmailLog.DEBUG(
+ "uris.jsm: msgIdentificationFromUrl: msgNum=" +
+ msgNum +
+ " / folder=" +
+ msgFolder +
+ "\n"
+ );
+
+ return {
+ msgNum,
+ folder: msgFolder.toLowerCase(),
+ };
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/webKey.jsm b/comm/mail/extensions/openpgp/content/modules/webKey.jsm
new file mode 100644
index 0000000000..76bd316e63
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/webKey.jsm
@@ -0,0 +1,293 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * This module serves to integrate WKS (Webkey service) into Enigmail
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailWks"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+});
+
+var EnigmailWks = {
+ wksClientPath: null,
+
+ /**
+ * Get WKS Client path (gpg-wks-client)
+ *
+ * @param window : Object - parent window for dialog display
+ * @param cb : Function(retValue) - callback function.
+ * retValue: nsIFile Object to gpg-wks-client executable or NULL
+ * @returns : Object - NULL or a process handle
+ */
+ getWksClientPathAsync(window, cb) {
+ lazy.EnigmailLog.DEBUG("webKey.jsm: getWksClientPathAsync\n");
+ throw new Error("Not implemented");
+ },
+
+ /**
+ * Determine if WKS is supported by email provider
+ *
+ * @param email : String - user's email address
+ * @param window: Object - parent window of dialog display
+ * @param cb : Function(retValue) - callback function.
+ * retValue: Boolean: true if WKS is supported / false otherwise
+ * @returns : Object - process handle
+ */
+ isWksSupportedAsync(email, window, cb) {
+ lazy.EnigmailLog.DEBUG(
+ "webKey.jsm: isWksSupportedAsync: email = " + email + "\n"
+ );
+ throw new Error("Not implemented");
+ },
+
+ /**
+ * Submit a set of keys to the Web Key Server (WKD)
+ *
+ * @param keys: Array of KeyObj
+ * @param win: parent Window for displaying dialogs
+ * @param observer: Object (KeySrvListener API)
+ * Object implementing:
+ * - onProgress: function(percentComplete) [only implemented for download()]
+ * - onCancel: function() - the body will be set by the callee
+ *
+ * @returns Promise<...>
+ */
+ wksUpload(keys, win, observer = null) {
+ lazy.EnigmailLog.DEBUG(`webKey.jsm: wksUpload(): keys = ${keys.length}\n`);
+ let ids = getWkdIdentities(keys);
+
+ if (observer === null) {
+ observer = {
+ onProgress() {},
+ };
+ }
+
+ observer.isCanceled = false;
+ observer.onCancel = function () {
+ this.isCanceled = true;
+ };
+
+ if (!ids) {
+ throw new Error("error");
+ }
+
+ if (ids.senderIdentities.length === 0) {
+ return new Promise(resolve => {
+ resolve([]);
+ });
+ }
+
+ return performWkdUpload(ids.senderIdentities, win, observer);
+ },
+
+ /**
+ * Submit a key to the email provider (= send publication request)
+ *
+ * @param ident : nsIMsgIdentity - user's ID
+ * @param key : Enigmail KeyObject of user's key
+ * @param window: Object - parent window of dialog display
+ * @param cb : Function(retValue) - callback function.
+ * retValue: Boolean: true if submit was successful / false otherwise
+ * @returns : Object - process handle
+ */
+
+ submitKey(ident, key, window, cb) {
+ lazy.EnigmailLog.DEBUG(
+ "webKey.jsm: submitKey(): email = " + ident.email + "\n"
+ );
+ throw new Error("Not implemented");
+ },
+
+ /**
+ * Submit a key to the email provider (= send publication request)
+ *
+ * @param ident : nsIMsgIdentity - user's ID
+ * @param body : String - complete message source of the confirmation-request email obtained
+ * from the email provider
+ * @param window: Object - parent window of dialog display
+ * @param cb : Function(retValue) - callback function.
+ * retValue: Boolean: true if submit was successful / false otherwise
+ * @returns : Object - process handle
+ */
+
+ confirmKey(ident, body, window, cb) {
+ lazy.EnigmailLog.DEBUG(
+ "webKey.jsm: confirmKey: ident=" + ident.email + "\n"
+ );
+ throw new Error("Not implemented");
+ },
+};
+
+/**
+ * Check if a file exists and is executable
+ *
+ * @param path: String - directory name
+ * @param execFileName: String - executable name
+ *
+ * @returns Object - nsIFile if file exists; NULL otherwise
+ */
+
+function getWkdIdentities(keys) {
+ lazy.EnigmailLog.DEBUG(
+ `webKey.jsm: getWkdIdentities(): keys = ${keys.length}\n`
+ );
+ let senderIdentities = [],
+ notFound = [];
+
+ for (let key of keys) {
+ try {
+ let found = false;
+ for (let uid of key.userIds) {
+ let email = lazy.EnigmailFuncs.stripEmail(uid.userId).toLowerCase();
+ let identity = MailServices.accounts.allIdentities.find(
+ id => id.email?.toLowerCase() == email
+ );
+
+ if (identity) {
+ senderIdentities.push({
+ identity,
+ fpr: key.fpr,
+ });
+ }
+ }
+ if (!found) {
+ notFound.push(key);
+ }
+ } catch (ex) {
+ lazy.EnigmailLog.DEBUG(ex + "\n");
+ return null;
+ }
+ }
+
+ return {
+ senderIdentities,
+ notFound,
+ };
+}
+
+/**
+ * Do the WKD upload and interact with a progress receiver
+ *
+ * @param keyList: Object:
+ * - fprList (String - fingerprint)
+ * - senderIdentities (nsIMsgIdentity)
+ * @param win: nsIWindow - parent window
+ * @param observer: Object:
+ * - onProgress: function(percentComplete [0 .. 100])
+ * called after processing of every key (independent of status)
+ * - onUpload: function(fpr)
+ * called after successful uploading of a key
+ * - onFinished: function(completionStatus, errorMessage, displayError)
+ * - isCanceled: Boolean - used to determine if process is canceled
+ */
+function performWkdUpload(keyList, win, observer) {
+ lazy.EnigmailLog.DEBUG(
+ `webKey.jsm: performWkdUpload: keyList.length=${keyList.length}\n`
+ );
+
+ let uploads = [];
+
+ let numKeys = keyList.length;
+
+ // For each key fpr/sender identity pair, check whenever WKS is supported
+ // Result is an array of booleans
+ for (let i = 0; i < numKeys; i++) {
+ let keyFpr = keyList[i].fpr;
+ let senderIdent = keyList[i].identity;
+
+ let was_uploaded = new Promise(function (resolve, reject) {
+ lazy.EnigmailLog.DEBUG(
+ "webKey.jsm: performWkdUpload: _isSupported(): ident=" +
+ senderIdent.email +
+ ", key=" +
+ keyFpr +
+ "\n"
+ );
+ EnigmailWks.isWksSupportedAsync(
+ senderIdent.email,
+ win,
+ function (is_supported) {
+ if (observer.isCanceled) {
+ lazy.EnigmailLog.DEBUG(
+ "webKey.jsm: performWkdUpload: canceled by user\n"
+ );
+ reject("canceled");
+ }
+
+ lazy.EnigmailLog.DEBUG(
+ "webKey.jsm: performWkdUpload: ident=" +
+ senderIdent.email +
+ ", supported=" +
+ is_supported +
+ "\n"
+ );
+ resolve(is_supported);
+ }
+ );
+ }).then(function (is_supported) {
+ lazy.EnigmailLog.DEBUG(
+ `webKey.jsm: performWkdUpload: _submitKey ${is_supported}\n`
+ );
+ if (is_supported) {
+ return new Promise(function (resolve, reject) {
+ EnigmailWks.submitKey(
+ senderIdent,
+ {
+ fpr: keyFpr,
+ },
+ win,
+ function (success) {
+ observer.onProgress(((i + 1) / numKeys) * 100);
+ if (success) {
+ resolve(senderIdent);
+ } else {
+ reject("submitFailed");
+ }
+ }
+ );
+ });
+ }
+
+ observer.onProgress(((i + 1) / numKeys) * 100);
+ return Promise.resolve(null);
+ });
+
+ uploads.push(was_uploaded);
+ }
+
+ return Promise.all(uploads)
+ .catch(function (reason) {
+ //let errorMsg = "Could not upload your key to the Web Key Service";
+ return [];
+ })
+ .then(function (senders) {
+ let uploaded_uids = [];
+ if (senders) {
+ senders.forEach(function (val) {
+ if (val !== null) {
+ uploaded_uids.push(val.email);
+ }
+ });
+ }
+ observer.onProgress(100);
+
+ return uploaded_uids;
+ });
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/windows.jsm b/comm/mail/extensions/openpgp/content/modules/windows.jsm
new file mode 100644
index 0000000000..baf2e1e5f0
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/windows.jsm
@@ -0,0 +1,518 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailWindows"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+var EnigmailWindows = {
+ /**
+ * Open a window, or focus it if it is already open
+ *
+ * @winName : String - name of the window; used to identify if it is already open
+ * @spec : String - window URL (e.g. chrome://openpgp/content/ui/test.xhtml)
+ * @winOptions: String - window options as defined in nsIWindow.open
+ * @optObj : any - an Object, Array, String, etc. that is passed as parameter
+ * to the window
+ */
+ openWin(winName, spec, winOptions, optObj) {
+ var windowManager = Services.wm;
+
+ var recentWin = null;
+ for (let win of windowManager.getEnumerator(null)) {
+ if (win.location.href == spec) {
+ recentWin = win;
+ break;
+ }
+ if (winName && win.name && win.name == winName) {
+ win.focus();
+ break;
+ }
+ }
+
+ if (recentWin) {
+ recentWin.focus();
+ } else {
+ var appShellSvc = Services.appShell;
+ var domWin = appShellSvc.hiddenDOMWindow;
+ try {
+ domWin.open(spec, winName, "chrome," + winOptions, optObj);
+ } catch (ex) {
+ domWin = windowManager.getMostRecentWindow(null);
+ domWin.open(spec, winName, "chrome," + winOptions, optObj);
+ }
+ }
+ },
+
+ /**
+ * Determine the best possible window to serve as parent window for dialogs.
+ *
+ * @return: nsIWindow object
+ */
+ getBestParentWin() {
+ var windowManager = Services.wm;
+
+ var bestFit = null;
+
+ for (let win of windowManager.getEnumerator(null)) {
+ if (win.location.href.search(/\/messenger.xhtml$/) > 0) {
+ bestFit = win;
+ }
+ if (
+ !bestFit &&
+ win.location.href.search(/\/messengercompose.xhtml$/) > 0
+ ) {
+ bestFit = win;
+ }
+ }
+
+ if (!bestFit) {
+ var winEnum = windowManager.getEnumerator(null);
+ bestFit = winEnum.getNext();
+ }
+
+ return bestFit;
+ },
+
+ /**
+ * Iterate through the frames of a window and return the first frame with a
+ * matching name.
+ *
+ * @win: nsIWindow - XUL window to search
+ * @frameName: String - name of the frame to search
+ *
+ * @return: the frame object or null if not found
+ */
+ getFrame(win, frameName) {
+ lazy.EnigmailLog.DEBUG("windows.jsm: getFrame: name=" + frameName + "\n");
+ for (var j = 0; j < win.frames.length; j++) {
+ if (win.frames[j].name == frameName) {
+ return win.frames[j];
+ }
+ }
+ return null;
+ },
+
+ getMostRecentWindow() {
+ var windowManager = Services.wm;
+ return windowManager.getMostRecentWindow(null);
+ },
+
+ /**
+ * Display the key help window
+ *
+ * @source - |string| containing the name of the file to display
+ *
+ * no return value
+ */
+
+ openHelpWindow(source) {
+ EnigmailWindows.openWin(
+ "enigmail:help",
+ "chrome://openpgp/content/ui/enigmailHelp.xhtml?src=" + source,
+ "centerscreen,resizable"
+ );
+ },
+
+ /**
+ * Open the Enigmail Documentation page in a new window
+ *
+ * no return value
+ */
+ openEnigmailDocu(parent) {
+ if (!parent) {
+ parent = this.getMostRecentWindow();
+ }
+
+ parent.open(
+ "https://doesnotexist-openpgp-integration.thunderbird/faq/docu.php",
+ "",
+ "chrome,width=600,height=500,resizable"
+ );
+ },
+
+ /**
+ * Display the OpenPGP key manager window
+ *
+ * no return value
+ */
+ openKeyManager(win) {
+ lazy.EnigmailCore.getService(win);
+
+ EnigmailWindows.openWin(
+ "enigmail:KeyManager",
+ "chrome://openpgp/content/ui/enigmailKeyManager.xhtml",
+ "resizable"
+ );
+ },
+
+ /**
+ * Display the OpenPGP key manager window
+ *
+ * no return value
+ */
+ openImportSettings(win) {
+ lazy.EnigmailCore.getService(win);
+
+ EnigmailWindows.openWin(
+ "",
+ "chrome://openpgp/content/ui/importSettings.xhtml",
+ "chrome,dialog,centerscreen,resizable,modal"
+ );
+ },
+
+ /**
+ * If the Key Manager is open, dispatch an event to tell the key
+ * manager to refresh the displayed keys
+ */
+ keyManReloadKeys() {
+ for (let thisWin of Services.wm.getEnumerator(null)) {
+ if (thisWin.name && thisWin.name == "enigmail:KeyManager") {
+ let evt = new thisWin.Event("reload-keycache", {
+ bubbles: true,
+ cancelable: false,
+ });
+ thisWin.dispatchEvent(evt);
+ break;
+ }
+ }
+ },
+
+ /**
+ * Display the card details window
+ *
+ * no return value
+ */
+ openCardDetails() {
+ EnigmailWindows.openWin(
+ "enigmail:cardDetails",
+ "chrome://openpgp/content/ui/enigmailCardDetails.xhtml",
+ "centerscreen"
+ );
+ },
+
+ /**
+ * Display the console log window
+ *
+ * @win - |object| holding the parent window for the dialog
+ *
+ * no return value
+ */
+ openConsoleWindow() {
+ EnigmailWindows.openWin(
+ "enigmail:console",
+ "chrome://openpgp/content/ui/enigmailConsole.xhtml",
+ "resizable,centerscreen"
+ );
+ },
+
+ /**
+ * Display the window for the debug log file
+ *
+ * @win - |object| holding the parent window for the dialog
+ *
+ * no return value
+ */
+ openDebugLog(win) {
+ EnigmailWindows.openWin(
+ "enigmail:logFile",
+ "chrome://openpgp/content/ui/enigmailViewFile.xhtml?viewLog=1&title=" +
+ escape(lazy.l10n.formatValueSync("debug-log-title")),
+ "centerscreen"
+ );
+ },
+
+ /**
+ * Display the dialog for changing the expiry date of one or several keys
+ *
+ * @win - |object| holding the parent window for the dialog
+ * @userIdArr - |array| of |strings| containing the User IDs
+ * @keyIdArr - |array| of |strings| containing the key IDs (eg. "0x12345678") to change
+ *
+ * @returns Boolean - true if expiry date was changed; false otherwise
+ */
+ editKeyExpiry(win, userIdArr, keyIdArr) {
+ const inputObj = {
+ keyId: keyIdArr,
+ userId: userIdArr,
+ };
+ const resultObj = {
+ refresh: false,
+ };
+ win.openDialog(
+ "chrome://openpgp/content/ui/enigmailEditKeyExpiryDlg.xhtml",
+ "",
+ "dialog,modal,centerscreen,resizable",
+ inputObj,
+ resultObj
+ );
+ return resultObj.refresh;
+ },
+
+ /**
+ * Display the dialog for changing key trust of one or several keys
+ *
+ * @win - |object| holding the parent window for the dialog
+ * @userIdArr - |array| of |strings| containing the User IDs
+ * @keyIdArr - |array| of |strings| containing the key IDs (eg. "0x12345678") to change
+ *
+ * @returns Boolean - true if key trust was changed; false otherwise
+ */
+ editKeyTrust(win, userIdArr, keyIdArr) {
+ const inputObj = {
+ keyId: keyIdArr,
+ userId: userIdArr,
+ };
+ const resultObj = {
+ refresh: false,
+ };
+ win.openDialog(
+ "chrome://openpgp/content/ui/enigmailEditKeyTrustDlg.xhtml",
+ "",
+ "dialog,modal,centerscreen,resizable",
+ inputObj,
+ resultObj
+ );
+ return resultObj.refresh;
+ },
+
+ /**
+ * Display the dialog for signing a key
+ *
+ * @win - |object| holding the parent window for the dialog
+ * @userId - |string| containing the User ID (for displaing in the dialog only)
+ * @keyId - |string| containing the key ID (eg. "0x12345678")
+ *
+ * @returns Boolean - true if key was signed; false otherwise
+ */
+ signKey(win, userId, keyId) {
+ const inputObj = {
+ keyId,
+ userId,
+ };
+ const resultObj = {
+ refresh: false,
+ };
+ win.openDialog(
+ "chrome://openpgp/content/ui/enigmailSignKeyDlg.xhtml",
+ "",
+ "dialog,modal,centerscreen,resizable",
+ inputObj,
+ resultObj
+ );
+ return resultObj.refresh;
+ },
+
+ /**
+ * Display the OpenPGP Key Details window
+ *
+ * @win - |object| holding the parent window for the dialog
+ * @keyId - |string| containing the key ID (eg. "0x12345678")
+ * @refresh - |boolean| if true, cache is cleared and the key data is loaded from GnuPG
+ *
+ * @returns Boolean - true: keylist needs to be refreshed
+ * - false: no need to refresh keylist
+ */
+ async openKeyDetails(win, keyId, refresh) {
+ if (!win) {
+ win = this.getBestParentWin();
+ }
+
+ keyId = keyId.replace(/^0x/, "");
+
+ if (refresh) {
+ lazy.EnigmailKeyRing.clearCache();
+ }
+
+ const resultObj = {
+ refresh: false,
+ };
+ win.openDialog(
+ "chrome://openpgp/content/ui/keyDetailsDlg.xhtml",
+ "KeyDetailsDialog",
+ "dialog,modal,centerscreen,resizable",
+ { keyId, modified: lazy.EnigmailKeyRing.clearCache },
+ resultObj
+ );
+
+ return resultObj.refresh;
+ },
+
+ /**
+ * Display the dialog to search and/or download key(s) from a keyserver
+ *
+ * @win - |object| holding the parent window for the dialog
+ * @inputObj - |object| with member searchList (|string| containing the keys to search)
+ * @resultObj - |object| with member importedKeys (|number| containing the number of imporeted keys)
+ *
+ * no return value
+ */
+ downloadKeys(win, inputObj, resultObj) {
+ lazy.EnigmailLog.DEBUG(
+ "windows.jsm: downloadKeys: searchList=" + inputObj.searchList + "\n"
+ );
+
+ resultObj.importedKeys = 0;
+
+ const ioService = Services.io;
+ if (ioService && ioService.offline) {
+ lazy.l10n.formatValue("need-online").then(value => {
+ lazy.EnigmailDialog.alert(win, value);
+ });
+ return;
+ }
+
+ let valueObj = {};
+ if (inputObj.searchList) {
+ valueObj = {
+ keyId: "<" + inputObj.searchList.join("> <") + ">",
+ };
+ }
+
+ const keysrvObj = {};
+
+ if (inputObj.searchList && inputObj.autoKeyServer) {
+ keysrvObj.value = inputObj.autoKeyServer;
+ } else {
+ win.openDialog(
+ "chrome://openpgp/content/ui/enigmailKeyserverDlg.xhtml",
+ "",
+ "dialog,modal,centerscreen",
+ valueObj,
+ keysrvObj
+ );
+ }
+
+ if (!keysrvObj.value) {
+ return;
+ }
+
+ inputObj.keyserver = keysrvObj.value;
+
+ if (!inputObj.searchList) {
+ const searchval = keysrvObj.email
+ .replace(/^(\s*)(.*)/, "$2")
+ .replace(/\s+$/, ""); // trim spaces
+ // special handling to convert fingerprints with spaces into fingerprint without spaces
+ if (
+ searchval.length == 49 &&
+ searchval.match(/^[0-9a-fA-F ]*$/) &&
+ searchval[4] == " " &&
+ searchval[9] == " " &&
+ searchval[14] == " " &&
+ searchval[19] == " " &&
+ searchval[24] == " " &&
+ searchval[29] == " " &&
+ searchval[34] == " " &&
+ searchval[39] == " " &&
+ searchval[44] == " "
+ ) {
+ inputObj.searchList = ["0x" + searchval.replace(/ /g, "")];
+ } else if (searchval.length == 40 && searchval.match(/^[0-9a-fA-F ]*$/)) {
+ inputObj.searchList = ["0x" + searchval];
+ } else if (searchval.length == 8 && searchval.match(/^[0-9a-fA-F]*$/)) {
+ // special handling to add the required leading 0x when searching for keys
+ inputObj.searchList = ["0x" + searchval];
+ } else if (searchval.length == 16 && searchval.match(/^[0-9a-fA-F]*$/)) {
+ inputObj.searchList = ["0x" + searchval];
+ } else {
+ inputObj.searchList = searchval.split(/[,; ]+/);
+ }
+ }
+
+ win.openDialog(
+ "chrome://openpgp/content/ui/enigmailSearchKey.xhtml",
+ "",
+ "dialog,modal,centerscreen",
+ inputObj,
+ resultObj
+ );
+ },
+
+ /**
+ * Display Autocrypt Setup Passwd dialog.
+ *
+ * @param dlgMode: String - dialog mode: "input" / "display"
+ * @param passwdType: String - type of password ("numeric9x4" / "generic")
+ * @param password: String - password or initial two digits of password
+ *
+ * @returns String entered password (in input mode) or NULL
+ */
+ autocryptSetupPasswd(window, dlgMode, passwdType = "numeric9x4", password) {
+ if (!window) {
+ window = this.getBestParentWin();
+ }
+
+ let inputObj = {
+ password: null,
+ passwdType,
+ dlgMode,
+ };
+
+ if (password) {
+ inputObj.initialPasswd = password;
+ }
+
+ window.openDialog(
+ "chrome://openpgp/content/ui/autocryptSetupPasswd.xhtml",
+ "",
+ "dialog,modal,centerscreen",
+ inputObj
+ );
+
+ return inputObj.password;
+ },
+
+ /**
+ * Display dialog to initiate the Autocrypt Setup Message.
+ *
+ */
+ inititateAcSetupMessage(window) {
+ if (!window) {
+ window = this.getBestParentWin();
+ }
+
+ window.openDialog(
+ "chrome://openpgp/content/ui/autocryptInitiateBackup.xhtml",
+ "",
+ "dialog,centerscreen"
+ );
+ },
+
+ shutdown(reason) {
+ lazy.EnigmailLog.DEBUG("windows.jsm: shutdown()\n");
+
+ let tabs = Services.wm
+ .getMostRecentWindow("mail:3pane")
+ .document.getElementById("tabmail");
+
+ for (let i = tabs.tabInfo.length - 1; i >= 0; i--) {
+ if (
+ "openedUrl" in tabs.tabInfo[i] &&
+ tabs.tabInfo[i].openedUrl.startsWith("chrome://openpgp/")
+ ) {
+ tabs.closeTab(tabs.tabInfo[i]);
+ }
+ }
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/modules/wkdLookup.jsm b/comm/mail/extensions/openpgp/content/modules/wkdLookup.jsm
new file mode 100644
index 0000000000..bf5fd25845
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/wkdLookup.jsm
@@ -0,0 +1,363 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Lookup keys by email addresses using WKD. A an email address is lookep up at most
+ * once a day. (see https://tools.ietf.org/html/draft-koch-openpgp-webkey-service)
+ */
+
+var EXPORTED_SYMBOLS = ["EnigmailWkdLookup"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ DNS: "resource:///modules/DNS.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailZBase32: "chrome://openpgp/content/modules/zbase32.jsm",
+});
+
+// Those domains are not expected to have WKD:
+var EXCLUDE_DOMAINS = [
+ /* Default domains included */
+ "aol.com",
+ "att.net",
+ "comcast.net",
+ "facebook.com",
+ "gmail.com",
+ "gmx.com",
+ "googlemail.com",
+ "google.com",
+ "hotmail.com",
+ "hotmail.co.uk",
+ "mac.com",
+ "me.com",
+ "mail.com",
+ "msn.com",
+ "live.com",
+ "sbcglobal.net",
+ "verizon.net",
+ "yahoo.com",
+ "yahoo.co.uk",
+
+ /* Other global domains */
+ "email.com",
+ "games.com" /* AOL */,
+ "gmx.net",
+ "icloud.com",
+ "iname.com",
+ "inbox.com",
+ "lavabit.com",
+ "love.com" /* AOL */,
+ "outlook.com",
+ "pobox.com",
+ "tutanota.de",
+ "tutanota.com",
+ "tutamail.com",
+ "tuta.io",
+ "keemail.me",
+ "rocketmail.com" /* Yahoo */,
+ "safe-mail.net",
+ "wow.com" /* AOL */,
+ "ygm.com" /* AOL */,
+ "ymail.com" /* Yahoo */,
+ "zoho.com",
+ "yandex.com",
+
+ /* United States ISP domains */
+ "bellsouth.net",
+ "charter.net",
+ "cox.net",
+ "earthlink.net",
+ "juno.com",
+
+ /* British ISP domains */
+ "btinternet.com",
+ "virginmedia.com",
+ "blueyonder.co.uk",
+ "freeserve.co.uk",
+ "live.co.uk",
+ "ntlworld.com",
+ "o2.co.uk",
+ "orange.net",
+ "sky.com",
+ "talktalk.co.uk",
+ "tiscali.co.uk",
+ "virgin.net",
+ "wanadoo.co.uk",
+ "bt.com",
+
+ /* Domains used in Asia */
+ "sina.com",
+ "sina.cn",
+ "qq.com",
+ "naver.com",
+ "hanmail.net",
+ "daum.net",
+ "nate.com",
+ "yahoo.co.jp",
+ "yahoo.co.kr",
+ "yahoo.co.id",
+ "yahoo.co.in",
+ "yahoo.com.sg",
+ "yahoo.com.ph",
+ "163.com",
+ "yeah.net",
+ "126.com",
+ "21cn.com",
+ "aliyun.com",
+ "foxmail.com",
+
+ /* French ISP domains */
+ "hotmail.fr",
+ "live.fr",
+ "laposte.net",
+ "yahoo.fr",
+ "wanadoo.fr",
+ "orange.fr",
+ "gmx.fr",
+ "sfr.fr",
+ "neuf.fr",
+ "free.fr",
+
+ /* German ISP domains */
+ "gmx.de",
+ "hotmail.de",
+ "live.de",
+ "online.de",
+ "t-online.de" /* T-Mobile */,
+ "web.de",
+ "yahoo.de",
+
+ /* Italian ISP domains */
+ "libero.it",
+ "virgilio.it",
+ "hotmail.it",
+ "aol.it",
+ "tiscali.it",
+ "alice.it",
+ "live.it",
+ "yahoo.it",
+ "email.it",
+ "tin.it",
+ "poste.it",
+ "teletu.it",
+
+ /* Russian ISP domains */
+ "mail.ru",
+ "rambler.ru",
+ "yandex.ru",
+ "ya.ru",
+ "list.ru",
+
+ /* Belgian ISP domains */
+ "hotmail.be",
+ "live.be",
+ "skynet.be",
+ "voo.be",
+ "tvcablenet.be",
+ "telenet.be",
+
+ /* Argentinian ISP domains */
+ "hotmail.com.ar",
+ "live.com.ar",
+ "yahoo.com.ar",
+ "fibertel.com.ar",
+ "speedy.com.ar",
+ "arnet.com.ar",
+
+ /* Domains used in Mexico */
+ "yahoo.com.mx",
+ "live.com.mx",
+ "hotmail.es",
+ "hotmail.com.mx",
+ "prodigy.net.mx",
+
+ /* Domains used in Canada */
+ "yahoo.ca",
+ "hotmail.ca",
+ "bell.net",
+ "shaw.ca",
+ "sympatico.ca",
+ "rogers.com",
+
+ /* Domains used in Brazil */
+ "yahoo.com.br",
+ "hotmail.com.br",
+ "outlook.com.br",
+ "uol.com.br",
+ "bol.com.br",
+ "terra.com.br",
+ "ig.com.br",
+ "itelefonica.com.br",
+ "r7.com",
+ "zipmail.com.br",
+ "globo.com",
+ "globomail.com",
+ "oi.com.br",
+];
+
+var EnigmailWkdLookup = {
+ /**
+ * get the download URL for an email address for WKD or domain-specific locations
+ *
+ * @param {string} email: email address
+ *
+ * @returns {Promise<string>}: URL (or null if not possible)
+ */
+ async getDownloadUrlFromEmail(email, advancedMethod) {
+ email = email.toLowerCase().trim();
+
+ let url = await getSiteSpecificUrl(email);
+ if (url) {
+ return url;
+ }
+
+ let at = email.indexOf("@");
+
+ let domain = email.substr(at + 1);
+ let user = email.substr(0, at);
+
+ let data = [...new TextEncoder().encode(user)];
+ let ch = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ ch.init(ch.SHA1);
+ ch.update(data, data.length);
+ let gotHash = ch.finish(false);
+ let encodedHash = lazy.EnigmailZBase32.encode(gotHash);
+
+ if (advancedMethod) {
+ url =
+ "https://openpgpkey." +
+ domain +
+ "/.well-known/openpgpkey/" +
+ domain +
+ "/hu/" +
+ encodedHash +
+ "?l=" +
+ escape(user);
+ } else {
+ url =
+ "https://" +
+ domain +
+ "/.well-known/openpgpkey/hu/" +
+ encodedHash +
+ "?l=" +
+ escape(user);
+ }
+
+ return url;
+ },
+
+ /**
+ * Download a key for an email address
+ *
+ * @param {string} email: email address
+ * @param {string} url: url from getDownloadUrlFromEmail()
+ *
+ * @returns {Promise<string>}: Key data (or null if not possible)
+ */
+ async downloadKey(url) {
+ let padLen = (url.length % 512) + 1;
+ let hdrs = new Headers({
+ Authorization: "Basic " + btoa("no-user:"),
+ });
+ hdrs.append("Content-Type", "application/octet-stream");
+ hdrs.append("X-Enigmail-Padding", "x".padEnd(padLen, "x"));
+
+ let myRequest = new Request(url, {
+ method: "GET",
+ headers: hdrs,
+ mode: "cors",
+ //redirect: 'error',
+ redirect: "follow",
+ cache: "default",
+ });
+
+ let response;
+ try {
+ lazy.EnigmailLog.DEBUG(
+ "wkdLookup.jsm: downloadKey: requesting " + url + "\n"
+ );
+ response = await fetch(myRequest);
+ if (!response.ok) {
+ return null;
+ }
+ } catch (ex) {
+ lazy.EnigmailLog.DEBUG(
+ "wkdLookup.jsm: downloadKey: error " + ex.toString() + "\n"
+ );
+ return null;
+ }
+
+ try {
+ if (
+ response.headers.has("content-type") &&
+ response.headers.get("content-type").search(/^text\/html/i) === 0
+ ) {
+ // if we get HTML output, we return nothing (for example redirects to error catching pages)
+ return null;
+ }
+ return lazy.EnigmailData.arrayBufferToString(
+ Cu.cloneInto(await response.arrayBuffer(), {})
+ );
+ } catch (ex) {
+ lazy.EnigmailLog.DEBUG(
+ "wkdLookup.jsm: downloadKey: error " + ex.toString() + "\n"
+ );
+ return null;
+ }
+ },
+
+ isWkdAvailable(email) {
+ let domain = email.toLowerCase().replace(/^.*@/, "");
+
+ return !EXCLUDE_DOMAINS.includes(domain);
+ },
+};
+
+/**
+ * Get special URLs for specific sites that don't use WKD, but still provide
+ * public keys of their users in
+ *
+ * @param {string}: emailAddr: email address in lowercase
+ *
+ * @returns {Promise<string>}: URL or null of no URL relevant
+ */
+async function getSiteSpecificUrl(emailAddr) {
+ let domain = emailAddr.replace(/^.+@/, "");
+ let url = null;
+
+ switch (domain) {
+ case "protonmail.ch":
+ case "protonmail.com":
+ case "pm.me":
+ url =
+ "https://api.protonmail.ch/pks/lookup?op=get&options=mr&search=" +
+ escape(emailAddr);
+ break;
+ }
+ if (!url) {
+ let records = await lazy.DNS.mx(domain);
+ const mxHosts = records.filter(record => record.host);
+
+ if (
+ mxHosts &&
+ (mxHosts.includes("mail.protonmail.ch") ||
+ mxHosts.includes("mailsec.protonmail.ch"))
+ ) {
+ url =
+ "https://api.protonmail.ch/pks/lookup?op=get&options=mr&search=" +
+ escape(emailAddr);
+ }
+ }
+ return url;
+}
diff --git a/comm/mail/extensions/openpgp/content/modules/wksMimeHandler.jsm b/comm/mail/extensions/openpgp/content/modules/wksMimeHandler.jsm
new file mode 100644
index 0000000000..40a8d221f0
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/wksMimeHandler.jsm
@@ -0,0 +1,262 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["EnigmailWksMimeHandler"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailDecryption: "chrome://openpgp/content/modules/decryption.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailSingletons: "chrome://openpgp/content/modules/singletons.jsm",
+ EnigmailVerify: "chrome://openpgp/content/modules/mimeVerify.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+/**
+ * Module for handling response messages from OpenPGP Web Key Service
+ */
+
+var gDebugLog = false;
+
+var EnigmailWksMimeHandler = {
+ /***
+ * register a PGP/MIME verify object the same way PGP/MIME encrypted mail is handled
+ */
+ registerContentTypeHandler() {
+ lazy.EnigmailLog.DEBUG(
+ "wksMimeHandler.jsm: registerContentTypeHandler()\n"
+ );
+ let reg = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+
+ let pgpMimeClass = Cc["@mozilla.org/mimecth;1?type=multipart/encrypted"];
+
+ reg.registerFactory(
+ pgpMimeClass,
+ "Enigmail WKD Response Handler",
+ "@mozilla.org/mimecth;1?type=application/vnd.gnupg.wks",
+ null
+ );
+ },
+
+ newHandler() {
+ lazy.EnigmailLog.DEBUG("wksMimeHandler.jsm: newHandler()\n");
+
+ let v = new PgpWkdHandler();
+ return v;
+ },
+};
+
+// MimeVerify Constructor
+function PgpWkdHandler(protocol) {
+ this.inStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+}
+
+// PgpWkdHandler implementation
+PgpWkdHandler.prototype = {
+ data: "",
+ mimePartNumber: "",
+ uri: null,
+ backgroundJob: false,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onStartRequest(request, ctxt) {
+ lazy.EnigmailLog.DEBUG("wksMimeHandler.jsm: onStartRequest\n"); // always log this one
+
+ this.mimeSvc = request.QueryInterface(Ci.nsIPgpMimeProxy);
+ if ("messageURI" in this.mimeSvc) {
+ this.uri = this.mimeSvc.messageURI;
+ } else {
+ this.uri = ctxt;
+ }
+
+ if ("mimePart" in this.mimeSvc) {
+ this.mimePartNumber = this.mimeSvc.mimePart;
+ } else {
+ this.mimePartNumber = "";
+ }
+ this.data = "";
+ this.msgWindow = lazy.EnigmailVerify.lastWindow;
+ this.backgroundJob = false;
+
+ if (this.uri) {
+ this.backgroundJob =
+ this.uri.spec.search(/[&?]header=(print|quotebody)/) >= 0;
+ }
+ },
+
+ onDataAvailable(req, dummy, stream, offset, count) {
+ if ("messageURI" in this.mimeSvc) {
+ // TB >= 67
+ stream = dummy;
+ count = offset;
+ }
+
+ LOCAL_DEBUG("wksMimeHandler.jsm: onDataAvailable: " + count + "\n");
+ if (count > 0) {
+ this.inStream.init(stream);
+ let data = this.inStream.read(count);
+ this.data += data;
+ }
+ },
+
+ onStopRequest() {
+ lazy.EnigmailLog.DEBUG("wksMimeHandler.jsm: onStopRequest\n");
+
+ if (this.data.search(/-----BEGIN PGP MESSAGE-----/i) >= 0) {
+ this.decryptChallengeData();
+ }
+
+ let jsonStr = this.requestToJsonString(this.data);
+
+ if (this.data.search(/^\s*type:\s+confirmation-request/im) >= 0) {
+ lazy.l10n.formatValue("wkd-message-body-req").then(value => {
+ this.returnData(value);
+ });
+ } else {
+ lazy.l10n.formatValue("wkd-message-body-process").then(value => {
+ this.returnData(value);
+ });
+ }
+
+ this.displayStatus(jsonStr);
+ },
+
+ decryptChallengeData() {
+ lazy.EnigmailLog.DEBUG("wksMimeHandler.jsm: decryptChallengeData()\n");
+ let windowManager = Services.wm;
+ let win = windowManager.getMostRecentWindow(null);
+ let statusFlagsObj = {};
+
+ let res = lazy.EnigmailDecryption.decryptMessage(
+ win,
+ 0,
+ this.data,
+ null, // date
+ {},
+ {},
+ statusFlagsObj,
+ {},
+ {},
+ {},
+ {},
+ {},
+ {}
+ );
+
+ if (statusFlagsObj.value & lazy.EnigmailConstants.DECRYPTION_OKAY) {
+ this.data = res;
+ }
+ lazy.EnigmailLog.DEBUG(
+ "wksMimeHandler.jsm: decryptChallengeData: decryption result: " +
+ res +
+ "\n"
+ );
+ },
+
+ // convert request data into JSON-string and parse it
+ requestToJsonString() {
+ // convert
+ let lines = this.data.split(/\r?\n/);
+ let s = "{";
+ for (let l of lines) {
+ let m = l.match(/^([^\s:]+)(:\s*)([^\s].+)$/);
+ if (m && m.length >= 4) {
+ s += '"' + m[1].trim().toLowerCase() + '": "' + m[3].trim() + '",';
+ }
+ }
+
+ s = s.substr(0, s.length - 1) + "}";
+
+ return s;
+ },
+
+ // return data to libMime
+ returnData(message) {
+ lazy.EnigmailLog.DEBUG("wksMimeHandler.jsm: returnData():\n");
+
+ let msg =
+ 'Content-Type: text/plain; charset="utf-8"\r\n' +
+ "Content-Transfer-Encoding: 8bit\r\n\r\n" +
+ message +
+ "\r\n";
+
+ if ("outputDecryptedData" in this.mimeSvc) {
+ this.mimeSvc.outputDecryptedData(msg, msg.length);
+ } else {
+ let gConv = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ gConv.setData(msg, msg.length);
+ try {
+ this.mimeSvc.onStartRequest(null);
+ this.mimeSvc.onDataAvailable(null, gConv, 0, msg.length);
+ this.mimeSvc.onStopRequest(null, 0);
+ } catch (ex) {
+ lazy.EnigmailLog.ERROR(
+ "wksMimeHandler.jsm: returnData(): mimeSvc.onDataAvailable failed:\n" +
+ ex.toString()
+ );
+ }
+ }
+ },
+
+ displayStatus(jsonStr) {
+ lazy.EnigmailLog.DEBUG("wksMimeHandler.jsm: displayStatus\n");
+ if (this.msgWindow === null || this.backgroundJob) {
+ return;
+ }
+
+ try {
+ LOCAL_DEBUG("wksMimeHandler.jsm: displayStatus displaying result\n");
+ let headerSink = lazy.EnigmailSingletons.messageReader;
+
+ if (headerSink) {
+ headerSink.processDecryptionResult(
+ this.uri,
+ "wksConfirmRequest",
+ jsonStr,
+ this.mimePartNumber
+ );
+ }
+ } catch (ex) {
+ lazy.EnigmailLog.writeException("wksMimeHandler.jsm", ex);
+ }
+ },
+};
+
+////////////////////////////////////////////////////////////////////
+// General-purpose functions, not exported
+
+function LOCAL_DEBUG(str) {
+ if (gDebugLog) {
+ lazy.EnigmailLog.DEBUG(str);
+ }
+}
+
+function initModule() {
+ let nspr_log_modules = Services.env.get("NSPR_LOG_MODULES");
+ let matches = nspr_log_modules.match(/wksMimeHandler:(\d+)/);
+
+ if (matches && matches.length > 1) {
+ if (matches[1] > 2) {
+ gDebugLog = true;
+ }
+ }
+}
+
+initModule();
diff --git a/comm/mail/extensions/openpgp/content/modules/zbase32.jsm b/comm/mail/extensions/openpgp/content/modules/zbase32.jsm
new file mode 100644
index 0000000000..c5587fde3f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/modules/zbase32.jsm
@@ -0,0 +1,108 @@
+/* eslint no-invalid-this: 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EnigmailZBase32"];
+
+const ZBase32Alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769";
+
+var EnigmailZBase32 = {
+ a: ZBase32Alphabet,
+ pad: "=",
+
+ /**
+ * Encode a string in Z-Base-32 encoding
+ *
+ * @param str String - input string
+ *
+ * @returns String - econded string
+ */
+ encode(str) {
+ let a = this.a;
+ let pad = this.pad;
+ let len = str.length;
+ let o = "";
+ let w,
+ c,
+ r = 0,
+ sh = 0;
+
+ for (let i = 0; i < len; i += 5) {
+ // mask top 5 bits
+ c = str.charCodeAt(i);
+ w = 0xf8 & c;
+ o += a.charAt(w >> 3);
+ r = 0x07 & c;
+ sh = 2;
+
+ if (i + 1 < len) {
+ c = str.charCodeAt(i + 1);
+ // mask top 2 bits
+ w = 0xc0 & c;
+ o += a.charAt((r << 2) + (w >> 6));
+ o += a.charAt((0x3e & c) >> 1);
+ r = c & 0x01;
+ sh = 4;
+ }
+
+ if (i + 2 < len) {
+ c = str.charCodeAt(i + 2);
+ // mask top 4 bits
+ w = 0xf0 & c;
+ o += a.charAt((r << 4) + (w >> 4));
+ r = 0x0f & c;
+ sh = 1;
+ }
+
+ if (i + 3 < len) {
+ c = str.charCodeAt(i + 3);
+ // mask top 1 bit
+ w = 0x80 & c;
+ o += a.charAt((r << 1) + (w >> 7));
+ o += a.charAt((0x7c & c) >> 2);
+ r = 0x03 & c;
+ sh = 3;
+ }
+
+ if (i + 4 < len) {
+ c = str.charCodeAt(i + 4);
+ // mask top 3 bits
+ w = 0xe0 & c;
+ o += a.charAt((r << 3) + (w >> 5));
+ o += a.charAt(0x1f & c);
+ r = 0;
+ sh = 0;
+ }
+ }
+ // Calculate length of pad by getting the
+ // number of words to reach an 8th octet.
+ if (r != 0) {
+ o += a.charAt(r << sh);
+ }
+ var padlen = 8 - (o.length % 8);
+
+ if (padlen === 8) {
+ return o;
+ }
+
+ if (padlen === 1 || padlen === 3 || padlen === 4 || padlen === 6) {
+ return o + pad.repeat(padlen);
+ }
+
+ throw new Error(
+ "there was some kind of error:\npadlen:" +
+ padlen +
+ " ,r:" +
+ r +
+ " ,sh:" +
+ sh +
+ ", w:" +
+ w
+ );
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/strings/enigmail.properties b/comm/mail/extensions/openpgp/content/strings/enigmail.properties
new file mode 100644
index 0000000000..c1daee4244
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/strings/enigmail.properties
@@ -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/.
+
+#####################################################################
+# Strings used within enigmailCommon.js and enigmailCommon.jsm
+#####################################################################
+
+### dlgYes=&Yes
+### dlgNo=&No
+### dlg.button.overwrite=&Overwrite
+### dlg.button.ignore=&Ignore
+### dlg.button.install=&Install
+
+#####################################################################
+### Strings in enigmailAbout.js
+#####################################################################
+
+### usingAgent=Using %1$S executable %2$S to encrypt and decrypt
+
+#####################################################################
+# Strings in enigmailKeygen.js
+#####################################################################
+
+### onlyGPG=Key generation only works with GnuPG (not with PGP)!
+
+### keygenComplete=Key generation completed! Identity <%S> will be used for signing.
+### revokeCertRecommended=We highly recommend to create a revocation certificate for your key. This certificate can be used to invalidate your key, e.g. in case your secret key gets lost or compromised. Do you want to create such a revocation certificate now?
+### keyMan.button.generateCert=&Generate Certificate
+### genCompleteNoSign=Key generation completed!
+
+### passNoMatch=Passphrase entries do not match; please re-enter
+### passCheckBox=Please check box if specifying no passphrase for key
+### passUserName=Please specify user name for this identity
+### keygen.passCharProblem=You are using special characters in your passphrase. Unfortunately, this can cause troubles for other applications. We recommend you choose a passphrase consisting only of any of these characters:\na-z A-Z 0-9 /.;:-,!?(){}[]%*
+### passSpaceProblem=Due to technical reasons, your passphrase may not start or end with a space character.
+### changePassFailed=Changing the passphrase failed.
+
+### expiryTooLongShorter=You cannot create a key that expires in more than 90 years.
+### setKeyExpirationDateFailed=The expiration date could not be changed
+
+### notePartEncrypted2=*Parts of the message have NOT been signed nor encrypted*
+### noteCutMessage2=*Multiple message blocks found -- decryption/verification aborted*
+
+### detailsDlg.importKey=Import key
+### wksNoIdentity=This key is not linked to any of your email accounts. Please add an account for at least one of the following email addresse(s):\n\n%S
+### wksConfirmSuccess=Confirmation email sent.
+### wksConfirmFailure=Sending the confirmation email failed.
+
+#####################################################################
+# Strings in enigmailMsgComposeOverlay.js
+#####################################################################
+
+### keysToUse=Select OpenPGP Key(s) to use for %S
+### pubKey=Public key for %S\n
+
+### composeSpecifyEmail=Please specify your primary email address, which will be used to choose the signing key for outgoing messages.\n If you leave it blank, the FROM address of the message will be used to choose the signing key.
+### attachWarning=Attachments to this message are not local, they cannot be encrypted. In order to encrypt the attachments, store them as local files first and attach these files. Do you wish to send the message anyway?
+### warning=Warning
+### signIconClicked=You have manually modified signing. Therefore, while you are composing this message, (de)activating signing does not depend anymore on (de)activating encryption.
+
+### msgCompose.internalEncryptionError=Internal Error: promised encryption disabled
+
+### msgCompose.protectSubject.tooltip=Protect the message subject
+### msgCompose.noSubjectProtection.tooltip=Do not protect the message subject
+### msgCompose.protectSubject.dialogTitle=Enable Protection of Subject?
+### msgCompose.protectSubject.question=Regular encrypted emails contain the unredacted subject.\n\nWe have established a standard to hide the original subject in the encrypted message\nand replace it with a dummy text, such that the subject is only visible after the email is decrypted.\n\nDo you want to protect the subject in encrypted messages?
+### msgCompose.protectSubject.yesButton=&Protect subject
+### msgCompose.protectSubject.noButton=&Leave subject unprotected
+
+# note: should end with double newline:
+
+# details:
+
+### statPGPMIME=PGP/MIME
+### statSMIME=S/MIME
+### statSigned=SIGNED
+### statEncrypted=ENCRYPTED
+### statPlain=UNSIGNED and UNENCRYPTED
+
+### offlineSave=Save %1$S message to %2$S in Unsent Messages folder?
+
+### onlineSend=Send %1$S message to %2$S?
+### encryptKeysNote=Note: The message is encrypted for the following User IDs / Keys: %S
+### hiddenKey=<hidden key>
+
+### msgCompose.button.sendUnencrypted=&Send Unencrypted Message
+### recipientsSelectionHdr=Select Recipients for Encryption
+
+# encryption/signing status and associated reasons:
+### encryptMessageAuto=Encrypt Message (auto)
+### encryptMessageNorm=Encrypt Message
+### signMessageAuto=Sign Message (auto)
+### signMessageNorm=Sign Message
+
+### encryptOff=Encryption: OFF
+### encryptOnWithReason=Encryption: ON (%S)
+### encryptOffWithReason=Encryption: OFF (%S)
+### encryptOn=Encryption: ON
+### signOn=Signing: ON
+### signOff=Signing: OFF
+### signOnWithReason=Signing: ON (%S)
+### signOffWithReason=Signing: OFF (%S)
+### reasonEnabledByDefault=enabled by default
+### reasonManuallyForced=manually forced
+### reasonByRecipientRules=forced by per-recipient rules
+### reasonByAutoEncryption=forced by auto encryption
+### reasonByConflict=due to conflict in per-recipient rules
+### reasonByEncryptionMode=due to encryption mode
+
+# should not be used anymore:
+### encryptYes=Message will be encrypted
+
+# should not be used anymore:
+### signYes=Message will be signed
+
+
+# PGP/MIME status:
+### pgpmimeNormal=Protocol: PGP/MIME
+### inlinePGPNormal=Protocol: Inline PGP
+### smimeNormal=Protocol: S/MIME
+### pgpmimeAuto=Protocol: PGP/MIME (auto)
+### inlinePGPAuto=Protocol: Inline PGP (auto)
+### smimeAuto=Protocol: S/MIME (auto)
+
+# should not be used anymore
+### pgpmimeYes=PGP/MIME will be used
+### pgpmimeNo=Inline PGP will be used
+
+# Attach own key status (tooltip strings):
+### attachOwnKeyYes=Your own public key will be attached
+### attachOwnKeyDisabled=Your own public key cannot be attached. You have to select a specific key\nin the OpenPGP section of the Account Settings to enable this feature.
+
+### rulesConflict=Conflicting per-recipient rules detected\n%S\n\nSend message with these settings?
+### msgCompose.button.configure=&Configure
+### msgCompose.button.save=&Save Message
+
+# Strings in enigmailMsgHdrViewOverlay.js
+### signatureFrom=Signature from public key %S
+### clickDecrypt=; use 'Decrypt/Verify' function
+### clickDecryptRetry=; use 'Decrypt/Verify' function to retry
+### clickDetailsButton=; click on 'Details' button for more information
+### clickImportButton=; click on the 'Import Key' button to import the key
+### keyTypeUnsupported=; the key type is not supported by your version of GnuPG
+### decryptManually=; click on the 'Decrypt' button to decrypt the message
+### verifyManually=; click on the 'Verify' button to verify the signature
+### headerView.button.verify=Verify
+### headerView.button.decrypt=Decrypt
+### msgPart=Part of the message %S
+### msgSigned=signed
+### msgSignedUnkownKey=signed with unknown key
+### msgEncrypted=encrypted
+### msgSignedAndEnc=signed and encrypted
+
+### goodSig=Good signature
+### uncertainSig=Uncertain signature
+### badSig=Bad signature
+### incompleteDecrypt=Decryption incomplete
+### needKey=Error - no matching secret key found to decrypt message
+### badPhrase=Error - bad passphrase
+### missingMdcError=Error - missing or broken integrity protection (MDC)
+### failedDecryptVerify=Error - decryption/verification failed
+### brokenExchangeMessage=Broken PGP/MIME message from MS-Exchange.
+
+### decryptedMsg=Decrypted message
+
+### usedAlgorithms=Used Algorithms: %1$S and %2$S
+
+### wksConfirmationReq=Web Key Directory Confirmation Request
+### wksConfirmationReq.message=This message has been sent by your email provider to confirm deployment of your OpenPGP public key\nin their Web Key Directory.\nProviding your public key helps others to discover your key and thus being able to encrypt messages to you.\n\nIf you want to deploy your key in the Web Key Directory now, please click on the button "Confirm Request" in the status bar.\nOtherwise, simply ignore this message.
+### wksConfirmationReq.button.label=Confirm Request
+
+### autocryptSetupReq.setupMsg.desc=This message contains all information to transfer your Autocrypt settings along with your secret key securely from your original device.
+### autocryptSetupReq.setupMsg.backup=You can keep this message and use it as a backup for your secret key. If you want to do this, you should write down the password and store it securely.
+### autocryptSetupReq.message.sent=Please click on the message on your new device and follow the instuctions to import the settings.
+
+# strings in pref-enigmail.js
+### locateGpg=Locate GnuPG program
+### warningsAreReset=All warnings have been reset.
+### prefs.gpgFound=GnuPG was found in %S
+### prefs.gpgNotFound=Could not find GnuPG
+### prefEnigmail.oneKeyserverOnly=Error - you can only specify one keyserver for automatic downloading of missing OpenPGP keys.
+### acSetupMessage.desc=Transfer your key to another Autocrypt-enabled device. (<html:span class='enigmailLink' href='https://autocrypt.org'>What is Autocrypt</html:span>)
+
+# Strings used in core.jsm
+# (said file also re-uses some strings from above)
+
+### enterAdminPin=Please type in the ADMIN PIN of your SmartCard
+### enterCardPin=Please type your SmartCard PIN
+
+### badCommand=Error - encryption command failed
+### cmdLine=command line and output:
+### notComplete=Error - key generation not yet completed
+
+### noPassphrase=Error - no passphrase supplied
+
+# Strings used in enigmailSingleRcptSettings.js
+### noEncryption=You have activated encryption, but you did not select a key. In order to encrypt emails sent to %1$S, you need to specify one or several valid key(s) from your key list. Do you want to disable encryption for %2$S?
+### noKeyToUse=(none - no encryption)
+### noEmptyRule=The Rule may not be empty! Please set an email address in the Rule field.
+### invalidAddress=The email address(es) you have entered are not valid. You should not set the names of the recipients, just the email addresses. E.g.:\nInvalid: Some Name <some.name@address.net>\nValid: some.name@address.net
+### noCurlyBrackets=The curly brackets {} have a special meaning and should not be used in an email address. If you want to modify the matching behavior for this rule, use the 'Apply rule if recipient ...' option.\nMore information is available from the Help button.
+
+# Strings used in enigmailRulesEditor.js
+### never=Never
+### always=Always
+### possible=Possible
+### deleteRule=Really delete the selected rule?
+### nextRcpt=(Next recipient)
+### negateRule=Not
+### addKeyToRule=Add key %1$S (%2$S) to per-recipient rule
+
+# Strings used in enigmailSearchKey.js
+### noKeyserverConn=Could not connect to keyserver at %S.
+### internalError=An internal error occurred. The keys could not be downloaded or imported.
+### keyDownload.keyUnavailable=The key with ID %S is not available on the keyserver. Most likely, the owner of the key did not upload their key to the keyserver.\n\nPlease ask the sender of the message to send you their public key by email.
+
+# Strings in enigmailEditKeyTrustDlg.xhtml
+### setKeyTrustFailed=Setting owner trust failed
+
+# Strings in enigmailSignKeyDlg.js
+### signKeyFailed=Key signing failed
+### alreadySigned.label=Note: the key %S is already signed with the selected secret key.
+### alreadySignedexportable.label=Note: the key %S is already signed exportable with the selected secret key. A local signature does not make sense.
+### partlySigned.label=Note: some user IDs of key %S are already signed with the selected secret key.
+### noTrustedOwnKeys=No eligible key found for signing! You need at least one fully trusted secret key in order to sign keys.
+
+# Strings in enigmailKeyManager.js
+### keyValid.noSubkey=no valid subkey
+
+### keyType.public=pub
+### keyType.publicAndSec=pub/sec
+
+### addUidOK=User ID added successfully
+### addUidFailed=Adding the User ID failed
+
+### sendKeysOk=Key(s) sent successfully
+### sendKeysFailed=Sending of keys failed
+### receiveKeysOk=Key(s) updated successfully
+### receiveKeysFailed=Downloading of keys failed
+### keyUpload.verifyEmails=The keyserver will send you an email for each email address of your uploaded key. To confirm publication of your key, you'll need to click on the link in each of the emails you'll receive.
+
+### deleteKeyFailed=The key could not be deleted.
+### revokeKeyOk=The key has been revoked. If your key is available on a key server, it is recommended to re-upload it, so that others can see the revocation.
+### revokeKeyFailed=The key could not be revoked.
+### refreshKeyServiceOn.warn=Warning: Your keys are currently being refreshed in the background as safely as possible.\nRefreshing all your keys at once will unnecessarily reveal information about you.\nDo you really want to do this?
+### downloadContactsKeys.importFrom=Import contacts from address book '%S'?
+
+### keylist.noOtherUids=Has no other identities
+### keylist.hasOtherUids=Also known as
+### keylist.noPhotos=No photo available
+### keylist.hasPhotos=Photos
+
+### keyMan.addphoto.filepicker.title=Select photo to add
+### keyMan.addphoto.warnLargeFile=The file you have chosen is larger than 25 kB.\nIt is not recommended to add very large files as it causes very large keys.
+### keyMan.addphoto.noJpegFile=The selected file does not appear to be a JPEG file. Please choose a different file.
+### keyMan.addphoto.failed=The photo could not be added.
+### noWksIdentity=The key %S does not have a WKS identity.
+### wksUpload.noKeySupported=The upload was not successful - your provider does not seem to support WKS.
+
+
+# Strings in enigmailManageUidDlg.xhtml
+### changePrimUidFailed=Changing the primary User ID failed
+### changePrimUidOK=The primary user ID was changed successfully
+### revokeUidFailed=Revoking the user ID %S failed
+### revokeUidOK=User ID %S was revoked successfully. If your key is available on a key server, it is recommended to re-upload it, so that others can see the revocation.
+### revokeUidQuestion=Do you really want to revoke the user ID %S?
+
+# Strings in enigmailGenCardKey.xhtml
+### keygen.started=Please wait while the key is being generated ....
+### keygen.completed=Key Generated. The new Key ID is: 0x%S
+### keygen.keyBackup=The key is backed up as %S
+### keygen.passRequired=Please specify a passphrase if you want to create a backup copy of your key outside your SmartCard.
+
+# Strings in enigmailSetCardPin.xhtml
+### cardPin.processFailed=Failed to change PIN
+
+# Strings in enigRetrieveProgres.js
+### keyserverProgress.refreshing=Refreshing keys, please wait ...
+### keyserverProgress.uploading=Uploading keys, please wait ...
+### keyserverProgress.wksUploadFailed=Could not upload your key to the Web Key Service
+### keyserverProgress.wksUploadCompleted=Your public key was successfully submitted to your provider. You will receive an email to confirm that you initiated the upload.
+### keyserverTitle.refreshing=Refresh Keys
+### keyserverTitle.uploading=Key Upload
+### keyserver.result.download.none=No key downloaded.
+### keyserver.result.download.1of1=Key successfully downloaded.
+### keyserver.result.download.1ofN=Successfully downloaded 1 of %S keys.
+### keyserver.result.download.NofN=Successfully downloaded %1$S of %2$S keys.
+### keyserver.result.uploadOne=Successfully uploaded 1 key.
+### keyserver.result.uploadMany=Successfully uploaded %S keys.
+
+# Strings in installGnuPG.jsm
+### installGnuPG.downloadFailed=An error occurred while trying to download GnuPG. Please check the console log for further details.
+### installGnuPG.installFailed=An error occurred while installing GnuPG. Please check the console log for further details.
+
+# Strings in enigmailAddUidDlg.xhtml
+### addUidDlg.nameOrEmailError=You have to fill in a name and an email address
+### addUidDlg.nameMinLengthError=The name must at least have 5 characters
+### addUidDlg.invalidEmailError=You must specify a valid email address
+
+# Strings in enigmailCardDetails.js
+### Carddetails.NoASCII=OpenPGP Smartcards only support ASCII characters in Firstname/Name.
+
+# network error types
+### errorType.SecurityCertificate=The security certificate presented by the web service is not valid.
+### errorType.SecurityProtocol=The security protocol used by the web service is unknown.
+### errorType.Network=A network error has occurred.
+
+### converter.decryptAtt.failed=Could not decrypt attachment '%1$S'\nof message with subject\n'%2$S'.\nDo you want to retry with a different passphrase or do you want to skip the message?
+
+### saveLogFile.title=Save Log File
+
+#strings in exportSettingsWizard.js
+### cannotWriteToFile=Cannot save to file '%S'. Please select a different file.
+### dataExportError=An error occurred during exporting your data.
+### specifyExportFile=Specify file name for exporting
+### homedirParamNotSUpported=Additional parameters that configure paths such as --homedir and --keyring are not supported for exporting/restoring your settings. Please use alternative methods such as setting the environment variable GNUPGHOME.
+
+#strings in gpgAgent.jsm
+### gpghomedir.notexists=The directory '%S' containing your OpenPGP keys does not exist and cannot be created.
+### gpghomedir.notwritable=The directory '%S' containing your OpenPGP keys is not writable.
+### gpghomedir.notdirectory=The directory '%S' containing your OpenPGP keys is a file instead of a directory.
+### gpghomedir.notusable=Please fix the directory permissions or change the location of your GnuPG "home" directory. GnuPG cannot work correctly otherwise.
+
+### handshakeDlg.button.initHandshake=Handshake...
+### handshakeDlg.button.stopTrust=Stop Trusting
+### handshakeDlg.button.reTrust=Stop Mistrusting
+### handshakeDlg.label.outgoingMessage=Outgoing message
+### handshakeDlg.label.incomingMessage=Incoming message
+### handshakeDlg.error.noPeers=Cannot handshake without any correspondents.
+### handshakeDlg.error.noProtection=Please enable protection in order to use the "Handshake" function.
+
+### enigmail.acSetupPasswd.descEnterPasswd=Please enter the setup code that is displayed on the other device.
+### enigmail.acSetupPasswd.descCopyPasswd=Please enter the setup code below on your other device to proceed with the setup.
+
+#strings in autocrypt.jsm
+
+### autocrypt.setupMsg.subject=Autocrypt Setup Message
+### autocrypt.setupMsg.msgBody=To set up your new device for Autocrypt, please follow the instuctions that should be presented by your new device.
+### autocrypt.setupMsg.fileTxt=This is the Autocrypt setup file used to transfer settings and keys between clients. You can decrypt it using the setup code displayed on your old device, then import the key to your keyring.
+
+#strings in gnupg-key.jsm
+### import.secretKeyImportError=An error has occurred in GnuPG while importing secret keys. The import was not successful.
+
+#strings in importSettings.js
+### importSettings.errorNoFile=The file you specified is not a regular file!
+### importSettings.cancelWhileInProgress=Restoring is in progress. Do you really want to abort the process?
+### importSettings.button.abortImport=&Abort process
diff --git a/comm/mail/extensions/openpgp/content/ui/attachmentItemContext.inc.xhtml b/comm/mail/extensions/openpgp/content/ui/attachmentItemContext.inc.xhtml
new file mode 100644
index 0000000000..060752b497
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/attachmentItemContext.inc.xhtml
@@ -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/.
+
+ <menuseparator id="openpgpCtxItemsSeparator"/>
+ <menuitem id="enigmail_ctxImportKey"
+ data-l10n-id="openpgp-ctx-import-key"
+ oncommand="Enigmail.msg.handleAttachmentSel('importKey');"/>
+ <menuitem id="enigmail_ctxDecryptOpen"
+ data-l10n-id="openpgp-ctx-decrypt-open"
+ oncommand="Enigmail.msg.handleAttachmentSel('openAttachment');"/>
+ <menuitem id="enigmail_ctxDecryptSave"
+ data-l10n-id="openpgp-ctx-decrypt-save"
+ oncommand="Enigmail.msg.handleAttachmentSel('saveAttachment');"/>
+ <menuitem id="enigmail_ctxVerifyAtt"
+ data-l10n-id="openpgp-ctx-verify-att"
+ oncommand="Enigmail.msg.handleAttachmentSel('verifySig');"/>
diff --git a/comm/mail/extensions/openpgp/content/ui/backupKeyPassword.js b/comm/mail/extensions/openpgp/content/ui/backupKeyPassword.js
new file mode 100644
index 0000000000..9916429bcb
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/backupKeyPassword.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/. */
+"use strict";
+
+/**
+ * @file Implements the functionality of backupKeyPassword.xhtml:
+ * a dialog that lets the user enter the password used to protect
+ * a backup of OpenPGP secret keys.
+ * Based on setp12password.js and setp12password.xhtml
+ */
+
+/**
+ * @property {boolean} confirmedPassword
+ * Set to true if the user entered two matching passwords and
+ * confirmed the dialog.
+ * @property {string} password
+ * The password the user entered. Undefined value if
+ * |confirmedPassword| is not true.
+ */
+
+let gAcceptButton;
+
+window.addEventListener("DOMContentLoaded", onLoad);
+
+/**
+ * onload() handler.
+ */
+function onLoad() {
+ // Ensure the first password textbox has focus.
+ document.getElementById("pw1").focus();
+ document.addEventListener("dialogaccept", onDialogAccept);
+ gAcceptButton = document
+ .getElementById("backupKeyPassword")
+ .getButton("accept");
+ gAcceptButton.disabled = true;
+}
+
+/**
+ * ondialogaccept() handler.
+ */
+function onDialogAccept() {
+ window.arguments[0].okCallback(
+ document.getElementById("pw1").value,
+ window.arguments[0].fprArray,
+ window.arguments[0].file,
+ true
+ );
+}
+
+/**
+ * Calculates the strength of the given password, suitable for use in updating
+ * a progress bar that represents said strength.
+ *
+ * The strength of the password is calculated by checking the number of:
+ * - Characters
+ * - Numbers
+ * - Non-alphanumeric chars
+ * - Upper case characters
+ *
+ * @param {string} password
+ * The password to calculate the strength of.
+ * @returns {number}
+ * The strength of the password in the range [0, 100].
+ */
+function getPasswordStrength(password) {
+ let lengthStrength = password.length;
+ if (lengthStrength > 5) {
+ lengthStrength = 5;
+ }
+
+ let nonNumericChars = password.replace(/[0-9]/g, "");
+ let numericStrength = password.length - nonNumericChars.length;
+ if (numericStrength > 3) {
+ numericStrength = 3;
+ }
+
+ let nonSymbolChars = password.replace(/\W/g, "");
+ let symbolStrength = password.length - nonSymbolChars.length;
+ if (symbolStrength > 3) {
+ symbolStrength = 3;
+ }
+
+ let nonUpperAlphaChars = password.replace(/[A-Z]/g, "");
+ let upperAlphaStrength = password.length - nonUpperAlphaChars.length;
+ if (upperAlphaStrength > 3) {
+ upperAlphaStrength = 3;
+ }
+
+ let strength =
+ lengthStrength * 10 -
+ 20 +
+ numericStrength * 10 +
+ symbolStrength * 15 +
+ upperAlphaStrength * 10;
+ if (strength < 0) {
+ strength = 0;
+ }
+ if (strength > 100) {
+ strength = 100;
+ }
+
+ return strength;
+}
+
+/**
+ * oninput() handler for both password textboxes.
+ *
+ * @param {boolean} recalculatePasswordStrength
+ * Whether to recalculate the strength of the first password.
+ */
+function onPasswordInput(recalculatePasswordStrength) {
+ let pw1 = document.getElementById("pw1").value;
+
+ if (recalculatePasswordStrength) {
+ document.getElementById("pwmeter").value = getPasswordStrength(pw1);
+ }
+
+ // Disable the accept button if the two passwords don't match, and enable it
+ // if the passwords do match.
+ let pw2 = document.getElementById("pw2").value;
+ gAcceptButton.disabled = pw1 != pw2 || !pw1.length;
+}
diff --git a/comm/mail/extensions/openpgp/content/ui/backupKeyPassword.xhtml b/comm/mail/extensions/openpgp/content/ui/backupKeyPassword.xhtml
new file mode 100644
index 0000000000..1fe18e1b0f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/backupKeyPassword.xhtml
@@ -0,0 +1,67 @@
+<?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://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/accountManage.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/backupKeyPassword.css" type="text/css"?>
+
+<html
+ 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"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="set-password-window-title"></title>
+ <link rel="localization" href="messenger/openpgp/backupKeyPassword.ftl" />
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js" />
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js" />
+ <script
+ defer="defer"
+ src="chrome://openpgp/content/ui/backupKeyPassword.js"
+ />
+ </head>
+ <body>
+ <xul:dialog id="backupKeyPassword" buttons="accept,cancel">
+ <p data-l10n-id="set-password-message"></p>
+
+ <fieldset>
+ <legend data-l10n-id="set-password-legend"></legend>
+
+ <label for="pw1" data-l10n-id="set-password-backup-pw-label" />
+ <input
+ id="pw1"
+ type="password"
+ class="input-inline"
+ oninput="onPasswordInput(true);"
+ />
+
+ <label for="pw2" data-l10n-id="set-password-backup-pw2-label" />
+ <input
+ id="pw2"
+ type="password"
+ class="input-inline"
+ oninput="onPasswordInput(false);"
+ />
+
+ <label for="pwmeter" data-l10n-id="password-quality-meter" />
+ <progress id="pwmeter" value="0" max="100"></progress>
+ </fieldset>
+
+ <div class="inline-notification-container info-container self-center">
+ <img
+ class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt=""
+ />
+ <p data-l10n-id="set-password-reminder"></p>
+ </div>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/extensions/openpgp/content/ui/changeExpiryDlg.js b/comm/mail/extensions/openpgp/content/ui/changeExpiryDlg.js
new file mode 100644
index 0000000000..f6974ddce9
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/changeExpiryDlg.js
@@ -0,0 +1,152 @@
+/* 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 { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+var { EnigmailKey } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/key.jsm"
+);
+var { RNP, RnpPrivateKeyUnlockTracker } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/RNP.jsm"
+);
+
+let gFingerprints = [];
+let gKeyCreated;
+
+window.addEventListener("DOMContentLoaded", onLoad);
+function onLoad() {
+ let keyId = window.arguments[0].keyId;
+ let keyObj = EnigmailKeyRing.getKeyById(window.arguments[0].keyId);
+ if (!keyObj) {
+ throw new Error(`Key not found: ${keyId}`);
+ }
+ if (!keyObj.secretAvailable) {
+ keyObj = null;
+ throw new Error(`Not your key: ${keyId}`);
+ }
+
+ if (!keyObj.iSimpleOneSubkeySameExpiry()) {
+ window.close();
+ return;
+ }
+
+ gFingerprints = [keyObj.fpr, keyObj.subKeys[0].fpr];
+ gKeyCreated = keyObj.keyCreated;
+
+ let currentExpiryInfo = document.getElementById("info-current-expiry");
+
+ if (!keyObj.expiryTime) {
+ document.l10n.setAttributes(currentExpiryInfo, "info-does-not-expire");
+ } else {
+ let nowSeconds = Math.floor(Date.now() / 1000);
+ if (keyObj.expiryTime < nowSeconds) {
+ document.l10n.setAttributes(currentExpiryInfo, "info-already-expired");
+ } else {
+ document.l10n.setAttributes(currentExpiryInfo, "info-will-expire", {
+ date: keyObj.expiry,
+ });
+ }
+ }
+
+ // Don't explain how to use longer, if this key already never expires.
+ document.getElementById("longerUsage").hidden = !keyObj.expiryTime;
+
+ let popup = document.getElementById("expiry-in");
+ let rtf = new Intl.RelativeTimeFormat(undefined, {
+ numeric: "always",
+ style: "long",
+ });
+ let today = new Date();
+ for (let i = 1; i < 24; i++) {
+ let d = new Date(
+ today.getFullYear(),
+ today.getMonth() + i,
+ today.getDate()
+ );
+ let option = document.createElement("option");
+ option.value = Math.floor(d.getTime() / 1000); // In seconds.
+ option.label = rtf.format(i, "month");
+ popup.appendChild(option);
+ }
+ for (let i = 2; i <= 10; i++) {
+ let d = new Date(
+ today.getFullYear() + i,
+ today.getMonth(),
+ today.getDate()
+ );
+ let option = document.createElement("option");
+ option.value = Math.floor(d.getTime() / 1000); // In seconds.
+ option.label = rtf.format(i, "year");
+ popup.appendChild(option);
+ }
+ if (keyObj.expiryTime) {
+ popup.selectedIndex = [...popup.children].findIndex(
+ o => o.value >= keyObj.expiryTime
+ );
+ } else {
+ popup.selectedIndex = 23; // 2 years
+ }
+ document.getElementById("radio-expire-yes").value = popup.value;
+
+ popup.addEventListener("change", event => {
+ document.getElementById("radio-expire-yes").value = event.target.value;
+ document.getElementById("radio-expire-yes").checked = true;
+ });
+}
+
+async function onAccept() {
+ let expirySecs = +document.querySelector("input[name='expiry']:checked")
+ .value;
+ if (expirySecs < 0) {
+ // Keep.
+ return true;
+ }
+ // Key Expiration Time - this is the number of seconds after the key creation
+ // time that the key expires.
+ let keyExpirationTime = expirySecs ? expirySecs - gKeyCreated : 0;
+
+ let pwCache = {
+ passwords: [],
+ };
+
+ let unlockFailed = false;
+ let keyTrackers = [];
+ for (let fp of gFingerprints) {
+ let tracker = RnpPrivateKeyUnlockTracker.constructFromFingerprint(fp);
+ tracker.setAllowPromptingUserForPassword(true);
+ tracker.setAllowAutoUnlockWithCachedPasswords(true);
+ tracker.setPasswordCache(pwCache);
+ await tracker.unlock();
+ keyTrackers.push(tracker);
+ if (!tracker.isUnlocked()) {
+ unlockFailed = true;
+ break;
+ }
+ }
+
+ let rv = false;
+ if (!unlockFailed) {
+ rv = RNP.changeExpirationDate(gFingerprints, keyExpirationTime);
+ }
+
+ for (let t of keyTrackers) {
+ t.release();
+ }
+ return rv;
+}
+
+document.addEventListener("dialogaccept", async function (event) {
+ // Prevent the closing of the dialog to wait until the call
+ // to onAccept() has properly returned.
+ event.preventDefault();
+ let result = await onAccept();
+ // If the change was unsuccessful, leave this dialog open.
+ if (!result) {
+ return;
+ }
+ // Otherwise, update the parent window and close the dialog.
+ window.arguments[0].modified();
+ window.close();
+});
diff --git a/comm/mail/extensions/openpgp/content/ui/changeExpiryDlg.xhtml b/comm/mail/extensions/openpgp/content/ui/changeExpiryDlg.xhtml
new file mode 100644
index 0000000000..ddfd67ce24
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/changeExpiryDlg.xhtml
@@ -0,0 +1,73 @@
+<?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://messenger/skin/accountManage.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/changeExpiryDlg.css" type="text/css"?>
+<html
+ 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"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="openpgp-change-expiry-title"></title>
+ <link rel="localization" href="messenger/openpgp/changeExpiryDlg.ftl" />
+ <script
+ defer="defer"
+ src="chrome://openpgp/content/ui/changeExpiryDlg.js"
+ ></script>
+ </head>
+ <body>
+ <xul:dialog id="changeExpiryDlg" buttons="cancel,accept">
+ <div class="inline-notification-container info-container self-center">
+ <img
+ class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt=""
+ />
+ <p data-l10n-id="info-explanation-1"></p>
+ </div>
+
+ <p id="info-current-expiry"></p>
+
+ <p id="longerUsage" data-l10n-id="info-explanation-2"></p>
+
+ <fieldset>
+ <div>
+ <input
+ id="radio-expire-keep"
+ type="radio"
+ name="expiry"
+ value="-1"
+ checked="checked"
+ />
+ <label
+ for="radio-expire-keep"
+ data-l10n-id="expire-no-change-label"
+ />
+ </div>
+
+ <div>
+ <input id="radio-expire-yes" type="radio" name="expiry" value="" />
+ <label for="radio-expire-yes" data-l10n-id="expire-in-time-label" />
+
+ <select id="expiry-in"></select>
+ </div>
+
+ <div>
+ <input id="radio-expire-no" type="radio" name="expiry" value="0" />
+ <label
+ for="radio-expire-no"
+ data-l10n-id="expire-never-expire-label"
+ />
+ </div>
+ </fieldset>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/extensions/openpgp/content/ui/commonWorkflows.js b/comm/mail/extensions/openpgp/content/ui/commonWorkflows.js
new file mode 100644
index 0000000000..ac6c054e2f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/commonWorkflows.js
@@ -0,0 +1,194 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var { EnigmailDialog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/dialog.jsm"
+);
+var { EnigmailKey } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/key.jsm"
+);
+var { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+var { EnigmailArmor } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/armor.jsm"
+);
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+var l10n = new Localization(["messenger/openpgp/openpgp.ftl"], true);
+
+/**
+ * opens a prompt, asking the user to enter passphrase for given key id
+ * returns: the passphrase if entered (empty string is allowed)
+ * resultFlags.canceled is set to true if the user clicked cancel
+ */
+function passphrasePromptCallback(win, promptString, resultFlags) {
+ let password = { value: "" };
+ if (!Services.prompt.promptPassword(win, "", promptString, password)) {
+ resultFlags.canceled = true;
+ return "";
+ }
+
+ resultFlags.canceled = false;
+ return password.value;
+}
+
+/**
+ * @param {nsIFile} file
+ * @returns {string} The first block of the wanted type, or empty string.
+ * Skip blocks of wrong type.
+ */
+async function getKeyBlockFromFile(file, wantSecret) {
+ let contents = await IOUtils.readUTF8(file.path).catch(() => "");
+ let searchOffset = 0;
+
+ while (searchOffset < contents.length) {
+ const beginIndexObj = {};
+ const endIndexObj = {};
+ const blockType = EnigmailArmor.locateArmoredBlock(
+ contents,
+ searchOffset,
+ "",
+ beginIndexObj,
+ endIndexObj,
+ {}
+ );
+ if (!blockType) {
+ return "";
+ }
+
+ if (
+ (wantSecret && blockType.search(/^PRIVATE KEY BLOCK$/) !== 0) ||
+ (!wantSecret && blockType.search(/^PUBLIC KEY BLOCK$/) !== 0)
+ ) {
+ searchOffset = endIndexObj.value;
+ continue;
+ }
+
+ return contents.substr(
+ beginIndexObj.value,
+ endIndexObj.value - beginIndexObj.value + 1
+ );
+ }
+ return "";
+}
+
+/**
+ * import OpenPGP keys from file
+ *
+ * @param {string} what - "rev" for revocation, "pub" for public keys
+ */
+async function EnigmailCommon_importObjectFromFile(what) {
+ if (what != "rev" && what != "pub") {
+ throw new Error(`Can't import. Invalid argument: ${what}`);
+ }
+
+ let importingRevocation = what == "rev";
+ let promptStr = importingRevocation ? "import-rev-file" : "import-key-file";
+
+ let files = EnigmailDialog.filePicker(
+ window,
+ l10n.formatValueSync(promptStr),
+ "",
+ false,
+ true,
+ "*.asc",
+ "",
+ [l10n.formatValueSync("gnupg-file"), "*.asc;*.gpg;*.pgp"]
+ );
+
+ if (!files.length) {
+ return;
+ }
+
+ for (let file of files) {
+ if (file.fileSize > 5000000) {
+ document.l10n.formatValue("file-to-big-to-import").then(value => {
+ EnigmailDialog.alert(window, value);
+ });
+ continue;
+ }
+
+ let errorMsgObj = {};
+
+ if (importingRevocation) {
+ await EnigmailKeyRing.importRevFromFile(file);
+ continue;
+ }
+
+ let importBinary = false;
+ let keyBlock = await getKeyBlockFromFile(file, false);
+
+ // if we don't find an ASCII block, try to import as binary.
+ if (!keyBlock) {
+ importBinary = true;
+ let data = await IOUtils.read(file.path);
+ keyBlock = MailStringUtils.uint8ArrayToByteString(data);
+ }
+
+ // Generate a preview of the imported key.
+ let preview = await EnigmailKey.getKeyListFromKeyBlock(
+ keyBlock,
+ errorMsgObj,
+ true, // interactive
+ true,
+ false // not secret
+ );
+
+ if (!preview || !preview.length || errorMsgObj.value) {
+ document.l10n.formatValue("import-keys-failed").then(value => {
+ EnigmailDialog.alert(window, value + "\n\n" + errorMsgObj.value);
+ });
+ continue;
+ }
+
+ if (preview.length > 0) {
+ let confirmImport = false;
+ let autoAcceptance = null;
+ let outParam = {};
+ confirmImport = EnigmailDialog.confirmPubkeyImport(
+ window,
+ preview,
+ outParam
+ );
+ if (confirmImport) {
+ autoAcceptance = outParam.acceptance;
+ }
+
+ if (confirmImport) {
+ // import
+ let resultKeys = {};
+
+ let importExitCode = EnigmailKeyRing.importKey(
+ window,
+ false, // interactive, we already asked for confirmation
+ keyBlock,
+ importBinary,
+ null, // expected keyId, ignored
+ errorMsgObj,
+ resultKeys,
+ false, // minimize
+ [], // filter
+ true, // allow prompt for permissive
+ autoAcceptance
+ );
+
+ if (importExitCode !== 0) {
+ document.l10n.formatValue("import-keys-failed").then(value => {
+ EnigmailDialog.alert(window, value + "\n\n" + errorMsgObj.value);
+ });
+ continue;
+ }
+
+ EnigmailDialog.keyImportDlg(window, resultKeys.value);
+ }
+ }
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/ui/composeKeyStatus.js b/comm/mail/extensions/openpgp/content/ui/composeKeyStatus.js
new file mode 100644
index 0000000000..a6320072ab
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/composeKeyStatus.js
@@ -0,0 +1,222 @@
+/* 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 { EnigmailFuncs } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/funcs.jsm"
+);
+var EnigmailKeyRing = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+).EnigmailKeyRing;
+var { EnigmailWindows } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/windows.jsm"
+);
+var { EnigmailKey } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/key.jsm"
+);
+const { OpenPGPAlias } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/OpenPGPAlias.jsm"
+);
+const { PgpSqliteDb2 } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/sqliteDb.jsm"
+);
+
+var gListBox;
+var gViewButton;
+
+var gEmailAddresses = [];
+var gRowToEmail = [];
+
+// One boolean entry per row. True means it is an alias row.
+// This allows us to use different dialog behavior for alias entries.
+var gAliasRows = [];
+
+var gMapAddressToKeyObjs = null;
+
+function addRecipients(toAddrList, recList) {
+ for (var i = 0; i < recList.length; i++) {
+ try {
+ let entry = EnigmailFuncs.stripEmail(recList[i].replace(/[",]/g, ""));
+ toAddrList.push(entry);
+ } catch (ex) {
+ console.debug(ex);
+ }
+ }
+}
+
+async function setListEntries() {
+ gMapAddressToKeyObjs = new Map();
+
+ for (let addr of gEmailAddresses) {
+ addr = addr.toLowerCase();
+
+ let statusStringID = null;
+ let statusStringDirect = "";
+
+ let aliasKeyList = EnigmailKeyRing.getAliasKeyList(addr);
+ let isAlias = !!aliasKeyList;
+
+ if (isAlias) {
+ let aliasKeys = EnigmailKeyRing.getAliasKeys(aliasKeyList);
+ if (!aliasKeys.length) {
+ // failure, at least one alias key is unusable/unavailable
+ statusStringDirect = await document.l10n.formatValue(
+ "openpgp-compose-alias-status-error"
+ );
+ } else {
+ statusStringDirect = await document.l10n.formatValue(
+ "openpgp-compose-alias-status-direct",
+ {
+ count: aliasKeys.length,
+ }
+ );
+ }
+ } else {
+ // We ask to include keys which are expired, because that's what
+ // our sub dialog oneRecipientStatus needs. This is for
+ // efficiency - because otherwise the sub dialog would have to
+ // query all keys again.
+ // The consequence is, we need to later call isValidForEncryption
+ // for the keys we have obtained, to confirm they are really valid.
+ let foundKeys = await EnigmailKeyRing.getMultValidKeysForOneRecipient(
+ addr,
+ true
+ );
+ if (!foundKeys || !foundKeys.length) {
+ statusStringID = "openpgp-recip-missing";
+ } else {
+ gMapAddressToKeyObjs.set(addr, foundKeys);
+ for (let keyObj of foundKeys) {
+ let goodPersonal = false;
+ if (keyObj.secretAvailable) {
+ goodPersonal = await PgpSqliteDb2.isAcceptedAsPersonalKey(
+ keyObj.fpr
+ );
+ }
+ if (
+ goodPersonal ||
+ (EnigmailKeyRing.isValidForEncryption(keyObj) &&
+ (keyObj.acceptance == "verified" ||
+ keyObj.acceptance == "unverified"))
+ ) {
+ statusStringID = "openpgp-recip-good";
+ break;
+ }
+ }
+ if (!statusStringID) {
+ statusStringID = "openpgp-recip-none-accepted";
+ }
+ }
+ }
+
+ let listitem = document.createXULElement("richlistitem");
+
+ let emailItem = document.createXULElement("label");
+ emailItem.setAttribute("value", addr);
+ emailItem.setAttribute("crop", "end");
+ emailItem.setAttribute("style", "width: var(--recipientWidth)");
+ listitem.appendChild(emailItem);
+
+ let status = document.createXULElement("label");
+
+ if (statusStringID) {
+ document.l10n.setAttributes(status, statusStringID);
+ } else {
+ status.setAttribute("value", statusStringDirect);
+ }
+
+ status.setAttribute("crop", "end");
+ status.setAttribute("style", "width: var(--statusWidth)");
+ listitem.appendChild(status);
+
+ gListBox.appendChild(listitem);
+
+ gRowToEmail.push(addr);
+ gAliasRows.push(isAlias);
+ }
+}
+
+async function onLoad() {
+ let params = window.arguments[0];
+ if (!params) {
+ return;
+ }
+
+ try {
+ await OpenPGPAlias.load();
+ } catch (ex) {
+ console.log("failed to load OpenPGP alias file: " + ex);
+ }
+
+ gListBox = document.getElementById("infolist");
+ gViewButton = document.getElementById("detailsButton");
+
+ var arrLen = {};
+ var recList;
+
+ if (params.compFields.to) {
+ recList = params.compFields.splitRecipients(
+ params.compFields.to,
+ true,
+ arrLen
+ );
+ addRecipients(gEmailAddresses, recList);
+ }
+ if (params.compFields.cc) {
+ recList = params.compFields.splitRecipients(
+ params.compFields.cc,
+ true,
+ arrLen
+ );
+ addRecipients(gEmailAddresses, recList);
+ }
+ if (params.compFields.bcc) {
+ recList = params.compFields.splitRecipients(
+ params.compFields.bcc,
+ true,
+ arrLen
+ );
+ addRecipients(gEmailAddresses, recList);
+ }
+
+ await setListEntries();
+}
+
+async function reloadAndReselect(selIndex = -1) {
+ while (true) {
+ let child = gListBox.lastChild;
+ // keep first child, which is the header
+ if (child == gListBox.firstChild) {
+ break;
+ }
+ gListBox.removeChild(child);
+ }
+ gRowToEmail = [];
+ await setListEntries();
+ gListBox.selectedIndex = selIndex;
+}
+
+function onSelectionChange(event) {
+ // We don't offer detail management/discovery for email addresses
+ // that match an alias rule.
+ gViewButton.disabled =
+ !gListBox.selectedItems.length || gAliasRows[gListBox.selectedIndex];
+}
+
+function viewSelectedEmail() {
+ let selIndex = gListBox.selectedIndex;
+ if (gViewButton.disabled || selIndex == -1) {
+ return;
+ }
+ let email = gRowToEmail[selIndex];
+ window.openDialog(
+ "chrome://openpgp/content/ui/oneRecipientStatus.xhtml",
+ "",
+ "chrome,modal,resizable,centerscreen",
+ {
+ email,
+ keys: gMapAddressToKeyObjs.get(email),
+ }
+ );
+ reloadAndReselect(selIndex);
+}
diff --git a/comm/mail/extensions/openpgp/content/ui/composeKeyStatus.xhtml b/comm/mail/extensions/openpgp/content/ui/composeKeyStatus.xhtml
new file mode 100644
index 0000000000..fc0192a8a6
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/composeKeyStatus.xhtml
@@ -0,0 +1,94 @@
+<?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://messenger/skin/openpgp/openPgpComposeStatus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<window
+ data-l10n-id="openpgp-compose-key-status-title"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="width: 40em; height: 25em"
+ persist="width height"
+ lightweightthemes="true"
+ onload="onLoad();"
+>
+ <dialog id="composeKeyStatus" buttons="accept">
+ <script src="chrome://openpgp/content/ui/composeKeyStatus.js" />
+ <script>
+ <![CDATA[
+ function resizeColumns() {
+ let list = document.getElementById("infolist");
+ let cols = list.getElementsByTagName("treecol");
+ list.style.setProperty("--recipientWidth", cols[0].getBoundingClientRect().width + "px");
+ list.style.setProperty("--statusWidth", cols[1].getBoundingClientRect().width + "px");
+ }
+ addEventListener("load", resizeColumns, { once: true });
+ addEventListener("resize", resizeColumns);
+ ]]>
+ </script>
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/openpgp/composeKeyStatus.ftl"
+ />
+ </linkset>
+
+ <description data-l10n-id="openpgp-compose-key-status-intro-need-keys" />
+
+ <separator class="thin" />
+ <label
+ data-l10n-id="openpgp-compose-key-status-keys-heading"
+ control="infolist"
+ />
+
+ <richlistbox
+ id="infolist"
+ class="theme-listbox"
+ flex="1"
+ onselect="onSelectionChange(event);"
+ >
+ <treecols>
+ <treecol
+ id="recipientComposeKeyCol"
+ data-l10n-id="openpgp-compose-key-status-recipient"
+ />
+ <treecol
+ id="statusComposeKeyCol"
+ data-l10n-id="openpgp-compose-key-status-status"
+ />
+ </treecols>
+ </richlistbox>
+ <hbox pack="start">
+ <button
+ id="detailsButton"
+ disabled="true"
+ data-l10n-id="openpgp-compose-key-status-open-details"
+ oncommand="viewSelectedEmail();"
+ />
+ </hbox>
+
+ <separator class="thin" />
+
+ <vbox flex="1">
+ <html:span
+ class="tail-with-learn-more"
+ data-l10n-id="openpgp-compose-general-info-alias"
+ ></html:span>
+ <label
+ is="text-link"
+ id="openPgpAliasLearnMore"
+ href="https://support.mozilla.org/kb/openpgp-recipient-alias-configuration"
+ data-l10n-id="openpgp-compose-general-info-alias-learn-more"
+ />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/extensions/openpgp/content/ui/confirmPubkeyImport.js b/comm/mail/extensions/openpgp/content/ui/confirmPubkeyImport.js
new file mode 100644
index 0000000000..d575c111b7
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/confirmPubkeyImport.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/. */
+
+"use strict";
+
+// Dialog event listeners.
+window.addEventListener("dialogaccept", onAccept);
+window.addEventListener("load", init);
+
+var gUndecided = null;
+var gUnverified = null;
+
+async function init() {
+ let num = window.arguments[0].keys.length;
+ let label1 = document.getElementById("importInfo");
+ document.l10n.setAttributes(label1, "openpgp-pubkey-import-intro", {
+ num,
+ });
+ let label2 = document.getElementById("acceptInfo");
+ document.l10n.setAttributes(label2, "openpgp-pubkey-import-accept", {
+ num,
+ });
+
+ let l10nElements = [];
+ l10nElements.push(label1);
+ l10nElements.push(label2);
+
+ // TODO: This should be changed to use data-l10n-id in the .xhtml
+ // at a later time. We reuse strings on the 78 branch that don't have
+ // the .label definition in the .ftl file.
+
+ let [rUnd, rUnv] = await document.l10n.formatValues([
+ { id: "openpgp-key-undecided" },
+ { id: "openpgp-key-unverified" },
+ ]);
+
+ gUndecided = document.getElementById("acceptUndecided");
+ gUndecided.label = rUnd;
+ gUnverified = document.getElementById("acceptUnverified");
+ gUnverified.label = rUnv;
+
+ let keyList = document.getElementById("importKeyList");
+
+ for (let key of window.arguments[0].keys) {
+ let container = document.createXULElement("hbox");
+ container.classList.add("key-import-row");
+
+ let titleContainer = document.createXULElement("vbox");
+ let headerHBox = document.createXULElement("hbox");
+
+ let idSpan = document.createElement("span");
+ let idLabel = document.createXULElement("label");
+ idSpan.appendChild(idLabel);
+ idSpan.classList.add("openpgp-key-id");
+ headerHBox.appendChild(idSpan);
+
+ document.l10n.setAttributes(idLabel, "openpgp-pubkey-import-id", {
+ kid: "0x" + key.keyId,
+ });
+
+ let fprSpan = document.createElement("span");
+ let fprLabel = document.createXULElement("label");
+ fprSpan.appendChild(fprLabel);
+ fprSpan.classList.add("openpgp-key-fpr");
+ headerHBox.appendChild(fprSpan);
+
+ document.l10n.setAttributes(fprLabel, "openpgp-pubkey-import-fpr", {
+ fpr: key.fpr,
+ });
+
+ titleContainer.appendChild(headerHBox);
+
+ for (let uid of key.userIds) {
+ let name = document.createXULElement("label");
+ name.classList.add("openpgp-key-name");
+ name.value = uid.userId;
+ titleContainer.appendChild(name);
+ }
+
+ container.appendChild(titleContainer);
+ keyList.appendChild(container);
+ }
+
+ await document.l10n.translateElements(l10nElements);
+ window.sizeToContent();
+ window.moveTo(
+ (screen.width - window.outerWidth) / 2,
+ (screen.height - window.outerHeight) / 2
+ );
+}
+
+function onAccept(event) {
+ window.arguments[0].confirmed = true;
+ if (gUndecided.selected) {
+ window.arguments[0].acceptance = "undecided";
+ } else if (gUnverified.selected) {
+ window.arguments[0].acceptance = "unverified";
+ } else {
+ throw new Error("internal error, no expected radio button was selected");
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/ui/confirmPubkeyImport.xhtml b/comm/mail/extensions/openpgp/content/ui/confirmPubkeyImport.xhtml
new file mode 100644
index 0000000000..35935e0be7
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/confirmPubkeyImport.xhtml
@@ -0,0 +1,55 @@
+<?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://messenger/skin/openpgp/confirmPubkeyImport.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <dialog
+ id="confirmPubkeyImportDialog"
+ data-l10n-id="pubkey-import-button"
+ data-l10n-attrs="buttonlabelaccept"
+ buttons="accept,cancel"
+ >
+ <script src="chrome://openpgp/content/ui/confirmPubkeyImport.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/openpgp/oneRecipientStatus.ftl"
+ />
+ </linkset>
+
+ <vbox>
+ <description id="importInfo" />
+
+ <vbox id="importKeyListContainer">
+ <vbox id="importKeyList" />
+ </vbox>
+
+ <separator />
+
+ <vbox id="acceptancePanel">
+ <description id="acceptInfo" />
+ <html:div>
+ <html:fieldset>
+ <radiogroup id="acceptanceRadio" class="indent">
+ <radio id="acceptUndecided" value="undecided" selected="true" />
+ <radio id="acceptUnverified" value="unverified" />
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+ </vbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailCommon.js b/comm/mail/extensions/openpgp/content/ui/enigmailCommon.js
new file mode 100644
index 0000000000..ea02824856
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailCommon.js
@@ -0,0 +1,69 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var { EnigmailCore } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/core.jsm"
+);
+var { RNP } = ChromeUtils.import("chrome://openpgp/content/modules/RNP.jsm");
+
+var l10nCommon = new Localization(["messenger/openpgp/openpgp.ftl"], true);
+
+var gEnigmailSvc;
+function GetEnigmailSvc() {
+ if (!gEnigmailSvc) {
+ gEnigmailSvc = EnigmailCore.getService(window);
+ }
+ return gEnigmailSvc;
+}
+
+async function EnigRevokeKey(keyObj, callbackFunc) {
+ var enigmailSvc = GetEnigmailSvc();
+ if (!enigmailSvc) {
+ return;
+ }
+
+ if (keyObj.keyTrust == "r") {
+ Services.prompt.alert(
+ null,
+ document.title,
+ l10nCommon.formatValueSync("already-revoked")
+ );
+ return;
+ }
+
+ let promptFlags =
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL;
+
+ let confirm = Services.prompt.confirmEx(
+ window,
+ l10nCommon.formatValueSync("openpgp-key-revoke-title"),
+ l10nCommon.formatValueSync("revoke-key-question", {
+ identity: `0x${keyObj.keyId} - ${keyObj.userId}`,
+ }),
+ promptFlags,
+ l10nCommon.formatValueSync("key-man-button-revoke-key"),
+ null,
+ null,
+ null,
+ {}
+ );
+
+ if (confirm != 0) {
+ return;
+ }
+
+ await RNP.revokeKey(keyObj.fpr);
+ callbackFunc(true);
+
+ Services.prompt.alert(
+ null,
+ l10nCommon.formatValueSync("openpgp-key-revoke-success"),
+ l10nCommon.formatValueSync("after-revoke-info")
+ );
+}
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailKeyImportInfo.js b/comm/mail/extensions/openpgp/content/ui/enigmailKeyImportInfo.js
new file mode 100644
index 0000000000..a0069ed3c2
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailKeyImportInfo.js
@@ -0,0 +1,172 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EnigmailWindows = ChromeUtils.import(
+ "chrome://openpgp/content/modules/windows.jsm"
+).EnigmailWindows;
+var EnigmailKeyRing = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+).EnigmailKeyRing;
+var EnigmailDialog = ChromeUtils.import(
+ "chrome://openpgp/content/modules/dialog.jsm"
+).EnigmailDialog;
+var EnigmailData = ChromeUtils.import(
+ "chrome://openpgp/content/modules/data.jsm"
+).EnigmailData;
+
+async function onLoad() {
+ let dlg = document.getElementById("enigmailKeyImportInfo");
+
+ let keys = [];
+
+ if (window.screen.width > 500) {
+ dlg.setAttribute("maxwidth", window.screen.width - 150);
+ }
+
+ if (window.screen.height > 300) {
+ dlg.setAttribute("maxheight", window.screen.height - 100);
+ }
+
+ var keyList = window.arguments[0].keyList;
+
+ let onClickFunc = function (event) {
+ let keyId = event.target.getAttribute("keyid");
+ EnigmailWindows.openKeyDetails(window, keyId, false);
+ };
+
+ for (let keyId of keyList) {
+ if (keyId.search(/^0x/) === 0) {
+ keyId = keyId.substr(2).toUpperCase();
+ }
+ let keyObj = EnigmailKeyRing.getKeyById(keyId);
+ if (keyObj && keyObj.fpr) {
+ let keyGroupBox = buildKeyGroupBox(keyObj);
+ keyGroupBox
+ .getElementsByClassName("enigmailKeyImportDetails")[0]
+ .addEventListener("click", onClickFunc, true);
+ keys.push(keyGroupBox);
+ }
+ }
+
+ dlg.getButton("accept").focus();
+
+ if (keys.length) {
+ let keysInfoBox = document.getElementById("keyInfo"),
+ keyBox = document.createXULElement("vbox");
+
+ keyBox.classList.add("grid-three-column");
+ for (let key of keys) {
+ keyBox.appendChild(key);
+ }
+
+ keysInfoBox.appendChild(keyBox);
+ } else {
+ EnigmailDialog.alert(
+ window,
+ await document.l10n.formatValue("import-info-no-keys")
+ );
+ setTimeout(window.close, 0);
+ return;
+ }
+
+ setTimeout(resizeDlg);
+ setTimeout(() => window.sizeToContent());
+}
+
+function buildKeyGroupBox(keyObj) {
+ let groupBox = document.createXULElement("vbox");
+ let userid = document.createXULElement("label");
+
+ groupBox.classList.add("enigmailGroupbox", "enigmailGroupboxMargin");
+ userid.setAttribute("value", keyObj.userId);
+ userid.setAttribute("class", "enigmailKeyImportUserId");
+
+ let infoBox = document.createElement("div");
+ let infoLabelH1 = document.createXULElement("label");
+ let infoLabelH2 = document.createXULElement("label");
+ let infoLabelB1 = document.createXULElement("label");
+ let infoLabelB2 = document.createXULElement("label");
+ let infoLabelB3 = document.createXULElement("label");
+
+ document.l10n.setAttributes(infoLabelH1, "import-info-bits");
+ document.l10n.setAttributes(infoLabelH2, "import-info-created");
+ infoLabelB1.setAttribute("value", keyObj.keySize);
+ infoLabelB2.setAttribute("value", keyObj.created);
+
+ infoLabelH1.classList.add("enigmailKeyImportHeader");
+ infoLabelH2.classList.add("enigmailKeyImportHeader");
+
+ infoBox.classList.add("grid-two-column-fr");
+ infoBox.appendChild(infoLabelH1);
+ infoBox.appendChild(infoLabelH2);
+ infoBox.appendChild(infoLabelB1);
+ infoBox.appendChild(infoLabelB2);
+
+ let fprBox = document.createXULElement("div");
+ let fprLabel = document.createXULElement("label");
+ document.l10n.setAttributes(fprLabel, "import-info-fpr");
+ fprLabel.setAttribute("class", "enigmailKeyImportHeader");
+ let gridTemplateColumns = "";
+ for (let i = 0; i < keyObj.fpr.length; i += 4) {
+ var label = document.createXULElement("label");
+ label.setAttribute("value", keyObj.fpr.substr(i, 4));
+ if (i < keyObj.fpr.length / 2) {
+ gridTemplateColumns += "auto ";
+ }
+ fprBox.appendChild(label);
+ }
+
+ fprBox.style.display = "inline-grid";
+ fprBox.style.gridTemplateColumns = gridTemplateColumns;
+
+ groupBox.appendChild(userid);
+ groupBox.appendChild(infoBox);
+ groupBox.appendChild(fprLabel);
+ groupBox.appendChild(fprBox);
+
+ document.l10n.setAttributes(infoLabelB3, "import-info-details");
+ infoLabelB3.setAttribute("keyid", keyObj.keyId);
+ infoLabelB3.setAttribute("class", "enigmailKeyImportDetails");
+ groupBox.appendChild(infoLabelB3);
+
+ return groupBox;
+}
+
+function resizeDlg() {
+ var txt = document.getElementById("keyInfo");
+ var box = document.getElementById("outerbox");
+
+ var deltaWidth = window.outerWidth - box.clientWidth;
+ var newWidth = txt.scrollWidth + deltaWidth + 20;
+
+ if (newWidth > window.screen.width - 50) {
+ newWidth = window.screen.width - 50;
+ }
+
+ txt.style["white-space"] = "pre-wrap";
+ window.resizeTo(newWidth, window.outerHeight);
+
+ var textHeight = txt.scrollHeight;
+ var boxHeight = box.clientHeight;
+ var deltaHeight = window.outerHeight - boxHeight;
+
+ var newHeight = textHeight + deltaHeight + 25;
+
+ if (newHeight > window.screen.height - 100) {
+ newHeight = window.screen.height - 100;
+ }
+
+ window.resizeTo(newWidth, newHeight);
+}
+
+function dlgClose(buttonNumber) {
+ window.arguments[1].value = buttonNumber;
+ window.close();
+}
+
+document.addEventListener("dialogaccept", function (event) {
+ dlgClose(0);
+});
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailKeyImportInfo.xhtml b/comm/mail/extensions/openpgp/content/ui/enigmailKeyImportInfo.xhtml
new file mode 100644
index 0000000000..c5c8436398
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailKeyImportInfo.xhtml
@@ -0,0 +1,42 @@
+<?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 https://mozilla.org/MPL/2.0/.
+-->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/enigmail.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/shared/grid-layout.css"?>
+
+<!DOCTYPE window [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD; ]>
+
+<window
+ data-l10n-id="import-info-title"
+ onload="onLoad();"
+ style="min-width: 100px; max-width: 750px"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <dialog id="enigmailKeyImportInfo" buttons="accept">
+ <script
+ type="application/x-javascript"
+ src="chrome://openpgp/content/ui/enigmailKeyImportInfo.js"
+ />
+ <linkset>
+ <html:link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ </linkset>
+
+ <vbox align="center" flex="1" style="overflow: auto" id="outerbox">
+ <hbox align="center" flex="1">
+ <description
+ flex="1"
+ id="keyInfo"
+ class="plain"
+ style="white-space: pre"
+ />
+ </hbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailKeyManager.js b/comm/mail/extensions/openpgp/content/ui/enigmailKeyManager.js
new file mode 100644
index 0000000000..e72cf8e6bc
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailKeyManager.js
@@ -0,0 +1,1442 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+/* global GetEnigmailSvc, EnigRevokeKey */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { EnigmailCore } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/core.jsm"
+);
+var { EnigmailStreams } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/streams.jsm"
+);
+var { EnigmailFuncs } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/funcs.jsm"
+);
+var { EnigmailWindows } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/windows.jsm"
+);
+var { EnigmailKeyServer } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyserver.jsm"
+);
+var { EnigmailWks } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/webKey.jsm"
+);
+var { EnigmailCryptoAPI } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/cryptoAPI.jsm"
+);
+var { KeyLookupHelper } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyLookupHelper.jsm"
+);
+var { EnigmailTrust } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/trust.jsm"
+);
+var { PgpSqliteDb2 } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/sqliteDb.jsm"
+);
+var { EnigmailLog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/log.jsm"
+);
+var { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+var { EnigmailKey } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/key.jsm"
+);
+var { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+var { EnigmailDialog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/dialog.jsm"
+);
+var { EnigmailKeyserverURIs } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyserverUris.jsm"
+);
+
+const ENIG_KEY_EXPIRED = "e";
+const ENIG_KEY_REVOKED = "r";
+const ENIG_KEY_INVALID = "i";
+const ENIG_KEY_DISABLED = "d";
+const ENIG_KEY_NOT_VALID =
+ ENIG_KEY_EXPIRED + ENIG_KEY_REVOKED + ENIG_KEY_INVALID + ENIG_KEY_DISABLED;
+
+var l10n = new Localization(["messenger/openpgp/openpgp.ftl"], true);
+
+const INPUT = 0;
+const RESULT = 1;
+
+var gUserList;
+var gKeyList;
+var gEnigLastSelectedKeys = null;
+var gKeySortList = null;
+var gSearchInput = null;
+var gTreeChildren = null;
+var gShowInvalidKeys = null;
+var gShowOthersKeys = null;
+var gTimeoutId = null;
+
+function enigmailKeyManagerLoad() {
+ EnigmailLog.DEBUG("enigmailKeyManager.js: enigmailKeyManagerLoad\n");
+
+ // Close the key manager if GnuPG is not available
+ if (!EnigmailCore.getService(window)) {
+ window.close();
+ return;
+ }
+
+ gUserList = document.getElementById("pgpKeyList");
+ gSearchInput = document.getElementById("filterKey");
+ gShowInvalidKeys = document.getElementById("showInvalidKeys");
+ gShowOthersKeys = document.getElementById("showOthersKeys");
+
+ window.addEventListener("reload-keycache", reloadKeys);
+ gSearchInput.addEventListener("keydown", event => {
+ switch (event.key) {
+ case "Escape":
+ event.target.value = "";
+ // fall through
+ case "Enter":
+ if (gTimeoutId) {
+ clearTimeout(gTimeoutId);
+ gTimeoutId = null;
+ }
+ gKeyListView.applyFilter(0);
+ event.preventDefault();
+ break;
+ default:
+ gTimeoutId = setTimeout(() => {
+ gKeyListView.applyFilter(0);
+ }, 200);
+ break;
+ }
+ });
+
+ gUserList.addEventListener("click", onListClick, true);
+ document.getElementById("statusText").value = l10n.formatValueSync(
+ "key-man-loading-keys"
+ );
+ document.getElementById("progressBar").style.visibility = "visible";
+ setTimeout(loadkeyList, 100);
+
+ gUserList.view = gKeyListView;
+ gSearchInput.focus();
+
+ // Dialog event listeners.
+ document.addEventListener("dialogaccept", onDialogAccept);
+ document.addEventListener("dialogcancel", onDialogClose);
+}
+
+function onDialogAccept() {
+ if (window.arguments[0].okCallback) {
+ window.arguments[0].okCallback();
+ }
+ window.close();
+}
+
+function onDialogClose() {
+ if (window.arguments[0].cancelCallback) {
+ window.arguments[0].cancelCallback();
+ }
+ window.close();
+}
+
+function loadkeyList() {
+ EnigmailLog.DEBUG("enigmailKeyManager.js: loadkeyList\n");
+
+ sortTree();
+ gKeyListView.applyFilter(0);
+ document.getElementById("pleaseWait").hidePopup();
+ document.getElementById("statusText").value = "";
+ document.getElementById("progressBar").style.visibility = "collapse";
+}
+
+function clearKeyCache() {
+ EnigmailKeyRing.clearCache();
+ refreshKeys();
+}
+
+function refreshKeys() {
+ EnigmailLog.DEBUG("enigmailKeyManager.js: refreshKeys\n");
+ var keyList = getSelectedKeys();
+ gEnigLastSelectedKeys = [];
+ for (var i = 0; i < keyList.length; i++) {
+ gEnigLastSelectedKeys[keyList[i]] = 1;
+ }
+
+ buildKeyList(true);
+}
+
+function reloadKeys() {
+ let i = 0;
+ let c = Components.stack;
+
+ while (c) {
+ if (c.name == "reloadKeys") {
+ i++;
+ }
+ c = c.caller;
+ }
+
+ // detect recursion and don't continue if too much recursion
+ // this can happen if the key list is empty
+ if (i < 4) {
+ buildKeyList(true);
+ }
+}
+
+function buildKeyList(refresh) {
+ EnigmailLog.DEBUG("enigmailKeyManager.js: buildKeyList\n");
+
+ var keyListObj = {};
+
+ if (refresh) {
+ EnigmailKeyRing.clearCache();
+ }
+
+ keyListObj = EnigmailKeyRing.getAllKeys(
+ window,
+ getSortColumn(),
+ getSortDirection()
+ );
+
+ if (!keyListObj.keySortList) {
+ return;
+ }
+
+ gKeyList = keyListObj.keyList;
+ gKeySortList = keyListObj.keySortList;
+
+ gKeyListView.keysRefreshed();
+}
+
+function getSelectedKeys() {
+ let selList = [];
+ let rangeCount = gUserList.view.selection.getRangeCount();
+ for (let i = 0; i < rangeCount; i++) {
+ let start = {};
+ let end = {};
+ gUserList.view.selection.getRangeAt(i, start, end);
+ for (let c = start.value; c <= end.value; c++) {
+ try {
+ //selList.push(gUserList.view.getItemAtIndex(c).getAttribute("keyNum"));
+ selList.push(gKeyListView.getFilteredRow(c).keyNum);
+ } catch (ex) {
+ return [];
+ }
+ }
+ }
+ return selList;
+}
+
+function getSelectedKeyIds() {
+ let keyList = getSelectedKeys();
+
+ let a = [];
+ for (let i in keyList) {
+ a.push(gKeyList[keyList[i]].keyId);
+ }
+
+ return a;
+}
+
+function enigmailKeyMenu() {
+ var keyList = getSelectedKeys();
+
+ let haveSecretForAll;
+ if (keyList.length == 0) {
+ haveSecretForAll = false;
+ } else {
+ haveSecretForAll = true;
+ for (let key of keyList) {
+ if (!gKeyList[key].secretAvailable) {
+ haveSecretForAll = false;
+ break;
+ }
+ }
+ }
+
+ let singleSecretSelected = keyList.length == 1 && haveSecretForAll;
+
+ // Make the selected key count available to translations.
+ for (let el of document.querySelectorAll(".enigmail-bulk-key-operation")) {
+ el.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({ count: keyList.length })
+ );
+ }
+
+ document.getElementById("backupSecretKey").disabled = !haveSecretForAll;
+ document.getElementById("uploadToServer").disabled = !singleSecretSelected;
+
+ document.getElementById("revokeKey").disabled =
+ keyList.length != 1 || !gKeyList[keyList[0]].secretAvailable;
+ document.getElementById("ctxRevokeKey").hidden =
+ keyList.length != 1 || !gKeyList[keyList[0]].secretAvailable;
+
+ document.getElementById("importFromClipbrd").disabled =
+ !Services.clipboard.hasDataMatchingFlavors(
+ ["text/plain"],
+ Ci.nsIClipboard.kGlobalClipboard
+ );
+
+ for (let item of document.querySelectorAll(
+ ".requires-single-key-selection"
+ )) {
+ item.disabled = keyList.length != 1;
+ }
+
+ for (let item of document.querySelectorAll(".requires-key-selection")) {
+ item.disabled = keyList.length == 0;
+ }
+
+ // Disable the "Generate key" menu item if no mail account is available.
+ document
+ .getElementById("genKey")
+ .setAttribute("disabled", MailServices.accounts.defaultAccount == null);
+
+ // Disable the context menu if no keys are selected.
+ return keyList.length > 0;
+}
+
+function onListClick(event) {
+ if (event.detail > 2) {
+ return;
+ }
+
+ if (event.type === "click") {
+ // Mouse event
+ let { col } = gUserList.getCellAt(event.clientX, event.clientY);
+
+ if (!col) {
+ // not clicked on a valid column (e.g. scrollbar)
+ return;
+ }
+ }
+
+ if (event.detail != 2) {
+ return;
+ }
+
+ // do not propagate double clicks
+ event.stopPropagation();
+ enigmailKeyDetails();
+}
+
+function enigmailSelectAllKeys() {
+ gUserList.view.selection.selectAll();
+}
+
+/**
+ * Open the Key Properties subdialog.
+ *
+ * @param {string|null} keyId - Optional ID of the selected OpenPGP Key.
+ */
+function enigmailKeyDetails(keyId = null) {
+ if (!keyId) {
+ let keyList = getSelectedKeys();
+ // Interrupt if we don't have a single selected key nor a key was passed.
+ if (keyList.length != 1) {
+ return;
+ }
+ keyId = gKeyList[keyList[0]].keyId;
+ }
+
+ if (EnigmailWindows.openKeyDetails(window, keyId, false)) {
+ refreshKeys();
+ }
+}
+
+async function enigmailDeleteKey() {
+ var keyList = getSelectedKeys();
+ var deleteSecret = false;
+
+ if (keyList.length == 1) {
+ // one key selected
+ var userId = gKeyList[keyList[0]].userId;
+ if (gKeyList[keyList[0]].secretAvailable) {
+ if (
+ !EnigmailDialog.confirmDlg(
+ window,
+ l10n.formatValueSync("delete-secret-key", {
+ userId,
+ }),
+ l10n.formatValueSync("dlg-button-delete")
+ )
+ ) {
+ return;
+ }
+ deleteSecret = true;
+ } else if (
+ !EnigmailDialog.confirmDlg(
+ window,
+ l10n.formatValueSync("delete-pub-key", {
+ userId,
+ }),
+ l10n.formatValueSync("dlg-button-delete")
+ )
+ ) {
+ return;
+ }
+ } else {
+ // several keys selected
+ for (var i = 0; i < keyList.length; i++) {
+ if (gKeyList[keyList[i]].secretAvailable) {
+ deleteSecret = true;
+ }
+ }
+
+ if (deleteSecret) {
+ if (
+ !EnigmailDialog.confirmDlg(
+ window,
+ l10n.formatValueSync("delete-mix"),
+ l10n.formatValueSync("dlg-button-delete")
+ )
+ ) {
+ return;
+ }
+ } else if (
+ !EnigmailDialog.confirmDlg(
+ window,
+ l10n.formatValueSync("delete-selected-pub-key"),
+ l10n.formatValueSync("dlg-button-delete")
+ )
+ ) {
+ return;
+ }
+ }
+
+ const cApi = EnigmailCryptoAPI();
+ for (let j in keyList) {
+ let fpr = gKeyList[keyList[j]].fpr;
+ await cApi.deleteKey(fpr, deleteSecret);
+ await PgpSqliteDb2.deleteAcceptance(fpr);
+ }
+ clearKeyCache();
+ gUserList.view.selection.clearSelection();
+}
+
+async function enigCreateKeyMsg() {
+ var keyList = getSelectedKeyIds();
+ var tmpFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tmpFile.append("key.asc");
+ tmpFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+
+ // save file
+ var exitCodeObj = {};
+ var errorMsgObj = {};
+
+ var keyIdArray = [];
+ for (let id of keyList) {
+ keyIdArray.push("0x" + id);
+ }
+
+ await EnigmailKeyRing.extractPublicKeys(
+ keyIdArray, // full
+ null,
+ null,
+ tmpFile,
+ exitCodeObj,
+ errorMsgObj
+ );
+ if (exitCodeObj.value !== 0) {
+ EnigmailDialog.alert(window, errorMsgObj.value);
+ return;
+ }
+
+ // create attachment
+ var ioServ = Services.io;
+ var tmpFileURI = ioServ.newFileURI(tmpFile);
+ var keyAttachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ keyAttachment.url = tmpFileURI.spec;
+ if (keyList.length == 1) {
+ keyAttachment.name = "0x" + keyList[0] + ".asc";
+ } else {
+ keyAttachment.name = "pgpkeys.asc";
+ }
+ keyAttachment.temporary = true;
+ keyAttachment.contentType = "application/pgp-keys";
+
+ // create Msg
+ var msgCompFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ msgCompFields.addAttachment(keyAttachment);
+
+ var msgCompSvc = Cc["@mozilla.org/messengercompose;1"].getService(
+ Ci.nsIMsgComposeService
+ );
+
+ var msgCompParam = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ msgCompParam.composeFields = msgCompFields;
+ msgCompParam.identity = EnigmailFuncs.getDefaultIdentity();
+ msgCompParam.type = Ci.nsIMsgCompType.New;
+ msgCompParam.format = Ci.nsIMsgCompFormat.Default;
+ msgCompParam.originalMsgURI = "";
+ msgCompSvc.OpenComposeWindowWithParams("", msgCompParam);
+}
+
+async function enigmailRevokeKey() {
+ var keyList = getSelectedKeys();
+ let keyInfo = gKeyList[keyList[0]];
+ EnigRevokeKey(keyInfo, function (success) {
+ if (success) {
+ refreshKeys();
+ }
+ });
+}
+
+async function enigmailExportKeys(which) {
+ let exportSecretKey = which == "secret";
+ var keyList = getSelectedKeys();
+ var defaultFileName;
+
+ if (keyList.length == 1) {
+ let extension = exportSecretKey ? "secret.asc" : "public.asc";
+ defaultFileName = gKeyList[keyList[0]].userId.replace(/[<>]/g, "");
+ defaultFileName =
+ defaultFileName +
+ "-" +
+ `(0x${gKeyList[keyList[0]].keyId})` +
+ "-" +
+ extension;
+ } else {
+ let id = exportSecretKey
+ ? "default-pub-sec-key-filename"
+ : "default-pub-key-filename";
+ defaultFileName = l10n.formatValueSync(id) + ".asc";
+ }
+
+ if (exportSecretKey) {
+ var fprArray = [];
+ for (let id of keyList) {
+ fprArray.push(gKeyList[id].fpr);
+ }
+ EnigmailKeyRing.backupSecretKeysInteractive(
+ window,
+ defaultFileName,
+ fprArray
+ );
+ } else {
+ let keyList2 = getSelectedKeyIds();
+ var keyIdArray = [];
+ for (let id of keyList2) {
+ keyIdArray.push("0x" + id);
+ }
+ await EnigmailKeyRing.exportPublicKeysInteractive(
+ window,
+ defaultFileName,
+ keyIdArray
+ );
+ }
+}
+
+async function enigmailImportFromClipbrd() {
+ if (
+ !EnigmailDialog.confirmDlg(
+ window,
+ l10n.formatValueSync("import-from-clip"),
+ l10n.formatValueSync("key-man-button-import")
+ )
+ ) {
+ return;
+ }
+
+ let cBoardContent = await navigator.clipboard.readText();
+ var errorMsgObj = {};
+ var preview = await EnigmailKey.getKeyListFromKeyBlock(
+ cBoardContent,
+ errorMsgObj,
+ true,
+ true,
+ false
+ );
+ // should we allow importing secret keys?
+ if (preview && preview.length > 0) {
+ let confirmImport = false;
+ let outParam = {};
+ confirmImport = EnigmailDialog.confirmPubkeyImport(
+ window,
+ preview,
+ outParam
+ );
+ if (confirmImport) {
+ // import
+ EnigmailKeyRing.importKey(
+ window,
+ false,
+ cBoardContent,
+ false,
+ "",
+ errorMsgObj,
+ null,
+ false,
+ [],
+ true,
+ outParam.acceptance
+ );
+ var keyList = preview.map(function (a) {
+ return a.id;
+ });
+ EnigmailDialog.keyImportDlg(window, keyList);
+ refreshKeys();
+ }
+ } else {
+ document.l10n.formatValue("preview-failed").then(value => {
+ EnigmailDialog.alert(window, value);
+ });
+ }
+}
+
+/**
+ * Places the fingerprint of each selected key onto the keyboard.
+ */
+async function copyOpenPGPFingerPrints() {
+ let fprs = getSelectedKeys()
+ .map(idx => gKeyList[idx].fpr)
+ .join("\n");
+ return navigator.clipboard.writeText(fprs);
+}
+
+/**
+ * Places the key id of each key selected onto the clipboard.
+ */
+async function copyOpenPGPKeyIds() {
+ let ids = getSelectedKeyIds();
+ return navigator.clipboard.writeText(ids.map(id => `0x${id}`).join("\n"));
+}
+
+async function enigmailCopyToClipbrd() {
+ var keyList = getSelectedKeyIds();
+ if (keyList.length === 0) {
+ document.l10n.formatValue("no-key-selected").then(value => {
+ EnigmailDialog.info(window, value);
+ });
+ return;
+ }
+ var exitCodeObj = {};
+ var errorMsgObj = {};
+
+ var keyIdArray = [];
+ for (let id of keyList) {
+ keyIdArray.push("0x" + id);
+ }
+
+ let keyData = await EnigmailKeyRing.extractPublicKeys(
+ keyIdArray, // full
+ null,
+ null,
+ null,
+ exitCodeObj,
+ errorMsgObj
+ );
+ if (exitCodeObj.value !== 0) {
+ l10n.formatValue("copy-to-clipbrd-failed").then(value => {
+ EnigmailDialog.alert(window, value);
+ });
+ return;
+ }
+ navigator.clipboard
+ .writeText(keyData)
+ .then(() => {
+ l10n.formatValue("copy-to-clipbrd-ok").then(value => {
+ EnigmailDialog.info(window, value);
+ });
+ })
+ .catch(err => {
+ l10n.formatValue("copy-to-clipbrd-failed").then(value => {
+ EnigmailDialog.alert(window, value);
+ });
+ });
+}
+
+async function enigmailSearchKey() {
+ var result = {
+ value: "",
+ };
+ if (
+ !Services.prompt.prompt(
+ window,
+ l10n.formatValueSync("enig-prompt"),
+ l10n.formatValueSync("openpgp-key-man-discover-prompt"),
+ result,
+ "",
+ {}
+ )
+ ) {
+ return;
+ }
+
+ result.value = result.value.trim();
+
+ let imported = false;
+ if (EnigmailFuncs.stringLooksLikeEmailAddress(result.value)) {
+ imported = await KeyLookupHelper.lookupAndImportByEmail(
+ "interactive-import",
+ window,
+ result.value,
+ true
+ );
+ } else {
+ imported = await KeyLookupHelper.lookupAndImportByKeyID(
+ "interactive-import",
+ window,
+ result.value,
+ true
+ );
+ }
+
+ if (imported) {
+ refreshKeys();
+ }
+}
+
+async function enigmailUploadKey() {
+ // Always upload to the first configured keyserver with a supported protocol.
+ let selKeyList = getSelectedKeys();
+ if (selKeyList.length != 1) {
+ return;
+ }
+
+ let keyId = gKeyList[selKeyList[0]].keyId;
+ let ks = EnigmailKeyserverURIs.getUploadKeyServer();
+
+ let ok = await EnigmailKeyServer.upload(keyId, ks);
+ document.l10n
+ .formatValue(ok ? "openpgp-key-publish-ok" : "openpgp-key-publish-fail", {
+ keyserver: ks,
+ })
+ .then(value => {
+ EnigmailDialog.alert(window, value);
+ });
+}
+
+/*
+function enigmailUploadToWkd() {
+ let selKeyList = getSelectedKeys();
+ let keyList = [];
+ for (let i = 0; i < selKeyList.length; i++) {
+ keyList.push(gKeyList[selKeyList[i]]);
+ }
+
+ EnigmailWks.wksUpload(keyList, window)
+ .then(result => {
+ if (result.length > 0) {
+ EnigmailDialog.info(window, "Key(s) sent successfully");
+ } else if (keyList.length === 1) {
+ EnigmailDialog.alert(
+ window,
+ "Sending of keys failed" +
+ "\n\n" +
+ "The key %S does not have a WKS identity.".replace("%S", keyList[0].userId)
+ );
+ } else {
+ EnigmailDialog.alert(
+ window,
+ "The upload was not successful - your provider does not seem to support WKS."
+ );
+ }
+ })
+ .catch(error => {
+ EnigmailDialog.alert(
+ "Sending of keys failed" + "\n" + error
+ );
+ });
+}
+*/
+
+function enigmailImportKeysFromUrl() {
+ var result = {
+ value: "",
+ };
+ if (
+ !Services.prompt.prompt(
+ window,
+ l10n.formatValueSync("enig-prompt"),
+ l10n.formatValueSync("import-from-url"),
+ result,
+ "",
+ {}
+ )
+ ) {
+ return;
+ }
+ var p = new Promise(function (resolve, reject) {
+ var cbFunc = async function (data) {
+ EnigmailLog.DEBUG("enigmailImportKeysFromUrl: _cbFunc()\n");
+ var errorMsgObj = {};
+
+ var preview = await EnigmailKey.getKeyListFromKeyBlock(
+ data,
+ errorMsgObj,
+ true,
+ true,
+ false
+ );
+ // should we allow importing secret keys?
+ if (preview && preview.length > 0) {
+ let confirmImport = false;
+ let outParam = {};
+ confirmImport = EnigmailDialog.confirmPubkeyImport(
+ window,
+ preview,
+ outParam
+ );
+ if (confirmImport) {
+ EnigmailKeyRing.importKey(
+ window,
+ false,
+ data,
+ false,
+ "",
+ errorMsgObj,
+ null,
+ false,
+ [],
+ true,
+ outParam.acceptance
+ );
+ errorMsgObj.preview = preview;
+ resolve(errorMsgObj);
+ }
+ } else {
+ EnigmailDialog.alert(
+ window,
+ await document.l10n.formatValue("preview-failed")
+ );
+ }
+ };
+
+ try {
+ var bufferListener = EnigmailStreams.newStringStreamListener(cbFunc);
+ var msgUri = Services.io.newURI(result.value.trim());
+
+ var channel = EnigmailStreams.createChannel(msgUri);
+ channel.asyncOpen(bufferListener, msgUri);
+ } catch (ex) {
+ var err = {
+ value: ex,
+ };
+ reject(err);
+ }
+ });
+
+ p.then(function (errorMsgObj) {
+ var keyList = errorMsgObj.preview.map(function (a) {
+ return a.id;
+ });
+ EnigmailDialog.keyImportDlg(window, keyList);
+ refreshKeys();
+ }).catch(async function (reason) {
+ EnigmailDialog.alert(
+ window,
+ await document.l10n.formatValue("general-error", {
+ reason: reason.value,
+ })
+ );
+ });
+}
+
+function initiateAcKeyTransfer() {
+ EnigmailWindows.inititateAcSetupMessage();
+}
+
+//
+// ----- key filtering functionality -----
+//
+
+function determineHiddenKeys(keyObj, showInvalidKeys, showOthersKeys) {
+ var show = true;
+
+ const INVALID_KEYS = "ierdD";
+
+ if (
+ !showInvalidKeys &&
+ INVALID_KEYS.includes(EnigmailTrust.getTrustCode(keyObj))
+ ) {
+ show = false;
+ }
+
+ if (!showOthersKeys && !keyObj.secretAvailable) {
+ show = false;
+ }
+
+ return show;
+}
+
+function getSortDirection() {
+ return gUserList.getAttribute("sortDirection") == "ascending" ? 1 : -1;
+}
+
+function sortTree(column) {
+ var columnName;
+ var order = getSortDirection();
+
+ //if the column is passed and it's already sorted by that column, reverse sort
+ if (column) {
+ columnName = column.id;
+ if (gUserList.getAttribute("sortResource") == columnName) {
+ order *= -1;
+ } else {
+ document
+ .getElementById(gUserList.getAttribute("sortResource"))
+ .removeAttribute("sortDirection");
+ order = 1;
+ }
+ } else {
+ columnName = gUserList.getAttribute("sortResource");
+ }
+ gUserList.setAttribute(
+ "sortDirection",
+ order == 1 ? "ascending" : "descending"
+ );
+ let col = document.getElementById(columnName);
+ if (col) {
+ col.setAttribute("sortDirection", order == 1 ? "ascending" : "descending");
+ gUserList.setAttribute("sortResource", columnName);
+ } else {
+ gUserList.setAttribute("sortResource", "enigUserNameCol");
+ }
+ buildKeyList(false);
+}
+
+function getSortColumn() {
+ switch (gUserList.getAttribute("sortResource")) {
+ case "enigUserNameCol":
+ return "userid";
+ case "keyCol":
+ return "keyid";
+ case "createdCol":
+ return "created";
+ case "expCol":
+ return "expiry";
+ case "fprCol":
+ return "fpr";
+ default:
+ return "?";
+ }
+}
+
+/**
+ * Open the OpenPGP Key Wizard to generate a new key or import secret keys.
+ *
+ * @param {boolean} isImport - If the keyWizard should automatically switch to
+ * the import or create screen as requested by the user.
+ */
+function openKeyWizard(isImport = false) {
+ let args = {
+ gSubDialog: null,
+ cancelCallback: clearKeyCache,
+ okCallback: clearKeyCache,
+ okImportCallback: clearKeyCache,
+ okExternalCallback: clearKeyCache,
+ keyDetailsDialog: enigmailKeyDetails,
+ isCreate: !isImport,
+ isImport,
+ };
+
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://openpgp/content/ui/keyWizard.xhtml",
+ "enigmail:KeyWizard",
+ "dialog,modal,centerscreen,resizable",
+ args
+ );
+}
+
+/***************************** TreeView for user list ***********************************/
+/**
+ * gKeyListView implements the nsITreeView interface for the displayed list.
+ *
+ * For speed reasons, we use two lists:
+ * - keyViewList: contains the full list of pointers to all keys and rows that are
+ * potentially displayed ordered according to the sort column
+ * - keyFilterList: contains the indexes to keyViewList of the keys that are displayed
+ * according to the current filter criteria.
+ */
+var gKeyListView = {
+ keyViewList: [],
+ keyFilterList: [],
+
+ //// nsITreeView implementation
+
+ rowCount: 0,
+ selection: null,
+
+ canDrop(index, orientation, dataTransfer) {
+ return false;
+ },
+
+ cycleCell(row, col) {},
+ cycleHeader(col) {},
+ drop(row, orientation, dataTransfer) {},
+
+ getCellProperties(row, col) {
+ let r = this.getFilteredRow(row);
+ if (!r) {
+ return "";
+ }
+
+ let keyObj = gKeyList[r.keyNum];
+ if (!keyObj) {
+ return "";
+ }
+
+ let keyTrustStyle = "";
+
+ switch (r.rowType) {
+ case "key":
+ case "uid":
+ switch (keyObj.keyTrust) {
+ case "q":
+ keyTrustStyle = "enigmail_keyValid_unknown";
+ break;
+ case "r":
+ keyTrustStyle = "enigmail_keyValid_revoked";
+ break;
+ case "e":
+ keyTrustStyle = "enigmail_keyValid_expired";
+ break;
+ case "n":
+ keyTrustStyle = "enigmail_keyTrust_untrusted";
+ break;
+ case "m":
+ keyTrustStyle = "enigmail_keyTrust_marginal";
+ break;
+ case "f":
+ keyTrustStyle = "enigmail_keyTrust_full";
+ break;
+ case "u":
+ keyTrustStyle = "enigmail_keyTrust_ultimate";
+ break;
+ case "-":
+ keyTrustStyle = "enigmail_keyTrust_unknown";
+ break;
+ default:
+ keyTrustStyle = "enigmail_keyTrust_unknown";
+ break;
+ }
+
+ if (
+ keyObj.keyTrust.length > 0 &&
+ ENIG_KEY_NOT_VALID.includes(keyObj.keyTrust.charAt(0))
+ ) {
+ keyTrustStyle += " enigKeyInactive";
+ }
+
+ if (r.rowType === "key" && keyObj.secretAvailable) {
+ keyTrustStyle += " enigmailOwnKey";
+ }
+ break;
+ }
+
+ return keyTrustStyle;
+ },
+
+ getCellText(row, col) {
+ let r = this.getFilteredRow(row);
+ if (!r) {
+ return "";
+ }
+ let keyObj = gKeyList[r.keyNum];
+ if (!keyObj) {
+ return "???";
+ }
+
+ switch (r.rowType) {
+ case "key":
+ switch (col.id) {
+ case "enigUserNameCol":
+ return keyObj.userId;
+ case "keyCol":
+ return `0x${keyObj.keyId}`;
+ case "createdCol":
+ return keyObj.created;
+ case "expCol":
+ return keyObj.effectiveExpiry;
+ case "fprCol":
+ return keyObj.fprFormatted;
+ }
+ break;
+ case "uid":
+ switch (col.id) {
+ case "enigUserNameCol":
+ return keyObj.userIds[r.uidNum].userId;
+ }
+ break;
+ }
+
+ return "";
+ },
+ getCellValue(row, col) {
+ return "";
+ },
+ getColumnProperties(col) {
+ return "";
+ },
+
+ getImageSrc(row, col) {
+ let r = this.getFilteredRow(row);
+ if (!r) {
+ return null;
+ }
+ //let keyObj = gKeyList[r.keyNum];
+
+ return null;
+ },
+
+ /**
+ * indentation level for rows
+ */
+ getLevel(row) {
+ let r = this.getFilteredRow(row);
+ if (!r) {
+ return 0;
+ }
+
+ switch (r.rowType) {
+ case "key":
+ return 0;
+ case "uid":
+ return 1;
+ }
+
+ return 0;
+ },
+
+ getParentIndex(idx) {
+ return -1;
+ },
+ getProgressMode(row, col) {},
+
+ getRowProperties(row) {
+ return "";
+ },
+ hasNextSibling(rowIndex, afterIndex) {
+ return false;
+ },
+ isContainer(row) {
+ let r = this.getFilteredRow(row);
+ if (!r) {
+ return false;
+ }
+ switch (r.rowType) {
+ case "key":
+ return true;
+ }
+
+ return false;
+ },
+ isContainerEmpty(row) {
+ let r = this.getFilteredRow(row);
+ if (!r) {
+ return true;
+ }
+ switch (r.rowType) {
+ case "key":
+ return !r.hasSubUID;
+ }
+ return true;
+ },
+ isContainerOpen(row) {
+ return this.getFilteredRow(row).isOpen;
+ },
+ isEditable(row, col) {
+ return false;
+ },
+ isSelectable(row, col) {
+ return true;
+ },
+ isSeparator(index) {
+ return false;
+ },
+ isSorted() {
+ return false;
+ },
+ performAction(action) {},
+ performActionOnCell(action, row, col) {},
+ performActionOnRow(action, row) {},
+ selectionChanged() {},
+ // void setCellText(in long row, in nsITreeColumn col, in AString value);
+ // void setCellValue(in long row, in nsITreeColumn col, in AString value);
+ setTree(treebox) {
+ this.treebox = treebox;
+ },
+
+ toggleOpenState(row) {
+ let r = this.getFilteredRow(row);
+ if (!r) {
+ return;
+ }
+ let realRow = this.keyFilterList[row];
+ switch (r.rowType) {
+ case "key":
+ if (r.isOpen) {
+ let i = 0;
+ while (
+ this.getFilteredRow(row + 1 + i) &&
+ this.getFilteredRow(row + 1 + i).keyNum === r.keyNum
+ ) {
+ ++i;
+ }
+
+ this.keyViewList.splice(realRow + 1, i);
+ r.isOpen = false;
+ this.applyFilter(row);
+ } else {
+ this.appendUids("uid", r.keyNum, realRow, this.keyViewList[row]);
+
+ r.isOpen = true;
+ this.applyFilter(row);
+ }
+ break;
+ }
+ },
+
+ /**
+ * add UIDs for a given key to key view
+ *
+ * @param uidType: String - one of uid (user ID), uat (photo)
+ * @param keyNum: Number - index of key in gKeyList
+ * @param realRow: Number - index of row in keyViewList (i.e. without filter)
+ *
+ * @returns Number: number of UIDs added
+ */
+ appendUids(uidType, keyNum, realRow, parentRow) {
+ let keyObj = gKeyList[keyNum];
+ let uidAdded = 0;
+
+ for (let i = 0; i < keyObj.userIds.length; i++) {
+ if (keyObj.userIds[i].type === uidType) {
+ if (keyObj.userIds[i].userId == keyObj.userId) {
+ continue;
+ }
+ ++uidAdded;
+ this.keyViewList.splice(realRow + uidAdded, 0, {
+ rowType: uidType,
+ keyNum,
+ parent: parentRow,
+ uidNum: i,
+ });
+ }
+ }
+
+ return uidAdded;
+ },
+
+ /**
+ * Reload key list entirely
+ */
+ keysRefreshed() {
+ this.keyViewList = [];
+ this.keyFilterList = [];
+ for (let i = 0; i < gKeySortList.length; i++) {
+ this.keyViewList.push({
+ row: i,
+ rowType: "key",
+ fpr: gKeySortList[i].fpr,
+ keyNum: gKeySortList[i].keyNum,
+ isOpen: false,
+ hasSubUID: gKeyList[gKeySortList[i].keyNum].userIds.length > 1,
+ });
+ }
+
+ this.applyFilter(0);
+ let oldRowCount = this.rowCount;
+ this.rowCount = this.keyViewList.length;
+ gUserList.rowCountChanged(0, this.rowCount - oldRowCount);
+ },
+
+ /**
+ * If no search term is entered, decide which keys to display
+ *
+ * @returns array of keyNums (= display some keys) or null (= display ALL keys)
+ */
+ showOrHideAllKeys() {
+ var showInvalidKeys = gShowInvalidKeys.getAttribute("checked") == "true";
+ var showOthersKeys = gShowOthersKeys.getAttribute("checked") == "true";
+
+ document.getElementById("nothingFound").hidePopup();
+
+ if (showInvalidKeys && showOthersKeys) {
+ return null;
+ }
+
+ let keyShowList = [];
+ for (let i = 0; i < gKeyList.length; i++) {
+ if (determineHiddenKeys(gKeyList[i], showInvalidKeys, showOthersKeys)) {
+ keyShowList.push(i);
+ }
+ }
+
+ return keyShowList;
+ },
+
+ /**
+ * Search for keys that match filter criteria
+ *
+ * @returns array of keyNums (= display some keys) or null (= display ALL keys)
+ */
+ getFilteredKeys() {
+ let searchTxt = gSearchInput.value;
+
+ if (!searchTxt || searchTxt.length === 0) {
+ return this.showOrHideAllKeys();
+ }
+
+ if (!gKeyList) {
+ return [];
+ }
+ let showInvalidKeys = gShowInvalidKeys.getAttribute("checked") == "true";
+ let showOthersKeys = gShowOthersKeys.getAttribute("checked") == "true";
+
+ // skip leading 0x in case we search for a key:
+ if (searchTxt.length > 2 && searchTxt.substr(0, 2).toLowerCase() == "0x") {
+ searchTxt = searchTxt.substr(2);
+ }
+
+ searchTxt = searchTxt.toLowerCase();
+ searchTxt = searchTxt.replace(/^(\s*)(.*)/, "$2").replace(/\s+$/, ""); // trim spaces
+
+ // check if we search for a full fingerprint (with optional spaces every 4 letters)
+ var fpr = null;
+ if (searchTxt.length == 49) {
+ // possible fingerprint with spaces?
+ if (
+ searchTxt.search(/^[0-9a-f ]*$/) >= 0 &&
+ searchTxt[4] == " " &&
+ searchTxt[9] == " " &&
+ searchTxt[14] == " " &&
+ searchTxt[19] == " " &&
+ searchTxt[24] == " " &&
+ searchTxt[29] == " " &&
+ searchTxt[34] == " " &&
+ searchTxt[39] == " " &&
+ searchTxt[44] == " "
+ ) {
+ fpr = searchTxt.replace(/ /g, "");
+ }
+ } else if (searchTxt.length == 40) {
+ // possible fingerprint without spaces
+ if (searchTxt.search(/^[0-9a-f ]*$/) >= 0) {
+ fpr = searchTxt;
+ }
+ }
+
+ let keyShowList = [];
+
+ for (let i = 0; i < gKeyList.length; i++) {
+ let keyObj = gKeyList[i];
+ let uid = keyObj.userId;
+ let showKey = false;
+
+ // does a user ID (partially) match?
+ for (let idx = 0; idx < keyObj.userIds.length; idx++) {
+ uid = keyObj.userIds[idx].userId;
+ if (uid.toLowerCase().includes(searchTxt)) {
+ showKey = true;
+ }
+ }
+
+ // does the full fingerprint (without spaces) match?
+ // - no partial match check because this is special for the collapsed spaces inside the fingerprint
+ if (showKey === false && fpr && keyObj.fpr.toLowerCase() == fpr) {
+ showKey = true;
+ }
+ // does the fingerprint (partially) match?
+ if (showKey === false && keyObj.fpr.toLowerCase().includes(searchTxt)) {
+ showKey = true;
+ }
+ // does a sub key of (partially) match?
+ if (showKey === false) {
+ for (
+ let subKeyIdx = 0;
+ subKeyIdx < keyObj.subKeys.length;
+ subKeyIdx++
+ ) {
+ let subkey = keyObj.subKeys[subKeyIdx].keyId;
+ if (subkey.toLowerCase().includes(searchTxt)) {
+ showKey = true;
+ }
+ }
+ }
+ // take option to show invalid/untrusted... keys into account
+ if (
+ showKey &&
+ determineHiddenKeys(keyObj, showInvalidKeys, showOthersKeys)
+ ) {
+ keyShowList.push(i);
+ }
+ }
+
+ return keyShowList;
+ },
+
+ /**
+ * Trigger re-displaying the list of keys and apply a filter
+ *
+ * @param selectedRow: Number - the row that is currently selected or
+ * clicked on
+ */
+ applyFilter(selectedRow) {
+ let keyDisplayList = this.getFilteredKeys();
+
+ this.keyFilterList = [];
+ if (keyDisplayList === null) {
+ for (let i = 0; i < this.keyViewList.length; i++) {
+ this.keyFilterList.push(i);
+ }
+
+ this.adjustRowCount(this.keyViewList.length, selectedRow);
+ } else {
+ for (let i = 0; i < this.keyViewList.length; i++) {
+ if (keyDisplayList.includes(this.keyViewList[i].keyNum)) {
+ this.keyFilterList.push(i);
+ }
+ }
+
+ this.adjustRowCount(this.keyFilterList.length, selectedRow);
+ }
+ },
+
+ /**
+ * Re-calculate the row count and instruct the view to update
+ */
+ adjustRowCount(newRowCount, selectedRow) {
+ if (this.rowCount === newRowCount) {
+ gUserList.invalidate();
+ return;
+ }
+
+ let delta = newRowCount - this.rowCount;
+ this.rowCount = newRowCount;
+ gUserList.rowCountChanged(selectedRow, delta);
+ },
+
+ /**
+ * Determine the row object from the a filtered row number
+ *
+ * @param row: Number - row number of displayed (=filtered) list
+ * @returns Object: keyViewList entry of corresponding row
+ */
+ getFilteredRow(row) {
+ let r = this.keyFilterList[row];
+ if (r !== undefined) {
+ return this.keyViewList[r];
+ }
+ return null;
+ },
+
+ treebox: null,
+};
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailKeyManager.xhtml b/comm/mail/extensions/openpgp/content/ui/enigmailKeyManager.xhtml
new file mode 100644
index 0000000000..3c66224b8f
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailKeyManager.xhtml
@@ -0,0 +1,406 @@
+<?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://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/enigmail.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window
+ id="enigmailKeyManager"
+ data-l10n-id="openpgp-key-man-title"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="enigmailKeyManagerLoad();"
+ height="450"
+ width="700"
+ style="min-height: 450px"
+>
+ <dialog
+ id="openPgpKeyManagerDialog"
+ data-l10n-id="openpgp-card-details-close-window-label"
+ data-l10n-attrs="buttonlabelaccept"
+ buttons="accept"
+ >
+ <script
+ type="application/x-javascript"
+ src="chrome://openpgp/content/ui/enigmailCommon.js"
+ />
+ <script
+ type="application/x-javascript"
+ src="chrome://openpgp/content/ui/enigmailKeyManager.js"
+ />
+ <script
+ type="application/x-javascript"
+ src="chrome://openpgp/content/ui/commonWorkflows.js"
+ />
+ <script
+ type="application/x-javascript"
+ src="chrome://messenger/content/dialogShadowDom.js"
+ />
+
+ <linkset>
+ <html:link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ </linkset>
+
+ <commandset id="tasksCommands" />
+
+ <command id="cmd_close" oncommand="window.close()" />
+ <command id="cmd_enigmailDeleteKey" oncommand="enigmailDeleteKey()" />
+
+ <keyset id="winKeys">
+ <key
+ id="key_selectAll"
+ data-l10n-id="openpgp-key-man-select-all-key"
+ oncommand="enigmailSelectAllKeys()"
+ modifiers="accel"
+ />
+
+ <key
+ id="key_keyDetails"
+ data-l10n-id="openpgp-key-man-key-details-key"
+ oncommand="enigmailKeyDetails()"
+ modifiers="accel"
+ />
+
+ <key
+ id="key_enigDelete"
+ keycode="VK_DELETE"
+ command="cmd_enigmailDeleteKey"
+ />
+ <key id="key_close" />
+ <key id="key_quit" />
+ </keyset>
+
+ <toolbar type="menubar" style="margin-inline: -8px -10px; margin-top: -8px">
+ <menubar id="main-menubar">
+ <menu id="menu_File" data-l10n-id="openpgp-key-man-file-menu">
+ <menupopup id="menu_FilePopup" onpopupshowing="enigmailKeyMenu();">
+ <menuitem
+ id="importPubFromFile"
+ data-l10n-id="openpgp-key-man-import-public-from-file"
+ oncommand="EnigmailCommon_importObjectFromFile('pub');"
+ />
+ <menuitem
+ id="importSecFromFile"
+ data-l10n-id="openpgp-key-man-import-secret-from-file"
+ oncommand="openKeyWizard(true)"
+ />
+ <menuitem
+ id="importSigFromFile"
+ data-l10n-id="openpgp-key-man-import-sig-from-file"
+ oncommand="EnigmailCommon_importObjectFromFile('rev');"
+ />
+ <menuseparator />
+ <menuitem
+ id="exportPublicKey"
+ class="requires-key-selection"
+ data-l10n-id="openpgp-key-man-export-to-file"
+ oncommand="enigmailExportKeys('public');"
+ />
+ <menuitem
+ id="sendKey"
+ data-l10n-id="openpgp-key-man-send-keys"
+ class="requires-key-selection"
+ oncommand="enigCreateKeyMsg();"
+ />
+ <menuseparator />
+ <menuitem
+ id="backupSecretKey"
+ data-l10n-id="openpgp-key-man-backup-secret-keys"
+ oncommand="enigmailExportKeys('secret');"
+ />
+ <menuseparator />
+ <menuitem
+ id="refreshKeys"
+ data-l10n-id="openpgp-key-man-reload"
+ oncommand="clearKeyCache();"
+ />
+ <!-- add Close and Exit menu items -->
+ <menuitem
+ id="menu_close"
+ data-l10n-id="openpgp-key-man-close"
+ oncommand="onDialogClose()"
+ />
+ </menupopup>
+ </menu>
+
+ <menu data-l10n-id="openpgp-key-man-edit-menu">
+ <menupopup onpopupshowing="enigmailKeyMenu();">
+ <menuitem
+ id="importFromClipbrd"
+ data-l10n-id="openpgp-key-man-import-from-clipbrd"
+ oncommand="enigmailImportFromClipbrd();"
+ />
+ <menuitem
+ id="importFromUrl"
+ data-l10n-id="openpgp-key-man-import-from-url"
+ oncommand="enigmailImportKeysFromUrl();"
+ />
+ <menuitem
+ id="copyFprs"
+ data-l10n-id="openpgp-key-man-copy-fprs"
+ data-l10n-args='{"count": 0}'
+ class="requires-key-selection enigmail-bulk-key-operation"
+ oncommand="copyOpenPGPFingerPrints()"
+ />
+ <menuitem
+ id="copyKeyIds"
+ data-l10n-id="openpgp-key-man-copy-key-ids"
+ data-l10n-args='{"count": 0}'
+ class="requires-key-selection enigmail-bulk-key-operation"
+ oncommand="copyOpenPGPKeyIds()"
+ />
+ <menuitem
+ id="copyToClipbrd"
+ data-l10n-id="openpgp-key-man-copy-to-clipboard"
+ data-l10n-args='{"count": 0}'
+ class="requires-key-selection enigmail-bulk-key-operation"
+ oncommand="enigmailCopyToClipbrd();"
+ />
+ <menuseparator />
+
+ <menuitem
+ id="revokeKey"
+ data-l10n-id="openpgp-key-man-revoke-key"
+ oncommand="enigmailRevokeKey()"
+ />
+
+ <menuitem
+ id="deleteKey"
+ data-l10n-id="openpgp-key-man-del-key"
+ key="key_enigDelete"
+ class="requires-key-selection"
+ oncommand="enigmailDeleteKey();"
+ />
+
+ <menuseparator />
+
+ <menuitem
+ id="selectAll"
+ data-l10n-id="openpgp-key-man-select-all"
+ oncommand="enigmailSelectAllKeys()"
+ key="key_selectAll"
+ />
+ </menupopup>
+ </menu>
+
+ <menu id="viewMenu" data-l10n-id="openpgp-key-man-view-menu">
+ <menupopup onpopupshowing="enigmailKeyMenu()">
+ <!-- view menu -->
+ <menuitem
+ id="keyDetails"
+ data-l10n-id="openpgp-key-man-key-props"
+ class="requires-single-key-selection"
+ key="key_keyDetails"
+ oncommand="enigmailKeyDetails();"
+ />
+ <menuseparator />
+ <menuitem
+ id="showInvalidKeys"
+ data-l10n-id="openpgp-key-man-show-invalid-keys"
+ type="checkbox"
+ checked="true"
+ persist="checked"
+ oncommand="applyFilter();"
+ />
+ <menuitem
+ id="showOthersKeys"
+ data-l10n-id="openpgp-key-man-show-others-keys"
+ type="checkbox"
+ checked="true"
+ persist="checked"
+ oncommand="applyFilter();"
+ />
+ </menupopup>
+ </menu>
+
+ <menu id="keyserverMenu" data-l10n-id="openpgp-key-man-keyserver-menu">
+ <menupopup onpopupshowing="enigmailKeyMenu()">
+ <menuitem
+ id="importFromServer"
+ data-l10n-id="openpgp-key-man-discover-cmd"
+ oncommand="enigmailSearchKey()"
+ />
+ <menuitem
+ id="uploadToServer"
+ data-l10n-id="openpgp-key-man-publish-cmd"
+ oncommand="enigmailUploadKey()"
+ />
+ </menupopup>
+ </menu>
+
+ <menu id="generateMenu" data-l10n-id="openpgp-key-man-generate-menu">
+ <menupopup onpopupshowing="enigmailKeyMenu();">
+ <!-- generate menu -->
+ <menuitem
+ id="genKey"
+ data-l10n-id="openpgp-key-man-generate"
+ oncommand="openKeyWizard()"
+ />
+ </menupopup>
+ </menu>
+ </menubar>
+ </toolbar>
+
+ <popupset>
+ <menupopup id="ctxmenu" onpopupshowing="return enigmailKeyMenu();">
+ <menu id="ctxmenu-copy" data-l10n-id="openpgp-key-man-ctx-copy">
+ <menupopup id="ctxmenu-copy-popup">
+ <menuitem
+ id="ctxCopyFprs"
+ data-l10n-id="openpgp-key-man-ctx-copy-fprs"
+ data-l10n-args='{"count": 0}'
+ class="requires-key-selection enigmail-bulk-key-operation"
+ oncommand="copyOpenPGPFingerPrints()"
+ />
+ <menuitem
+ id="ctxCopyKeyIds"
+ data-l10n-id="openpgp-key-man-ctx-copy-key-ids"
+ data-l10n-args='{"count": 0}'
+ class="requires-key-selection enigmail-bulk-key-operation"
+ oncommand="copyOpenPGPKeyIds()"
+ />
+ <menuitem
+ id="ctxCopyPublicKeys"
+ data-l10n-id="openpgp-key-man-ctx-copy-public-keys"
+ data-l10n-args='{"count": 0}'
+ class="requires-key-selection enigmail-bulk-key-operation"
+ oncommand="enigmailCopyToClipbrd()"
+ />
+ </menupopup>
+ </menu>
+ <menuitem
+ data-l10n-id="openpgp-key-man-export-to-file"
+ id="ctxExport"
+ oncommand="enigmailExportKeys('public')"
+ />
+ <menuitem
+ data-l10n-id="openpgp-key-man-send-keys"
+ id="ctxSendKey"
+ oncommand="enigCreateKeyMsg()"
+ />
+
+ <menuseparator />
+
+ <menuitem
+ id="ctxRevokeKey"
+ data-l10n-id="openpgp-key-man-revoke-key"
+ oncommand="enigmailRevokeKey()"
+ />
+ <menuitem
+ id="ctxDeleteKey"
+ data-l10n-id="openpgp-key-man-del-key"
+ class="requires-key-selection"
+ oncommand="enigmailDeleteKey()"
+ />
+ <menuitem
+ id="ctxDetails"
+ data-l10n-id="openpgp-key-man-key-props"
+ class="requires-single-key-selection"
+ oncommand="enigmailKeyDetails()"
+ />
+ </menupopup>
+ </popupset>
+
+ <separator class="thin" />
+
+ <hbox flex="0" align="center">
+ <html:input
+ id="filterKey"
+ size="35"
+ data-l10n-id="openpgp-key-man-filter-label"
+ />
+ </hbox>
+
+ <tooltip
+ id="nothingFound"
+ data-l10n-id="openpgp-key-man-nothing-found-tooltip"
+ noautohide="true"
+ />
+ <tooltip
+ id="pleaseWait"
+ data-l10n-id="openpgp-key-man-please-wait-tooltip"
+ noautohide="true"
+ />
+
+ <separator class="thin" />
+
+ <hbox flex="1" style="min-height: 300px">
+ <tree
+ id="pgpKeyList"
+ flex="1"
+ enableColumnDrag="true"
+ seltype="multiple"
+ persist="sortDirection sortResource"
+ sortDirection="ascending"
+ sortResource="enigUserNameCol"
+ hidecolumnpicker="false"
+ context="ctxmenu"
+ >
+ <treecols>
+ <treecol
+ id="enigUserNameCol"
+ primary="true"
+ class="sortDirectionIndicator"
+ onclick="sortTree(this)"
+ data-l10n-id="openpgp-key-man-user-id-label"
+ style="width: 400px; flex: 1 auto"
+ persist="width ordinal hidden"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="keyCol"
+ style="width: 100px; flex: 1 auto"
+ data-l10n-id="openpgp-key-id-label"
+ class="sortDirectionIndicator"
+ onclick="sortTree(this)"
+ persist="width ordinal hidden"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="createdCol"
+ style="width: 70px; flex: 1 auto"
+ data-l10n-id="openpgp-key-created-label"
+ class="sortDirectionIndicator"
+ onclick="sortTree(this)"
+ persist="width ordinal hidden"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="expCol"
+ style="width: 70px; flex: 1 auto"
+ data-l10n-id="openpgp-key-expiry-label"
+ class="sortDirectionIndicator"
+ onclick="sortTree(this)"
+ persist="width ordinal hidden"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="fprCol"
+ style="width: 70px; flex: 1 auto"
+ data-l10n-id="openpgp-key-man-fingerprint-label"
+ class="sortDirectionIndicator"
+ onclick="sortTree(this)"
+ hidden="true"
+ persist="width ordinal hidden"
+ />
+ </treecols>
+
+ <treechildren id="pgpKeyListChildren" properties="" />
+ </tree>
+ </hbox>
+
+ <hbox id="statusLine">
+ <label id="statusText" value="" />
+ <html:progress id="progressBar" style="visibility: collapsed" />
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailMessengerOverlay.js b/comm/mail/extensions/openpgp/content/ui/enigmailMessengerOverlay.js
new file mode 100644
index 0000000000..d62c676d25
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailMessengerOverlay.js
@@ -0,0 +1,3460 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from ../../../../base/content/aboutMessage.js */
+/* import-globals-from ../../../../base/content/msgHdrView.js */
+/* import-globals-from ../../../../base/content/msgSecurityPane.js */
+
+// TODO: check if this is safe
+/* eslint-disable no-unsanitized/property */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CollectedKeysDB: "chrome://openpgp/content/modules/CollectedKeysDB.jsm",
+ EnigmailArmor: "chrome://openpgp/content/modules/armor.jsm",
+ EnigmailCryptoAPI: "chrome://openpgp/content/modules/cryptoAPI.jsm",
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailData: "chrome://openpgp/content/modules/data.jsm",
+ EnigmailDecryption: "chrome://openpgp/content/modules/decryption.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ EnigmailFixExchangeMsg: "chrome://openpgp/content/modules/fixExchangeMsg.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKey: "chrome://openpgp/content/modules/key.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailKeyServer: "chrome://openpgp/content/modules/keyserver.jsm",
+ EnigmailKeyserverURIs: "chrome://openpgp/content/modules/keyserverUris.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+ EnigmailMsgRead: "chrome://openpgp/content/modules/msgRead.jsm",
+ EnigmailPersistentCrypto:
+ "chrome://openpgp/content/modules/persistentCrypto.jsm",
+ EnigmailSingletons: "chrome://openpgp/content/modules/singletons.jsm",
+ EnigmailStreams: "chrome://openpgp/content/modules/streams.jsm",
+ EnigmailTrust: "chrome://openpgp/content/modules/trust.jsm",
+ EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm",
+ EnigmailVerify: "chrome://openpgp/content/modules/mimeVerify.jsm",
+ EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+ // EnigmailWks: "chrome://openpgp/content/modules/webKey.jsm",
+ KeyLookupHelper: "chrome://openpgp/content/modules/keyLookupHelper.jsm",
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+ RNP: "chrome://openpgp/content/modules/RNP.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "l10n", () => {
+ return new Localization(["messenger/openpgp/openpgp.ftl"], true);
+});
+
+var Enigmail = {};
+
+Enigmail.getEnigmailSvc = function () {
+ return EnigmailCore.getService(window);
+};
+
+Enigmail.msg = {
+ decryptedMessage: null,
+ securityInfo: null,
+ lastSaveDir: "",
+ messagePane: null,
+ decryptButton: null,
+ savedHeaders: null,
+ removeListener: false,
+ enableExperiments: false,
+ headersList: [
+ "content-transfer-encoding",
+ "x-enigmail-version",
+ "x-pgp-encoding-format",
+ //"autocrypt-setup-message",
+ ],
+ buggyMailType: null,
+ changedAttributes: [],
+ allAttachmentsDone: false,
+ messageDecryptDone: false,
+ showPartialDecryptionReminder: false,
+
+ get notificationBox() {
+ return gMessageNotificationBar.msgNotificationBar;
+ },
+
+ removeNotification(value) {
+ let item = this.notificationBox.getNotificationWithValue(value);
+ // Remove the notification only if the user didn't previously close it.
+ if (item) {
+ this.notificationBox.removeNotification(item, true);
+ }
+ },
+
+ messengerStartup() {
+ Enigmail.msg.messagePane = document.getElementById("messagepane");
+
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: Startup\n");
+
+ Enigmail.msg.savedHeaders = null;
+
+ Enigmail.msg.decryptButton = document.getElementById(
+ "button-enigmail-decrypt"
+ );
+
+ setTimeout(function () {
+ // if nothing happened, then load all keys after 1 hour
+ // to trigger the key check
+ EnigmailKeyRing.getAllKeys();
+ }, 3600 * 1000); // 1 hour
+
+ // Need to add event listener to Enigmail.msg.messagePane to make it work
+ // Adding to msgFrame doesn't seem to work
+ Enigmail.msg.messagePane.addEventListener(
+ "unload",
+ Enigmail.msg.messageFrameUnload.bind(Enigmail.msg),
+ true
+ );
+
+ EnigmailMsgRead.ensureExtraAddonHeaders();
+ gMessageListeners.push(Enigmail.msg.messageListener);
+ Enigmail.msg.messageListener.onEndHeaders();
+ },
+
+ messageListener: {
+ onStartHeaders() {
+ Enigmail.hdrView.reset();
+ Enigmail.msg.mimeParts = null;
+
+ /*
+ if ("autocrypt" in gExpandedHeaderView) {
+ delete gExpandedHeaderView.autocrypt;
+ }
+ */
+ if ("openpgp" in gExpandedHeaderView) {
+ delete gExpandedHeaderView.openpgp;
+ }
+ },
+ onEndHeaders() {},
+ onEndAttachments() {},
+ },
+
+ /*
+ viewSecurityInfo(event, displaySmimeMsg) {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: viewSecurityInfo\n");
+
+ if (event && event.button !== 0) {
+ return;
+ }
+
+ if (gSignatureStatus >= 0 || gEncryptionStatus >= 0) {
+ showMessageReadSecurityInfo();
+ } else if (Enigmail.msg.securityInfo) {
+ this.viewOpenpgpInfo();
+ } else {
+ showMessageReadSecurityInfo();
+ }
+ },
+ */
+
+ clearLastMessage() {
+ EnigmailSingletons.clearLastDecryptedMessage();
+ },
+
+ messageReload(noShowReload) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: messageReload: " + noShowReload + "\n"
+ );
+
+ this.clearLastMessage();
+ ReloadMessage();
+ },
+
+ messengerClose() {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: messengerClose()\n");
+ },
+
+ reloadCompleteMsg() {
+ this.clearLastMessage();
+ ReloadMessage();
+ },
+
+ setAttachmentReveal(attachmentList) {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: setAttachmentReveal\n");
+
+ var revealBox = document.getElementById("enigmailRevealAttachments");
+ if (revealBox) {
+ // there are situations when evealBox is not yet present
+ revealBox.setAttribute("hidden", !attachmentList ? "true" : "false");
+ }
+ },
+
+ messageCleanup() {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: messageCleanup\n");
+ for (let value of [
+ "decryptInlinePGReminder",
+ "decryptInlinePG",
+ "brokenExchangeProgress",
+ "hasNestedEncryptedParts",
+ "hasConflictingKeyOpenPGP",
+ ]) {
+ this.removeNotification(value);
+ }
+ Enigmail.msg.showPartialDecryptionReminder = false;
+
+ let element = document.getElementById("openpgpKeyBox");
+ if (element) {
+ element.hidden = true;
+ }
+ element = document.getElementById("signatureKeyBox");
+ if (element) {
+ element.hidden = true;
+ element.removeAttribute("keyid");
+ }
+
+ this.setAttachmentReveal(null);
+
+ Enigmail.msg.decryptedMessage = null;
+ Enigmail.msg.securityInfo = null;
+
+ Enigmail.msg.allAttachmentsDone = false;
+ Enigmail.msg.messageDecryptDone = false;
+
+ let cryptoBox = document.getElementById("cryptoBox");
+ if (cryptoBox) {
+ cryptoBox.removeAttribute("decryptDone");
+ }
+
+ Enigmail.msg.toAndCCSet = null;
+ Enigmail.msg.authorEmail = "";
+
+ Enigmail.msg.keyCollectCandidates = new Map();
+
+ EnigmailKeyRing.emailAddressesWithSecretKey = null;
+
+ Enigmail.msg.attachedKeys = [];
+ Enigmail.msg.attachedSenderEmailKeysIndex = [];
+
+ Enigmail.msg.autoProcessPgpKeyAttachmentTransactionID++;
+ Enigmail.msg.autoProcessPgpKeyAttachmentCount = 0;
+ Enigmail.msg.autoProcessPgpKeyAttachmentProcessed = 0;
+ Enigmail.msg.unhideMissingSigKeyBoxIsTODO = false;
+ Enigmail.msg.missingSigKey = null;
+ Enigmail.msg.buggyMailType = null;
+ },
+
+ messageFrameUnload() {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: messageFrameUnload\n");
+ Enigmail.msg.savedHeaders = null;
+ Enigmail.msg.messageCleanup();
+ },
+
+ getCurrentMsgUriSpec() {
+ return gMessageURI || "";
+ },
+
+ getCurrentMsgUrl() {
+ var uriSpec = this.getCurrentMsgUriSpec();
+ return EnigmailMsgRead.getUrlFromUriSpec(uriSpec);
+ },
+
+ updateOptionsDisplay() {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: updateOptionsDisplay: \n");
+ var optList = ["autoDecrypt"];
+
+ for (let j = 0; j < optList.length; j++) {
+ let menuElement = document.getElementById("enigmail_" + optList[j]);
+ menuElement.setAttribute(
+ "checked",
+ Services.prefs.getBoolPref("temp.openpgp.autoDecrypt")
+ ? "true"
+ : "false"
+ );
+
+ menuElement = document.getElementById("enigmail_" + optList[j] + "2");
+ if (menuElement) {
+ menuElement.setAttribute(
+ "checked",
+ Services.prefs.getBoolPref("temp.openpgp.autoDecrypt")
+ ? "true"
+ : "false"
+ );
+ }
+ }
+
+ optList = ["decryptverify"];
+ for (let j = 0; j < optList.length; j++) {
+ let menuElement = document.getElementById("enigmail_" + optList[j]);
+ if (Enigmail.msg.decryptButton && Enigmail.msg.decryptButton.disabled) {
+ menuElement.setAttribute("disabled", "true");
+ } else {
+ menuElement.removeAttribute("disabled");
+ }
+
+ menuElement = document.getElementById("enigmail_" + optList[j] + "2");
+ if (menuElement) {
+ if (Enigmail.msg.decryptButton && Enigmail.msg.decryptButton.disabled) {
+ menuElement.setAttribute("disabled", "true");
+ } else {
+ menuElement.removeAttribute("disabled");
+ }
+ }
+ }
+ },
+
+ setMainMenuLabel() {
+ let o = ["menu_Enigmail", "appmenu-Enigmail"];
+
+ let m0 = document.getElementById(o[0]);
+ let m1 = document.getElementById(o[1]);
+
+ m1.setAttribute("enigmaillabel", m0.getAttribute("enigmaillabel"));
+
+ for (let menuId of o) {
+ let menu = document.getElementById(menuId);
+
+ if (menu) {
+ let lbl = menu.getAttribute("enigmaillabel");
+ menu.setAttribute("label", lbl);
+ }
+ }
+ },
+
+ displayMainMenu(menuPopup) {
+ let obj = menuPopup.firstChild;
+
+ while (obj) {
+ if (
+ obj.getAttribute("enigmailtype") == "enigmail" ||
+ obj.getAttribute("advanced") == "true"
+ ) {
+ obj.removeAttribute("hidden");
+ }
+
+ obj = obj.nextSibling;
+ }
+
+ EnigmailFuncs.collapseAdvanced(
+ menuPopup,
+ "hidden",
+ Enigmail.msg.updateOptionsDisplay()
+ );
+ },
+
+ /**
+ * Determine if Autocrypt is enabled for the currently selected message
+ */
+ /*
+ isAutocryptEnabled() {
+ try {
+ let email = EnigmailFuncs.stripEmail(
+ gFolderDisplay.selectedMessage.recipients
+ ).toLowerCase();
+ let identity = MailServices.accounts.allIdentities.find(id =>
+ id.email?.toLowerCase() == email
+ );
+
+ if (identity) {
+ let acct = EnigmailFuncs.getAccountForIdentity(identity);
+ return acct.incomingServer.getBoolValue("enableAutocrypt");
+ }
+ } catch (ex) {}
+
+ return false;
+ },
+ */
+
+ messageImport() {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: messageImport:\n");
+
+ return this.messageParse(
+ true,
+ true,
+ "",
+ this.getCurrentMsgUriSpec(),
+ false
+ );
+ },
+
+ /***
+ * check that handler for multipart/signed is set to Enigmail.
+ * if handler is different, change it and reload message
+ *
+ * @return: - true if handler is OK
+ * - false if handler was changed and message is reloaded
+ */
+ checkPgpmimeHandler() {
+ if (
+ EnigmailVerify.currentCtHandler !== EnigmailConstants.MIME_HANDLER_PGPMIME
+ ) {
+ EnigmailVerify.registerPGPMimeHandler();
+ this.messageReload();
+ return false;
+ }
+
+ return true;
+ },
+
+ // callback function for automatic decryption
+ async messageAutoDecrypt() {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: messageAutoDecrypt:\n");
+ await Enigmail.msg.messageDecrypt(null, true);
+ },
+
+ async notifyMessageDecryptDone() {
+ Enigmail.msg.messageDecryptDone = true;
+ await Enigmail.msg.processAfterAttachmentsAndDecrypt();
+
+ // Show the partial inline encryption reminder only if the decryption action
+ // came from a partially inline encrypted message.
+ if (Enigmail.msg.showPartialDecryptionReminder) {
+ Enigmail.msg.showPartialDecryptionReminder = false;
+
+ this.notificationBox.appendNotification(
+ "decryptInlinePGReminder",
+ {
+ label: await document.l10n.formatValue(
+ "openpgp-reminder-partial-display"
+ ),
+ priority: this.notificationBox.PRIORITY_INFO_HIGH,
+ },
+ null
+ );
+ }
+ },
+
+ // analyse message header and decrypt/verify message
+ async messageDecrypt(event, isAuto) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: messageDecrypt: " + event + "\n"
+ );
+
+ event = !!event;
+
+ this.mimeParts = null;
+
+ if (!isAuto) {
+ EnigmailVerify.setManualUri(this.getCurrentMsgUriSpec());
+ }
+
+ let contentType = "text/plain";
+ if ("content-type" in currentHeaderData) {
+ contentType = currentHeaderData["content-type"].headerValue;
+ }
+
+ // don't parse message if we know it's a PGP/MIME message
+ if (
+ contentType.search(/^multipart\/encrypted(;|$)/i) === 0 &&
+ contentType.search(/application\/pgp-encrypted/i) > 0
+ ) {
+ this.movePEPsubject();
+ await this.messageDecryptCb(event, isAuto, null);
+ await this.notifyMessageDecryptDone();
+ return;
+ } else if (
+ contentType.search(/^multipart\/signed(;|$)/i) === 0 &&
+ contentType.search(/application\/pgp-signature/i) > 0
+ ) {
+ this.movePEPsubject();
+ await this.messageDecryptCb(event, isAuto, null);
+ await this.notifyMessageDecryptDone();
+ return;
+ }
+
+ let url = this.getCurrentMsgUrl();
+ if (!url) {
+ await Enigmail.msg.messageDecryptCb(event, isAuto, null);
+ await Enigmail.msg.notifyMessageDecryptDone();
+ return;
+ }
+ await new Promise(resolve => {
+ EnigmailMime.getMimeTreeFromUrl(
+ url.spec,
+ false,
+ async function (mimeMsg) {
+ await Enigmail.msg.messageDecryptCb(event, isAuto, mimeMsg);
+ await Enigmail.msg.notifyMessageDecryptDone();
+ resolve();
+ }
+ );
+ });
+ },
+
+ /***
+ * walk through the (sub-) mime tree and determine PGP/MIME encrypted and signed message parts
+ *
+ * @param mimePart: parent object to walk through
+ * @param resultObj: object containing two arrays. The resultObj must be pre-initialized by the caller
+ * - encrypted
+ * - signed
+ */
+ enumerateMimeParts(mimePart, resultObj) {
+ EnigmailLog.DEBUG(
+ 'enumerateMimeParts: partNum="' + mimePart.partNum + '"\n'
+ );
+ EnigmailLog.DEBUG(" " + mimePart.fullContentType + "\n");
+ EnigmailLog.DEBUG(
+ " " + mimePart.subParts.length + " subparts\n"
+ );
+
+ try {
+ var ct = mimePart.fullContentType;
+ if (typeof ct == "string") {
+ ct = ct.replace(/[\r\n]/g, " ");
+ if (ct.search(/multipart\/signed.*application\/pgp-signature/i) >= 0) {
+ resultObj.signed.push(mimePart.partNum);
+ } else if (ct.search(/application\/pgp-encrypted/i) >= 0) {
+ resultObj.encrypted.push(mimePart.partNum);
+ }
+ }
+ } catch (ex) {
+ // catch exception if no headers or no content-type defined.
+ }
+
+ var i;
+ for (i in mimePart.subParts) {
+ this.enumerateMimeParts(mimePart.subParts[i], resultObj);
+ }
+ },
+
+ async messageDecryptCb(event, isAuto, mimeMsg) {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: messageDecryptCb:\n");
+
+ let enigmailSvc;
+ let contentType = "";
+ try {
+ if (!mimeMsg) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: messageDecryptCb: mimeMsg is null\n"
+ );
+ try {
+ contentType = currentHeaderData["content-type"].headerValue;
+ } catch (ex) {
+ contentType = "text/plain";
+ }
+ mimeMsg = {
+ partNum: "1",
+ headers: {
+ has() {
+ return false;
+ },
+ contentType: {
+ type: contentType,
+ mediatype: "",
+ subtype: "",
+ },
+ },
+ fullContentType: contentType,
+ body: "",
+ parent: null,
+ subParts: [],
+ };
+ }
+
+ // Copy selected headers
+ Enigmail.msg.savedHeaders = {
+ autocrypt: [],
+ };
+
+ for (let h in currentHeaderData) {
+ if (h.search(/^autocrypt\d*$/) === 0) {
+ Enigmail.msg.savedHeaders.autocrypt.push(
+ currentHeaderData[h].headerValue
+ );
+ }
+ }
+
+ if (!mimeMsg.fullContentType) {
+ mimeMsg.fullContentType = "text/plain";
+ }
+
+ Enigmail.msg.savedHeaders["content-type"] = mimeMsg.fullContentType;
+ this.mimeParts = mimeMsg;
+
+ for (var index = 0; index < Enigmail.msg.headersList.length; index++) {
+ var headerName = Enigmail.msg.headersList[index];
+ var headerValue = "";
+
+ if (mimeMsg.headers.has(headerName)) {
+ let h = mimeMsg.headers.get(headerName);
+ if (Array.isArray(h)) {
+ headerValue = h.join("");
+ } else {
+ headerValue = h;
+ }
+ }
+ Enigmail.msg.savedHeaders[headerName] = headerValue;
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: header " +
+ headerName +
+ ": '" +
+ headerValue +
+ "'\n"
+ );
+ }
+
+ var msgSigned =
+ mimeMsg.fullContentType.search(/^multipart\/signed/i) === 0 &&
+ EnigmailMime.getProtocol(mimeMsg.fullContentType).search(
+ /^application\/pgp-signature/i
+ ) === 0;
+ var msgEncrypted =
+ mimeMsg.fullContentType.search(/^multipart\/encrypted/i) === 0 &&
+ EnigmailMime.getProtocol(mimeMsg.fullContentType).search(
+ /^application\/pgp-encrypted/i
+ ) === 0;
+ var resultObj = {
+ encrypted: [],
+ signed: [],
+ };
+
+ if (mimeMsg.subParts.length > 0) {
+ this.enumerateMimeParts(mimeMsg, resultObj);
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: embedded objects: " +
+ resultObj.encrypted.join(", ") +
+ " / " +
+ resultObj.signed.join(", ") +
+ "\n"
+ );
+
+ msgSigned = msgSigned || resultObj.signed.length > 0;
+ msgEncrypted = msgEncrypted || resultObj.encrypted.length > 0;
+
+ /*
+ if (
+ "autocrypt-setup-message" in Enigmail.msg.savedHeaders &&
+ Enigmail.msg.savedHeaders["autocrypt-setup-message"].toLowerCase() ===
+ "v1"
+ ) {
+ if (
+ currentAttachments[0].contentType.search(
+ /^application\/autocrypt-setup$/i
+ ) === 0
+ ) {
+ Enigmail.hdrView.displayAutoCryptSetupMsgHeader();
+ return;
+ }
+ }
+ */
+
+ // HACK for Zimbra OpenPGP Zimlet
+ // Zimbra illegally changes attachment content-type to application/pgp-encrypted which interfers with below
+ // see https://sourceforge.net/p/enigmail/bugs/600/
+
+ try {
+ if (
+ mimeMsg.subParts.length > 1 &&
+ mimeMsg.headers.has("x-mailer") &&
+ mimeMsg.headers.get("x-mailer")[0].includes("ZimbraWebClient") &&
+ mimeMsg.subParts[0].fullContentType.includes("text/plain") &&
+ mimeMsg.fullContentType.includes("multipart/mixed") &&
+ mimeMsg.subParts[1].fullContentType.includes(
+ "application/pgp-encrypted"
+ )
+ ) {
+ await this.messageParse(
+ event,
+ false,
+ Enigmail.msg.savedHeaders["content-transfer-encoding"],
+ this.getCurrentMsgUriSpec(),
+ isAuto
+ );
+ return;
+ }
+ } catch (ex) {
+ console.debug(ex);
+ }
+
+ // HACK for MS-EXCHANGE-Server Problem:
+ // check for possible bad mime structure due to buggy exchange server:
+ // - multipart/mixed Container with
+ // - application/pgp-encrypted Attachment with name "PGPMIME Versions Identification"
+ // - application/octet-stream Attachment with name "encrypted.asc" having the encrypted content in base64
+ // - see:
+ // - http://www.mozilla-enigmail.org/forum/viewtopic.php?f=4&t=425
+ // - http://sourceforge.net/p/enigmail/forum/support/thread/4add2b69/
+
+ // iPGMail produces a similar broken structure, see here:
+ // - https://sourceforge.net/p/enigmail/forum/support/thread/afc9c246/#5de7
+
+ // Don't attempt to detect again, if we have already decided
+ // it's a buggy exchange message (buggyMailType is already set).
+
+ if (
+ !Enigmail.msg.buggyMailType &&
+ mimeMsg.subParts.length == 3 &&
+ mimeMsg.fullContentType.search(/multipart\/mixed/i) >= 0 &&
+ mimeMsg.subParts[0].fullContentType.search(/multipart\/encrypted/i) <
+ 0 &&
+ mimeMsg.subParts[0].fullContentType.search(
+ /(text\/(plain|html)|multipart\/alternative)/i
+ ) >= 0 &&
+ mimeMsg.subParts[1].fullContentType.search(
+ /application\/pgp-encrypted/i
+ ) >= 0
+ ) {
+ if (
+ mimeMsg.subParts[1].fullContentType.search(
+ /multipart\/encrypted/i
+ ) < 0 &&
+ mimeMsg.subParts[1].fullContentType.search(
+ /PGP\/?MIME Versions? Identification/i
+ ) >= 0 &&
+ mimeMsg.subParts[2].fullContentType.search(
+ /application\/octet-stream/i
+ ) >= 0 &&
+ mimeMsg.subParts[2].fullContentType.search(/encrypted.asc/i) >= 0
+ ) {
+ this.buggyMailType = "exchange";
+ } else {
+ this.buggyMailType = "iPGMail";
+ }
+
+ // signal that the structure matches to save the content later on
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay: messageDecryptCb: enabling MS-Exchange hack\n"
+ );
+
+ await this.buggyMailHeader();
+ return;
+ }
+ }
+
+ var contentEncoding = "";
+ var msgUriSpec = this.getCurrentMsgUriSpec();
+
+ if (Enigmail.msg.savedHeaders) {
+ contentType = Enigmail.msg.savedHeaders["content-type"];
+ contentEncoding =
+ Enigmail.msg.savedHeaders["content-transfer-encoding"];
+ }
+
+ let smime =
+ contentType.search(
+ /multipart\/signed; protocol="application\/pkcs7-signature/i
+ ) >= 0;
+ if (!smime && (msgSigned || msgEncrypted)) {
+ // PGP/MIME messages
+ enigmailSvc = Enigmail.getEnigmailSvc();
+ if (!enigmailSvc) {
+ return;
+ }
+
+ if (!Enigmail.msg.checkPgpmimeHandler()) {
+ return;
+ }
+
+ if (isAuto && !Services.prefs.getBoolPref("temp.openpgp.autoDecrypt")) {
+ if (EnigmailVerify.getManualUri() != this.getCurrentMsgUriSpec()) {
+ // decryption set to manual
+ Enigmail.hdrView.updatePgpStatus(
+ EnigmailConstants.POSSIBLE_PGPMIME,
+ 0, // exitCode, statusFlags
+ 0,
+ "",
+ "", // keyId, userId
+ "", // sigDetails
+ await l10n.formatValue("possibly-pgp-mime"), // infoMsg
+ null, // blockSeparation
+ null, // encToDetails
+ null
+ ); // xtraStatus
+ }
+ } else if (!isAuto) {
+ Enigmail.msg.messageReload(false);
+ }
+ return;
+ }
+
+ // inline-PGP messages
+ if (!isAuto || Services.prefs.getBoolPref("temp.openpgp.autoDecrypt")) {
+ await this.messageParse(
+ event,
+ false,
+ contentEncoding,
+ msgUriSpec,
+ isAuto
+ );
+ }
+ } catch (ex) {
+ EnigmailLog.writeException(
+ "enigmailMessengerOverlay.js: messageDecryptCb",
+ ex
+ );
+ }
+ },
+
+ /**
+ * Display header about reparing buggy MS-Exchange messages.
+ */
+ async buggyMailHeader() {
+ let uri = this.getCurrentMsgUrl();
+ Enigmail.hdrView.headerPane.updateSecurityStatus(
+ "",
+ 0,
+ 0,
+ 0,
+ "",
+ "",
+ "",
+ "",
+ "",
+ uri,
+ "",
+ "1"
+ );
+
+ // Warn that we can't fix a message that was opened from a local file.
+ if (!gFolder) {
+ Enigmail.msg.notificationBox.appendNotification(
+ "brokenExchange",
+ {
+ label: await document.l10n.formatValue(
+ "openpgp-broken-exchange-opened"
+ ),
+ priority: Enigmail.msg.notificationBox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ return;
+ }
+
+ let buttons = [
+ {
+ "l10n-id": "openpgp-broken-exchange-repair",
+ popup: null,
+ callback(notification, button) {
+ Enigmail.msg.fixBuggyExchangeMail();
+ return false; // Close notification.
+ },
+ },
+ ];
+
+ Enigmail.msg.notificationBox.appendNotification(
+ "brokenExchange",
+ {
+ label: await document.l10n.formatValue("openpgp-broken-exchange-info"),
+ priority: Enigmail.msg.notificationBox.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+ },
+
+ getFirstPGPMessageType(msgText) {
+ let indexEncrypted = msgText.indexOf("-----BEGIN PGP MESSAGE-----");
+ let indexSigned = msgText.indexOf("-----BEGIN PGP SIGNED MESSAGE-----");
+ if (indexEncrypted >= 0) {
+ if (
+ indexSigned == -1 ||
+ (indexSigned >= 0 && indexEncrypted < indexSigned)
+ ) {
+ return "encrypted";
+ }
+ }
+
+ if (indexSigned >= 0) {
+ return "signed";
+ }
+
+ return "";
+ },
+
+ trimIfEncrypted(msgText) {
+ // If it's an encrypted message, we want to trim (at least) the
+ // separator line between the header and the content.
+ // However, trimming all lines should be safe.
+
+ if (Enigmail.msg.getFirstPGPMessageType(msgText) == "encrypted") {
+ // \xA0 is non-breaking-space
+ msgText = msgText.replace(/^[ \t\xA0]+/gm, "");
+ }
+ return msgText;
+ },
+
+ async messageParse(
+ interactive,
+ importOnly,
+ contentEncoding,
+ msgUriSpec,
+ isAuto,
+ pbMessageIndex = "0"
+ ) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: messageParse: " + interactive + "\n"
+ );
+
+ var bodyElement = this.getBodyElement(pbMessageIndex);
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: bodyElement=" + bodyElement + "\n"
+ );
+
+ if (!bodyElement) {
+ return;
+ }
+
+ let topElement = bodyElement;
+ var findStr = /* interactive ? null : */ "-----BEGIN PGP";
+ var msgText = null;
+ var foundIndex = -1;
+
+ let bodyElementFound = false;
+ let hasHeadOrTailNode = false;
+
+ if (bodyElement.firstChild) {
+ let node = bodyElement.firstChild;
+ while (node) {
+ if (
+ node.firstChild &&
+ node.firstChild.nodeName.toUpperCase() == "LEGEND" &&
+ node.firstChild.className == "moz-mime-attachment-header-name"
+ ) {
+ // we reached the area where inline attachments are displayed
+ // --> don't try to decrypt displayed inline attachments
+ break;
+ }
+ if (node.nodeName === "DIV") {
+ if (bodyElementFound) {
+ hasHeadOrTailNode = true;
+ break;
+ }
+
+ foundIndex = node.textContent.indexOf(findStr);
+
+ if (foundIndex < 0) {
+ hasHeadOrTailNode = true;
+ node = node.nextSibling;
+ continue;
+ }
+
+ if (foundIndex >= 0) {
+ if (
+ node.textContent.indexOf(findStr + " LICENSE AUTHORIZATION") ==
+ foundIndex
+ ) {
+ foundIndex = -1;
+ node = node.nextSibling;
+ continue;
+ }
+ }
+
+ if (foundIndex === 0) {
+ bodyElement = node;
+ bodyElementFound = true;
+ } else if (
+ foundIndex > 0 &&
+ node.textContent.substr(foundIndex - 1, 1).search(/[\r\n]/) === 0
+ ) {
+ bodyElement = node;
+ bodyElementFound = true;
+ }
+ }
+ node = node.nextSibling;
+ }
+ }
+
+ if (foundIndex >= 0 && !this.hasInlineQuote(topElement)) {
+ let beginIndex = {};
+ let endIndex = {};
+ let indentStr = {};
+
+ if (
+ Enigmail.msg.savedHeaders["content-type"].search(/^text\/html/i) === 0
+ ) {
+ let p = Cc["@mozilla.org/parserutils;1"].createInstance(
+ Ci.nsIParserUtils
+ );
+ const de = Ci.nsIDocumentEncoder;
+ msgText = p.convertToPlainText(
+ topElement.innerHTML,
+ de.OutputRaw | de.OutputBodyOnly,
+ 0
+ );
+ } else {
+ msgText = bodyElement.textContent;
+ }
+
+ if (!isAuto) {
+ let blockType = EnigmailArmor.locateArmoredBlock(
+ msgText,
+ 0,
+ "",
+ beginIndex,
+ endIndex,
+ indentStr
+ );
+ if (!blockType) {
+ msgText = "";
+ } else {
+ msgText = msgText.substring(beginIndex.value, endIndex.value + 1);
+ }
+ }
+
+ msgText = this.trimIfEncrypted(msgText);
+ }
+
+ if (!msgText) {
+ // No PGP content
+ return;
+ }
+
+ let charset = currentCharacterSet ?? "";
+ if (charset != "UTF-8") {
+ // Encode ciphertext to charset from unicode
+ msgText = EnigmailData.convertFromUnicode(msgText, charset);
+ }
+
+ if (isAuto) {
+ let ht = hasHeadOrTailNode || this.hasHeadOrTailBesidesInlinePGP(msgText);
+ if (ht) {
+ let infoId;
+ let buttonId;
+ if (
+ ht & EnigmailConstants.UNCERTAIN_SIGNATURE ||
+ Enigmail.msg.getFirstPGPMessageType(msgText) == "signed"
+ ) {
+ infoId = "openpgp-partially-signed";
+ buttonId = "openpgp-partial-verify-button";
+ } else {
+ infoId = "openpgp-partially-encrypted";
+ buttonId = "openpgp-partial-decrypt-button";
+ }
+
+ let [description, buttonLabel] = await document.l10n.formatValues([
+ { id: infoId },
+ { id: buttonId },
+ ]);
+
+ let buttons = [
+ {
+ label: buttonLabel,
+ popup: null,
+ callback(aNotification, aButton) {
+ Enigmail.msg.processOpenPGPSubset();
+ return false; // Close notification.
+ },
+ },
+ ];
+
+ this.notificationBox.appendNotification(
+ "decryptInlinePG",
+ {
+ label: description,
+ priority: this.notificationBox.PRIORITY_INFO_HIGH,
+ },
+ buttons
+ );
+ return;
+ }
+ }
+
+ var mozPlainText = bodyElement.innerHTML.search(/class="moz-text-plain"/);
+
+ if (mozPlainText >= 0 && mozPlainText < 40) {
+ // workaround for too much expanded emoticons in plaintext msg
+ var r = new RegExp(
+ /( )(;-\)|:-\)|;\)|:\)|:-\(|:\(|:-\\|:-P|:-D|:-\[|:-\*|>:o|8-\)|:-\$|:-X|=-O|:-!|O:-\)|:'\()( )/g
+ );
+ if (msgText.search(r) >= 0) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: messageParse: performing emoticons fixing\n"
+ );
+ msgText = msgText.replace(r, "$2");
+ }
+ }
+
+ // ignoring text following armored block
+
+ //EnigmailLog.DEBUG("enigmailMessengerOverlay.js: msgText='"+msgText+"'\n");
+
+ var mailNewsUrl = EnigmailMsgRead.getUrlFromUriSpec(msgUriSpec);
+
+ var urlSpec = mailNewsUrl ? mailNewsUrl.spec : "";
+
+ let retry = 1;
+
+ await Enigmail.msg.messageParseCallback(
+ msgText,
+ EnigmailDecryption.getMsgDate(window),
+ contentEncoding,
+ charset,
+ interactive,
+ importOnly,
+ urlSpec,
+ "",
+ retry,
+ "", // head
+ "", // tail
+ msgUriSpec,
+ isAuto,
+ pbMessageIndex
+ );
+ },
+
+ hasInlineQuote(node) {
+ if (node.innerHTML.search(/<blockquote.*-----BEGIN PGP /i) < 0) {
+ return false;
+ }
+
+ return EnigmailMsgRead.searchQuotedPgp(node);
+ },
+
+ hasHeadOrTailBesidesInlinePGP(msgText) {
+ let startIndex = msgText.search(/-----BEGIN PGP (SIGNED )?MESSAGE-----/m);
+ let endIndex = msgText.indexOf("-----END PGP");
+ let hasHead = false;
+ let hasTail = false;
+ let crypto = 0;
+
+ if (startIndex > 0) {
+ let pgpMsg = msgText.match(/(-----BEGIN PGP (SIGNED )?MESSAGE-----)/m)[0];
+ if (pgpMsg.search(/SIGNED/) > 0) {
+ crypto = EnigmailConstants.UNCERTAIN_SIGNATURE;
+ } else {
+ crypto = EnigmailConstants.DECRYPTION_FAILED;
+ }
+ let startSection = msgText.substr(0, startIndex - 1);
+ hasHead = startSection.search(/\S/) >= 0;
+ }
+
+ if (endIndex > startIndex) {
+ let nextLine = msgText.substring(endIndex).search(/[\n\r]/);
+ if (nextLine > 0) {
+ hasTail = msgText.substring(endIndex + nextLine).search(/\S/) >= 0;
+ }
+ }
+
+ if (hasHead || hasTail) {
+ return EnigmailConstants.PARTIALLY_PGP | crypto;
+ }
+
+ return 0;
+ },
+
+ async processOpenPGPSubset() {
+ Enigmail.msg.showPartialDecryptionReminder = true;
+ await this.messageDecrypt(null, false);
+ },
+
+ getBodyElement() {
+ let msgFrame = document.getElementById("messagepane");
+ if (!msgFrame || !msgFrame.contentDocument) {
+ return null;
+ }
+ return msgFrame.contentDocument.getElementsByTagName("body")[0];
+ },
+
+ async messageParseCallback(
+ msgText,
+ msgDate,
+ contentEncoding,
+ charset,
+ interactive,
+ importOnly,
+ messageUrl,
+ signature,
+ retry,
+ head,
+ tail,
+ msgUriSpec,
+ isAuto,
+ pbMessageIndex
+ ) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: messageParseCallback: " +
+ interactive +
+ ", " +
+ interactive +
+ ", importOnly=" +
+ importOnly +
+ ", charset=" +
+ charset +
+ ", msgUrl=" +
+ messageUrl +
+ ", retry=" +
+ retry +
+ ", signature='" +
+ signature +
+ "'\n"
+ );
+
+ if (!msgText) {
+ return;
+ }
+
+ var enigmailSvc = Enigmail.getEnigmailSvc();
+ if (!enigmailSvc) {
+ return;
+ }
+
+ var plainText;
+ var exitCode;
+ var newSignature = "";
+ var statusFlags = 0;
+ var extStatusFlags = 0;
+
+ var errorMsgObj = {
+ value: "",
+ };
+ var keyIdObj = {};
+ var userIdObj = {};
+ var sigDetailsObj = {};
+ var encToDetailsObj = {};
+
+ var blockSeparationObj = {
+ value: "",
+ };
+
+ if (importOnly) {
+ // Import public key
+ await this.importKeyFromMsgBody(msgText);
+ return;
+ }
+ let armorHeaders = EnigmailArmor.getArmorHeaders(msgText);
+ if ("charset" in armorHeaders) {
+ charset = armorHeaders.charset;
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: messageParseCallback: OVERRIDING charset=" +
+ charset +
+ "\n"
+ );
+ }
+
+ var exitCodeObj = {};
+ var statusFlagsObj = {};
+ var signatureObj = {};
+ signatureObj.value = signature;
+
+ var uiFlags = interactive
+ ? EnigmailConstants.UI_INTERACTIVE |
+ // EnigmailConstants.UI_ALLOW_KEY_IMPORT |
+ EnigmailConstants.UI_UNVERIFIED_ENC_OK
+ : 0;
+
+ plainText = EnigmailDecryption.decryptMessage(
+ window,
+ uiFlags,
+ msgText,
+ msgDate,
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj,
+ blockSeparationObj,
+ encToDetailsObj
+ );
+
+ //EnigmailLog.DEBUG("enigmailMessengerOverlay.js: messageParseCallback: plainText='"+plainText+"'\n");
+
+ exitCode = exitCodeObj.value;
+ newSignature = signatureObj.value;
+
+ if (plainText === "" && exitCode === 0) {
+ plainText = " ";
+ }
+
+ statusFlags = statusFlagsObj.value;
+ extStatusFlags = statusFlagsObj.ext;
+
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: messageParseCallback: newSignature='" +
+ newSignature +
+ "'\n"
+ );
+
+ var errorMsg = errorMsgObj.value;
+
+ if (importOnly) {
+ if (interactive && errorMsg) {
+ EnigmailDialog.alert(window, errorMsg);
+ }
+ return;
+ }
+
+ var displayedUriSpec = Enigmail.msg.getCurrentMsgUriSpec();
+ if (!msgUriSpec || displayedUriSpec == msgUriSpec) {
+ if (exitCode && !statusFlags) {
+ // Failure, but we don't know why it failed.
+ // Peek inside msgText, and check what kind of content it is,
+ // so we can show a minimal error.
+
+ let msgType = Enigmail.msg.getFirstPGPMessageType(msgText);
+ if (msgType == "encrypted") {
+ statusFlags = EnigmailConstants.DECRYPTION_FAILED;
+ } else if (msgType == "signed") {
+ statusFlags = EnigmailConstants.BAD_SIGNATURE;
+ }
+ }
+
+ Enigmail.hdrView.updatePgpStatus(
+ exitCode,
+ statusFlags,
+ extStatusFlags,
+ keyIdObj.value,
+ userIdObj.value,
+ sigDetailsObj.value,
+ errorMsg,
+ null, // blockSeparation
+ encToDetailsObj.value,
+ null
+ ); // xtraStatus
+ }
+
+ var noSecondTry =
+ EnigmailConstants.GOOD_SIGNATURE |
+ EnigmailConstants.EXPIRED_SIGNATURE |
+ EnigmailConstants.EXPIRED_KEY_SIGNATURE |
+ EnigmailConstants.EXPIRED_KEY |
+ EnigmailConstants.REVOKED_KEY |
+ EnigmailConstants.NO_PUBKEY |
+ EnigmailConstants.NO_SECKEY |
+ EnigmailConstants.IMPORTED_KEY |
+ EnigmailConstants.MISSING_PASSPHRASE |
+ EnigmailConstants.BAD_PASSPHRASE |
+ EnigmailConstants.UNKNOWN_ALGO |
+ EnigmailConstants.DECRYPTION_OKAY |
+ EnigmailConstants.OVERFLOWED;
+
+ if (exitCode !== 0 && !(statusFlags & noSecondTry)) {
+ // Bad signature/armor
+ if (retry == 1) {
+ msgText = EnigmailData.convertFromUnicode(msgText, "UTF-8");
+ await Enigmail.msg.messageParseCallback(
+ msgText,
+ msgDate,
+ contentEncoding,
+ charset,
+ interactive,
+ importOnly,
+ messageUrl,
+ signature,
+ retry + 1,
+ head,
+ tail,
+ msgUriSpec,
+ isAuto,
+ pbMessageIndex
+ );
+ return;
+ } else if (retry == 2) {
+ // Try to verify signature by accessing raw message text directly
+ // (avoid recursion by setting retry parameter to false on callback)
+ newSignature = "";
+ await Enigmail.msg.msgDirectDecrypt(
+ interactive,
+ importOnly,
+ contentEncoding,
+ charset,
+ newSignature,
+ 0,
+ head,
+ tail,
+ msgUriSpec,
+ msgDate,
+ Enigmail.msg.messageParseCallback,
+ isAuto
+ );
+ return;
+ } else if (retry == 3) {
+ msgText = EnigmailData.convertFromUnicode(msgText, "UTF-8");
+ await Enigmail.msg.messageParseCallback(
+ msgText,
+ msgDate,
+ contentEncoding,
+ charset,
+ interactive,
+ importOnly,
+ messageUrl,
+ null,
+ retry + 1,
+ head,
+ tail,
+ msgUriSpec,
+ isAuto,
+ pbMessageIndex
+ );
+ return;
+ }
+ }
+
+ if (!plainText) {
+ // Show the subset that we cannot process, together with status.
+ plainText = msgText;
+ }
+
+ if (retry >= 2) {
+ plainText = EnigmailData.convertFromUnicode(
+ EnigmailData.convertToUnicode(plainText, "UTF-8"),
+ charset
+ );
+ }
+
+ // TODO: what is blockSeparation ? How to emulate with RNP?
+ /*
+ if (blockSeparationObj.value.includes(" ")) {
+ var blocks = blockSeparationObj.value.split(/ /);
+ var blockInfo = blocks[0].split(/:/);
+ plainText =
+ EnigmailData.convertFromUnicode(
+ "*Parts of the message have NOT been signed nor encrypted*",
+ charset
+ ) +
+ "\n\n" +
+ plainText.substr(0, blockInfo[1]) +
+ "\n\n" +
+ "*Multiple message blocks found -- decryption/verification aborted*";
+ }
+ */
+
+ // Save decrypted message status, headers, and content
+ var headerList = {
+ subject: "",
+ from: "",
+ date: "",
+ to: "",
+ cc: "",
+ };
+
+ var index, headerName;
+
+ if (!gViewAllHeaders) {
+ for (index = 0; index < headerList.length; index++) {
+ headerList[index] = "";
+ }
+ } else {
+ for (index = 0; index < gExpandedHeaderList.length; index++) {
+ headerList[gExpandedHeaderList[index].name] = "";
+ }
+
+ for (headerName in currentHeaderData) {
+ headerList[headerName] = "";
+ }
+ }
+
+ for (headerName in headerList) {
+ if (currentHeaderData[headerName]) {
+ headerList[headerName] = currentHeaderData[headerName].headerValue;
+ }
+ }
+
+ // WORKAROUND
+ if (headerList.cc == headerList.to) {
+ headerList.cc = "";
+ }
+
+ var hasAttachments = currentAttachments && currentAttachments.length;
+ var attachmentsEncrypted = true;
+
+ for (index in currentAttachments) {
+ if (!Enigmail.msg.checkEncryptedAttach(currentAttachments[index])) {
+ if (
+ !EnigmailMsgRead.checkSignedAttachment(
+ currentAttachments,
+ index,
+ currentAttachments
+ )
+ ) {
+ attachmentsEncrypted = false;
+ }
+ }
+ }
+
+ Enigmail.msg.decryptedMessage = {
+ url: messageUrl,
+ uri: msgUriSpec,
+ headerList,
+ hasAttachments,
+ attachmentsEncrypted,
+ charset,
+ plainText,
+ };
+
+ // don't display decrypted message if message selection has changed
+ displayedUriSpec = Enigmail.msg.getCurrentMsgUriSpec();
+ if (msgUriSpec && displayedUriSpec && displayedUriSpec != msgUriSpec) {
+ return;
+ }
+
+ // Create and load one-time message URI
+ var messageContent = Enigmail.msg.getDecryptedMessage(
+ "message/rfc822",
+ false
+ );
+
+ var node;
+ var bodyElement = Enigmail.msg.getBodyElement(pbMessageIndex);
+
+ if (bodyElement.firstChild) {
+ node = bodyElement.firstChild;
+
+ let divFound = false;
+
+ while (node) {
+ if (node.nodeName == "DIV") {
+ if (divFound) {
+ node.innerHTML = "";
+ } else {
+ // for safety reasons, we replace the complete visible message with
+ // the decrypted or signed part (bug 983)
+ divFound = true;
+ node.innerHTML = EnigmailFuncs.formatPlaintextMsg(
+ EnigmailData.convertToUnicode(messageContent, charset)
+ );
+ Enigmail.msg.movePEPsubject();
+ }
+ }
+ node = node.nextSibling;
+ }
+
+ if (divFound) {
+ return;
+ }
+
+ let preFound = false;
+
+ // if no <DIV> node is found, try with <PRE> (bug 24762)
+ node = bodyElement.firstChild;
+ while (node) {
+ if (node.nodeName == "PRE") {
+ if (preFound) {
+ node.innerHTML = "";
+ } else {
+ preFound = true;
+ node.innerHTML = EnigmailFuncs.formatPlaintextMsg(
+ EnigmailData.convertToUnicode(messageContent, charset)
+ );
+ Enigmail.msg.movePEPsubject();
+ }
+ }
+ node = node.nextSibling;
+ }
+
+ if (preFound) {
+ return;
+ }
+ }
+
+ EnigmailLog.ERROR(
+ "enigmailMessengerOverlay.js: no node found to replace message display\n"
+ );
+ },
+
+ importAttachedSenderKey() {
+ for (let info of Enigmail.msg.attachedSenderEmailKeysIndex) {
+ EnigmailKeyRing.importKeyDataWithConfirmation(
+ window,
+ [info.keyInfo],
+ Enigmail.msg.attachedKeys[info.idx],
+ true,
+ ["0x" + info.keyInfo.fpr]
+ );
+ }
+ },
+
+ async searchSignatureKey() {
+ let keyId = document
+ .getElementById("signatureKeyBox")
+ .getAttribute("keyid");
+ if (!keyId) {
+ return false;
+ }
+ return KeyLookupHelper.lookupAndImportByKeyID(
+ "interactive-import",
+ window,
+ keyId,
+ true
+ );
+ },
+
+ notifySigKeyMissing(keyId) {
+ Enigmail.msg.missingSigKey = keyId;
+ if (
+ Enigmail.msg.allAttachmentsDone &&
+ Enigmail.msg.messageDecryptDone &&
+ Enigmail.msg.autoProcessPgpKeyAttachmentProcessed ==
+ Enigmail.msg.autoProcessPgpKeyAttachmentCount
+ ) {
+ Enigmail.msg.unhideMissingSigKeyBox();
+ } else {
+ Enigmail.msg.unhideMissingSigKeyBoxIsTODO = true;
+ }
+ },
+
+ unhideMissingSigKeyBox() {
+ let sigKeyIsAttached = false;
+ for (let info of Enigmail.msg.attachedSenderEmailKeysIndex) {
+ if (info.keyInfo.keyId == Enigmail.msg.missingSigKey) {
+ sigKeyIsAttached = true;
+ break;
+ }
+ }
+ if (!sigKeyIsAttached) {
+ let b = document.getElementById("signatureKeyBox");
+ b.removeAttribute("hidden");
+ b.setAttribute("keyid", Enigmail.msg.missingSigKey);
+ }
+ },
+
+ async importKeyFromMsgBody(msgData) {
+ let beginIndexObj = {};
+ let endIndexObj = {};
+ let indentStrObj = {};
+ let blockType = EnigmailArmor.locateArmoredBlock(
+ msgData,
+ 0,
+ "",
+ beginIndexObj,
+ endIndexObj,
+ indentStrObj
+ );
+ if (!blockType || blockType !== "PUBLIC KEY BLOCK") {
+ return;
+ }
+
+ let keyData = msgData.substring(beginIndexObj.value, endIndexObj.value);
+
+ let errorMsgObj = {};
+ let preview = await EnigmailKey.getKeyListFromKeyBlock(
+ keyData,
+ errorMsgObj,
+ true,
+ true,
+ false
+ );
+ if (preview && errorMsgObj.value === "") {
+ EnigmailKeyRing.importKeyDataWithConfirmation(
+ window,
+ preview,
+ keyData,
+ false
+ );
+ } else {
+ document.l10n.formatValue("preview-failed").then(value => {
+ EnigmailDialog.alert(window, value + "\n" + errorMsgObj.value);
+ });
+ }
+ },
+
+ /**
+ * Extract the subject from the 1st content line and move it to the subject line
+ */
+ movePEPsubject() {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: movePEPsubject:\n");
+
+ let bodyElement = this.getBodyElement();
+ if (
+ bodyElement.textContent.search(/^\r?\n?Subject: [^\r\n]+\r?\n\r?\n/i) ===
+ 0 &&
+ "subject" in currentHeaderData &&
+ currentHeaderData.subject.headerValue === "pEp"
+ ) {
+ let m = EnigmailMime.extractSubjectFromBody(bodyElement.textContent);
+ if (m) {
+ let node = bodyElement.firstChild;
+ let found = false;
+
+ while (!found && node) {
+ if (node.nodeName == "DIV") {
+ node.innerHTML = EnigmailFuncs.formatPlaintextMsg(m.messageBody);
+ found = true;
+ }
+ node = node.nextSibling;
+ }
+
+ // if no <DIV> node is found, try with <PRE> (bug 24762)
+ node = bodyElement.firstChild;
+ while (!found && node) {
+ if (node.nodeName == "PRE") {
+ node.innerHTML = EnigmailFuncs.formatPlaintextMsg(m.messageBody);
+ found = true;
+ }
+ node = node.nextSibling;
+ }
+
+ Enigmail.hdrView.setSubject(m.subject);
+ }
+ }
+ },
+
+ /**
+ * Fix broken PGP/MIME messages from MS-Exchange by replacing the broken original
+ * message with a fixed copy.
+ *
+ * no return
+ */
+ async fixBuggyExchangeMail() {
+ EnigmailLog.DEBUG("enigmailMessengerOverlay.js: fixBuggyExchangeMail:\n");
+
+ this.notificationBox.appendNotification(
+ "brokenExchangeProgress",
+ {
+ label: await document.l10n.formatValue("openpgp-broken-exchange-wait"),
+ priority: this.notificationBox.PRIORITY_INFO_HIGH,
+ },
+ null
+ );
+
+ let msg = gMessage;
+ EnigmailFixExchangeMsg.fixExchangeMessage(msg, this.buggyMailType)
+ .then(msgKey => {
+ // Display the new message which now has the key msgKey.
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: fixBuggyExchangeMail: _success: msgKey=" +
+ msgKey +
+ "\n"
+ );
+ // TODO: scope is about:message, and this doesn't work
+ // parent.gDBView.selectMsgByKey(msgKey);
+ // ReloadMessage();
+ })
+ .catch(async function (ex) {
+ console.debug(ex);
+ EnigmailDialog.alert(
+ window,
+ await l10n.formatValue("fix-broken-exchange-msg-failed")
+ );
+ });
+
+ // Remove the brokenExchangeProgress notification at the end of the process.
+ this.removeNotification("brokenExchangeProgress");
+ },
+
+ /**
+ * Hide attachments containing OpenPGP keys
+ */
+ hidePgpKeys() {
+ let keys = [];
+ for (let i = 0; i < currentAttachments.length; i++) {
+ if (
+ currentAttachments[i].contentType.search(/^application\/pgp-keys/i) ===
+ 0
+ ) {
+ keys.push(i);
+ }
+ }
+
+ if (keys.length > 0) {
+ let attachmentList = document.getElementById("attachmentList");
+
+ for (let i = keys.length; i > 0; i--) {
+ currentAttachments.splice(keys[i - 1], 1);
+ }
+
+ if (attachmentList) {
+ // delete all keys from attachment list
+ while (attachmentList.firstChild) {
+ attachmentList.firstChild.remove();
+ }
+
+ // build new attachment list
+
+ /* global gBuildAttachmentsForCurrentMsg: true */
+ let orig = gBuildAttachmentsForCurrentMsg;
+ gBuildAttachmentsForCurrentMsg = false;
+ displayAttachmentsForExpandedView();
+ gBuildAttachmentsForCurrentMsg = orig;
+ }
+ }
+ },
+
+ // check if the attachment could be encrypted
+ checkEncryptedAttach(attachment) {
+ return (
+ EnigmailMsgRead.getAttachmentName(attachment).match(
+ /\.(gpg|pgp|asc)$/i
+ ) ||
+ (attachment.contentType.match(/^application\/pgp(-.*)?$/i) &&
+ attachment.contentType.search(/^application\/pgp-signature/i) < 0)
+ );
+ },
+
+ getDecryptedMessage(contentType, includeHeaders) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: getDecryptedMessage: " +
+ contentType +
+ ", " +
+ includeHeaders +
+ "\n"
+ );
+
+ if (!Enigmail.msg.decryptedMessage) {
+ return "No decrypted message found!\n";
+ }
+
+ var enigmailSvc = Enigmail.getEnigmailSvc();
+ if (!enigmailSvc) {
+ return "";
+ }
+
+ var headerList = Enigmail.msg.decryptedMessage.headerList;
+ var statusLine = Enigmail.msg.securityInfo
+ ? Enigmail.msg.securityInfo.statusLine
+ : "";
+ var contentData = "";
+ var headerName;
+
+ if (contentType == "message/rfc822") {
+ // message/rfc822
+
+ if (includeHeaders) {
+ try {
+ var msg = gMessage;
+ if (msg) {
+ let msgHdr = {
+ From: msg.author,
+ Subject: msg.subject,
+ To: msg.recipients,
+ Cc: msg.ccList,
+ Date: new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeStyle: "short",
+ }).format(new Date(msg.dateInSeconds * 1000)),
+ };
+
+ if (
+ msg?.folder?.flags & Ci.nsMsgFolderFlags.Newsgroup &&
+ currentHeaderData.newsgroups
+ ) {
+ msgHdr.Newsgroups = currentHeaderData.newsgroups.headerValue;
+ }
+
+ for (let headerName in msgHdr) {
+ if (msgHdr[headerName] && msgHdr[headerName].length > 0) {
+ contentData += headerName + ": " + msgHdr[headerName] + "\r\n";
+ }
+ }
+ }
+ } catch (ex) {
+ // the above seems to fail every now and then
+ // so, here is the fallback
+ for (let headerName in headerList) {
+ let headerValue = headerList[headerName];
+ contentData += headerName + ": " + headerValue + "\r\n";
+ }
+ }
+
+ contentData += "Content-Type: text/plain";
+
+ if (Enigmail.msg.decryptedMessage.charset) {
+ contentData += "; charset=" + Enigmail.msg.decryptedMessage.charset;
+ }
+
+ contentData += "\r\n";
+ }
+
+ contentData += "\r\n";
+
+ if (
+ Enigmail.msg.decryptedMessage.hasAttachments &&
+ !Enigmail.msg.decryptedMessage.attachmentsEncrypted
+ ) {
+ contentData += EnigmailData.convertFromUnicode(
+ l10n.formatValueSync("enig-content-note") + "\r\n\r\n",
+ Enigmail.msg.decryptedMessage.charset
+ );
+ }
+
+ contentData += Enigmail.msg.decryptedMessage.plainText;
+ } else {
+ // text/html or text/plain
+
+ if (contentType == "text/html") {
+ contentData +=
+ '<meta http-equiv="Content-Type" content="text/html; charset=' +
+ Enigmail.msg.decryptedMessage.charset +
+ '">\r\n';
+ contentData += "<html><head></head><body>\r\n";
+ }
+
+ if (statusLine) {
+ if (contentType == "text/html") {
+ contentData +=
+ EnigmailMsgRead.escapeTextForHTML(statusLine, false) +
+ "<br>\r\n<hr>\r\n";
+ } else {
+ contentData += statusLine + "\r\n\r\n";
+ }
+ }
+
+ if (includeHeaders) {
+ for (headerName in headerList) {
+ let headerValue = headerList[headerName];
+
+ if (headerValue) {
+ if (contentType == "text/html") {
+ contentData +=
+ "<b>" +
+ EnigmailMsgRead.escapeTextForHTML(headerName, false) +
+ ":</b> " +
+ EnigmailMsgRead.escapeTextForHTML(headerValue, false) +
+ "<br>\r\n";
+ } else {
+ contentData += headerName + ": " + headerValue + "\r\n";
+ }
+ }
+ }
+ }
+
+ if (contentType == "text/html") {
+ contentData +=
+ "<pre>" +
+ EnigmailMsgRead.escapeTextForHTML(
+ Enigmail.msg.decryptedMessage.plainText,
+ false
+ ) +
+ "</pre>\r\n";
+
+ contentData += "</body></html>\r\n";
+ } else {
+ contentData += "\r\n" + Enigmail.msg.decryptedMessage.plainText;
+ }
+
+ if (AppConstants.platform != "win") {
+ contentData = contentData.replace(/\r\n/g, "\n");
+ }
+ }
+
+ return contentData;
+ },
+
+ async msgDirectDecrypt(
+ interactive,
+ importOnly,
+ contentEncoding,
+ charset,
+ signature,
+ bufferSize,
+ head,
+ tail,
+ msgUriSpec,
+ msgDate,
+ callbackFunction,
+ isAuto
+ ) {
+ EnigmailLog.WRITE(
+ "enigmailMessengerOverlay.js: msgDirectDecrypt: contentEncoding=" +
+ contentEncoding +
+ ", signature=" +
+ signature +
+ "\n"
+ );
+ let mailNewsUrl = this.getCurrentMsgUrl();
+ if (!mailNewsUrl) {
+ return;
+ }
+
+ let PromiseStreamListener = function () {
+ this._promise = new Promise((resolve, reject) => {
+ this._resolve = resolve;
+ this._reject = reject;
+ });
+ this._data = null;
+ this._stream = null;
+ };
+
+ PromiseStreamListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onStartRequest(request) {
+ this.data = "";
+ this.inStream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ },
+
+ onStopRequest(request, statusCode) {
+ if (statusCode != Cr.NS_OK) {
+ this._reject(`Streaming failed: ${statusCode}`);
+ return;
+ }
+
+ let start = this.data.indexOf("-----BEGIN PGP");
+ let end = this.data.indexOf("-----END PGP");
+
+ if (start >= 0 && end > start) {
+ let tStr = this.data.substr(end);
+ let n = tStr.indexOf("\n");
+ let r = tStr.indexOf("\r");
+ let lEnd = -1;
+ if (n >= 0 && r >= 0) {
+ lEnd = Math.min(r, n);
+ } else if (r >= 0) {
+ lEnd = r;
+ } else if (n >= 0) {
+ lEnd = n;
+ }
+
+ if (lEnd >= 0) {
+ end += lEnd;
+ }
+
+ let data = Enigmail.msg.trimIfEncrypted(
+ this.data.substring(start, end + 1)
+ );
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: data: >" + data.substr(0, 100) + "<\n"
+ );
+
+ let currentMsgURL = Enigmail.msg.getCurrentMsgUrl();
+ let urlSpec = currentMsgURL ? currentMsgURL.spec : "";
+
+ let l = urlSpec.length;
+ if (urlSpec.substr(0, l) != mailNewsUrl.spec.substr(0, l)) {
+ EnigmailLog.ERROR(
+ "enigmailMessengerOverlay.js: Message URL mismatch " +
+ currentMsgURL +
+ " vs. " +
+ urlSpec +
+ "\n"
+ );
+ this._reject(`Msg url mismatch: ${currentMsgURL} vs ${urlSpec}`);
+ return;
+ }
+
+ Enigmail.msg
+ .messageParseCallback(
+ data,
+ msgDate,
+ contentEncoding,
+ charset,
+ interactive,
+ importOnly,
+ mailNewsUrl.spec,
+ signature,
+ 3,
+ head,
+ tail,
+ msgUriSpec,
+ isAuto
+ )
+ .then(() => this._resolve(this.data));
+ }
+ },
+
+ onDataAvailable(request, stream, off, count) {
+ this.inStream.init(stream);
+ this.data += this.inStream.read(count);
+ },
+
+ get promise() {
+ return this._promise;
+ },
+ };
+
+ let streamListener = new PromiseStreamListener();
+ let msgSvc = MailServices.messageServiceFromURI(msgUriSpec);
+ msgSvc.streamMessage(
+ msgUriSpec,
+ streamListener,
+ top.msgWindow,
+ null,
+ false,
+ null,
+ false
+ );
+ await streamListener;
+ },
+
+ revealAttachments(index) {
+ if (!index) {
+ index = 0;
+ }
+
+ if (index < currentAttachments.length) {
+ this.handleAttachment(
+ "revealName/" + index.toString(),
+ currentAttachments[index]
+ );
+ }
+ },
+
+ /**
+ * Set up some event handlers for the attachment items in #attachmentList.
+ */
+ handleAttachmentEvent() {
+ let attList = document.getElementById("attachmentList");
+
+ for (let att of attList.itemChildren) {
+ att.addEventListener("click", this.attachmentItemClick.bind(this), true);
+ }
+ },
+
+ // handle a selected attachment (decrypt & open or save)
+ handleAttachmentSel(actionType) {
+ let contextMenu = document.getElementById("attachmentItemContext");
+ let anAttachment = contextMenu.attachments[0];
+
+ switch (actionType) {
+ case "saveAttachment":
+ case "openAttachment":
+ case "importKey":
+ case "revealName":
+ this.handleAttachment(actionType, anAttachment);
+ break;
+ case "verifySig":
+ this.verifyDetachedSignature(anAttachment);
+ break;
+ }
+ },
+
+ /**
+ * save the original file plus the signature file to disk and then verify the signature
+ */
+ async verifyDetachedSignature(anAttachment) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: verifyDetachedSignature: url=" +
+ anAttachment.url +
+ "\n"
+ );
+
+ var enigmailSvc = Enigmail.getEnigmailSvc();
+ if (!enigmailSvc) {
+ return;
+ }
+
+ var origAtt, signatureAtt;
+ var isEncrypted = false;
+
+ if (
+ EnigmailMsgRead.getAttachmentName(anAttachment).search(/\.sig$/i) > 0 ||
+ anAttachment.contentType.search(/^application\/pgp-signature/i) === 0
+ ) {
+ // we have the .sig file; need to know the original file;
+
+ signatureAtt = anAttachment;
+ var origName = EnigmailMsgRead.getAttachmentName(anAttachment).replace(
+ /\.sig$/i,
+ ""
+ );
+
+ for (let i = 0; i < currentAttachments.length; i++) {
+ if (
+ origName == EnigmailMsgRead.getAttachmentName(currentAttachments[i])
+ ) {
+ origAtt = currentAttachments[i];
+ break;
+ }
+ }
+
+ if (!origAtt) {
+ for (let i = 0; i < currentAttachments.length; i++) {
+ if (
+ origName ==
+ EnigmailMsgRead.getAttachmentName(currentAttachments[i]).replace(
+ /\.pgp$/i,
+ ""
+ )
+ ) {
+ isEncrypted = true;
+ origAtt = currentAttachments[i];
+ break;
+ }
+ }
+ }
+ } else {
+ // we have a supposedly original file; need to know the .sig file;
+
+ origAtt = anAttachment;
+ var attachName = EnigmailMsgRead.getAttachmentName(anAttachment);
+ var sigName = attachName + ".sig";
+
+ for (let i = 0; i < currentAttachments.length; i++) {
+ if (
+ sigName == EnigmailMsgRead.getAttachmentName(currentAttachments[i])
+ ) {
+ signatureAtt = currentAttachments[i];
+ break;
+ }
+ }
+
+ if (!signatureAtt && attachName.search(/\.pgp$/i) > 0) {
+ sigName = attachName.replace(/\.pgp$/i, ".sig");
+ for (let i = 0; i < currentAttachments.length; i++) {
+ if (
+ sigName == EnigmailMsgRead.getAttachmentName(currentAttachments[i])
+ ) {
+ isEncrypted = true;
+ signatureAtt = currentAttachments[i];
+ break;
+ }
+ }
+ }
+ }
+
+ if (!signatureAtt) {
+ EnigmailDialog.alert(
+ window,
+ l10n.formatValueSync("attachment-no-match-to-signature", {
+ attachment: EnigmailMsgRead.getAttachmentName(origAtt),
+ })
+ );
+ return;
+ }
+ if (!origAtt) {
+ EnigmailDialog.alert(
+ window,
+ l10n.formatValueSync("attachment-no-match-from-signature", {
+ attachment: EnigmailMsgRead.getAttachmentName(signatureAtt),
+ })
+ );
+ return;
+ }
+
+ // open
+ var outFile1 = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ outFile1.append(EnigmailMsgRead.getAttachmentName(origAtt));
+ outFile1.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+
+ let response = await fetch(origAtt.url);
+ if (!response.ok) {
+ throw new Error(`Bad response for url=${origAtt.url}`);
+ }
+ await IOUtils.writeUTF8(outFile1.path, await response.text());
+
+ if (isEncrypted) {
+ // Try to decrypt message if we suspect the message is encrypted.
+ // If it fails we will just verify the encrypted data.
+ let readBinaryFile = async () => {
+ let data = await IOUtils.read(outFile1.path);
+ return MailStringUtils.uint8ArrayToByteString(data);
+ };
+ await EnigmailDecryption.decryptAttachment(
+ window,
+ outFile1,
+ EnigmailMsgRead.getAttachmentName(origAtt),
+ readBinaryFile,
+ {},
+ {},
+ {}
+ );
+ }
+
+ var outFile2 = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ outFile2.append(EnigmailMsgRead.getAttachmentName(signatureAtt));
+ outFile2.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+
+ let response2 = await fetch(signatureAtt.url);
+ if (!response2.ok) {
+ throw new Error(`Bad response for url=${signatureAtt.url}`);
+ }
+ await IOUtils.writeUTF8(outFile2.path, await response2.text());
+
+ let cApi = EnigmailCryptoAPI();
+ let promise = cApi.verifyAttachment(outFile1.path, outFile2.path);
+ promise.then(async function (message) {
+ EnigmailDialog.info(
+ window,
+ l10n.formatValueSync("signature-verified-ok", {
+ attachment: EnigmailMsgRead.getAttachmentName(origAtt),
+ }) +
+ "\n\n" +
+ message
+ );
+ });
+ promise.catch(async function (err) {
+ EnigmailDialog.alert(
+ window,
+ l10n.formatValueSync("signature-verify-failed", {
+ attachment: EnigmailMsgRead.getAttachmentName(origAtt),
+ }) +
+ "\n\n" +
+ err
+ );
+ });
+
+ outFile1.remove(false);
+ outFile2.remove(false);
+ },
+
+ handleAttachment(actionType, attachment) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: handleAttachment: actionType=" +
+ actionType +
+ ", attachment(url)=" +
+ attachment.url +
+ "\n"
+ );
+
+ let bufferListener = EnigmailStreams.newStringStreamListener(async data => {
+ Enigmail.msg.decryptAttachmentCallback([
+ {
+ actionType,
+ attachment,
+ forceBrowser: false,
+ data,
+ },
+ ]);
+ });
+ let msgUri = Services.io.newURI(attachment.url);
+ let channel = EnigmailStreams.createChannel(msgUri);
+ channel.asyncOpen(bufferListener, msgUri);
+ },
+
+ setAttachmentName(attachment, newLabel, index) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: setAttachmentName (" + newLabel + "):\n"
+ );
+
+ var attList = document.getElementById("attachmentList");
+ if (attList) {
+ var attNode = attList.firstChild;
+ while (attNode) {
+ if (attNode.getAttribute("name") == attachment.name) {
+ attNode.setAttribute("name", newLabel);
+ }
+ attNode = attNode.nextSibling;
+ }
+ }
+
+ if (typeof attachment.displayName == "undefined") {
+ attachment.name = newLabel;
+ } else {
+ attachment.displayName = newLabel;
+ }
+
+ if (index && index.length > 0) {
+ this.revealAttachments(parseInt(index, 10) + 1);
+ }
+ },
+
+ async decryptAttachmentCallback(cbArray) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: decryptAttachmentCallback:\n"
+ );
+
+ var callbackArg = cbArray[0];
+
+ var exitCodeObj = {};
+ var statusFlagsObj = {};
+ var errorMsgObj = {};
+ var exitStatus = -1;
+
+ var outFile;
+ var origFilename;
+ var rawFileName = EnigmailMsgRead.getAttachmentName(
+ callbackArg.attachment
+ ).replace(/\.(asc|pgp|gpg)$/i, "");
+
+ // TODO: We don't have code yet to extract the original filename
+ // from an encrypted data block.
+ /*
+ if (callbackArg.actionType != "importKey") {
+ let cApi = EnigmailCryptoAPI();
+ let origFilename = await cApi.getFileName(window, callbackArg.data);
+ if (origFilename && origFilename.length > rawFileName.length) {
+ rawFileName = origFilename;
+ }
+ }
+ */
+
+ if (callbackArg.actionType == "saveAttachment") {
+ outFile = EnigmailDialog.filePicker(
+ window,
+ l10n.formatValueSync("save-attachment-header"),
+ Enigmail.msg.lastSaveDir,
+ true,
+ false,
+ "",
+ rawFileName,
+ null
+ );
+ if (!outFile) {
+ return;
+ }
+ } else if (callbackArg.actionType.substr(0, 10) == "revealName") {
+ if (origFilename && origFilename.length > 0) {
+ Enigmail.msg.setAttachmentName(
+ callbackArg.attachment,
+ origFilename + ".pgp",
+ callbackArg.actionType.substr(11, 10)
+ );
+ }
+ Enigmail.msg.setAttachmentReveal(null);
+ return;
+ } else {
+ // open
+ outFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ outFile.append(rawFileName);
+ outFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+ }
+
+ if (callbackArg.actionType == "importKey") {
+ var preview = await EnigmailKey.getKeyListFromKeyBlock(
+ callbackArg.data,
+ errorMsgObj,
+ true,
+ true,
+ false
+ );
+
+ if (errorMsgObj.value !== "" || !preview || preview.length === 0) {
+ // try decrypting the attachment
+ exitStatus = await EnigmailDecryption.decryptAttachment(
+ window,
+ outFile,
+ EnigmailMsgRead.getAttachmentName(callbackArg.attachment),
+ callbackArg.data,
+ exitCodeObj,
+ statusFlagsObj,
+ errorMsgObj
+ );
+ if (exitStatus && exitCodeObj.value === 0) {
+ // success decrypting, let's try again
+ callbackArg.data = String.fromCharCode(
+ ...(await IOUtils.read(outFile.path))
+ );
+ preview = await EnigmailKey.getKeyListFromKeyBlock(
+ callbackArg.data,
+ errorMsgObj,
+ true,
+ true,
+ false
+ );
+ }
+ }
+
+ if (preview && errorMsgObj.value === "") {
+ EnigmailKeyRing.importKeyDataWithConfirmation(
+ window,
+ preview,
+ callbackArg.data,
+ false
+ );
+ } else {
+ document.l10n.formatValue("preview-failed").then(value => {
+ EnigmailDialog.alert(window, value + "\n" + errorMsgObj.value);
+ });
+ }
+ outFile.remove(true);
+ return;
+ }
+
+ exitStatus = await EnigmailDecryption.decryptAttachment(
+ window,
+ outFile,
+ EnigmailMsgRead.getAttachmentName(callbackArg.attachment),
+ callbackArg.data,
+ exitCodeObj,
+ statusFlagsObj,
+ errorMsgObj
+ );
+
+ if (!exitStatus || exitCodeObj.value !== 0) {
+ exitStatus = false;
+ if (
+ statusFlagsObj.value & EnigmailConstants.DECRYPTION_OKAY &&
+ statusFlagsObj.value & EnigmailConstants.UNCERTAIN_SIGNATURE
+ ) {
+ if (callbackArg.actionType == "openAttachment") {
+ let [title, button] = await document.l10n.formatValues([
+ { id: "decrypt-ok-no-sig" },
+ { id: "msg-ovl-button-cont-anyway" },
+ ]);
+
+ exitStatus = EnigmailDialog.confirmDlg(window, title, button);
+ } else {
+ EnigmailDialog.info(
+ window,
+ await document.l10n.formatValue("decrypt-ok-no-sig")
+ );
+ }
+ } else {
+ let msg = await document.l10n.formatValue("failed-decrypt");
+ if (errorMsgObj.errorMsg) {
+ msg += "\n\n" + errorMsgObj.errorMsg;
+ }
+ EnigmailDialog.info(window, msg);
+ exitStatus = false;
+ }
+ }
+ if (exitStatus) {
+ if (statusFlagsObj.value & EnigmailConstants.IMPORTED_KEY) {
+ if (exitCodeObj.keyList) {
+ let importKeyList = exitCodeObj.keyList.map(function (a) {
+ return a.id;
+ });
+ EnigmailDialog.keyImportDlg(window, importKeyList);
+ }
+ } else if (statusFlagsObj.value & EnigmailConstants.DISPLAY_MESSAGE) {
+ HandleSelectedAttachments("open");
+ } else if (
+ statusFlagsObj.value & EnigmailConstants.DISPLAY_MESSAGE ||
+ callbackArg.actionType == "openAttachment"
+ ) {
+ var ioServ = Services.io;
+ var outFileUri = ioServ.newFileURI(outFile);
+ var fileExt = outFile.leafName.replace(/(.*\.)(\w+)$/, "$2");
+ if (fileExt && !callbackArg.forceBrowser) {
+ var extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(outFile);
+
+ try {
+ var mimeService = Cc["@mozilla.org/mime;1"].getService(
+ Ci.nsIMIMEService
+ );
+ var fileMimeType = mimeService.getTypeFromFile(outFile);
+ var fileMimeInfo = mimeService.getFromTypeAndExtension(
+ fileMimeType,
+ fileExt
+ );
+
+ fileMimeInfo.launchWithFile(outFile);
+ } catch (ex) {
+ // if the attachment file type is unknown, an exception is thrown,
+ // so let it be handled by a browser window
+ Enigmail.msg.loadExternalURL(outFileUri.asciiSpec);
+ }
+ } else {
+ // open the attachment using an external application
+ Enigmail.msg.loadExternalURL(outFileUri.asciiSpec);
+ }
+ }
+ }
+ },
+
+ loadExternalURL(url) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(url));
+ },
+
+ // retrieves the most recent navigator window (opens one if need be)
+ loadURLInNavigatorWindow(url, aOpenFlag) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: loadURLInNavigatorWindow: " +
+ url +
+ ", " +
+ aOpenFlag +
+ "\n"
+ );
+
+ var navWindow;
+
+ // if this is a browser window, just use it
+ if ("document" in top) {
+ var possibleNavigator = top.document.getElementById("main-window");
+ if (
+ possibleNavigator &&
+ possibleNavigator.getAttribute("windowtype") == "navigator:browser"
+ ) {
+ navWindow = top;
+ }
+ }
+
+ // if not, get the most recently used browser window
+ if (!navWindow) {
+ var wm = Services.wm;
+ navWindow = wm.getMostRecentWindow("navigator:browser");
+ }
+
+ if (navWindow) {
+ if ("fixupAndLoadURIString" in navWindow) {
+ navWindow.fixupAndLoadURIString(url);
+ } else {
+ navWindow._content.location.href = url;
+ }
+ } else if (aOpenFlag) {
+ // if no browser window available and it's ok to open a new one, do so
+ navWindow = window.open(url, "Enigmail");
+ }
+
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: loadURLInNavigatorWindow: navWindow=" +
+ navWindow +
+ "\n"
+ );
+
+ return navWindow;
+ },
+
+ /**
+ * Open an encrypted attachment item.
+ */
+ attachmentItemClick(event) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: attachmentItemClick: event=" + event + "\n"
+ );
+
+ let attachment = event.currentTarget.attachment;
+ if (this.checkEncryptedAttach(attachment)) {
+ if (event.button === 0 && event.detail == 2) {
+ // double click
+ this.handleAttachment("openAttachment", attachment);
+ event.stopPropagation();
+ }
+ }
+ },
+
+ // decrypted and copy/move all selected messages in a target folder
+
+ async decryptToFolder(destFolder, move) {
+ let msgHdrs = gDBView.getSelectedMsgHdrs();
+ if (!msgHdrs || msgHdrs.length === 0) {
+ return;
+ }
+
+ let total = msgHdrs.length;
+ let failures = 0;
+ for (let msgHdr of msgHdrs) {
+ await EnigmailPersistentCrypto.cryptMessage(
+ msgHdr,
+ destFolder.URI,
+ move,
+ false
+ ).catch(err => {
+ failures++;
+ });
+ }
+
+ if (failures) {
+ let info = await document.l10n.formatValue(
+ "decrypt-and-copy-failures-multiple",
+ {
+ failures,
+ total,
+ }
+ );
+ Services.prompt.alert(null, document.title, info);
+ }
+ },
+
+ async searchKeysOnInternet(event) {
+ return KeyLookupHelper.lookupAndImportByEmail(
+ "interactive-import",
+ window,
+ event.currentTarget.parentNode.headerField?.emailAddress,
+ true
+ );
+ },
+
+ onUnloadEnigmail() {
+ window.removeEventListener("unload", Enigmail.msg.messengerClose);
+ window.removeEventListener(
+ "unload-enigmail",
+ Enigmail.msg.onUnloadEnigmail
+ );
+ window.removeEventListener("load-enigmail", Enigmail.msg.messengerStartup);
+
+ this.messageCleanup();
+
+ if (this.messagePane) {
+ this.messagePane.removeEventListener(
+ "unload",
+ Enigmail.msg.messageFrameUnload,
+ true
+ );
+ }
+
+ for (let c of this.changedAttributes) {
+ let elem = document.getElementById(c.id);
+ if (elem) {
+ elem.setAttribute(c.attrib, c.value);
+ }
+ }
+
+ this.messengerClose();
+
+ if (Enigmail.columnHandler) {
+ Enigmail.columnHandler.onUnloadEnigmail();
+ }
+ if (Enigmail.hdrView) {
+ Enigmail.hdrView.onUnloadEnigmail();
+ }
+
+ // eslint-disable-next-line no-global-assign
+ Enigmail = undefined;
+ },
+
+ /**
+ * Process key data from a message.
+ *
+ * @param {string} keyData - The key data.
+ * @param {boolean} isBinaryAutocrypt - false if ASCII armored data.
+ * @param {string} [description] - Key source description, if any.
+ */
+ async commonProcessAttachedKey(keyData, isBinaryAutocrypt, description) {
+ if (!keyData) {
+ return;
+ }
+
+ // Processing is slow for some types of keys.
+ // We want to avoid automatic key import/updates for users who
+ // have OpenPGP disabled (no account has an OpenPGP key configured).
+ if (
+ !MailServices.accounts.allIdentities.find(id =>
+ id.getUnicharAttribute("openpgp_key_id")
+ )
+ ) {
+ return;
+ }
+
+ let errorMsgObj = {};
+ let preview = await EnigmailKey.getKeyListFromKeyBlock(
+ keyData,
+ errorMsgObj,
+ true,
+ true,
+ false,
+ true
+ );
+
+ // If we cannot analyze the keyblock, or if it's empty, or if we
+ // got an error message, then the key is bad and shouldn't be used.
+ if (!preview || !preview.length || errorMsgObj.value) {
+ return;
+ }
+
+ this.fetchParticipants();
+
+ for (let newKey of preview) {
+ let oldKey = EnigmailKeyRing.getKeyById(newKey.fpr);
+ if (!oldKey) {
+ // If the key is unknown, an expired key cannot help us
+ // for anything new, so don't use it.
+ if (newKey.keyTrust == "e") {
+ continue;
+ }
+
+ // Potentially merge the revocation into CollectedKeysDB, it if
+ // already has that key.
+ if (newKey.keyTrust == "r") {
+ let db = await CollectedKeysDB.getInstance();
+ let existing = await db.findKeyForFingerprint(newKey.fpr);
+ if (existing) {
+ let key = await db.mergeExisting(newKey, newKey.pubKey, {
+ uri: `mid:${gMessage.messageId}`,
+ type: isBinaryAutocrypt ? "autocrypt" : "attachment",
+ description,
+ });
+ await db.storeKey(key);
+ Services.obs.notifyObservers(null, "openpgp-key-change");
+ }
+ continue;
+ }
+
+ // It doesn't make sense to import a public key,
+ // if we have a secret key for that email address.
+ // Because, if we are the owner of that email address, why would
+ // we need a public key referring to our own email address,
+ // sent to us by someone else?
+
+ let keyInOurName = false;
+ for (let userId of newKey.userIds) {
+ if (userId.type !== "uid") {
+ continue;
+ }
+ if (EnigmailTrust.isInvalid(userId.keyTrust)) {
+ continue;
+ }
+ if (
+ await EnigmailKeyRing.hasSecretKeyForEmail(
+ EnigmailFuncs.getEmailFromUserID(userId.userId).toLowerCase()
+ )
+ ) {
+ keyInOurName = true;
+ break;
+ }
+ }
+ if (keyInOurName) {
+ continue;
+ }
+
+ // Only advertise the key for import if it contains a user ID
+ // that points to the email author email address.
+ let relatedParticipantEmailAddress = null;
+ if (this.hasUserIdForEmail(newKey.userIds, this.authorEmail)) {
+ relatedParticipantEmailAddress = this.authorEmail;
+ }
+
+ if (relatedParticipantEmailAddress) {
+ // If it's a non expired, non revoked new key, in the email
+ // author's name (email address match), then offer it for
+ // manual (immediate) import.
+ let nextIndex = Enigmail.msg.attachedKeys.length;
+ let info = {
+ fpr: "0x" + newKey.fpr,
+ idx: nextIndex,
+ keyInfo: newKey,
+ binary: isBinaryAutocrypt,
+ };
+ Enigmail.msg.attachedSenderEmailKeysIndex.push(info);
+ Enigmail.msg.attachedKeys.push(newKey.pubKey);
+ }
+
+ // We want to collect keys for potential later use, however,
+ // we also want to avoid that an attacker can send us a large
+ // number of keys to poison our cache, so we only collect keys
+ // that are related to the author or one of the recipients.
+ // Also, we don't want a public key, if we already have a
+ // secret key for that email address.
+
+ if (!relatedParticipantEmailAddress) {
+ // Not related to the author
+ for (let toOrCc of this.toAndCCSet) {
+ if (this.hasUserIdForEmail(newKey.userIds, toOrCc)) {
+ // Might be ok to import, so remember to which email
+ // the key is related and leave the loop.
+ relatedParticipantEmailAddress = toOrCc;
+ break;
+ }
+ }
+ }
+
+ if (relatedParticipantEmailAddress) {
+ // It seems OK to import, however, don't import yet.
+ // Wait until after we have processed all attachments to
+ // the current message. Because we don't want to import
+ // multiple keys for the same email address, that wouldn't
+ // make sense. Remember the import candidate, and postpone
+ // until we are done looking at all attachments.
+
+ if (this.keyCollectCandidates.has(relatedParticipantEmailAddress)) {
+ // The email contains more than one public key for this
+ // email address.
+ this.keyCollectCandidates.set(relatedParticipantEmailAddress, {
+ skip: true,
+ });
+ } else {
+ let candidate = {};
+ candidate.skip = false;
+ candidate.newKeyObj = newKey;
+ candidate.pubKey = newKey.pubKey;
+ candidate.source = {
+ uri: `mid:${gMessage.messageId}`,
+ type: isBinaryAutocrypt ? "autocrypt" : "attachment",
+ description,
+ };
+ this.keyCollectCandidates.set(
+ relatedParticipantEmailAddress,
+ candidate
+ );
+ }
+ }
+
+ // done with processing for new keys (!oldKey)
+ continue;
+ }
+
+ // The key is known (we have an oldKey), then it makes sense to
+ // import, even if it's expired/revoked, to learn about the
+ // changed validity.
+
+ // Also, we auto import/merge such keys, even if the sender
+ // doesn't match any key user ID. Why is this useful?
+ // If I am Alice, and the email is from Bob, the email could have
+ // Charlie's revoked or extended key attached. It's useful for
+ // me to learn that.
+
+ // User IDs are another reason. The key might contain a new
+ // additional user ID, or a revoked user ID.
+ // That's relevant for Autocrypt headers, which only have one user
+ // ID. If we had imported the key with just one user ID in the
+ // past, and now we're being sent the same key for a different
+ // user ID, we must not skip it, even if it the validity is the
+ // same.
+ // Let's update on all possible changes of the user ID list,
+ // additions, removals, differences.
+
+ let shouldUpdate = false;
+
+ // new validity?
+ if (
+ oldKey.expiryTime < newKey.expiryTime ||
+ (oldKey.keyTrust != "r" && newKey.keyTrust == "r")
+ ) {
+ shouldUpdate = true;
+ } else if (
+ oldKey.userIds.length != newKey.userIds.length ||
+ !oldKey.userIds.every((el, ix) => el === newKey.userIds[ix])
+ ) {
+ shouldUpdate = true;
+ }
+
+ if (!shouldUpdate) {
+ continue;
+ }
+
+ if (
+ !(await EnigmailKeyRing.importKeyDataSilent(
+ window,
+ newKey.pubKey,
+ isBinaryAutocrypt,
+ "0x" + newKey.fpr
+ ))
+ ) {
+ console.debug(
+ "EnigmailKeyRing.importKeyDataSilent failed 0x" + newKey.fpr
+ );
+ }
+ }
+ },
+
+ /**
+ * Show the import key notification.
+ */
+ async unhideImportKeyBox() {
+ Enigmail.hdrView.notifyHasKeyAttached();
+ document.getElementById("openpgpKeyBox").removeAttribute("hidden");
+
+ // Check if the proposed key to import was previously accepted.
+ let hasAreadyAcceptedOther =
+ await PgpSqliteDb2.hasAnyPositivelyAcceptedKeyForEmail(
+ Enigmail.msg.authorEmail
+ );
+ if (hasAreadyAcceptedOther) {
+ Enigmail.msg.notificationBox.appendNotification(
+ "hasConflictingKeyOpenPGP",
+ {
+ label: await document.l10n.formatValue("openpgp-be-careful-new-key", {
+ email: Enigmail.msg.authorEmail,
+ }),
+ priority: Enigmail.msg.notificationBox.PRIORITY_INFO_HIGH,
+ },
+ null
+ );
+ }
+ },
+
+ /*
+ * This function is called from several places. Any call may trigger
+ * the final processing for this message, it depends on the amount
+ * of attachments present, and whether we decrypt immediately, or
+ * after a delay (for inline encryption).
+ */
+ async processAfterAttachmentsAndDecrypt() {
+ // Return early if message processing isn't ready yet.
+ if (!Enigmail.msg.allAttachmentsDone || !Enigmail.msg.messageDecryptDone) {
+ return;
+ }
+
+ // Return early if we haven't yet processed all attachments.
+ if (
+ Enigmail.msg.autoProcessPgpKeyAttachmentProcessed <
+ Enigmail.msg.autoProcessPgpKeyAttachmentCount
+ ) {
+ return;
+ }
+
+ if (Enigmail.msg.unhideMissingSigKeyBoxIsTODO) {
+ Enigmail.msg.unhideMissingSigKeyBox();
+ }
+
+ // We have already processed all attached pgp-keys, we're ready
+ // to make final decisions on how to notify the user about
+ // available or missing keys.
+ // If we already found a good key for the sender's email
+ // in attachments, then don't look at the autocrypt header.
+ if (Enigmail.msg.attachedSenderEmailKeysIndex.length) {
+ this.unhideImportKeyBox();
+ } else if (
+ Enigmail.msg.savedHeaders &&
+ "autocrypt" in Enigmail.msg.savedHeaders &&
+ Enigmail.msg.savedHeaders.autocrypt.length > 0 &&
+ "from" in currentHeaderData
+ ) {
+ let fromAddr = EnigmailFuncs.stripEmail(
+ currentHeaderData.from.headerValue
+ ).toLowerCase();
+ // There might be multiple headers, we only want the one
+ // matching the sender's address.
+ for (let ac of Enigmail.msg.savedHeaders.autocrypt) {
+ let acAddr = MimeParser.getParameter(ac, "addr");
+ if (fromAddr == acAddr) {
+ let senderAutocryptKey;
+ try {
+ senderAutocryptKey = atob(
+ MimeParser.getParameter(ac.replace(/ /g, ""), "keydata")
+ );
+ } catch {}
+ if (senderAutocryptKey) {
+ // Make sure to let the message load before doing potentially *very*
+ // time consuming auto processing (seconds!?).
+ await new Promise(resolve => ChromeUtils.idleDispatch(resolve));
+ await this.commonProcessAttachedKey(senderAutocryptKey, true);
+
+ if (Enigmail.msg.attachedSenderEmailKeysIndex.length) {
+ this.unhideImportKeyBox();
+ }
+ }
+ }
+ }
+ }
+
+ for (let gossipKey of EnigmailSingletons.lastDecryptedMessage.gossip) {
+ await this.commonProcessAttachedKey(gossipKey, true);
+ }
+
+ if (this.keyCollectCandidates && this.keyCollectCandidates.size) {
+ let db = await CollectedKeysDB.getInstance();
+
+ for (let candidate of this.keyCollectCandidates.values()) {
+ if (candidate.skip) {
+ continue;
+ }
+ // If key is known in the db: merge + update.
+ let key = await db.mergeExisting(
+ candidate.newKeyObj,
+ candidate.pubKey,
+ candidate.source
+ );
+
+ await db.storeKey(key);
+ Services.obs.notifyObservers(null, "openpgp-key-change");
+ }
+ }
+
+ // Should we notify the user about available encrypted nested parts,
+ // which have not been automatically decrypted?
+ if (
+ EnigmailSingletons.isRecentUriWithNestedEncryptedPart(
+ Enigmail.msg.getCurrentMsgUriSpec()
+ )
+ ) {
+ let buttons = [
+ {
+ "l10n-id": "openpgp-show-encrypted-parts",
+ popup: null,
+ callback(notification, button) {
+ top.viewEncryptedPart(Enigmail.msg.getCurrentMsgUriSpec());
+ return true; // keep notification
+ },
+ },
+ ];
+
+ Enigmail.msg.notificationBox.appendNotification(
+ "hasNestedEncryptedParts",
+ {
+ label: await document.l10n.formatValue(
+ "openpgp-has-nested-encrypted-parts"
+ ),
+ priority: Enigmail.msg.notificationBox.PRIORITY_INFO_HIGH,
+ },
+ buttons
+ );
+ }
+
+ document.dispatchEvent(
+ new CustomEvent("openpgpprocessed", {
+ detail: { messageDecryptDone: true },
+ })
+ );
+ },
+
+ async notifyEndAllAttachments() {
+ Enigmail.msg.allAttachmentsDone = true;
+
+ if (!Enigmail.msg.autoProcessPgpKeyAttachmentCount) {
+ await Enigmail.msg.processAfterAttachmentsAndDecrypt();
+ }
+ },
+
+ toAndCCSet: null,
+ authorEmail: "",
+
+ // Used to remember the list of keys that we might want to add to
+ // our cache of seen keys. Will be used after we are done looking
+ // at all attachments.
+ keyCollectCandidates: new Map(),
+
+ attachedKeys: [],
+ attachedSenderEmailKeysIndex: [], // each: {idx (to-attachedKeys), keyInfo, binary}
+
+ fetchParticipants() {
+ if (this.toAndCCSet) {
+ return;
+ }
+
+ // toAndCCSet non-null indicates that we already fetched.
+ this.toAndCCSet = new Set();
+
+ // This message may have already disappeared.
+ if (!gMessage) {
+ return;
+ }
+
+ let addresses = MailServices.headerParser.parseEncodedHeader(
+ gMessage.author
+ );
+ if (addresses.length) {
+ this.authorEmail = addresses[0].email.toLowerCase();
+ }
+
+ addresses = MailServices.headerParser.parseEncodedHeader(
+ gMessage.recipients + "," + gMessage.ccList
+ );
+ for (let addr of addresses) {
+ this.toAndCCSet.add(addr.email.toLowerCase());
+ }
+ },
+
+ hasUserIdForEmail(userIds, authorEmail) {
+ authorEmail = authorEmail.toLowerCase();
+
+ for (let id of userIds) {
+ if (id.type !== "uid") {
+ continue;
+ }
+
+ if (
+ EnigmailFuncs.getEmailFromUserID(id.userId).toLowerCase() == authorEmail
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ autoProcessPgpKeyAttachmentTransactionID: 0,
+ autoProcessPgpKeyAttachmentCount: 0,
+ autoProcessPgpKeyAttachmentProcessed: 0,
+ unhideMissingSigKeyBoxIsTODO: false,
+ unhideMissingSigKey: null,
+
+ autoProcessPgpKeyAttachment(attachment) {
+ if (
+ attachment.contentType != "application/pgp-keys" &&
+ !attachment.name.endsWith(".asc")
+ ) {
+ return;
+ }
+
+ Enigmail.msg.autoProcessPgpKeyAttachmentCount++;
+
+ let bufferListener = EnigmailStreams.newStringStreamListener(async data => {
+ // Make sure to let the message load before doing potentially *very*
+ // time consuming auto processing (seconds!?).
+ await new Promise(resolve => ChromeUtils.idleDispatch(resolve));
+ await this.commonProcessAttachedKey(data, false, attachment.name);
+ Enigmail.msg.autoProcessPgpKeyAttachmentProcessed++;
+ if (
+ Enigmail.msg.autoProcessPgpKeyAttachmentProcessed ==
+ Enigmail.msg.autoProcessPgpKeyAttachmentCount
+ ) {
+ await Enigmail.msg.processAfterAttachmentsAndDecrypt();
+ }
+ });
+ let msgUri = Services.io.newURI(attachment.url);
+ let channel = EnigmailStreams.createChannel(msgUri);
+ channel.asyncOpen(bufferListener, msgUri);
+ },
+
+ /**
+ * Populate the message security popup panel with OpenPGP data.
+ */
+ async loadOpenPgpMessageSecurityInfo() {
+ let sigInfoWithDateLabel = null;
+ let sigInfoLabel = null;
+ let sigInfo = null;
+ let sigClass = null;
+ let wantToShowDate = false;
+
+ // All scenarios that set wantToShowDate to true should set both
+ // sigInfoWithDateLabel and sigInfoLabel, to ensure we have a
+ // fallback label, if the date is unavailable.
+ switch (Enigmail.hdrView.msgSignatureState) {
+ case EnigmailConstants.MSG_SIG_NONE:
+ sigInfoLabel = "openpgp-no-sig";
+ sigClass = "none";
+ sigInfo = "openpgp-no-sig-info";
+ break;
+
+ case EnigmailConstants.MSG_SIG_UNCERTAIN_KEY_UNAVAILABLE:
+ sigInfoLabel = "openpgp-uncertain-sig";
+ sigClass = "unknown";
+ sigInfo = "openpgp-sig-uncertain-no-key";
+ break;
+
+ case EnigmailConstants.MSG_SIG_UNCERTAIN_UID_MISMATCH:
+ sigInfoLabel = "openpgp-uncertain-sig";
+ sigInfoWithDateLabel = "openpgp-uncertain-sig-with-date";
+ wantToShowDate = true;
+ sigClass = "mismatch";
+ sigInfo = "openpgp-sig-uncertain-uid-mismatch";
+ break;
+
+ case EnigmailConstants.MSG_SIG_UNCERTAIN_KEY_NOT_ACCEPTED:
+ sigInfoLabel = "openpgp-uncertain-sig";
+ sigInfoWithDateLabel = "openpgp-uncertain-sig-with-date";
+ wantToShowDate = true;
+ sigClass = "unknown";
+ sigInfo = "openpgp-sig-uncertain-not-accepted";
+ break;
+
+ case EnigmailConstants.MSG_SIG_INVALID_KEY_REJECTED:
+ sigInfoLabel = "openpgp-invalid-sig";
+ sigInfoWithDateLabel = "openpgp-invalid-sig-with-date";
+ wantToShowDate = true;
+ sigClass = "mismatch";
+ sigInfo = "openpgp-sig-invalid-rejected";
+ break;
+
+ case EnigmailConstants.MSG_SIG_INVALID:
+ sigInfoLabel = "openpgp-invalid-sig";
+ sigInfoWithDateLabel = "openpgp-invalid-sig-with-date";
+ wantToShowDate = true;
+ sigClass = "mismatch";
+ sigInfo = "openpgp-sig-invalid-technical-problem";
+ break;
+
+ case EnigmailConstants.MSG_SIG_VALID_KEY_UNVERIFIED:
+ sigInfoLabel = "openpgp-good-sig";
+ sigInfoWithDateLabel = "openpgp-good-sig-with-date";
+ wantToShowDate = true;
+ sigClass = "unverified";
+ sigInfo = "openpgp-sig-valid-unverified";
+ break;
+
+ case EnigmailConstants.MSG_SIG_VALID_KEY_VERIFIED:
+ sigInfoLabel = "openpgp-good-sig";
+ sigInfoWithDateLabel = "openpgp-good-sig-with-date";
+ wantToShowDate = true;
+ sigClass = "verified";
+ sigInfo = "openpgp-sig-valid-verified";
+ break;
+
+ case EnigmailConstants.MSG_SIG_VALID_SELF:
+ sigInfoLabel = "openpgp-good-sig";
+ sigInfoWithDateLabel = "openpgp-good-sig-with-date";
+ wantToShowDate = true;
+ sigClass = "ok";
+ sigInfo = "openpgp-sig-valid-own-key";
+ break;
+
+ default:
+ console.error(
+ "Unexpected msgSignatureState: " + Enigmail.hdrView.msgSignatureState
+ );
+ }
+
+ let signatureLabel = document.getElementById("signatureLabel");
+ if (wantToShowDate && Enigmail.hdrView.msgSignatureDate) {
+ let date = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeStyle: "short",
+ }).format(Enigmail.hdrView.msgSignatureDate);
+ document.l10n.setAttributes(signatureLabel, sigInfoWithDateLabel, {
+ date,
+ });
+ } else {
+ document.l10n.setAttributes(signatureLabel, sigInfoLabel);
+ }
+
+ // Remove the second class to properly update the signature icon.
+ signatureLabel.classList.remove(signatureLabel.classList.item(1));
+ signatureLabel.classList.add(sigClass);
+
+ let signatureExplanation = document.getElementById("signatureExplanation");
+ // eslint-disable-next-line mozilla/prefer-formatValues
+ signatureExplanation.textContent = await document.l10n.formatValue(sigInfo);
+
+ let encInfoLabel = null;
+ let encInfo = null;
+ let encClass = null;
+
+ switch (Enigmail.hdrView.msgEncryptionState) {
+ case EnigmailConstants.MSG_ENC_NONE:
+ encInfoLabel = "openpgp-enc-none";
+ encInfo = "openpgp-enc-none-label";
+ encClass = "none";
+ break;
+
+ case EnigmailConstants.MSG_ENC_NO_SECRET_KEY:
+ encInfoLabel = "openpgp-enc-invalid-label";
+ encInfo = "openpgp-enc-invalid";
+ encClass = "notok";
+ break;
+
+ case EnigmailConstants.MSG_ENC_FAILURE:
+ encInfoLabel = "openpgp-enc-invalid-label";
+ encInfo = "openpgp-enc-clueless";
+ encClass = "notok";
+ break;
+
+ case EnigmailConstants.MSG_ENC_OK:
+ encInfoLabel = "openpgp-enc-valid-label";
+ encInfo = "openpgp-enc-valid";
+ encClass = "ok";
+ break;
+
+ default:
+ console.error(
+ "Unexpected msgEncryptionState: " +
+ Enigmail.hdrView.msgEncryptionState
+ );
+ }
+
+ document.getElementById("techLabel").textContent = "- OpenPGP";
+
+ let encryptionLabel = document.getElementById("encryptionLabel");
+ // eslint-disable-next-line mozilla/prefer-formatValues
+ encryptionLabel.textContent = await document.l10n.formatValue(encInfoLabel);
+
+ // Remove the second class to properly update the encryption icon.
+ encryptionLabel.classList.remove(encryptionLabel.classList.item(1));
+ encryptionLabel.classList.add(encClass);
+
+ document.getElementById("encryptionExplanation").textContent =
+ // eslint-disable-next-line mozilla/prefer-formatValues
+ await document.l10n.formatValue(encInfo);
+
+ if (Enigmail.hdrView.msgSignatureKeyId) {
+ let sigKeyInfo = EnigmailKeyRing.getKeyById(
+ Enigmail.hdrView.msgSignatureKeyId
+ );
+
+ document.getElementById("signatureKey").collapsed = false;
+
+ if (
+ sigKeyInfo &&
+ sigKeyInfo.keyId != Enigmail.hdrView.msgSignatureKeyId
+ ) {
+ document.l10n.setAttributes(
+ document.getElementById("signatureKeyId"),
+ "openpgp-sig-key-id-with-subkey-id",
+ {
+ key: `0x${sigKeyInfo.keyId}`,
+ subkey: `0x${Enigmail.hdrView.msgSignatureKeyId}`,
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("signatureKeyId"),
+ "openpgp-sig-key-id",
+ {
+ key: `0x${Enigmail.hdrView.msgSignatureKeyId}`,
+ }
+ );
+ }
+
+ if (sigKeyInfo) {
+ document.getElementById("viewSignatureKey").collapsed = false;
+ gSigKeyId = Enigmail.hdrView.msgSignatureKeyId;
+ }
+ }
+
+ let myIdToSkipInList;
+ if (
+ Enigmail.hdrView.msgEncryptionKeyId &&
+ Enigmail.hdrView.msgEncryptionKeyId.keyId
+ ) {
+ myIdToSkipInList = Enigmail.hdrView.msgEncryptionKeyId.keyId;
+
+ // If we were given a separate primaryKeyId, it means keyId is a subkey.
+ let havePrimaryId = !!Enigmail.hdrView.msgEncryptionKeyId.primaryKeyId;
+ document.getElementById("encryptionKey").collapsed = false;
+
+ if (havePrimaryId) {
+ document.l10n.setAttributes(
+ document.getElementById("encryptionKeyId"),
+ "openpgp-enc-key-with-subkey-id",
+ {
+ key: `0x${Enigmail.hdrView.msgEncryptionKeyId.primaryKeyId}`,
+ subkey: `0x${Enigmail.hdrView.msgEncryptionKeyId.keyId}`,
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("encryptionKeyId"),
+ "openpgp-enc-key-id",
+ {
+ key: `0x${Enigmail.hdrView.msgEncryptionKeyId.keyId}`,
+ }
+ );
+ }
+
+ if (
+ EnigmailKeyRing.getKeyById(Enigmail.hdrView.msgEncryptionKeyId.keyId)
+ ) {
+ document.getElementById("viewEncryptionKey").collapsed = false;
+ gEncKeyId = Enigmail.hdrView.msgEncryptionKeyId.keyId;
+ }
+ }
+
+ let otherLabel = document.getElementById("otherLabel");
+ if (myIdToSkipInList) {
+ document.l10n.setAttributes(otherLabel, "openpgp-other-enc-all-key-ids");
+ } else {
+ document.l10n.setAttributes(
+ otherLabel,
+ "openpgp-other-enc-additional-key-ids"
+ );
+ }
+
+ if (!Enigmail.hdrView.msgEncryptionAllKeyIds) {
+ return;
+ }
+
+ let keyList = document.getElementById("otherEncryptionKeysList");
+ // Remove all the previously populated keys.
+ while (keyList.lastChild) {
+ keyList.removeChild(keyList.lastChild);
+ }
+
+ let showExtraKeysList = false;
+ for (let key of Enigmail.hdrView.msgEncryptionAllKeyIds) {
+ if (key.keyId == myIdToSkipInList) {
+ continue;
+ }
+
+ let container = document.createXULElement("vbox");
+ container.classList.add("other-key-row");
+
+ let havePrimaryId2 = !!key.primaryKeyId;
+ let keyInfo = EnigmailKeyRing.getKeyById(
+ havePrimaryId2 ? key.primaryKeyId : key.keyId
+ );
+
+ // Use textContent for label XUl elements to enable text wrapping.
+ let name = document.createXULElement("label");
+ name.classList.add("openpgp-key-name");
+ name.setAttribute("context", "simpleCopyPopup");
+ if (keyInfo) {
+ name.textContent = keyInfo.userId;
+ } else {
+ document.l10n.setAttributes(name, "openpgp-other-enc-all-key-ids");
+ }
+
+ let id = document.createXULElement("label");
+ id.setAttribute("context", "simpleCopyPopup");
+ id.classList.add("openpgp-key-id");
+ id.textContent = havePrimaryId2
+ ? ` 0x${key.primaryKeyId} (0x${key.keyId})`
+ : ` 0x${key.keyId}`;
+
+ container.appendChild(name);
+ container.appendChild(id);
+
+ keyList.appendChild(container);
+ showExtraKeysList = true;
+ }
+
+ // Show extra keys if present in the message.
+ document.getElementById("otherEncryptionKeys").collapsed =
+ !showExtraKeysList;
+ },
+};
+
+window.addEventListener(
+ "load-enigmail",
+ Enigmail.msg.messengerStartup.bind(Enigmail.msg)
+);
+window.addEventListener(
+ "unload",
+ Enigmail.msg.messengerClose.bind(Enigmail.msg)
+);
+window.addEventListener(
+ "unload-enigmail",
+ Enigmail.msg.onUnloadEnigmail.bind(Enigmail.msg)
+);
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailMsgBox.js b/comm/mail/extensions/openpgp/content/ui/enigmailMsgBox.js
new file mode 100644
index 0000000000..1ff4c2c27e
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailMsgBox.js
@@ -0,0 +1,181 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+function onLoad() {
+ document.documentElement.style.minHeight = "120px";
+ var dlg = document.getElementById("enigmailMsgBox");
+ dlg.getButton("cancel").setAttribute("hidden", "true");
+ dlg.getButton("extra1").setAttribute("hidden", "true");
+ dlg.getButton("extra2").setAttribute("hidden", "true");
+
+ document.getElementById("filler").style.maxWidth =
+ screen.availWidth - 50 + "px";
+
+ let args = window.arguments[0];
+ let msgtext = args.msgtext;
+ let button1 = args.button1;
+ let button2 = args.button2;
+ let button3 = args.button3;
+ let buttonCancel = args.cancelButton;
+ let checkboxLabel = args.checkboxLabel;
+
+ if (args.iconType) {
+ let icn = document.getElementById("infoImage");
+ icn.removeAttribute("collapsed");
+ let iconClass = "";
+
+ switch (args.iconType) {
+ case 2:
+ iconClass = "question-icon";
+ break;
+ case 3:
+ iconClass = "alert-icon";
+ break;
+ case 4:
+ iconClass = "error-icon";
+ break;
+ default:
+ iconClass = "message-icon";
+ }
+ icn.setAttribute("class", "spaced " + iconClass);
+ }
+
+ if (args.dialogTitle) {
+ if (AppConstants.platform == "macosx") {
+ let t = document.getElementById("macosDialogTitle");
+ t.setAttribute("value", args.dialogTitle);
+ t.removeAttribute("collapsed");
+ }
+
+ dlg.setAttribute("title", args.dialogTitle);
+ } else {
+ document.l10n.setAttributes(dlg, "enig-alert-title");
+ }
+
+ if (button1) {
+ setButton("accept", button1);
+ }
+ if (button2) {
+ setButton("extra1", button2);
+ }
+ if (button3) {
+ setButton("extra2", button3);
+ }
+ if (buttonCancel) {
+ setButton("cancel", buttonCancel);
+ }
+
+ if (checkboxLabel) {
+ let checkboxElem = document.getElementById("theCheckBox");
+ checkboxElem.setAttribute("label", checkboxLabel);
+ document.getElementById("checkboxContainer").removeAttribute("hidden");
+ }
+
+ dlg.getButton("accept").focus();
+ let textbox = document.getElementById("msgtext");
+ textbox.appendChild(textbox.ownerDocument.createTextNode(msgtext));
+
+ window.addEventListener("keypress", onKeyPress);
+ setTimeout(resizeDlg, 0);
+}
+
+function resizeDlg() {
+ let availHeight = screen.availHeight;
+ if (window.outerHeight > availHeight - 100) {
+ let box = document.getElementById("msgContainer");
+ let dlg = document.getElementById("enigmailMsgBox");
+ let btnHeight = dlg.getButton("accept").parentNode.clientHeight + 20;
+ let boxHeight = box.clientHeight;
+ let dlgHeight = dlg.clientHeight;
+
+ box.setAttribute("style", "overflow: auto;");
+ box.setAttribute(
+ "height",
+ boxHeight - btnHeight - (dlgHeight - availHeight)
+ );
+ window.resizeTo(window.outerWidth, availHeight);
+ }
+}
+
+function setButton(buttonId, label) {
+ var labelType = buttonId;
+
+ var dlg = document.getElementById("enigmailMsgBox");
+ var elem = dlg.getButton(labelType);
+
+ var i = label.indexOf(":");
+ if (i === 0) {
+ elem = dlg.getButton(label.substr(1));
+ elem.setAttribute("hidden", "false");
+ elem.setAttribute("oncommand", "dlgClose('" + buttonId + "')");
+ return;
+ }
+ if (i > 0) {
+ labelType = label.substr(0, i);
+ label = label.substr(i + 1);
+ elem = dlg.getButton(labelType);
+ }
+ i = label.indexOf("&");
+ if (i >= 0) {
+ var c = label.substr(i + 1, 1);
+ if (c != "&") {
+ elem.setAttribute("accesskey", c);
+ }
+ label = label.substr(0, i) + label.substr(i + 1);
+ }
+ elem.setAttribute("label", label);
+ elem.setAttribute("oncommand", "dlgClose('" + buttonId + "')");
+ elem.removeAttribute("hidden");
+}
+
+function dlgClose(buttonId) {
+ let buttonNumber = 99;
+
+ switch (buttonId) {
+ case "accept":
+ buttonNumber = 0;
+ break;
+ case "extra1":
+ buttonNumber = 1;
+ break;
+ case "extra2":
+ buttonNumber = 2;
+ break;
+ case "cancel":
+ buttonNumber = -1;
+ }
+
+ window.arguments[1].value = buttonNumber;
+ window.arguments[1].checked =
+ document.getElementById("theCheckBox").getAttribute("checked") == "true";
+ window.close();
+}
+
+function checkboxCb() {
+ // do nothing
+}
+
+async function copyToClipbrd() {
+ let s = window.getSelection().toString();
+ return navigator.clipboard.writeText(s);
+}
+
+function onKeyPress(event) {
+ if (event.key == "c" && event.getModifierState("Accel")) {
+ copyToClipbrd();
+ event.stopPropagation();
+ }
+}
+
+document.addEventListener("dialogaccept", function (event) {
+ dlgClose("accept");
+});
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailMsgBox.xhtml b/comm/mail/extensions/openpgp/content/ui/enigmailMsgBox.xhtml
new file mode 100644
index 0000000000..3fec668c8c
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailMsgBox.xhtml
@@ -0,0 +1,71 @@
+<?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 https://mozilla.org/MPL/2.0/.
+-->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/enigmail.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD; ]>
+
+<window
+ title=""
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ buttons="accept,help,cancel,extra1,extra2"
+ onload="onLoad();"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <dialog id="enigmailMsgBox" buttonpack="center">
+ <script
+ type="application/x-javascript"
+ src="chrome://openpgp/content/ui/enigmailMsgBox.js"
+ />
+ <linkset>
+ <html:link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ </linkset>
+
+ <popupset>
+ <menupopup id="ctxmenu">
+ <menuitem
+ data-l10n-id="openpgp-copy-cmd-label"
+ oncommand="copyToClipbrd()"
+ />
+ </menupopup>
+ </popupset>
+
+ <hbox id="filler" style="min-width: 0%">
+ <spacer style="width: 29em" />
+ </hbox>
+
+ <html:div class="grid-two-column-fr">
+ <html:div class="flex-items-center">
+ <image id="infoImage" class="spaced" collapsed="true" />
+ </html:div>
+ <html:div class="flex-items-center">
+ <vbox id="infoContainer" pack="center">
+ <label
+ id="macosDialogTitle"
+ collapsed="true"
+ class="enigmailDialogTitle"
+ />
+ <vbox id="msgContainer" style="max-width: 45em">
+ <description
+ id="msgtext"
+ context="ctxmenu"
+ noinitialfocus="true"
+ class="enigmailDialogBody"
+ />
+ </vbox>
+ </vbox>
+ </html:div>
+ <html:div id="checkboxContainer" class="grid-item-col2" hidden="hidden">
+ <checkbox id="theCheckBox" oncommand="checkboxCb()" />
+ </html:div>
+ </html:div>
+ </dialog>
+</window>
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailMsgComposeOverlay.js b/comm/mail/extensions/openpgp/content/ui/enigmailMsgComposeOverlay.js
new file mode 100644
index 0000000000..db973f0ee9
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailMsgComposeOverlay.js
@@ -0,0 +1,3034 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+/* import-globals-from ../../../../components/compose/content/MsgComposeCommands.js */
+/* import-globals-from ../../../../components/compose/content/addressingWidgetOverlay.js */
+/* global MsgAccountManager */
+/* global gCurrentIdentity */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var EnigmailCore = ChromeUtils.import(
+ "chrome://openpgp/content/modules/core.jsm"
+).EnigmailCore;
+var EnigmailFuncs = ChromeUtils.import(
+ "chrome://openpgp/content/modules/funcs.jsm"
+).EnigmailFuncs;
+var { EnigmailLog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/log.jsm"
+);
+var EnigmailArmor = ChromeUtils.import(
+ "chrome://openpgp/content/modules/armor.jsm"
+).EnigmailArmor;
+var EnigmailData = ChromeUtils.import(
+ "chrome://openpgp/content/modules/data.jsm"
+).EnigmailData;
+var EnigmailDialog = ChromeUtils.import(
+ "chrome://openpgp/content/modules/dialog.jsm"
+).EnigmailDialog;
+var EnigmailWindows = ChromeUtils.import(
+ "chrome://openpgp/content/modules/windows.jsm"
+).EnigmailWindows;
+var EnigmailKeyRing = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+).EnigmailKeyRing;
+var EnigmailURIs = ChromeUtils.import(
+ "chrome://openpgp/content/modules/uris.jsm"
+).EnigmailURIs;
+var EnigmailConstants = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+).EnigmailConstants;
+var EnigmailDecryption = ChromeUtils.import(
+ "chrome://openpgp/content/modules/decryption.jsm"
+).EnigmailDecryption;
+var EnigmailEncryption = ChromeUtils.import(
+ "chrome://openpgp/content/modules/encryption.jsm"
+).EnigmailEncryption;
+var EnigmailWkdLookup = ChromeUtils.import(
+ "chrome://openpgp/content/modules/wkdLookup.jsm"
+).EnigmailWkdLookup;
+var EnigmailMime = ChromeUtils.import(
+ "chrome://openpgp/content/modules/mime.jsm"
+).EnigmailMime;
+var EnigmailMsgRead = ChromeUtils.import(
+ "chrome://openpgp/content/modules/msgRead.jsm"
+).EnigmailMsgRead;
+var EnigmailMimeEncrypt = ChromeUtils.import(
+ "chrome://openpgp/content/modules/mimeEncrypt.jsm"
+).EnigmailMimeEncrypt;
+const { EnigmailCryptoAPI } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/cryptoAPI.jsm"
+);
+const { OpenPGPAlias } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/OpenPGPAlias.jsm"
+);
+var { jsmime } = ChromeUtils.import("resource:///modules/jsmime.jsm");
+
+var l10nOpenPGP = new Localization(["messenger/openpgp/openpgp.ftl"]);
+
+// Account encryption policy values:
+// const kEncryptionPolicy_Never = 0;
+// 'IfPossible' was used by ns4.
+// const kEncryptionPolicy_IfPossible = 1;
+var kEncryptionPolicy_Always = 2;
+
+var Enigmail = {};
+
+const IOSERVICE_CONTRACTID = "@mozilla.org/network/io-service;1";
+const LOCAL_FILE_CONTRACTID = "@mozilla.org/file/local;1";
+
+Enigmail.msg = {
+ editor: null,
+ dirty: 0,
+ // dirty means: composer contents were modified by this code, right?
+ processed: null, // contains information for undo of inline signed/encrypt
+ timeoutId: null, // TODO: once set, it's never reset
+ sendPgpMime: true,
+ //sendMode: null, // the current default for sending a message (0, SIGN, ENCRYPT, or SIGN|ENCRYPT)
+ //sendModeDirty: false, // send mode or final send options changed?
+
+ // processed strings to signal final encrypt/sign/pgpmime state:
+ statusEncryptedStr: "???",
+ statusSignedStr: "???",
+ //statusPGPMimeStr: "???",
+ //statusSMimeStr: "???",
+ //statusInlinePGPStr: "???",
+ statusAttachOwnKey: "???",
+
+ sendProcess: false,
+ composeBodyReady: false,
+ modifiedAttach: null,
+ lastFocusedWindow: null,
+ draftSubjectEncrypted: false,
+ attachOwnKeyObj: {
+ attachedObj: null,
+ attachedKey: null,
+ },
+
+ keyLookupDone: [],
+
+ addrOnChangeTimeout: 250,
+ /* timeout when entering something into the address field */
+
+ async composeStartup() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.composeStartup\n"
+ );
+
+ if (!gMsgCompose || !gMsgCompose.compFields) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: no gMsgCompose, leaving\n"
+ );
+ return;
+ }
+
+ gMsgCompose.RegisterStateListener(Enigmail.composeStateListener);
+ Enigmail.msg.composeBodyReady = false;
+
+ // Listen to message sending event
+ addEventListener(
+ "compose-send-message",
+ Enigmail.msg.sendMessageListener.bind(Enigmail.msg),
+ true
+ );
+
+ await OpenPGPAlias.load().catch(console.error);
+
+ Enigmail.msg.composeOpen();
+ //Enigmail.msg.processFinalState();
+ },
+
+ // TODO: call this from global compose when options change
+ enigmailComposeProcessFinalState() {
+ //Enigmail.msg.processFinalState();
+ },
+
+ /*
+ handleClick: function(event, modifyType) {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.handleClick\n");
+ switch (event.button) {
+ case 2:
+ // do not process the event any further
+ // needed on Windows to prevent displaying the context menu
+ event.preventDefault();
+ this.doPgpButton();
+ break;
+ case 0:
+ this.doPgpButton(modifyType);
+ break;
+ }
+ },
+ */
+
+ /* return whether the account specific setting key is enabled or disabled
+ */
+ /*
+ getAccDefault: function(key) {
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.getAccDefault: identity="+this.identity.key+"("+this.identity.email+") key="+key+"\n");
+ let res = null;
+ let mimePreferOpenPGP = this.identity.getIntAttribute("mimePreferOpenPGP");
+ let isSmimeEnabled = Enigmail.msg.isSmimeEnabled();
+ let wasEnigmailEnabledForIdentity = Enigmail.msg.wasEnigmailEnabledForIdentity();
+ let preferSmimeByDefault = false;
+
+ if (isSmimeEnabled && wasEnigmailEnabledForIdentity) {
+ }
+
+ if (wasEnigmailEnabledForIdentity) {
+ switch (key) {
+ case 'sign':
+ if (preferSmimeByDefault) {
+ res = (this.identity.signMail);
+ }
+ else {
+ res = (this.identity.getIntAttribute("defaultSigningPolicy") > 0);
+ }
+ break;
+ case 'encrypt':
+ if (preferSmimeByDefault) {
+ res = (this.identity.encryptionPolicy > 0);
+ }
+ else {
+ res = (this.identity.getIntAttribute("defaultEncryptionPolicy") > 0);
+ }
+ break;
+ case 'sign-pgp':
+ res = (this.identity.getIntAttribute("defaultSigningPolicy") > 0);
+ break;
+ case 'pgpMimeMode':
+ res = this.identity.getBoolAttribute(key);
+ break;
+ case 'attachPgpKey':
+ res = this.identity.attachPgpKey;
+ break;
+ }
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.getAccDefault: "+key+"="+res+"\n");
+ return res;
+ }
+ else if (Enigmail.msg.isSmimeEnabled()) {
+ switch (key) {
+ case 'sign':
+ res = this.identity.signMail;
+ break;
+ case 'encrypt':
+ res = (this.identity.encryptionPolicy > 0);
+ break;
+ default:
+ res = false;
+ }
+ return res;
+ }
+ else {
+ // every detail is disabled if OpenPGP in general is disabled:
+ switch (key) {
+ case 'sign':
+ case 'encrypt':
+ case 'pgpMimeMode':
+ case 'attachPgpKey':
+ case 'sign-pgp':
+ return false;
+ }
+ }
+
+ // should not be reached
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.getAccDefault: internal error: invalid key '" + key + "'\n");
+ return null;
+ },
+ */
+
+ /**
+ * Determine if any of Enigmail (OpenPGP) or S/MIME encryption is enabled for the account
+ */
+ /*
+ isAnyEncryptionEnabled: function() {
+ let id = getCurrentIdentity();
+
+ return ((id.getUnicharAttribute("encryption_cert_name") !== "") ||
+ Enigmail.msg.wasEnigmailEnabledForIdentity());
+ },
+ */
+
+ isSmimeEnabled() {
+ return (
+ gCurrentIdentity.getUnicharAttribute("signing_cert_name") !== "" ||
+ gCurrentIdentity.getUnicharAttribute("encryption_cert_name") !== ""
+ );
+ },
+
+ /**
+ * Determine if any of Enigmail (OpenPGP) or S/MIME signing is enabled for the account
+ */
+ /*
+ getSigningEnabled: function() {
+ let id = getCurrentIdentity();
+
+ return ((id.getUnicharAttribute("signing_cert_name") !== "") ||
+ Enigmail.msg.wasEnigmailEnabledForIdentity());
+ },
+ */
+
+ /*
+ getSmimeSigningEnabled: function() {
+ let id = getCurrentIdentity();
+
+ if (!id.getUnicharAttribute("signing_cert_name")) return false;
+
+ return id.signMail;
+ },
+ */
+
+ /*
+ // set the current default for sending a message
+ // depending on the identity
+ processAccountSpecificDefaultOptions: function() {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.processAccountSpecificDefaultOptions\n");
+
+ const SIGN = EnigmailConstants.SEND_SIGNED;
+ const ENCRYPT = EnigmailConstants.SEND_ENCRYPTED;
+
+ this.sendMode = 0;
+
+ if (this.getSmimeSigningEnabled()) {
+ this.sendMode |= SIGN;
+ }
+
+ if (!Enigmail.msg.wasEnigmailEnabledForIdentity()) {
+ return;
+ }
+
+ if (this.getAccDefault("encrypt")) {
+ this.sendMode |= ENCRYPT;
+ }
+ if (this.getAccDefault("sign")) {
+ this.sendMode |= SIGN;
+ }
+
+ //this.sendPgpMime = this.getAccDefault("pgpMimeMode");
+ //console.debug("processAccountSpecificDefaultOptions sendPgpMime: " + this.sendPgpMime);
+ gAttachMyPublicPGPKey = this.getAccDefault("attachPgpKey");
+ this.setOwnKeyStatus();
+ this.attachOwnKeyObj.attachedObj = null;
+ this.attachOwnKeyObj.attachedKey = null;
+
+ //this.finalSignDependsOnEncrypt = (this.getAccDefault("signIfEnc") || this.getAccDefault("signIfNotEnc"));
+ },
+ */
+
+ getOriginalMsgUri() {
+ let draftId = gMsgCompose.compFields.draftId;
+ let msgUri = null;
+
+ if (draftId) {
+ // original message is draft
+ msgUri = draftId.replace(/\?.*$/, "");
+ } else if (gMsgCompose.originalMsgURI) {
+ // original message is a "true" mail
+ msgUri = gMsgCompose.originalMsgURI;
+ }
+
+ return msgUri;
+ },
+
+ getMsgHdr(msgUri) {
+ try {
+ if (!msgUri) {
+ msgUri = this.getOriginalMsgUri();
+ }
+ if (msgUri) {
+ return gMessenger.msgHdrFromURI(msgUri);
+ }
+ } catch (ex) {
+ // See also bug 1635648
+ console.debug("exception in getMsgHdr: " + ex);
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: exception in getMsgHdr: " + ex + "\n"
+ );
+ }
+ return null;
+ },
+
+ getMsgProperties(draft, msgUri, msgHdr, mimeMsg, obtainedDraftFlagsObj) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: Enigmail.msg.getMsgProperties:\n"
+ );
+ obtainedDraftFlagsObj.value = false;
+
+ let self = this;
+ let properties = 0;
+ try {
+ if (msgHdr) {
+ properties = msgHdr.getUint32Property("enigmail");
+
+ if (draft) {
+ if (self.getSavedDraftOptions(mimeMsg)) {
+ obtainedDraftFlagsObj.value = true;
+ }
+ updateEncryptionDependencies();
+ }
+ }
+ } catch (ex) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: Enigmail.msg.getMsgProperties: got exception '" +
+ ex.toString() +
+ "'\n"
+ );
+ }
+
+ if (EnigmailURIs.isEncryptedUri(msgUri)) {
+ properties |= EnigmailConstants.DECRYPTION_OKAY;
+ }
+
+ return properties;
+ },
+
+ getSavedDraftOptions(mimeMsg) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.getSavedDraftOptions\n"
+ );
+ if (!mimeMsg || !mimeMsg.headers.has("x-enigmail-draft-status")) {
+ return false;
+ }
+
+ let stat = mimeMsg.headers.get("x-enigmail-draft-status").join("");
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.getSavedDraftOptions: draftStatus: " +
+ stat +
+ "\n"
+ );
+
+ if (stat.substr(0, 1) == "N") {
+ switch (Number(stat.substr(1, 1))) {
+ case 2:
+ // treat as "user decision to enable encryption, disable auto"
+ gUserTouchedSendEncrypted = true;
+ gSendEncrypted = true;
+ updateEncryptionDependencies();
+ break;
+ case 0:
+ // treat as "user decision to disable encryption, disable auto"
+ gUserTouchedSendEncrypted = true;
+ gSendEncrypted = false;
+ updateEncryptionDependencies();
+ break;
+ case 1:
+ default:
+ // treat as "no user decision, automatic mode"
+ break;
+ }
+
+ switch (Number(stat.substr(2, 1))) {
+ case 2:
+ gSendSigned = true;
+ gUserTouchedSendSigned = true;
+ break;
+ case 0:
+ gUserTouchedSendSigned = true;
+ gSendSigned = false;
+ break;
+ case 1:
+ default:
+ // treat as "no user decision, automatic mode, based on encryption or other prefs"
+ break;
+ }
+
+ switch (Number(stat.substr(3, 1))) {
+ case 1:
+ break;
+ case EnigmailConstants.ENIG_FORCE_SMIME:
+ // 3
+ gSelectedTechnologyIsPGP = false;
+ break;
+ case 2: // pgp/mime
+ case 0: // inline
+ default:
+ gSelectedTechnologyIsPGP = true;
+ break;
+ }
+
+ switch (Number(stat.substr(4, 1))) {
+ case 1:
+ gUserTouchedAttachMyPubKey = true;
+ gAttachMyPublicPGPKey = true;
+ break;
+ case 2:
+ gUserTouchedAttachMyPubKey = false;
+ break;
+ case 0:
+ default:
+ gUserTouchedAttachMyPubKey = true;
+ gAttachMyPublicPGPKey = false;
+ break;
+ }
+
+ switch (Number(stat.substr(4, 1))) {
+ case 1:
+ gUserTouchedAttachMyPubKey = true;
+ gAttachMyPublicPGPKey = true;
+ break;
+ case 2:
+ gUserTouchedAttachMyPubKey = false;
+ break;
+ case 0:
+ default:
+ gUserTouchedAttachMyPubKey = true;
+ gAttachMyPublicPGPKey = false;
+ break;
+ }
+
+ switch (Number(stat.substr(5, 1))) {
+ case 1:
+ gUserTouchedEncryptSubject = true;
+ gEncryptSubject = true;
+ break;
+ case 2:
+ gUserTouchedEncryptSubject = false;
+ break;
+ case 0:
+ default:
+ gUserTouchedEncryptSubject = true;
+ gEncryptSubject = false;
+ break;
+ }
+ }
+ //Enigmail.msg.setOwnKeyStatus();
+ return true;
+ },
+
+ composeOpen() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.composeOpen\n"
+ );
+
+ let msgUri = null;
+ let msgHdr = null;
+
+ msgUri = this.getOriginalMsgUri();
+ if (msgUri) {
+ msgHdr = this.getMsgHdr(msgUri);
+ if (msgHdr) {
+ try {
+ let msgUrl = EnigmailMsgRead.getUrlFromUriSpec(msgUri);
+ EnigmailMime.getMimeTreeFromUrl(msgUrl.spec, false, mimeMsg => {
+ Enigmail.msg.continueComposeOpenWithMimeTree(
+ msgUri,
+ msgHdr,
+ mimeMsg
+ );
+ });
+ } catch (ex) {
+ EnigmailLog.DEBUG(
+ "enigmailMessengerOverlay.js: composeOpen: exception in getMimeTreeFromUrl: " +
+ ex +
+ "\n"
+ );
+ this.continueComposeOpenWithMimeTree(msgUri, msgHdr, null);
+ }
+ } else {
+ this.continueComposeOpenWithMimeTree(msgUri, msgHdr, null);
+ }
+ } else {
+ this.continueComposeOpenWithMimeTree(msgUri, msgHdr, null);
+ }
+ },
+
+ continueComposeOpenWithMimeTree(msgUri, msgHdr, mimeMsg) {
+ let selectedElement = document.activeElement;
+
+ let msgIsDraft =
+ gMsgCompose.type === Ci.nsIMsgCompType.Draft ||
+ gMsgCompose.type === Ci.nsIMsgCompType.Template;
+
+ if (!gSendEncrypted || msgIsDraft) {
+ let useEncryptionUnlessWeHaveDraftInfo = false;
+ let usePGPUnlessWeKnowOtherwise = false;
+ let useSMIMEUnlessWeKnowOtherwise = false;
+
+ if (msgIsDraft) {
+ let globalSaysItsEncrypted =
+ gEncryptedURIService &&
+ gMsgCompose.originalMsgURI &&
+ gEncryptedURIService.isEncrypted(gMsgCompose.originalMsgURI);
+
+ if (globalSaysItsEncrypted) {
+ useEncryptionUnlessWeHaveDraftInfo = true;
+ useSMIMEUnlessWeKnowOtherwise = true;
+ }
+ }
+
+ let obtainedDraftFlagsObj = { value: false };
+ if (msgUri) {
+ let msgFlags = this.getMsgProperties(
+ msgIsDraft,
+ msgUri,
+ msgHdr,
+ mimeMsg,
+ obtainedDraftFlagsObj
+ );
+ if (msgFlags & EnigmailConstants.DECRYPTION_OKAY) {
+ usePGPUnlessWeKnowOtherwise = true;
+ useSMIMEUnlessWeKnowOtherwise = false;
+ }
+ if (msgIsDraft && obtainedDraftFlagsObj.value) {
+ useEncryptionUnlessWeHaveDraftInfo = false;
+ usePGPUnlessWeKnowOtherwise = false;
+ useSMIMEUnlessWeKnowOtherwise = false;
+ }
+ if (!msgIsDraft) {
+ if (msgFlags & EnigmailConstants.DECRYPTION_OKAY) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.composeOpen: has encrypted originalMsgUri\n"
+ );
+ EnigmailLog.DEBUG(
+ "originalMsgURI=" + gMsgCompose.originalMsgURI + "\n"
+ );
+ gSendEncrypted = true;
+ updateEncryptionDependencies();
+ gSelectedTechnologyIsPGP = true;
+ useEncryptionUnlessWeHaveDraftInfo = false;
+ usePGPUnlessWeKnowOtherwise = false;
+ useSMIMEUnlessWeKnowOtherwise = false;
+ }
+ }
+ this.removeAttachedKey();
+ }
+
+ if (useEncryptionUnlessWeHaveDraftInfo) {
+ gSendEncrypted = true;
+ updateEncryptionDependencies();
+ }
+ if (gSendEncrypted && !obtainedDraftFlagsObj.value) {
+ gSendSigned = true;
+ }
+ if (usePGPUnlessWeKnowOtherwise) {
+ gSelectedTechnologyIsPGP = true;
+ } else if (useSMIMEUnlessWeKnowOtherwise) {
+ gSelectedTechnologyIsPGP = false;
+ }
+ }
+
+ // check for attached signature files and remove them
+ var bucketList = document.getElementById("attachmentBucket");
+ if (bucketList.hasChildNodes()) {
+ var node = bucketList.firstChild;
+ while (node) {
+ if (node.attachment.contentType == "application/pgp-signature") {
+ if (!this.findRelatedAttachment(bucketList, node)) {
+ // Let's release the attachment object held by the node else it won't go away until the window is destroyed
+ node.attachment = null;
+ node = bucketList.removeChild(node);
+ }
+ }
+ node = node.nextSibling;
+ }
+ }
+
+ // If we removed all the children and the bucket wasn't meant
+ // to stay open, close it.
+ if (!Services.prefs.getBoolPref("mail.compose.show_attachment_pane")) {
+ UpdateAttachmentBucket(bucketList.hasChildNodes());
+ }
+
+ this.warnUserIfSenderKeyExpired();
+
+ //this.processFinalState();
+ if (selectedElement) {
+ selectedElement.focus();
+ }
+ },
+
+ // check if an signature is related to another attachment
+ findRelatedAttachment(bucketList, node) {
+ // check if filename ends with .sig
+ if (node.attachment.name.search(/\.sig$/i) < 0) {
+ return null;
+ }
+
+ var relatedNode = bucketList.firstChild;
+ var findFile = node.attachment.name.toLowerCase();
+ var baseAttachment = null;
+ while (relatedNode) {
+ if (relatedNode.attachment.name.toLowerCase() + ".sig" == findFile) {
+ baseAttachment = relatedNode.attachment;
+ }
+ relatedNode = relatedNode.nextSibling;
+ }
+ return baseAttachment;
+ },
+
+ async attachOwnKey(id) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.attachOwnKey: " + id + "\n"
+ );
+
+ if (
+ this.attachOwnKeyObj.attachedKey &&
+ this.attachOwnKeyObj.attachedKey != id
+ ) {
+ // remove attached key if user ID changed
+ this.removeAttachedKey();
+ }
+ let revokedIDs = EnigmailKeyRing.findRevokedPersonalKeysByEmail(
+ gCurrentIdentity.email
+ );
+
+ if (!this.attachOwnKeyObj.attachedKey) {
+ let hex = "0x" + id;
+ var attachedObj = await this.extractAndAttachKey(
+ hex,
+ revokedIDs,
+ gCurrentIdentity.email,
+ true,
+ true // one key plus revocations
+ );
+ if (attachedObj) {
+ this.attachOwnKeyObj.attachedObj = attachedObj;
+ this.attachOwnKeyObj.attachedKey = hex;
+ }
+ }
+ },
+
+ async extractAndAttachKey(
+ primaryId,
+ revokedIds,
+ emailForFilename,
+ warnOnError
+ ) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.extractAndAttachKey: \n"
+ );
+ var enigmailSvc = EnigmailCore.getService(window);
+ if (!enigmailSvc) {
+ return null;
+ }
+
+ var tmpFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tmpFile.append("key.asc");
+ tmpFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+
+ // save file
+ var exitCodeObj = {};
+ var errorMsgObj = {};
+
+ await EnigmailKeyRing.extractPublicKeys(
+ [], // full
+ [primaryId], // reduced
+ revokedIds, // minimal
+ tmpFile,
+ exitCodeObj,
+ errorMsgObj
+ );
+ if (exitCodeObj.value !== 0) {
+ if (warnOnError) {
+ EnigmailDialog.alert(window, errorMsgObj.value);
+ }
+ return null;
+ }
+
+ // create attachment
+ var tmpFileURI = Services.io.newFileURI(tmpFile);
+ var keyAttachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ keyAttachment.url = tmpFileURI.spec;
+ keyAttachment.name = primaryId.substr(-16, 16);
+ if (keyAttachment.name.search(/^0x/) < 0) {
+ keyAttachment.name = "0x" + keyAttachment.name;
+ }
+ let withRevSuffix = "";
+ if (revokedIds && revokedIds.length) {
+ withRevSuffix = "_and_old_rev";
+ }
+ keyAttachment.name =
+ "OpenPGP_" + keyAttachment.name + withRevSuffix + ".asc";
+ keyAttachment.temporary = true;
+ keyAttachment.contentType = "application/pgp-keys";
+ keyAttachment.size = tmpFile.fileSize;
+
+ if (
+ !gAttachmentBucket.itemChildren.find(
+ item => item.attachment.name == keyAttachment.name
+ )
+ ) {
+ await this.addAttachment(keyAttachment);
+ }
+
+ gContentChanged = true;
+ return keyAttachment;
+ },
+
+ addAttachment(attachment) {
+ return AddAttachments([attachment]);
+ },
+
+ removeAttachedKey() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.removeAttachedKey: \n"
+ );
+
+ let bucketList = document.getElementById("attachmentBucket");
+ let node = bucketList.firstElementChild;
+
+ if (bucketList.itemCount && this.attachOwnKeyObj.attachedObj) {
+ // Undo attaching own key.
+ while (node) {
+ if (node.attachment.url == this.attachOwnKeyObj.attachedObj.url) {
+ node = bucketList.removeChild(node);
+ // Let's release the attachment object held by the node else it won't
+ // go away until the window is destroyed.
+ node.attachment = null;
+ this.attachOwnKeyObj.attachedObj = null;
+ this.attachOwnKeyObj.attachedKey = null;
+ node = null; // exit loop.
+ } else {
+ node = node.nextSibling;
+ }
+ }
+
+ // Update the visibility of the attachment pane.
+ UpdateAttachmentBucket(bucketList.itemCount);
+ }
+ },
+
+ getSecurityParams(compFields = null) {
+ if (!compFields) {
+ if (!gMsgCompose) {
+ return null;
+ }
+
+ compFields = gMsgCompose.compFields;
+ }
+
+ return compFields.composeSecure;
+ },
+
+ setSecurityParams(newSecurityParams) {
+ if (!gMsgCompose || !gMsgCompose.compFields) {
+ return;
+ }
+ gMsgCompose.compFields.composeSecure = newSecurityParams;
+ },
+
+ // Used on send failure, to reset the pre-send modifications
+ resetUpdatedFields() {
+ this.removeAttachedKey();
+
+ // reset subject
+ let p = Enigmail.msg.getSecurityParams();
+ if (p && EnigmailMimeEncrypt.isEnigmailCompField(p)) {
+ let si = p.wrappedJSObject;
+ if (si.originalSubject) {
+ gMsgCompose.compFields.subject = si.originalSubject;
+ }
+ }
+ },
+
+ replaceEditorText(text) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.replaceEditorText:\n"
+ );
+
+ this.editorSelectAll();
+ // Overwrite text in clipboard for security
+ // (Otherwise plaintext will be available in the clipbaord)
+
+ if (this.editor.textLength > 0) {
+ this.editorInsertText("Enigmail");
+ } else {
+ this.editorInsertText(" ");
+ }
+
+ this.editorSelectAll();
+ this.editorInsertText(text);
+ },
+
+ /**
+ * Determine if Enigmail is enabled for the account
+ */
+
+ isEnigmailEnabledForIdentity() {
+ return !!gCurrentIdentity.getUnicharAttribute("openpgp_key_id");
+ },
+
+ /**
+ * Determine if Autocrypt is enabled for the account
+ */
+ isAutocryptEnabled() {
+ return false;
+ /*
+ if (Enigmail.msg.wasEnigmailEnabledForIdentity()) {
+ let srv = this.getCurrentIncomingServer();
+ return (srv ? srv.getBoolValue("enableAutocrypt") : false);
+ }
+
+ return false;
+ */
+ },
+
+ /*
+ doPgpButton: function(what) {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.doPgpButton: what=" + what + "\n");
+
+ if (Enigmail.msg.wasEnigmailEnabledForIdentity()) {
+ EnigmailCore.getService(window); // try to access Enigmail to launch the wizard if needed
+ }
+
+ // ignore settings for this account?
+ try {
+ if (!this.isAnyEncryptionEnabled() && !this.getSigningEnabled()) {
+ return;
+ }
+ }
+ catch (ex) {}
+
+ switch (what) {
+ case 'sign':
+ case 'encrypt':
+ this.setSendMode(what);
+ break;
+
+ case 'trustKeys':
+ this.tempTrustAllKeys();
+ break;
+
+ case 'nothing':
+ break;
+
+ case 'displaySecuritySettings':
+ this.displaySecuritySettings();
+ break;
+ default:
+ this.displaySecuritySettings();
+ }
+
+ },
+ */
+
+ // changes the DEFAULT sendMode
+ // - also called internally for saved emails
+ /*
+ setSendMode: function(sendMode) {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.setSendMode: sendMode=" + sendMode + "\n");
+ const SIGN = EnigmailConstants.SEND_SIGNED;
+ const ENCRYPT = EnigmailConstants.SEND_ENCRYPTED;
+
+ var origSendMode = this.sendMode;
+ switch (sendMode) {
+ case 'sign':
+ this.sendMode |= SIGN;
+ break;
+ case 'encrypt':
+ this.sendMode |= ENCRYPT;
+ break;
+ default:
+ EnigmailDialog.alert(window, "Enigmail.msg.setSendMode - unexpected value: " + sendMode);
+ break;
+ }
+ // sendMode changed ?
+ // - sign and send are internal initializations
+ if (!this.sendModeDirty && (this.sendMode != origSendMode) && sendMode != 'sign' && sendMode != 'encrypt') {
+ this.sendModeDirty = true;
+ }
+ this.processFinalState();
+ },
+ */
+
+ /**
+ key function to process the final encrypt/sign/pgpmime state from all settings
+ *
+ @param sendFlags: contains the sendFlags if the message is really processed. Optional, can be null
+ - uses as INPUT:
+ - this.sendMode
+ - this.encryptForced, this.encryptSigned
+ - uses as OUTPUT:
+ - this.statusEncrypt, this.statusSign
+
+ no return value
+ */
+ processFinalState(sendFlags) {},
+
+ /* check if encryption is possible (have keys for everyone or not)
+ */
+ async determineSendFlags() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.focusChange: Enigmail.msg.determineSendFlags\n"
+ );
+
+ let detailsObj = {};
+ var compFields = gMsgCompose.compFields;
+
+ if (!Enigmail.msg.composeBodyReady) {
+ compFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ }
+ Recipients2CompFields(compFields);
+
+ // disabled, see bug 1625135
+ // gMsgCompose.expandMailingLists();
+
+ if (Enigmail.msg.isEnigmailEnabledForIdentity()) {
+ var toAddrList = [];
+ var arrLen = {};
+ var recList;
+ if (compFields.to) {
+ recList = compFields.splitRecipients(compFields.to, true, arrLen);
+ this.addRecipients(toAddrList, recList);
+ }
+ if (compFields.cc) {
+ recList = compFields.splitRecipients(compFields.cc, true, arrLen);
+ this.addRecipients(toAddrList, recList);
+ }
+ if (compFields.bcc) {
+ recList = compFields.splitRecipients(compFields.bcc, true, arrLen);
+ this.addRecipients(toAddrList, recList);
+ }
+
+ let addresses = [];
+ try {
+ addresses = EnigmailFuncs.stripEmail(toAddrList.join(", ")).split(",");
+ } catch (ex) {}
+
+ // Resolve all the email addresses if possible.
+ await EnigmailKeyRing.getValidKeysForAllRecipients(addresses, detailsObj);
+ //this.autoPgpEncryption = (validKeyList !== null);
+ }
+
+ // process and signal new resulting state
+ //this.processFinalState();
+
+ return detailsObj;
+ },
+
+ /*
+ displaySecuritySettings: function() {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.displaySecuritySettings\n");
+
+ var inputObj = {
+ gSendEncrypted: gSendEncrypted,
+ gSendSigned: gSendSigned,
+ success: false,
+ resetDefaults: false
+ };
+ window.openDialog("chrome://openpgp/content/ui/enigmailEncryptionDlg.xhtml", "", "dialog,modal,centerscreen", inputObj);
+
+ if (!inputObj.success) return; // Cancel pressed
+
+ if (inputObj.resetDefaults) {
+ // reset everything to defaults
+ this.encryptForced = 1;
+ this.signForced = 1;
+ }
+ else {
+ if (this.signForced != inputObj.sign) {
+ this.dirty = 2;
+ this.signForced = inputObj.sign;
+ }
+
+ this.dirty = 2;
+
+ this.encryptForced = inputObj.encrypt;
+ }
+
+ //this.processFinalState();
+ },
+ */
+
+ addRecipients(toAddrList, recList) {
+ for (var i = 0; i < recList.length; i++) {
+ try {
+ toAddrList.push(
+ EnigmailFuncs.stripEmail(recList[i].replace(/[",]/g, ""))
+ );
+ } catch (ex) {}
+ }
+ },
+
+ setDraftStatus(doEncrypt) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.setDraftStatus - enabling draft mode\n"
+ );
+
+ // Draft Status:
+ // N (for new style) plus 5 digits:
+ // 1: encryption
+ // 2: signing
+ // 3: PGP/MIME
+ // 4: attach own key
+ // 5: subject encrypted
+
+ var draftStatus = "N";
+
+ // Encryption:
+ // 2 -> required/enabled
+ // 0 -> disabled
+
+ if (!gUserTouchedSendEncrypted && !gIsRelatedToEncryptedOriginal) {
+ // After opening draft, it's allowed to use automatic decision.
+ draftStatus += "1";
+ } else {
+ // After opening draft, use the same state that is set now.
+ draftStatus += gSendEncrypted ? "2" : "0";
+ }
+
+ if (!gUserTouchedSendSigned) {
+ // After opening draft, it's allowed to use automatic decision.
+ draftStatus += "1";
+ } else {
+ // After opening draft, use the same state that is set now.
+ // Signing:
+ // 2 -> enabled
+ // 0 -> disabled
+ draftStatus += gSendSigned ? "2" : "0";
+ }
+
+ // MIME/technology
+ // ENIG_FORCE_SMIME == 3 -> S/MIME
+ // ENIG_FORCE_ALWAYS == 2 -> PGP/MIME
+ // 0 -> PGP inline
+ if (gSelectedTechnologyIsPGP) {
+ // inline signing currently not implemented
+ draftStatus += "2";
+ } else {
+ draftStatus += "3";
+ }
+
+ if (!gUserTouchedAttachMyPubKey) {
+ draftStatus += "2";
+ } else {
+ draftStatus += gAttachMyPublicPGPKey ? "1" : "0";
+ }
+
+ if (!gUserTouchedEncryptSubject) {
+ draftStatus += "2";
+ } else {
+ draftStatus += gSendEncrypted && gEncryptSubject ? "1" : "0";
+ }
+
+ this.setAdditionalHeader("X-Enigmail-Draft-Status", draftStatus);
+ },
+
+ getSenderUserId() {
+ let keyId = gCurrentIdentity?.getUnicharAttribute("openpgp_key_id");
+ return keyId ? "0x" + keyId : null;
+ },
+
+ /**
+ * Determine if S/MIME or OpenPGP should be used
+ *
+ * @param sendFlags: Number - input send flags.
+ *
+ * @return: Boolean:
+ * 1: use OpenPGP
+ * 0: use S/MIME
+ */
+ /*
+ preferPgpOverSmime: function(sendFlags) {
+
+ let si = Enigmail.msg.getSecurityParams(null);
+ let isSmime = !EnigmailMimeEncrypt.isEnigmailCompField(si);
+
+ if (isSmime &&
+ (sendFlags & (EnigmailConstants.SEND_SIGNED | EnigmailConstants.SEND_ENCRYPTED))) {
+
+ if (si.requireEncryptMessage || si.signMessage) {
+
+ if (sendFlags & EnigmailConstants.SAVE_MESSAGE) {
+ // use S/MIME if it's enabled for saving drafts
+ return 0;
+ }
+ else {
+ return this.mimePreferOpenPGP;
+ }
+ }
+ }
+
+ return 1;
+ },
+ */
+
+ /* Manage the wrapping of inline signed mails
+ *
+ * @wrapresultObj: Result:
+ * @wrapresultObj.cancelled, true if send operation is to be cancelled, else false
+ * @wrapresultObj.usePpgMime, true if message send option was changed to PGP/MIME, else false
+ */
+
+ async wrapInLine(wrapresultObj) {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: WrapInLine\n");
+ wrapresultObj.cancelled = false;
+ wrapresultObj.usePpgMime = false;
+ try {
+ const dce = Ci.nsIDocumentEncoder;
+ var editor = gMsgCompose.editor.QueryInterface(Ci.nsIEditorMailSupport);
+ var encoderFlags = dce.OutputFormatted | dce.OutputLFLineBreak;
+
+ var wrapWidth = Services.prefs.getIntPref("mailnews.wraplength");
+ if (wrapWidth > 0 && wrapWidth < 68 && editor.wrapWidth > 0) {
+ if (
+ EnigmailDialog.confirmDlg(
+ window,
+ await l10nOpenPGP.formatValue("minimal-line-wrapping", {
+ width: wrapWidth,
+ })
+ )
+ ) {
+ wrapWidth = 68;
+ Services.prefs.setIntPref("mailnews.wraplength", wrapWidth);
+ }
+ }
+
+ if (wrapWidth && editor.wrapWidth > 0) {
+ // First use standard editor wrap mechanism:
+ editor.wrapWidth = wrapWidth - 2;
+ editor.rewrap(true);
+ editor.wrapWidth = wrapWidth;
+
+ // Now get plaintext from editor
+ var wrapText = this.editorGetContentAs("text/plain", encoderFlags);
+
+ // split the lines into an array
+ wrapText = wrapText.split(/\r\n|\r|\n/g);
+
+ var i = 0;
+ var excess = 0;
+ // inspect all lines of mail text to detect if we still have excessive lines which the "standard" editor wrapper leaves
+ for (i = 0; i < wrapText.length; i++) {
+ if (wrapText[i].length > wrapWidth) {
+ excess = 1;
+ }
+ }
+
+ if (excess) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Excess lines detected\n"
+ );
+ var resultObj = {};
+ window.openDialog(
+ "chrome://openpgp/content/ui/enigmailWrapSelection.xhtml",
+ "",
+ "dialog,modal,centerscreen",
+ resultObj
+ );
+ try {
+ if (resultObj.cancelled) {
+ // cancel pressed -> do not send, return instead.
+ wrapresultObj.cancelled = true;
+ return;
+ }
+ } catch (ex) {
+ // cancel pressed -> do not send, return instead.
+ wrapresultObj.cancelled = true;
+ return;
+ }
+
+ var limitedLine = "";
+ var restOfLine = "";
+
+ var WrapSelect = resultObj.Select;
+ switch (WrapSelect) {
+ case "0": // Selection: Force rewrap
+ for (i = 0; i < wrapText.length; i++) {
+ if (wrapText[i].length > wrapWidth) {
+ // If the current line is too long, limit it hard to wrapWidth and insert the rest as the next line into wrapText array
+ limitedLine = wrapText[i].slice(0, wrapWidth);
+ restOfLine = wrapText[i].slice(wrapWidth);
+
+ // We should add quotes at the beginning of "restOfLine", if limitedLine is a quoted line
+ // However, this would be purely academic, because limitedLine will always be "standard"-wrapped
+ // by the editor-rewrapper at the space between quote sign (>) and the quoted text.
+
+ wrapText.splice(i, 1, limitedLine, restOfLine);
+ }
+ }
+ break;
+ case "1": // Selection: Send as is
+ break;
+ case "2": // Selection: Use MIME
+ wrapresultObj.usePpgMime = true;
+ break;
+ case "3": // Selection: Edit manually -> do not send, return instead.
+ wrapresultObj.cancelled = true;
+ return;
+ } //switch
+ }
+ // Now join all lines together again and feed it back into the compose editor.
+ var newtext = wrapText.join("\n");
+ this.replaceEditorText(newtext);
+ }
+ } catch (ex) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Exception while wrapping=" + ex + "\n"
+ );
+ }
+ },
+
+ // Save draft message. We do not want most of the other processing for encrypted mails here...
+ async saveDraftMessage(senderKeyIsGnuPG) {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: saveDraftMessage()\n");
+
+ // If we have an encryption key configured, then encrypt saved
+ // drafts by default, as a precaution. This is independent from the
+ // final decision of sending the message encrypted or not.
+ // However, we allow the user to disable encrypted drafts.
+ let doEncrypt =
+ Enigmail.msg.isEnigmailEnabledForIdentity() &&
+ gCurrentIdentity.autoEncryptDrafts;
+
+ this.setDraftStatus(doEncrypt);
+
+ if (!doEncrypt) {
+ try {
+ let p = Enigmail.msg.getSecurityParams();
+ if (EnigmailMimeEncrypt.isEnigmailCompField(p)) {
+ p.wrappedJSObject.sendFlags = 0;
+ }
+ } catch (ex) {
+ console.debug(ex);
+ }
+
+ return true;
+ }
+
+ let sendFlags =
+ EnigmailConstants.SEND_PGP_MIME |
+ EnigmailConstants.SEND_ENCRYPTED |
+ EnigmailConstants.SEND_ENCRYPT_TO_SELF |
+ EnigmailConstants.SAVE_MESSAGE;
+
+ if (gEncryptSubject) {
+ sendFlags |= EnigmailConstants.ENCRYPT_SUBJECT;
+ }
+ if (senderKeyIsGnuPG) {
+ sendFlags |= EnigmailConstants.SEND_SENDER_KEY_EXTERNAL;
+ }
+
+ let fromAddr = this.getSenderUserId();
+
+ let enigmailSvc = EnigmailCore.getService(window);
+ if (!enigmailSvc) {
+ return true;
+ }
+
+ let senderKeyUsable = await EnigmailEncryption.determineOwnKeyUsability(
+ sendFlags,
+ fromAddr,
+ senderKeyIsGnuPG
+ );
+ if (senderKeyUsable.errorMsg) {
+ let fullAlert = await document.l10n.formatValue(
+ "msg-compose-cannot-save-draft"
+ );
+ fullAlert += " - " + senderKeyUsable.errorMsg;
+ EnigmailDialog.alert(window, fullAlert);
+ return false;
+ }
+
+ //if (this.preferPgpOverSmime(sendFlags) === 0) return true; // use S/MIME
+
+ let secInfo;
+
+ let param = Enigmail.msg.getSecurityParams();
+
+ if (EnigmailMimeEncrypt.isEnigmailCompField(param)) {
+ secInfo = param.wrappedJSObject;
+ } else {
+ try {
+ secInfo = EnigmailMimeEncrypt.createMimeEncrypt(param);
+ if (secInfo) {
+ Enigmail.msg.setSecurityParams(secInfo);
+ }
+ } catch (ex) {
+ EnigmailLog.writeException(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.saveDraftMessage",
+ ex
+ );
+ return false;
+ }
+ }
+
+ secInfo.sendFlags = sendFlags;
+ secInfo.UIFlags = 0;
+ secInfo.senderEmailAddr = fromAddr;
+ secInfo.recipients = "";
+ secInfo.bccRecipients = "";
+ secInfo.originalSubject = gMsgCompose.compFields.subject;
+ this.dirty = 1;
+
+ if (sendFlags & EnigmailConstants.ENCRYPT_SUBJECT) {
+ gMsgCompose.compFields.subject = "";
+ }
+
+ return true;
+ },
+
+ createEnigmailSecurityFields(oldSecurityInfo) {
+ let newSecurityInfo = EnigmailMimeEncrypt.createMimeEncrypt(
+ Enigmail.msg.getSecurityParams()
+ );
+
+ if (!newSecurityInfo) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ Enigmail.msg.setSecurityParams(newSecurityInfo);
+ },
+
+ /*
+ sendSmimeEncrypted: function(msgSendType, sendFlags, isOffline) {
+ let recList;
+ let toAddrList = [];
+ let arrLen = {};
+ const DeliverMode = Ci.nsIMsgCompDeliverMode;
+
+ switch (msgSendType) {
+ case DeliverMode.SaveAsDraft:
+ case DeliverMode.SaveAsTemplate:
+ case DeliverMode.AutoSaveAsDraft:
+ break;
+ default:
+ if (gAttachMyPublicPGPKey) {
+ await this.attachOwnKey();
+ Attachments2CompFields(gMsgCompose.compFields); // update list of attachments
+ }
+ }
+
+ gSMFields.signMessage = (sendFlags & EnigmailConstants.SEND_SIGNED ? true : false);
+ gSMFields.requireEncryptMessage = (sendFlags & EnigmailConstants.SEND_ENCRYPTED ? true : false);
+
+ Enigmail.msg.setSecurityParams(gSMFields);
+
+ let conf = this.isSendConfirmationRequired(sendFlags);
+
+ if (conf === null) return false;
+ if (conf) {
+ // confirm before send requested
+ let msgCompFields = gMsgCompose.compFields;
+ let splitRecipients = msgCompFields.splitRecipients;
+
+ if (msgCompFields.to.length > 0) {
+ recList = splitRecipients(msgCompFields.to, true, arrLen);
+ this.addRecipients(toAddrList, recList);
+ }
+
+ if (msgCompFields.cc.length > 0) {
+ recList = splitRecipients(msgCompFields.cc, true, arrLen);
+ this.addRecipients(toAddrList, recList);
+ }
+
+ switch (msgSendType) {
+ case DeliverMode.SaveAsDraft:
+ case DeliverMode.SaveAsTemplate:
+ case DeliverMode.AutoSaveAsDraft:
+ break;
+ default:
+ if (!this.confirmBeforeSend(toAddrList.join(", "), "", sendFlags, isOffline)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ },
+ */
+
+ getEncryptionFlags() {
+ let f = 0;
+
+ if (gSendEncrypted) {
+ f |= EnigmailConstants.SEND_ENCRYPTED;
+ } else {
+ f &= ~EnigmailConstants.SEND_ENCRYPTED;
+ }
+
+ if (gSendSigned) {
+ f |= EnigmailConstants.SEND_SIGNED;
+ } else {
+ f &= ~EnigmailConstants.SEND_SIGNED;
+ }
+
+ if (gSendEncrypted && gSendSigned) {
+ if (Services.prefs.getBoolPref("mail.openpgp.separate_mime_layers")) {
+ f |= EnigmailConstants.SEND_TWO_MIME_LAYERS;
+ }
+ }
+
+ if (gSendEncrypted && gEncryptSubject) {
+ f |= EnigmailConstants.ENCRYPT_SUBJECT;
+ }
+
+ return f;
+ },
+
+ resetDirty() {
+ let newSecurityInfo = null;
+
+ if (this.dirty) {
+ // make sure the sendFlags are reset before the message is processed
+ // (it may have been set by a previously cancelled send operation!)
+
+ let si = Enigmail.msg.getSecurityParams();
+
+ if (EnigmailMimeEncrypt.isEnigmailCompField(si)) {
+ si.sendFlags = 0;
+ si.originalSubject = gMsgCompose.compFields.subject;
+ } else {
+ try {
+ newSecurityInfo = EnigmailMimeEncrypt.createMimeEncrypt(si);
+ if (newSecurityInfo) {
+ newSecurityInfo.sendFlags = 0;
+ newSecurityInfo.originalSubject = gMsgCompose.compFields.subject;
+
+ Enigmail.msg.setSecurityParams(newSecurityInfo);
+ }
+ } catch (ex) {
+ EnigmailLog.writeException(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.resetDirty",
+ ex
+ );
+ }
+ }
+ }
+
+ return newSecurityInfo;
+ },
+
+ async determineMsgRecipients(sendFlags) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.determineMsgRecipients: currentId=" +
+ gCurrentIdentity +
+ ", " +
+ gCurrentIdentity.email +
+ "\n"
+ );
+
+ let fromAddr = gCurrentIdentity.email;
+ let toAddrList = [];
+ let recList;
+ let bccAddrList = [];
+ let arrLen = {};
+ let splitRecipients;
+
+ if (!Enigmail.msg.isEnigmailEnabledForIdentity()) {
+ return true;
+ }
+
+ let optSendFlags = 0;
+ let msgCompFields = gMsgCompose.compFields;
+ let newsgroups = msgCompFields.newsgroups;
+
+ if (Services.prefs.getBoolPref("temp.openpgp.encryptToSelf")) {
+ optSendFlags |= EnigmailConstants.SEND_ENCRYPT_TO_SELF;
+ }
+
+ sendFlags |= optSendFlags;
+
+ var userIdValue = this.getSenderUserId();
+ if (userIdValue) {
+ fromAddr = userIdValue;
+ }
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.determineMsgRecipients:gMsgCompose=" +
+ gMsgCompose +
+ "\n"
+ );
+
+ splitRecipients = msgCompFields.splitRecipients;
+
+ if (msgCompFields.to.length > 0) {
+ recList = splitRecipients(msgCompFields.to, true, arrLen);
+ this.addRecipients(toAddrList, recList);
+ }
+
+ if (msgCompFields.cc.length > 0) {
+ recList = splitRecipients(msgCompFields.cc, true, arrLen);
+ this.addRecipients(toAddrList, recList);
+ }
+
+ // We allow sending to BCC recipients, we assume the user interface
+ // has warned the user that there is no privacy of BCC recipients.
+ if (msgCompFields.bcc.length > 0) {
+ recList = splitRecipients(msgCompFields.bcc, true, arrLen);
+ this.addRecipients(bccAddrList, recList);
+ }
+
+ if (newsgroups) {
+ toAddrList.push(newsgroups);
+
+ if (sendFlags & EnigmailConstants.SEND_ENCRYPTED) {
+ if (!Services.prefs.getBoolPref("temp.openpgp.encryptToNews")) {
+ document.l10n.formatValue("sending-news").then(value => {
+ EnigmailDialog.alert(window, value);
+ });
+ return false;
+ } else if (
+ !EnigmailDialog.confirmBoolPref(
+ window,
+ await l10nOpenPGP.formatValue("send-to-news-warning"),
+ "temp.openpgp.warnOnSendingNewsgroups",
+ await l10nOpenPGP.formatValue("msg-compose-button-send")
+ )
+ ) {
+ return false;
+ }
+ }
+ }
+
+ return {
+ sendFlags,
+ optSendFlags,
+ fromAddr,
+ toAddrList,
+ bccAddrList,
+ };
+ },
+
+ prepareSending(sendFlags, toAddrStr, gpgKeys, isOffline) {
+ // perform confirmation dialog if necessary/requested
+ if (
+ sendFlags & EnigmailConstants.SEND_WITH_CHECK &&
+ !this.messageSendCheck()
+ ) {
+ // Abort send
+ if (!this.processed) {
+ this.removeAttachedKey();
+ }
+
+ return false;
+ }
+
+ return true;
+ },
+
+ prepareSecurityInfo(
+ sendFlags,
+ uiFlags,
+ rcpt,
+ newSecurityInfo,
+ autocryptGossipHeaders
+ ) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.prepareSecurityInfo(): Using PGP/MIME, flags=" +
+ sendFlags +
+ "\n"
+ );
+
+ let oldSecurityInfo = Enigmail.msg.getSecurityParams();
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.prepareSecurityInfo: oldSecurityInfo = " +
+ oldSecurityInfo +
+ "\n"
+ );
+
+ if (!newSecurityInfo) {
+ this.createEnigmailSecurityFields(Enigmail.msg.getSecurityParams());
+ newSecurityInfo = Enigmail.msg.getSecurityParams().wrappedJSObject;
+ }
+
+ newSecurityInfo.originalSubject = gMsgCompose.compFields.subject;
+ newSecurityInfo.originalReferences = gMsgCompose.compFields.references;
+
+ if (sendFlags & EnigmailConstants.SEND_ENCRYPTED) {
+ if (sendFlags & EnigmailConstants.ENCRYPT_SUBJECT) {
+ gMsgCompose.compFields.subject = "";
+ }
+
+ if (Services.prefs.getBoolPref("temp.openpgp.protectReferencesHdr")) {
+ gMsgCompose.compFields.references = "";
+ }
+ }
+
+ newSecurityInfo.sendFlags = sendFlags;
+ newSecurityInfo.UIFlags = uiFlags;
+ newSecurityInfo.senderEmailAddr = rcpt.fromAddr;
+ newSecurityInfo.bccRecipients = rcpt.bccAddrStr;
+ newSecurityInfo.autocryptGossipHeaders = autocryptGossipHeaders;
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.prepareSecurityInfo: securityInfo = " +
+ newSecurityInfo +
+ "\n"
+ );
+ return newSecurityInfo;
+ },
+
+ async prepareSendMsg(msgSendType) {
+ // msgSendType: value from nsIMsgCompDeliverMode
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.prepareSendMsg: msgSendType=" +
+ msgSendType +
+ ", gSendSigned=" +
+ gSendSigned +
+ ", gSendEncrypted=" +
+ gSendEncrypted +
+ "\n"
+ );
+
+ const SIGN = EnigmailConstants.SEND_SIGNED;
+ const ENCRYPT = EnigmailConstants.SEND_ENCRYPTED;
+ const DeliverMode = Ci.nsIMsgCompDeliverMode;
+
+ var ioService = Services.io;
+ // EnigSend: Handle both plain and encrypted messages below
+ var isOffline = ioService && ioService.offline;
+
+ let senderKeyIsGnuPG =
+ Services.prefs.getBoolPref("mail.openpgp.allow_external_gnupg") &&
+ gCurrentIdentity.getBoolAttribute("is_gnupg_key_id");
+
+ let sendFlags = this.getEncryptionFlags();
+
+ switch (msgSendType) {
+ case DeliverMode.SaveAsDraft:
+ case DeliverMode.SaveAsTemplate:
+ case DeliverMode.AutoSaveAsDraft:
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.prepareSendMsg: detected save draft\n"
+ );
+
+ // saving drafts is simpler and works differently than the rest of Enigmail.
+ // All rules except account-settings are ignored.
+ return this.saveDraftMessage(senderKeyIsGnuPG);
+ }
+
+ this.unsetAdditionalHeader("x-enigmail-draft-status");
+
+ let msgCompFields = gMsgCompose.compFields;
+ let newsgroups = msgCompFields.newsgroups; // Check if sending to any newsgroups
+
+ if (
+ msgCompFields.to === "" &&
+ msgCompFields.cc === "" &&
+ msgCompFields.bcc === "" &&
+ newsgroups === ""
+ ) {
+ // don't attempt to send message if no recipient specified
+ var bundle = document.getElementById("bundle_composeMsgs");
+ EnigmailDialog.alert(window, bundle.getString("12511"));
+ return false;
+ }
+
+ let senderKeyId = gCurrentIdentity.getUnicharAttribute("openpgp_key_id");
+
+ if ((gSendEncrypted || gSendSigned) && !senderKeyId) {
+ let msgId = gSendEncrypted
+ ? "cannot-send-enc-because-no-own-key"
+ : "cannot-send-sig-because-no-own-key";
+ let fullAlert = await document.l10n.formatValue(msgId, {
+ key: gCurrentIdentity.email,
+ });
+ EnigmailDialog.alert(window, fullAlert);
+ return false;
+ }
+
+ if (senderKeyIsGnuPG) {
+ sendFlags |= EnigmailConstants.SEND_SENDER_KEY_EXTERNAL;
+ }
+
+ if ((gSendEncrypted || gSendSigned) && senderKeyId) {
+ let senderKeyUsable = await EnigmailEncryption.determineOwnKeyUsability(
+ sendFlags,
+ senderKeyId,
+ senderKeyIsGnuPG
+ );
+ if (senderKeyUsable.errorMsg) {
+ let fullAlert = await document.l10n.formatValue(
+ "cannot-use-own-key-because",
+ {
+ problem: senderKeyUsable.errorMsg,
+ }
+ );
+ EnigmailDialog.alert(window, fullAlert);
+ return false;
+ }
+ }
+
+ let cannotEncryptMissingInfo = false;
+ if (gSendEncrypted) {
+ let canEncryptDetails = await this.determineSendFlags();
+ if (canEncryptDetails.errArray.length != 0) {
+ cannotEncryptMissingInfo = true;
+ }
+ }
+
+ if (gWindowLocked) {
+ EnigmailDialog.alert(
+ window,
+ await document.l10n.formatValue("window-locked")
+ );
+ return false;
+ }
+
+ let newSecurityInfo = this.resetDirty();
+ this.dirty = 1;
+
+ try {
+ this.modifiedAttach = null;
+
+ // fill fromAddr, toAddrList, bcc etc
+ let rcpt = await this.determineMsgRecipients(sendFlags);
+ if (typeof rcpt === "boolean") {
+ return rcpt;
+ }
+ sendFlags = rcpt.sendFlags;
+
+ if (cannotEncryptMissingInfo) {
+ showMessageComposeSecurityStatus(true);
+ return false;
+ }
+
+ if (this.sendPgpMime) {
+ // Use PGP/MIME
+ sendFlags |= EnigmailConstants.SEND_PGP_MIME;
+ }
+
+ let toAddrStr = rcpt.toAddrList.join(", ");
+ let bccAddrStr = rcpt.bccAddrList.join(", ");
+
+ if (gAttachMyPublicPGPKey) {
+ await this.attachOwnKey(senderKeyId);
+ }
+
+ let autocryptGossipHeaders = await this.getAutocryptGossip();
+
+ /*
+ if (this.preferPgpOverSmime(sendFlags) === 0) {
+ // use S/MIME
+ Attachments2CompFields(gMsgCompose.compFields); // update list of attachments
+ sendFlags = 0;
+ return true;
+ }
+ */
+
+ var usingPGPMime =
+ sendFlags & EnigmailConstants.SEND_PGP_MIME &&
+ sendFlags & (ENCRYPT | SIGN);
+
+ // ----------------------- Rewrapping code, taken from function "encryptInline"
+
+ if (sendFlags & ENCRYPT && !usingPGPMime) {
+ throw new Error("Sending encrypted inline not supported!");
+ }
+ if (sendFlags & SIGN && !usingPGPMime && gMsgCompose.composeHTML) {
+ throw new Error(
+ "Sending signed inline only supported for plain text composition!"
+ );
+ }
+
+ // Check wrapping, if sign only and inline and plaintext
+ if (
+ sendFlags & SIGN &&
+ !(sendFlags & ENCRYPT) &&
+ !usingPGPMime &&
+ !gMsgCompose.composeHTML
+ ) {
+ var wrapresultObj = {};
+
+ await this.wrapInLine(wrapresultObj);
+
+ if (wrapresultObj.usePpgMime) {
+ sendFlags |= EnigmailConstants.SEND_PGP_MIME;
+ usingPGPMime = EnigmailConstants.SEND_PGP_MIME;
+ }
+ if (wrapresultObj.cancelled) {
+ return false;
+ }
+ }
+
+ var uiFlags = EnigmailConstants.UI_INTERACTIVE;
+
+ if (usingPGPMime) {
+ uiFlags |= EnigmailConstants.UI_PGP_MIME;
+ }
+
+ if (sendFlags & (ENCRYPT | SIGN) && usingPGPMime) {
+ // Use PGP/MIME
+ newSecurityInfo = this.prepareSecurityInfo(
+ sendFlags,
+ uiFlags,
+ rcpt,
+ newSecurityInfo,
+ autocryptGossipHeaders
+ );
+ newSecurityInfo.recipients = toAddrStr;
+ newSecurityInfo.bccRecipients = bccAddrStr;
+ } else if (!this.processed && sendFlags & (ENCRYPT | SIGN)) {
+ // use inline PGP
+
+ let sendInfo = {
+ sendFlags,
+ fromAddr: rcpt.fromAddr,
+ toAddr: toAddrStr,
+ bccAddr: bccAddrStr,
+ uiFlags,
+ bucketList: document.getElementById("attachmentBucket"),
+ };
+
+ if (!(await this.signInline(sendInfo))) {
+ return false;
+ }
+ }
+
+ // update the list of attachments
+ Attachments2CompFields(msgCompFields);
+
+ if (
+ !this.prepareSending(
+ sendFlags,
+ rcpt.toAddrList.join(", "),
+ toAddrStr + ", " + bccAddrStr,
+ isOffline
+ )
+ ) {
+ return false;
+ }
+
+ if (msgCompFields.characterSet != "ISO-2022-JP") {
+ if (
+ (usingPGPMime && sendFlags & (ENCRYPT | SIGN)) ||
+ (!usingPGPMime && sendFlags & ENCRYPT)
+ ) {
+ try {
+ // make sure plaintext is not changed to 7bit
+ if (typeof msgCompFields.forceMsgEncoding == "boolean") {
+ msgCompFields.forceMsgEncoding = true;
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.prepareSendMsg: enabled forceMsgEncoding\n"
+ );
+ }
+ } catch (ex) {
+ console.debug(ex);
+ }
+ }
+ }
+ } catch (ex) {
+ EnigmailLog.writeException(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.prepareSendMsg",
+ ex
+ );
+ return false;
+ }
+
+ // The encryption process for PGP/MIME messages follows "here". It's
+ // called automatically from nsMsgCompose->sendMsg().
+ // registration for this is done in core.jsm: startup()
+
+ return true;
+ },
+
+ async signInline(sendInfo) {
+ // sign message using inline-PGP
+
+ if (sendInfo.sendFlags & ENCRYPT) {
+ throw new Error("Encryption not supported in inline messages!");
+ }
+ if (gMsgCompose.composeHTML) {
+ throw new Error(
+ "Signing inline only supported for plain text composition!"
+ );
+ }
+
+ const dce = Ci.nsIDocumentEncoder;
+ const SIGN = EnigmailConstants.SEND_SIGNED;
+ const ENCRYPT = EnigmailConstants.SEND_ENCRYPTED;
+
+ var enigmailSvc = EnigmailCore.getService(window);
+ if (!enigmailSvc) {
+ return false;
+ }
+
+ if (Services.prefs.getBoolPref("mail.strictly_mime")) {
+ if (
+ EnigmailDialog.confirmIntPref(
+ window,
+ await l10nOpenPGP.formatValue("quoted-printable-warn"),
+ "temp.openpgp.quotedPrintableWarn"
+ )
+ ) {
+ Services.prefs.setBoolPref("mail.strictly_mime", false);
+ }
+ }
+
+ var sendFlowed = Services.prefs.getBoolPref(
+ "mailnews.send_plaintext_flowed"
+ );
+ var encoderFlags = dce.OutputFormatted | dce.OutputLFLineBreak;
+
+ // plaintext: Wrapping code has been moved to superordinate function prepareSendMsg to enable interactive format switch
+
+ var exitCodeObj = {};
+ var statusFlagsObj = {};
+ var errorMsgObj = {};
+ var exitCode;
+
+ // Get plain text
+ // (Do we need to set the nsIDocumentEncoder.* flags?)
+ var origText = this.editorGetContentAs("text/plain", encoderFlags);
+ if (!origText) {
+ origText = "";
+ }
+
+ if (origText.length > 0) {
+ // Sign/encrypt body text
+
+ var escText = origText; // Copy plain text for possible escaping
+
+ if (sendFlowed) {
+ // Prevent space stuffing a la RFC 2646 (format=flowed).
+
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: escText["+encoderFlags+"] = '"+escText+"'\n");
+
+ escText = escText.replace(/^From /gm, "~From ");
+ escText = escText.replace(/^>/gm, "|");
+ escText = escText.replace(/^[ \t]+$/gm, "");
+ escText = escText.replace(/^ /gm, "~ ");
+
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: escText = '"+escText+"'\n");
+ // Replace plain text and get it again
+ this.replaceEditorText(escText);
+
+ escText = this.editorGetContentAs("text/plain", encoderFlags);
+ }
+
+ // Replace plain text and get it again (to avoid linewrapping problems)
+ this.replaceEditorText(escText);
+
+ escText = this.editorGetContentAs("text/plain", encoderFlags);
+
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: escText["+encoderFlags+"] = '"+escText+"'\n");
+
+ var charset = this.editorGetCharset();
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.signInline: charset=" +
+ charset +
+ "\n"
+ );
+
+ // Encode plaintext to charset from unicode
+ var plainText = EnigmailData.convertFromUnicode(escText, charset);
+
+ // this will sign, not encrypt
+ var cipherText = EnigmailEncryption.encryptMessage(
+ window,
+ sendInfo.uiFlags,
+ plainText,
+ sendInfo.fromAddr,
+ sendInfo.toAddr,
+ sendInfo.bccAddr,
+ sendInfo.sendFlags,
+ exitCodeObj,
+ statusFlagsObj,
+ errorMsgObj
+ );
+
+ exitCode = exitCodeObj.value;
+
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: cipherText = '"+cipherText+"'\n");
+ if (cipherText && exitCode === 0) {
+ // Encryption/signing succeeded; overwrite plaintext
+
+ cipherText = cipherText.replace(/\r\n/g, "\n");
+
+ // Decode ciphertext from charset to unicode and overwrite
+ this.replaceEditorText(
+ EnigmailData.convertToUnicode(cipherText, charset)
+ );
+
+ // Save original text (for undo)
+ this.processed = {
+ origText,
+ charset,
+ };
+ } else {
+ // Restore original text
+ this.replaceEditorText(origText);
+
+ if (sendInfo.sendFlags & SIGN) {
+ // Encryption/signing failed
+
+ this.sendAborted(window, errorMsgObj);
+ return false;
+ }
+ }
+ }
+
+ return true;
+ },
+
+ async sendAborted(window, errorMsgObj) {
+ if (errorMsgObj && errorMsgObj.value) {
+ var txt = errorMsgObj.value;
+ var txtLines = txt.split(/\r?\n/);
+ var errorMsg = "";
+ for (var i = 0; i < txtLines.length; ++i) {
+ var line = txtLines[i];
+ var tokens = line.split(/ /);
+ // process most important business reasons for invalid recipient (and sender) errors:
+ if (
+ tokens.length == 3 &&
+ (tokens[0] == "INV_RECP" || tokens[0] == "INV_SGNR")
+ ) {
+ var reason = tokens[1];
+ var key = tokens[2];
+ if (reason == "10") {
+ errorMsg +=
+ (await document.l10n.formatValue("key-not-trusted", { key })) +
+ "\n";
+ } else if (reason == "1") {
+ errorMsg +=
+ (await document.l10n.formatValue("key-not-found", { key })) +
+ "\n";
+ } else if (reason == "4") {
+ errorMsg +=
+ (await document.l10n.formatValue("key-revoked", { key })) + "\n";
+ } else if (reason == "5") {
+ errorMsg +=
+ (await document.l10n.formatValue("key-expired", { key })) + "\n";
+ }
+ }
+ }
+ if (errorMsg !== "") {
+ txt = errorMsg + "\n" + txt;
+ }
+ EnigmailDialog.info(
+ window,
+ (await document.l10n.formatValue("send-aborted")) + "\n" + txt
+ );
+ } else {
+ let [title, message] = await document.l10n.formatValues([
+ { id: "send-aborted" },
+ { id: "msg-compose-internal-error" },
+ ]);
+ EnigmailDialog.info(window, title + "\n" + message);
+ }
+ },
+
+ messageSendCheck() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.messageSendCheck\n"
+ );
+
+ try {
+ var warn = Services.prefs.getBoolPref("mail.warn_on_send_accel_key");
+
+ if (warn) {
+ var checkValue = {
+ value: false,
+ };
+ var bundle = document.getElementById("bundle_composeMsgs");
+ var buttonPressed = EnigmailDialog.getPromptSvc().confirmEx(
+ window,
+ bundle.getString("sendMessageCheckWindowTitle"),
+ bundle.getString("sendMessageCheckLabel"),
+ EnigmailDialog.getPromptSvc().BUTTON_TITLE_IS_STRING *
+ EnigmailDialog.getPromptSvc().BUTTON_POS_0 +
+ EnigmailDialog.getPromptSvc().BUTTON_TITLE_CANCEL *
+ EnigmailDialog.getPromptSvc().BUTTON_POS_1,
+ bundle.getString("sendMessageCheckSendButtonLabel"),
+ null,
+ null,
+ bundle.getString("CheckMsg"),
+ checkValue
+ );
+ if (buttonPressed !== 0) {
+ return false;
+ }
+ if (checkValue.value) {
+ Services.prefs.setBoolPref("mail.warn_on_send_accel_key", false);
+ }
+ }
+ } catch (ex) {}
+
+ return true;
+ },
+
+ /**
+ * set non-standard message Header
+ * (depending on TB version)
+ *
+ * hdr: String: header type (e.g. X-Enigmail-Version)
+ * val: String: header data (e.g. 1.2.3.4)
+ */
+ setAdditionalHeader(hdr, val) {
+ if ("otherRandomHeaders" in gMsgCompose.compFields) {
+ // TB <= 36
+ gMsgCompose.compFields.otherRandomHeaders += hdr + ": " + val + "\r\n";
+ } else {
+ gMsgCompose.compFields.setHeader(hdr, val);
+ }
+ },
+
+ unsetAdditionalHeader(hdr) {
+ gMsgCompose.compFields.deleteHeader(hdr);
+ },
+
+ // called just before sending
+ modifyCompFields() {
+ try {
+ if (
+ !Enigmail.msg.isEnigmailEnabledForIdentity() ||
+ !gCurrentIdentity.sendAutocryptHeaders
+ ) {
+ return;
+ }
+ if ((gSendSigned || gSendEncrypted) && !gSelectedTechnologyIsPGP) {
+ // If we're sending an S/MIME message, we don't want to send
+ // the OpenPGP autocrypt header.
+ return;
+ }
+ this.setAutocryptHeader();
+ } catch (ex) {
+ EnigmailLog.writeException(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.modifyCompFields",
+ ex
+ );
+ }
+ },
+
+ getCurrentIncomingServer() {
+ let currentAccountKey = getCurrentAccountKey();
+ let account = MailServices.accounts.getAccount(currentAccountKey);
+
+ return account.incomingServer; /* returns nsIMsgIncomingServer */
+ },
+
+ /**
+ * Obtain all Autocrypt-Gossip header lines that should be included in
+ * the outgoing message, excluding the sender's (from) email address.
+ * If there is just one recipient (ignoring the from address),
+ * no headers will be returned.
+ *
+ * @returns {string} - All header lines including line endings,
+ * could be the empty string.
+ */
+ async getAutocryptGossip() {
+ let fromMail = EnigmailFuncs.stripEmail(gMsgCompose.compFields.from);
+ let replyToMail = EnigmailFuncs.stripEmail(gMsgCompose.compFields.replyTo);
+
+ let optionalReplyToGossip = "";
+ if (replyToMail != fromMail) {
+ optionalReplyToGossip = ", " + gMsgCompose.compFields.replyTo;
+ }
+
+ // Assumes that extractHeaderAddressMailboxes will separate all
+ // entries with the sequence comma-space.
+ let allEmails = MailServices.headerParser
+ .extractHeaderAddressMailboxes(
+ gMsgCompose.compFields.to +
+ ", " +
+ gMsgCompose.compFields.cc +
+ optionalReplyToGossip
+ )
+ .split(/, /);
+
+ // Use a Set to ensure we have each address only once.
+ let uniqueEmails = new Set();
+ for (let e of allEmails) {
+ uniqueEmails.add(e);
+ }
+
+ // Potentially to/cc might contain the sender email address.
+ // Remove it, if it's there.
+ uniqueEmails.delete(fromMail);
+
+ // When sending to yourself, only, allEmails.length is 0.
+ // When sending to exactly one other person (with or without
+ // "from" in to/cc), then allEmails.length is 1. In that scenario,
+ // that recipient obviously already has their own key, and doesn't
+ // need the gossip. The sender's key will be included in the
+ // separate autocrypt (non-gossip) header.
+
+ if (uniqueEmails.size < 2) {
+ return "";
+ }
+
+ let gossip = "";
+ for (const email of uniqueEmails) {
+ let k = await EnigmailKeyRing.getRecipientAutocryptKeyForEmail(email);
+ if (!k) {
+ continue;
+ }
+ let keyData =
+ " " + k.replace(/(.{72})/g, "$1\r\n ").replace(/\r\n $/, "");
+ gossip +=
+ "Autocrypt-Gossip: addr=" + email + "; keydata=\r\n" + keyData + "\r\n";
+ }
+
+ return gossip;
+ },
+
+ setAutocryptHeader() {
+ let senderKeyId = gCurrentIdentity.getUnicharAttribute("openpgp_key_id");
+ if (!senderKeyId) {
+ return;
+ }
+
+ let fromMail = gCurrentIdentity.email;
+ try {
+ fromMail = EnigmailFuncs.stripEmail(gMsgCompose.compFields.from);
+ } catch (ex) {}
+
+ let keyData = EnigmailKeyRing.getAutocryptKey("0x" + senderKeyId, fromMail);
+
+ if (keyData) {
+ keyData =
+ " " + keyData.replace(/(.{72})/g, "$1\r\n ").replace(/\r\n $/, "");
+ this.setAdditionalHeader(
+ "Autocrypt",
+ "addr=" + fromMail + "; keydata=\r\n" + keyData
+ );
+ }
+ },
+
+ /**
+ * Handle the 'compose-send-message' event from TB
+ */
+ sendMessageListener(event) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.sendMessageListener\n"
+ );
+
+ let msgcomposeWindow = document.getElementById("msgcomposeWindow");
+ let sendMsgType = Number(msgcomposeWindow.getAttribute("msgtype"));
+
+ if (
+ !(
+ this.sendProcess &&
+ sendMsgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft
+ )
+ ) {
+ this.modifyCompFields();
+ if (!gSelectedTechnologyIsPGP) {
+ return;
+ }
+
+ this.sendProcess = true;
+ //let bc = document.getElementById("enigmail-bc-sendprocess");
+
+ try {
+ const cApi = EnigmailCryptoAPI();
+ let encryptResult = cApi.sync(this.prepareSendMsg(sendMsgType));
+ if (!encryptResult) {
+ this.resetUpdatedFields();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ } catch (ex) {
+ console.error("GenericSendMessage FAILED: " + ex);
+ this.resetUpdatedFields();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ } else {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.sendMessageListener: sending in progress - autosave aborted\n"
+ );
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ this.sendProcess = false;
+ },
+
+ async decryptQuote(interactive) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.decryptQuote: " +
+ interactive +
+ "\n"
+ );
+
+ if (gWindowLocked || this.processed) {
+ return;
+ }
+
+ var enigmailSvc = EnigmailCore.getService(window);
+ if (!enigmailSvc) {
+ return;
+ }
+
+ const dce = Ci.nsIDocumentEncoder;
+ var encoderFlags = dce.OutputFormatted | dce.OutputLFLineBreak;
+
+ var docText = this.editorGetContentAs("text/plain", encoderFlags);
+
+ var blockBegin = docText.indexOf("-----BEGIN PGP ");
+ if (blockBegin < 0) {
+ return;
+ }
+
+ // Determine indentation string
+ var indentBegin = docText.substr(0, blockBegin).lastIndexOf("\n");
+ var indentStr = docText.substring(indentBegin + 1, blockBegin);
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.decryptQuote: indentStr='" +
+ indentStr +
+ "'\n"
+ );
+
+ var beginIndexObj = {};
+ var endIndexObj = {};
+ var indentStrObj = {};
+ var blockType = EnigmailArmor.locateArmoredBlock(
+ docText,
+ 0,
+ indentStr,
+ beginIndexObj,
+ endIndexObj,
+ indentStrObj
+ );
+ if (blockType != "MESSAGE" && blockType != "SIGNED MESSAGE") {
+ return;
+ }
+
+ var beginIndex = beginIndexObj.value;
+ var endIndex = endIndexObj.value;
+
+ var head = docText.substr(0, beginIndex);
+ var tail = docText.substr(endIndex + 1);
+
+ var pgpBlock = docText.substr(beginIndex, endIndex - beginIndex + 1);
+ var indentRegexp;
+
+ if (indentStr) {
+ if (indentStr == "> ") {
+ // replace ">> " with "> > " to allow correct quoting
+ pgpBlock = pgpBlock.replace(/^>>/gm, "> >");
+ }
+
+ // Escape regex chars.
+ let escapedIndent1 = indentStr.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
+
+ // Delete indentation
+ indentRegexp = new RegExp("^" + escapedIndent1, "gm");
+
+ pgpBlock = pgpBlock.replace(indentRegexp, "");
+ //tail = tail.replace(indentRegexp, "");
+
+ if (indentStr.match(/[ \t]*$/)) {
+ indentStr = indentStr.replace(/[ \t]*$/gm, "");
+ // Escape regex chars.
+ let escapedIndent2 = indentStr.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
+ indentRegexp = new RegExp("^" + escapedIndent2 + "$", "gm");
+
+ pgpBlock = pgpBlock.replace(indentRegexp, "");
+ }
+
+ // Handle blank indented lines
+ pgpBlock = pgpBlock.replace(/^[ \t]*>[ \t]*$/gm, "");
+ //tail = tail.replace(/^[ \t]*>[ \t]*$/g, "");
+
+ // Trim leading space in tail
+ tail = tail.replace(/^\s*\n/m, "\n");
+ }
+
+ if (tail.search(/\S/) < 0) {
+ // No non-space characters in tail; delete it
+ tail = "";
+ }
+
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.decryptQuote: pgpBlock='"+pgpBlock+"'\n");
+
+ var charset = this.editorGetCharset();
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.decryptQuote: charset=" +
+ charset +
+ "\n"
+ );
+
+ // Encode ciphertext from unicode to charset
+ var cipherText = EnigmailData.convertFromUnicode(pgpBlock, charset);
+
+ // Decrypt message
+ var signatureObj = {};
+ signatureObj.value = "";
+ var exitCodeObj = {};
+ var statusFlagsObj = {};
+ var userIdObj = {};
+ var keyIdObj = {};
+ var sigDetailsObj = {};
+ var errorMsgObj = {};
+ var blockSeparationObj = {};
+ var encToDetailsObj = {};
+
+ var uiFlags = EnigmailConstants.UI_UNVERIFIED_ENC_OK;
+
+ var plainText = "";
+
+ plainText = EnigmailDecryption.decryptMessage(
+ window,
+ uiFlags,
+ cipherText,
+ null, // date
+ signatureObj,
+ exitCodeObj,
+ statusFlagsObj,
+ keyIdObj,
+ userIdObj,
+ sigDetailsObj,
+ errorMsgObj,
+ blockSeparationObj,
+ encToDetailsObj
+ );
+ // Decode plaintext from charset to unicode
+ plainText = EnigmailData.convertToUnicode(plainText, charset).replace(
+ /\r\n/g,
+ "\n"
+ );
+
+ //if (Services.prefs.getBoolPref("temp.openpgp.keepSettingsForReply")) {
+ if (statusFlagsObj.value & EnigmailConstants.DECRYPTION_OKAY) {
+ //this.setSendMode('encrypt');
+
+ // TODO : Check, when is this code reached?
+ // automatic enabling encryption currently depends on
+ // adjustSignEncryptAfterIdentityChanged to be always reached
+ gIsRelatedToEncryptedOriginal = true;
+ gSendEncrypted = true;
+ updateEncryptionDependencies();
+ }
+ //}
+
+ var exitCode = exitCodeObj.value;
+
+ if (exitCode !== 0) {
+ // Error processing
+ var errorMsg = errorMsgObj.value;
+
+ var statusLines = errorMsg ? errorMsg.split(/\r?\n/) : [];
+
+ var displayMsg;
+ if (statusLines && statusLines.length) {
+ // Display only first ten lines of error message
+ while (statusLines.length > 10) {
+ statusLines.pop();
+ }
+
+ displayMsg = statusLines.join("\n");
+
+ if (interactive) {
+ EnigmailDialog.info(window, displayMsg);
+ }
+ }
+ }
+
+ if (blockType == "MESSAGE" && exitCode === 0 && plainText.length === 0) {
+ plainText = " ";
+ }
+
+ if (!plainText) {
+ if (blockType != "SIGNED MESSAGE") {
+ return;
+ }
+
+ // Extract text portion of clearsign block
+ plainText = EnigmailArmor.extractSignaturePart(
+ pgpBlock,
+ EnigmailConstants.SIGNATURE_TEXT
+ );
+ }
+
+ const nsIMsgCompType = Ci.nsIMsgCompType;
+ var doubleDashSeparator = Services.prefs.getBoolPref(
+ "temp.openpgp.doubleDashSeparator"
+ );
+ if (
+ gMsgCompose.type != nsIMsgCompType.Template &&
+ gMsgCompose.type != nsIMsgCompType.Draft &&
+ doubleDashSeparator
+ ) {
+ var signOffset = plainText.search(/[\r\n]-- +[\r\n]/);
+
+ if (signOffset < 0 && blockType == "SIGNED MESSAGE") {
+ signOffset = plainText.search(/[\r\n]--[\r\n]/);
+ }
+
+ if (signOffset > 0) {
+ // Strip signature portion of quoted message
+ plainText = plainText.substr(0, signOffset + 1);
+ }
+ }
+
+ this.editorSelectAll();
+
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: Enigmail.msg.decryptQuote: plainText='"+plainText+"'\n");
+
+ if (head) {
+ this.editorInsertText(head);
+ }
+
+ var quoteElement;
+
+ if (indentStr) {
+ quoteElement = this.editorInsertAsQuotation(plainText);
+ } else {
+ this.editorInsertText(plainText);
+ }
+
+ if (tail) {
+ this.editorInsertText(tail);
+ }
+
+ if (statusFlagsObj.value & EnigmailConstants.DECRYPTION_OKAY) {
+ this.checkInlinePgpReply(head, tail);
+ }
+
+ if (interactive) {
+ return;
+ }
+
+ // Position cursor
+ var replyOnTop = gCurrentIdentity.replyOnTop;
+
+ if (!indentStr || !quoteElement) {
+ replyOnTop = 1;
+ }
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.decryptQuote: replyOnTop=" +
+ replyOnTop +
+ ", quoteElement=" +
+ quoteElement +
+ "\n"
+ );
+
+ if (this.editor.selectionController) {
+ var selection = this.editor.selectionController;
+ selection.completeMove(false, false); // go to start;
+
+ switch (replyOnTop) {
+ case 0:
+ // Position after quote
+ this.editor.endOfDocument();
+ if (tail) {
+ for (let cPos = 0; cPos < tail.length; cPos++) {
+ selection.characterMove(false, false); // move backwards
+ }
+ }
+ break;
+
+ case 2:
+ // Select quote
+
+ if (head) {
+ for (let cPos = 0; cPos < head.length; cPos++) {
+ selection.characterMove(true, false);
+ }
+ }
+ selection.completeMove(true, true);
+ if (tail) {
+ for (let cPos = 0; cPos < tail.length; cPos++) {
+ selection.characterMove(false, true); // move backwards
+ }
+ }
+ break;
+
+ default:
+ // Position at beginning of document
+
+ if (this.editor) {
+ this.editor.beginningOfDocument();
+ }
+ }
+
+ this.editor.selectionController.scrollSelectionIntoView(
+ Ci.nsISelectionController.SELECTION_NORMAL,
+ Ci.nsISelectionController.SELECTION_ANCHOR_REGION,
+ true
+ );
+ }
+
+ //this.processFinalState();
+ },
+
+ checkInlinePgpReply(head, tail) {
+ const CT = Ci.nsIMsgCompType;
+ let hLines = head.search(/[^\s>]/) < 0 ? 0 : 1;
+
+ if (hLines > 0) {
+ switch (gMsgCompose.type) {
+ case CT.Reply:
+ case CT.ReplyAll:
+ case CT.ReplyToSender:
+ case CT.ReplyToGroup:
+ case CT.ReplyToSenderAndGroup:
+ case CT.ReplyToList: {
+ // if head contains at only a few line of text, we assume it's the
+ // header above the quote (e.g. XYZ wrote:) and the user's signature
+
+ let h = head.split(/\r?\n/);
+ hLines = -1;
+
+ for (let i = 0; i < h.length; i++) {
+ if (h[i].search(/[^\s>]/) >= 0) {
+ hLines++;
+ }
+ }
+ }
+ }
+ }
+
+ if (
+ hLines > 0 &&
+ (!gCurrentIdentity.sigOnReply || gCurrentIdentity.sigBottom)
+ ) {
+ // display warning if no signature on top of message
+ this.displayPartialEncryptedWarning();
+ } else if (hLines > 10) {
+ this.displayPartialEncryptedWarning();
+ } else if (
+ tail.search(/[^\s>]/) >= 0 &&
+ !(gCurrentIdentity.sigOnReply && gCurrentIdentity.sigBottom)
+ ) {
+ // display warning if no signature below message
+ this.displayPartialEncryptedWarning();
+ }
+ },
+
+ editorInsertText(plainText) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.editorInsertText\n"
+ );
+ if (this.editor) {
+ var mailEditor;
+ try {
+ mailEditor = this.editor.QueryInterface(Ci.nsIEditorMailSupport);
+ mailEditor.insertTextWithQuotations(plainText);
+ } catch (ex) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.editorInsertText: no mail editor\n"
+ );
+ this.editor.insertText(plainText);
+ }
+ }
+ },
+
+ editorInsertAsQuotation(plainText) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.editorInsertAsQuotation\n"
+ );
+ if (this.editor) {
+ var mailEditor;
+ try {
+ mailEditor = this.editor.QueryInterface(Ci.nsIEditorMailSupport);
+ } catch (ex) {}
+
+ if (!mailEditor) {
+ return 0;
+ }
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.editorInsertAsQuotation: mailEditor=" +
+ mailEditor +
+ "\n"
+ );
+
+ mailEditor.insertAsCitedQuotation(plainText, "", false);
+
+ return 1;
+ }
+ return 0;
+ },
+
+ isSenderKeyExpired() {
+ const senderKeyId = this.getSenderUserId();
+
+ if (senderKeyId) {
+ const key = EnigmailKeyRing.getKeyById(senderKeyId);
+ return key?.expiryTime && Math.round(Date.now() / 1000) > key.expiryTime;
+ }
+
+ return false;
+ },
+
+ removeNotificationIfPresent(name) {
+ const notif = gComposeNotification.getNotificationWithValue(name);
+ if (notif) {
+ gComposeNotification.removeNotification(notif);
+ }
+ },
+
+ warnUserThatSenderKeyExpired() {
+ const label = {
+ "l10n-id": "openpgp-selection-status-error",
+ "l10n-args": { key: this.getSenderUserId() },
+ };
+
+ const buttons = [
+ {
+ "l10n-id": "settings-context-open-account-settings-item2",
+ callback() {
+ MsgAccountManager(
+ "am-e2e.xhtml",
+ MailServices.accounts.getServersForIdentity(gCurrentIdentity)[0]
+ );
+ Services.wm.getMostRecentWindow("mail:3pane")?.focus();
+ return true;
+ },
+ },
+ ];
+
+ gComposeNotification.appendNotification(
+ "openpgpSenderKeyExpired",
+ {
+ label,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+ },
+
+ warnUserIfSenderKeyExpired() {
+ if (!this.isSenderKeyExpired()) {
+ this.removeNotificationIfPresent("openpgpSenderKeyExpired");
+ return;
+ }
+
+ this.warnUserThatSenderKeyExpired();
+ },
+
+ /**
+ * Display a notification to the user at the bottom of the window
+ *
+ * @param priority: Number - Priority of the message [1 = high (error) ... 3 = low (info)]
+ * @param msgText: String - Text to be displayed in notification bar
+ * @param messageId: String - Unique message type identification
+ * @param detailsText: String - optional text to be displayed by clicking on "Details" button.
+ * if null or "", then the Detail button will no be displayed.
+ */
+ async notifyUser(priority, msgText, messageId, detailsText) {
+ let prio;
+
+ switch (priority) {
+ case 1:
+ prio = gComposeNotification.PRIORITY_CRITICAL_MEDIUM;
+ break;
+ case 3:
+ prio = gComposeNotification.PRIORITY_INFO_MEDIUM;
+ break;
+ default:
+ prio = gComposeNotification.PRIORITY_WARNING_MEDIUM;
+ }
+
+ let buttonArr = [];
+
+ if (detailsText && detailsText.length > 0) {
+ let [accessKey, label] = await document.l10n.formatValues([
+ { id: "msg-compose-details-button-access-key" },
+ { id: "msg-compose-details-button-label" },
+ ]);
+
+ buttonArr.push({
+ accessKey,
+ label,
+ callback(aNotificationBar, aButton) {
+ EnigmailDialog.info(window, detailsText);
+ },
+ });
+ }
+ gComposeNotification.appendNotification(
+ messageId,
+ {
+ label: msgText,
+ priority: prio,
+ },
+ buttonArr
+ );
+ },
+
+ /**
+ * Display a warning message if we are replying to or forwarding
+ * a partially decrypted inline-PGP email
+ */
+ async displayPartialEncryptedWarning() {
+ let [msgLong, msgShort] = await document.l10n.formatValues([
+ { id: "msg-compose-partially-encrypted-inlinePGP" },
+ { id: "msg-compose-partially-encrypted-short" },
+ ]);
+
+ this.notifyUser(1, msgShort, "notifyPartialDecrypt", msgLong);
+ },
+
+ editorSelectAll() {
+ if (this.editor) {
+ this.editor.selectAll();
+ }
+ },
+
+ editorGetCharset() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.editorGetCharset\n"
+ );
+ return this.editor.documentCharacterSet;
+ },
+
+ editorGetContentAs(mimeType, flags) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: Enigmail.msg.editorGetContentAs\n"
+ );
+ if (this.editor) {
+ return this.editor.outputToString(mimeType, flags);
+ }
+
+ return null;
+ },
+
+ async focusChange() {
+ // call original TB function
+ CommandUpdate_MsgCompose();
+
+ var focusedWindow = top.document.commandDispatcher.focusedWindow;
+
+ // we're just setting focus to where it was before
+ if (focusedWindow == Enigmail.msg.lastFocusedWindow) {
+ // skip
+ return;
+ }
+
+ Enigmail.msg.lastFocusedWindow = focusedWindow;
+ },
+
+ /**
+ * Merge multiple Re: Re: into one Re: in message subject
+ */
+ fixMessageSubject() {
+ let subjElem = document.getElementById("msgSubject");
+ if (subjElem) {
+ let r = subjElem.value.replace(/^(Re: )+/, "Re: ");
+ if (r !== subjElem.value) {
+ subjElem.value = r;
+ if (typeof subjElem.oninput === "function") {
+ subjElem.oninput();
+ }
+ }
+ }
+ },
+};
+
+Enigmail.composeStateListener = {
+ NotifyComposeFieldsReady() {
+ // Note: NotifyComposeFieldsReady is only called when a new window is created (i.e. not in case a window object is reused).
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: ECSL.NotifyComposeFieldsReady\n"
+ );
+
+ try {
+ Enigmail.msg.editor = gMsgCompose.editor.QueryInterface(Ci.nsIEditor);
+ } catch (ex) {}
+
+ if (!Enigmail.msg.editor) {
+ return;
+ }
+
+ Enigmail.msg.fixMessageSubject();
+
+ function enigDocStateListener() {}
+
+ enigDocStateListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIDocumentStateListener"]),
+
+ NotifyDocumentWillBeDestroyed() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: EDSL.enigDocStateListener.NotifyDocumentWillBeDestroyed\n"
+ );
+ },
+
+ NotifyDocumentStateChanged(nowDirty) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: EDSL.enigDocStateListener.NotifyDocumentStateChanged\n"
+ );
+ },
+ };
+
+ var docStateListener = new enigDocStateListener();
+
+ Enigmail.msg.editor.addDocumentStateListener(docStateListener);
+ },
+
+ ComposeProcessDone(aResult) {
+ // Note: called after a mail was sent (or saved)
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: ECSL.ComposeProcessDone: " + aResult + "\n"
+ );
+
+ if (aResult != Cr.NS_OK) {
+ Enigmail.msg.removeAttachedKey();
+ }
+
+ // ensure that securityInfo is set back to S/MIME flags (especially required if draft was saved)
+ if (gSMFields) {
+ Enigmail.msg.setSecurityParams(gSMFields);
+ }
+ },
+
+ NotifyComposeBodyReady() {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: ECSL.ComposeBodyReady\n");
+
+ var isEmpty, isEditable;
+
+ isEmpty = Enigmail.msg.editor.documentIsEmpty;
+ isEditable = Enigmail.msg.editor.isDocumentEditable;
+ Enigmail.msg.composeBodyReady = true;
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgComposeOverlay.js: ECSL.ComposeBodyReady: isEmpty=" +
+ isEmpty +
+ ", isEditable=" +
+ isEditable +
+ "\n"
+ );
+
+ /*
+ if (Enigmail.msg.disableSmime) {
+ if (gMsgCompose && gMsgCompose.compFields && Enigmail.msg.getSecurityParams()) {
+ let si = Enigmail.msg.getSecurityParams(null);
+ si.signMessage = false;
+ si.requireEncryptMessage = false;
+ }
+ else {
+ EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: ECSL.ComposeBodyReady: could not disable S/MIME\n");
+ }
+ }
+ */
+
+ if (isEditable && !isEmpty) {
+ if (!Enigmail.msg.timeoutId && !Enigmail.msg.dirty) {
+ Enigmail.msg.timeoutId = setTimeout(function () {
+ Enigmail.msg.decryptQuote(false);
+ }, 0);
+ }
+ }
+
+ // This must be called by the last registered NotifyComposeBodyReady()
+ // stateListener. We need this in order to know when the entire init
+ // sequence of the composeWindow has finished, so the WebExtension compose
+ // API can do its final modifications.
+ window.composeEditorReady = true;
+ window.dispatchEvent(new CustomEvent("compose-editor-ready"));
+ },
+
+ SaveInFolderDone(folderURI) {
+ //EnigmailLog.DEBUG("enigmailMsgComposeOverlay.js: ECSL.SaveInFolderDone\n");
+ },
+};
+
+window.addEventListener(
+ "load",
+ Enigmail.msg.composeStartup.bind(Enigmail.msg),
+ {
+ capture: false,
+ once: true,
+ }
+);
+
+window.addEventListener("compose-window-unload", () => {
+ if (gMsgCompose) {
+ gMsgCompose.UnregisterStateListener(Enigmail.composeStateListener);
+ }
+});
diff --git a/comm/mail/extensions/openpgp/content/ui/enigmailMsgHdrViewOverlay.js b/comm/mail/extensions/openpgp/content/ui/enigmailMsgHdrViewOverlay.js
new file mode 100644
index 0000000000..5bb4619793
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/enigmailMsgHdrViewOverlay.js
@@ -0,0 +1,1214 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from ../../../../base/content/aboutMessage.js */
+/* import-globals-from ../../../../base/content/msgHdrView.js */
+/* import-globals-from ../../../smime/content/msgHdrViewSMIMEOverlay.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailDialog: "chrome://openpgp/content/modules/dialog.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKey: "chrome://openpgp/content/modules/key.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
+ EnigmailMime: "chrome://openpgp/content/modules/mime.jsm",
+ EnigmailMsgRead: "chrome://openpgp/content/modules/msgRead.jsm",
+ EnigmailSingletons: "chrome://openpgp/content/modules/singletons.jsm",
+ EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm",
+ EnigmailVerify: "chrome://openpgp/content/modules/mimeVerify.jsm",
+ EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+ // EnigmailWks: "chrome://openpgp/content/modules/webKey.jsm",
+});
+
+Enigmail.hdrView = {
+ lastEncryptedMsgKey: null,
+ lastEncryptedUri: null,
+ flexbuttonAction: null,
+
+ msgSignedStateString: null,
+ msgEncryptedStateString: null,
+ msgSignatureState: EnigmailConstants.MSG_SIG_NONE,
+ msgEncryptionState: EnigmailConstants.MSG_ENC_NONE,
+ msgSignatureKeyId: "",
+ msgSignatureDate: null,
+ msgEncryptionKeyId: null,
+ msgEncryptionAllKeyIds: null,
+ msgHasKeyAttached: false,
+
+ ignoreStatusFromMimePart: "",
+ receivedStatusFromParts: new Set(),
+
+ reset() {
+ this.msgSignedStateString = null;
+ this.msgEncryptedStateString = null;
+ this.msgSignatureState = EnigmailConstants.MSG_SIG_NONE;
+ this.msgEncryptionState = EnigmailConstants.MSG_ENC_NONE;
+ this.msgSignatureKeyId = "";
+ this.msgSignatureDate = null;
+ this.msgEncryptionKeyId = null;
+ this.msgEncryptionAllKeyIds = null;
+ this.msgHasKeyAttached = false;
+ for (let value of ["decryptionFailed", "brokenExchange"]) {
+ Enigmail.msg.removeNotification(value);
+ }
+ this.ignoreStatusFromMimePart = "";
+ this.receivedStatusFromParts = new Set();
+ },
+
+ hdrViewLoad() {
+ EnigmailLog.DEBUG("enigmailMsgHdrViewOverlay.js: this.hdrViewLoad\n");
+
+ this.msgHdrViewLoad();
+
+ let addrPopup = document.getElementById("emailAddressPopup");
+ if (addrPopup) {
+ addrPopup.addEventListener(
+ "popupshowing",
+ Enigmail.hdrView.displayAddressPopup.bind(addrPopup)
+ );
+ }
+
+ // Thunderbird
+ let attCtx = document.getElementById("attachmentItemContext");
+ if (attCtx) {
+ attCtx.addEventListener(
+ "popupshowing",
+ this.onShowAttachmentContextMenu.bind(Enigmail.hdrView)
+ );
+ }
+ },
+
+ displayAddressPopup(event) {
+ let target = event.target;
+ EnigmailFuncs.collapseAdvanced(target, "hidden");
+ },
+
+ statusBarHide() {
+ /* elements might not have been set yet, so we try and ignore */
+ try {
+ this.reset();
+
+ Enigmail.msg.setAttachmentReveal(null);
+ if (Enigmail.msg.securityInfo) {
+ Enigmail.msg.securityInfo.statusFlags = 0;
+ }
+
+ let bodyElement = document.getElementById("messagepane");
+ bodyElement.removeAttribute("collapsed");
+ } catch (ex) {
+ console.debug(ex);
+ }
+ },
+
+ updatePgpStatus(
+ exitCode,
+ statusFlags,
+ extStatusFlags,
+ keyId,
+ userId,
+ sigDetails,
+ errorMsg,
+ blockSeparation,
+ encToDetails,
+ xtraStatus,
+ mimePartNumber
+ ) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: this.updatePgpStatus: exitCode=" +
+ exitCode +
+ ", statusFlags=" +
+ statusFlags +
+ ", extStatusFlags=" +
+ extStatusFlags +
+ ", keyId=" +
+ keyId +
+ ", userId=" +
+ userId +
+ ", " +
+ errorMsg +
+ "\n"
+ );
+
+ /*
+ if (
+ Enigmail.msg.securityInfo &&
+ Enigmail.msg.securityInfo.xtraStatus &&
+ Enigmail.msg.securityInfo.xtraStatus === "wks-request"
+ ) {
+ return;
+ }
+ */
+
+ if (gMessageURI) {
+ this.lastEncryptedMsgKey = gMessageURI;
+ }
+
+ if (!errorMsg) {
+ errorMsg = "";
+ } else {
+ console.debug("OpenPGP error status: " + errorMsg);
+ }
+
+ var replaceUid = null;
+ if (keyId && gMessage) {
+ replaceUid = EnigmailMsgRead.matchUidToSender(keyId, gMessage.author);
+ }
+
+ if (!replaceUid && userId) {
+ replaceUid = userId.replace(/\n.*$/gm, "");
+ }
+
+ if (
+ Enigmail.msg.savedHeaders &&
+ "x-pgp-encoding-format" in Enigmail.msg.savedHeaders &&
+ Enigmail.msg.savedHeaders["x-pgp-encoding-format"].search(
+ /partitioned/i
+ ) === 0
+ ) {
+ if (currentAttachments && currentAttachments.length) {
+ Enigmail.msg.setAttachmentReveal(currentAttachments);
+ }
+ }
+
+ if (userId && replaceUid) {
+ // no EnigmailData.convertGpgToUnicode here; strings are already UTF-8
+ replaceUid = replaceUid.replace(/\\[xe]3a/gi, ":");
+ errorMsg = errorMsg.replace(userId, replaceUid);
+ }
+
+ var errorLines = "";
+
+ if (exitCode == EnigmailConstants.POSSIBLE_PGPMIME) {
+ exitCode = 0;
+ } else if (errorMsg) {
+ // no EnigmailData.convertGpgToUnicode here; strings are already UTF-8
+ errorLines = errorMsg.split(/\r?\n/);
+ }
+
+ if (errorLines && errorLines.length > 22) {
+ // Retain only first twenty lines and last two lines of error message
+ var lastLines =
+ errorLines[errorLines.length - 2] +
+ "\n" +
+ errorLines[errorLines.length - 1] +
+ "\n";
+
+ while (errorLines.length > 20) {
+ errorLines.pop();
+ }
+
+ errorMsg = errorLines.join("\n") + "\n...\n" + lastLines;
+ }
+
+ let encryptedMimePart = "";
+ if (statusFlags & EnigmailConstants.PGP_MIME_ENCRYPTED) {
+ encryptedMimePart = mimePartNumber;
+ }
+
+ var msgSigned =
+ statusFlags &
+ (EnigmailConstants.BAD_SIGNATURE |
+ EnigmailConstants.GOOD_SIGNATURE |
+ EnigmailConstants.EXPIRED_KEY_SIGNATURE |
+ EnigmailConstants.EXPIRED_SIGNATURE |
+ EnigmailConstants.UNCERTAIN_SIGNATURE |
+ EnigmailConstants.REVOKED_KEY |
+ EnigmailConstants.EXPIRED_KEY_SIGNATURE |
+ EnigmailConstants.EXPIRED_SIGNATURE);
+
+ if (msgSigned && statusFlags & EnigmailConstants.IMPORTED_KEY) {
+ console.debug("unhandled status IMPORTED_KEY");
+ statusFlags &= ~EnigmailConstants.IMPORTED_KEY;
+ }
+
+ // TODO: visualize the following signature attributes,
+ // cross-check with corresponding email attributes
+ // - date
+ // - signer uid
+ // - signer key
+ // - signing and hash alg
+
+ this.msgSignatureKeyId = keyId;
+
+ if (encToDetails) {
+ this.msgEncryptionKeyId = encToDetails.myRecipKey;
+ this.msgEncryptionAllKeyIds = encToDetails.allRecipKeys;
+ }
+
+ this.msgSignatureDate = sigDetails?.sigDate;
+
+ let tmp = {
+ statusFlags,
+ extStatusFlags,
+ keyId,
+ userId,
+ msgSigned,
+ blockSeparation,
+ xtraStatus,
+ encryptedMimePart,
+ };
+ Enigmail.msg.securityInfo = tmp;
+
+ //Enigmail.msg.createArtificialAutocryptHeader();
+
+ /*
+ if (statusFlags & EnigmailConstants.UNCERTAIN_SIGNATURE) {
+ this.tryImportAutocryptHeader();
+ }
+ */
+
+ this.updateStatusFlags(mimePartNumber);
+ this.updateMsgDb();
+ },
+
+ /**
+ * Check whether we got a WKS request
+ */
+ /*
+ checkWksConfirmRequest(jsonStr) {
+ let requestObj;
+ try {
+ requestObj = JSON.parse(jsonStr);
+ } catch (ex) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: checkWksConfirmRequest parsing JSON failed\n"
+ );
+ return;
+ }
+
+ if (
+ "type" in requestObj &&
+ requestObj.type.toLowerCase() === "confirmation-request"
+ ) {
+ EnigmailWks.getWksClientPathAsync(window, function(wksClientPath) {
+ if (!wksClientPath) {
+ return;
+ }
+
+ Enigmail.hdrView.displayFlexAction(
+ "Web Key Directory Confirmation Request",
+ "Confirm Request",
+ "wks-request"
+ );
+ Enigmail.hdrView.displayWksMessage();
+ });
+ } else {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: checkWksConfirmRequest failed condition\n"
+ );
+ }
+ },
+ */
+
+ /**
+ * Display a localized message in lieu of the original message text
+ */
+ /*
+ displayWksMessage() {
+ EnigmailLog.DEBUG("enigmailMsgHdrViewOverlay.js: displayWksMessage()\n");
+
+ if (Enigmail.msg.securityInfo.xtraStatus === "wks-request") {
+ let enigMsgPane = document.getElementById("enigmailMsgDisplay");
+ let bodyElement = document.getElementById("messagepane");
+ bodyElement.setAttribute("collapsed", true);
+ enigMsgPane.removeAttribute("collapsed");
+ enigMsgPane.textContent = "This message has been sent by your email provider to confirm deployment of your OpenPGP public key\nin their Web Key Directory.\nProviding your public key helps others to discover your key and thus being able to encrypt messages to you.\n\nIf you want to deploy your key in the Web Key Directory now, please click on the button "Confirm Request" in the status bar.\nOtherwise, simply ignore this message."
+ );
+ }
+ },
+ */
+
+ /**
+ * Update the various variables that track the OpenPGP status of
+ * the current message.
+ *
+ * @param {string} triggeredByMimePartNumber - the MIME part that
+ * was processed and has triggered this status update request.
+ */
+ async updateStatusFlags(triggeredByMimePartNumber) {
+ let secInfo = Enigmail.msg.securityInfo;
+ let statusFlags = secInfo.statusFlags;
+ let extStatusFlags =
+ "extStatusFlags" in secInfo ? secInfo.extStatusFlags : 0;
+
+ let signed;
+ let encrypted;
+
+ if (
+ statusFlags &
+ (EnigmailConstants.DECRYPTION_FAILED |
+ EnigmailConstants.DECRYPTION_INCOMPLETE)
+ ) {
+ encrypted = "notok";
+ let unhideBar = false;
+ let infoId;
+ if (statusFlags & EnigmailConstants.NO_SECKEY) {
+ this.msgEncryptionState = EnigmailConstants.MSG_ENC_NO_SECRET_KEY;
+
+ unhideBar = true;
+ infoId = "openpgp-cannot-decrypt-because-missing-key";
+ } else {
+ this.msgEncryptionState = EnigmailConstants.MSG_ENC_FAILURE;
+ if (statusFlags & EnigmailConstants.MISSING_MDC) {
+ unhideBar = true;
+ infoId = "openpgp-cannot-decrypt-because-mdc";
+ }
+ }
+
+ if (unhideBar) {
+ Enigmail.msg.notificationBox.appendNotification(
+ "decryptionFailed",
+ {
+ label: await document.l10n.formatValue(infoId),
+ image: "chrome://global/skin/icons/warning.svg",
+ priority: Enigmail.msg.notificationBox.PRIORITY_CRITICAL_MEDIUM,
+ },
+ null
+ );
+ }
+
+ this.msgSignatureState = EnigmailConstants.MSG_SIG_NONE;
+ } else if (statusFlags & EnigmailConstants.DECRYPTION_OKAY) {
+ EnigmailURIs.rememberEncryptedUri(this.lastEncryptedMsgKey);
+ encrypted = "ok";
+ this.msgEncryptionState = EnigmailConstants.MSG_ENC_OK;
+ if (secInfo.xtraStatus && secInfo.xtraStatus == "buggyMailFormat") {
+ console.log(
+ await document.l10n.formatValue("decrypted-msg-with-format-error")
+ );
+ }
+ }
+
+ if (
+ statusFlags &
+ (EnigmailConstants.BAD_SIGNATURE |
+ EnigmailConstants.REVOKED_KEY |
+ EnigmailConstants.EXPIRED_KEY_SIGNATURE |
+ EnigmailConstants.EXPIRED_SIGNATURE)
+ ) {
+ if (statusFlags & EnigmailConstants.INVALID_RECIPIENT) {
+ this.msgSignatureState = EnigmailConstants.MSG_SIG_INVALID_KEY_REJECTED;
+ } else {
+ this.msgSignatureState = EnigmailConstants.MSG_SIG_INVALID;
+ }
+ signed = "notok";
+ } else if (statusFlags & EnigmailConstants.GOOD_SIGNATURE) {
+ if (statusFlags & EnigmailConstants.TRUSTED_IDENTITY) {
+ this.msgSignatureState = EnigmailConstants.MSG_SIG_VALID_KEY_VERIFIED;
+ signed = "verified";
+ } else if (extStatusFlags & EnigmailConstants.EXT_SELF_IDENTITY) {
+ signed = "ok";
+ this.msgSignatureState = EnigmailConstants.MSG_SIG_VALID_SELF;
+ } else {
+ signed = "unverified";
+ this.msgSignatureState = EnigmailConstants.MSG_SIG_VALID_KEY_UNVERIFIED;
+ }
+ } else if (statusFlags & EnigmailConstants.UNCERTAIN_SIGNATURE) {
+ signed = "unknown";
+ if (statusFlags & EnigmailConstants.INVALID_RECIPIENT) {
+ signed = "mismatch";
+ this.msgSignatureState =
+ EnigmailConstants.MSG_SIG_UNCERTAIN_UID_MISMATCH;
+ } else if (statusFlags & EnigmailConstants.NO_PUBKEY) {
+ this.msgSignatureState =
+ EnigmailConstants.MSG_SIG_UNCERTAIN_KEY_UNAVAILABLE;
+ Enigmail.msg.notifySigKeyMissing(secInfo.keyId);
+ } else {
+ this.msgSignatureState =
+ EnigmailConstants.MSG_SIG_UNCERTAIN_KEY_NOT_ACCEPTED;
+ }
+ }
+ // (statusFlags & EnigmailConstants.INLINE_KEY) ???
+
+ if (encrypted) {
+ this.msgEncryptedStateString = encrypted;
+ }
+ if (signed) {
+ this.msgSignedStateString = signed;
+ }
+ this.updateVisibleSecurityStatus(triggeredByMimePartNumber);
+
+ /*
+ // special handling after trying to fix buggy mail format (see buggyExchangeEmailContent in code)
+ if (secInfo.xtraStatus && secInfo.xtraStatus == "buggyMailFormat") {
+ }
+ */
+
+ if (encrypted) {
+ // For telemetry purposes.
+ window.dispatchEvent(
+ new CustomEvent("secureMsgLoaded", {
+ detail: {
+ key: "encrypted-openpgp",
+ data: encrypted,
+ },
+ })
+ );
+ }
+ if (signed) {
+ window.dispatchEvent(
+ new CustomEvent("secureMsgLoaded", {
+ detail: {
+ key: "signed-openpgp",
+ data: signed,
+ },
+ })
+ );
+ }
+ },
+
+ /**
+ * Should be called as soon as it is known that the message has
+ * an OpenPGP key attached.
+ */
+ notifyHasKeyAttached() {
+ this.msgHasKeyAttached = true;
+ this.updateVisibleSecurityStatus();
+ },
+
+ /**
+ * Should be called whenever more information about the OpenPGP
+ * message state became available, such as encryption or signature
+ * status, or the availability of an attached key.
+ *
+ * @param {string} triggeredByMimePartNumber - optional number of the
+ * MIME part that was processed and has triggered this status update
+ * request.
+ */
+ updateVisibleSecurityStatus(triggeredByMimePartNumber = undefined) {
+ setMessageCryptoBox(
+ "OpenPGP",
+ this.msgEncryptedStateString,
+ this.msgSignedStateString,
+ this.msgHasKeyAttached,
+ triggeredByMimePartNumber
+ );
+ },
+
+ editKeyExpiry() {
+ EnigmailWindows.editKeyExpiry(
+ window,
+ [Enigmail.msg.securityInfo.userId],
+ [Enigmail.msg.securityInfo.keyId]
+ );
+ ReloadMessage();
+ },
+
+ editKeyTrust() {
+ let key = EnigmailKeyRing.getKeyById(Enigmail.msg.securityInfo.keyId);
+
+ EnigmailWindows.editKeyTrust(
+ window,
+ [Enigmail.msg.securityInfo.userId],
+ [key.keyId]
+ );
+ ReloadMessage();
+ },
+
+ signKey() {
+ let key = EnigmailKeyRing.getKeyById(Enigmail.msg.securityInfo.keyId);
+
+ EnigmailWindows.signKey(
+ window,
+ Enigmail.msg.securityInfo.userId,
+ key.keyId,
+ null
+ );
+ ReloadMessage();
+ },
+
+ msgHdrViewLoad() {
+ EnigmailLog.DEBUG("enigmailMsgHdrViewOverlay.js: this.msgHdrViewLoad\n");
+
+ this.messageListener = {
+ onStartHeaders() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: _listener_onStartHeaders\n"
+ );
+
+ try {
+ Enigmail.hdrView.statusBarHide();
+ EnigmailVerify.setWindow(window, Enigmail.msg.getCurrentMsgUriSpec());
+
+ let msgFrame = document.getElementById("messagepane").contentDocument;
+
+ if (msgFrame) {
+ msgFrame.addEventListener(
+ "unload",
+ Enigmail.hdrView.messageUnload.bind(Enigmail.hdrView),
+ true
+ );
+ msgFrame.addEventListener(
+ "load",
+ Enigmail.hdrView.messageLoad.bind(Enigmail.hdrView),
+ true
+ );
+ }
+
+ Enigmail.hdrView.forgetEncryptedMsgKey();
+ Enigmail.hdrView.setWindowCallback();
+ } catch (ex) {
+ console.debug(ex);
+ }
+ },
+
+ onEndHeaders() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: _listener_onEndHeaders\n"
+ );
+ },
+
+ onEndAttachments() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: _listener_onEndAttachments\n"
+ );
+
+ try {
+ EnigmailVerify.setWindow(null, null);
+ } catch (ex) {}
+
+ Enigmail.hdrView.messageLoad();
+ },
+
+ beforeStartHeaders() {
+ return true;
+ },
+ };
+
+ gMessageListeners.push(this.messageListener);
+
+ // fire the handlers since some windows open directly with a visible message
+ this.messageListener.onStartHeaders();
+ this.messageListener.onEndAttachments();
+ },
+
+ messageUnload(event) {
+ EnigmailLog.DEBUG("enigmailMsgHdrViewOverlay.js: this.messageUnload\n");
+ if (Enigmail.hdrView.flexbuttonAction === null) {
+ if (Enigmail.msg.securityInfo && Enigmail.msg.securityInfo.xtraStatus) {
+ Enigmail.msg.securityInfo.xtraStatus = "";
+ }
+ this.forgetEncryptedMsgKey();
+ }
+ },
+
+ async messageLoad(event) {
+ EnigmailLog.DEBUG("enigmailMsgHdrViewOverlay.js: this.messageLoad\n");
+
+ await Enigmail.msg.messageAutoDecrypt();
+ Enigmail.msg.handleAttachmentEvent();
+ },
+
+ dispKeyDetails() {
+ if (!Enigmail.msg.securityInfo) {
+ return;
+ }
+
+ let key = EnigmailKeyRing.getKeyById(Enigmail.msg.securityInfo.keyId);
+
+ EnigmailWindows.openKeyDetails(window, key.keyId, false);
+ },
+
+ forgetEncryptedMsgKey() {
+ if (Enigmail.hdrView.lastEncryptedMsgKey) {
+ EnigmailURIs.forgetEncryptedUri(Enigmail.hdrView.lastEncryptedMsgKey);
+ Enigmail.hdrView.lastEncryptedMsgKey = null;
+ }
+
+ if (Enigmail.hdrView.lastEncryptedUri && gEncryptedURIService) {
+ gEncryptedURIService.forgetEncrypted(Enigmail.hdrView.lastEncryptedUri);
+ Enigmail.hdrView.lastEncryptedUri = null;
+ }
+ },
+
+ onShowAttachmentContextMenu(event) {
+ let contextMenu = document.getElementById("attachmentItemContext");
+ let separator = document.getElementById("openpgpCtxItemsSeparator");
+ let decryptOpenMenu = document.getElementById("enigmail_ctxDecryptOpen");
+ let decryptSaveMenu = document.getElementById("enigmail_ctxDecryptSave");
+ let importMenu = document.getElementById("enigmail_ctxImportKey");
+ let verifyMenu = document.getElementById("enigmail_ctxVerifyAtt");
+
+ if (contextMenu.attachments.length == 1) {
+ let attachment = contextMenu.attachments[0];
+
+ if (/^application\/pgp-keys/i.test(attachment.contentType)) {
+ importMenu.hidden = false;
+ decryptOpenMenu.hidden = true;
+ decryptSaveMenu.hidden = true;
+ verifyMenu.hidden = true;
+ } else if (Enigmail.msg.checkEncryptedAttach(attachment)) {
+ if (
+ (typeof attachment.name !== "undefined" &&
+ attachment.name.match(/\.asc\.(gpg|pgp)$/i)) ||
+ (typeof attachment.displayName !== "undefined" &&
+ attachment.displayName.match(/\.asc\.(gpg|pgp)$/i))
+ ) {
+ importMenu.hidden = false;
+ } else {
+ importMenu.hidden = true;
+ }
+ decryptOpenMenu.hidden = false;
+ decryptSaveMenu.hidden = false;
+ if (
+ EnigmailMsgRead.checkSignedAttachment(
+ attachment,
+ null,
+ currentAttachments
+ )
+ ) {
+ verifyMenu.hidden = false;
+ } else {
+ verifyMenu.hidden = true;
+ }
+ if (typeof attachment.displayName == "undefined") {
+ if (!attachment.name) {
+ attachment.name = "message.pgp";
+ }
+ } else if (!attachment.displayName) {
+ attachment.displayName = "message.pgp";
+ }
+ } else if (
+ EnigmailMsgRead.checkSignedAttachment(
+ attachment,
+ null,
+ currentAttachments
+ )
+ ) {
+ importMenu.hidden = true;
+ decryptOpenMenu.hidden = true;
+ decryptSaveMenu.hidden = true;
+
+ verifyMenu.hidden = false;
+ } else {
+ importMenu.hidden = true;
+ decryptOpenMenu.hidden = true;
+ decryptSaveMenu.hidden = true;
+ verifyMenu.hidden = true;
+ }
+
+ separator.hidden =
+ decryptOpenMenu.hidden &&
+ decryptSaveMenu.hidden &&
+ importMenu.hidden &&
+ verifyMenu.hidden;
+ } else {
+ decryptOpenMenu.hidden = true;
+ decryptSaveMenu.hidden = true;
+ importMenu.hidden = true;
+ verifyMenu.hidden = true;
+ separator.hidden = true;
+ }
+ },
+
+ updateMsgDb() {
+ EnigmailLog.DEBUG("enigmailMsgHdrViewOverlay.js: this.updateMsgDb\n");
+ var msg = gMessage;
+ if (!msg || !msg.folder) {
+ return;
+ }
+
+ var msgHdr = msg.folder.GetMessageHeader(msg.messageKey);
+
+ if (this.msgEncryptionState === EnigmailConstants.MSG_ENC_OK) {
+ Enigmail.msg.securityInfo.statusFlags |=
+ EnigmailConstants.DECRYPTION_OKAY;
+ }
+ msgHdr.setUint32Property("enigmail", Enigmail.msg.securityInfo.statusFlags);
+ },
+
+ enigCanDetachAttachments() {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: this.enigCanDetachAttachments\n"
+ );
+
+ var canDetach = true;
+ if (
+ Enigmail.msg.securityInfo &&
+ typeof Enigmail.msg.securityInfo.statusFlags != "undefined"
+ ) {
+ canDetach = !(
+ Enigmail.msg.securityInfo.statusFlags &
+ (EnigmailConstants.PGP_MIME_SIGNED |
+ EnigmailConstants.PGP_MIME_ENCRYPTED)
+ );
+ }
+ return canDetach;
+ },
+
+ setSubject(subject) {
+ // Strip multiple localized Re: prefixes. This emulates NS_MsgStripRE().
+ let prefixes = Services.prefs
+ .getComplexValue("mailnews.localizedRe", Ci.nsIPrefLocalizedString)
+ .data.split(",")
+ .filter(Boolean);
+ if (!prefixes.includes("Re")) {
+ prefixes.push("Re");
+ }
+ // Construct a regular expression like this: ^(Re: |Aw: )+
+ let newSubject = subject.replace(
+ new RegExp(`^(${prefixes.join(": |")}: )+`, "i"),
+ ""
+ );
+ let hadRe = newSubject != subject;
+
+ // Update the message.
+ gMessage.subject = newSubject;
+ let oldFlags = gMessage.flags;
+ if (hadRe) {
+ gMessage.flags |= Ci.nsMsgMessageFlags.HasRe;
+ newSubject = "Re: " + newSubject;
+ }
+ document.title = newSubject;
+ currentHeaderData.subject.headerValue = newSubject;
+ document.getElementById("expandedsubjectBox").headerValue = newSubject;
+ // This even works if the flags haven't changed. Causes repaint in all thread trees.
+ gMessage.folder?.msgDatabase.notifyHdrChangeAll(
+ gMessage,
+ oldFlags,
+ gMessage.flags,
+ {}
+ );
+ },
+
+ updateHdrBox(header, value) {
+ let e = document.getElementById("expanded" + header + "Box");
+ if (e) {
+ e.headerValue = value;
+ }
+ },
+
+ setWindowCallback() {
+ EnigmailLog.DEBUG("enigmailMsgHdrViewOverlay.js: setWindowCallback\n");
+
+ EnigmailSingletons.messageReader = this.headerPane;
+ },
+
+ clearWindowCallback() {
+ if (EnigmailSingletons.messageReader == this.headerPane) {
+ EnigmailSingletons.messageReader = null;
+ }
+ },
+
+ headerPane: {
+ isCurrentMessage(uri) {
+ let uriSpec = uri ? uri.spec : null;
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: EnigMimeHeaderSink.isCurrentMessage: uri.spec=" +
+ uriSpec +
+ "\n"
+ );
+
+ return true;
+ },
+
+ /**
+ * Determine if a given MIME part number is a multipart/related message or a child thereof
+ *
+ * @param mimePart: Object - The MIME Part object to evaluate from the MIME tree
+ * @param searchPartNum: String - The part number to determine
+ */
+ isMultipartRelated(mimePart, searchPartNum) {
+ if (
+ searchPartNum.indexOf(mimePart.partNum) == 0 &&
+ mimePart.partNum.length <= searchPartNum.length
+ ) {
+ if (mimePart.fullContentType.search(/^multipart\/related/i) === 0) {
+ return true;
+ }
+
+ for (let i in mimePart.subParts) {
+ if (this.isMultipartRelated(mimePart.subParts[i], searchPartNum)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Determine if a given mime part number should be displayed.
+ * Returns true if one of these conditions is true:
+ * - this is the 1st displayed block of the message
+ * - the message part displayed corresponds to the decrypted part
+ *
+ * @param mimePartNumber: String - the MIME part number that was decrypted/verified
+ * @param uriSpec: String - the URI spec that is being displayed
+ */
+ displaySubPart(mimePartNumber, uriSpec) {
+ if (!mimePartNumber || !uriSpec) {
+ return true;
+ }
+ let part = EnigmailMime.getMimePartNumber(uriSpec);
+
+ if (part.length === 0) {
+ // only display header if 1st message part
+ if (mimePartNumber.search(/^1(\.1)*$/) < 0) {
+ return false;
+ }
+ } else {
+ let r = EnigmailFuncs.compareMimePartLevel(mimePartNumber, part);
+
+ // analyzed mime part is contained in viewed message part
+ if (r === 2) {
+ if (mimePartNumber.substr(part.length).search(/^\.1(\.1)*$/) < 0) {
+ return false;
+ }
+ } else if (r !== 0) {
+ return false;
+ }
+
+ if (Enigmail.msg.mimeParts) {
+ if (this.isMultipartRelated(Enigmail.msg.mimeParts, mimePartNumber)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Determine if there are message parts that are not encrypted
+ *
+ * @param mimePartNumber String - the MIME part number that was authenticated
+ *
+ * @returns Boolean: true: there are siblings / false: no siblings
+ */
+ hasUnauthenticatedParts(mimePartNumber) {
+ function hasUnauthenticatedSiblings(
+ mimeSubTree,
+ mimePartToCheck,
+ parentOfMimePartToCheck
+ ) {
+ if (mimeSubTree.partNum === parentOfMimePartToCheck) {
+ // If this is an encrypted message that is the parent of mimePartToCheck,
+ // then we know that all its childs (including mimePartToCheck) are authenticated.
+ if (
+ mimeSubTree.fullContentType.search(
+ /^multipart\/encrypted.{1,255}protocol="?application\/pgp-encrypted"?/i
+ ) === 0
+ ) {
+ return false;
+ }
+ }
+ if (
+ mimeSubTree.partNum.indexOf(parentOfMimePartToCheck) == 0 &&
+ mimeSubTree.partNum !== mimePartToCheck
+ ) {
+ // This is a sibling (same parent, different part number).
+ return true;
+ }
+
+ for (let i in mimeSubTree.subParts) {
+ if (
+ hasUnauthenticatedSiblings(
+ mimeSubTree.subParts[i],
+ mimePartToCheck,
+ parentOfMimePartToCheck
+ )
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ if (!mimePartNumber || !Enigmail.msg.mimeParts) {
+ return false;
+ }
+
+ let parentNum = "";
+ if (mimePartNumber.includes(".")) {
+ parentNum = mimePartNumber.replace(/\.\d+$/, "");
+ }
+
+ return hasUnauthenticatedSiblings(
+ Enigmail.msg.mimeParts,
+ mimePartNumber,
+ parentNum
+ );
+ },
+
+ /**
+ * Request that OpenPGP security status from the given MIME part
+ * shall be ignored (not shown in the UI). If status for that
+ * MIME part was already received, then reset the status.
+ *
+ * @param {string} originMimePartNumber - Ignore security status
+ * of this MIME part.
+ */
+ ignoreStatusFrom(originMimePartNumber) {
+ Enigmail.hdrView.ignoreStatusFromMimePart = originMimePartNumber;
+ setIgnoreStatusFromMimePart(originMimePartNumber);
+ if (Enigmail.hdrView.receivedStatusFromParts.has(originMimePartNumber)) {
+ Enigmail.hdrView.reset();
+ Enigmail.hdrView.ignoreStatusFromMimePart = originMimePartNumber;
+ }
+ },
+
+ async updateSecurityStatus(
+ unusedUriSpec,
+ exitCode,
+ statusFlags,
+ extStatusFlags,
+ keyId,
+ userId,
+ sigDetails,
+ errorMsg,
+ blockSeparation,
+ uri,
+ extraDetails,
+ mimePartNumber
+ ) {
+ if (
+ Enigmail.hdrView.ignoreStatusFromMimePart != "" &&
+ mimePartNumber == Enigmail.hdrView.ignoreStatusFromMimePart
+ ) {
+ return;
+ }
+
+ Enigmail.hdrView.receivedStatusFromParts.add(mimePartNumber);
+
+ // uriSpec is not used for Enigmail anymore. It is here because other addons and pEp rely on it
+
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: updateSecurityStatus: mimePart=" +
+ mimePartNumber +
+ "\n"
+ );
+
+ let uriSpec = uri ? uri.spec : null;
+
+ if (this.isCurrentMessage(uri)) {
+ if (statusFlags & EnigmailConstants.DECRYPTION_OKAY) {
+ if (gEncryptedURIService) {
+ // remember encrypted message URI to enable TB prevention against EFAIL attack
+ Enigmail.hdrView.lastEncryptedUri = gMessageURI;
+ gEncryptedURIService.rememberEncrypted(
+ Enigmail.hdrView.lastEncryptedUri
+ );
+ }
+ }
+
+ if (!this.displaySubPart(mimePartNumber, uriSpec)) {
+ return;
+ }
+ if (this.hasUnauthenticatedParts(mimePartNumber)) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: updateSecurityStatus: found unauthenticated part\n"
+ );
+ statusFlags |= EnigmailConstants.PARTIALLY_PGP;
+ }
+
+ let encToDetails = null;
+
+ if (extraDetails && extraDetails.length > 0) {
+ try {
+ let o = JSON.parse(extraDetails);
+ if ("encryptedTo" in o) {
+ encToDetails = o.encryptedTo;
+ }
+ } catch (x) {
+ console.debug(x);
+ }
+ }
+
+ Enigmail.hdrView.updatePgpStatus(
+ exitCode,
+ statusFlags,
+ extStatusFlags,
+ keyId,
+ userId,
+ sigDetails,
+ errorMsg,
+ blockSeparation,
+ encToDetails,
+ null,
+ mimePartNumber
+ );
+ }
+ },
+
+ processDecryptionResult(uri, actionType, processData, mimePartNumber) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: EnigMimeHeaderSink.processDecryptionResult:\n"
+ );
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: actionType= " +
+ actionType +
+ ", mimePart=" +
+ mimePartNumber +
+ "\n"
+ );
+
+ let msg = gMessage;
+ if (!msg) {
+ return;
+ }
+ if (!this.isCurrentMessage(uri)) {
+ return;
+ }
+
+ switch (actionType) {
+ case "modifyMessageHeaders":
+ this.modifyMessageHeaders(uri, processData, mimePartNumber);
+ break;
+ /*
+ case "wksConfirmRequest":
+ Enigmail.hdrView.checkWksConfirmRequest(processData);
+ */
+ }
+ },
+
+ modifyMessageHeaders(uri, headerData, mimePartNumber) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: EnigMimeHeaderSink.modifyMessageHeaders:\n"
+ );
+
+ let uriSpec = uri ? uri.spec : null;
+ let hdr;
+
+ try {
+ hdr = JSON.parse(headerData);
+ } catch (ex) {
+ EnigmailLog.DEBUG(
+ "enigmailMsgHdrViewOverlay.js: modifyMessageHeaders: - no headers to display\n"
+ );
+ return;
+ }
+
+ if (typeof hdr !== "object") {
+ return;
+ }
+ if (!this.displaySubPart(mimePartNumber, uriSpec)) {
+ return;
+ }
+
+ let msg = gMessage;
+
+ if ("subject" in hdr) {
+ Enigmail.hdrView.setSubject(hdr.subject);
+ }
+
+ if ("date" in hdr) {
+ msg.date = Date.parse(hdr.date) * 1000;
+ }
+ /*
+ if ("newsgroups" in hdr) {
+ updateHdrBox("newsgroups", hdr.newsgroups);
+ }
+
+ if ("followup-to" in hdr) {
+ updateHdrBox("followup-to", hdr["followup-to"]);
+ }
+
+ if ("from" in hdr) {
+ gExpandedHeaderView.from.outputFunction(gExpandedHeaderView.from, hdr.from);
+ msg.setStringProperty("Enigmail-From", hdr.from);
+ }
+
+ if ("to" in hdr) {
+ gExpandedHeaderView.to.outputFunction(gExpandedHeaderView.to, hdr.to);
+ msg.setStringProperty("Enigmail-To", hdr.to);
+ }
+
+ if ("cc" in hdr) {
+ gExpandedHeaderView.cc.outputFunction(gExpandedHeaderView.cc, hdr.cc);
+ msg.setStringProperty("Enigmail-Cc", hdr.cc);
+ }
+
+ if ("reply-to" in hdr) {
+ gExpandedHeaderView["reply-to"].outputFunction(gExpandedHeaderView["reply-to"], hdr["reply-to"]);
+ msg.setStringProperty("Enigmail-ReplyTo", hdr["reply-to"]);
+ }
+ */
+ },
+
+ handleSMimeMessage(uri) {
+ if (
+ Enigmail.hdrView.msgSignedStateString != null ||
+ Enigmail.hdrView.msgEncryptedStateString != null
+ ) {
+ // If we already processed an OpenPGP part, then we are handling
+ // a message with an inner S/MIME part. We must not reload
+ // the message here, because we'd run into an endless loop.
+ return;
+ }
+ if (this.isCurrentMessage(uri)) {
+ EnigmailVerify.unregisterPGPMimeHandler();
+ Enigmail.msg.messageReload(false);
+ }
+ },
+ },
+
+ /*
+ onUnloadEnigmail() {
+ window.removeEventListener("load-enigmail", Enigmail.hdrView.hdrViewLoad);
+ for (let i = 0; i < gMessageListeners.length; i++) {
+ if (gMessageListeners[i] === Enigmail.hdrView.messageListener) {
+ gMessageListeners.splice(i, 1);
+ break;
+ }
+ }
+
+ let signedHdrElement = document.getElementById("signedHdrIcon");
+ if (signedHdrElement) {
+ signedHdrElement.setAttribute(
+ "onclick",
+ "showMessageReadSecurityInfo();"
+ );
+ }
+
+ let encryptedHdrElement = document.getElementById("encryptedHdrIcon");
+ if (encryptedHdrElement) {
+ encryptedHdrElement.setAttribute(
+ "onclick",
+ "showMessageReadSecurityInfo();"
+ );
+ }
+
+ let addrPopup = document.getElementById("emailAddressPopup");
+ if (addrPopup) {
+ addrPopup.removeEventListener(
+ "popupshowing",
+ Enigmail.hdrView.displayAddressPopup
+ );
+ }
+
+ let attCtx = document.getElementById("attachmentItemContext");
+ if (attCtx) {
+ attCtx.removeEventListener(
+ "popupshowing",
+ this.onShowAttachmentContextMenu
+ );
+ }
+
+ let msgFrame = EnigmailWindows.getFrame(window, "messagepane");
+ if (msgFrame) {
+ msgFrame.removeEventListener(
+ "unload",
+ Enigmail.hdrView.messageUnload,
+ true
+ );
+ msgFrame.removeEventListener("load", Enigmail.hdrView.messageLoad);
+ }
+ },
+ */
+};
+
+window.addEventListener(
+ "load-enigmail",
+ Enigmail.hdrView.hdrViewLoad.bind(Enigmail.hdrView)
+);
+window.addEventListener("unload", () => Enigmail.hdrView.clearWindowCallback());
diff --git a/comm/mail/extensions/openpgp/content/ui/keyAssistant.inc.xhtml b/comm/mail/extensions/openpgp/content/ui/keyAssistant.inc.xhtml
new file mode 100644
index 0000000000..e3a56edf77
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/keyAssistant.inc.xhtml
@@ -0,0 +1,119 @@
+# 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 https://mozilla.org/MPL/2.0/.
+
+<html:dialog id="keyAssistant" xmlns="http://www.w3.org/1999/xhtml"
+ class="modal-dialog">
+
+ <h1 class="dialog-title" data-l10n-id="openpgp-key-assistant-title"></h1>
+
+ <div id="discoverView" class="modal-dialog-body dialog-body-view">
+ <div>
+ <p data-l10n-id="openpgp-key-assistant-discover-title"></p>
+ <div id="discoveryOutput"></div>
+ </div>
+ <menu class="dialog-menu-container menu-in-body">
+ <button data-l10n-id="openpgp-key-assistant-cancel-button"
+ onclick="gKeyAssistant.resetViews();"></button>
+ </menu>
+ </div><!-- #discoverView -->
+
+ <div id="resolveView" class="modal-dialog-body dialog-body-view">
+ <div>
+ <p id="resolveViewTitle"></p>
+
+ <!-- Usable section. -->
+ <section id="resolveViewValid" hidden="hidden">
+ <p id="resolveViewValidDescription" class="font-bold"
+ data-l10n-id="openpgp-key-assistant-valid-description"></p>
+ <ul id="resolveValidKeysList" class="reset-list radio-list"></ul>
+ <p id="openpgp-key-assistant-help-accept"
+ class="tip-caption margin-top-1em"
+ data-l10n-id="openpgp-key-assistant-rogue-warning">
+ <a onclick="openContentTab(this.href);"
+ href="https://support.mozilla.org/kb/thunderbird-help-openpgp-counterfeit-key"
+ data-l10n-name="openpgp-link"></a>
+ </p>
+ </section>
+
+ <!-- Unusable section. -->
+ <section id="resolveViewInvalid" hidden="hidden">
+ <p id="resolveViewExpiredDescription" class="font-bold margin-top-1em"></p>
+ <ul id="resolveInvalidKeysList" class="reset-list radio-list"></ul>
+ </section>
+
+ <button data-l10n-id="openpgp-key-assistant-discover-online-button"
+ onclick="gKeyAssistant.changeView('discover', 'resolving');"></button>
+ <button data-l10n-id="openpgp-key-assistant-import-keys-button"
+ onclick="gKeyAssistant.importFromFile('resolving');"></button>
+ </div>
+
+ <menu class="dialog-menu-container two-columns menu-in-body">
+ <button data-l10n-id="openpgp-key-assistant-back-button"
+ onclick="gKeyAssistant.resetViews();"></button>
+ <button id="resolveViewAcceptKey"
+ data-l10n-id="openpgp-key-assistant-accept-button"
+ class="primary"
+ disabled="disabled"></button>
+ </menu>
+ </div><!-- #resolveView -->
+
+ <div id="mainView" class="modal-dialog-body dialog-body-view">
+ <div id="modalDialogNotification" class="modal-dialog-notifications">
+ <!-- Notifications will be lazily loaded here. -->
+ </div>
+
+ <!-- Issues section. -->
+ <section id="keyAssistantIssues" hidden="hidden">
+ <p id="keyAssistantIssuesHeader" class="font-bold"
+ data-l10n-id="openpgp-key-assistant-recipients-issue-header"/>
+ <p id="keyAssistantIssuesDescription">
+ <a onclick="openContentTab(this.href);"
+ href="https://support.mozilla.org/kb/thunderbird-help-cannot-encrypt"
+ data-l10n-name="openpgp-link"></a>
+ </p>
+
+ <ul id="keysListIssues" class="reset-list key-list"></ul>
+
+ <button data-l10n-id="openpgp-key-assistant-discover-online-button"
+ onclick="gKeyAssistant.changeView('discover', 'overview');"></button>
+ <button data-l10n-id="openpgp-key-assistant-import-keys-button"
+ onclick="gKeyAssistant.importFromFile('overview');"></button>
+
+ <p id="openpgp-key-assistant-help-alias"
+ class="tip-caption margin-top-1em"
+ data-l10n-id="openpgp-key-assistant-info-alias">
+ <a onclick="openContentTab(this.href);"
+ href="https://support.mozilla.org/kb/thunderbird-help-openpgp-alias"
+ data-l10n-name="openpgp-link"></a>
+ </p>
+ </section>
+
+ <!-- No issues section. -->
+ <section id="keyAssistantValid" class="margin-top-1em" hidden="hidden">
+ <div class="container-with-link">
+ <p id="keyAssistantValidDescription"></p>
+ <button id="toggleRecipientsButton"
+ class="button-link"
+ data-l10n-id="openpgp-key-assistant-recipients-show-button"></button>
+ </div>
+
+ <ul id="keysListValid" class="reset-list key-list" hidden="hidden"></ul>
+ </section>
+ </div><!-- #mainView -->
+
+ <menu id="mainButtons" class="dialog-menu-container two-columns">
+ <div>
+ <button data-l10n-id="openpgp-key-assistant-close-button"
+ onclick="gKeyAssistant.close();"></button>
+ </div>
+ <div>
+ <button id="disableEncryptionButton"
+ data-l10n-id="openpgp-key-assistant-disable-button"></button>
+ <button id="sendEncryptedButton"
+ data-l10n-id="openpgp-key-assistant-confirm-button"
+ class="primary"
+ disabled="disabled"></button>
+ </div>
+ </menu>
+</html:dialog>
diff --git a/comm/mail/extensions/openpgp/content/ui/keyAssistant.js b/comm/mail/extensions/openpgp/content/ui/keyAssistant.js
new file mode 100644
index 0000000000..ca6104c2cb
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/keyAssistant.js
@@ -0,0 +1,956 @@
+/*
+ * 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 MozElements */
+/* import-globals-from ../../../../components/compose/content/MsgComposeCommands.js */
+/* import-globals-from commonWorkflows.js */
+/* globals goDoCommand */ // From globalOverlay.js
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ KeyLookupHelper: "chrome://openpgp/content/modules/keyLookupHelper.jsm",
+ OpenPGPAlias: "chrome://openpgp/content/modules/OpenPGPAlias.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+ // FIXME: using this creates a conflict with another file where this symbol
+ // was imported with ChromeUtils instead of defined as lazy getter.
+ // EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+});
+var { EnigmailWindows } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/windows.jsm"
+);
+
+window.addEventListener("load", () => {
+ gKeyAssistant.onLoad();
+});
+window.addEventListener("unload", () => {
+ gKeyAssistant.onUnload();
+});
+
+var gKeyAssistant = {
+ dialog: null,
+ recipients: [],
+ currentRecip: null,
+
+ /*
+ * Variable ignoreExternal should be set to true whenever a
+ * keyAsssistant window is open that cannot tolerate changes to
+ * the keyAsssistant's own variables, that track the current user
+ * interaction.
+ *
+ * While the key assistant is showing, it takes care to update the
+ * elements on screen, based on the expected changes. Usually,
+ * it will perform a refresh after a current action is completed.
+ *
+ * Without this protection, you'd get data races and side effects like
+ * email addresses being shown twice, and worse.
+ */
+ ignoreExternal: false,
+
+ /**
+ * Initialize the main notification box for the account setup process.
+ */
+ get notificationBox() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document.getElementById("modalDialogNotification").append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ onLoad() {
+ this.dialog = document.getElementById("keyAssistant");
+
+ this._setupEventListeners();
+ },
+
+ _setupEventListeners() {
+ document
+ .getElementById("disableEncryptionButton")
+ .addEventListener("click", () => {
+ gSendEncrypted = false;
+ gUserTouchedSendEncrypted = true;
+ checkEncryptionState();
+ this.close();
+ });
+ document
+ .getElementById("sendEncryptedButton")
+ .addEventListener("click", () => {
+ goDoCommand("cmd_sendWithCheck");
+ this.close();
+ });
+ document
+ .getElementById("toggleRecipientsButton")
+ .addEventListener("click", () => {
+ this.toggleRecipientsList();
+ });
+
+ this.dialog.addEventListener("close", () => {
+ this.close();
+ });
+ },
+
+ async close() {
+ await checkEncryptionState();
+ this.dialog.close();
+ },
+
+ onUnload() {
+ this.recipients = [];
+ },
+
+ setMainDisableButton() {
+ document.getElementById("disableEncryptionButton").hidden =
+ !gSendEncrypted || (this.usableKeys && !this.problematicKeys);
+ },
+
+ /**
+ * Open the key assistant modal dialog.
+ *
+ * @param {string[]} recipients - An array of strings containing all currently
+ * written recipients.
+ * @param {boolean} isSending - If the key assistant was triggered during a
+ * sending attempt.
+ */
+ show(recipients, isSending) {
+ this.recipients = recipients;
+ this.buildMainView();
+ this.resetViews();
+
+ document.getElementById("sendEncryptedButton").hidden = !isSending;
+ this.setMainDisableButton();
+ this.dialog.showModal();
+ },
+
+ resetViews() {
+ this.notificationBox.removeAllNotifications();
+ this.dialog.removeAttribute("style");
+
+ for (let view of document.querySelectorAll(".dialog-body-view")) {
+ view.hidden = true;
+ }
+
+ document.getElementById("mainButtons").hidden = false;
+ document.getElementById("mainView").hidden = false;
+ },
+
+ changeView(view, context) {
+ this.resetViews();
+
+ this.dialog.setAttribute(
+ "style",
+ `min-height: ${this.dialog.getBoundingClientRect().height}px`
+ );
+
+ document.getElementById("mainView").hidden = true;
+ document.getElementById(`${view}View`).hidden = false;
+
+ switch (view) {
+ case "discover":
+ this.hideMainButtons();
+ this.initOnlineDiscovery(context);
+ break;
+
+ case "resolve":
+ this.hideMainButtons();
+ break;
+
+ default:
+ break;
+ }
+ },
+
+ hideMainButtons() {
+ document.getElementById("mainButtons").hidden = true;
+ },
+
+ usableKeys: 0,
+ problematicKeys: 0,
+
+ /**
+ * Populate the main view of the key assistant with the list of recipients and
+ * its keys, separating the recipients that have issues from those without
+ * issues.
+ */
+ async buildMainView() {
+ // Restore empty UI state.
+ document.getElementById("keyAssistantIssues").hidden = true;
+ document.getElementById("keysListIssues").replaceChildren();
+ document.getElementById("keyAssistantValid").hidden = true;
+ document.getElementById("keysListValid").replaceChildren();
+
+ this.usableKeys = 0;
+ this.problematicKeys = 0;
+
+ for (let addr of this.recipients) {
+ // Fetch all keys for the current recipient.
+ let keyMetas = await EnigmailKeyRing.getEncryptionKeyMeta(addr);
+ if (keyMetas.some(k => k.readiness == "alias")) {
+ let aliasKeyList = EnigmailKeyRing.getAliasKeyList(addr);
+ let aliasKeys = EnigmailKeyRing.getAliasKeys(aliasKeyList);
+ if (!aliasKeys.length) {
+ // failure, at least one alias key is unusable/unavailable
+
+ let descriptionDiv = document.createElement("div");
+ document.l10n.setAttributes(
+ descriptionDiv,
+ "openpgp-compose-alias-status-error"
+ );
+
+ this.addToProblematicList(addr, descriptionDiv, null);
+ this.problematicKeys++;
+ } else {
+ let aliasText = document.createElement("div");
+ document.l10n.setAttributes(
+ aliasText,
+ "openpgp-compose-alias-status-direct",
+ { count: aliasKeys.length }
+ );
+
+ this.addToReadyList(addr, aliasText);
+ this.usableKeys++;
+ }
+ } else {
+ // not alias
+
+ let acceptedKeys = keyMetas.filter(k => k.readiness == "accepted");
+ if (acceptedKeys.length) {
+ let button = document.createElement("button");
+ document.l10n.setAttributes(
+ button,
+ "openpgp-key-assistant-view-key-button"
+ );
+ button.addEventListener("click", () => {
+ gKeyAssistant.viewKeyFromOverview(addr, acceptedKeys[0]);
+ });
+
+ this.addToReadyList(addr, button);
+ this.usableKeys++;
+ } else {
+ let descriptionDiv = document.createElement("div");
+
+ let canOfferResolving = keyMetas.some(
+ k =>
+ k.readiness == "collected" ||
+ k.readiness == "expiredAccepted" ||
+ k.readiness == "expiredUndecided" ||
+ k.readiness == "expiredOtherAccepted" ||
+ k.readiness == "undecided" ||
+ k.readiness == "otherAccepted" ||
+ k.readiness == "expiredRejected" ||
+ k.readiness == "rejected"
+ );
+
+ let button = null;
+ if (canOfferResolving) {
+ this.fillKeysStatus(descriptionDiv, keyMetas);
+
+ button = document.createElement("button");
+ document.l10n.setAttributes(
+ button,
+ "openpgp-key-assistant-issue-resolve-button"
+ );
+ button.addEventListener("click", () => {
+ this.buildResolveView(addr, keyMetas);
+ });
+ } else {
+ document.l10n.setAttributes(
+ descriptionDiv,
+ "openpgp-key-assistant-no-key-available"
+ );
+ }
+
+ this.addToProblematicList(addr, descriptionDiv, button);
+ this.problematicKeys++;
+ }
+ }
+ }
+
+ document.getElementById("keyAssistantIssues").hidden =
+ !this.problematicKeys;
+ document.l10n.setAttributes(
+ document.getElementById("keyAssistantIssuesDescription"),
+ "openpgp-key-assistant-recipients-issue-description",
+ { count: this.problematicKeys }
+ );
+
+ document.getElementById("keyAssistantValid").hidden = !this.usableKeys;
+
+ if (!this.problematicKeys && this.usableKeys) {
+ document.l10n.setAttributes(
+ document.getElementById("keyAssistantValidDescription"),
+ "openpgp-key-assistant-recipients-description-no-issues"
+ );
+ document.getElementById("toggleRecipientsButton").click();
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("keyAssistantValidDescription"),
+ "openpgp-key-assistant-recipients-description",
+ { count: this.usableKeys }
+ );
+ }
+
+ document.getElementById("sendEncryptedButton").disabled =
+ this.problematicKeys || !this.usableKeys;
+ this.setMainDisableButton();
+ },
+
+ isAccepted(acc) {
+ return (
+ acc.emailDecided &&
+ (acc.fingerprintAcceptance == "verified" ||
+ acc.fingerprintAcceptance == "unverified")
+ );
+ },
+
+ async viewKeyFromResolve(keyMeta) {
+ let oldAccept = {};
+ await PgpSqliteDb2.getAcceptance(
+ keyMeta.keyObj.fpr,
+ this.currentRecip,
+ oldAccept
+ );
+
+ this.ignoreExternal = true;
+ await this._viewKey(keyMeta);
+ this.ignoreExternal = false;
+
+ // If the key is not yet accepted, then we want to automatically
+ // close the email-resolve view, if the user accepts the key
+ // while viewing the key details.
+ let autoCloseOnAccept = !this.isAccepted(oldAccept);
+
+ let newAccept = {};
+ await PgpSqliteDb2.getAcceptance(
+ keyMeta.keyObj.fpr,
+ this.currentRecip,
+ newAccept
+ );
+
+ if (autoCloseOnAccept && this.isAccepted(newAccept)) {
+ this.resetViews();
+ this.buildMainView();
+ } else {
+ // While viewing the key, the user could have triggered a refresh,
+ // which could have changed the validity of the key.
+ let keyMetas = await EnigmailKeyRing.getEncryptionKeyMeta(
+ this.currentRecip
+ );
+ this.buildResolveView(this.currentRecip, keyMetas);
+ }
+ },
+
+ async viewKeyFromOverview(recip, keyMeta) {
+ this.ignoreExternal = true;
+ await this._viewKey(keyMeta);
+ this.ignoreExternal = false;
+
+ // While viewing the key, the user could have triggered a refresh,
+ // which could have changed the validity of the key.
+ // In theory it would be sufficient to refresh the main view
+ // for the single email address.
+ await checkEncryptionState("openpgp-key-assistant-refresh");
+ this.buildMainView();
+ },
+
+ async _viewKey(keyMeta) {
+ let exists = EnigmailKeyRing.getKeyById(keyMeta.keyObj.keyId);
+
+ if (!exists) {
+ if (keyMeta.readiness != "collected") {
+ return;
+ }
+ await EnigmailKeyRing.importKeyDataSilent(
+ window,
+ keyMeta.collectedKey.pubKey,
+ true
+ );
+ }
+
+ EnigmailWindows.openKeyDetails(window, keyMeta.keyObj.keyId, false);
+ },
+
+ addToReadyList(recipient, detailElement) {
+ let list = document.getElementById("keysListValid");
+ let row = document.createElement("li");
+ row.classList.add("key-row");
+
+ let info = document.createElement("div");
+ info.classList.add("key-info");
+ let title = document.createElement("b");
+ title.textContent = recipient;
+
+ info.appendChild(title);
+ row.append(info, detailElement);
+ list.appendChild(row);
+ },
+
+ fillKeysStatus(element, keyMetas) {
+ let unaccepted = keyMetas.filter(
+ k =>
+ k.readiness == "undecided" ||
+ k.readiness == "rejected" ||
+ k.readiness == "otherAccepted"
+ );
+ let collected = keyMetas.filter(k => k.readiness == "collected");
+
+ // Multiple keys available.
+ if (unaccepted.length + collected.length > 1) {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-multiple-keys"
+ );
+ // TODO: add note to be careful?
+ return;
+ }
+
+ // Not expired but not accepted keys.
+ if (unaccepted.length) {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-key-unaccepted",
+ {
+ count: unaccepted.length,
+ }
+ );
+ if (unaccepted.length == 1) {
+ element.before("0x" + unaccepted[0].keyObj.keyId);
+ }
+ return;
+ }
+
+ let expiredAccepted = keyMetas.filter(
+ k => k.readiness == "expiredAccepted"
+ );
+
+ // Key accepted but expired.
+ if (expiredAccepted.length) {
+ if (expiredAccepted.length == 1) {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-key-accepted-expired",
+ {
+ date: expiredAccepted[0].keyObj.effectiveExpiry,
+ }
+ );
+ element.before("0x" + expiredAccepted[0].keyObj.keyId);
+ } else {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-keys-accepted-expired"
+ );
+ }
+ return;
+ }
+
+ let expiredUnaccepted = keyMetas.filter(
+ k =>
+ k.readiness == "expiredUndecided" ||
+ k.readiness == "expiredRejected" ||
+ k.readiness == "expiredOtherAccepted"
+ );
+
+ // Key not accepted and expired.
+ if (expiredUnaccepted.length) {
+ if (expiredUnaccepted.length == 1) {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-key-unaccepted-expired-one",
+ {
+ date: expiredUnaccepted[0].keyObj.effectiveExpiry,
+ }
+ );
+ element.before("0x" + expiredUnaccepted[0].keyObj.keyId);
+ } else {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-key-unaccepted-expired-many"
+ );
+ }
+ return;
+ }
+
+ let unacceptedNotYetImported = keyMetas.filter(
+ k => k.readiness == "collected"
+ );
+
+ if (unacceptedNotYetImported.length) {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-keys-has-collected",
+ {
+ count: unacceptedNotYetImported.length,
+ }
+ );
+ if (unacceptedNotYetImported.length == 1) {
+ element.before("0x" + unacceptedNotYetImported[0].keyObj.keyId);
+ }
+ return;
+ }
+
+ // We found nothing, so let's return a default message.
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-no-key-available"
+ );
+ },
+
+ addToProblematicList(recipient, descriptionDiv, resolveButton) {
+ let list = document.getElementById("keysListIssues");
+ let row = document.createElement("li");
+ row.classList.add("key-row");
+
+ let info = document.createElement("div");
+ info.classList.add("key-info");
+ let title = document.createElement("b");
+ title.textContent = recipient;
+ info.append(title, descriptionDiv);
+
+ if (resolveButton) {
+ row.append(info, resolveButton);
+ } else {
+ row.appendChild(info);
+ }
+
+ list.appendChild(row);
+ },
+
+ fillKeyOriginAndStatus(element, keyMeta) {
+ // The key was collected from somewhere.
+ if (keyMeta.collectedKey) {
+ let sourceSpan = document.createElement("span");
+ document.l10n.setAttributes(
+ sourceSpan,
+ "openpgp-key-assistant-key-source",
+ {
+ count: keyMeta.collectedKey.sources.length,
+ }
+ );
+ element.append(sourceSpan, ": ");
+ let linkSpan = document.createElement("span");
+ linkSpan.classList.add("comma-separated");
+
+ let sourceLinks = keyMeta.collectedKey.sources.map(source => {
+ source.type = source.type.toLowerCase(); // Earlier "WKD" was "wkd".
+ let a = document.createElement("a");
+ if (source.uri) {
+ a.href = source.uri;
+ a.title = source.uri;
+ }
+ if (source.description) {
+ if (a.title) {
+ a.title += " - ";
+ }
+ a.title += source.description;
+ }
+ let span = document.createElement("span");
+ // openpgp-key-assistant-key-collected-attachment
+ // openpgp-key-assistant-key-collected-autocrypt
+ // openpgp-key-assistant-key-collected-keyserver
+ // openpgp-key-assistant-key-collected-wkd
+ document.l10n.setAttributes(
+ span,
+ `openpgp-key-assistant-key-collected-${source.type}`
+ );
+ a.appendChild(span);
+ return a;
+ });
+ linkSpan.append(...sourceLinks);
+ element.appendChild(linkSpan);
+ return;
+ }
+
+ // The key was rejected.
+ if (keyMeta.readiness == "rejected") {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-key-rejected"
+ );
+ return;
+ }
+
+ // Key is expired.
+ if (
+ keyMeta.readiness == "expiredAccepted" ||
+ keyMeta.readiness == "expiredUndecided" ||
+ keyMeta.readiness == "expiredOtherAccepted" ||
+ keyMeta.readiness == "expiredRejected"
+ ) {
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-this-key-accepted-expired",
+ {
+ date: keyMeta.keyObj.effectiveExpiry,
+ }
+ );
+ return;
+ }
+
+ if (keyMeta.readiness == "otherAccepted") {
+ // Was the key already accepted for another email address?
+ document.l10n.setAttributes(
+ element,
+ "openpgp-key-assistant-key-accepted-other",
+ {
+ date: keyMeta.keyObj.effectiveExpiry,
+ }
+ );
+ }
+ },
+
+ async buildResolveView(recipient, keyMetas) {
+ this.currentRecip = recipient;
+ document.getElementById("resolveViewAcceptKey").disabled = true;
+
+ let unaccepted = keyMetas.filter(
+ k =>
+ k.readiness == "undecided" ||
+ k.readiness == "rejected" ||
+ k.readiness == "otherAccepted"
+ );
+ let collected = keyMetas.filter(k => k.readiness == "collected");
+ let expiredAccepted = keyMetas.filter(
+ k => k.readiness == "expiredAccepted"
+ );
+ let expiredUnaccepted = keyMetas.filter(
+ k =>
+ k.readiness == "expiredUndecided" ||
+ k.readiness == "expiredRejected" ||
+ k.readiness == "expiredOtherAccepted"
+ );
+
+ this.usableKeys = unaccepted.length + collected.length;
+ let problematicKeys = expiredAccepted.length + expiredUnaccepted.length;
+ let numKeys = this.usableKeys + problematicKeys;
+
+ document.l10n.setAttributes(
+ document.getElementById("resolveViewTitle"),
+ "openpgp-key-assistant-resolve-title",
+ {
+ recipient,
+ numKeys,
+ }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("resolveViewExpiredDescription"),
+ "openpgp-key-assistant-invalid-title",
+ { numKeys }
+ );
+
+ document.getElementById("resolveViewValid").hidden = !this.usableKeys;
+ let usableList = document.getElementById("resolveValidKeysList");
+ usableList.replaceChildren();
+
+ function createKeyRow(keyMeta, isValid) {
+ let row = document.createElement("li");
+ let label = document.createElement("label");
+ label.classList.add("flex-center");
+
+ let input = document.createElement("input");
+ input.type = "radio";
+ input.name = isValid ? "valid-key" : "invalid-key";
+ input.value = keyMeta.keyObj.keyId;
+ input.disabled = !isValid;
+
+ if (isValid) {
+ input.addEventListener("change", () => {
+ document.getElementById("resolveViewAcceptKey").disabled = false;
+ });
+ }
+ label.appendChild(input);
+
+ let keyId = document.createElement("b");
+ keyId.textContent = "0x" + keyMeta.keyObj.keyId;
+
+ let creationTime = document.createElement("time");
+ creationTime.setAttribute(
+ "datetime",
+ new Date(keyMeta.keyObj.keyCreated * 1000).toISOString()
+ );
+ document.l10n.setAttributes(
+ creationTime,
+ "openpgp-key-assistant-key-created",
+ { date: keyMeta.keyObj.created }
+ );
+ label.append(keyId, " - ", creationTime);
+ row.appendChild(label);
+
+ let fingerprint = document.createElement("div");
+ fingerprint.classList.add("key-info-block");
+ let fpDesc = document.createElement("span");
+ let fpLink = document.createElement("a");
+ fpLink.href = "#";
+ fpLink.textContent = EnigmailKey.formatFpr(keyMeta.keyObj.fpr);
+ fpLink.addEventListener("click", event => {
+ event.preventDefault();
+ gKeyAssistant.viewKeyFromResolve(keyMeta);
+ });
+ document.l10n.setAttributes(
+ fpDesc,
+ "openpgp-key-assistant-key-fingerprint"
+ );
+ fingerprint.append(fpDesc, ": ", fpLink);
+ row.appendChild(fingerprint);
+
+ let info = document.createElement("div");
+ info.classList.add("key-info-block");
+ row.append(info);
+
+ gKeyAssistant.fillKeyOriginAndStatus(info, keyMeta);
+ return row;
+ }
+
+ for (let meta of unaccepted) {
+ usableList.appendChild(createKeyRow(meta, true));
+ }
+
+ for (let meta of collected) {
+ usableList.appendChild(createKeyRow(meta, true));
+ }
+
+ document.getElementById("resolveViewInvalid").hidden = !problematicKeys;
+ let problematicList = document.getElementById("resolveInvalidKeysList");
+ problematicList.replaceChildren();
+
+ for (let meta of expiredAccepted) {
+ problematicList.appendChild(createKeyRow(meta, false));
+ }
+ for (let meta of expiredUnaccepted) {
+ problematicList.appendChild(createKeyRow(meta, false));
+ }
+
+ document.getElementById("resolveViewAcceptKey").onclick = () => {
+ this.acceptSelectedKey(recipient, keyMetas);
+ };
+ this.changeView("resolve");
+ },
+
+ async acceptSelectedKey(recipient, keyMetas) {
+ let selectedKey = document.querySelector(
+ 'input[name="valid-key"]:checked'
+ )?.value;
+ if (!selectedKey) {
+ // The accept button was enabled but nothing was selected.
+ return;
+ }
+ let fingerprint;
+
+ this.ignoreExternal = true;
+
+ let existingKey = EnigmailKeyRing.getKeyById(selectedKey);
+ if (existingKey) {
+ fingerprint = existingKey.fpr;
+ } else {
+ let unacceptedNotYetImported = keyMetas.filter(
+ k => k.readiness == "collected"
+ );
+
+ for (let keyMeta of unacceptedNotYetImported) {
+ if (keyMeta.keyObj.keyId != selectedKey) {
+ continue;
+ }
+ await EnigmailKeyRing.importKeyDataSilent(
+ window,
+ keyMeta.collectedKey.pubKey,
+ true
+ );
+ fingerprint = keyMeta.keyObj.fpr;
+ }
+ }
+
+ if (!fingerprint) {
+ throw new Error(`Key not found for id=${selectedKey}`);
+ }
+
+ await PgpSqliteDb2.addAcceptedEmail(fingerprint, recipient).catch(
+ console.error
+ );
+
+ // Trigger the UI refresh of the compose window.
+ await checkEncryptionState("openpgp-key-assistant-refresh");
+
+ this.ignoreExternal = false;
+ this.resetViews();
+ this.buildMainView();
+ },
+
+ async initOnlineDiscovery(context) {
+ let container = document.getElementById("discoveryOutput");
+ container.replaceChildren();
+
+ function write(recipient) {
+ let p = document.createElement("p");
+ let span = document.createElement("span");
+ document.l10n.setAttributes(span, "openpgp-key-assistant-discover-keys", {
+ recipient,
+ });
+ let span2 = document.createElement("span");
+ span2.classList.add("loading-inline");
+ p.append(span, " ", span2);
+ container.appendChild(p);
+ }
+
+ let gotNewData = false; // XXX: not used for anything atm
+
+ // Checking gotNewData isn't really sufficient, because the discovery could
+ // find an update for an existing key, which was expired, and is now valid
+ // again. Let's always rebuild for now.
+
+ if (context == "overview") {
+ this.ignoreExternal = true;
+ for (let email of this.recipients) {
+ if (OpenPGPAlias.hasAliasDefinition(email)) {
+ continue;
+ }
+ write(email);
+ let rv = await KeyLookupHelper.fullOnlineDiscovery(
+ "silent-collection",
+ window,
+ email,
+ null
+ );
+ gotNewData = gotNewData || rv;
+ }
+
+ // Wait a sec before closing the view, so the user has time to see what
+ // happened.
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ this.resetViews();
+ this.buildMainView();
+
+ // Online discovery and key collection triggered key change
+ // notifications. We must allow those notifications arrive while
+ // ignoreExternal is still true.
+ // Use settimeout to reset ignoreExternal to false afterwards.
+ setTimeout(function () {
+ this.ignoreExternal = false;
+ });
+ return;
+ }
+
+ // We should never arrive here for an email address that has an
+ // alias rule, because for those we don't want to perform online
+ // discovery.
+
+ if (OpenPGPAlias.hasAliasDefinition(this.currentRecip)) {
+ throw new Error(`${this.currentRecip} has an alias rule`);
+ }
+
+ write(this.currentRecip);
+
+ this.ignoreExternal = true;
+ gotNewData = await KeyLookupHelper.fullOnlineDiscovery(
+ "silent-collection",
+ window,
+ this.currentRecip,
+ null
+ );
+ // Online discovery and key collection triggered key change
+ // notifications. We must allow those notifications arrive while
+ // ignoreExternal is still true.
+ // Use settimeout to reset ignoreExternal to false afterwards.
+ setTimeout(function () {
+ this.ignoreExternal = false;
+ });
+
+ // If the recipient now has a usable previously accepted key, go back to
+ // the main view and show a successful notification.
+ let keyMetas = await EnigmailKeyRing.getEncryptionKeyMeta(
+ this.currentRecip
+ );
+
+ if (keyMetas.some(k => k.readiness == "accepted")) {
+ // Trigger the UI refresh of the compose window.
+ await checkEncryptionState("openpgp-key-assistant-refresh");
+
+ // Wait a sec before closing the view, so the user has time to see what
+ // happened.
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ this.resetViews();
+ this.buildMainView();
+
+ let notification =
+ this.notificationBox.getNotificationWithValue("acceptedKeyUpdated");
+
+ // If a notification already exists, simply update the message.
+ if (notification) {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "openpgp-key-assistant-expired-key-update",
+ {
+ recipient: this.currentRecip,
+ }
+ );
+ return;
+ }
+
+ notification = this.notificationBox.appendNotification(
+ "acceptedKeyUpdated",
+ {
+ label: {
+ "l10n-id": "openpgp-key-assistant-expired-key-update",
+ "l10n-args": { recipient: this.currentRecip },
+ },
+ priority: this.notificationBox.PRIORITY_INFO_HIGH,
+ },
+ null
+ );
+ notification.setAttribute("type", "success");
+ return;
+ }
+
+ this.buildResolveView(this.currentRecip, keyMetas);
+ gKeyAssistant.changeView("resolve");
+ },
+
+ toggleRecipientsList() {
+ let list = document.getElementById("keysListValid");
+ list.hidden = !list.hidden;
+
+ document.l10n.setAttributes(
+ document.getElementById("toggleRecipientsButton"),
+ list.hidden
+ ? "openpgp-key-assistant-recipients-show-button"
+ : "openpgp-key-assistant-recipients-hide-button"
+ );
+ },
+
+ async importFromFile(context) {
+ await EnigmailCommon_importObjectFromFile("pub");
+ if (context == "overview") {
+ this.buildMainView();
+ } else {
+ this.buildResolveView(
+ this.currentRecip,
+ await EnigmailKeyRing.getEncryptionKeyMeta(this.currentRecip)
+ );
+ }
+ },
+
+ onExternalKeyChange() {
+ if (!this.dialog || !this.dialog.open) {
+ return;
+ }
+
+ if (this.ignoreExternal) {
+ return;
+ }
+
+ // Refresh the "overview", which will potentially close a currently
+ // shown "resolve" view.
+ this.resetViews();
+ this.buildMainView();
+ },
+};
diff --git a/comm/mail/extensions/openpgp/content/ui/keyDetailsDlg.js b/comm/mail/extensions/openpgp/content/ui/keyDetailsDlg.js
new file mode 100644
index 0000000000..d82848f932
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/keyDetailsDlg.js
@@ -0,0 +1,1119 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+// from enigmailKeyManager.js:
+/* global l10n */
+
+"use strict";
+
+var { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+var { EnigmailFuncs } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/funcs.jsm"
+);
+var { EnigmailLog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/log.jsm"
+);
+var { EnigmailKey } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/key.jsm"
+);
+var { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+var { PgpSqliteDb2 } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/sqliteDb.jsm"
+);
+var { EnigmailCryptoAPI } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/cryptoAPI.jsm"
+);
+var { KeyLookupHelper } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyLookupHelper.jsm"
+);
+var { RNP, RnpPrivateKeyUnlockTracker } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/RNP.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+});
+
+var gModePersonal = false;
+
+// This is the ID that was given to us as a parameter.
+// Note that it might be the ID of a subkey.
+var gKeyId = null;
+
+var gUserId = null;
+var gKeyList = null;
+var gSigTree = null;
+
+var gAllEmails = [];
+var gOriginalAcceptedEmails = null;
+var gAcceptedEmails = null;
+
+var gHaveUnacceptedEmails = false;
+var gFingerprint = "";
+var gHasMissingSecret = false;
+
+var gAcceptanceRadio = null;
+var gPersonalRadio = null;
+
+var gOriginalAcceptance;
+var gOriginalPersonal;
+var gUpdateAllowed = false;
+
+let gAllEmailCheckboxes = [];
+let gOkButton;
+
+let gPrivateKeyTrackers = [];
+
+window.addEventListener("DOMContentLoaded", onLoad);
+window.addEventListener("unload", onUnload);
+
+function onUnload() {
+ releasePrivateKeys();
+}
+
+function releasePrivateKeys() {
+ for (let tracker of gPrivateKeyTrackers) {
+ tracker.release();
+ }
+ gPrivateKeyTrackers = [];
+}
+
+async function onLoad() {
+ if (window.arguments[1]) {
+ window.arguments[1].refresh = false;
+ }
+
+ gAcceptanceRadio = document.getElementById("acceptanceRadio");
+ gPersonalRadio = document.getElementById("personalRadio");
+
+ gKeyId = window.arguments[0].keyId;
+
+ gOkButton = document.querySelector("dialog").getButton("accept");
+ gOkButton.focus();
+
+ await reloadData(true);
+
+ let sepPassphraseEnabled =
+ gModePersonal &&
+ Services.prefs.getBoolPref("mail.openpgp.passphrases.enabled");
+ document.getElementById("passphraseTab").hidden = !sepPassphraseEnabled;
+ document.getElementById("passphrasePanel").hidden = !sepPassphraseEnabled;
+ if (sepPassphraseEnabled) {
+ await loadPassphraseProtection();
+ }
+
+ onAcceptanceChanged();
+}
+
+/***
+ * Set the label text of a HTML element
+ */
+function setLabel(elementId, label) {
+ let node = document.getElementById(elementId);
+ node.setAttribute("value", label);
+}
+
+async function changeExpiry() {
+ let keyObj = EnigmailKeyRing.getKeyById(gKeyId);
+ if (!keyObj || !keyObj.secretAvailable) {
+ return;
+ }
+
+ if (!keyObj.iSimpleOneSubkeySameExpiry()) {
+ Services.prompt.alert(
+ null,
+ document.title,
+ await document.l10n.formatValue("openpgp-cannot-change-expiry")
+ );
+ return;
+ }
+
+ let args = {
+ keyId: keyObj.keyId,
+ modified: onDataModified,
+ };
+
+ // The keyDetailsDlg can be opened from different locations, some of which
+ // don't belong to the Account Settings, therefore they won't have access to
+ // the gSubDialog object.
+ if (parent.gSubDialog) {
+ parent.gSubDialog.open(
+ "chrome://openpgp/content/ui/changeExpiryDlg.xhtml",
+ undefined,
+ args
+ );
+ return;
+ }
+
+ window.openDialog(
+ "chrome://openpgp/content/ui/changeExpiryDlg.xhtml",
+ "",
+ "dialog,modal,centerscreen,resizable",
+ args
+ );
+}
+
+async function refreshOnline() {
+ let keyObj = EnigmailKeyRing.getKeyById(gKeyId);
+ if (!keyObj) {
+ return;
+ }
+
+ let imported = await KeyLookupHelper.lookupAndImportByKeyID(
+ "interactive-import",
+ window,
+ keyObj.fpr,
+ true
+ );
+ if (imported) {
+ onDataModified();
+ }
+}
+
+async function loadPassphraseProtection() {
+ let keyObj = EnigmailKeyRing.getKeyById(gKeyId);
+ if (!keyObj || !keyObj.secretAvailable) {
+ return;
+ }
+
+ let primaryKey = RnpPrivateKeyUnlockTracker.constructFromFingerprint(
+ keyObj.fpr
+ );
+ primaryKey.setAllowPromptingUserForPassword(false);
+ primaryKey.setAllowAutoUnlockWithCachedPasswords(false);
+ let isSecretForPrimaryAvailable = primaryKey.available();
+ let canUnlockSecretForPrimary = false;
+ if (isSecretForPrimaryAvailable) {
+ await primaryKey.unlock();
+ canUnlockSecretForPrimary = primaryKey.isUnlocked();
+ gPrivateKeyTrackers.push(primaryKey);
+ }
+
+ let countSubkeysWithSecretAvailable = 0;
+ let countSubkeysCanAutoUnlock = 0;
+
+ for (let i = 0; i < keyObj.subKeys.length; i++) {
+ let subKey = RnpPrivateKeyUnlockTracker.constructFromFingerprint(
+ keyObj.subKeys[i].fpr
+ );
+ subKey.setAllowPromptingUserForPassword(false);
+ subKey.setAllowAutoUnlockWithCachedPasswords(false);
+ let isSecretForPrimaryAvailable = subKey.available();
+ let canUnlockSecretForPrimary = false;
+ if (isSecretForPrimaryAvailable) {
+ ++countSubkeysWithSecretAvailable;
+ await subKey.unlock();
+ canUnlockSecretForPrimary = subKey.isUnlocked();
+ if (canUnlockSecretForPrimary) {
+ countSubkeysCanAutoUnlock++;
+ }
+ gPrivateKeyTrackers.push(subKey);
+ }
+ }
+
+ let userPassphraseMode = "user-passphrase";
+ let usingPP = LoginHelper.isPrimaryPasswordSet();
+ let protectionMode;
+
+ // Could we use the automatic passphrase to unlock all secret keys for
+ // which the key material is available?
+
+ if (
+ (!isSecretForPrimaryAvailable || canUnlockSecretForPrimary) &&
+ countSubkeysWithSecretAvailable == countSubkeysCanAutoUnlock
+ ) {
+ protectionMode = usingPP ? "primary-password" : "unprotected";
+ } else {
+ protectionMode = userPassphraseMode;
+ }
+
+ // Strings used here:
+ // openpgp-passphrase-status-unprotected
+ // openpgp-passphrase-status-primary-password
+ // openpgp-passphrase-status-user-passphrase
+ document.l10n.setAttributes(
+ document.getElementById("passphraseStatus"),
+ `openpgp-passphrase-status-${protectionMode}`
+ );
+
+ // Strings used here:
+ // openpgp-passphrase-instruction-unprotected
+ // openpgp-passphrase-instruction-primary-password
+ // openpgp-passphrase-instruction-user-passphrase
+ document.l10n.setAttributes(
+ document.getElementById("passphraseInstruction"),
+ `openpgp-passphrase-instruction-${protectionMode}`
+ );
+
+ document.getElementById("unlockBox").hidden =
+ protectionMode != userPassphraseMode;
+ document.getElementById("lockBox").hidden =
+ protectionMode == userPassphraseMode;
+ document.getElementById("usePrimaryPassword").hidden = true;
+ document.getElementById("removeProtection").hidden = true;
+
+ document.l10n.setAttributes(
+ document.getElementById("setPassphrase"),
+ protectionMode == userPassphraseMode
+ ? "openpgp-passphrase-change"
+ : "openpgp-passphrase-set"
+ );
+
+ document.getElementById("passwordInput").value = "";
+ document.getElementById("passwordConfirm").value = "";
+}
+
+async function unlock() {
+ let pwCache = {
+ passwords: [],
+ };
+
+ for (let tracker of gPrivateKeyTrackers) {
+ tracker.setAllowPromptingUserForPassword(true);
+ tracker.setAllowAutoUnlockWithCachedPasswords(true);
+ tracker.setPasswordCache(pwCache);
+ await tracker.unlock();
+ if (!tracker.isUnlocked()) {
+ return;
+ }
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("passphraseInstruction"),
+ "openpgp-passphrase-unlocked"
+ );
+ document.getElementById("unlockBox").hidden = true;
+ document.getElementById("lockBox").hidden = false;
+ document.getElementById("passwordInput").value = "";
+ document.getElementById("passwordConfirm").value = "";
+
+ document.getElementById(
+ LoginHelper.isPrimaryPasswordSet()
+ ? "usePrimaryPassword"
+ : "removeProtection"
+ ).hidden = false;
+
+ // Necessary to set the disabled status of the button
+ onPasswordInput();
+}
+
+function onPasswordInput() {
+ let pw1 = document.getElementById("passwordInput").value;
+ let pw2 = document.getElementById("passwordConfirm").value;
+
+ // Disable the button if the two passwords don't match, and enable it
+ // if the passwords do match.
+ let disabled = pw1 != pw2 || !pw1.length;
+
+ document.getElementById("setPassphrase").disabled = disabled;
+}
+
+async function setPassphrase() {
+ let pw = document.getElementById("passwordInput").value;
+
+ for (let tracker of gPrivateKeyTrackers) {
+ tracker.setPassphrase(pw);
+ }
+ await RNP.saveKeyRings();
+
+ releasePrivateKeys();
+ loadPassphraseProtection();
+}
+
+async function useAutoPassphrase() {
+ for (let tracker of gPrivateKeyTrackers) {
+ await tracker.setAutoPassphrase();
+ }
+ await RNP.saveKeyRings();
+
+ releasePrivateKeys();
+ loadPassphraseProtection();
+}
+
+function onAcceptanceChanged() {
+ // The check for gAcceptedEmails.size is to handle an edge case.
+ // If a key was previously accepted, for an email address that is
+ // now revoked, and another email address has been added,
+ // then the key can be marked as accepted without any accepted
+ // email address.
+ // In this scenario, we must allow the user to edit the accepted
+ // email addresses, even if there's just one email address available.
+ // Another scenario is a data inconsistency, with accepted key,
+ // but no accepted email.
+
+ let originalAccepted = isAccepted(gOriginalAcceptance);
+ let wantAccepted = isAccepted(gAcceptanceRadio.value);
+
+ let disableEmailsTab =
+ (wantAccepted &&
+ gAllEmails.length < 2 &&
+ gAcceptedEmails.size != 0 &&
+ (!originalAccepted || !gHaveUnacceptedEmails)) ||
+ !wantAccepted;
+
+ document.getElementById("emailAddressesTab").disabled = disableEmailsTab;
+ document.getElementById("emailAddressesPanel").disabled = disableEmailsTab;
+
+ setOkButtonState();
+}
+
+function onDataModified() {
+ EnigmailKeyRing.clearCache();
+ enableRefresh();
+ reloadData(false);
+}
+
+function isAccepted(value) {
+ return value == "unverified" || value == "verified";
+}
+
+async function reloadData(firstLoad) {
+ gUserId = null;
+
+ var treeChildren = document.getElementById("keyListChildren");
+
+ // clean lists
+ while (treeChildren.firstChild) {
+ treeChildren.firstChild.remove();
+ }
+
+ let keyObj = EnigmailKeyRing.getKeyById(gKeyId);
+ if (!keyObj) {
+ return;
+ }
+
+ let acceptanceIntroText = "";
+ let acceptanceVerificationText = "";
+
+ if (keyObj.fpr) {
+ gFingerprint = keyObj.fpr;
+ setLabel("fingerprint", EnigmailKey.formatFpr(keyObj.fpr));
+ }
+
+ gSigTree = document.getElementById("signatures_tree");
+ let cApi = EnigmailCryptoAPI();
+ let signatures = await cApi.getKeyObjSignatures(keyObj);
+ gSigTree.view = new SigListView(signatures);
+
+ document.getElementById("subkeyList").view = new SubkeyListView(keyObj);
+
+ gUserId = keyObj.userId;
+
+ setLabel("keyId", "0x" + keyObj.keyId);
+ setLabel("keyCreated", keyObj.created);
+
+ let keyIsExpired =
+ keyObj.effectiveExpiryTime &&
+ keyObj.effectiveExpiryTime < Math.floor(Date.now() / 1000);
+
+ let expiryInfo;
+ let expireArgument = null;
+ let expiryInfoKey = "";
+ if (keyObj.keyTrust == "r") {
+ expiryInfoKey = "key-revoked-simple";
+ } else if (keyObj.keyTrust == "e" || keyIsExpired) {
+ expiryInfoKey = "key-expired-date";
+ expireArgument = keyObj.effectiveExpiry;
+ } else if (keyObj.effectiveExpiry.length === 0) {
+ expiryInfoKey = "key-does-not-expire";
+ } else {
+ expiryInfo = keyObj.effectiveExpiry;
+ }
+ if (expiryInfoKey) {
+ expiryInfo = l10n.formatValueSync(expiryInfoKey, {
+ keyExpiry: expireArgument,
+ });
+ }
+ setLabel("keyExpiry", expiryInfo);
+
+ gModePersonal = keyObj.secretAvailable;
+
+ document.getElementById("passphraseTab").hidden = !gModePersonal;
+ document.getElementById("passphrasePanel").hidden = !gModePersonal;
+
+ if (gModePersonal) {
+ gPersonalRadio.removeAttribute("hidden");
+ gAcceptanceRadio.setAttribute("hidden", "true");
+ acceptanceIntroText = "key-accept-personal";
+ let value = l10n.formatValueSync("key-type-pair");
+ setLabel("keyType", value);
+
+ gUpdateAllowed = true;
+ if (firstLoad) {
+ gOriginalPersonal = await PgpSqliteDb2.isAcceptedAsPersonalKey(
+ keyObj.fpr
+ );
+ gPersonalRadio.value = gOriginalPersonal ? "personal" : "not_personal";
+ }
+
+ if (keyObj.keyTrust != "r") {
+ document.getElementById("changeExpiryButton").removeAttribute("hidden");
+ }
+ } else {
+ gPersonalRadio.setAttribute("hidden", "true");
+ let value = l10n.formatValueSync("key-type-public");
+ setLabel("keyType", value);
+
+ let isStillValid = !(
+ keyObj.keyTrust == "r" ||
+ keyObj.keyTrust == "e" ||
+ keyIsExpired
+ );
+ if (!isStillValid) {
+ gAcceptanceRadio.setAttribute("hidden", "true");
+ if (keyObj.keyTrust == "r") {
+ acceptanceIntroText = "key-revoked-simple";
+ } else if (keyObj.keyTrust == "e" || keyIsExpired) {
+ acceptanceIntroText = "key-expired-simple";
+ }
+ } else {
+ gAcceptanceRadio.removeAttribute("hidden");
+ acceptanceIntroText = "key-do-you-accept";
+ acceptanceVerificationText = "key-verification";
+ gUpdateAllowed = true;
+
+ //await RNP.calculateAcceptance(keyObj.keyId, null);
+
+ let acceptanceResult = await PgpSqliteDb2.getFingerprintAcceptance(
+ null,
+ keyObj.fpr
+ );
+
+ if (firstLoad) {
+ if (!acceptanceResult) {
+ gOriginalAcceptance = "undecided";
+ } else {
+ gOriginalAcceptance = acceptanceResult;
+ }
+ gAcceptanceRadio.value = gOriginalAcceptance;
+ }
+ }
+
+ if (firstLoad) {
+ gAcceptedEmails = new Set();
+
+ for (let i = 0; i < keyObj.userIds.length; i++) {
+ if (keyObj.userIds[i].type === "uid") {
+ let uidEmail = EnigmailFuncs.getEmailFromUserID(
+ keyObj.userIds[i].userId
+ );
+ if (uidEmail) {
+ gAllEmails.push(uidEmail);
+
+ if (isAccepted(gOriginalAcceptance)) {
+ let rv = {};
+ await PgpSqliteDb2.getAcceptance(keyObj.fpr, uidEmail, rv);
+ if (rv.emailDecided) {
+ gAcceptedEmails.add(uidEmail);
+ } else {
+ gHaveUnacceptedEmails = true;
+ }
+ } else {
+ // For not-yet-accepted keys, our default is to accept
+ // all shown email addresses.
+ gAcceptedEmails.add(uidEmail);
+ }
+ }
+ }
+ }
+
+ // clone
+ gOriginalAcceptedEmails = new Set(gAcceptedEmails);
+ }
+ }
+
+ await createUidData(keyObj);
+
+ if (acceptanceIntroText) {
+ let acceptanceIntro = document.getElementById("acceptanceIntro");
+ document.l10n.setAttributes(acceptanceIntro, acceptanceIntroText);
+ }
+
+ if (acceptanceVerificationText) {
+ let acceptanceVerification = document.getElementById(
+ "acceptanceVerification"
+ );
+ document.l10n.setAttributes(
+ acceptanceVerification,
+ acceptanceVerificationText,
+ {
+ addr: EnigmailFuncs.getEmailFromUserID(gUserId).toLowerCase(),
+ }
+ );
+ }
+
+ document.getElementById("key-detail-has-insecure").hidden =
+ !keyObj.hasIgnoredAttributes;
+}
+
+function setOkButtonState() {
+ let atLeastOneChecked = gAllEmailCheckboxes.some(c => c.checked);
+ gOkButton.disabled = !atLeastOneChecked && isAccepted(gAcceptanceRadio.value);
+}
+
+async function createUidData(keyDetails) {
+ var uidList = document.getElementById("userIds");
+ while (uidList.firstChild) {
+ uidList.firstChild.remove();
+ }
+
+ let primaryIdIndex = 0;
+
+ for (let i = 0; i < keyDetails.userIds.length; i++) {
+ if (keyDetails.userIds[i].type === "uid") {
+ if (keyDetails.userIds[i].userId == keyDetails.userId) {
+ primaryIdIndex = i;
+ break;
+ }
+ }
+ }
+
+ for (let i = -1; i < keyDetails.userIds.length; i++) {
+ // Handle entry primaryIdIndex first.
+
+ let indexToUse;
+ if (i == -1) {
+ indexToUse = primaryIdIndex;
+ } else if (i == primaryIdIndex) {
+ // already handled when i was -1
+ continue;
+ } else {
+ indexToUse = i;
+ }
+
+ if (keyDetails.userIds[indexToUse].type === "uid") {
+ let uidStr = keyDetails.userIds[indexToUse].userId;
+
+ /* - attempted code with <ul id="userIds">, doesn't work yet
+ let item = document.createElement("li");
+
+ let text = document.createElement("div");
+ text.textContent = uidStr;
+ item.append(text);
+
+ let lf = document.createElement("br");
+ item.append(lf);
+ uidList.appendChild(item);
+ */
+
+ uidList.appendItem(uidStr);
+ }
+ }
+
+ if (gModePersonal) {
+ document.getElementById("emailAddressesTab").hidden = true;
+ } else {
+ let emailList = document.getElementById("addressesList");
+
+ let atLeastOneChecked = false;
+ let gUniqueEmails = new Set();
+
+ for (let i = 0; i < gAllEmails.length; i++) {
+ let email = gAllEmails[i];
+ if (gUniqueEmails.has(email)) {
+ continue;
+ }
+ gUniqueEmails.add(email);
+
+ let checkbox = document.createXULElement("checkbox");
+
+ checkbox.value = email;
+ checkbox.setAttribute("label", email);
+
+ checkbox.checked = gAcceptedEmails.has(email);
+ if (checkbox.checked) {
+ atLeastOneChecked = true;
+ }
+
+ checkbox.disabled = !gUpdateAllowed;
+ checkbox.addEventListener("command", () => {
+ setOkButtonState();
+ });
+
+ emailList.appendChild(checkbox);
+ gAllEmailCheckboxes.push(checkbox);
+ }
+
+ // Usually, if we have only one email address available,
+ // we want to hide the tab.
+ // There are edge cases - if we have a data inconsistency
+ // (key accepted, but no email accepted), then we must show,
+ // to allow the user to repair.
+
+ document.getElementById("emailAddressesTab").hidden =
+ gUniqueEmails.size < 2 && atLeastOneChecked;
+ }
+}
+
+function setAttr(attribute, value) {
+ var elem = document.getElementById(attribute);
+ if (elem) {
+ elem.value = value;
+ }
+}
+
+function enableRefresh() {
+ if (window.arguments[1]) {
+ window.arguments[1].refresh = true;
+ }
+
+ window.arguments[0].modified();
+}
+
+// ------------------ onCommand Functions -----------------
+
+/*
+function signKey() {
+ if (EnigmailWindows.signKey(window, gUserId, gKeyId)) {
+ enableRefresh();
+ reloadData(false);
+ }
+}
+*/
+
+/*
+function manageUids() {
+ let keyObj = EnigmailKeyRing.getKeyById(gKeyId);
+
+ var inputObj = {
+ keyId: keyObj.keyId,
+ ownKey: keyObj.secretAvailable,
+ };
+
+ var resultObj = {
+ refresh: false,
+ };
+ window.openDialog(
+ "chrome://openpgp/content/ui/enigmailManageUidDlg.xhtml",
+ "",
+ "dialog,modal,centerscreen,resizable=yes",
+ inputObj,
+ resultObj
+ );
+ if (resultObj.refresh) {
+ enableRefresh();
+ reloadData(false);
+ }
+}
+*/
+
+function genRevocationCert() {
+ throw new Error("Not implemented");
+
+ /*
+ var defaultFileName = userId.replace(/[<>]/g, "");
+ defaultFileName += " (0x" + keyId + ") rev.asc";
+ var outFile = EnigFilePicker("XXXsaveRevokeCertAs",
+ "", true, "*.asc",
+ defaultFileName, ["XXXasciiArmorFile", "*.asc"];
+ if (!outFile) return -1;
+
+ return 0;
+ */
+}
+
+/**
+ * @param {Object[]] signatures - list of signature objects
+ * signatures.userId {string} - User ID.
+ * signatures.uidLabel {string} - UID label.
+ * signatures.created
+ * signatures.fpr {string} - Fingerprint.
+ * signatures.sigList {Object[]} - Objects
+ * signatures.sigList.userId
+ * signatures.sigList.created
+ * signatures.sigList.signerKeyId
+ * signatures.sigList.sigType
+ * signatures.sigList.sigKnown
+ */
+function SigListView(signatures) {
+ this.keyObj = [];
+
+ for (let sig of signatures) {
+ let k = {
+ uid: sig.userId,
+ keyId: sig.keyId,
+ created: sig.created,
+ expanded: true,
+ sigList: [],
+ };
+
+ for (let s of sig.sigList) {
+ k.sigList.push({
+ uid: s.userId,
+ created: s.created,
+ keyId: s.signerKeyId,
+ sigType: s.sigType,
+ });
+ }
+ this.keyObj.push(k);
+ }
+
+ this.prevKeyObj = null;
+ this.prevRow = -1;
+
+ this.updateRowCount();
+}
+
+/**
+ * @implements {nsITreeView}
+ */
+SigListView.prototype = {
+ updateRowCount() {
+ let rc = 0;
+
+ for (let i in this.keyObj) {
+ rc += this.keyObj[i].expanded ? this.keyObj[i].sigList.length + 1 : 1;
+ }
+
+ this.rowCount = rc;
+ },
+
+ setLastKeyObj(keyObj, row) {
+ this.prevKeyObj = keyObj;
+ this.prevRow = row;
+ return keyObj;
+ },
+
+ getSigAtIndex(row) {
+ if (this.lastIndex == row) {
+ return this.lastKeyObj;
+ }
+
+ let j = 0,
+ l = 0;
+
+ for (let i in this.keyObj) {
+ if (j === row) {
+ return this.setLastKeyObj(this.keyObj[i], row);
+ }
+ j++;
+
+ if (this.keyObj[i].expanded) {
+ l = this.keyObj[i].sigList.length;
+
+ if (j + l >= row && row - j < l) {
+ return this.setLastKeyObj(this.keyObj[i].sigList[row - j], row);
+ }
+ j += l;
+ }
+ }
+
+ return null;
+ },
+
+ getCellText(row, column) {
+ let s = this.getSigAtIndex(row);
+
+ if (s) {
+ switch (column.id) {
+ case "sig_uid_col":
+ return s.uid;
+ case "sig_keyid_col":
+ return "0x" + s.keyId;
+ case "sig_created_col":
+ return s.created;
+ }
+ }
+
+ return "";
+ },
+
+ setTree(treebox) {
+ this.treebox = treebox;
+ },
+
+ isContainer(row) {
+ let s = this.getSigAtIndex(row);
+ return "sigList" in s;
+ },
+
+ isSeparator(row) {
+ return false;
+ },
+
+ isSorted() {
+ return false;
+ },
+
+ getLevel(row) {
+ let s = this.getSigAtIndex(row);
+ return "sigList" in s ? 0 : 1;
+ },
+
+ cycleHeader(col, elem) {},
+
+ getImageSrc(row, col) {
+ return null;
+ },
+
+ getRowProperties(row, props) {},
+
+ getCellProperties(row, col) {
+ return "";
+ },
+
+ canDrop(row, orientation, data) {
+ return false;
+ },
+
+ getColumnProperties(colid, col, props) {},
+
+ isContainerEmpty(row) {
+ return false;
+ },
+
+ getParentIndex(idx) {
+ return -1;
+ },
+
+ getProgressMode(row, col) {},
+
+ isContainerOpen(row) {
+ let s = this.getSigAtIndex(row);
+ return s.expanded;
+ },
+
+ isSelectable(row, col) {
+ return true;
+ },
+
+ toggleOpenState(row) {
+ let s = this.getSigAtIndex(row);
+ s.expanded = !s.expanded;
+ let r = this.rowCount;
+ this.updateRowCount();
+ gSigTree.rowCountChanged(row, this.rowCount - r);
+ },
+};
+
+function createSubkeyItem(mainKeyIsSecret, subkey) {
+ // Get expiry state of this subkey
+ let expire;
+ if (subkey.keyTrust === "r") {
+ expire = l10n.formatValueSync("key-valid-revoked");
+ } else if (subkey.expiryTime === 0) {
+ expire = l10n.formatValueSync("key-expiry-never");
+ } else {
+ expire = subkey.expiry;
+ }
+
+ let subkeyType = "";
+
+ if (mainKeyIsSecret && (!subkey.secretAvailable || !subkey.secretMaterial)) {
+ subkeyType = "(!) ";
+ gHasMissingSecret = true;
+ }
+ if (subkey.type === "pub") {
+ subkeyType += l10n.formatValueSync("key-type-primary");
+ } else {
+ subkeyType += l10n.formatValueSync("key-type-subkey");
+ }
+
+ let usagetext = "";
+ let i;
+ // e = encrypt
+ // s = sign
+ // c = certify
+ // a = authentication
+ // Capital Letters are ignored, as these reflect summary properties of a key
+
+ var singlecode = "";
+ for (i = 0; i < subkey.keyUseFor.length; i++) {
+ singlecode = subkey.keyUseFor.substr(i, 1);
+ switch (singlecode) {
+ case "e":
+ if (usagetext.length > 0) {
+ usagetext = usagetext + ", ";
+ }
+ usagetext = usagetext + l10n.formatValueSync("key-usage-encrypt");
+ break;
+ case "s":
+ if (usagetext.length > 0) {
+ usagetext = usagetext + ", ";
+ }
+ usagetext = usagetext + l10n.formatValueSync("key-usage-sign");
+ break;
+ case "c":
+ if (usagetext.length > 0) {
+ usagetext = usagetext + ", ";
+ }
+ usagetext = usagetext + l10n.formatValueSync("key-usage-certify");
+ break;
+ case "a":
+ if (usagetext.length > 0) {
+ usagetext = usagetext + ", ";
+ }
+ usagetext =
+ usagetext + l10n.formatValueSync("key-usage-authentication");
+ break;
+ } // * case *
+ } // * for *
+
+ let keyObj = {
+ keyType: subkeyType,
+ keyId: "0x" + subkey.keyId,
+ algo: subkey.algoSym,
+ size: subkey.keySize,
+ creationDate: subkey.created,
+ expiry: expire,
+ usage: usagetext,
+ };
+
+ return keyObj;
+}
+
+function SubkeyListView(keyObj) {
+ gHasMissingSecret = false;
+
+ this.subkeys = [];
+ this.rowCount = keyObj.subKeys.length + 1;
+ this.subkeys.push(createSubkeyItem(keyObj.secretAvailable, keyObj));
+
+ for (let i = 0; i < keyObj.subKeys.length; i++) {
+ this.subkeys.push(
+ createSubkeyItem(keyObj.secretAvailable, keyObj.subKeys[i])
+ );
+ }
+
+ document.getElementById("legendMissingSecret").hidden = !gHasMissingSecret;
+}
+
+// implements nsITreeView
+SubkeyListView.prototype = {
+ getCellText(row, column) {
+ let s = this.subkeys[row];
+
+ if (s) {
+ switch (column.id) {
+ case "keyTypeCol":
+ return s.keyType;
+ case "keyIdCol":
+ return s.keyId;
+ case "algoCol":
+ return s.algo;
+ case "sizeCol":
+ return s.size;
+ case "createdCol":
+ return s.creationDate;
+ case "expiryCol":
+ return s.expiry;
+ case "keyUsageCol":
+ return s.usage;
+ }
+ }
+
+ return "";
+ },
+
+ setTree(treebox) {
+ this.treebox = treebox;
+ },
+
+ isContainer(row) {
+ return false;
+ },
+
+ isSeparator(row) {
+ return false;
+ },
+
+ isSorted() {
+ return false;
+ },
+
+ getLevel(row) {
+ return 0;
+ },
+
+ cycleHeader(col, elem) {},
+
+ getImageSrc(row, col) {
+ return null;
+ },
+
+ getRowProperties(row, props) {},
+
+ getCellProperties(row, col) {
+ return "";
+ },
+
+ canDrop(row, orientation, data) {
+ return false;
+ },
+
+ getColumnProperties(colid, col, props) {},
+
+ isContainerEmpty(row) {
+ return false;
+ },
+
+ getParentIndex(idx) {
+ return -1;
+ },
+
+ getProgressMode(row, col) {},
+
+ isContainerOpen(row) {
+ return false;
+ },
+
+ isSelectable(row, col) {
+ return true;
+ },
+
+ toggleOpenState(row) {},
+};
+
+function sigHandleDblClick(event) {}
+
+document.addEventListener("dialogaccept", async function (event) {
+ // Prevent the closing of the dialog to wait until all the SQLite operations
+ // have properly been executed.
+ event.preventDefault();
+
+ // The user's personal OpenPGP key acceptance was edited.
+ if (gModePersonal) {
+ if (gUpdateAllowed && gPersonalRadio.value != gOriginalPersonal) {
+ if (gPersonalRadio.value == "personal") {
+ await PgpSqliteDb2.acceptAsPersonalKey(gFingerprint);
+ } else {
+ await PgpSqliteDb2.deletePersonalKeyAcceptance(gFingerprint);
+ }
+
+ enableRefresh();
+ }
+ window.close();
+ return;
+ }
+
+ // If the recipient's key hasn't been revoked or invalidated, and the
+ // signature acceptance was edited.
+ if (gUpdateAllowed) {
+ let selectedEmails = new Set();
+ for (let checkbox of gAllEmailCheckboxes) {
+ if (checkbox.checked) {
+ selectedEmails.add(checkbox.value);
+ }
+ }
+
+ if (
+ gAcceptanceRadio.value != gOriginalAcceptance ||
+ !CommonUtils.setEqual(gAcceptedEmails, selectedEmails)
+ ) {
+ await PgpSqliteDb2.updateAcceptance(
+ gFingerprint,
+ [...selectedEmails],
+ gAcceptanceRadio.value
+ );
+
+ enableRefresh();
+ }
+ }
+
+ window.close();
+});
diff --git a/comm/mail/extensions/openpgp/content/ui/keyDetailsDlg.xhtml b/comm/mail/extensions/openpgp/content/ui/keyDetailsDlg.xhtml
new file mode 100644
index 0000000000..a7f57d0339
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/keyDetailsDlg.xhtml
@@ -0,0 +1,405 @@
+<?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://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/accountManage.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/keyDetails.css" type="text/css"?>
+
+<!DOCTYPE html>
+<html
+ id="enigmailKeyDetailsDlg"
+ 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"
+>
+ <head>
+ <title data-l10n-id="openpgp-key-details-doc-title"></title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js" />
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js" />
+ <script defer="defer" src="chrome://openpgp/content/ui/enigmailCommon.js" />
+ <script
+ defer="defer"
+ src="chrome://openpgp/content/ui/enigmailKeyManager.js"
+ />
+ <script defer="defer" src="chrome://openpgp/content/ui/keyDetailsDlg.js" />
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog
+ buttons="accept,cancel"
+ data-l10n-id="openpgp-card-details-close-window-label"
+ >
+ <html:div class="key-details-container">
+ <html:aside class="key-details-grid">
+ <label
+ class="key-detail-label"
+ data-l10n-id="openpgp-key-details-user-id3-label"
+ />
+
+ <richlistbox
+ id="userIds"
+ class="additional-key-identity plain"
+ flex="1"
+ />
+
+ <label
+ class="key-detail-label"
+ data-l10n-id="openpgp-key-details-key-type-label"
+ />
+ <hbox class="input-container">
+ <html:input
+ id="keyType"
+ type="text"
+ class="plain"
+ readonly="readonly"
+ value="?"
+ />
+ </hbox>
+
+ <label
+ class="key-detail-label"
+ data-l10n-id="openpgp-key-details-key-id-label"
+ />
+ <hbox class="input-container">
+ <html:input
+ id="keyId"
+ type="text"
+ class="plain"
+ readonly="readonly"
+ value="?"
+ />
+ </hbox>
+
+ <label
+ class="key-detail-label"
+ data-l10n-id="openpgp-key-details-fingerprint-label"
+ />
+ <hbox class="input-container">
+ <html:input
+ id="fingerprint"
+ type="text"
+ class="plain"
+ readonly="readonly"
+ value="?"
+ />
+ </hbox>
+
+ <label
+ class="key-detail-label"
+ data-l10n-id="openpgp-key-details-created-header"
+ />
+ <hbox class="input-container">
+ <html:input
+ id="keyCreated"
+ type="text"
+ class="plain"
+ readonly="readonly"
+ value="?"
+ />
+ </hbox>
+
+ <label
+ class="key-detail-label"
+ data-l10n-id="openpgp-key-details-expiry-header"
+ />
+ <hbox class="input-container">
+ <html:input
+ id="keyExpiry"
+ type="text"
+ class="plain"
+ readonly="readonly"
+ value="?"
+ />
+ </hbox>
+ </html:aside>
+
+ <html:aside>
+ <vbox>
+ <button
+ id="refreshOnlineButton"
+ data-l10n-id="openpgp-key-man-refresh-online"
+ oncommand="refreshOnline()"
+ />
+ <button
+ id="changeExpiryButton"
+ data-l10n-id="openpgp-key-man-change-expiry"
+ oncommand="changeExpiry()"
+ hidden="true"
+ />
+ </vbox>
+ </html:aside>
+ </html:div>
+
+ <html:div id="key-detail-has-insecure" hidden="hidden">
+ <html:span
+ class="tail-with-learn-more"
+ data-l10n-id="openpgp-key-details-attr-ignored"
+ ></html:span>
+ <label
+ is="text-link"
+ href="https://support.mozilla.org/kb/openpgp-unsafe-key-properties-ignored"
+ data-l10n-id="e2e-learn-more"
+ />
+ </html:div>
+
+ <separator />
+
+ <tabbox flex="1" style="margin: 5px" id="mainTabs">
+ <tabs id="mainTabBox">
+ <tab id="acceptanceTab" data-l10n-id="openpgp-acceptance-label" />
+ <tab
+ id="emailAddressesTab"
+ data-l10n-id="openpgp-key-man-ignored-ids"
+ />
+ <tab
+ id="passphraseTab"
+ data-l10n-id="openpgp-passphrase-protection"
+ />
+ <tab
+ id="signaturesTab"
+ data-l10n-id="openpgp-key-details-signatures-tab"
+ />
+ <tab
+ id="structureTab"
+ data-l10n-id="openpgp-key-details-structure-tab"
+ />
+ </tabs>
+
+ <tabpanels flex="1" id="mainTabPanel">
+ <!-- Acceptance Tab -->
+ <vbox id="acceptancePanel" flex="1">
+ <description id="acceptanceIntro" />
+ <separator class="thin" />
+
+ <html:div>
+ <html:fieldset>
+ <radiogroup
+ id="acceptanceRadio"
+ hidden="true"
+ class="indent"
+ oncommand="onAcceptanceChanged();"
+ >
+ <radio
+ id="acceptRejected"
+ value="rejected"
+ data-l10n-id="openpgp-acceptance-rejected-label"
+ />
+ <radio
+ id="acceptUndecided"
+ value="undecided"
+ data-l10n-id="openpgp-acceptance-undecided-label"
+ />
+ <radio
+ id="acceptUnverified"
+ value="unverified"
+ data-l10n-id="openpgp-acceptance-unverified-label"
+ />
+ <radio
+ id="acceptVerified"
+ value="verified"
+ data-l10n-id="openpgp-acceptance-verified-label"
+ />
+ </radiogroup>
+ <radiogroup id="personalRadio" class="indent" hidden="true">
+ <radio
+ id="notPersonal"
+ value="not_personal"
+ data-l10n-id="openpgp-personal-no-label"
+ />
+ <radio
+ id="yesPersonal"
+ value="personal"
+ data-l10n-id="openpgp-personal-yes-label"
+ />
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <separator class="thin" />
+ <description id="acceptanceVerification" />
+ </vbox>
+
+ <!-- email addresses tab -->
+ <vbox id="emailAddressesPanel" flex="1">
+ <description data-l10n-id="openpgp-ign-addr-intro" />
+ <separator class="thin" />
+
+ <vbox id="addressesListContainer">
+ <vbox id="addressesList" class="indent" />
+ </vbox>
+ </vbox>
+
+ <!-- passphrase tab -->
+ <vbox id="passphrasePanel" flex="1">
+ <description id="passphraseStatus" />
+ <separator class="thin" />
+ <description id="passphraseInstruction" />
+ <separator class="thin" />
+
+ <vbox id="unlockBox">
+ <hbox>
+ <button
+ id="unlock"
+ data-l10n-id="openpgp-passphrase-unlock"
+ oncommand="unlock()"
+ />
+ </hbox>
+ </vbox>
+
+ <vbox id="lockBox">
+ <hbox>
+ <label data-l10n-id="openpgp-passphrase-new" />
+ <html:input
+ id="passwordInput"
+ type="password"
+ class="input-inline"
+ oninput="onPasswordInput();"
+ />
+ </hbox>
+ <hbox>
+ <label data-l10n-id="openpgp-passphrase-new-repeat" />
+ <html:input
+ id="passwordConfirm"
+ type="password"
+ class="input-inline"
+ oninput="onPasswordInput();"
+ />
+ <button
+ id="setPassphrase"
+ disabled="true"
+ oncommand="setPassphrase();"
+ />
+ </hbox>
+ <separator class="thin" />
+ </vbox>
+
+ <hbox>
+ <button
+ id="removeProtection"
+ hidden="true"
+ data-l10n-id="openpgp-remove-protection"
+ oncommand="useAutoPassphrase()"
+ />
+ <button
+ id="usePrimaryPassword"
+ hidden="true"
+ data-l10n-id="openpgp-use-primary-password"
+ oncommand="useAutoPassphrase()"
+ />
+ </hbox>
+ </vbox>
+
+ <!-- certifications tab -->
+ <vbox id="signaturesPanel">
+ <tree
+ id="signatures_tree"
+ flex="1"
+ hidecolumnpicker="true"
+ ondblclick="sigHandleDblClick(event)"
+ >
+ <treecols>
+ <treecol
+ id="sig_uid_col"
+ style="flex: 1 auto"
+ data-l10n-id="openpgp-key-details-uid-certified-col"
+ primary="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="sig_keyid_col"
+ data-l10n-id="openpgp-key-id-label"
+ persist="width"
+ minwidth="140"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="sig_created_col"
+ data-l10n-id="openpgp-key-details-created-label"
+ persist="width"
+ minwidth="100"
+ />
+ </treecols>
+
+ <treechildren />
+ </tree>
+ </vbox>
+
+ <!-- structure tab -->
+ <vbox id="structurePanel">
+ <hbox flex="1">
+ <tree
+ id="subkeyList"
+ flex="1"
+ enableColumnDrag="true"
+ hidecolumnpicker="false"
+ >
+ <treecols>
+ <treecol
+ id="keyTypeCol"
+ data-l10n-id="openpgp-key-details-key-part-label"
+ style="width: 71px"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="keyUsageCol"
+ data-l10n-id="openpgp-key-details-usage-label"
+ style="flex: 1 auto"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="keyIdCol"
+ style="width: 77px"
+ data-l10n-id="openpgp-key-details-id-label"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="algoCol"
+ style="width: 60px"
+ data-l10n-id="openpgp-key-details-algorithm-label"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="sizeCol"
+ style="width: 37px"
+ data-l10n-id="openpgp-key-details-size-label"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="createdCol"
+ style="width: 70px"
+ data-l10n-id="openpgp-key-details-created-label"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="expiryCol"
+ style="width: 70px"
+ data-l10n-id="openpgp-key-details-expiry-label"
+ persist="width"
+ />
+ </treecols>
+
+ <treechildren id="keyListChildren" />
+ </tree>
+ </hbox>
+ <label
+ id="legendMissingSecret"
+ class="tip-caption"
+ data-l10n-id="openpgp-key-details-legend-secret-missing"
+ hidden="true"
+ />
+ </vbox>
+ </tabpanels>
+ </tabbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/mail/extensions/openpgp/content/ui/keyWizard.js b/comm/mail/extensions/openpgp/content/ui/keyWizard.js
new file mode 100644
index 0000000000..fe699bcc7e
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/keyWizard.js
@@ -0,0 +1,1195 @@
+/* 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 GetEnigmailSvc */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { EnigmailCryptoAPI } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/cryptoAPI.jsm"
+);
+var { OpenPGPMasterpass } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/masterpass.jsm"
+);
+var { EnigmailDialog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/dialog.jsm"
+);
+var { EnigmailKey } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/key.jsm"
+);
+var { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+var { EnigmailWindows } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/windows.jsm"
+);
+var { PgpSqliteDb2 } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/sqliteDb.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+});
+
+// UI variables.
+var gIdentity;
+var gIdentityList;
+var gSubDialog;
+var kStartSection;
+var kDialog;
+var kCurrentSection = "start";
+var kGenerating = false;
+var kButtonLabel;
+
+// OpenPGP variables.
+var gKeygenRequest;
+var gAllData = "";
+var gGeneratedKey = null;
+var gFiles;
+
+const DEFAULT_FILE_PERMS = 0o600;
+
+// The revocation strings are not localization since the revocation certificate
+// will be published to others who may not know the native language of the user.
+const revocationFilePrefix1 =
+ "This is a revocation certificate for the OpenPGP key:";
+const revocationFilePrefix2 = `
+A revocation certificate is kind of a "kill switch" to publicly
+declare that a key shall no longer be used. It is not possible
+to retract such a revocation certificate once it has been published.
+
+Use it to revoke this key in case of a secret key compromise, or loss of
+the secret key, or loss of passphrase of the secret key.
+
+To avoid an accidental use of this file, a colon has been inserted
+before the 5 dashes below. Remove this colon with a text editor
+before importing and publishing this revocation certificate.
+
+:`;
+
+var syncl10n = new Localization(["messenger/openpgp/keyWizard.ftl"], true);
+
+// Dialog event listeners.
+document.addEventListener("dialogaccept", wizardContinue);
+document.addEventListener("dialogextra1", goBack);
+document.addEventListener("dialogcancel", onClose);
+
+/**
+ * Initialize the keyWizard dialog.
+ */
+async function init() {
+ gSubDialog = window.arguments[0].gSubDialog;
+ gIdentity = window.arguments[0].identity || null;
+ gIdentityList = document.getElementById("userIdentity");
+
+ kStartSection = document.getElementById("wizardStart");
+ kDialog = document.querySelector("dialog");
+
+ await initIdentity();
+
+ // Show the GnuPG radio selection if the pref is enabled.
+ if (Services.prefs.getBoolPref("mail.openpgp.allow_external_gnupg")) {
+ document.getElementById("externalOpenPgp").removeAttribute("hidden");
+ }
+
+ // After the dialog is visible, disable the event listeners causing it to
+ // close when clicking on the overlay or hitting the Esc key, and remove the
+ // close button from the header. This is necessary to control the escape
+ // point and prevent the accidental dismiss of the dialog during important
+ // processes, like the generation or importing of a key.
+ setTimeout(() => {
+ // Check if the attribute is not null. This can be removed after the full
+ // conversion of the Key Manager into a SubDialog in Bug 1652537.
+ if (gSubDialog) {
+ gSubDialog._topDialog._removeDialogEventListeners();
+ gSubDialog._topDialog._closeButton.remove();
+ resizeDialog();
+ }
+ }, 150);
+
+ // Switch directly to the create screen if requested by the user.
+ if (window.arguments[0].isCreate) {
+ document.getElementById("openPgpKeyChoices").value = 0;
+
+ switchSection();
+ }
+
+ // Switch directly to the import screen if requested by the user.
+ if (window.arguments[0].isImport) {
+ document.getElementById("openPgpKeyChoices").value = 1;
+
+ // Disable the "Continue" button so the user can't accidentally click on it.
+ // See bug 1689980.
+ kDialog.getButton("accept").setAttribute("disabled", true);
+
+ switchSection();
+ }
+}
+
+function onProtectionChange() {
+ let pw1Element = document.getElementById("passwordInput");
+ let pw2Element = document.getElementById("passwordConfirm");
+
+ let pw1 = pw1Element.value;
+ let pw2 = pw2Element.value;
+
+ let inputDisabled = document.getElementById("keygenAutoProtection").selected;
+ pw1Element.disabled = inputDisabled;
+ pw2Element.disabled = inputDisabled;
+
+ let buttonEnabled = inputDisabled || (!inputDisabled && pw1 == pw2 && pw1);
+ let ok = kDialog.getButton("accept");
+ ok.disabled = !buttonEnabled;
+}
+
+/**
+ * Populate the identity menulist with all the valid and available identities
+ * and autoselect the current identity if available.
+ */
+async function initIdentity() {
+ let identityListPopup = document.getElementById("userIdentityPopup");
+
+ for (let identity of MailServices.accounts.allIdentities) {
+ // Skip invalid and non-email identities.
+ if (!identity.valid || !identity.email) {
+ continue;
+ }
+
+ // Interrupt if no server was defined for this identity.
+ let servers = MailServices.accounts.getServersForIdentity(identity);
+ if (servers.length == 0) {
+ continue;
+ }
+
+ let item = document.createXULElement("menuitem");
+ item.setAttribute(
+ "label",
+ `${identity.identityName} - ${servers[0].prettyName}`
+ );
+ item.setAttribute("class", "identity-popup-item");
+ item.setAttribute("accountname", servers[0].prettyName);
+ item.setAttribute("identitykey", identity.key);
+ item.setAttribute("email", identity.email);
+
+ identityListPopup.appendChild(item);
+
+ if (gIdentity && gIdentity.key == identity.key) {
+ gIdentityList.selectedItem = item;
+ }
+ }
+
+ // If not identity was originally passed during the creation of this dialog,
+ // select the first available value.
+ if (!gIdentity) {
+ gIdentityList.selectedIndex = 0;
+ }
+
+ await setIdentity();
+}
+
+/**
+ * Update the currently used identity to reflect the user selection from the
+ * identity menulist.
+ */
+async function setIdentity() {
+ if (gIdentityList.selectedItem) {
+ gIdentity = MailServices.accounts.getIdentity(
+ gIdentityList.selectedItem.getAttribute("identitykey")
+ );
+
+ document.l10n.setAttributes(
+ document.documentElement,
+ "key-wizard-dialog-window",
+ {
+ identity: gIdentity.email,
+ }
+ );
+ }
+}
+
+/**
+ * Intercept the dialogaccept command to implement a wizard like setup workflow.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+function wizardContinue(event) {
+ event.preventDefault();
+
+ // Pretty impossible scenario but just in case if no radio button is
+ // currently selected, bail out.
+ if (!document.getElementById("openPgpKeyChoices").value) {
+ return;
+ }
+
+ // Trigger an action based on the currently visible section.
+ if (kCurrentSection != "start") {
+ wizardNextStep();
+ return;
+ }
+
+ // Disable the `Continue` button.
+ kDialog.getButton("accept").setAttribute("disabled", true);
+
+ kStartSection.addEventListener("transitionend", switchSection, {
+ once: true,
+ });
+ kStartSection.classList.add("hide");
+}
+
+/**
+ * Separated method dealing with the section switching to allow the removal of
+ * the event listener to prevent stacking.
+ */
+function switchSection() {
+ kStartSection.setAttribute("hidden", true);
+
+ // Save the current label of the accept button in order to restore it later.
+ kButtonLabel = kDialog.getButton("accept").label;
+
+ // Update the UI based on the radiogroup selection.
+ switch (document.getElementById("openPgpKeyChoices").value) {
+ case "0":
+ wizardCreateKey();
+ break;
+
+ case "1":
+ wizardImportKey();
+ break;
+
+ case "2":
+ wizardExternalKey();
+ break;
+ }
+
+ // Show the `Go back` button.
+ kDialog.getButton("extra1").hidden = false;
+ resizeDialog();
+}
+
+/**
+ * Handle the next step of the wizard based on the currently visible section.
+ */
+async function wizardNextStep() {
+ switch (kCurrentSection) {
+ case "create":
+ await openPgpKeygenStart();
+ break;
+
+ case "import":
+ await openPgpImportStart();
+ break;
+
+ case "importComplete":
+ openPgpImportComplete();
+ break;
+
+ case "external":
+ openPgpExternalComplete();
+ break;
+ }
+}
+
+/**
+ * Go back to the initial view of the wizard.
+ */
+function goBack() {
+ let section = document.querySelector(".wizard-section:not([hidden])");
+ section.addEventListener("transitionend", backToStart, { once: true });
+ section.classList.add("hide-reverse");
+}
+
+/**
+ * Hide the currently visible section at the end of the animation, remove the
+ * listener to prevent stacking, and trigger the reveal of the first section.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+function backToStart(event) {
+ // Hide the `Go Back` button.
+ kDialog.getButton("extra1").hidden = true;
+
+ // Enable the `Continue` button.
+ kDialog.getButton("accept").removeAttribute("disabled");
+
+ kDialog.getButton("accept").label = kButtonLabel;
+ kDialog.getButton("accept").classList.remove("primary");
+
+ // Reset the import section.
+ clearImportWarningNotifications();
+ document.getElementById("importKeyIntro").hidden = false;
+ document.getElementById("importKeyListContainer").collapsed = true;
+
+ event.target.setAttribute("hidden", true);
+
+ // Reset section key.
+ kCurrentSection = "start";
+
+ revealSection("wizardStart");
+}
+
+/**
+ * Create a new inline notification to append to the import warning container.
+ *
+ * @returns {XULElement} - The description element inside the notification.
+ */
+async function addImportWarningNotification() {
+ let notification = document.createXULElement("hbox");
+ notification.classList.add(
+ "inline-notification-container",
+ "error-container"
+ );
+
+ let wrapper = document.createXULElement("hbox");
+ wrapper.classList.add("inline-notification-wrapper", "align-center");
+
+ let image = document.createElement("img");
+ image.classList.add("notification-image");
+ image.setAttribute("src", "chrome://global/skin/icons/warning.svg");
+ image.setAttribute("alt", "");
+
+ let description = document.createXULElement("description");
+
+ wrapper.appendChild(image);
+ wrapper.appendChild(description);
+
+ notification.appendChild(wrapper);
+
+ let container = document.getElementById("openPgpImportWarning");
+ container.appendChild(notification);
+
+ // Show the notification container.
+ container.removeAttribute("hidden");
+
+ return description;
+}
+
+/**
+ * Remove all inline errors from the notification area of the import section.
+ */
+function clearImportWarningNotifications() {
+ let container = document.getElementById("openPgpImportWarning");
+
+ // Remove any existing notification.
+ for (let notification of container.querySelectorAll(
+ ".inline-notification-container"
+ )) {
+ notification.remove();
+ }
+
+ // Hide the entire notification container.
+ container.hidden = true;
+}
+
+/**
+ * Show the Key Creation section.
+ */
+async function wizardCreateKey() {
+ kCurrentSection = "create";
+ revealSection("wizardCreateKey");
+
+ kDialog.getButton("accept").label = await document.l10n.formatValue(
+ "openpgp-keygen-button"
+ );
+ kDialog.getButton("accept").classList.add("primary");
+
+ if (!gIdentity.fullName) {
+ document.getElementById("openPgpWarning").collapsed = false;
+ document.l10n.setAttributes(
+ document.getElementById("openPgpWarningDescription"),
+ "openpgp-keygen-long-expiry"
+ );
+ return;
+ }
+
+ let sepPassphraseEnabled = Services.prefs.getBoolPref(
+ "mail.openpgp.passphrases.enabled"
+ );
+ document.getElementById("keygenPassphraseSection").hidden =
+ !sepPassphraseEnabled;
+
+ if (sepPassphraseEnabled) {
+ let usingPP = LoginHelper.isPrimaryPasswordSet();
+ let autoProt = document.getElementById("keygenAutoProtection");
+
+ document.l10n.setAttributes(
+ autoProt,
+ usingPP
+ ? "radio-keygen-protect-primary-pass"
+ : "radio-keygen-no-protection"
+ );
+
+ autoProt.setAttribute("selected", true);
+ document
+ .getElementById("keygenPassphraseProtection")
+ .removeAttribute("selected");
+ }
+
+ // This also handles enable/disabling the accept/ok button.
+ onProtectionChange();
+}
+
+/**
+ * Show the Key Import section.
+ */
+function wizardImportKey() {
+ kCurrentSection = "import";
+ revealSection("wizardImportKey");
+
+ let sepPassphraseEnabled = Services.prefs.getBoolPref(
+ "mail.openpgp.passphrases.enabled"
+ );
+ let keepPassphrasesItem = document.getElementById(
+ "openPgpKeygenKeepPassphrases"
+ );
+ keepPassphrasesItem.hidden = !sepPassphraseEnabled;
+ keepPassphrasesItem.checked = false;
+}
+
+/**
+ * Show the Key Setup via external smartcard section.
+ */
+async function wizardExternalKey() {
+ kCurrentSection = "external";
+ revealSection("wizardExternalKey");
+
+ kDialog.getButton("accept").label = await document.l10n.formatValue(
+ "openpgp-save-external-button"
+ );
+ kDialog.getButton("accept").classList.add("primary");
+
+ // If the user is already using an external GnuPG key, populate the input,
+ // show the warning description, and enable the primary button.
+ if (gIdentity.getBoolAttribute("is_gnupg_key_id")) {
+ document.getElementById("externalKey").value =
+ gIdentity.getUnicharAttribute("last_entered_external_gnupg_key_id");
+ document.getElementById("openPgpExternalWarning").collapsed = false;
+ kDialog.getButton("accept").removeAttribute("disabled");
+ } else {
+ document.getElementById("openPgpExternalWarning").collapsed = true;
+ kDialog.getButton("accept").setAttribute("disabled", true);
+ }
+}
+
+/**
+ * Animate the reveal of a section of the wizard.
+ *
+ * @param {string} id - The id of the section to reveal.
+ */
+function revealSection(id) {
+ let section = document.getElementById(id);
+ section.removeAttribute("hidden");
+
+ // Timeout to animate after the hidden attribute has been removed.
+ setTimeout(() => {
+ section.classList.remove("hide", "hide-reverse");
+ });
+
+ resizeDialog();
+}
+
+/**
+ * Enable or disable the elements based on the radiogroup selection.
+ *
+ * @param {Event} event - The DOM event triggered on change.
+ */
+function onExpirationChange(event) {
+ document
+ .getElementById("expireInput")
+ .toggleAttribute("disabled", event.target.value != 0);
+ document.getElementById("timeScale").disabled = event.target.value != 0;
+
+ validateExpiration();
+}
+
+/**
+ * Enable or disable the #keySize input field based on the current selection of
+ * the #keyType radio group.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+function onKeyTypeChange(event) {
+ document.getElementById("keySize").disabled = event.target.value == "ECC";
+}
+
+/**
+ * Intercept the cancel event to prevent accidental closing if the generation of
+ * a key is currently in progress.
+ *
+ * @param {Event} event - The DOM event.
+ */
+function onClose(event) {
+ if (kGenerating) {
+ event.preventDefault();
+ }
+
+ window.arguments[0].cancelCallback();
+}
+
+/**
+ * Validate the expiration time of a newly generated key when the user changes
+ * values. Disable the "Generate Key" button and show an alert if the selected
+ * value is less than 1 day or more than 100 years.
+ */
+async function validateExpiration() {
+ // If the key doesn't have an expiration date, hide the warning message and
+ // enable the "Generate Key" button.
+ if (document.getElementById("openPgpKeygeExpiry").value == 1) {
+ document.getElementById("openPgpWarning").collapsed = true;
+ kDialog.getButton("accept").removeAttribute("disabled");
+ return;
+ }
+
+ // Calculate the selected expiration date.
+ let expiryTime =
+ Number(document.getElementById("expireInput").value) *
+ Number(document.getElementById("timeScale").value);
+
+ // If the expiration date exceeds 100 years.
+ if (expiryTime > 36500) {
+ document.getElementById("openPgpWarning").collapsed = false;
+ document.l10n.setAttributes(
+ document.getElementById("openPgpWarningDescription"),
+ "openpgp-keygen-long-expiry"
+ );
+ kDialog.getButton("accept").setAttribute("disabled", true);
+ resizeDialog();
+ return;
+ }
+
+ // If the expiration date is shorter than 1 day.
+ if (expiryTime <= 0) {
+ document.getElementById("openPgpWarning").collapsed = false;
+ document.l10n.setAttributes(
+ document.getElementById("openPgpWarningDescription"),
+ "openpgp-keygen-short-expiry"
+ );
+ kDialog.getButton("accept").setAttribute("disabled", true);
+ resizeDialog();
+ return;
+ }
+
+ // If the previous conditions are false, hide the warning message and
+ // enable the "Generate Key" button since the expiration date is valid.
+ document.getElementById("openPgpWarning").collapsed = true;
+ kDialog.getButton("accept").removeAttribute("disabled");
+}
+
+/**
+ * Resize the dialog to account for the newly visible sections.
+ */
+function resizeDialog() {
+ // Check if the attribute is not null. This can be removed after the full
+ // conversion of the Key Manager into a SubDialog in Bug 1652537.
+ if (gSubDialog && gSubDialog._topDialog) {
+ gSubDialog._topDialog.resizeVertically();
+ } else {
+ window.sizeToContent();
+ }
+}
+
+/**
+ * Start the generation of a new OpenPGP Key.
+ */
+async function openPgpKeygenStart() {
+ let openPgpWarning = document.getElementById("openPgpWarning");
+ let openPgpWarningText = document.getElementById("openPgpWarningDescription");
+ openPgpWarning.collapsed = true;
+
+ // If a key generation request is already pending, warn the user and
+ // don't proceed.
+ if (gKeygenRequest) {
+ let req = gKeygenRequest.QueryInterface(Ci.nsIRequest);
+
+ if (req.isPending()) {
+ openPgpWarning.collapsed = false;
+ document.l10n.setAttributes(openPgpWarningText, "openpgp-keygen-ongoing");
+ return;
+ }
+ }
+
+ // Reset global variables to be sure.
+ gGeneratedKey = null;
+ gAllData = "";
+
+ let enigmailSvc = GetEnigmailSvc();
+ if (!enigmailSvc) {
+ openPgpWarning.collapsed = false;
+ document.l10n.setAttributes(
+ openPgpWarningText,
+ "openpgp-keygen-error-core"
+ );
+ closeOverlay();
+
+ throw new Error("GetEnigmailSvc failed");
+ }
+
+ // Show wizard overlay before the start of the generation process. This is
+ // necessary because the generation happens synchronously and blocks the UI.
+ // We need to show the overlay before it, otherwise it would flash and freeze.
+ // This should be moved after the Services.prompt.confirmEx() method
+ // once Bug 1617444 is implemented.
+ let overlay = document.getElementById("wizardOverlay");
+ overlay.removeAttribute("hidden");
+ overlay.classList.remove("hide");
+
+ // Ask for confirmation before triggering the generation of a new key.
+ document.l10n.setAttributes(
+ document.getElementById("wizardOverlayQuestion"),
+ "openpgp-key-confirm",
+ {
+ identity: `${gIdentity.fullName} <b>"${gIdentity.email}"</b>`,
+ }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("wizardOverlayTitle"),
+ "openpgp-keygen-progress-title"
+ );
+}
+
+async function openPgpKeygenConfirm() {
+ document.getElementById("openPgpKeygenConfirm").collapsed = true;
+ document.getElementById("openPgpKeygenProcess").removeAttribute("collapsed");
+
+ let openPgpWarning = document.getElementById("openPgpWarning");
+ let openPgpWarningText = document.getElementById("openPgpWarningDescription");
+ openPgpWarning.collapsed = true;
+
+ kGenerating = true;
+
+ let password;
+ let cApi = EnigmailCryptoAPI();
+ let newId = null;
+
+ let sepPassphraseEnabled = Services.prefs.getBoolPref(
+ "mail.openpgp.passphrases.enabled"
+ );
+
+ if (
+ !sepPassphraseEnabled ||
+ document.getElementById("keygenAutoProtection").selected
+ ) {
+ password = await OpenPGPMasterpass.retrieveOpenPGPPassword();
+ } else {
+ password = document.getElementById("passwordInput").value;
+ }
+ newId = await cApi.genKey(
+ `${gIdentity.fullName} <${gIdentity.email}>`,
+ document.getElementById("keyType").value,
+ Number(document.getElementById("keySize").value),
+ document.getElementById("openPgpKeygeExpiry").value == 1
+ ? 0
+ : Number(document.getElementById("expireInput").value) *
+ Number(document.getElementById("timeScale").value),
+ password
+ );
+
+ gGeneratedKey = newId;
+
+ EnigmailWindows.keyManReloadKeys();
+
+ gKeygenRequest = null;
+ kGenerating = false;
+
+ // For wathever reason, the key wasn't generated. Show an error message and
+ // hide the processing overlay.
+ if (!gGeneratedKey) {
+ openPgpWarning.collapsed = false;
+ document.l10n.setAttributes(
+ openPgpWarningText,
+ "openpgp-keygen-error-failed"
+ );
+ closeOverlay();
+
+ throw new Error("key generation failed");
+ }
+
+ console.debug("saving new key id " + gGeneratedKey);
+ Services.prefs.savePrefFile(null);
+
+ // Hide wizard overlay at the end of the generation process.
+ closeOverlay();
+ EnigmailKeyRing.clearCache();
+
+ let rev = await cApi.unlockAndGetNewRevocation(
+ `0x${gGeneratedKey}`,
+ password
+ );
+ if (!rev) {
+ openPgpWarning.collapsed = false;
+ document.l10n.setAttributes(
+ openPgpWarningText,
+ "openpgp-keygen-error-revocation",
+ {
+ key: gGeneratedKey,
+ }
+ );
+ closeOverlay();
+
+ throw new Error("failed to obtain revocation for key " + gGeneratedKey);
+ }
+
+ let revFull =
+ revocationFilePrefix1 +
+ "\n\n" +
+ gGeneratedKey +
+ "\n" +
+ revocationFilePrefix2 +
+ rev;
+
+ let revFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ revFile.append(`0x${gGeneratedKey}_rev.asc`);
+
+ // Create a revokation cert in the Thunderbird profile directory.
+ await IOUtils.writeUTF8(revFile.path, revFull);
+
+ // Key successfully created. Close the dialog and show a confirmation message.
+ // Assigning the key to an identity is the responsibility of the caller,
+ // so we pass back what we created.
+ window.arguments[0].okCallback(gGeneratedKey);
+ window.close();
+}
+
+/**
+ * Cancel the keygen process, ask for confirmation before proceeding.
+ */
+async function openPgpKeygenCancel() {
+ let [abortTitle, abortText] = await document.l10n.formatValues([
+ { id: "openpgp-keygen-abort-title" },
+ { id: "openpgp-keygen-abort" },
+ ]);
+
+ if (
+ kGenerating &&
+ Services.prompt.confirmEx(
+ window,
+ abortTitle,
+ abortText,
+ Services.prompt.STD_YES_NO_BUTTONS,
+ "",
+ "",
+ "",
+ "",
+ {}
+ ) != 0
+ ) {
+ return;
+ }
+
+ closeOverlay();
+ gKeygenRequest.kill(false);
+ kGenerating = false;
+}
+
+/**
+ * Close the processing wizard overlay.
+ */
+function closeOverlay() {
+ document.getElementById("openPgpKeygenConfirm").removeAttribute("collapsed");
+ document.getElementById("openPgpKeygenProcess").collapsed = true;
+
+ let overlay = document.getElementById("wizardOverlay");
+
+ overlay.addEventListener("transitionend", hideOverlay, { once: true });
+ overlay.classList.add("hide");
+}
+
+/**
+ * Add the "hidden" attribute tot he processing wizard overlay after the CSS
+ * transition ended.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+function hideOverlay(event) {
+ event.target.setAttribute("hidden", true);
+ resizeDialog();
+}
+
+async function importSecretKey() {
+ let [importTitle, importType] = await document.l10n.formatValues([
+ { id: "import-key-file" },
+ { id: "gnupg-file" },
+ ]);
+
+ // Reset the array of selected files.
+ gFiles = [];
+
+ let files = EnigmailDialog.filePicker(
+ window,
+ importTitle,
+ "",
+ false,
+ true,
+ "*.asc",
+ "",
+ [importType, "*.asc;*.gpg;*.pgp"]
+ );
+
+ if (!files.length) {
+ return;
+ }
+
+ // Clear and hide the warning notification section.
+ clearImportWarningNotifications();
+
+ // Clear the key list from any previously listed key.
+ let keyList = document.getElementById("importKeyList");
+ while (keyList.lastChild) {
+ keyList.lastChild.remove();
+ }
+
+ let keyCount = 0;
+ for (let file of files) {
+ // Skip the file and show a warning message if larger than 5MB.
+ if (file.fileSize > 5000000) {
+ document.l10n.setAttributes(
+ await addImportWarningNotification(),
+ "import-error-file-size"
+ );
+ continue;
+ }
+
+ let errorMsgObj = {};
+ // Fetch the list of all the available keys inside the selected file.
+ let importKeys = await EnigmailKey.getKeyListFromKeyFile(
+ file,
+ errorMsgObj,
+ false,
+ true
+ );
+
+ // Skip the file and show a warning message if the import failed.
+ if (!importKeys || !importKeys.length || errorMsgObj.value) {
+ document.l10n.setAttributes(
+ await addImportWarningNotification(),
+ "import-error-failed",
+ {
+ error: errorMsgObj.value,
+ }
+ );
+ continue;
+ }
+
+ await appendFetchedKeys(importKeys);
+ keyCount += importKeys.length;
+
+ // Add the current file to the list of valid files to import.
+ gFiles.push(file);
+ }
+
+ // Update the list count recap and show the container.
+ document.l10n.setAttributes(
+ document.getElementById("keyListCount"),
+ "openpgp-import-key-list-amount-2",
+ {
+ count: keyCount,
+ }
+ );
+
+ document.getElementById("importKeyListContainer").collapsed = !keyCount;
+
+ // Hide the intro section and enable the import of keys only if we have valid
+ // keys currently listed.
+ if (keyCount) {
+ document.getElementById("importKeyIntro").hidden = true;
+ kDialog.getButton("accept").removeAttribute("disabled");
+ kDialog.getButton("accept").classList.add("primary");
+ }
+
+ resizeDialog();
+}
+
+/**
+ * Populate the key list in the import dialog with all the valid keys fetched
+ * from a single file.
+ *
+ * @param {string[]} importKeys - The array of keys fetched from a single file.
+ */
+async function appendFetchedKeys(importKeys) {
+ let keyList = document.getElementById("importKeyList");
+
+ // List all the keys fetched from the file.
+ for (let key of importKeys) {
+ let container = document.createXULElement("hbox");
+ container.classList.add("key-import-row", "selected");
+
+ let titleContainer = document.createXULElement("vbox");
+
+ let id = document.createXULElement("label");
+ id.classList.add("openpgp-key-id");
+ id.value = `0x${key.id}`;
+
+ let name = document.createXULElement("label");
+ name.classList.add("openpgp-key-name");
+ name.value = key.name;
+
+ titleContainer.appendChild(id);
+ titleContainer.appendChild(name);
+
+ // Allow users to treat imported keys as "Personal".
+ let checkbox = document.createXULElement("checkbox");
+ checkbox.setAttribute("id", `${key.id}-set-personal`);
+ document.l10n.setAttributes(checkbox, "import-key-personal-checkbox");
+ checkbox.checked = true;
+
+ container.appendChild(titleContainer);
+ container.appendChild(checkbox);
+
+ keyList.appendChild(container);
+ }
+}
+
+async function openPgpImportStart() {
+ if (!gFiles.length) {
+ return;
+ }
+
+ kGenerating = true;
+
+ // Show the overlay.
+ let overlay = document.getElementById("wizardImportOverlay");
+ overlay.removeAttribute("hidden");
+ overlay.classList.remove("hide");
+
+ // Clear and hide the warning notification section.
+ clearImportWarningNotifications();
+
+ // Clear the list of any previously improted keys from the DOM.
+ let keyList = document.getElementById("importKeyListRecap");
+ while (keyList.lastChild) {
+ keyList.lastChild.remove();
+ }
+
+ let keyCount = 0;
+ for (let file of gFiles) {
+ let resultKeys = {};
+ let errorMsgObj = {};
+
+ // keepPassphrases false is the classic behavior.
+ let keepPassphrases = false;
+
+ // If the pref is on, we allow the user to decide what to do.
+ let allowSeparatePassphrases = Services.prefs.getBoolPref(
+ "mail.openpgp.passphrases.enabled"
+ );
+ if (allowSeparatePassphrases) {
+ keepPassphrases = document.getElementById(
+ "openPgpKeygenKeepPassphrases"
+ ).checked;
+ }
+
+ let exitCode = await EnigmailKeyRing.importSecKeyFromFile(
+ window,
+ passphrasePromptCallback,
+ keepPassphrases,
+ file,
+ errorMsgObj,
+ resultKeys
+ );
+
+ // Skip this file if something went wrong.
+ if (exitCode !== 0) {
+ document.l10n.setAttributes(
+ await addImportWarningNotification(),
+ "openpgp-import-keys-failed",
+ {
+ error: errorMsgObj.value,
+ }
+ );
+ continue;
+ }
+
+ await appendImportedKeys(resultKeys);
+ keyCount += resultKeys.keys.length;
+ }
+
+ // Hide the previous key list container and title.
+ document.getElementById("importKeyListContainer").collapsed = keyCount;
+ document.getElementById("importKeyTitle").hidden = keyCount;
+
+ // Show the successful final screen only if at least one key was imported.
+ if (keyCount) {
+ // Update the dialog buttons for the final stage.
+ kDialog.getButton("extra1").hidden = true;
+ kDialog.getButton("cancel").hidden = true;
+
+ // Update the `Continue` button.
+ document.l10n.setAttributes(
+ kDialog.getButton("accept"),
+ "openpgp-keygen-import-complete"
+ );
+ kCurrentSection = "importComplete";
+
+ // Show the recently built key list.
+ document.getElementById("importKeyListSuccess").collapsed = false;
+ }
+
+ // Hide the loading overlay.
+ overlay.addEventListener("transitionend", hideOverlay, { once: true });
+ overlay.classList.add("hide");
+
+ resizeDialog();
+ kGenerating = false;
+}
+
+/**
+ * Populate the key list in the import dialog with all the valid keys imported
+ * from a single file.
+ *
+ * @param {string[]} resultKeys - The array of keys imported from a single file.
+ */
+async function appendImportedKeys(resultKeys) {
+ let keyList = document.getElementById("importKeyListRecap");
+
+ for (let keyId of resultKeys.keys) {
+ if (keyId.search(/^0x/) === 0) {
+ keyId = keyId.substr(2).toUpperCase();
+ }
+
+ let key = EnigmailKeyRing.getKeyById(keyId);
+
+ if (key && key.fpr) {
+ // If the checkbox was checked, update the acceptance of the key.
+ if (document.getElementById(`${key.keyId}-set-personal`).checked) {
+ PgpSqliteDb2.acceptAsPersonalKey(key.fpr);
+ }
+
+ let container = document.createXULElement("hbox");
+ container.classList.add("key-import-row");
+
+ // Start key info section.
+ let grid = document.createXULElement("hbox");
+ grid.classList.add("extra-information-label");
+
+ // Key identity.
+ let identityLabel = document.createXULElement("label");
+ identityLabel.classList.add("extra-information-label-type");
+ document.l10n.setAttributes(
+ identityLabel,
+ "openpgp-import-identity-label"
+ );
+
+ let identityValue = document.createXULElement("label");
+ identityValue.value = key.userId;
+
+ grid.appendChild(identityLabel);
+ grid.appendChild(identityValue);
+
+ // Key fingerprint.
+ let fingerprintLabel = document.createXULElement("label");
+ document.l10n.setAttributes(
+ fingerprintLabel,
+ "openpgp-import-fingerprint-label"
+ );
+ fingerprintLabel.classList.add("extra-information-label-type");
+
+ let fingerprintInput = document.createXULElement("label");
+ fingerprintInput.value = EnigmailKey.formatFpr(key.fpr);
+
+ grid.appendChild(fingerprintLabel);
+ grid.appendChild(fingerprintInput);
+
+ // Key creation date.
+ let createdLabel = document.createXULElement("label");
+ document.l10n.setAttributes(createdLabel, "openpgp-import-created-label");
+ createdLabel.classList.add("extra-information-label-type");
+
+ let createdValue = document.createXULElement("label");
+ createdValue.value = key.created;
+
+ grid.appendChild(createdLabel);
+ grid.appendChild(createdValue);
+
+ // Key bits.
+ let bitsLabel = document.createXULElement("label");
+ bitsLabel.classList.add("extra-information-label-type");
+ document.l10n.setAttributes(bitsLabel, "openpgp-import-bits-label");
+
+ let bitsValue = document.createXULElement("label");
+ bitsValue.value = key.keySize;
+
+ grid.appendChild(bitsLabel);
+ grid.appendChild(bitsValue);
+ // End key info section.
+
+ let info = document.createXULElement("button");
+ info.classList.add("openpgp-image-btn", "openpgp-props-btn");
+ document.l10n.setAttributes(info, "openpgp-import-key-props");
+ info.addEventListener("command", () => {
+ window.arguments[0].keyDetailsDialog(key.keyId);
+ });
+
+ container.appendChild(grid);
+ container.appendChild(info);
+
+ keyList.appendChild(container);
+ }
+ }
+}
+
+function openPgpImportComplete() {
+ window.arguments[0].okImportCallback();
+ window.close();
+}
+
+/**
+ * Opens a prompt asking the user to enter the passphrase for a given key id.
+ *
+ * @param {object} win - The current window.
+ * @param {string} keyId - The ID of the imported key.
+ * @param {object} resultFlags - Keep track of the cancelled action.
+ *
+ * @returns {string} - The entered passphrase or empty.
+ */
+function passphrasePromptCallback(win, promptString, resultFlags) {
+ let passphrase = { value: "" };
+
+ // We need to fetch these strings synchronously in order to properly work with
+ // the RNP key import method, which is not async.
+ let title = syncl10n.formatValueSync("openpgp-passphrase-prompt-title");
+
+ let prompt = Services.prompt.promptPassword(
+ win,
+ title,
+ promptString,
+ passphrase,
+ null,
+ {}
+ );
+
+ if (!prompt) {
+ let overlay = document.getElementById("wizardImportOverlay");
+ overlay.addEventListener("transitionend", hideOverlay, { once: true });
+ overlay.classList.add("hide");
+ kGenerating = false;
+ }
+
+ resultFlags.canceled = !prompt;
+ return !prompt ? "" : passphrase.value;
+}
+
+function toggleSaveButton(event) {
+ kDialog
+ .getButton("accept")
+ .toggleAttribute("disabled", !event.target.value.trim());
+}
+
+/**
+ * Save the GnuPG Key for the current identity and trigger a callback.
+ */
+function openPgpExternalComplete() {
+ gIdentity.setBoolAttribute("is_gnupg_key_id", true);
+
+ let externalKey = document.getElementById("externalKey").value;
+ gIdentity.setUnicharAttribute("openpgp_key_id", externalKey);
+
+ window.arguments[0].okExternalCallback(externalKey);
+ window.close();
+}
diff --git a/comm/mail/extensions/openpgp/content/ui/keyWizard.xhtml b/comm/mail/extensions/openpgp/content/ui/keyWizard.xhtml
new file mode 100644
index 0000000000..c630eacb48
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/keyWizard.xhtml
@@ -0,0 +1,506 @@
+<?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://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/keyWizard.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="init();"
+ lightweightthemes="true"
+ style="min-width: 50em"
+>
+ <dialog
+ id="openPgpKeyWizardDialog"
+ data-l10n-id="key-wizard-dialog"
+ data-l10n-attrs="buttonlabelaccept,buttonlabelextra1"
+ buttons="accept,cancel"
+ >
+ <script src="chrome://openpgp/content/ui/enigmailCommon.js" />
+ <script src="chrome://openpgp/content/ui/keyWizard.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="messenger/openpgp/keyWizard.ftl" />
+ </linkset>
+
+ <html:div
+ id="wizardOverlay"
+ class="wizard-section overlay hide"
+ hidden="hidden"
+ >
+ <hbox class="inline-notification-container info-container">
+ <hbox class="inline-notification-wrapper">
+ <html:img
+ class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt=""
+ />
+ <description data-l10n-id="openpgp-generate-key-info" />
+ </hbox>
+ </hbox>
+
+ <vbox id="openPgpKeygenConfirm" class="self-center" align="center">
+ <description id="wizardOverlayQuestion" />
+ <separator class="thin" />
+ <hbox>
+ <button
+ data-l10n-id="openpgp-keygen-dismiss"
+ oncommand="closeOverlay();"
+ />
+ <button
+ id="openPgpKeygenConfirmButton"
+ data-l10n-id="openpgp-keygen-confirm"
+ oncommand="openPgpKeygenConfirm();"
+ />
+ </hbox>
+ </vbox>
+
+ <vbox
+ id="openPgpKeygenProcess"
+ class="self-center"
+ align="center"
+ collapsed="true"
+ >
+ <html:legend id="wizardOverlayTitle"></html:legend>
+ <html:img
+ class="loading-status"
+ src="chrome://global/skin/icons/loading.png"
+ alt=""
+ />
+ <button
+ data-l10n-id="openpgp-keygen-cancel"
+ class="self-center"
+ oncommand="openPgpKeygenCancel();"
+ />
+ </vbox>
+ </html:div>
+
+ <html:div
+ id="wizardImportOverlay"
+ class="wizard-section overlay hide"
+ hidden="hidden"
+ >
+ <vbox id="importLoading" class="self-center" align="center">
+ <html:legend
+ data-l10n-id="openpgp-keygen-import-progress-title"
+ ></html:legend>
+ <html:img
+ class="loading-status"
+ src="chrome://global/skin/icons/loading.png"
+ alt=""
+ />
+ </vbox>
+ </html:div>
+
+ <vbox id="wizardStart" class="wizard-section">
+ <hbox class="inline-notification-container info-container">
+ <hbox class="inline-notification-wrapper">
+ <html:img
+ class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt=""
+ />
+ <description>
+ <html:span
+ class="tail-with-learn-more"
+ data-l10n-id="key-wizard-warning"
+ >
+ </html:span>
+ <label
+ is="text-link"
+ href="https://support.mozilla.org/kb/introduction-to-e2e-encryption"
+ data-l10n-id="key-wizard-learn-more"
+ class="learnMore text-link"
+ />
+ </description>
+ </hbox>
+ </hbox>
+
+ <html:div>
+ <html:fieldset>
+ <radiogroup id="openPgpKeyChoices" class="indent">
+ <radio
+ id="createOpenPgp"
+ value="0"
+ data-l10n-id="radio-create-key"
+ />
+ <radio
+ id="importOpenPgp"
+ value="1"
+ data-l10n-id="radio-import-key"
+ />
+ <radio
+ id="externalOpenPgp"
+ value="2"
+ data-l10n-id="radio-gnupg-key"
+ hidden="true"
+ />
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+ </vbox>
+
+ <vbox
+ id="wizardCreateKey"
+ class="wizard-section hide-reverse"
+ hidden="true"
+ >
+ <label
+ data-l10n-id="openpgp-generate-key-title"
+ class="dialogheader-title"
+ />
+
+ <html:div>
+ <html:fieldset>
+ <hbox align="center">
+ <html:legend
+ class="identity-legend"
+ data-l10n-id="openpgp-import-identity-label"
+ >
+ </html:legend>
+ <menulist id="userIdentity" flex="1" oncommand="setIdentity();">
+ <menupopup id="userIdentityPopup" />
+ </menulist>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div id="keygenPassphraseSection">
+ <html:fieldset>
+ <html:legend
+ data-l10n-id="openpgp-keygen-secret-protection"
+ ></html:legend>
+
+ <radiogroup id="openPgpKeyProtection" class="indent">
+ <radio
+ id="keygenAutoProtection"
+ value="0"
+ oncommand="onProtectionChange();"
+ />
+ <vbox>
+ <hbox>
+ <radio
+ id="keygenPassphraseProtection"
+ value="1"
+ data-l10n-id="radio-keygen-passphrase-protection"
+ oncommand="onProtectionChange();"
+ />
+ <html:input
+ id="passwordInput"
+ type="password"
+ oninput="onProtectionChange();"
+ />
+ </hbox>
+ <hbox class="indent">
+ <label data-l10n-id="openpgp-passphrase-repeat" />
+ <html:input
+ id="passwordConfirm"
+ type="password"
+ class="input-inline"
+ oninput="onProtectionChange();"
+ />
+ </hbox>
+ </vbox>
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <separator class="thin" />
+
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="openpgp-keygen-expiry-title"></html:legend>
+ <description data-l10n-id="openpgp-keygen-expiry-description" />
+
+ <radiogroup id="openPgpKeygeExpiry" class="indent">
+ <hbox flex="1" align="center">
+ <radio
+ id="keygenExpiration"
+ value="0"
+ data-l10n-id="radio-keygen-expiry"
+ oncommand="onExpirationChange(event);"
+ />
+ <html:input
+ id="expireInput"
+ type="number"
+ class="size4 input-inline autosync"
+ maxlength="5"
+ value="3"
+ min="1"
+ max="100"
+ aria-labelledby="keygenExpiration"
+ oninput="validateExpiration();"
+ />
+ <menulist id="timeScale">
+ <menupopup>
+ <menuitem
+ id="years"
+ value="365"
+ data-l10n-id="openpgp-keygen-years-label"
+ selected="true"
+ oncommand="validateExpiration();"
+ />
+ <menuitem
+ id="months"
+ value="30"
+ data-l10n-id="openpgp-keygen-months-label"
+ oncommand="validateExpiration();"
+ />
+ <menuitem
+ id="days"
+ value="1"
+ data-l10n-id="openpgp-keygen-days-label"
+ oncommand="validateExpiration();"
+ />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <radio
+ id="keygenDoesNotExpire"
+ value="1"
+ data-l10n-id="radio-keygen-no-expiry"
+ oncommand="onExpirationChange(event);"
+ />
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <separator class="thin" />
+
+ <html:div>
+ <html:fieldset>
+ <html:legend
+ data-l10n-id="openpgp-keygen-advanced-title"
+ ></html:legend>
+ <description data-l10n-id="openpgp-keygen-advanced-description" />
+
+ <vbox class="indent grid-size">
+ <hbox align="center">
+ <label for="keyType" data-l10n-id="openpgp-keygen-keytype" />
+ </hbox>
+ <hbox align="center">
+ <menulist id="keyType">
+ <menupopup>
+ <menuitem
+ id="keyType_rsa"
+ value="RSA"
+ data-l10n-id="openpgp-keygen-type-rsa"
+ selected="true"
+ oncommand="onKeyTypeChange(event);"
+ />
+ <menuitem
+ id="keyType_ecc"
+ value="ECC"
+ data-l10n-id="openpgp-keygen-type-ecc"
+ oncommand="onKeyTypeChange(event);"
+ />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <spacer />
+
+ <hbox align="center">
+ <label for="keySize" data-l10n-id="openpgp-keygen-keysize" />
+ </hbox>
+ <hbox align="center">
+ <menulist id="keySize">
+ <menupopup>
+ <menuitem
+ id="keySize_3072"
+ value="3072"
+ label="3072"
+ selected="true"
+ />
+ <menuitem id="keySize_4096" value="4096" label="4096" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <spacer />
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <separator />
+
+ <hbox
+ id="openPgpWarning"
+ class="inline-notification-container error-container"
+ collapsed="true"
+ >
+ <hbox class="inline-notification-wrapper">
+ <html:img
+ class="notification-image"
+ src="chrome://global/skin/icons/warning.svg"
+ alt=""
+ />
+ <description id="openPgpWarningDescription" />
+ </hbox>
+ </hbox>
+ </vbox>
+
+ <vbox
+ id="wizardImportKey"
+ class="wizard-section hide-reverse"
+ hidden="true"
+ >
+ <label
+ id="importKeyTitle"
+ data-l10n-id="openpgp-import-key-title"
+ class="dialogheader-title"
+ />
+
+ <vbox id="openPgpImportWarning" hidden="true" />
+
+ <vbox id="importKeyIntro" align="start">
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="openpgp-import-key-legend"></html:legend>
+ <description data-l10n-id="openpgp-import-key-description" />
+ <description
+ data-l10n-id="openpgp-import-key-info"
+ class="tip-caption"
+ />
+
+ <separator />
+
+ <button
+ data-l10n-id="openpgp-import-key-button"
+ oncommand="importSecretKey();"
+ />
+
+ <separator class="thin" />
+ </html:fieldset>
+ </html:div>
+ </vbox>
+
+ <vbox id="importKeyListContainer" collapsed="true">
+ <hbox class="inline-notification-container success-container">
+ <hbox class="inline-notification-wrapper align-center">
+ <html:img
+ class="notification-image"
+ src="chrome://global/skin/icons/check.svg"
+ alt=""
+ />
+ <description id="keyListCount" />
+ </hbox>
+ </hbox>
+
+ <description data-l10n-id="openpgp-import-key-list-description" />
+
+ <vbox id="importKeyList" />
+
+ <description
+ data-l10n-id="openpgp-import-key-list-caption"
+ class="tip-caption"
+ />
+
+ <separator class="thin" />
+ <checkbox
+ id="openPgpKeygenKeepPassphrases"
+ data-l10n-id="openpgp-import-keep-passphrases"
+ />
+
+ <separator />
+ </vbox>
+
+ <vbox id="importKeyListSuccess" collapsed="true">
+ <hbox class="inline-notification-container success-container">
+ <hbox class="inline-notification-wrapper align-center">
+ <html:img
+ class="notification-image"
+ src="chrome://global/skin/icons/check.svg"
+ alt=""
+ />
+ <description data-l10n-id="openpgp-import-success" />
+ </hbox>
+ </hbox>
+
+ <separator />
+
+ <vbox id="importKeyListRecap" />
+
+ <vbox align="center">
+ <html:legend
+ data-l10n-id="openpgp-import-success-title"
+ ></html:legend>
+ <description
+ data-l10n-id="openpgp-import-success-description"
+ class="description-centered"
+ />
+ </vbox>
+
+ <separator class="thin" />
+ </vbox>
+ </vbox>
+
+ <vbox
+ id="wizardExternalKey"
+ class="wizard-section hide-reverse"
+ hidden="true"
+ >
+ <label
+ data-l10n-id="openpgp-external-key-title"
+ class="dialogheader-title"
+ />
+
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="openpgp-external-key-description">
+ </html:legend>
+
+ <description data-l10n-id="openpgp-external-key-info" />
+
+ <separator />
+
+ <hbox align="center">
+ <label
+ for="externalKey"
+ data-l10n-id="openpgp-external-key-label"
+ ></label>
+ <hbox class="input-container" flex="1">
+ <html:input
+ id="externalKey"
+ type="text"
+ class="input-inline"
+ data-l10n-id="openpgp-external-key-input"
+ oninput="toggleSaveButton(event);"
+ />
+ </hbox>
+ </hbox>
+
+ <separator />
+
+ <hbox
+ id="openPgpExternalWarning"
+ class="inline-notification-container info-container"
+ collapsed="true"
+ >
+ <hbox class="inline-notification-wrapper">
+ <html:img
+ class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt=""
+ />
+ <description data-l10n-id="openpgp-external-key-warning" />
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <separator class="thin" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/extensions/openpgp/content/ui/oneRecipientStatus.js b/comm/mail/extensions/openpgp/content/ui/oneRecipientStatus.js
new file mode 100644
index 0000000000..e1d369e1ab
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/oneRecipientStatus.js
@@ -0,0 +1,177 @@
+/* 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 { EnigmailFuncs } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/funcs.jsm"
+);
+var EnigmailKeyRing = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+).EnigmailKeyRing;
+var { EnigmailWindows } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/windows.jsm"
+);
+var { EnigmailDialog } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/dialog.jsm"
+);
+var { EnigmailKey } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/key.jsm"
+);
+var KeyLookupHelper = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyLookupHelper.jsm"
+).KeyLookupHelper;
+const { PgpSqliteDb2 } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/sqliteDb.jsm"
+);
+
+var gListBox;
+var gViewButton;
+
+var gAddr;
+var gRowToKey = [];
+
+async function setListEntries(keys = null) {
+ let index = 0;
+
+ // Temporary code for debugging/development, should be removed when
+ // a final patch for bug 1627956 lands.
+ console.log(await EnigmailKeyRing.getEncryptionKeyMeta(gAddr));
+
+ if (!keys) {
+ keys = await EnigmailKeyRing.getMultValidKeysForOneRecipient(gAddr, true);
+ }
+
+ for (let keyObj of keys) {
+ let listitem = document.createXULElement("richlistitem");
+
+ let keyId = document.createXULElement("label");
+ keyId.setAttribute("value", "0x" + keyObj.keyId);
+ keyId.setAttribute("crop", "end");
+ keyId.setAttribute("style", "width: var(--keyWidth)");
+ listitem.appendChild(keyId);
+
+ let acceptanceText;
+
+ // Further above, we called getMultValidKeysForOneRecipient
+ // and asked to ignore if a key is expired.
+ // If the following check fails, the key must be expired.
+ if (!EnigmailKeyRing.isValidForEncryption(keyObj)) {
+ acceptanceText = "openpgp-key-expired";
+ } else if (keyObj.secretAvailable) {
+ if (await PgpSqliteDb2.isAcceptedAsPersonalKey(keyObj.fpr)) {
+ acceptanceText = "openpgp-key-own";
+ } else {
+ acceptanceText = "openpgp-key-secret-not-personal";
+ }
+ } else {
+ if (!("acceptance" in keyObj)) {
+ throw new Error(
+ "expected getMultValidKeysForOneRecipient to set acceptance"
+ );
+ }
+ switch (keyObj.acceptance) {
+ case "rejected":
+ acceptanceText = "openpgp-key-rejected";
+ break;
+ case "unverified":
+ acceptanceText = "openpgp-key-unverified";
+ break;
+ case "verified":
+ acceptanceText = "openpgp-key-verified";
+ break;
+ case "undecided":
+ acceptanceText = "openpgp-key-undecided";
+ break;
+ default:
+ throw new Error("unexpected acceptance value: " + keyObj.acceptance);
+ }
+ }
+
+ let status = document.createXULElement("label");
+ document.l10n.setAttributes(status, acceptanceText);
+ status.setAttribute("crop", "end");
+ status.setAttribute("style", "width: var(--statusWidth)");
+ listitem.appendChild(status);
+
+ let issued = document.createXULElement("label");
+ issued.setAttribute("value", keyObj.created);
+ issued.setAttribute("crop", "end");
+ issued.setAttribute("style", "width: var(--issuedWidth)");
+ listitem.appendChild(issued);
+
+ let expire = document.createXULElement("label");
+ expire.setAttribute("value", keyObj.expiry);
+ expire.setAttribute("crop", "end");
+ expire.setAttribute("style", "width: var(--expireWidth)");
+ listitem.appendChild(expire);
+
+ gListBox.appendChild(listitem);
+
+ gRowToKey[index] = keyObj.keyId;
+ index++;
+ }
+}
+
+async function onLoad() {
+ let params = window.arguments[0];
+ if (!params) {
+ return;
+ }
+
+ gListBox = document.getElementById("infolist");
+ gViewButton = document.getElementById("detailsButton");
+
+ gAddr = params.email;
+
+ document.l10n.setAttributes(
+ document.getElementById("intro"),
+ "openpgp-intro",
+ { key: gAddr }
+ );
+
+ await setListEntries(params.keys);
+}
+
+async function reloadAndSelect(selIndex = -1) {
+ while (true) {
+ let child = gListBox.lastChild;
+ // keep first child, which is the header
+ if (child == gListBox.firstChild) {
+ break;
+ }
+ gListBox.removeChild(child);
+ }
+ gRowToKey = [];
+ await setListEntries();
+ gListBox.selectedIndex = selIndex;
+}
+
+function onSelectionChange(event) {
+ let haveSelection = gListBox.selectedItems.length;
+ gViewButton.disabled = !haveSelection;
+}
+
+function viewSelectedKey() {
+ let selIndex = gListBox.selectedIndex;
+ if (gViewButton.disabled || selIndex == -1) {
+ return;
+ }
+ EnigmailWindows.openKeyDetails(window, gRowToKey[selIndex], false);
+ reloadAndSelect(selIndex);
+}
+
+async function discoverKey() {
+ let keyIds = gRowToKey;
+ let foundNewData = await KeyLookupHelper.fullOnlineDiscovery(
+ "interactive-import",
+ window,
+ gAddr,
+ keyIds
+ );
+ if (foundNewData) {
+ reloadAndSelect();
+ } else {
+ let value = await document.l10n.formatValue("no-key-found2");
+ EnigmailDialog.alert(window, value);
+ }
+}
diff --git a/comm/mail/extensions/openpgp/content/ui/oneRecipientStatus.xhtml b/comm/mail/extensions/openpgp/content/ui/oneRecipientStatus.xhtml
new file mode 100644
index 0000000000..5875224d4c
--- /dev/null
+++ b/comm/mail/extensions/openpgp/content/ui/oneRecipientStatus.xhtml
@@ -0,0 +1,86 @@
+<?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://messenger/skin/openpgp/openPgpComposeStatus.css" type="text/css"?>
+
+<window
+ data-l10n-id="openpgp-one-recipient-status-title"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="width: 50em; height: 22em"
+ persist="width height"
+ onload="onLoad();"
+>
+ <dialog id="oneRecipientStatus" buttons="accept">
+ <script src="chrome://openpgp/content/ui/oneRecipientStatus.js" />
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/openpgp/oneRecipientStatus.ftl"
+ />
+ <html:link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ </linkset>
+ <script>
+ <![CDATA[
+ function resizeColumns() {
+ let list = document.getElementById("infolist");
+ let cols = list.getElementsByTagName("treecol");
+ list.style.setProperty("--keyWidth", cols[0].getBoundingClientRect().width + "px");
+ list.style.setProperty("--statusWidth", cols[1].getBoundingClientRect().width + "px");
+ list.style.setProperty("--issuedWidth", cols[2].getBoundingClientRect().width + "px");
+ list.style.setProperty("--expireWidth", cols[3].getBoundingClientRect().width - 5 + "px");
+ }
+ addEventListener("load", resizeColumns, { once: true });
+ addEventListener("resize", resizeColumns);
+ ]]>
+ </script>
+
+ <description data-l10n-id="openpgp-one-recipient-status-instruction1" />
+ <separator class="thin" />
+ <description data-l10n-id="openpgp-one-recipient-status-instruction2" />
+ <separator class="thin" />
+ <label id="intro" control="infolist" />
+
+ <richlistbox
+ id="infolist"
+ class="theme-listbox"
+ flex="1"
+ onselect="onSelectionChange(event);"
+ >
+ <treecols>
+ <treecol
+ id="recipientKeyIdCol"
+ data-l10n-id="openpgp-one-recipient-status-key-id"
+ />
+ <treecol
+ id="recipientStatusCol"
+ data-l10n-id="openpgp-one-recipient-status-status"
+ />
+ <treecol
+ style="flex: 1 auto"
+ data-l10n-id="openpgp-one-recipient-status-created-date"
+ />
+ <treecol
+ style="flex: 1 auto"
+ data-l10n-id="openpgp-one-recipient-status-expires-date"
+ />
+ </treecols>
+ </richlistbox>
+ <hbox pack="start">
+ <button
+ id="detailsButton"
+ disabled="true"
+ data-l10n-id="openpgp-one-recipient-status-open-details"
+ oncommand="viewSelectedKey();"
+ />
+ <button
+ id="discoverButton"
+ data-l10n-id="openpgp-one-recipient-status-discover"
+ oncommand="discoverKey();"
+ />
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/extensions/openpgp/jar.mn b/comm/mail/extensions/openpgp/jar.mn
new file mode 100644
index 0000000000..fbcff37062
--- /dev/null
+++ b/comm/mail/extensions/openpgp/jar.mn
@@ -0,0 +1,14 @@
+#filter substitution
+# 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/.
+
+openpgp.jar:
+% content openpgp %content/openpgp/
+ content/openpgp/BondOpenPGP.jsm (content/BondOpenPGP.jsm)
+ content/openpgp/modules (content/modules/*.js*)
+ content/openpgp/modules/stdlib (content/modules/stdlib/*.js*)
+ content/openpgp/modules/cryptoAPI (content/modules/cryptoAPI/*.js*)
+ content/openpgp/strings/enigmail.properties (content/strings/enigmail.properties)
+ content/openpgp/ui (content/ui/*.js)
+ content/openpgp/ui (content/ui/*.xhtml)
diff --git a/comm/mail/extensions/openpgp/moz.build b/comm/mail/extensions/openpgp/moz.build
new file mode 100644
index 0000000000..64429f4bf4
--- /dev/null
+++ b/comm/mail/extensions/openpgp/moz.build
@@ -0,0 +1,7 @@
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/rnp/xpcshell.ini"]
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg b/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg
new file mode 100644
index 0000000000..6bc9b4f3af
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg
Binary files differ
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg.asc b/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg.asc
new file mode 100644
index 0000000000..c67d0dd057
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg.asc
@@ -0,0 +1,200 @@
+-----BEGIN PGP MESSAGE-----
+
+hQGMA3wvqk35PDeyAQv/fskZLTo2KonOaZoFSjMcDDtnbI94Ra6urjupaltpoblj
+suA8uQJaqeud54Es8ViCyMTdAcx5S3N0DdcyNVhbY8CMzNxvr65Yeti7vSydX+lE
+jo8bV4ahlGuHRm+57d4LTcgHclhHuEbQ/mg33k7kcRh+GUB0qX/j//ZpumBp/dVP
+a4d7G4BH3IaUBHkMR7zxYN2GytgcHcjHYaihjDejWWvTi/fEmUn8wUBF4kBA4ZBk
+P5VdkMEG5qFPoULbS6x+J+9DEOeoVxCRBupeR/wIB9XaxdjYuzjVnguIAAFhyvj4
+kmODe2mwz788fGeBU8pAXBnacBU2tuCtSQMT1v2hvY5l2R7PBnQRP8Og5lGRQGTN
+IMluCVfKdhAJugVUlBk0fqgmdxnggJtbm/9MdgALkG35cRTiQFA2Tbg/+fzDNj8j
+l1F6WiHe8bK4439rycE3RMpbcPChxMXSxg+tYyxVcbKyYVaV1p2QjBdI5GtLGy7y
+IXwFXIi2+f1G01JiJy1Q0u0BqEc1cUdlXPJJHmQCzx9P2ea87uEiSuUBQ9rvPNEM
+GraVb2AMIGtztVh7DEWdn+QpepGLB2c7wx1ZVT7uSIJzDt0rxMWEPwiUQR5Qd17p
+z4UAw7OsRl44QsXBiqAZj4T4i2Tj34c7uBGRgRCRV+J8qh/9MzFmL/4ZBQw6UhyK
+7GCrkwcwsC1GO7HTOZyZV1i6iVhFykDpDS6iDXypkgCCDMZC16fA4GgD1dhE/vR1
+q4O3YH250sfmRqMfN7M6NTTETK0h78rwuwng6Z7ah9QMX9WESJgfAG6ZvlrhmbCu
+K1kg1hJKvPCcxHRqAs+hkUcN42CNhLiMZTrQnHHyGp5BHq63IwU8qRWOeWVzdGGe
+JsdhGrS9LdWsk/Wh/5zoqj4LREqILgLQM3aMoFdk86T+Td7sZGCO7BIrjif+xktz
+PWEJAcl30mI8NXe9YYtpvm1riXqf52q45kyjjq/2WJA3T7X5CVr1iC60JxrVD9Rv
+TKsRdU6eVVmlECPNzgz8Ft1RDo+tbmtByOIkck9pnlbL9ZGJeTynjVRdHdogs9/i
+PbukExDgkQuW05E+NsAmj2N4/ery4uneRiWoe3VlKczXLpCyMMV6GlYHlIECcxkW
+OJ+nnaz2oT6AB4ooTbZADG9PxQN4sVPhLuCtksVoH1DZjOL63oPpDW047U7yyyFI
+t6JccClKIjgTsDyiLB38qjhdKcrvsd9gsuueowsB67zVCxDylG8IcrY+SHWXjEK5
+m23xwIEyGcmyotawK0GYuWH2a8ZxqoJLCTyCmxrFGjd7Twc89AVqaZ+BGZ3P8KgJ
+ztMg+oB9t3MugiAepMFTHRdOIO5FWUraNrJIq1fwT4ZzLpCLygY9xrCvjekm4sHy
+uKzvkFvjft9C6RKeEWd0PjV8FU1cnPlCDtidPEIrro8dQsgh6hyTuVOZWDwUh4k2
+kPErWW08UM4luFU60VgAeWNW3Z0Ue9ETZwbgl9MWpsxlJxC24E13Jsugj/xOw0By
+BwYFSH/Uy3+vggqKjdDC0mn0TSKD1ay+vTBfw1DUbE4FxmPoZQ5KjKX1949E8P29
+ejdTf9k56pKA5vcResOlHJqo16oBT7g4SRmK/vTXL2r+STS3egCdz/TAvGV7SY7s
+x2aifs6sMoe8dO6OgJUbxFUUgrzWqud/nlmZI2R7BnDdG37/05++7ETxxCdLT2Jb
+uzA9azO942UmcLttGoKsar7dI71Z5cgiwdckk+z4zkJ2W/JRGoWui09g1NzDbGSu
+rsT8SQ3wq3ExSABWYMywbaXj5KmKnLMzJfRmyDY6Nc2bN4KQbP86eLBi6tjNPJSZ
+bf+qqPgMIXsK0deH0KZlt0Gw2ltryuYB2KEc0RIUOKzA0cIGxpC9m6OTMn23+4sE
+e69X5Z+cd+ObrG0WxqUz5w7aE+Q/z0aqccL1yQdzN3VHBo/mmCErGDcfGNPbEBOR
+nJVv8ov6ueBaYiKtb2N1Rw6cTjT1kPbjFRnP9wxxu5FsVkn51jAOfdLlbGZPS9tR
+csvv/6QEdFcV1HAw1YJPsraCDFaK9rKjR38lzxb+kHJQV18HeDjiG6FIH7/0oOD0
+5vdEMI8hebMI8y4y/ly5xJoVJ8IDQURXLk+/uRlN0J7C2AX9egzJDNNrLZGJAcnx
+ITBLhhfj+pB8EsEYWAOsJ33Y8Q4y+RWoNZSly7bphvWQP30dYLa0R3U4xRwlzfLe
+0o8EbzZHTYWtWoGx5FmjopSvPqT4i4OzCWQZlCgedIMRZYITBOzqjj7g2xjKLeeE
+2qho8ra+X+OsuQhp24sMjuu9SfUUQUXoNxu8qCMfM8wjjeLKyG5/1ncMPV3z4t1/
+GBi/Gxd1eQ/whh6rzEyFrWdm0+zPTONAHJOLrO0iS3szK/EFZMQK1IcgUzFfQoKy
+wANnuc8mIsQH9IuglmqUak+bc2J1wmNFAwi3KE/cw+hhqCzpObf1J7OUISRRrx15
+mL5Z/kUGgTERWcEPaO9LU6h9eSx5k0oQxatrrOVen29LPRRxfPJQf/1PqQX8YAy+
+eP4de1RXbKp1XsyZSPyJ1qciPHopv1I1uwn0eBt52VWqX9kGtyYWK1uXGYJdSVB5
+wihOxZDYtBnq872lKS0CQjZb7DyCkMuTUeBEQNz2PK0d8pKA22wGBFyaRd9EIQye
+JtVMWLUBKFRu3bJffmDovwBeGw+CRzYv0qiRr10g2it9M1uMUm7XGBzFqXVMPkGA
+wE2rD5DM/gKt4OaE/ccGtpjdoH5eKYD+4UzEHYzHbyQh0JXbjdPPR5KNdh/oxpAm
+JLwUdPiG6+hcCab3FL4VBWFdJGQAez3okmI8CzgaxnDBgeYDDtN2IwCqJ6VHjv2w
+5aAOCbt8xxgcrDNlF4V6TsUSXTRYgQdxArwGiy54HxKpF2TTM24csLO1alWcdEfX
+mV2fcRo5GrnMeCKboVBoMIcY4x5ClAaoq8LuCRYDH3qT4/FDOgp2O4EqhzvwR/7I
+UQRHY0g/m92WuRb9rVhX+Z9a+ljW2AmxpNuyrwTQ0+KPykJMHQNy+LsKrG/7I9ro
+ZL7V1OyXuKbjE+8MI3PGOQWqH+dL+/hK24cMn3Tz0sIZmtQcf/KuJfe7TMERGB+d
+GugC9xLv0gfUuMcTSN34autdHC8pTDUufJOvDwZfKQzbFYcOXmC8cTFmw84Z4lLh
+Odq7UmaS6/UhE9D9nY+4XDUsKK7CRE6B1RqI/tFKYSO130vHklmCVKkLtrwYpGay
+JY/Nwo3Cw8NUqQOe8DCrhjKgAkE6QTMpf7cmqIwpif6J58kM282ZKnV/EVIaP0mv
+khd2FnJqHGKRrC76i24AtyuH+1jMY4jt0XpLhSE5oPcrtTsyadhcw/YIHMTXbYLA
+b3V+n/yYZkMlhTbKQmks4kVQTCJMOHce2ES69WuDeFkaHgo7uJUp2gkl3TuUQ184
++/Fi/6EhhlRff1O7AHXNMYp2FNJ5O/fbCbMuo4GVVqjBIG5nS2QfmfNUvtSsQQIg
+bpdP6wpkbcN24WHOYieNfiQHlvb85j2yXdaSRX6pv+biTw1JoEzWg4zoWRzWTf2O
+x7l9JD4vnhflxNrlvg1AoCzSWWo1qDVEO73lkwmDGU/Y/T5twzDAEkZfDCVWiSPe
+Ngz+oNsLGNSYC1HxuybVCCqX5ahLusRkX1NaAB2dlefsjnA9UJmKEtVUacDBR0Su
+tVie4FgRTBU5yWX2cMEmCm17+VLINhfBYpMRw0dOxZgH8uKQz6DddVm/5/O6lYYN
+GLtY9ZSbVV3cbBx3bGV6RqOVGuvPKFsZo6xwu3jo6Ij+O/aGaFiAwfFGzYZs+LOU
+BCE4ShXQDOxYUdVuM1f8R+ZabtiVogOE244nWwhqaR0INCxr1T5VsIyO2yWpDUld
+tQR1gCkzMEITIs7ORiUwFEq8MwzlwbI67V4s341HKgoQG4VG20zRfdNZrCElFiJq
+Q1WhIJJFzRZldVTdloQ/kDAHEEDu7ypVdkgmX4P3ak8Cp+FZjxJKYo+We1laioPj
+cn+YHq0aRG8sFJGAyMem4kW6m9TzZ0Nis48hFJdpjpcIXi+HmmmRlB8O30icSeOF
+D+4D5o/J0S5hdnLTL4jK7EfXspruiVjdcKTgZsEfo9xMbUSvkMfkUAq6CEqp5UpU
+8UL91QyK3NFvQ/0hb5IgrTKq5aeK93ZNr4RNlVfLJ7R83KbNbNFNrdxlga5Qr4h1
+7QUfXYVq0fkOVukxA+gYdVeE/oqrFmdpOlOte2sFdcEQm25HsDcpsVaV/GUpRuoh
+PU5HWa2FEEaes9R7Yyva7D6FTOTceEVCIWVPHRz8esujgxlKnvSA3zKqusPzEGqX
+lM1mLMmMlklrZQQ8cFULzmhllY2u+fcj6DGDSeXjm92CXzfoSjntclU1OXHxwZc1
+BQ88odnaMtjf9GkErG7oiiRyv1vuYVXEEh8D34JfV/AIZizLD5MhRuwhTKFdUtdM
+SgUZpYtLVRTmAeYRaTsSWeEKcPvHC3CuqmEwF9OHiArwxBve23PoLkgQufdygnQP
+LmFN8Xv706TOsm8flXmbRZs987llKDAinThrZlfN9VOQVZiKHt8ZM2hFFuwsh1sn
+vs/AF9e0NTI39vA2MtNrKbit2LoB+5ZTl7dgyzgRR/rhD26Nj7R8B68cXHaS3eKG
+cRAMcQ1Pl2AcofbXiKu9tLeXXouin0j1iyySBAt5yzMLw+j15t526zBrGQN1/Aes
+Ukp4ao2+gluz231crZXjgunXJo8AcW40RXmvvyWakL5Iv5hCGnAWih9LCvuaPXWg
+ADexTLNpCMmsR/WAUnB3xQCVXyYoF+OBmyaIuBQ5HzH7fUgQA3k1ARtynuYcPQhH
+CXK752U8Zh1QA/D5tPFy0kR/V1QDO9zXpUeZfUnb2B7dE5o0LBy3UbmzLkpv28LA
+vyEWiKq+8igH4zjNhFTbQ4bMaTDizGwoS4KfHVt0cqDxzBIYgfRdK4v7ybnfnWwA
++Ye2ULsvE6kdgGbQ+12PKdQCZ3++8r2xY9FxXivxQjVhzsA9c6J0VHCOH1b6vv/z
+5D0Db0rjWqd10J8rNW2sP+PB522fIP/BMZPEcMsu/UbMHYt+ULk/tHKEA7znGnLV
+BbIHgjr92DRlAT/rjUvzvfKg612fPYUeXU9ff+MNA+2sMFSsY5l8TcGZIKvk8OGR
++Pq1dJa+BVlB2PNQZp1/6294mqXcZ2LIYU7UAau8rB5ZLebGCnrSILWtmmPi4uIK
+HS6/PE9qIS3o1D3yflI6KKHDhU29OGLFTi89HHe3IUEnOw008fzLC8gh84vZWngA
+IX2HjKQEJJMX7mFJ3EteYSt4tdWCdYXz4yEjtZDfzsTJYKKYQvmu1nLElWDi0VIK
+eOkfQRF+hNXOkoBg5C4jmZk/wTwUV+bqYIxdK9kGOgJw6+tw97piw0hUI9leNIFl
+UMjxCgNHa52tEMgyOKxryKjUuxxj0oxdoNNp0d6CIgiAZEwMjw1aR2waieGZJJwD
+GWwtjRSdHearoKBlGeXclj3pZgSkDNE0TAJbMN9QduQHnDlWXRgV8iBO1A/PRDJs
+rpivUpOjNzhGWfWVGmn5lA/UWsYCtgjPxnbwkV/7BIqCmQvZOUkCWDnq/J+LHKeJ
+rXtMx71C3WfOOXOCFdh1d5NoBCCghPh2sOhTrevtT8oBvbZbqPz9xSwl4WwodAdm
+2EhkioiY+5hYaqzlbXj118BIROZsAjkBWsTy0Eh6ev8a+6Prc/nUEUhmvuya8swb
+b2L/x5kGSGX3BiPfUXi1xdHmCvoqxLTA128VfK4COsalqsfIDsbQ4zMU7b1esH4V
+mIyAiFL9k6UR0WboW/MneIV5zUxxHKiHH5qzhAZRzMvU6jOGZMmWfn2usYK8dsfQ
+L+d2NpapgCmVoM9Btv11ZOVNZhx6E5SpeM2zfxjlc47l72/2pfz/Hz7gRsiw4xuD
+L4YOnW9yjxdMoM54249LwozNpMR5GFaqzqBMF8/nLUwB6OQiXgjp2G65EaJG5T3+
++dVrl7AllIuAd04hpjwb/HR/KK2F73QG0hIQuCnLiqjSwNM6tGD4/BRZV7Z1zwgR
+Ab1dJKP7ZAO7Pnm8kO3AM9ib7KJnou3ZzErqnNwKRSMwvnQDI+E4feu2NTmg0LWB
+PLpgh3ljzl/TMDMHqsdm2gPvd5Ql/lLnivGELVFxtncVIxU29NwfmOoVRBW4LgVl
+roUEvSmoUdLxd+0c4l1nnoq87mVbpx5oQ3KjwK4GgUHyRJshYQs87HyJ5c8whM4M
+QBVvDBU/NvaZ0R/9FOqUFHM2yxWGHgqs4OSzlyHMCybOixxvwSOsDoauppIBO/t6
+cNqWruaqcYbs2FYhTZg3HKmjaRbIoGLl0Ko3BJstWyweqWJQeOijyQ84ejkwAkFL
+cuDYH55yehsQ5O1h0UlWXwM4qTLOJYj9UvH7aVJZY+mhbrIJ74Vo8eNeJ6GxShni
+gVJp2N3aiRc+brkAT7qFDG/+EPp+kK2h41wSqZLpZj24q5oLbiRksv5msS7CIE4w
+BL1xRK8SwpeVAlnWZ0XyBbQ9tccvqOLreY7EcsAjf2ONKsHYl6phciIyT1Cul/1M
+PzeRrYQuRLAnYi7+TOAFkkPqdZxqfbC5cs11tseFOg/Rjqmd6VHZw7jTzlgjw1Pw
+YsEr9TDAMbApLUwtDO/bAmkVPrUhufT9E+U4zBo7ln5oh51Tjiz/5Xp1olXWfa/j
+fv+Xwjbx1Sg7Y49SizVsS5ilzSd6XO51ob0O1dl7JFGT9JTmyTI++PEfSOwNFKRl
+NjoT0C4tIAXyBIRJm9umFooO+7URm6gmjLdggvbbUmJEIQsexhHIwKXMEUucpU1A
+PY6IoglWF44l3bhh/SANk1E9BeiDza00vZwzSNpPfFexRpt0XGhZabS+3MOBJLFi
+pk6ASWI81r0jTrRiz00COnQDPsBVp86NT3B/v4gbXJ6F05gDbA9cW5qOU8EIUtFo
+gblCweTQH8topgxKhcJenWkCuoEwpopfLwY3kJVVsKj7+4kwP+BrAdzreCQ83JCS
+5ySboNlHMx7WXFiRf3f1lSGPBYtzGN/v4X8m9tOW95kmIpuCKSYXNJQlnDiie73w
+2ogqgOsXWp6bI3d7u5VCKAZjpx9sEGLC46v+n1M5pjcoriit0PuEvyX9G5839F4T
+HUFx2wsAMQxT4I+Qhk9BxZ/G/d+u0i4FsgNns+ijvqfOMlURVBcYHYjOZUgXJZtO
+sfILq9jo8dyIjzmAbqj2RY3YvUJT3KTsDXJC7i9WhKv2hUADnScfo2MqfIO67HnN
+tGKb1XRhY+FMgMXOpLCiY86+qkZGrT23gks3W9vY4Ko7LIXj4Ao93+o/bbtcZTTj
+ZIXIpMaigjpEBFpVk5h6Af1ajFFSjzzPRWhywrBOUuXGsqSigzMvNMCsPYFuErp7
+GWVdTah+JE+V9uR0/JS2Tr+mnX6HnjoPltWRQ+MEQT/Jiw1AUi8pdD4LdR/MK5Tl
+307G9hsysMA/bQpPWsj7b994OF6XD2cBNFVt+sTHc4OpbTEnwLIIC/0uyHSE6vIL
+0pApr/+xtkSUmcbiEcLfBtKd1Wibdvkzea1zcSKdSXgtK/QjvK9AidMWI6WOuPiy
+Yk/9o/SnyYEVdHvbJg74JldVIzK8a7Hay4Q2XQ1eU3H63dPLOfeGiGARMQp9btv0
+vGvQJ7PWu6cNLF0gv5vaSvPU+YvDCRB8xd2J1/MScN3JEPHxRy8xyB89D5Z3dHmW
+SpIqiHJ9o3pP5CIoZjjftwPpZkklJGzG0272nFWJbbuScbBNa8JSZ1a8IXrhzUi5
+83fh2/TsCF8bSkyPgI8GPY5z/rj+MEzIrq67K/JWqCALcbrSfHFS59LGE5gJwMng
+7kLnLmI235FKlBwtQESgI8uP/t8ydYCEkNJRvIOAhJcbQR/Zbn7FIqFTFQrgGg3K
+nntK250VWYK2e83ZvaBK7wXc/7TTHBzpbdXpUvLDQxUi/CCJhgHluyISiR7PPn/H
+d3K0jWUGv5LBmLoq2S4OTgEq0C1bE5dTIXQE4SxDSxJoKUXrqO8nIY5SfUI5v5MD
+8VVU5IgUuaSXdcN5UAPRlT88bb1wRUQ4+WoJUYDTWFwhmjOoxeLo5nTCYRoBu8UW
+wmpc0VGU+j3/nxYI48sjn9gTHpoG2f8YBTzpgN9SriM6pM7zE12TgiDBU/fCDxB4
+YW+q/AWxa6hWyURfY9Uy0oa1FeocCDT4kAkaiNM9DqFUaI3Oviz55c7nnF2jS5v7
+cobAi1OxSZJcxH4DHSVYUptmXdXEyTlbYbJN7Wq1+h65SOV6aTQYQKu2YF4C9qvv
+zFtilXkyJ6gskG2209q9i6dA8C+hH9n+lxhkhy6llTZV3CHb/ru5yPUseV4XghwU
+6fH64AgqFmz1F5onEui2JJvoYbwyD4/UQ59/dTe5XIAgaI4WW0LuB3HuJiBu+8rv
+lhcxJEMWP6ZpHpvLD00ENYfFWBl/kZtuOs/M30SlErYO6OtrZfxo+42utFPv+c3f
+hUQqvSuBa7/Ix6adau6CBSL7Rqqmph/HZ4WCFLECCDKZNn2r3PAfVE4zmii/oK2K
+PdylDQoUkARvtZz1fX7PcDcPfUB/yPik3dH2UeqOBt4arcb1XB5+r5HMWc9E8KA/
+m6cPgYoNy4yxqw7+TFSZI5yrUFKCjQA9rpaI3ea2OjWc6CpE3sEo0EC2dcrLEuPe
+YKdQ6t5fe/Zwm3Soxh1GqKzcl2ZOAHZkzrZ0I/cHPHWIbOnEodmIW5WMMN8oStsS
+rBhD8k0ckS0EgvmJeStETuSPX0vJIQXBs9nxSVP9hPTLuDCQ7TJXInj9VZ3XifRH
+GKk4IvmvGj9CHSXros77m2Iw0NGKxhKNKjAVGehTy2GY1iC2kDYcPbM235OO3jTc
+8+XeN9m4XETSFPqeE4ALYcSRvYusFZ54VYloe97aHptYtz7u10r9ooM3rQPfalkZ
+HwR9zj8KfFS+dJhQCISw/pS/1yNU+m9dM69szKuZKfrN2f5Fd2qyZd5N74MdZUdB
+Qhcd5CLfme7wYUvxg+4Vo/hPe2aiX+xF3EgAC4sU3vsVb3gBRXAoWoY85UsZkmS1
+b82IXy/7PZcRAlU1qjelOMxu3DIkpTKsiCcxG5H8POLXpGte1WmFgj4XKU17gSHY
+jhXdxegbPbk4OQDsWPflWh3r6s/eUowPFL5yL1WUccoobesaWK+3mzkCZxmhJml5
+ASZyY2iD0uR/7ns9D1vgAWN3jHbSXNoMrm1pQZJ6Vfl0u5JfH7HLZXnIjQUYI/6k
+WLj70c4E8syK1ZQ7XfZd1zSh2ADATPd+t920YiIyS1i262W5rMkL9QzBDAT8b49c
+2+6wM94b8b2Fztw8zf2XPAurzvCRDs/jJ5mcVC03tP9pULVtVJ6hSshoRtiKQqce
+FJI7P9QGzEVwafB353VkYhpd6MYk2MdOclwhOACl+ri9VIZBKWah2gMbQ9sGbSwZ
+MZSWx8BDTyUq0y9iUoVnSaIoLenXFyAzlYuBql6dz56ZX5nhw2B4+Vj0OrhES2Sx
+9hsJuOMPeGWw8YCBGMigQMZpPRUDSaFmf790BIXhRIussbDTIaJsAXuEFjFg82AI
+thfxBgSKMcEzycbtuxDFDjSAKRb+cWn9zqxNusJlCuDfnf2wxwtLW1rLjkeM3ipz
+9IWle8+fPDOgSMVe9SUbnH38hBKsVNhzByFRtzJ7uAlJ4FFwo3OtMyMSRwMDW6sa
+TXWBoLDFcG0G5oBGeLcXgnScprkRnFNdpC5016bqe8i0bpBpLOJovdGERiCcmhPo
+9C9WzyzaU2C5O3zzkZKJvsiqMYTkDorqMN8gm1Oy2T39p8cLuYpf3/WLagRzjiik
+mcow8j1DU5pjqB+1a04xFysaQFaqDyV1Wdcq3Xps7tXw0v73S966zMtFUI0Yszzy
+qRiIjqoYdh4B11AITokzwqpaaHi7uifu87mDjZUQR1pH1owedtmMmXniF0SOmkX4
+BRq2XLqQC3cS1tp5kpJ/jHiLVMLw5ueadwQLjSBrDpbdmNHSgtD9HA9AaDo0kefD
+u/H3/xF24QOopYYci0X6vomKo0xWhaQTh0wXjem9pEL0I9NMYDZmlk3wAHQ0Qvw6
+zQoL7dbfLCWxfCle1nyuqvEnzzlLvtPgtbSpuXiLGD2PbKoWIJA6W7GnLXhUJnkf
+irMeseXpoTg/5tmOPh9OLELb8Z2hsoSDxkkpPFw6dBJxLTTc9gHBWB94Inz1fB4Y
+wk2xN4fNzJwFknG7NmRv9ieGpsHZ2Yg6seOiT1JfHFFlD1v4SC2NZo2qHVv3QfQ/
+nkh7NgDmq53VXXQGzOA5r8vPrxdj1tX2qW83Xpx1ENOVZQWq8t2AENe2OHR64bKH
+XmysCm6Jc9PtoHpVRd06Kme+aOHa0M3K2g/CWXjMD4nhOBDuRKz8/tKCIteSXsCO
+tu6pAk1w6jB7FnZBWPKXzLK4+4etYdoxTvcrDR1xpTuFQHE58EOZlXv1sISGjif2
+9rKfO+AOv1TKaqrXB5VT4x661IGsSa3ETne1UMdzREq8WGUPkb+oqGtnOCeiPNPl
+qoBLoaK7xZocaQBOJZXwPSSEacjxaB0JjFChI+DFU4AyjocVS7gk4cKA3p1ulvbK
+dgJsc2o0SBsS5tu9l0Y+T9McPIalh1PTkb6vR79BwIUucZ8gL88l6tXYdXHqMryi
+I9DOslqjgigJLoC0lPfyh3MikFoFI1vAn1flRbEj78kwsQ8v4YI/Z9k+fSUHDLVO
+HwRCi6CsZOmCwDk0dXxUH/EqPjbk7OHSrBgtLyZlgd3KZaLuXiQaENJBhh0Jlm0G
+zi1/hN+FgIb2yLnmQ2Ph2qz5wKZ8YDRn6itcYlMJzA/YZNHzY8TEu/pmtI9I964Q
+ZcOVusv+bhhtr9ygOvWkxH99CIDSwvvqrvzoIVeLRAoD/xpD9XxrmVtvQ2De3KrD
+x9VYXiYvXKtI/KIouC1a+aqiWb31+1y/xsuLPaOkWLH1E+QKmms2prW6PZ2dYw1w
+rQXs8BvM629l6RJqihZgXLPrTMTFaZ8U2mtIKUw3LkVCX2LgSdmpUUqvJ7yotQF0
+xFQG3Jwlp3tyL9Y8bbYIHae3lpTyw11sGQC+AJhL/+fks1zYwvopx+mW1qMNjlRp
+gGzKJnORJnYKM75CeGMsianazBcbCFcgPHoVg/T9ttGQOafh/pg/9YkyOEZeoE57
+/vH9EC9Bruzw3FzVZmWvFTsKjE7eRnj1tWt1hBf1KVxlPQotbwIvZpCKc6j5It7m
+RV2Uwv4vQNNnf35IMVpSpxvs17IgH5HcDZgoBpkc7EIrllmGFGSpkYueaxYNxP9+
+C9u1wUHkhNIoMhE/5QTsll9KwygQbqI4sQMEcLvYk4oQOPJjZ97UgemIUAWJDj34
+BNhuz6HHryt2tX7E/lq3YFXFKRDkq2F3q1siP9waW3Hf7b3e8bpK+T0z1PIvAQiC
+T+lqmad+OKSRpH/B8E9fgF60wJMUC/fRQXuaO4ZpSUWlOLwOE+mUv2j9BCsBvjDc
+bVXiSZEViQlQIL34CHk/AVVhxkiUaIjFRGzK7aXl+7Rd0jrwxrvGxgXHlbovfd5w
+Tptcp+i8xL+btFD5OPt2a7WKmd9UBu7FCO9NPLUohgJat+uQulvYDjdmj1wMRAbC
+U/kCxUUqrU3gNtDzqItOQy2wd53NiPTxlzilY+xUG1tbtFF3I5zSghikyezr84P0
+EK63HbyNLEt3vQmaVk/SUNspz/6r5eSeslnzZzgGDtiBdLGiCXG4iymI6C0UQWHT
+8Jzirj+XzeeON0cWO1xmOjwFys1ZB92Zn40BBWWXBR00WBd/5/gCEwm5gkC24z06
+dVaPanNqa2jIt1x9jaHrAz9mTkFsg8Krts9ShFsdrD7Pql7Q0sLXp/7pLf8DdkNX
+SL8KLiOQcwubiWl12EDlX+puEic1D81sxNzjWMIFXKZC0oUT3CSMbJjjk2O6Uaz8
+wFUz/PmnBLKjXOUZXAkfJM+h4s/zYDArkYfDOY28AiyOubwlPl4WE6ek0XgMngk7
+StJovaYlxVAeTyKZPGzjLsuULu8jOIiyQh68Uge2mMEgHEpMdu/46A7Dxb8jnxXF
+w6t5SONEd1QKppXpjtbcfYJ8+ZdCjYWdCPLySpChlf3WTsBXj/9g7hfXw9cnydpC
+K7x3fW3W3V5FPWfO9TjFYJFzh0r4lhuzpOk8xkWn+elexseIdVhciHutusIqj0t0
+PU2Gb/CACaP6akn0NP6aQTCqcu99iABqfbS4gUJAXb9sf/eTblCCaD03J/AqhgAN
+Kz0mcXgB3sHbw6LJ6W0tB0mvd2ogWBUzkTkrmarsDI27ueTjlDF6liVkJy5d4Kls
+2h/3EEFIwIHVFcBS+/2CfUQhMQbGO4ynivRLdkeXu47ahHB5IlKDyiVfDh1/CZRR
+fSacsGl/9JnZZhXHhJEccoReXEKaNQOF5okg/OOMcIk9Ub8FRIidtPu3sMNQd5aw
+OqSc9tGDcmANF3LQEj1htma9yReOKmZdQTDY
+=s6vG
+-----END PGP MESSAGE-----
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg.gpg b/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg.gpg
new file mode 100644
index 0000000000..1feb515938
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/data/bluebird50.jpg.gpg
Binary files differ
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/data/plaintext-with-key-and-windows-1252-encoded-eml-attachment.eml b/comm/mail/extensions/openpgp/test/unit/rnp/data/plaintext-with-key-and-windows-1252-encoded-eml-attachment.eml
new file mode 100644
index 0000000000..599d5f64f4
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/data/plaintext-with-key-and-windows-1252-encoded-eml-attachment.eml
@@ -0,0 +1,109 @@
+Content-Type: multipart/mixed; boundary=\"xy23ZrTYskosBXu9d5LEB0IV1ZMMCLTf7\";
+ protected-headers=\"v1\"
+Subject: Key And Windows 1252 Encoded Attachment
+From: Bob Babbage <bob@openpgp.example>
+To: alice@openpgp.example
+Message-ID: <ce639032-0823-5c4d-30a3-76a874950908@openpgp.example>
+
+--xy23ZrTYskosBXu9d5LEB0IV1ZMMCLTf7
+Content-Type: multipart/mixed; boundary=\"------------azSRG4BClDM2kDHcC4FYbiRW\"
+
+--------------azSRG4BClDM2kDHcC4FYbiRW
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+
+Please see attached.
+
+--------------azSRG4BClDM2kDHcC4FYbiRW
+Content-Type: message/rfc822; name=\"win1252.eml\"
+Content-Disposition: attachment; filename=\"win1252.eml\"
+Content-Transfer-Encoding: 8bit
+
+To: bob@openpgp.example
+From: carol@openpgp.example
+Subject: Windows 1252
+Date: Wed, 4 Nov 2020 16:32:02 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101
+ Thunderbird/78.4.0
+MIME-Version: 1.0
+Content-Type: text/plain; charset=windows-1252; format=flowed
+Content-Transfer-Encoding: 8bit
+Content-Language: en-US
+
+This message has 6 ü ü ü ü ü ü.
+
+--------------azSRG4BClDM2kDHcC4FYbiRW
+Content-Type: application/pgp-keys; name=\"OpenPGP_0xFBFCC82A015E7330.asc\"
+Content-Disposition: attachment; filename=\"OpenPGP_0xFBFCC82A015E7330.asc\"
+Content-Transfer-Encoding: quoted-printable
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsDNBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv/seOXpgec=
+TdO
+cVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz/56fW2O0F23qIRd8UUJp5=
+IIl
+N4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/5whqsyroEWDJoSV0yOb25B/iwk/pLUFoy=
+hDG
+9bj0kIzDxrEqW+7Ba8nocQlecMF3X5KMN5kp2zraLv9dlBBpWW43XktjcCZgMy20SouraVma8=
+Je/
+ECwUWYUiAZxLIlMv9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku890uk6BrewFzJyLAx=
+5wR
+Z4F0qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI2og5RsgTWtXfU7ebSGXrl=
+5ZM
+pbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9/0Dca3wbvLIwa3T4CyshfT0AE=
+QEA
+Ac0hQm9iIEJhYmJhZ2UgPGJvYkBvcGVucGdwLmV4YW1wbGU+wsEOBBMBCgA4AhsDBQsJCAcCB=
+hUK
+CQgLAgQWAgMBAh4BAheAFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAl2lnvoACgkQ+/zIKgFec=
+zBv
+bAv/VNk90a6hG8Od9xTzXxH5YRFUSGfIA1yjPIVOnKqhMwps2U+sWE3urL+MvjyQRlyRV8oY9=
+IOh
+Q5Esm6DOZYrTnE7qVETm1ajIAP2OFChEc55uH88x/anpPOXOJY7S8jbn3naC9qad75BrZ+3g9=
+EBU
+Wiy5p8TykP05WSnSxNRt7vFKLfEB4nGkehpwHXOVF0CRNwYle42bg8lpmdXFDcCZCi+qEbafm=
+TQz
+kAqyzS3nCh3IAqq6Y0kBuaKLm2tSNUOlZbD+OHYQNZ5Jix7cZUzs6Xh4+I55NRWl5smrLq66y=
+OQo
+FPy9jot/Qxikx/wP3MsAzeGaZSEPc0fHp5G16rlGbxQ3vl8/usUV7W+TMEMljgwd5x8POR6HC=
+8Ea
+CDfVnUBCPi/Gv+egLjsIbPJZZEroiE40e6/UoCiQtlpQB5exPJYSd1Q1txCwueih99PHepsDh=
+mUQ
+KiACszNU+RRozAYau2VdHqnRJ7QYdxHDiH49jPK4NTMyb/tJh2TiIwcmsIpGzsDNBF2lnPIBD=
+ADW
+ML9cbGMrp12CtF9b2P6z9TTT74S8iyBOzaSvdGDQY/sUtZXRg21HWamXnn9sSXvIDEINOQ6A9=
+Qxd
+xoqWdCHrOuW3ofneYXoG+zeKc4dC86wa1TR2q9vW+RMXSO4uImA+Uzula/6k1DogDf28qhCxM=
+wG/
+i/m9g1c/0aApuDyKdQ1PXsHHNlgd/Dn6rrd5y2AObaifV7wIhEJnvqgFXDN2RXGjLeCOHV4Q2=
+WTY
+Pg/S4k1nMXVDwZXrvIsA0YwIMgIT86Rafp1qKlgPNbiIlC1g9RY/iFaGN2b4Ir6GDohBQSfZW=
+2+L
+XoPZuVE/wGlQ01rh827KVZW4lXvqsge+wtnWlszcselGATyzqOK9LdHPdZGzROZYI2e8c+paL=
+NDd
+VPL6vdRBUnkCaEkOtl1mr2JpQi5nTU+gTX4IeInC7E+1a9UDF/Y85ybUz8XV8rUnR76UqVC7K=
+idN
+epdHbZjjXCt8/Zo+Tec9JNbYNQB/e9ExmDntmlHEsSEQzFwzj8sxH48AEQEAAcLA9gQYAQoAI=
+BYh
+BNGmbhojsYLJmA94jPv8yCoBXnMwBQJdpZzyAhsMAAoJEPv8yCoBXnMw6f8L/26C34dkjBffT=
+zMj
+5Bdzm8MtF67OYneJ4TQMw7+41IL4rVcSKhIhk/3Ud5knaRtP2ef1+5F66h9/RPQOJ5+tvBwhB=
+AcU
+WSupKnUrdVaZQanYmtSxcVV2PL9+QEiNN3tzluhaWO//rACxJ+K/ZXQlIzwQVTpNhfGzAaMVV=
+9zp
+f3u0k14itcv6alKY8+rLZvO1wIIeRZLmU0tZDD5HtWDvUV7rIFI1WuoLb+KZgbYn3OWjCPHVd=
+Trd
+Z2CqnZbG3SXw6awH9bzRLV9EXkbhIMez0deCVdeo+wFFklh8/5VK2b0vk/+wqMJxfpa1lHvJL=
+obz
+OP9fvrswsr92MA2+k901WeISR7qEzcI0Fdg8AyFAExaEK6VyjP7SXGLwvfisw34OxuZr3qmx1=
+Suf
+u4toH3XrB7QJN8XyqqbsGxUCBqWif9RSK4xjzRTe56iPeiSJJOIciMP9i2ldI+KgLycyeDvGo=
+Bj0
+HCLO3gVaBe4ubVrj5KjhX2PVNEJd3XZRzaXZE2aAMQ=3D=3D
+=3DF9yX
+-----END PGP PUBLIC KEY BLOCK-----
+--------------azSRG4BClDM2kDHcC4FYbiRW--
+
+
+--xy23ZrTYskosBXu9d5LEB0IV1ZMMCLTf7--
+
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/data/plaintext-with-windows-1252-encoded-eml-attachment.eml b/comm/mail/extensions/openpgp/test/unit/rnp/data/plaintext-with-windows-1252-encoded-eml-attachment.eml
new file mode 100644
index 0000000000..b43d9883bf
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/data/plaintext-with-windows-1252-encoded-eml-attachment.eml
@@ -0,0 +1,39 @@
+Content-Type: multipart/mixed; boundary=\"mzik1s6PCI8wO850i5PykshHnKiAGShJ0\";
+ protected-headers=\"v1\"
+Subject: Key And Windows 1252 Encoded Attachment
+From: Bob Babbage <bob@openpgp.example>
+To: alice@openpgp.example
+Message-ID: <26826c45-bac5-f173-9d60-dab5c907156d@openpgp.example>
+
+--mzik1s6PCI8wO850i5PykshHnKiAGShJ0
+Content-Type: multipart/mixed; boundary=\"------------voUvzEdWKr8OR9mQliQ0aHDW\"
+
+--------------voUvzEdWKr8OR9mQliQ0aHDW
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+
+Please see attached.
+
+--------------voUvzEdWKr8OR9mQliQ0aHDW
+Content-Type: message/rfc822; name=\"win1252.eml\"
+Content-Disposition: attachment; filename=\"win1252.eml\"
+Content-Transfer-Encoding: 8bit
+
+To: bob@openpgp.example
+From: carol@openpgp.example
+Subject: Windows 1252
+Date: Wed, 4 Nov 2020 16:32:02 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101
+ Thunderbird/78.4.0
+MIME-Version: 1.0
+Content-Type: text/plain; charset=windows-1252; format=flowed
+Content-Transfer-Encoding: 8bit
+Content-Language: en-US
+
+This message has 6 ü ü ü ü ü ü.
+
+--------------voUvzEdWKr8OR9mQliQ0aHDW--
+
+
+--mzik1s6PCI8wO850i5PykshHnKiAGShJ0--
+
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/test_alias.js b/comm/mail/extensions/openpgp/test/unit/rnp/test_alias.js
new file mode 100644
index 0000000000..cc30b52a8a
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/test_alias.js
@@ -0,0 +1,321 @@
+/* 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/. */
+
+/**
+ * Tests for OpenPGP encryption alias rules.
+ */
+
+"use strict";
+
+const { RNP } = ChromeUtils.import("chrome://openpgp/content/modules/RNP.jsm");
+const { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+const { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+const { EnigmailEncryption } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/encryption.jsm"
+);
+const { OpenPGPAlias } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/OpenPGPAlias.jsm"
+);
+
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const keyDir = "../../../../../test/browser/openpgp/data/keys";
+const mailNewsDir = "../../../../../../mailnews/test/data";
+
+// Alice's key: EB85BB5FA33A75E15E944E63F231550C4F47E38E
+// Bob's key: D1A66E1A23B182C9980F788CFBFCC82A015E7330
+// Carol's key: B8F2F6F4BD3AD3F82DC446833099FF1238852B9F
+
+const tests = [
+ {
+ info: "Should find Alice's key directly",
+ filename: undefined,
+ to: "alice@openpgp.example",
+ expectedMissing: false,
+ expectedAliasKeys: null,
+ },
+ {
+ info: "Key absent, no alias defined for address",
+ filename: `${mailNewsDir}/alias-1.json`,
+ to: "nobody@openpgp.example",
+ expectedMissing: true,
+ expectedAliasKeys: null,
+ },
+ {
+ info: "File maps Alice's address to Bob's (id) and Carol's (fingerprint) keys",
+ filename: `${mailNewsDir}/alias-1.json`,
+ to: "alice@openpgp.example",
+ expectedMissing: false,
+ expectedAliasKeys: [
+ "D1A66E1A23B182C9980F788CFBFCC82A015E7330",
+ "B8F2F6F4BD3AD3F82DC446833099FF1238852B9F",
+ ],
+ },
+ {
+ info: "File maps Alice's address to an absent key",
+ filename: `${mailNewsDir}/alias-2.json`,
+ to: "alice@openpgp.example",
+ expectedMissing: true,
+ expectedAliasKeys: null,
+ },
+ {
+ info: "File maps Alice's address to Alice's key (unnecessary alias)",
+ filename: `${mailNewsDir}/alias-3.json`,
+ to: "alice@openpgp.example",
+ expectedMissing: false,
+ expectedAliasKeys: ["EB85BB5FA33A75E15E944E63F231550C4F47E38E"],
+ },
+ {
+ info: "File maps an address to several keys, all available",
+ filename: `${mailNewsDir}/alias-4.json`,
+ to: "nobody@example.com",
+ expectedMissing: false,
+ expectedAliasKeys: [
+ "EB85BB5FA33A75E15E944E63F231550C4F47E38E",
+ "D1A66E1A23B182C9980F788CFBFCC82A015E7330",
+ "B8F2F6F4BD3AD3F82DC446833099FF1238852B9F",
+ ],
+ },
+ {
+ info: "File maps an address to several keys, one not available",
+ filename: `${mailNewsDir}/alias-5.json`,
+ to: "nobody@example.com",
+ expectedMissing: true,
+ expectedAliasKeys: null,
+ },
+ {
+ info: "File maps the domain to Carol's key",
+ filename: `${mailNewsDir}/alias-6.json`,
+ to: "someone@example.com",
+ expectedMissing: false,
+ expectedAliasKeys: ["B8F2F6F4BD3AD3F82DC446833099FF1238852B9F"],
+ },
+ {
+ info: "Multiple rules, should match domain1 rule",
+ filename: `${mailNewsDir}/alias-7.json`,
+ to: "someone@domain1.example.com",
+ expectedMissing: false,
+ expectedAliasKeys: ["EB85BB5FA33A75E15E944E63F231550C4F47E38E"],
+ },
+ {
+ info: "Multiple rules, should match domain2 rule",
+ filename: `${mailNewsDir}/alias-7.json`,
+ to: "contact@domain2.example.com",
+ expectedMissing: false,
+ expectedAliasKeys: ["D1A66E1A23B182C9980F788CFBFCC82A015E7330"],
+ },
+ {
+ info: "Multiple rules, should match email contact@domain1 rule",
+ filename: `${mailNewsDir}/alias-7.json`,
+ to: "contact@domain1.example.com",
+ expectedMissing: false,
+ expectedAliasKeys: [
+ "D1A66E1A23B182C9980F788CFBFCC82A015E7330",
+ "EB85BB5FA33A75E15E944E63F231550C4F47E38E",
+ ],
+ },
+ {
+ info: "Multiple rules, shouldn't match",
+ filename: `${mailNewsDir}/alias-7.json`,
+ to: "contact@domain2.example",
+ expectedMissing: true,
+ expectedAliasKeys: null,
+ },
+ {
+ info: "Mixed case test a",
+ filename: `${mailNewsDir}/alias-8.json`,
+ to: "a@UPPERDOM.EXAMPLE",
+ expectedMissing: false,
+ expectedAliasKeys: ["EB85BB5FA33A75E15E944E63F231550C4F47E38E"],
+ },
+ {
+ info: "Mixed case test b",
+ filename: `${mailNewsDir}/alias-8.json`,
+ to: "b@lowerdom.example",
+ expectedMissing: false,
+ expectedAliasKeys: ["D1A66E1A23B182C9980F788CFBFCC82A015E7330"],
+ },
+ {
+ info: "Mixed case test c",
+ filename: `${mailNewsDir}/alias-8.json`,
+ to: "C@MIXed.EXample",
+ expectedMissing: false,
+ expectedAliasKeys: ["B8F2F6F4BD3AD3F82DC446833099FF1238852B9F"],
+ },
+ {
+ info: "Mixed case test d",
+ filename: `${mailNewsDir}/alias-13.json`,
+ to: "NAME@DOMAIN.NET",
+ expectedMissing: false,
+ expectedAliasKeys: ["D1A66E1A23B182C9980F788CFBFCC82A015E7330"],
+ },
+ {
+ info: "Mixed case test e",
+ filename: `${mailNewsDir}/alias-14.json`,
+ to: "name@domain.net",
+ expectedMissing: false,
+ expectedAliasKeys: ["D1A66E1A23B182C9980F788CFBFCC82A015E7330"],
+ },
+ {
+ info: "Mixed case test f",
+ filename: `${mailNewsDir}/alias-15.json`,
+ to: "name@domain.net",
+ expectedMissing: false,
+ expectedAliasKeys: ["D1A66E1A23B182C9980F788CFBFCC82A015E7330"],
+ },
+ {
+ info: "JSON with bad syntax, should find Alice's key directly",
+ filename: `${mailNewsDir}/alias-9.json`,
+ to: "alice@openpgp.example",
+ expectedMissing: false,
+ expectedAliasKeys: null,
+ expectException: true,
+ },
+ {
+ info: "JSON with missing keys entry, should find Alice's key directly",
+ filename: `${mailNewsDir}/alias-10.json`,
+ to: "alice@openpgp.example",
+ expectedMissing: false,
+ expectedAliasKeys: null,
+ },
+ {
+ info: "JSON with empty keys entry, should find Alice's key directly",
+ filename: `${mailNewsDir}/alias-11.json`,
+ to: "alice@openpgp.example",
+ expectedMissing: false,
+ expectedAliasKeys: null,
+ },
+ {
+ info: "JSON with bad type keys entry, should find Alice's key directly",
+ filename: `${mailNewsDir}/alias-12.json`,
+ to: "alice@openpgp.example",
+ expectedMissing: false,
+ expectedAliasKeys: null,
+ },
+];
+
+/**
+ * Initialize OpenPGP add testing keys.
+ */
+add_setup(async function () {
+ do_get_profile();
+
+ await OpenPGPTestUtils.initOpenPGP();
+
+ await OpenPGPTestUtils.importPublicKey(
+ null,
+ do_get_file(`${keyDir}/alice@openpgp.example-0xf231550c4f47e38e-pub.asc`)
+ );
+
+ await OpenPGPTestUtils.importPublicKey(
+ null,
+ do_get_file(`${keyDir}/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc`)
+ );
+
+ await OpenPGPTestUtils.importPublicKey(
+ null,
+ do_get_file(`${keyDir}/carol@example.com-0x3099ff1238852b9f-pub.asc`)
+ );
+});
+
+add_task(async function testAlias() {
+ let aliasFilename = "openpgp-alias-rules.json";
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ for (let test of tests) {
+ if (test.filename) {
+ info(`Running alias test with rules from: ${test.filename}`);
+
+ // Copy test file to profile directory (which is a relative path),
+ // because load function only works with simple filenames
+ // or absolute file URLs.
+
+ let inFile = do_get_file(test.filename);
+ inFile.copyTo(profileDir, aliasFilename);
+
+ try {
+ await OpenPGPAlias._loadFromFile(aliasFilename);
+ Assert.ok(
+ !("expectException" in test) || !test.expectException,
+ "expected no load exception"
+ );
+ } catch (ex) {
+ console.log(
+ "exception when loading alias file " + aliasFilename + " : " + ex
+ );
+ Assert.ok(
+ "expectException" in test && test.expectException,
+ "expected load exception"
+ );
+ }
+ } else {
+ info(`Running alias test without rules`);
+ OpenPGPAlias._clear();
+ }
+ info(test.info);
+
+ let addresses = [test.to];
+ let resultDetails = {};
+
+ let isMissing = await EnigmailKeyRing.getValidKeysForAllRecipients(
+ addresses,
+ resultDetails
+ );
+
+ Assert.ok(
+ (isMissing && test.expectedMissing) ||
+ (!isMissing && !test.expectedMissing),
+ "Should have the expected result from getValidKeysForAllRecipients"
+ );
+
+ if (isMissing || test.expectedMissing) {
+ continue;
+ }
+
+ let errorMsgObj = { value: "" };
+ let logFileObj = {};
+ let encryptArgs = EnigmailEncryption.getCryptParams(
+ "",
+ test.to,
+ "",
+ "SHA256",
+ EnigmailConstants.SEND_ENCRYPTED,
+ 0,
+ errorMsgObj,
+ logFileObj
+ );
+
+ let foundAliasKeys = encryptArgs.aliasKeys.get(test.to.toLowerCase());
+
+ if (!test.expectedAliasKeys) {
+ Assert.ok(!foundAliasKeys, "foundAliasKeys should be empty");
+ } else {
+ Assert.equal(foundAliasKeys.length, test.expectedAliasKeys.length);
+
+ test.expectedAliasKeys.forEach((val, i) => {
+ Assert.ok(foundAliasKeys.includes(val));
+ });
+
+ let encryptResult = {};
+ let encrypted = await RNP.encryptAndOrSign(
+ "plaintext",
+ encryptArgs,
+ encryptResult
+ );
+
+ Assert.ok(
+ !encryptResult.exitCode,
+ "RNP.encryptAndOrSign() should exit ok"
+ );
+
+ Assert.ok(encrypted.includes("END PGP MESSAGE"));
+ }
+ }
+});
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/test_badKeys.js b/comm/mail/extensions/openpgp/test/unit/rnp/test_badKeys.js
new file mode 100644
index 0000000000..3ca7709dc6
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/test_badKeys.js
@@ -0,0 +1,69 @@
+/* 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/. */
+
+/**
+ * Tests for bad OpenPGP keys.
+ */
+
+"use strict";
+
+const { RNP } = ChromeUtils.import("chrome://openpgp/content/modules/RNP.jsm");
+const { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+const { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+const { EnigmailEncryption } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/encryption.jsm"
+);
+const { OpenPGPAlias } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/OpenPGPAlias.jsm"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const KEY_DIR = "../../../../../test/browser/openpgp/data/keys";
+
+add_setup(async function () {
+ do_get_profile();
+
+ await OpenPGPTestUtils.initOpenPGP();
+});
+
+// Attempt to import a key with a single user ID, which is invalid,
+// because it doesn't have a valid signature.
+// Our code should reject the attempt to import the key.
+add_task(async function testFailToImport() {
+ let ids = await OpenPGPTestUtils.importKey(
+ null,
+ do_get_file(`${KEY_DIR}/invalid-pubkey-nosigs.pgp`),
+ true
+ );
+ Assert.ok(!ids.length, "importKey should return empty list of imported keys");
+});
+
+// Import a key with two encryption subkeys. One is good, the other one
+// has an invalid signature. When attempting to encrypt, our code should
+// skip the bad subkey, and should use the expected good subkey.
+add_task(async function testAvoidBadSubkey() {
+ let ids = await OpenPGPTestUtils.importKey(
+ null,
+ do_get_file(`${KEY_DIR}/encryption-subkey-bad.pgp`),
+ true
+ );
+ await OpenPGPTestUtils.updateKeyIdAcceptance(
+ ids,
+ OpenPGPTestUtils.ACCEPTANCE_VERIFIED
+ );
+
+ let primaryKey = await RNP.findKeyByEmail(
+ "<encryption-subkey@example.org>",
+ true
+ );
+ let encSubKey = RNP.getSuitableSubkey(primaryKey, "encrypt");
+ let keyId = RNP.getKeyIDFromHandle(encSubKey);
+ Assert.ok(keyId == "BC63472A109D5859", "should obtain key ID of good subkey");
+});
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/test_encryptAndOrSign.js b/comm/mail/extensions/openpgp/test/unit/rnp/test_encryptAndOrSign.js
new file mode 100644
index 0000000000..a52911d288
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/test_encryptAndOrSign.js
@@ -0,0 +1,278 @@
+/* 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/. */
+
+/**
+ * Tests for RNP.encryptAndOrSign().
+ */
+
+"use strict";
+
+const { RNP } = ChromeUtils.import("chrome://openpgp/content/modules/RNP.jsm");
+const { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const keyDir = "../../../../../test/browser/openpgp/data/keys";
+const mailNewsDir = "../../../../../../mailnews/test/data";
+
+const tests = [
+ // Base64 encoded bodies.
+ {
+ filename: `${mailNewsDir}/01-plaintext.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/02-plaintext+attachment.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/03-HTML.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/04-HTML+attachment.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/05-HTML+embedded-image.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/06-plaintext+HMTL.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/07-plaintext+(HTML+embedded-image).eml`,
+ },
+ {
+ filename: `${mailNewsDir}/08-plaintext+HTML+attachment.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/09-(HTML+embedded-image)+attachment.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/10-plaintext+(HTML+embedded-image)+attachment.eml`,
+ },
+
+ // Bodies with non-ASCII characters in UTF-8 and other charsets.
+ {
+ filename: `${mailNewsDir}/11-plaintext.eml`,
+ skip: true,
+ },
+ // using ISO-8859-7 (Greek)
+ {
+ filename: `${mailNewsDir}/12-plaintext+attachment.eml`,
+ encoding: "iso-8859-7",
+ skip: true,
+ },
+ {
+ filename: `${mailNewsDir}/13-HTML.eml`,
+ skip: true,
+ },
+ {
+ filename: `${mailNewsDir}/14-HTML+attachment.eml`,
+ skip: true,
+ },
+ {
+ filename: `${mailNewsDir}/15-HTML+embedded-image.eml`,
+ skip: true,
+ },
+ // text part is base64 encoded
+ {
+ filename: `${mailNewsDir}/16-plaintext+HMTL.eml`,
+ skip: true,
+ },
+ // HTML part is base64 encoded
+ {
+ filename: `${mailNewsDir}/17-plaintext+(HTML+embedded-image).eml`,
+ skip: true,
+ },
+ {
+ filename: `${mailNewsDir}/18-plaintext+HTML+attachment.eml`,
+ skip: true,
+ },
+ {
+ filename: `${mailNewsDir}/19-(HTML+embedded-image)+attachment.eml`,
+ skip: true,
+ },
+ // using windows-1252
+ {
+ filename: `${mailNewsDir}/20-plaintext+(HTML+embedded-image)+attachment.eml`,
+ encoding: "windows-1252",
+ skip: true,
+ },
+
+ // Bodies with non-ASCII characters in UTF-8 and other charsets, all encoded
+ // with quoted printable.
+ {
+ filename: `${mailNewsDir}/21-plaintext.eml`,
+ },
+ // using ISO-8859-7 (Greek)
+ {
+ filename: `${mailNewsDir}/22-plaintext+attachment.eml`,
+ encoding: "iso-8859-7",
+ },
+ {
+ filename: `${mailNewsDir}/23-HTML.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/24-HTML+attachment.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/25-HTML+embedded-image.eml`,
+ },
+ // text part is base64 encoded
+ {
+ filename: `${mailNewsDir}/26-plaintext+HMTL.eml`,
+ },
+ // HTML part is base64 encoded
+ {
+ filename: `${mailNewsDir}/27-plaintext+(HTML+embedded-image).eml`,
+ },
+ {
+ filename: `${mailNewsDir}/28-plaintext+HTML+attachment.eml`,
+ },
+ {
+ filename: `${mailNewsDir}/29-(HTML+embedded-image)+attachment.eml`,
+ },
+ // using windows-1252
+ {
+ filename: `${mailNewsDir}/30-plaintext+(HTML+embedded-image)+attachment.eml`,
+ encoding: "windows-1252",
+ },
+
+ // Bug 1669107
+ {
+ filename:
+ "data/plaintext-with-key-and-windows-1252-encoded-eml-attachment.eml",
+ encoding: "windows-1252",
+ skip: true,
+ },
+ {
+ filename: "data/plaintext-with-windows-1252-encoded-eml-attachment.eml",
+ encoding: "windows-1252",
+ skip: true,
+ },
+];
+
+/**
+ * Initialize OpenPGP add testing keys.
+ */
+add_setup(async function () {
+ do_get_profile();
+
+ await OpenPGPTestUtils.initOpenPGP();
+
+ await OpenPGPTestUtils.importPrivateKey(
+ null,
+ do_get_file(`${keyDir}/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc`)
+ );
+
+ await OpenPGPTestUtils.importPublicKey(
+ null,
+ do_get_file(`${keyDir}/alice@openpgp.example-0xf231550c4f47e38e-pub.asc`)
+ );
+});
+
+/**
+ * Test the decrypted output of RNP.encryptOrSign() against its source text
+ * with various inputs.
+ */
+add_task(async function testEncryptAndOrSignResults() {
+ for (let test of tests) {
+ let chunks = test.filename.split("/");
+ let filename = chunks[chunks.length - 1];
+ if (test.skip) {
+ info(`Skipped input from: ${filename}`);
+ continue;
+ }
+
+ info(`Running test with input from: ${filename}`);
+
+ let buffer = await IOUtils.read(do_get_file(test.filename).path);
+ const textDecoder = new TextDecoder(test.encoding || "utf-8");
+
+ let sourceText = textDecoder.decode(buffer);
+ let encryptResult = {};
+
+ let encryptArgs = {
+ aliasKeys: new Map(),
+ armor: true,
+ bcc: [],
+ encrypt: true,
+ encryptToSender: true,
+ sender: "0xFBFCC82A015E7330",
+ senderKeyIsExternal: false,
+ sigTypeClear: false,
+ sigTypeDetached: false,
+ sign: false,
+ signatureHash: "SHA256",
+ to: ["<alice@openpgp.example>"],
+ };
+
+ let encrypted = await RNP.encryptAndOrSign(
+ sourceText,
+ encryptArgs,
+ encryptResult
+ );
+
+ Assert.ok(
+ !encryptResult.exitCode,
+ `${filename}: RNP.encryptAndOrSign() exited ok`
+ );
+
+ let decryptOptions = {
+ fromAddr: "bob@openpgp.example",
+ maxOutputLength: encrypted.length * 100,
+ noOutput: false,
+ uiFlags: EnigmailConstants.UI_PGP_MIME,
+ verifyOnly: false,
+ msgDate: null,
+ };
+
+ let { exitCode, decryptedData } = await RNP.decrypt(
+ encrypted,
+ decryptOptions
+ );
+
+ Assert.ok(!exitCode, `${filename}: RNP.decrypt() exited ok`);
+
+ Assert.equal(
+ sourceText,
+ decryptedData,
+ `${filename}: source text and decrypted text should be the same`
+ );
+ }
+});
+
+/**
+ * Test that we correctly produce binary files when decrypting,
+ * for both binary OpenPGP input and ASCII armored OpenPGP input.
+ *
+ * Image source: openclipart.org (public domain)
+ * https://openclipart.org/detail/191741/blue-bird
+ */
+add_task(async function testDecryptAttachment() {
+ let expected = String.fromCharCode(
+ ...(await IOUtils.read(do_get_file("data/bluebird50.jpg").path))
+ );
+
+ for (let filename of ["data/bluebird50.jpg.asc", "data/bluebird50.jpg.gpg"]) {
+ let encrypted = String.fromCharCode(
+ ...(await IOUtils.read(do_get_file(filename).path))
+ );
+ let options = {};
+ options.fromAddr = "";
+ options.msgDate = null;
+ let result = await RNP.decrypt(encrypted, options);
+
+ Assert.ok(!result.exitCode, `${filename}: RNP.decrypt() exited ok`);
+
+ // Don't use Assert.equal to avoid logging the raw binary data
+ let isEqual = expected === result.decryptedData;
+
+ Assert.ok(
+ isEqual,
+ `${filename}: decrypted data should match the expected binary file`
+ );
+ }
+});
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/test_secretKeys.js b/comm/mail/extensions/openpgp/test/unit/rnp/test_secretKeys.js
new file mode 100644
index 0000000000..772c5caae4
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/test_secretKeys.js
@@ -0,0 +1,384 @@
+/* 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/. */
+
+/**
+ * Tests for secret keys.
+ */
+
+"use strict";
+
+const { RNP, RnpPrivateKeyUnlockTracker } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/RNP.jsm"
+);
+const { OpenPGPMasterpass } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/masterpass.jsm"
+);
+const { EnigmailConstants } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/constants.jsm"
+);
+const { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const keyDir = "../../../../../test/browser/openpgp/data/keys";
+
+/**
+ * Initialize OpenPGP add testing keys.
+ */
+add_setup(async function () {
+ do_get_profile();
+
+ await OpenPGPTestUtils.initOpenPGP();
+});
+
+add_task(async function testSecretKeys() {
+ let pass = await OpenPGPMasterpass.retrieveOpenPGPPassword();
+ let newKeyId = await RNP.genKey(
+ "Erin <erin@example.com>",
+ "ECC",
+ 0,
+ 30,
+ pass
+ );
+
+ Assert.ok(
+ newKeyId != null && typeof newKeyId == "string",
+ "RNP.genKey() should return a non null string with a key ID"
+ );
+
+ let keyObj = EnigmailKeyRing.getKeyById(newKeyId);
+ Assert.ok(
+ keyObj && keyObj.secretAvailable,
+ "EnigmailKeyRing.getKeyById should return an object with a secret key"
+ );
+
+ let fpr = keyObj.fpr;
+
+ Assert.ok(
+ keyObj.iSimpleOneSubkeySameExpiry(),
+ "check iSimpleOneSubkeySameExpiry should succeed"
+ );
+
+ let allFingerprints = [fpr, keyObj.subKeys[0].fpr];
+
+ let keyTrackers = [];
+ for (let fp of allFingerprints) {
+ let tracker = RnpPrivateKeyUnlockTracker.constructFromFingerprint(fp);
+ await tracker.unlock();
+ keyTrackers.push(tracker);
+ }
+
+ let expiryChanged = await RNP.changeExpirationDate(
+ allFingerprints,
+ 100 * 24 * 60 * 60
+ );
+ Assert.ok(expiryChanged, "changeExpirationDate should return success");
+
+ for (let t of keyTrackers) {
+ t.release();
+ }
+
+ let backupPassword = "new-password-1234";
+
+ let backupKeyBlock = await RNP.backupSecretKeys([fpr], backupPassword);
+
+ let expectedString = "END PGP PRIVATE KEY BLOCK";
+
+ Assert.ok(
+ backupKeyBlock.includes(expectedString),
+ "backup of secret key should contain the string: " + expectedString
+ );
+
+ await RNP.deleteKey(fpr, true);
+
+ EnigmailKeyRing.clearCache();
+
+ keyObj = EnigmailKeyRing.getKeyById(newKeyId);
+ Assert.ok(
+ !keyObj,
+ "after deleting the key we should be unable to find it in the keyring"
+ );
+
+ let alreadyProvidedWrongPassword = false;
+
+ let getWrongPassword = function (win, keyId, resultFlags) {
+ if (alreadyProvidedWrongPassword) {
+ resultFlags.canceled = true;
+ return "";
+ }
+
+ alreadyProvidedWrongPassword = true;
+ return "wrong-password";
+ };
+
+ let importResult = await RNP.importSecKeyBlockImpl(
+ null,
+ getWrongPassword,
+ false,
+ backupKeyBlock
+ );
+
+ Assert.ok(importResult.exitCode != 0, "import should have failed");
+
+ let getGoodPassword = function (win, keyId, resultFlags) {
+ return backupPassword;
+ };
+
+ importResult = await RNP.importSecKeyBlockImpl(
+ null,
+ getGoodPassword,
+ false,
+ backupKeyBlock
+ );
+
+ Assert.ok(importResult.exitCode == 0, "import result code should be 0");
+
+ keyObj = EnigmailKeyRing.getKeyById(newKeyId);
+
+ Assert.ok(
+ keyObj && keyObj.secretAvailable,
+ "after import, EnigmailKeyRing.getKeyById should return an object with a secret key"
+ );
+});
+
+add_task(async function testImportSecretKeyIsProtected() {
+ let carolFile = do_get_file(
+ `${keyDir}/carol@example.com-0x3099ff1238852b9f-secret.asc`
+ );
+ let carolSec = await IOUtils.readUTF8(carolFile.path);
+
+ // Carol's secret key is protected with password "x".
+ let getCarolPassword = function (win, keyId, resultFlags) {
+ return "x";
+ };
+
+ let importResult = await RNP.importSecKeyBlockImpl(
+ null,
+ getCarolPassword,
+ false,
+ carolSec
+ );
+
+ Assert.equal(
+ importResult.exitCode,
+ 0,
+ "Should be able to import Carol's secret key"
+ );
+
+ let aliceFile = do_get_file(
+ `${keyDir}/alice@openpgp.example-0xf231550c4f47e38e-secret.asc`
+ );
+ let aliceSec = await IOUtils.readUTF8(aliceFile.path);
+
+ // Alice's secret key is unprotected.
+ importResult = await RNP.importSecKeyBlockImpl(null, null, false, aliceSec);
+
+ Assert.equal(
+ importResult.exitCode,
+ 0,
+ "Should be able to import Alice's secret key"
+ );
+
+ let [prot, unprot] = OpenPGPTestUtils.getProtectedKeysCount();
+ Assert.notEqual(prot, 0, "Should have protected secret keys");
+ Assert.equal(unprot, 0, "Should not have any unprotected secret keys");
+});
+
+add_task(async function testImportOfflinePrimaryKey() {
+ let importResult = await OpenPGPTestUtils.importPrivateKey(
+ null,
+ do_get_file(`${keyDir}/ofelia-secret-subkeys.asc`)
+ );
+
+ Assert.equal(
+ importResult[0],
+ "0x97DCDA5E56EBB822",
+ "expected key id should have been reported"
+ );
+
+ let primaryKey = await RNP.findKeyByEmail("<ofelia@openpgp.example>", false);
+
+ let encSubKey = RNP.getSuitableSubkey(primaryKey, "encrypt");
+ let keyId = RNP.getKeyIDFromHandle(encSubKey);
+ Assert.equal(
+ keyId,
+ "31C31DF1DFB67601",
+ "should obtain key ID of encryption subkey"
+ );
+
+ let sigSubKey = RNP.getSuitableSubkey(primaryKey, "sign");
+ let keyIdSig = RNP.getKeyIDFromHandle(sigSubKey);
+ Assert.equal(
+ keyIdSig,
+ "1BC8F5764D348FE1",
+ "should obtain key ID of signing subkey"
+ );
+
+ // Test that we can sign with a signing subkey
+ // (this ensures that our code can unlock the secret subkey).
+ // Ofelia's key has no secret key for the primary key available,
+ // which further ensures that signing used the subkey.
+
+ let sourceText = "we-sign-this-text";
+ let signResult = {};
+
+ let signArgs = {
+ aliasKeys: new Map(),
+ armor: true,
+ bcc: [],
+ encrypt: false,
+ encryptToSender: false,
+ sender: "0x97DCDA5E56EBB822",
+ senderKeyIsExternal: false,
+ sigTypeClear: true,
+ sigTypeDetached: false,
+ sign: true,
+ signatureHash: "SHA256",
+ to: ["<alice@openpgp.example>"],
+ };
+
+ await RNP.encryptAndOrSign(sourceText, signArgs, signResult);
+
+ Assert.ok(!signResult.exitCode, "signing with subkey should work");
+});
+
+add_task(async function testSecretForPreferredSignSubkeyIsMissing() {
+ let secBlock = await IOUtils.readUTF8(
+ do_get_file(
+ `${keyDir}/secret-for-preferred-sign-subkey-is-missing--a-without-second-sub--sec.asc`
+ ).path
+ );
+
+ let cancelPassword = function (win, keyId, resultFlags) {
+ resultFlags.canceled = true;
+ return "";
+ };
+
+ let importResult = await RNP.importSecKeyBlockImpl(
+ null,
+ cancelPassword,
+ false,
+ secBlock
+ );
+
+ Assert.ok(importResult.exitCode == 0);
+
+ let pubBlock = await IOUtils.readUTF8(
+ do_get_file(
+ `${keyDir}/secret-for-preferred-sign-subkey-is-missing--b-with-second-sub--pub.asc`
+ ).path
+ );
+
+ importResult = await RNP.importPubkeyBlockAutoAcceptImpl(
+ null,
+ pubBlock,
+ null // acceptance
+ );
+
+ Assert.ok(importResult.exitCode == 0);
+
+ let primaryKey = await RNP.findKeyByEmail(
+ "<secret-for-preferred-sign-subkey-is-missing@example.com>",
+ false
+ );
+
+ let signSubKey = RNP.getSuitableSubkey(primaryKey, "sign");
+ let keyId = RNP.getKeyIDFromHandle(signSubKey);
+ Assert.equal(
+ keyId,
+ "625D4819F02EE727",
+ "should obtain key ID of older, non-preferred subkey that has the secret key available"
+ );
+});
+
+// If we an existing public key, with multiple subkeys, and then we
+// import the secret key, but one of the existing public subkeys is
+// missing, test that we don't fail to import (bug 1795698).
+add_task(async function testNoSecretForExistingPublicSubkey() {
+ let pubBlock = await IOUtils.readUTF8(
+ do_get_file(`${keyDir}/two-enc-subkeys-still-both.pub.asc`).path
+ );
+
+ let importResult = await RNP.importPubkeyBlockAutoAcceptImpl(
+ null,
+ pubBlock,
+ null // acceptance
+ );
+
+ Assert.ok(importResult.exitCode == 0);
+
+ let secBlock = await IOUtils.readUTF8(
+ do_get_file(`${keyDir}/two-enc-subkeys-one-deleted.sec.asc`).path
+ );
+
+ let cancelPassword = function (win, keyId, resultFlags) {
+ resultFlags.canceled = true;
+ return "";
+ };
+
+ importResult = await RNP.importSecKeyBlockImpl(
+ null,
+ cancelPassword,
+ false,
+ secBlock
+ );
+
+ Assert.ok(importResult.exitCode == 0);
+});
+
+add_task(async function testImportAndBackupUntweakedECCKey() {
+ const untweakedFile = do_get_file(`${keyDir}/untweaked-secret.asc`);
+ const untweakedSecKey = await IOUtils.readUTF8(untweakedFile.path);
+
+ const getGoodPasswordForTweaked = function (win, keyId, resultFlags) {
+ return "pass112233";
+ };
+
+ const importResult = await RNP.importSecKeyBlockImpl(
+ null,
+ getGoodPasswordForTweaked,
+ false,
+ untweakedSecKey
+ );
+
+ Assert.ok(importResult.exitCode == 0);
+ const fpr = "492965A6F56DAD2423B3506E849F29B0020707F7";
+
+ const backupPassword = "new-password-1234";
+ const backupKeyBlock = await RNP.backupSecretKeys([fpr], backupPassword);
+ const expectedString = "END PGP PRIVATE KEY BLOCK";
+
+ Assert.ok(
+ backupKeyBlock.includes(expectedString),
+ "backup of secret key should contain the string: " + expectedString
+ );
+
+ await RNP.deleteKey(fpr, true);
+
+ EnigmailKeyRing.clearCache();
+});
+
+// Sanity check for bug 1790610 and bug 1792450, test that our passphrase
+// reading code, which can run through repair code for corrupted profiles,
+// will not replace our existing and good data.
+// Ideally this test should restart the application, but is is difficult.
+// We simulate a restart by erasing the cache and forcing it to read
+// data again from disk (which will run the consistency checks and
+// could potentially execute the repair code).
+add_task(async function testRereadingPassphrase() {
+ let pass1 = await OpenPGPMasterpass.retrieveOpenPGPPassword();
+ OpenPGPMasterpass.cachedPassword = null;
+ let pass2 = await OpenPGPMasterpass.retrieveOpenPGPPassword();
+ Assert.equal(
+ pass1,
+ pass2,
+ "openpgp passphrase should remain the same after cache invalidation"
+ );
+});
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/test_strip.js b/comm/mail/extensions/openpgp/test/unit/rnp/test_strip.js
new file mode 100644
index 0000000000..4260921ef2
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/test_strip.js
@@ -0,0 +1,137 @@
+/* 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/. */
+
+/**
+ * Tests stripping keys.
+ */
+
+"use strict";
+
+const { RNP } = ChromeUtils.import("chrome://openpgp/content/modules/RNP.jsm");
+const { EnigmailKeyRing } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/keyRing.jsm"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+const { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+const keyDir = "../../../../../test/browser/openpgp/data/keys";
+
+/**
+ * Initialize OpenPGP add testing keys.
+ */
+add_setup(async function () {
+ do_get_profile();
+
+ await OpenPGPTestUtils.initOpenPGP();
+});
+
+add_task(async function testStripSignatures() {
+ await OpenPGPTestUtils.importPublicKey(
+ null,
+ do_get_file(`${keyDir}/heisenberg-signed-by-pinkman.asc`)
+ );
+
+ let heisenbergFpr = "8E3D32E652A254F05BEA9F66CF3EB4AFCAC29340";
+ let foundKeys = await RNP.getKeys(["0x" + heisenbergFpr]);
+
+ Assert.equal(foundKeys.length, 1);
+
+ let sigs = RNP.getKeyObjSignatures(foundKeys[0]);
+
+ // Signatures for one user ID
+ Assert.equal(sigs.length, 1);
+
+ // The key in the file has two signatures: one self signature,
+ // plus one foreign certification signature.
+ Assert.equal(sigs[0].sigList.length, 2);
+
+ let reducedKey = RNP.getMultiplePublicKeys([], ["0x" + heisenbergFpr], []);
+
+ // Delete the key we have previously imported
+ await RNP.deleteKey(heisenbergFpr);
+ foundKeys = await RNP.getKeys(["0x" + heisenbergFpr]);
+ Assert.equal(foundKeys.length, 0);
+
+ // Import the reduced key
+ let errorObj = {};
+ let fingerPrintObj = {};
+ let result = await EnigmailKeyRing.importKeyAsync(
+ null,
+ false,
+ reducedKey,
+ false,
+ null,
+ errorObj,
+ fingerPrintObj,
+ false,
+ [],
+ false
+ );
+ Assert.equal(result, 0);
+
+ foundKeys = await RNP.getKeys(["0x" + heisenbergFpr]);
+ Assert.equal(foundKeys.length, 1);
+
+ sigs = RNP.getKeyObjSignatures(foundKeys[0]);
+
+ // The imported stripped key should have only the self signature.
+ Assert.equal(sigs[0].sigList.length, 1);
+});
+
+add_task(async function testKeyWithUnicodeComment() {
+ let keyFile = do_get_file(`${keyDir}/key-with-utf8-comment.asc`);
+ let keyBlock = await IOUtils.readUTF8(keyFile.path);
+
+ let errorObj = {};
+ let fingerPrintObj = {};
+ let result = await EnigmailKeyRing.importKeyAsync(
+ null,
+ false,
+ keyBlock,
+ false,
+ null,
+ errorObj,
+ fingerPrintObj,
+ false,
+ [],
+ false
+ );
+ Assert.equal(result, 0);
+
+ let fpr = "72514F43D0060FC588E80238852C55E6D2AFD7EF";
+ let foundKeys = await RNP.getKeys(["0x" + fpr]);
+
+ Assert.equal(foundKeys.length, 1);
+});
+
+add_task(async function testBinaryKey() {
+ let keyFile = do_get_file(`${keyDir}/key-binary.gpg`);
+ let keyData = await IOUtils.read(keyFile.path);
+ let keyBlock = MailStringUtils.uint8ArrayToByteString(keyData);
+
+ let errorObj = {};
+ let fingerPrintObj = {};
+ let result = await EnigmailKeyRing.importKeyAsync(
+ null,
+ false,
+ keyBlock,
+ true,
+ null,
+ errorObj,
+ fingerPrintObj,
+ false,
+ [],
+ false
+ );
+ Assert.equal(result, 0);
+
+ let fpr = "683F775BA2E5F0ADEBB29697A2D1B914F722004E";
+ let foundKeys = await RNP.getKeys(["0x" + fpr]);
+
+ Assert.equal(foundKeys.length, 1);
+});
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/test_uid.js b/comm/mail/extensions/openpgp/test/unit/rnp/test_uid.js
new file mode 100644
index 0000000000..b87e068e0d
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/test_uid.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/. */
+
+/**
+ * Tests for OpenPGP encryption alias rules.
+ */
+
+"use strict";
+
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+const { EnigmailFuncs } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/funcs.jsm"
+);
+
+const tests = [
+ {
+ input: "Cherry Blossom (æ¡œã®èŠ±) (description) <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input:
+ "Cherry Blossom (æ¡œã®èŠ±) (description) (more information) <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "First Last <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "Last, First <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "email@example.com",
+ email: "email@example.com",
+ },
+ {
+ input: "<email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "First Last email@example.com>",
+ email: "",
+ },
+ {
+ input: "First Last (comment) <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "First Last (a) (b) (c) (comment) <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "First Last (comment <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "First Last )comment) <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "",
+ email: "",
+ },
+ {
+ input: "First Last () <>",
+ email: "",
+ },
+ {
+ input: "First Last () <> <> <>",
+ email: "",
+ },
+ {
+ input: "First Last () <> <email1@example.com>",
+ email: "",
+ },
+ {
+ input: "First <Last> (comment) <email1@example.com>",
+ email: "",
+ },
+ {
+ input: "First Last <email@example.com> (bad comment)",
+ email: "email@example.com",
+ },
+ {
+ input: "First Last <email@example.com> extra text",
+ email: "email@example.com",
+ },
+ {
+ input: "First Last <not-an-email> extra text",
+ email: "",
+ },
+ {
+ input: "First Last (comment (nested)) <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input:
+ "First Last (comment (no second closing bracket) <email@example.com>",
+ email: "email@example.com",
+ },
+ {
+ input: "<a@example.org b@example.org>",
+ email: "",
+ },
+ {
+ input: "<a@@example.org>",
+ email: "",
+ },
+];
+
+/**
+ * Initialize OpenPGP add testing keys.
+ */
+add_setup(async function () {
+ do_get_profile();
+
+ await OpenPGPTestUtils.initOpenPGP();
+});
+
+add_task(async function testAlias() {
+ for (let test of tests) {
+ console.debug("testing input: " + test.input);
+
+ let email = EnigmailFuncs.getEmailFromUserID(test.input);
+
+ Assert.equal(test.email, email);
+ }
+});
diff --git a/comm/mail/extensions/openpgp/test/unit/rnp/xpcshell.ini b/comm/mail/extensions/openpgp/test/unit/rnp/xpcshell.ini
new file mode 100644
index 0000000000..f0601aefae
--- /dev/null
+++ b/comm/mail/extensions/openpgp/test/unit/rnp/xpcshell.ini
@@ -0,0 +1,14 @@
+[DEFAULT]
+head =
+tail =
+support-files =
+ ../../../../../test/browser/openpgp/data/keys/*
+ ../../../../../../mailnews/test/data/*
+ data/*
+
+[test_encryptAndOrSign.js]
+[test_secretKeys.js]
+[test_alias.js]
+[test_badKeys.js]
+[test_uid.js]
+[test_strip.js]
diff --git a/comm/mail/extensions/smime/content/msgHdrViewSMIMEOverlay.js b/comm/mail/extensions/smime/content/msgHdrViewSMIMEOverlay.js
new file mode 100644
index 0000000000..3c63cccbdd
--- /dev/null
+++ b/comm/mail/extensions/smime/content/msgHdrViewSMIMEOverlay.js
@@ -0,0 +1,483 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 ../../../../mailnews/extensions/smime/msgReadSMIMEOverlay.js */
+/* import-globals-from ../../../base/content/aboutMessage.js */
+/* import-globals-from ../../../base/content/msgHdrView.js */
+/* import-globals-from ../../../base/content/msgSecurityPane.js */
+
+// mailCommon.js
+/* globals gEncryptedURIService */
+
+var gMyLastEncryptedURI = null;
+
+var gSMIMEBundle = null;
+
+var gSignatureStatusForURI = null;
+var gEncryptionStatusForURI = null;
+
+// Get the necko URL for the message URI.
+function neckoURLForMessageURI(aMessageURI) {
+ let msgSvc = MailServices.messageServiceFromURI(aMessageURI);
+ let neckoURI = msgSvc.getUrlForUri(aMessageURI);
+ return neckoURI.spec;
+}
+
+var gIgnoreStatusFromMimePart = null;
+
+function setIgnoreStatusFromMimePart(mimePart) {
+ gIgnoreStatusFromMimePart = mimePart;
+}
+
+/**
+ * Set the cryptoBox content according to the given encryption states of the
+ * displayed message. null should be passed as a state if the message does not
+ * encrypted or is not signed.
+ *
+ * @param {string|null} tech - The name for the encryption technology in use
+ * for the message.
+ * @param {"ok"|"notok"|null} encryptedState - The encrypted state of the
+ * message.
+ * @param {"ok"|"notok"|"verified"|"unverified"|"unknown"|"mismatch"|null}
+ * signedState - The signed state of the message.
+ * @param {boolean} forceShow - Show the box if unsigned and unencrypted.
+ * @param {string} mimePartNumber - Should be set to the MIME part number
+ * that triggers this status update. If the value matches a currently
+ * ignored MIME part, then this function call will be ignored.
+ */
+function setMessageCryptoBox(
+ tech,
+ encryptedState,
+ signedState,
+ forceShow,
+ mimePartNumber
+) {
+ if (
+ !!gIgnoreStatusFromMimePart &&
+ mimePartNumber == gIgnoreStatusFromMimePart
+ ) {
+ return;
+ }
+
+ let container = document.getElementById("cryptoBox");
+ let encryptedIcon = document.getElementById("encryptedHdrIcon");
+ let signedIcon = document.getElementById("signedHdrIcon");
+ let button = document.getElementById("encryptionTechBtn");
+ let buttonText = button.querySelector(".crypto-label");
+
+ let hidden = !forceShow && (!tech || (!encryptedState && !signedState));
+ container.hidden = hidden;
+ button.hidden = hidden;
+ if (hidden) {
+ container.removeAttribute("tech");
+ buttonText.textContent = "";
+ } else {
+ container.setAttribute("tech", tech);
+ buttonText.textContent = tech;
+ }
+
+ if (encryptedState) {
+ encryptedIcon.hidden = false;
+ encryptedIcon.setAttribute(
+ "src",
+ `chrome://messenger/skin/icons/message-encrypted-${encryptedState}.svg`
+ );
+ // Set alt text.
+ document.l10n.setAttributes(
+ encryptedIcon,
+ `openpgp-message-header-encrypted-${encryptedState}-icon`
+ );
+ } else {
+ encryptedIcon.hidden = true;
+ encryptedIcon.removeAttribute("data-l10n-id");
+ encryptedIcon.removeAttribute("alt");
+ encryptedIcon.removeAttribute("src");
+ }
+
+ if (signedState) {
+ if (signedState === "notok") {
+ // Show the same as mismatch.
+ signedState = "mismatch";
+ }
+ signedIcon.hidden = false;
+ signedIcon.setAttribute(
+ "src",
+ `chrome://messenger/skin/icons/message-signed-${signedState}.svg`
+ );
+ // Set alt text.
+ document.l10n.setAttributes(
+ signedIcon,
+ `openpgp-message-header-signed-${signedState}-icon`
+ );
+ } else {
+ signedIcon.hidden = true;
+ signedIcon.removeAttribute("data-l10n-id");
+ signedIcon.removeAttribute("alt");
+ signedIcon.removeAttribute("src");
+ }
+}
+
+function smimeSignedStateToString(signedState) {
+ switch (signedState) {
+ case -1:
+ return null;
+ case Ci.nsICMSMessageErrors.SUCCESS:
+ return "ok";
+ case Ci.nsICMSMessageErrors.VERIFY_NOT_YET_ATTEMPTED:
+ return "unknown";
+ case Ci.nsICMSMessageErrors.VERIFY_CERT_WITHOUT_ADDRESS:
+ case Ci.nsICMSMessageErrors.VERIFY_HEADER_MISMATCH:
+ return "mismatch";
+ default:
+ return "notok";
+ }
+}
+
+function smimeEncryptedStateToString(encryptedState) {
+ switch (encryptedState) {
+ case -1:
+ return null;
+ case Ci.nsICMSMessageErrors.SUCCESS:
+ return "ok";
+ default:
+ return "notok";
+ }
+}
+
+/**
+ * Refresh the cryptoBox content using the global gEncryptionStatus and
+ * gSignatureStatus variables.
+ *
+ * @param {string} mimePartNumber - Should be set to the MIME part number
+ * that triggers this status update.
+ */
+function refreshSmimeMessageEncryptionStatus(mimePartNumber = undefined) {
+ let signed = smimeSignedStateToString(gSignatureStatus);
+ let encrypted = smimeEncryptedStateToString(gEncryptionStatus);
+ setMessageCryptoBox("S/MIME", encrypted, signed, false, mimePartNumber);
+}
+
+var smimeHeaderSink = {
+ /**
+ * @returns the URI of the selected message, or null if the current
+ * message displayed isn't in a folder, for example if the
+ * message is displayed in a separate window.
+ */
+ getSelectedMessageURI() {
+ if (!gMessage) {
+ return null;
+ }
+ if (!gFolder) {
+ // The folder should be absent only if the message gets opened
+ // from an external file (.eml), which is opened in its own window.
+ // That window won't get reused for other messages. We conclude
+ // the incoming status is for this window.
+ // This special handling is necessary, because the necko URL for
+ // separate windows that is seen by the MIME code differs from the
+ // one we see here in JS.
+ return null;
+ }
+
+ return neckoURLForMessageURI(gMessageURI);
+ },
+
+ signedStatus(
+ aNestingLevel,
+ aSignatureStatus,
+ aSignerCert,
+ aMsgNeckoURL,
+ aOriginMimePartNumber
+ ) {
+ if (
+ !!gIgnoreStatusFromMimePart &&
+ aOriginMimePartNumber == gIgnoreStatusFromMimePart
+ ) {
+ return;
+ }
+
+ if (aNestingLevel > 1) {
+ // we are not interested
+ return;
+ }
+
+ if (aMsgNeckoURL != this.getSelectedMessageURI()) {
+ // Status isn't for selected message.
+ return;
+ }
+
+ if (gSignatureStatusForURI == aMsgNeckoURL) {
+ // We already received a status previously for this URL.
+ // Don't allow overriding an existing bad status.
+ if (gSignatureStatus != Ci.nsICMSMessageErrors.SUCCESS) {
+ return;
+ }
+ }
+
+ gSignatureStatusForURI = aMsgNeckoURL;
+ // eslint-disable-next-line no-global-assign
+ gSignatureStatus = aSignatureStatus;
+ gSignerCert = aSignerCert;
+
+ refreshSmimeMessageEncryptionStatus(aOriginMimePartNumber);
+
+ let signed = smimeSignedStateToString(aSignatureStatus);
+ if (signed == "unknown" || signed == "mismatch") {
+ this.showSenderIfSigner();
+ }
+
+ // For telemetry purposes.
+ window.dispatchEvent(
+ new CustomEvent("secureMsgLoaded", {
+ detail: {
+ key: "signed-smime",
+ data: signed,
+ },
+ })
+ );
+ },
+
+ /**
+ * Force showing Sender if we have a Sender and it's not signed by From.
+ * For a valid cert that means the Sender signed it - and the signed mismatch
+ * mark is shown. To understand why it's not a confirmed signing it's useful
+ * to have the Sender header showing.
+ */
+ showSenderIfSigner() {
+ if (!("sender" in currentHeaderData)) {
+ // Sender not set, or same as From (so no longer present).
+ return;
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showSender")) {
+ // Sender header will be show due to pref - nothing more to do.
+ return;
+ }
+
+ let fromMailboxes = MailServices.headerParser
+ .extractHeaderAddressMailboxes(currentHeaderData.from.headerValue)
+ .split(",");
+ for (let i = 0; i < fromMailboxes.length; i++) {
+ if (gSignerCert.containsEmailAddress(fromMailboxes[i])) {
+ return; // It's signed by a From. Nothing more to do
+ }
+ }
+
+ let senderInfo = { name: "sender", outputFunction: outputEmailAddresses };
+ let senderEntry = new MsgHeaderEntry("expanded", senderInfo);
+
+ gExpandedHeaderView[senderInfo.name] = senderEntry;
+ UpdateExpandedMessageHeaders();
+ },
+
+ encryptionStatus(
+ aNestingLevel,
+ aEncryptionStatus,
+ aRecipientCert,
+ aMsgNeckoURL,
+ aOriginMimePartNumber
+ ) {
+ if (
+ !!gIgnoreStatusFromMimePart &&
+ aOriginMimePartNumber == gIgnoreStatusFromMimePart
+ ) {
+ return;
+ }
+
+ if (aNestingLevel > 1) {
+ // we are not interested
+ return;
+ }
+
+ if (aMsgNeckoURL != this.getSelectedMessageURI()) {
+ // Status isn't for selected message.
+ return;
+ }
+
+ if (gEncryptionStatusForURI == aMsgNeckoURL) {
+ // We already received a status previously for this URL.
+ // Don't allow overriding an existing bad status.
+ if (gEncryptionStatus != Ci.nsICMSMessageErrors.SUCCESS) {
+ return;
+ }
+ }
+
+ gEncryptionStatusForURI = aMsgNeckoURL;
+ // eslint-disable-next-line no-global-assign
+ gEncryptionStatus = aEncryptionStatus;
+ gEncryptionCert = aRecipientCert;
+
+ refreshSmimeMessageEncryptionStatus(aOriginMimePartNumber);
+
+ if (gEncryptedURIService) {
+ // Remember the message URI and the corresponding necko URI.
+ gMyLastEncryptedURI = gMessageURI;
+ gEncryptedURIService.rememberEncrypted(gMyLastEncryptedURI);
+ gEncryptedURIService.rememberEncrypted(
+ neckoURLForMessageURI(gMyLastEncryptedURI)
+ );
+ }
+
+ switch (aEncryptionStatus) {
+ case Ci.nsICMSMessageErrors.SUCCESS:
+ case Ci.nsICMSMessageErrors.ENCRYPT_INCOMPLETE:
+ break;
+ default:
+ var brand = document
+ .getElementById("bundle_brand")
+ .getString("brandShortName");
+ var title = gSMIMEBundle
+ .GetStringFromName("CantDecryptTitle")
+ .replace(/%brand%/g, brand);
+ var body = gSMIMEBundle
+ .GetStringFromName("CantDecryptBody")
+ .replace(/%brand%/g, brand);
+
+ // TODO: This should be replaced with a real page, and made not ugly.
+ HideMessageHeaderPane();
+ MailE10SUtils.loadURI(
+ getMessagePaneBrowser(),
+ "data:text/html;base64," +
+ btoa(
+ `<html>
+ <head>
+ <title>${title}</title>
+ </head>
+ <body>
+ <h1>${title}</h1>
+ ${body}
+ </body>
+ </html>`
+ )
+ );
+ break;
+ }
+
+ // For telemetry purposes.
+ window.dispatchEvent(
+ new CustomEvent("secureMsgLoaded", {
+ detail: {
+ key: "encrypted-smime",
+ data: smimeEncryptedStateToString(aEncryptionStatus),
+ },
+ })
+ );
+ },
+
+ ignoreStatusFrom(aOriginMimePartNumber) {
+ setIgnoreStatusFromMimePart(aOriginMimePartNumber);
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgSMIMEHeaderSink"]),
+};
+
+function forgetEncryptedURI() {
+ if (gMyLastEncryptedURI && gEncryptedURIService) {
+ gEncryptedURIService.forgetEncrypted(gMyLastEncryptedURI);
+ gEncryptedURIService.forgetEncrypted(
+ neckoURLForMessageURI(gMyLastEncryptedURI)
+ );
+ gMyLastEncryptedURI = null;
+ }
+}
+
+function onSMIMEStartHeaders() {
+ // eslint-disable-next-line no-global-assign
+ gEncryptionStatus = -1;
+ // eslint-disable-next-line no-global-assign
+ gSignatureStatus = -1;
+
+ gSignatureStatusForURI = null;
+ gEncryptionStatusForURI = null;
+
+ gSignerCert = null;
+ gEncryptionCert = null;
+
+ setMessageCryptoBox(null, null, null, false);
+
+ forgetEncryptedURI();
+ onMessageSecurityPopupHidden();
+}
+
+function onSMIMEEndHeaders() {}
+
+function onSmartCardChange() {
+ // only reload encrypted windows
+ if (gMyLastEncryptedURI && gEncryptionStatus != -1) {
+ ReloadMessage();
+ }
+}
+
+function onSMIMEBeforeShowHeaderPane() {
+ // For signed messages with differing Sender as signer we force showing Sender.
+ // If we're now in a different message, hide the (old) sender row and remove
+ // it from the header view, so that Sender normally isn't shown.
+ if (
+ "sender" in gExpandedHeaderView &&
+ !Services.prefs.getBoolPref("mailnews.headers.showSender")
+ ) {
+ gExpandedHeaderView.sender.enclosingRow.hidden = true;
+ delete gExpandedHeaderView.sender;
+ }
+}
+
+function msgHdrViewSMIMEOnLoad(event) {
+ window.crypto.enableSmartCardEvents = true;
+ document.addEventListener("smartcard-insert", onSmartCardChange);
+ document.addEventListener("smartcard-remove", onSmartCardChange);
+ if (!gSMIMEBundle) {
+ gSMIMEBundle = Services.strings.createBundle(
+ "chrome://messenger-smime/locale/msgReadSMIMEOverlay.properties"
+ );
+ }
+
+ // Add ourself to the list of message display listeners so we get notified
+ // when we are about to display a message.
+ var listener = {};
+ listener.onStartHeaders = onSMIMEStartHeaders;
+ listener.onEndHeaders = onSMIMEEndHeaders;
+ listener.onBeforeShowHeaderPane = onSMIMEBeforeShowHeaderPane;
+ gMessageListeners.push(listener);
+
+ // eslint-disable-next-line no-global-assign
+ gEncryptedURIService = Cc[
+ "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1"
+ ].getService(Ci.nsIEncryptedSMIMEURIsService);
+}
+
+function msgHdrViewSMIMEOnUnload(event) {
+ window.crypto.enableSmartCardEvents = false;
+ document.removeEventListener("smartcard-insert", onSmartCardChange);
+ document.removeEventListener("smartcard-remove", onSmartCardChange);
+ forgetEncryptedURI();
+ removeEventListener("messagepane-loaded", msgHdrViewSMIMEOnLoad, true);
+ removeEventListener("messagepane-unloaded", msgHdrViewSMIMEOnUnload, true);
+ removeEventListener(
+ "messagepane-hide",
+ msgHdrViewSMIMEOnMessagePaneHide,
+ true
+ );
+ removeEventListener(
+ "messagepane-unhide",
+ msgHdrViewSMIMEOnMessagePaneUnhide,
+ true
+ );
+}
+
+function msgHdrViewSMIMEOnMessagePaneHide() {
+ setMessageCryptoBox(null, null, null, false);
+}
+
+function msgHdrViewSMIMEOnMessagePaneUnhide() {
+ refreshSmimeMessageEncryptionStatus();
+}
+
+addEventListener("messagepane-loaded", msgHdrViewSMIMEOnLoad, true);
+addEventListener("messagepane-unloaded", msgHdrViewSMIMEOnUnload, true);
+addEventListener("messagepane-hide", msgHdrViewSMIMEOnMessagePaneHide, true);
+addEventListener(
+ "messagepane-unhide",
+ msgHdrViewSMIMEOnMessagePaneUnhide,
+ true
+);
diff --git a/comm/mail/extensions/smime/jar.mn b/comm/mail/extensions/smime/jar.mn
new file mode 100644
index 0000000000..97645f5e45
--- /dev/null
+++ b/comm/mail/extensions/smime/jar.mn
@@ -0,0 +1,14 @@
+# 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-smime %content/messenger-smime/
+ content/messenger-smime/msgHdrViewSMIMEOverlay.js (content/msgHdrViewSMIMEOverlay.js)
+ content/messenger/certpicker.js (/comm/mailnews/extensions/smime/certpicker.js)
+ content/messenger/certpicker.xhtml (/comm/mailnews/extensions/smime/certpicker.xhtml)
+ content/messenger-smime/msgReadSMIMEOverlay.js (/comm/mailnews/extensions/smime/msgReadSMIMEOverlay.js)
+ content/messenger-smime/msgCompSecurityInfo.js (/comm/mailnews/extensions/smime/msgCompSecurityInfo.js)
+ content/messenger-smime/msgCompSecurityInfo.xhtml (/comm/mailnews/extensions/smime/msgCompSecurityInfo.xhtml)
+ content/messenger-smime/certFetchingStatus.xhtml (/comm/mailnews/extensions/smime/certFetchingStatus.xhtml)
+ content/messenger-smime/certFetchingStatus.js (/comm/mailnews/extensions/smime/certFetchingStatus.js)
diff --git a/comm/mail/extensions/smime/moz.build b/comm/mail/extensions/smime/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/mail/extensions/smime/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/installer/Makefile.in b/comm/mail/installer/Makefile.in
new file mode 100644
index 0000000000..01b10d3fde
--- /dev/null
+++ b/comm/mail/installer/Makefile.in
@@ -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/.
+
+STANDALONE_MAKEFILE := 1
+# DIST_SUBDIR := mail # Not sure why TB is not using this
+
+include $(topsrcdir)/config/rules.mk
+
+MOZ_PKG_REMOVALS = $(srcdir)/removed-files.in
+
+MOZ_PKG_MANIFEST = $(srcdir)/package-manifest.in
+# Some files have been already bundled with xulrunner
+ifndef SYSTEM_LIBXUL
+MOZ_PKG_FATAL_WARNINGS = 1
+endif
+
+# When packaging an artifact build not all xpt files expected by the
+# packager will be present.
+ifdef MOZ_ARTIFACT_BUILDS
+MOZ_PKG_FATAL_WARNINGS =
+endif
+
+DEFINES += -DPKG_LOCALE_MANIFEST=$(topobjdir)/comm/mail/installer/locale-manifest.in
+MOZ_CHROME_LOCALE_ENTRIES=@RESPATH@/chrome/
+
+MOZ_PKG_DUPEFLAGS = \
+ -f $(srcdir)/allowed-dupes.mn \
+ $(NULL)
+
+MOZ_NONLOCALIZED_PKG_LIST = \
+ xpcom \
+ mail \
+ newsblog \
+ offline \
+ $(NULL)
+
+MOZ_LOCALIZED_PKG_LIST = $(AB_CD)
+
+DEFINES += -DMOZ_APP_NAME=$(MOZ_APP_NAME) -DPREF_DIR=$(PREF_DIR)
+
+ifdef NIGHTLY_BUILD
+DEFINES += -DNIGHTLY_BUILD=1
+endif
+
+ifdef MOZ_DEBUG
+DEFINES += -DMOZ_DEBUG=1
+endif
+
+ifeq ($(MOZ_WIDGET_TOOLKIT),gtk)
+DEFINES += -DMOZ_GTK=1
+endif
+
+ifdef MOZ_SYSTEM_NSPR
+DEFINES += -DMOZ_SYSTEM_NSPR=1
+endif
+
+ifdef MOZ_SYSTEM_NSS
+DEFINES += -DMOZ_SYSTEM_NSS=1
+endif
+
+ifdef MOZ_ARTIFACT_BUILDS
+DEFINES += -DMOZ_ARTIFACT_BUILDS=1
+DEFINES += -DMZLA_LIBRNP=1
+endif
+
+DEFINES += -DJAREXT=
+
+ifdef MOZ_ANGLE_RENDERER
+DEFINES += -DMOZ_ANGLE_RENDERER=$(MOZ_ANGLE_RENDERER)
+ifdef MOZ_D3DCOMPILER_VISTA_DLL
+DEFINES += -DMOZ_D3DCOMPILER_VISTA_DLL=$(MOZ_D3DCOMPILER_VISTA_DLL)
+endif
+endif
+
+ifdef MOZ_UPDATER
+DEFINES += -DMOZ_UPDATER=1
+endif
+
+DEFINES += -DMOZ_CHILD_PROCESS_NAME=$(MOZ_CHILD_PROCESS_NAME)
+
+# Set MSVC dlls version to package, if any.
+ifdef MOZ_NO_DEBUG_RTL
+ifdef WIN32_REDIST_DIR
+DEFINES += -DMOZ_PACKAGE_MSVC_DLLS=1
+DEFINES += -DMSVC_C_RUNTIME_DLL=$(MSVC_C_RUNTIME_DLL)
+ifdef MSVC_C_RUNTIME_1_DLL
+DEFINES += -DMSVC_C_RUNTIME_1_DLL=$(MSVC_C_RUNTIME_1_DLL)
+endif
+DEFINES += -DMSVC_CXX_RUNTIME_DLL=$(MSVC_CXX_RUNTIME_DLL)
+endif
+ifdef WIN_UCRT_REDIST_DIR
+DEFINES += -DMOZ_PACKAGE_WIN_UCRT_DLLS=1
+endif
+endif
+
+ifneq (,$(filter WINNT Darwin Android,$(OS_TARGET)))
+DEFINES += -DMOZ_SHARED_MOZGLUE=1
+endif
+
+ifdef NECKO_WIFI
+DEFINES += -DNECKO_WIFI
+endif
+
+ifdef MOZ_BUNDLED_FONTS
+DEFINES += -DMOZ_BUNDLED_FONTS=1
+endif
+
+ifdef MAKENSISU
+DEFINES += -DHAVE_MAKENSISU=1
+endif
+
+ifdef MOZ_BACKGROUNDTASKS
+DEFINES += -DMOZ_BACKGROUNDTASKS=1
+endif
+
+ifdef MOZ_PREF_EXTENSIONS
+DEFINES += -DMOZ_PREF_EXTENSIONS=1
+endif
+
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+MOZ_PKG_MAC_DSSTORE=$(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/dsstore
+MOZ_PKG_MAC_BACKGROUND=$(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/background.png
+MOZ_PKG_MAC_ICON=$(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/disk.icns
+MOZ_PKG_MAC_EXTRA=--symlink '/Applications:/ '
+endif
+
+# The packager minifies two different types of files: non-JS (mostly property
+# files for l10n), and JS. Setting MOZ_PACKAGER_MINIFY only minifies the
+# former. We don't yet minify JS, due to concerns about debuggability.
+#
+# Also, the JS minification setup really only works correctly on Android:
+# we need extra setup to use the newly-built shell for Linux and Windows,
+# and Mac requires some extra care due to cross-compilation.
+MOZ_PACKAGER_MINIFY=1
+
+NON_OMNIJAR_FILES = defaults/messenger/mailViews.dat
+
+UPLOAD_EXTRA_FILES = $(MOZ_BUILT_FROM_FILE)
+include $(topsrcdir)/toolkit/mozapps/installer/packager.mk
+
+ifeq (Darwin,$(OS_TARGET))
+BINPATH = $(_BINPATH)
+DEFINES += -DAPPNAME='$(_APPNAME)'
+else
+# Every other platform just winds up in dist/bin
+BINPATH = bin
+endif
+DEFINES += -DBINPATH='$(BINPATH)'
+
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+RESPATH = $(_RESPATH)
+else
+RESPATH = $(BINPATH)
+endif
+DEFINES += -DRESPATH='$(RESPATH)'
+
+LPROJ_ROOT = $(firstword $(subst -, ,$(AB_CD)))
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+ifeq (zh-TW,$(AB_CD))
+LPROJ_ROOT := $(subst -,_,$(AB_CD))
+endif
+endif
+DEFINES += -DLPROJ_ROOT=$(LPROJ_ROOT)
+
+ifdef MOZ_SYSTEM_ICU
+DEFINES += -DMOZ_SYSTEM_ICU
+endif
+
+ifdef CLANG_CXX
+DEFINES += -DCLANG_CXX
+endif
+ifdef CLANG_CL
+DEFINES += -DCLANG_CL
+endif
+
+ifdef LLVM_SYMBOLIZER
+DEFINES += -DLLVM_SYMBOLIZER=$(notdir $(LLVM_SYMBOLIZER))
+endif
+ifdef MOZ_CLANG_RT_ASAN_LIB_PATH
+DEFINES += -DMOZ_CLANG_RT_ASAN_LIB=$(notdir $(MOZ_CLANG_RT_ASAN_LIB_PATH))
+endif
+
+ifdef TB_LIBOTR_PREBUILT
+DEFINES += -DTB_LIBOTR_PREBUILT
+endif
+
+libs::
+ $(MAKE) -C $(DEPTH)/comm/mail/locales langpack
+
+ifeq (WINNT,$(OS_ARCH))
+PKGCOMP_FIND_OPTS =
+else
+PKGCOMP_FIND_OPTS = -L
+endif
+ifeq (Darwin, $(OS_ARCH))
+FINDPATH = $(_APPNAME)/Contents/MacOS
+else
+FINDPATH=bin
+endif
+
+package-compare:: $(MOZ_PKG_MANIFEST)
+ cd $(DIST); find $(PKGCOMP_FIND_OPTS) '$(FINDPATH)' -type f | sort > bin-list.txt
+ $(call py_action,preprocessor,$(DEFINES) $(ACDEFINES) $(MOZ_PKG_MANIFEST)) | grep '^$(BINPATH)' | sed -e 's/^\///' | sort > $(DIST)/pack-list.txt
+ -diff -u $(DIST)/pack-list.txt $(DIST)/bin-list.txt
+ rm -f $(DIST)/pack-list.txt $(DIST)/bin-list.txt
+
+# Overwrite the one made by Firefox with our own.
+make-sourcestamp-file::
+ $(PYTHON3) $(commtopsrcdir)/build/source_repos.py gen_sourcestamp > $(MOZ_SOURCESTAMP_FILE)
+
+ifdef ENABLE_WEBDRIVER
+DEFINES += -DENABLE_WEBDRIVER=1
+endif
diff --git a/comm/mail/installer/allowed-dupes.mn b/comm/mail/installer/allowed-dupes.mn
new file mode 100644
index 0000000000..11c53e5919
--- /dev/null
+++ b/comm/mail/installer/allowed-dupes.mn
@@ -0,0 +1,81 @@
+# Known duplicate files
+# This file is ideally removed, but some existing files will be grandfathered in
+# See bug 1303184, bug 1313670
+#
+# PLEASE DO NOT ADD MORE EXCEPTIONS TO THIS LIST
+#
+
+# browser (see bug 1451050, bug 1476934, 1818999)
+defaults/settings/main/anti-tracking-url-decoration.json
+defaults/settings/thunderbird/anti-tracking-url-decoration.json
+
+# mail
+chrome/classic/skin/classic/messenger/icons/addon-install-confirm.svg
+chrome/classic/skin/classic/messenger/icons/new-mail-alert.png
+chrome/classic/skin/classic/messenger/icons/connection-insecure.svg
+chrome/classic/skin/classic/messenger/icons/message-encrypted-notok.svg
+chrome/classic/skin/classic/messenger/icons/new/compact/compress.svg
+chrome/classic/skin/classic/messenger/icons/new/compact/density-compact.svg
+chrome/messenger/content/branding/icon32.png
+chrome/messenger/content/branding/icon48.png
+chrome/messenger/content/branding/icon64.png
+chrome/messenger/content/branding/icon128.png
+chrome/messenger/content/branding/icon256.png
+chrome/messenger/content/messenger/extension.svg
+chrome/messenger/skin/classic/messenger/messages/simple/Variants/Normal.css
+chrome/messenger/skin/classic/messenger/messages/simple/Incoming/Context.html
+chrome/messenger/skin/classic/messenger/messages/simple/Incoming/NextContext.html
+chrome/messenger/skin/classic/messenger/messages/mail/Incoming/buddy_icon.svg
+chrome/messenger/skin/classic/messenger/messages/mail/Outgoing/buddy_icon.svg
+chrome/icons/default/default32.png
+chrome/icons/default/default48.png
+chrome/icons/default/default64.png
+chrome/icons/default/default128.png
+chrome/icons/default/default256.png
+
+#ifdef XP_MACOSX
+LaunchServices/org.mozilla.updater
+updater.app/Contents/MacOS/org.mozilla.updater
+
+plugin-container.app/Contents/PkgInfo
+updater.app/Contents/PkgInfo
+
+crashreporter.app/Contents/Resources/English.lproj/MainMenu.nib/classes.nib
+crashreporter.app/Contents/Resources/English.lproj/MainMenuRTL.nib/classes.nib
+#endif
+
+# Variants of paths in mozilla/browser/installer/allowed-dupes.mn:
+# bug 658850
+@MOZ_APP_NAME@
+@MOZ_APP_NAME@-bin
+
+# Row and column icons are duplicated
+res/table-remove-column-active.gif
+res/table-remove-row-active.gif
+res/table-remove-column-hover.gif
+res/table-remove-row-hover.gif
+res/table-remove-column.gif
+res/table-remove-row.gif
+
+res/multilocale.txt
+update.locale
+
+# Bug 1496075 - Switch searchplugins to Web Extensions
+chrome/messenger/search-extensions/amazon/favicon.ico
+chrome/messenger/search-extensions/amazondotcn/favicon.ico
+chrome/messenger/search-extensions/amazondotcom/favicon.ico
+chrome/messenger/search-extensions/mercadolibre/favicon.ico
+chrome/messenger/search-extensions/mercadolivre/favicon.ico
+
+# Windows artifact builds hack - bug 1714650
+chrome/remote/content/server/HTTPD.jsm
+components/httpd.js
+
+# Bug 1723628 - Empty files to stop the tests crashing
+chrome/messenger/skin/classic/messenger/messages/mail/Footer.html
+chrome/messenger/skin/classic/messenger/messages/mail/Header.html
+chrome/messenger/skin/classic/messenger/messages/mail/Incoming/NextContext.html
+chrome/messenger/skin/classic/messenger/messages/mail/Outgoing/Content.html
+chrome/messenger/skin/classic/messenger/messages/mail/Outgoing/Context.html
+chrome/messenger/skin/classic/messenger/messages/mail/Outgoing/NextContent.html
+chrome/messenger/skin/classic/messenger/messages/mail/Outgoing/NextContext.html
diff --git a/comm/mail/installer/moz.build b/comm/mail/installer/moz.build
new file mode 100644
index 0000000000..89251dc396
--- /dev/null
+++ b/comm/mail/installer/moz.build
@@ -0,0 +1,4 @@
+# vim: set filetype=python:
+# 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/.
diff --git a/comm/mail/installer/package-manifest.in b/comm/mail/installer/package-manifest.in
new file mode 100644
index 0000000000..f846fe9766
--- /dev/null
+++ b/comm/mail/installer/package-manifest.in
@@ -0,0 +1,516 @@
+; 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/.
+
+; Package file for the Thunderbird build.
+;
+; Packaging manifest is used to copy files from dist/bin
+; to the staging directory.
+; Some other files are built in the staging directory directly,
+; so they will be implicitly packaged too.
+;
+; File format:
+;
+; [] designates a toplevel component. Example: [xpcom]
+; * wildcard support to recursively copy the entire directory
+; ; file comment
+;
+
+; Due to Apple Mac OS X packaging requirements, files that are in the same
+; directory on other platforms must be located in different directories on
+; Mac OS X. The following defines allow specifying the Mac OS X bundle
+; location which also work on other platforms.
+;
+; @BINPATH@
+; Equals Contents/MacOS/ on Mac OS X and is the path to the main binary on other
+; platforms.
+;
+; @RESPATH@
+; Equals Contents/Resources/ on Mac OS X and is equivalent to @BINPATH@ on other
+; platforms.
+
+#filter substitution
+
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+#define UNIX_BUT_NOT_MAC
+#endif
+#endif
+
+#ifdef XP_MACOSX
+; Mac bundle stuff
+@APPNAME@/Contents/Info.plist
+#ifdef MOZ_UPDATER
+@APPNAME@/Contents/Library/LaunchServices
+#endif
+@APPNAME@/Contents/PkgInfo
+@APPNAME@/Contents/Library/Spotlight/thunderbird.mdimporter/
+@RESPATH@/thunderbird.icns
+@RESPATH@/@LPROJ_ROOT@.lproj/*
+#endif
+
+[@AB_CD@]
+@RESPATH@/@PREF_DIR@/all-l10n.js
+@RESPATH@/dictionaries/*
+@RESPATH@/localization/*
+#ifdef MOZ_BUNDLED_FONTS
+@RESPATH@/fonts/*
+#endif
+@RESPATH@/hyphenation/*
+#ifdef HAVE_MAKENSISU
+@BINPATH@/uninstall/helper.exe
+#endif
+#ifdef MOZ_UPDATER
+@RESPATH@/update.locale
+@RESPATH@/updater.ini
+#endif
+
+#ifdef LLVM_SYMBOLIZER
+@BINPATH@/@LLVM_SYMBOLIZER@
+#endif
+
+#ifdef MOZ_CLANG_RT_ASAN_LIB
+@BINPATH@/@MOZ_CLANG_RT_ASAN_LIB@
+#endif
+
+#ifdef MOZ_DMD
+; DMD
+@RESPATH@/dmd.py
+@RESPATH@/fix_stacks.py
+#endif
+
+#ifdef PKG_LOCALE_MANIFEST
+#include @PKG_LOCALE_MANIFEST@
+#endif
+
+[xpcom]
+@RESPATH@/dependentlibs.list
+#ifdef MOZ_SHARED_MOZGLUE
+@BINPATH@/@DLL_PREFIX@mozglue@DLL_SUFFIX@
+#endif
+#ifndef MOZ_STATIC_JS
+@BINPATH@/@DLL_PREFIX@mozjs@DLL_SUFFIX@
+#endif
+#ifndef MOZ_SYSTEM_NSPR
+#ifndef MOZ_FOLD_LIBS
+@BINPATH@/@DLL_PREFIX@nspr4@DLL_SUFFIX@
+@BINPATH@/@DLL_PREFIX@plc4@DLL_SUFFIX@
+@BINPATH@/@DLL_PREFIX@plds4@DLL_SUFFIX@
+#endif
+#endif
+#ifdef XP_MACOSX
+@BINPATH@/XUL
+#else
+@BINPATH@/@DLL_PREFIX@xul@DLL_SUFFIX@
+#endif
+#ifdef XP_MACOSX
+@BINPATH@/@MOZ_CHILD_PROCESS_NAME@.app/
+#else
+@BINPATH@/@MOZ_CHILD_PROCESS_NAME@
+#endif
+#ifdef XP_WIN
+#if MOZ_PACKAGE_MSVC_DLLS
+@BINPATH@/@MSVC_C_RUNTIME_DLL@
+#ifdef MSVC_C_RUNTIME_1_DLL
+@BINPATH@/@MSVC_C_RUNTIME_1_DLL@
+#endif
+@BINPATH@/@MSVC_CXX_RUNTIME_DLL@
+#endif
+#if MOZ_PACKAGE_WIN_UCRT_DLLS
+@BINPATH@/api-ms-win-*.dll
+@BINPATH@/ucrtbase.dll
+#endif
+#endif
+#ifdef MOZ_GTK
+@BINPATH@/glxtest
+@BINPATH@/@DLL_PREFIX@mozgtk@DLL_SUFFIX@
+#ifdef MOZ_WAYLAND
+@BINPATH@/@DLL_PREFIX@mozwayland@DLL_SUFFIX@
+@BINPATH@/vaapitest
+#endif
+#endif
+
+; We don't have a complete view of which dlls to expect when doing an artifact
+; build because we haven't run the relevant parts of configure, so we guess
+; here and trust what came from our source build.
+#if defined(MOZ_ARTIFACT_BUILDS) && defined(XP_WIN)
+@BINPATH@/api-ms-win-*.dll
+@BINPATH@/ucrtbase.dll
+@BINPATH@/vcruntime*.dll
+@BINPATH@/msvcp*.dll
+@BINPATH@/libEGL.dll
+@BINPATH@/libGLESv2.dll
+#endif
+
+; Optional RSS extension
+[newsblog]
+@RESPATH@/chrome/newsblog@JAREXT@
+@RESPATH@/chrome/newsblog.manifest
+
+[mail]
+#ifndef XP_UNIX
+@BINPATH@/@MOZ_APP_NAME@.exe
+@BINPATH@/thunderbird.VisualElementsManifest.xml
+@BINPATH@/VisualElements/VisualElements_150.png
+@BINPATH@/VisualElements/VisualElements_70.png
+#else
+@BINPATH@/@MOZ_APP_NAME@-bin
+@BINPATH@/@MOZ_APP_NAME@
+#endif
+@RESPATH@/application.ini
+#ifdef MOZ_UPDATER
+@RESPATH@/update-settings.ini
+#endif
+@BINPATH@/@DLL_PREFIX@lgpllibs@DLL_SUFFIX@
+#ifdef MOZ_FFVPX
+@BINPATH@/@DLL_PREFIX@mozavutil@DLL_SUFFIX@
+@BINPATH@/@DLL_PREFIX@mozavcodec@DLL_SUFFIX@
+#endif
+@RESPATH@/platform.ini
+#ifndef MOZ_FOLD_LIBS
+@RESPATH@/@DLL_PREFIX@mozsqlite3@DLL_SUFFIX@
+#endif
+#ifdef UNIX_BUT_NOT_MAC
+#ifdef MOZ_UPDATER
+@RESPATH@/icons/*.png
+#endif
+#endif
+#ifdef XP_WIN
+#ifdef _AMD64_
+@BINPATH@/@DLL_PREFIX@qipcap64@DLL_SUFFIX@
+#else
+@BINPATH@/@DLL_PREFIX@qipcap@DLL_SUFFIX@
+#endif
+#endif
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; Mail Specific Files
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+@RESPATH@/defaults/messenger/mailViews.dat
+
+@RESPATH@/isp/*
+
+; interfaces.manifest doesn't get packaged because it is dynamically
+; re-created at packaging time when linking the xpts that will actually
+; go into the package, so the test related interfaces aren't included.
+@RESPATH@/components/msgMime.manifest
+@RESPATH@/components/MailComponents.manifest
+@RESPATH@/chrome/toolkit@JAREXT@
+@RESPATH@/chrome/toolkit.manifest
+@RESPATH@/chrome/comm@JAREXT@
+@RESPATH@/chrome/comm.manifest
+; Browser: Hack to get built_in_addons.json packaged
+@RESPATH@/chrome/browser@JAREXT@
+@RESPATH@/chrome/browser.manifest
+@RESPATH@/chrome/messenger@JAREXT@
+@RESPATH@/chrome/messenger.manifest
+@RESPATH@/chrome/pdfjs.manifest
+@RESPATH@/chrome/pdfjs/*
+#ifndef XP_UNIX
+@RESPATH@/chrome/icons/default/messengerWindow.ico
+@RESPATH@/chrome/icons/default/msgcomposeWindow.ico
+@RESPATH@/chrome/icons/default/calendar-alarm-dialog.ico
+@RESPATH@/chrome/icons/default/calendar-general-dialog.ico
+#elifdef UNIX_BUT_NOT_MAC
+@RESPATH@/chrome/icons/default/*.png
+#endif
+
+; Gloda
+@RESPATH@/chrome/gloda@JAREXT@
+@RESPATH@/chrome/gloda.manifest
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; Mail Extensions (smime, etc.)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+#ifdef MOZ_MAPI_SUPPORT
+@BINPATH@/MapiProxy.dll
+@BINPATH@/mozMapi32.dll
+#endif
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; instant messaging
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+@RESPATH@/@PREF_DIR@/chat-prefs.js
+@RESPATH@/chrome/chat@JAREXT@
+@RESPATH@/chrome/chat.manifest
+
+; Thunderbird specific
+@RESPATH@/@PREF_DIR@/all-im.js
+
+; OTR libraries
+#ifdef TB_LIBOTR_PREBUILT
+#ifdef XP_WIN
+@BINPATH@/libssp-0@DLL_SUFFIX@
+@BINPATH@/libotr@DLL_SUFFIX@
+#else
+@BINPATH@/@DLL_PREFIX@otr@DLL_SUFFIX@
+#endif
+#endif
+
+; OpenPGP (librnp)
+#ifdef MZLA_LIBRNP
+@BINPATH@/@DLL_PREFIX@rnp@DLL_SUFFIX@
+@BINPATH@/rnp-cli@BIN_SUFFIX@
+@BINPATH@/rnpkeys@BIN_SUFFIX@
+#endif
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; Chrome Files
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+@RESPATH@/chrome/classic@JAREXT@
+@RESPATH@/chrome/classic.manifest
+
+; [DevTools Startup Files]
+@RESPATH@/chrome/devtools-startup@JAREXT@
+@RESPATH@/chrome/devtools-startup.manifest
+
+; DevTools
+@RESPATH@/chrome/devtools@JAREXT@
+@RESPATH@/chrome/devtools.manifest
+@RESPATH@/@PREF_DIR@/debugger.js
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; Default Profile Settings
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+; default pref files
+@RESPATH@/defaults/pref/all-thunderbird.js
+@RESPATH@/defaults/pref/channel-prefs.js
+@RESPATH@/defaults/pref/composer.js
+@RESPATH@/defaults/pref/mailnews.js
+@RESPATH@/defaults/pref/mdn.js
+@RESPATH@/defaults/pref/e2e-prefs.js
+@RESPATH@/defaults/pref/thunderbird-branding.js
+@RESPATH@/defaults/permissions
+@RESPATH@/greprefs.js
+
+; Remote Settings JSON dumps
+@RESPATH@/defaults/settings/last_modified.json
+@RESPATH@/defaults/settings/blocklists
+@RESPATH@/defaults/settings/main
+@RESPATH@/defaults/settings/security-state
+@RESPATH@/defaults/settings/thunderbird
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; App extensions to Mail
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+@RESPATH@/chrome/openpgp@JAREXT@
+@RESPATH@/chrome/openpgp.manifest
+
+; misson control, autoconfig
+#ifdef MOZ_PREF_EXTENSIONS
+@RESPATH@/defaults/autoconfig/prefcalls.js
+#endif
+
+; Windows Search integration
+; the module is included as part of the "Modules" rule
+#ifdef XP_WIN
+@BINPATH@/WSEnable.exe
+#endif
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; Base Package Files
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+; accessibility (out of process API support)
+#ifdef ACCESSIBILITY
+#ifdef XP_WIN
+@BINPATH@/AccessibleMarshal.dll
+#endif
+#endif
+
+; toolkit
+@RESPATH@/components/extensions.manifest
+#ifdef MOZ_UPDATER
+@RESPATH@/components/nsUpdateService.manifest
+#endif
+@RESPATH@/components/ProcessSingleton.manifest
+
+[calendar]
+@RESPATH@/chrome/calendar@JAREXT@
+@RESPATH@/chrome/calendar.manifest
+
+@RESPATH@/@PREF_DIR@/calendar.js
+
+# Files added to components directory via `FINAL_TARGET_FILES.components`.
+@RESPATH@/components/calCachedCalendar.js
+@RESPATH@/components/calICSService-worker.js
+@RESPATH@/components/calItemBase.js
+
+@RESPATH@/components/SyncComponents.manifest
+@RESPATH@/components/servicesComponents.manifest
+@RESPATH@/components/servicesSettings.manifest
+@RESPATH@/components/cryptoComponents.manifest
+@RESPATH@/components/TelemetryStartup.manifest
+@RESPATH@/components/Push.manifest
+
+@RESPATH@/components/l10n-registry.manifest
+
+; WebDriver (Marionette, Remote Agent) remote protocols
+#ifdef ENABLE_WEBDRIVER
+@RESPATH@/chrome/remote@JAREXT@
+@RESPATH@/chrome/remote.manifest
+#endif
+
+; Phishing Protection
+
+; Modules
+@RESPATH@/modules/*
+@RESPATH@/actors/*
+
+; ANGLE GLES-on-D3D rendering library
+#ifdef MOZ_ANGLE_RENDERER
+@BINPATH@/libEGL.dll
+@BINPATH@/libGLESv2.dll
+
+#ifdef MOZ_D3DCOMPILER_VISTA_DLL
+@BINPATH@/@MOZ_D3DCOMPILER_VISTA_DLL@
+#endif
+#endif # MOZ_ANGLE_RENDERER
+
+; Background tasks-specific preferences. These are in the GRE
+; location since they apply to all tasks at this time.
+#ifdef MOZ_BACKGROUNDTASKS
+@RESPATH@/defaults/backgroundtasks/backgroundtasks.js
+#endif
+
+; [Layout Engine Resources]
+; Style Sheets, Graphics and other Resources used by the layout engine.
+@RESPATH@/res/EditorOverride.css
+@RESPATH@/res/contenteditable.css
+@RESPATH@/res/designmode.css
+@RESPATH@/res/table-add-column-after-active.gif
+@RESPATH@/res/table-add-column-after-hover.gif
+@RESPATH@/res/table-add-column-after.gif
+@RESPATH@/res/table-add-column-before-active.gif
+@RESPATH@/res/table-add-column-before-hover.gif
+@RESPATH@/res/table-add-column-before.gif
+@RESPATH@/res/table-add-row-after-active.gif
+@RESPATH@/res/table-add-row-after-hover.gif
+@RESPATH@/res/table-add-row-after.gif
+@RESPATH@/res/table-add-row-before-active.gif
+@RESPATH@/res/table-add-row-before-hover.gif
+@RESPATH@/res/table-add-row-before.gif
+@RESPATH@/res/table-remove-column-active.gif
+@RESPATH@/res/table-remove-column-hover.gif
+@RESPATH@/res/table-remove-column.gif
+@RESPATH@/res/table-remove-row-active.gif
+@RESPATH@/res/table-remove-row-hover.gif
+@RESPATH@/res/table-remove-row.gif
+@RESPATH@/res/grabber.gif
+#ifdef XP_MACOSX
+@RESPATH@/res/cursors/*
+#endif
+@RESPATH@/res/fonts/*
+@RESPATH@/res/dtd/*
+@RESPATH@/res/language.properties
+@RESPATH@/res/locale/layout/HtmlForm.properties
+@RESPATH@/res/locale/layout/MediaDocument.properties
+@RESPATH@/res/locale/layout/xmlparser.properties
+@RESPATH@/res/locale/dom/dom.properties
+#ifdef XP_MACOSX
+@RESPATH@/res/MainMenu.nib/
+#endif
+
+; Content-accessible resources.
+@RESPATH@/contentaccessible/*
+
+; svg
+@RESPATH@/res/svg.css
+
+; [Extensions]
+@RESPATH@/components/extensions-toolkit.manifest
+@RESPATH@/components/extensions-mail.manifest
+
+; [Personal Security Manager]
+;
+; NSS libraries are signed in the staging directory,
+; meaning their .chk files are created there directly.
+;
+#ifndef MOZ_SYSTEM_NSS
+#if defined(XP_LINUX) && !defined(ANDROID)
+@BINPATH@/@DLL_PREFIX@freeblpriv3@DLL_SUFFIX@
+#elif defined(XP_SOLARIS) && defined(SPARC64)
+@BINPATH@/@DLL_PREFIX@freebl_64fpu_3@DLL_SUFFIX@
+@BINPATH@/@DLL_PREFIX@freebl_64int_3@DLL_SUFFIX@
+#else
+@BINPATH@/@DLL_PREFIX@freebl3@DLL_SUFFIX@
+#endif
+@BINPATH@/@DLL_PREFIX@nss3@DLL_SUFFIX@
+@BINPATH@/@DLL_PREFIX@nssckbi@DLL_SUFFIX@
+#ifndef MOZ_FOLD_LIBS
+@BINPATH@/@DLL_PREFIX@nssutil3@DLL_SUFFIX@
+@BINPATH@/@DLL_PREFIX@smime3@DLL_SUFFIX@
+@BINPATH@/@DLL_PREFIX@ssl3@DLL_SUFFIX@
+#endif
+@BINPATH@/@DLL_PREFIX@softokn3@DLL_SUFFIX@
+#endif
+@RESPATH@/chrome/pippki@JAREXT@
+@RESPATH@/chrome/pippki.manifest
+
+; preprocessor.py doesn't handle parentheses, so while the following could be
+; expressed in a single line, it's more clear to break them up.
+#if defined(XP_WIN) || defined(XP_MACOSX)
+#if !defined(_ARM64_)
+@BINPATH@/@DLL_PREFIX@osclientcerts@DLL_SUFFIX@
+#endif
+#endif
+
+; For process sandboxing
+#if defined(MOZ_SANDBOX)
+#if defined(XP_LINUX)
+@BINPATH@/@DLL_PREFIX@mozsandbox@DLL_SUFFIX@
+#endif
+#endif
+
+; [Updater]
+;
+#ifdef MOZ_UPDATER
+#ifdef XP_MACOSX
+@BINPATH@/updater.app/
+#else
+@BINPATH@/updater@BIN_SUFFIX@
+#endif
+#endif
+
+; [MaintenanceService]
+;
+#ifdef MOZ_MAINTENANCE_SERVICE
+@BINPATH@/maintenanceservice.exe
+@BINPATH@/maintenanceservice_installer.exe
+#endif
+
+; [Crash Reporter]
+;
+#ifdef MOZ_CRASHREPORTER
+#ifdef XP_MACOSX
+@BINPATH@/crashreporter.app/
+#else
+@BINPATH@/crashreporter@BIN_SUFFIX@
+@RESPATH@/crashreporter.ini
+#ifdef XP_UNIX
+@RESPATH@/Throbber-small.gif
+#elif defined(XP_WIN)
+@BINPATH@/@DLL_PREFIX@mozwer@DLL_SUFFIX@
+#endif
+#endif
+#ifdef MOZ_CRASHREPORTER_INJECTOR
+@RESPATH@/crashreporter-override.ini
+@BINPATH@/breakpadinjector.dll
+#endif
+#endif
+
+; [ minidump-analyzer ]
+;
+#ifdef MOZ_CRASHREPORTER
+@BINPATH@/minidump-analyzer@BIN_SUFFIX@
+#endif
+
+; [ Ping Sender ]
+;
+@BINPATH@/pingsender@BIN_SUFFIX@
+
+; Shutdown Terminator
+@RESPATH@/components/terminator.manifest
diff --git a/comm/mail/installer/removed-files.in b/comm/mail/installer/removed-files.in
new file mode 100644
index 0000000000..ecf3520e23
--- /dev/null
+++ b/comm/mail/installer/removed-files.in
@@ -0,0 +1,92 @@
+# 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 removed-files.in file specifies files and directories to be removed during
+# an application update that are not automatically removed by the application
+# update process. The application update process handles the vast majority of
+# file and directory removals automatically so this file should not be used in
+# the vast majority of cases.
+
+# When to use removed-files.in file to remove files and directories:
+# * Files and directories located in the installation's "distribution/" and
+# "extensions/" directories that were added before Thunderbird 27. Files and
+# directories located in these directories were not included in the
+# application update file removals for a complete update prior to Thunderbird 27.
+# * Empty directories that were accidentally added to the installation
+# directory.
+# * Third party files and directories that were added to the installation
+# directory. Under normal circumstances this should only be done after release
+# drivers have approved the removal of these third party files.
+
+# If you are not sure whether a file or directory should be removed using the
+# removed-files.in file please contact one of the developers that work on
+# application update.
+
+# Note: the "distribution/" and "browser/extensions/" directories should never
+# be removed recursively since these directories are used by Partner builds and
+# custom installations.
+
+# To specify a file to be removed add the path to the file.
+# * If the file doesn't exist the update will succeed.
+# * If the file exists and can't be removed (e.g. the file is locked) the
+# update will fail.
+#
+# Example: path/to/file
+
+# To specify a directory to be removed only if it is empty add the path to the
+# directory with a trailing forward slash.
+# * If the directory doesn't exist the update will succeed.
+# * If the directory can't be removed (e.g. the directory is locked, contains
+# files, etc.) the update will succeed.
+#
+# Example: path/to/dir/
+
+# To specify a directory that should be recursively removed add the path to the
+# directory with a trailing forward slash and "*".
+# * If the directory doesn't exist the update will succeed.
+# * If all of the files the directory contains can be removed but the directory
+# or a subdirectory can't be removed (e.g. the directory is locked) the update
+# will succeed.
+# * If a file within the directory can't be removed the update will fail.
+#
+# Example: path/to/dir/*
+
+# Due to Apple Mac OS X packaging requirements files that are in the same
+# directory on other platforms must be located in different directories on
+# Mac OS X. The following defines allow specifying the Mac OS X bundle
+# location which will also work on other platforms.
+#
+# @DIR_MACOS@
+# Equals Contents/MacOS/ on Mac OX X and is an empty string on other platforms.
+#
+# @DIR_RESOURCES@
+# Equals Contents/Resources/ on Mac OX X and is an empty string on other
+# platforms.
+
+# An update watershed was required to update to Thunderbird 56 for LZMA and SHA384
+# support. This made it possible to delete all of the removal instructions in
+# this file.
+
+# Since then, the following were added:
+
+# Remove lightning extension.
+#ifdef NIGHTLY_BUILD
+@DIR_RESOURCES@extensions/
+@DIR_RESOURCES@extensions/{e2fda1a4-762b-4020-b5ad-a41df1933103}/*
+@DIR_RESOURCES@extensions/{e2fda1a4-762b-4020-b5ad-a41df1933103}.xpi
+#else
+@DIR_RESOURCES@distribution/
+@DIR_RESOURCES@distribution/extensions/
+@DIR_RESOURCES@distribution/extensions/{e2fda1a4-762b-4020-b5ad-a41df1933103}/*
+@DIR_RESOURCES@distribution/extensions/{e2fda1a4-762b-4020-b5ad-a41df1933103}.xpi
+#endif
+
+# Remove erroneous files left over from broken Mac signing process
+#ifdef XP_MACOSX
+Contents/Library/Spotlight/thunderbird.mdimporter/Contents/_CodeSignature/._CodeResources
+Contents/Library/Spotlight/thunderbird.mdimporter/Contents/._Info.plist
+Contents/Library/Spotlight/thunderbird.mdimporter/Contents/Resources/._schema.xml
+Contents/Library/Spotlight/thunderbird.mdimporter/Contents/Resources/English.lproj/._InfoPlist.strings
+Contents/Library/Spotlight/thunderbird.mdimporter/Contents/Resources/English.lproj/._schema.strings
+#endif
diff --git a/comm/mail/installer/windows/Makefile.in b/comm/mail/installer/windows/Makefile.in
new file mode 100644
index 0000000000..2a1f518bbf
--- /dev/null
+++ b/comm/mail/installer/windows/Makefile.in
@@ -0,0 +1,59 @@
+# 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/.
+
+include $(topsrcdir)/toolkit/mozapps/installer/package-name.mk
+
+CONFIG_DIR = instgen
+SFX_MODULE = $(commtopsrcdir)/other-licenses/7zstub/thunderbird/7zSD.sfx
+
+INSTALLER_FILES = \
+ app.tag \
+ nsis/installer.nsi \
+ nsis/uninstaller.nsi \
+ nsis/shared.nsh \
+ $(NULL)
+
+ifdef MOZ_MAINTENANCE_SERVICE
+INSTALLER_FILES += \
+ nsis/maintenanceservice_installer.nsi \
+ $(NULL)
+endif
+
+BRANDING_FILES = \
+ branding.nsi \
+ wizHeader.bmp \
+ wizHeaderRTL.bmp \
+ wizWatermark.bmp \
+ $(NULL)
+
+LOCALE_TOPDIR=$(commtopsrcdir)
+LOCALE_RELATIVEDIR=mail/installer/windows
+
+include $(topsrcdir)/config/config.mk
+
+ifdef IS_LANGUAGE_REPACK
+PPL_LOCALE_ARGS = \
+ --l10n-dir=$(REAL_LOCALE_MERGEDIR)/mail/installer \
+ --l10n-dir=$(call EXPAND_LOCALE_SRCDIR,mail/locales)/installer \
+ --l10n-dir=$(commtopsrcdir)/mail/locales/en-US/installer \
+ $(NULL)
+else
+PPL_LOCALE_ARGS=$(call EXPAND_LOCALE_SRCDIR,mail/locales)/installer
+endif
+
+$(CONFIG_DIR)/setup.exe::
+ $(RM) -r $(CONFIG_DIR)
+ $(MKDIR) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(srcdir)/,$(INSTALLER_FILES)) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/,$(BRANDING_FILES)) $(CONFIG_DIR)
+ $(call py_action,preprocessor,-Fsubstitution $(DEFINES) $(ACDEFINES) \
+ $(srcdir)/nsis/defines.nsi.in -o $(CONFIG_DIR)/defines.nsi)
+ $(PYTHON3) $(topsrcdir)/toolkit/mozapps/installer/windows/nsis/preprocess-locale.py \
+ --preprocess-locale $(topsrcdir) \
+ $(PPL_LOCALE_ARGS) $(AB_CD) $(CONFIG_DIR)
+
+GARBARGE_DIRS += instgen
+
+include $(topsrcdir)/config/rules.mk
+include $(topsrcdir)/toolkit/mozapps/installer/windows/nsis/makensis.mk
diff --git a/comm/mail/installer/windows/app.tag b/comm/mail/installer/windows/app.tag
new file mode 100644
index 0000000000..324c2c72b7
--- /dev/null
+++ b/comm/mail/installer/windows/app.tag
@@ -0,0 +1,4 @@
+;!@Install@!UTF-8!
+Title="Mozilla Thunderbird"
+RunProgram="setup.exe"
+;!@InstallEnd@! \ No newline at end of file
diff --git a/comm/mail/installer/windows/docs/MSIX.rst b/comm/mail/installer/windows/docs/MSIX.rst
new file mode 100644
index 0000000000..13dbd93a74
--- /dev/null
+++ b/comm/mail/installer/windows/docs/MSIX.rst
@@ -0,0 +1,23 @@
+MSIX Package
+============
+
+See the Firefox MSIX installer docs.
+
+resources.pri
+'''''''''''''
+
+Generate a new ``resources.pri`` file on a Windows machine using
+``makepri.exe`` from the Windows SDK, like:
+
+::
+
+ C:\> makepri.exe new ^
+ -IndexName thunderbird ^
+ -ConfigXml comm\mail\installer\windows\msix\priconfig.xml ^
+ -ProjectRoot comm\mail\branding\nightly\msix ^
+ -OutputFile comm\mail\installer\windows\msix\resources.pri ^
+ -Overwrite
+
+The choice of channel (i.e.,
+``comm\mail\branding\{thunderbird,nightly}``) should
+not matter.
diff --git a/comm/mail/installer/windows/moz.build b/comm/mail/installer/windows/moz.build
new file mode 100644
index 0000000000..712024b1e0
--- /dev/null
+++ b/comm/mail/installer/windows/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# 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/.
+
+DEFINES["APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+
+DEFINES["MOZ_APP_NAME"] = CONFIG["MOZ_APP_NAME"]
+DEFINES["MOZ_APP_DISPLAYNAME"] = CONFIG["MOZ_APP_DISPLAYNAME"]
+DEFINES["MOZILLA_VERSION"] = CONFIG["MOZILLA_VERSION"]
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["PRE_RELEASE_SUFFIX"] = ""
diff --git a/comm/mail/installer/windows/msi/installer.wxs b/comm/mail/installer/windows/msi/installer.wxs
new file mode 100644
index 0000000000..255bd81e86
--- /dev/null
+++ b/comm/mail/installer/windows/msi/installer.wxs
@@ -0,0 +1,104 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
+
+<Product Name="Mozilla Thunderbird $(var.Version) $(var.Architecture) $(var.AB_CD)"
+ Manufacturer="*" Language="0" Codepage="1252"
+ Version="$(var.EmbeddedVersionCode)" Id="18d4d2f4-b394-48a2-8886-9c68180cead5"
+ UpgradeCode="53ba97bb-f0fb-476d-baf5-e2d649f39a31" >
+
+ <Package Id="*" InstallerVersion="200" Compressed="yes"
+ Platform="$(var.Architecture)" InstallScope ="perMachine"/>
+
+ <!-- We need a CAB to avoid failing an ICE, even though we have no payload. -->
+ <Media Id="1" Cabinet="setup.cab" EmbedCab="yes" />
+
+ <!-- We need a component and feature, or msiexec will refuse to load us. -->
+ <Directory Id="TARGETDIR" Name="SourceDir">
+ <Directory Id="TempFolder">
+ <Component Id="EmptyComponent" Guid="d7fd8e89-04a4-4dd4-affc-35d8695a50a2">
+ <CreateFolder />
+ </Component>
+ </Directory>
+ </Directory>
+
+ <!-- Setting the feature to level 0 marks it hidden, so it can't be installed.
+ That prevents getting this MSI registered as an installed product,
+ because it has no features of its own to install. -->
+ <Feature Id="EmptyFeature" Level="0">
+ <ComponentRef Id="EmptyComponent" />
+ </Feature>
+
+ <!-- Embed the installer we want to run directly into the MSI database. -->
+ <Binary Id="WrappedExe" SourceFile="$(var.ExeSourcePath)" />
+
+ <!-- User-configurable properties. One of these corresponds to each documented
+ command-line parameter. Properties cannot be present without a value,
+ so use a conspicuous and difficult to mistake string for the parameters
+ that have no real default values. -->
+ <Property Id="INSTALL_DIRECTORY_PATH" Value="__DEFAULT__" />
+ <Property Id="INSTALL_DIRECTORY_NAME" Value="__DEFAULT__" />
+ <Property Id="TASKBAR_SHORTCUT" Value="true" />
+ <Property Id="DESKTOP_SHORTCUT" Value="true" />
+ <Property Id="START_MENU_SHORTCUT" Value="true" />
+ <Property Id="INSTALL_MAINTENANCE_SERVICE" Value="true" />
+ <Property Id="REMOVE_DISTRIBUTION_DIR" Value="true" />
+ <Property Id="PREVENT_REBOOT_REQUIRED" Value="false" />
+ <Property Id="OPTIONAL_EXTENSIONS" Value="true" />
+ <Property Id="EXTRACT_DIR" Value="__DEFAULT__" />
+
+ <!-- Always include all of the boolean options on the command line, so we don't
+ have to conditionally decide when to include each one of them. For the
+ directory settings though, we can't put them on the command line with the
+ default values those properties have, so we need a separate action for
+ each possible configuration of those settings, and conditions to select
+ the right action to use based on which properties are configured.
+ WiX throws warning LGHT1076 complaining that these command strings are
+ too long, but they actually work just fine, the warning is spurious. -->
+ <CustomAction Id="RunInstallNoDir" Return="check" Execute="deferred"
+ HideTarget="no" Impersonate="no" BinaryKey="WrappedExe"
+ ExeCommand="/S /TaskbarShortcut=[TASKBAR_SHORTCUT] /DesktopShortcut=[DESKTOP_SHORTCUT] /StartMenuShortcut=[START_MENU_SHORTCUT] /MaintenanceService=[INSTALL_MAINTENANCE_SERVICE] /RemoveDistributionDir=[REMOVE_DISTRIBUTION_DIR] /PreventRebootRequired=[PREVENT_REBOOT_REQUIRED] /OptionalExtensions=[OPTIONAL_EXTENSIONS] /LaunchedFromMSI" />
+ <CustomAction Id="RunInstallDirPath" Return="check" Execute="deferred"
+ HideTarget="no" Impersonate="no" BinaryKey="WrappedExe"
+ ExeCommand="/S /InstallDirectoryPath=[INSTALL_DIRECTORY_PATH] /TaskbarShortcut=[TASKBAR_SHORTCUT] /DesktopShortcut=[DESKTOP_SHORTCUT] /StartMenuShortcut=[START_MENU_SHORTCUT] /MaintenanceService=[INSTALL_MAINTENANCE_SERVICE] /RemoveDistributionDir=[REMOVE_DISTRIBUTION_DIR] /PreventRebootRequired=[PREVENT_REBOOT_REQUIRED] /OptionalExtensions=[OPTIONAL_EXTENSIONS] /LaunchedFromMSI" />
+ <CustomAction Id="RunInstallDirName" Return="check" Execute="deferred"
+ HideTarget="no" Impersonate="no" BinaryKey="WrappedExe"
+ ExeCommand="/S /InstallDirectoryName=[INSTALL_DIRECTORY_NAME] /TaskbarShortcut=[TASKBAR_SHORTCUT] /DesktopShortcut=[DESKTOP_SHORTCUT] /StartMenuShortcut=[START_MENU_SHORTCUT] /MaintenanceService=[INSTALL_MAINTENANCE_SERVICE] /RemoveDistributionDir=[REMOVE_DISTRIBUTION_DIR] /PreventRebootRequired=[PREVENT_REBOOT_REQUIRED] /OptionalExtensions=[OPTIONAL_EXTENSIONS] /LaunchedFromMSI" />
+ <CustomAction Id="RunExtractOnly" Return="check" Execute="deferred"
+ HideTarget="no" Impersonate="no" BinaryKey="WrappedExe"
+ ExeCommand="/ExtractDir=[EXTRACT_DIR]" />
+
+ <!-- When we run the custom actions is kind of arbitrary; this sequencing gets
+ us the least confusing message showing in the MSI progress dialog while
+ the installer runs. Our actions don't need to be sequenced relative
+ to one another because only one will ever run. -->
+ <InstallExecuteSequence>
+ <Custom Action="RunInstallNoDir" After="ProcessComponents">
+ <![CDATA[
+ (INSTALL_DIRECTORY_PATH = "__DEFAULT__") AND
+ (INSTALL_DIRECTORY_NAME = "__DEFAULT__") AND
+ (EXTRACT_DIR = "__DEFAULT__")
+ ]]>
+ </Custom>
+ <Custom Action="RunInstallDirPath" After="ProcessComponents">
+ <![CDATA[
+ (INSTALL_DIRECTORY_PATH <> "__DEFAULT__") AND
+ (INSTALL_DIRECTORY_NAME = "__DEFAULT__") AND
+ (EXTRACT_DIR = "__DEFAULT__")
+ ]]>
+ </Custom>
+ <Custom Action="RunInstallDirName" After="ProcessComponents">
+ <![CDATA[
+ (INSTALL_DIRECTORY_NAME <> "__DEFAULT__") AND
+ (EXTRACT_DIR = "__DEFAULT__")
+ ]]>
+ </Custom>
+ <Custom Action="RunExtractOnly" After="ProcessComponents">
+ <![CDATA[
+ EXTRACT_DIR <> "__DEFAULT__"
+ ]]>
+ </Custom>
+ </InstallExecuteSequence>
+</Product>
+
+</Wix>
diff --git a/comm/mail/installer/windows/msix/AppxManifest.xml.in b/comm/mail/installer/windows/msix/AppxManifest.xml.in
new file mode 100644
index 0000000000..80b86f4624
--- /dev/null
+++ b/comm/mail/installer/windows/msix/AppxManifest.xml.in
@@ -0,0 +1,150 @@
+<?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/. -->
+<!-- #filter substitution -->
+<Package
+ xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
+ xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
+ xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
+ xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
+ xmlns:uap2="http://schemas.microsoft.com/appx/manifest/uap/windows10/2"
+ xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
+ xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
+ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
+ IgnorableNamespaces="uap uap2 uap3 uap10 rescap">
+
+ <Identity Name="@APPX_IDENTITY@" Publisher="@APPX_PUBLISHER@" Version="@APPX_VERSION@"
+ ProcessorArchitecture="@APPX_ARCH@"/>
+ <Properties>
+ <DisplayName>@APPX_DISPLAYNAME@</DisplayName>
+ <PublisherDisplayName>@APPX_PUBLISHER_DISPLAY_NAME@</PublisherDisplayName>
+ <Description>@APPX_DESCRIPTION@</Description>
+ <Logo>Assets\StoreLogo.png</Logo>
+ <uap10:PackageIntegrity>
+ <uap10:Content Enforcement="on"/>
+ </uap10:PackageIntegrity>
+ </Properties>
+ <Resources>
+ @APPX_RESOURCE_LANGUAGE_LIST@
+ </Resources>
+ <Dependencies>
+ <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0"
+ MaxVersionTested="10.0.22621.1555"/>
+ </Dependencies>
+ <Capabilities>
+ <rescap:Capability Name="runFullTrust"/>
+ </Capabilities>
+ <Applications>
+ <Application Id="App" Executable="VFS\ProgramFiles\@APPX_INSTDIR@\@MOZ_APP_NAME@.exe"
+ EntryPoint="Windows.FullTrustApplication">
+ <uap:VisualElements BackgroundColor="#20123A" DisplayName="@APPX_DISPLAYNAME@"
+ Square150x150Logo="Assets\Square150x150Logo.png"
+ Square44x44Logo="Assets\Square44x44Logo.png"
+ Description="@APPX_DESCRIPTION@">
+ <uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png"
+ Square310x310Logo="Assets\LargeTile.png"
+ Square71x71Logo="Assets\SmallTile.png">
+ <uap:ShowNameOnTiles>
+ <uap:ShowOn Tile="square150x150Logo"/>
+ <uap:ShowOn Tile="wide310x150Logo"/>
+ <uap:ShowOn Tile="square310x310Logo"/>
+ </uap:ShowNameOnTiles>
+ </uap:DefaultTile>
+ </uap:VisualElements>
+ <Extensions>
+ <uap3:Extension Category="windows.appExecutionAlias"
+ EntryPoint="Windows.FullTrustApplication"
+ Executable="VFS\ProgramFiles\@APPX_INSTDIR@\@MOZ_APP_NAME@.exe">
+ <uap3:AppExecutionAlias>
+ <desktop:ExecutionAlias Alias="@MOZ_APP_NAME@.exe"/>
+ </uap3:AppExecutionAlias>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.fileTypeAssociation">
+ <uap3:FileTypeAssociation Name="eml">
+ <uap:SupportedFileTypes>
+ <!-- Keep synchronized with
+ https://searchfox.org/comm-central/source/mail/installer/windows/nsis/shared.nsh -->
+ <uap:FileType>.eml</uap:FileType>
+ </uap:SupportedFileTypes>
+ <uap:Logo>Assets\Email44x44.png</uap:Logo>
+ <uap2:SupportedVerbs>
+ <uap3:Verb Id="open" Parameters="-osint -mail &quot;%1&quot;">open</uap3:Verb>
+ </uap2:SupportedVerbs>
+ </uap3:FileTypeAssociation>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.fileTypeAssociation">
+ <uap3:FileTypeAssociation Name="ics">
+ <uap:SupportedFileTypes>
+ <!-- Keep synchronized with
+ https://searchfox.org/comm-central/source/mail/installer/windows/nsis/shared.nsh -->
+ <uap:FileType>.ics</uap:FileType>
+ </uap:SupportedFileTypes>
+ <uap:Logo>Assets\Calendar44x44.png</uap:Logo>
+ <uap2:SupportedVerbs>
+ <uap3:Verb Id="open" Parameters="-osint &quot;%1&quot;">open</uap3:Verb>
+ </uap2:SupportedVerbs>
+ </uap3:FileTypeAssociation>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.protocol">
+ <uap3:Protocol Name="mailto" Parameters="-osint -url &quot;%1&quot;">
+ <uap:DisplayName>mailto</uap:DisplayName>
+ <uap:Logo>Assets\Email44x44.png</uap:Logo>
+ </uap3:Protocol>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.protocol">
+ <uap3:Protocol Name="mid" Parameters="-osint -url &quot;%1&quot;">
+ <uap:DisplayName>mid</uap:DisplayName>
+ <uap:Logo>Assets\Email44x44.png</uap:Logo>
+ </uap3:Protocol>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.protocol">
+ <uap3:Protocol Name="nntp" Parameters="-osint -url &quot;%1&quot;">
+ <uap:DisplayName>nntp</uap:DisplayName>
+ <uap:Logo>Assets\News44x44.png</uap:Logo>
+ </uap3:Protocol>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.protocol">
+ <uap3:Protocol Name="news" Parameters="-osint -url &quot;%1&quot;">
+ <uap:DisplayName>news</uap:DisplayName>
+ <uap:Logo>Assets\News44x44.png</uap:Logo>
+ </uap3:Protocol>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.protocol">
+ <uap3:Protocol Name="snews" Parameters="-osint -url &quot;%1&quot;">
+ <uap:DisplayName>snews</uap:DisplayName>
+ <uap:Logo>Assets\News44x44.png</uap:Logo>
+ </uap3:Protocol>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.protocol">
+ <uap3:Protocol Name="webcal" Parameters="-osint -url &quot;%1&quot;">
+ <uap:DisplayName>webcal</uap:DisplayName>
+ <uap:Logo>Assets\Calendar44x44.png</uap:Logo>
+ </uap3:Protocol>
+ </uap3:Extension>
+ <uap3:Extension Category="windows.protocol">
+ <uap3:Protocol Name="webcals" Parameters="-osint -url &quot;%1&quot;">
+ <uap:DisplayName>webcals</uap:DisplayName>
+ <uap:Logo>Assets\Calendar44x44.png</uap:Logo>
+ </uap3:Protocol>
+ </uap3:Extension>
+ </Extensions>
+ </Application>
+ </Applications>
+ <Extensions>
+ <!-- These COM registrations allow Windows/MSAA to access Thunderbird accessibility features. -->
+ <com:Extension Category="windows.comInterface">
+ <com:ComInterface>
+ <com:ProxyStub DisplayName="AccessibleMarshal"
+ Id="1814ceeb-49e2-407f-af99-fa755a7d2607"
+ Path="VFS\ProgramFiles\@APPX_INSTDIR@\AccessibleMarshal.dll"/>
+ <com:Interface Id="4e747be5-2052-4265-8af0-8ecad7aad1c0"
+ ProxyStubClsid="1814ceeb-49e2-407f-af99-fa755a7d2607"/>
+ <com:Interface Id="1814ceeb-49e2-407f-af99-fa755a7d2607"
+ ProxyStubClsid="1814ceeb-49e2-407f-af99-fa755a7d2607"/>
+ <com:Interface Id="0d68d6d0-d93d-4d08-a30d-f00dd1f45b24"
+ ProxyStubClsid="1814ceeb-49e2-407f-af99-fa755a7d2607"/>
+ </com:ComInterface>
+ </com:Extension>
+ </Extensions>
+</Package>
diff --git a/comm/mail/installer/windows/msix/Resources.pri b/comm/mail/installer/windows/msix/Resources.pri
new file mode 100644
index 0000000000..aefbf20b1a
--- /dev/null
+++ b/comm/mail/installer/windows/msix/Resources.pri
Binary files differ
diff --git a/comm/mail/installer/windows/msix/distribution/distribution.ini b/comm/mail/installer/windows/msix/distribution/distribution.ini
new file mode 100644
index 0000000000..192a41637e
--- /dev/null
+++ b/comm/mail/installer/windows/msix/distribution/distribution.ini
@@ -0,0 +1,12 @@
+# Partner Distribution Configuration File
+# Author: MZLA Technologies
+# Date: 2022-09-05
+
+[Global]
+id=thunderbird-MSIX
+version=1.0
+about=Thunderbird Windows MSIX package
+
+[Preferences]
+intl.locale.requested=""
+intl.multilingual.enabled=true
diff --git a/comm/mail/installer/windows/msix/msix-all-locales b/comm/mail/installer/windows/msix/msix-all-locales
new file mode 100644
index 0000000000..8d73ee6312
--- /dev/null
+++ b/comm/mail/installer/windows/msix/msix-all-locales
@@ -0,0 +1,361 @@
+# Locale codes supported by Microsoft Windows and MSIX packages.
+#
+# From https://docs.microsoft.com/en-us/windows/uwp/publish/supported-languages.
+# Fetched on September 15, 2021.
+#
+# The following codes are listed as supported but for reasons unknown, Windows won't install
+# packages advertising them:
+#
+# sr* (Serbian)
+# uz* (Uzbek)
+
+af
+af-za
+am
+am-et
+ar
+ar-ae
+ar-bh
+ar-dz
+ar-eg
+ar-iq
+ar-jo
+ar-kw
+ar-lb
+ar-ly
+ar-ma
+ar-om
+ar-qa
+ar-sa
+ar-sy
+ar-tn
+ar-ye
+as
+as-in
+az-arab
+az-arab-az
+az-cyrl
+az-cyrl-az
+az-latn
+az-latn-az
+be
+be-by
+bg
+bg-bg
+bn
+bn-bd
+bn-in
+bs
+bs-cyrl
+bs-cyrl-ba
+bs-latn
+bs-latn-ba
+ca
+ca-es
+ca-es-valencia
+chr-cher
+chr-cher-us
+chr-latn
+cs
+cs-cz
+cy
+cy-gb
+da
+da-dk
+de
+de-at
+de-ch
+de-de
+de-li
+de-lu
+el
+el-gr
+en
+en-011
+en-014
+en-018
+en-021
+en-029
+en-053
+en-au
+en-bz
+en-ca
+en-gb
+en-hk
+en-id
+en-ie
+en-in
+en-jm
+en-kz
+en-mt
+en-my
+en-nz
+en-ph
+en-pk
+en-sg
+en-tt
+en-us
+en-vn
+en-za
+en-zw
+es
+es-019
+es-419
+es-ar
+es-bo
+es-cl
+es-co
+es-cr
+es-do
+es-ec
+es-es
+es-gt
+es-hn
+es-mx
+es-ni
+es-pa
+es-pe
+es-pr
+es-py
+es-sv
+es-us
+es-uy
+es-ve
+et
+et-ee
+eu
+eu-es
+fa
+fa-ir
+fi
+fi-fi
+fil
+fil-latn
+fil-ph
+fr
+fr-011
+fr-015
+fr-021
+fr-029
+fr-155
+fr-be
+fr-ca
+fr-cd
+fr-ch
+fr-ci
+fr-cm
+fr-fr
+fr-ht
+fr-lu
+fr-ma
+fr-mc
+fr-ml
+fr-re
+frc-latn
+frp-latn
+ga
+ga-ie
+gd-gb
+gd-latn
+gl
+gl-es
+gu
+gu-in
+ha
+ha-latn
+ha-latn-ng
+he
+he-il
+hi
+hi-in
+hr
+hr-ba
+hr-hr
+hu
+hu-hu
+hy
+hy-am
+id
+id-id
+ig-latn
+ig-ng
+is
+is-is
+it
+it-ch
+it-it
+iu-cans
+iu-latn
+iu-latn-ca
+ja
+ja-jp
+ka
+ka-ge
+kk
+kk-kz
+km
+km-kh
+kn
+kn-in
+ko
+ko-kr
+kok
+kok-in
+ku-arab
+ku-arab-iq
+ky-cyrl
+ky-kg
+lb
+lb-lu
+lo
+lo-la
+lt
+lt-lt
+lv
+lv-lv
+mi
+mi-latn
+mi-nz
+mk
+mk-mk
+ml
+ml-in
+mn-cyrl
+mn-mn
+mn-mong
+mn-phag
+mr
+mr-in
+ms
+ms-bn
+ms-my
+mt
+mt-mt
+nb
+nb-no
+ne
+ne-np
+nl
+nl-be
+nl-nl
+nn
+nn-no
+no
+no-no
+nso
+nso-za
+or
+or-in
+pa
+pa-arab
+pa-arab-pk
+pa-deva
+pa-in
+pl
+pl-pl
+prs
+prs-af
+prs-arab
+pt
+pt-br
+pt-pt
+quc-latn
+qut-gt
+qut-latn
+quz
+quz-bo
+quz-ec
+quz-pe
+ro
+ro-ro
+ru
+ru-ru
+rw
+rw-rw
+sd-arab
+sd-arab-pk
+sd-deva
+si
+si-lk
+sk
+sk-sk
+sl
+sl-si
+sq
+sq-al
+# sr # For reasons unknown, Windows won't install packages advertising these locales.
+# sr-Latn
+# sr-cyrl
+# sr-cyrl-ba
+# sr-cyrl-cs
+# sr-cyrl-me
+# sr-cyrl-rs
+# sr-latn-ba
+# sr-latn-cs
+# sr-latn-me
+# sr-latn-rs
+sv
+sv-fi
+sv-se
+sw
+sw-ke
+ta
+ta-in
+te
+te-in
+tg-arab
+tg-cyrl
+tg-cyrl-tj
+tg-latn
+th
+th-th
+ti
+ti-et
+tk-cyrl
+tk-cyrl-tr
+tk-latn
+tk-latn-tr
+tk-tm
+tn
+tn-bw
+tn-za
+tr
+tr-tr
+tt-arab
+tt-cyrl
+tt-latn
+tt-ru
+ug-arab
+ug-cn
+ug-cyrl
+ug-latn
+uk
+uk-ua
+ur
+ur-pk
+# uz # For reasons unknown, Windows won't install packages advertising these locales.
+# uz-cyrl
+# uz-latn
+# uz-latn-uz
+vi
+vi-vn
+wo
+wo-sn
+xh
+xh-za
+yo-latn
+yo-ng
+zh-Hans
+zh-Hant
+zh-cn
+zh-hans-cn
+zh-hans-sg
+zh-hant-hk
+zh-hant-mo
+zh-hant-tw
+zh-hk
+zh-mo
+zh-sg
+zh-tw
+zu
+zu-za
diff --git a/comm/mail/installer/windows/msix/priconfig.xml b/comm/mail/installer/windows/msix/priconfig.xml
new file mode 100644
index 0000000000..62f0668d5f
--- /dev/null
+++ b/comm/mail/installer/windows/msix/priconfig.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<!-- 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/. -->
+
+<resources targetOsVersion="10.0.0" majorVersion="1">
+ <!-- Commenting this out yields a single `.pri` declaring all scales. See
+ https://stackoverflow.com/a/42975692. -->
+ <!-- <packaging> -->
+ <!-- <autoResourcePackage qualifier="Language"/> -->
+ <!-- <autoResourcePackage qualifier="Scale"/> -->
+ <!-- <autoResourcePackage qualifier="DXFeatureLevel"/> -->
+ <!-- </packaging> -->
+ <index root="\" startIndexAt="\">
+ <default>
+ <qualifier name="Language" value="en-US"/>
+ <qualifier name="Contrast" value="standard"/>
+ <qualifier name="Scale" value="100"/>
+ <qualifier name="HomeRegion" value="001"/>
+ <qualifier name="TargetSize" value="256"/>
+ <qualifier name="LayoutDirection" value="LTR"/>
+ <qualifier name="Theme" value="dark"/>
+ <qualifier name="AlternateForm" value=""/>
+ <qualifier name="DXFeatureLevel" value="DX9"/>
+ <qualifier name="Configuration" value=""/>
+ <qualifier name="DeviceFamily" value="Universal"/>
+ <qualifier name="Custom" value=""/>
+ </default>
+ <indexer-config type="folder" foldernameAsQualifier="true" filenameAsQualifier="true" qualifierDelimiter="."/>
+ <indexer-config type="resw" convertDotsToSlashes="true" initialPath=""/>
+ <indexer-config type="resjson" initialPath=""/>
+ <indexer-config type="PRI"/>
+ </index>
+ <!--<index startIndexAt="Start Index Here" root="Root Here">-->
+ <!-- <indexer-config type="resfiles" qualifierDelimiter="."/>-->
+ <!-- <indexer-config type="priinfo" emitStrings="true" emitPaths="true" emitEmbeddedData="true"/>-->
+ <!--</index>-->
+</resources>
diff --git a/comm/mail/installer/windows/nsis/defines.nsi.in b/comm/mail/installer/windows/nsis/defines.nsi.in
new file mode 100755
index 0000000000..0862c8b050
--- /dev/null
+++ b/comm/mail/installer/windows/nsis/defines.nsi.in
@@ -0,0 +1,86 @@
+#filter substitution
+# 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/.
+
+
+# These defines should match application.ini settings
+!define AppName "Thunderbird"
+!define AppVersion "@MOZ_APP_VERSION@"
+
+!define GREVersion @MOZILLA_VERSION@
+!define AB_CD "@AB_CD@"
+
+!define FileMainEXE "@MOZ_APP_NAME@.exe"
+!define WindowClass "ThunderbirdMessageWindow"
+
+!define AppRegNameMail "Thunderbird"
+!define AppRegNameNews "Thunderbird (News)"
+!define AppRegNameCalendar "Thunderbird (Calendar)"
+
+!define ClientsRegName "Mozilla Thunderbird"
+
+!define BrandProductName "Thunderbird"
+!define BrandShortName "@MOZ_APP_DISPLAYNAME@"
+!ifndef BrandFullName
+!define BrandFullName "${BrandFullNameInternal}"
+!endif
+
+# Due to official and beta using the same branding (and`thus the same install
+# directory), this is used to differentiate between them.
+!if "@MOZ_UPDATE_CHANNEL@" == "beta"
+!undef BrandFullName
+!define BrandFullName "${BrandFullNameInternal} Beta"
+# NO_INSTDIR_FROM_REG is defined for Beta to prevent finding a non-default
+# installation directory in the registry and using that as the default. This
+# prevents Beta releases built with official branding from finding an existing
+# install of an official release and defaulting to its installation directory.
+!define NO_INSTDIR_FROM_REG
+!endif
+!define InstDirName "${BrandFullName}"
+
+!define CERTIFICATE_NAME "Mozilla Corporation"
+!define CERTIFICATE_ISSUER "DigiCert SHA2 Assured ID Code Signing CA"
+; Changing the name or issuer requires us to have both the old and the new
+; in the registry at the same time, temporarily.
+!define CERTIFICATE_NAME_PREVIOUS "Mozilla Corporation"
+!define CERTIFICATE_ISSUER_PREVIOUS "DigiCert Assured ID Code Signing CA-1"
+
+# ARCH is used when it is necessary to differentiate the x64 registry keys from
+# the x86 registry keys (e.g. the uninstall registry key).
+#ifdef HAVE_64BIT_BUILD
+!define HAVE_64BIT_BUILD
+#ifdef _ARM64_
+!define ARCH "AArch64"
+!define MinSupportedVer "Microsoft Windows 10 for ARM"
+#else
+!define ARCH "x64"
+!define MinSupportedVer "Microsoft Windows 7 x64"
+#endif
+#else
+!define MinSupportedVer "Microsoft Windows 7"
+!define ARCH "x86"
+#endif
+
+!define MinSupportedCPU "SSE2"
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+!define MOZ_MAINTENANCE_SERVICE
+#endif
+
+#ifdef MOZ_BITS_DOWNLOAD
+!define MOZ_BITS_DOWNLOAD
+#endif
+
+# File details shared by both the installer and uninstaller
+VIProductVersion "1.0.0.0"
+VIAddVersionKey "ProductName" "${BrandShortName}"
+VIAddVersionKey "CompanyName" "${CompanyName}"
+#ifdef MOZ_OFFICIAL_BRANDING
+VIAddVersionKey "LegalTrademarks" "${BrandShortName} is a Trademark of The Mozilla Foundation."
+#endif
+VIAddVersionKey "LegalCopyright" "${CompanyName}"
+VIAddVersionKey "FileVersion" "${AppVersion}"
+VIAddVersionKey "ProductVersion" "${AppVersion}"
+# Comments is not used but left below commented out for future reference
+# VIAddVersionKey "Comments" "Comments"
diff --git a/comm/mail/installer/windows/nsis/installer.nsi b/comm/mail/installer/windows/nsis/installer.nsi
new file mode 100755
index 0000000000..75b22b0d08
--- /dev/null
+++ b/comm/mail/installer/windows/nsis/installer.nsi
@@ -0,0 +1,1316 @@
+# 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/.
+
+# Required Plugins:
+# AppAssocReg http://nsis.sourceforge.net/Application_Association_Registration_plug-in
+# ApplicationID http://nsis.sourceforge.net/ApplicationID_plug-in
+# CityHash http://dxr.mozilla.org/mozilla-central/source/other-licenses/nsis/Contrib/CityHash
+# ShellLink http://nsis.sourceforge.net/ShellLink_plug-in
+# UAC http://nsis.sourceforge.net/UAC_plug-in
+# ServicesHelper Mozilla specific plugin that is located in /other-licenses/nsis
+
+; Set verbosity to 3 (e.g. no script) to lessen the noise in the build logs
+!verbose 3
+
+; 7-Zip provides better compression than the lzma from NSIS so we add the files
+; uncompressed and use 7-Zip to create a SFX archive of it
+SetDatablockOptimize on
+SetCompress off
+CRCCheck on
+
+RequestExecutionLevel user
+
+Unicode true
+ManifestSupportedOS all
+ManifestDPIAware true
+
+!addplugindir ./
+
+Var TmpVal
+Var InstallType
+Var AddStartMenuSC
+Var AddTaskbarSC
+Var AddQuickLaunchSC
+Var AddDesktopSC
+Var InstallMaintenanceService
+Var InstallOptionalExtensions
+Var PageName
+Var PreventRebootRequired
+Var RegisterDefaultAgent
+
+; By defining NO_STARTMENU_DIR an installer that doesn't provide an option for
+; an application's Start Menu PROGRAMS directory and doesn't define the
+; StartMenuDir variable can use the common InstallOnInitCommon macro.
+!define NO_STARTMENU_DIR
+
+; Attempt to elevate Standard Users in addition to users that
+; are a member of the Administrators group.
+!define NONADMIN_ELEVATE
+
+!define AbortSurveyURL "https://live.thunderbird.net/survey/cancel/?page="
+
+; Other included files may depend upon these includes!
+; The following includes are provided by NSIS.
+!include FileFunc.nsh
+!include LogicLib.nsh
+!include MUI.nsh
+!include WinMessages.nsh
+!include WinVer.nsh
+!include WordFunc.nsh
+
+!insertmacro GetOptions
+!insertmacro GetParameters
+!insertmacro GetSize
+!insertmacro StrFilter
+!insertmacro WordFind
+!insertmacro WordReplace
+
+; The following includes are custom.
+!include branding.nsi
+!include defines.nsi
+!include common.nsh
+!include locales.nsi
+
+VIAddVersionKey "FileDescription" "${BrandShortName} Installer"
+VIAddVersionKey "OriginalFilename" "setup.exe"
+
+; Must be inserted before other macros that use logging
+!insertmacro _LoggingCommon
+
+!insertmacro AddHandlerValues
+!insertmacro ChangeMUIHeaderImage
+!insertmacro CheckForFilesInUse
+!insertmacro CleanMaintenanceServiceLogs
+!insertmacro CopyFilesFromDir
+!insertmacro CreateRegKey
+!insertmacro GetLongPath
+!insertmacro GetPathFromString
+!insertmacro GetParent
+!insertmacro InitHashAppModelId
+!insertmacro IsHandlerForInstallDir
+!insertmacro IsPinnedToTaskBar
+!insertmacro IsUserAdmin
+!insertmacro LogDesktopShortcut
+!insertmacro LogQuickLaunchShortcut
+!insertmacro LogStartMenuShortcut
+!insertmacro ManualCloseAppPrompt
+!insertmacro PinnedToStartMenuLnkCount
+!insertmacro RegCleanAppHandler
+!insertmacro RegCleanMain
+!insertmacro RegCleanUninstall
+!insertmacro RemovePrecompleteEntries
+!insertmacro SetAppLSPCategories
+!insertmacro SetBrandNameVars
+!insertmacro UpdateShortcutAppModelIDs
+!insertmacro UnloadUAC
+!insertmacro WriteRegStr2
+!insertmacro WriteRegDWORD2
+
+; This needs to be inserted after InitHashAppModelId because it uses
+; $AppUserModelID and the compiler can't handle using variables lexically before
+; they've been declared.
+!insertmacro GetInstallerRegistryPref
+
+!include shared.nsh
+
+; Helper macros for ui callbacks. Insert these after shared.nsh
+!insertmacro CheckCustomCommon
+!insertmacro InstallEndCleanupCommon
+!insertmacro InstallOnInitCommon
+!insertmacro InstallStartCleanupCommon
+!insertmacro LeaveDirectoryCommon
+!insertmacro LeaveOptionsCommon
+!insertmacro OnEndCommon
+!insertmacro PreDirectoryCommon
+
+Name "${BrandFullName}"
+OutFile "setup.exe"
+!ifdef HAVE_64BIT_BUILD
+ InstallDir "$PROGRAMFILES64\${InstDirName}\"
+!else
+ InstallDir "$PROGRAMFILES32\${InstDirName}\"
+!endif
+ShowInstDetails nevershow
+
+################################################################################
+# Modern User Interface - MUI
+
+!define MOZ_MUI_CUSTOM_ABORT
+!define MUI_CUSTOMFUNCTION_ABORT "CustomAbort"
+!define MUI_ICON setup.ico
+!define MUI_UNICON setup.ico
+!define MUI_WELCOMEPAGE_TITLE_3LINES
+!define MUI_HEADERIMAGE
+!define MUI_HEADERIMAGE_RIGHT
+!define MUI_WELCOMEFINISHPAGE_BITMAP wizWatermark.bmp
+; By default MUI_BGCOLOR is hardcoded to FFFFFF, which is only correct if the
+; Windows theme or high-contrast mode hasn't changed it, so we need to
+; override that with GetSysColor(COLOR_WINDOW) (this string ends up getting
+; passed to SetCtlColors, which uses this custom syntax to mean that).
+!define MUI_BGCOLOR SYSCLR:WINDOW
+
+; Use a right to left header image when the language is right to left
+!ifdef ${AB_CD}_rtl
+!define MUI_HEADERIMAGE_BITMAP_RTL wizHeaderRTL.bmp
+!else
+!define MUI_HEADERIMAGE_BITMAP wizHeader.bmp
+!endif
+
+/**
+ * Installation Pages
+ */
+; Welcome Page
+!define MUI_PAGE_CUSTOMFUNCTION_PRE preWelcome
+!define MUI_PAGE_CUSTOMFUNCTION_SHOW showWelcome
+!insertmacro MUI_PAGE_WELCOME
+
+; Custom Options Page
+Page custom preOptions leaveOptions
+
+; Select Install Directory Page
+!define MUI_PAGE_CUSTOMFUNCTION_PRE preDirectory
+!define MUI_PAGE_CUSTOMFUNCTION_LEAVE leaveDirectory
+!define MUI_DIRECTORYPAGE_VERIFYONLEAVE
+!insertmacro MUI_PAGE_DIRECTORY
+
+; Custom Components Page
+!ifdef MOZ_MAINTENANCE_SERVICE
+Page custom preComponents leaveComponents
+!endif
+
+; Custom Shortcuts Page
+Page custom preShortcuts leaveShortcuts
+
+; Custom Summary Page
+Page custom preSummary leaveSummary
+
+; Install Files Page
+!insertmacro MUI_PAGE_INSTFILES
+
+; Finish Page
+!define MUI_FINISHPAGE_TITLE_3LINES
+!define MUI_FINISHPAGE_RUN
+!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
+!define MUI_FINISHPAGE_RUN_TEXT $(LAUNCH_TEXT)
+!define MUI_PAGE_CUSTOMFUNCTION_PRE preFinish
+!define MUI_PAGE_CUSTOMFUNCTION_SHOW showFinish
+!insertmacro MUI_PAGE_FINISH
+
+; Use the default dialog for IDD_VERIFY for a simple Banner
+ChangeUI IDD_VERIFY "${NSISDIR}\Contrib\UIs\default.exe"
+
+################################################################################
+# Install Sections
+
+; Cleanup operations to perform at the start of the installation.
+Section "-InstallStartCleanup"
+ SetDetailsPrint both
+ DetailPrint $(STATUS_CLEANUP)
+ SetDetailsPrint none
+
+ SetOutPath "$INSTDIR"
+ ${StartInstallLog} "${BrandFullName}" "${AB_CD}" "${AppVersion}" "${GREVersion}"
+
+ StrCpy $R9 "true"
+ StrCpy $PreventRebootRequired "false"
+ ${GetParameters} $R8
+ ${GetOptions} "$R8" "/INI=" $R7
+ ${Unless} ${Errors}
+ ; The configuration file must also exist
+ ${If} ${FileExists} "$R7"
+ ReadINIStr $R9 $R7 "Install" "RemoveDistributionDir"
+ ReadINIStr $R8 $R7 "Install" "PreventRebootRequired"
+ ${If} $R8 == "true"
+ StrCpy $PreventRebootRequired "true"
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+
+ ${GetParameters} $R8
+ ${InstallGetOption} $R8 "RemoveDistributionDir" $R9
+ ${If} $R9 == "0"
+ StrCpy $R9 "false"
+ ${EndIf}
+ ${InstallGetOption} $R8 "PreventRebootRequired" $PreventRebootRequired
+ ${If} $PreventRebootRequired == "1"
+ StrCpy $PreventRebootRequired "true"
+ ${EndIf}
+
+ ; Remove directories and files we always control before parsing the uninstall
+ ; log so empty directories can be removed.
+ ${If} ${FileExists} "$INSTDIR\updates"
+ RmDir /r "$INSTDIR\updates"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\updated"
+ RmDir /r "$INSTDIR\updated"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\defaults\shortcuts"
+ RmDir /r "$INSTDIR\defaults\shortcuts"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\distribution"
+ ${AndIf} $R9 != "false"
+ RmDir /r "$INSTDIR\distribution"
+ ${EndIf}
+
+ ; Delete the app exe if present to prevent launching the app while we are
+ ; installing.
+ ClearErrors
+ ${DeleteFile} "$INSTDIR\${FileMainEXE}"
+ ${If} ${Errors}
+ ; If the user closed the application it can take several seconds for it to
+ ; shut down completely. If the application is being used by another user we
+ ; can rename the file and then delete is when the system is restarted.
+ Sleep 5000
+ ${DeleteFile} "$INSTDIR\${FileMainEXE}"
+ ClearErrors
+ ${EndIf}
+
+ ; setup the application model id registration value
+ ${InitHashAppModelId} "$INSTDIR" "Software\Mozilla\${AppName}\TaskBarIDs"
+
+ ; Clean up old maintenance service logs
+ ${CleanMaintenanceServiceLogs} "Thunderbird"
+
+ ${RemovePrecompleteEntries} "false"
+
+ ${If} ${FileExists} "$INSTDIR\defaults\pref\channel-prefs.js"
+ Delete "$INSTDIR\defaults\pref\channel-prefs.js"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\defaults\pref"
+ RmDir "$INSTDIR\defaults\pref"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\defaults"
+ RmDir "$INSTDIR\defaults"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\uninstall"
+ ; Remove the uninstall directory that we control
+ RmDir /r "$INSTDIR\uninstall"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\update-settings.ini"
+ Delete "$INSTDIR\update-settings.ini"
+ ${EndIf}
+
+ ; Upgrade the copies of the MAPI DLL's
+ ${UpgradeMapiDLLs}
+
+ ; Delete two files installed by Kaspersky Anti-Spam extension that are only
+ ; compatible with Thunderbird 2 (bug 533692).
+ ${If} ${FileExists} "$INSTDIR\components\klthbplg.dll"
+ Delete /REBOOTOK "$INSTDIR\components\klthbplg.dll"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\components\IKLAntiSpam.xpt"
+ Delete /REBOOTOK "$INSTDIR\components\IKLAntiSpam.xpt"
+ ${EndIf}
+
+ ${InstallStartCleanupCommon}
+SectionEnd
+
+Section "-Application" APP_IDX
+ ${StartUninstallLog}
+
+ SetDetailsPrint both
+ DetailPrint $(STATUS_INSTALL_APP)
+ SetDetailsPrint none
+
+ ${LogHeader} "Installing Main Files"
+ ${CopyFilesFromDir} "$EXEDIR\core" "$INSTDIR" \
+ "$(ERROR_CREATE_DIRECTORY_PREFIX)" \
+ "$(ERROR_CREATE_DIRECTORY_SUFFIX)"
+
+
+ ; The MAPI DLL's are copied and the copies are then registered to lessen
+ ; file in use errors on application update.
+ ClearErrors
+ ${DeleteFile} "$INSTDIR\MapiProxy_InUse.dll"
+ ${If} ${Errors}
+ ; Clear the way for the new file and delete the old file on reboot
+ Rename "$INSTDIR\MapiProxy_InUse.dll" "$INSTDIR\MapiProxy_InUse.dll.moz-delete"
+ Delete /REBOOTOK "$INSTDIR\MapiProxy_InUse.dll.moz-delete"
+ ${EndIf}
+ CopyFiles /SILENT "$EXEDIR\core\MapiProxy.dll" "$INSTDIR\MapiProxy_InUse.dll"
+ ${LogMsg} "Installed File: $INSTDIR\MapiProxy_InUse.dll"
+ ${LogUninstall} "File: \MapiProxy_InUse.dll"
+
+ ClearErrors
+ ${DeleteFile} "$INSTDIR\mozMapi32_InUse.dll"
+ ${If} ${Errors}
+ ; Clear the way for the new file and delete the old file on reboot
+ Rename "$INSTDIR\mozMapi32_InUse.dll" "$INSTDIR\mozMapi32_InUse.dll.moz-delete"
+ Delete /REBOOTOK "$INSTDIR\mozMapi32_InUse.dll.moz-delete"
+ ${EndIf}
+ CopyFiles /SILENT "$EXEDIR\core\mozMapi32.dll" "$INSTDIR\mozMapi32_InUse.dll"
+ ${LogMsg} "Installed File: $INSTDIR\mozMapi32_InUse.dll"
+ ${LogUninstall} "File: \mozMapi32_InUse.dll"
+
+ ; Register DLLs
+ ; XXXrstrong - AccessibleMarshal.dll can be used by multiple applications but
+ ; is only registered for the last application installed. When the last
+ ; application installed is uninstalled AccessibleMarshal.dll will no longer be
+ ; registered. bug 338878
+ ${LogHeader} "DLL Registration"
+ ClearErrors
+ ${RegisterDLL} "$INSTDIR\AccessibleMarshal.dll"
+ ${If} ${Errors}
+ ${LogMsg} "** ERROR Registering: $INSTDIR\AccessibleMarshal.dll **"
+ ${Else}
+ ${LogUninstall} "DLLReg: \AccessibleMarshal.dll"
+ ${LogMsg} "Registered: $INSTDIR\AccessibleMarshal.dll"
+ ${EndIf}
+
+ ClearErrors
+
+ ; Record the Windows Error Reporting module
+ WriteRegDWORD HKLM "SOFTWARE\Microsoft\Windows\Windows Error Reporting\RuntimeExceptionHelperModules" "$INSTDIR\mozwer.dll" 0
+ ${If} ${Errors}
+ ${LogMsg} "** ERROR Recording: $INSTDIR\mozwer.dll **"
+ ${Else}
+ ${LogMsg} "Recorded: $INSTDIR\mozwer.dll"
+ ${EndIf}
+
+ ClearErrors
+
+ ; Default for creating Start Menu shortcut
+ ; (1 = create, 0 = don't create)
+ ${If} $AddStartMenuSC == ""
+ StrCpy $AddStartMenuSC "1"
+ ${EndIf}
+
+ ; Default for creating Quick Launch shortcut (1 = create, 0 = don't create)
+ ${If} $AddQuickLaunchSC == ""
+ ; Don't install the quick launch shortcut on Windows 7
+ ${If} ${AtLeastWin7}
+ StrCpy $AddQuickLaunchSC "0"
+ ${Else}
+ StrCpy $AddQuickLaunchSC "1"
+ ${EndIf}
+ ${EndIf}
+
+ ; Default for creating Desktop shortcut (1 = create, 0 = don't create)
+ ${If} $AddDesktopSC == ""
+ StrCpy $AddDesktopSC "1"
+ ${EndIf}
+
+ ; Default for adding a Taskbar pin (1 = pin, 0 = don't pin)
+ ${If} $AddTaskbarSC == ""
+ ${GetPinningSupportedByWindowsVersionWithoutSystemPopup} $AddTaskbarSC
+ ${EndIf}
+
+ ${LogHeader} "Adding Registry Entries"
+ SetShellVarContext current ; Set SHCTX to HKCU
+ ${RegCleanMain} "Software\Mozilla"
+ ${RegCleanUninstall}
+ ${UpdateProtocolHandlers}
+
+ ClearErrors
+ WriteRegStr HKLM "Software\Mozilla" "${BrandShortName}InstallerTest" "Write Test"
+ ${If} ${Errors}
+ StrCpy $TmpVal "HKCU" ; used primarily for logging
+ ${Else}
+ SetShellVarContext all ; Set SHCTX to HKLM
+ DeleteRegValue HKLM "Software\Mozilla" "${BrandShortName}InstallerTest"
+ StrCpy $TmpVal "HKLM" ; used primarily for logging
+ ${RegCleanMain} "Software\Mozilla"
+ ${RegCleanUninstall}
+ ${UpdateProtocolHandlers}
+ ${EndIf}
+
+ ${RemoveDeprecatedKeys}
+ ${Set32to64DidMigrateReg}
+
+ ; The previous installer adds several regsitry values to both HKLM and HKCU.
+ ; We now try to add to HKLM and if that fails to HKCU
+
+ ; The order that reg keys and values are added is important if you use the
+ ; uninstall log to remove them on uninstall. When using the uninstall log you
+ ; MUST add children first so they will be removed first on uninstall so they
+ ; will be empty when the key is deleted. This allows the uninstaller to
+ ; specify that only empty keys will be deleted.
+ ${SetAppKeys}
+
+ ; Uninstall keys can only exist under HKLM on some versions of windows. Since
+ ; it doesn't cause problems always add them.
+ ${SetUninstallKeys}
+
+ ; On install always add the ThunderbirdEML, Thunderbird.Url.mailto,
+ ; Thunderbird.Url.mid, Thunderbird.Url.news, Thunderbird.webcal and
+ ; ThunderbirdICS keys.
+ ${GetLongPath} "$INSTDIR\${FileMainEXE}" $8
+ StrCpy $0 "SOFTWARE\Classes"
+ StrCpy $1 "$\"$8$\" $\"%1$\""
+ StrCpy $2 "$\"$8$\" -osint -compose $\"%1$\""
+ StrCpy $3 "$\"$8$\" -osint -mail $\"%1$\""
+
+ ; An empty string is used for the 5th param because ThunderbirdEML is not a
+ ; protocol handler
+ ${AddHandlerValues} "$0\ThunderbirdEML" "$1" "$8,0" \
+ "${AppRegNameMail} Document" "" ""
+ ${AddHandlerValues} "$0\Thunderbird.Url.mailto" "$2" "$8,0" \
+ "${AppRegNameMail} URL" "delete" ""
+ ${AddHandlerValues} "$0\Thunderbird.Url.news" "$3" "$8,0" \
+ "${AppRegNameNews} URL" "delete" ""
+ ${AddHandlerValues} "$0\Thunderbird.Url.mid" "$1" "$8,0" \
+ "${AppRegNameMail} URL" "delete" ""
+ ${AddHandlerValues} "$0\Thunderbird.Url.webcal" "$1" "$8,0" \
+ "${AppRegNameCalendar} URL" "delete" ""
+ ; An empty string is used for the 5th param because ThunderbirdICS is not a
+ ; protocol handler
+ ${AddHandlerValues} "$0\ThunderbirdICS" "$1" "$8,0" \
+ "${AppRegNameCalendar} Document" "" ""
+
+ ; For pre win8, the following keys should only be set if we can write to HKLM.
+ ; For post win8, the keys below can be set in HKCU if needed.
+ ${If} $TmpVal == "HKLM"
+ ; Set the Start Menu Mail/News and Registered App HKLM registry keys.
+ ${SetClientsMail} "HKLM"
+ ${SetClientsNews} "HKLM"
+ ${SetClientsCalendar} "HKLM"
+ ${ElseIf} ${AtLeastWin8}
+ ; Set the Start Menu Mail/News and Registered App HKCU registry keys.
+ ${SetClientsMail} "HKCU"
+ ${SetClientsNews} "HKCU"
+ ${SetClientsCalendar} "HKCU"
+ ${EndIf}
+
+!ifdef MOZ_MAINTENANCE_SERVICE
+ ; If the maintenance service page was displayed then a value was already
+ ; explicitly selected for installing the maintenance service and
+ ; and so InstallMaintenanceService will already be 0 or 1.
+ ; If the maintenance service page was not displayed then
+ ; InstallMaintenanceService will be equal to "".
+ ${If} $InstallMaintenanceService == ""
+ Call IsUserAdmin
+ Pop $R0
+ ${If} $R0 == "true"
+ ; Only proceed if we have HKLM write access
+ ${AndIf} $TmpVal == "HKLM"
+ ; The user is an admin, so we should default to installing the service.
+ StrCpy $InstallMaintenanceService "1"
+ ${Else}
+ ; The user is not admin, so we can't install the service.
+ StrCpy $InstallMaintenanceService "0"
+ ${EndIf}
+ ${EndIf}
+
+ ${If} $InstallMaintenanceService == "1"
+ ; The user wants to install the maintenance service, so execute
+ ; the pre-packaged maintenance service installer.
+ ; This option can only be turned on if the user is an admin so there
+ ; is no need to use ExecShell w/ verb runas to enforce elevated.
+ nsExec::Exec "$\"$INSTDIR\maintenanceservice_installer.exe$\""
+ ${EndIf}
+!endif
+
+ ; These need special handling on uninstall since they may be overwritten by
+ ; an install into a different location.
+ StrCpy $0 "Software\Microsoft\Windows\CurrentVersion\App Paths\${FileMainEXE}"
+ ${WriteRegStr2} $TmpVal "$0" "" "$INSTDIR\${FileMainEXE}" 0
+ ${WriteRegStr2} $TmpVal "$0" "Path" "$INSTDIR" 0
+
+ ; Create shortcuts
+ ${LogHeader} "Adding Shortcuts"
+
+ ; Remove the start menu shortcuts and directory if the SMPROGRAMS section
+ ; exists in the shortcuts_log.ini and the SMPROGRAMS. The installer's shortcut
+ ; creation code will create the shortcut in the root of the Start Menu
+ ; Programs directory.
+ ${RemoveStartMenuDir}
+
+ ; Always add the application's shortcuts to the shortcuts log ini file. The
+ ; DeleteShortcuts macro will do the right thing on uninstall if the
+ ; shortcuts don't exist.
+ ${LogStartMenuShortcut} "${BrandShortName}.lnk"
+ ${LogQuickLaunchShortcut} "${BrandShortName}.lnk"
+ ${LogDesktopShortcut} "${BrandShortName}.lnk"
+
+ ; Best effort to update the Win7 taskbar and start menu shortcut app model
+ ; id's. The possible contexts are current user / system and the user that
+ ; elevated the installer.
+ Call FixShortcutAppModelIDs
+ ; If the current context is all also perform Win7 taskbar and start menu link
+ ; maintenance for the current user context.
+ ${If} $TmpVal == "HKLM"
+ SetShellVarContext current ; Set SHCTX to HKCU
+ Call FixShortcutAppModelIDs
+ SetShellVarContext all ; Set SHCTX to HKLM
+ ${EndIf}
+
+ ; If running elevated also perform Win7 taskbar and start menu link
+ ; maintenance for the unelevated user context in case that is different than
+ ; the current user.
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/UAC:" $0
+ ${Unless} ${Errors}
+ GetFunctionAddress $0 FixShortcutAppModelIDs
+ UAC::ExecCodeSegment $0
+ ${EndUnless}
+
+ ; UAC only allows elevating to an Admin account so there is no need to add
+ ; the Start Menu or Desktop shortcuts from the original unelevated process
+ ; since this will either add it for the user if unelevated or All Users if
+ ; elevated.
+ ${If} $AddStartMenuSC == 1
+ CreateShortCut "$SMPROGRAMS\${BrandShortName}.lnk" "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$SMPROGRAMS\${BrandShortName}.lnk"
+ ShellLink::SetShortCutWorkingDirectory "$SMPROGRAMS\${BrandShortName}.lnk" \
+ "$INSTDIR"
+ ${If} ${AtLeastWin7}
+ ${AndIf} "$AppUserModelID" != ""
+ ApplicationID::Set "$SMPROGRAMS\${BrandShortName}.lnk" "$AppUserModelID" "true"
+ ${EndIf}
+ ${LogMsg} "Added Shortcut: $SMPROGRAMS\${BrandShortName}.lnk"
+ ${Else}
+ ${LogMsg} "** ERROR Adding Shortcut: $SMPROGRAMS\${BrandShortName}.lnk"
+ ${EndIf}
+ ${EndIf}
+
+ ; Update lastwritetime of the Start Menu shortcut to clear the tile cache.
+ ; Do this for both shell contexts in case the user has shortcuts in multiple
+ ; locations, then restore the previous context at the end.
+ ${If} ${AtLeastWin8}
+ SetShellVarContext all
+ ${TouchStartMenuShortcut}
+ SetShellVarContext current
+ ${TouchStartMenuShortcut}
+ ${If} $TmpVal == "HKLM"
+ SetShellVarContext all
+ ${ElseIf} $TmpVal == "HKCU"
+ SetShellVarContext current
+ ${EndIf}
+ ${EndIf}
+
+ ${If} $AddDesktopSC == 1
+ CreateShortCut "$DESKTOP\${BrandShortName}.lnk" "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$DESKTOP\${BrandShortName}.lnk"
+ ShellLink::SetShortCutWorkingDirectory "$DESKTOP\${BrandShortName}.lnk" \
+ "$INSTDIR"
+ ${If} ${AtLeastWin7}
+ ${AndIf} "$AppUserModelID" != ""
+ ApplicationID::Set "$DESKTOP\${BrandShortName}.lnk" "$AppUserModelID" "true"
+ ${EndIf}
+ ${LogMsg} "Added Shortcut: $DESKTOP\${BrandShortName}.lnk"
+ ${Else}
+ ${LogMsg} "** ERROR Adding Shortcut: $DESKTOP\${BrandShortName}.lnk"
+ ${EndIf}
+ ${EndIf}
+
+ ; If elevated the Quick Launch shortcut must be added from the unelevated
+ ; original process.
+ ${If} $AddQuickLaunchSC == 1
+ ${Unless} ${AtLeastWin7}
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/UAC:" $0
+ ${If} ${Errors}
+ Call AddQuickLaunchShortcut
+ ${LogMsg} "Added Shortcut: $QUICKLAUNCH\${BrandShortName}.lnk"
+ ${Else}
+ ; It is not possible to add a log entry from the unelevated process so
+ ; add the log entry without the path since there is no simple way to
+ ; know the correct full path.
+ ${LogMsg} "Added Quick Launch Shortcut: ${BrandShortName}.lnk"
+ GetFunctionAddress $0 AddQuickLaunchShortcut
+ UAC::ExecCodeSegment $0
+ ${EndIf}
+ ${EndUnless}
+ ${EndIf}
+
+!ifdef MOZ_MAINTENANCE_SERVICE
+ ${If} $TmpVal == "HKLM"
+ ; Add the registry keys for allowed certificates.
+ ${AddMaintCertKeys}
+ ${EndIf}
+!endif
+SectionEnd
+
+; Cleanup operations to perform at the end of the installation.
+Section "-InstallEndCleanup"
+ SetDetailsPrint both
+ DetailPrint "$(STATUS_CLEANUP)"
+ SetDetailsPrint none
+
+ ${Unless} ${Silent}
+ ClearErrors
+ ${MUI_INSTALLOPTIONS_READ} $0 "summary.ini" "Field 4" "State"
+ ${If} "$0" == "1"
+ ${LogHeader} "Setting as the default mail application"
+ ; AddTaskbarSC is needed by MigrateTaskBarShortcut, which is called by
+ ; SetAsDefaultAppUserHKCU. If this is called via ExecCodeSegment,
+ ; MigrateTaskBarShortcut will not see the value of AddTaskbarSC, so we
+ ; send it via a register instead.
+ StrCpy $R0 $AddTaskbarSC
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/UAC:" $0
+ ${If} ${Errors}
+ Call SetAsDefaultMailAppUserHKCU
+ ${Else}
+ GetFunctionAddress $0 SetAsDefaultMailAppUserHKCU
+ UAC::ExecCodeSegment $0
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+
+ ; Adds a pinned Task Bar shortcut (see MigrateTaskBarShortcut for details).
+ ${MigrateTaskBarShortcut} "$AddTaskbarSC"
+
+ ; Refresh desktop icons
+ ${RefreshShellIcons}
+
+ ${InstallEndCleanupCommon}
+
+ ${If} $PreventRebootRequired == "true"
+ SetRebootFlag false
+ ${EndIf}
+
+ ${If} ${RebootFlag}
+ ; Admin is required to delete files on reboot so only add the moz-delete if
+ ; the user is an admin. After calling UAC::IsAdmin $0 will equal 1 if the
+ ; user is an admin.
+ UAC::IsAdmin
+ ${If} "$0" == "1"
+ ; When a reboot is required give RefreshShellIcons time to finish the
+ ; refreshing the icons so the OS doesn't display the icons from helper.exe
+ Sleep 10000
+ ${LogHeader} "Reboot Required To Finish Installation"
+ ; ${FileMainEXE}.moz-upgrade should never exist but just in case...
+ ${Unless} ${FileExists} "$INSTDIR\${FileMainEXE}.moz-upgrade"
+ Rename "$INSTDIR\${FileMainEXE}" "$INSTDIR\${FileMainEXE}.moz-upgrade"
+ ${EndUnless}
+
+ ${If} ${FileExists} "$INSTDIR\${FileMainEXE}"
+ ClearErrors
+ Rename "$INSTDIR\${FileMainEXE}" "$INSTDIR\${FileMainEXE}.moz-delete"
+ ${Unless} ${Errors}
+ Delete /REBOOTOK "$INSTDIR\${FileMainEXE}.moz-delete"
+ ${EndUnless}
+ ${EndIf}
+
+ ${Unless} ${FileExists} "$INSTDIR\${FileMainEXE}"
+ CopyFiles /SILENT "$INSTDIR\uninstall\helper.exe" "$INSTDIR"
+ FileOpen $0 "$INSTDIR\${FileMainEXE}" w
+ FileWrite $0 "Will be deleted on restart"
+ Rename /REBOOTOK "$INSTDIR\${FileMainEXE}.moz-upgrade" "$INSTDIR\${FileMainEXE}"
+ FileClose $0
+ Delete "$INSTDIR\${FileMainEXE}"
+ Rename "$INSTDIR\helper.exe" "$INSTDIR\${FileMainEXE}"
+ ${EndUnless}
+ ${EndIf}
+ ${EndIf}
+SectionEnd
+
+################################################################################
+# Install Abort Survey Functions
+
+Function CustomAbort
+ ${If} "${AB_CD}" == "en-US"
+ ${AndIf} "$PageName" != ""
+ ${AndIf} ${FileExists} "$EXEDIR\core\distribution\distribution.ini"
+ ReadINIStr $0 "$EXEDIR\core\distribution\distribution.ini" "Global" "about"
+ ClearErrors
+ ${WordFind} "$0" "Funnelcake" "E#" $1
+ ${Unless} ${Errors}
+ ; Yes = fill out the survey and exit, No = don't fill out survey and exit,
+ ; Cancel = don't exit.
+ MessageBox MB_YESNO|MB_ICONEXCLAMATION \
+ "Would you like to tell us why you are canceling this installation?" \
+ IDYes +1 IDNO CustomAbort_finish
+ ${If} "$PageName" == "Welcome"
+ GetFunctionAddress $0 AbortSurveyWelcome
+ ${ElseIf} "$PageName" == "Options"
+ GetFunctionAddress $0 AbortSurveyOptions
+ ${ElseIf} "$PageName" == "Directory"
+ GetFunctionAddress $0 AbortSurveyDirectory
+ ${ElseIf} "$PageName" == "Shortcuts"
+ GetFunctionAddress $0 AbortSurveyShortcuts
+ ${ElseIf} "$PageName" == "Summary"
+ GetFunctionAddress $0 AbortSurveySummary
+ ${EndIf}
+ ClearErrors
+ ${GetParameters} $1
+ ${GetOptions} "$1" "/UAC:" $2
+ ${If} ${Errors}
+ Call $0
+ ${Else}
+ UAC::ExecCodeSegment $0
+ ${EndIf}
+
+ CustomAbort_finish:
+ Return
+ ${EndUnless}
+ ${EndIf}
+
+ MessageBox MB_YESNO|MB_ICONEXCLAMATION "$(MOZ_MUI_TEXT_ABORTWARNING)" \
+ IDYES +1 IDNO +2
+ Return
+ Abort
+FunctionEnd
+
+Function AbortSurveyWelcome
+ ExecShell "open" "${AbortSurveyURL}step1"
+FunctionEnd
+
+Function AbortSurveyOptions
+ ExecShell "open" "${AbortSurveyURL}step2"
+FunctionEnd
+
+Function AbortSurveyDirectory
+ ExecShell "open" "${AbortSurveyURL}step3"
+FunctionEnd
+
+Function AbortSurveyShortcuts
+ ExecShell "open" "${AbortSurveyURL}step4"
+FunctionEnd
+
+Function AbortSurveySummary
+ ExecShell "open" "${AbortSurveyURL}step5"
+FunctionEnd
+
+################################################################################
+# Helper Functions
+
+Function AddQuickLaunchShortcut
+ CreateShortCut "$QUICKLAUNCH\${BrandShortName}.lnk" "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$QUICKLAUNCH\${BrandShortName}.lnk"
+ ShellLink::SetShortCutWorkingDirectory "$QUICKLAUNCH\${BrandShortName}.lnk" \
+ "$INSTDIR"
+ ${EndIf}
+FunctionEnd
+
+Function CheckExistingInstall
+ ; If there is a pending file copy from a previous upgrade don't allow
+ ; installing until after the system has rebooted.
+ IfFileExists "$INSTDIR\${FileMainEXE}.moz-upgrade" +1 +4
+ MessageBox MB_YESNO|MB_ICONEXCLAMATION "$(WARN_RESTART_REQUIRED_UPGRADE)" IDNO +2
+ Reboot
+ Quit
+
+ ; If there is a pending file deletion from a previous uninstall don't allow
+ ; installing until after the system has rebooted.
+ IfFileExists "$INSTDIR\${FileMainEXE}.moz-delete" +1 +4
+ MessageBox MB_YESNO|MB_ICONEXCLAMATION "$(WARN_RESTART_REQUIRED_UNINSTALL)" IDNO +2
+ Reboot
+ Quit
+
+ ${If} ${FileExists} "$INSTDIR\${FileMainEXE}"
+ ; Disable the next, cancel, and back buttons
+ GetDlgItem $0 $HWNDPARENT 1 ; Next button
+ EnableWindow $0 0
+ GetDlgItem $0 $HWNDPARENT 2 ; Cancel button
+ EnableWindow $0 0
+ GetDlgItem $0 $HWNDPARENT 3 ; Back button
+ EnableWindow $0 0
+
+ Banner::show /NOUNLOAD "$(BANNER_CHECK_EXISTING)"
+
+ ${If} "$TmpVal" == "FoundMessageWindow"
+ Sleep 5000
+ ${EndIf}
+
+ ${PushFilesToCheck}
+
+ ; Store the return value in $TmpVal so it is less likely to be accidentally
+ ; overwritten elsewhere.
+ ${CheckForFilesInUse} $TmpVal
+
+ Banner::destroy
+
+ ; Enable the next, cancel, and back buttons
+ GetDlgItem $0 $HWNDPARENT 1 ; Next button
+ EnableWindow $0 1
+ GetDlgItem $0 $HWNDPARENT 2 ; Cancel button
+ EnableWindow $0 1
+ GetDlgItem $0 $HWNDPARENT 3 ; Back button
+ EnableWindow $0 1
+
+ ${If} "$TmpVal" == "true"
+ StrCpy $TmpVal "FoundMessageWindow"
+ ${ManualCloseAppPrompt} "${WindowClass}" "$(WARN_MANUALLY_CLOSE_APP_INSTALL)"
+ StrCpy $TmpVal "true"
+ ${EndIf}
+ ${EndIf}
+FunctionEnd
+
+Function LaunchApp
+ ${ManualCloseAppPrompt} "${WindowClass}" "$(WARN_MANUALLY_CLOSE_APP_LAUNCH)"
+
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/UAC:" $1
+ ${If} ${Errors}
+ Exec "$\"$INSTDIR\${FileMainEXE}$\""
+ ${Else}
+ GetFunctionAddress $0 LaunchAppFromElevatedProcess
+ UAC::ExecCodeSegment $0
+ ${EndIf}
+FunctionEnd
+
+Function LaunchAppFromElevatedProcess
+ ; Set our current working directory to the application's install directory
+ ; otherwise the 7-Zip temp directory will be in use and won't be deleted.
+ SetOutPath "$INSTDIR"
+ Exec "$\"$INSTDIR\${FileMainEXE}$\""
+FunctionEnd
+
+################################################################################
+# Language
+
+!insertmacro MOZ_MUI_LANGUAGE 'baseLocale'
+!verbose push
+!verbose 3
+!include "overrideLocale.nsh"
+!include "customLocale.nsh"
+!verbose pop
+
+; Set this after the locale files to override it if it is in the locale
+; using " " for BrandingText will hide the "Nullsoft Install System..." branding
+BrandingText " "
+
+################################################################################
+# Page pre, show, and leave functions
+
+Function preWelcome
+ StrCpy $PageName "Welcome"
+ ${If} ${FileExists} "$EXEDIR\core\distribution\modern-wizard.bmp"
+ Delete "$PLUGINSDIR\modern-wizard.bmp"
+ CopyFiles /SILENT "$EXEDIR\core\distribution\modern-wizard.bmp" "$PLUGINSDIR\modern-wizard.bmp"
+ ${EndIf}
+FunctionEnd
+
+Function showWelcome
+ ; The welcome and finish pages don't get the correct colors for their labels
+ ; like the other pages do, presumably because they're built by filling in an
+ ; InstallOptions .ini file instead of from a dialog resource like the others.
+ ; Field 2 is the header and Field 3 is the body text.
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 2" "HWND"
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 3" "HWND"
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+FunctionEnd
+
+Function preOptions
+ ; The header and subheader on the wizard pages don't get the correct text
+ ; color by default for some reason, even though the other controls do.
+ GetDlgItem $0 $HWNDPARENT 1037
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+ GetDlgItem $0 $HWNDPARENT 1038
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+
+ StrCpy $PageName "Options"
+ ${If} ${FileExists} "$EXEDIR\core\distribution\modern-header.bmp"
+ ${AndIf} $hHeaderBitmap == ""
+ Delete "$PLUGINSDIR\modern-header.bmp"
+ CopyFiles /SILENT "$EXEDIR\core\distribution\modern-header.bmp" "$PLUGINSDIR\modern-header.bmp"
+ ${ChangeMUIHeaderImage} "$PLUGINSDIR\modern-header.bmp"
+ ${EndIf}
+ !insertmacro MUI_HEADER_TEXT "$(OPTIONS_PAGE_TITLE)" "$(OPTIONS_PAGE_SUBTITLE)"
+ !insertmacro MUI_INSTALLOPTIONS_DISPLAY "options.ini"
+FunctionEnd
+
+Function leaveOptions
+ ${MUI_INSTALLOPTIONS_READ} $0 "options.ini" "Settings" "State"
+ ${If} $0 != 0
+ Abort
+ ${EndIf}
+ ${MUI_INSTALLOPTIONS_READ} $R0 "options.ini" "Field 2" "State"
+ StrCmp $R0 "1" +1 +2
+ StrCpy $InstallType ${INSTALLTYPE_BASIC}
+ ${MUI_INSTALLOPTIONS_READ} $R0 "options.ini" "Field 3" "State"
+ StrCmp $R0 "1" +1 +2
+ StrCpy $InstallType ${INSTALLTYPE_CUSTOM}
+
+ ${LeaveOptionsCommon}
+
+ ${If} $InstallType == ${INSTALLTYPE_BASIC}
+ Call CheckExistingInstall
+ ${EndIf}
+FunctionEnd
+
+Function preDirectory
+ StrCpy $PageName "Directory"
+ ${PreDirectoryCommon}
+FunctionEnd
+
+Function leaveDirectory
+ ${If} $InstallType == ${INSTALLTYPE_BASIC}
+ Call CheckExistingInstall
+ ${EndIf}
+ ${LeaveDirectoryCommon} "$(WARN_DISK_SPACE)" "$(WARN_WRITE_ACCESS)"
+FunctionEnd
+
+Function preShortcuts
+ StrCpy $PageName "Shortcuts"
+ ${CheckCustomCommon}
+ !insertmacro MUI_HEADER_TEXT "$(SHORTCUTS_PAGE_TITLE)" "$(SHORTCUTS_PAGE_SUBTITLE)"
+ !insertmacro MUI_INSTALLOPTIONS_DISPLAY "shortcuts.ini"
+FunctionEnd
+
+Function leaveShortcuts
+ ${MUI_INSTALLOPTIONS_READ} $0 "shortcuts.ini" "Settings" "State"
+ ${If} $0 != 0
+ Abort
+ ${EndIf}
+ ${MUI_INSTALLOPTIONS_READ} $AddDesktopSC "shortcuts.ini" "Field 2" "State"
+ ${MUI_INSTALLOPTIONS_READ} $AddStartMenuSC "shortcuts.ini" "Field 3" "State"
+
+ ${If} ${IsPinningSupportedByWindowsVersionWithoutSystemPopup}
+ ${MUI_INSTALLOPTIONS_READ} $AddTaskbarSC "shortcuts.ini" "Field 4" "State"
+ ${EndIf}
+
+ ${If} $InstallType == ${INSTALLTYPE_CUSTOM}
+ Call CheckExistingInstall
+ ${EndIf}
+FunctionEnd
+
+!ifdef MOZ_MAINTENANCE_SERVICE
+Function preComponents
+ ; If the service already exists, don't show this page
+ ServicesHelper::IsInstalled "MozillaMaintenance"
+ Pop $R9
+ ${If} $R9 == 1
+ ; The service already exists so don't show this page.
+ Abort
+ ${EndIf}
+
+ ; Don't show the custom components page if the
+ ; user is not an admin
+ Call IsUserAdmin
+ Pop $R9
+ ${If} $R9 != "true"
+ Abort
+ ${EndIf}
+
+ ; Only show the maintenance service page if we have write access to HKLM
+ ClearErrors
+ WriteRegStr HKLM "Software\Mozilla" \
+ "${BrandShortName}InstallerTest" "Write Test"
+ ${If} ${Errors}
+ ClearErrors
+ Abort
+ ${Else}
+ DeleteRegValue HKLM "Software\Mozilla" "${BrandShortName}InstallerTest"
+ ${EndIf}
+
+ StrCpy $PageName "Components"
+ ${CheckCustomCommon}
+ !insertmacro MUI_HEADER_TEXT "$(COMPONENTS_PAGE_TITLE)" "$(COMPONENTS_PAGE_SUBTITLE)"
+ !insertmacro MUI_INSTALLOPTIONS_DISPLAY "components.ini"
+FunctionEnd
+
+Function leaveComponents
+ ${MUI_INSTALLOPTIONS_READ} $0 "components.ini" "Settings" "State"
+ ${If} $0 != 0
+ Abort
+ ${EndIf}
+ ${MUI_INSTALLOPTIONS_READ} $InstallMaintenanceService "components.ini" "Field 2" "State"
+ ${If} $InstallType == ${INSTALLTYPE_CUSTOM}
+ Call CheckExistingInstall
+ ${EndIf}
+FunctionEnd
+!endif
+
+Function preSummary
+ StrCpy $PageName "Summary"
+ ; Setup the summary.ini file for the Custom Summary Page
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Settings" NumFields "3"
+
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 1" Type "label"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 1" Text "$(SUMMARY_INSTALLED_TO)"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 1" Left "0"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 1" Right "-1"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 1" Top "5"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 1" Bottom "15"
+
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 2" Type "text"
+ ; The contents of this control must be set as follows in the pre function
+ ; ${MUI_INSTALLOPTIONS_READ} $1 "summary.ini" "Field 2" "HWND"
+ ; SendMessage $1 ${WM_SETTEXT} 0 "STR:$INSTDIR"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 2" state ""
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 2" Left "0"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 2" Right "-1"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 2" Top "17"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 2" Bottom "30"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 2" flags "READONLY"
+
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 3" Type "label"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 3" Left "0"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 3" Right "-1"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 3" Top "130"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 3" Bottom "150"
+
+ ${If} ${FileExists} "$INSTDIR\${FileMainEXE}"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 3" Text "$(SUMMARY_UPGRADE_CLICK)"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Settings" NextButtonText "$(UPGRADE_BUTTON)"
+ ${Else}
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 3" Text "$(SUMMARY_INSTALL_CLICK)"
+ DeleteINIStr "$PLUGINSDIR\summary.ini" "Settings" NextButtonText
+ ${EndIf}
+
+ ; Remove the "Field 4" ini section in case the user hits back and changes the
+ ; installation directory which could change whether the make default checkbox
+ ; should be displayed.
+ DeleteINISec "$PLUGINSDIR\summary.ini" "Field 4"
+
+ ; Check if it is possible to write to HKLM
+ ClearErrors
+ WriteRegStr HKLM "Software\Thunderbird" "${BrandShortName}InstallerTest" "Write Test"
+ ${Unless} ${Errors}
+ DeleteRegValue HKLM "Software\Thunderbird" "${BrandShortName}InstallerTest"
+ ; Check if Firefox is already the handler for http. This is set on all
+ ; versions of Windows.
+ ${IsHandlerForInstallDir} "http" $R9
+ ${If} "$R9" != "true"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Settings" NumFields "4"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 4" Type "checkbox"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 4" Text "$(SUMMARY_TAKE_DEFAULTS)"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 4" Left "0"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 4" Right "-1"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 4" State "1"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 4" Top "32"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field 4" Bottom "53"
+ ${EndIf}
+ ${EndUnless}
+
+ ${If} "$TmpVal" == "true"
+ ; If there is already a Type entry in the "Field 4" section with a value of
+ ; checkbox then the set as the default mail client checkbox is displayed and
+ ; this text must be moved below it.
+ ReadINIStr $0 "$PLUGINSDIR\summary.ini" "Field 4" "Type"
+ ${If} "$0" == "checkbox"
+ StrCpy $0 "5"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field $0" Top "53"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field $0" Bottom "68"
+ ${Else}
+ StrCpy $0 "4"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field $0" Top "35"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field $0" Bottom "50"
+ ${EndIf}
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Settings" NumFields "$0"
+
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field $0" Type "label"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field $0" Text "$(SUMMARY_REBOOT_REQUIRED_INSTALL)"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field $0" Left "0"
+ WriteINIStr "$PLUGINSDIR\summary.ini" "Field $0" Right "-1"
+ ${EndIf}
+
+ !insertmacro MUI_HEADER_TEXT "$(SUMMARY_PAGE_TITLE)" "$(SUMMARY_PAGE_SUBTITLE)"
+
+ ; The Summary custom page has a textbox that will automatically receive
+ ; focus. This sets the focus to the Install button instead.
+ !insertmacro MUI_INSTALLOPTIONS_INITDIALOG "summary.ini"
+ GetDlgItem $0 $HWNDPARENT 1
+ System::Call "user32::SetFocus(i r0, i 0x0007, i,i)i"
+ ${MUI_INSTALLOPTIONS_READ} $1 "summary.ini" "Field 2" "HWND"
+ SendMessage $1 ${WM_SETTEXT} 0 "STR:$INSTDIR"
+ !insertmacro MUI_INSTALLOPTIONS_SHOW
+FunctionEnd
+
+Function leaveSummary
+ ; Try to delete the app executable and if we can't delete it try to find the
+ ; app's message window and prompt the user to close the app. This allows
+ ; running an instance that is located in another directory. If for whatever
+ ; reason there is no message window we will just rename the app's files and
+ ; then remove them on restart.
+ ClearErrors
+ ${DeleteFile} "$INSTDIR\${FileMainEXE}"
+ ${If} ${Errors}
+ ${ManualCloseAppPrompt} "${WindowClass}" "$(WARN_MANUALLY_CLOSE_APP_INSTALL)"
+ ${EndIf}
+FunctionEnd
+
+; When we add an optional action to the finish page the cancel button is
+; enabled. This disables it and leaves the finish button as the only choice.
+Function preFinish
+ StrCpy $PageName ""
+ ${EndInstallLog} "${BrandFullName}"
+ !insertmacro MUI_INSTALLOPTIONS_WRITE "ioSpecial.ini" "settings" "cancelenabled" "0"
+FunctionEnd
+
+Function showFinish
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 2" "HWND"
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 3" "HWND"
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+
+ ; Field 4 is the launch checkbox. Since it's a checkbox, we need to
+ ; clear the theme from it before we can set its background color.
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 4" "HWND"
+ System::Call 'uxtheme::SetWindowTheme(i $0, w " ", w " ")'
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+FunctionEnd
+
+################################################################################
+# Initialization Functions
+
+Function .onInit
+ ; Remove the current exe directory from the search order.
+ ; This only effects LoadLibrary calls and not implicitly loaded DLLs.
+ System::Call 'kernel32::SetDllDirectoryW(w "")'
+
+ StrCpy $PageName ""
+ StrCpy $LANGUAGE 0
+ ${SetBrandNameVars} "$EXEDIR\core\distribution\setup.ini"
+
+ ; Don't install on systems that don't support SSE2. The parameter value of
+ ; 10 is for PF_XMMI64_INSTRUCTIONS_AVAILABLE which will check whether the
+ ; SSE2 instruction set is available. Result returned in $R7.
+ System::Call "kernel32::IsProcessorFeaturePresent(i 10)i .R7"
+
+ ; Windows NT 6.0 (Vista/Server 2008) and lower are not supported.
+ ${Unless} ${AtLeastWin7}
+ ${If} "$R7" == "0"
+ strCpy $R7 "$(WARN_MIN_SUPPORTED_OSVER_CPU_MSG)"
+ ${Else}
+ strCpy $R7 "$(WARN_MIN_SUPPORTED_OSVER_MSG)"
+ ${EndIf}
+ MessageBox MB_OKCANCEL|MB_ICONSTOP "$R7" IDCANCEL +2
+ ExecShell "open" "${URLSystemRequirements}"
+ Quit
+ ${EndUnless}
+
+ ; SSE2 CPU support
+ ${If} "$R7" == "0"
+ MessageBox MB_OKCANCEL|MB_ICONSTOP "$(WARN_MIN_SUPPORTED_CPU_MSG)" IDCANCEL +2
+ ExecShell "open" "${URLSystemRequirements}"
+ Quit
+ ${EndIf}
+
+!ifdef HAVE_64BIT_BUILD
+ ${If} "${ARCH}" == "AArch64"
+ ${IfNot} ${IsNativeARM64}
+ ${OrIfNot} ${AtLeastWin10}
+ MessageBox MB_OKCANCEL|MB_ICONSTOP "$(WARN_MIN_SUPPORTED_OSVER_MSG)" IDCANCEL +2
+ ExecShell "open" "${URLSystemRequirements}"
+ Quit
+ ${EndIf}
+ ${ElseIfNot} ${RunningX64}
+ MessageBox MB_OKCANCEL|MB_ICONSTOP "$(WARN_MIN_SUPPORTED_OSVER_MSG)" IDCANCEL +2
+ ExecShell "open" "${URLSystemRequirements}"
+ Quit
+ ${EndIf}
+ SetRegView 64
+!endif
+
+ ${InstallOnInitCommon} "$(WARN_MIN_SUPPORTED_OSVER_CPU_MSG)"
+
+ !insertmacro InitInstallOptionsFile "options.ini"
+ !insertmacro InitInstallOptionsFile "shortcuts.ini"
+ !insertmacro InitInstallOptionsFile "components.ini"
+ !insertmacro InitInstallOptionsFile "summary.ini"
+
+ WriteINIStr "$PLUGINSDIR\options.ini" "Settings" NumFields "5"
+
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 1" Type "label"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 1" Text "$(OPTIONS_SUMMARY)"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 1" Left "0"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 1" Right "-1"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 1" Top "0"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 1" Bottom "10"
+
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 2" Type "RadioButton"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 2" Text "$(OPTION_STANDARD_RADIO)"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 2" Left "0"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 2" Right "-1"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 2" Top "25"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 2" Bottom "35"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 2" State "1"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 2" Flags "GROUP"
+
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 3" Type "RadioButton"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 3" Text "$(OPTION_CUSTOM_RADIO)"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 3" Left "0"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 3" Right "-1"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 3" Top "55"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 3" Bottom "65"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 3" State "0"
+
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 4" Type "label"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 4" Text "$(OPTION_STANDARD_DESC)"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 4" Left "15"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 4" Right "-1"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 4" Top "37"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 4" Bottom "57"
+
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 5" Type "label"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 5" Text "$(OPTION_CUSTOM_DESC)"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 5" Left "15"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 5" Right "-1"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 5" Top "67"
+ WriteINIStr "$PLUGINSDIR\options.ini" "Field 5" Bottom "87"
+
+ ${If} ${IsPinningSupportedByWindowsVersionWithoutSystemPopup}
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Settings" NumFields "4"
+ ${Else}
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Settings" NumFields "3"
+ ${EndIf}
+
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 1" Type "label"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 1" Text "$(CREATE_ICONS_DESC)"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 1" Left "0"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 1" Right "-1"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 1" Top "5"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 1" Bottom "15"
+
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 2" Type "checkbox"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 2" Text "$(ICONS_DESKTOP)"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 2" Left "0"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 2" Right "-1"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 2" Top "20"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 2" Bottom "30"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 2" State "1"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 2" Flags "GROUP"
+
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 3" Type "checkbox"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 3" Text "$(ICONS_STARTMENU)"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 3" Left "0"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 3" Right "-1"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 3" Top "40"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 3" Bottom "50"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 3" State "1"
+
+ ${If} ${IsPinningSupportedByWindowsVersionWithoutSystemPopup}
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 4" Type "checkbox"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 4" Text "$(ICONS_TASKBAR)"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 4" Left "0"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 4" Right "-1"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 4" Top "60"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 4" Bottom "70"
+ WriteINIStr "$PLUGINSDIR\shortcuts.ini" "Field 4" State "1"
+ ${EndIf}
+
+ ; Setup the components.ini file for the Components Page
+ WriteINIStr "$PLUGINSDIR\components.ini" "Settings" NumFields "2"
+
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 1" Type "label"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 1" Text "$(OPTIONAL_COMPONENTS_DESC)"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 1" Left "0"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 1" Right "-1"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 1" Top "5"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 1" Bottom "25"
+
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 2" Type "checkbox"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 2" Text "$(MAINTENANCE_SERVICE_CHECKBOX_DESC)"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 2" Left "0"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 2" Right "-1"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 2" Top "27"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 2" Bottom "37"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 2" State "1"
+ WriteINIStr "$PLUGINSDIR\components.ini" "Field 2" Flags "GROUP"
+
+ ; There must always be a core directory.
+ ${GetSize} "$EXEDIR\core\" "/S=0K" $R5 $R7 $R8
+ ; Add 1024 Kb to the diskspace requirement since the installer makes a copy
+ ; of the MAPI dll's (around 20 Kb)... also, see Bug 434338.
+ IntOp $R5 $R5 + 1024
+ SectionSetSize ${APP_IDX} $R5
+
+ ; Initialize $hHeaderBitmap to prevent redundant changing of the bitmap if
+ ; the user clicks the back button
+ StrCpy $hHeaderBitmap ""
+FunctionEnd
+
+Function .onGUIEnd
+ ${OnEndCommon}
+FunctionEnd
diff --git a/comm/mail/installer/windows/nsis/maintenanceservice_installer.nsi b/comm/mail/installer/windows/nsis/maintenanceservice_installer.nsi
new file mode 100644
index 0000000000..081b70b4cb
--- /dev/null
+++ b/comm/mail/installer/windows/nsis/maintenanceservice_installer.nsi
@@ -0,0 +1,343 @@
+# 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/.
+
+; Set verbosity to 3 (e.g. no script) to lessen the noise in the build logs
+!verbose 3
+
+; 7-Zip provides better compression than the lzma from NSIS so we add the files
+; uncompressed and use 7-Zip to create a SFX archive of it
+SetDatablockOptimize on
+SetCompress off
+CRCCheck on
+
+RequestExecutionLevel admin
+
+Unicode true
+ManifestSupportedOS all
+ManifestDPIAware true
+
+!addplugindir ./
+
+; Variables
+Var TempMaintServiceName
+Var BrandFullNameDA
+Var BrandFullName
+
+; Other included files may depend upon these includes!
+; The following includes are provided by NSIS.
+!include FileFunc.nsh
+!include LogicLib.nsh
+!include MUI.nsh
+!include WinMessages.nsh
+!include WinVer.nsh
+!include WordFunc.nsh
+
+!insertmacro GetOptions
+!insertmacro GetParameters
+!insertmacro GetSize
+
+; The test machines use this fallback key to run tests.
+; And anyone that wants to run tests themselves should already have
+; this installed.
+!define FallbackKey \
+ "SOFTWARE\Mozilla\MaintenanceService\3932ecacee736d366d6436db0f55bce4"
+
+!define CompanyName "Mozilla Corporation"
+!define BrandFullNameInternal ""
+
+; The following includes are custom.
+!include defines.nsi
+; We keep defines.nsi defined so that we get other things like
+; the version number, but we redefine BrandFullName
+!define MaintFullName "Mozilla Maintenance Service"
+!ifdef BrandFullName
+!undef BrandFullName
+!endif
+!define BrandFullName "${MaintFullName}"
+
+!include common.nsh
+!include locales.nsi
+
+VIAddVersionKey "FileDescription" "${MaintFullName} Installer"
+VIAddVersionKey "OriginalFilename" "maintenanceservice_installer.exe"
+
+Name "${MaintFullName}"
+OutFile "maintenanceservice_installer.exe"
+
+; Get installation folder from registry if available
+InstallDirRegKey HKLM "Software\Mozilla\MaintenanceService" ""
+
+SetOverwrite on
+
+; serviceinstall.cpp also uses this key, in case the path is changed, update
+; there too.
+!define MaintUninstallKey \
+ "Software\Microsoft\Windows\CurrentVersion\Uninstall\MozillaMaintenanceService"
+
+; Always install into the 32-bit location even if we have a 64-bit build.
+; This is because we use only 1 service for all Firefox channels.
+; Allow either x86 and x64 builds to exist at this location, depending on
+; what is the latest build.
+InstallDir "$PROGRAMFILES32\${MaintFullName}\"
+ShowUnInstDetails nevershow
+
+################################################################################
+# Modern User Interface - MUI
+
+!define MUI_ICON setup.ico
+!define MUI_UNICON setup.ico
+!define MUI_WELCOMEPAGE_TITLE_3LINES
+!define MUI_UNWELCOMEFINISHPAGE_BITMAP wizWatermark.bmp
+
+;Interface Settings
+!define MUI_ABORTWARNING
+
+; Uninstaller Pages
+!insertmacro MUI_UNPAGE_CONFIRM
+!insertmacro MUI_UNPAGE_INSTFILES
+
+################################################################################
+# Language
+
+!insertmacro MOZ_MUI_LANGUAGE 'baseLocale'
+!verbose push
+!verbose 3
+!include "overrideLocale.nsh"
+!include "customLocale.nsh"
+!verbose pop
+
+; Set this after the locale files to override it if it is in the locale
+; using " " for BrandingText will hide the "Nullsoft Install System..." branding
+BrandingText " "
+
+Function .onInit
+ ; Remove the current exe directory from the search order.
+ ; This only effects LoadLibrary calls and not implicitly loaded DLLs.
+ System::Call 'kernel32::SetDllDirectoryW(w "")'
+
+ SetSilent silent
+
+ ${Unless} ${AtLeastWin7}
+ Abort
+ ${EndUnless}
+FunctionEnd
+
+Function un.onInit
+ ; Remove the current exe directory from the search order.
+ ; This only effects LoadLibrary calls and not implicitly loaded DLLs.
+ System::Call 'kernel32::SetDllDirectoryW(w "")'
+
+ StrCpy $BrandFullNameDA "${MaintFullName}"
+ StrCpy $BrandFullName "${MaintFullName}"
+FunctionEnd
+
+Section "MaintenanceService"
+ AllowSkipFiles off
+
+ CreateDirectory $INSTDIR
+ SetOutPath $INSTDIR
+
+ ; If the service already exists, then it will be stopped when upgrading it
+ ; via the maintenanceservice_tmp.exe command executed below.
+ ; The maintenanceservice_tmp.exe command will rename the file to
+ ; maintenanceservice.exe if maintenanceservice_tmp.exe is newer.
+ ; If the service does not exist yet, we install it and drop the file on
+ ; disk as maintenanceservice.exe directly.
+ StrCpy $TempMaintServiceName "maintenanceservice.exe"
+ IfFileExists "$INSTDIR\maintenanceservice.exe" 0 skipAlreadyExists
+ StrCpy $TempMaintServiceName "maintenanceservice_tmp.exe"
+ skipAlreadyExists:
+
+ ; We always write out a copy and then decide whether to install it or
+ ; not via calling its 'install' cmdline which works by version comparison.
+ CopyFiles /SILENT "$EXEDIR\maintenanceservice.exe" "$INSTDIR\$TempMaintServiceName"
+
+ ; The updater.ini file is only used when performing an install or upgrade,
+ ; and only if that install or upgrade is successful. If an old updater.ini
+ ; happened to be copied into the maintenance service installation directory
+ ; but the service was not newer, the updater.ini file would be unused.
+ ; It is used to fill the description of the service on success.
+ CopyFiles /SILENT "$EXEDIR\updater.ini" "$INSTDIR\updater.ini"
+
+ ; Install the application maintenance service.
+ ; If a service already exists, the command line parameter will stop the
+ ; service and only install itself if it is newer than the already installed
+ ; service. If successful it will remove the old maintenanceservice.exe
+ ; and replace it with maintenanceservice_tmp.exe.
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/Upgrade" $0
+ ${If} ${Errors}
+ ExecWait '"$INSTDIR\$TempMaintServiceName" install'
+ ${Else}
+ ; The upgrade cmdline is the same as install except
+ ; It will fail if the service isn't already installed.
+ ExecWait '"$INSTDIR\$TempMaintServiceName" upgrade'
+ ${EndIf}
+
+ WriteUninstaller "$INSTDIR\Uninstall.exe"
+
+ ; Since the Maintenance service can be installed either x86 or x64,
+ ; always use the 64-bit registry.
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ ; Previous versions always created the uninstall key in the 32-bit registry.
+ ; Clean those old entries out if they still exist.
+ SetRegView 32
+ DeleteRegKey HKLM "${MaintUninstallKey}"
+ ; Preserve the lastused value before we switch to 64.
+ SetRegView lastused
+
+ SetRegView 64
+ ${EndIf}
+
+ WriteRegStr HKLM "${MaintUninstallKey}" "DisplayName" "${MaintFullName}"
+ WriteRegStr HKLM "${MaintUninstallKey}" "UninstallString" \
+ '"$INSTDIR\uninstall.exe"'
+ WriteRegStr HKLM "${MaintUninstallKey}" "DisplayIcon" \
+ "$INSTDIR\Uninstall.exe,0"
+ WriteRegStr HKLM "${MaintUninstallKey}" "DisplayVersion" "${AppVersion}"
+ WriteRegStr HKLM "${MaintUninstallKey}" "Publisher" "Mozilla"
+ WriteRegStr HKLM "${MaintUninstallKey}" "Comments" "${BrandFullName}"
+ WriteRegDWORD HKLM "${MaintUninstallKey}" "NoModify" 1
+ ${GetSize} "$INSTDIR" "/S=0K" $R2 $R3 $R4
+ WriteRegDWORD HKLM "${MaintUninstallKey}" "EstimatedSize" $R2
+
+ ; Write out that a maintenance service was attempted.
+ ; We do this because on upgrades we will check this value and we only
+ ; want to install once on the first upgrade to maintenance service.
+ ; Also write out that we are currently installed, preferences will check
+ ; this value to determine if we should show the service update pref.
+ WriteRegDWORD HKLM "Software\Mozilla\MaintenanceService" "Attempted" 1
+ WriteRegDWORD HKLM "Software\Mozilla\MaintenanceService" "Installed" 1
+ DeleteRegValue HKLM "Software\Mozilla\MaintenanceService" "FFPrefetchDisabled"
+
+ ; Included here for debug purposes only.
+ ; These keys are used to bypass the installation dir is a valid installation
+ ; check from the service so that tests can be run.
+ ; WriteRegStr HKLM "${FallbackKey}\0" "name" "Mozilla Corporation"
+ ; WriteRegStr HKLM "${FallbackKey}\0" "issuer" "DigiCert SHA2 Assured ID Code Signing CA"
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView lastused
+ ${EndIf}
+SectionEnd
+
+; By renaming before deleting we improve things slightly in case
+; there is a file in use error. In this case a new install can happen.
+Function un.RenameDelete
+ Pop $9
+ ; If the .moz-delete file already exists previously, delete it
+ ; If it doesn't exist, the call is ignored.
+ ; We don't need to pass /REBOOTOK here since it was already marked that way
+ ; if it exists.
+ Delete "$9.moz-delete"
+ Rename "$9" "$9.moz-delete"
+ ${If} ${Errors}
+ Delete /REBOOTOK "$9"
+ ${Else}
+ Delete /REBOOTOK "$9.moz-delete"
+ ${EndIf}
+ ClearErrors
+FunctionEnd
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; NOTE: The maintenance service uninstaller does not currently get updated when
+; the service itself does during application updates. Under normal use, only
+; running the Thunderbird installer will generate a new maintenance service
+; uninstaller. That means anything added here will not be seen by users until
+; they run a new Thunderbird installer. Fixing this is tracked in
+; https://bugzilla.mozilla.org/show_bug.cgi?id=1665193
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+Section "Uninstall"
+ ; Delete the service so that no updates will be attempted
+ ExecWait '"$INSTDIR\maintenanceservice.exe" uninstall'
+
+ Push "$INSTDIR\updater.ini"
+ Call un.RenameDelete
+ Push "$INSTDIR\maintenanceservice.exe"
+ Call un.RenameDelete
+ Push "$INSTDIR\maintenanceservice_tmp.exe"
+ Call un.RenameDelete
+ Push "$INSTDIR\maintenanceservice.old"
+ Call un.RenameDelete
+ Push "$INSTDIR\Uninstall.exe"
+ Call un.RenameDelete
+ Push "$INSTDIR\update\updater.ini"
+ Call un.RenameDelete
+ Push "$INSTDIR\update\updater.exe"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-1.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-2.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-3.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-4.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-5.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-6.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-7.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-8.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-9.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-10.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-install.log"
+ Call un.RenameDelete
+ Push "$INSTDIR\logs\maintenanceservice-uninstall.log"
+ Call un.RenameDelete
+ SetShellVarContext all
+ Push "$APPDATA\Mozilla\logs\maintenanceservice.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-1.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-2.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-3.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-4.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-5.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-6.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-7.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-8.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-9.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-10.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-install.log"
+ Call un.RenameDelete
+ Push "$APPDATA\Mozilla\logs\maintenanceservice-uninstall.log"
+ Call un.RenameDelete
+ RMDir /REBOOTOK "$APPDATA\Mozilla\logs"
+ RMDir /REBOOTOK "$APPDATA\Mozilla"
+ RMDir /REBOOTOK "$INSTDIR\logs"
+ RMDir /REBOOTOK "$INSTDIR\update"
+ RMDir /REBOOTOK "$INSTDIR\UpdateLogs"
+ RMDir /REBOOTOK "$INSTDIR"
+
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView 64
+ ${EndIf}
+ DeleteRegKey HKLM "${MaintUninstallKey}"
+ DeleteRegValue HKLM "Software\Mozilla\MaintenanceService" "Installed"
+ DeleteRegValue HKLM "Software\Mozilla\MaintenanceService" "FFPrefetchDisabled"
+ DeleteRegKey HKLM "${FallbackKey}\"
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView lastused
+ ${EndIf}
+SectionEnd
diff --git a/comm/mail/installer/windows/nsis/shared.nsh b/comm/mail/installer/windows/nsis/shared.nsh
new file mode 100755
index 0000000000..8ac38ddd3b
--- /dev/null
+++ b/comm/mail/installer/windows/nsis/shared.nsh
@@ -0,0 +1,1617 @@
+# 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/.
+
+!macro PostUpdate
+ ; PostUpdate is called from both session 0 and from the user session
+ ; for service updates, make sure that we only register with the user session
+ ; Otherwise ApplicationID::Set can fail intermittently with a file in use error.
+ System::Call "kernel32::GetCurrentProcessId() i.r0"
+ System::Call "kernel32::ProcessIdToSessionId(i $0, *i ${NSIS_MAX_STRLEN} r9)"
+
+ ${CreateShortcutsLog}
+
+ ; Remove registry entries for non-existent apps and for apps that point to our
+ ; install location in the Software\Mozilla key and uninstall registry entries
+ ; that point to our install location for both HKCU and HKLM.
+ SetShellVarContext current ; Set SHCTX to the current user (e.g. HKCU)
+ ${RegCleanMain} "Software\Mozilla"
+ ${RegCleanUninstall}
+ ${UpdateProtocolHandlers}
+
+ ; setup the application model id registration value
+ ${InitHashAppModelId} "$INSTDIR" "Software\Mozilla\${AppName}\TaskBarIDs"
+
+ ; Upgrade the copies of the MAPI DLL's
+ ${UpgradeMapiDLLs}
+
+ ClearErrors
+ WriteRegStr HKLM "Software\Mozilla" "${BrandShortName}InstallerTest" "Write Test"
+ ${If} ${Errors}
+ StrCpy $TmpVal "HKCU"
+ ${Else}
+ SetShellVarContext all ; Set SHCTX to all users (e.g. HKLM)
+ DeleteRegValue HKLM "Software\Mozilla" "${BrandShortName}InstallerTest"
+ StrCpy $TmpVal "HKLM"
+ ${RegCleanMain} "Software\Mozilla"
+ ${RegCleanUninstall}
+ ${UpdateProtocolHandlers}
+ ${SetAppLSPCategories} ${LSP_CATEGORIES}
+
+ ; Only update the Clients\Mail registry key values if they don't exist or
+ ; this installation is the same as the one set in those keys.
+ ReadRegStr $0 HKLM "Software\Clients\Mail\${ClientsRegName}\DefaultIcon" ""
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${EndIf}
+ ${If} "$0" == "$INSTDIR"
+ ${SetClientsMail} "HKLM"
+ ${EndIf}
+
+ ; Only update the Clients\News registry key values if they don't exist or
+ ; this installation is the same as the one set in those keys.
+ ReadRegStr $0 HKLM "Software\Clients\News\${ClientsRegName}\DefaultIcon" ""
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${EndIf}
+ ${If} "$0" == "$INSTDIR"
+ ${SetClientsNews} "HKLM"
+ ${EndIf}
+
+ ; Only update the Clients\Calendar registry key values if they don't exist or
+ ; this installation is the same as the one set in those keys.
+ ReadRegStr $0 HKLM "Software\Clients\Calendar\${ClientsRegName}\DefaultIcon" ""
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${EndIf}
+ ${If} "$0" == "$INSTDIR"
+ ${SetClientsCalendar} "HKLM"
+ ${EndIf}
+ ${EndIf}
+
+ ; Adds a pinned Task Bar shortcut (see MigrateTaskBarShortcut for details).
+ ; When we enabled this feature for Windows 10 & 11 we decided _not_ to pin
+ ; during an update (even once) because we already offered to do when the
+ ; the user originally installed, and we don't want to go against their
+ ; explicit wishes.
+ ; For Windows 7 and 8, we've been doing this ~forever, and those users may
+ ; not have experienced the onboarding offer to pin to taskbar, so we're
+ ; leaving it enabled there.
+ ${If} ${AtMostWin2012R2}
+ ${MigrateTaskBarShortcut} "$AddTaskbarSC"
+ ${EndIf}
+
+ ; Update the name/icon/AppModelID of our shortcuts as needed, then update the
+ ; lastwritetime of the Start Menu shortcut to clear the tile icon cache.
+ ; Do this for both shell contexts in case the user has shortcuts in multiple
+ ; locations, then restore the previous context at the end.
+ SetShellVarContext all
+ ${UpdateShortcutsBranding}
+ ${If} ${AtLeastWin8}
+ ${TouchStartMenuShortcut}
+ ${EndIf}
+ Call FixShortcutAppModelIDs
+ SetShellVarContext current
+ ${UpdateShortcutsBranding}
+ ${If} ${AtLeastWin8}
+ ${TouchStartMenuShortcut}
+ ${EndIf}
+ Call FixShortcutAppModelIDs
+ ${If} $TmpVal == "HKLM"
+ SetShellVarContext all
+ ${ElseIf} $TmpVal == "HKCU"
+ SetShellVarContext current
+ ${EndIf}
+
+ ${RemoveDeprecatedKeys}
+ ${Set32to64DidMigrateReg}
+
+ ${SetAppKeys}
+ ${SetUninstallKeys}
+
+ ; Remove files that may be left behind by the application in the
+ ; VirtualStore directory.
+ ${CleanVirtualStore}
+
+ RmDir /r /REBOOTOK "$INSTDIR\${TO_BE_DELETED}"
+
+ ; Register AccessibleMarshal.dll with COM (this requires write access to HKLM)
+ ${RegisterAccessibleMarshal}
+
+ ; Record the Windows Error Reporting module
+ WriteRegDWORD HKLM "SOFTWARE\Microsoft\Windows\Windows Error Reporting\RuntimeExceptionHelperModules" "$INSTDIR\mozwer.dll" 0
+
+!ifdef MOZ_MAINTENANCE_SERVICE
+ Call IsUserAdmin
+ Pop $R0
+ ${If} $R0 == "true"
+ ; Only proceed if we have HKLM write access
+ ${AndIf} $TmpVal == "HKLM"
+ ; We check to see if the maintenance service install was already attempted.
+ ; Since the Maintenance service can be installed either x86 or x64,
+ ; always use the 64-bit registry for checking if an attempt was made.
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView 64
+ ${EndIf}
+ ReadRegDWORD $5 HKLM "Software\Mozilla\MaintenanceService" "Attempted"
+ ClearErrors
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView lastused
+ ${EndIf}
+
+ ; Add the registry keys for allowed certificates.
+ ${AddMaintCertKeys}
+
+ ; If the maintenance service is already installed, do nothing.
+ ; The maintenance service will launch:
+ ; maintenanceservice_installer.exe /Upgrade to upgrade the maintenance
+ ; service if necessary. If the update was done from updater.exe without
+ ; the service (i.e. service is failing), updater.exe will do the update of
+ ; the service. The reasons we do not do it here is because we don't want
+ ; to have to prompt for limited user accounts when the service isn't used
+ ; and we currently call the PostUpdate twice, once for the user and once
+ ; for the SYSTEM account. Also, this would stop the maintenance service
+ ; and we need a return result back to the service when run that way.
+ ${If} $5 == ""
+ ; An install of maintenance service was never attempted.
+ ; We know we are an Admin and that we have write access into HKLM
+ ; based on the above checks, so attempt to just run the EXE.
+ ; In the worst case, in case there is some edge case with the
+ ; IsAdmin check and the permissions check, the maintenance service
+ ; will just fail to be attempted to be installed.
+ nsExec::Exec "$\"$INSTDIR\maintenanceservice_installer.exe$\""
+ ${EndIf}
+ ${EndIf}
+!endif
+!macroend
+!define PostUpdate "!insertmacro PostUpdate"
+
+; Update the last modified time on the Start Menu shortcut, so that its icon
+; gets refreshed. Should be called on Win8+ after UpdateShortcutBranding.
+!macro TouchStartMenuShortcut
+ ${If} ${FileExists} "$SMPROGRAMS\${BrandShortName}.lnk"
+ FileOpen $0 "$SMPROGRAMS\${BrandShortName}.lnk" a
+ ${IfNot} ${Errors}
+ System::Call '*(i, i) p .r1'
+ System::Call 'kernel32::GetSystemTimeAsFileTime(p r1)'
+ System::Call 'kernel32::SetFileTime(p r0, i 0, i 0, p r1) i .r2'
+ System::Free $1
+ FileClose $0
+ ${EndIf}
+ ${EndIf}
+!macroend
+!define TouchStartMenuShortcut "!insertmacro TouchStartMenuShortcut"
+
+!macro SetAsDefaultAppGlobal
+ ${RemoveDeprecatedKeys} ; Does not use SHCTX
+
+ SetShellVarContext all ; Set SHCTX to all users (e.g. HKLM)
+ ${SetHandlersMail} ; Uses SHCTX
+ ${SetHandlersNews} ; Uses SHCTX
+ ${SetClientsMail} "HKLM"
+ ${SetClientsNews} "HKLM"
+ ${SetClientsCalendar} "HKLM"
+ ${SetMailClientForMapi} "HKLM"
+ ${ShowShortcuts}
+!macroend
+!define SetAsDefaultAppGlobal "!insertmacro SetAsDefaultAppGlobal"
+
+!macro SetMailClientForMapi RegKey
+ WriteRegStr ${RegKey} "Software\Clients\Mail" "" "${ClientsRegName}"
+!macroend
+!define SetMailClientForMapi "!insertmacro SetMailClientForMapi"
+
+!macro HideShortcuts
+ StrCpy $R1 "Software\Clients\Mail\${ClientsRegName}\InstallInfo"
+ WriteRegDWORD HKLM "$R1" "IconsVisible" 0
+ ${If} ${AtLeastWin8}
+ WriteRegDWORD HKCU "$R1" "IconsVisible" 0
+ ${EndIf}
+
+ SetShellVarContext all ; Set $DESKTOP to All Users
+ ${Unless} ${FileExists} "$DESKTOP\${BrandShortName}.lnk"
+ SetShellVarContext current ; Set $DESKTOP to the current user's desktop
+ ${EndUnless}
+
+ ${If} ${FileExists} "$DESKTOP\${BrandShortName}.lnk"
+ ShellLink::GetShortCutArgs "$DESKTOP\${BrandShortName}.lnk"
+ Pop $0
+ ${If} "$0" == ""
+ ShellLink::GetShortCutTarget "$DESKTOP\${BrandShortName}.lnk"
+ Pop $0
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR\${FileMainEXE}"
+ Delete "$DESKTOP\${BrandShortName}.lnk"
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+
+ SetShellVarContext all ; Set $SMPROGRAMS to All Users
+ ${Unless} ${FileExists} "$SMPROGRAMS\${BrandShortName}.lnk"
+ SetShellVarContext current ; Set $SMPROGRAMS to the current user's Start
+ ; Menu Programs directory
+ ${EndUnless}
+
+ ${If} ${FileExists} "$SMPROGRAMS\${BrandShortName}.lnk"
+ ShellLink::GetShortCutArgs "$SMPROGRAMS\${BrandShortName}.lnk"
+ Pop $0
+ ${If} "$0" == ""
+ ShellLink::GetShortCutTarget "$SMPROGRAMS\${BrandShortName}.lnk"
+ Pop $0
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR\${FileMainEXE}"
+ Delete "$SMPROGRAMS\${BrandShortName}.lnk"
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+
+ ${If} ${FileExists} "$QUICKLAUNCH\${BrandShortName}.lnk"
+ ShellLink::GetShortCutArgs "$QUICKLAUNCH\${BrandShortName}.lnk"
+ Pop $0
+ ${If} "$0" == ""
+ ShellLink::GetShortCutTarget "$QUICKLAUNCH\${BrandShortName}.lnk"
+ Pop $0
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR\${FileMainEXE}"
+ Delete "$QUICKLAUNCH\${BrandShortName}.lnk"
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+!macroend
+!define HideShortcuts "!insertmacro HideShortcuts"
+
+; Adds shortcuts for this installation. This should also add the application
+; to Open With for the file types the application handles (bug 370480).
+!macro ShowShortcuts
+ StrCpy $R1 "Software\Clients\Mail\${ClientsRegName}\InstallInfo"
+ WriteRegDWORD HKLM "$R1" "IconsVisible" 1
+ ${If} ${AtLeastWin8}
+ WriteRegDWORD HKCU "$R1" "IconsVisible" 1
+ ${EndIf}
+
+ SetShellVarContext all ; Set $DESKTOP to All Users
+ ${Unless} ${FileExists} "$DESKTOP\${BrandShortName}.lnk"
+ CreateShortCut "$DESKTOP\${BrandShortName}.lnk" "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$DESKTOP\${BrandShortName}.lnk"
+ ShellLink::SetShortCutWorkingDirectory "$DESKTOP\${BrandShortName}.lnk" "$INSTDIR"
+ ${If} ${AtLeastWin7}
+ ${AndIf} "$AppUserModelID" != ""
+ ApplicationID::Set "$DESKTOP\${BrandShortName}.lnk" "$AppUserModelID" "true"
+ ${EndIf}
+ ${Else}
+ SetShellVarContext current ; Set $DESKTOP to the current user's desktop
+ ${Unless} ${FileExists} "$DESKTOP\${BrandShortName}.lnk"
+ CreateShortCut "$DESKTOP\${BrandShortName}.lnk" "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$DESKTOP\${BrandShortName}.lnk"
+ ShellLink::SetShortCutWorkingDirectory "$DESKTOP\${BrandShortName}.lnk" \
+ "$INSTDIR"
+ ${If} ${AtLeastWin7}
+ ${AndIf} "$AppUserModelID" != ""
+ ApplicationID::Set "$DESKTOP\${BrandShortName}.lnk" "$AppUserModelID" "true"
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+ ${EndIf}
+ ${EndUnless}
+
+ SetShellVarContext all ; Set $SMPROGRAMS to All Users
+ ${Unless} ${FileExists} "$SMPROGRAMS\${BrandShortName}.lnk"
+ CreateShortCut "$SMPROGRAMS\${BrandShortName}.lnk" "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$SMPROGRAMS\${BrandShortName}.lnk"
+ ShellLink::SetShortCutWorkingDirectory "$SMPROGRAMS\${BrandShortName}.lnk" \
+ "$INSTDIR"
+ ${If} ${AtLeastWin7}
+ ${AndIf} "$AppUserModelID" != ""
+ ApplicationID::Set "$SMPROGRAMS\${BrandShortName}.lnk" "$AppUserModelID" "true"
+ ${EndIf}
+ ${Else}
+ SetShellVarContext current ; Set $SMPROGRAMS to the current user's Start
+ ; Menu Programs directory
+ ${Unless} ${FileExists} "$SMPROGRAMS\${BrandShortName}.lnk"
+ CreateShortCut "$SMPROGRAMS\${BrandShortName}.lnk" "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$SMPROGRAMS\${BrandShortName}.lnk"
+ ShellLink::SetShortCutWorkingDirectory "$SMPROGRAMS\${BrandShortName}.lnk" \
+ "$INSTDIR"
+ ${If} ${AtLeastWin7}
+ ${AndIf} "$AppUserModelID" != ""
+ ApplicationID::Set "$SMPROGRAMS\${BrandShortName}.lnk" "$AppUserModelID" "true"
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+ ${EndIf}
+ ${EndUnless}
+
+ ; Windows 7 doesn't use the QuickLaunch directory
+ ${Unless} ${AtLeastWin7}
+ ${AndUnless} ${FileExists} "$QUICKLAUNCH\${BrandShortName}.lnk"
+ CreateShortCut "$QUICKLAUNCH\${BrandShortName}.lnk" \
+ "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$QUICKLAUNCH\${BrandShortName}.lnk"
+ ShellLink::SetShortCutWorkingDirectory "$QUICKLAUNCH\${BrandShortName}.lnk" \
+ "$INSTDIR"
+ ${EndIf}
+ ${EndUnless}
+!macroend
+!define ShowShortcuts "!insertmacro ShowShortcuts"
+
+; Update the branding name on all shortcuts our installer created
+; to convert from BrandFullName (which is what we used to name shortcuts)
+; to BrandShortName (which is what we now name shortcuts). We only rename
+; desktop and start menu shortcuts, because touching taskbar pins often
+; (but inconsistently) triggers various broken behaviors in the shell.
+; This assumes SHCTX is set correctly.
+!macro UpdateShortcutsBranding
+ ${UpdateOneShortcutBranding} "STARTMENU" "$SMPROGRAMS"
+ ${UpdateOneShortcutBranding} "DESKTOP" "$DESKTOP"
+!macroend
+!define UpdateShortcutsBranding "!insertmacro UpdateShortcutsBranding"
+
+!macro UpdateOneShortcutBranding LOG_SECTION SHORTCUT_DIR
+ ; Only try to rename the shortcuts found in the shortcuts log, to avoid
+ ; blowing away a name that the user created.
+ ${GetLongPath} "$INSTDIR\uninstall\${SHORTCUTS_LOG}" $R9
+ ${If} ${FileExists} "$R9"
+ ClearErrors
+ ; The shortcuts log contains a numbered list of entries for each section,
+ ; but we never actually create more than one.
+ ReadINIStr $R8 "$R9" "${LOG_SECTION}" "Shortcut0"
+ ${IfNot} ${Errors}
+ ${If} ${FileExists} "${SHORTCUT_DIR}\$R8"
+ ShellLink::GetShortCutTarget "${SHORTCUT_DIR}\$R8"
+ Pop $R7
+ ${GetLongPath} "$R7" $R7
+ ${If} $R7 == "$INSTDIR\${FileMainEXE}"
+ ${AndIf} $R8 != "${BrandShortName}.lnk"
+ ${AndIfNot} ${FileExists} "${SHORTCUT_DIR}\${BrandShortName}.lnk"
+ ClearErrors
+ Rename "${SHORTCUT_DIR}\$R8" "${SHORTCUT_DIR}\${BrandShortName}.lnk"
+ ${IfNot} ${Errors}
+ ; Update the shortcut log manually instead of calling LogShortcut
+ ; because it would add a Shortcut1 entry, and we really do want to
+ ; overwrite the existing entry 0, since we just renamed the file.
+ WriteINIStr "$R9" "${LOG_SECTION}" "Shortcut0" \
+ "${BrandShortName}.lnk"
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+!macroend
+!define UpdateOneShortcutBranding "!insertmacro UpdateOneShortcutBranding"
+
+!macro SetHandlersMail
+ ${GetLongPath} "$INSTDIR\${FileMainEXE}" $8
+
+ StrCpy $0 "SOFTWARE\Classes"
+ StrCpy $1 "$\"$8$\" $\"%1$\""
+ StrCpy $2 "$\"$8$\" -osint -compose $\"%1$\""
+
+ ; An empty string is used for the 5th param because ThunderbirdEML is not a
+ ; protocol handler
+ ${AddHandlerValues} "$0\ThunderbirdEML" "$1" "$8,0" \
+ "${AppRegNameMail} Document" "" ""
+ ${AddHandlerValues} "$0\Thunderbird.Url.mailto" "$2" "$8,0" "${AppRegNameMail} URL" "delete" ""
+ ${AddHandlerValues} "$0\mailto" "$2" "$8,0" "${AppRegNameMail} URL" "true" ""
+ ${AddHandlerValues} "$0\Thunderbird.Url.mid" "$1" "$8,0" "${AppRegNameMail} URL" "delete" ""
+ ${AddHandlerValues} "$0\mid" "$1" "$8,0" "${AppRegNameMail} URL" "true" ""
+
+ ; Associate the file handlers with ThunderbirdEML
+ ReadRegStr $6 SHCTX ".eml" ""
+ ${If} "$6" != "ThunderbirdEML"
+ WriteRegStr SHCTX "$0\.eml" "" "ThunderbirdEML"
+ ${EndIf}
+!macroend
+!define SetHandlersMail "!insertmacro SetHandlersMail"
+
+!macro SetHandlersNews
+ ${GetLongPath} "$INSTDIR\${FileMainEXE}" $8
+ StrCpy $0 "SOFTWARE\Classes"
+ StrCpy $1 "$\"$8$\" -osint -mail $\"%1$\""
+
+ ${AddHandlerValues} "$0\Thunderbird.Url.news" "$1" "$8,0" \
+ "${AppRegNameNews} URL" "delete" ""
+ ${AddHandlerValues} "$0\news" "$1" "$8,0" "${AppRegNameNews} URL" "true" ""
+ ${AddHandlerValues} "$0\nntp" "$1" "$8,0" "${AppRegNameNews} URL" "true" ""
+ ${AddHandlerValues} "$0\snews" "$1" "$8,0" "${AppRegNameNews} URL" "true" ""
+!macroend
+!define SetHandlersNews "!insertmacro SetHandlersNews"
+
+!macro SetHandlersCalendar
+ ${GetLongPath} "$INSTDIR\${FileMainEXE}" $8
+
+ StrCpy $0 "SOFTWARE\Classes"
+ StrCpy $1 "$\"$8$\" $\"%1$\""
+
+ ${AddHandlerValues} "$0\Thunderbird.Url.webcal" "$1" "$8,0" "${AppRegNameCalendar} URL" "delete" ""
+ ${AddHandlerValues} "$0\webcal" "$1" "$8,0" "${AppRegNameCalendar} URL" "true" ""
+ ${AddHandlerValues} "$0\webcals" "$1" "$8,0" "${AppRegNameCalendar} URL" "true" ""
+ ; An empty string is used for the 5th param because ThunderbirdICS is not a
+ ; protocol handler
+ ${AddHandlerValues} "$0\ThunderbirdICS" "$1" "$8,0" \
+ "${AppRegNameCalendar} Document" "" ""
+
+ ;; Associate the file handlers with ThunderbirdICS
+ ReadRegStr $6 SHCTX ".ics" ""
+ ${If} "$6" != "ThunderbirdICS"
+ WriteRegStr SHCTX "$0\.ics" "" "ThunderbirdICS"
+ ${EndIf}
+!macroend
+!define SetHandlersCalendar "!insertmacro SetHandlersCalendar"
+
+; XXXrstrong - there are several values that will be overwritten by and
+; overwrite other installs of the same application.
+!macro SetClientsMail RegKey
+ ${GetLongPath} "$INSTDIR\${FileMainEXE}" $8
+ ${GetLongPath} "$INSTDIR\uninstall\helper.exe" $7
+ ${GetLongPath} "$INSTDIR\mozMapi32_InUse.dll" $6
+
+ StrCpy $0 "Software\Clients\Mail\${ClientsRegName}"
+
+ WriteRegStr ${RegKey} "$0" "" "${ClientsRegName}"
+ WriteRegStr ${RegKey} "$0\DefaultIcon" "" "$8,0"
+ WriteRegStr ${RegKey} "$0" "DLLPath" "$6"
+
+ ; The MapiProxy dll can exist in multiple installs of the application.
+ ; Registration occurs as follows with the last action to occur being the one
+ ; that wins:
+ ; On install and software update when helper.exe runs with the /PostUpdate
+ ; argument. On setting the application as the system's default application
+ ; using Window's "Set program access and defaults".
+
+ !ifndef NO_LOG
+ ${LogHeader} "DLL Registration"
+ !endif
+ ClearErrors
+ ${RegisterDLL} "$INSTDIR\MapiProxy_InUse.dll"
+ !ifndef NO_LOG
+ ${If} ${Errors}
+ ${LogMsg} "** ERROR Registering: $INSTDIR\MapiProxy_InUse.dll **"
+ ${Else}
+ ${LogUninstall} "DLLReg: \MapiProxy_InUse.dll"
+ ${LogMsg} "Registered: $INSTDIR\MapiProxy_InUse.dll"
+ ${EndIf}
+ !endif
+
+ StrCpy $1 "Software\Classes\CLSID\{29F458BE-8866-11D5-A3DD-00B0D0F3BAA7}"
+ WriteRegStr ${RegKey} "$1\LocalServer32" "" "$\"$8$\" /MAPIStartup"
+ WriteRegStr ${RegKey} "$1\ProgID" "" "MozillaMapi.1"
+ WriteRegStr ${RegKey} "$1\VersionIndependentProgID" "" "MozillaMAPI"
+ StrCpy $1 "SOFTWARE\Classes"
+ WriteRegStr ${RegKey} "$1\MozillaMapi" "" "Mozilla MAPI"
+ WriteRegStr ${RegKey} "$1\MozillaMapi\CLSID" "" "{29F458BE-8866-11D5-A3DD-00B0D0F3BAA7}"
+ WriteRegStr ${RegKey} "$1\MozillaMapi\CurVer" "" "MozillaMapi.1"
+ WriteRegStr ${RegKey} "$1\MozillaMapi.1" "" "Mozilla MAPI"
+ WriteRegStr ${RegKey} "$1\MozillaMapi.1\CLSID" "" "{29F458BE-8866-11D5-A3DD-00B0D0F3BAA7}"
+
+ ; The Reinstall Command is defined at
+ ; http://msdn.microsoft.com/library/default.asp?url=/library/en-us/shellcc/platform/shell/programmersguide/shell_adv/registeringapps.asp
+ WriteRegStr ${RegKey} "$0\InstallInfo" "HideIconsCommand" "$\"$7$\" /HideShortcuts"
+ WriteRegStr ${RegKey} "$0\InstallInfo" "ShowIconsCommand" "$\"$7$\" /ShowShortcuts"
+ WriteRegStr ${RegKey} "$0\InstallInfo" "ReinstallCommand" "$\"$7$\" /SetAsDefaultAppGlobal"
+
+ ClearErrors
+ ReadRegDWORD $1 ${RegKey} "$0\InstallInfo" "IconsVisible"
+ ; If the IconsVisible name value pair doesn't exist add it otherwise the
+ ; application won't be displayed in Set Program Access and Defaults.
+ ${If} ${Errors}
+ ${If} ${FileExists} "$QUICKLAUNCH\${BrandShortName}.lnk"
+ WriteRegDWORD ${RegKey} "$0\InstallInfo" "IconsVisible" 1
+ ${Else}
+ WriteRegDWORD ${RegKey} "$0\InstallInfo" "IconsVisible" 0
+ ${EndIf}
+ ${EndIf}
+
+ WriteRegStr ${RegKey} "$0\shell\open\command" "" "$\"$8$\" -mail"
+
+ WriteRegStr ${RegKey} "$0\shell\properties" "" "$(CONTEXT_OPTIONS)"
+ WriteRegStr ${RegKey} "$0\shell\properties\command" "" "$\"$8$\" -options"
+
+ WriteRegStr ${RegKey} "$0\shell\safemode" "" "$(CONTEXT_SAFE_MODE)"
+ WriteRegStr ${RegKey} "$0\shell\safemode\command" "" "$\"$8$\" -safe-mode"
+
+ ; Protocols
+ StrCpy $1 "$\"$8$\" -osint -compose $\"%1$\""
+ StrCpy $2 "$\"$8$\" $\"%1$\""
+ ${AddHandlerValues} "$0\Protocols\mailto" "$1" "$8,0" "${AppRegNameMail} URL" "true" ""
+ ${AddHandlerValues} "$0\Protocols\mid" "$2" "$8,0" "${AppRegNameMail} URL" "true" ""
+
+ ; Capabilities registry keys
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationDescription" "$(REG_APP_DESC)"
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationIcon" "$8,0"
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationName" "${AppRegNameMail}"
+ WriteRegStr ${RegKey} "$0\Capabilities\FileAssociations" ".eml" "ThunderbirdEML"
+ WriteRegStr ${RegKey} "$0\Capabilities\FileAssociations" ".wdseml" "ThunderbirdEML"
+ WriteRegStr ${RegKey} "$0\Capabilities\StartMenu" "Mail" "${ClientsRegName}"
+ WriteRegStr ${RegKey} "$0\Capabilities\URLAssociations" "mailto" "Thunderbird.Url.mailto"
+ WriteRegStr ${RegKey} "$0\Capabilities\URLAssociations" "mid" "Thunderbird.Url.mid"
+
+ ; Registered Application
+ WriteRegStr ${RegKey} "Software\RegisteredApplications" "${AppRegNameMail}" "$0\Capabilities"
+!macroend
+!define SetClientsMail "!insertmacro SetClientsMail"
+
+; Add registry keys to support the Thunderbird 32 bit to 64 bit migration.
+; These registry entries are not removed on uninstall at this time. After the
+; Thunderbird 32 bit to 64 bit migration effort is completed these registry
+; entries can be removed during install, post update, and uninstall.
+!macro Set32to64DidMigrateReg
+ ${GetLongPath} "$INSTDIR" $1
+ ; These registry keys are always in the 32 bit hive since they are never
+ ; needed by a Thunderbird 64 bit install unless it has been updated from
+ ; Thunderbird 32 bit.
+ SetRegView 32
+
+!ifdef HAVE_64BIT_BUILD
+
+ ; Running Thunderbird 64 bit on Windows 64 bit
+ ClearErrors
+ ReadRegDWORD $2 HKLM "Software\Mozilla\${AppName}\32to64DidMigrate" "$1"
+ ; If there were no errors then the system was updated from Thunderbird 32 bit
+ ; to Thunderbird 64 bit and if the value is already 1 then the registry value
+ ; has already been updated in the HKLM registry.
+ ${IfNot} ${Errors}
+ ${AndIf} $2 != 1
+ ClearErrors
+ WriteRegDWORD HKLM "Software\Mozilla\${AppName}\32to64DidMigrate" "$1" 1
+ ${If} ${Errors}
+ ; There was an error writing to HKLM so just write it to HKCU
+ WriteRegDWORD HKCU "Software\Mozilla\${AppName}\32to64DidMigrate" "$1" 1
+ ${Else}
+ ; This will delete the value from HKCU if it exists
+ DeleteRegValue HKCU "Software\Mozilla\${AppName}\32to64DidMigrate" "$1"
+ ${EndIf}
+ ${EndIf}
+
+ ClearErrors
+ ReadRegDWORD $2 HKCU "Software\Mozilla\${AppName}\32to64DidMigrate" "$1"
+ ; If there were no errors then the system was updated from Thunderbird 32 bit
+ ; to Thunderbird 64 bit and if the value is already 1 then the registry value
+ ; has already been updated in the HKCU registry.
+ ${IfNot} ${Errors}
+ ${AndIf} $2 != 1
+ WriteRegDWORD HKCU "Software\Mozilla\${AppName}\32to64DidMigrate" "$1" 1
+ ${EndIf}
+
+!else
+
+ ; Running Thunderbird 32 bit
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ ; Running Thunderbird 32 bit on a Windows 64 bit system
+ ClearErrors
+ ReadRegDWORD $2 HKLM "Software\Mozilla\${AppName}\32to64DidMigrate" "$1"
+ ; If there were errors the value doesn't exist yet.
+ ${If} ${Errors}
+ ClearErrors
+ WriteRegDWORD HKLM "Software\Mozilla\${AppName}\32to64DidMigrate" "$1" 0
+ ; If there were errors write the value in HKCU.
+ ${If} ${Errors}
+ WriteRegDWORD HKCU "Software\Mozilla\${AppName}\32to64DidMigrate" "$1" 0
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+
+!endif
+
+ ClearErrors
+ SetRegView lastused
+!macroend
+!define Set32to64DidMigrateReg "!insertmacro Set32to64DidMigrateReg"
+
+; XXXrstrong - there are several values that will be overwritten by and
+; overwrite other installs of the same application.
+!macro SetClientsNews RegKey
+ ${GetLongPath} "$INSTDIR\${FileMainEXE}" $8
+ ${GetLongPath} "$INSTDIR\uninstall\helper.exe" $7
+ ${GetLongPath} "$INSTDIR\mozMapi32_InUse.dll" $6
+
+ StrCpy $0 "Software\Clients\News\${ClientsRegName}"
+
+ WriteRegStr ${RegKey} "$0" "" "${ClientsRegName}"
+ WriteRegStr ${RegKey} "$0\DefaultIcon" "" "$8,0"
+ WriteRegStr ${RegKey} "$0" "DLLPath" "$6"
+
+ ; The MapiProxy dll can exist in multiple installs of the application.
+ ; Registration occurs as follows with the last action to occur being the one
+ ; that wins:
+ ; On install and software update when helper.exe runs with the /PostUpdate
+ ; argument. On setting the application as the system's default application
+ ; using Window's "Set program access and defaults".
+
+ !ifndef NO_LOG
+ ${LogHeader} "DLL Registration"
+ !endif
+ ClearErrors
+ ${RegisterDLL} "$INSTDIR\MapiProxy_InUse.dll"
+ !ifndef NO_LOG
+ ${If} ${Errors}
+ ${LogMsg} "** ERROR Registering: $INSTDIR\MapiProxy_InUse.dll **"
+ ${Else}
+ ${LogUninstall} "DLLReg: \MapiProxy_InUse.dll"
+ ${LogMsg} "Registered: $INSTDIR\MapiProxy_InUse.dll"
+ ${EndIf}
+ !endif
+
+ StrCpy $1 "Software\Classes\CLSID\{29F458BE-8866-11D5-A3DD-00B0D0F3BAA7}"
+ WriteRegStr ${RegKey} "$1\LocalServer32" "" "$\"$8$\" /MAPIStartup"
+ WriteRegStr ${RegKey} "$1\ProgID" "" "MozillaMapi.1"
+ WriteRegStr ${RegKey} "$1\VersionIndependentProgID" "" "MozillaMAPI"
+ StrCpy $1 "SOFTWARE\Classes"
+ WriteRegStr ${RegKey} "$1\MozillaMapi" "" "Mozilla MAPI"
+ WriteRegStr ${RegKey} "$1\MozillaMapi\CLSID" "" "{29F458BE-8866-11D5-A3DD-00B0D0F3BAA7}"
+ WriteRegStr ${RegKey} "$1\MozillaMapi\CurVer" "" "MozillaMapi.1"
+ WriteRegStr ${RegKey} "$1\MozillaMapi.1" "" "Mozilla MAPI"
+ WriteRegStr ${RegKey} "$1\MozillaMapi.1\CLSID" "" "{29F458BE-8866-11D5-A3DD-00B0D0F3BAA7}"
+
+ ; Mail shell/open/command
+ WriteRegStr ${RegKey} "$0\shell\open\command" "" "$\"$8$\" -mail"
+
+ ; Capabilities registry keys
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationDescription" "$(REG_APP_DESC)"
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationIcon" "$8,0"
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationName" "${AppRegNameNews}"
+ WriteRegStr ${RegKey} "$0\Capabilities\URLAssociations" "nntp" "Thunderbird.Url.news"
+ WriteRegStr ${RegKey} "$0\Capabilities\URLAssociations" "news" "Thunderbird.Url.news"
+ WriteRegStr ${RegKey} "$0\Capabilities\URLAssociations" "snews" "Thunderbird.Url.news"
+
+ ; Protocols
+ StrCpy $1 "$\"$8$\" -osint -mail $\"%1$\""
+ ${AddHandlerValues} "$0\Protocols\nntp" "$1" "$8,0" "${AppRegNameNews} URL" "true" ""
+ ${AddHandlerValues} "$0\Protocols\news" "$1" "$8,0" "${AppRegNameNews} URL" "true" ""
+ ${AddHandlerValues} "$0\Protocols\snews" "$1" "$8,0" "${AppRegNameNews} URL" "true" ""
+
+ ; Registered Application
+ WriteRegStr ${RegKey} "Software\RegisteredApplications" "${AppRegNameNews}" "$0\Capabilities"
+!macroend
+!define SetClientsNews "!insertmacro SetClientsNews"
+
+!macro SetClientsCalendar RegKey
+ ${GetLongPath} "$INSTDIR\${FileMainEXE}" $8
+ ${GetLongPath} "$INSTDIR\uninstall\helper.exe" $7
+ ${GetLongPath} "$INSTDIR\mozMapi32_InUse.dll" $6
+
+ StrCpy $0 "Software\Clients\Calendar\${ClientsRegName}"
+
+ WriteRegStr ${RegKey} "$0" "" "${ClientsRegName}"
+ WriteRegStr ${RegKey} "$0\DefaultIcon" "" "$8,0"
+ WriteRegStr ${RegKey} "$0" "DLLPath" "$6"
+
+ WriteRegStr ${RegKey} "$0\shell\open\command" "" "$\"$8$\""
+
+ WriteRegStr ${RegKey} "$0\shell\properties" "" "$(CONTEXT_OPTIONS)"
+ WriteRegStr ${RegKey} "$0\shell\properties\command" "" "$\"$8$\" -options"
+
+ WriteRegStr ${RegKey} "$0\shell\safemode" "" "$(CONTEXT_SAFE_MODE)"
+ WriteRegStr ${RegKey} "$0\shell\safemode\command" "" "$\"$8$\" -safe-mode"
+
+ ; Protocols
+ StrCpy $1 "$\"$8$\" $\"%1$\""
+ ${AddHandlerValues} "$0\Protocols\webcal" "$1" "$8,0" "${AppRegNameCalendar} URL" "true" ""
+ ${AddHandlerValues} "$0\Protocols\webcals" "$1" "$8,0" "${AppRegNameCalendar} URL" "true" ""
+
+ ; Capabilities registry keys
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationDescription" "$(REG_APP_DESC)"
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationIcon" "$8,0"
+ WriteRegStr ${RegKey} "$0\Capabilities" "ApplicationName" "${AppRegNameCalendar}"
+ WriteRegStr ${RegKey} "$0\Capabilities\FileAssociations" ".ics" "ThunderbirdICS"
+ WriteRegStr ${RegKey} "$0\Capabilities\URLAssociations" "webcal" "Thunderbird.Url.webcal"
+ WriteRegStr ${RegKey} "$0\Capabilities\URLAssociations" "webcals" "Thunderbird.Url.webcal"
+
+ ; Registered Application
+ WriteRegStr ${RegKey} "Software\RegisteredApplications" "${AppRegNameCalendar}" "$0\Capabilities"
+!macroend
+!define SetClientsCalendar "!insertmacro SetClientsCalendar"
+
+; Add Software\Mozilla\ registry entries (uses SHCTX).
+!macro SetAppKeys
+ ${GetLongPath} "$INSTDIR" $8
+ StrCpy $0 "Software\Mozilla\${BrandFullNameInternal}\${AppVersion} (${AB_CD})\Main"
+ ${WriteRegStr2} $TmpVal "$0" "Install Directory" "$8" 0
+ ${WriteRegStr2} $TmpVal "$0" "PathToExe" "$8\${FileMainEXE}" 0
+
+ StrCpy $0 "Software\Mozilla\${BrandFullNameInternal}\${AppVersion} (${AB_CD})\Uninstall"
+ ${WriteRegStr2} $TmpVal "$0" "Description" "${BrandFullNameInternal} ${AppVersion} (${ARCH} ${AB_CD})" 0
+
+ StrCpy $0 "Software\Mozilla\${BrandFullNameInternal}\${AppVersion} (${AB_CD})"
+ ${WriteRegStr2} $TmpVal "$0" "" "${AppVersion} (${AB_CD})" 0
+
+ StrCpy $0 "Software\Mozilla\${BrandFullNameInternal} ${AppVersion}\bin"
+ ${WriteRegStr2} $TmpVal "$0" "PathToExe" "$8\${FileMainEXE}" 0
+
+ StrCpy $0 "Software\Mozilla\${BrandFullNameInternal} ${AppVersion}\extensions"
+ ${WriteRegStr2} $TmpVal "$0" "Components" "$8\components" 0
+ ${WriteRegStr2} $TmpVal "$0" "Plugins" "$8\plugins" 0
+
+ StrCpy $0 "Software\Mozilla\${BrandFullNameInternal} ${AppVersion}"
+ ${WriteRegStr2} $TmpVal "$0" "GeckoVer" "${GREVersion}" 0
+
+ StrCpy $0 "Software\Mozilla\${BrandFullNameInternal}"
+ ${WriteRegStr2} $TmpVal "$0" "" "${AppVersion}" 0
+ ${WriteRegStr2} $TmpVal "$0" "CurrentVersion" "${AppVersion} (${AB_CD})" 0
+ ${WriteRegStr2} $TmpVal "$0" "GeckoVersion" "${GREVersion}" 0
+!macroend
+!define SetAppKeys "!insertmacro SetAppKeys"
+
+; Add uninstall registry entries. This macro tests for write access to determine
+; if the uninstall keys should be added to HKLM or HKCU.
+!macro SetUninstallKeys
+ StrCpy $0 "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BrandFullNameInternal} ${AppVersion} (${ARCH} ${AB_CD})"
+
+ StrCpy $2 ""
+ ClearErrors
+ WriteRegStr HKLM "$0" "${BrandShortName}InstallerTest" "Write Test"
+ ${If} ${Errors}
+ ; If the uninstall keys already exist in HKLM don't create them in HKCU
+ ClearErrors
+ ReadRegStr $2 "HKLM" $0 "DisplayName"
+ ${If} $2 == ""
+ ; Otherwise we don't have any keys for this product in HKLM so proceed
+ ; to create them in HKCU. Better handling for this will be done in:
+ ; Bug 711044 - Better handling for 2 uninstall icons
+ StrCpy $1 "HKCU"
+ SetShellVarContext current ; Set SHCTX to the current user (e.g. HKCU)
+ ${EndIf}
+ ClearErrors
+ ${Else}
+ StrCpy $1 "HKLM"
+ SetShellVarContext all ; Set SHCTX to all users (e.g. HKLM)
+ DeleteRegValue HKLM "$0" "${BrandShortName}InstallerTest"
+ ${EndIf}
+
+ ${If} $2 == ""
+ ${GetLongPath} "$INSTDIR" $8
+
+
+ ; Write the uninstall registry keys
+ ${WriteRegStr2} $1 "$0" "Comments" "${BrandFullNameInternal} ${AppVersion} (${ARCH} ${AB_CD})" 0
+ ${WriteRegStr2} $1 "$0" "DisplayIcon" "$8\${FileMainEXE},0" 0
+ ${WriteRegStr2} $1 "$0" "DisplayName" "${BrandFullNameInternal} (${ARCH} ${AB_CD})" 0
+ ${WriteRegStr2} $1 "$0" "DisplayVersion" "${AppVersion}" 0
+ ${WriteRegStr2} $1 "$0" "InstallLocation" "$8" 0
+ ${WriteRegStr2} $1 "$0" "Publisher" "Mozilla" 0
+ ${WriteRegStr2} $1 "$0" "UninstallString" "$\"$8\uninstall\helper.exe$\"" 0
+ ${WriteRegStr2} $1 "$0" "URLInfoAbout" "${URLInfoAbout}" 0
+ ${WriteRegStr2} $1 "$0" "URLUpdateInfo" "${URLUpdateInfo}" 0
+ ${WriteRegDWORD2} $1 "$0" "NoModify" 1 0
+ ${WriteRegDWORD2} $1 "$0" "NoRepair" 1 0
+
+ ${GetSize} "$8" "/S=0K" $R2 $R3 $R4
+ ${WriteRegDWORD2} $1 "$0" "EstimatedSize" $R2 0
+
+ ${If} "$TmpVal" == "HKLM"
+ SetShellVarContext all ; Set SHCTX to all users (e.g. HKLM)
+ ${Else}
+ SetShellVarContext current ; Set SHCTX to the current user (e.g. HKCU)
+ ${EndIf}
+ ${EndIf}
+!macroend
+!define SetUninstallKeys "!insertmacro SetUninstallKeys"
+
+; Updates protocol handlers if their registry open command value is for this
+; install location (uses SHCTX).
+!macro UpdateProtocolHandlers
+ ; Store the command to open the app with an url in a register for easy access.
+ ${GetLongPath} "$INSTDIR\${FileMainEXE}" $8
+ StrCpy $0 "SOFTWARE\Classes"
+ StrCpy $1 "$\"$8$\" -osint -compose $\"%1$\""
+ StrCpy $2 "$\"$8$\" -osint -mail $\"%1$\""
+ StrCpy $3 "$\"$8$\" $\"%1$\""
+
+ ; Only set the file and protocol handlers if the existing one under HKCR is
+ ; for this install location.
+ ${IsHandlerForInstallDir} "ThunderbirdEML" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\ThunderbirdEML" "$3" "$8,0" \
+ "${AppRegNameMail} Document" "" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "Thunderbird.Url.mailto" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\Thunderbird.Url.mailto" "$1" "$8,0" \
+ "${AppRegNameMail} URL" "delete" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "mailto" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\mailto" "$1" "$8,0" "" "" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "Thunderbird.Url.mid" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\Thunderbird.Url.mid" "$3" "$8,0" \
+ "${AppRegNameMail} URL" "delete" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "mid" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\mid" "$3" "$8,0" "" "" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "Thunderbird.Url.news" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\Thunderbird.Url.news" "$2" "$8,0" \
+ "${AppRegNameNews} URL" "delete" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "news" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\news" "$2" "$8,0" "" "" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "snews" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\snews" "$2" "$8,0" "" "" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "nntp" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\nntp" "$2" "$8,0" "" "" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "Thunderbird.Url.webcal" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\Thunderbird.Url.webcal" "$3" "$8,0" \
+ "${AppRegNameCalendar} URL" "delete" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "webcal" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\webcal" "$3" "$8,0" "" "" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "webcals" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\webcals" "$3" "$8,0" "" "" ""
+ ${EndIf}
+
+ ${IsHandlerForInstallDir} "ThunderbirdICS" $R9
+ ${If} "$R9" == "true"
+ ${AddHandlerValues} "SOFTWARE\Classes\ThunderbirdICS" "$3" "$8,0" \
+ "${AppRegNameCalendar} Document" "" ""
+ ${EndIf}
+!macroend
+!define UpdateProtocolHandlers "!insertmacro UpdateProtocolHandlers"
+
+!ifdef MOZ_MAINTENANCE_SERVICE
+; Adds maintenance service certificate keys for the install dir.
+; For the cert to work, it must also be signed by a trusted cert for the user.
+!macro AddMaintCertKeys
+ Push $R0
+ ; Allow main Mozilla cert information for updates
+ ; This call will push the needed key on the stack
+ ServicesHelper::PathToUniqueRegistryPath "$INSTDIR"
+ Pop $R0
+ ${If} $R0 != ""
+ ; More than one certificate can be specified in a different subfolder
+ ; for example: $R0\1, but each individual binary can be signed
+ ; with at most one certificate. A fallback certificate can only be used
+ ; if the binary is replaced with a different certificate.
+ ; We always use the 64bit registry for certs.
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView 64
+ ${EndIf}
+ DeleteRegKey HKLM "$R0"
+
+ ; Setting the Attempted value will ensure that a new Maintenance Service
+ ; install will never be attempted again after this from updates. The value
+ ; is used only to see if updates should attempt new service installs.
+ WriteRegDWORD HKLM "Software\Mozilla\MaintenanceService" "Attempted" 1
+
+ ; These values associate the allowed certificates for the current
+ ; installation.
+ WriteRegStr HKLM "$R0\0" "name" "${CERTIFICATE_NAME}"
+ WriteRegStr HKLM "$R0\0" "issuer" "${CERTIFICATE_ISSUER}"
+ ; These values associate the allowed certificates for the previous
+ ; installation, so that we can update from it cleanly using the
+ ; old updater.exe (which will still have this signature).
+ WriteRegStr HKLM "$R0\1" "name" "${CERTIFICATE_NAME_PREVIOUS}"
+ WriteRegStr HKLM "$R0\1" "issuer" "${CERTIFICATE_ISSUER_PREVIOUS}"
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView lastused
+ ${EndIf}
+ ClearErrors
+ ${EndIf}
+ ; Restore the previously used value back
+ Pop $R0
+!macroend
+!define AddMaintCertKeys "!insertmacro AddMaintCertKeys"
+!endif
+
+!macro RegisterAccessibleMarshal
+ ${RegisterDLL} "$INSTDIR\AccessibleMarshal.dll"
+!macroend
+!define RegisterAccessibleMarshal "!insertmacro RegisterAccessibleMarshal"
+
+; Removes various registry entries for reasons noted below (does not use SHCTX).
+!macro RemoveDeprecatedKeys
+ StrCpy $0 "SOFTWARE\Classes"
+
+ ; remove DI and SOC from the .eml class if it exists and contains
+ ; thunderbird.exe
+ ClearErrors
+ ReadRegStr $1 HKLM "$0\.eml\shell\open\command" ""
+ ${WordFind} "$1" "${FileMainEXE}" "E+1{" $R1
+ ${Unless} ${Errors}
+ DeleteRegKey HKLM "$0\.eml\shell\open\command"
+ ${EndUnless}
+
+ ClearErrors
+ ReadRegStr $1 HKCU "$0\.eml\shell\open\command" ""
+ ${WordFind} "$1" "${FileMainEXE}" "E+1{" $R1
+ ${Unless} ${Errors}
+ DeleteRegKey HKCU "$0\.eml\shell\open\command"
+ ${EndUnless}
+
+ ClearErrors
+ ReadRegStr $1 HKLM "$0\.eml\DefaultIcon" ""
+ ${WordFind} "$1" "${FileMainEXE}" "E+1{" $R1
+ ${Unless} ${Errors}
+ DeleteRegKey HKLM "$0\.eml\DefaultIcon"
+ ${EndUnless}
+
+ ClearErrors
+ ReadRegStr $1 HKCU "$0\.eml\DefaultIcon" ""
+ ${WordFind} "$1" "${FileMainEXE}" "E+1{" $R1
+ ${Unless} ${Errors}
+ DeleteRegKey HKCU "$0\.eml\DefaultIcon"
+ ${EndUnless}
+
+ ; Remove the Shredder clients key if its default icon contains thunderbird.exe
+ ClearErrors
+ ReadRegStr $1 HKLM "SOFTWARE\clients\mail\Shredder\DefaultIcon" ""
+ ${WordFind} "$1" "${FileMainEXE}" "E+1{" $R1
+ ${Unless} ${Errors}
+ DeleteRegKey HKLM "SOFTWARE\clients\mail\Shredder"
+ ${EndUnless}
+
+ ClearErrors
+ ReadRegStr $1 HKLM "SOFTWARE\clients\news\Shredder\DefaultIcon" ""
+ ${WordFind} "$1" "${FileMainEXE}" "E+1{" $R1
+ ${Unless} ${Errors}
+ DeleteRegKey HKLM "SOFTWARE\clients\news\Shredder"
+ ${EndUnless}
+
+ ; The shim for 1.5.0.10 writes out a set of bogus keys which we need to
+ ; cleanup. Intentionally hard coding Mozilla Thunderbird here
+ ; as this is the string used by the shim.
+ DeleteRegKey HKLM "$0\Mozilla Thunderbird.Url.mailto"
+ DeleteRegValue HKLM "Software\RegisteredApplications" "Mozilla Thunderbird"
+
+ ; Remove the app compatibility registry key
+ StrCpy $0 "Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers"
+ DeleteRegValue HKLM "$0" "$INSTDIR\${FileMainEXE}"
+ DeleteRegValue HKCU "$0" "$INSTDIR\${FileMainEXE}"
+
+ ; Remove the SupportUTF8 registry value as it causes MAPI issues on some locales
+ ; with non-ASCII characters in file names.
+ StrCpy $0 "Software\Clients\Mail\${ClientsRegName}"
+ DeleteRegValue HKLM $0 "SupportUTF8"
+
+ ; Unregister deprecated AccessibleHandler.dll.
+ ${If} ${FileExists} "$INSTDIR\AccessibleHandler.dll"
+ ${UnregisterDLL} "$INSTDIR\AccessibleHandler.dll"
+ ${EndIf}
+!macroend
+!define RemoveDeprecatedKeys "!insertmacro RemoveDeprecatedKeys"
+
+; For updates, adds a pinned shortcut to Task Bar on update for Windows 7
+; and 8 if this macro has never been called before and the application
+; is default (see PinToTaskBar for more details). This doesn't get called
+; for Windows 10 and 11 on updates, so we will never pin on update there.
+;
+; For installs, adds a taskbar pin if SHOULD_PIN is 1. (Defaults to 1,
+; but is controllable through the UI, ini file, and command line flags.)
+!macro MigrateTaskBarShortcut SHOULD_PIN
+ ${GetShortcutsLogPath} $0
+ ${If} ${FileExists} "$0"
+ ClearErrors
+ ReadINIStr $1 "$0" "TASKBAR" "Migrated"
+ ${If} ${Errors}
+ ClearErrors
+ WriteIniStr "$0" "TASKBAR" "Migrated" "true"
+ WriteRegDWORD HKCU \
+ "Software\Mozilla\${AppName}\Installer\$AppUserModelID" \
+ "WasPinnedToTaskbar" 1
+ ${If} ${AtLeastWin7}
+ ${If} "${SHOULD_PIN}" == "1"
+ ${PinToTaskBar}
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+!macroend
+!define MigrateTaskBarShortcut "!insertmacro MigrateTaskBarShortcut"
+
+!define GetPinningSupportedByWindowsVersionWithoutSystemPopup "!insertmacro GetPinningSupportedByWindowsVersionWithoutSystemPopup "
+
+; Starting with a version of Windows 11 (10.0.22621), the OS will show a system popup
+; when trying to pin to the taskbar.
+;
+; Pass in the variable to put the output into. A '1' means pinning is supported on this
+; OS without generating a popup, a '0' means pinning will generate a system popup.
+;
+;
+; More info: a version of Windows was released that introduced a system popup when
+; an exe (such as setup.exe) attempts to pin an app to the taskbar.
+; We already handle pinning in the onboarding process once Firefox
+; launches so we don't want to also attempt to pin it in the installer
+; and have the OS ask the user for confirmation without the full context.
+;
+; The number for that version of windows is still unclear (it might be 22H2 or 23H2)
+; and it's not supported by the version of WinVer.nsh we have anyways,
+; so instead we are confirming that it's a build of Windows that is less
+; than 10.0 BuildNumber: 22621 to do pinning in the installer.
+!macro GetPinningSupportedByWindowsVersionWithoutSystemPopup outvar
+ !define pin_lbl lbl_GPSBWVWSP_${__COUNTER__}
+
+ Push $0
+ Push $1
+ Push $2
+ Push $3
+
+ ${WinVerGetMajor} $0
+ ${WinVerGetMinor} $1
+ ${WinVerGetBuild} $2
+
+ ; Get the UBR; only documented way I could figure out how to get this reliably
+ ClearErrors
+ ReadRegDWORD $3 HKLM \
+ "Software\Microsoft\Windows NT\CurrentVersion" \
+ "UBR"
+
+ ; It's not obvious how to use LogicLib itself within a LogicLib custom
+ ; operator, so we do everything by hand with `IntCmp`. The below lines
+ ; translate to:
+ ; StrCpy ${outvar} '0' ; default to false
+ ; ${If} $0 <= 10
+ ; ${If} $1 <= 0
+ ; ${If} $2 < 22621
+ ; StrCpy ${outvar} '1'
+ ; ${ElseIf} $2 == 22621
+ ; ${If} $3 < 2361
+ ; StrCpy ${outvar} '1'
+ ; ${Endif}
+ ; ${EndIf}
+ ; ${Endif}
+ ; ${EndIf}
+
+ StrCpy ${outvar} '0' ; default to false on pinning
+
+ ; If the major version is greater than 10, no pinning in setup
+ IntCmp $0 10 "" "" ${pin_lbl}_bad
+
+ ; If the minor version is greater than 0, no pinning in setup
+ IntCmp $1 0 "" "" ${pin_lbl}_bad
+
+ ; If the build number is less than 22621, jump to pinning; if greater than, no pinning
+ IntCmp $2 22621 "" ${pin_lbl}_good ${pin_lbl}_bad
+
+ ; Only if the version is 10.0.22621 do we fall through to here
+ ; If the UBR is greater than or equal to 2361, jump to no pinning
+ IntCmp $3 2361 ${pin_lbl}_bad "" ${pin_lbl}_bad
+
+ ${pin_lbl}_good:
+
+ StrCpy ${outvar} '1'
+
+ ${pin_lbl}_bad:
+ !undef pin_lbl
+
+ Pop $3
+ Pop $2
+ Pop $1
+ Pop $0
+!macroend
+
+!macro _PinningSupportedByWindowsVersionWithoutSystemPopup _ignore _ignore2 _t _f
+ !insertmacro _LOGICLIB_TEMP
+ ${GetPinningSupportedByWindowsVersionWithoutSystemPopup} $_LOGICLIB_TEMP
+ !insertmacro _= $_LOGICLIB_TEMP "1" `${_t}` `${_f}`
+!macroend
+
+; The following is to make if statements for the functionality easier. When using an if statement,
+; Use IsPinningSupportedByWindowsVersionWithoutSystemPopup like so, instead of GetPinningSupportedByWindowsVersionWithoutSystemPopup:
+;
+; ${If} ${IsPinningSupportedByWindowsVersionWithoutSystemPopup}
+; ; do something
+; ${EndIf}
+;
+!define IsPinningSupportedByWindowsVersionWithoutSystemPopup `"" PinningSupportedByWindowsVersionWithoutSystemPopup "" `
+
+; Adds a pinned Task Bar shortcut on Windows 7 if there isn't one for the main
+; application executable already. Existing pinned shortcuts for the same
+; application model ID must be removed first to prevent breaking the pinned
+; item's lists but multiple installations with the same application model ID is
+; an edgecase. If removing existing pinned shortcuts with the same application
+; model ID removes a pinned pinned Start Menu shortcut this will also add a
+; pinned Start Menu shortcut.
+!macro PinToTaskBar
+ ${If} ${AtLeastWin7}
+ StrCpy $8 "false" ; Whether a shortcut had to be created
+ ${IsPinnedToTaskBar} "$INSTDIR\${FileMainEXE}" $R9
+ ${If} "$R9" == "false"
+ ; Find an existing Start Menu shortcut or create one to use for pinning
+ ${GetShortcutsLogPath} $0
+ ${If} ${FileExists} "$0"
+ ClearErrors
+ ReadINIStr $1 "$0" "STARTMENU" "Shortcut0"
+ ${Unless} ${Errors}
+ SetShellVarContext all ; Set SHCTX to all users
+ ${Unless} ${FileExists} "$SMPROGRAMS\$1"
+ SetShellVarContext current ; Set SHCTX to the current user
+ ${Unless} ${FileExists} "$SMPROGRAMS\$1"
+ StrCpy $8 "true"
+ CreateShortCut "$SMPROGRAMS\$1" "$INSTDIR\${FileMainEXE}"
+ ${If} ${FileExists} "$SMPROGRAMS\$1"
+ ShellLink::SetShortCutWorkingDirectory "$SMPROGRAMS\$1" \
+ "$INSTDIR"
+ ${If} "$AppUserModelID" != ""
+ ApplicationID::Set "$SMPROGRAMS\$1" "$AppUserModelID" "true"
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+ ${EndUnless}
+
+ ${If} ${FileExists} "$SMPROGRAMS\$1"
+ ; Count of Start Menu pinned shortcuts before unpinning.
+ ${PinnedToStartMenuLnkCount} $R9
+
+ ; Having multiple shortcuts pointing to different installations with
+ ; the same AppUserModelID (e.g. side by side installations of the
+ ; same version) will make the TaskBar shortcut's lists into an bad
+ ; state where the lists are not shown. To prevent this first
+ ; uninstall the pinned item.
+ ApplicationID::UninstallPinnedItem "$SMPROGRAMS\$1"
+
+ ; Count of Start Menu pinned shortcuts after unpinning.
+ ${PinnedToStartMenuLnkCount} $R8
+
+ ; If there is a change in the number of Start Menu pinned shortcuts
+ ; assume that unpinning unpinned a side by side installation from
+ ; the Start Menu and pin this installation to the Start Menu.
+ ${Unless} $R8 == $R9
+ ; Pin the shortcut to the Start Menu. 5381 is the shell32.dll
+ ; resource id for the "Pin to Start Menu" string.
+ InvokeShellVerb::DoIt "$SMPROGRAMS" "$1" "5381"
+ ${EndUnless}
+
+ ${If} ${AtMostWin2012R2}
+ ; Pin the shortcut to the TaskBar. 5386 is the shell32.dll
+ ; resource id for the "Pin to Taskbar" string.
+ InvokeShellVerb::DoIt "$SMPROGRAMS" "$1" "5386"
+ ${ElseIf} ${AtMostWaaS} 1809
+ ; In Windows 10 the "Pin to Taskbar" resource was removed, so we
+ ; can't access the verb that way anymore. We have a create a
+ ; command key using the GUID that's assigned to this action and
+ ; then invoke that as a verb. This works up until build 1809.
+ ReadRegStr $R9 HKLM \
+ "Software\Microsoft\Windows\CurrentVersion\Explorer\CommandStore\shell\Windows.taskbarpin" \
+ "ExplorerCommandHandler"
+ WriteRegStr HKCU "Software\Classes\*\shell\${AppRegNameMail}-$AppUserModelID" "ExplorerCommandHandler" $R9
+ InvokeShellVerb::DoIt "$SMPROGRAMS" "$1" "${AppRegNameMail}-$AppUserModelID"
+ DeleteRegKey HKCU "Software\Classes\*\shell\${AppRegNameMail}-$AppUserModelID"
+ ${Else}
+ ; In Windows 10 1903 and up, and Windows 11 prior to 22H2, the above no
+ ; longer works. We have yet another method for these versions
+ ; which is detailed in the PinToTaskbar plugin code.
+ ${If} ${IsPinningSupportedByWindowsVersionWithoutSystemPopup}
+ PinToTaskbar::Pin "$SMPROGRAMS\$1"
+ ${EndIf}
+ ${EndIf}
+
+ ; Delete the shortcut if it was created
+ ${If} "$8" == "true"
+ Delete "$SMPROGRAMS\$1"
+ ${EndIf}
+ ${EndIf}
+
+ ${If} $TmpVal == "HKCU"
+ SetShellVarContext current ; Set SHCTX to the current user
+ ${Else}
+ SetShellVarContext all ; Set SHCTX to all users
+ ${EndIf}
+ ${EndUnless}
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+!macroend
+!define PinToTaskBar "!insertmacro PinToTaskBar"
+
+; Removes the application's start menu directory along with its shortcuts if
+; they exist and if they exist creates a start menu shortcut in the root of the
+; start menu directory (bug 598779). If the application's start menu directory
+; is not empty after removing the shortucts the directory will not be removed
+; since these additional items were not created by the installer (uses SHCTX).
+!macro RemoveStartMenuDir
+ ${GetShortcutsLogPath} $0
+ ${If} ${FileExists} "$0"
+ ; Delete Start Menu Programs shortcuts, directory if it is empty, and
+ ; parent directories if they are empty up to but not including the start
+ ; menu directory.
+ ${GetLongPath} "$SMPROGRAMS" $1
+ ClearErrors
+ ReadINIStr $2 "$0" "SMPROGRAMS" "RelativePathToDir"
+ ${Unless} ${Errors}
+ ${GetLongPath} "$1\$2" $2
+ ${If} "$2" != ""
+ ; Delete shortucts in the Start Menu Programs directory.
+ StrCpy $3 0
+ ${Do}
+ ClearErrors
+ ReadINIStr $4 "$0" "SMPROGRAMS" "Shortcut$3"
+ ; Stop if there are no more entries
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${If} ${FileExists} "$2\$4"
+ ShellLink::GetShortCutTarget "$2\$4"
+ Pop $5
+ ${If} "$INSTDIR\${FileMainEXE}" == "$5"
+ Delete "$2\$4"
+ ${EndIf}
+ ${EndIf}
+ IntOp $3 $3 + 1 ; Increment the counter
+ ${Loop}
+ ; Delete Start Menu Programs directory and parent directories
+ ${Do}
+ ; Stop if the current directory is the start menu directory
+ ${If} "$1" == "$2"
+ ${ExitDo}
+ ${EndIf}
+ ClearErrors
+ RmDir "$2"
+ ; Stop if removing the directory failed
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${GetParent} "$2" $2
+ ${Loop}
+ ${EndIf}
+ DeleteINISec "$0" "SMPROGRAMS"
+ ${EndUnless}
+ ${EndIf}
+!macroend
+!define RemoveStartMenuDir "!insertmacro RemoveStartMenuDir"
+
+; Creates the shortcuts log ini file with the appropriate entries if it doesn't
+; already exist.
+!macro CreateShortcutsLog
+ ${GetShortcutsLogPath} $0
+ ${Unless} ${FileExists} "$0"
+ ${LogStartMenuShortcut} "${BrandShortName}.lnk"
+ ${LogQuickLaunchShortcut} "${BrandShortName}.lnk"
+ ${LogDesktopShortcut} "${BrandShortName}.lnk"
+ ${EndUnless}
+!macroend
+!define CreateShortcutsLog "!insertmacro CreateShortcutsLog"
+
+; The MAPI DLL's are copied and the copies are used for the MAPI registration
+; to lessen file in use errors on application update.
+!macro UpgradeMapiDLLs
+ ClearErrors
+ ${DeleteFile} "$INSTDIR\MapiProxy_InUse.dll"
+ ${If} ${Errors}
+ ${DeleteFile} "$INSTDIR\MapiProxy_InUse.dll.moz-delete" ; shouldn't exist
+ Rename "$INSTDIR\MapiProxy_InUse.dll" "$INSTDIR\MapiProxy_InUse.dll.moz-delete"
+ Delete /REBOOTOK "$INSTDIR\MapiProxy_InUse.dll.moz-delete"
+ ${EndIf}
+ CopyFiles /SILENT "$INSTDIR\MapiProxy.dll" "$INSTDIR\MapiProxy_InUse.dll"
+
+ ClearErrors
+ ${DeleteFile} "$INSTDIR\mozMapi32_InUse.dll"
+ ${If} ${Errors}
+ ${DeleteFile} "$INSTDIR\mozMapi32_InUse.dll.moz-delete" ; shouldn't exist
+ Rename "$INSTDIR\mozMapi32_InUse.dll" "$INSTDIR\mozMapi32_InUse.dll.moz-delete"
+ Delete /REBOOTOK "$INSTDIR\mozMapi32_InUse.dll.moz-delete"
+ ${EndIf}
+ CopyFiles /SILENT "$INSTDIR\mozMapi32.dll" "$INSTDIR\mozMapi32_InUse.dll"
+!macroend
+!define UpgradeMapiDLLs "!insertmacro UpgradeMapiDLLs"
+
+; The files to check if they are in use during (un)install so the restart is
+; required message is displayed. All files must be located in the $INSTDIR
+; directory.
+!macro PushFilesToCheck
+ ; The first string to be pushed onto the stack MUST be "end" to indicate
+ ; that there are no more files to check in $INSTDIR and the last string
+ ; should be ${FileMainEXE} so if it is in use the CheckForFilesInUse macro
+ ; returns after the first check.
+ Push "end"
+ Push "AccessibleMarshal.dll"
+ Push "freebl3.dll"
+ Push "nssckbi.dll"
+ Push "nspr4.dll"
+ Push "nssdbm3.dll"
+ Push "sqlite3.dll"
+ Push "mozsqlite3.dll"
+ Push "xpcom.dll"
+ Push "crashreporter.exe"
+ Push "minidump-analyzer.exe"
+ Push "pingsender.exe"
+ Push "updater.exe"
+ Push "mozwer.dll"
+ Push "xpicleanup.exe"
+ Push "MapiProxy.dll"
+ Push "MapiProxy_InUse.dll"
+ Push "mozMapi32.dll"
+ Push "mozMapi32_InUse.dll"
+ Push "${FileMainEXE}"
+!macroend
+!define PushFilesToCheck "!insertmacro PushFilesToCheck"
+
+; Helper for updating the shortcut application model IDs.
+Function FixShortcutAppModelIDs
+ ${If} ${AtLeastWin7}
+ ${AndIf} "$AppUserModelID" != ""
+ ${UpdateShortcutAppModelIDs} "$INSTDIR\${FileMainEXE}" "$AppUserModelID" $0
+ ${EndIf}
+FunctionEnd
+
+; The !ifdef NO_LOG prevents warnings when compiling the installer.nsi due to
+; this function only being used by the uninstaller.nsi.
+!ifdef NO_LOG
+
+Function SetAsDefaultAppUser
+ ; AddTaskbarSC is needed by MigrateTaskBarShortcut, which is called by
+ ; SetAsDefaultAppUserHKCU. If this is called via ExecCodeSegment,
+ ; MigrateTaskBarShortcut will not see the value of AddTaskbarSC, so we
+ ; send it via a register instead.
+ StrCpy $R0 $AddTaskbarSC
+ ; It is only possible to set this installation of the application as the
+ ; Mail handler if it was added to the HKLM Mail
+ ; registry keys.
+ ; http://support.microsoft.com/kb/297878
+ ${GetParameters} $R0
+
+ ClearErrors
+ ${GetOptions} "$R0" "Mail" $R1
+ ${Unless} ${Errors}
+ ; Check if this install location registered as the Mail client
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\Clients\Mail\${ClientsRegName}\DefaultIcon" ""
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR"
+ ; Check if this is running in an elevated process
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/UAC:" $0
+ ${If} ${Errors} ; Not elevated
+ Call SetAsDefaultMailAppUserHKCU
+ ${Else} ; Elevated - execute the function in the unelevated process
+ GetFunctionAddress $0 SetAsDefaultMailAppUserHKCU
+ UAC::ExecCodeSegment $0
+ ${EndIf}
+
+ ; Do we also set TB as default News client? If not we can return
+ ClearErrors
+ ${GetOptions} "$R0" "News" $R1
+ ${If} ${Errors}
+ Return
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+
+ ClearErrors
+ ${GetOptions} "$R0" "News" $R1
+ ${Unless} ${Errors}
+ ; Check if this install location registered as the News client
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\Clients\News\${ClientsRegName}\DefaultIcon" ""
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR"
+ ; Check if this is running in an elevated process
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/UAC:" $0
+ ${If} ${Errors} ; Not elevated
+ Call SetAsDefaultNewsAppUserHKCU
+ ${Else} ; Elevated - execute the function in the unelevated process
+ GetFunctionAddress $0 SetAsDefaultNewsAppUserHKCU
+ UAC::ExecCodeSegment $0
+ ${EndIf}
+ Return ; Nothing more needs to be done
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+
+ ClearErrors
+ ${GetOptions} "$R0" "Calendar" $R1
+ ${Unless} ${Errors}
+ ; Check if this install location registered as the Calendar client
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\Clients\Calendar\${ClientsRegName}\DefaultIcon" ""
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR"
+ ; Check if this is running in an elevated process
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/UAC:" $0
+ ${If} ${Errors} ; Not elevated
+ Call SetAsDefaultCalendarAppUserHKCU
+ ${Else} ; Elevated - execute the function in the unelevated process
+ GetFunctionAddress $0 SetAsDefaultCalendarAppUserHKCU
+ UAC::ExecCodeSegment $0
+ ${EndIf}
+ Return ; Nothing more needs to be done
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+
+ ; The code after ElevateUAC won't be executed when the user:
+ ; a) is a member of the administrators group (e.g. elevation is required)
+ ; b) is not a member of the administrators group and chooses to elevate
+ ${ElevateUAC}
+
+ SetShellVarContext all ; Set SHCTX to all users (e.g. HKLM)
+ ${SetClientsMail} "HKLM"
+ ${SetClientsNews} "HKLM"
+ ${SetClientsCalendar} "HKLM"
+
+ ${RemoveDeprecatedKeys}
+ ${MigrateTaskBarShortcut} "$R0"
+
+ ClearErrors
+ ${GetParameters} $0
+ ${GetOptions} "$0" "/UAC:" $0
+ ${If} ${Errors}
+ ClearErrors
+ ${GetOptions} "$R0" "Mail" $R1
+ ${Unless} ${Errors}
+ Call SetAsDefaultMailAppUserHKCU
+ ${EndUnless}
+ ClearErrors
+ ${GetOptions} "$R0" "News" $R1
+ ${Unless} ${Errors}
+ Call SetAsDefaultNewsAppUserHKCU
+ ${EndUnless}
+ ${Else}
+ ${GetOptions} "$R0" "Mail" $R1
+ ${Unless} ${Errors}
+ GetFunctionAddress $0 SetAsDefaultMailAppUserHKCU
+ UAC::ExecCodeSegment $0
+ ${EndUnless}
+ ClearErrors
+ ${GetOptions} "$R0" "News" $R1
+ ${Unless} ${Errors}
+ GetFunctionAddress $0 SetAsDefaultNewsAppUserHKCU
+ UAC::ExecCodeSegment $0
+ ${EndUnless}
+ ${EndIf}
+FunctionEnd
+!define SetAsDefaultAppUser "Call SetAsDefaultAppUser"
+
+!endif
+
+; Sets this installation as the default mailer by setting the registry keys
+; under HKEY_CURRENT_USER via registry calls and using the AppAssocReg NSIS
+; plugin. This is a function instead of a macro so it is
+; easily called from an elevated instance of the binary. Since this can be
+; called by an elevated instance logging is not performed in this function.
+Function SetAsDefaultMailAppUserHKCU
+ ; Only set as the user's Mail client if the StartMenuInternet
+ ; registry keys are for this install.
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\Clients\Mail\${ClientsRegName}\DefaultIcon" ""
+ ${Unless} ${Errors}
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR"
+ WriteRegStr HKCU "Software\Clients\Mail" "" "${ClientsRegName}"
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+
+ SetShellVarContext current ; Set SHCTX to the current user (e.g. HKCU)
+
+ ${If} ${AtLeastWin8}
+ ${SetHandlersMail}
+ ${EndIf}
+
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\RegisteredApplications" "${AppRegNameMail}"
+ ; Only register as the handler if the app registry name exists
+ ; under the RegisteredApplications registry key.
+ ${Unless} ${Errors}
+ AppAssocReg::SetAppAsDefaultAll "${AppRegNameMail}"
+ ${EndUnless}
+FunctionEnd
+
+; The !ifdef NO_LOG prevents warnings when compiling the installer.nsi due to
+; this function only being used by SetAsDefaultAppUser.
+!ifdef NO_LOG
+
+; Sets this installation as the default news client by setting the registry keys
+; under HKEY_CURRENT_USER via registry calls and using the AppAssocReg NSIS
+; plugin. This is a function instead of a macro so it is
+; easily called from an elevated instance of the binary. Since this can be
+; called by an elevated instance logging is not performed in this function.
+Function SetAsDefaultNewsAppUserHKCU
+ ; Only set as the user's News client if the StartMenuInternet
+ ; registry keys are for this install.
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\Clients\News\${ClientsRegName}\DefaultIcon" ""
+ ${Unless} ${Errors}
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR"
+ WriteRegStr HKCU "Software\Clients\News" "" "${ClientsRegName}"
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+
+ SetShellVarContext current ; Set SHCTX to the current user (e.g. HKCU)
+
+ ${If} ${AtLeastWin8}
+ ${SetHandlersNews}
+ ${EndIf}
+
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\RegisteredApplications" "${AppRegNameNews}"
+ ; Only register as the handler if the app registry name exists
+ ; under the RegisteredApplications registry key.
+ ${Unless} ${Errors}
+ AppAssocReg::SetAppAsDefaultAll "${AppRegNameNews}"
+ ${EndUnless}
+FunctionEnd
+
+; Sets this installation as the default calendar client by setting the registry keys
+; under HKEY_CURRENT_USER via registry calls and using the AppAssocReg NSIS
+; plugin. This is a function instead of a macro so it is
+; easily called from an elevated instance of the binary. Since this can be
+; called by an elevated instance logging is not performed in this function.
+Function SetAsDefaultCalendarAppUserHKCU
+ ; Only set as the user's Calendar client if the StartMenuInternet
+ ; registry keys are for this install.
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\Clients\Calendar\${ClientsRegName}\DefaultIcon" ""
+ ${Unless} ${Errors}
+ ${GetPathFromString} "$0" $0
+ ${GetParent} "$0" $0
+ ${If} ${FileExists} "$0"
+ ${GetLongPath} "$0" $0
+ ${If} "$0" == "$INSTDIR"
+ WriteRegStr HKCU "Software\Clients\Calendar" "" "${ClientsRegName}"
+ ${EndIf}
+ ${EndIf}
+ ${EndUnless}
+
+ SetShellVarContext current ; Set SHCTX to the current user (e.g. HKCU)
+
+ ${If} ${AtLeastWin8}
+ ${SetHandlersCalendar}
+ ${EndIf}
+
+ ClearErrors
+ ReadRegStr $0 HKLM "Software\RegisteredApplications" "${AppRegNameCalendar}"
+ ; Only register as the handler if the app registry name exists
+ ; under the RegisteredApplications registry key.
+ ${Unless} ${Errors}
+ AppAssocReg::SetAppAsDefaultAll "${AppRegNameCalendar}"
+ ${EndUnless}
+FunctionEnd
+
+!endif
diff --git a/comm/mail/installer/windows/nsis/uninstaller.nsi b/comm/mail/installer/windows/nsis/uninstaller.nsi
new file mode 100755
index 0000000000..c2a6b4b1b3
--- /dev/null
+++ b/comm/mail/installer/windows/nsis/uninstaller.nsi
@@ -0,0 +1,683 @@
+# 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/.
+
+# Required Plugins:
+# AppAssocReg http://nsis.sourceforge.net/Application_Association_Registration_plug-in
+# BitsUtils http://dxr.mozilla.org/mozilla-central/source/other-licenses/nsis/Contrib/BitsUtils
+# CityHash http://dxr.mozilla.org/mozilla-central/source/other-licenses/nsis/Contrib/CityHash
+# ShellLink http://nsis.sourceforge.net/ShellLink_plug-in
+# UAC http://nsis.sourceforge.net/UAC_plug-in
+
+; Set verbosity to 3 (e.g. no script) to lessen the noise in the build logs
+!verbose 3
+
+; 7-Zip provides better compression than the lzma from NSIS so we add the files
+; uncompressed and use 7-Zip to create a SFX archive of it
+SetDatablockOptimize on
+SetCompress off
+CRCCheck on
+
+RequestExecutionLevel user
+
+Unicode true
+ManifestSupportedOS all
+ManifestDPIAware true
+
+!addplugindir ./
+
+; Attempt to elevate Standard Users in addition to users that
+; are a member of the Administrators group.
+!define NONADMIN_ELEVATE
+
+; prevents compiling of the reg write logging.
+!define NO_LOG
+
+!define MaintUninstallKey \
+ "Software\Microsoft\Windows\CurrentVersion\Uninstall\MozillaMaintenanceService"
+
+Var TmpVal
+Var MaintCertKey
+; AddTaskbarSC is defined here in order to silence warnings from inside
+; MigrateTaskBarShortcut and is not intended to be used here.
+; See Bug 1329869 for more.
+Var AddTaskbarSC
+
+; Other included files may depend upon these includes!
+; The following includes are provided by NSIS.
+!include FileFunc.nsh
+!include LogicLib.nsh
+!include MUI.nsh
+!include WinMessages.nsh
+!include WinVer.nsh
+!include WordFunc.nsh
+
+!insertmacro GetSize
+!insertmacro StrFilter
+!insertmacro WordReplace
+
+!insertmacro un.GetParent
+
+; The following includes are custom.
+!include branding.nsi
+!include defines.nsi
+!include common.nsh
+!include locales.nsi
+
+; This is named BrandShortName helper because we use this for software update
+; post update cleanup.
+VIAddVersionKey "FileDescription" "${BrandShortName} Helper"
+VIAddVersionKey "OriginalFilename" "helper.exe"
+
+!insertmacro AddHandlerValues
+!insertmacro CleanVirtualStore
+!insertmacro ElevateUAC
+!insertmacro GetLongPath
+!insertmacro GetPathFromString
+!insertmacro InitHashAppModelId
+!insertmacro IsHandlerForInstallDir
+!insertmacro IsPinnedToTaskBar
+!insertmacro IsUserAdmin
+!insertmacro LogDesktopShortcut
+!insertmacro LogQuickLaunchShortcut
+!insertmacro LogStartMenuShortcut
+!insertmacro PinnedToStartMenuLnkCount
+!insertmacro RegCleanAppHandler
+!insertmacro RegCleanMain
+!insertmacro RegCleanUninstall
+!insertmacro SetAppLSPCategories
+!insertmacro SetBrandNameVars
+!insertmacro UpdateShortcutAppModelIDs
+!insertmacro UnloadUAC
+!insertmacro WriteRegDWORD2
+!insertmacro WriteRegStr2
+
+; This needs to be inserted after InitHashAppModelId because it uses
+; $AppUserModelID and the compiler can't handle using variables lexically before
+; they've been declared.
+!insertmacro GetInstallerRegistryPref
+
+!insertmacro un.ChangeMUIHeaderImage
+!insertmacro un.CheckForFilesInUse
+!insertmacro un.CleanMaintenanceServiceLogs
+!insertmacro un.CleanVirtualStore
+!insertmacro un.DeleteShortcuts
+!insertmacro un.GetLongPath
+!insertmacro un.GetSecondInstallPath
+!insertmacro un.InitHashAppModelId
+!insertmacro un.ManualCloseAppPrompt
+!insertmacro un.RegCleanAppHandler
+!insertmacro un.RegCleanFileHandler
+!insertmacro un.RegCleanMain
+!insertmacro un.RegCleanUninstall
+!insertmacro un.RegCleanProtocolHandler
+!insertmacro un.RemoveQuotesFromPath
+!insertmacro un.RemovePrecompleteEntries
+!insertmacro un.SetAppLSPCategories
+!insertmacro un.SetBrandNameVars
+
+!include shared.nsh
+
+; Helper macros for ui callbacks. Insert these after shared.nsh
+!insertmacro OnEndCommon
+!insertmacro UninstallOnInitCommon
+
+!insertmacro un.OnEndCommon
+!insertmacro un.UninstallUnOnInitCommon
+
+Name "${BrandFullName}"
+OutFile "helper.exe"
+!ifdef HAVE_64BIT_BUILD
+ InstallDir "$PROGRAMFILES64\${BrandFullName}\"
+!else
+ InstallDir "$PROGRAMFILES32\${BrandFullName}\"
+!endif
+ShowUnInstDetails nevershow
+
+################################################################################
+# Modern User Interface - MUI
+
+!define MUI_ABORTWARNING
+!define MUI_ICON setup.ico
+!define MUI_UNICON setup.ico
+!define MUI_WELCOMEPAGE_TITLE_3LINES
+!define MUI_HEADERIMAGE
+!define MUI_HEADERIMAGE_RIGHT
+!define MUI_UNWELCOMEFINISHPAGE_BITMAP wizWatermark.bmp
+; By default MUI_BGCOLOR is hardcoded to FFFFFF, which is only correct if the
+; the Windows theme or high-contrast mode hasn't changed it, so we need to
+; override that with GetSysColor(COLOR_WINDOW) (this string ends up getting
+; passed to SetCtlColors, which uses this custom syntax to mean that).
+!define MUI_BGCOLOR SYSCLR:WINDOW
+
+; Use a right to left header image when the language is right to left
+!ifdef ${AB_CD}_rtl
+!define MUI_HEADERIMAGE_BITMAP_RTL wizHeaderRTL.bmp
+!else
+!define MUI_HEADERIMAGE_BITMAP wizHeader.bmp
+!endif
+
+/**
+ * Uninstall Pages
+ */
+; Welcome Page
+!define MUI_PAGE_CUSTOMFUNCTION_PRE un.preWelcome
+!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.showWelcome
+!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.leaveWelcome
+!insertmacro MUI_UNPAGE_WELCOME
+
+; Custom Uninstall Confirm Page
+UninstPage custom un.preConfirm
+
+; Remove Files Page
+!insertmacro MUI_UNPAGE_INSTFILES
+
+; Finish Page
+!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.showFinish
+!insertmacro MUI_UNPAGE_FINISH
+
+; Use the default dialog for IDD_VERIFY for a simple Banner
+ChangeUI IDD_VERIFY "${NSISDIR}\Contrib\UIs\default.exe"
+
+################################################################################
+# Helper Functions
+
+; This function is used to uninstall the maintenance service if the
+; application currently being uninstalled is the last application to use the
+; maintenance service.
+Function un.UninstallServiceIfNotUsed
+ ; $0 will store if a subkey exists
+ ; $1 will store the first subkey if it exists or an empty string if it doesn't
+ ; Backup the old values
+ Push $0
+ Push $1
+
+ ; The maintenance service always uses the 64-bit registry on x64 systems
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView 64
+ ${EndIf}
+
+ ; Figure out the number of subkeys
+ StrCpy $0 0
+ ${Do}
+ EnumRegKey $1 HKLM "Software\Mozilla\MaintenanceService" $0
+ ${If} "$1" == ""
+ ${ExitDo}
+ ${EndIf}
+ IntOp $0 $0 + 1
+ ${Loop}
+
+ ; Restore back the registry view
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView lastUsed
+ ${EndIf}
+
+ ${If} $0 == 0
+ ; Get the path of the maintenance service uninstaller.
+ ; Look in both the 32-bit and 64-bit registry views.
+ SetRegView 32
+ ReadRegStr $1 HKLM ${MaintUninstallKey} "UninstallString"
+ SetRegView lastused
+
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ ${If} $1 == ""
+ SetRegView 64
+ ReadRegStr $1 HKLM ${MaintUninstallKey} "UninstallString"
+ SetRegView lastused
+ ${EndIf}
+ ${EndIf}
+
+ ; If the uninstall string does not exist, skip executing it
+ ${If} $1 != ""
+ ; $1 is already a quoted string pointing to the install path
+ ; so we're already protected against paths with spaces
+ nsExec::Exec "$1 /S"
+ ${EndIf}
+ ${EndIf}
+
+ ; Restore the old value of $1 and $0
+ Pop $1
+ Pop $0
+FunctionEnd
+
+################################################################################
+# Install Sections
+; Empty section required for the installer to compile as an uninstaller
+Section ""
+SectionEnd
+
+################################################################################
+# Uninstall Sections
+
+Section "Uninstall"
+ SetDetailsPrint textonly
+ DetailPrint $(STATUS_UNINSTALL_MAIN)
+ SetDetailsPrint none
+
+ ; Delete the app exe to prevent launching the app while we are uninstalling.
+ ClearErrors
+ ${DeleteFile} "$INSTDIR\${FileMainEXE}"
+ ${If} ${Errors}
+ ; If the user closed the application it can take several seconds for it to
+ ; shut down completely. If the application is being used by another user we
+ ; can still delete the files when the system is restarted.
+ Sleep 5000
+ ${DeleteFile} "$INSTDIR\${FileMainEXE}"
+ ClearErrors
+ ${EndIf}
+
+ ; setup the application model id registration value
+ ${un.InitHashAppModelId} "$INSTDIR" "Software\Mozilla\${AppName}\TaskBarIDs"
+
+ SetShellVarContext current ; Set SHCTX to HKCU
+ ${un.RegCleanMain} "Software\Mozilla"
+ ${un.RegCleanUninstall}
+ ${un.DeleteShortcuts}
+
+ ; Unregister resources associated with Win7 taskbar jump lists.
+ ${If} ${AtLeastWin7}
+ ${AndIf} "$AppUserModelID" != ""
+ ApplicationID::UninstallJumpLists "$AppUserModelID"
+ ${EndIf}
+
+ ; Clean up old maintenance service logs
+ ${un.CleanMaintenanceServiceLogs} "Thunderbird"
+
+ ; Remove any app model id's stored in the registry for this install path
+ DeleteRegValue HKCU "Software\Mozilla\${AppName}\TaskBarIDs" "$INSTDIR"
+ DeleteRegValue HKLM "Software\Mozilla\${AppName}\TaskBarIDs" "$INSTDIR"
+
+ ClearErrors
+ WriteRegStr HKLM "Software\Mozilla" "${BrandShortName}InstallerTest" "Write Test"
+ ${If} ${Errors}
+ StrCpy $TmpVal "HKCU" ; used primarily for logging
+ ${Else}
+ SetShellVarContext all ; Set SHCTX to HKLM
+ DeleteRegValue HKLM "Software\Mozilla" "${BrandShortName}InstallerTest"
+ StrCpy $TmpVal "HKLM" ; used primarily for logging
+ ${un.RegCleanMain} "Software\Mozilla"
+ ${un.RegCleanUninstall}
+ ${un.DeleteShortcuts}
+ ${EndIf}
+
+ ${un.RegCleanAppHandler} "Thunderbird.Url.mailto"
+ ${un.RegCleanAppHandler} "Thunderbird.Url.mid"
+ ${un.RegCleanAppHandler} "Thunderbird.Url.news"
+ ${un.RegCleanAppHandler} "Thunderbird.Url.webcal"
+ ${un.RegCleanAppHandler} "ThunderbirdEML"
+ ${un.RegCleanAppHandler} "ThunderbirdICS"
+ ${un.RegCleanProtocolHandler} "mailto"
+ ${un.RegCleanProtocolHandler} "mid"
+ ${un.RegCleanProtocolHandler} "news"
+ ${un.RegCleanProtocolHandler} "nntp"
+ ${un.RegCleanProtocolHandler} "snews"
+ ${un.RegCleanProtocolHandler} "webcal"
+ ${un.RegCleanProtocolHandler} "webcals"
+
+ ClearErrors
+ ReadRegStr $R9 HKCR "ThunderbirdEML" ""
+ ; Don't clean up the file handlers if the ThunderbirdEML key still exists
+ ; since there could be a second installation that may be the default file
+ ; handler.
+ ${If} ${Errors}
+ ${un.RegCleanFileHandler} ".eml" "ThunderbirdEML"
+ ${un.RegCleanFileHandler} ".wdseml" "ThunderbirdEML"
+ DeleteRegValue HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\explorer\KindMap" ".wdseml"
+ ; It doesn't matter if the value didn't exist
+ ClearErrors
+ ${EndIf}
+
+ ClearErrors
+ ReadRegStr $R9 HKCR "ThunderbirdICS" ""
+ ; Don't clean up the file handlers if the ThunderbirdICS key still exists
+ ; since there could be a second installation that may be the default file
+ ; handler.
+ ${If} ${Errors}
+ ${un.RegCleanFileHandler} ".ics" "ThunderbirdICS"
+ ; It doesn't matter if the value didn't exist
+ ClearErrors
+ ${EndIf}
+
+ SetShellVarContext all ; Set SHCTX to HKLM
+ ${un.GetSecondInstallPath} "Software\Mozilla" $R9
+ ${If} $R9 == "false"
+ SetShellVarContext current ; Set SHCTX to HKCU
+ ${un.GetSecondInstallPath} "Software\Mozilla" $R9
+ ${EndIf}
+
+ StrCpy $0 "Software\Clients\Mail\${ClientsRegName}\shell\open\command"
+ ReadRegStr $R1 HKLM "$0" ""
+ ${un.RemoveQuotesFromPath} "$R1" $R1
+ ${un.GetParent} "$R1" $R1
+
+ ; Only remove the Clients\Mail & Clients\News & Clients\Calendar key if it
+ ; refers to this install location. The Clients\* keys are independent
+ ; of the default app for the OS settings. The XPInstall base un-installer
+ ; always removes these keys if it is uninstalling the default app and it
+ ; will always replace the keys when installing even if there is another
+ ; install of Thunderbird that is set as the
+ ; default app. Now the keys are always updated on install but are only
+ ; removed if they refer to this install location.
+ ${If} "$INSTDIR" == "$R1"
+ DeleteRegKey HKLM "Software\Clients\Mail\${ClientsRegName}"
+ DeleteRegKey HKLM "Software\Clients\News\${ClientsRegName}"
+ DeleteRegKey HKLM "Software\Clients\Calendar\${ClientsRegName}"
+ DeleteRegValue HKLM "Software\RegisteredApplications" "${AppRegNameMail}"
+ DeleteRegValue HKLM "Software\RegisteredApplications" "${AppRegNameNews}"
+ DeleteRegValue HKLM "Software\RegisteredApplications" "${AppRegNameCalendar}"
+ ${EndIf}
+
+ StrCpy $0 "Software\Microsoft\Windows\CurrentVersion\App Paths\${FileMainEXE}"
+ ${If} $R9 == "false"
+ DeleteRegKey HKLM "$0"
+ DeleteRegKey HKCU "$0"
+ ${Else}
+ ReadRegStr $R1 HKLM "$0" ""
+ ${un.RemoveQuotesFromPath} "$R1" $R1
+ ${un.GetParent} "$R1" $R1
+ ${If} "$INSTDIR" == "$R1"
+ WriteRegStr HKLM "$0" "" "$R9"
+ ${un.GetParent} "$R9" $R1
+ WriteRegStr HKLM "$0" "Path" "$R1"
+ ${EndIf}
+ ${EndIf}
+
+ ; Remove directories and files we always control before parsing the uninstall
+ ; log so empty directories can be removed.
+ ${If} ${FileExists} "$INSTDIR\updates"
+ RmDir /r /REBOOTOK "$INSTDIR\updates"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\defaults\shortcuts"
+ RmDir /r /REBOOTOK "$INSTDIR\defaults\shortcuts"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\distribution"
+ RmDir /r /REBOOTOK "$INSTDIR\distribution"
+ ${EndIf}
+
+ ; Application update won't add these files to the uninstall log so delete
+ ; them if they still exist.
+ ${If} ${FileExists} "$INSTDIR\MapiProxy_InUse.dll"
+ Delete /REBOOTOK "$INSTDIR\MapiProxy_InUse.dll"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\mozMapi32_InUse.dll"
+ Delete /REBOOTOK "$INSTDIR\mozMapi32_InUse.dll"
+ ${EndIf}
+
+ ; Remove files that may be left behind by the application in the
+ ; VirtualStore directory.
+ ${un.CleanVirtualStore}
+
+ ; Only unregister the dll if the registration points to this installation
+ ReadRegStr $R1 HKCR "CLSID\{0D68D6D0-D93D-4D08-A30D-F00DD1F45B24}\InProcServer32" ""
+ ${If} "$INSTDIR\AccessibleMarshal.dll" == "$R1"
+ ${UnregisterDLL} "$INSTDIR\AccessibleMarshal.dll"
+ ${EndIf}
+
+ ${If} ${FileExists} "$INSTDIR\AccessibleHandler.dll"
+ ${UnregisterDLL} "$INSTDIR\AccessibleHandler.dll"
+ ${EndIf}
+
+ ; Remove the Windows Reporter Module entry
+ DeleteRegValue HKLM "SOFTWARE\Microsoft\Windows\Windows Error Reporting\RuntimeExceptionHelperModules" "$INSTDIR\mozwer.dll"
+
+ ${un.RemovePrecompleteEntries} "false"
+
+ ${If} ${FileExists} "$INSTDIR\defaults\pref\channel-prefs.js"
+ Delete /REBOOTOK "$INSTDIR\defaults\pref\channel-prefs.js"
+ ${EndIf}
+ RmDir "$INSTDIR\defaults\pref"
+ RmDir "$INSTDIR\defaults"
+ ${If} ${FileExists} "$INSTDIR\uninstall"
+ ; Remove the uninstall directory that we control
+ RmDir /r /REBOOTOK "$INSTDIR\uninstall"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\install.log"
+ Delete /REBOOTOK "$INSTDIR\install.log"
+ ${EndIf}
+ ${If} ${FileExists} "$INSTDIR\update-settings.ini"
+ Delete /REBOOTOK "$INSTDIR\update-settings.ini"
+ ${EndIf}
+
+ ; Remove the installation directory if it is empty
+ RmDir "$INSTDIR"
+
+ ; If thunderbird.exe was successfully deleted yet we still need to restart to
+ ; remove other files create a dummy thunderbird.exe.moz-delete to prevent the
+ ; installer from allowing an install without restart when it is required
+ ; to complete an uninstall.
+ ${If} ${RebootFlag}
+ ; Admin is required to delete files on reboot so only add the moz-delete if
+ ; the user is an admin. After calling UAC::IsAdmin $0 will equal 1 if the
+ ; user is an admin.
+ UAC::IsAdmin
+ ${If} "$0" == "1"
+ ${Unless} ${FileExists} "$INSTDIR\${FileMainEXE}.moz-delete"
+ FileOpen $0 "$INSTDIR\${FileMainEXE}.moz-delete" w
+ FileWrite $0 "Will be deleted on restart"
+ Delete /REBOOTOK "$INSTDIR\${FileMainEXE}.moz-delete"
+ FileClose $0
+ ${EndUnless}
+ ${EndIf}
+ ${EndIf}
+
+ ; Refresh desktop icons otherwise the start menu internet item won't be
+ ; removed and other ugly things will happen like recreation of the app's
+ ; clients registry key by the OS under some conditions.
+ ${RefreshShellIcons}
+
+!ifdef MOZ_MAINTENANCE_SERVICE
+ ; Get the path the allowed cert is at and remove it
+ ; Keep this block of code last since it modifies the reg view
+ ServicesHelper::PathToUniqueRegistryPath "$INSTDIR"
+ Pop $MaintCertKey
+ ${If} $MaintCertKey != ""
+ ; Always use the 64bit registry for certs on 64bit systems.
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView 64
+ ${EndIf}
+ DeleteRegKey HKLM "$MaintCertKey"
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView lastused
+ ${EndIf}
+ ${EndIf}
+ Call un.UninstallServiceIfNotUsed
+!endif
+
+!ifdef MOZ_BITS_DOWNLOAD
+ BitsUtils::CancelBitsJobsByName "MozillaUpdate $AppUserModelID"
+ Pop $0
+!endif
+
+SectionEnd
+
+################################################################################
+# Language
+
+!insertmacro MOZ_MUI_LANGUAGE 'baseLocale'
+!verbose push
+!verbose 3
+!include "overrideLocale.nsh"
+!include "customLocale.nsh"
+!verbose pop
+
+; Set this after the locale files to override it if it is in the locale. Using
+; " " for BrandingText will hide the "Nullsoft Install System..." branding.
+BrandingText " "
+
+################################################################################
+# Page pre, show, and leave functions
+
+Function un.preWelcome
+ ${If} ${FileExists} "$INSTDIR\distribution\modern-wizard.bmp"
+ Delete "$PLUGINSDIR\modern-wizard.bmp"
+ CopyFiles /SILENT "$INSTDIR\distribution\modern-wizard.bmp" "$PLUGINSDIR\modern-wizard.bmp"
+ ${EndIf}
+!ifdef MOZ_BITS_DOWNLOAD
+ BitsUtils::StartBitsServiceBackground
+!endif
+FunctionEnd
+
+Function un.ShowWelcome
+ ; The welcome and finish pages don't get the correct colors for their labels
+ ; like the other pages do, presumably because they're built by filling in an
+ ; InstallOptions .ini file instead of from a dialog resource like the others.
+ ; Field 2 is the header and Field 3 is the body text.
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 2" "HWND"
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 3" "HWND"
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+FunctionEnd
+
+Function un.leaveWelcome
+ ${If} ${FileExists} "$INSTDIR\${FileMainEXE}"
+ Banner::show /NOUNLOAD "$(BANNER_CHECK_EXISTING)"
+
+ ; If the message window has been found previously give the app an additional
+ ; five seconds to close.
+ ${If} "$TmpVal" == "FoundMessageWindow"
+ Sleep 5000
+ ${EndIf}
+
+ ${PushFilesToCheck}
+
+ ${un.CheckForFilesInUse} $TmpVal
+
+ Banner::destroy
+
+ ; If there are files in use $TmpVal will be "true"
+ ${If} "$TmpVal" == "true"
+ ; If the message window is found the call to ManualCloseAppPrompt will
+ ; abort leaving the value of $TmpVal set to "FoundMessageWindow".
+ StrCpy $TmpVal "FoundMessageWindow"
+ ${un.ManualCloseAppPrompt} "${WindowClass}" "$(WARN_MANUALLY_CLOSE_APP_UNINSTALL)"
+ ; If the message window is not found set $TmpVal to "true" so the restart
+ ; required message is displayed.
+ StrCpy $TmpVal "true"
+ ${EndIf}
+ ${EndIf}
+FunctionEnd
+
+Function un.preConfirm
+ ; The header and subheader on the wizard pages don't get the correct text
+ ; color by default for some reason, even though the other controls do.
+ GetDlgItem $0 $HWNDPARENT 1037
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+ GetDlgItem $0 $HWNDPARENT 1038
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+
+ ${If} ${FileExists} "$INSTDIR\distribution\modern-header.bmp"
+ ${AndIf} $hHeaderBitmap == ""
+ Delete "$PLUGINSDIR\modern-header.bmp"
+ CopyFiles /SILENT "$INSTDIR\distribution\modern-header.bmp" "$PLUGINSDIR\modern-header.bmp"
+ ${un.ChangeMUIHeaderImage} "$PLUGINSDIR\modern-header.bmp"
+ ${EndIf}
+
+ ; Setup the unconfirm.ini file for the Custom Uninstall Confirm Page
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Settings" NumFields "3"
+
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 1" Type "label"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 1" Text "$(UN_CONFIRM_UNINSTALLED_FROM)"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 1" Left "0"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 1" Right "-1"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 1" Top "5"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 1" Bottom "15"
+
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 2" Type "text"
+ ; The contents of this control must be set as follows in the pre function
+ ; ${MUI_INSTALLOPTIONS_READ} $1 "unconfirm.ini" "Field 2" "HWND"
+ ; SendMessage $1 ${WM_SETTEXT} 0 "STR:$INSTDIR"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 2" State ""
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 2" Left "0"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 2" Right "-1"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 2" Top "17"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 2" Bottom "30"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 2" flags "READONLY"
+
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 3" Type "label"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 3" Text "$(UN_CONFIRM_CLICK)"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 3" Left "0"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 3" Right "-1"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 3" Top "130"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 3" Bottom "150"
+
+ ${If} "$TmpVal" == "true"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 4" Type "label"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 4" Text "$(SUMMARY_REBOOT_REQUIRED_UNINSTALL)"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 4" Left "0"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 4" Right "-1"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 4" Top "35"
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Field 4" Bottom "45"
+
+ WriteINIStr "$PLUGINSDIR\unconfirm.ini" "Settings" NumFields "4"
+ ${EndIf}
+
+ !insertmacro MUI_HEADER_TEXT "$(UN_CONFIRM_PAGE_TITLE)" "$(UN_CONFIRM_PAGE_SUBTITLE)"
+ ; The Summary custom page has a textbox that will automatically receive
+ ; focus. This sets the focus to the Install button instead.
+ !insertmacro MUI_INSTALLOPTIONS_INITDIALOG "unconfirm.ini"
+ GetDlgItem $0 $HWNDPARENT 1
+ System::Call "user32::SetFocus(i r0, i 0x0007, i,i)i"
+ ${MUI_INSTALLOPTIONS_READ} $1 "unconfirm.ini" "Field 2" "HWND"
+ SendMessage $1 ${WM_SETTEXT} 0 "STR:$INSTDIR"
+ !insertmacro MUI_INSTALLOPTIONS_SHOW
+FunctionEnd
+
+Function un.ShowFinish
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 2" "HWND"
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 3" "HWND"
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+
+ ; Either Fields 4 and 5 are the reboot option radio buttons, or Field 4 is
+ ; the survey checkbox and Field 5 doesn't exist. Either way, we need to
+ ; clear the theme from them before we can set their background colors.
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 4" "HWND"
+ System::Call 'uxtheme::SetWindowTheme(i $0, w " ", w " ")'
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+
+ ClearErrors
+ ReadINIStr $0 "$PLUGINSDIR\ioSpecial.ini" "Field 5" "HWND"
+ ${IfNot} ${Errors}
+ System::Call 'uxtheme::SetWindowTheme(i $0, w " ", w " ")'
+ SetCtlColors $0 SYSCLR:WINDOWTEXT SYSCLR:WINDOW
+ ${EndIf}
+FunctionEnd
+
+################################################################################
+# Initialization Functions
+
+Function .onInit
+ ; Remove the current exe directory from the search order.
+ ; This only effects LoadLibrary calls and not implicitly loaded DLLs.
+ System::Call 'kernel32::SetDllDirectoryW(w "")'
+
+ ; We need this set up for most of the helper.exe operations.
+ ${UninstallOnInitCommon}
+FunctionEnd
+
+Function un.onInit
+ ; Remove the current exe directory from the search order.
+ ; This only effects LoadLibrary calls and not implicitly loaded DLLs.
+ System::Call 'kernel32::SetDllDirectoryW(w "")'
+
+ StrCpy $LANGUAGE 0
+
+ ${un.UninstallUnOnInitCommon}
+
+ !insertmacro InitInstallOptionsFile "unconfirm.ini"
+FunctionEnd
+
+Function .onGUIEnd
+ ${OnEndCommon}
+FunctionEnd
+
+Function un.onGUIEnd
+ ${un.OnEndCommon}
+FunctionEnd
diff --git a/comm/mail/installer/windows/nsis/updater_append.ini b/comm/mail/installer/windows/nsis/updater_append.ini
new file mode 100644
index 0000000000..af7742c12c
--- /dev/null
+++ b/comm/mail/installer/windows/nsis/updater_append.ini
@@ -0,0 +1,12 @@
+
+; IMPORTANT: This file should always start with a newline in case a locale
+; provided updater.ini does not end with a newline.
+; Application to launch after an update has been successfully applied. This
+; must be in the same directory or a sub-directory of the directory of the
+; application executable that initiated the software update.
+[PostUpdateWin]
+; ExeRelPath is the path to the PostUpdateWin executable relative to the
+; application executable.
+ExeRelPath=uninstall\helper.exe
+; ExeArg is the argument to pass to the PostUpdateWin exe
+ExeArg=/PostUpdate
diff --git a/comm/mail/locales/Makefile.in b/comm/mail/locales/Makefile.in
new file mode 100644
index 0000000000..d79366b4c9
--- /dev/null
+++ b/comm/mail/locales/Makefile.in
@@ -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/.
+
+LOCALE_TOPDIR=$(commtopsrcdir)
+LOCALE_RELATIVEDIR=mail/locales
+
+include $(topsrcdir)/config/config.mk
+
+SUBMAKEFILES += \
+ $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/Makefile \
+ $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/locales/Makefile \
+ $(NULL)
+
+# This makefile uses variable overrides from the l10n-% target to
+# build non-default locales to non-default dist/ locations. Be aware!
+
+PWD := $(CURDIR)
+
+# These are defaulted to be compatible with the files the wget-en-US target
+# pulls. You may override them if you provide your own files.
+ZIP_IN ?= $(ABS_DIST)/$(PACKAGE)
+
+MOZ_LANGPACK_EID=langpack-$(AB_CD)@thunderbird.mozilla.org
+# For Nightly, we know where to get the builds from to do local repacks
+ifdef NIGHTLY_BUILD
+export EN_US_BINARY_URL ?= https://archive.mozilla.org/pub/thunderbird/nightly/latest-comm-central
+endif
+
+ifneq (,$(filter cocoa,$(MOZ_WIDGET_TOOLKIT)))
+MOZ_PKG_MAC_DSSTORE=$(ABS_DIST)/branding/dsstore
+MOZ_PKG_MAC_BACKGROUND=$(ABS_DIST)/branding/background.png
+MOZ_PKG_MAC_ICON=$(ABS_DIST)/branding/disk.icns
+MOZ_PKG_MAC_EXTRA=--symlink '/Applications:/ '
+endif
+
+NON_OMNIJAR_FILES = defaults/messenger/mailViews.dat
+
+# Required for l10n.mk - defines a list of app sub dirs that should
+# be included in langpack xpis.
+DIST_SUBDIRS = $(DIST_SUBDIR)
+
+include $(topsrcdir)/config/rules.mk
+
+include $(topsrcdir)/toolkit/locales/l10n.mk
+
+ifeq ($(VCS_CHECKOUT_TYPE),hg)
+L10N_CO = $(PYTHON3) $(topsrcdir)/comm/python/l10n/l10n_clone/l10n_clone.py $(AB_CD) $(L10NBASEDIR)
+else
+L10N_CO = $(error You need to use hg)
+endif
+
+l10n-%: AB_CD=$*
+l10n-%:
+# merge if we're not en-US. Conditional function because
+# we need the current value of AB_CD.
+ $(if $(filter en-US,$(AB_CD)),, @$(MAKE) merge-$*)
+ $(if $(filter en-US,$(AB_CD)),, @$(MAKE) post-merge-$*)
+ $(NSINSTALL) -D $(DIST)/install
+ @$(MAKE) -C $(DEPTH)/toolkit/locales l10n-$* XPI_ROOT_APPID='$(XPI_ROOT_APPID)'
+ @$(MAKE) -C $(DEPTH)/devtools/client/locales AB_CD=$* XPI_NAME=locale-$* XPI_ROOT_APPID='$(XPI_ROOT_APPID)'
+ @$(MAKE) -C $(DEPTH)/devtools/startup/locales AB_CD=$* XPI_NAME=locale-$* XPI_ROOT_APPID='$(XPI_ROOT_APPID)'
+ @$(MAKE) -C ../../chat/locales AB_CD=$* XPI_NAME=locale-$*
+ @$(MAKE) -C ../../calendar/locales AB_CD=$* XPI_NAME=locale-$*
+ @$(MAKE) -C $(DEPTH)/extensions/spellcheck/locales AB_CD=$* XPI_NAME=locale-$*
+ @$(MAKE) l10n AB_CD=$* XPI_NAME=locale-$* PREF_DIR=$(PREF_DIR)
+ @$(MAKE) multilocale.txt-$* AB_CD=$* XPI_NAME=locale-$*
+ @$(MAKE) -C $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/locales AB_CD=$* XPI_NAME=locale-$*
+
+post-merge-%:
+ $(PYTHON3) $(MOZILLA_DIR)/mach tb-add-missing-ftls --merge $(BASE_MERGE) $*
+
+package-win32-installer: $(SUBMAKEFILES)
+ $(MAKE) -C ../installer/windows CONFIG_DIR=l10ngen ZIP_IN='$(ZIP_OUT)' installer
+
+ifdef MOZ_ARTIFACT_BUILDS
+langpack:
+ @echo 'Skipping language pack creation'
+else
+langpack: langpack-$(AB_CD)
+endif
+
+# This is a generic target that will make a langpack, repack ZIP (+tarball)
+# builds, and repack an installer if applicable. It is called from the
+# tinderbox scripts. Alter it with caution.
+
+installers-%: IS_LANGUAGE_REPACK=1
+installers-%:
+ @$(MAKE) clobber-$*
+ @$(MAKE) l10n-$*
+ @$(MAKE) package-langpack-$*
+ @$(MAKE) repackage-zip-$*
+ifeq (WINNT,$(OS_ARCH))
+ @$(MAKE) package-win32-installer AB_CD=$*
+endif
+ @echo 'repackaging done'
+
+ident:
+ @printf 'comm_revision '
+ @$(PYTHON3) $(topsrcdir)/config/printconfigsetting.py \
+ '$(STAGEDIST)/application.ini' App SourceStamp
+ @printf 'moz_revision '
+ @$(PYTHON3) $(topsrcdir)/config/printconfigsetting.py \
+ '$(STAGEDIST)/platform.ini' Build SourceStamp
+ @printf 'buildid '
+ @$(PYTHON3) $(topsrcdir)/config/printconfigsetting.py \
+ '$(STAGEDIST)/application.ini' App BuildID
diff --git a/comm/mail/locales/all-locales b/comm/mail/locales/all-locales
new file mode 100644
index 0000000000..f3b6beb4dd
--- /dev/null
+++ b/comm/mail/locales/all-locales
@@ -0,0 +1,67 @@
+af
+ar
+ast
+be
+bg
+br
+ca
+cak
+cs
+cy
+da
+de
+dsb
+el
+en-CA
+en-GB
+es-AR
+es-ES
+es-MX
+et
+eu
+fi
+fr
+fy-NL
+ga-IE
+gd
+gl
+he
+hr
+hsb
+hu
+hy-AM
+id
+is
+it
+ja
+ja-JP-mac
+ka
+kab
+kk
+ko
+lt
+lv
+mk
+ms
+nb-NO
+nl
+nn-NO
+pa-IN
+pl
+pt-BR
+pt-PT
+rm
+ro
+ru
+sk
+sl
+sq
+sr
+sv-SE
+th
+tr
+uk
+uz
+vi
+zh-CN
+zh-TW
diff --git a/comm/mail/locales/en-US/all-l10n.js b/comm/mail/locales/en-US/all-l10n.js
new file mode 100644
index 0000000000..8d0fb84064
--- /dev/null
+++ b/comm/mail/locales/en-US/all-l10n.js
@@ -0,0 +1,7 @@
+# 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/.
+
+#filter substitution
+
+pref("spellchecker.dictionary", "@AB_CD@");
diff --git a/comm/mail/locales/en-US/browser/appExtensionFields.ftl b/comm/mail/locales/en-US/browser/appExtensionFields.ftl
new file mode 100644
index 0000000000..001ffc0161
--- /dev/null
+++ b/comm/mail/locales/en-US/browser/appExtensionFields.ftl
@@ -0,0 +1,15 @@
+# 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/.
+
+## Theme names and descriptions used in the Themes panel in about:addons
+
+# "Auto" is short for automatic. It can be localized without limitations.
+extension-default-theme-name-auto=System theme — auto
+extension-default-theme-description=Follow the operating system setting for buttons, menus, and windows.
+
+extension-thunderbird-compact-light-name=Light
+extension-thunderbird-compact-light-description=A theme with a light color scheme.
+
+extension-thunderbird-compact-dark-name=Dark
+extension-thunderbird-compact-dark-description=A theme with a dark color scheme.
diff --git a/comm/mail/locales/en-US/browser/branding/brandings.ftl b/comm/mail/locales/en-US/browser/branding/brandings.ftl
new file mode 100644
index 0000000000..c0d2655308
--- /dev/null
+++ b/comm/mail/locales/en-US/browser/branding/brandings.ftl
@@ -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/.
+
+## The following feature names must be treated as a brand.
+##
+## They cannot be:
+## - Transliterated.
+## - Translated.
+##
+## Declension should be avoided where possible, leaving the original
+## brand unaltered in prominent UI positions.
+##
+## For further details, consult:
+## https://mozilla-l10n.github.io/styleguides/mozilla_general/#brands-copyright-and-trademark
+
+-profiler-brand-name = Firefox Profiler
diff --git a/comm/mail/locales/en-US/browser/components/mozSupportLink.ftl b/comm/mail/locales/en-US/browser/components/mozSupportLink.ftl
new file mode 100644
index 0000000000..0cfaf42816
--- /dev/null
+++ b/comm/mail/locales/en-US/browser/components/mozSupportLink.ftl
@@ -0,0 +1,5 @@
+# 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/.
+
+moz-support-link-text = Learn more
diff --git a/comm/mail/locales/en-US/chrome/communicator/utilityOverlay.dtd b/comm/mail/locales/en-US/chrome/communicator/utilityOverlay.dtd
new file mode 100644
index 0000000000..98c8912440
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/communicator/utilityOverlay.dtd
@@ -0,0 +1,32 @@
+<!-- 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/. -->
+
+<!-- LOCALIZATION NOTE : FILE This file contains the global menu items -->
+
+<!ENTITY fileMenu.label "File">
+<!ENTITY fileMenu.accesskey "F">
+<!ENTITY newMenu.label "New">
+<!ENTITY newMenu.accesskey "N">
+
+<!ENTITY editMenu.label "Edit">
+<!ENTITY editMenu.accesskey "E">
+<!ENTITY undoCmd.label "Undo">
+<!ENTITY undoCmd.accesskey "U">
+<!ENTITY redoCmd.label "Redo">
+<!ENTITY redoCmd.accesskey "R">
+<!ENTITY deleteCmd.label "Delete">
+<!ENTITY deleteCmd.accesskey "D">
+<!ENTITY customizeCmd.label "Customize">
+<!ENTITY customizeCmd.accesskey "t">
+
+<!ENTITY viewMenu.label "View">
+<!ENTITY viewMenu.accesskey "V">
+<!ENTITY viewToolbarsMenu.label "Toolbars">
+<!ENTITY viewToolbarsMenu.accesskey "T">
+<!ENTITY showTaskbarCmd.label "Status Bar">
+<!ENTITY showTaskbarCmd.accesskey "u">
+
+<!ENTITY closeCmd.label "Close">
+<!ENTITY closeCmd.key "W">
+<!ENTITY closeCmd.accesskey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger-mapi/mapi.properties b/comm/mail/locales/en-US/chrome/messenger-mapi/mapi.properties
new file mode 100644
index 0000000000..69ed6a3e0e
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-mapi/mapi.properties
@@ -0,0 +1,35 @@
+# 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/.
+
+# Mail Integration Dialog
+dialogTitle=%S
+dialogText=Do you want to use %S as the default mail application?
+newsDialogText=Do you want to use %S as the default news application?
+feedDialogText=Do you want to use %S as the default feed aggregator?
+checkboxText=Do not display this dialog again
+setDefaultMail=%S is not currently set as your default mail application. Would you like to make it your default mail application?
+setDefaultNews=%S is not currently set as your default news application. Would you like to make it your default news application?
+setDefaultFeed=%S is not currently set as your default feed aggregator. Would you like to make it your default feed aggregator?
+alreadyDefaultMail=%S is already set as your default mail application.
+alreadyDefaultNews=%S is already set as your default news application.
+alreadyDefaultFeed=%S is already set as your default feed aggregator.
+
+# MAPI Messages
+loginText=Please enter your password for %S:
+loginTextwithName=Please enter your username and password
+loginTitle=%S
+PasswordTitle=%S
+
+# MAPI Error Messages
+errorMessage=%S could not be set as the default mail application because a registry key could not be updated. Verify with your system administrator that you have write access to your system registry, and then try again.
+errorMessageNews=%S could not be set as the default news application because a registry key could not be updated. Verify with your system administrator that you have write access to your system registry, and then try again.
+errorMessageTitle=%S
+
+# MAPI Security Messages
+mapiBlindSendWarning=Another application is attempting to send mail using your user profile. Are you sure you want to send mail?
+mapiBlindSendDontShowAgain=Warn me whenever other applications try to send mail from me
+
+#Default Mail Display String
+# localization note, %S is the vendor name
+defaultMailDisplayTitle=%S
diff --git a/comm/mail/locales/en-US/chrome/messenger-newsblog/am-newsblog.dtd b/comm/mail/locales/en-US/chrome/messenger-newsblog/am-newsblog.dtd
new file mode 100644
index 0000000000..d7a6851bef
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-newsblog/am-newsblog.dtd
@@ -0,0 +1,14 @@
+<!-- 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/. -->
+
+<!ENTITY biffAll.label "Enable updates for all feeds">
+<!ENTITY biffAll.accesskey "E">
+
+<!ENTITY newFeedSettings.label "Default Settings for New Feeds">
+
+<!ENTITY manageSubscriptions.label "Manage Subscriptions…">
+<!ENTITY manageSubscriptions.accesskey "M">
+
+<!ENTITY feedWindowTitle.label "Feed Account Wizard">
+<!ENTITY feeds.accountName "Blogs &amp; News Feeds">
diff --git a/comm/mail/locales/en-US/chrome/messenger-newsblog/feed-subscriptions.dtd b/comm/mail/locales/en-US/chrome/messenger-newsblog/feed-subscriptions.dtd
new file mode 100644
index 0000000000..0df7f953aa
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-newsblog/feed-subscriptions.dtd
@@ -0,0 +1,55 @@
+<!-- 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/. -->
+
+<!-- Subscription Dialog -->
+<!ENTITY feedSubscriptions.label "Feed Subscriptions">
+<!ENTITY learnMore.label "Learn more about Feeds">
+
+<!ENTITY feedTitle.label "Title:">
+<!ENTITY feedTitle.accesskey "T">
+
+<!ENTITY feedLocation.label "Feed URL:">
+<!ENTITY feedLocation.accesskey "F">
+<!ENTITY feedLocation2.placeholder "Enter a valid feed url">
+<!ENTITY locationValidate.label "Validate">
+<!ENTITY validateText.label "Check validation and retrieve a valid url.">
+
+<!ENTITY feedFolder.label "Store Articles in:">
+<!ENTITY feedFolder.accesskey "S">
+
+<!-- Account Settings and Subscription Dialog -->
+<!ENTITY biffStart.label "Check for new articles every ">
+<!ENTITY biffStart.accesskey "k">
+<!ENTITY biffMinutes.label "minutes">
+<!ENTITY biffMinutes.accesskey "n">
+<!ENTITY biffDays.label "days">
+<!ENTITY biffDays.accesskey "d">
+<!ENTITY recommendedUnits.label "Publisher recommends:">
+
+<!ENTITY quickMode.label "Show the article summary instead of loading the web page">
+<!ENTITY quickMode.accesskey "h">
+
+<!ENTITY autotagEnable.label "Automatically create tags from feed &lt;category&gt; names">
+<!ENTITY autotagEnable.accesskey "o">
+<!ENTITY autotagUsePrefix.label "Prefix tags with:">
+<!ENTITY autotagUsePrefix.accesskey "P">
+<!ENTITY autoTagPrefix.placeholder "Enter a tag prefix">
+
+<!-- Subscription Dialog -->
+<!ENTITY button.addFeed.label "Add">
+<!ENTITY button.addFeed.accesskey "A">
+<!ENTITY button.verifyFeed.label "Verify">
+<!ENTITY button.verifyFeed.accesskey "V">
+<!ENTITY button.updateFeed.label "Update">
+<!ENTITY button.updateFeed.accesskey "U">
+<!ENTITY button.removeFeed.label "Remove">
+<!ENTITY button.removeFeed.accesskey "R">
+<!ENTITY button.importOPML.label "Import">
+<!ENTITY button.importOPML.accesskey "I">
+<!ENTITY button.exportOPML.label "Export">
+<!ENTITY button.exportOPML.accesskey "X">
+<!ENTITY button.exportOPML.tooltip "Export Feeds with folder structure; ctrl click or ctrl enter to export Feeds as a list">
+
+<!ENTITY cmd.close.commandKey "w">
+<!ENTITY button.close.label "Close">
diff --git a/comm/mail/locales/en-US/chrome/messenger-newsblog/newsblog.properties b/comm/mail/locales/en-US/chrome/messenger-newsblog/newsblog.properties
new file mode 100644
index 0000000000..972d2af4f4
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-newsblog/newsblog.properties
@@ -0,0 +1,93 @@
+# 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/.
+
+subscribe-validating-feed=Verifying the feed…
+subscribe-cancelSubscription=Are you sure you wish to cancel subscribing to the current feed?
+subscribe-cancelSubscriptionTitle=Subscribing to a Feed…
+subscribe-feedAlreadySubscribed=You already have a subscription for this feed.
+subscribe-errorOpeningFile=Could not open the file.
+subscribe-feedAdded=Feed added.
+subscribe-feedUpdated=Feed updated.
+subscribe-feedMoved=Feed subscription moved.
+subscribe-feedCopied=Feed subscription copied.
+subscribe-feedRemoved=Feed unsubscribed.
+subscribe-feedNotValid=The Feed URL is not a valid feed.
+subscribe-feedVerified=The Feed URL has been verified.
+subscribe-networkError=The Feed URL could not be found. Please check the name and try again.
+subscribe-noAuthError=The Feed URL is not authorized.
+subscribe-loading=Loading, please wait…
+
+subscribe-OPMLImportTitle=Select OPML file to import
+## LOCALIZATION NOTE(subscribe-OPMLExportTitleList):
+## %S is the name of the feed account folder name.
+subscribe-OPMLExportTitleList=Export %S as an OPML file - Feeds list
+## LOCALIZATION NOTE(subscribe-OPMLExportTitleStruct):
+## %S is the name of the feed account folder name.
+subscribe-OPMLExportTitleStruct=Export %S as an OPML file - Feeds with folder structure
+## LOCALIZATION NOTE(subscribe-OPMLExportFileDialogTitle):
+## %1$S is the brandShortName, %2$S is the name of the feed account folder name.
+subscribe-OPMLExportFileDialogTitle=%1$S OPML Export - %2$S
+## LOCALIZATION NOTE(subscribe-OPMLExportDefaultFileName):
+## %1$S is the brandShortName (Thunderbird for example), %2$S is the account name.
+## The default extension (.opml) is added here as it is not automatically appended in the file picker on MacOS.
+subscribe-OPMLExportDefaultFileName=My%1$SFeeds-%2$S.opml
+## LOCALIZATION NOTE(subscribe-OPMLImportInvalidFile): %S is the name of the OPML file the user tried to import.
+subscribe-OPMLImportInvalidFile=The file %S does not seem to be a valid OPML file.
+## LOCALIZATION NOTE(subscribe-OPMLImportFeedCount): Semi-colon list of plural forms.
+## See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+## #1 is the count of new imported entries.
+subscribe-OPMLImportFeedCount=Imported #1 new feed.;Imported #1 new feeds.
+## LOCALIZATION NOTE(subscribe-OPMLImportUniqueFeeds): Semi-colon list of plural forms.
+## #1 is the count of new imported entries
+subscribe-OPMLImportUniqueFeeds=Imported #1 new feed to which you aren't already subscribed;Imported #1 new feeds to which you aren't already subscribed
+## LOCALIZATION NOTE(subscribe-OPMLImportFoundFeeds):
+## #1 is total number of elements found in the file
+subscribe-OPMLImportFoundFeeds=(out of #1 entry found);(out of #1 total entries found)
+## LOCALIZATION NOTE(subscribe-OPMLImportStatus):
+## This is the concatenation of the two strings defined above to compose 1 sentence.
+## %1$S = subscribe-OPMLImportUniqueFeeds
+## %2$S = subscribe-OPMLImportFoundFeeds
+subscribe-OPMLImportStatus=%1$S %2$S.
+
+subscribe-OPMLExportOPMLFilesFilterText=OPML Files
+## LOCALIZATION NOTE(subscribe-OPMLExportDone): %S is the export file name.
+subscribe-OPMLExportDone=Feeds in this account have been exported to %S.
+
+subscribe-confirmFeedDeletionTitle=Remove Feed
+## LOCALIZATION NOTE(subscribe-confirmFeedDeletion): %S is the name of the feed the user wants to unsubscribe from.
+subscribe-confirmFeedDeletion=Are you sure you want to unsubscribe from the feed: \n %S?
+
+## LOCALIZATION NOTE(subscribe-gettingFeedItems):
+## - The first %S is the number of articles processed so far;
+## - The second %S is the total number of items
+subscribe-gettingFeedItems=Downloading feed articles (%S of %S)…
+
+newsblog-noNewArticlesForFeed=There are no new articles for this feed.
+## LOCALIZATION NOTE(newsblog-networkError): %S is the feed URL
+newsblog-networkError=%S could not be found. Please check the name and try again.
+## LOCALIZATION NOTE(newsblog-feedNotValid): %S is the feed URL
+newsblog-feedNotValid=%S is not a valid feed.
+## LOCALIZATION NOTE(newsblog-badCertError): %S is the feed URL host
+newsblog-badCertError=%S uses an invalid security certificate.
+## LOCALIZATION NOTE(newsblog-noAuthError): %S is the feed URL
+newsblog-noAuthError=%S is not authorized.
+newsblog-getNewMsgsCheck=Checking feeds for new items…
+
+## LOCALIZATION NOTE(feeds-accountname): This string should be the same as feeds.accountName in am-newsblog.dtd
+feeds-accountname=Blogs & News Feeds
+
+## LOCALIZATION NOTE(externalAttachmentMsg): Content in the MIME part for external link attachments.
+externalAttachmentMsg=This MIME attachment is stored separately from the message.
+
+## Import wizard.
+ImportFeedsCreateNewListItem=* New Account *
+ImportFeedsNewAccount=Create and import into a new Feeds account
+ImportFeedsExistingAccount=Import into an existing Feeds account
+## LOCALIZATION NOTE(ImportFeedsDone):
+## - The first %S is the import file name;
+## - The second %S is the value of either ImportFeedsNew or ImportFeedsExisting;
+## - The third %S is the feed account name.
+ImportFeedsNew=new
+ImportFeedsExisting=existing
+ImportFeedsDone=The feed subscriptions import from file %1$S into %2$S account '%3$S' has finished.
diff --git a/comm/mail/locales/en-US/chrome/messenger-region/region.properties b/comm/mail/locales/en-US/chrome/messenger-region/region.properties
new file mode 100644
index 0000000000..14e9df18b9
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-region/region.properties
@@ -0,0 +1,9 @@
+# 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/.
+
+mailnews.messageid_browser.url=https://groups.google.com/search?as_umsgid=%mid
+
+# Recognize non-standard versions of "Re:" in subjects from localized versions of MS Outlook et al.
+# Specify a comma-separated list without spaces. For example: mailnews.localizedRe=AW,SV
+mailnews.localizedRe=
diff --git a/comm/mail/locales/en-US/chrome/messenger-smime/certFetchingStatus.dtd b/comm/mail/locales/en-US/chrome/messenger-smime/certFetchingStatus.dtd
new file mode 100644
index 0000000000..8673b9ed9d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-smime/certFetchingStatus.dtd
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+
+<!--LOCALIZATION NOTE shown while obtaining certificates from a directory -->
+
+<!ENTITY title.label "Downloading Certificates">
+<!ENTITY info.message "Searching the directory for recipients' certificates. This may take a few minutes.">
+<!ENTITY stop.label "Stop Searching">
diff --git a/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSMIMEOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSMIMEOverlay.dtd
new file mode 100644
index 0000000000..32e2ea6546
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSMIMEOverlay.dtd
@@ -0,0 +1,10 @@
+<!-- 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/. -->
+
+<!--LOCALIZATION NOTE msgCompSMIMEOverlay.dtd UI for s/mime hooks in message composition -->
+
+<!ENTITY menu_techPGP.label "OpenPGP">
+<!ENTITY menu_techPGP.accesskey "O">
+<!ENTITY menu_techSMIME.label "S/MIME">
+<!ENTITY menu_techSMIME.accesskey "S">
diff --git a/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSecurityInfo.dtd b/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSecurityInfo.dtd
new file mode 100644
index 0000000000..d8dbe6df27
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSecurityInfo.dtd
@@ -0,0 +1,18 @@
+<!-- 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/. -->
+
+<!--LOCALIZATION NOTE msgCompSecurityInfo.dtd UI for viewing security status when composing a message -->
+
+<!ENTITY title.label "Message Security">
+<!ENTITY subject.plaintextWarning "Please note: Subject lines of email messages are never encrypted.">
+<!ENTITY status.heading "The contents of your message will be sent as follows:">
+<!ENTITY status.signed "Digitally signed:">
+<!ENTITY status.encrypted "Encrypted:">
+<!ENTITY status.certificates "Certificates:">
+<!ENTITY view.label "View">
+<!ENTITY view.accesskey "V">
+<!ENTITY tree.recipient "Recipient">
+<!ENTITY tree.status "Status">
+<!ENTITY tree.issuedDate "Issued">
+<!ENTITY tree.expiresDate "Expires">
diff --git a/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSecurityInfo.properties b/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSecurityInfo.properties
new file mode 100644
index 0000000000..bf8ca06592
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-smime/msgCompSecurityInfo.properties
@@ -0,0 +1,13 @@
+# 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/.
+
+StatusNotFound=Not Found
+StatusValid=Valid
+StatusExpired=Expired
+StatusUntrusted=Not Trusted
+StatusRevoked=Revoked
+StatusInvalid=Invalid
+StatusYes=Yes
+StatusNo=No
+StatusNotPossible=Not possible
diff --git a/comm/mail/locales/en-US/chrome/messenger-smime/msgReadSMIMEOverlay.properties b/comm/mail/locales/en-US/chrome/messenger-smime/msgReadSMIMEOverlay.properties
new file mode 100644
index 0000000000..5980bf2d69
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-smime/msgReadSMIMEOverlay.properties
@@ -0,0 +1,11 @@
+# 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/.
+
+ImapOnDemand=The displayed message has been digitally signed, but not all its attachments have been downloaded yet. Therefore, the signature cannot be validated. Click OK to download the complete message and validate the signature.
+#
+#NOTE To translator, anything between %..% and <..> should not be translated.
+# the former will be replaced by java script, and the latter is HTML formatting.
+#
+CantDecryptTitle=%brand% cannot decrypt this message
+CantDecryptBody=The sender encrypted this message to you using one of your digital certificates, however %brand% was not able to find this certificate and corresponding private key. <br> Possible solutions: <br><ul><li>If you have a smartcard, please insert it now. <li>If you are using a new machine, or if you are using a new %brand% profile, you will need to restore your certificate and private key from a backup. Certificate backups usually end in ".p12".</ul>
diff --git a/comm/mail/locales/en-US/chrome/messenger-smime/msgReadSecurityInfo.dtd b/comm/mail/locales/en-US/chrome/messenger-smime/msgReadSecurityInfo.dtd
new file mode 100644
index 0000000000..8ce7ec95f1
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-smime/msgReadSecurityInfo.dtd
@@ -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/. -->
+
+<!--LOCALIZATION NOTE msgReadSecurityInfo.dtd UI for viewing security status when reading a received message -->
+
+<!ENTITY status.label "Message Security">
+<!ENTITY signatureCert.label "View Signature Certificate">
+<!ENTITY encryptionCert.label "View Encryption Certificate">
+
+<!ENTITY signer.name "Signed by:">
+<!ENTITY recipient.name "Encrypted for:">
+<!ENTITY email.address "Email address:">
+<!ENTITY issuer.name "Certificate issued by:">
+
+<!-- LOCALIZATION NOTE(SMIME.label): This a name for a technical standard. You should not translate it, but if applicable, you may write it using localized characters. -->
+<!ENTITY SMIME.label "S/MIME">
diff --git a/comm/mail/locales/en-US/chrome/messenger-smime/msgSecurityInfo.properties b/comm/mail/locales/en-US/chrome/messenger-smime/msgSecurityInfo.properties
new file mode 100644
index 0000000000..d3bcd87054
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger-smime/msgSecurityInfo.properties
@@ -0,0 +1,36 @@
+# 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/.
+
+## Signature Information strings
+SINoneLabel=Message Has No Digital Signature
+SINone=This message does not include the sender's digital signature. The absence of a digital signature means that the message could have been sent by someone pretending to have this email address. It is also possible that the message has been altered while in transit over the network. However, it is unlikely that either event has occurred.
+SIValidLabel=Message Is Signed
+SIValid=This message includes a valid digital signature. The message has not been altered since it was sent.
+SIInvalidLabel=Digital Signature Is Not Valid
+SIInvalidHeader=This message includes a digital signature, but the signature is invalid.
+SIContentAltered=The signature does not match the message content correctly. The message appears to have been altered after the sender signed it. You should not trust the validity of this message until you verify its contents with the sender.
+SIExpired=The certificate used to sign the message appears to have expired. Make sure your computer's clock is set correctly.
+SIRevoked=The certificate used to sign the message has been revoked. You should not trust the validity of this message until you verify its contents with the sender.
+SINotYetValid=The certificate used to sign the message appears not to be valid yet. Make sure your computer's clock is set correctly.
+SIUnknownCA=The certificate used to sign the message was issued by an unknown certificate authority.
+SIUntrustedCA=The certificate used to sign the message was issued by a certificate authority that you do not trust for issuing this kind of certificate.
+SIExpiredCA=The certificate used to sign the message was issued by a certificate authority whose own certificate has expired. Make sure your computer's clock is set correctly.
+SIRevokedCA=The certificate used to sign the message was issued by a certificate authority whose own certificate has been revoked. You should not trust the validity of this message until you verify its contents with the sender.
+SINotYetValidCA=The certificate used to sign the message was issued by a certificate authority whose own certificate is not yet valid. Make sure your computer's clock is set correctly.
+SIInvalidCipher=The message was signed using an encryption strength that this version of your software does not support.
+SIClueless=There are unknown problems with this digital signature. You should not trust the validity of this message until you verify its contents with the sender.
+SIPartiallyValidLabel=Message is signed
+SIPartiallyValidHeader=Although the digital signature is valid, it is unknown whether sender and signer are the same person.
+SIHeaderMismatch=The email address listed in the signer's certificate is different from the email address that was used to send this message. Please look at the details of the signature certificate to learn who signed the message.
+SICertWithoutAddress=The certificate used to sign the message does not contain an email address. Please look at the details of the signature certificate to learn who signed the message.
+
+## Encryption Information strings
+EINoneLabel2=Message Is Not Encrypted
+EINone=This message was not encrypted before it was sent. Information sent over the Internet without encryption can be seen by other people while in transit.
+EIValidLabel=Message Is Encrypted
+EIValid=This message was encrypted before it was sent to you. Encryption makes it very difficult for other people to view information while it is traveling over the network.
+EIInvalidLabel=Message Cannot Be Decrypted
+EIInvalidHeader=This message was encrypted before it was sent to you, but it cannot be decrypted.
+EIContentAltered=The message contents appear to have been altered during transmission.
+EIClueless=There are unknown problems with this encrypted message.
diff --git a/comm/mail/locales/en-US/chrome/messenger/AccountManager.dtd b/comm/mail/locales/en-US/chrome/messenger/AccountManager.dtd
new file mode 100644
index 0000000000..84d9069d56
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/AccountManager.dtd
@@ -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/. -->
+
+<!-- extracted from AccountManager.xhtml -->
+
+<!ENTITY accountManagerTitle.label "Account Settings">
+<!ENTITY accountManagerCloseButton.label "Close">
+
+<!ENTITY accountActionsButton.label "Account Actions">
+<!ENTITY accountActionsButton.accesskey "A">
+<!ENTITY addMailAccountButton.label "Add Mail Account…">
+<!ENTITY addMailAccountButton.accesskey "A">
+<!ENTITY addIMAccountButton.label "Add Chat Account…">
+<!ENTITY addIMAccountButton.accesskey "C">
+<!ENTITY addFeedAccountButton.label "Add Feed Account…">
+<!ENTITY addFeedAccountButton.accesskey "F">
+<!ENTITY setDefaultButton.label "Set as Default">
+<!ENTITY setDefaultButton.accesskey "D">
+<!ENTITY removeButton.label "Remove Account">
+<!ENTITY removeButton.accesskey "R">
+
+<!ENTITY addonsButton.label "Extensions &amp; Themes">
diff --git a/comm/mail/locales/en-US/chrome/messenger/AccountWizard.dtd b/comm/mail/locales/en-US/chrome/messenger/AccountWizard.dtd
new file mode 100644
index 0000000000..2e88164fdc
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/AccountWizard.dtd
@@ -0,0 +1,50 @@
+<!-- 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/. -->
+
+<!-- Entities for AccountWizard -->
+
+<!ENTITY windowTitle.label "Account Wizard">
+
+<!-- Entities for Identity page -->
+
+<!ENTITY identityTitle.label "Identity">
+<!ENTITY identityDesc.label "Each account has an identity, which is the information that identifies you to others when they receive your messages.">
+
+<!-- LOCALIZATION NOTE (fullnameDesc.label) : do not translate two of "&quot;" in below line -->
+<!ENTITY fullnameDesc.label "Enter the name you would like to appear in the &quot;From&quot; field of your outgoing messages">
+<!-- LOCALIZATION NOTE (fullnameExample.label) : use following directions for below line
+ 1, do not translate two of "&quot;"
+ 2, Use localized full name instead of "John Smith"
+-->
+<!ENTITY fullnameExample.label "(for example, &quot;John Smith&quot;).">
+<!ENTITY fullnameLabel.label "Your Name:">
+<!ENTITY fullnameLabel.accesskey "Y">
+
+<!ENTITY emailLabel.label "Email Address:">
+<!ENTITY emailLabel.accesskey "E">
+
+<!-- Entities for Incoming Server page -->
+
+<!ENTITY incomingTitle.label "Incoming Server Information">
+<!ENTITY incomingUsername.label "User Name:">
+<!-- LOCALIZATION NOTE (newsServerNameDesc.label) : Do not translate "NNTP" or the "&quot;" entities in below line -->
+<!ENTITY newsServerNameDesc.label "Enter the name of your news server (NNTP) (for example, &quot;news.example.net&quot;).">
+<!ENTITY newsServerLabel.label "Newsgroup Server:">
+<!ENTITY newsServerLabel.accesskey "S">
+
+<!-- Entities for Account name page -->
+
+<!ENTITY accnameTitle.label "Account Name">
+<!-- LOCALIZATION NOTE (accnameDesc.label) : do not translate any "&quot;" in below line -->
+<!ENTITY accnameDesc.label "Enter the name by which you would like to refer to this account (for example, &quot;Work Account&quot;, &quot;Home Account&quot; or &quot;News Account&quot;).">
+<!ENTITY accnameLabel.label "Account Name:">
+<!ENTITY accnameLabel.accesskey "A">
+
+<!-- Entities for Done (Congratulations) page -->
+
+<!ENTITY completionTitle.label "Congratulations!">
+<!ENTITY completionText.label "Please verify that the information below is correct.">
+<!ENTITY newsServerNamePrefix.label "News Server Name (NNTP):">
+<!ENTITY clickFinish.label "Click Finish to save these settings and exit the Account Wizard.">
+<!ENTITY clickFinish.labelMac "Click Done to save these settings and exit the Account Wizard.">
diff --git a/comm/mail/locales/en-US/chrome/messenger/CustomHeaders.dtd b/comm/mail/locales/en-US/chrome/messenger/CustomHeaders.dtd
new file mode 100644
index 0000000000..0f0858c0ae
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/CustomHeaders.dtd
@@ -0,0 +1,11 @@
+<!-- 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/. -->
+
+<!ENTITY window.title "Customize Headers">
+<!ENTITY addButton.label "Add">
+<!ENTITY addButton.accesskey "A">
+<!ENTITY removeButton.label "Remove">
+<!ENTITY removeButton.accesskey "R">
+<!ENTITY newMsgHeader.label "New message header:">
+<!ENTITY newMsgHeader.accesskey "N">
diff --git a/comm/mail/locales/en-US/chrome/messenger/FilterEditor.dtd b/comm/mail/locales/en-US/chrome/messenger/FilterEditor.dtd
new file mode 100644
index 0000000000..21146c5cff
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/FilterEditor.dtd
@@ -0,0 +1,66 @@
+<!-- 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/. -->
+
+<!ENTITY window.title "Filter Rules">
+<!ENTITY filterName.label "Filter name:">
+<!ENTITY filterName.accesskey "i">
+
+<!ENTITY junk.label "Junk">
+<!ENTITY notJunk.label "Not Junk">
+
+<!ENTITY lowestPriorityCmd.label "Lowest">
+<!ENTITY lowPriorityCmd.label "Low">
+<!ENTITY normalPriorityCmd.label "Normal">
+<!ENTITY highPriorityCmd.label "High">
+<!ENTITY highestPriorityCmd.label "Highest">
+
+<!ENTITY contextDesc.label "Apply filter when:">
+<!ENTITY contextIncomingMail.label "Getting New Mail:">
+<!ENTITY contextIncomingMail.accesskey "G">
+<!ENTITY contextManual.label "Manually Run">
+<!ENTITY contextManual.accesskey "R">
+<!ENTITY contextBeforeCls.label "Filter before Junk Classification">
+<!ENTITY contextAfterCls.label "Filter after Junk Classification">
+<!ENTITY contextOutgoing.label "After Sending">
+<!ENTITY contextOutgoing.accesskey "S">
+<!ENTITY contextArchive.label "Archiving">
+<!ENTITY contextArchive.accesskey "A">
+<!ENTITY contextPeriodic.accesskey "e">
+
+<!ENTITY filterActionDesc.label "Perform these actions:">
+<!ENTITY filterActionDesc.accesskey "P">
+
+<!ENTITY filterActionOrderWarning.label "Note: Filter actions will be run in a different order.">
+<!ENTITY filterActionOrder.label "See execution order">
+
+<!-- New Style Filter Rule Actions -->
+<!ENTITY moveMessage.label "Move Message to">
+<!ENTITY copyMessage.label "Copy Message to">
+<!ENTITY forwardTo.label "Forward Message to">
+<!ENTITY replyWithTemplate.label "Reply with Template">
+<!ENTITY markMessageRead.label "Mark As Read">
+<!ENTITY markMessageUnread.label "Mark As Unread">
+<!ENTITY markMessageStarred.label "Add Star">
+<!ENTITY setPriority.label "Set Priority to">
+<!ENTITY addTag.label "Tag Message">
+<!ENTITY setJunkScore.label "Set Junk Status to">
+<!ENTITY deleteMessage.label "Delete Message">
+<!ENTITY deleteFromPOP.label "Delete From POP Server">
+<!ENTITY fetchFromPOP.label "Fetch From POP Server">
+<!ENTITY ignoreThread.label "Ignore Thread">
+<!ENTITY ignoreSubthread.label "Ignore Subthread">
+<!ENTITY watchThread.label "Watch Thread">
+<!ENTITY stopExecution.label "Stop Filter Execution">
+
+<!ENTITY addAction.tooltip "Add a new action">
+<!ENTITY removeAction.tooltip "Remove this action">
+
+<!-- LOCALIZATION NOTE
+ The values below are used to control the widths of the filter action widgets.
+ Change the values only when the localized strings in the popup menus
+ are truncated in the widgets.
+ -->
+<!-- Flex Attribute: https://developer.mozilla.org/docs/XUL/Attribute/flex -->
+<!ENTITY filterActionTypeFlexValue "1">
+<!ENTITY filterActionTargetFlexValue "4">
diff --git a/comm/mail/locales/en-US/chrome/messenger/FilterListDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/FilterListDialog.dtd
new file mode 100644
index 0000000000..0d41269eea
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/FilterListDialog.dtd
@@ -0,0 +1,40 @@
+<!-- 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/. -->
+
+<!ENTITY window.title "Message Filters">
+<!ENTITY nameColumn.label "Filter Name">
+<!ENTITY activeColumn.label "Enabled">
+<!ENTITY newButton.label "New…">
+<!ENTITY newButton.accesskey "N">
+<!ENTITY newButton.popupCopy.label "Copy…">
+<!ENTITY newButton.popupCopy.accesskey "C">
+<!ENTITY editButton.label "Edit…">
+<!ENTITY editButton.accesskey "E">
+<!ENTITY deleteButton.label "Delete">
+<!ENTITY deleteButton.accesskey "t">
+<!ENTITY reorderTopButton "Move to Top">
+<!ENTITY reorderTopButton.accessKey "o">
+<!ENTITY reorderTopButton.toolTip "Rearrange filter so it executes before all others">
+<!ENTITY reorderUpButton.label "Move Up">
+<!ENTITY reorderUpButton.accesskey "U">
+<!ENTITY reorderDownButton.label "Move Down">
+<!ENTITY reorderDownButton.accesskey "D">
+<!ENTITY reorderBottomButton "Move to Bottom">
+<!ENTITY reorderBottomButton.accessKey "B">
+<!ENTITY reorderBottomButton.toolTip "Rearrange filter so it executes after all others">
+<!ENTITY filterHeader.label "Enabled filters are run automatically in the order shown below.">
+<!ENTITY filtersForPrefix.label "Filters for:">
+<!ENTITY filtersForPrefix.accesskey "F">
+<!ENTITY viewLogButton.label "Filter Log">
+<!ENTITY viewLogButton.accesskey "L">
+<!ENTITY runFilters.label "Run Now">
+<!ENTITY runFilters.accesskey "R">
+<!ENTITY stopFilters.label "Stop">
+<!ENTITY stopFilters.accesskey "S">
+<!ENTITY folderPickerPrefix.label "Run selected filter(s) on:">
+<!ENTITY folderPickerPrefix.accesskey "c">
+<!ENTITY helpButton.label "Help">
+<!ENTITY helpButton.accesskey "H">
+<!ENTITY closeCmd.key "W">
+<!ENTITY searchBox.emptyText "Search filters by name…">
diff --git a/comm/mail/locales/en-US/chrome/messenger/SearchDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/SearchDialog.dtd
new file mode 100644
index 0000000000..61135e17b3
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/SearchDialog.dtd
@@ -0,0 +1,37 @@
+<!-- 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/. -->
+
+<!-- for SearchDialog.xhtml -->
+<!ENTITY searchHeading.label "Search for messages in:">
+<!ENTITY searchHeading.accesskey "h">
+<!ENTITY searchSubfolders.label "Search subfolders">
+<!ENTITY searchSubfolders.accesskey "e">
+<!ENTITY searchOnServer.label "Run search on server">
+<!ENTITY searchOnServer.accesskey "u">
+<!ENTITY resetButton.label "Clear">
+<!ENTITY resetButton.accesskey "C">
+<!ENTITY openButton.label "Open">
+<!ENTITY openButton.accesskey "n">
+<!ENTITY deleteButton.label "Delete">
+<!ENTITY deleteButton.accesskey "D">
+<!ENTITY searchDialogTitle.label "Search Messages">
+<!ENTITY results.label "Results">
+<!ENTITY moveButton.label "Move To">
+<!ENTITY moveButton.accesskey "T">
+<!ENTITY closeCmd.key "W">
+<!ENTITY openInFolder.label "Open in Folder">
+<!ENTITY openInFolder.accesskey "r">
+<!ENTITY saveAsVFButton.label "Save as Search Folder">
+<!ENTITY saveAsVFButton.accesskey "v">
+
+<!-- for abSearchDialog.xhtml -->
+<!ENTITY abSearchHeading.label "Search in:">
+<!ENTITY abSearchHeading.accesskey "h">
+<!ENTITY propertiesButton.label "Properties">
+<!ENTITY propertiesButton.accesskey "P">
+<!ENTITY composeButton.label "Write">
+<!ENTITY composeButton.accesskey "W">
+<!ENTITY deleteCardButton.label "Delete">
+<!ENTITY deleteCardButton.accesskey "D">
+<!ENTITY abSearchDialogTitle.label "Advanced Address Book Search">
diff --git a/comm/mail/locales/en-US/chrome/messenger/aboutDownloads.dtd b/comm/mail/locales/en-US/chrome/messenger/aboutDownloads.dtd
new file mode 100644
index 0000000000..48cc0c01a5
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/aboutDownloads.dtd
@@ -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/. -->
+
+<!ENTITY aboutDownloads.title "Saved Files">
+<!-- LOCALIZATION NOTE (cmd.show.label, cmd.show.accesskey, cmd.showMac.label,
+ cmd.showMac.accesskey):
+ The show and showMac commands are never shown together, thus they can share
+ the same access key (though the two access keys can also be different).
+ -->
+<!ENTITY cmd.show.label "Open Containing Folder">
+<!ENTITY cmd.show.accesskey "F">
+<!ENTITY cmd.showMac.label "Show In Finder">
+<!ENTITY cmd.showMac.accesskey "F">
+<!ENTITY cmd.open.label "Open">
+<!ENTITY cmd.open.accesskey "O">
+<!ENTITY cmd.removeFromHistory.label "Remove From History">
+<!ENTITY cmd.removeFromHistory.accesskey "e">
+<!ENTITY cmd.clearList.label "Clear List">
+<!ENTITY cmd.clearList.accesskey "C">
+<!ENTITY cmd.clearList.tooltip "Remove all entries from the list of saved files, except ongoing downloads.">
+<!ENTITY cmd.searchDownloads.label "Search…">
+<!ENTITY cmd.searchDownloads.key "F">
diff --git a/comm/mail/locales/en-US/chrome/messenger/aboutRights.properties b/comm/mail/locales/en-US/chrome/messenger/aboutRights.properties
new file mode 100644
index 0000000000..4a305cdf51
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/aboutRights.properties
@@ -0,0 +1,6 @@
+# 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/.
+
+buttonLabel=Know your rights…
+buttonAccessKey=K
diff --git a/comm/mail/locales/en-US/chrome/messenger/aboutSupportMail.properties b/comm/mail/locales/en-US/chrome/messenger/aboutSupportMail.properties
new file mode 100644
index 0000000000..5c130c39c4
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/aboutSupportMail.properties
@@ -0,0 +1,15 @@
+# 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/.
+
+# LOCALIZATION NOTE (warningLabel): Label for warning text that shows up when private data is included
+warningLabel=WARNING:
+# LOCALIZATION NOTE (warningText): Warning text that shows up when private data is included
+warningText=This contains sensitive information which shouldn't be forwarded or published without permission.
+
+# LOCALIZATION NOTE (fsType.local): Indicator that the displayed directory is on a local drive
+fsType.local = (Local drive)
+# LOCALIZATION NOTE (fsType.network): Indicator that the displayed directory is on the network
+fsType.network = (Network drive)
+# LOCALIZATION NOTE (fsType.unknown): Indicator that we couldn't figure out whether the directory is local or on a network
+fsType.unknown = (Unknown location)
diff --git a/comm/mail/locales/en-US/chrome/messenger/accountCreationModel.properties b/comm/mail/locales/en-US/chrome/messenger/accountCreationModel.properties
new file mode 100644
index 0000000000..dd55a6f011
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/accountCreationModel.properties
@@ -0,0 +1,20 @@
+# 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 has the strings, mostly error strings, for the logic / JS backend / model
+# files: fetchConfig.js, readFromXML.js, guessConfig.js, verifyConfig.js, createInBackend.js
+
+
+# readFromXML.js
+no_emailProvider.error=The config file XML does not contain an email account configuration.
+outgoing_not_smtp.error=The outgoing server must be of type SMTP
+
+# verifyConfig.js
+cannot_login.error=Unable to log in at server. Probably wrong configuration, username or password.
+
+# guessConfig.js
+cannot_find_server.error=Can't find a server
+
+# exchangeAutoDiscover.js
+no_autodiscover.error=The Exchange AutoDiscover XML is invalid.
diff --git a/comm/mail/locales/en-US/chrome/messenger/accountCreationUtil.properties b/comm/mail/locales/en-US/chrome/messenger/accountCreationUtil.properties
new file mode 100644
index 0000000000..0d47935d49
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/accountCreationUtil.properties
@@ -0,0 +1,34 @@
+# 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 has the strings, mostly error strings, for the logic / JS backend / model
+# files: sanitizeDatatypes.js, fetchhttp.js, util.js
+
+
+# sanitizeDatatypes.js
+hostname_syntax.error=Hostname is empty or contains forbidden characters. Only letters, numbers, - and . are allowed.
+alphanumdash.error=String contains unsupported characters. Only letters, numbers, - and _ are allowed.
+allowed_value.error=Supplied value not in allowed list
+url_scheme.error=URL scheme not allowed
+url_parsing.error=URL not recognized
+string_empty.error=You must supply a value for this string
+boolean.error=Not a boolean
+no_number.error=Not a number
+number_too_large.error=Number too large
+number_too_small.error=Number too small
+
+
+# fetchhttp.js
+cannot_contact_server.error=Cannot contact server
+bad_response_content.error=Bad response content
+
+# verifyConfig.js
+# LOCALIZATION NOTE(auth_failed_generic.error): The login failed (server refused to allow the user in), but the server did not give any meaningful error message. This is a common case when the user entered a wrong password or is otherwise not allowed.
+auth_failed_generic.error=Login failed. Are username/email address and password correct?
+# LOCALIZATION NOTE(auth_failed_with_reason.error): The login failed (server refused to allow the user in), and the server gave an error message which we can present to the user. This is a common case when the user entered a wrong password or is otherwise not allowed. %1$S will be the IMAP/POP3/SMTP server hostname. %2$S will be the error message from the server (usually in the local language where the server is or in English).
+auth_failed_with_reason.error=Login failed. The server %1$S said: %2$S
+# LOCALIZATION NOTE(verification_failed.error): We had some other error, not during authentication with the server, but at earlier points, e.g. locally or we entirely failed to contact the given server, and we unfortunately have no detailed error message.
+verification_failed.error=Login verification failed for an unknown reason.
+# LOCALIZATION NOTE(verification_failed_with_exception.error): We had some other error, not during authentication with the server, but at earlier points, e.g. locally or we entirely failed to contact the given server, and we have an error message. %1$S will be an error message, possibly in English
+verification_failed_with_exception.error=Login verification failed with message: %1$S
diff --git a/comm/mail/locales/en-US/chrome/messenger/activity.dtd b/comm/mail/locales/en-US/chrome/messenger/activity.dtd
new file mode 100644
index 0000000000..faf8c139c3
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/activity.dtd
@@ -0,0 +1,18 @@
+<!-- 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/. -->
+
+<!-- LOCALIZATION NOTE (window.width2, window.height): These values should be
+close to the golden ratio (1.618:1) while making sure it's wide enough for long
+file names and tall enough to hint that there are more activities in the list -->
+<!ENTITY window.width2 "485">
+<!ENTITY window.height "300">
+
+<!ENTITY activity.title "Activity Manager">
+
+<!ENTITY cmd.close.commandkey "w">
+<!ENTITY cmd.close2.commandkey "j">
+<!ENTITY cmd.close2Unix.commandkey "y">
+<!ENTITY cmd.clearList.label "Clear List">
+<!ENTITY cmd.clearList.tooltip "Removes completed, canceled, and failed items from the list">
+<!ENTITY cmd.clearList.accesskey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger/activity.properties b/comm/mail/locales/en-US/chrome/messenger/activity.properties
new file mode 100644
index 0000000000..88a20b8109
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/activity.properties
@@ -0,0 +1,99 @@
+# 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/.
+
+# Status Text
+paused2=Paused
+processing=Processing
+notStarted=Not Started
+failed=Failed
+waitingForInput=Waiting for input
+waitingForRetry=Waiting for retry
+completed=Completed
+canceled=Canceled
+
+# LOCALIZATION NOTE (sendingMessages): this is used as a title for grouping processes in the activity manager when sending email.
+sendingMessages=Sending Messages
+sendingMessage=Sending Message
+# LOCALIZATION NOTE (sendingMessageWithSubject): %S will be replaced by the subject of the message being sent.
+sendingMessageWithSubject=Sending Message: %S
+copyMessage=Copying message to sent folder
+sentMessage=Sent Message
+# LOCALIZATION NOTE (sentMessageWithSubject): %S will be replaced by the subject of the message being sent.
+sentMessageWithSubject=Sent Message: %S
+failedToSendMessage=Failed to send message
+failedToCopyMessage=Failed to copy message
+# LOCALIZATION NOTE (failedToSendMessageWithSubject): %S will be replaced by the subject of the message being sent.
+failedToSendMessageWithSubject=Failed to send message: %S
+# LOCALIZATION NOTE (failedToCopyMessageWithSubject): %S will be replaced by the subject of the message being sent.
+failedToCopyMessageWithSubject=Failed to copy message: %S
+
+# LOCALIZATION NOTE (autosyncProcessProgress2): Do not translate the words "%1$S", "%2$S", "%3$S" and "%4$S" below.
+# Place the word %1$S in your translation where the number of the message being downloaded should appear.
+# Place the word %2$S in your translation where the total number of messages to be downloaded should appear.
+# Place the word %3$S in your translation where the name of the folder being processed should appear.
+# Place the word %4$S in your translation where the name of account being processed should appear.
+# EXAMPLE: Ted's account: Downloading message 334 of 1008 in Inbox…
+autosyncProcessProgress2=%4$S: Downloading message %1$S of %2$S in %3$S…
+# LOCALIZATION NOTE (autosyncProcessDisplayText): %S will be replaced by the folder name
+autosyncProcessDisplayText=Bringing folder %S up to date
+# LOCALIZATION NOTE (autosyncEventDisplayText): %S will be replaced by the account name
+autosyncEventDisplayText=%S is up to date
+# LOCALIZATION NOTE (autosyncEventStatusText): %S will be replaced by total number of downloaded messages
+autosyncEventStatusText=Total number of messages downloaded: %S
+autosyncEventStatusTextNoMsgs=No messages downloaded
+# LOCALIZATION NOTE (autosyncContextDisplayText): %S will be replaced by the account name
+autosyncContextDisplayText=Synchronizing: %S
+
+# LOCALIZATION NOTE (pop3EventStartDisplayText2): Do not translate the words "%1$S" and "%2$S" below.
+# Place the word %1$S in your translation where the name of the account being checked for new messages should appear.
+# Place the word %2$S in your translation where the name of the folder being checked for new messages should appear.
+# EXAMPLE: George's account: Checking Inbox for new messages…
+pop3EventStartDisplayText2=%1$S: Checking %2$S for new messages…
+# LOCALIZATION NOTE (pop3EventDisplayText): %S will be replaced by the account name
+pop3EventDisplayText=%S is up to date
+# LOCALIZATION NOTE (pop3EventStatusText): #1 will be replaced by total number of downloaded messages
+pop3EventStatusText=#1 message downloaded;#1 messages downloaded
+pop3EventStatusTextNoMsgs=No messages to download
+
+# Message actions that show up in activity manager
+# LOCALIZATION NOTE (deletedMessages2): #1 number of messages, #2 folder name
+deletedMessages2=Deleted #1 message from #2;Deleted #1 messages from #2
+# LOCALIZATION NOTE (movedMessages): #1 number of messages, #2 and #3: folder names
+movedMessages=Moved #1 message from #2 to #3;Moved #1 messages from #2 to #3
+# LOCALIZATION NOTE (copiedMessages): #1 number of messages, #2 and #3: folder names
+copiedMessages=Copied #1 message from #2 to #3;Copied #1 messages from #2 to #3
+# LOCALIZATION NOTE (fromServerToServer): #1 source server, #2 destination server
+fromServerToServer=from #1 to #2
+# LOCALIZATION NOTE (deletedFolder): #1 folder name
+deletedFolder=Deleted folder #1
+emptiedTrash=Emptied Trash
+# LOCALIZATION NOTE (movedFolder): #1 and #2 are folder names
+movedFolder=Moved folder #1 into folder #2
+# LOCALIZATION NOTE (movedFolderToTrash): #1 is the folder name
+movedFolderToTrash=Moved folder #1 to Trash
+# LOCALIZATION NOTE (copiedFolder): #1 and #2 are folder names
+copiedFolder=Copied folder #1 into folder #2
+# LOCALIZATION NOTE (renamedFolder): #1 and #2 are folder names
+renamedFolder=Renamed folder #1 to #2
+indexing=Indexing messages
+# LOCALIZATION NOTE (indexingFolder): #1 is a folder name
+indexingFolder=Indexing messages in #1
+indexingStatusVague=Determining which messages to index
+# LOCALIZATION NOTE (indexingFolderStatusVague): #1 is a folder name
+indexingFolderStatusVague=Determining which messages to index in #1
+# LOCALIZATION NOTE (indexingStatusExact):
+# #1 is the number of the message currently being indexed
+# #2 is the total number of messages being indexed
+# #3 is the percentage of indexing that is complete
+indexingStatusExact=Indexing #1 of #2 message;Indexing #1 of #2 messages (#3% complete)
+# LOCALIZATION NOTE (indexingFolderStatusExact):
+# #1 is the number of the message currently being indexed
+# #2 is the total number of messages being indexed
+# #3 is the percentage of indexing that is complete
+# #4 is a folder name
+indexingFolderStatusExact=Indexing #1 of #2 message in #4;Indexing #1 of #2 messages in #4 (#3% complete)
+# LOCALIZATION NOTE (indexedFolder): #1 number of messages; #2 folder name
+indexedFolder=Indexed #1 message in #2;Indexed #1 messages in #2
+# LOCALIZATION NOTE (indexedFolderStatus): #1 number of seconds spent indexing
+indexedFolderStatus=#1 second elapsed;#1 seconds elapsed
diff --git a/comm/mail/locales/en-US/chrome/messenger/addbuddy.dtd b/comm/mail/locales/en-US/chrome/messenger/addbuddy.dtd
new file mode 100644
index 0000000000..9c6d5c4d40
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addbuddy.dtd
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+
+<!ENTITY addBuddyWindow.title "Add contact">
+<!ENTITY name.label "Username">
+<!ENTITY account.label "Account">
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/abAddressBookNameDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/addressbook/abAddressBookNameDialog.dtd
new file mode 100644
index 0000000000..37e5ac4f43
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/abAddressBookNameDialog.dtd
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+
+<!-- Labels -->
+<!ENTITY name.label "Address Book Name:">
+<!ENTITY name.accesskey "A">
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/abContactsPanel.dtd b/comm/mail/locales/en-US/chrome/messenger/addressbook/abContactsPanel.dtd
new file mode 100644
index 0000000000..76ff38fe42
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/abContactsPanel.dtd
@@ -0,0 +1,49 @@
+<!-- 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/. -->
+
+<!ENTITY propertiesMenu.label "Properties">
+<!ENTITY propertiesMenu.accesskey "i">
+<!ENTITY propertiesCmd.key "i">
+<!ENTITY abPropertiesMenu.label "Address Book Properties">
+<!ENTITY abPropertiesMenu.accesskey "i">
+<!ENTITY contactPropertiesMenu.label "Contact Properties">
+<!ENTITY contactPropertiesMenu.accesskey "i">
+<!ENTITY mailingListPropertiesMenu.label "Mailing List Properties">
+<!ENTITY mailingListPropertiesMenu.accesskey "i">
+
+<!ENTITY abContextMenuButton.tooltip "Display Address Book Context Menu">
+<!ENTITY addressbookPicker.label "Address Book:">
+<!ENTITY addressbookPicker.accesskey "k">
+<!ENTITY searchContacts.label "Search Contacts:">
+<!ENTITY searchContacts.accesskey "n">
+<!ENTITY SearchNameOrEmail.label "Name or Email">
+
+<!ENTITY addtoToFieldMenu.label "Add to To field">
+<!ENTITY addtoToFieldMenu.accesskey "A">
+<!ENTITY addtoCcFieldMenu.label "Add to Cc field">
+<!ENTITY addtoCcFieldMenu.accesskey "C">
+<!ENTITY addtoBccFieldMenu.label "Add to Bcc field">
+<!ENTITY addtoBccFieldMenu.accesskey "B">
+<!ENTITY deleteAddrBookCard.label "Delete">
+<!ENTITY deleteAddrBookCard.accesskey "D">
+<!ENTITY propertiesContext.label "Properties">
+<!ENTITY propertiesContext.accesskey "i">
+<!ENTITY abPropertiesContext.label "Properties">
+<!ENTITY abPropertiesContext.accesskey "i">
+<!ENTITY editContactContext.label "Edit Contact">
+<!ENTITY editContactContext.accesskey "E">
+<!ENTITY editMailingListContext.label "Edit List">
+<!ENTITY editMailingListContext.accesskey "E">
+
+<!ENTITY newContactAbContext.label "New Contact">
+<!ENTITY newContactAbContext.accesskey "C">
+<!ENTITY newListAbContext.label "New List">
+<!ENTITY newListAbContext.accesskey "L">
+
+<!ENTITY toButton.label "Add to To:">
+<!ENTITY toButton.accesskey "A">
+<!ENTITY ccButton.label "Add to Cc:">
+<!ENTITY ccButton.accesskey "C">
+<!ENTITY bccButton.label "Add to Bcc:">
+<!ENTITY bccButton.accesskey "B">
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/abMailListDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/addressbook/abMailListDialog.dtd
new file mode 100644
index 0000000000..dafa97c116
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/abMailListDialog.dtd
@@ -0,0 +1,21 @@
+<!-- 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/. -->
+
+<!-- Title -->
+<!ENTITY mailListWindowAdd.title "New Mailing List">
+
+<!-- Labels and Access Keys -->
+<!ENTITY addToAddressBook.label "Add to: ">
+<!ENTITY addToAddressBook.accesskey "A">
+<!ENTITY ListName.label "List Name: ">
+<!ENTITY ListName.accesskey "L">
+<!ENTITY ListNickName.label "List Nickname: ">
+<!ENTITY ListNickName.accesskey "N">
+<!ENTITY ListDescription.label "Description: ">
+<!ENTITY ListDescription.accesskey "D">
+<!-- See bug 58485, when we implement drag and drop, add 'or drag addresses' back in -->
+<!ENTITY AddressTitle.label "Type email addresses to add them to the mailing list:">
+<!ENTITY AddressTitle.accesskey "m">
+<!ENTITY UpButton.label "Move Up">
+<!ENTITY DownButton.label "Move Down">
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/abMainWindow.dtd b/comm/mail/locales/en-US/chrome/messenger/addressbook/abMainWindow.dtd
new file mode 100644
index 0000000000..81356d48d9
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/abMainWindow.dtd
@@ -0,0 +1,14 @@
+<!-- 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/. -->
+
+<!ENTITY showAsDefault.label "Default startup directory">
+<!ENTITY showAsDefault.accesskey "S">
+
+<!-- Search Bar -->
+<!ENTITY SearchNameOrEmail.label "Name or Email">
+
+<!-- Results Pane -->
+<!ENTITY Addrbook.label "Address Book">
+<!ENTITY GeneratedName.label "Name">
+<!ENTITY PrimaryEmail.label "Email">
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/abResultsPane.dtd b/comm/mail/locales/en-US/chrome/messenger/addressbook/abResultsPane.dtd
new file mode 100644
index 0000000000..29044874fc
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/abResultsPane.dtd
@@ -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/. -->
+
+<!ENTITY Addrbook.label "Address Book">
+<!ENTITY Addrbook.accesskey "B">
+<!ENTITY GeneratedName.label "Name">
+<!ENTITY GeneratedName.accesskey "N">
+<!ENTITY PrimaryEmail.label "Email">
+<!ENTITY PrimaryEmail.accesskey "E">
+<!ENTITY Company.label "Organization">
+<!ENTITY Company.accesskey "z">
+<!ENTITY _PhoneticName.label "Phonetic Name">
+<!ENTITY _PhoneticName.accesskey "o">
+<!ENTITY NickName.label "Nickname">
+<!ENTITY NickName.accesskey "i">
+<!ENTITY SecondEmail.label "Additional Email">
+<!ENTITY SecondEmail.accesskey "l">
+<!ENTITY Department.label "Department">
+<!ENTITY Department.accesskey "r">
+<!ENTITY JobTitle.label "Title">
+<!ENTITY JobTitle.accesskey "T">
+<!ENTITY CellularNumber.label "Mobile">
+<!ENTITY CellularNumber.accesskey "M">
+<!ENTITY PagerNumber.label "Pager">
+<!ENTITY PagerNumber.accesskey "P">
+<!ENTITY FaxNumber.label "Fax">
+<!ENTITY FaxNumber.accesskey "F">
+<!ENTITY HomePhone.label "Home Phone">
+<!ENTITY HomePhone.accesskey "H">
+<!ENTITY WorkPhone.label "Work Phone">
+<!ENTITY WorkPhone.accesskey "W">
+<!ENTITY ChatName.label "Chat Name">
+<!ENTITY ChatName.accesskey "C">
+<!ENTITY sortAscending.label "Ascending">
+<!ENTITY sortAscending.accesskey "A">
+<!ENTITY sortDescending.label "Descending">
+<!ENTITY sortDescending.accesskey "D">
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/addressBook.properties b/comm/mail/locales/en-US/chrome/messenger/addressbook/addressBook.properties
new file mode 100644
index 0000000000..4bedfc341c
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/addressBook.properties
@@ -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/.
+
+# The following are used by the Mailing list dialog.
+# LOCALIZATION NOTE (mailingListTitleEdit): %S will be replaced by the Mailing List's display name.
+mailingListTitleEdit=Edit %S
+emptyListName=You must enter a list name.
+badListNameCharacters=A list name cannot contain any of the following characters: < > ; , "
+badListNameSpaces=A list name cannot contain multiple adjacent spaces.
+lastFirstFormat=%S, %S
+firstLastFormat=%S %S
+
+allAddressBooks=All Address Books
+
+newContactTitle=New Contact
+# %S will be the contact's display name
+newContactTitleWithDisplayName=New Contact for %S
+editContactTitle=Edit Contact
+# %S will be the contact's display name
+editContactTitleWithDisplayName=Edit Contact for %S
+# don't translate vCard
+editVCardTitle=Edit vCard
+# %S will be the card's display name, don't translate vCard
+editVCardTitleWithDisplayName=Edit vCard for %S
+
+## LOCALIZATION NOTE (cardRequiredDataMissingMessage): do not localize \n
+cardRequiredDataMissingMessage=You must enter at least one of the following items:\nEmail Address, First Name, Last Name, Display Name, Organization.
+cardRequiredDataMissingTitle=Required Information Missing
+incorrectEmailAddressFormatMessage=The primary email address must be of the form user@host.
+incorrectEmailAddressFormatTitle=Incorrect Email Address Format
+
+viewListTitle=Mailing List: %S
+mailListNameExistsTitle=Mailing List Already Exists
+mailListNameExistsMessage=A Mailing List with that name already exists. Please choose a different name.
+
+propertyPrimaryEmail=Email
+propertyListName=List Name
+propertySecondaryEmail=Additional Email
+propertyNickname=Nickname
+propertyDisplayName=Display Name
+propertyWork=Work
+propertyHome=Home
+propertyFax=Fax
+propertyCellular=Mobile
+propertyPager=Pager
+propertyBirthday=Birthday
+propertyCustom1=Custom 1
+propertyCustom2=Custom 2
+propertyCustom3=Custom 3
+propertyCustom4=Custom 4
+
+propertyGtalk=Google Talk
+propertyAIM=AIM
+propertyYahoo=Yahoo!
+propertySkype=Skype
+propertyQQ=QQ
+propertyMSN=MSN
+propertyICQ=ICQ
+propertyXMPP=Jabber ID
+propertyIRC=IRC Nick
+
+## LOCALIZATION NOTE (cityAndStateAndZip):
+## %1$S is city, %2$S is state, %3$S is zip
+cityAndStateAndZip=%1$S, %2$S %3$S
+## LOCALIZATION NOTE (cityAndStateNoZip):
+## %1$S is city, %2$S is state
+cityAndStateNoZip=%1$S, %2$S
+## LOCALIZATION NOTE (cityOrStateAndZip):
+## %1$S is city or state, %2$S is zip
+cityOrStateAndZip=%1$S %2$S
+
+stateZipSeparator=
+
+prefixTo=To
+prefixCc=Cc
+prefixBcc=Bcc
+addressBook=Address Book
+
+# Contact photo management
+browsePhoto=Contact Photo
+stateImageSave=Saving the image…
+errorInvalidUri=Error: Invalid source image.
+errorNotAvailable=Error: The file is not accessible.
+errorInvalidImage=Error: Only JPG, PNG and GIF image types are supported.
+errorSaveOperation=Error: Could not save the image.
+
+# mailnews.js
+ldap_2.servers.pab.description=Personal Address Book
+ldap_2.servers.history.description=Collected Addresses
+## LOCALIZATION NOTE (ldap_2.servers.osx.description is only used on Mac OS X)
+ldap_2.servers.osx.description=Mac OS X Address Book
+## LOCALIZATION NOTE (ldap_2.servers.outlook.description is only used on Windows)
+ldap_2.servers.outlook.description=Outlook Address Book
+
+# status bar stuff
+## LOCALIZATION NOTE (totalContactStatus):
+## %1$S is address book name, %2$S is contact count
+totalContactStatus=Total contacts in %1$S: %2$S
+noMatchFound=No matches found
+## LOCALIZATION NOTE (matchesFound1):
+## Semicolon-separated list of singular and plural forms.
+## See: https://developer.mozilla.org/docs/Mozilla/Localization/Localization_and_Plurals
+## #1 is the number of matching contacts found
+matchesFound1=#1 match found;#1 matches found
+
+## LOCALIZATION NOTE (contactsCopied): Semi-colon list of plural forms
+## %1$S is the number of contacts that were copied. This should be used multiple
+## times wherever you need it. Do not replace by %S.
+contactsCopied=%1$S contact copied;%1$S contacts copied
+
+## LOCALIZATION NOTE (contactsMoved): Semi-colon list of plural forms
+## %1$S is the number of contacts that were moved. This should be used multiple
+## times wherever you need it. Do not replace by %S.
+contactsMoved=%1$S contact moved;%1$S contacts moved
+
+# LDAP directory stuff
+invalidName=Please enter a valid Name.
+invalidHostname=Please enter a valid Hostname.
+invalidPortNumber=Please enter a valid Port Number.
+invalidResults=Please enter a valid number in the results field.
+abReplicationOfflineWarning=You must be online to perform LDAP replication.
+abReplicationSaveSettings=Settings must be saved before a directory may be downloaded.
+
+# For importing / exporting
+## LOCALIZATION NOTE (ExportAddressBookNameTitle): %S is the name of exported addressbook
+ExportAddressBookNameTitle=Export Address Book - %S
+LDIFFiles=LDIF
+CSVFiles=Comma Separated
+CSVFilesSysCharset=Comma Separated (System Charset)
+CSVFilesUTF8=Comma Separated (UTF-8)
+TABFiles=Tab Delimited
+TABFilesSysCharset=Tab Delimited (System Charset)
+TABFilesUTF8=Tab Delimited (UTF-8)
+VCFFiles=vCard
+SupportedABFiles=Supported Address Book Files
+failedToExportTitle=Export Failed
+failedToExportMessageNoDeviceSpace=Failed to export addressbook, no space left on device.
+failedToExportMessageFileAccessDenied=Failed to export addressbook, file access denied.
+
+# For getting authDN for replication using dlg box
+AuthDlgTitle=Address Book LDAP Replication
+AuthDlgDesc=To access the directory server, enter your user name and password.
+
+# LOCALIZATION NOTE(joinMeInThisChat)
+# use + for spaces
+joinMeInThisChat=Join+me+in+this+Chat.
+
+# For printing
+headingHome=Home
+headingWork=Work
+headingOther=Other
+headingChat=Chat
+headingPhone=Phone
+headingDescription=Description
+headingAddresses=Addresses
+
+## For address books
+addressBookTitleNew=New Address Book
+# LOCALIZATION NOTE (addressBookTitleEdit):
+# %S is the current name of the address book.
+# Example: My Custom AB Properties
+addressBookTitleEdit=%S Properties
+duplicateNameTitle=Duplicate Address Book Name
+# LOCALIZATION NOTE (duplicateNameText):
+# Don't localize "\n• %S" unless your local layout comes out wrong.
+# %S is the name of the existing address book.
+# Example: An address book with this name already exists:
+# • My Custom AB
+duplicateNameText=An address book with this name already exists:\n• %S
+
+# For corrupt .mab files
+corruptMabFileTitle=Corrupt Address Book File
+corruptMabFileAlert=One of your address book files (%1$S file) could not be read. A new %2$S file will be created and a backup of the old file, called %3$S, will be created in the same directory.
+
+# For locked .mab files
+lockedMabFileTitle=Unable to Load Address Book File
+lockedMabFileAlert=Unable to load address book file %S. It may be read-only, or locked by another application. Please try again later.
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/ldapAutoCompErrs.properties b/comm/mail/locales/en-US/chrome/messenger/addressbook/ldapAutoCompErrs.properties
new file mode 100644
index 0000000000..4bb5b401fd
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/ldapAutoCompErrs.properties
@@ -0,0 +1,104 @@
+# 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/.
+
+# These are error strings for problems that happen while in the
+# various states declared in nsILDAPAutoCompFormatter.idl. Note that
+# the number that indexes each error state is the same as the number
+# corresponding to that state in nsILDAPAutoCompFormatter.idl.
+
+## @name ERR_STATE_UNBOUND
+## @loc none
+0=LDAP initialization problem
+
+## @name ERR_STATE_INITIALIZING
+## @loc none
+1=LDAP server connection failed
+
+## @name ERR_STATE_BINDING
+## @loc none
+2=LDAP server connection failed
+
+## @name ERR_STATE_BOUND
+## @loc none
+3=LDAP server communications problem
+
+## @name ERR_STATE_SEARCHING
+## @loc none
+4=LDAP server search problem
+
+
+# The format of the alert dialog itself
+#
+## @name ALERT_FORMAT
+## @loc None of %1$S, %2$S and %3$S should be localized.
+## %1$S is the error code itself, %2$S is an LDAP SDK error message from
+## chrome://mozldap/locale/ldap.properties, and %3$S is a hint relating
+## to that specific error, found in this file.
+errorAlertFormat=Error code %1$S: %2$S\n\n %3$S
+
+## The following errors are for error codes other than LDAP-specific ones.
+## Someday mozilla will actually have a system for mapping nsresults to
+## error strings that's actually widely used, unlike nsIErrorService. But
+## until it does, these strings live here...
+
+## @name HOST_NOT_FOUND
+## @loc none
+5000=Host not found
+
+## @name GENERIC_ERROR
+## @loc none
+9999=Unknown error
+
+
+# Hints to for the user, associated with specific error codes (ie error code
+# + 10000)
+
+
+## @name TIMELIMIT_EXCEEDED_HINT
+## @loc none
+10003=Please try again later, or else contact your System Administrator.
+
+## @name STRONGAUTH_REQUIRED_HINT
+## @loc none
+10008=Strong authentication is not currently supported.
+
+## @name INVALID_SYNTAX_HINT
+## @loc none
+10021=Verify that the search filter is correct, and then try again, or else contact your System Administrator. To verify that the search filter is correct, from the Edit menu, choose Preferences, then choose Mail & Newsgroups, and then choose Addressing. Click Edit Directories, and select the LDAP server being used. Click Edit, and then click Advanced to display the Search Filter.
+
+## @name NO_SUCH_OBJECT_HINT
+## @loc none
+10032=Verify that the Base DN is correct, and then try again, or else contact your System Administrator. To verify that the Base DN is correct, from the Edit menu, choose Preferences, then choose Mail & Newsgroups, and then choose Addressing. Click Edit Directories, and select the LDAP server being used. Click Edit to display the Base DN.
+
+## @name BUSY_HINT
+## @loc none
+10051=Please try again later.
+
+## @name SERVER_DOWN_HINT
+## @loc none
+10081=Verify that the Hostname and Port Number are correct, and then try again, or else contact your System Administrator. To verify that the Hostname and Port Number are correct, from the Edit menu, choose Preferences, then choose Mail & Newsgroups, and then choose Addressing. Click Edit Directories, and select the LDAP server being used. Click Edit to display the Hostname. Click Advanced to display the Port Number.
+
+## @name TIMEOUT_HINT
+## @loc none
+10085=Please try again later.
+
+## @name FILTER_ERROR_HINT
+## @loc none
+10087=Verify that the search filter is correct, and then try again, or else contact your System Administrator. To verify that the search filter is correct, from the Edit menu, choose Preferences, then choose Mail & Newsgroups, and then choose Addressing. Click Edit Directories, and select the LDAP server being used. Click Edit, and then click Advanced to display the Search Filter.
+
+## @name NO_MEMORY_HINT
+## @loc none
+10090=Please close some other windows and/or applications and try again.
+
+## @name CONNECT_ERROR_HINT
+## @loc none
+10091=Verify that the Hostname and Port Number are correct, and then try again, or else contact your System Administrator. To verify that the Hostname and Port Number are correct, from the Edit menu, choose Preferences, then choose Mail & Newsgroups, and then choose Addressing. Click Edit Directories, and select the LDAP server being used. Click Edit to display the Hostname. Click Advanced to display the Port Number.
+
+## @name HOST_NOT_FOUND_HINT
+## @loc none
+15000=Verify that the Hostname is correct, and then try again, or else contact your System Administrator. To verify that the Hostname is correct, from the Edit menu, choose Preferences, then choose Mail & Newsgroups, and then choose Addressing. Click Edit Directories, and select the LDAP server being used. Click Edit to display the Hostname.
+
+## @name GENERIC_HINT
+## @loc none
+19999=Please contact your System Administrator.
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/pref-directory-add.dtd b/comm/mail/locales/en-US/chrome/messenger/addressbook/pref-directory-add.dtd
new file mode 100644
index 0000000000..2e721ce1e4
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/pref-directory-add.dtd
@@ -0,0 +1,45 @@
+<!-- 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/. -->
+
+<!ENTITY directoryName.label "Name: ">
+<!ENTITY directoryName.accesskey "n">
+<!ENTITY directoryHostname.label "Hostname: ">
+<!ENTITY directoryHostname.accesskey "o">
+<!ENTITY directoryBaseDN.label "Base DN: ">
+<!ENTITY directoryBaseDN.accesskey "b">
+<!ENTITY findButton.label "Find">
+<!ENTITY findButton.accesskey "f">
+<!ENTITY directorySecure.label "Use secure connection (SSL)">
+<!ENTITY directorySecure.accesskey "U">
+<!ENTITY directoryLogin.label "Bind DN: ">
+<!ENTITY directoryLogin.accesskey "i">
+<!ENTITY General.tab "General">
+<!ENTITY Offline.tab "Offline">
+<!ENTITY Advanced.tab "Advanced">
+<!ENTITY portNumber.label "Port number: ">
+<!ENTITY portNumber.accesskey "p">
+<!ENTITY searchFilter.label "Search filter: ">
+<!ENTITY searchFilter.accesskey "f">
+<!ENTITY scope.label "Scope: ">
+<!ENTITY scope.accesskey "c">
+<!ENTITY scopeOneLevel.label "One Level">
+<!ENTITY scopeOneLevel.accesskey "L">
+<!ENTITY scopeSubtree.label "Subtree">
+<!ENTITY scopeSubtree.accesskey "S">
+<!ENTITY return.label "Don't return more than">
+<!ENTITY return.accesskey "r">
+<!ENTITY results.label "results">
+<!ENTITY offlineText.label "You can download a local copy of this directory so that it is available for use when you are working offline.">
+<!ENTITY saslMechanism.label "Login method: ">
+<!ENTITY saslMechanism.accesskey "m">
+<!ENTITY saslOff.label "Simple">
+<!ENTITY saslOff.accesskey "l">
+<!ENTITY saslGSSAPI.label "Kerberos (GSSAPI)">
+<!ENTITY saslGSSAPI.accesskey "K">
+
+<!-- Localization note: this is here because the width of the dialog
+ is determined by the width of the base DN box; and that is likely
+ to vary somewhat with the language.
+-->
+<!ENTITY newDirectoryWidth "36em">
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/pref-directory.dtd b/comm/mail/locales/en-US/chrome/messenger/addressbook/pref-directory.dtd
new file mode 100644
index 0000000000..874cfe4749
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/pref-directory.dtd
@@ -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/. -->
+
+<!-- LOCALIZATION NOTE (window.title) : do not translate "LDAP" in below line -->
+<!ENTITY pref.ldap.window.title "LDAP Directory Servers">
+<!-- LOCALIZATION NOTE (directories.label) : do not translate "LDAP" in below line -->
+<!ENTITY directories.label "LDAP Directory Server:">
+<!-- LOCALIZATION NOTE (directoriesText.label) : do not translate "LDAP" in below line -->
+<!ENTITY directoriesText.label "Select an LDAP Directory Server:">
+<!ENTITY directoriesText.accesskey "S">
+<!ENTITY addDirectory.label "Add">
+<!ENTITY addDirectory.accesskey "a">
+<!ENTITY editDirectory.label "Edit">
+<!ENTITY editDirectory.accesskey "e">
+<!ENTITY deleteDirectory.label "Delete">
+<!ENTITY deleteDirectory.accesskey "d">
diff --git a/comm/mail/locales/en-US/chrome/messenger/addressbook/replicationProgress.properties b/comm/mail/locales/en-US/chrome/messenger/addressbook/replicationProgress.properties
new file mode 100644
index 0000000000..9acf606463
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/addressbook/replicationProgress.properties
@@ -0,0 +1,20 @@
+# 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/.
+replicationStarted=Replication started…
+changesStarted=Started finding changes to replicate…
+replicationSucceeded=Replication succeeded
+replicationFailed=Replication failed
+replicationCancelled=Replication cancelled
+# LOCALIZATION NOTE
+# do not localize %S. %S is the current entry number (an integer)
+currentCount=Replicating directory entry: %S
+
+downloadButton=Download Now
+downloadButton.accesskey=D
+cancelDownloadButton=Cancel Download
+cancelDownloadButton.accesskey=C
+
+directoryTitleNew=New LDAP Directory
+## LOCALIZATION NOTE (directoryTitleEdit): %S will be replaced by the LDAP directory's display name
+directoryTitleEdit=%S Properties
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-addressing.dtd b/comm/mail/locales/en-US/chrome/messenger/am-addressing.dtd
new file mode 100644
index 0000000000..db95c9c95d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-addressing.dtd
@@ -0,0 +1,47 @@
+<!-- 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/. -->
+
+<!-- extracted from am-addressing.xhtml -->
+
+<!ENTITY addressing.label "Composition &amp; Addressing">
+<!ENTITY addressingGroupTitle.label "Addressing">
+<!ENTITY addressingText.label "When looking up addresses:">
+<!-- LOCALIZATION NOTE (override.label) : do not translate "LDAP" in below line -->
+<!ENTITY useGlobal.label "Use my global LDAP server preferences for this account">
+<!ENTITY useGlobal.accesskey "U">
+<!ENTITY editDirectories.label "Edit Directories…">
+<!ENTITY editDirectories.accesskey "E">
+<!-- LOCALIZATION NOTE (directories.label) : do not translate "LDAP" in below line -->
+<!ENTITY directories.label "Use a different LDAP server:">
+<!ENTITY directories.accesskey "d">
+<!ENTITY directoriesNone.label "None">
+
+<!-- am-addressing.xhtml -->
+
+<!ENTITY compositionGroupTitle.label "Composition">
+<!-- LOCALIZATION NOTE (useHtml.label) : do not translate "html" in below line -->
+<!ENTITY useHtml.label "Compose messages in HTML format">
+<!ENTITY useHtml.accesskey "C">
+<!ENTITY autoQuote.label "Automatically quote the original message when replying">
+<!ENTITY autoQuote.accesskey "m">
+<!-- LOCALIZATION NOTE (quoting.label): This will concatenate with the 4 strings that follow. -->
+<!ENTITY quoting.label "When quoting,">
+<!ENTITY quoting.accesskey "q">
+<!ENTITY aboveQuote.label "start my reply above the quote">
+<!ENTITY belowQuote.label "start my reply below the quote">
+<!ENTITY selectAndQuote.label "select the quote">
+<!ENTITY place.label "and place my signature">
+<!ENTITY place.accesskey "s">
+<!ENTITY belowText.label "below the quote (recommended)">
+<!ENTITY aboveText.label "below my reply (above the quote)">
+<!ENTITY includeSigOnReply.label "Include signature for replies">
+<!ENTITY includeSigOnReply.accesskey "I">
+<!ENTITY includeSigOnForward.label "Include signature for forwards">
+<!ENTITY includeSigOnForward.accesskey "w">
+
+<!ENTITY globalComposingPrefs.label "Global Composing Preferences…">
+<!ENTITY globalComposingPrefs.accesskey "G">
+
+<!ENTITY globalAddressingPrefs.label "Global Addressing Preferences…">
+<!ENTITY globalAddressingPrefs.accesskey "P">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-advanced.dtd b/comm/mail/locales/en-US/chrome/messenger/am-advanced.dtd
new file mode 100644
index 0000000000..9d496bd4aa
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-advanced.dtd
@@ -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/. -->
+
+<!-- extracted from am-advanced.xhtml -->
+
+<!-- LOCALIZATION NOTE (smtpServer.label): do not translate "SMTP" in below line -->
+<!ENTITY smtpServer.label "Outgoing Server (SMTP) Settings">
+
+<!-- LOCALIZATION NOTE (smtpDescription.label): do not translate "SMTP" in below line -->
+<!ENTITY smtpDescription.label "When managing your identities you can use a server from this list by selecting it as the Outgoing Server (SMTP), or you can use the default server from this list by selecting &quot;Use Default Server&quot;.">
+
+<!ENTITY smtpListAdd.label "Add…">
+<!ENTITY smtpListAdd.accesskey "d">
+<!ENTITY smtpListEdit.label "Edit…">
+<!ENTITY smtpListEdit.accesskey "E">
+<!ENTITY smtpListDelete.label "Remove">
+<!ENTITY smtpListDelete.accesskey "m">
+<!ENTITY smtpListSetDefault.label "Set Default">
+<!ENTITY smtpListSetDefault.accesskey "t">
+
+<!ENTITY serverDetails.label "Details of selected server:">
+<!ENTITY serverDescription.label "Description: ">
+<!ENTITY serverName.label "Server Name: ">
+<!ENTITY serverPort.label "Port: ">
+<!ENTITY userName.label "User Name: ">
+<!ENTITY connectionSecurity.label "Connection Security: ">
+<!ENTITY authMethod.label "Authentication method: ">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-archiveoptions.dtd b/comm/mail/locales/en-US/chrome/messenger/am-archiveoptions.dtd
new file mode 100644
index 0000000000..512017c852
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-archiveoptions.dtd
@@ -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/. -->
+
+<!-- extracted from am-archiveoptions.xhtml -->
+
+<!ENTITY dialogTitle.label "Archive Options">
+<!ENTITY archiveGranularityPrefix.label "When archiving messages, place them in:">
+<!ENTITY archiveFlat.label "A single folder">
+<!ENTITY archiveFlat.accesskey "s">
+<!ENTITY archiveYearly.label "Yearly archived folders">
+<!ENTITY archiveYearly.accesskey "Y">
+<!ENTITY archiveMonthly.label "Monthly archived folders">
+<!ENTITY archiveMonthly.accesskey "M">
+<!ENTITY keepFolderStructure.label "Keep existing folder structure of archived messages">
+<!ENTITY keepFolderStructure.accesskey "K">
+<!ENTITY archiveExample.label "Example">
+<!-- LOCALIZATION NOTE (archiveFolderName.label): this should match the default
+ name for the "Archives" folder -->
+<!ENTITY archiveFolderName.label "Archives">
+<!-- LOCALIZATION NOTE (inboxFolderName.label): this should match the default
+ name for the "Inbox" folder -->
+<!ENTITY inboxFolderName.label "Inbox">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-copies.dtd b/comm/mail/locales/en-US/chrome/messenger/am-copies.dtd
new file mode 100644
index 0000000000..29118d5ef7
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-copies.dtd
@@ -0,0 +1,50 @@
+<!-- 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/. -->
+
+<!-- extracted from am-copies.xhtml -->
+
+<!ENTITY copyAndFolderTitle.label "Copies &amp; Folders">
+<!ENTITY sendingPrefix.label "When sending messages, automatically: ">
+<!ENTITY fccMailFolder.label "Place a copy in:">
+<!ENTITY fccMailFolder.accesskey "P">
+<!ENTITY fccReplyFollowsParent.label "Place replies in the folder of the message being replied to">
+<!ENTITY fccReplyFollowsParent.accesskey "R">
+<!-- LOCALIZATION NOTE (ccAddress.label): do not translate "Cc" in below line -->
+<!ENTITY ccAddress.label "Cc these email addresses:">
+<!ENTITY ccAddress.accesskey "C">
+<!ENTITY ccAddressList.placeholder "Separate addresses with commas">
+<!-- LOCALIZATION NOTE (bccAddress.label): do not translate "Bcc" in below line -->
+<!ENTITY bccAddress.label "Bcc these email addresses:">
+<!ENTITY bccAddress.accesskey "B">
+<!ENTITY bccAddressList.placeholder "Separate addresses with commas">
+<!ENTITY saveMessageDlg.label "Show confirmation dialog when messages are saved">
+<!ENTITY saveMessageDlg.accesskey "w">
+<!-- LOCALIZATION NOTE (sentFolderOn.label): OK to translate this, bug #57440 -->
+<!ENTITY sentFolderOn.label "&quot;Sent&quot; Folder on:">
+<!ENTITY sentFolderOn.accesskey "S">
+<!ENTITY sentInOtherFolder.label "Other:">
+<!ENTITY sentInOtherFolder.accesskey "O">
+<!-- LOCALIZATION NOTE (archivesFolderOn.label): OK to translate this, bug #57440 -->
+<!ENTITY archivesTitle.label "Message Archives">
+<!ENTITY keepArchives.label "Keep message archives in:">
+<!ENTITY keepArchives.accesskey "K">
+<!ENTITY archiveHierarchyButton.label "Archive options…">
+<!ENTITY archiveHierarchyButton.accesskey "A">
+<!ENTITY archivesFolderOn.label "&quot;Archives&quot; Folder on:">
+<!ENTITY archivesFolderOn.accesskey "n">
+<!ENTITY archiveInOtherFolder.label "Other:">
+<!ENTITY archiveInOtherFolder.accesskey "h">
+<!ENTITY specialFolders.label "Drafts and Templates">
+<!ENTITY keepDrafts2.label "Keep draft messages in:">
+<!-- LOCALIZATION NOTE (draftsFolderOn.label): OK to translate this, bug #57440 -->
+<!ENTITY draftsFolderOn.label "&quot;Drafts&quot; Folder on:">
+<!ENTITY draftsFolderOn.accesskey "D">
+<!ENTITY draftInOtherFolder.label "Other:">
+<!ENTITY draftInOtherFolder.accesskey "t">
+<!ENTITY keepTemplates.label "Keep message templates in:">
+<!-- LOCALIZATION NOTE (templatesFolderOn.label): OK to translate this, bug #57440 -->
+<!ENTITY templatesFolderOn.label "&quot;Templates&quot; Folder on:">
+<!ENTITY templatesFolderOn.accesskey "m">
+<!ENTITY templateInOtherFolder.label "Other:">
+<!ENTITY templateInOtherFolder.accesskey "e">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-e2e.properties b/comm/mail/locales/en-US/chrome/messenger/am-e2e.properties
new file mode 100644
index 0000000000..9d42cb9257
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-e2e.properties
@@ -0,0 +1,5 @@
+# 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/.
+
+prefPanel-e2e=End-To-End Encryption
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-identities-list.dtd b/comm/mail/locales/en-US/chrome/messenger/am-identities-list.dtd
new file mode 100644
index 0000000000..1935a37ba1
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-identities-list.dtd
@@ -0,0 +1,15 @@
+<!-- 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/. -->
+
+<!ENTITY identitiesListManageDesc.label "Manage the identities for this account. The first identity is used by default.">
+<!ENTITY identitiesListAdd.label "Add…">
+<!ENTITY identitiesListAdd.accesskey "A">
+<!ENTITY identitiesListEdit.label "Edit…">
+<!ENTITY identitiesListEdit.accesskey "E">
+<!ENTITY identitiesListDefault.label "Set Default">
+<!ENTITY identitiesListDefault.accesskey "S">
+<!ENTITY identitiesListDelete.label "Delete">
+<!ENTITY identitiesListDelete.accesskey "D">
+<!ENTITY identitiesListClose.label "Close">
+<!ENTITY identitiesListClose.accesskey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-identity-edit.dtd b/comm/mail/locales/en-US/chrome/messenger/am-identity-edit.dtd
new file mode 100644
index 0000000000..af5a78a285
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-identity-edit.dtd
@@ -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/. -->
+
+<!ENTITY settingsTab.label "Settings">
+<!ENTITY copiesFoldersTab.label "Copies &amp; Folders">
+<!ENTITY addressingTab.label "Composition &amp; Addressing">
+
+<!ENTITY publicData.label "Public Data">
+<!ENTITY privateData.label "Private Data">
+<!ENTITY identityAlias.label "Identity Label:">
+<!ENTITY identityAlias.accesskey "b">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-im.dtd b/comm/mail/locales/en-US/chrome/messenger/am-im.dtd
new file mode 100644
index 0000000000..2703b5ff38
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-im.dtd
@@ -0,0 +1,16 @@
+<!-- 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/. -->
+
+<!ENTITY accountWindow.title "Account properties">
+<!ENTITY accountWindow.width "300">
+<!ENTITY account.general "General">
+<!ENTITY account.advanced "Advanced Options">
+<!ENTITY account.name "Username:">
+<!ENTITY account.password "Password:">
+<!ENTITY account.alias "Alias:">
+<!ENTITY account.newMailNotification "Notify on new Mail">
+<!ENTITY account.autojoin "Auto-Joined Channels:">
+<!ENTITY account.proxySettings.caption "Proxy Settings:">
+<!ENTITY account.proxySettings.change.label "Change…">
+<!ENTITY account.proxySettings.change.accessKey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-junk.dtd b/comm/mail/locales/en-US/chrome/messenger/am-junk.dtd
new file mode 100644
index 0000000000..0f9c55d881
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-junk.dtd
@@ -0,0 +1,31 @@
+<!-- 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/. -->
+
+<!ENTITY junkSettings.label "Junk Settings">
+<!ENTITY trainingDescription.label "If enabled, you must first train &brandShortName; to identify junk mail by using the Junk toolbar button to mark messages as junk or not. You need to identify both junk and non junk messages. After that &brandShortName; will be able to mark junk automatically.">
+<!ENTITY level.label "Enable adaptive junk mail controls for this account">
+<!ENTITY level.accesskey "E">
+
+<!ENTITY move.label "Move new junk messages to:">
+<!ENTITY move.accesskey "M">
+<!ENTITY junkFolderOn.label "&quot;Junk&quot; folder on:">
+<!ENTITY junkFolderOn.accesskey "J">
+<!ENTITY otherFolder.label "Other:">
+<!ENTITY otherFolder.accesskey "O">
+<!ENTITY purge1.label "Automatically delete junk mail older than">
+<!ENTITY purge1.accesskey "u">
+<!ENTITY purge2.label "days">
+
+<!ENTITY whitelistHeader.label "Do not automatically mark mail as junk if the sender is in: ">
+<!ENTITY whitelistHeader.accesskey "D">
+
+<!ENTITY ispHeadersWarning.label "If enabled, &brandShortName; will automatically consider messages marked by this external classifier as junk.">
+<!ENTITY ispHeaders.label "Trust junk mail headers set by: ">
+<!ENTITY ispHeaders.accesskey "T">
+
+<!ENTITY junkClassification.label "Selection">
+<!ENTITY junkActions.label "Destination and Retention">
+
+<!ENTITY globalJunkPrefs.label "Global Junk Preferences…">
+<!ENTITY globalJunkPrefs.accesskey "G">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-main.dtd b/comm/mail/locales/en-US/chrome/messenger/am-main.dtd
new file mode 100644
index 0000000000..c5575214a1
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-main.dtd
@@ -0,0 +1,47 @@
+<!-- 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/. -->
+
+<!-- extracted from am-main.xhtml -->
+
+<!ENTITY accountTitle.label "Account Settings">
+<!ENTITY accountName.label "Account Name:">
+<!ENTITY accountName.accesskey "N">
+<!ENTITY identityTitle.label "Default Identity">
+<!ENTITY identityDesc.label "Each account has an identity, which is the information that other people see when they read your messages.">
+<!ENTITY name.label "Your Name:">
+<!ENTITY name.accesskey "Y">
+<!ENTITY email.label "Email Address:">
+<!ENTITY email.accesskey "E">
+<!ENTITY catchAll.label "Reply from this identity when delivery headers match:">
+<!ENTITY catchAll.accesskey "d">
+<!ENTITY replyTo.label "Reply-to Address:">
+<!ENTITY replyTo.accesskey "s">
+<!ENTITY replyTo.placeholder "Recipients will reply to this other address">
+<!ENTITY organization.label "Organization:">
+<!ENTITY organization.accesskey "O">
+<!ENTITY signatureText.label "Signature text:">
+<!ENTITY signatureText.accesskey "x">
+<!ENTITY signatureHtml.label "Use HTML (e.g., &lt;b&gt;bold&lt;/b&gt;)">
+<!ENTITY signatureHtml.accesskey "L">
+<!ENTITY signatureFile.label "Attach the signature from a file instead (text, HTML, or image):">
+<!ENTITY signatureFile.accesskey "t">
+<!ENTITY edit.label "Edit…">
+<!ENTITY choose.label "Choose…">
+<!ENTITY choose.accesskey "C">
+<!ENTITY editVCard.label "Edit Card…">
+<!ENTITY editVCard.accesskey "d">
+<!-- LOCALIZATION NOTE (attachVCard.label) : do not translate "vCard" in below line -->
+<!ENTITY attachVCard.label "Attach my vCard to messages">
+<!ENTITY attachVCard.accesskey "v">
+
+<!ENTITY manageIdentities.label "Manage Identities…">
+<!ENTITY manageIdentities.accesskey "M">
+
+<!-- LOCALIZATION NOTE (smtpName.label) : do not translate "SMTP" in below line -->
+<!ENTITY smtpName.label "Outgoing Server (SMTP):">
+<!ENTITY smtpName.accesskey "u">
+<!ENTITY smtpDefaultServer.label "Use Default Server">
+
+<!ENTITY smtpServerEdit.label "Edit SMTP server…">
+<!ENTITY smtpServerEdit.accesskey "P">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-mdn.dtd b/comm/mail/locales/en-US/chrome/messenger/am-mdn.dtd
new file mode 100644
index 0000000000..292508ec86
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-mdn.dtd
@@ -0,0 +1,33 @@
+<!-- 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/. -->
+
+<!ENTITY pane.title "Return Receipts">
+<!ENTITY useGlobalPrefs.label "Use my global return receipt preferences for this account">
+<!ENTITY useGlobalPrefs.accesskey "U">
+<!ENTITY globalReceipts.label "Global Preferences…">
+<!ENTITY globalReceipts.accesskey "G">
+<!ENTITY useCustomPrefs.label "Customize return receipts for this account">
+<!ENTITY useCustomPrefs.accesskey "C">
+<!ENTITY requestReceipt.label "When sending messages, always request a return receipt">
+<!ENTITY requestReceipt.accesskey "W">
+<!ENTITY receiptArrive.label "When a receipt arrives:">
+<!ENTITY leaveIt.label "Leave it in my Inbox">
+<!ENTITY leaveIt.accesskey "I">
+<!-- LOCALIZATION NOTE moveToSent.label Translate: 'Sent' according to Netscape glossary -->
+<!ENTITY moveToSent.label "Move it to my &quot;Sent&quot; folder">
+<!ENTITY moveToSent.accesskey "M">
+<!ENTITY requestMDN.label "When I receive a request for a return receipt:">
+<!ENTITY returnSome.label "Allow return receipts for some messages">
+<!ENTITY returnSome.accesskey "e">
+<!ENTITY never.label "Never send a return receipt">
+<!ENTITY never.accesskey "N">
+<!ENTITY notInToCc.label "If I'm not in the To or Cc of the message:">
+<!ENTITY notInToCc.accesskey "T">
+<!ENTITY outsideDomain.label "If the sender is outside my domain:">
+<!ENTITY outsideDomain.accesskey "s">
+<!ENTITY otherCases.label "In all other cases:">
+<!ENTITY otherCases.accesskey "o">
+<!ENTITY askMe.label "Ask me">
+<!ENTITY alwaysSend.label "Always send">
+<!ENTITY neverSend.label "Never send">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-mdn.properties b/comm/mail/locales/en-US/chrome/messenger/am-mdn.properties
new file mode 100644
index 0000000000..90dd7bcc4d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-mdn.properties
@@ -0,0 +1,6 @@
+# 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/.
+
+## Strings used in prefs.
+prefPanel-mdn=Return Receipts
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-offline.dtd b/comm/mail/locales/en-US/chrome/messenger/am-offline.dtd
new file mode 100644
index 0000000000..466cfbb5f2
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-offline.dtd
@@ -0,0 +1,57 @@
+<!-- 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/. -->
+
+<!ENTITY doNotDownloadPop3Movemail.label "To save disk space, do not download:">
+<!ENTITY doNotDownloadNntp.label "To save disk space, do not download for offline use:">
+<!ENTITY doNotDownloadImap.label "To save disk space, downloading messages from the server and keeping local copies for offline use can be restricted by age or size.">
+<!ENTITY allFoldersOffline2.label "Keep messages in all folders for this account on this computer">
+<!ENTITY allFoldersOffline2.accesskey "o">
+<!ENTITY allFoldersOfflineNote.label "Note: Changing this affects all folders in this account. To set individual folders, use the Advanced… button.">
+<!ENTITY offlineNotDownload.label "Messages larger than">
+<!ENTITY offlineNotDownload.accesskey "M">
+<!ENTITY autosyncNotDownload.label "Don't download messages larger than">
+<!ENTITY autosyncNotDownload.accesskey "m">
+<!ENTITY kb.label "KB">
+<!ENTITY daysOld.label "days old">
+<!ENTITY message.label "messages">
+<!ENTITY nntpNotDownloadRead.label "Read messages">
+<!ENTITY nntpNotDownloadRead.accesskey "d">
+<!ENTITY nntpDownloadMsg.label "Messages more than">
+<!ENTITY nntpDownloadMsg.accesskey "e">
+<!ENTITY retentionCleanup.label "To recover disk space, old messages can be permanently deleted.">
+<!ENTITY retentionCleanupImap.label "To recover disk space, old messages can be permanently deleted, both local copies and originals on the remote server.">
+<!ENTITY retentionCleanupPop.label "To recover disk space, old messages can be permanently deleted, including originals on the remote server.">
+<!ENTITY retentionKeepMsg.label "Delete messages more than">
+<!ENTITY retentionKeepMsg.accesskey "t">
+<!ENTITY retentionKeepAll.label "Don't delete any messages">
+<!ENTITY retentionKeepAll.accesskey "n">
+<!ENTITY retentionKeepRecent.label "Delete all but the most recent">
+<!ENTITY retentionKeepRecent.accesskey "b">
+<!ENTITY retentionApplyToFlagged.label "Always keep starred messages">
+<!ENTITY retentionApplyToFlagged.accesskey "k">
+<!ENTITY nntpRemoveMsgBody.label "Remove bodies from messages more than">
+<!ENTITY nntpRemoveMsgBody.accesskey "o">
+<!ENTITY offlineSelectNntp.label "Select newsgroups for offline use…">
+<!ENTITY offlineSelectNntp.accesskey "S">
+<!ENTITY offlineImapAdvancedOffline.label "Advanced…">
+<!ENTITY offlineImapAdvancedOffline.accesskey "v">
+<!ENTITY syncGroupTitle.label "Message Synchronizing">
+<!ENTITY diskspaceGroupTitle.label "Disk Space">
+
+<!-- LOCALIZATION NOTE: (ageAutosyncBefore.label, ageAutosyncMiddle.label, ageAutosyncAfter.label):
+ The entities ageAutosyncBefore.label, ageAutosyncMiddle.label, and ageAutosyncAfter.label appear
+ on a single line within the scope of useAutosync.ByAge as follows:
+
+ &ageAutosyncBefore.label [textbox for autosync value] &ageAutosyncMiddle.label; [dropdown for autosync interval] &ageAutosyncAfter.label;
+-->
+<!ENTITY allAutosync.label "Synchronize all messages locally regardless of age">
+<!ENTITY allAutosync.accesskey "c">
+<!ENTITY ageAutosyncBefore.label "Synchronize the most recent">
+<!ENTITY ageAutosync.accesskey "z">
+<!ENTITY ageAutosyncMiddle.label "">
+<!ENTITY dayAgeInterval.label "Days">
+<!ENTITY weekAgeInterval.label "Weeks">
+<!ENTITY monthAgeInterval.label "Months">
+<!ENTITY yearAgeInterval.label "Years">
+<!ENTITY ageAutosyncAfter.label "">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-server-advanced.dtd b/comm/mail/locales/en-US/chrome/messenger/am-server-advanced.dtd
new file mode 100644
index 0000000000..9a87a7abdd
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-server-advanced.dtd
@@ -0,0 +1,31 @@
+<!-- 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/. -->
+
+<!ENTITY serverAdvanced.label "Advanced Account Settings">
+<!-- LOCALIZATION NOTE (serverDirectory.label): Do not translate "IMAP" -->
+<!ENTITY serverDirectory.label "IMAP server directory:">
+<!ENTITY serverDirectory.accesskey "d">
+<!ENTITY usingSubscription.label "Show only subscribed folders">
+<!ENTITY usingSubscription.accesskey "w">
+<!ENTITY dualUseFolders.label "Server supports folders that contain sub-folders and messages">
+<!ENTITY dualUseFolders.accesskey "f">
+<!ENTITY maximumConnectionsNumber.label "Maximum number of server connections to cache">
+<!ENTITY maximumConnectionsNumber.accesskey "M">
+<!-- LOCALIZATION NOTE (namespaceDesc.label): Do not translate "IMAP" -->
+<!ENTITY namespaceDesc.label "These preferences specify the namespaces on your IMAP server">
+<!ENTITY personalNamespace.label "Personal namespace:">
+<!ENTITY personalNamespace.accesskey "P">
+<!ENTITY publicNamespace.label "Public (shared):">
+<!ENTITY publicNamespace.accesskey "u">
+<!ENTITY otherUsersNamespace.label "Other Users:">
+<!ENTITY otherUsersNamespace.accesskey "O">
+<!ENTITY overrideNamespaces.label "Allow server to override these namespaces">
+<!ENTITY overrideNamespaces.accesskey "A">
+<!ENTITY pop3DeferringDesc.label "When downloading mail from this account's server, use the following folder to store new messages:">
+<!ENTITY accountInbox.label "Inbox for this account">
+<!ENTITY accountInbox.accesskey "s">
+<!ENTITY deferToServer.label "Inbox for different account">
+<!ENTITY deferToServer.accesskey "d">
+<!ENTITY deferGetNewMail.label "Include this server when getting new mail">
+<!ENTITY deferGetNewMail.accesskey "I">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-server-top.dtd b/comm/mail/locales/en-US/chrome/messenger/am-server-top.dtd
new file mode 100644
index 0000000000..53d410db8b
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-server-top.dtd
@@ -0,0 +1,89 @@
+<!-- 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/. -->
+
+<!ENTITY messageStorage.label "Message Storage">
+<!ENTITY securitySettings.label "Security Settings">
+<!ENTITY serverSettings.label "Server Settings">
+<!ENTITY serverType.label "Server Type:">
+<!ENTITY serverName.label "Server Name:">
+<!ENTITY serverName.accesskey "S">
+<!ENTITY userName.label "User Name:">
+<!ENTITY userName.accesskey "N">
+<!ENTITY port.label "Port:">
+<!ENTITY port.accesskey "P">
+<!ENTITY serverPortDefault.label "Default:">
+<!-- LOCALIZATION NOTE (biffStart.label) : translate below 2 line with grammar dependency
+ For example, in Japanese cases:
+ biffStart.label "every"
+ biffEnd.label "minutes for new messages Check"
+-->
+<!ENTITY biffStart.label "Check for new messages every ">
+<!ENTITY biffStart.accesskey "y">
+<!ENTITY biffEnd.label "minutes">
+<!ENTITY useIdleNotifications.label "Allow immediate server notifications when new messages arrive">
+<!ENTITY useIdleNotifications.accesskey "w">
+<!ENTITY connectionSecurity.label "Connection security:">
+<!ENTITY connectionSecurity.accesskey "u">
+<!ENTITY connectionSecurityType-0.label "None">
+<!ENTITY connectionSecurityType-1.label "STARTTLS, if available">
+<!ENTITY connectionSecurityType-2.label "STARTTLS">
+<!ENTITY connectionSecurityType-3.label "SSL/TLS">
+<!ENTITY authMethod.label "Authentication method:">
+<!ENTITY authMethod.accesskey "i">
+<!ENTITY leaveOnServer.label "Leave messages on server">
+<!ENTITY leaveOnServer.accesskey "g">
+<!ENTITY headersOnly.label "Fetch headers only">
+<!ENTITY headersOnly.accesskey "e">
+<!ENTITY deleteByAgeFromServer.label "For at most">
+<!ENTITY deleteByAgeFromServer.accesskey "o">
+<!ENTITY daysEnd.label "days">
+<!ENTITY deleteOnServer2.label "Until I delete them">
+<!ENTITY deleteOnServer2.accesskey "d">
+<!ENTITY downloadOnBiff.label "Automatically download new messages">
+<!ENTITY downloadOnBiff.accesskey "m">
+<!ENTITY deleteMessagePrefix.label "When I delete a message:">
+<!ENTITY modelMoveToTrash.label "Move it to this folder:">
+<!ENTITY modelMoveToTrash.accesskey "o">
+<!ENTITY modelMarkDeleted.label "Just mark it as deleted">
+<!ENTITY modelMarkDeleted.accesskey "k">
+<!ENTITY modelDeleteImmediately.label "Remove it immediately">
+<!ENTITY modelDeleteImmediately.accesskey "d">
+<!-- LOCALIZATION NOTE (expungeOnExit.label) : do not translate two of "&quot;" in below line -->
+<!ENTITY expungeOnExit.label "Clean up (&quot;Expunge&quot;) Inbox on Exit">
+<!ENTITY expungeOnExit.accesskey "E">
+<!ENTITY emptyTrashOnExit.label "Empty Trash on Exit">
+<!ENTITY emptyTrashOnExit.accesskey "x">
+<!ENTITY loginAtStartup.label "Check for new messages at startup">
+<!ENTITY loginAtStartup.accesskey "C">
+<!-- LOCALIZATION NOTE (maxMessagesStart.label) : translate below 2 lines with grammar dependency
+ maxMessengerStart.label will be followed by maxMessagesEnd.label with the number
+ of messages between them
+-->
+<!ENTITY maxMessagesStart.label "Ask me before downloading more than">
+<!ENTITY maxMessagesStart.accesskey "m">
+<!-- LOCALIZATION NOTE (maxMessagesEnd.label) : see note for maxMessagesStart.label -->
+<!ENTITY maxMessagesEnd.label "messages">
+<!ENTITY alwaysAuthenticate.label "Always request authentication when connecting to this server">
+<!ENTITY alwaysAuthenticate.accesskey "w">
+<!ENTITY newsrcFilePath1.label "News.rc File:">
+<!ENTITY newsrcPicker1.label "Select News.rc File">
+<!ENTITY abbreviate.label "Show newsgroup names in the Mail Folder pane as:">
+<!ENTITY abbreviateOn.label "Full names (For example, 'netscape.public.mozilla.mail-news')">
+<!ENTITY abbreviateOff.label "Abbreviate names (For example, 'n.p.m.mail-news')">
+<!ENTITY advancedButton.label "Advanced…">
+<!ENTITY advancedButton.accesskey "v">
+<!ENTITY serverDefaultCharset2.label "Default Text Encoding:">
+<!ENTITY localPath1.label "Local Directory:">
+<!ENTITY localFolderPicker.label "Select Local Directory">
+<!ENTITY browseFolder.label "Browse…">
+<!ENTITY browseFolder.accesskey "B">
+<!ENTITY browseNewsrc.label "Browse…">
+<!ENTITY browseNewsrc.accesskey "e">
+
+<!ENTITY accountTitle.label "Account Settings">
+<!ENTITY accountSettingsDesc.label "The following is a special account. There are no identities associated with it.">
+<!ENTITY storeType.label "Message Store Type:">
+<!ENTITY storeType.accesskey "T">
+<!ENTITY mboxStore2.label "File per folder (mbox)">
+<!ENTITY maildirStore.label "File per message (maildir)">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-serverwithnoidentities.dtd b/comm/mail/locales/en-US/chrome/messenger/am-serverwithnoidentities.dtd
new file mode 100644
index 0000000000..f568613a16
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-serverwithnoidentities.dtd
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+
+<!ENTITY accountName.label "Account Name:">
+<!ENTITY accountName.accesskey "N">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-smime.dtd b/comm/mail/locales/en-US/chrome/messenger/am-smime.dtd
new file mode 100644
index 0000000000..13d49d85b9
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-smime.dtd
@@ -0,0 +1,46 @@
+<!-- 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/. -->
+
+<!ENTITY e2eTitle.label "End-To-End Encryption">
+<!ENTITY e2eLearnMore.label "Learn more">
+
+<!ENTITY e2eEnc.description "Without end-to-end encryption the contents of messages are easily exposed to your email provider and to mass surveillance.">
+
+<!ENTITY e2eTechPref.description "Preferred encryption technology:">
+
+<!ENTITY encryptionCert2.message "Personal certificate for encryption:">
+<!ENTITY digitalSign.certificate.button "Select…">
+<!ENTITY digitalSign.certificate.accesskey "S">
+<!ENTITY digitalSign.certificate_clear.button "Clear">
+<!ENTITY digitalSign.certificate_clear.accesskey "C">
+<!ENTITY encryption.certificate.button "Select…">
+<!ENTITY encryption.certificate.accesskey "t">
+<!ENTITY encryption.certificate_clear.button "Clear">
+<!ENTITY encryption.certificate_clear.accesskey "e">
+<!ENTITY signingGroupTitle.label "Digital Signing">
+<!ENTITY signingCert2.message "Personal certificate for digital signing:">
+
+<!ENTITY sendingDefaults.label "Default settings for sending messages">
+
+<!ENTITY technologyAutomatic.label "Select automatically based on available keys or certificates">
+
+<!ENTITY certificates2.label "S/MIME">
+<!ENTITY manageCerts3.label "Manage S/MIME Certificates">
+<!ENTITY manageCerts3.accesskey "M">
+<!ENTITY manageDevices2.label "S/MIME Security Devices">
+<!ENTITY manageDevices2.accesskey "y">
+
+<!ENTITY technologySMIME.label "Prefer S/MIME">
+<!ENTITY technologyOpenPGP.label "Prefer OpenPGP">
+
+<!ENTITY openpgpKeys.label "OpenPGP">
+
+<!-- Strings for the cert picker dialog -->
+<!ENTITY certPicker.title "Select Certificate">
+<!ENTITY certPicker.info "Certificate:">
+<!ENTITY certPicker.detailsLabel "Details of selected certificate:">
+
+<!ENTITY openpgpKey.message "Personal key for encryption and digital signing:">
+<!ENTITY openpgpKey.button "Set Personal Key…">
+<!ENTITY openpgpKey.accesskey "o">
diff --git a/comm/mail/locales/en-US/chrome/messenger/am-smime.properties b/comm/mail/locales/en-US/chrome/messenger/am-smime.properties
new file mode 100644
index 0000000000..5a76a47ca1
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/am-smime.properties
@@ -0,0 +1,40 @@
+# 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/.
+
+## S/MIME error strings.
+## Note to localization: %S is a placeholder
+NoSenderSigningCert=You specified that this message should be digitally signed, but the application either failed to find the signing certificate specified in your Mail & Newsgroup Account Settings, or the certificate has expired.
+NoSenderEncryptionCert=You specified encryption for this message, but the application either failed to find the encryption certificate specified in your Mail & Newsgroup Account Settings, or the certificate has expired.
+MissingRecipientEncryptionCert=You specified encryption for this message, but the application failed to find an encryption certificate for %S.
+ErrorEncryptMail=Unable to encrypt message. Please check that you have a valid email certificate for each recipient. Please check that the certificates specified in Mail & Newsgroups Account Settings for this mail account are valid and trusted for mail.
+ErrorCanNotSignMail=Unable to sign message. Please check that the certificates specified in Mail & Newsgroups Account Settings for this mail account are valid and trusted for mail.
+
+## Strings used for in the prefs.
+NoSigningCert=Certificate Manager can't locate a valid certificate that can be used to digitally sign your messages.
+NoSigningCertForThisAddress=Certificate Manager can't locate a valid certificate that can be used to digitally sign your messages with an address of <%S>.
+NoEncryptionCert=Certificate Manager can't locate a valid certificate that other people can use to send you encrypted email messages.
+NoEncryptionCertForThisAddress=Certificate Manager can't locate a valid certificate that other people can use to send you encrypted email messages to the address <%S>.
+
+encryption_needCertWantSame=You should also specify a certificate for other people to use when they send you encrypted messages. Do you want to use the same certificate to encrypt & decrypt messages sent to you?
+encryption_wantSame=Do you want to use the same certificate to encrypt & decrypt messages sent to you?
+encryption_needCertWantToSelect=You should also specify a certificate for other people to use when they send you encrypted messages. Do you want to configure an encryption certificate now?
+signing_needCertWantSame=You should also specify a certificate to use for digitally signing your messages. Do you want to use the same certificate to digitally sign your messages?
+signing_wantSame=Do you want to use the same certificate to digitally sign your messages?
+signing_needCertWantToSelect=You should also specify a certificate to use for digitally signing your messages. Do you want to configure a certificate for digitally signing messages now?
+
+## Strings used by nsMsgComposeSecure
+mime_smimeEncryptedContentDesc=S/MIME Encrypted Message
+mime_smimeSignatureContentDesc=S/MIME Cryptographic Signature
+
+## Strings used by the cert picker.
+CertInfoIssuedFor=Issued to:
+CertInfoIssuedBy=Issued by:
+CertInfoValid=Valid
+CertInfoFrom=from
+CertInfoTo=to
+CertInfoPurposes=Purposes
+CertInfoEmail=Email
+CertInfoStoredIn=Stored in:
+NicknameExpired=(expired)
+NicknameNotYetValid=(not yet valid)
diff --git a/comm/mail/locales/en-US/chrome/messenger/appUpdate.properties b/comm/mail/locales/en-US/chrome/messenger/appUpdate.properties
new file mode 100644
index 0000000000..0a423d55d0
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/appUpdate.properties
@@ -0,0 +1,40 @@
+# 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/.
+
+# LOCALIZATION NOTE (updateAvailableTitle): %S will be replaced with brandShortName
+updateAvailableTitle=A new %S update is available.
+# LOCALIZATION NOTE (updateAvailableMessage): %S will be replaced with brandShortName
+updateAvailableMessage=Update your %S for the latest in speed and privacy.
+updateAvailablePrimaryButtonLabel=Download Update
+updateAvailablePrimaryButtonAccessKey=D
+updateAvailableSecondaryButtonLabel=Not Now
+updateAvailableSecondaryButtonAccessKey=N
+
+# LOCALIZATION NOTE (updateManualTitle): %S will be replaced with brandShortName
+updateManualTitle=%S can’t update to the latest version.
+# LOCALIZATION NOTE (updateManualMessage): %S will be replaced with brandShortName
+updateManualMessage=Download a fresh copy of %S and we’ll help you to install it.
+# LOCALIZATION NOTE (updateManualPrimaryButtonLabel): %S will be replaced with brandShortName
+updateManualPrimaryButtonLabel=Download %S
+updateManualPrimaryButtonAccessKey=D
+updateManualSecondaryButtonLabel=Not Now
+updateManualSecondaryButtonAccessKey=N
+
+# LOCALIZATION NOTE (updateUnsupportedTitle): %S will be replaced with brandShortName
+updateUnsupportedTitle=%S is unable to update to the latest version.
+# LOCALIZATION NOTE (updateUnsupportedMessage): %S will be replaced with brandShortName
+updateUnsupportedMessage=The latest version of %S is not supported on your system.
+updateUnsupportedPrimaryButtonLabel=Learn more
+updateUnsupportedPrimaryButtonAccessKey=L
+updateUnsupportedSecondaryButtonLabel=Close
+updateUnsupportedSecondaryButtonAccessKey=C
+
+# LOCALIZATION NOTE (updateRestartTitle): %S will be replaced with brandShortName
+updateRestartTitle=Restart to update %S.
+# LOCALIZATION NOTE (updateRestartMessage): %S will be replaced with brandShortName
+updateRestartMessage=After a quick restart, %S will restore all your open tabs and windows.
+updateRestartPrimaryButtonLabel=Restart
+updateRestartPrimaryButtonAccessKey=R
+updateRestartSecondaryButtonLabel=Not Now
+updateRestartSecondaryButtonAccessKey=N
diff --git a/comm/mail/locales/en-US/chrome/messenger/appleMailImportMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/appleMailImportMsgs.properties
new file mode 100644
index 0000000000..38c9e3eaa0
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/appleMailImportMsgs.properties
@@ -0,0 +1,20 @@
+# 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/.
+
+# Short name of import module
+ApplemailImportName=Apple Mail
+
+# Description of import module
+ApplemailImportDescription=Import Local Mail from Mac OS X Mail
+
+# Success Message
+# LOCALIZATION NOTE(ApplemailImportMailboxSuccess): Do not translate the word "%S" below.
+ApplemailImportMailboxSuccess=Local messages were successfully imported from %S
+
+# Error Message
+ApplemailImportMailboxBadparam=An internal error occurred. Importing failed. Try importing again.
+
+# Error message
+# LOCALIZATION NOTE(ApplemailImportMailboxConverterror): Do not translate the word "%S" below.
+ApplemailImportMailboxConverterror=An error occurred while importing messages from %S. Messages were not imported.
diff --git a/comm/mail/locales/en-US/chrome/messenger/baseMenuOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/baseMenuOverlay.dtd
new file mode 100644
index 0000000000..0daa202985
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/baseMenuOverlay.dtd
@@ -0,0 +1,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/. -->
+
+<!-- Help Menu -->
+<!ENTITY productHelp.commandkey "VK_F1">
+<!ENTITY productHelpMac.commandkey "?">
+<!ENTITY productHelpMac.modifiers "accel">
diff --git a/comm/mail/locales/en-US/chrome/messenger/beckyImportMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/beckyImportMsgs.properties
new file mode 100644
index 0000000000..97aa769651
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/beckyImportMsgs.properties
@@ -0,0 +1,19 @@
+# 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 following are used by the becky import code to display status/error
+# and informational messages
+
+# Short name of import module
+BeckyImportName=Becky! Internet Mail
+
+# Description of import module
+BeckyImportDescription=Import Local Mail from Becky! Internet Mail
+
+# Success Message
+# LOCALIZATION NOTE : Do not translate the word "%S" below.
+# The variable %S will contain the name of the Mailbox
+BeckyImportMailboxSuccess=Local messages were successfully imported from %S.
+
+BeckyImportAddressSuccess=Address book imported
diff --git a/comm/mail/locales/en-US/chrome/messenger/charsetTitles.properties b/comm/mail/locales/en-US/chrome/messenger/charsetTitles.properties
new file mode 100644
index 0000000000..4200239d2b
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/charsetTitles.properties
@@ -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/.
+
+## Rule of this file:
+## 1. key should always be in lower case ascii so we can do case insensitive
+## comparison in the code faster.
+
+## Format of this file:
+## charset_name.title = a_title - specifies the human readable title for
+## this charset
+
+iso-8859-1.title = Western (ISO-8859-1)
+iso-8859-2.title = Central European (ISO-8859-2)
+iso-8859-3.title = South European (ISO-8859-3)
+iso-8859-4.title = Baltic (ISO-8859-4)
+iso-8859-10.title = Nordic (ISO-8859-10)
+iso-8859-13.title = Baltic (ISO-8859-13)
+iso-8859-14.title = Celtic (ISO-8859-14)
+iso-8859-15.title = Western (ISO-8859-15)
+iso-8859-16.title = Romanian (ISO-8859-16)
+windows-1250.title = Central European (Windows-1250)
+windows-1252.title = Western (Windows-1252)
+windows-1254.title = Turkish (Windows-1254)
+windows-1257.title = Baltic (Windows-1257)
+macintosh.title = Western (MacRoman)
+x-mac-ce.title = Central European (MacCE)
+x-mac-turkish.title = Turkish (MacTurkish)
+x-mac-croatian.title = Croatian (MacCroatian)
+x-mac-romanian.title = Romanian (MacRomanian)
+x-mac-icelandic.title = Icelandic (MacIcelandic)
+iso-2022-jp.title = Japanese (ISO-2022-JP)
+shift_jis.title = Japanese (Shift_JIS)
+euc-jp.title = Japanese (EUC-JP)
+big5.title = Chinese Traditional (Big5)
+big5-hkscs.title = Chinese Traditional (Big5-HKSCS)
+gb2312.title = Chinese Simplified (GB2312)
+gbk.title = Chinese Simplified (GBK)
+euc-kr.title = Korean (EUC-KR)
+utf-7.title = Unicode (UTF-7)
+utf-8.title = Unicode (UTF-8)
+utf-16.title = Unicode (UTF-16)
+utf-16le.title = Unicode (UTF-16LE)
+utf-16be.title = Unicode (UTF-16BE)
+iso-8859-5.title = Cyrillic (ISO-8859-5)
+windows-1251.title = Cyrillic (Windows-1251)
+x-mac-cyrillic.title = Cyrillic (MacCyrillic)
+x-mac-ukrainian.title = Cyrillic/Ukrainian (MacUkrainian)
+koi8-r.title = Cyrillic (KOI8-R)
+koi8-u.title = Cyrillic/Ukrainian (KOI8-U)
+iso-8859-7.title = Greek (ISO-8859-7)
+windows-1253.title = Greek (Windows-1253)
+x-mac-greek.title = Greek (MacGreek)
+windows-1258.title = Vietnamese (Windows-1258)
+windows-874.title = Thai (Windows-874)
+iso-8859-6.title = Arabic (ISO-8859-6)
+iso-8859-8.title = Hebrew Visual (ISO-8859-8)
+iso-8859-8-i.title = Hebrew (ISO-8859-8-I)
+windows-1255.title = Hebrew (Windows-1255)
+windows-1256.title = Arabic (Windows-1256)
+x-user-defined.title = User Defined
+ibm866.title = Cyrillic/Russian (CP-866)
+gb18030.title = Chinese Simplified (GB18030)
+x-mac-arabic.title = Arabic (MacArabic)
+x-mac-farsi.title = Farsi (MacFarsi)
+x-mac-hebrew.title = Hebrew (MacHebrew)
+x-mac-devanagari.title = Hindi (MacDevanagari)
+x-mac-gujarati.title = Gujarati (MacGujarati)
+x-mac-gurmukhi.title = Gurmukhi (MacGurmukhi)
+
+chardet.off.title = (Off)
+chardet.universal_charset_detector.title = Universal
+chardet.ja_parallel_state_machine.title = Japanese
+chardet.ko_parallel_state_machine.title = Korean
+chardet.zhtw_parallel_state_machine.title = Traditional Chinese
+chardet.zhcn_parallel_state_machine.title = Simplified Chinese
+chardet.zh_parallel_state_machine.title = Chinese
+chardet.cjk_parallel_state_machine.title = East Asian
+chardet.ruprob.title = Russian
+chardet.ukprob.title = Ukrainian
diff --git a/comm/mail/locales/en-US/chrome/messenger/chat.dtd b/comm/mail/locales/en-US/chrome/messenger/chat.dtd
new file mode 100644
index 0000000000..99d6fd7bc2
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/chat.dtd
@@ -0,0 +1,44 @@
+<!-- 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/. -->
+
+<!ENTITY onlineContactsHeader.label "Online Contacts">
+<!ENTITY offlineContactsHeader.label "Offline Contacts">
+<!ENTITY conversationsHeader.label "Conversations">
+<!ENTITY searchResultConversation.label "Search result">
+<!ENTITY chat.noConv.title "Conversations will be displayed here.">
+<!ENTITY chat.noConv.description "Use the contact list in the left panel to start a conversation.">
+<!ENTITY chat.noPreviousConv.description "&brandShortName; currently doesn't have any previous conversations stored for this contact.">
+<!ENTITY chat.noAccount.title "You haven't set up a chat account yet.">
+<!ENTITY chat.noAccount.description "Let &brandShortName; guide you through the process of setting up your chat account.">
+<!ENTITY chat.accountWizard.button "Get started">
+<!ENTITY chat.noConnectedAccount.title "Your chat accounts are not connected.">
+<!ENTITY chat.noConnectedAccount.description "You can connect them from the 'Chat status' dialog:">
+<!ENTITY chat.showAccountManager.button "Show chat status">
+
+<!ENTITY chat.participants "Participants:">
+<!ENTITY chat.previousConversations "Previous Conversations:">
+<!ENTITY chat.ongoingConversation "Ongoing conversation">
+
+<!ENTITY openConversationCmd.label "Start a Conversation">
+<!ENTITY openConversationCmd.accesskey "c">
+<!ENTITY closeConversationCmd.label "Close Conversation">
+<!ENTITY closeConversationCmd.accesskey "C">
+<!ENTITY aliasCmd.label "Rename">
+<!ENTITY aliasCmd.accesskey "R">
+<!ENTITY deleteCmd.label "Remove Contact">
+<!ENTITY deleteCmd.accesskey "v">
+
+<!ENTITY openConversationButton.tooltip "Start a conversation">
+<!ENTITY closeConversationButton.tooltip "Close conversation">
+
+<!ENTITY addBuddyButton.label "Add Contact">
+<!ENTITY joinChatButton.label "Join Chat">
+<!ENTITY chatAccountsButton.label "Show Accounts">
+
+<!ENTITY status.available "Available">
+<!ENTITY status.unavailable "Unavailable">
+<!ENTITY status.offline "Offline">
+
+<!ENTITY openLinkCmd.label "Open Link…">
+<!ENTITY openLinkCmd.accesskey "O">
diff --git a/comm/mail/locales/en-US/chrome/messenger/chat.properties b/comm/mail/locales/en-US/chrome/messenger/chat.properties
new file mode 100644
index 0000000000..f971e5353a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/chat.properties
@@ -0,0 +1,110 @@
+# 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/.
+
+chatTabTitle=Chat
+goBackToCurrentConversation.button=Back to current conversation
+# LOCALIZATION NOTE (startAConversationWith.button):
+# %S is replaced with the display name of a contact.
+startAConversationWith.button=Start a conversation with %S
+
+# LOCALIZATION NOTE (defaultGroup):
+# this is used in the addBuddies dialog if the list of existing groups is empty
+defaultGroup=Contacts
+
+# LOCALIZATION NOTE (buddy.authRequest.label):
+# This string appears in a notification bar at the
+# top of the Contacts window when someone added the user to his/her
+# contact list, to request the permission from the user to share
+# status information with this potential new contact.
+# %S is replaced with the user name of the potential new contact.
+buddy.authRequest.label=%S wants to chat with you
+buddy.authRequest.allow.label=Allow
+buddy.authRequest.allow.accesskey=A
+buddy.authRequest.deny.label=Deny
+buddy.authRequest.deny.accesskey=D
+
+## LOCALIZATION NOTE (buddy.verificationRequest):
+# Strings used in a notification bar at the top of the chat tab when someone
+# sends a verification request for end-to-end encryption keys.
+# %S is replaced with the display name of the user or, if this is to verify a
+# session of yourself, a string that identifies the session.
+buddy.verificationRequest.label=%S wants to verify each other’s identities
+buddy.verificationRequest.allow.label=Start Verification
+buddy.verificationRequest.allow.accesskey=S
+buddy.verificationRequest.deny.label=Deny
+buddy.verificationRequest.deny.accesskey=D
+
+# LOCALIZATION NOTE (buddy.deletePrompt.title):
+# %S here will be replaced by the alias (or username) of a buddy about
+# to be removed from the buddy list.
+buddy.deletePrompt.title=Delete %S?
+
+# LOCALIZATION NOTE (buddy.deletePrompt.message):
+# %1$S will be replaced by the name of a buddy (either the alias
+# followed by the username between parenthesis if an alias is set, or
+# only the username otherwise).
+# %2$S will be the name of the protocol on which this buddy is removed
+# (for example: AIM, MSN, Google Talk).
+#
+# Please find a wording that will keep the username as close as
+# possible to the beginning of the string, because this is the
+# important information that an user should see when looking quickly
+# at this prompt.
+buddy.deletePrompt.message=%1$S will be permanently removed from your %2$S buddy list if you continue.
+
+# LOCALIZATION NOTE (buddy.deletePrompt.displayName):
+# This is used to format the display name inserted in buddy.deletePrompt.message
+# %1$S is the alias, %2$S is the username.
+buddy.deletePrompt.displayName=%1$S (%2$S)
+
+# LOCALIZATION NOTE (buddy.deletePrompt.button):
+# the & symbol indicates the position of the character that should be
+# used as the accesskey for this button.
+buddy.deletePrompt.button=&Delete
+
+displayNameEmptyText=Display Name
+userIconFilePickerTitle=Select the new icon…
+
+# LOCALIZATION NOTE (chat.isTyping, chat.hasStoppedTyping):
+# The contact display name is displayed with a big font on a first
+# line and these two strings are displayed on a second line with a
+# smaller font. Please try to find a wording that make this look
+# almost like a sentence.
+chat.isTyping=is typing…
+chat.hasStoppedTyping=has stopped typing.
+# LOCALIZATION NOTE (chat.contactIsTyping, chat.contactHasStoppedTyping):
+# These strings are displayed in a tooltip when hovering the status type icon.
+# %S is replaced with the display name of the contact.
+chat.contactIsTyping=%S is typing.
+chat.contactHasStoppedTyping=%S has stopped typing.
+
+# LOCALIZATION NOTE (unknownCommand):
+# This is shown when an unknown command (/foo) is attempted. %S is the command.
+unknownCommand=%S is not a supported command. Type /help to see the list of commands.
+
+#LOCALIZATION NOTE
+# These are special entries in the log tree for the corresponding days.
+log.today=Today
+log.yesterday=Yesterday
+
+#LOCALIZATION NOTE
+# These are special groups in the log tree for the last 3-7 days and
+# the last 8-14 days.
+log.currentWeek=This Week
+log.previousWeek=Last Week
+
+# LOCALIZATION NOTE (messagePreview):
+# This is the default message preview to be shown
+# when the user has chosen not to show any info in the notification about the
+# incoming message being notified.
+messagePreview=New Chat Message
+
+#LOCALIZATION NOTE (bundledMessagePreview): Semi-colon list of plural forms.
+# Used when multiple incoming messages from the same sender are bundled
+# into a single notification.
+# #1 is the number of incoming messages the user is being notified about. When #1
+# is greater than one, the plural form after the semicolon is used.
+# Do not translate %1$S, it is the message preview to be shown in the
+# notification, i.e. the first incoming message.
+bundledMessagePreview=%1$S… (and #1 more message);%1$S… (and #1 more messages)
diff --git a/comm/mail/locales/en-US/chrome/messenger/configEditorOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/configEditorOverlay.dtd
new file mode 100644
index 0000000000..3d78799d68
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/configEditorOverlay.dtd
@@ -0,0 +1,5 @@
+<!-- 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/. -->
+
+<!ENTITY closeCmd.key "W">
diff --git a/comm/mail/locales/en-US/chrome/messenger/converterDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/converterDialog.dtd
new file mode 100644
index 0000000000..4bd8419d24
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/converterDialog.dtd
@@ -0,0 +1,10 @@
+<!-- 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/. -->
+
+<!ENTITY converterDialog.title "Message Store Type Converter">
+<!ENTITY converterDialog.continueButton "Continue">
+<!ENTITY converterDialog.cancelButton "Cancel">
+<!ENTITY converterDialog.finishButton "Finish">
+<!ENTITY converterDialog.complete "The conversion is complete. &brandShortName; will now restart.">
+<!ENTITY converterDialog.error "Conversion failed.">
diff --git a/comm/mail/locales/en-US/chrome/messenger/converterDialog.properties b/comm/mail/locales/en-US/chrome/messenger/converterDialog.properties
new file mode 100644
index 0000000000..d808be0319
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/converterDialog.properties
@@ -0,0 +1,41 @@
+# 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/.
+
+# LOCALIZATION NOTE (converterDialog.warning):
+# %1$S will be replaced by the name of the account which is going to be converted.
+# %2$S will be replaced by the format into which the account will be converted.
+# %3$S will be replaced by $BrandShortName.
+converterDialog.warning=The messages in the account %1$S will now be converted to the %2$S format. %3$S will restart after the conversion is complete.
+
+# LOCALIZATION NOTE (converterDialog.message):
+# %1$S will be replaced by the name of the account which is being converted.
+# %2$S will be replaced by the format into which the account will be converted.
+converterDialog.message=Converting the account %1$S to %2$S…
+
+# LOCALIZATION NOTE (converterDialog.warningForDeferredAccount):
+# %1$S will be replaced by the name of the deferred account for which migration is initiated by the user.
+# %2$S will be replaced by the name of the account to which the deferred account is deferred ie the name of the deferred-to account.
+# %3$S will be replaced by the name of the deferred-to account.
+# %4$S will be replaced by a comma separated list of names of accounts which are deferred to the deferred-to account.
+# %5$S will be replaced by a comma separated list of names of accounts which are going to get converted.
+# %6$S will be replaced by the format into which the accounts will be converted.
+# %7$S will be replaced by $BrandShortName.
+converterDialog.warningForDeferredAccount=%1$S is deferred to %2$S. Accounts deferred to %3$S: %4$S. The messages in the accounts %5$S will now be converted to the %6$S format. %7$S will restart after the conversion is complete.
+
+# LOCALIZATION NOTE (converterDialog.warningForDeferredToAccount):
+# %1$S will be replaced by the name of the deferred-to account for which migration is initiated by the user and to which other accounts are deferred.
+# %2$S will be replaced by a comma separated list of names of accounts which are deferred to the deferred-to account.
+# %3$S will be replaced by a comma separated list of names of accounts which are going to get converted.
+# %4$S will be replaced by the format into which the accounts will be converted.
+# %5$S will be replaced by $BrandShortName.
+converterDialog.warningForDeferredToAccount=Accounts deferred to %1$S: %2$S. The messages in the accounts %3$S will now be converted to the %4$S format. %5$S will restart after the conversion is complete.
+
+# LOCALIZATION NOTE (converterDialog.messageForDeferredAccount):
+# %1$S will be replaced by a comma separated list of names of accounts which are being converted.
+# %2$S will be replaced by the format into which the accounts will be converted.
+converterDialog.messageForDeferredAccount=Converting the accounts %1$S to %2$S…
+
+# LOCALIZATION NOTE (converterDialog.percentDone):
+# %1$S will be replaced by the percentage of conversion that is complete.
+converterDialog.percentDone=%1$S%% done
diff --git a/comm/mail/locales/en-US/chrome/messenger/custom.properties b/comm/mail/locales/en-US/chrome/messenger/custom.properties
new file mode 100644
index 0000000000..f51faa3a68
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/custom.properties
@@ -0,0 +1,5 @@
+# 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/.
+
+colonInHeaderName=The header you entered contains an invalid character, such as ':', a non-printable character, a non-ascii character, or an eight bit ascii character. Please remove the invalid character and try again.
diff --git a/comm/mail/locales/en-US/chrome/messenger/customizeToolbar.dtd b/comm/mail/locales/en-US/chrome/messenger/customizeToolbar.dtd
new file mode 100644
index 0000000000..6ecaa6c9da
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/customizeToolbar.dtd
@@ -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/. -->
+
+<!ENTITY dialog.title "Customize Toolbar">
+<!ENTITY dialog.dimensions "width: 92ch; height: 36em;">
+<!ENTITY instructions.description "You can add or remove items by dragging to or from the toolbars.">
+<!ENTITY show.label "Show:">
+<!ENTITY iconsAndText.label "Icons and Text">
+<!ENTITY icons.label "Icons">
+<!ENTITY text.label "Text">
+<!ENTITY iconsBesideText.label "Icons beside Text">
+<!ENTITY useSmallIcons.label "Use Small Icons">
+<!ENTITY restoreDefaultSet.label "Restore Default Set">
+<!ENTITY showTitlebar2.label "Title Bar">
+<!ENTITY saveChanges.label "Done">
+<!ENTITY undoChanges.label "Undo Changes">
diff --git a/comm/mail/locales/en-US/chrome/messenger/customizeToolbar.properties b/comm/mail/locales/en-US/chrome/messenger/customizeToolbar.properties
new file mode 100644
index 0000000000..0ec6d2c1db
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/customizeToolbar.properties
@@ -0,0 +1,11 @@
+# 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/.
+
+enterToolbarTitle=New Toolbar
+enterToolbarName=Enter a name for this toolbar:
+enterToolbarDup=There is already a toolbar with the name “%Sâ€. Please enter a different name.
+enterToolbarBlank=You must enter a name to create a new toolbar.
+separatorTitle=Separator
+springTitle=Flexible Space
+spacerTitle=Space
diff --git a/comm/mail/locales/en-US/chrome/messenger/devtools/dbgserver.dtd b/comm/mail/locales/en-US/chrome/messenger/devtools/dbgserver.dtd
new file mode 100644
index 0000000000..c62c720c3c
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/devtools/dbgserver.dtd
@@ -0,0 +1,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/. -->
+
+<!ENTITY options.title "Developer Tools Options">
+<!ENTITY options.status.label "Status:">
+<!ENTITY options.port.label "Port:">
+<!ENTITY options.forcelocal.label "Allow connections from other computers">
diff --git a/comm/mail/locales/en-US/chrome/messenger/devtools/dbgserver.properties b/comm/mail/locales/en-US/chrome/messenger/devtools/dbgserver.properties
new file mode 100644
index 0000000000..6d3ff2fa1b
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/devtools/dbgserver.properties
@@ -0,0 +1,15 @@
+# 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/.
+
+options.stop.label=Stop Developer Tools Server
+options.start.label=Start Developer Tools Server
+
+options.connected.label=#1 Client Connected;#1 Clients Connected
+options.connected.tooltip=The developer tools server is running and there are clients connected.
+options.listening.label=Listening
+options.listening.tooltip=The developer tools server is running and waiting for connections.
+options.idle.label=Not Running
+options.idle.tooltip=The developer tools server is not running. You can start it from this dialog.
+options.unsupported.label=Unsupported
+options.unsupported.tooltip=There was an error loading the built-in developer tools server. Make sure it is packaged and check your error console for messages.
diff --git a/comm/mail/locales/en-US/chrome/messenger/downloadheaders.dtd b/comm/mail/locales/en-US/chrome/messenger/downloadheaders.dtd
new file mode 100644
index 0000000000..405c2950bb
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/downloadheaders.dtd
@@ -0,0 +1,20 @@
+<!-- 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/. -->
+
+<!ENTITY all.label "Download all headers">
+<!ENTITY all.accesskey "D">
+<!--LOCALIZATION NOTE (download.label):
+ consider the download.label and headers.label as a single sentence
+ with the number of headers to be downloaded inserted between them:
+ EXAMPLE: "Download" <some number> "headers"
+ Either label could be set to null ("") if required grammatically.
+-->
+
+<!ENTITY download.label "Download">
+<!ENTITY download.accesskey "o">
+<!--LOCALIZATION NOTE (headers.label): see note for download.label -->
+<!ENTITY headers.label "headers">
+<!ENTITY headers.accesskey "h">
+<!ENTITY mark.label "Mark remaining headers as read">
+<!ENTITY mark.accesskey "M">
diff --git a/comm/mail/locales/en-US/chrome/messenger/editContactOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/editContactOverlay.dtd
new file mode 100644
index 0000000000..7f6d5959ac
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/editContactOverlay.dtd
@@ -0,0 +1,20 @@
+<!-- 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/. -->
+
+<!ENTITY editContactPanelDeleteContact.label "Delete">
+<!ENTITY editContactPanelDeleteContact.accesskey "l">
+
+<!ENTITY editContactName.label "Name:">
+<!ENTITY editContactName.accesskey "N">
+
+<!ENTITY editContactEmail.label "Email:">
+<!ENTITY editContactEmail.accesskey "E">
+
+<!ENTITY editContactAddressBook.label "Address Book:">
+<!ENTITY editContactAddressBook.accesskey "A">
+
+<!ENTITY editContactPanelDone.label "Done">
+<!ENTITY editContactPanelDone.accesskey "D">
+
+<!ENTITY contactMoveDisabledWarning.description "You can't change the address book because the contact is in a mailing list.">
diff --git a/comm/mail/locales/en-US/chrome/messenger/editContactOverlay.properties b/comm/mail/locales/en-US/chrome/messenger/editContactOverlay.properties
new file mode 100644
index 0000000000..76908410bd
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/editContactOverlay.properties
@@ -0,0 +1,14 @@
+# 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/.
+
+editTitle=Edit Contact
+viewTitle=View Contact
+
+editDetailsLabel=Edit Details
+editDetailsAccessKey=t
+viewDetailsLabel=View Details
+viewDetailsAccessKey=t
+
+deleteContactTitle=Delete Contact
+deleteContactMessage=Are you sure you want to delete this Contact?
diff --git a/comm/mail/locales/en-US/chrome/messenger/fieldMapImport.dtd b/comm/mail/locales/en-US/chrome/messenger/fieldMapImport.dtd
new file mode 100644
index 0000000000..e9d06a1b7e
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/fieldMapImport.dtd
@@ -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/. -->
+
+<!ENTITY fieldMapImport.next.label "Next">
+<!ENTITY fieldMapImport.next.accesskey "N">
+<!ENTITY fieldMapImport.previous.label "Previous">
+<!ENTITY fieldMapImport.previous.accesskey "P">
+<!ENTITY fieldMapImport.text "Use Move Up and Move Down to match the address book fields on the left to the correct data for import on the right. Uncheck items you do not want to import.">
+<!ENTITY fieldMapImport.up.label "Move Up">
+<!ENTITY fieldMapImport.up.accesskey "U">
+<!ENTITY fieldMapImport.down.label "Move Down">
+<!ENTITY fieldMapImport.down.accesskey "D">
+<!ENTITY fieldMapImport.fieldListTitle "Address Book fields">
+<!ENTITY fieldMapImport.dataTitle "Record data to import">
+<!ENTITY fieldMapImport.skipFirstRecord.label "First record contains field names">
+<!ENTITY fieldMapImport.skipFirstRecord.accessKey "F">
diff --git a/comm/mail/locales/en-US/chrome/messenger/filter.properties b/comm/mail/locales/en-US/chrome/messenger/filter.properties
new file mode 100644
index 0000000000..3152ca5afd
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/filter.properties
@@ -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/.
+
+mustSelectFolder=You must select a target folder.
+enterValidEmailAddress=Enter a valid email address to forward to.
+pickTemplateToReplyWith=Choose a template to reply with.
+mustEnterName=You must give this filter a name.
+cannotHaveDuplicateFilterTitle=Duplicate Filter Name
+cannotHaveDuplicateFilterMessage=The filter name you entered already exists. Please enter a different filter name.
+mustHaveFilterTypeTitle=No filter event selected
+mustHaveFilterTypeMessage=You must select at least one event when this filter is applied. If you temporarily do not wish the filter to run at any event, uncheck its enabled state from the Message Filters dialog.
+deleteFilterConfirmation=Are you sure you want to delete the selected filter(s)?
+matchAllFilterName=Match All Messages
+filterListBackUpMsg=Your filters do not work because the msgFilterRules.dat file, which contains your filters, could not be read. A new msgFilterRules.dat file will be created and a backup of the old file, called rulesbackup.dat, will be created in the same directory.
+customHeaderOverflow=You've exceeded the limit of 50 custom headers. Please remove one or more custom headers and try again.
+filterCustomHeaderOverflow=Your filters have exceeded the limit of 50 custom headers. Please edit the msgFilterRules.dat file, which contains your filters, to use fewer custom headers.
+invalidCustomHeader=One of your filters uses a custom header that contains an invalid character, such as ':', a non-printable character, a non-ascii character, or an eight-bit ascii character. Please edit the msgFilterRules.dat file, which contains your filters, to remove invalid characters from your custom headers.
+continueFilterExecution=Applying filter %S failed. Would you like to continue applying filters?
+promptTitle=Running Filters
+promptMsg=You are currently in the process of filtering messages.\nWould you like to continue applying filters?
+stopButtonLabel=Stop
+continueButtonLabel=Continue
+# LOCALIZATION NOTE(cannotEnableIncompatFilter)
+# %S=the name of the application
+cannotEnableIncompatFilter=This filter was probably created by a newer or incompatible version of %S. You cannot enable this filter because we don't know how to apply it.
+dontWarnAboutDeleteCheckbox=Don't ask me again
+# LOCALIZATION NOTE(copyToNewFilterName)
+# %S=the name of the filter that is being copied
+copyToNewFilterName=Copy of %S
+# LOCALIZATION NOTE(contextPeriodic.label): Semi-colon list of plural forms.
+# #1=the number of minutes
+contextPeriodic.label=Periodically, every minute;Periodically, every #1 minutes
+
+# LOCALIZATION NOTE(filterFailureWarningPrefix)
+# %1$S=filter error action
+# %2$S=error code as hexadecimal string.
+filterFailureWarningPrefix=Filter action failed: "%1$S" with error code=%2$S while attempting:
+filterFailureSendingReplyError=Error sending reply
+filterFailureSendingReplyAborted=Sending reply aborted
+filterFailureMoveFailed=Move failed
+filterFailureCopyFailed=Copy failed
+filterFailureAction=Failed applying the filter action
+
+searchTermsInvalidTitle=Search Terms Invalid
+# LOCALIZATION NOTE(searchTermsInvalidRule)
+# %1$S=search attribute name from the invalid rule
+# %2$S=search operator from the bad rule
+searchTermsInvalidRule=This filter cannot be saved because the search term "%1$S %2$S" is invalid in the current context.
+# LOCALIZATION NOTE(filterActionOrderExplanation)
+# Keep the \n\n that mean 2 linebreaks.
+filterActionOrderExplanation=When a message matches this filter the actions will be run in this order:\n\n
+filterActionOrderTitle=Real action order
+## LOCALIZATION NOTE(filterActionItem):
+# %1$S=sequence number of the action, %2$S=action text, %3$S=action argument
+filterActionItem=%1$S. %2$S %3$S\n
+
+## LOCALIZATION NOTE(filterCountVisibleOfTotal):
+# %1$S=number of matching filters, %2$S=total number of filters
+filterCountVisibleOfTotal=%1$S of %2$S
+## LOCALIZATION NOTE(filterCountItems):
+## Semicolon-separated list of singular and plural forms.
+## See: https://developer.mozilla.org/en/docs/Localization_and_Plurals
+## #1 is the count of items in the list.
+filterCountItems=#1 item; #1 items
+# for junk mail logging / mail filter logging
+# LOCALIZATION NOTE(junkLogDetectStr)
+# %1$S=author, %2$S=subject, %3$S=date
+junkLogDetectStr=Detected junk message from %1$S - %2$S at %3$S
+# LOCALIZATION NOTE(logMoveStr)
+# %1$S=message id, %2$S=folder URI
+logMoveStr=moved message id = %1$S to %2$S
+# LOCALIZATION NOTE(logCopyStr)
+# %1$S=message id, %2$S=folder URI
+logCopyStr=copied message id = %1$S to %2$S
+# LOCALIZATION NOTE(filterLogLine):
+# %1$S=timestamp, %2$S=log message
+filterLogLine=[%1$S] %2$S
+# LOCALIZATION NOTE(filterMessage):
+# %1$S=filter name, %1$S=log message
+filterMessage=Message from filter "%1$S": %2$S
+# LOCALIZATION NOTE(filterLogDetectStr)
+# %1$S=filter name %2$S=author, %3$S=subject, %4$S=date
+filterLogDetectStr=Applied filter "%1$S" to message from %2$S - %3$S at %4$S
+filterMissingCustomAction=Missing Custom Action
+filterAction2=priority changed
+filterAction3=deleted
+filterAction4=marked as read
+filterAction5=thread killed
+filterAction6=thread watched
+filterAction7=starred
+filterAction9=replied
+filterAction10=forwarded
+filterAction11=execution stopped
+filterAction12=deleted from POP3 server
+filterAction13=left on POP3 server
+filterAction14=junk score
+filterAction15=body fetched from POP3 server
+filterAction16=copied to folder
+filterAction17=tagged
+filterAction18=ignored subthread
+filterAction19=marked as unread
+# LOCALIZATION NOTE(filterAutoNameStr)
+# %1$S=Header or item to match, e.g. "From", "Tag", "Age in days", etc.
+# %2$S=Operator, e.g. "Contains", "is", "is greater than", etc.
+# %3$S=Value, e.g. "Steve Jobs", "Important", "42", etc.
+filterAutoNameStr=%1$S %2$S: %3$S
diff --git a/comm/mail/locales/en-US/chrome/messenger/folderProps.dtd b/comm/mail/locales/en-US/chrome/messenger/folderProps.dtd
new file mode 100644
index 0000000000..1db6ccd826
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/folderProps.dtd
@@ -0,0 +1,70 @@
+<!-- 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/. -->
+
+<!ENTITY folderProps.windowtitle.label "Properties">
+
+<!ENTITY generalInfo.label "General Information">
+<!ENTITY folderRebuildSummaryFileTip2.label "Rebuild Summary File Index">
+<!ENTITY folderRebuildSummaryFile2.label "Repair Folder">
+<!ENTITY folderRebuildSummaryFile2.accesskey "R">
+<!ENTITY folderRebuildSummaryFile.explanation "Sometimes the folder index (.msf) file becomes damaged and messages may appear missing or deleted messages continue showing; repairing the folder may fix these issues.">
+<!ENTITY folderIncludeInGlobalSearch.label "Include messages in this folder in Global Search results">
+<!ENTITY folderIncludeInGlobalSearch.accesskey "G">
+
+<!ENTITY retention.label "Retention Policy">
+<!ENTITY retentionUseAccount.label "Use my account settings">
+<!ENTITY retentionUseAccount.accesskey "U">
+<!ENTITY daysOld.label "days old">
+<!ENTITY message.label "messages">
+<!ENTITY retentionCleanup.label "To recover disk space, old messages can be permanently deleted.">
+<!ENTITY retentionCleanupImap.label "To recover disk space, old messages can be permanently deleted, both local copies and originals on the remote server.">
+<!ENTITY retentionCleanupPop.label "To recover disk space, old messages can be permanently deleted, including originals on the remote server.">
+<!ENTITY retentionDeleteMsg.label "Delete messages more than">
+<!ENTITY retentionDeleteMsg.accesskey "m">
+<!ENTITY retentionKeepAll.label "Don't delete any messages">
+<!ENTITY retentionKeepAll.accesskey "A">
+<!ENTITY retentionKeepRecent.label "Delete all but the most recent">
+<!ENTITY retentionKeepRecent.accesskey "l">
+<!ENTITY retentionApplyToFlagged.label "Always keep starred messages">
+<!ENTITY retentionApplyToFlagged.accesskey "e">
+
+<!ENTITY folderSynchronizationTab.label "Synchronization">
+<!ENTITY folderCheckForNewMessages2.label "When getting new messages for this account, always check this folder">
+<!ENTITY folderCheckForNewMessages2.accesskey "c">
+
+<!ENTITY offlineFolder.check.label "Select this folder for offline use">
+<!ENTITY offlineFolder.check.accesskey "S">
+<!ENTITY offlineFolder.button.label "Download Now">
+<!ENTITY offlineFolder.button.accesskey "D">
+
+<!ENTITY selectofflineNewsgroup.check.label "Select this newsgroup for offline use">
+<!ENTITY selectofflineNewsgroup.check.accesskey "o">
+<!ENTITY offlineNewsgroup.button.label "Download Now">
+<!ENTITY offlineNewsgroup.button.accesskey "D">
+
+<!ENTITY folderProps.name.label "Name:">
+<!ENTITY folderProps.name.accesskey "N">
+<!ENTITY folderProps.color.label "Icon Color:">
+<!ENTITY folderProps.color.accesskey "I">
+<!ENTITY folderProps.reset.tooltip "Restore default color">
+<!ENTITY folderProps.location.label "Location:">
+<!ENTITY folderProps.location.accesskey "L">
+
+<!ENTITY folderSharingTab.label "Sharing">
+<!ENTITY privileges.button.label "Privileges…">
+<!ENTITY privileges.button.accesskey "P">
+<!ENTITY permissionsDesc.label "You have the following permissions:">
+<!ENTITY folderOtherUsers.label "Others with access to this folder:">
+<!ENTITY folderType.label "Folder Type:">
+
+<!ENTITY folderQuotaTab.label "Quota">
+<!ENTITY folderQuotaUsage.label "Usage:">
+<!ENTITY folderQuotaStatus.label "Status:">
+
+<!ENTITY numberOfMessages.label "Number of messages:">
+<!-- LOCALIZATION NOTE: When the number of messages can't be determined, this string is displayed as the number -->
+<!ENTITY numberUnknown.label "unknown">
+<!ENTITY sizeOnDisk.label "Size on disk:">
+<!-- LOCALIZATION NOTE: When the size can't be determined, this string is displayed as the size -->
+<!ENTITY sizeUnknown.label "unknown">
diff --git a/comm/mail/locales/en-US/chrome/messenger/folderWidgets.properties b/comm/mail/locales/en-US/chrome/messenger/folderWidgets.properties
new file mode 100644
index 0000000000..82465951dd
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/folderWidgets.properties
@@ -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/.
+
+# LOCALIZATION NOTE(globalInbox)
+# %S=name of the Local folders account
+globalInbox=Global Inbox (%S)
+# LOCALIZATION NOTE(verboseFolderFormat): %1$S is folder name, %2$S is server name
+verboseFolderFormat=%1$S on %2$S
+chooseFolder=Choose Folder…
+chooseAccount=Choose Account…
+noFolders=No available folders
diff --git a/comm/mail/locales/en-US/chrome/messenger/folderpane.dtd b/comm/mail/locales/en-US/chrome/messenger/folderpane.dtd
new file mode 100644
index 0000000000..047c50079d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/folderpane.dtd
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+<!ENTITY nameColumn.label "Name">
+<!ENTITY unreadColumn.label "Unread">
+<!ENTITY totalColumn.label "Total">
+<!ENTITY folderSizeColumn.label "Size">
diff --git a/comm/mail/locales/en-US/chrome/messenger/gloda.properties b/comm/mail/locales/en-US/chrome/messenger/gloda.properties
new file mode 100644
index 0000000000..2be495a9bd
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/gloda.properties
@@ -0,0 +1,175 @@
+# 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/.
+
+# LOCALIZATION NOTE (*.facetNameLabel): These are the labels used to label the facet
+# displays in the global search facet display mechanism. They should be
+# compact descriptions of the facet type (e.g. "Folder", and don't need to
+# refer to the objects being faceted (e.g. "Message" or "Mail").
+
+# LOCALIZATION NOTE (*.includeLabel): The label to use for the included group
+# in the facet display. If not provided, we will fall back to
+# "glodaFacetView.facets.included.fallbackLabel".
+
+# LOCALIZATION NOTE (*.excludeLabel): The label to use for the excluded group
+# in the facet display. If not provided, we will fall back to
+# "glodaFacetView.facets.excluded.fallbackLabel".
+
+# LOCALIZATION NOTE (*.remainderLabel): The label to use for the remaining items
+# that are neither part of the included group or the excluded group in the
+# facet display. If not provided, we will fall back to
+# "glodaFacetView.facets.remainder.fallbackLabel".
+
+# LOCALIZATION NOTE (*.mustMatchLabel): The label to use for the popup menu
+# to indicate that the results should be restricted to messages which match
+# a particular value. If not provided, we will fall back to
+# "glodaFacetView.facets.mustMatch.fallbackLabel". #1, if present, is
+# replaced by the value of the facet (name, folder, mailing list, etc.)
+
+# LOCALIZATION NOTE (*.cantMatchLabel): The label to use for the popup menu
+# to indicate that the results should be restricted to messages which match
+# a particular value. If not provided, we will fall back to
+# "glodaFacetView.facets.cantMatch.fallbackLabel". #1, if present, is
+# replaced by the value of the facet (name, folder, mailing list, etc.)
+
+# LOCALIZATION NOTE (*.mayMatchLabel): The label to use for the popup menu
+# to indicate that the results should no longer be restricted relative to
+# this particular facet value. If not provided, we will fall back to
+# "glodaFacetView.facets.mayMatch.fallbackLabel". #1, if present, is
+# replaced by the value of the facet (name, folder, mailing list, etc.)
+
+# LOCALIZATION NOTE (*.mustMatchSomeLabel): The label to use for the popup menu
+# to indicate that the results should be restricted to messages which have
+# _some_ value (e.g. at least one tag is set). If not provided, we will fall
+# back to "glodaFacetView.facets.mustMatchSome.fallbackLabel". #1, if present,
+# is replaced by the value of the facet (name, folder, mailing list, etc.)
+
+# LOCALIZATION NOTE (*.mustMatchNoneLabel): The label to use for the popup menu
+# to indicate that the results should be restricted to messages which have _no_
+# value (e.g. no tags are set). If not provided, we will fall back to
+# "glodaFacetView.facets.mustMatchNoneLabel.fallbackLabel". #1, if present, is
+# replaced by the value of the facet (name, folder, mailing list, etc.)
+
+# LOCALIZATION NOTE (*.mayMatchAnyLabel): The label to use for the popup menu
+# to indicate that the results should not be restricted to messages which have
+# any or no value (e.g. no requirements on any tags are set). If not provided,
+# we will fall back to "glodaFacetView.facets.mayMatchAnyLabel.fallbackLabel".
+# #1, if present, is replaced by the value of the facet (name, folder, mailing
+# list, etc.)
+
+# LOCALIZATION NOTE (gloda.message.attr.account.*): Stores the account in which
+# a message's folder is located.
+gloda.message.attr.account.facetNameLabel=Account
+gloda.message.attr.account.includeLabel=stored in any of:
+gloda.message.attr.account.excludeLabel=not stored in:
+gloda.message.attr.account.remainderLabel=other accounts:
+gloda.message.attr.account.mustMatchLabel=must be in #1
+gloda.message.attr.account.cantMatchLabel=can't be in #1
+
+# LOCALIZATION NOTE (gloda.message.attr.folder.*): Stores the message folder in
+# which the message is stored.
+gloda.message.attr.folder.facetNameLabel=Folder
+gloda.message.attr.folder.includeLabel=stored in any of:
+gloda.message.attr.folder.excludeLabel=not stored in:
+gloda.message.attr.folder.remainderLabel=other folders:
+gloda.message.attr.folder.mustMatchLabel=must be stored in #1
+gloda.message.attr.folder.cantMatchLabel=can't be stored in #1
+
+# LOCALIZATION NOTE (gloda.message.attr.fromMe.*): Stores everyone involved
+# with the message. This means from/to/cc/bcc.
+gloda.message.attr.fromMe.facetNameLabel=From Me
+
+# LOCALIZATION NOTE (gloda.message.attr.toMe.*): Stores everyone involved
+# with the message. This means from/to/cc/bcc.
+gloda.message.attr.toMe.facetNameLabel=To Me
+
+# LOCALIZATION NOTE (gloda.message.attr.involves.*): Stores everyone involved
+# with the message. This means from/to/cc/bcc.
+gloda.message.attr.involves.facetNameLabel=People
+gloda.message.attr.involves.includeLabel=involving any of:
+gloda.message.attr.involves.excludeLabel=not involving:
+gloda.message.attr.involves.remainderLabel=other participants:
+gloda.message.attr.involves.mustMatchLabel=must involve #1
+gloda.message.attr.involves.cantMatchLabel=can't involve #1
+
+# LOCALIZATION NOTE (gloda.message.attr.date.*): Stores the date of the message.
+# Thunderbird normally stores the date the message claims it was composed
+# according to the "Date" header. This is not the same as when the message
+# was sent or when it was eventually received by the user. In the future we
+# may change this to be one of the other dates, but not anytime soon.
+gloda.message.attr.date.facetNameLabel=Date
+
+# LOCALIZATION NOTE (gloda.message.attr.attachmentTypes.*): Stores the list of
+# MIME types (ex: image/png, text/plain) of real attachments (not just part of
+# the message content but explicitly named attachments) on the message.
+# Although we hope to be able to provide localized human-readable explanations
+# of the MIME type (ex: "PowerPoint document"), I don't know if that is going
+# to happen.
+gloda.message.attr.attachmentTypes.facetNameLabel=Attachments
+
+# LOCALIZATION NOTE (gloda.message.attr.mailing-list.*): Stores the mailing
+# lists detected in the message. This will normally be the email address of
+# the mailing list and only be detected in messages received from the mailing
+# list. Extensions may contribute additional detected mailing-list-like
+# things.
+gloda.message.attr.mailing-list.facetNameLabel=Mailing List
+gloda.message.attr.mailing-list.noneLabel=None
+gloda.message.attr.mailing-list.includeLabel=received on any of:
+gloda.message.attr.mailing-list.excludeLabel=not received on any of:
+gloda.message.attr.mailing-list.remainderLabel=other mailing lists:
+gloda.message.attr.mailing-list.mustMatchLabel=must be on #1
+gloda.message.attr.mailing-list.cantMatchLabel=can't be on #1
+gloda.message.attr.mailing-list.mustMatchSomeLabel=must be on a mailing list
+gloda.message.attr.mailing-list.mustMatchNoneLabel=can't be on a mailing list
+
+# LOCALIZATION NOTE (gloda.message.attr.tag.*): Stores the tags applied to the
+# message. Notably, gmail's labels are not currently exposed via IMAP and we
+# do not do anything clever with gmail, so this is independent of gmail labels
+# This may change in the future, but it's a safe bet it's not happening on
+# Thunderbird's side prior to 3.0.
+gloda.message.attr.tag.facetNameLabel=Tags
+gloda.message.attr.tag.noneLabel=None
+gloda.message.attr.tag.includeLabel=tagged any of:
+gloda.message.attr.tag.excludeLabel=not tagged:
+gloda.message.attr.tag.remainderLabel=other tags:
+gloda.message.attr.tag.mustMatchLabel=must be tagged #1
+gloda.message.attr.tag.cantMatchLabel=can't be tagged #1
+gloda.message.attr.tag.mustMatchSomeLabel=must be tagged
+gloda.message.attr.tag.mustMatchNoneLabel=can't be tagged
+
+# LOCALIZATION NOTE (gloda.message.attr.star.*): Stores whether the message is
+# starred or not, as indicated by a pretty star icon. In the past, the icon
+# used to be a flag. The IMAP terminology continues to be "flagged".
+gloda.message.attr.star.facetNameLabel=Starred
+
+# LOCALIZATION NOTE (gloda.message.attr.read.*): Stores whether the user has
+# read the message or not.
+gloda.message.attr.read.facetNameLabel=Read
+
+# LOCALIZATION NOTE (gloda.message.attr.repliedTo.*): Stores whether we believe
+# the user has ever replied to the message. We normally show a little icon in
+# the thread pane when this is the case.
+gloda.message.attr.repliedTo.facetNameLabel=Replied To
+
+# LOCALIZATION NOTE (gloda.message.attr.forwarded.*): Stores whether we believe
+# the user has ever forwarded the message. We normally show a little icon in
+# the thread pane when this is the case.
+gloda.message.attr.forwarded.facetNameLabel=Forwarded
+
+# LOCALIZATION NOTE (gloda.mimetype.category.*.label): Map categories of MIME
+# types defined in MimeTypeCategories to labels.
+# LOCALIZATION NOTE (gloda.mimetype.category.archives.label): Archive is
+# referring to things like zip files, tar files, tar.gz files, etc.
+gloda.mimetype.category.archives.label=Archives
+gloda.mimetype.category.documents.label=Documents
+gloda.mimetype.category.images.label=Images
+# LOCALIZATION NOTE (gloda.mimetype.category.media.label): Media is meant to
+# encompass both audio and video. This is because video and audio streams are
+# frequently stored in the same type of container and we cannot rely on the
+# sending email client to have been clever enough to figure out what was
+# really in the file. So we group them together.
+gloda.mimetype.category.media.label=Media (Audio, Video)
+gloda.mimetype.category.pdf.label=PDF Files
+# LOCALIZATION NOTE (gloda.mimetype.category.other.label): Other is the category
+# for MIME types that we don't really know what it is.
+gloda.mimetype.category.other.label=Other
diff --git a/comm/mail/locales/en-US/chrome/messenger/glodaComplete.properties b/comm/mail/locales/en-US/chrome/messenger/glodaComplete.properties
new file mode 100644
index 0000000000..fec860fec6
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/glodaComplete.properties
@@ -0,0 +1,19 @@
+# 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/.
+
+# LOCALIZATION NOTE (glodaComplete.messagesTagged.label): The label used
+# in the autocomplete widget to refer to a query for all messages tagged
+# by a particular tag (replacing #1).
+glodaComplete.messagesTagged.label=Messages tagged: #1
+
+# LOCALIZATION NOTE (glodaComplete.messagesMentioning.label): The label used
+# in the autocomplete widget to refer to a search for all messages mentioning
+# a particular word (replacing #1).
+glodaComplete.messagesMentioning.label=Messages mentioning: #1
+
+# LOCALIZATION NOTE (glodaComplete.messagesWithMany.label): The label used
+# in the autocomplete widget to refer to a search for all messages mentioning
+# a set of words, or a phrase containing multiple words (e.g. "red pepper")
+# We use the same words in en-US, but maybe that's not always true.
+glodaComplete.messagesMentioningMany.label=Messages mentioning: #1
diff --git a/comm/mail/locales/en-US/chrome/messenger/glodaFacetView.dtd b/comm/mail/locales/en-US/chrome/messenger/glodaFacetView.dtd
new file mode 100644
index 0000000000..af3ff4a8b7
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/glodaFacetView.dtd
@@ -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/. -->
+
+<!-- LOCALIZATION NOTE (glodaFacetView.filters.label): Label at the top of the
+ faceting sidebar. Serves as a header both for the checkboxes under it as
+ well for labeled facets with multiple options. -->
+<!ENTITY glodaFacetView.filters.label "Filters">
+
+<!-- LOCALIZATION NOTE (glodaFacetView.loading.label): Label that appears when
+ the search results take a long time to appear. -->
+<!ENTITY glodaFacetView.loading.label "Searching&#8230;">
+
+<!-- LOCALIZATION NOTE (glodaFacetView.empty.label): Label that appears when
+ there are no results that match the search query. -->
+<!ENTITY glodaFacetView.empty.label "No messages match your search">
+
+<!-- LOCALIZATION NOTE (glodaFacetView.pageMore.label): Label at the bottom
+ of the results list to show more hits. -->
+<!ENTITY glodaFacetView.pageMore.label "More &#187;">
+
+<!-- LOCALIZATION NOTE(glodaFacetView.results.message.openEmailAsList.label2): The
+ label for the button/link that causes us to display all of the emails in
+ the active set in a new thread pane display tab. -->
+<!ENTITY glodaFacetView.openEmailAsList.label "Show results as list">
+
+<!-- LOCALIZATION NOTE(glodaFacetView.results.message.openEmailAsList.tooltip):
+ The tooltip to display when hovering over the openEmailAsList label. -->
+<!ENTITY glodaFacetView.openEmailAsList.tooltip "Show all of the email messages in the active set in a new tab">
diff --git a/comm/mail/locales/en-US/chrome/messenger/glodaFacetView.properties b/comm/mail/locales/en-US/chrome/messenger/glodaFacetView.properties
new file mode 100644
index 0000000000..c734a05adb
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/glodaFacetView.properties
@@ -0,0 +1,171 @@
+# 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/.
+
+# LOCALIZATION NOTE (glodaFacetView.tab.query.label):
+# The tab title to display for tabs that are based on a gloda (global database)
+# query or collection rather than a user search. At some point we might try
+# and explain what the query/collection is in automatic fashion, but not today.
+glodaFacetView.tab.query.label=Search
+
+# LOCALIZATION NOTE (glodaFacetView.tab.search.label):
+# The tab title to display for tabs with a new gloda (global database)
+# user search (rather than a query or collection) without a search string.
+# After the search has been started, we just display the search string entered
+# by the user.
+glodaFacetView.tab.search.label=Search
+
+# LOCALIZATION NOTE(glodaFacetView.search.label2):
+# The heading for the search page.
+# A short description of user's search query will be appended.
+glodaFacetView.search.label2=Results for:
+
+# LOCALIZATION NOTE(glodaFacetView.constraints.query.fulltext.label):
+# The label to display to describe when our base query was a fulltext search
+# across messages. The value is displayed following the label.
+glodaFacetView.constraints.query.fulltext.label=Searching for #1
+glodaFacetView.constraints.query.fulltext.andJoinWord=and
+glodaFacetView.constraints.query.fulltext.orJoinWord=or
+
+# LOCALIZATION NOTE(glodaFacetView.constraints.query.initial):
+# The label to display to describe when our base query is not a full-text
+# search. Additional labels are appended describing each constraint.
+glodaFacetView.constraints.query.initial=Searching for messages
+
+# LOCALIZATION NOTE(glodaFacetView.constraints.query.involves.label):
+# The label to display to describe when our base query was on messages
+# involving a given contact from the address book. The value is displayed
+# where the #1 is.
+glodaFacetView.constraints.query.involves.label=involving #1
+
+# LOCALIZATION NOTE(glodaFacetView.constraints.query.contact.label):
+# The label to display to describe when our base query was on messages
+# tagged with a specific tag. The tag is displayed following the label.
+glodaFacetView.constraints.query.tagged.label=tagged:
+
+
+# LOCALIZATION NOTE (glodaFacetView.facets.mode.top.listAllLabel): The label to
+# use when we are only displaying the top entries for a facet. When the
+# label is clicked on, it results in us displaying all of the values for that
+# facet. The value "#1" (if present) is replaced with the total number of
+# values that will be displayed (rather than the number currently hidden).
+# This string supports pluralization. See
+# https://developer.mozilla.org/en/Localization_and_Plurals for details on
+# how this stuff works.
+glodaFacetView.facets.mode.top.listAllLabel=List all #1;List all #1
+
+# LOCALIZATION NOTE (glodaFacetView.facets.included.fallbackLabel): The label to
+# use for groups in a facet that have been explicitly included by the user if
+# there is no explicit attribute "includeLabel" defined. (The explicit label
+# would be named "gloda.message.attr.ATTRIBUTE.includeLabel".)
+glodaFacetView.facets.included.fallbackLabel=including any of:
+# LOCALIZATION NOTE (glodaFacetView.facets.excluded.fallbackLabel): The label to
+# use for groups in a facet that have been explicitly excluded by the user if
+# there is no explicit attribute "excludeLabel" defined. (The explicit label
+# would be named "gloda.message.attr.ATTRIBUTE.excludeLabel".)
+glodaFacetView.facets.excluded.fallbackLabel=excluding:
+# LOCALIZATION NOTE (glodaFacetView.facets.remainder.fallbackLabel): The label
+# to use for groups in a facet that are neither part of the included group or
+# the excluded group if there is no explicit attribute "remainderLabel"
+# defined. (The explicit label would be named
+# "gloda.message.attr.ATTRIBUTE.remainderLabel".)
+glodaFacetView.facets.remainder.fallbackLabel=other:
+
+# LOCALIZATION NOTE (glodaFacetView.facets.mustMatchLabel.fallbackLabel): The label
+# to use to restrict a facet by a particular value if there is no explicit
+# attribute "mustMatchLabel" defined. (The explicit label would be named
+# "gloda.message.attr.ATTRIBUTE.mustMatchLabel".)
+glodaFacetView.facets.mustMatchLabel.fallbackLabel=must match #1
+glodaFacetView.facets.mustMatchNoneLabel.fallbackLabel=can't have a value
+
+# LOCALIZATION NOTE (glodaFacetView.facets.cantMatchLabel.fallbackLabel): The label
+# to use to restrict a facet by the absence of a particular value if there is
+# no explicit attribute "cantMatchLabel" defined. (The explicit label would be
+# named "gloda.message.attr.ATTRIBUTE.cantMatchLabel".)
+glodaFacetView.facets.cantMatchLabel.fallbackLabel=can't match #1
+glodaFacetView.facets.mustMatchSomeLabel.fallbackLabel=must have a value
+
+# LOCALIZATION NOTE (glodaFacetView.facets.mayMatchLabel.fallbackLabel): The label
+# to use to undo the restriction of a facet by a particular value if there is
+# no explicit attribute "mayMatchLabel" defined. (The explicit label would be
+# named "gloda.message.attr.ATTRIBUTE.mayMatchLabel".)
+glodaFacetView.facets.mayMatchLabel.fallbackLabel=remove constraint
+glodaFacetView.facets.mayMatchAnyLabel.fallbackLabel=remove constraint
+
+# LOCALIZATION NOTE (glodaFacetView.facets.noneLabel): The text to display when
+# a facet needs to indicate that an attribute omitted a value or was otherwise
+# empty.
+glodaFacetView.facets.noneLabel=None
+
+# LOCALIZATION NOTE (glodaFacetView.facets.filter.attachmentTypes.allLabel):
+# The label to use when all types of attachments are being displayed.
+glodaFacetView.facets.filter.attachmentTypes.allLabel=Any Kind
+
+# LOCALIZATION NOTE (glodaFacetView.result.message.fromLabel): Used in the
+# faceted search message display to indicate the author of a message.
+# An example usage is "from: Bob".
+glodaFacetView.result.message.fromLabel=from:
+
+# LOCALIZATION NOTE (glodaFacetView.result.message.toLabel): Used in the
+# faceted search message display to indicate the recipients of a message.
+# An example usage is "to: Bob, Chuck, Don".
+glodaFacetView.result.message.toLabel=to:
+
+# LOCALIZATION NOTE (glodaFacetView.result.message.noSubject): Used in the
+# faceted search message display to act as a click target for messages with
+# no subject.
+glodaFacetView.result.message.noSubject=(no subject)
+
+# LOCALIZATION NOTE(glodaFacetView.results.header.countLabel):
+# This label is displayed above the list of result messages; it tells the user
+# how many messages we are displaying in the list out of the total number of
+# messages in the active set (the set of messages remaining after the
+# application of the facet constraints.)
+# The goal of the various sub-parts here is to make a label along the lines of
+# "M of N". Because there are two numbers, this is split into two parts,
+# 'NMessages' for what in English is just the first number and 'ofN' for the
+# "of N" part. We then use 'grouping' to decide how to combine the two. This
+# was suggested by Rimas Kudelis.
+# LOCALIZATION NOTE(glodaFacetView.results.header.countLabel.NMessages):
+# The first part of the countLabel string (although you can change the order
+# in 'grouping'). This is pluralized using the mechanism described at
+# https://developer.mozilla.org/en/Localization_and_Plurals. We replace
+# "#1" with the number of messages being shown in the result list.
+glodaFacetView.results.header.countLabel.NMessages=#1;#1
+# LOCALIZATION NOTE(glodaFacetView.results.header.countLabel.ofN):
+# The second part of the countLabel string (although you can change the order
+# in 'grouping'). This is pluralized using the mechanism described at
+# https://developer.mozilla.org/en/Localization_and_Plurals. We replace
+# "#1" with the total number of messagse in the active set.
+glodaFacetView.results.header.countLabel.ofN=of #1;of #1
+# LOCALIZATION NOTE(glodaFacetView.results.header.countLabel.grouping):
+# Combines the pluralized
+# "glodaFacetView.results.header.countLabel.NMessages" string (as #1) with
+# the pluralized "glodaFacetView.results.header.countLabel.ofN" (as #2)
+# to make a single label.
+glodaFacetView.results.header.countLabel.grouping=#1 #2
+
+glodaFacetView.results.message.timeline.label=Toggle timeline
+# LOCALIZATION NOTE(glodaFacetView.results.message.sort.relevance2):
+# a clickable label causing the sort to be done by most relevant messages first.
+glodaFacetView.results.message.sort.relevance2=Sort by Relevance
+# LOCALIZATION NOTE(glodaFacetView.results.message.sort.date2):
+# a clickable label causing the sort to be done by most recent messages first.
+glodaFacetView.results.message.sort.date2=Sort by Date
+
+# LOCALIZATION NOTE(glodaFacetView.results.message.recipientSeparator): This is
+# the string in between the names of recipients (see
+# glodaFacetView.results.message.andOthers for more information). The \u0020
+# character is a Unicode space character, which is needed as otherwise the
+# trailing whitespace is trimmed before it gets to the code.
+glodaFacetView.results.message.recipientSeparator=,\u0020
+
+# LOCALIZATION NOTE(glodaFacetView.results.message.andOthers):
+# When a message has too many recipients, we only show the first few and then
+# display this label to express how many are not displayed. So if a message
+# has 5 recipients, we might only show the first 3, and then use this label
+# to indicate that there are 2 that are not displayed. This string can be
+# pluralized; see https://developer.mozilla.org/en/Localization_and_Plurals
+# for details on how to do that. Note that in English, we use the "serial
+# comma", but other languages may not need a leading separator there.
+glodaFacetView.results.message.andOthers=, and #1 other;, and #1 others
diff --git a/comm/mail/locales/en-US/chrome/messenger/imAccountWizard.dtd b/comm/mail/locales/en-US/chrome/messenger/imAccountWizard.dtd
new file mode 100644
index 0000000000..e97075d70a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/imAccountWizard.dtd
@@ -0,0 +1,32 @@
+<!-- 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/. -->
+
+<!ENTITY windowTitle.label "Chat Account Wizard">
+
+<!ENTITY accountProtocolTitle.label "Chat network">
+<!ENTITY accountProtocolInfo.label "Please choose the network of your chat account.">
+<!ENTITY accountProtocolField.label "Network:">
+<!ENTITY accountProtocolGetMore.label "Get more…">
+
+<!ENTITY accountUsernameTitle.label "Username">
+<!ENTITY accountUsernameDuplicate.label "This account is already configured!">
+
+<!ENTITY accountPasswordTitle.label "Password">
+<!ENTITY accountPasswordInfo.label "Please enter your password in the box below.">
+<!ENTITY accountPasswordField.label "Password:">
+<!ENTITY accountPasswordManager.label "The password entered here will be stored in the Password Manager. Leave this box empty if you want to be prompted for your password each time this account is connected.">
+
+<!ENTITY accountAdvancedTitle.label "Advanced Options">
+<!ENTITY accountAdvancedInfo.label "Feel free to skip this step if you want to.">
+<!ENTITY accountAdvanced.newMailNotification.label "Notify on new Mail">
+<!ENTITY accountAliasGroupbox.caption "Local Alias">
+<!ENTITY accountAliasField.label "Alias:">
+<!ENTITY accountAliasInfo.label "This will only be displayed in your conversations when you talk, remote contacts won't see it.">
+<!ENTITY accountProxySettings.caption "Proxy Settings">
+<!ENTITY accountProxySettings.change.label "Change…">
+<!ENTITY accountProxySettings.change.accessKey "C">
+
+<!ENTITY accountSummaryTitle.label "Summary">
+<!ENTITY accountSummaryInfo.label "A summary of the information you entered is displayed below. Please check it before the account is created.">
+<!ENTITY accountSummary.connectNow.label "Connect this account now.">
diff --git a/comm/mail/locales/en-US/chrome/messenger/imAccounts.properties b/comm/mail/locales/en-US/chrome/messenger/imAccounts.properties
new file mode 100644
index 0000000000..b99a040ab2
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/imAccounts.properties
@@ -0,0 +1,63 @@
+# 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/.
+
+# LOCALIZATION NOTE (protoOptions):
+# %S is replaced by the name of a protocol
+protoOptions=%S Options
+accountUsername=Username:
+# LOCALIZATION NOTE (accountColon):
+# This string is used to append a colon after the label of each
+# option. It's localizable so that the typography can be adapted.
+accountColon=%S:
+# LOCALIZATION NOTE (accountUsernameInfo):
+# %S is replaced by the name of a protocol
+accountUsernameInfo=Please enter the username for your %S account.
+# LOCALIZATION NOTE (accountUsernameInfoWithDescription):
+# %1$S is a hint for the expected format of the username
+# %2$S is the name of a protocol
+accountUsernameInfoWithDescription=Please enter the username (%1$S) for your %2$S account.
+
+# LOCALIZATION NOTE (account.connection.error):
+# %S is the error message.
+account.connection.error=Error: %S
+# LOCALIZATION NOTE (account.connection.errorUnknownPrpl)
+# %S is the id (not very user friendly; hence the quotes) of the missing plugin.
+account.connection.errorUnknownPrpl=No '%S' protocol plugin.
+account.connection.errorEnteringPasswordRequired=Entering a password is required to connect this account.
+account.connection.errorCrashedAccount=A crash occurred while connecting this account.
+# LOCALIZATION NOTE (account.connection.progress):
+# %S is a message indicating progress of the connection process
+account.connection.progress=Connecting: %S…
+account.connecting=Connecting…
+account.connectedForSeconds=Connected for a few seconds.
+# LOCALIZATION NOTE (account.connectedFor{Double,Single},
+# account.reconnectIn{Double,Single}):
+# Each pair of %S is a number followed by a unit. The units are
+# already localized in a downloads.properties file of the toolkit.
+account.connectedForDouble=Connected for %1$S %2$S and %3$S %4$S.
+account.connectedForSingle=Connected for about %1$S %2$S.
+account.reconnectInDouble=Reconnection in %1$S %2$S and %3$S %4$S.
+account.reconnectInSingle=Reconnection in %1$S %2$S.
+
+requestAuthorizeTitle=Authorization request
+# LOCALIZATION NOTE (requestAuthorizeAllow, requestAuthorizeDeny):
+# the & symbol indicates the position of the character that should be
+# used as the accesskey for this button.
+requestAuthorizeAllow=&Allow
+requestAuthorizeDeny=&Deny
+# LOCALIZATION NOTE (requestAuthorizeText):
+# %S is a contact username.
+requestAuthorizeText=%S added you to his/her buddy list, do you want to allow him/her to see you?
+
+accountsManager.notification.button.accessKey=C
+accountsManager.notification.button.label=Connect Now
+accountsManager.notification.userDisabled.label=You have disabled automatic connections.
+accountsManager.notification.safeMode.label=Automatic Connection Settings have been ignored because the application is currently running in Safe-Mode.
+accountsManager.notification.startOffline.label=Automatic Connection Settings have been ignored because the application was started in Offline Mode.
+accountsManager.notification.crash.label=The last run exited unexpectedly while connecting. Automatic Connections have been disabled to give you an opportunity to Edit your Settings.
+# LOCALIZATION NOTE (accountsManager.notification.singleCrash.label): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 is the number of accounts that are suspected to have caused a crash.
+accountsManager.notification.singleCrash.label=A previous run exited unexpectedly while connecting a new or edited account. It has not been connected so that you can Edit its Settings.;A previous run exited unexpectedly while connecting #1 new or edited accounts. They have not been connected so that you can Edit their Settings.
+accountsManager.notification.other.label=Automatic connection has been disabled.
diff --git a/comm/mail/locales/en-US/chrome/messenger/imapMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/imapMsgs.properties
new file mode 100644
index 0000000000..c3a18a7d2b
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/imapMsgs.properties
@@ -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/.
+
+#
+# The following are used by the imap code to display progress/status/error messages
+#
+
+#LOCALIZATION NOTE(imapAlertDialogTile): Do not translate the word "%S"
+# below. Place the word %S where the account name should appear.
+imapAlertDialogTitle=Alert for account %S
+
+# Status - opening folder
+imapStatusSelectingMailbox=Opening folder %S…
+
+# Status - create folder
+imapStatusCreatingMailbox=Creating folder…
+
+# Status - deleting a folder
+# LOCALIZATION NOTE (imapStatusDeletingMailbox): The "%S" below should not be translated.
+# Instead, insert "%S" in your translation where you wish to display the name
+# of the folder being deleted.
+imapStatusDeletingMailbox=Deleting folder %S…
+
+# Status - renaming mailbox
+# LOCALIZATION NOTE (imapStatusRenamingMailbox): The "%S" below should not be translated.
+# Instead, insert "%S" in your translation where you wish to display the name
+# of the folder being renamed.
+imapStatusRenamingMailbox=Renaming folder %S…
+
+# Status - looking for mailboxes
+imapStatusLookingForMailbox=Looking for folders…
+
+# Status - subscribing to mailbox
+# LOCALIZATION NOTE (imapStatusSubscribeToMailbox): The "%S" below should not be translated.
+# Instead, insert "%S" in your translation where you wish to display the name
+# of the folder being subscribed to.
+imapStatusSubscribeToMailbox=Subscribing to folder %S…
+
+# Status - unsubscribing from mailbox
+# LOCALIZATION NOTE (imapStatusUnsubscribeMailbox): The "%S" below should not be translated.
+# Instead, insert "%S" in your translation where you wish to display the name
+# of the folder being unsubscribed from.
+imapStatusUnsubscribeMailbox=Unsubscribing from folder %S…
+
+# Status - searching imap folder
+imapStatusSearchMailbox=Searching folder…
+
+# Status - closing a folder
+imapStatusCloseMailbox=Closing folder…
+
+# Status - compacting a folder
+imapStatusExpungingMailbox=Compacting folder…
+
+# Status - logging out
+imapStatusLoggingOut=Logging out…
+
+# Status - checking server capabilities
+imapStatusCheckCompat=Checking mail server capabilities…
+
+# Status - logging on
+imapStatusSendingLogin=Sending login information…
+
+# Status - auth logon
+imapStatusSendingAuthLogin=Sending login information…
+
+imapDownloadingMessage=Downloading message…
+
+# LOCALIZATION NOTE (imapGettingACLForFolder): Do not translate the word "ACL" below.
+imapGettingACLForFolder=Getting folder ACL…
+
+imapGettingServerInfo=Getting Server Configuration Info…
+
+imapGettingMailboxInfo=Getting Mailbox Configuration Info…
+
+imapEmptyMimePart=This body part will be downloaded on demand.
+
+# LOCALIZATION NOTE (imapReceivingMessageHeaders3): Do not translate the words "%1$S", "%2$S", and "%3$S" below.
+# Place the word %1$S in your translation where the number of the header being downloaded should appear.
+# Place the word %2$S in your translation where the total number of headers to be downloaded should appear.
+# Place the word %3$S in your translation where the name of the folder being processed should appear.
+# Note: The account name and separators (e.g. colon, space) are automatically added to the status message.
+# Example: "Joe's Account: Downloading message header 100 of 1000 in Drafts…"
+imapReceivingMessageHeaders3=Downloading message header %1$S of %2$S in %3$S…
+
+# LOCALIZATION NOTE (imapReceivingMessageFlags3): Do not translate the words "%1$S", "%2$S", and "%3$S" below.
+# Place the word %1$S in your translation where the number of the flag being downloaded should appear.
+# Place the word %2$S in your translation where the total number of flags to be downloaded should appear.
+# Place the word %3$S in your translation where the name of the folder being processed should appear.
+# Note: The account name and separators (e.g. colon, space) are automatically added to the status message.
+# Example: "Jim's Account: Downloading message flag 100 of 1000 in INBOX…"
+imapReceivingMessageFlags3=Downloading message flag %1$S of %2$S in %3$S…
+
+imapDeletingMessages=Deleting messages…
+
+imapDeletingMessage=Deleting message…
+
+# LOCALIZATION NOTE (imapMovingMessages): Do not translate the word "%S" below.
+# Place the word %S in your translation where the name of the folder should appear.
+imapMovingMessages=Moving messages to %S…
+
+# LOCALIZATION NOTE (imapMovingMessage): Do not translate the word "%S" below.
+# Place the word %S in your translation where the name of the folder should appear.
+imapMovingMessage=Moving message to %S…
+
+# LOCALIZATION NOTE (imapCopyingMessages): Do not translate the word "%S" below.
+# Place the word %S in your translation where the name of the folder should appear.
+imapCopyingMessages=Copying messages to %S…
+
+# LOCALIZATION NOTE (imapCopyingMessage): Do not translate the word "%S" below.
+# Place the word %S in your translation where the name of the folder should appear.
+imapCopyingMessage=Copying message to %S…
+
+# LOCALIZATION NOTE (imapFolderReceivingMessageOf3): Do not translate the words "%1$S", "%2$S", and "%3$S" below.
+# Place the word %1$S in your translation where the number of the message being downloaded should appear.
+# Place the word %2$S in your translation where the total number of messages to be downloaded should appear.
+# Place the word %3$S in your translation where the name of the folder being processed should appear.
+# Note: The account name and separators (e.g. colon, space) are automatically added to the status message.
+# Example: "Juan's Account: Downloading message 100 of 1000 in Sent…"
+imapFolderReceivingMessageOf3=Downloading message %1$S of %2$S in %3$S…
+
+# LOCALIZATION NOTE (imapDiscoveringMailbox): Do not translate the word "%S" below.
+# Place the word %S in your translation where the name of the folder should appear.
+imapDiscoveringMailbox=Found folder: %S
+
+# LOCALIZATION NOTE (imapEnterServerPasswordPrompt): Do not translate the words %1$S and %2$S below.
+# Place the word %1$S in your translation where the username should appear.
+# Place the word %2$S in your translation where the servername should appear.
+imapEnterServerPasswordPrompt=Enter your password for %1$S on %2$S:
+
+# LOCALIZATION NOTE (imapServerNotImap4): Do not translate the word "IMAP4" below.
+imapServerNotImap4=Mail server %S is not an IMAP4 mail server.
+
+# This is intentionally left blank.
+imapDone=
+
+# LOCALIZATION NOTE (imapEnterPasswordPromptTitleWithUsername): Do not translate the
+# word %1$S. Place the word %1$S where the user name should appear.
+imapEnterPasswordPromptTitleWithUsername=Enter your password for %1$S
+
+imapUnknownHostError=Failed to connect to server %S.
+imapOAuth2Error=Authentication failure while connecting to server %S.
+
+imapConnectionRefusedError=Could not connect to mail server %S; the connection was refused.
+
+imapNetTimeoutError=Connection to server %S timed out.
+
+imapTlsError=Non-overridable TLS error occurred. Handshake error or probably the TLS version or certificate used by server %S is incompatible.
+
+# Status - no messages to download
+imapNoNewMessages=There are no new messages on the server.
+
+imapDefaultAccountName=Mail for %S
+
+imapSpecialChar2=The %S character is reserved on this imap server. Please choose another name.
+
+imapPersonalSharedFolderTypeName=Personal Folder
+
+imapPublicFolderTypeName=Public Folder
+
+imapOtherUsersFolderTypeName=Other User's Folder
+
+imapPersonalFolderTypeDescription=This is a personal mail folder. It is not shared.
+
+imapPersonalSharedFolderTypeDescription=This is a personal mail folder. It has been shared.
+
+imapPublicFolderTypeDescription=This is a public folder.
+
+imapOtherUsersFolderTypeDescription=This is a mail folder shared by the user '%S'.
+
+imapAclFullRights=Full Control
+
+imapAclLookupRight=Lookup
+
+imapAclReadRight=Read
+
+imapAclSeenRight=Set Read/Unread State
+
+imapAclWriteRight=Write
+
+imapAclInsertRight=Insert (Copy Into)
+
+imapAclPostRight=Post
+
+imapAclCreateRight=Create Subfolder
+
+imapAclDeleteRight=Delete Messages
+
+imapAclAdministerRight=Administer Folder
+
+imapServerDoesntSupportAcl=This server does not support shared folders.
+
+imapAclExpungeRight=Expunge
+
+imapServerDisconnected= Server %S has disconnected. The server may have gone down or there may be a network problem.
+
+# LOCALIZATION NOTE (autoSubscribeText): %1$S is the imap folder.
+imapSubscribePrompt=Would you like to subscribe to %1$S?
+
+imapServerDroppedConnection=Unable to connect to your IMAP server. You may have exceeded the maximum number \
+of connections to this server. If so, use the Advanced IMAP Server Settings dialog to \
+reduce the number of cached connections.
+
+# This will occur when a folder that has never been imap selected or opened
+# (left-clicked) is first right-clicked to access quota properties.
+imapQuotaStatusFolderNotOpen=Quota information is not available because the folder is not open.
+
+# The imap capability response reports that QUOTA is not supported.
+imapQuotaStatusNotSupported=This server does not support quotas.
+
+# The getqutaroot command succeeded but reported no quota information.
+imapQuotaStatusNoQuota2=This folder reports no quota information.
+
+# Folder properties were requested by the user (right-click) before the getquotaroot
+# command was sent.
+imapQuotaStatusInProgress=Quota information not yet available.
+
+# Out of memory
+imapOutOfMemory=Application is out of memory.
+
+# LOCALIZATION NOTE (imapCopyingMessageOf2): Do not translate the word "%S" below.
+# Place the word %3$S in your translation where the name of the destination folder should appear.
+# Place the word %1$S where the currently copying message should appear.
+# Place the word %2$S where the total number of messages should appear.
+imapCopyingMessageOf2=Copying message %1$S of %2$S to %3$S…
+
+# LOCALIZATION NOTE (imapMoveFolderToTrash): Do not translate the word %S below.
+# "%S" is the the name of the folder.
+imapMoveFolderToTrash=Are you sure you want to delete the folder '%S'?
+
+# LOCALIZATION NOTE (imapDeleteNoTrash): Do not translate the word %S below.
+# "%S" is the the name of the folder.
+imapDeleteNoTrash=Deleting this folder is not undoable and will delete all of the messages it contains, and its sub-folders. Are you sure you still want to delete the folder '%S'?
+
+imapDeleteFolderDialogTitle=Delete Folder
+
+imapDeleteFolderButtonLabel=&Delete Folder
+
+# LOCALIZATION NOTE (imapAuthChangeEncryptToPlainSSL): %S is the server hostname
+imapAuthChangeEncryptToPlainSSL=The IMAP server %S does not seem to support encrypted passwords. If you just set up this account, please try changing to 'Normal password' as the 'Authentication method' in the 'Account Settings | Server settings'. If it used to work and now suddenly fails, please contact your email administrator or provider.
+
+# LOCALIZATION NOTE (imapAuthChangePlainToEncrypt): %S is the server hostname
+imapAuthChangePlainToEncrypt=The IMAP server %S does not allow plaintext passwords. Please try changing to 'Encrypted password' as the 'Authentication method' in the 'Account Settings | Server settings'.
+
+# LOCALIZATION NOTE (imapAuthChangeEncryptToPlainNoSSL): %S is the server hostname
+imapAuthChangeEncryptToPlainNoSSL=The IMAP server %S does not seem to support encrypted passwords. If you just set up the account, please try changing to 'Password, transmitted insecurely' as the 'Authentication method' in the 'Account Settings | Server settings'. If it used to work and now suddenly fails, this is a common scenario how someone could steal your password.
+
+# LOCALIZATION NOTE (imapAuthMechNotSupported): %S is the server hostname
+imapAuthMechNotSupported=The IMAP server %S does not support the selected authentication method. Please change the 'Authentication method' in the 'Account Settings | Server settings'.
+
+# LOCALIZATION NOTE (imapAuthGssapiFailed): %S is the server hostname
+imapAuthGssapiFailed=The Kerberos/GSSAPI ticket was not accepted by the IMAP server %S. Please check that you are logged in to the Kerberos/GSSAPI realm.
+
+# LOCALIZATION NOTE (imapServerCommandFailed):
+# Place the word %1$S in your translation where the name of the account name should appear.
+# Place the word %2$S in your translation where the server response should appear.
+imapServerCommandFailed=The current command did not succeed. The mail server for account %1$S responded: %2$S
+
+# LOCALIZATION NOTE (imapFolderCommandFailed): Do not translate the word %S below.
+# Place the word %1$S in your translation where the name of the account should appear.
+# Place the word %2$S in your translation where the name of the folder should appear.
+# Place the word %3$S in your translation where the server response should appear.
+imapFolderCommandFailed=The current operation on '%2$S' did not succeed. The mail server for account %1$S responded: %3$S
+
+# LOCALIZATION NOTE (imapServerAlert):
+# Place the word %1$S in your translation where the name of the account should appear.
+# Place the word %2$S in your translation where the alert from the server should appear.
+imapServerAlert=Alert from account %1$S: %2$S
diff --git a/comm/mail/locales/en-US/chrome/messenger/importDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/importDialog.dtd
new file mode 100644
index 0000000000..edc5167e6e
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/importDialog.dtd
@@ -0,0 +1,48 @@
+<!-- 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/. -->
+
+<!--
+LOCALIZATION NOTE : 'Communicator 4.x' is the used for previous versions of
+Netscape Communicator, Please translate using the brandname in respective
+languages for Netscape Communicator 4 releases.
+LOCALIZATION NOTE : Do not translate any of the occurrences of the word
+"&brandShortName;" below.
+-->
+
+<!ENTITY importDialog.windowTitle "Import">
+<!ENTITY importAll.label "Import Everything">
+<!ENTITY importAll.accesskey "E">
+<!ENTITY importMail.label "Mail">
+<!ENTITY importMail.accesskey "M">
+<!ENTITY importFeeds.label "Feed Subscriptions">
+<!ENTITY importFeeds.accesskey "d">
+<!ENTITY importAddressbook.label "Address Books">
+<!ENTITY importAddressbook.accesskey "A">
+<!ENTITY importSettings.label "Settings">
+<!ENTITY importSettings.accesskey "S">
+<!ENTITY importFilters.label "Filters">
+<!ENTITY importFilters.accesskey "F">
+
+<!ENTITY importTitle.label "&brandShortName; Import Wizard">
+<!ENTITY importShortDesc.label "Import Mail, Address Books, Settings, and Filters from other programs">
+
+<!ENTITY importDescription1.label "This wizard will import mail messages, address book entries, feed subscriptions, preferences, and/or filters from other mail programs and common address book formats into &brandShortName;.">
+<!ENTITY importDescription2.label "Once they have been imported, you will be able to access them from within &brandShortName;.">
+
+<!ENTITY selectDescription.label "Please select the type of file that you would like to import:">
+<!ENTITY selectDescriptionB.label "Please select an existing account or create a new account:">
+<!ENTITY selectDescription.accesskey "P">
+<!ENTITY acctName.label "Name:">
+<!ENTITY acctName.accesskey "N">
+<!ENTITY noModulesFound.label "No application or file to import data from was found.">
+
+<!ENTITY back.label "&lt; Back">
+<!ENTITY forward.label "Next &gt;">
+<!ENTITY finish.label "Finish">
+<!ENTITY cancel.label "Cancel">
+
+<!ENTITY select.label "or select the type of material to import:">
+
+<!ENTITY title.label "Title">
+<!ENTITY processing.label "Importing…">
diff --git a/comm/mail/locales/en-US/chrome/messenger/importMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/importMsgs.properties
new file mode 100644
index 0000000000..5c42678d28
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/importMsgs.properties
@@ -0,0 +1,304 @@
+# 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 following are used by the import code to display status/error
+# and informational messages
+
+# Success message when no address books are found to import
+## @name IMPORT_NO_ADDRBOOKS
+## @loc None
+2000=No address books were found to import.
+
+# Error: Address book import not initialized
+## @name IMPORT_ERROR_AB_NOTINITIALIZED
+## @loc None
+2001=Unable to import address books: initialization error.
+
+# Error: Unable to create the import thread
+## @name IMPORT_ERROR_AB_NOTHREAD
+## @loc None
+2002=Unable to import address books: cannot create import thread.
+
+# Error: Unable to create the import thread
+## @name IMPORT_ERROR_GETABOOK
+## @loc None
+# LOCALIZATION NOTE (Error 2003): Do not translate the word "%S" below.
+2003=Error importing %S: unable to create address book.
+
+# Success message when no mailboxes are found to import
+## @name IMPORT_NO_MAILBOXES
+## @loc None
+2004=No mailboxes were found to import
+
+# Error: Mailbox import not initialized
+## @name IMPORT_ERROR_MB_NOTINITIALIZED
+## @loc None
+2005=Unable to import mailboxes, initialization error
+
+# Error: Unable to create the import thread
+## @name IMPORT_ERROR_MB_NOTHREAD
+## @loc None
+2006=Unable to import mailboxes, cannot create import thread
+
+# Error: Unable to create the proxy object for importing mailboxes
+## @name IMPORT_ERROR_MB_NOPROXY
+## @loc None
+2007=Unable to import mailboxes, cannot create proxy object for destination mailboxes
+
+# Error: Error creating destination mailboxes
+## @name IMPORT_ERROR_MB_FINDCHILD
+## @loc None
+# LOCALIZATION NOTE (Error 2008): Do not translate the word "%S" below.
+# Place %S in your translation where the name of the mailbox should appear.
+2008=Error creating destination mailboxes, cannot find mailbox %S
+
+# Error: Error creating destination mailboxes
+## @name IMPORT_ERROR_MB_CREATE
+## @loc None
+# LOCALIZATION NOTE (Error 2009): Do not translate the word "%S" below.
+# Place %S in your translation where the name of the mailbox should appear.
+2009=Error importing mailbox %S, unable to create destination mailbox
+
+# Error: No destination folder to import mailboxes
+## @name IMPORT_ERROR_MB_NODESTFOLDER
+## @loc None
+2010=Unable to create folder to import mail into
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC_START
+## @loc None
+2100=First Name
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2101=Last Name
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2102=Display Name
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2103=Nickname
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2104=Primary Email
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2105=Secondary Email
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2106=Work Phone
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2107=Home Phone
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2108=Fax Number
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2109=Pager Number
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2110=Mobile Number
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2111=Home Address
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2112=Home Address 2
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2113=Home City
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2114=Home State
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2115=Home ZipCode
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2116=Home Country
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2117=Work Address
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2118=Work Address 2
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2119=Work City
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2120=Work State
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2121=Work ZipCode
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2122=Work Country
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2123=Job Title
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2124=Department
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2125=Organization
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2126=Web Page 1
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2127=Web Page 2
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2128=Birth Year
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2129=Birth Month
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2130=Birth Day
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2131=Custom 1
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2132=Custom 2
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2133=Custom 3
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2134=Custom 4
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC
+## @loc None
+2135=Notes
+
+# Description: Address book field name
+## @name IMPORT_FIELD_DESC_END
+## @loc None
+2136=Screen Name
+
+#Error strings
+ImportAlreadyInProgress=An import operation is currently in progress. Try again when the current import has finished.
+
+#Error strings for settings import
+ImportSettingsBadModule=Unable to load settings module
+ImportSettingsNotFound=Unable to find settings. Check to make sure the application is installed on this machine.
+ImportSettingsFailed=An error occurred while importing settings. Some, or all, of the settings may not have been imported.
+# LOCALIZATION NOTE : Do not translate the word "%S" below.
+ImportSettingsSuccess=Settings were imported from %S
+
+#Error string for mail import
+ImportMailBadModule=Unable to load mail import module
+ImportMailNotFound=Unable to find mail to import. Check to make sure the mail application is correctly installed on this machine.
+ImportEmptyAddressBook=Can't import empty address book %S.
+# LOCALIZATION NOTE: Do not translate the word "%S" below.
+ImportMailFailed=An error occurred importing mail from %S
+# LOCALIZATION NOTE: Do not translate the word "%S" below.
+ImportMailSuccess=Mail was successfully imported from %S
+
+# Error string for address import
+ImportAddressBadModule=Unable to load address book import module.
+ImportAddressNotFound=Unable to find any address books to import. Check to make sure the selected application or format is correctly installed on this machine.
+# LOCALIZATION NOTE : Do not translate the word "%S" below.
+ImportAddressFailed=An error occurred importing addresses from %S.
+# LOCALIZATION NOTE : Do not translate the word "%S" below.
+ImportAddressSuccess=Addresses successfully imported from %S.
+
+# Error string for filters import
+ImportFiltersBadModule=Unable to load filters import module.
+# LOCALIZATION NOTE : The %S will get replaced by the name of the import module.
+ImportFiltersFailed=An error occurred importing filters from %S.
+# LOCALIZATION NOTE : The %S will get replaced by the name of the import module.
+ImportFiltersSuccess=Filters successfully imported from %S.
+# LOCALIZATION NOTE : The %S will get replaced by the name of the import module.
+ImportFiltersPartial=Filters partially imported from %S. Warnings below:
+
+#Progress strings
+# LOCALIZATION NOTE : Do not translate the word "%S" below.
+MailProgressMeterText=Converting mailboxes from %S
+# LOCALIZATION NOTE : Do not translate the word "%S" below.
+AddrProgressMeterText=Converting address books from %S
+
+#Import file dialog strings
+ImportSelectSettings=Select settings file
+ImportSelectMailDir=Select mail directory
+ImportSelectAddrDir=Select address book directory
+ImportSelectAddrFile=Select address book file
+
+# Folder Names for imported Mail
+DefaultFolderName=Imported Mail
+# LOCALIZATION NOTE: Do not translate the word "%S" below.
+ImportModuleFolderName=%S Import
diff --git a/comm/mail/locales/en-US/chrome/messenger/joinChat.dtd b/comm/mail/locales/en-US/chrome/messenger/joinChat.dtd
new file mode 100644
index 0000000000..4b830142a5
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/joinChat.dtd
@@ -0,0 +1,10 @@
+<!-- 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/. -->
+
+<!ENTITY joinChatWindow.title "Join chat">
+<!ENTITY name.label "Room">
+<!ENTITY optional.label "(optional)">
+<!ENTITY account.label "Account">
+<!ENTITY autojoin.label "Auto-join this Chat Room">
+<!ENTITY autojoin.accesskey "A">
diff --git a/comm/mail/locales/en-US/chrome/messenger/junkLog.dtd b/comm/mail/locales/en-US/chrome/messenger/junkLog.dtd
new file mode 100644
index 0000000000..dba7e67dbc
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/junkLog.dtd
@@ -0,0 +1,10 @@
+<!-- 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/. -->
+
+<!ENTITY adaptiveJunkLog.title "Adaptive Junk Mail Log">
+<!ENTITY adaptiveJunkLogInfo.label "Log of adaptive junk mail control activity.">
+<!ENTITY clearLog.label "Clear Log">
+<!ENTITY clearLog.accesskey "C">
+<!ENTITY closeLog.label "Close">
+<!ENTITY closeLog.accesskey "o">
diff --git a/comm/mail/locales/en-US/chrome/messenger/localMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/localMsgs.properties
new file mode 100644
index 0000000000..8b792fa84d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/localMsgs.properties
@@ -0,0 +1,140 @@
+# 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 following are used by the local mail code to display progress/status/error messages
+#
+
+# LOCALIZATION NOTE(pop3ErrorDialogTitle): Do not translate the word "%S"
+# below. Place the word %S where the account name should appear.
+pop3ErrorDialogTitle=Error with account %S
+
+# LOCALIZATION NOTE (pop3EnterPasswordPromptTitleWithUsername): Do not translate the
+# word %1$S. Place the word %1$S where the user name should appear.
+pop3EnterPasswordPromptTitleWithUsername=Enter your password for %1$S
+
+# LOCALIZATION NOTE(pop3EnterPasswordPrompt): Do not translate the words "%1$S"
+# and "%2$S" below. Place the word %1$S where the user name should appear, and
+# %2$S where the host name should appear.
+pop3EnterPasswordPrompt=Enter your password for %1$S on %2$S:
+
+# LOCALIZATION NOTE(pop3PreviouslyEnteredPasswordIsInvalidPrompt): Do not
+# translate the words "%1$S" and "%2$S" below. Place the word %1$S where the
+# user name should appear, and %2$S where the host name should appear.
+pop3PreviouslyEnteredPasswordIsInvalidPrompt=Please enter a new password for user %1$S on %2$S:
+
+# Status - Downloading message n of m
+# LOCALIZATION NOTE (receivingMessages): Do not translate %1$S or %2$S in the following lines.
+# Place the word %1$S where the number of messages downloaded so far should appear.
+# Place the word %2$S where the total number of messages to receive should appear;
+receivingMessages=Downloading message %1$S of %2$S…
+
+# Status - connecting to host
+hostContact=Host contacted, sending login information…
+
+# Status - no messages to download
+noNewMessages=There are no new messages.
+
+# Status - messages received after the download
+#LOCALIZATION NOTE : Do not translate %1$S or %2$S in the following line.
+# %1$S will receive the number of messages received
+# %2$S will receive the total number of messages
+receivedMsgs=Received %1$S of %2$S messages
+
+# Status - parsing folder
+#LOCALIZATION NOTE (buildingSummary): Do not translate %S in the following line.
+# Place the word %S where the name of the mailbox should appear
+buildingSummary=Building summary file for %S…
+
+# Status - parsing folder
+localStatusDocumentDone=Done
+
+# Status - pop3 server error
+#LOCALIZATION NOTE (pop3ServerError): Do not translate POP3 in the following line.
+pop3ServerError=An error occurred with the POP3 mail server.
+
+# Status - pop3 user name failed
+pop3UsernameFailure=Sending of username did not succeed.
+
+# Status - password failed
+#LOCALIZATION NOTE (pop3PasswordFailed): Do not translate "%1$S" below.
+# Place the word %1$S where the user name should appear.
+pop3PasswordFailed=Sending of password for user %1$S did not succeed.
+
+# Status - write error occurred
+pop3MessageWriteError=Unable to write the email to the mailbox. Make sure the file system allows you write privileges, and you have enough disk space to copy the mailbox.
+
+# Status - retr failure from the server
+pop3RetrFailure=The RETR command did not succeed. Error retrieving a message.
+
+# Status - password undefined
+pop3PasswordUndefined=Error getting mail password.
+
+# Status - username undefined
+pop3UsernameUndefined=You have not supplied a username for this server. Please provide one in the account setup menu and try again.
+
+# Status - list failure
+pop3ListFailure=The LIST command did not succeed. Error getting the ID and size of a message.
+
+# Status - delete error
+pop3DeleFailure=The DELE command did not succeed. Error marking a message as deleted.
+
+# Status - stat failed
+pop3StatFail=The STAT command did not succeed. Error getting message number and sizes.
+
+#LOCALIZATION NOTE (pop3ServerSaid): Do not remove the leading space during translation.
+pop3ServerSaid= Mail server %S responded:
+
+#LOCALIZATION NOTE (pop3TempServerError): %S is where the POP3 server name will appear.
+pop3TempServerError=Temporary error from %S while retrieving new messages. \
+The operation will be retried at the next check for new messages.
+
+copyingMessagesStatus=Copying %S of %S messages to %S
+
+movingMessagesStatus=Moving %S of %S messages to %S
+
+# Status - pop3 server or folder busy
+# LOCALIZATION NOTE (pop3ServerBusy): Do not translate the word "%S" below.
+# Place %S where the account name should appear.
+pop3ServerBusy=The account %S is being processed. Please wait until processing is complete to get messages.
+
+pop3TmpDownloadError=There was an error downloading the following message: \nFrom: %S\n Subject: %S\n This message may contain a virus or there is not enough disk space. Skip this message?
+
+# Status - the server doesn't support UIDL…
+# LOCALIZATION NOTE(pop3ServerDoesNotSupportUidlEtc): The following sentence should be translated in this way:
+# Do not translate "POP3"
+# Do not translate "%S". Place %S in your translation where the name of the server should appear.
+# Do not translate "UIDL"
+pop3ServerDoesNotSupportUidlEtc=The POP3 mail server (%S) does not support UIDL or XTND XLST, which are required to implement the ``Leave on Server'', ``Maximum Message Size'' or ``Fetch Headers Only'' options. To download your mail, turn off these options in the Server Settings for your mail server in the Account Settings window.
+
+# Status - the server doesn't support the top command
+# LOCALIZATION NOTE(pop3ServerDoesNotSupportTopCommand): The following sentence should be translated in this way:
+# Do not translate "POP3"
+# Do not translate "%S". Place %S in your translation where the name of the server should appear.
+# Do not translate "TOP"
+pop3ServerDoesNotSupportTopCommand=The POP3 mail server (%S) does not support the TOP command. Without server support for this, we cannot implement the ``Maximum Message Size'' or ``Fetch Headers Only'' preference. This option has been disabled, and messages will be downloaded regardless of their size.
+
+nsErrorCouldNotConnectViaTls=Unable to establish TLS connection to POP3 server. The server may be down or may be incorrectly configured. Please verify the correct configuration in the Server Settings for your mail server in the Account Settings window and try again.
+
+# LOCALIZATION NOTE (pop3MoveFolderToTrash): Do not translate the word %S below.
+# "%S" is the the name of the folder.
+pop3MoveFolderToTrash=Are you sure you want to delete the folder '%S'?
+
+pop3DeleteFolderDialogTitle=Delete Folder
+
+pop3DeleteFolderButtonLabel=&Delete Folder
+
+pop3AuthInternalError=Internal state error during POP3 server authentication. This is an internal, unexpected error in the application, please report it as bug.
+
+pop3AuthChangeEncryptToPlainNoSSL=This POP3 server does not seem to support encrypted passwords. If you just set up the account, please try changing to 'Password, transmitted insecurely' as the 'Authentication method' in the 'Account Settings | Server settings'. If it used to work and now suddenly fails, this is a common scenario how someone could steal your password.
+
+pop3AuthChangeEncryptToPlainSSL=This POP3 server does not seem to support encrypted passwords. If you just set up this account, please try changing to 'Normal password' as the 'Authentication method' in the 'Account Settings | Server settings'. If it used to work and now suddenly fails, please contact your email administrator or provider.
+
+pop3AuthChangePlainToEncrypt=This POP3 server does not allow plaintext passwords. Please try changing to 'Encrypted password' as the 'Authentication method' in the 'Account Settings | Server settings'.
+
+# Authentication server caps and pref don't match
+pop3AuthMechNotSupported=The server does not support the selected authentication method. Please change the 'Authentication method' in the 'Account Settings | Server settings'.
+
+# Status - Could not log in to GSSAPI, and it was the only method
+pop3GssapiFailure=The Kerberos/GSSAPI ticket was not accepted by the POP server. Please check that you are logged in to the Kerberos/GSSAPI realm.
diff --git a/comm/mail/locales/en-US/chrome/messenger/mailEditorOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/mailEditorOverlay.dtd
new file mode 100644
index 0000000000..d0f65354ac
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/mailEditorOverlay.dtd
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+
+<!ENTITY sendPage.label "Send Page…">
+<!ENTITY sendPage.accesskey "g">
diff --git a/comm/mail/locales/en-US/chrome/messenger/mailOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/mailOverlay.dtd
new file mode 100644
index 0000000000..727849c052
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/mailOverlay.dtd
@@ -0,0 +1,11 @@
+<!-- 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/. -->
+
+<!ENTITY newMessageCmd2.key "N">
+<!ENTITY newMessageCmd.key "M">
+<!ENTITY newMessageCmd.label "Message">
+<!ENTITY newMessageCmd.accesskey "m">
+
+<!ENTITY newContactCmd.label "Address Book Contact…">
+<!ENTITY newContactCmd.accesskey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger/mailViewList.dtd b/comm/mail/locales/en-US/chrome/messenger/mailViewList.dtd
new file mode 100644
index 0000000000..4b828983cd
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/mailViewList.dtd
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+
+<!--LOCALIZATION NOTE msgViewPickerOverlay.dtd UI for showing various views on a folder -->
+
+<!ENTITY mailViewListTitle.label "Customize Message Views">
diff --git a/comm/mail/locales/en-US/chrome/messenger/mailViewSetup.dtd b/comm/mail/locales/en-US/chrome/messenger/mailViewSetup.dtd
new file mode 100644
index 0000000000..3ebe7246b6
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/mailViewSetup.dtd
@@ -0,0 +1,10 @@
+<!-- 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/. -->
+
+<!--LOCALIZATION NOTE msgViewPickerOverlay.dtd UI for showing various views on a folder -->
+
+<!ENTITY mailViewSetupTitle.label "Message View Setup">
+<!ENTITY mailViewHeading.label "Message view name:">
+<!ENTITY mailViewHeading.accesskey "e">
+<!ENTITY searchTermCaption.label "When this view is selected, display only messages that:">
diff --git a/comm/mail/locales/en-US/chrome/messenger/mailviews.properties b/comm/mail/locales/en-US/chrome/messenger/mailviews.properties
new file mode 100644
index 0000000000..0257ac6d5d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/mailviews.properties
@@ -0,0 +1,13 @@
+# 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/.
+
+#
+# Mail Views
+#
+
+mailViewPeopleIKnow=People I Know
+mailViewRecentMail=Recent Mail
+mailViewLastFiveDays=Last 5 Days
+mailViewNotJunk=Not Junk
+mailViewHasAttachments=Has Attachments
diff --git a/comm/mail/locales/en-US/chrome/messenger/markByDate.dtd b/comm/mail/locales/en-US/chrome/messenger/markByDate.dtd
new file mode 100644
index 0000000000..e8158142bb
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/markByDate.dtd
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+
+<!ENTITY messageMarkByDate.label "Mark Messages as Read by Date">
+<!ENTITY markByDateLower.label "Mark messages as read from:">
+<!ENTITY markByDateLower.accesskey "F">
+<!ENTITY markByDateUpper.label "To:">
+<!ENTITY markByDateUpper.accesskey "T">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messenger.dtd b/comm/mail/locales/en-US/chrome/messenger/messenger.dtd
new file mode 100644
index 0000000000..8514ae1c9b
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messenger.dtd
@@ -0,0 +1,920 @@
+<!-- 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/. -->
+
+<!ENTITY titledefault.label "&brandFullName;">
+<!ENTITY titleSeparator.label " - ">
+
+<!-- File Menu -->
+<!ENTITY newFolderCmd.label "Folder…">
+<!ENTITY newFolderCmd.accesskey "F">
+<!ENTITY closeTabCmd2.label "Close Tab">
+<!ENTITY closeTabCmd2.accesskey "C">
+<!ENTITY closeOtherTabsCmd2.label "Close Other Tabs">
+<!ENTITY closeOtherTabsCmd2.accesskey "o">
+<!ENTITY recentlyClosedTabsCmd.label "Recently Closed Tabs">
+<!ENTITY recentlyClosedTabsCmd.accesskey "R">
+
+<!ENTITY undoCloseTabCmd.commandkey "T">
+<!-- LOCALIZATION NOTE (moveToNewWindow.label):
+ Menu option to cause the current tab to be migrated to a new Thunderbird
+ window.
+ -->
+<!ENTITY moveToNewWindow.label "Move to New Window">
+<!ENTITY moveToNewWindow.accesskey "W">
+<!ENTITY newVirtualFolderCmd.label "Saved Search…">
+<!ENTITY newVirtualFolderCmd.accesskey "S">
+<!ENTITY newCreateEmailAccountCmd.label "Get a New Mail Account…">
+<!ENTITY newCreateEmailAccountCmd.accesskey "G">
+<!ENTITY newExistingEmailAccountCmd.label "Existing Mail Account…">
+<!ENTITY newExistingEmailAccountCmd.accesskey "E">
+<!ENTITY newIMAccountCmd.label "Chat Account…">
+<!ENTITY newIMAccountCmd.accesskey "C">
+<!ENTITY newFeedAccountCmd.label "Feed Account…">
+<!ENTITY newFeedAccountCmd.accesskey "d">
+<!ENTITY newIMContactCmd.label "Chat Contact…">
+<!ENTITY newIMContactCmd.accesskey "h">
+<!ENTITY newMessageCmd2.key "N">
+<!ENTITY newMessageCmd.key "M">
+<!ENTITY newMessageCmd.label "Message">
+<!ENTITY newMessageCmd.accesskey "m">
+<!ENTITY newContactCmd.label "Address Book Contact…">
+<!ENTITY newContactCmd.accesskey "C">
+<!ENTITY openMenuCmd.label "Open">
+<!ENTITY openMenuCmd.accesskey "O">
+<!ENTITY openMessageFileCmd.label "Saved Message…">
+<!ENTITY openMessageFileCmd.accesskey "M">
+<!ENTITY saveAsMenu.label "Save As">
+<!ENTITY saveAsMenu.accesskey "S">
+<!ENTITY saveAsFileCmd.key "s">
+<!ENTITY saveAsTemplateCmd.label "Template">
+<!ENTITY saveAsTemplateCmd.accesskey "T">
+<!ENTITY getNewMsgForCmd.label "Get New Messages for">
+<!ENTITY getNewMsgForCmd.accesskey "w">
+<!ENTITY getAllNewMsgCmdPopupMenu.label "All Accounts">
+<!ENTITY getAllNewMsgCmdPopupMenu.accesskey "A">
+<!ENTITY getNewMsgCurrentAccountCmdPopupMenu.label "Current Account">
+<!ENTITY getNewMsgCurrentAccountCmdPopupMenu.accesskey "C">
+<!ENTITY getNextNMsgCmd2.label "Get Next News Messages">
+<!ENTITY getNextNMsgCmd2.accesskey "t">
+<!ENTITY sendUnsentCmd.label "Send Unsent Messages">
+<!ENTITY sendUnsentCmd.accesskey "d">
+<!ENTITY subscribeCmd.label "Subscribe…">
+<!ENTITY subscribeCmd.accesskey "b">
+<!ENTITY deleteFolder.label "Delete Folder">
+<!ENTITY deleteFolder.accesskey "e">
+<!ENTITY renameFolder.label "Rename Folder…">
+<!ENTITY renameFolder.accesskey "R">
+<!ENTITY renameFolder.key "VK_F2">
+<!ENTITY compactFolders.label "Compact Folders">
+<!ENTITY compactFolders.accesskey "F">
+<!ENTITY emptyTrashCmd.label "Empty Trash">
+<!ENTITY emptyTrashCmd.accesskey "y">
+<!ENTITY offlineMenu.label "Offline">
+<!ENTITY offlineMenu.accesskey "l">
+<!ENTITY offlineGoOfflineCmd.label "Work Offline">
+<!ENTITY offlineGoOfflineCmd.accesskey "w">
+<!ENTITY synchronizeOfflineCmd.label "Download/Sync Now…">
+<!ENTITY synchronizeOfflineCmd.accesskey "S">
+<!ENTITY settingsOfflineCmd2.label "Offline Settings">
+<!ENTITY settingsOfflineCmd2.accesskey "e">
+<!ENTITY downloadSelectedCmd.label "Get Selected Messages">
+<!ENTITY downloadSelectedCmd.accesskey "l">
+<!ENTITY downloadStarredCmd.label "Get Starred Messages">
+<!ENTITY downloadStarredCmd.accesskey "a">
+<!ENTITY printCmd.label "Print…">
+<!ENTITY printCmd.accesskey "P">
+<!ENTITY printCmd.key "p">
+
+<!-- Edit Menu -->
+<!ENTITY selectMenu.label "Select">
+<!ENTITY selectMenu.accesskey "S">
+<!ENTITY all.label "All">
+<!ENTITY all.accesskey "A">
+<!ENTITY selectThreadCmd.label "Thread">
+<!ENTITY selectThreadCmd.accesskey "T">
+<!ENTITY selectThreadCmd.key "a">
+<!ENTITY selectFlaggedCmd.label "Starred Messages">
+<!ENTITY selectFlaggedCmd.accesskey "S">
+<!ENTITY menuFavoriteFolder.label "Favorite Folder">
+<!ENTITY menuFavoriteFolder.accesskey "v">
+<!ENTITY undoDeleteMsgCmd.label "Undo Delete Message">
+<!ENTITY redoDeleteMsgCmd.label "Redo Delete Message">
+<!ENTITY undoMoveMsgCmd.label "Undo Move Message">
+<!ENTITY redoMoveMsgCmd.label "Redo Move Message">
+<!ENTITY undoCopyMsgCmd.label "Undo Copy Message">
+<!ENTITY redoCopyMsgCmd.label "Redo Copy Message">
+<!ENTITY undoMarkAllCmd.label "Undo Mark All Read">
+<!ENTITY redoMarkAllCmd.label "Redo Mark All Read">
+<!ENTITY undoDefaultCmd.label "Undo">
+<!ENTITY undoDefaultCmd.accesskey "U">
+<!ENTITY redoDefaultCmd.label "Redo">
+<!ENTITY redoDefaultCmd.accesskey "R">
+
+<!-- View Menu -->
+<!ENTITY menubarCmd.label "Menu Bar">
+<!ENTITY menubarCmd.accesskey "M">
+<!ENTITY showMessengerToolbarCmd.label "Mail Toolbar">
+<!ENTITY showMessengerToolbarCmd.accesskey "o">
+<!ENTITY customizeToolbar.label "Customize…">
+<!ENTITY customizeToolbar.accesskey "C">
+
+<!ENTITY messagePaneLayoutStyle.label "Layout">
+<!ENTITY messagePaneLayoutStyle.accesskey "L">
+<!ENTITY messagePaneClassic.label "Classic View">
+<!ENTITY messagePaneClassic.accesskey "C">
+<!ENTITY messagePaneWide.label "Wide View">
+<!ENTITY messagePaneWide.accesskey "W">
+<!ENTITY messagePaneVertical.label "Vertical View">
+<!ENTITY messagePaneVertical.accesskey "V">
+<!ENTITY showFolderPaneCmd.label "Folder Pane">
+<!ENTITY showFolderPaneCmd.accesskey "F">
+<!ENTITY showMessageCmd.label "Message Pane">
+<!ENTITY showMessageCmd.accesskey "M">
+
+<!ENTITY folderView.label "Folders">
+<!ENTITY folderView.accesskey "F">
+<!ENTITY unifiedFolders.label "Unified">
+<!ENTITY unifiedFolders.accesskey "n">
+<!ENTITY allFolders.label "All">
+<!ENTITY allFolders.accesskey "A">
+<!ENTITY unreadFolders.label "Unread">
+<!ENTITY unreadFolders.accesskey "U">
+<!ENTITY favoriteFolders.label "Favorite">
+<!ENTITY favoriteFolders.accesskey "F">
+<!ENTITY recentFolders.label "Recent">
+<!ENTITY recentFolders.accesskey "R">
+<!ENTITY compactVersion.label "Compact View">
+<!ENTITY compactVersion.accesskey "C">
+
+<!-- Sort Menu -->
+<!ENTITY sortMenu.label "Sort by">
+<!ENTITY sortMenu.accesskey "S">
+<!ENTITY sortByDateCmd.label "Date">
+<!ENTITY sortByDateCmd.accesskey "e">
+<!ENTITY sortByReceivedCmd.label "Received">
+<!ENTITY sortByReceivedCmd.accesskey "v">
+<!ENTITY sortByStarCmd.label "Star">
+<!ENTITY sortByStarCmd.accesskey "S">
+<!ENTITY sortByAttachmentsCmd.label "Attachments">
+<!ENTITY sortByAttachmentsCmd.accesskey "m">
+<!ENTITY sortByPriorityCmd.label "Priority">
+<!ENTITY sortByPriorityCmd.accesskey "P">
+<!ENTITY sortBySizeCmd.label "Size">
+<!ENTITY sortBySizeCmd.accesskey "z">
+<!ENTITY sortByStatusCmd.label "Status">
+<!ENTITY sortByStatusCmd.accesskey "u">
+<!ENTITY sortByTagsCmd.label "Tags">
+<!ENTITY sortByTagsCmd.accesskey "g">
+<!ENTITY sortByJunkStatusCmd.label "Junk Status">
+<!ENTITY sortByJunkStatusCmd.accesskey "J">
+<!ENTITY sortBySubjectCmd.label "Subject">
+<!ENTITY sortBySubjectCmd.accesskey "b">
+<!ENTITY sortByFromCmd.label "From">
+<!ENTITY sortByFromCmd.accesskey "F">
+<!ENTITY sortByRecipientCmd.label "Recipient">
+<!ENTITY sortByRecipientCmd.accesskey "c">
+<!ENTITY sortByCorrespondentCmd.label "Correspondents">
+<!ENTITY sortByCorrespondentCmd.accesskey "n">
+<!ENTITY sortByUnreadCmd.label "Read">
+<!ENTITY sortByUnreadCmd.accesskey "R">
+<!ENTITY sortByOrderReceivedCmd.label "Order Received">
+<!ENTITY sortByOrderReceivedCmd.accesskey "O">
+<!ENTITY sortAscending.label "Ascending">
+<!ENTITY sortAscending.accesskey "A">
+<!ENTITY sortDescending.label "Descending">
+<!ENTITY sortDescending.accesskey "D">
+<!ENTITY sortThreaded.label "Threaded">
+<!ENTITY sortThreaded.accesskey "T">
+<!ENTITY sortUnthreaded.label "Unthreaded">
+<!ENTITY sortUnthreaded.accesskey "h">
+<!ENTITY groupBySort.label "Grouped By Sort">
+<!ENTITY groupBySort.accesskey "G">
+<!ENTITY msgsMenu.label "Messages">
+<!ENTITY msgsMenu.accesskey "M">
+<!ENTITY threads.label "Threads">
+<!ENTITY threads.accesskey "e">
+<!ENTITY allMsgsCmd.label "All">
+<!ENTITY allMsgsCmd.accesskey "A">
+<!ENTITY expandAllThreadsCmd.label "Expand All Threads">
+<!ENTITY expandAllThreadsCmd.accesskey "E">
+<!ENTITY expandAllThreadsCmd.key "*">
+<!ENTITY collapseAllThreadsCmd.label "Collapse All Threads">
+<!ENTITY collapseAllThreadsCmd.accesskey "C">
+<!ENTITY collapseAllThreadsCmd.key "\">
+<!ENTITY unreadMsgsCmd.label "Unread">
+<!ENTITY unreadMsgsCmd.accesskey "U">
+<!ENTITY threadsWithUnreadCmd.label "Threads with Unread">
+<!ENTITY threadsWithUnreadCmd.accesskey "T">
+<!ENTITY watchedThreadsWithUnreadCmd.label "Watched Threads with Unread">
+<!ENTITY watchedThreadsWithUnreadCmd.accesskey "W">
+<!ENTITY ignoredThreadsCmd.label "Ignored Threads">
+<!ENTITY ignoredThreadsCmd.accesskey "i">
+
+<!ENTITY headersMenu.label "Headers">
+<!ENTITY headersMenu.accesskey "H">
+<!ENTITY headersAllCmd.label "All">
+<!ENTITY headersAllCmd.accesskey "A">
+<!ENTITY headersNormalCmd.label "Normal">
+<!ENTITY headersNormalCmd.accesskey "N">
+<!ENTITY bodyMenu.label "Message Body As">
+<!ENTITY bodyMenu.accesskey "B">
+<!ENTITY bodyAllowHTML.label "Original HTML">
+<!ENTITY bodyAllowHTML.accesskey "H">
+<!ENTITY bodySanitized.label "Simple HTML">
+<!ENTITY bodySanitized.accesskey "S">
+<!ENTITY bodyAsPlaintext.label "Plain Text">
+<!ENTITY bodyAsPlaintext.accesskey "P">
+<!ENTITY bodyAllParts.label "All Body Parts">
+<!ENTITY bodyAllParts.accesskey "A">
+
+<!ENTITY bodyMenuFeed.label "Feed Message Body As">
+<!ENTITY bodyMenuFeed.accesskey "B">
+<!ENTITY viewFeedWebPage.label "Web Page">
+<!ENTITY viewFeedWebPage.accesskey "W">
+<!ENTITY viewFeedSummary.label "Summary">
+<!ENTITY viewFeedSummary.accesskey "m">
+<!ENTITY viewFeedSummaryFeedPropsPref.label "Default Format">
+<!ENTITY viewFeedSummaryFeedPropsPref.accesskey "D">
+
+<!ENTITY viewAttachmentsInlineCmd.label "Display Attachments Inline">
+<!ENTITY viewAttachmentsInlineCmd.accesskey "A">
+
+<!ENTITY pageSourceCmd.label "Message Source">
+<!ENTITY pageSourceCmd.accesskey "o">
+<!ENTITY pageSourceCmd.key "u">
+<!ENTITY getNewMessagesCmd.key "y">
+<!ENTITY getAllNewMessagesCmd.key "Y">
+
+<!-- Search Menu -->
+<!ENTITY findMenu.label "Find">
+<!ENTITY findMenu.accesskey "F">
+<!ENTITY findCmd.label "Find in This Message…">
+<!ENTITY findCmd.accesskey "F">
+<!ENTITY findCmd.key "f">
+<!ENTITY findAgainCmd.label "Find Again">
+<!ENTITY findAgainCmd.accesskey "g">
+<!ENTITY findAgainCmd.key "g">
+<!ENTITY findAgainCmd.key2 "VK_F3">
+<!ENTITY findPrevCmd.key "g">
+<!ENTITY findPrevCmd.key2 "VK_F3">
+<!ENTITY searchMailCmd.label "Search Messages…">
+<!ENTITY searchMailCmd.accesskey "M">
+<!ENTITY searchMailCmd.key "f">
+<!ENTITY glodaSearchCmd.label "Global Search…">
+<!ENTITY glodaSearchCmd.accesskey "G">
+<!ENTITY searchAddressesCmd.label "Search Addresses…">
+<!ENTITY searchAddressesCmd.accesskey "S">
+
+<!-- Go Menu -->
+<!ENTITY goMenu.label "Go">
+<!ENTITY goMenu.accesskey "G">
+<!ENTITY nextMenu.label "Next">
+<!ENTITY nextMenu.accesskey "N">
+<!ENTITY nextMsgCmd.label "Message">
+<!ENTITY nextMsgCmd.accesskey "M">
+<!ENTITY nextMsgCmd.key "f">
+<!ENTITY nextUnreadMsgCmd.label "Unread Message">
+<!ENTITY nextUnreadMsgCmd.accesskey "U">
+<!ENTITY nextUnreadMsgCmd.key "n">
+<!ENTITY nextStarredMsgCmd.label "Starred Message">
+<!ENTITY nextStarredMsgCmd.accesskey "S">
+<!ENTITY nextUnreadThread.label "Unread Thread">
+<!ENTITY nextUnreadThread.accesskey "T">
+<!ENTITY nextUnreadThread.key "t">
+<!ENTITY prevMenu.label "Previous">
+<!ENTITY prevMenu.accesskey "P">
+<!ENTITY prevMsgCmd.label "Message">
+<!ENTITY prevMsgCmd.accesskey "M">
+<!ENTITY prevMsgCmd.key "b">
+<!ENTITY prevUnreadMsgCmd.label "Unread Message">
+<!ENTITY prevUnreadMsgCmd.accesskey "U">
+<!ENTITY prevUnreadMsgCmd.key "p">
+<!ENTITY goForwardCmd.label "Forward">
+<!ENTITY goForwardCmd.accesskey "F">
+<!ENTITY goForwardCmd.commandKey "]">
+<!ENTITY goBackCmd.label "Back">
+<!ENTITY goBackCmd.accesskey "B">
+<!ENTITY goBackCmd.commandKey "[">
+<!ENTITY goChatCmd.label "Chat">
+<!ENTITY goChatCmd.accesskey "c">
+<!ENTITY prevStarredMsgCmd.label "Starred Message">
+<!ENTITY prevStarredMsgCmd.accesskey "S">
+<!ENTITY folderMenu.label "Folder">
+<!ENTITY folderMenu.accesskey "O">
+<!ENTITY goRecentlyClosedTabs.label "Recently Closed Tabs">
+<!ENTITY goRecentlyClosedTabs.accesskey "R">
+<!ENTITY startPageCmd.label "Mail Start Page">
+<!ENTITY startPageCmd.accesskey "S">
+
+<!-- Message Menu -->
+<!ENTITY msgMenu.label "Message">
+<!ENTITY msgMenu.accesskey "M">
+<!ENTITY newMsgCmd.label "New Message">
+<!ENTITY newMsgCmd.accesskey "N">
+<!ENTITY newNewMsgCmd.label "Message">
+<!ENTITY newNewMsgCmd.accesskey "M">
+<!ENTITY archiveMsgCmd.label "Archive">
+<!ENTITY archiveMsgCmd.accesskey "A">
+<!ENTITY archiveMsgCmd.key "a">
+<!ENTITY cancelNewsMsgCmd.label "Cancel Message">
+<!ENTITY cancelNewsMsgCmd.accesskey "C">
+<!ENTITY replyMsgCmd.label "Reply">
+<!ENTITY replyMsgCmd.accesskey "R">
+<!ENTITY replyMsgCmd.key "r">
+<!ENTITY replySenderCmd.label "Reply to Sender Only">
+<!ENTITY replySenderCmd.accesskey "R">
+<!ENTITY replyNewsgroupCmd2.label "Followup to Newsgroup">
+<!ENTITY replyNewsgroupCmd2.accesskey "u">
+<!ENTITY replyToAllMsgCmd.label "Reply to All">
+<!ENTITY replyToAllMsgCmd.accesskey "p">
+<!ENTITY replyToAllMsgCmd.key "r">
+<!ENTITY replyToListMsgCmd.label "Reply to List">
+<!ENTITY replyToListMsgCmd.accesskey "L">
+<!ENTITY replyToListMsgCmd.key "l">
+<!ENTITY forwardMsgCmd.label "Forward">
+<!ENTITY forwardMsgCmd.accesskey "F">
+<!ENTITY forwardMsgCmd.key "l">
+<!ENTITY forwardAsMenu.label "Forward As">
+<!ENTITY forwardAsMenu.accesskey "w">
+<!ENTITY forwardAsInline.label "Inline">
+<!ENTITY forwardAsInline.accesskey "I">
+<!ENTITY forwardAsAttachmentCmd.label "Attachment">
+<!ENTITY forwardAsAttachmentCmd.accesskey "A">
+<!ENTITY editAsNewMsgCmd.label "Edit As New Message">
+<!ENTITY editAsNewMsgCmd.accesskey "E">
+<!ENTITY editAsNewMsgCmd.key "e">
+<!ENTITY editDraftMsgCmd.label "Edit Draft Message">
+<!ENTITY editDraftMsgCmd.accesskey "D">
+<!ENTITY editTemplateMsgCmd.label "Edit Template">
+<!ENTITY editTemplateMsgCmd.accesskey "T">
+<!ENTITY newMsgFromTemplateCmd.label "New Message from Template">
+<!ENTITY newMsgFromTemplateCmd.keycode "VK_RETURN"><!-- do not change "VK_RETURN" -->
+<!ENTITY createFilter.label "Create Filter From Message…">
+<!ENTITY createFilter.accesskey "a">
+<!ENTITY moveMsgToMenu.label "Move To">
+<!ENTITY moveMsgToMenu.accesskey "M">
+<!ENTITY moveCopyMsgRecentMenu.label "Recent">
+<!ENTITY moveCopyMsgRecentMenu.accesskey "R">
+<!ENTITY copyMessageLocation.label "Copy Message Location">
+<!ENTITY copyMessageLocation.accesskey "M">
+<!ENTITY copyMsgToMenu.label "Copy To">
+<!ENTITY copyMsgToMenu.accesskey "C">
+<!ENTITY moveToFolderAgain.label "Move Again">
+<!ENTITY moveToFolderAgain.accesskey "i">
+<!ENTITY moveToFolderAgainCmd.key "m">
+<!ENTITY killThreadMenu.label "Ignore Thread">
+<!ENTITY killThreadMenu.accesskey "I">
+<!ENTITY killThreadMenu.key "k">
+<!ENTITY killSubthreadMenu.label "Ignore Subthread">
+<!ENTITY killSubthreadMenu.accesskey "S">
+<!ENTITY killSubthreadMenu.key "k">
+<!ENTITY watchThreadMenu.label "Watch Thread">
+<!ENTITY watchThreadMenu.accesskey "W">
+<!ENTITY watchThreadMenu.key "w">
+<!ENTITY tagMenu.label "Tag">
+<!ENTITY tagMenu.accesskey "g">
+<!ENTITY tagCmd0.key "0">
+<!ENTITY tagCmd1.key "1">
+<!ENTITY tagCmd2.key "2">
+<!ENTITY tagCmd3.key "3">
+<!ENTITY tagCmd4.key "4">
+<!ENTITY tagCmd5.key "5">
+<!ENTITY tagCmd6.key "6">
+<!ENTITY tagCmd7.key "7">
+<!ENTITY tagCmd8.key "8">
+<!ENTITY tagCmd9.key "9">
+<!ENTITY markMenu.label "Mark">
+<!ENTITY markMenu.accesskey "k">
+<!ENTITY toggleReadCmd.key "m">
+<!ENTITY markAsReadCmd.label "As Read">
+<!ENTITY markAsReadCmd.accesskey "R">
+<!ENTITY markAsUnreadCmd.label "As Unread">
+<!ENTITY markAsUnreadCmd.accesskey "U">
+<!ENTITY markThreadAsReadCmd.label "Thread As Read">
+<!ENTITY markThreadAsReadCmd.accesskey "T">
+<!ENTITY markThreadAsReadCmd.key "r">
+<!ENTITY markReadByDateCmd.label "As Read by Date…">
+<!ENTITY markReadByDateCmd.accesskey "D">
+<!ENTITY markReadByDateCmd.key "c">
+<!ENTITY markAllReadCmd.label "All Read">
+<!ENTITY markAllReadCmd.accesskey "A">
+<!ENTITY markAllReadCmd.key "c">
+<!ENTITY markStarredCmd.label "Add Star">
+<!ENTITY markStarredCmd.accesskey "S">
+<!ENTITY markStarredCmd.key "S">
+<!ENTITY markAsJunkCmd.label "As Junk">
+<!ENTITY markAsJunkCmd.accesskey "J">
+<!ENTITY markAsJunkCmd.key "j">
+<!ENTITY markAsNotJunkCmd.label "As Not Junk">
+<!ENTITY markAsNotJunkCmd.accesskey "N">
+<!ENTITY markAsNotJunkCmd.key "j">
+<!ENTITY recalculateJunkScoreCmd.label "Run Junk Mail Controls">
+<!ENTITY recalculateJunkScoreCmd.accesskey "C">
+<!ENTITY openMessageWindowCmd.label "Open Message">
+<!ENTITY openMessageWindowCmd.accesskey "O">
+<!ENTITY openMessageWindowCmd.key "o">
+<!ENTITY openInConversationCmd.label "Open in Conversation">
+<!ENTITY openInConversationCmd.accesskey "s">
+<!ENTITY openInConversationCmd.key "o">
+<!ENTITY openAttachmentListCmd.label "Attachments">
+<!ENTITY openAttachmentListCmd.accesskey "h">
+<!ENTITY openFeedMessage1.label "When Opening Feed Messages">
+<!ENTITY openFeedMessage1.accesskey "O">
+<!ENTITY openFeedWebPage.label "Open as Web Page">
+<!ENTITY openFeedWebPage.accesskey "W">
+<!ENTITY openFeedSummary.label "Open as Summary">
+<!ENTITY openFeedSummary.accesskey "S">
+<!ENTITY openFeedWebPageInMP.label "Toggle Web Page and Summary in Message Pane">
+<!ENTITY openFeedWebPageInMP.accesskey "T">
+
+<!-- Windows Menu -->
+<!ENTITY windowMenu.label "Window">
+
+<!-- Tools Menu -->
+<!ENTITY tasksMenu.label "Tools">
+<!ENTITY tasksMenu.accesskey "T">
+<!ENTITY messengerCmd.label "Mail &amp; Newsgroups">
+<!ENTITY messengerCmd.accesskey "N">
+<!ENTITY addressBookCmd.label "Address Book">
+<!ENTITY addressBookCmd.accesskey "B">
+<!ENTITY addressBookCmd.key "B">
+<!ENTITY addonNoPrefs.label "No Add-on settings found.">
+<!ENTITY activitymanager.label "Activity Manager">
+<!ENTITY activitymanager.accesskey "v">
+<!ENTITY imAccountsStatus.label "Chat status">
+<!ENTITY imAccountsStatus.accesskey "C">
+<!ENTITY imStatus.available "Available">
+<!ENTITY imStatus.unavailable "Unavailable">
+<!ENTITY imStatus.offline "Offline">
+<!ENTITY imStatus.showAccounts "Show Accounts…">
+<!ENTITY joinChatCmd.label "Join Chat…">
+<!ENTITY joinChatCmd.accesskey "t">
+<!ENTITY savedFiles.label "Saved Files">
+<!ENTITY savedFiles.accesskey "l">
+<!ENTITY savedFiles.key "j">
+<!ENTITY filtersCmd2.label "Message Filters">
+<!ENTITY filtersCmd2.accesskey "F">
+<!ENTITY filtersApply.label "Run Filters on Folder">
+<!ENTITY filtersApply.accesskey "R">
+<!ENTITY filtersApplyToSelection.label "Run Filters on Selected Messages">
+<!ENTITY filtersApplyToSelection.accesskey "u">
+<!ENTITY filtersApplyToMessage.label "Run Filters on Message">
+<!ENTITY filtersApplyToMessage.accesskey "u">
+<!ENTITY runJunkControls.label "Run Junk Mail Controls on Folder">
+<!ENTITY runJunkControls.accesskey "C">
+<!ENTITY deleteJunk.label "Delete Mail Marked as Junk in Folder">
+<!ENTITY deleteJunk.accesskey "D">
+<!ENTITY importCmd.label "Import…">
+<!ENTITY importCmd.accesskey "m">
+<!ENTITY exportCmd.label "Export…">
+<!ENTITY exportCmd.accesskey "x">
+<!ENTITY clearRecentHistory.label "Clear Recent History…">
+<!ENTITY clearRecentHistory.accesskey "H">
+<!ENTITY accountManagerCmd2.label "Account Settings">
+<!ENTITY accountManagerCmd2.accesskey "S">
+<!-- LOCALIZATION NOTE (accountManagerCmdUnix.accesskey):
+ Belongs to accountManagerCmd.label, which is placed under the Edit menu
+ on Unix systems
+ -->
+<!ENTITY accountManagerCmdUnix2.accesskey "A">
+
+<!-- Developer Tools Submenu -->
+<!ENTITY devtoolsMenu.label "Developer Tools">
+<!ENTITY devtoolsMenu.accesskey "e">
+<!ENTITY devToolboxCmd.label "Developer Toolbox">
+<!ENTITY devToolboxCmd.accesskey "T">
+<!ENTITY devToolboxCmd.commandkey "i">
+<!ENTITY debugAddonsCmd.label "Debug Add-ons">
+<!ENTITY debugAddonsCmd.accesskey "A">
+<!ENTITY errorConsoleCmd.label "Error Console">
+<!ENTITY errorConsoleCmd.accesskey "E">
+<!ENTITY errorConsoleCmd.commandkey "j">
+
+<!-- Mail Toolbar -->
+<!ENTITY getMsgButton1.label "Get Messages">
+<!ENTITY newMsgButton.label "Write">
+<!ENTITY replyButton.label "Reply">
+<!ENTITY replyAllButton.label "Reply All">
+<!ENTITY replyListButton.label "Reply to List">
+<!ENTITY forwardButton.label "Forward">
+<!ENTITY fileButton.label "File">
+<!ENTITY archiveButton.label "Archive">
+<!ENTITY openConversationButton.label "Conversation">
+<!ENTITY nextButton.label "Next">
+<!ENTITY nextButtonToolbarItem.label "Next Unread">
+<!ENTITY nextMsgButton.label "Next">
+<!ENTITY previousButton.label "Previous">
+<!ENTITY previousButtonToolbarItem.label "Previous Unread">
+<!ENTITY previousMsgButton.label "Previous">
+<!ENTITY backButton1.label "Back">
+<!ENTITY goForwardButton1.label "Forward">
+<!ENTITY deleteItem.title "Delete">
+<!ENTITY markButton.label "Mark">
+<!ENTITY printButton.label "Print">
+<!ENTITY stopButton.label "Stop">
+<!ENTITY throbberItem.title "Activity Indicator">
+<!ENTITY junkItem.title "Junk">
+<!ENTITY addressBookButton.label "Address Book">
+<!ENTITY chatButton.label "Chat">
+<!ENTITY glodaSearch.title "Global Search">
+<!ENTITY searchItem.title "Quick Search">
+<!ENTITY mailViewsToolbarItem.title "Mail Views">
+<!ENTITY folderLocationToolbarItem.title "Folder Location">
+<!ENTITY tagButton.label "Tag">
+<!ENTITY compactButton.label "Compact">
+<!ENTITY appmenuButton.label "AppMenu">
+
+<!-- Mail Toolbar Tooltips-->
+<!ENTITY advancedButton.tooltip "Advanced message search">
+<!ENTITY getMsgButton.tooltip "Get new messages">
+<!ENTITY getAllNewMsgCmd.label "Get All New Messages">
+<!ENTITY getAllNewMsgCmd.accesskey "G">
+<!ENTITY newMsgButton.tooltip "Create a new message">
+<!ENTITY replyButton.tooltip "Reply to the message">
+<!ENTITY replyAllButton.tooltip "Reply to sender and all recipients">
+<!ENTITY replyListButton.tooltip "Reply to mailing list">
+<!ENTITY forwardButton.tooltip "Forward selected message">
+<!ENTITY forwardAsInline.tooltip "Forward selected message as inline text">
+<!ENTITY forwardAsAttachment.tooltip "Forward selected message as an attachment">
+<!ENTITY fileButton.tooltip "File selected message">
+<!ENTITY archiveButton.tooltip "Archive selected messages">
+<!ENTITY openMsgConversationButton.tooltip "Show conversation of selected message">
+<!ENTITY nextButton.tooltip "Move to the next unread message">
+<!ENTITY nextMsgButton.tooltip "Move to the next message">
+<!ENTITY previousButton.tooltip "Move to the previous unread message">
+<!ENTITY previousMsgButton.tooltip "Move to the previous message">
+<!ENTITY goForwardButton.tooltip "Go forward one message">
+<!ENTITY goBackButton.tooltip "Go back one message">
+<!ENTITY markButton.tooltip "Mark messages">
+<!ENTITY printButton.tooltip "Print this message">
+<!ENTITY stopButton.tooltip "Stop the current transfer">
+<!ENTITY addressBookButton.tooltip "Go to the address book">
+<!ENTITY chatButton.tooltip "Show the Chat tab">
+<!ENTITY tagButton.tooltip "Tag messages">
+<!ENTITY compactButton.tooltip "Remove deleted messages from selected folder">
+<!ENTITY appmenuButton1.tooltip "Display the &brandShortName; Menu">
+
+<!-- Toolbar Button Popup -->
+<!ENTITY buttonMenuForwardAsInline.label "Forward Inline">
+<!ENTITY buttonMenuForwardAsAttachment.label "Forward As Attachment">
+
+<!-- Remote Content Button Popup -->
+<!ENTITY remoteContentOptionsAllowForMsg.label "Show remote content in this message">
+<!ENTITY remoteContentOptionsAllowForMsg.accesskey "S">
+<!ENTITY editRemoteContentSettings.label "Edit remote content options…">
+<!ENTITY editRemoteContentSettings.accesskey "E">
+<!ENTITY editRemoteContentSettingsUnix.label "Edit remote content preferences…">
+<!ENTITY editRemoteContentSettingsUnix.accesskey "E">
+
+<!-- Phishing Button Popup -->
+<!ENTITY phishingOptionIgnore.label "Ignore warning for this message">
+<!ENTITY phishingOptionIgnore.accesskey "n">
+<!ENTITY phishingOptionSettings.label "Edit scam detection options…">
+<!ENTITY phishingOptionSettings.accesskey "d">
+<!ENTITY phishingOptionSettingsUnix.label "Edit scam detection preferences…">
+<!ENTITY phishingOptionSettingsUnix.accesskey "d">
+
+<!-- AppMenu Popup -->
+<!ENTITY appmenuNewMsgCmd.label "New Message">
+<!ENTITY appmenuNewContactCmd.label "Address Book Contact…">
+<!ENTITY appmenuToolbarLayout.label "Toolbar Layout…">
+
+<!-- Tags Menu Popup -->
+<!ENTITY addNewTag.label "New Tag…">
+<!ENTITY addNewTag.accesskey "N">
+<!ENTITY manageTags.label "Manage Tags…">
+<!ENTITY manageTags.accesskey "M">
+
+<!-- Folder Pane -->
+<!ENTITY folderNameColumn.label "Name">
+<!ENTITY folderUnreadColumn.label "Unread">
+<!ENTITY folderTotalColumn.label "Total">
+<!ENTITY folderSizeColumn.label "Size">
+
+<!-- Folder Pane Context Menu -->
+<!ENTITY folderContextGetMessages.label "Get Messages">
+<!ENTITY folderContextGetMessages.accesskey "G">
+<!ENTITY folderContextMarkAllFoldersRead.label "Mark All Folders Read">
+<!ENTITY folderContextPauseAllUpdates.label "Pause All Updates">
+<!ENTITY folderContextPauseUpdates.label "Pause Updates">
+<!ENTITY folderContextPauseUpdates.accesskey "U">
+<!ENTITY folderContextOpenInNewWindow.label "Open in New Window">
+<!ENTITY folderContextOpenInNewWindow.accesskey "O">
+<!ENTITY folderContextOpenNewTab.label "Open in New Tab">
+<!ENTITY folderContextOpenNewTab.accesskey "T">
+<!ENTITY folderContextNew.label "New Subfolder…">
+<!ENTITY folderContextNew.accesskey "N">
+<!ENTITY folderContextRename.label "Rename">
+<!ENTITY folderContextRename.accesskey "R">
+<!ENTITY folderContextRemove.label "Delete">
+<!ENTITY folderContextRemove.accesskey "D">
+<!ENTITY folderContextCompact.label "Compact">
+<!ENTITY folderContextCompact.accesskey "C">
+<!ENTITY folderContextEmptyTrash.label "Empty Trash">
+<!ENTITY folderContextEmptyTrash.accesskey "y">
+<!ENTITY folderContextEmptyJunk.label "Empty Junk">
+<!ENTITY folderContextEmptyJunk.accesskey "J">
+<!ENTITY folderContextSendUnsentMessages.label "Send Unsent Messages">
+<!ENTITY folderContextSendUnsentMessages.accesskey "d">
+<!ENTITY folderContextUnsubscribe.label "Unsubscribe">
+<!ENTITY folderContextUnsubscribe.accesskey "U">
+<!ENTITY folderContextMarkNewsgroupRead.label "Mark Newsgroup Read">
+<!ENTITY folderContextMarkNewsgroupRead.accesskey "k">
+<!ENTITY folderContextMarkMailFolderRead.label "Mark Folder Read">
+<!ENTITY folderContextMarkMailFolderRead.accesskey "k">
+<!ENTITY folderContextSubscribe.label "Subscribe…">
+<!ENTITY folderContextSubscribe.accesskey "b">
+<!ENTITY folderContextSearchForMessages.label "Search Messages…">
+<!ENTITY folderContextSearchForMessages.accesskey "S">
+<!ENTITY folderContextProperties2.label "Properties">
+<!ENTITY folderContextProperties2.accesskey "P">
+<!ENTITY folderContextFavoriteFolder.label "Favorite Folder">
+<!ENTITY folderContextFavoriteFolder.accesskey "a">
+<!ENTITY folderContextSettings2.label "Settings">
+<!ENTITY folderContextSettings2.accesskey "e">
+
+<!-- Search Bar -->
+<!ENTITY SearchNameOrEmail.label "Name or Email contains:">
+<!ENTITY SearchNameOrEmail.accesskey "N">
+
+<!-- Gloda Search Bar -->
+<!ENTITY glodaSearchBar.placeholder "Search messages…">
+
+<!-- Quick Search Menu Bar -->
+<!ENTITY searchSubjectMenu.label "Subject">
+<!ENTITY searchFromMenu.label "From">
+<!ENTITY searchSubjectOrFromMenu.label "Subject or From">
+<!ENTITY searchRecipient.label "To or Cc">
+<!ENTITY searchSubjectOrRecipientMenu.label "Subject, To or Cc">
+<!ENTITY searchMessageBody.label "Entire Message">
+<!ENTITY saveAsVirtualFolderMenu.label "Save Search as a Folder…">
+
+<!-- Thread Pane -->
+<!ENTITY selectColumn.label "Select Messages">
+<!ENTITY threadColumn.label "Thread">
+<!ENTITY fromColumn.label "From">
+<!ENTITY recipientColumn.label "Recipient">
+<!ENTITY correspondentColumn.label "Correspondents">
+<!ENTITY subjectColumn.label "Subject">
+<!ENTITY dateColumn.label "Date">
+<!ENTITY priorityColumn.label "Priority">
+<!ENTITY tagsColumn.label "Tag">
+<!ENTITY accountColumn.label "Account">
+<!ENTITY statusColumn.label "Status">
+<!ENTITY sizeColumn.label "Size">
+<!ENTITY junkStatusColumn.label "Junk Status">
+<!ENTITY unreadColumn.label "Unread">
+<!ENTITY totalColumn.label "Total">
+<!ENTITY readColumn.label "Read">
+<!ENTITY receivedColumn.label "Received">
+<!ENTITY starredColumn.label "Starred">
+<!ENTITY locationColumn.label "Location">
+<!ENTITY idColumn.label "Order Received">
+<!ENTITY attachmentColumn.label "Attachments">
+<!ENTITY deleteColumn.label "Delete">
+
+<!-- Thread Pane Tooltips -->
+<!ENTITY columnChooser2.tooltip "Select columns to display">
+<!ENTITY selectColumn.tooltip "Toggle select all messages">
+<!ENTITY threadColumn2.tooltip "Display message threads">
+<!ENTITY fromColumn2.tooltip "Sort by from">
+<!ENTITY recipientColumn2.tooltip "Sort by recipient">
+<!ENTITY correspondentColumn2.tooltip "Sort by correspondents">
+<!ENTITY subjectColumn2.tooltip "Sort by subject">
+<!ENTITY dateColumn2.tooltip "Sort by date">
+<!ENTITY priorityColumn2.tooltip "Sort by priority">
+<!ENTITY tagsColumn2.tooltip "Sort by tags">
+<!ENTITY accountColumn2.tooltip "Sort by account">
+<!ENTITY statusColumn2.tooltip "Sort by status">
+<!ENTITY sizeColumn2.tooltip "Sort by size">
+<!ENTITY junkStatusColumn2.tooltip "Sort by junk status">
+<!ENTITY unreadColumn2.tooltip "Number of unread messages in thread">
+<!ENTITY totalColumn2.tooltip "Total number of messages in thread">
+<!ENTITY readColumn2.tooltip "Sort by read">
+<!ENTITY receivedColumn2.tooltip "Sort by date received">
+<!ENTITY starredColumn2.tooltip "Sort by star">
+<!ENTITY locationColumn2.tooltip "Sort by location">
+<!ENTITY idColumn2.tooltip "Sort by order received">
+<!ENTITY attachmentColumn2.tooltip "Sort by attachments">
+<!ENTITY deleteColumn.tooltip "Delete a message">
+
+<!-- Thread Pane Context Menu -->
+<!ENTITY contextNewMsgFromTemplate.label "New Message from Template">
+<!ENTITY contextOpenNewWindow.label "Open Message in New Window">
+<!ENTITY contextOpenNewWindow.accesskey "W">
+<!-- The contextOpenNewTab.accesskey ("T") potentially conflicts with
+ cutCmd.accessKey which is defined in textcontext.dtd from toolkit. Right
+ now, both menu items can't be visible at the same time, but should someone
+ enable copy/paste of message, this key would probably need to be changed. -->
+<!ENTITY contextOpenNewTab.label "Open Message in New Tab">
+<!ENTITY contextOpenNewTab.accesskey "T">
+<!ENTITY contextOpenConversation.label "Open Message in Conversation">
+<!ENTITY contextOpenConversation.accesskey "n">
+<!ENTITY contextOpenContainingFolder.label "Open Message in Containing Folder">
+<!ENTITY contextOpenContainingFolder.accesskey "n">
+<!ENTITY contextEditMsgAsNew.label "Edit As New Message">
+<!ENTITY contextEditMsgAsNew.accesskey "E">
+<!ENTITY contextEditDraftMsg.label "Edit Draft Message">
+<!ENTITY contextEditTemplate.label "Edit Template">
+<!ENTITY contextEditTemplate.accesskey "T">
+<!ENTITY contextArchive.label "Archive">
+<!ENTITY contextArchive.accesskey "h">
+<!ENTITY contextReplySender.label "Reply to Sender Only">
+<!ENTITY contextReplySender.accesskey "R">
+<!ENTITY contextReplyNewsgroup2.label "Followup to Newsgroup">
+<!ENTITY contextReplyNewsgroup2.accesskey "u">
+<!ENTITY contextReplyAll.label "Reply to All">
+<!ENTITY contextReplyAll.accesskey "A">
+<!ENTITY contextReplyList.label "Reply to List">
+<!ENTITY contextReplyList.accesskey "L">
+<!ENTITY contextForward.label "Forward">
+<!ENTITY contextForward.accesskey "F">
+<!ENTITY contextForwardAsMenu.label "Forward As">
+<!ENTITY contextForwardAsMenu.accesskey "o">
+<!ENTITY contextForwardAsInline.label "Inline">
+<!ENTITY contextForwardAsInline.accesskey "I">
+<!ENTITY contextForwardAsAttachmentItem.label "Attachment">
+<!ENTITY contextForwardAsAttachmentItem.accesskey "A">
+<!ENTITY contextMultiForwardAsAttachment.label "Forward as Attachments">
+<!ENTITY contextMultiForwardAsAttachment.accesskey "o">
+<!ENTITY contextMoveMsgMenu.label "Move To">
+<!ENTITY contextMoveMsgMenu.accesskey "M">
+<!ENTITY contextMoveCopyMsgRecentMenu.label "Recent">
+<!ENTITY contextMoveCopyMsgRecentMenu.accesskey "R">
+<!ENTITY contextMoveCopyMsgFavoritesMenu.label "Favorites">
+<!ENTITY contextMoveCopyMsgFavoritesMenu.accesskey "F">
+<!ENTITY contextCopyMsgMenu.label "Copy To">
+<!ENTITY contextCopyMsgMenu.accesskey "C">
+<!ENTITY contextKillThreadMenu.label "Ignore Thread">
+<!ENTITY contextKillSubthreadMenu.accesskey "b">
+<!ENTITY contextKillThreadMenu.accesskey "I">
+<!ENTITY contextKillSubthreadMenu.label "Ignore Subthread">
+<!ENTITY contextWatchThreadMenu.label "Watch Thread">
+<!-- LOCALIZATION NOTE (contextWatchThreadMenu.accesskey):
+ In the en-US locale we ran out of access keys, so there is an empty access key for
+ Watch Thread. Localizers can pick a suitable key
+ -->
+<!ENTITY contextWatchThreadMenu.accesskey "">
+<!ENTITY contextSaveAs.label "Save As…">
+<!ENTITY contextSaveAs.accesskey "S">
+<!ENTITY contextPrint.label "Print…">
+<!ENTITY contextPrint.accesskey "P">
+<!ENTITY contextPrintPreview.label "Print Preview">
+<!ENTITY contextPrintPreview.accesskey "v">
+
+<!-- LOCALIZATION NOTE (columnPicker.applyTo.label):
+ This option in the thread pane column picker pops up a sub-menu containing
+ the "columnPicker.applyToFolder.label" and
+ "columnPicker.applyToFolderAndChildren.label" options. This item indicates
+ a desire to apply the currently displayed set of columns to some other
+ folder(s). The sub-menu items indicate whether we want to apply it to just
+ a folder or also its children.
+ -->
+<!ENTITY columnPicker.applyTo.label "Apply columns to…">
+<!-- LOCALIZATION NOTE (columnPicker.applyToFolder.label):
+ This option in the thread pane column picker is found on a sub-menu beneath
+ the "columnPicker.applyTo.label" alongside
+ "columnPicker.applyToFolderAndChildren.label". It indicates a desire to
+ apply the currently display thread pane column settings to a single folder
+ that the user selects using the same widget as the move to/copy to
+ mechanism (via a series of popups).
+ -->
+<!ENTITY columnPicker.applyToFolder.label "Folder…">
+<!-- LOCALIZATION NOTE (columnPicker.applyToFolderAndChildren.label):
+ This option in the thread pane column picker is found on a sub-menu beneath
+ the "columnPicker.applyTo.label" alongside
+ "columnPicker.applyToFolder.label". It indicates a desire to
+ apply the currently display thread pane column settings to a folder and all
+ of its descendents. The user selects the folder using the same widget as the
+ move to/copy to mechanism (via a series of popups).
+ -->
+<!ENTITY columnPicker.applyToFolderAndChildren.label "Folder and its children…">
+<!-- LOCALIZATION NOTE (columnPicker.thisFolder.label):
+ This is used in the folder selection widget for the
+ "columnPicker.applyToFolder.label" and
+ "columnPicker.applyToFolderAndChildren.label" menu options. Whenever
+ a folder has children, it results in a menu popup; the first menu item
+ in that popup is given this label to indicate that that folder should be
+ selected. For example, if folder "A" has two children, "B" and "C", then
+ when the user hovers over "A", a new popup menu will be displayed whose
+ items are "This folder", "B", and "C". This is the equivalent of the
+ "File here" option for the move to/copy to widget.
+ -->
+<!ENTITY columnPicker.thisFolder.label "This folder">
+
+<!-- Media (video/audio) controls -->
+<!ENTITY contextPlay.label "Play">
+<!ENTITY contextPlay.accesskey "P">
+<!ENTITY contextPause.label "Pause">
+<!ENTITY contextPause.accesskey "P">
+<!ENTITY contextMute.label "Mute">
+<!ENTITY contextMute.accesskey "M">
+<!ENTITY contextUnmute.label "Unmute">
+<!ENTITY contextUnmute.accesskey "m">
+
+<!-- Quick Search Bar -->
+<!-- LOCALIZATION NOTE (quickSearchCmd.key):
+ This is actually the key used for the global message search box; we have
+ not changed
+ -->
+<!ENTITY quickSearchCmd.key "k">
+<!-- LOCALIZATION NOTE (search.label.base1):
+ This is the base of the empty text for the global search box. We replace
+ #1 with the contents of the appropriate search.keyLabel.* value for the
+ platform.
+ The goal is to convey to the user that typing in the box will allow them
+ to search for messages globally and that there is a hotkey they can press
+ to get to the box faster. If the global indexer is disabled, the search
+ box will be collapsed and the user will never see this message.
+ -->
+<!ENTITY search.label.base1 "Search #1">
+<!-- LOCALIZATION NOTE (search.keyLabel.nonmac):
+ The description of the key-binding to get into the global search box on
+ windows and linux (which use the control key). We use the key defined in
+ the quickSearchCmd.key entity defined above, the letter should match it.
+ -->
+<!ENTITY search.keyLabel.nonmac "&lt;Ctrl+K&gt;">
+<!-- LOCALIZATION NOTE (search.keyLabel.mac):
+ The description of the key-binding to get into the global search box on mac
+ systems. We use the key defined in the quickSearchCmd.key entity defined
+ above, the letter should match it.
+ -->
+<!ENTITY search.keyLabel.mac "&lt;&#x2318;K&gt;">
+
+<!-- Message Header Context Menu -->
+<!ENTITY AddToAddressBook.label "Add to Address Book…">
+<!ENTITY AddToAddressBook.accesskey "B">
+<!ENTITY AddDirectlyToAddressBook.label "Add to Address Book">
+<!ENTITY AddDirectlyToAddressBook.accesskey "B">
+<!ENTITY EditContact1.label "Edit Contact">
+<!ENTITY EditContact1.accesskey "E">
+<!ENTITY ViewContact.label "View Contact">
+<!ENTITY ViewContact.accesskey "V">
+<!ENTITY SubscribeToNewsgroup.label "Subscribe to Newsgroup">
+<!ENTITY SubscribeToNewsgroup.accesskey "N">
+<!ENTITY SendMessageTo.label "Compose Message To">
+<!ENTITY SendMessageTo.accesskey "s">
+<!ENTITY CopyEmailAddress.label "Copy Email Address">
+<!ENTITY CopyEmailAddress.accesskey "C">
+<!ENTITY CopyNameAndEmailAddress.label "Copy Name and Email Address">
+<!ENTITY CopyNameAndEmailAddress.accesskey "N">
+<!ENTITY CopyNewsgroupName.label "Copy Newsgroup Name">
+<!ENTITY CopyNewsgroupName.accesskey "C">
+<!ENTITY CopyNewsgroupURL.label "Copy Newsgroup URL">
+<!ENTITY CopyNewsgroupURL.accesskey "U">
+<!ENTITY CreateFilterFrom.label "Create Filter From…">
+<!ENTITY CreateFilterFrom.accesskey "F">
+<!ENTITY reportPhishingURL.label "Report Email Scam">
+<!ENTITY reportPhishingURL.accesskey "o">
+
+<!-- Spell checker context menu items -->
+<!ENTITY spellAddDictionaries.label "Add Dictionaries…">
+<!ENTITY spellAddDictionaries.accesskey "A">
+
+<!-- Content Pane Context Menu -->
+<!ENTITY saveLinkAsCmd.label "Save Link As…">
+<!ENTITY saveLinkAsCmd.accesskey "k">
+<!ENTITY saveImageAsCmd.label "Save Image As…">
+<!ENTITY saveImageAsCmd.accesskey "v">
+<!ENTITY copyLinkCmd.label "Copy Link Location">
+<!ENTITY copyLinkCmd.accesskey "L">
+<!ENTITY copyImageAllCmd.label "Copy Image">
+<!ENTITY copyImageAllCmd.accesskey "I">
+<!ENTITY copyEmailCmd.label "Copy Email Address">
+<!ENTITY copyEmailCmd.accesskey "E">
+<!ENTITY openInBrowser.label "Open In Browser">
+<!ENTITY openInBrowser.accesskey "O">
+<!ENTITY openLinkInBrowser.label "Open Link In Browser">
+<!ENTITY openLinkInBrowser.accesskey "O">
+
+<!-- Statusbar -->
+<!ENTITY statusText.label "Done">
+
+<!-- Mac OS X Window Menu -->
+<!ENTITY minimizeWindow.label "Minimize">
+<!ENTITY minimizeWindow.key "m">
+<!ENTITY bringAllToFront.label "Bring All to Front">
+<!ENTITY zoomWindow.label "Zoom">
+
+<!-- Mac OS X Application Menu (Cocoa widgets) -->
+<!ENTITY preferencesCmdMac2.label "Preferences">
+<!ENTITY preferencesCmdMac.commandkey ",">
+<!ENTITY preferencesCmdMac.modifiers "accel">
+<!ENTITY servicesMenuMac.label "Services">
+<!ENTITY hideThisAppCmdMac.label "Hide &brandShortName;">
+<!ENTITY hideThisAppCmdMac.commandkey "H">
+<!ENTITY hideThisAppCmdMac.modifiers "accel">
+<!ENTITY hideOtherAppsCmdMac.label "Hide Others">
+<!ENTITY hideOtherAppsCmdMac.commandkey "H">
+<!ENTITY hideOtherAppsCmdMac.modifiers "accel,alt">
+<!ENTITY showAllAppsCmdMac.label "Show All">
+
+<!-- Mac OS X Dock Icon pop-up menu -->
+<!ENTITY dockOptions.label "App Icon Options…">
+<!ENTITY writeNewMessageDock.label "Write New Message">
+<!ENTITY openAddressBookDock.label "Open Address Book">
+
+<!-- Content tab Navigation buttons -->
+<!ENTITY browseBackButton.tooltip "Go back one page">
+<!ENTITY browseForwardButton.tooltip "Go forward one page">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messenger.properties b/comm/mail/locales/en-US/chrome/messenger/messenger.properties
new file mode 100644
index 0000000000..a879073dca
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messenger.properties
@@ -0,0 +1,758 @@
+# 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 following are used by the messenger application
+#
+
+# LOCALIZATION NOTE(statusMessage):
+# Do not translate the words %1$S and %2$S below. Place the word %1$S where the
+# account name should appear and %2$S where the status message should appear.
+# EXAMPLE: Jim's Account: Downloading messages...
+statusMessage=%1$S: %2$S
+
+removeAccount=Delete Account…
+newFolderMenuItem=Folder…
+newSubfolderMenuItem=Subfolder…
+newFolder=New Folder…
+newSubfolder=New Subfolder…
+markFolderRead=Mark Folder Read;Mark Folders Read
+markNewsgroupRead=Mark Newsgroup Read;Mark Newsgroups Read
+folderProperties=Folder Properties
+newTag=New Tag…
+# LOCALIZATION NOTE (getNextNewsMessages): Semi-colon list of plural forms.
+# #1 is the number of news messages to get.
+getNextNewsMessages=Get Next #1 News Message;Get Next #1 News Messages
+advanceNextPrompt=Advance to next unread message in %S?
+titleNewsPreHost=on
+replyToSender=Reply to Sender
+reply=Reply
+EMLFiles=Mail Files
+OpenEMLFiles=Open Message
+# LOCALIZATION NOTE(defaultSaveMessageAsFileName): Do not translate ".eml"
+# in the line below. Also, the complete file name should be 8.3.
+defaultSaveMessageAsFileName=message.eml
+# LOCALIZATION NOTE(longMsgSubjectTruncator): A suffix string appended to the filename
+# (created from message subject) if it needed to be truncated due to length.
+longMsgSubjectTruncator=...
+SaveMailAs=Save Message As
+SaveAttachment=Save Attachment
+SaveAllAttachments=Save All Attachments
+DetachAttachment=Detach Attachment
+DetachAllAttachments=Detach All Attachments
+ChooseFolder=Choose Folder
+MessageLoaded=Message loaded…
+PreviewTitle=%S - %S
+saveAttachmentFailed=Unable to save the attachment. Please check your file name and try again later.
+saveMessageFailed=Unable to save the message. Please check your file name and try again later.
+fileExists=%S already exists. Do you want to replace it?
+# LOCALIZATION NOTE(failedToReadFile): %1$S is replaced by the file name, %2$S is replaced by the reason the file load failed.
+failedToReadFile=Failed to read file: %1$S reason: %2$S
+
+downloadingNewsgroups=Downloading Newsgroups for Offline Use
+downloadingMail=Downloading Mail for Offline Use
+sendingUnsent=Sending Unsent Messages
+
+folderExists=A folder with that name already exists. Please enter a different name.
+# LOCALIZATION NOTE(confirmDuplicateFolderRename): %1$S is name of folder being moved, %2$S is parent folder name, %3$S is proposed new folder name
+confirmDuplicateFolderRename=A subfolder with the name '%1$S' already exists in the folder '%2$S'. Would you like to move this folder using the new name '%3$S'?
+folderCreationFailed=The folder could not be created because the folder name you specified contains an unrecognized character. Please enter a different name and try again.
+
+compactingFolder=Compacting folder %S…
+# LOCALIZATION NOTE(compactingDone): %1$S is the compaction gain.
+compactingDone=Done compacting (approx. %1$S saved).
+
+confirmFolderDeletionForFilter=Deleting the folder '%S' will disable its associated filter(s). Are you sure you want to delete the folder?
+alertFilterChanged=Filters associated with this folder will be updated.
+filterDisabled=The folder '%S' could not be found, so filter(s) associated with this folder will be disabled. Verify that the folder exists, and that filters point to a valid destination folder.
+filterFolderDeniedLocked=The messages could not be filtered to folder '%S' because another operation is in progress.
+parsingFolderFailed=Unable to open the folder %S because it is in use by some other operation. Please wait for that operation to finish and then select the folder again.
+deletingMsgsFailed=Unable to delete messages in folder %S because it is in use by some other operation. Please wait for that operation to finish and then try again.
+alertFilterCheckbox=Do not warn me again.
+compactFolderDeniedLock=The folder '%S' cannot be compacted because another operation is in progress. Please try again later.
+compactFolderWriteFailed=The folder '%S' could not be compacted because writing to folder failed. Verify that you have enough disk space, and that you have write privileges to the file system, then try again.
+compactFolderInsufficientSpace=Some folders (e.g. '%S') cannot be compacted because there is not enough free disk space. Please delete some files and try again.
+filterFolderHdrAddFailed=The messages could not be filtered to folder '%S' because adding a message to it failed. Verify that the folder is displaying properly or try to repair it from the folder properties.
+filterFolderWriteFailed=The messages could not be filtered to folder '%S' because writing to folder failed. Verify that you have enough disk space, and that you have write privileges to the file system, then try again.
+copyMsgWriteFailed=The messages could not be moved or copied to folder '%S' because writing to folder failed. To gain disk space, from the File menu, first choose Empty Trash, and then choose Compact Folders, and then try again.
+cantMoveMsgWOBodyOffline=While working offline, you cannot move or copy messages that were not downloaded for offline use. From the Mail window, open the File menu, choose Offline, then uncheck Work Offline, and then try again.
+operationFailedFolderBusy=The operation failed because another operation is using the folder. Please wait for that operation to finish and then try again.
+folderRenameFailed=The folder could not be renamed. Perhaps the folder is being reparsed, or the new name is not a valid folder name.
+# LOCALIZATION NOTE(verboseFolderFormat): %1$S is folder name, %2$S is server name
+verboseFolderFormat=%1$S on %2$S
+# LOCALIZATION NOTE(filterFolderTruncateFailed): %1$S is replaced by the folder name, %2$S is replaced by the brandShortName
+filterFolderTruncateFailed=There was an error truncating the Inbox after filtering a message to folder '%1$S'. You may need to shutdown %2$S and delete INBOX.msf.
+
+mailboxTooLarge=The folder %S is full, and can't hold any more messages. To make room for more messages, delete any old or unwanted mail and compact the folder.
+outOfDiskSpace=There is not enough disk space to download new messages. Try deleting old mail, emptying the Trash folder, and compacting your mail folders, and then try again.
+errorGettingDB=Unable to open the summary file for %S. Perhaps there was an error on disk, or the full path is too long.
+defaultServerTag=(Default)
+
+# Used in message database list view to provide a text value for graphic based cells.
+messageUnread=Unread
+messageHasFlag=Starred
+messageHasAttachment=Has Attachment
+messageJunk=Junk
+messageExpanded=Expanded
+messageCollapsed=Collapsed
+
+# Used in the SMTP Account Settings panel when a server value has no properties
+smtpServerList-NotSpecified=<not specified>
+smtpServer-ConnectionSecurityType-0=None
+smtpServer-ConnectionSecurityType-1=STARTTLS, if available
+smtpServer-ConnectionSecurityType-2=STARTTLS
+smtpServer-ConnectionSecurityType-3=SSL/TLS
+smtpServers-confirmServerDeletionTitle=Delete Server
+smtpServers-confirmServerDeletion=Are you sure you want to delete the server: \n %S?
+
+# Account Settings - Both Incoming and SMTP server
+authNo=No authentication
+authOld=Password, original method (insecure)
+authPasswordCleartextInsecurely=Password, transmitted insecurely
+authPasswordCleartextViaSSL=Normal password
+authPasswordEncrypted=Encrypted password
+authKerberos=Kerberos / GSSAPI
+authExternal=TLS Certificate
+authNTLM=NTLM
+authOAuth2=OAuth2
+authAnySecure=Any secure method (deprecated)
+authAny=Any method (insecure)
+
+# OAuth2 window title
+# LOCALIZATION NOTE(oauth2WindowTitle):
+# %1$S is the username (or full email address) used for authentication.
+# %2$S is the hostname of the account being authenticated.
+oauth2WindowTitle=Enter credentials for %1$S on %2$S
+
+# LOCALIZATION NOTE(serverType-nntp): Do not translate "NNTP" in the line below
+serverType-nntp=News Server (NNTP)
+# LOCALIZATION NOTE(serverType-pop3): Do not translate "POP" in the line below
+serverType-pop3=POP Mail Server
+# LOCALIZATION NOTE(serverType-imap): Do not translate "IMAP" in the line below
+serverType-imap=IMAP Mail Server
+serverType-none=Local Mail Store
+
+sizeColumnTooltip2=Sort by size
+sizeColumnHeader=Size
+linesColumnTooltip2=Sort by lines
+linesColumnHeader=Lines
+
+# LOCALIZATION NOTE (getMsgButtonTooltip): Do not translate the word "%S" below.
+# Place the word "%S" in your translation where the name of the comma separated accounts should appear.
+getMsgButtonTooltip=Get new messages for %S
+# Used to separate email addresses in a list. Note the trailing space ', '
+getMsgButtonTooltip.listSeparator=,\u0020
+
+# status feedback stuff
+documentDone=
+documentLoading=Loading Message…
+
+# LOCALIZATION NOTE (autosyncProgress): Do not translate the word "%1$S" or "%2$S" below.
+# Place the word %1$S in your translation where the name of the comma separated folders should appear.
+# Place the word %2$S in your translation where the name of the comma separated accounts should appear.
+autosyncProgress=Synchronizing messages in %1$S from %2$S…
+
+# localized folder names
+
+localFolders=Local Folders
+
+# LOCALIZATION NOTE (inboxFolderName): OK to translate all foldernames, bugzilla #57440 & bugzilla #23625 fixed
+inboxFolderName=Inbox
+trashFolderName=Trash
+sentFolderName=Sent
+draftsFolderName=Drafts
+templatesFolderName=Templates
+outboxFolderName=Outbox
+junkFolderName=Junk
+archivesFolderName=Archives
+
+# "Normal" priority is often blank,
+# depending on the consumers of these strings
+priorityLowest=Lowest
+priorityLow=Low
+priorityNormal=Normal
+priorityHigh=High
+priorityHighest=Highest
+
+#Group by date thread pane titles
+today=Today
+yesterday=Yesterday
+lastWeek=Last Week
+last7Days=Last 7 Days
+twoWeeksAgo=Two Weeks Ago
+last14Days=Last 14 Days
+older=Older
+futureDate=Future
+
+#Grouped By Tags
+untaggedMessages=Untagged Messages
+
+# Grouped by status
+messagesWithNoStatus=No Status
+
+#Grouped by priority
+noPriority=No Priority
+
+#Grouped by has attachments
+noAttachments=No Attachments
+attachments=Attachments
+
+#Grouped by starred
+notFlagged=Not Starred
+groupFlagged=Starred
+
+# defaults descriptions for tag prefs listed in mailnews.js
+# (we keep the .labels. names for backwards compatibility)
+mailnews.tags.remove=Remove All Tags
+mailnews.labels.description.1=Important
+mailnews.labels.description.2=Work
+mailnews.labels.description.3=Personal
+mailnews.labels.description.4=To Do
+mailnews.labels.description.5=Later
+
+# Format definition tag menu texts.
+# This is necessary in order to get the accesskeys to be the on the first
+# character of the menu text instead of after the menu text.
+# If a key definition exists for the tag at index n, that key's key will be
+# taken as the accesskey, eg.
+# <key id="key_tag3" key="&tagCmd3.key;" oncommand="ToggleMessageTagKey(3);"/>
+# makes the third tag have the accesskey &tagCmd3.key;.
+# In the menuitem's label, this accesskey appears at %1$S below; %2$S will be
+# replaced by the tag label.
+mailnews.tags.format=%1$S %2$S
+
+replied=Replied
+forwarded=Forwarded
+redirected=Redirected
+new=New
+read=Read
+flagged=Starred
+
+# for junk status picker in search and mail views
+junk=Junk
+
+# for junk score origin picker in search and mail views
+junkScoreOriginPlugin=Plugin
+junkScoreOriginFilter=Filter
+junkScoreOriginWhitelist=Whitelist
+junkScoreOriginUser=User
+junkScoreOriginImapFlag=IMAP Flag
+
+# for the has attachment picker in search and mail views
+hasAttachments=Has Attachments
+
+# for the Tag picker in search and mail views.
+tag=Tags
+
+# LOCALIZATION NOTE(andOthers):
+# for multiple authors, add this abbreviation to the first author to indicate
+# there are more; for the From column in the threadpane message list.
+andOthers=et al.
+
+# whether to also show phonetic fields in the addressbook
+# LOCALIZATION NOTE(mail.addr_book.show_phonetic_fields):
+# the only valid values are: true OR false (choose from the untranslated English words)
+mail.addr_book.show_phonetic_fields=false
+
+# valid format options are:
+# 1: yyyy/mm/dd
+# 2: yyyy/dd/mm
+# 3: mm/dd/yyyy
+# 4: mm/yyyy/dd
+# 5: dd/mm/yyyy
+# 6: dd/yyyy/mm
+#
+# 0: auto-detect the current locale format
+# a separator has to be either '/', '-', '.' and the year in Christian year
+# otherwise mm/dd/yyyy (option 3) is used
+#
+mailnews.search_date_format=0
+# separator for search date (e.g. "/", "-"), or empty when search_date_format is zero
+mailnews.search_date_separator=
+# leading zeros for day and month values, not used if mailnews.search_date_format is not zero
+mailnews.search_date_leading_zeros=true
+
+# offline msg
+nocachedbodybody2=The body of this message has not been downloaded from \
+the server for reading offline. To read this message, \
+you must reconnect to the network, choose Offline from \
+the File menu and then uncheck Work Offline. \
+In the future, you can select which messages or folders to read offline. To do \
+this, choose Offline from the file menu and then select Download/Sync Now. \
+You can adjust the Disk Space preference to prevent the downloading of large \
+messages.
+
+# LOCALIZATION NOTE(acctCentralTitleFormat): %1$S is brand, %2$S is account type, %3$S is account name
+acctCentralTitleFormat=%1$S %2$S - %3$S
+mailAcctType=Mail
+newsAcctType=News
+feedsAcctType=Feeds
+
+# LOCALIZATION NOTE(nocachedbodytitle): Do not translate "<TITLE>" or "</TITLE>" in the line below
+nocachedbodytitle=<TITLE>Go Online to View This Message</TITLE>\n
+
+# mailWindowOverlay.js
+confirmUnsubscribeTitle=Confirm Unsubscribe
+confirmUnsubscribeText=Are you sure you want to unsubscribe from %S?
+confirmUnsubscribeManyText=Are you sure you want to unsubscribe from these newsgroups?
+restoreAllTabs=Restore All Tabs
+
+confirmMarkAllFoldersReadTitle=Mark All Folders Read
+confirmMarkAllFoldersReadMessage=Are you sure you want to mark all messages in all folders of this account as read?
+
+# LOCALIZATION NOTE(junkBarMessage): %S is brand
+junkBarMessage=%S thinks this message is Junk mail.
+junkBarButton=Not Junk
+junkBarButtonKey=N
+junkBarInfoButton=Learn More
+junkBarInfoButtonKey=L
+
+# LOCALIZATION NOTE(remoteContentBarMessage): %S is brand
+remoteContentBarMessage=To protect your privacy, %S has blocked remote content in this message.
+remoteContentPrefLabel=Options
+remoteContentPrefAccesskey=O
+remoteContentPrefLabelUnix=Preferences
+remoteContentPrefAccesskeyUnix=P
+
+# LOCALIZATION NOTE(remoteAllowResource): %S is origin
+remoteAllowResource=Allow remote content from %S
+# LOCALIZATION NOTE(remoteAllowAll): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# #1 is the number of origins
+remoteAllowAll=Allow remote content from the #1 origin listed above;Allow remote content from all #1 origins listed above
+
+phishingBarMessage=This message may be a scam.
+phishingBarPrefLabel=Options
+phishingBarPrefAccesskey=O
+phishingBarPrefLabelUnix=Preferences
+phishingBarPrefAccesskeyUnix=P
+
+mdnBarIgnoreButton=Ignore Request
+mdnBarIgnoreButtonKey=n
+mdnBarSendReqButton=Send Receipt
+mdnBarSendReqButtonKey=S
+
+draftMessageMsg=This is a draft message.
+draftMessageButton=Edit
+draftMessageButtonKey=E
+
+# msgHdrViewOverlay.js
+openLabel=Open
+openLabelAccesskey=O
+saveLabel=Save As…
+saveLabelAccesskey=A
+detachLabel=Detach…
+detachLabelAccesskey=D
+deleteLabel=Delete
+deleteLabelAccesskey=E
+openFolderLabel=Open Containing Folder
+openFolderLabelAccesskey=F
+deleteAttachments=The following attachments will be permanently deleted from this message:\n%S\nThis action cannot be undone. Do you wish to continue?
+detachAttachments=The following attachments have been successfully saved and will now be permanently deleted from this message:\n%S\nThis action cannot be undone. Do you wish to continue?
+deleteAttachmentFailure=Failed to delete the selected attachments.
+emptyAttachment=This attachment appears to be empty.\nPlease check with the person who sent this.\nOften company firewalls or antivirus programs will destroy attachments.
+externalAttachmentNotFound=This detached file or link attachment is not found or is not accessible at this location anymore.
+
+# LOCALIZATION NOTE (attachmentCount): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# #1 number of attachments
+attachmentCount=#1 attachment;#1 attachments
+
+# LOCALIZATION NOTE (attachmentCountSingle): This is the format for the
+# attachment header when a message has only one attachment. This is separate
+# from attachmentCount above, since attachmentCountSingle typically ends with a
+# colon.
+attachmentCountSingle=1 attachment:
+
+# LOCALIZATION NOTE (attachmentSizeUnknown): The string to show for the total
+# size of all attachments when none of the attachments' sizes can be detected.
+attachmentSizeUnknown=size unknown
+
+# LOCALIZATION NOTE (attachmentSizeAtLeast): The string to show for the total
+# size of all attachments when at least one (but not all) of the attachments'
+# sizes can't be detected. %1$S is the formatted size.
+attachmentSizeAtLeast=at least %1$S
+
+# This is the format for prepending accesskeys to the
+# each of the attachments in the file|attachments menu:
+# ie: 1 file.txt
+# 2 another file.txt
+attachmentDisplayNameFormat=%S %S
+
+# This is the heading for the attachment summary when printing an email
+attachmentsPrintHeader=Attachments:
+
+# Connection Error Messages
+# LOCALIZATION NOTE(unknownHostError): %S is the server name
+unknownHostError=Failed to connect to server %S.
+# LOCALIZATION NOTE(connectionRefusedError): %S is the server name
+connectionRefusedError=Could not connect to server %S; the connection was refused.
+# LOCALIZATION NOTE(netTimeoutError): %S is the server name
+netTimeoutError=Connection to server %S timed out.
+# LOCALIZATION NOTE(netResetError): %S is the server name
+netResetError=Connection to server %S was reset.
+# LOCALIZATION NOTE(netInterruptError): %S is the server name
+netInterruptError=Connection to server %S was interrupted.
+
+recipientSearchCriteria=Subject or Recipient contains:
+fromSearchCriteria=Subject or From contains:
+
+# LOCALIZATION NOTE(biffNotification): %1$S is the number of new messages
+biffNotification_message=has %1$S new message
+biffNotification_messages=has %1$S new messages
+
+# LOCALIZATION NOTE(newMailNotification_message): %1$S is the name of the account %2$S is the number of new messages
+newMailNotification_message=%1$S received %2$S new message
+
+# LOCALIZATION NOTE(newMailNotification_messages): %1$S is the name of the account %2$S is the number of new messages
+newMailNotification_messages=%1$S received %2$S new messages
+
+# LOCALIZATION NOTE(newMailNotification_messagetitle): %1$S is subject of new message and %2$S is sender of new message.
+# This is UNIX only
+newMailNotification_messagetitle=%1$S from %2$S
+
+# LOCALIZATION NOTE(newMailAlert_message):
+# Semi-colon list of plural forms. See:
+# https://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 is the name of the account, #2 is the number of new messages
+newMailAlert_message=#1 received #2 new message;#1 received #2 new messages
+
+# for message views
+confirmViewDeleteTitle=Confirm
+confirmViewDeleteMessage=Are you sure you want to delete this view?
+
+# for virtual folders
+confirmSavedSearchTitle=Confirm Delete
+confirmSavedSearchDeleteMessage=Are you sure you want to delete this saved search?
+
+## @name ENTER_PASSWORD_PROMPT
+## @loc None
+# LOCALIZATION NOTE (passwordPrompt): Do not translate the word %S below.
+# Place the word "%S" in your translation where the email address
+# or the username should appear
+passwordPrompt=Enter your password for %1$S on %2$S:
+
+## @name ENTER_PASSWORD_PROMPT_TITLE
+## @loc None
+passwordTitle=Mail Server Password Required
+
+# for checking if the user really wants to open lots of messages in separate windows.
+openWindowWarningTitle=Confirm
+# LOCALIZATION NOTE (openWindowWarningConfirmation): Semi-colon list of plural forms.
+# #1 is the number of messages the user is attempting to open.
+openWindowWarningConfirmation=Opening #1 message may be slow. Continue?;Opening #1 messages may be slow. Continue?
+
+# for checking if the user really wants to open lots of messages in tabs.
+openTabWarningTitle=Confirm
+# LOCALIZATION NOTE (openTabWarningConfirmation): Semi-colon list of plural forms.
+# #1 is the number of messages the user is attempting to open.
+openTabWarningConfirmation=Opening #1 message may be slow. Continue?;Opening #1 messages may be slow. Continue?
+
+# for warning the user that a tag they're trying to create already exists
+tagExists=A tag with that name already exists.
+
+# title of the edit tag dialog
+editTagTitle=Edit Tag
+
+# for the virtual folder list dialog title
+# %S is the name of the saved search folder
+editVirtualFolderPropertiesTitle=Edit Saved Search Properties for %S
+# LOCALIZATION NOTE (foldersChosen): #1 number of chosen folders
+virtualFolderSourcesChosen=#1 folder chosen;#1 folders chosen
+
+#alert to inform the user to choose one or more folders to search for a saved search folder
+alertNoSearchFoldersSelected=You must choose at least one folder to search for the saved search folder.
+
+# These are displayed in the message and folder pane windows
+# LOCALIZATION NOTE %.*f is the abbreviated size in the appropriate units
+byteAbbreviation2=%.*f bytes
+kiloByteAbbreviation2=%.*f KB
+megaByteAbbreviation2=%.*f MB
+gigaByteAbbreviation2=%.*f GB
+teraByteAbbreviation2=%.*f TB
+petaByteAbbreviation2=%.*f PB
+
+## LOCALIZATION NOTE(folderWithAccount):
+## This is used to show folder name together with an account name.
+## %1$S = folder name
+## %2$S = account name
+folderWithAccount=%1$S - %2$S
+## LOCALIZATION NOTE(folderWithUnreadMsgs):
+## This is a concatenation of two strings to compose a folder label with unread messages.
+## %1$S = folder name
+## %2$S = count of unread messages
+folderWithUnreadMsgs=%1$S (%2$S)
+## LOCALIZATION NOTE(summarizedValue):
+## This string shows an indication that the value shown is actually a summary
+## accumulated from all subfolders.
+## %S = summarized value from all subfolders
+folderSummarizedSymbolValue=â–¾%S
+## LOCALIZATION NOTE(subfoldersExplanation):
+## This is a tooltip message shown on the values in the numeric folder pane columns.
+## %1$S = is the count of messages having the respective property, found in the folder under mouse cursor
+## %2$S = is the count of messages having the respective property, found in subfolders of the folder under mouse cursor
+subfoldersExplanation=%1$S in this folder, %2$S in subfolders
+
+# Error message if message for a message id wasn't found
+errorOpenMessageForMessageIdTitle=Error opening message-id
+errorOpenMessageForMessageIdMessage=Message for message-id %S not found
+
+# Warnings to alert users about phishing urls
+confirmPhishingTitle=Email Scam Alert
+linkMismatchTitle=Link Mismatch Detected
+#LOCALIZATION NOTE %1$S is the brand name, %2$S is the host name of the url being visited
+confirmPhishingUrl=%1$S thinks this message is a scam. The links in the message may be trying to impersonate web pages you want to visit. Are you sure you want to visit %2$S?
+#LOCALIZATION NOTE %1$S is the host name of indicated host, %2$S is the host name of the actual host.
+confirmPhishingUrlAlternate=The link you just clicked seems to lead to another site than what the link text indicated. This is sometimes used for tracking whether you clicked the link, but it could also be a scam.\n\nThe link text indicated that the link would lead to %1$S, but it leads to %2$S.
+#LOCALIZATION NOTE $1$S is the host name of the indicated host.
+confirmPhishingGoAhead=Go to %1$S anyway
+#LOCALIZATION NOTE %1$S is the host name that was displayed to the user.
+confirmPhishingGoDirect=Go to %1$S
+
+# Check for Updates
+# LOCALIZATION NOTE (updatesItem_*): these are alternative labels for Check for Update item in Help menu.
+# Which one is used depends on Update process state.
+updatesItem_default=Check for Updates…
+updatesItem_defaultFallback=Check for Updates…
+updatesItem_default.accesskey=C
+updatesItem_downloading=Downloading %S…
+updatesItem_downloadingFallback=Downloading Update…
+updatesItem_downloading.accesskey=D
+updatesItem_resume=Resume Downloading %S…
+updatesItem_resumeFallback=Resume Downloading Update…
+updatesItem_resume.accesskey=D
+updatesItem_pending=Apply Downloaded Update Now…
+updatesItem_pendingFallback=Apply Downloaded Update Now…
+updatesItem_pending.accesskey=D
+
+# Folder Pane Header Title Strings
+folderPaneModeHeader_all=All Folders
+folderPaneModeHeader_unread=Unread Folders
+folderPaneModeHeader_favorite=Favorite Folders
+folderPaneModeHeader_recent=Recent Folders
+folderPaneModeHeader_smart=Unified Folders
+unifiedAccountName=Unified Folders
+
+# Copy / Move to Folder Again
+#LOCALIZATION NOTE %1$S is the name of the folder we will move to. moveToFolderAgainAccessKey
+# should have the same value as copyToFolderAgainAccessKey as they are the same menu item in the UI
+# moveToFolderAgainAccessKey should also be a letter that occurs before %1$S
+moveToFolderAgain=Move to "%1$S" Again
+moveToFolderAgainAccessKey=t
+#LOCALIZATION NOTE %1$S is the name of the folder we will copy to
+# copyToFolderAgainAccessKey
+# should have the same value as moveToFolderAgainAccessKey as they are the same menu item in the UI
+# copyToFolderAgainAccessKey should also be a letter that occurs before %1$S
+copyToFolderAgain=Copy to "%1$S" Again
+copyToFolderAgainAccessKey=t
+
+#LOCALIZATION NOTE(mdnBarMessageNormal) %1$S is the name of the sender
+mdnBarMessageNormal=%1$S has asked to be notified when you read this message.
+#LOCALIZATION NOTE(mdnBarMessageAddressDiffers) %1$S is the name of the sender, %2$S is the address(es) to send return receipt to
+mdnBarMessageAddressDiffers=%1$S has asked to be notified (on %2$S) when you read this message.
+
+# mailCommands.js
+emptyJunkFolderTitle=Empty "%S"
+emptyJunkFolderMessage=Delete all messages and subfolders in the Junk folder?
+emptyJunkDontAsk=Don't ask me again.
+emptyTrashFolderTitle=Empty "%S"
+emptyTrashFolderMessage=Delete all messages and subfolders in the Trash folder?
+emptyTrashDontAsk=Don't ask me again.
+
+# junkCommands.js
+junkAnalysisPercentComplete=Junk analysis %S complete
+processingJunkMessages=Processing Junk Messages
+
+# Messenger bootstrapping messages
+fileNotFoundTitle = File Not Found
+#LOCALIZATION NOTE(fileNotFoundMsg): %S is the filename
+fileNotFoundMsg = The file %S does not exist.
+
+fileEmptyTitle = File Empty
+#LOCALIZATION NOTE(fileEmptyMsg): %S is the filename
+fileEmptyMsg = The file %S is empty.
+
+# LOCALIZATION NOTE (headerMoreAddrs): semicolon separated list of plural
+# forms of the word "more" as used after the number of addresses
+# currently hidden while displaying a header such as "to", "cc", or "bcc"
+# in the message header box. English has two identical forms here, so it will
+# construct strings that look like (for example) "1 more" or "20 more".
+# <https://developer.mozilla.org/en/Localization_and_Plurals> has details
+# on this mechanism.
+headerMoreAddrs=#1 more;#1 more
+
+# LOCALIZATION NOTE (headerMoreAddrsTooltip): semicolon separated list of
+# plural forms of the phrase ", and #1 more" as used in the tooltip text
+# of the more widget displayed in the header pane (see headerMoreAddrs).
+# English has two identical forms here, so it will construct strings that
+# look like (for example) ", and 1 more" or ", and 20 more".
+# <https://developer.mozilla.org/en/Localization_and_Plurals> has details
+# on this mechanism.
+headerMoreAddrsTooltip=, and #1 more;, and #1 more
+
+# LOCALIZATION NOTE (headertoFieldMe): first person prepositional object
+# pronoun used in the "to" header of the message header pane. This is also
+# used for the fallback case if a header-specific localization is not
+# available.
+headertoFieldMe=Me
+
+# LOCALIZATION NOTE (headerfromFieldMe): first person prepositional object
+# pronoun used in the "from" header of the message header pane.
+headerfromFieldMe=Me
+
+# LOCALIZATION NOTE (headerreply-toFieldMe): first person prepositional
+# object pronoun used in the "reply-to" header of the message header pane.
+headerreply-toFieldMe=Me
+
+# LOCALIZATION NOTE (headerccFieldMe): first person prepositional object
+# pronoun used in the "cc" header of the message header pane.
+headerccFieldMe=Me
+
+# LOCALIZATION NOTE (headerbccFieldMe): first person prepositional object
+# pronoun used in the "bcc" header of the message header pane.
+headerbccFieldMe=Me
+
+expandAttachmentPaneTooltip=Show the attachment pane
+collapseAttachmentPaneTooltip=Hide the attachment pane
+
+# Shown when content tabs are being loaded.
+loadingTab=Loading…
+
+confirmMsgDelete.title=Confirm Deletion
+confirmMsgDelete.collapsed.desc=This will delete messages in collapsed threads. Are you sure you want to continue?
+confirmMsgDelete.deleteNoTrash.desc=This will delete messages immediately, without saving a copy to Trash. Are you sure you want to continue?
+confirmMsgDelete.deleteFromTrash.desc=This will permanently delete messages from Trash. Are you sure you want to continue?
+confirmMsgDelete.dontAsk.label=Don't ask me again.
+confirmMsgDelete.delete.label=Delete
+
+mailServerLoginFailedTitle=Login Failed
+# LOCALIZATION NOTE (mailServerLoginFailedTitleWithAccount):
+# "%S" is the account name.
+mailServerLoginFailedTitleWithAccount=Login to account "%S" failed
+# LOCALIZATION NOTE (mailServerLoginFailed2):
+# %1$S is the host name of the server, %2$S is the user name.
+mailServerLoginFailed2=Login to server %1$S with username %2$S failed.
+mailServerLoginFailedRetryButton=&Retry
+mailServerLoginFailedEnterNewPasswordButton=&Enter New Password
+
+# LOCALIZATION NOTE (threadPane.columnPicker.confirmFolder.noChildren.title):
+# When the user selects a folder to apply the currently displayed columns to
+# via the "columnPicker.applyToFolder.label" menu option, this is the title of
+# the confirmation dialog used to verify they selected the correct folder. This
+# is the case in which we apply the columns only to the folder and not to any of
+# its children.
+threadPane.columnPicker.confirmFolder.noChildren.title=Apply Changes?
+# LOCALIZATION NOTE (threadPane.columnPicker.confirmFolder.noChildren.message):
+# When the user selects a folder to apply the currently displayed columns to
+# via the "columnPicker.applyToFolder.label" menu option, this is the text of
+# the confirmation dialog used to verify they selected the correct folder. The
+# string '%S' is replaced with the name of the folder the user selected in
+# order to help them confirm they picked what they thought they picked. This
+# is the case in which we apply the columns only to the folder and not to any of
+# its children.
+threadPane.columnPicker.confirmFolder.noChildren.message=Apply the current folder's columns to %S?
+
+# LOCALIZATION NOTE (threadPane.columnPicker.confirmFolder.withChildren.title):
+# When the user selects a folder to apply the currently displayed columns to via
+# the "columnPicker.applyToFolderAndChildren.label" menu option, this is the
+# title of the confirmation dialog used to verify they selected the correct
+# folder. This is the case in which we apply the columns to the folder and all
+# of its children.
+threadPane.columnPicker.confirmFolder.withChildren.title=Apply Changes?
+# LOCALIZATION NOTE (threadPane.columnPicker.confirmFolder.withChildren.message):
+# When the user selects a folder to apply the currently displayed columns to via
+# the "columnPicker.applyToFolderAndChildren.label" menu option, this is the
+# text of the confirmation dialog used to verify they selected the correct
+# folder. The string '%S' is replaced with the name of the folder the user
+# selected in order to help them confirm they picked what they thought they
+# picked. This is the case in which we apply the columns to the folder and all
+# of its children.
+threadPane.columnPicker.confirmFolder.withChildren.message=Apply the current folder's columns to %S and its children?
+
+# LOCALIZATION NOTE (lwthemeInstallRequest.message): %S will be replaced with
+# the host name of the site.
+lwthemeInstallRequest.message=This site (%S) attempted to install a theme.
+lwthemeInstallRequest.allowButton=Allow
+lwthemeInstallRequest.allowButton.accesskey=a
+
+lwthemePostInstallNotification.message=A new theme has been installed.
+lwthemePostInstallNotification.undoButton=Undo
+lwthemePostInstallNotification.undoButton.accesskey=U
+lwthemePostInstallNotification.manageButton=Manage Themes…
+lwthemePostInstallNotification.manageButton.accesskey=M
+
+# troubleshootModeRestart
+troubleshootModeRestartPromptTitle=Restart in Troubleshoot Mode
+troubleshootModeRestartPromptMessage=Troubleshoot Mode will disable all add-ons and temporarily use some default preferences.\nAre you sure you want to restart?
+troubleshootModeRestartButton=Restart
+
+# LOCALIZATION NOTE (downloadAndInstallButton.label): %S is replaced by the
+# version of the update: "Update to 28.0".
+update.downloadAndInstallButton.label=Update to %S
+update.downloadAndInstallButton.accesskey=U
+
+# Sanitize
+# LOCALIZATION NOTE (sanitizeDialog2.everything.title): When "Time range to
+# clear" is set to "Everything", the Clear Recent History dialog's title is
+# changed to this. See UI mockup and comment 11 at bug 480169 -->
+sanitizeDialog2.everything.title=Clear All History
+sanitizeButtonOK=Clear Now
+# LOCALIZATION NOTE (sanitizeEverythingWarning2): Warning that appears when
+# "Time range to clear" is set to "Everything" in Clear Recent History dialog,
+# provided that the user has not modified the default set of history items to clear.
+sanitizeEverythingWarning2=All history will be cleared.
+# LOCALIZATION NOTE (sanitizeSelectedWarning): Warning that appears when
+# "Time range to clear" is set to "Everything" in Clear Recent History dialog,
+# provided that the user has modified the default set of history items to clear.
+sanitizeSelectedWarning=All selected items will be cleared.
+
+learnMoreAboutIgnoreThread=Learn More…
+learnMoreAboutIgnoreThreadAccessKey = L
+undoIgnoreThread=Undo Ignore Thread
+undoIgnoreThreadAccessKey=U
+undoIgnoreSubthread=Undo Ignore Subthread
+undoIgnoreSubthreadAccessKey=U
+# LOCALIZATION NOTE (ignoredThreadFeedback): #1 is the message thread title
+ignoredThreadFeedback=Replies to the thread "#1" will not be shown.
+# LOCALIZATION NOTE (ignoredSubthreadFeedback): #1 is the message subthread title
+ignoredSubthreadFeedback=Replies to the subthread "#1" will not be shown.
+# LOCALIZATION NOTE (ignoredThreadsFeedback): Semi-colon list of plural forms.
+# #1 is the number of threads
+ignoredThreadsFeedback=Replies to the thread that was selected will not be shown.;Replies to the #1 threads that were selected will not be shown.
+# LOCALIZATION NOTE (ignoredSubthreadsFeedback): Semi-colon list of plural forms.
+# #1 is number of subthreads
+ignoredSubthreadsFeedback=Replies to the subthread that was selected will not be shown.;Replies to the #1 subthreads that were selected will not be shown.
+# LOCALIZATION NOTE (saveAsType): replace %S with the extension of the file to be saved.
+saveAsType=%S file
+
+# LOCALIZATION NOTE (openSearch.label): The label used in the autocomplete
+# widget to refer to a search on the web for a short string containing at most
+# 15 characters. %1$S is the search provider to use. %2$S is the string to
+# search for.
+openSearch.label=Search %1$S for "%2$S"
+
+# LOCALIZATION NOTE (openSearch.label.truncated): The label used in the
+# autocomplete widget to refer to a search on the web for a short string
+# containing more than 15 characters. %1$S is the search provider to use. %2$S
+# is the string to search for, truncated to 15 characters.
+openSearch.label.truncated=Search %1$S for "%2$S…"
+
+# LOCALIZATION NOTE (aboutDialog.architecture.*):
+# The sixtyFourBit and thirtyTwoBit strings describe the architecture of the
+# current Thunderbird build: 32-bit or 64-bit. These strings are used in parentheses
+# after the Thunderbird version in the About dialog,
+# e.g.: "48.0.2 (32-bit)" or "51.0a1 (2016-09-05) (64-bit)".
+aboutDialog.architecture.sixtyFourBit = 64-bit
+aboutDialog.architecture.thirtyTwoBit = 32-bit
+
+errorConsoleTitle = Error Console
+
+# LOCALIZATION NOTE (panel.back):
+# This is used by screen readers to label the "back" button in various browser
+# popup panels, including the sliding subviews of the appmenu.
+panel.back = Back
+
+# LOCALIZATION NOTE (folderErrorAlertTitle):
+# %S is a pretty string to identify the folder and account.
+# EXAMPLE: Error - Inbox on bob@example.com
+folderErrorAlertTitle = Error - %S
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdAdvancedEdit.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdAdvancedEdit.dtd
new file mode 100644
index 0000000000..83fcbd7416
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdAdvancedEdit.dtd
@@ -0,0 +1,18 @@
+<!-- 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/. -->
+
+<!ENTITY WindowTitle.label "Advanced Property Editor">
+<!ENTITY AttName.label "Attribute: ">
+<!ENTITY AttValue.label "Value: ">
+<!ENTITY PropertyName.label "Property: ">
+<!ENTITY currentattributesfor.label "Current attributes for: ">
+<!ENTITY tree.attributeHeader.label "Attribute">
+<!ENTITY tree.propertyHeader.label "Property">
+<!ENTITY tree.valueHeader.label "Value">
+<!ENTITY tabHTML.label "HTML Attributes">
+<!ENTITY tabCSS.label "Inline Style">
+<!ENTITY tabJSE.label "JavaScript Events">
+
+<!ENTITY editAttribute.label "Click on an item above to edit its value">
+<!ENTITY removeAttribute.label "Remove">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdColorPicker.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdColorPicker.dtd
new file mode 100644
index 0000000000..c00d24f298
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdColorPicker.dtd
@@ -0,0 +1,22 @@
+<!-- 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/. -->
+
+<!ENTITY windowTitle.label "Color">
+<!ENTITY lastPickedColor.label "Last-picked color">
+<!ENTITY lastPickedColor.accessKey "L">
+<!ENTITY chooseColor1.label "Choose a color:">
+<!ENTITY chooseColor2.label "Enter an HTML color string">
+<!ENTITY chooseColor2.accessKey "H">
+<!ENTITY setColorExample.label "(e.g.: &quot;#0000ff&quot; or &quot;blue&quot;):">
+<!ENTITY default.label "Default">
+<!ENTITY default.accessKey "D">
+<!ENTITY palette.label "Palette:">
+<!ENTITY standardPalette.label "Standard">
+<!ENTITY webPalette.label "All web colors">
+<!ENTITY background.label "Background for:">
+<!ENTITY background.accessKey "B">
+<!ENTITY table.label "Table">
+<!ENTITY table.accessKey "T">
+<!ENTITY cell.label "Cell(s)">
+<!ENTITY cell.accessKey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdConvertToTable.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdConvertToTable.dtd
new file mode 100644
index 0000000000..044f60e496
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdConvertToTable.dtd
@@ -0,0 +1,15 @@
+<!-- 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/. -->
+
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Convert To Table">
+<!ENTITY instructions1.label "Composer creates a new table row for each paragraph in the selection.">
+<!ENTITY instructions2.label "Choose the character used to separate the selection into columns:">
+<!ENTITY commaRadio.label "Comma">
+<!ENTITY spaceRadio.label "Space">
+<!ENTITY otherRadio.label "Other Character:">
+<!ENTITY deleteCharCheck.label "Delete separator character">
+<!ENTITY collapseSpaces.label "Ignore extra spaces">
+<!ENTITY collapseSpaces.tooltip "Convert adjacent spaces to one separator">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdDialogOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdDialogOverlay.dtd
new file mode 100644
index 0000000000..527e723a5a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdDialogOverlay.dtd
@@ -0,0 +1,18 @@
+<!-- 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/. -->
+
+<!ENTITY AdvancedEditButton.label "Advanced Edit…">
+<!ENTITY AdvancedEditButton.accessKey "E">
+<!ENTITY AdvancedEditButton.tooltip "Add or modify HTML attributes, style attributes, and JavaScript">
+<!ENTITY chooseFileButton.label "Choose File...">
+<!ENTITY chooseFileButton.accessKey "F">
+<!ENTITY chooseFileLinkButton.label "Choose File...">
+<!ENTITY chooseFileLinkButton.accessKey "o">
+<!ENTITY makeUrlRelative.label "URL is relative to page location">
+<!ENTITY makeUrlRelative.accessKey "r">
+<!ENTITY makeUrlRelative.tooltip "Change between relative and absolute URL. You must first save the page to change this.">
+
+<!-- Shared by Link and Image dialogs -->
+<!ENTITY LinkURLEditField2.label "Enter a web page location, a local file, or select a Named Anchor or Heading from the field's context menu:">
+<!ENTITY LinkURLEditField2.accessKey "w">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdNamedAnchorProperties.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdNamedAnchorProperties.dtd
new file mode 100644
index 0000000000..d418ed14b6
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EdNamedAnchorProperties.dtd
@@ -0,0 +1,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/. -->
+
+<!ENTITY windowTitle.label "Named Anchor Properties">
+<!ENTITY anchorNameEditField.label "Anchor Name:">
+<!ENTITY anchorNameEditField.accessKey "N">
+<!ENTITY nameInput.tooltip "Enter a unique name for this named anchor (target)">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorColorProperties.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorColorProperties.dtd
new file mode 100644
index 0000000000..2e3bf76925
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorColorProperties.dtd
@@ -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/. -->
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Page Colors and Background">
+<!ENTITY pageColors.label "Page Colors">
+<!ENTITY defaultColorsRadio.label "Reader's default colors (Don't set colors in page)">
+<!ENTITY defaultColorsRadio.accessKey "D">
+<!ENTITY defaultColorsRadio.tooltip "Use the color settings from the viewer (reader's) browser only">
+<!ENTITY customColorsRadio.label "Use custom colors:">
+<!ENTITY customColorsRadio.accessKey "C">
+<!ENTITY customColorsRadio.tooltip "These color settings override the viewer's browser settings">
+
+<!ENTITY normalText.label "Normal text">
+<!ENTITY normalText.accessKey "N">
+<!ENTITY linkText.label "Link text">
+<!ENTITY linkText.accessKey "L">
+<!ENTITY activeLinkText.label "Active link text">
+<!ENTITY activeLinkText.accessKey "A">
+<!ENTITY visitedLinkText.label "Visited link text">
+<!ENTITY visitedLinkText.accessKey "V">
+<!ENTITY background.label "Background:">
+<!ENTITY background.accessKey "B">
+<!ENTITY colon.character ":">
+<!ENTITY backgroundImage.label "Background Image:">
+<!ENTITY backgroundImage.accessKey "m">
+<!ENTITY backgroundImage.tooltip "Use an image file as the background for your page">
+<!ENTITY backgroundImage.shortenedDataURI "Shortened data URI (copy will place the full URI onto the clipboard)">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorHLineProperties.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorHLineProperties.dtd
new file mode 100644
index 0000000000..9ad023dee0
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorHLineProperties.dtd
@@ -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/. -->
+
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Horizontal Line Properties">
+
+<!ENTITY dimensionsBox.label "Dimensions">
+<!ENTITY heightEditField.label "Height:">
+<!ENTITY heightEditField.accessKey "G">
+<!ENTITY widthEditField.label "Width:">
+<!ENTITY widthEditField.accessKey "W">
+<!ENTITY pixelsPopup.value "pixels">
+<!ENTITY alignmentBox.label "Alignment">
+<!ENTITY leftRadio.label "Left">
+<!ENTITY leftRadio.accessKey "L">
+<!ENTITY centerRadio.label "Center">
+<!ENTITY centerRadio.accessKey "C">
+<!ENTITY rightRadio.label "Right">
+<!ENTITY rightRadio.accessKey "R">
+
+<!ENTITY threeDShading.label "3-D Shading">
+<!ENTITY threeDShading.accessKey "S">
+<!ENTITY saveSettings.label "Use as Default">
+<!ENTITY saveSettings.accessKey "D">
+<!ENTITY saveSettings.tooltip "Save these settings to use when inserting new horizontal lines">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorImageProperties.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorImageProperties.dtd
new file mode 100644
index 0000000000..280af6df2a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorImageProperties.dtd
@@ -0,0 +1,79 @@
+<!-- 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/. -->
+
+<!-- These strings are for use specifically in the editor's image and form image dialogs. -->
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Image Properties">
+
+<!ENTITY pixelsPopup.value "pixels">
+
+<!-- These are in the Location tab panel -->
+<!ENTITY locationEditField.label "Image Location:">
+<!ENTITY locationEditField.accessKey "L">
+<!ENTITY locationEditField.tooltip "Type the image's filename or location">
+<!ENTITY locationEditField.shortenedDataURI "Shortened data URI (copy will place the full URI onto the clipboard)">
+<!ENTITY title.label "Tooltip:">
+<!ENTITY title.accessKey "T">
+<!ENTITY title.tooltip "The html 'title' attribute that displays as a tooltip">
+<!ENTITY altText.label "Alternate text:">
+<!ENTITY altText.accessKey "A">
+<!ENTITY altTextEditField.tooltip "Type text to display in place of the image">
+<!ENTITY noAltText.label "Don't use alternate text">
+<!ENTITY noAltText.accessKey "D">
+
+<!ENTITY previewBox.label "Image Preview">
+
+<!-- These controls are in the Dimensions tab panel -->
+<!-- actualSize.label should be same as actualSizeRadio.label + ":" -->
+<!ENTITY actualSize.label "Actual Size:">
+<!ENTITY actualSizeRadio.label "Actual Size">
+<!ENTITY actualSizeRadio.accessKey "A">
+<!ENTITY actualSizeRadio.tooltip "Revert to the image's actual size">
+<!ENTITY customSizeRadio.label "Custom Size">
+<!ENTITY customSizeRadio.accessKey "S">
+<!ENTITY customSizeRadio.tooltip "Change the image's size as displayed in the page">
+<!ENTITY heightEditField.label "Height:">
+<!ENTITY heightEditField.accessKey "G">
+<!ENTITY widthEditField.label "Width:">
+<!ENTITY widthEditField.accessKey "W">
+<!ENTITY constrainCheckbox.label "Constrain">
+<!ENTITY constrainCheckbox.accessKey "C">
+<!ENTITY constrainCheckbox.tooltip "Maintain the image's aspect ratio">
+
+<!-- These controls are in the Image Map box of the expanded area -->
+<!ENTITY imagemapBox.label "Image Map">
+<!ENTITY removeImageMapButton.label "Remove">
+<!ENTITY removeImageMapButton.accessKey "R">
+
+<!-- These are the options for image alignment -->
+<!ENTITY alignment.label "Align Text to Image">
+<!ENTITY bottomPopup.value "At the bottom">
+<!ENTITY topPopup.value "At the top">
+<!ENTITY centerPopup.value "In the center">
+<!ENTITY wrapRightPopup.value "Wrap to the right">
+<!ENTITY wrapLeftPopup.value "Wrap to the left">
+
+<!-- These controls are in the Spacing Box -->
+<!ENTITY spacingBox.label "Spacing">
+<!ENTITY leftRightEditField.label "Left and Right:">
+<!ENTITY leftRightEditField.accessKey "L">
+<!ENTITY topBottomEditField.label "Top and Bottom:">
+<!ENTITY topBottomEditField.accessKey "T">
+<!ENTITY borderEditField.label "Solid Border:">
+<!ENTITY borderEditField.accessKey "B">
+
+<!-- These controls are in the Link Box -->
+<!ENTITY showImageLinkBorder.label "Show border around linked image">
+<!ENTITY showImageLinkBorder.accessKey "B">
+<!ENTITY LinkAdvancedEditButton.label "Link Advanced Edit…">
+<!ENTITY LinkAdvancedEditButton.accessKey "L">
+<!ENTITY LinkAdvancedEditButton.tooltip "Add or modify HTML attributes, style attributes, and JavaScript">
+
+<!-- These tabs are currently used in the image input dialog -->
+<!ENTITY imageInputTab.label "Form">
+<!ENTITY imageLocationTab.label "Location">
+<!ENTITY imageDimensionsTab.label "Dimensions">
+<!ENTITY imageAppearanceTab.label "Appearance">
+<!ENTITY imageLinkTab.label "Link">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertChars.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertChars.dtd
new file mode 100644
index 0000000000..1755e499ca
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertChars.dtd
@@ -0,0 +1,19 @@
+<!-- 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/. -->
+
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Insert Character">
+<!ENTITY category.label "Category">
+<!ENTITY letter.label "Letter:">
+<!ENTITY letter.accessKey "L">
+<!ENTITY character.label "Character:">
+<!ENTITY character.accessKey "C">
+<!ENTITY accentUpper.label "Accent Uppercase">
+<!ENTITY accentLower.label "Accent Lowercase">
+<!ENTITY otherUpper.label "Other Uppercase">
+<!ENTITY otherLower.label "Other Lowercase">
+<!ENTITY commonSymbols.label "Common Symbols">
+<!ENTITY insertButton.label "Insert">
+<!ENTITY closeButton.label "Close">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertMath.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertMath.dtd
new file mode 100644
index 0000000000..357ed0b20d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertMath.dtd
@@ -0,0 +1,21 @@
+<!-- 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/. -->
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Insert Math">
+
+<!ENTITY sourceEditField.label "Enter LaTeX source code:">
+
+<!ENTITY options.label "Options">
+<!ENTITY optionInline.label "Inline mode">
+<!ENTITY optionInline.accesskey "N">
+<!ENTITY optionDisplay.label "Display mode">
+<!ENTITY optionDisplay.accesskey "D">
+<!ENTITY optionLTR.label "Left-to-right direction">
+<!ENTITY optionLTR.accesskey "L">
+<!ENTITY optionRTL.label "Right-to-left direction">
+<!ENTITY optionRTL.accesskey "R">
+
+<!ENTITY insertButton.label "Insert">
+<!ENTITY insertButton.accesskey "I">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertSource.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertSource.dtd
new file mode 100644
index 0000000000..0b51a86c8c
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertSource.dtd
@@ -0,0 +1,15 @@
+<!-- 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/. -->
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Insert HTML">
+<!ENTITY sourceEditField.label "Enter HTML tags and text:">
+<!ENTITY example.label "Example: ">
+<!-- LOCALIZATION NOTE (exampleOpenTag.label): DONT_TRANSLATE: they are text for HTML tagnames: "<i>" and "</i>" -->
+<!ENTITY exampleOpenTag.label "&lt;i&gt;">
+<!-- LOCALIZATION NOTE (exampleCloseTag.label): DONT_TRANSLATE: they are text for HTML tagnames: "<i>" and "</i>" -->
+<!ENTITY exampleCloseTag.label "&lt;/i&gt;">
+<!ENTITY exampleText.label "Hello World!">
+<!ENTITY insertButton.label "Insert">
+<!ENTITY insertButton.accesskey "I">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertTOC.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertTOC.dtd
new file mode 100644
index 0000000000..f3285a357a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertTOC.dtd
@@ -0,0 +1,16 @@
+<!-- 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/. -->
+
+<!ENTITY Window.title "Table of Contents">
+<!ENTITY buildToc.label "Build table of contents from:">
+<!ENTITY tag.label "Tag:">
+<!ENTITY class.label "Class:">
+<!ENTITY header1.label "Level 1">
+<!ENTITY header2.label "Level 2">
+<!ENTITY header3.label "Level 3">
+<!ENTITY header4.label "Level 4">
+<!ENTITY header5.label "Level 5">
+<!ENTITY header6.label "Level 6">
+<!ENTITY makeReadOnly.label "Make the table of contents read-only">
+<!ENTITY orderedList.label "Number all entries in the table of contents">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertTable.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertTable.dtd
new file mode 100644
index 0000000000..00b5d2d131
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorInsertTable.dtd
@@ -0,0 +1,18 @@
+<!-- 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/. -->
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Insert Table">
+
+<!ENTITY size.label "Size">
+<!ENTITY numRowsEditField.label "Rows:">
+<!ENTITY numRowsEditField.accessKey "R">
+<!ENTITY numColumnsEditField.label "Columns:">
+<!ENTITY numColumnsEditField.accessKey "C">
+<!ENTITY widthEditField.label "Width:">
+<!ENTITY widthEditField.accessKey "W">
+<!ENTITY borderEditField.label "Border:">
+<!ENTITY borderEditField.accessKey "B">
+<!ENTITY borderEditField.tooltip "Type a number for the table's border, or type zero (0) for no border">
+<!ENTITY pixels.label "pixels">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorLinkProperties.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorLinkProperties.dtd
new file mode 100644
index 0000000000..09b3b01550
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorLinkProperties.dtd
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+
+<!ENTITY windowTitle.label "Link Properties">
+<!ENTITY LinkURLBox.label "Link Location">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorListProperties.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorListProperties.dtd
new file mode 100644
index 0000000000..2494330000
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorListProperties.dtd
@@ -0,0 +1,20 @@
+<!-- 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/. -->
+
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "List Properties">
+
+<!ENTITY ListType.label "List Type">
+<!ENTITY bulletStyle.label "Bullet Style:">
+<!ENTITY startingNumber.label "Start at:">
+<!ENTITY startingNumber.accessKey "S">
+<!ENTITY none.value "None">
+<!ENTITY bulletList.value "Bullet (Unnumbered) List">
+<!ENTITY numberList.value "Numbered List">
+<!ENTITY definitionList.value "Definition List">
+<!ENTITY changeEntireListRadio.label "Change entire list">
+<!ENTITY changeEntireListRadio.accessKey "C">
+<!ENTITY changeSelectedRadio.label "Change just selected items">
+<!ENTITY changeSelectedRadio.accessKey "I">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorPersonalDictionary.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorPersonalDictionary.dtd
new file mode 100644
index 0000000000..5eb0aa1af1
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorPersonalDictionary.dtd
@@ -0,0 +1,18 @@
+<!-- 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/. -->
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Personal Dictionary">
+
+<!ENTITY wordEditField.label "New word:">
+<!ENTITY wordEditField.accessKey "N">
+<!ENTITY AddButton.label "Add">
+<!ENTITY AddButton.accessKey "A">
+<!ENTITY DictionaryList.label "Words in dictionary:">
+<!ENTITY DictionaryList.accessKey "W">
+<!ENTITY RemoveButton.label "Remove">
+<!ENTITY RemoveButton.accessKey "e">
+
+<!ENTITY CloseButton.label "Close">
+<!ENTITY CloseButton.accessKey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorReplace.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorReplace.dtd
new file mode 100644
index 0000000000..74d907a012
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorReplace.dtd
@@ -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/. -->
+
+<!-- extracted from EdReplace.xhtml -->
+
+<!ENTITY replaceDialog.title "Find and Replace">
+<!ENTITY findField.label "Find text:">
+<!ENTITY findField.accesskey "n">
+<!ENTITY replaceField.label "Replace with:">
+<!ENTITY replaceField.accesskey "e">
+<!ENTITY caseSensitiveCheckbox.label "Match exact case">
+<!ENTITY caseSensitiveCheckbox.accesskey "M">
+<!ENTITY wrapCheckbox.label "Wrap around">
+<!ENTITY wrapCheckbox.accesskey "W">
+<!ENTITY backwardsCheckbox.label "Search backwards">
+<!ENTITY backwardsCheckbox.accesskey "b">
+<!ENTITY findNextButton.label "Find Next">
+<!ENTITY findNextButton.accesskey "F">
+<!ENTITY replaceButton.label "Replace">
+<!ENTITY replaceButton.accesskey "R">
+<!ENTITY replaceAndFindButton.label "Replace and Find">
+<!ENTITY replaceAndFindButton.accesskey "d">
+<!ENTITY replaceAllButton.label "Replace All">
+<!ENTITY replaceAllButton.accesskey "A">
+<!ENTITY closeButton.label "Close">
+<!ENTITY closeButton.accesskey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorSpellCheck.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorSpellCheck.dtd
new file mode 100644
index 0000000000..7d29154831
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorSpellCheck.dtd
@@ -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/. -->
+
+<!-- Window title -->
+<!ENTITY windowTitle.label "Check Spelling">
+
+<!ENTITY misspelledWord.label "Misspelled word:">
+<!ENTITY wordEditField.label "Replace with:">
+<!ENTITY wordEditField.accessKey "w">
+<!ENTITY checkwordButton.label "Check Word">
+<!ENTITY checkwordButton.accessKey "k">
+<!ENTITY suggestions.label "Suggestions:">
+<!ENTITY suggestions.accessKey "u">
+<!ENTITY ignoreButton.label "Ignore">
+<!ENTITY ignoreButton.accessKey "I">
+<!ENTITY ignoreAllButton.label "Ignore All">
+<!ENTITY ignoreAllButton.accessKey "n">
+<!ENTITY replaceButton.label "Replace">
+<!ENTITY replaceButton.accessKey "R">
+<!ENTITY replaceAllButton.label "Replace All">
+<!ENTITY replaceAllButton.accessKey "A">
+<!ENTITY stopButton.label "Stop">
+<!ENTITY stopButton.accessKey "o">
+<!ENTITY userDictionary.label "Personal Dictionary:">
+<!ENTITY moreDictionaries.label "Download more dictionaries…">
+<!ENTITY addToUserDictionaryButton.label "Add Word">
+<!ENTITY addToUserDictionaryButton.accessKey "d">
+<!ENTITY editUserDictionaryButton.label "Edit…">
+<!ENTITY editUserDictionaryButton.accessKey "E">
+<!ENTITY recheckButton2.label "Recheck Text">
+<!ENTITY recheckButton2.accessKey "T">
+<!ENTITY closeButton.label "Close">
+<!ENTITY closeButton.accessKey "C">
+<!ENTITY sendButton.label "Send">
+<!ENTITY sendButton.accessKey "S">
+<!ENTITY languagePopup.label "Language:">
+<!ENTITY languagePopup.accessKey "L">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorTableProperties.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorTableProperties.dtd
new file mode 100644
index 0000000000..512734d7a0
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/EditorTableProperties.dtd
@@ -0,0 +1,75 @@
+<!-- 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/. -->
+
+<!ENTITY tableWindow.title "Table Properties">
+<!ENTITY applyButton.label "Apply">
+<!ENTITY applyButton.accesskey "A">
+<!ENTITY closeButton.label "Close">
+<!ENTITY tableTab.label "Table">
+<!ENTITY cellTab.label "Cells">
+<!ENTITY tableRows.label "Rows:">
+<!ENTITY tableRows.accessKey "R">
+<!ENTITY tableColumns.label "Columns:">
+<!ENTITY tableColumns.accessKey "C">
+<!ENTITY tableHeight.label "Height:">
+<!ENTITY tableHeight.accessKey "G">
+<!ENTITY tableWidth.label "Width:">
+<!ENTITY tableWidth.accessKey "W">
+<!ENTITY tableBorderSpacing.label "Borders and Spacing">
+<!ENTITY tableBorderWidth.label "Border:">
+<!ENTITY tableBorderWidth.accessKey "B">
+<!ENTITY tableSpacing.label "Spacing:">
+<!ENTITY tableSpacing.accessKey "S">
+<!ENTITY tablePadding.label "Padding:">
+<!ENTITY tablePadding.accessKey "P">
+<!ENTITY tablePxBetwCells.label "pixels between cells">
+<!ENTITY tablePxBetwBrdrCellContent.label "pixels between cell border and content">
+<!ENTITY tableAlignment.label "Table Alignment:">
+<!ENTITY tableAlignment.accessKey "T">
+<!ENTITY tableCaption.label "Caption:">
+<!ENTITY tableCaption.accessKey "N">
+<!ENTITY tableCaptionAbove.label "Above Table">
+<!ENTITY tableCaptionBelow.label "Below Table">
+<!ENTITY tableCaptionLeft.label "Left of Table">
+<!ENTITY tableCaptionRight.label "Right of table">
+<!ENTITY tableCaptionNone.label "None">
+<!ENTITY tableInheritColor.label "(Let page color show through)">
+
+<!ENTITY cellSelection.label "Selection">
+<!ENTITY cellSelectCell.label "Cell">
+<!ENTITY cellSelectRow.label "Row">
+<!ENTITY cellSelectColumn.label "Column">
+<!ENTITY cellSelectNext.label "Next">
+<!ENTITY cellSelectNext.accessKey "N">
+<!ENTITY cellSelectPrevious.label "Previous">
+<!ENTITY cellSelectPrevious.accessKey "P">
+<!ENTITY applyBeforeChange.label "Current changes will be applied before changing the selection.">
+<!ENTITY cellContentAlignment.label "Content Alignment">
+<!ENTITY cellHorizontal.label "Horizontal:">
+<!ENTITY cellHorizontal.accessKey "Z">
+<!ENTITY cellVertical.label "Vertical:">
+<!ENTITY cellVertical.accessKey "V">
+<!ENTITY cellStyle.label "Cell Style:">
+<!ENTITY cellStyle.accessKey "C">
+<!ENTITY cellNormal.label "Normal">
+<!ENTITY cellHeader.label "Header">
+<!ENTITY cellTextWrap.label "Text Wrap:">
+<!ENTITY cellTextWrap.accessKey "T">
+<!ENTITY cellWrap.label "Wrap">
+<!ENTITY cellNoWrap.label "Don't wrap">
+<!ENTITY cellAlignTop.label "Top">
+<!ENTITY cellAlignMiddle.label "Middle">
+<!ENTITY cellAlignBottom.label "Bottom">
+<!ENTITY cellAlignJustify.label "Justify">
+<!ENTITY cellInheritColor.label "(Let table color show through)">
+<!ENTITY cellUseCheckboxHelp.label "Use checkboxes to determine which properties are applied to all selected cells">
+
+<!-- Used in both Table and Cell panels -->
+<!ENTITY size.label "Size">
+<!ENTITY pixels.label "pixels">
+<!ENTITY backgroundColor.label "Background Color:">
+<!ENTITY backgroundColor.accessKey "B">
+<!ENTITY AlignLeft.label "Left">
+<!ENTITY AlignCenter.label "Center">
+<!ENTITY AlignRight.label "Right">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/composeMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/messengercompose/composeMsgs.properties
new file mode 100644
index 0000000000..58e380b580
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/composeMsgs.properties
@@ -0,0 +1,457 @@
+# 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 following are used by the compose back end
+#
+## LOCALIZATION NOTE (unableToOpenFile, unableToOpenTmpFile):
+## %S will be replaced with the name of file that could not be opened
+unableToOpenFile=Unable to open the file %S.
+unableToOpenTmpFile=Unable to open the temporary file %S. Check your 'Temporary Directory' setting.
+unableToSaveTemplate=Unable to save your message as a template.
+unableToSaveDraft=Unable to save your message as a draft.
+couldntOpenFccFolder=Couldn't open the Sent Mail folder. Please verify that your account settings are correct.
+noSender=No sender was specified. Please add your email address in the account settings.
+noRecipients=No recipients were specified. Please enter a recipient or newsgroup in the addressing area.
+errorWritingFile=Error writing temporary file.
+
+## LOCALIZATION NOTE (errorSendingFromCommand): argument %s is the Outgoing server (SMTP) response
+errorSendingFromCommand=An error occurred while sending mail. The mail server responded: %s. Please verify that your email address is correct in your account settings and try again.
+
+## LOCALIZATION NOTE (errorSendingDataCommand): argument %s is the Outgoing server (SMTP) response
+errorSendingDataCommand=An Outgoing server (SMTP) error occurred while sending mail. The server responded: %s.
+
+## LOCALIZATION NOTE (errorSendingMessage): argument %s is the Outgoing server (SMTP) response
+errorSendingMessage=An error occurred while sending mail. The mail server responded: %s. Please check the message and try again.
+postFailed=The message could not be posted because connecting to the news server failed. The server may be unavailable or is refusing connections. Please verify that your news server settings are correct and try again.
+errorQueuedDeliveryFailed=An error occurred while delivering the unsent messages.
+sendFailed=Sending of the message failed.
+
+## LOCALIZATION NOTE (sendFailedUnexpected): argument %X is a hex error code value
+sendFailedUnexpected=Failed due to unexpected error %X. No description is available.
+
+## LOCALIZATION NOTE (smtpSecurityIssue): argument %S is the Outgoing server (SMTP) response
+smtpSecurityIssue=The configuration related to %S must be corrected.
+
+## LOCALIZATION NOTE (smtpServerError): argument %s is the Outgoing server (SMTP) response
+smtpServerError=An error occurred while sending mail: Outgoing server (SMTP) error. The server responded: %s.
+unableToSendLater=Sorry, we were unable to save your message for sending later.
+
+## LOCALIZATION NOTE (communicationsError): argument %d is the error code
+communicationsError=A communications error occurred: %d. Please try again.
+dontShowAlert=THIS IS JUST A PLACEHOLDER. YOU SHOULD NEVER SEE THIS STRING.
+
+couldNotGetUsersMailAddress2=An error occurred while sending mail: the sender's address (From:) was invalid. Please verify that this email address is correct and try again.
+couldNotGetSendersIdentity=An error occurred while sending mail: the sender identity was invalid. Please verify the configuration of your identity and try again.
+
+mimeMpartAttachmentError=Attachment error.
+failedCopyOperation=The message was sent successfully, but could not be copied to your Sent folder.
+nntpNoCrossPosting=You can only send a message to one news server at a time.
+msgCancelling=Cancelling…
+sendFailedButNntpOk=Your message has been posted to the newsgroup but has not been sent to the other recipient.
+errorReadingFile=Error reading file.
+followupToSenderMessage=The author of this message has requested that responses be sent only to the author. If you also want to reply to the newsgroup, add a new row to the addressing area, choose Newsgroup from the recipients list, and enter the name of the newsgroup.
+
+## LOCALIZATION NOTE (errorAttachingFile): argument %S is the file name/URI of the object to be attached
+errorAttachingFile=There was an error attaching %S. Please check that you have access to the file.
+
+## LOCALIZATION NOTE (incorrectSmtpGreeting): argument %s is the Outgoing server (SMTP) greeting
+incorrectSmtpGreeting=An error occurred while sending mail: The mail server sent an incorrect greeting: %s.
+
+## LOCALIZATION NOTE (errorSendingRcptCommand): argument %1$S is the Outgoing server (SMTP) response, argument %2$S is the intended message recipient.
+errorSendingRcptCommand=An error occurred while sending mail. The mail server responded:\n%1$S.\nPlease check the message recipient "%2$S" and try again.
+
+## LOCALIZATION NOTE (startTlsFailed): argument %S is the Outgoing server (SMTP)
+startTlsFailed=An error occurred while sending mail: Unable to establish a secure link with Outgoing server (SMTP) %S using STARTTLS since it doesn't advertise that feature. Switch off STARTTLS for that server or contact your service provider.
+
+## LOCALIZATION NOTE (smtpPasswordUndefined): argument %S is the Outgoing server (SMTP) account
+smtpPasswordUndefined=An error occurred while sending mail: Could not get password for %S. The message was not sent.
+
+## LOCALIZATION NOTE (smtpSendNotAllowed): argument %s is the Outgoing server (SMTP) response
+smtpSendNotAllowed=An error occurred while sending mail. The mail server responded:\n%s.\nPlease ensure that you are using the correct identity to send and that the used authentication method is correct. Verify that you are allowed to send via this SMTP server with your current credentials from your current network.
+
+## LOCALIZATION NOTE (smtpTooManyRecipients): argument %s is the Outgoing server (SMTP) response
+smtpTooManyRecipients=The message was not sent due to exceeding the allowed number of recipients. The server responded: %s.
+
+## LOCALIZATION NOTE (smtpClientid): argument %s is the Outgoing server (SMTP) response
+smtpClientid=The outgoing server (SMTP) detected an error in the CLIENTID command. The message was not sent. The server responded: %s
+
+## LOCALIZATION NOTE (smtpClientidPermission): argument %s is the Outgoing server (SMTP) response
+smtpClientidPermission=The outgoing server (SMTP) response to the CLIENTID command indicates that your device is not permitted to send mail. The server responded: %s
+
+## LOCALIZATION NOTE (smtpPermSizeExceeded2): argument %s is the Outgoing server (SMTP) response
+smtpPermSizeExceeded2=The size of the message you are trying to send exceeds the global size limit of the server. The message was not sent; reduce the message size and try again. The server responded: %s.
+
+## LOCALIZATION NOTE (smtpSendFailedUnknownServer): argument %S is the Outgoing server (SMTP)
+smtpSendFailedUnknownServer=An error occurred while sending mail: Outgoing server (SMTP) %S is unknown. The server may be incorrectly configured. Please verify that your Outgoing server (SMTP) settings are correct and try again.
+
+## LOCALIZATION NOTE (smtpSendRequestRefused): argument %S is the Outgoing server (SMTP)
+smtpSendRequestRefused=The message could not be sent because connecting to Outgoing server (SMTP) %S failed. The server may be unavailable or is refusing SMTP connections. Please verify that your Outgoing server (SMTP) settings are correct and try again.
+
+## LOCALIZATION NOTE (smtpSendInterrupted): argument %S is the Outgoing server (SMTP)
+smtpSendInterrupted=The message could not be sent because the connection to Outgoing server (SMTP) %S was lost in the middle of the transaction. Try again.
+
+## LOCALIZATION NOTE (smtpSendTimeout): argument %S is the Outgoing server (SMTP)
+smtpSendTimeout=The message could not be sent because the connection to Outgoing server (SMTP) %S timed out. Try again.
+
+## LOCALIZATION NOTE (smtpSendFailedUnknownReason): argument %S is the Outgoing server (SMTP)
+smtpSendFailedUnknownReason=The message could not be sent using Outgoing server (SMTP) %S for an unknown reason. Please verify that your Outgoing server (SMTP) settings are correct and try again.
+
+# LOCALIZATION NOTE (smtpHintAuthEncryptToPlainNoSsl): %S is the server hostname
+smtpHintAuthEncryptToPlainNoSsl=The Outgoing server (SMTP) %S does not seem to support encrypted passwords. If you just set up the account, try changing the 'Authentication method' in 'Account Settings | Outgoing server (SMTP)' to 'Password, transmitted insecurely'. If it used to work but now doesn't, you may be susceptible to getting your password stolen.
+
+# LOCALIZATION NOTE (smtpHintAuthEncryptToPlainSsl): %S is the server hostname
+smtpHintAuthEncryptToPlainSsl=The Outgoing server (SMTP) %S does not seem to support encrypted passwords. If you just set up the account, try changing the 'Authentication method' in 'Account settings | Outgoing server (SMTP)' to 'Normal password'.
+
+# LOCALIZATION NOTE (smtpHintAuthPlainToEncrypt): %S is the server hostname
+smtpHintAuthPlainToEncrypt=The Outgoing server (SMTP) %S does not allow plaintext passwords. Please try changing the 'Authentication method' in 'Account Settings | Outgoing server (SMTP)' to 'Encrypted password'.
+
+# LOCALIZATION NOTE (smtpAuthFailure): %S is the server hostname
+smtpAuthFailure=Unable to authenticate to Outgoing server (SMTP) %S. Please check the password and verify the 'Authentication method' in 'Account Settings | Outgoing server (SMTP)'.
+
+# LOCALIZATION NOTE (smtpAuthGssapi): %S is the server hostname
+smtpAuthGssapi=The Kerberos/GSSAPI ticket was not accepted by the Outgoing server (SMTP) %S. Please check that you are logged in to the Kerberos/GSSAPI realm.
+
+# LOCALIZATION NOTE (smtpAuthMechNotSupported): %S is the server hostname
+smtpAuthMechNotSupported=The Outgoing server (SMTP) %S does not support the selected authentication method. Please change the 'Authentication method' in 'Account Settings | Outgoing Server (SMTP)'.
+
+# LOCALIZATION NOTE (errorIllegalLocalPart2): %s is an email address with an illegal localpart
+errorIllegalLocalPart2=There are non-ASCII characters in the local part of the recipient address %s and your server does not support SMTPUTF8. Please change this address and try again.
+
+## Strings used for the save message dialog shown when the user closes a message compose window
+saveDlogTitle=Save Message
+
+## LOCALIZATION NOTE (saveDlogMessages3): Do not translate the words %1$S and \n.
+## %1$S is replaced by the folder name configured for saving drafts (typically the "Drafts" folder).
+## Translate "Write" to match the translation of item "windowTitleWrite" below.
+saveDlogMessages3=Save this message to your drafts folder (%1$S) and close the Write window?
+discardButtonLabel=&Discard changes
+
+## generics string
+defaultSubject=(no subject)
+chooseFileToAttach=Attach File(s)
+genericFailureExplanation=Please verify that your account settings are correct and try again.
+
+## LOCALIZATION NOTE (undisclosedRecipients): this string must use only US_ASCII characters
+undisclosedRecipients=undisclosed-recipients
+
+# LOCALIZATION NOTE (chooseFileToAttachViaCloud): %1$S is the cloud
+# provider to save the file to.
+chooseFileToAttachViaCloud=Attach File(s) via %1$S
+
+## Window titles
+# LOCALIZATION NOTE (windowTitleWrite):
+# %1$S is the message subject.
+# %2$S is the application name.
+# Example: Write: Re: Invitation - Thunderbird
+windowTitleWrite=Write: %1$S - %2$S
+# LOCALIZATION NOTE (windowTitlePrintPreview):
+# %1$S is the message subject.
+# %2$S is the application name.
+# Example: Print Preview: Re: Invitation - Thunderbird
+windowTitlePrintPreview=Print Preview: %1$S - %2$S
+
+## From field
+msgIdentityPlaceholder=Enter custom From address to be used instead of %S
+customizeFromAddressTitle=Customize From Address
+customizeFromAddressWarning=If your email provider supports it, Customize From Address allows you to make a one-off minor alteration to your From address without having to create a new identity in Account Settings. For example, if your From address is John Doe <john@example.com> you may want to change it to John Doe <john+doe@example.com> or John <john@example.com>.
+customizeFromAddressIgnore=Never notify me of this again
+
+## Strings used by the empty subject dialog
+subjectEmptyTitle=Subject Reminder
+subjectEmptyMessage=Your message doesn't have a subject.
+sendWithEmptySubjectButton=&Send Without Subject
+cancelSendingButton=&Cancel Sending
+
+## Strings used by the dialog that informs about the lack of newsgroup support.
+noNewsgroupSupportTitle=Newsgroups Not Supported
+recipientDlogMessage=This account only supports email recipients. Continuing will ignore newsgroups.
+
+## Strings used by the alert that tells the user that an email address is invalid.
+addressInvalidTitle=Invalid Recipient Address
+addressInvalid=%1$S is not a valid email address because it is not of the form user@host. You must correct it before sending the email.
+
+## String used by the dialog that asks the user to attach a web page
+attachPageDlogTitle=Please specify a location to attach
+attachPageDlogMessage=Web Page (URL):
+
+## String used for attachment pretty name, when the attachment is a message
+messageAttachmentSafeName=Attached Message
+
+## String used for attachment pretty name, when the attachment is a message part
+partAttachmentSafeName=Attached Message Part
+
+# LOCALIZATION NOTE (attachmentBucketAttachFilesTooltip):
+# This tooltip should be same as attachFile.label in messengercompose.dtd,
+# but without ellipsis (…).
+attachmentBucketAttachFilesTooltip=Attach File(s)
+attachmentBucketClearSelectionTooltip=Clear Selection
+attachmentBucketHeaderShowTooltip=Show attachment pane
+attachmentBucketHeaderMinimizeTooltip=Minimize attachment pane
+attachmentBucketHeaderRestoreTooltip=Restore attachment pane
+
+## String used by the Initialization Error dialog
+initErrorDlogTitle=Message Compose
+initErrorDlgMessage=An error occurred while creating a message compose window. Please try again.
+
+## String used if a file to attach does not exist when passed as
+## a command line argument
+errorFileAttachTitle=File Attach
+
+## LOCALIZATION NOTE (errorFileAttachMessage): %1$S will be replaced by the non-existent file name.
+errorFileAttachMessage=The file %1$S does not exist so could not be attached to the message.
+
+## String used if a file to serve as message body does not exist or cannot be loaded when passed
+## as a command line argument
+errorFileMessageTitle=Message File
+
+## LOCALIZATION NOTE (errorFileMessageMessage): %1$S will be replaced by the non-existent file name.
+errorFileMessageMessage=The file %1$S does not exist and could not be used as message body.
+
+## LOCALIZATION NOTE (errorLoadFileMessageMessage): %1$S will be replaced by the name of the file that can't be loaded.
+errorLoadFileMessageMessage=The file %1$S could not be loaded as message body.
+
+## Strings used by the Save as Draft/Template dialog
+SaveDialogTitle=Save Message
+
+## LOCALIZATION NOTE (SaveDialogMsg): %1$S is the folder name, %2$S is the host name
+SaveDialogMsg=Your message has been saved to the folder %1$S under %2$S.
+CheckMsg=Do not show me this dialog box again.
+
+## Strings used by the prompt when Quitting while in progress
+quitComposeWindowTitle=Sending Message
+
+## LOCALIZATION NOTE (quitComposeWindowMessage2): don't translate \n
+quitComposeWindowMessage2=%1$S is currently in the process of sending a message.\nWould you like to wait until the message has been sent before quitting or quit now?
+quitComposeWindowQuitButtonLabel2=&Quit
+quitComposeWindowWaitButtonLabel2=&Wait
+quitComposeWindowSaveTitle=Saving Message
+
+## LOCALIZATION NOTE (quitComposeWindowSaveMessage): don't translate \n
+quitComposeWindowSaveMessage=%1$S is currently in the process of saving a message.\nWould you like to wait until the message has been saved before quitting or quit now?
+
+## Strings used by the prompt for Ctrl-Enter check before sending message
+sendMessageCheckWindowTitle=Send Message
+sendMessageCheckLabel=Are you sure you are ready to send this message?
+sendMessageCheckSendButtonLabel=Send
+assemblingMessageDone=Assembling message…Done
+assemblingMessage=Assembling message…
+smtpDeliveringMail=Delivering mail…
+smtpMailSent=Mail sent successfully
+assemblingMailInformation=Assembling mail information…
+
+## LOCALIZATION NOTE (gatheringAttachment): argument %S is the file name/URI of attachment
+gatheringAttachment=Attaching %S…
+creatingMailMessage=Creating mail message…
+
+## LOCALIZATION NOTE (copyMessageStart): argument %S is the folder name
+copyMessageStart=Copying message to %S folder…
+copyMessageComplete=Copy complete.
+copyMessageFailed=Copy failed.
+filterMessageComplete=Filter complete.
+filterMessageFailed=Filter failed.
+
+## LOCALIZATION NOTE (largeMessageSendWarning):
+## Do not translate %S. It is the size of the message in user-friendly notation.
+largeMessageSendWarning=Warning! You are about to send a message of size %S. Are you sure you want to do this?
+sendingMessage=Sending message…
+sendMessageErrorTitle=Send Message Error
+postingMessage=Posting message…
+sendLaterErrorTitle=Send Later Error
+saveDraftErrorTitle=Save Draft Error
+saveTemplateErrorTitle=Save Template Error
+
+## LOCALIZATION NOTE (failureOnObjectEmbeddingWhileSaving): argument %.200S is the file name/URI of object to be embedded
+failureOnObjectEmbeddingWhileSaving=There was a problem including the file %.200S in the message. Would you like to continue saving the message without this file?
+
+## LOCALIZATION NOTE (failureOnObjectEmbeddingWhileSending): argument %.200S is the file name/URI of object to be embedded
+failureOnObjectEmbeddingWhileSending=There was a problem including the file %.200S in the message. Would you like to continue sending the message without this file?
+returnToComposeWindowQuestion=Would you like to return to the compose window?
+
+## reply header in composeMsg
+## LOCALIZATION NOTE (mailnews.reply_header_authorwrotesingle): #1 is the author (name of the person replying to)
+mailnews.reply_header_authorwrotesingle=#1 wrote:
+
+## LOCALIZATION NOTE (mailnews.reply_header_ondateauthorwrote): #1 is the author, #2 is the date, #3 is the time
+mailnews.reply_header_ondateauthorwrote=On #2 #3, #1 wrote:
+
+## LOCALIZATION NOTE (mailnews.reply_header_authorwroteondate): #1 is the author, #2 is the date, #3 is the time
+mailnews.reply_header_authorwroteondate=#1 wrote on #2 #3:
+
+## reply header in composeMsg
+## user specified
+mailnews.reply_header_originalmessage=-------- Original Message --------
+
+## forwarded header in composeMsg
+## user specified
+mailnews.forward_header_originalmessage=-------- Forwarded Message --------
+
+## Strings used by the rename attachment dialog
+renameAttachmentTitle=Rename Attachment
+renameAttachmentMessage=New attachment name:
+
+## Attachment Reminder
+## LOCALIZATION NOTE (mail.compose.attachment_reminder_keywords): comma separated
+## words that should trigger an attachment reminder.
+mail.compose.attachment_reminder_keywords=.doc,.pdf,.xls,.ppt,.rtf,.pps,attachment,attach,attached,attaching,enclosed,CV,cover letter
+
+remindLaterButton=Remind Me Later
+remindLaterButton.accesskey=L
+disableAttachmentReminderButton=Disable attachment reminder for current message
+attachmentReminderTitle=Attachment Reminder
+attachmentReminderMsg=Did you forget to add an attachment?
+
+# LOCALIZATION NOTE (attachmentReminderKeywordsMsgs): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# #1 number of keywords
+attachmentReminderKeywordsMsgs=Found an attachment keyword:;Found #1 attachment keywords:
+attachmentReminderOptionsMsg=Attachment reminder words can be configured in your preferences
+attachmentReminderYesIForgot=Oh, I did!
+attachmentReminderFalseAlarm=No, Send Now
+
+# Strings used by the Filelink offer notification bar.
+learnMore.label=Learn More…
+learnMore.accesskey=m
+
+# LOCALIZATION NOTE (bigFileDescription): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# #1 number of big attached files
+bigFileDescription=This is a large file. It might be better to use Filelink instead.;These are large files. It might be better to use Filelink instead.
+bigFileShare.label=Link
+bigFileShare.accesskey=l
+bigFileAttach.label=Ignore
+bigFileAttach.accesskey=i
+bigFileChooseAccount.title=Choose Account
+bigFileChooseAccount.text=Choose a cloud account to upload the attachment to
+bigFileHideNotification.title=Don't Upload My Files
+bigFileHideNotification.text=You won't be notified if you attach more big files to this message.
+bigFileHideNotification.check=Never notify me of this again.
+
+# LOCALIZATION NOTE(cloudFileUploadingTooltip): Do not translate the string
+# %S. %S is the display name for the cloud account the attachment is being
+# uploaded to.
+cloudFileUploadingTooltip=Uploading to %S…
+
+# LOCALIZATION NOTE(cloudFileUploadedTooltip): Do not translate the string
+# %S. %S is the display name for the cloud account the attachment was uploaded
+# to.
+cloudFileUploadedTooltip=Uploaded to %S
+cloudFileUploadingNotification=Your file is being linked. It will appear in the body of the message when it's done.;Your files are being linked. They will appear in the body of the message when it's done.
+cloudFileUploadingCancel.label=Cancel
+cloudFileUploadingCancel.accesskey=c
+cloudFilePrivacyNotification=Linking is complete. Please note that linked attachments may be accessible to people who can see or guess the links.
+
+## LOCALIZATION NOTE(smtpEnterPasswordPrompt): Do not translate the
+## word %S. Place the word %S where the host name should appear.
+smtpEnterPasswordPrompt=Enter your password for %S:
+
+## LOCALIZATION NOTE(smtpEnterPasswordPromptWithUsername): Do not translate the
+## words %1$S and %2$S. Place the word %1$S where the host name should appear,
+## and %2$S where the user name should appear.
+smtpEnterPasswordPromptWithUsername=Enter your password for %2$S on %1$S:
+## LOCALIZATION NOTE(smtpEnterPasswordPromptTitleWithHostname): Do not translate the
+## word %1$S. Place the word %1$S where the server host name should appear.
+smtpEnterPasswordPromptTitleWithHostname=Password Required for Outgoing (SMTP) Server %1$S
+
+# LOCALIZATION NOTE (removeAttachmentMsgs): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+removeAttachmentMsgs=Remove Attachment;Remove Attachments
+
+## LOCALIZATION NOTE(promptToSaveSentLocally2): Do not translate the strings %1$S, %2$S, %3$S and \n.
+## %2$S will be replaced with the account name. $1$S will be replaced by the folder name
+## configured to contain saved sent messages (typically the "Sent" folder).
+## %3$S will be replaced with the local folders account name (typically "Local Folders").
+## Translate "Write" to match the translation of item "windowTitleWrite" above.
+promptToSaveSentLocally2=Your message was sent but a copy was not placed in your sent folder (%1$S) due to network or file access errors.\nYou can retry or save the message locally to %3$S/%1$S-%2$S.
+errorFilteringMsg=Your message has been sent and saved, but there was an error while running message filters on it.
+errorCloudFileAuth.title=Authentication Error
+
+## LOCALIZATION NOTE(promptToSaveDraftLocally2): Do not translate the strings %1$S, %2$S, %3$S and \n.
+## %2$S will be replaced with the account name. $1$S will be replaced by the folder name
+## configured to contain saved draft messages (typically the "Drafts" folder).
+## %3$S will be replaced with the local folders account name (typically "Local Folders").
+promptToSaveDraftLocally2=Your draft message was not copied to your drafts folder (%1$S) due to network or file access errors.\nYou can retry or save the draft locally to %3$S/%1$S-%2$S.
+buttonLabelRetry2=&Retry
+
+## LOCALIZATION NOTE(promptToSaveTemplateLocally2): Do not translate the strings %1$S, %2$S, %3$S and \n.
+## %2$S will be replaced with the account name. $1$S will be replaced by the folder name
+## configured to contain saved templates (typically the "Templates" folder).
+## %3$S will be replaced with the local folders account name (typically "Local Folders").
+promptToSaveTemplateLocally2=Your template was not copied to your templates folder (%1$S) due to network or file access errors.\nYou can retry or save the template locally to %3$S/%1$S-%2$S.
+
+## LOCALIZATION NOTE(saveToLocalFoldersFailed): Message appears after normal
+## save fails (e.g., to Sent) and save to Local Folders also fails. This could
+## occur if network is down and filesystem problems are present such as disk
+## full, permission issues or hardware failure.
+saveToLocalFoldersFailed=Unable to save your message to local folders. Possibly out of file storage space.
+
+## LOCALIZATION NOTE(errorCloudFileAuth.message):
+## %1$S is the name of the online storage service against which the authentication failed.
+errorCloudFileAuth.message=Unable to authenticate to %1$S.
+errorCloudFileUpload.title=Upload Error
+
+## LOCALIZATION NOTE(errorCloudFileUpload.message):
+## %1$S is the name of the online storage service against which the uploading failed.
+## %2$S is the name of the file that failed to upload.
+errorCloudFileUpload.message=Unable to upload %2$S to %1$S.
+errorCloudFileQuota.title=Quota Error
+
+## LOCALIZATION NOTE(errorCloudFileQuota.message):
+## %1$S is the name of the online storage service being uploaded to.
+## %2$S is the name of the file that could not be uploaded due to exceeding the storage limit.
+errorCloudFileQuota.message=Uploading %2$S to %1$S would exceed your space quota.
+errorCloudFileLimit.title=File Size Error
+
+## LOCALIZATION NOTE(errorCloudFileLimit.message):
+## %1$S is the name of the online storage service being uploaded to.
+## %2$S is the name of the file that could not be uploaded due to size restrictions.
+errorCloudFileLimit.message=%2$S exceeds the maximum size for %1$S.
+errorCloudFileOther.title=Unknown Error
+
+## LOCALIZATION NOTE(errorCloudFileOther.message):
+## %1$S is the name of the online storage service that cannot be communicated with.
+errorCloudFileOther.message=An unknown error occurred when communicating with %1$S.
+errorCloudFileDeletion.title=Deletion Error
+
+## LOCALIZATION NOTE(errorCloudFileDeletion.message):
+## %1$S is the name of the online storage service that the file is to be deleted from.
+## %2$S is the name of the file that failed to be deleted.
+errorCloudFileDeletion.message=There was a problem deleting %2$S from %1$S.
+errorCloudFileUpgrade.label=Upgrade
+
+## LOCALIZATION NOTE(stopShowingUploadingNotification): This string is used in the Filelink
+## upload notification bar to allow the user to dismiss the notification permanently.
+stopShowingUploadingNotification.accesskey=N
+stopShowingUploadingNotification.label=Never show this again
+replaceButton.label=Replace…
+replaceButton.accesskey=x
+replaceButton.tooltip=Show the Find and Replace dialog
+
+## LOCALIZATION NOTE(blockedAllowResource): %S is the URL to load.
+blockedAllowResource=Unblock %S
+## LOCALIZATION NOTE (blockedContentMessage): Semi-colon list of plural forms.
+## See: https://developer.mozilla.org/en/docs/Localization_and_Plurals
+## %S will be replaced by brandShortName.
+## Files must be unblocked individually, therefore the plural form reads:
+## Unblocking a file (one of several) will include it (that one file) in your sent message.
+## In other words:
+## Unblocking one/several file(s) will include it/them in your message.
+blockedContentMessage=%S has blocked a file from loading into this message. Unblocking the file will include it in your sent message.;%S has blocked some files from loading into this message. Unblocking a file will include it in your sent message.
+
+blockedContentPrefLabel=Options
+blockedContentPrefAccesskey=O
+
+blockedContentPrefLabelUnix=Preferences
+blockedContentPrefAccesskeyUnix=P
+
+## Recipient pills fields.
+## LOCALIZATION NOTE(confirmRemoveRecipientRowTitle2): %S will be replaced with the field name.
+confirmRemoveRecipientRowTitle2=Remove %S Addresses
+## LOCALIZATION NOTE(confirmRemoveRecipientRowBody2): %S will be replaced with the field name.
+confirmRemoveRecipientRowBody2=Are you sure you want to remove the %S addresses?
+confirmRemoveRecipientRowButton=Remove
+
+## LOCALIZATION NOTE headersSpaceStyle is for aligning label of a newly create recipient row.
+## It should be larger than the largest Header label and identical to &headersSpace2.style;
+headersSpaceStyle=width: 8em
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/editor.properties b/comm/mail/locales/en-US/chrome/messenger/messengercompose/editor.properties
new file mode 100644
index 0000000000..de9f688ed8
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/editor.properties
@@ -0,0 +1,208 @@
+# 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/.
+
+# LOCALIZATION NOTE FILE: embedded "\n" represent HTML breaks (<br>)
+# Don't translate embedded "\n".
+# Don't translate strings like this: %variable%
+# as they will be replaced using JavaScript
+#
+No=No
+Save=Save
+More=More
+Less=Less
+MoreProperties=More Properties
+FewerProperties=Fewer Properties
+PropertiesAccessKey=P
+None=None
+none=none
+OpenHTMLFile=Open HTML File
+OpenTextFile=Open Text File
+SelectImageFile=Select Image File
+SaveDocument=Save Page
+SaveDocumentAs=Save Page As
+SaveTextAs=Save Text As
+EditMode=Edit Mode
+Preview=Preview
+Publish=Publish
+PublishPage=Publish Page
+DontPublish=Don't Publish
+SavePassword=Use Password Manager to save this password
+CorrectSpelling=(correct spelling)
+NoSuggestedWords=(no suggested words)
+NoMisspelledWord=No misspelled words
+CheckSpellingDone=Completed spell checking.
+CheckSpelling=Check Spelling
+InputError=Error
+Alert=Alert
+CantEditFramesetMsg=Composer cannot edit HTML framesets, or pages with inline frames. For framesets, try editing the page for each frame separately. For pages with iframes, save a copy of the page and remove the <iframe> tag.
+CantEditMimeTypeMsg=This type of page can't be edited.
+CantEditDocumentMsg=This page can't be edited for an unknown reason.
+BeforeClosing=before closing
+BeforePreview=before viewing in the browser
+BeforeValidate=before validating the document
+# LOCALIZATION NOTE (SaveFilePrompt, PublishPrompt): Don't translate %title% and %reason% (this is the reason for asking user to close, such as "before closing")
+SaveFilePrompt=Save changes to "%title%" %reason%?
+PublishPrompt=Save changes to "%title%" %reason%?
+SaveFileFailed=Saving file failed!
+
+# Publishing error strings:
+# LOCALIZATION NOTE Don't translate %dir% or %file% in the Publishing error strings:
+FileNotFound=%file% not found.
+SubdirDoesNotExist=The subdirectory "%dir%" doesn't exist on this site or the filename "%file%" is already in use by another subdirectory.
+FilenameIsSubdir=The filename "%file%" is already in use by another subdirectory.
+ServerNotAvailable=The server is not available. Check your connection and try again later.
+Offline=You are currently offline. Click the icon near the lower-right corner of any window to go online.
+DiskFull=There is not enough disk space available to save the file "%file%."
+NameTooLong=The filename or subdirectory name is too long.
+AccessDenied=You do not have permission to publish to this location.
+UnknownPublishError=Unknown publishing error occurred.
+PublishFailed=Publishing failed.
+PublishCompleted=Publishing completed.
+AllFilesPublished=All files published
+# LOCALIZATION NOTE Don't translate %x% or %total%
+FailedFileMsg=%x% of %total% files failed to publish.
+# End-Publishing error strings
+Prompt=Prompt
+# LOCALIZATION NOTE (PromptFTPUsernamePassword): Don't translate %host%
+PromptFTPUsernamePassword=Enter username and password for FTP server at %host%
+RevertCaption=Revert To Last Saved
+Revert=Revert
+SendPageReason=before sending this page
+Send=Send
+## LOCALIZATION NOTE (PublishProgressCaption, PublishToSite, AbandonChanges): Don't translate %title%
+PublishProgressCaption=Publishing: %title%
+PublishToSite=Publishing to Site: %title%
+AbandonChanges=Abandon unsaved changes to "%title%" and reload page?
+DocumentTitle=Page Title
+NeedDocTitle=Please enter a title for the current page.
+DocTitleHelp=This identifies the page in the window title and bookmarks.
+CancelPublishTitle=Cancel publishing?
+## LOCALIZATION NOTE: "Continue" in this sentence must match the text for
+## the CancelPublishContinue key below
+CancelPublishMessage=Cancelling while publishing is in progress may result in your file(s) being incompletely transferred. Would you like to Continue or Cancel?
+CancelPublishContinue=Continue
+MissingImageError=Please enter or choose an image of type gif, jpg, or png.
+EmptyHREFError=Please choose a location to create a new link.
+LinkText=Link Text
+LinkImage=Link Image
+MixedSelection=[Mixed selection]
+Mixed=(mixed)
+# LOCALIZATION NOTE (NotInstalled): %S is the name of the font
+NotInstalled=%S (not installed)
+EnterLinkText=Enter text to display for the link:
+EnterLinkTextAccessKey=T
+EmptyLinkTextError=Please enter some text for this link.
+EditTextWarning=This will replace existing content.
+#LOCALIZATION NOTE (ValidateNumber):Don't translate: %n% %min% %max%
+ValidateRangeMsg=The number you entered (%n%) is outside of the allowed range.
+ValidateNumberMsg=Please enter a number between %min% and %max%.
+MissingAnchorNameError=Please enter a name for this anchor.
+#LOCALIZATION NOTE (DuplicateAnchorNameError): Don't translate %name%
+DuplicateAnchorNameError="%name%" already exists in this page. Please enter a different name.
+BulletStyle=Bullet Style
+SolidCircle=Solid circle
+OpenCircle=Open circle
+SolidSquare=Solid square
+NumberStyle=Number Style
+Automatic=Automatic
+Style_1=1, 2, 3…
+Style_I=I, II, III…
+Style_i=i, ii, iii…
+Style_A=A, B, C…
+Style_a=a, b, c…
+Pixels=pixels
+Percent=percent
+PercentOfCell=% of cell
+PercentOfWindow=% of window
+PercentOfTable=% of table
+#LOCALIZATION NOTE (untitledTitle): %S is the window #. No plural handling needed.
+untitledTitle=untitled-%S
+untitledDefaultFilename=untitled
+ShowToolbar=Show Toolbar
+HideToolbar=Hide Toolbar
+ImapError=Unable to load image
+ImapCheck=\nPlease select a new location (URL) and try again.
+SaveToUseRelativeUrl=Relative URLs can only be used on pages which have been saved
+NoNamedAnchorsOrHeadings=(No named anchors or headings in this page)
+TextColor=Text Color
+HighlightColor=Highlight Color
+PageColor=Page Background Color
+BlockColor=Block Background Color
+TableColor=Table Background Color
+CellColor=Cell Background Color
+TableOrCellColor=Table or Cell Color
+LinkColor=Link Text Color
+ActiveLinkColor=Active Link Color
+VisitedLinkColor=Visited Link Color
+NoColorError=Click on a color or enter a valid HTML color string
+Table=Table
+TableCell=Table Cell
+NestedTable=Nested Table
+HLine=Horizontal Line
+Link=Link
+Image=Image
+ImageAndLink=Image and Link
+NamedAnchor=Named Anchor
+List=List
+ListItem=List Item
+Form=Form
+InputTag=Form Field
+InputImage=Form Image
+TextArea=Text Area
+Select=Selection List
+Button=Button
+Label=Label
+FieldSet=Field Set
+Tag=Tag
+MissingSiteNameError=Please enter a name for this publishing site.
+MissingPublishUrlError=Please enter a location for publishing this page.
+MissingPublishFilename=Please enter a filename for the current page.
+#LOCALIZATION NOTE (DuplicateSiteNameError): Don't translate %name%
+DuplicateSiteNameError="%name%" already exists. Please enter a different site name.
+AdvancedProperties=Advanced Properties…
+AdvancedEditForCellMsg=Advanced Edit is unavailable when multiple cells are selected
+# LOCALIZATION NOTE (ObjectProperties):Don't translate "%obj%" it will be replaced with one of above object nouns
+ObjectProperties=%obj% Properties…
+# LOCALIZATION NOTE This character must be in the above string and not conflict with other accesskeys in Format menu
+ObjectPropertiesAccessKey=o
+# LOCALIZATION NOTE (JoinSelectedCells): This variable should contain the "tableJoinCells.accesskey"
+# letter as defined in editorOverlay.dtd
+JoinSelectedCells=Join Selected Cells
+# LOCALIZATION NOTE (JoinCellToRight): This variable should contain the "tableJoinCells.accesskey"
+# letter as defined in editorOverlay.dtd
+JoinCellToRight=Join with Cell to the Right
+JoinCellAccesskey=j
+# LOCALIZATION NOTE (TableSelectKey): Ctrl key on a keyboard
+TableSelectKey=Ctrl+
+# LOCALIZATION NOTE (XulKeyMac): Command key on a Mac keyboard
+XulKeyMac=Cmd+
+# LOCALIZATION NOTE (Del): Del key on a keyboard
+Del=Del
+Delete=Delete
+DeleteCells=Delete Cells
+DeleteTableTitle=Delete Rows or Columns
+DeleteTableMsg=Reducing the number of rows or columns will delete table cells and their contents. Do you really want to do this?
+Clear=Clear
+#Mouse actions
+Click=Click
+Drag=Drag
+Unknown=Unknown
+#
+# LOCALIZATION NOTE "RemoveTextStylesAccesskey" is used for both
+# menu items: "RemoveTextStyles" and "StopTextStyles"
+RemoveTextStylesAccesskey=x
+RemoveTextStyles=Remove All Text Styles
+StopTextStyles=Discontinue Text Styles
+#
+# LOCALIZATION NOTE "RemoveLinksAccesskey" is used for both
+# menu items: "RemoveLinks" and "StopLinks"
+RemoveLinksAccesskey=n
+RemoveLinks=Remove Links
+StopLinks=Discontinue Link
+#
+NoFormAction=It is recommended that you enter an action for this form. Self-posting forms are an advanced technique that may not work consistently in all browsers.
+NoAltText=If the image is relevant to the content of the document, you must supply alternate text that will appear in text-only browsers, and that will appear in other browsers when an image is loading or when image loading is disabled.
+#
+Malformed=The source could not be converted back into the document because it is not valid XHTML.
+NoLinksToCheck=There are no elements with links to check
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/editorOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/editorOverlay.dtd
new file mode 100644
index 0000000000..5dab39ce19
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/editorOverlay.dtd
@@ -0,0 +1,304 @@
+<!-- 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/. -->
+
+<!-- Attn: Localization - some of the menus in this dialog directly affect mail also. -->
+
+<!-- Edit menu items -->
+<!ENTITY pasteNoFormatting.label "Paste Without Formatting">
+<!ENTITY pasteNoFormatting.accesskey "n">
+<!ENTITY pasteNoFormatting.key "V">
+<!ENTITY pasteAsQuotationCmd.label "Paste As Quotation">
+<!ENTITY pasteAsQuotationCmd.accesskey "Q">
+
+<!-- Insert menu items -->
+<!ENTITY insertMenu.label "Insert">
+<!ENTITY insertMenu.accesskey "I">
+<!ENTITY insertLinkCmd2.label "Link…">
+<!ENTITY insertLinkCmd2.accesskey "L">
+<!ENTITY insertLinkCmd2.key "K">
+<!ENTITY insertAnchorCmd.label "Named Anchor…">
+<!ENTITY insertAnchorCmd.accesskey "A">
+<!ENTITY insertImageCmd.label "Image…">
+<!ENTITY insertImageCmd.accesskey "I">
+<!ENTITY insertHLineCmd.label "Horizontal Line">
+<!ENTITY insertHLineCmd.accesskey "o">
+<!ENTITY insertTableCmd.label "Table…">
+<!ENTITY insertTableCmd.accesskey "T">
+<!ENTITY insertHTMLCmd.label "HTML…">
+<!ENTITY insertHTMLCmd.accesskey "H">
+<!ENTITY insertMathCmd.label "Math…">
+<!ENTITY insertMathCmd.accesskey "M">
+<!ENTITY insertCharsCmd.label "Characters and Symbols…">
+<!ENTITY insertCharsCmd.accesskey "C">
+<!ENTITY insertBreakAllCmd.label "Break Below Image(s)">
+<!ENTITY insertBreakAllCmd.accesskey "k">
+
+<!-- Used just in context popup. -->
+<!ENTITY createLinkCmd.label "Create Link…">
+<!ENTITY createLinkCmd.accesskey "k">
+<!ENTITY editLinkCmd.label "Edit Link in New Composer">
+<!ENTITY editLinkCmd.accesskey "i">
+
+<!-- Font Face SubMenu -->
+<!ENTITY FontFaceSelect.tooltip "Choose a font">
+<!ENTITY fontfaceMenu.label "Font">
+<!ENTITY fontfaceMenu.accesskey "F">
+<!ENTITY fontVarWidth.label "Variable Width">
+<!ENTITY fontVarWidth.accesskey "V">
+<!ENTITY fontFixedWidth.label "Fixed Width">
+<!ENTITY fontFixedWidth.accesskey "x">
+<!ENTITY fontFixedWidth.key "T">
+<!ENTITY fontHelvetica.label "Helvetica, Arial">
+<!ENTITY fontHelvetica.accesskey "l">
+<!ENTITY fontTimes.label "Times">
+<!ENTITY fontTimes.accesskey "T">
+<!ENTITY fontCourier.label "Courier">
+<!ENTITY fontCourier.accesskey "C">
+
+<!-- Font Size SubMenu -->
+<!ENTITY FontSizeSelect.tooltip "Choose a font size">
+<!ENTITY decreaseFontSize.label "Smaller">
+<!ENTITY decreaseFontSize.accesskey "r">
+<!ENTITY decrementFontSize.key "&lt;">
+<!ENTITY decrementFontSize.key2 ","> <!-- < is above this key on many keyboards -->
+<!ENTITY increaseFontSize.label "Larger">
+<!ENTITY increaseFontSize.accesskey "g">
+<!ENTITY incrementFontSize.key "&gt;">
+<!ENTITY incrementFontSize.key2 "."> <!-- > is above this key on many keyboards -->
+
+<!ENTITY fontSizeMenu.label "Size">
+<!ENTITY fontSizeMenu.accesskey "z">
+<!ENTITY size-tinyCmd.label "Tiny">
+<!ENTITY size-tinyCmd.accesskey "T">
+<!ENTITY size-smallCmd.label "Small">
+<!ENTITY size-smallCmd.accesskey "S">
+<!ENTITY size-mediumCmd.label "Medium">
+<!ENTITY size-mediumCmd.accesskey "M">
+<!ENTITY size-largeCmd.label "Large">
+<!ENTITY size-largeCmd.accesskey "L">
+<!ENTITY size-extraLargeCmd.label "Extra Large">
+<!ENTITY size-extraLargeCmd.accesskey "x">
+<!ENTITY size-hugeCmd.label "Huge">
+<!ENTITY size-hugeCmd.accesskey "H">
+
+<!-- Font Style SubMenu -->
+<!ENTITY fontStyleMenu.label "Text Style">
+<!ENTITY fontStyleMenu.accesskey "S">
+<!ENTITY styleBoldCmd.label "Bold">
+<!ENTITY styleBoldCmd.accesskey "B">
+<!ENTITY styleBoldCmd.key "B">
+<!ENTITY styleItalicCmd.label "Italic">
+<!ENTITY styleItalicCmd.accesskey "I">
+<!ENTITY styleItalicCmd.key "I">
+<!ENTITY styleUnderlineCmd.label "Underline">
+<!ENTITY styleUnderlineCmd.accesskey "U">
+<!ENTITY styleUnderlineCmd.key "U">
+<!ENTITY styleStrikeThruCmd.label "Strikethrough">
+<!ENTITY styleStrikeThruCmd.accesskey "k">
+<!ENTITY styleSuperscriptCmd.label "Superscript">
+<!ENTITY styleSuperscriptCmd.accesskey "p">
+<!ENTITY styleSubscriptCmd.label "Subscript">
+<!ENTITY styleSubscriptCmd.accesskey "S">
+<!ENTITY styleNonbreakingCmd.label "Nonbreaking">
+<!ENTITY styleNonbreakingCmd.accesskey "N">
+<!ENTITY styleEm.label "Emphasis">
+<!ENTITY styleEm.accesskey "E">
+<!ENTITY styleStrong.label "Stronger Emphasis">
+<!ENTITY styleStrong.accesskey "t">
+<!ENTITY styleCite.label "Citation">
+<!ENTITY styleCite.accesskey "C">
+<!ENTITY styleAbbr.label "Abbreviation">
+<!ENTITY styleAbbr.accesskey "A">
+<!ENTITY styleAcronym.label "Acronym">
+<!ENTITY styleAcronym.accesskey "r">
+<!ENTITY styleCode.label "Code">
+<!ENTITY styleCode.accesskey "o">
+<!ENTITY styleSamp.label "Sample Output">
+<!ENTITY styleSamp.accesskey "m">
+<!ENTITY styleVar.label "Variable">
+<!ENTITY styleVar.accesskey "V">
+
+<!ENTITY formatFontColor.label "Text Color…">
+<!ENTITY formatFontColor.accesskey "C">
+<!ENTITY tableOrCellColor.label "Table or Cell Background Color…">
+<!ENTITY tableOrCellColor.accesskey "B">
+
+<!ENTITY formatRemoveStyles.key "Y">
+<!ENTITY formatRemoveLinks.key "K">
+<!ENTITY formatRemoveNamedAnchors.label "Remove Named Anchors">
+<!ENTITY formatRemoveNamedAnchors.accesskey "R">
+<!ENTITY formatRemoveNamedAnchors2.key "R">
+
+<!ENTITY paragraphMenu.label "Paragraph">
+<!ENTITY paragraphMenu.accesskey "P">
+<!ENTITY paragraphParagraphCmd.label "Paragraph">
+<!ENTITY paragraphParagraphCmd.accesskey "P">
+<!ENTITY heading1Cmd.label "Heading 1">
+<!ENTITY heading1Cmd.accesskey "1">
+<!ENTITY heading2Cmd.label "Heading 2">
+<!ENTITY heading2Cmd.accesskey "2">
+<!ENTITY heading3Cmd.label "Heading 3">
+<!ENTITY heading3Cmd.accesskey "3">
+<!ENTITY heading4Cmd.label "Heading 4">
+<!ENTITY heading4Cmd.accesskey "4">
+<!ENTITY heading5Cmd.label "Heading 5">
+<!ENTITY heading5Cmd.accesskey "5">
+<!ENTITY heading6Cmd.label "Heading 6">
+<!ENTITY heading6Cmd.accesskey "6">
+<!ENTITY paragraphAddressCmd.label "Address">
+<!ENTITY paragraphAddressCmd.accesskey "A">
+<!ENTITY paragraphPreformatCmd.label "Preformat">
+<!ENTITY paragraphPreformatCmd.accesskey "f">
+
+<!-- List menu items -->
+<!ENTITY formatlistMenu.label "List">
+<!ENTITY formatlistMenu.accesskey "L">
+<!ENTITY noneCmd.label "None">
+<!ENTITY noneCmd.accesskey "N">
+<!ENTITY listBulletCmd.label "Bulleted">
+<!ENTITY listBulletCmd.accesskey "B">
+<!ENTITY listNumberedCmd.label "Numbered">
+<!ENTITY listNumberedCmd.accesskey "m">
+<!ENTITY listTermCmd.label "Term">
+<!ENTITY listTermCmd.accesskey "T">
+<!ENTITY listDefinitionCmd.label "Definition">
+<!ENTITY listDefinitionCmd.accesskey "D">
+<!ENTITY listPropsCmd.label "List Properties…">
+<!ENTITY listPropsCmd.accesskey "L">
+
+<!ENTITY ParagraphSelect.tooltip "Choose a paragraph format">
+<!-- Shared in Paragraph, and Toolbar menulist -->
+<!ENTITY bodyTextCmd.label "Body Text">
+<!ENTITY bodyTextCmd.accesskey "T">
+
+<!-- Align menu items -->
+<!ENTITY alignMenu.label "Align">
+<!ENTITY alignMenu.accesskey "A">
+<!ENTITY alignLeft.label "Left">
+<!ENTITY alignLeft.accesskey "L">
+<!ENTITY alignLeft.tooltip "Align Left">
+<!ENTITY alignCenter.label "Center">
+<!ENTITY alignCenter.accesskey "C">
+<!ENTITY alignCenter.tooltip "Align Center">
+<!ENTITY alignRight.label "Right">
+<!ENTITY alignRight.accesskey "R">
+<!ENTITY alignRight.tooltip "Align Right">
+<!ENTITY alignJustify.label "Justify">
+<!ENTITY alignJustify.accesskey "J">
+<!ENTITY alignJustify.tooltip "Align Justified">
+
+<!ENTITY increaseIndent.label "Increase Indent">
+<!ENTITY increaseIndent.accesskey "I">
+<!ENTITY increaseIndent.key "]">
+<!ENTITY decreaseIndent.label "Decrease Indent">
+<!ENTITY decreaseIndent.accesskey "D">
+<!ENTITY decreaseIndent.key "[">
+
+<!ENTITY colorsAndBackground.label "Page Colors and Background…">
+<!ENTITY colorsAndBackground.accesskey "u">
+
+<!-- Table Menu -->
+<!ENTITY tableMenu.label "Table">
+<!ENTITY tableMenu.accesskey "b">
+
+<!-- Select Submenu -->
+<!ENTITY tableSelectMenu.label "Select">
+<!ENTITY tableSelectMenu.accesskey "S">
+
+<!ENTITY tableSelectMenu2.label "Table Select">
+<!ENTITY tableSelectMenu2.accesskey "S">
+<!ENTITY tableInsertMenu2.label "Table Insert">
+<!ENTITY tableInsertMenu2.accesskey "I">
+<!ENTITY tableDeleteMenu2.label "Table Delete">
+<!ENTITY tableDeleteMenu2.accesskey "D">
+
+<!-- Insert SubMenu -->
+<!ENTITY tableInsertMenu.label "Insert">
+<!ENTITY tableInsertMenu.accesskey "I">
+<!ENTITY tableTable.label "Table">
+<!ENTITY tableTable.accesskey "T">
+<!ENTITY tableRow.label "Row">
+<!ENTITY tableRows.label "Row(s)">
+<!ENTITY tableRow.accesskey "R">
+<!ENTITY tableRowAbove.label "Row Above">
+<!ENTITY tableRowAbove.accesskey "R">
+<!ENTITY tableRowBelow.label "Row Below">
+<!ENTITY tableRowBelow.accesskey "B">
+<!ENTITY tableColumn.label "Column">
+<!ENTITY tableColumns.label "Column(s)">
+<!ENTITY tableColumn.accesskey "o">
+<!ENTITY tableColumnBefore.label "Column Before">
+<!ENTITY tableColumnBefore.accesskey "o">
+<!ENTITY tableColumnAfter.label "Column After">
+<!ENTITY tableColumnAfter.accesskey "A">
+<!ENTITY tableCell.label "Cell">
+<!ENTITY tableCells.label "Cell(s)">
+<!ENTITY tableCell.accesskey "C">
+<!ENTITY tableCellContents.label "Cell Contents">
+<!ENTITY tableCellContents.accesskey "n">
+<!ENTITY tableAllCells.label "All Cells">
+<!ENTITY tableAllCells.accesskey "A">
+<!ENTITY tableCellBefore.label "Cell Before">
+<!ENTITY tableCellBefore.accesskey "C">
+<!ENTITY tableCellAfter.label "Cell After">
+<!ENTITY tableCellAfter.accesskey "f">
+<!-- Delete SubMenu -->
+<!ENTITY tableDeleteMenu.label "Delete">
+<!ENTITY tableDeleteMenu.accesskey "D">
+
+<!-- text for "Join Cells" is in editor.properties
+ ("JoinSelectedCells" and "JoinCellToRight")
+ the access key must exist in both of those strings
+ But value must be set here for accesskey to draw properly
+-->
+<!ENTITY tableJoinCells.label "j">
+<!ENTITY tableJoinCells.accesskey "j">
+<!ENTITY tableSplitCell.label "Split Cell">
+<!ENTITY tableSplitCell.accesskey "C">
+<!ENTITY convertToTable.label "Create Table from Selection">
+<!ENTITY convertToTable.accesskey "r">
+<!ENTITY tableProperties.label "Table Properties…">
+<!ENTITY tableProperties.accesskey "o">
+
+<!-- Toolbar-only items -->
+<!ENTITY imageToolbarCmd.label "Image">
+<!ENTITY imageToolbarCmd.tooltip "Insert new image or edit selected image's properties">
+<!ENTITY hruleToolbarCmd.label "H.Line">
+<!ENTITY hruleToolbarCmd.tooltip "Insert horizontal line or edit selected line's properties">
+<!ENTITY tableToolbarCmd.label "Table">
+<!ENTITY tableToolbarCmd.tooltip "Insert new table or edit selected table's properties">
+<!ENTITY linkToolbarCmd.label "Link">
+<!ENTITY linkToolbarCmd.tooltip "Insert new link or edit selected link's properties">
+<!ENTITY anchorToolbarCmd.label "Anchor">
+<!ENTITY anchorToolbarCmd.tooltip "Insert new named anchor or edit selected anchor's properties">
+<!ENTITY TextColorButton.tooltip "Choose color for text">
+<!ENTITY BackgroundColorButton.tooltip "Choose color for background">
+
+<!-- Editor toolbar -->
+<!ENTITY absoluteFontSizeToolbarCmd.tooltip "Set font size">
+<!ENTITY decreaseFontSizeToolbarCmd.tooltip "Smaller font size">
+<!ENTITY increaseFontSizeToolbarCmd.tooltip "Larger font size">
+<!ENTITY boldToolbarCmd.tooltip "Bold">
+<!ENTITY italicToolbarCmd.tooltip "Italic">
+<!ENTITY underlineToolbarCmd.tooltip "Underline">
+<!ENTITY bulletListToolbarCmd.tooltip "Apply or remove bulleted list">
+<!ENTITY numberListToolbarCmd.tooltip "Apply or remove numbered list">
+<!ENTITY outdentToolbarCmd.tooltip "Outdent text (move left)">
+<!ENTITY indentToolbarCmd.tooltip "Indent text (move right)">
+<!ENTITY AlignPopupButton.tooltip "Choose text alignment">
+<!ENTITY InsertPopupButton.tooltip "Insert a Link, Anchor, Image, Horizontal Line, or Table">
+<!ENTITY alignLeftButton.tooltip "Align text along left margin">
+<!ENTITY alignCenterButton.tooltip "Align text centered">
+<!ENTITY alignRightButton.tooltip "Align text along right margin">
+<!ENTITY alignJustifyButton.tooltip "Align text along left and right margins">
+
+<!-- TOC manipulation -->
+<!ENTITY insertTOC.label "Insert">
+<!ENTITY insertTOC.accesskey "i">
+<!ENTITY updateTOC.label "Update">
+<!ENTITY updateTOC.accesskey "u">
+<!ENTITY removeTOC.label "Remove">
+<!ENTITY removeTOC.accesskey "r">
+<!ENTITY tocMenu.label "Table of Contents…">
+<!ENTITY tocMenu.accesskey "b">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/mailComposeEditorOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/mailComposeEditorOverlay.dtd
new file mode 100755
index 0000000000..e367a329b0
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/mailComposeEditorOverlay.dtd
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+
+<!ENTITY attachImageSource.label "Attach this image to the message">
+<!ENTITY attachImageSource.accesskey "s">
+
+<!ENTITY attachLinkSource.label "Attach the source of this link to the message">
+<!ENTITY attachLinkSource.accesskey "s">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/messengercompose.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/messengercompose.dtd
new file mode 100644
index 0000000000..969189a02c
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/messengercompose.dtd
@@ -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/. -->
+
+<!--LOCALIZATION NOTE messengercompose.dtd Main UI for message composition -->
+<!ENTITY msgComposeWindow.title "Write: (no subject)">
+
+<!-- File Menu -->
+<!ENTITY fileMenu.label "File">
+<!ENTITY fileMenu.accesskey "f">
+<!ENTITY newMenu.label "New">
+<!ENTITY newMenu.accesskey "N">
+<!ENTITY newMessage.label "Message">
+<!ENTITY newMessage.accesskey "M">
+<!ENTITY newMessageCmd2.key "N">
+<!ENTITY newContact.label "Address Book Contact…">
+<!ENTITY newContact.accesskey "C">
+<!ENTITY attachMenu.label "Attach">
+<!ENTITY attachMenu.accesskey "h">
+<!ENTITY attachCloudCmd.label "Filelink">
+<!ENTITY attachCloudCmd.accesskey "i">
+<!ENTITY attachPageCmd.label "Web Page…">
+<!ENTITY attachPageCmd.accesskey "W">
+<!ENTITY remindLater.label "Remind Me Later">
+<!ENTITY remindLater.accesskey "L">
+<!ENTITY closeCmd.label "Close">
+<!ENTITY closeCmd.key "W">
+<!ENTITY closeCmd.accesskey "c">
+<!ENTITY saveCmd.label "Save">
+<!ENTITY saveCmd.key "S">
+<!ENTITY saveCmd.accesskey "s">
+<!ENTITY saveAsCmd.label "Save As">
+<!ENTITY saveAsCmd.accesskey "A">
+<!ENTITY saveAsFileCmd.label "File…">
+<!ENTITY saveAsFileCmd.accesskey "F">
+<!ENTITY saveAsDraftCmd.label "Draft">
+<!ENTITY saveAsDraftCmd.accesskey "D">
+<!ENTITY saveAsTemplateCmd.label "Template">
+<!ENTITY saveAsTemplateCmd.accesskey "T">
+<!ENTITY sendNowCmd.label "Send Now">
+<!ENTITY sendCmd.keycode "VK_RETURN">
+<!ENTITY sendNowCmd.accesskey "d">
+<!ENTITY sendLaterCmd.label "Send Later">
+<!ENTITY sendLaterCmd.keycode "VK_RETURN">
+<!ENTITY sendLaterCmd.accesskey "L">
+<!ENTITY printCmd.label "Print…">
+<!ENTITY printCmd.key "P">
+<!ENTITY printCmd.accesskey "P">
+
+<!-- Edit Menu -->
+<!ENTITY editMenu.label "Edit">
+<!ENTITY editMenu.accesskey "e">
+<!ENTITY undoCmd.label "Undo">
+<!ENTITY undoCmd.key "Z">
+<!ENTITY undoCmd.accesskey "u">
+<!ENTITY redoCmd.label "Redo">
+<!ENTITY redoCmd.key "Y">
+<!ENTITY redoCmd.accesskey "r">
+<!ENTITY cutCmd.key "X">
+<!ENTITY copyCmd.key "C">
+<!ENTITY pasteCmd.key "V">
+<!ENTITY pasteNoFormattingCmd.key "V">
+<!ENTITY pasteAsQuotationCmd.key "o">
+<!ENTITY editRewrapCmd.accesskey "w">
+<!ENTITY deleteCmd.label "Delete">
+<!ENTITY deleteCmd.accesskey "d">
+<!ENTITY editRewrapCmd.label "Rewrap">
+<!ENTITY editRewrapCmd.key "R">
+<!ENTITY renameAttachmentCmd.label "Rename Attachment…">
+<!ENTITY renameAttachmentCmd.accesskey "e">
+<!ENTITY reorderAttachmentsCmd.label "Reorder Attachments…">
+<!ENTITY reorderAttachmentsCmd.accesskey "s">
+<!ENTITY reorderAttachmentsCmd.key "x">
+<!ENTITY selectAllCmd.accesskey "a">
+<!ENTITY findBarCmd.label "Find…">
+<!ENTITY findBarCmd.accesskey "F">
+<!ENTITY findBarCmd.key "F">
+<!ENTITY findReplaceCmd.label "Find and Replace…">
+<!ENTITY findReplaceCmd.accesskey "l">
+<!ENTITY findReplaceCmd.key "H">
+<!ENTITY findAgainCmd.label "Find Again">
+<!ENTITY findAgainCmd.accesskey "g">
+<!ENTITY findAgainCmd.key "G">
+<!ENTITY findAgainCmd.key2 "VK_F3">
+<!ENTITY findPrevCmd.label "Find Previous">
+<!ENTITY findPrevCmd.accesskey "v">
+<!ENTITY findPrevCmd.key "G">
+<!ENTITY findPrevCmd.key2 "VK_F3">
+
+<!-- Reorder Attachment Panel -->
+<!ENTITY reorderAttachmentsPanel.label "Reorder Attachments">
+<!ENTITY moveAttachmentBundleUpPanelBtn.label "Move together">
+
+<!-- LOCALIZATION NOTE (sortAttachmentsPanelBtn.Sort.AZ.label):
+ Please ensure that this translation matches
+ sortAttachmentsPanelBtn.Sort.ZA.label, except for the sort direction. -->
+<!ENTITY sortAttachmentsPanelBtn.Sort.AZ.label "Sort: A - Z">
+<!ENTITY sortAttachmentsPanelBtn.Sort.ZA.label "Sort: Z - A">
+<!-- LOCALIZATION NOTE (sortAttachmentsPanelBtn.SortSelection.AZ.label):
+ Please ensure that this translation matches
+ sortAttachmentsPanelBtn.SortSelection.ZA.label, except for the sort direction. -->
+<!ENTITY sortAttachmentsPanelBtn.SortSelection.AZ.label "Sort Selection: A - Z">
+<!ENTITY sortAttachmentsPanelBtn.SortSelection.ZA.label "Sort Selection: Z - A">
+<!ENTITY sortAttachmentsPanelBtn.key "y">
+
+<!-- View Menu -->
+<!ENTITY viewMenu.label "View">
+<!ENTITY viewMenu.accesskey "v">
+<!ENTITY viewToolbarsMenuNew.label "Toolbars">
+<!ENTITY viewToolbarsMenuNew.accesskey "T">
+<!ENTITY menubarCmd.label "Menu Bar">
+<!ENTITY menubarCmd.accesskey "M">
+<!ENTITY showCompositionToolbarCmd.label "Composition Toolbar">
+<!ENTITY showCompositionToolbarCmd.accesskey "o">
+<!ENTITY showFormattingBarCmd.label "Formatting Bar">
+<!ENTITY showFormattingBarCmd.accesskey "F">
+<!ENTITY showTaskbarCmd.label "Status Bar">
+<!ENTITY showTaskbarCmd.accesskey "S">
+<!ENTITY customizeToolbar.label "Customize…">
+<!ENTITY customizeToolbar.accesskey "C">
+
+<!ENTITY addressSidebar.label "Contacts Sidebar">
+<!ENTITY addressSidebar.accesskey "o">
+
+<!-- Format Menu -->
+<!ENTITY formatMenu.label "Format">
+<!ENTITY formatMenu.accesskey "o">
+
+<!-- Options Menu -->
+<!ENTITY optionsMenu.label "Options">
+<!ENTITY optionsMenu.accesskey "p">
+<!ENTITY checkSpellingCmd2.label "Check Spelling…">
+<!ENTITY checkSpellingCmd2.key "p">
+<!ENTITY checkSpellingCmd2.key2 "VK_F7">
+<!ENTITY checkSpellingCmd2.accesskey "h">
+<!ENTITY enableInlineSpellChecker.label "Spellcheck As You Type">
+<!ENTITY enableInlineSpellChecker.accesskey "S">
+<!ENTITY quoteCmd.label "Quote Message">
+<!ENTITY quoteCmd.accesskey "Q">
+
+<!--LOCALIZATION NOTE attachVCard.label Don't translate the term 'vCard' -->
+<!ENTITY attachVCard.label "Attach Personal Card (vCard)">
+<!ENTITY attachVCard.accesskey "v">
+
+<!ENTITY returnReceiptMenu.label "Return Receipt">
+<!ENTITY returnReceiptMenu.accesskey "t">
+<!ENTITY dsnMenu.label "Delivery Status Notification">
+<!ENTITY dsnMenu.accesskey "N">
+<!ENTITY priorityMenu.label "Priority">
+<!ENTITY priorityMenu.accesskey "p">
+<!ENTITY priorityButton.title "Priority">
+<!ENTITY priorityButton.tooltiptext "Change the message priority">
+<!ENTITY priorityButton.label "Priority:">
+<!ENTITY lowestPriorityCmd.label "Lowest">
+<!ENTITY lowestPriorityCmd.accesskey "l">
+<!ENTITY lowPriorityCmd.label "Low">
+<!ENTITY lowPriorityCmd.accesskey "o">
+<!ENTITY normalPriorityCmd.label "Normal">
+<!ENTITY normalPriorityCmd.accesskey "n">
+<!ENTITY highPriorityCmd.label "High">
+<!ENTITY highPriorityCmd.accesskey "i">
+<!ENTITY highestPriorityCmd.label "Highest">
+<!ENTITY highestPriorityCmd.accesskey "H">
+<!ENTITY fileCarbonCopyCmd.label "Send a Copy To">
+<!ENTITY fileCarbonCopyCmd.accesskey "d">
+<!ENTITY fileHereMenu.label "File Here">
+
+<!-- Tools Menu -->
+<!ENTITY tasksMenu.label "Tools">
+<!ENTITY tasksMenu.accesskey "T">
+<!ENTITY messengerCmd.label "Mail &amp; Newsgroups">
+<!ENTITY messengerCmd.accesskey "m">
+<!ENTITY messengerCmd.commandkey "1">
+<!ENTITY addressBookCmd.label "Address Book">
+<!ENTITY addressBookCmd.accesskey "a">
+<!ENTITY addressBookCmd.key "B">
+<!ENTITY accountManagerCmd2.label "Account Settings">
+<!ENTITY accountManagerCmd2.accesskey "S">
+<!ENTITY accountManagerCmdUnix2.accesskey "S">
+
+<!-- Mac OS X Window Menu -->
+<!ENTITY minimizeWindow.key "m">
+<!ENTITY minimizeWindow.label "Minimize">
+<!ENTITY bringAllToFront.label "Bring All to Front">
+<!ENTITY zoomWindow.label "Zoom">
+<!ENTITY windowMenu.label "Window">
+
+<!-- Mail Toolbar -->
+<!ENTITY sendButton.label "Send">
+<!ENTITY quoteButton.label "Quote">
+<!ENTITY addressButton.label "Contacts">
+<!ENTITY spellingButton.label "Spelling">
+<!ENTITY saveButton.label "Save">
+<!ENTITY printButton.label "Print">
+
+<!-- Mail Toolbar Tooltips -->
+<!ENTITY sendButton.tooltip "Send this message now">
+<!ENTITY sendlaterButton.tooltip "Send this message later">
+<!ENTITY quoteButton.tooltip "Quote the previous message">
+<!ENTITY addressButton.tooltip "Select a recipient from an Address Book">
+<!ENTITY spellingButton.tooltip "Check spelling of selection or entire message">
+<!ENTITY saveButton.tooltip "Save this message">
+<!ENTITY cutButton.tooltip "Cut">
+<!ENTITY copyButton.tooltip "Copy">
+<!ENTITY pasteButton.tooltip "Paste">
+<!ENTITY printButton.tooltip "Print this message">
+
+<!-- Headers -->
+<!--LOCALIZATION NOTE headersSpaces.style is for aligning the From:, To: and
+ Subject: rows. It should be larger than the largest Header label -->
+<!ENTITY headersSpace2.style "width: 8em;">
+<!ENTITY fromAddr2.label "From">
+<!ENTITY fromAddr.accesskey "r">
+<!ENTITY replyAddr2.label "Reply-To">
+<!ENTITY newsgroupsAddr2.label "Newsgroup">
+<!ENTITY followupAddr2.label "Followup-To">
+<!ENTITY subject2.label "Subject">
+<!ENTITY subject.accesskey "S">
+<!ENTITY attachmentBucketCloseButton.tooltip "Hide the attachment pane">
+
+<!-- Format Toolbar, imported from editorAppShell.xhtml -->
+<!ENTITY SmileButton.tooltip "Insert a smiley face">
+<!ENTITY smiley1Cmd.label "Smile">
+<!ENTITY smiley2Cmd.label "Frown">
+<!ENTITY smiley3Cmd.label "Wink">
+<!ENTITY smiley4Cmd.label "Tongue-out">
+<!ENTITY smiley5Cmd.label "Laughing">
+<!ENTITY smiley6Cmd.label "Embarrassed">
+<!ENTITY smiley7Cmd.label "Undecided">
+<!ENTITY smiley8Cmd.label "Surprise">
+<!ENTITY smiley9Cmd.label "Kiss">
+<!ENTITY smiley10Cmd.label "Yell">
+<!ENTITY smiley11Cmd.label "Cool">
+<!ENTITY smiley12Cmd.label "Money-Mouth">
+<!ENTITY smiley13Cmd.label "Foot-in-Mouth">
+<!ENTITY smiley14Cmd.label "Innocent">
+<!ENTITY smiley15Cmd.label "Cry">
+<!ENTITY smiley16Cmd.label "Lips-are-Sealed">
+
+<!-- Message Pane Context Menu -->
+<!ENTITY spellCheckNoSuggestions.label "No Suggestions Found">
+<!ENTITY spellCheckIgnoreWord.label "Ignore Word">
+<!ENTITY spellCheckIgnoreWord.accesskey "I">
+<!ENTITY spellCheckAddToDictionary.label "Add to Dictionary">
+<!ENTITY spellCheckAddToDictionary.accesskey "n">
+<!ENTITY undo.label "Undo">
+<!ENTITY undo.accesskey "U">
+<!ENTITY cut.label "Cut">
+<!ENTITY cut.accesskey "t">
+<!ENTITY copy.label "Copy">
+<!ENTITY copy.accesskey "C">
+<!ENTITY paste.label "Paste">
+<!ENTITY paste.accesskey "P">
+<!ENTITY pasteQuote.label "Paste As Quotation">
+<!ENTITY pasteQuote.accesskey "Q">
+
+<!-- Attachment Item and List Context Menus -->
+<!ENTITY openAttachment.label "Open">
+<!ENTITY openAttachment.accesskey "O">
+<!ENTITY delete.label "Delete">
+<!ENTITY delete.accesskey "D">
+<!ENTITY removeAttachment.label "Remove Attachment">
+<!ENTITY removeAttachment.accesskey "M">
+<!ENTITY renameAttachment.label "Rename…">
+<!ENTITY renameAttachment.accesskey "R">
+<!ENTITY reorderAttachments.label "Reorder Attachments…">
+<!ENTITY reorderAttachments.accesskey "s">
+<!ENTITY removeAllAttachments.label "Remove All Attachments">
+<!ENTITY removeAllAttachments.accesskey "v">
+<!ENTITY selectAll.label "Select All">
+<!ENTITY selectAll.accesskey "A">
+<!ENTITY attachCloud.label "Filelink…">
+<!ENTITY attachCloud.accesskey "i">
+<!ENTITY convertCloud.label "Convert to…">
+<!ENTITY convertCloud.accesskey "C">
+<!ENTITY cancelUpload.label "Cancel Upload">
+<!ENTITY cancelUpload.accesskey "n">
+<!ENTITY convertRegularAttachment.label "Regular Attachment">
+<!ENTITY convertRegularAttachment.accesskey "A">
+<!ENTITY attachPage.label "Attach Web Page…">
+<!ENTITY attachPage.accesskey "W">
+
+<!-- Attachment Pane Header Bar Context Menu -->
+<!-- LOCALIZATION NOTE (initiallyShowAttachmentPane.label):
+ Should use the same wording as startExpandedCmd.label
+ in msgHdrViewOverlay.dtd. -->
+<!ENTITY initiallyShowAttachmentPane.label "Initially Show Attachment Pane">
+<!ENTITY initiallyShowAttachmentPane.accesskey "S">
+
+<!-- Spell checker context menu items -->
+<!ENTITY spellAddDictionaries.label "Add Dictionaries…">
+<!ENTITY spellAddDictionaries.accesskey "A">
+
+<!-- Title for the address picker panel -->
+<!ENTITY addressesSidebarTitle.label "Contacts">
+
+<!-- Identity popup customize menuitem -->
+<!ENTITY customizeFromAddress.label "Customize From Address…">
+<!ENTITY customizeFromAddress.accesskey "A">
+
+<!-- Accessibility name for the document -->
+<!ENTITY aria.message.bodyName "Message body">
+
+<!-- Status Bar -->
+<!ENTITY languageStatusButton.tooltip "Spellcheck language">
+<!ENTITY encodingStatusPanel.tooltip "Text encoding">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/sendProgress.dtd b/comm/mail/locales/en-US/chrome/messenger/messengercompose/sendProgress.dtd
new file mode 100644
index 0000000000..0a94a609b1
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/sendProgress.dtd
@@ -0,0 +1,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/. -->
+
+<!--LOCALIZATION NOTE sendProgress.dtd Main UI for Send Message Progress Dialog -->
+<!ENTITY sendDialog.title "Processing Message">
+<!ENTITY status.label "Status:">
+<!ENTITY progress.label "Progress:">
diff --git a/comm/mail/locales/en-US/chrome/messenger/messengercompose/sendProgress.properties b/comm/mail/locales/en-US/chrome/messenger/messengercompose/sendProgress.properties
new file mode 100644
index 0000000000..61799efb37
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/messengercompose/sendProgress.properties
@@ -0,0 +1,21 @@
+# 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/.
+
+# LOCALIZATION NOTE (titleSendMsgSubject):
+# %S will be replaced by the message subject.
+titleSendMsgSubject=Sending Message - %S
+titleSendMsg=Sending Message
+# LOCALIZATION NOTE (titleSaveMsgSubject):
+# %S will be replaced by the message subject.
+titleSaveMsgSubject=Saving Message - %S
+titleSaveMsg=Saving Message
+
+# LOCALIZATION NOTE (percentMsg):
+# This string is used to format the text to the right of the progress meter.
+# %S will be replaced by the percentage of the file that has been saved.
+# %% will be replaced a single % sign.
+percentMsg=%S%%
+
+messageSent=Your message has been sent.
+messageSaved=Your message has been saved.
diff --git a/comm/mail/locales/en-US/chrome/messenger/migration/migration.dtd b/comm/mail/locales/en-US/chrome/messenger/migration/migration.dtd
new file mode 100644
index 0000000000..bd126dfd1a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/migration/migration.dtd
@@ -0,0 +1,30 @@
+<!-- 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/. -->
+
+
+<!ENTITY migrationWizard.title "Import Wizard">
+
+<!ENTITY importFromWin.label "Import Options, Account Settings, Address Book, Filters and other data from:">
+<!ENTITY importFromNonWin.label "Import Preferences, Account Settings, Address Book, Filters, and other data from:">
+<!ENTITY importSourceNotFound.label "No application to import data from found.">
+
+<!ENTITY importFromNothing.label "Don't import anything">
+<!ENTITY importFromNothing.accesskey "D">
+<!ENTITY importFromSeamonkey3.label "SeaMonkey 2 or later">
+<!ENTITY importFromSeamonkey3.accesskey "S">
+<!ENTITY importFromOutlook.label "Outlook">
+<!ENTITY importFromOutlook.accesskey "O">
+
+<!ENTITY importSource.title "Import Settings and Mail Folders">
+<!ENTITY importItems.title "Items to Import">
+<!ENTITY importItems.label "Select which items to import:">
+
+<!ENTITY migrating.title "Importing…">
+<!ENTITY migrating.label "The following items are currently being imported…">
+
+<!ENTITY selectProfile.title "Select Profile">
+<!ENTITY selectProfile.label "The following profiles are available to import from:">
+
+<!ENTITY done.title "Import Complete">
+<!ENTITY done.label "The following items were successfully imported:">
diff --git a/comm/mail/locales/en-US/chrome/messenger/migration/migration.properties b/comm/mail/locales/en-US/chrome/messenger/migration/migration.properties
new file mode 100644
index 0000000000..d679963c8c
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/migration/migration.properties
@@ -0,0 +1,30 @@
+# 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/.
+
+profileName_format=%S %S
+
+# Import Sources
+1_seamonkey=Preferences
+1_thunderbird=Preferences
+
+2_seamonkey=Account Settings
+2_thunderbird=Account Settings
+2_outlook=Account Settings
+
+4_seamonkey=Address Books
+4_thunderbird=Address Books
+4_outlook=Address Book
+
+8_seamonkey=Junk Mail Training
+
+16_seamonkey=Saved Passwords
+
+32_seamonkey=Other Data
+
+64_seamonkey=Newsgroup Folders
+64_thunderbird=Newsgroup Folders
+
+128_seamonkey=Mail Folders
+128_thunderbird=Mail Folders
+128_outlook=Mail Folders
diff --git a/comm/mail/locales/en-US/chrome/messenger/mime.properties b/comm/mail/locales/en-US/chrome/messenger/mime.properties
new file mode 100644
index 0000000000..55a4acff4c
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/mime.properties
@@ -0,0 +1,154 @@
+# 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 following are used by libmime to emit header display in HTML
+#
+
+# Mail subject
+## @name MIME_MHTML_SUBJECT
+## @loc None
+1000=Subject
+
+# Resent-Comments
+## @name MIME_MHTML_RESENT_COMMENTS
+## @loc
+1001=Resent-Comments
+
+# Resent-Date
+## @name MIME_MHTML_RESENT_DATE
+## @loc
+1002=Resent-Date
+
+# Resent-Sender
+## @name MIME_MHTML_RESENT_SENDER
+## @loc
+1003=Resent-Sender
+
+# Resent-From
+## @name MIME_MHTML_RESENT_FROM
+## @loc
+1004=Resent-From
+
+# Resent-To
+## @name MIME_MHTML_RESENT_TO
+## @loc
+1005=Resent-To
+
+# Resent-CC
+## @name MIME_MHTML_RESENT_CC
+## @loc
+1006=Resent-CC
+
+# Date
+## @name MIME_MHTML_DATE
+## @loc
+1007=Date
+
+# Sender
+## @name MIME_MHTML_SENDER
+## @loc
+1008=Sender
+
+# From
+## @name MIME_MHTML_FROM
+## @loc
+1009=From
+
+# Reply-To
+## @name MIME_MHTML_REPLY_TO
+## @loc
+1010=Reply-To
+
+# Organization
+## @name MIME_MHTML_ORGANIZATION
+## @loc
+1011=Organization
+
+# To
+## @name MIME_MHTML_TO
+## @loc
+1012=To
+
+# CC
+## @name MIME_MHTML_CC
+## @loc
+1013=CC
+
+# Newsgroups
+## @name MIME_MHTML_NEWSGROUPS
+## @loc
+1014=Newsgroups
+
+# Followup-To
+## @name MIME_MHTML_FOLLOWUP_TO
+## @loc
+1015=Followup-To
+
+# References
+## @name MIME_MHTML_REFERENCES
+## @loc
+1016=References
+
+# Message ID
+## @name MIME_MHTML_MESSAGE_ID
+## @loc
+1021=Message-ID
+
+# BCC
+## @name MIME_MHTML_BCC
+## @loc
+1023=BCC
+
+# Link to doc
+## @name MIME_MSG_LINK_TO_DOCUMENT
+## @loc
+1026=Link to Document
+
+# Get Doc info
+## @name MIME_MSG_DOCUMENT_INFO
+## @loc
+1027=<B>Document Info:</B>
+
+# Msg Attachment
+## @name MIME_MSG_ATTACHMENT
+## @loc
+1028=Attachment
+
+# default attachment name
+## @name MIME_MSG_DEFAULT_ATTACHMENT_NAME
+## @loc
+# LOCALIZATION NOTE (1040): Do not translate "%s" below.
+# Place the %s where you wish the part number of the attachment to appear
+1040=Part %s
+
+# default forwarded message prefix
+## @name MIME_FORWARDED_MESSAGE_HTML_USER_WROTE
+## @loc
+1041=-------- Original Message --------
+
+# Partial Message Truncated
+## @name MIME_MSG_PARTIAL_TRUNCATED
+## @loc
+MIME_MSG_PARTIAL_TRUNCATED=Truncated!
+
+# Partial Message Truncated Explanation
+## @name MIME_MSG_PARTIAL_TRUNCATED_EXPLANATION
+## @loc
+MIME_MSG_PARTIAL_TRUNCATED_EXPLANATION=This message exceeded the Maximum Message Size set in Account Settings, so we have only downloaded the first few lines from the mail server.
+
+# Partial Message Not Downloaded
+## @name MIME_MSG_PARTIAL_NOT_DOWNLOADED
+## @loc
+MIME_MSG_PARTIAL_NOT_DOWNLOADED=Not Downloaded
+
+# Partial Message Not Downloaded Explanation
+## @name MIME_MSG_PARTIAL_NOT_DOWNLOADED_EXPLANATION
+## @loc
+MIME_MSG_PARTIAL_NOT_DOWNLOADED_EXPLANATION=Only the headers for this message were downloaded from the mail server.
+
+# MIME_MSG_PARTIAL_CLICK_FOR_REST
+## @name MIME_MSG_PARTIAL_CLICK_FOR_REST
+## @loc
+MIME_MSG_PARTIAL_CLICK_FOR_REST=Download the rest of the message.
diff --git a/comm/mail/locales/en-US/chrome/messenger/mimeheader.properties b/comm/mail/locales/en-US/chrome/messenger/mimeheader.properties
new file mode 100644
index 0000000000..0166e31fef
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/mimeheader.properties
@@ -0,0 +1,35 @@
+# 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 following are used by libmime for header display in XML & HTML
+#
+TO=To
+BCC=BCC
+CC=CC
+DATE=Date
+DISTRIBUTION=Distribution
+FCC=FCC
+FOLLOWUP-TO=Followup-To
+FROM=From
+STATUS=Status
+LINES=Lines
+MESSAGE-ID=Message-ID
+MIME-VERSION=MIME-Version
+NEWSGROUPS=Newsgroups
+ORGANIZATION=Organization
+REFERENCES=References
+REPLY-TO=Reply-To
+RESENT-COMMENTS=Resent-Comments
+RESENT-DATE=Resent-Date
+RESENT-FROM=Resent-From
+RESENT-MESSAGE-ID=Resent-Message-ID
+RESENT-SENDER=Resent-Sender
+RESENT-TO=Resent-To
+RESENT-CC=Resent-CC
+SENDER=Sender
+SUBJECT=Subject
+APPROVED-BY=Approved-By
+USER-AGENT=User-Agent
+FILENAME=Filename
diff --git a/comm/mail/locales/en-US/chrome/messenger/morkImportMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/morkImportMsgs.properties
new file mode 100644
index 0000000000..ce65917ca4
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/morkImportMsgs.properties
@@ -0,0 +1,18 @@
+# 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 following are used by the Mork import code to display status/error
+# and informational messages.
+#
+
+MABFiles = Mork Address Books
+
+# Short name of import module
+morkImportName = Mork database (.mab)
+
+# Description of import module
+morkImportDescription = Import an address book from SeaMonkey or earlier versions of Thunderbird.
+
+morkImportSuccess = Success!
diff --git a/comm/mail/locales/en-US/chrome/messenger/msgAccountCentral.dtd b/comm/mail/locales/en-US/chrome/messenger/msgAccountCentral.dtd
new file mode 100644
index 0000000000..a2d885270d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/msgAccountCentral.dtd
@@ -0,0 +1,26 @@
+<!-- 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/. -->
+
+<!ENTITY emailSectionHdr.label "Email">
+<!ENTITY readMsgsLink.label "Read messages">
+<!ENTITY composeMsgLink.label "Write a new message">
+
+<!ENTITY newsSectionHdr.label "Newsgroups">
+<!ENTITY subscribeNewsLink.label "Manage newsgroup subscriptions">
+
+<!ENTITY feedsSectionHdr.label "Feeds">
+<!ENTITY subscribeFeeds.label "Manage subscriptions">
+
+<!ENTITY chat.label "Chat">
+
+<!ENTITY accountsSectionHdr.label "Accounts">
+<!ENTITY subscribeImapFolders.label "Manage folder subscriptions">
+<!ENTITY settingsLink.label "View settings for this account">
+<!ENTITY setupNewAcct.label "Set up an account:">
+
+<!ENTITY advFeaturesSectionHdr.label "Advanced Features">
+<!ENTITY searchMsgsLink.label "Search messages">
+<!ENTITY filtersLink.label "Manage message filters">
+<!ENTITY junkSettings.label "Junk mail settings">
+<!ENTITY offlineLink.label "Offline settings">
diff --git a/comm/mail/locales/en-US/chrome/messenger/msgHdrViewOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/msgHdrViewOverlay.dtd
new file mode 100644
index 0000000000..e868a669d7
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/msgHdrViewOverlay.dtd
@@ -0,0 +1,114 @@
+<!-- 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/. -->
+
+<!ENTITY toField4.label "To">
+<!ENTITY fromField4.label "From">
+<!ENTITY senderField4.label "Sender">
+<!ENTITY author.label "Author">
+<!ENTITY organizationField4.label "Organization">
+<!ENTITY replyToField4.label "Reply to">
+
+<!ENTITY subjectField4.label "Subject">
+<!ENTITY ccField4.label "Cc">
+<!ENTITY bccField4.label "Bcc">
+<!ENTITY newsgroupsField4.label "Newsgroups">
+<!ENTITY followupToField4.label "Followup to">
+<!ENTITY tagsHdr4.label "Tags">
+<!ENTITY dateField4.label "Date">
+<!ENTITY userAgentField4.label "User agent">
+<!ENTITY referencesField4.label "References">
+<!ENTITY messageIdField4.label "Message ID">
+<!ENTITY inReplyToField4.label "In reply to">
+<!ENTITY originalWebsite4.label "Website">
+
+<!ENTITY hdrArchiveButton1.label "Archive">
+<!ENTITY hdrArchiveButton1.tooltip "Archive this message">
+<!ENTITY hdrSmartReplyButton1.label "Smart Reply">
+<!ENTITY hdrReplyButton1.label "Reply">
+<!ENTITY hdrReplyButton2.tooltip "Reply to the sender of this message">
+<!ENTITY hdrReplyAllButton1.label "Reply All">
+<!ENTITY hdrReplyAllButton1.tooltip "Reply to sender and all recipients">
+<!ENTITY hdrReplyListButton1.label "Reply List">
+<!ENTITY hdrReplyListButton1.tooltip "Reply to mailing list">
+<!ENTITY hdrFollowupButton1.label "Followup">
+<!ENTITY hdrFollowupButton1.tooltip "Post a followup to this newsgroup">
+<!ENTITY hdrForwardButton1.label "Forward">
+<!ENTITY hdrForwardButton1.tooltip "Forward this message">
+<!ENTITY hdrJunkButton1.label "Junk">
+<!ENTITY hdrJunkButton1.tooltip "Mark this message as junk">
+<!ENTITY hdrTrashButton1.label "Delete">
+<!ENTITY hdrTrashButton1.tooltip "Delete this message">
+
+<!ENTITY hdrViewToolbarShowFull.label "Show Icons and Text">
+<!ENTITY hdrViewToolbarShowFull.accesskey "a">
+<!ENTITY hdrViewToolbarShowIcons.label "Show Icons Only">
+<!ENTITY hdrViewToolbarShowIcons.accesskey "I">
+<!ENTITY hdrViewToolbarShowText.label "Show Text Only">
+<!ENTITY hdrViewToolbarShowText.accesskey "T">
+<!ENTITY hdrViewToolbarAlwaysReplySender.label "Always Show Reply to Sender">
+<!ENTITY hdrViewToolbarAlwaysReplySender.accesskey "R">
+
+<!ENTITY otherActionsButton2.label "More">
+<!ENTITY otherActionsButton.tooltip "More actions">
+<!ENTITY otherActionsOpenConversation1.label "Open in Conversation">
+<!ENTITY otherActionsOpenConversation1.accesskey "C">
+<!ENTITY otherActionsOpenInNewWindow1.label "Open in New Window">
+<!ENTITY otherActionsOpenInNewWindow1.accesskey "W">
+<!ENTITY otherActionsOpenInNewTab1.label "Open in New Tab">
+<!ENTITY otherActionsOpenInNewTab1.accesskey "T">
+<!ENTITY markAsReadMenuItem1.label "Mark as Read">
+<!ENTITY markAsReadMenuItem1.accesskey "R">
+<!ENTITY markAsUnreadMenuItem1.label "Mark as Unread">
+<!ENTITY markAsUnreadMenuItem1.accesskey "r">
+<!ENTITY saveAsMenuItem1.label "Save as…">
+<!ENTITY saveAsMenuItem1.accesskey "S">
+<!ENTITY viewSourceMenuItem1.label "View Source">
+<!ENTITY viewSourceMenuItem1.accesskey "V">
+<!ENTITY otherActionsPrint1.label "Print…">
+<!ENTITY otherActionsPrint1.accesskey "P">
+
+<!-- Attachment bar context menu items -->
+<!ENTITY startExpandedCmd.label "Initially Show Attachment Pane">
+<!ENTITY startExpandedCmd.accesskey "S">
+
+<!-- Attachment context menu items -->
+<!ENTITY openAttachmentCmd.label "Open">
+<!ENTITY openAttachmentCmd.accesskey "O">
+<!ENTITY saveAsAttachmentCmd.label "Save As…">
+<!ENTITY saveAsAttachmentCmd.accesskey "S">
+<!ENTITY detachAttachmentCmd.label "Detach…">
+<!ENTITY detachAttachmentCmd.accesskey "D">
+<!ENTITY deleteAttachmentCmd.label "Delete">
+<!ENTITY deleteAttachmentCmd.accesskey "e">
+<!ENTITY openAllAttachmentsCmd.label "Open All…">
+<!ENTITY openAllAttachmentsCmd.accesskey "O">
+<!ENTITY saveAllAttachmentsCmd.label "Save All…">
+<!ENTITY saveAllAttachmentsCmd.accesskey "S">
+<!ENTITY detachAllAttachmentsCmd.label "Detach All…">
+<!ENTITY detachAllAttachmentsCmd.accesskey "D">
+<!ENTITY deleteAllAttachmentsCmd.label "Delete All…">
+<!ENTITY deleteAllAttachmentsCmd.accesskey "e">
+
+<!ENTITY openAttachment.tooltip "Open the attached file">
+
+<!ENTITY detachedAttachmentFolder.show.label "Open Containing Folder">
+<!ENTITY detachedAttachmentFolder.show.accesskey "F">
+<!ENTITY detachedAttachmentFolder.showMac.label "Show In Finder">
+<!ENTITY detachedAttachmentFolder.showMac.accesskey "F">
+
+<!-- Attachment toolbar items -->
+<!ENTITY saveAttachmentButton1.label "Save">
+<!ENTITY saveAttachmentButton1.tooltip "Save the attached file">
+<!ENTITY saveAllAttachmentsButton1.label "Save All">
+<!ENTITY saveAllAttachmentsButton1.tooltip "Save all the attached files">
+
+<!ENTITY copyLinkCmd.label "Copy Link Location">
+<!ENTITY copyLinkCmd.accesskey "C">
+
+<!ENTITY CopyMessageId.label "Copy Message-ID">
+<!ENTITY CopyMessageId.accesskey "C">
+<!ENTITY OpenMessageForMsgId.label "Open Message For ID">
+<!ENTITY OpenMessageForMsgId.accesskey "O">
+<!ENTITY OpenBrowserWithMsgId.label "Open Browser With Message-ID">
+<!ENTITY OpenBrowserWithMsgId.accesskey "B">
diff --git a/comm/mail/locales/en-US/chrome/messenger/msgSynchronize.dtd b/comm/mail/locales/en-US/chrome/messenger/msgSynchronize.dtd
new file mode 100644
index 0000000000..813cd5eb71
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/msgSynchronize.dtd
@@ -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/. -->
+
+<!-- extracted from MsgSynchronize.xhtml and msgSelectOfflineFolders.xhtml-->
+
+<!ENTITY MsgSynchronize.label "Download and Sync Messages">
+<!ENTITY MsgSelect.label "Items for Offline Use">
+<!ENTITY MsgSyncDesc.label "If you have already selected mail folders or newsgroups for offline use, you can download and/or sync them now. Otherwise, use the &quot;Select&quot; button to choose mail folders and newsgroups for offline use.">
+<!ENTITY MsgSyncDirections.label "Download and/or sync the following:">
+<!ENTITY syncTypeMail.label "Mail messages">
+<!ENTITY syncTypeMail.accesskey "M">
+<!ENTITY syncTypeNews.label "Newsgroup messages">
+<!ENTITY syncTypeNews.accesskey "N">
+<!ENTITY sendMessage.label "Send Unsent messages">
+<!ENTITY sendMessage.accesskey "S">
+<!ENTITY workOffline.label "Work offline once download and/or sync is complete">
+<!ENTITY workOffline.accesskey "W">
+<!ENTITY selectButton.label "Select…">
+<!ENTITY selectButton.accesskey "E">
+<!ENTITY MsgSelectDesc.label "Choose mail folders and newsgroups for offline use.">
+<!ENTITY MsgSelectInd.label "Download">
+<!ENTITY MsgSelectItems.label "Folders and Newsgroups">
diff --git a/comm/mail/locales/en-US/chrome/messenger/msgViewPickerOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/msgViewPickerOverlay.dtd
new file mode 100644
index 0000000000..95937c7e33
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/msgViewPickerOverlay.dtd
@@ -0,0 +1,22 @@
+<!-- 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/. -->
+
+<!--LOCALIZATION NOTE msgViewPickerOverlay.dtd UI for showing various views on a folder -->
+
+<!ENTITY viewPicker.label "View:">
+<!ENTITY viewPicker.accesskey "i">
+<!ENTITY viewAll.label "All">
+<!ENTITY viewAll.accesskey "A">
+<!ENTITY viewUnread.label "Unread">
+<!ENTITY viewUnread.accesskey "U">
+<!ENTITY viewNotDeleted.label "Not Deleted">
+<!ENTITY viewNotDeleted.accesskey "D">
+<!ENTITY viewTags.label "Tags">
+<!ENTITY viewTags.accesskey "T">
+<!ENTITY viewCustomViews.label "Custom Views">
+<!ENTITY viewCustomViews.accesskey "V">
+<!ENTITY viewVirtualFolder.label "Save View as a Folder…">
+<!ENTITY viewVirtualFolder.accesskey "S">
+<!ENTITY viewCustomizeView.label "Customize…">
+<!ENTITY viewCustomizeView.accesskey "C">
diff --git a/comm/mail/locales/en-US/chrome/messenger/msgmdn.properties b/comm/mail/locales/en-US/chrome/messenger/msgmdn.properties
new file mode 100644
index 0000000000..6a9ccf58f6
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/msgmdn.properties
@@ -0,0 +1,18 @@
+# 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/.
+## Msg Mdn Report strings
+MsgMdnDisplayed=Note: This Return Receipt only acknowledges that the message was displayed on the recipient's computer. There is no guarantee that the recipient has read or understood the message contents.
+MsgMdnDispatched=The message was either printed, faxed, or forwarded without being displayed to the recipient. There is no guarantee that the recipient will read the message at a later time.
+MsgMdnProcessed=The message was processed by the recipient's mail client without being displayed. There is no guarantee that the message will be read at a later time.
+MsgMdnDeleted=The message has been deleted. The person you sent it to may or may not have seen it. They might undelete it at a later time and read it.
+MsgMdnDenied=The recipient of the message does not wish to send a return receipt back to you.
+MsgMdnFailed=A failure occurred. A proper return receipt could not be generated or sent to you.
+# LOCALIZATION NOTE : Do not translate the word "%S" below.
+MsgMdnMsgSentTo=This is a Return Receipt for the mail that you sent to %S.
+MdnDisplayedReceipt=Return Receipt (displayed)
+MdnDispatchedReceipt=Return Receipt (dispatched)
+MdnProcessedReceipt=Return Receipt (processed)
+MdnDeletedReceipt=Return Receipt (deleted)
+MdnDeniedReceipt=Return Receipt (denied)
+MdnFailedReceipt=Return Receipt (failed)
diff --git a/comm/mail/locales/en-US/chrome/messenger/multimessageview.dtd b/comm/mail/locales/en-US/chrome/messenger/multimessageview.dtd
new file mode 100644
index 0000000000..ca5cae2cb6
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/multimessageview.dtd
@@ -0,0 +1,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/. -->
+
+<!ENTITY window.title "Message Summary">
+<!ENTITY selectedmessages.label "Selected Messages">
+<!ENTITY archiveButton.label "Archive">
+<!ENTITY deleteButton.label "Delete">
diff --git a/comm/mail/locales/en-US/chrome/messenger/multimessageview.properties b/comm/mail/locales/en-US/chrome/messenger/multimessageview.properties
new file mode 100644
index 0000000000..31e4e7546c
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/multimessageview.properties
@@ -0,0 +1,66 @@
+# 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/.
+
+# LOCALIZATION NOTE (numConversations): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# The number of conversations (threads or solitary messages) selected. #1 is the
+# number of conversations.
+numConversations=#1 conversation; #1 conversations
+
+# LOCALIZATION NOTE (atLeastNumConversations): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# The number of conversations (threads or solitary messages) selected. #1 is the
+# number of conversations. We use this when we didn't actually scan the entire
+# list of selected messages, so there may be more conversations than reported
+# (or maybe not!).
+atLeastNumConversations=#1+ conversation; #1+ conversations
+
+# LOCALIZATION NOTE (numMessages): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# The number of messages in a thread. #1 is the number of messages.
+numMessages=#1 message;#1 messages
+
+# LOCALIZATION NOTE (numUnread): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# The number of unread messages in a thread; meant to be appended to
+# "numMessages". #1 is the number of unread messages.
+numUnread=, #1 unread;, #1 unread
+
+# LOCALIZATION NOTE (numIgnored): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# The number of ignored messages in a thread; meant to be appended to
+# "numMessages". #1 is the number of ignored messages.
+numIgnored=, #1 ignored;, #1 ignored
+
+# LOCALIZATION NOTE (atLeastNumIgnored): Semi-colon list of plural forms.
+# See: https://developer.mozilla.org/en/Localization_and_Plurals
+# The number of ignored messages in a thread; meant to be appended to
+# "numMessages". #1 is the number of ignored messages. We use this when we
+# didn't actually scan the entire list of selected messages, so there may be
+# more ignored messages than reported (or maybe not!).
+atLeastNumIgnored=, #1+ ignored;, #1+ ignored
+
+# LOCALIZATION NOTE (noSubject): What to display for a message if it has no
+# subject.
+noSubject=(no subject)
+
+# LOCALIZATION NOTE (messagesTotalSize): A message indicating the total size on
+# disk of the selected messages. #1 is the size, e.g. "123 KB".
+messagesTotalSize=These messages take up #1.
+
+# LOCALIZATION NOTE (messagesTotalSizeMoreThan): A message indicating the total
+# size on disk of the selected messages. #1 is the size, e.g. "123 KB". We use
+# this when we didn't actually scan the entire list of selected messages, so
+# this is a *minimum* size.
+messagesTotalSizeMoreThan=These messages take up more than #1.
+
+# LOCALIZATION NOTE (maxCountExceeded): A message to let the user know that not
+# all of the selected messages were summarized. #1 is the total number of
+# messages selected and #2 is the number of messages actually shown.
+maxCountExceeded= (Note: #1 messages are selected, the first #2 are shown)
+
+# LOCALIZATION NOTE (maxThreadCountExceeded): A message to let the user know that
+# not all of the selected thread were summarized. #1 is the total number of
+# threads selected and #2 is the number of threads actually shown.
+maxThreadCountExceeded= (Note: #1 threads are selected, the first #2 are shown)
diff --git a/comm/mail/locales/en-US/chrome/messenger/newFolderDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/newFolderDialog.dtd
new file mode 100644
index 0000000000..c34028d2d4
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/newFolderDialog.dtd
@@ -0,0 +1,16 @@
+<!-- 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/. -->
+
+<!-- Labels -->
+<!ENTITY newFolderDialog.title "New Folder">
+<!ENTITY name.label "Name:">
+<!ENTITY name.accesskey "n">
+<!ENTITY description.label "Create as a subfolder of:">
+<!ENTITY description.accesskey "c">
+<!ENTITY folderRestriction1.label "This server restricts folders to two special kinds.">
+<!ENTITY folderRestriction2.label "Allow your new folder to contain:">
+<!ENTITY foldersOnly.label "Folders Only">
+<!ENTITY messagesOnly.label "Messages Only">
+<!ENTITY accept.label "Create Folder">
+<!ENTITY accept.accesskey "r">
diff --git a/comm/mail/locales/en-US/chrome/messenger/news.properties b/comm/mail/locales/en-US/chrome/messenger/news.properties
new file mode 100644
index 0000000000..32d0fe7c7a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/news.properties
@@ -0,0 +1,55 @@
+# 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/.
+downloadHeadersTitlePrefix=Download Headers
+downloadHeadersInfoText=There are %S new message headers to download for this newsgroup.
+cancelConfirm=Are you sure you want to cancel this message?
+messageCancelled=Message cancelled.
+enterUserPassTitle=News Server Username and Password Required
+# LOCALIZATION NOTE (enterUserPassServer): %S is the server being accessed
+enterUserPassServer=Please enter a username and password for %S:
+# LOCALIZATION NOTE (enterUserPassGroup): %1$S is a specific newsgroup to set
+# the password for; %2$S is the server from which the newsgroup is accessed
+enterUserPassGroup=Please enter a username and password for %1$S on %2$S:
+okButtonText=Download
+
+noNewMessages=There are no new messages on the server.
+# LOCALIZATION NOTE (newNewsgroupHeaders): %1$S is the number of the current
+# header being downloaded, %2$S is the number of headers to be downloaded, and
+# %3$S is the newsgroup whose headers are being downloaded.
+newNewsgroupHeaders=Downloading %1$S of %2$S headers for %3$S
+# LOCALIZATION NOTE (newNewsgroupFilteringHeaders): %1$S is the name of the MIME
+# header being filtered on, %2$S is the number of the current header being
+# downloaded, %3$S is the number of headers to be downloaded, and %4$S is the
+# newsgroup whose headers are being downloaded.
+newNewsgroupFilteringHeaders=Getting headers for filters: %1$S (%2$S/%3$S) on %4$S
+downloadingArticles=Downloading articles %S-%S
+bytesReceived=Downloading newsgroups: %S received (%SKB read at %SKB/sec)
+downloadingArticlesForOffline=Downloading articles %S-%S in %S
+
+# LOCALIZATION NOTE (autoUnsubscribeText): %1$S is the newsgroup and %2$S is the newsgroup-server it is being removed from.
+autoUnsubscribeText=The newsgroup %1$S does not appear to exist on the host %2$S. Would you like to unsubscribe from it?
+
+# LOCALIZATION NOTE (autoSubscribeText): %1$S is the newsgroup.
+autoSubscribeText=Would you like to subscribe to %1$S?
+
+# LOCALIZATION NOTE (Error -304): In the following item, don't translate "NNTP"
+# Error - server error
+## @name NNTP_ERROR_MESSAGE
+## @loc None
+-304=A News (NNTP) error occurred:
+
+# Error - newsgroup scan error
+## @name NNTP_NEWSGROUP_SCAN_ERROR
+## @loc None
+-305=A News error occurred. The scan of all newsgroups is incomplete. Try to View All Newsgroups again
+
+# Error - NNTP authinfo failure
+## @name NNTP_AUTH_FAILED
+## @loc None
+-260=An authorization error occurred. Please try entering your name and/or password again.
+
+# Error - TCP error
+## @name TCP_ERROR
+## @loc None
+-206=A communications error occurred. Try connecting again. TCP Error:
diff --git a/comm/mail/locales/en-US/chrome/messenger/newsError.dtd b/comm/mail/locales/en-US/chrome/messenger/newsError.dtd
new file mode 100644
index 0000000000..be88a12507
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/newsError.dtd
@@ -0,0 +1,31 @@
+<!-- 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/. -->
+
+<!-- LOCALIZATION NOTE (newsError.title): The title of the news error page.
+ Not generally visible. -->
+<!ENTITY newsError.title "Problem Loading Article">
+
+<!-- LOCALIZATION NOTE (articleNotFound.title): The main heading for the news
+ error page. -->
+<!ENTITY articleNotFound.title "Article not found">
+
+<!-- LOCALIZATION NOTE (articleNotFound.desc): A longer description for the news
+ error page. -->
+<!ENTITY articleNotFound.desc "The newsgroup server reports that it can't find the article.">
+
+<!-- LOCALIZATION NOTE (serverResponded.title): A string preceding the text
+ response from the newsgroup server describing the error. -->
+<!ENTITY serverResponded.title "Newsgroup server responded:">
+
+<!-- LOCALIZATION NOTE (articleExpired.title): A string explaining that the
+ article may have expired. -->
+<!ENTITY articleExpired.title "Perhaps the article has expired?">
+
+<!-- LOCALIZATION NOTE (trySearching.title): A string preceding the message's
+ ID. -->
+<!ENTITY trySearching.title "Try searching for article:">
+
+<!-- LOCALIZATION NOTE (removeExpiredArticles.title): The label for the button
+ to remove all expired articles from the newsgroup. -->
+<!ENTITY removeExpiredArticles.title "Remove All Expired Articles">
diff --git a/comm/mail/locales/en-US/chrome/messenger/offline.properties b/comm/mail/locales/en-US/chrome/messenger/offline.properties
new file mode 100644
index 0000000000..bdb69847c9
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/offline.properties
@@ -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/.
+
+# Download Messages Prompt
+downloadMessagesWindowTitle1=Download Messages
+downloadMessagesLabel1=Do you want to download messages for offline use before going offline?
+downloadMessagesCheckboxLabel1=Always ask me when I go offline
+downloadMessagesNow2=&Download Now
+
+# Send Messages Prompt
+sendMessagesWindowTitle1=Unsent Messages
+sendMessagesLabel2=Do you want to send your unsent messages now?
+sendMessagesCheckboxLabel1=Always ask me when I go online
+sendMessagesNow2=&Send Now
+
+processMessagesLater2=&Later
+
+# GetMessages While Offline Prompt
+getMessagesOfflineWindowTitle1=Get Messages
+getMessagesOfflineLabel1=You are currently offline. Do you want to go online to get new messages?
+
+# Send Messages Offline Prompt
+sendMessagesOfflineWindowTitle1=Unsent Messages
+sendMessagesOfflineLabel1=You are currently offline. Do you want to go online to send unsent messages?
+
+offlineTooltip=You are currently offline.
+onlineTooltip=You are currently online.
diff --git a/comm/mail/locales/en-US/chrome/messenger/offlineStartup.properties b/comm/mail/locales/en-US/chrome/messenger/offlineStartup.properties
new file mode 100644
index 0000000000..bd2024e761
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/offlineStartup.properties
@@ -0,0 +1,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/.
+
+title=Work Online
+desc=Would you like to go online now?\n\n(If you choose to work offline, you can go online later - choose `Offline' from the `File' menu, then uncheck `Work Offline'.)
+workOnline=Work Online
+workOffline=Work Offline
diff --git a/comm/mail/locales/en-US/chrome/messenger/outlookImportMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/outlookImportMsgs.properties
new file mode 100644
index 0000000000..ce2bc1fa3f
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/outlookImportMsgs.properties
@@ -0,0 +1,72 @@
+# 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 following are used by the Outlook import code to display status/error
+# and informational messages
+#
+
+# Short name of import module
+## @name OUTLOOKIMPORT_NAME
+## @loc None
+## LOCALIZATION NOTE (2000): DONT_TRANSLATE
+2000=Outlook
+
+# Description of import module
+## @name OUTLOOKIMPORT_DESCRIPTION
+## @loc None
+## LOCALIZATION NOTE (2010): In this item, don't translate "Outlook"
+2010=Outlook mail, address books, and settings
+
+# Success message
+## @name OUTLOOKIMPORT_MAILBOX_SUCCESS
+## @loc None
+## LOCALIZATION NOTE (2002): In this item, don't translate "%S" or "%d"
+## The variable %S will receive the name of the mailbox
+## The variable %d will receive the number of messages
+2002=Mailbox %S, imported %d messages
+
+# Error message
+## @name OUTLOOKIMPORT_MAILBOX_BADPARAM
+## @loc None
+2003=Bad parameter passed to import mailbox.
+
+# Error message
+## @name OUTLOOKIMPORT_MAILBOX_CONVERTERROR
+## @loc None
+## LOCALIZATION NOTE (2004): In this item, don't translate "%S"
+## The variable %S will receive the name of the mailbox
+2004=Error importing mailbox %S, all messages may not be imported from this mailbox.
+
+# Address book name
+## @name OUTLOOKIMPORT_ADDRNAME
+## @loc None
+## LOCALIZATION NOTE (2005): In this item, don't translate "Outlook"
+2005=Outlook address books
+
+# Description
+## @name OUTLOOKIMPORT_ADDRESS_SUCCESS
+## @loc None
+## LOCALIZATION NOTE (2006): In this item, don't translate "%S"
+## The variable %S will receive the name of the address book
+2006=Imported address book %S
+
+# Error message
+## @name OUTLOOKIMPORT_ADDRESS_BADPARAM
+## @loc None
+2007=Bad parameter passed to import address book.
+
+# Error message
+## @name OUTLOOKIMPORT_ADDRESS_BADSOURCEFILE
+## @loc None
+## LOCALIZATION NOTE (2008): In this item, don't translate "%S"
+## The variable %S will receive the name of the address book
+2008=Error accessing file for address book %S.
+
+# Error message
+## @name OUTLOOKIMPORT_ADDRESS_CONVERTERROR
+## @loc None
+## LOCALIZATION NOTE (2009): In this item, don't translate "%S"
+## The variable %S will receive the name of the address book
+2009=Error importing address book %S, all addresses may not have been imported.
diff --git a/comm/mail/locales/en-US/chrome/messenger/pgpmime.properties b/comm/mail/locales/en-US/chrome/messenger/pgpmime.properties
new file mode 100644
index 0000000000..2754392a07
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/pgpmime.properties
@@ -0,0 +1,10 @@
+# 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 following are used by the pgpmime content type handler
+#
+
+# LOCALIZATION NOTE(pgpMimeNeedsAddon): The text can contain HTML tags.
+pgpNotAvailable=This is an encrypted OpenPGP message, but support for OpenPGP decryption is not available.
diff --git a/comm/mail/locales/en-US/chrome/messenger/preferences/applicationManager.properties b/comm/mail/locales/en-US/chrome/messenger/preferences/applicationManager.properties
new file mode 100644
index 0000000000..576fe11b9f
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/preferences/applicationManager.properties
@@ -0,0 +1,13 @@
+# 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/.
+
+# LOCALIZATION NOTE
+# in descriptionApplications, %S will be replaced by one of the 3 following strings
+descriptionApplications=The following applications can be used to handle %S.
+
+handleProtocol=%S links
+handleFile=%S content
+
+descriptionWebApp=This web application is hosted at:
+descriptionLocalApp=This application is located at:
diff --git a/comm/mail/locales/en-US/chrome/messenger/preferences/applications.properties b/comm/mail/locales/en-US/chrome/messenger/preferences/applications.properties
new file mode 100644
index 0000000000..3ed05ce859
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/preferences/applications.properties
@@ -0,0 +1,13 @@
+# 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/.
+
+# LOCALIZATION NOTE (dialog_removeAccount):
+# %S will be replaced with the user-defined name of a storage account.
+dialog_removeAccount=Are you sure you want to remove the account "%S"?
+
+# LOCALIZATION NOTE (addProvider):
+# %S will be replace with the display name of a provider, e.g. DropBox
+addProvider=Add %S
+
+notConfiguredYet=This account has not been configured yet
diff --git a/comm/mail/locales/en-US/chrome/messenger/preferences/messagestyle.properties b/comm/mail/locales/en-US/chrome/messenger/preferences/messagestyle.properties
new file mode 100644
index 0000000000..9a2081a522
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/preferences/messagestyle.properties
@@ -0,0 +1,13 @@
+# 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/.
+
+# Content of preview conversation for chat message styles
+default=Default
+nick1=Florian
+buddy1=florian@im.instantbird.org
+nick2=Patrick
+buddy2=patrick@im.instantbird.org
+message1=Hi! :-)
+message2=What's up?
+message3=I'm trying Thunderbird! ;-)
diff --git a/comm/mail/locales/en-US/chrome/messenger/preferences/preferences.properties b/comm/mail/locales/en-US/chrome/messenger/preferences/preferences.properties
new file mode 100644
index 0000000000..8763436949
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/preferences/preferences.properties
@@ -0,0 +1,100 @@
+# 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/.
+
+#### Junk
+confirmResetJunkTrainingTitle=Confirm
+confirmResetJunkTrainingText=Are you sure you want to reset the adaptive filter training data?
+
+#### Downloads
+desktopFolderName=Desktop
+myDownloadsFolderName=My Downloads
+chooseAttachmentsFolderTitle=Choose Folder
+
+#### Applications
+
+fileEnding=%S file
+saveFile=Save File
+
+# LOCALIZATION NOTE (useApp, useDefault): %S = Application name
+useApp=Use %S
+useDefault=Use %S (default)
+
+useOtherApp=Use other…
+fpTitleChooseApp=Select Helper Application
+manageApp=Application Details…
+alwaysAsk=Always ask
+delete=Delete Action
+confirmDeleteTitle=Delete Action
+confirmDeleteText=Are you sure you want to delete this action?
+
+# LOCALIZATION NOTE (typeDescriptionWithDetails):
+# %1$S = type description (for example "Portable Document Format")
+# %2$S = details (see below, for example "(application/pdf: .pdf, .pdfx)")
+typeDescriptionWithDetails=%1$S %2$S
+
+# LOCALIZATION NOTE (typeDetailsWithTypeOrExt):
+# %1$S = type or extensions (for example "application/pdf", or ".pdf, .pdfx")
+typeDetailsWithTypeOrExt=(%1$S)
+
+# LOCALIZATION NOTE (typeDetailsWithTypeAndExt):
+# %1$S = type (for example "application/pdf")
+# %2$S = extensions (for example ".pdf, .pdfx")
+typeDetailsWithTypeAndExt=(%1$S: %2$S)
+
+#### Sound Notifications
+soundFilePickerTitle=Choose Sound
+
+#### Remote content
+imagepermissionstext=You can specify from which web sites images and other remote content are allowed to load. You can also allow all remote content based on sender email address. Type the address of the site or email you want to manage and then click Block or Allow.
+imagepermissionstitle=Exceptions - Remote Content
+
+#### Cookies
+cookiepermissionstitle=Exceptions - Cookies
+cookiepermissionstext=You can specify which web sites are always or never allowed to use cookies. Type the exact address of the site you want to manage and then click Block, Allow for Session, or Allow.
+
+#### Cookie Viewer
+hostColon=Host:
+domainColon=Domain:
+forSecureOnly=Encrypted connections only
+forAnyConnection=Any type of connection
+expireAtEndOfSession=At end of session
+
+noCookieSelected=<no cookie selected>
+cookiesAll=The following cookies are stored on your computer:
+cookiesFiltered=The following cookies match your search:
+# LOCALIZATION NOTE (removeSelectedCookies):
+# Semicolon-separated list of plural forms. See:
+# https://developer.mozilla.org/en/docs/Localization_and_Plurals
+# If you need to display the number of selected elements in your language,
+# you can use #1 in your localization as a placeholder for the number.
+# For example this is the English string with numbers:
+# removeSelectedCookies=Remove #1 Selected;Remove #1 Selected
+removeSelectedCookies=Remove Selected;Remove Selected
+defaultUserContextLabel=None
+
+####Preferences::Advanced::Network
+#LOCALIZATION NOTE: The next string is for the disk usage of the cache.
+# e.g., "Your cache is currently using 200 MB"
+# %1$S = size
+# %2$S = unit (MB, KB, etc.)
+actualDiskCacheSize=Your cache is currently using %1$S %2$S of disk space
+actualDiskCacheSizeCalculated=Calculating cache size…
+
+# LOCALIZATION NOTE (labelDefaultFont): %S = font name
+labelDefaultFont=Default (%S)
+labelDefaultFontUnnamed=Default
+
+# LOCALIZATION NOTE (appLocale.label): %S = Name of the application locale,
+# e.g. English (United States)
+appLocale.label=Application locale: %S
+appLocale.accesskey=o
+# LOCALIZATION NOTE (rsLocale.label): %S = Name of the locale chosen in regional settings,
+# e.g. German (Germany)
+rsLocale.label=Regional settings locale: %S
+rsLocale.accesskey=e
+
+applications-type-pdf = Portable Document Format (PDF)
+
+# LOCALIZATION NOTE (previewInApp): %S = brandShortName
+previewInApp=Preview in %S
diff --git a/comm/mail/locales/en-US/chrome/messenger/prefs.properties b/comm/mail/locales/en-US/chrome/messenger/prefs.properties
new file mode 100644
index 0000000000..484ce2c15d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/prefs.properties
@@ -0,0 +1,89 @@
+# 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 following are used by the Account Wizard
+#
+enterValidEmail=Please enter a valid email address.
+accountNameExists=An account with this name already exists. Please enter a different account name.
+accountNameEmpty=The account name can not be empty.
+modifiedAccountExists=An account with that user name and server name already exists. Please enter a different user name and/or server name.
+userNameChanged=Your User Name has been updated. You may also need to update your Email Address and/or User Name associated with this account.
+serverNameChanged=The server name setting has changed. Please verify that any folders used by filters exist on the new server.
+# LOCALIZATION NOTE (junkSettingsBroken): %1$S is the account name
+junkSettingsBroken=The Junk settings on account "%1$S" have a possible problem. Would you like to review them before saving Account Settings?
+# LOCALIZATION NOTE (localDirectoryChanged): %1$S is program name (&brandShortName;)
+localDirectoryChanged=%1$S needs to restart now to apply the change to the Local directory setting.
+localDirectoryRestart=Restart
+userNameEmpty=The user name can not be empty.
+# LOCALIZATION NOTE (localDirectoryInvalid): %1$S is path to folder
+localDirectoryInvalid=The Local Directory path "%1$S" is invalid. Please pick a different directory.
+# LOCALIZATION NOTE (localDirectoryNotAllowed): %1$S is path to folder
+localDirectoryNotAllowed=The Local Directory path "%1$S" is not suitable for message storage. Please choose another directory.
+# if the user chooses to cancel the wizard when no accounts are there throw a message
+# LOCALIZATION NOTE (cancelWizard)
+# do not localize "\n\n"
+cancelWizard=Are you sure you want to exit the Account Wizard?\n\nIf you exit, any information you have entered will be lost and the account will not be created.
+accountWizard=Account Wizard
+WizardExit=Exit
+WizardContinue=Cancel
+# when the wizard already has a domain (Should we say something different?)
+enterValidServerName=Please enter a valid server name.
+failedRemoveAccount=Failed to remove this account.
+#LOCALIZATION NOTE: accountName: %1$S is server name, %2$S is user name
+accountName=%1$S - %2$S
+
+# LOCALIZATION NOTE: confirmDeferAccountWarning: do not localize "\n\n", it means a new empty line in the string.
+confirmDeferAccountWarning=If you store this account's new mail in a different account's Inbox, you will no longer be able to access already downloaded email for this account. If you have mail in this account, please copy it to another account first.\n\nIf you have filters that filter mail into this account, you should disable them or change the destination folder. If any accounts have special folders in this account (Sent, Drafts, Templates, Archives, Junk), you should change them to be in another account.\n\nDo you still want to store this account's email in a different account?
+confirmDeferAccountTitle=Defer Account?
+
+directoryAlreadyUsedByOtherAccount=The directory specified in the Local Directory setting is already used by the "%S" account. Please pick a different directory.
+directoryParentUsedByOtherAccount=A parent directory of the directory specified in the Local Directory setting is already used by the "%S" account. Please pick a different directory.
+directoryChildUsedByOtherAccount=A subdirectory of the directory specified in the Local Directory setting is already used by the "%S" account. Please pick a different directory.
+#Provide default example values for sample email address
+exampleEmailUserName=user
+exampleEmailDomain=example.net
+emailFieldText=Email Address:
+#LOCALIZATION NOTE: defaultEmailText: %1$S is user name, %2$S is domain
+defaultEmailText=Enter your email address. This is the address others will use to send email to you (for example, "%1$S@%2$S").
+#LOCALIZATION NOTE: customizedEmailText: %1$S is provider, %2$S is email username, %3$S is sample email, %4$S is sample username
+customizedEmailText=Enter your %1$S %2$S (for example, if your %1$S email address is "%3$S", your %2$S is "%4$S").
+
+# account manager stuff
+prefPanel-server=Server Settings
+prefPanel-copies=Copies & Folders
+prefPanel-synchronization=Synchronization & Storage
+prefPanel-diskspace=Disk Space
+prefPanel-addressing=Composition & Addressing
+prefPanel-junk=Junk Settings
+## LOCALIZATION NOTE (prefPanel-smtp): Don't translate "SMTP"
+prefPanel-smtp=Outgoing Server (SMTP)
+
+# account manager multiple identity support
+#LOCALIZATION NOTE: accountName: %1$S
+identity-list-title=Identities for %1$S
+
+identityDialogTitleAdd=New Identity
+## LOCALIZATION NOTE (identityDialogTitleEdit): %S is the identity name
+identityDialogTitleEdit=Edit %S
+
+identity-edit-req=You must specify a valid email address for this identity.
+identity-edit-req-title=Error Creating Identity
+
+## LOCALIZATION NOTE (identity-delete-confirm): %S is the identity name
+# and should be put on a new line. The new line is produced with the "\n" string.
+identity-delete-confirm=Are you sure you want to delete the identity\n%S?
+## LOCALIZATION NOTE (identity-delete-confirm-title): %S is the account name
+identity-delete-confirm-title=Deleting identity for %S
+identity-delete-confirm-button=Delete
+
+choosefile=Choose a file
+
+forAccount=For account "%S"
+
+removeFromServerTitle=Confirm permanent, automatic deletion of messages
+removeFromServer=This setting will permanently delete old messages from the remote server AND your local storage. Are you sure you want to proceed?
+
+confirmSyncChangesTitle=Confirm synchronization changes
+confirmSyncChanges=The Message Synchronization settings were changed.\n\nDo you want to save them?
+confirmSyncChangesDiscard=Discard
diff --git a/comm/mail/locales/en-US/chrome/messenger/removeAccount.dtd b/comm/mail/locales/en-US/chrome/messenger/removeAccount.dtd
new file mode 100644
index 0000000000..fbc22bcec3
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/removeAccount.dtd
@@ -0,0 +1,22 @@
+<!-- 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/. -->
+
+<!ENTITY dialogTitle "Remove Account and Data">
+<!ENTITY removeButton.label "Remove">
+<!ENTITY removeButton.accesskey "R">
+<!ENTITY removeAccount.label "Remove account information">
+<!ENTITY removeAccount.accesskey "a">
+<!ENTITY removeAccount.desc "Removes only &brandShortName;'s knowledge of this account. Does not affect the account itself on the server.">
+<!ENTITY removeData.label "Remove message data">
+<!ENTITY removeData.accesskey "d">
+<!ENTITY removeDataChat.label "Remove conversation data">
+<!ENTITY removeDataChat.accesskey "d">
+<!ENTITY removeDataLocalAccount.desc "Removes all messages, folders and filters associated with this account from your local disk. This does not affect some messages which may still be kept on the server. Do not choose this if you plan to archive the local data or re-use it in &brandShortName; later.">
+<!ENTITY removeDataServerAccount.desc "Removes all messages, folders and filters associated with this account from your local disk. Your messages and folders are still kept on the server.">
+<!ENTITY removeDataChatAccount.desc "Removes all logs of conversations stored for this account on your local disk.">
+<!ENTITY showData.label "Show data location">
+<!ENTITY showData.accesskey "S">
+<!ENTITY progressPending "Removing selected data…">
+<!ENTITY progressSuccess "Removal succeeded.">
+<!ENTITY progressFailure "Removal failed.">
diff --git a/comm/mail/locales/en-US/chrome/messenger/removeAccount.properties b/comm/mail/locales/en-US/chrome/messenger/removeAccount.properties
new file mode 100644
index 0000000000..535fd1ea9c
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/removeAccount.properties
@@ -0,0 +1,5 @@
+# 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/.
+
+removeQuestion=Are you sure you want to remove the account "%S"?
diff --git a/comm/mail/locales/en-US/chrome/messenger/renameFolderDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/renameFolderDialog.dtd
new file mode 100644
index 0000000000..62dd784f45
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/renameFolderDialog.dtd
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+
+<!ENTITY renameFolderDialog.title "Rename Folder">
+<!ENTITY rename.label "Enter the new name for your folder:">
+<!ENTITY rename.accesskey "E">
+<!ENTITY accept.label "Rename">
+<!ENTITY accept.accesskey "R">
diff --git a/comm/mail/locales/en-US/chrome/messenger/sanitize.dtd b/comm/mail/locales/en-US/chrome/messenger/sanitize.dtd
new file mode 100644
index 0000000000..e959a11924
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/sanitize.dtd
@@ -0,0 +1,36 @@
+<!-- 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/. -->
+
+<!ENTITY sanitizeDialog2.title "Clear Recent History">
+
+<!-- XXX rearrange entities to match physical layout when l10n isn't an issue -->
+<!-- LOCALIZATION NOTE (clearTimeDuration.*): "Time range to clear" dropdown.
+ See UI mockup at bug 480169 -->
+<!ENTITY clearTimeDuration.label "Time range to clear: ">
+<!ENTITY clearTimeDuration.accesskey "T">
+<!ENTITY clearTimeDuration.lastHour "Last Hour">
+<!ENTITY clearTimeDuration.last2Hours "Last Two Hours">
+<!ENTITY clearTimeDuration.last4Hours "Last Four Hours">
+<!ENTITY clearTimeDuration.today "Today">
+<!ENTITY clearTimeDuration.everything "Everything">
+<!-- Localization note (clearTimeDuration.suffix) - trailing entity for languages
+that require it. -->
+<!ENTITY clearTimeDuration.suffix "">
+
+
+<!ENTITY historyGroup.label "History">
+
+<!ENTITY itemHistory.label "Browsing History">
+<!ENTITY itemHistory.accesskey "B">
+<!ENTITY itemCookies.label "Cookies">
+<!ENTITY itemCookies.accesskey "C">
+<!ENTITY itemCache.label "Cache">
+<!ENTITY itemCache.accesskey "A">
+
+<!-- LOCALIZATION NOTE (sanitizeEverythingUndoWarning): Second warning paragraph
+ that appears when "Time range to clear" is set to "Everything". See UI
+ mockup at bug 480169 -->
+<!ENTITY sanitizeEverythingUndoWarning "This action cannot be undone.">
+
+<!ENTITY dialog.width "28em">
diff --git a/comm/mail/locales/en-US/chrome/messenger/seamonkeyImportMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/seamonkeyImportMsgs.properties
new file mode 100644
index 0000000000..5e0b9d8499
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/seamonkeyImportMsgs.properties
@@ -0,0 +1,18 @@
+# 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 following are used by the seamonkey import code to display status/error
+# and informational messages
+
+# Short name of import module
+SeamonkeyImportName=SeaMonkey
+
+# Description of import module
+SeamonkeyImportDescription=Import address books, mail and accounts from SeaMonkey.
+
+# Success Message for addressbook import
+SeamonkeyImportAddressSuccess=Address books were successfully imported.
+
+# Success Message for mail import
+SeamonkeyImportSettingsSuccess=Local messages and accounts were successfully imported.
diff --git a/comm/mail/locales/en-US/chrome/messenger/search-attributes.properties b/comm/mail/locales/en-US/chrome/messenger/search-attributes.properties
new file mode 100644
index 0000000000..010b8cd1ef
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/search-attributes.properties
@@ -0,0 +1,45 @@
+# 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/.
+
+#these need to match nsMsgSearchAttrib interface in nsMsgSearchCore.idl
+#and nsMsgSearchAttribMap in nsMsgSearchAdapter.cpp
+Subject=Subject
+From=From
+Body=Body
+Date=Date
+Priority=Priority
+Status=Status
+To=To
+Cc=Cc
+ToOrCc=To or Cc
+AgeInDays=Age In Days
+SizeKB=Size (KB)
+Tags=Tags
+# for AB and LDAP
+AnyName=Any Name
+DisplayName=Display Name
+Nickname=Nickname
+ScreenName=Screen Name
+Email=Email
+AdditionalEmail=Additional Email
+AnyNumber=Any Number
+WorkPhone=Work Phone
+HomePhone=Home Phone
+Fax=Fax
+Pager=Pager
+Mobile=Mobile
+City=City
+Street=Street
+Title=Title
+Organization=Organization
+Department=Department
+# more mailnews
+FromToCcOrBcc=From, To, Cc or Bcc
+JunkScoreOrigin=Junk Score Origin
+JunkPercent=Junk Percent
+AttachmentStatus=Attachment Status
+JunkStatus=Junk Status
+Label=Label
+Customize=Customize…
+MissingCustomTerm=Missing Custom Term
diff --git a/comm/mail/locales/en-US/chrome/messenger/search-operators.properties b/comm/mail/locales/en-US/chrome/messenger/search-operators.properties
new file mode 100644
index 0000000000..f61c239b30
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/search-operators.properties
@@ -0,0 +1,31 @@
+# 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/.
+
+0=contains
+1=doesn't contain
+2=is
+3=isn't
+4=is empty
+
+5=is before
+6=is after
+
+7=is higher than
+8=is lower than
+
+9=begins with
+10=ends with
+
+11=sounds like
+12=LdapDwim
+
+13=is greater than
+14=is less than
+
+15=NameCompletion
+16=is in my address book
+17=isn't in my address book
+18=isn't empty
+19=matches
+20=doesn't match
diff --git a/comm/mail/locales/en-US/chrome/messenger/search.properties b/comm/mail/locales/en-US/chrome/messenger/search.properties
new file mode 100644
index 0000000000..c629881f6a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/search.properties
@@ -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/.
+
+# these are the fields that get inserted in the search line
+# for "and" searches, this looks like:
+#
+# searchAnd0 <attribute> searchAnd1 <operator> searchAnd2 <value> searchAnd4
+#
+# for example, in english this looks like:
+# and the [Sender ] [doesn't contain] [John]
+#
+# TODO: need to special-case the first line (filterindex==0)
+
+# filter stuff
+
+searchingMessage=Searching…
+# LOCALIZATION NOTE (matchesFound): #1 number of matches found
+matchesFound=#1 match found;#1 matches found
+noMatchesFound=No matches found
+labelForStopButton=Stop
+labelForSearchButton=Search
+labelForStopButton.accesskey=S
+labelForSearchButton.accesskey=S
+
+moreButtonTooltipText=Add a new rule
+lessButtonTooltipText=Remove this rule
diff --git a/comm/mail/locales/en-US/chrome/messenger/searchTermOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/searchTermOverlay.dtd
new file mode 100644
index 0000000000..e9bdfa0870
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/searchTermOverlay.dtd
@@ -0,0 +1,19 @@
+<!-- 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/. -->
+
+<!ENTITY matchAll.label "Match all of the following">
+<!ENTITY matchAll.accesskey "a">
+<!ENTITY matchAny.label "Match any of the following">
+<!ENTITY matchAny.accesskey "o">
+<!ENTITY matchAllMsgs.label "Match all messages">
+<!ENTITY matchAllMsgs.accesskey "m">
+
+<!-- LOCALIZATION NOTE
+ The values below are used to control the widths of the search widgets.
+ Change the values only when the localized strings in the popup menus
+ are truncated in the widgets.
+ -->
+<!ENTITY searchTermListAttributesFlexValue "1">
+<!ENTITY searchTermListOperatorsFlexValue "1">
+<!ENTITY searchTermListValueFlexValue "3">
diff --git a/comm/mail/locales/en-US/chrome/messenger/shutdownWindow.properties b/comm/mail/locales/en-US/chrome/messenger/shutdownWindow.properties
new file mode 100644
index 0000000000..4333ba08a3
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/shutdownWindow.properties
@@ -0,0 +1,10 @@
+# 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/.
+
+
+# These strings are loaded and represented by the XUL dialog.
+shutdownDialogTitle=Shutdown Progress Window
+taskProgress=Processing %1$S of %2$S Tasks
+
+# These strings are loaded by the individual shutdown tasks.
diff --git a/comm/mail/locales/en-US/chrome/messenger/smime.properties b/comm/mail/locales/en-US/chrome/messenger/smime.properties
new file mode 100644
index 0000000000..7bfffdb56a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/smime.properties
@@ -0,0 +1,11 @@
+# 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 following are used by the smime content type handler
+#
+
+## @name NS_MSG_UNABLE_TO_OPEN_FILE
+## LOCALIZATION NOTE: the text can contain HTML tags.
+1000=This is an <B>ENCRYPTED</B> or <B>SIGNED</B> message.<br> This Mail application does not support encrypted or signed mail.
diff --git a/comm/mail/locales/en-US/chrome/messenger/smtpEditOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/smtpEditOverlay.dtd
new file mode 100644
index 0000000000..593393a21b
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/smtpEditOverlay.dtd
@@ -0,0 +1,24 @@
+<!-- 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/. -->
+
+<!ENTITY settings.caption "Settings">
+<!ENTITY security.caption "Security and Authentication">
+<!ENTITY serverName.label "Server Name:">
+<!ENTITY serverName.accesskey "S">
+<!ENTITY serverDescription.label "Description:">
+<!ENTITY serverDescription.accesskey "D">
+<!ENTITY serverPort.label "Port:">
+<!ENTITY serverPort.accesskey "P">
+<!ENTITY userName.label "User Name:">
+<!ENTITY userName.accesskey "m">
+<!ENTITY connectionSecurity.label "Connection security:">
+<!ENTITY connectionSecurity.accesskey "n">
+<!ENTITY connectionSecurityType-0.label "None">
+<!ENTITY connectionSecurityType-1.label "STARTTLS, if available">
+<!ENTITY connectionSecurityType-2.label "STARTTLS">
+<!ENTITY connectionSecurityType-3.label "SSL/TLS">
+<!ENTITY smtpEditTitle.label "SMTP Server">
+<!ENTITY serverPortDefault.label "Default:">
+<!ENTITY authMethod.label "Authentication method:">
+<!ENTITY authMethod.accesskey "i">
diff --git a/comm/mail/locales/en-US/chrome/messenger/subscribe.dtd b/comm/mail/locales/en-US/chrome/messenger/subscribe.dtd
new file mode 100644
index 0000000000..37c7979f2f
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/subscribe.dtd
@@ -0,0 +1,22 @@
+<!-- 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/. -->
+
+<!ENTITY subscribeDialog.title "Subscribe">
+<!ENTITY subscribeButton.label "Subscribe">
+<!ENTITY subscribeButton.accesskey "S">
+<!ENTITY unsubscribeButton.label "Unsubscribe">
+<!ENTITY unsubscribeButton.accesskey "U">
+<!ENTITY newGroupsTab.label "New Groups">
+<!ENTITY newGroupsTab.accesskey "N">
+<!ENTITY refreshButton.label "Refresh">
+<!ENTITY refreshButton.accesskey "R">
+<!ENTITY stopButton.label "Stop">
+<!ENTITY stopButton.accesskey "T">
+<!ENTITY server.label "Account:">
+<!ENTITY server.accesskey "A">
+<!ENTITY subscribedHeader.label "Subscribe">
+<!-- commenting out until bug 38906 is fixed
+<!ENTITY messagesHeader.label "Messages"> -->
+<!ENTITY namefield.label "Show items that contain:">
+<!ENTITY namefield.accesskey "O">
diff --git a/comm/mail/locales/en-US/chrome/messenger/subscribe.properties b/comm/mail/locales/en-US/chrome/messenger/subscribe.properties
new file mode 100644
index 0000000000..10dbb101c3
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/subscribe.properties
@@ -0,0 +1,13 @@
+# 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/.
+
+subscribeLabel-nntp=Select the newsgroups to subscribe to:
+subscribeLabel-imap=Select the folders to subscribe to:
+currentListTab-nntp.label=Current Group List
+currentListTab-nntp.accesskey=L
+currentListTab-imap.label=Folder List
+currentListTab-imap.accesskey=L
+pleaseWaitString=Please wait…
+offlineState=You are offline. Items could not be retrieved from the server.
+errorPopulating=Error retrieving items from the server.
diff --git a/comm/mail/locales/en-US/chrome/messenger/tabmail.dtd b/comm/mail/locales/en-US/chrome/messenger/tabmail.dtd
new file mode 100644
index 0000000000..0b49814597
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/tabmail.dtd
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+
+<!ENTITY closeTab.label "Close Tab">
+<!ENTITY listAllTabs.label "List all tabs">
+<!-- LOCALIZATION NOTE(defaultTabTitle.label): This is the default tab
+ title to show when the tab has no title. -->
+<!ENTITY defaultTabTitle.label "Home">
diff --git a/comm/mail/locales/en-US/chrome/messenger/taskbar.properties b/comm/mail/locales/en-US/chrome/messenger/taskbar.properties
new file mode 100644
index 0000000000..47959f9102
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/taskbar.properties
@@ -0,0 +1,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/.
+
+taskbar.tasks.composeMessage.label=Write new message
+taskbar.tasks.composeMessage.description=Write a new message.
+taskbar.tasks.openAddressBook.label=Open address book
+taskbar.tasks.openAddressBook.description=Open your address book.
diff --git a/comm/mail/locales/en-US/chrome/messenger/telemetry.properties b/comm/mail/locales/en-US/chrome/messenger/telemetry.properties
new file mode 100644
index 0000000000..f80bc9edd8
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/telemetry.properties
@@ -0,0 +1,13 @@
+# 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/.
+
+# Telemetry prompt
+# LOCALIZATION NOTE (telemetryText): %1$S will be replaced by brandFullName,
+# and %2$S by the value of the toolkit.telemetry.server_owner preference.
+telemetryText = Would you like to help improve %1$S by automatically reporting memory usage, performance, and responsiveness to %2$S?
+telemetryLinkLabel = Learn More
+telemetryYesButtonLabel = Yes
+telemetryYesButtonAccessKey = Y
+telemetryNoButtonLabel = No
+telemetryNoButtonAccessKey = N
diff --git a/comm/mail/locales/en-US/chrome/messenger/templateUtils.properties b/comm/mail/locales/en-US/chrome/messenger/templateUtils.properties
new file mode 100644
index 0000000000..063891d153
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/templateUtils.properties
@@ -0,0 +1,7 @@
+# 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/.
+
+# LOCALIZATION NOTE yesterday: used in various places where we compute
+# a "friendly" date, e.g. displaying that a message was from yesterday.
+yesterday=yesterday
diff --git a/comm/mail/locales/en-US/chrome/messenger/textImportMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/textImportMsgs.properties
new file mode 100644
index 0000000000..37df51c03f
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/textImportMsgs.properties
@@ -0,0 +1,43 @@
+# 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 following are used by the text import code to display status/error
+# and informational messages
+#
+
+# Short name of import module
+## @name TEXTIMPORT_NAME
+## @loc None
+2000=Text file (LDIF, .tab, .csv, .txt)
+
+# Description of import module
+## @name TEXTIMPORT_DESCRIPTION
+## @loc None
+2001=Import an address book from a text file, including: LDIF (.ldif, .ldi), tab-delimited (.tab, .txt) or comma-separated (.csv) formats.
+
+# Description of import module
+## @name TEXTIMPORT_ADDRESS_NAME
+## @loc None
+2002=Text Address Book
+
+# Description
+## @name TEXTIMPORT_ADDRESS_SUCCESS
+## @loc None
+2003=Imported address book %S
+
+# Error message
+## @name TEXTIMPORT_ADDRESS_BADPARAM
+## @loc None
+2004=Bad parameter passed to import address book.
+
+# Error message
+## @name TEXTIMPORT_ADDRESS_BADSOURCEFILE
+## @loc None
+2005=Error accessing file for address book %S.
+
+# Error message
+## @name TEXTIMPORT_ADDRESS_CONVERTERROR
+## @loc None
+2006=Error importing address book %S, all addresses may not have been imported.
diff --git a/comm/mail/locales/en-US/chrome/messenger/vCardImportMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/vCardImportMsgs.properties
new file mode 100644
index 0000000000..0a24e9cf2d
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/vCardImportMsgs.properties
@@ -0,0 +1,26 @@
+# 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 following are used by the vCard import code to display status, error, and
+# informational messages
+#
+
+vCardImportName=vCard file (.vcf)
+
+vCardImportDescription=Import an address book from vCard format
+
+vCardImportAddressName=vCard Address Book
+
+# LOCALIZATION NOTE (vCardImportAddressSuccess): %S is replaced by the
+# name of the address book being imported.
+vCardImportAddressSuccess=Imported address book %S
+
+# LOCALIZATION NOTE (vCardImportAddressSuccess): %S is replaced by the
+# name of the address book being imported.
+vCardImportAddressBadSourceFile=Error accessing file for address book %S.
+
+# LOCALIZATION NOTE (vCardImportAddressSuccess): %S is replaced by the
+# name of the address book being imported.
+vCardImportAddressConvertError=Error importing address book %S, all addresses may not have been imported.
diff --git a/comm/mail/locales/en-US/chrome/messenger/viewLog.dtd b/comm/mail/locales/en-US/chrome/messenger/viewLog.dtd
new file mode 100644
index 0000000000..5bca64539e
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/viewLog.dtd
@@ -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/. -->
+
+<!ENTITY viewLog.title "Filter Log">
+<!ENTITY viewLogInfo.text "The Filter Log documents the filters that have been run for this account. Use the check box below to enable logging.">
+<!ENTITY clearLog.label "Clear Log">
+<!ENTITY clearLog.accesskey "C">
+<!ENTITY enableLog.label "Enable the Filter Log">
+<!ENTITY enableLog.accesskey "E">
+<!ENTITY closeLog.label "Close">
+<!ENTITY closeLog.accesskey "o">
diff --git a/comm/mail/locales/en-US/chrome/messenger/viewSource.dtd b/comm/mail/locales/en-US/chrome/messenger/viewSource.dtd
new file mode 100644
index 0000000000..7895345453
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/viewSource.dtd
@@ -0,0 +1,84 @@
+<!-- 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/. -->
+
+<!-- extracted from content/viewSource.xhtml -->
+
+<!-- LOCALIZATION NOTE (mainWindow.title) : DONT_TRANSLATE -->
+<!ENTITY mainWindow.title "&brandFullName;">
+<!-- LOCALIZATION NOTE (mainWindow.titlemodifier) : DONT_TRANSLATE -->
+<!ENTITY mainWindow.titlemodifier "&brandFullName;">
+<!-- LOCALIZATION NOTE (mainWindow.titlemodifierseparator) : DONT_TRANSLATE -->
+<!ENTITY mainWindow.titlemodifierseparator " - ">
+<!ENTITY mainWindow.preface "Source of: ">
+
+<!ENTITY editMenu.label "Edit">
+<!ENTITY editMenu.accesskey "E">
+<!ENTITY fileMenu.label "File">
+<!ENTITY fileMenu.accesskey "F">
+<!ENTITY savePageCmd.label "Save Page As…">
+<!ENTITY savePageCmd.accesskey "A">
+<!ENTITY savePageCmd.commandkey "S">
+<!ENTITY printCmd.label "Print…">
+<!ENTITY printCmd.accesskey "P">
+<!ENTITY printCmd.commandkey "P">
+<!ENTITY closeCmd.label "Close">
+<!ENTITY closeCmd.accesskey "C">
+<!ENTITY closeCmd.commandkey "W">
+
+<!-- LOCALIZATION NOTE :
+textEnlarge.commandkey3, textReduce.commandkey2 and
+textReset.commandkey2 are alternative acceleration keys for zoom.
+If shift key is needed with your locale popular keyboard for them,
+you can use these alternative items. Otherwise, their values should be empty. -->
+
+<!ENTITY textEnlarge.commandkey "+">
+<!ENTITY textEnlarge.commandkey2 "=">
+<!ENTITY textEnlarge.commandkey3 "">
+<!ENTITY textReduce.commandkey "-">
+<!ENTITY textReduce.commandkey2 "">
+<!ENTITY textReset.commandkey "0">
+<!ENTITY textReset.commandkey2 "">
+
+<!ENTITY goToLineCmd.label "Go to Line…">
+<!ENTITY goToLineCmd.accesskey "G">
+<!ENTITY goToLineCmd.commandkey "l">
+
+<!ENTITY viewMenu.label "View">
+<!ENTITY viewMenu.accesskey "V">
+<!ENTITY reloadCmd.label "Reload">
+<!ENTITY reloadCmd.accesskey "R">
+<!ENTITY reloadCmd.commandkey "r">
+<!ENTITY menu_wrapLongLines.title "Wrap Long Lines">
+<!ENTITY menu_wrapLongLines.accesskey "W">
+<!ENTITY menu_highlightSyntax.label "Syntax Highlighting">
+<!ENTITY menu_highlightSyntax.accesskey "H">
+<!ENTITY menu_textSize.label "Text Size">
+<!ENTITY menu_textSize.accesskey "Z">
+<!ENTITY menu_textEnlarge.label "Increase">
+<!ENTITY menu_textEnlarge.accesskey "I">
+<!ENTITY menu_textReduce.label "Decrease">
+<!ENTITY menu_textReduce.accesskey "D">
+<!ENTITY menu_textReset.label "Normal">
+<!ENTITY menu_textReset.accesskey "N">
+
+<!ENTITY findOnCmd.label "Find in This Page…">
+<!ENTITY findOnCmd.accesskey "F">
+<!ENTITY findOnCmd.commandkey "f">
+<!ENTITY findAgainCmd.label "Find Again">
+<!ENTITY findAgainCmd.accesskey "g">
+<!ENTITY findAgainCmd.commandkey "g">
+<!ENTITY findAgainCmd.commandkey2 "VK_F3">
+<!ENTITY findSelectionCmd.commandkey "e">
+
+<!ENTITY backCmd.label "Back">
+<!ENTITY backCmd.accesskey "B">
+<!ENTITY forwardCmd.label "Forward">
+<!ENTITY forwardCmd.accesskey "F">
+<!ENTITY goBackCmd.commandKey "[">
+<!ENTITY goForwardCmd.commandKey "]">
+
+<!ENTITY copyLinkCmd.label "Copy Link Location">
+<!ENTITY copyLinkCmd.accesskey "L">
+<!ENTITY copyEmailCmd.label "Copy Email Address">
+<!ENTITY copyEmailCmd.accesskey "E">
diff --git a/comm/mail/locales/en-US/chrome/messenger/viewSource.properties b/comm/mail/locales/en-US/chrome/messenger/viewSource.properties
new file mode 100644
index 0000000000..ea79ace721
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/viewSource.properties
@@ -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/.
+
+goToLineTitle = Go to line
+goToLineText = Enter line number
+invalidInputTitle = Invalid input
+invalidInputText = The line number entered is invalid.
+outOfRangeTitle = Line not found
+outOfRangeText = The specified line was not found.
+viewSelectionSourceTitle = DOM Source of Selection
+viewMathMLSourceTitle = DOM Source of MathML
+
+context_goToLine_label = Go to Line…
+context_goToLine_accesskey = L
+context_wrapLongLines_label = Wrap Long Lines
+context_highlightSyntax_label = Syntax Highlighting
diff --git a/comm/mail/locales/en-US/chrome/messenger/viewZoomOverlay.dtd b/comm/mail/locales/en-US/chrome/messenger/viewZoomOverlay.dtd
new file mode 100644
index 0000000000..55541f6dc8
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/viewZoomOverlay.dtd
@@ -0,0 +1,30 @@
+<!-- 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/. -->
+
+<!-- LOCALIZATION NOTE :
+fullZoomEnlargeCmd.commandkey3, fullZoomReduceCmd.commandkey2 and
+fullZoomResetCmd.commandkey2 are alternative acceleration keys for zoom.
+If shift key is needed with your locale popular keyboard for them,
+you can use these alternative items. Otherwise, their values should be empty. -->
+
+<!ENTITY fullZoomEnlargeCmd.label "Zoom In">
+<!ENTITY fullZoomEnlargeCmd.accesskey "I">
+<!ENTITY fullZoomEnlargeCmd.commandkey "+">
+<!ENTITY fullZoomEnlargeCmd.commandkey2 "="> <!-- + is above this key on many keyboards -->
+<!ENTITY fullZoomEnlargeCmd.commandkey3 "">
+
+<!ENTITY fullZoomReduceCmd.label "Zoom Out">
+<!ENTITY fullZoomReduceCmd.accesskey "O">
+<!ENTITY fullZoomReduceCmd.commandkey "-">
+<!ENTITY fullZoomReduceCmd.commandkey2 "">
+
+<!ENTITY fullZoomResetCmd.label "Reset">
+<!ENTITY fullZoomResetCmd.accesskey "R">
+<!ENTITY fullZoomResetCmd.commandkey "0">
+<!ENTITY fullZoomResetCmd.commandkey2 "">
+
+<!ENTITY fullZoomToggleCmd.label "Zoom Text Only">
+<!ENTITY fullZoomToggleCmd.accesskey "T">
+<!ENTITY fullZoom.label "Zoom">
+<!ENTITY fullZoom.accesskey "Z">
diff --git a/comm/mail/locales/en-US/chrome/messenger/virtualFolderListDialog.dtd b/comm/mail/locales/en-US/chrome/messenger/virtualFolderListDialog.dtd
new file mode 100644
index 0000000000..d3199adb48
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/virtualFolderListDialog.dtd
@@ -0,0 +1,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/. -->
+
+<!ENTITY virtualFolderListTitle.title "Select Folder(s)">
+<!ENTITY virtualFolderDesc.label "Select the folders to search:">
+<!ENTITY folderName.label "Folder name">
+<!ENTITY folderSearch.label "Search">
diff --git a/comm/mail/locales/en-US/chrome/messenger/virtualFolderProperties.dtd b/comm/mail/locales/en-US/chrome/messenger/virtualFolderProperties.dtd
new file mode 100644
index 0000000000..46018de550
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/virtualFolderProperties.dtd
@@ -0,0 +1,22 @@
+<!-- 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/. -->
+
+<!ENTITY virtualFolderProperties.title "New Saved Search Folder">
+<!ENTITY name.label "Name:">
+<!ENTITY name.accesskey "N">
+<!ENTITY description.label "Create as a subfolder of:">
+<!ENTITY description.accesskey "C">
+
+<!ENTITY searchTermCaption.label "Configure the search criteria used for this saved search folder: ">
+
+<!ENTITY folderSelectionCaption.label "Select the folders to search: ">
+<!ENTITY chooseFoldersButton.label "Choose…">
+<!ENTITY chooseFoldersButton.accesskey "h">
+
+<!ENTITY searchOnline.label "Search Online (Gives up-to-date results for IMAP and News folders but increases time to open the folder)">
+<!ENTITY searchOnline.accesskey "S">
+<!ENTITY newFolderButton.label "Create">
+<!ENTITY newFolderButton.accesskey "r">
+<!ENTITY editFolderButton.label "Update">
+<!ENTITY editFolderButton.accesskey "U">
diff --git a/comm/mail/locales/en-US/chrome/messenger/wmImportMsgs.properties b/comm/mail/locales/en-US/chrome/messenger/wmImportMsgs.properties
new file mode 100644
index 0000000000..42786af7c1
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/messenger/wmImportMsgs.properties
@@ -0,0 +1,76 @@
+# 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 following are used by the windows live mail import code to display status/error
+# and informational messages
+#
+
+# Short name of import module
+## @name WMIMPORT_NAME
+## @loc None
+## LOCALIZATION NOTE (2000): DONT_TRANSLATE
+2000=Windows Live Mail
+
+# Description of import module
+## @name WMIMPORT_DESCRIPTION
+## @loc None
+## LOCALIZATION NOTE (2001): In this item, don't translate "Windows Live Mail"
+2001=Windows Live Mail settings
+
+# Success message
+## @name WMIMPORT_MAILBOX_SUCCESS
+## @loc None
+## LOCALIZATION NOTE (2002): In this item, don't translate "%1$S" or "%2$d"
+## The variable %1$S will contain the name of the Mailbox
+## The variable %2$d will contain the number of messages
+2002=Mailbox %1$S, imported %2$d messages
+
+# Error message
+## @name WMIMPORT_MAILBOX_BADPARAM
+## @loc None
+2003=Bad parameter passed to import mailbox.
+
+# Error message
+## @name WMIMPORT_MAILBOX_BADSOURCEFILE
+## @loc None
+## LOCALIZATION NOTE (2004): In this item, don't translate "%S"
+## The variable %S will contain the name of the Mailbox
+2004=Error accessing file for mailbox %S.
+
+# Error message
+## @name WMIMPORT_MAILBOX_CONVERTERROR
+## @loc None
+## LOCALIZATION NOTE (2005): In this item, don't translate "%S"
+## The variable %S will contain the name of the Mailbox
+2005=Error importing mailbox %S, all messages may not be imported from this mailbox.
+
+# Default name of imported addressbook
+## @name WMIMPORT_DEFAULT_NAME
+## @loc None
+2006=Windows Live Mail Address Book
+
+# Autofind description
+## @name WMIMPORT_AUTOFIND
+## @loc None
+2007=Windows Live Mail address book (windows address book)
+
+# Description
+## @name WMIMPORT_ADDRESS_SUCCESS
+## @loc None
+## LOCALIZATION NOTE (2006): In this item, don't translate "%S"
+## The variable %S will receive the name of the address book
+2008=Imported address book %S
+
+# Error message
+## @name WMIMPORT_ADDRESS_CONVERTERROR
+## @loc None
+## LOCALIZATION NOTE (2009): In this item, don't translate "%S"
+## The variable %S will receive the name of the address book
+2009=Error importing address book %S, all addresses may not have been imported.
+
+# Error message
+## @name WMIMPORT_ADDRESS_BADPARAM
+## @loc None
+2010=Bad parameter passed to import addressbook.
diff --git a/comm/mail/locales/en-US/chrome/mozldap/ldap.properties b/comm/mail/locales/en-US/chrome/mozldap/ldap.properties
new file mode 100644
index 0000000000..4f5df59f4a
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/mozldap/ldap.properties
@@ -0,0 +1,261 @@
+#
+# 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 following two strings are used when prompting the user for authentication
+# information:
+
+## @name AUTH_PROMPT_TITLE
+## @loc none
+authPromptTitle=LDAP Server Password Required
+
+## @name AUTH_PROMPT_TEXT
+## @loc %1$S should not be localized. It is the hostname of the LDAP server.
+authPromptText=Please enter your password for %1$S.
+
+# These are string versions of all the errors defined in
+# nsILDAPErrors.idl, as well as the nsresult codes based on those
+# errors. See that file for the genesis of these codes, as well as
+# for info about how to get documentation about their precise
+# meanings.
+
+## @name OPERATIONS_ERROR
+## @loc none
+1=Operations error
+
+## @name PROTOCOL_ERROR
+## @loc none
+2=Protocol error
+
+## @name TIMELIMIT_EXCEEDED
+## @loc none
+3=Timelimit exceeded
+
+## @name SIZELIMIT_EXCEEDED
+## @loc none
+4=Sizelimit exceeded
+
+## @name COMPARE_FALSE
+## @loc none
+5=Compare false
+
+## @name COMPARE_TRUE
+## @loc none
+6=Compare true
+
+## @name STRONG_AUTH_NOT_SUPPORTED
+## @loc none
+7=Authentication method not supported
+
+## @name STRONG_AUTH_REQUIRED
+## @loc none
+8=Strong authentication required
+
+## @name PARTIAL_RESULTS
+## @loc none
+9=Partial results and referral received
+
+## @name REFERRAL
+## @loc none
+10=Referral received
+
+## @name ADMINLIMIT_EXCEEDED
+## @loc none
+11=Administrative limit exceeded
+
+## @name UNAVAILABLE_CRITICAL_EXTENSION
+## @loc none
+12=Unavailable critical extension
+
+## @name CONFIDENTIALITY_REQUIRED
+## @loc none
+13=Confidentiality required
+
+## @name SASL_BIND_IN_PROGRESS
+## @loc none
+14=SASL bind in progress
+
+## @name NO_SUCH_ATTRIBUTE
+## @loc none
+16=No such attribute
+
+## @name UNDEFINED_TYPE
+## @loc none
+17=Undefined attribute type
+
+## @name INAPPROPRIATE MATCHING
+## @loc none
+18=Inappropriate matching
+
+## @name CONSTRAINT_VIOLATION
+## @loc none
+19=Constraint violation
+
+## @name TYPE_OR_VALUE_EXISTS
+## @loc none
+20=Type or value exists
+
+## @name INVALID_SYNTAX
+## @loc none
+21=Invalid syntax
+
+## @name NO_SUCH_OBJECT
+## @loc none
+32=No such object
+
+## @name ALIAS_PROBLEM
+## @loc none
+33=Alias problem
+
+## @name INVALID_DN_ SYNTAX
+## @loc none
+34=Invalid DN syntax
+
+## @name IS_LEAF
+## @loc none
+35=Object is a leaf
+
+## @name ALIAS_DEREF_PROBLEM
+## @loc none
+36=Alias dereferencing problem
+
+## @name INAPPROPRIATE_AUTH
+## @loc none
+48=Inappropriate authentication
+
+## @name INVALID_CREDENTIALS
+## @loc none
+49=Invalid credentials
+
+## @name INSUFFICIENT_ACCESS
+## @loc none
+50=Insufficient access
+
+## @name BUSY
+## @loc none
+51=The LDAP server is busy
+
+## @name UNAVAILABLE
+## @loc none
+52=LDAP server is unavailable
+
+## @name UNWILLING_TO_PERFORM
+## @loc none
+53=LDAP server is unwilling to perform
+
+## @name LOOP_DETECT
+## @loc none
+54=Loop detected
+
+## @name SORT_CONTROL_MISSING
+## @loc none
+60=Sort Control is missing
+
+## @name INDEX_RANGE_ERROR
+## @loc none
+61=Search results exceed the range specified by the offsets
+
+## @name NAMING_VIOLATION
+## @loc none
+64=Naming violation
+
+## @name OBJECT_CLASS_VIOLATION
+## @loc none
+65=Object class violation
+
+## @name NOT_ALLOWED_ON_NONLEAF
+## @loc none
+66=Operation not allowed on nonleaf
+
+## @name NOT_ALLOWED_ON_RDN
+## @loc none
+67=Operation not allowed on RDN
+
+## @name ALREADY_EXISTS
+## @loc none
+68=Already exists
+
+## @name NO_OBJECT_CLASS_MODS
+## @loc none
+69=Cannot modify object class
+
+## @name RESULTS_TOO_LARGE
+## @loc none
+70=Results too large
+
+## @name AFFECTS_MULTIPLE_DSAS
+## @loc none
+71=Affects multiple servers
+
+## @name OTHER
+## @loc none
+80=Unknown error
+
+## @name SERVER_DOWN
+## @loc none
+81=Can't contact the LDAP server
+
+## @name LOCAL_ERROR
+## @loc none
+82=Local error
+
+## @name ENCODING_ERROR
+## @loc none
+83=Encoding error
+
+## @name DECODING_ERROR
+## @loc none
+84=Decoding error
+
+## @name TIMEOUT
+## @loc none
+85=The LDAP server timed out
+
+## @name AUTH_UNKNOWN
+## @loc none
+86=Unknown authentication method
+
+## @name FILTER_ERROR
+## @loc none
+87=Invalid search filter
+
+## @name USER_CANCELLED
+## @loc none
+88=User cancelled operation
+
+## @name PARAM_ERROR
+## @loc none
+89=Bad parameter to an LDAP routine
+
+## @name NO_MEMORY
+## @loc none
+90=Out of memory
+
+## @name CONNECT_ERROR
+## @loc none
+91=Can't connect to the LDAP server
+
+## @name NOT_SUPPORTED
+## @loc none
+92=Not supported by this version of the LDAP protocol
+
+## @name CONTROL_NOT_FOUND
+## @loc none
+93=Requested LDAP control not found
+
+## @name NO_RESULTS_RETURNED
+## @loc none
+94=No results returned
+
+## @name MORE_RESULTS_TO_RETURN
+## @loc none
+95=More results to return
+
+## @name CLIENT_LOOP
+## @loc none
+96=Client detected loop
+
+## @name REFERRAL_LIMIT_EXCEEDED
+## @loc none
+97=Referral hop limit exceeded
diff --git a/comm/mail/locales/en-US/chrome/overrides/profileDowngrade.dtd b/comm/mail/locales/en-US/chrome/overrides/profileDowngrade.dtd
new file mode 100644
index 0000000000..c23dc62d13
--- /dev/null
+++ b/comm/mail/locales/en-US/chrome/overrides/profileDowngrade.dtd
@@ -0,0 +1,19 @@
+<!-- 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/. -->
+
+<!-- LOCALIZATION NOTE:
+ This UI can be most easily shown by modifying the version in compatibility.ini
+ to a newer version and then starting Thunderbird.
+ For this feature, "installation" is used to mean "this discrete download of
+ Thunderbird" and "version" is used to mean "the specific revision number of a
+ given Thunderbird channel". These terms are not synonymous.
+-->
+<!ENTITY window.title "You have launched an older version of &brandProductName;">
+
+<!ENTITY window.nosync2 "A newer version of &brandProductName; may have made changes to your profile which are no longer compatible with this older version. Use this profile only with that newer version, or create a new profile for this installation of &brandShortName;. Creating a new profile requires setting up your accounts, calendars and add-ons again.">
+
+<!ENTITY window.moreinfo "More information…">
+<!ENTITY window.create "Create New Profile">
+<!ENTITY window.quit-win "Exit">
+<!ENTITY window.quit-nonwin "Quit">
diff --git a/comm/mail/locales/en-US/crashreporter/crashreporter-override.ini b/comm/mail/locales/en-US/crashreporter/crashreporter-override.ini
new file mode 100644
index 0000000000..c54574d69b
--- /dev/null
+++ b/comm/mail/locales/en-US/crashreporter/crashreporter-override.ini
@@ -0,0 +1,9 @@
+; 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 in the UTF-8 encoding
+[Strings]
+# LOCALIZATION NOTE (CrashReporterProductErrorText2): The %s is replaced with a string containing detailed information.
+CrashReporterProductErrorText2=Thunderbird had a problem and crashed. We’ll try to restore your tabs and windows when it restarts.\n\nUnfortunately the crash reporter is unable to submit a crash report.\n\nDetails: %s
+CrashReporterDescriptionText2=Thunderbird had a problem and crashed. We’ll try to restore your tabs and windows when it restarts.\n\nTo help us diagnose and fix the problem, you can send us a crash report.
diff --git a/comm/mail/locales/en-US/installer/custom.properties b/comm/mail/locales/en-US/installer/custom.properties
new file mode 100755
index 0000000000..56d9f8ff65
--- /dev/null
+++ b/comm/mail/locales/en-US/installer/custom.properties
@@ -0,0 +1,85 @@
+# 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/.
+
+# LOCALIZATION NOTE:
+
+# This file must be saved as UTF8
+
+# Accesskeys are defined by prefixing the letter that is to be used for the
+# accesskey with an ampersand (e.g. &).
+
+# Do not replace $BrandShortName, $BrandFullName, or $BrandFullNameDA with a
+# custom string and always use the same one as used by the en-US files.
+# $BrandFullNameDA allows the string to contain an ampersand (e.g. DA stands
+# for double ampersand) and prevents the letter following the ampersand from
+# being used as an accesskey.
+
+# You can use \n to create a newline in the string but only when the string
+# from en-US contains a \n.
+
+REG_APP_DESC=$BrandShortName is a full-featured email application. $BrandShortName supports IMAP and POP mail protocols, as well as HTML mail formatting. Built-in junk mail controls, RSS capabilities, powerful quick search, spell check as you type, global inbox, and advanced message filtering round out $BrandShortName's modern feature set.
+CONTEXT_OPTIONS=$BrandShortName &Options
+CONTEXT_SAFE_MODE=$BrandShortName &Safe Mode
+OPTIONS_PAGE_TITLE=Setup Type
+OPTIONS_PAGE_SUBTITLE=Choose setup options
+SHORTCUTS_PAGE_TITLE=Set Up Shortcuts
+SHORTCUTS_PAGE_SUBTITLE=Create Program Icons
+COMPONENTS_PAGE_TITLE=Set Up Optional Components
+COMPONENTS_PAGE_SUBTITLE=Optional Recommended Components
+OPTIONAL_COMPONENTS_DESC=The Maintenance Service will allow you to update $BrandShortName silently in the background.
+MAINTENANCE_SERVICE_CHECKBOX_DESC=Install &Maintenance Service
+SUMMARY_PAGE_TITLE=Summary
+SUMMARY_PAGE_SUBTITLE=Ready to start installing $BrandShortName
+SUMMARY_INSTALLED_TO=$BrandShortName will be installed to the following location:
+SUMMARY_REBOOT_REQUIRED_INSTALL=A restart of your computer may be required to complete the installation.
+SUMMARY_REBOOT_REQUIRED_UNINSTALL=A restart of your computer may be required to complete the uninstall.
+SUMMARY_TAKE_DEFAULTS=U&se $BrandShortName as my default mail application
+SUMMARY_INSTALL_CLICK=Click Install to continue.
+SUMMARY_UPGRADE_CLICK=Click Upgrade to continue.
+SURVEY_TEXT=&Tell us what you thought of $BrandShortName
+LAUNCH_TEXT=&Launch $BrandFullName now
+CREATE_ICONS_DESC=Create icons for $BrandShortName:
+ICONS_DESKTOP=On my &desktop
+ICONS_STARTMENU=In my &Start menu Programs folder
+ICONS_TASKBAR=On my &taskbar
+WARN_MANUALLY_CLOSE_APP_INSTALL=$BrandShortName must be closed to proceed with the installation.\n\nPlease close $BrandShortName to continue.
+WARN_MANUALLY_CLOSE_APP_UNINSTALL=$BrandShortName must be closed to proceed with the uninstall.\n\nPlease close $BrandShortName to continue.
+WARN_MANUALLY_CLOSE_APP_LAUNCH=$BrandShortName is already running.\n\nPlease close $BrandShortName prior to launching the version you have just installed.
+WARN_WRITE_ACCESS=You don't have access to write to the installation directory.\n\nClick OK to select a different directory.
+WARN_DISK_SPACE=You don't have sufficient disk space to install to this location.\n\nClick OK to select a different location.
+WARN_MIN_SUPPORTED_OSVER_MSG=Sorry, $BrandShortName can't be installed. This version of $BrandShortName requires ${MinSupportedVer} or newer. Please click the OK button for additional information.
+WARN_MIN_SUPPORTED_CPU_MSG=Sorry, $BrandShortName can't be installed. This version of $BrandShortName requires a processor with ${MinSupportedCPU} support. Please click the OK button for additional information.
+WARN_MIN_SUPPORTED_OSVER_CPU_MSG=Sorry, $BrandShortName can't be installed. This version of $BrandShortName requires ${MinSupportedVer} or newer and a processor with ${MinSupportedCPU} support. Please click the OK button for additional information.
+WARN_RESTART_REQUIRED_UNINSTALL=Your computer must be restarted to complete a previous uninstall of $BrandShortName. Do you want to reboot now?
+WARN_RESTART_REQUIRED_UPGRADE=Your computer must be restarted to complete a previous upgrade of $BrandShortName. Do you want to reboot now?
+ERROR_CREATE_DIRECTORY_PREFIX=Error creating directory:
+ERROR_CREATE_DIRECTORY_SUFFIX=Click Cancel to stop the installation or\nRetry to try again.
+
+UN_CONFIRM_PAGE_TITLE=Uninstall $BrandFullName
+UN_CONFIRM_PAGE_SUBTITLE=Remove $BrandFullName from your computer.
+UN_CONFIRM_UNINSTALLED_FROM=$BrandShortName will be uninstalled from the following location:
+UN_CONFIRM_CLICK=Click Uninstall to continue.
+
+BANNER_CHECK_EXISTING=Checking existing installation…
+
+STATUS_INSTALL_APP=Installing $BrandShortName…
+STATUS_INSTALL_LANG=Installing Language Files (${AB_CD})…
+STATUS_UNINSTALL_MAIN=Uninstalling $BrandShortName…
+STATUS_CLEANUP=Cleaning up the birdcage…
+
+# _DESC strings support approximately 65 characters per line.
+# One line
+OPTIONS_SUMMARY=Choose the type of setup you prefer, then click Next.
+# One line
+OPTION_STANDARD_DESC=$BrandShortName will be installed with the most common options.
+OPTION_STANDARD_RADIO=&Standard
+# Two lines
+OPTION_CUSTOM_DESC=You may choose individual options to be installed. Recommended for experienced users.
+OPTION_CUSTOM_RADIO=&Custom
+
+# LOCALIZATION NOTE:
+# The following text replaces the Install button text on the summary page.
+# Verify that the access key for InstallBtn (in override.properties) and
+# UPGRADE_BUTTON is not already used by SUMMARY_TAKE_DEFAULTS.
+UPGRADE_BUTTON=&Upgrade
diff --git a/comm/mail/locales/en-US/installer/mui.properties b/comm/mail/locales/en-US/installer/mui.properties
new file mode 100755
index 0000000000..8620b8ae9d
--- /dev/null
+++ b/comm/mail/locales/en-US/installer/mui.properties
@@ -0,0 +1,60 @@
+# 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/.
+
+# To make the l10n tinderboxen see changes to this file you can change a value
+# name by adding - to the end of the name followed by chars (e.g. Branding-2).
+
+# LOCALIZATION NOTE:
+
+# This file must be saved as UTF8
+
+# Accesskeys are defined by prefixing the letter that is to be used for the
+# accesskey with an ampersand (e.g. &).
+
+# Do not replace $BrandShortName, $BrandFullName, or $BrandFullNameDA with a
+# custom string and always use the same one as used by the en-US files.
+# $BrandFullNameDA allows the string to contain an ampersand (e.g. DA stands
+# for double ampersand) and prevents the letter following the ampersand from
+# being used as an accesskey.
+
+# You can use \n to create a newline in the string but only when the string
+# from en-US contains a \n.
+MUI_TEXT_WELCOME_INFO_TITLE=Welcome to the $BrandFullNameDA Setup Wizard
+MUI_TEXT_WELCOME_INFO_TEXT=This wizard will guide you through the installation of $BrandFullNameDA.\n\nIt is recommended that you close all other applications before starting Setup. This will make it possible to update relevant system files without having to reboot your computer.\n\n$_CLICK
+MUI_TEXT_COMPONENTS_TITLE=Choose Components
+MUI_TEXT_COMPONENTS_SUBTITLE=Choose which features of $BrandFullNameDA you want to install.
+MUI_INNERTEXT_COMPONENTS_DESCRIPTION_TITLE=Description
+MUI_INNERTEXT_COMPONENTS_DESCRIPTION_INFO=Position your mouse over a component to see its description.
+MUI_TEXT_DIRECTORY_TITLE=Choose Install Location
+MUI_TEXT_DIRECTORY_SUBTITLE=Choose the folder in which to install $BrandFullNameDA.
+MUI_TEXT_INSTALLING_TITLE=Installing
+MUI_TEXT_INSTALLING_SUBTITLE=Please wait while $BrandFullNameDA is being installed.
+MUI_TEXT_FINISH_TITLE=Installation Complete
+MUI_TEXT_FINISH_SUBTITLE=Setup was completed successfully.
+MUI_TEXT_ABORT_TITLE=Installation Aborted
+MUI_TEXT_ABORT_SUBTITLE=Setup was not completed successfully.
+MUI_BUTTONTEXT_FINISH=&Finish
+MUI_TEXT_FINISH_INFO_TITLE=Completing the $BrandFullNameDA Setup Wizard
+MUI_TEXT_FINISH_INFO_TEXT=$BrandFullNameDA has been installed on your computer.\n\nClick Finish to close this wizard.
+MUI_TEXT_FINISH_INFO_REBOOT=Your computer must be restarted in order to complete the installation of $BrandFullNameDA. Do you want to reboot now?
+MUI_TEXT_FINISH_REBOOTNOW=Reboot now
+MUI_TEXT_FINISH_REBOOTLATER=I want to manually reboot later
+MUI_TEXT_STARTMENU_TITLE=Choose Start Menu Folder
+MUI_TEXT_STARTMENU_SUBTITLE=Choose a Start Menu folder for the $BrandFullNameDA shortcuts.
+MUI_INNERTEXT_STARTMENU_TOP=Select the Start Menu folder in which you would like to create the program's shortcuts. You can also enter a name to create a new folder.
+MUI_TEXT_ABORTWARNING=Are you sure you want to quit $BrandFullName Setup?
+MUI_UNTEXT_WELCOME_INFO_TITLE=Welcome to the $BrandFullNameDA Uninstall Wizard
+MUI_UNTEXT_WELCOME_INFO_TEXT=This wizard will guide you through the uninstallation of $BrandFullNameDA.\n\nBefore starting the uninstallation, make sure $BrandFullNameDA is not running.\n\n$_CLICK
+MUI_UNTEXT_CONFIRM_TITLE=Uninstall $BrandFullNameDA
+MUI_UNTEXT_CONFIRM_SUBTITLE=Remove $BrandFullNameDA from your computer.
+MUI_UNTEXT_UNINSTALLING_TITLE=Uninstalling
+MUI_UNTEXT_UNINSTALLING_SUBTITLE=Please wait while $BrandFullNameDA is being uninstalled.
+MUI_UNTEXT_FINISH_TITLE=Uninstallation Complete
+MUI_UNTEXT_FINISH_SUBTITLE=Uninstall was completed successfully.
+MUI_UNTEXT_ABORT_TITLE=Uninstallation Aborted
+MUI_UNTEXT_ABORT_SUBTITLE=Uninstall was not completed successfully.
+MUI_UNTEXT_FINISH_INFO_TITLE=Completing the $BrandFullNameDA Uninstall Wizard
+MUI_UNTEXT_FINISH_INFO_TEXT=$BrandFullNameDA has been uninstalled from your computer.\n\nClick Finish to close this wizard.
+MUI_UNTEXT_FINISH_INFO_REBOOT=Your computer must be restarted in order to complete the uninstallation of $BrandFullNameDA. Do you want to reboot now?
+MUI_UNTEXT_ABORTWARNING=Are you sure you want to quit $BrandFullName Uninstall?
diff --git a/comm/mail/locales/en-US/installer/override.properties b/comm/mail/locales/en-US/installer/override.properties
new file mode 100755
index 0000000000..bb8cac7387
--- /dev/null
+++ b/comm/mail/locales/en-US/installer/override.properties
@@ -0,0 +1,86 @@
+# 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/.
+
+# LOCALIZATION NOTE:
+
+# This file must be saved as UTF8
+
+# Accesskeys are defined by prefixing the letter that is to be used for the
+# accesskey with an ampersand (e.g. &).
+
+# Do not replace $BrandShortName, $BrandFullName, or $BrandFullNameDA with a
+# custom string and always use the same one as used by the en-US files.
+# $BrandFullNameDA allows the string to contain an ampersand (e.g. DA stands
+# for double ampersand) and prevents the letter following the ampersand from
+# being used as an accesskey.
+
+# You can use \n to create a newline in the string but only when the string
+# from en-US contains a \n.
+
+# Strings that require a space at the end should be enclosed with double
+# quotes and the double quotes will be removed. To add quotes to the beginning
+# and end of a string enclose the strin with an additional double quote
+# (e.g. ""This will include quotes"").
+
+SetupCaption=$BrandFullName Setup
+UninstallCaption=$BrandFullName Uninstall
+BackBtn=< &Back
+NextBtn=&Next >
+AcceptBtn=I &accept the terms in the License Agreement
+DontAcceptBtn=I &do not accept the terms in the License Agreement
+InstallBtn=&Install
+UninstallBtn=&Uninstall
+CancelBtn=Cancel
+CloseBtn=&Close
+BrowseBtn=B&rowse…
+ShowDetailsBtn=Show &details
+ClickNext=Click Next to continue.
+ClickInstall=Click Install to start the installation.
+ClickUninstall=Click Uninstall to start the uninstallation.
+Completed=Completed
+LicenseTextRB=Please review the license agreement before installing $BrandFullNameDA. If you accept all terms of the agreement, select the first option below. $_CLICK
+ComponentsText=Check the components you want to install and uncheck the components you don't want to install. $_CLICK
+ComponentsSubText2_NoInstTypes=Select components to install:
+DirText=Setup will install $BrandFullNameDA in the following folder. To install in a different folder, click Browse and select another folder. $_CLICK
+DirSubText=Destination Folder
+DirBrowseText=Select the folder to install $BrandFullNameDA in:
+SpaceAvailable="Space available: "
+SpaceRequired="Space required: "
+UninstallingText=$BrandFullNameDA will be uninstalled from the following folder. $_CLICK
+UninstallingSubText=Uninstalling from:
+FileError=Error opening file for writing: \r\n\r\n$0\r\n\r\nClick Abort to stop the installation,\r\nRetry to try again, or\r\nIgnore to skip this file.
+FileError_NoIgnore=Error opening file for writing: \r\n\r\n$0\r\n\r\nClick Retry to try again, or\r\nCancel to stop the installation.
+CantWrite="Can't write: "
+CopyFailed=Copy failed
+CopyTo="Copy to "
+Registering="Registering: "
+Unregistering="Unregistering: "
+SymbolNotFound="Could not find symbol: "
+CouldNotLoad="Could not load: "
+CreateFolder="Create folder: "
+CreateShortcut="Create shortcut: "
+CreatedUninstaller="Created uninstaller: "
+Delete="Delete file: "
+DeleteOnReboot="Delete on reboot: "
+ErrorCreatingShortcut="Error creating shortcut: "
+ErrorCreating="Error creating: "
+ErrorDecompressing=Error decompressing data! Corrupted installer?
+ErrorRegistering=Error registering DLL
+ExecShell="ExecShell: "
+Exec="Execute: "
+Extract="Extract: "
+ErrorWriting="Extract: error writing to file "
+InvalidOpcode=Installer corrupted: invalid opcode
+NoOLE="No OLE for: "
+OutputFolder="Output folder: "
+RemoveFolder="Remove folder: "
+RenameOnReboot="Rename on reboot: "
+Rename="Rename: "
+Skipped="Skipped: "
+CopyDetails=Copy Details To Clipboard
+LogInstall=Log install process
+Byte=B
+Kilo=K
+Mega=M
+Giga=G
diff --git a/comm/mail/locales/en-US/langpack-metadata.ftl b/comm/mail/locales/en-US/langpack-metadata.ftl
new file mode 100644
index 0000000000..86dbc9c490
--- /dev/null
+++ b/comm/mail/locales/en-US/langpack-metadata.ftl
@@ -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/.
+
+## Strings used to define the metadata of langpacks published on addons.mozilla.org.
+## Only text elements and literals are supported for these strings.
+
+langpack-creator = mozilla.org
+
+# To credit multiple contributors, use a comma-delimited list.
+# Example: Joe Solon, Suzy Solon
+langpack-contributors = { "" }
diff --git a/comm/mail/locales/en-US/messenger/about3Pane.ftl b/comm/mail/locales/en-US/messenger/about3Pane.ftl
new file mode 100644
index 0000000000..267570aac9
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/about3Pane.ftl
@@ -0,0 +1,482 @@
+# 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/.
+
+## Message List Header Bar
+
+quick-filter-button =
+ .title = Toggle the Quick Filter Bar
+quick-filter-button-label = Quick Filter
+
+thread-pane-header-display-button =
+ .title = Message list display options
+
+# Variables:
+# $count (Number) - The number of messages in this folder.
+thread-pane-folder-message-count =
+ { $count ->
+ [one] { $count } Message
+ *[other] { $count } Messages
+ }
+
+# Variables:
+# $count (Number) - The number of messages currently selected.
+thread-pane-folder-selected-count =
+ { $count ->
+ *[other] { $count } Selected
+ }
+
+thread-pane-header-context-table-view =
+ .label = Table View
+
+thread-pane-header-context-cards-view =
+ .label = Cards View
+
+thread-pane-header-context-hide =
+ .label = Hide Message List Header
+
+## Quick Filter Bar
+
+# The tooltip to display when the user hovers over the sticky button
+# (currently displayed as a push-pin). When active, the sticky button
+# causes the current filter settings to be retained when the user changes
+# folders or opens new tabs. (When inactive, only the state of the text
+# filters are propagated between folder changes and when opening new tabs.)
+quick-filter-bar-sticky =
+ .title = Keep filters applied when switching folders
+
+# The tooltip for the filter button that replaces the quick filter buttons with
+# a dropdown menu.
+quick-filter-bar-dropdown =
+ .title = Quick filter menu
+
+quick-filter-bar-dropdown-unread =
+ .label = Unread
+
+quick-filter-bar-dropdown-starred =
+ .label = Starred
+
+quick-filter-bar-dropdown-inaddrbook =
+ .label = Contact
+
+quick-filter-bar-dropdown-tags =
+ .label = Tags
+
+quick-filter-bar-dropdown-attachment =
+ .label = Attachment
+
+# The tooltip for the filter button that causes us to filter results to only
+# include unread messages.
+quick-filter-bar-unread =
+ .title = Show only unread messages
+# The label for the filter button that causes us to filter results to only
+# include unread messages.
+quick-filter-bar-unread-label = Unread
+
+# The tooltip for the filter button that causes us to filter results to only
+# include messages that have been starred/flagged.
+quick-filter-bar-starred =
+ .title = Show only starred messages
+# The label for the filter button that causes us to filter results to only
+# include messages that have been starred/flagged.
+quick-filter-bar-starred-label = Starred
+
+# The tooltip for the filter button that causes us to filter results to only
+# include messages from contacts in one of the user's non-remote address
+# books.
+quick-filter-bar-inaddrbook =
+ .title = Show only messages from people in your address book
+# The label for the filter button that causes us to filter results to only
+# include messages from contacts in one of the user's non-remote address
+# books.
+quick-filter-bar-inaddrbook-label = Contact
+
+# The tooltip for the filter button that causes us to filter results to only
+# include messages with at least one tag on them.
+quick-filter-bar-tags =
+ .title = Show only messages with tags on them
+# The label for the filter button that causes us to filter results to only
+# include messages with at least one tag on them.
+quick-filter-bar-tags-label = Tags
+
+# The tooltip for the filter button that causes us to filter results to only
+# include messages with attachments.
+quick-filter-bar-attachment =
+ .title = Show only messages with attachments
+# The label for the filter button that causes us to filter results to only
+# include messages with attachments.
+quick-filter-bar-attachment-label = Attachment
+
+# The contents of the results box when there is a filter active but there
+# are no messages matching the filter.
+quick-filter-bar-no-results = No results
+
+# This is used to populate the results box; it either displays the
+# number of messages found using this string, that there are no messages
+# (using quick-filter-bar-no-results), or the box is hidden.
+# Variables:
+# $count (Number) - The number of messages that match selected filters.
+quick-filter-bar-results =
+ { $count ->
+ [one] { $count } message
+ *[other] { $count } messages
+ }
+
+# Keyboard shortcut for the text search box.
+# This should match quick-filter-bar-show in messenger.ftl.
+quick-filter-bar-textbox-shortcut =
+ { PLATFORM() ->
+ [macos] ⇧ ⌘ K
+ *[other] Ctrl+Shift+K
+ }
+
+# This is the empty text for the text search box.
+# The goal is to convey to the user that typing in the box will filter
+# the messages and that there is a hotkey they can press to get to the
+# box faster.
+quick-filter-bar-textbox =
+ .placeholder = Filter these messages <{ quick-filter-bar-textbox-shortcut }>
+
+# Tooltip of the Any-of/All-of tagging mode selector.
+quick-filter-bar-boolean-mode =
+ .title = Tag filtering mode
+# The Any-of tagging mode.
+quick-filter-bar-boolean-mode-any =
+ .label = Any of
+ .title = At least one of the selected tag criteria should match
+# The All-of tagging mode.
+quick-filter-bar-boolean-mode-all =
+ .label = All of
+ .title = All of the selected tag criteria must match
+
+# This label explains what the sender/recipients/subject/body buttons do.
+# This string should ideally be kept short because the label and the text
+# filter buttons share their bar (that appears when there is text in the text
+# filter box) with the list of tags when the tag filter is active, and the
+# tag sub-bar wants as much space as possible. (Overflow is handled by an
+# arrow scroll box.)
+quick-filter-bar-text-filter-explanation = Filter messages by:
+# The button label that toggles whether the text filter searches the message
+# sender for the string.
+quick-filter-bar-text-filter-sender = Sender
+# The button label that toggles whether the text filter searches the message
+# recipients (to, cc) for the string.
+quick-filter-bar-text-filter-recipients = Recipients
+# The button label that toggles whether the text filter searches the message
+# subject for the string.
+quick-filter-bar-text-filter-subject = Subject
+# The button label that toggles whether the text filter searches the message
+# body for the string.
+quick-filter-bar-text-filter-body = Body
+
+# The first line of the panel popup that tells the user we found no matches
+# but we can convert to a global search for them.
+quick-filter-bar-gloda-upsell-line1 = Continue this search across all folders
+# The second line of the panel popup that tells the user we found no matches.
+# Variables:
+# $text (String) - What the user has typed so far.
+quick-filter-bar-gloda-upsell-line2 = Press ‘Enter’ again to continue your search for: { $text }
+
+## Folder pane
+
+folder-pane-get-messages-button =
+ .title = Get Messages
+
+folder-pane-get-all-messages-menuitem =
+ .label = Get All New Messages
+ .accesskey = G
+
+folder-pane-write-message-button = New Message
+ .title = Compose a new message
+
+folder-pane-more-menu-button =
+ .title = Folder pane options
+
+# Context menu item to show/hide different folder types in the folder pane
+folder-pane-header-folder-modes =
+ .label = Folder Modes
+
+# Context menu item to toggle display of "Get messages" button in folder pane header
+folder-pane-header-context-toggle-get-messages =
+ .label = Show “Get Messagesâ€
+
+# Context menu item to toggle display of "New Message" button in folder pane header
+folder-pane-header-context-toggle-new-message =
+ .label = Show “New Messageâ€
+
+folder-pane-header-context-hide =
+ .label = Hide Folder Pane Header
+
+folder-pane-show-total-toggle =
+ .label = Show Total Message Count
+
+# Context menu item to show or hide folder sizes
+folder-pane-header-toggle-folder-size =
+ .label = Show Folder Size
+
+folder-pane-header-hide-local-folders =
+ .label = Hide Local Folders
+
+folder-pane-mode-context-button =
+ .title = Folder mode options
+
+folder-pane-mode-context-toggle-compact-mode =
+ .label = Compact View
+ .accesskey = C
+
+folder-pane-mode-move-up =
+ .label = Move Up
+
+folder-pane-mode-move-down =
+ .label = Move Down
+
+# Variables:
+# $count (Number) - Number of unread messages.
+folder-pane-unread-aria-label =
+ { $count ->
+ [one] 1 unread message
+ *[other] { $count } unread messages
+ }
+
+# Variables:
+# $count (Number) - Number of total messages.
+folder-pane-total-aria-label =
+ { $count ->
+ [one] 1 total message
+ *[other] { $count } total messages
+ }
+
+## Message thread pane
+
+threadpane-column-header-select =
+ .title = Toggle select all messages
+threadpane-column-header-select-all =
+ .title = Select all messages
+threadpane-column-header-deselect-all =
+ .title = Deselect all messages
+threadpane-column-label-select =
+ .label = Select Messages
+threadpane-cell-select =
+ .aria-label = Select message
+
+threadpane-column-header-thread =
+ .title = Toggle message threads
+threadpane-column-label-thread =
+ .label = Thread
+threadpane-cell-thread =
+ .aria-label = Thread status
+
+threadpane-column-header-flagged =
+ .title = Sort by star
+threadpane-column-label-flagged =
+ .label = Starred
+threadpane-cell-flagged =
+ .aria-label = Starred
+
+threadpane-flagged-cell-label = Starred
+
+threadpane-column-header-attachments =
+ .title = Sort by attachments
+threadpane-column-label-attachments =
+ .label = Attachments
+threadpane-cell-attachments =
+ .aria-label = Attachments
+
+threadpane-attachments-cell-label = Attachments
+
+threadpane-column-header-spam =
+ .title = Sort by spam status
+threadpane-column-label-spam =
+ .label = Spam
+threadpane-cell-spam =
+ .aria-label = Spam status
+
+threadpane-spam-cell-label = Spam
+
+threadpane-column-header-unread-button =
+ .title = Sort by read status
+threadpane-column-label-unread-button =
+ .label = Read status
+
+threadpane-cell-read-status =
+ .aria-label = Read status
+
+threadpane-read-cell-label = Read
+threadpane-unread-cell-label = Unread
+
+threadpane-column-header-sender = From
+ .title = Sort by from
+threadpane-column-label-sender =
+ .label = From
+threadpane-cell-sender =
+ .aria-label = From
+
+threadpane-column-header-recipient = Recipient
+ .title = Sort by recipient
+threadpane-column-label-recipient =
+ .label = Recipient
+threadpane-cell-recipient =
+ .aria-label = Recipient
+
+threadpane-column-header-correspondents = Correspondents
+ .title = Sort by correspondents
+threadpane-column-label-correspondents =
+ .label = Correspondents
+threadpane-cell-correspondents =
+ .aria-label = Correspondents
+
+threadpane-column-header-subject = Subject
+ .title = Sort by subject
+threadpane-column-label-subject =
+ .label = Subject
+threadpane-cell-subject =
+ .aria-label = Subject
+
+threadpane-column-header-date = Date
+ .title = Sort by date
+threadpane-column-label-date =
+ .label = Date
+threadpane-cell-date =
+ .aria-label = Date
+
+threadpane-column-header-received = Received
+ .title = Sort by date received
+threadpane-column-label-received =
+ .label = Received
+threadpane-cell-received =
+ .aria-label = Date received
+
+threadpane-column-header-status = Status
+ .title = Sort by status
+threadpane-column-label-status =
+ .label = Status
+threadpane-cell-status =
+ .aria-label = Status
+
+threadpane-column-header-size = Size
+ .title = Sort by size
+threadpane-column-label-size =
+ .label = Size
+threadpane-cell-size =
+ .aria-label = Size
+
+threadpane-column-header-tags = Tags
+ .title = Sort by tags
+threadpane-column-label-tags =
+ .label = Tags
+threadpane-cell-tags =
+ .aria-label = Tags
+
+threadpane-column-header-account = Account
+ .title = Sort by account
+threadpane-column-label-account =
+ .label = Account
+threadpane-cell-account =
+ .aria-label = Account
+
+threadpane-column-header-priority = Priority
+ .title = Sort by priority
+threadpane-column-label-priority =
+ .label = Priority
+threadpane-cell-priority =
+ .aria-label = Priority
+
+threadpane-column-header-unread = Unread
+ .title = Number of unread messages in thread
+threadpane-column-label-unread =
+ .label = Unread
+threadpane-cell-unread =
+ .aria-label = Number of unread messages
+
+threadpane-column-header-total = Total
+ .title = Total number of messages in thread
+threadpane-column-label-total =
+ .label = Total
+threadpane-cell-total =
+ .aria-label = Total number of messages
+
+threadpane-column-header-location = Location
+ .title = Sort by location
+threadpane-column-label-location =
+ .label = Location
+threadpane-cell-location =
+ .aria-label = Location
+
+threadpane-column-header-id = Order Received
+ .title = Sort by order received
+threadpane-column-label-id =
+ .label = Order Received
+threadpane-cell-id =
+ .aria-label = Order received
+
+threadpane-column-header-delete =
+ .title = Delete a message
+threadpane-column-label-delete =
+ .label = Delete
+threadpane-cell-delete =
+ .aria-label = Delete
+
+## Message state variations
+
+threadpane-message-new =
+ .alt = New message indicator
+ .title = New message
+
+threadpane-message-replied =
+ .alt = Replied indicator
+ .title = Message replied
+
+threadpane-message-redirected =
+ .alt = Redirected indicator
+ .title = Message redirected
+
+threadpane-message-forwarded =
+ .alt = Forwarded indicator
+ .title = Message forwarded
+
+threadpane-message-replied-forwarded =
+ .alt = Replied and forwarded indicator
+ .title = Message replied and forwarded
+
+threadpane-message-replied-redirected =
+ .alt = Replied and redirected indicator
+ .title = Message replied and redirected
+
+threadpane-message-forwarded-redirected =
+ .alt = Forwarded and redirected indicator
+ .title = Message forwarded and redirected
+
+threadpane-message-replied-forwarded-redirected =
+ .alt = Replied, forwarded, and redirected indicator
+ .title = Message replied, forwarded, and redirected
+
+apply-columns-to-menu =
+ .label = Apply columns to…
+
+apply-current-view-to-menu =
+ .label = Apply current view to…
+
+apply-current-view-to-folder =
+ .label = Folder…
+
+apply-current-view-to-folder-children =
+ .label = Folder and its children…
+
+## Apply columns confirmation dialog
+
+apply-changes-to-folder-title = Apply Changes?
+
+# Variables:
+# $name (String): The name of the folder to apply to.
+apply-current-columns-to-folder-message = Apply the current folder’s columns to { $name }?
+
+# Variables:
+# $name (String): The name of the folder to apply to.
+apply-current-columns-to-folder-with-children-message = Apply the current folder’s columns to { $name } and its children?
+
+# Variables:
+# $name (String): The name of the folder to apply to.
+apply-current-view-to-folder-message = Apply the current folder’s view to { $name }?
+# Variables:
+# $name (String): The name of the folder to apply to.
+apply-current-view-to-folder-with-children-message = Apply the current folder’s view to { $name } and its children?
diff --git a/comm/mail/locales/en-US/messenger/aboutAddonsExtra.ftl b/comm/mail/locales/en-US/messenger/aboutAddonsExtra.ftl
new file mode 100644
index 0000000000..991a75695c
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/aboutAddonsExtra.ftl
@@ -0,0 +1,11 @@
+# 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-on-options-button =
+ .title = Add-on Options
+
+add-on-search-alternative-button-label = Find an alternative add-on
+
+atn-addons-heading-search-input =
+ .placeholder = Search addons.thunderbird.net
diff --git a/comm/mail/locales/en-US/messenger/aboutDialog.ftl b/comm/mail/locales/en-US/messenger/aboutDialog.ftl
new file mode 100644
index 0000000000..e14777deee
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/aboutDialog.ftl
@@ -0,0 +1,74 @@
+# 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/.
+
+about-update-whats-new = What’s New
+
+about-dialog-title = About { -brand-full-name }
+
+release-notes-link = Release notes
+
+update-internal-error = Unable to check for updates due to internal error. Updates available at <a data-l10n-name="manual-link"/>
+
+update-check-for-updates-button = Check for Updates
+ .accesskey = C
+
+update-update-button = Restart to update { -brand-shorter-name }
+ .accesskey = R
+
+update-checking-for-updates = Checking for updates…
+
+update-downloading-message = Downloading update — <span data-l10n-name="download-status"></span>
+
+update-applying = Applying update…
+
+update-downloading = <img data-l10n-name="icon"/>Downloading update — <span data-l10n-name="download-status"></span>
+
+update-failed = Update failed. <a data-l10n-name="failed-link">Download the latest version</a>
+
+update-admin-disabled = Updates disabled by your system administrator
+
+update-no-updates-found = { -brand-short-name } is up to date
+
+update-other-instance-handling-updates = { -brand-short-name } is being updated by another instance
+
+update-manual = Updates available at <a data-l10n-name="manual-link"/>
+
+update-unsupported = You can not perform further updates on this system. <a data-l10n-name="unsupported-link">Learn more</a>
+
+update-restarting = Restarting…
+
+# Variables:
+# $channel (String): description of the update channel (e.g. "release", "beta", "nightly" etc.)
+channel-description = You are currently on the <span data-l10n-name="current-channel">{ $channel }</span> update channel.
+
+warning-desc-version = { -brand-short-name } is experimental and may be unstable.
+
+warning-desc-telemetry = It automatically sends information about performance, hardware, usage and customizations back to { -vendor-short-name } to help make { -brand-short-name } better.
+
+# Example of resulting string: 66.0.1 (64-bit)
+# Variables:
+# $version (String): version of Thunderbird, e.g. 66.0.1
+# $bits (Number): bits of the architecture (32 or 64)
+aboutDialog-version = { $version } ({ $bits }-bit)
+
+# Example of resulting string: 66.0a1 (2019-01-16) (64-bit)
+# Variables:
+# $version (String): version of Thunderbird for Daily builds, e.g. 66.0a1
+# $isodate (String): date in ISO format, e.g. 2019-01-16
+# $bits (Number): bits of the architecture (32 or 64)
+aboutDialog-version-nightly = { $version } ({ $isodate }) ({ $bits }-bit)
+
+aboutdialog-update-checking-failed = Failed to check for updates.
+
+community-experimental = <a data-l10n-name="community-exp-mozilla-link">{ -vendor-short-name }</a> is a <a data-l10n-name="community-exp-credits-link">global community</a> working together to keep the Web open, public and accessible to all.
+
+community-desc = { -brand-short-name } is designed by <a data-l10n-name="community-mozilla-link">{ -vendor-short-name }</a>, a <a data-l10n-name="community-credits-link">global community</a> working together to keep the Web open, public and accessible to all.
+
+about-donation = Want to help? <a data-l10n-name="helpus-donate-link">Make a donation</a> or <a data-l10n-name="helpus-get-involved-link">get involved!</a>
+
+bottom-links-license = Licensing Information
+bottom-links-rights = End-User Rights
+bottom-links-privacy = Privacy Policy
+cmd-close-mac-command-key =
+ .key = w
diff --git a/comm/mail/locales/en-US/messenger/aboutImport.ftl b/comm/mail/locales/en-US/messenger/aboutImport.ftl
new file mode 100644
index 0000000000..3cf62f0c7b
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/aboutImport.ftl
@@ -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/.
+
+import-page-title = Import
+
+export-page-title = Export
+
+## Header
+
+import-start = Import Tool
+
+import-start-title = Import settings or data from an application or a file.
+
+import-start-description = Select the source from which you want to import. You will later be asked to choose which data needs to be imported.
+
+import-from-app = Import from Application
+
+import-file = Import from a file
+
+import-file-title = Select a file to import its content.
+
+import-file-description = Choose to import a previously backed up profile, address books or calendars.
+
+import-address-book-title = Import Address Book file
+
+import-calendar-title = Import Calendar file
+
+export-profile = Export
+
+## Buttons
+
+button-back = Back
+
+button-continue = Continue
+
+button-export = Export
+
+button-finish = Finish
+
+## Import from app steps
+
+app-name-thunderbird = Thunderbird
+
+app-name-seamonkey = SeaMonkey
+
+app-name-outlook = Outlook
+
+app-name-becky = Becky! Internet Mail
+
+app-name-apple-mail = Apple Mail
+
+source-thunderbird = Import from another { app-name-thunderbird } installation
+
+source-thunderbird-description = Import settings, filters, messages, and other data from a { app-name-thunderbird } profile.
+
+source-seamonkey = Import from a { app-name-seamonkey } installation
+
+source-seamonkey-description = Import settings, filters, messages, and other data from a { app-name-seamonkey } profile.
+
+source-outlook = Import from { app-name-outlook }
+
+source-outlook-description = Import accounts, address books, and messages from { app-name-outlook }.
+
+source-becky = Import from { app-name-becky }
+
+source-becky-description = Import address books and messages from { app-name-becky }.
+
+source-apple-mail = Import from { app-name-apple-mail }
+
+source-apple-mail-description = Import messages from { app-name-apple-mail }.
+
+source-file2 = Import from a file
+
+source-file-description = Select a file to import address books, calendars, or a profile backup (ZIP file).
+
+## Import from file selections
+
+file-profile2 = Import Backed-up Profile
+
+file-profile-description = Select a previously backed up Thunderbird profile (.zip)
+
+file-calendar = Import Calendars
+
+file-calendar-description = Select a file containing exported Calendars or Events (.ics)
+
+file-addressbook = Import Address Books
+
+file-addressbook-description = Select a file containing exported Address Books and Contacts
+
+## Import from app profile steps
+
+from-app-thunderbird = Import from a { app-name-thunderbird } profile
+
+from-app-seamonkey = Import from a { app-name-seamonkey } profile
+
+from-app-outlook = Import from { app-name-outlook }
+
+from-app-becky = Import from { app-name-becky }
+
+from-app-apple-mail = Import from { app-name-apple-mail }
+
+profiles-pane-title-thunderbird = Import Settings and Data from a { app-name-thunderbird } profile.
+
+profiles-pane-title-seamonkey = Import Settings and Data from a { app-name-seamonkey } profile.
+
+profiles-pane-title-outlook = Import Data from { app-name-outlook }.
+
+profiles-pane-title-becky = Import Data from { app-name-becky }.
+
+profiles-pane-title-apple-mail = Import Messages from { app-name-apple-mail }.
+
+profile-source = Import from profile
+
+# $profileName (string) - name of the profile
+profile-source-named = Import from profile <strong>"{ $profileName }"</strong>
+
+profile-file-picker-directory = Choose a profile folder
+
+profile-file-picker-archive = Choose a <strong>ZIP</strong> file
+
+profile-file-picker-archive-description = The ZIP file must be smaller than 2GB.
+
+profile-file-picker-archive-title = Choose a ZIP file (smaller than 2GB)
+
+items-pane-title2 = Choose what to import:
+
+items-pane-directory = Directory:
+
+items-pane-profile-name = Profile name:
+
+items-pane-checkbox-accounts = Accounts and Settings
+
+items-pane-checkbox-address-books = Address Books
+
+items-pane-checkbox-calendars = Calendars
+
+items-pane-checkbox-mail-messages = Mail Messages
+
+items-pane-override = Any existing or identical data will not be overwritten.
+
+## Import from address book file steps
+
+import-from-addr-book-file-description = Choose the file format containing your Address Book data.
+
+addr-book-csv-file = Comma or tab separated file (.csv, .tsv)
+
+addr-book-ldif-file = LDIF file (.ldif)
+
+addr-book-vcard-file = vCard file (.vcf, .vcard)
+
+addr-book-sqlite-file = SQLite database file (.sqlite)
+
+addr-book-mab-file = Mork database file (.mab)
+
+addr-book-file-picker = Select an address book file
+
+addr-book-csv-field-map-title = Match field names
+
+addr-book-csv-field-map-desc = Select address book fields corresponding to the source fields. Uncheck fields you do not want to import.
+
+addr-book-directories-title = Select where to import the chosen data
+
+addr-book-directories-pane-source = Source file:
+
+# $addressBookName (string) - name of the new address book that would be created.
+addr-book-import-into-new-directory2 = Create a new directory called <strong>"{ $addressBookName }"</strong>
+
+# $addressBookName (string) - name of the address book to import into
+addr-book-summary-title = Import the chosen data into the "{ $addressBookName }" directory
+
+# $addressBookName (string) - name of the address book that will be created.
+addr-book-summary-description = A new address book called "{ $addressBookName }" will be created.
+
+## Import from calendar file steps
+
+import-from-calendar-file-desc = Select the iCalendar (.ics) file you would like to import.
+
+calendar-items-title = Select which items to import.
+
+calendar-items-loading = Loading items…
+
+calendar-items-filter-input =
+ .placeholder = Filter items…
+
+calendar-select-all-items = Select all
+
+calendar-deselect-all-items = Deselect all
+
+calendar-target-title = Select where to import the chosen items.
+
+# $targetCalendar (string) - name of the new calendar that would be created
+calendar-import-into-new-calendar2 = Create a new calendar called <strong>"{ $targetCalendar }"</strong>
+
+# $itemCount (number) - count of selected items (tasks, events) that will be imported
+# $targetCalendar (string) - name of the calendar the items will be imported into
+calendar-summary-title =
+ { $itemCount ->
+ [one] Import one item into the "{ $targetCalendar }" calendar
+ *[other] Import { $itemCount } items into the "{ $targetCalendar }" calendar
+ }
+
+# $targetCalendar (string) - name of the calendar that will be created
+calendar-summary-description = A new calendar called "{ $targetCalendar }" will be created.
+
+## Import dialog
+
+# $progressPercent (string) - percent formatted progress (for example "10%")
+progress-pane-importing2 = Importing… { $progressPercent }
+
+# $progressPercent (string) - percent formatted progress (for example "10%")
+progress-pane-exporting2 = Exporting… { $progressPercent }
+
+progress-pane-finished-desc2 = Complete.
+
+error-pane-title = Error
+
+error-message-zip-file-too-big2 = The selected ZIP file is larger than 2GB. Please extract it first, then import from the extracted folder instead.
+
+error-message-extract-zip-file-failed2 = Failed to extract the ZIP file. Please extract it manually, then import from the extracted folder instead.
+
+error-message-failed = Import failed unexpectedly, more information may be available in the Error Console.
+
+error-failed-to-parse-ics-file = No importable items found in the file.
+
+error-export-failed = Export failed unexpectedly, more information may be available in the Error Console.
+
+error-message-no-profile = No profile found.
+
+## <csv-field-map> element
+
+csv-first-row-contains-headers = First row contains field names
+
+csv-source-field = Source field
+
+csv-source-first-record = First record
+
+csv-source-second-record = Second record
+
+csv-target-field = Address book field
+
+## Export tab
+
+export-profile-title = Export accounts, messages, address books, and settings to a ZIP file.
+
+export-profile-description = If your current profile is larger than 2GB, we suggest you back it up by yourself.
+
+export-open-profile-folder = Open profile folder
+
+export-file-picker2 = Export to a ZIP file
+
+export-brand-name = { -brand-product-name }
+
+## Summary pane
+
+summary-pane-title = Data to be imported
+
+summary-pane-start = Start Import
+
+summary-pane-warning = { -brand-product-name } will need to be restarted when importing is complete.
+
+summary-pane-start-over = Restart Import Tool
+
+## Footer area
+
+footer-help = Need help?
+
+footer-import-documentation = Import documentation
+
+footer-export-documentation = Export documentation
+
+footer-support-forum = Support forum
+
+## Step navigation on top of the wizard pages
+
+step-list =
+ .aria-label = Import steps
+
+step-confirm = Confirm
+
+# Variables:
+# $number (number) - step number
+step-count = { $number }
diff --git a/comm/mail/locales/en-US/messenger/aboutProfilesExtra.ftl b/comm/mail/locales/en-US/messenger/aboutProfilesExtra.ftl
new file mode 100644
index 0000000000..fe428ad56b
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/aboutProfilesExtra.ftl
@@ -0,0 +1,5 @@
+# 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/.
+
+profiles-launch-profile-plain = Launch profile
diff --git a/comm/mail/locales/en-US/messenger/aboutRights.ftl b/comm/mail/locales/en-US/messenger/aboutRights.ftl
new file mode 100644
index 0000000000..7888f0f098
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/aboutRights.ftl
@@ -0,0 +1,122 @@
+# 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/.
+
+rights-title = About Your Rights
+rights-intro =
+ { -brand-full-name } is free and open source software, built by a community
+ of thousands from all over the world. There are a few things you should
+ know:
+rights-intro-point-1 =
+ { -brand-short-name } is made available to you under the terms of the
+ <a data-l10n-name="mozilla-public-license-link">Mozilla Public License</a>.
+ This means you may use, copy and distribute { -brand-short-name } to
+ others. You are also welcome to modify the source code of
+ { -brand-short-name } as you want to meet your needs. The Mozilla Public
+ License also gives you the right to distribute your modified versions.
+rights-intro-point-2 =
+ You are not granted any trademark rights or licenses to the trademarks of
+ the Mozilla Foundation or any party, including without limitation the
+ Thunderbird name or logo. Additional information on trademarks may be found
+ <a data-l10n-name="mozilla-trademarks-link">here</a>.
+rights-intro-point-3 =
+ Some features in { -brand-short-name }, such as the Crash Reporter, give
+ you the option to provide feedback to { -vendor-short-name }. By choosing
+ to submit feedback, you give { -vendor-short-name } permission to use the
+ feedback to improve its products, to publish the feedback on its websites,
+ and to distribute the feedback.
+rights-intro-point-4 =
+ How we use your personal information and feedback submitted to
+ { -vendor-short-name } through { -brand-short-name } is described in the
+ <a data-l10n-name="mozilla-privacy-policy-link">{ -brand-short-name }
+ Privacy Policy</a>.
+rights-intro-point-4-unbranded =
+ Any applicable privacy policies for this product should be listed here.
+rights-intro-point-5 =
+ Some { -brand-short-name } features make use of web-based information
+ services, however, we cannot guarantee they are 100% accurate or
+ error-free. More details, including information on how to disable the
+ features that use these services, can be found in the
+ <a data-l10n-name="mozilla-service-terms-link">service terms</a>.
+rights-intro-point-5-unbranded =
+ If this product incorporates web services, any applicable service terms for
+ the service(s) should be linked to the
+ <a data-l10n-name="mozilla-website-services-link"> Website Services</a>
+ section.
+rights-intro-point-6 =
+ In order to play back certain types of video content, { -brand-short-name }
+ downloads certain content decryption modules from third parties.
+rights-webservices-header = { -brand-full-name } Web-Based Information Services
+rights-webservices2 =
+ { -brand-full-name } uses web-based information services (“Servicesâ€) to
+ provide some of the features provided for your use with this binary version
+ of { -brand-short-name } under the terms described below. If you do not
+ want to use one or more of the Services or the terms below are
+ unacceptable, you may disable the feature or Service(s). Instructions on
+ how to disable a particular feature or Service may be found
+ <a data-l10n-name="mozilla-disable-service-link">here</a>. Other features
+ and Services can be disabled in the application settings.
+rights-locationawarebrowsing =
+ <strong>Location Aware Browsing: </strong>is always opt-in. No location
+ information is ever sent without your permission. If you wish to disable
+ the feature completely, follow these steps:
+rights-locationawarebrowsing-term-1 =
+ Open the Config Editor
+rights-locationawarebrowsing-term-2 = Type geo.enabled
+rights-locationawarebrowsing-term-3 =
+ Double click on the geo.enabled preference
+rights-locationawarebrowsing-term-4 = Location-Aware Browsing is now disabled
+rights-webservices-unbranded =
+ An overview of the website services the product incorporates, along with
+ instructions on how to disable them, if applicable, should be included
+ here.
+rights-webservices-term-unbranded =
+ Any applicable service terms for this product should be listed here.
+rights-webservices-term-1 =
+ { -vendor-short-name } and its contributors, licensors and partners work to
+ provide the most accurate and up-to-date Services. However, we cannot
+ guarantee that this information is comprehensive and error-free. For
+ example, the Safe Browsing Service may not identify some risky sites and
+ may identify some safe sites in error and the Location Aware Service all
+ locations returned by our service providers are estimates only and neither
+ we nor our service providers guarantee the accuracy of the locations
+ provided.
+rights-webservices-term-2 =
+ { -vendor-short-name } may discontinue or change the Services at its
+ discretion.
+rights-webservices-term-3 =
+ You are welcome to use these Services with the accompanying version of
+ { -brand-short-name }, and { -vendor-short-name } grants you its rights to
+ do so. { -vendor-short-name } and its licensors reserve all other rights in
+ the Services. These terms are not intended to limit any rights granted
+ under open source licenses applicable to { -brand-short-name } and to
+ corresponding source code versions of { -brand-short-name }.
+rights-webservices-term-4 =
+ <strong>The Services are provided “as-is.†{ -vendor-short-name }, its
+ contributors, licensors, and distributors, disclaim all warranties, whether
+ express or implied, including without limitation, warranties that the
+ Services are merchantable and fit for your particular purposes. You bear
+ the entire risk as to selecting the Services for your purposes and as to
+ the quality and performance of the Services. Some jurisdictions do not
+ allow the exclusion or limitation of implied warranties, so this disclaimer
+ may not apply to you.</strong>
+rights-webservices-term-5 =
+ <strong>Except as required by law, { -vendor-short-name }, its
+ contributors, licensors, and distributors will not be liable for any
+ indirect, special, incidental, consequential, punitive, or exemplary
+ damages arising out of or in any way relating to the use of
+ { -brand-short-name } and the Services. The collective liability under
+ these terms will not exceed $500 (five hundred dollars). Some jurisdictions
+ do not allow the exclusion or limitation of certain damages, so this
+ exclusion and limitation may not apply to you.</strong>
+rights-webservices-term-6 =
+ { -vendor-short-name } may update these terms as necessary from time to
+ time. These terms may not be modified or canceled without
+ { -vendor-short-name }’s written agreement.
+rights-webservices-term-7 =
+ These terms are governed by the laws of the state of California, U.S.A.,
+ excluding its conflict of law provisions. If any portion of these terms is
+ held to be invalid or unenforceable, the remaining portions will remain in
+ full force and effect. In the event of a conflict between a translated
+ version of these terms and the English language version, the English
+ language version shall control.
diff --git a/comm/mail/locales/en-US/messenger/aboutSupportCalendar.ftl b/comm/mail/locales/en-US/messenger/aboutSupportCalendar.ftl
new file mode 100644
index 0000000000..50073f91bd
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/aboutSupportCalendar.ftl
@@ -0,0 +1,33 @@
+# 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/.
+
+calendars-title = Calendar Settings
+calendars-table-heading-property = Name
+calendars-table-heading-value = Value
+calendars-table-name = Name
+calendars-table-type = Type
+calendars-table-disabled = Disabled
+calendars-table-username = Username
+calendars-table-uri = URI
+calendars-table-refreshinterval = Refresh Interval
+calendars-table-readonly = Read-only
+calendars-table-suppressalarms = Suppress Alarms
+calendars-table-cache-enabled = Cache Enabled
+calendars-table-imip-identity = iMIP Identity
+calendars-table-imip-identity-disabled = iMIP Disabled
+calendars-table-imip-identity-account = iMIP Account
+calendars-table-organizerid = Organizer Id
+calendars-table-forceemailscheduling = Force Email Scheduling
+calendars-table-capabilities-alarms-popup-supported = Popup Alarms Supported
+calendars-table-capabilities-alarms-oninviations-supported = Alarms on Invitation Supported
+calendars-table-capabilities-alarms-maxcount = Max Alarms Per Event
+calendars-table-capabilities-attachments-supported = Attachment Supported
+calendars-table-capabilities-categories-maxcount = Max Categories
+calendars-table-capabilities-privacy-supported = Privacy State Supported
+calendars-table-capabilities-priority-supported = Priority Supported
+calendars-table-capabilities-events-supported = Event Supported
+calendars-table-capabilities-tasks-supported = Task Supported
+calendars-table-capabilities-timezones-floating-supported = Local Time Supported
+calendars-table-capabilities-timezones-utc-supported = UTC/GMT Supported
+calendars-table-capabilities-autoschedule-supported = Auto-Scheduling Supported
diff --git a/comm/mail/locales/en-US/messenger/aboutSupportChat.ftl b/comm/mail/locales/en-US/messenger/aboutSupportChat.ftl
new file mode 100644
index 0000000000..7731079ba6
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/aboutSupportChat.ftl
@@ -0,0 +1,11 @@
+# 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/.
+
+chat-title = Chat Accounts
+chat-table-heading-account = ID
+chat-table-heading-protocol = Protocol
+chat-table-heading-name = Name
+chat-table-heading-actions = Actions
+chat-table-copy-debug-log = Copy Debug Log
+ .title = Copy errors and other logging from this chat account to the clipboard. May contain personal information like chat messages.
diff --git a/comm/mail/locales/en-US/messenger/aboutSupportMail.ftl b/comm/mail/locales/en-US/messenger/aboutSupportMail.ftl
new file mode 100644
index 0000000000..e7b0e45cca
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/aboutSupportMail.ftl
@@ -0,0 +1,33 @@
+# 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/.
+
+accounts-title = Mail and News Accounts
+show-private-data-main-text = Include account names
+show-private-data-explanation-text = (possibly identifying information)
+accounts-ID = ID
+accounts-name = Name
+accounts-incoming-server = Incoming server
+accounts-outgoing-servers = Outgoing servers
+accounts-server-name = Name
+accounts-conn-security = Connection security
+accounts-auth-method = Authentication method
+accounts-default = Default?
+identity-name = Identity
+
+send-via-email = Send via email
+
+app-basics-telemetry = Telemetry Data
+app-basics-cache-use = Cache Use
+
+mail-libs-title = Libraries
+libs-table-heading-library = Library
+libs-table-heading-expected-version = Expected minimum version
+libs-table-heading-loaded-version = Version in use
+libs-table-heading-path = Path
+libs-table-heading-status = Status
+
+libs-rnp-status-ok = OK
+libs-rnp-status-load-failed = Failed to load. OpenPGP will not work.
+libs-rnp-status-incompatible = Incompatible version. OpenPGP will not work.
+libs-rnp-status-unofficial = Unofficial version. OpenPGP might not work as expected.
diff --git a/comm/mail/locales/en-US/messenger/accountCentral.ftl b/comm/mail/locales/en-US/messenger/accountCentral.ftl
new file mode 100644
index 0000000000..24141189af
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/accountCentral.ftl
@@ -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/.
+
+account-central-title = Welcome to { -brand-full-name }
+account-settings = Account Settings
+
+# $accounts (Number) - the number of configured accounts
+setup-title = { $accounts ->
+ [0] Choose What to Set Up
+ *[other] Set Up Another Account
+}
+about-title = About { -brand-full-name }
+resources-title = Resources
+
+release-notes =
+ .title = About { -brand-full-name }
+
+email-label = Email
+ .aria-label = Connect to your existing email account
+email-description = { -brand-short-name } lets you connect to your existing email account, to read your emails conveniently and efficiently from within the application.
+
+calendar-label = Calendar
+ .aria-label = Create a new calendar
+calendar-description = { -brand-short-name } lets you handle events and keeps you organized. Connecting to a remote calendar will keep all your events in sync across all your devices.
+
+chat-label = Chat
+ .aria-label = Connect to your chat account
+chat-description = { -brand-short-name } lets you connect to multiple instant messaging accounts, offering support for various platforms.
+
+filelink-label = Filelink
+ .aria-label = Set up Filelink
+filelink-description = { -brand-short-name } lets you set up a convenient filelink cloud account to easily send large attachments.
+
+addressbook-label = Address Book
+ .aria-label = Create a new address book
+addressbook-description = { -brand-short-name } lets you organize all your contacts in an address book. You can also connect to a remote address book to keep all your contacts in sync.
+
+feeds-label = Feeds
+ .aria-label = Connect to feeds
+feeds-description = { -brand-short-name } lets you connect to RSS/Atom feeds to get news and updates from all around.
+
+newsgroups-label = Newsgroups
+ .aria-label = Connect to a newsgroup
+newsgroups-description = { -brand-short-name } lets you connect to all the newsgroups you want.
+
+import-title = Import from Another Program
+import-paragraph2 = { -brand-short-name } lets you import mail messages, address book entries, feed subscriptions, settings, and/or filters from other mail programs and common address book formats.
+
+import-label = Import
+ .aria-label = Import data from other programs
+
+about-paragraph = Thunderbird is the leading open source, cross-platform email and calendaring client, free for business and personal use. We want it to stay secure and become even better. A donation will allow us to hire developers, pay for infrastructure, and continue to improve.
+
+about-paragraph-consider-donation = <b>Thunderbird is funded by users like you! If you like Thunderbird, please consider making a donation.</b> The best way for you to ensure Thunderbird remains available is to <a data-l10n-name="donation-link"> make a donation</a>.
+
+explore-link = Explore Features
+support-link = Support
+involved-link = Get Involved
+developer-link = Developer Documentation
+
+read = Read messages
+compose = Write a new message
+search = Search messages
+filter = Manage message filters
+nntp-subscription = Manage newsgroup subscriptions
+rss-subscription = Manage feed subscriptions
+e2e = End-to-end Encryption
diff --git a/comm/mail/locales/en-US/messenger/accountManager.ftl b/comm/mail/locales/en-US/messenger/accountManager.ftl
new file mode 100644
index 0000000000..607c22e94a
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/accountManager.ftl
@@ -0,0 +1,18 @@
+# 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/.
+
+open-preferences-sidebar-button2 = { -brand-short-name } Settings
+
+open-addons-sidebar-button = Add-ons and Themes
+
+account-action-add-newsgroup-account =
+ .label = Add Newsgroup Account…
+ .accesskey = N
+
+server-change-restart-required = Restart is required to apply the server name or username change.
+
+edit-vcard-dialog-accept-button = Save
+ .accesskey = S
+edit-vcard-dialog-cancel-button = Cancel
+ .accesskey = C
diff --git a/comm/mail/locales/en-US/messenger/accountProvisioner.ftl b/comm/mail/locales/en-US/messenger/accountProvisioner.ftl
new file mode 100644
index 0000000000..ac60ba5939
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/accountProvisioner.ftl
@@ -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/.
+
+account-provisioner-tab-title = Get a new email address from a service provider
+
+provisioner-searching-icon =
+ .alt = Searching…
+
+account-provisioner-title = Create a new email address
+
+account-provisioner-description = Use our trusted partners to get a new private and secure email address.
+
+account-provisioner-start-help = The search terms used are sent to { -vendor-short-name } (<a data-l10n-name="mozilla-privacy-link">Privacy Policy</a>) and 3rd party email providers <strong>mailfence.com</strong> (<a data-l10n-name="mailfence-privacy-link">Privacy Policy</a>, <a data-l10n-name="mailfence-tou-link">Terms of Use</a>) and <strong>gandi.net</strong> (<a data-l10n-name="gandi-privacy-link">Privacy Policy</a>, <a data-l10n-name="gandi-tou-link">Terms of Use</a>) to find available email addresses.
+
+account-provisioner-mail-account-title = Buy a new email address
+
+account-provisioner-mail-account-description = Thunderbird partnered with <a data-l10n-name="mailfence-home-link">Mailfence</a> to offer you a new private and secure email. We believe everyone should have a secure email.
+
+account-provisioner-domain-title = Buy an email and domain of your own
+
+account-provisioner-domain-description = Thunderbird partnered with <a data-l10n-name="gandi-home-link">Gandi</a> to offer you a custom domain. This lets you use any address on that domain.
+
+## Forms
+
+account-provisioner-mail-input =
+ .placeholder = Your name, nickname, or other search term
+
+account-provisioner-domain-input =
+ .placeholder = Your name, nickname, or other search term
+
+account-provisioner-search-button = Search
+
+account-provisioner-button-cancel = Cancel
+
+account-provisioner-button-existing = Use an existing email account
+
+account-provisioner-button-back = Go back
+
+## Notifications
+
+account-provisioner-fetching-provisioners = Retrieving provisioners…
+
+account-provisioner-connection-issues = Unable to communicate with our sign-up servers. Please check your connection.
+
+account-provisioner-searching-email = Searching for available email accounts…
+
+account-provisioner-searching-domain = Searching for available domains…
+
+account-provisioner-searching-error = Could not find any addresses to suggest. Try changing the search terms.
+
+## Illustrations
+
+account-provisioner-step1-image =
+ .title = Choose which account to create
+
+## Search results
+
+# Variables:
+# $count (Number) - The number of domains found during search.
+account-provisioner-results-title =
+ { $count ->
+ [one] One available address found for:
+ *[other] { $count } available addresses found for:
+ }
+
+account-provisioner-mail-results-caption = You can try to search for nicknames or any other term to find more emails.
+
+account-provisioner-domain-results-caption = You can try to search for nicknames or any other term to find more domains.
+
+account-provisioner-free-account = Free
+
+# Variables:
+# $price (String) - Yearly fee for the mail account. For example "US $9.99".
+account-provision-price-per-year = { $price } a year
+
+account-provisioner-all-results-button = Show all results
+
+account-provisioner-open-in-tab-img =
+ .title = Opens in a new Tab
diff --git a/comm/mail/locales/en-US/messenger/accountcreation/accountHub.ftl b/comm/mail/locales/en-US/messenger/accountcreation/accountHub.ftl
new file mode 100644
index 0000000000..b26305a689
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/accountcreation/accountHub.ftl
@@ -0,0 +1,60 @@
+# 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/.
+
+### Account Hub
+### Account hub is where user can setup new accounts in Thunderbird.
+
+## Header
+
+account-hub-brand = { -brand-full-name }
+
+account-hub-welcome-line = Welcome to <span data-l10n-name="brand-name">{ -brand-full-name }</span>
+
+account-hub-title = Account Hub
+
+## Footer
+
+account-hub-release-notes = Release notes
+
+account-hub-support = Support
+
+account-hub-donate = Donate
+
+## Initial setup page
+
+account-hub-email-setup-button = Email Account
+ .title = Set up an email account
+
+account-hub-calendar-setup-button = Calendar
+ .title = Set up a local or remote calendar
+
+account-hub-address-book-setup-button = Address Book
+ .title = Set up a local or remote address book
+
+account-hub-chat-setup-button = Chat
+ .title = Set up a chat account
+
+account-hub-feed-setup-button = RSS feed
+ .title = Set up an RSS feed account
+
+account-hub-newsgroup-setup-button = Newsgroup
+ .title = Set up a newsgroup account
+
+account-hub-import-setup-button = Import
+ .title = Import a backed up profile
+
+# Note: "Sync" represents the Firefox Sync product so it shouldn't be translated.
+account-hub-sync-button = Sign in to Sync…
+
+## Email page
+
+account-hub-email-title = Set up your email account
+
+account-hub-email-cancel-button = Cancel
+
+account-hub-email-back-button = Back
+
+account-hub-email-continue-button = Continue
+
+account-hub-email-confirm-button = Confirm
diff --git a/comm/mail/locales/en-US/messenger/accountcreation/accountSetup.ftl b/comm/mail/locales/en-US/messenger/accountcreation/accountSetup.ftl
new file mode 100644
index 0000000000..f36d924d60
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/accountcreation/accountSetup.ftl
@@ -0,0 +1,428 @@
+# 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/.
+
+account-setup-tab-title = Account Setup
+
+## Header
+
+account-setup-title = Set Up Your Existing Email Address
+
+account-setup-description = To use your current email address fill in your credentials.
+
+account-setup-secondary-description = { -brand-product-name } will automatically search for a working and recommended server configuration.
+
+account-setup-success-title = Account successfully created
+
+account-setup-success-description = You can now use this account with { -brand-short-name }.
+
+account-setup-success-secondary-description = You can improve the experience by connecting related services and configuring advanced account settings.
+
+## Form fields
+
+account-setup-name-label = Your full name
+ .accesskey = n
+
+# Note: "John Doe" is a multiple-use name that is used when the true name of a person is unknown. We use this fake name as an input placeholder. Translators should update this to reflect the placeholder name of their language/country.
+account-setup-name-input =
+ .placeholder = John Doe
+
+account-setup-name-info-icon =
+ .title = Your name, as shown to others
+
+
+account-setup-name-warning-icon =
+ .title = Please enter your name
+
+account-setup-email-label = Email address
+ .accesskey = E
+
+account-setup-email-input =
+ .placeholder = john.doe@example.com
+
+account-setup-email-info-icon =
+ .title = Your existing email address
+
+account-setup-email-warning-icon =
+ .title = Invalid email address
+
+account-setup-password-label = Password
+ .accesskey = P
+ .title = Optional, will only be used to validate the username
+
+account-provisioner-button = Get a new email address
+ .accesskey = G
+
+account-setup-password-toggle-show =
+ .title = Show password in clear text
+
+account-setup-password-toggle-hide =
+ .title = Hide password
+
+account-setup-remember-password = Remember password
+ .accesskey = m
+
+account-setup-exchange-label = Your login
+ .accesskey = l
+
+# YOURDOMAIN refers to the Windows domain in ActiveDirectory. yourusername refers to the user's account name in Windows.
+account-setup-exchange-input =
+ .placeholder = YOURDOMAIN\yourusername
+
+# Domain refers to the Windows domain in ActiveDirectory. We mean the user's login in Windows at the local corporate network.
+account-setup-exchange-info-icon =
+ .title = Domain login
+
+## Action buttons
+
+account-setup-button-cancel = Cancel
+ .accesskey = a
+
+account-setup-button-manual-config = Configure manually
+ .accesskey = m
+
+account-setup-button-stop = Stop
+ .accesskey = S
+
+account-setup-button-retest = Re-test
+ .accesskey = t
+
+account-setup-button-continue = Continue
+ .accesskey = C
+
+account-setup-button-done = Done
+ .accesskey = D
+
+## Notifications
+
+account-setup-looking-up-settings = Looking up configuration…
+
+account-setup-looking-up-settings-guess = Looking up configuration: Trying common server names…
+
+account-setup-looking-up-settings-half-manual = Looking up configuration: Probing server…
+
+account-setup-looking-up-disk = Looking up configuration: { -brand-short-name } installation…
+
+account-setup-looking-up-isp = Looking up configuration: Email provider…
+
+# Note: Do not translate or replace Mozilla. It stands for the public project mozilla.org, not Mozilla Corporation. The database is a generic, public domain facility usable by any client.
+account-setup-looking-up-db = Looking up configuration: Mozilla ISP database…
+
+account-setup-looking-up-mx = Looking up configuration: Incoming mail domain…
+
+account-setup-looking-up-exchange = Looking up configuration: Exchange server…
+
+account-setup-checking-password = Checking password…
+
+account-setup-installing-addon = Downloading and installing add-on…
+
+account-setup-success-half-manual = The following settings were found by probing the given server:
+
+account-setup-success-guess = Configuration found by trying common server names.
+
+account-setup-success-guess-offline = You are offline. We guessed some settings but you will need to enter the right settings.
+
+account-setup-success-password = Password OK
+
+account-setup-success-addon = Successfully installed the add-on
+
+# Note: Do not translate or replace Mozilla. It stands for the public project mozilla.org, not Mozilla Corporation. The database is a generic, public domain facility usable by any client.
+account-setup-success-settings-db = Configuration found in Mozilla ISP database.
+
+account-setup-success-settings-disk = Configuration found on { -brand-short-name } installation.
+
+account-setup-success-settings-isp = Configuration found at email provider.
+
+# Note: Microsoft Exchange is a product name.
+account-setup-success-settings-exchange = Configuration found for a Microsoft Exchange server.
+
+## Illustrations
+
+account-setup-step1-image =
+ .title = Initial setup
+
+account-setup-step2-image =
+ .title = Loading…
+
+account-setup-step3-image =
+ .title = Configuration found
+
+account-setup-step4-image =
+ .title = Connection error
+
+account-setup-step5-image =
+ .title = Account created
+
+account-setup-privacy-footnote2 = Your credentials will only be stored locally on your computer.
+
+account-setup-selection-help = Not sure what to select?
+
+account-setup-selection-error = Need help?
+
+account-setup-success-help = Not sure about your next steps?
+
+account-setup-documentation-help = Setup documentation
+
+account-setup-forum-help = Support forum
+
+account-setup-privacy-help = Privacy policy
+
+account-setup-getting-started = Getting started
+
+## Results area
+
+# Variables:
+# $count (Number) - Number of available protocols.
+account-setup-results-area-title =
+ { $count ->
+ [one] Available configuration
+ *[other] Available configurations
+ }
+
+account-setup-result-imap-description = Keep your folders and emails synced on your server
+
+account-setup-result-pop-description = Keep your folders and emails on your computer
+
+# Note: Exchange, Office365 are the name of products.
+account-setup-result-exchange2-description = Use the Microsoft Exchange server or Office365 cloud services
+
+account-setup-incoming-title = Incoming
+
+account-setup-outgoing-title = Outgoing
+
+account-setup-username-title = Username
+
+account-setup-exchange-title = Server
+
+account-setup-result-no-encryption = No Encryption
+
+account-setup-result-ssl = SSL/TLS
+
+account-setup-result-starttls = STARTTLS
+
+account-setup-result-outgoing-existing = Use existing outgoing SMTP server
+
+# Variables:
+# $incoming (String): The email/username used to log into the incoming server
+# $outgoing (String): The email/username used to log into the outgoing server
+account-setup-result-username-different = Incoming: { $incoming }, Outgoing: { $outgoing }
+
+## Error messages
+
+# Note: The reference to "janedoe" (Jane Doe) is the name of an example person. You will want to translate it to whatever example persons would be named in your language. In the example, AD is the name of the Windows domain, and this should usually not be translated.
+account-setup-credentials-incomplete = Authentication failed. Either the entered credentials are incorrect or a separate username is required for logging in. This username is usually your Windows domain login with or without the domain (for example, janedoe or AD\\janedoe)
+
+account-setup-credentials-wrong = Authentication failed. Please check the username and password
+
+account-setup-find-settings-failed = { -brand-short-name } failed to find the settings for your email account
+
+account-setup-exchange-config-unverifiable = Configuration could not be verified. If your username and password are correct, it’s likely that the server administrator has disabled the selected configuration for your account. Try selecting another protocol.
+
+account-setup-provisioner-error = An error occurred while setting up your new account in { -brand-short-name }. Please, try to manually set up your account with your credentials.
+
+## Manual configuration area
+
+account-setup-manual-config-title = Manual configuration
+
+account-setup-incoming-server-legend = Incoming server
+
+account-setup-protocol-label = Protocol:
+
+account-setup-hostname-label = Hostname:
+
+account-setup-port-label = Port:
+ .title = Set the port number to 0 for autodetection
+
+account-setup-auto-description = { -brand-short-name } will attempt to auto-detect fields that are left blank.
+
+account-setup-ssl-label = Connection security:
+
+account-setup-outgoing-server-legend = Outgoing server
+
+## Incoming/Outgoing SSL Authentication options
+
+ssl-autodetect-option = Autodetect
+
+ssl-no-authentication-option = No authentication
+
+ssl-cleartext-password-option = Normal password
+
+ssl-encrypted-password-option = Encrypted password
+
+## Incoming/Outgoing SSL options
+
+ssl-noencryption-option = None
+
+account-setup-auth-label = Authentication method:
+
+account-setup-username-label = Username:
+
+account-setup-advanced-setup-button = Advanced config
+ .accesskey = A
+
+## Warning insecure server dialog
+
+account-setup-insecure-title = Warning!
+
+account-setup-insecure-incoming-title = Incoming settings:
+
+account-setup-insecure-outgoing-title = Outgoing settings:
+
+# Variables:
+# $server (String): The name of the hostname of the server the user was trying to connect to.
+account-setup-warning-cleartext = <b>{ $server }</b> does not use encryption.
+
+account-setup-warning-cleartext-details = Insecure mail servers do not use encrypted connections to protect your passwords and private information. By connecting to this server you could expose your password and private information.
+
+account-setup-insecure-server-checkbox = I understand the risks
+ .accesskey = u
+
+account-setup-insecure-description = { -brand-short-name } can allow you to get to your mail using the provided configurations. However, you should contact your administrator or email provider regarding these improper connections. See the <a data-l10n-name="thunderbird-faq-link">Thunderbird FAQ</a> for more information.
+
+insecure-dialog-cancel-button = Change Settings
+ .accesskey = S
+
+insecure-dialog-confirm-button = Confirm
+ .accesskey = C
+
+## Warning Exchange confirmation dialog
+
+# Variables:
+# $domain (String): The name of the server where the configuration was found, e.g. rackspace.com.
+exchange-dialog-question = { -brand-short-name } found your account setup information on { $domain }. Do you want to proceed and submit your credentials?
+
+exchange-dialog-confirm-button = Login
+
+exchange-dialog-cancel-button = Cancel
+
+## Dismiss account creation dialog
+
+exit-dialog-title = No Email Account Configured
+
+exit-dialog-description = Are you sure you want to cancel the setup process? { -brand-short-name } can still be used without an email account, but many features will not be available.
+
+account-setup-no-account-checkbox = Use { -brand-short-name } without an email account
+ .accesskey = U
+
+exit-dialog-cancel-button = Continue Setup
+ .accesskey = C
+
+exit-dialog-confirm-button = Exit Setup
+ .accesskey = E
+
+## Alert dialogs
+
+account-setup-creation-error-title = Error Creating Account
+
+account-setup-error-server-exists = Incoming server already exists.
+
+account-setup-confirm-advanced-title = Confirm Advanced Configuration
+
+account-setup-confirm-advanced-description = This dialog will be closed and an account with the current settings will be created, even if the configuration is incorrect. Do you want to proceed?
+
+## Addon installation section
+
+account-setup-addon-install-title = Install
+
+account-setup-addon-install-intro = A third-party add-on can allow you to access your email account on this server:
+
+account-setup-addon-no-protocol = This email server unfortunately does not support open protocols. { account-setup-addon-install-intro }
+
+## Success view
+
+account-setup-settings-button = Account settings
+
+account-setup-encryption-button = End-to-end encryption
+
+account-setup-signature-button = Add a signature
+
+account-setup-dictionaries-button = Download dictionaries
+
+account-setup-address-book-carddav-button = Connect to a CardDAV address book
+
+account-setup-address-book-ldap-button = Connect to an LDAP address book
+
+account-setup-calendar-button = Connect to a remote calendar
+
+account-setup-linked-services-title = Connect your linked services
+
+account-setup-linked-services-description = { -brand-short-name } detected other services linked to your email account.
+
+account-setup-no-linked-description = Setup other services to get the most out of your { -brand-short-name } experience.
+
+# Variables:
+# $count (Number) - The number of address books found during autoconfig.
+account-setup-found-address-books-description =
+ { $count ->
+ [one] { -brand-short-name } found one address book linked to your email account.
+ *[other] { -brand-short-name } found { $count } address books linked to your email account.
+ }
+
+# Variables:
+# $count (Number) - The number of calendars found during autoconfig.
+account-setup-found-calendars-description =
+ { $count ->
+ [one] { -brand-short-name } found one calendar linked to your email account.
+ *[other] { -brand-short-name } found { $count } calendars linked to your email account.
+ }
+
+account-setup-button-finish = Finish
+ .accesskey = F
+
+account-setup-looking-up-address-books = Looking up address books…
+
+account-setup-looking-up-calendars = Looking up calendars…
+
+account-setup-address-books-button = Address Books
+
+account-setup-calendars-button = Calendars
+
+account-setup-connect-link = Connect
+
+account-setup-existing-address-book = Connected
+ .title = Address book already connected
+
+account-setup-existing-calendar = Connected
+ .title = Calendar already connected
+
+account-setup-connect-all-calendars = Connect all calendars
+
+account-setup-connect-all-address-books = Connect all address books
+
+## Calendar synchronization dialog
+
+calendar-dialog-title = Connect calendar
+
+calendar-dialog-cancel-button = Cancel
+ .accesskey = C
+
+calendar-dialog-confirm-button = Connect
+ .accesskey = n
+
+account-setup-calendar-name-label = Name
+
+account-setup-calendar-name-input =
+ .placeholder = My calendar
+
+account-setup-calendar-color-label = Color
+
+account-setup-calendar-refresh-label = Refresh
+
+account-setup-calendar-refresh-manual = Manually
+
+# Variables:
+# $count (Number) - Number of minutes in the calendar refresh interval.
+account-setup-calendar-refresh-interval =
+ { $count ->
+ [one] Every minute
+ *[other] Every { $count } minutes
+ }
+
+account-setup-calendar-read-only = Read only
+ .accesskey = R
+
+account-setup-calendar-show-reminders = Show reminders
+ .accesskey = S
+
+account-setup-calendar-offline-support = Offline support
+ .accesskey = O
diff --git a/comm/mail/locales/en-US/messenger/addonNotifications.ftl b/comm/mail/locales/en-US/messenger/addonNotifications.ftl
new file mode 100644
index 0000000000..a2451e41ab
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/addonNotifications.ftl
@@ -0,0 +1,130 @@
+# 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/.
+
+xpinstall-prompt = { -brand-short-name } prevented this site from asking you to install software on your computer.
+
+## Variables:
+## $host (String): The hostname of the site the add-on is being installed from.
+
+xpinstall-prompt-header = Allow { $host } to install an add-on?
+xpinstall-prompt-message = You are attempting to install an add-on from { $host }. Make sure you trust this site before continuing.
+
+##
+
+xpinstall-prompt-header-unknown = Allow an unknown site to install an add-on?
+xpinstall-prompt-message-unknown = You are attempting to install an add-on from an unknown site. Make sure you trust this site before continuing.
+
+xpinstall-prompt-dont-allow =
+ .label = Don’t Allow
+ .accesskey = D
+xpinstall-prompt-never-allow =
+ .label = Never Allow
+ .accesskey = N
+# Long text in this context make the dropdown menu extend awkwardly to the left,
+# avoid a localization that's significantly longer than the English version.
+xpinstall-prompt-never-allow-and-report =
+ .label = Report Suspicious Site
+ .accesskey = R
+# Accessibility Note:
+# Be sure you do not choose an accesskey that is used elsewhere in the active context (e.g. main menu bar, submenu of the warning popup button)
+# See https://website-archive.mozilla.org/www.mozilla.org/access/access/keyboard/ for details
+xpinstall-prompt-install =
+ .label = Continue to Installation
+ .accesskey = C
+
+# These messages are shown when a website invokes navigator.requestMIDIAccess.
+
+site-permission-install-first-prompt-midi-header = This site is requesting access to your MIDI (Musical Instrument Digital Interface) devices. Device access can be enabled by installing an add-on.
+site-permission-install-first-prompt-midi-message = This access is not guaranteed to be safe. Only continue if you trust this site.
+
+##
+
+xpinstall-disabled-locked = Software installation has been disabled by your system administrator.
+xpinstall-disabled = Software installation is currently disabled. Click Enable and try again.
+xpinstall-disabled-button =
+ .label = Enable
+ .accesskey = n
+
+# This message is shown when the installation of an add-on is blocked by enterprise policy.
+# Variables:
+# $addonName (String): the name of the add-on.
+# $addonId (String): the ID of add-on.
+addon-install-blocked-by-policy = { $addonName } ({ $addonId }) is blocked by your system administrator.
+# This message is shown when the installation of add-ons from a domain is blocked by enterprise policy.
+addon-domain-blocked-by-policy = Your system administrator prevented this site from asking you to install software on your computer.
+addon-install-full-screen-blocked = Add-on installation is not allowed while in or before entering fullscreen mode.
+
+# Variables:
+# $addonName (String): the localized name of the sideloaded add-on.
+webext-perms-sideload-menu-item = { $addonName } added to { -brand-short-name }
+# Variables:
+# $addonName (String): the localized name of the extension which has been updated.
+webext-perms-update-menu-item = { $addonName } requires new permissions
+
+## Add-on removal warning
+
+# Variables:
+# $name (String): The name of the add-on that will be removed.
+addon-removal-title = Remove { $name }?
+# Variables:
+# $name (String): the name of the extension which is about to be removed.
+addon-removal-message = Remove { $name } from { -brand-shorter-name }?
+addon-removal-button = Remove
+addon-removal-abuse-report-checkbox = Report this extension to { -vendor-short-name }
+
+# Variables:
+# $addonCount (Number): the number of add-ons being downloaded
+addon-downloading-and-verifying =
+ { $addonCount ->
+ [1] Downloading and verifying add-on…
+ *[other] Downloading and verifying { $addonCount } add-ons…
+ }
+addon-download-verifying = Verifying
+
+addon-install-cancel-button =
+ .label = Cancel
+ .accesskey = C
+addon-install-accept-button =
+ .label = Add
+ .accesskey = A
+
+## Variables:
+## $addonCount (Number): the number of add-ons being installed
+
+addon-confirm-install-message =
+ { $addonCount ->
+ [1] This site would like to install an add-on in { -brand-short-name }:
+ *[other] This site would like to install { $addonCount } add-ons in { -brand-short-name }:
+ }
+addon-confirm-install-unsigned-message =
+ { $addonCount ->
+ [1] Caution: This site would like to install an unverified add-on in { -brand-short-name }. Proceed at your own risk.
+ *[other] Caution: This site would like to install { $addonCount } unverified add-ons in { -brand-short-name }. Proceed at your own risk.
+ }
+# Variables:
+# $addonCount (Number): the number of add-ons being installed (at least 2)
+addon-confirm-install-some-unsigned-message =
+ { $addonCount ->
+ *[other] Caution: This site would like to install { $addonCount } add-ons in { -brand-short-name }, some of which are unverified. Proceed at your own risk.
+ }
+
+## Add-on install errors
+## Variables:
+## $addonName (String): the add-on name.
+
+addon-install-error-network-failure = The add-on could not be downloaded because of a connection failure.
+addon-install-error-incorrect-hash = The add-on could not be installed because it does not match the add-on { -brand-short-name } expected.
+addon-install-error-corrupt-file = The add-on downloaded from this site could not be installed because it appears to be corrupt.
+addon-install-error-file-access = { $addonName } could not be installed because { -brand-short-name } cannot modify the needed file.
+addon-install-error-not-signed = { -brand-short-name } has prevented this site from installing an unverified add-on.
+addon-install-error-invalid-domain = The add-on { $addonName } can not be installed from this location.
+addon-local-install-error-network-failure = This add-on could not be installed because of a filesystem error.
+addon-local-install-error-incorrect-hash = This add-on could not be installed because it does not match the add-on { -brand-short-name } expected.
+addon-local-install-error-corrupt-file = This add-on could not be installed because it appears to be corrupt.
+addon-local-install-error-file-access = { $addonName } could not be installed because { -brand-short-name } cannot modify the needed file.
+addon-local-install-error-not-signed = This add-on could not be installed because it has not been verified.
+# Variables:
+# $appVersion (String): the application version.
+addon-install-error-incompatible = { $addonName } could not be installed because it is not compatible with { -brand-short-name } { $appVersion }.
+addon-install-error-blocklisted = { $addonName } could not be installed because it has a high risk of causing stability or security problems.
diff --git a/comm/mail/locales/en-US/messenger/addressbook/abCardDAVDialog.ftl b/comm/mail/locales/en-US/messenger/addressbook/abCardDAVDialog.ftl
new file mode 100644
index 0000000000..8558b4a2d9
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/addressbook/abCardDAVDialog.ftl
@@ -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/.
+
+carddav-window-title = New CardDAV Address Book
+
+carddav-dialog =
+ .buttonlabelaccept = Continue
+ .buttonaccesskeyaccept = C
+
+carddav-username-label =
+ .value = Username:
+ .accesskey = U
+carddav-location-label =
+ .value = Location:
+ .accesskey = L
+carddav-location =
+ .default-placeholder = URL or host name of the address book server
+
+carddav-loading = Looking up configuration…
+
+# Variables:
+# $url (String) - CardDAV endpoint hostname. For example "example.com".
+carddav-known-incompatible = { $url } is known to be incompatible with { -brand-short-name }.
+carddav-connection-error = Failed to connect.
+carddav-none-found = Found no address books to add for the specified account.
+carddav-already-added = All address books for the specified account have already been added.
+
+carddav-available-books = Available address books:
diff --git a/comm/mail/locales/en-US/messenger/addressbook/abCardDAVProperties.ftl b/comm/mail/locales/en-US/messenger/addressbook/abCardDAVProperties.ftl
new file mode 100644
index 0000000000..4306c8bf78
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/addressbook/abCardDAVProperties.ftl
@@ -0,0 +1,31 @@
+# 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/.
+
+carddav-url-label =
+ .value = CardDAV URL:
+ .accesskey = V
+
+carddav-refreshinterval-label =
+ .label = Synchronize:
+ .accesskey = S
+
+# Variables:
+# $minutes (integer) - Number of minutes between address book synchronizations
+carddav-refreshinterval-minutes-value =
+ .label = { $minutes ->
+ [one] every minute
+ *[other] every { $minutes } minutes
+ }
+
+# Variables:
+# $hours (integer) - Number of hours between address book synchronizations
+carddav-refreshinterval-hours-value =
+ .label = { $hours ->
+ [one] every hour
+ *[other] every { $hours } hours
+ }
+
+carddav-readonly-label =
+ .label = Read-only
+ .accesskey = R
diff --git a/comm/mail/locales/en-US/messenger/addressbook/aboutAddressBook.ftl b/comm/mail/locales/en-US/messenger/addressbook/aboutAddressBook.ftl
new file mode 100644
index 0000000000..cd3e13fd05
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/addressbook/aboutAddressBook.ftl
@@ -0,0 +1,299 @@
+# 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/.
+
+about-addressbook-title = Address Book
+
+## Toolbar
+
+about-addressbook-toolbar-new-address-book =
+ .label = New Address Book
+about-addressbook-toolbar-add-carddav-address-book =
+ .label = Add CardDAV Address Book
+about-addressbook-toolbar-add-ldap-address-book =
+ .label = Add LDAP Address Book
+about-addressbook-toolbar-new-contact =
+ .label = New Contact
+about-addressbook-toolbar-new-list =
+ .label = New List
+about-addressbook-toolbar-import =
+ .label = Import
+
+## Books
+
+all-address-books-row =
+ .title = All Address Books
+all-address-books = All Address Books
+
+# Variables:
+# $name (String) - The name of the selected book/list.
+# $count (Number) - The number of contacts in the selected book/list.
+about-addressbook-card-count = Total contacts in { $name }: { $count }
+# Variables:
+# $count (Number) - The number of contacts in all address books.
+about-addressbook-card-count-all = Total contacts in all address books: { $count }
+
+about-addressbook-books-context-properties =
+ .label = Properties
+about-addressbook-books-context-edit-list =
+ .label = Edit list
+about-addressbook-books-context-synchronize =
+ .label = Synchronize
+about-addressbook-books-context-edit =
+ .label = Edit
+about-addressbook-books-context-print =
+ .label = Print…
+about-addressbook-books-context-export =
+ .label = Export…
+about-addressbook-books-context-delete =
+ .label = Delete
+about-addressbook-books-context-remove =
+ .label = Remove
+about-addressbook-books-context-startup-default =
+ .label = Default startup directory
+
+about-addressbook-confirm-delete-book-title = Delete Address Book
+# Variables:
+# $name (String) - Name of the address book to be deleted.
+about-addressbook-confirm-delete-book =
+ Are you sure you want to delete { $name } and all of its contacts?
+about-addressbook-confirm-remove-remote-book-title = Remove Address Book
+# Variables:
+# $name (String) - Name of the remote address book to be removed.
+about-addressbook-confirm-remove-remote-book =
+ Are you sure you want to remove { $name }?
+
+## Cards
+
+# Variables:
+# $name (String) - Name of the address book that will be searched.
+about-addressbook-search =
+ .placeholder = Search { $name }
+about-addressbook-search-all =
+ .placeholder = Search all address books
+
+about-addressbook-sort-button2 =
+ .title = List display options
+
+about-addressbook-name-format-display =
+ .label = Display Name
+about-addressbook-name-format-firstlast =
+ .label = First Last
+about-addressbook-name-format-lastfirst =
+ .label = Last, First
+
+about-addressbook-sort-name-ascending =
+ .label = Sort by name (A > Z)
+about-addressbook-sort-name-descending =
+ .label = Sort by name (Z > A)
+about-addressbook-sort-email-ascending =
+ .label = Sort by email address (A > Z)
+about-addressbook-sort-email-descending =
+ .label = Sort by email address (Z > A)
+
+about-addressbook-table-layout =
+ .label = Table layout
+
+## Card column headers
+## Each string is listed here twice, and the values should match.
+
+about-addressbook-column-header-generatedname2 = Name
+ .title = Sort by name
+about-addressbook-column-label-generatedname2 =
+ .label = Name
+
+about-addressbook-column-header-emailaddresses2 = Email Addresses
+ .title = Sort by email addresses
+about-addressbook-column-label-emailaddresses2 =
+ .label = Email Addresses
+
+about-addressbook-column-header-nickname2 = Nickname
+ .title = Sort by nickname
+about-addressbook-column-label-nickname2 =
+ .label = Nickname
+
+about-addressbook-column-header-phonenumbers2 = Phone Numbers
+ .title = Sort by phone numbers
+about-addressbook-column-label-phonenumbers2 =
+ .label = Phone Numbers
+
+about-addressbook-column-header-addresses2 = Addresses
+ .title = Sort by addresses
+about-addressbook-column-label-addresses2 =
+ .label = Addresses
+
+about-addressbook-column-header-title2 = Title
+ .title = Sort by title
+about-addressbook-column-label-title2 =
+ .label = Title
+
+about-addressbook-column-header-department2 = Department
+ .title = Sort by department
+about-addressbook-column-label-department2 =
+ .label = Department
+
+about-addressbook-column-header-organization2 = Organization
+ .title = Sort by organization
+about-addressbook-column-label-organization2 =
+ .label = Organization
+
+about-addressbook-column-header-addrbook2 = Address Book
+ .title = Sort by address book
+about-addressbook-column-label-addrbook2 =
+ .label = Address Book
+
+about-addressbook-cards-context-write =
+ .label = Write
+
+about-addressbook-confirm-delete-mixed-title = Delete Contacts and Lists
+# Variables:
+# $count (Number) - The number of contacts and lists to be deleted. Always greater than 1.
+about-addressbook-confirm-delete-mixed =
+ Are you sure you want to delete these { $count } contacts and lists?
+# Variables:
+# $count (Number) - The number of lists to be deleted.
+about-addressbook-confirm-delete-lists-title =
+ { $count ->
+ [one] Delete List
+ *[other] Delete Lists
+ }
+# Variables:
+# $count (Number) - The number of lists to be deleted.
+# $name (String) - The name of the list to be deleted, if $count is 1.
+about-addressbook-confirm-delete-lists =
+ { $count ->
+ [one] Are you sure you want to delete the list { $name }?
+ *[other] Are you sure you want to delete these { $count } lists?
+ }
+# Variables:
+# $count (Number) - The number of contacts to be removed.
+about-addressbook-confirm-remove-contacts-title =
+ { $count ->
+ [one] Remove Contact
+ *[other] Remove Contacts
+ }
+# Variables:
+# $name (String) - The name of the contact to be removed.
+# $list (String) - The name of the list that contacts will be removed from.
+about-addressbook-confirm-remove-contacts-single =
+ Are you sure you want to remove { $name } from { $list }?
+# Variables:
+# $count (Number) - The number of contacts to be removed.
+# $list (String) - The name of the list that contacts will be removed from.
+about-addressbook-confirm-remove-contacts-multi =
+ { $count ->
+ *[other] Are you sure you want to remove these { $count } contacts from { $list }?
+ }
+# Variables:
+# $count (Number) - The number of contacts to be deleted.
+about-addressbook-confirm-delete-contacts-title =
+ { $count ->
+ [one] Delete Contact
+ *[other] Delete Contacts
+ }
+# Variables:
+# $name (String) - The name of the contact to be deleted.
+about-addressbook-confirm-delete-contacts-single =
+ Are you sure you want to delete the contact { $name }?
+# Variables:
+# $count (Number) - The number of contacts to be deleted.
+about-addressbook-confirm-delete-contacts-multi =
+ { $count ->
+ *[other] Are you sure you want to delete these { $count } contacts?
+ }
+
+## Card list placeholder
+## Shown when there are no cards in the list
+
+about-addressbook-placeholder-empty-book = No contacts available
+about-addressbook-placeholder-new-contact = New Contact
+about-addressbook-placeholder-search-only = This address book shows contacts only after a search
+about-addressbook-placeholder-searching = Searching…
+about-addressbook-placeholder-no-search-results = No contacts found
+
+## Details
+
+# Variables:
+# $count (Number) - The number of selected items (will never be fewer than 2).
+about-addressbook-selection-mixed-header2 =
+ { $count ->
+ *[other] { $count } selected address book entries
+ }
+# Variables:
+# $count (Number) - The number of selected contacts
+about-addressbook-selection-contacts-header2 =
+ { $count ->
+ [one] { $count } selected contact
+ *[other] { $count } selected contacts
+ }
+# Variables:
+# $count (Number) - The number of selected lists
+about-addressbook-selection-lists-header2 =
+ { $count ->
+ [one] { $count } selected list
+ *[other] { $count } selected lists
+ }
+
+about-addressbook-details-edit-photo =
+ .title = Edit contact photo
+
+about-addressbook-new-contact-header = New Contact
+
+about-addressbook-prefer-display-name = Prefer display name over message header
+
+about-addressbook-write-action-button = Write
+about-addressbook-event-action-button = Event
+about-addressbook-search-action-button = Search
+about-addressbook-new-list-action-button = New List
+
+about-addressbook-begin-edit-contact-button = Edit
+about-addressbook-delete-edit-contact-button = Delete
+about-addressbook-cancel-edit-contact-button = Cancel
+about-addressbook-save-edit-contact-button = Save
+
+about-addressbook-add-contact-to = Add to:
+
+about-addressbook-details-email-addresses-header = Email Addresses
+about-addressbook-details-phone-numbers-header = Phone Numbers
+about-addressbook-details-addresses-header = Addresses
+about-addressbook-details-notes-header = Notes
+about-addressbook-details-impp-header = Instant Messaging
+about-addressbook-details-websites-header = Websites
+about-addressbook-details-other-info-header = Other Information
+
+about-addressbook-entry-type-work = Work
+about-addressbook-entry-type-home = Home
+about-addressbook-entry-type-fax = Fax
+# Or "Mobile"
+about-addressbook-entry-type-cell = Cell
+about-addressbook-entry-type-pager = Pager
+
+about-addressbook-entry-name-birthday = Birthday
+about-addressbook-entry-name-anniversary = Anniversary
+about-addressbook-entry-name-title = Title
+about-addressbook-entry-name-role = Role
+about-addressbook-entry-name-organization = Organization
+about-addressbook-entry-name-website = Website
+about-addressbook-entry-name-time-zone = Time Zone
+about-addressbook-entry-name-custom1 = Custom 1
+about-addressbook-entry-name-custom2 = Custom 2
+about-addressbook-entry-name-custom3 = Custom 3
+about-addressbook-entry-name-custom4 = Custom 4
+
+about-addressbook-unsaved-changes-prompt-title = Unsaved Changes
+about-addressbook-unsaved-changes-prompt = Do you want to save your changes before leaving the edit view?
+
+# Photo dialog
+
+about-addressbook-photo-drop-target = Drop or paste a photo here, or click to select a file.
+about-addressbook-photo-drop-loading = Loading photo…
+about-addressbook-photo-drop-error = Failed to load photo.
+about-addressbook-photo-filepicker-title = Select an image file
+
+about-addressbook-photo-discard = Discard existing photo
+about-addressbook-photo-cancel = Cancel
+about-addressbook-photo-save = Save
+
+# Keyboard shortcuts
+
+about-addressbook-new-contact-key = N
diff --git a/comm/mail/locales/en-US/messenger/addressbook/fieldMapImport.ftl b/comm/mail/locales/en-US/messenger/addressbook/fieldMapImport.ftl
new file mode 100644
index 0000000000..5e39bf05ea
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/addressbook/fieldMapImport.ftl
@@ -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-ab-csv-dialog-title = Import Address Book from Text File
+
+# $recordNumber (Number) - The current record number of the preview data.
+import-ab-csv-preview-record-number = Preview of the data import for record { $recordNumber }
+
+import-ab-csv-dialog =
+ .buttonlabelaccept = Import
+ .buttonaccesskeyaccept = I
diff --git a/comm/mail/locales/en-US/messenger/addressbook/vcard.ftl b/comm/mail/locales/en-US/messenger/addressbook/vcard.ftl
new file mode 100644
index 0000000000..febae64507
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/addressbook/vcard.ftl
@@ -0,0 +1,189 @@
+# 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/.
+
+# Display Name
+
+vcard-displayname = Display name
+
+vcard-displayname-placeholder =
+ .placeholder = { vcard-displayname }
+
+# Type selection
+
+vcard-entry-type-label = Type
+
+vcard-entry-type-home = Home
+
+vcard-entry-type-work = Work
+
+vcard-entry-type-none = None
+
+vcard-entry-type-custom = Custom
+
+# N vCard field
+
+vcard-name-header = Name
+
+vcard-n-prefix = Prefix
+
+vcard-n-add-prefix =
+ .title = Add prefix
+
+vcard-n-firstname = First name
+
+vcard-n-add-firstname =
+ .title = Add first name
+
+vcard-n-middlename = Middle name
+
+vcard-n-add-middlename =
+ .title = Add middle name
+
+vcard-n-lastname = Last name
+
+vcard-n-add-lastname =
+ .title = Add last name
+
+vcard-n-suffix = Suffix
+
+vcard-n-add-suffix =
+ .title = Add suffix
+
+# Nickname
+
+vcard-nickname = Nickname
+
+# Email vCard field
+
+vcard-email-header = Email Addresses
+
+vcard-email-add = Add email address
+
+vcard-email-label = Email address
+
+vcard-primary-email-label = Default
+
+# URL vCard field
+
+vcard-url-header = Websites
+
+vcard-url-add = Add website
+
+vcard-url-label = Website
+
+# Tel vCard field
+
+vcard-tel-header = Phone Numbers
+
+vcard-tel-add = Add phone number
+
+vcard-tel-label = Phone number
+
+# Or "Mobile"
+vcard-entry-type-cell = Cell
+
+vcard-entry-type-fax = Fax
+
+vcard-entry-type-pager = Pager
+
+# TZ vCard field
+
+vcard-tz-header = Time Zone
+
+vcard-tz-add = Add time zone
+
+# IMPP vCard field
+
+vcard-impp2-header = Instant Messaging
+
+vcard-impp-add = Add chat account
+
+vcard-impp-label = Chat account
+
+vcard-impp-select = Protocol
+vcard-impp-option-other = Other
+
+vcard-impp-input-label = URI
+vcard-impp-input-title = URI for instant messaging
+
+# BDAY and ANNIVERSARY vCard field
+
+vcard-bday-anniversary-header = Special Dates
+
+vcard-bday-anniversary-add = Add special date
+
+vcard-bday-label = Birthday
+
+vcard-anniversary-label = Anniversary
+
+vcard-date-day = Day
+
+vcard-date-month = Month
+
+vcard-date-year = Year
+
+# ADR vCard field
+
+vcard-adr-header = Addresses
+
+vcard-adr-add = Add address
+
+vcard-adr-label = Address
+
+vcard-adr-delivery-label = Delivery label
+
+vcard-adr-street = Street address
+
+# Or "Locality"
+vcard-adr-locality = City
+
+# Or "Region"
+vcard-adr-region = State/Province
+
+# The term "ZIP code" only applies in USA. Most locales should use "Postal code" only.
+vcard-adr-code = ZIP/Postal code
+
+vcard-adr-country = Country
+
+# NOTE vCard field
+
+vcard-note-header = Notes
+
+vcard-note-add = Add note
+
+# TITLE, ROLE and ORGANIZATION vCard fields
+
+vcard-org-header = Organizational Properties
+
+vcard-org-add = Add organizational properties
+
+vcard-org-title = Title
+vcard-org-title-input =
+ .title = Position or job
+ .placeholder = Job title
+
+vcard-org-role = Role
+vcard-org-role-input =
+ .title = Function or part played in a particular situation
+ .placeholder = Role in a project
+
+vcard-org-org = Organization
+vcard-org-org-input =
+ .title = Organizational name
+ .placeholder = Company name
+vcard-org-org-unit = Department
+vcard-org-org-unit-input =
+ .title = Organizational unit name
+ .placeholder = Department
+
+# Custom properties
+
+vcard-custom-header = Custom Properties
+
+vcard-custom-add = Add custom properties
+
+vcard-remove-button-title =
+ .title = Remove
+
+vcard-remove-button = Remove
diff --git a/comm/mail/locales/en-US/messenger/appmenu.ftl b/comm/mail/locales/en-US/messenger/appmenu.ftl
new file mode 100644
index 0000000000..c8a8d394c3
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/appmenu.ftl
@@ -0,0 +1,267 @@
+# 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/.
+
+## Sync
+
+appmenu-sync-panel-title =
+ .title = Sync
+
+appmenu-signin-panel =
+ .label = Sign in to Sync
+ .accesskey = i
+
+appmenu-sync-sync =
+ .value = Account Sync
+ .accesskey = A
+
+appmenu-sync-manage =
+ .value = Manage Account
+ .accesskey = M
+
+appmenu-sync-account =
+ .value = example@example.com
+
+appmenu-sync-now =
+ .label = Sync Now
+ .accesskey = N
+
+appmenu-sync-settings =
+ .label = Sync Settings
+ .accesskey = S
+
+appmenu-sync-sign-out =
+ .label = Sign Out…
+ .accesskey = O
+
+## New Account
+
+appmenu-new-account-panel-title =
+ .title = New Account
+
+appmenu-new-account-panel =
+ .label = New Account
+ .accesskey = N
+
+appmenu-create-new-mail-account =
+ .label = Get a New Email
+ .accesskey = G
+
+appmenu-new-mail-account =
+ .label = Existing Email
+ .accesskey = E
+
+appmenu-new-calendar =
+ .label = Calendar
+ .accesskey = C
+
+appmenu-new-chat-account =
+ .label = Chat
+ .accesskey = h
+
+appmenu-new-feed =
+ .label = Feed
+ .accesskey = F
+
+appmenu-new-newsgroup =
+ .label = Newsgroup
+ .accesskey = N
+
+## New Account / Address Book
+
+appmenu-newab-panel-title =
+ .title = New Address Book
+
+appmenu-newab-panel =
+ .label = New Address Book
+ .accesskey = A
+
+appmenu-new-addressbook =
+ .label = Local Address Book
+ .accesskey = A
+
+appmenu-new-carddav =
+ .label = CardDav Address Book
+ .accesskey = C
+
+appmenu-new-ldap =
+ .label = LDAP Address Book
+ .accesskey = L
+
+## Create
+
+appmenu-create-panel-title =
+ .title = Create
+
+appmenu-create-panel =
+ .label = Create
+ .accesskey = C
+
+appmenu-create-message =
+ .label = Message
+ .accesskey = M
+
+appmenu-create-event =
+ .label = Event
+ .accesskey = E
+
+appmenu-create-task =
+ .label = Task
+ .accesskey = T
+
+appmenu-create-contact =
+ .label = Contact
+ .accesskey = C
+
+## Open
+
+appmenu-open-file-panel =
+ .label = Open from File
+ .accesskey = O
+
+appmenu-open-file-panel-title =
+ .title = Open from File
+
+appmenu-open-message =
+ .label = Message…
+ .accesskey = M
+
+appmenu-open-calendar =
+ .label = Calendar…
+ .accesskey = C
+
+## View / Layout
+
+appmenu-view-panel-title =
+ .title = View
+
+appmenu-view-panel =
+ .label = View
+ .accesskey = V
+
+appmenuitem-toggle-thread-pane-header =
+ .label = Message List Header
+
+appmenu-font-size-value = Font Size
+
+appmenu-mail-uidensity-value = Density
+
+appmenu-uidensity-compact =
+ .tooltiptext = Compact
+
+appmenu-uidensity-default =
+ .tooltiptext = Default
+
+appmenu-uidensity-relaxed =
+ .tooltiptext = Relaxed
+
+appmenuitem-font-size-enlarge =
+ .tooltiptext = Increase Font Size
+
+appmenuitem-font-size-reduce =
+ .tooltiptext = Reduce Font Size
+
+# Variables:
+# $size (String) - The current font size.
+appmenuitem-font-size-reset =
+ .label = { $size }px
+ .tooltiptext = Reset Font Size
+
+## Tools
+
+appmenu-tools-panel-title =
+ .title = Tools
+
+appmenu-tools-panel =
+ .label = Tools
+ .accesskey = T
+
+appmenu-tools-import =
+ .label = Import
+ .accesskey = I
+
+appmenu-tools-export =
+ .label = Export
+ .accesskey = E
+
+appmenu-tools-message-search =
+ .label = Search Messages
+ .accesskey = S
+
+appmenu-tools-message-filters =
+ .label = Message Filters
+ .accesskey = F
+
+appmenu-tools-download-manager =
+ .label = Download Manager
+ .accesskey = D
+
+appmenu-tools-activity-manager =
+ .label = Activity Manager
+ .accesskey = A
+
+appmenu-tools-dev-tools =
+ .label = Developer Tools
+ .accesskey = T
+
+## Help
+
+appmenu-help-panel-title =
+ .title = Help
+
+appmenu-help-get-help =
+ .label = Get Help
+ .accesskey = H
+
+appmenu-help-explore-features =
+ .label = Explore Features
+ .accesskey = F
+
+appmenu-help-shortcuts =
+ .label = Keyboard Shortcuts
+ .accesskey = K
+
+appmenu-help-get-involved =
+ .label = Get Involved
+ .accesskey = G
+
+appmenu-help-donation =
+ .label = Make a Donation
+ .accesskey = D
+
+appmenu-help-share-feedback =
+ .label = Share Ideas and Feedback
+ .accesskey = S
+
+appmenu-help-enter-troubleshoot-mode2 =
+ .label = Troubleshoot Mode…
+ .accesskey = M
+
+appmenu-help-exit-troubleshoot-mode2 =
+ .label = Turn Troubleshoot Mode Off
+ .accesskey = M
+
+appmenu-help-troubleshooting-info =
+ .label = Troubleshooting Information
+ .accesskey = T
+
+appmenu-help-about-product =
+ .label = About { -brand-short-name }
+ .accesskey = A
+
+## Application Update
+
+appmenuitem-banner-update-downloading =
+ .label = Downloading { -brand-shorter-name } update
+
+appmenuitem-banner-update-available =
+ .label = Update available — download now
+
+appmenuitem-banner-update-manual =
+ .label = Update available — download now
+
+appmenuitem-banner-update-unsupported =
+ .label = Unable to update — system incompatible
+
+appmenuitem-banner-update-restart =
+ .label = Update available — restart now
diff --git a/comm/mail/locales/en-US/messenger/chat-verifySession.ftl b/comm/mail/locales/en-US/messenger/chat-verifySession.ftl
new file mode 100644
index 0000000000..f231654bfa
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/chat-verifySession.ftl
@@ -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/.
+
+verify-window-title = Verify Identity
+
+# Variables:
+# $subject (String) - a human readable identifier for the other side of the verification flow.
+verify-window-subject-title = Verify Identity of { $subject }
+
+verify-dialog =
+ .buttonlabelaccept = They Match
+ .buttonaccesskeyaccept = M
+ .buttonlabelextra2 = They don’t match
+ .buttonaccesskeyextra2 = D
+
+challenge-label = Verify the displayed string matches the display on the other end.
diff --git a/comm/mail/locales/en-US/messenger/chat.ftl b/comm/mail/locales/en-US/messenger/chat.ftl
new file mode 100644
index 0000000000..49573e7d58
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/chat.ftl
@@ -0,0 +1,47 @@
+# 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/.
+
+chat-joining-chat-icon2 =
+ .alt = Joining chat
+
+chat-left-chat-icon2 =
+ .alt = Left chat
+
+chat-participant-owner-role-icon2 =
+ .alt = Owner
+
+chat-participant-administrator-role-icon2 =
+ .alt = Administrator
+
+chat-participant-moderator-role-icon2 =
+ .alt = Moderator
+
+chat-participant-voiced-role-icon2 =
+ .alt = Participant can post messages
+
+chat-verify-identity =
+ .label = Verify Identity
+ .accesskey = I
+
+chat-identity-verified =
+ .label = Identity already verified
+
+chat-buddy-identity-status = Encryption Trust
+chat-buddy-identity-status-verified = Verified
+chat-buddy-identity-status-unverified = Unverified
+
+## Conversation invite notification box
+
+# This string appears in a notification bar at the top of the Contacts window
+# when someone invited the user to a multi user chat conversation, to request
+# the user to confirm they want to join the chat.
+# Variables:
+# $conversation (String) - Name of the conversation the user is invited to.
+chat-conv-invite-label = You have been invited to chat in { $conversation }
+chat-conv-invite-accept =
+ .label = Accept
+ .accesskey = A
+chat-conv-invite-deny =
+ .label = Reject
+ .accesskey = R
diff --git a/comm/mail/locales/en-US/messenger/compactFoldersDialog.ftl b/comm/mail/locales/en-US/messenger/compactFoldersDialog.ftl
new file mode 100644
index 0000000000..322a78ec76
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/compactFoldersDialog.ftl
@@ -0,0 +1,22 @@
+# 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/.
+
+compact-dialog-window-title =
+ .title = Compact folders
+
+compact-dialog =
+ .buttonlabelaccept = Compact now
+ .buttonaccesskeyaccept = C
+ .buttonlabelcancel = Remind me later
+ .buttonaccesskeycancel = R
+ .buttonlabelextra1 = Learn more…
+ .buttonaccesskeyextra1 = L
+
+# Variables:
+# $data (String): The amount of space to be freed, formatted byte, MB, GB, etc., based on the size.
+compact-dialog-message = { -brand-short-name } needs to do regular file maintenance to improve the performance of your mail folders. This will recover { $data } of disk space without changing your messages. To let { -brand-short-name } do this automatically in the future without asking, check the box below before choosing ‘{ compact-dialog.buttonlabelaccept }’.
+
+compact-dialog-never-ask-checkbox =
+ .label = Compact folders automatically in the future
+ .accesskey = a
diff --git a/comm/mail/locales/en-US/messenger/exportDialog.ftl b/comm/mail/locales/en-US/messenger/exportDialog.ftl
new file mode 100644
index 0000000000..554e627e34
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/exportDialog.ftl
@@ -0,0 +1,22 @@
+# 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/.
+
+export-dialog-brand-name = { -brand-product-name }
+
+export-dialog-title = Export
+
+export-dialog =
+ .buttonlabelaccept = Next
+
+export-dialog-button-finish = Finish
+
+export-dialog-file-picker = Export to a zip file
+
+export-dialog-description1 = Export mail accounts, mail messages, address books, settings to a zip file.
+
+export-dialog-desc2 = When needed, you can import the zip file to restore your profile.
+
+export-dialog-exporting = Exporting…
+
+export-dialog-exported = Exported!
diff --git a/comm/mail/locales/en-US/messenger/extensionPermissions.ftl b/comm/mail/locales/en-US/messenger/extensionPermissions.ftl
new file mode 100644
index 0000000000..10caa835e6
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/extensionPermissions.ftl
@@ -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/.
+
+## Extension permission description keys are derived from permission names.
+## Permissions for which the message has been changed and the key updated
+## must have a corresponding entry in the `PERMISSION_L10N_ID_OVERRIDES` map.
+
+webext-perms-description-accountsFolders=Create, rename, or delete your mail account folders
+webext-perms-description-accountsIdentities=Create, modify or delete your mail account identities
+webext-perms-description-accountsRead = See your mail accounts, their identities and their folders
+webext-perms-description-addressBooks=Read and modify your address books and contacts
+webext-perms-description-compose=Read and modify your email messages as you compose and send them
+webext-perms-description-compose-send=Send composed email messages on your behalf
+webext-perms-description-compose-save=Save composed email messages as drafts or templates
+webext-perms-description-experiment=Have full, unrestricted access to { -brand-short-name }, and your computer
+webext-perms-description-messagesImport=Import messages into Thunderbird
+webext-perms-description-messagesModify=Read and modify your email messages as they are displayed to you
+webext-perms-description-messagesMove = Copy or move your email messages (including moving them to the trash folder)
+webext-perms-description-messagesDelete=Permanently delete your email messages
+webext-perms-description-messagesRead=Read your email messages and mark or tag them
+webext-perms-description-messagesTags=Create, modify and delete message tags
+webext-perms-description-sensitiveDataUpload=Transfer sensitive user data (if access has been granted) to a remote server for further processing
diff --git a/comm/mail/locales/en-US/messenger/extensions/popup.ftl b/comm/mail/locales/en-US/messenger/extensions/popup.ftl
new file mode 100644
index 0000000000..0e6908ef87
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/extensions/popup.ftl
@@ -0,0 +1,14 @@
+# 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/.
+
+close-shortcut =
+ .key = w
+
+# Variables:
+# $title (String): the title of the popup window
+extension-popup-title = { PLATFORM() ->
+ [macos] { $title }
+ *[other] { $title } - { -brand-full-name }
+}
+extension-popup-default-title = { -brand-full-name }
diff --git a/comm/mail/locales/en-US/messenger/extensionsUI.ftl b/comm/mail/locales/en-US/messenger/extensionsUI.ftl
new file mode 100644
index 0000000000..9a6ac822b8
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/extensionsUI.ftl
@@ -0,0 +1,11 @@
+# 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/.
+
+webext-experiment-warning = Malicious add-ons can steal your private information or compromise your computer. Only install this add-on if you trust the source.
+
+webext-perms-learn-more = Learn more
+
+# Variables:
+# $addonName (String): localized named of the extension that was just installed.
+addon-post-install-message = { $addonName } was added.
diff --git a/comm/mail/locales/en-US/messenger/firefoxAccounts.ftl b/comm/mail/locales/en-US/messenger/firefoxAccounts.ftl
new file mode 100644
index 0000000000..669fd166f3
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/firefoxAccounts.ftl
@@ -0,0 +1,32 @@
+# 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/.
+
+# “Account†can be localized, “Firefox†must be treated as a brand,
+# and kept in English.
+-fxaccount-brand-name =
+ { $capitalization ->
+ [sentence] Firefox account
+ *[title] Firefox Account
+ }
+
+## These strings are shown in a desktop notification after the user requests we resend a verification email.
+
+fxa-verification-sent-title = Verification Sent
+# Variables:
+# $userEmail (String) - Email address of user's Firefox Account.
+fxa-verification-sent-body = A verification link has been sent to { $userEmail }.
+fxa-verification-not-sent-title = Unable to Send Verification
+fxa-verification-not-sent-body = We are unable to send a verification mail at this time, please try again later.
+
+## These strings are shown in a confirmation dialog when the user chooses to sign out.
+
+fxa-signout-dialog-title = Sign out of { -fxaccount-brand-name(capitalization: "sentence") }?
+fxa-signout-dialog-body = Synced data will remain in your account.
+fxa-signout-dialog-button = Sign out
+
+## These strings are shown in a confirmation dialog when the user chooses to stop syncing.
+
+sync-disconnect-dialog-title = Disconnect?
+sync-disconnect-dialog-body = { -brand-product-name } will stop syncing but won’t delete any of your data on this device.
+sync-disconnect-dialog-button = Disconnect
diff --git a/comm/mail/locales/en-US/messenger/flatpak.ftl b/comm/mail/locales/en-US/messenger/flatpak.ftl
new file mode 100644
index 0000000000..b96d09dfe3
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/flatpak.ftl
@@ -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/.
+
+### These messages are used by the Thunderbird Linux Flatpak "desktop" file.
+### An end user will see them associated with the application launcher icon
+
+# This is the label on the icon
+flatpak-desktop-name = { -brand-short-name }
+
+# Appears as a tooltip when hovering over application menu entry
+flatpak-desktop-comment = Send and receive mail with { -brand-product-name }
+
+# A generic description of Thunderbird
+flatpak-desktop-generic-name = Mail Client
+
+## Actions Section
+## These are alternative ways of starting Thunderbird, such as open the compose
+## window to write a message. Visible in a context menu after right clicking a
+## Thunderbird taskbar icon, possibly other places depending on the environment.
+
+flatpak-desktop-action-compose = Write New Message
+
+flatpak-desktop-action-addressbook = Open the Address Book
+
+flatpak-desktop-action-calendar = Open the Calendar
+
+flatpak-desktop-action-keymanager = Open the OpenPGP Key Manager
diff --git a/comm/mail/locales/en-US/messenger/folderprops.ftl b/comm/mail/locales/en-US/messenger/folderprops.ftl
new file mode 100644
index 0000000000..8610bf91d0
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/folderprops.ftl
@@ -0,0 +1,9 @@
+# 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/.
+
+## Quota tab
+
+# Variables:
+# $percent (Number) - Usage percentage of the assigned IMAP quota.
+quota-percent-used = { $percent }% full
diff --git a/comm/mail/locales/en-US/messenger/importDialog.ftl b/comm/mail/locales/en-US/messenger/importDialog.ftl
new file mode 100644
index 0000000000..679d02e0a1
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/importDialog.ftl
@@ -0,0 +1,30 @@
+# 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/.
+
+# Short name of the import module
+thunderbird-import-name = Thunderbird
+
+# Description of the import module
+thunderbird-import-description = Import mail from a Thunderbird profile directory.
+
+import-from-thunderbird-zip =
+ .label = Thunderbird (exported profile backup; zip file smaller than 2GB)
+ .accesskey = Z
+
+import-from-thunderbird-dir =
+ .label = Thunderbird (profile folder)
+ .accesskey = T
+
+import-select-profile-zip = Select a zipped profile directory
+
+import-select-profile-dir = Select a profile directory
+
+zip-file-too-big-title = Zip File Too Big
+
+zip-file-too-big-message = The selected zip file is larger than 2GB. Please extract it first, then import from the extracted folder instead.
+
+wizardpage-failed =
+ .label = Import Failed
+
+wizardpage-failed-message = Import failed unexpectedly, more information may be available in the Error Console.
diff --git a/comm/mail/locales/en-US/messenger/mailWidgets.ftl b/comm/mail/locales/en-US/messenger/mailWidgets.ftl
new file mode 100644
index 0000000000..0df738f354
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/mailWidgets.ftl
@@ -0,0 +1,14 @@
+# 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/.
+
+apply-current-view-to-menu =
+ .label = Apply current view to…
+
+threadpane-apply-changes-prompt-title = Apply Changes?
+# Variables:
+# $name (String): The name of the folder to apply to.
+threadpane-apply-changes-prompt-no-children-text = Apply the current folder’s view to { $name }?
+# Variables:
+# $name (String): The name of the folder to apply to.
+threadpane-apply-changes-prompt-with-children-text = Apply the current folder’s view to { $name } and its children?
diff --git a/comm/mail/locales/en-US/messenger/menubar.ftl b/comm/mail/locales/en-US/messenger/menubar.ftl
new file mode 100644
index 0000000000..3da55dd14a
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/menubar.ftl
@@ -0,0 +1,161 @@
+# 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/.
+
+toolbar-context-menu-menu-bar =
+ .toolbarname = Menu Bar
+ .accesskey = M
+
+## Tools Menu
+
+menu-tools-settings =
+ .label = Settings
+ .accesskey = e
+
+menu-addons-and-themes =
+ .label = Add-ons and Themes
+ .accesskey = A
+
+## Help Menu
+
+menu-help-help-title =
+ .label = Help
+ .accesskey = H
+
+menu-help-get-help =
+ .label = Get Help
+ .accesskey = H
+
+menu-help-explore-features =
+ .label = Explore Features
+ .accesskey = F
+
+menu-help-shortcuts =
+ .label = Keyboard Shortcuts
+ .accesskey = K
+
+menu-help-get-involved =
+ .label = Get Involved
+ .accesskey = G
+
+menu-help-donation =
+ .label = Make a Donation
+ .accesskey = D
+
+menu-help-share-feedback =
+ .label = Share Ideas and Feedback
+ .accesskey = S
+
+menu-help-enter-troubleshoot-mode =
+ .label = Troubleshoot Mode…
+ .accesskey = M
+
+menu-help-exit-troubleshoot-mode =
+ .label = Turn Troubleshoot Mode Off
+ .accesskey = M
+
+menu-help-troubleshooting-info =
+ .label = Troubleshooting Information
+ .accesskey = T
+
+menu-help-about-product =
+ .label = About { -brand-short-name }
+ .accesskey = A
+
+# These menu-quit strings are only used on Windows and Linux.
+menu-quit =
+ .label =
+ { PLATFORM() ->
+ [windows] Exit
+ *[other] Quit
+ }
+ .accesskey =
+ { PLATFORM() ->
+ [windows] x
+ *[other] Q
+ }
+
+# This menu-quit-mac string is only used on macOS.
+menu-quit-mac =
+ .label = Quit { -brand-shorter-name }
+
+# Localization note: Do not translate unless your locale's keyboard layout
+# does not include this key, as it determines the keyboard shortcut for
+# shutting down the application.
+quit-app-shortcut =
+ .key = Q
+
+## Mail Toolbar
+
+toolbar-junk-button =
+ .label = Junk
+ .tooltiptext = Mark the selected messages as junk
+toolbar-not-junk-button =
+ .label = Not Junk
+ .tooltiptext = Mark the selected messages as not junk
+toolbar-delete-button =
+ .label = Delete
+ .tooltiptext = Delete selected messages or folder
+toolbar-undelete-button =
+ .label = Undelete
+ .tooltiptext = Undelete selected messages
+
+## View
+
+menu-view-repair-text-encoding =
+ .label = Repair Text Encoding
+ .accesskey = c
+
+## View / Folders
+
+menu-view-folders-toggle-header =
+ .label = Folder Pane Header
+ .accesskey = F
+
+## View / Layout
+
+menu-view-toggle-thread-pane-header =
+ .label = Message List Header
+ .accesskey = H
+
+menu-font-size-label =
+ .label = Font Size
+ .accesskey = o
+
+menuitem-font-size-enlarge =
+ .label = Increase Font Size
+ .accesskey = I
+
+menuitem-font-size-reduce =
+ .label = Reduce Font Size
+ .accesskey = D
+
+menuitem-font-size-reset =
+ .label = Reset Font Size
+ .accesskey = R
+
+mail-uidensity-label =
+ .label = Density
+ .accesskey = D
+
+mail-uidensity-compact =
+ .label = Compact
+ .accesskey = C
+
+mail-uidensity-default =
+ .label = Default
+ .accesskey = D
+
+mail-uidensity-relaxed =
+ .label = Relaxed
+ .accesskey = R
+
+menu-spaces-toolbar-button =
+ .label = Spaces Toolbar
+ .accesskey = S
+
+## File
+
+file-new-newsgroup-account =
+ .label = Newsgroup Account…
+ .accesskey = N
diff --git a/comm/mail/locales/en-US/messenger/messageheader/headerFields.ftl b/comm/mail/locales/en-US/messenger/messageheader/headerFields.ftl
new file mode 100644
index 0000000000..e74cc5a865
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/messageheader/headerFields.ftl
@@ -0,0 +1,69 @@
+# 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/.
+
+## Header lists
+
+message-header-to-list-name = To
+
+message-header-from-list-name = From
+
+message-header-sender-list-name = Sender
+
+message-header-reply-to-list-name = Reply to
+
+message-header-cc-list-name = Cc
+
+message-header-bcc-list-name = Bcc
+
+message-header-newsgroups-list-name = Newsgroups
+
+message-header-followup-to-list-name = Followup to
+
+message-header-tags-list-name = Tags
+
+## Other message headers.
+## The field-separator is for screen readers to separate the field name from the field value.
+
+message-header-author-field = Author<span data-l10n-name="field-separator">:</span>
+
+message-header-organization-field = Organization<span data-l10n-name="field-separator">:</span>
+
+message-header-subject-field = Subject<span data-l10n-name="field-separator">:</span>
+
+message-header-date-field = Date<span data-l10n-name="field-separator">:</span>
+
+message-header-user-agent-field = User agent<span data-l10n-name="field-separator">:</span>
+
+message-header-references-field = References<span data-l10n-name="field-separator">:</span>
+
+message-header-message-id-field = Message ID<span data-l10n-name="field-separator">:</span>
+
+message-header-in-reply-to-field = In reply to<span data-l10n-name="field-separator">:</span>
+
+message-header-website-field = Website<span data-l10n-name="field-separator">:</span>
+
+# An additional email header field that the user has chosen to display. Unlike
+# the other headers, the name of this header is not expected to be localised
+# because it is generated from the raw field name found in the email header.
+# $fieldName (String) - The field name.
+message-header-custom-field = { $fieldName }<span data-l10n-name="field-separator">:</span>
+
+##
+
+message-header-address-in-address-book-icon2 =
+ .alt = In the Address Book
+
+message-header-address-not-in-address-book-icon2 =
+ .alt = Not in the Address Book
+
+message-header-address-not-in-address-book-button =
+ .title = Save this address in the Address Book
+
+message-header-address-in-address-book-button =
+ .title = Edit contact
+
+message-header-field-show-more = More
+ .title = Show all recipients
+
+message-ids-field-show-all = Show all
diff --git a/comm/mail/locales/en-US/messenger/messenger.ftl b/comm/mail/locales/en-US/messenger/messenger.ftl
new file mode 100644
index 0000000000..1ef66fb6a4
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/messenger.ftl
@@ -0,0 +1,484 @@
+# 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/.
+
+## Window controls
+
+messenger-window-minimize-button =
+ .tooltiptext = Minimize
+messenger-window-maximize-button =
+ .tooltiptext = Maximize
+messenger-window-restore-down-button =
+ .tooltiptext = Restore Down
+messenger-window-close-button =
+ .tooltiptext = Close
+
+# Variables:
+# $count (Number) - Number of unread messages.
+unread-messages-os-tooltip =
+ { $count ->
+ [one] 1 unread message
+ *[other] { $count } unread messages
+ }
+
+about-rights-notification-text = { -brand-short-name } is free and open source software, built by a community of thousands from all over the world.
+
+## Content tabs
+
+content-tab-page-loading-icon =
+ .alt = The page is loading
+content-tab-security-high-icon =
+ .alt = The connection is secure
+content-tab-security-broken-icon =
+ .alt = The connection is not secure
+
+# Back
+
+# Variables
+# $shortcut (String) - A keyboard shortcut for the Go Back command.
+content-tab-menu-back =
+ .tooltiptext = Go back one page ({ $shortcut })
+ .aria-label = Back
+ .accesskey = B
+
+# This menuitem is only visible on macOS
+content-tab-menu-back-mac =
+ .label = Back
+ .accesskey = B
+
+# Forward
+
+# Variables
+# $shortcut (String) - A keyboard shortcut for the Go Forward command.
+content-tab-menu-forward =
+ .tooltiptext = Go forward one page ({ $shortcut })
+ .aria-label = Forward
+ .accesskey = F
+
+# This menuitem is only visible on macOS
+content-tab-menu-forward-mac =
+ .label = Forward
+ .accesskey = F
+
+# Reload
+
+content-tab-menu-reload =
+ .tooltiptext = Reload page
+ .aria-label = Reload
+ .accesskey = R
+
+# This menuitem is only visible on macOS
+content-tab-menu-reload-mac =
+ .tooltiptext = Reload page
+ .label = Reload
+ .accesskey = R
+
+# Stop
+
+content-tab-menu-stop =
+ .tooltiptext = Stop page loading
+ .aria-label = Stop
+ .accesskey = S
+
+# This menuitem is only visible on macOS
+content-tab-menu-stop-mac =
+ .tooltiptext = Stop page loading
+ .label = Stop
+ .accesskey = S
+
+## Toolbar
+
+addons-and-themes-toolbarbutton =
+ .label = Add-ons and Themes
+ .tooltiptext = Manage your add-ons
+
+quick-filter-toolbarbutton =
+ .label = Quick Filter
+ .tooltiptext = Filter messages
+
+redirect-msg-button =
+ .label = Redirect
+ .tooltiptext = Redirect selected message
+
+## Folder Pane
+
+folder-pane-toolbar =
+ .toolbarname = Folder Pane Toolbar
+ .accesskey = F
+
+folder-pane-toolbar-options-button =
+ .tooltiptext = Folder Pane Options
+
+folder-pane-header-label = Folders
+
+## Folder Toolbar Header Popup
+
+folder-toolbar-hide-toolbar-toolbarbutton =
+ .label = Hide Toolbar
+ .accesskey = H
+
+show-all-folders-label =
+ .label = All Folders
+ .accesskey = A
+
+show-unread-folders-label =
+ .label = Unread Folders
+ .accesskey = n
+
+show-favorite-folders-label =
+ .label = Favorite Folders
+ .accesskey = F
+
+show-smart-folders-label =
+ .label = Unified Folders
+ .accesskey = U
+
+show-recent-folders-label =
+ .label = Recent Folders
+ .accesskey = R
+
+show-tags-folders-label =
+ .label = Tags
+ .accesskey = T
+
+folder-toolbar-toggle-folder-compact-view =
+ .label = Compact View
+ .accesskey = C
+
+## File Menu
+
+menu-file-save-as-file =
+ .label = File…
+ .accesskey = F
+
+## Edit Menu
+
+menu-edit-delete-folder =
+ .label = Delete Folder
+ .accesskey = D
+
+menu-edit-unsubscribe-newsgroup =
+ .label = Unsubscribe Newsgroup
+ .accesskey = b
+
+# Variables:
+# $count (Number) - Number of selected messages.
+menu-edit-delete-messages =
+ .label =
+ { $count ->
+ [one] Delete Message
+ *[other] Delete Selected Messages
+ }
+ .accesskey = D
+
+# Variables:
+# $count (Number) - Number of selected messages.
+menu-edit-undelete-messages =
+ .label =
+ { $count ->
+ [one] Undelete Message
+ *[other] Undelete Selected Messages
+ }
+ .accesskey = d
+
+menu-edit-properties =
+ .label = Properties
+ .accesskey = o
+
+menu-edit-folder-properties =
+ .label = Folder Properties
+ .accesskey = o
+
+menu-edit-newsgroup-properties =
+ .label = Newsgroup Properties
+ .accesskey = o
+
+## Message Menu
+
+redirect-msg-menuitem =
+ .label = Redirect
+ .accesskey = D
+
+## AppMenu
+
+appmenu-save-as-file =
+ .label = File…
+
+appmenu-settings =
+ .label = Settings
+
+appmenu-addons-and-themes =
+ .label = Add-ons and Themes
+
+## Context menu
+
+context-menu-redirect-msg =
+ .label = Redirect
+
+# This menu item is for canceling an NNTP message
+context-menu-cancel-msg =
+ .label = Cancel Message
+
+# Variables:
+# $count (Number) - Number of selected messages.
+mail-context-delete-messages =
+ .label =
+ { $count ->
+ [one] Delete Message
+ *[other] Delete Selected Messages
+ }
+
+# Variables:
+# $count (Number) - Number of selected messages.
+mail-context-undelete-messages =
+ .label =
+ { $count ->
+ [one] Undelete Message
+ *[other] Undelete Selected Messages
+ }
+
+context-menu-decrypt-to-folder2 =
+ .label = Create Decrypted Copy In
+ .accesskey = y
+
+## Message header pane
+
+other-action-redirect-msg =
+ .label = Redirect
+
+message-header-msg-flagged =
+ .title = Starred
+ .aria-label = Starred
+
+# Variables:
+# $address (String) - The email address of the recipient this picture belongs to.
+message-header-recipient-avatar =
+ .alt = Profile picture of { $address }.
+
+## Message header cutomize panel
+
+message-header-customize-panel-title = Message Header Settings
+
+message-header-customize-button-style =
+ .value = Button style
+ .accesskey = B
+
+message-header-button-style-default =
+ .label = Icons and text
+
+message-header-button-style-text =
+ .label = Text
+
+message-header-button-style-icons =
+ .label = Icons
+
+message-header-show-sender-full-address =
+ .label = Always show sender’s full address
+ .accesskey = f
+
+message-header-show-sender-full-address-description = The email address will be shown underneath the display name.
+
+message-header-show-recipient-avatar =
+ .label = Show sender’s profile picture
+ .accesskey = p
+
+message-header-show-big-avatar =
+ .label = Larger profile picture
+ .accesskey = g
+
+message-header-hide-label-column =
+ .label = Hide labels column
+ .accesskey = l
+
+message-header-large-subject =
+ .label = Large subject
+ .accesskey = s
+
+message-header-all-headers =
+ .label = Show all headers
+ .accesskey = a
+
+## Action Button Context Menu
+
+toolbar-context-menu-manage-extension =
+ .label = Manage Extension
+ .accesskey = E
+toolbar-context-menu-remove-extension =
+ .label = Remove Extension
+ .accesskey = v
+
+## Add-on removal warning
+
+# Variables:
+# $name (String): The name of the add-on that will be removed.
+addon-removal-title = Remove { $name }?
+addon-removal-confirmation-button = Remove
+# Variables:
+# $name (String): The name of the add-on that will be removed.
+addon-removal-confirmation-message = Remove { $name } as well as its configuration and data from { -brand-short-name }?
+
+caret-browsing-prompt-title = Caret Browsing
+caret-browsing-prompt-text = Pressing F7 turns Caret Browsing on or off. This feature places a moveable cursor within some content, allowing you to select text with the keyboard. Do you want to turn Caret Browsing on?
+caret-browsing-prompt-check-text = Do not ask again.
+
+repair-text-encoding-button =
+ .label = Repair Text Encoding
+ .tooltiptext = Guess correct text encoding from message content
+
+## no-reply handling
+
+no-reply-title = Reply Not Supported
+# Variables:
+# $email (String) - Email address the reply will be sent to. Example: "noreply@example.com"
+no-reply-message = The reply address ({ $email }) does not appear to be a monitored address. Messages to this address will likely not be read by anyone.
+no-reply-reply-anyway-button = Reply Anyway
+
+## error messages
+
+# Variables:
+# $failures (Number) - Number of messages that could not be decrypted.
+# $total (Number) - Total number of messages that were attempted to be decrypted.
+decrypt-and-copy-failures-multiple =
+ { $failures ->
+ [one] { $failures } of { $total } messages could not be decrypted and was not copied.
+ *[other] { $failures } of { $total } messages could not be decrypted and were not copied.
+ }
+
+## Spaces toolbar
+
+spaces-toolbar-element =
+ .toolbarname = Spaces Toolbar
+ .aria-label = Spaces Toolbar
+ .aria-description = Vertical toolbar for switching between different spaces. Use the arrow keys to navigate the available buttons.
+
+spaces-toolbar-button-mail2 =
+ .title = Mail
+
+spaces-toolbar-button-address-book2 =
+ .title = Address Book
+
+spaces-toolbar-button-calendar2 =
+ .title = Calendar
+
+spaces-toolbar-button-tasks2 =
+ .title = Tasks
+
+spaces-toolbar-button-chat2 =
+ .title = Chat
+
+spaces-toolbar-button-overflow =
+ .title = More spaces…
+
+spaces-toolbar-button-settings2 =
+ .title = Settings
+
+spaces-toolbar-button-hide =
+ .title = Hide Spaces Toolbar
+
+spaces-toolbar-button-show =
+ .title = Show Spaces Toolbar
+
+spaces-context-new-tab-item =
+ .label = Open in new tab
+
+spaces-context-new-window-item =
+ .label = Open in new window
+
+# Variables:
+# $tabName (String) - The name of the tab this item will switch to.
+spaces-context-switch-tab-item =
+ .label = Switch to { $tabName }
+
+settings-context-open-settings-item2 =
+ .label = Settings
+
+settings-context-open-account-settings-item2 =
+ .label = Account Settings
+
+settings-context-open-addons-item2 =
+ .label = Add-ons and Themes
+
+## Spaces toolbar pinned tab menupopup
+
+spaces-toolbar-pinned-tab-button =
+ .tooltiptext = Spaces Menu
+
+spaces-pinned-button-menuitem-mail2 =
+ .label = { spaces-toolbar-button-mail2.title }
+
+spaces-pinned-button-menuitem-address-book2 =
+ .label = { spaces-toolbar-button-address-book2.title }
+
+spaces-pinned-button-menuitem-calendar2 =
+ .label = { spaces-toolbar-button-calendar2.title }
+
+spaces-pinned-button-menuitem-tasks2 =
+ .label = { spaces-toolbar-button-tasks2.title }
+
+spaces-pinned-button-menuitem-chat2 =
+ .label = { spaces-toolbar-button-chat2.title }
+
+spaces-pinned-button-menuitem-settings2 =
+ .label = { spaces-toolbar-button-settings2.title }
+
+spaces-pinned-button-menuitem-show =
+ .label = { spaces-toolbar-button-show.title }
+
+# Variables:
+# $count (Number) - Number of unread messages.
+chat-button-unread-messages = { $count }
+ .title = { $count ->
+ [one] One unread message
+ *[other] { $count } unread messages
+ }
+
+## Spaces toolbar customize panel
+
+menuitem-customize-label =
+ .label = Customize…
+
+spaces-customize-panel-title = Spaces Toolbar Settings
+
+spaces-customize-background-color = Background color
+
+spaces-customize-icon-color = Button color
+
+# The background color used on the buttons of the spaces toolbar when they are
+# `current`, meaning the related space/tab is active and visible.
+spaces-customize-accent-background-color = Selected button background color
+
+# The icon color used on the buttons of the spaces toolbar when they are
+# `current`, meaning the related space/tab is active and visible.
+spaces-customize-accent-text-color = Selected button color
+
+spaces-customize-button-restore = Restore Defaults
+ .accesskey = R
+
+customize-panel-button-save = Done
+ .accesskey = D
+
+## Quick Filter Bar
+
+# The label to display for the "View... Toolbars..." menu item that controls
+# whether the quick filter bar is visible.
+quick-filter-bar-toggle =
+ .label = Quick Filter Bar
+ .accesskey = Q
+
+# This is the key used to show the quick filter bar.
+# This should match quick-filter-bar-textbox-shortcut in about3Pane.ftl.
+quick-filter-bar-show =
+ .key = k
+
+## OpenPGP
+
+openpgp-forget = Forget OpenPGP passphrases
+
+## Quota panel.
+
+# Variables:
+# $percent (Number) - Usage percentage of the assigned IMAP quota.
+# $usage (String) - Current quota usage (may include unit)
+# $limit (String) - Current quota limit (may include unit)
+quota-panel-percent-used = { $percent }% full
+ .title = IMAP quota: { $usage } used of { $limit } total
diff --git a/comm/mail/locales/en-US/messenger/messengercompose/messengercompose.ftl b/comm/mail/locales/en-US/messenger/messengercompose/messengercompose.ftl
new file mode 100644
index 0000000000..8d7c39cdc4
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/messengercompose/messengercompose.ftl
@@ -0,0 +1,492 @@
+# 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/.
+
+## Send Format
+
+compose-send-format-menu =
+ .label = Sending Format
+ .accesskey = F
+
+compose-send-auto-menu-item =
+ .label = Automatic
+ .accesskey = A
+
+compose-send-both-menu-item =
+ .label = Both HTML and Plain Text
+ .accesskey = B
+
+compose-send-html-menu-item =
+ .label = Only HTML
+ .accesskey = H
+
+compose-send-plain-menu-item =
+ .label = Only Plain Text
+ .accesskey = P
+
+## Addressing widget
+
+# $type (String) - the type of the addressing row
+remove-address-row-button =
+ .title = Remove the { $type } field
+
+# $type (String) - the type of the addressing row
+# $count (Number) - the number of address pills currently present in the addressing row
+address-input-type-aria-label = { $count ->
+ [0] { $type }
+ [one] { $type } with one address, use left arrow key to focus on it.
+ *[other] { $type } with { $count } addresses, use left arrow key to focus on them.
+}
+
+# $email (String) - the email address
+# $count (Number) - the number of address pills currently present in the addressing row
+pill-aria-label = { $count ->
+ [one] { $email }: press Enter to edit, Delete to remove.
+ *[other] { $email }, 1 of { $count }: press Enter to edit, Delete to remove.
+}
+
+# $email (String) - the email address
+pill-tooltip-invalid-address = { $email } is not a valid email address
+
+# $email (String) - the email address
+pill-tooltip-not-in-address-book = { $email } is not in your address book
+
+pill-action-edit =
+ .label = Edit Address
+ .accesskey = E
+
+# $type (String) - the type of the addressing row, e.g. Cc, Bcc, etc.
+pill-action-select-all-sibling-pills =
+ .label = Select All Addresses in { $type }
+ .accesskey = A
+
+pill-action-select-all-pills =
+ .label = Select All Addresses
+ .accesskey = S
+
+pill-action-move-to =
+ .label = Move to To
+ .accesskey = T
+
+pill-action-move-cc =
+ .label = Move to Cc
+ .accesskey = C
+
+pill-action-move-bcc =
+ .label = Move to Bcc
+ .accesskey = B
+
+pill-action-expand-list =
+ .label = Expand List
+ .accesskey = x
+
+## Attachment widget
+
+ctrl-cmd-shift-pretty-prefix = {
+ PLATFORM() ->
+ [macos] ⇧ ⌘{" "}
+ *[other] Ctrl+Shift+
+}
+
+trigger-attachment-picker-key = A
+toggle-attachment-pane-key = M
+
+menuitem-toggle-attachment-pane =
+ .label = Attachment Pane
+ .accesskey = m
+ .acceltext = { ctrl-cmd-shift-pretty-prefix }{ toggle-attachment-pane-key }
+
+toolbar-button-add-attachment =
+ .label = Attach
+ .tooltiptext = Add an Attachment ({ ctrl-cmd-shift-pretty-prefix }{ trigger-attachment-picker-key })
+
+add-attachment-notification-reminder2 =
+ .label = Add Attachment…
+ .accesskey = A
+ .tooltiptext = { toolbar-button-add-attachment.tooltiptext }
+
+menuitem-attach-files =
+ .label = File(s)…
+ .accesskey = F
+ .acceltext = { ctrl-cmd-shift-pretty-prefix }{ trigger-attachment-picker-key }
+
+context-menuitem-attach-files =
+ .label = Attach File(s)…
+ .accesskey = F
+ .acceltext = { ctrl-cmd-shift-pretty-prefix }{ trigger-attachment-picker-key }
+
+# Note: Do not translate the term 'vCard'.
+context-menuitem-attach-vcard =
+ .label = My vCard
+ .accesskey = C
+
+context-menuitem-attach-openpgp-key =
+ .label = My OpenPGP Public Key
+ .accesskey = K
+
+# $count (Number) - the number of attachments in the attachment bucket
+attachment-bucket-count-value = { $count ->
+ [1] { $count } Attachment
+ *[other] { $count } Attachments
+}
+
+attachment-area-show =
+ .title = Show the attachment pane ({ ctrl-cmd-shift-pretty-prefix }{ toggle-attachment-pane-key })
+
+attachment-area-hide =
+ .title = Hide the attachment pane ({ ctrl-cmd-shift-pretty-prefix }{ toggle-attachment-pane-key })
+
+## Variables:
+## $count (Number) - Number of files being dropped onto the composer.
+
+drop-file-label-attachment = { $count ->
+ [one] Add as attachment
+ *[other] Add as attachments
+}
+
+drop-file-label-inline = { $count ->
+ [one] Insert inline
+ *[other] Insert inline
+}
+
+## Reorder Attachment Panel
+
+move-attachment-first-panel-button =
+ .label = Move First
+move-attachment-left-panel-button =
+ .label = Move Left
+move-attachment-right-panel-button =
+ .label = Move Right
+move-attachment-last-panel-button =
+ .label = Move Last
+
+button-return-receipt =
+ .label = Receipt
+ .tooltiptext = Request a return receipt for this message
+
+## Encryption
+
+encryption-menu =
+ .label = Security
+ .accesskey = c
+
+encryption-toggle =
+ .label = Encrypt
+ .tooltiptext = Use end-to-end encryption for this message
+
+encryption-options-openpgp =
+ .label = OpenPGP
+ .tooltiptext = View or change OpenPGP encryption settings
+
+encryption-options-smime =
+ .label = S/MIME
+ .tooltiptext = View or change S/MIME encryption settings
+
+signing-toggle =
+ .label = Sign
+ .tooltiptext = Use digital signing for this message
+
+menu-openpgp =
+ .label = OpenPGP
+ .accesskey = O
+
+menu-smime =
+ .label = S/MIME
+ .accesskey = S
+
+menu-encrypt =
+ .label = Encrypt
+ .accesskey = E
+
+menu-encrypt-subject =
+ .label = Encrypt Subject
+ .accesskey = B
+
+menu-sign =
+ .label = Digitally Sign
+ .accesskey = i
+
+menu-manage-keys =
+ .label = Key Assistant
+ .accesskey = A
+
+menu-view-certificates =
+ .label = View Certificates Of Recipients
+ .accesskey = V
+
+menu-open-key-manager =
+ .label = Key Manager
+ .accesskey = M
+
+# Variables:
+# $addr (String) - Email address (which related to the currently selected
+# from address) which isn't set up to end-to-end encryption.
+openpgp-key-issue-notification-from =
+ You are not set up to send end-to-end encrypted messages from { $addr }.
+
+# Variables:
+# $addr (String) - Email address with key issues.
+openpgp-key-issue-notification-single = End-to-end encryption requires resolving key issues for { $addr }.
+
+# Variables:
+# $count (Number) - Number of recipients with key issues.
+openpgp-key-issue-notification-multi =
+ { $count ->
+ *[other] End-to-end encryption requires resolving key issues for { $count } recipients.
+ }
+
+# Variables:
+# $addr (String) - mail address with certificate issues.
+smime-cert-issue-notification-single = End-to-end encryption requires resolving certificate issues for { $addr }.
+
+# Variables:
+# $count (Number) - Number of recipients with certificate issues.
+smime-cert-issue-notification-multi =
+ { $count ->
+ *[other] End-to-end encryption requires resolving certificate issues for { $count } recipients.
+ }
+
+key-notification-disable-encryption =
+ .label = Do Not Encrypt
+ .accesskey = D
+ .tooltiptext = Disable end-to-end encryption
+
+key-notification-resolve =
+ .label = Resolve…
+ .accesskey = R
+ .tooltiptext = Open the OpenPGP Key Assistant
+
+can-encrypt-smime-notification =
+ S/MIME end-to-end encryption is possible.
+
+can-encrypt-openpgp-notification =
+ OpenPGP end-to-end encryption is possible.
+
+can-e2e-encrypt-button =
+ .label = Encrypt
+ .accesskey = E
+
+## Addressing Area
+
+to-address-row-label =
+ .value = To
+
+# $key (String) - the shortcut key for this field
+show-to-row-main-menuitem =
+ .label = To Field
+ .accesskey = T
+ .acceltext = { ctrl-cmd-shift-pretty-prefix }{ $key }
+
+# No acceltext should be shown.
+# The label should match the show-to-row-button text.
+show-to-row-extra-menuitem =
+ .label = To
+ .accesskey = T
+
+# $key (String) - the shortcut key for this field
+show-to-row-button = To
+ .title = Show To Field ({ ctrl-cmd-shift-pretty-prefix }{ $key })
+
+
+cc-address-row-label =
+ .value = Cc
+
+# $key (String) - the shortcut key for this field
+show-cc-row-main-menuitem =
+ .label = Cc Field
+ .accesskey = C
+ .acceltext = { ctrl-cmd-shift-pretty-prefix }{ $key }
+
+# No acceltext should be shown.
+# The label should match the show-cc-row-button text.
+show-cc-row-extra-menuitem =
+ .label = Cc
+ .accesskey = C
+
+# $key (String) - the shortcut key for this field
+show-cc-row-button = Cc
+ .title = Show Cc Field ({ ctrl-cmd-shift-pretty-prefix }{ $key })
+
+
+bcc-address-row-label =
+ .value = Bcc
+
+# $key (String) - the shortcut key for this field
+show-bcc-row-main-menuitem =
+ .label = Bcc Field
+ .accesskey = B
+ .acceltext = { ctrl-cmd-shift-pretty-prefix }{ $key }
+
+# No acceltext should be shown.
+# The label should match the show-bcc-row-button text.
+show-bcc-row-extra-menuitem =
+ .label = Bcc
+ .accesskey = B
+
+# $key (String) - the shortcut key for this field
+show-bcc-row-button = Bcc
+ .title = Show Bcc Field ({ ctrl-cmd-shift-pretty-prefix }{ $key })
+
+extra-address-rows-menu-button =
+ .title = Other addressing fields to show
+
+public-recipients-notice-single =
+ Your message has a public recipient. You can avoid disclosing the recipient by using Bcc instead.
+
+# Variables:
+# $count (Number) - the count of addresses in the "To" and "Cc" fields.
+public-recipients-notice-multi = { $count ->
+ *[other] The { $count } recipients in To and Cc will see each other’s address. You can avoid disclosing recipients by using Bcc instead.
+}
+
+many-public-recipients-bcc =
+ .label = Use Bcc Instead
+ .accesskey = U
+
+many-public-recipients-ignore =
+ .label = Keep Recipients Public
+ .accesskey = K
+
+many-public-recipients-prompt-title = Too Many Public Recipients
+
+# $count (Number) - the count of addresses in the public recipients fields.
+many-public-recipients-prompt-msg = { $count ->
+ [one] Your message has a public recipient. This may be a privacy concern. You can avoid this by moving the recipient from To/Cc to Bcc instead.
+ *[other] Your message has { $count } public recipients, who will be able to see each other’s addresses. This may be a privacy concern. You can avoid disclosing recipients by moving recipients from To/Cc to Bcc instead.
+}
+
+many-public-recipients-prompt-cancel = Cancel Sending
+many-public-recipients-prompt-send = Send Anyway
+
+## Notifications
+
+# Variables:
+# $identity (string) - The name of the used identity, most likely an email address.
+compose-missing-identity-warning = A unique identity matching the From address was not found. The message will be sent using the current From field and settings from identity { $identity }.
+
+encrypted-bcc-warning = When sending an encrypted message, recipients in Bcc are not fully hidden. All recipients may be able to identify them.
+
+encrypted-bcc-ignore-button = Understood
+
+auto-disable-e2ee-warning = End-to-end encryption for this message was automatically disabled.
+
+## Editing
+
+# Tools
+
+compose-tool-button-remove-text-styling =
+ .tooltiptext = Remove Text Styling
+
+## Filelink
+
+# A text used in a tooltip of Filelink attachments, whose account has been
+# removed or is unknown.
+cloud-file-unknown-account-tooltip = Uploaded to an unknown Filelink account.
+
+# Placeholder file
+
+# Title for the html placeholder file.
+# $filename - name of the file
+cloud-file-placeholder-title = { $filename } - Filelink Attachment
+
+# A text describing that the file was attached as a Filelink and can be downloaded
+# from the link shown below.
+# $filename - name of the file
+cloud-file-placeholder-intro = The file { $filename } was attached as a Filelink. It can be downloaded from the link below.
+
+# Template
+
+# A line of text describing how many uploaded files have been appended to this
+# message. Emphasis should be on sharing as opposed to attaching. This item is
+# used as a header to a list, hence the colon.
+# Variables:
+# $count (Number) - Number of files.
+cloud-file-count-header = { $count ->
+ [one] I’ve linked { $count } file to this email:
+ *[other] I’ve linked { $count } files to this email:
+}
+
+# A text used in a footer, instructing the reader where to find additional
+# information about the used service provider.
+# $link (string) - html a-tag for a link pointing to the web page of the provider
+cloud-file-service-provider-footer-single = Learn more about { $link }.
+
+# A text used in a footer, instructing the reader where to find additional
+# information about the used service providers. Links for the used providers are
+# split into a comma separated list of the first n-1 providers and a single entry
+# at the end.
+# $firstLinks (string) - comma separated list of html a-tags pointing to web pages
+# of the first n-1 used providers
+# $lastLink (string) - html a-tag pointing the web page of the n-th used provider
+cloud-file-service-provider-footer-multiple = Learn more about { $firstLinks } and { $lastLink }.
+
+# Tooltip for an icon, indicating that the link is protected by a password.
+cloud-file-tooltip-password-protected-link = Password protected link
+
+# Used in a list of stats about a specific file
+# Service - the used service provider to host the file (Filelink Service: BOX.com)
+# Size - the size of the file (Size: 4.2 MB)
+# Link - the link to the file (Link: https://some.provider.com)
+# Expiry Date - stating the date the link will expire (Expiry Date: 12.12.2022)
+# Download Limit - stating the maximum allowed downloads, before the link becomes invalid
+# (Download Limit: 6)
+cloud-file-template-service-name = Filelink Service:
+cloud-file-template-size = Size:
+cloud-file-template-link = Link:
+cloud-file-template-password-protected-link = Password Protected Link:
+cloud-file-template-expiry-date = Expiry Date:
+cloud-file-template-download-limit = Download Limit:
+
+# Messages
+
+cloud-file-connection-error-title = Connection Error
+# Variables:
+# $provider (string) - name of the online storage service that reported the error
+cloud-file-connection-error = { -brand-short-name } is offline. Could not connect to { $provider }.
+
+# Variables:
+# $provider (string) - name of the online storage service that reported the error
+# $filename (string) - name of the file that was uploaded and caused the error
+cloud-file-upload-error-with-custom-message-title = Uploading { $filename } to { $provider } Failed
+
+cloud-file-rename-error-title = Rename Error
+
+# Variables:
+# $provider (string) - name of the online storage service that reported the error
+# $filename (string) - name of the file that was renamed and caused the error
+cloud-file-rename-error = There was a problem renaming { $filename } on { $provider }.
+
+# Variables:
+# $provider (string) - name of the online storage service that reported the error
+# $filename (string) - name of the file that was renamed and caused the error
+cloud-file-rename-error-with-custom-message-title = Renaming { $filename } on { $provider } Failed
+
+# Variables:
+# $provider (string) - name of the online storage service that reported the error
+cloud-file-rename-not-supported = { $provider } does not support renaming already uploaded files.
+
+cloud-file-attachment-error-title = Filelink Attachment Error
+
+# Variables:
+# $filename (string) - name of the file that was renamed and caused the error
+cloud-file-attachment-error = Failed to update the Filelink attachment { $filename }, because its local file has been moved or deleted.
+
+cloud-file-account-error-title = Filelink Account Error
+
+# Variables:
+# $filename (string) - name of the file that was renamed and caused the error
+cloud-file-account-error = Failed to update the Filelink attachment { $filename }, because its Filelink account has been deleted.
+
+## Link Preview
+
+link-preview-title = Link Preview
+link-preview-description = { -brand-short-name } can add an embedded preview when pasting links.
+link-preview-autoadd = Automatically add link previews when possible
+link-preview-replace-now = Add a Link Preview for this link?
+link-preview-yes-replace = Yes
+
+## Dictionary selection popup
+
+spell-add-dictionaries =
+ .label = Add Dictionaries…
+ .accesskey = A
diff --git a/comm/mail/locales/en-US/messenger/migration.ftl b/comm/mail/locales/en-US/messenger/migration.ftl
new file mode 100644
index 0000000000..9690fe9b34
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/migration.ftl
@@ -0,0 +1,10 @@
+# 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/.
+
+migration-progress-header = Getting { -brand-short-name } ready…
+
+## Migration tasks
+
+# These strings are displayed to the user if a migration is taking a long time.
+# They should be short (no more than a handful of words) and in the present tense.
diff --git a/comm/mail/locales/en-US/messenger/multimessageview.ftl b/comm/mail/locales/en-US/messenger/multimessageview.ftl
new file mode 100644
index 0000000000..4dce968837
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/multimessageview.ftl
@@ -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/.
+
+multi-message-window-title =
+ .title = Message Summary
+
+selected-messages-label =
+ .label = Selected Messages
+
+multi-message-archive-button =
+ .label = Archive
+ .tooltiptext = Archive
+
+multi-message-delete-button =
+ .label = Delete
+ .tooltiptext = Delete
diff --git a/comm/mail/locales/en-US/messenger/openpgp/backupKeyPassword.ftl b/comm/mail/locales/en-US/messenger/openpgp/backupKeyPassword.ftl
new file mode 100644
index 0000000000..264ad1aa68
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/backupKeyPassword.ftl
@@ -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/.
+
+set-password-window-title = Choose a password to backup your OpenPGP Key
+
+set-password-legend = Choose a Password
+
+set-password-message = The password you set here protects the OpenPGP secret key backup file that you are about to create. You must set this password to proceed with the backup.
+
+set-password-backup-pw-label = Secret Key backup password:
+
+set-password-backup-pw2-label = Secret Key backup password (again):
+
+set-password-reminder = <b>Important!</b> If you forget your secret key backup password, you will not be able to restore this backup later. Please record it in a safe location.
+
+password-quality-meter = Password quality meter
diff --git a/comm/mail/locales/en-US/messenger/openpgp/changeExpiryDlg.ftl b/comm/mail/locales/en-US/messenger/openpgp/changeExpiryDlg.ftl
new file mode 100644
index 0000000000..58155e866c
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/changeExpiryDlg.ftl
@@ -0,0 +1,19 @@
+# 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/.
+
+openpgp-change-expiry-title = Change Key Expiration
+
+# Variables:
+# $date (String) - Date the key is expiring on.
+info-will-expire = This key is currently configured to expire on { $date }.
+info-already-expired = This key has already expired.
+info-does-not-expire = This key is currently configured to never expire.
+
+info-explanation-1 = <b>After a key expires</b>, it’s no longer possible to use it for encryption or digital signing.
+
+info-explanation-2 = To use this key for a longer period of time, change its expiration date, and then share the public key with your conversation partners again.
+
+expire-no-change-label = Do not change the expiry date
+expire-in-time-label = Key will expire in:
+expire-never-expire-label = Key will never expire
diff --git a/comm/mail/locales/en-US/messenger/openpgp/composeKeyStatus.ftl b/comm/mail/locales/en-US/messenger/openpgp/composeKeyStatus.ftl
new file mode 100644
index 0000000000..cd3abc5f86
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/composeKeyStatus.ftl
@@ -0,0 +1,25 @@
+# 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/.
+
+openpgp-compose-key-status-intro-need-keys = To send an end-to-end encrypted message, you must obtain and accept a public key for each recipient.
+openpgp-compose-key-status-keys-heading = Availability of OpenPGP keys:
+openpgp-compose-key-status-title =
+ .title = OpenPGP Message Security
+openpgp-compose-key-status-recipient =
+ .label = Recipient
+openpgp-compose-key-status-status =
+ .label = Status
+openpgp-compose-key-status-open-details = Manage keys for selected recipient…
+openpgp-recip-good = ok
+openpgp-recip-missing = no key available
+openpgp-recip-none-accepted = no accepted key
+openpgp-compose-general-info-alias = { -brand-short-name} normally requires that the recipient’s public key contains a user ID with a matching email address. This can be overridden by using OpenPGP recipient alias rules.
+openpgp-compose-general-info-alias-learn-more = Learn more
+# Variables:
+# $count (Number) - Number of alias keys for a recipient.
+openpgp-compose-alias-status-direct = { $count ->
+ [one] mapped to an alias key
+ *[other] mapped to {$count} alias keys
+ }
+openpgp-compose-alias-status-error = unusable/unavailable alias key
diff --git a/comm/mail/locales/en-US/messenger/openpgp/keyAssistant.ftl b/comm/mail/locales/en-US/messenger/openpgp/keyAssistant.ftl
new file mode 100644
index 0000000000..ece7c8e127
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/keyAssistant.ftl
@@ -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/.
+
+openpgp-key-assistant-title = OpenPGP Key Assistant
+
+openpgp-key-assistant-rogue-warning = Avoid accepting a counterfeit key. To ensure you have obtained the right key you should verify it. <a data-l10n-name="openpgp-link">Learn more…</a>
+
+## Encryption status
+
+openpgp-key-assistant-recipients-issue-header = Cannot Encrypt
+
+# Variables:
+# $count (Number) - The number of recipients that need attention.
+openpgp-key-assistant-recipients-issue-description =
+ { $count ->
+ [one] To encrypt, you must obtain and accept a usable key for one recipient. <a data-l10n-name="openpgp-link">Learn more…</a>
+ *[other] To encrypt, you must obtain and accept usable keys for { $count } recipients. <a data-l10n-name="openpgp-link">Learn more…</a>
+ }
+
+openpgp-key-assistant-info-alias = { -brand-short-name } normally requires that the recipient’s public key contains a user ID with a matching email address. This can be overridden by using OpenPGP recipient alias rules. <a data-l10n-name="openpgp-link">Learn more…</a>
+
+# Variables:
+# $count (Number) - The number of recipients that need attention.
+openpgp-key-assistant-recipients-description =
+ { $count ->
+ [one] You already have a usable and accepted key for one recipient.
+ *[other] You already have usable and accepted keys for { $count } recipients.
+ }
+
+openpgp-key-assistant-recipients-description-no-issues = This message can be encrypted. You have usable and accepted keys for all recipients.
+
+## Resolve section
+
+# Variables:
+# $recipient (String) - The email address of the recipient needing resolution.
+# $numKeys (Number) - The number of keys.
+openpgp-key-assistant-resolve-title =
+ { $numKeys ->
+ [one] { -brand-short-name } found the following key for { $recipient }.
+ *[other] { -brand-short-name } found the following keys for { $recipient }.
+ }
+
+openpgp-key-assistant-valid-description = Select the key that you want to accept
+
+# Variables:
+# $numKeys (Number) - The number of available keys.
+openpgp-key-assistant-invalid-title =
+ { $numKeys ->
+ [one] The following key cannot be used, unless you obtain an update.
+ *[other] The following keys cannot be used, unless you obtain an update.
+ }
+
+openpgp-key-assistant-no-key-available = No key available.
+
+openpgp-key-assistant-multiple-keys = Multiple keys are available.
+
+# Variables:
+# $count (Number) - The number of unaccepted keys.
+openpgp-key-assistant-key-unaccepted =
+ { $count ->
+ [one] A key is available, but it hasn’t been accepted yet.
+ *[other] Multiple keys are available, but none of them have been accepted yet.
+ }
+
+# Variables:
+# $date (String) - The expiration date of the key.
+openpgp-key-assistant-key-accepted-expired = An accepted key has expired on { $date }.
+
+openpgp-key-assistant-keys-accepted-expired = Multiple accepted keys have expired.
+
+# Variables:
+# $date (String) - The expiration date of the key.
+openpgp-key-assistant-this-key-accepted-expired = This key was previously accepted but expired on { $date }.
+
+# Variables:
+# $date (String) - The expiration date of the key.
+openpgp-key-assistant-key-unaccepted-expired-one =
+ The key expired on { $date }.
+openpgp-key-assistant-key-unaccepted-expired-many =
+ Multiple keys have expired.
+
+openpgp-key-assistant-key-fingerprint = Fingerprint
+
+# Variables:
+# $count (Number) - Number of key sources.
+openpgp-key-assistant-key-source =
+ { $count ->
+ [one] Source
+ *[other] Sources
+ }
+
+openpgp-key-assistant-key-collected-attachment = email attachment
+# Autocrypt is the name of a standard.
+openpgp-key-assistant-key-collected-autocrypt = Autocrypt header
+openpgp-key-assistant-key-collected-keyserver = keyserver
+# Web Key Directory (WKD) is a concept.
+openpgp-key-assistant-key-collected-wkd = Web Key Directory
+# Do not translate GnuPG, it's a name of other software.
+openpgp-key-assistant-key-collected-gnupg = GnuPG keyring
+
+# Variables:
+# $count (Number) - Number of found keys.
+openpgp-key-assistant-keys-has-collected =
+ { $count ->
+ [one] A key was found, but it hasn’t been accepted yet.
+ *[other] Multiple keys were found, but none of them have been accepted yet.
+ }
+
+openpgp-key-assistant-key-rejected = This key has been previously rejected.
+openpgp-key-assistant-key-accepted-other = This key has been previously accepted for a different email address.
+
+# Variables:
+# $recipient (String) - The email address of the recipient needing resolution.
+openpgp-key-assistant-resolve-discover-info =
+ Discover additional or updated keys for { $recipient } online, or import them from a file.
+
+## Discovery section
+
+openpgp-key-assistant-discover-title = Online discovery in progress.
+
+# Variables:
+# $recipient (String) - The email address which we're discovering keys.
+openpgp-key-assistant-discover-keys = Discovering keys for { $recipient }…
+
+# Variables:
+# $recipient (String) - The email address which we're discovering keys.
+openpgp-key-assistant-expired-key-update =
+ An update was found for one of the previously accepted keys for { $recipient }.
+ It can now be used as it is no longer expired.
+
+## Dialog buttons
+
+openpgp-key-assistant-discover-online-button = Discover Public Keys Online…
+
+openpgp-key-assistant-import-keys-button = Import Public Keys From File…
+
+openpgp-key-assistant-issue-resolve-button = Resolve…
+
+openpgp-key-assistant-view-key-button = View Key…
+
+openpgp-key-assistant-recipients-show-button = Show
+
+openpgp-key-assistant-recipients-hide-button = Hide
+
+openpgp-key-assistant-cancel-button = Cancel
+
+openpgp-key-assistant-back-button = Back
+
+openpgp-key-assistant-accept-button = Accept
+
+openpgp-key-assistant-close-button = Close
+
+openpgp-key-assistant-disable-button = Disable Encryption
+
+openpgp-key-assistant-confirm-button = Send Encrypted
+
+# Variables:
+# $date (String) - The key creation date.
+openpgp-key-assistant-key-created = created on { $date }
diff --git a/comm/mail/locales/en-US/messenger/openpgp/keyWizard.ftl b/comm/mail/locales/en-US/messenger/openpgp/keyWizard.ftl
new file mode 100644
index 0000000000..59382143b5
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/keyWizard.ftl
@@ -0,0 +1,198 @@
+# 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/.
+
+# $identity (String) - the email address of the currently selected identity
+key-wizard-dialog-window =
+ .title = Add a Personal OpenPGP Key for { $identity }
+
+key-wizard-dialog =
+ .buttonlabelaccept = Continue
+ .buttonlabelextra1 = Go back
+
+key-wizard-warning = <b>If you have an existing personal key</b> for this email address, you should import it. Otherwise you will not have access to your archives of encrypted emails, nor be able to read incoming encrypted emails from people who are still using your existing key.
+
+key-wizard-learn-more = Learn more
+
+radio-create-key =
+ .label = Create a new OpenPGP Key
+ .accesskey = C
+
+radio-import-key =
+ .label = Import an existing OpenPGP Key
+ .accesskey = I
+
+radio-gnupg-key =
+ .label = Use your external key through GnuPG (e.g. from a smartcard)
+ .accesskey = U
+
+## Generate key section
+
+openpgp-generate-key-title = Generate OpenPGP Key
+
+openpgp-keygen-secret-protection = Secret Key Protection
+
+radio-keygen-no-protection =
+ .label = Unprotected
+radio-keygen-protect-primary-pass =
+ .label = Protect with the Primary Password
+
+radio-keygen-passphrase-protection =
+ .label = Protect with a passphrase:
+openpgp-passphrase-repeat = Confirm the passphrase:
+
+openpgp-generate-key-info = <b>Key generation may take up to several minutes to complete.</b> Do not exit the application while key generation is in progress. Actively browsing or performing disk-intensive operations during key generation will replenish the ‘randomness pool’ and speed-up the process. You will be alerted when key generation is completed.
+
+openpgp-keygen-expiry-title = Key expiry
+
+openpgp-keygen-expiry-description = Define the expiration time of your newly generated key. You can later control the date to extend it if necessary.
+
+radio-keygen-expiry =
+ .label = Key expires in
+ .accesskey = e
+
+radio-keygen-no-expiry =
+ .label = Key does not expire
+ .accesskey = d
+
+openpgp-keygen-days-label =
+ .label = days
+openpgp-keygen-months-label =
+ .label = months
+openpgp-keygen-years-label =
+ .label = years
+
+openpgp-keygen-advanced-title = Advanced settings
+
+openpgp-keygen-advanced-description = Control the advanced settings of your OpenPGP Key.
+
+openpgp-keygen-keytype =
+ .value = Key type:
+ .accesskey = t
+
+openpgp-keygen-keysize =
+ .value = Key size:
+ .accesskey = s
+
+openpgp-keygen-type-rsa =
+ .label = RSA
+
+openpgp-keygen-type-ecc =
+ .label = ECC (Elliptic Curve)
+
+openpgp-keygen-button = Generate key
+
+openpgp-keygen-progress-title = Generating your new OpenPGP Key…
+
+openpgp-keygen-import-progress-title = Importing your OpenPGP Keys…
+
+openpgp-import-success = OpenPGP Keys successfully imported!
+
+openpgp-import-success-title = Complete the import process
+
+openpgp-import-success-description = To start using your imported OpenPGP key for email encryption, close this dialog and access your Account Settings to select it.
+
+openpgp-keygen-confirm =
+ .label = Confirm
+
+openpgp-keygen-dismiss =
+ .label = Cancel
+
+openpgp-keygen-cancel =
+ .label = Cancel process…
+
+openpgp-keygen-import-complete =
+ .label = Close
+ .accesskey = C
+
+openpgp-keygen-missing-username = There is no name specified for the current account. Please enter a value in the field “Your name†in the account settings.
+openpgp-keygen-long-expiry = You cannot create a key that expires in more than 100 years.
+openpgp-keygen-short-expiry = Your key must be valid for at least one day.
+
+openpgp-keygen-ongoing = Key generation already in progress!
+
+openpgp-keygen-error-core = Unable to initialize OpenPGP Core Service
+
+openpgp-keygen-error-failed = OpenPGP Key generation unexpectedly failed
+
+# $key (String) - the ID of the newly generated OpenPGP key
+openpgp-keygen-error-revocation = OpenPGP Key created successfully, but failed to obtain revocation for key { $key }
+
+openpgp-keygen-abort-title = Abort key generation?
+openpgp-keygen-abort = OpenPGP Key generation currently in progress, are you sure you want to cancel it?
+
+# $identity (String) - the name and email address of the currently selected identity
+openpgp-key-confirm = Generate public and secret key for { $identity }?
+
+## Import Key section
+
+openpgp-import-key-title = Import an existing personal OpenPGP Key
+
+openpgp-import-key-legend = Select a previously backed up file.
+
+openpgp-import-key-description = You may import personal keys that were created with other OpenPGP software.
+
+openpgp-import-key-info = Other software might describe a personal key using alternative terms such as your own key, secret key, private key or key pair.
+
+# $count (Number) - the number of keys found in the selected files
+openpgp-import-key-list-amount-2 = { $count ->
+ [one] { -brand-short-name } found one key that can be imported.
+ *[other] { -brand-short-name } found { $count } keys that can be imported.
+}
+
+openpgp-import-key-list-description = Confirm which keys may be treated as your personal keys. Only keys that you created yourself and that show your own identity should be used as personal keys. You can change this option later in the Key Properties dialog.
+
+openpgp-import-key-list-caption = Keys marked to be treated as Personal Keys will be listed in the End-To-End Encryption section. The others will be available inside the Key Manager.
+
+openpgp-import-keep-passphrases =
+ .label = Keep passphrase protection for imported secret keys
+
+openpgp-passphrase-prompt-title = Passphrase required
+
+openpgp-import-key-button =
+ .label = Select File to Import…
+ .accesskey = S
+
+import-key-file = Import OpenPGP Key File
+
+import-key-personal-checkbox =
+ .label = Treat this key as a Personal Key
+
+gnupg-file = GnuPG Files
+
+import-error-file-size = <b>Error!</b> Files larger than 5MB are not supported.
+
+# $error (String) - the reported error from the failed key import method
+import-error-failed = <b>Error!</b> Failed to import file. { $error }
+
+# $error (String) - the reported error from the failed key import method
+openpgp-import-keys-failed = <b>Error!</b> Failed to import keys. { $error }
+
+openpgp-import-identity-label = Identity
+
+openpgp-import-fingerprint-label = Fingerprint
+
+openpgp-import-created-label = Created
+
+openpgp-import-bits-label = Bits
+
+openpgp-import-key-props =
+ .label = Key Properties
+ .accesskey = K
+
+## External Key section
+
+openpgp-external-key-title = External GnuPG Key
+
+openpgp-external-key-description = Configure an external GnuPG key by entering the Key ID
+
+openpgp-external-key-info = In addition, you must use Key Manager to import and accept the corresponding Public Key.
+
+openpgp-external-key-warning = <b>You may configure only one external GnuPG Key.</b> Your previous entry will be replaced.
+
+openpgp-save-external-button = Save key ID
+
+openpgp-external-key-label = Secret Key ID:
+
+openpgp-external-key-input =
+ .placeholder = 123456789341298340
diff --git a/comm/mail/locales/en-US/messenger/openpgp/msgReadStatus.ftl b/comm/mail/locales/en-US/messenger/openpgp/msgReadStatus.ftl
new file mode 100644
index 0000000000..13db8ac6f6
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/msgReadStatus.ftl
@@ -0,0 +1,93 @@
+# 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/.
+
+## Message Header Encryption Button
+
+message-header-show-security-info-key = S
+
+# $type (String) - the shortcut key defined in the message-header-show-security-info-key
+message-security-button =
+ .title = { PLATFORM() ->
+ [macos] Show Message Security (⌃ ⌘ { message-header-show-security-info-key })
+ *[other] Show Message Security (Ctrl+Alt+{ message-header-show-security-info-key })
+ }
+
+openpgp-view-signer-key =
+ .label = View signer key
+openpgp-view-your-encryption-key =
+ .label = View your decryption key
+openpgp-openpgp = OpenPGP
+
+openpgp-no-sig = No Digital Signature
+openpgp-no-sig-info = This message does not include the sender’s digital signature. The absence of a digital signature means that the message could have been sent by someone pretending to have this email address. It is also possible that the message has been altered while in transit over the network.
+openpgp-uncertain-sig = Uncertain Digital Signature
+# Variables:
+# $date (String) - Date with time the signature was made in a short format.
+openpgp-uncertain-sig-with-date = Uncertain Digital Signature - Signed on { $date }
+openpgp-invalid-sig = Invalid Digital Signature
+# Variables:
+# $date (String) - Date with time the signature was made in a short format.
+openpgp-invalid-sig-with-date = Invalid Digital Signature - Signed on { $date }
+openpgp-good-sig = Good Digital Signature
+# Variables:
+# $date (String) - Date with time the signature was made in a short format.
+openpgp-good-sig-with-date = Good Digital Signature - Signed on { $date }
+
+openpgp-sig-uncertain-no-key = This message contains a digital signature, but it is uncertain if it is correct. To verify the signature, you need to obtain a copy of the sender’s public key.
+openpgp-sig-uncertain-uid-mismatch = This message contains a digital signature, but a mismatch was detected. The message was sent from an email address that doesn’t match the signer’s public key.
+openpgp-sig-uncertain-not-accepted = This message contains a digital signature, but you haven’t yet decided if the signer’s key is acceptable to you.
+openpgp-sig-invalid-rejected = This message contains a digital signature, but you have previously decided to reject the signer key.
+openpgp-sig-invalid-technical-problem = This message contains a digital signature, but a technical error was detected. Either the message has been corrupted, or the message has been modified by someone else.
+openpgp-sig-valid-unverified = This message includes a valid digital signature from a key that you have already accepted. However, you have not yet verified that the key is really owned by the sender.
+openpgp-sig-valid-verified = This message includes a valid digital signature from a verified key.
+openpgp-sig-valid-own-key = This message includes a valid digital signature from your personal key.
+
+# Variables:
+# $key (String) - The ID of the OpenPGP key used to create the signature.
+openpgp-sig-key-id = Signer key ID: { $key }
+# Variables:
+# $key (String) - The primary ID of the OpenPGP key used to create the signature.
+# $subkey (String) - A subkey of the primary key was used to create the signature, and this is the ID of that subkey.
+openpgp-sig-key-id-with-subkey-id = Signer key ID: { $key } (Sub key ID: { $subkey })
+
+# Variables:
+# $key (String) - The ID of the user's OpenPGP key used to decrypt the message.
+openpgp-enc-key-id = Your decryption key ID: { $key }
+# Variables:
+# $key (String) - The primary ID of the user's OpenPGP key used to decrypt the message.
+# $subkey (String) - A subkey of the primary key was used to decrypt the message, and this is the ID of that subkey.
+openpgp-enc-key-with-subkey-id = Your decryption key ID: { $key } (Sub key ID: { $subkey })
+
+openpgp-enc-none = Message Is Not Encrypted
+openpgp-enc-none-label = This message was not encrypted before it was sent. Information sent over the Internet without encryption can be seen by other people while in transit.
+
+openpgp-enc-invalid-label = Message Cannot Be Decrypted
+openpgp-enc-invalid = This message was encrypted before it was sent to you, but it cannot be decrypted.
+
+openpgp-enc-clueless = There are unknown problems with this encrypted message.
+
+openpgp-enc-valid-label = Message Is Encrypted
+openpgp-enc-valid = This message was encrypted before it was sent to you. Encryption ensures the message can only be read by the recipients it was intended for.
+
+openpgp-unknown-key-id = Unknown key
+
+openpgp-other-enc-additional-key-ids = In addition, the message was encrypted to the owners of the following keys:
+openpgp-other-enc-all-key-ids = The message was encrypted to the owners of the following keys:
+
+openpgp-message-header-encrypted-ok-icon =
+ .alt = Decryption successful
+openpgp-message-header-encrypted-notok-icon =
+ .alt = Decryption failed
+
+openpgp-message-header-signed-ok-icon =
+ .alt = Good signature
+# Mismatch icon is used for notok state as well
+openpgp-message-header-signed-mismatch-icon =
+ .alt = Bad signature
+openpgp-message-header-signed-unknown-icon =
+ .alt = Unknown signature status
+openpgp-message-header-signed-verified-icon =
+ .alt = Verified signature
+openpgp-message-header-signed-unverified-icon =
+ .alt = Unverified signature
diff --git a/comm/mail/locales/en-US/messenger/openpgp/oneRecipientStatus.ftl b/comm/mail/locales/en-US/messenger/openpgp/oneRecipientStatus.ftl
new file mode 100644
index 0000000000..6d5e858ab6
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/oneRecipientStatus.ftl
@@ -0,0 +1,60 @@
+# 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/.
+
+openpgp-one-recipient-status-title =
+ .title = OpenPGP Message Security
+openpgp-one-recipient-status-status =
+ .label = Status
+openpgp-one-recipient-status-key-id =
+ .label = Key ID
+openpgp-one-recipient-status-created-date =
+ .label = Created
+openpgp-one-recipient-status-expires-date =
+ .label = Expires
+openpgp-one-recipient-status-open-details =
+ .label = Open details and edit acceptance…
+openpgp-one-recipient-status-discover =
+ .label = Discover new or updated key
+
+openpgp-one-recipient-status-instruction1 = To send an end-to-end encrypted message to a recipient, you need to obtain their OpenPGP public key and mark it as accepted.
+openpgp-one-recipient-status-instruction2 = To obtain their public key, import them from email they have sent to you and that includes it. Alternatively, you can try to discover their public key on a directory.
+
+openpgp-key-own = Accepted (personal key)
+openpgp-key-secret-not-personal = Not usable
+openpgp-key-verified = Accepted (verified)
+openpgp-key-unverified = Accepted (unverified)
+openpgp-key-undecided = Not accepted (undecided)
+openpgp-key-rejected = Not accepted (rejected)
+openpgp-key-expired = Expired
+
+# Variables:
+# $key (String) - Recipient email address.
+openpgp-intro = Available public keys for { $key }
+
+# Variables:
+# $kid (String) - Public key id to import.
+openpgp-pubkey-import-id = ID: { $kid }
+# Variables:
+# $fpr (String) - Fingerprint of the public key to import.
+openpgp-pubkey-import-fpr = Fingerprint: { $fpr }
+
+# Variables:
+# $num (Number) - Number of public keys contained in the key file.
+openpgp-pubkey-import-intro =
+ { $num ->
+ [one] The file contains one public key as shown below:
+ *[other] The file contains {$num} public keys as shown below:
+ }
+
+# Variables:
+# $num (Number) - Number of keys to accept.
+openpgp-pubkey-import-accept =
+ { $num ->
+ [one] Do you accept this key for verifying digital signatures and for encrypting messages, for all shown email addresses?
+ *[other] Do you accept these keys for verifying digital signatures and for encrypting messages, for all shown email addresses?
+ }
+
+pubkey-import-button =
+ .buttonlabelaccept = Import
+ .buttonaccesskeyaccept = I
diff --git a/comm/mail/locales/en-US/messenger/openpgp/openpgp-frontend.ftl b/comm/mail/locales/en-US/messenger/openpgp/openpgp-frontend.ftl
new file mode 100644
index 0000000000..88011ce2ab
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/openpgp-frontend.ftl
@@ -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/.
+
+openpgp-manage-keys-openpgp-cmd =
+ .label = OpenPGP Key Manager
+ .accesskey = O
+
+openpgp-ctx-decrypt-open =
+ .label = Decrypt and Open
+ .accesskey = D
+openpgp-ctx-decrypt-save =
+ .label = Decrypt and Save As…
+ .accesskey = C
+openpgp-ctx-import-key =
+ .label = Import OpenPGP Key
+ .accesskey = I
+openpgp-ctx-verify-att =
+ .label = Verify Signature
+ .accesskey = V
+
+openpgp-has-sender-key = This message claims to contain the sender’s OpenPGP public key.
+# Variables:
+# $email (String) - Email address with the problematic public key.
+openpgp-be-careful-new-key =
+ Warning: The new OpenPGP public key in this message differs from the public keys that you previously accepted for { $email }.
+
+openpgp-import-sender-key =
+ .label = Import…
+
+openpgp-search-keys-openpgp =
+ .label = Discover OpenPGP Key
+
+openpgp-missing-signature-key = This message was signed with a key that you don’t yet have.
+
+openpgp-search-signature-key =
+ .label = Discover…
+
+# Don't translate the terms "OpenPGP" and "MS-Exchange"
+openpgp-broken-exchange-opened = This is an OpenPGP message that was apparently corrupted by MS-Exchange and it can’t be repaired because it was opened from a local file. Copy the message into a mail folder to try an automatic repair.
+openpgp-broken-exchange-info = This is an OpenPGP message that was apparently corrupted by MS-Exchange. If the message contents isn’t shown as expected, you can try an automatic repair.
+openpgp-broken-exchange-repair =
+ .label = Repair message
+openpgp-broken-exchange-wait = Please wait…
+
+openpgp-has-nested-encrypted-parts = This message includes additional encrypted parts.
+openpgp-show-encrypted-parts = Decrypt and Show
+
+openpgp-cannot-decrypt-because-mdc =
+ This is an encrypted message that uses an old and vulnerable mechanism.
+ It could have been modified while in transit, with the intention to steal its contents.
+ To prevent this risk, the contents are not shown.
+
+openpgp-cannot-decrypt-because-missing-key =
+ The secret key that is required to decrypt this message is not available.
+
+openpgp-partially-signed =
+ Only a subset of this message was digitally signed using OpenPGP.
+ If you click the verify button, the unprotected parts will be hidden, and the status of the digital signature will be shown.
+
+openpgp-partially-encrypted =
+ Only a subset of this message was encrypted using OpenPGP.
+ The readable parts of the message that are already shown were not encrypted.
+ If you click the decrypt button, the contents of the encrypted parts will be shown.
+
+openpgp-reminder-partial-display = Reminder: The message shown below is only a subset of the original message.
+
+openpgp-partial-verify-button = Verify
+openpgp-partial-decrypt-button = Decrypt
+
+openpgp-unexpected-key-for-you = Warning: This message contains an unknown OpenPGP key that refers to one of your own email addresses. If this isn’t one of your own keys, it could be an attempt to trick other correspondents.
diff --git a/comm/mail/locales/en-US/messenger/openpgp/openpgp.ftl b/comm/mail/locales/en-US/messenger/openpgp/openpgp.ftl
new file mode 100644
index 0000000000..8804e0cd05
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/openpgp/openpgp.ftl
@@ -0,0 +1,856 @@
+
+# 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/.
+
+e2e-intro-description = To send encrypted or digitally signed messages, you need to configure an encryption technology, either OpenPGP or S/MIME.
+e2e-intro-description-more = Select your personal key to enable the use of OpenPGP, or your personal certificate to enable the use of S/MIME. For a personal key or certificate you own the corresponding secret key.
+
+e2e-signing-description = A digital signature allows recipients to verify that the message was sent by you and its content was not changed. Encrypted messages are always signed by default.
+
+e2e-sign-message =
+ .label = Sign unencrypted messages
+ .accesskey = u
+
+e2e-disable-enc =
+ .label = Disable encryption for new messages
+ .accesskey = D
+e2e-enable-enc =
+ .label = Enable encryption for new messages
+ .accesskey = n
+e2e-enable-description = You will be able to disable encryption for individual messages.
+
+e2e-advanced-section = Advanced settings
+e2e-attach-key =
+ .label = Attach my public key when adding an OpenPGP digital signature
+ .accesskey = p
+e2e-encrypt-subject =
+ .label = Encrypt the subject of OpenPGP messages
+ .accesskey = b
+e2e-encrypt-drafts =
+ .label = Store draft messages in encrypted format
+ .accesskey = r
+
+# Do not translate "Autocrypt", it's the name of a standard.
+e2e-autocrypt-headers =
+ .label = Send OpenPGP public key(s) in the email headers for compatibility with Autocrypt
+ .accesskey = t
+
+openpgp-key-created-label =
+ .label = Created
+
+openpgp-key-expiry-label =
+ .label = Expiry
+
+openpgp-key-id-label =
+ .label = Key ID
+
+openpgp-cannot-change-expiry = This is a key with a complex structure, changing its expiry date isn’t supported.
+
+openpgp-key-man-title =
+ .title = OpenPGP Key Manager
+openpgp-key-man-generate =
+ .label = New Key Pair
+ .accesskey = K
+openpgp-key-man-gen-revoke =
+ .label = Revocation Certificate
+ .accesskey = R
+openpgp-key-man-ctx-gen-revoke-label =
+ .label = Generate & Save Revocation Certificate
+
+openpgp-key-man-file-menu =
+ .label = File
+ .accesskey = F
+openpgp-key-man-edit-menu =
+ .label = Edit
+ .accesskey = E
+openpgp-key-man-view-menu =
+ .label = View
+ .accesskey = V
+openpgp-key-man-generate-menu =
+ .label = Generate
+ .accesskey = G
+openpgp-key-man-keyserver-menu =
+ .label = Keyserver
+ .accesskey = K
+
+openpgp-key-man-import-public-from-file =
+ .label = Import Public Key(s) From File
+ .accesskey = I
+openpgp-key-man-import-secret-from-file =
+ .label = Import Secret Key(s) From File
+openpgp-key-man-import-sig-from-file =
+ .label = Import Revocation(s) From File
+openpgp-key-man-import-from-clipbrd =
+ .label = Import Key(s) From Clipboard
+ .accesskey = I
+openpgp-key-man-import-from-url =
+ .label = Import Key(s) From URL
+ .accesskey = U
+openpgp-key-man-export-to-file =
+ .label = Export Public Key(s) To File
+ .accesskey = E
+openpgp-key-man-send-keys =
+ .label = Send Public Key(s) By Email
+ .accesskey = S
+openpgp-key-man-backup-secret-keys =
+ .label = Backup Secret Key(s) To File
+ .accesskey = B
+
+openpgp-key-man-discover-cmd =
+ .label = Discover Keys Online
+ .accesskey = D
+openpgp-key-man-publish-cmd =
+ .label = Publish
+ .accesskey = P
+openpgp-key-publish = Publish
+openpgp-key-man-discover-prompt = To discover OpenPGP keys online, on keyservers or using the WKD protocol, enter either an email address or a key ID.
+openpgp-key-man-discover-progress = Searching…
+
+# Variables:
+# $keyserver (String) - The address of a server that contains a directory of OpenPGP public keys
+openpgp-key-publish-ok = Public key sent to "{ $keyserver }".
+
+# Variables:
+# $keyserver (String) - The address of a server that contains a directory of OpenPGP public keys
+openpgp-key-publish-fail = Failed to send your public key to "{ $keyserver }".
+
+openpgp-key-copy-key =
+ .label = Copy Public Key
+ .accesskey = C
+
+openpgp-key-export-key =
+ .label = Export Public Key To File
+ .accesskey = E
+
+openpgp-key-backup-key =
+ .label = Backup Secret Key To File
+ .accesskey = B
+
+openpgp-key-send-key =
+ .label = Send Public Key Via Email
+ .accesskey = S
+
+# Variables:
+# $count (Number) - Number of keys ids to copy.
+openpgp-key-man-copy-key-ids =
+ .label = { $count ->
+ [one] Copy Key ID To Clipboard
+ *[other] Copy Key IDs To Clipboard
+ }
+ .accesskey = K
+
+# Variables:
+# $count (Number) - Number of fingerprints to copy.
+openpgp-key-man-copy-fprs =
+ .label = { $count ->
+ [one] Copy Fingerprint To Clipboard
+ *[other] Copy Fingerprints To Clipboard
+ }
+ .accesskey = F
+
+# Variables:
+# $count (Number) - Number of public keys to copy.
+openpgp-key-man-copy-to-clipboard =
+ .label = { $count ->
+ [one] Copy Public Key To Clipboard
+ *[other] Copy Public Keys To Clipboard
+ }
+ .accesskey = P
+
+openpgp-key-man-ctx-copy =
+ .label = Copy
+ .accesskey = C
+
+# Variables:
+# $count (Number) - Number of fingerprints.
+openpgp-key-man-ctx-copy-fprs =
+ .label = { $count ->
+ [one] Fingerprint
+ *[other] Fingerprints
+ }
+ .accesskey = F
+
+# Variables:
+# $count (Number) - Number of key ids.
+openpgp-key-man-ctx-copy-key-ids =
+ .label = { $count ->
+ [one] Key ID
+ *[other] Key IDs
+ }
+ .accesskey = K
+
+# Variables:
+# $count (Number) - Number of public keys.
+openpgp-key-man-ctx-copy-public-keys =
+ .label = { $count ->
+ [one] Public Key
+ *[other] Public Keys
+ }
+ .accesskey = P
+
+openpgp-key-man-close =
+ .label = Close
+openpgp-key-man-reload =
+ .label = Reload Key Cache
+ .accesskey = R
+openpgp-key-man-change-expiry =
+ .label = Change Expiration Date
+ .accesskey = E
+openpgp-key-man-refresh-online =
+ .label = Refresh Online
+ .accesskey = R
+openpgp-key-man-ignored-ids =
+ .label = Email addresses
+openpgp-key-man-del-key =
+ .label = Delete Key(s)
+ .accesskey = D
+openpgp-delete-key =
+ .label = Delete Key
+ .accesskey = D
+openpgp-key-man-revoke-key =
+ .label = Revoke Key
+ .accesskey = R
+openpgp-key-man-key-props =
+ .label = Key Properties
+ .accesskey = K
+openpgp-key-man-key-more =
+ .label = More
+ .accesskey = M
+openpgp-key-man-view-photo =
+ .label = Photo ID
+ .accesskey = P
+openpgp-key-man-ctx-view-photo-label =
+ .label = View Photo ID
+openpgp-key-man-show-invalid-keys =
+ .label = Display invalid keys
+ .accesskey = D
+openpgp-key-man-show-others-keys =
+ .label = Display Keys From Other People
+ .accesskey = O
+openpgp-key-man-user-id-label =
+ .label = Name
+openpgp-key-man-fingerprint-label =
+ .label = Fingerprint
+openpgp-key-man-select-all =
+ .label = Select All Keys
+ .accesskey = A
+openpgp-key-man-empty-tree-tooltip =
+ .label = Enter search terms in the box above
+openpgp-key-man-nothing-found-tooltip =
+ .label = No keys match your search terms
+openpgp-key-man-please-wait-tooltip =
+ .label = Please wait while keys are being loaded…
+
+openpgp-key-man-filter-label =
+ .placeholder = Search for keys
+
+openpgp-key-man-select-all-key =
+ .key = A
+openpgp-key-man-key-details-key =
+ .key = I
+
+openpgp-ign-addr-intro =
+ You accept using this key for the following selected email addresses:
+
+openpgp-key-details-doc-title = Key Properties
+openpgp-key-details-signatures-tab =
+ .label = Certifications
+openpgp-key-details-structure-tab =
+ .label = Structure
+openpgp-key-details-uid-certified-col =
+ .label = User ID / Certified by
+openpgp-key-details-key-id-label = Key ID
+openpgp-key-details-user-id3-label = Claimed Key Owner
+openpgp-key-details-id-label =
+ .label = ID
+openpgp-key-details-key-type-label = Type
+openpgp-key-details-key-part-label =
+ .label = Key Part
+
+openpgp-key-details-attr-ignored = Warning: This key might not work as expected, because some of its properties are unsafe and might be ignored.
+openpgp-key-details-attr-upgrade-sec = You should upgrade the unsafe properties.
+openpgp-key-details-attr-upgrade-pub = You should ask the owner of this key to upgrade the unsafe properties.
+
+openpgp-key-details-upgrade-unsafe =
+ .label = Upgrade Unsafe Properties
+ .accesskey = P
+
+openpgp-key-details-upgrade-ok = The key was successfully upgraded. You should share the upgraded public key with your correspondents.
+
+openpgp-key-details-algorithm-label =
+ .label = Algorithm
+openpgp-key-details-size-label =
+ .label = Size
+openpgp-key-details-created-label =
+ .label = Created
+openpgp-key-details-created-header = Created
+openpgp-key-details-expiry-label =
+ .label = Expiry
+openpgp-key-details-expiry-header = Expiry
+openpgp-key-details-usage-label =
+ .label = Usage
+openpgp-key-details-fingerprint-label = Fingerprint
+openpgp-key-details-legend-secret-missing =
+ For keys marked with (!) the secret key is not available.
+openpgp-key-details-sel-action =
+ .label = Select action…
+ .accesskey = S
+openpgp-card-details-close-window-label =
+ .buttonlabelaccept = Close
+openpgp-acceptance-label =
+ .label = Your Acceptance
+openpgp-acceptance-rejected-label =
+ .label = No, reject this key.
+openpgp-acceptance-undecided-label =
+ .label = Not yet, maybe later.
+openpgp-acceptance-unverified-label =
+ .label = Yes, but I have not verified that it is the correct key.
+openpgp-acceptance-verified-label =
+ .label = Yes, I’ve verified in person this key has the correct fingerprint.
+key-accept-personal =
+ For this key, you have both the public and the secret part. You may use it as a personal key.
+ If this key was given to you by someone else, then don’t use it as a personal key.
+openpgp-personal-no-label =
+ .label = No, don’t use it as my personal key.
+openpgp-personal-yes-label =
+ .label = Yes, treat this key as a personal key.
+
+openpgp-passphrase-protection =
+ .label = Passphrase Protection
+
+openpgp-passphrase-status-unprotected = Unprotected
+openpgp-passphrase-status-primary-password = Protected by { -brand-short-name }’s Primary Password
+openpgp-passphrase-status-user-passphrase = Protected by a passphrase
+
+openpgp-passphrase-instruction-unprotected = Set a passphrase to protect this key
+openpgp-passphrase-instruction-primary-password = Alternatively protect this key with a separate passphrase
+openpgp-passphrase-instruction-user-passphrase = Unlock this key to change its protection.
+
+openpgp-passphrase-unlock = Unlock
+openpgp-passphrase-unlocked = Key successfully unlocked.
+
+openpgp-remove-protection = Remove passphrase protection
+openpgp-use-primary-password = Remove passphrase and protect with Primary Password
+
+openpgp-passphrase-new = New passphrase
+openpgp-passphrase-new-repeat = Confirm new passphrase
+
+openpgp-passphrase-set = Set passphrase
+openpgp-passphrase-change = Change passphrase
+
+openpgp-copy-cmd-label =
+ .label = Copy
+
+## e2e encryption settings
+
+# $identity (String) - the email address of the currently selected identity
+openpgp-description-no-key = { -brand-short-name } doesn’t have a personal OpenPGP key for <b>{ $identity }</b>
+
+# $count (Number) - the number of configured keys associated with the current identity
+# $identity (String) - the email address of the currently selected identity
+openpgp-description-has-keys = { $count ->
+ [one] { -brand-short-name } found { $count } personal OpenPGP key associated with <b>{ $identity }</b>
+ *[other] { -brand-short-name } found { $count } personal OpenPGP keys associated with <b>{ $identity }</b>
+}
+
+# $key (String) - the currently selected OpenPGP key
+openpgp-selection-status-have-key = Your current configuration uses key ID <b>{ $key }</b>
+
+# $key (String) - the currently selected OpenPGP key
+openpgp-selection-status-error = Your current configuration uses the key <b>{ $key }</b>, which has expired.
+
+openpgp-add-key-button =
+ .label = Add Key…
+ .accesskey = A
+
+e2e-learn-more = Learn more
+
+openpgp-keygen-success = OpenPGP Key created successfully!
+
+openpgp-keygen-import-success = OpenPGP Keys imported successfully!
+
+openpgp-keygen-external-success = External GnuPG Key ID saved!
+
+## OpenPGP Key selection area
+
+openpgp-radio-none =
+ .label = None
+
+openpgp-radio-none-desc = Do not use OpenPGP for this identity.
+
+openpgp-radio-key-not-usable = This key is not usable as a personal key, because the secret key is missing!
+openpgp-radio-key-not-accepted = To use this key you must approve it as a personal key!
+openpgp-radio-key-not-found = This key could not be found! If you want to use it you must import it to { -brand-short-name }.
+
+# $date (String) - the future expiration date of when the OpenPGP key will expire
+openpgp-radio-key-expires = Expires on: { $date }
+
+# $date (String) - the past expiration date of when the OpenPGP key expired
+openpgp-radio-key-expired = Expired on: { $date }
+
+openpgp-key-expires-within-6-months-icon =
+ .title = Key is expiring in less than 6 months
+
+openpgp-key-has-expired-icon =
+ .title = Key expired
+
+openpgp-suggest-publishing-key = Publishing the public key on a keyserver allows others to discover it.
+
+openpgp-key-expand-section =
+ .tooltiptext = More information
+
+openpgp-key-revoke-title = Revoke Key
+
+openpgp-key-edit-title = Change OpenPGP Key
+
+openpgp-key-edit-date-title = Extend Expiration Date
+
+openpgp-manager-description = Use the OpenPGP Key Manager to view and manage public keys of your correspondents and all other keys not listed above.
+
+openpgp-manager-button =
+ .label = OpenPGP Key Manager
+ .accesskey = K
+
+openpgp-key-remove-external =
+ .label = Remove External Key ID
+ .accesskey = E
+
+key-external-label = External GnuPG Key
+
+## Strings in keyDetailsDlg.xhtml
+
+key-type-public = public key
+key-type-primary = primary key
+key-type-subkey = subkey
+key-type-pair = key pair (secret key and public key)
+key-expiry-never = never
+key-usage-encrypt = Encrypt
+key-usage-sign = Sign
+key-usage-certify = Certify
+key-usage-authentication = Authentication
+key-does-not-expire = The key does not expire
+# Variables:
+# $keyExpiry (String) - Date the key expired on.
+key-expired-date = The key expired on { $keyExpiry }
+key-expired-simple = The key has expired
+key-revoked-simple = The key was revoked
+key-do-you-accept = Do you accept this key for verifying digital signatures and for encrypting messages?
+# Variables:
+# $addr (String) - Email address the key claims it belongs to.
+key-verification = Verify the fingerprint of the key using a secure communication channel other than email to make sure that it’s really the key of { $addr }.
+
+## Strings enigmailMsgComposeOverlay.js
+
+# Variables:
+# $problem (String) - Error message from key usability check.
+cannot-use-own-key-because = Unable to send the message, because there is a problem with your personal key. { $problem }
+window-locked = Compose window is locked; send cancelled
+
+## Strings in keyserver.jsm
+
+keyserver-error-aborted = Aborted
+keyserver-error-unknown = An unknown error occurred
+keyserver-error-server-error = The keyserver reported an error.
+keyserver-error-import-error = Failed to import the downloaded key.
+keyserver-error-unavailable = The keyserver is not available.
+keyserver-error-security-error = The keyserver does not support encrypted access.
+keyserver-error-certificate-error = The keyserver’s certificate is not valid.
+keyserver-error-unsupported = The keyserver is not supported.
+
+## Strings in mimeWkdHandler.jsm
+
+wkd-message-body-req =
+ Your email provider processed your request to upload your public key to the OpenPGP Web Key Directory.
+ Please confirm to complete the publishing of your public key.
+wkd-message-body-process =
+ This is an email related to the automatic processing to upload your public key to the OpenPGP Web Key Directory.
+ You do not need to take any manual action at this point.
+
+## Strings in persistentCrypto.jsm
+
+# Variables:
+# $subject (String) - Subject of the message.
+converter-decrypt-body-failed =
+ Could not decrypt message with subject
+ { $subject }.
+ Do you want to retry with a different passphrase or do you want to skip the message?
+
+## Strings filters.jsm
+
+filter-folder-required = You must select a target folder.
+filter-decrypt-move-warn-experimental =
+ Warning - the filter action “Decrypt permanently†may lead to destroyed messages.
+ We strongly recommend that you first try the “Create decrypted Copy†filter, test the result carefully, and only start using this filter once you are satisfied with the result.
+filter-term-pgpencrypted-label = OpenPGP Encrypted
+filter-key-required = You must select a recipient key.
+# Variables:
+# $desc (String) - Email address to look for a key of.
+filter-key-not-found = Could not find an encryption key for ‘{ $desc }’.
+# Variables:
+# $desc (String) - The ID of a secret key that is required to read the email after the user executes the current action.
+filter-warn-key-not-secret =
+ Warning - the filter action “Encrypt to key†replaces the recipients.
+ If you do not have the secret key for ‘{ $desc }’ you will no longer be able to read the emails.
+
+## Strings filtersWrapper.jsm
+
+filter-decrypt-move-label = Decrypt permanently (OpenPGP)
+filter-decrypt-copy-label = Create decrypted Copy (OpenPGP)
+filter-encrypt-label = Encrypt to key (OpenPGP)
+
+## Strings in enigmailKeyImportInfo.js
+
+import-info-title =
+ .title = Success! Keys imported
+import-info-bits = Bits
+import-info-created = Created
+import-info-fpr = Fingerprint
+import-info-details = View Details and manage key acceptance
+import-info-no-keys = No keys imported.
+
+## Strings in enigmailKeyManager.js
+
+import-from-clip = Do you want to import some key(s) from clipboard?
+import-from-url = Download public key from this URL:
+copy-to-clipbrd-failed = Could not copy the selected key(s) to the clipboard.
+copy-to-clipbrd-ok = Key(s) copied to clipboard
+# Variables:
+# $userId (String) - User id of the key.
+delete-secret-key =
+ WARNING: You are about to delete a secret key!
+
+ If you delete your secret key, you will no longer be able to decrypt any messages encrypted for that key, nor will you be able to revoke it.
+
+ Do you really want to delete BOTH, the secret key and the public key
+ ‘{ $userId }’?
+delete-mix =
+ WARNING: You are about to delete secret keys!
+ If you delete your secret key, you will no longer be able to decrypt any messages encrypted for that key.
+ Do you really want to delete BOTH, the selected secret and public keys?
+# Variables:
+# $userId (String) - User id of the key.
+delete-pub-key =
+ Do you want to delete the public key
+ ‘{ $userId }’?
+delete-selected-pub-key = Do you want to delete the public keys?
+refresh-all-question = You did not select any key. Would you like to refresh ALL keys?
+key-man-button-export-sec-key = Export &Secret Keys
+key-man-button-export-pub-key = Export &Public Keys Only
+key-man-button-refresh-all = &Refresh All Keys
+key-man-loading-keys = Loading keys, please wait…
+ascii-armor-file = ASCII Armored Files (*.asc)
+no-key-selected = You should select at least one key in order to perform the selected operation
+export-to-file = Export Public Key To File
+export-keypair-to-file = Export Secret and Public Key To File
+export-secret-key = Do you want to include the secret key in the saved OpenPGP key file?
+save-keys-ok = The keys were successfully saved
+save-keys-failed = Saving the keys failed
+default-pub-key-filename = Exported-public-keys
+default-pub-sec-key-filename = Backup-of-secret-keys
+refresh-key-warn = Warning: depending on the number of keys and the connection speed, refreshing all keys could be quite a lengthy process!
+preview-failed = Can’t read public key file.
+# Variables:
+# $reason (String) - Error description.
+general-error = Error: { $reason }
+dlg-button-delete = &Delete
+
+## Account settings export output
+
+openpgp-export-public-success = <b>Public Key successfully exported!</b>
+openpgp-export-public-fail = <b>Unable to export the selected public key!</b>
+
+openpgp-export-secret-success = <b>Secret Key successfully exported!</b>
+openpgp-export-secret-fail = <b>Unable to export the selected secret key!</b>
+
+## Strings in keyObj.jsm
+## Variables:
+## $userId (String) - The name and/or email address that is mentioned in the key's information.
+## $keyId (String) - Key id for the key entry.
+
+key-ring-pub-key-revoked = The key { $userId } (key ID { $keyId }) is revoked.
+key-ring-pub-key-expired = The key { $userId } (key ID { $keyId }) has expired.
+key-ring-no-secret-key = You do not seem to have the secret key for { $userId } (key ID { $keyId }) on your keyring; you cannot use the key for signing.
+key-ring-pub-key-not-for-signing = The key { $userId } (key ID { $keyId }) cannot be used for signing.
+key-ring-pub-key-not-for-encryption = The key { $userId } (key ID { $keyId }) cannot be used for encryption.
+key-ring-sign-sub-keys-revoked = All signing-subkeys of key { $userId } (key ID { $keyId }) are revoked.
+key-ring-sign-sub-keys-expired = All signing-subkeys of key { $userId } (key ID { $keyId }) have expired.
+key-ring-enc-sub-keys-revoked = All encryption subkeys of key { $userId } (key ID { $keyId }) are revoked.
+key-ring-enc-sub-keys-expired = All encryption subkeys of key { $userId } (key ID { $keyId }) have expired.
+
+## Strings in gnupg-keylist.jsm
+
+keyring-photo = Photo
+user-att-photo = User attribute (JPEG image)
+
+## Strings in key.jsm
+
+already-revoked = This key has already been revoked.
+
+# $identity (String) - the id and associated user identity of the key being revoked
+revoke-key-question =
+ You are about to revoke the key ‘{ $identity }’.
+ You will no longer be able to sign with this key, and once distributed, others will no longer be able to encrypt with that key. You can still use the key to decrypt old messages.
+ Do you want to proceed?
+
+# $keyId (String) - the id of the key being revoked
+revoke-key-not-present =
+ You have no key (0x{ $keyId }) which matches this revocation certificate!
+ If you have lost your key, you must import it (e.g. from a keyserver) before importing the revocation certificate!
+
+# $keyId (String) - the id of the key being revoked
+revoke-key-already-revoked = The key 0x{ $keyId } has already been revoked.
+
+key-man-button-revoke-key = &Revoke Key
+
+openpgp-key-revoke-success = Key successfully revoked.
+
+after-revoke-info =
+ The key has been revoked.
+ Share this public key again, by sending it by email, or by uploading it to keyservers, to let others know that you revoked your key.
+ As soon as the software used by other people learns about the revocation, it will stop using your old key.
+ If you are using a new key for the same email address, and you attach the new public key to emails you send, then information about your revoked old key will be automatically included.
+
+## Strings in keyRing.jsm & decryption.jsm
+
+key-man-button-import = &Import
+
+delete-key-title = Delete OpenPGP Key
+
+delete-external-key-title = Remove the External GnuPG Key
+
+delete-external-key-description = Do you want to remove this External GnuPG key ID?
+
+key-in-use-title = OpenPGP Key currently in use
+
+delete-key-in-use-description = Unable to proceed! The Key you selected for deletion is currently being used by this identity. Select a different key, or select none, and try again.
+
+revoke-key-in-use-description = Unable to proceed! The Key you selected for revocation is currently being used by this identity. Select a different key, or select none, and try again.
+
+## Strings used in errorHandling.jsm
+
+# Variables:
+# $keySpec (String) - Email address.
+key-error-key-spec-not-found = The email address ‘{ $keySpec }’ cannot be matched to a key on your keyring.
+# $keySpec (String) - Key id.
+key-error-key-id-not-found = The configured key ID ‘{ $keySpec }’ cannot be found on your keyring.
+# $keySpec (String) - Key id.
+key-error-not-accepted-as-personal = You have not confirmed that the key with ID ‘{ $keySpec }’ is your personal key.
+
+## Strings used in enigmailKeyManager.js & windows.jsm
+
+need-online = The function you have selected is not available in offline mode. Please go online and try again.
+
+## Strings used in keyRing.jsm & keyLookupHelper.jsm
+
+no-key-found2 = We couldn’t find any usable key matching the specified search criteria.
+no-update-found = You already have the keys that were discovered online.
+
+## Strings used in keyRing.jsm & GnuPGCryptoAPI.jsm
+
+fail-key-extract = Error - key extraction command failed
+
+## Strings used in keyRing.jsm
+
+fail-cancel = Error - Key receive cancelled by user
+not-first-block = Error - First OpenPGP block not public key block
+import-key-confirm = Import public key(s) embedded in message?
+fail-key-import = Error - key importing failed
+# Variables:
+# $output (String) - File that writing was attempted to.
+file-write-failed = Failed to write to file { $output }
+no-pgp-block = Error - No valid armored OpenPGP data block found
+confirm-permissive-import = Import failed. The key you are trying to import might be corrupt or use unknown attributes. Would you like to attempt to import the parts that are correct? This might result in the import of incomplete and unusable keys.
+
+## Strings used in trust.jsm
+
+key-valid-unknown = unknown
+key-valid-invalid = invalid
+key-valid-disabled = disabled
+key-valid-revoked = revoked
+key-valid-expired = expired
+key-trust-untrusted = untrusted
+key-trust-marginal = marginal
+key-trust-full = trusted
+key-trust-ultimate = ultimate
+key-trust-group = (group)
+
+## Strings used in commonWorkflows.js
+
+import-key-file = Import OpenPGP Key File
+import-rev-file = Import OpenPGP Revocation File
+gnupg-file = GnuPG Files
+import-keys-failed=Importing the keys failed
+
+# Variables:
+# $key (String) - Key id to unlock.
+# $date (String) - The date on which the key was created
+# $username_and_email (String) - The user name, and/or the email address which the key owner has set for the key.
+passphrase-prompt2 = Enter the passphrase to unlock the secret key with ID { $key }, created { $date }, { $username_and_email }
+
+# Variables:
+# $subkey (String) - Key id to unlock, which is a subkey.
+# $key (String) - This is the main key, to which the subkey belongs.
+# $date (String) - The date on which the key was created
+# $username_and_email (String) - The user name, and/or the email address which the key owner has set for the key.
+passphrase-prompt2-sub = Enter the passphrase to unlock the secret key with ID { $subkey }, which is a subkey of key ID { $key }, created { $date }, { $username_and_email }
+
+file-to-big-to-import = This file is too big. Please don’t import a large set of keys at once.
+
+## Strings used in enigmailKeygen.js
+
+save-revoke-cert-as = Create & Save Revocation Certificate
+revoke-cert-ok = The revocation certificate has been successfully created. You can use it to invalidate your public key, e.g. in case you would lose your secret key.
+revoke-cert-failed = The revocation certificate could not be created.
+gen-going = Key generation already in progress!
+keygen-missing-user-name = There is no name specified for the selected account/identity. Please enter a value in the field “Your name†in the account settings.
+expiry-too-short = Your key must be valid for at least one day.
+expiry-too-long = You cannot create a key that expires in more than 100 years.
+# Variables:
+# $id (String) - Name and/or email address to generate keys for.
+key-confirm = Generate public and secret key for ‘{ $id }’?
+key-man-button-generate-key = &Generate Key
+key-abort = Abort key generation?
+key-man-button-generate-key-abort = &Abort Key Generation
+key-man-button-generate-key-continue = &Continue Key Generation
+
+## Strings used in enigmailMessengerOverlay.js
+
+failed-decrypt = Error - decryption failed
+fix-broken-exchange-msg-failed = Unable to repair this message.
+
+# Variables:
+# $attachment (String) - File name of the signature file.
+attachment-no-match-from-signature = Could not match signature file ‘{ $attachment }’ to an attachment
+# Variables:
+# $attachment (String) - File name of the attachment.
+attachment-no-match-to-signature = Could not match attachment ‘{ $attachment }’ to a signature file
+# Variables:
+# $attachment (String) - File name of the attachment
+signature-verified-ok = The signature for attachment { $attachment } was successfully verified
+# Variables:
+# $attachment (String) - File name of the attachment
+signature-verify-failed = The signature for attachment { $attachment } could not be verified
+decrypt-ok-no-sig =
+ Warning
+ Decryption was successful, but the signature could not be verified correctly
+msg-ovl-button-cont-anyway = &Continue Anyway
+enig-content-note = *Attachments to this message have not been signed nor encrypted*
+
+## Strings used in enigmailMsgComposeOverlay.js
+
+msg-compose-button-send = &Send Message
+msg-compose-details-button-label = Details…
+msg-compose-details-button-access-key = D
+send-aborted = Send operation aborted.
+# Variables:
+# $key (String) - Key id.
+key-not-trusted = Not enough trust for key ‘{ $key }’
+# Variables:
+# $key (String) - Key id.
+key-not-found = Key ‘{ $key }’ not found
+# Variables:
+# $key (String) - Key id.
+key-revoked = Key ‘{ $key }’ revoked
+# Variables:
+# $key (String) - Key id.
+key-expired = Key ‘{ $key }’ expired
+msg-compose-internal-error = An internal error has occurred.
+keys-to-export = Select OpenPGP Keys to Insert
+msg-compose-partially-encrypted-inlinePGP =
+ The message you are replying to contained both unencrypted and encrypted parts. If the sender was not able to decrypt some message parts originally, you may be leaking confidential information that the sender was not able to originally decrypt themselves.
+ Please consider removing all quoted text from your reply to this sender.
+msg-compose-cannot-save-draft = Error while saving draft
+msg-compose-partially-encrypted-short = Beware of leaking sensitive information - partially encrypted email.
+quoted-printable-warn =
+ You have enabled ‘quoted-printable’ encoding for sending messages. This may result in incorrect decryption and/or verification of your message.
+ Do you wish to turn off sending ‘quoted-printable’ messages now?
+# Variables:
+# $width (Number) - Number of characters per line.
+minimal-line-wrapping =
+ You have set line wrapping to { $width } characters. For correct encryption and/or signing, this value needs to be at least 68.
+ Do you wish to change line wrapping to 68 characters now?
+sending-news =
+ Encrypted send operation aborted.
+ This message cannot be encrypted because there are newsgroup recipients. Please re-send the message without encryption.
+send-to-news-warning =
+ Warning: you are about to send an encrypted email to a newsgroup.
+ This is discouraged because it only makes sense if all members of the group can decrypt the message, i.e. the message needs to be encrypted with the keys of all group participants. Please send this message only if you know exactly what you are doing.
+ Continue?
+save-attachment-header = Save decrypted attachment
+possibly-pgp-mime = Possibly PGP/MIME encrypted or signed message; use ‘Decrypt/Verify’ function to verify
+# Variables:
+# $key (String) - Sender email address.
+cannot-send-sig-because-no-own-key = Cannot digitally sign this message, because you haven’t yet configured end-to-end encryption for <{ $key }>
+# Variables:
+# $key (String) - Sender email address.
+cannot-send-enc-because-no-own-key = Cannot send this message encrypted, because you haven’t yet configured end-to-end encryption for <{ $key }>
+
+## Strings used in decryption.jsm
+
+# Variables:
+# $key (String) - Newline separated list of a tab character then name and/or email address mentioned in the key followed by the key id in parenthesis.
+do-import-multiple =
+ Import the following keys?
+ { $key }
+# Variables:
+# $name (String) - Name and/or email address mentioned in the key.
+# $id (String) - Key id of the key.
+do-import-one = Import { $name } ({ $id })?
+cant-import = Error importing public key
+unverified-reply = Indented message part (reply) was probably modified
+key-in-message-body = A key was found in the message body. Click ‘Import Key’ to import the key
+sig-mismatch = Error - Signature mismatch
+invalid-email = Error - invalid email address(es)
+# Variables:
+# $name (String) - File name of the attachment.
+attachment-pgp-key =
+ The attachment ‘{ $name }’ you are opening appears to be an OpenPGP key file.
+ Click ‘Import’ to import the keys contained or ‘View’ to view the file contents in a browser window
+dlg-button-view = &View
+
+## Strings used in enigmailMsgHdrViewOverlay.js
+
+decrypted-msg-with-format-error = Decrypted message (restored broken PGP email format probably caused by an old Exchange server, so that the result might not be perfect to read)
+
+## Strings used in encryption.jsm
+
+not-required = Error - no encryption required
+
+## Strings used in windows.jsm
+
+no-photo-available = No Photo available
+# Variables:
+# $photo (String) - Path of the photo in the key.
+error-photo-path-not-readable = Photo path ‘{ $photo }’ is not readable
+debug-log-title = OpenPGP Debug Log
+
+## Strings used in dialog.jsm
+
+# This string is followed by either repeat-suffix-singular if $count is 1 or else
+# by repeat-suffix-plural.
+# Variables:
+# $count (Number) - Number of times the alert will repeat.
+repeat-prefix = This alert will repeat { $count }
+repeat-suffix-singular = more time.
+repeat-suffix-plural = more times.
+no-repeat = This alert will not be shown again.
+dlg-keep-setting = Remember my answer and do not ask me again
+dlg-button-ok = &OK
+dlg-button-close = &Close
+dlg-button-cancel = &Cancel
+dlg-no-prompt = Do not show me this dialog again
+enig-prompt = OpenPGP Prompt
+enig-confirm = OpenPGP Confirmation
+enig-alert = OpenPGP Alert
+enig-info = OpenPGP Information
+
+## Strings used in persistentCrypto.jsm
+
+dlg-button-retry = &Retry
+dlg-button-skip = &Skip
+
+## Strings used in enigmailMsgBox.js
+
+enig-alert-title =
+ .title = OpenPGP Alert
diff --git a/comm/mail/locales/en-US/messenger/otr/add-finger.ftl b/comm/mail/locales/en-US/messenger/otr/add-finger.ftl
new file mode 100644
index 0000000000..5709168e2b
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/otr/add-finger.ftl
@@ -0,0 +1,16 @@
+# 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/.
+
+otr-add-finger-title = Add OTR Key Fingerprint
+
+# Variables:
+# $name (String) - name of a chat contact person
+# Do not translate 'OTR' (name of an encryption protocol)
+otr-add-finger-description = Enter the OTR key fingerprint for { $name }.
+
+otr-add-finger-fingerprint = Fingerprint:
+otr-add-finger-tooltip-error = Invalid character entered. Only letters ABCDEF and numbers are allowed
+
+otr-add-finger-input =
+ .placeholder = The 40 characters long OTR key fingerprint
diff --git a/comm/mail/locales/en-US/messenger/otr/am-im-otr.ftl b/comm/mail/locales/en-US/messenger/otr/am-im-otr.ftl
new file mode 100644
index 0000000000..e156f25cfc
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/otr/am-im-otr.ftl
@@ -0,0 +1,25 @@
+# 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/.
+
+account-encryption =
+ .label = End-to-end Encryption
+account-otr-label = Off-the-Record Messaging (OTR)
+account-otr-description2 = { -brand-short-name } supports end-to-end encryption of one-to-one conversations based on OTR. This prevents third parties from eavesdropping on a conversation. This kind of end-to-end encryption can only be used when the other person also uses software that supports OTR.
+otr-encryption-title = Verified Encryption
+otr-encryption-caption = To enable others to verify your identity in OTR chats, share your own OTR fingerprint using an outside (out-of-band) communication channel.
+otr-fingerprint-label = Your Fingerprint:
+view-fingerprint-button =
+ .label = Manage Fingerprints of Contacts
+ .accesskey = F
+otr-settings-title = OTR Settings
+otr-require-encryption =
+ .label = Require end-to-end encryption for one-to-one conversations
+otr-require-encryption-info =
+ When requiring end-to-end encryption, messages in one-to-one conversations
+ will not be sent unless they can be encrypted. Received unencrypted messages
+ will not be shown as part of the regular conversation, and not logged either.
+otr-verify-nudge =
+ .label = Always remind me to verify an unverified contact
+
+otr-not-yet-available = not yet available
diff --git a/comm/mail/locales/en-US/messenger/otr/auth.ftl b/comm/mail/locales/en-US/messenger/otr/auth.ftl
new file mode 100644
index 0000000000..9804b6dfed
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/otr/auth.ftl
@@ -0,0 +1,56 @@
+# 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/.
+
+otr-auth =
+ .title = Verify contact’s identity
+ .buttonlabelaccept = Verify
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+auth-title = Verify the identity of { $name }
+
+# Variables:
+# $own_name (String) - the user's own screen name
+auth-your-fp-value = Fingerprint for you, { $own_name }:
+
+# Variables:
+# $their_name (String) - the screen name of a chat contact
+auth-their-fp-value = Fingerprint for { $their_name }:
+
+auth-question-received = This is the question asked by your contact:
+
+auth-yes =
+ .label = Yes
+
+auth-no =
+ .label = No
+
+auth-verified = I have verified that this is in fact the correct fingerprint.
+
+auth-manual-verification = Manual fingerprint verification
+auth-question-and-answer = Question and answer
+auth-shared-secret = Shared secret
+
+auth-manual-verification-label =
+ .label = { auth-manual-verification }
+
+auth-question-and-answer-label =
+ .label = { auth-question-and-answer }
+
+auth-shared-secret-label =
+ .label = { auth-shared-secret }
+
+auth-manual-instruction = Contact your intended conversation partner via some other authenticated channel, such as OpenPGP-signed email or over the phone. You should tell each other your fingerprints. (A fingerprint is a checksum that identifies an encryption key.) If the fingerprint matches, you should indicate in the dialog below that you have verified the fingerprint.
+
+auth-how = How would you like to verify your contact’s identity?
+
+auth-qa-instruction = Think of a question to which the answer is known only to you and your contact. Enter the question and answer, then wait for your contact to enter the answer. If the answers do not match, the communication channel you are using may be under surveillance.
+
+auth-secret-instruction = Think of a secret known only to you and your contact. Do not use the same Internet connection to exchange the secret. Enter the secret, then wait for your contact to enter it. If the secrets do not match, the communication channel you are using may be under surveillance.
+
+auth-question = Enter a question:
+
+auth-answer = Enter the answer (case sensitive):
+
+auth-secret = Enter the secret:
diff --git a/comm/mail/locales/en-US/messenger/otr/chat.ftl b/comm/mail/locales/en-US/messenger/otr/chat.ftl
new file mode 100644
index 0000000000..c7163781d3
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/otr/chat.ftl
@@ -0,0 +1,19 @@
+# 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/.
+
+state-label = Encryption Status:
+
+start-text = Start an encrypted conversation
+
+start-label =
+ .label = { start-text }
+
+start-tooltip =
+ .tooltiptext = { start-text }
+
+end-label =
+ .label = End the encrypted conversation
+
+auth-label =
+ .label = Verify your contact’s identity
diff --git a/comm/mail/locales/en-US/messenger/otr/finger-sync.ftl b/comm/mail/locales/en-US/messenger/otr/finger-sync.ftl
new file mode 100644
index 0000000000..b3e731b979
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/otr/finger-sync.ftl
@@ -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/.
+
+finger-yes = Verified
+finger-no = Unverified
+
+finger-subset-title = Remove Fingerprints
+finger-subset-message = At least one fingerprint couldn’t be removed, because the corresponding key is currently used in an active conversation.
+
+finger-remove-all-title = Remove All Fingerprints
+finger-remove-all-message = Are you sure you want to remove all previously seen fingerprints? All previous OTR identity verifications will be lost.
diff --git a/comm/mail/locales/en-US/messenger/otr/finger.ftl b/comm/mail/locales/en-US/messenger/otr/finger.ftl
new file mode 100644
index 0000000000..f411991085
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/otr/finger.ftl
@@ -0,0 +1,20 @@
+# 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/.
+
+otr-finger-title = Previously Seen OTR Fingerprints
+
+finger-intro = OTR key fingerprints from previous end-to-end encrypted conversations.
+
+finger-screen-name =
+ .label = Contact
+finger-verified =
+ .label = Verification Status
+finger-fingerprint =
+ .label = Fingerprint
+
+finger-remove =
+ .label = Remove Selected
+
+finger-remove-all =
+ .label = Remove All
diff --git a/comm/mail/locales/en-US/messenger/otr/otr.ftl b/comm/mail/locales/en-US/messenger/otr/otr.ftl
new file mode 100644
index 0000000000..4b4e8ea3a4
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/otr/otr.ftl
@@ -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/.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-encryption-required-part1 = You attempted to send an unencrypted message to { $name }. As a policy, unencrypted messages are not allowed.
+
+msgevent-encryption-required-part2 = Attempting to start a private conversation. Your message will be resent when the private conversation starts.
+msgevent-encryption-error = An error occurred when encrypting your message. The message was not sent.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-connection-ended = { $name } has already closed their encrypted connection to you. To avoid that you accidentally send a message without encryption, your message was not sent. Please end your encrypted conversation, or restart it.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-setup-error = An error occurred while setting up a private conversation with { $name }.
+
+# Do not translate 'OTR' (name of an encryption protocol)
+msgevent-msg-reflected = You are receiving your own OTR messages. You are either trying to talk to yourself, or someone is reflecting your messages back at you.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-msg-resent = The last message to { $name } was resent.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-rcvdmsg-not-private = The encrypted message received from { $name } is unreadable, as you are not currently communicating privately.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-rcvdmsg-unreadable = You received an unreadable encrypted message from { $name }.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-rcvdmsg-malformed = You received a malformed data message from { $name }.
+
+# A Heartbeat is a technical message used to keep a connection alive.
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-log-heartbeat-rcvd = Heartbeat received from { $name }.
+
+# A Heartbeat is a technical message used to keep a connection alive.
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-log-heartbeat-sent = Heartbeat sent to { $name }.
+
+# Do not translate 'OTR' (name of an encryption protocol)
+msgevent-rcvdmsg-general-err = An unexpected error occurred while trying to protect your conversation using OTR.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+# $msg (string) - the message that was received.
+msgevent-rcvdmsg-unencrypted = The following message received from { $name } was not encrypted: { $msg }
+
+# Do not translate 'OTR' (name of an encryption protocol)
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-rcvdmsg-unrecognized = You received an unrecognized OTR message from { $name }.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+msgevent-rcvdmsg-for-other-instance = { $name } has sent a message intended for a different session. If you are logged in multiple times, another session may have received the message.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+context-gone-secure-private = Private conversation with { $name } started.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+context-gone-secure-unverified = Encrypted, but unverified conversation with { $name } started.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+context-still-secure = Successfully refreshed the encrypted conversation with { $name }.
+
+error-enc = An error occurred while encrypting the message.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+error-not-priv = You sent encrypted data to { $name }, who wasn’t expecting it.
+
+error-unreadable = You transmitted an unreadable encrypted message.
+error-malformed = You transmitted a malformed data message.
+
+resent = [resent]
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+tlv-disconnected = { $name } has ended their encrypted conversation with you; you should do the same.
+
+# Do not translate "Off-the-Record" and "OTR" which is the name of an encryption protocol
+# Make sure that this string does NOT contain any numbers, e.g. like "3".
+# Variables:
+# $name (String) - the screen name of a chat contact person
+query-msg = { $name } has requested an Off-the-Record (OTR) encrypted conversation. However, you do not have a plugin to support that. See https://en.wikipedia.org/wiki/Off-the-Record_Messaging for more information.
diff --git a/comm/mail/locales/en-US/messenger/otr/otrUI.ftl b/comm/mail/locales/en-US/messenger/otr/otrUI.ftl
new file mode 100644
index 0000000000..79e2c890ee
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/otr/otrUI.ftl
@@ -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/.
+
+start-label = Start an encrypted conversation
+refresh-label = Refresh the encrypted conversation
+auth-label = Verify your contact’s identity
+reauth-label = Reverify your contact’s identity
+
+auth-cancel = Cancel
+auth-cancel-access-key = C
+
+auth-error = An error occurred while verifying the identity of your contact.
+auth-success = Verifying your contact’s identity completed successfully.
+auth-success-them = Your contact has successfully verified your identity. You may want to verify their identity as well by asking your own question.
+auth-fail = Failed to verify the identity of your contact.
+auth-waiting = Waiting for the contact to complete the verification…
+
+finger-verify = Verify
+finger-verify-access-key = V
+
+finger-ignore = Ignore
+finger-ignore-access-key = I
+
+# Do not translate 'OTR' (name of an encryption protocol)
+buddycontextmenu-label = Add OTR Fingerprint
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+alert-start = Attempting to start an encrypted conversation with { $name }.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+alert-refresh = Attempting to refresh the encrypted conversation with { $name }.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+alert-gone-insecure = The encrypted conversation with { $name } ended.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+finger-unseen = The identity of { $name } has not been verified yet. Casual eavesdropping is not possible, but with some effort someone could be listening in. Prevent surveillance by verifying this contact’s identity.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+finger-seen={ $name } is contacting you from an unrecognized computer. Casual eavesdropping is not possible, but with some effort someone could be listening in. Prevent surveillance by verifying this contact’s identity.
+
+state-not-private = The current conversation is not private.
+state-generic-not-private = The current conversation is not private.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+state-unverified = The current conversation is encrypted but not private, since the identity of { $name } has not yet been verified.
+
+state-generic-unverified = The current conversation is encrypted but not private, since some identities have not yet been verified.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+state-private = The identity of { $name } has been verified. The current conversation is encrypted and private.
+
+state-generic-private = The current conversation is encrypted and private.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+state-finished = { $name } has ended their encrypted conversation with you; you should do the same.
+
+state-not-private-label = Insecure
+state-unverified-label = Unverified
+state-private-label = Private
+state-finished-label = Finished
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+verify-request = { $name } requested the verification of your identity.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+afterauth-private = You have verified the identity of { $name }.
+
+# Variables:
+# $name (String) - the screen name of a chat contact person
+afterauth-unverified = The identity of { $name } has not been verified.
+
+# Do not translate 'OTR' (name of an encryption protocol)
+# Variables:
+# $error (String) - contains an error message that describes the cause of the failure
+otr-genkey-failed = Generating OTR private key failed: { $error }
diff --git a/comm/mail/locales/en-US/messenger/policies/aboutPolicies.ftl b/comm/mail/locales/en-US/messenger/policies/aboutPolicies.ftl
new file mode 100644
index 0000000000..d5ef87a573
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/policies/aboutPolicies.ftl
@@ -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/.
+
+about-policies-title = Enterprise Policies
+
+# 'Active' is used to describe the policies that are currently active
+active-policies-tab = Active
+errors-tab = Errors
+documentation-tab = Documentation
+
+no-specified-policies-message = The Enterprise Policies service is active but there are no policies enabled.
+inactive-message = The Enterprise Policies service is inactive.
+
+policy-name = Policy Name
+policy-value = Policy Value
+policy-errors = Policy Errors
diff --git a/comm/mail/locales/en-US/messenger/policies/policies-descriptions.ftl b/comm/mail/locales/en-US/messenger/policies/policies-descriptions.ftl
new file mode 100644
index 0000000000..44a76052bb
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/policies/policies-descriptions.ftl
@@ -0,0 +1,158 @@
+# 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 Enterprise Policies feature is aimed at system administrators
+## who want to deploy these settings across several Thunderbird installations
+## all at once. This is traditionally done through the Windows Group Policy
+## feature, but the system also supports other forms of deployment.
+## These are short descriptions for individual policies, to be displayed
+## in the documentation section in about:policies.
+
+policy-3rdparty = Set policies that WebExtensions can access via chrome.storage.managed.
+
+policy-AppAutoUpdate = Enable or disable automatic application update.
+
+policy-AppUpdatePin = Prevent { -brand-short-name } from being updated beyond the specified version.
+
+policy-AppUpdateURL = Set custom app update URL.
+
+policy-Authentication = Configure integrated authentication for websites that support it.
+
+policy-BackgroundAppUpdate2 = Enable or disable the background updater.
+
+policy-BlockAboutAddons = Block access to the Add-ons Manager (about:addons).
+
+policy-BlockAboutConfig = Block access to the about:config page.
+
+policy-BlockAboutProfiles = Block access to the about:profiles page.
+
+policy-BlockAboutSupport = Block access to the about:support page.
+
+policy-CaptivePortal = Enable or disable captive portal support.
+
+policy-CertificatesDescription = Add certificates or use built-in certificates.
+
+policy-Cookies = Allow or deny websites to set cookies.
+
+policy-DisableBuiltinPDFViewer = Disable PDF.js, the built-in PDF viewer in { -brand-short-name }.
+
+policy-DisabledCiphers = Disable ciphers.
+
+policy-DefaultDownloadDirectory = Set the default download directory.
+
+policy-DisableAppUpdate = Prevent { -brand-short-name } from updating.
+
+policy-DisableDefaultClientAgent = Prevent the default client agent from taking any actions. Only applicable to Windows; other platforms don’t have the agent.
+
+policy-DisableDeveloperTools = Block access to the developer tools.
+
+policy-DisableFeedbackCommands = Disable commands to send feedback from the Help menu (Submit Feedback and Report Deceptive Site).
+
+policy-DisableForgetButton = Prevent access to the Forget button.
+
+policy-DisableFormHistory = Don’t remember search and form history.
+
+policy-DisableMasterPasswordCreation = If true, a master password can’t be created.
+
+policy-DisablePasswordReveal = Do not allow passwords to be revealed in saved logins.
+
+policy-DisableProfileImport = Disable the menu command to Import data from another application.
+
+policy-DisableSafeMode = Disable the feature to restart in Safe Mode. Note: the Shift key to enter Safe Mode can only be disabled on Windows using Group Policy.
+
+policy-DisableSecurityBypass = Prevent the user from bypassing certain security warnings.
+
+policy-DisableSystemAddonUpdate = Prevent { -brand-short-name } from installing and updating system add-ons.
+
+policy-DisableTelemetry = Turn off Telemetry.
+
+policy-DisplayMenuBar = Display the Menu Bar by default.
+
+policy-DNSOverHTTPS = Configure DNS over HTTPS.
+
+policy-DontCheckDefaultClient = Disable check for default client on startup.
+
+policy-DownloadDirectory = Set and lock the download directory.
+
+# “lock†means that the user won’t be able to change this setting
+policy-EnableTrackingProtection = Enable or disable Content Blocking and optionally lock it.
+
+# “lock†means that the user won’t be able to change this setting
+policy-EncryptedMediaExtensions = Enable or disable Encrypted Media Extensions and optionally lock it.
+
+# A “locked†extension can’t be disabled or removed by the user. This policy
+# takes 3 keys (“Installâ€, â€Uninstallâ€, â€Lockedâ€), you can either keep them in
+# English or translate them as verbs.
+policy-Extensions = Install, uninstall or lock extensions. The Install option takes URLs or paths as parameters. The Uninstall and Locked options take extension IDs.
+
+policy-ExtensionSettings = Manage all aspects of extension installation.
+
+policy-ExtensionUpdate = Enable or disable automatic extension updates.
+
+policy-Handlers = Configure default application handlers.
+
+policy-HardwareAcceleration = If false, turn off hardware acceleration.
+
+policy-InstallAddonsPermission = Allow certain websites to install add-ons.
+
+policy-LegacyProfiles = Disable the feature enforcing a separate profile for each installation.
+
+## Do not translate "SameSite", it's the name of a cookie attribute.
+
+policy-LegacySameSiteCookieBehaviorEnabled = Enable default legacy SameSite cookie behavior setting.
+
+policy-LegacySameSiteCookieBehaviorEnabledForDomainList = Revert to legacy SameSite behavior for cookies on specified sites.
+
+##
+
+policy-LocalFileLinks = Allow specific websites to link to local files.
+
+policy-ManualAppUpdateOnly = Allow manual updates only and do not notify the user about updates.
+
+policy-NetworkPrediction = Enable or disable network prediction (DNS prefetching).
+
+policy-OfferToSaveLogins = Enforce the setting to allow { -brand-short-name } to offer to remember saved logins and passwords. Both true and false values are accepted.
+
+policy-OfferToSaveLoginsDefault = Set the default value for allowing { -brand-short-name } to offer to remember saved logins and passwords. Both true and false values are accepted.
+
+policy-OverrideFirstRunPage = Override the first run page. Set this policy to blank if you want to disable the first run page.
+
+policy-OverridePostUpdatePage = Override the post-update “What’s New†page. Set this policy to blank if you want to disable the post-update page.
+
+policy-PasswordManagerEnabled = Enable saving passwords to the password manager.
+
+# PDF.js and PDF should not be translated
+policy-PDFjs = Disable or configure PDF.js, the built-in PDF viewer in { -brand-short-name }.
+
+policy-Permissions2 = Configure permissions for camera, microphone, location, notifications, and autoplay.
+
+policy-Preferences = Set and lock the value for a subset of preferences.
+
+policy-PrimaryPassword = Require or prevent using a Primary Password.
+
+policy-PromptForDownloadLocation = Ask where to save files when downloading.
+
+policy-Proxy = Configure proxy settings.
+
+policy-RequestedLocales = Set the list of requested locales for the application in order of preference.
+
+policy-SanitizeOnShutdown2 = Clear navigation data on shutdown.
+
+policy-SearchEngines = Configure search engine settings. This policy is only available on the Extended Support Release (ESR) version.
+
+policy-SearchSuggestEnabled = Enable or disable search suggestions.
+
+# For more information, see https://developer.mozilla.org/en-US/docs/Mozilla/Projects/NSS/PKCS11/Module_Installation
+policy-SecurityDevices = Install PKCS #11 modules.
+
+policy-SSLVersionMax = Set the maximum SSL version.
+
+policy-SSLVersionMin = Set the minimum SSL version.
+
+policy-SupportMenu = Add a custom support menu item to the help menu.
+
+policy-UserMessaging = Don’t show certain messages to the user.
+
+# “format†refers to the format used for the value of this policy.
+policy-WebsiteFilter = Block websites from being visited. See documentation for more details on the format.
diff --git a/comm/mail/locales/en-US/messenger/preferences/am-copies.ftl b/comm/mail/locales/en-US/messenger/preferences/am-copies.ftl
new file mode 100644
index 0000000000..24e0b6f29b
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/am-copies.ftl
@@ -0,0 +1,5 @@
+# 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/.
+
+account-prefs-show-address-row-description = Leave the address field blank to always show the address row when starting a new message.
diff --git a/comm/mail/locales/en-US/messenger/preferences/am-im.ftl b/comm/mail/locales/en-US/messenger/preferences/am-im.ftl
new file mode 100644
index 0000000000..795bc6ae57
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/am-im.ftl
@@ -0,0 +1,25 @@
+# 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/.
+
+account-settings-title = Authentication Settings
+account-channel-title = Default Channels
+
+chat-autologin =
+ .label = Sign-on at startup
+
+chat-encryption-generic = Generic
+chat-encryption-log =
+ .label = Include end-to-end encrypted messages in conversation logs
+chat-encryption-label = Native End-to-end Encryption
+# Variables:
+# $protocol (String) - Name of the chat protocol. Example: Matrix
+chat-encryption-description = { $protocol } provides end-to-end encryption for chat messages. This prevents third parties from eavesdropping on a conversation. Additional setup might be required below for the encryption to be operational.
+chat-encryption-status = Encryption Status
+chat-encryption-placeholder = Encryption not initialized.
+chat-encryption-sessions = Sessions
+chat-encryption-sessions-description = For end-to-end encryption to work correctly, you have to trust the other sessions currently logged in to your account. Interaction with the other client is required to verify a session. Verifying a session might lead to all sessions that it trusts to also be trusted by { -brand-short-name }.
+chat-encryption-session-verify = verify
+ .title = Verify the identity of this session
+chat-encryption-session-trusted = trusted
+ .title = This session’s identity is verified
diff --git a/comm/mail/locales/en-US/messenger/preferences/application-manager.ftl b/comm/mail/locales/en-US/messenger/preferences/application-manager.ftl
new file mode 100644
index 0000000000..40dd34a1e6
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/application-manager.ftl
@@ -0,0 +1,10 @@
+# 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/.
+
+app-manager-window-dialog2 =
+ .title = Application Details
+
+remove-app-button =
+ .label = Remove
+ .accesskey = R
diff --git a/comm/mail/locales/en-US/messenger/preferences/attachment-reminder.ftl b/comm/mail/locales/en-US/messenger/preferences/attachment-reminder.ftl
new file mode 100644
index 0000000000..65ab01a944
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/attachment-reminder.ftl
@@ -0,0 +1,26 @@
+# 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/.
+
+attachment-reminder-window =
+ .title = Attachment Reminder Keywords
+
+attachment-reminder-label = { -brand-short-name } will warn you about missing attachments if you’re about to send an email containing one of these keywords.
+
+keyword-new-button =
+ .label = New…
+ .accesskey = N
+
+keyword-edit-button =
+ .label = Edit…
+ .accesskey = E
+
+keyword-remove-button =
+ .label = Delete
+ .accesskey = D
+
+new-keyword-title = New Keyword
+new-keyword-label = Keyword:
+
+edit-keyword-title = Edit Keyword
+edit-keyword-label = Keyword:
diff --git a/comm/mail/locales/en-US/messenger/preferences/colors.ftl b/comm/mail/locales/en-US/messenger/preferences/colors.ftl
new file mode 100644
index 0000000000..85c50e9e18
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/colors.ftl
@@ -0,0 +1,47 @@
+# 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/.
+
+colors-dialog-window2 =
+ .title = Colors
+
+colors-dialog-legend = Text and Background
+
+text-color-label =
+ .value = Text:
+ .accesskey = T
+
+background-color-label =
+ .value = Background:
+ .accesskey = B
+
+use-system-colors =
+ .label = Use system colors
+ .accesskey = s
+
+colors-link-legend = Link Colors
+
+link-color-label =
+ .value = Unvisited Links:
+ .accesskey = L
+
+visited-link-color-label =
+ .value = Visited Links:
+ .accesskey = V
+
+underline-link-checkbox =
+ .label = Underline links
+ .accesskey = U
+
+override-color-label =
+ .value = Override the colors specified by the content with my selections above:
+ .accesskey = O
+
+override-color-always =
+ .label = Always
+
+override-color-auto =
+ .label = Only with High Contrast themes
+
+override-color-never =
+ .label = Never
diff --git a/comm/mail/locales/en-US/messenger/preferences/connection.ftl b/comm/mail/locales/en-US/messenger/preferences/connection.ftl
new file mode 100644
index 0000000000..ec1fd76acc
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/connection.ftl
@@ -0,0 +1,115 @@
+# 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/.
+
+connection-dns-over-https-url-resolver = Use Provider
+ .accesskey = r
+
+# Variables:
+# $name (String) - Display name or URL for the DNS over HTTPS provider
+connection-dns-over-https-url-item-default =
+ .label = { $name } (Default)
+ .tooltiptext = Use the default URL for resolving DNS over HTTPS
+
+connection-dns-over-https-url-custom =
+ .label = Custom
+ .accesskey = C
+ .tooltiptext = Enter your preferred URL for resolving DNS over HTTPS
+
+connection-dns-over-https-custom-label = Custom
+
+connection-dialog-window2 =
+ .title = Connection Settings
+
+disable-extension-button = Disable Extension
+
+# Variables:
+# $name (String) - The extension that is controlling the proxy settings.
+#
+# The extension-icon is the extension's icon, or a fallback image. It should be
+# purely decoration for the actual extension name, with alt="".
+proxy-settings-controlled-by-extension = An extension, <img data-l10n-name="extension-icon" alt="" /> { $name }, is controlling how { -brand-short-name } connects to the Internet.
+
+connection-proxy-legend = Configure Proxies to Access the Internet
+
+proxy-type-no =
+ .label = No proxy
+ .accesskey = y
+
+proxy-type-wpad =
+ .label = Auto-detect proxy settings for this network
+ .accesskey = w
+
+proxy-type-system =
+ .label = Use system proxy settings
+ .accesskey = u
+
+proxy-type-manual =
+ .label = Manual proxy configuration:
+ .accesskey = m
+
+proxy-http-label =
+ .value = HTTP Proxy:
+ .accesskey = h
+
+http-port-label =
+ .value = Port:
+ .accesskey = p
+
+proxy-http-sharing =
+ .label = Also use this proxy for HTTPS
+ .accesskey = x
+
+proxy-https-label =
+ .value = HTTPS Proxy:
+ .accesskey = S
+
+ssl-port-label =
+ .value = Port:
+ .accesskey = o
+
+proxy-socks-label =
+ .value = SOCKS Host:
+ .accesskey = c
+
+socks-port-label =
+ .value = Port:
+ .accesskey = t
+
+proxy-socks4-label =
+ .label = SOCKS v4
+ .accesskey = k
+
+proxy-socks5-label =
+ .label = SOCKS v5
+ .accesskey = v
+
+proxy-type-auto =
+ .label = Automatic proxy configuration URL:
+ .accesskey = A
+
+proxy-reload-label =
+ .label = Reload
+ .accesskey = l
+
+no-proxy-label =
+ .value = No proxy for:
+ .accesskey = n
+
+no-proxy-example = Example: .mozilla.org, .net.nz, 192.168.1.0/24
+
+# Do not translate "localhost", "127.0.0.1/8" and "::1". (You can translate "and".)
+connection-proxy-noproxy-localhost-desc-2 = Connections to localhost, 127.0.0.1/8, and ::1 are never proxied.
+
+proxy-password-prompt =
+ .label = Do not prompt for authentication if password is saved
+ .accesskey = i
+ .tooltiptext = This option silently authenticates you to proxies when you have saved credentials for them. You will be prompted if authentication fails.
+
+proxy-remote-dns =
+ .label = Proxy DNS when using SOCKS v5
+ .accesskey = d
+
+proxy-enable-doh =
+ .label = Enable DNS over HTTPS
+ .accesskey = b
diff --git a/comm/mail/locales/en-US/messenger/preferences/cookies.ftl b/comm/mail/locales/en-US/messenger/preferences/cookies.ftl
new file mode 100644
index 0000000000..6fc6f9d5bc
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/cookies.ftl
@@ -0,0 +1,54 @@
+# 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/.
+
+cookies-window-dialog2 =
+ .title = Cookies
+
+window-close-key =
+ .key = w
+
+window-focus-search-key =
+ .key = f
+
+window-focus-search-alt-key =
+ .key = k
+
+filter-search-label =
+ .value = Search:
+ .accesskey = S
+
+cookies-on-system-label = The following cookies are stored on your computer:
+
+treecol-site-header =
+ .label = Site
+
+treecol-name-header =
+ .label = Cookie Name
+
+props-name-label =
+ .value = Name:
+props-value-label =
+ .value = Content:
+props-domain-label =
+ .value = Host:
+props-path-label =
+ .value = Path:
+props-secure-label =
+ .value = Send For:
+props-expires-label =
+ .value = Expires:
+props-container-label =
+ .value = Container:
+
+remove-cookie-button =
+ .label = Remove Cookie
+ .accesskey = R
+
+remove-all-cookies-button =
+ .label = Remove All Cookies
+ .accesskey = A
+
+cookie-close-button =
+ .label = Close
+ .accesskey = C
diff --git a/comm/mail/locales/en-US/messenger/preferences/dock-options.ftl b/comm/mail/locales/en-US/messenger/preferences/dock-options.ftl
new file mode 100644
index 0000000000..436199b392
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/dock-options.ftl
@@ -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/.
+
+dock-options-window-dialog2 =
+ .title = App icon options
+
+dock-options-show-badge =
+ .label = Show badge icon
+ .accesskey = b
+
+bounce-system-dock-icon =
+ .label = Animate the app icon when a new message arrives
+ .accesskey = i
+
+dock-icon-legend = App icon badge
+
+dock-icon-show-label =
+ .value = Badge app icon with:
+
+count-unread-messages-radio =
+ .label = Count of unread messages
+ .accesskey = u
+
+count-new-messages-radio =
+ .label = Count of new messages
+ .accesskey = n
+
+notification-settings-info2 = You can disable the badge on the Notification pane of System Settings.
diff --git a/comm/mail/locales/en-US/messenger/preferences/fonts.ftl b/comm/mail/locales/en-US/messenger/preferences/fonts.ftl
new file mode 100644
index 0000000000..b62ad94c8c
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/fonts.ftl
@@ -0,0 +1,150 @@
+# 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/.
+
+fonts-dialog-title = Fonts
+
+fonts-window-close =
+ .key = w
+
+# Variables:
+# $name {string, "Arial"} - Name of the default font
+fonts-label-default =
+ .label = Default ({ $name })
+fonts-label-default-unnamed =
+ .label = Default
+
+fonts-language-legend =
+ .value = Fonts for:
+ .accesskey = t
+
+fonts-proportional-label =
+ .value = Proportional:
+ .accesskey = P
+
+## Languages
+
+# Note: Translate "Latin" as the name of Latin (Roman) script, not as the name of the Latin language.
+font-language-group-latin =
+ .label = Latin
+font-language-group-japanese =
+ .label = Japanese
+font-language-group-trad-chinese =
+ .label = Traditional Chinese (Taiwan)
+font-language-group-simpl-chinese =
+ .label = Simplified Chinese
+font-language-group-trad-chinese-hk =
+ .label = Traditional Chinese (Hong Kong)
+font-language-group-korean =
+ .label = Korean
+font-language-group-cyrillic =
+ .label = Cyrillic
+font-language-group-el =
+ .label = Greek
+font-language-group-other =
+ .label = Other Writing Systems
+font-language-group-thai =
+ .label = Thai
+font-language-group-hebrew =
+ .label = Hebrew
+font-language-group-arabic =
+ .label = Arabic
+font-language-group-devanagari =
+ .label = Devanagari
+font-language-group-tamil =
+ .label = Tamil
+font-language-group-armenian =
+ .label = Armenian
+font-language-group-bengali =
+ .label = Bengali
+font-language-group-canadian =
+ .label = Unified Canadian Syllabary
+font-language-group-ethiopic =
+ .label = Ethiopic
+font-language-group-georgian =
+ .label = Georgian
+font-language-group-gujarati =
+ .label = Gujarati
+font-language-group-gurmukhi =
+ .label = Gurmukhi
+font-language-group-khmer =
+ .label = Khmer
+font-language-group-malayalam =
+ .label = Malayalam
+font-language-group-math =
+ .label = Mathematics
+font-language-group-odia =
+ .label = Odia
+font-language-group-telugu =
+ .label = Telugu
+font-language-group-kannada =
+ .label = Kannada
+font-language-group-sinhala =
+ .label = Sinhala
+font-language-group-tibetan =
+ .label = Tibetan
+
+## Default font type
+
+default-font-serif =
+ .label = Serif
+
+default-font-sans-serif =
+ .label = Sans Serif
+
+font-size-proportional-label =
+ .value = Size:
+ .accesskey = e
+
+font-size-monospace-label =
+ .value = Size:
+ .accesskey = i
+
+font-serif-label =
+ .value = Serif:
+ .accesskey = S
+
+font-sans-serif-label =
+ .value = Sans-serif:
+ .accesskey = n
+
+font-monospace-label =
+ .value = Monospace:
+ .accesskey = M
+
+font-min-size-label =
+ .value = Minimum font size:
+ .accesskey = z
+
+min-size-none =
+ .label = None
+
+## Fonts in message
+
+font-control-legend = Font Control
+
+use-document-fonts-checkbox =
+ .label = Allow messages to use other fonts
+ .accesskey = o
+
+use-fixed-width-plain-checkbox =
+ .label = Use fixed width font for plain text messages
+ .accesskey = x
+
+## Language settings
+
+text-encoding-legend = Text Encoding
+
+text-encoding-description = Set the default text encoding for sending and receiving mail
+
+font-outgoing-email-label =
+ .value = Outgoing Mail:
+ .accesskey = u
+
+font-incoming-email-label =
+ .value = Incoming Mail:
+ .accesskey = I
+
+default-font-reply-checkbox =
+ .label = When possible, use the default text encoding in replies
+ .accesskey = h
diff --git a/comm/mail/locales/en-US/messenger/preferences/languages.ftl b/comm/mail/locales/en-US/messenger/preferences/languages.ftl
new file mode 100644
index 0000000000..60cae72388
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/languages.ftl
@@ -0,0 +1,44 @@
+# 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/.
+
+languages-customize-moveup =
+ .label = Move Up
+ .accesskey = U
+
+languages-customize-movedown =
+ .label = Move Down
+ .accesskey = D
+
+languages-customize-remove =
+ .label = Remove
+ .accesskey = R
+
+languages-customize-select-language =
+ .placeholder = Select a language to add…
+
+languages-customize-add =
+ .label = Add
+ .accesskey = A
+
+messenger-languages-window2 =
+ .title = { -brand-short-name } Language Settings
+
+messenger-languages-description = { -brand-short-name } will display the first language as your default and will display alternate languages if necessary in the order they appear.
+
+messenger-languages-search = Search for more languages…
+
+messenger-languages-searching =
+ .label = Searching for languages…
+
+messenger-languages-downloading =
+ .label = Downloading…
+
+messenger-languages-select-language =
+ .label = Select a language to add…
+ .placeholder = Select a language to add…
+
+messenger-languages-installed-label = Installed languages
+messenger-languages-available-label = Available languages
+
+messenger-languages-error = { -brand-short-name } can’t update your languages right now. Check that you are connected to the internet or try again.
diff --git a/comm/mail/locales/en-US/messenger/preferences/new-tag.ftl b/comm/mail/locales/en-US/messenger/preferences/new-tag.ftl
new file mode 100644
index 0000000000..8bfe3ddd9d
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/new-tag.ftl
@@ -0,0 +1,14 @@
+# 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/.
+
+tag-dialog-window =
+ .title = New Tag
+
+tag-name-label =
+ .value = Tag Name:
+ .accesskey = T
+
+tag-color-label =
+ .value = Color:
+ .accesskey = C
diff --git a/comm/mail/locales/en-US/messenger/preferences/notifications.ftl b/comm/mail/locales/en-US/messenger/preferences/notifications.ftl
new file mode 100644
index 0000000000..b7ec3c9670
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/notifications.ftl
@@ -0,0 +1,33 @@
+# 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/.
+
+notifications-dialog-window =
+ .title = Customize New Mail Alert
+
+customize-alert-description = Choose which fields to show in the alert notification:
+
+preview-text-checkbox =
+ .label = Message Preview Text
+ .accesskey = M
+
+subject-checkbox =
+ .label = Subject
+ .accesskey = S
+
+sender-checkbox =
+ .label = Sender
+ .accesskey = e
+
+## Note: open-time-label-before is displayed first, then there's a field where
+## the user can enter a number, and open-time-label-after is displayed at the end
+## of the line. The translations of the open-time-label-before and open-time-label-after
+## parts don't have to mean the exact same thing as in English; please try instead
+## to translate the whole sentence.
+
+open-time-label-before =
+ .value = Show New Mail Alert for
+ .accesskey = N
+
+open-time-label-after =
+ .value = seconds
diff --git a/comm/mail/locales/en-US/messenger/preferences/offline.ftl b/comm/mail/locales/en-US/messenger/preferences/offline.ftl
new file mode 100644
index 0000000000..97d83c118b
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/offline.ftl
@@ -0,0 +1,56 @@
+# 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/.
+
+offline-dialog-window =
+ .title = Offline Settings
+
+autodetect-online-label =
+ .label = Automatically follow detected online state
+ .accesskey = d
+
+offline-preference-startup-label = Manual state when starting up:
+
+status-radio-remember =
+ .label = Remember previous online state
+ .accesskey = R
+
+status-radio-ask =
+ .label = Ask me for online state
+ .accesskey = k
+
+status-radio-always-online =
+ .label = Online
+ .accesskey = l
+
+status-radio-always-offline =
+ .label = Offline
+ .accesskey = f
+
+going-online-label = Send unsent messages when going online?
+
+going-online-auto =
+ .label = Yes
+ .accesskey = Y
+
+going-online-not =
+ .label = No
+ .accesskey = N
+
+going-online-ask =
+ .label = Ask me
+ .accesskey = s
+
+going-offline-label = Download messages for offline use when going offline?
+
+going-offline-auto =
+ .label = Yes
+ .accesskey = e
+
+going-offline-not =
+ .label = No
+ .accesskey = o
+
+going-offline-ask =
+ .label = Ask me
+ .accesskey = a
diff --git a/comm/mail/locales/en-US/messenger/preferences/passwordManager.ftl b/comm/mail/locales/en-US/messenger/preferences/passwordManager.ftl
new file mode 100644
index 0000000000..3662eca22c
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/passwordManager.ftl
@@ -0,0 +1,85 @@
+# 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/.
+
+saved-logins =
+ .title = Saved Logins
+window-close =
+ .key = w
+focus-search-primary-shortcut =
+ .key = f
+focus-search-alt-shortcut =
+ .key = k
+copy-provider-url-cmd =
+ .label = Copy URL
+ .accesskey = y
+copy-username-cmd =
+ .label = Copy Username
+ .accesskey = U
+edit-username-cmd =
+ .label = Edit Username
+ .accesskey = d
+copy-password-cmd =
+ .label = Copy Password
+ .accesskey = C
+edit-password-cmd =
+ .label = Edit Password
+ .accesskey = E
+search-filter =
+ .accesskey = S
+ .placeholder = Search
+column-heading-provider =
+ .label = Provider
+column-heading-username =
+ .label = Username
+column-heading-password =
+ .label = Password
+column-heading-time-created =
+ .label = First Used
+column-heading-time-last-used =
+ .label = Last Used
+column-heading-time-password-changed =
+ .label = Last Changed
+column-heading-times-used =
+ .label = Times Used
+remove =
+ .label = Remove
+ .accesskey = R
+import =
+ .label = Import…
+ .accesskey = I
+password-close-button =
+ .label = Close
+ .accesskey = C
+
+show-passwords =
+ .label = Show Passwords
+ .accesskey = P
+hide-passwords =
+ .label = Hide Passwords
+ .accesskey = P
+logins-description-all = Logins for the following providers are stored on your computer
+logins-description-filtered = The following logins match your search:
+remove-all =
+ .label = Remove All
+ .accesskey = A
+remove-all-shown =
+ .label = Remove All Shown
+ .accesskey = A
+remove-all-passwords-prompt = Are you sure you wish to remove all passwords?
+remove-all-passwords-title = Remove all passwords
+no-master-password-prompt = Are you sure you wish to show your passwords?
+
+## OS Authentication dialog
+
+# This message can be seen by trying to show or copy the passwords.
+password-os-auth-dialog-message = Verify your identity to reveal the saved passwords.
+
+# This message can be seen by trying to show or copy the passwords.
+# The macOS strings are preceded by the operating system with "Thunderbird is trying to "
+# and includes subtitle of "Enter password for the user "xxx" to allow this." These
+# notes are only valid for English. Please test in your locale.
+password-os-auth-dialog-message-macosx = reveal the saved passwords
+
+# Don't change this label.
+password-os-auth-dialog-caption = { -brand-full-name }
diff --git a/comm/mail/locales/en-US/messenger/preferences/permissions.ftl b/comm/mail/locales/en-US/messenger/preferences/permissions.ftl
new file mode 100644
index 0000000000..7b0c931d61
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/permissions.ftl
@@ -0,0 +1,55 @@
+# 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/.
+
+permissions-reminder-window2 =
+ .title = Exceptions
+
+permission-preferences-close-window =
+ .key = w
+
+website-address-label =
+ .value = Address of website:
+ .accesskey = d
+
+block-button =
+ .label = Block
+ .accesskey = B
+
+allow-session-button =
+ .label = Allow for Session
+ .accesskey = n
+
+allow-button =
+ .label = Allow
+ .accesskey = A
+
+treehead-sitename-label =
+ .label = Site
+
+treehead-status-label =
+ .label = Status
+
+remove-site-button =
+ .label = Remove Site
+ .accesskey = R
+
+remove-all-site-button =
+ .label = Remove All Sites
+ .accesskey = e
+
+cancel-button =
+ .label = Cancel
+ .accesskey = C
+
+save-button =
+ .label = Save Changes
+ .accesskey = S
+
+permission-can-label = Allow
+permission-can-access-first-party-label = Allow first party only
+permission-can-session-label = Allow for Session
+permission-cannot-label = Block
+
+invalid-uri-message = Please enter a valid hostname
+invalid-uri-title = Invalid Hostname Entered
diff --git a/comm/mail/locales/en-US/messenger/preferences/preferences.ftl b/comm/mail/locales/en-US/messenger/preferences/preferences.ftl
new file mode 100644
index 0000000000..592e106cf8
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/preferences.ftl
@@ -0,0 +1,1026 @@
+# 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/.
+
+close-button =
+ .aria-label = Close
+
+preferences-doc-title2 = Settings
+
+category-list =
+ .aria-label = Categories
+
+pane-general-title = General
+category-general =
+ .tooltiptext = { pane-general-title }
+
+pane-compose-title = Composition
+category-compose =
+ .tooltiptext = Composition
+
+pane-privacy-title = Privacy & Security
+category-privacy =
+ .tooltiptext = Privacy & Security
+
+pane-chat-title = Chat
+category-chat =
+ .tooltiptext = Chat
+
+pane-calendar-title = Calendar
+category-calendar =
+ .tooltiptext = Calendar
+
+pane-sync-title = Sync
+category-sync =
+ .tooltiptext = Sync
+
+general-language-and-appearance-header = Language & Appearance
+
+general-incoming-mail-header = Incoming Mails
+
+general-files-and-attachment-header = Files & Attachments
+
+general-tags-header = Tags
+
+general-reading-and-display-header = Reading & Display
+
+general-updates-header = Updates
+
+general-network-and-diskspace-header = Network & Disk Space
+
+general-indexing-label = Indexing
+
+composition-category-header = Composition
+
+composition-attachments-header = Attachments
+
+composition-spelling-title = Spelling
+
+compose-html-style-title = HTML Style
+
+composition-addressing-header = Addressing
+
+privacy-main-header = Privacy
+
+privacy-passwords-header = Passwords
+
+privacy-junk-header = Junk
+
+collection-header = { -brand-short-name } Data Collection and Use
+
+collection-description = We strive to provide you with choices and collect only what we need to provide and improve { -brand-short-name } for everyone. We always ask permission before receiving personal information.
+collection-privacy-notice = Privacy Notice
+
+collection-health-report-telemetry-disabled = You’re no longer allowing { -vendor-short-name } to capture technical and interaction data. All past data will be deleted within 30 days.
+collection-health-report-telemetry-disabled-link = Learn more
+
+collection-health-report =
+ .label = Allow { -brand-short-name } to send technical and interaction data to { -vendor-short-name }
+ .accesskey = r
+collection-health-report-link = Learn more
+
+# This message is displayed above disabled data sharing options in developer builds
+# or builds with no Telemetry support available.
+collection-health-report-disabled = Data reporting is disabled for this build configuration
+
+collection-backlogged-crash-reports =
+ .label = Allow { -brand-short-name } to send backlogged crash reports on your behalf
+ .accesskey = c
+collection-backlogged-crash-reports-link = Learn more
+
+privacy-security-header = Security
+
+privacy-scam-detection-title = Scam Detection
+
+privacy-anti-virus-title = Antivirus
+
+privacy-certificates-title = Certificates
+
+chat-pane-header = Chat
+
+chat-status-title = Status
+
+chat-notifications-title = Notifications
+
+chat-pane-styling-header = Styling
+
+choose-messenger-language-description = Choose the languages used to display menus, messages, and notifications from { -brand-short-name }.
+manage-messenger-languages-button =
+ .label = Set Alternatives…
+ .accesskey = l
+confirm-messenger-language-change-description = Restart { -brand-short-name } to apply these changes
+confirm-messenger-language-change-button = Apply and Restart
+
+update-setting-write-failure-title = Error saving Update preferences
+
+# Variables:
+# $path (String) - Path to the configuration file
+# The newlines between the main text and the line containing the path is
+# intentional so the path is easier to identify.
+update-setting-write-failure-message =
+ { -brand-short-name } encountered an error and didn’t save this change. Note that setting this update preference requires permission to write to the file below. You or a system administrator may be able resolve the error by granting the Users group full control to this file.
+
+ Could not write to file: { $path }
+
+update-in-progress-title = Update In Progress
+
+update-in-progress-message = Do you want { -brand-short-name } to continue with this update?
+
+update-in-progress-ok-button = &Discard
+# Continue is the cancel button so pressing escape or using a platform standard
+# method of closing the UI will not discard the update.
+update-in-progress-cancel-button = &Continue
+
+account-button = Account Settings
+open-addons-sidebar-button = Add-ons and Themes
+
+## OS Authentication dialog
+
+# This message can be seen by trying to add a Primary Password.
+primary-password-os-auth-dialog-message-win = To create a Primary Password, enter your Windows login credentials. This helps protect the security of your accounts.
+
+# This message can be seen by trying to add a Primary Password.
+# The macOS strings are preceded by the operating system with "Thunderbird is trying to "
+# and includes subtitle of "Enter password for the user "xxx" to allow this." These
+# notes are only valid for English. Please test in your locale.
+primary-password-os-auth-dialog-message-macosx = create a Primary Password
+
+# Don't change this label.
+master-password-os-auth-dialog-caption = { -brand-full-name }
+
+## General Tab
+
+focus-search-shortcut =
+ .key = f
+focus-search-shortcut-alt =
+ .key = k
+
+general-legend = { -brand-short-name } Start Page
+
+start-page-label =
+ .label = When { -brand-short-name } launches, show the Start Page in the message area
+ .accesskey = W
+
+location-label =
+ .value = Location:
+ .accesskey = o
+restore-default-label =
+ .label = Restore Default
+ .accesskey = R
+
+default-search-engine = Default Search Engine
+add-web-search-engine =
+ .label = Add…
+ .accesskey = A
+remove-search-engine =
+ .label = Remove
+ .accesskey = v
+
+add-opensearch-provider-title = Add OpenSearch Provider
+add-opensearch-provider-text = Enter the URL of the OpenSearch provider to add. Either use the direct URL of the OpenSearch Description file, or a URL where it can be auto-discovered.
+
+adding-opensearch-provider-failed-title = Adding OpenSearch Provider Failed
+# Variables:
+# $url (String) - URL an OpenSearch provider was requested for.
+adding-opensearch-provider-failed-text = Could not add OpenSearch Provider for { $url }.
+
+minimize-to-tray-label =
+ .label = When { -brand-short-name } is minimized, move it to the tray
+ .accesskey = m
+
+new-message-arrival = When new messages arrive:
+mail-play-sound-label =
+ .label = { PLATFORM() ->
+ [macos] Play the following sound file:
+ *[other] Play a sound
+ }
+ .accesskey = d
+mail-play-button =
+ .label = Play
+ .accesskey = P
+
+change-dock-icon = Change preferences for the app icon
+app-icon-options =
+ .label = App Icon Options…
+ .accesskey = n
+
+notification-settings2 = Alerts and the default sound can be disabled on the Notification pane of System Settings.
+
+animated-alert-label =
+ .label = Show an alert
+ .accesskey = S
+customize-alert-label =
+ .label = Customize…
+ .accesskey = C
+
+biff-use-system-alert =
+ .label = Use the system notification
+
+tray-icon-unread-label =
+ .label = Show a tray icon for unread messages
+ .accesskey = t
+
+tray-icon-unread-description = Recommended when using small taskbar buttons
+
+mail-system-sound-label =
+ .label = Default system sound for new mail
+ .accesskey = D
+mail-custom-sound-label =
+ .label = Use the following sound file
+ .accesskey = U
+mail-browse-sound-button =
+ .label = Browse…
+ .accesskey = B
+
+enable-gloda-search-label =
+ .label = Enable Global Search and Indexer
+ .accesskey = G
+
+datetime-formatting-legend = Date and Time Formatting
+language-selector-legend = Language
+
+allow-hw-accel =
+ .label = Use hardware acceleration when available
+ .accesskey = h
+
+store-type-label =
+ .value = Message Store Type for new accounts:
+ .accesskey = T
+
+mbox-store-label =
+ .label = File per folder (mbox)
+maildir-store-label =
+ .label = File per message (maildir)
+
+scrolling-legend = Scrolling
+autoscroll-label =
+ .label = Use autoscrolling
+ .accesskey = U
+smooth-scrolling-label =
+ .label = Use smooth scrolling
+ .accesskey = m
+browsing-gtk-use-non-overlay-scrollbars =
+ .label = Always show scrollbars
+ .accesskey = c
+
+window-layout-legend = Window Layout
+
+draw-in-titlebar-label =
+ .label = Hide system window titlebar
+ .accesskey = H
+
+auto-hide-tabbar-label =
+ .label = Auto hide tab bar
+ .accesskey = A
+auto-hide-tabbar-description = Hide the tab bar when only a single tab is open
+
+system-integration-legend = System Integration
+always-check-default =
+ .label = Always check to see if { -brand-short-name } is the default mail client on startup
+ .accesskey = A
+check-default-button =
+ .label = Check Now…
+ .accesskey = N
+
+# Note: This is the search engine name for all the different platforms.
+# Platforms that don't support it should be left blank.
+search-engine-name = { PLATFORM() ->
+ [macos] Spotlight
+ [windows] Windows Search
+ *[other] { "" }
+}
+
+search-integration-label =
+ .label = Allow { search-engine-name } to search messages
+ .accesskey = S
+
+config-editor-button =
+ .label = Config Editor…
+ .accesskey = C
+
+return-receipts-description = Determine how { -brand-short-name } handles return receipts
+return-receipts-button =
+ .label = Return Receipts…
+ .accesskey = R
+
+update-app-legend = { -brand-short-name } Updates
+
+# Variables:
+# $version (String): version of Thunderbird, e.g. 68.0.1
+update-app-version = Version { $version }
+
+allow-description = Allow { -brand-short-name } to
+automatic-updates-label =
+ .label = Automatically install updates (recommended: improved security)
+ .accesskey = A
+check-updates-label =
+ .label = Check for updates, but let me choose whether to install them
+ .accesskey = C
+
+update-history-button =
+ .label = Show Update History
+ .accesskey = p
+
+use-service =
+ .label = Use a background service to install updates
+ .accesskey = b
+
+cross-user-udpate-warning = This setting will apply to all Windows accounts and { -brand-short-name } profiles using this installation of { -brand-short-name }.
+
+networking-legend = Connection
+proxy-config-description = Configure how { -brand-short-name } connects to the Internet
+
+network-settings-button =
+ .label = Settings…
+ .accesskey = S
+
+offline-legend = Offline
+offline-settings = Configure offline settings
+
+offline-settings-button =
+ .label = Offline…
+ .accesskey = O
+
+diskspace-legend = Disk Space
+offline-compact-folder =
+ .label = Compact all folders when it will save over
+ .accesskey = a
+
+offline-compact-folder-automatically =
+ .label = Ask every time before compacting
+ .accesskey = b
+
+compact-folder-size =
+ .value = MB in total
+
+## Note: The entities use-cache-before and use-cache-after appear on a single
+## line in preferences as follows:
+## use-cache-before [ textbox for cache size in MB ] use-cache-after
+
+use-cache-before =
+ .value = Use up to
+ .accesskey = U
+
+use-cache-after = MB of space for the cache
+
+##
+
+smart-cache-label =
+ .label = Override automatic cache management
+ .accesskey = v
+
+clear-cache-button =
+ .label = Clear Now
+ .accesskey = C
+
+clear-cache-shutdown-label =
+ .label = Clear cache on shutdown
+ .accesskey = s
+
+fonts-legend = Fonts & Colors
+
+default-font-label =
+ .value = Default font:
+ .accesskey = D
+
+default-size-label =
+ .value = Size:
+ .accesskey = S
+
+font-options-button =
+ .label = Advanced…
+ .accesskey = A
+
+color-options-button =
+ .label = Colors…
+ .accesskey = C
+
+display-width-legend = Plain Text Messages
+
+# Note : convert-emoticons-label 'Emoticons' are also known as 'Smileys', e.g. :-)
+convert-emoticons-label =
+ .label = Display emoticons as graphics
+ .accesskey = e
+
+display-text-label = When displaying quoted plain text messages:
+
+style-label =
+ .value = Style:
+ .accesskey = y
+
+regular-style-item =
+ .label = Regular
+bold-style-item =
+ .label = Bold
+italic-style-item =
+ .label = Italic
+bold-italic-style-item =
+ .label = Bold Italic
+
+size-label =
+ .value = Size:
+ .accesskey = z
+
+regular-size-item =
+ .label = Regular
+bigger-size-item =
+ .label = Bigger
+smaller-size-item =
+ .label = Smaller
+
+quoted-text-color =
+ .label = Color:
+ .accesskey = o
+
+search-handler-table =
+ .placeholder = Filter content types and actions
+
+type-column-header = Content Type
+
+action-column-header = Action
+
+save-to-label =
+ .label = Save files to
+ .accesskey = S
+
+choose-folder-label =
+ .label = { PLATFORM() ->
+ [macos] Choose…
+ *[other] Browse…
+ }
+ .accesskey = { PLATFORM() ->
+ [macos] C
+ *[other] B
+ }
+
+always-ask-label =
+ .label = Always ask me where to save files
+ .accesskey = A
+
+
+display-tags-text = Tags can be used to categorize and prioritize your messages.
+
+new-tag-button =
+ .label = New…
+ .accesskey = N
+
+edit-tag-button =
+ .label = Edit…
+ .accesskey = E
+
+delete-tag-button =
+ .label = Delete
+ .accesskey = D
+
+auto-mark-as-read =
+ .label = Automatically mark messages as read
+ .accesskey = A
+
+mark-read-no-delay =
+ .label = Immediately on display
+ .accesskey = o
+
+view-attachments-inline =
+ .label = View attachments inline
+ .accesskey = V
+
+## Note: This will concatenate to "After displaying for [___] seconds",
+## using (mark-read-delay) and a number (seconds-label).
+
+mark-read-delay =
+ .label = After displaying for
+ .accesskey = d
+
+seconds-label = seconds
+
+##
+
+open-msg-label =
+ .value = Open messages in:
+
+open-msg-tab =
+ .label = A new tab
+ .accesskey = t
+
+open-msg-window =
+ .label = A new message window
+ .accesskey = n
+
+open-msg-ex-window =
+ .label = An existing message window
+ .accesskey = e
+
+close-move-delete =
+ .label = Close message window/tab on move or delete
+ .accesskey = C
+
+display-name-label =
+ .value = Display name:
+
+condensed-addresses-label =
+ .label = Show only display name for people in my address book
+ .accesskey = S
+
+## Compose Tab
+
+forward-label =
+ .value = Forward messages:
+ .accesskey = F
+
+inline-label =
+ .label = Inline
+
+as-attachment-label =
+ .label = As Attachment
+
+extension-label =
+ .label = add extension to file name
+ .accesskey = e
+
+## Note: This will concatenate to "Auto Save every [___] minutes",
+## using (auto-save-label) and a number (auto-save-end).
+
+auto-save-label =
+ .label = Auto Save every
+ .accesskey = A
+
+auto-save-end = minutes
+
+##
+
+warn-on-send-accel-key =
+ .label = Confirm when using keyboard shortcut to send message
+ .accesskey = C
+
+add-link-previews =
+ .label = Add link previews when pasting URLs
+ .accesskey = i
+
+spellcheck-label =
+ .label = Check spelling before sending
+ .accesskey = C
+
+spellcheck-inline-label =
+ .label = Enable spellcheck as you type
+ .accesskey = E
+
+language-popup-label =
+ .value = Language:
+ .accesskey = L
+
+download-dictionaries-link = Download More Dictionaries
+
+font-label =
+ .value = Font:
+ .accesskey = n
+
+font-size-label =
+ .value = Size:
+ .accesskey = z
+
+default-colors-label =
+ .label = Use reader’s default colors
+ .accesskey = d
+
+font-color-label =
+ .value = Text Color:
+ .accesskey = T
+
+bg-color-label =
+ .value = Background Color:
+ .accesskey = B
+
+restore-html-label =
+ .label = Restore Defaults
+ .accesskey = R
+
+default-format-label =
+ .label = Use Paragraph format instead of Body Text by default
+ .accesskey = P
+
+compose-send-format-title = Sending Format
+
+compose-send-automatic-option =
+ .label = Automatic
+
+compose-send-automatic-description = If no styling is used in the message, send Plain Text. Otherwise, send HTML with a Plain Text fallback.
+
+compose-send-both-option =
+ .label = Both HTML and Plain Text
+
+compose-send-both-description = The recipient’s email application will determine which version to show.
+
+compose-send-html-option =
+ .label = Only HTML
+
+compose-send-html-description = Some recipients may not be able to read the message without a Plain Text fallback.
+
+compose-send-plain-option =
+ .label = Only Plain Text
+
+compose-send-plain-description = Some styling will be converted into a plain alternative, while other composition features will be disabled.
+
+autocomplete-description = When addressing messages, look for matching entries in:
+
+ab-label =
+ .label = Local Address Books
+ .accesskey = L
+
+directories-label =
+ .label = Directory Server:
+ .accesskey = D
+
+directories-none-label =
+ .none = None
+
+edit-directories-label =
+ .label = Edit Directories…
+ .accesskey = E
+
+email-picker-label =
+ .label = Automatically add outgoing email addresses to my:
+ .accesskey = A
+
+default-directory-label =
+ .value = Default startup directory in the address book window:
+ .accesskey = S
+
+default-last-label =
+ .none = Last used directory
+
+attachment-label =
+ .label = Check for missing attachments
+ .accesskey = m
+
+attachment-options-label =
+ .label = Keywords…
+ .accesskey = K
+
+enable-cloud-share =
+ .label = Offer to share for files larger than
+cloud-share-size =
+ .value = MB
+
+add-cloud-account =
+ .label = Add…
+ .accesskey = A
+ .defaultlabel = Add…
+
+remove-cloud-account =
+ .label = Remove
+ .accesskey = R
+
+find-cloud-providers =
+ .value = Find more providers…
+
+cloud-account-description = Add a new Filelink storage service
+
+## Privacy Tab
+
+mail-content = Mail Content
+
+remote-content-label =
+ .label = Allow remote content in messages
+ .accesskey = m
+
+exceptions-button =
+ .label = Exceptions…
+ .accesskey = E
+
+remote-content-info =
+ .value = Learn more about the privacy issues of remote content
+
+web-content = Web Content
+
+history-label =
+ .label = Remember websites and links I’ve visited
+ .accesskey = R
+
+cookies-label =
+ .label = Accept cookies from sites
+ .accesskey = A
+
+third-party-label =
+ .value = Accept third-party cookies:
+ .accesskey = c
+
+third-party-always =
+ .label = Always
+third-party-never =
+ .label = Never
+third-party-visited =
+ .label = From visited
+
+cookies-button =
+ .label = Show Cookies…
+ .accesskey = S
+
+do-not-track-label =
+ .label = Send websites a “Do Not Track†signal that you don’t want to be tracked
+ .accesskey = n
+
+dnt-learn-more-button =
+ .value = Learn more
+
+passwords-description = { -brand-short-name } can remember passwords for all of your accounts.
+
+passwords-button =
+ .label = Saved Passwords…
+ .accesskey = S
+
+primary-password-description = A Primary Password protects all your passwords, but you must enter it once per session.
+
+primary-password-label =
+ .label = Use a Primary Password
+ .accesskey = U
+
+primary-password-button =
+ .label = Change Primary Password…
+ .accesskey = C
+
+forms-primary-pw-fips-title = You are currently in FIPS mode. FIPS requires a non-empty Primary Password.
+forms-master-pw-fips-desc = Password Change Failed
+
+
+junk-description = Set your default junk mail settings. Account-specific junk mail settings can be configured in Account Settings.
+
+junk-label =
+ .label = When I mark messages as junk:
+ .accesskey = W
+
+junk-move-label =
+ .label = Move them to the account’s “Junk†folder
+ .accesskey = o
+
+junk-delete-label =
+ .label = Delete them
+ .accesskey = D
+
+junk-read-label =
+ .label = Mark messages determined to be Junk as read
+ .accesskey = M
+
+junk-log-label =
+ .label = Enable adaptive junk filter logging
+ .accesskey = E
+
+junk-log-button =
+ .label = Show log
+ .accesskey = S
+
+reset-junk-button =
+ .label = Reset Training Data
+ .accesskey = R
+
+phishing-description = { -brand-short-name } can analyze messages for suspected email scams by looking for common techniques used to deceive you.
+
+phishing-label =
+ .label = Tell me if the message I’m reading is a suspected email scam
+ .accesskey = T
+
+antivirus-description = { -brand-short-name } can make it easy for antivirus software to analyze incoming mail messages for viruses before they are stored locally.
+
+antivirus-label =
+ .label = Allow antivirus clients to quarantine individual incoming messages
+ .accesskey = A
+
+certificate-description = When a server requests my personal certificate:
+
+certificate-auto =
+ .label = Select one automatically
+ .accesskey = S
+
+certificate-ask =
+ .label = Ask me every time
+ .accesskey = A
+
+ocsp-label =
+ .label = Query OCSP responder servers to confirm the current validity of certificates
+ .accesskey = Q
+
+certificate-button =
+ .label = Manage Certificates…
+ .accesskey = M
+
+security-devices-button =
+ .label = Security Devices…
+ .accesskey = D
+
+email-e2ee-header = Email End-To-End Encryption
+
+account-settings = Account Settings
+
+email-e2ee-enable-info =
+ Set up email accounts and identities for End-To-End Encryption in Account Settings.
+
+email-e2ee-automatism = Automatic Use of Encryption
+email-e2ee-automatism-pre =
+ { -brand-short-name } can assist by automatically enabling or disabling encryption while composing an email.
+ Auto enabling/disabling is based on the availability of valid and accepted correspondents’ keys or certificates.
+email-e2ee-auto-on =
+ .label = Automatically enable encryption when possible
+email-e2ee-auto-off =
+ .label = Automatically disable encryption when recipients change and encryption is no longer possible
+email-e2ee-auto-off-notify =
+ .label = Show a notification whenever encryption is disabled automatically
+email-e2ee-automatism-post =
+ Automatic decisions may be overridden by manually enabling or disabling encryption when composing a message.
+ Note: encryption is always automatically enabled when replying to an encrypted message.
+
+## Chat Tab
+
+startup-label =
+ .value = When { -brand-short-name } starts:
+ .accesskey = s
+
+offline-label =
+ .label = Keep my Chat Accounts offline
+
+auto-connect-label =
+ .label = Connect my chat accounts automatically
+
+## Note: idle-label is displayed first, then there's a field where the user
+## can enter a number, and itemTime is displayed at the end of the line.
+## The translations of the idle-label and idle-time-label parts don't have
+## to mean the exact same thing as in English; please try instead to
+## translate the whole sentence.
+
+idle-label =
+ .label = Let my contacts know that I am Idle after
+ .accesskey = I
+
+idle-time-label = minutes of inactivity
+
+##
+
+away-message-label =
+ .label = and set my status to Away with this status message:
+ .accesskey = A
+
+send-typing-label =
+ .label = Send typing notifications in conversations
+ .accesskey = t
+
+notification-label = When messages directed at you arrive:
+
+show-notification-label =
+ .label = Show a notification:
+ .accesskey = c
+
+notification-all =
+ .label = with sender’s name and message preview
+notification-name =
+ .label = with sender’s name only
+notification-empty =
+ .label = without any info
+
+notification-type-label =
+ .label = { PLATFORM() ->
+ [macos] Animate dock icon
+ *[other] Flash the taskbar item
+ }
+ .accesskey = { PLATFORM() ->
+ [macos] o
+ *[other] F
+ }
+
+chat-play-sound-label =
+ .label = Play a sound
+ .accesskey = d
+
+chat-play-button =
+ .label = Play
+ .accesskey = P
+
+chat-system-sound-label =
+ .label = Default system sound for new mail
+ .accesskey = D
+
+chat-custom-sound-label =
+ .label = Use the following sound file
+ .accesskey = U
+
+chat-browse-sound-button =
+ .label = Browse…
+ .accesskey = B
+
+theme-label =
+ .value = Theme:
+ .accesskey = T
+
+style-mail =
+ .label = { -brand-short-name }
+style-bubbles =
+ .label = Bubbles
+style-dark =
+ .label = Dark
+style-paper =
+ .label = Paper Sheets
+style-simple =
+ .label = Simple
+
+preview-label = Preview:
+no-preview-label = No preview available
+no-preview-description = This theme is not valid or is currently unavailable (disabled addon, safe-mode, …).
+
+chat-variant-label =
+ .value = Variant:
+ .accesskey = V
+
+# This is used to determine the width of the search field in about:preferences,
+# in order to make the entire placeholder string visible
+#
+# Please keep the placeholder string short to avoid truncation.
+#
+# Notice: The value of the `.style` attribute is a CSS string, and the `width`
+# is the name of the CSS property. It is intended only to adjust the element's width.
+# Do not translate.
+search-preferences-input2 =
+ .style = width: 15.4em
+ .placeholder = Find in Settings
+
+## Settings UI Search Results
+
+search-results-header = Search Results
+
+# `<span data-l10n-name="query"></span>` will be replaced by the search term.
+search-results-empty-message2 = { PLATFORM() ->
+ [windows] Sorry! There are no results in Options for “<span data-l10n-name="query"></span>â€.
+ *[other] Sorry! There are no results in Settings for “<span data-l10n-name="query"></span>â€.
+}
+
+search-results-help-link = Need help? Visit <a data-l10n-name="url">{ -brand-short-name } Support</a>
+
+## Sync Tab
+
+sync-signedout-caption = Take Your Web With You
+
+sync-signedout-description = Synchronize your accounts, address books, calendars, add-ons, and settings across all your devices.
+
+# Note: "Sync" represents the Firefox Sync product so it shouldn't be translated.
+sync-signedout-account-signin-btn = Sign in to Sync…
+
+sync-pane-header = Sync
+
+# Variables:
+# $userEmail (String) - The email logged into Sync.
+sync-pane-email-not-verified = “{ $userEmail }†is not verified.
+
+# Variables:
+# $userEmail (String) - The email logged into Sync.
+sync-signedin-login-failure = Please sign in to reconnect “{ $userEmail }â€
+
+sync-pane-resend-verification = Resend verification
+
+sync-pane-sign-in = Sign in
+
+sync-pane-remove-account = Remove account
+
+sync-pane-edit-photo =
+ .title = Change profile picture
+
+sync-pane-manage-account = Manage account
+
+sync-pane-sign-out = Sign out…
+
+sync-pane-device-name-title = Device Name
+
+sync-pane-change-device-name = Change device name
+
+sync-pane-cancel = Cancel
+
+sync-pane-save = Save
+
+sync-pane-show-synced-header-on = Syncing ON
+
+sync-pane-show-synced-header-off = Syncing OFF
+
+sync-pane-sync-now = Sync Now
+
+sync-panel-sync-now-syncing = Syncing…
+
+show-synced-list-heading = You are currently syncing these items:
+
+show-synced-learn-more = Learn more…
+
+show-synced-item-account = Email Accounts
+
+show-synced-item-address = Address Books
+
+show-synced-item-calendar = Calendars
+
+show-synced-item-identity = Identities
+
+show-synced-item-passwords = Passwords
+
+show-synced-change = Change…
+
+synced-acount-item-server-config = Server configuration
+
+synced-acount-item-filters = Filters
+
+synced-acount-item-keys = OpenPGP - S/MIME
+
+sync-disconnected-text = Synchronize your email accounts, address books, calendars, and identities across all your devices.
+
+sync-disconnected-turn-on-sync = Turn on Syncing…
diff --git a/comm/mail/locales/en-US/messenger/preferences/receipts.ftl b/comm/mail/locales/en-US/messenger/preferences/receipts.ftl
new file mode 100644
index 0000000000..7d65333000
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/receipts.ftl
@@ -0,0 +1,51 @@
+# 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/.
+
+receipts-dialog-window =
+ .title = Return Receipts
+
+return-receipt-checkbox-control =
+ .label = When sending messages, always request a return receipt
+ .accesskey = W
+
+receipt-arrive-label = When a receipt arrives:
+
+receipt-leave-radio-control =
+ .label = Leave it in my Inbox
+ .accesskey = i
+
+receipt-move-radio-control =
+ .label = Move it to my “Sent†folder
+ .accesskey = m
+
+receipt-request-label = When I receive a request for a return receipt:
+
+receipt-return-never-radio-control =
+ .label = Never send a return receipt
+ .accesskey = n
+
+receipt-return-some-radio-control =
+ .label = Allow return receipts for some messages
+ .accesskey = r
+
+receipt-not-to-cc-label =
+ .value = If I’m not in the To or Cc of the message:
+ .accesskey = f
+
+receipt-send-never-label =
+ .label = Never send
+
+receipt-send-always-label =
+ .label = Always send
+
+receipt-send-ask-label =
+ .label = Ask me
+
+sender-outside-domain-label =
+ .value = If the sender is outside my domain:
+ .accesskey = t
+
+other-cases-text-label =
+ .value = In all other cases:
+ .accesskey = a
diff --git a/comm/mail/locales/en-US/messenger/preferences/sync-dialog.ftl b/comm/mail/locales/en-US/messenger/preferences/sync-dialog.ftl
new file mode 100644
index 0000000000..da03ca42bd
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/sync-dialog.ftl
@@ -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/.
+
+config-sync-dailog-title =
+ .title = Choose what to sync
+
+sync-dialog =
+ .buttonlabelaccept = Save Changes
+ .buttonaccesskeyaccept = S
+ .buttonlabelextra2 = Disconnect…
+ .buttonaccesskeyextra2 = D
diff --git a/comm/mail/locales/en-US/messenger/preferences/system-integration.ftl b/comm/mail/locales/en-US/messenger/preferences/system-integration.ftl
new file mode 100644
index 0000000000..f9723bf8de
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/preferences/system-integration.ftl
@@ -0,0 +1,44 @@
+# 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/.
+
+system-integration-title =
+ .title = System Integration
+
+system-integration-dialog =
+ .buttonlabelaccept = Set as Default
+ .buttonlabelcancel = Skip Integration
+ .buttonlabelcancel2 = Cancel
+
+default-client-intro = Use { -brand-short-name } as the default client for:
+
+unset-default-tooltip = It is not possible to unset { -brand-short-name } as the default client within { -brand-short-name }. To make another application the default you must use its “Set as default†dialog.
+
+checkbox-email-label =
+ .label = Email
+ .tooltiptext = { unset-default-tooltip }
+checkbox-newsgroups-label =
+ .label = Newsgroups
+ .tooltiptext = { unset-default-tooltip }
+checkbox-feeds-label =
+ .label = Feeds
+ .tooltiptext = { unset-default-tooltip }
+checkbox-calendar-label =
+ .label = Calendar
+ .tooltiptext = { unset-default-tooltip }
+
+# Note: This is the search engine name for all the different platforms.
+# Platforms that don't support it should be left blank.
+system-search-engine-name = { PLATFORM() ->
+ [macos] Spotlight
+ [windows] Windows Search
+ *[other] { "" }
+}
+
+system-search-integration-label =
+ .label = Allow { system-search-engine-name } to search messages
+ .accesskey = S
+
+check-on-startup-label =
+ .label = Always perform this check when starting { -brand-short-name }
+ .accesskey = A
diff --git a/comm/mail/locales/en-US/messenger/shortcuts.ftl b/comm/mail/locales/en-US/messenger/shortcuts.ftl
new file mode 100644
index 0000000000..89c76072b1
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/shortcuts.ftl
@@ -0,0 +1,114 @@
+# 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/.
+
+## Shortcuts
+## Variables:
+## $key (String) - The shortcut key.
+
+shortcut-key = { $key }
+
+meta-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌘ { $key }
+ *[other] Meta+{ $key }
+}
+
+ctrl-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌃ { $key }
+ *[other] Ctrl+{ $key }
+}
+
+shift-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⇧ { $key }
+ *[other] Shift+{ $key }
+}
+
+alt-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌥ { $key }
+ *[other] Alt+{ $key }
+}
+
+meta-ctrl-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌃ ⌘ { $key }
+ *[other] Meta+Ctrl+{ $key }
+}
+
+meta-alt-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌥ ⌘ { $key }
+ *[other] Meta+Alt+{ $key }
+}
+
+ctrl-alt-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌃ ⌥ { $key }
+ *[other] Ctrl+Alt+{ $key }
+}
+
+meta-ctrl-alt-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌃ ⌥ ⌘ { $key }
+ *[other] Meta+Ctrl+Alt+{ $key }
+}
+
+meta-shift-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⇧ ⌘ { $key }
+ *[other] Meta+Shift+{ $key }
+}
+
+ctrl-shift-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌃ ⇧ { $key }
+ *[other] Ctrl+Shift+{ $key }
+}
+
+meta-ctrl-shift-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌃ ⇧ ⌘ { $key }
+ *[other] Meta+Ctrl+Shift+{ $key }
+}
+
+alt-shift-shortcut-key = {
+ PLATFORM() ->
+ [macos] ⌥ ⇧ { $key }
+ *[other] Alt+Shift+{ $key }
+}
+
+meta-shift-alt-shortcut-key2 = {
+ PLATFORM() ->
+ [macos] ⌥ ⇧ ⌘ { $key }
+ *[other] Meta+Alt+Shift+{ $key }
+}
+
+ctrl-shift-alt-shortcut-key2 = {
+ PLATFORM() ->
+ [macos] ⌃ ⌥ ⇧ { $key }
+ *[other] Ctrl+Alt+Shift+{ $key }
+}
+
+meta-ctrl-shift-alt-shortcut-key2 = {
+ PLATFORM() ->
+ [macos] ⌃ ⌥ ⇧ ⌘ { $key }
+ *[other] Meta+Ctrl+Alt+Shift+{ $key }
+}
+
+## Shortcut and label combined strings
+
+# Variables:
+# $title (String): The title coming from the original element.
+# $shortcut (String): The shortcut generated from the keystroke combination.
+button-shortcut-string =
+ .title = { $title } ({ $shortcut })
+
+# Variables:
+# $label (String): The text label coming from the original element.
+# $shortcut (String): The shortcut generated from the keystroke combination.
+menuitem-shortcut-string =
+ .label = { $label }
+ .acceltext = { $shortcut }
diff --git a/comm/mail/locales/en-US/messenger/treeView.ftl b/comm/mail/locales/en-US/messenger/treeView.ftl
new file mode 100644
index 0000000000..d984a36bf1
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/treeView.ftl
@@ -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/.
+
+## Table
+
+tree-list-view-row-select =
+ .alt = Checkbox to toggle select the current row
+ .title = Select the current row
+
+tree-list-view-row-deselect =
+ .alt = Checkbox to toggle select the current row
+ .title = Deselect the current row
+
+tree-list-view-row-delete =
+ .title = Delete the current row
+
+tree-list-view-row-restore =
+ .title = Restore the current row
+
+tree-list-view-column-picker =
+ .title = Select columns to display
+
+tree-list-view-column-picker-restore =
+ .label = Restore column order
+
+tree-list-view-row-thread-button =
+ .title = This is a threaded message
+
+tree-list-view-row-ignored-thread = Thread ignored
+
+tree-list-view-row-ignored-thread-button =
+ .title = This threaded message is ignored
+
+tree-list-view-row-ignored-subthread = Subthread ignored
+
+tree-list-view-row-ignored-subthread-button =
+ .title = This subthread is ignored
+
+tree-list-view-row-watched-thread = Thread watched
+
+tree-list-view-row-watched-thread-button =
+ .title = This threaded message is watched
+
+tree-list-view-row-flagged =
+ .alt = Starred message indicator
+ .title = Message starred
+
+tree-list-view-row-flag =
+ .alt = Starred message indicator
+ .title = Message not starred
+
+tree-list-view-row-attach =
+ .alt = Attachment indicator
+ .title = Message contains attachments
+
+tree-list-view-row-spam =
+ .alt = Spam status indicator
+ .title = Message marked as spam
+
+tree-list-view-row-not-spam =
+ .alt = Spam status indicator
+ .title = Message not marked as spam
+
+tree-list-view-row-read =
+ .alt = Read status indicator
+ .title = Message read status
+
+tree-list-view-row-not-read =
+ .alt = Unread status indicator
+ .title = Message unread status
diff --git a/comm/mail/locales/en-US/messenger/troubleshootMode.ftl b/comm/mail/locales/en-US/messenger/troubleshootMode.ftl
new file mode 100644
index 0000000000..69cfa347f9
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/troubleshootMode.ftl
@@ -0,0 +1,39 @@
+# 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/.
+
+troubleshoot-mode-window =
+ .title = { -brand-short-name } Troubleshoot Mode
+ .style = width: 40em;
+
+troubleshoot-mode-description = Use { -brand-short-name } troubleshoot mode to diagnose issues. Your add-ons and customizations will be temporarily disabled.
+
+troubleshoot-mode-description2 = You can make some or all of these changes permanent:
+
+troubleshoot-mode-disable-addons =
+ .label = Disable all add-ons
+ .accesskey = D
+
+troubleshoot-mode-reset-toolbars =
+ .label = Reset toolbars and controls
+ .accesskey = R
+
+troubleshoot-mode-change-and-restart =
+ .label = Make Changes and Restart
+ .accesskey = M
+
+troubleshoot-mode-continue =
+ .label = Continue in Troubleshoot Mode
+ .accesskey = C
+
+troubleshoot-mode-quit =
+ .label =
+ { PLATFORM() ->
+ [windows] Exit
+ *[other] Quit
+ }
+ .accesskey =
+ { PLATFORM() ->
+ [windows] x
+ *[other] Q
+ }
diff --git a/comm/mail/locales/en-US/messenger/unifiedToolbar.ftl b/comm/mail/locales/en-US/messenger/unifiedToolbar.ftl
new file mode 100644
index 0000000000..59df02aee5
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/unifiedToolbar.ftl
@@ -0,0 +1,129 @@
+# 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/.
+
+### Unified Toolbar strings
+
+## Search bar
+
+search-bar-button =
+ .alt = Search
+
+search-bar-item =
+ .label = Search:
+
+search-bar-placeholder = Search…
+
+# Search bar placeholder with formatted key shortcut hint (platform dependent).
+# The key after the control modifier should match the key from quickSearchCmd.key
+# in messenger.dtd.
+search-bar-placeholder-with-key2 = {
+ PLATFORM() ->
+ [macos] {search-bar-placeholder} <kbd>⌘</kbd> <kbd>K</kbd>
+ *[other] {search-bar-placeholder} <kbd>Ctrl</kbd> + <kbd>K</kbd>
+}
+
+## Unified toolbar context menu
+
+customize-menu-customize =
+ .label = Customize…
+
+## Unified Toolbar customization
+
+customize-title = Customize Toolbars
+
+customize-space-tab-mail = Mail
+ .title = Mail
+
+customize-space-tab-addressbook = Address Book
+ .title = Address Book
+
+customize-space-tab-calendar = Calendar
+ .title = Calendar
+
+customize-space-tab-tasks = Tasks
+ .title = Tasks
+
+customize-space-tab-chat = Chat
+ .title = Chat
+
+customize-space-tab-settings = Settings
+ .title = Settings
+
+customize-restore-default = Restore default
+
+customize-change-appearance = Change appearance…
+
+customize-button-style-label = Button style:
+
+customize-button-style-icons-beside-text-option = Icons beside text
+
+customize-button-style-icons-above-text-option = Icons above text
+
+customize-button-style-icons-only-option = Icons only
+
+customize-button-style-text-only-option = Text only
+
+customize-cancel = Cancel
+
+customize-save = Save
+
+customize-unsaved-changes = Unsaved changes in other spaces
+
+customize-search-bar =
+ .label = Search toolbar buttons…
+
+customize-spaces-tabs =
+ .aria-label = Spaces
+
+customize-main-toolbar-target =
+ .aria-label = Main toolbar
+
+customize-palette-generic-title = Available for all Spaces
+
+customize-palette-mail-specific-title = Available for Mail Space only
+
+customize-palette-addressbook-specific-title = Available for Address Book Space only
+
+customize-palette-calendar-specific-title = Available for Calendar Space only
+
+customize-palette-tasks-specific-title = Available for Tasks Space only
+
+customize-palette-chat-specific-title = Available for Chat Space only
+
+customize-palette-settings-specific-title = Available for Settings Space only
+
+customize-palette-extension-specific-title = Available for this Space only
+
+## Unified toolbar customization palette context menu
+
+# Variables:
+# $target (String) - Name of the target the item should be added to.
+customize-palette-add-to =
+ .label = Add to { $target }
+
+customize-palette-add-everywhere =
+ .label = Add to all toolbars
+
+## Unified toolbar customization target context menu
+
+customize-target-forward =
+ .label = Move forward
+
+customize-target-backward =
+ .label = Move backward
+
+customize-target-remove =
+ .label = Remove
+
+customize-target-remove-everywhere =
+ .label = Remove from all toolbars
+
+customize-target-add-everywhere =
+ .label = Add to all toolbars
+
+customize-target-start =
+ .label = Move to the start
+
+customize-target-end =
+ .label = Move to the end
diff --git a/comm/mail/locales/en-US/messenger/unifiedToolbarItems.ftl b/comm/mail/locales/en-US/messenger/unifiedToolbarItems.ftl
new file mode 100644
index 0000000000..fa96706834
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/unifiedToolbarItems.ftl
@@ -0,0 +1,234 @@
+# 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/.
+
+### Unified Toolbar Item Label strings
+
+spacer-label = Flexible Space
+
+search-bar-label = Search
+
+toolbar-write-message-label = Write
+
+toolbar-write-message =
+ .title = Create a new message
+
+toolbar-move-to-label = Move to
+
+toolbar-move-to =
+ .title = Move selected message
+
+toolbar-unifinder-label = Find Events
+
+toolbar-unifinder =
+ .title = Toggle the find events pane
+
+toolbar-folder-location-label = Folder Location
+
+toolbar-folder-location =
+ .title = Switch to folder
+
+toolbar-edit-event-label = Edit
+
+toolbar-edit-event =
+ .title = Edit selected event or task
+
+toolbar-get-messages-label = Get Messages
+
+toolbar-get-messages =
+ .title = Fetch new messages for all accounts
+
+toolbar-reply-label = Reply
+
+toolbar-reply =
+ .title = Reply to the message
+
+toolbar-reply-all-label = Reply All
+
+toolbar-reply-all =
+ .title = Reply to sender and all recipients
+
+toolbar-reply-to-list-label = Reply to List
+
+toolbar-reply-to-list =
+ .title = Reply to mailing list
+
+toolbar-redirect-label = Redirect
+
+toolbar-redirect =
+ .title = Redirect selected message
+
+toolbar-archive-label = Archive
+
+toolbar-archive =
+ .title = Archive selected messages
+
+toolbar-conversation-label = Conversation
+
+toolbar-conversation =
+ .title = Show conversation of selected message
+
+toolbar-previous-unread-label = Previous Unread
+
+toolbar-previous-unread =
+ .title = Move to the previous unread message
+
+toolbar-previous-label = Previous
+
+toolbar-previous =
+ .title = Move to the previous message
+
+toolbar-next-unread-label = Next Unread
+
+toolbar-next-unread =
+ .title = Move to the next unread message
+
+toolbar-next-label = Next
+
+toolbar-next =
+ .title = Move to the next message
+
+toolbar-junk-label = Junk
+
+toolbar-junk =
+ .title = Mark the selected messages as junk
+
+toolbar-delete-label = Delete
+
+toolbar-delete-title =
+ .title = Delete the selected messages
+
+toolbar-undelete-label = Undelete
+
+toolbar-undelete =
+ .title = Undelete the selected messages
+
+toolbar-compact-label = Compact
+
+toolbar-compact =
+ .title = Remove deleted messages from selected folder
+
+toolbar-add-as-event-label = Add as Event
+
+toolbar-add-as-event =
+ .title = Extract calendaring information from the message and add it to your calendar as an event
+
+toolbar-add-as-task-label = Add as Task
+
+toolbar-add-as-task =
+ .title = Extract calendaring information from the message and add it to your calendar as a task
+
+toolbar-tag-message-label = Tag
+
+toolbar-tag-message =
+ .title = Tag messages
+
+toolbar-forward-inline-label = Forward
+
+toolbar-forward-inline =
+ .title = Forward selected message as inline text
+
+toolbar-forward-attachment-label = Forward as Attachment
+
+toolbar-forward-attachment =
+ .title = Forward selected message as an attachment
+
+toolbar-mark-as-label = Mark
+
+toolbar-mark-as =
+ .title = Mark messages
+
+toolbar-view-picker-label = View
+
+toolbar-view-picker =
+ .title = Customize view of current folder
+
+toolbar-address-book-label = Address Book
+
+toolbar-address-book =
+ .title = Go to the address book
+
+toolbar-chat-label = Chat
+
+toolbar-chat =
+ .title = Show the Chat tab
+
+toolbar-add-ons-and-themes-label = Add-ons and Themes
+
+toolbar-add-ons-and-themes =
+ .title = Manage your add-ons
+
+toolbar-calendar-label = Calendar
+
+toolbar-calendar =
+ .title = Switch to the calendar tab
+
+toolbar-tasks-label = Tasks
+
+toolbar-tasks =
+ .title = Switch to the tasks tab
+
+toolbar-mail-label = Mail
+
+toolbar-mail =
+ .title = Switch to the mail tab
+
+toolbar-print-label = Print
+
+toolbar-print =
+ .title = Print this message
+
+toolbar-quick-filter-bar-label = Quick Filter
+
+toolbar-quick-filter-bar =
+ .title = Filter messages
+
+toolbar-synchronize-label = Synchronize
+
+toolbar-synchronize =
+ .title = Reload calendars and synchronize changes
+
+toolbar-delete-event-label = Delete
+
+toolbar-delete-event =
+ .title = Delete selected events or tasks
+
+toolbar-go-to-today-label = Go to Today
+
+toolbar-go-to-today =
+ .title = Go to Today
+
+toolbar-print-event-label = Print
+
+toolbar-print-event =
+ .title = Print events or tasks
+
+toolbar-new-event-label = Event
+
+toolbar-new-event =
+ .title = Create a new event
+
+toolbar-new-task-label = Task
+
+toolbar-new-task =
+ .title = Create a new task
+
+toolbar-go-back-label = Back
+
+toolbar-go-back =
+ .title = Go back one message
+
+toolbar-go-forward-label = Forward
+
+toolbar-go-forward =
+ .title = Go forward one message
+
+toolbar-stop-label = Stop
+
+toolbar-stop =
+ .title = Stop the current transfer
+
+toolbar-throbber-label = Activity Indicator
+
+toolbar-throbber =
+ .title = Activity Indicator
diff --git a/comm/mail/locales/en-US/messenger/viewSource.ftl b/comm/mail/locales/en-US/messenger/viewSource.ftl
new file mode 100644
index 0000000000..51e110d48c
--- /dev/null
+++ b/comm/mail/locales/en-US/messenger/viewSource.ftl
@@ -0,0 +1,19 @@
+# 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/.
+
+context-text-action-find =
+ .label = Find
+ .accesskey = F
+
+context-text-action-find-again =
+ .label = Find Again
+ .accesskey = g
+
+text-action-find =
+ .label = Find
+ .accesskey = F
+
+text-action-find-again =
+ .label = Find Again
+ .accesskey = g
diff --git a/comm/mail/locales/en-US/updater/updater.ini b/comm/mail/locales/en-US/updater/updater.ini
new file mode 100644
index 0000000000..40cec45ef6
--- /dev/null
+++ b/comm/mail/locales/en-US/updater/updater.ini
@@ -0,0 +1,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/.
+
+; This file is in the UTF-8 encoding
+[Strings]
+TitleText=%MOZ_APP_DISPLAYNAME% Update
+InfoText=%MOZ_APP_DISPLAYNAME% is installing your updates and will start in a few moments…
diff --git a/comm/mail/locales/filter.py b/comm/mail/locales/filter.py
new file mode 100644
index 0000000000..eb0bfcc6be
--- /dev/null
+++ b/comm/mail/locales/filter.py
@@ -0,0 +1,26 @@
+# 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/.
+
+
+def test(mod, path, entity=None):
+ # ignore anything but Thunderbird
+ if mod not in (
+ "netwerk",
+ "dom",
+ "toolkit",
+ "security/manager",
+ "devtools/shared",
+ "devtools/client",
+ "mail",
+ "chat",
+ "extensions/spellcheck",
+ "mail/branding/thunderbird",
+ ):
+ return "ignore"
+
+ # ignore dictionaries
+ if mod == "extensions/spellcheck":
+ return "ignore"
+
+ return "error"
diff --git a/comm/mail/locales/generate_ini.py b/comm/mail/locales/generate_ini.py
new file mode 100644
index 0000000000..0d9ee1aaad
--- /dev/null
+++ b/comm/mail/locales/generate_ini.py
@@ -0,0 +1,26 @@
+# 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/.
+
+# Generate updater.ini by doing some light substitution on the localized updater.ini input,
+# and appending the contents of updater_ini_append on Windows.
+
+import codecs
+import re
+import shutil
+
+import buildconfig
+
+
+def main(output, ini, ini_append=None, locale=None):
+ fixup_re = re.compile("^(Info|Title)Text=")
+ # Input INI is always utf-8.
+ with codecs.open(ini, "rb", "utf_8") as f:
+ for line in f:
+ line = fixup_re.sub(r"\1=", line)
+ line = line.replace("%MOZ_APP_DISPLAYNAME%", buildconfig.substs["MOZ_APP_DISPLAYNAME"])
+ output.write(line)
+ if ini_append and buildconfig.substs["OS_TARGET"] == "WINNT":
+ # Also append the contents of `ini_append`.
+ with codecs.open(ini_append, "rb", "utf_8") as f:
+ shutil.copyfileobj(f, output)
diff --git a/comm/mail/locales/jar.mn b/comm/mail/locales/jar.mn
new file mode 100644
index 0000000000..100c7584a5
--- /dev/null
+++ b/comm/mail/locales/jar.mn
@@ -0,0 +1,197 @@
+# 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/.
+
+# Note: This file should only contain locale entries. All
+# override and resource entries should go to mail/base/jar.mn to avoid
+# having to create the same entry for each locale.
+
+#filter substitution
+
+[localization] @AB_CD@.jar:
+ browser (%browser/**/*.ftl)
+ messenger (%messenger/**/*.ftl)
+
+@AB_CD@.jar:
+% locale messenger @AB_CD@ %locale/@AB_CD@/messenger/
+ locale/@AB_CD@/messenger/aboutDownloads.dtd (%chrome/messenger/aboutDownloads.dtd)
+ locale/@AB_CD@/messenger/aboutRights.properties (%chrome/messenger/aboutRights.properties)
+ locale/@AB_CD@/messenger/aboutSupportMail.properties (%chrome/messenger/aboutSupportMail.properties)
+ locale/@AB_CD@/messenger/telemetry.properties (%chrome/messenger/telemetry.properties)
+ locale/@AB_CD@/messenger/accountCreationModel.properties (%chrome/messenger/accountCreationModel.properties)
+ locale/@AB_CD@/messenger/accountCreationUtil.properties (%chrome/messenger/accountCreationUtil.properties)
+ locale/@AB_CD@/messenger/appUpdate.properties (%chrome/messenger/appUpdate.properties)
+ locale/@AB_CD@/messenger/charsetTitles.properties (%chrome/messenger/charsetTitles.properties)
+ locale/@AB_CD@/messenger/customizeToolbar.dtd (%chrome/messenger/customizeToolbar.dtd)
+ locale/@AB_CD@/messenger/customizeToolbar.properties (%chrome/messenger/customizeToolbar.properties)
+ locale/@AB_CD@/messenger/viewSource.dtd (%chrome/messenger/viewSource.dtd)
+ locale/@AB_CD@/messenger/viewSource.properties (%chrome/messenger/viewSource.properties)
+ locale/@AB_CD@/messenger/virtualFolderProperties.dtd (%chrome/messenger/virtualFolderProperties.dtd)
+ locale/@AB_CD@/messenger/virtualFolderListDialog.dtd (%chrome/messenger/virtualFolderListDialog.dtd)
+ locale/@AB_CD@/messenger/multimessageview.properties (%chrome/messenger/multimessageview.properties)
+ locale/@AB_CD@/messenger/multimessageview.dtd (%chrome/messenger/multimessageview.dtd)
+ locale/@AB_CD@/messenger/mailOverlay.dtd (%chrome/messenger/mailOverlay.dtd)
+ locale/@AB_CD@/messenger/messenger.dtd (%chrome/messenger/messenger.dtd)
+ locale/@AB_CD@/messenger/viewZoomOverlay.dtd (%chrome/messenger/viewZoomOverlay.dtd)
+ locale/@AB_CD@/messenger/baseMenuOverlay.dtd (%chrome/messenger/baseMenuOverlay.dtd)
+ locale/@AB_CD@/messenger/tabmail.dtd (%chrome/messenger/tabmail.dtd)
+ locale/@AB_CD@/messenger/msgAccountCentral.dtd (%chrome/messenger/msgAccountCentral.dtd)
+ locale/@AB_CD@/messenger/SearchDialog.dtd (%chrome/messenger/SearchDialog.dtd)
+ locale/@AB_CD@/messenger/AccountManager.dtd (%chrome/messenger/AccountManager.dtd)
+ locale/@AB_CD@/messenger/AccountWizard.dtd (%chrome/messenger/AccountWizard.dtd)
+ locale/@AB_CD@/messenger/am-advanced.dtd (%chrome/messenger/am-advanced.dtd)
+ locale/@AB_CD@/messenger/am-server-advanced.dtd (%chrome/messenger/am-server-advanced.dtd)
+ locale/@AB_CD@/messenger/am-copies.dtd (%chrome/messenger/am-copies.dtd)
+ locale/@AB_CD@/messenger/am-offline.dtd (%chrome/messenger/am-offline.dtd)
+ locale/@AB_CD@/messenger/am-addressing.dtd (%chrome/messenger/am-addressing.dtd)
+ locale/@AB_CD@/messenger/am-main.dtd (%chrome/messenger/am-main.dtd)
+ locale/@AB_CD@/messenger/am-server-top.dtd (%chrome/messenger/am-server-top.dtd)
+ locale/@AB_CD@/messenger/converterDialog.dtd (%chrome/messenger/converterDialog.dtd)
+ locale/@AB_CD@/messenger/converterDialog.properties (%chrome/messenger/converterDialog.properties)
+ locale/@AB_CD@/messenger/am-identities-list.dtd (%chrome/messenger/am-identities-list.dtd)
+ locale/@AB_CD@/messenger/am-identity-edit.dtd (%chrome/messenger/am-identity-edit.dtd)
+ locale/@AB_CD@/messenger/am-im.dtd (%chrome/messenger/am-im.dtd)
+ locale/@AB_CD@/messenger/am-serverwithnoidentities.dtd (%chrome/messenger/am-serverwithnoidentities.dtd)
+ locale/@AB_CD@/messenger/am-junk.dtd (%chrome/messenger/am-junk.dtd)
+ locale/@AB_CD@/messenger/prefs.properties (%chrome/messenger/prefs.properties)
+ locale/@AB_CD@/messenger/smtpEditOverlay.dtd (%chrome/messenger/smtpEditOverlay.dtd)
+ locale/@AB_CD@/messenger/am-smime.dtd (%chrome/messenger/am-smime.dtd)
+ locale/@AB_CD@/messenger/am-smime.properties (%chrome/messenger/am-smime.properties)
+ locale/@AB_CD@/messenger/am-e2e.properties (%chrome/messenger/am-e2e.properties)
+ locale/@AB_CD@/messenger/removeAccount.dtd (%chrome/messenger/removeAccount.dtd)
+ locale/@AB_CD@/messenger/removeAccount.properties (%chrome/messenger/removeAccount.properties)
+ locale/@AB_CD@/messenger/messenger.properties (%chrome/messenger/messenger.properties)
+ locale/@AB_CD@/messenger/newFolderDialog.dtd (%chrome/messenger/newFolderDialog.dtd)
+ locale/@AB_CD@/messenger/renameFolderDialog.dtd (%chrome/messenger/renameFolderDialog.dtd)
+ locale/@AB_CD@/messenger/folderpane.dtd (%chrome/messenger/folderpane.dtd)
+ locale/@AB_CD@/messenger/folderProps.dtd (%chrome/messenger/folderProps.dtd)
+ locale/@AB_CD@/messenger/folderWidgets.properties (%chrome/messenger/folderWidgets.properties)
+ locale/@AB_CD@/messenger/subscribe.dtd (%chrome/messenger/subscribe.dtd)
+ locale/@AB_CD@/messenger/subscribe.properties (%chrome/messenger/subscribe.properties)
+ locale/@AB_CD@/messenger/msgHdrViewOverlay.dtd (%chrome/messenger/msgHdrViewOverlay.dtd)
+ locale/@AB_CD@/messenger/editContactOverlay.dtd (%chrome/messenger/editContactOverlay.dtd)
+ locale/@AB_CD@/messenger/editContactOverlay.properties (%chrome/messenger/editContactOverlay.properties)
+ locale/@AB_CD@/messenger/mailEditorOverlay.dtd (%chrome/messenger/mailEditorOverlay.dtd)
+ locale/@AB_CD@/messenger/msgSynchronize.dtd (%chrome/messenger/msgSynchronize.dtd)
+ locale/@AB_CD@/messenger/offline.properties (%chrome/messenger/offline.properties)
+ locale/@AB_CD@/messenger/viewLog.dtd (%chrome/messenger/viewLog.dtd)
+ locale/@AB_CD@/messenger/FilterListDialog.dtd (%chrome/messenger/FilterListDialog.dtd)
+ locale/@AB_CD@/messenger/CustomHeaders.dtd (%chrome/messenger/CustomHeaders.dtd)
+ locale/@AB_CD@/messenger/FilterEditor.dtd (%chrome/messenger/FilterEditor.dtd)
+ locale/@AB_CD@/messenger/search-attributes.properties (%chrome/messenger/search-attributes.properties)
+ locale/@AB_CD@/messenger/search-operators.properties (%chrome/messenger/search-operators.properties)
+ locale/@AB_CD@/messenger/search.properties (%chrome/messenger/search.properties)
+ locale/@AB_CD@/messenger/filter.properties (%chrome/messenger/filter.properties)
+ locale/@AB_CD@/messenger/custom.properties (%chrome/messenger/custom.properties)
+ locale/@AB_CD@/messenger/searchTermOverlay.dtd (%chrome/messenger/searchTermOverlay.dtd)
+ locale/@AB_CD@/messenger/imapMsgs.properties (%chrome/messenger/imapMsgs.properties)
+ locale/@AB_CD@/messenger/localMsgs.properties (%chrome/messenger/localMsgs.properties)
+ locale/@AB_CD@/messenger/downloadheaders.dtd (%chrome/messenger/downloadheaders.dtd)
+ locale/@AB_CD@/messenger/news.properties (%chrome/messenger/news.properties)
+ locale/@AB_CD@/messenger/mime.properties (%chrome/messenger/mime.properties)
+ locale/@AB_CD@/messenger/mimeheader.properties (%chrome/messenger/mimeheader.properties)
+ locale/@AB_CD@/messenger/smime.properties (%chrome/messenger/smime.properties)
+ locale/@AB_CD@/messenger/pgpmime.properties (%chrome/messenger/pgpmime.properties)
+ locale/@AB_CD@/messenger/markByDate.dtd (%chrome/messenger/markByDate.dtd)
+ locale/@AB_CD@/messenger/am-mdn.dtd (%chrome/messenger/am-mdn.dtd)
+ locale/@AB_CD@/messenger/am-mdn.properties (%chrome/messenger/am-mdn.properties)
+ locale/@AB_CD@/messenger/am-archiveoptions.dtd (%chrome/messenger/am-archiveoptions.dtd)
+ locale/@AB_CD@/messenger/msgmdn.properties (%chrome/messenger/msgmdn.properties)
+ locale/@AB_CD@/messenger/mailviews.properties (%chrome/messenger/mailviews.properties)
+ locale/@AB_CD@/messenger/msgViewPickerOverlay.dtd (%chrome/messenger/msgViewPickerOverlay.dtd)
+ locale/@AB_CD@/messenger/mailViewSetup.dtd (%chrome/messenger/mailViewSetup.dtd)
+ locale/@AB_CD@/messenger/mailViewList.dtd (%chrome/messenger/mailViewList.dtd)
+ locale/@AB_CD@/messenger/offlineStartup.properties (%chrome/messenger/offlineStartup.properties)
+ locale/@AB_CD@/messenger/importMsgs.properties (%chrome/messenger/importMsgs.properties)
+ locale/@AB_CD@/messenger/importDialog.dtd (%chrome/messenger/importDialog.dtd)
+ locale/@AB_CD@/messenger/fieldMapImport.dtd (%chrome/messenger/fieldMapImport.dtd)
+ locale/@AB_CD@/messenger/morkImportMsgs.properties (%chrome/messenger/morkImportMsgs.properties)
+ locale/@AB_CD@/messenger/textImportMsgs.properties (%chrome/messenger/textImportMsgs.properties)
+ locale/@AB_CD@/messenger/vCardImportMsgs.properties (%chrome/messenger/vCardImportMsgs.properties)
+ locale/@AB_CD@/messenger/appleMailImportMsgs.properties (%chrome/messenger/appleMailImportMsgs.properties)
+ locale/@AB_CD@/messenger/wmImportMsgs.properties (%chrome/messenger/wmImportMsgs.properties)
+ locale/@AB_CD@/messenger/outlookImportMsgs.properties (%chrome/messenger/outlookImportMsgs.properties)
+ locale/@AB_CD@/messenger/beckyImportMsgs.properties (%chrome/messenger/beckyImportMsgs.properties)
+ locale/@AB_CD@/messenger/seamonkeyImportMsgs.properties (%chrome/messenger/seamonkeyImportMsgs.properties)
+ locale/@AB_CD@/messenger/shutdownWindow.properties (%chrome/messenger/shutdownWindow.properties)
+ locale/@AB_CD@/messenger/configEditorOverlay.dtd (%chrome/messenger/configEditorOverlay.dtd)
+ locale/@AB_CD@/messenger/gloda.properties (%chrome/messenger/gloda.properties)
+ locale/@AB_CD@/messenger/glodaComplete.properties (%chrome/messenger/glodaComplete.properties)
+ locale/@AB_CD@/messenger/templateUtils.properties (%chrome/messenger/templateUtils.properties)
+ locale/@AB_CD@/messenger/glodaFacetView.properties (%chrome/messenger/glodaFacetView.properties)
+ locale/@AB_CD@/messenger/glodaFacetView.dtd (%chrome/messenger/glodaFacetView.dtd)
+ locale/@AB_CD@/messenger/taskbar.properties (%chrome/messenger/taskbar.properties)
+ locale/@AB_CD@/messenger/junkLog.dtd (%chrome/messenger/junkLog.dtd)
+ locale/@AB_CD@/messenger/addressbook/abMainWindow.dtd (%chrome/messenger/addressbook/abMainWindow.dtd)
+ locale/@AB_CD@/messenger/addressbook/abContactsPanel.dtd (%chrome/messenger/addressbook/abContactsPanel.dtd)
+ locale/@AB_CD@/messenger/addressbook/abAddressBookNameDialog.dtd (%chrome/messenger/addressbook/abAddressBookNameDialog.dtd)
+ locale/@AB_CD@/messenger/addressbook/abResultsPane.dtd (%chrome/messenger/addressbook/abResultsPane.dtd)
+ locale/@AB_CD@/messenger/addressbook/abMailListDialog.dtd (%chrome/messenger/addressbook/abMailListDialog.dtd)
+ locale/@AB_CD@/messenger/addressbook/addressBook.properties (%chrome/messenger/addressbook/addressBook.properties)
+ locale/@AB_CD@/messenger/addressbook/ldapAutoCompErrs.properties (%chrome/messenger/addressbook/ldapAutoCompErrs.properties)
+ locale/@AB_CD@/messenger/addressbook/pref-directory.dtd (%chrome/messenger/addressbook/pref-directory.dtd)
+ locale/@AB_CD@/messenger/addressbook/pref-directory-add.dtd (%chrome/messenger/addressbook/pref-directory-add.dtd)
+ locale/@AB_CD@/messenger/addressbook/replicationProgress.properties (%chrome/messenger/addressbook/replicationProgress.properties)
+ locale/@AB_CD@/messenger/messengercompose/messengercompose.dtd (%chrome/messenger/messengercompose/messengercompose.dtd)
+ locale/@AB_CD@/messenger/messengercompose/sendProgress.dtd (%chrome/messenger/messengercompose/sendProgress.dtd)
+ locale/@AB_CD@/messenger/messengercompose/sendProgress.properties (%chrome/messenger/messengercompose/sendProgress.properties)
+ locale/@AB_CD@/messenger/messengercompose/composeMsgs.properties (%chrome/messenger/messengercompose/composeMsgs.properties)
+ locale/@AB_CD@/messenger/messengercompose/mailComposeEditorOverlay.dtd (%chrome/messenger/messengercompose/mailComposeEditorOverlay.dtd)
+ locale/@AB_CD@/messenger/messengercompose/editorOverlay.dtd (%chrome/messenger/messengercompose/editorOverlay.dtd)
+ locale/@AB_CD@/messenger/messengercompose/editor.properties (%chrome/messenger/messengercompose/editor.properties)
+ locale/@AB_CD@/messenger/messengercompose/EditorHLineProperties.dtd (%chrome/messenger/messengercompose/EditorHLineProperties.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorImageProperties.dtd (%chrome/messenger/messengercompose/EditorImageProperties.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorInsertSource.dtd (%chrome/messenger/messengercompose/EditorInsertSource.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorInsertMath.dtd (%chrome/messenger/messengercompose/EditorInsertMath.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorInsertChars.dtd (%chrome/messenger/messengercompose/EditorInsertChars.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorInsertTable.dtd (%chrome/messenger/messengercompose/EditorInsertTable.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorInsertTOC.dtd (%chrome/messenger/messengercompose/EditorInsertTOC.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorLinkProperties.dtd (%chrome/messenger/messengercompose/EditorLinkProperties.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorListProperties.dtd (%chrome/messenger/messengercompose/EditorListProperties.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorColorProperties.dtd (%chrome/messenger/messengercompose/EditorColorProperties.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EdColorPicker.dtd (%chrome/messenger/messengercompose/EdColorPicker.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorReplace.dtd (%chrome/messenger/messengercompose/EditorReplace.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorSpellCheck.dtd (%chrome/messenger/messengercompose/EditorSpellCheck.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorPersonalDictionary.dtd (%chrome/messenger/messengercompose/EditorPersonalDictionary.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EditorTableProperties.dtd (%chrome/messenger/messengercompose/EditorTableProperties.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EdNamedAnchorProperties.dtd (%chrome/messenger/messengercompose/EdNamedAnchorProperties.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EdDialogOverlay.dtd (%chrome/messenger/messengercompose/EdDialogOverlay.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EdAdvancedEdit.dtd (%chrome/messenger/messengercompose/EdAdvancedEdit.dtd)
+ locale/@AB_CD@/messenger/messengercompose/EdConvertToTable.dtd (%chrome/messenger/messengercompose/EdConvertToTable.dtd)
+ locale/@AB_CD@/messenger/preferences/messagestyle.properties (%chrome/messenger/preferences/messagestyle.properties)
+ locale/@AB_CD@/messenger/preferences/applications.properties (%chrome/messenger/preferences/applications.properties)
+ locale/@AB_CD@/messenger/preferences/applicationManager.properties (%chrome/messenger/preferences/applicationManager.properties)
+ locale/@AB_CD@/messenger/preferences/preferences.properties (%chrome/messenger/preferences/preferences.properties)
+ locale/@AB_CD@/messenger/migration/migration.dtd (%chrome/messenger/migration/migration.dtd)
+ locale/@AB_CD@/messenger/migration/migration.properties (%chrome/messenger/migration/migration.properties)
+ locale/@AB_CD@/messenger/activity.dtd (%chrome/messenger/activity.dtd)
+ locale/@AB_CD@/messenger/activity.properties (%chrome/messenger/activity.properties)
+ locale/@AB_CD@/messenger/profileDowngrade.dtd (%chrome/overrides/profileDowngrade.dtd)
+ locale/@AB_CD@/messenger/newsError.dtd (%chrome/messenger/newsError.dtd)
+ locale/@AB_CD@/messenger/chat.dtd (%chrome/messenger/chat.dtd)
+ locale/@AB_CD@/messenger/chat.properties (%chrome/messenger/chat.properties)
+ locale/@AB_CD@/messenger/addbuddy.dtd (%chrome/messenger/addbuddy.dtd)
+ locale/@AB_CD@/messenger/joinChat.dtd (%chrome/messenger/joinChat.dtd)
+ locale/@AB_CD@/messenger/imAccounts.properties (%chrome/messenger/imAccounts.properties)
+ locale/@AB_CD@/messenger/imAccountWizard.dtd (%chrome/messenger/imAccountWizard.dtd)
+ locale/@AB_CD@/messenger/sanitize.dtd (%chrome/messenger/sanitize.dtd)
+% locale messenger-mapi @AB_CD@ %locale/@AB_CD@/messenger-mapi/
+ locale/@AB_CD@/messenger-mapi/mapi.properties (%chrome/messenger-mapi/mapi.properties)
+% locale messenger-newsblog @AB_CD@ %locale/@AB_CD@/messenger-newsblog/
+ locale/@AB_CD@/messenger-newsblog/newsblog.properties (%chrome/messenger-newsblog/newsblog.properties)
+ locale/@AB_CD@/messenger-newsblog/feed-subscriptions.dtd (%chrome/messenger-newsblog/feed-subscriptions.dtd)
+ locale/@AB_CD@/messenger-newsblog/am-newsblog.dtd (%chrome/messenger-newsblog/am-newsblog.dtd)
+% locale messenger-smime @AB_CD@ %locale/@AB_CD@/messenger-smime/
+ locale/@AB_CD@/messenger-smime/msgCompSMIMEOverlay.dtd (%chrome/messenger-smime/msgCompSMIMEOverlay.dtd)
+ locale/@AB_CD@/messenger-smime/msgReadSMIMEOverlay.properties (%chrome/messenger-smime/msgReadSMIMEOverlay.properties)
+ locale/@AB_CD@/messenger-smime/msgCompSecurityInfo.dtd (%chrome/messenger-smime/msgCompSecurityInfo.dtd)
+ locale/@AB_CD@/messenger-smime/msgCompSecurityInfo.properties (%chrome/messenger-smime/msgCompSecurityInfo.properties)
+ locale/@AB_CD@/messenger-smime/msgReadSecurityInfo.dtd (%chrome/messenger-smime/msgReadSecurityInfo.dtd)
+ locale/@AB_CD@/messenger-smime/certFetchingStatus.dtd (%chrome/messenger-smime/certFetchingStatus.dtd)
+ locale/@AB_CD@/messenger-smime/msgSecurityInfo.properties (%chrome/messenger-smime/msgSecurityInfo.properties)
+% locale messenger-region @AB_CD@ %locale/@AB_CD@/messenger-region/
+ locale/@AB_CD@/messenger-region/region.properties (%chrome/messenger-region/region.properties)
+% locale mozldap @AB_CD@ %locale/@AB_CD@/mozldap/
+ locale/@AB_CD@/mozldap/ldap.properties (%chrome/mozldap/ldap.properties)
+% locale communicator @AB_CD@ %locale/@AB_CD@/communicator/
+ locale/@AB_CD@/communicator/utilityOverlay.dtd (%chrome/communicator/utilityOverlay.dtd)
diff --git a/comm/mail/locales/l10n-beta.ini b/comm/mail/locales/l10n-beta.ini
new file mode 100644
index 0000000000..c2958c18dd
--- /dev/null
+++ b/comm/mail/locales/l10n-beta.ini
@@ -0,0 +1,27 @@
+[general]
+depth = ../..
+all = mail/locales/all-locales
+
+[compare]
+dirs = mail
+ chat
+ other-licenses/branding/thunderbird
+ mail/branding/thunderbird
+
+[includes]
+# non-central apps might want to use %(topsrcdir)s here, or other vars
+# RFE: that needs to be supported by compare-locales, too, though
+toolkit = mozilla/toolkit/locales/l10n.ini
+devtools_client = mozilla/devtools/client/locales/l10n.ini
+
+[include_toolkit]
+type = hg
+mozilla = releases/mozilla-beta
+repo = https://hg.mozilla.org/
+l10n.ini = toolkit/locales/l10n.ini
+
+[include_devtools_client]
+type = hg
+mozilla = releases/mozilla-beta
+repo = https://hg.mozilla.org/
+l10n.ini = devtools/client/locales/l10n.ini
diff --git a/comm/mail/locales/l10n-central.ini b/comm/mail/locales/l10n-central.ini
new file mode 100644
index 0000000000..bf5831f1f5
--- /dev/null
+++ b/comm/mail/locales/l10n-central.ini
@@ -0,0 +1,27 @@
+[general]
+depth = ../..
+all = mail/locales/all-locales
+
+[compare]
+dirs = mail
+ chat
+ other-licenses/branding/thunderbird
+ mail/branding/thunderbird
+
+[includes]
+# non-central apps might want to use %(topsrcdir)s here, or other vars
+# RFE: that needs to be supported by compare-locales, too, though
+toolkit = mozilla/toolkit/locales/l10n.ini
+devtools_client = mozilla/devtools/client/locales/l10n.ini
+
+[include_toolkit]
+type = hg
+mozilla = mozilla-central
+repo = https://hg.mozilla.org/
+l10n.ini = toolkit/locales/l10n.ini
+
+[include_devtools_client]
+type = hg
+mozilla = mozilla-central
+repo = https://hg.mozilla.org/
+l10n.ini = devtools/client/locales/l10n.ini
diff --git a/comm/mail/locales/l10n-changesets.json b/comm/mail/locales/l10n-changesets.json
new file mode 100644
index 0000000000..ba6cd8984c
--- /dev/null
+++ b/comm/mail/locales/l10n-changesets.json
@@ -0,0 +1,723 @@
+{
+ "af": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ar": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ast": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "be": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "bg": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "br": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ca": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "cak": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "cs": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "cy": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "da": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "de": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "dsb": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "el": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "en-CA": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "en-GB": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "es-AR": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "es-ES": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "es-MX": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "et": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "eu": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "fi": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "fr": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "fy-NL": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ga-IE": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "gd": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "gl": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "he": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "hr": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "hsb": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "hu": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "hy-AM": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "id": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "is": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "it": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ja": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ja-JP-mac": {
+ "pin": false,
+ "platforms": [
+ "macosx64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ka": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "kab": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "kk": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ko": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "lt": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "lv": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ms": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "nb-NO": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "nl": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "nn-NO": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "pa-IN": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "pl": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "pt-BR": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "pt-PT": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "rm": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ro": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "ru": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "sk": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "sl": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "sq": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "sr": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "sv-SE": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "th": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "tr": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "uk": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "uz": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "vi": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "zh-CN": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ },
+ "zh-TW": {
+ "pin": false,
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "ab83e93dc1bbe560992fbeca2e4f3a2cb3db1af8"
+ }
+} \ No newline at end of file
diff --git a/comm/mail/locales/l10n-onchange-changesets.json b/comm/mail/locales/l10n-onchange-changesets.json
new file mode 100644
index 0000000000..a436a422ff
--- /dev/null
+++ b/comm/mail/locales/l10n-onchange-changesets.json
@@ -0,0 +1,77 @@
+{
+ "de": {
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "default"
+ },
+ "en-GB": {
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "default"
+ },
+ "es-ES": {
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "default"
+ },
+ "fr": {
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "default"
+ },
+ "ja": {
+ "platforms": [
+ "linux",
+ "linux64",
+ "win32",
+ "win64"
+ ],
+ "revision": "default"
+ },
+ "ja-JP-mac": {
+ "platforms": [
+ "macosx64"
+ ],
+ "revision": "default"
+ },
+ "ru": {
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "default"
+ },
+ "zh-CN": {
+ "platforms": [
+ "linux",
+ "linux64",
+ "macosx64",
+ "win32",
+ "win64"
+ ],
+ "revision": "default"
+ }
+}
diff --git a/comm/mail/locales/l10n.ini b/comm/mail/locales/l10n.ini
new file mode 100644
index 0000000000..a3ca235b94
--- /dev/null
+++ b/comm/mail/locales/l10n.ini
@@ -0,0 +1,16 @@
+[general]
+depth = ../..
+all = mail/locales/all-locales
+
+[compare]
+dirs = mail
+ chat
+ other-licenses/branding/thunderbird
+ mail/branding/thunderbird
+
+[includes]
+# include toolkit from mozilla.
+# Don't specify which, use l10n-central.ini and friends if you're
+# not working on a local check-out
+toolkit = mozilla/toolkit/locales/l10n.ini
+devtools_client = mozilla/devtools/client/locales/l10n.ini
diff --git a/comm/mail/locales/l10n.toml b/comm/mail/locales/l10n.toml
new file mode 100644
index 0000000000..ab9fb19d03
--- /dev/null
+++ b/comm/mail/locales/l10n.toml
@@ -0,0 +1,112 @@
+# 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/.
+
+basepath = "../.."
+
+locales = [
+ "af",
+ "ar",
+ "ast",
+ "be",
+ "bg",
+ "br",
+ "ca",
+ "cak",
+ "cs",
+ "cy",
+ "da",
+ "de",
+ "dsb",
+ "el",
+ "en-CA",
+ "en-GB",
+ "es-AR",
+ "es-ES",
+ "es-MX",
+ "et",
+ "eu",
+ "fi",
+ "fr",
+ "fy-NL",
+ "ga-IE",
+ "gd",
+ "gl",
+ "he",
+ "hr",
+ "hsb",
+ "hu",
+ "hy-AM",
+ "id",
+ "is",
+ "it",
+ "ja",
+ "ja-JP-mac",
+ "ka",
+ "kab",
+ "kk",
+ "ko",
+ "lv",
+ "lt",
+ "mk",
+ "ms",
+ "nb-NO",
+ "nl",
+ "nn-NO",
+ "pa-IN",
+ "pl",
+ "pt-BR",
+ "pt-PT",
+ "rm",
+ "ro",
+ "ru",
+ "sk",
+ "sl",
+ "sq",
+ "sr",
+ "sv-SE",
+ "th",
+ "tr",
+ "uk",
+ "uz",
+ "vi",
+ "zh-CN",
+ "zh-TW",
+]
+
+[env]
+ l = "{l10n_base}/{locale}/"
+ mozilla = ".."
+
+
+[[paths]]
+ reference = "mail/locales/en-US/**"
+ l10n = "{l}mail/**"
+
+[[paths]]
+ reference = "chat/locales/en-US/**"
+ l10n = "{l}chat/**"
+
+[[paths]]
+ reference = "mail/branding/thunderbird/locales/en-US/**"
+ l10n = "{l}mail/branding/thunderbird/**"
+
+[[includes]]
+ path = "{mozilla}/devtools/client/locales/l10n.toml"
+
+[[includes]]
+ path = "{mozilla}/toolkit/locales/l10n.toml"
+
+[[includes]]
+ path = "calendar/locales/l10n.toml"
+
+[[paths]]
+ reference = "{mozilla}/devtools/startup/locales/en-US/**"
+ l10n = "{l}devtools/startup/**"
+
+# all-l10n.js can be missing completely
+[[filters]]
+ path = [
+ "{l}mail/all-l10n.js",
+ ]
+ action = "ignore"
diff --git a/comm/mail/locales/moz.build b/comm/mail/locales/moz.build
new file mode 100644
index 0000000000..902095f9e4
--- /dev/null
+++ b/comm/mail/locales/moz.build
@@ -0,0 +1,25 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+# If DIST_SUBDIR ever gets set for Thunderbird this path might be wrong due to PREF_DIR changing.
+LOCALIZED_PP_FILES.defaults.pref += ["en-US/all-l10n.js"]
+
+if CONFIG["MOZ_CRASHREPORTER"]:
+ LOCALIZED_FILES += ["en-US/crashreporter/crashreporter-override.ini"]
+
+if CONFIG["MOZ_UPDATER"]:
+ LOCALIZED_GENERATED_FILES += ["updater.ini"]
+ updater = LOCALIZED_GENERATED_FILES["updater.ini"]
+ updater.script = "generate_ini.py"
+ updater.inputs = [
+ "en-US/updater/updater.ini",
+ "../installer/windows/nsis/updater_append.ini",
+ ]
+ LOCALIZED_FILES += ["!updater.ini"]
+
+DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"]
diff --git a/comm/mail/locales/onchange-locales b/comm/mail/locales/onchange-locales
new file mode 100644
index 0000000000..5d2e648433
--- /dev/null
+++ b/comm/mail/locales/onchange-locales
@@ -0,0 +1,13 @@
+de
+en-GB
+en-US
+es-ES
+fr
+it
+ja
+ja-JP-mac
+nl
+pl
+pt-BR
+ru
+zh-CN
diff --git a/comm/mail/locales/shipped-locales b/comm/mail/locales/shipped-locales
new file mode 100644
index 0000000000..d4224bcbbf
--- /dev/null
+++ b/comm/mail/locales/shipped-locales
@@ -0,0 +1,67 @@
+af
+ar
+ast
+be
+bg
+br
+ca
+cak
+cs
+cy
+da
+de
+dsb
+el
+en-CA
+en-GB
+en-US
+es-AR
+es-ES
+es-MX
+et
+eu
+fi
+fr
+fy-NL
+ga-IE
+gd
+gl
+he
+hr
+hsb
+hu
+hy-AM
+id
+is
+it
+ja
+ja-JP-mac
+ka
+kab
+kk
+ko
+lt
+lv
+ms
+nb-NO
+nl
+nn-NO
+pa-IN
+pl
+pt-BR
+pt-PT
+rm
+ro
+ru
+sk
+sl
+sq
+sr
+sv-SE
+th
+tr
+uk
+uz
+vi
+zh-CN
+zh-TW
diff --git a/comm/mail/modules/AttachmentChecker.jsm b/comm/mail/modules/AttachmentChecker.jsm
new file mode 100644
index 0000000000..88da3a1e83
--- /dev/null
+++ b/comm/mail/modules/AttachmentChecker.jsm
@@ -0,0 +1,118 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["AttachmentChecker"];
+
+var AttachmentChecker = {
+ getAttachmentKeywords,
+};
+
+/**
+ * Check whether the character is a CJK character or not.
+ *
+ * @returns true if it is a CJK character.
+ */
+function IsCJK(code) {
+ if (code >= 0x2000 && code <= 0x9fff) {
+ // Hiragana, Katakana and Kanaji
+ return true;
+ } else if (code >= 0xac00 && code <= 0xd7ff) {
+ // Hangul
+ return true;
+ } else if (code >= 0xf900 && code <= 0xffff) {
+ // Hiragana, Katakana and Kanaji
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Get the (possibly-empty) list of attachment keywords in this message.
+ *
+ * @returns the (possibly-empty) list of attachment keywords in this message
+ */
+function getAttachmentKeywords(mailData, keywordsInCsv) {
+ // The empty string would get split to an array of size 1. Avoid that...
+ var keywordsArray = keywordsInCsv ? keywordsInCsv.split(",") : [];
+
+ function escapeRegxpSpecials(inputString) {
+ const specials = [
+ ".",
+ "\\",
+ "^",
+ "$",
+ "*",
+ "+",
+ "?",
+ "|",
+ "(",
+ ")",
+ "[",
+ "]",
+ "{",
+ "}",
+ ];
+ var re = new RegExp("(\\" + specials.join("|\\") + ")", "g");
+ inputString = inputString.replace(re, "\\$1");
+ return inputString.replace(" ", "\\s+");
+ }
+
+ // NOT_W is the character class that isn't in the Unicode classes "Ll",
+ // "Lu" and "Lt". It should work like \W, if \W knew about Unicode.
+ const NOT_W =
+ "[^\\u0041-\\u005a\\u0061-\\u007a\\u00aa\\u00b5\\u00ba\\u00c0-\\u00d6\\u00d8-\\u00f6\\u00f8-\\u01ba\\u01bc-\\u01bf\\u01c4-\\u02ad\\u0386\\u0388-\\u0481\\u048c-\\u0556\\u0561-\\u0587\\u10a0-\\u10c5\\u1e00-\\u1fbc\\u1fbe\\u1fc2-\\u1fcc\\u1fd0-\\u1fdb\\u1fe0-\\u1fec\\u1ff2-\\u1ffc\\u207f\\u2102\\u2107\\u210a-\\u2113\\u2115\\u2119-\\u211d\\u2124\\u2126\\u2128\\u212a-\\u212d\\u212f-\\u2131\\u2133\\u2134\\u2139\\ufb00-\\ufb17\\uff21-\\uff3a\\uff41-\\uff5a]";
+
+ var keywordsFound = [];
+ for (var i = 0; i < keywordsArray.length; i++) {
+ var kw = escapeRegxpSpecials(keywordsArray[i]);
+ // If the keyword starts (ends) with a CJK character, we don't care
+ // what the previous (next) character is, because the words aren't
+ // space delimited.
+ if (keywordsArray[i].charAt(0) == ".") {
+ // like .pdf
+ // For this case we want to match the whole document name.
+ let start = "(([^\\s]*)\\b)";
+ let end = IsCJK(kw.charCodeAt(kw.length - 1)) ? "" : "(\\s|$)";
+ let re = new RegExp(start + kw + end, "ig");
+ let matching = mailData.match(re);
+ if (matching) {
+ for (var j = 0; j < matching.length; j++) {
+ // Ignore the match if it was in a URL.
+ if (!/^(https?|ftp):\/\//i.test(matching[j])) {
+ // We can have several *different* matches for one dot-keyword.
+ // E.g. foo.pdf and bar.pdf would both match for .pdf.
+ var m = matching[j].trim();
+ if (!keywordsFound.includes(m)) {
+ keywordsFound.push(m);
+ }
+ }
+ }
+ }
+ } else {
+ let start = IsCJK(kw.charCodeAt(0)) ? "" : "((^|\\s)\\S*)";
+ let end = IsCJK(kw.charCodeAt(kw.length - 1)) ? "" : "(" + NOT_W + "|$)";
+ let re = new RegExp(start + kw + end, "ig");
+ let matching;
+ while ((matching = re.exec(mailData)) !== null) {
+ // Ignore the match if it was in a URL.
+ if (!/^(https?|ftp):\/\//i.test(matching[0].trim())) {
+ keywordsFound.push(keywordsArray[i]);
+ break;
+ }
+ }
+ }
+ }
+ return keywordsFound;
+}
+
+// This file is also used as a Worker.
+/* exported onmessage */
+/* globals postMessage */
+var onmessage = function (event) {
+ var keywordsFound = AttachmentChecker.getAttachmentKeywords(
+ event.data[0],
+ event.data[1]
+ );
+ postMessage(keywordsFound);
+};
diff --git a/comm/mail/modules/AttachmentInfo.sys.mjs b/comm/mail/modules/AttachmentInfo.sys.mjs
new file mode 100644
index 0000000000..8d7fa7920b
--- /dev/null
+++ b/comm/mail/modules/AttachmentInfo.sys.mjs
@@ -0,0 +1,626 @@
+/* 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 = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ gHandlerService: [
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService",
+ ],
+ gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"],
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "messengerBundle", () => {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+});
+
+/**
+ * A class to handle attachment information and actions.
+ */
+export class AttachmentInfo {
+ /**
+ * A cache of message/rfc822 attachments saved to temporary files for display.
+ * Saving the same attachment again is avoided.
+ *
+ * @type {Map<string, nsIFile>}
+ */
+ #temporaryFiles = new Map();
+
+ /**
+ * A function to call when checking to see if an attachment exists or not, so
+ * that the display can be updated.
+ *
+ * @type {Function}
+ */
+ #updateAttachmentsDisplayFn = null;
+
+ /**
+ * Create a new attachment object which goes into the data attachment array.
+ * This method checks whether the passed attachment is empty or not.
+ *
+ * @param {object} options
+ * @param {string} options.contentType - The attachment's mimetype.
+ * @param {string} options.url - The URL for the attachment.
+ * @param {string} options.name - The name to be displayed for this attachment
+ * (usually the filename).
+ * @param {string} options.uri - The URI for the message containing the
+ * attachment.
+ * @param {boolean} options.isExternalAttachment - True if the attachment has
+ * been detached to file or is a link attachment.
+ * @param {object} options.message - The message object associated to this
+ * attachment.
+ * @param {function} [updateAttachmentsDisplayFn] - An optional callback
+ * function that is called to update the attachment display at appropriate
+ * times.
+ */
+ constructor({
+ contentType,
+ url,
+ name,
+ uri,
+ isExternalAttachment,
+ message,
+ updateAttachmentsDisplayFn,
+ }) {
+ this.message = message;
+ this.contentType = contentType;
+ this.name = name;
+ this.url = url;
+ this.uri = uri;
+ this.isExternalAttachment = isExternalAttachment;
+ this.#updateAttachmentsDisplayFn = updateAttachmentsDisplayFn;
+ // A |size| value of -1 means we don't have a valid size. Check again if
+ // |sizeResolved| is false. For internal attachments and link attachments
+ // with a reported size, libmime streams values to addAttachmentField()
+ // which updates this object. For external file attachments, |size| is updated
+ // in the #() function when the list is built. Deleted attachments
+ // are resolved to -1.
+ this.size = -1;
+ this.sizeResolved = this.isDeleted;
+
+ // Remove [?&]part= from remote urls, after getting the partID.
+ // Remote urls, unlike non external mail part urls, may also contain query
+ // strings starting with ?; PART_RE does not handle this.
+ if (this.isLinkAttachment || this.isFileAttachment) {
+ let match = url.match(/[?&]part=[^&]+$/);
+ match = match && match[0];
+ this.partID = match && match.split("part=")[1];
+ this.url = url.replace(match, "");
+ } else {
+ let match = lazy.GlodaUtils.PART_RE.exec(url);
+ this.partID = match && match[1];
+ }
+ }
+
+ /**
+ * Save this attachment to a file.
+ *
+ * @param {nsIMessenger} messenger
+ * The messenger object associated with the window.
+ */
+ async save(messenger) {
+ if (!this.hasFile) {
+ return;
+ }
+
+ let empty = await this.isEmpty();
+ if (empty) {
+ return;
+ }
+
+ messenger.saveAttachment(
+ this.contentType,
+ this.url,
+ encodeURIComponent(this.name),
+ this.uri,
+ this.isExternalAttachment
+ );
+ }
+
+ /**
+ * Open this attachment.
+ *
+ * @param {integer} [browsingContextId]
+ * The browsingContext of the browser that this attachment is being opened
+ * from.
+ */
+ async open(browsingContext) {
+ if (!this.hasFile) {
+ return;
+ }
+
+ let win = browsingContext.topChromeWindow;
+ let empty = await this.isEmpty();
+ if (empty) {
+ let prompt = lazy.messengerBundle.GetStringFromName(
+ this.isExternalAttachment
+ ? "externalAttachmentNotFound"
+ : "emptyAttachment"
+ );
+ Services.prompt.alert(win, null, prompt);
+ } else {
+ // @see MsgComposeCommands.js which has simililar opening functionality
+ let dotPos = this.name.lastIndexOf(".");
+ let extension =
+ dotPos >= 0 ? this.name.substring(dotPos + 1).toLowerCase() : "";
+ if (this.contentType == "application/pdf" || extension == "pdf") {
+ let handlerInfo = lazy.gMIMEService.getFromTypeAndExtension(
+ this.contentType,
+ extension
+ );
+ // Only open a new tab for pdfs if we are handling them internally.
+ if (
+ !handlerInfo.alwaysAskBeforeHandling &&
+ handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally
+ ) {
+ // Add the content type to avoid a "how do you want to open this?"
+ // dialog. The type may already be there, but that doesn't matter.
+ let url = this.url;
+ if (!url.includes("type=")) {
+ url += url.includes("?") ? "&" : "?";
+ url += "type=application/pdf";
+ }
+ let tabmail = win.document.getElementById("tabmail");
+ if (!tabmail) {
+ // If no tabmail available in this window, try and find it in
+ // another.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ tabmail = win?.document.getElementById("tabmail");
+ }
+ if (tabmail) {
+ tabmail.openTab("contentTab", {
+ url,
+ background: false,
+ linkHandler: "single-page",
+ });
+ tabmail.ownerGlobal.focus();
+ return;
+ }
+ // If no tabmail, open PDF same as other attachments.
+ }
+ }
+
+ // Just use the old method for handling messages, it works.
+
+ let { name, url } = this;
+
+ let sourceURI = Services.io.newURI(url);
+ async function saveToFile(path, isTmp = false) {
+ let buffer = await new Promise((resolve, reject) => {
+ lazy.NetUtil.asyncFetch(
+ {
+ uri: sourceURI,
+ loadUsingSystemPrincipal: true,
+ },
+ (inputStream, status) => {
+ if (Components.isSuccessCode(status)) {
+ resolve(lazy.NetUtil.readInputStream(inputStream));
+ } else {
+ reject(
+ new Components.Exception(`Failed to fetch ${path}`, status)
+ );
+ }
+ }
+ );
+ });
+ await IOUtils.write(path, new Uint8Array(buffer));
+
+ if (!isTmp) {
+ // Create a download so that saved files show up under... Saved Files.
+ let file = await IOUtils.getFile(path);
+ lazy.Downloads.createDownload({
+ source: {
+ url: sourceURI.spec,
+ },
+ target: file,
+ startTime: new Date(),
+ })
+ .then(async download => {
+ await download.start();
+ let list = await lazy.Downloads.getList(lazy.Downloads.ALL);
+ await list.add(download);
+ })
+ .catch(console.error);
+ }
+ }
+
+ if (this.contentType == "message/rfc822") {
+ let tempFile = this.#temporaryFiles.get(url);
+ if (!tempFile?.exists()) {
+ tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempFile.append("subPart.eml");
+ tempFile.createUnique(0, 0o600);
+ await saveToFile(tempFile.path, true);
+
+ this.#temporaryFiles.set(url, tempFile);
+ }
+
+ lazy.MailUtils.openEMLFile(
+ win,
+ tempFile,
+ Services.io.newFileURI(tempFile)
+ );
+ return;
+ }
+
+ // Get the MIME info from the service.
+
+ let mimeInfo;
+ try {
+ mimeInfo = lazy.gMIMEService.getFromTypeAndExtension(
+ this.contentType,
+ extension
+ );
+ } catch (ex) {
+ // If the call above fails, which can happen on Windows where there's
+ // nothing registered for the file type, assume this generic type.
+ mimeInfo = lazy.gMIMEService.getFromTypeAndExtension(
+ "application/octet-stream",
+ ""
+ );
+ }
+ // The default action is saveToDisk, which is not what we want.
+ // If we don't have a stored handler, ask before handling.
+ if (!lazy.gHandlerService.exists(mimeInfo)) {
+ mimeInfo.alwaysAskBeforeHandling = true;
+ mimeInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ }
+
+ // If we know what to do, do it.
+
+ name = lazy.DownloadPaths.sanitize(name);
+
+ let createTemporaryFileAndOpen = async mimeInfo => {
+ let tmpPath = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "pid-" + Services.appinfo.processID
+ );
+ await IOUtils.makeDirectory(tmpPath, { permissions: 0o700 });
+ let tempFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ tempFile.initWithPath(tmpPath);
+
+ tempFile.append(name);
+ tempFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+ tempFile.remove(false);
+
+ Cc["@mozilla.org/uriloader/external-helper-app-service;1"]
+ .getService(Ci.nsPIExternalAppLauncher)
+ .deleteTemporaryFileOnExit(tempFile);
+
+ await saveToFile(tempFile.path, true);
+ // Before opening from the temp dir, make the file read-only so that
+ // users don't edit and lose their edits...
+ await IOUtils.setPermissions(tempFile.path, 0o400); // Set read-only
+ this._openFile(mimeInfo, tempFile);
+ };
+
+ let openLocalFile = mimeInfo => {
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ try {
+ let externalFile = fileHandler.getFileFromURLSpec(this.displayUrl);
+ this._openFile(mimeInfo, externalFile);
+ } catch (ex) {
+ console.error(
+ "AttachmentInfo.open: file - " + this.displayUrl + ", " + ex
+ );
+ }
+ };
+
+ if (!mimeInfo.alwaysAskBeforeHandling) {
+ switch (mimeInfo.preferredAction) {
+ case Ci.nsIHandlerInfo.saveToDisk:
+ if (Services.prefs.getBoolPref("browser.download.useDownloadDir")) {
+ let destFile = new lazy.FileUtils.File(
+ await lazy.Downloads.getPreferredDownloadsDirectory()
+ );
+ destFile.append(name);
+ destFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
+ destFile.remove(false);
+ await saveToFile(destFile.path);
+ } else {
+ let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ filePicker.defaultString = this.name;
+ filePicker.defaultExtension = extension;
+ filePicker.init(
+ win,
+ lazy.messengerBundle.GetStringFromName("SaveAttachment"),
+ Ci.nsIFilePicker.modeSave
+ );
+ let rv = await new Promise(resolve => filePicker.open(resolve));
+ if (rv != Ci.nsIFilePicker.returnCancel) {
+ await saveToFile(filePicker.file.path);
+ }
+ }
+ return;
+ case Ci.nsIHandlerInfo.useHelperApp:
+ case Ci.nsIHandlerInfo.useSystemDefault:
+ // Attachments can be detached and, if this is the case, opened from
+ // their location on disk instead of copied to a temporary file.
+ if (this.isExternalAttachment) {
+ openLocalFile(mimeInfo);
+ return;
+ }
+
+ await createTemporaryFileAndOpen(mimeInfo);
+ return;
+ }
+ }
+
+ // Ask what to do, then do it.
+ let appLauncherDialog = Cc[
+ "@mozilla.org/helperapplauncherdialog;1"
+ ].createInstance(Ci.nsIHelperAppLauncherDialog);
+ appLauncherDialog.show(
+ {
+ QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncher"]),
+ MIMEInfo: mimeInfo,
+ source: Services.io.newURI(this.url),
+ suggestedFileName: this.name,
+ cancel(reason) {},
+ promptForSaveDestination() {
+ appLauncherDialog.promptForSaveToFileAsync(
+ this,
+ win,
+ this.suggestedFileName,
+ "." + extension, // Dot stripped by promptForSaveToFileAsync.
+ false
+ );
+ },
+ launchLocalFile() {
+ openLocalFile(mimeInfo);
+ },
+ async setDownloadToLaunch(handleInternally, file) {
+ await createTemporaryFileAndOpen(mimeInfo);
+ },
+ async saveDestinationAvailable(file) {
+ if (file) {
+ await saveToFile(file.path);
+ }
+ },
+ setWebProgressListener(webProgressListener) {},
+ targetFile: null,
+ targetFileIsExecutable: null,
+ timeDownloadStarted: null,
+ contentLength: this.size,
+ browsingContextId: browsingContext.id,
+ },
+ win,
+ null
+ );
+ }
+ }
+
+ /**
+ * Unless overridden by a test, opens a saved attachment when called by `open`.
+ *
+ * @param {nsIMIMEInfo} mimeInfo
+ * @param {nsIFile} file
+ */
+ _openFile(mimeInfo, file) {
+ mimeInfo.launchWithFile(file);
+ }
+
+ /**
+ * Detach this attachment from the message.
+ *
+ * @param {nsIMessenger} messenger
+ * The messenger object associated with the window.
+ * @param {boolean} aSaveFirst - true if the attachment should be saved
+ * before detaching, false otherwise.
+ */
+ detach(messenger, aSaveFirst) {
+ messenger.detachAttachment(
+ this.contentType,
+ this.url,
+ encodeURIComponent(this.name),
+ this.uri,
+ aSaveFirst
+ );
+ }
+
+ /**
+ * This method checks whether the attachment has been deleted or not.
+ *
+ * @returns true if the attachment has been deleted, false otherwise.
+ */
+ get isDeleted() {
+ return this.contentType == "text/x-moz-deleted";
+ }
+
+ /**
+ * This method checks whether the attachment is a detached file.
+ *
+ * @returns true if the attachment is a detached file, false otherwise.
+ */
+ get isFileAttachment() {
+ return this.isExternalAttachment && this.url.startsWith("file:");
+ }
+
+ /**
+ * This method checks whether the attachment is an http link.
+ *
+ * @returns true if the attachment is an http link, false otherwise.
+ */
+ get isLinkAttachment() {
+ return this.isExternalAttachment && /^https?:/.test(this.url);
+ }
+
+ /**
+ * This method checks whether the attachment has an associated file or not.
+ * Deleted attachments or detached attachments with missing external files
+ * do *not* have a file.
+ *
+ * @returns true if the attachment has an associated file, false otherwise.
+ */
+ get hasFile() {
+ if (this.sizeResolved && this.size == -1) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Return display url, decoded and converted to utf8 from IDN punycode ascii,
+ * if the attachment is external (http or file schemes).
+ *
+ * @returns {string} url.
+ */
+ get displayUrl() {
+ if (this.isExternalAttachment) {
+ // For status bar url display purposes, we want the displaySpec.
+ // The ?part= has already been removed.
+ return decodeURI(Services.io.newURI(this.url).displaySpec);
+ }
+
+ return this.url;
+ }
+
+ /**
+ * This method checks whether the attachment url location exists and
+ * is accessible. For http and file urls, fetch() will have the size
+ * in the content-length header.
+ *
+ * @returns {Boolean}
+ * true if the attachment is empty or error, false otherwise.
+ */
+ async isEmpty() {
+ if (this.isDeleted) {
+ return true;
+ }
+
+ const isFetchable = url => {
+ let uri = Services.io.newURI(url);
+ return !(uri.username || uri.userPass);
+ };
+
+ // We have a resolved size.
+ if (this.sizeResolved) {
+ return this.size < 1;
+ }
+
+ if (!isFetchable(this.url)) {
+ return false;
+ }
+
+ let empty = true;
+ let size = -1;
+ let options = { method: "GET" };
+
+ let request = new Request(this.url, options);
+
+ if (this.isExternalAttachment && this.#updateAttachmentsDisplayFn) {
+ this.#updateAttachmentsDisplayFn(this, true);
+ }
+
+ await fetch(request)
+ .then(response => {
+ if (!response.ok) {
+ console.warn(
+ "AttachmentInfo.#: fetch response error - " +
+ response.statusText +
+ ", response.url - " +
+ response.url
+ );
+ return null;
+ }
+
+ if (this.isLinkAttachment) {
+ if (response.status < 200 || response.status > 304) {
+ console.warn(
+ "AttachmentInfo.#: link fetch response status - " +
+ response.status +
+ ", response.url - " +
+ response.url
+ );
+ return null;
+ }
+ }
+
+ return response;
+ })
+ .then(async response => {
+ if (this.isExternalAttachment) {
+ size = response ? response.headers.get("content-length") : -1;
+ } else {
+ // Check the attachment again if addAttachmentField() sets a
+ // libmime -1 return value for size in this object.
+ // Note: just test for a non zero size, don't need to drain the
+ // stream. We only get here if the url is fetchable.
+ // The size for internal attachments is not calculated here but
+ // will come from libmime.
+ let reader = response.body.getReader();
+ let result = await reader.read();
+ reader.cancel();
+ size = result && result.value ? result.value.length : -1;
+ }
+
+ if (size > 0) {
+ empty = false;
+ }
+ })
+ .catch(error => {
+ console.warn(`AttachmentInfo.#: ${error.message} url - ${this.url}`);
+ });
+
+ this.sizeResolved = true;
+
+ if (this.isExternalAttachment) {
+ // For link attachments, we may have had a published value or -1
+ // indicating unknown value. We now know the real size, so set it and
+ // update the ui. For detached file attachments, get the size here
+ // instead of the old xpcom way.
+ this.size = size;
+ this.#updateAttachmentsDisplayFn?.(this, false);
+ }
+
+ return empty;
+ }
+
+ /**
+ * Open a file attachment's containing folder.
+ */
+ openFolder() {
+ if (!this.isFileAttachment || !this.hasFile) {
+ return;
+ }
+
+ // The file url is stored in the attachment info part with unix path and
+ // needs to be converted to os path for nsIFile.
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ try {
+ fileHandler.getFileFromURLSpec(this.displayUrl).reveal();
+ } catch (ex) {
+ console.error(
+ "AttachmentInfo.openFolder: file - " + this.displayUrl + ", " + ex
+ );
+ }
+ }
+}
diff --git a/comm/mail/modules/BrowserWindowTracker.jsm b/comm/mail/modules/BrowserWindowTracker.jsm
new file mode 100644
index 0000000000..619fc78268
--- /dev/null
+++ b/comm/mail/modules/BrowserWindowTracker.jsm
@@ -0,0 +1,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/. */
+
+// This module is deliberately not implemented. It only exists to keep
+// the automated tests happy. See bug 1782621.
+
+var EXPORTED_SYMBOLS = [];
diff --git a/comm/mail/modules/ConversationOpener.jsm b/comm/mail/modules/ConversationOpener.jsm
new file mode 100644
index 0000000000..4414e7d659
--- /dev/null
+++ b/comm/mail/modules/ConversationOpener.jsm
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["ConversationOpener"];
+
+const { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm");
+const { GlodaSyntheticView } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaSyntheticView.jsm"
+);
+
+class ConversationOpener {
+ static isMessageIndexed(message) {
+ if (
+ !Services.prefs.getBoolPref("mailnews.database.global.indexer.enabled")
+ ) {
+ return false;
+ }
+ if (!message || !message.folder) {
+ return false;
+ }
+ return Gloda.isMessageIndexed(message);
+ }
+
+ constructor(window) {
+ this.window = window;
+ }
+ openConversationForMessages(messages) {
+ if (messages.length < 1) {
+ return;
+ }
+ try {
+ this._items = [];
+ this._msgHdr = messages[0];
+ this._queries = [Gloda.getMessageCollectionForHeaders(messages, this)];
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ onItemsAdded(items) {}
+ onItemsModified(items) {}
+ onItemsRemoved(items) {}
+ onQueryCompleted(collection) {
+ try {
+ if (!collection.items.length) {
+ console.error("Couldn't find a collection for msg: " + this._msgHdr);
+ } else {
+ let message = collection.items[0];
+ let tabmail = this.window.top.document.getElementById("tabmail");
+ if (!tabmail) {
+ tabmail = Services.wm
+ .getMostRecentWindow("mail:3pane")
+ .document.getElementById("tabmail");
+ }
+ tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ conversation: message.conversation,
+ message,
+ }),
+ title: message.conversation.subject,
+ background: false,
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}
diff --git a/comm/mail/modules/DBViewWrapper.jsm b/comm/mail/modules/DBViewWrapper.jsm
new file mode 100644
index 0000000000..e88ac3dc05
--- /dev/null
+++ b/comm/mail/modules/DBViewWrapper.jsm
@@ -0,0 +1,2250 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["DBViewWrapper", "IDBViewWrapperListener"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { MailViewConstants, MailViewManager } = ChromeUtils.import(
+ "resource:///modules/MailViewManager.jsm"
+);
+const { SearchSpec } = ChromeUtils.import("resource:///modules/SearchSpec.jsm");
+const { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+
+var MSG_VIEW_FLAG_DUMMY = 0x20000000;
+
+var nsMsgViewIndex_None = 0xffffffff;
+
+/**
+ * Helper singleton for DBViewWrapper that tells instances when something
+ * interesting is happening to the folder(s) they care about. The rationale
+ * for this is to:
+ * - reduce listener overhead (although arguably the events we listen to are
+ * fairly rare)
+ * - make testing / verification easier by centralizing and exposing listeners.
+ *
+ */
+var FolderNotificationHelper = {
+ /**
+ * Maps URIs of pending folder loads to the DBViewWrapper instances that
+ * are waiting on the loads. The value is a list of instances in case
+ * a quick-clicking user is able to do something unexpected.
+ */
+ _pendingFolderUriToViewWrapperLists: {},
+
+ /**
+ * Map URIs of folders to view wrappers interested in hearing about their
+ * deletion.
+ */
+ _interestedWrappers: {},
+
+ /**
+ * Array of wrappers that are interested in all folders, used for
+ * search results wrappers.
+ */
+ _curiousWrappers: [],
+
+ /**
+ * Initialize our listeners. We currently don't bother cleaning these up
+ * because we are a singleton and if anyone imports us, they probably want
+ * us for as long as their application so shall live.
+ */
+ _init() {
+ // register with the session for our folded loaded notifications
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.event | Ci.nsIFolderListener.intPropertyChanged
+ );
+
+ // register with the notification service for deleted folder notifications
+ MailServices.mfn.addListener(
+ this,
+ Ci.nsIMsgFolderNotificationService.folderDeleted |
+ // we need to track renames because we key off of URIs. frick.
+ Ci.nsIMsgFolderNotificationService.folderRenamed |
+ Ci.nsIMsgFolderNotificationService.folderMoveCopyCompleted
+ );
+ },
+
+ /**
+ * Call updateFolder, and assuming all goes well, request that the provided
+ * FolderDisplayWidget be notified when the folder is loaded. This method
+ * performs the updateFolder call for you so there is less chance of leaking.
+ * In the event the updateFolder call fails, we will propagate the exception.
+ */
+ updateFolderAndNotifyOnLoad(aFolder, aFolderDisplay, aMsgWindow) {
+ // set up our datastructure first in case of wacky event sequences
+ let folderURI = aFolder.URI;
+ let wrappers = this._pendingFolderUriToViewWrapperLists[folderURI];
+ if (wrappers == null) {
+ wrappers = this._pendingFolderUriToViewWrapperLists[folderURI] = [];
+ }
+ wrappers.push(aFolderDisplay);
+ try {
+ aFolder.updateFolder(aMsgWindow);
+ } catch (ex) {
+ // uh-oh, that didn't work. tear down the data structure...
+ wrappers.pop();
+ if (wrappers.length == 0) {
+ delete this._pendingFolderUriToViewWrapperLists[folderURI];
+ }
+ throw ex;
+ }
+ },
+
+ /**
+ * Request notification of every little thing these folders do.
+ *
+ * @param aFolders The folders.
+ * @param aNotherFolder A folder that may or may not be in aFolders.
+ * @param aViewWrapper The view wrapper that is up to no good.
+ */
+ stalkFolders(aFolders, aNotherFolder, aViewWrapper) {
+ let folders = aFolders ? aFolders.concat() : [];
+ if (aNotherFolder && !folders.includes(aNotherFolder)) {
+ folders.push(aNotherFolder);
+ }
+ for (let folder of folders) {
+ let wrappers = this._interestedWrappers[folder.URI];
+ if (wrappers == null) {
+ wrappers = this._interestedWrappers[folder.URI] = [];
+ }
+ wrappers.push(aViewWrapper);
+ }
+ },
+
+ /**
+ * Request notification of every little thing every folder does.
+ *
+ * @param aViewWrapper - the viewWrapper interested in every notification.
+ * This will be a search results view of some sort.
+ */
+ noteCuriosity(aViewWrapper) {
+ this._curiousWrappers.push(aViewWrapper);
+ },
+
+ /**
+ * Removal helper for use by removeNotifications.
+ *
+ * @param aTable The table mapping URIs to list of view wrappers.
+ * @param aFolder The folder we care about.
+ * @param aViewWrapper The view wrapper of interest.
+ */
+ _removeWrapperFromListener(aTable, aFolder, aViewWrapper) {
+ let wrappers = aTable[aFolder.URI];
+ if (wrappers) {
+ let index = wrappers.indexOf(aViewWrapper);
+ if (index >= 0) {
+ wrappers.splice(index, 1);
+ }
+ if (wrappers.length == 0) {
+ delete aTable[aFolder.URI];
+ }
+ }
+ },
+ /**
+ * Remove notification requests on the provided folders by the given view
+ * wrapper.
+ */
+ removeNotifications(aFolders, aViewWrapper) {
+ if (!aFolders) {
+ this._curiousWrappers.splice(
+ this._curiousWrappers.indexOf(aViewWrapper),
+ 1
+ );
+ return;
+ }
+ for (let folder of aFolders) {
+ this._removeWrapperFromListener(
+ this._interestedWrappers,
+ folder,
+ aViewWrapper
+ );
+ this._removeWrapperFromListener(
+ this._pendingFolderUriToViewWrapperLists,
+ folder,
+ aViewWrapper
+ );
+ }
+ },
+
+ /**
+ * @returns true if there are any listeners still registered. This is intended
+ * to support debugging code. If you are not debug code, you are a bad
+ * person/code.
+ */
+ haveListeners() {
+ if (Object.keys(this._pendingFolderUriToViewWrapperLists).length > 0) {
+ return true;
+ }
+ if (Object.keys(this._interestedWrappers).length > 0) {
+ return true;
+ }
+ return this._curiousWrappers.length != 0;
+ },
+
+ /* ***** Notifications ***** */
+ _notifyHelper(aFolder, aHandlerName) {
+ let wrappers = this._interestedWrappers[aFolder.URI];
+ if (wrappers) {
+ // clone the list to avoid confusing mutation by listeners
+ for (let wrapper of wrappers.concat()) {
+ wrapper[aHandlerName](aFolder);
+ }
+ }
+ for (let wrapper of this._curiousWrappers) {
+ wrapper[aHandlerName](aFolder);
+ }
+ },
+
+ onFolderEvent(aFolder, aEvent) {
+ if (aEvent == "FolderLoaded") {
+ let folderURI = aFolder.URI;
+ let widgets = this._pendingFolderUriToViewWrapperLists[folderURI];
+ if (widgets) {
+ for (let widget of widgets) {
+ // we are friends, this is an explicit relationship.
+ // (we don't use a generic callback mechanism because the 'this' stuff
+ // gets ugly and no one else should be hooking in at this level.)
+ try {
+ widget._folderLoaded(aFolder);
+ } catch (ex) {
+ dump(
+ "``` EXCEPTION DURING NOTIFY: " +
+ ex.fileName +
+ ":" +
+ ex.lineNumber +
+ ": " +
+ ex +
+ "\n"
+ );
+ if (ex.stack) {
+ dump("STACK: " + ex.stack + "\n");
+ }
+ console.error(ex);
+ }
+ }
+ delete this._pendingFolderUriToViewWrapperLists[folderURI];
+ }
+ } else if (aEvent == "AboutToCompact") {
+ this._notifyHelper(aFolder, "_aboutToCompactFolder");
+ } else if (aEvent == "CompactCompleted") {
+ this._notifyHelper(aFolder, "_compactedFolder");
+ } else if (aEvent == "DeleteOrMoveMsgCompleted") {
+ this._notifyHelper(aFolder, "_deleteCompleted");
+ } else if (aEvent == "DeleteOrMoveMsgFailed") {
+ this._notifyHelper(aFolder, "_deleteFailed");
+ } else if (aEvent == "RenameCompleted") {
+ this._notifyHelper(aFolder, "_renameCompleted");
+ }
+ },
+
+ onFolderIntPropertyChanged(aFolder, aProperty, aOldValue, aNewValue) {
+ if (aProperty == "TotalMessages" || aProperty == "TotalUnreadMessages") {
+ this._notifyHelper(aFolder, "_messageCountsChanged");
+ }
+ },
+
+ _folderMoveHelper(aOldFolder, aNewFolder) {
+ let oldURI = aOldFolder.URI;
+ let newURI = aNewFolder.URI;
+ // fix up our listener tables.
+ if (oldURI in this._pendingFolderUriToViewWrapperLists) {
+ this._pendingFolderUriToViewWrapperLists[newURI] =
+ this._pendingFolderUriToViewWrapperLists[oldURI];
+ delete this._pendingFolderUriToViewWrapperLists[oldURI];
+ }
+ if (oldURI in this._interestedWrappers) {
+ this._interestedWrappers[newURI] = this._interestedWrappers[oldURI];
+ delete this._interestedWrappers[oldURI];
+ }
+
+ let wrappers = this._interestedWrappers[newURI];
+ if (wrappers) {
+ // clone the list to avoid confusing mutation by listeners
+ for (let wrapper of wrappers.concat()) {
+ wrapper._folderMoved(aOldFolder, aNewFolder);
+ }
+ }
+ },
+
+ /**
+ * Update our URI mapping tables when renames happen.
+ */
+ folderRenamed(aOrigFolder, aNewFolder) {
+ this._folderMoveHelper(aOrigFolder, aNewFolder);
+ },
+
+ folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) {
+ if (aMove) {
+ let aNewFolder = aDestFolder.getChildNamed(aSrcFolder.prettyName);
+ this._folderMoveHelper(aSrcFolder, aNewFolder);
+ }
+ },
+
+ folderDeleted(aFolder) {
+ let wrappers = this._interestedWrappers[aFolder.URI];
+ if (wrappers) {
+ // clone the list to avoid confusing mutation by listeners
+ for (let wrapper of wrappers.concat()) {
+ wrapper._folderDeleted(aFolder);
+ }
+ // if the folder is deleted, it's not going to ever do anything again
+ delete this._interestedWrappers[aFolder.URI];
+ }
+ },
+};
+FolderNotificationHelper._init();
+
+/**
+ * Defines the DBViewWrapper listener interface. This class exists exclusively
+ * for documentation purposes and should never be instantiated.
+ */
+function IDBViewWrapperListener() {}
+IDBViewWrapperListener.prototype = {
+ // uh, this is secretly exposed for debug purposes. DO NOT LOOK AT ME!
+ _FNH: FolderNotificationHelper,
+
+ /* ===== Exposure of UI Globals ===== */
+ messenger: null,
+ msgWindow: null,
+ threadPaneCommandUpdater: null,
+
+ /* ===== Guidance ===== */
+ /**
+ * Indicate whether mail view settings should be used/honored. A UI oddity
+ * is that we only have mail views be sticky if its combo box UI is visible.
+ * (Without the view combobox, it may not be obvious that the mail is
+ * filtered.)
+ */
+ get shouldUseMailViews() {
+ return false;
+ },
+
+ /**
+ * Should we defer displaying the messages in this folder until after we have
+ * talked to the server? This is for our poor man's password protection
+ * via the "mail.password_protect_local_cache" pref. We add this specific
+ * check rather than internalizing the logic in the wrapper because the
+ * password protection is a shoddy UI-only protection.
+ */
+ get shouldDeferMessageDisplayUntilAfterServerConnect() {
+ return false;
+ },
+
+ /**
+ * Should we mark all messages in a folder as read on exit?
+ * This is nominally controlled by the "mailnews.mark_message_read.SERVERTYPE"
+ * preference (on a per-server-type basis).
+ * For the record, this functionality should not remotely be in the core.
+ *
+ * @param aMsgFolder The folder we are leaving and are unsure if we should
+ * mark all its messages read. I pass the folder instead of the server
+ * type because having a crazy feature like this will inevitably lead to
+ * a more full-featured crazy feature (why not on a per-folder basis, eh?)
+ * @returns true if we should mark all the dudes as read, false if not.
+ */
+ shouldMarkMessagesReadOnLeavingFolder(aMsgFolder) {
+ return false;
+ },
+
+ /* ===== Event Notifications ===== */
+ /* === Status Changes === */
+ /**
+ * We tell you when we start and stop loading the folder. This is a good
+ * time to mess with the hour-glass cursor machinery if you are inclined to
+ * do so.
+ */
+ onFolderLoading(aIsFolderLoading) {},
+
+ /**
+ * We tell you when we start and stop searching. This is a good time to mess
+ * with progress spinners (meteors) and the like, if you are so inclined.
+ */
+ onSearching(aIsSearching) {},
+
+ /**
+ * This event is generated when a new view has been created. It is intended
+ * to be used to provide the MsgCreateDBView notification so that custom
+ * columns can add themselves to the view.
+ * The notification is not generated by the DBViewWrapper itself because this
+ * is fundamentally a UI issue. Additionally, because the MsgCreateDBView
+ * notification consumers assume gDBView whose exposure is affected by tabs,
+ * the tab logic needs to be involved.
+ */
+ onCreatedView() {},
+
+ /**
+ * This event is generated just before we close/destroy a message view.
+ *
+ * @param aFolderIsComingBack Indicates whether we are planning to create a
+ * new view to display the same folder after we destroy this view. This
+ * will be the case unless we are switching to display a new folder or
+ * closing the view wrapper entirely.
+ */
+ onDestroyingView(aFolderIsComingBack) {},
+
+ /**
+ * Generated when we are loading information about the folder from its
+ * dbFolderInfo. The dbFolderInfo object is passed in.
+ * The DBViewWrapper has already restored its state when this function is
+ * called, but has not yet created the dbView. A view update is in process,
+ * so the view settings can be changed and will take effect when the update
+ * is closed.
+ * |onDisplayingFolder| is the next expected notification following this
+ * notification.
+ */
+ onLoadingFolder(aDbFolderInfo) {},
+
+ /**
+ * Generated when the folder is being entered for display. This is the chance
+ * for the listener to affect any UI-related changes to the folder required.
+ * Currently, this just means setting the header cache size (which needs to
+ * be proportional to the number of lines in the tree view, and is thus a
+ * UI issue.)
+ * The dbView has already been created and is valid when this function is
+ * called.
+ * |onLoadingFolder| is called before this notification.
+ */
+ onDisplayingFolder() {},
+
+ /**
+ * Generated when we are leaving a folder.
+ */
+ onLeavingFolder() {},
+
+ /**
+ * Things to do once all the messages that should show up in a folder have
+ * shown up. For a real folder, this happens when the folder is entered.
+ * For a (multi-folder) virtual folder, this happens when the search
+ * completes.
+ * You may get onMessagesLoaded called with aAll false immediately after
+ * the view is opened. You will definitely get onMessagesLoaded(true)
+ * when we've finished getting the headers for the view.
+ */
+ onMessagesLoaded(aAll) {},
+
+ /**
+ * The mail view changed. The mail view widget is likely to care about this.
+ */
+ onMailViewChanged() {},
+
+ /**
+ * The active sort changed, and that is all that changed. If the sort is
+ * changing because the view is being destroyed and re-created, this event
+ * will not be generated.
+ */
+ onSortChanged() {},
+
+ /**
+ * This event is generated when messages in one of the folders backing the
+ * view have been removed by message moves / deletion. If there is a search
+ * in effect, it is possible that the removed messages were not visible in
+ * the view in the first place.
+ */
+ onMessagesRemoved() {},
+
+ /**
+ * Like onMessagesRemoved, but something went awry in the move/deletion and
+ * it failed. Although this is not a very interesting event on its own,
+ * it is useful in cases where the listener was expecting an
+ * onMessagesRemoved and might need to clean some state up.
+ */
+ onMessageRemovalFailed() {},
+
+ /**
+ * The total message count or total unread message counts changed.
+ */
+ onMessageCountsChanged() {},
+};
+
+/**
+ * Encapsulates everything related to working with our nsIMsgDBView
+ * implementations.
+ *
+ * Things we do not do and why we do not do them:
+ * - Selection. This depends on having an nsITreeSelection object and we choose
+ * to avoid entanglement with XUL/layout code. Selection accordingly must be
+ * handled a layer up in the FolderDisplayWidget.
+ */
+function DBViewWrapper(aListener) {
+ this.displayedFolder = null;
+ this.listener = aListener;
+
+ this._underlyingData = this.kUnderlyingNone;
+ this._underlyingFolders = null;
+ this._syntheticView = null;
+
+ this._viewUpdateDepth = 0;
+
+ this._mailViewIndex = MailViewConstants.kViewItemAll;
+ this._mailViewData = null;
+
+ this._specialView = null;
+
+ this._sort = [];
+ // see the _viewFlags getter and setter for info on our use of __viewFlags.
+ this.__viewFlags = null;
+
+ /**
+ * It's possible to support grouped view thread expand/collapse, and also sort
+ * by thread despite the back end (see nsMsgQuickSearchDBView::SortThreads).
+ * Also, nsMsgQuickSearchDBView does not respect the kExpandAll flag, fix that.
+ */
+ this._threadExpandAll = true;
+
+ this.dbView = null;
+ this.search = null;
+
+ this._folderLoading = false;
+ this._searching = false;
+}
+DBViewWrapper.prototype = {
+ /* = constants explaining the nature of the underlying data = */
+ /**
+ * We currently don't have any underlying data.
+ */
+ kUnderlyingNone: 0,
+ /**
+ * The underlying data source is a single folder.
+ */
+ kUnderlyingRealFolder: 1,
+ /**
+ * The underlying data source is a virtual folder that is operating over
+ * multiple underlying folders.
+ */
+ kUnderlyingMultipleFolder: 2,
+ /**
+ * Our data source is transient, most likely a gloda search that crammed the
+ * results into us. This is different from a search view.
+ */
+ kUnderlyingSynthetic: 3,
+ /**
+ * We are a search view, which translates into a search that has underlying
+ * folders, just like kUnderlyingMultipleFolder, but we have no
+ * displayedFolder. We differ from kUnderlyingSynthetic in that we are
+ * not just a bunch of message headers randomly crammed in.
+ */
+ kUnderlyingSearchView: 4,
+
+ /**
+ * @returns true if the folder being displayed is backed by a single 'real'
+ * folder. This folder can be a saved search on that folder or just
+ * an outright un-filtered display of that folder.
+ */
+ get isSingleFolder() {
+ return this._underlyingData == this.kUnderlyingRealFolder;
+ },
+
+ /**
+ * @returns true if the folder being displayed is a virtual folder backed by
+ * multiple 'real' folders or a search view. This corresponds to a
+ * cross-folder saved search.
+ */
+ get isMultiFolder() {
+ return (
+ this._underlyingData == this.kUnderlyingMultipleFolder ||
+ this._underlyingData == this.kUnderlyingSearchView
+ );
+ },
+
+ /**
+ * @returns true if the folder being displayed is not a real folder at all,
+ * but rather the result of an un-scoped search, such as a gloda search.
+ */
+ get isSynthetic() {
+ return this._underlyingData == this.kUnderlyingSynthetic;
+ },
+
+ /**
+ * @returns true if the folder being displayed is not a real folder at all,
+ * but rather the result of a search.
+ */
+ get isSearch() {
+ return this._underlyingData == this.kUnderlyingSearchView;
+ },
+
+ /**
+ * Check if the folder in question backs the currently displayed folder. For
+ * a virtual folder, this is a test of whether the virtual folder includes
+ * messages from the given folder. For a 'real' single folder, this is
+ * effectively a test against displayedFolder.
+ * If you want to see if the displayed folder is a folder, just compare
+ * against the displayedFolder attribute.
+ */
+ isUnderlyingFolder(aFolder) {
+ return this._underlyingFolders.some(
+ underlyingFolder => aFolder == underlyingFolder
+ );
+ },
+
+ /**
+ * Refresh the view by re-creating the view. You would do this to get rid of
+ * messages that no longer match the view but are kept around for view
+ * stability reasons. (In other words, in an unread-messages view, you would
+ * go insane if when you clicked on a message it immediately disappeared
+ * because it no longer matched.)
+ * This method was adding for testing purposes and does not have a (legacy) UI
+ * reason for existing. (The 'open' method is intended to behave identically
+ * to the legacy UI if you click on the currently displayed folder.)
+ */
+ refresh() {
+ this._applyViewChanges();
+ },
+
+ /**
+ * Null out the folder's database to avoid memory bloat if we don't have a
+ * reason to keep the database around. Currently, we keep all Inboxes
+ * around and null out everyone else. This is a standard stopgap measure
+ * until we have something more clever going on.
+ * In general, there is little potential downside to nulling out the message
+ * database reference when it is in use. As long as someone is holding onto
+ * a message header from the database, the database will be kept open, and
+ * therefore the database service will still have a reference to the db.
+ * When the folder goes to ask for the database again, the service will have
+ * it, and it will not need to be re-opened.
+ *
+ * Another heuristic we could theoretically use is use the mail session's
+ * isFolderOpenInWindow call, except that uses the outmoded concept that each
+ * window will have at most one folder open. So nuts to that.
+ *
+ * Note: regrettably a unit test cannot verify that we did this; msgDatabase
+ * is a getter that will always try and load the message database!
+ */
+ _releaseFolderDatabase(aFolder) {
+ if (!aFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Inbox, false)) {
+ aFolder.msgDatabase = null;
+ }
+ },
+
+ /**
+ * Clone this DBViewWrapper and its underlying nsIMsgDBView.
+ *
+ * @param aListener {IDBViewWrapperListener} The listener to use on the new view.
+ */
+ clone(aListener) {
+ let doppel = new DBViewWrapper(aListener);
+
+ // -- copy attributes
+ doppel.displayedFolder = this.displayedFolder;
+ doppel._underlyingData = this._underlyingData;
+ doppel._underlyingFolders = this._underlyingFolders
+ ? this._underlyingFolders.concat()
+ : null;
+ doppel._syntheticView = this._syntheticView;
+
+ // _viewUpdateDepth should stay at its initial value of zero
+ doppel._mailViewIndex = this._mailViewIndex;
+ doppel._mailViewData = this._mailViewData;
+
+ doppel._specialView = this._specialView;
+ // a shallow copy is all that is required for sort; we do not mutate entries
+ doppel._sort = this._sort.concat();
+
+ // -- register listeners...
+ // note: this does not get us a folder loaded notification. Our expected
+ // use case for cloning is displaying a single message already visible in
+ // the original view, which implies we don't need to hang about for folder
+ // loaded notification messages.
+ FolderNotificationHelper.stalkFolders(
+ doppel._underlyingFolders,
+ doppel.displayedFolder,
+ doppel
+ );
+
+ // -- clone the view
+ if (this.dbView) {
+ doppel.dbView = this.dbView
+ .cloneDBView(
+ aListener.messenger,
+ aListener.msgWindow,
+ aListener.threadPaneCommandUpdater
+ )
+ .QueryInterface(Ci.nsITreeView);
+ }
+ // -- clone the search
+ if (this.search) {
+ doppel.search = this.search.clone(doppel);
+ }
+
+ if (
+ doppel._underlyingData == this.kUnderlyingSearchView ||
+ doppel._underlyingData == this.kUnderlyingSynthetic
+ ) {
+ FolderNotificationHelper.noteCuriosity(doppel);
+ }
+
+ return doppel;
+ },
+
+ /**
+ * Close the current view. You would only do this if you want to clean up all
+ * the resources associated with this view wrapper. You would not do this
+ * for UI reasons like the user de-selecting the node in the tree; we should
+ * always be displaying something when used in a UI context!
+ *
+ * @param {boolean} folderIsDead - If true, tells us not to try and tidy up
+ * on our way out by virtue of the fact that the folder is dead and should
+ * not be messed with.
+ */
+ close(folderIsDead) {
+ if (this.displayedFolder != null) {
+ // onLeavingFolder does all the application-level stuff related to leaving
+ // the folder (marking as read, etc.) We only do this when the folder
+ // is not dead (for obvious reasons).
+ if (!folderIsDead) {
+ // onLeavingFolder must be called before we potentially null out its
+ // msgDatabase, which we will do in the upcoming underlyingFolders loop
+ this.onLeavingFolder(); // application logic
+ this.listener.onLeavingFolder(); // display logic
+ }
+ // (potentially) zero out the display folder if we are dealing with a
+ // virtual folder and so the next loop won't take care of it.
+ if (this.isVirtual) {
+ FolderNotificationHelper.removeNotifications(
+ [this.displayedFolder],
+ this
+ );
+ this._releaseFolderDatabase(this.displayedFolder);
+ }
+
+ this.folderLoading = false;
+ this.displayedFolder = null;
+ }
+
+ FolderNotificationHelper.removeNotifications(this._underlyingFolders, this);
+ if (this.isSearch || this.isSynthetic) {
+ // Opposite of FolderNotificationHelper.noteCuriosity(this)
+ FolderNotificationHelper.removeNotifications(null, this);
+ }
+
+ if (this._underlyingFolders) {
+ // (potentially) zero out the underlying msgDatabase references
+ for (let folder of this._underlyingFolders) {
+ this._releaseFolderDatabase(folder);
+ }
+ }
+
+ // kill off the view and its search association
+ if (this.dbView) {
+ this.listener.onDestroyingView(false);
+ this.search.dissociateView(this.dbView);
+ this.dbView.setTree(null);
+ this.dbView.setJSTree(null);
+ this.dbView.selection = null;
+ this.dbView.close();
+ this.dbView = null;
+ }
+
+ // zero out the view update depth here. We don't do it on open because it's
+ // theoretically be nice to be able to start a view update before you open
+ // something so you can defer the open. In practice, that is not yet
+ // tested.
+ this._viewUpdateDepth = 0;
+
+ this._underlyingData = this.kUnderlyingNone;
+ this._underlyingFolders = null;
+ this._syntheticView = null;
+
+ this._mailViewIndex = MailViewConstants.kViewItemAll;
+ this._mailViewData = null;
+
+ this._specialView = null;
+
+ this._sort = [];
+ this.__viewFlags = null;
+
+ this.search = null;
+ },
+
+ /**
+ * Open the passed-in nsIMsgFolder folder. Use openSynthetic for synthetic
+ * view providers.
+ */
+ open(aFolder) {
+ if (aFolder == null) {
+ this.close();
+ return;
+ }
+
+ // If we are in the same folder, there is nothing to do unless we are a
+ // virtual folder. Virtual folders apparently want to try and get updated.
+ if (this.displayedFolder == aFolder) {
+ if (!this.isVirtual) {
+ return;
+ }
+ // note: we intentionally (for consistency with old code, not that the
+ // code claimed to have a good reason) fall through here and call
+ // onLeavingFolder via close even though that's debatable in this case.
+ }
+ this.close();
+
+ this.displayedFolder = aFolder;
+ this._enteredFolder = false;
+
+ this.search = new SearchSpec(this);
+ this._sort = [];
+
+ if (aFolder.isServer) {
+ this._showServer();
+ return;
+ }
+
+ let typeForTelemetry =
+ [
+ "Inbox",
+ "Drafts",
+ "Trash",
+ "SentMail",
+ "Templates",
+ "Junk",
+ "Archive",
+ "Queue",
+ "Virtual",
+ ].find(x => aFolder.getFlag(Ci.nsMsgFolderFlags[x])) || "Other";
+ Services.telemetry.keyedScalarAdd(
+ "tb.mails.folder_opened",
+ typeForTelemetry,
+ 1
+ );
+
+ this.beginViewUpdate();
+ let msgDatabase;
+ try {
+ // This will throw an exception if the .msf file is missing,
+ // out of date (e.g., the local folder has changed), or corrupted.
+ msgDatabase = this.displayedFolder.msgDatabase;
+ } catch (e) {}
+ if (msgDatabase) {
+ this._prepareToLoadView(msgDatabase, aFolder);
+ }
+
+ if (!this.isVirtual) {
+ this.folderLoading = true;
+ FolderNotificationHelper.updateFolderAndNotifyOnLoad(
+ this.displayedFolder,
+ this,
+ this.listener.msgWindow
+ );
+ }
+
+ // we do this after kicking off the update because this could initiate a
+ // search which could fight our explicit updateFolder call if the search
+ // is already outstanding.
+ if (this.shouldShowMessagesForFolderImmediately()) {
+ this._enterFolder();
+ }
+ },
+
+ /**
+ * Open a synthetic view provider as backing our view.
+ */
+ openSynthetic(aSyntheticView) {
+ this.close();
+
+ this._underlyingData = this.kUnderlyingSynthetic;
+ this._syntheticView = aSyntheticView;
+
+ this.search = new SearchSpec(this);
+ this._sort = this._syntheticView.defaultSort.concat();
+
+ this._applyViewChanges();
+ FolderNotificationHelper.noteCuriosity(this);
+ this.listener.onDisplayingFolder();
+ },
+
+ /**
+ * Makes us irrevocavbly be a search view, for use in search windows.
+ * Once you call this, you are not allowed to use us for anything
+ * but a search view!
+ * We add a 'searchFolders' property that allows you to control what
+ * folders we are searching over.
+ */
+ openSearchView() {
+ this.close();
+
+ this._underlyingData = this.kUnderlyingSearchView;
+ this._underlyingFolders = [];
+
+ let dis = this;
+ this.__defineGetter__("searchFolders", function () {
+ return dis._underlyingFolders;
+ });
+ this.__defineSetter__("searchFolders", function (aSearchFolders) {
+ dis._underlyingFolders = aSearchFolders;
+ dis._applyViewChanges();
+ });
+
+ this.search = new SearchSpec(this);
+ // the search view uses the order in which messages are added as the
+ // order by default.
+ this._sort = [
+ [Ci.nsMsgViewSortType.byNone, Ci.nsMsgViewSortOrder.ascending],
+ ];
+ this.__viewFlags = Ci.nsMsgViewFlagsType.kNone;
+
+ FolderNotificationHelper.noteCuriosity(this);
+ this._applyViewChanges();
+ },
+
+ get folderLoading() {
+ return this._folderLoading;
+ },
+ set folderLoading(aFolderLoading) {
+ if (this._folderLoading == aFolderLoading) {
+ return;
+ }
+ this._folderLoading = aFolderLoading;
+ // tell the folder about what is going on so it can remove its db change
+ // listener and restore it, respectively.
+ if (aFolderLoading) {
+ this.displayedFolder.startFolderLoading();
+ } else {
+ this.displayedFolder.endFolderLoading();
+ }
+ this.listener.onFolderLoading(aFolderLoading);
+ },
+
+ get searching() {
+ return this._searching;
+ },
+ set searching(aSearching) {
+ if (aSearching == this._searching) {
+ return;
+ }
+ this._searching = aSearching;
+ this.listener.onSearching(aSearching);
+ // notify that all messages are loaded if searching has concluded
+ if (!aSearching) {
+ this.listener.onMessagesLoaded(true);
+ }
+ },
+
+ /**
+ * Do we want to show the messages immediately, or should we wait for
+ * updateFolder to complete? The historical heuristic is:
+ * - Virtual folders get shown immediately (and updateFolder has no
+ * meaning for them anyways.)
+ * - If _underlyingFolders == null, we failed to open the database,
+ * so we need to wait for UpdateFolder to reparse the folder (in the
+ * local folder case).
+ * - Wait on updateFolder if our poor man's security via
+ * "mail.password_protect_local_cache" preference is enabled and the
+ * server requires a password to login. This is accomplished by asking our
+ * listener via shouldDeferMessageDisplayUntilAfterServerConnect. Note that
+ * there is an obvious hole in this logic because of the virtual folder case
+ * above.
+ *
+ * @pre this.folderDisplayed is the folder we are talking about.
+ *
+ * @returns true if the folder should be shown immediately, false if we should
+ * wait for updateFolder to complete.
+ */
+ shouldShowMessagesForFolderImmediately() {
+ return (
+ this.isVirtual ||
+ !(
+ this._underlyingFolders == null ||
+ this.listener.shouldDeferMessageDisplayUntilAfterServerConnect
+ )
+ );
+ },
+ /**
+ * Extract information about the view from the dbFolderInfo (e.g., sort type,
+ * sort order, current view flags, etc), and save in the view wrapper.
+ */
+ _prepareToLoadView(msgDatabase, aFolder) {
+ let dbFolderInfo = msgDatabase.dBFolderInfo;
+ // - retrieve persisted sort information
+ this._sort = [[dbFolderInfo.sortType, dbFolderInfo.sortOrder]];
+
+ // - retrieve persisted display settings
+ this.__viewFlags = dbFolderInfo.viewFlags;
+ // - retrieve persisted thread last expanded state.
+ this._threadExpandAll = Boolean(
+ this.__viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ // Make sure the threaded bit is set if group-by-sort is set. The views
+ // encode 3 states in 2-bits, and we want to avoid that odd-man-out
+ // state.
+ // The expand flag must be set when opening a single virtual folder
+ // (quicksearch) in grouped view. The user's last set expand/collapse state
+ // for grouped/threaded in this use case is restored later.
+ if (this.__viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort) {
+ this.__viewFlags |= Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ this.__viewFlags |= Ci.nsMsgViewFlagsType.kExpandAll;
+ this._ensureValidSort();
+ }
+
+ // See if the last-used view was one of the special views. If so, put us in
+ // that special view mode. We intentionally do this after restoring the
+ // view flags because _setSpecialView enforces threading.
+ // The nsMsgDBView is the one who persists this information for us. In this
+ // case the nsMsgThreadedDBView superclass of the special views triggers it
+ // when opened.
+ let viewType = dbFolderInfo.viewType;
+ if (
+ viewType == Ci.nsMsgViewType.eShowThreadsWithUnread ||
+ viewType == Ci.nsMsgViewType.eShowWatchedThreadsWithUnread
+ ) {
+ this._setSpecialView(viewType);
+ }
+
+ // - retrieve virtual folder configuration
+ if (aFolder.flags & Ci.nsMsgFolderFlags.Virtual) {
+ let virtFolder = VirtualFolderHelper.wrapVirtualFolder(aFolder);
+
+ if (virtFolder.searchFolderURIs == "*") {
+ // This is a special virtual folder that searches all folders in all
+ // accounts (except the unwanted types listed). Get those folders now.
+ let unwantedFlags =
+ Ci.nsMsgFolderFlags.Trash |
+ Ci.nsMsgFolderFlags.Junk |
+ Ci.nsMsgFolderFlags.Queue |
+ Ci.nsMsgFolderFlags.Virtual;
+ this._underlyingFolders = [];
+ for (let server of MailServices.accounts.allServers) {
+ for (let f of server.rootFolder.descendants) {
+ if (!f.isSpecialFolder(unwantedFlags, true)) {
+ this._underlyingFolders.push(f);
+ }
+ }
+ }
+ } else {
+ // Filter out the server roots; they only exist for UI reasons.
+ this._underlyingFolders = virtFolder.searchFolders.filter(
+ folder => !folder.isServer
+ );
+ }
+ this._underlyingData =
+ this._underlyingFolders.length > 1
+ ? this.kUnderlyingMultipleFolder
+ : this.kUnderlyingRealFolder;
+
+ // figure out if we are using online IMAP searching
+ this.search.onlineSearch = virtFolder.onlineSearch;
+
+ // retrieve and chew the search query
+ this.search.virtualFolderTerms = virtFolder.searchTerms;
+ } else {
+ this._underlyingData = this.kUnderlyingRealFolder;
+ this._underlyingFolders = [this.displayedFolder];
+ }
+
+ FolderNotificationHelper.stalkFolders(
+ this._underlyingFolders,
+ this.displayedFolder,
+ this
+ );
+
+ // - retrieve mail view configuration
+ if (this.listener.shouldUseMailViews) {
+ // if there is a view tag (basically ":tagname"), then it's a
+ // mailview tag. clearly.
+ let mailViewTag = dbFolderInfo.getCharProperty(
+ MailViewConstants.kViewCurrentTag
+ );
+ // "0" and "1" are all and unread views, respectively, from 2.0
+ if (mailViewTag && mailViewTag != "0" && mailViewTag != "1") {
+ // the tag gets stored with a ":" on the front, presumably done
+ // as a means of name-spacing that was never subsequently leveraged.
+ if (mailViewTag.startsWith(":")) {
+ mailViewTag = mailViewTag.substr(1);
+ }
+ // (the true is so we don't persist)
+ this.setMailView(MailViewConstants.kViewItemTags, mailViewTag, true);
+ } else {
+ // otherwise it's just an index. we kinda-sorta migrate from old-school
+ // $label tags, except someone reused one of the indices for
+ // kViewItemNotDeleted, which means that $label2 can no longer be
+ // migrated.
+ let mailViewIndex = dbFolderInfo.getUint32Property(
+ MailViewConstants.kViewCurrent,
+ MailViewConstants.kViewItemAll
+ );
+ // label migration per above
+ if (
+ mailViewIndex == MailViewConstants.kViewItemTags ||
+ (MailViewConstants.kViewItemTags + 2 <= mailViewIndex &&
+ mailViewIndex < MailViewConstants.kViewItemVirtual)
+ ) {
+ this.setMailView(
+ MailViewConstants.kViewItemTags,
+ "$label" + (mailViewIndex - 1)
+ );
+ } else {
+ this.setMailView(mailViewIndex);
+ }
+ }
+ }
+
+ this.listener.onLoadingFolder(dbFolderInfo);
+ },
+
+ /**
+ * Creates a view appropriate to the current settings of the folder display
+ * widget, returning it. The caller is responsible to assign the result to
+ * this.dbView (or whatever it wants to do with it.)
+ */
+ _createView() {
+ let dbviewContractId = "@mozilla.org/messenger/msgdbview;1?type=";
+
+ // we will have saved these off when closing our view
+ let viewFlags =
+ this.__viewFlags ??
+ Services.prefs.getIntPref("mailnews.default_view_flags", 1);
+
+ // real folders are subject to the most interest set of possibilities...
+ if (this._underlyingData == this.kUnderlyingRealFolder) {
+ // quick-search inherits from threaded which inherits from group, so this
+ // is right to choose it first.
+ if (this.search.hasSearchTerms) {
+ dbviewContractId += "quicksearch";
+ } else if (this.showGroupedBySort) {
+ dbviewContractId += "group";
+ } else if (this.specialViewThreadsWithUnread) {
+ dbviewContractId += "threadswithunread";
+ } else if (this.specialViewWatchedThreadsWithUnread) {
+ dbviewContractId += "watchedthreadswithunread";
+ } else {
+ dbviewContractId += "threaded";
+ }
+ } else if (this._underlyingData == this.kUnderlyingMultipleFolder) {
+ // if we're dealing with virtual folders, the answer is always an xfvf
+ dbviewContractId += "xfvf";
+ } else {
+ // kUnderlyingSynthetic or kUnderlyingSearchView
+ dbviewContractId += "search";
+ }
+
+ // and now zero the saved-off flags.
+ this.__viewFlags = null;
+
+ let dbView = Cc[dbviewContractId].createInstance(Ci.nsIMsgDBView);
+ dbView.init(
+ this.listener.messenger,
+ this.listener.msgWindow,
+ this.listener.threadPaneCommandUpdater
+ );
+ // Excluding Group By views, use the least-specific sort so we can clock
+ // them back through to build up the correct sort order,
+ const index =
+ viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort
+ ? 0
+ : this._sort.length - 1;
+ let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(index);
+ let outCount = {};
+ // when the underlying folder is a single real folder (virtual or no), we
+ // tell the view about the underlying folder.
+ if (this.isSingleFolder) {
+ // If the folder is virtual, m_viewFolder needs to be set before the
+ // folder is opened, otherwise persisted sort info will not be restored
+ // from the right dbFolderInfo. The use case is for a single folder
+ // backed saved search. Currently, sort etc. changes in quick filter are
+ // persisted (gloda list and quick filter in gloda list are not involved).
+ if (this.isVirtual) {
+ dbView.viewFolder = this.displayedFolder;
+ }
+
+ // Open the folder.
+ dbView.open(
+ this._underlyingFolders[0],
+ sortType,
+ sortOrder,
+ viewFlags,
+ outCount
+ );
+
+ // If there are any search terms, we need to tell the db view about the
+ // the display (/virtual) folder so it can store all the view-specific
+ // data there (things like the active mail view and such that go in
+ // dbFolderInfo.) This also goes for cases where the quick search is
+ // active; the C++ code explicitly nulls out the view folder for no
+ // good/documented reason, so we need to set it again if we want changes
+ // made with the quick filter applied. (We don't just change the C++
+ // code because there could be SeaMonkey fallout.) See bug 502767 for
+ // info about the quick-search part of the problem.
+ if (this.search.hasSearchTerms) {
+ dbView.viewFolder = this.displayedFolder;
+ }
+ } else {
+ // when we're dealing with a multi-folder virtual folder, we just tell the
+ // db view about the display folder. (It gets its own XFVF view, so it
+ // knows what to do.)
+ // and for a synthetic folder, displayedFolder is null anyways
+ dbView.open(
+ this.displayedFolder,
+ sortType,
+ sortOrder,
+ viewFlags,
+ outCount
+ );
+ }
+ if (sortCustomCol) {
+ dbView.curCustomColumn = sortCustomCol;
+ }
+
+ // we all know it's a tree view, make sure the interface is available
+ // so no one else has to do this.
+ dbView.QueryInterface(Ci.nsITreeView);
+
+ // If Grouped By, the view has already been opened with the most specific
+ // sort (groups themselves are always sorted by date).
+ if (!(viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort)) {
+ // clock through the rest of the sorts, if there are any
+ for (let iSort = this._sort.length - 2; iSort >= 0; iSort--) {
+ [sortType, sortOrder, sortCustomCol] = this._getSortDetails(iSort);
+ if (sortCustomCol) {
+ dbView.curCustomColumn = sortCustomCol;
+ }
+ dbView.sort(sortType, sortOrder);
+ }
+ }
+
+ return dbView;
+ },
+
+ /**
+ * Callback method invoked by FolderNotificationHelper when our folder is
+ * loaded. Assuming we are still interested in the folder, we enter the
+ * folder via _enterFolder.
+ */
+ _folderLoaded(aFolder) {
+ if (aFolder == this.displayedFolder) {
+ this.folderLoading = false;
+ // If _underlyingFolders is null, DBViewWrapper_open probably got
+ // an exception trying to open the db, but after reparsing the local
+ // folder, we should have a db, so set up the view based on info
+ // from the db.
+ if (this._underlyingFolders == null) {
+ this._prepareToLoadView(aFolder.msgDatabase, aFolder);
+ }
+ this._enterFolder();
+ }
+ },
+
+ /**
+ * Enter this.displayedFolder if we have not yet entered it.
+ *
+ * Things we do on entering a folder:
+ * - clear the folder's biffState!
+ * - set the message database's header cache size
+ */
+ _enterFolder() {
+ if (this._enteredFolder) {
+ this.listener.onMessagesLoaded(true);
+ return;
+ }
+
+ this.displayedFolder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail;
+
+ // we definitely want a view at this point; force the view.
+ this._viewUpdateDepth = 0;
+ this._applyViewChanges();
+
+ this.listener.onDisplayingFolder();
+
+ this._enteredFolder = true;
+ },
+
+ /**
+ * Renames, moves to the trash, it's all crazy. We have to update all our
+ * references when this happens.
+ */
+ _folderMoved(aOldFolder, aNewFolder) {
+ if (aOldFolder == this.displayedFolder) {
+ this.displayedFolder = aNewFolder;
+ }
+
+ if (!this._underlyingFolders) {
+ // View is closed already.
+ return;
+ }
+
+ let i = this._underlyingFolders.findIndex(f => f == aOldFolder);
+ if (i >= 0) {
+ this._underlyingFolders[i] = aNewFolder;
+ }
+
+ // re-populate the view.
+ this._applyViewChanges();
+ },
+
+ /**
+ * FolderNotificationHelper tells us when folders we care about are deleted
+ * (because we asked it to in |open|). If it was the folder we were
+ * displaying (real or virtual), this closes it. If we are virtual and
+ * backed by a single folder, this closes us. If we are backed by multiple
+ * folders, we just update ourselves. (Currently, cross-folder views are
+ * not clever enough to purge the mooted messages, so we need to do this to
+ * help them out.)
+ * We do not update virtual folder definitions as a result of deletion; we are
+ * a display abstraction. That (hopefully) happens elsewhere.
+ */
+ _folderDeleted(aFolder) {
+ // XXX When we empty the trash, we're actually sending a folder deleted
+ // notification around. This check ensures we don't think we've really
+ // deleted the trash folder in the DBViewWrapper, and that stops nasty
+ // things happening, like forgetting we've got the trash folder selected.
+ if (aFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, false)) {
+ return;
+ }
+
+ if (aFolder == this.displayedFolder) {
+ this.close();
+ return;
+ }
+
+ // indexOf doesn't work for this (reliably)
+ for (let [i, underlyingFolder] of this._underlyingFolders.entries()) {
+ if (aFolder == underlyingFolder) {
+ this._underlyingFolders.splice(i, 1);
+ break;
+ }
+ }
+
+ if (this._underlyingFolders.length == 0) {
+ this.close();
+ return;
+ }
+ // if we are virtual, this will update the search session which draws its
+ // search scopes from this._underlyingFolders anyways.
+ this._applyViewChanges();
+ },
+
+ /**
+ * Compacting a local folder nukes its message keys, requiring the view to be
+ * rebuilt. If the folder is IMAP, it doesn't matter because the UIDs are
+ * the message keys and we can ignore it. In the local case we want to
+ * notify our listener so they have a chance to save the selected messages.
+ */
+ _aboutToCompactFolder(aFolder) {
+ // IMAP compaction does not affect us unless we are holding headers
+ if (aFolder.server.type == "imap") {
+ return;
+ }
+
+ // we will have to re-create the view, so nuke the view now.
+ if (this.dbView) {
+ this.listener.onDestroyingView(true);
+ this.search.dissociateView(this.dbView);
+ this.dbView.close();
+ this.dbView = null;
+ }
+ },
+
+ /**
+ * Compaction is all done, let's re-create the view! (Unless the folder is
+ * IMAP, in which case we are ignoring this event sequence.)
+ */
+ _compactedFolder(aFolder) {
+ // IMAP compaction does not affect us unless we are holding headers
+ if (aFolder.server.type == "imap") {
+ return;
+ }
+
+ this.refresh();
+ },
+
+ /**
+ * DB Views need help to know when their move / deletion operations complete.
+ * This happens in both single-folder and multiple-folder backed searches.
+ * In the latter case, there is potential danger that we tell a view that did
+ * not initiate the move / deletion but has kicked off its own about the
+ * completion and confuse it. However, that's on the view code.
+ */
+ _deleteCompleted(aFolder) {
+ if (this.dbView) {
+ this.dbView.onDeleteCompleted(true);
+ }
+ this.listener.onMessagesRemoved();
+ },
+
+ /**
+ * See _deleteCompleted for an explanation of what is going on.
+ */
+ _deleteFailed(aFolder) {
+ if (this.dbView) {
+ this.dbView.onDeleteCompleted(false);
+ }
+ this.listener.onMessageRemovalFailed();
+ },
+
+ _forceOpen(aFolder) {
+ this.displayedFolder = null;
+ this.open(aFolder);
+ },
+
+ _renameCompleted(aFolder) {
+ if (aFolder == this.displayedFolder) {
+ this._forceOpen(aFolder);
+ }
+ },
+
+ /**
+ * If the displayed folder had its total message count or total unread message
+ * count change, notify the listener. (Note: only for the display folder;
+ * not the underlying folders!)
+ */
+ _messageCountsChanged(aFolder) {
+ if (aFolder == this.displayedFolder) {
+ this.listener.onMessageCountsChanged();
+ }
+ },
+
+ /**
+ * @returns the current set of viewFlags. This may be:
+ * - A modified set of flags that are pending application because a view
+ * update is in effect and we don't want to modify the view when it's just
+ * going to get destroyed.
+ * - The live set of flags from the current dbView.
+ * - The 'limbo' set of flags because we currently lack a view but will have
+ * one soon (and then we will apply the flags).
+ */
+ get _viewFlags() {
+ if (this.__viewFlags != null) {
+ return this.__viewFlags;
+ }
+ if (this.dbView) {
+ return this.dbView.viewFlags;
+ }
+ return 0;
+ },
+ /**
+ * Update the view flags to use on the view. If we are in a view update or
+ * currently don't have a view, we save the view flags for later usage when
+ * the view gets (re)built. If we have a view, depending on what's happening
+ * we may re-create the view or just set the bits. The rules/reasons are:
+ * - XFVF views can handle the flag changes, just set the flags.
+ * - XFVF threaded/unthreaded change must re-sort, the backend forgot.
+ * - Single-folder virtual folders (quicksearch) can handle viewFlag changes,
+ * to/from grouped included, so set it.
+ * - Single-folder threaded/unthreaded can handle a change to/from unthreaded/
+ * threaded, so set it.
+ * - Single-folder can _not_ handle a change between grouped and not-grouped,
+ * so re-generate the view. Also it can't handle a change involving
+ * kUnreadOnly or kShowIgnored.
+ */
+ set _viewFlags(aViewFlags) {
+ if (this._viewUpdateDepth || !this.dbView) {
+ this.__viewFlags = aViewFlags;
+ return;
+ }
+
+ // For viewFlag changes, do not make a random selection if there is not
+ // actually anything selected; some views do this (looking at xfvf).
+ if (this.dbView.selection && this.dbView.selection.count == 0) {
+ this.dbView.selection.currentIndex = -1;
+ }
+
+ let setViewFlags = true;
+ let reSort = false;
+ let oldFlags = this.dbView.viewFlags;
+ let changedFlags = oldFlags ^ aViewFlags;
+
+ if (this.isVirtual) {
+ if (
+ this.isMultiFolder &&
+ changedFlags & Ci.nsMsgViewFlagsType.kThreadedDisplay &&
+ !(changedFlags & Ci.nsMsgViewFlagsType.kGroupBySort)
+ ) {
+ reSort = true;
+ }
+ if (this.isSingleFolder) {
+ // ugh, and the single folder case needs us to re-apply his sort...
+ reSort = true;
+ }
+ } else {
+ // The regular single folder case.
+ if (
+ changedFlags &
+ (Ci.nsMsgViewFlagsType.kGroupBySort |
+ Ci.nsMsgViewFlagsType.kUnreadOnly |
+ Ci.nsMsgViewFlagsType.kShowIgnored)
+ ) {
+ setViewFlags = false;
+ }
+ // ugh, and the single folder case needs us to re-apply his sort...
+ reSort = true;
+ }
+
+ if (setViewFlags) {
+ this.dbView.viewFlags = aViewFlags;
+ if (reSort) {
+ this.dbView.sort(this.dbView.sortType, this.dbView.sortOrder);
+ }
+ this.listener.onSortChanged();
+ } else {
+ this.__viewFlags = aViewFlags;
+ this._applyViewChanges();
+ }
+ },
+
+ /**
+ * Apply accumulated changes to the view. If we are in a batch, we do
+ * nothing, relying on endDisplayUpdate to call us.
+ */
+ _applyViewChanges() {
+ // if we are in a batch, wait for endDisplayUpdate to be called to get us
+ // out to zero.
+ if (this._viewUpdateDepth) {
+ return;
+ }
+ // make the dbView stop being a search listener if it is one
+ if (this.dbView) {
+ // save the view's flags if it has any and we haven't already overridden
+ // them.
+ if (this.__viewFlags == null) {
+ this.__viewFlags = this.dbView.viewFlags;
+ }
+ this.listener.onDestroyingView(true); // we will re-create it!
+ this.search.dissociateView(this.dbView);
+ this.dbView.close();
+ this.dbView = null;
+ }
+
+ this.dbView = this._createView();
+ // if the synthetic view defines columns, add those for it
+ if (this.isSynthetic) {
+ for (let customCol of this._syntheticView.customColumns) {
+ customCol.bindToView(this.dbView);
+ this.dbView.addColumnHandler(customCol.id, customCol);
+ }
+ }
+ this.listener.onCreatedView();
+
+ // this ends up being a no-op if there are no search terms
+ this.search.associateView(this.dbView);
+
+ // If we are searching, then the search will generate the all messages
+ // loaded notification. Although in some cases the search may have
+ // completed by now, that is not a guarantee. The search logic is
+ // time-slicing, which is why this can vary. (If it uses up its time
+ // slices, it will re-schedule itself, returning to us before completing.)
+ // Which is why we always defer to the search if one is active.
+ // If we are loading the folder, the load completion will also notify us,
+ // so we should not generate all messages loaded right now.
+ if (!this.searching && !this.folderLoading) {
+ this.listener.onMessagesLoaded(true);
+ } else if (this.dbView.numMsgsInView > 0) {
+ this.listener.onMessagesLoaded(false);
+ }
+ },
+
+ get isMailFolder() {
+ return Boolean(
+ this.displayedFolder &&
+ this.displayedFolder.flags & Ci.nsMsgFolderFlags.Mail
+ );
+ },
+
+ get isNewsFolder() {
+ return Boolean(
+ this.displayedFolder &&
+ this.displayedFolder.flags & Ci.nsMsgFolderFlags.Newsgroup
+ );
+ },
+
+ get isFeedFolder() {
+ return Boolean(
+ this.displayedFolder && this.displayedFolder.server.type == "rss"
+ );
+ },
+
+ OUTGOING_FOLDER_FLAGS:
+ Ci.nsMsgFolderFlags.SentMail |
+ Ci.nsMsgFolderFlags.Drafts |
+ Ci.nsMsgFolderFlags.Queue |
+ Ci.nsMsgFolderFlags.Templates,
+ /**
+ * @returns true if the folder is an outgoing folder by virtue of being a
+ * sent mail folder, drafts folder, queue folder, or template folder,
+ * or being a sub-folder of one of those types of folders.
+ */
+ get isOutgoingFolder() {
+ return (
+ this.displayedFolder &&
+ this.displayedFolder.isSpecialFolder(this.OUTGOING_FOLDER_FLAGS, true)
+ );
+ },
+ /**
+ * @returns true if the folder is not known to be a special outgoing folder
+ * or the descendent of a special outgoing folder.
+ */
+ get isIncomingFolder() {
+ return !this.isOutgoingFolder;
+ },
+
+ get isVirtual() {
+ return Boolean(
+ this.displayedFolder &&
+ this.displayedFolder.flags & Ci.nsMsgFolderFlags.Virtual
+ );
+ },
+
+ /**
+ * Prevent view updates from running until a paired |endViewUpdate| call is
+ * made. This is an advisory method intended to aid us in performing
+ * redundant view re-computations and does not forbid us from building the
+ * view earlier if we have a good reason.
+ * Since calling endViewUpdate will compel a view update when the update
+ * depth reaches 0, you should only call this method if you are sure that
+ * you will need the view to be re-built. If you are doing things like
+ * changing to/from threaded mode that do not cause the view to be rebuilt,
+ * you should just set those attributes directly.
+ */
+ beginViewUpdate() {
+ this._viewUpdateDepth++;
+ },
+
+ /**
+ * Conclude a paired call to |beginViewUpdate|. Assuming the view depth has
+ * reached 0 with this call, the view will be re-created with the current
+ * settings.
+ */
+ endViewUpdate(aForceLevel) {
+ if (--this._viewUpdateDepth == 0) {
+ this._applyViewChanges();
+ }
+ // Avoid pathological situations.
+ if (this._viewUpdateDepth < 0) {
+ this._viewUpdateDepth = 0;
+ }
+ },
+
+ /**
+ * @returns the primary sort type (as one of the numeric constants from
+ * nsMsgViewSortType).
+ */
+ get primarySortType() {
+ return this._sort[0][0];
+ },
+
+ /**
+ * @returns the primary sort order (as one of the numeric constants from
+ * nsMsgViewSortOrder.)
+ */
+ get primarySortOrder() {
+ return this._sort[0][1];
+ },
+
+ /**
+ * @returns true if the dominant sort is ascending.
+ */
+ get isSortedAscending() {
+ return (
+ this._sort.length && this._sort[0][1] == Ci.nsMsgViewSortOrder.ascending
+ );
+ },
+ /**
+ * @returns true if the dominant sort is descending.
+ */
+ get isSortedDescending() {
+ return (
+ this._sort.length && this._sort[0][1] == Ci.nsMsgViewSortOrder.descending
+ );
+ },
+ /**
+ * Indicate if we are sorting by time or something correlated with time.
+ *
+ * @returns true if the dominant sort is by time.
+ */
+ get sortImpliesTemporalOrdering() {
+ if (!this._sort.length) {
+ return false;
+ }
+ let sortType = this._sort[0][0];
+ return (
+ sortType == Ci.nsMsgViewSortType.byDate ||
+ sortType == Ci.nsMsgViewSortType.byReceived ||
+ sortType == Ci.nsMsgViewSortType.byId ||
+ sortType == Ci.nsMsgViewSortType.byThread
+ );
+ },
+
+ sortAscending() {
+ if (!this.isSortedAscending) {
+ this.magicSort(this._sort[0][0], Ci.nsMsgViewSortOrder.ascending);
+ }
+ },
+ sortDescending() {
+ if (!this.isSortedDescending) {
+ this.magicSort(this._sort[0][0], Ci.nsMsgViewSortOrder.descending);
+ }
+ },
+
+ /**
+ * Explicit sort command. We ignore all previous sort state and only apply
+ * what you tell us. If you want implied secondary sort, use |magicSort|.
+ * You must use this sort command, and never directly call the sort commands
+ * on the underlying db view! If you do not, make sure to fight us every
+ * step of the way, because we will keep clobbering your manually applied
+ * sort.
+ * For secondary and multiple custom column support, a byCustom aSortType and
+ * aSecondaryType must be the column name string.
+ */
+ sort(aSortType, aSortOrder, aSecondaryType, aSecondaryOrder) {
+ // For sort changes, do not make a random selection if there is not
+ // actually anything selected; some views do this (looking at xfvf).
+ if (this.dbView.selection && this.dbView.selection.count == 0) {
+ this.dbView.selection.currentIndex = -1;
+ }
+
+ this._sort = [[aSortType, aSortOrder]];
+ if (aSecondaryType != null && aSecondaryOrder != null) {
+ this._sort.push([aSecondaryType, aSecondaryOrder]);
+ }
+ // make sure the sort won't make the view angry...
+ this._ensureValidSort();
+ // if we are not in a view update, invoke the sort.
+ if (this._viewUpdateDepth == 0 && this.dbView) {
+ for (let iSort = this._sort.length - 1; iSort >= 0; iSort--) {
+ // apply them in the reverse order
+ let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(iSort);
+ if (sortCustomCol) {
+ this.dbView.curCustomColumn = sortCustomCol;
+ }
+ this.dbView.sort(sortType, sortOrder);
+ }
+ // (only generate the event since we're not in a update batch)
+ this.listener.onSortChanged();
+ }
+ // (if we are in a view update, then a new view will be created when the
+ // update ends, and it will just use the new sort order anyways.)
+ },
+
+ /**
+ * Logic that compensates for custom column identifiers being provided as
+ * sort types.
+ *
+ * @returns [sort type, sort order, sort custom column name]
+ */
+ _getSortDetails(aIndex) {
+ let [sortType, sortOrder] = this._sort[aIndex];
+ let sortCustomColumn = null;
+ let sortTypeType = typeof sortType;
+ if (sortTypeType != "number") {
+ sortCustomColumn = sortTypeType == "string" ? sortType : sortType.id;
+ sortType = Ci.nsMsgViewSortType.byCustom;
+ }
+
+ return [sortType, sortOrder, sortCustomColumn];
+ },
+
+ /**
+ * Accumulates implied secondary sorts based on multiple calls to this method.
+ * This is intended to be hooked up to be controlled by the UI.
+ * Because we are lazy, we actually just poke the view's sort method and save
+ * the apparent secondary sort. This also allows perfect compliance with the
+ * way this used to be implemented!
+ * For secondary and multiple custom column support, a byCustom aSortType must
+ * be the column name string.
+ */
+ magicSort(aSortType, aSortOrder) {
+ if (this.dbView) {
+ // For sort changes, do not make a random selection if there is not
+ // actually anything selected; some views do this (looking at xfvf).
+ if (this.dbView.selection && this.dbView.selection.count == 0) {
+ this.dbView.selection.currentIndex = -1;
+ }
+
+ // so, the thing we just set obviously will be there
+ this._sort = [[aSortType, aSortOrder]];
+ // (make sure it is valid...)
+ this._ensureValidSort();
+ // get sort details, handle custom column as string sortType
+ let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(0);
+ if (sortCustomCol) {
+ this.dbView.curCustomColumn = sortCustomCol;
+ }
+ // apply the sort to see what happens secondary-wise
+ this.dbView.sort(sortType, sortOrder);
+ // there is only a secondary sort if it's not none and not the same.
+ if (
+ this.dbView.secondarySortType != Ci.nsMsgViewSortType.byNone &&
+ (this.dbView.secondarySortType != sortType ||
+ (this.dbView.secondarySortType == Ci.nsMsgViewSortType.byCustom &&
+ this.dbView.secondaryCustomColumn != sortCustomCol))
+ ) {
+ this._sort.push([
+ this.dbView.secondaryCustomColumn || this.dbView.secondarySortType,
+ this.dbView.secondarySortOrder,
+ ]);
+ }
+ // only tell our listener if we're not in a view update batch
+ if (this._viewUpdateDepth == 0) {
+ this.listener.onSortChanged();
+ }
+ }
+ },
+
+ /**
+ * Make sure the current sort is valid under our other constraints, make it
+ * safe if it is not. Most specifically, some sorts are illegal when
+ * grouping by sort, and we reset the sort to date in those cases.
+ *
+ * @param aViewFlags Optional set of view flags to consider instead of the
+ * potentially live view flags.
+ */
+ _ensureValidSort(aViewFlags) {
+ if (
+ (aViewFlags != null ? aViewFlags : this._viewFlags) &
+ Ci.nsMsgViewFlagsType.kGroupBySort
+ ) {
+ // We cannot be sorting by thread, id, none, or size. If we are, switch
+ // to sorting by date.
+ for (let sortPair of this._sort) {
+ let sortType = sortPair[0];
+ if (
+ sortType == Ci.nsMsgViewSortType.byThread ||
+ sortType == Ci.nsMsgViewSortType.byId ||
+ sortType == Ci.nsMsgViewSortType.byNone ||
+ sortType == Ci.nsMsgViewSortType.bySize
+ ) {
+ this._sort = [[Ci.nsMsgViewSortType.byDate, this._sort[0][1]]];
+ break;
+ }
+ }
+ }
+ },
+
+ /**
+ * @returns {boolean} true if we are grouped-by-sort, false if not. If we are
+ * not grouped-by-sort, then we are either threaded or unthreaded; check
+ * the showThreaded property to find out which of those it is.
+ */
+ get showGroupedBySort() {
+ return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort);
+ },
+ /**
+ * Enable grouped-by-sort which is mutually exclusive with threaded display
+ * (as controlled/exposed by showThreaded). Grouped-by-sort is not legal
+ * for sorts by thread/id/size/none and enabling this will cause us to change
+ * our sort to by date in those cases.
+ */
+ set showGroupedBySort(aShowGroupBySort) {
+ if (this.showGroupedBySort != aShowGroupBySort) {
+ if (aShowGroupBySort) {
+ // For virtual single folders, the kExpandAll flag must be set.
+ // Do not apply the flag change until we have made the sort safe.
+ let viewFlags =
+ this._viewFlags |
+ Ci.nsMsgViewFlagsType.kGroupBySort |
+ Ci.nsMsgViewFlagsType.kExpandAll |
+ Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ this._ensureValidSort(viewFlags);
+ this._viewFlags = viewFlags;
+ } else {
+ // maybe we shouldn't do anything in this case?
+ this._viewFlags &= ~(
+ Ci.nsMsgViewFlagsType.kGroupBySort |
+ Ci.nsMsgViewFlagsType.kThreadedDisplay
+ );
+ }
+ }
+ },
+
+ /**
+ * Are we showing ignored/killed threads?
+ */
+ get showIgnored() {
+ return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kShowIgnored);
+ },
+ /**
+ * Set whether we are showing ignored/killed threads.
+ */
+ set showIgnored(aShowIgnored) {
+ if (this.showIgnored == aShowIgnored) {
+ return;
+ }
+
+ if (aShowIgnored) {
+ this._viewFlags |= Ci.nsMsgViewFlagsType.kShowIgnored;
+ } else {
+ this._viewFlags &= ~Ci.nsMsgViewFlagsType.kShowIgnored;
+ }
+ },
+
+ /**
+ * @returns {boolean} true if we are in threaded mode (as opposed to unthreaded
+ * or grouped-by-sort).
+ */
+ get showThreaded() {
+ return Boolean(
+ this._viewFlags & Ci.nsMsgViewFlagsType.kThreadedDisplay &&
+ !(this._viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort)
+ );
+ },
+ /**
+ * Set us to threaded display mode when set to true. If we are already in
+ * threaded display mode, we do nothing. If you want to set us to unthreaded
+ * mode, set |showUnthreaded| to true. (Because we have three modes of
+ * operation: unthreaded, threaded, and grouped-by-sort, we are a tri-state
+ * and setting us to false is ambiguous. We should probably be using a
+ * single attribute with three constants...)
+ */
+ set showThreaded(aShowThreaded) {
+ if (this.showThreaded != aShowThreaded) {
+ let viewFlags = this._viewFlags;
+ if (aShowThreaded) {
+ viewFlags |= Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ } else {
+ // Maybe we shouldn't do anything in this case?
+ viewFlags &= ~Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ }
+ // lose the group bit...
+ viewFlags &= ~Ci.nsMsgViewFlagsType.kGroupBySort;
+ this._viewFlags = viewFlags;
+ }
+ },
+
+ /**
+ * @returns {boolean} true if we are in unthreaded mode (which means not
+ * threaded and not grouped-by-sort).
+ */
+ get showUnthreaded() {
+ return Boolean(
+ !(
+ this._viewFlags &
+ (Ci.nsMsgViewFlagsType.kGroupBySort |
+ Ci.nsMsgViewFlagsType.kThreadedDisplay)
+ )
+ );
+ },
+ /**
+ * Set to true to put us in unthreaded mode (which means not threaded and
+ * not grouped-by-sort).
+ */
+ set showUnthreaded(aShowUnthreaded) {
+ if (this.showUnthreaded != aShowUnthreaded) {
+ if (aShowUnthreaded) {
+ this._viewFlags &= ~(
+ Ci.nsMsgViewFlagsType.kGroupBySort |
+ Ci.nsMsgViewFlagsType.kThreadedDisplay
+ );
+ } else {
+ // Maybe we shouldn't do anything in this case?
+ this._viewFlags =
+ (this._viewFlags & ~Ci.nsMsgViewFlagsType.kGroupBySort) |
+ Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ }
+ }
+ },
+
+ /**
+ * @returns true if we are showing only unread messages.
+ */
+ get showUnreadOnly() {
+ return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kUnreadOnly);
+ },
+ /**
+ * Enable/disable showing only unread messages using the view's flag-based
+ * mechanism. This functionality can also be approximated using a mail
+ * view (or other search) for unread messages. There also exist special
+ * views for showing messages with unread threads which is different and
+ * has serious limitations because of its nature.
+ * Setting anything to this value clears any active special view because the
+ * actual UI use case (the "View... Threads..." menu) uses this setter
+ * intentionally as a mutually exclusive UI choice from the special views.
+ */
+ set showUnreadOnly(aShowUnreadOnly) {
+ if (this._specialView || this.showUnreadOnly != aShowUnreadOnly) {
+ let viewRebuildRequired = this._specialView != null;
+ this._specialView = null;
+ if (viewRebuildRequired) {
+ this.beginViewUpdate();
+ }
+
+ if (aShowUnreadOnly) {
+ this._viewFlags |= Ci.nsMsgViewFlagsType.kUnreadOnly;
+ } else {
+ this._viewFlags &= ~Ci.nsMsgViewFlagsType.kUnreadOnly;
+ }
+
+ if (viewRebuildRequired) {
+ this.endViewUpdate();
+ }
+ }
+ },
+
+ /**
+ * Read-only attribute indicating if a 'special view' is in use. There are
+ * two special views in existence, both of which are concerned about
+ * showing you threads that have any unread messages in them. They are views
+ * rather than search predicates because the search mechanism is not capable
+ * of expressing such a thing. (Or at least it didn't use to be? We might
+ * be able to whip something up these days...)
+ */
+ get specialView() {
+ return this._specialView != null;
+ },
+ /**
+ * Private helper for use by the specialView* setters that handles the common
+ * logic. We don't want this method to be public because we want it to be
+ * feasible for the view hierarchy and its enumerations to go away without
+ * code outside this class having to care so much.
+ */
+ _setSpecialView(aViewEnum) {
+ // special views simply cannot work for virtual folders. explode.
+ if (this.isVirtual) {
+ throw new Error("Virtual folders cannot use special views!");
+ }
+ this.beginViewUpdate();
+ // all special views imply a threaded view
+ this.showThreaded = true;
+ this._specialView = aViewEnum;
+ // We clear the search for paranoia/correctness reasons. However, the UI
+ // layer is currently responsible for making sure these are already zeroed
+ // out.
+ this.search.clear();
+ this.endViewUpdate();
+ },
+ /**
+ * @returns true if the special view that shows threads with unread messages
+ * in them is active.
+ */
+ get specialViewThreadsWithUnread() {
+ return this._specialView == Ci.nsMsgViewType.eShowThreadsWithUnread;
+ },
+ /**
+ * If true is assigned, attempts to enable the special view that shows threads
+ * with unread messages in them. This will not work on virtual folders
+ * because of the inheritance hierarchy.
+ * Any mechanism that requires search terms (quick search, mailviews) will be
+ * reset/disabled when enabling this view.
+ */
+ set specialViewThreadsWithUnread(aSpecial) {
+ this._setSpecialView(Ci.nsMsgViewType.eShowThreadsWithUnread);
+ },
+ /**
+ * @returns true if the special view that shows watched threads with unread
+ * messages in them is active.
+ */
+ get specialViewWatchedThreadsWithUnread() {
+ return this._specialView == Ci.nsMsgViewType.eShowWatchedThreadsWithUnread;
+ },
+ /**
+ * If true is assigned, attempts to enable the special view that shows watched
+ * threads with unread messages in them. This will not work on virtual
+ * folders because of the inheritance hierarchy.
+ * Any mechanism that requires search terms (quick search, mailviews) will be
+ * reset/disabled when enabling this view.
+ */
+ set specialViewWatchedThreadsWithUnread(aSpecial) {
+ this._setSpecialView(Ci.nsMsgViewType.eShowWatchedThreadsWithUnread);
+ },
+
+ get mailViewIndex() {
+ return this._mailViewIndex;
+ },
+
+ get mailViewData() {
+ return this._mailViewData;
+ },
+
+ /**
+ * Set the current mail view to the given mail view index with the provided
+ * data (normally only used for the 'tag' mail views.) We persist the state
+ * change
+ *
+ * @param aMailViewIndex The view to use, one of the kViewItem* constants from
+ * msgViewPickerOverlay.js OR the name of a custom view. (It's really up
+ * to MailViewManager.getMailViewByIndex...)
+ * @param aData Some piece of data appropriate to the mail view, currently
+ * this is only used for the tag name for kViewItemTags (sans the ":").
+ * @param aDoNotPersist If true, we don't save this change to the db folder
+ * info. This is intended for internal use only.
+ */
+ setMailView(aMailViewIndex, aData, aDoNotPersist) {
+ let mailViewDef = MailViewManager.getMailViewByIndex(aMailViewIndex);
+
+ this._mailViewIndex = aMailViewIndex;
+ this._mailViewData = aData;
+
+ // - update the search terms
+ // (this triggers a view update if we are not in a batch)
+ this.search.viewTerms = mailViewDef.makeTerms(this.search.session, aData);
+
+ // - persist the view to the folder.
+ if (!aDoNotPersist && this.displayedFolder) {
+ let msgDatabase = this.displayedFolder.msgDatabase;
+ if (msgDatabase) {
+ let dbFolderInfo = msgDatabase.dBFolderInfo;
+ dbFolderInfo.setUint32Property(
+ MailViewConstants.kViewCurrent,
+ this._mailViewIndex
+ );
+ // _mailViewData attempts to be sane and be the tag name, as opposed to
+ // magic-value ":"-prefixed value historically stored on disk. Because
+ // we want to be forwards and backwards compatible, we put this back on
+ // when we persist it. It's not like the property is really generic
+ // anyways.
+ dbFolderInfo.setCharProperty(
+ MailViewConstants.kViewCurrentTag,
+ this._mailViewData ? ":" + this._mailViewData : ""
+ );
+ }
+ }
+
+ this.listener.onMailViewChanged();
+ },
+
+ /**
+ * @returns true if the row at the given index contains a collapsed thread,
+ * false if the row is a collapsed group or anything else.
+ */
+ isCollapsedThreadAtIndex(aViewIndex) {
+ let flags = this.dbView.getFlagsAt(aViewIndex);
+ return (
+ flags & Ci.nsMsgMessageFlags.Elided &&
+ !(flags & MSG_VIEW_FLAG_DUMMY) &&
+ this.dbView.isContainer(aViewIndex)
+ );
+ },
+
+ /**
+ * @returns true if the row at the given index is a grouped view dummy header
+ * row, false if anything else.
+ */
+ isGroupedByHeaderAtIndex(aViewIndex) {
+ if (
+ !this.dbView ||
+ aViewIndex < 0 ||
+ aViewIndex >= this.dbView.rowCount ||
+ !this.showGroupedBySort
+ ) {
+ return false;
+ }
+ return Boolean(this.dbView.getFlagsAt(aViewIndex) & MSG_VIEW_FLAG_DUMMY);
+ },
+
+ /**
+ * Perform application-level behaviors related to leaving a folder that have
+ * nothing to do with our abstraction.
+ *
+ * Things we do on leaving a folder:
+ * - Mark the folder's messages as no longer new
+ * - Mark all messages read in the folder _if so configured_.
+ */
+ onLeavingFolder() {
+ // Suppress useless InvalidateRange calls to the tree by the dbView.
+ if (this.dbView) {
+ this.dbView.suppressChangeNotifications = true;
+ }
+ this.displayedFolder.clearNewMessages();
+ this.displayedFolder.hasNewMessages = false;
+ try {
+ // For legacy reasons, we support marking all messages as read when we
+ // leave a folder based on the server type. It's this listener's job
+ // to do the legwork to figure out if this is desired.
+ //
+ // Mark all messages of aFolder as read:
+ // We can't use the command controller, because it is already tuned in to
+ // the new folder, so we just mimic its behaviour wrt
+ // goDoCommand('cmd_markAllRead').
+ if (
+ this.dbView &&
+ this.listener.shouldMarkMessagesReadOnLeavingFolder(
+ this.displayedFolder
+ )
+ ) {
+ this.dbView.doCommand(Ci.nsMsgViewCommandType.markAllRead);
+ }
+ } catch (e) {}
+ },
+
+ /**
+ * Returns the view index for this message header in this view.
+ *
+ * - If this is a single folder view, we first check whether the folder is the
+ * right one. If it is, we call the db view's findIndexOfMsgHdr. We do the
+ * first check because findIndexOfMsgHdr only checks for whether the message
+ * key matches, which might lead to false positives.
+ *
+ * - If this isn't, we trust findIndexOfMsgHdr to do the right thing.
+ *
+ * @param aMsgHdr The message header for which the view index should be
+ * returned.
+ * @param [aForceFind] If the message is not in the view and this is true, we
+ * will drop any applied view filters to look for the
+ * message. The dropping of view filters is persistent, so
+ * use with care. Defaults to false.
+ *
+ * @returns the view index for this header, or nsMsgViewIndex_None if it isn't
+ * found.
+ *
+ * @public
+ */
+ getViewIndexForMsgHdr(aMsgHdr, aForceFind) {
+ if (this.dbView) {
+ if (this.isSingleFolder && aMsgHdr.folder != this.dbView.msgFolder) {
+ return nsMsgViewIndex_None;
+ }
+
+ let viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true);
+
+ if (aForceFind && viewIndex == nsMsgViewIndex_None) {
+ // Consider dropping view filters.
+ // - If we're not displaying all messages, switch to All
+ if (
+ viewIndex == nsMsgViewIndex_None &&
+ this.mailViewIndex != MailViewConstants.kViewItemAll
+ ) {
+ this.setMailView(MailViewConstants.kViewItemAll, null);
+ viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true);
+ }
+
+ // - Don't just show unread only
+ if (viewIndex == nsMsgViewIndex_None) {
+ this.showUnreadOnly = false;
+ viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true);
+ }
+ }
+
+ // We've done all we can.
+ return viewIndex;
+ }
+
+ // No db view, so we can't do anything
+ return nsMsgViewIndex_None;
+ },
+
+ /**
+ * Convenience function to retrieve the first nsIMsgDBHdr in any of the
+ * folders backing this view with the given message-id header. This
+ * is for the benefit of FolderDisplayWidget's selection logic.
+ * When thinking about using this, please keep in mind that, currently, this
+ * is O(n) for the total number of messages across all the backing folders.
+ * Since the folder database should already be in memory, this should
+ * ideally not involve any disk I/O.
+ * Additionally, duplicate message-ids can and will happen, but since we
+ * are using the message database's getMsgHdrForMessageID method to be fast,
+ * our semantics are limited to telling you about only the first one we find.
+ *
+ * @param aMessageId The message-id of the message you want.
+ * @returns The first nsIMsgDBHdr found in any of the underlying folders with
+ * the given message header, null if none are found. The fact that we
+ * return something does not guarantee that it is actually visible in the
+ * view. (The search may be filtering it out.)
+ */
+ getMsgHdrForMessageID(aMessageId) {
+ if (this._syntheticView) {
+ return this._syntheticView.getMsgHdrForMessageID(aMessageId);
+ }
+ if (!this._underlyingFolders) {
+ return null;
+ }
+ for (let folder of this._underlyingFolders) {
+ let msgHdr = folder.msgDatabase.getMsgHdrForMessageID(aMessageId);
+ if (msgHdr) {
+ return msgHdr;
+ }
+ }
+ return null;
+ },
+};
diff --git a/comm/mail/modules/DNS.jsm b/comm/mail/modules/DNS.jsm
new file mode 100644
index 0000000000..de913aa5cd
--- /dev/null
+++ b/comm/mail/modules/DNS.jsm
@@ -0,0 +1,493 @@
+/* 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 module is responsible for performing DNS queries using ctypes for
+ * loading system DNS libraries on Linux, Mac and Windows.
+ */
+
+const EXPORTED_SYMBOLS = ["DNS", "SRVRecord"];
+
+var DNS = null;
+
+if (typeof Components !== "undefined") {
+ var { ctypes } = ChromeUtils.importESModule(
+ "resource://gre/modules/ctypes.sys.mjs"
+ );
+ var { BasePromiseWorker } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseWorker.sys.mjs"
+ );
+}
+
+var LOCATION = "resource:///modules/DNS.jsm";
+
+// These constants are luckily shared, but with different names
+var NS_T_TXT = 16; // DNS_TYPE_TXT
+var NS_T_SRV = 33; // DNS_TYPE_SRV
+var NS_T_MX = 15; // DNS_TYPE_MX
+
+// For Linux and Mac.
+function load_libresolv(os) {
+ this._open(os);
+}
+
+load_libresolv.prototype = {
+ library: null,
+
+ // Tries to find and load library.
+ _open(os) {
+ function findLibrary() {
+ let lastException = null;
+ let candidates = [];
+ if (os == "FreeBSD") {
+ candidates = [{ name: "c", suffix: ".7" }];
+ } else if (os == "OpenBSD") {
+ candidates = [{ name: "c", suffix: "" }];
+ } else {
+ candidates = [
+ { name: "resolv.9", suffix: "" },
+ { name: "resolv", suffix: ".2" },
+ { name: "resolv", suffix: "" },
+ ];
+ }
+ let tried = [];
+ for (let candidate of candidates) {
+ try {
+ let name = ctypes.libraryName(candidate.name) + candidate.suffix;
+ tried.push(name);
+ return ctypes.open(name);
+ } catch (ex) {
+ lastException = ex;
+ }
+ }
+ throw new Error(
+ "Could not find libresolv in any of " +
+ tried +
+ " Exception: " +
+ lastException +
+ "\n"
+ );
+ }
+
+ // Declaring functions to be able to call them.
+ function declare(aSymbolNames, ...aArgs) {
+ let lastException = null;
+ if (!Array.isArray(aSymbolNames)) {
+ aSymbolNames = [aSymbolNames];
+ }
+
+ for (let name of aSymbolNames) {
+ try {
+ return library.declare(name, ...aArgs);
+ } catch (ex) {
+ lastException = ex;
+ }
+ }
+ library.close();
+ throw new Error(
+ "Failed to declare " +
+ aSymbolNames +
+ " Exception: " +
+ lastException +
+ "\n"
+ );
+ }
+
+ let library = (this.library = findLibrary());
+ this.res_search = declare(
+ ["res_9_search", "res_search", "__res_search"],
+ ctypes.default_abi,
+ ctypes.int,
+ ctypes.char.ptr,
+ ctypes.int,
+ ctypes.int,
+ ctypes.unsigned_char.ptr,
+ ctypes.int
+ );
+ this.res_query = declare(
+ ["res_9_query", "res_query", "__res_query"],
+ ctypes.default_abi,
+ ctypes.int,
+ ctypes.char.ptr,
+ ctypes.int,
+ ctypes.int,
+ ctypes.unsigned_char.ptr,
+ ctypes.int
+ );
+ this.dn_expand = declare(
+ ["res_9_dn_expand", "dn_expand", "__dn_expand"],
+ ctypes.default_abi,
+ ctypes.int,
+ ctypes.unsigned_char.ptr,
+ ctypes.unsigned_char.ptr,
+ ctypes.unsigned_char.ptr,
+ ctypes.char.ptr,
+ ctypes.int
+ );
+ this.dn_skipname = declare(
+ ["res_9_dn_skipname", "dn_skipname", "__dn_skipname"],
+ ctypes.default_abi,
+ ctypes.int,
+ ctypes.unsigned_char.ptr,
+ ctypes.unsigned_char.ptr
+ );
+ this.ns_get16 = declare(
+ ["res_9_ns_get16", "ns_get16", "_getshort"],
+ ctypes.default_abi,
+ ctypes.unsigned_int,
+ ctypes.unsigned_char.ptr
+ );
+ this.ns_get32 = declare(
+ ["res_9_ns_get32", "ns_get32", "_getlong"],
+ ctypes.default_abi,
+ ctypes.unsigned_long,
+ ctypes.unsigned_char.ptr
+ );
+
+ this.QUERYBUF_SIZE = 1024;
+ this.NS_MAXCDNAME = 255;
+ this.NS_HFIXEDSZ = 12;
+ this.NS_QFIXEDSZ = 4;
+ this.NS_RRFIXEDSZ = 10;
+ this.NS_C_IN = 1;
+ },
+
+ close() {
+ this.library.close();
+ this.library = null;
+ },
+
+ // Maps record to SRVRecord, TXTRecord, or MXRecord according to aTypeID and
+ // returns it.
+ _mapAnswer(aTypeID, aAnswer, aIdx, aLength) {
+ if (aTypeID == NS_T_SRV) {
+ let prio = this.ns_get16(aAnswer.addressOfElement(aIdx));
+ let weight = this.ns_get16(aAnswer.addressOfElement(aIdx + 2));
+ let port = this.ns_get16(aAnswer.addressOfElement(aIdx + 4));
+
+ let hostbuf = ctypes.char.array(this.NS_MAXCDNAME)();
+ let hostlen = this.dn_expand(
+ aAnswer.addressOfElement(0),
+ aAnswer.addressOfElement(aLength),
+ aAnswer.addressOfElement(aIdx + 6),
+ hostbuf,
+ this.NS_MAXCDNAME
+ );
+ let host = hostlen > -1 ? hostbuf.readString() : null;
+ return new SRVRecord(prio, weight, host, port);
+ } else if (aTypeID == NS_T_TXT) {
+ // TODO should only read dataLength characters.
+ let data = ctypes.unsigned_char.ptr(aAnswer.addressOfElement(aIdx + 1));
+
+ return new TXTRecord(data.readString());
+ } else if (aTypeID == NS_T_MX) {
+ let prio = this.ns_get16(aAnswer.addressOfElement(aIdx));
+
+ let hostbuf = ctypes.char.array(this.NS_MAXCDNAME)();
+ let hostlen = this.dn_expand(
+ aAnswer.addressOfElement(0),
+ aAnswer.addressOfElement(aLength),
+ aAnswer.addressOfElement(aIdx + 2),
+ hostbuf,
+ this.NS_MAXCDNAME
+ );
+ let host = hostlen > -1 ? hostbuf.readString() : null;
+ return new MXRecord(prio, host);
+ }
+ return {};
+ },
+
+ // Performs a DNS query for aTypeID on a certain address (aName) and returns
+ // array of records of aTypeID.
+ lookup(aName, aTypeID) {
+ let qname = ctypes.char.array()(aName);
+ let answer = ctypes.unsigned_char.array(this.QUERYBUF_SIZE)();
+ let length = this.res_search(
+ qname,
+ this.NS_C_IN,
+ aTypeID,
+ answer,
+ this.QUERYBUF_SIZE
+ );
+
+ // There is an error.
+ if (length < 0) {
+ return [];
+ }
+
+ let results = [];
+ let idx = this.NS_HFIXEDSZ;
+
+ let qdcount = this.ns_get16(answer.addressOfElement(4));
+ let ancount = this.ns_get16(answer.addressOfElement(6));
+
+ for (let qdidx = 0; qdidx < qdcount && idx < length; qdidx++) {
+ idx +=
+ this.NS_QFIXEDSZ +
+ this.dn_skipname(
+ answer.addressOfElement(idx),
+ answer.addressOfElement(length)
+ );
+ }
+
+ for (let anidx = 0; anidx < ancount && idx < length; anidx++) {
+ idx += this.dn_skipname(
+ answer.addressOfElement(idx),
+ answer.addressOfElement(length)
+ );
+ let rridx = idx;
+ let type = this.ns_get16(answer.addressOfElement(rridx));
+ let dataLength = this.ns_get16(answer.addressOfElement(rridx + 8));
+
+ idx += this.NS_RRFIXEDSZ;
+
+ if (type === aTypeID) {
+ let resource = this._mapAnswer(aTypeID, answer, idx, length);
+ resource.type = type;
+ resource.nsclass = this.ns_get16(answer.addressOfElement(rridx + 2));
+ resource.ttl = this.ns_get32(answer.addressOfElement(rridx + 4)) | 0;
+ results.push(resource);
+ }
+ idx += dataLength;
+ }
+ return results;
+ },
+};
+
+// For Windows.
+function load_dnsapi() {
+ this._open();
+}
+
+load_dnsapi.prototype = {
+ library: null,
+
+ // Tries to find and load library.
+ _open() {
+ function declare(aSymbolName, ...aArgs) {
+ try {
+ return library.declare(aSymbolName, ...aArgs);
+ } catch (ex) {
+ throw new Error(
+ "Failed to declare " + aSymbolName + " Exception: " + ex + "\n"
+ );
+ }
+ }
+
+ let library = (this.library = ctypes.open(ctypes.libraryName("DnsAPI")));
+
+ this.DNS_SRV_DATA = ctypes.StructType("DNS_SRV_DATA", [
+ { pNameTarget: ctypes.jschar.ptr },
+ { wPriority: ctypes.unsigned_short },
+ { wWeight: ctypes.unsigned_short },
+ { wPort: ctypes.unsigned_short },
+ { Pad: ctypes.unsigned_short },
+ ]);
+
+ this.DNS_TXT_DATA = ctypes.StructType("DNS_TXT_DATA", [
+ { dwStringCount: ctypes.unsigned_long },
+ { pStringArray: ctypes.jschar.ptr.array(1) },
+ ]);
+
+ this.DNS_MX_DATA = ctypes.StructType("DNS_MX_DATA", [
+ { pNameTarget: ctypes.jschar.ptr },
+ { wPriority: ctypes.unsigned_short },
+ { Pad: ctypes.unsigned_short },
+ ]);
+
+ this.DNS_RECORD = ctypes.StructType("_DnsRecord");
+ this.DNS_RECORD.define([
+ { pNext: this.DNS_RECORD.ptr },
+ { pName: ctypes.jschar.ptr },
+ { wType: ctypes.unsigned_short },
+ { wDataLength: ctypes.unsigned_short },
+ { Flags: ctypes.unsigned_long },
+ { dwTtl: ctypes.unsigned_long },
+ { dwReserved: ctypes.unsigned_long },
+ { Data: this.DNS_SRV_DATA }, // it's a union, can be cast to many things
+ ]);
+
+ this.PDNS_RECORD = ctypes.PointerType(this.DNS_RECORD);
+ this.DnsQuery_W = declare(
+ "DnsQuery_W",
+ ctypes.winapi_abi,
+ ctypes.long,
+ ctypes.jschar.ptr,
+ ctypes.unsigned_short,
+ ctypes.unsigned_long,
+ ctypes.voidptr_t,
+ this.PDNS_RECORD.ptr,
+ ctypes.voidptr_t.ptr
+ );
+ this.DnsRecordListFree = declare(
+ "DnsRecordListFree",
+ ctypes.winapi_abi,
+ ctypes.void_t,
+ this.PDNS_RECORD,
+ ctypes.int
+ );
+
+ this.ERROR_SUCCESS = ctypes.Int64(0);
+ this.DNS_QUERY_STANDARD = 0;
+ this.DnsFreeRecordList = 1;
+ },
+
+ close() {
+ this.library.close();
+ this.library = null;
+ },
+
+ // Maps record to SRVRecord, TXTRecord, or MXRecord according to aTypeID and
+ // returns it.
+ _mapAnswer(aTypeID, aData) {
+ if (aTypeID == NS_T_SRV) {
+ let srvdata = ctypes.cast(aData, this.DNS_SRV_DATA);
+
+ return new SRVRecord(
+ srvdata.wPriority,
+ srvdata.wWeight,
+ srvdata.pNameTarget.readString(),
+ srvdata.wPort
+ );
+ } else if (aTypeID == NS_T_TXT) {
+ let txtdata = ctypes.cast(aData, this.DNS_TXT_DATA);
+ if (txtdata.dwStringCount > 0) {
+ return new TXTRecord(txtdata.pStringArray[0].readString());
+ }
+ } else if (aTypeID == NS_T_MX) {
+ let mxdata = ctypes.cast(aData, this.DNS_MX_DATA);
+
+ return new MXRecord(mxdata.wPriority, mxdata.pNameTarget.readString());
+ }
+ return {};
+ },
+
+ // Performs a DNS query for aTypeID on a certain address (aName) and returns
+ // array of records of aTypeID (e.g. SRVRecord, TXTRecord, or MXRecord).
+ lookup(aName, aTypeID) {
+ let queryResultsSet = this.PDNS_RECORD();
+ let qname = ctypes.jschar.array()(aName);
+ let dnsStatus = this.DnsQuery_W(
+ qname,
+ aTypeID,
+ this.DNS_QUERY_STANDARD,
+ null,
+ queryResultsSet.address(),
+ null
+ );
+
+ // There is an error.
+ if (ctypes.Int64.compare(dnsStatus, this.ERROR_SUCCESS) != 0) {
+ return [];
+ }
+
+ let results = [];
+ for (
+ let presult = queryResultsSet;
+ presult && !presult.isNull();
+ presult = presult.contents.pNext
+ ) {
+ let result = presult.contents;
+ if (result.wType == aTypeID) {
+ let resource = this._mapAnswer(aTypeID, result.Data);
+ resource.type = result.wType;
+ resource.nsclass = 0;
+ resource.ttl = result.dwTtl | 0;
+ results.push(resource);
+ }
+ }
+
+ this.DnsRecordListFree(queryResultsSet, this.DnsFreeRecordList);
+ return results;
+ },
+};
+
+// Used to make results of different libraries consistent for SRV queries.
+function SRVRecord(aPrio, aWeight, aHost, aPort) {
+ this.prio = aPrio;
+ this.weight = aWeight;
+ this.host = aHost;
+ this.port = aPort;
+}
+
+// Used to make results of different libraries consistent for TXT queries.
+function TXTRecord(aData) {
+ this.data = aData;
+}
+
+// Used to make results of different libraries consistent for MX queries.
+function MXRecord(aPrio, aHost) {
+ this.prio = aPrio;
+ this.host = aHost;
+}
+
+if (typeof Components === "undefined") {
+ /* eslint-env worker */
+
+ // We are in a worker, wait for our message then execute the wanted method.
+ /* import-globals-from /toolkit/components/workerloader/require.js */
+ importScripts("resource://gre/modules/workers/require.js");
+ let PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js");
+
+ let worker = new PromiseWorker.AbstractWorker();
+ worker.dispatch = function (aMethod, aArgs = []) {
+ return self[aMethod](...aArgs);
+ };
+ worker.postMessage = function (...aArgs) {
+ self.postMessage(...aArgs);
+ };
+ worker.close = function () {
+ self.close();
+ };
+ self.addEventListener("message", msg => worker.handleMessage(msg));
+
+ // eslint-disable-next-line no-unused-vars
+ function execute(aOS, aMethod, aArgs) {
+ let DNS = aOS == "WINNT" ? new load_dnsapi() : new load_libresolv(aOS);
+ return DNS[aMethod].apply(DNS, aArgs);
+ }
+} else {
+ // We are loaded as a JSM, provide the async front that will start the
+ // worker.
+ var dns_async_front = {
+ /**
+ * Constants for use with the lookup function.
+ */
+ TXT: NS_T_TXT,
+ SRV: NS_T_SRV,
+ MX: NS_T_MX,
+
+ /**
+ * Do an asynchronous DNS lookup. The returned promise resolves with
+ * one of the Answer objects as defined above, or rejects with the
+ * error from the worker.
+ *
+ * Example: DNS.lookup("_caldavs._tcp.example.com", DNS.SRV)
+ *
+ * @param aName The aName to look up.
+ * @param aTypeID The RR type to look up as a constant.
+ * @returns A promise resolved when completed.
+ */
+ lookup(aName, aTypeID) {
+ let worker = new BasePromiseWorker(LOCATION);
+ return worker.post("execute", [
+ Services.appinfo.OS,
+ "lookup",
+ [...arguments],
+ ]);
+ },
+
+ /** Convenience functions */
+ srv(aName) {
+ return this.lookup(aName, NS_T_SRV);
+ },
+ txt(aName) {
+ return this.lookup(aName, NS_T_TXT);
+ },
+ mx(aName) {
+ return this.lookup(aName, NS_T_MX);
+ },
+ };
+ DNS = dns_async_front;
+}
diff --git a/comm/mail/modules/DisplayNameUtils.jsm b/comm/mail/modules/DisplayNameUtils.jsm
new file mode 100644
index 0000000000..bee32796fd
--- /dev/null
+++ b/comm/mail/modules/DisplayNameUtils.jsm
@@ -0,0 +1,130 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["DisplayNameUtils"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var DisplayNameUtils = {
+ formatDisplayName,
+ formatDisplayNameList,
+};
+
+// XXX: Maybe the strings for this file should go in a separate bundle?
+var gMessengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+);
+
+function _getIdentityForAddress(aEmailAddress) {
+ let emailAddress = aEmailAddress.toLowerCase();
+ for (let identity of MailServices.accounts.allIdentities) {
+ if (!identity.email) {
+ continue;
+ }
+ if (emailAddress == identity.email.toLowerCase()) {
+ return identity;
+ }
+ }
+ return null;
+}
+
+/**
+ * Take an email address and compose a sensible display name based on the
+ * header display name and/or the display name from the address book. If no
+ * appropriate name can be made (e.g. there is no card for this address),
+ * returns |null|.
+ *
+ * @param {string} emailAddress - The email address to format.
+ * @param {string} headerDisplayName - The display name from the header, if any.
+ * @param {string} context - The field being formatted (e.g. "to", "from").
+ * @returns The formatted display name, or null.
+ */
+function formatDisplayName(emailAddress, headerDisplayName, context) {
+ let displayName = null;
+ let identity = _getIdentityForAddress(emailAddress);
+ let card = MailServices.ab.cardForEmailAddress(emailAddress);
+
+ // If this address is one of the user's identities...
+ if (identity) {
+ if (
+ MailServices.accounts.allIdentities.length == 1 &&
+ (!headerDisplayName || identity.fullName == headerDisplayName)
+ ) {
+ // ...pick a localized version of the word "Me" appropriate to this
+ // specific header; fall back to the version used by the "to" header
+ // if nothing else is available.
+ try {
+ displayName = gMessengerBundle.GetStringFromName(
+ `header${context}FieldMe`
+ );
+ } catch (e) {
+ displayName = gMessengerBundle.GetStringFromName("headertoFieldMe");
+ }
+ } else {
+ // Use the full address. It's not the expected name, maybe a customized
+ // one the user sent, or one the sender got wrong, or we have multiple
+ // identities making the "Me" short string ambiguous.
+ displayName = MailServices.headerParser
+ .makeMailboxObject(headerDisplayName, emailAddress)
+ .toString();
+ }
+ }
+
+ // If we don't have a card, refuse to generate a display name. Places calling
+ // this are then responsible for falling back to something else (e.g. the
+ // value from the message header).
+ if (card) {
+ // getProperty may return a "1" or "0" string, we want a boolean
+ if (card.getProperty("PreferDisplayName", "1") == "1") {
+ displayName = card.displayName || null;
+ }
+
+ // Note: headerDisplayName is not used as a fallback as confusion could be
+ // caused by a collected address using an e-mail address as display name.
+ }
+
+ return displayName;
+}
+
+/**
+ * Format the display name from a list of addresses. First, try using
+ * formatDisplayName, then fall back to the header's display name or the
+ * address.
+ *
+ * @param aHeaderValue The decoded header value (e.g. mime2DecodedAuthor).
+ * @param aContext The context of the header field (e.g. "to", "from").
+ * @returns The formatted display name.
+ */
+function formatDisplayNameList(aHeaderValue, aContext) {
+ let addresses = MailServices.headerParser.parseDecodedHeader(aHeaderValue);
+ if (addresses.length > 0) {
+ let displayName = formatDisplayName(
+ addresses[0].email,
+ addresses[0].name,
+ aContext
+ );
+ let andOthersStr = "";
+ if (addresses.length > 1) {
+ andOthersStr = " " + gMessengerBundle.GetStringFromName("andOthers");
+ }
+
+ if (displayName) {
+ return displayName + andOthersStr;
+ }
+
+ // Construct default display.
+ if (addresses[0].email) {
+ return (
+ MailServices.headerParser
+ .makeMailboxObject(addresses[0].name, addresses[0].email)
+ .toString() + andOthersStr
+ );
+ }
+ }
+
+ // Something strange happened, just return the raw header value.
+ return aHeaderValue;
+}
diff --git a/comm/mail/modules/ExtensionSupport.jsm b/comm/mail/modules/ExtensionSupport.jsm
new file mode 100644
index 0000000000..cbf00bdb76
--- /dev/null
+++ b/comm/mail/modules/ExtensionSupport.jsm
@@ -0,0 +1,240 @@
+/* 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 functions for use by extensions that should ease them plug
+ * into the application.
+ */
+
+const EXPORTED_SYMBOLS = ["ExtensionSupport"];
+
+var extensionHooks = new Map();
+var openWindowList;
+
+var ExtensionSupport = {
+ /**
+ * Register listening for windows getting opened that will run the specified callback function
+ * when a matching window is loaded.
+ *
+ * @param aID {String} - Some identification of the caller, usually the extension ID.
+ * @param aExtensionHook {Object} - The object describing the hook the caller wants to register.
+ * Members of the object can be (all optional, but one callback must be supplied):
+ * chromeURLs {Array} An array of strings of document URLs on which
+ * the given callback should run. If not specified,
+ * run on all windows.
+ * onLoadWindow {function} The callback function to run when window loads
+ * the matching document.
+ * onUnloadWindow {function} The callback function to run when window
+ * unloads the matching document.
+ * Both callbacks receive the matching window object as argument.
+ *
+ * @returns {boolean} True if the passed arguments were valid and the caller could be registered.
+ * False otherwise.
+ */
+ registerWindowListener(aID, aExtensionHook) {
+ if (!aID) {
+ console.error("No extension ID provided for the window listener");
+ return false;
+ }
+
+ if (extensionHooks.has(aID)) {
+ console.error(
+ "Window listener for extension + '" + aID + "' already registered"
+ );
+ return false;
+ }
+
+ if (
+ !("onLoadWindow" in aExtensionHook) &&
+ !("onUnloadWindow" in aExtensionHook)
+ ) {
+ console.error(
+ "The extension + '" + aID + "' does not provide any callbacks"
+ );
+ return false;
+ }
+
+ extensionHooks.set(aID, aExtensionHook);
+
+ // Add our global listener if there isn't one already
+ // (only when we have first caller).
+ if (extensionHooks.size == 1) {
+ Services.wm.addListener(this._windowListener);
+ }
+
+ if (openWindowList) {
+ // We already have a list of open windows, notify the caller about them.
+ openWindowList.forEach(domWindow =>
+ ExtensionSupport._checkAndRunMatchingExtensions(domWindow, "load", aID)
+ );
+ } else {
+ openWindowList = new Set();
+ // Get the list of windows already open.
+ let windows = Services.wm.getEnumerator(null);
+ while (windows.hasMoreElements()) {
+ let domWindow = windows.getNext();
+ if (domWindow.document.location.href === "about:blank") {
+ ExtensionSupport._waitForLoad(domWindow, aID);
+ } else {
+ ExtensionSupport._addToListAndNotify(domWindow, aID);
+ }
+ }
+ }
+
+ return true;
+ },
+
+ /**
+ * Unregister listening for windows for the given caller.
+ *
+ * @param aID {String} - Some identification of the caller, usually the extension ID.
+ *
+ * @returns {boolean} True if the passed arguments were valid and the caller could be unregistered.
+ * False otherwise.
+ */
+ unregisterWindowListener(aID) {
+ if (!aID) {
+ console.error("No extension ID provided for the window listener");
+ return false;
+ }
+
+ let windowListener = extensionHooks.get(aID);
+ if (!windowListener) {
+ console.error(
+ "Couldn't remove window listener for extension + '" + aID + "'"
+ );
+ return false;
+ }
+
+ extensionHooks.delete(aID);
+ // Remove our global listener if there are no callers registered anymore.
+ if (extensionHooks.size == 0) {
+ Services.wm.removeListener(this._windowListener);
+ openWindowList.clear();
+ openWindowList = undefined;
+ }
+
+ return true;
+ },
+
+ get openWindows() {
+ if (!openWindowList) {
+ return [];
+ }
+ return openWindowList.values();
+ },
+
+ _windowListener: {
+ // nsIWindowMediatorListener functions
+ onOpenWindow(appWindow) {
+ // A new window has opened.
+ let domWindow = appWindow.docShell.domWindow;
+
+ // Here we pass no caller ID, so all registered callers get notified.
+ ExtensionSupport._waitForLoad(domWindow);
+ },
+
+ onCloseWindow(appWindow) {
+ // One of the windows has closed.
+ let domWindow = appWindow.docShell.domWindow;
+ openWindowList.delete(domWindow);
+ },
+ },
+
+ /**
+ * Set up listeners to run the callbacks on the given window.
+ *
+ * @param aWindow {nsIDOMWindow} - The window to set up.
+ * @param aID {String} Optional. ID of the new caller that has registered right now.
+ */
+ _waitForLoad(aWindow, aID) {
+ // Wait for the load event of the window. At that point
+ // aWindow.document.location.href will not be "about:blank" any more.
+ aWindow.addEventListener(
+ "load",
+ function () {
+ ExtensionSupport._addToListAndNotify(aWindow, aID);
+ },
+ { once: true }
+ );
+ },
+
+ /**
+ * Once the window is fully loaded with the href referring to the XUL document,
+ * add it to our list, attach the "unload" listener to it and notify interested
+ * callers.
+ *
+ * @param aWindow {nsIDOMWindow} - The window to process.
+ * @param aID {String} Optional. ID of the new caller that has registered right now.
+ */
+ _addToListAndNotify(aWindow, aID) {
+ openWindowList.add(aWindow);
+ aWindow.addEventListener(
+ "unload",
+ function () {
+ ExtensionSupport._checkAndRunMatchingExtensions(aWindow, "unload");
+ },
+ { once: true }
+ );
+ ExtensionSupport._checkAndRunMatchingExtensions(aWindow, "load", aID);
+ },
+
+ /**
+ * Check if the caller matches the given window and run its callback function.
+ *
+ * @param aWindow {nsIDOMWindow} - The window to run the callbacks on.
+ * @param aEventType {String} - Which callback to run if caller matches (load/unload).
+ * @param aID {String} - Optional ID of the caller whose callback is to be run.
+ * If not given, all registered callers are notified.
+ */
+ _checkAndRunMatchingExtensions(aWindow, aEventType, aID) {
+ if (aID) {
+ checkAndRunExtensionCode(extensionHooks.get(aID));
+ } else {
+ for (let extensionHook of extensionHooks.values()) {
+ checkAndRunExtensionCode(extensionHook);
+ }
+ }
+
+ /**
+ * Check if the single given caller matches the given window
+ * and run its callback function.
+ *
+ * @param aExtensionHook {Object} - The object describing the hook the caller
+ * has registered.
+ */
+ function checkAndRunExtensionCode(aExtensionHook) {
+ try {
+ let windowChromeURL = aWindow.document.location.href;
+ // Check if extension applies to this document URL.
+ if (
+ "chromeURLs" in aExtensionHook &&
+ !aExtensionHook.chromeURLs.some(url => url == windowChromeURL)
+ ) {
+ return;
+ }
+
+ // Run the relevant callback.
+ switch (aEventType) {
+ case "load":
+ if ("onLoadWindow" in aExtensionHook) {
+ aExtensionHook.onLoadWindow(aWindow);
+ }
+ break;
+ case "unload":
+ if ("onUnloadWindow" in aExtensionHook) {
+ aExtensionHook.onUnloadWindow(aWindow);
+ }
+ break;
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ },
+
+ get registeredWindowListenerCount() {
+ return extensionHooks.size;
+ },
+};
diff --git a/comm/mail/modules/ExtensionsUI.jsm b/comm/mail/modules/ExtensionsUI.jsm
new file mode 100644
index 0000000000..cc969f2d5a
--- /dev/null
+++ b/comm/mail/modules/ExtensionsUI.jsm
@@ -0,0 +1,1461 @@
+/* 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 EXPORTED_SYMBOLS = ["ExtensionsUI"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { EventEmitter } = ChromeUtils.importESModule(
+ "resource://gre/modules/EventEmitter.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ SITEPERMS_ADDON_TYPE:
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
+});
+
+const { PERMISSIONS_WITH_MESSAGE, PERMISSION_L10N } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissionMessages.sys.mjs"
+ );
+
+// Add the Thunderbird specific permission description locale file, to allow
+// Extension.sys.mjs to resolve our permissions strings.
+PERMISSION_L10N.addResourceIds(["messenger/extensionPermissions.ftl"]);
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () =>
+ new Localization(
+ [
+ "branding/brand.ftl",
+ "messenger/extensionsUI.ftl",
+ "messenger/addonNotifications.ftl",
+ ],
+ true
+ )
+);
+
+const DEFAULT_EXTENSION_ICON =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+const THUNDERBIRD_ANCHOR_ID = "addons-notification-icon";
+
+// Thunderbird shim of PopupNotifications for usage in this module.
+var PopupNotifications = {
+ get isPanelOpen() {
+ return getTopWindow().PopupNotifications.isPanelOpen;
+ },
+
+ getNotification(id, browser) {
+ return getTopWindow().PopupNotifications.getNotification(id, browser);
+ },
+
+ remove(notification, isCancel) {
+ return getTopWindow().PopupNotifications.remove(notification, isCancel);
+ },
+
+ show(browser, id, message, anchorID, mainAction, secondaryActions, options) {
+ let notifications = getTopWindow().PopupNotifications;
+ if (options.popupIconURL == "chrome://browser/content/extension.svg") {
+ options.popupIconURL = DEFAULT_EXTENSION_ICON;
+ }
+ return notifications.show(
+ browser,
+ id,
+ message,
+ anchorID,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ },
+};
+
+function getTopWindow() {
+ return Services.wm.getMostRecentWindow("mail:3pane");
+}
+
+function getTabBrowser(browser) {
+ while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) {
+ browser = browser.ownerGlobal.docShell.chromeEventHandler;
+ }
+ if (browser.getAttribute("webextension-view-type") == "popup") {
+ browser = browser.ownerGlobal.gBrowser.selectedBrowser;
+ }
+ return { browser, window: browser.ownerGlobal };
+}
+
+// Removes a doorhanger notification if all of the installs it was notifying
+// about have ended in some way.
+function removeNotificationOnEnd(notification, installs) {
+ let count = installs.length;
+
+ function maybeRemove(install) {
+ install.removeListener(this);
+
+ if (--count == 0) {
+ // Check that the notification is still showing
+ let current = PopupNotifications.getNotification(
+ notification.id,
+ notification.browser
+ );
+ if (current === notification) {
+ notification.remove();
+ }
+ }
+ }
+
+ for (let install of installs) {
+ install.addListener({
+ onDownloadCancelled: maybeRemove,
+ onDownloadFailed: maybeRemove,
+ onInstallFailed: maybeRemove,
+ onInstallEnded: maybeRemove,
+ });
+ }
+}
+
+// Copied from browser/base/content/browser-addons.js
+function buildNotificationAction(msg, callback) {
+ let label = "";
+ let accessKey = "";
+ for (let { name, value } of msg.attributes) {
+ switch (name) {
+ case "label":
+ label = value;
+ break;
+ case "accesskey":
+ accessKey = value;
+ break;
+ }
+ }
+ return { label, accessKey, callback };
+}
+
+/**
+ * Mapping of error code -> [error-id, local-error-id]
+ *
+ * error-id is used for errors in DownloadedAddonInstall,
+ * local-error-id for errors in LocalAddonInstall.
+ *
+ * The error codes are defined in AddonManager's _errors Map.
+ * Not all error codes listed there are translated,
+ * since errors that are only triggered during updates
+ * will never reach this code.
+ *
+ * @see browser/base/content/browser-addons.js (where this is copied from)
+ */
+const ERROR_L10N_IDS = new Map([
+ [
+ -1,
+ [
+ "addon-install-error-network-failure",
+ "addon-local-install-error-network-failure",
+ ],
+ ],
+ [
+ -2,
+ [
+ "addon-install-error-incorrect-hash",
+ "addon-local-install-error-incorrect-hash",
+ ],
+ ],
+ [
+ -3,
+ [
+ "addon-install-error-corrupt-file",
+ "addon-local-install-error-corrupt-file",
+ ],
+ ],
+ [
+ -4,
+ [
+ "addon-install-error-file-access",
+ "addon-local-install-error-file-access",
+ ],
+ ],
+ [
+ -5,
+ ["addon-install-error-not-signed", "addon-local-install-error-not-signed"],
+ ],
+ [-8, ["addon-install-error-invalid-domain"]],
+]);
+
+// Add Thunderbird specific permissions so localization will work. Add entries
+// to PERMISSION_L10N_ID_OVERRIDES here in case a permission string needs to be
+// overridden.
+for (let perm of [
+ "accountsFolders",
+ "accountsIdentities",
+ "accountsRead",
+ "addressBooks",
+ "compose",
+ "compose-send",
+ "compose-save",
+ "experiment",
+ "messagesImport",
+ "messagesModify",
+ "messagesMove",
+ "messagesDelete",
+ "messagesRead",
+ "messagesTags",
+ "sensitiveDataUpload",
+]) {
+ PERMISSIONS_WITH_MESSAGE.add(perm);
+}
+
+/**
+ * This object is Thunderbird's version of the same object in
+ * browser/base/content/browser-addons.js. Firefox has one of these objects
+ * per window but Thunderbird has only one total, because we simply pick the
+ * most recent window for notifications, rather than the window related to a
+ * particular tab.
+ */
+var gXPInstallObserver = {
+ pendingInstalls: new WeakMap(),
+
+ // Themes do not have a permission prompt and instead call for an install
+ // confirmation.
+ showInstallConfirmation(browser, installInfo, height = undefined) {
+ let document = getTopWindow().document;
+ // If the confirmation notification is already open cache the installInfo
+ // and the new confirmation will be shown later
+ if (
+ PopupNotifications.getNotification("addon-install-confirmation", browser)
+ ) {
+ let pending = this.pendingInstalls.get(browser);
+ if (pending) {
+ pending.push(installInfo);
+ } else {
+ this.pendingInstalls.set(browser, [installInfo]);
+ }
+ return;
+ }
+
+ let showNextConfirmation = () => {
+ let pending = this.pendingInstalls.get(browser);
+ if (pending && pending.length) {
+ this.showInstallConfirmation(browser, pending.shift());
+ }
+ };
+
+ // If all installs have already been cancelled in some way then just show
+ // the next confirmation.
+ if (
+ installInfo.installs.every(
+ i => i.state != lazy.AddonManager.STATE_DOWNLOADED
+ )
+ ) {
+ showNextConfirmation();
+ return;
+ }
+
+ // Make notifications persistent
+ var options = {
+ displayURI: installInfo.originatingURI,
+ persistent: true,
+ hideClose: true,
+ popupOptions: {
+ position: "bottomright topright",
+ },
+ };
+
+ let acceptInstallation = () => {
+ for (let install of installInfo.installs) {
+ install.install();
+ }
+ installInfo = null;
+
+ Services.telemetry
+ .getHistogramById("SECURITY_UI")
+ .add(
+ Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH
+ );
+ };
+
+ let cancelInstallation = () => {
+ if (installInfo) {
+ for (let install of installInfo.installs) {
+ // The notification may have been closed because the add-ons got
+ // cancelled elsewhere, only try to cancel those that are still
+ // pending install.
+ if (install.state != lazy.AddonManager.STATE_CANCELLED) {
+ install.cancel();
+ }
+ }
+ }
+
+ showNextConfirmation();
+ };
+
+ options.eventCallback = event => {
+ switch (event) {
+ case "removed":
+ cancelInstallation();
+ break;
+ case "shown":
+ let addonList = document.getElementById(
+ "addon-install-confirmation-content"
+ );
+ while (addonList.lastChild) {
+ addonList.lastChild.remove();
+ }
+
+ for (let install of installInfo.installs) {
+ let container = document.createXULElement("hbox");
+
+ let name = document.createXULElement("label");
+ name.setAttribute("value", install.addon.name);
+ name.setAttribute("class", "addon-install-confirmation-name");
+ container.appendChild(name);
+
+ addonList.appendChild(container);
+ }
+ break;
+ }
+ };
+
+ let msgId;
+ let notification = document.getElementById(
+ "addon-install-confirmation-notification"
+ );
+ msgId = "addon-confirm-install-message";
+ notification.removeAttribute("warning");
+ options.learnMoreURL =
+ "https://support.thunderbird.net/kb/installing-addon-thunderbird";
+ const addonCount = installInfo.installs.length;
+ const messageString = lazy.l10n.formatValueSync(msgId, { addonCount });
+
+ const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([
+ "addon-install-accept-button",
+ "addon-install-cancel-button",
+ ]);
+ const action = buildNotificationAction(acceptMsg, acceptInstallation);
+ const secondaryAction = buildNotificationAction(cancelMsg, () => {});
+
+ if (height) {
+ notification.style.minHeight = height + "px";
+ }
+
+ let popup = PopupNotifications.show(
+ browser,
+ "addon-install-confirmation",
+ messageString,
+ THUNDERBIRD_ANCHOR_ID,
+ action,
+ [secondaryAction],
+ options
+ );
+ removeNotificationOnEnd(popup, installInfo.installs);
+
+ Services.telemetry
+ .getHistogramById("SECURITY_UI")
+ .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL);
+ },
+
+ // IDs of addon install related notifications
+ NOTIFICATION_IDS: [
+ "addon-install-blocked",
+ "addon-install-confirmation",
+ "addon-install-failed",
+ "addon-install-origin-blocked",
+ "addon-install-webapi-blocked",
+ "addon-install-policy-blocked",
+ "addon-progress",
+ "addon-webext-permissions",
+ "xpinstall-disabled",
+ ],
+
+ /**
+ * Remove all opened addon installation notifications
+ *
+ * @param {*} browser - Browser to remove notifications for
+ * @returns {boolean} - true if notifications have been removed.
+ */
+ removeAllNotifications(browser) {
+ let notifications = this.NOTIFICATION_IDS.map(id =>
+ PopupNotifications.getNotification(id, browser)
+ ).filter(notification => notification != null);
+
+ PopupNotifications.remove(notifications, true);
+
+ return !!notifications.length;
+ },
+
+ async observe(aSubject, aTopic, aData) {
+ let installInfo = aSubject.wrappedJSObject;
+ let browser = installInfo.browser;
+
+ // Make notifications persistent
+ let options = {
+ displayURI: installInfo.originatingURI,
+ persistent: true,
+ hideClose: true,
+ timeout: Date.now() + 30000,
+ popupOptions: {
+ position: "bottomright topright",
+ },
+ };
+
+ switch (aTopic) {
+ case "addon-install-disabled": {
+ let msgId, action, secondaryActions;
+ if (Services.prefs.prefIsLocked("xpinstall.enabled")) {
+ msgId = "xpinstall-disabled-locked";
+ action = null;
+ secondaryActions = null;
+ } else {
+ msgId = "xpinstall-disabled";
+ const [disabledMsg, cancelMsg] = await lazy.l10n.formatMessages([
+ "xpinstall-disabled-button",
+ "addon-install-cancel-button",
+ ]);
+ action = buildNotificationAction(disabledMsg, () => {
+ Services.prefs.setBoolPref("xpinstall.enabled", true);
+ });
+ secondaryActions = [buildNotificationAction(cancelMsg, () => {})];
+ }
+
+ PopupNotifications.show(
+ browser,
+ "xpinstall-disabled",
+ await lazy.l10n.formatValue(msgId),
+ THUNDERBIRD_ANCHOR_ID,
+ action,
+ secondaryActions,
+ options
+ );
+ break;
+ }
+ case "addon-install-fullscreen-blocked": {
+ // AddonManager denied installation because we are in DOM fullscreen
+ this.logWarningFullScreenInstallBlocked();
+ break;
+ }
+ case "addon-install-webapi-blocked":
+ case "addon-install-policy-blocked":
+ case "addon-install-origin-blocked": {
+ const msgId =
+ aTopic == "addon-install-policy-blocked"
+ ? "addon-domain-blocked-by-policy"
+ : "xpinstall-prompt";
+ let messageString = await lazy.l10n.formatValue(msgId);
+ if (Services.policies) {
+ let extensionSettings = Services.policies.getExtensionSettings("*");
+ if (
+ extensionSettings &&
+ "blocked_install_message" in extensionSettings
+ ) {
+ messageString += " " + extensionSettings.blocked_install_message;
+ }
+ }
+
+ options.removeOnDismissal = true;
+ options.persistent = false;
+
+ let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
+ secHistogram.add(
+ Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
+ );
+ let popup = PopupNotifications.show(
+ browser,
+ aTopic,
+ messageString,
+ THUNDERBIRD_ANCHOR_ID,
+ null,
+ null,
+ options
+ );
+ removeNotificationOnEnd(popup, installInfo.installs);
+ break;
+ }
+ case "addon-install-blocked": {
+ let window = getTopWindow();
+ await window.ensureCustomElements("moz-support-link");
+ // Dismiss the progress notification. Note that this is bad if
+ // there are multiple simultaneous installs happening, see
+ // bug 1329884 for a longer explanation.
+ let progressNotification = PopupNotifications.getNotification(
+ "addon-progress",
+ browser
+ );
+ if (progressNotification) {
+ progressNotification.remove();
+ }
+
+ // The informational content differs somewhat for site permission
+ // add-ons. AOM no longer supports installing multiple addons,
+ // so the array handling here is vestigial.
+ let isSitePermissionAddon = installInfo.installs.every(
+ ({ addon }) => addon?.type === lazy.SITEPERMS_ADDON_TYPE
+ );
+ let hasHost = false;
+ let headerId, msgId;
+ if (isSitePermissionAddon) {
+ // At present, WebMIDI is the only consumer of the site permission
+ // add-on infrastructure, and so we can hard-code a midi string here.
+ // If and when we use it for other things, we'll need to plumb that
+ // information through. See bug 1826747.
+ headerId = "site-permission-install-first-prompt-midi-header";
+ msgId = "site-permission-install-first-prompt-midi-message";
+ } else if (options.displayURI) {
+ // PopupNotifications.show replaces <> with options.name.
+ headerId = { id: "xpinstall-prompt-header", args: { host: "<>" } };
+ // getLocalizedFragment replaces %1$S with options.name.
+ msgId = { id: "xpinstall-prompt-message", args: { host: "%1$S" } };
+ options.name = options.displayURI.displayHost;
+ hasHost = true;
+ } else {
+ headerId = "xpinstall-prompt-header-unknown";
+ msgId = "xpinstall-prompt-message-unknown";
+ }
+ const [headerString, msgString] = await lazy.l10n.formatValues([
+ headerId,
+ msgId,
+ ]);
+
+ // displayURI becomes it's own label, so we unset it for this panel. It will become part of the
+ // messageString above.
+ let displayURI = options.displayURI;
+ options.displayURI = undefined;
+
+ options.eventCallback = topic => {
+ if (topic !== "showing") {
+ return;
+ }
+ let doc = browser.ownerDocument;
+ let message = doc.getElementById("addon-install-blocked-message");
+ // We must remove any prior use of this panel message in this window.
+ while (message.firstChild) {
+ message.firstChild.remove();
+ }
+
+ if (!hasHost) {
+ message.textContent = msgString;
+ } else {
+ let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b");
+ b.textContent = options.name;
+ let fragment = getLocalizedFragment(doc, msgString, b);
+ message.appendChild(fragment);
+ }
+
+ let article = isSitePermissionAddon
+ ? "site-permission-addons"
+ : "unlisted-extensions-risks";
+ let learnMore = doc.getElementById("addon-install-blocked-info");
+ learnMore.setAttribute("support-page", article);
+ };
+
+ let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
+ secHistogram.add(
+ Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
+ );
+
+ const [
+ installMsg,
+ dontAllowMsg,
+ neverAllowMsg,
+ neverAllowAndReportMsg,
+ ] = await lazy.l10n.formatMessages([
+ "xpinstall-prompt-install",
+ "xpinstall-prompt-dont-allow",
+ "xpinstall-prompt-never-allow",
+ "xpinstall-prompt-never-allow-and-report",
+ ]);
+
+ const action = buildNotificationAction(installMsg, () => {
+ secHistogram.add(
+ Ci.nsISecurityUITelemetry
+ .WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH
+ );
+ installInfo.install();
+ });
+
+ const neverAllowCallback = () => {
+ // SitePermissions is browser/ only.
+ // lazy.SitePermissions.setForPrincipal(
+ // browser.contentPrincipal,
+ // "install",
+ // lazy.SitePermissions.BLOCK
+ // );
+ for (let install of installInfo.installs) {
+ if (install.state != lazy.AddonManager.STATE_CANCELLED) {
+ install.cancel();
+ }
+ }
+ if (installInfo.cancel) {
+ installInfo.cancel();
+ }
+ };
+
+ const declineActions = [
+ buildNotificationAction(dontAllowMsg, () => {
+ for (let install of installInfo.installs) {
+ if (install.state != lazy.AddonManager.STATE_CANCELLED) {
+ install.cancel();
+ }
+ }
+ if (installInfo.cancel) {
+ installInfo.cancel();
+ }
+ }),
+ buildNotificationAction(neverAllowMsg, neverAllowCallback),
+ ];
+
+ if (isSitePermissionAddon) {
+ // Restrict this to site permission add-ons for now pending a decision
+ // from product about how to approach this for extensions.
+ declineActions.push(
+ buildNotificationAction(neverAllowAndReportMsg, () => {
+ lazy.AMTelemetry.recordEvent({
+ method: "reportSuspiciousSite",
+ object: "suspiciousSite",
+ value: displayURI?.displayHost ?? "(unknown)",
+ extra: {},
+ });
+ neverAllowCallback();
+ })
+ );
+ }
+
+ let popup = PopupNotifications.show(
+ browser,
+ aTopic,
+ headerString,
+ THUNDERBIRD_ANCHOR_ID,
+ action,
+ declineActions,
+ options
+ );
+ removeNotificationOnEnd(popup, installInfo.installs);
+ break;
+ }
+ case "addon-install-started": {
+ // If all installs have already been downloaded then there is no need to
+ // show the download progress
+ if (
+ installInfo.installs.every(
+ aInstall => aInstall.state == lazy.AddonManager.STATE_DOWNLOADED
+ )
+ ) {
+ return;
+ }
+
+ const messageString = lazy.l10n.formatValueSync(
+ "addon-downloading-and-verifying",
+ { addonCount: installInfo.installs.length }
+ );
+ options.installs = installInfo.installs;
+ options.contentWindow = browser.contentWindow;
+ options.sourceURI = browser.currentURI;
+ options.eventCallback = function (aEvent) {
+ switch (aEvent) {
+ case "removed":
+ options.contentWindow = null;
+ options.sourceURI = null;
+ break;
+ }
+ };
+
+ const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([
+ "addon-install-accept-button",
+ "addon-install-cancel-button",
+ ]);
+
+ const action = buildNotificationAction(acceptMsg, () => {});
+ action.disabled = true;
+
+ const secondaryAction = buildNotificationAction(cancelMsg, () => {
+ for (let install of installInfo.installs) {
+ if (install.state != lazy.AddonManager.STATE_CANCELLED) {
+ install.cancel();
+ }
+ }
+ });
+
+ let notification = PopupNotifications.show(
+ browser,
+ "addon-progress",
+ messageString,
+ THUNDERBIRD_ANCHOR_ID,
+ action,
+ [secondaryAction],
+ options
+ );
+ notification._startTime = Date.now();
+
+ break;
+ }
+ case "addon-install-failed": {
+ options.removeOnDismissal = true;
+ options.persistent = false;
+
+ // TODO This isn't terribly ideal for the multiple failure case
+ for (let install of installInfo.installs) {
+ let host;
+ try {
+ host = options.displayURI.host;
+ } catch (e) {
+ // displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs.
+ }
+
+ if (!host) {
+ host =
+ install.sourceURI instanceof Ci.nsIStandardURL &&
+ install.sourceURI.host;
+ }
+
+ let messageString;
+ if (
+ install.addon &&
+ !Services.policies.mayInstallAddon(install.addon)
+ ) {
+ messageString = lazy.l10n.formatValueSync(
+ "addon-install-blocked-by-policy",
+ { addonName: install.name, addonId: install.addon.id }
+ );
+ let extensionSettings = Services.policies.getExtensionSettings(
+ install.addon.id
+ );
+ if (
+ extensionSettings &&
+ "blocked_install_message" in extensionSettings
+ ) {
+ messageString += " " + extensionSettings.blocked_install_message;
+ }
+ } else {
+ // TODO bug 1834484: simplify computation of isLocal.
+ const isLocal = !host;
+ let errorId = ERROR_L10N_IDS.get(install.error)?.[isLocal ? 1 : 0];
+ const args = { addonName: install.name };
+ if (!errorId) {
+ if (
+ install.addon.blocklistState ==
+ Ci.nsIBlocklistService.STATE_BLOCKED
+ ) {
+ errorId = "addon-install-error-blocklisted";
+ } else {
+ errorId = "addon-install-error-incompatible";
+ args.appVersion = Services.appinfo.version;
+ }
+ }
+ messageString = lazy.l10n.formatValueSync(errorId, args);
+ }
+
+ // Add Learn More link when refusing to install an unsigned add-on
+ if (install.error == lazy.AddonManager.ERROR_SIGNEDSTATE_REQUIRED) {
+ options.learnMoreURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "unsigned-addons";
+ }
+
+ PopupNotifications.show(
+ browser,
+ aTopic,
+ messageString,
+ THUNDERBIRD_ANCHOR_ID,
+ null,
+ null,
+ options
+ );
+
+ // Can't have multiple notifications with the same ID, so stop here.
+ break;
+ }
+ this._removeProgressNotification(browser);
+ break;
+ }
+ case "addon-install-confirmation": {
+ let showNotification = () => {
+ let height;
+ if (PopupNotifications.isPanelOpen) {
+ let rect = getTopWindow()
+ .document.getElementById("addon-progress-notification")
+ .getBoundingClientRect();
+ height = rect.height;
+ }
+
+ this._removeProgressNotification(browser);
+ this.showInstallConfirmation(browser, installInfo, height);
+ };
+
+ let progressNotification = PopupNotifications.getNotification(
+ "addon-progress",
+ browser
+ );
+ if (progressNotification) {
+ let downloadDuration = Date.now() - progressNotification._startTime;
+ let securityDelay =
+ Services.prefs.getIntPref("security.dialog_enable_delay") -
+ downloadDuration;
+ if (securityDelay > 0) {
+ getTopWindow().setTimeout(() => {
+ // The download may have been cancelled during the security delay
+ if (
+ PopupNotifications.getNotification("addon-progress", browser)
+ ) {
+ showNotification();
+ }
+ }, securityDelay);
+ break;
+ }
+ }
+ showNotification();
+ }
+ }
+ },
+ _removeProgressNotification(aBrowser) {
+ let notification = PopupNotifications.getNotification(
+ "addon-progress",
+ aBrowser
+ );
+ if (notification) {
+ notification.remove();
+ }
+ },
+};
+
+Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-origin-blocked");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-policy-blocked");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-webapi-blocked");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-started");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-failed");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation");
+
+/**
+ * This object is Thunderbird's version of the same object in
+ * browser/modules/ExtensionsUI.jsm
+ */
+var ExtensionsUI = {
+ sideloaded: new Set(),
+ updates: new Set(),
+ sideloadListener: null,
+
+ pendingNotifications: new WeakMap(),
+
+ async init() {
+ Services.obs.addObserver(this, "webextension-permission-prompt");
+ Services.obs.addObserver(this, "webextension-update-permissions");
+ Services.obs.addObserver(this, "webextension-install-notify");
+ Services.obs.addObserver(this, "webextension-optional-permission-prompt");
+ Services.obs.addObserver(this, "webextension-defaultsearch-prompt");
+
+ await Services.wm.getMostRecentWindow("mail:3pane").delayedStartupPromise;
+ this._checkForSideloaded();
+ },
+
+ async _checkForSideloaded() {
+ let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads();
+
+ if (!sideloaded.length) {
+ // No new side-loads. We're done.
+ return;
+ }
+
+ // The ordering shouldn't matter, but tests depend on notifications
+ // happening in a specific order.
+ sideloaded.sort((a, b) => a.id.localeCompare(b.id));
+
+ if (!this.sideloadListener) {
+ this.sideloadListener = {
+ onEnabled: addon => {
+ if (!this.sideloaded.has(addon)) {
+ return;
+ }
+
+ this.sideloaded.delete(addon);
+ this._updateNotifications();
+
+ if (this.sideloaded.size == 0) {
+ lazy.AddonManager.removeAddonListener(this.sideloadListener);
+ this.sideloadListener = null;
+ }
+ },
+ };
+ lazy.AddonManager.addAddonListener(this.sideloadListener);
+ }
+
+ for (let addon of sideloaded) {
+ this.sideloaded.add(addon);
+ }
+ this._updateNotifications();
+ },
+
+ _updateNotifications() {
+ if (this.sideloaded.size + this.updates.size == 0) {
+ lazy.AppMenuNotifications.removeNotification("addon-alert");
+ } else {
+ lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
+ }
+ this.emit("change");
+ },
+
+ showAddonsManager(tabbrowser, strings, icon) {
+ // This is for compatibility. Thunderbird just shows the prompt.
+ return this.showPermissionsPrompt(tabbrowser, strings, icon);
+ },
+
+ showSideloaded(tabbrowser, addon) {
+ addon.markAsSeen();
+ this.sideloaded.delete(addon);
+ this._updateNotifications();
+
+ let strings = this._buildStrings({
+ addon,
+ permissions: addon.userPermissions,
+ type: "sideload",
+ });
+
+ lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
+ num_strings: strings.msgs.length,
+ });
+
+ this.showAddonsManager(tabbrowser, strings, addon.iconURL).then(
+ async answer => {
+ if (answer) {
+ await addon.enable();
+
+ this._updateNotifications();
+
+ // The user has just enabled a sideloaded extension, if the permission
+ // can be changed for the extension, show the post-install panel to
+ // give the user that opportunity.
+ if (
+ addon.permissions &
+ lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ ) {
+ await this.showInstallNotification(
+ tabbrowser.selectedBrowser,
+ addon
+ );
+ }
+ }
+ this.emit("sideload-response");
+ }
+ );
+ },
+
+ showUpdate(browser, info) {
+ lazy.AMTelemetry.recordInstallEvent(info.install, {
+ step: "permissions_prompt",
+ num_strings: info.strings.msgs.length,
+ });
+
+ this.showAddonsManager(browser, info.strings, info.addon.iconURL).then(
+ answer => {
+ if (answer) {
+ info.resolve();
+ } else {
+ info.reject();
+ }
+ // At the moment, this prompt will re-appear next time we do an update
+ // check. See bug 1332360 for proposal to avoid this.
+ this.updates.delete(info);
+ this._updateNotifications();
+ }
+ );
+ },
+
+ async observe(subject, topic, data) {
+ if (topic == "webextension-permission-prompt") {
+ let { target, info } = subject.wrappedJSObject;
+
+ let { browser, window } = getTabBrowser(target);
+
+ // Dismiss the progress notification. Note that this is bad if
+ // there are multiple simultaneous installs happening, see
+ // bug 1329884 for a longer explanation.
+ let progressNotification = window.PopupNotifications.getNotification(
+ "addon-progress",
+ browser
+ );
+ if (progressNotification) {
+ progressNotification.remove();
+ }
+
+ let strings = this._buildStrings(info);
+ let data = new lazy.ExtensionData(info.addon.getResourceURI());
+ await data.loadManifest();
+ if (data.manifest.experiment_apis) {
+ // Add the experiment permission text and use the header for
+ // extensions with permissions.
+ let [experimentWarning] = await lazy.l10n.formatValues([
+ "webext-experiment-warning",
+ ]);
+ let [header, msg] = await PERMISSION_L10N.formatValues([
+ {
+ id: "webext-perms-header-with-perms",
+ args: { extension: "<>" },
+ },
+ "webext-perms-description-experiment",
+ ]);
+ strings.header = header;
+ strings.msgs = [msg];
+ if (info.source != "AMO") {
+ strings.experimentWarning = experimentWarning;
+ }
+ }
+
+ // Thunderbird doesn't care about signing and does not check
+ // info.addon.signedState as Firefox is doing it.
+ info.unsigned = false;
+
+ // If this is an update with no promptable permissions, just apply it. Skip
+ // prompts also, if this add-on already has full access via experiment_apis.
+ if (info.type == "update") {
+ let extension = lazy.ExtensionParent.GlobalManager.getExtension(
+ info.addon.id
+ );
+ if (
+ !strings.msgs.length ||
+ (extension && extension.manifest.experiment_apis)
+ ) {
+ info.resolve();
+ return;
+ }
+ }
+
+ let icon = info.unsigned
+ ? "chrome://global/skin/icons/warning.svg"
+ : info.icon;
+
+ if (info.type == "sideload") {
+ lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
+ num_strings: strings.msgs.length,
+ });
+ } else {
+ lazy.AMTelemetry.recordInstallEvent(info.install, {
+ step: "permissions_prompt",
+ num_strings: strings.msgs.length,
+ });
+ }
+
+ // Reject add-ons using the legacy API. We cannot use the general "ignore
+ // unknown APIs" policy, as add-ons using the Legacy API from TB68 will
+ // not do anything, confusing the user.
+ if (data.manifest.legacy) {
+ let subject = {
+ wrappedJSObject: {
+ browser,
+ originatingURI: null,
+ installs: [
+ {
+ addon: info.addon,
+ name: info.addon.name,
+ error: 0,
+ },
+ ],
+ install: null,
+ cancel: null,
+ },
+ };
+ Services.obs.notifyObservers(subject, "addon-install-failed");
+ info.reject();
+ return;
+ }
+
+ this.showPermissionsPrompt(browser, strings, icon).then(answer => {
+ if (answer) {
+ info.resolve();
+ } else {
+ info.reject();
+ }
+ });
+ } else if (topic == "webextension-update-permissions") {
+ let info = subject.wrappedJSObject;
+ info.type = "update";
+ let strings = this._buildStrings(info);
+
+ // If we don't prompt for any new permissions, just apply it. Skip prompts
+ // also, if this add-on already has full access via experiment_apis.
+ let extension = lazy.ExtensionParent.GlobalManager.getExtension(
+ info.addon.id
+ );
+ if (
+ !strings.msgs.length ||
+ (extension && extension.manifest.experiment_apis)
+ ) {
+ info.resolve();
+ return;
+ }
+
+ let update = {
+ strings,
+ permissions: info.permissions,
+ install: info.install,
+ addon: info.addon,
+ resolve: info.resolve,
+ reject: info.reject,
+ };
+
+ this.updates.add(update);
+ this._updateNotifications();
+ } else if (topic == "webextension-install-notify") {
+ let { target, addon, callback } = subject.wrappedJSObject;
+ this.showInstallNotification(target, addon).then(() => {
+ if (callback) {
+ callback();
+ }
+ });
+ } else if (topic == "webextension-optional-permission-prompt") {
+ let browser =
+ getTopWindow().document.getElementById("tabmail").selectedBrowser;
+ let { name, icon, permissions, resolve } = subject.wrappedJSObject;
+ let strings = this._buildStrings({
+ type: "optional",
+ addon: { name },
+ permissions,
+ });
+
+ // If we don't have any promptable permissions, just proceed
+ if (!strings.msgs.length) {
+ resolve(true);
+ return;
+ }
+ resolve(this.showPermissionsPrompt(browser, strings, icon));
+ } else if (topic == "webextension-defaultsearch-prompt") {
+ let { browser, name, icon, respond, currentEngine, newEngine } =
+ subject.wrappedJSObject;
+
+ // FIXME: These only exist in mozilla/browser/locales/en-US/browser/extensionsUI.ftl.
+ const [searchDesc, searchYes, searchNo] = lazy.l10n.formatMessagesSync([
+ {
+ id: "webext-default-search-description",
+ args: { addonName: "<>", currentEngine, newEngine },
+ },
+ "webext-default-search-yes",
+ "webext-default-search-no",
+ ]);
+
+ const strings = { addonName: name, text: searchDesc.value };
+ for (let attr of searchYes.attributes) {
+ if (attr.name === "label") {
+ strings.acceptText = attr.value;
+ } else if (attr.name === "accesskey") {
+ strings.acceptKey = attr.value;
+ }
+ }
+ for (let attr of searchNo.attributes) {
+ if (attr.name === "label") {
+ strings.cancelText = attr.value;
+ } else if (attr.name === "accesskey") {
+ strings.cancelKey = attr.value;
+ }
+ }
+
+ this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
+ }
+ },
+
+ // Create a set of formatted strings for a permission prompt
+ _buildStrings(info) {
+ const strings = lazy.ExtensionData.formatPermissionStrings(info, {
+ collapseOrigins: true,
+ });
+ strings.addonName = info.addon.name;
+ strings.learnMore = lazy.l10n.formatValueSync("webext-perms-learn-more");
+ return strings;
+ },
+
+ async showPermissionsPrompt(target, strings, icon) {
+ let { browser } = getTabBrowser(target);
+
+ // Wait for any pending prompts to complete before showing the next one.
+ let pending;
+ while ((pending = this.pendingNotifications.get(browser))) {
+ await pending;
+ }
+
+ let promise = new Promise(resolve => {
+ function eventCallback(topic) {
+ let doc = this.browser.ownerDocument;
+ if (topic == "showing") {
+ let textEl = doc.getElementById("addon-webext-perm-text");
+ textEl.textContent = strings.text;
+ textEl.hidden = !strings.text;
+
+ // By default, multiline strings don't get formatted properly. These
+ // are presently only used in site permission add-ons, so we treat it
+ // as a special case to avoid unintended effects on other things.
+ let isMultiline = strings.text.includes("\n\n");
+ textEl.classList.toggle(
+ "addon-webext-perm-text-multiline",
+ isMultiline
+ );
+
+ let listIntroEl = doc.getElementById("addon-webext-perm-intro");
+ listIntroEl.textContent = strings.listIntro;
+ listIntroEl.hidden = !strings.msgs.length || !strings.listIntro;
+
+ let listInfoEl = doc.getElementById("addon-webext-perm-info");
+ listInfoEl.textContent = strings.learnMore;
+ listInfoEl.href =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "extension-permissions";
+ listInfoEl.hidden = !strings.msgs.length;
+
+ let list = doc.getElementById("addon-webext-perm-list");
+ while (list.firstChild) {
+ list.firstChild.remove();
+ }
+ let singleEntryEl = doc.getElementById(
+ "addon-webext-perm-single-entry"
+ );
+ singleEntryEl.textContent = "";
+ singleEntryEl.hidden = true;
+ list.hidden = true;
+
+ if (strings.msgs.length === 1) {
+ singleEntryEl.textContent = strings.msgs[0];
+ singleEntryEl.hidden = false;
+ } else if (strings.msgs.length) {
+ for (let msg of strings.msgs) {
+ let item = doc.createElementNS(HTML_NS, "li");
+ item.textContent = msg;
+ list.appendChild(item);
+ }
+ list.hidden = false;
+ }
+
+ let experimentsEl = doc.getElementById(
+ "addon-webext-experiment-warning"
+ );
+ experimentsEl.textContent = strings.experimentWarning;
+ experimentsEl.hidden = !strings.experimentWarning;
+ } else if (topic == "swapping") {
+ return true;
+ }
+ if (topic == "removed") {
+ Services.tm.dispatchToMainThread(() => {
+ resolve(false);
+ });
+ }
+ return false;
+ }
+
+ let options = {
+ hideClose: true,
+ popupIconURL: icon || DEFAULT_EXTENSION_ICON,
+ popupIconClass: icon ? "" : "addon-warning-icon",
+ persistent: true,
+ eventCallback,
+ removeOnDismissal: true,
+ popupOptions: {
+ position: "bottomright topright",
+ },
+ };
+ // The prompt/notification machinery has a special affordance wherein
+ // certain subsets of the header string can be designated "names", and
+ // referenced symbolically as "<>" and "{}" to receive special formatting.
+ // That code assumes that the existence of |name| and |secondName| in the
+ // options object imply the presence of "<>" and "{}" (respectively) in
+ // in the string.
+ //
+ // At present, WebExtensions use this affordance while SitePermission
+ // add-ons don't, so we need to conditionally set the |name| field.
+ //
+ // NB: This could potentially be cleaned up, see bug 1799710.
+ if (strings.header.includes("<>")) {
+ options.name = strings.addonName;
+ }
+
+ let action = {
+ label: strings.acceptText,
+ accessKey: strings.acceptKey,
+ callback: () => {
+ resolve(true);
+ },
+ };
+ let secondaryActions = [
+ {
+ label: strings.cancelText,
+ accessKey: strings.cancelKey,
+ callback: () => {
+ resolve(false);
+ },
+ },
+ ];
+
+ PopupNotifications.show(
+ browser,
+ "addon-webext-permissions",
+ strings.header,
+ THUNDERBIRD_ANCHOR_ID,
+ action,
+ secondaryActions,
+ options
+ );
+ });
+
+ this.pendingNotifications.set(browser, promise);
+ promise.finally(() => this.pendingNotifications.delete(browser));
+ return promise;
+ },
+
+ showDefaultSearchPrompt(target, strings, icon) {
+ return new Promise(resolve => {
+ let options = {
+ hideClose: true,
+ popupIconURL: icon || DEFAULT_EXTENSION_ICON,
+ persistent: true,
+ removeOnDismissal: true,
+ eventCallback(topic) {
+ if (topic == "removed") {
+ resolve(false);
+ }
+ },
+ name: strings.addonName,
+ };
+
+ let action = {
+ label: strings.acceptText,
+ accessKey: strings.acceptKey,
+ callback: () => {
+ resolve(true);
+ },
+ };
+ let secondaryActions = [
+ {
+ label: strings.cancelText,
+ accessKey: strings.cancelKey,
+ callback: () => {
+ resolve(false);
+ },
+ },
+ ];
+
+ let { browser } = getTabBrowser(target);
+
+ PopupNotifications.show(
+ browser,
+ "addon-webext-defaultsearch",
+ strings.text,
+ THUNDERBIRD_ANCHOR_ID,
+ action,
+ secondaryActions,
+ options
+ );
+ });
+ },
+
+ async showInstallNotification(target, addon) {
+ let { browser, window } = getTabBrowser(target);
+
+ const message = await lazy.l10n.formatValue("addon-post-install-message", {
+ addonName: "<>",
+ });
+
+ let icon = addon.isWebExtension
+ ? lazy.AddonManager.getPreferredIconURL(addon, 32, window) ||
+ DEFAULT_EXTENSION_ICON
+ : "chrome://messenger/skin/addons/addon-install-installed.svg";
+
+ let options = {
+ hideClose: true,
+ timeout: Date.now() + 30000,
+ popupIconURL: icon,
+ name: addon.name,
+ };
+
+ return PopupNotifications.show(
+ browser,
+ "addon-installed",
+ message,
+ THUNDERBIRD_ANCHOR_ID,
+ null,
+ null,
+ options
+ );
+ },
+};
+
+EventEmitter.decorate(ExtensionsUI);
+
+/**
+ * Generate a document fragment for a localized string that has DOM
+ * node replacements. This avoids using getFormattedString followed
+ * by assigning to innerHTML. Fluent can probably replace this when
+ * it is in use everywhere.
+ *
+ * Lifted from BrowserUIUtils.jsm.
+ *
+ * @param {Document} doc
+ * @param {string} msg
+ * The string to put replacements in. Fetch from
+ * a stringbundle using getString or GetStringFromName,
+ * or even an inserted dtd string.
+ * @param {Node | string} nodesOrStrings
+ * The replacement items. Can be a mix of Nodes
+ * and Strings. However, for correct behaviour, the
+ * number of items provided needs to exactly match
+ * the number of replacement strings in the l10n string.
+ * @returns {DocumentFragment}
+ * A document fragment. In the trivial case (no
+ * replacements), this will simply be a fragment with 1
+ * child, a text node containing the localized string.
+ */
+function getLocalizedFragment(doc, msg, ...nodesOrStrings) {
+ // Ensure replacement points are indexed:
+ for (let i = 1; i <= nodesOrStrings.length; i++) {
+ if (!msg.includes("%" + i + "$S")) {
+ msg = msg.replace(/%S/, "%" + i + "$S");
+ }
+ }
+ let numberOfInsertionPoints = msg.match(/%\d+\$S/g).length;
+ if (numberOfInsertionPoints != nodesOrStrings.length) {
+ console.error(
+ `Message has ${numberOfInsertionPoints} insertion points, ` +
+ `but got ${nodesOrStrings.length} replacement parameters!`
+ );
+ }
+
+ let fragment = doc.createDocumentFragment();
+ let parts = [msg];
+ let insertionPoint = 1;
+ for (let replacement of nodesOrStrings) {
+ let insertionString = "%" + insertionPoint++ + "$S";
+ let partIndex = parts.findIndex(
+ part => typeof part == "string" && part.includes(insertionString)
+ );
+ if (partIndex == -1) {
+ fragment.appendChild(doc.createTextNode(msg));
+ return fragment;
+ }
+
+ if (typeof replacement == "string") {
+ parts[partIndex] = parts[partIndex].replace(insertionString, replacement);
+ } else {
+ let [firstBit, lastBit] = parts[partIndex].split(insertionString);
+ parts.splice(partIndex, 1, firstBit, replacement, lastBit);
+ }
+ }
+
+ // Put everything in a document fragment:
+ for (let part of parts) {
+ if (typeof part == "string") {
+ if (part) {
+ fragment.appendChild(doc.createTextNode(part));
+ }
+ } else {
+ fragment.appendChild(part);
+ }
+ }
+ return fragment;
+}
diff --git a/comm/mail/modules/FolderTreeProperties.jsm b/comm/mail/modules/FolderTreeProperties.jsm
new file mode 100644
index 0000000000..6931c2794a
--- /dev/null
+++ b/comm/mail/modules/FolderTreeProperties.jsm
@@ -0,0 +1,84 @@
+/* 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/. */
+
+/**
+ * Persistent storage for various properties of items on the folder tree.
+ * Data is serialised to the file folderTree.json in the profile directory.
+ */
+
+const EXPORTED_SYMBOLS = ["FolderTreeProperties"];
+
+const { JSONFile } = ChromeUtils.importESModule(
+ "resource://gre/modules/JSONFile.sys.mjs"
+);
+
+var jsonFile = new JSONFile({
+ path: PathUtils.join(PathUtils.profileDir, "folderTree.json"),
+});
+var readyPromise = jsonFile.load();
+
+function ensureReady() {
+ if (!jsonFile.dataReady) {
+ throw new Error("Folder tree properties cache not ready.");
+ }
+}
+
+var FolderTreeProperties = {
+ get ready() {
+ return readyPromise;
+ },
+
+ /**
+ * Get the colour associated with a folder.
+ *
+ * @param {string} folderURI
+ * @returns {?string}
+ */
+ getColor(folderURI) {
+ ensureReady();
+ return jsonFile.data.colors?.[folderURI];
+ },
+
+ /**
+ * Set the colour associated with a folder.
+ *
+ * @param {string} folderURI
+ * @param {string} color
+ */
+ setColor(folderURI, color) {
+ ensureReady();
+ jsonFile.data.colors = jsonFile.data.colors ?? {};
+ jsonFile.data.colors[folderURI] = color;
+ jsonFile.saveSoon();
+ },
+
+ resetColors() {
+ ensureReady();
+ delete jsonFile.data.colors;
+ jsonFile.saveSoon();
+ },
+
+ getIsExpanded(folderURI, mode) {
+ ensureReady();
+ if (!Array.isArray(jsonFile.data.open?.[mode])) {
+ return false;
+ }
+ return jsonFile.data.open[mode].includes(folderURI);
+ },
+
+ setIsExpanded(folderURI, mode, isExpanded) {
+ ensureReady();
+ jsonFile.data.open = jsonFile.data.open ?? {};
+ jsonFile.data.open[mode] = jsonFile.data.open[mode] ?? [];
+ let index = jsonFile.data.open[mode].indexOf(folderURI);
+ if (isExpanded) {
+ if (index < 0) {
+ jsonFile.data.open[mode].push(folderURI);
+ }
+ } else if (index >= 0) {
+ jsonFile.data.open[mode].splice(index, 1);
+ }
+ jsonFile.saveSoon();
+ },
+};
diff --git a/comm/mail/modules/GlobalPopupNotifications.jsm b/comm/mail/modules/GlobalPopupNotifications.jsm
new file mode 100644
index 0000000000..6d7837614c
--- /dev/null
+++ b/comm/mail/modules/GlobalPopupNotifications.jsm
@@ -0,0 +1,1606 @@
+/* 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 semi-fork of PopupNotifications.jsm */
+
+var EXPORTED_SYMBOLS = ["PopupNotifications"];
+
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+
+const NOTIFICATION_EVENT_DISMISSED = "dismissed";
+const NOTIFICATION_EVENT_REMOVED = "removed";
+const NOTIFICATION_EVENT_SHOWING = "showing";
+const NOTIFICATION_EVENT_SHOWN = "shown";
+const NOTIFICATION_EVENT_SWAPPING = "swapping";
+
+const ICON_SELECTOR = ".notification-anchor-icon";
+const ICON_ATTRIBUTE_SHOWING = "showing";
+
+const PREF_SECURITY_DELAY = "security.notification_enable_delay";
+
+// Enumerated values for the POPUP_NOTIFICATION_STATS telemetry histogram.
+const TELEMETRY_STAT_OFFERED = 0;
+const TELEMETRY_STAT_ACTION_1 = 1;
+const TELEMETRY_STAT_ACTION_2 = 2;
+// const TELEMETRY_STAT_ACTION_3 = 3;
+const TELEMETRY_STAT_ACTION_LAST = 4;
+const TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE = 5;
+const TELEMETRY_STAT_DISMISSAL_LEAVE_PAGE = 6;
+const TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON = 7;
+const TELEMETRY_STAT_OPEN_SUBMENU = 10;
+const TELEMETRY_STAT_LEARN_MORE = 11;
+
+const TELEMETRY_STAT_REOPENED_OFFSET = 20;
+
+var popupNotificationsMap = [];
+var gNotificationParents = new WeakMap();
+
+/**
+ * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>.
+ */
+function getNotificationFromElement(aElement) {
+ return aElement.closest("popupnotification");
+}
+
+/**
+ * Notification object describes a single popup notification.
+ *
+ * @see PopupNotifications.show()
+ */
+function Notification(
+ id,
+ message,
+ anchorID,
+ mainAction,
+ secondaryActions,
+ browser,
+ owner,
+ options
+) {
+ this.id = id;
+ this.message = message;
+ this.anchorID = anchorID;
+ this.mainAction = mainAction;
+ this.secondaryActions = secondaryActions || [];
+ this.browser = browser;
+ this.owner = owner;
+ this.options = options || {};
+
+ this._dismissed = false;
+ // Will become a boolean when manually toggled by the user.
+ this._checkboxChecked = null;
+ this.wasDismissed = false;
+ this.recordedTelemetryStats = new Set();
+ this.timeCreated = this.owner.window.performance.now();
+}
+
+Notification.prototype = {
+ id: null,
+ message: null,
+ anchorID: null,
+ mainAction: null,
+ secondaryActions: null,
+ browser: null,
+ owner: null,
+ options: null,
+ timeShown: null,
+
+ /**
+ * Indicates whether the notification is currently dismissed.
+ */
+ set dismissed(value) {
+ this._dismissed = value;
+ if (value) {
+ // Keep the dismissal into account when recording telemetry.
+ this.wasDismissed = true;
+ }
+ },
+ get dismissed() {
+ return this._dismissed;
+ },
+
+ /**
+ * Removes the notification and updates the popup accordingly if needed.
+ */
+ remove() {
+ this.owner.remove(this);
+ },
+
+ get anchorElement() {
+ let iconBox = this.owner.iconBox;
+ return iconBox.querySelector("#" + this.anchorID);
+ },
+
+ reshow() {
+ this.owner._reshowNotifications(this.anchorElement, this.browser);
+ },
+
+ /**
+ * Adds a value to the specified histogram, that must be keyed by ID.
+ */
+ _recordTelemetry(histogramId, value) {
+ let histogram = Services.telemetry.getKeyedHistogramById(histogramId);
+ histogram.add("(all)", value);
+ histogram.add(this.id, value);
+ },
+
+ /**
+ * Adds an enumerated value to the POPUP_NOTIFICATION_STATS histogram,
+ * ensuring that it is recorded at most once for each distinct Notification.
+ *
+ * Statistics for reopened notifications are recorded in separate buckets.
+ *
+ * @param value
+ * One of the TELEMETRY_STAT_ constants.
+ */
+ _recordTelemetryStat(value) {
+ if (this.wasDismissed) {
+ value += TELEMETRY_STAT_REOPENED_OFFSET;
+ }
+ if (!this.recordedTelemetryStats.has(value)) {
+ this.recordedTelemetryStats.add(value);
+ this._recordTelemetry("POPUP_NOTIFICATION_STATS", value);
+ }
+ },
+};
+
+/**
+ * The PopupNotifications object manages popup notifications for a given browser
+ * window.
+ *
+ * @param tabbrowser
+ * window's TabBrowser. Used to observe tab switching events and
+ * for determining the active browser element.
+ * @param panel
+ * The <xul:panel/> element to use for notifications. The panel is
+ * populated with <popupnotification> children and displayed it as
+ * needed.
+ * @param iconBox
+ * Reference to a container element that should be hidden or
+ * unhidden when notifications are hidden or shown. It should be the
+ * parent of anchor elements whose IDs are passed to show().
+ * It is used as a fallback popup anchor if notifications specify
+ * invalid or non-existent anchor IDs.
+ * @param options
+ * An optional object with the following optional properties:
+ * {
+ * shouldSuppress:
+ * If this function returns true, then all notifications are
+ * suppressed for this window. This state is checked on construction
+ * and when the "anchorVisibilityChange" method is called.
+ * }
+ */
+function PopupNotifications(tabbrowser, panel, iconBox, options = {}) {
+ if (!tabbrowser) {
+ throw new Error("Invalid tabbrowser");
+ }
+ if (iconBox && ChromeUtils.getClassName(iconBox) != "HTMLDivElement") {
+ throw new Error("Invalid iconBox");
+ }
+ if (ChromeUtils.getClassName(panel) != "XULPopupElement") {
+ throw new Error("Invalid panel");
+ }
+
+ this._shouldSuppress = options.shouldSuppress || (() => false);
+ this._suppress = this._shouldSuppress();
+
+ this.window = tabbrowser.ownerGlobal;
+ this.panel = panel;
+ this.tabbrowser = tabbrowser;
+ this.iconBox = iconBox;
+ this.buttonDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY);
+
+ this.panel.addEventListener("popuphidden", this, true);
+ this.panel.classList.add("popup-notification-panel", "panel-no-padding");
+
+ // This listener will be attached to the chrome window whenever a notification
+ // is showing, to allow the user to dismiss notifications using the escape key.
+ this._handleWindowKeyPress = aEvent => {
+ if (aEvent.keyCode != aEvent.DOM_VK_ESCAPE) {
+ return;
+ }
+
+ // Esc key cancels the topmost notification, if there is one.
+ let notification = this.panel.firstElementChild;
+ if (!notification) {
+ return;
+ }
+
+ let doc = this.window.document;
+ let focusedElement = Services.focus.focusedElement;
+
+ // If the chrome window has a focused element, let it handle the ESC key instead.
+ if (
+ !focusedElement ||
+ focusedElement == doc.body ||
+ focusedElement == this.tabbrowser.selectedBrowser ||
+ notification.contains(focusedElement)
+ ) {
+ this._onButtonEvent(
+ aEvent,
+ "secondarybuttoncommand",
+ "esc-press",
+ notification
+ );
+ }
+ };
+
+ let documentElement = this.window.document.documentElement;
+ let locationBarHidden = documentElement
+ .getAttribute("chromehidden")
+ .includes("location");
+ let isFullscreen = !!this.window.document.fullscreenElement;
+
+ this.panel.setAttribute("followanchor", !locationBarHidden && !isFullscreen);
+
+ // There are no anchor icons in DOM fullscreen mode, but we would
+ // still like to show the popup notification. To avoid an infinite
+ // loop of showing and hiding, we have to disable followanchor
+ // (which hides the element without an anchor) in fullscreen.
+ this.window.addEventListener(
+ "MozDOMFullscreen:Entered",
+ () => {
+ this.panel.setAttribute("followanchor", "false");
+ },
+ true
+ );
+ this.window.addEventListener(
+ "MozDOMFullscreen:Exited",
+ () => {
+ this.panel.setAttribute("followanchor", !locationBarHidden);
+ },
+ true
+ );
+
+ this.window.addEventListener("activate", this, true);
+ if (this.tabbrowser.tabContainer) {
+ this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true);
+ }
+}
+
+PopupNotifications.prototype = {
+ window: null,
+ panel: null,
+ tabbrowser: null,
+
+ _iconBox: null,
+ set iconBox(iconBox) {
+ // Remove the listeners on the old iconBox, if needed
+ if (this._iconBox) {
+ this._iconBox.removeEventListener("click", this);
+ this._iconBox.removeEventListener("keypress", this);
+ }
+ this._iconBox = iconBox;
+ if (iconBox) {
+ iconBox.addEventListener("click", this);
+ iconBox.addEventListener("keypress", this);
+ }
+ },
+ get iconBox() {
+ return this._iconBox;
+ },
+
+ /**
+ * Retrieve a Notification object associated with the browser/ID pair.
+ *
+ * @param id
+ * The Notification ID to search for.
+ * @param browser
+ * The browser whose notifications should be searched. If null, the
+ * currently selected browser's notifications will be searched.
+ *
+ * @returns the corresponding Notification object, or null if no such
+ * notification exists.
+ */
+ getNotification(id) {
+ return popupNotificationsMap.find(x => x.id == id) || null;
+ },
+
+ /**
+ * Adds a new popup notification.
+ *
+ * @param browser
+ * The <xul:browser> element associated with the notification. Must not
+ * be null.
+ * @param id
+ * A unique ID that identifies the type of notification (e.g.
+ * "geolocation"). Only one notification with a given ID can be visible
+ * at a time. If a notification already exists with the given ID, it
+ * will be replaced.
+ * @param message
+ * A string containing the text to be displayed as the notification header.
+ * The string may optionally contain "<>" as a placeholder which is later
+ * replaced by a host name or an addon name that is formatted to look bold,
+ * in which case the options.name property needs to be specified.
+ * @param anchorID
+ * The ID of the element that should be used as this notification
+ * popup's anchor. May be null, in which case the notification will be
+ * anchored to the iconBox.
+ * @param mainAction
+ * A JavaScript object literal describing the notification button's
+ * action. If present, it must have the following properties:
+ * - label (string): the button's label.
+ * - accessKey (string): the button's accessKey.
+ * - callback (function): a callback to be invoked when the button is
+ * pressed, is passed an object that contains the following fields:
+ * - checkboxChecked: (boolean) If the optional checkbox is checked.
+ * - source: (string): the source of the action that initiated the
+ * callback, either:
+ * - "button" if popup buttons were directly activated, or
+ * - "esc-press" if the user pressed the escape key, or
+ * - "menucommand" if a menu was activated.
+ * - [optional] dismiss (boolean): If this is true, the notification
+ * will be dismissed instead of removed after running the callback.
+ * - [optional] disableHighlight (boolean): If this is true, the button
+ * will not apply the default highlight style.
+ * If null, the notification will have a default "OK" action button
+ * that can be used to dismiss the popup and secondaryActions will be ignored.
+ * @param secondaryActions
+ * An optional JavaScript array describing the notification's alternate
+ * actions. The array should contain objects with the same properties
+ * as mainAction. These are used to populate the notification button's
+ * dropdown menu.
+ * @param options
+ * An options JavaScript object holding additional properties for the
+ * notification. The following properties are currently supported:
+ * persistence: An integer. The notification will not automatically
+ * dismiss for this many page loads.
+ * timeout: A time in milliseconds. The notification will not
+ * automatically dismiss before this time.
+ * persistWhileVisible:
+ * A boolean. If true, a visible notification will always
+ * persist across location changes.
+ * persistent: A boolean. If true, the notification will always
+ * persist even across tab and app changes (but not across
+ * location changes), until the user accepts or rejects
+ * the request. The notification will never be implicitly
+ * dismissed.
+ * dismissed: Whether the notification should be added as a dismissed
+ * notification. Dismissed notifications can be activated
+ * by clicking on their anchorElement.
+ * autofocus: Whether the notification should be autofocused on
+ * showing, stealing focus from any other focused element.
+ * eventCallback:
+ * Callback to be invoked when the notification changes
+ * state. The callback's first argument is a string
+ * identifying the state change:
+ * "dismissed": notification has been dismissed by the
+ * user (e.g. by clicking away or switching
+ * tabs)
+ * "removed": notification has been removed (due to
+ * location change or user action)
+ * "showing": notification is about to be shown
+ * (this can be fired multiple times as
+ * notifications are dismissed and re-shown)
+ * If the callback returns true, the notification
+ * will be dismissed.
+ * "shown": notification has been shown (this can be fired
+ * multiple times as notifications are dismissed
+ * and re-shown)
+ * "swapping": the docshell of the browser that created
+ * the notification is about to be swapped to
+ * another browser. A second parameter contains
+ * the browser that is receiving the docshell,
+ * so that the event callback can transfer stuff
+ * specific to this notification.
+ * If the callback returns true, the notification
+ * will be moved to the new browser.
+ * If the callback isn't implemented, returns false,
+ * or doesn't return any value, the notification
+ * will be removed.
+ * neverShow: Indicate that no popup should be shown for this
+ * notification. Useful for just showing the anchor icon.
+ * removeOnDismissal:
+ * Notifications with this parameter set to true will be
+ * removed when they would have otherwise been dismissed
+ * (i.e. any time the popup is closed due to user
+ * interaction).
+ * hideClose: Indicate that the little close button in the corner of
+ * the panel should be hidden.
+ * checkbox: An object that allows you to add a checkbox and
+ * control its behavior with these fields:
+ * label:
+ * (required) Label to be shown next to the checkbox.
+ * checked:
+ * (optional) Whether the checkbox should be checked
+ * by default. Defaults to false.
+ * checkedState:
+ * (optional) An object that allows you to customize
+ * the notification state when the checkbox is checked.
+ * disableMainAction:
+ * (optional) Whether the mainAction is disabled.
+ * Defaults to false.
+ * warningLabel:
+ * (optional) A (warning) text that is shown below the
+ * checkbox. Pass null to hide.
+ * uncheckedState:
+ * (optional) An object that allows you to customize
+ * the notification state when the checkbox is not checked.
+ * Has the same attributes as checkedState.
+ * popupIconClass:
+ * A string. A class (or space separated list of classes)
+ * that will be applied to the icon in the popup so that
+ * several notifications using the same panel can use
+ * different icons.
+ * popupIconURL:
+ * A string. URL of the image to be displayed in the popup.
+ * Normally specified in CSS using list-style-image and the
+ * .popup-notification-icon[popupid=...] selector.
+ * learnMoreURL:
+ * A string URL. Setting this property will make the
+ * prompt display a "Learn More" link that, when clicked,
+ * opens the URL in a new tab.
+ * displayURI:
+ * The nsIURI of the page the notification came
+ * from. If present, this will be displayed above the message.
+ * If the nsIURI represents a file, the path will be displayed,
+ * otherwise the hostPort will be displayed.
+ * name:
+ * An optional string formatted to look bold and used in the
+ * notifiation description header text. Usually a host name or
+ * addon name.
+ * @returns the Notification object corresponding to the added notification.
+ */
+ show(browser, id, message, anchorID, mainAction, secondaryActions, options) {
+ function isInvalidAction(a) {
+ return (
+ !a || !(typeof a.callback == "function") || !a.label || !a.accessKey
+ );
+ }
+
+ if (!id) {
+ throw new Error("PopupNotifications_show: invalid ID");
+ }
+ if (mainAction && isInvalidAction(mainAction)) {
+ throw new Error("PopupNotifications_show: invalid mainAction");
+ }
+ if (secondaryActions && secondaryActions.some(isInvalidAction)) {
+ throw new Error("PopupNotifications_show: invalid secondaryActions");
+ }
+
+ let notification = new Notification(
+ id,
+ message,
+ anchorID,
+ mainAction,
+ secondaryActions,
+ browser,
+ this,
+ options
+ );
+
+ if (options && options.dismissed) {
+ notification.dismissed = true;
+ }
+
+ let existingNotification = this.getNotification(id);
+ if (existingNotification) {
+ this._remove(existingNotification);
+ }
+
+ popupNotificationsMap.push(notification);
+
+ let isActiveWindow = Services.focus.activeWindow == this.window;
+
+ if (isActiveWindow) {
+ // Autofocus if the notification requests focus.
+ if (options && !options.dismissed && options.autofocus) {
+ this.panel.removeAttribute("noautofocus");
+ } else {
+ this.panel.setAttribute("noautofocus", "true");
+ }
+
+ // show panel now
+ this._update(
+ popupNotificationsMap,
+ new Set([notification.anchorElement]),
+ true
+ );
+ } else {
+ // indicate attention and update the icon if necessary
+ if (!notification.dismissed) {
+ this.window.getAttention();
+ }
+ this._updateAnchorIcons(
+ popupNotificationsMap,
+ this._getAnchorsForNotifications(
+ popupNotificationsMap,
+ notification.anchorElement
+ )
+ );
+ this._notify("backgroundShow");
+ }
+
+ return notification;
+ },
+
+ /**
+ * Returns true if the notification popup is currently being displayed.
+ */
+ get isPanelOpen() {
+ let panelState = this.panel.state;
+
+ return panelState == "showing" || panelState == "open";
+ },
+
+ /**
+ * Removes a Notification.
+ *
+ * @param notification
+ * The Notification object to remove.
+ */
+ remove(notification) {
+ this._remove(notification);
+
+ let notifications = this._getNotificationsForBrowser(notification.browser);
+ this._update(notifications);
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "popuphidden":
+ this._onPopupHidden(aEvent);
+ break;
+ case "activate":
+ if (this.isPanelOpen) {
+ for (let elt of this.panel.children) {
+ elt.notification.timeShown = this.window.performance.now();
+ }
+ break;
+ }
+ // Falls through
+ case "TabSelect":
+ let self = this;
+ // This is where we could detect if the panel is dismissed if the page
+ // was switched. Unfortunately, the user usually has clicked elsewhere
+ // at this point so this value only gets recorded for programmatic
+ // reasons, like the "Learn More" link being clicked and resulting in a
+ // tab switch.
+ this.nextDismissReason = TELEMETRY_STAT_DISMISSAL_LEAVE_PAGE;
+ // setTimeout(..., 0) needed, otherwise openPopup from "activate" event
+ // handler results in the popup being hidden again for some reason...
+ this.window.setTimeout(function () {
+ self._update();
+ }, 0);
+ break;
+ case "click":
+ case "keypress":
+ this._onIconBoxCommand(aEvent);
+ break;
+ }
+ },
+
+ // Utility methods
+
+ _ignoreDismissal: null,
+ _currentAnchorElement: null,
+
+ /**
+ * Gets notifications for the currently selected browser.
+ */
+ get _currentNotifications() {
+ return this.tabbrowser.selectedBrowser
+ ? this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser)
+ : [];
+ },
+
+ _remove(notification) {
+ // This notification may already be removed, in which case let's just fail
+ // silently.
+ var index = popupNotificationsMap.indexOf(notification);
+ if (index == -1) {
+ return;
+ }
+
+ // remove the notification
+ popupNotificationsMap.splice(index, 1);
+ this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED);
+ },
+
+ /**
+ * Dismisses the notification without removing it.
+ */
+ _dismiss(event, telemetryReason) {
+ if (telemetryReason) {
+ this.nextDismissReason = telemetryReason;
+ }
+
+ // An explicitly dismissed persistent notification effectively becomes
+ // non-persistent.
+ if (event && telemetryReason == TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON) {
+ let notificationEl = getNotificationFromElement(event.target);
+ if (notificationEl) {
+ notificationEl.notification.options.persistent = false;
+ }
+ }
+
+ let browser =
+ this.panel.firstElementChild &&
+ this.panel.firstElementChild.notification.browser;
+ this.panel.hidePopup();
+ if (browser) {
+ browser.focus();
+ }
+ },
+
+ /**
+ * Hides the notification popup.
+ */
+ _hidePanel() {
+ if (this.panel.state == "closed") {
+ return Promise.resolve();
+ }
+ if (this._ignoreDismissal) {
+ return this._ignoreDismissal.promise;
+ }
+ let deferred = PromiseUtils.defer();
+ this._ignoreDismissal = deferred;
+ this.panel.hidePopup();
+ return deferred.promise;
+ },
+
+ /**
+ * Removes all notifications from the notification popup.
+ */
+ _clearPanel() {
+ let popupnotification;
+ while ((popupnotification = this.panel.lastElementChild)) {
+ this.panel.removeChild(popupnotification);
+
+ // If this notification was provided by the chrome document rather than
+ // created ad hoc, move it back to where we got it from.
+ let originalParent = gNotificationParents.get(popupnotification);
+ if (originalParent) {
+ popupnotification.notification = null;
+
+ // Re-hide the notification such that it isn't rendered in the chrome
+ // document. _refreshPanel will unhide it again when needed.
+ popupnotification.hidden = true;
+
+ originalParent.appendChild(popupnotification);
+ }
+ }
+ },
+
+ /**
+ * Formats the notification description message before we display it
+ * and splits it into three parts if the message contains "<>" as
+ * placeholder.
+ *
+ * param notification
+ * The Notification object which contains the message to format.
+ *
+ * @returns a Javascript object that has the following properties:
+ * start: A start label string containing the first part of the message.
+ * It may contain the whole string if the description message
+ * does not have "<>" as a placeholder. For example, local
+ * file URIs with description messages that don't display hostnames.
+ * name: A string that is formatted to look bold. It replaces the
+ * placeholder with the options.name property from the notification
+ * object which is usually an addon name or a host name.
+ * end: The last part of the description message.
+ */
+ _formatDescriptionMessage(n) {
+ let text = {};
+ let array = n.message.split("<>");
+ text.start = array[0] || "";
+ text.name = n.options.name || "";
+ text.end = array[1] || "";
+ return text;
+ },
+
+ _refreshPanel(notificationsToShow) {
+ this._clearPanel();
+
+ notificationsToShow.forEach(function (n) {
+ let doc = this.window.document;
+
+ // Append "-notification" to the ID to try to avoid ID conflicts with other stuff
+ // in the document.
+ let popupnotificationID = n.id + "-notification";
+
+ // If the chrome document provides a popupnotification with this id, use
+ // that. Otherwise create it ad-hoc.
+ let popupnotification = doc.getElementById(popupnotificationID);
+ if (popupnotification) {
+ gNotificationParents.set(
+ popupnotification,
+ popupnotification.parentNode
+ );
+ } else {
+ popupnotification = doc.createXULElement("popupnotification");
+ }
+
+ // Create the notification description element.
+ let desc = this._formatDescriptionMessage(n);
+ popupnotification.setAttribute("label", desc.start);
+ popupnotification.setAttribute("name", desc.name);
+ popupnotification.setAttribute("endlabel", desc.end);
+
+ popupnotification.setAttribute("id", popupnotificationID);
+ popupnotification.setAttribute("popupid", n.id);
+ popupnotification.setAttribute(
+ "oncommand",
+ "PopupNotifications._onCommand(event);"
+ );
+ popupnotification.setAttribute(
+ "closebuttoncommand",
+ `PopupNotifications._dismiss(event, ${TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON});`
+ );
+
+ if (n.mainAction) {
+ popupnotification.setAttribute("buttonlabel", n.mainAction.label);
+ popupnotification.setAttribute(
+ "buttonaccesskey",
+ n.mainAction.accessKey
+ );
+ popupnotification.setAttribute(
+ "buttonhighlight",
+ !n.mainAction.disableHighlight
+ );
+ popupnotification.setAttribute(
+ "buttoncommand",
+ "PopupNotifications._onButtonEvent(event, 'buttoncommand');"
+ );
+ popupnotification.setAttribute(
+ "dropmarkerpopupshown",
+ "PopupNotifications._onButtonEvent(event, 'dropmarkerpopupshown');"
+ );
+ popupnotification.setAttribute(
+ "learnmoreclick",
+ "PopupNotifications._onButtonEvent(event, 'learnmoreclick');"
+ );
+ popupnotification.setAttribute(
+ "menucommand",
+ "PopupNotifications._onMenuCommand(event);"
+ );
+ } else {
+ // Enable the default button to let the user close the popup if the close button is hidden
+ popupnotification.setAttribute(
+ "buttoncommand",
+ "PopupNotifications._onButtonEvent(event, 'buttoncommand');"
+ );
+ popupnotification.setAttribute("buttonhighlight", "true");
+ popupnotification.removeAttribute("buttonlabel");
+ popupnotification.removeAttribute("buttonaccesskey");
+ popupnotification.removeAttribute("dropmarkerpopupshown");
+ popupnotification.removeAttribute("learnmoreclick");
+ popupnotification.removeAttribute("menucommand");
+ }
+
+ if (n.options.popupIconClass) {
+ let classes = "popup-notification-icon " + n.options.popupIconClass;
+ popupnotification.setAttribute("iconclass", classes);
+ }
+ if (n.options.popupIconURL) {
+ popupnotification.setAttribute("icon", n.options.popupIconURL);
+ }
+
+ if (n.options.learnMoreURL) {
+ popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL);
+ } else {
+ popupnotification.removeAttribute("learnmoreurl");
+ }
+
+ if (n.options.displayURI) {
+ let uri;
+ try {
+ if (n.options.displayURI instanceof Ci.nsIFileURL) {
+ uri = n.options.displayURI.pathQueryRef;
+ } else {
+ try {
+ uri = n.options.displayURI.hostPort;
+ } catch (e) {
+ uri = n.options.displayURI.spec;
+ }
+ }
+ popupnotification.setAttribute("origin", uri);
+ } catch (e) {
+ console.error(e);
+ popupnotification.removeAttribute("origin");
+ }
+ } else {
+ popupnotification.removeAttribute("origin");
+ }
+
+ if (n.options.hideClose) {
+ popupnotification.setAttribute("closebuttonhidden", "true");
+ }
+
+ popupnotification.notification = n;
+ let menuitems = [];
+
+ if (n.mainAction && n.secondaryActions && n.secondaryActions.length > 0) {
+ let telemetryStatId = TELEMETRY_STAT_ACTION_2;
+
+ let secondaryAction = n.secondaryActions[0];
+ popupnotification.setAttribute(
+ "secondarybuttonlabel",
+ secondaryAction.label
+ );
+ popupnotification.setAttribute(
+ "secondarybuttonaccesskey",
+ secondaryAction.accessKey
+ );
+ popupnotification.setAttribute(
+ "secondarybuttoncommand",
+ "PopupNotifications._onButtonEvent(event, 'secondarybuttoncommand');"
+ );
+ popupnotification.removeAttribute("secondarybuttonhidden");
+
+ for (let i = 1; i < n.secondaryActions.length; i++) {
+ let action = n.secondaryActions[i];
+ let item = doc.createXULElement("menuitem");
+ item.setAttribute("label", action.label);
+ item.setAttribute("accesskey", action.accessKey);
+ item.notification = n;
+ item.action = action;
+
+ menuitems.push(item);
+
+ // We can only record a limited number of actions in telemetry. If
+ // there are more, the latest are all recorded in the last bucket.
+ item.action.telemetryStatId = telemetryStatId;
+ if (telemetryStatId < TELEMETRY_STAT_ACTION_LAST) {
+ telemetryStatId++;
+ }
+ }
+
+ if (n.secondaryActions.length < 2) {
+ popupnotification.setAttribute("dropmarkerhidden", "true");
+ }
+ } else {
+ popupnotification.setAttribute("secondarybuttonhidden", "true");
+ popupnotification.setAttribute("dropmarkerhidden", "true");
+ }
+
+ let checkbox = n.options.checkbox;
+ if (checkbox && checkbox.label) {
+ let checked =
+ n._checkboxChecked != null ? n._checkboxChecked : !!checkbox.checked;
+
+ popupnotification.checkboxState = {
+ checked,
+ label: checkbox.label,
+ };
+
+ if (checked) {
+ this._setNotificationUIState(
+ popupnotification,
+ checkbox.checkedState
+ );
+ } else {
+ this._setNotificationUIState(
+ popupnotification,
+ checkbox.uncheckedState
+ );
+ }
+ } else {
+ popupnotification.setAttribute("checkboxhidden", "true");
+ popupnotification.setAttribute("warninghidden", "true");
+ }
+
+ this.panel.appendChild(popupnotification);
+
+ // The popupnotification may be hidden if we got it from the chrome
+ // document rather than creating it ad hoc.
+ popupnotification.show();
+
+ popupnotification.menupopup.textContent = "";
+ popupnotification.menupopup.append(...menuitems);
+ }, this);
+ },
+
+ _setNotificationUIState(notification, state = {}) {
+ if (
+ state.disableMainAction ||
+ notification.hasAttribute("invalidselection")
+ ) {
+ notification.setAttribute("mainactiondisabled", "true");
+ } else {
+ notification.removeAttribute("mainactiondisabled");
+ }
+ if (state.warningLabel) {
+ notification.setAttribute("warninglabel", state.warningLabel);
+ notification.removeAttribute("warninghidden");
+ } else {
+ notification.setAttribute("warninghidden", "true");
+ }
+ },
+
+ _showPanel(notificationsToShow, anchorElement) {
+ this.panel.hidden = false;
+
+ notificationsToShow = notificationsToShow.filter(n => {
+ if (anchorElement != n.anchorElement) {
+ return false;
+ }
+
+ let dismiss = this._fireCallback(n, NOTIFICATION_EVENT_SHOWING);
+ if (dismiss) {
+ n.dismissed = true;
+ }
+ return !dismiss;
+ });
+ if (!notificationsToShow.length) {
+ return;
+ }
+ let notificationIds = notificationsToShow.map(n => n.id);
+
+ this._refreshPanel(notificationsToShow);
+
+ function isNullOrHidden(elem) {
+ if (!elem) {
+ return true;
+ }
+
+ let anchorRect = elem.getBoundingClientRect();
+ return anchorRect.width == 0 && anchorRect.height == 0;
+ }
+
+ // If the anchor element is hidden or null, fall back to the identity icon.
+ if (isNullOrHidden(anchorElement)) {
+ anchorElement = this.window.document.getElementById("identity-icon");
+
+ // If the identity icon is not available in this window, or maybe the
+ // entire location bar is hidden for any reason, use the tab as the
+ // anchor. We only ever show notifications for the current browser, so we
+ // can just use the current tab.
+ if (isNullOrHidden(anchorElement)) {
+ anchorElement = this.tabbrowser.selectedTab;
+
+ // If we're in an entirely chromeless environment, set the anchorElement
+ // to null and let openPopup show the notification at (0,0) later.
+ if (isNullOrHidden(anchorElement)) {
+ anchorElement = null;
+ }
+ }
+ }
+
+ if (this.isPanelOpen && this._currentAnchorElement == anchorElement) {
+ notificationsToShow.forEach(function (n) {
+ this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
+ }, this);
+
+ // Make sure we update the noautohide attribute on the panel, in case it changed.
+ if (notificationsToShow.some(n => n.options.persistent)) {
+ this.panel.setAttribute("noautohide", "true");
+ } else {
+ this.panel.removeAttribute("noautohide");
+ }
+
+ // Let tests know that the panel was updated and what notifications it was
+ // updated with so that tests can wait for the correct notifications to be
+ // added.
+ let event = new this.window.CustomEvent("PanelUpdated", {
+ detail: notificationIds,
+ });
+ this.panel.dispatchEvent(event);
+ return;
+ }
+
+ // If the panel is already open but we're changing anchors, we need to hide
+ // it first. Otherwise it can appear in the wrong spot. (_hidePanel is
+ // safe to call even if the panel is already hidden.)
+ this._hidePanel().then(() => {
+ this._currentAnchorElement = anchorElement;
+
+ if (notificationsToShow.some(n => n.options.persistent)) {
+ this.panel.setAttribute("noautohide", "true");
+ } else {
+ this.panel.removeAttribute("noautohide");
+ }
+
+ notificationsToShow.forEach(function (n) {
+ // Record that the notification was actually displayed on screen.
+ // Notifications that were opened a second time or that were originally
+ // shown with "options.dismissed" will be recorded in a separate bucket.
+ n._recordTelemetryStat(TELEMETRY_STAT_OFFERED);
+ // Remember the time the notification was shown for the security delay.
+ n.timeShown = this.window.performance.now();
+ }, this);
+
+ // Unless the panel closing is triggered by a specific known code path,
+ // the next reason will be that the user clicked elsewhere.
+ this.nextDismissReason = TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE;
+
+ let target = this.panel;
+ if (target.parentNode) {
+ // NOTIFICATION_EVENT_SHOWN should be fired for the panel before
+ // anyone listening for popupshown on the panel gets run. Otherwise,
+ // the panel will not be initialized when the popupshown event
+ // listeners run.
+ // By targeting the panel's parent and using a capturing listener, we
+ // can have our listener called before others waiting for the panel to
+ // be shown (which probably expect the panel to be fully initialized)
+ target = target.parentNode;
+ }
+ if (this._popupshownListener) {
+ target.removeEventListener(
+ "popupshown",
+ this._popupshownListener,
+ true
+ );
+ }
+ this._popupshownListener = function (e) {
+ target.removeEventListener(
+ "popupshown",
+ this._popupshownListener,
+ true
+ );
+ this._popupshownListener = null;
+
+ notificationsToShow.forEach(function (n) {
+ this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
+ }, this);
+ // These notifications are used by tests to know when all the processing
+ // required to display the panel has happened.
+ this.panel.dispatchEvent(new this.window.CustomEvent("Shown"));
+ let event = new this.window.CustomEvent("PanelUpdated", {
+ detail: notificationIds,
+ });
+ this.panel.dispatchEvent(event);
+ };
+ this._popupshownListener = this._popupshownListener.bind(this);
+ target.addEventListener("popupshown", this._popupshownListener, true);
+
+ this.panel.openPopup(anchorElement, "after_end", 0, 0, true);
+ });
+ },
+
+ /**
+ * Updates the notification state in response to window activation or tab
+ * selection changes.
+ *
+ * @param notifications an array of Notification instances. if null,
+ * notifications will be retrieved off the current
+ * browser tab
+ * @param anchors is a XUL element or a Set of XUL elements that the
+ * notifications panel(s) will be anchored to.
+ * @param dismissShowing if true, dismiss any currently visible notifications
+ * if there are no notifications to show. Otherwise,
+ * currently displayed notifications will be left alone.
+ */
+ _update(notifications, anchors = new Set(), dismissShowing = false) {
+ if (ChromeUtils.getClassName(anchors) == "XULElement") {
+ anchors = new Set([anchors]);
+ }
+
+ if (!notifications) {
+ notifications = this._currentNotifications;
+ }
+
+ let haveNotifications = notifications.length > 0;
+ if (!anchors.size && haveNotifications) {
+ anchors = this._getAnchorsForNotifications(notifications);
+ }
+
+ let useIconBox = !!this.iconBox;
+ if (useIconBox && anchors.size) {
+ for (let anchor of anchors) {
+ if (anchor.parentNode == this.iconBox) {
+ continue;
+ }
+ useIconBox = false;
+ break;
+ }
+ }
+
+ // Filter out notifications that have been dismissed, unless they are
+ // persistent. Also check if we should not show any notification.
+ let notificationsToShow = [];
+ if (!this._suppress) {
+ notificationsToShow = notifications.filter(
+ n => (!n.dismissed || n.options.persistent) && !n.options.neverShow
+ );
+ }
+
+ if (useIconBox) {
+ // Hide icons of the previous tab.
+ this._hideIcons();
+ }
+
+ if (haveNotifications) {
+ // Also filter out notifications that are for a different anchor.
+ notificationsToShow = notificationsToShow.filter(function (n) {
+ return anchors.has(n.anchorElement);
+ });
+
+ if (useIconBox) {
+ this._showIcons(notifications);
+ this.iconBox.hidden = false;
+ // Make sure that panels can only be attached to anchors of shown
+ // notifications inside an iconBox.
+ anchors = this._getAnchorsForNotifications(notificationsToShow);
+ } else if (anchors.size) {
+ this._updateAnchorIcons(notifications, anchors);
+ }
+ }
+
+ if (notificationsToShow.length > 0) {
+ let anchorElement = anchors.values().next().value;
+ if (anchorElement) {
+ this._showPanel(notificationsToShow, anchorElement);
+ }
+
+ // Setup a capturing event listener on the whole window to catch the
+ // escape key while persistent notifications are visible.
+ this.window.addEventListener(
+ "keypress",
+ this._handleWindowKeyPress,
+ true
+ );
+ } else {
+ // Notify observers that we're not showing the popup (useful for testing)
+ this._notify("updateNotShowing");
+
+ // Close the panel if there are no notifications to show.
+ // When called from PopupNotifications.show() we should never close the
+ // panel, however. It may just be adding a dismissed notification, in
+ // which case we want to continue showing any existing notifications.
+ if (!dismissShowing) {
+ this._dismiss();
+ }
+
+ // Only hide the iconBox if we actually have no notifications (as opposed
+ // to not having any showable notifications)
+ if (!haveNotifications) {
+ if (useIconBox) {
+ this.iconBox.hidden = true;
+ } else if (anchors.size) {
+ for (let anchorElement of anchors) {
+ anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
+ }
+ }
+ }
+
+ // Stop listening to keyboard events for notifications.
+ this.window.removeEventListener(
+ "keypress",
+ this._handleWindowKeyPress,
+ true
+ );
+ }
+ },
+
+ _updateAnchorIcons(notifications, anchorElements) {
+ for (let anchorElement of anchorElements) {
+ anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
+ // Use the anchorID as a class along with the default icon class as a
+ // fallback if anchorID is not defined in CSS. We always use the first
+ // notifications icon, so in the case of multiple notifications we'll
+ // only use the default icon.
+ if (anchorElement.classList.contains("notification-anchor-icon")) {
+ // remove previous icon classes
+ let className = anchorElement.className.replace(
+ /([-\w]+-notification-icon\s?)/g,
+ ""
+ );
+ if (notifications.length > 0) {
+ // Find the first notification this anchor used for.
+ let notification = notifications[0];
+ for (let n of notifications) {
+ if (n.anchorElement == anchorElement) {
+ notification = n;
+ break;
+ }
+ }
+ // With this notification we can better approximate the most fitting
+ // style.
+ className = notification.anchorID + " " + className;
+ }
+ anchorElement.className = className;
+ }
+ }
+ },
+
+ _showIcons(aCurrentNotifications) {
+ for (let notification of aCurrentNotifications) {
+ let anchorElm = notification.anchorElement;
+ if (anchorElm) {
+ anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
+
+ if (notification.options.extraAttr) {
+ anchorElm.setAttribute("extraAttr", notification.options.extraAttr);
+ }
+ }
+ }
+ },
+
+ _hideIcons() {
+ let icons = this.iconBox.querySelectorAll(ICON_SELECTOR);
+ for (let icon of icons) {
+ icon.removeAttribute(ICON_ATTRIBUTE_SHOWING);
+ }
+ },
+
+ _getNotificationsForBrowser(browser) {
+ return popupNotificationsMap;
+ },
+ _setNotificationsForBrowser(browser, notifications) {
+ popupNotificationsMap = notifications;
+ return notifications;
+ },
+
+ _getAnchorsForNotifications(notifications, defaultAnchor) {
+ let anchors = new Set();
+ for (let notification of notifications) {
+ if (notification.anchorElement) {
+ anchors.add(notification.anchorElement);
+ }
+ }
+ if (defaultAnchor && !anchors.size) {
+ anchors.add(defaultAnchor);
+ }
+ return anchors;
+ },
+
+ _onIconBoxCommand(event) {
+ // Left click, space or enter only
+ let type = event.type;
+ if (type == "click" && event.button != 0) {
+ return;
+ }
+
+ if (
+ type == "keypress" &&
+ !(
+ event.charCode == event.DOM_VK_SPACE ||
+ event.keyCode == event.DOM_VK_RETURN
+ )
+ ) {
+ return;
+ }
+
+ if (this._currentNotifications.length == 0) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ // Get the anchor that is the immediate child of the icon box
+ let anchor = event.target;
+ while (anchor && anchor.parentNode != this.iconBox) {
+ anchor = anchor.parentNode;
+ }
+
+ if (!anchor) {
+ return;
+ }
+
+ // If the panel is not closed, and the anchor is different, immediately mark all
+ // active notifications for the previous anchor as dismissed
+ if (this.panel.state != "closed" && anchor != this._currentAnchorElement) {
+ this._dismissOrRemoveCurrentNotifications();
+ }
+
+ // Avoid reshowing notifications that are already shown and have not been dismissed.
+ if (this.panel.state == "closed" || anchor != this._currentAnchorElement) {
+ // As soon as the panel is shown, focus the first element in the selected notification.
+ this.panel.addEventListener(
+ "popupshown",
+ () =>
+ this.window.document.commandDispatcher.advanceFocusIntoSubtree(
+ this.panel
+ ),
+ { once: true }
+ );
+
+ this._reshowNotifications(anchor);
+ } else {
+ // Focus the first element in the selected notification.
+ this.window.document.commandDispatcher.advanceFocusIntoSubtree(
+ this.panel
+ );
+ }
+ },
+
+ _reshowNotifications(anchor, browser) {
+ // Mark notifications anchored to this anchor as un-dismissed
+ browser = browser || this.tabbrowser.selectedBrowser;
+ let notifications = this._getNotificationsForBrowser(browser);
+ notifications.forEach(function (n) {
+ if (n.anchorElement == anchor) {
+ n.dismissed = false;
+ }
+ });
+
+ // ...and then show them.
+ this._update(notifications, anchor);
+ },
+
+ _swapBrowserNotifications(ourBrowser, otherBrowser) {
+ // When swapping browser docshells (e.g. dragging tab to new window) we need
+ // to update our notification map.
+
+ let ourNotifications = this._getNotificationsForBrowser(ourBrowser);
+ let other = otherBrowser.ownerGlobal.PopupNotifications;
+ if (!other) {
+ if (ourNotifications.length > 0) {
+ console.error(
+ "unable to swap notifications: otherBrowser doesn't support notifications"
+ );
+ }
+ return;
+ }
+ let otherNotifications = other._getNotificationsForBrowser(otherBrowser);
+ if (ourNotifications.length < 1 && otherNotifications.length < 1) {
+ // No notification to swap.
+ return;
+ }
+
+ otherNotifications = otherNotifications.filter(n => {
+ if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, ourBrowser)) {
+ n.browser = ourBrowser;
+ n.owner = this;
+ return true;
+ }
+ other._fireCallback(n, NOTIFICATION_EVENT_REMOVED);
+ return false;
+ });
+
+ ourNotifications = ourNotifications.filter(n => {
+ if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, otherBrowser)) {
+ n.browser = otherBrowser;
+ n.owner = other;
+ return true;
+ }
+ this._fireCallback(n, NOTIFICATION_EVENT_REMOVED);
+ return false;
+ });
+
+ this._setNotificationsForBrowser(otherBrowser, ourNotifications);
+ other._setNotificationsForBrowser(ourBrowser, otherNotifications);
+
+ if (otherNotifications.length > 0) {
+ this._update(otherNotifications);
+ }
+ if (ourNotifications.length > 0) {
+ other._update(ourNotifications);
+ }
+ },
+
+ _fireCallback(n, event, ...args) {
+ try {
+ if (n.options.eventCallback) {
+ return n.options.eventCallback.call(n, event, ...args);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ return undefined;
+ },
+
+ _onPopupHidden(event) {
+ if (event.target != this.panel) {
+ return;
+ }
+
+ // We may have removed the "noautofocus" attribute before showing the panel
+ // if the notification specified it wants to autofocus on first show.
+ // When the panel is closed, we have to restore the attribute to its default
+ // value, so we don't autofocus it if it's subsequently opened from a different code path.
+ this.panel.setAttribute("noautofocus", "true");
+
+ // Handle the case where the panel was closed programmatically.
+ if (this._ignoreDismissal) {
+ this._ignoreDismissal.resolve();
+ this._ignoreDismissal = null;
+ return;
+ }
+
+ this._dismissOrRemoveCurrentNotifications();
+
+ this._clearPanel();
+
+ this._update();
+ },
+
+ _dismissOrRemoveCurrentNotifications() {
+ let browser =
+ this.panel.firstElementChild &&
+ this.panel.firstElementChild.notification.browser;
+ if (!browser) {
+ return;
+ }
+
+ let notifications = this._getNotificationsForBrowser(browser);
+ // Mark notifications as dismissed and call dismissal callbacks
+ for (let nEl of this.panel.children) {
+ let notificationObj = nEl.notification;
+ // Never call a dismissal handler on a notification that's been removed.
+ if (!notifications.includes(notificationObj)) {
+ return;
+ }
+
+ // Record the time of the first notification dismissal if the main action
+ // was not triggered in the meantime.
+ let timeSinceShown =
+ this.window.performance.now() - notificationObj.timeShown;
+ if (
+ !notificationObj.wasDismissed &&
+ !notificationObj.recordedTelemetryMainAction
+ ) {
+ notificationObj._recordTelemetry(
+ "POPUP_NOTIFICATION_DISMISSAL_MS",
+ timeSinceShown
+ );
+ }
+ notificationObj._recordTelemetryStat(this.nextDismissReason);
+
+ // Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED
+ // if the notification is removed.
+ if (notificationObj.options.removeOnDismissal) {
+ this._remove(notificationObj);
+ } else {
+ notificationObj.dismissed = true;
+ this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED);
+ }
+ }
+ },
+
+ _onCheckboxCommand(event) {
+ let notificationEl = getNotificationFromElement(event.target);
+ let checked = notificationEl.checkbox.checked;
+ let notification = notificationEl.notification;
+
+ // Save checkbox state to be able to persist it when re-opening the doorhanger.
+ notification._checkboxChecked = checked;
+
+ if (checked) {
+ this._setNotificationUIState(
+ notificationEl,
+ notification.options.checkbox.checkedState
+ );
+ } else {
+ this._setNotificationUIState(
+ notificationEl,
+ notification.options.checkbox.uncheckedState
+ );
+ }
+ event.stopPropagation();
+ },
+
+ _onCommand(event) {
+ // Ignore events from buttons as they are submitting and so don't need checks
+ if (event.target.localName == "button") {
+ return;
+ }
+ let notificationEl = getNotificationFromElement(event.target);
+ this._setNotificationUIState(notificationEl);
+ },
+
+ _onButtonEvent(event, type, source = "button", notificationEl = null) {
+ if (!notificationEl) {
+ notificationEl = getNotificationFromElement(event.target);
+ }
+
+ if (!notificationEl) {
+ throw new Error(
+ "PopupNotifications._onButtonEvent: couldn't find notification element"
+ );
+ }
+
+ if (!notificationEl.notification) {
+ throw new Error(
+ "PopupNotifications._onButtonEvent: couldn't find notification"
+ );
+ }
+
+ let notification = notificationEl.notification;
+
+ if (type == "dropmarkerpopupshown") {
+ notification._recordTelemetryStat(TELEMETRY_STAT_OPEN_SUBMENU);
+ return;
+ }
+
+ if (type == "learnmoreclick") {
+ notification._recordTelemetryStat(TELEMETRY_STAT_LEARN_MORE);
+ return;
+ }
+
+ if (type == "buttoncommand") {
+ // Record the total timing of the main action since the notification was
+ // created, even if the notification was dismissed in the meantime.
+ let timeSinceCreated =
+ this.window.performance.now() - notification.timeCreated;
+ if (!notification.recordedTelemetryMainAction) {
+ notification.recordedTelemetryMainAction = true;
+ notification._recordTelemetry(
+ "POPUP_NOTIFICATION_MAIN_ACTION_MS",
+ timeSinceCreated
+ );
+ }
+ }
+
+ if (type == "buttoncommand" || type == "secondarybuttoncommand") {
+ if (Services.focus.activeWindow != this.window) {
+ Services.console.logStringMessage(
+ "PopupNotifications._onButtonEvent: " +
+ "Button click happened before the window was focused"
+ );
+ this.window.focus();
+ return;
+ }
+
+ let timeSinceShown =
+ this.window.performance.now() - notification.timeShown;
+ if (timeSinceShown < this.buttonDelay) {
+ Services.console.logStringMessage(
+ "PopupNotifications._onButtonEvent: " +
+ "Button click happened before the security delay: " +
+ timeSinceShown +
+ "ms"
+ );
+ return;
+ }
+ }
+
+ let action = notification.mainAction;
+ let telemetryStatId = TELEMETRY_STAT_ACTION_1;
+
+ if (type == "secondarybuttoncommand") {
+ action = notification.secondaryActions[0];
+ telemetryStatId = TELEMETRY_STAT_ACTION_2;
+ }
+
+ notification._recordTelemetryStat(telemetryStatId);
+
+ if (action) {
+ try {
+ action.callback.call(undefined, {
+ checkboxChecked: notificationEl.checkbox.checked,
+ source,
+ });
+ } catch (error) {
+ console.error(error);
+ }
+
+ if (action.dismiss) {
+ this._dismiss();
+ return;
+ }
+ }
+
+ this._remove(notification);
+ this._update();
+ },
+
+ _onMenuCommand(event) {
+ let target = event.target;
+ if (!target.action || !target.notification) {
+ throw new Error(
+ "menucommand target has no associated action/notification"
+ );
+ }
+
+ let notificationEl = getNotificationFromElement(target);
+ event.stopPropagation();
+
+ target.notification._recordTelemetryStat(target.action.telemetryStatId);
+
+ try {
+ target.action.callback.call(undefined, {
+ checkboxChecked: notificationEl.checkbox.checked,
+ source: "menucommand",
+ });
+ } catch (error) {
+ console.error(error);
+ }
+
+ if (target.action.dismiss) {
+ this._dismiss();
+ return;
+ }
+
+ this._remove(target.notification);
+ this._update();
+ },
+
+ _notify(topic) {
+ Services.obs.notifyObservers(null, "PopupNotifications-" + topic);
+ },
+};
diff --git a/comm/mail/modules/MailConsts.jsm b/comm/mail/modules/MailConsts.jsm
new file mode 100644
index 0000000000..38c219702a
--- /dev/null
+++ b/comm/mail/modules/MailConsts.jsm
@@ -0,0 +1,39 @@
+/* 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 is a place to store constants and enumerations that are needed only by
+ * JavaScript code, especially component/module code.
+ */
+
+var EXPORTED_SYMBOLS = ["MailConsts"];
+
+var MailConsts = {
+ /**
+ * Determine how to open a message when it is double-clicked or selected and
+ * Enter pressed. The preference to set this is mail.openMessageBehavior.
+ */
+ OpenMessageBehavior: {
+ /**
+ * Open the message in a new window. If multiple messages are selected, all
+ * of them are opened in separate windows.
+ */
+ NEW_WINDOW: 0,
+
+ /**
+ * Open the message in an existing window. If multiple messages are
+ * selected, the fallback is to "new window" behavior. If no standalone
+ * windows are open, the message is opened in a new standalone window.
+ */
+ EXISTING_WINDOW: 1,
+
+ /**
+ * Open the message in a new tab. If multiple messages are selected, all of
+ * them are opened as tabs, with the last tab in the foreground and all the
+ * rest in the background. If no 3-pane window is open, the message is
+ * opened in a new standalone window.
+ */
+ NEW_TAB: 2,
+ },
+};
diff --git a/comm/mail/modules/MailE10SUtils.jsm b/comm/mail/modules/MailE10SUtils.jsm
new file mode 100644
index 0000000000..224dd50a71
--- /dev/null
+++ b/comm/mail/modules/MailE10SUtils.jsm
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["MailE10SUtils"];
+
+const { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+);
+const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+var MailE10SUtils = {
+ /**
+ * Loads about:blank in `browser` without switching remoteness. about:blank
+ * can load in a local browser or a remote browser, and `loadURI` will make
+ * it load in a remote browser even if you don't want it to.
+ *
+ * @param {nsIBrowser} browser
+ */
+ loadAboutBlank(browser) {
+ if (!browser.currentURI || browser.currentURI.spec == "about:blank") {
+ return;
+ }
+ browser.loadURI(Services.io.newURI("about:blank"), {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ remoteTypeOverride: browser.remoteType,
+ });
+ },
+
+ /**
+ * Loads `uri` in `browser`, changing to a remote/local browser if necessary.
+ *
+ * @see `nsIWebNavigation.loadURI`
+ *
+ * @param {nsIBrowser} browser
+ * @param {string} uri
+ * @param {object} params
+ */
+ loadURI(browser, uri, params = {}) {
+ let multiProcess = browser.ownerGlobal.docShell.QueryInterface(
+ Ci.nsILoadContext
+ ).useRemoteTabs;
+ let remoteSubframes = browser.ownerGlobal.docShell.QueryInterface(
+ Ci.nsILoadContext
+ ).useRemoteSubframes;
+
+ let isRemote = browser.getAttribute("remote") == "true";
+ let remoteType = E10SUtils.getRemoteTypeForURI(
+ uri,
+ multiProcess,
+ remoteSubframes
+ );
+ let shouldBeRemote = remoteType !== E10SUtils.NOT_REMOTE;
+
+ if (shouldBeRemote != isRemote) {
+ this.changeRemoteness(browser, remoteType);
+ }
+
+ params.triggeringPrincipal =
+ params.triggeringPrincipal ||
+ Services.scriptSecurityManager.getSystemPrincipal();
+ browser.fixupAndLoadURIString(uri, params);
+ },
+
+ /**
+ * Force `browser` to be a remote/local browser.
+ *
+ * @see E10SUtils.jsm for remote types.
+ *
+ * @param {nsIBrowser} browser - the browser to enforce the remoteness of.
+ * @param {string} remoteType - the remoteness to enforce.
+ * @returns {boolean} true if any change happened on the browser (which would
+ * not be the case if its remoteness is already in the correct state).
+ */
+ changeRemoteness(browser, remoteType) {
+ if (browser.remoteType == remoteType) {
+ return false;
+ }
+
+ browser.destroy();
+
+ if (remoteType) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", remoteType);
+ } else {
+ browser.setAttribute("remote", "false");
+ browser.removeAttribute("remoteType");
+ }
+
+ browser.changeRemoteness({ remoteType });
+ browser.construct();
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+
+ return true;
+ },
+};
diff --git a/comm/mail/modules/MailMigrator.jsm b/comm/mail/modules/MailMigrator.jsm
new file mode 100644
index 0000000000..129ff5e835
--- /dev/null
+++ b/comm/mail/modules/MailMigrator.jsm
@@ -0,0 +1,1200 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 module handles migrating mail-specific preferences, etc. Migration has
+ * traditionally been a part of messenger.js, but separating the code out into
+ * a module makes unit testing much easier.
+ */
+
+const EXPORTED_SYMBOLS = ["MailMigrator", "MigrationTasks"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ migrateToolbarForSpace: "resource:///modules/ToolbarMigration.sys.mjs",
+ clearXULToolbarState: "resource:///modules/ToolbarMigration.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ FolderUtils: "resource:///modules/FolderUtils.jsm",
+ migrateMailnews: "resource:///modules/MailnewsMigrator.jsm",
+});
+
+var MailMigrator = {
+ _migrateXULStoreForDocument(fromURL, toURL) {
+ Array.from(Services.xulStore.getIDsEnumerator(fromURL)).forEach(id => {
+ Array.from(Services.xulStore.getAttributeEnumerator(fromURL, id)).forEach(
+ attr => {
+ let value = Services.xulStore.getValue(fromURL, id, attr);
+ Services.xulStore.setValue(toURL, id, attr, value);
+ }
+ );
+ });
+ },
+
+ _migrateXULStoreForElement(url, fromID, toID) {
+ Array.from(Services.xulStore.getAttributeEnumerator(url, fromID)).forEach(
+ attr => {
+ let value = Services.xulStore.getValue(url, fromID, attr);
+ Services.xulStore.setValue(url, toID, attr, value);
+ Services.xulStore.removeValue(url, fromID, attr);
+ }
+ );
+ },
+
+ /* eslint-disable complexity */
+ /**
+ * Determine if the UI has been upgraded in a way that requires us to reset
+ * some user configuration. If so, performs the resets.
+ */
+ _migrateUI() {
+ // The code for this was ported from
+ // mozilla/browser/components/nsBrowserGlue.js
+ const UI_VERSION = 40;
+ const MESSENGER_DOCURL = "chrome://messenger/content/messenger.xhtml";
+ const MESSENGERCOMPOSE_DOCURL =
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml";
+ const UI_VERSION_PREF = "mail.ui-rdf.version";
+ let currentUIVersion = Services.prefs.getIntPref(UI_VERSION_PREF, 0);
+
+ if (currentUIVersion >= UI_VERSION) {
+ return;
+ }
+
+ let xulStore = Services.xulStore;
+
+ let newProfile = currentUIVersion == 0;
+ if (newProfile) {
+ // Collapse the main menu by default if the override pref
+ // "mail.main_menu.collapse_by_default" is set to true.
+ if (Services.prefs.getBoolPref("mail.main_menu.collapse_by_default")) {
+ xulStore.setValue(
+ MESSENGER_DOCURL,
+ "toolbar-menubar",
+ "autohide",
+ "true"
+ );
+ }
+
+ // Set to current version to skip all the migration below.
+ currentUIVersion = UI_VERSION;
+ }
+
+ try {
+ // UI versions below 5 could only exist in an old profile with localstore.rdf
+ // file used for the XUL store. Since TB55 this file is no longer read.
+ // Since UI version 5, the xulstore.json file is being used, so we only
+ // support those versions here, see bug 1371898.
+
+ // In UI version 6, we move the otherActionsButton button to the
+ // header-view-toolbar.
+ if (currentUIVersion < 6) {
+ let cs = xulStore.getValue(
+ MESSENGER_DOCURL,
+ "header-view-toolbar",
+ "currentset"
+ );
+ if (cs && !cs.includes("otherActionsButton")) {
+ // Put the otherActionsButton button at the end.
+ cs = cs + ",otherActionsButton";
+ xulStore.setValue(
+ MESSENGER_DOCURL,
+ "header-view-toolbar",
+ "currentset",
+ cs
+ );
+ }
+ }
+
+ // In UI version 7, the three-state doNotTrack setting was reverted back
+ // to two-state. This reverts a (no longer supported) setting of "please
+ // track me" to the default "don't say anything".
+ if (currentUIVersion < 7) {
+ try {
+ if (
+ Services.prefs.getBoolPref("privacy.donottrackheader.enabled") &&
+ Services.prefs.getIntPref("privacy.donottrackheader.value") != 1
+ ) {
+ Services.prefs.clearUserPref("privacy.donottrackheader.enabled");
+ Services.prefs.clearUserPref("privacy.donottrackheader.value");
+ }
+ } catch (ex) {}
+ }
+
+ // In UI version 8, we change from boolean browser.display.use_document_colors
+ // to the tri-state browser.display.document_color_use.
+ if (currentUIVersion < 8) {
+ const kOldColorPref = "browser.display.use_document_colors";
+ if (
+ Services.prefs.prefHasUserValue(kOldColorPref) &&
+ !Services.prefs.getBoolPref(kOldColorPref)
+ ) {
+ Services.prefs.setIntPref("browser.display.document_color_use", 2);
+ }
+ }
+
+ // This one is needed also in all new profiles.
+ // Add an expanded entry for All Address Books.
+ if (currentUIVersion < 10 || newProfile) {
+ // If the file exists, read its contents, prepend the "All ABs" URI
+ // and save it, else, just write the "All ABs" URI to the file.
+ let spec = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "directoryTree.json"
+ );
+ IOUtils.readJSON(spec)
+ .then(data => {
+ data.unshift("moz-abdirectory://?");
+ IOUtils.writeJSON(spec, data);
+ })
+ .catch(ex => {
+ if (["NotFoundError"].includes(ex.name)) {
+ IOUtils.writeJSON(spec, ["moz-abdirectory://?"]);
+ } else {
+ console.error(ex);
+ }
+ });
+ }
+
+ // Several Latin language groups were consolidated into x-western.
+ if (currentUIVersion < 11) {
+ let group = null;
+ try {
+ group = Services.prefs.getComplexValue(
+ "font.language.group",
+ Ci.nsIPrefLocalizedString
+ );
+ } catch (ex) {}
+ if (
+ group &&
+ ["tr", "x-baltic", "x-central-euro"].some(g => g == group.data)
+ ) {
+ group.data = "x-western";
+ Services.prefs.setComplexValue(
+ "font.language.group",
+ Ci.nsIPrefLocalizedString,
+ group
+ );
+ }
+ }
+
+ // Untangle starting in Paragraph mode from Enter key preference.
+ if (currentUIVersion < 13) {
+ Services.prefs.setBoolPref(
+ "mail.compose.default_to_paragraph",
+ Services.prefs.getBoolPref("editor.CR_creates_new_p")
+ );
+ Services.prefs.clearUserPref("editor.CR_creates_new_p");
+ }
+
+ // Migrate remote content exceptions for email addresses which are
+ // encoded as chrome URIs.
+ if (currentUIVersion < 14) {
+ let permissionsDB = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ permissionsDB.append("permissions.sqlite");
+ let db = Services.storage.openDatabase(permissionsDB);
+
+ try {
+ let statement = db.createStatement(
+ "select origin,permission from moz_perms where " +
+ // Avoid 'like' here which needs to be escaped.
+ "substr(origin, 1, 28)='chrome://messenger/content/?';"
+ );
+ try {
+ while (statement.executeStep()) {
+ let origin = statement.getUTF8String(0);
+ let permission = statement.getInt32(1);
+ Services.perms.removeFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(origin),
+ {}
+ ),
+ "image"
+ );
+ origin = origin.replace(
+ "chrome://messenger/content/?",
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ Services.perms.addFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(origin),
+ {}
+ ),
+ "image",
+ permission
+ );
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ // Sadly we still need to clear the database manually. Experiments
+ // showed that the permissions manager deleted only one record.
+ db.defaultTransactionType =
+ Ci.mozIStorageConnection.TRANSACTION_EXCLUSIVE;
+ db.beginTransaction();
+ try {
+ db.executeSimpleSQL(
+ "delete from moz_perms where " +
+ "substr(origin, 1, 28)='chrome://messenger/content/?';"
+ );
+ db.commitTransaction();
+ } catch (ex) {
+ db.rollbackTransaction();
+ throw ex;
+ }
+ } finally {
+ db.close();
+ }
+ }
+
+ // Changed notification sound behaviour on OS X.
+ if (currentUIVersion < 15) {
+ var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ if (AppConstants.platform == "macosx") {
+ // For people updating from versions < 52 who had "Play system sound"
+ // selected for notifications. As TB no longer plays system sounds,
+ // uncheck the pref to match the new behaviour.
+ const soundPref = "mail.biff.play_sound";
+ if (
+ Services.prefs.getBoolPref(soundPref) &&
+ Services.prefs.getIntPref(soundPref + ".type") == 0
+ ) {
+ Services.prefs.setBoolPref(soundPref, false);
+ }
+ }
+ }
+
+ if (currentUIVersion < 16) {
+ // Migrate the old requested locales prefs to use the new model
+ const SELECTED_LOCALE_PREF = "general.useragent.locale";
+ const MATCHOS_LOCALE_PREF = "intl.locale.matchOS";
+
+ if (
+ Services.prefs.prefHasUserValue(MATCHOS_LOCALE_PREF) ||
+ Services.prefs.prefHasUserValue(SELECTED_LOCALE_PREF)
+ ) {
+ if (Services.prefs.getBoolPref(MATCHOS_LOCALE_PREF, false)) {
+ Services.locale.requestedLocales = [];
+ } else {
+ let locale = Services.prefs.getComplexValue(
+ SELECTED_LOCALE_PREF,
+ Ci.nsIPrefLocalizedString
+ );
+ if (locale) {
+ try {
+ Services.locale.requestedLocales = [locale.data];
+ } catch (e) {
+ /* Don't panic if the value is not a valid locale code. */
+ }
+ }
+ }
+ Services.prefs.clearUserPref(SELECTED_LOCALE_PREF);
+ Services.prefs.clearUserPref(MATCHOS_LOCALE_PREF);
+ }
+ }
+
+ if (currentUIVersion < 17) {
+ // Move composition's [Attach |v] button to the right end of Composition
+ // Toolbar (unless the button was removed by user), so that it is
+ // right above the attachment pane.
+ // First, get value of currentset (string of comma-separated button ids).
+ let cs = xulStore.getValue(
+ MESSENGERCOMPOSE_DOCURL,
+ "composeToolbar2",
+ "currentset"
+ );
+ if (cs && cs.includes("button-attach")) {
+ // Get array of button ids from currentset string.
+ let csArray = cs.split(",");
+ let attachButtonIndex = csArray.indexOf("button-attach");
+ // Remove attach button id from current array position.
+ csArray.splice(attachButtonIndex, 1);
+ // If the currentset string does not contain a spring which causes
+ // elements after the spring to be right-aligned, add it now at the
+ // end of the array. Note: Prior to this UI version, only MAC OS
+ // defaultset contained a spring; in any case, user might have added
+ // or removed it via customization.
+ if (!cs.includes("spring")) {
+ csArray.push("spring");
+ }
+ // Add attach button id to the end of the array.
+ csArray.push("button-attach");
+ // Join array values back into comma-separated string.
+ cs = csArray.join(",");
+ // Apply changes to currentset.
+ xulStore.setValue(
+ MESSENGERCOMPOSE_DOCURL,
+ "composeToolbar2",
+ "currentset",
+ cs
+ );
+ }
+ }
+
+ if (currentUIVersion < 18) {
+ for (let url of [
+ "chrome://calendar/content/calendar-event-dialog-attendees.xul",
+ "chrome://calendar/content/calendar-event-dialog.xul",
+ "chrome://messenger/content/addressbook/addressbook.xul",
+ "chrome://messenger/content/messageWindow.xul",
+ "chrome://messenger/content/messenger.xul",
+ "chrome://messenger/content/messengercompose/messengercompose.xul",
+ ]) {
+ this._migrateXULStoreForDocument(
+ url,
+ url.replace(/\.xul$/, ".xhtml")
+ );
+ }
+ // See bug 1653168. messagepanebox is the problematic one, but ensure
+ // messagepaneboxwrapper doesn't cause problems as well.
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "messagepanebox",
+ "collapsed"
+ );
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "messagepaneboxwrapper",
+ "collapsed"
+ );
+
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "messagepanebox",
+ "collapsed"
+ );
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "messagepaneboxwrapper",
+ "collapsed"
+ );
+ }
+
+ if (currentUIVersion < 19) {
+ // Clear socks proxy values if they were shared from http, to prevent
+ // websocket breakage after bug 1577862 (see bug 1606679).
+ if (
+ Services.prefs.getBoolPref(
+ "network.proxy.share_proxy_settings",
+ false
+ ) &&
+ Services.prefs.getIntPref("network.proxy.type", 0) == 1
+ ) {
+ let httpProxy = Services.prefs.getCharPref("network.proxy.http", "");
+ let httpPort = Services.prefs.getIntPref(
+ "network.proxy.http_port",
+ 0
+ );
+ let socksProxy = Services.prefs.getCharPref(
+ "network.proxy.socks",
+ ""
+ );
+ let socksPort = Services.prefs.getIntPref(
+ "network.proxy.socks_port",
+ 0
+ );
+ if (httpProxy && httpProxy == socksProxy && httpPort == socksPort) {
+ Services.prefs.setCharPref(
+ "network.proxy.socks",
+ Services.prefs.getCharPref("network.proxy.backup.socks", "")
+ );
+ Services.prefs.setIntPref(
+ "network.proxy.socks_port",
+ Services.prefs.getIntPref("network.proxy.backup.socks_port", 0)
+ );
+ }
+ }
+ }
+
+ // Clear unused socks proxy backup values - see bug 1625773.
+ if (currentUIVersion < 20) {
+ let backup = Services.prefs.getCharPref(
+ "network.proxy.backup.socks",
+ ""
+ );
+ let backupPort = Services.prefs.getIntPref(
+ "network.proxy.backup.socks_port",
+ 0
+ );
+ let socksProxy = Services.prefs.getCharPref("network.proxy.socks", "");
+ let socksPort = Services.prefs.getIntPref(
+ "network.proxy.socks_port",
+ 0
+ );
+ if (backup == socksProxy) {
+ Services.prefs.clearUserPref("network.proxy.backup.socks");
+ }
+ if (backupPort == socksPort) {
+ Services.prefs.clearUserPref("network.proxy.backup.socks_port");
+ }
+ }
+
+ // Make "bad" msgcompose.font_face value "tt" be "monospace" instead.
+ if (currentUIVersion < 21) {
+ if (Services.prefs.getStringPref("msgcompose.font_face") == "tt") {
+ Services.prefs.setStringPref("msgcompose.font_face", "monospace");
+ }
+ }
+
+ // Migrate Yahoo users to OAuth2, since "normal password" is going away
+ // on October 20, 2020.
+ if (currentUIVersion < 22) {
+ this._migrateIncomingToOAuth2("mail.yahoo.com");
+ this._migrateSMTPToOAuth2("mail.yahoo.com");
+ }
+ // ... and same thing for AOL users.
+ if (currentUIVersion < 23) {
+ this._migrateIncomingToOAuth2("imap.aol.com");
+ this._migrateIncomingToOAuth2("pop.aol.com");
+ this._migrateSMTPToOAuth2("smtp.aol.com");
+ }
+
+ // Version 24 was used and backed out.
+
+ // Some elements changed ID, move their persisted values to the new ID.
+ if (currentUIVersion < 25) {
+ let url = "chrome://messenger/content/messenger.xhtml";
+ this._migrateXULStoreForElement(url, "view-deck", "view-box");
+ this._migrateXULStoreForElement(url, "displayDeck", "displayBox");
+ }
+
+ // Migrate the old Folder Pane modes dropdown.
+ if (currentUIVersion < 26) {
+ this._migrateXULStoreForElement(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPane-toolbar",
+ "folderPaneHeader"
+ );
+ }
+
+ if (currentUIVersion < 27) {
+ let accountList = MailServices.accounts.accounts.filter(
+ a => a.incomingServer
+ );
+ accountList.sort(lazy.FolderUtils.compareAccounts);
+ let accountKeyList = accountList.map(account => account.key);
+ try {
+ MailServices.accounts.reorderAccounts(accountKeyList);
+ } catch (error) {
+ console.error(
+ "Migrating account list order failed. Error message was: " +
+ error +
+ " -- Will not reattempt migration."
+ );
+ }
+ }
+
+ // Migrating the preference of the font size in the message compose window
+ // to use in document.execCommand.
+ if (currentUIVersion < 28) {
+ let fontSize = Services.prefs.getCharPref("msgcompose.font_size");
+ let newFontSize;
+ switch (fontSize) {
+ case "x-small":
+ newFontSize = "1";
+ break;
+ case "small":
+ newFontSize = "2";
+ break;
+ case "medium":
+ newFontSize = "3";
+ break;
+ case "large":
+ newFontSize = "4";
+ break;
+ case "x-large":
+ newFontSize = "5";
+ break;
+ case "xx-large":
+ newFontSize = "6";
+ break;
+ default:
+ newFontSize = "3";
+ }
+ Services.prefs.setCharPref("msgcompose.font_size", newFontSize);
+ }
+
+ // Migrate mail.biff.use_new_count_in_mac_dock to
+ // mail.biff.use_new_count_in_badge.
+ if (currentUIVersion < 29) {
+ if (
+ Services.prefs.getBoolPref(
+ "mail.biff.use_new_count_in_mac_dock",
+ false
+ )
+ ) {
+ Services.prefs.setBoolPref("mail.biff.use_new_count_in_badge", true);
+ Services.prefs.clearUserPref("mail.biff.use_new_count_in_mac_dock");
+ }
+ }
+
+ // Clear ui.systemUsesDarkTheme after bug 1736252.
+ if (currentUIVersion < 30) {
+ Services.prefs.clearUserPref("ui.systemUsesDarkTheme");
+ }
+
+ if (currentUIVersion < 32) {
+ this._migrateIncomingToOAuth2("imap.gmail.com");
+ this._migrateIncomingToOAuth2("pop.gmail.com");
+ this._migrateSMTPToOAuth2("smtp.gmail.com");
+ }
+
+ if (currentUIVersion < 33) {
+ // Put button-encryption and button-encryption-options on the
+ // Composition Toolbar.
+ // First, get value of currentset (string of comma-separated button ids).
+ let cs = xulStore.getValue(
+ MESSENGERCOMPOSE_DOCURL,
+ "composeToolbar2",
+ "currentset"
+ );
+ if (cs) {
+ // Button ids from currentset string.
+ let buttonIds = cs.split(",");
+
+ // We want to insert the two buttons at index 2 and 3.
+ buttonIds.splice(2, 0, "button-encryption");
+ buttonIds.splice(3, 0, "button-encryption-options");
+
+ cs = buttonIds.join(",");
+ // Apply changes to currentset.
+ xulStore.setValue(
+ MESSENGERCOMPOSE_DOCURL,
+ "composeToolbar2",
+ "currentset",
+ cs
+ );
+ }
+ }
+
+ if (currentUIVersion < 34) {
+ // Migrate from
+ // + mailnews.sendformat.auto_downgrade - Whether we should
+ // auto-downgrade to plain text when the message is plain.
+ // + mail.default_html_action - The default sending format if we didn't
+ // auto-downgrade.
+ // to mail.default_send_format
+ let defaultHTMLAction = Services.prefs.getIntPref(
+ "mail.default_html_action",
+ 3
+ );
+ Services.prefs.clearUserPref("mail.default_html_action");
+ let autoDowngrade = Services.prefs.getBoolPref(
+ "mailnews.sendformat.auto_downgrade",
+ true
+ );
+ Services.prefs.clearUserPref("mailnews.sendformat.auto_downgrade");
+
+ let sendFormat;
+ switch (defaultHTMLAction) {
+ case 0:
+ // Was AskUser. Move to the new Auto default.
+ sendFormat = Ci.nsIMsgCompSendFormat.Auto;
+ break;
+ case 1:
+ // Was PlainText only. Keep as plain text. Note, autoDowngrade has
+ // no effect on this option.
+ sendFormat = Ci.nsIMsgCompSendFormat.PlainText;
+ break;
+ case 2:
+ // Was HTML. Keep as HTML if autoDowngrade was false, otherwise use
+ // the Auto default.
+ sendFormat = autoDowngrade
+ ? Ci.nsIMsgCompSendFormat.Auto
+ : Ci.nsIMsgCompSendFormat.HTML;
+ break;
+ case 3:
+ // Was Both. If autoDowngrade was true, this is the same as the
+ // new Auto default. Otherwise, keep as Both.
+ sendFormat = autoDowngrade
+ ? Ci.nsIMsgCompSendFormat.Auto
+ : Ci.nsIMsgCompSendFormat.Both;
+ break;
+ default:
+ sendFormat = Ci.nsIMsgCompSendFormat.Auto;
+ break;
+ }
+ Services.prefs.setIntPref("mail.default_send_format", sendFormat);
+ }
+
+ if (currentUIVersion < 35) {
+ // Both IMAP and POP settings currently use this domain
+ this._migrateIncomingToOAuth2("outlook.office365.com");
+ this._migrateSMTPToOAuth2("smtp.office365.com");
+ }
+
+ if (currentUIVersion < 36) {
+ lazy.migrateToolbarForSpace("mail");
+ }
+
+ if (currentUIVersion < 37) {
+ if (!Services.prefs.prefHasUserValue("mail.uidensity")) {
+ Services.prefs.setIntPref("mail.uidensity", 0);
+ }
+ }
+
+ if (currentUIVersion < 38) {
+ lazy.migrateToolbarForSpace("calendar");
+ lazy.migrateToolbarForSpace("tasks");
+ lazy.migrateToolbarForSpace("chat");
+ lazy.migrateToolbarForSpace("settings");
+ lazy.migrateToolbarForSpace("addressbook");
+ // Clear menubar and tabbar XUL toolbar state.
+ lazy.clearXULToolbarState("tabbar-toolbar");
+ lazy.clearXULToolbarState("toolbar-menubar");
+ }
+
+ if (currentUIVersion < 39) {
+ // Set old defaults for message header customization in existing
+ // profiles without any customization settings.
+ if (
+ !Services.xulStore.hasValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "messageHeader",
+ "layout"
+ )
+ ) {
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "messageHeader",
+ "layout",
+ JSON.stringify({
+ showAvatar: false,
+ showBigAvatar: false,
+ showFullAddress: false,
+ hideLabels: false,
+ subjectLarge: false,
+ buttonStyle: "default",
+ })
+ );
+ }
+ }
+
+ if (currentUIVersion < 40) {
+ // Keep the view to table for existing profiles if the user never
+ // customized the thread pane view.
+ if (
+ !Services.xulStore.hasValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view"
+ )
+ ) {
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view",
+ "table"
+ );
+ }
+
+ // Maintain the default horizontal layout for existing profiles if the
+ // user never changed it.
+ if (!Services.prefs.prefHasUserValue("mail.pane_config.dynamic")) {
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 0);
+ }
+ }
+
+ // Migration tasks that may take a long time are not run immediately, but
+ // added to the MigrationTasks object then run at the end.
+ //
+ // See the documentation on MigrationTask and MigrationTasks for how to
+ // add a task.
+ MigrationTasks.runTasks();
+
+ // Update the migration version.
+ Services.prefs.setIntPref(UI_VERSION_PREF, UI_VERSION);
+ } catch (e) {
+ console.error(
+ "Migrating from UI version " +
+ currentUIVersion +
+ " to " +
+ UI_VERSION +
+ " failed. Error message was: " +
+ e +
+ " -- " +
+ "Will reattempt on next start."
+ );
+ }
+ },
+ /* eslint-enable complexity */
+
+ /**
+ * Migrate incoming server to using OAuth2 as authMethod.
+ *
+ * @param {string} hostnameHint - What the hostname should end with.
+ */
+ _migrateIncomingToOAuth2(hostnameHint) {
+ for (let account of MailServices.accounts.accounts) {
+ // Skip if not a matching account.
+ if (!account.incomingServer.hostName.endsWith(hostnameHint)) {
+ continue;
+ }
+
+ // Change Incoming server to OAuth2.
+ account.incomingServer.authMethod = Ci.nsMsgAuthMethod.OAuth2;
+ }
+ },
+
+ /**
+ * Migrate outgoing server to using OAuth2 as authMethod.
+ *
+ * @param {string} hostnameHint - What the hostname should end with.
+ */
+ _migrateSMTPToOAuth2(hostnameHint) {
+ for (let server of MailServices.smtp.servers) {
+ // Skip if not a matching server.
+ if (!server.hostname.endsWith(hostnameHint)) {
+ continue;
+ }
+
+ // Change Outgoing SMTP server to OAuth2.
+ server.authMethod = Ci.nsMsgAuthMethod.OAuth2;
+ }
+ },
+
+ /**
+ * RSS subscriptions and items used to be stored in .rdf files, but now
+ * we've changed to use JSON files instead. This migration routine checks
+ * for the old format files and upgrades them as appropriate.
+ * The feeds and items migration are handled as separate (hopefully atomic)
+ * steps. It is careful to not overwrite new-style .json files.
+ *
+ * @returns {void}
+ */
+ async _migrateRSS() {
+ // Find all the RSS IncomingServers.
+ let rssServers = [];
+ for (let server of MailServices.accounts.allServers) {
+ if (server && server.type == "rss") {
+ rssServers.push(server);
+ }
+ }
+
+ // For each one...
+ for (let server of rssServers) {
+ await this._migrateRSSServer(server);
+ }
+ },
+
+ async _migrateRSSServer(server) {
+ let rssServer = server.QueryInterface(Ci.nsIRssIncomingServer);
+
+ // Convert feeds.rdf to feeds.json (if needed).
+ let feedsFile = rssServer.subscriptionsPath;
+ let legacyFeedsFile = server.localPath;
+ legacyFeedsFile.append("feeds.rdf");
+
+ try {
+ await this._migrateRSSSubscriptions(legacyFeedsFile, feedsFile);
+ } catch (err) {
+ console.error(
+ "Failed to migrate '" +
+ feedsFile.path +
+ "' to '" +
+ legacyFeedsFile.path +
+ "': " +
+ err
+ );
+ }
+
+ // Convert feeditems.rdf to feeditems.json (if needed).
+ let itemsFile = rssServer.feedItemsPath;
+ let legacyItemsFile = server.localPath;
+ legacyItemsFile.append("feeditems.rdf");
+ try {
+ await this._migrateRSSItems(legacyItemsFile, itemsFile);
+ } catch (err) {
+ console.error(
+ "Failed to migrate '" +
+ itemsFile.path +
+ "' to '" +
+ legacyItemsFile.path +
+ "': " +
+ err
+ );
+ }
+ },
+
+ // Assorted namespace strings required for the feed migrations.
+ FZ_NS: "urn:forumzilla:",
+ DC_NS: "http://purl.org/dc/elements/1.1/",
+ RSS_NS: "http://purl.org/rss/1.0/",
+ RDF_SYNTAX_NS: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+ RDF_SYNTAX_TYPE: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
+
+ /**
+ * Convert rss subscriptions in a legacy feeds.rdf file into feeds.json.
+ * If the conversion is successful, the legacy file will be removed.
+ *
+ * @param {nsIFile} legacyFile - Location of the rdf file.
+ * @param {nsIFile} jsonFile - Location for the output JSON file.
+ * @returns {void}
+ */
+ async _migrateRSSSubscriptions(legacyFile, jsonFile) {
+ // Load .rdf file into an XMLDocument.
+ let rawXMLRDF;
+ try {
+ rawXMLRDF = await IOUtils.readUTF8(legacyFile.path);
+ } catch (ex) {
+ if (["NotFoundError"].includes(ex.name)) {
+ return; // nothing legacy file to migrate
+ }
+ }
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(rawXMLRDF, "text/xml");
+
+ let feeds = [];
+ // Skip the fz:root->fz:feeds->etc structure. Just grab fz:feed nodes.
+ let feedNodes = doc.documentElement.getElementsByTagNameNS(
+ this.FZ_NS,
+ "feed"
+ );
+
+ let toBool = function (val) {
+ return val == "true";
+ };
+
+ // Map RDF feed property names to js.
+ let propMap = [
+ { ns: this.DC_NS, name: "title", dest: "title" },
+ { ns: this.DC_NS, name: "lastModified", dest: "lastModified" },
+ { ns: this.DC_NS, name: "identifier", dest: "url" },
+ { ns: this.FZ_NS, name: "quickMode", dest: "quickMode", cook: toBool },
+ { ns: this.FZ_NS, name: "options", dest: "options", cook: JSON.parse },
+ { ns: this.FZ_NS, name: "destFolder", dest: "destFolder" },
+ { ns: this.RSS_NS, name: "link", dest: "link" },
+ ];
+
+ for (let f of feedNodes) {
+ let feed = {};
+ for (let p of propMap) {
+ // The data could be in either an attribute or an element.
+ let val = f.getAttributeNS(p.ns, p.name);
+ if (!val) {
+ let el = f.getElementsByTagNameNS(p.ns, p.name).item(0);
+ if (el) {
+ // Might be a RDF:resource...
+ val = el.getAttributeNS(this.RDF_SYNTAX_NS, "resource");
+ if (!val) {
+ // ...or a literal string.
+ val = el.textContent;
+ }
+ }
+ }
+ if (!val) {
+ // log.warn(`feeds.rdf: ${p.name} missing`);
+ continue;
+ }
+ // Conversion needed?
+ if ("cook" in p) {
+ val = p.cook(val);
+ }
+ feed[p.dest] = val;
+ }
+
+ if (feed.url) {
+ feeds.push(feed);
+ }
+ }
+
+ await IOUtils.writeJSON(jsonFile.path, feeds);
+ legacyFile.remove(false);
+ },
+
+ /**
+ * Convert a legacy feeditems.rdf file into feeditems.json.
+ * If the conversion is successful, the legacy file will be removed.
+ *
+ * @param {nsIFile} legacyFile - Location of the rdf file.
+ * @param {nsIFile} jsonFile - Location for the output JSON file.
+ * @returns {void}
+ */
+ async _migrateRSSItems(legacyFile, jsonFile) {
+ // Load .rdf file into an XMLDocument.
+ let rawXMLRDF;
+ try {
+ rawXMLRDF = await IOUtils.readUTF8(legacyFile.path);
+ } catch (ex) {
+ if (["NotFoundError"].includes(ex.name)) {
+ return; // nothing legacy file to migrate
+ }
+ }
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(rawXMLRDF, "text/xml");
+
+ let items = {};
+
+ let demangleURL = function (itemURI) {
+ // Reverse the mapping that originally turned links/guids into URIs.
+ let url = itemURI;
+ url = url.replace("urn:feeditem:", "");
+ url = url.replace(/%23/g, "#");
+ url = url.replace(/%2f/g, "/");
+ url = url.replace(/%3f/g, "?");
+ url = url.replace(/%26/g, "&");
+ url = url.replace(/%7e/g, "~");
+ url = decodeURI(url);
+ return url;
+ };
+
+ let toBool = function (s) {
+ return s == "true";
+ };
+
+ let toInt = function (s) {
+ let t = parseInt(s);
+ return Number.isNaN(t) ? 0 : t;
+ };
+
+ let itemNodes = doc.documentElement.getElementsByTagNameNS(
+ this.RDF_SYNTAX_NS,
+ "Description"
+ );
+
+ // Map RDF feed property names to js.
+ let propMap = [
+ { ns: this.FZ_NS, name: "stored", dest: "stored", cook: toBool },
+ { ns: this.FZ_NS, name: "valid", dest: "valid", cook: toBool },
+ {
+ ns: this.FZ_NS,
+ name: "last-seen-timestamp",
+ dest: "lastSeenTime",
+ cook: toInt,
+ },
+ ];
+
+ for (let itemNode of itemNodes) {
+ let item = {};
+ for (let p of propMap) {
+ // The data could be in either an attribute or an element.
+ let val = itemNode.getAttributeNS(p.ns, p.name);
+ if (!val) {
+ let elements = itemNode.getElementsByTagNameNS(p.ns, p.name);
+ if (elements.length > 0) {
+ val = elements.item(0).textContent;
+ }
+ }
+ if (!val) {
+ // log.warn(`feeditems.rdf: ${p.name} missing`);
+ continue;
+ }
+ // Conversion needed?
+ if ("cook" in p) {
+ val = p.cook(val);
+ }
+ item[p.dest] = val;
+ }
+
+ item.feedURLs = [];
+ let feedNodes = itemNode.getElementsByTagNameNS(this.FZ_NS, "feed");
+ for (let feedNode of feedNodes) {
+ let feedURL = feedNode.getAttributeNS(this.RDF_SYNTAX_NS, "resource");
+ item.feedURLs.push(feedURL);
+ }
+
+ let id = itemNode.getAttributeNS(this.RDF_SYNTAX_NS, "about");
+ id = demangleURL(id);
+ if (id) {
+ items[id] = item;
+ }
+ }
+
+ await IOUtils.writeJSON(jsonFile.path, items);
+ legacyFile.remove(false);
+ },
+
+ /**
+ * Perform any migration work that needs to occur once the user profile has
+ * been loaded.
+ */
+ migrateAtProfileStartup() {
+ lazy.migrateMailnews();
+ this._migrateUI();
+ this._migrateRSS();
+ },
+};
+
+/**
+ * Controls migration tasks, including (if the migration is taking a while)
+ * presenting the user with a pop-up window showing the current status.
+ */
+var MigrationTasks = {
+ _finished: false,
+ _progressWindow: null,
+ _start: null,
+ _tasks: [],
+ _waitThreshold: 1000,
+
+ /**
+ * Adds a simple task to be completed.
+ *
+ * @param {string} [fluentID] - The name of this task. If specified, a string
+ * for this name MUST be in migration.ftl. If not specified, this task
+ * won't appear in the list of migration tasks.
+ * @param {Function} action
+ */
+ addSimpleTask(fluentID, action) {
+ this._tasks.push(new MigrationTask(fluentID, action));
+ },
+
+ /**
+ * Adds a task to be completed. Subclasses of MigrationTask are allowed,
+ * allowing more complex tasks than `addSimpleTask`.
+ *
+ * @param {MigrationTask} task
+ */
+ addComplexTask(task) {
+ if (!(task instanceof MigrationTask)) {
+ throw new Error("Task is not a MigrationTask");
+ }
+ this._tasks.push(task);
+ },
+
+ /**
+ * Runs the tasks in sequence.
+ */
+ async _runTasksInternal() {
+ this._start = Date.now();
+
+ // Do not optimise this for-loop. More tasks could be added.
+ for (let t = 0; t < this._tasks.length; t++) {
+ let task = this._tasks[t];
+ task.status = "running";
+
+ await task.action();
+
+ for (let i = 0; i < task.subTasks.length; i++) {
+ task.emit("progress", i, task.subTasks.length);
+ let subTask = task.subTasks[i];
+ subTask.status = "running";
+
+ await subTask.action();
+ subTask.status = "finished";
+ }
+ if (task.subTasks.length) {
+ task.emit("progress", task.subTasks.length, task.subTasks.length);
+ // Pause long enough for the user to see the progress bar at 100%.
+ await new Promise(resolve => lazy.setTimeout(resolve, 150));
+ }
+
+ task.status = "finished";
+ }
+
+ this._tasks.length = 0;
+ this._finished = true;
+ },
+
+ /**
+ * Runs the migration tasks. Controls the opening and closing of the pop-up.
+ */
+ runTasks() {
+ this._runTasksInternal();
+
+ Services.tm.spinEventLoopUntil("MigrationTasks", () => {
+ if (this._finished) {
+ return true;
+ }
+
+ if (
+ !this._progressWindow &&
+ Date.now() - this._start > this._waitThreshold
+ ) {
+ this._progressWindow = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/migrationProgress.xhtml",
+ "_blank",
+ "centerscreen,width=640",
+ Services.ww
+ );
+ this.addSimpleTask(undefined, async () => {
+ await new Promise(r => lazy.setTimeout(r, 1000));
+ this._progressWindow.close();
+ });
+ }
+
+ return false;
+ });
+
+ delete this._progressWindow;
+ },
+
+ /**
+ * @type MigrationTask[]
+ */
+ get tasks() {
+ return this._tasks;
+ },
+};
+
+/**
+ * A single task to be completed.
+ */
+class MigrationTask {
+ /**
+ * The name of this task. If specified, a string for this name MUST be in
+ * migration.ftl. If not specified, this task won't appear in the list of
+ * migration tasks.
+ *
+ * @type string
+ */
+ fluentID = null;
+
+ /**
+ * Smaller tasks for this task. If there are sub-tasks, a progress bar will
+ * be displayed to the user, showing how many sub-tasks are complete.
+ *
+ * @note A sub-task may not have sub-sub-tasks.
+ *
+ * @type MigrationTask[]
+ */
+ subTasks = [];
+
+ /**
+ * Current status of the task. Either "pending", "running" or "finished".
+ *
+ * @type string
+ */
+ _status = "pending";
+
+ /**
+ * @param {string} [fluentID]
+ * @param {Function} action
+ */
+ constructor(fluentID, action) {
+ this.fluentID = fluentID;
+ this.action = action;
+ lazy.EventEmitter.decorate(this);
+ }
+
+ /**
+ * Current status of the task. Either "pending", "running" or "finished".
+ * Emits a "status-change" notification on change.
+ *
+ * @type string
+ */
+ get status() {
+ return this._status;
+ }
+
+ set status(value) {
+ this._status = value;
+ this.emit("status-change", value);
+ }
+}
diff --git a/comm/mail/modules/MailUsageTelemetry.jsm b/comm/mail/modules/MailUsageTelemetry.jsm
new file mode 100644
index 0000000000..5fe99bee02
--- /dev/null
+++ b/comm/mail/modules/MailUsageTelemetry.jsm
@@ -0,0 +1,362 @@
+/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
+/* 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 = ["MailUsageTelemetry"];
+
+// Observed topic names.
+const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
+
+// Window types we're interested in.
+const WINDOW_TYPES = ["mail:3pane", "mail:messageWindow"];
+
+// Window URLs we're interested in.
+const WINDOW_URLS = [
+ "chrome://messenger/content/messenger.xhtml",
+ "chrome://messenger/content/messageWindow.xhtml",
+ "about:3pane",
+ "about:message",
+];
+
+// The elements we consider to be interactive.
+const UI_TARGET_ELEMENTS = [
+ "menuitem",
+ "toolbarbutton",
+ "key",
+ "command",
+ "checkbox",
+ "input",
+ "button",
+ "image",
+ "radio",
+ "richlistitem",
+];
+
+// The containers of interactive elements that we care about and their pretty
+// names. These should be listed in order of most-specific to least-specific,
+// when iterating JavaScript will guarantee that ordering and so we will find
+// the most specific area first.
+const MESSENGER_UI_CONTAINER_IDS = {
+ // Calendar.
+ "today-pane-panel": "calendar",
+ calendarTabPanel: "calendar",
+ "calendar-popupset": "calendar",
+
+ // Chat.
+ chatTabPanel: "chat",
+ buddyListContextMenu: "chat",
+ chatConversationContextMenu: "chat",
+ "chat-toolbar-context-menu": "chat",
+ chatContextMenu: "chat",
+ participantListContextMenu: "chat",
+
+ // Anything to do with the 3-pane tab or message window.
+ folderPaneHeaderBar: "message-display",
+ folderPaneGetMessagesContext: "message-display",
+ folderPaneMoreContext: "message-display",
+ folderPaneContext: "message-display",
+ threadPaneHeaderBar: "message-display",
+ threadPaneDisplayContext: "message-display",
+ aboutPagesContext: "message-display",
+ browserContext: "message-display",
+ mailContext: "message-display",
+ singleMessage: "message-display",
+ emailAddressPopup: "message-display",
+ copyPopup: "message-display",
+ messageIdContext: "message-display",
+ attachmentItemContext: "message-display",
+ attachmentListContext: "message-display",
+ "attachment-toolbar-context-menu": "message-display",
+ copyUrlPopup: "message-display",
+ newsgroupPopup: "message-display",
+
+ // The tab bar and the toolbox.
+ "navigation-toolbox": "toolbox",
+ "mail-toolbox": "toolbox",
+ "quick-filter-bar": "toolbox",
+ "appMenu-popup": "toolbox",
+ tabContextMenu: "toolbox",
+ spacesToolbar: "toolbox",
+};
+
+const KNOWN_ADDONS = [];
+
+function telemetryId(widgetId, obscureAddons = true) {
+ // Add-on IDs need to be obscured.
+ function addonId(id) {
+ if (!obscureAddons) {
+ return id;
+ }
+
+ let pos = KNOWN_ADDONS.indexOf(id);
+ if (pos < 0) {
+ pos = KNOWN_ADDONS.length;
+ KNOWN_ADDONS.push(id);
+ }
+ return `addon${pos}`;
+ }
+
+ if (widgetId.endsWith("-browserAction-toolbarbutton")) {
+ widgetId = addonId(
+ widgetId.substring(
+ 0,
+ widgetId.length - "-browserAction-toolbarbutton".length
+ )
+ );
+ } else if (widgetId.endsWith("-messageDisplayAction-toolbarbutton")) {
+ widgetId = addonId(
+ widgetId.substring(
+ 0,
+ widgetId.length - "-messageDisplayAction-toolbarbutton".length
+ )
+ );
+ } else if (widgetId.startsWith("ext-keyset-id-")) {
+ // Webextension command shortcuts don't have an id on their key element so
+ // we see the id from the keyset that contains them.
+ widgetId = addonId(widgetId.substring("ext-keyset-id-".length));
+ } else if (widgetId.includes("-menuitem--")) {
+ widgetId = addonId(widgetId.substring(0, widgetId.indexOf("-menuitem--")));
+ } else if (/^qfb-tag-(?!\$label\d$)/.test(widgetId)) {
+ // Only record the full ID of buttons for tags named label0...label9.
+ // The data for other tags are of no use to us and could contain personal
+ // information, so hide it behind this generic ID.
+ widgetId = "qfb-tag-";
+ }
+ // Collapse these IDs as each element is given a unique ID.
+ widgetId = widgetId.replace(/^folderPanelView\d+/, "folderPanelView");
+ // Strip UUIDs in widget IDs.
+ widgetId = widgetId.replace(
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
+ ""
+ );
+ // Strip mail URLs as they could contain personal information.
+ widgetId = widgetId.replace(/(imap|mailbox):\/\/.*/, "");
+ return widgetId.replace(/_/g, "-");
+}
+
+let MailUsageTelemetry = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ _inited: false,
+
+ init() {
+ // Make sure to catch new chrome windows and subsession splits.
+ Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true);
+
+ // Attach the handlers to the existing Windows.
+ for (let winType of WINDOW_TYPES) {
+ for (let win of Services.wm.getEnumerator(winType)) {
+ this._registerWindow(win);
+ }
+ }
+
+ this._inited = true;
+ },
+
+ uninit() {
+ if (!this._inited) {
+ return;
+ }
+ Services.obs.removeObserver(this, DOMWINDOW_OPENED_TOPIC);
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case DOMWINDOW_OPENED_TOPIC:
+ this._onWindowOpen(subject);
+ break;
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "unload":
+ this._unregisterWindow(event.target);
+ break;
+ }
+ },
+
+ _getWidgetID(node) {
+ // We want to find a sensible ID for this element.
+ if (!node) {
+ return null;
+ }
+
+ if (node.id) {
+ return node.id;
+ }
+
+ // Special case in the tabs.
+ if (node.classList.contains("tab-close-button")) {
+ return "tab-close-button";
+ }
+
+ // One of these will at least let us know what the widget is for.
+ let possibleAttributes = [
+ "preference",
+ "command",
+ "observes",
+ "data-l10n-id",
+ ];
+
+ // The key attribute on key elements is the actual key to listen for.
+ if (node.localName != "key") {
+ possibleAttributes.unshift("key");
+ }
+
+ for (let idAttribute of possibleAttributes) {
+ if (node.hasAttribute(idAttribute)) {
+ return node.getAttribute(idAttribute);
+ }
+ }
+
+ return this._getWidgetID(node.parentElement);
+ },
+
+ _getBrowserWidgetContainer(node) {
+ // Find the container holding this element.
+ for (let containerId of Object.keys(MESSENGER_UI_CONTAINER_IDS)) {
+ let container = node.ownerDocument.getElementById(containerId);
+ if (container && container.contains(node)) {
+ return MESSENGER_UI_CONTAINER_IDS[containerId];
+ }
+ }
+ return null;
+ },
+
+ _getWidgetContainer(node) {
+ if (node.localName == "key") {
+ return "keyboard";
+ }
+
+ const { URL } = node.ownerDocument;
+ if (WINDOW_URLS.includes(URL)) {
+ return this._getBrowserWidgetContainer(node);
+ }
+ return null;
+ },
+
+ lastClickTarget: null,
+
+ _recordCommand(event) {
+ let types = [event.type];
+ let sourceEvent = event;
+ while (sourceEvent.sourceEvent) {
+ sourceEvent = sourceEvent.sourceEvent;
+ types.push(sourceEvent.type);
+ }
+
+ let lastTarget = this.lastClickTarget?.get();
+ if (
+ lastTarget &&
+ sourceEvent.type == "command" &&
+ sourceEvent.target.contains(lastTarget)
+ ) {
+ // Ignore a command event triggered by a click.
+ this.lastClickTarget = null;
+ return;
+ }
+
+ this.lastClickTarget = null;
+
+ if (sourceEvent.type == "click") {
+ // Only care about main button clicks.
+ if (sourceEvent.button != 0) {
+ return;
+ }
+
+ // This click may trigger a command event so retain the target to be able
+ // to dedupe that event.
+ this.lastClickTarget = Cu.getWeakReference(sourceEvent.target);
+ }
+
+ // We should never see events from web content as they are fired in a
+ // content process, but let's be safe.
+ let url = sourceEvent.target.ownerDocument.documentURIObject;
+ if (!url.schemeIs("chrome") && !url.schemeIs("about")) {
+ return;
+ }
+
+ // This is what events targeted at content will actually look like.
+ if (sourceEvent.target.localName == "browser") {
+ return;
+ }
+
+ // Find the actual element we're interested in.
+ let node = sourceEvent.target;
+ while (!UI_TARGET_ELEMENTS.includes(node.localName)) {
+ node = node.parentNode;
+ if (!node) {
+ // A click on a space or label or something we're not interested in.
+ return;
+ }
+ }
+
+ let item = this._getWidgetID(node);
+ let source = this._getWidgetContainer(node);
+
+ if (item && source) {
+ let scalar = `tb.ui.interaction.${source.replace("-", "_")}`;
+ Services.telemetry.keyedScalarAdd(scalar, telemetryId(item), 1);
+ }
+ },
+
+ /**
+ * Listens for UI interactions in the window.
+ */
+ _addUsageListeners(win) {
+ // Listen for command events from the UI.
+ win.addEventListener("command", event => this._recordCommand(event), true);
+ win.addEventListener("click", event => this._recordCommand(event), true);
+ },
+
+ /**
+ * Adds listeners to a single chrome window.
+ */
+ _registerWindow(win) {
+ this._addUsageListeners(win);
+
+ win.addEventListener("unload", this);
+ },
+
+ /**
+ * Removes listeners from a single chrome window.
+ */
+ _unregisterWindow(win) {
+ win.removeEventListener("unload", this);
+ },
+
+ /**
+ * Tracks the window count and registers the listeners for the tab count.
+ *
+ * @param{Object} win The window object.
+ */
+ _onWindowOpen(win) {
+ // Make sure to have a |nsIDOMWindow|.
+ if (!(win instanceof Ci.nsIDOMWindow)) {
+ return;
+ }
+
+ let onLoad = () => {
+ win.removeEventListener("load", onLoad);
+
+ // Ignore non browser windows.
+ if (
+ !WINDOW_TYPES.includes(
+ win.document.documentElement.getAttribute("windowtype")
+ )
+ ) {
+ return;
+ }
+
+ this._registerWindow(win);
+ };
+ win.addEventListener("load", onLoad);
+ },
+};
diff --git a/comm/mail/modules/MailUtils.jsm b/comm/mail/modules/MailUtils.jsm
new file mode 100644
index 0000000000..7ba78005b1
--- /dev/null
+++ b/comm/mail/modules/MailUtils.jsm
@@ -0,0 +1,820 @@
+/* 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 EXPORTED_SYMBOLS = ["MailUtils"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ MailConsts: "resource:///modules/MailConsts.jsm",
+ MailServices: "resource:///modules/MailServices.jsm",
+ MimeParser: "resource:///modules/mimeParser.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+/**
+ * This module has several utility functions for use by both core and
+ * third-party code. Some functions are aimed at code that doesn't have a
+ * window context, while others can be used anywhere.
+ */
+var MailUtils = {
+ /**
+ * Restarts the application, keeping it in
+ * safe mode if it is already in safe mode.
+ */
+ restartApplication() {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+ if (cancelQuit.data) {
+ return;
+ }
+ // If already in safe mode restart in safe mode.
+ if (Services.appinfo.inSafeMode) {
+ Services.startup.restartInSafeMode(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ );
+ return;
+ }
+ Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ );
+ },
+
+ /**
+ * Discover all folders. This is useful during startup, when you have code
+ * that deals with folders and that executes before the main 3pane window is
+ * open (the folder tree wouldn't have been initialized yet).
+ */
+ discoverFolders() {
+ for (let server of lazy.MailServices.accounts.allServers) {
+ // Bug 466311 Sometimes this can throw file not found, we're unsure
+ // why, but catch it and log the fact.
+ try {
+ server.rootFolder.subFolders;
+ } catch (ex) {
+ Services.console.logStringMessage(
+ "Discovering folders for account failed with exception: " + ex
+ );
+ }
+ }
+ },
+
+ /**
+ * Get the nsIMsgFolder corresponding to this file. This just looks at all
+ * folders and does a direct match.
+ *
+ * One of the places this is used is desktop search integration -- to open
+ * the search result corresponding to a mozeml/wdseml file, we need to figure
+ * out the folder using the file's path.
+ *
+ * @param aFile the nsIFile to convert to a folder
+ * @returns the nsIMsgFolder corresponding to aFile, or null if the folder
+ * isn't found
+ */
+ getFolderForFileInProfile(aFile) {
+ for (let folder of lazy.MailServices.accounts.allFolders) {
+ if (folder.filePath.equals(aFile)) {
+ return folder;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Get the nsIMsgFolder corresponding to this URI.
+ *
+ * @param aFolderURI the URI of the target folder
+ * @returns {nsIMsgFolder} Folder corresponding to this URI, or null if
+ * the folder doesn't already exist.
+ */
+ getExistingFolder(aFolderURI) {
+ let fls = Cc["@mozilla.org/mail/folder-lookup;1"].getService(
+ Ci.nsIFolderLookupService
+ );
+ return fls.getFolderForURL(aFolderURI);
+ },
+
+ /**
+ * Get the nsIMsgFolder corresponding to this URI, or create a detached
+ * folder if it doesn't already exist.
+ *
+ * @param aFolderURI the URI of the target folder
+ * @returns {nsIMsgFolder} Folder corresponding to this URI.
+ */
+ getOrCreateFolder(aFolderURI) {
+ let fls = Cc["@mozilla.org/mail/folder-lookup;1"].getService(
+ Ci.nsIFolderLookupService
+ );
+ return fls.getOrCreateFolderForURL(aFolderURI);
+ },
+
+ /**
+ * Display this message header in a new tab, a new window or an existing
+ * window, depending on the preference and whether a 3pane or standalone
+ * window is already open. This function should be called when you'd like to
+ * display a message to the user according to the pref set.
+ *
+ * @note Do not use this if you want to open multiple messages at once. Use
+ * |displayMessages| instead.
+ *
+ * @param {nsIMsgHdr} aMsgHdr - The message header to display.
+ * @param {DBViewWrapper} [aViewWrapperToClone] - A view wrapper to clone.
+ * If null or not given, the message header's folder's default view will
+ * be used.
+ * @param {Element} [aTabmail] - A tabmail element to use in case we need to
+ * open tabs. If null or not given:
+ * - if one or more 3pane windows are open, the most recent one's tabmail
+ * is used, and the window is brought to the front
+ * - if no 3pane windows are open, a standalone window is opened instead
+ * of a tab
+ */
+ displayMessage(aMsgHdr, aViewWrapperToClone, aTabmail) {
+ this.displayMessages([aMsgHdr], aViewWrapperToClone, aTabmail);
+ },
+
+ /**
+ * Display the warning if the number of messages to be displayed is greater than
+ * the limit set in preferences.
+ *
+ * @param aNumMessages: number of messages to be displayed
+ * @param aConfirmTitle: title ID
+ * @param aConfirmMsg: message ID
+ * @param aLiitingPref: the name of the pref to retrieve the limit from
+ */
+ confirmAction(aNumMessages, aConfirmTitle, aConfirmMsg, aLimitingPref) {
+ let openWarning = Services.prefs.getIntPref(aLimitingPref);
+ if (openWarning > 1 && aNumMessages >= openWarning) {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let title = bundle.GetStringFromName(aConfirmTitle);
+ let message = lazy.PluralForm.get(
+ aNumMessages,
+ bundle.GetStringFromName(aConfirmMsg)
+ ).replace("#1", aNumMessages);
+ if (!Services.prompt.confirm(null, title, message)) {
+ return true;
+ }
+ }
+ return false;
+ },
+ /**
+ * Display these message headers in new tabs, new windows or existing
+ * windows, depending on the preference, the number of messages, and whether
+ * a 3pane or standalone window is already open. This function should be
+ * called when you'd like to display multiple messages to the user according
+ * to the pref set.
+ *
+ * @param {nsIMsgHdr[]} aMsgHdrs - An array containing the message headers to
+ * display. The array should contain at least one message header.
+ * @param {DBViewWrapper} [aViewWrapperToClone] - A DB view wrapper to clone
+ * for each of the tabs or windows.
+ * @param {Element} [aTabmail] - A tabmail element to use in case we need to
+ * open tabs. If given, the window containing the tabmail is assumed to be
+ * in front. If null or not given:
+ * - if one or more 3pane windows are open, the most recent one's tabmail
+ * is used, and the window is brought to the front
+ * - if no 3pane windows are open, a standalone window is opened instead
+ * of a tab
+ */
+ displayMessages(
+ aMsgHdrs,
+ aViewWrapperToClone,
+ aTabmail,
+ useBackgroundPref = false
+ ) {
+ let openMessageBehavior = Services.prefs.getIntPref(
+ "mail.openMessageBehavior"
+ );
+
+ if (openMessageBehavior == lazy.MailConsts.OpenMessageBehavior.NEW_WINDOW) {
+ this.openMessagesInNewWindows(aMsgHdrs, aViewWrapperToClone);
+ } else if (
+ openMessageBehavior == lazy.MailConsts.OpenMessageBehavior.EXISTING_WINDOW
+ ) {
+ // Try reusing an existing window. If we can't, fall back to opening new
+ // windows
+ if (
+ aMsgHdrs.length > 1 ||
+ !this.openMessageInExistingWindow(aMsgHdrs[0])
+ ) {
+ this.openMessagesInNewWindows(aMsgHdrs, aViewWrapperToClone);
+ }
+ } else if (
+ openMessageBehavior == lazy.MailConsts.OpenMessageBehavior.NEW_TAB
+ ) {
+ let mail3PaneWindow = null;
+ if (!aTabmail) {
+ // Try opening new tabs in a 3pane window
+ mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mail3PaneWindow) {
+ aTabmail = mail3PaneWindow.document.getElementById("tabmail");
+ }
+ }
+
+ if (aTabmail) {
+ if (
+ this.confirmAction(
+ aMsgHdrs.length,
+ "openTabWarningTitle",
+ "openTabWarningConfirmation",
+ "mailnews.open_tab_warning"
+ )
+ ) {
+ return;
+ }
+ const loadInBackground = useBackgroundPref
+ ? Services.prefs.getBoolPref("mail.tabs.loadInBackground")
+ : false;
+
+ // Open all the tabs in the background, except for the last one
+ for (let [i, msgHdr] of aMsgHdrs.entries()) {
+ aTabmail.openTab("mailMessageTab", {
+ messageURI: msgHdr.folder.getUriForMsg(msgHdr),
+ viewWrapper: aViewWrapperToClone,
+ background: i < aMsgHdrs.length - 1 || loadInBackground,
+ disregardOpener: aMsgHdrs.length > 1,
+ });
+ }
+
+ if (mail3PaneWindow) {
+ mail3PaneWindow.focus();
+ }
+ } else {
+ // We still haven't found a tabmail, so we'll need to open new windows
+ this.openMessagesInNewWindows(aMsgHdrs, aViewWrapperToClone);
+ }
+ }
+ },
+
+ /**
+ * Show this message in an existing window.
+ *
+ * @param {nsIMsgHdr} aMsgHdr - The message header to display.
+ * @param {DBViewWrapper} [aViewWrapperToClone] - A DB view wrapper to clone
+ * for the message window.
+ * @returns {boolean} true if an existing window was found and the message
+ * header was displayed, false otherwise.
+ */
+ openMessageInExistingWindow(aMsgHdr, aViewWrapperToClone) {
+ let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow");
+ if (messageWindow) {
+ messageWindow.displayMessage(aMsgHdr, aViewWrapperToClone);
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Open a new standalone message window with this header.
+ *
+ * @param {nsIMsgHdr} aMsgHdr the message header to display
+ * @param {DBViewWrapper} [aViewWrapperToClone] - A DB view wrapper to clone
+ * for the message window.
+ * @returns {DOMWindow} the opened window
+ */
+ openMessageInNewWindow(aMsgHdr, aViewWrapperToClone) {
+ // It sucks that we have to go through XPCOM for this.
+ let args = { msgHdr: aMsgHdr, viewWrapperToClone: aViewWrapperToClone };
+ args.wrappedJSObject = args;
+
+ return Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messageWindow.xhtml",
+ "",
+ "all,chrome,dialog=no,status,toolbar",
+ args
+ );
+ },
+
+ /**
+ * Open new standalone message windows for these headers. This will prompt
+ * for confirmation if the number of windows to be opened is greater than the
+ * value of the mailnews.open_window_warning preference.
+ *
+ * @param {nsIMsgHdr[]} aMsgHdrs - An array containing the message headers
+ * to display.
+ * @param {DBViewWrapper} [aViewWrapperToClone] - A DB view wrapper to clone
+ * for each message window.
+ */
+ openMessagesInNewWindows(aMsgHdrs, aViewWrapperToClone) {
+ if (
+ this.confirmAction(
+ aMsgHdrs.length,
+ "openWindowWarningTitle",
+ "openWindowWarningConfirmation",
+ "mailnews.open_window_warning"
+ )
+ ) {
+ return;
+ }
+
+ for (let msgHdr of aMsgHdrs) {
+ this.openMessageInNewWindow(msgHdr, aViewWrapperToClone);
+ }
+ },
+
+ /**
+ * Display the given folder in the 3pane of the most recent 3pane window.
+ *
+ * @param {string} folderURI - The URI of the folder to display
+ */
+ displayFolderIn3Pane(folderURI) {
+ // Try opening new tabs in a 3pane window
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ let tabmail = win.document.getElementById("tabmail");
+ if (!tabmail.currentAbout3Pane) {
+ tabmail.switchToTab(tabmail.tabInfo[0]);
+ tabmail.updateCurrentTab();
+ }
+ tabmail.currentAbout3Pane.displayFolder(folderURI);
+ win.focus();
+ },
+
+ /**
+ * Display this message header in a folder tab in a 3pane window. This is
+ * useful when the message needs to be displayed in the context of its folder
+ * or thread.
+ *
+ * @param {nsIMsgHdr} msgHdr - The message header to display.
+ * @param {boolean} [openIfMessagePaneHidden] - If true, and the folder tab's
+ * message pane is hidden, opens the message in a new tab or window.
+ * Otherwise uses the folder tab.
+ */
+ displayMessageInFolderTab(msgHdr, openIfMessagePaneHidden) {
+ // Try opening new tabs in a 3pane window
+ let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mail3PaneWindow) {
+ if (openIfMessagePaneHidden) {
+ let tab = mail3PaneWindow.document.getElementById("tabmail").tabInfo[0];
+ if (!tab.chromeBrowser.contentWindow.paneLayout.messagePaneVisible) {
+ this.displayMessage(msgHdr);
+ return;
+ }
+ }
+
+ mail3PaneWindow.MsgDisplayMessageInFolderTab(msgHdr);
+ if (Ci.nsIMessengerWindowsIntegration) {
+ Cc["@mozilla.org/messenger/osintegration;1"]
+ .getService(Ci.nsIMessengerWindowsIntegration)
+ .showWindow(mail3PaneWindow);
+ }
+ mail3PaneWindow.focus();
+ } else {
+ let args = { msgHdr };
+ args.wrappedJSObject = args;
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messenger.xhtml",
+ "",
+ "all,chrome,dialog=no,status,toolbar",
+ args
+ );
+ }
+ },
+
+ /**
+ * Open a message from a message id.
+ *
+ * @param {string} msgId - The message id string without the brackets.
+ */
+ openMessageByMessageId(msgId) {
+ let msgHdr = this.getMsgHdrForMsgId(msgId);
+ if (msgHdr) {
+ this.displayMessage(msgHdr);
+ return;
+ }
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let errorTitle = bundle.GetStringFromName(
+ "errorOpenMessageForMessageIdTitle"
+ );
+ let errorMessage = bundle.formatStringFromName(
+ "errorOpenMessageForMessageIdMessage",
+ [msgId]
+ );
+ Services.prompt.alert(null, errorTitle, errorMessage);
+ },
+
+ /**
+ * Open the given .eml file.
+ *
+ * @param {DOMWindow} win - The window which the file is being opened within.
+ * @param {nsIFile} aFile - The file being opened.
+ * @param {nsIURL} aURL - The full file URL.
+ */
+ openEMLFile(win, aFile, aURL) {
+ let url = aURL
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ let fstream = null;
+ let headers = new Map();
+ // Read this eml and extract its headers to check for X-Unsent.
+ try {
+ fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(aFile, -1, 0, 0);
+ let data = lazy.NetUtil.readInputStreamToString(
+ fstream,
+ fstream.available()
+ );
+ headers = lazy.MimeParser.extractHeaders(data);
+ } catch (e) {
+ // Ignore errors on reading the eml or extracting its headers. The test for
+ // the X-Unsent header below will fail and the message window will take care
+ // of any error handling.
+ } finally {
+ if (fstream) {
+ fstream.close();
+ }
+ }
+
+ if (headers.get("X-Unsent") == "1") {
+ let msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+ );
+ lazy.MailServices.compose.OpenComposeWindow(
+ null,
+ {},
+ url.spec,
+ Ci.nsIMsgCompType.Draft,
+ Ci.nsIMsgCompFormat.Default,
+ null,
+ headers.get("from"),
+ msgWindow
+ );
+ } else if (
+ Services.prefs.getIntPref("mail.openMessageBehavior") ==
+ lazy.MailConsts.OpenMessageBehavior.NEW_TAB &&
+ win.document.getElementById("tabmail")
+ ) {
+ win.document
+ .getElementById("tabmail")
+ .openTab("mailMessageTab", { messageURI: url.spec });
+ } else {
+ win.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ url
+ );
+ }
+ },
+
+ /**
+ * The number of milliseconds to wait between loading of folders in
+ * |takeActionOnFolderAndDescendents|. We wait at all because
+ * opening msf databases is a potentially expensive synchronous operation that
+ * can approach the order of a second in pathological cases like gmail's
+ * all mail folder.
+ *
+ * If we did not use a timer or otherwise spin the event loop we would
+ * completely lock up the UI. In theory we would still maintain some degree
+ * of UI responsiveness if we just used postMessage to break up our work so
+ * that the event loop still got a chance to run between our folder openings.
+ * The use of any delay between processing folders is to try and avoid causing
+ * system-wide interactivity problems from dominating the system's available
+ * disk seeks to such an extent that other applications start experiencing
+ * non-trivial I/O waits.
+ *
+ * The specific choice of delay remains an arbitrary one to maintain app
+ * and system responsiveness per the above while also processing as many
+ * folders as quickly as possible.
+ *
+ * This is exposed primarily to allow unit tests to set this to 0 to minimize
+ * throttling.
+ */
+ INTER_FOLDER_PROCESSING_DELAY_MS: 10,
+
+ /**
+ * Set a string property on a folder and all of its descendents, taking care
+ * to avoid locking up the main thread and to avoid leaving folder databases
+ * open. To avoid locking up the main thread we operate in an asynchronous
+ * fashion; we invoke a callback when we have completed our work.
+ *
+ * Using this function will write the value into the folder cache
+ * as well as the folder itself. Hopefully you want this; if
+ * you do not, keep in mind that the only way to avoid that is to retrieve
+ * the nsIMsgDatabase and then the nsIDbFolderInfo. You would want to avoid
+ * that as much as possible because once those are exposed to you, XPConnect
+ * is going to hold onto them creating a situation where you are going to be
+ * in severe danger of extreme memory bloat unless you force garbage
+ * collections after every time you close a database.
+ *
+ * @param {nsIMsgFolder} folder - The parent folder; we take action on it and all
+ * of its descendents.
+ * @param {Function} action - the function to call on each folder.
+ */
+ async takeActionOnFolderAndDescendents(folder, action) {
+ // We need to add the base folder as it is not included by .descendants.
+ let allFolders = [folder, ...folder.descendants];
+
+ // - worker function
+ function* folderWorker() {
+ for (let folder of allFolders) {
+ action(folder);
+ yield undefined;
+ }
+ }
+ let worker = folderWorker();
+
+ return new Promise((resolve, reject) => {
+ // - driver logic
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ function folderDriver() {
+ try {
+ if (worker.next().done) {
+ timer.cancel();
+ resolve();
+ }
+ } catch (ex) {
+ // Any type of exception kills the generator.
+ timer.cancel();
+ reject(ex);
+ }
+ }
+ // make sure there is at least 100 ms of not us between doing things.
+ timer.initWithCallback(
+ folderDriver,
+ this.INTER_FOLDER_PROCESSING_DELAY_MS,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ });
+ },
+
+ /**
+ * Get the identity that most likely is the best one to use, given the hint.
+ *
+ * @param {nsIMsgIdentity[]} identities - The candidates to pick from.
+ * @param {string} [optionalHint] - String containing comma separated mailboxes.
+ * @param {boolean} useDefault - If true, use the default identity of the
+ * account as last choice. This is useful when all default account as last
+ * choice. This is useful when all identities are passed in. Otherwise, use
+ * the first entity in the list.
+ * @returns {Array} - An array of two elements, [identity, matchingHint].
+ * identity is an nsIMsgIdentity and matchingHint is a string.
+ */
+ getBestIdentity(identities, optionalHint, useDefault = false) {
+ let identityCount = identities.length;
+ if (identityCount < 1) {
+ return [null, null];
+ }
+
+ // If we have a hint to help us pick one identity, search for a match.
+ // Even if we only have one identity, check which hint might match.
+ if (optionalHint) {
+ let hints =
+ lazy.MailServices.headerParser.makeFromDisplayAddress(optionalHint);
+
+ for (let hint of hints) {
+ for (let identity of identities.filter(i => i.email)) {
+ if (hint.email.toLowerCase() == identity.email.toLowerCase()) {
+ return [identity, hint];
+ }
+ }
+ }
+
+ // Lets search again, this time for a match from catchAll.
+ for (let hint of hints) {
+ for (let identity of identities.filter(
+ i => i.email && i.catchAll && i.catchAllHint
+ )) {
+ for (let caHint of identity.catchAllHint.toLowerCase().split(",")) {
+ // If the hint started with *@, it applies to the whole domain. In
+ // this case return the hint so it can be used for replying.
+ // If the hint was for a more specific hint, don't return a hint
+ // so that the normal from address for the identity is used.
+ let wholeDomain = caHint.trim().startsWith("*@");
+ caHint = caHint.trim().replace(/^\*/, ""); // Remove initial star.
+ if (hint.email.toLowerCase().includes(caHint)) {
+ return wholeDomain ? [identity, hint] : [identity, null];
+ }
+ }
+ }
+ }
+ }
+
+ // Still no matches? Give up and pick the default or the first one.
+ if (useDefault) {
+ let defaultAccount = lazy.MailServices.accounts.defaultAccount;
+ if (defaultAccount && defaultAccount.defaultIdentity) {
+ return [defaultAccount.defaultIdentity, null];
+ }
+ }
+
+ return [identities[0], null];
+ },
+
+ getIdentityForServer(server, optionalHint) {
+ let identities = lazy.MailServices.accounts.getIdentitiesForServer(server);
+ return this.getBestIdentity(identities, optionalHint);
+ },
+
+ /**
+ * Get the identity for the given header.
+ *
+ * @param {nsIMsgHdr} hdr - Message header.
+ * @param {nsIMsgCompType} type - Compose type the identity is used for.
+ * @returns {Array} - An array of two elements, [identity, matchingHint].
+ * identity is an nsIMsgIdentity and matchingHint is a string.
+ */
+ getIdentityForHeader(hdr, type, hint = "") {
+ let server = null;
+ let identity = null;
+ let matchingHint = null;
+ let folder = hdr.folder;
+ if (folder) {
+ server = folder.server;
+ identity = folder.customIdentity;
+ if (identity) {
+ return [identity, null];
+ }
+ }
+
+ if (!server) {
+ let accountKey = hdr.accountKey;
+ if (accountKey) {
+ let account = lazy.MailServices.accounts.getAccount(accountKey);
+ if (account) {
+ server = account.incomingServer;
+ }
+ }
+ }
+
+ let hintForIdentity = "";
+ if (type == Ci.nsIMsgCompType.ReplyToList) {
+ hintForIdentity = hint;
+ } else if (
+ type == Ci.nsIMsgCompType.Template ||
+ type == Ci.nsIMsgCompType.EditTemplate ||
+ type == Ci.nsIMsgCompType.EditAsNew
+ ) {
+ hintForIdentity = hdr.author;
+ } else {
+ hintForIdentity = hdr.recipients + "," + hdr.ccList + "," + hint;
+ }
+
+ if (server) {
+ [identity, matchingHint] = this.getIdentityForServer(
+ server,
+ hintForIdentity
+ );
+ }
+
+ if (!identity) {
+ [identity, matchingHint] = this.getBestIdentity(
+ lazy.MailServices.accounts.allIdentities,
+ hintForIdentity,
+ true
+ );
+ }
+ return [identity, matchingHint];
+ },
+
+ getInboxFolder(server) {
+ try {
+ var rootMsgFolder = server.rootMsgFolder;
+
+ // Now find the Inbox.
+ return rootMsgFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox);
+ } catch (ex) {
+ dump(ex + "\n");
+ }
+ return null;
+ },
+
+ /**
+ * Finds a mailing list anywhere in the address books.
+ *
+ * @param {string} entryName - Value against which dirName is checked.
+ * @returns {nsIAbDirectory|null} - Found list or null.
+ */
+ findListInAddressBooks(entryName) {
+ for (let abDir of lazy.MailServices.ab.directories) {
+ if (abDir.supportsMailingLists) {
+ for (let dir of abDir.childNodes) {
+ if (dir.isMailList && dir.dirName == entryName) {
+ return dir;
+ }
+ }
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Recursively search for message id in a given folder and its subfolders,
+ * return the first one found.
+ *
+ * @param {string} msgId - The message id to find.
+ * @param {nsIMsgFolder} folder - The folder to check.
+ * @returns {nsIMsgDBHdr}
+ */
+ findMsgIdInFolder(msgId, folder) {
+ let msgHdr;
+
+ // Search in folder.
+ if (!folder.isServer) {
+ try {
+ msgHdr = folder.msgDatabase.getMsgHdrForMessageID(msgId);
+ if (msgHdr) {
+ return msgHdr;
+ }
+ folder.closeDBIfFolderNotOpen(true);
+ } catch (ex) {
+ console.error(`Database for ${folder.name} not accessible`);
+ }
+ }
+
+ // Search subfolders recursively.
+ for (let currentFolder of folder.subFolders) {
+ msgHdr = this.findMsgIdInFolder(msgId, currentFolder);
+ if (msgHdr) {
+ return msgHdr;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Recursively search for message id in all msg folders, return the first one
+ * found.
+ *
+ * @param {string} msgId - The message id to search for.
+ * @param {nsIMsgIncomingServer} [startServer] - The server to check first.
+ * @returns {nsIMsgDBHdr}
+ */
+ getMsgHdrForMsgId(msgId, startServer) {
+ let allServers = lazy.MailServices.accounts.allServers;
+ if (startServer) {
+ allServers = [startServer].concat(
+ allServers.filter(s => s.key != startServer.key)
+ );
+ }
+ for (let server of allServers) {
+ if (server && server.canSearchMessages && !server.isDeferredTo) {
+ let msgHdr = this.findMsgIdInFolder(msgId, server.rootFolder);
+ if (msgHdr) {
+ return msgHdr;
+ }
+ }
+ }
+ return null;
+ },
+};
+
+/**
+ * A class that listens to notifications about folders, and deals with them
+ * appropriately.
+ * @implements {nsIObserver}
+ */
+class FolderNotificationManager {
+ QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
+
+ static #manager = null;
+
+ static init() {
+ if (FolderNotificationManager.#manager) {
+ return;
+ }
+ FolderNotificationManager.#manager = new FolderNotificationManager();
+ }
+
+ constructor() {
+ Services.obs.addObserver(this, "profile-before-change");
+ Services.obs.addObserver(this, "folder-attention");
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "profile-before-change":
+ Services.obs.removeObserver(this, "profile-before-change");
+ Services.obs.removeObserver(this, "folder-attention");
+ return;
+ case "folder-attention":
+ MailUtils.displayFolderIn3Pane(
+ subject.QueryInterface(Ci.nsIMsgFolder).URI
+ );
+ }
+ }
+}
+FolderNotificationManager.init();
diff --git a/comm/mail/modules/MailViewManager.jsm b/comm/mail/modules/MailViewManager.jsm
new file mode 100644
index 0000000000..f81221e609
--- /dev/null
+++ b/comm/mail/modules/MailViewManager.jsm
@@ -0,0 +1,169 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["MailViewManager", "MailViewConstants"];
+
+/**
+ * Put the MailViewConstants in an object so we can export them to
+ * msgViewPickerOverlay in one blob without contaminating everyone's address
+ * space who might want to import us.
+ */
+var MailViewConstants = {
+ // tag views have kViewTagMarker + their key as value
+ kViewItemAll: 0,
+ kViewItemUnread: 1,
+ kViewItemTags: 2, // former labels used values 2-6
+ kViewItemNotDeleted: 3,
+ // not a real view! a sentinel value to pop up a dialog
+ kViewItemVirtual: 7,
+ // not a real view! a sentinel value to pop up a dialog
+ kViewItemCustomize: 8,
+ kViewItemFirstCustom: 9,
+
+ kViewCurrent: "current-view",
+ kViewCurrentTag: "current-view-tag",
+ kViewTagMarker: ":",
+};
+
+/**
+ * MailViews are view 'filters' implemented using search terms. DBViewWrapper
+ * uses the SearchSpec class to combine the search terms of the mailview with
+ * those of the virtual folder (if applicable) and the quicksearch (if
+ * applicable).
+ */
+var MailViewManager = {
+ _views: {},
+ _customMailViews: Cc["@mozilla.org/messenger/mailviewlist;1"].getService(
+ Ci.nsIMsgMailViewList
+ ),
+
+ /**
+ * Define one of the built-in mail-views. If you want to define your own
+ * view, you need to define a custom view using nsIMsgMailViewList.
+ *
+ * We define our own little view definition abstraction because some day this
+ * functionality may want to be generalized to be usable by gloda as well.
+ *
+ * @param aViewDef The view definition, three attributes are required:
+ * - name: A string name for the view, for debugging purposes only. This
+ * should not be localized!
+ * - index: The index to assign to the view.
+ * - makeTerms: A function to invoke that returns a list of search terms.
+ */
+ defineView(aViewDef) {
+ this._views[aViewDef.index] = aViewDef;
+ },
+
+ /**
+ * Wrap a custom view into our cute little view abstraction. We do not cache
+ * these because views should not change often enough for it to matter from
+ * a performance perspective, but they will change enough to make stale
+ * caches a potential issue.
+ */
+ _wrapCustomView(aCustomViewIndex) {
+ let mailView = this._customMailViews.getMailViewAt(aCustomViewIndex);
+ return {
+ name: mailView.prettyName, // since the user created it it's localized
+ index: aCustomViewIndex,
+ makeTerms(aSession, aData) {
+ return mailView.searchTerms;
+ },
+ };
+ },
+
+ _findCustomViewByName(aName) {
+ let count = this._customMailViews.mailViewCount;
+ for (let i = 0; i < count; i++) {
+ let mailView = this._customMailViews.getMailViewAt(i);
+ if (mailView.mailViewName == aName) {
+ return this._wrapCustomView(i);
+ }
+ }
+ throw new Error("No custom view with name: " + aName);
+ },
+
+ /**
+ * Return the view definition associated with the given view index.
+ *
+ * @param aViewIndex If the value is an integer it references the built-in
+ * view with the view index from MailViewConstants, or if the index
+ * is >= MailViewConstants.kViewItemFirstCustom, it is a reference to
+ * a custom view definition. If the value is a string, it is the name
+ * of a custom view. The string case is mainly intended for testing
+ * purposes.
+ */
+ getMailViewByIndex(aViewIndex) {
+ if (typeof aViewIndex == "string") {
+ return this._findCustomViewByName(aViewIndex);
+ }
+ if (aViewIndex < MailViewConstants.kViewItemFirstCustom) {
+ return this._views[aViewIndex];
+ }
+ return this._wrapCustomView(
+ aViewIndex - MailViewConstants.kViewItemFirstCustom
+ );
+ },
+};
+
+MailViewManager.defineView({
+ name: "all mail", // debugging assistance only! not localized!
+ index: MailViewConstants.kViewItemAll,
+ makeTerms(aSession, aData) {
+ return null;
+ },
+});
+
+MailViewManager.defineView({
+ name: "new mail / unread", // debugging assistance only! not localized!
+ index: MailViewConstants.kViewItemUnread,
+ makeTerms(aSession, aData) {
+ let term = aSession.createTerm();
+ let value = term.value;
+
+ value.status = Ci.nsMsgMessageFlags.Read;
+ value.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ term.value = value;
+ term.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ term.op = Ci.nsMsgSearchOp.Isnt;
+ term.booleanAnd = true;
+
+ return [term];
+ },
+});
+
+MailViewManager.defineView({
+ name: "tags", // debugging assistance only! not localized!
+ index: MailViewConstants.kViewItemTags,
+ makeTerms(aSession, aKeyword) {
+ let term = aSession.createTerm();
+ let value = term.value;
+
+ value.str = aKeyword;
+ value.attrib = Ci.nsMsgSearchAttrib.Keywords;
+ term.value = value;
+ term.attrib = Ci.nsMsgSearchAttrib.Keywords;
+ term.op = Ci.nsMsgSearchOp.Contains;
+ term.booleanAnd = true;
+
+ return [term];
+ },
+});
+
+MailViewManager.defineView({
+ name: "not deleted", // debugging assistance only! not localized!
+ index: MailViewConstants.kViewItemNotDeleted,
+ makeTerms(aSession, aKeyword) {
+ let term = aSession.createTerm();
+ let value = term.value;
+
+ value.status = Ci.nsMsgMessageFlags.IMAPDeleted;
+ value.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ term.value = value;
+ term.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ term.op = Ci.nsMsgSearchOp.Isnt;
+ term.booleanAnd = true;
+
+ return [term];
+ },
+});
diff --git a/comm/mail/modules/MessageArchiver.jsm b/comm/mail/modules/MessageArchiver.jsm
new file mode 100644
index 0000000000..bf13a17295
--- /dev/null
+++ b/comm/mail/modules/MessageArchiver.jsm
@@ -0,0 +1,392 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["MessageArchiver"];
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+);
+
+function MessageArchiver() {
+ this._batches = {};
+ this._currentKey = null;
+ this._dstFolderParent = null;
+ this._dstFolderName = null;
+
+ this.msgWindow = null;
+ this.oncomplete = null;
+}
+
+/**
+ * The maximum number of messages to try to examine directly to determine if
+ * they can be archived; if we exceed this count, we'll try to approximate
+ * the answer by looking at the server's identities. This is only here to
+ * let tests tweak the value.
+ */
+MessageArchiver.MAX_COUNT_FOR_CAN_ARCHIVE_CHECK = 100;
+MessageArchiver.canArchive = function (messages, isSingleFolder) {
+ if (messages.length == 0) {
+ return false;
+ }
+
+ // If we're looking at a single folder (i.e. not a cross-folder search), we
+ // can just check to see if all the identities for this folder/server have
+ // archives enabled (or disabled). This is way faster than checking every
+ // message. Note: this may be slightly inaccurate if the identity for a
+ // header is actually on another server.
+ if (
+ messages.length > MessageArchiver.MAX_COUNT_FOR_CAN_ARCHIVE_CHECK &&
+ isSingleFolder
+ ) {
+ let folder = messages[0].folder;
+ let folderIdentity = folder.customIdentity;
+ if (folderIdentity) {
+ return folderIdentity.archiveEnabled;
+ }
+
+ if (folder.server) {
+ let serverIdentities = MailServices.accounts.getIdentitiesForServer(
+ folder.server
+ );
+
+ // Do all identities have the same archiveEnabled setting?
+ if (serverIdentities.every(id => id.archiveEnabled)) {
+ return true;
+ }
+ if (serverIdentities.every(id => !id.archiveEnabled)) {
+ return false;
+ }
+ // If we get here it's a mixture, so have to examine all the messages.
+ }
+ }
+
+ // Either we've selected a small number of messages or we just can't
+ // fast-path the result; examine all the messages.
+ return messages.every(function (msg) {
+ let [identity] = lazy.MailUtils.getIdentityForHeader(msg);
+ return Boolean(identity && identity.archiveEnabled);
+ });
+};
+
+// Bad things happen if you have multiple archivers running on the same
+// messages (See Bug 1705824). We could probably make this more fine
+// grained, and maintain a list of messages/folders already queued up...
+// but that'd get complex quick, so let's keep things simple for now and
+// only allow one active archiver.
+let gIsArchiving = false;
+
+MessageArchiver.prototype = {
+ archiveMessages(aMsgHdrs) {
+ if (!aMsgHdrs.length) {
+ return;
+ }
+ if (gIsArchiving) {
+ throw new Error("Can only have one MessageArchiver running at once");
+ }
+ gIsArchiving = true;
+
+ for (let i = 0; i < aMsgHdrs.length; i++) {
+ let msgHdr = aMsgHdrs[i];
+
+ let server = msgHdr.folder.server;
+
+ // Convert date to JS date object.
+ let msgDate = new Date(msgHdr.date / 1000);
+ let msgYear = msgDate.getFullYear().toString();
+ let monthFolderName =
+ msgYear + "-" + (msgDate.getMonth() + 1).toString().padStart(2, "0");
+
+ let archiveFolderURI;
+ let archiveGranularity;
+ let archiveKeepFolderStructure;
+
+ let [identity] = lazy.MailUtils.getIdentityForHeader(msgHdr);
+ if (!identity || msgHdr.folder.server.type == "rss") {
+ // If no identity, or a server (RSS) which doesn't have an identity
+ // and doesn't want the default unrelated identity value, figure
+ // this out based on the default identity prefs.
+ let enabled = Services.prefs.getBoolPref(
+ "mail.identity.default.archive_enabled"
+ );
+ if (!enabled) {
+ continue;
+ }
+
+ archiveFolderURI = server.serverURI + "/Archives";
+ archiveGranularity = Services.prefs.getIntPref(
+ "mail.identity.default.archive_granularity"
+ );
+ archiveKeepFolderStructure = Services.prefs.getBoolPref(
+ "mail.identity.default.archive_keep_folder_structure"
+ );
+ } else {
+ if (!identity.archiveEnabled) {
+ continue;
+ }
+
+ archiveFolderURI = identity.archiveFolder;
+ archiveGranularity = identity.archiveGranularity;
+ archiveKeepFolderStructure = identity.archiveKeepFolderStructure;
+ }
+
+ let copyBatchKey = msgHdr.folder.URI;
+ if (archiveGranularity >= Ci.nsIMsgIdentity.perYearArchiveFolders) {
+ copyBatchKey += "\0" + msgYear;
+ }
+
+ if (archiveGranularity >= Ci.nsIMsgIdentity.perMonthArchiveFolders) {
+ copyBatchKey += "\0" + monthFolderName;
+ }
+
+ if (archiveKeepFolderStructure) {
+ copyBatchKey += msgHdr.folder.URI;
+ }
+
+ // Add a key to copyBatchKey
+ if (!(copyBatchKey in this._batches)) {
+ this._batches[copyBatchKey] = {
+ srcFolder: msgHdr.folder,
+ archiveFolderURI,
+ granularity: archiveGranularity,
+ keepFolderStructure: archiveKeepFolderStructure,
+ yearFolderName: msgYear,
+ monthFolderName,
+ messages: [],
+ };
+ }
+ this._batches[copyBatchKey].messages.push(msgHdr);
+ }
+ MailServices.mfn.addListener(this, MailServices.mfn.folderAdded);
+
+ // Now we launch the code iterating over all message copies, one in turn.
+ this.processNextBatch();
+ },
+
+ processNextBatch() {
+ // get the first defined key and value
+ for (let key in this._batches) {
+ this._currentBatch = this._batches[key];
+ delete this._batches[key];
+ this.filterBatch();
+ return;
+ }
+ // All done!
+ this._batches = null;
+ MailServices.mfn.removeListener(this);
+
+ if (typeof this.oncomplete == "function") {
+ this.oncomplete();
+ }
+ gIsArchiving = false;
+ },
+
+ filterBatch() {
+ let batch = this._currentBatch;
+ // Apply filters to this batch.
+ MailServices.filters.applyFilters(
+ Ci.nsMsgFilterType.Archive,
+ batch.messages,
+ batch.srcFolder,
+ this.msgWindow,
+ this
+ );
+ // continues with onStopOperation
+ },
+
+ onStopOperation(aResult) {
+ if (!Components.isSuccessCode(aResult)) {
+ console.error("Archive filter failed: " + aResult);
+ // We don't want to effectively disable archiving because a filter
+ // failed, so we'll continue after reporting the error.
+ }
+ // Now do the default archive processing
+ this.continueBatch();
+ },
+
+ // continue processing of default archive operations
+ continueBatch() {
+ let batch = this._currentBatch;
+ let srcFolder = batch.srcFolder;
+ let archiveFolderURI = batch.archiveFolderURI;
+ let archiveFolder = lazy.MailUtils.getOrCreateFolder(archiveFolderURI);
+ let dstFolder = archiveFolder;
+
+ let moveArray = [];
+ // Don't move any items that the filter moves or deleted
+ for (let item of batch.messages) {
+ if (
+ srcFolder.msgDatabase.containsKey(item.messageKey) &&
+ !(
+ srcFolder.getProcessingFlags(item.messageKey) &
+ Ci.nsMsgProcessingFlags.FilterToMove
+ )
+ ) {
+ moveArray.push(item);
+ }
+ }
+
+ if (moveArray.length == 0) {
+ // Continue processing.
+ this.processNextBatch();
+ }
+
+ // For folders on some servers (e.g. IMAP), we need to create the
+ // sub-folders asynchronously, so we chain the urls using the listener
+ // called back from createStorageIfMissing. For local,
+ // createStorageIfMissing is synchronous.
+ let isAsync = archiveFolder.server.protocolInfo.foldersCreatedAsync;
+ if (!archiveFolder.parent) {
+ archiveFolder.setFlag(Ci.nsMsgFolderFlags.Archive);
+ archiveFolder.createStorageIfMissing(this);
+ if (isAsync) {
+ // Continues with OnStopRunningUrl.
+ return;
+ }
+ }
+
+ let granularity = batch.granularity;
+ let forceSingle = !archiveFolder.canCreateSubfolders;
+ if (
+ !forceSingle &&
+ archiveFolder.server instanceof Ci.nsIImapIncomingServer
+ ) {
+ forceSingle = archiveFolder.server.isGMailServer;
+ }
+ if (forceSingle) {
+ granularity = Ci.nsIMsgIncomingServer.singleArchiveFolder;
+ }
+
+ if (granularity >= Ci.nsIMsgIdentity.perYearArchiveFolders) {
+ archiveFolderURI += "/" + batch.yearFolderName;
+ dstFolder = lazy.MailUtils.getOrCreateFolder(archiveFolderURI);
+ if (!dstFolder.parent) {
+ dstFolder.createStorageIfMissing(this);
+ if (isAsync) {
+ // Continues with OnStopRunningUrl.
+ return;
+ }
+ }
+ }
+ if (granularity >= Ci.nsIMsgIdentity.perMonthArchiveFolders) {
+ archiveFolderURI += "/" + batch.monthFolderName;
+ dstFolder = lazy.MailUtils.getOrCreateFolder(archiveFolderURI);
+ if (!dstFolder.parent) {
+ dstFolder.createStorageIfMissing(this);
+ if (isAsync) {
+ // Continues with OnStopRunningUrl.
+ return;
+ }
+ }
+ }
+
+ // Create the folder structure in Archives.
+ // For imap folders, we need to create the sub-folders asynchronously,
+ // so we chain the actions using the listener called back from
+ // createSubfolder. For local, createSubfolder is synchronous.
+ if (archiveFolder.canCreateSubfolders && batch.keepFolderStructure) {
+ // Collect in-order list of folders of source folder structure,
+ // excluding top-level INBOX folder
+ let folderNames = [];
+ let rootFolder = srcFolder.server.rootFolder;
+ let inboxFolder = lazy.MailUtils.getInboxFolder(srcFolder.server);
+ let folder = srcFolder;
+ while (folder != rootFolder && folder != inboxFolder) {
+ folderNames.unshift(folder.name);
+ folder = folder.parent;
+ }
+ // Determine Archive folder structure.
+ for (let i = 0; i < folderNames.length; ++i) {
+ let folderName = folderNames[i];
+ if (!dstFolder.containsChildNamed(folderName)) {
+ // Create Archive sub-folder (IMAP: async).
+ if (isAsync) {
+ this._dstFolderParent = dstFolder;
+ this._dstFolderName = folderName;
+ }
+ dstFolder.createSubfolder(folderName, this.msgWindow);
+ if (isAsync) {
+ // Continues with folderAdded.
+ return;
+ }
+ }
+ dstFolder = dstFolder.getChildNamed(folderName);
+ }
+ }
+
+ if (dstFolder != srcFolder) {
+ let isNews = srcFolder.flags & Ci.nsMsgFolderFlags.Newsgroup;
+ // If the source folder doesn't support deleting messages, we
+ // make archive a copy, not a move.
+ MailServices.copy.copyMessages(
+ srcFolder,
+ moveArray,
+ dstFolder,
+ srcFolder.canDeleteMessages && !isNews,
+ this,
+ this.msgWindow,
+ true
+ );
+ return; // continues with OnStopCopy
+ }
+ this.processNextBatch(); // next batch
+ },
+
+ // @implements {nsIUrlListener}
+ OnStartRunningUrl(url) {},
+ OnStopRunningUrl(url, exitCode) {
+ // this will always be a create folder url, afaik.
+ if (Components.isSuccessCode(exitCode)) {
+ this.continueBatch();
+ } else {
+ console.error("Archive failed to create folder: " + exitCode);
+ this._batches = null;
+ this.processNextBatch(); // for cleanup and exit
+ }
+ },
+
+ // also implements nsIMsgCopyServiceListener, but we only care
+ // about the OnStopCopy
+ // @implements {nsIMsgCopyServiceListener}
+ OnStartCopy() {},
+ OnProgress(aProgress, aProgressMax) {},
+ SetMessageKey(aKey) {},
+ GetMessageId() {},
+ OnStopCopy(aStatus) {
+ if (Components.isSuccessCode(aStatus)) {
+ this.processNextBatch();
+ } else {
+ // stop on error
+ console.error("Archive failed to copy: " + aStatus);
+ this._batches = null;
+ this.processNextBatch(); // for cleanup and exit
+ }
+ },
+
+ // This also implements nsIMsgFolderListener, but we only care about the
+ // folderAdded (createSubfolder callback).
+ // @implements {nsIMsgFolderListener}
+ folderAdded(aFolder) {
+ // Check that this is the folder we're interested in.
+ if (
+ aFolder.parent == this._dstFolderParent &&
+ aFolder.name == this._dstFolderName
+ ) {
+ this._dstFolderParent = null;
+ this._dstFolderName = null;
+ this.continueBatch();
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIUrlListener",
+ "nsIMsgCopyServiceListener",
+ "nsIMsgOperationListener",
+ ]),
+};
diff --git a/comm/mail/modules/MsgHdrSyntheticView.jsm b/comm/mail/modules/MsgHdrSyntheticView.jsm
new file mode 100644
index 0000000000..0219e13a1c
--- /dev/null
+++ b/comm/mail/modules/MsgHdrSyntheticView.jsm
@@ -0,0 +1,67 @@
+/* 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 object provides you a way to have a synthetic nsIMsgDBView for a single
+ * message header.
+ */
+
+var EXPORTED_SYMBOLS = ["MsgHdrSyntheticView"];
+
+/**
+ * Create a synthetic view suitable for passing to |FolderDisplayWidget.show|.
+ * You must pass a single message header in.
+ *
+ * @param aMsgHdr The message header to create the synthetic view for.
+ */
+function MsgHdrSyntheticView(aMsgHdr) {
+ this.msgHdr = aMsgHdr;
+
+ this.customColumns = [];
+}
+
+MsgHdrSyntheticView.prototype = {
+ defaultSort: [
+ [Ci.nsMsgViewSortType.byDate, Ci.nsMsgViewSortOrder.descending],
+ ],
+
+ /**
+ * Request the search be performed and notifications provided to
+ * aSearchListener. Since we already have the result with us, this is
+ * synchronous.
+ */
+ search(aSearchListener, aCompletionCallback) {
+ this.searchListener = aSearchListener;
+ this.completionCallback = aCompletionCallback;
+ aSearchListener.onNewSearch();
+ aSearchListener.onSearchHit(this.msgHdr, this.msgHdr.folder);
+ // we're not really aborting, but it closes things out nicely
+ this.abortSearch();
+ },
+
+ /**
+ * Aborts or completes the search -- we do not make a distinction.
+ */
+ abortSearch() {
+ if (this.searchListener) {
+ this.searchListener.onSearchDone(Cr.NS_OK);
+ }
+ if (this.completionCallback) {
+ this.completionCallback();
+ }
+ this.searchListener = null;
+ this.completionCallback = null;
+ },
+
+ /**
+ * Helper function used by |DBViewWrapper.getMsgHdrForMessageID|.
+ */
+ getMsgHdrForMessageID(aMessageId) {
+ if (this.msgHdr.messageId == aMessageId) {
+ return this.msgHdr;
+ }
+
+ return null;
+ },
+};
diff --git a/comm/mail/modules/PhishingDetector.jsm b/comm/mail/modules/PhishingDetector.jsm
new file mode 100644
index 0000000000..016530fd96
--- /dev/null
+++ b/comm/mail/modules/PhishingDetector.jsm
@@ -0,0 +1,335 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["PhishingDetector"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ isLegalIPAddress: "resource:///modules/hostnameUtils.jsm",
+ isLegalLocalIPAddress: "resource:///modules/hostnameUtils.jsm",
+});
+
+const PhishingDetector = new (class PhishingDetector {
+ mEnabled = true;
+ mCheckForIPAddresses = true;
+ mCheckForMismatchedHosts = true;
+ mDisallowFormActions = true;
+
+ constructor() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "mEnabled",
+ "mail.phishing.detection.enabled",
+ true
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "mCheckForIPAddresses",
+ "mail.phishing.detection.ipaddresses",
+ true
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "mCheckForMismatchedHosts",
+ "mail.phishing.detection.mismatched_hosts",
+ true
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "mDisallowFormActions",
+ "mail.phishing.detection.disallow_form_actions",
+ true
+ );
+ }
+
+ /**
+ * Analyze the currently loaded message in the message pane, looking for signs
+ * of a phishing attempt. Also checks for forms with action URLs, which are
+ * disallowed.
+ * Assumes the message has finished loading in the message pane (i.e.
+ * OnMsgParsed has fired).
+ *
+ * @param {nsIMsgMailNewsUrl} aUrl
+ * Url for the message being analyzed.
+ * @param {Element} browser
+ * The browser element where the message is loaded.
+ * @returns {boolean}
+ * Returns true if this does have phishing urls. Returns false if we
+ * do not check this message or the phishing message does not need to be
+ * displayed.
+ */
+ analyzeMsgForPhishingURLs(aUrl, browser) {
+ if (!aUrl || !this.mEnabled) {
+ return false;
+ }
+
+ try {
+ // nsIMsgMailNewsUrl.folder can throw an NS_ERROR_FAILURE, especially if
+ // we are opening an .eml file.
+ var folder = aUrl.folder;
+
+ // Ignore nntp and RSS messages.
+ if (
+ !folder ||
+ folder.server.type == "nntp" ||
+ folder.server.type == "rss"
+ ) {
+ return false;
+ }
+
+ // Also ignore messages in Sent/Drafts/Templates/Outbox.
+ let outgoingFlags =
+ Ci.nsMsgFolderFlags.SentMail |
+ Ci.nsMsgFolderFlags.Drafts |
+ Ci.nsMsgFolderFlags.Templates |
+ Ci.nsMsgFolderFlags.Queue;
+ if (folder.isSpecialFolder(outgoingFlags, true)) {
+ return false;
+ }
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_FAILURE) {
+ throw ex;
+ }
+ }
+
+ // If the message contains forms with action attributes, warn the user.
+ let formNodes = browser.contentDocument.querySelectorAll("form[action]");
+
+ return this.mDisallowFormActions && formNodes.length > 0;
+ }
+
+ /**
+ * Analyze the url contained in aLinkNode for phishing attacks.
+ *
+ * @param {string} aHref - the url to be analyzed
+ * @param {string} [aLinkText] - user visible link text associated with aHref
+ * in case we are dealing with a link node.
+ * @returns true if link node contains phishing URL. false otherwise.
+ */
+ #analyzeUrl(aUrl, aLinkText) {
+ if (!aUrl) {
+ return false;
+ }
+
+ let hrefURL;
+ // make sure relative link urls don't make us bail out
+ try {
+ hrefURL = Services.io.newURI(aUrl);
+ } catch (ex) {
+ return false;
+ }
+
+ // only check for phishing urls if the url is an http or https link.
+ // this prevents us from flagging imap and other internally handled urls
+ if (hrefURL.schemeIs("http") || hrefURL.schemeIs("https")) {
+ // The link is not suspicious if the visible text is the same as the URL,
+ // even if the URL is an IP address. URLs are commonly surrounded by
+ // < > or "" (RFC2396E) - so strip those from the link text before comparing.
+ if (aLinkText) {
+ aLinkText = aLinkText.replace(/^<(.+)>$|^"(.+)"$/, "$1$2");
+ }
+
+ var failsStaticTests = false;
+ // If the link text and url differs by something other than a trailing
+ // slash, do some further checks.
+ if (
+ aLinkText &&
+ aLinkText != aUrl &&
+ aLinkText.replace(/\/+$/, "") != aUrl.replace(/\/+$/, "")
+ ) {
+ if (this.mCheckForIPAddresses) {
+ let unobscuredHostNameValue = lazy.isLegalIPAddress(
+ hrefURL.host,
+ true
+ );
+ if (unobscuredHostNameValue) {
+ failsStaticTests = !lazy.isLegalLocalIPAddress(
+ unobscuredHostNameValue
+ );
+ }
+ }
+
+ if (!failsStaticTests && this.mCheckForMismatchedHosts) {
+ failsStaticTests =
+ aLinkText && this.misMatchedHostWithLinkText(hrefURL, aLinkText);
+ }
+ }
+ // We don't use dynamic checks anymore. The old implementation was removed
+ // in bug bug 1085382. Using the toolkit safebrowsing is bug 778611.
+ //
+ // Because these static link checks tend to cause false positives
+ // we delay showing the warning until a user tries to click the link.
+ if (failsStaticTests) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Opens the default browser to a page where the user can submit the given url
+ * as a phish.
+ *
+ * @param aPhishingURL the url we want to report back as a phishing attack
+ */
+ reportPhishingURL(aPhishingURL) {
+ let reportUrl = Services.urlFormatter.formatURLPref(
+ "browser.safebrowsing.reportPhishURL"
+ );
+ reportUrl += "&url=" + encodeURIComponent(aPhishingURL);
+
+ let uri = Services.io.newURI(reportUrl);
+ let protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ protocolSvc.loadURI(uri);
+ }
+
+ /**
+ * Private helper method to determine if the link node contains a user visible
+ * url with a host name that differs from the actual href the user would get
+ * taken to.
+ * i.e. <a href="http://myevilsite.com">http://mozilla.org</a>
+ *
+ * @returns true if aHrefURL.host does NOT match the host of the link node text
+ */
+ misMatchedHostWithLinkText(aHrefURL, aLinkNodeText) {
+ // gatherTextUnder puts a space between each piece of text it gathers,
+ // so strip the spaces out (see bug 326082 for details).
+ aLinkNodeText = aLinkNodeText.replace(/ /g, "");
+
+ // Only worry about http: and https: urls.
+ if (/^https?:/.test(aLinkNodeText)) {
+ let linkTextURI = Services.io.newURI(aLinkNodeText);
+
+ // Compare the base domain of the href and the link text.
+ try {
+ return (
+ Services.eTLD.getBaseDomain(aHrefURL) !=
+ Services.eTLD.getBaseDomain(linkTextURI)
+ );
+ } catch (e) {
+ // If we throw above, one of the URIs probably has no TLD (e.g.
+ // http://localhost), so just check the entire host.
+ return aHrefURL.host != linkTextURI.host;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * If the current message has been identified as an email scam, prompts the
+ * user with a warning before allowing the link click to be processed.
+ * The warning prompt includes the unobscured host name of the http(s) url the
+ * user clicked on.
+ *
+ * @param {DOMWindow} win
+ * The window the message is being displayed within.
+ * @param {string} aUrl
+ * The url of the message
+ * @param {string} [aLinkText]
+ * User visible link text associated with the link
+ * @returns {number}
+ * 0 if the URL implied by aLinkText should be used instead.
+ * 1 if the request should be blocked.
+ * 2 if aUrl should be allowed to load.
+ */
+ warnOnSuspiciousLinkClick(win, aUrl, aLinkText) {
+ if (!this.#analyzeUrl(aUrl, aLinkText)) {
+ return 2; // No problem with the url. Allow it to load.
+ }
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+
+ // Analysis said there was a problem.
+ if (aLinkText && /^https?:/i.test(aLinkText)) {
+ let actualURI = Services.io.newURI(aUrl);
+ let displayedURI;
+ try {
+ displayedURI = Services.io.newURI(aLinkText);
+ } catch (e) {
+ return 1;
+ }
+
+ let titleMsg = bundle.GetStringFromName("linkMismatchTitle");
+ let dialogMsg = bundle.formatStringFromName(
+ "confirmPhishingUrlAlternate",
+ [displayedURI.host, actualURI.host]
+ );
+ let warningButtons =
+ Ci.nsIPromptService.BUTTON_POS_0 *
+ Ci.nsIPromptService.BUTTON_TITLE_IS_STRING +
+ Ci.nsIPromptService.BUTTON_POS_1 *
+ Ci.nsIPromptService.BUTTON_TITLE_CANCEL +
+ Ci.nsIPromptService.BUTTON_POS_2 *
+ Ci.nsIPromptService.BUTTON_TITLE_IS_STRING;
+ let button0Text = bundle.formatStringFromName("confirmPhishingGoDirect", [
+ displayedURI.host,
+ ]);
+ let button2Text = bundle.formatStringFromName("confirmPhishingGoAhead", [
+ actualURI.host,
+ ]);
+ return Services.prompt.confirmEx(
+ win,
+ titleMsg,
+ dialogMsg,
+ warningButtons,
+ button0Text,
+ "",
+ button2Text,
+ "",
+ {}
+ );
+ }
+
+ let hrefURL;
+ try {
+ // make sure relative link urls don't make us bail out
+ hrefURL = Services.io.newURI(aUrl);
+ } catch (e) {
+ return 1; // block the load
+ }
+
+ // only prompt for http and https urls
+ if (hrefURL.schemeIs("http") || hrefURL.schemeIs("https")) {
+ // unobscure the host name in case it's an encoded ip address..
+ let unobscuredHostNameValue =
+ lazy.isLegalIPAddress(hrefURL.host, true) || hrefURL.host;
+
+ let brandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let brandShortName = brandBundle.GetStringFromName("brandShortName");
+ let titleMsg = bundle.GetStringFromName("confirmPhishingTitle");
+ let dialogMsg = bundle.formatStringFromName("confirmPhishingUrl", [
+ brandShortName,
+ unobscuredHostNameValue,
+ ]);
+ let warningButtons =
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS +
+ Ci.nsIPromptService.BUTTON_POS_1_DEFAULT;
+ let button = Services.prompt.confirmEx(
+ win,
+ titleMsg,
+ dialogMsg,
+ warningButtons,
+ "",
+ "",
+ "",
+ "",
+ {}
+ );
+ return button == 0 ? 2 : 1; // 2 == allow, 1 == block
+ }
+ return 2; // allow the link to load
+ }
+})();
diff --git a/comm/mail/modules/QuickFilterManager.jsm b/comm/mail/modules/QuickFilterManager.jsm
new file mode 100644
index 0000000000..b92a5eeea7
--- /dev/null
+++ b/comm/mail/modules/QuickFilterManager.jsm
@@ -0,0 +1,1369 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "QuickFilterState",
+ "QuickFilterManager",
+ "MessageTextFilter",
+ "QuickFilterSearchListener",
+];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+// XXX we need to know whether the gloda indexer is enabled for upsell reasons,
+// but this should really just be exposed on the main Gloda public interface.
+// we need to be able to create gloda message searcher instances for upsells:
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ GlodaIndexer: "resource:///modules/gloda/GlodaIndexer.jsm",
+ GlodaMsgSearcher: "resource:///modules/gloda/GlodaMsgSearcher.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+/**
+ * Shallow object copy.
+ */
+function shallowObjCopy(obj) {
+ let newObj = {};
+ for (let key in obj) {
+ newObj[key] = obj[key];
+ }
+ return newObj;
+}
+
+/**
+ * Should the filter be visible when there's no previous state to propagate it
+ * from? The idea is that when session persistence is working this should only
+ * ever affect the first time Thunderbird is started up. Although opening
+ * additional 3-panes will likely trigger this unless we go out of our way to
+ * implement propagation across those boundaries (and we're not).
+ */
+var FILTER_VISIBILITY_DEFAULT = true;
+
+/**
+ * Represents the state of a quick filter bar. This mainly decorates the
+ * manipulation of the filter states with support of tracking the filter most
+ * recently manipulated so we can maintain a very limited undo stack of sorts.
+ */
+function QuickFilterState(aTemplateState, aJsonedState) {
+ if (aJsonedState) {
+ this.filterValues = aJsonedState.filterValues;
+ this.visible = aJsonedState.visible;
+ } else if (aTemplateState) {
+ this.filterValues = QuickFilterManager.propagateValues(
+ aTemplateState.filterValues
+ );
+ this.visible = aTemplateState.visible;
+ } else {
+ this.filterValues = QuickFilterManager.getDefaultValues();
+ this.visible = FILTER_VISIBILITY_DEFAULT;
+ }
+ this._lastFilterAttr = null;
+}
+QuickFilterState.prototype = {
+ /**
+ * Maps filter names to their current states. We rely on QuickFilterManager
+ * to do most of the interesting manipulation of this value.
+ */
+ filterValues: null,
+ /**
+ * Is the filter bar visible? Always inherited from the template regardless
+ * of stickyness.
+ */
+ visible: null,
+
+ /**
+ * Get a filter state and update lastFilterAttr appropriately. This is
+ * intended for use when the filter state is a rich object whose state
+ * cannot be updated just by clobbering as provided by |setFilterValue|.
+ *
+ * @param aName The name of the filter we are retrieving.
+ * @param [aNoChange=false] Is this actually a change for the purposes of
+ * lastFilterAttr purposes?
+ */
+ getFilterValue(aName, aNoChange) {
+ if (!aNoChange) {
+ this._lastFilterAttr = aName;
+ }
+ return this.filterValues[aName];
+ },
+
+ /**
+ * Set a filter state and update lastFilterAttr appropriately.
+ *
+ * @param aName The name of the filter we are setting.
+ * @param aValue The value to set; null/undefined implies deletion.
+ * @param [aNoChange=false] Is this actually a change for the purposes of
+ * lastFilterAttr purposes?
+ */
+ setFilterValue(aName, aValue, aNoChange) {
+ if (aValue == null) {
+ delete this.filterValues[aName];
+ return;
+ }
+
+ this.filterValues[aName] = aValue;
+ if (!aNoChange) {
+ this._lastFilterAttr = aName;
+ }
+ },
+
+ /**
+ * Track the last filter that was affirmatively applied. If you hit escape
+ * and this value is non-null, we clear the referenced filter constraint.
+ * If you hit escape and the value is null, we clear all filters.
+ */
+ _lastFilterAttr: null,
+
+ /**
+ * The user hit escape; based on _lastFilterAttr and whether there are any
+ * applied filters, change our constraints. First press clears the last
+ * added constraint (if any), second press (or if no last constraint) clears
+ * the state entirely.
+ *
+ * @returns true if we relaxed the state, false if there was nothing to relax.
+ */
+ userHitEscape() {
+ if (this._lastFilterAttr) {
+ // it's possible the UI state the last attribute has already been cleared,
+ // in which case we want to fall through...
+ if (
+ QuickFilterManager.clearFilterValue(
+ this._lastFilterAttr,
+ this.filterValues
+ )
+ ) {
+ this._lastFilterAttr = null;
+ return true;
+ }
+ }
+
+ return QuickFilterManager.clearAllFilterValues(this.filterValues);
+ },
+
+ /**
+ * Clear the state without going through any undo-ish steps like
+ * |userHitEscape| tries to do.
+ */
+ clear() {
+ QuickFilterManager.clearAllFilterValues(this.filterValues);
+ },
+
+ /**
+ * Create the search terms appropriate to the current filter states.
+ */
+ createSearchTerms(aTermCreator) {
+ return QuickFilterManager.createSearchTerms(
+ this.filterValues,
+ aTermCreator
+ );
+ },
+
+ persistToObj() {
+ return {
+ filterValues: this.filterValues,
+ visible: this.visible,
+ };
+ },
+};
+
+/**
+ * An nsIMsgSearchNotify listener wrapper to facilitate faceting of messages
+ * being returned by a search. We have to use a listener because the
+ * nsMsgDBView includes presentation logic and unless we force all of its
+ * results to be fully expanded (and dummy headers ignored), we can't get
+ * at all the messages reliably.
+ *
+ * We need to provide a wrapper so that:
+ * - We can provide better error handling support.
+ * - We can provide better GC support.
+ * - We can ensure the right life-cycle stuff happens (unregister ourselves as
+ * a listener, namely.)
+ *
+ * It is nice that we have a wrapper so that:
+ * - We can provide context to the thing we are calling that it does not need
+ * to maintain.
+ *
+ * The listener should implement the following methods:
+ *
+ * - function onSearchStart(aCurState) returning aScratch.
+ * This function should initialize the scratch object that will be passed to
+ * onSearchMessage and onSearchDone. This is an attempt to provide a
+ * friendly API that provides debugging support by dumping the state of
+ * said object when things go wrong.
+ *
+ * - function onSearchMessage(aScratch, aMsgHdr, aFolder)
+ * Processes messages reported as search hits. Its only context is the
+ * object you returned from onSearchStart. Take the hint and try and keep
+ * this method efficient! We will catch all exceptions for you and report
+ * errors. We will also handle forcing GCs as appropriate.
+ *
+ * - function onSearchDone(aCurState, aScratch, aSuccess) returning
+ * [new state for your filter, should call reflectInDOM, should treat the
+ * state as if it is a result of user action].
+ * This ends up looking exactly the same as the postFilterProcess handler
+ *
+ * @param aFilterer The QuickFilterState instance.
+ * @param aListener The thing on which we invoke methods.
+ */
+function QuickFilterSearchListener(
+ aViewWrapper,
+ aFilterer,
+ aFilterDef,
+ aListener,
+ aMuxer
+) {
+ this.filterer = aFilterer;
+ this.filterDef = aFilterDef;
+ this.listener = aListener;
+ this.muxer = aMuxer;
+
+ this.session = aViewWrapper.search.session;
+
+ this.scratch = null;
+ this.count = 0;
+ this.started = false;
+
+ this.session.registerListener(this, Ci.nsIMsgSearchSession.allNotifications);
+}
+QuickFilterSearchListener.prototype = {
+ onNewSearch() {
+ this.started = true;
+ let curState =
+ this.filterDef.name in this.filterer.filterValues
+ ? this.filterer.filterValues[this.filterDef.name]
+ : null;
+ this.scratch = this.listener.onSearchStart(curState);
+ },
+
+ onSearchHit(aMsgHdr, aFolder) {
+ // GC sanity demands that we trigger a GC if we have seen a large number
+ // of headers. Because we are driven by the search mechanism which likes
+ // to time-slice when it has a lot of messages on its plate, it is
+ // conceivable something else may trigger a GC for us. Unfortunately,
+ // we can't guarantee it, as XPConnect does not inform memory pressure,
+ // so it's us to stop-gap it.
+ this.count++;
+ if (!(this.count % 4096)) {
+ Cu.forceGC();
+ }
+
+ try {
+ this.listener.onSearchMessage(this.scratch, aMsgHdr, aFolder);
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+
+ onSearchDone(aStatus) {
+ // it's possible we will see the tail end of an existing search. ignore.
+ if (!this.started) {
+ return;
+ }
+
+ this.session.unregisterListener(this);
+
+ let curState =
+ this.filterDef.name in this.filterer.filterValues
+ ? this.filterer.filterValues[this.filterDef.name]
+ : null;
+ let [newState, update, treatAsUserAction] = this.listener.onSearchDone(
+ curState,
+ this.scratch,
+ aStatus
+ );
+
+ this.filterer.setFilterValue(
+ this.filterDef.name,
+ newState,
+ !treatAsUserAction
+ );
+ if (update) {
+ this.muxer.reflectFiltererState(this.filterDef.name);
+ }
+ },
+};
+
+/**
+ * Extensible mechanism for defining filters for the quick filter bar. This
+ * is the spiritual successor to the mailViewManager and quickSearchManager.
+ *
+ * The manager includes and requires UI-relevant metadata for use by its
+ * counterparts in quickFilterBar.js. New filters are expected to contribute
+ * DOM nodes to the overlay and tell us about them using their id during
+ * registration.
+ *
+ * We support two types of filtery things.
+ * - Filters via defineFilter.
+ * - Text filters via defineTextFilter. These always take the filter text as
+ * a parameter.
+ *
+ * If you are an adventurous extension developer and want to add a magic
+ * text filter that does the whole "from:bob to:jim subject:shoes" what you
+ * will want to do is register a normal filter and collapse the normal text
+ * filter text-box. You add your own text box, etc.
+ */
+var QuickFilterManager = {
+ /**
+ * List of filter definitions, potentially prioritized.
+ */
+ filterDefs: [],
+ /**
+ * Keys are filter definition names, values are the filter defs.
+ */
+ filterDefsByName: {},
+ /**
+ * The DOM id of the text widget that should get focused when the user hits
+ * control-f or the equivalent. This is here so it can get clobbered.
+ */
+ textBoxDomId: null,
+
+ /**
+ * Define a new filter.
+ *
+ * Filter states must always be JSON serializable. A state of undefined means
+ * that we are not persisting any state for your filter.
+ *
+ * @param {string} aFilterDef.name The name of your filter. This is the name
+ * of the attribute we cram your state into the state dictionary as, so
+ * the key thing is that it doesn't conflict with other id's.
+ * @param {string} aFilterDef.domId The id of the DOM node that you have
+ * overlaid into the quick filter bar.
+ * @param {function(aTermCreator, aTerms, aState)} aFilterDef.appendTerms
+ * The function to invoke to contribute your terms to the list of
+ * search terms in aTerms. Your function will not be invoked if you do
+ * not have any currently persisted state (as is the case if null or
+ * undefined was set). If you have nothing to add, then don't do
+ * anything. If you do add terms, the first term you add needs to have
+ * the booleanAnd flag set to true. You may optionally return a listener
+ * that complies with the documentation on QuickFilterSearchListener if
+ * you want to process all of the messages returned by the filter; doing
+ * so is not cheap, so don't do that lightly. (Tag faceting uses this.)
+ * @param {function()} [aFilterDef.getDefaults] Function that returns the
+ * default state for the filter. If the function is not defined or the
+ * returned value is == undefined/null, no state is set.
+ * @param {function(aTemplState, aSticky)} [aFilterDef.propagateState] A
+ * function that takes the state from another QuickFilterState instance
+ * for this definition and propagates it to a new state which it returns.
+ * You would use this to keep the 'sticky' bits of state that you want to
+ * persist between folder changes and when new tabs are opened. The
+ * aSticky argument tells you if the user wants all the filters still
+ * applied or not. When false, the idea is you might keep things like
+ * which text fields to filter on, but not the text to filter. When true,
+ * you would keep the text to filter on too. Return undefined if you do
+ * not want any state stored in the new filter state. If you do not
+ * define this function and aSticky would be true, we will propagate your
+ * state verbatim; accordingly functions using rich object state must
+ * implement this method.
+ * @param {function(aState)} [aFilterDef.clearState] Function to reset the
+ * the filter's value for the given state, returning a tuple of the new
+ * state and a boolean flag indicating whether there was actually state to
+ * clear. This is used when the user decides to reset the state of the
+ * filter bar or (just one specific filter). If omitted, we just delete
+ * the filter state entirely, so you only need to define this if you have
+ * some sticky meta-state you want to maintain. Return undefined for the
+ * state value if you do not need any state kept around.
+ * @param {function(aDocument, aMuxer, aNode)} [aFilterDef.domBindExtra]
+ * Function invoked at initial UI binding of the quick filter bar after
+ * we add a command listener to whatever is identified by domId. If you
+ * have additional widgets to hook up, this is where you do it. aDocument
+ * and aMuxer are provided to assist in this endeavor. Use aMuxer's
+ * getFilterValueForMutation/setFilterValue/updateSearch methods from any
+ * event handlers you register.
+ * @param {function(aState, aNode, aEvent, aDocument)} [aFilterDef.onCommand]
+ * If omitted, the default handler assumes your widget has a "checked"
+ * state that should set your state value to true when checked and delete
+ * the state when unchecked. Implement this function if that is not what
+ * you need. The function should return a tuple of [new state, should
+ * update the search] as its result.
+ * @param {function(aDomNode, aFilterValue, aDoc, aMuxer, aCallId)}
+ * [aFilterDef.reflectInDOM]
+ * If omitted, we assume the widget referenced by domId has a checked
+ * attribute and assign the filter value coerced to a boolean to the
+ * checked attribute. Otherwise we call your function and it's up to you
+ * to reflect your state. aDomNode is the node referred to by domId.
+ * This function will be called when the tab changes, folder changes, or
+ * if we called postFilterProcess and you returned a value != undefined.
+ * @param {function(aState, aViewWrapper, aFiltering)}
+ * [aFilterDef.postFilterProcess]
+ * Invoked after all of the message headers for the view have been
+ * displayed, allowing your code to perform some kind of faceting or other
+ * clever logic. Return a tuple of [new state, should call reflectInDOM,
+ * should treat as if the user modified the state]. We call this _even
+ * when there is no filter_ applied. We tell you what's happening via
+ * aFiltering; true means we have applied some terms, false means not.
+ * It's vitally important that you do not just facet things willy nilly
+ * unless there is expected user payoff and they opted in. Our tagging UI
+ * only facets when the user clicked the tag facet. If you write an
+ * extension that provides really sweet visualizations or something like
+ * that and the user installs you knowing what's what, that is also cool,
+ * we just can't do it in core for now.
+ */
+ defineFilter(aFilterDef) {
+ this.filterDefs.push(aFilterDef);
+ this.filterDefsByName[aFilterDef.name] = aFilterDef;
+ },
+
+ /**
+ * Remove a filter from existence by name. This is for extensions to disable
+ * existing filters and not a dynamic jetpack-like lifecycle. It falls to
+ * the code calling killFilter to deal with the DOM nodes themselves for now.
+ *
+ * @param aName The name of the filter to kill.
+ */
+ killFilter(aName) {
+ let filterDef = this.filterDefsByName[aName];
+ this.filterDefs.splice(this.filterDefs.indexOf(filterDef), 1);
+ delete this.filterDefsByName[aName];
+ },
+
+ /**
+ * Propagate values from an existing state into a new state based on
+ * propagation rules. For use by QuickFilterState.
+ *
+ * @param aTemplValues A set of existing filterValues.
+ * @returns The new filterValues state.
+ */
+ propagateValues(aTemplValues) {
+ let values = {};
+ let sticky = "sticky" in aTemplValues ? aTemplValues.sticky : false;
+
+ for (let filterDef of this.filterDefs) {
+ if ("propagateState" in filterDef) {
+ let curValue =
+ filterDef.name in aTemplValues
+ ? aTemplValues[filterDef.name]
+ : undefined;
+ let newValue = filterDef.propagateState(curValue, sticky);
+ if (newValue != null) {
+ values[filterDef.name] = newValue;
+ }
+ } else if (sticky) {
+ // Always propagate the value if sticky and there was no handler.
+ if (filterDef.name in aTemplValues) {
+ values[filterDef.name] = aTemplValues[filterDef.name];
+ }
+ }
+ }
+
+ return values;
+ },
+ /**
+ * Get the set of default filterValues for the current set of defined filters.
+ *
+ * @returns Thew new filterValues state.
+ */
+ getDefaultValues() {
+ let values = {};
+ for (let filterDef of this.filterDefs) {
+ if ("getDefaults" in filterDef) {
+ let newValue = filterDef.getDefaults();
+ if (newValue != null) {
+ values[filterDef.name] = newValue;
+ }
+ }
+ }
+ return values;
+ },
+
+ /**
+ * Reset the state of a single filter given the provided values.
+ *
+ * @returns true if we actually cleared some state, false if there was nothing
+ * to clear.
+ */
+ clearFilterValue(aFilterName, aValues) {
+ let filterDef = this.filterDefsByName[aFilterName];
+ if (!("clearState" in filterDef)) {
+ if (aFilterName in aValues) {
+ delete aValues[aFilterName];
+ return true;
+ }
+ return false;
+ }
+
+ let curValue = aFilterName in aValues ? aValues[aFilterName] : undefined;
+ // Yes, we want to call it to clear its state even if it has no state.
+ let [newValue, didClear] = filterDef.clearState(curValue);
+ if (newValue != null) {
+ aValues[aFilterName] = newValue;
+ } else {
+ delete aValues[aFilterName];
+ }
+ return didClear;
+ },
+
+ /**
+ * Reset the state of all filters given the provided values.
+ *
+ * @returns true if we actually cleared something, false if there was nothing
+ * to clear.
+ */
+ clearAllFilterValues(aFilterValues) {
+ let didClearSomething = false;
+ for (let filterDef of this.filterDefs) {
+ if (this.clearFilterValue(filterDef.name, aFilterValues)) {
+ didClearSomething = true;
+ }
+ }
+ return didClearSomething;
+ },
+
+ /**
+ * Populate and return a list of search terms given the provided state.
+ *
+ * We only invoke appendTerms on filters that have state in aFilterValues,
+ * as per the contract.
+ */
+ createSearchTerms(aFilterValues, aTermCreator) {
+ let searchTerms = [],
+ listeners = [];
+ for (let filterName in aFilterValues) {
+ let filterValue = aFilterValues[filterName];
+ let filterDef = this.filterDefsByName[filterName];
+ try {
+ let listener = filterDef.appendTerms(
+ aTermCreator,
+ searchTerms,
+ filterValue
+ );
+ if (listener) {
+ listeners.push([listener, filterDef]);
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ return searchTerms.length ? [searchTerms, listeners] : [null, listeners];
+ },
+};
+
+/**
+ * Meta-filter, just handles whether or not things are sticky.
+ */
+QuickFilterManager.defineFilter({
+ name: "sticky",
+ domId: "qfb-sticky",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {},
+ /**
+ * This should not cause an update, otherwise default logic.
+ */
+ onCommand(aState, aNode, aEvent, aDocument) {
+ let checked = aNode.pressed;
+ return [checked, false];
+ },
+});
+
+/**
+ * true: must be unread, false: must be read.
+ */
+QuickFilterManager.defineFilter({
+ name: "unread",
+ domId: "qfb-unread",
+ menuItemID: "quickFilterButtonsContextUnreadToggle",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.status = Ci.nsMsgMessageFlags.Read;
+ term.value = value;
+ term.op = aFilterValue ? Ci.nsMsgSearchOp.Isnt : Ci.nsMsgSearchOp.Is;
+ term.booleanAnd = true;
+ aTerms.push(term);
+ },
+});
+
+/**
+ * true: must be starred, false: must not be starred.
+ */
+QuickFilterManager.defineFilter({
+ name: "starred",
+ domId: "qfb-starred",
+ menuItemID: "quickFilterButtonsContextStarredToggle",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.status = Ci.nsMsgMessageFlags.Marked;
+ term.value = value;
+ term.op = aFilterValue ? Ci.nsMsgSearchOp.Is : Ci.nsMsgSearchOp.Isnt;
+ term.booleanAnd = true;
+ aTerms.push(term);
+ },
+});
+
+/**
+ * true: sender must be in a local address book, false: sender must not be.
+ */
+QuickFilterManager.defineFilter({
+ name: "addrBook",
+ domId: "qfb-inaddrbook",
+ menuItemID: "quickFilterButtonsContextInaddrbookToggle",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+ let firstBook = true;
+ term = null;
+ for (let addrbook of MailServices.ab.directories) {
+ if (!addrbook.isRemote) {
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.Sender;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.str = addrbook.URI;
+ term.value = value;
+ term.op = aFilterValue
+ ? Ci.nsMsgSearchOp.IsInAB
+ : Ci.nsMsgSearchOp.IsntInAB;
+ // It's an AND if we're the first book (so the boolean affects the
+ // group as a whole.)
+ // It's the negation of whether we're filtering otherwise; demorgans.
+ term.booleanAnd = firstBook || !aFilterValue;
+ term.beginsGrouping = firstBook;
+ aTerms.push(term);
+ firstBook = false;
+ }
+ }
+ if (term) {
+ term.endsGrouping = true;
+ }
+ },
+});
+
+/**
+ * It's a tag filter that sorta facets! Stealing gloda's thunder! Woo!
+ *
+ * Filter on message tags? Meanings:
+ * - true: Yes, must have at least one tag on it.
+ * - false: No, no tags on it!
+ * - dictionary where keys are tag keys and values are tri-state with null
+ * meaning don't constraint, true meaning yes should be present, false
+ * meaning no, don't be present
+ */
+var TagFacetingFilter = {
+ name: "tags",
+ domId: "qfb-tags",
+ menuItemID: "quickFilterButtonsContextTagsToggle",
+ callID: "",
+
+ /**
+ * @returns true if the constaint is only on has tags/does not have tags,
+ * false if there are specific tag constraints in play.
+ */
+ isSimple(aFilterValue) {
+ // it's the simple case if the value is just a boolean
+ if (typeof aFilterValue != "object") {
+ return true;
+ }
+ // but also if the object contains no non-null values
+ let simpleCase = true;
+ for (let key in aFilterValue.tags) {
+ let value = aFilterValue.tags[key];
+ if (value !== null) {
+ simpleCase = false;
+ break;
+ }
+ }
+ return simpleCase;
+ },
+
+ /**
+ * Because we support both inclusion and exclusion we can produce up to two
+ * groups. One group for inclusion, one group for exclusion. To get listed
+ * the message must have any/all of the tags marked for inclusion,
+ * (depending on mode), but it cannot have any of the tags marked for
+ * exclusion.
+ */
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ if (aFilterValue == null) {
+ return null;
+ }
+
+ let term, value;
+
+ // just the true/false case
+ if (this.isSimple(aFilterValue)) {
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.Keywords;
+ value = term.value;
+ value.str = "";
+ term.value = value;
+ term.op = aFilterValue
+ ? Ci.nsMsgSearchOp.IsntEmpty
+ : Ci.nsMsgSearchOp.IsEmpty;
+ term.booleanAnd = true;
+ aTerms.push(term);
+
+ // we need to perform faceting if the value is literally true.
+ if (aFilterValue === true) {
+ return this;
+ }
+ } else {
+ let firstIncludeClause = true,
+ firstExcludeClause = true;
+ let lastIncludeTerm = null;
+ term = null;
+
+ let excludeTerms = [];
+
+ let mode = aFilterValue.mode;
+ for (let key in aFilterValue.tags) {
+ let shouldFilter = aFilterValue.tags[key];
+ if (shouldFilter !== null) {
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.Keywords;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.str = key;
+ term.value = value;
+ if (shouldFilter) {
+ term.op = Ci.nsMsgSearchOp.Contains;
+ // AND for the group. Inside the group we also want AND if the
+ // mode is set to "All of".
+ term.booleanAnd = firstIncludeClause || mode === "AND";
+ term.beginsGrouping = firstIncludeClause;
+ aTerms.push(term);
+ firstIncludeClause = false;
+ lastIncludeTerm = term;
+ } else {
+ term.op = Ci.nsMsgSearchOp.DoesntContain;
+ // you need to not include all of the tags marked excluded.
+ term.booleanAnd = true;
+ term.beginsGrouping = firstExcludeClause;
+ excludeTerms.push(term);
+ firstExcludeClause = false;
+ }
+ }
+ }
+ if (lastIncludeTerm) {
+ lastIncludeTerm.endsGrouping = true;
+ }
+
+ // if we have any exclude terms:
+ // - we might need to add a "has a tag" clause if there were no explicit
+ // inclusions.
+ // - extend the exclusions list in.
+ if (excludeTerms.length) {
+ // (we need to add has a tag)
+ if (!lastIncludeTerm) {
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.Keywords;
+ value = term.value;
+ value.str = "";
+ term.value = value;
+ term.op = Ci.nsMsgSearchOp.IsntEmpty;
+ term.booleanAnd = true;
+ aTerms.push(term);
+ }
+
+ // (extend in the exclusions)
+ excludeTerms[excludeTerms.length - 1].endsGrouping = true;
+ aTerms.push.apply(aTerms, excludeTerms);
+ }
+ }
+ return null;
+ },
+
+ onSearchStart(aCurState) {
+ // this becomes aKeywordMap; we want to start with an empty one
+ return {};
+ },
+ onSearchMessage(aKeywordMap, aMsgHdr, aFolder) {
+ let keywords = aMsgHdr.getStringProperty("keywords");
+ let keywordList = keywords.split(" ");
+ for (let iKeyword = 0; iKeyword < keywordList.length; iKeyword++) {
+ let keyword = keywordList[iKeyword];
+ aKeywordMap[keyword] = null;
+ }
+ },
+ onSearchDone(aCurState, aKeywordMap, aStatus) {
+ // we are an async operation; if the user turned off the tag facet already,
+ // then leave that state intact...
+ if (aCurState == null) {
+ return [null, false, false];
+ }
+
+ // only propagate things that are actually tags though!
+ let outKeyMap = { tags: {} };
+ let tags = MailServices.tags.getAllTags();
+ let tagCount = tags.length;
+ for (let iTag = 0; iTag < tagCount; iTag++) {
+ let tag = tags[iTag];
+
+ if (tag.key in aKeywordMap) {
+ outKeyMap.tags[tag.key] = aKeywordMap[tag.key];
+ }
+ }
+ return [outKeyMap, true, false];
+ },
+
+ /**
+ * We need to clone our state if it's an object to avoid bad sharing.
+ */
+ propagateState(aOld, aSticky) {
+ // stay disabled when disabled, get disabled when not sticky
+ if (aOld == null || !aSticky) {
+ return null;
+ }
+ if (this.isSimple(aOld)) {
+ // Could be an object, need to convert.
+ return !!aOld;
+ }
+ return shallowObjCopy(aOld);
+ },
+
+ /**
+ * Default behaviour but:
+ * - We collapse our expando if we get unchecked.
+ * - We want to initiate a faceting pass if we just got checked.
+ */
+ onCommand(aState, aNode, aEvent, aDocument) {
+ let checked;
+ if (aNode.tagName == "button") {
+ checked = aNode.pressed ? true : null;
+ } else {
+ checked = aNode.hasAttribute("checked") ? true : null;
+ }
+
+ if (!checked) {
+ aDocument.getElementById("quickFilterBarTagsContainer").hidden = true;
+ }
+
+ // return ourselves if we just got checked to have
+ // onSearchStart/onSearchMessage/onSearchDone get to do their thing.
+ return [checked, true];
+ },
+
+ domBindExtra(aDocument, aMuxer, aNode) {
+ // Tag filtering mode menu (All of/Any of)
+ function commandHandler(aEvent) {
+ let filterValue = aMuxer.getFilterValueForMutation(
+ TagFacetingFilter.name
+ );
+ filterValue.mode = aEvent.target.value;
+ aMuxer.updateSearch();
+ }
+ aDocument
+ .getElementById("qfb-boolean-mode")
+ .addEventListener("ValueChange", commandHandler);
+ },
+
+ reflectInDOM(aNode, aFilterValue, aDocument, aMuxer, aCallId) {
+ if (aCallId !== null && aCallId == "menuItem") {
+ aFilterValue
+ ? aNode.setAttribute("checked", aFilterValue)
+ : aNode.removeAttribute("checked");
+ } else {
+ aNode.pressed = aFilterValue;
+ }
+ if (aFilterValue != null && typeof aFilterValue == "object") {
+ this._populateTagBar(aFilterValue, aDocument, aMuxer);
+ } else {
+ aDocument.getElementById("quickFilterBarTagsContainer").hidden = true;
+ }
+ },
+
+ _populateTagBar(aState, aDocument, aMuxer) {
+ let tagbar = aDocument.getElementById("quickFilterBarTagsContainer");
+ let keywordMap = aState.tags;
+
+ // If we have a mode stored use that. If we don't have a mode, then update
+ // our state to agree with what the UI is currently displaying;
+ // this will happen for fresh profiles.
+ let qbm = aDocument.getElementById("qfb-boolean-mode");
+ if (aState.mode) {
+ qbm.value = aState.mode;
+ } else {
+ aState.mode = qbm.value;
+ }
+
+ function clickHandler(aEvent) {
+ let tagKey = this.getAttribute("value");
+ let state = aMuxer.getFilterValueForMutation(TagFacetingFilter.name);
+ state.tags[tagKey] = this.pressed ? true : null;
+ this.removeAttribute("inverted");
+ aMuxer.updateSearch();
+ }
+
+ function rightClickHandler(aEvent) {
+ if (aEvent.button == 2) {
+ // Toggle isn't triggered by a contextmenu event, so do it here.
+ this.pressed = !this.pressed;
+
+ let tagKey = this.getAttribute("value");
+ let state = aMuxer.getFilterValueForMutation(TagFacetingFilter.name);
+ state.tags[tagKey] = this.pressed ? false : null;
+ if (this.pressed) {
+ this.setAttribute("inverted", "true");
+ } else {
+ this.removeAttribute("inverted");
+ }
+ aMuxer.updateSearch();
+ aEvent.preventDefault();
+ }
+ }
+
+ // -- nuke existing exposed tags, but not the mode selector (which is first)
+ while (tagbar.children.length > 1) {
+ tagbar.lastElementChild.remove();
+ }
+
+ let addCount = 0;
+
+ // -- create an element for each tag
+ let tags = MailServices.tags.getAllTags();
+ let tagCount = tags.length;
+ for (let iTag = 0; iTag < tagCount; iTag++) {
+ let tag = tags[iTag];
+
+ if (tag.key in keywordMap) {
+ addCount++;
+
+ // Keep in mind that the XBL does not get built for dynamically created
+ // elements such as these until they get displayed, which definitely
+ // means not before we append it into the tree.
+ let button = aDocument.createElement("button", { is: "toggle-button" });
+
+ button.setAttribute("id", "qfb-tag-" + tag.key);
+ button.addEventListener("click", clickHandler);
+ button.addEventListener("contextmenu", rightClickHandler);
+ if (keywordMap[tag.key] !== null) {
+ button.pressed = true;
+ if (!keywordMap[tag.key]) {
+ button.setAttribute("inverted", "true");
+ }
+ }
+ button.textContent = tag.tag;
+ button.setAttribute("value", tag.key);
+ let color = tag.color;
+ let contrast = lazy.TagUtils.isColorContrastEnough(color)
+ ? "black"
+ : "white";
+ // everybody always gets to be an qfb-tag-button.
+ button.setAttribute("class", "button qfb-tag-button");
+ if (color) {
+ button.setAttribute(
+ "style",
+ `--tag-color: ${color}; --tag-contrast-color: ${contrast};`
+ );
+ }
+ tagbar.appendChild(button);
+ }
+ }
+ tagbar.hidden = !addCount;
+ },
+};
+QuickFilterManager.defineFilter(TagFacetingFilter);
+
+/**
+ * true: must have attachment, false: must not have attachment.
+ */
+QuickFilterManager.defineFilter({
+ name: "attachment",
+ domId: "qfb-attachment",
+ menuItemID: "quickFilterButtonsContextAttachmentToggle",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.status = Ci.nsMsgMessageFlags.Attachment;
+ term.value = value;
+ term.op = aFilterValue ? Ci.nsMsgSearchOp.Is : Ci.nsMsgSearchOp.Isnt;
+ term.booleanAnd = true;
+ aTerms.push(term);
+ },
+});
+
+/**
+ * The traditional quick-search text filter now with added gloda upsell! We
+ * are mildly extensible in case someone wants to add more specific text filter
+ * criteria to toggle, but otherwise are intended to be taken out of the
+ * picture entirely by extensions implementing more featureful text searches.
+ *
+ * Our state looks like {text: "", states: {a: true, b: false}} where a and b
+ * are text filters.
+ */
+var MessageTextFilter = {
+ name: "text",
+ domId: "qfb-qs-textbox",
+ /**
+ * Parse the string into terms/phrases by finding matching double-quotes. If
+ * we find a quote that doesn't have a friend, we assume the user was going
+ * to put a quote at the end of the string. (This is important because we
+ * update using a timer and this results in stable behavior.)
+ *
+ * This code is cloned from gloda's GlodaMsgSearcher.jsm and known good (enough :).
+ * I did change the friendless quote situation, though.
+ *
+ * @param aSearchString The phrase to parse up.
+ * @returns A list of terms.
+ */
+ _parseSearchString(aSearchString) {
+ aSearchString = aSearchString.trim();
+ let terms = [];
+
+ /*
+ * Add the term as long as the trim on the way in didn't obliterate it.
+ *
+ * In the future this might have other helper logic; it did once before.
+ */
+ function addTerm(aTerm) {
+ if (aTerm) {
+ terms.push(aTerm);
+ }
+ }
+
+ /**
+ * Look for spaces around | (OR operator) and remove them.
+ */
+ aSearchString = aSearchString.replace(/\s*\|\s*/g, "|");
+ while (aSearchString) {
+ if (aSearchString.startsWith('"')) {
+ let endIndex = aSearchString.indexOf('"', 1);
+ // treat a quote without a friend as making a phrase containing the
+ // rest of the string...
+ if (endIndex == -1) {
+ endIndex = aSearchString.length;
+ }
+
+ addTerm(aSearchString.substring(1, endIndex).trim());
+ aSearchString = aSearchString.substring(endIndex + 1);
+ continue;
+ }
+
+ let searchTerms = aSearchString.split(" ");
+ searchTerms.forEach(searchTerm => addTerm(searchTerm));
+ break;
+ }
+
+ return terms;
+ },
+
+ /**
+ * For each search phrase, build a group that contains all our active text
+ * filters OR'ed together. So if the user queries for 'foo bar' with
+ * sender and recipient enabled, we build:
+ * ("foo" sender OR "foo" recipient) AND ("bar" sender OR "bar" recipient)
+ */
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+
+ if (aFilterValue.text) {
+ let phrases = this._parseSearchString(aFilterValue.text);
+ for (let groupedPhrases of phrases) {
+ let firstClause = true;
+ term = null;
+ let splitPhrases = groupedPhrases.split("|");
+ for (let phrase of splitPhrases) {
+ for (let [tfName, tfValue] of Object.entries(aFilterValue.states)) {
+ if (!tfValue) {
+ continue;
+ }
+ let tfDef = this.textFilterDefs[tfName];
+
+ term = aTermCreator.createTerm();
+ term.attrib = tfDef.attrib;
+ value = term.value;
+ value.attrib = tfDef.attrib;
+ value.str = phrase;
+ term.value = value;
+ term.op = Ci.nsMsgSearchOp.Contains;
+ // AND for the group, but OR inside the group
+ term.booleanAnd = firstClause;
+ term.beginsGrouping = firstClause;
+ aTerms.push(term);
+ firstClause = false;
+ }
+ }
+ if (term) {
+ term.endsGrouping = true;
+ }
+ }
+ }
+ },
+ getDefaults() {
+ let states = {};
+ for (let name in this._defaultStates) {
+ states[name] = this._defaultStates[name];
+ }
+ return {
+ text: null,
+ states,
+ };
+ },
+ propagateState(aOld, aSticky) {
+ return {
+ text: aSticky ? aOld.text : null,
+ states: shallowObjCopy(aOld.states),
+ };
+ },
+ clearState(aState) {
+ let hadState = Boolean(aState.text);
+ aState.text = null;
+ return [aState, hadState];
+ },
+
+ /**
+ * We need to create and bind our expando-bar toggle buttons. We also need to
+ * add a special down keypress handler that escapes the textbox into the
+ * thread pane.
+ */
+ domBindExtra(aDocument, aMuxer, aNode) {
+ // -- Keypresses for focus transferral and upsell
+ aNode.addEventListener("keypress", function (aEvent) {
+ // - Down key into the thread pane. Calls `preventDefault` to stop the
+ // event from causing scrolling, but that prevents the tree from
+ // selecting a message if necessary, so we must do it here.
+ if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
+ let threadTree = aDocument.getElementById("threadTree");
+ threadTree.table.body.focus();
+ if (threadTree.selectedIndex == -1) {
+ threadTree.selectedIndex = 0;
+ }
+ aEvent.preventDefault();
+ }
+ });
+
+ // -- Blurring kills upsell.
+ aNode.addEventListener(
+ "blur",
+ function (aEvent) {
+ let panel = aDocument.getElementById("qfb-text-search-upsell");
+ if (
+ (Services.focus.activeWindow != aDocument.defaultView ||
+ aDocument.commandDispatcher.focusedElement != aNode.inputField) &&
+ panel.state == "open"
+ ) {
+ panel.hidePopup();
+ }
+ },
+ true
+ );
+
+ // -- Expando Buttons!
+ function commandHandler(aEvent) {
+ let state = aMuxer.getFilterValueForMutation(MessageTextFilter.name);
+ let filterDef = MessageTextFilter.textFilterDefsByDomId[this.id];
+ state.states[filterDef.name] = this.pressed;
+ aMuxer.updateSearch();
+ }
+
+ for (let name in this.textFilterDefs) {
+ let textFilter = this.textFilterDefs[name];
+ aDocument
+ .getElementById(textFilter.domId)
+ .addEventListener("click", commandHandler);
+ }
+ },
+
+ onCommand(aState, aNode, aEvent, aDocument) {
+ let text = aNode.value.length ? aNode.value : null;
+ if (text == aState.text) {
+ let upsell = aDocument.getElementById("qfb-text-search-upsell");
+ if (upsell.state == "open") {
+ upsell.hidePopup();
+ let tabmail =
+ aDocument.ownerGlobal.top.document.getElementById("tabmail");
+ tabmail.openTab("glodaFacet", {
+ searcher: new lazy.GlodaMsgSearcher(null, aState.text),
+ });
+ }
+ return [aState, false];
+ }
+
+ aState.text = text;
+ aDocument.getElementById("quick-filter-bar-filter-text-bar").hidden =
+ text == null;
+ return [aState, true];
+ },
+
+ reflectInDOM(aNode, aFilterValue, aDocument, aMuxer, aFromPFP) {
+ let panel = aDocument.getElementById("qfb-text-search-upsell");
+
+ if (aFromPFP == "nosale") {
+ if (panel.state != "closed") {
+ panel.hidePopup();
+ }
+ return;
+ }
+
+ if (aFromPFP == "upsell") {
+ let line2 = aDocument.getElementById("qfb-upsell-line-two");
+ aDocument.l10n.setAttributes(
+ line2,
+ "quick-filter-bar-gloda-upsell-line2",
+ { text: aFilterValue.text }
+ );
+
+ if (panel.state == "closed" && aDocument.activeElement == aNode) {
+ aDocument.ownerGlobal.setTimeout(() => {
+ panel.openPopup(
+ aDocument.getElementById("quick-filter-bar"),
+ "after_end",
+ -7,
+ 7,
+ false,
+ true
+ );
+ });
+ }
+ return;
+ }
+
+ // Make sure we have no visible upsell on state change while our textbox
+ // retains focus.
+ if (panel.state != "closed") {
+ panel.hidePopup();
+ }
+
+ // Update the text if it has changed (linux does weird things with empty
+ // text if we're transitioning emptytext to emptytext).
+ let desiredValue = aFilterValue.text || "";
+ if (aNode.value != desiredValue && aNode != aMuxer.activeElement) {
+ aNode.value = desiredValue;
+ }
+
+ // Update our expanded filters buttons.
+ let states = aFilterValue.states;
+ for (let name in this.textFilterDefs) {
+ let textFilter = this.textFilterDefs[name];
+ aDocument.getElementById(textFilter.domId).pressed =
+ states[textFilter.name];
+ }
+
+ // Toggle the expanded filters visibility.
+ aDocument.getElementById("quick-filter-bar-filter-text-bar").hidden =
+ aFilterValue.text == null;
+ },
+
+ /**
+ * In order to do our upsell we need to know when we are not getting any
+ * results.
+ */
+ postFilterProcess(aState, aViewWrapper, aFiltering) {
+ // If we're not filtering, not filtering on text, there are results, or
+ // gloda is not enabled so upselling makes no sense, then bail.
+ // (Currently we always return "nosale" to make sure our panel is closed;
+ // this might be overkill but unless it becomes a performance problem, it
+ // keeps us safe from weird stuff.)
+ if (
+ !aFiltering ||
+ !aState.text ||
+ aViewWrapper.dbView.numMsgsInView ||
+ !lazy.GlodaIndexer.enabled
+ ) {
+ return [aState, "nosale", false];
+ }
+
+ // since we're filtering, filtering on text, and there are no results, tell
+ // the upsell code to get bizzay
+ return [aState, "upsell", false];
+ },
+
+ /** maps text filter names to whether they are enabled by default (bool) */
+ _defaultStates: {},
+ /** maps text filter name to text filter def */
+ textFilterDefs: {},
+ /** maps dom id to text filter def */
+ textFilterDefsByDomId: {},
+ defineTextFilter(aTextDef) {
+ this.textFilterDefs[aTextDef.name] = aTextDef;
+ this.textFilterDefsByDomId[aTextDef.domId] = aTextDef;
+ if (aTextDef.defaultState) {
+ this._defaultStates[aTextDef.name] = true;
+ }
+ },
+};
+// Note that we definitely want this filter defined AFTER the cheap message
+// status filters, so don't reorder this invocation willy nilly.
+QuickFilterManager.defineFilter(MessageTextFilter);
+QuickFilterManager.textBoxDomId = "qfb-qs-textbox";
+
+MessageTextFilter.defineTextFilter({
+ name: "sender",
+ domId: "qfb-qs-sender",
+ attrib: Ci.nsMsgSearchAttrib.Sender,
+ defaultState: true,
+});
+MessageTextFilter.defineTextFilter({
+ name: "recipients",
+ domId: "qfb-qs-recipients",
+ attrib: Ci.nsMsgSearchAttrib.ToOrCC,
+ defaultState: true,
+});
+MessageTextFilter.defineTextFilter({
+ name: "subject",
+ domId: "qfb-qs-subject",
+ attrib: Ci.nsMsgSearchAttrib.Subject,
+ defaultState: true,
+});
+MessageTextFilter.defineTextFilter({
+ name: "body",
+ domId: "qfb-qs-body",
+ attrib: Ci.nsMsgSearchAttrib.Body,
+ defaultState: false,
+});
+
+/**
+ * The results label says whether there were any matches and, if so, how many.
+ */
+QuickFilterManager.defineFilter({
+ name: "results",
+ domId: "qfb-results-label",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {},
+
+ /**
+ * Our state is meaningless; we implement this to avoid clearState ever
+ * thinking we were a facet.
+ */
+ clearState(aState) {
+ return [null, false];
+ },
+
+ /**
+ * We never have any state to propagate!
+ */
+ propagateState(aOld, aSticky) {
+ return null;
+ },
+
+ reflectInDOM(aNode, aFilterValue, aDocument) {
+ if (aFilterValue == null) {
+ aNode.removeAttribute("data-l10n-id");
+ aNode.removeAttribute("data-l10n-attrs");
+ aNode.textContent = "";
+ aNode.style.visibility = "hidden";
+ } else if (aFilterValue == 0) {
+ aDocument.l10n.setAttributes(aNode, "quick-filter-bar-no-results");
+ aNode.style.visibility = "visible";
+ } else {
+ aDocument.l10n.setAttributes(aNode, "quick-filter-bar-results", {
+ count: aFilterValue,
+ });
+ aNode.style.visibility = "visible";
+ }
+ },
+ /**
+ * We slightly abuse the filtering hook to figure out how many messages there
+ * are and whether a filter is active. What makes this reasonable is that
+ * a more complicated widget that visualized the results as a timeline would
+ * definitely want to be hooked up like this. (Although they would want
+ * to implement propagateState since the state they store would be pretty
+ * expensive.)
+ */
+ postFilterProcess(aState, aViewWrapper, aFiltering) {
+ return [aFiltering ? aViewWrapper.dbView.numMsgsInView : null, true, false];
+ },
+});
diff --git a/comm/mail/modules/SearchSpec.jsm b/comm/mail/modules/SearchSpec.jsm
new file mode 100644
index 0000000000..50bbfbaa64
--- /dev/null
+++ b/comm/mail/modules/SearchSpec.jsm
@@ -0,0 +1,562 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["SearchSpec"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Wrapper abstraction around a view's search session. This is basically a
+ * friend class of FolderDisplayWidget and is privy to some of its internals.
+ */
+function SearchSpec(aViewWrapper) {
+ this.owner = aViewWrapper;
+
+ this._viewTerms = null;
+ this._virtualFolderTerms = null;
+ this._userTerms = null;
+
+ this._session = null;
+ this._sessionListener = null;
+ this._listenersRegistered = false;
+
+ this._onlineSearch = false;
+}
+SearchSpec.prototype = {
+ /**
+ * Clone this SearchSpec; intended to be used by DBViewWrapper.clone().
+ */
+ clone(aViewWrapper) {
+ let doppel = new SearchSpec(aViewWrapper);
+
+ // we can just copy the terms since we never mutate them
+ doppel._viewTerms = this._viewTerms;
+ doppel._virtualFolderTerms = this._virtualFolderTerms;
+ doppel._userTerms = this._userTerms;
+
+ // _session can stay null
+ // no listener is required, so we can keep _sessionListener and
+ // _listenersRegistered at their default values
+
+ return doppel;
+ },
+
+ get hasSearchTerms() {
+ return this._viewTerms || this._virtualFolderTerms || this._userTerms;
+ },
+
+ get hasOnlyVirtualTerms() {
+ return this._virtualFolderTerms && !this._viewTerms && !this._userTerms;
+ },
+
+ /**
+ * On-demand creation of the nsIMsgSearchSession. Automatically creates a
+ * SearchSpecListener at the same time and registers it as a listener. The
+ * DBViewWrapper is responsible for adding (and removing) the db view
+ * as a listener.
+ *
+ * Code should only access this attribute when it wants to manipulate the
+ * session. Callers should use hasSearchTerms if they want to determine if
+ * a search session is required.
+ */
+ get session() {
+ if (this._session == null) {
+ this._session = Cc[
+ "@mozilla.org/messenger/searchSession;1"
+ ].createInstance(Ci.nsIMsgSearchSession);
+ }
+ return this._session;
+ },
+
+ /**
+ * (Potentially) add the db view as a search listener and kick off the search.
+ * We only do that if we have search terms. The intent is to allow you to
+ * call this all the time, even if you don't need to.
+ * DBViewWrapper._applyViewChanges used to handle a lot more of this, but our
+ * need to make sure that the session listener gets added after the DBView
+ * caused us to introduce this method. (We want the DB View's OnDone method
+ * to run before our listener, as it may do important work.)
+ */
+ associateView(aDBView) {
+ if (this.hasSearchTerms) {
+ this.updateSession();
+
+ if (this.owner.isSynthetic) {
+ this.owner._syntheticView.search(new FilteringSyntheticListener(this));
+ } else {
+ if (!this._sessionListener) {
+ this._sessionListener = new SearchSpecListener(this);
+ }
+
+ this.session.registerListener(
+ aDBView,
+ Ci.nsIMsgSearchSession.allNotifications
+ );
+ aDBView.searchSession = this._session;
+ this._session.registerListener(
+ this._sessionListener,
+ Ci.nsIMsgSearchSession.onNewSearch |
+ Ci.nsIMsgSearchSession.onSearchDone
+ );
+ this._listenersRegistered = true;
+
+ this.owner.searching = true;
+ this.session.search(this.owner.listener.msgWindow);
+ }
+ } else if (this.owner.isSynthetic) {
+ // If it's synthetic but we have no search terms, hook the output of the
+ // synthetic view directly up to the search nsIMsgDBView.
+ let owner = this.owner;
+ owner.searching = true;
+ this.owner._syntheticView.search(
+ aDBView.QueryInterface(Ci.nsIMsgSearchNotify),
+ function () {
+ owner.searching = false;
+ }
+ );
+ }
+ },
+ /**
+ * Stop any active search and stop the db view being a search listener (if it
+ * is one).
+ */
+ dissociateView(aDBView) {
+ // If we are currently searching, interrupt the search. This will
+ // immediately notify the listeners that the search is done with and
+ // clear the searching flag for us.
+ if (this.owner.searching) {
+ if (this.owner.isSynthetic) {
+ this.owner._syntheticView.abortSearch();
+ } else {
+ this.session.interruptSearch();
+ }
+ }
+
+ if (this._listenersRegistered) {
+ this._session.unregisterListener(this._sessionListener);
+ this._session.unregisterListener(aDBView);
+ aDBView.searchSession = null;
+ this._listenersRegistered = false;
+ }
+ },
+
+ /**
+ * Given a list of terms, mutate them so that they form a single boolean
+ * group.
+ *
+ * @param aTerms The search terms
+ * @param aCloneTerms Do we need to clone the terms?
+ */
+ _flattenGroupifyTerms(aTerms, aCloneTerms) {
+ let iTerm = 0,
+ term;
+ let outTerms = aCloneTerms ? [] : aTerms;
+ for (term of aTerms) {
+ if (aCloneTerms) {
+ let cloneTerm = this.session.createTerm();
+ cloneTerm.value = term.value;
+ cloneTerm.attrib = term.attrib;
+ cloneTerm.arbitraryHeader = term.arbitraryHeader;
+ cloneTerm.hdrProperty = term.hdrProperty;
+ cloneTerm.customId = term.customId;
+ cloneTerm.op = term.op;
+ cloneTerm.booleanAnd = term.booleanAnd;
+ cloneTerm.matchAll = term.matchAll;
+ term = cloneTerm;
+ outTerms.push(term);
+ }
+ if (iTerm == 0) {
+ term.beginsGrouping = true;
+ term.endsGrouping = false;
+ term.booleanAnd = true;
+ } else {
+ term.beginsGrouping = false;
+ term.endsGrouping = false;
+ }
+ iTerm++;
+ }
+ if (term) {
+ term.endsGrouping = true;
+ }
+
+ return outTerms;
+ },
+
+ /**
+ * Normalize the provided list of terms so that all of the 'groups' in it are
+ * ANDed together. If any OR clauses are detected outside of a group, we
+ * defer to |_flattenGroupifyTerms| to force the terms to be bundled up into
+ * a single group, maintaining the booleanAnd state of terms.
+ *
+ * This particular logic is desired because it allows the quick filter bar to
+ * produce interesting and useful filters.
+ *
+ * @param aTerms The search terms
+ * @param aCloneTerms Do we need to clone the terms?
+ */
+ _groupifyTerms(aTerms, aCloneTerms) {
+ let term;
+ let outTerms = aCloneTerms ? [] : aTerms;
+ let inGroup = false;
+ for (term of aTerms) {
+ // If we're in a group, all that is forbidden is the creation of new
+ // groups.
+ if (inGroup) {
+ if (term.beginsGrouping) {
+ // forbidden!
+ return this._flattenGroupifyTerms(aTerms, aCloneTerms);
+ } else if (term.endsGrouping) {
+ inGroup = false;
+ }
+ } else {
+ // If we're not in a group, the boolean must be AND. It's okay for a group
+ // to start.
+ // If it's not an AND then it needs to be in a group and we use the other
+ // function to take care of it. (This function can't back up...)
+ if (!term.booleanAnd) {
+ return this._flattenGroupifyTerms(aTerms, aCloneTerms);
+ }
+
+ inGroup = term.beginsGrouping;
+ }
+
+ if (aCloneTerms) {
+ let cloneTerm = this.session.createTerm();
+ cloneTerm.attrib = term.attrib;
+ cloneTerm.value = term.value;
+ cloneTerm.arbitraryHeader = term.arbitraryHeader;
+ cloneTerm.hdrProperty = term.hdrProperty;
+ cloneTerm.customId = term.customId;
+ cloneTerm.op = term.op;
+ cloneTerm.booleanAnd = term.booleanAnd;
+ cloneTerm.matchAll = term.matchAll;
+ cloneTerm.beginsGrouping = term.beginsGrouping;
+ cloneTerm.endsGrouping = term.endsGrouping;
+ term = cloneTerm;
+ outTerms.push(term);
+ }
+ }
+
+ return outTerms;
+ },
+
+ /**
+ * Set search terms that are defined by the 'view', which translates to that
+ * weird combo-box that lets you view your unread messages, messages by tag,
+ * messages that aren't deleted, etc.
+ *
+ * @param aViewTerms The list of terms. We take ownership and mutate it.
+ */
+ set viewTerms(aViewTerms) {
+ if (aViewTerms) {
+ this._viewTerms = this._groupifyTerms(aViewTerms);
+ } else if (this._viewTerms === null) {
+ // If they are nulling out already null values, do not apply view changes!
+ return;
+ } else {
+ this._viewTerms = null;
+ }
+ this.owner._applyViewChanges();
+ },
+ /**
+ * @returns the view terms currently in effect. Do not mutate this.
+ */
+ get viewTerms() {
+ return this._viewTerms;
+ },
+ /**
+ * Set search terms that are defined by the 'virtual folder' definition. This
+ * could also be thought of as the 'saved search' part of a saved search.
+ *
+ * @param aVirtualFolderTerms The list of terms. We make our own copy and
+ * do not mutate yours.
+ */
+ set virtualFolderTerms(aVirtualFolderTerms) {
+ if (aVirtualFolderTerms) {
+ // we need to clone virtual folder terms because they are pulled from a
+ // persistent location rather than created on demand
+ this._virtualFolderTerms = this._groupifyTerms(aVirtualFolderTerms, true);
+ } else if (this._virtualFolderTerms === null) {
+ // If they are nulling out already null values, do not apply view changes!
+ return;
+ } else {
+ this._virtualFolderTerms = null;
+ }
+ this.owner._applyViewChanges();
+ },
+ /**
+ * @returns the Virtual folder terms currently in effect. Do not mutate this.
+ */
+ get virtualFolderTerms() {
+ return this._virtualFolderTerms;
+ },
+
+ /**
+ * Set the terms that the user is explicitly searching on. These will be
+ * augmented with the 'context' search terms potentially provided by
+ * viewTerms and virtualFolderTerms.
+ *
+ * @param aUserTerms The list of terms. We take ownership and mutate it.
+ */
+ set userTerms(aUserTerms) {
+ if (aUserTerms) {
+ this._userTerms = this._groupifyTerms(aUserTerms);
+ } else if (this._userTerms === null) {
+ // If they are nulling out already null values, do not apply view changes!
+ return;
+ } else {
+ this._userTerms = null;
+ }
+ this.owner._applyViewChanges();
+ },
+ /**
+ * @returns the user terms currently in effect as set via the |userTerms|
+ * attribute or via the |quickSearch| method. Do not mutate this.
+ */
+ get userTerms() {
+ return this._userTerms;
+ },
+
+ clear() {
+ if (this.hasSearchTerms) {
+ this._viewTerms = null;
+ this._virtualFolderTerms = null;
+ this._userTerms = null;
+ this.owner._applyViewChanges();
+ }
+ },
+
+ get onlineSearch() {
+ return this._onlineSearch;
+ },
+ /**
+ * Virtual folders have a concept of 'online search' which affects the logic
+ * in updateSession that builds our search scopes. If onlineSearch is false,
+ * then when displaying the virtual folder unaffected by mail views or quick
+ * searches, we will most definitely perform an offline search. If
+ * onlineSearch is true, we will perform an online search only for folders
+ * which are not available offline and for which the server is configured
+ * to have an online 'searchScope'.
+ * When mail views or quick searches are in effect our search is always
+ * offline unless the only way to satisfy the needs of the constraints is an
+ * online search (read: the message body is required but not available
+ * offline.)
+ */
+ set onlineSearch(aOnlineSearch) {
+ this._onlineSearch = aOnlineSearch;
+ },
+
+ /**
+ * Populate the search session using viewTerms, virtualFolderTerms, and
+ * userTerms. The way this works is that each of the 'context' sets of
+ * terms gets wrapped into a group which is boolean anded together with
+ * everything else.
+ */
+ updateSession() {
+ let session = this.session;
+
+ // clear out our current terms and scope
+ session.searchTerms = [];
+ session.clearScopes();
+
+ // -- apply terms
+ if (this._virtualFolderTerms) {
+ for (let term of this._virtualFolderTerms) {
+ session.appendTerm(term);
+ }
+ }
+
+ if (this._viewTerms) {
+ for (let term of this._viewTerms) {
+ session.appendTerm(term);
+ }
+ }
+
+ if (this._userTerms) {
+ for (let term of this._userTerms) {
+ session.appendTerm(term);
+ }
+ }
+
+ // -- apply scopes
+ // If it is a synthetic view, create a single bogus scope so that we can use
+ // MatchHdr.
+ if (this.owner.isSynthetic) {
+ // We don't want to pass in a folder, and we don't want to use the
+ // allSearchableGroups scope, so we cheat and use AddDirectoryScopeTerm.
+ session.addDirectoryScopeTerm(Ci.nsMsgSearchScope.offlineMail);
+ return;
+ }
+
+ let filtering = this._userTerms != null || this._viewTerms != null;
+ let validityManager = Cc[
+ "@mozilla.org/mail/search/validityManager;1"
+ ].getService(Ci.nsIMsgSearchValidityManager);
+ for (let folder of this.owner._underlyingFolders) {
+ // we do not need to check isServer here because _underlyingFolders
+ // filtered it out when it was initialized.
+
+ let scope;
+ let serverScope = folder.server.searchScope;
+ // If we're offline, or this is a local folder, or there's no separate
+ // online scope, use server scope.
+ if (
+ Services.io.offline ||
+ serverScope == Ci.nsMsgSearchScope.offlineMail ||
+ folder instanceof Ci.nsIMsgLocalMailFolder
+ ) {
+ scope = serverScope;
+ } else {
+ // we need to test the validity in online and offline tables
+ let onlineValidityTable = validityManager.getTable(serverScope);
+
+ let offlineScope;
+ if (folder.flags & Ci.nsMsgFolderFlags.Offline) {
+ offlineScope = Ci.nsMsgSearchScope.offlineMail;
+ } else {
+ // The onlineManual table is used for local search when there is no
+ // body available.
+ offlineScope = Ci.nsMsgSearchScope.onlineManual;
+ }
+
+ let offlineValidityTable = validityManager.getTable(offlineScope);
+ let offlineAvailable = true;
+ let onlineAvailable = true;
+ for (let term of session.searchTerms) {
+ if (!term.matchAll) {
+ // for custom terms, we need to getAvailable from the custom term
+ if (term.attrib == Ci.nsMsgSearchAttrib.Custom) {
+ let customTerm = MailServices.filters.getCustomTerm(
+ term.customId
+ );
+ if (customTerm) {
+ offlineAvailable = customTerm.getAvailable(
+ offlineScope,
+ term.op
+ );
+ onlineAvailable = customTerm.getAvailable(serverScope, term.op);
+ } else {
+ // maybe an extension with a custom term was unloaded?
+ console.error(
+ "Custom search term " + term.customId + " missing"
+ );
+ }
+ } else {
+ if (!offlineValidityTable.getAvailable(term.attrib, term.op)) {
+ offlineAvailable = false;
+ }
+ if (!onlineValidityTable.getAvailable(term.attrib, term.op)) {
+ onlineAvailable = false;
+ }
+ }
+ }
+ }
+ // If both scopes work, honor the onlineSearch request, for saved search folders (!filtering)
+ // and the search dialog (!displayedFolder).
+ // If only one works, use it. Otherwise, default to offline
+ if (onlineAvailable && offlineAvailable) {
+ scope =
+ (!filtering || !this.owner.displayedFolder) && this.onlineSearch
+ ? serverScope
+ : offlineScope;
+ } else if (onlineAvailable) {
+ scope = serverScope;
+ } else {
+ scope = offlineScope;
+ }
+ }
+ session.addScopeTerm(scope, folder);
+ }
+ },
+
+ prettyStringOfSearchTerms(aSearchTerms) {
+ if (aSearchTerms == null) {
+ return " (none)\n";
+ }
+
+ let s = "";
+
+ for (let term of aSearchTerms) {
+ s += " " + term.termAsString + "\n";
+ }
+
+ return s;
+ },
+
+ prettyString() {
+ let s = " Search Terms:\n";
+ s += " Virtual Folder Terms:\n";
+ s += this.prettyStringOfSearchTerms(this._virtualFolderTerms);
+ s += " View Terms:\n";
+ s += this.prettyStringOfSearchTerms(this._viewTerms);
+ s += " User Terms:\n";
+ s += this.prettyStringOfSearchTerms(this._userTerms);
+ s += " Scope (Folders):\n";
+ for (let folder of this.owner._underlyingFolders) {
+ s += " " + folder.prettyName + "\n";
+ }
+ return s;
+ },
+};
+
+/**
+ * A simple nsIMsgSearchNotify listener that only listens for search start/stop
+ * so that it can tell the DBViewWrapper when the search has completed.
+ */
+function SearchSpecListener(aSearchSpec) {
+ this.searchSpec = aSearchSpec;
+}
+SearchSpecListener.prototype = {
+ onNewSearch() {
+ // searching should already be true by the time this happens. if it's not,
+ // it means some code is poking at the search session. bad!
+ if (!this.searchSpec.owner.searching) {
+ console.error("Search originated from unknown initiator! Confusion!");
+ this.searchSpec.owner.searching = true;
+ }
+ },
+
+ onSearchHit(aMsgHdr, aFolder) {
+ // this method is never invoked!
+ },
+
+ onSearchDone(aStatus) {
+ this.searchSpec.owner.searching = false;
+ },
+};
+
+/**
+ * Pretend to implement the nsIMsgSearchNotify interface, checking all matches
+ * we are given against the search session on the search spec. If they pass,
+ * relay them to the underlying db view, otherwise quietly eat them.
+ * This is what allows us to use mail-views and quick searches against
+ * gloda-backed searches.
+ */
+function FilteringSyntheticListener(aSearchSpec) {
+ this.searchSpec = aSearchSpec;
+ this.session = this.searchSpec.session;
+ this.dbView = this.searchSpec.owner.dbView.QueryInterface(
+ Ci.nsIMsgSearchNotify
+ );
+}
+FilteringSyntheticListener.prototype = {
+ onNewSearch() {
+ this.searchSpec.owner.searching = true;
+ this.dbView.onNewSearch();
+ },
+ onSearchHit(aMsgHdr, aFolder) {
+ // We don't need to worry about msgDatabase opening the database.
+ // It is (obviously) already open, and presumably gloda is already on the
+ // hook to perform the cleanup (assuming gloda is backing this search).
+ if (this.session.MatchHdr(aMsgHdr, aFolder.msgDatabase)) {
+ this.dbView.onSearchHit(aMsgHdr, aFolder);
+ }
+ },
+ onSearchDone(aStatus) {
+ this.searchSpec.owner.searching = false;
+ this.dbView.onSearchDone(aStatus);
+ },
+};
diff --git a/comm/mail/modules/SelectionWidgetController.jsm b/comm/mail/modules/SelectionWidgetController.jsm
new file mode 100644
index 0000000000..267ff7902b
--- /dev/null
+++ b/comm/mail/modules/SelectionWidgetController.jsm
@@ -0,0 +1,1355 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["SelectionWidgetController"];
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+/**
+ * @callback GetLayoutDirectionMethod
+ *
+ * @returns {"horizontal"|"vertical"} - The direction in which the widget
+ * visually lays out its items. "vertical" for top to bottom, "horizontal" for
+ * following the text direction.
+ */
+/**
+ * Details about the sizing of the widget in the same direction as its layout.
+ *
+ * @typedef {object} PageSizeDetails
+ * @param {number} viewSize - The size of the widget's "view" of its items. If
+ * the items are placed under a scrollable area with 0 padding, this would
+ * usually be the clientHeight or clientWidth, which exclude the border and
+ * the scroll bars.
+ * @param {number} viewOffset - The offset of the widget's "view" from the
+ * starting item. If the items are placed under a scrollable area with 0
+ * padding, this would usually be its scrollTop, or the absolute value of its
+ * scrollLeft (to account for negative values in right-to-left).
+ * @param {?number} itemSize - The size of an item. If the items have no spacing
+ * between them, then this would usually correspond to their bounding client
+ * widths or heights. If the items do not share the same size, or there are no
+ * items this should return null.
+ */
+/**
+ * @callback GetPageSizeDetailsMethod
+ *
+ * @returns {?PageSizeDetails} Details about the currently visible items. Or null
+ * if page navigation should not be allowed: either because the required
+ * conditions do not apply or PageUp and PageDown should be used for something
+ * else.
+ */
+/**
+ * @callback IndexFromTargetMethod
+ *
+ * @param {EventTarget} target - An event target.
+ *
+ * @returns {?number} - The index for the selectable item that contains the event
+ * target, or null if there is none.
+ */
+/**
+ * @callback SetFocusableItemMethod
+ *
+ * @param {?number} index - The index for the selectable item that should become
+ * focusable, replacing any previous focusable item. Or null if the widget
+ * itself should become focusable instead. If the corresponding item was not
+ * previously the focused item and it is not yet visible, it should be scrolled
+ * into view.
+ * @param {boolean} focus - Whether to also focus the specified item after it
+ * becomes focusable.
+ */
+/**
+ * @callback SetItemSelectionStateMethod
+ *
+ * @param {number} index - The index of the first selectable items to set the
+ * selection state of.
+ * @param {number} number - The number of subsequent selectable items that
+ * should be set to the same selection state, including the first item and any
+ * immediately following it.
+ * @param {boolean} selected - Whether the specified items should be selected or
+ * unselected.
+ */
+
+/**
+ * A class for handling the focus and selection controls for a widget.
+ *
+ * The widget is assumed to control a totally ordered set of selectable items,
+ * each of which may be referenced by their index in this ordering. The visual
+ * display of these items has an ordering that is faithful to this ordering.
+ * Note, a "selectable item" is any item that may receive focus and can be
+ * selected or unselected.
+ *
+ * A SelectionWidgetController instance will keep track of its widget's focus
+ * and selection states, and will provide a standard set of keyboard and mouse
+ * controls to the widget that handle changes in these states.
+ *
+ * The SelectionWidgetController instance will communicate with the widget to
+ * inform it of any changes in these states that the widget should adjust to. It
+ * may also query the widget for information as needed.
+ *
+ * The widget must inform its SelectionWidgetController instance of any changes
+ * in the index of selectable items. In particular, the widget should call the
+ * addedSelectableItems method to inform the controller of any initial set of
+ * items or any additional items that are added to the widget. It should also
+ * use the removeSelectableItems and moveSelectableItems methods when it wishes
+ * to remove or move items.
+ *
+ * The communication between the widget and its SelectionWidgetController
+ * instance will use the item's index to reference the item. This means that the
+ * representation of the item itself is left up to the widget.
+ *
+ * # Selection models
+ *
+ * The controller implements a number of selection models. Each of which has
+ * different selection features and controls suited to them. A model appropriate
+ * to the specific situation should be chosen.
+ *
+ * Model behaviour table:
+ *
+ * Model Name | Selection follows focus | Multi selectable
+ * ==========================================================================
+ * focus always no
+ * browse default no
+ * browse-multi default yes
+ *
+ *
+ * ## Behaviour: Selection follows focus
+ *
+ * This determines whether the focused item is selected.
+ *
+ * "always" means a focused item will always be selected, and no other item will
+ * be selected, which makes the selection redundant to the focus. This should be
+ * used if a change in the selection has no side effect beyond what a change in
+ * focus should trigger.
+ *
+ * "default" means the default action when navigating to a focused item is to
+ * change the selection to just that item, but the user may press a modifier
+ * (Control) to move the focus without selecting an item. The side effects to
+ * selecting an item should be light and non-disruptive since a user will likely
+ * change the selection regularly as they navigate the items without a modifier.
+ * Moreover, this behaviour will prefer selecting a single item, and so is not
+ * appropriate if the primary use case is to select multiple, or zero, items.
+ *
+ * ## Behaviour: Multi selectable
+ *
+ * This determines whether the user can select more than one item. If the
+ * selection follows the focus (by default) the user can use a modifier to
+ * select more than one item.
+ *
+ * Note, if this is "no", then in most usage, exactly one item will be selected.
+ * However, it is still possible to get into a state where no item is selected
+ * when the widget is empty or the selected item is deleted when it doesn't have
+ * focus.
+ */
+class SelectionWidgetController {
+ /**
+ * The widget this controller controls.
+ *
+ * @type {Element}
+ */
+ #widget = null;
+ /**
+ * A collection of methods passed to the controller at initialization.
+ *
+ * @type {object}
+ */
+ #methods = null;
+ /**
+ * The number of items the controller controls.
+ *
+ * @type {number}
+ */
+ #numItems = 0;
+ /**
+ * A range that points to all selectable items whose index `i` obeys
+ * `start <= i < end`
+ * Note, the `start` is inclusive of the index but the `end` is not.
+ *
+ * @typedef {object} SelectionRange
+ * @property {number} start - The starting point of the range.
+ * @property {number} end - The ending point of the range.
+ */
+ /**
+ * The ranges of selected indices, ordered by their `start` property.
+ *
+ * Each range is kept "disjoint": no natural number N obeys
+ * `#ranges[i].start <= N <= #ranges[i].end`
+ * for more than one index `i`. Essentially, this means that no range of
+ * selected items will overlap, or even be immediately adjacent to
+ * another set of selected items. Instead, if two ranges would be adjacent or
+ * overlap, they will be merged into one range instead.
+ *
+ * We use ranges, rather than a list of indices to reduce the footprint when a
+ * large number of items are selected. Similarly, we also avoid looping over
+ * all selected indices.
+ *
+ * @type {SelectionRange[]}
+ */
+ #ranges = [];
+ /**
+ * The direction of travel when holding the Shift modifier, or null if some
+ * other selection has broken the Shift selection sequence.
+ *
+ * @type {"forward"|"backward"|null}
+ */
+ #shiftRangeDirection = null;
+ /**
+ * The index of the focused selectable item, or null if the widget is focused
+ * instead.
+ *
+ * @type {?number}
+ */
+ #focusIndex = null;
+ /**
+ * Whether the focused item must always be selected.
+ *
+ * @type {boolean}
+ */
+ #focusIsSelected = false;
+ /**
+ * Whether the user can select multiple items.
+ *
+ * @type {boolean}
+ */
+ #multiSelectable = false;
+
+ /**
+ * Creates a new selection controller for the given widget.
+ *
+ * @param {widget} widget - The widget to control.
+ * @param {"focus"|"browse"|"browse-multi"} model - The selection model to
+ * follow.
+ * @param {object} methods - Methods for the controller to communicate with
+ * the widget.
+ * @param {GetLayoutDirectionMethod} methods.getLayoutDirection - Used to
+ * get the layout direction of the widget.
+ * @param {IndexFromTargetMethod} methods.indexFromTarget - Used to get the
+ * corresponding item index from an event target.
+ * @param {GetPageSizeDetailsMethod} method.getPageSizeDetails - Used to get
+ * details about the visible display of the widget items for page
+ * navigation.
+ * @param {SetFocusableItemMethod} methods.setFocusableItem - Used to update
+ * the widget on which item should receive focus.
+ * @param {SetItemSelectionStateMethod} methods.setItemSelectionState - Used
+ * to update the widget on whether a range of items should be selected.
+ */
+ constructor(widget, model, methods) {
+ this.#widget = widget;
+ switch (model) {
+ case "focus":
+ this.#focusIsSelected = true;
+ this.#multiSelectable = false;
+ break;
+ case "browse":
+ this.#focusIsSelected = false;
+ this.#multiSelectable = false;
+ break;
+ case "browse-multi":
+ this.#focusIsSelected = false;
+ this.#multiSelectable = true;
+ break;
+ default:
+ throw new RangeError(`The model "${model}" is not a supported model`);
+ }
+ this.#methods = methods;
+
+ widget.addEventListener("mousedown", event => this.#handleMouseDown(event));
+ if (this.#multiSelectable) {
+ widget.addEventListener("click", event => this.#handleClick(event));
+ }
+ widget.addEventListener("keydown", event => this.#handleKeyDown(event));
+ widget.addEventListener("focusin", event => this.#handleFocusIn(event));
+ }
+
+ #assertIntegerInRange(integer, lower, upper, name) {
+ if (!Number.isInteger(integer)) {
+ throw new RangeError(`"${name}" ${integer} is not an integer`);
+ }
+ if (lower != null && integer < lower) {
+ throw new RangeError(
+ `"${name}" ${integer} is not greater than or equal to ${lower}`
+ );
+ }
+ if (upper != null && integer > upper) {
+ throw new RangeError(
+ `"${name}" ${integer} is not less than or equal to ${upper}`
+ );
+ }
+ }
+
+ /**
+ * Update the widget's selection state for the specified items.
+ *
+ * @param {number} index - The index at which to start.
+ * @param {number} number - The number of items to set the state of.
+ */
+ #updateWidgetSelectionState(index, number) {
+ // First, inform the widget of the selection state of the new items.
+ let prevRangeEnd = index;
+ for (let { start, end } of this.#ranges) {
+ // Deselect the items in the gap between the previous range and this one.
+ // For the first range, there may not be a gap.
+ if (start > prevRangeEnd) {
+ this.#methods.setItemSelectionState(
+ prevRangeEnd,
+ start - prevRangeEnd,
+ false
+ );
+ }
+ // Select the items in the range.
+ this.#methods.setItemSelectionState(start, end - start, true);
+ prevRangeEnd = end;
+ }
+ // Deselect the items in the gap between the final range and the end of the
+ // new items, if there is a gap.
+ if (index + number > prevRangeEnd) {
+ this.#methods.setItemSelectionState(
+ prevRangeEnd,
+ index + number - prevRangeEnd,
+ false
+ );
+ }
+ }
+
+ /**
+ * Informs the controller that a set of selectable items were added to the
+ * widget. It is important to call this *after* the widget has indexed the new
+ * items.
+ *
+ * @param {number} index - The index at which the selectable items were added.
+ * Between 0 and the current number of items (inclusive).
+ * @param {number} number - The number of selectable items that were added at
+ * this index.
+ */
+ addedSelectableItems(index, number) {
+ this.#assertIntegerInRange(index, 0, this.#numItems, "index");
+ this.#assertIntegerInRange(number, 1, null, "number");
+ // Newly added items are unselected.
+ this.#adjustRangesOnAddItems(index, number, []);
+ this.#numItems += number;
+
+ if (this.#focusIndex != null && this.#focusIndex >= index) {
+ // Focus remains on the same item, but is adjusted in index.
+ this.#focusIndex += number;
+ }
+
+ this.#updateWidgetSelectionState(index, number);
+ }
+
+ /**
+ * Adjust the #ranges to account for additional inserted items.
+ *
+ * @param {number} index - The index at which items are added.
+ * @param {number} number - The number of items that are added at this index.
+ * @param {SelectionRange[]} insertSelection - The selection state of the
+ * inserted items. The ranges should be "disjoint" and only overlap the
+ * added indices. The given array is owned by the method.
+ */
+ #adjustRangesOnAddItems(index, number, insertSelection) {
+ // We want to insert whatever ranges are specified in insertSelection into
+ // the #ranges Array. insertRangeIndex tracks the index at which we will
+ // insert the given insertSelection.
+ let insertRangeIndex = 0;
+ // However, if insertSelection touches the start or end of the new items, it
+ // may be possible to merge it with an existing SelectionRange that touches
+ // the same edge.
+ let touchStartRange =
+ insertSelection.length && insertSelection[0].start == index
+ ? insertSelection[0]
+ : null;
+ let touchEndRange =
+ insertSelection.length &&
+ insertSelection[insertSelection.length - 1].end == index + number
+ ? insertSelection[insertSelection.length - 1]
+ : null;
+
+ // Go through ranges from last to first.
+ for (let i = this.#ranges.length - 1; i >= 0; i--) {
+ let { start, end } = this.#ranges[i];
+ if (touchStartRange && end == index) {
+ // Merge the range with touchStartRange.
+ touchStartRange.start = start;
+ this.#ranges.splice(i, 1, ...insertSelection);
+ // All earlier ranges should end strictly before the index.
+ return;
+ }
+ if (end <= index) {
+ // A B [ C D E ] F G
+ // ^start end^
+ // ^index (or higher)
+ // No change, and all earlier ranges are also before.
+ // This is the last range that lies before the inserted items, so we
+ // want to insert the given insertSelection after this range.
+ insertRangeIndex = i + 1;
+ break;
+ }
+ if (start < index) {
+ // start < index < end
+ // A B [ C D E ] F G
+ // ^start end^
+ // ^index
+ // The range is split in two parts by the index.
+ if (touchEndRange) {
+ // Extend touchEndRange to the end part of the current range.
+ // We add "number" to account for the inserted indices.
+ touchEndRange.end = end + number;
+ } else {
+ // Append a new range for the end part of the current range.
+ insertSelection.push({ start: index + number, end: end + number });
+ }
+ if (touchStartRange) {
+ // We merge touchStartRange with the first part of the current range.
+ touchStartRange.start = start;
+ this.#ranges.splice(i, 1, ...insertSelection);
+ } else {
+ // We adjust the first part to end where the inserted indices begin.
+ this.#ranges[i].end = index;
+ this.#ranges.splice(i + 1, 0, ...insertSelection);
+ }
+ // All earlier ranges should end strictly before the index.
+ return;
+ }
+ // A B [ C D E ] F G
+ // ^start end^
+ // ^index (or lower)
+ if (touchEndRange && start == index) {
+ // Merge the range with the touchEndRange.
+ // We add "number" to account for the inserted indices.
+ touchEndRange.end = end + number;
+ this.#ranges.splice(i, 1, ...insertSelection);
+ // All earlier ranges should end strictly before the index.
+ return;
+ }
+ // Shift the range to account for the inserted indices.
+ this.#ranges[i].start = start + number;
+ this.#ranges[i].end = end + number;
+ }
+
+ // Add the insert ranges in the gap.
+ if (insertSelection.length) {
+ this.#ranges.splice(insertRangeIndex, 0, ...insertSelection);
+ }
+ }
+
+ /**
+ * Remove a set of selectable items from the widget. The actual removing of
+ * the items and their elements from the widget is controlled by the widget
+ * through a callback, and the controller will update its internals. The
+ * controller may also change the selection state and focus of the widget
+ * if need be.
+ *
+ * @param {number} index - The index of the first selectable item to be
+ * removed.
+ * @param {number} number - The number of subsequent selectable items that
+ * will be removed, including the first item and any immediately following
+ * it.
+ * @param {Function} removeCallback - A function to call with no arguments
+ * that removes the specified items from the widget. After this call the
+ * widget should no longer be tracking the specified items and should have
+ * shifted the indices of the remaining items to fill the gap.
+ */
+ removeSelectableItems(index, number, removeCallback) {
+ this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
+ this.#assertIntegerInRange(number, 1, this.#numItems - index, "number");
+
+ let focusWasSelected =
+ this.#focusIndex != null && this.itemIsSelected(this.#focusIndex);
+ // Get whether the focus is within the widget now in case it is lost when
+ // the items are removed.
+ let focusInWidget = this.#focusInWidget();
+
+ removeCallback();
+
+ this.#adjustRangesOnRemoveItems(index, number);
+ this.#numItems -= number;
+
+ if (!this.#ranges.length) {
+ // Ends any shift range.
+ this.#shiftRangeDirection = null;
+ }
+
+ // Adjust focus.
+ if (this.#focusIndex == null || this.#focusIndex < index) {
+ // No change in index if on widget or before the removed index.
+ return;
+ }
+ if (this.#focusIndex >= index + number) {
+ // Reduce index if after the removed items.
+ this.#focusIndex -= number;
+ return;
+ }
+ // Focus is lost.
+ // Try to move to the first item after the removed items. If this does
+ // not exist, it will be capped to the last item overall in #moveFocus.
+ let newFocus = index;
+ if (focusWasSelected && this.#shiftRangeDirection) {
+ // As a special case, if the focused item was inside a shift selection
+ // range when it was removed, and the range still exists after, we keep
+ // the focus within the selection boundary that is opposite the "pivot"
+ // point. I.e. when selecting forwards we keep the focus below the
+ // selection end, and when selecting backwards we keep the focus above the
+ // selection start. This is to prevent the focused item becoming
+ // unselected in the middle of an ongoing shift range selection.
+ // NOTE: When selecting forwards, we do not keep the focus above the
+ // selection start because the user would only be here (at the selection
+ // "pivot") if they navigated with Ctrl+Space to this position, so we do
+ // not override the default behaviour. Similarly when selecting backwards
+ // we do not require the focus to remain above the selection end.
+ switch (this.#shiftRangeDirection) {
+ case "forward":
+ newFocus = Math.min(
+ newFocus,
+ this.#ranges[this.#ranges.length - 1].end - 1
+ );
+ break;
+ case "backward":
+ newFocus = Math.max(newFocus, this.#ranges[0].start);
+ }
+ }
+ // TODO: if we have a tree structure, we will want to move the focus
+ // within the nearest parent by clamping the focus to lie between the
+ // parent index (inclusive) and its last descendant (inclusive). If
+ // there are no children left, this will fallback to focusing the
+ // parent.
+ this.#moveFocus(newFocus, focusInWidget);
+ // #focusIndex may now be different from newFocus if the deleted indices
+ // were the final ones, and may be null if no items remain.
+ if (!this.#ranges.length && this.#focusIndex != null) {
+ // If the focus was moved, and now we have no selection, we select it.
+ // This is deemed relatively safe to do since it only effects the state of
+ // the focused item. And it is convenient to have selection resume.
+ this.#selectSingle(this.#focusIndex);
+ }
+ }
+
+ /**
+ * Adjust the #ranges to remove items.
+ *
+ * @param {number} index - The index at which items are removed.
+ * @param {number} number - The number of items that are removed.
+ *
+ * @returns {SelectionRange[]} - The removed SelectionRange objects. This will
+ * contain all the ranges that touched or overlapped the selected items.
+ * Owned by the caller.
+ */
+ #adjustRangesOnRemoveItems(index, number) {
+ // The ranges to remove.
+ let deleteRangesStart = 0;
+ let deleteRangesNumber = 0;
+ // The range to insert by combining overlapping ranges on either side of the
+ // deleted indices.
+ let insertRange = { start: index, end: index };
+
+ // Go through ranges from last to first.
+ for (let i = this.#ranges.length - 1; i >= 0; i--) {
+ let { start, end } = this.#ranges[i];
+ if (end < index) {
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index (or higher)
+ deleteRangesStart = i + 1;
+ // This and all earlier ranges do not need to be updated.
+ break;
+ } else if (start > index + number) {
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index + number (or lower)
+ // Shift the range.
+ this.#ranges[i].start = start - number;
+ this.#ranges[i].end = end - number;
+ continue;
+ }
+ deleteRangesNumber++;
+ if (end > index + number) {
+ // start <= (index + number) < end
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // <- removed ->
+ // A B C [ D E F G H I ] J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // Overlaps or touches the end of the removed indices, but is not
+ // entirely contained within the removed region.
+ // Extend the insertRange to the end of this range, and then shift it to
+ // remove the deleted indices.
+ insertRange.end = end - number;
+ }
+ if (start < index) {
+ // start < index <= end
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // <- removed ->
+ // A B C [ D E F G H I ] J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // Overlaps or touches the start of the removed indices, but is not
+ // entirely contained within the removed region.
+ // Extend the insertRange to the start of this range.
+ insertRange.start = start;
+ // Expect break on next loop.
+ }
+ }
+ if (!deleteRangesNumber) {
+ // No change in selection.
+ return [];
+ }
+ if (insertRange.end > insertRange.start) {
+ return this.#ranges.splice(
+ deleteRangesStart,
+ deleteRangesNumber,
+ insertRange
+ );
+ }
+ // No range to insert.
+ return this.#ranges.splice(deleteRangesStart, deleteRangesNumber);
+ }
+
+ /**
+ * Move a set of selectable items within the widget. The actual moving of
+ * the items and their elements in the widget is controlled by the widget
+ * through a callback, and the controller will update its internals.
+ *
+ * Unlike simply adding and then removing indices, this will transfer the
+ * focus and selection states along with the moved items.
+ *
+ * @param {number} from - The index of the first selectable item to be
+ * moved, before the move.
+ * @param {number} to - The index that the first selectable item will be moved
+ * to, after the move.
+ * @param {number} number - The number of subsequent selectable items that
+ * will be moved along with the first item, including the first item and any
+ * immediately following it. Their relative positions should remain the
+ * same.
+ * @param {Function} moveCallback - A function to call with no arguments
+ * that moves the specified items within the widget to the specified
+ * position. After this call the widget should have adjusted the indices
+ * of its items accordingly.
+ */
+ moveSelectableItems(from, to, number, moveCallback) {
+ this.#assertIntegerInRange(from, 0, this.#numItems - 1, "from");
+ this.#assertIntegerInRange(number, 1, this.#numItems - from, "number");
+ this.#assertIntegerInRange(to, 0, this.#numItems - number, "to");
+ // Get whether the focus is within the widget now in case it is lost when
+ // the items are moved.
+ let focusInWidget = this.#focusInWidget();
+
+ moveCallback();
+
+ let movedSelection = this.#adjustRangesOnRemoveItems(from, number);
+ // Descend the removed ranges.
+ for (let i = movedSelection.length - 1; i >= 0; i--) {
+ let range = movedSelection[i];
+ if (range.end <= from || range.start >= from + number) {
+ // Touched the start or end, but did not overlap.
+ movedSelection.splice(i, 1);
+ // NOTE: Since we are descending it is safe to continue the loop by
+ // decreasing i by 1.
+ continue;
+ }
+ // Translate and clip the range.
+ range.start = to + Math.max(0, range.start - from);
+ range.end = to + Math.min(number, range.end - from);
+ }
+ this.#adjustRangesOnAddItems(to, number, movedSelection);
+
+ // End any range selection.
+ this.#shiftRangeDirection = null;
+
+ // Adjust focus.
+ if (this.#focusIndex != null) {
+ if (this.#focusIndex >= from && this.#focusIndex < from + number) {
+ // Focus was in the moved range.
+ // We adjust the #focusIndex, but we also force the widget to reset the
+ // focus in case it needs to apply it to a newly created items.
+ this.#moveFocus(this.#focusIndex + to - from, focusInWidget);
+ } else {
+ // Adjust for removing `number` items at `from`.
+ if (this.#focusIndex >= from + number) {
+ this.#focusIndex -= number;
+ }
+ // Adjust for then adding `number` items at `to`.
+ if (this.#focusIndex >= to) {
+ this.#focusIndex += number;
+ }
+ }
+ }
+ // Reset the selection state for the moved items in case it needs to be
+ // applied to newly created items.
+ this.#updateWidgetSelectionState(to, number);
+ }
+
+ /**
+ * Select the specified item and deselect all other items. The next time the
+ * widget is entered by the user, the specified item will also receive the
+ * focus.
+ *
+ * This should normally not be used in a situation were the focus may already
+ * be within the widget because it will actively move the focus, which can be
+ * disruptive if unexpected. It is mostly exposed to set an initial selection
+ * after creating the widget, or when changing its dataset.
+ *
+ * @param {number} index - The index for the item to select. This must not
+ * exceed the number of items controlled by the widget.
+ */
+ selectSingleItem(index) {
+ this.#selectSingle(index);
+ let focusInWidget = this.#focusInWidget();
+ if (this.#focusIndex == null && !focusInWidget) {
+ // Wait until handleFocusIn to move the focus to the selected item in case
+ // other items become selected through setItemSelected.
+ return;
+ }
+ this.#moveFocus(index, focusInWidget);
+ }
+
+ /**
+ * Set the selection state of the specified item, but otherwise leave the
+ * selection state of other items the same.
+ *
+ * Note that this will throw if the selection model does not support multi
+ * selection. Generally, you should try and use selectSingleItem instead
+ * because this also moves the focus appropriately and works for all models.
+ *
+ * @param {number} index - The index for the item to set the selection state
+ * of.
+ * @param {boolean} selected - Whether the item should be selected or
+ * unselected.
+ */
+ setItemSelected(index, selected) {
+ if (!this.#multiSelectable) {
+ throw new Error("Widget does not support multi-selection");
+ }
+ this.#toggleSelection(index, !!selected);
+ }
+
+ /**
+ * Get the ranges of all selected items.
+ *
+ * Note that ranges are returned rather than individual indices to keep this
+ * method fast. Unlike the selected indices which might become very large with
+ * a single user operation, like Select-All, the number of ranges will
+ * increase by order-one range per user interaction or public method call.
+ *
+ * Note that the SelectionRange objects specify the range with a `start` and
+ * `end` index. The `start` is inclusive of the index, but the `end` is
+ * not.
+ *
+ * Note that the returned Array is static (it will not update as the selection
+ * changes).
+ *
+ * @returns {SelectionRange[]} - An array of all non-overlapping selection
+ * ranges, order by their start index.
+ */
+ getSelectionRanges() {
+ return Array.from(this.#ranges, r => {
+ return { start: r.start, end: r.end };
+ });
+ }
+
+ /**
+ * Query whether the specified item is selected or not.
+ *
+ * @param {number} index - The index for the item to query.
+ *
+ * @returns {boolean} - Whether the item is selected.
+ */
+ itemIsSelected(index) {
+ this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
+ for (let { start, end } of this.#ranges) {
+ if (index < start) {
+ // index was not in any lower ranges and is before the start of this
+ // range, so should be unselected.
+ return false;
+ }
+ if (index < end) {
+ // start <= index < end
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Select the specified range of indices, and nothing else.
+ *
+ * @param {number} index - The first index to select.
+ * @param {number} number - The number of indices to select.
+ */
+ #selectRange(index, number) {
+ this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
+ this.#assertIntegerInRange(number, 1, this.#numItems - index, "number");
+
+ let prevRanges = this.#ranges;
+ let start = index;
+ let end = index + number;
+ if (
+ prevRanges.length == 1 &&
+ prevRanges[0].start == start &&
+ prevRanges[0].end == end
+ ) {
+ // No change.
+ return;
+ }
+
+ this.#ranges = [{ start, end }];
+ // Adjust the selection state to match the new range.
+ // NOTE: For simplicity, we do a blanket re-selection across the whole
+ // region, even items in between ranges that are not selected.
+ // NOTE: If the new range overlaps the previous range then the selection
+ // state be set more than once for an item, but it will be to the same
+ // value.
+ if (prevRanges.length) {
+ let firstRangeStart = prevRanges[0].start;
+ let lastRangeEnd = prevRanges[prevRanges.length - 1].end;
+ this.#updateWidgetSelectionState(
+ firstRangeStart,
+ lastRangeEnd - firstRangeStart
+ );
+ }
+ this.#updateWidgetSelectionState(index, number);
+ }
+
+ /**
+ * Select one index and nothing else.
+ *
+ * @param {number} index - The index to select.
+ */
+ #selectSingle(index) {
+ this.#selectRange(index, 1);
+ // Cancel any shift range.
+ this.#shiftRangeDirection = null;
+ }
+
+ /**
+ * Toggle the selection state at a single index.
+ *
+ * @param {number} index - The index to toggle the selection state of.
+ * @param {boolean} [selectState] - The state to force the selection state of
+ * the item to, or leave undefined to toggle the state.
+ */
+ #toggleSelection(index, selectState) {
+ this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
+
+ let wasSelected = false;
+ let i;
+ // We traverse over the ranges.
+ for (i = 0; i < this.#ranges.length; i++) {
+ let { start, end } = this.#ranges[i];
+ // Test if in a gap between the end of last range and the start of the
+ // current one.
+ // NOTE: Since we did not break on the previous loop, we already know that
+ // the index is above the end of the previous range.
+ if (index < start) {
+ // This index is not selected.
+ break;
+ }
+ // Test if in the range.
+ if (index < end) {
+ // start <= index < end
+ wasSelected = true;
+ if (selectState) {
+ // Already selected and we want to keep it that way.
+ break;
+ }
+ if (start == index && end == index + 1) {
+ // A B C [ D ] E F G
+ // start^ ^end
+ // ^index
+ //
+ // Remove the range entirely.
+ this.#ranges.splice(i, 1);
+ } else if (start == index) {
+ // A [ B C D E F ] G
+ // ^start end^
+ // ^index
+ //
+ // Remove the start of the range.
+ this.#ranges[i].start = index + 1;
+ } else if (end == index + 1) {
+ // A [ B C D E F ] G
+ // ^start end^
+ // ^index
+ //
+ // Remove the end of the range.
+ this.#ranges[i].end = index;
+ } else {
+ // A [ B C D E F ] G
+ // ^start end^
+ // ^index
+ //
+ // Split the range in two.
+ //
+ // A [ B C ] D [ E F ] G
+ this.#ranges[i].end = index;
+ this.#ranges.splice(i + 1, 0, { start: index + 1, end });
+ }
+ break;
+ }
+ }
+ if (!wasSelected && (selectState == undefined || selectState)) {
+ // The index i points to a *gap* between existing ranges, so lies in
+ // [0, numItems]. Note, the space between the start and the first range,
+ // or the end and the last range count as gaps, even if they are zero
+ // width.
+ // We want to know whether the index touches the borders of the range
+ // either side of the gap.
+ let touchesRangeEnd = i > 0 && index == this.#ranges[i - 1].end;
+ // A [ B C D ] E F G H I
+ // end(i-1)^
+ // ^index
+ let touchesRangeStart =
+ i < this.#ranges.length && index + 1 == this.#ranges[i].start;
+ // A B C D E [ F G H ] I
+ // ^start(i)
+ // ^index
+ if (touchesRangeEnd && touchesRangeStart) {
+ // A [ B C D ] E [ F G H ] I
+ // ^index
+ // Merge the two ranges together.
+ this.#ranges[i - 1].end = this.#ranges[i].end;
+ this.#ranges.splice(i, 1);
+ } else if (touchesRangeEnd) {
+ // Grow the range forwards to include the index.
+ this.#ranges[i - 1].end = index + 1;
+ } else if (touchesRangeStart) {
+ // Grow the range backwards to include the index.
+ this.#ranges[i].start = index;
+ } else {
+ // Create a new range.
+ this.#ranges.splice(i, 0, { start: index, end: index + 1 });
+ }
+ }
+ this.#methods.setItemSelectionState(index, 1, selectState ?? !wasSelected);
+ // Cancel any shift range.
+ this.#shiftRangeDirection = null;
+ }
+
+ /**
+ * Determine whether the focus lies within the widget or elsewhere.
+ *
+ * @returns {boolean} - Whether the active element is the widget or one of its
+ * descendants.
+ */
+ #focusInWidget() {
+ return this.#widget.contains(this.#widget.ownerDocument.activeElement);
+ }
+
+ /**
+ * Make the specified element focusable. Also move focus to this element if
+ * the widget already has focus.
+ *
+ * @param {?number} index - The index of the item to focus, or null to focus
+ * the widget. If the index is out of range, it will be truncated.
+ * @param {boolean} [forceInWidget] - Whether the focus was in the widget
+ * before the specified element becomes focusable. This should be given to
+ * reference an earlier focus state, otherwise leave undefined to use the
+ * current focus state.
+ */
+ #moveFocus(index, focusInWidget) {
+ let numItems = this.#numItems;
+ if (index != null) {
+ if (index >= numItems) {
+ index = numItems ? numItems - 1 : null;
+ } else if (index < 0) {
+ index = numItems ? 0 : null;
+ }
+ }
+ if (focusInWidget == undefined) {
+ focusInWidget = this.#focusInWidget();
+ }
+
+ this.#focusIndex = index;
+ // If focus is within the widget, we move focus onto the new item.
+ this.#methods.setFocusableItem(index, focusInWidget);
+ }
+
+ #handleFocusIn(event) {
+ if (
+ // No item is focused,
+ this.#focusIndex == null &&
+ // and we have at least one item,
+ this.#numItems &&
+ // and the focus moved from outside the widget.
+ // NOTE: relatedTarget may be null, but Node.contains will also return
+ // false for this case, as desired.
+ !this.#widget.contains(event.relatedTarget)
+ ) {
+ // If nothing is selected, select the first item.
+ if (!this.#ranges.length) {
+ this.#selectSingle(0);
+ }
+ // Focus first selected item.
+ this.#moveFocus(this.#ranges[0].start);
+ return;
+ }
+ if (this.#focusIndex != this.#methods.indexFromTarget(event.target)) {
+ // Restore focus to where it needs to be.
+ this.#moveFocus(this.#focusIndex);
+ }
+ }
+
+ /**
+ * Adjust the focus and selection in response to a user generated event.
+ *
+ * @param {?number} [focusIndex] - The new index to move focus to, or null to
+ * move the focus to the widget, or undefined to leave the focus as it is.
+ * Note that the focusIndex will be clamped to lie within the current index
+ * range.
+ * @param {string} [select] - The change in selection to trigger, relative to
+ * the #focusIndex. "single" to select the #focusIndex, "toggle" to swap its
+ * selection state, "range" to start or continue a range selection, or "all"
+ * to select all items.
+ */
+ #adjustFocusAndSelection(focusIndex, select) {
+ let prevFocusIndex = this.#focusIndex;
+ if (focusIndex !== undefined) {
+ // NOTE: We need a strict inequality since focusIndex may be null.
+ this.#moveFocus(focusIndex);
+ }
+ // Change selection relative to the focused index.
+ // NOTE: We use the #focusIndex value rather than the focusIndex variable.
+ if (this.#focusIndex != null) {
+ switch (select) {
+ case "single":
+ this.#selectSingle(this.#focusIndex);
+ break;
+ case "toggle":
+ this.#toggleSelection(this.#focusIndex);
+ break;
+ case "range":
+ // We want to select all items between a "pivot" point and the focused
+ // index. If we do not have a "pivot" point, we use the previously
+ // focused index.
+ // This "pivot" point is lost every time the user performs a single
+ // selection or a toggle selection. I.e. if the selection changes by
+ // any means other than "range" selection.
+ //
+ // NOTE: We represent the presence of such a "pivot" point using the
+ // #shiftRangeDirection property. If it is null, no such point exists,
+ // if it is "forward" then the "pivot" point is the first selected
+ // index, and if it is "backward" then the "pivot" point is the last
+ // selected index.
+ // Usually, we only have one #ranges entry whilst doing such a Shift
+ // selection, but if items are added in the middle of such a range,
+ // then the selection can be split, but subsequent Shift selection
+ // will reselect all of them.
+ // NOTE: We do not keep track of this "pivot" index explicitly in a
+ // property because otherwise we would have to adjust its value every
+ // time items are removed, and handle cases where the "pivot" index is
+ // removed. Instead, we just borrow the logic of how the #ranges array
+ // is updated, and continue to derive the "pivot" point from the
+ // #shiftRangeDirection and #ranges properties.
+ let start;
+ switch (this.#shiftRangeDirection) {
+ case "forward":
+ // When selecting forward, the range start is the first selected
+ // index.
+ start = this.#ranges[0].start;
+ break;
+ case "backward":
+ // When selecting backward, the range end is the last selected
+ // index.
+ start = this.#ranges[this.#ranges.length - 1].end - 1;
+ break;
+ default:
+ // We start a new range selection between the previously focused
+ // index and the newly focused index.
+ start = prevFocusIndex || 0;
+ break;
+ }
+ let number;
+ // NOTE: Selection may transition from "forward" to "backward" if the
+ // user moves the selection in the other direction.
+ if (start > this.#focusIndex) {
+ this.#shiftRangeDirection = "backward";
+ number = start - this.#focusIndex + 1;
+ start = this.#focusIndex;
+ } else {
+ this.#shiftRangeDirection = "forward";
+ number = this.#focusIndex - start + 1;
+ }
+ this.#selectRange(start, number);
+ break;
+ }
+ }
+
+ // Selecting all does not require focus.
+ if (select == "all" && this.#numItems) {
+ this.#shiftRangeDirection = null;
+ this.#selectRange(0, this.#numItems);
+ }
+ }
+
+ #handleMouseDown(event) {
+ // NOTE: The default handler for mousedown will move focus onto the clicked
+ // item or the widget, but #handleFocusIn will re-assign it to the current
+ // #focusIndex if it differs.
+ if (event.button != 0 || event.metaKey || event.altKey) {
+ return;
+ }
+ let { shiftKey, ctrlKey } = event;
+ if (
+ (ctrlKey && shiftKey) ||
+ // Both modifiers pressed.
+ ((ctrlKey || shiftKey) && !this.#multiSelectable)
+ // Attempting multi-selection when not supported
+ ) {
+ return;
+ }
+ let clickIndex = this.#methods.indexFromTarget(event.target);
+ if (clickIndex == null) {
+ // Clicked empty space.
+ return;
+ }
+ if (ctrlKey) {
+ this.#adjustFocusAndSelection(clickIndex, "toggle");
+ } else if (shiftKey) {
+ this.#adjustFocusAndSelection(clickIndex, "range");
+ } else if (this.#multiSelectable && this.itemIsSelected(clickIndex)) {
+ // We set the focus now, but wait until "click" to select a single item.
+ // We do this to allow the user to drag a multi selection.
+ this.#adjustFocusAndSelection(clickIndex, undefined);
+ } else {
+ this.#adjustFocusAndSelection(clickIndex, "single");
+ }
+ }
+
+ #handleClick(event) {
+ // NOTE: This handler is only used if we have #multiSelectable.
+ // See #handleMouseDown
+ if (
+ event.button != 0 ||
+ event.metaKey ||
+ event.altKey ||
+ event.shiftKey ||
+ event.ctrlKey
+ ) {
+ return;
+ }
+ let clickIndex = this.#methods.indexFromTarget(event.target);
+ if (clickIndex == null) {
+ return;
+ }
+ this.#adjustFocusAndSelection(clickIndex, "single");
+ }
+
+ #handleKeyDown(event) {
+ if (event.altKey) {
+ // Not handled.
+ return;
+ }
+
+ let { shiftKey, ctrlKey, metaKey } = event;
+ if (
+ this.#multiSelectable &&
+ event.key == "a" &&
+ !shiftKey &&
+ (AppConstants.platform == "macosx") == metaKey &&
+ (AppConstants.platform != "macosx") == ctrlKey
+ ) {
+ this.#adjustFocusAndSelection(undefined, "all");
+ event.stopPropagation();
+ event.preventDefault();
+ return;
+ }
+
+ if (metaKey) {
+ // Not handled.
+ return;
+ }
+
+ if (event.key == " ") {
+ // Always reserve the Space press.
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (shiftKey) {
+ // Not handled.
+ return;
+ }
+
+ if (ctrlKey) {
+ if (this.#multiSelectable) {
+ this.#adjustFocusAndSelection(undefined, "toggle");
+ }
+ // Else, do nothing.
+ return;
+ }
+
+ this.#adjustFocusAndSelection(undefined, "single");
+ return;
+ }
+
+ let forwardKey;
+ let backwardKey;
+ if (this.#methods.getLayoutDirection() == "vertical") {
+ forwardKey = "ArrowDown";
+ backwardKey = "ArrowUp";
+ } else if (this.#widget.matches(":dir(ltr)")) {
+ forwardKey = "ArrowRight";
+ backwardKey = "ArrowLeft";
+ } else {
+ forwardKey = "ArrowLeft";
+ backwardKey = "ArrowRight";
+ }
+
+ // NOTE: focusIndex may be set to an out of range index, but it will be
+ // clipped in #moveFocus.
+ let focusIndex;
+ switch (event.key) {
+ case "Home":
+ focusIndex = 0;
+ break;
+ case "End":
+ focusIndex = this.#numItems - 1;
+ break;
+ case "PageUp":
+ case "PageDown":
+ let sizeDetails = this.#methods.getPageSizeDetails();
+ if (!sizeDetails) {
+ // Do not handle and allow PageUp or PageDown to propagate.
+ return;
+ }
+ if (!sizeDetails.itemSize || !sizeDetails.viewSize) {
+ // Still reserve PageUp and PageDown
+ break;
+ }
+ let { itemSize, viewSize, viewOffset } = sizeDetails;
+ // We want to determine what items are visible. We count an item as
+ // "visible" if more than half of it is in view.
+ //
+ // Consider an item at index i that follows the assumed model:
+ //
+ // [ item content ]
+ // <---- itemSize ---->
+ // ---->start_i = i * itemSize
+ //
+ // where start_i is the offset of the starting edge of the item relative
+ // to the starting edge of the first item.
+ //
+ // As such, an item will be visible if
+ // start_i + itemSize / 2 > viewOffset
+ // and
+ // start_i + itemSize / 2 < viewOffset + viewSize
+ // <=>
+ // i > (viewOffset / itemSize) - 1/2
+ // and
+ // i < ((viewOffset + viewSize) / itemSize) - 1/2
+
+ // First, we want to know the number of items we can visibly fit on a
+ // page. I.e. when the viewOffset is 0, the number of items whose midway
+ // point is lower than the viewSize. This is given by (i + 1), where i
+ // is the largest index i that satisfies
+ // i < (viewSize / itemSize) - 1/2
+ // This is given by taking the ceiling - 1, which cancels with the +1.
+ let itemsPerPage = Math.ceil(viewSize / itemSize - 0.5);
+ if (itemsPerPage <= 1) {
+ break;
+ }
+ if (event.key == "PageUp") {
+ // We want to know what the first visible index is. I.e. the smallest
+ // i that satisfies
+ // i > (viewOffset / itemSize) - 1/2
+ // This is equivalent to flooring the right hand side + 1.
+ let pageStart = Math.floor(viewOffset / itemSize - 0.5) + 1;
+ if (this.#focusIndex == null || this.#focusIndex > pageStart) {
+ // Move focus to the top of the page.
+ focusIndex = pageStart;
+ } else {
+ // Reduce focusIndex by one page.
+ // We add "1" index to try and keep the previous focusIndex visible
+ // at the bottom of the view.
+ focusIndex = this.#focusIndex - itemsPerPage + 1;
+ }
+ } else {
+ // We want to know what the last visible index is. I.e. the largest i
+ // that satisfies
+ // i < (viewOffset + viewSize) / itemSize - 1/2
+ // This is equivalent to ceiling the right hand side - 1.
+ let pageEnd = Math.ceil((viewOffset + viewSize) / itemSize - 0.5) - 1;
+ if (this.#focusIndex == null || this.#focusIndex < pageEnd) {
+ // Move focus to the end of the page.
+ focusIndex = pageEnd;
+ } else {
+ // Increase focusIndex by one page.
+ // We minus "1" index to try and keep the previous focusIndex
+ // visible at the top of the view.
+ focusIndex = this.#focusIndex + itemsPerPage - 1;
+ }
+ }
+ break;
+ case forwardKey:
+ if (this.#focusIndex == null) {
+ // Move to first item.
+ focusIndex = 0;
+ } else {
+ focusIndex = this.#focusIndex + 1;
+ }
+ break;
+ case backwardKey:
+ if (this.#focusIndex == null) {
+ // Move to first item.
+ focusIndex = 0;
+ } else {
+ focusIndex = this.#focusIndex - 1;
+ }
+ break;
+ default:
+ // Not a navigation key.
+ return;
+ }
+
+ // NOTE: We always reserve control over these keys, regardless of whether
+ // we respond to them.
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (focusIndex === undefined) {
+ return;
+ }
+
+ if (shiftKey && ctrlKey) {
+ // Both modifiers not handled.
+ return;
+ }
+
+ if (ctrlKey) {
+ // Move the focus without changing the selection.
+ if (!this.#focusIsSelected) {
+ this.#adjustFocusAndSelection(focusIndex, undefined);
+ }
+ return;
+ }
+
+ if (shiftKey) {
+ // Range selection.
+ if (this.#multiSelectable) {
+ this.#adjustFocusAndSelection(focusIndex, "range");
+ }
+ return;
+ }
+
+ this.#adjustFocusAndSelection(focusIndex, "single");
+ }
+}
diff --git a/comm/mail/modules/SessionStore.jsm b/comm/mail/modules/SessionStore.jsm
new file mode 100644
index 0000000000..162abfecef
--- /dev/null
+++ b/comm/mail/modules/SessionStore.jsm
@@ -0,0 +1,16 @@
+/* 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 EXPORTED_SYMBOLS = ["SessionStore"];
+
+/**
+ * This is a shim for SessionStore in moz-central to prevent bug 1713801. Only
+ * the methods that appear to be hit by comm-central are implemented.
+ */
+var SessionStore = {
+ updateSessionStoreFromTablistener(aBrowser, aBrowsingContext, aData) {},
+ maybeExitCrashedState() {},
+};
diff --git a/comm/mail/modules/SessionStoreManager.jsm b/comm/mail/modules/SessionStoreManager.jsm
new file mode 100644
index 0000000000..1c8ea6bec6
--- /dev/null
+++ b/comm/mail/modules/SessionStoreManager.jsm
@@ -0,0 +1,302 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["SessionStoreManager"];
+
+const { JSONFile } = ChromeUtils.importESModule(
+ "resource://gre/modules/JSONFile.sys.mjs"
+);
+
+/**
+ * asuth arbitrarily chose this value to trade-off powersaving,
+ * processor usage, and recency of state in the face of the impossibility of
+ * our crashing; he also worded this.
+ */
+var SESSION_AUTO_SAVE_DEFAULT_MS = 300000; // 5 minutes
+
+var SessionStoreManager = {
+ _initialized: false,
+
+ /**
+ * Session restored successfully on startup; use this to test for an early
+ * failed startup which does not restore user tab state to ensure a session
+ * save on close will not overwrite the last good session state.
+ */
+ _restored: false,
+
+ _sessionAutoSaveTimer: null,
+
+ _sessionAutoSaveTimerIntervalMS: SESSION_AUTO_SAVE_DEFAULT_MS,
+
+ /**
+ * The persisted state of the previous session. This is resurrected
+ * from disk when the module is initialized and cleared when all
+ * required windows have been restored.
+ */
+ _initialState: null,
+
+ /**
+ * A flag indicating whether the state "just before shutdown" of the current
+ * session has been persisted to disk. See |observe| and |unloadingWindow|
+ * for justification on why we need this.
+ */
+ _shutdownStateSaved: false,
+
+ /**
+ * The JSONFile store object.
+ */
+ get store() {
+ if (this._store) {
+ return this._store;
+ }
+
+ return (this._store = new JSONFile({
+ path: this.sessionFile.path,
+ backupTo: this.sessionFile.path + ".backup",
+ }));
+ },
+
+ /**
+ * Gets the nsIFile used for session storage.
+ */
+ get sessionFile() {
+ let sessionFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ sessionFile.append("session.json");
+ return sessionFile;
+ },
+
+ /**
+ * This is called on startup, and when a new 3 pane window is opened after
+ * the last 3 pane window was closed (e.g., on the mac, closing the last
+ * window doesn't shut down the app).
+ */
+ async _init() {
+ await this._loadSessionFile();
+
+ // we listen for "quit-application-granted" instead of
+ // "quit-application-requested" because other observers of the
+ // latter can cancel the shutdown.
+ Services.obs.addObserver(this, "quit-application-granted");
+
+ this.startPeriodicSave();
+
+ this._initialized = true;
+ },
+
+ /**
+ * Loads the session file into _initialState. This should only be called by
+ * _init and a unit test.
+ */
+ async _loadSessionFile() {
+ if (!this.sessionFile.exists()) {
+ return;
+ }
+
+ // Read the session state data from file, asynchronously.
+ // An error on the json file returns an empty object which corresponds
+ // to a null |_initialState|.
+ await this.store.load();
+ this._initialState =
+ this.store.data.toSource() == {}.toSource() ? null : this.store.data;
+ },
+
+ /**
+ * Opens the windows that were open in the previous session.
+ */
+ _openOtherRequiredWindows(aWindow) {
+ // XXX we might want to display a restore page and let the user decide
+ // whether to restore the other windows, just like Firefox does.
+
+ if (!this._initialState || !this._initialState.windows || !aWindow) {
+ return;
+ }
+
+ for (var i = 0; i < this._initialState.windows.length; ++i) {
+ aWindow.open(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar"
+ );
+ }
+ },
+
+ /**
+ * Writes the state object to disk.
+ */
+ _saveStateObject(aStateObj) {
+ if (!this.store) {
+ console.error(
+ "SessionStoreManager: could not create data store from file"
+ );
+ return;
+ }
+
+ let currentStateString = JSON.stringify(aStateObj);
+ let storedStateString =
+ this.store.dataReady && this.store.data
+ ? JSON.stringify(this.store.data)
+ : null;
+
+ // Do not save state (overwrite last good state) in case of a failed startup.
+ // Write async to disk only if state changed since last write.
+ if (!this._restored || currentStateString == storedStateString) {
+ return;
+ }
+
+ this.store.data = aStateObj;
+ this.store.saveSoon();
+ },
+
+ /**
+ * @returns an empty state object that can be populated with window states.
+ */
+ _createStateObject() {
+ return {
+ rev: 0,
+ windows: [],
+ };
+ },
+
+ /**
+ * Writes the state of all currently open 3pane windows to disk.
+ */
+ _saveState() {
+ let state = this._createStateObject();
+
+ // XXX we'd like to support other window types in future, but for now
+ // only get the 3pane windows.
+ for (let win of Services.wm.getEnumerator("mail:3pane")) {
+ if (
+ win &&
+ "complete" == win.document.readyState &&
+ win.getWindowStateForSessionPersistence
+ ) {
+ state.windows.push(win.getWindowStateForSessionPersistence());
+ }
+ }
+
+ this._saveStateObject(state);
+ },
+
+ // Timer Callback
+ _sessionAutoSaveTimerCallback() {
+ SessionStoreManager._saveState();
+ },
+
+ // Observer Notification Handler
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ // This is observed before any windows start unloading if something other
+ // than the last 3pane window closing requested the application be
+ // shutdown. For example, when the user quits via the file menu.
+ case "quit-application-granted":
+ if (!this._shutdownStateSaved) {
+ this.stopPeriodicSave();
+ this._saveState();
+
+ // this is to ensure we don't clobber the saved state when the
+ // 3pane windows unload.
+ this._shutdownStateSaved = true;
+ }
+ break;
+ }
+ },
+
+ // Public API
+
+ /**
+ * Called by each 3pane window instance when it loads.
+ *
+ * @returns a window state object if aWindow was opened as a result of a
+ * session restoration, null otherwise.
+ */
+ async loadingWindow(aWindow) {
+ let firstWindow = !this._initialized || this._shutdownStateSaved;
+ if (firstWindow) {
+ await this._init();
+ }
+
+ // If we are seeing a new 3-pane, we are obviously not in a shutdown
+ // state anymore. (This would happen if all the 3panes got closed but
+ // we did not quit because another window was open and then a 3pane showed
+ // up again. This can happen in both unit tests and real life.)
+ // We treat this case like the first window case, and do a session restore.
+ this._shutdownStateSaved = false;
+
+ let windowState = null;
+ if (this._initialState && this._initialState.windows) {
+ windowState = this._initialState.windows.pop();
+ if (0 == this._initialState.windows.length) {
+ this._initialState = null;
+ }
+ }
+
+ if (firstWindow) {
+ this._openOtherRequiredWindows(aWindow);
+ }
+
+ return windowState;
+ },
+
+ /**
+ * Called by each 3pane window instance when it unloads. If aWindow is the
+ * last 3pane window, its state is persisted. The last 3pane window unloads
+ * first before the "quit-application-granted" event is generated.
+ */
+ unloadingWindow(aWindow) {
+ if (!this._shutdownStateSaved) {
+ // determine whether aWindow is the last open window
+ let lastWindow = true;
+ for (let win of Services.wm.getEnumerator("mail:3pane")) {
+ if (win != aWindow) {
+ lastWindow = false;
+ }
+ }
+
+ if (lastWindow) {
+ // last chance to save any state for the current session since
+ // aWindow is the last 3pane window and the "quit-application-granted"
+ // event is observed AFTER this.
+ this.stopPeriodicSave();
+
+ let state = this._createStateObject();
+ state.windows.push(aWindow.getWindowStateForSessionPersistence());
+ this._saveStateObject(state);
+
+ // XXX this is to ensure we don't clobber the saved state when we
+ // observe the "quit-application-granted" event.
+ this._shutdownStateSaved = true;
+ }
+ }
+ },
+
+ /**
+ * Stops periodic session persistence.
+ */
+ stopPeriodicSave() {
+ if (this._sessionAutoSaveTimer) {
+ this._sessionAutoSaveTimer.cancel();
+
+ delete this._sessionAutoSaveTimer;
+ this._sessionAutoSaveTimer = null;
+ }
+ },
+
+ /**
+ * Starts periodic session persistence.
+ */
+ startPeriodicSave() {
+ if (!this._sessionAutoSaveTimer) {
+ this._sessionAutoSaveTimer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ );
+
+ this._sessionAutoSaveTimer.initWithCallback(
+ this._sessionAutoSaveTimerCallback,
+ this._sessionAutoSaveTimerIntervalMS,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ }
+ },
+};
diff --git a/comm/mail/modules/ShortcutsManager.jsm b/comm/mail/modules/ShortcutsManager.jsm
new file mode 100644
index 0000000000..38167d887b
--- /dev/null
+++ b/comm/mail/modules/ShortcutsManager.jsm
@@ -0,0 +1,345 @@
+/* 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/. */
+
+/**
+ * Module used to collect all global shortcuts that can (will) be customizable.
+ * Use the shortcuts[] array to add global shortcuts that need to work on the
+ * whole window. The `context` property allows using the same shortcut for
+ * different context. The event handling needs to be defined in the window.
+ */
+
+const EXPORTED_SYMBOLS = ["ShortcutsManager"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const ShortcutsManager = {
+ /**
+ * Fluent strings mapping to allow updating strings without changing all the
+ * IDs in the shortcuts.ftl file. This is needed because the IDs are
+ * dynamically generated.
+ *
+ * @type {object}
+ */
+ fluentMapping: {
+ "meta-shift-alt-shortcut-key": "meta-shift-alt-shortcut-key2",
+ "ctrl-shift-alt-shortcut-key": "ctrl-shift-alt-shortcut-key2",
+ "meta-ctrl-shift-alt-shortcut-key": "meta-ctrl-shift-alt-shortcut-key2",
+ },
+
+ /**
+ * Data set for a shortcut.
+ *
+ * @typedef {object} Shortcut
+ * @property {string} id - The id for this shortcut.
+ * @property {string} name - The name of this shortcut. TODO: This should use
+ * fluent to be translatable in the future, once we decide to expose this
+ * array and make it customizable.
+ * @property {?string} key - The keyboard key used by this shortcut, or null
+ * if the shortcut is disabled.
+ * @property {object} modifiers - The list of modifiers expected by this
+ * shortcut in order to be triggered, organized per platform.
+ * @property {string[]} context - An array of strings representing the context
+ * string to filter out duplicated shortcuts, if necessary.
+ */
+ /**
+ * @type {Shortcut[]}
+ */
+ shortcuts: [
+ /* Numbers. */
+ {
+ id: "space-mail",
+ name: "Open the Mail space",
+ key: "1",
+ modifiers: {
+ win: {
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: true,
+ },
+ macosx: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ linux: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ },
+ context: [],
+ },
+ {
+ id: "space-addressbook",
+ name: "Open the Address Book space",
+ key: "2",
+ modifiers: {
+ win: {
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: true,
+ },
+ macosx: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ linux: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ },
+ context: [],
+ },
+ {
+ id: "space-calendar",
+ name: "Open the Calendar space",
+ key: "3",
+ modifiers: {
+ win: {
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: true,
+ },
+ macosx: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ linux: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ },
+ context: [],
+ },
+ {
+ id: "space-tasks",
+ name: "Open the Tasks space",
+ key: "4",
+ modifiers: {
+ win: {
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: true,
+ },
+ macosx: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ linux: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ },
+ context: [],
+ },
+ {
+ id: "space-chat",
+ name: "Open the Chat space",
+ key: "5",
+ modifiers: {
+ win: {
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: true,
+ },
+ macosx: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ linux: {
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ },
+ },
+ context: [],
+ },
+ {
+ id: "space-toggle",
+ name: "Toggle the Spaces Toolbar",
+ key: null, // Disabled shortcut.
+ code: null,
+ modifiers: {
+ win: {
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ },
+ macosx: {
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ },
+ linux: {
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ },
+ },
+ context: [],
+ },
+ /* Characters. */
+ /* Special characters. */
+ ],
+
+ /**
+ * Find the matching shortcut from a keydown DOM Event.
+ *
+ * @param {Event} event - The keydown DOM Event.
+ * @param {?string} context - The context string to filter out duplicated
+ * shortcuts, if necessary.
+ * @returns {?Shortcut} - The matching shortcut, or null if nothing matches.
+ */
+ matches(event, context = null) {
+ let found = [];
+ for (let shortcut of this.shortcuts) {
+ // No need to run any other condition if the base key doesn't match.
+ if (shortcut.key != event.key) {
+ continue;
+ }
+
+ // Skip this key if we require a context not present, or we don't require
+ // a context and key has some.
+ if (
+ (context && !shortcut.context.includes(context)) ||
+ (!context && shortcut.context.length)
+ ) {
+ continue;
+ }
+
+ found.push(shortcut);
+ }
+
+ if (found.length > 1) {
+ // Trigger an error since we don't want to allow multiple shortcuts to
+ // run at the same time. If this happens, we got a problem!
+ throw new Error(
+ `Multiple shortcuts (${found
+ .map(f => f.id)
+ .join(",")}) are conflicting with the keydown event:\n
+ - KEY: ${event.key}\n
+ - CTRL: ${event.ctrlKey}\n
+ - META: ${event.metaKey}\n
+ - SHIFT: ${event.shiftKey}\n
+ - ALT: ${event.altKey}\n
+ - CONTEXT: ${context}\n`
+ );
+ }
+
+ if (!found.length) {
+ return null;
+ }
+
+ let shortcut = found[0];
+ let mods = shortcut.modifiers[AppConstants.platform];
+ // Return the shortcut if it doesn't require any modifier and no modifier
+ // is present in the key press event.
+ if (
+ !Object.keys(mods).length &&
+ !(event.ctrlKey || event.metaKey) &&
+ !event.shiftKey &&
+ !event.altKey
+ ) {
+ return shortcut;
+ }
+
+ // Perfectly match all modifiers to prevent false positives.
+ return mods.metaKey == event.metaKey &&
+ mods.ctrlKey == event.ctrlKey &&
+ mods.shiftKey == event.shiftKey &&
+ mods.altKey == event.altKey
+ ? shortcut
+ : null;
+ },
+
+ /**
+ * Generate a string that will be used to create the fluent ID to visually
+ * represent the keyboard shortcut.
+ *
+ * @param {string} id - The ID of the requested shortcut.
+ * @returns {?object} - An object containing the generate shortcut and aria
+ * string, if available.
+ * @property {string} localizedShortcut - The shortcut in a human-readable,
+ * localized and platform-specific form.
+ * @property {string} ariaKeyShortcuts - The shortcut in a form appropriate
+ * for the aria-keyshortcuts attribute.
+ */
+ async getShortcutStrings(id) {
+ let shortcut = this.shortcuts.find(s => s.id == id);
+ if (!shortcut?.key) {
+ return null;
+ }
+
+ let platform = AppConstants.platform;
+ let string = [];
+ let aria = [];
+ if (shortcut.modifiers[platform].metaKey) {
+ string.push("meta");
+ aria.push("Meta");
+ }
+
+ if (shortcut.modifiers[platform].ctrlKey) {
+ string.push("ctrl");
+ aria.push("Control");
+ }
+
+ if (shortcut.modifiers[platform].shiftKey) {
+ string.push("shift");
+ aria.push("Shift");
+ }
+
+ if (shortcut.modifiers[platform].altKey) {
+ string.push("alt");
+ aria.push("Alt");
+ }
+ string.push("shortcut-key");
+ aria.push(shortcut.key.toUpperCase());
+
+ // Check if the ID was updated in the fluent file and replace it.
+ let stringId = string.join("-");
+ stringId = this.fluentMapping[stringId] || stringId;
+
+ let value = await this.l10n.formatValue(stringId, {
+ key: shortcut.key.toUpperCase(),
+ });
+
+ return { localizedShortcut: value, ariaKeyShortcuts: aria.join("+") };
+ },
+};
+
+XPCOMUtils.defineLazyGetter(
+ ShortcutsManager,
+ "l10n",
+ () => new Localization(["messenger/shortcuts.ftl"])
+);
diff --git a/comm/mail/modules/SummaryFrameManager.jsm b/comm/mail/modules/SummaryFrameManager.jsm
new file mode 100644
index 0000000000..dc8261eb3a
--- /dev/null
+++ b/comm/mail/modules/SummaryFrameManager.jsm
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["SummaryFrameManager"];
+
+/**
+ * The SummaryFrameManager manages the source attribute of iframes which can
+ * be multi-purposed. For example, the thread/multimessage summary and the
+ * folder summary both use it. The SummaryFrameManager takes care of
+ * causing the content file to be reloaded as necessary, and manages event
+ * handlers, so that the right callback is called when the specified
+ * document is loaded.
+ *
+ * @param aFrame the iframe that we're managing
+ */
+function SummaryFrameManager(aFrame) {
+ this.iframe = aFrame;
+ this.iframe.addEventListener(
+ "DOMContentLoaded",
+ this._onLoad.bind(this),
+ true
+ );
+ this.pendingCallback = null;
+ this.pendingOrLoadedUrl = this.iframe.docShell
+ ? this.iframe.contentDocument.location.href
+ : "about:blank";
+ this.callback = null;
+ this.url = "";
+}
+
+SummaryFrameManager.prototype = {
+ /**
+ * Clear the summary frame.
+ */
+ clear() {
+ this.loadAndCallback("about:blank");
+ },
+
+ /**
+ * Load the specified URL if necessary, and cause the specified callback to be
+ * called either when the document is loaded, or immediately if the document
+ * is already loaded.
+ *
+ * @param aUrl the URL to load
+ * @param aCallback the callback to run when the URL has loaded; this function
+ * is passed a single boolean indicating if the URL was changed
+ */
+ loadAndCallback(aUrl, aCallback) {
+ this.url = aUrl;
+ if (this.pendingOrLoadedUrl != aUrl) {
+ // We're changing the document. Stash the callback that we want to call
+ // when it's done loading
+ this.pendingCallback = aCallback;
+ this.callback = null; // clear it
+ this.iframe.contentDocument.location.href = aUrl;
+ this.pendingOrLoadedUrl = aUrl;
+ } else if (!this.pendingCallback) {
+ // We're being called, but the document has been set already -- either
+ // we've already received the DOMContentLoaded event, in which case we can
+ // just call the callback directly, or we're still loading in which case
+ // we should just wait for the dom event handler, but update the callback.
+
+ this.callback = aCallback;
+ if (this.callback) {
+ this.callback(false);
+ }
+ } else {
+ this.pendingCallback = aCallback;
+ }
+ },
+
+ _onLoad(event) {
+ try {
+ // Make sure we're responding to the summary frame being loaded, and not
+ // some subnode.
+ if (
+ event.target != this.iframe.contentDocument ||
+ this.pendingOrLoadedUrl == "about:blank"
+ ) {
+ return;
+ }
+ if (event.target.ownerGlobal.location.href == "about:blank") {
+ return;
+ }
+
+ this.callback = this.pendingCallback;
+ this.pendingCallback = null;
+ if (
+ this.pendingOrLoadedUrl != this.iframe.contentDocument.location.href
+ ) {
+ console.error(
+ "Please do not load stuff in the multimessage browser directly, " +
+ "use the SummaryFrameManager instead."
+ );
+ } else if (this.callback) {
+ this.callback(true);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+};
diff --git a/comm/mail/modules/TBDistCustomizer.jsm b/comm/mail/modules/TBDistCustomizer.jsm
new file mode 100644
index 0000000000..0de0c3ceb3
--- /dev/null
+++ b/comm/mail/modules/TBDistCustomizer.jsm
@@ -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/. */
+
+const EXPORTED_SYMBOLS = ["TBDistCustomizer"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var TBDistCustomizer = {
+ applyPrefDefaults() {
+ this._prefDefaultsApplied = true;
+ if (!this._ini) {
+ return;
+ }
+ // Grab the sections of the ini file
+ let sections = enumToObject(this._ini.getSections());
+
+ // The global section, and several of its fields, is required
+ // Function exits if this section and its fields are not present
+ if (!sections.Global) {
+ return;
+ }
+
+ // Get the keys in the "Global" section of the ini file
+ let globalPrefs = enumToObject(this._ini.getKeys("Global"));
+ if (!(globalPrefs.id && globalPrefs.version && globalPrefs.about)) {
+ return;
+ }
+
+ // Get the entire preferences tree (defaults is an instance of nsIPrefBranch)
+ let defaults = Services.prefs.getDefaultBranch(null);
+
+ // Set the following user prefs
+ defaults.setCharPref(
+ "distribution.id",
+ this._ini.getString("Global", "id")
+ );
+ defaults.setCharPref(
+ "distribution.version",
+ this._ini.getString("Global", "version")
+ );
+ let partnerAbout;
+ if (globalPrefs["about." + this._locale]) {
+ partnerAbout = this._ini.getString("Global", "about." + this._locale);
+ } else {
+ partnerAbout = this._ini.getString("Global", "about");
+ }
+ defaults.setStringPref("distribution.about", partnerAbout);
+
+ if (sections.Preferences) {
+ let keys = this._ini.getKeys("Preferences");
+ for (let key of keys) {
+ try {
+ // Get the string value of the key
+ let value = this.parseValue(this._ini.getString("Preferences", key));
+ // After determining what type it is, set the pref
+ switch (typeof value) {
+ case "boolean":
+ defaults.setBoolPref(key, value);
+ break;
+ case "number":
+ defaults.setIntPref(key, value);
+ break;
+ case "string":
+ defaults.setCharPref(key, value);
+ break;
+ case "undefined":
+ // In case of custom pref created by partner
+ defaults.setCharPref(key, value);
+ break;
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ // Set the prefs in the other sections
+ let localizedStr = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(
+ Ci.nsIPrefLocalizedString
+ );
+
+ if (sections.LocalizablePreferences) {
+ let keys = this._ini.getKeys("LocalizablePreferences");
+ for (let key of keys) {
+ try {
+ let value = this.parseValue(
+ this._ini.getString("LocalizablePreferences", key)
+ );
+ value = value.replace(/%LOCALE%/g, this._locale);
+ localizedStr.data = "data:text/plain," + key + "=" + value;
+ defaults.setComplexValue(
+ key,
+ Ci.nsIPrefLocalizedString,
+ localizedStr
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ if (sections["LocalizablePreferences-" + this._locale]) {
+ let keys = this._ini.getKeys("LocalizablePreferences-" + this._locale);
+ for (let key of keys) {
+ try {
+ let value = this.parseValue(
+ this._ini.getString("LocalizablePreferences-" + this._locale, key)
+ );
+ localizedStr.data = "data:text/plain," + key + "=" + value;
+ defaults.setComplexValue(
+ key,
+ Ci.nsIPrefLocalizedString,
+ localizedStr
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ },
+
+ parseValue(value) {
+ try {
+ value = JSON.parse(value);
+ } catch (e) {
+ // JSON.parse catches numbers and booleans.
+ // Anything else, we assume is a string.
+ // Remove the quotes that aren't needed anymore.
+ value = value.replace(/^"/, "");
+ value = value.replace(/"$/, "");
+ }
+ return value;
+ },
+};
+
+XPCOMUtils.defineLazyGetter(TBDistCustomizer, "_ini", function () {
+ let ini = null;
+ let iniFile = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+ iniFile.append("distribution");
+ iniFile.append("distribution.ini");
+ if (iniFile.exists()) {
+ ini = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
+ .getService(Ci.nsIINIParserFactory)
+ .createINIParser(iniFile);
+ }
+ return ini;
+});
+
+XPCOMUtils.defineLazyGetter(TBDistCustomizer, "_locale", function () {
+ return Services.locale.requestedLocale;
+});
+
+function enumToObject(UTF8Enumerator) {
+ let ret = {};
+ for (let UTF8Obj of UTF8Enumerator) {
+ ret[UTF8Obj] = 1;
+ }
+ return ret;
+}
diff --git a/comm/mail/modules/TabStateFlusher.jsm b/comm/mail/modules/TabStateFlusher.jsm
new file mode 100644
index 0000000000..79771f133d
--- /dev/null
+++ b/comm/mail/modules/TabStateFlusher.jsm
@@ -0,0 +1,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/. */
+
+// This module is deliberately not implemented. It only exists to keep
+// the automated tests (those in services/sync) happy.
+
+var EXPORTED_SYMBOLS = [];
diff --git a/comm/mail/modules/TagUtils.jsm b/comm/mail/modules/TagUtils.jsm
new file mode 100644
index 0000000000..05747e7b8c
--- /dev/null
+++ b/comm/mail/modules/TagUtils.jsm
@@ -0,0 +1,152 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Color: "resource://gre/modules/Color.sys.mjs",
+});
+
+var EXPORTED_SYMBOLS = ["TagUtils"];
+
+var TagUtils = {
+ loadTagsIntoCSS,
+ addTagToAllDocumentSheets,
+ isColorContrastEnough,
+};
+
+function loadTagsIntoCSS(aDocument) {
+ let tagSheet = findTagColorSheet(aDocument);
+ let tagArray = MailServices.tags.getAllTags();
+ for (let tag of tagArray) {
+ // tag.key is the internal key, like "$label1" for "Important".
+ // For user defined keys with non-ASCII characters, key is
+ // the MUTF-7 encoded name.
+ addTagToSheet(tag.key, tag.color, tagSheet);
+ }
+}
+
+function addTagToAllDocumentSheets(aKey, aColor) {
+ for (let nextWin of Services.wm.getEnumerator("mail:3pane", true)) {
+ addTagToSheet(aKey, aColor, findTagColorSheet(nextWin.document));
+ }
+
+ for (let nextWin of Services.wm.getEnumerator("mailnews:search", true)) {
+ addTagToSheet(aKey, aColor, findTagColorSheet(nextWin.document));
+ }
+}
+
+function addTagToSheet(aKey, aColor, aSheet) {
+ if (!aSheet) {
+ return;
+ }
+
+ // Add rules to sheet.
+ let ruleString1;
+ let ruleString2;
+ let ruleString3;
+ let ruleString4;
+ let selector = MailServices.tags.getSelectorForKey(aKey);
+ if (!aColor) {
+ ruleString1 =
+ ":root[lwt-tree] treechildren::-moz-tree-row(" +
+ selector +
+ ", selected, focus) { background-color: " +
+ "var(--sidebar-highlight-background-color) !important; }";
+ ruleString2 =
+ "treechildren::-moz-tree-cell-text(" +
+ selector +
+ ", selected, focus) { color: SelectedItemText !important; }";
+ ruleString3 =
+ "tree:-moz-lwtheme treechildren::-moz-tree-cell-text(" +
+ selector +
+ ", selected) { color: currentColor !important; }";
+ ruleString4 =
+ ":root[lwt-tree] treechildren::-moz-tree-cell-text(" +
+ selector +
+ ", selected, focus) { color: var(--sidebar-highlight-text-color, " +
+ "var(--sidebar-text-color)) !important; }";
+ } else {
+ ruleString1 =
+ "treechildren::-moz-tree-row(" +
+ selector +
+ ", selected, focus) { background-color: " +
+ aColor +
+ " !important; outline-color: color-mix(in srgb, " +
+ aColor +
+ ", black 25%); }";
+ ruleString2 =
+ "treechildren::-moz-tree-cell-text(" +
+ selector +
+ ") { color: " +
+ aColor +
+ "; }";
+ let textColor = "black";
+ if (!isColorContrastEnough(aColor)) {
+ textColor = "white";
+ }
+ ruleString3 =
+ "treechildren::-moz-tree-cell-text(" +
+ selector +
+ ", selected, focus) { color: " +
+ textColor +
+ " }";
+ ruleString4 =
+ "treechildren::-moz-tree-image(" +
+ selector +
+ ", selected, focus)," +
+ "treechildren::-moz-tree-twisty(" +
+ selector +
+ ", selected, focus) { --select-focus-text-color: " +
+ textColor +
+ "; }";
+ }
+ try {
+ aSheet.insertRule(ruleString1, aSheet.cssRules.length);
+ aSheet.insertRule(ruleString2, aSheet.cssRules.length);
+ aSheet.insertRule(ruleString3, aSheet.cssRules.length);
+ aSheet.insertRule(ruleString4, aSheet.cssRules.length);
+ } catch (ex) {
+ aSheet.ownerNode.addEventListener(
+ "load",
+ () => addTagToSheet(aKey, aColor, aSheet),
+ { once: true }
+ );
+ }
+}
+
+function findTagColorSheet(aDocument) {
+ const cssUri = "chrome://messenger/skin/tagColors.css";
+ let tagSheet = null;
+ for (let sheet of aDocument.styleSheets) {
+ if (sheet.href == cssUri) {
+ tagSheet = sheet;
+ break;
+ }
+ }
+ if (!tagSheet) {
+ console.error("TagUtils.findTagColorSheet: tagColors.css not found");
+ }
+ return tagSheet;
+}
+
+/* Checks if black writing on 'aColor' background has enough contrast */
+function isColorContrastEnough(aColor) {
+ // Is a color set? If not, return "true" to use the default color.
+ if (!aColor) {
+ return true;
+ }
+ // Zero-pad the number just to make sure that it is 8 digits.
+ let colorHex = ("00000000" + aColor).substr(-8);
+ let colorArray = colorHex.match(/../g);
+ let [, cR, cG, cB] = colorArray.map(val => parseInt(val, 16));
+ return new lazy.Color(cR, cG, cB).isContrastRatioAcceptable(
+ new lazy.Color(0, 0, 0),
+ "AAA"
+ );
+}
diff --git a/comm/mail/modules/UIDensity.jsm b/comm/mail/modules/UIDensity.jsm
new file mode 100644
index 0000000000..be94ed1408
--- /dev/null
+++ b/comm/mail/modules/UIDensity.jsm
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["UIDensity"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var registeredWindows = new Set();
+
+function updateWindow(win) {
+ switch (UIDensity.prefValue) {
+ case UIDensity.MODE_COMPACT:
+ win.document.documentElement.setAttribute("uidensity", "compact");
+ break;
+ case UIDensity.MODE_TOUCH:
+ win.document.documentElement.setAttribute("uidensity", "touch");
+ break;
+ default:
+ win.document.documentElement.removeAttribute("uidensity");
+ break;
+ }
+
+ if (win.TabsInTitlebar !== undefined) {
+ win.TabsInTitlebar.update();
+ }
+
+ win.dispatchEvent(
+ new win.CustomEvent("uidensitychange", { detail: UIDensity.prefValue })
+ );
+}
+
+function updateAllWindows() {
+ for (let win of registeredWindows) {
+ updateWindow(win);
+ }
+}
+
+var UIDensity = {
+ MODE_COMPACT: 0,
+ MODE_NORMAL: 1,
+ MODE_TOUCH: 2,
+
+ prefName: "mail.uidensity",
+
+ /**
+ * Set the UI density.
+ *
+ * @param {integer} mode - One of the MODE constants.
+ */
+ setMode(mode) {
+ Services.prefs.setIntPref(this.prefName, mode);
+ },
+
+ /**
+ * Register a window to be updated if the mode ever changes. The current
+ * value is applied to the window. Deregistration is automatic.
+ *
+ * @param {Window} win
+ */
+ registerWindow(win) {
+ registeredWindows.add(win);
+ win.addEventListener("unload", () => registeredWindows.delete(win));
+ updateWindow(win);
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ UIDensity,
+ "prefValue",
+ UIDensity.prefName,
+ null,
+ updateAllWindows
+);
diff --git a/comm/mail/modules/UIFontSize.jsm b/comm/mail/modules/UIFontSize.jsm
new file mode 100644
index 0000000000..dd5a6db101
--- /dev/null
+++ b/comm/mail/modules/UIFontSize.jsm
@@ -0,0 +1,346 @@
+/* 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 = ["UIFontSize"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var langGroup = Services.prefs.getComplexValue(
+ "font.language.group",
+ Ci.nsIPrefLocalizedString
+).data;
+
+var registeredWindows = new Set();
+
+/**
+ * Update the font size of the registered window.
+ *
+ * @param {Window} win - The window to be registered.
+ */
+function updateWindow(win) {
+ let tabmail = win.document.getElementById("tabmail");
+ let browser =
+ tabmail?.getBrowserForSelectedTab() ||
+ win.document.getElementById("messagepane");
+
+ if (
+ UIFontSize.prefValue == UIFontSize.DEFAULT ||
+ UIFontSize.prefValue == UIFontSize.user_value
+ ) {
+ win.document.documentElement.style.removeProperty("font-size");
+ UIFontSize.updateMessageBrowser(browser);
+ UIFontSize.updateAppMenuButton(win);
+ return;
+ }
+
+ // Prevent any font update if the defined value can make the UI unusable.
+ if (
+ UIFontSize.prefValue < UIFontSize.MIN_VALUE ||
+ UIFontSize.prefValue > UIFontSize.MAX_VALUE
+ ) {
+ // Reset to the default font size.
+ UIFontSize.size = 0;
+ Services.console.logStringMessage(
+ `Unsupported font size: ${UIFontSize.prefValue}`
+ );
+ return;
+ }
+
+ // Add the font size to the HTML document element.
+ win.document.documentElement.style.setProperty(
+ "font-size",
+ `${UIFontSize.prefValue}px`
+ );
+
+ UIFontSize.updateMessageBrowser(browser);
+ UIFontSize.updateAppMenuButton(win);
+}
+
+/**
+ * Loop through all registered windows and update the font size.
+ */
+function updateAllWindows() {
+ for (let win of registeredWindows) {
+ updateWindow(win);
+ }
+}
+
+/**
+ * The object controlling the global font size.
+ */
+var UIFontSize = {
+ // Default value is 0 so we know the font wasn't changed.
+ DEFAULT: 0,
+ // Font size limit to avoid unusable UI.
+ MIN_VALUE: 9,
+ MAX_VALUE: 30,
+ // The default font size of the user's OS, rounded to integer. We use this in
+ // order to prevent issues in case the user has a float default font size
+ // (e.g.: 14.345px). By rounding to an INT, we can always go back the original
+ // default font size and the rounding doesn't affect the actual sizing but
+ // only the value shown to the user.
+ user_value: 0,
+
+ // Keeps track of the custom value while in safe mode.
+ safe_mode_value: 0,
+
+ // Keep track of the state of the custom font size. We use this instead of the
+ // size attribute because we need to keep track of when things changed back to
+ // a default state, and using the size attribute wouldn't be accurate.
+ isEdited: false,
+
+ /**
+ * Set the font size.
+ *
+ * @param {integer} size - The new size value.
+ */
+ set size(size) {
+ this.isEdited = true;
+ Services.prefs.setIntPref("mail.uifontsize", size);
+ },
+
+ /**
+ * Get the font size.
+ *
+ * @returns {integer} - The current font size defined in the pref or the value
+ * defined by the OS, extracted from the messenger window computed style.
+ */
+ get size() {
+ // If the pref is set to 0, it means the user never changed font size so we
+ // return the default OS font size.
+ return this.prefValue || this.user_value;
+ },
+
+ /**
+ * Get the font size to be applied to the message browser.
+ *
+ * @param {boolean} isPlainText - If the current message is in plain text.
+ * @returns {int} - The font size to apply to the message, changed relative to
+ * the default preferences.
+ */
+ browserSize(isPlainText) {
+ if (isPlainText) {
+ let monospaceSize = Services.prefs.getIntPref(
+ "font.size.monospace." + langGroup,
+ this.size
+ );
+ return monospaceSize + (this.size - this.user_value);
+ }
+ let variableSize = Services.prefs.getIntPref(
+ "font.size.variable." + langGroup,
+ this.size
+ );
+ return variableSize + (this.size - this.user_value);
+ },
+
+ /**
+ * Register a window to be updated if the size ever changes. The current
+ * value is applied to the window. Deregistration is automatic.
+ *
+ * @param {Window} win - The window to be registered.
+ */
+ registerWindow(win) {
+ // Save the edited pref so we can restore it, and set the user value to the
+ // default if the app is in safe mode to make sure we start from a clean
+ // state.
+ if (Services.appinfo.inSafeMode) {
+ this.safe_mode_value = this.size;
+ this.size = 0;
+ }
+
+ // Fetch the default font size defined by the OS as soon as we register the
+ // first window. Don't do it again if we already have a value.
+ if (!this.user_value) {
+ let style = win
+ .getComputedStyle(win.document.documentElement)
+ .getPropertyValue("font-size");
+
+ // Store the rounded default value.
+ this.user_value = Math.round(parseFloat(style));
+ }
+
+ registeredWindows.add(win);
+ win.addEventListener("unload", () => {
+ registeredWindows.delete(win);
+ // If we deregistered all the windows (application is getting closed) and
+ // we're in safe mode, reset the font size value to the original one in
+ // case the user edited the font size while in safe mode.
+ if (!registeredWindows.size && Services.appinfo.inSafeMode) {
+ Services.prefs.setIntPref("mail.uifontsize", this.safe_mode_value);
+ }
+ });
+ updateWindow(win);
+ },
+
+ /**
+ * Update the label of the PanelUI app menu to reflect the current font size.
+ *
+ * @param {Window} win - The window from where the app menu is visible.
+ */
+ updateAppMenuButton(win) {
+ let panelButton = win.document.getElementById(
+ "appMenu-fontSizeReset-button"
+ );
+ if (panelButton) {
+ win.document.l10n.setAttributes(
+ panelButton,
+ "appmenuitem-font-size-reset",
+ {
+ size: this.size,
+ }
+ );
+ }
+
+ win.document
+ .getElementById("appMenu-fontSizeReduce-button")
+ ?.toggleAttribute("disabled", this.size <= this.MIN_VALUE);
+ win.document
+ .getElementById("appMenu-fontSizeEnlarge-button")
+ ?.toggleAttribute("disabled", this.size >= this.MAX_VALUE);
+ },
+
+ reduceSize() {
+ if (this.size <= this.MIN_VALUE) {
+ return;
+ }
+ this.size--;
+ },
+
+ resetSize() {
+ this.size = 0;
+ },
+
+ increaseSize() {
+ if (this.size >= this.MAX_VALUE) {
+ return;
+ }
+ this.size++;
+ },
+
+ /**
+ * Update the font size of the document body element of a browser content.
+ * This is used primarily for each loaded message in the message pane.
+ *
+ * @param {XULElement} browser - The message browser element.
+ */
+ updateMessageBrowser(browser) {
+ // Bail out if the font size wasn't changed, or we don't have a browser.
+ // This might happen if the method is called before the message browser is
+ // available in the DOM.
+ if (!this.isEdited || !browser) {
+ return;
+ }
+
+ if (this.prefValue == this.DEFAULT || this.prefValue == this.user_value) {
+ browser.contentDocument?.body?.style.removeProperty("font-size");
+ // Update the state indicator here only after we cleared the font size
+ // from the message browser.
+ this.isEdited = false;
+ return;
+ }
+
+ // Check if the current message is in plain text.
+ let isPlainText = browser.contentDocument?.querySelector(
+ ".moz-text-plain, .moz-text-flowed"
+ );
+
+ browser.contentDocument?.body?.style.setProperty(
+ "font-size",
+ `${UIFontSize.browserSize(isPlainText)}px`
+ );
+
+ // We need to remove the inline font size written in the div wrapper of the
+ // body content in order to let our inline style take effect.
+ if (isPlainText) {
+ isPlainText.style.removeProperty("font-size");
+ }
+ },
+
+ observe(win, topic, data) {
+ // Observe any new window or dialog that is opened and register it to
+ // inherit the font sizing variation.
+ switch (topic) {
+ // FIXME! Temporarily disabled until we can properly manage all dialogs.
+ // case "domwindowopened":
+ // win.addEventListener(
+ // "load",
+ // () => {
+ // this.registerWindow(win);
+ // },
+ // { once: true }
+ // );
+ // break;
+
+ default:
+ break;
+ }
+ },
+
+ /**
+ * Ensure the subdialogs are properly resized to fit larger font size
+ * variations.
+ * This is copied from SubDialog.jsm:resizeDialog(), and we need to do that
+ * because that method triggers again the `resizeCallback` and `dialogopen`
+ * Event, which we use to detect the opening of a dialog, therefore calling
+ * the `resizeDialog()` method would cause an infinite loop.
+ *
+ * @param {SubDialog} dialog - The dialog prototype.
+ */
+ resizeSubDialog(dialog) {
+ // No need to update the dialog size if the font size wasn't changed.
+ if (this.prefValue == this.DEFAULT) {
+ return;
+ }
+ let docEl = dialog._frame.contentDocument.documentElement;
+
+ // These are deduced from styles which we don't change, so it's safe to get
+ // them now:
+ let boxHorizontalBorder =
+ 2 *
+ parseFloat(dialog._window.getComputedStyle(dialog._box).borderLeftWidth);
+ let frameHorizontalMargin =
+ 2 * parseFloat(dialog._window.getComputedStyle(dialog._frame).marginLeft);
+
+ // Then determine and set a bunch of width stuff:
+ let { scrollWidth } = docEl.ownerDocument.body || docEl;
+ let frameMinWidth = docEl.style.width || scrollWidth + "px";
+ let frameWidth = docEl.getAttribute("width")
+ ? docEl.getAttribute("width") + "px"
+ : frameMinWidth;
+
+ if (dialog._box.getAttribute("sizeto") != "available") {
+ dialog._frame.style.width = frameWidth;
+ }
+
+ let boxMinWidth = `calc(${
+ boxHorizontalBorder + frameHorizontalMargin
+ }px + ${frameMinWidth})`;
+
+ // Temporary fix to allow parent chrome to collapse properly to min width.
+ // See Bug 1658722.
+ if (dialog._window.isChromeWindow) {
+ boxMinWidth = `min(80vw, ${boxMinWidth})`;
+ }
+ dialog._box.style.minWidth = boxMinWidth;
+
+ dialog.resizeVertically();
+ },
+};
+
+/**
+ * Bind the font size pref change to the updateAllWindows method.
+ */
+XPCOMUtils.defineLazyPreferenceGetter(
+ UIFontSize,
+ "prefValue",
+ "mail.uifontsize",
+ null,
+ updateAllWindows
+);
+
+Services.ww.registerNotification(UIFontSize);
diff --git a/comm/mail/modules/WindowsJumpLists.jsm b/comm/mail/modules/WindowsJumpLists.jsm
new file mode 100644
index 0000000000..8bce51a3a3
--- /dev/null
+++ b/comm/mail/modules/WindowsJumpLists.jsm
@@ -0,0 +1,262 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 EXPORTED_SYMBOLS = ["WinTaskbarJumpList"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+// Prefs
+var PREF_TASKBAR_BRANCH = "mail.taskbar.lists.";
+var PREF_TASKBAR_ENABLED = "enabled";
+var PREF_TASKBAR_TASKS = "tasks.enabled";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_stringBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/taskbar.properties"
+ );
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "_taskbarService",
+ "@mozilla.org/windows-taskbar;1",
+ "nsIWinTaskbar"
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "_prefs", function () {
+ return Services.prefs.getBranch(PREF_TASKBAR_BRANCH);
+});
+
+function _getString(aName) {
+ return lazy._stringBundle.GetStringFromName(aName);
+}
+
+/**
+ * Task list
+ */
+var gTasks = [
+ // Write new message
+ {
+ get title() {
+ return _getString("taskbar.tasks.composeMessage.label");
+ },
+ get description() {
+ return _getString("taskbar.tasks.composeMessage.description");
+ },
+ args: "-compose",
+ iconIndex: 2, // Write message icon
+ },
+
+ // Open address book
+ {
+ get title() {
+ return _getString("taskbar.tasks.openAddressBook.label");
+ },
+ get description() {
+ return _getString("taskbar.tasks.openAddressBook.description");
+ },
+ args: "-addressbook",
+ iconIndex: 3, // Open address book icon
+ },
+];
+
+var WinTaskbarJumpList = {
+ /**
+ * Startup, shutdown, and update
+ */
+
+ startup() {
+ // exit if this isn't win7 or higher.
+ if (!this._initTaskbar()) {
+ return;
+ }
+
+ // Store our task list config data
+ this._tasks = gTasks;
+
+ // retrieve taskbar related prefs.
+ this._refreshPrefs();
+
+ // observer for our prefs branch
+ this._initObs();
+
+ this.update();
+ },
+
+ update() {
+ // are we disabled via prefs? don't do anything!
+ if (!this._enabled) {
+ return;
+ }
+
+ // do what we came here to do, update the taskbar jumplist
+ this._buildList();
+ },
+
+ _shutdown() {
+ this._shuttingDown = true;
+
+ this._free();
+ },
+
+ /**
+ * List building
+ */
+
+ _buildList() {
+ // anything to build?
+ if (!this._showTasks) {
+ // don't leave the last list hanging on the taskbar.
+ this._deleteActiveJumpList();
+ return;
+ }
+
+ if (!this._startBuild()) {
+ return;
+ }
+
+ if (this._showTasks) {
+ this._buildTasks();
+ }
+
+ this._commitBuild();
+ },
+
+ /**
+ * Taskbar api wrappers
+ */
+
+ _startBuild() {
+ // This is useful if there are any async tasks pending. Since we don't right
+ // now, it's just harmless.
+ this._builder.abortListBuild();
+ // Since our list is static right now, we won't actually get back any
+ // removed items.
+ let removedItems = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ return this._builder.initListBuild(removedItems);
+ },
+
+ _commitBuild() {
+ this._builder.commitListBuild(succeed => {
+ if (!succeed) {
+ this._builder.abortListBuild();
+ }
+ });
+ },
+
+ _buildTasks() {
+ if (this._tasks.length > 0) {
+ var items = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ for (let item of this._tasks.map(task =>
+ this._createHandlerAppItem(task)
+ )) {
+ items.appendElement(item);
+ }
+ this._builder.addListToBuild(
+ this._builder.JUMPLIST_CATEGORY_TASKS,
+ items
+ );
+ }
+ },
+
+ _deleteActiveJumpList() {
+ this._builder.deleteActiveList();
+ },
+
+ /**
+ * Jump list item creation helpers
+ */
+
+ _createHandlerAppItem(aTask) {
+ let file = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+
+ // XXX where can we grab this from in the build? Do we need to?
+ file.append("thunderbird.exe");
+
+ let handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.executable = file;
+ // handlers default to the leaf name if a name is not specified
+ let title = aTask.title;
+ if (title && title.length != 0) {
+ handlerApp.name = title;
+ }
+ handlerApp.detailedDescription = aTask.description;
+ handlerApp.appendParameter(aTask.args);
+
+ let item = Cc["@mozilla.org/windows-jumplistshortcut;1"].createInstance(
+ Ci.nsIJumpListShortcut
+ );
+ item.app = handlerApp;
+ item.iconIndex = aTask.iconIndex;
+ return item;
+ },
+
+ _createSeparatorItem() {
+ return Cc["@mozilla.org/windows-jumplistseparator;1"].createInstance(
+ Ci.nsIJumpListSeparator
+ );
+ },
+
+ /**
+ * Prefs utilities
+ */
+
+ _refreshPrefs() {
+ this._enabled = lazy._prefs.getBoolPref(PREF_TASKBAR_ENABLED);
+ this._showTasks = lazy._prefs.getBoolPref(PREF_TASKBAR_TASKS);
+ },
+
+ /**
+ * Init and shutdown utilities
+ */
+
+ _initTaskbar() {
+ this._builder = lazy._taskbarService.createJumpListBuilder(false);
+ if (!this._builder || !this._builder.available) {
+ return false;
+ }
+
+ return true;
+ },
+
+ _initObs() {
+ Services.obs.addObserver(this, "profile-before-change");
+ lazy._prefs.addObserver("", this);
+ },
+
+ _freeObs() {
+ Services.obs.removeObserver(this, "profile-before-change");
+ lazy._prefs.removeObserver("", this);
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "nsPref:changed":
+ if (this._enabled && !lazy._prefs.getBoolPref(PREF_TASKBAR_ENABLED)) {
+ this._deleteActiveJumpList();
+ }
+ this._refreshPrefs();
+ this.update();
+ break;
+
+ case "profile-before-change":
+ this._shutdown();
+ break;
+ }
+ },
+
+ _free() {
+ this._freeObs();
+ delete this._builder;
+ },
+};
diff --git a/comm/mail/modules/moz.build b/comm/mail/modules/moz.build
new file mode 100644
index 0000000000..b171e6ba1a
--- /dev/null
+++ b/comm/mail/modules/moz.build
@@ -0,0 +1,47 @@
+# vim: set filetype=python:
+# 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_JS_MODULES += [
+ "AttachmentChecker.jsm",
+ "AttachmentInfo.sys.mjs",
+ "BrowserWindowTracker.jsm",
+ "ConversationOpener.jsm",
+ "DBViewWrapper.jsm",
+ "DisplayNameUtils.jsm",
+ "DNS.jsm",
+ "ExtensionsUI.jsm",
+ "ExtensionSupport.jsm",
+ "FolderTreeProperties.jsm",
+ "GlobalPopupNotifications.jsm",
+ "MailConsts.jsm",
+ "MailE10SUtils.jsm",
+ "MailMigrator.jsm",
+ "MailUsageTelemetry.jsm",
+ "MailUtils.jsm",
+ "MailViewManager.jsm",
+ "MessageArchiver.jsm",
+ "MsgHdrSyntheticView.jsm",
+ "PhishingDetector.jsm",
+ "QuickFilterManager.jsm",
+ "SearchSpec.jsm",
+ "SelectionWidgetController.jsm",
+ "SessionStoreManager.jsm",
+ "ShortcutsManager.jsm",
+ "SummaryFrameManager.jsm",
+ "TagUtils.jsm",
+ "TBDistCustomizer.jsm",
+ "UIDensity.jsm",
+ "UIFontSize.jsm",
+]
+
+EXTRA_JS_MODULES.sessionstore += [
+ "SessionStore.jsm",
+ "TabStateFlusher.jsm",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ EXTRA_JS_MODULES += [
+ "WindowsJumpLists.jsm",
+ ]
diff --git a/comm/mail/moz.build b/comm/mail/moz.build
new file mode 100644
index 0000000000..e3c02d3877
--- /dev/null
+++ b/comm/mail/moz.build
@@ -0,0 +1,40 @@
+# vim: set filetype=python:
+# 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/.
+
+CONFIGURE_SUBST_FILES += ["installer/Makefile"]
+
+# app is always last as it packages up the built files on mac.
+DIRS += [
+ "actors",
+ "app",
+ "base",
+ "extensions",
+ "locales",
+ "modules",
+ "themes",
+]
+
+if CONFIG["MAKENSISU"]:
+ DIRS += ["installer/windows"]
+
+if CONFIG["MOZ_BUNDLED_FONTS"]:
+ DIRS += ["/browser/fonts"]
+
+if CONFIG["NIGHTLY_BUILD"]:
+ DIRS += ["services/sync"]
+
+DIRS += [
+ "../python",
+ "../taskcluster",
+ "../third_party",
+]
+
+TEST_DIRS += [
+ "test/browser",
+ "test/marionette",
+ "test/static",
+]
+
+FINAL_TARGET_FILES.defaults += ["app/permissions"]
diff --git a/comm/mail/moz.configure b/comm/mail/moz.configure
new file mode 100644
index 0000000000..26e1ba1d8f
--- /dev/null
+++ b/comm/mail/moz.configure
@@ -0,0 +1,77 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+set_config("MOZ_THUNDERBIRD", True)
+set_define("MOZ_THUNDERBIRD", True)
+
+imply_option("MOZ_APP_BASENAME", "Thunderbird")
+set_config("MOZ_APPUPDATE_HOST", "aus.thunderbird.net")
+
+imply_option("--enable-default-browser-agent", False)
+imply_option("MOZ_REQUIRE_SIGNING", False)
+imply_option("MOZ_SERVICES_SYNC", True)
+
+
+@depends(target_is_windows, target_has_linux_kernel)
+def bundled_fonts(is_windows, is_linux):
+ if is_windows or is_linux:
+ return True
+
+
+set_config("MOZ_BUNDLED_FONTS", bundled_fonts)
+add_old_configure_assignment("MOZ_BUNDLED_FONTS", bundled_fonts)
+
+
+@depends(build_environment, "--help")
+@imports(_from="os.path", _import="join")
+def commtopsrcdir(build_env, _):
+ topsrcdir = build_env.topsrcdir
+ return join(topsrcdir, "comm")
+
+
+add_old_configure_assignment("commtopsrcdir", commtopsrcdir)
+set_config("commtopsrcdir", commtopsrcdir)
+
+
+imply_option("MOZ_PLACES", True)
+imply_option("MOZ_SERVICES_HEALTHREPORT", True)
+imply_option("MOZ_DEDICATED_PROFILES", True)
+imply_option("MOZ_BLOCK_PROFILE_DOWNGRADE", True)
+
+with only_when(target_has_linux_kernel & compile_environment):
+ option(env="MOZ_NO_PIE_COMPAT", help="Enable non-PIE wrapper")
+
+ set_config("MOZ_NO_PIE_COMPAT", depends_if("MOZ_NO_PIE_COMPAT")(lambda _: True))
+
+
+@depends("MOZ_AUTOMATION")
+@imports(_from="os", _import="environ")
+def pkg_libotr(automation):
+ if automation:
+ fetch_dir = environ.get("MOZ_FETCHES_DIR", None)
+ if fetch_dir:
+ log.info("Including libotr from {}".format(fetch_dir))
+ return fetch_dir
+
+ log.info("TB_LIBOTR_PREBUILT is set, but MOZ_FETCHES_DIR is invalid.")
+
+
+set_config("TB_LIBOTR_PREBUILT", pkg_libotr)
+
+
+set_config(
+ "MOZ_TELEMETRY_EXTRA_HISTOGRAM_FILES",
+ ["/comm/mail/components/telemetry/Histograms.json"],
+)
+set_config("MOZ_TELEMETRY_EXTRA_SCALAR_FILES", ["/comm/mail/components/telemetry/Scalars.yaml"])
+set_config("MOZ_TELEMETRY_EXTRA_EVENT_FILES", ["/comm/mail/components/telemetry/Events.yaml"])
+
+include("../build/moz.configure/gecko_source.configure")
+
+include("../mailnews/moz.configure")
+
+imply_option("--enable-app-system-headers", True)
+include("../../toolkit/moz.configure")
diff --git a/comm/mail/services/sync/modules/engines/accounts.sys.mjs b/comm/mail/services/sync/modules/engines/accounts.sys.mjs
new file mode 100644
index 0000000000..d504da0fee
--- /dev/null
+++ b/comm/mail/services/sync/modules/engines/accounts.sys.mjs
@@ -0,0 +1,392 @@
+/* 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 { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
+import {
+ Store,
+ SyncEngine,
+ Tracker,
+} from "resource://services-sync/engines.sys.mjs";
+import { Utils } from "resource://services-sync/util.sys.mjs";
+
+const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import(
+ "resource://services-sync/constants.js"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const SYNCED_SMTP_PROPERTIES = {
+ authMethod: "authMethod",
+ port: "port",
+ description: "name",
+ socketType: "socketType",
+};
+
+const SYNCED_SERVER_PROPERTIES = {
+ authMethod: "authMethod",
+ biffMinutes: "check_time",
+ doBiff: "check_new_mail",
+ downloadOnBiff: "download_on_biff",
+ emptyTrashOnExit: "empty_trash_on_exit",
+ incomingDuplicateAction: "dup_action",
+ limitOfflineMessageSize: "limit_offline_message_size",
+ loginAtStartUp: "login_at_startup",
+ maxMessageSize: "max_size",
+ port: "port",
+ prettyName: "name",
+ socketType: "socketType",
+};
+
+/**
+ * AccountRecord represents the state of an add-on in an application.
+ *
+ * Each add-on has its own record for each application ID it is installed
+ * on.
+ *
+ * The ID of add-on records is a randomly-generated GUID. It is random instead
+ * of deterministic so the URIs of the records cannot be guessed and so
+ * compromised server credentials won't result in disclosure of the specific
+ * add-ons present in a Sync account.
+ *
+ * The record contains the following fields:
+ *
+ */
+export function AccountRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+AccountRecord.prototype = {
+ __proto__: CryptoWrapper.prototype,
+ _logName: "Record.Account",
+};
+Utils.deferGetSet(AccountRecord, "cleartext", [
+ "username",
+ "hostname",
+ "type",
+ "prefs",
+ "isDefault",
+]);
+
+export function AccountsEngine(service) {
+ SyncEngine.call(this, "Accounts", service);
+}
+
+AccountsEngine.prototype = {
+ __proto__: SyncEngine.prototype,
+ _storeObj: AccountStore,
+ _trackerObj: AccountTracker,
+ _recordObj: AccountRecord,
+ version: 1,
+ syncPriority: 3,
+
+ /*
+ * Returns a changeset for this sync. Engine implementations can override this
+ * method to bypass the tracker for certain or all changed items.
+ */
+ async getChangedIDs() {
+ return this._tracker.getChangedIDs();
+ },
+};
+
+function AccountStore(name, engine) {
+ Store.call(this, name, engine);
+}
+AccountStore.prototype = {
+ __proto__: Store.prototype,
+
+ /**
+ * Create an item in the store from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The store record to create an item from
+ */
+ async create(record) {
+ if (record.type == "smtp") {
+ let smtpServer = MailServices.smtp.createServer();
+ smtpServer.UID = record.id;
+ smtpServer.username = record.username;
+ smtpServer.hostname = record.hostname;
+ for (let key of Object.keys(SYNCED_SMTP_PROPERTIES)) {
+ if (key in record.prefs) {
+ smtpServer[key] = record.prefs[key];
+ }
+ }
+ if (record.isDefault) {
+ MailServices.smtp.defaultServer = smtpServer;
+ }
+ return;
+ }
+
+ try {
+ // Ensure there is a local mail account...
+ MailServices.accounts.localFoldersServer;
+ } catch {
+ // ... if not, make one.
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ let server = MailServices.accounts.createIncomingServer(
+ record.username,
+ record.hostname,
+ record.type
+ );
+ server.UID = record.id;
+
+ for (let key of Object.keys(SYNCED_SERVER_PROPERTIES)) {
+ if (key in record.prefs) {
+ server[key] = record.prefs[key];
+ }
+ }
+
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = server;
+
+ if (server.loginAtStartUp) {
+ Services.wm
+ .getMostRecentWindow("mail:3pane")
+ ?.GetNewMsgs(server, server.rootFolder);
+ }
+ },
+
+ /**
+ * Remove an item in the store from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The store record to delete an item from
+ */
+ async remove(record) {
+ let smtpServer = MailServices.smtp.servers.find(s => s.UID == record.id);
+ if (smtpServer) {
+ MailServices.smtp.deleteServer(smtpServer);
+ return;
+ }
+
+ let server = MailServices.accounts.allServers.find(s => s.UID == record.id);
+ if (!server) {
+ this._log.trace("Asked to remove record that doesn't exist, ignoring");
+ return;
+ }
+
+ let account = MailServices.accounts.FindAccountForServer(server);
+ if (account) {
+ MailServices.accounts.removeAccount(account, true);
+ } else {
+ // Is this even possible?
+ MailServices.accounts.removeIncomingServer(account, true);
+ }
+ },
+
+ /**
+ * Update an item from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The record to use to update an item from
+ */
+ async update(record) {
+ if (record.type == "smtp") {
+ await this._updateSMTP(record);
+ return;
+ }
+
+ await this._updateIncoming(record);
+ },
+
+ async _updateSMTP(record) {
+ let smtpServer = MailServices.smtp.servers.find(s => s.UID == record.id);
+ if (!smtpServer) {
+ this._log.trace("Skipping update for unknown item: " + record.id);
+ return;
+ }
+ smtpServer.username = record.username;
+ smtpServer.hostname = record.hostname;
+ for (let key of Object.keys(SYNCED_SMTP_PROPERTIES)) {
+ if (key in record.prefs) {
+ smtpServer[key] = record.prefs[key];
+ }
+ }
+ if (record.isDefault) {
+ MailServices.smtp.defaultServer = smtpServer;
+ }
+ },
+
+ async _updateIncoming(record) {
+ let server = MailServices.accounts.allServers.find(s => s.UID == record.id);
+ if (!server) {
+ this._log.trace("Skipping update for unknown item: " + record.id);
+ return;
+ }
+ if (server.type != record.type) {
+ throw new Components.Exception(
+ `Refusing to change server type from ${server.type} to ${record.type}`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ for (let key of Object.keys(SYNCED_SERVER_PROPERTIES)) {
+ if (key in record.prefs) {
+ server[key] = record.prefs[key];
+ }
+ }
+ },
+
+ /**
+ * Determine whether a record with the specified ID exists.
+ *
+ * Takes a string record ID and returns a booleans saying whether the record
+ * exists.
+ *
+ * @param id
+ * string record ID
+ * @return boolean indicating whether record exists locally
+ */
+ async itemExists(id) {
+ return id in (await this.getAllIDs());
+ },
+
+ /**
+ * Obtain the set of all known record IDs.
+ *
+ * @return Object with ID strings as keys and values of true. The values
+ * are ignored.
+ */
+ async getAllIDs() {
+ let ids = {};
+ for (let s of MailServices.smtp.servers) {
+ ids[s.UID] = true;
+ }
+ for (let s of MailServices.accounts.allServers) {
+ if (["imap", "pop3"].includes(s.type)) {
+ ids[s.UID] = true;
+ }
+ }
+ return ids;
+ },
+
+ /**
+ * Create a record from the specified ID.
+ *
+ * If the ID is known, the record should be populated with metadata from
+ * the store. If the ID is not known, the record should be created with the
+ * delete field set to true.
+ *
+ * @param id
+ * string record ID
+ * @param collection
+ * Collection to add record to. This is typically passed into the
+ * constructor for the newly-created record.
+ * @return record type for this engine
+ */
+ async createRecord(id, collection) {
+ let record = new AccountRecord(collection, id);
+
+ let server = MailServices.smtp.servers.find(s => s.UID == id);
+ if (server) {
+ record.type = "smtp";
+ record.username = server.username;
+ record.hostname = server.hostname;
+ record.prefs = {};
+ for (let key of Object.keys(SYNCED_SMTP_PROPERTIES)) {
+ record.prefs[key] = server[key];
+ }
+ record.isDefault = MailServices.smtp.defaultServer == server;
+ return record;
+ }
+
+ server = MailServices.accounts.allServers.find(s => s.UID == id);
+ // If we don't know about this ID, mark the record as deleted.
+ if (!server) {
+ record.deleted = true;
+ return record;
+ }
+
+ record.type = server.type;
+ record.username = server.username;
+ record.hostname = server.hostName;
+ record.prefs = {};
+ for (let key of Object.keys(SYNCED_SERVER_PROPERTIES)) {
+ record.prefs[key] = server[key];
+ }
+
+ return record;
+ },
+};
+
+function AccountTracker(name, engine) {
+ Tracker.call(this, name, engine);
+}
+AccountTracker.prototype = {
+ __proto__: Tracker.prototype,
+
+ _changedIDs: new Set(),
+ _ignoreAll: false,
+
+ async getChangedIDs() {
+ let changes = {};
+ for (let id of this._changedIDs) {
+ changes[id] = 0;
+ }
+ return changes;
+ },
+
+ clearChangedIDs() {
+ this._changedIDs.clear();
+ },
+
+ get ignoreAll() {
+ return this._ignoreAll;
+ },
+
+ set ignoreAll(value) {
+ this._ignoreAll = value;
+ },
+
+ onStart() {
+ Services.prefs.addObserver("mail.server.", this);
+ Services.obs.addObserver(this, "message-server-removed");
+ },
+
+ onStop() {
+ Services.prefs.removeObserver("mail.server.", this);
+ Services.obs.removeObserver(this, "message-server-removed");
+ },
+
+ observe(subject, topic, data) {
+ if (this._ignoreAll) {
+ return;
+ }
+
+ let server;
+ if (topic == "message-server-removed") {
+ server = subject.QueryInterface(Ci.nsIMsgIncomingServer);
+ } else {
+ let serverKey = data.split(".")[2];
+ let prefName = data.substring(serverKey.length + 13);
+ if (!Object.values(SYNCED_SERVER_PROPERTIES).includes(prefName)) {
+ return;
+ }
+
+ // Don't use getIncomingServer or it'll throw if the server doesn't exist.
+ server = MailServices.accounts.allServers.find(s => s.key == serverKey);
+ }
+
+ if (
+ server &&
+ ["imap", "pop3"].includes(server.type) &&
+ !this._changedIDs.has(server.UID)
+ ) {
+ this._changedIDs.add(server.UID);
+ this.score += SCORE_INCREMENT_XLARGE;
+ }
+ },
+};
diff --git a/comm/mail/services/sync/modules/engines/addressBooks.sys.mjs b/comm/mail/services/sync/modules/engines/addressBooks.sys.mjs
new file mode 100644
index 0000000000..9df9f678df
--- /dev/null
+++ b/comm/mail/services/sync/modules/engines/addressBooks.sys.mjs
@@ -0,0 +1,380 @@
+/* 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 { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
+import {
+ Store,
+ SyncEngine,
+ Tracker,
+} from "resource://services-sync/engines.sys.mjs";
+import { Utils } from "resource://services-sync/util.sys.mjs";
+
+const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import(
+ "resource://services-sync/constants.js"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const SYNCED_COMMON_PROPERTIES = {
+ autocomplete: "enable_autocomplete",
+ readOnly: "readOnly",
+};
+
+const SYNCED_CARDDAV_PROPERTIES = {
+ syncInterval: "carddav.syncinterval",
+ url: "carddav.url",
+ username: "carddav.username",
+};
+
+const SYNCED_LDAP_PROPERTIES = {
+ protocolVersion: "protocolVersion",
+ authSASLMechanism: "auth.saslmech",
+ authDN: "auth.dn",
+ uri: "uri",
+ maxHits: "maxHits",
+};
+
+/**
+ * AddressBookRecord represents the state of an add-on in an application.
+ *
+ * Each add-on has its own record for each application ID it is installed
+ * on.
+ *
+ * The ID of add-on records is a randomly-generated GUID. It is random instead
+ * of deterministic so the URIs of the records cannot be guessed and so
+ * compromised server credentials won't result in disclosure of the specific
+ * add-ons present in a Sync account.
+ *
+ * The record contains the following fields:
+ *
+ */
+export function AddressBookRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+AddressBookRecord.prototype = {
+ __proto__: CryptoWrapper.prototype,
+ _logName: "Record.AddressBook",
+};
+Utils.deferGetSet(AddressBookRecord, "cleartext", ["name", "type", "prefs"]);
+
+export function AddressBooksEngine(service) {
+ SyncEngine.call(this, "AddressBooks", service);
+}
+
+AddressBooksEngine.prototype = {
+ __proto__: SyncEngine.prototype,
+ _storeObj: AddressBookStore,
+ _trackerObj: AddressBookTracker,
+ _recordObj: AddressBookRecord,
+ version: 1,
+ syncPriority: 6,
+
+ /*
+ * Returns a changeset for this sync. Engine implementations can override this
+ * method to bypass the tracker for certain or all changed items.
+ */
+ async getChangedIDs() {
+ return this._tracker.getChangedIDs();
+ },
+};
+
+function AddressBookStore(name, engine) {
+ Store.call(this, name, engine);
+}
+AddressBookStore.prototype = {
+ __proto__: Store.prototype,
+
+ _addPrefsToBook(book, record, whichPrefs) {
+ for (let [key, realKey] of Object.entries(whichPrefs)) {
+ let value = record.prefs[key];
+ let type = typeof value;
+ if (type == "string") {
+ book.setStringValue(realKey, value);
+ } else if (type == "number") {
+ book.setIntValue(realKey, value);
+ } else if (type == "boolean") {
+ book.setBoolValue(realKey, value);
+ }
+ }
+ },
+
+ /**
+ * Create an item in the store from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The store record to create an item from
+ */
+ async create(record) {
+ if (
+ ![
+ MailServices.ab.LDAP_DIRECTORY_TYPE,
+ MailServices.ab.CARDDAV_DIRECTORY_TYPE,
+ ].includes(record.type)
+ ) {
+ return;
+ }
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ record.name,
+ null,
+ record.type,
+ record.id
+ );
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+
+ this._addPrefsToBook(book, record, SYNCED_COMMON_PROPERTIES);
+ if (record.type == MailServices.ab.CARDDAV_DIRECTORY_TYPE) {
+ this._addPrefsToBook(book, record, SYNCED_CARDDAV_PROPERTIES);
+ book.wrappedJSObject.fetchAllFromServer();
+ } else if (record.type == MailServices.ab.LDAP_DIRECTORY_TYPE) {
+ this._addPrefsToBook(book, record, SYNCED_LDAP_PROPERTIES);
+ }
+ },
+
+ /**
+ * Remove an item in the store from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The store record to delete an item from
+ */
+ async remove(record) {
+ let book = MailServices.ab.getDirectoryFromUID(record.id);
+ if (!book) {
+ this._log.trace("Asked to remove record that doesn't exist, ignoring");
+ return;
+ }
+
+ let deletedPromise = new Promise(resolve => {
+ Services.obs.addObserver(
+ {
+ observe() {
+ Services.obs.removeObserver(this, "addrbook-directory-deleted");
+ resolve();
+ },
+ },
+ "addrbook-directory-deleted"
+ );
+ });
+ MailServices.ab.deleteAddressBook(book.URI);
+ await deletedPromise;
+ },
+
+ /**
+ * Update an item from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The record to use to update an item from
+ */
+ async update(record) {
+ let book = MailServices.ab.getDirectoryFromUID(record.id);
+ if (!book) {
+ this._log.trace("Skipping update for unknown item: " + record.id);
+ return;
+ }
+ if (book.dirType != record.type) {
+ throw new Components.Exception(
+ `Refusing to change book type from ${book.dirType} to ${record.type}`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (book.dirName != record.name) {
+ book.dirName = record.name;
+ }
+ this._addPrefsToBook(book, record, SYNCED_COMMON_PROPERTIES);
+ if (record.type == MailServices.ab.CARDDAV_DIRECTORY_TYPE) {
+ this._addPrefsToBook(book, record, SYNCED_CARDDAV_PROPERTIES);
+ } else if (record.type == MailServices.ab.LDAP_DIRECTORY_TYPE) {
+ this._addPrefsToBook(book, record, SYNCED_LDAP_PROPERTIES);
+ }
+ },
+
+ /**
+ * Determine whether a record with the specified ID exists.
+ *
+ * Takes a string record ID and returns a booleans saying whether the record
+ * exists.
+ *
+ * @param id
+ * string record ID
+ * @return boolean indicating whether record exists locally
+ */
+ async itemExists(id) {
+ return id in (await this.getAllIDs());
+ },
+
+ /**
+ * Obtain the set of all known record IDs.
+ *
+ * @return Object with ID strings as keys and values of true. The values
+ * are ignored.
+ */
+ async getAllIDs() {
+ let ids = {};
+ for (let b of MailServices.ab.directories) {
+ if (
+ [
+ MailServices.ab.LDAP_DIRECTORY_TYPE,
+ MailServices.ab.CARDDAV_DIRECTORY_TYPE,
+ ].includes(b.dirType)
+ ) {
+ ids[b.UID] = true;
+ }
+ }
+ return ids;
+ },
+
+ /**
+ * Create a record from the specified ID.
+ *
+ * If the ID is known, the record should be populated with metadata from
+ * the store. If the ID is not known, the record should be created with the
+ * delete field set to true.
+ *
+ * @param id
+ * string record ID
+ * @param collection
+ * Collection to add record to. This is typically passed into the
+ * constructor for the newly-created record.
+ * @return record type for this engine
+ */
+ async createRecord(id, collection) {
+ let record = new AddressBookRecord(collection, id);
+
+ let book = MailServices.ab.getDirectoryFromUID(id);
+
+ // If we don't know about this ID, mark the record as deleted.
+ if (!book) {
+ record.deleted = true;
+ return record;
+ }
+
+ record.name = book.dirName;
+ record.type = book.dirType;
+ record.prefs = {};
+
+ function collectPrefs(prefData) {
+ for (let [key, realKey] of Object.entries(prefData)) {
+ realKey = `${book.dirPrefId}.${realKey}`;
+ switch (Services.prefs.getPrefType(realKey)) {
+ case Services.prefs.PREF_STRING:
+ record.prefs[key] = Services.prefs.getStringPref(realKey);
+ break;
+ case Services.prefs.PREF_INT:
+ record.prefs[key] = Services.prefs.getIntPref(realKey);
+ break;
+ case Services.prefs.PREF_BOOL:
+ record.prefs[key] = Services.prefs.getBoolPref(realKey);
+ break;
+ }
+ }
+ }
+
+ collectPrefs(SYNCED_COMMON_PROPERTIES);
+
+ if (book.dirType == MailServices.ab.CARDDAV_DIRECTORY_TYPE) {
+ collectPrefs(SYNCED_CARDDAV_PROPERTIES);
+ } else if (book.dirType == MailServices.ab.LDAP_DIRECTORY_TYPE) {
+ collectPrefs(SYNCED_LDAP_PROPERTIES);
+ }
+
+ return record;
+ },
+};
+
+function AddressBookTracker(name, engine) {
+ Tracker.call(this, name, engine);
+}
+AddressBookTracker.prototype = {
+ __proto__: Tracker.prototype,
+
+ _changedIDs: new Set(),
+ _ignoreAll: false,
+
+ async getChangedIDs() {
+ let changes = {};
+ for (let id of this._changedIDs) {
+ changes[id] = 0;
+ }
+ return changes;
+ },
+
+ clearChangedIDs() {
+ this._changedIDs.clear();
+ },
+
+ get ignoreAll() {
+ return this._ignoreAll;
+ },
+
+ set ignoreAll(value) {
+ this._ignoreAll = value;
+ },
+
+ onStart() {
+ Services.prefs.addObserver("ldap_2.servers.", this);
+ Services.obs.addObserver(this, "addrbook-directory-created");
+ Services.obs.addObserver(this, "addrbook-directory-deleted");
+ },
+
+ onStop() {
+ Services.prefs.removeObserver("ldap_2.servers.", this);
+ Services.obs.removeObserver(this, "addrbook-directory-created");
+ Services.obs.removeObserver(this, "addrbook-directory-deleted");
+ },
+
+ observe(subject, topic, data) {
+ if (this._ignoreAll) {
+ return;
+ }
+
+ let book;
+ switch (topic) {
+ case "nsPref:changed": {
+ let serverKey = data.split(".")[2];
+ let prefName = data.substring(serverKey.length + 16);
+ if (
+ prefName != "description" &&
+ !Object.values(SYNCED_COMMON_PROPERTIES).includes(prefName) &&
+ !Object.values(SYNCED_CARDDAV_PROPERTIES).includes(prefName) &&
+ !Object.values(SYNCED_LDAP_PROPERTIES).includes(prefName)
+ ) {
+ return;
+ }
+
+ book = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers." + serverKey
+ );
+ break;
+ }
+ case "addrbook-directory-created":
+ case "addrbook-directory-deleted":
+ book = subject;
+ break;
+ }
+
+ if (
+ book &&
+ [
+ MailServices.ab.LDAP_DIRECTORY_TYPE,
+ MailServices.ab.CARDDAV_DIRECTORY_TYPE,
+ ].includes(book.dirType) &&
+ !this._changedIDs.has(book.UID)
+ ) {
+ this._changedIDs.add(book.UID);
+ this.score += SCORE_INCREMENT_XLARGE;
+ }
+ },
+};
diff --git a/comm/mail/services/sync/modules/engines/calendars.sys.mjs b/comm/mail/services/sync/modules/engines/calendars.sys.mjs
new file mode 100644
index 0000000000..72a3799786
--- /dev/null
+++ b/comm/mail/services/sync/modules/engines/calendars.sys.mjs
@@ -0,0 +1,349 @@
+/* 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 { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
+import {
+ Store,
+ SyncEngine,
+ Tracker,
+} from "resource://services-sync/engines.sys.mjs";
+import { Utils } from "resource://services-sync/util.sys.mjs";
+
+const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import(
+ "resource://services-sync/constants.js"
+);
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const SYNCED_PROPERTIES = {
+ cacheEnabled: "cache.enabled",
+ color: "color",
+ displayed: "calendar-main-in-composite",
+ disabled: "disabled",
+ forceEmailScheduling: "forceEmailScheduling",
+ // imipIdentityKey: "imip.identity.key",
+ readOnly: "readOnly",
+ refreshInterval: "refreshInterval",
+ sessionId: "sessionId",
+ suppressAlarms: "suppressAlarms",
+ username: "username",
+};
+
+function shouldSyncCalendar(calendar) {
+ if (calendar.type == "caldav") {
+ return true;
+ }
+ if (calendar.type == "ics") {
+ return calendar.uri.schemeIs("http") || calendar.uri.schemeIs("https");
+ }
+ return false;
+}
+
+/**
+ * CalendarRecord represents the state of an add-on in an application.
+ *
+ * Each add-on has its own record for each application ID it is installed
+ * on.
+ *
+ * The ID of add-on records is a randomly-generated GUID. It is random instead
+ * of deterministic so the URIs of the records cannot be guessed and so
+ * compromised server credentials won't result in disclosure of the specific
+ * add-ons present in a Sync account.
+ *
+ * The record contains the following fields:
+ *
+ */
+export function CalendarRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+CalendarRecord.prototype = {
+ __proto__: CryptoWrapper.prototype,
+ _logName: "Record.Calendar",
+};
+Utils.deferGetSet(CalendarRecord, "cleartext", [
+ "name",
+ "type",
+ "uri",
+ "prefs",
+]);
+
+export function CalendarsEngine(service) {
+ SyncEngine.call(this, "Calendars", service);
+}
+
+CalendarsEngine.prototype = {
+ __proto__: SyncEngine.prototype,
+ _storeObj: CalendarStore,
+ _trackerObj: CalendarTracker,
+ _recordObj: CalendarRecord,
+ version: 1,
+ syncPriority: 6,
+
+ /*
+ * Returns a changeset for this sync. Engine implementations can override this
+ * method to bypass the tracker for certain or all changed items.
+ */
+ async getChangedIDs() {
+ return this._tracker.getChangedIDs();
+ },
+};
+
+function CalendarStore(name, engine) {
+ Store.call(this, name, engine);
+}
+CalendarStore.prototype = {
+ __proto__: Store.prototype,
+
+ /**
+ * Create an item in the store from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The store record to create an item from
+ */
+ async create(record) {
+ if (!["caldav", "ics"].includes(record.type)) {
+ return;
+ }
+
+ let calendar = cal.manager.createCalendar(
+ record.type,
+ Services.io.newURI(record.uri)
+ );
+ calendar.name = record.name;
+
+ for (let [key, realKey] of Object.entries(SYNCED_PROPERTIES)) {
+ if (key in record.prefs) {
+ calendar.setProperty(realKey, record.prefs[key]);
+ }
+ }
+
+ // Set this *after* the properties so it can pick up the session ID or username.
+ calendar.id = record.id;
+ cal.manager.registerCalendar(calendar);
+ if (!calendar.getProperty("disabled")) {
+ calendar.refresh();
+ }
+ },
+
+ /**
+ * Remove an item in the store from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The store record to delete an item from
+ */
+ async remove(record) {
+ let calendar = cal.manager.getCalendarById(record.id);
+ if (!calendar) {
+ this._log.trace("Asked to remove record that doesn't exist, ignoring");
+ return;
+ }
+ cal.manager.removeCalendar(calendar);
+ },
+
+ /**
+ * Update an item from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The record to use to update an item from
+ */
+ async update(record) {
+ let calendar = cal.manager.getCalendarById(record.id);
+ if (!calendar) {
+ this._log.trace("Skipping update for unknown item: " + record.id);
+ return;
+ }
+ if (calendar.type != record.type) {
+ throw new Components.Exception(
+ `Refusing to change calendar type from ${calendar.type} to ${record.type}`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ if (calendar.getProperty("cache.enabled") != record.prefs.cacheEnabled) {
+ throw new Components.Exception(
+ `Refusing to change the cache setting`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ calendar.name = record.name;
+ if (calendar.uri.spec != record.uri) {
+ calendar.uri = Services.io.newURI(record.uri); // Should this be allowed?
+ }
+ for (let [key, realKey] of Object.entries(SYNCED_PROPERTIES)) {
+ if (key in record.prefs) {
+ calendar.setProperty(realKey, record.prefs[key]);
+ } else if (calendar.getProperty(key)) {
+ // Only delete properties if they exist. Otherwise bad things happen.
+ calendar.deleteProperty(realKey);
+ }
+ }
+ },
+
+ /**
+ * Determine whether a record with the specified ID exists.
+ *
+ * Takes a string record ID and returns a booleans saying whether the record
+ * exists.
+ *
+ * @param id
+ * string record ID
+ * @return boolean indicating whether record exists locally
+ */
+ async itemExists(id) {
+ return id in (await this.getAllIDs());
+ },
+
+ /**
+ * Obtain the set of all known record IDs.
+ *
+ * @return Object with ID strings as keys and values of true. The values
+ * are ignored.
+ */
+ async getAllIDs() {
+ let ids = {};
+ for (let c of cal.manager.getCalendars()) {
+ if (shouldSyncCalendar(c)) {
+ ids[c.id] = true;
+ }
+ }
+ return ids;
+ },
+
+ /**
+ * Create a record from the specified ID.
+ *
+ * If the ID is known, the record should be populated with metadata from
+ * the store. If the ID is not known, the record should be created with the
+ * delete field set to true.
+ *
+ * @param id
+ * string record ID
+ * @param collection
+ * Collection to add record to. This is typically passed into the
+ * constructor for the newly-created record.
+ * @return record type for this engine
+ */
+ async createRecord(id, collection) {
+ let record = new CalendarRecord(collection, id);
+
+ let calendar = cal.manager.getCalendarById(id);
+
+ // If we don't know about this ID, mark the record as deleted.
+ if (!calendar) {
+ record.deleted = true;
+ return record;
+ }
+
+ record.name = calendar.name;
+ record.type = calendar.type;
+ record.uri = calendar.uri.spec;
+ record.prefs = {};
+
+ for (let [key, realKey] of Object.entries(SYNCED_PROPERTIES)) {
+ let value = calendar.getProperty(realKey);
+ if (value !== null) {
+ record.prefs[key] = value;
+ }
+ }
+
+ return record;
+ },
+};
+
+function CalendarTracker(name, engine) {
+ Tracker.call(this, name, engine);
+}
+CalendarTracker.prototype = {
+ __proto__: Tracker.prototype,
+
+ QueryInterface: cal.generateQI([
+ "calICalendarManagerObserver",
+ "nsIObserver",
+ ]),
+
+ _changedIDs: new Set(),
+ _ignoreAll: false,
+
+ async getChangedIDs() {
+ let changes = {};
+ for (let id of this._changedIDs) {
+ changes[id] = 0;
+ }
+ return changes;
+ },
+
+ clearChangedIDs() {
+ this._changedIDs.clear();
+ },
+
+ get ignoreAll() {
+ return this._ignoreAll;
+ },
+
+ set ignoreAll(value) {
+ this._ignoreAll = value;
+ },
+
+ onStart() {
+ Services.prefs.addObserver("calendar.registry.", this);
+ cal.manager.addObserver(this);
+ },
+
+ onStop() {
+ Services.prefs.removeObserver("calendar.registry.", this);
+ cal.manager.removeObserver(this);
+ },
+
+ observe(subject, topic, data) {
+ if (this._ignoreAll) {
+ return;
+ }
+
+ let id = data.split(".")[2];
+ let prefName = data.substring(id.length + 19);
+ if (
+ prefName != "name" &&
+ !Object.values(SYNCED_PROPERTIES).includes(prefName)
+ ) {
+ return;
+ }
+
+ let calendar = cal.manager.getCalendarById(id);
+ if (calendar && shouldSyncCalendar(calendar) && !this._changedIDs.has(id)) {
+ this._changedIDs.add(id);
+ this.score += SCORE_INCREMENT_XLARGE;
+ }
+ },
+
+ onCalendarRegistered(calendar) {
+ if (this._ignoreAll) {
+ return;
+ }
+
+ if (shouldSyncCalendar(calendar)) {
+ this._changedIDs.add(calendar.id);
+ this.score += SCORE_INCREMENT_XLARGE;
+ }
+ },
+ onCalendarUnregistering(calendar) {},
+ onCalendarDeleting(calendar) {
+ if (this._ignoreAll) {
+ return;
+ }
+
+ if (shouldSyncCalendar(calendar)) {
+ this._changedIDs.add(calendar.id);
+ this.score += SCORE_INCREMENT_XLARGE;
+ }
+ },
+};
diff --git a/comm/mail/services/sync/modules/engines/identities.sys.mjs b/comm/mail/services/sync/modules/engines/identities.sys.mjs
new file mode 100644
index 0000000000..07976272eb
--- /dev/null
+++ b/comm/mail/services/sync/modules/engines/identities.sys.mjs
@@ -0,0 +1,394 @@
+/* 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 { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
+import {
+ Store,
+ SyncEngine,
+ Tracker,
+} from "resource://services-sync/engines.sys.mjs";
+import { Utils } from "resource://services-sync/util.sys.mjs";
+
+const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import(
+ "resource://services-sync/constants.js"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const SYNCED_IDENTITY_PROPERTIES = {
+ attachSignature: "attach_signature",
+ attachVCard: "attach_vcard",
+ autoQuote: "auto_quote",
+ catchAll: "catchAll",
+ catchAllHint: "catchAllHint",
+ composeHtml: "compose_html",
+ email: "useremail",
+ escapedVCard: "escapedVCard",
+ fullName: "fullName",
+ htmlSigFormat: "htmlSigFormat",
+ htmlSigText: "htmlSigText",
+ label: "label",
+ organization: "organization",
+ replyOnTop: "reply_on_top",
+ replyTo: "reply_to",
+ sigBottom: "sig_bottom",
+ sigOnForward: "sig_on_fwd",
+ sigOnReply: "sig_on_reply",
+};
+
+/**
+ * IdentityRecord represents the state of an add-on in an application.
+ *
+ * Each add-on has its own record for each application ID it is installed
+ * on.
+ *
+ * The ID of add-on records is a randomly-generated GUID. It is random instead
+ * of deterministic so the URIs of the records cannot be guessed and so
+ * compromised server credentials won't result in disclosure of the specific
+ * add-ons present in a Sync account.
+ *
+ * The record contains the following fields:
+ *
+ */
+export function IdentityRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+IdentityRecord.prototype = {
+ __proto__: CryptoWrapper.prototype,
+ _logName: "Record.Identity",
+};
+Utils.deferGetSet(IdentityRecord, "cleartext", ["accounts", "prefs", "smtpID"]);
+
+export function IdentitiesEngine(service) {
+ SyncEngine.call(this, "Identities", service);
+}
+
+IdentitiesEngine.prototype = {
+ __proto__: SyncEngine.prototype,
+ _storeObj: IdentityStore,
+ _trackerObj: IdentityTracker,
+ _recordObj: IdentityRecord,
+ version: 1,
+ syncPriority: 4,
+
+ /*
+ * Returns a changeset for this sync. Engine implementations can override this
+ * method to bypass the tracker for certain or all changed items.
+ */
+ async getChangedIDs() {
+ return this._tracker.getChangedIDs();
+ },
+};
+
+function IdentityStore(name, engine) {
+ Store.call(this, name, engine);
+}
+IdentityStore.prototype = {
+ __proto__: Store.prototype,
+
+ /**
+ * Create an item in the store from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The store record to create an item from
+ */
+ async create(record) {
+ let identity = MailServices.accounts.createIdentity();
+ identity.UID = record.id;
+
+ for (let key of Object.keys(SYNCED_IDENTITY_PROPERTIES)) {
+ if (key in record.prefs) {
+ identity[key] = record.prefs[key];
+ }
+ }
+
+ if (record.smtpID) {
+ let smtpServer = MailServices.smtp.servers.find(
+ s => s.UID == record.smtpID
+ );
+ if (smtpServer) {
+ identity.smtpServerKey = smtpServer.key;
+ } else {
+ this._log.warn(
+ `Identity uses SMTP server ${record.smtpID}, but it doesn't exist.`
+ );
+ }
+ }
+
+ for (let { id, isDefault } of record.accounts) {
+ let account = MailServices.accounts.accounts.find(
+ a => a.incomingServer?.UID == id
+ );
+ if (account) {
+ account.addIdentity(identity);
+ if (isDefault) {
+ account.defaultIdentity = identity;
+ }
+ } else {
+ this._log.warn(`Identity is for account ${id}, but it doesn't exist.`);
+ }
+ }
+ },
+
+ /**
+ * Remove an item in the store from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The store record to delete an item from
+ */
+ async remove(record) {
+ let identity = MailServices.accounts.allIdentities.find(
+ i => i.UID == record.id
+ );
+ if (!identity) {
+ this._log.trace("Asked to remove record that doesn't exist, ignoring");
+ return;
+ }
+
+ for (let server of MailServices.accounts.getServersForIdentity(identity)) {
+ let account = MailServices.accounts.FindAccountForServer(server);
+ account.removeIdentity(identity);
+ // Removing the identity from one account should destroy it.
+ // No need to continue.
+ return;
+ }
+ },
+
+ /**
+ * Update an item from a record.
+ *
+ * This is called by the default implementation of applyIncoming(). If using
+ * applyIncomingBatch(), this won't be called unless your store calls it.
+ *
+ * @param record
+ * The record to use to update an item from
+ */
+ async update(record) {
+ let identity = MailServices.accounts.allIdentities.find(
+ i => i.UID == record.id
+ );
+ if (!identity) {
+ this._log.trace("Skipping update for unknown item: " + record.id);
+ return;
+ }
+
+ for (let key of Object.keys(SYNCED_IDENTITY_PROPERTIES)) {
+ if (key in record.prefs) {
+ identity[key] = record.prefs[key];
+ }
+ }
+
+ if (record.smtpID) {
+ let smtpServer = MailServices.smtp.servers.find(
+ s => s.UID == record.smtpID
+ );
+ if (smtpServer) {
+ identity.smtpServerKey = smtpServer.key;
+ } else {
+ this._log.warn(
+ `Identity uses SMTP server ${record.smtpID}, but it doesn't exist.`
+ );
+ }
+ } else {
+ identity.smtpServerKey = null;
+ }
+
+ for (let { id, isDefault } of record.accounts) {
+ let account = MailServices.accounts.accounts.find(
+ a => a.incomingServer?.UID == id
+ );
+ if (account) {
+ if (!account.identities.includes(identity)) {
+ account.addIdentity(identity);
+ }
+ if (isDefault && account.defaultIdentity != identity) {
+ account.defaultIdentity = identity;
+ }
+ } else {
+ this._log.warn(`Identity is for account ${id}, but it doesn't exist.`);
+ }
+ }
+ },
+
+ /**
+ * Determine whether a record with the specified ID exists.
+ *
+ * Takes a string record ID and returns a booleans saying whether the record
+ * exists.
+ *
+ * @param id
+ * string record ID
+ * @return boolean indicating whether record exists locally
+ */
+ async itemExists(id) {
+ return id in (await this.getAllIDs());
+ },
+
+ /**
+ * Obtain the set of all known record IDs.
+ *
+ * @return Object with ID strings as keys and values of true. The values
+ * are ignored.
+ */
+ async getAllIDs() {
+ let ids = {};
+ for (let i of MailServices.accounts.allIdentities) {
+ let servers = MailServices.accounts.getServersForIdentity(i);
+ if (servers.find(s => ["imap", "pop3"].includes(s.type))) {
+ ids[i.UID] = true;
+ }
+ }
+ return ids;
+ },
+
+ /**
+ * Create a record from the specified ID.
+ *
+ * If the ID is known, the record should be populated with metadata from
+ * the store. If the ID is not known, the record should be created with the
+ * delete field set to true.
+ *
+ * @param id
+ * string record ID
+ * @param collection
+ * Collection to add record to. This is typically passed into the
+ * constructor for the newly-created record.
+ * @return record type for this engine
+ */
+ async createRecord(id, collection) {
+ let record = new IdentityRecord(collection, id);
+
+ let identity = MailServices.accounts.allIdentities.find(i => i.UID == id);
+
+ // If we don't know about this ID, mark the record as deleted.
+ if (!identity) {
+ record.deleted = true;
+ return record;
+ }
+
+ record.accounts = [];
+ for (let server of MailServices.accounts.getServersForIdentity(identity)) {
+ let account = MailServices.accounts.FindAccountForServer(server);
+ if (account) {
+ record.accounts.push({
+ id: server.UID,
+ isDefault: account.defaultIdentity == identity,
+ });
+ }
+ }
+
+ record.prefs = {};
+ for (let key of Object.keys(SYNCED_IDENTITY_PROPERTIES)) {
+ record.prefs[key] = identity[key];
+ }
+
+ if (identity.smtpServerKey) {
+ let smtpServer = MailServices.smtp.getServerByIdentity(identity);
+ record.smtpID = smtpServer.UID;
+ }
+
+ return record;
+ },
+};
+
+function IdentityTracker(name, engine) {
+ Tracker.call(this, name, engine);
+}
+IdentityTracker.prototype = {
+ __proto__: Tracker.prototype,
+
+ _changedIDs: new Set(),
+ _ignoreAll: false,
+
+ async getChangedIDs() {
+ let changes = {};
+ for (let id of this._changedIDs) {
+ changes[id] = 0;
+ }
+ return changes;
+ },
+
+ clearChangedIDs() {
+ this._changedIDs.clear();
+ },
+
+ get ignoreAll() {
+ return this._ignoreAll;
+ },
+
+ set ignoreAll(value) {
+ this._ignoreAll = value;
+ },
+
+ onStart() {
+ Services.prefs.addObserver("mail.identity.", this);
+ Services.obs.addObserver(this, "account-identity-added");
+ Services.obs.addObserver(this, "account-identity-removed");
+ Services.obs.addObserver(this, "account-default-identity-changed");
+ },
+
+ onStop() {
+ Services.prefs.removeObserver("mail.account.", this);
+ Services.obs.removeObserver(this, "account-identity-added");
+ Services.obs.removeObserver(this, "account-identity-removed");
+ Services.obs.removeObserver(this, "account-default-identity-changed");
+ },
+
+ observe(subject, topic, data) {
+ if (this._ignoreAll) {
+ return;
+ }
+
+ let markAsChanged = identity => {
+ if (identity && !this._changedIDs.has(identity.UID)) {
+ this._changedIDs.add(identity.UID);
+ this.score = SCORE_INCREMENT_XLARGE;
+ }
+ };
+
+ if (
+ ["account-identity-added", "account-identity-removed"].includes(topic)
+ ) {
+ markAsChanged(subject.QueryInterface(Ci.nsIMsgIdentity));
+ return;
+ }
+
+ if (topic == "account-default-identity-changed") {
+ // The default identity has changed, update the default identity and
+ // the previous one, which will now be second on the list.
+ let [newDefault, oldDefault] = Services.prefs
+ .getStringPref(`mail.account.${data}.identities`)
+ .split(",");
+ if (newDefault) {
+ markAsChanged(MailServices.accounts.getIdentity(newDefault));
+ }
+ if (oldDefault) {
+ markAsChanged(MailServices.accounts.getIdentity(oldDefault));
+ }
+ return;
+ }
+
+ let idKey = data.split(".")[2];
+ let prefName = data.substring(idKey.length + 15);
+ if (
+ prefName != "smtpServer" &&
+ !Object.values(SYNCED_IDENTITY_PROPERTIES).includes(prefName)
+ ) {
+ return;
+ }
+
+ // Don't use .getIdentity because it will create one if it doesn't exist.
+ markAsChanged(
+ MailServices.accounts.allIdentities.find(i => i.key == idKey)
+ );
+ },
+};
diff --git a/comm/mail/services/sync/moz.build b/comm/mail/services/sync/moz.build
new file mode 100644
index 0000000000..47321a80c6
--- /dev/null
+++ b/comm/mail/services/sync/moz.build
@@ -0,0 +1,14 @@
+# 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_JS_MODULES["services-sync"].engines += [
+ "modules/engines/accounts.sys.mjs",
+ "modules/engines/addressBooks.sys.mjs",
+ "modules/engines/calendars.sys.mjs",
+ "modules/engines/identities.sys.mjs",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/unit/xpcshell.ini",
+]
diff --git a/comm/mail/services/sync/test/unit/head.js b/comm/mail/services/sync/test/unit/head.js
new file mode 100644
index 0000000000..8ccbe36050
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/head.js
@@ -0,0 +1,7 @@
+/* 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/. */
+
+function newUID() {
+ return Services.uuid.generateUUID().toString().substring(1, 37);
+}
diff --git a/comm/mail/services/sync/test/unit/test_account_store.js b/comm/mail/services/sync/test/unit/test_account_store.js
new file mode 100644
index 0000000000..e920ce14fe
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/test_account_store.js
@@ -0,0 +1,361 @@
+/* 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/. */
+
+do_get_profile();
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { AccountsEngine, AccountRecord } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/accounts.sys.mjs"
+);
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+let engine, store, tracker;
+let imapAccount, imapServer, pop3Account, pop3Server, smtpServer;
+
+add_setup(async function () {
+ engine = new AccountsEngine(Service);
+ await engine.initialize();
+ store = engine._store;
+
+ try {
+ // Ensure there is a local mail account...
+ MailServices.accounts.localFoldersServer;
+ } catch {
+ // ... if not, make one.
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ imapAccount = MailServices.accounts.createAccount();
+ imapServer = imapAccount.incomingServer =
+ MailServices.accounts.createIncomingServer("username", "hostname", "imap");
+ imapAccount.incomingServer.prettyName = "IMAP Server";
+
+ Assert.ok(imapServer.UID);
+ Assert.equal(
+ Services.prefs.getStringPref(`mail.server.${imapServer.key}.uid`),
+ imapServer.UID
+ );
+
+ pop3Account = MailServices.accounts.createAccount();
+ pop3Server = pop3Account.incomingServer =
+ MailServices.accounts.createIncomingServer("username", "hostname", "pop3");
+ pop3Account.incomingServer.prettyName = "POP3 Server";
+
+ Assert.ok(pop3Server.UID);
+ Assert.equal(
+ Services.prefs.getStringPref(`mail.server.${pop3Server.key}.uid`),
+ pop3Server.UID
+ );
+
+ smtpServer = MailServices.smtp.createServer();
+ smtpServer.username = "username";
+ smtpServer.hostname = "hostname";
+ smtpServer.description = "SMTP Server";
+
+ Assert.ok(smtpServer.UID);
+ Assert.equal(
+ Services.prefs.getStringPref(`mail.smtpserver.${smtpServer.key}.uid`, ""),
+ smtpServer.UID
+ );
+
+ // Sanity check.
+ Assert.equal(MailServices.accounts.accounts.length, 3);
+ Assert.equal(MailServices.smtp.servers.length, 1);
+});
+
+add_task(async function testGetAllIDs() {
+ Assert.deepEqual(await store.getAllIDs(), {
+ [imapServer.UID]: true,
+ [pop3Server.UID]: true,
+ [smtpServer.UID]: true,
+ });
+});
+
+add_task(async function testItemExists() {
+ Assert.equal(await store.itemExists(imapServer.UID), true);
+ Assert.equal(await store.itemExists(pop3Server.UID), true);
+ Assert.equal(await store.itemExists(smtpServer.UID), true);
+});
+
+add_task(async function testCreateIMAPRecord() {
+ let record = await store.createRecord(imapServer.UID);
+ Assert.ok(record instanceof AccountRecord);
+ Assert.equal(record.id, imapServer.UID);
+ Assert.equal(record.username, "username");
+ Assert.equal(record.hostname, "hostname");
+ Assert.equal(record.type, "imap");
+ Assert.deepEqual(record.prefs, {
+ authMethod: 3,
+ biffMinutes: 10,
+ doBiff: true,
+ downloadOnBiff: false,
+ emptyTrashOnExit: false,
+ incomingDuplicateAction: 0,
+ limitOfflineMessageSize: false,
+ loginAtStartUp: false,
+ maxMessageSize: 50,
+ port: 143,
+ prettyName: "IMAP Server",
+ socketType: 0,
+ });
+ Assert.equal(record.isDefault, undefined);
+});
+
+add_task(async function testCreatePOP3Record() {
+ let record = await store.createRecord(pop3Server.UID);
+ Assert.ok(record instanceof AccountRecord);
+ Assert.equal(record.id, pop3Server.UID);
+ Assert.equal(record.username, "username");
+ Assert.equal(record.hostname, "hostname");
+ Assert.equal(record.type, "pop3");
+ Assert.deepEqual(record.prefs, {
+ authMethod: 3,
+ biffMinutes: 10,
+ doBiff: true,
+ downloadOnBiff: false,
+ emptyTrashOnExit: false,
+ incomingDuplicateAction: 0,
+ limitOfflineMessageSize: false,
+ loginAtStartUp: false,
+ maxMessageSize: 50,
+ port: 110,
+ prettyName: "POP3 Server",
+ socketType: 0,
+ });
+ Assert.equal(record.isDefault, undefined);
+});
+
+add_task(async function testCreateSMTPRecord() {
+ let smtpServerID = smtpServer.UID;
+
+ let record = await store.createRecord(smtpServerID);
+ Assert.ok(record instanceof AccountRecord);
+ Assert.equal(record.id, smtpServerID);
+ Assert.equal(record.username, "username");
+ Assert.equal(record.hostname, "hostname");
+ Assert.equal(record.type, "smtp");
+ Assert.deepEqual(record.prefs, {
+ authMethod: 3,
+ port: 0,
+ description: "SMTP Server",
+ socketType: 0,
+ });
+ Assert.equal(record.isDefault, true);
+});
+
+add_task(async function testCreateDeletedRecord() {
+ let fakeID = "12345678-1234-1234-1234-123456789012";
+ let record = await store.createRecord(fakeID);
+ Assert.ok(record instanceof AccountRecord);
+ Assert.equal(record.id, fakeID);
+ Assert.equal(record.deleted, true);
+});
+
+add_task(async function testSyncIMAPRecords() {
+ let newID = newUID();
+ await store.applyIncoming({
+ id: newID,
+ username: "username",
+ hostname: "new.hostname",
+ type: "imap",
+ prefs: {
+ authMethod: 3,
+ biffMinutes: 10,
+ doBiff: true,
+ downloadOnBiff: false,
+ emptyTrashOnExit: false,
+ incomingDuplicateAction: 0,
+ limitOfflineMessageSize: false,
+ loginAtStartUp: false,
+ maxMessageSize: 50,
+ port: 143,
+ prettyName: "New IMAP Server",
+ socketType: Ci.nsMsgSocketType.plain,
+ },
+ });
+
+ Assert.equal(MailServices.accounts.accounts.length, 4);
+
+ let newServer = MailServices.accounts.allServers.find(s => s.UID == newID);
+ Assert.equal(newServer.username, "username");
+ Assert.equal(newServer.hostName, "new.hostname");
+ Assert.equal(newServer.prettyName, "New IMAP Server");
+ Assert.equal(newServer.port, 143);
+ Assert.equal(newServer.socketType, Ci.nsMsgSocketType.plain);
+
+ await store.applyIncoming({
+ id: newID,
+ username: "username",
+ hostname: "new.hostname",
+ type: "imap",
+ prefs: {
+ authMethod: 3,
+ biffMinutes: 10,
+ doBiff: true,
+ downloadOnBiff: false,
+ emptyTrashOnExit: false,
+ incomingDuplicateAction: 0,
+ limitOfflineMessageSize: false,
+ loginAtStartUp: false,
+ maxMessageSize: 50,
+ port: 993,
+ prettyName: "Changed IMAP Server",
+ socketType: Ci.nsMsgSocketType.SSL,
+ },
+ });
+
+ Assert.equal(newServer.prettyName, "Changed IMAP Server");
+ Assert.equal(newServer.port, 993);
+ Assert.equal(newServer.socketType, Ci.nsMsgSocketType.SSL);
+
+ await Assert.rejects(
+ store.applyIncoming({
+ id: newID,
+ type: "pop3",
+ }),
+ /Refusing to change server type/
+ );
+
+ await store.applyIncoming({
+ id: newID,
+ deleted: true,
+ });
+
+ Assert.equal(MailServices.accounts.accounts.length, 3);
+});
+
+add_task(async function testSyncPOP3Records() {
+ let newID = newUID();
+ await store.applyIncoming({
+ id: newID,
+ username: "username",
+ hostname: "new.hostname",
+ type: "pop3",
+ prefs: {
+ authMethod: 3,
+ biffMinutes: 10,
+ doBiff: true,
+ downloadOnBiff: false,
+ emptyTrashOnExit: false,
+ incomingDuplicateAction: 0,
+ limitOfflineMessageSize: false,
+ loginAtStartUp: false,
+ maxMessageSize: 50,
+ port: 110,
+ prettyName: "New POP3 Server",
+ socketType: Ci.nsMsgSocketType.plain,
+ },
+ });
+
+ Assert.equal(MailServices.accounts.accounts.length, 4);
+
+ let newServer = MailServices.accounts.allServers.find(s => s.UID == newID);
+ Assert.equal(newServer.username, "username");
+ Assert.equal(newServer.hostName, "new.hostname");
+ Assert.equal(newServer.prettyName, "New POP3 Server");
+ Assert.equal(newServer.port, 110);
+ Assert.equal(newServer.socketType, Ci.nsMsgSocketType.plain);
+
+ await store.applyIncoming({
+ id: newID,
+ username: "username",
+ hostname: "new.hostname",
+ type: "pop3",
+ prefs: {
+ authMethod: 3,
+ biffMinutes: 10,
+ doBiff: true,
+ downloadOnBiff: false,
+ emptyTrashOnExit: false,
+ incomingDuplicateAction: 0,
+ limitOfflineMessageSize: false,
+ loginAtStartUp: false,
+ maxMessageSize: 50,
+ port: 995,
+ prettyName: "Changed POP3 Server",
+ socketType: Ci.nsMsgSocketType.SSL,
+ },
+ });
+
+ Assert.equal(newServer.prettyName, "Changed POP3 Server");
+ Assert.equal(newServer.port, 995);
+ Assert.equal(newServer.socketType, Ci.nsMsgSocketType.SSL);
+
+ await Assert.rejects(
+ store.applyIncoming({
+ id: newID,
+ type: "imap",
+ }),
+ /Refusing to change server type/
+ );
+
+ await store.applyIncoming({
+ id: newID,
+ deleted: true,
+ });
+
+ Assert.equal(MailServices.accounts.accounts.length, 3);
+});
+
+add_task(async function testSyncSMTPRecords() {
+ let newSMTPServerID = newUID();
+ await store.applyIncoming({
+ id: newSMTPServerID,
+ username: "username",
+ hostname: "hostname",
+ type: "smtp",
+ prefs: {
+ authMethod: 3,
+ port: 0,
+ description: "Second Outgoing Server",
+ socketType: 0,
+ },
+ isDefault: true,
+ });
+
+ Assert.equal(MailServices.smtp.servers.length, 2);
+
+ let newSMTPServer = MailServices.smtp.servers.find(
+ s => s.UID == newSMTPServerID
+ );
+ Assert.equal(newSMTPServer.username, "username");
+ Assert.equal(newSMTPServer.hostname, "hostname");
+ Assert.equal(newSMTPServer.description, "Second Outgoing Server");
+ Assert.equal(MailServices.smtp.defaultServer.key, newSMTPServer.key);
+
+ await store.applyIncoming({
+ id: smtpServer.UID,
+ username: "username",
+ hostname: "new.hostname",
+ type: "smtp",
+ prefs: {
+ authMethod: 3,
+ port: 0,
+ description: "New SMTP Server",
+ socketType: 0,
+ },
+ isDefault: true,
+ });
+
+ Assert.equal(smtpServer.description, "New SMTP Server");
+ Assert.equal(MailServices.smtp.defaultServer.key, smtpServer.key);
+
+ // TODO test update
+
+ await store.applyIncoming({
+ id: newSMTPServerID,
+ deleted: true,
+ });
+
+ Assert.equal(MailServices.smtp.servers.length, 1);
+ Assert.equal(MailServices.smtp.servers[0].key, smtpServer.key);
+ Assert.equal(MailServices.smtp.defaultServer.key, smtpServer.key);
+});
diff --git a/comm/mail/services/sync/test/unit/test_account_tracker.js b/comm/mail/services/sync/test/unit/test_account_tracker.js
new file mode 100644
index 0000000000..98a132b48f
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/test_account_tracker.js
@@ -0,0 +1,155 @@
+/* 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/. */
+
+do_get_profile();
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { AccountsEngine } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/accounts.sys.mjs"
+);
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+let engine, store, tracker;
+
+add_setup(async function () {
+ engine = new AccountsEngine(Service);
+ await engine.initialize();
+ store = engine._store;
+ tracker = engine._tracker;
+
+ Assert.equal(tracker.score, 0);
+ Assert.equal(tracker._isTracking, false);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ try {
+ // Ensure there is a local mail account...
+ MailServices.accounts.localFoldersServer;
+ } catch {
+ // ... if not, make one.
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ tracker.start();
+ Assert.equal(tracker._isTracking, true);
+});
+
+/**
+ * Test creating, changing, and deleting an account that should be synced.
+ */
+add_task(async function testAccount() {
+ Assert.equal(tracker.score, 0);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ let id = newUID();
+ info(id);
+ let newAccount = MailServices.accounts.createAccount();
+ newAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ "username",
+ "hostname",
+ "imap"
+ );
+ newAccount.incomingServer.UID = id;
+ newAccount.incomingServer.prettyName = "First Incoming Server";
+
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ tracker.resetScore();
+ Assert.equal(tracker.score, 0);
+
+ newAccount.incomingServer.prettyName = "Changed name";
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ MailServices.accounts.removeAccount(newAccount, true);
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+});
+
+/**
+ * Test the store methods on calendars. The tracker should ignore them.
+ */
+add_task(async function testIncomingChanges() {
+ let id = newUID();
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ username: "username",
+ hostname: "new.hostname",
+ type: "imap",
+ prefs: {
+ authMethod: 3,
+ biffMinutes: 10,
+ doBiff: true,
+ downloadOnBiff: false,
+ emptyTrashOnExit: false,
+ incomingDuplicateAction: 0,
+ limitOfflineMessageSize: false,
+ loginAtStartUp: false,
+ maxMessageSize: 50,
+ port: 143,
+ prettyName: "New IMAP Server",
+ socketType: Ci.nsMsgSocketType.plain,
+ },
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ username: "username",
+ hostname: "new.hostname",
+ type: "imap",
+ prefs: {
+ authMethod: 3,
+ biffMinutes: 10,
+ doBiff: true,
+ downloadOnBiff: false,
+ emptyTrashOnExit: false,
+ incomingDuplicateAction: 0,
+ limitOfflineMessageSize: false,
+ loginAtStartUp: false,
+ maxMessageSize: 50,
+ port: 993,
+ prettyName: "Changed IMAP Server",
+ socketType: Ci.nsMsgSocketType.SSL,
+ },
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ deleted: true,
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+});
diff --git a/comm/mail/services/sync/test/unit/test_addressBook_store.js b/comm/mail/services/sync/test/unit/test_addressBook_store.js
new file mode 100644
index 0000000000..d32b80eac6
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/test_addressBook_store.js
@@ -0,0 +1,130 @@
+/* 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/. */
+
+do_get_profile();
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { AddressBooksEngine, AddressBookRecord } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/addressBooks.sys.mjs"
+);
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+
+let engine, store, tracker;
+let cardDAVBook;
+
+// TODO test ldap books
+
+add_setup(async function () {
+ engine = new AddressBooksEngine(Service);
+ await engine.initialize();
+ store = engine._store;
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "Sync Address Book",
+ null,
+ MailServices.ab.CARDDAV_DIRECTORY_TYPE
+ );
+ cardDAVBook = MailServices.ab.getDirectoryFromId(dirPrefId);
+ cardDAVBook.setStringValue("carddav.url", "https://localhost:1234/a/book");
+});
+
+add_task(async function testGetAllIDs() {
+ Assert.deepEqual(await store.getAllIDs(), {
+ [cardDAVBook.UID]: true,
+ });
+});
+
+add_task(async function testItemExists() {
+ Assert.equal(await store.itemExists(cardDAVBook.UID), true);
+});
+
+add_task(async function testCreateRecord() {
+ let record = await store.createRecord(cardDAVBook.UID);
+ Assert.ok(record instanceof AddressBookRecord);
+ Assert.equal(record.id, cardDAVBook.UID);
+ Assert.equal(record.name, "Sync Address Book");
+ Assert.equal(record.type, MailServices.ab.CARDDAV_DIRECTORY_TYPE);
+ Assert.deepEqual(record.prefs, { url: "https://localhost:1234/a/book" });
+});
+
+add_task(async function testCreateDeletedRecord() {
+ let fakeID = "12345678-1234-1234-1234-123456789012";
+ let record = await store.createRecord(fakeID);
+ Assert.ok(record instanceof AddressBookRecord);
+ Assert.equal(record.id, fakeID);
+ Assert.equal(record.deleted, true);
+});
+
+add_task(async function testSyncRecords() {
+ Assert.equal(MailServices.ab.directories.length, 3);
+ PromiseTestUtils.expectUncaughtRejection(/Connection failure/);
+
+ let newID = newUID();
+ await store.applyIncoming({
+ id: newID,
+ name: "bar",
+ type: MailServices.ab.CARDDAV_DIRECTORY_TYPE,
+ prefs: {
+ url: "https://localhost/",
+ syncInterval: 0,
+ username: "username",
+ },
+ });
+ Services.obs.notifyObservers(null, "weave:service:sync:finish");
+
+ Assert.equal(MailServices.ab.directories.length, 4);
+ let newBook = MailServices.ab.getDirectoryFromUID(newID);
+ Assert.equal(newBook.dirName, "bar");
+ Assert.equal(newBook.dirType, MailServices.ab.CARDDAV_DIRECTORY_TYPE);
+ Assert.equal(
+ newBook.getStringValue("carddav.url", null),
+ "https://localhost/"
+ );
+ Assert.equal(newBook.getIntValue("carddav.syncinterval", null), 0);
+ Assert.equal(newBook.getStringValue("carddav.username", null), "username");
+
+ await store.applyIncoming({
+ id: newID,
+ name: "bar!",
+ type: MailServices.ab.CARDDAV_DIRECTORY_TYPE,
+ prefs: {
+ url: "https://localhost/",
+ syncInterval: 30,
+ username: "username@localhost",
+ },
+ });
+
+ Assert.equal(MailServices.ab.directories.length, 4);
+ newBook = MailServices.ab.getDirectoryFromUID(newID);
+ Assert.equal(newBook.dirName, "bar!");
+ Assert.equal(newBook.dirType, MailServices.ab.CARDDAV_DIRECTORY_TYPE);
+ Assert.equal(
+ newBook.getStringValue("carddav.url", null),
+ "https://localhost/"
+ );
+ Assert.equal(newBook.getIntValue("carddav.syncinterval", null), 30);
+ Assert.equal(
+ newBook.getStringValue("carddav.username", null),
+ "username@localhost"
+ );
+
+ await store.applyIncoming({
+ id: newID,
+ deleted: true,
+ });
+
+ Assert.equal(MailServices.ab.directories.length, 3);
+ newBook = MailServices.ab.getDirectoryFromUID(newID);
+ Assert.equal(newBook, null);
+});
diff --git a/comm/mail/services/sync/test/unit/test_addressBook_tracker.js b/comm/mail/services/sync/test/unit/test_addressBook_tracker.js
new file mode 100644
index 0000000000..9831252fb6
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/test_addressBook_tracker.js
@@ -0,0 +1,167 @@
+/* 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/. */
+
+do_get_profile();
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { AddressBooksEngine } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/addressBooks.sys.mjs"
+);
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+
+let engine, store, tracker;
+
+add_setup(async function () {
+ engine = new AddressBooksEngine(Service);
+ await engine.initialize();
+ store = engine._store;
+ tracker = engine._tracker;
+
+ Assert.equal(tracker.score, 0);
+ Assert.equal(tracker._isTracking, false);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ tracker.start();
+ Assert.equal(tracker._isTracking, true);
+});
+
+/**
+ * Test creating, changing, and deleting an address book that should be synced.
+ */
+add_task(async function testNetworkAddressBook() {
+ Assert.equal(tracker.score, 0);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ let id = newUID();
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "Sync Address Book",
+ null,
+ MailServices.ab.CARDDAV_DIRECTORY_TYPE,
+ id
+ );
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ tracker.resetScore();
+ Assert.equal(tracker.score, 0);
+
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+ book.dirName = "changed name";
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ book.setIntValue("carddav.syncinterval", 0);
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ book.setStringValue("carddav.url", "https://localhost/");
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ let deletedPromise = TestUtils.topicObserved("addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(book.URI);
+ await deletedPromise;
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+});
+
+/**
+ * Test a local address book. This shouldn't affect the tracker at all.
+ */
+add_task(async function testStorageAddressBook() {
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "Sync Address Book",
+ null,
+ MailServices.ab.JS_DIRECTORY_TYPE
+ );
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+ book.dirName = "changed name";
+ book.setBoolValue("readOnly", true);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ let deletedPromise = TestUtils.topicObserved("addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(book.URI);
+ await deletedPromise;
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+});
+
+/**
+ * Test the store methods on address books. The tracker should ignore them.
+ */
+add_task(async function testIncomingChanges() {
+ PromiseTestUtils.expectUncaughtRejection(/Connection failure/);
+
+ let id = newUID();
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ name: "New Book",
+ type: MailServices.ab.CARDDAV_DIRECTORY_TYPE,
+ prefs: {
+ url: "https://localhost/",
+ syncInterval: 0,
+ username: "username",
+ },
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ name: "New Book (changed)!",
+ type: MailServices.ab.CARDDAV_DIRECTORY_TYPE,
+ prefs: {
+ url: "https://localhost/",
+ syncInterval: 30,
+ username: "username@localhost",
+ },
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ deleted: true,
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+});
diff --git a/comm/mail/services/sync/test/unit/test_calendar_store.js b/comm/mail/services/sync/test/unit/test_calendar_store.js
new file mode 100644
index 0000000000..41dd01c22a
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/test_calendar_store.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/. */
+
+do_get_profile();
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalendarsEngine, CalendarRecord } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/calendars.sys.mjs"
+);
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+
+let engine, store, tracker;
+let calDAVCalendar, icsCalendar, fileICSCalendar, storageCalendar;
+
+// TODO test caldav calendars
+
+add_setup(async function () {
+ await new Promise(resolve => cal.manager.startup({ onResult: resolve }));
+
+ engine = new CalendarsEngine(Service);
+ await engine.initialize();
+ store = engine._store;
+
+ calDAVCalendar = cal.manager.createCalendar(
+ "caldav",
+ Services.io.newURI("https://localhost/caldav")
+ );
+ calDAVCalendar.name = "CalDAV Calendar";
+ cal.manager.registerCalendar(calDAVCalendar);
+
+ icsCalendar = cal.manager.createCalendar(
+ "ics",
+ Services.io.newURI("https://localhost/ics")
+ );
+ icsCalendar.name = "ICS Calendar";
+ cal.manager.registerCalendar(icsCalendar);
+
+ fileICSCalendar = cal.manager.createCalendar(
+ "ics",
+ Services.io.newURI("file:///home/user/test.ics")
+ );
+ fileICSCalendar.name = "File ICS Calendar";
+ cal.manager.registerCalendar(fileICSCalendar);
+
+ storageCalendar = cal.manager.createCalendar(
+ "storage",
+ Services.io.newURI("moz-storage-calendar://")
+ );
+ storageCalendar.name = "Storage Calendar";
+ cal.manager.registerCalendar(storageCalendar);
+});
+
+add_task(async function testGetAllIDs() {
+ Assert.deepEqual(await store.getAllIDs(), {
+ [calDAVCalendar.id]: true,
+ [icsCalendar.id]: true,
+ });
+});
+
+add_task(async function testItemExists() {
+ Assert.equal(await store.itemExists(calDAVCalendar.id), true);
+ Assert.equal(await store.itemExists(icsCalendar.id), true);
+});
+
+add_task(async function testCreateCalDAVRecord() {
+ let record = await store.createRecord(calDAVCalendar.id);
+ Assert.ok(record instanceof CalendarRecord);
+ Assert.equal(record.id, calDAVCalendar.id);
+ Assert.equal(record.name, "CalDAV Calendar");
+ Assert.equal(record.type, "caldav");
+ Assert.equal(record.uri, "https://localhost/caldav");
+ Assert.deepEqual(record.prefs, {});
+});
+
+add_task(async function testCreateICSRecord() {
+ let record = await store.createRecord(icsCalendar.id);
+ Assert.ok(record instanceof CalendarRecord);
+ Assert.equal(record.id, icsCalendar.id);
+ Assert.equal(record.name, "ICS Calendar");
+ Assert.equal(record.type, "ics");
+ Assert.equal(record.uri, "https://localhost/ics");
+ Assert.deepEqual(record.prefs, {});
+});
+
+add_task(async function testCreateDeletedRecord() {
+ let fakeID = "12345678-1234-1234-1234-123456789012";
+ let record = await store.createRecord(fakeID);
+ Assert.ok(record instanceof CalendarRecord);
+ Assert.equal(record.id, fakeID);
+ Assert.equal(record.deleted, true);
+});
+
+add_task(async function testSyncRecords() {
+ // Sync a new calendar.
+
+ let newID = newUID();
+ await store.applyIncoming({
+ id: newID,
+ name: "New ICS Calendar",
+ type: "ics",
+ uri: "https://localhost/newICS",
+ prefs: {
+ color: "#abcdef",
+ },
+ });
+
+ Assert.equal(cal.manager.getCalendars().length, 5);
+ let calendar = cal.manager.getCalendarById(newID);
+ Assert.equal(calendar.id, newID);
+ Assert.equal(calendar.name, "New ICS Calendar");
+ Assert.equal(calendar.type, "ics");
+ Assert.equal(calendar.uri.spec, "https://localhost/newICS");
+ Assert.equal(calendar.getProperty("color"), "#abcdef");
+
+ // Change the name and some properties.
+
+ await store.applyIncoming({
+ id: newID,
+ name: "Changed ICS Calendar",
+ type: "ics",
+ uri: "https://localhost/changedICS",
+ prefs: {
+ color: "#123456",
+ readOnly: true,
+ },
+ });
+
+ Assert.equal(cal.manager.getCalendars().length, 5);
+ calendar = cal.manager.getCalendarById(newID);
+ Assert.equal(calendar.name, "Changed ICS Calendar");
+ Assert.equal(calendar.type, "ics");
+ Assert.equal(calendar.uri.spec, "https://localhost/changedICS");
+ Assert.equal(calendar.getProperty("color"), "#123456");
+ Assert.equal(calendar.getProperty("readOnly"), true);
+
+ // Change the calendar type. This should fail.
+
+ await Assert.rejects(
+ store.applyIncoming({
+ id: newID,
+ name: "New CalDAV Calendar",
+ type: "caldav",
+ uri: "https://localhost/caldav",
+ prefs: {
+ color: "#123456",
+ readOnly: true,
+ },
+ }),
+ /Refusing to change calendar type/
+ );
+
+ // Enable the cache. This should fail.
+
+ await Assert.rejects(
+ store.applyIncoming({
+ id: newID,
+ name: "Changed ICS Calendar",
+ type: "ics",
+ uri: "https://localhost/changedICS",
+ prefs: {
+ cacheEnabled: true,
+ color: "#123456",
+ readOnly: true,
+ },
+ }),
+ /Refusing to change the cache setting/
+ );
+
+ await store.applyIncoming({
+ id: newID,
+ deleted: true,
+ });
+
+ Assert.equal(cal.manager.getCalendars().length, 4);
+ calendar = cal.manager.getCalendarById(newID);
+ Assert.equal(calendar, null);
+});
diff --git a/comm/mail/services/sync/test/unit/test_calendar_tracker.js b/comm/mail/services/sync/test/unit/test_calendar_tracker.js
new file mode 100644
index 0000000000..042f5782aa
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/test_calendar_tracker.js
@@ -0,0 +1,154 @@
+/* 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/. */
+
+do_get_profile();
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalendarsEngine } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/calendars.sys.mjs"
+);
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+
+let engine, store, tracker;
+
+add_setup(async function () {
+ await new Promise(resolve => cal.manager.startup({ onResult: resolve }));
+ cal.manager.getCalendars();
+
+ engine = new CalendarsEngine(Service);
+ await engine.initialize();
+ store = engine._store;
+ tracker = engine._tracker;
+
+ Assert.equal(tracker.score, 0);
+ Assert.equal(tracker._isTracking, false);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ tracker.start();
+ Assert.equal(tracker._isTracking, true);
+});
+
+/**
+ * Test creating, changing, and deleting a calendar that should be synced.
+ */
+add_task(async function testNetworkCalendar() {
+ Assert.equal(tracker.score, 0);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ let id = newUID();
+ let calendar = cal.manager.createCalendar(
+ "ics",
+ Services.io.newURI("https://localhost:1234/a/calendar")
+ );
+ calendar.name = "Sync Calendar";
+ calendar.id = id;
+ cal.manager.registerCalendar(calendar);
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ tracker.resetScore();
+ Assert.equal(tracker.score, 0);
+
+ calendar.name = "changed name";
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ calendar.setProperty("color", "#123456");
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ calendar.setProperty("calendar-main-in-composite", true);
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ cal.manager.unregisterCalendar(calendar);
+ cal.manager.removeCalendar(calendar);
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+});
+
+/**
+ * Test a storage calendar. This shouldn't affect the tracker at all.
+ */
+add_task(async function testStorageCalendar() {
+ let storageCalendar = cal.manager.createCalendar(
+ "storage",
+ Services.io.newURI("moz-storage-calendar://")
+ );
+ storageCalendar.name = "Sync Calendar";
+ storageCalendar.id = newUID();
+ cal.manager.registerCalendar(storageCalendar);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ storageCalendar.name = "changed name";
+ storageCalendar.setProperty("color", "#123456");
+ storageCalendar.setProperty("calendar-main-in-composite", true);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ cal.manager.unregisterCalendar(storageCalendar);
+ cal.manager.removeCalendar(storageCalendar);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+});
+
+/**
+ * Test the store methods on calendars. The tracker should ignore them.
+ */
+add_task(async function testIncomingChanges() {
+ let id = newUID();
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ name: "New Calendar",
+ type: "ics",
+ uri: "https://localhost/ics",
+ prefs: {},
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ name: "New Calendar (changed)",
+ type: "ics",
+ uri: "https://localhost/ics",
+ prefs: {},
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ deleted: true,
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+});
diff --git a/comm/mail/services/sync/test/unit/test_identity_store.js b/comm/mail/services/sync/test/unit/test_identity_store.js
new file mode 100644
index 0000000000..42d4a0f356
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/test_identity_store.js
@@ -0,0 +1,222 @@
+/* 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/. */
+
+do_get_profile();
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { IdentitiesEngine, IdentityRecord } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/identities.sys.mjs"
+);
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+let engine, store, tracker;
+let accountA, smtpServerA, identityA;
+let accountB, identityB;
+
+add_setup(async function () {
+ engine = new IdentitiesEngine(Service);
+ await engine.initialize();
+ store = engine._store;
+
+ try {
+ // Ensure there is a local mail account...
+ MailServices.accounts.localFoldersServer;
+ } catch {
+ // ... if not, make one.
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ // Mail account and identity.
+
+ accountA = MailServices.accounts.createAccount();
+ accountA.incomingServer = MailServices.accounts.createIncomingServer(
+ "username",
+ "hostname",
+ "imap"
+ );
+ smtpServerA = MailServices.smtp.createServer();
+
+ identityA = MailServices.accounts.createIdentity();
+ identityA.email = "username@hostname";
+ identityA.fullName = "User";
+ identityA.smtpServerKey = smtpServerA.key;
+ accountA.addIdentity(identityA);
+
+ Assert.ok(identityA.UID);
+ Assert.equal(
+ Services.prefs.getStringPref(`mail.identity.${identityA.key}.uid`),
+ identityA.UID
+ );
+
+ // NNTP account and identity. NNTP isn't currently supported, so this test
+ // will prove the identity isn't synced.
+
+ accountB = MailServices.accounts.createAccount();
+ accountB.incomingServer = MailServices.accounts.createIncomingServer(
+ "username",
+ "hostname",
+ "nntp"
+ );
+
+ identityB = MailServices.accounts.createIdentity();
+ identityB.email = "username@hostname";
+ identityB.fullName = "user";
+ accountB.addIdentity(identityB);
+
+ // Sanity check.
+
+ Assert.equal(MailServices.accounts.allIdentities.length, 2);
+ Assert.equal(accountA.identities.length, 1);
+ Assert.equal(accountA.defaultIdentity.key, identityA.key);
+ Assert.equal(accountB.identities.length, 1);
+ Assert.equal(accountB.defaultIdentity.key, identityB.key);
+});
+
+add_task(async function testGetAllIDs() {
+ Assert.deepEqual(await store.getAllIDs(), {
+ [identityA.UID]: true,
+ });
+});
+
+add_task(async function testItemExists() {
+ Assert.equal(await store.itemExists(identityA.UID), true);
+});
+
+add_task(async function testCreateRecord() {
+ let record = await store.createRecord(identityA.UID);
+ Assert.ok(record instanceof IdentityRecord);
+ Assert.equal(record.id, identityA.UID);
+ Assert.deepEqual(record.accounts, [
+ {
+ id: accountA.incomingServer.UID,
+ isDefault: true,
+ },
+ ]);
+ Assert.deepEqual(record.prefs, {
+ attachSignature: false,
+ attachVCard: false,
+ autoQuote: true,
+ catchAll: false,
+ catchAllHint: null,
+ composeHtml: true,
+ email: "username@hostname",
+ escapedVCard: null,
+ fullName: "User",
+ htmlSigFormat: false,
+ htmlSigText: "",
+ label: "",
+ organization: "",
+ replyOnTop: 0,
+ replyTo: null,
+ sigBottom: true,
+ sigOnForward: false,
+ sigOnReply: true,
+ });
+ Assert.equal(record.smtpID, smtpServerA.UID);
+});
+
+add_task(async function testCreateDeletedRecord() {
+ let fakeID = "12345678-1234-1234-1234-123456789012";
+ let record = await store.createRecord(fakeID);
+ Assert.ok(record instanceof IdentityRecord);
+ Assert.equal(record.id, fakeID);
+ Assert.equal(record.deleted, true);
+});
+
+add_task(async function testSyncRecords() {
+ let newIdentityID = newUID();
+ await store.applyIncoming({
+ id: newIdentityID,
+ accounts: [
+ {
+ id: accountA.incomingServer.UID,
+ isDefault: false,
+ },
+ ],
+ prefs: {
+ attachSignature: false,
+ attachVCard: false,
+ autoQuote: true,
+ catchAll: false,
+ catchAllHint: null,
+ composeHtml: true,
+ email: "username@hostname",
+ escapedVCard: null,
+ fullName: "User",
+ htmlSigFormat: false,
+ htmlSigText: "",
+ label: "",
+ organization: "",
+ replyOnTop: 0,
+ replyTo: null,
+ sigBottom: true,
+ sigOnForward: false,
+ sigOnReply: true,
+ },
+ smtpID: smtpServerA.UID,
+ });
+
+ Assert.equal(MailServices.accounts.allIdentities.length, 3);
+ Assert.equal(accountA.identities.length, 2);
+
+ let newIdentity = MailServices.accounts.allIdentities.find(
+ i => i.UID == newIdentityID
+ );
+ Assert.equal(newIdentity.email, "username@hostname");
+ Assert.equal(newIdentity.fullName, "User");
+ Assert.equal(newIdentity.smtpServerKey, smtpServerA.key);
+ Assert.equal(accountA.defaultIdentity.key, identityA.key);
+
+ await store.applyIncoming({
+ id: newIdentityID,
+ accounts: [
+ {
+ id: accountA.incomingServer.UID,
+ isDefault: true,
+ },
+ ],
+ prefs: {
+ attachSignature: false,
+ attachVCard: false,
+ autoQuote: true,
+ catchAll: false,
+ catchAllHint: null,
+ composeHtml: true,
+ email: "username@hostname",
+ escapedVCard: null,
+ fullName: "User (changed)",
+ htmlSigFormat: false,
+ htmlSigText: "",
+ label: "",
+ organization: "",
+ replyOnTop: 0,
+ replyTo: null,
+ sigBottom: true,
+ sigOnForward: false,
+ sigOnReply: true,
+ },
+ smtpID: smtpServerA.UID,
+ });
+
+ Assert.equal(newIdentity.fullName, "User (changed)");
+ Assert.equal(accountA.defaultIdentity.key, newIdentity.key);
+
+ await store.applyIncoming({
+ id: newIdentityID,
+ deleted: true,
+ });
+
+ Assert.equal(MailServices.accounts.allIdentities.length, 2);
+ Assert.equal(accountA.identities.length, 1);
+ Assert.equal(accountA.defaultIdentity.key, identityA.key);
+ Assert.equal(accountB.identities.length, 1);
+ Assert.equal(accountB.defaultIdentity.key, identityB.key);
+});
diff --git a/comm/mail/services/sync/test/unit/test_identity_tracker.js b/comm/mail/services/sync/test/unit/test_identity_tracker.js
new file mode 100644
index 0000000000..eda39a1794
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/test_identity_tracker.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/. */
+
+do_get_profile();
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { IdentitiesEngine } = ChromeUtils.importESModule(
+ "resource://services-sync/engines/identities.sys.mjs"
+);
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+let engine, store, tracker;
+let accountA, smtpServerA, smtpServerB, identityA, identityB;
+
+add_setup(async function () {
+ engine = new IdentitiesEngine(Service);
+ await engine.initialize();
+ store = engine._store;
+ tracker = engine._tracker;
+
+ Assert.equal(tracker.score, 0);
+ Assert.equal(tracker._isTracking, false);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ try {
+ // Ensure there is a local mail account...
+ MailServices.accounts.localFoldersServer;
+ } catch {
+ // ... if not, make one.
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ accountA = MailServices.accounts.createAccount();
+ accountA.incomingServer = MailServices.accounts.createIncomingServer(
+ "username",
+ "hostname",
+ "imap"
+ );
+ smtpServerA = MailServices.smtp.createServer();
+ smtpServerB = MailServices.smtp.createServer();
+
+ identityA = MailServices.accounts.createIdentity();
+ identityA.email = "identity.a@hostname";
+ identityA.fullName = "Identity A";
+ identityA.smtpServerKey = smtpServerA.key;
+ accountA.addIdentity(identityA);
+
+ identityB = MailServices.accounts.createIdentity();
+ identityB.email = "identity.b@hostname";
+ identityB.fullName = "Identity B";
+ identityB.smtpServerKey = smtpServerB.key;
+ accountA.addIdentity(identityB);
+
+ Assert.equal(MailServices.accounts.allIdentities.length, 2);
+ Assert.equal(accountA.identities.length, 2);
+ Assert.equal(accountA.defaultIdentity.key, identityA.key);
+
+ tracker.start();
+ Assert.equal(tracker._isTracking, true);
+});
+
+/**
+ * Test creating, changing, and deleting an identity that should be synced.
+ */
+add_task(async function testIdentity() {
+ Assert.equal(tracker.score, 0);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ let id = newUID();
+ let newIdentity = MailServices.accounts.createIdentity();
+ newIdentity.UID = id;
+ newIdentity.email = "username@hostname";
+ newIdentity.fullName = "User";
+ newIdentity.smtpServerKey = smtpServerA.key;
+ accountA.addIdentity(newIdentity);
+
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ tracker.resetScore();
+ Assert.equal(tracker.score, 0);
+
+ newIdentity.fullName = "Changed name";
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ newIdentity.label = "Changed label";
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ newIdentity.smtpServerKey = smtpServerB.key;
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ newIdentity.smtpServerKey = null;
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ accountA.removeIdentity(newIdentity);
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), { [id]: 0 });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+});
+
+/**
+ * Test swapping the default identity of an account.
+ */
+add_task(async function testDefaultIdentityChange() {
+ Assert.equal(tracker.score, 0);
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+
+ accountA.defaultIdentity = identityB;
+
+ Assert.equal(tracker.score, 301);
+ Assert.deepEqual(await tracker.getChangedIDs(), {
+ [identityA.UID]: 0,
+ [identityB.UID]: 0,
+ });
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+});
+
+/**
+ * Test the store methods on identites. The tracker should ignore them.
+ */
+add_task(async function testIncomingChanges() {
+ let id = newUID();
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ accounts: [
+ {
+ id: accountA.UID,
+ isDefault: true,
+ },
+ ],
+ prefs: {
+ attachSignature: false,
+ attachVCard: false,
+ autoQuote: true,
+ catchAll: false,
+ catchAllHint: null,
+ composeHtml: true,
+ email: "username@hostname",
+ escapedVCard: null,
+ fullName: "User",
+ htmlSigFormat: false,
+ htmlSigText: "",
+ label: "",
+ organization: "",
+ replyOnTop: 0,
+ replyTo: null,
+ sigBottom: true,
+ sigOnForward: false,
+ sigOnReply: true,
+ },
+ smtpID: smtpServerA.UID,
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ tracker.clearChangedIDs();
+ tracker.resetScore();
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ accounts: [
+ {
+ id: accountA.UID,
+ isDefault: true,
+ },
+ ],
+ prefs: {
+ attachSignature: false,
+ attachVCard: false,
+ autoQuote: true,
+ catchAll: false,
+ catchAllHint: null,
+ composeHtml: true,
+ email: "username@hostname",
+ escapedVCard: null,
+ fullName: "User (changed)",
+ htmlSigFormat: false,
+ htmlSigText: "",
+ label: "",
+ organization: "",
+ replyOnTop: 0,
+ replyTo: null,
+ sigBottom: true,
+ sigOnForward: false,
+ sigOnReply: true,
+ },
+ smtpID: smtpServerA.UID,
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+
+ tracker.ignoreAll = true;
+ await store.applyIncoming({
+ id,
+ deleted: true,
+ });
+ tracker.ignoreAll = false;
+
+ Assert.deepEqual(await tracker.getChangedIDs(), {});
+ Assert.equal(tracker.score, 0);
+});
diff --git a/comm/mail/services/sync/test/unit/xpcshell.ini b/comm/mail/services/sync/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..a8de1816a6
--- /dev/null
+++ b/comm/mail/services/sync/test/unit/xpcshell.ini
@@ -0,0 +1,11 @@
+[default]
+head = head.js
+
+[test_account_store.js]
+[test_account_tracker.js]
+[test_addressBook_store.js]
+[test_addressBook_tracker.js]
+[test_calendar_store.js]
+[test_calendar_tracker.js]
+[test_identity_store.js]
+[test_identity_tracker.js]
diff --git a/comm/mail/test/browser/account/browser-clear.ini b/comm/mail/test/browser/account/browser-clear.ini
new file mode 100644
index 0000000000..69678d22f5
--- /dev/null
+++ b/comm/mail/test/browser/account/browser-clear.ini
@@ -0,0 +1,21 @@
+[DEFAULT]
+prefs =
+ mail.account.account1.server=
+ mail.account.account2.identities=
+ mail.account.account2.server=
+ mail.accountmanager.accounts=
+ mail.accountmanager.defaultaccount=
+ mail.accountmanager.localfoldersserver=
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.auto_config.addons_url=about:blank
+ mailnews.auto_config_url=about:blank
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+ chat.otr.enable=false
+skip-if = debug
+subsuite = thunderbird
+
+[browser_accountSetupTab.js]
diff --git a/comm/mail/test/browser/account/browser.ini b/comm/mail/test/browser/account/browser.ini
new file mode 100644
index 0000000000..bd5c4b5652
--- /dev/null
+++ b/comm/mail/test/browser/account/browser.ini
@@ -0,0 +1,67 @@
+[DEFAULT]
+head = head.js
+prefs =
+ calendar.debug.log=true
+ carddav.setup.loglevel=Debug
+ carddav.sync.loglevel=Debug
+ chat.otr.enable=false
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+ mail.account.account1.server=server1
+ mail.account.account2.identities=id1,id2
+ mail.account.account2.server=server2
+ mail.accountmanager.accounts=account1,account2
+ mail.accountmanager.defaultaccount=account2
+ mail.accountmanager.localfoldersserver=server1
+ mail.identity.id1.fullName=Tinderbox
+ mail.identity.id1.htmlSigFormat=false
+ mail.identity.id1.smtpServer=smtp1
+ mail.identity.id1.useremail=tinderbox@foo.invalid
+ mail.identity.id1.valid=true
+ mail.identity.id2.fullName=Tinderboxpushlog
+ mail.identity.id2.htmlSigFormat=true
+ mail.identity.id2.smtpServer=smtp1
+ mail.identity.id2.useremail=tinderboxpushlog@foo.invalid
+ mail.identity.id2.valid=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server1.type=none
+ mail.server.server1.userName=nobody
+ mail.server.server2.check_new_mail=false
+ mail.server.server2.directory-rel=[ProfD]Mail/tinderbox
+ mail.server.server2.download_on_biff=true
+ mail.server.server2.hostname=tinderbox123
+ mail.server.server2.login_at_startup=false
+ mail.server.server2.name=tinderbox@foo.invalid
+ mail.server.server2.type=pop3
+ mail.server.server2.userName=tinderbox
+ mail.server.server2.whiteListAbURI=
+ mail.shell.checkDefaultClient=false
+ mail.smtp.defaultserver=smtp1
+ mail.smtpserver.smtp1.hostname=tinderbox123
+ mail.smtpserver.smtp1.username=tinderbox
+ mail.smtpservers=smtp1
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.auto_config.addons_url=about:blank
+ mailnews.auto_config_url=about:blank
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+skip-if = debug
+subsuite = thunderbird
+support-files = xml/**
+
+[browser_abWhitelist.js]
+[browser_accountHub.js]
+[browser_accountOrder.js]
+[browser_accountTelemetry.js]
+[browser_actions.js]
+[browser_archiveOptions.js]
+[browser_deletion.js]
+[browser_mailAccountSetupWizard.js]
+[browser_manageIdentities.js]
+[browser_portSetting.js]
+skip-if = os == "win"
+[browser_retestConfig.js]
+[browser_settingsInfrastructure.js]
+[browser_tree.js]
+[browser_values.js]
diff --git a/comm/mail/test/browser/account/browser_abWhitelist.js b/comm/mail/test/browser/account/browser_abWhitelist.js
new file mode 100644
index 0000000000..be81e317a5
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_abWhitelist.js
@@ -0,0 +1,164 @@
+/* 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 { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { click_account_tree_row, get_account_tree_row, open_advanced_settings } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+ );
+var { FAKE_SERVER_HOSTNAME } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gOldWhiteList = null;
+var gKeyString = null;
+
+var gAccount = null;
+
+add_setup(function () {
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ gAccount = MailServices.accounts.FindAccountForServer(server);
+ let serverKey = server.key;
+
+ gKeyString = "mail.server." + serverKey + ".whiteListAbURI";
+ gOldWhiteList = Services.prefs.getCharPref(gKeyString);
+ Services.prefs.setCharPref(gKeyString, "");
+});
+
+registerCleanupFunction(function () {
+ Services.prefs.setCharPref(gKeyString, gOldWhiteList);
+});
+
+/**
+ * First, test that when we initially load the account manager, that
+ * we're not whitelisting any address books. Then, we'll check all
+ * address books and save.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_whitelist_init_and_save(tab) {
+ // Ok, the advanced settings window is open. Let's choose
+ // the junkmail settings.
+ let accountRow = get_account_tree_row(gAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ let doc =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ // At this point, we shouldn't have anything checked, but we should have
+ // the two default address books (Personal and Collected) displayed
+ let list = doc.getElementById("whiteListAbURI");
+ Assert.equal(
+ 2,
+ list.getRowCount(),
+ "There was an unexpected number of address books"
+ );
+
+ // Now we'll check both address books
+ for (let i = 0; i < list.getRowCount(); i++) {
+ let abNode = list.getItemAtIndex(i);
+ EventUtils.synthesizeMouseAtCenter(
+ abNode.firstElementChild,
+ { clickCount: 1 },
+ abNode.firstElementChild.ownerGlobal
+ );
+ }
+}
+
+/**
+ * Next, we'll make sure that the address books we checked in
+ * subtest_check_whitelist_init_and_save were properly saved.
+ * Then, we'll clear the address books and save.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_whitelist_load_and_clear(tab) {
+ let accountRow = get_account_tree_row(gAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ let doc =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+ let list = doc.getElementById("whiteListAbURI");
+ let whiteListURIs = Services.prefs.getCharPref(gKeyString).split(" ");
+
+ for (let i = 0; i < list.getRowCount(); i++) {
+ let abNode = list.getItemAtIndex(i);
+ Assert.equal(
+ true,
+ abNode.firstElementChild.checked,
+ "Should have been checked"
+ );
+ // Also ensure that the address book URI was properly saved in the
+ // prefs
+ Assert.ok(whiteListURIs.includes(abNode.getAttribute("value")));
+ // Now un-check that address book
+ EventUtils.synthesizeMouseAtCenter(
+ abNode.firstElementChild,
+ { clickCount: 1 },
+ abNode.firstElementChild.ownerGlobal
+ );
+ }
+}
+
+/**
+ * Finally, we'll make sure that the address books we cleared
+ * were actually cleared.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_whitelist_load_cleared(tab) {
+ let accountRow = get_account_tree_row(gAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ let doc =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+ let list = doc.getElementById("whiteListAbURI");
+ let whiteListURIs = "";
+
+ try {
+ whiteListURIs = Services.prefs.getCharPref(gKeyString);
+ // We should have failed here, because the pref should have been cleared
+ // out.
+ throw Error(
+ "The whitelist preference for this server wasn't properly cleared."
+ );
+ } catch (e) {}
+
+ for (let i = 0; i < list.getRowCount(); i++) {
+ let abNode = list.getItemAtIndex(i);
+ Assert.equal(
+ false,
+ abNode.firstElementChild.checked,
+ "Should not have been checked"
+ );
+ // Also ensure that the address book URI was properly cleared in the
+ // prefs
+ Assert.ok(!whiteListURIs.includes(abNode.getAttribute("value")));
+ }
+}
+
+add_task(async function test_address_book_whitelist() {
+ await open_advanced_settings(subtest_check_whitelist_init_and_save);
+ await open_advanced_settings(subtest_check_whitelist_load_and_clear);
+ await open_advanced_settings(subtest_check_whitelist_load_cleared);
+});
diff --git a/comm/mail/test/browser/account/browser_accountHub.js b/comm/mail/test/browser/account/browser_accountHub.js
new file mode 100644
index 0000000000..8fc459a666
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_accountHub.js
@@ -0,0 +1,54 @@
+/* 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";
+
+// TODO: Defer this for when the account hub replaces the account setup tab.
+// add_task(async function test_account_hub_opening_at_startup() {});
+
+add_task(async function test_account_hub_opening() {
+ // TODO: Use an actual button once it's implemented in the UI.
+ // Open the dialog.
+ await window.openAccountHub();
+
+ const hub = document.querySelector("account-hub-container");
+ await TestUtils.waitForCondition(
+ () => hub.modal,
+ "The dialog element was created"
+ );
+
+ const dialog = hub.shadowRoot.querySelector(".account-hub-dialog");
+ Assert.ok(dialog.open, "The dialog element was opened");
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, window);
+ await TestUtils.waitForCondition(
+ () => !dialog.open,
+ "The dialog element was closed"
+ );
+
+ // Open the dialog again.
+ await window.openAccountHub();
+ Assert.ok(dialog.open, "The dialog element was opened");
+
+ // We already have a tinderbox account, so the default header should be
+ // visible and the welcome header should be hidden.
+ Assert.ok(
+ !hub.shadowRoot.querySelector("#defaultHeader").hidden,
+ "The #defaultHeader is visible"
+ );
+ Assert.ok(
+ hub.shadowRoot.querySelector("#welcomeHeader").hidden,
+ "The #welcomeHeader is hidden"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ hub.shadowRoot.querySelector("#closeButton"),
+ {},
+ window
+ );
+ await TestUtils.waitForCondition(
+ () => !dialog.open,
+ "The dialog element was closed"
+ );
+});
diff --git a/comm/mail/test/browser/account/browser_accountOrder.js b/comm/mail/test/browser/account/browser_accountOrder.js
new file mode 100644
index 0000000000..44ec226388
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_accountOrder.js
@@ -0,0 +1,91 @@
+/* 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 test checks proper operation of the account ordering functionality in the Account manager.
+ */
+
+"use strict";
+
+var { click_account_tree_row, get_account_tree_row, open_advanced_settings } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+ );
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var gPopAccount, gOriginalAccountCount;
+
+add_setup(function () {
+ // There may be pre-existing accounts from other tests.
+ gOriginalAccountCount = MailServices.accounts.allServers.length;
+
+ // Create a POP server
+ let popServer = MailServices.accounts
+ .createIncomingServer("nobody", "foo.invalid", "pop3")
+ .QueryInterface(Ci.nsIPop3IncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@foo.invalid";
+
+ gPopAccount = MailServices.accounts.createAccount();
+ gPopAccount.incomingServer = popServer;
+ gPopAccount.addIdentity(identity);
+
+ // Now there should be one more account.
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ gOriginalAccountCount + 1
+ );
+});
+
+registerCleanupFunction(function () {
+ if (gPopAccount) {
+ // Remove our test account to leave the profile clean.
+ MailServices.accounts.removeAccount(gPopAccount);
+ gPopAccount = null;
+ }
+ // There should be only the original accounts left.
+ Assert.equal(MailServices.accounts.allServers.length, gOriginalAccountCount);
+});
+
+add_task(async function test_account_open_state() {
+ await open_advanced_settings(async function (tab) {
+ await subtest_check_account_order(tab);
+ });
+});
+
+/**
+ * Check the order of the accounts.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+async function subtest_check_account_order(tab) {
+ let accountRow = get_account_tree_row(gPopAccount.key, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ let prevAccountList = MailServices.accounts.accounts.map(
+ account => account.key
+ );
+
+ // Moving the account up to reorder.
+ EventUtils.synthesizeKey("VK_UP", { altKey: true });
+ await new Promise(resolve => setTimeout(resolve));
+ let curAccountList = MailServices.accounts.accounts.map(
+ account => account.key
+ );
+ Assert.notEqual(curAccountList.join(), prevAccountList.join());
+
+ // Moving the account down, back to the starting position.
+ EventUtils.synthesizeKey("VK_DOWN", { altKey: true });
+ await new Promise(resolve => setTimeout(resolve));
+ curAccountList = MailServices.accounts.accounts.map(account => account.key);
+ Assert.equal(curAccountList.join(), prevAccountList.join());
+}
diff --git a/comm/mail/test/browser/account/browser_accountSetupTab.js b/comm/mail/test/browser/account/browser_accountSetupTab.js
new file mode 100644
index 0000000000..4fe6b74869
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_accountSetupTab.js
@@ -0,0 +1,96 @@
+/* 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 { openAccountSetup } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Test the ability of dismissing the account setup without triggering the
+ * generation of a local folders account nor the update of the mail UI.
+ */
+add_task(async function test_use_thunderbird_without_email() {
+ // Delete all accounts to start clean.
+ for (let account of MailServices.accounts.accounts) {
+ MailServices.accounts.removeAccount(account, true);
+ }
+
+ // Confirm that we don't have any account in our test run.
+ Assert.equal(
+ MailServices.accounts.accounts.length,
+ 0,
+ "No account currently configured"
+ );
+
+ let spacesToolbar = document.getElementById("spacesToolbar");
+ Assert.ok(spacesToolbar, "The spaces toolbar exists");
+
+ let spacesVisiblePromise = BrowserTestUtils.waitForCondition(
+ () => !spacesToolbar.hidden,
+ "The spaces toolbar is visible"
+ );
+
+ // Get the current tab, which should be the account setup tab.
+ let tab = mc.window.document.getElementById("tabmail").selectedTab;
+ Assert.equal(tab.browser.currentURI?.spec, "about:accountsetup");
+
+ let tabDocument = tab.browser.contentWindow.document;
+
+ let closeButton = tabDocument.getElementById("cancelButton");
+ closeButton.scrollIntoView();
+
+ // Close the account setup tab by clicking on the Cancel button.
+ EventUtils.synthesizeMouseAtCenter(
+ closeButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // Confirm the exit dialog is visible.
+ Assert.ok(tabDocument.getElementById("confirmExitDialog").open);
+
+ // Check the checkbox and close the dialog.
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("useWithoutAccount"),
+ {},
+ tab.browser.contentWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("exitDialogConfirmButton"),
+ {},
+ tab.browser.contentWindow
+ );
+
+ // We should now have switched to the main mail tab.
+ Assert.equal(
+ mc.window.document.getElementById("tabmail").selectedTab.mode.name,
+ "mail3PaneTab",
+ "The currently selected tab is the primary Mail tab"
+ );
+
+ // Confirm the folder pane didn't load.
+ // Assert.ok(!mc.window.document.getElementById("tabmail").currentTabInfo.folderPaneVisible); TODO
+
+ // The spaces toolbar should be available and visible.
+ await spacesVisiblePromise;
+
+ // Confirm the pref was updated properly.
+ Assert.ok(Services.prefs.getBoolPref("app.use_without_mail_account", false));
+});
+
+registerCleanupFunction(function () {
+ // Reset the changed pref.
+ Services.prefs.setBoolPref("app.use_without_mail_account", false);
+
+ // Restore the local folders account.
+ MailServices.accounts.createLocalMailAccount();
+});
diff --git a/comm/mail/test/browser/account/browser_accountTelemetry.js b/comm/mail/test/browser/account/browser_accountTelemetry.js
new file mode 100644
index 0000000000..5488eaf9d6
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_accountTelemetry.js
@@ -0,0 +1,278 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test telemetry related to account.
+ */
+
+let { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+
+let { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+let { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+let { MailTelemetryForTests } = ChromeUtils.import(
+ "resource:///modules/MailGlue.jsm"
+);
+
+let {
+ add_message_to_folder,
+ create_message,
+ msgGen,
+ get_special_folder,
+ create_folder,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+let { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Check that we are counting account types.
+ */
+add_task(async function test_account_types() {
+ // Collect all added accounts to be cleaned up at the end.
+ let addedAccounts = [];
+
+ Services.telemetry.clearScalars();
+
+ const NUM_IMAP = 3;
+ const NUM_RSS = 1;
+ const NUM_IRC = 1;
+
+ // Add incoming servers.
+ let imapServer = MailServices.accounts
+ .createIncomingServer("nobody", "foo.invalid", "imap")
+ .QueryInterface(Ci.nsIImapIncomingServer);
+ let imAccount = IMServices.accounts.createAccount(
+ "telemetry-irc-user",
+ "prpl-irc"
+ );
+ imAccount.autoLogin = false;
+ let ircServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "foo.invalid",
+ "im"
+ );
+ ircServer.wrappedJSObject.imAccount = imAccount;
+
+ // Add accounts and assign incoming servers.
+ for (let i = 0; i < NUM_IMAP; i++) {
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@foo.invalid";
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = imapServer;
+ account.addIdentity(identity);
+ addedAccounts.push(account);
+ }
+ for (let i = 0; i < NUM_RSS; i++) {
+ let account = FeedUtils.createRssAccount("rss");
+ addedAccounts.push(account);
+ }
+ for (let i = 0; i < NUM_IRC; i++) {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = ircServer;
+ addedAccounts.push(account);
+ }
+
+ registerCleanupFunction(() => {
+ for (let account of addedAccounts) {
+ MailServices.accounts.removeAccount(account);
+ }
+ });
+
+ MailTelemetryForTests.reportAccountTypes();
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+
+ // Check if we count account types correctly.
+ Assert.equal(
+ scalars["tb.account.count"].imap,
+ NUM_IMAP,
+ "IMAP account number must be correct"
+ );
+ Assert.equal(
+ scalars["tb.account.count"].rss,
+ NUM_RSS,
+ "RSS account number must be correct"
+ );
+ Assert.equal(
+ scalars["tb.account.count"].im_irc,
+ NUM_IRC,
+ "IRC account number must be correct"
+ );
+ Assert.equal(
+ scalars["tb.account.count"].none,
+ undefined,
+ "Should not report Local Folders account"
+ );
+});
+
+/**
+ * Check that we are counting account sizes.
+ */
+add_task(async function test_account_sizes() {
+ Services.telemetry.clearScalars();
+
+ const NUM_INBOX = 3;
+ const NUM_OTHER = 2;
+
+ let inbox = await get_special_folder(
+ Ci.nsMsgFolderFlags.Inbox,
+ true,
+ null,
+ false
+ );
+ let other = await create_folder("TestAccountSize");
+ for (let i = 0; i < NUM_INBOX; i++) {
+ await add_message_to_folder(
+ [inbox],
+ msgGen.makeMessage({ body: { body: `test inbox ${i}` } })
+ );
+ }
+ for (let i = 0; i < NUM_OTHER; i++) {
+ await add_message_to_folder(
+ [other],
+ msgGen.makeMessage({ body: { body: `test other ${i}` } })
+ );
+ }
+
+ MailTelemetryForTests.reportAccountSizes();
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+
+ // Check if we count total messages correctly.
+ Assert.equal(
+ scalars["tb.account.total_messages"].Inbox,
+ NUM_INBOX,
+ "Number of messages in Inbox must be correct"
+ );
+ Assert.equal(
+ scalars["tb.account.total_messages"].Other,
+ NUM_OTHER,
+ "Number of messages in other folders must be correct"
+ );
+ Assert.equal(
+ scalars["tb.account.total_messages"].Total,
+ NUM_INBOX + NUM_OTHER,
+ "Number of messages in all folders must be correct"
+ );
+
+ // The folder sizes on Windows are not exactly the same with Linux/macOS.
+ function checkSize(actual, expected, message) {
+ Assert.ok(Math.abs(actual - expected) < 10, message);
+ }
+ // Check if we count size on disk correctly.
+ checkSize(
+ scalars["tb.account.size_on_disk"].Inbox,
+ 873,
+ "Size of Inbox must be correct"
+ );
+ checkSize(
+ scalars["tb.account.size_on_disk"].Other,
+ 618,
+ "Size of other folders must be correct"
+ );
+ checkSize(
+ scalars["tb.account.size_on_disk"].Total,
+ 873 + 618,
+ "Size of all folders must be correct"
+ );
+});
+
+/**
+ * Verify counting of OAuth2 providers
+ */
+add_task(async function test_account_oauth_providers() {
+ // Collect all added accounts to be cleaned up at the end
+ const addedAccounts = [];
+
+ Services.telemetry.clearScalars();
+
+ const EXPECTED_GOOGLE_COUNT = 2;
+ const EXPECTED_MICROSOFT_COUNT = 1;
+ const EXPECTED_YAHOO_AOL_COUNT = 2;
+ const EXPECTED_OTHER_COUNT = 2;
+
+ const hostnames = [
+ "imap.googlemail.com",
+ "imap.gmail.com",
+ "imap.mail.ru",
+ "imap.yandex.com",
+ "imap.mail.yahoo.com",
+ "imap.aol.com",
+ "outlook.office365.com",
+ "something.totally.unexpected",
+ ];
+
+ function createIncomingImapServer(username, hostname, authMethod) {
+ const incoming = MailServices.accounts.createIncomingServer(
+ username,
+ hostname,
+ "imap"
+ );
+
+ incoming.authMethod = authMethod;
+
+ const account = MailServices.accounts.createAccount();
+ account.incomingServer = incoming;
+
+ const identity = MailServices.accounts.createIdentity();
+ account.addIdentity(identity);
+
+ addedAccounts.push(account);
+ }
+
+ // Add incoming servers
+ let i = 0;
+ const otherAuthMethods = [
+ Ci.nsMsgAuthMethod.none,
+ Ci.nsMsgAuthMethod.passwordCleartext,
+ Ci.nsMsgAuthMethod.passwordEncrypted,
+ Ci.nsMsgAuthMethod.secure,
+ ];
+
+ for (const hostname of hostnames) {
+ // Create one with OAuth2
+ createIncomingImapServer("nobody", hostname, Ci.nsMsgAuthMethod.OAuth2);
+
+ // Create one with an arbitrary method from our list
+ createIncomingImapServer("somebody_else", hostname, otherAuthMethods[i]);
+ i = i + (1 % otherAuthMethods.length);
+ }
+
+ registerCleanupFunction(() => {
+ for (const account of addedAccounts) {
+ MailServices.accounts.removeAccount(account);
+ }
+ });
+
+ MailTelemetryForTests.reportAccountTypes();
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+
+ // Check if we count account types correctly.
+ Assert.equal(
+ scalars["tb.account.oauth2_provider_count"].google,
+ EXPECTED_GOOGLE_COUNT,
+ "should have expected number of Google accounts"
+ );
+ Assert.equal(
+ scalars["tb.account.oauth2_provider_count"].microsoft,
+ EXPECTED_MICROSOFT_COUNT,
+ "should have expected number of Microsoft accounts"
+ );
+ Assert.equal(
+ scalars["tb.account.oauth2_provider_count"].yahoo_aol,
+ EXPECTED_YAHOO_AOL_COUNT,
+ "should have expected number of Yahoo/AOL accounts"
+ );
+ Assert.equal(
+ scalars["tb.account.oauth2_provider_count"].other,
+ EXPECTED_OTHER_COUNT,
+ "should have expected number of other accounts"
+ );
+});
diff --git a/comm/mail/test/browser/account/browser_actions.js b/comm/mail/test/browser/account/browser_actions.js
new file mode 100644
index 0000000000..dff3b3dc2d
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_actions.js
@@ -0,0 +1,226 @@
+/* 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 { click_account_tree_row, get_account_tree_row, open_advanced_settings } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+ );
+var { close_popup, wait_for_popup_to_open } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { content_tab_e } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var imapAccount, nntpAccount, originalAccountCount;
+
+add_setup(function () {
+ // There may be pre-existing accounts from other tests.
+ originalAccountCount = MailServices.accounts.allServers.length;
+ // There already should be a Local Folders account created.
+ // It is needed for this test.
+ Assert.ok(MailServices.accounts.localFoldersServer);
+
+ // Create an IMAP server
+ let imapServer = MailServices.accounts
+ .createIncomingServer("nobody", "example.com", "imap")
+ .QueryInterface(Ci.nsIImapIncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@example.com";
+
+ imapAccount = MailServices.accounts.createAccount();
+ imapAccount.incomingServer = imapServer;
+ imapAccount.addIdentity(identity);
+
+ // Create a NNTP server
+ let nntpServer = MailServices.accounts
+ .createIncomingServer(null, "example.nntp.invalid", "nntp")
+ .QueryInterface(Ci.nsINntpIncomingServer);
+
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox2@example.com";
+
+ nntpAccount = MailServices.accounts.createAccount();
+ nntpAccount.incomingServer = nntpServer;
+ nntpAccount.addIdentity(identity);
+ // Now there should be 2 more accounts.
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ originalAccountCount + 2
+ );
+});
+
+registerCleanupFunction(function () {
+ // Remove our test accounts to leave the profile clean.
+ MailServices.accounts.removeAccount(nntpAccount);
+ MailServices.accounts.removeAccount(imapAccount);
+ // There should be only the original accounts left.
+ Assert.equal(MailServices.accounts.allServers.length, originalAccountCount);
+});
+
+/**
+ * Check that the account actions for the account are enabled or disabled appropriately.
+ *
+ * @param {object} tab - The account manager tab.
+ * @param {number} accountKey - The key of the account to select.
+ * @param {boolean} isSetAsDefaultEnabled - True if the menuitem should be enabled, false otherwise.
+ * @param {boolean} isRemoveEnabled - True if the menuitem should be enabled, false otherwise.
+ * @param {boolean} isAddAccountEnabled - True if the menuitems (Add Mail Account+Add Other Account)
+ * should be enabled, false otherwise.
+ */
+async function subtest_check_account_actions(
+ tab,
+ accountKey,
+ isSetAsDefaultEnabled,
+ isRemoveEnabled,
+ isAddAccountEnabled
+) {
+ let accountRow = get_account_tree_row(accountKey, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ // click the Actions Button to bring up the popup with menuitems to test
+ let button = content_tab_e(tab, "accountActionsButton");
+ EventUtils.synthesizeMouseAtCenter(
+ button,
+ { clickCount: 1 },
+ button.ownerGlobal
+ );
+ await wait_for_popup_to_open(content_tab_e(tab, "accountActionsDropdown"));
+
+ let actionAddMailAccount = content_tab_e(tab, "accountActionsAddMailAccount");
+ Assert.notEqual(actionAddMailAccount, undefined);
+ Assert.equal(
+ !actionAddMailAccount.getAttribute("disabled"),
+ isAddAccountEnabled
+ );
+
+ let actionAddOtherAccount = content_tab_e(
+ tab,
+ "accountActionsAddOtherAccount"
+ );
+ Assert.notEqual(actionAddOtherAccount, undefined);
+ Assert.equal(
+ !actionAddOtherAccount.getAttribute("disabled"),
+ isAddAccountEnabled
+ );
+
+ let actionSetDefault = content_tab_e(tab, "accountActionsDropdownSetDefault");
+ Assert.notEqual(actionSetDefault, undefined);
+ Assert.equal(
+ !actionSetDefault.getAttribute("disabled"),
+ isSetAsDefaultEnabled
+ );
+
+ let actionRemove = content_tab_e(tab, "accountActionsDropdownRemove");
+ Assert.notEqual(actionRemove, undefined);
+ Assert.equal(!actionRemove.getAttribute("disabled"), isRemoveEnabled);
+
+ await close_popup(mc, content_tab_e(tab, "accountActionsDropdown"));
+}
+
+add_task(async function test_account_actions() {
+ // IMAP account: can be default, can be removed.
+ await open_advanced_settings(async function (tab) {
+ await subtest_check_account_actions(tab, imapAccount.key, true, true, true);
+ });
+
+ // NNTP (News) account: can't be default, can be removed.
+ await open_advanced_settings(async function (tab) {
+ await subtest_check_account_actions(
+ tab,
+ nntpAccount.key,
+ false,
+ true,
+ true
+ );
+ });
+
+ // Local Folders account: can't be removed, can't be default.
+ var localFoldersAccount = MailServices.accounts.FindAccountForServer(
+ MailServices.accounts.localFoldersServer
+ );
+ await open_advanced_settings(async function (tab) {
+ await subtest_check_account_actions(
+ tab,
+ localFoldersAccount.key,
+ false,
+ false,
+ true
+ );
+ });
+ // SMTP server row: can't be removed, can't be default.
+ await open_advanced_settings(async function (tab) {
+ await subtest_check_account_actions(tab, "smtp", false, false, true);
+ });
+
+ // on the IMAP account, disable Delete Account menu item
+ let disableItemPref = "mail.disable_button.delete_account";
+
+ // Set the pref on the default branch, otherwise .getBoolPref on it throws.
+ Services.prefs.getDefaultBranch("").setBoolPref(disableItemPref, true);
+ Services.prefs.lockPref(disableItemPref);
+
+ await open_advanced_settings(async function (tab) {
+ await subtest_check_account_actions(
+ tab,
+ imapAccount.key,
+ true,
+ false,
+ true
+ );
+ });
+
+ Services.prefs.unlockPref(disableItemPref);
+ Services.prefs.getDefaultBranch("").deleteBranch(disableItemPref);
+
+ // on the IMAP account, disable Set as Default menu item
+ disableItemPref = "mail.disable_button.set_default_account";
+
+ Services.prefs.getDefaultBranch("").setBoolPref(disableItemPref, true);
+ Services.prefs.lockPref(disableItemPref);
+
+ await open_advanced_settings(async function (tab) {
+ await subtest_check_account_actions(
+ tab,
+ imapAccount.key,
+ false,
+ true,
+ true
+ );
+ });
+
+ Services.prefs.unlockPref(disableItemPref);
+ Services.prefs.getDefaultBranch("").deleteBranch(disableItemPref);
+
+ // on the IMAP account, disable Add new Account menu items
+ disableItemPref = "mail.disable_new_account_addition";
+
+ Services.prefs.getDefaultBranch("").setBoolPref(disableItemPref, true);
+ Services.prefs.lockPref(disableItemPref);
+
+ await open_advanced_settings(async function (tab) {
+ await subtest_check_account_actions(
+ tab,
+ imapAccount.key,
+ true,
+ true,
+ false
+ );
+ });
+
+ Services.prefs.unlockPref(disableItemPref);
+ Services.prefs.getDefaultBranch("").deleteBranch(disableItemPref);
+});
diff --git a/comm/mail/test/browser/account/browser_archiveOptions.js b/comm/mail/test/browser/account/browser_archiveOptions.js
new file mode 100644
index 0000000000..fd8ed9868d
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_archiveOptions.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/. */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { click_account_tree_row, get_account_tree_row, open_advanced_settings } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+ );
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ plan_for_modal_dialog,
+ plan_for_window_close,
+ wait_for_modal_dialog,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var defaultIdentity;
+
+add_setup(function () {
+ defaultIdentity = MailServices.accounts.defaultAccount.defaultIdentity;
+});
+
+/**
+ * Check that the archive options button is enabled or disabled appropriately.
+ *
+ * @param {object} tab - The account manager tab.
+ * @param {number} accountKey - Key of the account the check.
+ * @param {boolean} isEnabled - True if the button should be enabled, false otherwise.
+ */
+function subtest_check_archive_options_enabled(tab, accountKey, isEnabled) {
+ let accountRow = get_account_tree_row(accountKey, "am-copies.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+ let button = iframe.contentDocument.getElementById("archiveHierarchyButton");
+
+ Assert.equal(button.disabled, !isEnabled);
+}
+
+add_task(async function test_archive_options_enabled() {
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ // First, create an IMAP server
+ let imapServer = MailServices.accounts
+ .createIncomingServer("nobody", "example.com", "imap")
+ .QueryInterface(Ci.nsIImapIncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@example.com";
+
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = imapServer;
+ account.addIdentity(identity);
+
+ // Then test that the archive options button is enabled/disabled appropriately
+
+ // Let the default identity archive to our IMAP folder, to ensure that the
+ // archive folder's server is used to determine the enabled/disabled state
+ // of the "archive options" button, *not* the incoming server for that
+ // identity.
+ defaultIdentity.archiveFolder = imapServer.rootFolder.URI;
+
+ imapServer.isGMailServer = false;
+ await open_advanced_settings(function (tab) {
+ subtest_check_archive_options_enabled(tab, account.key, true);
+ });
+ await open_advanced_settings(function (tab) {
+ subtest_check_archive_options_enabled(tab, defaultAccount.key, true);
+ });
+
+ imapServer.isGMailServer = true;
+ await open_advanced_settings(function (tab) {
+ subtest_check_archive_options_enabled(tab, account.key, false);
+ });
+ await open_advanced_settings(function (tab) {
+ subtest_check_archive_options_enabled(tab, defaultAccount.key, false);
+ });
+
+ MailServices.accounts.removeAccount(account);
+});
+
+async function subtest_initial_state(identity) {
+ plan_for_modal_dialog("archiveOptions", async function (ac) {
+ Assert.equal(
+ ac.window.document.getElementById("archiveGranularity").selectedIndex,
+ identity.archiveGranularity
+ );
+ Assert.equal(
+ ac.window.document.getElementById("archiveKeepFolderStructure").checked,
+ identity.archiveKeepFolderStructure
+ );
+ });
+ mc.window.openDialog(
+ "chrome://messenger/content/am-archiveoptions.xhtml",
+ "",
+ "centerscreen,chrome,modal,titlebar,resizable=yes",
+ { identity }
+ );
+ wait_for_modal_dialog("archiveOptions");
+}
+
+add_task(async function test_open_archive_options() {
+ for (let granularity = 0; granularity < 3; granularity++) {
+ defaultIdentity.archiveGranularity = granularity;
+ for (let kfs = 0; kfs < 2; kfs++) {
+ defaultIdentity.archiveKeepFolderStructure = kfs;
+ await subtest_initial_state(defaultIdentity);
+ }
+ }
+});
+
+function subtest_save_state(identity, granularity, kfs) {
+ plan_for_modal_dialog("archiveOptions", function (ac) {
+ ac.window.document.getElementById("archiveGranularity").selectedIndex =
+ granularity;
+ ac.window.document.getElementById("archiveKeepFolderStructure").checked =
+ kfs;
+ EventUtils.synthesizeKey("VK_RETURN", {}, ac.window);
+ ac.window.document.querySelector("dialog").acceptDialog();
+ });
+ mc.window.openDialog(
+ "chrome://messenger/content/am-archiveoptions.xhtml",
+ "",
+ "centerscreen,chrome,modal,titlebar,resizable=yes",
+ { identity }
+ );
+ wait_for_modal_dialog("archiveOptions");
+}
+
+add_task(function test_save_archive_options() {
+ defaultIdentity.archiveGranularity = 0;
+ defaultIdentity.archiveKeepFolderStructure = false;
+ subtest_save_state(defaultIdentity, 1, true);
+
+ Assert.equal(defaultIdentity.archiveGranularity, 1);
+ Assert.equal(defaultIdentity.archiveKeepFolderStructure, true);
+});
+
+function subtest_check_archive_enabled(tab, archiveEnabled) {
+ defaultIdentity.archiveEnabled = archiveEnabled;
+
+ click_account_tree_row(tab, 2);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+ let checkbox = iframe.contentDocument.getElementById(
+ "identity.archiveEnabled"
+ );
+
+ Assert.equal(checkbox.checked, archiveEnabled);
+}
+
+add_task(async function test_archive_enabled() {
+ await open_advanced_settings(function (amc) {
+ subtest_check_archive_enabled(amc, true);
+ });
+
+ await open_advanced_settings(function (amc) {
+ subtest_check_archive_enabled(amc, false);
+ });
+});
+
+function subtest_disable_archive(tab) {
+ defaultIdentity.archiveEnabled = true;
+ click_account_tree_row(tab, 2);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+ let checkbox = iframe.contentDocument.getElementById(
+ "identity.archiveEnabled"
+ );
+
+ Assert.ok(checkbox.checked);
+ Assert.ok(!checkbox.disabled);
+ EventUtils.synthesizeMouseAtCenter(
+ checkbox,
+ { clickCount: 1 },
+ checkbox.ownerGlobal
+ );
+ utils.waitFor(
+ () => !checkbox.checked,
+ "Archive checkbox didn't toggle to unchecked"
+ );
+
+ Assert.ok(!defaultIdentity.archiveEnabled);
+}
+
+add_task(async function test_disable_archive() {
+ await open_advanced_settings(subtest_disable_archive);
+});
diff --git a/comm/mail/test/browser/account/browser_deletion.js b/comm/mail/test/browser/account/browser_deletion.js
new file mode 100644
index 0000000000..45c8d701b1
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_deletion.js
@@ -0,0 +1,108 @@
+/* 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 test checks proper deletion of an account from the Account manager.
+ */
+
+"use strict";
+
+var { open_advanced_settings, remove_account } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gPopAccount, gImapAccount, gOriginalAccountCount;
+
+add_setup(function () {
+ // There may be pre-existing accounts from other tests.
+ gOriginalAccountCount = MailServices.accounts.allServers.length;
+
+ // Create a POP server
+ let popServer = MailServices.accounts
+ .createIncomingServer("nobody", "pop.foo.invalid", "pop3")
+ .QueryInterface(Ci.nsIPop3IncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@pop.foo.invalid";
+
+ gPopAccount = MailServices.accounts.createAccount();
+ gPopAccount.incomingServer = popServer;
+ gPopAccount.addIdentity(identity);
+
+ // Create an IMAP server
+ let imapServer = MailServices.accounts
+ .createIncomingServer("nobody", "imap.foo.invalid", "imap")
+ .QueryInterface(Ci.nsIImapIncomingServer);
+
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@imap.foo.invalid";
+
+ gImapAccount = MailServices.accounts.createAccount();
+ gImapAccount.incomingServer = imapServer;
+ gImapAccount.addIdentity(identity);
+
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ gOriginalAccountCount + 2
+ );
+});
+
+registerCleanupFunction(function () {
+ // There should be only the original accounts left.
+ Assert.equal(MailServices.accounts.allServers.length, gOriginalAccountCount);
+});
+
+add_task(async function test_account_data_deletion() {
+ await open_advanced_settings(function (tab) {
+ subtest_account_data_deletion1(tab);
+ });
+
+ await open_advanced_settings(function (tab) {
+ subtest_account_data_deletion2(tab);
+ });
+});
+
+/**
+ * Bug 274452
+ * Check if files of an account are preserved.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_account_data_deletion1(tab) {
+ let accountDir = gPopAccount.incomingServer.localPath;
+ Assert.ok(accountDir.isDirectory());
+
+ // Get some existing file in the POP3 account data dir.
+ let inboxFile = accountDir.clone();
+ inboxFile.append("Inbox.msf");
+ Assert.ok(inboxFile.isFile());
+
+ remove_account(gPopAccount, tab, true, false);
+ gPopAccount = null;
+ Assert.ok(accountDir.exists());
+}
+
+/**
+ * Bug 274452
+ * Check if files of an account can be deleted.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_account_data_deletion2(tab) {
+ let accountDir = gImapAccount.incomingServer.localPath;
+ Assert.ok(accountDir.isDirectory());
+
+ // Get some file in the IMAP account data dir.
+ let inboxFile = accountDir.clone();
+ inboxFile.append("INBOX.msf");
+ Assert.ok(inboxFile.isFile());
+
+ remove_account(gImapAccount, tab, true, true);
+ gImapAccount = null;
+ Assert.ok(!accountDir.exists());
+}
diff --git a/comm/mail/test/browser/account/browser_mailAccountSetupWizard.js b/comm/mail/test/browser/account/browser_mailAccountSetupWizard.js
new file mode 100644
index 0000000000..33cc69e645
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_mailAccountSetupWizard.js
@@ -0,0 +1,931 @@
+/* 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 { openAccountSetup, wait_for_account_tree_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { input_value, delete_all_existing } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+var { gMockPromptService } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PromptHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+var { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+);
+
+var originalAlertsServiceCID;
+// We need a mock alerts service to capture notification events when loading the
+// UI after a successful account configuration in order to catch the alert
+// triggered when trying to connect to the fake IMAP server.
+class MockAlertsService {
+ QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]);
+ showAlert() {}
+}
+
+var user = {
+ name: "Yamato Nadeshiko",
+ email: "yamato.nadeshiko@example.com",
+ password: "abc12345",
+ incomingHost: "testin.example.com",
+ outgoingHost: "testout.example.com",
+};
+var outgoingShortName = "Example Två";
+
+var imapUser = {
+ name: "John Doe",
+ email: "john.doe@example-imap.com",
+ password: "abc12345",
+ incomingHost: "testin.example-imap.com",
+ outgoingHost: "testout.example-imap.com",
+};
+
+var IMAPServer = {
+ open() {
+ const {
+ ImapDaemon,
+ ImapMessage,
+ IMAP_RFC2195_extension,
+ IMAP_RFC3501_handler,
+ mixinExtension,
+ } = ChromeUtils.import("resource://testing-common/mailnews/Imapd.jsm");
+ const { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+ );
+ IMAPServer.ImapMessage = ImapMessage;
+
+ this.daemon = new ImapDaemon();
+ this.server = new nsMailServer(daemon => {
+ let handler = new IMAP_RFC3501_handler(daemon);
+ mixinExtension(handler, IMAP_RFC2195_extension);
+
+ handler.kUsername = "john.doe@example-imap.com";
+ handler.kPassword = "abc12345";
+ handler.kAuthRequired = true;
+ handler.kAuthSchemes = ["PLAIN"];
+ return handler;
+ }, this.daemon);
+ this.server.start(1993);
+ info(`IMAP server started on port ${this.server.port}`);
+
+ registerCleanupFunction(() => this.close());
+ },
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+};
+
+var SMTPServer = {
+ open() {
+ const { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+ );
+ const { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+ );
+
+ this.daemon = new SmtpDaemon();
+ this.server = new nsMailServer(daemon => {
+ let handler = new SMTP_RFC2821_handler(daemon);
+ handler.kUsername = "john.doe@example-imap.com";
+ handler.kPassword = "abc12345";
+ handler.kAuthRequired = true;
+ handler.kAuthSchemes = ["PLAIN"];
+ return handler;
+ }, this.daemon);
+ this.server.start(1587);
+ info(`SMTP server started on port ${this.server.port}`);
+
+ registerCleanupFunction(() => this.close());
+ },
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+};
+
+var _srv = DNS.srv;
+var _txt = DNS.txt;
+DNS.srv = function (name) {
+ if (["_caldavs._tcp.localhost", "_carddavs._tcp.localhost"].includes(name)) {
+ return [{ prio: 0, weight: 0, host: "example.org", port: 443 }];
+ }
+ if (
+ [
+ "_caldavs._tcp.example-imap.com",
+ "_carddavs._tcp.example-imap.com",
+ ].includes(name)
+ ) {
+ return [{ prio: 0, weight: 0, host: "example.org", port: 443 }];
+ }
+ throw new Error(`Unexpected DNS SRV lookup: ${name}`);
+};
+DNS.txt = function (name) {
+ if (name == "_caldavs._tcp.localhost") {
+ return [{ data: "path=/browser/comm/calendar/test/browser/data/dns.sjs" }];
+ }
+ if (name == "_carddavs._tcp.localhost") {
+ return [
+ {
+ data: "path=/browser/comm/mail/components/addrbook/test/browser/data/dns.sjs",
+ },
+ ];
+ }
+ if (name == "_caldavs._tcp.example-imap.com") {
+ return [{ data: "path=/browser/comm/calendar/test/browser/data/dns.sjs" }];
+ }
+ if (name == "_carddavs._tcp.example-imap.com") {
+ return [
+ {
+ data: "path=/browser/comm/mail/components/addrbook/test/browser/data/dns.sjs",
+ },
+ ];
+ }
+ throw new Error(`Unexpected DNS TXT lookup: ${name}`);
+};
+
+const PREF_NAME = "mailnews.auto_config_url";
+const PREF_VALUE = Services.prefs.getCharPref(PREF_NAME);
+
+// Remove an account in the Account Manager, but not via the UI.
+function remove_account_internal(tab, account, outgoing) {
+ let win = tab.browser.contentWindow;
+
+ // Remove the account and incoming server
+ let serverId = account.incomingServer.serverURI;
+ MailServices.accounts.removeAccount(account);
+ account = null;
+ if (serverId in win.accountArray) {
+ delete win.accountArray[serverId];
+ }
+ win.selectServer(null, null);
+
+ // Remove the outgoing server
+ let smtpKey = outgoing.key;
+ MailServices.smtp.deleteServer(outgoing);
+ win.replaceWithDefaultSmtpServer(smtpKey);
+}
+
+add_task(async function test_mail_account_setup() {
+ originalAlertsServiceCID = MockRegistrar.register(
+ "@mozilla.org/alerts-service;1",
+ MockAlertsService
+ );
+
+ // Set the pref to load a local autoconfig file.
+ let url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/account/xml/";
+ Services.prefs.setCharPref(PREF_NAME, url);
+
+ let tab = await openAccountSetup();
+ let tabDocument = tab.browser.contentWindow.document;
+
+ // Input user's account information
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("realname"),
+ {},
+ tab.browser.contentWindow
+ );
+
+ if (tabDocument.getElementById("realname").value) {
+ // If any realname is already filled, clear it out, we have our own.
+ delete_all_existing(mc, tabDocument.getElementById("realname"));
+ }
+ input_value(mc, user.name);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ input_value(mc, user.email);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ input_value(mc, user.password);
+
+ let notificationBox = tab.browser.contentWindow.gAccountSetup.notificationBox;
+
+ let notificationShowed = BrowserTestUtils.waitForCondition(
+ () =>
+ notificationBox.getNotificationWithValue("accountSetupSuccess") != null,
+ "Timeout waiting for error notification to be showed"
+ );
+
+ let popOption = tabDocument.getElementById("resultsOption-pop3");
+ let protocolPOPSelected = BrowserTestUtils.waitForCondition(
+ () => !popOption.hidden && popOption.classList.contains("selected"),
+ "Timeout waiting for the POP3 option to be visible and selected"
+ );
+
+ // Load the autoconfig file from http://localhost:433**/autoconfig/example.com
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("continueButton"),
+ {},
+ tab.browser.contentWindow
+ );
+
+ // Wait for the successful notification to show up.
+ await notificationShowed;
+
+ // Only the POP protocol should be available, therefore we need to confirm
+ // that the UI is returning only 1 pre-selected protocol.
+ await protocolPOPSelected;
+
+ // Confirm that the IMAP and EXCHANGE options are hidden.
+ Assert.ok(tabDocument.getElementById("resultsOption-imap").hidden);
+ Assert.ok(tabDocument.getElementById("resultsOption-exchange").hidden);
+
+ // Register the prompt service to handle the confirm() dialog
+ gMockPromptService.register();
+ gMockPromptService.returnValue = true;
+
+ // Open the advanced settings (Account Manager) to create the account
+ // immediately. We use an invalid email/password so the setup will fail
+ // anyway.
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("manualConfigButton"),
+ {},
+ tab.browser.contentWindow
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => !tabDocument.getElementById("manualConfigArea").hidden,
+ "Timeout waiting for the manual edit area to become visible"
+ );
+
+ let tabmail = mc.window.document.getElementById("tabmail");
+ let tabChanged = BrowserTestUtils.waitForCondition(
+ () => tabmail.selectedTab != tab,
+ "Timeout waiting for the currently active tab to change"
+ );
+
+ let advancedSetupButton = tabDocument.getElementById("advancedSetupButton");
+ advancedSetupButton.scrollIntoView();
+
+ EventUtils.synthesizeMouseAtCenter(
+ advancedSetupButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // Wait for the current Account Setup tab to be closed and the Account
+ // Settings tab to open before running other sub tests.
+ await tabChanged;
+
+ await subtest_verify_account(tabmail.selectedTab, user);
+
+ // Close the Account Settings tab.
+ tabmail.closeTab(tabmail.currentTabInfo);
+
+ // Confirm that we properly updated the folderPaneVisible attribute for the
+ // tabmail when we created the account in the background.
+ Assert.ok(tabmail.currentTabInfo.folderPaneVisible);
+
+ // Confirm that the folder pane is visible.
+ Assert.ok(BrowserTestUtils.is_visible(tabmail.currentAbout3Pane.folderTree));
+
+ let promptState = gMockPromptService.promptState;
+ Assert.equal("confirm", promptState.method);
+
+ // Clean up
+ gMockPromptService.unregister();
+ Services.prefs.setCharPref(PREF_NAME, PREF_VALUE);
+});
+
+async function subtest_verify_account(tab, user) {
+ await BrowserTestUtils.waitForCondition(
+ () => tab.browser.contentWindow.currentAccount != null,
+ "Timeout waiting for current account to become non-null"
+ );
+
+ let account = tab.browser.contentWindow.currentAccount;
+ let identity = account.defaultIdentity;
+ let incoming = account.incomingServer;
+ let outgoing = MailServices.smtp.getServerByKey(identity.smtpServerKey);
+
+ let config = {
+ "incoming server username": {
+ actual: incoming.username,
+ expected: user.email.split("@")[0],
+ },
+ // This was creating test failure.
+ //
+ // "outgoing server username": {
+ // actual: outgoing.username,
+ // expected: user.email,
+ // },
+ "incoming server hostname": {
+ // Note: N in the hostName is uppercase
+ actual: incoming.hostName,
+ expected: user.incomingHost,
+ },
+ "outgoing server hostname": {
+ // And this is lowercase
+ actual: outgoing.hostname,
+ expected: user.outgoingHost,
+ },
+ "user real name": { actual: identity.fullName, expected: user.name },
+ "user email address": { actual: identity.email, expected: user.email },
+ "outgoing description": {
+ actual: outgoing.description,
+ expected: outgoingShortName,
+ },
+ };
+
+ try {
+ for (let i in config) {
+ Assert.equal(
+ config[i].actual,
+ config[i].expected,
+ `Configured ${i} is ${config[i].actual}. It should be ${config[i].expected}.`
+ );
+ }
+ } finally {
+ remove_account_internal(tab, account, outgoing);
+ }
+}
+
+/**
+ * Make sure that we don't re-set the information we get from the config
+ * file if the password is incorrect.
+ */
+add_task(async function test_bad_password_uses_old_settings() {
+ // Set the pref to load a local autoconfig file, that will fetch the
+ // ../account/xml/example.com which contains the settings for the
+ // @example.com email account (see the 'user' object).
+ let url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/account/xml/";
+ Services.prefs.setCharPref(PREF_NAME, url);
+
+ Services.telemetry.clearScalars();
+
+ let tab = await openAccountSetup();
+ let tabDocument = tab.browser.contentWindow.document;
+
+ // Input user's account information
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("realname"),
+ {},
+ tab.browser.contentWindow
+ );
+
+ if (tabDocument.getElementById("realname").value) {
+ // If any realname is already filled, clear it out, we have our own.
+ delete_all_existing(mc, tabDocument.getElementById("realname"));
+ }
+ input_value(mc, user.name);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ input_value(mc, user.email);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ input_value(mc, user.password);
+
+ // Load the autoconfig file from http://localhost:433**/autoconfig/example.com
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("continueButton"),
+ {},
+ tab.browser.contentWindow
+ );
+
+ let createButton = tabDocument.getElementById("createButton");
+ await BrowserTestUtils.waitForCondition(
+ () => !createButton.hidden && !createButton.disabled,
+ "Timeout waiting for create button to become visible and active"
+ );
+
+ let notificationBox = tab.browser.contentWindow.gAccountSetup.notificationBox;
+
+ let notificationShowed = BrowserTestUtils.waitForCondition(
+ () => notificationBox.getNotificationWithValue("accountSetupError") != null,
+ "Timeout waiting for error notification to be showed"
+ );
+
+ createButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ createButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ await notificationShowed;
+
+ await BrowserTestUtils.waitForCondition(
+ () => !createButton.disabled,
+ "Timeout waiting for create button to become active"
+ );
+
+ let manualConfigButton = tabDocument.getElementById("manualConfigButton");
+ manualConfigButton.scrollIntoView();
+
+ EventUtils.synthesizeMouseAtCenter(
+ manualConfigButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => !tabDocument.getElementById("manualConfigArea").hidden,
+ "Timeout waiting for the manual edit area to become visible"
+ );
+
+ let outgoingAuthSelect = tabDocument.getElementById("outgoingAuthMethod");
+ // Make sure the select field is inside the viewport.
+ outgoingAuthSelect.scrollIntoView();
+ outgoingAuthSelect.focus();
+
+ let popupOpened = BrowserTestUtils.waitForEvent(
+ document.getElementById("ContentSelectDropdown"),
+ "popupshown"
+ );
+ EventUtils.sendKey("space", tab.browser.contentWindow);
+ await popupOpened;
+
+ // The default value should be on "Normal password", which is after
+ // "No authentication", so we need to go up. We do this on purpose so we can
+ // properly test and track the order of options.
+ EventUtils.sendKey("up", tab.browser.contentWindow);
+
+ let userNameDisabled = BrowserTestUtils.waitForCondition(
+ () => tabDocument.getElementById("outgoingUsername").disabled,
+ "Timeout waiting for the outgoing username field to be disabled"
+ );
+ EventUtils.sendKey("return", tab.browser.contentWindow);
+
+ // Confirm that the outgoing username field is disabled.
+ await userNameDisabled;
+
+ // Revert the outgoing authentication method to "Normal Password".
+ outgoingAuthSelect.focus();
+ popupOpened = BrowserTestUtils.waitForEvent(
+ document.getElementById("ContentSelectDropdown"),
+ "popupshown"
+ );
+ // Change the outgoing authentication method to "No Authentication".
+ EventUtils.sendKey("space", tab.browser.contentWindow);
+ await popupOpened;
+
+ EventUtils.sendKey("down", tab.browser.contentWindow);
+
+ let usernameEnabled = BrowserTestUtils.waitForCondition(
+ () => !tabDocument.getElementById("outgoingUsername").disabled,
+ "Timeout waiting for the outgoing username field to be enabled"
+ );
+ EventUtils.sendKey("return", tab.browser.contentWindow);
+
+ // Confirm that the outgoing username field is enabled.
+ await usernameEnabled;
+
+ let notificationRemoved = BrowserTestUtils.waitForCondition(
+ () => notificationBox.getNotificationWithValue("accountSetupError") == null,
+ "Timeout waiting for error notification to be removed"
+ );
+
+ createButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ createButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // Triggering again the "createButton" should clear previous notifications.
+ await notificationRemoved;
+
+ // Make sure all the values are the same as in the user object.
+ Assert.equal(
+ tabDocument.getElementById("outgoingHostname").value,
+ user.outgoingHost,
+ "Outgoing server changed!"
+ );
+ Assert.equal(
+ tabDocument.getElementById("incomingHostname").value,
+ user.incomingHost,
+ "incoming server changed!"
+ );
+
+ // A new error notification should appear.
+ await BrowserTestUtils.waitForCondition(
+ () => notificationBox.getNotificationWithValue("accountSetupError") != null,
+ "Timeout waiting for error notification to be showed"
+ );
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.account.failed_email_account_setup"]["xml-from-db"],
+ 1,
+ "Count of failed email account setup with xml config must be correct"
+ );
+ Assert.equal(
+ scalars["tb.account.failed_email_account_setup"].user,
+ 1,
+ "Count of failed email account setup with manual config must be correct"
+ );
+
+ // Clean up
+ Services.prefs.setCharPref(PREF_NAME, PREF_VALUE);
+
+ let closeButton = tabDocument.getElementById("cancelButton");
+ closeButton.scrollIntoView();
+
+ EventUtils.synthesizeMouseAtCenter(
+ closeButton,
+ {},
+ tab.browser.contentWindow
+ );
+});
+
+add_task(async function test_remember_password() {
+ await remember_password_test(true);
+ await remember_password_test(false);
+});
+
+/**
+ * Test remember_password checkbox behavior with
+ * signon.rememberSignons set to "aPrefValue"
+ *
+ * @param {boolean} aPrefValue - The preference value for signon.rememberSignons.
+ */
+async function remember_password_test(aPrefValue) {
+ // Save the pref for backup purpose.
+ let rememberSignons_pref_save = Services.prefs.getBoolPref(
+ "signon.rememberSignons",
+ true
+ );
+
+ Services.prefs.setBoolPref("signon.rememberSignons", aPrefValue);
+
+ let tab = await openAccountSetup();
+ let tabDocument = tab.browser.contentWindow.document;
+ let password = tabDocument.getElementById("password");
+ let passwordToggle = tabDocument.getElementById("passwordToggleButton");
+
+ // The password field is empty, so confirm that the toggle button is hidden.
+ Assert.ok(passwordToggle.hidden);
+
+ // Type something in the password field.
+ password.focus();
+ input_value(mc, "testing");
+
+ // The password toggle button should be visible now.
+ Assert.ok(!passwordToggle.hidden);
+
+ // Click on the password toggle button.
+ EventUtils.synthesizeMouseAtCenter(
+ passwordToggle,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // The password field should have being turned into clear text.
+ Assert.equal(password.type, "text");
+
+ // Click on the password toggle button again.
+ EventUtils.synthesizeMouseAtCenter(
+ passwordToggle,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // The password field should have being turned back into a password type.
+ Assert.equal(password.type, "password");
+
+ let rememberPassword = tabDocument.getElementById("rememberPassword");
+ Assert.ok(rememberPassword.disabled != aPrefValue);
+ Assert.equal(rememberPassword.checked, aPrefValue);
+
+ // Empty the password field.
+ delete_all_existing(mc, password);
+
+ // Restore the saved signon.rememberSignons value.
+ Services.prefs.setBoolPref(
+ "signon.rememberSignons",
+ rememberSignons_pref_save
+ );
+
+ let closeButton = tabDocument.getElementById("cancelButton");
+ closeButton.scrollIntoView();
+
+ // Close the wizard.
+ EventUtils.synthesizeMouseAtCenter(
+ closeButton,
+ {},
+ tab.browser.contentWindow
+ );
+}
+
+/**
+ * Test the full account setup with an IMAP account, verifying the correct info
+ * in the final page.
+ */
+add_task(async function test_full_account_setup() {
+ // Initialize the fake IMAP and SMTP server to simulate a real account login.
+ IMAPServer.open();
+ SMTPServer.open();
+
+ // Set the pref to load a local autoconfig file.
+ let url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/account/xml/";
+ Services.prefs.setCharPref(PREF_NAME, url);
+
+ let tab = await openAccountSetup();
+ let tabDocument = tab.browser.contentWindow.document;
+
+ // If any realname is already filled, clear it out, we have our own.
+ tabDocument.getElementById("realname").value = "";
+
+ // The focus should be on the "realname" input by default, so let's fill it.
+ input_value(mc, imapUser.name);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ input_value(mc, imapUser.email);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ input_value(mc, imapUser.password);
+
+ let notificationBox = tab.browser.contentWindow.gAccountSetup.notificationBox;
+
+ let notificationShowed = BrowserTestUtils.waitForCondition(
+ () =>
+ notificationBox.getNotificationWithValue("accountSetupSuccess") != null,
+ "Timeout waiting for error notification to be showed"
+ );
+
+ let imapOption = tabDocument.getElementById("resultsOption-imap");
+ let protocolIMAPSelected = BrowserTestUtils.waitForCondition(
+ () => !imapOption.hidden && imapOption.classList.contains("selected"),
+ "Timeout waiting for the IMAP option to be visible and selected"
+ );
+
+ // Since we're focused inside a form, pressing "Enter" should submit it.
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ // Wait for the successful notification to show up.
+ await notificationShowed;
+
+ // Confirm the IMAP protocol is visible and selected.
+ await protocolIMAPSelected;
+
+ let finalViewShowed = BrowserTestUtils.waitForCondition(
+ () => !tabDocument.getElementById("successView").hidden,
+ "Timeout waiting for the final page to be visible"
+ );
+
+ let insecureDialogShowed = BrowserTestUtils.waitForCondition(
+ () => tabDocument.getElementById("insecureDialog").open,
+ "Timeout waiting for the #insecureDialog to be visible"
+ );
+
+ // Press "Enter" again to proceed with the account creation.
+ tabDocument.getElementById("createButton").focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ // Since we're using plain authentication in the mock IMAP server, the
+ // insecure warning dialog should appear. Let's wait for it.
+ await insecureDialogShowed;
+
+ // Click the acknowledge checkbox and confirm the insecure dialog.
+ let acknowledgeCheckbox = tabDocument.getElementById("acknowledgeWarning");
+ acknowledgeCheckbox.scrollIntoView();
+
+ EventUtils.synthesizeMouseAtCenter(
+ acknowledgeCheckbox,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // Prepare to handle the linked services notification.
+ let syncingBox = tab.browser.contentWindow.gAccountSetup.syncingBox;
+
+ let syncingNotificationShowed = BrowserTestUtils.waitForCondition(
+ () => syncingBox.getNotificationWithValue("accountSetupLoading") != null,
+ "Timeout waiting for the syncing notification to be removed"
+ );
+
+ let syncingNotificationRemoved = BrowserTestUtils.waitForCondition(
+ () => !syncingBox.getNotificationWithValue("accountSetupLoading"),
+ "Timeout waiting for the syncing notification to be removed"
+ );
+
+ let confirmButton = tabDocument.getElementById("insecureConfirmButton");
+ confirmButton.scrollIntoView();
+
+ // Close the insecure dialog.
+ EventUtils.synthesizeMouseAtCenter(
+ confirmButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // The final page should be visible.
+ await finalViewShowed;
+
+ let tabmail = mc.window.document.getElementById("tabmail");
+
+ // The tab shouldn't change even if we created a new account.
+ Assert.equal(tab, tabmail.selectedTab, "Tab should should still be the same");
+
+ // Assert the UI is properly filled with the new account info.
+ Assert.equal(
+ tabDocument.getElementById("newAccountName").textContent,
+ imapUser.name
+ );
+ Assert.equal(
+ tabDocument.getElementById("newAccountEmail").textContent,
+ imapUser.email
+ );
+ Assert.equal(
+ tabDocument.getElementById("newAccountProtocol").textContent,
+ "imap"
+ );
+
+ // The fetching of connected address books and calendars should start.
+ await syncingNotificationShowed;
+
+ // Wait for the fetching of address books and calendars to end.
+ await syncingNotificationRemoved;
+
+ // Wait for the linked address book section to be visible.
+ let addressBookSection = tabDocument.getElementById("linkedAddressBooks");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(addressBookSection),
+ "linked address book section visible",
+ 250
+ );
+
+ // The section should be expanded already.
+ let abList = tabDocument.querySelector(
+ "#addressBooksSetup .linked-services-list"
+ );
+ Assert.ok(BrowserTestUtils.is_visible(abList), "address book list visible");
+
+ // Check the linked address book was found.
+ Assert.equal(abList.childElementCount, 1);
+ Assert.equal(
+ abList.querySelector("li > span.protocol-type").textContent,
+ "CardDAV"
+ );
+ Assert.equal(
+ abList.querySelector("li > span.list-item-name").textContent,
+ "You found me!"
+ );
+
+ // Connect the linked address book.
+ let abDirectoryPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ EventUtils.synthesizeMouseAtCenter(
+ abList.querySelector("li > button.small-button"),
+ {},
+ tab.browser.contentWindow
+ );
+ let [abDirectory] = await abDirectoryPromise;
+ Assert.equal(abDirectory.dirName, "You found me!");
+ Assert.equal(abDirectory.dirType, Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ Assert.equal(
+ abDirectory.getStringValue("carddav.url", ""),
+ "https://example.org/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs"
+ );
+
+ // Wait for the linked calendar section to be visible.
+ let calendarSection = tabDocument.getElementById("linkedCalendars");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(calendarSection),
+ "linked calendar section visible",
+ 250
+ );
+
+ // The section should be expanded already.
+ let calendarList = tabDocument.querySelector(
+ "#calendarsSetup .linked-services-list"
+ );
+ Assert.ok(BrowserTestUtils.is_visible(calendarList), "calendar list visible");
+
+ // Check the linked calendar was found.
+ Assert.equal(calendarList.childElementCount, 2);
+ Assert.equal(
+ calendarList.querySelector("li > span.protocol-type").textContent,
+ "CalDAV"
+ );
+ Assert.equal(
+ calendarList.querySelector("li > span.list-item-name").textContent,
+ "You found me!"
+ );
+ Assert.equal(
+ calendarList.querySelector("li:nth-child(2) > span.protocol-type")
+ .textContent,
+ "CalDAV"
+ );
+ Assert.equal(
+ calendarList.querySelector("li:nth-child(2) > span.list-item-name")
+ .textContent,
+ "Röda dagar"
+ );
+
+ // Connect the linked calendar.
+ let calendarPromise = new Promise(resolve => {
+ let observer = {
+ onCalendarRegistered(calendar) {
+ cal.manager.removeObserver(this);
+ resolve(calendar);
+ },
+ onCalendarUnregistering() {},
+ onCalendarDeleting() {},
+ };
+ cal.manager.addObserver(observer);
+ });
+
+ let calendarDialogShowed = BrowserTestUtils.waitForCondition(
+ () => tabDocument.getElementById("calendarDialog").open,
+ "Timeout waiting for the #calendarDialog to be visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ calendarList.querySelector("li > button.small-button"),
+ {},
+ tab.browser.contentWindow
+ );
+ await calendarDialogShowed;
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("calendarDialogConfirmButton"),
+ {},
+ tab.browser.contentWindow
+ );
+
+ let calendar = await calendarPromise;
+ Assert.equal(calendar.name, "You found me!");
+ Assert.equal(calendar.type, "caldav");
+ // This address doesn't need to actually exist for the test to pass.
+ Assert.equal(
+ calendar.uri.spec,
+ "https://example.org/browser/comm/calendar/test/browser/data/calendar.sjs"
+ );
+
+ let logins = Services.logins.findLogins("https://example.org", null, "");
+ Assert.equal(logins.length, 1);
+ Assert.equal(
+ logins[0].username,
+ imapUser.email,
+ "username was saved for linked address book/calendar"
+ );
+ Assert.equal(
+ logins[0].password,
+ imapUser.password,
+ "password was saved for linked address book/calendar"
+ );
+
+ let tabChanged = BrowserTestUtils.waitForCondition(
+ () => tabmail.selectedTab != tab,
+ "Timeout waiting for the currently active tab to change"
+ );
+
+ let finishButton = tabDocument.getElementById("finishButton");
+ finishButton.focus();
+ finishButton.scrollIntoView();
+
+ // Close the wizard.
+ EventUtils.synthesizeMouseAtCenter(
+ finishButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ await tabChanged;
+
+ // Confirm the mail 3 pane is the currently selected tab.
+ Assert.equal(
+ tabmail.selectedTab.mode.name,
+ "mail3PaneTab",
+ "The currently selected tab is the primary Mail tab"
+ );
+
+ // Remove the address book and calendar.
+ MailServices.ab.deleteAddressBook(abDirectory.URI);
+ cal.manager.removeCalendar(calendar);
+
+ // Restore the original pref.
+ Services.prefs.setCharPref(PREF_NAME, PREF_VALUE);
+
+ // Wait for Thunderbird to connect to the server and check for messages.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ IMAPServer.close();
+ SMTPServer.close();
+ Services.logins.removeAllLogins();
+});
+
+registerCleanupFunction(function () {
+ MockRegistrar.unregister(originalAlertsServiceCID);
+ DNS.srv = _srv;
+ DNS.txt = _txt;
+});
diff --git a/comm/mail/test/browser/account/browser_manageIdentities.js b/comm/mail/test/browser/account/browser_manageIdentities.js
new file mode 100644
index 0000000000..f67f19891b
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_manageIdentities.js
@@ -0,0 +1,325 @@
+/* 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/. */
+
+/**
+ * Tests for the account settings manage identity.
+ */
+
+"use strict";
+
+var {
+ click_account_tree_row,
+ get_account_tree_row,
+ open_advanced_settings,
+ openAccountSettings,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+);
+const { wait_for_frame_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var { gMockPromptService } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PromptHelpers.jsm"
+);
+var { plan_for_modal_dialog, wait_for_modal_dialog } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+var gPopAccount, gOriginalAccountCount, gIdentitiesWin;
+
+/**
+ * Load the identities dialog.
+ *
+ * @returns {Window} The loaded window of the identities dialog.
+ */
+async function identitiesListDialogLoaded(win) {
+ let manageButton = win.document.getElementById(
+ "identity.manageIdentitiesbutton"
+ );
+ let identitiesDialogLoad = promiseLoadSubDialog(
+ "chrome://messenger/content/am-identities-list.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(manageButton, {}, win);
+ return identitiesDialogLoad;
+}
+
+/**
+ * Load an identity listed in the identities dialog.
+ *
+ * @param {number} identityIdx - The index of the identity, in the list.
+ * @returns {Window} The loaded window of the identities dialog.
+ */
+async function identityDialogLoaded(identityIdx) {
+ let identitiesList = gIdentitiesWin.document.getElementById("identitiesList");
+
+ // Let's dbl click to open the identity.
+ let identityDialogLoaded = promiseLoadSubDialog(
+ "chrome://messenger/content/am-identity-edit.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ identitiesList.children[identityIdx],
+ { clickCount: 2 },
+ gIdentitiesWin
+ );
+ return identityDialogLoaded;
+}
+
+/** Close the open dialog. */
+async function dialogClosed(win) {
+ let dialogElement = win.document.querySelector("dialog");
+ let dialogClosing = BrowserTestUtils.waitForEvent(
+ dialogElement,
+ "dialogclosing"
+ );
+ dialogElement.acceptDialog();
+ return dialogClosing;
+}
+
+add_setup(async function () {
+ // There may be pre-existing accounts from other tests.
+ gOriginalAccountCount = MailServices.accounts.allServers.length;
+
+ // Create a POP server
+ let popServer = MailServices.accounts
+ .createIncomingServer("nobody", "exampleX.invalid", "pop3")
+ .QueryInterface(Ci.nsIPop3IncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@example.invalid";
+
+ gPopAccount = MailServices.accounts.createAccount();
+ gPopAccount.incomingServer = popServer;
+ gPopAccount.addIdentity(identity);
+
+ // Now there should be one more account.
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ gOriginalAccountCount + 1
+ );
+
+ let firstIdentity = gPopAccount.identities[0];
+
+ Assert.equal(
+ firstIdentity.autoEncryptDrafts,
+ true,
+ "encrypted drafts should be enabled by default"
+ );
+ Assert.equal(
+ firstIdentity.protectSubject,
+ true,
+ "protected subject should be enabled by default"
+ );
+ Assert.equal(
+ firstIdentity.signMail,
+ false,
+ "signing should be disabled by default"
+ );
+
+ firstIdentity.autoEncryptDrafts = false;
+
+ registerCleanupFunction(function rmAccount() {
+ // Remove our test account to leave the profile clean.
+ MailServices.accounts.removeAccount(gPopAccount);
+ // There should be only the original accounts left.
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ gOriginalAccountCount
+ );
+ });
+
+ // Go to the account settings.
+ let tab = await openAccountSettings();
+ registerCleanupFunction(function closeTab() {
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+ });
+
+ // To the account main page.
+ let accountRow = get_account_tree_row(
+ gPopAccount.key,
+ null, // "am-main.xhtml",
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ // Click "Manage Identities" to show the list of identities.
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+ gIdentitiesWin = await identitiesListDialogLoaded(iframe.contentWindow);
+});
+
+/**
+ * Test that adding a new identity works, and that the identity is listed
+ * once the dialog to add new identity closes.
+ */
+add_task(async function test_add_identity() {
+ let identitiesList = gIdentitiesWin.document.getElementById("identitiesList");
+
+ Assert.equal(
+ identitiesList.childElementCount,
+ 1,
+ "should start with 1 identity"
+ );
+
+ // Open the dialog to add a new identity.
+ let identityDialogLoaded = promiseLoadSubDialog(
+ "chrome://messenger/content/am-identity-edit.xhtml"
+ );
+ let addButton = gIdentitiesWin.document.getElementById("addButton");
+ EventUtils.synthesizeMouseAtCenter(addButton, {}, gIdentitiesWin);
+ let identityWin = await identityDialogLoaded;
+
+ // Fill in some values, and close. The new identity should now be listed.
+ identityWin.document.getElementById("identity.fullName").focus();
+ EventUtils.sendString("bob", identityWin);
+ identityWin.document.getElementById("identity.email").focus();
+ EventUtils.sendString("bob@openpgp.example", identityWin);
+
+ // Check the e2e tab is only available for existing identities that
+ // have the email set - that is, it should not be shown yet.
+ Assert.ok(identityWin.document.getElementById("identityE2ETab").hidden);
+
+ await dialogClosed(identityWin);
+
+ Assert.equal(
+ identitiesList.childElementCount,
+ 2,
+ "should have 2 identities now"
+ );
+});
+
+async function test_identity_idx(idx) {
+ info(`Checking identity #${idx}`);
+ let identityWin = await identityDialogLoaded(idx);
+
+ let identity = gPopAccount.identities[idx];
+ Assert.ok(!!identity, "identity #1 should be set");
+ let keyId = identity.getCharAttribute("openpgp_key_id");
+
+ // The e2e tab should now be shown.
+ Assert.ok(
+ !identityWin.document.getElementById("identityE2ETab").hidden,
+ "e2e tab should show"
+ );
+ // Click the e2e tab to switch to it (for further clicks below).
+ EventUtils.synthesizeMouseAtCenter(
+ identityWin.document.getElementById("identityE2ETab"),
+ {},
+ identityWin
+ );
+
+ Assert.equal(
+ identityWin.document.getElementById("openPgpKeyListRadio").value,
+ keyId,
+ "keyId should be correct"
+ );
+
+ Assert.equal(
+ identityWin.document
+ .getElementById("openPgpKeyListRadio")
+ .querySelectorAll("radio[selected]").length,
+ 1,
+ "Should have exactly one key selected (can be None)"
+ );
+
+ if (keyId) {
+ // Click "More information", then "Key Properties" to see that the key
+ // properties dialog opens.
+ let keyDetailsDialogLoaded = promiseLoadSubDialog(
+ "chrome://openpgp/content/ui/keyDetailsDlg.xhtml"
+ );
+ info(`Will open key details dialog for key 0x${keyId}`);
+ let arrowHead = identityWin.document.querySelector(
+ `#openPgpOption${keyId} button.arrowhead`
+ );
+ arrowHead.scrollIntoView(); // Test window is small on CI...
+ EventUtils.synthesizeMouseAtCenter(arrowHead, {}, identityWin);
+ let propsButton = identityWin.document.querySelector(
+ `#openPgpOption${keyId} button.openpgp-props-btn`
+ );
+ Assert.ok(BrowserTestUtils.is_visible(propsButton));
+ propsButton.scrollIntoView(); // Test window is small on CI...
+ EventUtils.synthesizeMouseAtCenter(propsButton, {}, identityWin);
+ let keyDetailsDialog = await keyDetailsDialogLoaded;
+ info(`Key details dialog for key 0x${keyId} loaded`);
+ keyDetailsDialog.close();
+ }
+
+ Assert.equal(
+ identityWin.document.getElementById("encryptionChoices").value,
+ identity.encryptionPolicy,
+ "Encrypt setting should be correct"
+ );
+
+ // Signing checked based on the pref.
+ Assert.equal(
+ identityWin.document.getElementById("identity_sign_mail").checked,
+ identity.signMail
+ );
+ // Disabled if the identity don't have a key configured.
+ Assert.equal(
+ identityWin.document.getElementById("identity_sign_mail").disabled,
+ !identity.getCharAttribute("openpgp_key_id")
+ );
+
+ return dialogClosed(identityWin);
+}
+
+add_task(async function test_identity_idx_1() {
+ return test_identity_idx(1);
+});
+
+add_task(async function test_identity_changes() {
+ let identity = gPopAccount.identities[1];
+
+ // Check that prefs were copied from identity 0 to identity 1
+ Assert.equal(
+ identity.autoEncryptDrafts,
+ false,
+ "encrypted drafts should be disabled in [1] because we disabled it in [0]"
+ );
+ Assert.equal(
+ identity.protectSubject,
+ true,
+ "protected subject should be enabled in [1] because it is enabled in [0]"
+ );
+ Assert.equal(
+ identity.signMail,
+ false,
+ "signing should be disabled in [1] because it is disabled in [0]"
+ );
+
+ // Let's poke identity 1 and check the changes got applied
+ // Note: can't set the prefs to encrypt/sign unless there's also a key.
+
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc"
+ )
+ )
+ );
+ info(`Set up openpgp key; id=${id}`);
+
+ identity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+ identity.signMail = "true"; // Sign by default.
+ identity.encryptionPolicy = 2; // Require encryption.
+ info("Modified identity 1 - will check it now");
+ await test_identity_idx(1);
+
+ info("Will load identity 0 again and re-check that");
+ await test_identity_idx(0);
+});
diff --git a/comm/mail/test/browser/account/browser_portSetting.js b/comm/mail/test/browser/account/browser_portSetting.js
new file mode 100644
index 0000000000..ae2c613b56
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_portSetting.js
@@ -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";
+
+var { click_account_tree_row, get_account_tree_row, open_advanced_settings } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+ );
+var { FAKE_SERVER_HOSTNAME, mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { input_value, delete_all_existing } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var PORT_NUMBERS_TO_TEST = [
+ "110", // The original port number. We don't input this though.
+ "456", // Random port number.
+ "995", // The SSL port number.
+ "110", // Back to the original.
+];
+
+var gTestNumber;
+
+async function subtest_check_set_port_number(tab, dontSet) {
+ // This test expects the following POP account to exist by default
+ // with port number 110 and no security.
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ let account = MailServices.accounts.FindAccountForServer(server);
+
+ let accountRow = get_account_tree_row(account.key, "am-server.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+ let portElem = iframe.contentDocument.getElementById("server.port");
+ portElem.focus();
+
+ if (portElem.value != PORT_NUMBERS_TO_TEST[gTestNumber - 1]) {
+ throw new Error(
+ "Port Value is not " +
+ PORT_NUMBERS_TO_TEST[gTestNumber - 1] +
+ " as expected, it is: " +
+ portElem.value
+ );
+ }
+
+ if (!dontSet) {
+ delete_all_existing(mc, portElem);
+ input_value(mc, PORT_NUMBERS_TO_TEST[gTestNumber]);
+
+ await new Promise(resolve => setTimeout(resolve));
+ }
+}
+
+async function subtest_check_port_number(tab) {
+ await subtest_check_set_port_number(tab, true);
+}
+
+add_task(async function test_account_port_setting() {
+ for (
+ gTestNumber = 1;
+ gTestNumber < PORT_NUMBERS_TO_TEST.length;
+ ++gTestNumber
+ ) {
+ await open_advanced_settings(subtest_check_set_port_number);
+ }
+
+ await open_advanced_settings(subtest_check_port_number);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/account/browser_retestConfig.js b/comm/mail/test/browser/account/browser_retestConfig.js
new file mode 100644
index 0000000000..d6d66740c2
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_retestConfig.js
@@ -0,0 +1,110 @@
+/* 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 { openAccountSetup } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { input_value, delete_all_existing } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var user = {
+ name: "test",
+ email: "test@momo.invalid",
+ altEmail: "test2@momo.invalid",
+};
+
+const PREF_NAME = "mailnews.auto_config_url";
+const PREF_VALUE = Services.prefs.getCharPref(PREF_NAME);
+
+add_setup(function () {
+ Services.prefs.setCharPref("mail.setup.loglevel", "All");
+
+ let url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/account/xml/";
+ Services.prefs.setCharPref(PREF_NAME, url);
+});
+
+registerCleanupFunction(function () {
+ Services.prefs.setCharPref(PREF_NAME, PREF_VALUE);
+ Services.prefs.clearUserPref("mail.setup.loglevel");
+});
+
+add_task(async function test_re_test_config() {
+ // Opening multiple windows in the same run seems to require letting the stack
+ // unwind before opening the next one, so do that here.
+ let tab = await openAccountSetup();
+ let tabDocument = tab.browser.contentWindow.document;
+ // Input user's account information
+ EventUtils.synthesizeMouseAtCenter(
+ tabDocument.getElementById("realname"),
+ {},
+ tab.browser.contentWindow
+ );
+
+ if (tabDocument.getElementById("realname").value) {
+ // If any realname is already filled, clear it out, we have our own.
+ delete_all_existing(mc, tabDocument.getElementById("realname"));
+ }
+ input_value(mc, user.name);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ input_value(mc, user.email);
+
+ // Click "continue" button.
+ let nextButton = tabDocument.getElementById("continueButton");
+ EventUtils.synthesizeMouseAtCenter(nextButton, {}, tab.browser.contentWindow);
+
+ // Wait for 'edit' button to be enabled.
+ let editButton = tabDocument.getElementById("manualConfigButton");
+ await BrowserTestUtils.waitForCondition(
+ () => !editButton.hidden && !editButton.disabled,
+ "Timeout waiting for edit button to become visible and active"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, tab.browser.contentWindow);
+
+ // Click "re-test" button.
+ let testButton = tabDocument.getElementById("reTestButton");
+ EventUtils.synthesizeMouseAtCenter(testButton, {}, tab.browser.contentWindow);
+
+ await BrowserTestUtils.waitForCondition(
+ () => !testButton.disabled,
+ "Timeout waiting for re-test button to become active"
+ );
+
+ // There used to be a "start over" button (line commented out below). Now just
+ // changing the value of the email field does the trick.
+ tabDocument.getElementById("realname").focus();
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ tabDocument.getElementById("email").focus();
+ input_value(mc, user.altEmail);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+
+ // Wait for the "continue" button to be back, which means we're back to the
+ // original state.
+ await BrowserTestUtils.waitForCondition(
+ () => !nextButton.hidden,
+ "Timeout waiting for continue button to become visible"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(nextButton, {}, tab.browser.contentWindow);
+
+ // Previously, we'd switched to the manual editing state. Now we've started
+ // over, we should make sure the information is presented back in its original
+ // "automatic" mode.
+ Assert.ok(
+ tabDocument.getElementById("manualConfigArea").hidden,
+ "We're not back to the original state!"
+ );
+
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+});
diff --git a/comm/mail/test/browser/account/browser_settingsInfrastructure.js b/comm/mail/test/browser/account/browser_settingsInfrastructure.js
new file mode 100644
index 0000000000..184479c5e8
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_settingsInfrastructure.js
@@ -0,0 +1,488 @@
+/* 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 test checks proper operation of the account settings panes infrastructure
+ * in the Account manager. E.g. if the values of elements are properly stored when
+ * panes are switched.
+ *
+ * New checks can be added to it as needed.
+ */
+
+"use strict";
+
+var { click_account_tree_row, get_account_tree_row, open_advanced_settings } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+ );
+var { FAKE_SERVER_HOSTNAME } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var gPopAccount, gImapAccount, gOriginalAccountCount;
+
+add_setup(function () {
+ // There may be pre-existing accounts from other tests.
+ gOriginalAccountCount = MailServices.accounts.allServers.length;
+
+ // Create a POP server
+ let popServer = MailServices.accounts
+ .createIncomingServer("nobody", "pop.invalid", "pop3")
+ .QueryInterface(Ci.nsIPop3IncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@pop.invalid";
+
+ gPopAccount = MailServices.accounts.createAccount();
+ gPopAccount.incomingServer = popServer;
+ gPopAccount.addIdentity(identity);
+
+ // Create an IMAP server
+ let imapServer = MailServices.accounts
+ .createIncomingServer("nobody", "imap.invalid", "imap")
+ .QueryInterface(Ci.nsIImapIncomingServer);
+
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@imap.invalid";
+
+ gImapAccount = MailServices.accounts.createAccount();
+ gImapAccount.incomingServer = imapServer;
+ gImapAccount.addIdentity(identity);
+
+ // Now there should be one more account.
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ gOriginalAccountCount + 2
+ );
+});
+
+registerCleanupFunction(function () {
+ // Remove our test accounts to leave the profile clean.
+ MailServices.accounts.removeAccount(gPopAccount);
+ MailServices.accounts.removeAccount(gImapAccount);
+
+ // There should be only the original accounts left.
+ Assert.equal(MailServices.accounts.allServers.length, gOriginalAccountCount);
+});
+
+/**
+ * Bug 525024.
+ * Check that the options in the server pane are properly preserved across
+ * pane switches.
+ */
+add_task(async function test_account_dot_IDs() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_account_dot_IDs(tab);
+ });
+});
+
+/**
+ * Check that the options in the server pane are stored even if the id
+ * of the element contains multiple dots (not used in standard TB yet
+ * but extensions may want it).
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_account_dot_IDs(tab) {
+ let accountRow = get_account_tree_row(
+ gPopAccount.key,
+ "am-server.xhtml",
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+ // Check whether a standard element with "server.loginAtStartUp" stores its
+ // value properly.
+ let loginCheck = iframe.getElementById("server.loginAtStartUp");
+ Assert.ok(!loginCheck.checked);
+ EventUtils.synthesizeMouseAtCenter(loginCheck, {}, loginCheck.ownerGlobal);
+
+ accountRow = get_account_tree_row(gPopAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ accountRow = get_account_tree_row(gPopAccount.key, "am-server.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ // Re-assign iframe.contentDocument because it was lost when changing panes
+ // (uses loadURI to load a new document).
+ iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ // Check by element properties.
+ loginCheck = iframe.getElementById("server.loginAtStartUp");
+ Assert.ok(loginCheck.checked);
+
+ // Check for correct value in the accountValues array, that will be saved into prefs.
+ let rawCheckValue = tab.browser.contentWindow.getAccountValue(
+ gPopAccount,
+ tab.browser.contentWindow.getValueArrayFor(gPopAccount),
+ "server",
+ "loginAtStartUp",
+ null,
+ false
+ );
+
+ Assert.ok(rawCheckValue);
+
+ // The "server.login.At.StartUp" value does not exist yet, so the value should be 'null'.
+ rawCheckValue = tab.browser.contentWindow.getAccountValue(
+ gPopAccount,
+ tab.browser.contentWindow.getValueArrayFor(gPopAccount),
+ "server",
+ "login.At.StartUp",
+ null,
+ false
+ );
+ Assert.equal(rawCheckValue, null);
+
+ // Change the ID so that "server.login.At.StartUp" exists now.
+ loginCheck.id = "server.login.At.StartUp";
+
+ EventUtils.synthesizeMouseAtCenter(loginCheck, {}, loginCheck.ownerGlobal);
+ EventUtils.synthesizeMouseAtCenter(loginCheck, {}, loginCheck.ownerGlobal);
+
+ // Check for correct value in the accountValues array, that will be saved into prefs.
+ rawCheckValue = tab.browser.contentWindow.getAccountValue(
+ gPopAccount,
+ tab.browser.contentWindow.getValueArrayFor(gPopAccount),
+ "server",
+ "login.At.StartUp",
+ null,
+ false
+ );
+
+ Assert.ok(rawCheckValue);
+}
+
+/**
+ * Test for bug 807101.
+ * Check if form controls are properly disabled when their attached prefs are locked.
+ */
+add_task(async function test_account_locked_prefs() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_locked_prefs_addressing(tab);
+ });
+
+ await open_advanced_settings(function (tab) {
+ subtest_check_locked_prefs_server(tab);
+ });
+});
+
+/**
+ * Check that the LDAP server selection elements (radio group) are properly
+ * disabled when their attached pref (prefstring attribute) is locked.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_locked_prefs_addressing(tab) {
+ let accountRow = get_account_tree_row(
+ gPopAccount.key,
+ "am-addressing.xhtml",
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ // By default, 'use global LDAP server preferences' is set, not the
+ // 'different LDAP server'.
+ let useLDAPdirectory = iframe.getElementById("directories");
+ Assert.ok(!useLDAPdirectory.selected);
+
+ // So the server selector is disabled.
+ let LDAPdirectory = iframe.getElementById("identity.directoryServer");
+ Assert.ok(LDAPdirectory.disabled);
+
+ // And the Edit button too.
+ let LDAPeditButton = iframe.getElementById("editButton");
+ Assert.ok(LDAPeditButton.disabled);
+
+ // Now toggle the 'different LDAP server' on. The server selector
+ // and edit button should enable.
+ EventUtils.synthesizeMouseAtCenter(
+ useLDAPdirectory,
+ {},
+ useLDAPdirectory.ownerGlobal
+ );
+ Assert.ok(!LDAPdirectory.disabled);
+ Assert.ok(!LDAPeditButton.disabled);
+
+ // Lock the pref for the server selector.
+ let prefstring = LDAPdirectory.getAttribute("prefstring");
+ let controlPref = prefstring.replace(
+ "%identitykey%",
+ gPopAccount.defaultIdentity.key
+ );
+ Services.prefs.getDefaultBranch("").setBoolPref(controlPref, "xxx");
+ Services.prefs.lockPref(controlPref);
+
+ // Refresh the pane by switching to another one.
+ accountRow = get_account_tree_row(gPopAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ accountRow = get_account_tree_row(
+ gPopAccount.key,
+ "am-addressing.xhtml",
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ // Re-assign iframe.contentDocument because it was lost when changing panes
+ // (uses loadURI to load a new document).
+ iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ // We are now back and the 'different LDAP server' should still be selected
+ // (the setting was saved).
+ useLDAPdirectory = iframe.getElementById("directories");
+ Assert.ok(useLDAPdirectory.selected);
+
+ // But now the server selector should be disabled due to locked pref.
+ LDAPdirectory = iframe.getElementById("identity.directoryServer");
+ Assert.ok(LDAPdirectory.disabled);
+
+ // The edit button still enabled (does not depend on the same pref lock)
+ LDAPeditButton = iframe.getElementById("editButton");
+ Assert.ok(!LDAPeditButton.disabled);
+
+ // Unlock the pref to clean up.
+ Services.prefs.unlockPref(controlPref);
+ Services.prefs.getDefaultBranch("").deleteBranch(controlPref);
+}
+
+/**
+ * Check that the POP3 'keep on server' settings elements (2-level
+ * checkboxes + textbox) are properly disabled when their attached pref
+ * (prefstring attribute) is locked.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_locked_prefs_server(tab) {
+ let accountRow = get_account_tree_row(
+ gPopAccount.key,
+ "am-server.xhtml",
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ // Top level leaveOnServer checkbox, disabled by default.
+ let leaveOnServer = iframe.getElementById("pop3.leaveMessagesOnServer");
+ Assert.ok(!leaveOnServer.disabled);
+ Assert.ok(!leaveOnServer.checked);
+
+ // Second level deleteByAge checkbox, disabled by default.
+ let deleteByAge = iframe.getElementById("pop3.deleteByAgeFromServer");
+ Assert.ok(deleteByAge.disabled);
+ Assert.ok(!deleteByAge.checked);
+
+ // Third level daysToLeave textbox, disabled by default.
+ let daysToLeave = iframe.getElementById("pop3.numDaysToLeaveOnServer");
+ Assert.ok(daysToLeave.disabled);
+
+ // When leaveOnServer is checked, only deleteByAge will get enabled.
+ EventUtils.synthesizeMouseAtCenter(
+ leaveOnServer,
+ {},
+ leaveOnServer.ownerGlobal
+ );
+ Assert.ok(leaveOnServer.checked);
+ Assert.ok(!deleteByAge.disabled);
+ Assert.ok(daysToLeave.disabled);
+
+ // When deleteByAge is checked, daysToLeave will get enabled.
+ EventUtils.synthesizeMouseAtCenter(deleteByAge, {}, deleteByAge.ownerGlobal);
+ Assert.ok(deleteByAge.checked);
+ Assert.ok(!daysToLeave.disabled);
+
+ // Lock the pref deleteByAge checkbox (middle of the element hierarchy).
+ let prefstring = deleteByAge.getAttribute("prefstring");
+ let controlPref = prefstring.replace(
+ "%serverkey%",
+ gPopAccount.incomingServer.key
+ );
+ Services.prefs.getDefaultBranch("").setBoolPref(controlPref, true);
+ Services.prefs.lockPref(controlPref);
+
+ // Refresh the pane by switching to another one.
+ accountRow = get_account_tree_row(gPopAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ accountRow = get_account_tree_row(gPopAccount.key, "am-server.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ // Re-assign iframe.contentDocument because it was lost when changing panes
+ // (uses loadURI to load a new document).
+ iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ // Now leaveOnServer was preserved as checked.
+ leaveOnServer = iframe.getElementById("pop3.leaveMessagesOnServer");
+ Assert.ok(!leaveOnServer.disabled);
+ Assert.ok(leaveOnServer.checked);
+
+ // Now deleteByAge was preserved as checked but is locked/disabled.
+ deleteByAge = iframe.getElementById("pop3.deleteByAgeFromServer");
+ Assert.ok(deleteByAge.disabled);
+ Assert.ok(deleteByAge.checked);
+
+ // Because deleteByAge is checked, daysToLeave should be enabled.
+ daysToLeave = iframe.getElementById("pop3.numDaysToLeaveOnServer");
+ Assert.ok(!daysToLeave.disabled);
+
+ // When leaveOnserver is unchecked, both of deleteByAge and daysToLeave
+ // should get disabled.
+ EventUtils.synthesizeMouseAtCenter(
+ leaveOnServer,
+ {},
+ leaveOnServer.ownerGlobal
+ );
+ Assert.ok(!leaveOnServer.disabled);
+ Assert.ok(!leaveOnServer.checked);
+
+ Assert.ok(deleteByAge.disabled);
+ Assert.ok(deleteByAge.checked);
+ Assert.ok(daysToLeave.disabled);
+
+ // Unlock the pref to clean up.
+ Services.prefs.unlockPref(controlPref);
+ Services.prefs.getDefaultBranch("").deleteBranch(controlPref);
+}
+
+/**
+ * Bug 530142.
+ * Check that that if one field is set to a value, switching directly to another
+ * account pane showing the same field really loads the value from the new account,
+ * even when empty. This is tested on the Reply-To field.
+ */
+add_task(async function test_replyTo_leak() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_replyTo_leak(tab);
+ });
+});
+
+/**
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_replyTo_leak(tab) {
+ let accountRow = get_account_tree_row(gPopAccount.key, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ // The Reply-To field should be empty.
+ let replyAddress = iframe.getElementById("identity.replyTo");
+ Assert.equal(replyAddress.value, "");
+
+ // Now we set a value into it and switch to another account, the main pane.
+ replyAddress.value = "somewhere@else.com";
+
+ // This test expects the following POP account to exist by default
+ // in the test profile with port number 110 and no security.
+ let firstServer = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ let firstAccount = MailServices.accounts.FindAccountForServer(firstServer);
+
+ accountRow = get_account_tree_row(firstAccount.key, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ // the Reply-To field should be empty as this account does not have it set.
+ replyAddress = iframe.getElementById("identity.replyTo");
+ Assert.equal(replyAddress.value, "");
+}
+
+/**
+ * Test for bug 804091.
+ * Check if onchange handlers are properly executed when panes are switched.
+ */
+add_task(async function test_account_onchange_handler() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_onchange_handler(tab);
+ });
+});
+
+/**
+ * Check if onchange handlers are properly executed when panes are switched.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_onchange_handler(tab) {
+ let accountRow = get_account_tree_row(
+ gImapAccount.key,
+ "am-offline.xhtml",
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ let autoSync = iframe.getElementById("autosyncValue");
+ // 30 is the default value so check if we are in clean state.
+ Assert.equal(autoSync.value, 30);
+
+ let autoSyncInterval = iframe.getElementById("autosyncInterval");
+ // 1 is the default value and means the 30 is in days.
+ Assert.equal(autoSyncInterval.value, 1);
+
+ // Now type in 35 (days).
+ let byAge = iframe.getElementById("useAutosync.ByAge");
+ EventUtils.synthesizeMouseAtCenter(byAge, {}, byAge.ownerGlobal);
+ autoSync.select();
+ autoSync.focus();
+ EventUtils.sendString("35", mc.window);
+
+ // Immediately switch to another pane and back.
+ accountRow = get_account_tree_row(gImapAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ accountRow = get_account_tree_row(gImapAccount.key, "am-offline.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ iframe =
+ tab.browser.contentWindow.document.getElementById(
+ "contentFrame"
+ ).contentDocument;
+
+ // The pane optimized the entered value a bit. So now we should find 5.
+ autoSync = iframe.getElementById("autosyncValue");
+ Assert.equal(autoSync.value, 5);
+
+ // And the unit is 7 days = week.
+ autoSyncInterval = iframe.getElementById("autosyncInterval");
+ Assert.equal(autoSyncInterval.value, 7);
+}
diff --git a/comm/mail/test/browser/account/browser_tree.js b/comm/mail/test/browser/account/browser_tree.js
new file mode 100644
index 0000000000..547e6de489
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_tree.js
@@ -0,0 +1,208 @@
+/* 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 test checks proper operation of the account tree in the Account manager.
+ */
+
+"use strict";
+
+var {
+ click_account_tree_row,
+ get_account_tree_row,
+ open_advanced_settings,
+ remove_account,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { content_tab_e } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+
+var gPopAccount, gOriginalAccountCount;
+
+add_setup(function () {
+ // There may be pre-existing accounts from other tests.
+ gOriginalAccountCount = MailServices.accounts.allServers.length;
+
+ // Create a POP server
+ let popServer = MailServices.accounts
+ .createIncomingServer("nobody", "foo.invalid", "pop3")
+ .QueryInterface(Ci.nsIPop3IncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@foo.invalid";
+
+ gPopAccount = MailServices.accounts.createAccount();
+ gPopAccount.incomingServer = popServer;
+ gPopAccount.addIdentity(identity);
+
+ // Now there should be one more account.
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ gOriginalAccountCount + 1
+ );
+});
+
+registerCleanupFunction(function () {
+ if (gPopAccount) {
+ // Remove our test account to leave the profile clean.
+ MailServices.accounts.removeAccount(gPopAccount);
+ gPopAccount = null;
+ }
+ // There should be only the original accounts left.
+ Assert.equal(MailServices.accounts.allServers.length, gOriginalAccountCount);
+});
+
+/**
+ * Test for bug 536248.
+ * Check if the account manager dialog remembers the open state of accounts.
+ */
+add_task(async function test_account_open_state() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_account_open_state(tab, true);
+ });
+ await open_advanced_settings(function (tab) {
+ subtest_check_account_open_state(tab, false);
+ });
+ // After this test all the accounts must be "open".
+});
+
+/**
+ * Check if the open state of accounts is in the wished state.
+ *
+ * @param {object} tab - The account manager tab.
+ * @param {boolean} wishedState - The open state in which the account row should be found.
+ */
+function subtest_check_account_open_state(tab, wishedState) {
+ let accountRow = get_account_tree_row(gPopAccount.key, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ // See if the account row is in the wished open state.
+ let accountTree = content_tab_e(tab, "accounttree");
+ Assert.equal(accountRow, accountTree.selectedIndex);
+ Assert.equal(
+ !accountTree.rows[accountRow].classList.contains("collapsed"),
+ wishedState
+ );
+
+ accountTree.rows[accountRow].classList.toggle("collapsed");
+ Assert.equal(
+ accountTree.rows[accountRow].classList.contains("collapsed"),
+ wishedState
+ );
+
+ // Whatever the open state of the account was, selecting one of its subpanes
+ // must open it.
+ tab.browser.contentWindow.selectServer(
+ gPopAccount.incomingServer,
+ "am-junk.xhtml"
+ );
+ Assert.ok(!accountTree.rows[accountRow].classList.contains("collapsed"));
+
+ // Set the proper state again for continuation of the test.
+ if (wishedState) {
+ accountTree.collapseRowAtIndex(accountRow);
+ } else {
+ accountTree.expandRowAtIndex(accountRow);
+ }
+ Assert.equal(
+ accountTree.rows[accountRow].classList.contains("collapsed"),
+ wishedState
+ );
+}
+
+/**
+ * Bug 740617.
+ * Check if the default account is styled in bold.
+ */
+add_task(async function test_default_account_highlight() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_default_account_highlight(tab);
+ });
+});
+
+/**
+ * Check if the default account is styled in bold and another account is not.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_default_account_highlight(tab) {
+ // Select the default account.
+ let accountRow = get_account_tree_row(
+ MailServices.accounts.defaultAccount.key,
+ null,
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ let accountTree = content_tab_e(tab, "accounttree");
+ Assert.equal(accountRow, accountTree.selectedIndex);
+
+ // We can't read the computed style of the tree cell directly, so at least see
+ // if the isDefaultServer-true property is set on it. Hopefully the proper style
+ // is attached to this property.
+ Assert.ok(accountTree.rows[accountRow].classList.contains("isDefaultServer"));
+
+ // Now select another account that is not default.
+ accountRow = get_account_tree_row(gPopAccount.key, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ // There should isDefaultServer-true on its tree cell.
+ Assert.ok(
+ !accountTree.rows[accountRow].classList.contains("isDefaultServer")
+ );
+}
+/**
+ * Bug 58713.
+ * Check if after deleting an account the next one is selected.
+ *
+ * This test should always be the last one as it removes our specially
+ * created gPopAccount.
+ */
+add_task(async function test_selection_after_account_deletion() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_selection_after_account_deletion(tab);
+ });
+});
+
+/**
+ * Check if after deleting an account the next one is selected.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_selection_after_account_deletion(tab) {
+ let accountList = [];
+ let accountTree = content_tab_e(tab, "accounttree");
+ // Build the list of accounts in the account tree (order is important).
+ for (let row of accountTree.children) {
+ if ("_account" in row) {
+ let curAccount = row._account;
+ if (!accountList.includes(curAccount)) {
+ accountList.push(curAccount);
+ }
+ }
+ }
+
+ // Get position of the current account in the account list.
+ let accountIndex = accountList.indexOf(gPopAccount);
+
+ // Remove our account.
+ remove_account(gPopAccount, tab);
+ gPopAccount = null;
+ // Now there should be only the original accounts left.
+ Assert.equal(MailServices.accounts.allServers.length, gOriginalAccountCount);
+
+ // See if the currently selected account is the one next in the account list.
+ let accountRow = accountTree.selectedIndex;
+ Assert.equal(
+ accountTree.rows[accountRow]._account,
+ accountList[accountIndex + 1]
+ );
+}
diff --git a/comm/mail/test/browser/account/browser_values.js b/comm/mail/test/browser/account/browser_values.js
new file mode 100644
index 0000000000..8d44a65f5a
--- /dev/null
+++ b/comm/mail/test/browser/account/browser_values.js
@@ -0,0 +1,401 @@
+/* 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 test checks proper operation of the account settings panes
+ * when certain special or invalid values are entered into the fields.
+ */
+
+"use strict";
+
+var { click_account_tree_row, get_account_tree_row, open_advanced_settings } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+ );
+var { input_value } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+var { gMockPromptService } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PromptHelpers.jsm"
+);
+var { plan_for_modal_dialog, wait_for_modal_dialog } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var gPopAccount, gOriginalAccountCount;
+
+add_setup(function () {
+ // There may be pre-existing accounts from other tests.
+ gOriginalAccountCount = MailServices.accounts.allServers.length;
+
+ // Create a POP server
+ let popServer = MailServices.accounts
+ .createIncomingServer("nobody", "example.invalid", "pop3")
+ .QueryInterface(Ci.nsIPop3IncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@example.invalid";
+
+ gPopAccount = MailServices.accounts.createAccount();
+ gPopAccount.incomingServer = popServer;
+ gPopAccount.addIdentity(identity);
+
+ // Now there should be one more account.
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ gOriginalAccountCount + 1
+ );
+});
+
+registerCleanupFunction(function () {
+ // Remove our test account to leave the profile clean.
+ MailServices.accounts.removeAccount(gPopAccount);
+ // There should be only the original accounts left.
+ Assert.equal(MailServices.accounts.allServers.length, gOriginalAccountCount);
+});
+
+/**
+ * Bug 208628.
+ * Check that if the CC field is empty, enabling CC will automatically
+ * prefill the currently default email address.
+ */
+add_task(async function test_default_CC_address() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_default_CC_address(tab);
+ });
+});
+
+/**
+ * Check that if the CC field is empty, enabling CC will automatically
+ * prefill the currently default email address.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_default_CC_address(tab) {
+ let accountRow = get_account_tree_row(
+ gPopAccount.key,
+ "am-copies.xhtml",
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+
+ let defaultAddress =
+ iframe.contentDocument.getElementById("identity.email").value;
+ let ccCheck = iframe.contentDocument.getElementById("identity.doCc");
+ let ccAddress = iframe.contentDocument.getElementById("identity.doCcList");
+ // The CC checkbox is not enabled and the address value is empty.
+ Assert.ok(!ccCheck.checked);
+ Assert.equal(ccAddress.value, "");
+ // After ticking the CC checkbox the default address should be prefilled.
+ EventUtils.synthesizeMouseAtCenter(ccCheck, {}, ccCheck.ownerGlobal);
+ Assert.equal(ccAddress.value, defaultAddress);
+
+ let bccCheck = iframe.contentDocument.getElementById("identity.doBcc");
+ let bccAddress = iframe.contentDocument.getElementById("identity.doBccList");
+ // The BCC checkbox is not enabled but we set the address value to something.
+ Assert.ok(!bccCheck.checked);
+ Assert.equal(bccAddress.value, "");
+ let bccUserAddress = "somebody@else.invalid";
+ bccAddress.value = bccUserAddress;
+ // After ticking the BCC checkbox the current value of the address should not change.
+ EventUtils.synthesizeMouseAtCenter(bccCheck, {}, bccCheck.ownerGlobal);
+ Assert.equal(bccAddress.value, bccUserAddress);
+}
+
+/**
+ * Bug 720199.
+ * Check if the account name automatically changes when the user changes
+ * the username or hostname.
+ */
+add_task(async function test_account_name() {
+ // We already have a POP account ready.
+ // Create also a NNTP server.
+ let nntpServer = MailServices.accounts
+ .createIncomingServer(null, "example.nntp.invalid", "nntp")
+ .QueryInterface(Ci.nsINntpIncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox2@example.invalid";
+
+ let nntpAccount = MailServices.accounts.createAccount();
+ nntpAccount.incomingServer = nntpServer;
+ nntpAccount.addIdentity(identity);
+
+ Assert.equal(
+ gPopAccount.incomingServer.prettyName,
+ "nobody on example.invalid"
+ );
+ Assert.equal(nntpAccount.incomingServer.prettyName, "example.nntp.invalid");
+
+ // The automatic account name update works only if the name is
+ // in the form of user@host.
+ gPopAccount.incomingServer.prettyName = "nobody@example.invalid";
+
+ let newHost = "some.host.invalid";
+ let newUser = "somebody";
+
+ // On NNTP there is no user name so just set new hostname.
+ await open_advanced_settings(function (tab) {
+ subtest_check_account_name(nntpAccount, newHost, null, tab);
+ });
+
+ // And see if the account name is updated to it.
+ Assert.equal(nntpAccount.incomingServer.prettyName, newHost);
+
+ // On POP3 there is both user name and host name.
+ // Set new host name first.
+ await open_advanced_settings(function (tab) {
+ subtest_check_account_name(gPopAccount, newHost, null, tab);
+ });
+
+ // And see if in the account name the host part is updated to it.
+ Assert.equal(gPopAccount.incomingServer.prettyName, "nobody@" + newHost);
+
+ // Set new host name first.
+ await open_advanced_settings(function (tab) {
+ subtest_check_account_name(gPopAccount, null, newUser, tab);
+ });
+
+ // And see if in the account name the user part is updated.
+ Assert.equal(gPopAccount.incomingServer.prettyName, newUser + "@" + newHost);
+
+ newHost = "another.host.invalid";
+ newUser = "anotherbody";
+
+ // Set user name and host name at once.
+ await open_advanced_settings(function (tab) {
+ subtest_check_account_name(gPopAccount, newHost, newUser, tab);
+ });
+
+ // And see if in the account name the host part is updated to it.
+ Assert.equal(gPopAccount.incomingServer.prettyName, newUser + "@" + newHost);
+
+ // Now have an account name where the name does not match the hostname.
+ gPopAccount.incomingServer.prettyName = newUser + "@example.invalid";
+
+ newHost = "third.host.invalid";
+ // Set the host name again.
+ await open_advanced_settings(function (tab) {
+ subtest_check_account_name(gPopAccount, newHost, null, tab);
+ });
+
+ // And the account name should not be touched.
+ Assert.equal(
+ gPopAccount.incomingServer.prettyName,
+ newUser + "@example.invalid"
+ );
+
+ MailServices.accounts.removeAccount(nntpAccount);
+}).skip(); // Restart is required to apply change to server name or username.
+
+/**
+ * Changes the user name and hostname to the supplied values.
+ *
+ * @param {object} account - The account to change
+ * @param {string} newHostname - The hostname value to set
+ * @param {string} newUsername - The username value to set
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_account_name(account, newHostname, newUsername, tab) {
+ let accountRow = get_account_tree_row(account.key, "am-server.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+
+ if (newHostname) {
+ let hostname = iframe.contentDocument.getElementById("server.hostName");
+ Assert.equal(hostname.value, account.incomingServer.hostName);
+
+ // Now change the server host name.
+ hostname.value = newHostname;
+ }
+
+ if (newUsername) {
+ let username = iframe.contentDocument.getElementById("server.username");
+ Assert.equal(username.value, account.incomingServer.username);
+
+ // Now change the server user name.
+ username.value = newUsername;
+ }
+
+ if (newUsername) {
+ gMockPromptService.register();
+ }
+
+ tab.browser.contentWindow.onAccept(true);
+ if (newUsername) {
+ Assert.equal("alert", gMockPromptService.promptState.method);
+ gMockPromptService.unregister();
+ }
+}
+
+/**
+ * Bug 536768.
+ * Check if invalid junk target settings (folders) are fixed to sane values.
+ */
+add_task(async function test_invalid_junk_target() {
+ // Set the junk target prefs to invalid values.
+ let branch = Services.prefs.getBranch(
+ "mail.server." + gPopAccount.incomingServer.key + "."
+ );
+ branch.setCharPref("spamActionTargetAccount", "some random non-existent URI");
+ branch.setStringPref(
+ "spamActionTargetFolder",
+ "some random non-existent URI"
+ );
+ let moveOnSpam = true;
+ branch.setBoolPref("moveOnSpam", moveOnSpam);
+ await open_advanced_settings(function (tab) {
+ subtest_check_invalid_junk_target(tab);
+ });
+
+ // The pref has no default so its non-existence means it was cleared.
+ moveOnSpam = branch.getBoolPref("moveOnSpam", false);
+ Assert.ok(!moveOnSpam);
+ // The targets should point to the same pop account now.
+ let targetAccount = branch.getCharPref("spamActionTargetAccount");
+ Assert.equal(targetAccount, gPopAccount.incomingServer.serverURI);
+ let targetFolder = branch.getStringPref("spamActionTargetFolder");
+ Assert.equal(targetFolder, gPopAccount.incomingServer.serverURI + "/Junk");
+});
+
+/**
+ * Just show the Junk settings pane and let it fix the values.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_invalid_junk_target(tab) {
+ let accountRow = get_account_tree_row(gPopAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+ tab.browser.contentWindow.onAccept(true);
+}
+
+/**
+ * Bug 327812.
+ * Checks if invalid server hostnames are not accepted.
+ */
+add_task(async function test_invalid_hostname() {
+ let branch = Services.prefs.getBranch(
+ "mail.server." + gPopAccount.incomingServer.key + "."
+ );
+ let origHostname = branch.getCharPref("hostname");
+
+ await open_advanced_settings(function (tab) {
+ subtest_check_invalid_hostname(tab, false, origHostname);
+ });
+ await open_advanced_settings(function (tab) {
+ subtest_check_invalid_hostname(tab, true, origHostname);
+ });
+
+ // The new bad hostname should not have been saved.
+ let newHostname = branch.getCharPref("hostname");
+ Assert.equal(origHostname, newHostname);
+});
+
+/**
+ * Set the hostname to an invalid value and check if it gets fixed.
+ *
+ * @param {object} tab - The account manager tab.
+ * @param {boolean} exitSettings - Attempt to close the Account settings dialog.
+ * @param {string} originalHostname - Original hostname of this server.
+ */
+function subtest_check_invalid_hostname(tab, exitSettings, originalHostname) {
+ let accountRow = get_account_tree_row(
+ gPopAccount.key,
+ "am-server.xhtml",
+ tab
+ );
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+ let hostname = iframe.contentDocument.getElementById("server.hostName");
+ Assert.equal(hostname.value, originalHostname);
+
+ hostname.value = "some_invalid+host&domain*in>invalid";
+
+ if (!exitSettings) {
+ accountRow = get_account_tree_row(gPopAccount.key, "am-junk.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ // The invalid hostname should be set back to previous value at this point...
+ accountRow = get_account_tree_row(gPopAccount.key, "am-server.xhtml", tab);
+ click_account_tree_row(tab, accountRow);
+
+ // ...let's check that:
+ iframe = tab.browser.contentWindow.document.getElementById("contentFrame");
+ hostname = iframe.contentDocument.getElementById("server.hostName");
+ Assert.equal(hostname.value, originalHostname);
+ } else {
+ // If the hostname is bad, we should get a warning dialog.
+ plan_for_modal_dialog("commonDialogWindow", function (cdc) {
+ // Just dismiss it.
+ cdc.window.document.documentElement
+ .querySelector("dialog")
+ .acceptDialog();
+ });
+ tab.browser.contentWindow.onAccept(true);
+ wait_for_modal_dialog("commonDialogWindow");
+ }
+}
+
+/**
+ * Bug 1426328.
+ * Check that the AM will trim user added spaces around text values.
+ */
+const badName = "trailing space ";
+const badEmail = " leading_space@example.com";
+
+add_task(async function test_trailing_spaces() {
+ await open_advanced_settings(function (tab) {
+ subtest_check_trailing_spaces(tab);
+ });
+ Assert.equal(gPopAccount.incomingServer.prettyName, badName.trim());
+ Assert.equal(gPopAccount.defaultIdentity.email, badEmail.trim());
+});
+
+/**
+ * Check that the AM will trim user added spaces around text values
+ * when storing them into the account.
+ *
+ * @param {object} tab - The account manager tab.
+ */
+function subtest_check_trailing_spaces(tab) {
+ let accountRow = get_account_tree_row(gPopAccount.key, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ let iframe =
+ tab.browser.contentWindow.document.getElementById("contentFrame");
+
+ let accountName = iframe.contentDocument.getElementById("server.prettyName");
+ let defaultAddress = iframe.contentDocument.getElementById("identity.email");
+ accountName.value = "";
+ defaultAddress.value = "";
+ input_value(mc, badName, accountName);
+ input_value(mc, badEmail, defaultAddress);
+
+ Assert.equal(
+ accountName.value,
+ badName,
+ "accountName should have the correct value typed in"
+ );
+ // type="email" inputs are now automatically trimmed
+ Assert.equal(
+ defaultAddress.value,
+ badEmail.trim(),
+ "defaultAddress should have the correct value typed in"
+ );
+}
diff --git a/comm/mail/test/browser/account/head.js b/comm/mail/test/browser/account/head.js
new file mode 100644
index 0000000000..8bae9d629a
--- /dev/null
+++ b/comm/mail/test/browser/account/head.js
@@ -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/. */
+
+// From browser/components/preferences/tests/head.js
+
+function is_element_visible(aElement, aMsg) {
+ isnot(aElement, null, "Element should not be null, when checking visibility");
+ ok(!BrowserTestUtils.is_hidden(aElement), aMsg);
+}
+
+function openAndLoadSubDialog(
+ aURL,
+ aFeatures = null,
+ aParams = null,
+ aClosingCallback = null
+) {
+ let promise = promiseLoadSubDialog(aURL);
+ content.gSubDialog.open(
+ aURL,
+ { features: aFeatures, closingCallback: aClosingCallback },
+ aParams
+ );
+ return promise;
+}
+
+function promiseLoadSubDialog(aURL) {
+ if (Services.env.get("MOZ_HEADLESS")) {
+ throw new Error("promiseLoadSubDialog doesn't work in headless mode!");
+ }
+
+ return new Promise((resolve, reject) => {
+ content.gSubDialog._dialogStack.addEventListener(
+ "dialogopen",
+ function dialogopen(aEvent) {
+ if (
+ aEvent.detail.dialog._frame.contentWindow.location == "about:blank"
+ ) {
+ return;
+ }
+ content.gSubDialog._dialogStack.removeEventListener(
+ "dialogopen",
+ dialogopen
+ );
+
+ is(
+ aEvent.detail.dialog._frame.contentWindow.location.toString(),
+ aURL,
+ "Check the proper URL is loaded"
+ );
+
+ // Check visibility
+ is_element_visible(aEvent.detail.dialog._overlay, "Overlay is visible");
+
+ // Check that stylesheets were injected
+ let expectedStyleSheetURLs =
+ aEvent.detail.dialog._injectedStyleSheets.slice(0);
+ for (let styleSheet of aEvent.detail.dialog._frame.contentDocument
+ .styleSheets) {
+ let i = expectedStyleSheetURLs.indexOf(styleSheet.href);
+ if (i >= 0) {
+ info("found " + styleSheet.href);
+ expectedStyleSheetURLs.splice(i, 1);
+ }
+ }
+ is(
+ expectedStyleSheetURLs.length,
+ 0,
+ "All expectedStyleSheetURLs should have been found"
+ );
+
+ // Wait for the next event tick to make sure the remaining part of the
+ // testcase runs after the dialog gets ready for input.
+ executeSoon(() => resolve(aEvent.detail.dialog._frame.contentWindow));
+ }
+ );
+ });
+}
diff --git a/comm/mail/test/browser/account/xml/example-imap.com b/comm/mail/test/browser/account/xml/example-imap.com
new file mode 100644
index 0000000000..783eabcb94
--- /dev/null
+++ b/comm/mail/test/browser/account/xml/example-imap.com
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<clientConfig>
+ <emailProvider id="example-imap.com">
+ <domain>example-imap.com</domain>
+ <displayName>Example Två</displayName>
+ <incomingServer type="imap">
+ <hostname>localhost</hostname>
+ <port>1993</port>
+ <socketType>plain</socketType>
+ <username>john.doe@example-imap.com</username>
+ <password>abc12345</password>
+ <authentication>plain</authentication>
+ </incomingServer>
+ <outgoingServer type="smtp">
+ <hostname>localhost</hostname>
+ <port>1587</port>
+ <socketType>plain</socketType>
+ <username>john.doe@example-imap.com</username>
+ <password>abc12345</password>
+ <authentication>plain</authentication>
+ <addThisServer>true</addThisServer>
+ <useGlobalPreferredServer>false</useGlobalPreferredServer>
+ </outgoingServer>
+ </emailProvider>
+</clientConfig>
diff --git a/comm/mail/test/browser/account/xml/example-imap.com^headers^ b/comm/mail/test/browser/account/xml/example-imap.com^headers^
new file mode 100644
index 0000000000..f203c6368e
--- /dev/null
+++ b/comm/mail/test/browser/account/xml/example-imap.com^headers^
@@ -0,0 +1 @@
+Content-Type: text/xml
diff --git a/comm/mail/test/browser/account/xml/example.com b/comm/mail/test/browser/account/xml/example.com
new file mode 100644
index 0000000000..b07c00fda0
--- /dev/null
+++ b/comm/mail/test/browser/account/xml/example.com
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<clientConfig>
+ <emailProvider id="example.com">
+ <domain>example.com</domain>
+ <displayName>Example Två</displayName>
+ <incomingServer type="pop3">
+ <hostname>testin.%EMAILDOMAIN%</hostname>
+ <port>995</port>
+ <socketType>SSL</socketType>
+ <username>%EMAILLOCALPART%</username>
+ <authentication>plain</authentication>
+ </incomingServer>
+ <outgoingServer type="smtp">
+ <hostname>testout.%EMAILDOMAIN%</hostname>
+ <port>587</port>
+ <socketType>STARTTLS</socketType>
+ <username>%EMAILADDRESS%</username>
+ <password>Blä4</password>
+ <authentication>plain</authentication>
+ <addThisServer>true</addThisServer>
+ <useGlobalPreferredServer>false</useGlobalPreferredServer>
+ </outgoingServer>
+ </emailProvider>
+</clientConfig>
diff --git a/comm/mail/test/browser/account/xml/example.com^headers^ b/comm/mail/test/browser/account/xml/example.com^headers^
new file mode 100644
index 0000000000..f203c6368e
--- /dev/null
+++ b/comm/mail/test/browser/account/xml/example.com^headers^
@@ -0,0 +1 @@
+Content-Type: text/xml
diff --git a/comm/mail/test/browser/account/xml/momo.invalid b/comm/mail/test/browser/account/xml/momo.invalid
new file mode 100644
index 0000000000..f0ba552c9a
--- /dev/null
+++ b/comm/mail/test/browser/account/xml/momo.invalid
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<clientConfig version="1.1">
+ <emailProvider id="momo.invalid">
+ <domain>momo.invalid</domain>
+ <displayName>MoMo Mail</displayName>
+ <displayShortName>Yahoo</displayShortName>
+ <incomingServer type="pop3">
+ <hostname>pop.mail.momo.invalid</hostname>
+ <port>995</port>
+ <socketType>SSL</socketType>
+ <username>%EMAILLOCALPART%</username>
+ <authentication>password-cleartext</authentication>
+ </incomingServer>
+ <incomingServer type="imap">
+ <hostname>imap.mail.momo.invalid</hostname>
+ <port>993</port>
+ <socketType>SSL</socketType>
+ <username>%EMAILLOCALPART%</username>
+ <authentication>password-cleartext</authentication>
+ </incomingServer>
+ <outgoingServer type="smtp">
+ <hostname>smtp.mail.momo.invalid</hostname>
+ <port>465</port>
+ <socketType>SSL</socketType>
+ <username>%EMAILLOCALPART%</username>
+ <authentication>password-cleartext</authentication>
+ </outgoingServer>
+ </emailProvider>
+</clientConfig>
diff --git a/comm/mail/test/browser/account/xml/momo.invalid^headers^ b/comm/mail/test/browser/account/xml/momo.invalid^headers^
new file mode 100644
index 0000000000..f203c6368e
--- /dev/null
+++ b/comm/mail/test/browser/account/xml/momo.invalid^headers^
@@ -0,0 +1 @@
+Content-Type: text/xml
diff --git a/comm/mail/test/browser/attachment/browser.ini b/comm/mail/test/browser/attachment/browser.ini
new file mode 100644
index 0000000000..357098a517
--- /dev/null
+++ b/comm/mail/test/browser/attachment/browser.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+support-files = data/**
+
+[browser_attachment.js]
+[browser_attachmentEvents.js]
+[browser_attachmentInPlainMsg.js]
+[browser_attachmentMenus.js]
+[browser_attachmentSize.js]
+[browser_attachmentIcon.js]
+[browser_openAttachment.js]
diff --git a/comm/mail/test/browser/attachment/browser_attachment.js b/comm/mail/test/browser/attachment/browser_attachment.js
new file mode 100644
index 0000000000..c24a15eff2
--- /dev/null
+++ b/comm/mail/test/browser/attachment/browser_attachment.js
@@ -0,0 +1,764 @@
+/* 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/. */
+
+/**
+ * Checks various attachments display correctly
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_forward } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_attachment_list_focused,
+ assert_message_pane_focused,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_popup,
+ create_folder,
+ create_message,
+ get_about_message,
+ mc,
+ msgGen,
+ plan_to_wait_for_folder_events,
+ select_click_row,
+ select_none,
+ wait_for_folder_events,
+ wait_for_message_display_completion,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { SyntheticPartLeaf, SyntheticPartMultiMixed } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+var {
+ async_plan_for_new_window,
+ close_window,
+ plan_for_modal_dialog,
+ wait_for_modal_dialog,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var folder;
+var messages;
+
+var textAttachment =
+ "One of these days... people like me will rise up and overthrow you, and " +
+ "the end of tyranny by the homeostatic machine will have arrived. The day " +
+ "of human values and compassion and simple warmth will return, and when " +
+ "that happens someone like myself who has gone through an ordeal and who " +
+ "genuinely needs hot coffee to pick him up and keep him functioning when " +
+ "he has to function will get the hot coffee whether he happens to have a " +
+ "poscred readily available or not.";
+
+var binaryAttachment = textAttachment;
+
+add_setup(async function () {
+ folder = await create_folder("AttachmentA");
+
+ var attachedMessage = msgGen.makeMessage({
+ body: { body: "I'm an attached email!" },
+ attachments: [
+ { body: textAttachment, filename: "inner attachment.txt", format: "" },
+ ],
+ });
+
+ // create some messages that have various types of attachments
+ messages = [
+ // no attachment
+ {},
+ // text attachment
+ {
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ },
+ // binary attachment; filename has 9 "1"s, which should be just within the
+ // limit for showing the original name
+ {
+ attachments: [
+ {
+ body: binaryAttachment,
+ contentType: "application/octet-stream",
+ filename: "ubik-111111111.xxyyzz",
+ format: "",
+ },
+ ],
+ },
+ // multiple attachments
+ {
+ attachments: [
+ { body: textAttachment, filename: "ubik.txt", format: "" },
+ {
+ body: binaryAttachment,
+ contentType: "application/octet-stream",
+ filename: "ubik.xxyyzz",
+ format: "",
+ },
+ ],
+ },
+ // attachment with a long name; the attachment bar should crop this
+ {
+ attachments: [
+ {
+ body: textAttachment,
+ filename:
+ "this-is-a-file-with-an-extremely-long-name-" +
+ "that-seems-to-go-on-forever-seriously-you-" +
+ "would-not-believe-how-long-this-name-is-it-" +
+ "surely-exceeds-the-maximum-filename-length-" +
+ "for-most-filesystems.txt",
+ format: "",
+ },
+ ],
+ },
+ // a message with a text attachment and an email attachment, which in turn
+ // has its own text attachment
+ {
+ bodyPart: new SyntheticPartMultiMixed([
+ new SyntheticPartLeaf("I'm a message!"),
+ new SyntheticPartLeaf(textAttachment, {
+ filename: "outer attachment.txt",
+ contentType: "text/plain",
+ format: "",
+ }),
+ attachedMessage,
+ ]),
+ },
+ // evilly-named attachment; spaces should be collapsed and trimmed on the
+ // ends
+ {
+ attachments: [
+ {
+ body: textAttachment,
+ contentType: "application/octet-stream",
+ filename: " ubik .txt .evil ",
+ sanitizedFilename: "ubik .txt .evil",
+ format: "",
+ },
+ ],
+ },
+ // another evilly-named attachment; filename has 10 "_"s, which should be
+ // just enough to trigger the sanitizer
+ {
+ attachments: [
+ {
+ body: textAttachment,
+ contentType: "application/octet-stream",
+ filename: "ubik.txt__________.evil",
+ sanitizedFilename: "ubik.txt_…_.evil",
+ format: "",
+ },
+ ],
+ },
+ // No texdir change in the filename please.
+ {
+ attachments: [
+ {
+ body: textAttachment,
+ filename: "ABC\u202EE.txt.zip",
+ sanitizedFilename: "ABC.E.txt.zip",
+ },
+ ],
+ },
+ ];
+
+ // Add another evilly-named attachment for Windows tests, to ensure that
+ // trailing periods are stripped.
+ if ("@mozilla.org/windows-registry-key;1" in Cc) {
+ messages.push({
+ attachments: [
+ {
+ body: textAttachment,
+ contentType: "application/octet-stream",
+ filename: "ubik.evil. . . . . . . . . ....",
+ sanitizedFilename: "ubik.evil",
+ format: "",
+ },
+ ],
+ });
+ }
+
+ for (let i = 0; i < messages.length; i++) {
+ await add_message_to_folder([folder], create_message(messages[i]));
+ }
+});
+
+/**
+ * Set the pref to ensure that the attachments pane starts out (un)expanded
+ *
+ * @param expand true if the attachment pane should start out expanded,
+ * false otherwise
+ */
+function ensure_starts_expanded(expand) {
+ Services.prefs.setBoolPref(
+ "mailnews.attachments.display.start_expanded",
+ expand
+ );
+}
+
+add_task(async function test_attachment_view_collapsed() {
+ await be_in_folder(folder);
+
+ select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ if (
+ !get_about_message().document.getElementById("attachmentView").collapsed
+ ) {
+ throw new Error("Attachment pane expanded when it shouldn't be!");
+ }
+});
+
+add_task(async function test_attachment_view_expanded() {
+ await be_in_folder(folder);
+
+ for (let i = 1; i < messages.length; i++) {
+ select_click_row(i);
+ assert_selected_and_displayed(i);
+
+ if (
+ get_about_message().document.getElementById("attachmentView").collapsed
+ ) {
+ throw new Error(
+ "Attachment pane collapsed (on message #" + i + " when it shouldn't be!"
+ );
+ }
+ }
+});
+
+add_task(async function test_attachment_name_sanitization() {
+ await be_in_folder(folder);
+
+ let aboutMessage = get_about_message();
+ let attachmentList = aboutMessage.document.getElementById("attachmentList");
+
+ for (let i = 0; i < messages.length; i++) {
+ if ("attachments" in messages[i]) {
+ select_click_row(i);
+ assert_selected_and_displayed(i);
+
+ let attachments = messages[i].attachments;
+ if (messages[i].attachments.length == 1) {
+ Assert.equal(
+ aboutMessage.document.getElementById("attachmentName").value,
+ attachments[0].sanitizedFilename || attachments[0].filename
+ );
+ }
+
+ for (let j = 0; j < attachments.length; j++) {
+ Assert.equal(
+ attachmentList.getItemAtIndex(j).getAttribute("name"),
+ attachments[j].sanitizedFilename || attachments[j].filename
+ );
+ }
+ }
+ }
+});
+
+add_task(async function test_long_attachment_name() {
+ await be_in_folder(folder);
+
+ select_click_row(4);
+ assert_selected_and_displayed(4);
+
+ let aboutMessage = get_about_message();
+ let messagepaneBox = aboutMessage.document.getElementById("messagepanebox");
+ let attachmentBar = aboutMessage.document.getElementById("attachmentBar");
+
+ Assert.ok(
+ messagepaneBox.getBoundingClientRect().width >=
+ attachmentBar.getBoundingClientRect().width,
+ "Attachment bar has expanded off the edge of the window!"
+ );
+});
+
+/**
+ * Make sure that, when opening attached messages, we only show the attachments
+ * "beneath" the attached message (as opposed to all attachments for the root
+ * message).
+ */
+add_task(async function test_attached_message_attachments() {
+ await be_in_folder(folder);
+
+ select_click_row(5);
+ assert_selected_and_displayed(5);
+
+ // Make sure we have the expected number of attachments in the root message:
+ // an outer text attachment, an attached email, and an inner text attachment.
+ let aboutMessage = get_about_message();
+ Assert.equal(
+ aboutMessage.document.getElementById("attachmentList").itemCount,
+ 3
+ );
+
+ // Open the attached email.
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ aboutMessage.document
+ .getElementById("attachmentList")
+ .getItemAtIndex(1)
+ .attachment.open();
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ // Make sure we have the expected number of attachments in the attached
+ // message: just an inner text attachment.
+ Assert.equal(
+ msgc.window.document.getElementById("attachmentList").itemCount,
+ 1
+ );
+
+ close_window(msgc);
+}).skip();
+
+add_task(async function test_attachment_name_click() {
+ await be_in_folder(folder);
+
+ select_click_row(1);
+ assert_selected_and_displayed(1);
+
+ let aboutMessage = get_about_message();
+ let attachmentList = aboutMessage.document.getElementById("attachmentList");
+
+ Assert.ok(
+ attachmentList.collapsed,
+ "Attachment list should start out collapsed!"
+ );
+
+ // Ensure the open dialog appears when clicking on the attachment name and
+ // that the attachment list doesn't expand.
+ plan_for_modal_dialog("unknownContentTypeWindow", function () {});
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentName"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+ wait_for_modal_dialog("unknownContentTypeWindow");
+ Assert.ok(
+ attachmentList.collapsed,
+ "Attachment list should not expand when clicking on attachmentName!"
+ );
+});
+
+/**
+ * Test that right-clicking on a particular element opens the expected context
+ * menu.
+ *
+ * @param elementId the id of the element to right click on
+ * @param contextMenuId the id of the context menu that should appear
+ */
+async function subtest_attachment_right_click(elementId, contextMenuId) {
+ let aboutMessage = get_about_message();
+ let element = aboutMessage.document.getElementById(elementId);
+ let contextMenu = aboutMessage.document.getElementById(contextMenuId);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ element,
+ { type: "contextmenu" },
+ aboutMessage
+ );
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.hidePopup();
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+}
+
+add_task(async function test_attachment_right_click_single() {
+ await be_in_folder(folder);
+
+ select_click_row(1);
+ assert_selected_and_displayed(1);
+
+ await subtest_attachment_right_click(
+ "attachmentIcon",
+ "attachmentItemContext"
+ );
+ await subtest_attachment_right_click(
+ "attachmentCount",
+ "attachmentItemContext"
+ );
+ await subtest_attachment_right_click(
+ "attachmentName",
+ "attachmentItemContext"
+ );
+ await subtest_attachment_right_click(
+ "attachmentSize",
+ "attachmentItemContext"
+ );
+
+ await subtest_attachment_right_click(
+ "attachmentToggle",
+ "attachment-toolbar-context-menu"
+ );
+ await subtest_attachment_right_click(
+ "attachmentSaveAllSingle",
+ "attachment-toolbar-context-menu"
+ );
+ await subtest_attachment_right_click(
+ "attachmentBar",
+ "attachment-toolbar-context-menu"
+ );
+});
+
+add_task(async function test_attachment_right_click_multiple() {
+ await be_in_folder(folder);
+
+ select_click_row(3);
+ assert_selected_and_displayed(3);
+
+ await subtest_attachment_right_click(
+ "attachmentIcon",
+ "attachmentListContext"
+ );
+ await subtest_attachment_right_click(
+ "attachmentCount",
+ "attachmentListContext"
+ );
+ await subtest_attachment_right_click(
+ "attachmentSize",
+ "attachmentListContext"
+ );
+
+ await subtest_attachment_right_click(
+ "attachmentToggle",
+ "attachment-toolbar-context-menu"
+ );
+ await subtest_attachment_right_click(
+ "attachmentSaveAllMultiple",
+ "attachment-toolbar-context-menu"
+ );
+ await subtest_attachment_right_click(
+ "attachmentBar",
+ "attachment-toolbar-context-menu"
+ );
+});
+
+/**
+ * Test that clicking on various elements in the attachment bar toggles the
+ * attachment list.
+ *
+ * @param elementId the id of the element to click
+ */
+function subtest_attachment_list_toggle(elementId) {
+ let aboutMessage = get_about_message();
+ let attachmentList = aboutMessage.document.getElementById("attachmentList");
+ let element = aboutMessage.document.getElementById(elementId);
+
+ EventUtils.synthesizeMouseAtCenter(element, { clickCount: 1 }, aboutMessage);
+ Assert.ok(
+ !attachmentList.collapsed,
+ `Attachment list should be expanded after clicking ${elementId}!`
+ );
+ assert_attachment_list_focused();
+
+ EventUtils.synthesizeMouseAtCenter(element, { clickCount: 1 }, aboutMessage);
+ Assert.ok(
+ attachmentList.collapsed,
+ `Attachment list should be collapsed after clicking ${elementId} again!`
+ );
+ assert_message_pane_focused();
+}
+
+add_task(async function test_attachment_list_expansion() {
+ await be_in_folder(folder);
+
+ select_click_row(1);
+ assert_selected_and_displayed(1);
+
+ let aboutMessage = get_about_message();
+ Assert.ok(
+ aboutMessage.document.getElementById("attachmentList").collapsed,
+ "Attachment list should start out collapsed!"
+ );
+
+ subtest_attachment_list_toggle("attachmentToggle");
+ subtest_attachment_list_toggle("attachmentIcon");
+ subtest_attachment_list_toggle("attachmentCount");
+ subtest_attachment_list_toggle("attachmentSize");
+ subtest_attachment_list_toggle("attachmentBar");
+
+ // Ensure that clicking the "Save All" button doesn't expand the attachment
+ // list.
+ let dm = aboutMessage.document.querySelector(
+ "#attachmentSaveAllSingle .toolbarbutton-menubutton-dropmarker"
+ );
+ EventUtils.synthesizeMouseAtCenter(dm, { clickCount: 1 }, aboutMessage);
+ Assert.ok(
+ aboutMessage.document.getElementById("attachmentList").collapsed,
+ "Attachment list should be collapsed after clicking save button!"
+ );
+}).skip();
+
+add_task(async function test_attachment_list_starts_expanded() {
+ ensure_starts_expanded(true);
+ await be_in_folder(folder);
+
+ select_click_row(2);
+ assert_selected_and_displayed(2);
+
+ Assert.ok(
+ !get_about_message().document.getElementById("attachmentList").collapsed,
+ "Attachment list should start out expanded!"
+ );
+});
+
+add_task(async function test_selected_attachments_are_cleared() {
+ ensure_starts_expanded(false);
+ await be_in_folder(folder);
+ // First, select the message with two attachments.
+ select_click_row(3);
+
+ // Expand the attachment list.
+ let aboutMessage = get_about_message();
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentToggle"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+
+ // Select both the attachments.
+ let attachmentList = aboutMessage.document.getElementById("attachmentList");
+ Assert.equal(
+ attachmentList.selectedItems.length,
+ 1,
+ "On first load the first item should be selected"
+ );
+
+ // We can just click on the first element, but the second one needs a
+ // ctrl-click (or cmd-click for those Mac-heads among us).
+ EventUtils.synthesizeMouseAtCenter(
+ attachmentList.children[0],
+ { clickCount: 1 },
+ aboutMessage
+ );
+ EventUtils.synthesizeMouse(
+ attachmentList.children[1],
+ 5,
+ 5,
+ { accelKey: true },
+ aboutMessage
+ );
+
+ Assert.equal(
+ attachmentList.selectedItems.length,
+ 2,
+ "We had the wrong number of selected items after selecting some!"
+ );
+
+ // Switch to the message with one attachment, and make sure there are no
+ // selected attachments.
+ select_click_row(2);
+
+ // Expand the attachment list again.
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentToggle"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+
+ Assert.equal(
+ attachmentList.selectedItems.length,
+ 1,
+ "After loading a new message the first item should be selected"
+ );
+});
+
+add_task(async function test_select_all_attachments_key() {
+ await be_in_folder(folder);
+
+ // First, select the message with two attachments.
+ select_none();
+ select_click_row(3);
+
+ // Expand the attachment list.
+ let aboutMessage = get_about_message();
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentToggle"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+
+ let attachmentList = aboutMessage.document.getElementById("attachmentList");
+ attachmentList.focus();
+ EventUtils.synthesizeKey("a", { accelKey: true }, aboutMessage);
+ Assert.equal(
+ attachmentList.selectedItems.length,
+ 2,
+ "Should have selected all attachments!"
+ );
+});
+
+add_task(async function test_delete_attachment_key() {
+ await be_in_folder(folder);
+
+ // First, select the message with two attachments.
+ select_none();
+ select_click_row(3);
+
+ // Expand the attachment list.
+ assert_selected_and_displayed(3);
+ let aboutMessage = get_about_message();
+ if (aboutMessage.document.getElementById("attachmentList").collapsed) {
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentToggle"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+ }
+ let firstAttachment =
+ aboutMessage.document.getElementById("attachmentList").firstElementChild;
+ EventUtils.synthesizeMouseAtCenter(
+ firstAttachment,
+ { clickCount: 1 },
+ aboutMessage
+ );
+
+ // Try deleting with the delete key
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ firstAttachment.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, aboutMessage);
+ await dialogPromise;
+
+ // Try deleting with the shift-delete key combo.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ firstAttachment.focus();
+ EventUtils.synthesizeKey("VK_DELETE", { shiftKey: true }, aboutMessage);
+ await dialogPromise;
+}).skip();
+
+add_task(async function test_attachments_compose_menu() {
+ await be_in_folder(folder);
+
+ // First, select the message with two attachments.
+ select_none();
+ select_click_row(3);
+
+ let cwc = open_compose_with_forward();
+ let attachment = cwc.window.document.getElementById("attachmentBucket");
+
+ // On Linux and OSX, focus events don't seem to be sent to child elements properly if
+ // the parent window is not focused. This causes some random oranges for us.
+ // We use the force_focus function to "cheat" a bit, and trigger the function
+ // that focusing normally would fire. We do normal focusing for Windows.
+ function force_focus(aId) {
+ let element = cwc.window.document.getElementById(aId);
+ element.focus();
+
+ if (["linux", "macosx"].includes(AppConstants.platform)) {
+ // First, call the window's default controller's function.
+ cwc.window.defaultController.isCommandEnabled("cmd_delete");
+
+ // Walk up the DOM tree and call isCommandEnabled on the first controller
+ // that supports "cmd_delete".
+ while (element != cwc.window.document) {
+ // NOTE: html elements (like body) don't have controllers.
+ let numControllers = element.controllers?.getControllerCount() || 0;
+ for (let i = 0; numControllers; i++) {
+ let currController = element.controllers.getControllerAt(i);
+ if (currController.supportsCommand("cmd_delete")) {
+ currController.isCommandEnabled("cmd_delete");
+ return;
+ }
+ }
+ element = element.parentNode;
+ }
+ }
+ }
+
+ // Click on a portion of the attachmentBucket to focus on it. The last
+ // attachment should be selected since we don't handle any action on an empty
+ // bucket, and we always ensure that the last attached file is visible.
+ force_focus("attachmentBucket");
+
+ Assert.equal(
+ "Remove Attachment",
+ cwc.window.document.getElementById("cmd_delete").getAttribute("label"),
+ "attachmentBucket with last attachment is focused!"
+ );
+
+ // We opened a message with 2 attachments, so index 1 should be focused.
+ Assert.equal(attachment.selectedIndex, 1, "Last attachment is focused!");
+
+ // Select 1 attachment, and
+ // focus the subject to see the label change and to execute isCommandEnabled
+ attachment.selectedIndex = 0;
+ force_focus("msgSubject");
+ Assert.equal(
+ "Delete",
+ cwc.window.document.getElementById("cmd_delete").getAttribute("label"),
+ "attachmentBucket is not focused!"
+ );
+
+ // Focus back to the attachmentBucket
+ force_focus("attachmentBucket");
+ Assert.equal(
+ "Remove Attachment",
+ cwc.window.document.getElementById("cmd_delete").getAttribute("label"),
+ "Only 1 attachment is selected!"
+ );
+
+ // Select multiple attachments, and focus the identity for the same purpose
+ attachment.selectAll();
+ force_focus("msgIdentity");
+ Assert.equal(
+ "Delete",
+ cwc.window.document.getElementById("cmd_delete").getAttribute("label"),
+ "attachmentBucket is not focused!"
+ );
+
+ // Focus back to the attachmentBucket
+ force_focus("attachmentBucket");
+ Assert.equal(
+ "Remove Attachments",
+ cwc.window.document.getElementById("cmd_delete").getAttribute("label"),
+ "Multiple attachments are selected!"
+ );
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_delete_from_toolbar() {
+ await be_in_folder(folder);
+
+ // First, select the message with two attachments.
+ select_none();
+ select_click_row(3);
+
+ // Expand the attachment list.
+ assert_selected_and_displayed(3);
+ let aboutMessage = get_about_message();
+ if (aboutMessage.document.getElementById("attachmentList").collapsed) {
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentToggle"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+ }
+
+ let firstAttachment =
+ aboutMessage.document.getElementById("attachmentList").firstElementChild;
+ EventUtils.synthesizeMouseAtCenter(
+ firstAttachment,
+ { clickCount: 1 },
+ aboutMessage
+ );
+
+ // Make sure clicking the "Delete" toolbar button with an attachment focused
+ // deletes the *message*.
+ plan_to_wait_for_folder_events("DeleteOrMoveMsgCompleted");
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("hdrTrashButton"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+ wait_for_folder_events();
+}).skip();
+
+registerCleanupFunction(() => {
+ // Remove created folders.
+ folder.deleteSelf(null);
+});
diff --git a/comm/mail/test/browser/attachment/browser_attachmentEvents.js b/comm/mail/test/browser/attachment/browser_attachmentEvents.js
new file mode 100644
index 0000000000..beb7034a86
--- /dev/null
+++ b/comm/mail/test/browser/attachment/browser_attachmentEvents.js
@@ -0,0 +1,494 @@
+/* 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/. */
+
+/**
+ * Ensures that attachment events are fired properly
+ */
+
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+
+"use strict";
+
+var { select_attachments } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AttachmentHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { add_attachments, close_compose_window, open_compose_new_mail } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { gMockPromptService } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PromptHelpers.jsm"
+);
+
+var kAttachmentsAdded = "attachments-added";
+var kAttachmentsRemoved = "attachments-removed";
+var kAttachmentRenamed = "attachment-renamed";
+
+/**
+ * Test that the attachments-added event is fired when we add a single
+ * attachment.
+ */
+add_task(function test_attachments_added_on_single() {
+ // Prepare to listen for attachments-added
+ let eventCount = 0;
+ let lastEvent;
+ let listener = function (event) {
+ eventCount++;
+ lastEvent = event;
+ };
+
+ // Open up the compose window
+ let cw = open_compose_new_mail(mc);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .addEventListener(kAttachmentsAdded, listener);
+
+ // Attach a single file
+ add_attachments(cw, "http://www.example.com/1", 0, false);
+
+ // Make sure we only saw the event once
+ Assert.equal(1, eventCount);
+
+ // Make sure that we were passed the right subject
+ let subjects = lastEvent.detail;
+ Assert.equal(1, subjects.length);
+ Assert.equal("http://www.example.com/1", subjects[0].url);
+
+ // Make sure that we can get that event again if we
+ // attach more files.
+ add_attachments(cw, "http://www.example.com/2", 0, false);
+ Assert.equal(2, eventCount);
+ subjects = lastEvent.detail;
+ Assert.equal("http://www.example.com/2", subjects[0].url);
+
+ // And check that we don't receive the event if we try to attach a file
+ // that's already attached.
+ add_attachments(cw, "http://www.example.com/2", null, false);
+ Assert.equal(2, eventCount);
+
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .removeEventListener(kAttachmentsAdded, listener);
+ close_compose_window(cw);
+});
+
+/**
+ * Test that the attachments-added event is fired when we add a series
+ * of files all at once.
+ */
+add_task(function test_attachments_added_on_multiple() {
+ // Prepare to listen for attachments-added
+ let eventCount = 0;
+ let lastEvent;
+ let listener = function (event) {
+ eventCount++;
+ lastEvent = event;
+ };
+
+ // Prepare the attachments - we store the names in attachmentNames to
+ // make sure that we observed the right event subjects later on.
+ let attachmentUrls = ["http://www.example.com/1", "http://www.example.com/2"];
+
+ // Open the compose window and add the attachments
+ let cw = open_compose_new_mail(mc);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .addEventListener(kAttachmentsAdded, listener);
+
+ add_attachments(cw, attachmentUrls, null, false);
+
+ // Make sure we only saw a single attachments-added for this group
+ // of files.
+ Assert.equal(1, eventCount);
+
+ // Now make sure we got passed the right subjects for the event
+ let subjects = lastEvent.detail;
+ Assert.equal(2, subjects.length);
+
+ for (let attachment of subjects) {
+ Assert.ok(attachmentUrls.includes(attachment.url));
+ }
+
+ // Close the compose window - let's try again with 3 attachments.
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .removeEventListener(kAttachmentsAdded, listener);
+ close_compose_window(cw);
+
+ attachmentUrls = [
+ "http://www.example.com/1",
+ "http://www.example.com/2",
+ "http://www.example.com/3",
+ ];
+
+ // Open the compose window and attach the files, and ensure that we saw
+ // the attachments-added event
+ cw = open_compose_new_mail(mc);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .addEventListener(kAttachmentsAdded, listener);
+
+ add_attachments(cw, attachmentUrls, null, false);
+ Assert.equal(2, eventCount);
+
+ // Make sure that we got the right subjects back
+ subjects = lastEvent.detail;
+ Assert.equal(3, subjects.length);
+
+ for (let attachment of subjects) {
+ Assert.ok(attachmentUrls.includes(attachment.url));
+ }
+
+ // Make sure we don't fire the event again if we try to attach the same
+ // files.
+ add_attachments(cw, attachmentUrls, null, false);
+ Assert.equal(2, eventCount);
+
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .removeEventListener(kAttachmentsAdded, listener);
+ close_compose_window(cw);
+});
+
+/**
+ * Test that the attachments-removed event is fired when removing a
+ * single file.
+ */
+add_task(function test_attachments_removed_on_single() {
+ // Prepare to listen for attachments-removed
+ let eventCount = 0;
+ let lastEvent;
+ let listener = function (event) {
+ eventCount++;
+ lastEvent = event;
+ };
+
+ // Open up the compose window, attach a file...
+ let cw = open_compose_new_mail(mc);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .addEventListener(kAttachmentsRemoved, listener);
+
+ add_attachments(cw, "http://www.example.com/1");
+
+ // Now select that attachment and delete it
+ select_attachments(cw, 0);
+ // We need to hold a reference to removedAttachment here because
+ // the delete routine nulls it out from the attachmentitem.
+ cw.window.goDoCommand("cmd_delete");
+ // Make sure we saw the event
+ Assert.equal(1, eventCount);
+ // And make sure we were passed the right attachment item as the
+ // subject.
+ let subjects = lastEvent.detail;
+ Assert.equal(1, subjects.length);
+ Assert.equal(subjects[0].url, "http://www.example.com/1");
+
+ // Ok, let's attach it again, and remove it again to ensure that
+ // we still see the event.
+ add_attachments(cw, "http://www.example.com/2");
+ select_attachments(cw, 0);
+ cw.window.goDoCommand("cmd_delete");
+
+ Assert.equal(2, eventCount);
+ subjects = lastEvent.detail;
+ Assert.equal(1, subjects.length);
+ Assert.equal(subjects[0].url, "http://www.example.com/2");
+
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .removeEventListener(kAttachmentsRemoved, listener);
+ close_compose_window(cw);
+});
+
+/**
+ * Test that the attachments-removed event is fired when removing multiple
+ * files all at once.
+ */
+add_task(function test_attachments_removed_on_multiple() {
+ // Prepare to listen for attachments-removed
+ let eventCount = 0;
+ let lastEvent;
+ let listener = function (event) {
+ eventCount++;
+ lastEvent = event;
+ };
+
+ // Open up the compose window and attach some files...
+ let cw = open_compose_new_mail(mc);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .addEventListener(kAttachmentsRemoved, listener);
+
+ add_attachments(cw, [
+ "http://www.example.com/1",
+ "http://www.example.com/2",
+ "http://www.example.com/3",
+ ]);
+
+ // Select all three attachments, and remove them.
+ let removedAttachmentItems = select_attachments(cw, 0, 2);
+
+ let removedAttachmentUrls = removedAttachmentItems.map(
+ aAttachment => aAttachment.attachment.url
+ );
+
+ cw.window.goDoCommand("cmd_delete");
+
+ // We should have seen the attachments-removed event exactly once.
+ Assert.equal(1, eventCount);
+
+ // Now let's make sure we got passed back the right attachment items
+ // as the event subject
+ let subjects = lastEvent.detail;
+ Assert.equal(3, subjects.length);
+
+ for (let attachment of subjects) {
+ Assert.ok(removedAttachmentUrls.includes(attachment.url));
+ }
+
+ // Ok, let's attach and remove some again to ensure that we still see the event.
+ add_attachments(cw, ["http://www.example.com/1", "http://www.example.com/2"]);
+
+ select_attachments(cw, 0, 1);
+ cw.window.goDoCommand("cmd_delete");
+ Assert.equal(2, eventCount);
+
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .removeEventListener(kAttachmentsRemoved, listener);
+ close_compose_window(cw);
+});
+
+/**
+ * Test that we don't see the attachments-removed event if no attachments
+ * are selected when hitting "Delete"
+ */
+add_task(function test_no_attachments_removed_on_none() {
+ // Prepare to listen for attachments-removed
+ let eventCount = 0;
+ let listener = function (event) {
+ eventCount++;
+ };
+
+ // Open the compose window and add some attachments.
+ let cw = open_compose_new_mail(mc);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .addEventListener(kAttachmentsRemoved, listener);
+
+ add_attachments(cw, [
+ "http://www.example.com/1",
+ "http://www.example.com/2",
+ "http://www.example.com/3",
+ ]);
+
+ // Choose no attachments
+ cw.window.document.getElementById("attachmentBucket").clearSelection();
+ // Run the delete command
+ cw.window.goDoCommand("cmd_delete");
+ // Make sure we didn't see the attachments_removed event.
+ Assert.equal(0, eventCount);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .removeEventListener(kAttachmentsRemoved, listener);
+
+ close_compose_window(cw);
+});
+
+/**
+ * Test that we see the attachment-renamed event when an attachments
+ * name is changed.
+ */
+add_task(function test_attachment_renamed() {
+ // Here's what we'll rename some files to.
+ const kRenameTo1 = "Renamed-1";
+ const kRenameTo2 = "Renamed-2";
+ const kRenameTo3 = "Renamed-3";
+
+ // Prepare to listen for attachment-renamed
+ let eventCount = 0;
+ let lastEvent;
+ let listener = function (event) {
+ eventCount++;
+ lastEvent = event;
+ };
+
+ // Renaming a file brings up a Prompt, so we'll mock the Prompt Service
+ gMockPromptService.reset();
+ gMockPromptService.register();
+ // The inoutValue is used to set the attachment name
+ gMockPromptService.inoutValue = kRenameTo1;
+ gMockPromptService.returnValue = true;
+
+ // Open up the compose window, attach some files, choose the first
+ // attachment, and choose to rename it.
+ let cw = open_compose_new_mail(mc);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .addEventListener(kAttachmentRenamed, listener);
+
+ add_attachments(cw, [
+ "http://www.example.com/1",
+ "http://www.example.com/2",
+ "http://www.example.com/3",
+ ]);
+
+ select_attachments(cw, 0);
+ Assert.equal(0, eventCount);
+ cw.window.goDoCommand("cmd_renameAttachment");
+
+ // Wait until we saw the attachment-renamed event.
+ utils.waitFor(function () {
+ return eventCount == 1;
+ });
+
+ // Ensure that the event mentions the right attachment
+ let renamedAttachment1 = lastEvent.target.attachment;
+ let originalAttachment1 = lastEvent.detail;
+ Assert.ok(renamedAttachment1 instanceof Ci.nsIMsgAttachment);
+ Assert.equal(kRenameTo1, renamedAttachment1.name);
+ Assert.ok(renamedAttachment1.url.includes("http://www.example.com/1"));
+ Assert.equal("www.example.com/1", originalAttachment1.name);
+
+ // Ok, let's try renaming the same attachment.
+ gMockPromptService.reset();
+ gMockPromptService.inoutValue = kRenameTo2;
+ gMockPromptService.returnValue = true;
+
+ select_attachments(cw, 0);
+ Assert.equal(1, eventCount);
+ cw.window.goDoCommand("cmd_renameAttachment");
+
+ // Wait until we saw the attachment-renamed event.
+ utils.waitFor(function () {
+ return eventCount == 2;
+ });
+
+ let renamedAttachment2 = lastEvent.target.attachment;
+ let originalAttachment2 = lastEvent.detail;
+ Assert.ok(renamedAttachment2 instanceof Ci.nsIMsgAttachment);
+ Assert.equal(kRenameTo2, renamedAttachment2.name);
+ Assert.ok(renamedAttachment2.url.includes("http://www.example.com/1"));
+ Assert.equal(kRenameTo1, originalAttachment2.name);
+
+ // Ok, let's rename another attachment
+ gMockPromptService.reset();
+ gMockPromptService.inoutValue = kRenameTo3;
+ gMockPromptService.returnValue = true;
+
+ // We'll select the second attachment this time.
+ select_attachments(cw, 1);
+ Assert.equal(2, eventCount);
+ cw.window.goDoCommand("cmd_renameAttachment");
+
+ // Wait until we saw the attachment-renamed event.
+ utils.waitFor(function () {
+ return eventCount == 3;
+ });
+
+ // Ensure that the event mentions the right attachment
+ let renamedAttachment3 = lastEvent.target.attachment;
+ let originalAttachment3 = lastEvent.detail;
+ Assert.ok(renamedAttachment3 instanceof Ci.nsIMsgAttachment);
+ Assert.equal(kRenameTo3, renamedAttachment3.name);
+ Assert.ok(renamedAttachment3.url.includes("http://www.example.com/2"));
+ Assert.equal("www.example.com/2", originalAttachment3.name);
+
+ // Unregister the Mock Prompt service, and remove our observer.
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .removeEventListener(kAttachmentRenamed, listener);
+
+ close_compose_window(cw);
+ gMockPromptService.unregister();
+});
+
+/**
+ * Test that the attachment-renamed event is not fired if we set the
+ * filename to be blank.
+ */
+add_task(function test_no_attachment_renamed_on_blank() {
+ // Prepare to listen for attachment-renamed
+ let eventCount = 0;
+ let listener = function (event) {
+ eventCount++;
+ };
+
+ // Register the Mock Prompt Service to return the empty string when
+ // prompted.
+ gMockPromptService.reset();
+ gMockPromptService.register();
+ gMockPromptService.inoutValue = "";
+ gMockPromptService.returnValue = true;
+
+ // Open the compose window, attach some files, select one, and chooes to
+ // rename it.
+ let cw = open_compose_new_mail(mc);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .addEventListener(kAttachmentRenamed, listener);
+
+ add_attachments(cw, [
+ "http://www.example.com/1",
+ "http://www.example.com/2",
+ "http://www.example.com/3",
+ ]);
+
+ select_attachments(cw, 0);
+ cw.window.goDoCommand("cmd_renameAttachment");
+
+ // Ensure that we didn't see the attachment-renamed event.
+ Assert.equal(0, eventCount);
+ cw.window.document
+ .getElementById("attachmentBucket")
+ .removeEventListener(kAttachmentRenamed, listener);
+ close_compose_window(cw);
+ gMockPromptService.unregister();
+});
+
+/**
+ * Test that toggling attachments pane works.
+ */
+add_task(function test_attachments_pane_toggle() {
+ // Open the compose window.
+ let cw = open_compose_new_mail(mc);
+
+ // Use the hotkey to try to toggle attachmentsArea open.
+ let opts =
+ AppConstants.platform == "macosx"
+ ? { metaKey: true, shiftKey: true }
+ : { ctrlKey: true, shiftKey: true };
+ EventUtils.synthesizeKey("m", opts, cw.window);
+ let attachmentArea = cw.window.document.getElementById("attachmentArea");
+
+ // Since we don't have any uploaded attachment, assert that the box remains
+ // closed.
+ utils.waitFor(() => !attachmentArea.open);
+ Assert.ok(!attachmentArea.open);
+
+ // Add an attachment. This should automatically open the box.
+ add_attachments(cw, ["http://www.example.com/1"]);
+ Assert.ok(attachmentArea.open);
+
+ // Press again, should toggle to closed.
+ EventUtils.synthesizeKey("m", opts, cw.window);
+ utils.waitFor(() => !attachmentArea.open);
+ Assert.ok(!attachmentArea.open);
+
+ // Press again, should toggle to open.
+ EventUtils.synthesizeKey("m", opts, cw.window);
+ utils.waitFor(() => attachmentArea.open);
+ Assert.ok(attachmentArea.open);
+
+ close_compose_window(cw);
+});
+
+registerCleanupFunction(() => {
+ // 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;
+});
diff --git a/comm/mail/test/browser/attachment/browser_attachmentIcon.js b/comm/mail/test/browser/attachment/browser_attachmentIcon.js
new file mode 100644
index 0000000000..ec39497a3b
--- /dev/null
+++ b/comm/mail/test/browser/attachment/browser_attachmentIcon.js
@@ -0,0 +1,254 @@
+/* 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 folder;
+var messenger;
+
+var {
+ create_body_part,
+ create_deleted_attachment,
+ create_detached_attachment,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/AttachmentHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_message,
+ mc,
+ msgGen,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { SyntheticPartLeaf, SyntheticPartMultiMixed } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+var textAttachment =
+ "Can't make the frug contest, Helen; stomach's upset. I'll fix you, " +
+ "Ubik! Ubik drops you back in the thick of things fast. Taken as " +
+ "directed, Ubik speeds relief to head and stomach. Remember: Ubik is " +
+ "only seconds away. Avoid prolonged use.";
+
+var binaryAttachment = textAttachment;
+
+var imageAttachment =
+ "iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAABHNCSVQICAgIfAhkiAAAAAlwS" +
+ "FlzAAAN1wAADdcBQiibeAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAA" +
+ "A5SURBVCiRY/z//z8DKYCJJNXkaGBgYGD4D8NQ5zUgiTVAxeBqSLaBkVRPM0KtIhrQ3km0jwe" +
+ "SNQAAlmAY+71EgFoAAAAASUVORK5CYII=";
+var imageSize = 188;
+
+var vcardAttachment =
+ "YmVnaW46dmNhcmQNCmZuOkppbSBCb2INCm46Qm9iO0ppbQ0KZW1haWw7aW50ZXJuZXQ6Zm9v" +
+ "QGJhci5jb20NCnZlcnNpb246Mi4xDQplbmQ6dmNhcmQNCg0K";
+var vcardSize = 90;
+
+var detachedName = "./attachment.txt";
+var missingName = "./nonexistent.txt";
+var deletedName = "deleted.txt";
+
+// Create some messages that have various types of attachments.
+var messages = [
+ {
+ name: "text_attachment",
+ attachments: [
+ {
+ body: textAttachment,
+ filename: "ubik.txt",
+ format: "",
+ icon: "moz-icon://ubik.txt?size=16&contentType=text/plain",
+ },
+ ],
+ },
+ {
+ name: "binary_attachment",
+ attachments: [
+ {
+ body: binaryAttachment,
+ contentType: "application/x-ubik",
+ filename: "ubik",
+ format: "",
+ icon: "moz-icon://ubik?size=16&contentType=application/x-ubik",
+ },
+ ],
+ },
+ {
+ name: "image_attachment",
+ attachments: [
+ {
+ body: imageAttachment,
+ contentType: "image/png",
+ filename: "lines.png",
+ encoding: "base64",
+ format: "",
+ icon: "moz-icon://lines.png?size=16&contentType=image/png",
+ },
+ ],
+ },
+ {
+ name: "detached_attachment",
+ bodyPart: null,
+ attachments: [
+ {
+ icon: "moz-icon://attachment.txt?size=16&contentType=text/plain",
+ },
+ ],
+ },
+ {
+ name: "detached_attachment_with_missing_file",
+ bodyPart: null,
+ attachments: [
+ {
+ icon: "moz-icon://nonexistent.txt?size=16&contentType=text/plain",
+ },
+ ],
+ },
+ {
+ name: "deleted_attachment",
+ bodyPart: null,
+ attachments: [
+ {
+ icon: "chrome://messenger/skin/icons/attachment-deleted.svg",
+ },
+ ],
+ },
+ {
+ name: "multiple_attachments",
+ attachments: [
+ {
+ body: textAttachment,
+ filename: "ubik.txt",
+ format: "",
+ icon: "moz-icon://ubik.txt?size=16&contentType=text/plain",
+ },
+ {
+ body: binaryAttachment,
+ contentType: "application/x-ubik",
+ filename: "ubik",
+ format: "",
+ icon: "moz-icon://ubik?size=16&contentType=application/x-ubik",
+ },
+ ],
+ },
+ // vCards should be included in the attachment list.
+ {
+ name: "multiple_attachments_one_vcard",
+ attachments: [
+ {
+ body: textAttachment,
+ filename: "ubik.txt",
+ format: "",
+ icon: "moz-icon://ubik.txt?size=16&contentType=text/plain",
+ },
+ {
+ body: vcardAttachment,
+ contentType: "text/vcard",
+ filename: "ubik.vcf",
+ encoding: "base64",
+ format: "",
+ icon: "moz-icon://ubik.vcf?size=16&contentType=text/vcard",
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+ // Set up our detached/deleted attachments.
+ var detachedFile = new FileUtils.File(
+ getTestFilePath(`data/${detachedName}`)
+ );
+ var detached = create_body_part("Here is a file", [
+ create_detached_attachment(detachedFile, "text/plain"),
+ ]);
+
+ var missingFile = new FileUtils.File(getTestFilePath(`data/${missingName}`));
+ var missing = create_body_part(
+ "Here is a file (but you deleted the external file, you silly oaf!)",
+ [create_detached_attachment(missingFile, "text/plain")]
+ );
+
+ var deleted = create_body_part("Here is a file that you deleted", [
+ create_deleted_attachment(deletedName, "text/plain"),
+ ]);
+
+ folder = await create_folder("AttachmentIcons");
+ for (let i = 0; i < messages.length; i++) {
+ switch (messages[i].name) {
+ case "detached_attachment":
+ messages[i].bodyPart = detached;
+ break;
+ case "detached_attachment_with_missing_file":
+ messages[i].bodyPart = missing;
+ break;
+ case "deleted_attachment":
+ messages[i].bodyPart = deleted;
+ break;
+ }
+
+ await add_message_to_folder([folder], create_message(messages[i]));
+ }
+});
+
+/**
+ * Make sure that the attachment's icon is what we expect.
+ *
+ * @param index the attachment's index, starting at 0
+ * @param expectedSize the URL of the expected icon of the attachment
+ */
+function check_attachment_icon(index, expectedIcon) {
+ let win = get_about_message();
+ let list = win.document.getElementById("attachmentList");
+ let node = list.querySelectorAll("richlistitem.attachmentItem")[index];
+
+ Assert.equal(
+ node.querySelector("img.attachmentcell-icon").src,
+ expectedIcon,
+ `Icon should be correct for attachment #${index}`
+ );
+}
+
+/**
+ * Make sure that the individual icons are as expected.
+ *
+ * @param index the index of the message to check in the thread pane
+ */
+async function help_test_attachment_icon(index) {
+ await be_in_folder(folder);
+ select_click_row(index);
+ info(`Testing message ${index}: ${messages[index].name}`);
+ let attachments = messages[index].attachments;
+
+ let win = get_about_message();
+ win.toggleAttachmentList(true);
+
+ let attachmentList = win.document.getElementById("attachmentList");
+ await TestUtils.waitForCondition(
+ () => !attachmentList.collapsed,
+ "Attachment list is shown"
+ );
+
+ for (let i = 0; i < attachments.length; i++) {
+ check_attachment_icon(i, attachments[i].icon);
+ }
+}
+
+add_task(async function test_attachment_icons() {
+ for (let i = 0; i < messages.length; i++) {
+ await help_test_attachment_icon(i);
+ }
+});
+
+registerCleanupFunction(() => {
+ // Remove created folders.
+ folder.deleteSelf(null);
+});
diff --git a/comm/mail/test/browser/attachment/browser_attachmentInPlainMsg.js b/comm/mail/test/browser/attachment/browser_attachmentInPlainMsg.js
new file mode 100644
index 0000000000..fb8ff5eb6c
--- /dev/null
+++ b/comm/mail/test/browser/attachment/browser_attachmentInPlainMsg.js
@@ -0,0 +1,51 @@
+/* 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 { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+/**
+ * Bug 1358565
+ * Check that a non-empty image is shown as attachment and is detected as non-empty
+ * when message is viewed as plain text.
+ */
+add_task(async function test_attachment_not_empty() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", true);
+
+ let file = new FileUtils.File(getTestFilePath("data/bug1358565.eml"));
+
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentToggle"),
+ {},
+ aboutMessage
+ );
+ Assert.equal(
+ aboutMessage.document.getElementById("attachmentList").itemCount,
+ 1
+ );
+
+ let attachmentElem = aboutMessage.document
+ .getElementById("attachmentList")
+ .getItemAtIndex(0);
+ Assert.equal(attachmentElem.attachment.contentType, "image/jpeg");
+ Assert.equal(attachmentElem.attachment.name, "bug.png");
+ Assert.ok(attachmentElem.attachment.hasFile);
+ Assert.ok(
+ !(await attachmentElem.attachment.isEmpty()),
+ "Attachment incorrectly determined empty"
+ );
+
+ close_window(msgc);
+
+ Services.prefs.clearUserPref("mailnews.display.prefer_plaintext");
+});
diff --git a/comm/mail/test/browser/attachment/browser_attachmentMenus.js b/comm/mail/test/browser/attachment/browser_attachmentMenus.js
new file mode 100644
index 0000000000..cc24e6ccc9
--- /dev/null
+++ b/comm/mail/test/browser/attachment/browser_attachmentMenus.js
@@ -0,0 +1,565 @@
+/* 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 folder;
+var messenger;
+var epsilon;
+
+var {
+ create_body_part,
+ create_deleted_attachment,
+ create_detached_attachment,
+ create_enclosure_attachment,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/AttachmentHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ be_in_folder,
+ close_popup,
+ create_folder,
+ create_message,
+ get_about_message,
+ mc,
+ select_click_row,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var aboutMessage = get_about_message();
+
+var textAttachment =
+ "Can't make the frug contest, Helen; stomach's upset. I'll fix you, " +
+ "Ubik! Ubik drops you back in the thick of things fast. Taken as " +
+ "directed, Ubik speeds relief to head and stomach. Remember: Ubik is " +
+ "only seconds away. Avoid prolonged use.";
+
+var detachedName = "./attachment.txt";
+var missingName = "./nonexistent.txt";
+var deletedName = "deleted.txt";
+
+// create some messages that have various types of attachments
+var messages = [
+ {
+ name: "regular_attachment",
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ menuStates: [{ open: true, save: true, detach: true, delete_: true }],
+ allMenuStates: { open: true, save: true, detach: true, delete_: true },
+ },
+ {
+ name: "detached_attachment",
+ bodyPart: null,
+ menuStates: [{ open: true, save: true, detach: false, delete_: false }],
+ allMenuStates: { open: true, save: true, detach: false, delete_: false },
+ },
+ {
+ name: "detached_attachment_with_missing_file",
+ bodyPart: null,
+ menuStates: [{ open: false, save: false, detach: false, delete_: false }],
+ allMenuStates: { open: false, save: false, detach: false, delete_: false },
+ },
+ {
+ name: "deleted_attachment",
+ bodyPart: null,
+ menuStates: [{ open: false, save: false, detach: false, delete_: false }],
+ allMenuStates: { open: false, save: false, detach: false, delete_: false },
+ },
+ {
+ name: "multiple_attachments",
+ attachments: [
+ { body: textAttachment, filename: "ubik.txt", format: "" },
+ { body: textAttachment, filename: "ubik2.txt", format: "" },
+ ],
+ menuStates: [
+ { open: true, save: true, detach: true, delete_: true },
+ { open: true, save: true, detach: true, delete_: true },
+ ],
+ allMenuStates: { open: true, save: true, detach: true, delete_: true },
+ },
+ {
+ name: "multiple_attachments_one_detached",
+ bodyPart: null,
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ menuStates: [
+ { open: true, save: true, detach: false, delete_: false },
+ { open: true, save: true, detach: true, delete_: true },
+ ],
+ allMenuStates: { open: true, save: true, detach: true, delete_: true },
+ },
+ {
+ name: "multiple_attachments_one_detached_with_missing_file",
+ bodyPart: null,
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ menuStates: [
+ { open: false, save: false, detach: false, delete_: false },
+ { open: true, save: true, detach: true, delete_: true },
+ ],
+ allMenuStates: { open: true, save: true, detach: true, delete_: true },
+ },
+ {
+ name: "multiple_attachments_one_deleted",
+ bodyPart: null,
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ menuStates: [
+ { open: false, save: false, detach: false, delete_: false },
+ { open: true, save: true, detach: true, delete_: true },
+ ],
+ allMenuStates: { open: true, save: true, detach: true, delete_: true },
+ },
+ {
+ name: "multiple_attachments_all_detached",
+ bodyPart: null,
+ menuStates: [
+ { open: true, save: true, detach: false, delete_: false },
+ { open: true, save: true, detach: false, delete_: false },
+ ],
+ allMenuStates: { open: true, save: true, detach: false, delete_: false },
+ },
+ {
+ name: "multiple_attachments_all_detached_with_missing_files",
+ bodyPart: null,
+ menuStates: [
+ { open: false, save: false, detach: false, delete_: false },
+ { open: false, save: false, detach: false, delete_: false },
+ ],
+ allMenuStates: { open: false, save: false, detach: false, delete_: false },
+ },
+ {
+ name: "multiple_attachments_all_deleted",
+ bodyPart: null,
+ menuStates: [
+ { open: false, save: false, detach: false, delete_: false },
+ { open: false, save: false, detach: false, delete_: false },
+ ],
+ allMenuStates: { open: false, save: false, detach: false, delete_: false },
+ },
+ {
+ name: "link_enclosure_valid",
+ bodyPart: null,
+ menuStates: [{ open: true, save: true, detach: false, delete_: false }],
+ allMenuStates: { open: true, save: true, detach: false, delete_: false },
+ },
+ {
+ name: "link_enclosure_invalid",
+ bodyPart: null,
+ menuStates: [{ open: false, save: false, detach: false, delete_: false }],
+ allMenuStates: { open: false, save: false, detach: false, delete_: false },
+ },
+ {
+ name: "link_multiple_enclosures",
+ bodyPart: null,
+ menuStates: [
+ { open: true, save: true, detach: false, delete_: false },
+ { open: true, save: true, detach: false, delete_: false },
+ ],
+ allMenuStates: { open: true, save: true, detach: false, delete_: false },
+ },
+ {
+ name: "link_multiple_enclosures_one_invalid",
+ bodyPart: null,
+ menuStates: [
+ { open: true, save: true, detach: false, delete_: false },
+ { open: false, save: false, detach: false, delete_: false },
+ ],
+ allMenuStates: { open: true, save: true, detach: false, delete_: false },
+ },
+ {
+ name: "link_multiple_enclosures_all_invalid",
+ bodyPart: null,
+ menuStates: [
+ { open: false, save: false, detach: false, delete_: false },
+ { open: false, save: false, detach: false, delete_: false },
+ ],
+ allMenuStates: { open: false, save: false, detach: false, delete_: false },
+ },
+];
+
+add_setup(async function () {
+ messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+ /* Today's gory details (thanks to Jonathan Protzenko): libmime somehow
+ * counts the trailing newline for an attachment MIME part. Most of the time,
+ * assuming attachment has N bytes (no matter what's inside, newlines or
+ * not), libmime will return N + 1 bytes. On Linux and Mac, this always
+ * holds. However, on Windows, if the attachment is not encoded (that is, is
+ * inline text), libmime will return N + 2 bytes.
+ */
+ epsilon = "@mozilla.org/windows-registry-key;1" in Cc ? 2 : 1;
+
+ // set up our detached/deleted attachments
+ var detachedFile = new FileUtils.File(
+ getTestFilePath(`data/${detachedName}`)
+ );
+ var detached = create_body_part("Here is a file", [
+ create_detached_attachment(detachedFile, "text/plain"),
+ ]);
+ var multiple_detached = create_body_part("Here are some files", [
+ create_detached_attachment(detachedFile, "text/plain"),
+ create_detached_attachment(detachedFile, "text/plain"),
+ ]);
+
+ var missingFile = new FileUtils.File(getTestFilePath(`data/${missingName}`));
+ var missing = create_body_part(
+ "Here is a file (but you deleted the external file, you silly oaf!)",
+ [create_detached_attachment(missingFile, "text/plain")]
+ );
+ var multiple_missing = create_body_part(
+ "Here are some files (but you deleted the external files, you silly oaf!)",
+ [
+ create_detached_attachment(missingFile, "text/plain"),
+ create_detached_attachment(missingFile, "text/plain"),
+ ]
+ );
+
+ var deleted = create_body_part("Here is a file that you deleted", [
+ create_deleted_attachment(deletedName, "text/plain"),
+ ]);
+ var multiple_deleted = create_body_part(
+ "Here are some files that you deleted",
+ [
+ create_deleted_attachment(deletedName, "text/plain"),
+ create_deleted_attachment(deletedName, "text/plain"),
+ ]
+ );
+
+ var enclosure_valid_url = create_body_part("My blog has the best enclosure", [
+ create_enclosure_attachment(
+ "purr.mp3",
+ "audio/mpeg",
+ "https://example.com",
+ 12345678
+ ),
+ ]);
+ var enclosure_invalid_url = create_body_part(
+ "My blog has the best enclosure with a dead link",
+ [
+ create_enclosure_attachment(
+ "meow.mp3",
+ "audio/mpeg",
+ "https://example.com/invalid"
+ ),
+ ]
+ );
+ var multiple_enclosures = create_body_part(
+ "My blog has the best 2 cat sound enclosures",
+ [
+ create_enclosure_attachment(
+ "purr.mp3",
+ "audio/mpeg",
+ "https://example.com",
+ 1234567
+ ),
+ create_enclosure_attachment(
+ "meow.mp3",
+ "audio/mpeg",
+ "https://example.com",
+ 987654321
+ ),
+ ]
+ );
+ var multiple_enclosures_one_link_invalid = create_body_part(
+ "My blog has the best 2 cat sound enclosures but one is invalid",
+ [
+ create_enclosure_attachment(
+ "purr.mp3",
+ "audio/mpeg",
+ "https://example.com",
+ 1234567
+ ),
+ create_enclosure_attachment(
+ "meow.mp3",
+ "audio/mpeg",
+ "https://example.com/invalid"
+ ),
+ ]
+ );
+ var multiple_enclosures_all_links_invalid = create_body_part(
+ "My blog has 2 enclosures with 2 bad links",
+ [
+ create_enclosure_attachment(
+ "purr.mp3",
+ "audio/mpeg",
+ "https://example.com/invalid"
+ ),
+ create_enclosure_attachment(
+ "meow.mp3",
+ "audio/mpeg",
+ "https://example.com/invalid"
+ ),
+ ]
+ );
+
+ folder = await create_folder("AttachmentMenusA");
+ for (let i = 0; i < messages.length; i++) {
+ // First, add any missing info to the message object.
+ switch (messages[i].name) {
+ case "detached_attachment":
+ case "multiple_attachments_one_detached":
+ messages[i].bodyPart = detached;
+ break;
+ case "multiple_attachments_all_detached":
+ messages[i].bodyPart = multiple_detached;
+ break;
+ case "detached_attachment_with_missing_file":
+ case "multiple_attachments_one_detached_with_missing_file":
+ messages[i].bodyPart = missing;
+ break;
+ case "multiple_attachments_all_detached_with_missing_files":
+ messages[i].bodyPart = multiple_missing;
+ break;
+ case "deleted_attachment":
+ case "multiple_attachments_one_deleted":
+ messages[i].bodyPart = deleted;
+ break;
+ case "multiple_attachments_all_deleted":
+ messages[i].bodyPart = multiple_deleted;
+ break;
+ case "link_enclosure_valid":
+ messages[i].bodyPart = enclosure_valid_url;
+ break;
+ case "link_enclosure_invalid":
+ messages[i].bodyPart = enclosure_invalid_url;
+ break;
+ case "link_multiple_enclosures":
+ messages[i].bodyPart = multiple_enclosures;
+ break;
+ case "link_multiple_enclosures_one_invalid":
+ messages[i].bodyPart = multiple_enclosures_one_link_invalid;
+ break;
+ case "link_multiple_enclosures_all_invalid":
+ messages[i].bodyPart = multiple_enclosures_all_links_invalid;
+ break;
+ }
+
+ await add_message_to_folder([folder], create_message(messages[i]));
+ }
+});
+
+/**
+ * Ensure that the specified element is visible/hidden
+ *
+ * @param id the id of the element to check
+ * @param visible true if the element should be visible, false otherwise
+ */
+function assert_shown(id, visible) {
+ Assert.notEqual(
+ aboutMessage.document.getElementById(id).hidden,
+ visible,
+ `"${id}" should be ${visible ? "visible" : "hidden"}`
+ );
+}
+
+/**
+ * Ensure that the specified element is enabled/disabled
+ *
+ * @param id the id of the element to check
+ * @param enabled true if the element should be enabled, false otherwise
+ */
+function assert_enabled(id, enabled) {
+ Assert.notEqual(
+ aboutMessage.document.getElementById(id).disabled,
+ enabled,
+ `"${id}" should be ${enabled ? "enabled" : "disabled"}`
+ );
+}
+
+/**
+ * Check that the menu states in the "save" toolbar button are correct.
+ *
+ * @param expected a dictionary containing the expected states
+ */
+async function check_toolbar_menu_states_single(expected) {
+ assert_shown("attachmentSaveAllSingle", true);
+ assert_shown("attachmentSaveAllMultiple", false);
+
+ if (expected.save === false) {
+ assert_enabled("attachmentSaveAllSingle", false);
+ } else {
+ assert_enabled("attachmentSaveAllSingle", true);
+ let dm = aboutMessage.document.querySelector(
+ "#attachmentSaveAllSingle .toolbarbutton-menubutton-dropmarker"
+ );
+ EventUtils.synthesizeMouseAtCenter(dm, { clickCount: 1 }, aboutMessage);
+ await wait_for_popup_to_open(
+ aboutMessage.document.getElementById("attachmentSaveAllSingleMenu")
+ );
+
+ try {
+ assert_enabled("button-openAttachment", expected.open);
+ assert_enabled("button-saveAttachment", expected.save);
+ assert_enabled("button-detachAttachment", expected.detach);
+ assert_enabled("button-deleteAttachment", expected.delete_);
+ } finally {
+ await close_popup(
+ aboutMessage,
+ aboutMessage.document.getElementById("attachmentSaveAllSingleMenu")
+ );
+ }
+ }
+}
+
+/**
+ * Check that the menu states in the "save all" toolbar button are correct.
+ *
+ * @param expected a dictionary containing the expected states
+ */
+async function check_toolbar_menu_states_multiple(expected) {
+ assert_shown("attachmentSaveAllSingle", false);
+ assert_shown("attachmentSaveAllMultiple", true);
+
+ if (expected.save === false) {
+ assert_enabled("attachmentSaveAllMultiple", false);
+ } else {
+ assert_enabled("attachmentSaveAllMultiple", true);
+ let dm = aboutMessage.document.querySelector(
+ "#attachmentSaveAllMultiple .toolbarbutton-menubutton-dropmarker"
+ );
+ EventUtils.synthesizeMouseAtCenter(dm, { clickCount: 1 }, aboutMessage);
+ await wait_for_popup_to_open(
+ aboutMessage.document.getElementById("attachmentSaveAllMultipleMenu")
+ );
+
+ try {
+ assert_enabled("button-openAllAttachments", expected.open);
+ assert_enabled("button-saveAllAttachments", expected.save);
+ assert_enabled("button-detachAllAttachments", expected.detach);
+ assert_enabled("button-deleteAllAttachments", expected.delete_);
+ } finally {
+ await close_popup(
+ mc,
+ aboutMessage.document.getElementById("attachmentSaveAllMultipleMenu")
+ );
+ }
+ }
+}
+
+/**
+ * Check that the menu states in the single item context menu are correct
+ *
+ * @param expected a dictionary containing the expected states
+ */
+async function check_menu_states_single(index, expected) {
+ let attachmentList = aboutMessage.document.getElementById("attachmentList");
+ let node = attachmentList.getItemAtIndex(index);
+
+ let contextMenu = aboutMessage.document.getElementById(
+ "attachmentItemContext"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ attachmentList.selectItem(node);
+ EventUtils.synthesizeMouseAtCenter(
+ node,
+ { type: "contextmenu" },
+ aboutMessage
+ );
+ await shownPromise;
+
+ try {
+ assert_shown("context-openAttachment", true);
+ assert_shown("context-saveAttachment", true);
+ assert_shown("context-menu-separator", true);
+ assert_shown("context-detachAttachment", true);
+ assert_shown("context-deleteAttachment", true);
+
+ assert_enabled("context-openAttachment", expected.open);
+ assert_enabled("context-saveAttachment", expected.save);
+ assert_enabled("context-detachAttachment", expected.detach);
+ assert_enabled("context-deleteAttachment", expected.delete_);
+ } finally {
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ }
+}
+
+/**
+ * Check that the menu states in the all items context menu are correct
+ *
+ * @param expected a dictionary containing the expected states
+ */
+async function check_menu_states_all(expected) {
+ // Using a rightClick here is unsafe, because we need to hit the empty area
+ // beside the attachment items and that seems to be different per platform.
+ // Using DOM methods to open the popup works fine.
+ let contextMenu = aboutMessage.document.getElementById(
+ "attachmentListContext"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ aboutMessage.document
+ .getElementById("attachmentListContext")
+ .openPopup(aboutMessage.document.getElementById("attachmentList"));
+ await shownPromise;
+
+ try {
+ assert_shown("context-openAllAttachments", true);
+ assert_shown("context-saveAllAttachments", true);
+ assert_shown("context-menu-separator-all", true);
+ assert_shown("context-detachAllAttachments", true);
+ assert_shown("context-deleteAllAttachments", true);
+
+ assert_enabled("context-openAllAttachments", expected.open);
+ assert_enabled("context-saveAllAttachments", expected.save);
+ assert_enabled("context-detachAllAttachments", expected.detach);
+ assert_enabled("context-deleteAllAttachments", expected.delete_);
+ } finally {
+ await close_popup(
+ aboutMessage,
+ aboutMessage.document.getElementById("attachmentListContext")
+ );
+ }
+}
+
+async function help_test_attachment_menus(index) {
+ await be_in_folder(folder);
+ select_click_row(index);
+ let expectedStates = messages[index].menuStates;
+
+ let aboutMessage = get_about_message();
+ aboutMessage.toggleAttachmentList(true);
+
+ for (let attachment of aboutMessage.currentAttachments) {
+ // Ensure all attachments are resolved; other than external they already
+ // should be.
+ await attachment.isEmpty();
+ }
+
+ if (expectedStates.length == 1) {
+ await check_toolbar_menu_states_single(messages[index].allMenuStates);
+ } else {
+ await check_toolbar_menu_states_multiple(messages[index].allMenuStates);
+ }
+
+ await check_menu_states_all(messages[index].allMenuStates);
+ for (let i = 0; i < expectedStates.length; i++) {
+ await check_menu_states_single(i, expectedStates[i]);
+ }
+}
+
+// Generate a test for each message in |messages|.
+for (let i = 0; i < messages.length; i++) {
+ add_task(function () {
+ return help_test_attachment_menus(i);
+ });
+}
+
+add_task(() => {
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
+
+registerCleanupFunction(() => {
+ // Remove created folders.
+ folder.deleteSelf(null);
+});
diff --git a/comm/mail/test/browser/attachment/browser_attachmentSize.js b/comm/mail/test/browser/attachment/browser_attachmentSize.js
new file mode 100644
index 0000000000..f4980c46ad
--- /dev/null
+++ b/comm/mail/test/browser/attachment/browser_attachmentSize.js
@@ -0,0 +1,421 @@
+/* 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 folder;
+var messenger;
+var epsilon;
+
+var {
+ create_body_part,
+ create_deleted_attachment,
+ create_detached_attachment,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/AttachmentHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_message,
+ mc,
+ msgGen,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { SyntheticPartLeaf, SyntheticPartMultiMixed } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+var textAttachment =
+ "Can't make the frug contest, Helen; stomach's upset. I'll fix you, " +
+ "Ubik! Ubik drops you back in the thick of things fast. Taken as " +
+ "directed, Ubik speeds relief to head and stomach. Remember: Ubik is " +
+ "only seconds away. Avoid prolonged use.";
+
+var binaryAttachment = textAttachment;
+
+var imageAttachment =
+ "iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAABHNCSVQICAgIfAhkiAAAAAlwS" +
+ "FlzAAAN1wAADdcBQiibeAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAA" +
+ "A5SURBVCiRY/z//z8DKYCJJNXkaGBgYGD4D8NQ5zUgiTVAxeBqSLaBkVRPM0KtIhrQ3km0jwe" +
+ "SNQAAlmAY+71EgFoAAAAASUVORK5CYII=";
+var imageSize = 188;
+
+var vcardAttachment =
+ "YmVnaW46dmNhcmQNCmZuOkppbSBCb2INCm46Qm9iO0ppbQ0KZW1haWw7aW50ZXJuZXQ6Zm9v" +
+ "QGJhci5jb20NCnZlcnNpb246Mi4xDQplbmQ6dmNhcmQNCg0K";
+var vcardSize = 90;
+
+var detachedName = "./attachment.txt";
+var missingName = "./nonexistent.txt";
+var deletedName = "deleted.txt";
+
+// create some messages that have various types of attachments
+var messages = [
+ {
+ name: "text_attachment",
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ attachmentSizes: [textAttachment.length],
+ attachmentTotalSize: { size: textAttachment.length, exact: true },
+ },
+ {
+ name: "binary_attachment",
+ attachments: [
+ {
+ body: binaryAttachment,
+ contentType: "application/x-ubik",
+ filename: "ubik",
+ format: "",
+ },
+ ],
+ attachmentSizes: [binaryAttachment.length],
+ attachmentTotalSize: { size: binaryAttachment.length, exact: true },
+ },
+ {
+ name: "image_attachment",
+ attachments: [
+ {
+ body: imageAttachment,
+ contentType: "image/png",
+ filename: "lines.png",
+ encoding: "base64",
+ format: "",
+ },
+ ],
+ attachmentSizes: [imageSize],
+ attachmentTotalSize: { size: imageSize, exact: true },
+ },
+ {
+ name: "detached_attachment",
+ bodyPart: null,
+ // Sizes filled in on message creation.
+ attachmentSizes: [null],
+ attachmentTotalSize: { size: 0, exact: true },
+ },
+ {
+ name: "detached_attachment_with_missing_file",
+ bodyPart: null,
+ attachmentSizes: [-1],
+ attachmentTotalSize: { size: 0, exact: false },
+ },
+ {
+ name: "deleted_attachment",
+ bodyPart: null,
+ attachmentSizes: [-1],
+ attachmentTotalSize: { size: 0, exact: true },
+ },
+ {
+ name: "multiple_attachments",
+ attachments: [
+ { body: textAttachment, filename: "ubik.txt", format: "" },
+ {
+ body: binaryAttachment,
+ contentType: "application/x-ubik",
+ filename: "ubik",
+ format: "",
+ },
+ ],
+ attachmentSizes: [textAttachment.length, binaryAttachment.length],
+ attachmentTotalSize: {
+ size: textAttachment.length + binaryAttachment.length,
+ exact: true,
+ },
+ },
+ // vCards should be included in the attachment list.
+ {
+ name: "multiple_attachments_one_vcard",
+ attachments: [
+ { body: textAttachment, filename: "ubik.txt", format: "" },
+ {
+ body: vcardAttachment,
+ contentType: "text/vcard",
+ filename: "ubik.vcf",
+ encoding: "base64",
+ format: "",
+ },
+ ],
+ attachmentSizes: [textAttachment.length, vcardSize],
+ attachmentTotalSize: {
+ size: textAttachment.length + vcardSize,
+ exact: true,
+ },
+ },
+ {
+ name: "multiple_attachments_one_detached",
+ bodyPart: null,
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ attachmentSizes: [null, textAttachment.length],
+ attachmentTotalSize: { size: textAttachment.length, exact: true },
+ },
+ {
+ name: "multiple_attachments_one_detached_with_missing_file",
+ bodyPart: null,
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ attachmentSizes: [-1, textAttachment.length],
+ attachmentTotalSize: { size: textAttachment.length, exact: false },
+ },
+ {
+ name: "multiple_attachments_one_deleted",
+ bodyPart: null,
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ attachmentSizes: [-1, textAttachment.length],
+ attachmentTotalSize: { size: textAttachment.length, exact: true },
+ },
+ // this is an attached message that itself has an attachment
+ {
+ name: "attached_message_with_attachment",
+ bodyPart: null,
+ attachmentSizes: [-1, textAttachment.length],
+ attachmentTotalSize: { size: 0, exact: true },
+ },
+];
+
+add_setup(async function () {
+ messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+ /* Today's gory details (thanks to Jonathan Protzenko): libmime somehow
+ * counts the trailing newline for an attachment MIME part. Most of the time,
+ * assuming attachment has N bytes (no matter what's inside, newlines or
+ * not), libmime will return N + 1 bytes. On Linux and Mac, this always
+ * holds. However, on Windows, if the attachment is not encoded (that is, is
+ * inline text), libmime will return N + 2 bytes.
+ */
+ epsilon = "@mozilla.org/windows-registry-key;1" in Cc ? 4 : 2;
+
+ // set up our detached/deleted attachments
+ var detachedFile = new FileUtils.File(
+ getTestFilePath(`data/${detachedName}`)
+ );
+ var detached = create_body_part("Here is a file", [
+ create_detached_attachment(detachedFile, "text/plain"),
+ ]);
+
+ var missingFile = new FileUtils.File(getTestFilePath(`data/${missingName}`));
+ var missing = create_body_part(
+ "Here is a file (but you deleted the external file, you silly oaf!)",
+ [create_detached_attachment(missingFile, "text/plain")]
+ );
+
+ var deleted = create_body_part("Here is a file that you deleted", [
+ create_deleted_attachment(deletedName, "text/plain"),
+ ]);
+
+ var attachedMessage = msgGen.makeMessage({
+ body: { body: textAttachment },
+ attachments: [{ body: textAttachment, filename: "ubik.txt", format: "" }],
+ });
+
+ /* Much like the above comment, libmime counts bytes differently on Windows,
+ * where it counts newlines (\r\n) as 2 bytes. Mac and Linux treats them as
+ * 1 byte.
+ */
+ var attachedMessageLength;
+ if (epsilon == 4) {
+ // Windows
+ attachedMessageLength = attachedMessage.toMessageString().length;
+ } else {
+ // Mac/Linux
+ attachedMessageLength = attachedMessage
+ .toMessageString()
+ .replace(/\r\n/g, "\n").length;
+ }
+
+ folder = await create_folder("AttachmentSizeA");
+ for (let i = 0; i < messages.length; i++) {
+ // First, add any missing info to the message object.
+ switch (messages[i].name) {
+ case "detached_attachment":
+ case "multiple_attachments_one_detached":
+ messages[i].bodyPart = detached;
+ messages[i].attachmentSizes[0] = detachedFile.fileSize;
+ messages[i].attachmentTotalSize.size += detachedFile.fileSize;
+ break;
+ case "detached_attachment_with_missing_file":
+ case "multiple_attachments_one_detached_with_missing_file":
+ messages[i].bodyPart = missing;
+ break;
+ case "deleted_attachment":
+ case "multiple_attachments_one_deleted":
+ messages[i].bodyPart = deleted;
+ break;
+ case "attached_message_with_attachment":
+ messages[i].bodyPart = new SyntheticPartMultiMixed([
+ new SyntheticPartLeaf("I am text!", { contentType: "text/plain" }),
+ attachedMessage,
+ ]);
+ messages[i].attachmentSizes[0] = attachedMessageLength;
+ messages[i].attachmentTotalSize.size += attachedMessageLength;
+ break;
+ }
+
+ await add_message_to_folder([folder], create_message(messages[i]));
+ }
+});
+
+/**
+ * Make sure that the attachment's size is what we expect
+ *
+ * @param index the attachment's index, starting at 0
+ * @param expectedSize the expected size of the attachment, in bytes
+ */
+function check_attachment_size(index, expectedSize) {
+ let win = get_about_message();
+ let list = win.document.getElementById("attachmentList");
+ let node = list.querySelectorAll("richlistitem.attachmentItem")[index];
+
+ // First, let's check that the attachment size is correct
+ let size = node.attachment.size;
+ Assert.ok(
+ Math.abs(size - expectedSize) <= epsilon,
+ `Attachment "${node.attachment.name}" size should be within ${epsilon} ` +
+ `of ${expectedSize} (actual: ${size})`
+ );
+
+ // Next, make sure that the formatted size in the label is correct
+ Assert.equal(
+ node.getAttribute("size"),
+ messenger.formatFileSize(size),
+ `Attachment "${node.attachment.name}" displayed size should match`
+ );
+}
+
+/**
+ * Make sure that the attachment's size is not displayed
+ *
+ * @param index the attachment's index, starting at 0
+ */
+function check_no_attachment_size(index) {
+ let win = get_about_message();
+ let list = win.document.getElementById("attachmentList");
+ let node = list.querySelectorAll("richlistitem.attachmentItem")[index];
+
+ Assert.equal(
+ node.attachment.size,
+ -1,
+ `Attachment "${node.attachment.name}" should have a size of -1`
+ );
+
+ // If there's no size, the size attribute is the zero-width space.
+ let nodeSize = node.getAttribute("size");
+ Assert.equal(
+ nodeSize,
+ "",
+ `Attachment "${node.attachment.name}" size should not be displayed`
+ );
+}
+
+/**
+ * Make sure that the total size of all attachments is what we expect.
+ *
+ * @param count the expected number of attachments
+ * @param expectedSize the expected size in bytes of all the attachments
+ * @param exact true if the size of all attachments is known, false otherwise
+ */
+function check_total_attachment_size(count, expectedSize, exact) {
+ let win = get_about_message();
+ let list = win.document.getElementById("attachmentList");
+ let nodes = list.querySelectorAll("richlistitem.attachmentItem");
+ let sizeNode = win.document.getElementById("attachmentSize");
+
+ Assert.equal(
+ nodes.length,
+ count,
+ "Should have the expected number of attachments"
+ );
+
+ let lastPartID;
+ let size = 0;
+ for (let i = 0; i < nodes.length; i++) {
+ let attachment = nodes[i].attachment;
+ if (!lastPartID || attachment.partID.indexOf(lastPartID) != 0) {
+ lastPartID = attachment.partID;
+ let currSize = attachment.size;
+ if (currSize > 0 && !isNaN(currSize)) {
+ size += Number(currSize);
+ }
+ }
+ }
+
+ Assert.ok(
+ Math.abs(size - expectedSize) <= epsilon * count,
+ `Total attachments size should be within ${epsilon * count} ` +
+ `of ${expectedSize} (actual: ${size})`
+ );
+
+ // Next, make sure that the formatted size in the label is correct
+ let formattedSize = sizeNode.getAttribute("value");
+ let expectedFormattedSize = messenger.formatFileSize(size);
+ let messengerBundle = mc.window.document.getElementById("bundle_messenger");
+
+ if (!exact) {
+ if (size == 0) {
+ expectedFormattedSize = messengerBundle.getString(
+ "attachmentSizeUnknown"
+ );
+ } else {
+ expectedFormattedSize = messengerBundle.getFormattedString(
+ "attachmentSizeAtLeast",
+ [expectedFormattedSize]
+ );
+ }
+ }
+ Assert.equal(
+ formattedSize,
+ expectedFormattedSize,
+ "Displayed attachments total size should match"
+ );
+}
+
+/**
+ * Make sure that the individual and total attachment sizes for this message
+ * are as expected
+ *
+ * @param index the index of the message to check in the thread pane
+ */
+async function help_test_attachment_size(index) {
+ await be_in_folder(folder);
+ select_click_row(index);
+ info(`Testing message ${index}: ${messages[index].name}`);
+ let expectedSizes = messages[index].attachmentSizes;
+
+ let aboutMessage = get_about_message();
+ aboutMessage.toggleAttachmentList(true);
+
+ let attachmentList = aboutMessage.document.getElementById("attachmentList");
+ await TestUtils.waitForCondition(
+ () => !attachmentList.collapsed,
+ "Attachment list is shown"
+ );
+
+ for (let i = 0; i < expectedSizes.length; i++) {
+ if (expectedSizes[i] == -1) {
+ check_no_attachment_size(i);
+ } else {
+ check_attachment_size(i, expectedSizes[i]);
+ }
+ }
+
+ let totalSize = messages[index].attachmentTotalSize;
+ check_total_attachment_size(
+ expectedSizes.length,
+ totalSize.size,
+ totalSize.exact
+ );
+}
+
+add_task(async function test_attachment_sizes() {
+ for (let i = 0; i < messages.length; i++) {
+ await help_test_attachment_size(i);
+ }
+});
+
+registerCleanupFunction(() => {
+ // Remove created folders.
+ folder.deleteSelf(null);
+});
diff --git a/comm/mail/test/browser/attachment/browser_openAttachment.js b/comm/mail/test/browser/attachment/browser_openAttachment.js
new file mode 100644
index 0000000000..737144b3c6
--- /dev/null
+++ b/comm/mail/test/browser/attachment/browser_openAttachment.js
@@ -0,0 +1,738 @@
+/* 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 {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_message,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+let aboutMessage = get_about_message();
+
+const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+const handlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+].getService(Ci.nsIHandlerService);
+
+const { MockFilePicker } = SpecialPowers;
+MockFilePicker.init(window);
+
+// At the time of writing, this pref was set to true on nightly channels only.
+// The behaviour is slightly different when it is false.
+const IMPROVEMENTS_PREF_SET = Services.prefs.getBoolPref(
+ "browser.download.improvements_to_download_panel",
+ true
+);
+
+let tmpD;
+let savePath;
+let homeDirectory;
+
+let folder;
+
+let mockedHandlerApp;
+let mockedHandlers = new Set();
+
+function getNsIFileFromPath(path) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(path);
+ return file;
+}
+
+add_setup(async function () {
+ folder = await create_folder("OpenAttachment");
+ await be_in_folder(folder);
+
+ // @see logic for tmpD in msgHdrView.js
+ tmpD = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "pid-" + Services.appinfo.processID
+ );
+
+ savePath = await IOUtils.createUniqueDirectory(tmpD, "saveDestination");
+ Services.prefs.setStringPref("browser.download.dir", savePath);
+
+ homeDirectory = await IOUtils.createUniqueDirectory(tmpD, "homeDirectory");
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setBoolPref("browser.download.useDownloadDir", true);
+ Services.prefs.setIntPref("security.dialog_enable_delay", 0);
+
+ let mockedExecutable = FileUtils.getFile("TmpD", ["mockedExecutable"]);
+ if (!mockedExecutable.exists()) {
+ mockedExecutable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
+ }
+
+ mockedHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ mockedHandlerApp.executable = mockedExecutable;
+ mockedHandlerApp.detailedDescription = "Mocked handler app";
+ registerCleanupFunction(() => {
+ if (mockedExecutable.exists()) {
+ mockedExecutable.remove(true);
+ }
+ });
+});
+
+registerCleanupFunction(async function () {
+ MockFilePicker.cleanup();
+
+ await IOUtils.remove(savePath, { recursive: true });
+ await IOUtils.remove(homeDirectory, { recursive: true });
+
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.useDownloadDir");
+ Services.prefs.clearUserPref("security.dialog.dialog_enable_delay");
+
+ for (let type of mockedHandlers) {
+ let handlerInfo = mimeService.getFromTypeAndExtension(type, null);
+ if (handlerService.exists(handlerInfo)) {
+ handlerService.remove(handlerInfo);
+ }
+ }
+
+ // Remove created folders.
+ folder.deleteSelf(null);
+
+ Services.focus.focusedWindow = window;
+});
+
+function createMockedHandler(type, preferredAction, alwaysAskBeforeHandling) {
+ info(`Creating handler for ${type}`);
+
+ let handlerInfo = mimeService.getFromTypeAndExtension(type, null);
+ handlerInfo.preferredAction = preferredAction;
+ handlerInfo.alwaysAskBeforeHandling = alwaysAskBeforeHandling;
+
+ handlerInfo.description = mockedHandlerApp.detailedDescription;
+ handlerInfo.possibleApplicationHandlers.appendElement(mockedHandlerApp);
+ handlerInfo.hasDefaultHandler = true;
+ handlerInfo.preferredApplicationHandler = mockedHandlerApp;
+
+ handlerService.store(handlerInfo);
+ mockedHandlers.add(type);
+}
+
+let messageIndex = -1;
+async function createAndLoadMessage(
+ type,
+ { filename, isDetached = false } = {}
+) {
+ messageIndex++;
+
+ if (!filename) {
+ filename = `attachment${messageIndex}.test${messageIndex}`;
+ }
+
+ let attachment = {
+ contentType: type,
+ body: `${type}Attachment`,
+ filename,
+ };
+
+ // Allow for generation of messages with detached attachments.
+ if (isDetached) {
+ // Generate a file with content to represent the attachment.
+ let attachmentFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ attachmentFile.initWithPath(homeDirectory);
+ attachmentFile.append(filename);
+ if (!attachmentFile.exists()) {
+ attachmentFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
+ await IOUtils.writeUTF8(attachmentFile.path, "some file content");
+ }
+
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ // Append relevant Thunderbird headers to indicate a detached file.
+ attachment.extraHeaders = {
+ "X-Mozilla-External-Attachment-URL":
+ fileHandler.getURLSpecFromActualFile(attachmentFile),
+ "X-Mozilla-Altered":
+ 'AttachmentDetached; date="Mon Apr 04 13:59:42 2022"',
+ };
+ }
+
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ subject: `${type} attachment`,
+ body: {
+ body: "I'm an attached email!",
+ },
+ attachments: [attachment],
+ })
+ );
+ select_click_row(messageIndex);
+}
+
+async function singleClickAttachmentAndWaitForDialog(
+ { mode = "save", rememberExpected = true, remember } = {},
+ button = "cancel"
+) {
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ {
+ async callback(dialogWindow) {
+ await new Promise(resolve => dialogWindow.setTimeout(resolve));
+ await new Promise(resolve => dialogWindow.setTimeout(resolve));
+
+ let dialogDocument = dialogWindow.document;
+ let rememberChoice = dialogDocument.getElementById("rememberChoice");
+ Assert.equal(
+ dialogDocument.getElementById("mode").selectedItem.id,
+ mode,
+ "correct action is selected"
+ );
+ Assert.equal(
+ rememberChoice.checked,
+ rememberExpected,
+ "remember choice checkbox checked/not checked as expected"
+ );
+ if (remember !== undefined && remember != rememberExpected) {
+ EventUtils.synthesizeMouseAtCenter(rememberChoice, {}, dialogWindow);
+ Assert.equal(
+ rememberChoice.checked,
+ remember,
+ "remember choice checkbox changed"
+ );
+ }
+
+ dialogDocument.querySelector("dialog").getButton(button).click();
+ },
+ }
+ );
+
+ info(aboutMessage.document.getElementById("attachmentName").value);
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentName"),
+ {},
+ aboutMessage
+ );
+ await dialogPromise;
+}
+
+async function singleClickAttachment() {
+ info(aboutMessage.document.getElementById("attachmentName").value);
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentName"),
+ {},
+ aboutMessage
+ );
+}
+
+// Other test boilerplate should initialize a message with attachment; here we
+// verify that it was created and return an nsIFile handle to it.
+async function verifyAndFetchSavedAttachment(parentPath = savePath, leafName) {
+ let expectedFile = getNsIFileFromPath(parentPath);
+ if (leafName) {
+ expectedFile.append(leafName);
+ } else {
+ expectedFile.append(`attachment${messageIndex}.test${messageIndex}`);
+ }
+ await TestUtils.waitForCondition(
+ () => expectedFile.exists(),
+ `attachment was not saved to ${expectedFile.path}`
+ );
+ Assert.ok(expectedFile.exists(), `${expectedFile.path} exists`);
+
+ // Wait a moment in case the file is still locked for writing.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 250));
+
+ return expectedFile;
+}
+
+function checkHandler(type, preferredAction, alwaysAskBeforeHandling) {
+ let handlerInfo = mimeService.getFromTypeAndExtension(type, null);
+ Assert.equal(
+ handlerInfo.preferredAction,
+ preferredAction,
+ `preferredAction of ${type}`
+ );
+ Assert.equal(
+ handlerInfo.alwaysAskBeforeHandling,
+ alwaysAskBeforeHandling,
+ `alwaysAskBeforeHandling of ${type}`
+ );
+}
+
+function promiseFileOpened() {
+ let __openFile = aboutMessage.AttachmentInfo.prototype._openFile;
+ return new Promise(resolve => {
+ aboutMessage.AttachmentInfo.prototype._openFile = function (
+ mimeInfo,
+ file
+ ) {
+ aboutMessage.AttachmentInfo.prototype._openFile = __openFile;
+ resolve({ mimeInfo, file });
+ };
+ });
+}
+
+/**
+ * Check that the directory for saving is correct.
+ * If not, we're gonna have a bad time.
+ */
+add_task(async function sanityCheck() {
+ Assert.equal(
+ await Downloads.getPreferredDownloadsDirectory(),
+ savePath,
+ "sanity check: correct downloads directory"
+ );
+});
+
+// First, check content types we have no saved information about.
+
+/**
+ * Open a content type we've never seen before. Save, and remember the action.
+ */
+add_task(async function noHandler() {
+ await createAndLoadMessage("test/foo");
+ await singleClickAttachmentAndWaitForDialog(
+ { rememberExpected: false, remember: true },
+ "accept"
+ );
+ let file = await verifyAndFetchSavedAttachment();
+ file.remove(false);
+ checkHandler("test/foo", Ci.nsIHandlerInfo.saveToDisk, false);
+});
+
+/**
+ * Open a content type we've never seen before. Save, and DON'T remember the
+ * action (except that we do remember it, but also remember to ask next time).
+ */
+add_task(async function noHandlerNoSave() {
+ await createAndLoadMessage("test/bar");
+ await singleClickAttachmentAndWaitForDialog(
+ { rememberExpected: false, remember: false },
+ "accept"
+ );
+ let file = await verifyAndFetchSavedAttachment();
+ file.remove(false);
+ checkHandler("test/bar", Ci.nsIHandlerInfo.saveToDisk, true);
+});
+
+/**
+ * The application/octet-stream type is handled weirdly. Check that opening it
+ * still behaves in a useful way.
+ */
+add_task(async function applicationOctetStream() {
+ await createAndLoadMessage("application/octet-stream");
+ await singleClickAttachmentAndWaitForDialog(
+ { rememberExpected: false },
+ "accept"
+ );
+ let file = await verifyAndFetchSavedAttachment();
+ file.remove(false);
+});
+
+// Now we'll test the various states that handler info objects might be in.
+// There's two fields: preferredAction and alwaysAskBeforeHandling. If the
+// latter is true, we MUST get a prompt. Check that first.
+
+/**
+ * Open a content type set to save to disk, but always ask.
+ */
+add_task(async function saveToDiskAlwaysAsk() {
+ createMockedHandler(
+ "test/saveToDisk-true",
+ Ci.nsIHandlerInfo.saveToDisk,
+ true
+ );
+ await createAndLoadMessage("test/saveToDisk-true");
+ await singleClickAttachmentAndWaitForDialog(
+ { rememberExpected: false },
+ "accept"
+ );
+ let file = await verifyAndFetchSavedAttachment();
+ file.remove(false);
+ checkHandler("test/saveToDisk-true", Ci.nsIHandlerInfo.saveToDisk, true);
+});
+
+/**
+ * Open a content type set to save to disk, but always ask, and with no
+ * default download directory.
+ */
+add_task(async function saveToDiskAlwaysAskPromptLocation() {
+ Services.prefs.setBoolPref("browser.download.useDownloadDir", false);
+
+ createMockedHandler(
+ "test/saveToDisk-true",
+ Ci.nsIHandlerInfo.saveToDisk,
+ true
+ );
+ await createAndLoadMessage("test/saveToDisk-true");
+
+ let expectedFile = getNsIFileFromPath(tmpD);
+ expectedFile.append(`attachment${messageIndex}.test${messageIndex}`);
+ MockFilePicker.showCallback = function (instance) {
+ Assert.equal(instance.defaultString, expectedFile.leafName);
+ Assert.equal(instance.defaultExtension, `test${messageIndex}`);
+ };
+ MockFilePicker.setFiles([expectedFile]);
+ MockFilePicker.returnValue = Ci.nsIFilePicker.returnOK;
+
+ await singleClickAttachmentAndWaitForDialog(
+ { rememberExpected: false },
+ "accept"
+ );
+ let file = await verifyAndFetchSavedAttachment(tmpD);
+ file.remove(false);
+ Assert.ok(MockFilePicker.shown, "file picker was shown");
+
+ MockFilePicker.reset();
+ Services.prefs.setBoolPref("browser.download.useDownloadDir", true);
+});
+
+/**
+ * Open a content type set to always ask in both fields.
+ */
+add_task(async function alwaysAskAlwaysAsk() {
+ createMockedHandler("test/alwaysAsk-true", Ci.nsIHandlerInfo.alwaysAsk, true);
+ await createAndLoadMessage("test/alwaysAsk-true");
+ await singleClickAttachmentAndWaitForDialog({
+ mode: IMPROVEMENTS_PREF_SET ? "save" : "open",
+ rememberExpected: false,
+ });
+});
+
+/**
+ * Open a content type set to use helper app, but always ask.
+ */
+add_task(async function useHelperAppAlwaysAsk() {
+ createMockedHandler(
+ "test/useHelperApp-true",
+ Ci.nsIHandlerInfo.useHelperApp,
+ true
+ );
+ await createAndLoadMessage("test/useHelperApp-true");
+ await singleClickAttachmentAndWaitForDialog({
+ mode: "open",
+ rememberExpected: false,
+ });
+});
+
+/*
+ * Open a detached attachment with content type set to use helper app, but
+ * always ask.
+ */
+add_task(async function detachedUseHelperAppAlwaysAsk() {
+ const mimeType = "test/useHelperApp-true";
+ let openedPromise = promiseFileOpened();
+
+ createMockedHandler(mimeType, Ci.nsIHandlerInfo.useHelperApp, true);
+
+ // Generate an email with detached attachment.
+ await createAndLoadMessage(mimeType, { isDetached: true });
+ await singleClickAttachmentAndWaitForDialog(
+ { mode: "open", rememberExpected: false },
+ "accept"
+ );
+
+ let expectedPath = PathUtils.join(
+ homeDirectory,
+ `attachment${messageIndex}.test${messageIndex}`
+ );
+
+ let { file } = await openedPromise;
+ Assert.equal(
+ file.path,
+ expectedPath,
+ "opened file should match attachment path"
+ );
+
+ file.remove(false);
+});
+
+/**
+ * Open a content type set to use the system default app, but always ask.
+ */
+add_task(async function useSystemDefaultAlwaysAsk() {
+ createMockedHandler(
+ "test/useSystemDefault-true",
+ Ci.nsIHandlerInfo.useSystemDefault,
+ true
+ );
+ await createAndLoadMessage("test/useSystemDefault-true");
+ // Would be mode: "open" on all platforms except our handler isn't real.
+ await singleClickAttachmentAndWaitForDialog({
+ mode: AppConstants.platform == "win" ? "open" : "save",
+ rememberExpected: false,
+ });
+});
+
+// Check what happens with alwaysAskBeforeHandling set to false. We can't test
+// the actions that would result in an external app opening the file.
+
+/**
+ * Open a content type set to save to disk without asking.
+ */
+add_task(async function saveToDisk() {
+ createMockedHandler("test/saveToDisk-false", saveToDisk, false);
+ await createAndLoadMessage("test/saveToDisk-false");
+ await singleClickAttachment();
+ let file = await verifyAndFetchSavedAttachment();
+ file.remove(false);
+});
+
+/**
+ * Open a content type set to save to disk without asking, and with no
+ * default download directory.
+ */
+add_task(async function saveToDiskPromptLocation() {
+ Services.prefs.setBoolPref("browser.download.useDownloadDir", false);
+
+ createMockedHandler(
+ "test/saveToDisk-true",
+ Ci.nsIHandlerInfo.saveToDisk,
+ false
+ );
+ await createAndLoadMessage("test/saveToDisk-false");
+
+ let expectedFile = getNsIFileFromPath(tmpD);
+ expectedFile.append(`attachment${messageIndex}.test${messageIndex}`);
+ MockFilePicker.showCallback = function (instance) {
+ Assert.equal(instance.defaultString, expectedFile.leafName);
+ Assert.equal(instance.defaultExtension, `test${messageIndex}`);
+ };
+ MockFilePicker.setFiles([expectedFile]);
+ MockFilePicker.returnValue = Ci.nsIFilePicker.returnOK;
+
+ await singleClickAttachment();
+ let file = await verifyAndFetchSavedAttachment(tmpD);
+ file.remove(false);
+ Assert.ok(MockFilePicker.shown, "file picker was shown");
+
+ MockFilePicker.reset();
+ Services.prefs.setBoolPref("browser.download.useDownloadDir", true);
+});
+
+/**
+ * Open a content type set to always ask without asking (weird but plausible).
+ * Check the action is saved and the "do this automatically" checkbox works.
+ */
+add_task(async function alwaysAskRemember() {
+ createMockedHandler(
+ "test/alwaysAsk-false",
+ Ci.nsIHandlerInfo.alwaysAsk,
+ false
+ );
+ await createAndLoadMessage("test/alwaysAsk-false");
+ await singleClickAttachmentAndWaitForDialog(undefined, "accept");
+ let file = await verifyAndFetchSavedAttachment();
+ file.remove(false);
+ checkHandler("test/alwaysAsk-false", Ci.nsIHandlerInfo.saveToDisk, false);
+}).__skipMe = !IMPROVEMENTS_PREF_SET;
+
+/**
+ * Open a content type set to always ask without asking (weird but plausible).
+ * Check the action is saved and the unticked "do this automatically" leaves
+ * alwaysAskBeforeHandling set.
+ */
+add_task(async function alwaysAskForget() {
+ createMockedHandler(
+ "test/alwaysAsk-false",
+ Ci.nsIHandlerInfo.alwaysAsk,
+ false
+ );
+ await createAndLoadMessage("test/alwaysAsk-false");
+ await singleClickAttachmentAndWaitForDialog({ remember: false }, "accept");
+ let file = await verifyAndFetchSavedAttachment();
+ file.remove(false);
+ checkHandler("test/alwaysAsk-false", Ci.nsIHandlerInfo.saveToDisk, true);
+}).__skipMe = !IMPROVEMENTS_PREF_SET;
+
+/**
+ * Open a content type set to use helper app.
+ */
+add_task(async function useHelperApp() {
+ let openedPromise = promiseFileOpened();
+
+ createMockedHandler(
+ "test/useHelperApp-false",
+ Ci.nsIHandlerInfo.useHelperApp,
+ false
+ );
+ await createAndLoadMessage("test/useHelperApp-false");
+ await singleClickAttachment();
+ let attachmentFile = await verifyAndFetchSavedAttachment(tmpD);
+
+ let { file } = await openedPromise;
+ Assert.ok(file.path);
+
+ // In the temp dir, files should be read-only.
+ if (AppConstants.platform != "win") {
+ let fileInfo = await IOUtils.stat(file.path);
+ Assert.equal(
+ fileInfo.permissions,
+ 0o400,
+ `file ${file.path} should be read-only`
+ );
+ }
+ attachmentFile.permissions = 0o755;
+ attachmentFile.remove(false);
+});
+
+/*
+ * Open a detached attachment with content type set to use helper app.
+ */
+add_task(async function detachedUseHelperApp() {
+ const mimeType = "test/useHelperApp-false";
+ let openedPromise = promiseFileOpened();
+
+ createMockedHandler(mimeType, Ci.nsIHandlerInfo.useHelperApp, false);
+
+ // Generate an email with detached attachment.
+ await createAndLoadMessage(mimeType, { isDetached: true });
+ await singleClickAttachment();
+
+ let expectedPath = PathUtils.join(
+ homeDirectory,
+ `attachment${messageIndex}.test${messageIndex}`
+ );
+
+ let { file } = await openedPromise;
+ Assert.equal(
+ file.path,
+ expectedPath,
+ "opened file should match attachment path"
+ );
+
+ file.remove(false);
+});
+
+/**
+ * Open a content type set to use the system default app.
+ */
+add_task(async function useSystemDefault() {
+ let openedPromise = promiseFileOpened();
+
+ createMockedHandler(
+ "test/useSystemDefault-false",
+ Ci.nsIHandlerInfo.useSystemDefault,
+ false
+ );
+ await createAndLoadMessage("test/useSystemDefault-false");
+ await singleClickAttachment();
+ let attachmentFile = await verifyAndFetchSavedAttachment(tmpD);
+ let { file } = await openedPromise;
+ Assert.ok(file.path);
+
+ // In the temp dir, files should be read-only.
+ if (AppConstants.platform != "win") {
+ let fileInfo = await IOUtils.stat(file.path);
+ Assert.equal(
+ fileInfo.permissions,
+ 0o400,
+ `file ${file.path} should be read-only`
+ );
+ }
+ attachmentFile.permissions = 0o755;
+ attachmentFile.remove(false);
+});
+
+/*
+ * Open a detached attachment with content type set to use the system default
+ * app.
+ */
+add_task(async function detachedUseSystemDefault() {
+ const mimeType = "test/useSystemDefault-false";
+ let openedPromise = promiseFileOpened();
+
+ createMockedHandler(mimeType, Ci.nsIHandlerInfo.useSystemDefault, false);
+
+ // Generate an email with detached attachment.
+ await createAndLoadMessage(mimeType, { isDetached: true });
+ await singleClickAttachment();
+
+ let expectedPath = PathUtils.join(
+ homeDirectory,
+ `attachment${messageIndex}.test${messageIndex}`
+ );
+
+ let { file } = await openedPromise;
+ Assert.equal(
+ file.path,
+ expectedPath,
+ "opened file should match attachment path"
+ );
+
+ file.remove(false);
+});
+
+/**
+ * Save an attachment with characters that are illegal in a file name.
+ * Check the characters are sanitized.
+ */
+add_task(async function filenameSanitisedSave() {
+ createMockedHandler("test/bar", Ci.nsIHandlerInfo.saveToDisk, false);
+
+ // Colon, slash and backslash are escaped on all platforms.
+ // Backslash is double-escaped here because of the message generator.
+ await createAndLoadMessage("test/bar", { filename: "f:i\\\\le/123.bar" });
+ await singleClickAttachment();
+ let file = await verifyAndFetchSavedAttachment(undefined, "f i_le_123.bar");
+ file.remove(false);
+
+ // Asterisk, question mark, pipe and angle brackets are escaped on Windows.
+ await createAndLoadMessage("test/bar", { filename: "f*i?|le<123>.bar" });
+ await singleClickAttachment();
+ file = await verifyAndFetchSavedAttachment(undefined, "f i le 123 .bar");
+ file.remove(false);
+});
+
+/**
+ * Open an attachment with characters that are illegal in a file name.
+ * Check the characters are sanitized.
+ */
+add_task(async function filenameSanitisedOpen() {
+ createMockedHandler("test/bar", Ci.nsIHandlerInfo.useHelperApp, false);
+
+ let openedPromise = promiseFileOpened();
+
+ // Colon, slash and backslash are escaped on all platforms.
+ // Backslash is double-escaped here because of the message generator.
+ await createAndLoadMessage("test/bar", { filename: "f:i\\\\le/123.bar" });
+ await singleClickAttachment();
+ let { file } = await openedPromise;
+ let attachmentFile = await verifyAndFetchSavedAttachment(
+ tmpD,
+ "f i_le_123.bar"
+ );
+ Assert.equal(file.leafName, "f i_le_123.bar");
+ // In the temp dir, files should be read-only.
+ if (AppConstants.platform != "win") {
+ let fileInfo = await IOUtils.stat(file.path);
+ Assert.equal(
+ fileInfo.permissions,
+ 0o400,
+ `file ${file.path} should be read-only`
+ );
+ }
+ attachmentFile.permissions = 0o755;
+ attachmentFile.remove(false);
+
+ openedPromise = promiseFileOpened();
+
+ // Asterisk, question mark, pipe and angle brackets are escaped on Windows.
+ await createAndLoadMessage("test/bar", { filename: "f*i?|le<123>.bar" });
+ await singleClickAttachment();
+ ({ file } = await openedPromise);
+ attachmentFile = await verifyAndFetchSavedAttachment(tmpD, "f i le 123 .bar");
+ Assert.equal(file.leafName, "f i le 123 .bar");
+ attachmentFile.permissions = 0o755;
+ attachmentFile.remove(false);
+});
diff --git a/comm/mail/test/browser/attachment/data/attachment.txt b/comm/mail/test/browser/attachment/data/attachment.txt
new file mode 100644
index 0000000000..385b5b2c95
--- /dev/null
+++ b/comm/mail/test/browser/attachment/data/attachment.txt
@@ -0,0 +1 @@
+This is a test attachment! It sure is exciting!
diff --git a/comm/mail/test/browser/attachment/data/bug1358565.eml b/comm/mail/test/browser/attachment/data/bug1358565.eml
new file mode 100644
index 0000000000..a2cf644898
--- /dev/null
+++ b/comm/mail/test/browser/attachment/data/bug1358565.eml
@@ -0,0 +1,62 @@
+Date: Thu, 22 Feb 2017 10:00:00 -0300
+From: <nobody@example.com>
+MIME-Version: 1.0
+To: <nobody2@example.com>
+Content-Type: multipart/alternative; boundary="------alternative"
+Subject: thunderbird bug
+
+
+--------alternative
+Content-Type: text/plain;
+ charset=US-ASCII;
+ format=flowed
+Content-Transfer-Encoding: 7bit
+
+text plain part.
+
+--------alternative
+Content-Type: multipart/related;
+ boundary="------related";
+ type="text/html"
+
+--------related
+Content-Type: text/html;
+ charset=US-ASCII
+Content-Transfer-Encoding: 7bit
+
+<html><body>HTML part<br><img src="cid:bug.png"></body></html>
+--------related
+Content-Disposition: inline;
+ filename=bug.png
+Content-Transfer-Encoding: base64
+Content-Type: image/jpeg;
+ name="bug.png"
+Content-Id: <bug.png>
+
+iVBORw0KGgoAAAANSUhEUgAAAHYAAABNCAYAAABzGpB/AAAABGdBTUEAALGPC/xhBQAAAAFzUkdC
+AK7OHOkAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAS/SURBVHja7Zy/TexAEIdPJKQkhDQAJRBQAhk5
+GQWQ0QARBSBRAVRAAQRkpERIFEAJfvqeZOnOzHj/2l4fv5+00ruz18/M55kd787eppP2UhuZQGAl
+gZUEVhJYSWAFVib4o2BPTk66zWaz056fn2U5gZUEVhJYSWAFVmAFVpYTWElgJYGVBFZgBVZgZTmB
+lVYP9v39vbu9ve1OT0+7w8PDnT585loc57y/pM/Pz+7h4aG7urrqjo+Pd+xycXHR3dzcdK+vr93P
+z09bYL++vrrz8/Nf5401zs8FbN3T9fV1kSH4m4bX5P8pEbDOzs6ibQL0l5eXXUDGeR8fH9ODfXx8
+7A4ODpKg9o1+9/f3ewcWz8M7c2xCo2/vvYuABUruzW+3VLgtgyXsDsNtTiNELwLWMgQeSIjFyP05
+QLu8vPw15pYkZK2CxcvGoHLs7u6ue3p6+g+HRujlO6sf4/KsYIfhl39jWMbaMQHZC9t8H+rfOli8
+zAPKeBsSkGO8fTKw23COjo6ibno70aKPdcOxcFoEixd6IfX7+7tqKJ8M7DbgnMwWuJbnxnpta2C9
+EExGnPMKE4I7OdiSiQov8eI9d21gPW+NAeDp7e1tGbAkSaWyEqoYY7YG1npXJSEqlTdmTwo2ZVz1
+hHda114TWMKm9Tfwfamw8axg8bQaYnzOCfEtgbXCMB5cQyRds4KtEYZ7WUlUaJxtCaw1w8T7Z7U5
+3znBlhqx9PotgbWyVxKfVYKtuWy3drC1s2GBbQCslzjVlMAuABYDC6zACqzA7gFYa0FAYPcA7Jon
+KLzkabVZccxEfa4xaaEVoynA0r/W606N6dbSBycLLBURNcR1cqYrp7gnqxgvBuxUCwCrniu2Vndi
+pitzvSv1XmKuCUSrYqKGvIK4SVd3SsdZbz2WspuQvFWh2NKa2CEhBqznVaXh2AvDk4Mlm80VACwP
+iY0EHoicUtaxvzEGLBUSVt/c6omQt85SQZG7yuMVl6eAsVaFeDBSvdbz/pTwboXjkrHWq06cDWwO
+XA9qagTwrpNyP6G66Fiw3rppDlyvzGb2KsXeAKFXFI6PPSSpY5IXjmnsHRrzXI4NEzD6TFGl2Ff2
+hyoVOT4Mv4vXFW97HQbjeF8szmev3LR0bLRgDGFtF68TdvFo68GsUVcc2tZBDRMPAOu1wOFh5rNV
+28R33vg96cwTN5W7Z6cG1N7zQg9NqNGf69TaCeAVoKVu71h0704JXPrFvNqERIjPhdtDzZ1S9OCy
+LbLGhqxF54oxbCgkWvt7ct85Pc+1ZrHG2nBLSu1tlITblC2UnDvcQjkp2BTPwVgYwxrDgEnYrQnU
+Ajy28ZrvOT7lPViA+03PlneSNY/VSC0OVppGucuCAtuwStZ7BbZhMeZa4VtgVy4ru44tRhfYRuVN
+TsQWowtso7KmKFPWeQW2QXlrsSkLCgLbYAj2piVTfvJAYCuJbLV0MxavN95PFKTu4BPYyhMJTA0C
+IaUEldDrLdYPFwUEdiGw1nsnoEmGho3XmdB8Mh6cszteYCcGW7p8l/uTBwLbKFi8vKQYTmAriYyV
+8JqyVOf9JF9K9iuwM0OmCKFfrvNgc4zW/7Zi1QgiDHs6NMgEAisJrCSwksBK8foHNXX2LMmMFTgA
+AAAASUVORK5CYII=
+--------related--
+
+--------alternative--
diff --git a/comm/mail/test/browser/cloudfile/browser.ini b/comm/mail/test/browser/cloudfile/browser.ini
new file mode 100644
index 0000000000..0ea38fdc52
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/browser.ini
@@ -0,0 +1,52 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.account.account1.server=server1
+ mail.account.account2.identities=id1,id2
+ mail.account.account2.server=server2
+ mail.accountmanager.accounts=account1,account2
+ mail.accountmanager.defaultaccount=account2
+ mail.accountmanager.localfoldersserver=server1
+ mail.identity.id1.fullName=Tinderbox
+ mail.identity.id1.htmlSigFormat=false
+ mail.identity.id1.smtpServer=smtp1
+ mail.identity.id1.useremail=tinderbox@foo.invalid
+ mail.identity.id1.valid=true
+ mail.identity.id2.fullName=Tinderboxpushlog
+ mail.identity.id2.htmlSigFormat=true
+ mail.identity.id2.smtpServer=smtp1
+ mail.identity.id2.useremail=tinderboxpushlog@foo.invalid
+ mail.identity.id2.valid=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server1.type=none
+ mail.server.server1.userName=nobody
+ mail.server.server2.check_new_mail=false
+ mail.server.server2.directory-rel=[ProfD]Mail/tinderbox
+ mail.server.server2.download_on_biff=true
+ mail.server.server2.hostname=tinderbox123
+ mail.server.server2.login_at_startup=false
+ mail.server.server2.name=tinderbox@foo.invalid
+ mail.server.server2.type=pop3
+ mail.server.server2.userName=tinderbox
+ mail.server.server2.whiteListAbURI=
+ mail.shell.checkDefaultClient=false
+ mail.smtp.defaultserver=smtp1
+ mail.smtpserver.smtp1.hostname=tinderbox123
+ mail.smtpserver.smtp1.username=tinderbox
+ mail.smtpservers=smtp1
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+support-files =
+ data/**
+ html/**
+
+[browser_attachmentItem.js]
+[browser_attachmentUrls.js]
+[browser_attachmentErrors.js]
+[browser_notifications.js]
+[browser_filelinkTelemetry.js]
diff --git a/comm/mail/test/browser/cloudfile/browser_attachmentErrors.js b/comm/mail/test/browser/cloudfile/browser_attachmentErrors.js
new file mode 100644
index 0000000000..e2cf049c1c
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/browser_attachmentErrors.js
@@ -0,0 +1,440 @@
+/* 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/. */
+
+/**
+ * Tests CloudFile alerts on errors.
+ */
+
+"use strict";
+
+XPCOMUtils.defineLazyGetter(this, "brandShortName", () =>
+ Services.strings
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShortName")
+);
+
+var { gMockFilePicker, gMockFilePickReg, select_attachments } =
+ ChromeUtils.import("resource://testing-common/mozmill/AttachmentHelpers.jsm");
+var { gMockCloudfileManager, MockCloudfileAccount } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CloudfileHelpers.jsm"
+);
+var {
+ add_cloud_attachments,
+ rename_selected_cloud_attachment,
+ close_compose_window,
+ open_compose_new_mail,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ add_message_to_folder,
+ create_message,
+ FAKE_SERVER_HOSTNAME,
+ get_special_folder,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var kHtmlPrefKey = "mail.identity.default.compose_html";
+var kDefaultSigKey = "mail.identity.id1.htmlSigText";
+var kDefaultSig = "This is my signature.\n\nCheck out my website sometime!";
+var kFiles = ["./data/testFile1", "./data/testFile2"];
+
+var gInbox;
+
+function test_expected_included(actual, expected, description) {
+ Assert.equal(
+ actual.length,
+ expected.length,
+ `${description}: correct length`
+ );
+ for (let i = 0; i < expected.length; i++) {
+ for (let item of Object.keys(expected[i])) {
+ Assert.equal(
+ actual[i][item],
+ expected[i][item],
+ `${description}: ${item} exists and is correct`
+ );
+ }
+ }
+}
+
+add_setup(async function () {
+ requestLongerTimeout(3);
+
+ // These prefs can't be set in the manifest as they contain white-space.
+ Services.prefs.setStringPref(
+ "mail.identity.id1.htmlSigText",
+ "Tinderbox is soo 90ies"
+ );
+ Services.prefs.setStringPref(
+ "mail.identity.id2.htmlSigText",
+ "Tinderboxpushlog is the new <b>hotness!</b>"
+ );
+
+ // For replies and forwards, we'll work off a message in the Inbox folder
+ // of the fake "tinderbox" account.
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ gInbox = await get_special_folder(Ci.nsMsgFolderFlags.Inbox, false, server);
+ await add_message_to_folder([gInbox], create_message());
+
+ gMockFilePickReg.register();
+ gMockCloudfileManager.register();
+
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+ // Don't create paragraphs in the test.
+ // The test fails if it encounters paragraphs <p> instead of breaks <br>.
+ Services.prefs.setBoolPref("mail.compose.default_to_paragraph", false);
+});
+
+registerCleanupFunction(function () {
+ gMockCloudfileManager.unregister();
+ gMockFilePickReg.unregister();
+ Services.prefs.clearUserPref(kDefaultSigKey);
+ Services.prefs.clearUserPref(kHtmlPrefKey);
+ Services.prefs.clearUserPref("mail.compose.default_to_paragraph");
+});
+
+/**
+ * Test that we get the correct alert message when the provider reports a custom
+ * error during upload operation.
+ */
+add_task(function test_custom_error_during_upload() {
+ subtest_errors_during_upload({
+ exception: {
+ message: "This is a custom error.",
+ result: cloudFileAccounts.constants.uploadErrWithCustomMessage,
+ },
+ expectedAlerts: [
+ {
+ title: "Uploading testFile1 to providerA Failed",
+ message: "This is a custom error.",
+ },
+ {
+ title: "Uploading testFile2 to providerA Failed",
+ message: "This is a custom error.",
+ },
+ ],
+ });
+});
+
+/**
+ * Test that we get the correct alert message when the provider reports a standard
+ * error during upload operation.
+ */
+add_task(function test_standard_error_during_upload() {
+ subtest_errors_during_upload({
+ exception: {
+ message: "This is a standard error.",
+ result: cloudFileAccounts.constants.uploadErr,
+ },
+ expectedAlerts: [
+ {
+ title: "Upload Error",
+ message: "Unable to upload testFile1 to providerA.",
+ },
+ {
+ title: "Upload Error",
+ message: "Unable to upload testFile2 to providerA.",
+ },
+ ],
+ });
+});
+
+/**
+ * Test that we get the correct alert message when the provider reports a quota
+ * error.
+ */
+add_task(function test_quota_error_during_upload() {
+ subtest_errors_during_upload({
+ exception: {
+ message: "Quota Error.",
+ result: cloudFileAccounts.constants.uploadWouldExceedQuota,
+ },
+ expectedAlerts: [
+ {
+ title: "Quota Error",
+ message:
+ "Uploading testFile1 to providerA would exceed your space quota.",
+ },
+ {
+ title: "Quota Error",
+ message:
+ "Uploading testFile2 to providerA would exceed your space quota.",
+ },
+ ],
+ });
+});
+
+/**
+ * Test that we get the correct alert message when the provider reports a file
+ * size exceeded error.
+ */
+add_task(function test_file_size_error_during_upload() {
+ subtest_errors_during_upload({
+ exception: {
+ message: "File Size Error.",
+ result: cloudFileAccounts.constants.uploadExceedsFileLimit,
+ },
+ expectedAlerts: [
+ {
+ title: "File Size Error",
+ message: "testFile1 exceeds the maximum size for providerA.",
+ },
+ {
+ title: "File Size Error",
+ message: "testFile2 exceeds the maximum size for providerA.",
+ },
+ ],
+ });
+});
+
+/**
+ * Test that we get the connection error in offline mode.
+ */
+add_task(function test_offline_error_during_upload() {
+ subtest_errors_during_upload({
+ toggleOffline: true,
+ expectedAlerts: [
+ {
+ title: "Connection Error",
+ message: `${brandShortName} is offline. Could not connect to providerA.`,
+ },
+ {
+ title: "Connection Error",
+ message: `${brandShortName} is offline. Could not connect to providerA.`,
+ },
+ ],
+ });
+});
+
+/**
+ * Subtest for testing error messages during upload operation.
+ *
+ * @param error - defines the the thrown exception and the expected alert messages
+ * @param error.exception - the exception to be thrown by uploadFile()
+ * @param error.expectedAlerts - array with { title, message } objects for expected
+ * alerts for each uploaded file
+ */
+function subtest_errors_during_upload(error) {
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ let config = {
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ };
+ if (error.exception) {
+ config.uploadError = error.exception;
+ }
+ provider.init("providerA", config);
+
+ let cw = open_compose_new_mail();
+
+ if (error.toggleOffline) {
+ Services.io.offline = true;
+ }
+ let seenAlerts = add_cloud_attachments(
+ cw,
+ provider,
+ false,
+ error.expectedAlerts.length
+ );
+ if (error.toggleOffline) {
+ Services.io.offline = false;
+ }
+
+ Assert.equal(
+ seenAlerts.length,
+ error.expectedAlerts.length,
+ "Should have seen the correct number of alerts."
+ );
+ for (let i = 0; i < error.expectedAlerts.length; i++) {
+ Assert.equal(
+ error.expectedAlerts[i].title,
+ seenAlerts[i].title,
+ "Alert should have the correct title."
+ );
+ Assert.equal(
+ error.expectedAlerts[i].message,
+ seenAlerts[i].message,
+ "Alert should have the correct message."
+ );
+ }
+ close_compose_window(cw);
+}
+
+/**
+ * Test that we get the correct alert message when the provider does not support
+ * renaming.
+ */
+add_task(function test_nosupport_error_during_rename() {
+ subtest_errors_during_rename({
+ exception: {
+ message: "Rename not supported.",
+ result: cloudFileAccounts.constants.renameNotSupported,
+ },
+ expectedAlerts: [
+ {
+ title: "Rename Error",
+ message: "providerA does not support renaming already uploaded files.",
+ },
+ {
+ title: "Rename Error",
+ message: "providerA does not support renaming already uploaded files.",
+ },
+ ],
+ });
+});
+
+/**
+ * Test that we get the correct alert message when the provider reports a standard
+ * error during rename operation.
+ */
+add_task(function test_standard_error_during_rename() {
+ subtest_errors_during_rename({
+ exception: {
+ message: "Rename error.",
+ result: cloudFileAccounts.constants.renameErr,
+ },
+ expectedAlerts: [
+ {
+ title: "Rename Error",
+ message: "There was a problem renaming testFile1 on providerA.",
+ },
+ {
+ title: "Rename Error",
+ message: "There was a problem renaming testFile2 on providerA.",
+ },
+ ],
+ });
+});
+
+/**
+ * Test that we get the correct alert message when the provider reports a custom
+ * error during rename operation.
+ */
+add_task(function test_custom_error_during_rename() {
+ subtest_errors_during_rename({
+ exception: {
+ message: "This is a custom error.",
+ result: cloudFileAccounts.constants.renameErrWithCustomMessage,
+ },
+ expectedAlerts: [
+ {
+ title: "Renaming testFile1 on providerA Failed",
+ message: "This is a custom error.",
+ },
+ {
+ title: "Renaming testFile2 on providerA Failed",
+ message: "This is a custom error.",
+ },
+ ],
+ });
+});
+
+/**
+ * Test that we get the connection error in offline mode.
+ */
+add_task(function test_offline_error_during_rename() {
+ subtest_errors_during_rename({
+ toggleOffline: true,
+ expectedAlerts: [
+ {
+ title: "Connection Error",
+ message: `${brandShortName} is offline. Could not connect to providerA.`,
+ },
+ {
+ title: "Connection Error",
+ message: `${brandShortName} is offline. Could not connect to providerA.`,
+ },
+ ],
+ });
+});
+
+/**
+ * Subtest for testing error messages during rename operation.
+ *
+ * @param error - defines the the thrown exception and the expected alert messagees
+ * @param error.exception - the exception to be thrown by renameFile()
+ * @param error.expectedAlerts - array with { title, message } objects for each renamed file
+ */
+function subtest_errors_during_rename(error) {
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ let config = {
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ };
+ if (error.exception) {
+ config.renameError = error.exception;
+ }
+ provider.init("providerA", config);
+
+ let cw = open_compose_new_mail();
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerA/testFile1",
+ name: "testFile1",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ },
+ {
+ url: "https://www.example.com/providerA/testFile2",
+ name: "testFile2",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ },
+ ],
+ `Expected values in uploads array before renaming the files`
+ );
+
+ // Try to rename each Filelink, ensuring that we get the correct alerts.
+ if (error.toggleOffline) {
+ Services.io.offline = true;
+ }
+ let seenAlerts = [];
+ for (let i = 0; i < kFiles.length; ++i) {
+ select_attachments(cw, i);
+ seenAlerts.push(rename_selected_cloud_attachment(cw, "IgnoredNewName"));
+ }
+ if (error.toggleOffline) {
+ Services.io.offline = false;
+ }
+
+ Assert.equal(
+ seenAlerts.length,
+ error.expectedAlerts.length,
+ "Should have seen the correct number of alerts."
+ );
+ for (let i = 0; i < error.expectedAlerts.length; i++) {
+ Assert.equal(
+ error.expectedAlerts[i].title,
+ seenAlerts[i].title,
+ "Alert should have the correct title."
+ );
+ Assert.equal(
+ error.expectedAlerts[i].message,
+ seenAlerts[i].message,
+ "Alert should have the correct message."
+ );
+ }
+ close_compose_window(cw);
+}
diff --git a/comm/mail/test/browser/cloudfile/browser_attachmentItem.js b/comm/mail/test/browser/cloudfile/browser_attachmentItem.js
new file mode 100644
index 0000000000..18c3d92e41
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/browser_attachmentItem.js
@@ -0,0 +1,451 @@
+/* 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/. */
+
+/**
+ * Tests Filelink attachment item behaviour.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { gMockFilePicker, gMockFilePickReg, select_attachments } =
+ ChromeUtils.import("resource://testing-common/mozmill/AttachmentHelpers.jsm");
+var { getFile, gMockCloudfileManager, MockCloudfileAccount } =
+ ChromeUtils.import("resource://testing-common/mozmill/CloudfileHelpers.jsm");
+var {
+ add_cloud_attachments,
+ convert_selected_to_cloud_attachment,
+ close_compose_window,
+ open_compose_new_mail,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { close_popup, mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+var kAttachmentItemContextID = "msgComposeAttachmentItemContext";
+
+// Prepare the mock prompt.
+var originalPromptService = Services.prompt;
+var mockPromptService = {
+ alertCount: 0,
+ alert() {
+ this.alertCount++;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+};
+
+add_setup(function () {
+ Services.prompt = mockPromptService;
+ gMockFilePickReg.register();
+ gMockCloudfileManager.register();
+});
+
+registerCleanupFunction(function () {
+ gMockCloudfileManager.unregister();
+ gMockFilePickReg.unregister();
+ Services.prompt = originalPromptService;
+});
+
+/**
+ * Test that when an upload has been started, we can cancel and restart
+ * the upload, and then cancel again. For this test, we repeat this
+ * 3 times.
+ */
+add_task(async function test_upload_cancel_repeat() {
+ const kFile = "./data/testFile1";
+
+ // Prepare the mock file picker to return our test file.
+ let file = new FileUtils.File(getTestFilePath(kFile));
+ gMockFilePicker.returnFiles = [file];
+
+ let provider = new MockCloudfileAccount();
+ provider.init("someKey");
+ let cw = open_compose_new_mail(mc);
+
+ // We've got a compose window open, and our mock Filelink provider
+ // ready. Let's attach a file...
+ cw.window.AttachFile();
+
+ // Now we override the uploadFile function of the MockCloudfileAccount
+ // so that we're perpetually uploading...
+ let promise;
+ let started;
+ provider.uploadFile = function (window, aFile) {
+ return new Promise((resolve, reject) => {
+ promise = { resolve, reject };
+ started = true;
+ });
+ };
+
+ const kAttempts = 3;
+ for (let i = 0; i < kAttempts; i++) {
+ promise = null;
+ started = false;
+
+ let bucket = cw.window.document.getElementById("attachmentBucket");
+ Assert.equal(
+ bucket.itemCount,
+ 1,
+ "Should find correct number of attachments before converting."
+ );
+
+ // Select the attachment, and choose to convert it to a Filelink
+ select_attachments(cw, 0)[0];
+ cw.window.convertSelectedToCloudAttachment(provider);
+ utils.waitFor(() => started);
+
+ await assert_can_cancel_upload(cw, provider, promise, file);
+ await new Promise(resolve => setTimeout(resolve));
+
+ // A cancelled conversion must not remove the attachment.
+ Assert.equal(
+ bucket.itemCount,
+ 1,
+ "Should find correct number of attachments after converting."
+ );
+ }
+
+ close_compose_window(cw);
+});
+
+/**
+ * Test that we can cancel a whole series of files being uploaded at once.
+ */
+add_task(async function test_upload_multiple_and_cancel() {
+ const kFiles = ["./data/testFile1", "./data/testFile2", "./data/testFile3"];
+
+ // Prepare the mock file picker to return our test file.
+ let files = collectFiles(kFiles);
+ gMockFilePicker.returnFiles = files;
+
+ let provider = new MockCloudfileAccount();
+ provider.init("someKey");
+ let cw = open_compose_new_mail();
+
+ let promises = {};
+ provider.uploadFile = function (window, aFile) {
+ return new Promise((resolve, reject) => {
+ promises[aFile.leafName] = { resolve, reject };
+ });
+ };
+
+ add_cloud_attachments(cw, provider, false);
+
+ let bucket = cw.window.document.getElementById("attachmentBucket");
+ Assert.equal(
+ bucket.itemCount,
+ kFiles.length,
+ "Should find correct number of attachments before uploading."
+ );
+
+ for (let i = files.length - 1; i >= 0; --i) {
+ await assert_can_cancel_upload(
+ cw,
+ provider,
+ promises[files[i].leafName],
+ files[i]
+ );
+ }
+
+ // The cancelled attachment uploads should have been removed.
+ Assert.equal(
+ bucket.itemCount,
+ 0,
+ "Should find correct number of attachments after uploading."
+ );
+
+ close_compose_window(cw);
+});
+
+/**
+ * Helper function that takes an upload in progress, and cancels it,
+ * ensuring that the nsIMsgCloudFileProvider.uploadCanceled status message
+ * is returned to the passed in listener.
+ *
+ * @param aController the compose window controller to use.
+ * @param aProvider a MockCloudfileAccount for which the uploads have already
+ * started.
+ * @param aListener the nsIRequestObserver passed to aProvider's uploadFile
+ * function.
+ * @param aTargetFile the nsIFile to cancel the upload for.
+ */
+async function assert_can_cancel_upload(
+ aController,
+ aProvider,
+ aPromise,
+ aTargetFile
+) {
+ let cancelled = false;
+
+ // Override the provider's cancelFileUpload function. We can do this because
+ // it's assumed that the provider is a MockCloudfileAccount.
+ aProvider.cancelFileUpload = function (window, aFileToCancel) {
+ if (aTargetFile.equals(aFileToCancel)) {
+ aPromise.reject(
+ Components.Exception(
+ "Upload cancelled.",
+ cloudFileAccounts.constants.uploadCancelled
+ )
+ );
+ cancelled = true;
+ }
+ };
+
+ // Retrieve the attachment bucket index for the target file...
+ let index = get_attachmentitem_index_for_file(aController, aTargetFile);
+
+ // Select that attachmentitem in the bucket
+ select_attachments(aController, index)[0];
+
+ // Bring up the context menu, and click cancel.
+ let cmd = aController.window.document.getElementById("cmd_cancelUpload");
+ aController.window.updateAttachmentItems();
+
+ Assert.ok(!cmd.hidden, "cmd_cancelUpload should be shown");
+ Assert.ok(!cmd.disabled, "cmd_cancelUpload should be enabled");
+
+ let attachmentItem =
+ aController.window.document.getElementById("attachmentBucket").selectedItem;
+ let contextMenu = aController.window.document.getElementById(
+ "msgComposeAttachmentItemContext"
+ );
+
+ let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ attachmentItem,
+ { type: "contextmenu", button: 2 },
+ attachmentItem.ownerGlobal
+ );
+ await popupPromise;
+
+ let cancelItem = aController.window.document.getElementById(
+ "composeAttachmentContext_cancelUploadItem"
+ );
+ if (AppConstants.platform == "macosx") {
+ // We need to use click() since the synthesizeMouseAtCenter doesn't work for
+ // context menu items on macos.
+ cancelItem.click();
+ } else {
+ EventUtils.synthesizeMouseAtCenter(cancelItem, {}, cancelItem.ownerGlobal);
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ // Close the popup, and wait for the cancellation to be complete.
+ await close_popup(
+ aController,
+ aController.window.document.getElementById(kAttachmentItemContextID)
+ );
+ utils.waitFor(() => cancelled);
+}
+
+/**
+ * A helper function to find the attachment bucket index for a particular
+ * nsIFile. Returns null if no attachmentitem is found.
+ *
+ * @param aController the compose window controller to use.
+ * @param aFile the nsIFile to search for.
+ */
+function get_attachmentitem_index_for_file(aController, aFile) {
+ // Get the fileUrl from the file.
+ let fileUrl = aController.window.FileToAttachment(aFile).url;
+
+ // Get the bucket, and go through each item looking for the matching
+ // attachmentitem.
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ for (let i = 0; i < bucket.getRowCount(); ++i) {
+ let attachmentitem = bucket.getItemAtIndex(i);
+ if (attachmentitem.attachment.url == fileUrl) {
+ return i;
+ }
+ }
+ return null;
+}
+
+/**
+ * Helper function to start uploads and check number and icon of attachments
+ * after successful or failed uploads.
+ *
+ * @param error - to be returned error by uploadFile in case of failure
+ * @param expectedAttachments - number of expected attachments at the end of the test
+ * @param expectedAlerts - number of expected alerts at the end of the test
+ */
+async function test_upload(cw, error, expectedAttachments, expectedAlerts = 0) {
+ const kFiles = ["./data/testFile1", "./data/testFile2", "./data/testFile3"];
+
+ // Prepare the mock file picker to return our test file.
+ let files = collectFiles(kFiles);
+ gMockFilePicker.returnFiles = files;
+
+ let provider = new MockCloudfileAccount();
+ provider.init("someKey");
+
+ // Override the uploadFile function of the MockCloudfileAccount.
+ let promises = [];
+ provider.uploadFile = function (window, aFile) {
+ return new Promise((resolve, reject) => {
+ promises.push({
+ resolve,
+ reject,
+ upload: {
+ url: `https://example.org/${aFile.leafName}`,
+ size: aFile.fileSize,
+ path: aFile.path,
+ },
+ });
+ });
+ };
+
+ add_cloud_attachments(cw, provider, false);
+ utils.waitFor(() => promises.length == kFiles.length);
+
+ let bucket = cw.window.document.getElementById("attachmentBucket");
+ Assert.equal(
+ bucket.itemCount,
+ kFiles.length,
+ "Should find correct number of attachments before uploading."
+ );
+
+ for (let item of bucket.itemChildren) {
+ is(
+ item.querySelector("img.attachmentcell-icon").src,
+ "chrome://global/skin/icons/loading.png",
+ "CloudFile icon should be the loading spinner."
+ );
+ }
+
+ for (let promise of promises) {
+ if (error) {
+ promise.reject(error);
+ } else {
+ promise.resolve(promise.upload);
+ }
+ }
+ await new Promise(resolve => setTimeout(resolve));
+
+ Assert.equal(
+ bucket.itemCount,
+ expectedAttachments,
+ "Should find correct number of attachments after uploading."
+ );
+ // Check if the spinner is no longer shown, but the expected moz-icon.
+ for (let item of bucket.itemChildren) {
+ ok(
+ item
+ .querySelector("img.attachmentcell-icon")
+ .src.startsWith("moz-icon://testFile"),
+ "CloudFile icon should be correct."
+ );
+ }
+
+ // Check and reset the prompt mock service.
+ is(
+ expectedAlerts,
+ Services.prompt.alertCount,
+ "Number of expected alert prompts should be correct."
+ );
+ Services.prompt.alertCount = 0;
+}
+
+/**
+ * Check if attachment is removed if upload failed.
+ */
+add_task(async function test_error_upload() {
+ let cw = open_compose_new_mail();
+ await test_upload(
+ cw,
+ Components.Exception(
+ "Upload error.",
+ cloudFileAccounts.constants.uploadErr
+ ),
+ 0,
+ 3
+ );
+ close_compose_window(cw);
+});
+
+/**
+ * Check if attachment is not removed if upload is successful.
+ */
+add_task(async function test_successful_upload() {
+ let cw = open_compose_new_mail();
+ await test_upload(cw, null, 3, 0);
+ close_compose_window(cw);
+});
+
+/**
+ * Check if the original cloud attachment is kept, after converting it to another
+ * provider failed.
+ */
+add_task(async function test_error_conversion() {
+ let cw = open_compose_new_mail();
+ let bucket = cw.window.document.getElementById("attachmentBucket");
+
+ // Upload 3 files to the standard provider.
+ await test_upload(cw, null, 3, 0);
+
+ // Define another provider.
+ let providerB = new MockCloudfileAccount();
+ providerB.init("someOtherKey");
+
+ let uploadPromise = null;
+ providerB.uploadFile = function (window, aFile) {
+ return new Promise((resolve, reject) => {
+ uploadPromise = { resolve, reject };
+ });
+ };
+
+ select_attachments(cw, 0);
+ convert_selected_to_cloud_attachment(cw, providerB, false);
+
+ let uploadError = new Promise(resolve => {
+ bucket.addEventListener("attachment-move-failed", resolve, {
+ once: true,
+ });
+ });
+
+ // Reject the upload, causing the conversion to fail.
+ uploadPromise.reject(
+ new Components.Exception(
+ "Upload error.",
+ cloudFileAccounts.constants.uploadErr
+ )
+ );
+ await uploadError;
+
+ // Wait for the showLocalizedCloudFileAlert() to localize the error message.
+ await new Promise(resolve => setTimeout(resolve));
+
+ is(
+ Services.prompt.alertCount,
+ 1,
+ "Number of expected alert prompts should be correct."
+ );
+ Services.prompt.alertCount = 0;
+
+ // Check that we still have the 3 attachments we started with.
+ Assert.equal(
+ bucket.itemCount,
+ 3,
+ "Should find correct number of attachments."
+ );
+ for (let i = 0; i < bucket.itemCount; i++) {
+ let item = bucket.itemChildren[i];
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ true,
+ "Attachment should be a cloud attachment."
+ );
+ Assert.equal(
+ item.attachment.cloudFileAccountKey,
+ "someKey",
+ "Attachment should be hosted by the correct provider."
+ );
+ }
+
+ close_compose_window(cw);
+});
diff --git a/comm/mail/test/browser/cloudfile/browser_attachmentUrls.js b/comm/mail/test/browser/cloudfile/browser_attachmentUrls.js
new file mode 100644
index 0000000000..38604afc6d
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/browser_attachmentUrls.js
@@ -0,0 +1,1554 @@
+/* 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/. */
+
+/**
+ * Tests Filelink URL insertion behaviours in compose windows.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { gMockFilePicker, gMockFilePickReg, select_attachments } =
+ ChromeUtils.import("resource://testing-common/mozmill/AttachmentHelpers.jsm");
+var { gMockCloudfileManager, MockCloudfileAccount } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CloudfileHelpers.jsm"
+);
+var {
+ add_cloud_attachments,
+ convert_selected_to_cloud_attachment,
+ rename_selected_cloud_attachment,
+ assert_previous_text,
+ close_compose_window,
+ get_compose_body,
+ open_compose_new_mail,
+ open_compose_with_forward,
+ open_compose_with_reply,
+ type_in_composer,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { assert_next_nodes, assert_previous_nodes, wait_for_element } =
+ ChromeUtils.import("resource://testing-common/mozmill/DOMHelpers.jsm");
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_message,
+ FAKE_SERVER_HOSTNAME,
+ get_special_folder,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var kHtmlPrefKey = "mail.identity.default.compose_html";
+var kReplyOnTopKey = "mail.identity.default.reply_on_top";
+var kReplyOnTop = 1;
+var kReplyOnBottom = 0;
+var kTextNodeType = 3;
+var kSigPrefKey = "mail.identity.id1.htmlSigText";
+var kSigOnReplyKey = "mail.identity.default.sig_on_reply";
+var kSigOnForwardKey = "mail.identity.default.sig_on_fwd";
+var kDefaultSigKey = "mail.identity.id1.htmlSigText";
+var kDefaultSig = "This is my signature.\n\nCheck out my website sometime!";
+var kFiles = ["./data/testFile1", "./data/testFile2"];
+var kLines = ["This is a line of text", "and here's another!"];
+
+const DATA_URLS = {
+ "chrome://messenger/content/extension.svg":
+ "data:image/svg+xml;filename=extension.svg;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiCiAgICAgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiB2aWV3Qm94PSIwIDAgNjQgNjQiPgogIDxkZWZzPgogICAgPHN0eWxlPgogICAgICAuc3R5bGUtcHV6emxlLXBpZWNlIHsKICAgICAgICBmaWxsOiB1cmwoJyNncmFkaWVudC1saW5lYXItcHV6emxlLXBpZWNlJyk7CiAgICAgIH0KICAgIDwvc3R5bGU+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9ImdyYWRpZW50LWxpbmVhci1wdXp6bGUtcGllY2UiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMCUiIHkyPSIxMDAlIj4KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzY2Y2M1MiIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzYwYmY0YyIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KICA8cGF0aCBjbGFzcz0ic3R5bGUtcHV6emxlLXBpZWNlIiBkPSJNNDIsNjJjMi4yLDAsNC0xLjgsNC00bDAtMTQuMmMwLDAsMC40LTMuNywyLjgtMy43YzIuNCwwLDIuMiwzLjksNi43LDMuOWMyLjMsMCw2LjItMS4yLDYuMi04LjIgYzAtNy0zLjktNy45LTYuMi03LjljLTQuNSwwLTQuMywzLjctNi43LDMuN2MtMi40LDAtMi44LTMuOC0yLjgtMy44VjIyYzAtMi4yLTEuOC00LTQtNEgzMS41YzAsMC0zLjQtMC42LTMuNC0zIGMwLTIuNCwzLjgtMi42LDMuOC03LjFjMC0yLjMtMS4zLTUuOS04LjMtNS45cy04LDMuNi04LDUuOWMwLDQuNSwzLjQsNC43LDMuNCw3LjFjMCwyLjQtMy40LDMtMy40LDNINmMtMi4yLDAtNCwxLjgtNCw0bDAsNy44IGMwLDAtMC40LDYsNC40LDZjMy4xLDAsMy4yLTQuMSw3LjMtNC4xYzIsMCw0LDEuOSw0LDZjMCw0LjItMiw2LjMtNCw2LjNjLTQsMC00LjItNC4xLTcuMy00LjFjLTQuOCwwLTQuNCw1LjgtNC40LDUuOEwyLDU4IGMwLDIuMiwxLjgsNCw0LDRIMTljMCwwLDYuMywwLjQsNi4zLTQuNGMwLTMuMS00LTMuNi00LTcuN2MwLTIsMi4yLTQuNSw2LjQtNC41YzQuMiwwLDYuNiwyLjUsNi42LDQuNWMwLDQtMy45LDQuNi0zLjksNy43IGMwLDQuOSw2LjMsNC40LDYuMyw0LjRINDJ6Ii8+Cjwvc3ZnPgo=",
+ "chrome://messenger/skin/icons/globe.svg":
+ "data:image/svg+xml;filename=globe.svg;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGZpbGw9ImNvbnRleHQtZmlsbCIgZD0iTTggMGE4IDggMCAxIDAgOCA4IDguMDA5IDguMDA5IDAgMCAwLTgtOHptNS4xNjMgNC45NThoLTEuNTUyYTcuNyA3LjcgMCAwIDAtMS4wNTEtMi4zNzYgNi4wMyA2LjAzIDAgMCAxIDIuNjAzIDIuMzc2ek0xNCA4YTUuOTYzIDUuOTYzIDAgMCAxLS4zMzUgMS45NThoLTEuODIxQTEyLjMyNyAxMi4zMjcgMCAwIDAgMTIgOGExMi4zMjcgMTIuMzI3IDAgMCAwLS4xNTYtMS45NThoMS44MjFBNS45NjMgNS45NjMgMCAwIDEgMTQgOHptLTYgNmMtMS4wNzUgMC0yLjAzNy0xLjItMi41NjctMi45NThoNS4xMzVDMTAuMDM3IDEyLjggOS4wNzUgMTQgOCAxNHpNNS4xNzQgOS45NThhMTEuMDg0IDExLjA4NCAwIDAgMSAwLTMuOTE2aDUuNjUxQTExLjExNCAxMS4xMTQgMCAwIDEgMTEgOGExMS4xMTQgMTEuMTE0IDAgMCAxLS4xNzQgMS45NTh6TTIgOGE1Ljk2MyA1Ljk2MyAwIDAgMSAuMzM1LTEuOTU4aDEuODIxYTEyLjM2MSAxMi4zNjEgMCAwIDAgMCAzLjkxNkgyLjMzNUE1Ljk2MyA1Ljk2MyAwIDAgMSAyIDh6bTYtNmMxLjA3NSAwIDIuMDM3IDEuMiAyLjU2NyAyLjk1OEg1LjQzM0M1Ljk2MyAzLjIgNi45MjUgMiA4IDJ6bS0yLjU2LjU4MmE3LjcgNy43IDAgMCAwLTEuMDUxIDIuMzc2SDIuODM3QTYuMDMgNi4wMyAwIDAgMSA1LjQ0IDIuNTgyem0tMi42IDguNDZoMS41NDlhNy43IDcuNyAwIDAgMCAxLjA1MSAyLjM3NiA2LjAzIDYuMDMgMCAwIDEtMi42MDMtMi4zNzZ6bTcuNzIzIDIuMzc2YTcuNyA3LjcgMCAwIDAgMS4wNTEtMi4zNzZoMS41NTJhNi4wMyA2LjAzIDAgMCAxLTIuNjA2IDIuMzc2eiI+PC9wYXRoPgo8L3N2Zz4K",
+};
+
+var gInbox;
+
+function test_expected_included(actual, expected, description) {
+ Assert.equal(
+ actual.length,
+ expected.length,
+ `${description}: correct length`
+ );
+
+ for (let i = 0; i < expected.length; i++) {
+ for (let item of Object.keys(expected[i])) {
+ Assert.deepEqual(
+ actual[i][item],
+ expected[i][item],
+ `${description}: ${item} should exist and be correct`
+ );
+ }
+ }
+}
+
+add_setup(async function () {
+ requestLongerTimeout(4);
+
+ // These prefs can't be set in the manifest as they contain white-space.
+ Services.prefs.setStringPref(
+ "mail.identity.id1.htmlSigText",
+ "Tinderbox is soo 90ies"
+ );
+ Services.prefs.setStringPref(
+ "mail.identity.id2.htmlSigText",
+ "Tinderboxpushlog is the new <b>hotness!</b>"
+ );
+
+ // For replies and forwards, we'll work off a message in the Inbox folder
+ // of the fake "tinderbox" account.
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ gInbox = await get_special_folder(Ci.nsMsgFolderFlags.Inbox, false, server);
+ await add_message_to_folder([gInbox], create_message());
+
+ gMockFilePickReg.register();
+ gMockCloudfileManager.register();
+
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+ // Don't create paragraphs in the test.
+ // The test fails if it encounters paragraphs <p> instead of breaks <br>.
+ Services.prefs.setBoolPref("mail.compose.default_to_paragraph", false);
+});
+
+registerCleanupFunction(function () {
+ gMockCloudfileManager.unregister();
+ gMockFilePickReg.unregister();
+ Services.prefs.clearUserPref(kDefaultSigKey);
+ Services.prefs.clearUserPref(kHtmlPrefKey);
+ Services.prefs.clearUserPref("mail.compose.default_to_paragraph");
+});
+
+function setupTest() {
+ // If our signature got accidentally wiped out, let's just put it back.
+ Services.prefs.setCharPref(kDefaultSigKey, kDefaultSig);
+}
+
+/**
+ * Given some compose window controller, wait for some Filelink URLs to be
+ * inserted.
+ *
+ * Note: This function also validates, if the correct items have been added to
+ * the template (serviceUrl, downloadLimit, downloadExpiryDate,
+ * downloadPasswordProtected). There is no dedicated test for the different
+ * conditions, but the tests in this file are using different setups.
+ * See the values in the used provider.init() calls.
+ *
+ * @param aController the controller for a compose window.
+ * @param aNumUrls the number of Filelink URLs that are expected.
+ * @param aUploads an array containing the objects returned by
+ * cloudFileAccounts.uploadFile() for all uploads
+ * @returns an array containing the root containment node, the list node, and
+ * an array of the link URL nodes.
+ */
+function wait_for_attachment_urls(aController, aNumUrls, aUploads = []) {
+ let mailBody = get_compose_body(aController);
+
+ // Wait until we can find the root attachment URL node...
+ let root = wait_for_element(
+ mailBody.parentNode,
+ "body > #cloudAttachmentListRoot"
+ );
+
+ let list = wait_for_element(
+ mailBody,
+ "#cloudAttachmentListRoot > #cloudAttachmentList"
+ );
+
+ let header = wait_for_element(
+ mailBody,
+ "#cloudAttachmentListRoot > #cloudAttachmentListHeader"
+ );
+
+ let footer = wait_for_element(
+ mailBody,
+ "#cloudAttachmentListRoot > #cloudAttachmentListFooter"
+ );
+
+ let urls = null;
+ utils.waitFor(function () {
+ urls = mailBody.querySelectorAll(
+ "#cloudAttachmentList > .cloudAttachmentItem"
+ );
+ return urls != null && urls.length == aNumUrls;
+ });
+
+ Assert.equal(
+ aUploads.length,
+ aNumUrls,
+ "Number of links should match number of linked files."
+ );
+
+ Assert.equal(
+ header.textContent,
+ aNumUrls == 1
+ ? `I’ve linked 1 file to this email:`
+ : `I’ve linked ${aNumUrls} files to this email:`,
+ "Number of links mentioned in header should matches number of linked files."
+ );
+
+ let footerExpected = false;
+ for (let entry of aUploads) {
+ if (!entry.serviceUrl) {
+ continue;
+ }
+
+ footerExpected = true;
+ Assert.ok(
+ footer.innerHTML.includes(entry.serviceUrl),
+ `Footer "${footer.innerHTML}" should include serviceUrl "${entry.serviceUrl}".`
+ );
+ Assert.ok(
+ footer.innerHTML.includes(entry.serviceName),
+ `Footer "${footer.innerHTML}" should include serviceName "${entry.serviceName}".`
+ );
+ }
+ if (footerExpected) {
+ Assert.ok(
+ footer.innerHTML.startsWith("Learn more about"),
+ `Footer "${footer.innerHTML}" should start with "Learn more about "`
+ );
+ } else {
+ Assert.ok(
+ footer.innerHTML == "",
+ `Footer should be empty if no serviceUrl is specified.`
+ );
+ }
+
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+
+ // Check the actual content of the generated cloudAttachmentItems.
+ for (let i = 0; i < urls.length; i++) {
+ if (aController.window.gMsgCompose.composeHTML) {
+ // Test HTML message.
+
+ let paperClipIcon = urls[i].querySelector(".paperClipIcon");
+ Assert.equal(
+ aUploads[i].downloadPasswordProtected
+ ? "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIfSURBVFhH7ZfLK0RRHMfvNd6PMV4Lj5UkO5bslJIdf4ClRw2TlY2yt2EhsZO9DYoFoiSvJBZkI6SsNMyIiLnH93vmXDF5HNe9pHzqM797fufMPb+Zc4Z7jC+QBnvgJryD93AddkH2eUop3IPiHXdgCfSEdLgLOdE+bIFFSl4zZxeRAl2HXzsn2IIZTCTAHPs4hsvhOlxz3rxRtt6GfRyzJlsucw1582zZehv2cUxEtlyGN6afkThuFa7EL7+H0wK03pek4q/xJwtYVv4YumurO+4V/3vgvwAvC5iHTfHL9zFV/Ah7J9tjE9s2r/K3YwWlD8IaREP+ExPCWBDJVl+gM3LEto0nBURHCiuNpBiflvLjqWcufDFfdVbo4ly1PVoC0xrAaz4qnLdiVjk1hVhArvDRFxuSYxQeFSAaGHzCbAuEIsf0URjtsithX3i1Cf18yewKn8kWyOu+OlWXuSpKnBRwpWKxioTXi7BCtr6Ak004BZvhJAwyAUZhb3Q0bwKxXmY+xVzyB8MNOgXwE/NrC0A+clXBDZV7iYkC7GK18AcvTZ0lOFGRE5NDWAtn4A28hdPQEToFcG1Jq4qERXAZ+DCaBXk+cIROAePQgh2whgk30SngAA7CVDgLq6Fr6P4M++Ec5PmPp6BhWAdzIA+m3BOO0C2AJ2GuMyfme0KQp6Ao5EmZf/fLDGFuI2oi+EEcUQm5JDywhpWc2MFGNIwn/WmcKhqF50UAAAAASUVORK5CYII="
+ : "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAVFJREFUWIXtl8FKw0AQhj8EbQ/p0Ut8AVEPgYLUB+i5L6J9E0Wtr1HPgl48WU8K1Tfw4LktxUAhHvZfiMXUbdhVhB0Yms78M/NldwkJuFsD6AMjYCYfASfKBbUd4BkoKvxJmiDWKA1/AXrAtrynmIUIshJ9DXgEmt/km8oVwHEIANu8u0LTleYhBMBUzZMVmkSaSQgAe9DW1d3L/wzAqW6jJpQ3+5cA3vbW1Vz3Np6BCBABIkAE+DWAmX7TUixdynm15Wf6jf5fa3Cq60K5qrraNuHrK1kbmJcGWJ8rB9DC4yvaq5odlmK7wBB4lw8Vs9ZRzdgHwLmaXa5RM1DNmQ+AA2ABfACZgz4DctXs+QAAuMLc0dsPEJk0BXDhazjAFnCnxjlmiTuYg5kAR4rl0twCmz4BLMQAs7RVH6kLzJ17H162fczhGmO+mqa6PqXGnn8CxMN0PcC9DrQAAAAASUVORK5CYII=",
+ paperClipIcon.src,
+ "The paperClipIcon should be correct."
+ );
+
+ Assert.equal(
+ urls[i].querySelector(".cloudfile-name").href,
+ aUploads[i].url,
+ "The link attached to the cloudfile name should be correct."
+ );
+
+ let providerIcon = urls[i].querySelector(".cloudfile-service-icon");
+ if (providerIcon) {
+ Assert.equal(
+ DATA_URLS[aUploads[i].serviceIcon] || aUploads[i].serviceIcon,
+ providerIcon.src,
+ "The cloufile service icon should be correct."
+ );
+ }
+
+ let expected = {
+ url: aUploads[i].downloadPasswordProtected
+ ? ".cloudfile-password-protected-link"
+ : ".cloudfile-link",
+ name: ".cloudfile-name",
+ serviceName: ".cloudfile-service-name",
+ downloadLimit: ".cloudfile-download-limit",
+ downloadExpiryDateString: ".cloudfile-expiry-date",
+ };
+
+ for (let [fieldName, id] of Object.entries(expected)) {
+ let element = urls[i].querySelector(id);
+ Assert.ok(
+ !!element == !!aUploads[i][fieldName],
+ `The ${fieldName} should have been correctly added.`
+ );
+ if (aUploads[i][fieldName]) {
+ Assert.equal(
+ element.textContent,
+ `${aUploads[i][fieldName]}`,
+ `The cloudfile ${fieldName} should be correct.`
+ );
+ } else {
+ Assert.equal(
+ element,
+ null,
+ `The cloudfile ${fieldName} should not be present.`
+ );
+ }
+ }
+ } else {
+ // Test plain text message.
+
+ let lines = urls[i].textContent.split("\n");
+ let expected = {
+ url: aUploads[i].downloadPasswordProtected
+ ? ` Password Protected Link: `
+ : ` Link: `,
+ name: ` * `,
+ downloadLimit: ` Download Limit: `,
+ downloadExpiryDateString: ` Expiry Date: `,
+ };
+
+ if (urls[i].serviceUrl) {
+ expected.serviceName = ` CloudFile Service: `;
+ }
+
+ for (let [fieldName, prefix] of Object.entries(expected)) {
+ if (aUploads[i][fieldName]) {
+ let line = `${prefix}${aUploads[i][fieldName]}`;
+ Assert.ok(
+ lines.includes(line),
+ `Line "${line}" should be part of "${lines}".`
+ );
+ } else {
+ !lines.find(
+ line => line.startsWith(prefix),
+ `There should be no line starting with "${prefix}" part of "${lines}".`
+ );
+ }
+ }
+ }
+
+ // Find the bucket entry for this upload.
+ let items = Array.from(
+ bucket.querySelectorAll(".attachmentItem"),
+ item => item
+ ).filter(item => item.attachment.name == aUploads[i].name);
+ Assert.equal(
+ items.length,
+ 1,
+ `Should find one matching bucket entry for ${aUploads[i].serviceName} / ${aUploads[i].name}.`
+ );
+ Assert.equal(
+ items[0].querySelector("img.attachmentcell-icon").src,
+ aUploads[i].serviceIcon,
+ `CloudFile icon should be correct for ${aUploads[i].serviceName} / ${aUploads[i].name}`
+ );
+ }
+
+ return [root, list, urls];
+}
+
+/**
+ * Helper function that sets up the mock file picker for a series of files,
+ * spawns a reply window for the first message in the gInbox, optionally
+ * types some strings into the compose window, and then attaches some
+ * Filelinks.
+ *
+ * @param aText an array of strings to type into the compose window. Each
+ * string is followed by pressing the RETURN key, except for
+ * the final string. Pass an empty array if you don't want
+ * anything typed.
+ * @param aFiles an array of filename strings for files located beneath
+ * the test directory.
+ */
+async function prepare_some_attachments_and_reply(aText, aFiles) {
+ gMockFilePicker.returnFiles = collectFiles(aFiles);
+
+ let provider = new MockCloudfileAccount();
+ provider.init("providerF", {
+ serviceName: "MochiTest F",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-F.org",
+ downloadLimit: 2,
+ });
+
+ await be_in_folder(gInbox);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ let cw = open_compose_with_reply();
+
+ // If we have any typing to do, let's do it.
+ type_in_composer(cw, aText);
+ let uploads = add_cloud_attachments(cw, provider);
+
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerF/testFile1",
+ name: "testFile1",
+ serviceName: "MochiTest F",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-F.org",
+ downloadLimit: 2,
+ },
+ {
+ url: "https://www.example.com/providerF/testFile2",
+ name: "testFile2",
+ serviceName: "MochiTest F",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-F.org",
+ downloadLimit: 2,
+ },
+ ],
+ `Expected values in uploads array #11`
+ );
+ let [root] = wait_for_attachment_urls(cw, aFiles.length, uploads);
+
+ return [cw, root];
+}
+
+/**
+ * Helper function that sets up the mock file picker for a series of files,
+ * spawns an inline forward compose window for the first message in the gInbox,
+ * optionally types some strings into the compose window, and then attaches
+ * some Filelinks.
+ *
+ * @param aText an array of strings to type into the compose window. Each
+ * string is followed by pressing the RETURN key, except for
+ * the final string. Pass an empty array if you don't want
+ * anything typed.
+ * @param aFiles an array of filename strings for files located beneath
+ * the test directory.
+ */
+async function prepare_some_attachments_and_forward(aText, aFiles) {
+ gMockFilePicker.returnFiles = collectFiles(aFiles);
+
+ let provider = new MockCloudfileAccount();
+ provider.init("providerG", {
+ serviceName: "MochiTest G",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-G.org",
+ downloadExpiryDate: { timestamp: 1639827408073 },
+ });
+
+ await be_in_folder(gInbox);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ let cw = open_compose_with_forward();
+
+ // Put the selection at the beginning of the document...
+ let editor = cw.window.GetCurrentEditor();
+ editor.beginningOfDocument();
+
+ // Do any necessary typing...
+ type_in_composer(cw, aText);
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerG/testFile1",
+ name: "testFile1",
+ serviceName: "MochiTest G",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-G.org",
+ downloadExpiryDate: { timestamp: 1639827408073 },
+ },
+ {
+ url: "https://www.example.com/providerG/testFile2",
+ name: "testFile2",
+ serviceName: "MochiTest G",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-G.org",
+ downloadExpiryDate: { timestamp: 1639827408073 },
+ },
+ ],
+ `Expected values in uploads array #12`
+ );
+
+ // Add the expected time string.
+ let timeString = new Date(1639827408073).toLocaleString(undefined, {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZoneName: "short",
+ });
+ uploads[0].downloadExpiryDateString = timeString;
+ uploads[1].downloadExpiryDateString = timeString;
+ let [root] = wait_for_attachment_urls(cw, aFiles.length, uploads);
+
+ return [cw, root];
+}
+
+/**
+ * Helper function that runs a test function with signature-in-reply and
+ * signature-in-forward enabled, and then runs the test again with those
+ * prefs disabled.
+ *
+ * @param aSpecialTest a test that takes two arguments - the first argument
+ * is the aText array of any text that should be typed,
+ * and the second is a boolean for whether or not the
+ * special test should expect a signature or not.
+ * @param aText any text to be typed into the compose window, passed to
+ * aSpecialTest.
+ */
+async function try_with_and_without_signature_in_reply_or_fwd(
+ aSpecialTest,
+ aText
+) {
+ // By default, we have a signature included in replies, so we'll start
+ // with that.
+ Services.prefs.setBoolPref(kSigOnReplyKey, true);
+ Services.prefs.setBoolPref(kSigOnForwardKey, true);
+ await aSpecialTest(aText, true);
+
+ Services.prefs.setBoolPref(kSigOnReplyKey, false);
+ Services.prefs.setBoolPref(kSigOnForwardKey, false);
+ await aSpecialTest(aText, false);
+}
+
+/**
+ * Helper function that runs a test function without a signature, once
+ * in HTML mode, and again in plaintext mode.
+ *
+ * @param aTest a test that takes no arguments.
+ */
+async function try_without_signature(aTest) {
+ let oldSig = Services.prefs.getCharPref(kSigPrefKey);
+ Services.prefs.setCharPref(kSigPrefKey, "");
+
+ await try_with_plaintext_and_html_mail(aTest);
+ Services.prefs.setCharPref(kSigPrefKey, oldSig);
+}
+
+/**
+ * Helper function that runs a test function for HTML mail composition, and
+ * then again in plaintext mail composition.
+ *
+ * @param aTest a test that takes no arguments.
+ */
+async function try_with_plaintext_and_html_mail(aTest) {
+ await aTest();
+ Services.prefs.setBoolPref(kHtmlPrefKey, false);
+ await aTest();
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+}
+
+/**
+ * Test that if we open up a composer and immediately attach a Filelink,
+ * a linebreak is inserted before the containment node in order to allow
+ * the user to write before the attachment URLs. This assumes the user
+ * does not have a signature already inserted into the message body.
+ */
+add_task(async function test_inserts_linebreak_on_empty_compose() {
+ await try_without_signature(subtest_inserts_linebreak_on_empty_compose);
+});
+
+/**
+ * Subtest for test_inserts_linebreak_on_empty_compose - can be executed
+ * on both plaintext and HTML compose windows.
+ */
+function subtest_inserts_linebreak_on_empty_compose() {
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ provider.init("someKey", {
+ downloadPasswordProtected: false,
+ });
+ let cw = open_compose_new_mail();
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/someKey/testFile1",
+ name: "testFile1",
+ serviceName: "default",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceUrl: "",
+ downloadPasswordProtected: false,
+ },
+ {
+ url: "https://www.example.com/someKey/testFile2",
+ name: "testFile2",
+ serviceName: "default",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceUrl: "",
+ downloadPasswordProtected: false,
+ },
+ ],
+ `Expected values in uploads array #1`
+ );
+ let [root] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ let br = root.previousSibling;
+ Assert.equal(
+ br.localName,
+ "br",
+ "The attachment URL containment node should be preceded by a linebreak"
+ );
+
+ let mailBody = get_compose_body(cw);
+
+ Assert.equal(
+ mailBody.firstChild,
+ br,
+ "The linebreak should be the first child of the compose body"
+ );
+
+ close_compose_window(cw);
+}
+
+/**
+ * Test that if we open up a composer and immediately attach a Filelink,
+ * a linebreak is inserted before the containment node. This test also
+ * ensures that, with a signature already in the compose window, we don't
+ * accidentally insert the attachment URL containment within the signature
+ * node.
+ */
+add_task(function test_inserts_linebreak_on_empty_compose_with_signature() {
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ provider.init("someKey", {
+ downloadPasswordProtected: true,
+ });
+
+ let cw = open_compose_new_mail();
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/someKey/testFile1",
+ name: "testFile1",
+ serviceName: "default",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceUrl: "",
+ downloadPasswordProtected: true,
+ },
+ {
+ url: "https://www.example.com/someKey/testFile2",
+ name: "testFile2",
+ serviceName: "default",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceUrl: "",
+ downloadPasswordProtected: true,
+ },
+ ],
+ `Expected values in uploads array #2`
+ );
+ // wait_for_attachment_urls ensures that the attachment URL containment
+ // node is an immediate child of the body of the message, so if this
+ // succeeds, then we were not in the signature node.
+ let [root] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ let br = assert_previous_nodes("br", root, 1);
+
+ let mailBody = get_compose_body(cw);
+ Assert.equal(
+ mailBody.firstChild,
+ br,
+ "The linebreak should be the first child of the compose body"
+ );
+
+ // Now ensure that the node after the attachments is a br, and following
+ // that is the signature.
+ br = assert_next_nodes("br", root, 1);
+
+ let pre = br.nextSibling;
+ Assert.equal(
+ pre.localName,
+ "pre",
+ "The linebreak should be followed by the signature pre"
+ );
+ Assert.ok(
+ pre.classList.contains("moz-signature"),
+ "The pre should have the moz-signature class"
+ );
+
+ close_compose_window(cw);
+
+ Services.prefs.setBoolPref(kHtmlPrefKey, false);
+
+ // Now let's try with plaintext mail.
+ cw = open_compose_new_mail();
+ uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/someKey/testFile1",
+ name: "testFile1",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceName: "default",
+ serviceUrl: "",
+ downloadPasswordProtected: true,
+ },
+ {
+ url: "https://www.example.com/someKey/testFile2",
+ name: "testFile2",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceName: "default",
+ serviceUrl: "",
+ downloadPasswordProtected: true,
+ },
+ ],
+ `Expected values in uploads array #3`
+ );
+ [root] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ br = assert_previous_nodes("br", root, 1);
+
+ mailBody = get_compose_body(cw);
+ Assert.equal(
+ mailBody.firstChild,
+ br,
+ "The linebreak should be the first child of the compose body"
+ );
+
+ // Now ensure that the node after the attachments is a br, and following
+ // that is the signature.
+ br = assert_next_nodes("br", root, 1);
+
+ let div = br.nextSibling;
+ Assert.equal(
+ div.localName,
+ "div",
+ "The linebreak should be followed by the signature div"
+ );
+ Assert.ok(
+ div.classList.contains("moz-signature"),
+ "The div should have the moz-signature class"
+ );
+
+ close_compose_window(cw);
+
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+});
+
+/**
+ * Tests that removing all Filelinks causes the root node to be removed.
+ */
+add_task(async function test_removing_filelinks_removes_root_node() {
+ await try_with_plaintext_and_html_mail(
+ subtest_removing_filelinks_removes_root_node
+ );
+});
+
+/**
+ * Test for test_removing_filelinks_removes_root_node - can be executed
+ * on both plaintext and HTML compose windows.
+ */
+async function subtest_removing_filelinks_removes_root_node() {
+ let [cw, root] = await prepare_some_attachments_and_reply([], kFiles);
+
+ // Now select the attachments in the attachment bucket, and remove them.
+ select_attachments(cw, 0, 1);
+ cw.window.goDoCommand("cmd_delete");
+
+ // Wait for the root to be removed.
+ let mailBody = get_compose_body(cw);
+ utils.waitFor(function () {
+ let result = mailBody.querySelector(root.id);
+ return result == null;
+ }, "Timed out waiting for attachment container to be removed");
+
+ close_compose_window(cw);
+}
+
+/**
+ * Test that if we write some text in an empty message (no signature),
+ * and the selection is at the end of a line of text, attaching some Filelinks
+ * causes the attachment URL container to be separated from the text by
+ * two br tags.
+ */
+add_task(async function test_adding_filelinks_to_written_message() {
+ await try_without_signature(subtest_adding_filelinks_to_written_message);
+});
+
+/**
+ * Subtest for test_adding_filelinks_to_written_message - generalized for both
+ * HTML and plaintext mail.
+ */
+function subtest_adding_filelinks_to_written_message() {
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ provider.init("someKey");
+ let cw = open_compose_new_mail();
+
+ type_in_composer(cw, kLines);
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/someKey/testFile1",
+ name: "testFile1",
+ serviceName: "default",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceUrl: "",
+ },
+ {
+ url: "https://www.example.com/someKey/testFile2",
+ name: "testFile2",
+ serviceName: "default",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceUrl: "",
+ },
+ ],
+ `Expected values in uploads array #4`
+ );
+ let [root] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ let br = root.previousSibling;
+ Assert.equal(
+ br.localName,
+ "br",
+ "The attachment URL containment node should be preceded by a linebreak"
+ );
+ br = br.previousSibling;
+ Assert.equal(
+ br.localName,
+ "br",
+ "The attachment URL containment node should be preceded by " +
+ "two linebreaks"
+ );
+ close_compose_window(cw);
+}
+
+/**
+ * Tests for inserting Filelinks into a reply, when we're configured to
+ * reply above the quote.
+ */
+add_task(async function test_adding_filelinks_to_empty_reply_above() {
+ let oldReplyOnTop = Services.prefs.getIntPref(kReplyOnTopKey);
+ Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnTop);
+
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_reply_above,
+ []
+ );
+ // Now with HTML mail...
+ Services.prefs.setBoolPref(kHtmlPrefKey, false);
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_reply_above_plaintext,
+ []
+ );
+
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+ Services.prefs.setIntPref(kReplyOnTopKey, oldReplyOnTop);
+});
+
+/**
+ * Tests for inserting Filelinks into a reply, when we're configured to
+ * reply above the quote, after entering some text.
+ */
+add_task(async function test_adding_filelinks_to_nonempty_reply_above() {
+ let oldReplyOnTop = Services.prefs.getIntPref(kReplyOnTopKey);
+ Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnTop);
+
+ await subtest_adding_filelinks_to_reply_above(kLines);
+
+ Services.prefs.setBoolPref(kHtmlPrefKey, false);
+ await subtest_adding_filelinks_to_reply_above_plaintext(kLines);
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+ Services.prefs.setIntPref(kReplyOnTopKey, oldReplyOnTop);
+});
+
+/**
+ * Subtest for test_adding_filelinks_to_reply_above for the plaintext composer.
+ * Does some special casing for the weird br insertions that happens in
+ * various cases.
+ */
+async function subtest_adding_filelinks_to_reply_above_plaintext(
+ aText,
+ aWithSig
+) {
+ let [cw, root] = await prepare_some_attachments_and_reply(aText, kFiles);
+
+ let br;
+ if (aText.length) {
+ br = assert_next_nodes("br", root, 2);
+ } else {
+ br = assert_next_nodes("br", root, 1);
+ }
+
+ let div = br.nextSibling;
+ Assert.equal(
+ div.localName,
+ "div",
+ "The linebreak should be followed by a div"
+ );
+
+ Assert.ok(div.classList.contains("moz-cite-prefix"));
+
+ if (aText.length) {
+ br = assert_previous_nodes("br", root, 2);
+ } else {
+ br = assert_previous_nodes("br", root, 1);
+ }
+
+ if (aText.length == 0) {
+ // If we didn't type anything, that br should be the first element of the
+ // message body.
+ let msgBody = get_compose_body(cw);
+ Assert.equal(
+ msgBody.firstChild,
+ br,
+ "The linebreak should have been the first element in the " +
+ "message body"
+ );
+ } else {
+ let targetText = aText[aText.length - 1];
+ let textNode = br.previousSibling;
+ Assert.equal(textNode.nodeType, kTextNodeType);
+ Assert.equal(textNode.nodeValue, targetText);
+ }
+
+ close_compose_window(cw);
+}
+
+/**
+ * Subtest for test_adding_filelinks_to_reply_above for the HTML composer.
+ */
+async function subtest_adding_filelinks_to_reply_above(aText) {
+ let [cw, root] = await prepare_some_attachments_and_reply(aText, kFiles);
+
+ // If there's any text written, then there's only a single break between the
+ // end of the text and the reply. Otherwise, there are two breaks.
+ let br =
+ aText.length > 1
+ ? assert_next_nodes("br", root, 2)
+ : assert_next_nodes("br", root, 1);
+
+ // ... which is followed by a div with a class of "moz-cite-prefix".
+ let div = br.nextSibling;
+ Assert.equal(
+ div.localName,
+ "div",
+ "The linebreak should be followed by a div"
+ );
+
+ Assert.ok(div.classList.contains("moz-cite-prefix"));
+
+ close_compose_window(cw);
+}
+
+/**
+ * Tests for inserting Filelinks into a reply, when we're configured to
+ * reply below the quote.
+ */
+add_task(async function test_adding_filelinks_to_empty_reply_below() {
+ let oldReplyOnTop = Services.prefs.getIntPref(kReplyOnTopKey);
+ Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnBottom);
+
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_reply_below,
+ []
+ );
+ Services.prefs.setBoolPref(kHtmlPrefKey, false);
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_plaintext_reply_below,
+ []
+ );
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+ Services.prefs.setIntPref(kReplyOnTopKey, oldReplyOnTop);
+});
+
+/**
+ * Tests for inserting Filelinks into a reply, when we're configured to
+ * reply below the quote, after entering some text.
+ */
+add_task(async function test_adding_filelinks_to_nonempty_reply_below() {
+ let oldReplyOnTop = Services.prefs.getIntPref(kReplyOnTopKey);
+ Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnBottom);
+
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_reply_below,
+ kLines
+ );
+
+ Services.prefs.setBoolPref(kHtmlPrefKey, false);
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_plaintext_reply_below,
+ kLines
+ );
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+ Services.prefs.setIntPref(kReplyOnTopKey, oldReplyOnTop);
+});
+
+/**
+ * Subtest for test_adding_filelinks_to_reply_below for the HTML composer.
+ */
+async function subtest_adding_filelinks_to_reply_below(aText, aWithSig) {
+ let [cw, root] = await prepare_some_attachments_and_reply(aText, kFiles);
+
+ // So, we should have the root, followed by a br
+ let br = root.nextSibling;
+ Assert.equal(
+ br.localName,
+ "br",
+ "The attachment URL containment node should be followed by a br"
+ );
+
+ let blockquote;
+ if (aText.length) {
+ // If there was any text inserted, check for 2 previous br nodes, and then
+ // the inserted text, and then the blockquote.
+ br = assert_previous_nodes("br", root, 2);
+ let textNode = assert_previous_text(br.previousSibling, aText);
+ blockquote = textNode.previousSibling;
+ } else {
+ // If no text was inserted, check for 1 previous br node, and then the
+ // blockquote.
+ br = assert_previous_nodes("br", root, 1);
+ blockquote = br.previousSibling;
+ }
+
+ Assert.equal(
+ blockquote.localName,
+ "blockquote",
+ "The linebreak should be preceded by a blockquote."
+ );
+
+ let prefix = blockquote.previousSibling;
+ Assert.equal(
+ prefix.localName,
+ "div",
+ "The blockquote should be preceded by the prefix div"
+ );
+ Assert.ok(
+ prefix.classList.contains("moz-cite-prefix"),
+ "The prefix should have the moz-cite-prefix class"
+ );
+
+ close_compose_window(cw);
+}
+
+/**
+ * Subtest for test_adding_filelinks_to_reply_below for the plaintext composer.
+ */
+async function subtest_adding_filelinks_to_plaintext_reply_below(
+ aText,
+ aWithSig
+) {
+ let [cw, root] = await prepare_some_attachments_and_reply(aText, kFiles);
+ let br, span;
+
+ assert_next_nodes("br", root, 1);
+
+ if (aText.length) {
+ br = assert_previous_nodes("br", root, 2);
+ // If text was entered, make sure it matches what we expect...
+ let textNode = assert_previous_text(br.previousSibling, aText);
+ // And then grab the span, which should be before the final text node.
+ span = textNode.previousSibling;
+ } else {
+ br = assert_previous_nodes("br", root, 1);
+ // If no text was entered, just grab the last br's previous sibling - that
+ // will be the span.
+ span = br.previousSibling;
+ // Sometimes we need to skip one more linebreak.
+ if (span.localName != "span") {
+ span = span.previousSibling;
+ }
+ }
+
+ Assert.equal(
+ span.localName,
+ "span",
+ "The linebreak should be preceded by a span."
+ );
+
+ let prefix = span.previousSibling;
+ Assert.equal(
+ prefix.localName,
+ "div",
+ "The blockquote should be preceded by the prefix div"
+ );
+ Assert.ok(
+ prefix.classList.contains("moz-cite-prefix"),
+ "The prefix should have the moz-cite-prefix class"
+ );
+
+ close_compose_window(cw);
+}
+
+/**
+ * Tests Filelink insertion on an inline-forward compose window with nothing
+ * typed into it.
+ */
+add_task(async function test_adding_filelinks_to_empty_forward() {
+ Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnTop);
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_forward,
+ []
+ );
+ Services.prefs.setBoolPref(kHtmlPrefKey, false);
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_forward,
+ []
+ );
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+});
+
+/**
+ * Tests Filelink insertion on an inline-forward compose window with some
+ * text typed into it.
+ */
+add_task(async function test_adding_filelinks_to_forward() {
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_forward,
+ kLines
+ );
+ Services.prefs.setBoolPref(kHtmlPrefKey, false);
+ await try_with_and_without_signature_in_reply_or_fwd(
+ subtest_adding_filelinks_to_forward,
+ kLines
+ );
+ Services.prefs.setBoolPref(kHtmlPrefKey, true);
+});
+
+/**
+ * Subtest for both test_adding_filelinks_to_empty_forward and
+ * test_adding_filelinks_to_forward - ensures that the inserted Filelinks
+ * are positioned correctly.
+ */
+async function subtest_adding_filelinks_to_forward(aText, aWithSig) {
+ let [cw, root] = await prepare_some_attachments_and_forward(aText, kFiles);
+
+ let br = assert_next_nodes("br", root, 1);
+ let forwardDiv = br.nextSibling;
+ Assert.equal(forwardDiv.localName, "div");
+ Assert.ok(forwardDiv.classList.contains("moz-forward-container"));
+
+ if (aText.length) {
+ // If there was text typed in, it should be separated from the root by two
+ // br's
+ let br = assert_previous_nodes("br", root, 2);
+ assert_previous_text(br.previousSibling, aText);
+ } else {
+ // Otherwise, there's only 1 br, and that br should be the first element
+ // of the message body.
+ let br = assert_previous_nodes("br", root, 1);
+ let mailBody = get_compose_body(cw);
+ Assert.equal(br, mailBody.firstChild);
+ }
+
+ close_compose_window(cw);
+}
+
+/**
+ * Test that if we convert a Filelink from one provider to another, that the
+ * old Filelink is removed, and a new Filelink is added for the new provider.
+ * We test this on both HTML and plaintext mail.
+ */
+add_task(async function test_converting_filelink_updates_urls() {
+ await try_with_plaintext_and_html_mail(
+ subtest_converting_filelink_updates_urls
+ );
+});
+
+/**
+ * Subtest for test_converting_filelink_updates_urls that creates two
+ * storage provider accounts, uploads files to one, converts them to the
+ * other, and ensures that the attachment links in the message body get
+ * get updated.
+ */
+function subtest_converting_filelink_updates_urls() {
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let providerA = new MockCloudfileAccount();
+ let providerB = new MockCloudfileAccount();
+ providerA.init("providerA", {
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ });
+ providerB.init("providerB", {
+ serviceName: "MochiTest B",
+ serviceUrl: "https://www.provider-B.org",
+ });
+
+ let cw = open_compose_new_mail();
+ let uploads = add_cloud_attachments(cw, providerA);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerA/testFile1",
+ name: "testFile1",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ },
+ {
+ url: "https://www.example.com/providerA/testFile2",
+ name: "testFile2",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ },
+ ],
+ `Expected values in uploads array #5`
+ );
+ let [, , UrlsA] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ // Convert each Filelink to providerB, ensuring that the URLs are replaced.
+ uploads = [];
+ for (let i = 0; i < kFiles.length; ++i) {
+ select_attachments(cw, i);
+ uploads.push(...convert_selected_to_cloud_attachment(cw, providerB));
+ }
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerB/testFile1",
+ name: "testFile1",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceName: "MochiTest B",
+ serviceUrl: "https://www.provider-B.org",
+ },
+ {
+ url: "https://www.example.com/providerB/testFile2",
+ name: "testFile2",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceName: "MochiTest B",
+ serviceUrl: "https://www.provider-B.org",
+ },
+ ],
+ `Expected values in uploads array #6`
+ );
+ let [, , UrlsB] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+ Assert.notEqual(UrlsA, UrlsB, "The original URL should have been replaced");
+
+ close_compose_window(cw);
+}
+
+/**
+ * Test that if we rename a Filelink, that the old Filelink is removed, and a
+ * new Filelink is added. We test this on both HTML and plaintext mail.
+ */
+add_task(async function test_renaming_filelink_updates_urls() {
+ await try_with_plaintext_and_html_mail(
+ subtest_renaming_filelink_updates_urls
+ );
+});
+
+/**
+ * Subtest for test_renaming_filelink_updates_urls that uploads a file to a
+ * storage provider account, renames the upload, and ensures that the attachment
+ * links in the message body get get updated.
+ */
+function subtest_renaming_filelink_updates_urls() {
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ provider.init("providerA", {
+ serviceName: "MochiTest A",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-A.org",
+ downloadExpiryDate: {
+ timestamp: 1639827408073,
+ format: { dateStyle: "short" },
+ },
+ });
+
+ let cw = open_compose_new_mail();
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerA/testFile1",
+ name: "testFile1",
+ serviceName: "MochiTest A",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-A.org",
+ downloadExpiryDate: {
+ timestamp: 1639827408073,
+ format: { dateStyle: "short" },
+ },
+ },
+ {
+ url: "https://www.example.com/providerA/testFile2",
+ name: "testFile2",
+ serviceName: "MochiTest A",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-A.org",
+ downloadExpiryDate: {
+ timestamp: 1639827408073,
+ format: { dateStyle: "short" },
+ },
+ },
+ ],
+ `Expected values in uploads array before renaming the files`
+ );
+
+ // Add the expected time string.
+ let timeString = new Date(1639827408073).toLocaleString(undefined, {
+ dateStyle: "short",
+ });
+ uploads[0].downloadExpiryDateString = timeString;
+ uploads[1].downloadExpiryDateString = timeString;
+ let [, , Urls1] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ // Rename each Filelink, ensuring that the URLs are replaced.
+ let newNames = ["testFile1Renamed", "testFile2Renamed"];
+ uploads = [];
+ for (let i = 0; i < kFiles.length; ++i) {
+ select_attachments(cw, i);
+ uploads.push(rename_selected_cloud_attachment(cw, newNames[i]));
+ }
+
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerA/testFile1Renamed",
+ name: "testFile1Renamed",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ downloadExpiryDate: {
+ timestamp: 1639827408073,
+ format: { dateStyle: "short" },
+ },
+ },
+ {
+ url: "https://www.example.com/providerA/testFile2Renamed",
+ name: "testFile2Renamed",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceName: "MochiTest A",
+ serviceUrl: "https://www.provider-A.org",
+ downloadExpiryDate: {
+ timestamp: 1639827408073,
+ format: { dateStyle: "short" },
+ },
+ },
+ ],
+ `Expected values in uploads array after renaming the files`
+ );
+
+ // Add the expected time string.
+ uploads[0].downloadExpiryDateString = timeString;
+ uploads[1].downloadExpiryDateString = timeString;
+ let [, , Urls2] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+ Assert.notEqual(Urls1, Urls2, "The original URL should have been replaced");
+
+ close_compose_window(cw);
+}
+
+/**
+ * Test that if we convert a Filelink to a normal attachment that the
+ * Filelink is removed from the message body.
+ */
+add_task(async function test_converting_filelink_to_normal_removes_url() {
+ await try_with_plaintext_and_html_mail(
+ subtest_converting_filelink_to_normal_removes_url
+ );
+});
+
+/**
+ * Subtest for test_converting_filelink_to_normal_removes_url that adds
+ * some Filelinks to an email, and then converts those Filelinks back into
+ * normal attachments, checking to ensure that the links are removed from
+ * the body of the email.
+ */
+async function subtest_converting_filelink_to_normal_removes_url() {
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ provider.init("providerC", {
+ serviceName: "MochiTest C",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-C.org",
+ });
+
+ let cw = open_compose_new_mail();
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerC/testFile1",
+ name: "testFile1",
+ serviceName: "MochiTest C",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-C.org",
+ },
+ {
+ url: "https://www.example.com/providerC/testFile2",
+ name: "testFile2",
+ serviceName: "MochiTest C",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-C.org",
+ },
+ ],
+ `Expected values in uploads array #7`
+ );
+ let [root, list] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ for (let i = 0; i < kFiles.length; ++i) {
+ let [selectedItem] = select_attachments(cw, i);
+ cw.window.convertSelectedToRegularAttachment();
+
+ // Wait until the cloud file entry has been removed.
+ utils.waitFor(function () {
+ let urls = list.querySelectorAll(".cloudAttachmentItem");
+ return urls.length == kFiles.length - (i + 1);
+ });
+
+ // Check that the cloud icon has been removed.
+ Assert.equal(
+ selectedItem.querySelector("img.attachmentcell-icon").src,
+ `moz-icon://${selectedItem.attachment.name}?size=16`,
+ `CloudIcon should be correctly removed for ${selectedItem.attachment.name}`
+ );
+ }
+
+ // At this point, the root should also have been removed.
+ await new Promise(resolve => setTimeout(resolve));
+ let mailBody = get_compose_body(cw);
+ root = mailBody.querySelector("#cloudAttachmentListRoot");
+ if (root) {
+ throw new Error("Should not have found the cloudAttachmentListRoot");
+ }
+
+ close_compose_window(cw);
+}
+
+/**
+ * Tests that if the user manually removes the Filelinks from the message body
+ * that it doesn't break future Filelink insertions. Tests both HTML and
+ * plaintext composers.
+ */
+add_task(async function test_filelinks_work_after_manual_removal() {
+ await try_with_plaintext_and_html_mail(
+ subtest_filelinks_work_after_manual_removal
+ );
+});
+
+/**
+ * Subtest that first adds some Filelinks to the message body, removes them,
+ * and then adds another Filelink ensuring that the new URL is successfully
+ * inserted.
+ */
+function subtest_filelinks_work_after_manual_removal() {
+ // Insert some Filelinks...
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ provider.init("providerD", {
+ serviceName: "MochiTest D",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-D.org",
+ });
+
+ let cw = open_compose_new_mail();
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerD/testFile1",
+ name: "testFile1",
+ serviceName: "MochiTest D",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-D.org",
+ },
+ {
+ url: "https://www.example.com/providerD/testFile2",
+ name: "testFile2",
+ serviceName: "MochiTest D",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceUrl: "https://www.provider-D.org",
+ },
+ ],
+ `Expected values in uploads array #8`
+ );
+ let [root] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ // Now remove the root node from the document body
+ root.remove();
+
+ gMockFilePicker.returnFiles = collectFiles(["./data/testFile3"]);
+ uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerD/testFile3",
+ name: "testFile3",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ serviceName: "MochiTest D",
+ serviceUrl: "https://www.provider-D.org",
+ },
+ ],
+ `Expected values in uploads array #9`
+ );
+ [root] = wait_for_attachment_urls(cw, 1, uploads);
+
+ close_compose_window(cw);
+}
+
+/**
+ * Test that if the users selection caret is on a newline when the URL
+ * insertion occurs, that the caret does not move when the insertion is
+ * complete. Tests both HTML and plaintext composers.
+ */
+add_task(async function test_insertion_restores_caret_point() {
+ await try_with_plaintext_and_html_mail(
+ subtest_insertion_restores_caret_point
+ );
+});
+
+/**
+ * Subtest that types some things into the composer, finishes on two
+ * linebreaks, inserts some Filelink URLs, and then types some more,
+ * ensuring that the selection is where we expect it to be.
+ */
+function subtest_insertion_restores_caret_point() {
+ // Insert some Filelinks...
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ provider.init("providerE", {
+ serviceName: "MochiTest E",
+ serviceUrl: "https://www.provider-E.org",
+ });
+
+ let cw = open_compose_new_mail();
+
+ // Put the selection at the beginning of the document...
+ let editor = cw.window.GetCurrentEditor();
+ editor.beginningOfDocument();
+
+ // Do any necessary typing, ending with two linebreaks.
+ type_in_composer(cw, ["Line 1", "Line 2", "", ""]);
+
+ // Attach some Filelinks.
+ let uploads = add_cloud_attachments(cw, provider);
+ test_expected_included(
+ uploads,
+ [
+ {
+ url: "https://www.example.com/providerE/testFile1",
+ name: "testFile1",
+ serviceName: "MochiTest E",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceUrl: "https://www.provider-E.org",
+ },
+ {
+ url: "https://www.example.com/providerE/testFile2",
+ name: "testFile2",
+ serviceName: "MochiTest E",
+ serviceIcon: "chrome://messenger/content/extension.svg",
+ serviceUrl: "https://www.provider-E.org",
+ },
+ ],
+ `Expected values in uploads array #10`
+ );
+ let [root] = wait_for_attachment_urls(cw, kFiles.length, uploads);
+
+ // Type some text.
+ const kTypedIn = "Test";
+ type_in_composer(cw, [kTypedIn]);
+
+ // That text should be inserted just above the root attachment URL node.
+ let br = assert_previous_nodes("br", root, 1);
+ assert_previous_text(br.previousSibling, [kTypedIn]);
+
+ close_compose_window(cw);
+}
diff --git a/comm/mail/test/browser/cloudfile/browser_filelinkTelemetry.js b/comm/mail/test/browser/cloudfile/browser_filelinkTelemetry.js
new file mode 100644
index 0000000000..100cbdd1c6
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/browser_filelinkTelemetry.js
@@ -0,0 +1,123 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test telemetry related to filelink.
+ */
+
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+let { gMockFilePicker, gMockFilePickReg } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AttachmentHelpers.jsm"
+);
+let { gMockCloudfileManager } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CloudfileHelpers.jsm"
+);
+let {
+ add_attachments,
+ add_cloud_attachments,
+ close_compose_window,
+ open_compose_new_mail,
+ setup_msg_contents,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+let { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+let { wait_for_notification_to_stop } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+let { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+let cloudType = "default";
+let kInsertNotificationPref =
+ "mail.compose.big_attachments.insert_notification";
+
+let maxSize =
+ Services.prefs.getIntPref("mail.compose.big_attachments.threshold_kb") * 1024;
+
+add_setup(function () {
+ requestLongerTimeout(2);
+
+ gMockCloudfileManager.register(cloudType);
+ gMockFilePickReg.register();
+
+ Services.prefs.setBoolPref(kInsertNotificationPref, true);
+});
+
+registerCleanupFunction(function () {
+ gMockCloudfileManager.unregister(cloudType);
+ gMockFilePickReg.unregister();
+ Services.prefs.clearUserPref(kInsertNotificationPref);
+});
+
+let kBoxId = "compose-notification-bottom";
+let kNotificationValue = "bigAttachment";
+
+/**
+ * Check that we're counting file size uploaded.
+ */
+add_task(async function test_filelink_uploaded_size() {
+ Services.telemetry.clearScalars();
+ let testFile1Size = 495;
+ let testFile2Size = 637;
+ let totalSize = testFile1Size + testFile2Size;
+
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile1",
+ "./data/testFile2",
+ ]);
+
+ let provider = cloudFileAccounts.getProviderForType(cloudType);
+ let cwc = open_compose_new_mail(mc);
+ let account = cloudFileAccounts.createAccount(cloudType);
+
+ add_cloud_attachments(cwc, account, false);
+ gMockCloudfileManager.resolveUploads();
+ wait_for_notification_to_stop(cwc.window, kBoxId, "bigAttachmentUploading");
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.filelink.uploaded_size"][provider.displayName],
+ totalSize,
+ "Count of uploaded size must be correct."
+ );
+ close_compose_window(cwc);
+});
+
+/**
+ * Check that we're counting filelink suggestion ignored.
+ */
+add_task(async function test_filelink_ignored() {
+ Services.telemetry.clearScalars();
+
+ let cwc = open_compose_new_mail(mc);
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "Testing ignoring filelink suggestion",
+ "Hello! "
+ );
+
+ // Multiple big attachments should be counted as one ignoring.
+ add_attachments(cwc, "https://www.example.com/1", maxSize);
+ add_attachments(cwc, "https://www.example.com/2", maxSize + 10);
+ add_attachments(cwc, "https://www.example.com/3", maxSize - 1);
+ let aftersend = BrowserTestUtils.waitForEvent(cwc.window, "aftersend");
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("button-send"),
+ {},
+ cwc.window.document.getElementById("button-send").ownerGlobal
+ );
+ await aftersend;
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+ Assert.equal(
+ scalars["tb.filelink.ignored"],
+ 1,
+ "Count of ignored times must be correct."
+ );
+ close_compose_window(cwc, true);
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/cloudfile/browser_notifications.js b/comm/mail/test/browser/cloudfile/browser_notifications.js
new file mode 100644
index 0000000000..98e0c49c0f
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/browser_notifications.js
@@ -0,0 +1,565 @@
+/* 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/. */
+
+/**
+ * Tests that the cloudfile notifications work as they should.
+ */
+
+"use strict";
+
+var { gMockFilePicker, gMockFilePickReg, select_attachments } =
+ ChromeUtils.import("resource://testing-common/mozmill/AttachmentHelpers.jsm");
+var { gMockCloudfileManager, MockCloudfileAccount } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CloudfileHelpers.jsm"
+);
+var {
+ add_attachments,
+ add_cloud_attachments,
+ close_compose_window,
+ open_compose_new_mail,
+ delete_attachment,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_notification_displayed,
+ close_notification,
+ wait_for_notification_to_show,
+ wait_for_notification_to_stop,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var { gMockPromptService } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PromptHelpers.jsm"
+);
+
+var maxSize, oldInsertNotificationPref;
+
+var kOfferThreshold = "mail.compose.big_attachments.threshold_kb";
+var kInsertNotificationPref =
+ "mail.compose.big_attachments.insert_notification";
+
+var kBoxId = "compose-notification-bottom";
+
+add_setup(function () {
+ requestLongerTimeout(2);
+
+ gMockCloudfileManager.register();
+ gMockFilePickReg.register();
+
+ maxSize = Services.prefs.getIntPref(kOfferThreshold, 0) * 1024;
+ oldInsertNotificationPref = Services.prefs.getBoolPref(
+ kInsertNotificationPref
+ );
+ Services.prefs.setBoolPref(kInsertNotificationPref, true);
+});
+
+registerCleanupFunction(function () {
+ gMockCloudfileManager.unregister();
+ gMockFilePickReg.unregister();
+ Services.prefs.setBoolPref(
+ kInsertNotificationPref,
+ oldInsertNotificationPref
+ );
+ Services.prefs.setIntPref(kOfferThreshold, maxSize);
+});
+
+/**
+ * A helper function to assert that the Filelink offer notification is
+ * either displayed or not displayed.
+ *
+ * @param aController the controller of the compose window to check.
+ * @param aDisplayed true if the notification should be displayed, false
+ * otherwise.
+ */
+function assert_cloudfile_notification_displayed(aController, aDisplayed) {
+ assert_notification_displayed(
+ aController.window,
+ kBoxId,
+ "bigAttachment",
+ aDisplayed
+ );
+}
+
+/**
+ * A helper function to assert that the Filelink upload notification is
+ * either displayed or not displayed.
+ *
+ * @param aController the controller of the compose window to check.
+ * @param aDisplayed true if the notification should be displayed, false
+ * otherwise.
+ */
+function assert_upload_notification_displayed(aController, aDisplayed) {
+ assert_notification_displayed(
+ aController.window,
+ kBoxId,
+ "bigAttachmentUploading",
+ aDisplayed
+ );
+}
+
+/**
+ * A helper function to assert that the Filelink privacy warning notification
+ * is either displayed or not displayed.
+ *
+ * @param aController the controller of the compose window to check.
+ * @param aDisplayed true if the notification should be displayed, false
+ * otherwise.
+ */
+function assert_privacy_warning_notification_displayed(
+ aController,
+ aDisplayed
+) {
+ assert_notification_displayed(
+ aController.window,
+ kBoxId,
+ "bigAttachmentPrivacyWarning",
+ aDisplayed
+ );
+}
+
+/**
+ * A helper function to close the Filelink upload notification.
+ */
+function close_upload_notification(aController) {
+ close_notification(aController.window, kBoxId, "bigAttachmentUploading");
+}
+
+/**
+ * A helper function to close the Filelink privacy warning notification.
+ */
+function close_privacy_warning_notification(aController) {
+ close_notification(aController.window, kBoxId, "bigAttachmentPrivacyWarning");
+}
+
+add_task(function test_no_notification_for_small_file() {
+ let cwc = open_compose_new_mail(mc);
+ add_attachments(cwc, "https://www.example.com/1", 0);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ add_attachments(cwc, "https://www.example.com/2", 1);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ add_attachments(cwc, "https://www.example.com/3", 100);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ add_attachments(cwc, "https://www.example.com/4", 500);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ close_compose_window(cwc);
+});
+
+add_task(function test_notification_for_big_files() {
+ let cwc = open_compose_new_mail(mc);
+ add_attachments(cwc, "https://www.example.com/1", maxSize);
+ assert_cloudfile_notification_displayed(cwc, true);
+
+ add_attachments(cwc, "https://www.example.com/2", maxSize + 1000);
+ assert_cloudfile_notification_displayed(cwc, true);
+
+ add_attachments(cwc, "https://www.example.com/3", maxSize + 10000);
+ assert_cloudfile_notification_displayed(cwc, true);
+
+ add_attachments(cwc, "https://www.example.com/4", maxSize + 100000);
+ assert_cloudfile_notification_displayed(cwc, true);
+
+ close_compose_window(cwc);
+});
+
+add_task(function test_graduate_to_notification() {
+ let cwc = open_compose_new_mail(mc);
+ add_attachments(cwc, "https://www.example.com/1", maxSize - 100);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ add_attachments(cwc, "https://www.example.com/2", maxSize - 25);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ add_attachments(cwc, "https://www.example.com/3", maxSize);
+ assert_cloudfile_notification_displayed(cwc, true);
+
+ close_compose_window(cwc);
+});
+
+add_task(function test_no_notification_if_disabled() {
+ Services.prefs.setBoolPref("mail.cloud_files.enabled", false);
+ let cwc = open_compose_new_mail(mc);
+
+ add_attachments(cwc, "https://www.example.com/1", maxSize);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ add_attachments(cwc, "https://www.example.com/2", maxSize + 1000);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ add_attachments(cwc, "https://www.example.com/3", maxSize + 10000);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ add_attachments(cwc, "https://www.example.com/4", maxSize + 100000);
+ assert_cloudfile_notification_displayed(cwc, false);
+
+ close_compose_window(cwc);
+ Services.prefs.setBoolPref("mail.cloud_files.enabled", true);
+});
+
+/**
+ * Tests that if we upload a single file, we get the link insertion
+ * notification bar displayed (unless preffed off).
+ */
+add_task(function test_link_insertion_notification_single() {
+ gMockFilePicker.returnFiles = collectFiles(["./data/testFile1"]);
+ let provider = new MockCloudfileAccount();
+ provider.init("aKey");
+
+ let cwc = open_compose_new_mail(mc);
+ add_cloud_attachments(cwc, provider, false);
+
+ assert_upload_notification_displayed(cwc, true);
+ close_upload_notification(cwc);
+ gMockCloudfileManager.resolveUploads();
+
+ Services.prefs.setBoolPref(kInsertNotificationPref, false);
+ gMockFilePicker.returnFiles = collectFiles(["./data/testFile2"]);
+ add_cloud_attachments(cwc, provider, false);
+
+ assert_upload_notification_displayed(cwc, false);
+ Services.prefs.setBoolPref(kInsertNotificationPref, true);
+
+ close_compose_window(cwc);
+ gMockCloudfileManager.resolveUploads();
+});
+
+/**
+ * Tests that if we upload multiple files, we get the link insertion
+ * notification bar displayed (unless preffed off).
+ */
+add_task(function test_link_insertion_notification_multiple() {
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile1",
+ "./data/testFile2",
+ ]);
+ let provider = new MockCloudfileAccount();
+ provider.init("aKey");
+
+ let cwc = open_compose_new_mail(mc);
+ add_cloud_attachments(cwc, provider, false);
+
+ assert_upload_notification_displayed(cwc, true);
+ close_upload_notification(cwc);
+ gMockCloudfileManager.resolveUploads();
+
+ Services.prefs.setBoolPref(kInsertNotificationPref, false);
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile3",
+ "./data/testFile4",
+ ]);
+ add_cloud_attachments(cwc, provider, false);
+
+ assert_upload_notification_displayed(cwc, false);
+ Services.prefs.setBoolPref(kInsertNotificationPref, true);
+
+ close_compose_window(cwc);
+ gMockCloudfileManager.resolveUploads();
+});
+
+/**
+ * Tests that the link insertion notification bar goes away even
+ * if we hit an uploading error.
+ */
+add_task(function test_link_insertion_goes_away_on_error() {
+ gMockPromptService.register();
+ gMockPromptService.returnValue = false;
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile1",
+ "./data/testFile2",
+ ]);
+ let provider = new MockCloudfileAccount();
+ provider.init("aKey");
+
+ let cwc = open_compose_new_mail(mc);
+ add_cloud_attachments(cwc, provider, false);
+
+ wait_for_notification_to_show(cwc.window, kBoxId, "bigAttachmentUploading");
+ gMockCloudfileManager.rejectUploads();
+ wait_for_notification_to_stop(cwc.window, kBoxId, "bigAttachmentUploading");
+
+ close_compose_window(cwc);
+ gMockPromptService.unregister();
+});
+
+/**
+ * Test that we do not show the Filelink offer notification if we convert
+ * a Filelink back into a normal attachment. Also test, that the privacy
+ * notification is correctly shown and hidden.
+ */
+add_task(async function test_no_offer_on_conversion() {
+ const kFiles = ["./data/testFile1", "./data/testFile2"];
+ // Set the notification threshold to 0 to ensure that we get it.
+ Services.prefs.setIntPref(kOfferThreshold, 0);
+
+ // Insert some Filelinks...
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+ let provider = new MockCloudfileAccount();
+ provider.init("someKey");
+
+ // Override uploadFile to succeed instantaneously so that we don't have
+ // to worry about waiting for the onStopRequest method being called
+ // asynchronously.
+ provider.uploadFile = function (window, aFile) {
+ return Promise.resolve({
+ id: 1,
+ url: "https://some.cloud.net/1",
+ path: aFile.path,
+ size: aFile.fileSize,
+ });
+ };
+
+ let cw = open_compose_new_mail();
+ add_cloud_attachments(cw, provider, false);
+
+ assert_cloudfile_notification_displayed(cw, false);
+ assert_privacy_warning_notification_displayed(cw, true);
+
+ // Now convert the file back into a normal attachment
+ select_attachments(cw, 0);
+ await cw.window.convertSelectedToRegularAttachment();
+ assert_cloudfile_notification_displayed(cw, false);
+ assert_privacy_warning_notification_displayed(cw, true);
+
+ // Convert also the other file, the privacy notification should no longer
+ // be shown as well.
+ select_attachments(cw, 1);
+ await cw.window.convertSelectedToRegularAttachment();
+ assert_cloudfile_notification_displayed(cw, false);
+ assert_privacy_warning_notification_displayed(cw, false);
+
+ close_compose_window(cw);
+
+ // Now put the old threshold back.
+ Services.prefs.setIntPref(kOfferThreshold, maxSize);
+});
+
+/**
+ * Test that when we kick off an upload via the offer notification, then
+ * the upload notification is shown.
+ */
+add_task(async function test_offer_then_upload_notifications() {
+ const kFiles = ["./data/testFile1", "./data/testFile2"];
+ // Set the notification threshold to 0 to ensure that we get it.
+ Services.prefs.setIntPref(kOfferThreshold, 0);
+
+ // We're going to add attachments to the attachmentbucket, and we'll
+ // use the add_attachments helper function to do it. First, retrieve
+ // some file URIs...
+ let fileURIs = collectFiles(kFiles).map(
+ file => Services.io.newFileURI(file).spec
+ );
+
+ // Create our mock provider
+ let provider = new MockCloudfileAccount();
+ provider.init("someKey");
+
+ // Override uploadFile to succeed instantaneously so that we don't have
+ // to worry about waiting for the onStopRequest method being called
+ // asynchronously.
+ provider.uploadFile = function (window, aFile) {
+ return Promise.resolve({
+ id: 1,
+ url: "https://some.cloud.net/1",
+ path: aFile.path,
+ size: aFile.fileSize,
+ });
+ };
+
+ let cw = open_compose_new_mail();
+
+ // Attach the files, saying that each is 500 bytes large - which should
+ // certainly trigger the offer.
+ add_attachments(cw, fileURIs, [500, 500]);
+ // Assert that the offer is displayed.
+ assert_cloudfile_notification_displayed(cw, true);
+ // Select both attachments in the attachmentbucket, and choose to convert
+ // them.
+ select_attachments(cw, 0, 1);
+ // Convert them.
+ await cw.window.convertSelectedToCloudAttachment(provider);
+
+ // The offer should now be gone...
+ assert_cloudfile_notification_displayed(cw, false);
+ // And the upload notification should be displayed.
+ assert_upload_notification_displayed(cw, true);
+
+ close_compose_window(cw);
+
+ // Now put the old threshold back.
+ Services.prefs.setIntPref(kOfferThreshold, maxSize);
+});
+
+/**
+ * Test that when we first upload some files, we get the privacy warning
+ * message. We should only get this the first time.
+ */
+add_task(function test_privacy_warning_notification() {
+ gMockPromptService.register();
+ gMockPromptService.returnValue = false;
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile1",
+ "./data/testFile2",
+ ]);
+ let provider = new MockCloudfileAccount();
+ provider.init("aKey");
+
+ let cwc = open_compose_new_mail(mc);
+ add_cloud_attachments(cwc, provider, false);
+
+ wait_for_notification_to_show(cwc.window, kBoxId, "bigAttachmentUploading");
+ gMockCloudfileManager.resolveUploads();
+ wait_for_notification_to_stop(cwc.window, kBoxId, "bigAttachmentUploading");
+
+ // Assert that the warning is displayed.
+ assert_privacy_warning_notification_displayed(cwc, true);
+
+ // Close the privacy warning notification...
+ close_privacy_warning_notification(cwc);
+
+ // And now upload some more files. We shouldn't get the warning again.
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile3",
+ "./data/testFile4",
+ ]);
+ add_cloud_attachments(cwc, provider, false);
+ gMockCloudfileManager.resolveUploads();
+ assert_privacy_warning_notification_displayed(cwc, false);
+
+ close_compose_window(cwc);
+ gMockPromptService.unregister();
+});
+
+/**
+ * Test that when all cloud attachments are removed, the privacy warning will
+ * be removed as well.
+ */
+add_task(function test_privacy_warning_notification() {
+ gMockPromptService.register();
+ gMockPromptService.returnValue = false;
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile1",
+ "./data/testFile2",
+ ]);
+ let provider = new MockCloudfileAccount();
+ provider.init("aKey");
+
+ let cwc = open_compose_new_mail(mc);
+ add_cloud_attachments(cwc, provider, false);
+
+ wait_for_notification_to_show(cwc.window, kBoxId, "bigAttachmentUploading");
+ gMockCloudfileManager.resolveUploads();
+ wait_for_notification_to_stop(cwc.window, kBoxId, "bigAttachmentUploading");
+
+ // Assert that the warning is displayed.
+ assert_privacy_warning_notification_displayed(cwc, true);
+
+ // Assert that the warning is still displayed, if one attachment is removed.
+ delete_attachment(cwc, 1);
+ assert_privacy_warning_notification_displayed(cwc, true);
+
+ // Assert that the warning is not displayed, after both attachments are removed.
+ delete_attachment(cwc, 0);
+ assert_privacy_warning_notification_displayed(cwc, false);
+
+ close_compose_window(cwc);
+ gMockPromptService.unregister();
+});
+
+/**
+ * Test that the privacy warning notification does not persist when closing
+ * and re-opening a compose window.
+ */
+add_task(function test_privacy_warning_notification_no_persist() {
+ gMockPromptService.register();
+ gMockPromptService.returnValue = false;
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile1",
+ "./data/testFile2",
+ ]);
+ let provider = new MockCloudfileAccount();
+ provider.init("mocktestKey");
+
+ let cwc = open_compose_new_mail(mc);
+ add_cloud_attachments(cwc, provider, false);
+
+ wait_for_notification_to_show(cwc.window, kBoxId, "bigAttachmentUploading");
+ gMockCloudfileManager.resolveUploads();
+ wait_for_notification_to_stop(cwc.window, kBoxId, "bigAttachmentUploading");
+
+ // Assert that the warning is displayed.
+ assert_privacy_warning_notification_displayed(cwc, true);
+
+ // Close the compose window
+ close_compose_window(cwc);
+
+ // Open a new compose window
+ cwc = open_compose_new_mail(mc);
+
+ // We shouldn't be displaying the privacy warning.
+ assert_privacy_warning_notification_displayed(cwc, false);
+
+ close_compose_window(cwc);
+ gMockPromptService.unregister();
+});
+
+/**
+ * Test that if we close the privacy warning in a composer, it will still
+ * spawn in a new one.
+ */
+add_task(function test_privacy_warning_notification_open_after_close() {
+ gMockPromptService.register();
+ gMockPromptService.returnValue = false;
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile1",
+ "./data/testFile2",
+ ]);
+ let provider = new MockCloudfileAccount();
+ provider.init("aKey");
+
+ let cwc = open_compose_new_mail(mc);
+ add_cloud_attachments(cwc, provider, false);
+
+ wait_for_notification_to_show(cwc.window, kBoxId, "bigAttachmentUploading");
+ gMockCloudfileManager.resolveUploads();
+ wait_for_notification_to_stop(cwc.window, kBoxId, "bigAttachmentUploading");
+
+ // Assert that the warning is displayed.
+ assert_privacy_warning_notification_displayed(cwc, true);
+
+ // Close the privacy warning notification...
+ close_privacy_warning_notification(cwc);
+
+ close_compose_window(cwc);
+
+ // Open a new compose window
+ cwc = open_compose_new_mail(mc);
+
+ gMockFilePicker.returnFiles = collectFiles([
+ "./data/testFile3",
+ "./data/testFile4",
+ ]);
+ add_cloud_attachments(cwc, provider, false);
+
+ wait_for_notification_to_show(cwc.window, kBoxId, "bigAttachmentUploading");
+ gMockCloudfileManager.resolveUploads();
+ wait_for_notification_to_stop(cwc.window, kBoxId, "bigAttachmentUploading");
+
+ // Assert that the privacy warning notification is displayed again.
+ assert_privacy_warning_notification_displayed(cwc, true);
+
+ close_compose_window(cwc);
+ gMockPromptService.unregister();
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/cloudfile/data/testFile1 b/comm/mail/test/browser/cloudfile/data/testFile1
new file mode 100644
index 0000000000..e07edf8e40
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/data/testFile1
@@ -0,0 +1 @@
+Thundercats single-origin coffee culpa, irony minim vero sunt laborum synth aesthetic. Wayfarers photo booth dolore 8-bit, DIY four loko skateboard forage portland id consectetur. Aesthetic aliquip raw denim aute tofu consequat. Before they sold out etsy cliche marfa, magna seitan fixie brooklyn voluptate laborum messenger bag chillwave narwhal truffaut. Cray cupidatat PBR delectus aliqua synth cillum gentrify. Aesthetic do vegan etsy locavore. Veniam assumenda ea, cupidatat aute vero qui.
diff --git a/comm/mail/test/browser/cloudfile/data/testFile2 b/comm/mail/test/browser/cloudfile/data/testFile2
new file mode 100644
index 0000000000..0509cc907e
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/data/testFile2
@@ -0,0 +1,3 @@
+Nesciunt odio minim, cupidatat photo booth non post-ironic street art banh mi salvia duis aesthetic squid single-origin coffee. Ex DIY trust fund butcher, esse mustache consequat authentic bushwick twee gentrify hella PBR kogi sustainable. PBR nihil VHS veniam, occaecat dreamcatcher odio iphone irony vero seitan mollit fanny pack adipisicing. Swag jean shorts labore, aesthetic dolore letterpress gluten-free lomo ex wes anderson. Et street art cred Austin velit, raw denim do blog godard leggings. Accusamus adipisicing excepteur occaecat cray sriracha. Leggings brunch artisan occaecat, 3 wolf moon forage mlkshk ad farm-to-table.
+
+
diff --git a/comm/mail/test/browser/cloudfile/data/testFile3 b/comm/mail/test/browser/cloudfile/data/testFile3
new file mode 100644
index 0000000000..627d5147b5
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/data/testFile3
@@ -0,0 +1,3 @@
+Commodo et laborum fingerstache semiotics etsy. Organic locavore next level, master cleanse raw denim consectetur wes anderson ethical tempor photo booth quis. Leggings pop-up sed trust fund. Chillwave godard velit high life, typewriter umami trust fund. Laboris aliquip assumenda you probably haven't heard of them exercitation portland. Ea do selvage, stumptown dolore etsy commodo tattooed kogi assumenda. Aute tempor carles consequat cray locavore.
+
+Craft beer consectetur anim ex fap consequat, helvetica hella nihil retro before they sold out letterpress cillum mlkshk. Deserunt tempor scenester put a bird on it kale chips mlkshk occaecat, et umami artisan letterpress raw denim sapiente. Echo park pork belly marfa sunt. Iphone aesthetic fanny pack mollit. Irony pork belly bespoke, shoreditch locavore fixie iphone officia mollit mlkshk consequat hoodie mixtape. Nisi consectetur locavore, godard whatever occaecat id blog ethical wolf hoodie PBR. Trust fund sunt mustache, enim eiusmod aesthetic helvetica leggings pinterest laboris polaroid brooklyn.
diff --git a/comm/mail/test/browser/cloudfile/data/testFile4 b/comm/mail/test/browser/cloudfile/data/testFile4
new file mode 100644
index 0000000000..0076011da0
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/data/testFile4
@@ -0,0 +1 @@
+Ullamco farm-to-table banh mi, echo park dolor lomo chillwave seitan cred ad mustache 3 wolf moon excepteur. Odd future placeat sint, cliche consequat portland banksy vinyl 3 wolf moon high life you probably haven't heard of them nisi jean shorts. Eu freegan skateboard, kogi etsy beard aliquip blog sapiente pour-over sunt. Cosby sweater jean shorts wayfarers keytar trust fund, four loko pitchfork pinterest forage semiotics lo-fi cray beard swag butcher. Odd future cred swag, pork belly keytar velit terry richardson locavore id brunch excepteur commodo occupy mollit pickled. Street art voluptate pickled fap, ad craft beer cupidatat messenger bag placeat helvetica. Enim raw denim occupy pork belly irure et.
diff --git a/comm/mail/test/browser/cloudfile/head.js b/comm/mail/test/browser/cloudfile/head.js
new file mode 100644
index 0000000000..436af7ea0e
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/head.js
@@ -0,0 +1,7 @@
+/* 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/. */
+
+function collectFiles(files) {
+ return files.map(filename => new FileUtils.File(getTestFilePath(filename)));
+}
diff --git a/comm/mail/test/browser/cloudfile/html/settings-with-link.xhtml b/comm/mail/test/browser/cloudfile/html/settings-with-link.xhtml
new file mode 100644
index 0000000000..838c247a8f
--- /dev/null
+++ b/comm/mail/test/browser/cloudfile/html/settings-with-link.xhtml
@@ -0,0 +1,9 @@
+<?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/. -->
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <body>
+ <a id="a" href="https://www.example.com/">Click me!</a>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/composition/browser.ini b/comm/mail/test/browser/composition/browser.ini
new file mode 100644
index 0000000000..8391965d89
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser.ini
@@ -0,0 +1,127 @@
+[DEFAULT]
+head = head.js
+prefs =
+ ldap_2.servers.osx.dirType=-3
+ mail.account.account1.server=server1
+ mail.account.account2.identities=id1,id2
+ mail.account.account2.server=server2
+ mail.accountmanager.accounts=account1,account2
+ mail.accountmanager.defaultaccount=account2
+ mail.accountmanager.localfoldersserver=server1
+ mail.identity.id1.fullName=Tinderbox
+ mail.identity.id1.htmlSigFormat=false
+ mail.identity.id1.smtpServer=smtp1
+ mail.identity.id1.useremail=tinderbox@foo.invalid
+ mail.identity.id1.valid=true
+ mail.identity.id2.fullName=Tinderboxpushlog
+ mail.identity.id2.htmlSigFormat=true
+ mail.identity.id2.smtpServer=smtp1
+ mail.identity.id2.useremail=tinderboxpushlog@foo.invalid
+ mail.identity.id2.valid=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server1.type=none
+ mail.server.server1.userName=nobody
+ mail.server.server2.check_new_mail=false
+ mail.server.server2.directory-rel=[ProfD]Mail/tinderbox
+ mail.server.server2.download_on_biff=true
+ mail.server.server2.hostname=tinderbox123
+ mail.server.server2.login_at_startup=false
+ mail.server.server2.name=tinderbox@foo.invalid
+ mail.server.server2.type=pop3
+ mail.server.server2.userName=tinderbox
+ mail.server.server2.whiteListAbURI=
+ mail.shell.checkDefaultClient=false
+ mail.smtp.defaultserver=smtp1
+ mail.smtpserver.smtp1.hostname=tinderbox123
+ mail.smtpserver.smtp1.username=tinderbox
+ mail.smtpservers=smtp1
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+skip-if = os == 'linux' && bits == 32 && debug # Perma-fail
+subsuite = thunderbird
+support-files =
+ data/**
+ ../openpgp/data/**
+ html/linkpreview.html
+
+[browser_addressWidgets.js]
+[browser_attachment.js]
+[browser_attachmentCloudDraft.js]
+[browser_attachmentDragDrop.js]
+[browser_attachmentReminder.js]
+[browser_base64Display.js]
+[browser_blockedContent.js]
+skip-if = headless # clipboard doesn't work with headless
+[browser_charsetEdit.js]
+[browser_checkRecipientKeys.js]
+[browser_cp932Display.js]
+[browser_customHeaders.js]
+[browser_draftIdentity.js]
+skip-if = os == "mac"
+reason = See bug 1413851.
+[browser_drafts.js]
+[browser_emlActions.js]
+[browser_expandLists.js]
+[browser_encryptedBccRecipients.js]
+[browser_focus.js]
+[browser_font_color.js]
+skip-if = os == "mac"
+reason = Cannot open the Format menu
+[browser_font_family.js]
+skip-if = os == "mac"
+reason = Cannot open the Format menu
+[browser_font_size.js]
+skip-if = os == "mac"
+reason = Cannot open the Format menu
+[browser_forwardDefectiveCharset.js]
+[browser_forwardHeaders.js]
+[browser_forwardRFC822Attach.js]
+[browser_forwardUTF8.js]
+[browser_forwardedContent.js]
+[browser_forwardedEmlActions.js]
+[browser_imageDisplay.js]
+[browser_imageInsertionDialog.js]
+[browser_inlineImage.js]
+skip-if = headless # clipboard doesn't work with headless
+[browser_linkPreviews.js]
+[browser_messageBody.js]
+[browser_multipartRelated.js]
+[browser_newmsgComposeIdentity.js]
+[browser_paragraph_state.js]
+skip-if = os == "mac"
+reason = Cannot open the Format menu
+[browser_publicRecipientsWarning.js]
+[browser_quoteMessage.js]
+[browser_recipientPillsSelection.js]
+[browser_redirect.js]
+[browser_replyAddresses.js]
+skip-if = debug # Bug 1601591
+[browser_replyCatchAll.js]
+[browser_replyFormatFlowed.js]
+[browser_replyMultipartCharset.js]
+skip-if = debug # Bug 1606542
+[browser_replySelection.js]
+skip-if = true # Not working on 115 due to test changes elsewhere.
+[browser_replySignature.js]
+[browser_remove_text_styling.js]
+skip-if = os == "mac"
+reason = Cannot open the Format menu
+[browser_saveChangesOnQuit.js]
+[browser_sendButton.js]
+tags = addrbook
+skip-if = os == 'win' && bits == 64 && debug # Bug 1601520
+[browser_sendFormat.js]
+skip-if = os == "mac"
+reason = See bug 1763407
+[browser_signatureInit.js]
+[browser_signatureUpdating.js]
+[browser_spelling.js]
+[browser_text_styling.js]
+skip-if = os == "mac"
+reason = Cannot open the Format menu
+[browser_subjectWas.js]
+[browser_xUnsent.js]
diff --git a/comm/mail/test/browser/composition/browser_addressWidgets.js b/comm/mail/test/browser/composition/browser_addressWidgets.js
new file mode 100644
index 0000000000..4e5db02a72
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_addressWidgets.js
@@ -0,0 +1,773 @@
+/* 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/. */
+
+/**
+ * Tests proper enabling of addressing widgets.
+ */
+
+"use strict";
+
+var { click_menus_in_sequence } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { close_compose_window, open_compose_new_mail } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var { be_in_folder, FAKE_SERVER_HOSTNAME } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var cwc = null; // compose window controller
+var accountPOP3 = null;
+var accountNNTP = null;
+var originalAccountCount;
+
+add_setup(function () {
+ // Ensure we're in the tinderbox account as that has the right identities set
+ // up for this test.
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ accountPOP3 = MailServices.accounts.FindAccountForServer(server);
+
+ // There may be pre-existing accounts from other tests.
+ originalAccountCount = MailServices.accounts.allServers.length;
+});
+
+/**
+ * Check if the address type items are in the wished state.
+ *
+ * @param {Window} win - The window to search in.
+ * @param {string[]} itemsEnabled - List of item values that should be visible.
+ */
+function check_address_types_state(win, itemsEnabled) {
+ for (let item of win.document.querySelectorAll(
+ "#extraAddressRowsMenu > menuitem"
+ )) {
+ let buttonId = item.dataset.buttonId;
+ let showRowEl;
+ if (buttonId) {
+ let button = win.document.getElementById(buttonId);
+ if (item.dataset.preferButton == "true") {
+ showRowEl = button;
+ Assert.ok(item.hidden, `${item.id} menuitem should be hidden`);
+ } else {
+ showRowEl = item;
+ Assert.ok(button.hidden, `${button.id} button should be hidden`);
+ }
+ } else {
+ showRowEl = item;
+ }
+
+ let type = item.id.replace(/ShowAddressRowMenuItem$/, "");
+
+ let expectShown = itemsEnabled.includes(type);
+ let row = win.document.querySelector(
+ `.address-row[data-recipienttype="${type}"]`
+ );
+ if (expectShown) {
+ // Either the row or the element that shows it should be visible, but not
+ // both.
+ if (row.classList.contains("hidden")) {
+ Assert.ok(
+ !showRowEl.hidden,
+ `${showRowEl.id} should be visible when the row is hidden`
+ );
+ } else {
+ Assert.ok(
+ showRowEl.hidden,
+ `${showRowEl.id} should be hidden when the row is visible`
+ );
+ }
+ } else {
+ // Both the row and the element that shows it should be hidden.
+ Assert.ok(row.classList.contains("hidden"), `${row.id} should be hidden`);
+ Assert.ok(showRowEl.hidden, `${showRowEl.id} should be hidden`);
+ }
+ }
+}
+
+/**
+ * With only a POP3 account, no News related address types should be enabled.
+ */
+function check_mail_address_types(win) {
+ check_address_types_state(win, [
+ "addr_to",
+ "addr_cc",
+ "addr_reply",
+ "addr_bcc",
+ ]);
+}
+
+/**
+ * With a NNTP account, all address types should be enabled.
+ */
+function check_nntp_address_types(win) {
+ check_address_types_state(win, [
+ "addr_to",
+ "addr_cc",
+ "addr_reply",
+ "addr_bcc",
+ "addr_newsgroups",
+ "addr_followup",
+ ]);
+}
+
+/**
+ * With an NNTP account, the 'To' addressing row should be hidden.
+ */
+function check_collapsed_pop_recipient(cwc) {
+ Assert.ok(
+ cwc.window.document
+ .getElementById("addressRowTo")
+ .classList.contains("hidden")
+ );
+}
+
+function add_NNTP_account() {
+ // Create a NNTP server
+ let nntpServer = MailServices.accounts
+ .createIncomingServer(null, "example.nntp.invalid", "nntp")
+ .QueryInterface(Ci.nsINntpIncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox2@example.invalid";
+
+ accountNNTP = MailServices.accounts.createAccount();
+ accountNNTP.incomingServer = nntpServer;
+ accountNNTP.addIdentity(identity);
+ // Now there should be 1 more account.
+ Assert.equal(
+ MailServices.accounts.allServers.length,
+ originalAccountCount + 1
+ );
+}
+
+function remove_NNTP_account() {
+ // Remove our NNTP account to leave the profile clean.
+ MailServices.accounts.removeAccount(accountNNTP);
+ // There should be only the original accounts left.
+ Assert.equal(MailServices.accounts.allServers.length, originalAccountCount);
+}
+
+/**
+ * Bug 399446 & bug 922614
+ * Test that the allowed address types depend on the account type
+ * we are sending from.
+ */
+add_task(async function test_address_types() {
+ // Be sure there is no NNTP account yet.
+ for (let account of MailServices.accounts.accounts) {
+ Assert.notEqual(
+ account.incomingServer.type,
+ "nntp",
+ "There is a NNTP account existing unexpectedly"
+ );
+ }
+
+ // Open compose window on the existing POP3 account.
+ await be_in_folder(accountPOP3.incomingServer.rootFolder);
+ cwc = open_compose_new_mail();
+ check_mail_address_types(cwc.window);
+ close_compose_window(cwc);
+
+ add_NNTP_account();
+
+ // From now on, we should always get all possible address types offered,
+ // regardless of which account is used of composing (bug 922614).
+ await be_in_folder(accountNNTP.incomingServer.rootFolder);
+ cwc = open_compose_new_mail();
+ check_nntp_address_types(cwc.window);
+ check_collapsed_pop_recipient(cwc);
+ close_compose_window(cwc);
+
+ // Now try the same accounts but choosing them in the From dropdown
+ // inside compose window.
+ await be_in_folder(accountPOP3.incomingServer.rootFolder);
+ cwc = open_compose_new_mail();
+ check_nntp_address_types(cwc.window);
+
+ let NNTPidentity = accountNNTP.defaultIdentity.key;
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("msgIdentity"),
+ {},
+ cwc.window.document.getElementById("msgIdentity").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ cwc.window.document.getElementById("msgIdentityPopup"),
+ [{ identitykey: NNTPidentity }]
+ );
+ check_nntp_address_types(cwc.window);
+
+ // Switch back to the POP3 account.
+ let POP3identity = accountPOP3.defaultIdentity.key;
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("msgIdentity"),
+ {},
+ cwc.window.document.getElementById("msgIdentity").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ cwc.window.document.getElementById("msgIdentityPopup"),
+ [{ identitykey: POP3identity }]
+ );
+ check_nntp_address_types(cwc.window);
+
+ close_compose_window(cwc);
+
+ remove_NNTP_account();
+
+ // Now the NNTP account is lost, so we should be back to mail only addresses.
+ await be_in_folder(accountPOP3.incomingServer.rootFolder);
+ cwc = open_compose_new_mail();
+ check_mail_address_types(cwc.window);
+ close_compose_window(cwc);
+});
+
+add_task(async function test_address_suppress_leading_comma_space() {
+ await be_in_folder(accountPOP3.incomingServer.rootFolder);
+ let controller = open_compose_new_mail();
+
+ let addrInput = controller.window.document.getElementById("toAddrInput");
+ Assert.ok(addrInput);
+ Assert.equal(addrInput.value, "");
+
+ // Create a pill.
+ addrInput.value = "person@org";
+ // Comma triggers the pill creation.
+ // Note: the address input should already have focus.
+ EventUtils.synthesizeKey(",", {}, controller.window);
+
+ let addrPill = await TestUtils.waitForCondition(
+ () =>
+ controller.window.document.querySelector(
+ "#toAddrContainer > .address-pill"
+ ),
+ "Pill creation"
+ );
+ Assert.equal(addrInput.value, "");
+ let pillInput = addrPill.querySelector("input");
+ Assert.ok(pillInput);
+
+ // Asserts that the input has the correct exceptional behaviour for 'comma'
+ // and 'space'.
+ async function assertKeyInput(input) {
+ // Since we will be partially testing for a lack of response to the " " and
+ // "," key presses, we first run the tests with the "a" key press to assure
+ // us that the tests would otherwise capture the normal behaviour. This will
+ // also shows us that the comma and space behaviour is exceptional.
+ for (let key of ["a", " ", ","]) {
+ // Clear input.
+ input.value = "";
+ await TestUtils.waitForTick();
+
+ // Type the key in an empty input.
+ let eventPromise = BrowserTestUtils.waitForEvent(input, "keydown");
+ EventUtils.synthesizeKey(key, {}, controller.window);
+ await eventPromise;
+
+ if (key === " " || key === ",") {
+ // Key is suppressed, so the input remains empty.
+ Assert.equal(input.value, "");
+ } else {
+ // Normal behaviour: key is added to the input.
+ Assert.equal(input.value, key);
+ }
+
+ // If the input is not empty, we should still have the normal behaviour.
+ input.value = "z";
+ input.selectionStart = 1;
+ input.SelectionEnd = 1;
+ await TestUtils.waitForTick();
+
+ await BrowserTestUtils.synthesizeKey(
+ key,
+ {},
+ controller.window.browsingContext
+ );
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ Assert.equal(input.value, "z" + key);
+
+ // Test typing the key to replace all the trimmed input.
+ // Sample text with two spaces as start and end. Also includes a 2
+ // character emoji.
+ let someText = " some, text📜 ";
+ for (let selection of [
+ { start: 0, end: 0 },
+ { start: 1, end: 0 },
+ { start: 0, end: 1 },
+ { start: 2, end: 2 },
+ ]) {
+ input.value = someText;
+ input.selectionStart = selection.start;
+ input.selectionEnd = someText.length - selection.end;
+ await TestUtils.waitForTick();
+
+ // Type the key to replace the text.
+ await BrowserTestUtils.synthesizeKey(
+ key,
+ {},
+ controller.window.browsingContext
+ );
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ if (key === " " || key === ",") {
+ // Key is suppressed and input is empty.
+ Assert.equal(input.value, "");
+ } else {
+ // Normal behaviour: key replaces the selected text.
+ Assert.equal(
+ input.value,
+ someText.slice(0, selection.start) +
+ key +
+ someText.slice(someText.length - selection.end)
+ );
+ }
+ }
+
+ // If we do not replace all the trimmed input, we should still have
+ // normal behaviour.
+ input.value = " text ";
+ input.selectionStart = 1;
+ // Select up to 'x'.
+ input.selectionEnd = 5;
+ await TestUtils.waitForTick();
+
+ await BrowserTestUtils.synthesizeKey(
+ key,
+ {},
+ controller.window.browsingContext
+ );
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ Assert.equal(input.value, " " + key + "t ");
+ }
+ }
+
+ // Assert that the address input has the correct behaviour for key presses.
+ // Note: the address input should still have focus.
+ await assertKeyInput(addrInput);
+
+ // Now test the behaviour when editing a pill.
+ // First, we need to get into editing mode by clicking the pill twice.
+ EventUtils.synthesizeMouseAtCenter(
+ addrPill,
+ { clickCount: 1 },
+ controller.window
+ );
+ let clickPromise = BrowserTestUtils.waitForEvent(addrPill, "click");
+ // We do not want a double click, but two separate clicks.
+ EventUtils.synthesizeMouseAtCenter(
+ addrPill,
+ { clickCount: 1 },
+ controller.window
+ );
+ await clickPromise;
+
+ Assert.ok(!pillInput.hidden);
+
+ // Assert that editing a pill has the same behaviour as the address input.
+ await assertKeyInput(pillInput);
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_pill_creation_in_all_fields() {
+ await be_in_folder(accountPOP3.incomingServer.rootFolder);
+ let cwc = open_compose_new_mail();
+
+ let addresses = ["person@org", "foo@address.valid", "invalid", "foo@address"];
+ let subjectField = cwc.window.document.getElementById("msgSubject");
+
+ // Helper method to create multiple pills in a field.
+ async function assertPillsCreationInField(input) {
+ Assert.ok(input);
+ Assert.equal(input.value, "");
+
+ // Write an address in the field.
+ input.value = addresses[0];
+ // Enter triggers the pill creation.
+ EventUtils.synthesizeKey("VK_RETURN", {}, cwc.window);
+ // Assert the pill was created.
+ await TestUtils.waitForCondition(
+ () =>
+ input
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill").length == 1,
+ "Pills created"
+ );
+ // Assert the pill has the correct address.
+ Assert.equal(
+ input
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill")[0].emailAddress,
+ addresses[0]
+ );
+
+ // Write another address in the field.
+ input.value = addresses[1];
+ // Tab triggers the pill creation.
+ EventUtils.synthesizeKey("VK_TAB", {}, cwc.window);
+ // Assert the pill was created.
+ await TestUtils.waitForCondition(
+ () =>
+ input
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill").length == 2,
+ "Pills created"
+ );
+ // Assert the pill has the correct address.
+ Assert.equal(
+ input
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill")[1].emailAddress,
+ addresses[1]
+ );
+
+ // Write an invalid email address in the To field.
+ input.value = addresses[2];
+ // Enter triggers the pill creation.
+ EventUtils.synthesizeKey("VK_RETURN", {}, cwc.window);
+ // Assert that an invalid address pill was created.
+ await TestUtils.waitForCondition(
+ () =>
+ input
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill.invalid-address").length == 1,
+ "Invalid pill created"
+ );
+ // Assert the pill has the correct address.
+ Assert.equal(
+ input
+ .closest(".address-container")
+ .querySelector("mail-address-pill.invalid-address").emailAddress,
+ addresses[2]
+ );
+
+ // Write another address in the field.
+ input.value = addresses[3];
+ // Focusing on another element triggers the pill creation.
+ subjectField.focus();
+ // Assert the pill was created.
+ await TestUtils.waitForCondition(
+ () =>
+ input
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill").length == 4,
+ "Pills created"
+ );
+ // Assert the pill has the correct address.
+ Assert.equal(
+ input
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill")[3].emailAddress,
+ addresses[3]
+ );
+ }
+
+ // The To field is visible and focused by default when the compose window is
+ // first opened.
+ // Test pill creation for the To input field.
+ let toInput = cwc.window.document.getElementById("toAddrInput");
+ await assertPillsCreationInField(toInput);
+
+ // Click on the Cc recipient label.
+ let ccInput = cwc.window.document.getElementById("ccAddrInput");
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_ccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ // The Cc field should now be visible.
+ Assert.ok(
+ !ccInput.closest(".address-row").classList.contains("hidden"),
+ "The Cc field is visible"
+ );
+ // Test pill creation for the Cc input field.
+ await assertPillsCreationInField(ccInput);
+
+ // Click on the Bcc recipient label.
+ let bccInput = cwc.window.document.getElementById("bccAddrInput");
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_bccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ // The Bcc field should now be visible.
+ Assert.ok(
+ !bccInput.closest(".address-row").classList.contains("hidden"),
+ "The Bcc field is visible"
+ );
+ // Test pill creation for the Bcc input field.
+ await assertPillsCreationInField(bccInput);
+
+ // Focus on the Bcc field and hold press the Backspace key.
+ bccInput.focus();
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 5 }, cwc.window);
+
+ // All pills should be deleted, but the focus should remain on the Bcc field.
+ Assert.equal(
+ bccInput.closest(".address-container").querySelectorAll("mail-address-pill")
+ .length,
+ 0,
+ "All pills in the Bcc field have been removed."
+ );
+ Assert.ok(
+ !bccInput.closest(".address-row").classList.contains("hidden"),
+ "The Bcc field is still visible"
+ );
+
+ // Press and hold Backspace again.
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 2 }, cwc.window);
+
+ // Confirm the Bcc field is closed and the focus moved to the Cc field.
+ Assert.ok(
+ bccInput.closest(".address-row").classList.contains("hidden"),
+ "The Bcc field was closed"
+ );
+ Assert.equal(cwc.window.document.activeElement, ccInput);
+
+ // Now we're on the Cc field. Press and hold Backspace to delete all pills.
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 5 }, cwc.window);
+
+ // All pills should be deleted, but the focus should remain on the Cc field.
+ Assert.equal(
+ ccInput.closest(".address-container").querySelectorAll("mail-address-pill")
+ .length,
+ 0,
+ "All pills in the Cc field have been removed."
+ );
+ Assert.ok(
+ !ccInput.closest(".address-row").classList.contains("hidden"),
+ "The Cc field is still visible"
+ );
+
+ // Press and hold Backspace again.
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 2 }, cwc.window);
+
+ // Confirm the Cc field is closed and the focus moved to the To field.
+ Assert.ok(
+ ccInput.closest(".address-row").classList.contains("hidden"),
+ "The Cc field was closed"
+ );
+ Assert.equal(cwc.window.document.activeElement, toInput);
+
+ // Now we're on the To field. Press and hold Backspace to delete all pills.
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 5 }, cwc.window);
+
+ // All pills should be deleted, but the focus should remain on the To field.
+ Assert.equal(
+ toInput.closest(".address-container").querySelectorAll("mail-address-pill")
+ .length,
+ 0,
+ "All pills in the To field have been removed."
+ );
+ Assert.ok(
+ !toInput.closest(".address-row").classList.contains("hidden"),
+ "The To field is still visible"
+ );
+
+ // Press and hold Backspace again.
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 2 }, cwc.window);
+
+ // Long backspace keypress on the To field shouldn't do anything if the field
+ // is empty. Confirm the To field is still visible and the focus stays on the
+ // To field.
+ Assert.ok(
+ !toInput.closest(".address-row").classList.contains("hidden"),
+ "The To field is still visible"
+ );
+ Assert.equal(cwc.window.document.activeElement, toInput);
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_addressing_fields_shortcuts() {
+ await be_in_folder(accountPOP3.incomingServer.rootFolder);
+ let cwc = open_compose_new_mail();
+
+ let addrToInput = cwc.window.document.getElementById("toAddrInput");
+ // The To input field should be empty.
+ Assert.equal(addrToInput.value, "");
+ // The To input field should be the currently focused element.
+ Assert.equal(cwc.window.document.activeElement, addrToInput);
+
+ const modifiers =
+ AppConstants.platform == "macosx"
+ ? { accelKey: true, shiftKey: true }
+ : { ctrlKey: true, shiftKey: true };
+
+ let addrCcInput = cwc.window.document.getElementById("ccAddrInput");
+ let ccRowShownPromise = BrowserTestUtils.waitForCondition(
+ () => !addrCcInput.closest(".address-row").classList.contains("hidden"),
+ "The Cc addressing row is not visible."
+ );
+ // Press the Ctrl/Cmd+Shift+C.
+ EventUtils.synthesizeKey("C", modifiers, cwc.window);
+ // The Cc addressing row should be visible.
+ await ccRowShownPromise;
+ // The Cc input field should be currently focused.
+ Assert.equal(cwc.window.document.activeElement, addrCcInput);
+
+ let addrBccInput = cwc.window.document.getElementById("bccAddrInput");
+ let bccRowShownPromise = BrowserTestUtils.waitForCondition(
+ () => !addrBccInput.closest(".address-row").classList.contains("hidden"),
+ "The Bcc addressing row is not visible."
+ );
+ // Press the Ctrl/Cmd+Shift+B.
+ EventUtils.synthesizeKey("B", modifiers, cwc.window);
+ await bccRowShownPromise;
+ // The Bcc input field should be currently focused.
+ Assert.equal(cwc.window.document.activeElement, addrBccInput);
+
+ // Press the Ctrl/Cmd+Shift+T.
+ EventUtils.synthesizeKey("T", modifiers, cwc.window);
+ // The To input field should be the currently focused element.
+ Assert.equal(cwc.window.document.activeElement, addrToInput);
+
+ // Press the Ctrl/Cmd+Shift+C.
+ EventUtils.synthesizeKey("C", modifiers, cwc.window);
+ // The Cc input field should be currently focused.
+ Assert.equal(cwc.window.document.activeElement, addrCcInput);
+
+ // Press the Ctrl/Cmd+Shift+B.
+ EventUtils.synthesizeKey("B", modifiers, cwc.window);
+ // The Bcc input field should be currently focused.
+ Assert.equal(cwc.window.document.activeElement, addrBccInput);
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_pill_deletion_and_focus() {
+ await be_in_folder(accountPOP3.incomingServer.rootFolder);
+ let cwc = open_compose_new_mail();
+
+ // When the compose window is opened, the focus should be on the To field.
+ let toInput = cwc.window.document.getElementById("toAddrInput");
+ Assert.equal(cwc.window.document.activeElement, toInput);
+
+ const modifiers =
+ AppConstants.platform == "macosx" ? { accelKey: true } : { ctrlKey: true };
+ const addresses = "person@org, foo@address.valid, invalid, foo@address";
+
+ // Test the To field.
+ test_deletion_and_focus_on_input(cwc, toInput, addresses, modifiers);
+
+ // Reveal and test the Cc field.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_ccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ test_deletion_and_focus_on_input(
+ cwc,
+ cwc.window.document.getElementById("ccAddrInput"),
+ addresses,
+ modifiers
+ );
+
+ // Reveal and test the Bcc field.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_bccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ test_deletion_and_focus_on_input(
+ cwc,
+ cwc.window.document.getElementById("bccAddrInput"),
+ addresses,
+ modifiers
+ );
+
+ close_compose_window(cwc);
+});
+
+function test_deletion_and_focus_on_input(cwc, input, addresses, modifiers) {
+ // Focus on the input before adding anything to be sure keyboard shortcut are
+ // triggered from the right element.
+ input.focus();
+
+ // Fill the input field with a long of string of comma separated addresses.
+ input.value = addresses;
+
+ // Enter triggers the pill creation.
+ EventUtils.synthesizeKey("VK_RETURN", {}, cwc.window);
+
+ let container = input.closest(".address-container");
+ // We should now have 4 pills.
+ Assert.equal(
+ container.querySelectorAll("mail-address-pill").length,
+ 4,
+ "All pills in the field have been created."
+ );
+
+ // One pill should be flagged as invalid.
+ Assert.equal(
+ container.querySelectorAll("mail-address-pill.invalid-address").length,
+ 1,
+ "One created pill is invalid."
+ );
+
+ // After pills creation, the same field should be still focused.
+ Assert.equal(cwc.window.document.activeElement, input);
+
+ // Keypress left arrow should focus and select the last created pill.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, cwc.window);
+ Assert.equal(
+ container.querySelectorAll("mail-address-pill[selected]").length,
+ 1,
+ "One pill is currently selected."
+ );
+
+ // Pressing delete should delete the selected pill and move the focus back to
+ // the input.
+ EventUtils.synthesizeKey("KEY_Delete", {}, cwc.window);
+ Assert.equal(
+ container.querySelectorAll("mail-address-pill").length,
+ 3,
+ "One pill correctly deleted."
+ );
+ Assert.equal(cwc.window.document.activeElement, input);
+
+ // Keypress left arrow to select the last available pill.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, cwc.window);
+ Assert.equal(
+ container.querySelectorAll("mail-address-pill[selected]").length,
+ 1,
+ "One pill is currently selected."
+ );
+
+ // BackSpace should delete the pill and focus on the previous adjacent pill.
+ EventUtils.synthesizeKey("KEY_Backspace", {}, cwc.window);
+ Assert.equal(
+ container.querySelectorAll("mail-address-pill").length,
+ 2,
+ "One pill correctly deleted."
+ );
+ let selectedPill = container.querySelector("mail-address-pill[selected]");
+ Assert.equal(cwc.window.document.activeElement, selectedPill);
+
+ // Pressing CTRL+A should select all pills.
+ EventUtils.synthesizeKey("a", modifiers, cwc.window);
+ Assert.equal(
+ container.querySelectorAll("mail-address-pill[selected]").length,
+ 2,
+ "All remaining 2 pills are currently selected."
+ );
+
+ // BackSpace should delete all pills and focus on empty inptu field.
+ EventUtils.synthesizeKey("KEY_Backspace", {}, cwc.window);
+ Assert.equal(
+ container.querySelectorAll("mail-address-pill").length,
+ 0,
+ "All pills have been deleted."
+ );
+ Assert.equal(cwc.window.document.activeElement, input);
+}
diff --git a/comm/mail/test/browser/composition/browser_attachment.js b/comm/mail/test/browser/composition/browser_attachment.js
new file mode 100644
index 0000000000..23f8e9a089
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_attachment.js
@@ -0,0 +1,995 @@
+/* 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/. */
+
+/**
+ * Tests attachment handling functionality of the message compose window.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var {
+ add_attachments,
+ close_compose_window,
+ delete_attachment,
+ open_compose_new_mail,
+ open_compose_with_forward,
+ open_compose_with_forward_as_attachments,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ select_click_row,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { plan_for_modal_dialog, wait_for_modal_dialog } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var messenger;
+var folder;
+var epsilon;
+var filePrefix;
+
+var rawAttachment =
+ "Can't make the frug contest, Helen; stomach's upset. I'll fix you, " +
+ "Ubik! Ubik drops you back in the thick of things fast. Taken as " +
+ "directed, Ubik speeds relief to head and stomach. Remember: Ubik is " +
+ "only seconds away. Avoid prolonged use.";
+
+var b64Attachment =
+ "iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAABHNCSVQICAgIfAhkiAAAAAlwS" +
+ "FlzAAAN1wAADdcBQiibeAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAA" +
+ "A5SURBVCiRY/z//z8DKYCJJNXkaGBgYGD4D8NQ5zUgiTVAxeBqSLaBkVRPM0KtIhrQ3km0jwe" +
+ "SNQAAlmAY+71EgFoAAAAASUVORK5CYII=";
+var b64Size = 188;
+
+add_setup(async function () {
+ folder = await create_folder("ComposeAttachmentA");
+
+ messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+ /* Today's gory details (thanks to Jonathan Protzenko): libmime somehow
+ * counts the trailing newline for an attachment MIME part. Most of the time,
+ * assuming attachment has N bytes (no matter what's inside, newlines or
+ * not), libmime will return N + 1 bytes. On Linux and Mac, this always
+ * holds. However, on Windows, if the attachment is not encoded (that is, is
+ * inline text), libmime will return N + 2 bytes. Since we're dealing with
+ * forwarded message data here, the bonus byte(s) appear twice.
+ */
+ epsilon = AppConstants.platform == "win" ? 4 : 2;
+ filePrefix = AppConstants.platform == "win" ? "file:///C:/" : "file:///";
+
+ // create some messages that have various types of attachments
+ let messages = [
+ // no attachment
+ {},
+ // raw attachment
+ {
+ attachments: [{ body: rawAttachment, filename: "ubik.txt", format: "" }],
+ },
+ // b64-encoded image attachment
+ {
+ attachments: [
+ {
+ body: b64Attachment,
+ contentType: "image/png",
+ filename: "lines.png",
+ encoding: "base64",
+ format: "",
+ },
+ ],
+ },
+ ];
+
+ for (let i = 0; i < messages.length; i++) {
+ await add_message_to_folder([folder], create_message(messages[i]));
+ }
+});
+
+/**
+ * Make sure that the attachment's size is what we expect
+ *
+ * @param controller the controller for the compose window
+ * @param index the attachment to examine, as an index into the listbox
+ * @param expectedSize the expected size of the attachment, in bytes
+ */
+function check_attachment_size(controller, index, expectedSize) {
+ let bucket = controller.window.document.getElementById("attachmentBucket");
+ let node = bucket.querySelectorAll("richlistitem.attachmentItem")[index];
+
+ // First, let's check that the attachment size is correct
+ let size = node.attachment.size;
+ if (Math.abs(size - expectedSize) > epsilon) {
+ throw new Error(
+ "Reported attachment size (" +
+ size +
+ ") not within epsilon " +
+ "of actual attachment size (" +
+ expectedSize +
+ ")"
+ );
+ }
+
+ // Next, make sure that the formatted size in the label is correct
+ let formattedSize = node.getAttribute("size");
+ let expectedFormattedSize = messenger.formatFileSize(size);
+ if (formattedSize != expectedFormattedSize) {
+ throw new Error(
+ "Formatted attachment size (" +
+ formattedSize +
+ ") does not " +
+ "match expected value (" +
+ expectedFormattedSize +
+ ")"
+ );
+ }
+}
+
+/**
+ * Make sure that the attachment's size is not displayed
+ *
+ * @param controller the controller for the compose window
+ * @param index the attachment to examine, as an index into the listbox
+ */
+function check_no_attachment_size(controller, index) {
+ let bucket = controller.window.document.getElementById("attachmentBucket");
+ let node = bucket.querySelectorAll("richlistitem.attachmentItem")[index];
+
+ if (node.attachment.size != -1) {
+ throw new Error("attachment.size attribute should be -1!");
+ }
+
+ // If there's no size, the size attribute is empty.
+ if (node.getAttribute("size") != "") {
+ throw new Error("Attachment size should not be displayed!");
+ }
+}
+
+/**
+ * Make sure that the total size of all attachments is what we expect.
+ *
+ * @param controller the controller for the compose window
+ * @param count the expected number of attachments
+ */
+function check_total_attachment_size(controller, count) {
+ let bucket = controller.window.document.getElementById("attachmentBucket");
+ let nodes = bucket.querySelectorAll("richlistitem.attachmentItem");
+ let sizeNode = controller.window.document.getElementById(
+ "attachmentBucketSize"
+ );
+
+ if (nodes.length != count) {
+ throw new Error(
+ "Saw " + nodes.length + " attachments, but expected " + count
+ );
+ }
+
+ let size = 0;
+ for (let i = 0; i < nodes.length; i++) {
+ let currSize = nodes[i].attachment.size;
+ if (currSize != -1) {
+ size += currSize;
+ }
+ }
+
+ // Next, make sure that the formatted size in the label is correct
+ let expectedFormattedSize = messenger.formatFileSize(size);
+ if (sizeNode.textContent != expectedFormattedSize) {
+ throw new Error(
+ "Formatted attachment size (" +
+ sizeNode.textContent +
+ ") does not " +
+ "match expected value (" +
+ expectedFormattedSize +
+ ")"
+ );
+ }
+}
+
+add_task(function test_file_attachment() {
+ let cwc = open_compose_new_mail();
+
+ let url = filePrefix + "some/file/here.txt";
+ let size = 1234;
+
+ add_attachments(cwc, url, size);
+ check_attachment_size(cwc, 0, size);
+ check_total_attachment_size(cwc, 1);
+
+ close_compose_window(cwc);
+});
+
+add_task(function test_webpage_attachment() {
+ let cwc = open_compose_new_mail();
+
+ add_attachments(cwc, "https://www.mozilla.org/");
+ check_no_attachment_size(cwc, 0);
+ check_total_attachment_size(cwc, 1);
+
+ close_compose_window(cwc);
+});
+
+add_task(function test_multiple_attachments() {
+ let cwc = open_compose_new_mail();
+
+ let files = [
+ { name: "foo.txt", size: 1234 },
+ { name: "bar.txt", size: 5678 },
+ { name: "baz.txt", size: 9012 },
+ ];
+ for (let i = 0; i < files.length; i++) {
+ add_attachments(cwc, filePrefix + files[i].name, files[i].size);
+ check_attachment_size(cwc, i, files[i].size);
+ }
+
+ check_total_attachment_size(cwc, files.length);
+ close_compose_window(cwc);
+});
+
+add_task(function test_delete_attachments() {
+ let cwc = open_compose_new_mail();
+
+ let files = [
+ { name: "foo.txt", size: 1234 },
+ { name: "bar.txt", size: 5678 },
+ { name: "baz.txt", size: 9012 },
+ ];
+ for (let i = 0; i < files.length; i++) {
+ add_attachments(cwc, filePrefix + files[i].name, files[i].size);
+ check_attachment_size(cwc, i, files[i].size);
+ }
+
+ delete_attachment(cwc, 0);
+ check_total_attachment_size(cwc, files.length - 1);
+
+ close_compose_window(cwc);
+});
+
+function subtest_rename_attachment(cwc) {
+ cwc.window.document.getElementById("loginTextbox").value = "renamed.txt";
+ cwc.window.document.querySelector("dialog").getButton("accept").doCommand();
+}
+
+add_task(function test_rename_attachment() {
+ let cwc = open_compose_new_mail();
+
+ let url = filePrefix + "some/file/here.txt";
+ let size = 1234;
+
+ add_attachments(cwc, url, size);
+
+ // Now, rename the attachment.
+ let bucket = cwc.window.document.getElementById("attachmentBucket");
+ let node = bucket.querySelector("richlistitem.attachmentItem");
+ EventUtils.synthesizeMouseAtCenter(node, {}, node.ownerGlobal);
+ plan_for_modal_dialog("commonDialogWindow", subtest_rename_attachment);
+ cwc.window.RenameSelectedAttachment();
+ wait_for_modal_dialog("commonDialogWindow");
+
+ Assert.equal(node.getAttribute("name"), "renamed.txt");
+
+ check_attachment_size(cwc, 0, size);
+ check_total_attachment_size(cwc, 1);
+
+ close_compose_window(cwc);
+});
+
+function subtest_open_attachment(cwc) {
+ cwc.window.document.querySelector("dialog").getButton("cancel").doCommand();
+}
+
+add_task(function test_open_attachment() {
+ let cwc = open_compose_new_mail();
+
+ // set up our external file for attaching
+ let file = new FileUtils.File(getTestFilePath("data/attachment.txt"));
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let url = fileHandler.getURLSpecFromActualFile(file);
+ let size = file.fileSize;
+
+ add_attachments(cwc, url, size);
+
+ // Now, open the attachment.
+ let bucket = cwc.window.document.getElementById("attachmentBucket");
+ let node = bucket.querySelector("richlistitem.attachmentItem");
+ plan_for_modal_dialog("unknownContentTypeWindow", subtest_open_attachment);
+ EventUtils.synthesizeMouseAtCenter(node, { clickCount: 2 }, node.ownerGlobal);
+ wait_for_modal_dialog("unknownContentTypeWindow");
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_forward_raw_attachment() {
+ await be_in_folder(folder);
+ select_click_row(1);
+
+ let cwc = open_compose_with_forward();
+ check_attachment_size(cwc, 0, rawAttachment.length);
+ check_total_attachment_size(cwc, 1);
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_forward_b64_attachment() {
+ await be_in_folder(folder);
+ select_click_row(2);
+
+ let cwc = open_compose_with_forward();
+ check_attachment_size(cwc, 0, b64Size);
+ check_total_attachment_size(cwc, 1);
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_forward_message_as_attachment() {
+ await be_in_folder(folder);
+ let curMessage = select_click_row(0);
+
+ let cwc = open_compose_with_forward_as_attachments();
+ check_attachment_size(cwc, 0, curMessage.messageSize);
+ check_total_attachment_size(cwc, 1);
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_forward_message_with_attachments_as_attachment() {
+ await be_in_folder(folder);
+ let curMessage = select_click_row(1);
+
+ let cwc = open_compose_with_forward_as_attachments();
+ check_attachment_size(cwc, 0, curMessage.messageSize);
+ check_total_attachment_size(cwc, 1);
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Check that the compose window has the attachments we expect.
+ *
+ * @param aController The controller for the compose window
+ * @param aNames An array of attachment names that are expected
+ */
+function check_attachment_names(aController, aNames) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ Assert.equal(aNames.length, bucket.itemCount);
+ for (let i = 0; i < aNames.length; i++) {
+ Assert.equal(bucket.getItemAtIndex(i).getAttribute("name"), aNames[i]);
+ }
+}
+
+/**
+ * Execute a test of attachment reordering actions and check the resulting order.
+ *
+ * @param aCwc The controller for the compose window
+ * @param aInitialAttachmentNames An array of attachment names specifying the
+ * initial set of attachments to be created
+ * @param aReorder_actions An array of objects specifying a reordering action:
+ * { select: array of attachment item indexes to select,
+ * button: ID of button to click in the reordering menu,
+ * key: keycode of key to press instead of a click,
+ * key_modifiers: { accelKey: bool, ctrlKey: bool
+ * shiftKey: bool, altKey: bool, etc.},
+ * result: an array of attachment names in the new
+ * order that should result
+ * }
+ * @param openPanel {boolean} - Whether to open reorderAttachmentsPanel for the test
+ */
+async function subtest_reordering(
+ aCwc,
+ aInitialAttachmentNames,
+ aReorder_actions,
+ aOpenPanel = true
+) {
+ let bucket = aCwc.window.document.getElementById("attachmentBucket");
+ let panel;
+
+ // Create a set of attachments for the test.
+ const size = 1234;
+ for (let name of aInitialAttachmentNames) {
+ add_attachments(aCwc, filePrefix + name, size);
+ }
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.equal(bucket.itemCount, aInitialAttachmentNames.length);
+ check_attachment_names(aCwc, aInitialAttachmentNames);
+
+ if (aOpenPanel) {
+ // Bring up the reordering panel.
+ aCwc.window.showReorderAttachmentsPanel();
+ await new Promise(resolve => setTimeout(resolve));
+ panel = aCwc.window.document.getElementById("reorderAttachmentsPanel");
+ await wait_for_popup_to_open(panel);
+ }
+
+ for (let action of aReorder_actions) {
+ // Ensure selection.
+ bucket.clearSelection();
+ for (let itemIndex of action.select) {
+ bucket.addItemToSelection(bucket.getItemAtIndex(itemIndex));
+ }
+ // Take action.
+ if ("button" in action) {
+ EventUtils.synthesizeMouseAtCenter(
+ aCwc.window.document.getElementById(action.button),
+ {},
+ aCwc.window.document.getElementById(action.button).ownerGlobal
+ );
+ } else if ("key" in action) {
+ EventUtils.synthesizeKey(action.key, action.key_modifiers, aCwc.window);
+ }
+ await new Promise(resolve => setTimeout(resolve));
+ // Check result.
+ check_attachment_names(aCwc, action.result);
+ }
+
+ if (aOpenPanel) {
+ // Close the panel.
+ panel.hidePopup();
+ utils.waitFor(
+ () => panel.state == "closed",
+ "Reordering panel didn't close"
+ );
+ }
+
+ // Clean up for a new set of attachments.
+ aCwc.window.RemoveAllAttachments();
+}
+
+/**
+ * Bug 663695, Bug 1417856, Bug 1426344, Bug 1425891, Bug 1427037.
+ * Check basic and advanced attachment reordering operations.
+ * This is the main function of this test.
+ */
+add_task(async function test_attachment_reordering() {
+ let cwc = open_compose_new_mail();
+ let editorEl = cwc.window.GetCurrentEditorElement();
+ let bucket = cwc.window.document.getElementById("attachmentBucket");
+ let panel = cwc.window.document.getElementById("reorderAttachmentsPanel");
+ // const openReorderPanelModifiers =
+ // (AppConstants.platform == "macosx") ? { controlKey: true }
+ // : { altKey: true };
+
+ // First, some checks if the 'Reorder Attachments' panel
+ // opens and closes correctly.
+
+ // Create two attachments as otherwise the reordering panel won't open.
+ const size = 1234;
+ const initialAttachmentNames_0 = ["A1", "A2"];
+ for (let name of initialAttachmentNames_0) {
+ add_attachments(cwc, filePrefix + name, size);
+ await new Promise(resolve => setTimeout(resolve));
+ }
+ Assert.equal(bucket.itemCount, initialAttachmentNames_0.length);
+ check_attachment_names(cwc, initialAttachmentNames_0);
+
+ // Show 'Reorder Attachments' panel via mouse clicks.
+ let contextMenu = cwc.window.document.getElementById(
+ "msgComposeAttachmentItemContext"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ bucket.getItemAtIndex(1),
+ { type: "contextmenu" },
+ cwc.window
+ );
+ await shownPromise;
+ contextMenu.activateItem(
+ cwc.window.document.getElementById("composeAttachmentContext_reorderItem")
+ );
+ await wait_for_popup_to_open(panel);
+
+ // Click on the editor which should close the panel.
+ EventUtils.synthesizeMouseAtCenter(editorEl, {}, editorEl.ownerGlobal);
+ utils.waitFor(
+ () => panel.state == "closed",
+ "Reordering panel didn't close when editor was clicked."
+ );
+
+ // Clean up for a new set of attachments.
+ cwc.window.RemoveAllAttachments();
+
+ // Define checks for various moving operations.
+ // Check 1: basic, mouse-only.
+ const initialAttachmentNames_1 = ["a", "C", "B", "b", "bb", "x"];
+ const reorderActions_1 = [
+ {
+ select: [1, 2, 3],
+ button: "btn_sortAttachmentsToggle",
+ result: ["a", "b", "B", "C", "bb", "x"],
+ },
+ {
+ select: [4],
+ button: "btn_moveAttachmentLeft",
+ result: ["a", "b", "B", "bb", "C", "x"],
+ },
+ {
+ select: [5],
+ button: "btn_moveAttachmentFirst",
+ result: ["x", "a", "b", "B", "bb", "C"],
+ },
+ {
+ select: [0],
+ button: "btn_moveAttachmentRight",
+ result: ["a", "x", "b", "B", "bb", "C"],
+ },
+ {
+ select: [1],
+ button: "btn_moveAttachmentLast",
+ result: ["a", "b", "B", "bb", "C", "x"],
+ },
+ {
+ select: [1, 3],
+ button: "btn_moveAttachmentBundleUp",
+ result: ["a", "b", "bb", "B", "C", "x"],
+ },
+ // Bug 1417856
+ {
+ select: [2],
+ button: "btn_sortAttachmentsToggle",
+ result: ["a", "b", "B", "bb", "C", "x"],
+ },
+ ];
+
+ // Check 2: basic and advanced, mouse-only.
+ const initialAttachmentNames_2 = [
+ "a",
+ "x",
+ "C",
+ "y1",
+ "y2",
+ "B",
+ "b",
+ "z",
+ "bb",
+ ];
+ const reorderActions_2 = [
+ // For starters: moving a single attachment around in the list.
+ {
+ select: [1],
+ button: "btn_moveAttachmentLeft",
+ result: ["x", "a", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+ {
+ select: [0],
+ button: "btn_moveAttachmentLast",
+ result: ["a", "C", "y1", "y2", "B", "b", "z", "bb", "x"],
+ },
+ {
+ select: [8],
+ button: "btn_moveAttachmentFirst",
+ result: ["x", "a", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+ {
+ select: [0],
+ button: "btn_moveAttachmentRight",
+ result: ["a", "x", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+
+ // Moving multiple, disjunct selection with inner block up/down as-is.
+ // This feature can be useful for multiple disjunct selection patterns
+ // in an alternating list of attachments like
+ // {photo1.jpg, description1.txt, photo2.jpg, description2.txt},
+ // where the order of alternation should be inverted to become
+ // {description1.txt, photo1.jpg, description2.txt, photo2.txt}.
+ {
+ select: [1, 3, 4, 7],
+ button: "btn_moveAttachmentRight",
+ result: ["a", "C", "x", "B", "y1", "y2", "b", "bb", "z"],
+ },
+ {
+ select: [2, 4, 5, 8],
+ button: "btn_moveAttachmentLeft",
+ result: ["a", "x", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+ {
+ select: [1, 3, 4, 7],
+ button: "btn_moveAttachmentLeft",
+ result: ["x", "a", "y1", "y2", "C", "B", "z", "b", "bb"],
+ },
+
+ // Folding multiple, disjunct selection with inner block towards top/bottom.
+ {
+ select: [0, 2, 3, 6],
+ button: "btn_moveAttachmentLeft",
+ result: ["x", "y1", "y2", "a", "C", "z", "B", "b", "bb"],
+ },
+ {
+ select: [0, 1, 2, 5],
+ button: "btn_moveAttachmentLeft",
+ result: ["x", "y1", "y2", "a", "z", "C", "B", "b", "bb"],
+ },
+ {
+ select: [0, 1, 2, 4],
+ button: "btn_moveAttachmentLeft",
+ result: ["x", "y1", "y2", "z", "a", "C", "B", "b", "bb"],
+ },
+ {
+ select: [3, 5, 6, 8],
+ button: "btn_moveAttachmentRight",
+ result: ["x", "y1", "y2", "a", "z", "b", "C", "B", "bb"],
+ },
+ {
+ select: [4, 6, 7, 8],
+ button: "btn_moveAttachmentRight",
+ result: ["x", "y1", "y2", "a", "b", "z", "C", "B", "bb"],
+ },
+
+ // Prepare scenario for and test 'Group together' (upwards).
+ {
+ select: [1, 2],
+ button: "btn_moveAttachmentRight",
+ result: ["x", "a", "y1", "y2", "b", "z", "C", "B", "bb"],
+ },
+ {
+ select: [0, 2, 3, 5],
+ button: "btn_moveAttachmentRight",
+ result: ["a", "x", "b", "y1", "y2", "C", "z", "B", "bb"],
+ },
+ {
+ select: [1, 3, 4, 6],
+ button: "btn_moveAttachmentBundleUp",
+ result: ["a", "x", "y1", "y2", "z", "b", "C", "B", "bb"],
+ },
+ // 'Group together' (downwards) is not tested here because it is
+ // only available via keyboard shortcuts, e.g. Alt+Cursor Right.
+
+ // Sort selected attachments only.
+ // Unsorted multiple selection must be collapsed upwards first if disjunct,
+ // then sorted ascending.
+ {
+ select: [0, 5, 6, 8],
+ button: "btn_sortAttachmentsToggle",
+ result: ["a", "b", "bb", "C", "x", "y1", "y2", "z", "B"],
+ },
+ // Sorted multiple block selection must be sorted the other way round.
+ {
+ select: [0, 1, 2, 3],
+ button: "btn_sortAttachmentsToggle",
+ result: ["C", "bb", "b", "a", "x", "y1", "y2", "z", "B"],
+ },
+ // Sorted, multiple, disjunct selection must just be collapsed upwards.
+ {
+ select: [3, 8],
+ button: "btn_sortAttachmentsToggle",
+ result: ["C", "bb", "b", "a", "B", "x", "y1", "y2", "z"],
+ },
+ {
+ select: [0, 2, 3],
+ button: "btn_sortAttachmentsToggle",
+ result: ["C", "b", "a", "bb", "B", "x", "y1", "y2", "z"],
+ },
+
+ // Bug 1417856: Sort all attachments when 1 or no attachment selected.
+ {
+ select: [1],
+ button: "btn_sortAttachmentsToggle",
+ result: ["a", "b", "B", "bb", "C", "x", "y1", "y2", "z"],
+ },
+ {
+ select: [],
+ button: "btn_sortAttachmentsToggle",
+ result: ["z", "y2", "y1", "x", "C", "bb", "B", "b", "a"],
+ },
+
+ // Collapsing multiple, disjunct selection with inner block to top/bottom.
+ {
+ select: [3, 5, 6, 8],
+ button: "btn_moveAttachmentFirst",
+ result: ["x", "bb", "B", "a", "z", "y2", "y1", "C", "b"],
+ },
+ {
+ select: [0, 2, 3, 7],
+ button: "btn_moveAttachmentLast",
+ result: ["bb", "z", "y2", "y1", "b", "x", "B", "a", "C"],
+ },
+ ];
+
+ // Check 3: basic and advanced, keyboard-only.
+ const initialAttachmentNames_3 = [
+ "a",
+ "x",
+ "C",
+ "y1",
+ "y2",
+ "B",
+ "b",
+ "z",
+ "bb",
+ ];
+ const modAlt = { altKey: true };
+ const modifiers2 =
+ AppConstants.platform == "macosx"
+ ? { accelKey: true, altKey: true }
+ : { altKey: true };
+ const reorderActions_3 = [
+ // For starters: moving a single attachment around in the list.
+ {
+ select: [1],
+ // key_moveAttachmentLeft
+ key: "VK_LEFT",
+ key_modifiers: modAlt,
+ result: ["x", "a", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+ {
+ select: [0],
+ // key_moveAttachmentBottom
+ key: AppConstants.platform == "macosx" ? "VK_DOWN" : "VK_END",
+ key_modifiers: modifiers2,
+ result: ["a", "C", "y1", "y2", "B", "b", "z", "bb", "x"],
+ },
+ {
+ select: [8],
+ // key_moveAttachmentTop
+ key: AppConstants.platform == "macosx" ? "VK_UP" : "VK_HOME",
+ key_modifiers: modifiers2,
+ result: ["x", "a", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+ {
+ select: [0],
+ // key_moveAttachmentBottom2 (secondary shortcut on MAC, same as Win primary)
+ key: "VK_END",
+ key_modifiers: modAlt,
+ result: ["a", "C", "y1", "y2", "B", "b", "z", "bb", "x"],
+ },
+ {
+ select: [8],
+ // key_moveAttachmentTop2 (secondary shortcut on MAC, same as Win primary)
+ key: "VK_HOME",
+ key_modifiers: modAlt,
+ result: ["x", "a", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+ {
+ select: [0],
+ // key_moveAttachmentRight
+ key: "VK_RIGHT",
+ key_modifiers: modAlt,
+ result: ["a", "x", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+
+ // Moving multiple, disjunct selection with inner block up/down as-is.
+ // This feature can be useful for multiple disjunct selection patterns
+ // in an alternating list of attachments like
+ // {photo1.jpg, description1.txt, photo2.jpg, description2.txt},
+ // where the order of alternation should be inverted to become
+ // {description1.txt, photo1.jpg, description2.txt, photo2.txt}.
+ {
+ select: [1, 3, 4, 7],
+ // key_moveAttachmentRight
+ key: "VK_RIGHT",
+ key_modifiers: modAlt,
+ result: ["a", "C", "x", "B", "y1", "y2", "b", "bb", "z"],
+ },
+ {
+ select: [2, 4, 5, 8],
+ // key_moveAttachmentLeft
+ key: "VK_LEFT",
+ key_modifiers: modAlt,
+ result: ["a", "x", "C", "y1", "y2", "B", "b", "z", "bb"],
+ },
+ {
+ select: [1, 3, 4, 7],
+ // key_moveAttachmentLeft
+ key: "VK_LEFT",
+ key_modifiers: modAlt,
+ result: ["x", "a", "y1", "y2", "C", "B", "z", "b", "bb"],
+ },
+
+ // Folding multiple, disjunct selection with inner block towards top/bottom.
+ {
+ select: [0, 2, 3, 6],
+ // key_moveAttachmentLeft
+ key: "VK_LEFT",
+ key_modifiers: modAlt,
+ result: ["x", "y1", "y2", "a", "C", "z", "B", "b", "bb"],
+ },
+ {
+ select: [0, 1, 2, 5],
+ // key_moveAttachmentLeft
+ key: "VK_LEFT",
+ key_modifiers: modAlt,
+ result: ["x", "y1", "y2", "a", "z", "C", "B", "b", "bb"],
+ },
+ {
+ select: [0, 1, 2, 4],
+ // key_moveAttachmentLeft
+ key: "VK_LEFT",
+ key_modifiers: modAlt,
+ result: ["x", "y1", "y2", "z", "a", "C", "B", "b", "bb"],
+ },
+ {
+ select: [3, 5, 6, 8],
+ // key_moveAttachmentRight
+ key: "VK_RIGHT",
+ key_modifiers: modAlt,
+ result: ["x", "y1", "y2", "a", "z", "b", "C", "B", "bb"],
+ },
+ {
+ select: [4, 6, 7, 8],
+ // key_moveAttachmentRight
+ key: "VK_RIGHT",
+ key_modifiers: modAlt,
+ result: ["x", "y1", "y2", "a", "b", "z", "C", "B", "bb"],
+ },
+
+ // Prepare scenario for and test 'Group together' (upwards/downwards).
+ {
+ select: [1, 2],
+ // key_moveAttachmentRight
+ key: "VK_RIGHT",
+ key_modifiers: modAlt,
+ result: ["x", "a", "y1", "y2", "b", "z", "C", "B", "bb"],
+ },
+ {
+ select: [0, 2, 3, 5],
+ // key_moveAttachmentRight
+ key: "VK_RIGHT",
+ key_modifiers: modAlt,
+ result: ["a", "x", "b", "y1", "y2", "C", "z", "B", "bb"],
+ },
+ {
+ select: [1, 3, 4, 6],
+ // key_moveAttachmentBundleUp
+ key: "VK_UP",
+ key_modifiers: modAlt,
+ result: ["a", "x", "y1", "y2", "z", "b", "C", "B", "bb"],
+ },
+ {
+ select: [5, 6],
+ // key_moveAttachmentLeft
+ key: "VK_LEFT",
+ key_modifiers: modAlt,
+ result: ["a", "x", "y1", "y2", "b", "C", "z", "B", "bb"],
+ },
+ {
+ select: [0, 4, 5, 7],
+ // key_moveAttachmentBundleDown
+ key: "VK_DOWN",
+ key_modifiers: modAlt,
+ result: ["x", "y1", "y2", "z", "a", "b", "C", "B", "bb"],
+ },
+
+ // Collapsing multiple, disjunct selection with inner block to top/bottom.
+ {
+ select: [0, 4, 5, 7],
+ // key_moveAttachmentTop
+ key: AppConstants.platform == "macosx" ? "VK_UP" : "VK_HOME",
+ key_modifiers: modifiers2,
+ result: ["x", "a", "b", "B", "y1", "y2", "z", "C", "bb"],
+ },
+ {
+ select: [0, 4, 5, 6],
+ // key_moveAttachmentBottom
+ key: AppConstants.platform == "macosx" ? "VK_DOWN" : "VK_END",
+ key_modifiers: modifiers2,
+ result: ["a", "b", "B", "C", "bb", "x", "y1", "y2", "z"],
+ },
+ {
+ select: [0, 1, 3, 4],
+ // key_moveAttachmentBottom2 (secondary shortcut on MAC, same as Win primary)
+ key: "VK_END",
+ key_modifiers: modAlt,
+ result: ["B", "x", "y1", "y2", "z", "a", "b", "C", "bb"],
+ },
+ {
+ select: [5, 6, 7, 8],
+ // key_moveAttachmentTop2 (secondary shortcut on MAC, same as Win primary)
+ key: "VK_HOME",
+ key_modifiers: modAlt,
+ result: ["a", "b", "C", "bb", "B", "x", "y1", "y2", "z"],
+ },
+ ];
+
+ // Check 4: Alt+Y keyboard shortcut for sorting (Bug 1425891).
+ const initialAttachmentNames_4 = [
+ "a",
+ "x",
+ "C",
+ "y1",
+ "y2",
+ "B",
+ "b",
+ "z",
+ "bb",
+ ];
+
+ const reorderActions_4 = [
+ {
+ select: [1],
+ // key_sortAttachmentsToggle
+ key: "y",
+ key_modifiers: modAlt,
+ result: ["a", "b", "B", "bb", "C", "x", "y1", "y2", "z"],
+ },
+ ];
+
+ // Execute the tests of reordering actions as defined above.
+ await subtest_reordering(cwc, initialAttachmentNames_1, reorderActions_1);
+ await subtest_reordering(cwc, initialAttachmentNames_2, reorderActions_2);
+ // Check 3 (keyboard-only) with panel open.
+ await subtest_reordering(cwc, initialAttachmentNames_3, reorderActions_3);
+ // Check 3 (keyboard-only) without panel.
+ await subtest_reordering(
+ cwc,
+ initialAttachmentNames_3,
+ reorderActions_3,
+ false
+ );
+ // Check 4 (Alt+Y keyboard shortcut for sorting) without panel.
+ await subtest_reordering(
+ cwc,
+ initialAttachmentNames_4,
+ reorderActions_4,
+ false
+ );
+ // Check 4 (Alt+Y keyboard shortcut for sorting) with panel open.
+ await subtest_reordering(cwc, initialAttachmentNames_4, reorderActions_4);
+ // XXX When the root problem of bug 1425891 has been found and fixed, we should
+ // test here if the panel stays open as it should, esp. on Windows.
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_restore_attachment_bucket_height() {
+ let cwc = open_compose_new_mail();
+
+ let attachmentArea = cwc.window.document.getElementById("attachmentArea");
+ let attachmentBucket = cwc.window.document.getElementById("attachmentBucket");
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(attachmentArea),
+ "Attachment area should be hidden initially with no attachments"
+ );
+
+ // Add 9 attachments to open a pane least 2 rows height.
+ let files = [
+ { name: "foo.txt", size: 1234 },
+ { name: "bar.txt", size: 5678 },
+ { name: "baz.txt", size: 9012 },
+ { name: "foo2.txt", size: 1234 },
+ { name: "bar2.txt", size: 5678 },
+ { name: "baz2.txt", size: 9012 },
+ { name: "foo3.txt", size: 1234 },
+ { name: "bar3.txt", size: 5678 },
+ { name: "baz3.txt", size: 9012 },
+ ];
+ for (let i = 0; i < files.length; i++) {
+ add_attachments(cwc, filePrefix + files[i].name, files[i].size);
+ }
+
+ // Store the height of the attachment bucket.
+ let heightBefore = attachmentBucket.getBoundingClientRect().height;
+
+ let modifiers =
+ AppConstants.platform == "macosx"
+ ? { accelKey: true, shiftKey: true }
+ : { ctrlKey: true, shiftKey: true };
+
+ let collapsedPromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(attachmentArea) && !attachmentArea.open,
+ "The attachment area should be visible but closed."
+ );
+
+ // Press Ctrl/Cmd+Shift+M to collapse the attachment pane.
+ EventUtils.synthesizeKey("M", modifiers, cwc.window);
+ await collapsedPromise;
+
+ let visiblePromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(attachmentArea) && attachmentArea.open,
+ "The attachment area should be visible and open."
+ );
+ // Press Ctrl/Cmd+Shift+M again.
+ EventUtils.synthesizeKey("M", modifiers, cwc.window);
+ await visiblePromise;
+
+ // The height of these elements should have been properly restored.
+ Assert.equal(attachmentBucket.getBoundingClientRect().height, heightBefore);
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_attachmentCloudDraft.js b/comm/mail/test/browser/composition/browser_attachmentCloudDraft.js
new file mode 100644
index 0000000000..5054bd9fed
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_attachmentCloudDraft.js
@@ -0,0 +1,577 @@
+/* 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/. */
+
+/**
+ * Tests that cloudFile attachments are properly restored in re-opened drafts.
+ */
+
+"use strict";
+
+var { gMockFilePicker, gMockFilePickReg } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AttachmentHelpers.jsm"
+);
+var {
+ close_compose_window,
+ open_compose_new_mail,
+ save_compose_message,
+ setup_msg_contents,
+ wait_for_compose_window,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { CloudFileTestProvider } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CloudfileHelpers.jsm"
+);
+var {
+ be_in_folder,
+ get_special_folder,
+ get_about_message,
+ mc,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { get_notification, wait_for_notification_to_show } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var { plan_for_modal_dialog, plan_for_new_window, wait_for_modal_dialog } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gDrafts;
+var gOutbox;
+var gCloudFileProvider;
+const kFiles = ["./data/attachment.txt"];
+
+add_setup(async function () {
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+ gOutbox = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+ gMockFilePickReg.register();
+ // Register an extension based cloudFile provider.
+ gCloudFileProvider = new CloudFileTestProvider("testProvider");
+ await gCloudFileProvider.register(this);
+});
+
+registerCleanupFunction(async function () {
+ gMockFilePickReg.unregister();
+ await gCloudFileProvider.unregister();
+});
+
+/**
+ * Test reopening a draft with a cloudFile attachment.
+ *
+ * It must be possible to rename the restored cloudFile attachment.
+ * It must be possible to convert the restored cloudFile to a local attachment.
+ */
+add_task(async function test_draft_with_cloudFile_attachment() {
+ // Prepare the mock file picker.
+ let files = collectFiles(kFiles);
+ gMockFilePicker.returnFiles = files;
+
+ let cloudFileAccount = await gCloudFileProvider.createAccount("validAccount");
+ let draft = await createAndCloseDraftWithCloudAttachment(cloudFileAccount);
+ let expectedUpload = { ...draft.upload };
+
+ let cwc = openDraft();
+
+ let bucket = cwc.window.document.getElementById("attachmentBucket");
+ Assert.equal(
+ bucket.itemCount,
+ kFiles.length,
+ "Should find correct number of attachments."
+ );
+ let itemFromDraft = [...bucket.children].find(
+ e => e.attachment.name == "attachment.txt"
+ );
+ Assert.ok(itemFromDraft, "Should have found the attachment item");
+ Assert.ok(itemFromDraft.attachment.sendViaCloud, "Should be a cloudFile.");
+ Assert.equal(
+ draft.url,
+ itemFromDraft.attachment.url,
+ "Should have restored the url of the original attachment, pointing to the local file."
+ );
+ Assert.ok(
+ !itemFromDraft.attachment.temporary,
+ "The attachments local file should not be temporary."
+ );
+ Assert.equal(
+ cloudFileAccount.accountKey,
+ itemFromDraft.attachment.cloudFileAccountKey,
+ "Should have restored the correct account key."
+ );
+
+ expectedUpload.immutable = true;
+ Assert.deepEqual(
+ expectedUpload,
+ itemFromDraft.cloudFileUpload,
+ "Should have found the existing upload."
+ );
+ Assert.equal(
+ draft.itemIcon,
+ itemFromDraft.querySelector("img.attachmentcell-icon").src,
+ "CloudFile icon of draft should match CloudFile icon of original email."
+ );
+ Assert.equal(
+ draft.itemSize,
+ itemFromDraft.querySelector(".attachmentcell-size").textContent,
+ "Attachment size of draft should match attachment size of original email."
+ );
+ Assert.equal(
+ draft.totalSize,
+ cwc.window.document.getElementById("attachmentBucketSize").textContent,
+ "Total size of draft should match total size of original email."
+ );
+
+ // Rename attachment.
+ await cwc.window.UpdateAttachment(itemFromDraft, { name: "renamed.txt" });
+ Assert.equal(
+ "renamed.txt",
+ itemFromDraft.attachment.name,
+ "Renaming a restored cloudFile attachment should succeed."
+ );
+
+ // Convert to regular attachment.
+ await cwc.window.UpdateAttachment(itemFromDraft, { cloudFileAccount: null });
+ Assert.ok(
+ !itemFromDraft.attachment.sendViaCloud,
+ "Converting a restored cloudFile attachment to a regular attachment should succeed."
+ );
+
+ close_compose_window(cwc);
+
+ // Delete the leftover draft message.
+ press_delete();
+
+ // Cleanup cloudFile account.
+ await gCloudFileProvider.removeAccount(cloudFileAccount);
+});
+
+/**
+ * Test reopening a draft with a cloudFile attachment, which is not know to the
+ * current session.
+ *
+ * It must be possible to rename the restored cloudFile attachment.
+ * It must be possible to convert the restored cloudFile to a local attachment.
+ */
+add_task(async function test_draft_with_unknown_cloudFile_attachment() {
+ // Prepare the mock file picker.
+ let files = collectFiles(kFiles);
+ gMockFilePicker.returnFiles = files;
+
+ let cloudFileAccount = await gCloudFileProvider.createAccount(
+ "validAccountUnknownUpload"
+ );
+ let draft = await createAndCloseDraftWithCloudAttachment(cloudFileAccount);
+ let expectedUpload = { ...draft.upload };
+
+ // Change the known upload, so the draft comes back as unknown.
+ let id1 = cloudFileAccount._uploads.get(1);
+ id1.serviceName = "wrongService";
+ cloudFileAccount._uploads.set(1, id1);
+
+ let cwc = openDraft();
+
+ let bucket = cwc.window.document.getElementById("attachmentBucket");
+ Assert.equal(
+ bucket.itemCount,
+ kFiles.length,
+ "Should find correct number of attachments."
+ );
+ let itemFromDraft = [...bucket.children].find(
+ e => e.attachment.name == "attachment.txt"
+ );
+ Assert.ok(itemFromDraft, "Should have found the attachment item");
+ Assert.ok(itemFromDraft.attachment.sendViaCloud, "Should be a cloudFile.");
+ Assert.equal(
+ draft.url,
+ itemFromDraft.attachment.url,
+ "Should have restored the url of the original attachment, pointing to the local file."
+ );
+ Assert.ok(
+ !itemFromDraft.attachment.temporary,
+ "The attachments local file should not be temporary."
+ );
+ Assert.equal(
+ cloudFileAccount.accountKey,
+ itemFromDraft.attachment.cloudFileAccountKey,
+ "Should have restored the correct account key."
+ );
+
+ expectedUpload.id = 2;
+ expectedUpload.immutable = true;
+ Assert.deepEqual(
+ expectedUpload,
+ itemFromDraft.cloudFileUpload,
+ "Should have created a new upload with id = 2."
+ );
+ Assert.equal(
+ draft.itemIcon,
+ itemFromDraft.querySelector("img.attachmentcell-icon").src,
+ "CloudFile icon of draft should match CloudFile icon of original email."
+ );
+ Assert.equal(
+ draft.itemSize,
+ itemFromDraft.querySelector(".attachmentcell-size").textContent,
+ "Attachment size of draft should match attachment size of original email."
+ );
+ Assert.equal(
+ draft.totalSize,
+ cwc.window.document.getElementById("attachmentBucketSize").textContent,
+ "Total size of draft should match total size of original email."
+ );
+
+ // Rename attachment.
+ await cwc.window.UpdateAttachment(itemFromDraft, { name: "renamed.txt" });
+ Assert.equal(
+ "renamed.txt",
+ itemFromDraft.attachment.name,
+ "Renaming an unknown cloudFile attachment should succeed."
+ );
+
+ // Convert to regular attachment.
+ await cwc.window.UpdateAttachment(itemFromDraft, { cloudFileAccount: null });
+ Assert.ok(
+ !itemFromDraft.attachment.sendViaCloud,
+ "Converting an unknown cloudFile attachment to a regular attachment should succeed."
+ );
+
+ close_compose_window(cwc);
+
+ // Delete the leftover draft message.
+ press_delete();
+
+ // Cleanup cloudFile account.
+ await gCloudFileProvider.removeAccount(cloudFileAccount);
+});
+
+/**
+ * Test reopening a draft with a cloudFile attachment, whose account has been
+ * deleted.
+ *
+ * It must NOT be possible to rename the restored cloudFile attachment.
+ * It must be possible to convert the restored cloudFile to a local attachment.
+ */
+add_task(async function test_draft_with_cloudFile_attachment_no_account() {
+ // Prepare the mock file picker.
+ let files = collectFiles(kFiles);
+ gMockFilePicker.returnFiles = files;
+
+ let cloudFileAccount = await gCloudFileProvider.createAccount(
+ "invalidAccount"
+ );
+ let draft = await createAndCloseDraftWithCloudAttachment(cloudFileAccount);
+ let expectedUpload = { ...draft.upload };
+
+ // Remove account.
+ await gCloudFileProvider.removeAccount(cloudFileAccount);
+
+ let cwc = openDraft();
+
+ // Check that the draft has a cloudFile attachment.
+ let bucket = cwc.window.document.getElementById("attachmentBucket");
+ Assert.equal(
+ bucket.itemCount,
+ kFiles.length,
+ "Should find correct number of attachments."
+ );
+ let itemFromDraft = [...bucket.children].find(
+ e => e.attachment.name == "attachment.txt"
+ );
+ Assert.ok(itemFromDraft, "Should have found the attachment item");
+ Assert.ok(itemFromDraft.attachment.sendViaCloud, "Should be a cloudFile.");
+ Assert.equal(
+ draft.url,
+ itemFromDraft.attachment.url,
+ "Should have restored the url of the original attachment, pointing to the local file."
+ );
+ Assert.ok(
+ !itemFromDraft.attachment.temporary,
+ "The attachments local file should not be temporary."
+ );
+ Assert.equal(
+ cloudFileAccount.accountKey,
+ itemFromDraft.attachment.cloudFileAccountKey,
+ "Should have restored the correct account key."
+ );
+
+ delete expectedUpload.id;
+ expectedUpload.immutable = false;
+ Assert.deepEqual(
+ expectedUpload,
+ itemFromDraft.cloudFileUpload,
+ "Should have restored the upload from the draft without an id and immutable = false."
+ );
+ Assert.equal(
+ draft.itemIcon,
+ itemFromDraft.querySelector("img.attachmentcell-icon").src,
+ "CloudFile icon of draft should match CloudFile icon of original email."
+ );
+ Assert.equal(
+ draft.itemSize,
+ itemFromDraft.querySelector(".attachmentcell-size").textContent,
+ "Attachment size of draft should match attachment size of original email."
+ );
+ Assert.equal(
+ draft.totalSize,
+ cwc.window.document.getElementById("attachmentBucketSize").textContent,
+ "Total size of draft should match total size of original email."
+ );
+
+ // Rename attachment.
+ await Assert.rejects(
+ cwc.window.UpdateAttachment(itemFromDraft, { name: "renamed.txt" }),
+ /CloudFile Error: Account not found: undefined/,
+ "Renaming a restored cloudFile attachment (without account) should not succeed."
+ );
+
+ // Convert to regular attachment.
+ await cwc.window.UpdateAttachment(itemFromDraft, { cloudFileAccount: null });
+ Assert.ok(
+ !itemFromDraft.attachment.sendViaCloud,
+ "Converting a restored cloudFile attachment (without account) to a regular attachment should succeed."
+ );
+
+ close_compose_window(cwc);
+
+ // Delete the leftover draft message.
+ press_delete();
+});
+
+/**
+ * Test reopening a draft with a cloudFile attachment, whose local file has been
+ * deleted.
+ *
+ * It must NOT be possible to rename the restored cloudFile attachment.
+ * It must NOT be possible to convert the restored cloudFile to a local attachment.
+ */
+add_task(async function test_draft_with_cloudFile_attachment_no_file() {
+ // Prepare the mock file picker.
+ let tempFile = await createAttachmentFile(
+ "attachment.txt",
+ "This is a sample text."
+ );
+ gMockFilePicker.returnFiles = [tempFile.file];
+
+ let cloudFileAccount = await gCloudFileProvider.createAccount(
+ "validAccountNoFile"
+ );
+ let draft = await createAndCloseDraftWithCloudAttachment(cloudFileAccount);
+ let expectedUpload = { ...draft.upload };
+
+ // Remove local file of cloudFile attachment.
+ await IOUtils.remove(tempFile.path);
+
+ let cwc = openDraft();
+
+ // Check that the draft has a cloudFile attachment.
+ let bucket = cwc.window.document.getElementById("attachmentBucket");
+ Assert.equal(
+ bucket.itemCount,
+ kFiles.length,
+ "Should find correct number of attachments."
+ );
+ let itemFromDraft = [...bucket.children].find(
+ e => e.attachment.name == "attachment.txt"
+ );
+ Assert.ok(itemFromDraft, "Should have found the attachment item");
+ Assert.ok(itemFromDraft.attachment.sendViaCloud, "Should be a cloudFile.");
+ Assert.notEqual(
+ draft.url,
+ itemFromDraft.attachment.url,
+ "Should NOT have restored the url of the original attachment."
+ );
+ Assert.ok(
+ itemFromDraft.attachment.url.endsWith(".html"),
+ "The attachments url should still point to the html placeholder file."
+ );
+ Assert.ok(
+ itemFromDraft.attachment.temporary,
+ "The attachments html placeholder file should be temporary."
+ );
+
+ Assert.equal(
+ cloudFileAccount.accountKey,
+ itemFromDraft.attachment.cloudFileAccountKey,
+ "Should have restored the correct account key."
+ );
+
+ expectedUpload.immutable = true;
+ Assert.deepEqual(
+ expectedUpload,
+ itemFromDraft.cloudFileUpload,
+ "Should have restored the correct upload."
+ );
+ Assert.equal(
+ draft.itemIcon,
+ itemFromDraft.querySelector("img.attachmentcell-icon").src,
+ "CloudFile icon of draft should match CloudFile icon of original email."
+ );
+ Assert.equal(
+ draft.itemSize,
+ itemFromDraft.querySelector(".attachmentcell-size").textContent,
+ "Attachment size of draft should match attachment size of original email."
+ );
+ Assert.equal(
+ draft.totalSize,
+ cwc.window.document.getElementById("attachmentBucketSize").textContent,
+ "Total size of draft should match total size of original email."
+ );
+
+ // Rename attachment.
+ await Assert.rejects(
+ cwc.window.UpdateAttachment(itemFromDraft, { name: "renamed.txt" }),
+ e => {
+ return (
+ e.message.startsWith("CloudFile Error: Attachment file not found: ") &&
+ e.message.endsWith("attachment.txt")
+ );
+ },
+ "Renaming a restored cloudFile attachment (without local file) should not succeed."
+ );
+
+ // Rename attachment.
+ await Assert.rejects(
+ cwc.window.UpdateAttachment(itemFromDraft, { name: "renamed.txt" }),
+ e => {
+ return (
+ e.message.startsWith("CloudFile Error: Attachment file not found: ") &&
+ e.message.endsWith("attachment.txt")
+ );
+ },
+ "Renaming a restored cloudFile attachment (without local file) should not succeed."
+ );
+
+ // Convert to regular attachment.
+ await Assert.rejects(
+ cwc.window.UpdateAttachment(itemFromDraft, { cloudFileAccount: null }),
+ e => {
+ return (
+ e.message.startsWith("CloudFile Error: Attachment file not found: ") &&
+ e.message.endsWith("attachment.txt")
+ );
+ },
+ "Converting a restored cloudFile attachment (without local file) to a regular attachment should not succeed."
+ );
+
+ close_compose_window(cwc);
+
+ // Delete the leftover draft message.
+ press_delete();
+
+ // Cleanup cloudFile account.
+ await gCloudFileProvider.removeAccount(cloudFileAccount);
+});
+
+async function createAndCloseDraftWithCloudAttachment(cloudFileAccount) {
+ // Open a sample message.
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ `Testing drafts with cloudFiles for provider ${cloudFileAccount.displayName}!`,
+ "Some body..."
+ );
+
+ await cwc.window.attachToCloudNew(cloudFileAccount);
+
+ let bucket = cwc.window.document.getElementById("attachmentBucket");
+ Assert.equal(
+ bucket.itemCount,
+ kFiles.length,
+ "Should find correct number of attachments."
+ );
+ let item = [...bucket.children].find(
+ e => e.attachment.name == "attachment.txt"
+ );
+ Assert.ok(item, "Should have found the attachment item");
+ Assert.ok(item.attachment.sendViaCloud, "Should be a cloudFile.");
+ Assert.equal(
+ cloudFileAccount.accountKey,
+ item.attachment.cloudFileAccountKey,
+ "Should have the correct account key."
+ );
+ Assert.deepEqual(
+ cloudFileAccount,
+ item.cloudFileAccount,
+ "Should have the correct cloudFileAccount."
+ );
+
+ let url = item.attachment.url;
+ let upload = item.cloudFileUpload;
+ let itemIcon = item.querySelector("img.attachmentcell-icon").src;
+ let itemSize = item.querySelector(".attachmentcell-size").textContent;
+ let totalSize = cwc.window.document.getElementById(
+ "attachmentBucketSize"
+ ).textContent;
+
+ Assert.equal(
+ itemIcon,
+ "chrome://messenger/content/extension.svg",
+ "CloudFile icon should be correct."
+ );
+
+ // Now close the message with saving it as draft.
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ // The draft message was saved into Local Folders/Drafts.
+ await be_in_folder(gDrafts);
+
+ return { upload, url, itemIcon, itemSize, totalSize };
+}
+
+function openDraft() {
+ select_click_row(0);
+ let aboutMessage = get_about_message();
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ // Edit the draft again...
+ plan_for_new_window("msgcompose");
+ let box = get_notification(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ // ... by clicking Edit in the draft message notification bar.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ return wait_for_compose_window();
+}
+
+/**
+ * Click Save in the Save message dialog.
+ */
+function click_save_message(controller) {
+ if (controller.window.document.title != "Save Message") {
+ throw new Error(
+ "Not a Save message dialog; title=" + controller.window.document.title
+ );
+ }
+ controller.window.document
+ .querySelector("dialog")
+ .getButton("accept")
+ .doCommand();
+}
+
+function collectFiles(files) {
+ return files.map(filename => new FileUtils.File(getTestFilePath(filename)));
+}
+
+async function createAttachmentFile(filename, content) {
+ let tempPath = PathUtils.join(PathUtils.tempDir, filename);
+ await IOUtils.writeUTF8(tempPath, content);
+ return {
+ path: tempPath,
+ file: new FileUtils.File(tempPath),
+ };
+}
diff --git a/comm/mail/test/browser/composition/browser_attachmentDragDrop.js b/comm/mail/test/browser/composition/browser_attachmentDragDrop.js
new file mode 100644
index 0000000000..bc826574c7
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_attachmentDragDrop.js
@@ -0,0 +1,689 @@
+/* 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/. */
+
+/**
+ * Tests the Drag and Drop functionalities of the attachment bucket in the
+ * message compose window.
+ */
+
+"use strict";
+
+var { CloudFileTestProvider } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CloudfileHelpers.jsm"
+);
+var { gMockFilePicker, gMockFilePickReg } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AttachmentHelpers.jsm"
+);
+
+var { open_compose_new_mail, close_compose_window, add_attachments } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ FAKE_SERVER_HOSTNAME,
+ get_about_message,
+ inboxFolder,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+const dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+var gCloudFileProvider;
+var gCloudFileAccount;
+const kFiles = [
+ "./data/attachment.txt",
+ "./data/base64-bug1586890.eml",
+ "./data/base64-encoded-msg.eml",
+ "./data/base64-with-whitespace.eml",
+ "./data/body-greek.eml",
+ "./data/body-utf16.eml",
+];
+
+add_setup(async function () {
+ // Prepare the mock file picker.
+ gMockFilePickReg.register();
+ gMockFilePicker.returnFiles = collectFiles(kFiles);
+
+ // Register an extension based cloudFile provider.
+ gCloudFileProvider = new CloudFileTestProvider("testProvider");
+ await gCloudFileProvider.register(this);
+ gCloudFileAccount = await gCloudFileProvider.createAccount("testAccount");
+});
+
+registerCleanupFunction(async function () {
+ gMockFilePickReg.unregister();
+ // Remove the cloudFile account and unregister the provider.
+ await gCloudFileProvider.removeAccount(gCloudFileAccount);
+ await gCloudFileProvider.unregister();
+});
+
+function getDragOverTarget(win) {
+ return win.document.getElementById("messageArea");
+}
+
+function getDropTarget(win) {
+ return win.document.getElementById("dropAttachmentOverlay");
+}
+
+function initDragSession({ dragData, dropEffect }) {
+ let dropAction;
+ switch (dropEffect) {
+ case null:
+ case undefined:
+ case "move":
+ dropAction = Ci.nsIDragService.DRAGDROP_ACTION_MOVE;
+ break;
+ case "copy":
+ dropAction = Ci.nsIDragService.DRAGDROP_ACTION_COPY;
+ break;
+ case "link":
+ dropAction = Ci.nsIDragService.DRAGDROP_ACTION_LINK;
+ break;
+ default:
+ throw new Error(`${dropEffect} is an invalid drop effect value`);
+ }
+
+ const dataTransfer = new DataTransfer();
+ dataTransfer.dropEffect = dropEffect;
+
+ for (let i = 0; i < dragData.length; i++) {
+ const item = dragData[i];
+ for (let j = 0; j < item.length; j++) {
+ dataTransfer.mozSetDataAt(item[j].type, item[j].data, i);
+ }
+ }
+
+ dragService.startDragSessionForTests(dropAction);
+ const session = dragService.getCurrentSession();
+ session.dataTransfer = dataTransfer;
+
+ return session;
+}
+
+/**
+ * Helper method to simulate a drag and drop action above the window.
+ */
+async function simulateDragAndDrop(win, dragData, type) {
+ let dropTarget = getDropTarget(win);
+ let dragOverTarget = getDragOverTarget(win);
+ let dropEffect = "move";
+
+ let session = initDragSession({ dragData, dropEffect });
+
+ info("Simulate drag over and wait for the drop target to be visible");
+
+ EventUtils.synthesizeDragOver(
+ dragOverTarget,
+ dragOverTarget,
+ dragData,
+ dropEffect,
+ win
+ );
+
+ // This make sure that the fake dataTransfer has still
+ // the expected drop effect after the synthesizeDragOver call.
+ session.dataTransfer.dropEffect = "move";
+
+ await BrowserTestUtils.waitForCondition(
+ () => dropTarget.classList.contains("show"),
+ "Wait for the drop target element to be visible"
+ );
+
+ // If the dragged file is an image, the attach inline container should be
+ // visible.
+ if (type == "image" || type == "inline" || type == "link") {
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !win.document.getElementById("addInline").classList.contains("hidden"),
+ "Wait for the addInline element to be visible"
+ );
+ } else {
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ win.document.getElementById("addInline").classList.contains("hidden"),
+ "Wait for the addInline element to be hidden"
+ );
+ }
+
+ if (type == "inline") {
+ // Change the drop target to the #addInline container.
+ dropTarget = win.document.getElementById("addInline");
+ }
+
+ info("Simulate drop dragData on drop target");
+
+ EventUtils.synthesizeDropAfterDragOver(
+ null,
+ session.dataTransfer,
+ dropTarget,
+ win,
+ { _domDispatchOnly: true }
+ );
+
+ if (type == "inline") {
+ let editor = win.GetCurrentEditor();
+
+ await BrowserTestUtils.waitForCondition(() => {
+ editor.selectAll();
+ return editor.getSelectedElement("img");
+ }, "Confirm the image was added to the message body");
+
+ Assert.equal(
+ win.document.getElementById("attachmentBucket").itemCount,
+ 0,
+ "Confirm the file hasn't been attached"
+ );
+ } else {
+ // The dropped files should have been attached.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ win.document.getElementById("attachmentBucket").itemCount ==
+ dragData.length,
+ "Wait for the file to be attached"
+ );
+ }
+
+ dragService.endDragSession(true);
+}
+
+/**
+ * Test how the attachment overlay reacts to an image file being dragged above
+ * the message compose window.
+ */
+add_task(async function test_image_file_drag() {
+ let file = new FileUtils.File(getTestFilePath("data/tb-logo.png"));
+ let cwc = open_compose_new_mail();
+
+ await simulateDragAndDrop(
+ cwc.window,
+ [[{ type: "application/x-moz-file", data: file }]],
+ "image"
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test how the attachment overlay reacts to an image file being dragged above
+ * the message compose window and dropped above the inline container.
+ */
+add_task(async function test_image_file_drag() {
+ let file = new FileUtils.File(getTestFilePath("data/tb-logo.png"));
+ let cwc = open_compose_new_mail();
+
+ await simulateDragAndDrop(
+ cwc.window,
+ [[{ type: "application/x-moz-file", data: file }]],
+ "inline"
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test how the attachment overlay reacts to a text file being dragged above
+ * the message compose window.
+ */
+add_task(async function test_text_file_drag() {
+ let file = new FileUtils.File(getTestFilePath("data/attachment.txt"));
+ let cwc = open_compose_new_mail();
+
+ await simulateDragAndDrop(
+ cwc.window,
+ [[{ type: "application/x-moz-file", data: file }]],
+ "text"
+ );
+
+ close_compose_window(cwc);
+});
+
+add_task(async function test_message_drag() {
+ let folder = await create_folder("dragondrop");
+ let subject = "Dragons don't drop from the sky";
+ let body = "Dragons can fly after all.";
+ await be_in_folder(folder);
+ await add_message_to_folder(
+ [folder],
+ create_message({ subject, body: { body } })
+ );
+ select_click_row(0);
+
+ let msgStr = get_about_message().gMessageURI;
+ let msgUrl = MailServices.messageServiceFromURI(msgStr).getUrlForUri(msgStr);
+
+ let cwc = open_compose_new_mail();
+ let attachmentBucket = cwc.window.document.getElementById("attachmentBucket");
+
+ await simulateDragAndDrop(
+ cwc.window,
+ [
+ [
+ { type: "text/x-moz-message", data: msgStr },
+ { type: "text/x-moz-url", data: msgUrl.spec },
+ {
+ type: "application/x-moz-file-promise-url",
+ data: msgUrl.spec + "?fileName=" + encodeURIComponent("message.eml"),
+ },
+ {
+ type: "application/x-moz-file-promise",
+ data: new window.messageFlavorDataProvider(),
+ },
+ ],
+ ],
+ "message"
+ );
+
+ let attachment = attachmentBucket.childNodes[0].attachment;
+ Assert.equal(
+ attachment.name,
+ "Dragons don't drop from the sky.eml",
+ "attachment should have expected file name"
+ );
+ Assert.equal(
+ attachment.contentType,
+ "message/rfc822",
+ "attachment should say it's a message"
+ );
+ Assert.notEqual(attachment, 0, "attachment should not be 0 bytes");
+
+ // Clear the added attachment.
+ await cwc.window.RemoveAttachments([attachmentBucket.childNodes[0]]);
+
+ // Try the same with mail.forward_add_extension false.
+ Services.prefs.setBoolPref("mail.forward_add_extension", false);
+
+ await simulateDragAndDrop(
+ cwc.window,
+ [
+ [
+ { type: "text/x-moz-message", data: msgStr },
+ { type: "text/x-moz-url", data: msgUrl.spec },
+ {
+ type: "application/x-moz-file-promise-url",
+ data: msgUrl.spec + "?fileName=" + encodeURIComponent("message.eml"),
+ },
+ {
+ type: "application/x-moz-file-promise",
+ data: new window.messageFlavorDataProvider(),
+ },
+ ],
+ ],
+ "message"
+ );
+
+ let attachment2 = attachmentBucket.childNodes[0].attachment;
+ Assert.equal(
+ attachment2.name,
+ "Dragons don't drop from the sky",
+ "attachment2 should have expected file name"
+ );
+ Assert.equal(
+ attachment2.contentType,
+ "message/rfc822",
+ "attachment2 should say it's a message"
+ );
+ Assert.notEqual(attachment2, 0, "attachment2 should not be 0 bytes");
+
+ Services.prefs.clearUserPref("mail.forward_add_extension");
+
+ close_compose_window(cwc);
+ await be_in_folder(inboxFolder);
+ folder.deleteSelf(null);
+});
+
+add_task(async function test_link_drag() {
+ let cwc = open_compose_new_mail();
+ await simulateDragAndDrop(
+ cwc.window,
+ [
+ [
+ {
+ type: "text/uri-list",
+ data: "https://example.com",
+ },
+ {
+ type: "text/x-moz-url",
+ data: "https://example.com\nExample website",
+ },
+ { type: "application/x-moz-file", data: "" },
+ ],
+ ],
+ "link"
+ );
+
+ let attachment =
+ cwc.window.document.getElementById("attachmentBucket").childNodes[0]
+ .attachment;
+ Assert.equal(
+ attachment.name,
+ "Example website",
+ "Attached link has expected name"
+ );
+ Assert.equal(
+ attachment.url,
+ "https://example.com",
+ "Attached link has correct URL"
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Get the attachment item for the given url.
+ *
+ * @param {Element} bucket - The element to search in for the attachment item.
+ * @param {string} url - The url of the attachment to find.
+ *
+ * @returns {Element?} - The item with the given attachment url, or null if none
+ * was found.
+ */
+function getAttachmentItem(bucket, url) {
+ for (let child of bucket.childNodes) {
+ if (child.attachment.url == url) {
+ return child;
+ }
+ }
+ return null;
+}
+
+/**
+ * Assert that the given bucket has the given selected items.
+ *
+ * @param {Element} bucket - The bucket to check.
+ * @param {Element[]} selectedItems - The expected selected items in the bucket.
+ */
+function assertSelection(bucket, selectedItems) {
+ for (let child of bucket.childNodes) {
+ if (selectedItems.includes(child)) {
+ Assert.ok(
+ child.selected,
+ `${child.attachment.url} item should be selected`
+ );
+ } else {
+ Assert.ok(
+ !child.selected,
+ `${child.attachment.url} item should not be selected`
+ );
+ }
+ }
+}
+
+/**
+ * Select the given attachment items in the bucket.
+ *
+ * @param {Element} bucket - The attachment bucket to select from.
+ * @param {Element[]} itemSet - The set of attachment items to select. This must
+ * contain at least one item.
+ */
+function selectAttachments(bucket, itemSet) {
+ let win = bucket.ownerGlobal;
+ let first = true;
+ for (let item of itemSet) {
+ item.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(item, { ctrlKey: !first }, win);
+ first = false;
+ }
+ assertSelection(bucket, itemSet);
+}
+
+/**
+ * Perform a single drag operation between attachment buckets.
+ *
+ * @param {Element} dragSrc - The attachment item to start dragging from.
+ * @param {Element} destBucket - The attachment bucket to drag to.
+ * @param {string[]} expectUrls - The expected list of all the attachment urls
+ * in the destBucket after the drop. Note this should include both the current
+ * attachments as well as the expected gained attachments.
+ */
+async function moveAttachments(dragSrc, destBucket, expectUrls) {
+ let srcWindow = dragSrc.ownerGlobal;
+ let destWindow = destBucket.ownerGlobal;
+ let dragOverTarget = getDragOverTarget(destWindow);
+ let dropTarget = getDropTarget(destWindow);
+
+ let [dragOverResult, dataTransfer] = EventUtils.synthesizeDragOver(
+ dragSrc,
+ dragOverTarget,
+ null,
+ null,
+ srcWindow,
+ destWindow
+ );
+
+ EventUtils.synthesizeDropAfterDragOver(
+ dragOverResult,
+ dataTransfer,
+ dropTarget,
+ destWindow
+ );
+
+ await TestUtils.waitForCondition(
+ () => destBucket.itemCount == expectUrls.length,
+ `Destination bucket has ${expectUrls.length} attachments`
+ );
+ let items = Array.from(destBucket.childNodes);
+ for (let i = 0; i < items.length; i++) {
+ Assert.ok(
+ items[i].attachment.url.startsWith("file://") &&
+ items[i].attachment.url.includes(expectUrls[i].split(".")[0]),
+ `Attachment url ${items[i].attachment.url} should be the correct file:// url`
+ );
+ }
+}
+
+/**
+ * Perform a series of drag and drop of attachments from the given source bucket
+ * to the given destination bucket.
+ *
+ * The dragged attachment will be saved as a local temporary file. This test
+ * extracts the filename from the url and checks if the url of the attachment
+ * in the destBucket is a file:// url and has the correct file name.
+ * The original url is a mailbox:// url:
+ * mailbox:///something?number=1&part=1.4&filename=file2.txt
+ *
+ * @param {Element} srcBucket - The bucket to drag from. It must contain 6
+ * attachment items and be open.
+ * @param {Element} destBucket - The bucket to drag to. It must be empty.
+ */
+async function drag_between_buckets(srcBucket, destBucket) {
+ Assert.equal(srcBucket.itemCount, 6, "Src bucket starts with 6 attachments");
+ Assert.equal(
+ destBucket.itemCount,
+ 0,
+ "Dest bucket starts with no attachments"
+ );
+
+ let attachmentSet = Array.from(srcBucket.childNodes, item => {
+ return { url: item.attachment.url, srcItem: item };
+ });
+
+ let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+ );
+ dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE);
+
+ // NOTE: Attachment #4 is never dragged from the source to the destination
+ // bucket as part of this test.
+
+ let destUrls = [];
+
+ // Select attachment #2, and drag it.
+ selectAttachments(srcBucket, [attachmentSet[2].srcItem]);
+ destUrls.push(attachmentSet[2].url.split("=").pop());
+ await moveAttachments(attachmentSet[2].srcItem, destBucket, destUrls);
+
+ // Start with attachment #3 selected, but drag attachment #1.
+ // The drag operation should at first change the selection to attachment #1,
+ // such that it becomes the transferred file.
+ selectAttachments(srcBucket, [attachmentSet[3].srcItem]);
+ destUrls.push(attachmentSet[1].url.split("=").pop());
+ await moveAttachments(attachmentSet[1].srcItem, destBucket, destUrls);
+ // Confirm that attachment #1 was selected.
+ assertSelection(srcBucket, [attachmentSet[1].srcItem]);
+
+ // Select two attachments. And then start a drag on one of them.
+ // We expect both the selected attachments to move.
+ selectAttachments(srcBucket, [
+ attachmentSet[0].srcItem,
+ attachmentSet[3].srcItem,
+ ]);
+ destUrls.push(
+ attachmentSet[0].url.split("=").pop(),
+ attachmentSet[3].url.split("=").pop()
+ );
+ await moveAttachments(attachmentSet[3].srcItem, destBucket, destUrls);
+
+ // Select three attachments, two of which are already added.
+ // Expect the new one to be added.
+ selectAttachments(srcBucket, [
+ attachmentSet[1].srcItem,
+ attachmentSet[5].srcItem,
+ attachmentSet[2].srcItem,
+ ]);
+ destUrls.push(attachmentSet[5].url.split("=").pop());
+ await moveAttachments(attachmentSet[1].srcItem, destBucket, destUrls);
+ dragService.endDragSession(true);
+}
+
+/**
+ * Test dragging regular attachments from one composition window to another.
+ */
+add_task(async function test_drag_and_drop_between_composition_windows() {
+ let ctrlSrc = open_compose_new_mail();
+ let ctrlDest = open_compose_new_mail();
+
+ // Add attachments (via mocked file picker).
+ await ctrlSrc.window.AttachFile();
+
+ let srcAttachmentArea =
+ ctrlSrc.window.document.getElementById("attachmentArea");
+
+ // Wait for attachment area to be visible and open in response.
+ await TestUtils.waitForCondition(
+ () =>
+ BrowserTestUtils.is_visible(srcAttachmentArea) && srcAttachmentArea.open,
+ "Attachment area is visible and open"
+ );
+
+ let srcBucket = ctrlSrc.window.document.getElementById("attachmentBucket");
+ let dstBucket = ctrlDest.window.document.getElementById("attachmentBucket");
+ await drag_between_buckets(srcBucket, dstBucket);
+
+ // Make sure a dragged attachment can be converted to a cloudFile attachment.
+ try {
+ await ctrlSrc.window.UpdateAttachment(dstBucket.childNodes[0], {
+ cloudFileAccount: gCloudFileAccount,
+ });
+ Assert.ok(
+ dstBucket.childNodes[0].attachment.sendViaCloud,
+ "Regular attachment should have been converted to a cloudFile attachment."
+ );
+ } catch (ex) {
+ Assert.ok(
+ false,
+ `Converting a drag'n'dropped regular attachment to a cloudFile attachment should succeed: ${ex.message}`
+ );
+ }
+
+ close_compose_window(ctrlSrc);
+ close_compose_window(ctrlDest);
+});
+
+/**
+ * Test dragging cloudFile attachments from one composition window to another.
+ */
+add_task(async function test_cloud_drag_and_drop_between_composition_windows() {
+ let ctrlSrc = open_compose_new_mail();
+ let ctrlDest = open_compose_new_mail();
+
+ // Add cloudFile attachments (via mocked file picker).
+ await ctrlSrc.window.attachToCloudNew(gCloudFileAccount);
+
+ let srcAttachmentArea =
+ ctrlSrc.window.document.getElementById("attachmentArea");
+
+ // Wait for attachment area to be visible and open in response.
+ await TestUtils.waitForCondition(
+ () =>
+ BrowserTestUtils.is_visible(srcAttachmentArea) && srcAttachmentArea.open,
+ "Attachment area is visible and open"
+ );
+
+ let srcBucket = ctrlSrc.window.document.getElementById("attachmentBucket");
+ let dstBucket = ctrlDest.window.document.getElementById("attachmentBucket");
+ await drag_between_buckets(srcBucket, dstBucket);
+
+ // Make sure a dragged cloudFile attachment can be converted to a regular
+ // attachment.
+ try {
+ await ctrlSrc.window.UpdateAttachment(dstBucket.childNodes[0], {
+ cloudFileAccount: null,
+ });
+ Assert.ok(
+ !dstBucket.childNodes[0].attachment.sendViaCloud,
+ "CloudFile Attachment should have been converted to a regular attachment."
+ );
+ } catch (ex) {
+ Assert.ok(
+ false,
+ `Converting a drag'n'dropped cloudFile attachment to a regular attachment should succeed: ${ex.message}`
+ );
+ }
+
+ close_compose_window(ctrlSrc);
+ close_compose_window(ctrlDest);
+});
+
+/**
+ * Test dragging attachments from a message into a composition window.
+ */
+add_task(async function test_drag_and_drop_between_composition_windows() {
+ let ctrlDest = open_compose_new_mail();
+
+ let folder = await create_folder("AttachmentsForComposition");
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ attachments: [0, 1, 2, 3, 4, 5].map(num => {
+ return {
+ body: "Some Text",
+ filename: `file${num}.txt`,
+ format: "text/plain",
+ };
+ }),
+ })
+ );
+ await be_in_folder(folder);
+ select_click_row(0);
+ let aboutMessage = get_about_message();
+ let srcAttachmentArea =
+ aboutMessage.document.getElementById("attachmentView");
+ Assert.ok(!srcAttachmentArea.collapsed, "Attachment area is visible");
+
+ let srcBucket = aboutMessage.document.getElementById("attachmentList");
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentBar"),
+ {},
+ aboutMessage
+ );
+ Assert.ok(!srcBucket.collapsed, "Attachment list is visible");
+
+ await drag_between_buckets(
+ srcBucket,
+ ctrlDest.window.document.getElementById("attachmentBucket")
+ );
+
+ close_compose_window(ctrlDest);
+});
+
+function collectFiles(files) {
+ return files.map(filename => new FileUtils.File(getTestFilePath(filename)));
+}
diff --git a/comm/mail/test/browser/composition/browser_attachmentReminder.js b/comm/mail/test/browser/composition/browser_attachmentReminder.js
new file mode 100644
index 0000000000..8566f9b4cf
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_attachmentReminder.js
@@ -0,0 +1,892 @@
+/* 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/. */
+
+/**
+ * Tests that the attachment reminder works properly.
+ */
+
+"use strict";
+
+var {
+ add_attachments,
+ close_compose_window,
+ open_compose_new_mail,
+ save_compose_message,
+ setup_msg_contents,
+ wait_for_compose_window,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ get_special_folder,
+ get_about_message,
+ mc,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { delete_all_existing } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+var {
+ assert_notification_displayed,
+ check_notification_displayed,
+ get_notification_button,
+ get_notification,
+ wait_for_notification_to_show,
+ wait_for_notification_to_stop,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var {
+ click_menus_in_sequence,
+ plan_for_modal_dialog,
+ plan_for_new_window,
+ plan_for_window_close,
+ wait_for_modal_dialog,
+ wait_for_window_close,
+ wait_for_window_focused,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let aboutMessage = get_about_message();
+
+var kBoxId = "compose-notification-bottom";
+var kNotificationId = "attachmentReminder";
+var kReminderPref = "mail.compose.attachment_reminder";
+var gDrafts;
+var gOutbox;
+
+add_setup(async function () {
+ requestLongerTimeout(4);
+
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+ gOutbox = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+
+ Assert.ok(Services.prefs.getBoolPref(kReminderPref));
+});
+
+/**
+ * Check if the attachment reminder bar is in the wished state.
+ *
+ * @param aCwc A compose window controller.
+ * @param aShown True for expecting the bar to be shown, false otherwise.
+ *
+ * @returns If the bar is shown, return the notification object.
+ */
+function assert_automatic_reminder_state(aCwc, aShown) {
+ return assert_notification_displayed(
+ aCwc.window,
+ kBoxId,
+ kNotificationId,
+ aShown
+ );
+}
+
+/**
+ * Waits for the attachment reminder bar to change into the wished state.
+ *
+ * @param aCwc A compose window controller.
+ * @param aShown True for waiting for the bar to be shown,
+ * false for waiting for it to be hidden.
+ * @param aDelay Set to true to sleep a while to give the notification time
+ * to change. This is used if the state is already what we want
+ * but we expect it could change in a short while.
+ */
+async function wait_for_reminder_state(aCwc, aShown, aDelay = false) {
+ const notificationSlackTime = 5000;
+
+ if (aShown) {
+ if (aDelay) {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, notificationSlackTime));
+ }
+ // This waits up to 30 seconds for the notification to appear.
+ wait_for_notification_to_show(aCwc.window, kBoxId, kNotificationId);
+ } else if (
+ check_notification_displayed(aCwc.window, kBoxId, kNotificationId)
+ ) {
+ // This waits up to 30 seconds for the notification to disappear.
+ wait_for_notification_to_stop(aCwc.window, kBoxId, kNotificationId);
+ } else {
+ // This waits 5 seconds during which the notification must not appear.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, notificationSlackTime));
+ assert_automatic_reminder_state(aCwc, false);
+ }
+}
+
+/**
+ * Check whether the manual reminder is in the proper state.
+ *
+ * @param aCwc A compose window controller.
+ * @param aChecked Whether the reminder should be enabled.
+ */
+function assert_manual_reminder_state(aCwc, aChecked) {
+ const remindCommand = "cmd_remindLater";
+ Assert.equal(
+ aCwc.window.document
+ .getElementById("button-attachPopup_remindLaterItem")
+ .getAttribute("command"),
+ remindCommand
+ );
+
+ let checkedValue = aChecked ? "true" : "false";
+ Assert.equal(
+ aCwc.window.document.getElementById(remindCommand).getAttribute("checked"),
+ checkedValue
+ );
+}
+
+/**
+ * Returns the keywords string currently shown in the notification message.
+ *
+ * @param {MozMillController} cwc - The compose window controller.
+ */
+function get_reminder_keywords(cwc) {
+ assert_automatic_reminder_state(cwc, true);
+ let box = get_notification(cwc.window, kBoxId, kNotificationId);
+ return box.messageText.querySelector("#attachmentKeywords").textContent;
+}
+
+/**
+ * Test that the attachment reminder works, in general.
+ */
+add_task(async function test_attachment_reminder_appears_properly() {
+ let cwc = open_compose_new_mail();
+
+ // There should be no notification yet.
+ assert_automatic_reminder_state(cwc, false);
+
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "Testing automatic reminder!",
+ "Hello! "
+ );
+
+ // Give the notification time to appear. It shouldn't.
+ await wait_for_reminder_state(cwc, false);
+
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Seen this cool attachment?", cwc.window);
+
+ // Give the notification time to appear. It should now.
+ await wait_for_reminder_state(cwc, true);
+
+ // The manual reminder should be disabled yet.
+ assert_manual_reminder_state(cwc, false);
+
+ let box = get_notification(cwc.window, kBoxId, kNotificationId);
+ // Click ok to be notified on send if no attachments are attached.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.lastElementChild,
+ {},
+ cwc.window
+ );
+ await wait_for_reminder_state(cwc, false);
+
+ // The manual reminder should be enabled now.
+ assert_manual_reminder_state(cwc, true);
+
+ // Now try to send, make sure we get the alert.
+ // Click the "Oh, I Did!" button in the attachment reminder dialog.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ let buttonSend = cwc.window.document.getElementById("button-send");
+ EventUtils.synthesizeMouseAtCenter(buttonSend, {}, buttonSend.ownerGlobal);
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ // After confirming the reminder the menuitem should get disabled.
+ assert_manual_reminder_state(cwc, false);
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test that the alert appears normally, but not after closing the
+ * notification.
+ */
+add_task(async function test_attachment_reminder_dismissal() {
+ let cwc = open_compose_new_mail();
+
+ // There should be no notification yet.
+ assert_automatic_reminder_state(cwc, false);
+
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "popping up, eh?",
+ "Hi there, remember the attachment! " +
+ "Yes, there is a file test.doc attached! " +
+ "Do check it, test.doc is a nice attachment."
+ );
+
+ // Give the notification time to appear.
+ await wait_for_reminder_state(cwc, true);
+
+ Assert.equal(get_reminder_keywords(cwc), "test.doc, attachment, attached");
+
+ // We didn't click the "Remind Me Later" - the alert should pop up
+ // on send anyway.
+ // Click the "Oh, I Did!" button in the attachment reminder dialog.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ let buttonSend = cwc.window.document.getElementById("button-send");
+ EventUtils.synthesizeMouseAtCenter(buttonSend, {}, buttonSend.ownerGlobal);
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ let notification = assert_automatic_reminder_state(cwc, true);
+
+ notification.close();
+ assert_automatic_reminder_state(cwc, false);
+
+ click_send_and_handle_send_error(cwc);
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Bug 938829
+ * Check that adding an attachment actually hides the notification.
+ */
+add_task(async function test_attachment_reminder_with_attachment() {
+ let cwc = open_compose_new_mail();
+
+ // There should be no notification yet.
+ assert_automatic_reminder_state(cwc, false);
+
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "Testing automatic reminder!",
+ "Hello! We will have a real attachment here."
+ );
+
+ // Give the notification time to appear. It should.
+ await wait_for_reminder_state(cwc, true);
+
+ // Add an attachment.
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append("prefs.js");
+ Assert.ok(
+ file.exists(),
+ "The required file prefs.js was not found in the profile."
+ );
+ let attachments = [cwc.window.FileToAttachment(file)];
+ cwc.window.AddAttachments(attachments);
+
+ // The notification should hide.
+ await wait_for_reminder_state(cwc, false);
+
+ // Add some more text with keyword so the automatic notification
+ // could potentially show up.
+ setup_msg_contents(cwc, "", "", " Yes, there is a file attached!");
+ // Give the notification time to appear. It shouldn't.
+ await wait_for_reminder_state(cwc, false);
+
+ cwc.window.RemoveAllAttachments();
+
+ // After removing the attachment, notification should come back
+ // with all the keywords, even those input while having an attachment.
+ await wait_for_reminder_state(cwc, true);
+ Assert.equal(get_reminder_keywords(cwc), "attachment, attached");
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test that the mail.compose.attachment_reminder_aggressive pref works.
+ */
+add_task(async function test_attachment_reminder_aggressive_pref() {
+ const kPref = "mail.compose.attachment_reminder_aggressive";
+ Services.prefs.setBoolPref(kPref, false);
+
+ let cwc = open_compose_new_mail();
+
+ // There should be no notification yet.
+ assert_automatic_reminder_state(cwc, false);
+
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "aggressive?",
+ "Check this attachment!"
+ );
+
+ await wait_for_reminder_state(cwc, true);
+ click_send_and_handle_send_error(cwc);
+
+ close_compose_window(cwc);
+
+ // Now reset the pref back to original value.
+ if (Services.prefs.prefHasUserValue(kPref)) {
+ Services.prefs.clearUserPref(kPref);
+ }
+});
+
+/**
+ * Test that clicking "No, Send Now" in the attachment reminder alert
+ * works.
+ */
+add_task(async function test_no_send_now_sends() {
+ let cwc = open_compose_new_mail();
+
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "will the 'No, Send Now' button work?",
+ "Hello, I got your attachment!"
+ );
+
+ await wait_for_reminder_state(cwc, true);
+
+ // Click the send button again, this time choose "No, Send Now".
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let buttonSend = cwc.window.document.getElementById("button-send");
+ EventUtils.synthesizeMouseAtCenter(buttonSend, {}, buttonSend.ownerGlobal);
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ // After clicking "Send Now" sending is proceeding, just handle the error.
+ click_send_and_handle_send_error(cwc, true);
+
+ // We're now back in the compose window, let's close it then.
+ close_compose_window(cwc);
+});
+
+/**
+ * Click the manual reminder in the menu.
+ *
+ * @param aCwc A compose window controller.
+ * @param aExpectedState A boolean specifying what is the expected state
+ * of the reminder menuitem after the click.
+ */
+async function click_manual_reminder(aCwc, aExpectedState) {
+ wait_for_window_focused(aCwc.window);
+ let button = aCwc.window.document.getElementById("button-attach");
+
+ let popup = aCwc.window.document.getElementById("button-attachPopup");
+ let shownPromise = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ button.querySelector(".toolbarbutton-menubutton-dropmarker"),
+ {},
+ aCwc.window
+ );
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ popup.activateItem(
+ aCwc.window.document.getElementById("button-attachPopup_remindLaterItem")
+ );
+ await hiddenPromise;
+ wait_for_window_focused(aCwc.window);
+ assert_manual_reminder_state(aCwc, aExpectedState);
+}
+
+/**
+ * Bug 521128
+ * Test proper behaviour of the manual reminder.
+ */
+add_task(async function test_manual_attachment_reminder() {
+ // Open a sample message with no attachment keywords.
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing manual reminder!",
+ "Some body..."
+ );
+
+ // Enable the manual reminder.
+ await click_manual_reminder(cwc, true);
+ // There should be no attachment notification.
+ assert_automatic_reminder_state(cwc, false);
+
+ // Now close the message with saving it as draft.
+ plan_for_modal_dialog("commonDialogWindow", click_save_message);
+ cwc.window.goDoCommand("cmd_close");
+ wait_for_modal_dialog("commonDialogWindow");
+
+ // Open another blank compose window.
+ cwc = open_compose_new_mail();
+ // This one should have the reminder disabled.
+ assert_manual_reminder_state(cwc, false);
+ // There should be no attachment notification.
+ assert_automatic_reminder_state(cwc, false);
+
+ close_compose_window(cwc);
+
+ // The draft message was saved into Local Folders/Drafts.
+ await be_in_folder(gDrafts);
+
+ select_click_row(0);
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ // Edit the draft again...
+ plan_for_new_window("msgcompose");
+ let box = get_notification(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ // ... by clicking Edit in the draft message notification bar.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ cwc = wait_for_compose_window();
+
+ // Check the reminder enablement was preserved in the message.
+ assert_manual_reminder_state(cwc, true);
+ // There should be no attachment notification.
+ assert_automatic_reminder_state(cwc, false);
+
+ // Now try to send, make sure we get the alert.
+ // Click the "Oh, I Did!" button in the attachment reminder dialog.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ let buttonSend = cwc.window.document.getElementById("button-send");
+ EventUtils.synthesizeMouseAtCenter(buttonSend, {}, buttonSend.ownerGlobal);
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ // We were alerted once and the manual reminder is automatically turned off.
+ assert_manual_reminder_state(cwc, false);
+
+ // Enable the manual reminder and disable it again to see if it toggles right.
+ await click_manual_reminder(cwc, true);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ await click_manual_reminder(cwc, false);
+
+ // Now try to send again, there should be no more alert.
+ click_send_and_handle_send_error(cwc);
+
+ close_compose_window(cwc);
+
+ // Delete the leftover draft message.
+ press_delete();
+}).__skipMe = AppConstants.platform == "linux"; // See bug 1535292.
+
+/**
+ * Bug 938759
+ * Test hiding of the automatic notification if the manual reminder is set.
+ */
+add_task(
+ async function test_manual_automatic_attachment_reminder_interaction() {
+ // Open a blank message compose
+ let cwc = open_compose_new_mail();
+ // This one should have the reminder disabled.
+ assert_manual_reminder_state(cwc, false);
+ // There should be no attachment notification.
+ assert_automatic_reminder_state(cwc, false);
+
+ // Add some attachment keywords.
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing manual reminder!",
+ "Expect an attachment here..."
+ );
+
+ // The automatic attachment notification should pop up.
+ await wait_for_reminder_state(cwc, true);
+
+ // Now enable the manual reminder.
+ await click_manual_reminder(cwc, true);
+ // The attachment notification should disappear.
+ await wait_for_reminder_state(cwc, false);
+
+ // Add some more text so the automatic notification
+ // could potentially show up.
+ setup_msg_contents(cwc, "", "", " and look for your attachment!");
+ // Give the notification time to appear. It shouldn't.
+ await wait_for_reminder_state(cwc, false);
+
+ // Now disable the manual reminder.
+ await click_manual_reminder(cwc, false);
+ // Give the notification time to appear. It shouldn't.
+ await wait_for_reminder_state(cwc, false);
+
+ // Add some more text without keywords.
+ setup_msg_contents(cwc, "", "", " No keywords here.");
+ // Give the notification time to appear. It shouldn't.
+ await wait_for_reminder_state(cwc, false);
+
+ // Add some more text with a new keyword.
+ setup_msg_contents(cwc, "", "", " Do you find it attached?");
+ // Give the notification time to appear. It should now.
+ await wait_for_reminder_state(cwc, true);
+ Assert.equal(get_reminder_keywords(cwc), "attachment, attached");
+
+ close_compose_window(cwc);
+ }
+);
+
+/**
+ * Assert if there is any notification in the compose window.
+ *
+ * @param aCwc Compose Window Controller
+ * @param aValue True if notification should exist.
+ * False otherwise.
+ */
+function assert_any_notification(aCwc, aValue) {
+ let notification =
+ aCwc.window.document.getElementById(kBoxId).currentNotification;
+ if ((notification == null) == aValue) {
+ throw new Error("Notification in wrong state");
+ }
+}
+
+/**
+ * Bug 989653
+ * Send filelink attachment should not trigger the attachment reminder.
+ */
+add_task(function test_attachment_vs_filelink_reminder() {
+ // Open a blank message compose
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing Filelink notification",
+ "There is no body. I hope you don't mind!"
+ );
+
+ // There should be no notification yet.
+ assert_any_notification(cwc, false);
+
+ // Bring up the FileLink notification.
+ let kOfferThreshold = "mail.compose.big_attachments.threshold_kb";
+ let maxSize = Services.prefs.getIntPref(kOfferThreshold, 0) * 1024;
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append("prefs.js");
+ add_attachments(cwc, Services.io.newFileURI(file).spec, maxSize);
+
+ // The filelink attachment proposal should be up but not the attachment
+ // reminder and it should also not interfere with the sending of the message.
+ wait_for_notification_to_show(cwc.window, kBoxId, "bigAttachment");
+ assert_automatic_reminder_state(cwc, false);
+
+ click_send_and_handle_send_error(cwc);
+ close_compose_window(cwc);
+});
+
+/**
+ * Bug 944643
+ * Test the attachment reminder coming up when keyword is in subject line.
+ */
+add_task(async function test_attachment_reminder_in_subject() {
+ // Open a blank message compose
+ let cwc = open_compose_new_mail();
+ // This one should have the reminder disabled.
+ assert_manual_reminder_state(cwc, false);
+ // There should be no attachment notification.
+ assert_automatic_reminder_state(cwc, false);
+
+ // Add some attachment keyword in subject.
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing attachment reminder!",
+ "There is no keyword in this body..."
+ );
+
+ // The automatic attachment notification should pop up.
+ await wait_for_reminder_state(cwc, true);
+ Assert.equal(get_reminder_keywords(cwc), "attachment");
+
+ // Now clear the subject
+ delete_all_existing(cwc, cwc.window.document.getElementById("msgSubject"));
+
+ // Give the notification time to disappear.
+ await wait_for_reminder_state(cwc, false);
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Bug 944643
+ * Test the attachment reminder coming up when keyword is in subject line
+ * and also body.
+ */
+add_task(async function test_attachment_reminder_in_subject_and_body() {
+ // Open a blank message compose
+ let cwc = open_compose_new_mail();
+ // This one should have the reminder disabled.
+ assert_manual_reminder_state(cwc, false);
+ // There should be no attachment notification.
+ assert_automatic_reminder_state(cwc, false);
+
+ // Add some attachment keyword in subject.
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing attachment reminder!",
+ "There should be an attached file in this body..."
+ );
+
+ // The automatic attachment notification should pop up.
+ await wait_for_reminder_state(cwc, true);
+ Assert.equal(get_reminder_keywords(cwc), "attachment, attached");
+
+ // Now clear only the subject
+ delete_all_existing(cwc, cwc.window.document.getElementById("msgSubject"));
+
+ // Give the notification some time. It should not disappear,
+ // just reduce the keywords list.
+ await wait_for_reminder_state(cwc, true, true);
+ Assert.equal(get_reminder_keywords(cwc), "attached");
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Bug 1099866
+ * Test proper behaviour of attachment reminder when keyword reminding
+ * is turned off.
+ */
+add_task(async function test_disabled_attachment_reminder() {
+ Services.prefs.setBoolPref(kReminderPref, false);
+
+ // Open a sample message with no attachment keywords.
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing disabled keyword reminder!",
+ "Some body..."
+ );
+
+ // This one should have the manual reminder disabled.
+ assert_manual_reminder_state(cwc, false);
+ // There should be no attachment notification.
+ assert_automatic_reminder_state(cwc, false);
+
+ // Add some keyword so the automatic notification
+ // could potentially show up.
+ setup_msg_contents(cwc, "", "", " and look for your attachment!");
+ // Give the notification time to appear. It shouldn't.
+ await wait_for_reminder_state(cwc, false);
+
+ // Enable the manual reminder.
+ await click_manual_reminder(cwc, true);
+ assert_automatic_reminder_state(cwc, false);
+
+ // Disable the manual reminder and the notification should still be hidden
+ // even when there are still keywords in the body.
+ await click_manual_reminder(cwc, false);
+ assert_automatic_reminder_state(cwc, false);
+
+ // There should be no attachment message upon send.
+ click_send_and_handle_send_error(cwc);
+
+ close_compose_window(cwc);
+
+ Services.prefs.setBoolPref(kReminderPref, true);
+});
+
+/**
+ * Bug 833909
+ * Test reminder comes up when a draft with keywords is opened.
+ */
+add_task(async function test_reminder_in_draft() {
+ // Open a sample message with no attachment keywords.
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing draft reminder!",
+ "Some body..."
+ );
+
+ // This one should have the manual reminder disabled.
+ assert_manual_reminder_state(cwc, false);
+ // There should be no attachment notification.
+ assert_automatic_reminder_state(cwc, false);
+
+ // Add some keyword so the automatic notification
+ // could potentially show up.
+ setup_msg_contents(cwc, "", "", " and look for your attachment!");
+
+ // Give the notification time to appear.
+ await wait_for_reminder_state(cwc, true);
+
+ // Now close the message with saving it as draft.
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ // The draft message was saved into Local Folders/Drafts.
+ await be_in_folder(gDrafts);
+
+ select_click_row(0);
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ // Edit the draft again...
+ plan_for_new_window("msgcompose");
+ let box = get_notification(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ // ... by clicking Edit in the draft message notification bar.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ cwc = wait_for_compose_window();
+
+ // Give the notification time to appear.
+ await wait_for_reminder_state(cwc, true);
+
+ close_compose_window(cwc);
+
+ // Delete the leftover draft message.
+ press_delete();
+});
+
+/**
+ * Bug 942436
+ * Test that the reminder can be turned off for the current message.
+ */
+add_task(async function test_disabling_attachment_reminder() {
+ // Open a sample message with attachment keywords.
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing turning off the reminder",
+ "Some attachment keywords here..."
+ );
+
+ // This one should have the manual reminder disabled.
+ assert_manual_reminder_state(cwc, false);
+ // There should be an attachment reminder.
+ await wait_for_reminder_state(cwc, true);
+
+ // Disable the reminder (not just dismiss) using the menuitem
+ // in the notification bar menu-button.
+ let disableButton = get_notification_button(
+ cwc.window,
+ kBoxId,
+ kNotificationId,
+ {
+ popup: "reminderBarPopup",
+ }
+ );
+ let dropmarker = disableButton.querySelector("dropmarker");
+ EventUtils.synthesizeMouseAtCenter(dropmarker, {}, dropmarker.ownerGlobal);
+ await click_menus_in_sequence(
+ disableButton.closest("toolbarbutton").querySelector("menupopup"),
+ [{ id: "disableReminder" }]
+ );
+
+ await wait_for_reminder_state(cwc, false);
+
+ // Add more keywords.
+ setup_msg_contents(cwc, "", "", "... and another file attached.");
+ // Give the notification time to appear. It shouldn't.
+ await wait_for_reminder_state(cwc, false);
+
+ // Enable the manual reminder.
+ // This overrides the previous explicit disabling of any reminder.
+ await click_manual_reminder(cwc, true);
+ assert_automatic_reminder_state(cwc, false);
+
+ // Disable the manual reminder and the notification should still be hidden
+ // even when there are still keywords in the body.
+ await click_manual_reminder(cwc, false);
+ assert_automatic_reminder_state(cwc, false);
+
+ // Add more keywords to trigger automatic reminder.
+ setup_msg_contents(cwc, "", "", "I enclosed another file.");
+ // Give the notification time to appear. It should now.
+ await wait_for_reminder_state(cwc, true);
+
+ // Disable the reminder again.
+ disableButton = get_notification_button(cwc.window, kBoxId, kNotificationId, {
+ popup: "reminderBarPopup",
+ });
+ dropmarker = disableButton.querySelector("dropmarker");
+ EventUtils.synthesizeMouseAtCenter(dropmarker, {}, dropmarker.ownerGlobal);
+ await click_menus_in_sequence(
+ disableButton.closest("toolbarbutton").querySelector("menupopup"),
+ [{ id: "disableReminder" }]
+ );
+ await wait_for_reminder_state(cwc, false);
+
+ // Now send the message.
+ plan_for_window_close(cwc);
+ cwc.window.goDoCommand("cmd_sendLater");
+ wait_for_window_close();
+
+ // There should be no alert so it is saved in Outbox.
+ await be_in_folder(gOutbox);
+
+ select_click_row(0);
+ // Delete the leftover outgoing message.
+ press_delete();
+
+ // Get back to the mail account for other tests.
+ let mail = MailServices.accounts.defaultAccount.incomingServer.rootFolder;
+ await be_in_folder(mail);
+});
+
+/**
+ * Click the send button and handle the send error dialog popping up.
+ * It will return us back to the compose window.
+ *
+ * @param aController
+ * @param aAlreadySending Set this to true if sending was already triggered
+ * by other means.
+ */
+function click_send_and_handle_send_error(aController, aAlreadySending) {
+ plan_for_modal_dialog("commonDialogWindow", click_ok_on_send_error);
+ if (!aAlreadySending) {
+ let buttonSend = aController.window.document.getElementById("button-send");
+ EventUtils.synthesizeMouseAtCenter(buttonSend, {}, buttonSend.ownerGlobal);
+ }
+ wait_for_modal_dialog("commonDialogWindow");
+}
+
+/**
+ * Click Ok in the Send Message Error dialog.
+ */
+function click_ok_on_send_error(controller) {
+ if (controller.window.document.title != "Send Message Error") {
+ throw new Error(
+ "Not a send error dialog; title=" + controller.window.document.title
+ );
+ }
+ controller.window.document
+ .querySelector("dialog")
+ .getButton("accept")
+ .doCommand();
+}
+
+/**
+ * Click Save in the Save message dialog.
+ */
+function click_save_message(controller) {
+ if (controller.window.document.title != "Save Message") {
+ throw new Error(
+ "Not a Save message dialog; title=" + controller.window.document.title
+ );
+ }
+ controller.window.document
+ .querySelector("dialog")
+ .getButton("accept")
+ .doCommand();
+}
diff --git a/comm/mail/test/browser/composition/browser_base64Display.js b/comm/mail/test/browser/composition/browser_base64Display.js
new file mode 100644
index 0000000000..8b2dc1edaf
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_base64Display.js
@@ -0,0 +1,48 @@
+/* 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/. */
+
+/**
+ * Tests that messages with "broken" base64 are correctly displayed.
+ */
+
+"use strict";
+
+var { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+add_task(async function test_base64_display() {
+ let file = new FileUtils.File(
+ getTestFilePath("data/base64-with-whitespace.eml")
+ );
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+ let bodyText = aboutMessage.document
+ .getElementById("messagepane")
+ .contentDocument.querySelector("body").textContent;
+ close_window(msgc);
+
+ Assert.ok(
+ bodyText.includes("abcdefghijklmnopqrstuvwxyz"),
+ "Decode base64 body from message."
+ );
+});
+
+add_task(async function test_base64_display2() {
+ let file = new FileUtils.File(getTestFilePath("data/base64-bug1586890.eml"));
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+ let bodyText = aboutMessage.document
+ .getElementById("messagepane")
+ .contentDocument.querySelector("body").textContent;
+ close_window(msgc);
+
+ Assert.ok(
+ bodyText.includes("abcdefghijklm"),
+ "Decode base64 body from UTF-16 message with broken charset."
+ );
+});
diff --git a/comm/mail/test/browser/composition/browser_blockedContent.js b/comm/mail/test/browser/composition/browser_blockedContent.js
new file mode 100644
index 0000000000..997c760af0
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_blockedContent.js
@@ -0,0 +1,149 @@
+/* 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/. */
+
+/**
+ * Tests that we do the right thing wrt. blocked resources during composition.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { get_msg_source, open_compose_new_mail, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { be_in_folder, get_special_folder, press_delete, select_click_row } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+var { wait_for_notification_to_show } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var { plan_for_window_close, wait_for_window_close } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gOutboxFolder;
+
+var kBoxId = "compose-notification-bottom";
+var kNotificationId = "blockedContent";
+
+add_setup(async function () {
+ gOutboxFolder = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+});
+
+function putHTMLOnClipboard(html) {
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+
+ // Register supported data flavors
+ trans.init(null);
+ trans.addDataFlavor("text/html");
+
+ let wapper = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wapper.data = html;
+ trans.setTransferData("text/html", wapper);
+
+ Services.clipboard.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
+}
+
+/**
+ * Test that accessing file: URLs will block when appropriate, and load
+ * the content when appropriate.
+ */
+add_task(async function test_paste_file_urls() {
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "someone@example.com",
+ "testing html paste",
+ "See these images- one broken one not\n"
+ );
+
+ const fname = "data/tb-logo.png";
+ let file = new FileUtils.File(getTestFilePath(fname));
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ let dest = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ file.leafName
+ );
+ let tmpFile;
+ let tmpFileURL;
+ IOUtils.remove(dest, { ignoreAbsent: true })
+ .then(function () {
+ return IOUtils.copy(file.path, dest);
+ })
+ .then(function () {
+ return IOUtils.setModificationTime(dest);
+ })
+ .then(function () {
+ tmpFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ tmpFile.initWithPath(dest);
+ Assert.ok(tmpFile.exists(), "tmpFile's not there at " + dest);
+
+ tmpFileURL = fileHandler.getURLSpecFromActualFile(tmpFile);
+ putHTMLOnClipboard(
+ "<img id='bad-img' src='file://foo/non-existent' alt='bad' /> and " +
+ "<img id='tmp-img' src='" +
+ tmpFileURL +
+ "' alt='tmp' />"
+ );
+
+ cwc.window.document.getElementById("messageEditor").focus();
+ // Ctrl+V = Paste
+ EventUtils.synthesizeKey(
+ "v",
+ { shiftKey: false, accelKey: true },
+ cwc.window
+ );
+ })
+ .catch(function (err) {
+ throw new Error("Setting up img file FAILED: " + err);
+ });
+
+ // Now wait for the paste, and for the file: based image to get converted
+ // to data:.
+ utils.waitFor(function () {
+ let img = cwc.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementById("tmp-img");
+ return img && img.naturalHeight == 84 && img.src.startsWith("data:");
+ }, "Timeout waiting for pasted tmp image to be loaded ok");
+
+ // For the non-existent (non-accessible!) image we should get a notification.
+ wait_for_notification_to_show(cwc.window, kBoxId, kNotificationId);
+
+ plan_for_window_close(cwc);
+ cwc.window.goDoCommand("cmd_sendLater");
+ wait_for_window_close();
+
+ await be_in_folder(gOutboxFolder);
+ let outMsg = select_click_row(0);
+ let outMsgContent = await get_msg_source(outMsg);
+
+ Assert.ok(
+ outMsgContent.includes("file://foo/non-existent"),
+ "non-existent file not in content=" + outMsgContent
+ );
+
+ Assert.ok(
+ !outMsgContent.includes(tmpFileURL),
+ "tmp file url still in content=" + outMsgContent
+ );
+
+ Assert.ok(
+ outMsgContent.includes('id="tmp-img" src="cid:'),
+ "tmp-img should be cid after send; content=" + outMsgContent
+ );
+
+ press_delete(); // Delete the msg from Outbox.
+});
diff --git a/comm/mail/test/browser/composition/browser_charsetEdit.js b/comm/mail/test/browser/composition/browser_charsetEdit.js
new file mode 100644
index 0000000000..61b4a756b8
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_charsetEdit.js
@@ -0,0 +1,231 @@
+/* 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/. */
+
+/**
+ * Tests that we do the right thing wrt. message encoding when editing or
+ * replying to messages.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var {
+ close_compose_window,
+ open_compose_with_reply,
+ save_compose_message,
+ wait_for_compose_window,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_message,
+ get_special_folder,
+ get_about_message,
+ make_display_unthreaded,
+ mc,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { SyntheticPartLeaf } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { wait_for_notification_to_show, get_notification } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var { plan_for_new_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+
+let aboutMessage = get_about_message();
+
+var gDrafts;
+
+add_setup(async function () {
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+/**
+ * Helper to get the full message content.
+ *
+ * @param aMsgHdr: nsIMsgDBHdr object whose text body will be read
+ * @param aGetText: if true, return header objects. if false, return body data.
+ * @returns Map(partnum -> message headers)
+ */
+function getMsgHeaders(aMsgHdr, aGetText = false) {
+ let msgFolder = aMsgHdr.folder;
+ let msgUri = msgFolder.getUriForMsg(aMsgHdr);
+
+ let handler = {
+ _done: false,
+ _data: new Map(),
+ _text: new Map(),
+ endMessage() {
+ this._done = true;
+ },
+ deliverPartData(num, text) {
+ this._text.set(num, this._text.get(num) + text);
+ },
+ startPart(num, headers) {
+ this._data.set(num, headers);
+ this._text.set(num, "");
+ },
+ };
+ let streamListener = MimeParser.makeStreamListenerParser(handler, {
+ strformat: "unicode",
+ });
+ MailServices.messageServiceFromURI(msgUri).streamMessage(
+ msgUri,
+ streamListener,
+ null,
+ null,
+ false,
+ "",
+ false
+ );
+ utils.waitFor(() => handler._done);
+ return aGetText ? handler._text : handler._data;
+}
+
+/**
+ * Test that if we reply to a message in an invalid charset, we don't try to compose
+ * in that charset. Instead, we should be using UTF-8.
+ */
+add_task(async function test_wrong_reply_charset() {
+ let folder = gDrafts;
+ let msg0 = create_message({
+ bodyPart: new SyntheticPartLeaf("Some text", {
+ charset: "invalid-charset",
+ }),
+ });
+ await add_message_to_folder([folder], msg0);
+ await be_in_folder(folder);
+ // Make the folder unthreaded for easier message selection.
+ make_display_unthreaded();
+
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+ Assert.equal(getMsgHeaders(msg).get("").charset, "invalid-charset");
+
+ let rwc = open_compose_with_reply();
+ await save_compose_message(rwc.window);
+ await TestUtils.waitForCondition(
+ () => folder.getTotalMessages(false) == 2,
+ "message saved to drafts folder"
+ );
+ close_compose_window(rwc);
+
+ let draftMsg = select_click_row(1);
+ Assert.equal(getMsgHeaders(draftMsg).get("").charset, "UTF-8");
+ press_delete(mc); // Delete message
+
+ // Edit the original message. Charset should be UTF-8 now.
+ msg = select_click_row(0);
+
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+
+ plan_for_new_window("msgcompose");
+
+ let box = get_notification(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ // Click on the "Edit" button in the draft notification.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ rwc = wait_for_compose_window();
+ await save_compose_message(rwc.window);
+ close_compose_window(rwc);
+ msg = select_click_row(0);
+ await TestUtils.waitForCondition(
+ () => getMsgHeaders(msg).get("").charset == "UTF-8",
+ "The charset matches"
+ );
+ press_delete(mc); // Delete message
+});
+
+/**
+ * Test that replying to bad charsets don't screw up the existing text.
+ */
+add_task(async function test_no_mojibake() {
+ let folder = gDrafts;
+ let nonASCII = "ケツァルコアトル";
+ let UTF7 = "+MLEwxDChMOswszCiMMgw6w-";
+ let msg0 = create_message({
+ bodyPart: new SyntheticPartLeaf(UTF7, { charset: "utf-7" }),
+ });
+ await add_message_to_folder([folder], msg0);
+ await be_in_folder(folder);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+ await TestUtils.waitForCondition(
+ () => getMsgHeaders(msg).get("").charset == "utf-7",
+ "message charset correctly set"
+ );
+ Assert.equal(getMsgHeaders(msg, true).get("").trim(), nonASCII);
+
+ let rwc = open_compose_with_reply();
+ await save_compose_message(rwc.window);
+ await TestUtils.waitForCondition(
+ () => folder.getTotalMessages(false) == 2,
+ "message saved to drafts folder"
+ );
+ close_compose_window(rwc);
+
+ let draftMsg = select_click_row(1);
+ Assert.equal(getMsgHeaders(draftMsg).get("").charset.toUpperCase(), "UTF-8");
+ let text = getMsgHeaders(draftMsg, true).get("");
+ // Delete message first before throwing so subsequent tests are not affected.
+ press_delete(mc);
+ if (!text.includes(nonASCII)) {
+ throw new Error("Expected to find " + nonASCII + " in " + text);
+ }
+
+ // Edit the original message. Charset should be UTF-8 now.
+ msg = select_click_row(0);
+
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+
+ plan_for_new_window("msgcompose");
+ let box = get_notification(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ // Click on the "Edit" button in the draft notification.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ rwc = wait_for_compose_window();
+ await save_compose_message(rwc.window);
+ close_compose_window(rwc);
+ msg = select_click_row(0);
+ Assert.equal(getMsgHeaders(msg).get("").charset.toUpperCase(), "UTF-8");
+ Assert.equal(getMsgHeaders(msg, true).get("").trim(), nonASCII);
+ press_delete(mc); // Delete message
+});
diff --git a/comm/mail/test/browser/composition/browser_checkRecipientKeys.js b/comm/mail/test/browser/composition/browser_checkRecipientKeys.js
new file mode 100644
index 0000000000..64754c0ac0
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_checkRecipientKeys.js
@@ -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/. */
+
+var { plan_for_new_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var {
+ close_compose_window,
+ open_compose_new_mail,
+ setup_msg_contents,
+ wait_for_compose_window,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { be_in_folder } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+add_setup(() => {
+ Services.prefs.setBoolPref("mail.smime.remind_encryption_possible", true);
+ Services.prefs.setBoolPref("mail.openpgp.remind_encryption_possible", true);
+});
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("mail.smime.remind_encryption_possible");
+ Services.prefs.clearUserPref("mail.openpgp.remind_encryption_possible");
+});
+
+/**
+ * Test that checkEncryptionState should not affect gMsgCompose.compFields.
+ */
+add_task(async function test_checkEncryptionState() {
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc"
+ )
+ )
+ );
+
+ // Set up the identity to cover the remindOpenPGP/remindSMime branches in
+ // checkEncryptionState.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "test@local";
+ identity.setUnicharAttribute("encryption_cert_name", "smime-cert");
+ identity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+ let account = MailServices.accounts.createAccount();
+ account.addIdentity(identity);
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "test",
+ "openpgp.example",
+ "imap"
+ );
+ await be_in_folder(account.incomingServer.rootFolder);
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ // Set up the compose fields used to init the compose window.
+ let fields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ fields.to = "to@local";
+ fields.cc = "cc1@local,cc2@local";
+ fields.bcc = "bcc1@local,bcc2@local";
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.identity = identity;
+ params.composeFields = fields;
+
+ // Open a compose window.
+ plan_for_new_window("msgcompose");
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let cwc = wait_for_compose_window();
+
+ // Test gMsgCompose.compFields is intact.
+ let compFields = cwc.window.gMsgCompose.compFields;
+ Assert.equal(compFields.to, "to@local");
+ Assert.equal(compFields.cc, "cc1@local, cc2@local");
+ Assert.equal(compFields.bcc, "bcc1@local, bcc2@local");
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_cp932Display.js b/comm/mail/test/browser/composition/browser_cp932Display.js
new file mode 100644
index 0000000000..4c3aef3f44
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_cp932Display.js
@@ -0,0 +1,37 @@
+/* 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/. */
+
+/**
+ * Tests that messages in cp932, Thunderbirds alias for Shift_JIS, are correctly displayed.
+ */
+
+"use strict";
+
+var { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+add_task(async function test_cp932_display() {
+ let file = new FileUtils.File(getTestFilePath("data/charset-cp932.eml"));
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+ let subjectText =
+ aboutMessage.document.getElementById("expandedsubjectBox").textContent;
+ let bodyText = aboutMessage.document
+ .getElementById("messagepane")
+ .contentDocument.querySelector("body").textContent;
+ Assert.ok(
+ subjectText.includes("ã“ã“ã«æœ¬æ–‡ãŒãã¾ã™ã€‚"),
+ "Decoded cp932 text not found in message subject. subjectText=" +
+ subjectText
+ );
+ Assert.ok(
+ bodyText.includes("ã“ã“ã«æœ¬æ–‡ãŒãã¾ã™ã€‚"),
+ "Decoded cp932 text not found in message body."
+ );
+ close_window(msgc);
+});
diff --git a/comm/mail/test/browser/composition/browser_customHeaders.js b/comm/mail/test/browser/composition/browser_customHeaders.js
new file mode 100644
index 0000000000..308c800c04
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_customHeaders.js
@@ -0,0 +1,92 @@
+/* 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 mail.compose.other.header is rendered and handled correctly.
+ */
+var {
+ close_compose_window,
+ get_msg_source,
+ open_compose_new_mail,
+ save_compose_message,
+ open_compose_from_draft,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { be_in_folder, select_click_row, get_special_folder } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { wait_for_window_focused } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+/**
+ * Test custom headers are set and encoded correctly.
+ */
+add_task(async function test_customHeaders() {
+ let draftsFolder = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+
+ // Set other.header so that they will be rendered in compose window.
+ let otherHeaders = Services.prefs.getCharPref("mail.compose.other.header");
+ Services.prefs.setCharPref(
+ "mail.compose.other.header",
+ "X-Header1, X-Header2, Approved ,Supersedes"
+ );
+
+ // Set values to custom headers.
+ let cwc = open_compose_new_mail();
+ let inputs = cwc.window.document.querySelectorAll(".address-row-raw input");
+ inputs[0].value = "Test äöü";
+ inputs[1].value = "Test 😃";
+ inputs[2].value = "moderator@tinderbox.com";
+ inputs[3].value = "<message-id-1234@tinderbox.com>";
+
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+ await TestUtils.waitForCondition(
+ () => draftsFolder.getTotalMessages(false) == 1,
+ "message saved to drafts folder"
+ );
+
+ await be_in_folder(draftsFolder);
+ let draftMsg = select_click_row(0);
+ let draftMsgLines = (await get_msg_source(draftMsg)).split("\n");
+
+ // Check header values are set and encoded correctly.
+ Assert.ok(
+ draftMsgLines.some(
+ line => line.trim() == "X-Header1: =?UTF-8?B?VGVzdCDDpMO2w7w=?="
+ ),
+ "Correct X-Header1 found"
+ );
+ Assert.ok(
+ draftMsgLines.some(
+ line => line.trim() == "X-Header2: =?UTF-8?B?VGVzdCDwn5iD?="
+ ),
+ "Correct X-Header2 found"
+ );
+ Assert.ok(
+ draftMsgLines.some(
+ line => line.trim() == "Approved: moderator@tinderbox.com"
+ ),
+ "Correct Approved found"
+ );
+ Assert.ok(
+ draftMsgLines.some(
+ line => line.trim() == "Supersedes: <message-id-1234@tinderbox.com>"
+ ),
+ "Correct Supersedes found"
+ );
+
+ cwc = open_compose_from_draft();
+ let inputs2 = cwc.window.document.querySelectorAll(".address-row-raw input");
+
+ Assert.equal(inputs2[0].value, "Test äöü");
+ Assert.equal(inputs2[1].value, "Test 😃");
+ Assert.equal(inputs2[2].value, "moderator@tinderbox.com");
+ Assert.equal(inputs2[3].value, "<message-id-1234@tinderbox.com>");
+
+ close_compose_window(cwc);
+
+ // Reset other.header.
+ Services.prefs.setCharPref("mail.compose.other.header", otherHeaders);
+});
diff --git a/comm/mail/test/browser/composition/browser_draftIdentity.js b/comm/mail/test/browser/composition/browser_draftIdentity.js
new file mode 100644
index 0000000000..554b3f594e
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_draftIdentity.js
@@ -0,0 +1,313 @@
+/* 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/. */
+
+/**
+ * Tests that compose new message chooses the correct initial identity when
+ * called from the context of an open composer.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_from_draft } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ assert_selected_and_displayed,
+ be_in_folder,
+ get_special_folder,
+ get_about_message,
+ mc,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { assert_notification_displayed, wait_for_notification_to_show } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+ );
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let aboutMessage = get_about_message();
+
+var gDrafts;
+var gAccount;
+
+// The first identity should become the default in the account.
+var gIdentities = [
+ { email: "x@example.invalid" },
+ { email: "y@example.invalid", fullname: "User Y" },
+ { email: "y@example.invalid", fullname: "User YY", label: "YY" },
+ { email: "y+y@example.invalid", fullname: "User Y" },
+ { email: "z@example.invalid", fullname: "User Z", label: "Label Z" },
+ { email: "a+b@example.invalid", fullname: "User A" },
+];
+
+add_setup(async function () {
+ // Now set up an account with some identities.
+ gAccount = MailServices.accounts.createAccount();
+ gAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "Draft Identity Testing",
+ "pop3"
+ );
+
+ for (let id of gIdentities) {
+ let identity = MailServices.accounts.createIdentity();
+ if ("email" in id) {
+ identity.email = id.email;
+ }
+ if ("fullname" in id) {
+ identity.fullName = id.fullname;
+ }
+ if ("label" in id) {
+ identity.label = id.label;
+ }
+ gAccount.addIdentity(identity);
+ id.key = identity.key;
+ id.name = identity.identityName;
+ }
+
+ MailServices.accounts.defaultAccount = gAccount;
+
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+/**
+ * Create a new templated draft message in the drafts folder.
+ *
+ * @returns {integer} The index (position) of the created message in the drafts folder.
+ */
+function create_draft(aFrom, aIdKey) {
+ let msgCount = gDrafts.getTotalMessages(false);
+ let source =
+ "From - Wed Mar 01 01:02:03 2017\n" +
+ "X-Mozilla-Status: 0000\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "X-Mozilla-Keys:\n" +
+ "FCC: mailbox://nobody@Local%20Folders/Sent\n" +
+ (aIdKey
+ ? // prettier-ignore
+ `X-Identity-Key: ${aIdKey}\n` +
+ `X-Account-Key: ${gAccount.key}\n`
+ : "") +
+ `From: ${aFrom}\n` +
+ "To: nobody@example.invalid\n" +
+ "Subject: test!\n" +
+ `Message-ID: <${msgCount}@example.invalid>\n` +
+ "Date: Wed, 1 Mar 2017 01:02:03 +0100\n" +
+ "X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0;\n" +
+ " attachmentreminder=0; deliveryformat=4\n" +
+ "MIME-Version: 1.0\n" +
+ "Content-Type: text/plain; charset=utf-8\n" +
+ "Content-Transfer-Encoding: 8bit\n" +
+ "\n" +
+ "Testing draft identity.\n";
+
+ gDrafts.QueryInterface(Ci.nsIMsgLocalMailFolder).addMessage(source);
+ let msgCountNew = gDrafts.getTotalMessages(false);
+
+ Assert.equal(msgCountNew, msgCount + 1);
+ return msgCountNew - 1;
+}
+
+/**
+ * Helper to check that a suitable From identity was set up in the given
+ * composer window.
+ *
+ * @param cwc Compose window controller.
+ * @param aIdentityKey The key of the expected identity.
+ * @param aFrom The expected displayed From address.
+ */
+function checkCompIdentity(cwc, aIdentityKey, aFrom) {
+ Assert.equal(
+ cwc.window.getCurrentAccountKey(),
+ gAccount.key,
+ "The From account is not correctly selected"
+ );
+ Assert.equal(
+ cwc.window.getCurrentIdentityKey(),
+ aIdentityKey,
+ "The From identity is not correctly selected"
+ );
+ Assert.equal(
+ cwc.window.document.getElementById("msgIdentity").value,
+ aFrom,
+ "The From value was initialized to an unexpected value"
+ );
+}
+
+/**
+ * Bug 394216
+ * Test that starting a new message from a draft with various combinations
+ * of From and X-Identity-Key gets the expected initial identity selected.
+ */
+add_task(async function test_draft_identity_selection() {
+ let tests = [
+ // X-Identity-Key header exists:
+ // 1. From header matches X-Identity-Key identity exactly
+ {
+ idIndex: 1,
+ warning: false,
+ draftIdKey: gIdentities[1].key,
+ draftFrom: gIdentities[1].name,
+ },
+ // 2. From header similar to X-Identity-Key identity with +suffix appended
+ {
+ idIndex: 1,
+ warning: false,
+ draftIdKey: gIdentities[1].key,
+ draftFrom: gIdentities[1].name.replace("y@", "y+x@"),
+ },
+ // 3. X-Identity-Key identity similar to From header with +suffix appended
+ {
+ idIndex: 5,
+ warning: false,
+ draftIdKey: gIdentities[5].key,
+ draftFrom: gIdentities[5].name.replace("a+b@", "a@"),
+ },
+
+ // From header not similar to existing X-Identity-Key identity:
+ // 4. From header not even containing an email address
+ {
+ idIndex: 5,
+ warning: false,
+ draftIdKey: gIdentities[5].key,
+ draftFrom: "User",
+ from: "User <>",
+ },
+ // 5. no matching identity exists
+ {
+ idIndex: 1,
+ warning: true,
+ draftIdKey: gIdentities[1].key,
+ draftFrom: "New User <modified@sender.invalid>",
+ },
+ // 6. 1 matching identity exists
+ {
+ idIndex: 4,
+ warning: false,
+ draftIdKey: gIdentities[4].key,
+ draftFrom: "New User <" + gIdentities[4].email + ">",
+ },
+ // 7. 2 or more matching identities exist
+ {
+ idIndex: 1,
+ warning: true,
+ draftIdKey: gIdentities[0].key,
+ draftFrom: gIdentities[1].name.replace("User Y", "User YZ"),
+ },
+
+ // No X-Identity-Key header:
+ // 8. no matching identity exists
+ // This is a 'foreign draft' in which case we won't preserve the From value
+ // and set it from the default identity.
+ {
+ idIndex: 0,
+ warning: true,
+ draftFrom: "Unknown <unknown@nowhere.invalid>",
+ from: gIdentities[0].name,
+ },
+ // 9. From header matches default identity
+ { idIndex: 0, warning: false, draftFrom: gIdentities[0].name },
+ // 10. From header matches some other identity
+ { idIndex: 5, warning: false, draftFrom: gIdentities[5].name },
+ // 11. From header matches identity with suffix
+ { idIndex: 3, warning: false, draftFrom: gIdentities[3].name },
+ // 12. From header matches 2 identities
+ {
+ idIndex: 1,
+ warning: true,
+ draftFrom: gIdentities[1].email,
+ from: gIdentities[1].name,
+ },
+ ];
+
+ for (let test of tests) {
+ test.draftIndex = create_draft(test.draftFrom, test.draftIdKey);
+ }
+
+ for (let test of tests) {
+ dump("Running draft identity test" + tests.indexOf(test) + "\n");
+ await be_in_folder(gDrafts);
+ select_click_row(test.draftIndex);
+ assert_selected_and_displayed(test.draftIndex);
+ wait_for_notification_to_show(
+ aboutMessage,
+ "mail-notification-top",
+ "draftMsgContent"
+ );
+ let cwc = open_compose_from_draft();
+ checkCompIdentity(
+ cwc,
+ gIdentities[test.idIndex].key,
+ test.from ? test.from : test.draftFrom
+ );
+ if (test.warning) {
+ wait_for_notification_to_show(
+ cwc.window,
+ "compose-notification-bottom",
+ "identityWarning"
+ );
+ } else {
+ assert_notification_displayed(
+ cwc.window,
+ "compose-notification-bottom",
+ "identityWarning",
+ false
+ );
+ }
+
+ close_compose_window(cwc, false);
+ }
+ /*
+ // TODO: fix this in bug 1238264, the identity selector does not properly close.
+ // Open a draft again that shows the notification.
+ await be_in_folder(gDrafts);
+ select_click_row(tests[tests.length-1].draftIndex);
+ let cwc = open_compose_from_draft();
+ wait_for_notification_to_show(cwc, "compose-notification-bottom",
+ "identityWarning");
+ // Notification should go away when another identity is chosen.
+ EventUtils.synthesizeMouseAtCenter(cwc.e("msgIdentity"), { }, cwc.window.document.getElementById("msgIdentity").ownerGlobal)
+ await click_menus_in_sequence(cwc.window.document.getElementById("msgIdentityPopup"),
+ [ { identitykey: gIdentities[0].key } ]);
+
+ wait_for_notification_to_stop(cwc, "compose-notification-bottom",
+ "identityWarning");
+ close_compose_window(cwc, false);
+*/
+});
+
+registerCleanupFunction(async function () {
+ for (let id = 1; id < gIdentities.length; id++) {
+ gAccount.removeIdentity(
+ MailServices.accounts.getIdentity(gIdentities[id].key)
+ );
+ }
+
+ // The last identity of an account can't be removed so clear all its prefs
+ // which effectively destroys it.
+ MailServices.accounts.getIdentity(gIdentities[0].key).clearAllValues();
+ MailServices.accounts.removeAccount(gAccount);
+ gAccount = null;
+
+ // Clear our drafts.
+ await be_in_folder(gDrafts);
+ while (gDrafts.getTotalMessages(false) > 0) {
+ press_delete();
+ }
+
+ // 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();
+});
diff --git a/comm/mail/test/browser/composition/browser_drafts.js b/comm/mail/test/browser/composition/browser_drafts.js
new file mode 100644
index 0000000000..a5d6bed0cc
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_drafts.js
@@ -0,0 +1,457 @@
+/* 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/. */
+
+/**
+ * Tests draft related functionality:
+ * - that we don't allow opening multiple copies of a draft.
+ */
+
+"use strict";
+
+var {
+ close_compose_window,
+ get_compose_body,
+ get_msg_source,
+ open_compose_new_mail,
+ save_compose_message,
+ setup_msg_contents,
+ wait_for_compose_window,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ get_special_folder,
+ get_about_message,
+ make_message_sets_in_folders,
+ mc,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { wait_for_notification_to_show, get_notification } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+
+var {
+ click_menus_in_sequence,
+ close_popup_sequence,
+ plan_for_new_window,
+ wait_for_window_focused,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let aboutMessage = get_about_message();
+
+var kBoxId = "mail-notification-top";
+var draftsFolder;
+
+add_setup(async function () {
+ requestLongerTimeout(2);
+ draftsFolder = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+/**
+ * Bug 349547.
+ * Tests that we only open one compose window for one instance of a draft.
+ */
+add_task(async function test_open_draft_again() {
+ await make_message_sets_in_folders([draftsFolder], [{ count: 1 }]);
+ await be_in_folder(draftsFolder);
+ select_click_row(0);
+
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(aboutMessage, kBoxId, "draftMsgContent");
+ let box = get_notification(aboutMessage, kBoxId, "draftMsgContent");
+
+ plan_for_new_window("msgcompose");
+ // Click on the "Edit" button in the draft notification.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ let cwc = wait_for_compose_window();
+
+ let cwins = [...Services.wm.getEnumerator("msgcompose")].length;
+
+ // click edit in main win again
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+
+ // Wait a sec to see if it caused a new window.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ Assert.ok(
+ Services.ww.activeWindow == cwc.window,
+ "the original draft composition window should have got focus (again)"
+ );
+
+ let cwins2 = [...Services.wm.getEnumerator("msgcompose")].length;
+
+ Assert.ok(cwins2 > 0, "No compose window open!");
+ Assert.equal(cwins, cwins2, "The number of compose windows changed!");
+
+ // Type something and save, then check that we only have one draft.
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Hello!", cwc.window);
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+ Assert.equal(draftsFolder.getTotalMessages(false), 1);
+
+ select_click_row(0);
+ press_delete(mc); // clean up after ourselves
+});
+
+/**
+ * Bug 1202165
+ * Test that the user set delivery format is preserved in a draft message.
+ */
+async function internal_check_delivery_format(editDraft) {
+ let cwc = open_compose_new_mail();
+
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing storing of the composition properties in the draft!",
+ "Hello!"
+ );
+
+ // Select our wanted format.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("optionsMenu"),
+ {},
+ cwc.window.document.getElementById("optionsMenu").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ cwc.window.document.getElementById("optionsMenuPopup"),
+ [{ id: "outputFormatMenu" }, { id: "format_both" }]
+ );
+
+ /**
+ * Check if the right format is selected in the menu.
+ *
+ * @param aMenuItemId The id of the menuitem expected to be selected.
+ * @param aValue A value of nsIMsgCompSendFormat constants of the expected selected format.
+ */
+ async function assert_format_value(aMenuItemId, aValue) {
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("optionsMenu"),
+ {},
+ cwc.window.document.getElementById("optionsMenu").ownerGlobal
+ );
+ let formatMenu = await click_menus_in_sequence(
+ cwc.window.document.getElementById("optionsMenuPopup"),
+ [{ id: "outputFormatMenu" }],
+ true
+ );
+ let formatItem = cwc.window.document
+ .getElementById("outputFormatMenuPopup")
+ .querySelector("[name=output_format][checked=true]");
+ Assert.equal(formatItem.id, aMenuItemId);
+ close_popup_sequence(formatMenu);
+ }
+
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ // Open a new composition see if the menu is again at default value, not the one
+ // chosen above.
+ cwc = open_compose_new_mail();
+
+ await assert_format_value("format_auto", Ci.nsIMsgCompSendFormat.Auto);
+
+ close_compose_window(cwc);
+
+ await be_in_folder(draftsFolder);
+ select_click_row(0);
+
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(aboutMessage, kBoxId, "draftMsgContent");
+ let box = get_notification(aboutMessage, kBoxId, "draftMsgContent");
+
+ plan_for_new_window("msgcompose");
+ if (editDraft) {
+ // Trigger "edit draft".
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ } else {
+ // Trigger "edit as new" resulting in template processing.
+ EventUtils.synthesizeKey(
+ "e",
+ { shiftKey: false, accelKey: true },
+ mc.window
+ );
+ }
+ cwc = wait_for_compose_window();
+
+ // Check if format value was restored.
+ await assert_format_value("format_both", Ci.nsIMsgCompSendFormat.Both);
+
+ close_compose_window(cwc);
+
+ press_delete(mc); // clean up the created draft
+}
+
+add_task(async function test_save_delivery_format_with_edit_draft() {
+ await internal_check_delivery_format(true);
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+add_task(async function test_save_delivery_format_with_edit_template() {
+ await internal_check_delivery_format(false);
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests that 'Edit as New' leaves the original message in drafts folder.
+ */
+add_task(async function test_edit_as_new_in_draft() {
+ await make_message_sets_in_folders([draftsFolder], [{ count: 1 }]);
+ await be_in_folder(draftsFolder);
+
+ Assert.equal(draftsFolder.getTotalMessages(false), 1);
+
+ select_click_row(0);
+
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(aboutMessage, kBoxId, "draftMsgContent");
+
+ plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey("e", { shiftKey: false, accelKey: true });
+ let cwc = wait_for_compose_window();
+
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Hello!", cwc.window);
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ await TestUtils.waitForCondition(
+ () => draftsFolder.getTotalMessages(false) == 2,
+ "message saved to drafts folder"
+ );
+
+ // Clean up the created drafts and count again.
+ press_delete(mc);
+ select_click_row(0);
+ press_delete(mc);
+ Assert.equal(draftsFolder.getTotalMessages(false), 0);
+});
+
+/**
+ * Tests that editing a draft works as it should also when the identity
+ * name has properties that require mime encoding when sent out.
+ */
+add_task(async function test_edit_draft_mime_from() {
+ const identity = MailServices.accounts.createIdentity();
+ identity.email = "skinner@example.com";
+ identity.fullName = "SKINNER, Seymore";
+ const accounts = MailServices.accounts.accounts.at(-1); // Local Folders
+ accounts.addIdentity(identity);
+ registerCleanupFunction(() => {
+ accounts.removeIdentity(identity);
+ });
+
+ draftsFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .addMessage(
+ "From - Sun Oct 01 01:02:03 2023\n" +
+ "X-Mozilla-Status: 0000\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "X-Mozilla-Keys:\n" +
+ `X-Account-Key: ${accounts.key}\n` +
+ `From: "SKINNER, Seymore <skinner@example.com>\n` +
+ "To: nobody@example.invalid\n" +
+ "Subject: test_edit_draft_mime_from!\n" +
+ `Message-ID: <${Date.now()}@example.invalid>\n` +
+ "Date: Sun, 1 Oct 2017 01:02:03 +0100\n" +
+ "X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0;\n" +
+ " attachmentreminder=0; deliveryformat=4\n" +
+ "MIME-Version: 1.0\n" +
+ "Content-Type: text/plain; charset=utf-8\n" +
+ "Content-Transfer-Encoding: 8bit\n" +
+ "\n" +
+ "Identitiy names should not show quotes!.\n"
+ );
+ await be_in_folder(draftsFolder);
+
+ Assert.equal(
+ draftsFolder.getTotalMessages(false),
+ 1,
+ "should have one draft"
+ );
+
+ select_click_row(0);
+
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(aboutMessage, kBoxId, "draftMsgContent");
+
+ plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey("e", { shiftKey: false, accelKey: true });
+ let cwc = wait_for_compose_window();
+
+ const msgIdentity = cwc.window.document.getElementById("msgIdentity");
+ // Should show no quotes in the address.
+ Assert.equal(
+ msgIdentity.value,
+ "SKINNER, Seymore <skinner@example.com>",
+ "should show human readable version of identity"
+ );
+ // Should not be editable - which it would be if no identity matched.
+ Assert.equal(
+ msgIdentity.getAttribute("editable"),
+ "",
+ "msgIdentity should not be editable since a draft identity email matches"
+ );
+
+ close_compose_window(cwc);
+ // Clean up the created draft and count again.
+ press_delete(mc);
+ Assert.equal(
+ draftsFolder.getTotalMessages(false),
+ 0,
+ "should have no drafts after deleting"
+ );
+});
+
+/**
+ * Tests Content-Language header.
+ */
+add_task(async function test_content_language_header() {
+ let cwc = open_compose_new_mail();
+
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing Content-Language header",
+ "Hello, we speak en-US"
+ );
+
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ await TestUtils.waitForCondition(
+ () => draftsFolder.getTotalMessages(false) == 1,
+ "message saved to drafts folder"
+ );
+
+ await be_in_folder(draftsFolder);
+ let draftMsg = select_click_row(0);
+ let draftMsgContent = await get_msg_source(draftMsg);
+
+ // Check for a single line that contains our header.
+ if (
+ !draftMsgContent
+ .split("\n")
+ .some(line => line.trim() == "Content-Language: en-US")
+ ) {
+ Assert.ok(false, "Failed to find Content-Language: en-US");
+ }
+
+ // Clean up the created draft.
+ press_delete(mc);
+});
+
+/**
+ * Tests Content-Language header suppression.
+ */
+add_task(async function test_content_language_header_suppression() {
+ let statusQuo = Services.prefs.getBoolPref("mail.suppress_content_language");
+ Services.prefs.setBoolPref("mail.suppress_content_language", true);
+
+ let cwc = open_compose_new_mail();
+
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing Content-Language header suppression",
+ "Hello, we speak blank"
+ );
+
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ await TestUtils.waitForCondition(
+ () => draftsFolder.getTotalMessages(false) == 1,
+ "message saved to drafts folder"
+ );
+
+ await be_in_folder(draftsFolder);
+ let draftMsg = select_click_row(0);
+ let draftMsgContent = await get_msg_source(draftMsg);
+
+ // Check no line contains our Content-Language.
+ Assert.ok(
+ !draftMsgContent.split("\n").some(line => /^Content-Language:/.test(line)),
+ "Didn't find Content-Language header in draft content"
+ );
+
+ // Clean up the created draft.
+ press_delete(mc);
+
+ Services.prefs.setBoolPref("mail.suppress_content_language", statusQuo);
+});
+
+/**
+ * Tests space stuffing of plaintext message.
+ */
+add_task(async function test_remove_space_stuffing_format_flowed() {
+ // Prepare for plaintext email.
+ let oldHtmlPref = Services.prefs.getBoolPref(
+ "mail.identity.default.compose_html"
+ );
+ Services.prefs.setBoolPref("mail.identity.default.compose_html", false);
+
+ let cwc = open_compose_new_mail();
+
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ "Testing space stuffing in plain text email",
+ "NoSpace\n OneSpace\n TwoSpaces"
+ );
+
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ await TestUtils.waitForCondition(
+ () => draftsFolder.getTotalMessages(false) == 1,
+ "message saved to drafts folder"
+ );
+
+ await be_in_folder(draftsFolder);
+
+ select_click_row(0);
+
+ // Wait for the notification with the Edit button.
+ wait_for_notification_to_show(aboutMessage, kBoxId, "draftMsgContent");
+ let box = get_notification(aboutMessage, kBoxId, "draftMsgContent");
+
+ plan_for_new_window("msgcompose");
+ // Click on the "Edit" button in the draft notification.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ cwc = wait_for_compose_window();
+
+ let bodyText = get_compose_body(cwc).innerHTML;
+ if (!bodyText.includes("NoSpace<br> OneSpace<br> TwoSpaces")) {
+ Assert.ok(false, "Something went wrong with space stuffing");
+ }
+ close_compose_window(cwc);
+
+ // Clean up the created draft.
+ press_delete(mc);
+
+ Services.prefs.setBoolPref("mail.identity.default.compose_html", oldHtmlPref);
+});
diff --git a/comm/mail/test/browser/composition/browser_emlActions.js b/comm/mail/test/browser/composition/browser_emlActions.js
new file mode 100644
index 0000000000..cf24be0b23
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_emlActions.js
@@ -0,0 +1,194 @@
+/* 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/. */
+
+/**
+ * Tests that actions such as replying to an .eml works properly.
+ */
+
+"use strict";
+
+var {
+ close_compose_window,
+ get_compose_body,
+ open_compose_with_forward,
+ open_compose_with_reply,
+ save_compose_message,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ get_special_folder,
+ open_message_from_file,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gDrafts;
+
+add_setup(async function () {
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+/**
+ * Test that replying to an opened .eml message works, and that the reply can
+ * be saved as a draft.
+ */
+add_task(async function test_reply_to_eml_save_as_draft() {
+ // Open an .eml file.
+ let file = new FileUtils.File(getTestFilePath("data/testmsg.eml"));
+ let msgc = await open_message_from_file(file);
+
+ let replyWin = open_compose_with_reply(msgc);
+
+ // Ctrl+S saves as draft.
+ await save_compose_message(replyWin.window);
+ close_compose_window(replyWin);
+
+ await TestUtils.waitForCondition(
+ () => gDrafts.getTotalMessages(false) == 1,
+ "message saved to drafts folder"
+ );
+
+ // Drafts folder should exist now.
+ await be_in_folder(gDrafts);
+ let draftMsg = select_click_row(0);
+ if (!draftMsg) {
+ throw new Error("No draft saved!");
+ }
+ press_delete(); // Delete the draft.
+
+ close_window(msgc); // close base .eml message
+});
+
+/**
+ * Test that forwarding an opened .eml message works, and that the forward can
+ * be saved as a draft.
+ */
+add_task(async function test_forward_eml_save_as_draft() {
+ // Open an .eml file.
+ let file = new FileUtils.File(getTestFilePath("data/testmsg.eml"));
+ let msgc = await open_message_from_file(file);
+
+ let replyWin = open_compose_with_forward(msgc);
+
+ await save_compose_message(replyWin.window);
+ close_compose_window(replyWin);
+
+ await TestUtils.waitForCondition(
+ () => gDrafts.getTotalMessages(false) == 1,
+ "message saved to drafts folder"
+ );
+
+ // Drafts folder should exist now.
+ await be_in_folder(gDrafts);
+ let draftMsg = select_click_row(0);
+ if (!draftMsg) {
+ throw new Error("No draft saved!");
+ }
+ press_delete(); // Delete the draft.
+
+ close_window(msgc); // close base .eml message
+});
+
+/**
+ * Test that MIME encoded subject is decoded when replying to an opened .eml.
+ */
+add_task(async function test_reply_eml_subject() {
+ // Open an .eml file whose subject is encoded.
+ let file = new FileUtils.File(
+ getTestFilePath("data/mime-encoded-subject.eml")
+ );
+ let msgc = await open_message_from_file(file);
+
+ let replyWin = open_compose_with_reply(msgc);
+
+ Assert.equal(
+ replyWin.window.document.getElementById("msgSubject").value,
+ "Re: \u2200a\u220aA"
+ );
+ close_compose_window(replyWin); // close compose window
+ close_window(msgc); // close base .eml message
+});
+
+/**
+ * Test that replying to a base64 encoded .eml works.
+ */
+add_task(async function test_reply_to_base64_eml() {
+ // Open an .eml file.
+ let file = new FileUtils.File(getTestFilePath("data/base64-encoded-msg.eml"));
+ let msgc = await open_message_from_file(file);
+ let compWin = open_compose_with_reply(msgc);
+ let bodyText = get_compose_body(compWin).textContent;
+ const TXT = "You have decoded this text from base64.";
+ Assert.ok(bodyText.includes(TXT), "body should contain the decoded text");
+ close_compose_window(compWin);
+ close_window(msgc);
+});
+
+/**
+ * Test that forwarding a base64 encoded .eml works.
+ */
+add_task(async function test_forward_base64_eml() {
+ // Open an .eml file.
+ let file = new FileUtils.File(getTestFilePath("data/base64-encoded-msg.eml"));
+ let msgc = await open_message_from_file(file);
+ let compWin = open_compose_with_forward(msgc);
+ let bodyText = get_compose_body(compWin).textContent;
+ const TXT = "You have decoded this text from base64.";
+ Assert.ok(bodyText.includes(TXT), "body should contain the decoded text");
+ close_compose_window(compWin);
+ close_window(msgc);
+});
+
+/**
+ * Test that replying and forwarding an evil meta msg works.
+ */
+add_task(async function test_reply_fwd_to_evil_meta() {
+ // Open an .eml file.
+ let file = new FileUtils.File(getTestFilePath("data/evil-meta-msg.eml"));
+ let msgc = await open_message_from_file(file);
+
+ const TXT = "KABOOM!";
+
+ let reWin = open_compose_with_reply(msgc);
+ let reText = get_compose_body(reWin).textContent;
+ Assert.ok(reText.includes(TXT), "re body should contain the text");
+ close_compose_window(reWin);
+
+ let fwdWin = open_compose_with_forward(msgc);
+ let fwdText = get_compose_body(fwdWin).textContent;
+ Assert.ok(fwdText.includes(TXT), "fwd body should contain the text");
+ close_compose_window(fwdWin);
+
+ close_window(msgc);
+});
+
+/**
+ * Test that forwarding an opened .eml message works with catchAll enabled.
+ */
+add_task(async function test_forward_eml_catchall() {
+ // Open an .eml file.
+ let file = new FileUtils.File(getTestFilePath("data/testmsg.eml"));
+ let msgc = await open_message_from_file(file);
+
+ MailServices.accounts.defaultAccount.defaultIdentity.catchAll = true;
+
+ let replyWin = open_compose_with_forward(msgc);
+ let bodyText = get_compose_body(replyWin).textContent;
+ const message = "Because they're stupid, that's why";
+ Assert.ok(bodyText.includes(message), "Correct message body");
+
+ MailServices.accounts.defaultAccount.defaultIdentity.catchAll = false;
+
+ close_compose_window(replyWin); // close compose window
+ close_window(msgc); // close base .eml message
+});
diff --git a/comm/mail/test/browser/composition/browser_encryptedBccRecipients.js b/comm/mail/test/browser/composition/browser_encryptedBccRecipients.js
new file mode 100644
index 0000000000..65d8542b69
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_encryptedBccRecipients.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/. */
+
+/**
+ * Tests for the notification displayed when Bcc recipients are used while
+ * encryption is enabled.
+ */
+
+"use strict";
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+var { be_in_folder } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_compose_window, open_compose_new_mail, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+let bobAcct;
+
+async function waitCheckEncryptionStateDone(win) {
+ return BrowserTestUtils.waitForEvent(
+ win.document,
+ "encryption-state-checked"
+ );
+}
+
+/**
+ * Setup an account with OpenPGP for testing.
+ */
+add_setup(async function () {
+ bobAcct = MailServices.accounts.createAccount();
+ bobAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "bob",
+ "openpgp.example",
+ "imap"
+ );
+
+ let bobIdentity = MailServices.accounts.createIdentity();
+ bobIdentity.email = "bob@openpgp.example";
+ bobAcct.addIdentity(bobIdentity);
+
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc"
+ )
+ )
+ );
+
+ Assert.ok(id, "private key id received");
+ bobIdentity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeIncomingServer(bobAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(bobAcct, true);
+ });
+});
+
+/**
+ * Test the warning is shown when encryption is enabled.
+ */
+add_task(async function testWarningShowsWhenEncryptionEnabled() {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+
+ Assert.ok(!cwc.window.gSendEncrypted);
+
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ let checkDonePromise = waitCheckEncryptionStateDone(cwc.window);
+ await OpenPGPTestUtils.toggleMessageEncryption(cwc.window);
+ await checkDonePromise;
+
+ Assert.ok(cwc.window.gSendEncrypted);
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_bccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ checkDonePromise = waitCheckEncryptionStateDone(cwc.window);
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "Encryption Enabled ",
+ "",
+ "bccAddrInput"
+ );
+ await checkDonePromise;
+
+ // Warning should show when encryption enabled
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ ),
+ "Timeout waiting for warnEncryptedBccRecipients notification"
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test dismissing the warning works.
+ */
+add_task(async function testNotificationDismissal() {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+
+ Assert.ok(!cwc.window.gSendEncrypted);
+
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ let checkDonePromise = waitCheckEncryptionStateDone(cwc.window);
+ await OpenPGPTestUtils.toggleMessageEncryption(cwc.window);
+ await checkDonePromise;
+
+ Assert.ok(cwc.window.gSendEncrypted);
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_bccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ checkDonePromise = waitCheckEncryptionStateDone(cwc.window);
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "Warning Dismissal",
+ "",
+ "bccAddrInput"
+ );
+ await checkDonePromise;
+
+ // Warning should show when encryption enabled
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ ),
+ "Timeout waiting for warnEncryptedBccRecipients notification"
+ );
+
+ let notificationHidden = BrowserTestUtils.waitForCondition(
+ () =>
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ ),
+ "notification was not removed in time"
+ );
+
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.lastElementChild,
+ {},
+ cwc.window
+ );
+ await notificationHidden;
+
+ Assert.ok(
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ ),
+ "notification should be removed"
+ );
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ checkDonePromise = waitCheckEncryptionStateDone(cwc.window);
+ setup_msg_contents(cwc, "test2@example.org", "", "", "bccAddrInput");
+ await checkDonePromise;
+
+ // Give the notification some time to incorrectly appear.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ Assert.ok(
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ ),
+ "notification should not reappear after dismissal"
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the warning does not show when encryption is not enabled.
+ */
+add_task(async function testNoWarningWhenEncryptionDisabled() {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+
+ Assert.ok(!window.gSendEncrypted);
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_bccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(cwc.window);
+ setup_msg_contents(
+ cwc,
+ "test@example.org",
+ "No Warning ",
+ "",
+ "bccAddrInput"
+ );
+ await checkDonePromise;
+
+ // Give the notification some time to incorrectly appear.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ Assert.ok(
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ ),
+ "warning should not show when encryption disabled"
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the warning does not show when the Bcc recipient is the sender.
+ */
+add_task(async function testNoWarningWhenBccRecipientIsSender() {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+
+ Assert.ok(!window.gSendEncrypted);
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_bccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(cwc.window);
+ setup_msg_contents(
+ cwc,
+ "bob@openpgp.example",
+ "Bcc Self",
+ "",
+ "bccAddrInput"
+ );
+ await checkDonePromise;
+
+ // Give the notification some time to incorrectly appear.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ Assert.ok(
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ ),
+ "warning should not show when Bcc recipient is the sender"
+ );
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_expandLists.js b/comm/mail/test/browser/composition/browser_expandLists.js
new file mode 100644
index 0000000000..03041400cb
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_expandLists.js
@@ -0,0 +1,151 @@
+/* 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/. */
+
+/**
+ * Tests for the "Expand List" mail pill context menu.
+ */
+
+"use strict";
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { close_compose_window, open_compose_new_mail, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+/**
+ * Tests mailing list expansion works via the mail pill context menu.
+ *
+ * @param {Window} win The compose window.
+ * @param {string} target The id of the mail pill container to test expansion on.
+ * @param {string} addresses A comma separated string of addresses to put in
+ * the target field. Instances of "Test List" will be replaced to test that the
+ * expansion was successful.
+ */
+async function testListExpansion(win, target, addresses) {
+ let menu = win.document.getElementById("emailAddressPillPopup");
+ let menuItem = win.document.getElementById("expandList");
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ let container = win.document.getElementById(target);
+ let listPill = Array.from(
+ container.querySelectorAll("mail-address-pill")
+ ).find(pill => pill.isMailList);
+
+ EventUtils.synthesizeMouseAtCenter(listPill, { type: "contextmenu" }, win);
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(menuItem);
+ await hiddenPromise;
+
+ let expected = [];
+ for (let addr of addresses.split(",")) {
+ if (addr == "Test List") {
+ expected.push("Member 0 <member0@example>");
+ expected.push("Member 1 <member1@example>");
+ expected.push("Member 2 <member2@example>");
+ } else {
+ expected.push(addr);
+ }
+ }
+
+ let allPills = [];
+ await TestUtils.waitForCondition(() => {
+ allPills = Array.from(container.querySelectorAll("mail-address-pill"));
+ return allPills.length == expected.length;
+ }, "expanded list pills did not appear in time");
+
+ Assert.equal(
+ allPills.map(pill => pill.fullAddress).join(","),
+ expected.join(","),
+ "mail list pills were expanded correctly"
+ );
+}
+
+/**
+ * Creates the mailing list used during the tests.
+ */
+add_setup(async function () {
+ let book = MailServices.ab.directories[0];
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(
+ Ci.nsIAbDirectory
+ );
+ list.isMailList = true;
+ list.dirName = "Test List";
+ list = book.addMailList(list);
+
+ for (let i = 0; i < 3; i++) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.primaryEmail = `member${i}@example`;
+ card.displayName = `Member ${i}`;
+ list.addCard(card);
+ }
+ list.editMailListToDatabase(null);
+});
+
+/**
+ * Tests the "Expand List" menu option works with the "To" list.
+ */
+add_task(async function testExpandListsOnTo() {
+ let cwc = open_compose_new_mail();
+ let addresses = "start@example,Test List,end@example";
+
+ setup_msg_contents(cwc, addresses, "Expand To Test", "");
+ await testListExpansion(cwc.window, "toAddrContainer", addresses);
+ close_compose_window(cwc);
+});
+
+/**
+ * Tests the "Expand List" menu option works with the "To" list,
+ * with invalid pills involved.
+ */
+add_task(async function testExpandListsInvalidPill() {
+ let cwc = open_compose_new_mail();
+ // We add one invalid pill in the middle so see that parsing out the
+ // addresses still works correctly for that case.
+ let addresses =
+ "start@example,invalidpill,Test List,end@example,invalidpill2";
+
+ setup_msg_contents(cwc, addresses, "Expand To Test Invalid Pill", "");
+ await testListExpansion(cwc.window, "toAddrContainer", addresses);
+ close_compose_window(cwc);
+});
+
+/**
+ * Tests the "Expand List" menu option works with the "Cc" list.
+ */
+add_task(async function testExpandListsOnCc() {
+ let cwc = open_compose_new_mail();
+ let button = cwc.window.document.getElementById(
+ "addr_ccShowAddressRowButton"
+ );
+ let addresses = "start@example,Test List,end@example";
+
+ button.click();
+ setup_msg_contents(cwc, addresses, "Expand Cc Test", "", "ccAddrInput");
+ await testListExpansion(cwc.window, "ccAddrContainer", addresses);
+ close_compose_window(cwc);
+});
+
+/**
+ * Tests the "Expand List" menu option works with the "Bcc" list.
+ */
+add_task(async function testExpandListsOnBcc() {
+ let cwc = open_compose_new_mail();
+ let button = cwc.window.document.getElementById(
+ "addr_bccShowAddressRowButton"
+ );
+ let addresses = "start@example,Test List,end@example";
+
+ button.click();
+ setup_msg_contents(cwc, addresses, "Expand Bcc Test", "", "bccAddrInput");
+ await testListExpansion(cwc.window, "bccAddrContainer", addresses);
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_focus.js b/comm/mail/test/browser/composition/browser_focus.js
new file mode 100644
index 0000000000..852f2e99cc
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_focus.js
@@ -0,0 +1,523 @@
+/* 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 that cycling through the focus of the 3pane's panes works correctly.
+ */
+
+"use strict";
+
+var { add_attachments, close_compose_window, open_compose_new_mail } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+requestLongerTimeout(3);
+
+/**
+ * Test the cycling of focus in the composition window through (Shift+)F6.
+ *
+ * @param {MozMillController} controller - Controller for the compose window.
+ * @param {object} options - Options to set for the test.
+ * @param {boolean} options.useTab - Whether to use Ctrl+Tab instead of F6.
+ * @param {boolean} options.attachment - Whether to add an attachment.
+ * @param {boolean} options.notifications - Whether to show notifications.
+ * @param {boolean} options.languageButton - Whether to show the language
+ * menu button.
+ * @param {boolean} options.contacts - Whether to show the contacts side pane.
+ * @param {string} otherHeader - The name of the custom header to show.
+ */
+async function checkFocusCycling(controller, options) {
+ let win = controller.window;
+ let doc = win.document;
+ let contactDoc;
+ let contactsInput;
+ let identityElement = doc.getElementById("msgIdentity");
+ let bccButton = doc.getElementById("addr_bccShowAddressRowButton");
+ let toInput = doc.getElementById("toAddrInput");
+ let bccInput = doc.getElementById("bccAddrInput");
+ let subjectInput = doc.getElementById("msgSubject");
+ let editorElement = doc.getElementById("messageEditor");
+ let attachmentElement = doc.getElementById("attachmentBucket");
+ let extraMenuButton = doc.getElementById("extraAddressRowsMenuButton");
+ let languageButton = doc.getElementById("languageStatusButton");
+ let firstNotification;
+ let secondNotification;
+
+ if (Services.ww.activeWindow != win) {
+ // Wait for the window to be in focus before beginning.
+ await BrowserTestUtils.waitForEvent(win, "activate");
+ }
+
+ let key = options.useTab ? "VK_TAB" : "VK_F6";
+ let goForward = () =>
+ EventUtils.synthesizeKey(key, { ctrlKey: options.useTab }, win);
+ let goBackward = () =>
+ EventUtils.synthesizeKey(
+ key,
+ { ctrlKey: options.useTab, shiftKey: true },
+ win
+ );
+
+ if (options.attachment) {
+ add_attachments(controller, "https://www.mozilla.org/");
+ }
+
+ if (options.contacts) {
+ // Open the contacts sidebar.
+ EventUtils.synthesizeKey("VK_F9", {}, win);
+ contactsInput = await TestUtils.waitForCondition(() => {
+ contactDoc = doc.getElementById("contactsBrowser").contentDocument;
+ return contactDoc.getElementById("peopleSearchInput");
+ }, "Waiting for the contacts pane to load");
+ }
+
+ if (options.languageButton) {
+ // languageButton only shows if we have more than one dictionary, but we
+ // will show it anyway.
+ languageButton.hidden = false;
+ }
+
+ // Show the bcc row by clicking the button.
+ EventUtils.synthesizeMouseAtCenter(bccButton, {}, win);
+
+ // Show the custom row.
+ let otherRow = doc.querySelector(
+ `.address-row[data-recipienttype="${options.otherHeader}"]`
+ );
+ // Show the input.
+ let menu = doc.getElementById("extraAddressRowsMenu");
+ let promise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(extraMenuButton, {}, win);
+ await promise;
+ promise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(doc.getElementById(otherRow.dataset.showSelfMenuitem));
+ await promise;
+ let otherHeaderInput = otherRow.querySelector(".address-row-input");
+
+ // Move the initial focus back to the To input.
+ toInput.focus();
+
+ if (options.notifications) {
+ // Exceed the recipient threshold.
+ Assert.equal(
+ win.gComposeNotification.allNotifications.length,
+ 0,
+ "Should be no initial notifications"
+ );
+ let notificationPromise = TestUtils.waitForCondition(
+ () => win.gComposeNotification.allNotifications[0],
+ "First notification shown"
+ );
+ EventUtils.sendString("a@b.org,c@d.org", win);
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ firstNotification = await notificationPromise;
+ }
+
+ // We start on the addressing widget and go from there.
+
+ // From To to Subject.
+ goForward();
+ Assert.ok(bccInput.matches(":focus"), "forward to bcc row");
+ goForward();
+ Assert.ok(otherHeaderInput.matches(":focus"), "forward to other row");
+ goForward();
+ Assert.ok(subjectInput.matches(":focus"), "forward to subject");
+
+ if (options.notifications && !options.attachment) {
+ // Include an attachment key word in the subject.
+ let notificationPromise = TestUtils.waitForCondition(() => {
+ let notifications = win.gComposeNotification.allNotifications;
+ if (notifications.length != 2) {
+ return null;
+ }
+ return notifications[1];
+ }, "Second notification shown");
+ EventUtils.sendString("My attached file", win);
+ secondNotification = await notificationPromise;
+ Assert.notEqual(
+ firstNotification,
+ secondNotification,
+ "New notification shown second"
+ );
+ }
+
+ // From Subject to Message Body.
+ goForward();
+ // The editor's body will not match ":focus", even when it has focus, instead,
+ // we use the parent window's activeElement.
+ Assert.equal(editorElement, doc.activeElement, "forward to message body");
+
+ // From Message Body to Attachment bucket if visible.
+ goForward();
+ if (options.attachment) {
+ Assert.ok(attachmentElement.matches(":focus"), "forward to attachments");
+ goForward();
+ }
+
+ if (options.notifications) {
+ Assert.equal(
+ firstNotification,
+ doc.activeElement,
+ "forward to notification"
+ );
+ goForward();
+ }
+
+ // From Message Body (or Attachment bucket) to Language button.
+ if (options.languageButton) {
+ Assert.ok(languageButton.matches(":focus"), "forward to status bar");
+ goForward();
+ }
+
+ // From Language button to contacts pane.
+ if (options.contacts) {
+ Assert.ok(
+ contactsInput.matches(":focus-within"),
+ "forward to contacts pane"
+ );
+ goForward();
+ }
+
+ // From contacts pane to identity.
+ Assert.ok(identityElement.matches(":focus"), "forward to 'from' row");
+
+ // Back to the To input.
+ goForward();
+ Assert.ok(toInput.matches(":focus"), "forward to 'to' row");
+
+ // Reverse the direction.
+
+ goBackward();
+ Assert.ok(identityElement.matches(":focus"), "backward to 'from' row");
+
+ goBackward();
+ if (options.contacts) {
+ Assert.ok(
+ contactsInput.matches(":focus-within"),
+ "backward to contacts pane"
+ );
+ goBackward();
+ }
+
+ if (options.languageButton) {
+ Assert.ok(languageButton.matches(":focus"), "backward to status bar");
+ goBackward();
+ }
+
+ if (options.notifications) {
+ Assert.equal(
+ firstNotification,
+ doc.activeElement,
+ "backward to notification"
+ );
+ goBackward();
+ }
+
+ if (options.attachment) {
+ Assert.ok(attachmentElement.matches(":focus"), "backward to attachments");
+ goBackward();
+ }
+
+ Assert.equal(editorElement, doc.activeElement, "backward to message body");
+ goBackward();
+ Assert.ok(subjectInput.matches(":focus"), "backward to subject");
+ goBackward();
+ Assert.ok(otherHeaderInput.matches(":focus"), "backward to other row");
+ goBackward();
+ Assert.ok(bccInput.matches(":focus"), "backward to bcc row");
+ goBackward();
+
+ Assert.ok(toInput.matches(":focus"), "backward to 'to' row");
+
+ // Now test some other elements that aren't the main focus point of their
+ // areas. I.e. focusable elements that are within an area, but are not
+ // focused when the area is *entered* through F6 or Ctrl+Tab. When these
+ // elements have focus, we still want F6 or Ctrl+Tab to move the focus to the
+ // neighbouring area.
+
+ // Focus the close button.
+ let bccCloseButton = doc.querySelector("#addressRowBcc .remove-field-button");
+ bccCloseButton.focus();
+ goForward();
+ Assert.ok(
+ otherHeaderInput.matches(":focus"),
+ "from close bcc button to other row"
+ );
+ goBackward();
+ // The input is focused on return.
+ Assert.ok(bccInput.matches(":focus"), "back to bcc row");
+ // Same the other way.
+ bccCloseButton.focus();
+ goBackward();
+ Assert.ok(toInput.matches(":focus"), "from close bcc button to 'to' row");
+
+ if (options.contacts) {
+ let addressBookList = contactDoc.getElementById("addressbookList");
+ addressBookList.focus();
+ goForward();
+ Assert.ok(
+ identityElement.matches(":focus"),
+ "from addressbook selector to 'from' row"
+ );
+ goBackward();
+ // The input is focused on return.
+ Assert.ok(contactsInput.matches(":focus-within"), "back to contacts input");
+ // Same the other way.
+ addressBookList.focus();
+ goBackward();
+ if (options.languageButton) {
+ Assert.ok(
+ languageButton.matches(":focus"),
+ "from addressbook selector to status bar"
+ );
+ } else if (options.notifications) {
+ Assert.equal(
+ firstNotification,
+ doc.activeElement,
+ "from addressbook selector to notification"
+ );
+ } else if (options.attachment) {
+ Assert.ok(
+ attachmentElement.matches(":focus"),
+ "from addressbook selector to attachments"
+ );
+ } else {
+ Assert.equal(
+ editorElement,
+ doc.activeElement,
+ "from addressbook selector to message body"
+ );
+ }
+ }
+
+ // Cc button and extra address rows menu button are in the same area as the
+ // message identity.
+ let ccButton = doc.getElementById("addr_ccShowAddressRowButton");
+ ccButton.focus();
+ goBackward();
+ if (options.contacts) {
+ Assert.ok(
+ contactsInput.matches(":focus-within"),
+ "from Cc button to contacts"
+ );
+ } else if (options.languageButton) {
+ Assert.ok(languageButton.matches(":focus"), "from Cc button to status bar");
+ } else if (options.notifications) {
+ Assert.equal(
+ firstNotification,
+ doc.activeElement,
+ "from Cc button to notification"
+ );
+ } else if (options.attachment) {
+ Assert.ok(
+ attachmentElement.matches(":focus"),
+ "from Cc button to attachments"
+ );
+ } else {
+ Assert.equal(
+ editorElement,
+ doc.activeElement,
+ "from Cc button to message body"
+ );
+ }
+ goForward();
+ // Return to the input.
+ Assert.ok(identityElement.matches(":focus"), "back to 'from' row");
+
+ // Try in the other direction with the extra menu button.
+ extraMenuButton.focus();
+ goForward();
+ Assert.ok(toInput.matches(":focus"), "from extra menu button to 'to' row");
+ goBackward();
+ // Return to the input.
+ Assert.ok(identityElement.matches(":focus"), "back to 'from' row again");
+
+ if (options.attachment) {
+ let attachmentArea = doc.getElementById("attachmentArea");
+ let attachmentSummary = attachmentArea.querySelector("summary");
+ Assert.ok(attachmentArea.open, "Attachment area should be open");
+ for (let open of [true, false]) {
+ if (open) {
+ Assert.ok(attachmentArea.open, "Attachment area should be open");
+ } else {
+ // Close the attachment bucket. In this case, the focus will move to the
+ // summary element (where the bucket can be shown again).
+ EventUtils.synthesizeMouseAtCenter(attachmentSummary, {}, win);
+ Assert.ok(!attachmentArea.open, "Attachment area should be closed");
+ }
+
+ // Focus the attachmentSummary.
+ attachmentSummary.focus();
+ goBackward();
+ Assert.equal(
+ editorElement,
+ doc.activeElement,
+ `backward from attachment summary (open: ${open}) to message body`
+ );
+ goForward();
+ if (open) {
+ // Focus returns to the bucket when it is open.
+ Assert.ok(
+ attachmentElement.matches(":focus"),
+ "forward to attachment bucket"
+ );
+ } else {
+ // Otherwise, it returns to the summary.
+ Assert.ok(
+ attachmentSummary.matches(":focus"),
+ "forward to attachment summary"
+ );
+ }
+ // Try reverse.
+ attachmentSummary.focus();
+ goForward();
+ if (options.notifications) {
+ Assert.equal(
+ firstNotification,
+ doc.activeElement,
+ `forward from attachment summary (open: ${open}) to notification`
+ );
+ } else if (options.languageButton) {
+ Assert.ok(
+ languageButton.matches(":focus"),
+ `forward from attachment summary (open: ${open}) to status bar`
+ );
+ } else if (options.contacts) {
+ Assert.ok(
+ contactsInput.matches(":focus-within"),
+ `forward from attachment summary (open: ${open}) to contacts pane`
+ );
+ } else {
+ Assert.ok(
+ identityElement.matches(":focus"),
+ `forward from attachment summary (open: ${open}) to 'from' row`
+ );
+ }
+ goBackward();
+ if (open) {
+ Assert.ok(
+ attachmentElement.matches(":focus"),
+ "return to attachment bucket"
+ );
+ } else {
+ Assert.ok(
+ attachmentSummary.matches(":focus"),
+ "return to attachment summary"
+ );
+ // Open again.
+ EventUtils.synthesizeMouseAtCenter(attachmentSummary, {}, win);
+ Assert.ok(attachmentArea.open, "Attachment area should be open again");
+ }
+ }
+ }
+
+ if (options.notifications) {
+ // Focus inside the notification.
+ let closeButton = (secondNotification || firstNotification).closeButton;
+ closeButton.focus();
+
+ goBackward();
+
+ if (options.attachment) {
+ Assert.ok(
+ attachmentElement.matches(":focus"),
+ "backward from notification button to attachments"
+ );
+ } else {
+ Assert.equal(
+ editorElement,
+ doc.activeElement,
+ "backward from notification button to message body"
+ );
+ }
+ goForward();
+ // Go to the first notification.
+ Assert.equal(
+ firstNotification,
+ doc.activeElement,
+ "forward to the first notification"
+ );
+
+ // Try reverse.
+ closeButton.focus();
+ goForward();
+ if (options.languageButton) {
+ Assert.ok(
+ languageButton.matches(":focus"),
+ "forward from notification button to status bar"
+ );
+ } else if (options.contacts) {
+ Assert.ok(
+ contactsInput.matches(":focus-within"),
+ "forward from notification button to contacts pane"
+ );
+ } else {
+ Assert.ok(
+ identityElement.matches(":focus"),
+ "forward from notification button to 'from' row"
+ );
+ }
+ goBackward();
+ Assert.equal(
+ firstNotification,
+ doc.activeElement,
+ "return to the first notification"
+ );
+ }
+
+ // Contacts pane is persistent, so we close it again.
+ if (options.contacts) {
+ // Close the contacts sidebar.
+ EventUtils.synthesizeKey("VK_F9", {}, win);
+ }
+}
+
+add_task(async function test_jump_focus() {
+ // Make sure the accessibility tabfocus is set to 7 to enable normal Tab
+ // focus on non-input field elements. This is necessary only for macOS as
+ // the default value is 2 instead of the default 7 used on Windows and Linux.
+ Services.prefs.setIntPref("accessibility.tabfocus", 7);
+ let prevHeader = Services.prefs.getCharPref("mail.compose.other.header");
+ let prevThreshold = Services.prefs.getIntPref(
+ "mail.compose.warn_public_recipients.threshold"
+ );
+ // Set two custom headers, but only one is shown.
+ Services.prefs.setCharPref(
+ "mail.compose.other.header",
+ "X-Header2,X-Header1"
+ );
+ Services.prefs.setIntPref("mail.compose.warn_public_recipients.threshold", 2);
+ for (let useTab of [false, true]) {
+ for (let attachment of [false, true]) {
+ for (let notifications of [false, true]) {
+ for (let languageButton of [false, true]) {
+ for (let contacts of [false, true]) {
+ let options = {
+ useTab,
+ attachment,
+ notifications,
+ languageButton,
+ contacts,
+ otherHeader: "X-Header1",
+ };
+ info(`Test run: ${JSON.stringify(options)}`);
+ let controller = open_compose_new_mail();
+ await checkFocusCycling(controller, options);
+ close_compose_window(controller);
+ }
+ }
+ }
+ }
+ }
+
+ // Reset the preferences.
+ Services.prefs.clearUserPref("accessibility.tabfocus");
+ Services.prefs.setCharPref("mail.compose.other.header", prevHeader);
+ Services.prefs.setIntPref(
+ "mail.compose.warn_public_recipients.threshold",
+ prevThreshold
+ );
+});
diff --git a/comm/mail/test/browser/composition/browser_font_color.js b/comm/mail/test/browser/composition/browser_font_color.js
new file mode 100644
index 0000000000..e534ab65be
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_font_color.js
@@ -0,0 +1,114 @@
+/* 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 font color in messages.
+ */
+
+var { close_compose_window, open_compose_new_mail, FormatHelper } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+add_task(async function test_font_color() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let colorSet = [
+ { value: "#0000ff", rgb: [0, 0, 255] },
+ { value: "#fb3e83", rgb: [251, 62, 131] },
+ ];
+
+ // Before focus, disabled.
+ Assert.ok(
+ formatHelper.colorSelector.hasAttribute("disabled"),
+ "Selector should be disabled with no focus"
+ );
+
+ formatHelper.focusMessage();
+ Assert.ok(
+ !formatHelper.colorSelector.hasAttribute("disabled"),
+ "Selector should be enabled with focus"
+ );
+
+ let firstText = "no color";
+ let secondText = "with color";
+
+ for (let color of colorSet) {
+ let value = color.value;
+ await formatHelper.assertShownColor("", `No color at start (${value})`);
+
+ await formatHelper.typeInMessage(firstText);
+ formatHelper.assertMessageParagraph(
+ [firstText],
+ `No color at start after typing (${value})`
+ );
+
+ // Select through toolbar.
+ await formatHelper.selectColor(value);
+ await formatHelper.assertShownColor(color, `Color ${value} selected`);
+
+ await formatHelper.typeInMessage(secondText);
+ await formatHelper.assertShownColor(
+ color,
+ `Color ${value} selected and typing`
+ );
+ formatHelper.assertMessageParagraph(
+ [firstText, { text: secondText, color: value }],
+ `${value} on second half`
+ );
+
+ // Test text selections.
+ for (let [start, end, forward, expect] of [
+ // Make sure we expect changes, so the test does not capture the previous
+ // state.
+ [0, null, true, ""], // At start.
+ [firstText.length + 1, null, true, color], // In the color region.
+ [0, firstText.length + secondText.length, true, null], // Mixed.
+ [firstText.length, null, true, ""], // Boundary travelling forward.
+ [firstText.length, null, false, color], // On boundary travelling backward.
+ ]) {
+ await formatHelper.selectTextRange(start, end, forward);
+ await formatHelper.assertShownColor(
+ expect,
+ `Selecting text with ${value}, from ${start} to ${end} ` +
+ `${forward ? "forwards" : "backwards"}`
+ );
+ }
+
+ // Select mixed.
+ await formatHelper.selectTextRange(3, firstText.length + 1);
+ await formatHelper.assertShownColor(null, `Mixed selection (${value})`);
+
+ // Select the same color.
+ await formatHelper.selectColor(value);
+ await formatHelper.assertShownColor(
+ color,
+ `Selected ${value} color on more`
+ );
+ formatHelper.assertMessageParagraph(
+ [
+ firstText.slice(0, 3),
+ { text: firstText.slice(3) + secondText, color: value },
+ ],
+ `${value} color on more`
+ );
+
+ // Select the default color.
+ let selector = formatHelper.selectColorInDialog(null);
+ // Select through Format menu.
+ formatHelper.selectFromFormatMenu(formatHelper.colorMenuItem);
+ await selector;
+ await formatHelper.assertShownColor("", `Unselected ${value} color`);
+ formatHelper.assertMessageParagraph(
+ [
+ firstText + secondText.slice(0, 1),
+ { text: secondText.slice(1), color: value },
+ ],
+ `Cleared some ${value} color`
+ );
+
+ await formatHelper.emptyParagraph();
+ }
+
+ close_compose_window(controller);
+});
diff --git a/comm/mail/test/browser/composition/browser_font_family.js b/comm/mail/test/browser/composition/browser_font_family.js
new file mode 100644
index 0000000000..a365836d27
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_font_family.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/. */
+
+/**
+ * Test font family in messages.
+ */
+
+var { close_compose_window, open_compose_new_mail, FormatHelper } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+add_task(async function test_font_family() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ // Before focus, disabled.
+ Assert.ok(
+ formatHelper.fontSelector.disabled,
+ "Selector should be disabled with no focus"
+ );
+
+ formatHelper.focusMessage();
+ Assert.ok(
+ !formatHelper.fontSelector.disabled,
+ "Selector should be enabled with focus"
+ );
+
+ let firstText = "no font";
+ let secondText = "with font";
+
+ // Only test standard fonts.
+ for (let font of formatHelper.commonFonts) {
+ await formatHelper.assertShownFont("", `Variable width at start (${font})`);
+
+ await formatHelper.typeInMessage(firstText);
+ formatHelper.assertMessageParagraph(
+ [firstText],
+ `No font family at start after typing (${font})`
+ );
+
+ // Select through toolbar.
+ await formatHelper.selectFont(font);
+ await formatHelper.assertShownFont(font, `Changed to "${font}"`);
+
+ await formatHelper.typeInMessage(secondText);
+ await formatHelper.assertShownFont(font, `Still "${font}" when typing`);
+ formatHelper.assertMessageParagraph(
+ [firstText, { text: secondText, font }],
+ `"${font}" on second half`
+ );
+
+ // Test text selections.
+ for (let [start, end, forward, expect] of [
+ // Make sure we expect changes, so the test does not capture the previous
+ // state.
+ [0, null, true, ""], // At start.
+ [firstText.length + 1, null, true, font], // In the font region.
+ [0, firstText.length + secondText.length, true, null], // Mixed.
+ [firstText.length, null, true, ""], // On boundary travelling forward.
+ [firstText.length, null, false, font], // On boundary travelling backward.
+ ]) {
+ await formatHelper.selectTextRange(start, end, forward);
+ await formatHelper.assertShownFont(
+ expect,
+ `Selecting text with "${font}", from ${start} to ${end} ` +
+ `${forward ? "forwards" : "backwards"}`
+ );
+ }
+
+ // Select mixed.
+ await formatHelper.selectTextRange(3, firstText.length + 1);
+ await formatHelper.assertShownFont(null, `Mixed selection (${font})`);
+ // Select through menu.
+ let item = formatHelper.getFontMenuItem(font);
+ await formatHelper.selectFromFormatSubMenu(item, formatHelper.fontMenu);
+ // See Bug 1718225
+ // await formatHelper.assertShownFont(font, `"${font}" on more`);
+ formatHelper.assertMessageParagraph(
+ [firstText.slice(0, 3), { text: firstText.slice(3) + secondText, font }],
+ `"${font}" on more`
+ );
+
+ await formatHelper.selectFont("");
+ await formatHelper.assertShownFont("", `Cleared some "${font}"`);
+ formatHelper.assertMessageParagraph(
+ [firstText + secondText.slice(0, 1), { text: secondText.slice(1), font }],
+ `Cleared some "${font}"`
+ );
+
+ await formatHelper.emptyParagraph();
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_fixed_width() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let ttStyleItem = formatHelper.getStyleMenuItem("tt");
+
+ formatHelper.focusMessage();
+
+ // Currently, when the monospace font family is selected the UI is updated to
+ // show the tt style as selected (even though the underlying document still
+ // uses <font face="monospace"> rather than <tt>).
+
+ await formatHelper.selectFont("monospace");
+ await formatHelper.assertShownFont("monospace", "Changed to monospace");
+ // See Bug 1716840
+ // await formatHelper.assertShownStyles(
+ // "tt",
+ // "tt style shown after setting to Fixed Width",
+ // );
+ let text = "monospace text content";
+ await formatHelper.typeInMessage(text);
+ await formatHelper.assertShownFont(
+ "monospace",
+ "Still monospace when typing"
+ );
+ await formatHelper.assertShownStyles(
+ "tt",
+ "tt style shown after setting to Fixed Width and typing"
+ );
+ formatHelper.assertMessageParagraph(
+ [{ text, font: "monospace" }],
+ "monospace text"
+ );
+
+ // Trying to unset the font using Text Styles -> Fixed Width is ignored.
+ // NOTE: This is currently asymmetric: i.e. the Text Styles -> Fixed Width
+ // style *can* be removed by changing the Font to Variable Width.
+ await formatHelper.selectFromFormatSubMenu(
+ ttStyleItem,
+ formatHelper.styleMenu
+ );
+ await formatHelper.assertShownFont("monospace", "Still monospace");
+ await formatHelper.typeInMessage("+");
+ await formatHelper.assertShownFont("monospace", "Still monospace after key");
+ formatHelper.assertMessageParagraph(
+ [{ text: text + "+", font: "monospace" }],
+ "still produce monospace text"
+ );
+
+ close_compose_window(controller);
+});
diff --git a/comm/mail/test/browser/composition/browser_font_size.js b/comm/mail/test/browser/composition/browser_font_size.js
new file mode 100644
index 0000000000..a6113188c7
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_font_size.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/. */
+
+/**
+ * Test font size in messages.
+ */
+
+var { close_compose_window, open_compose_new_mail, FormatHelper } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+add_task(async function test_font_size() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ const NO_SIZE = formatHelper.NO_SIZE;
+ const MIN_SIZE = formatHelper.MIN_SIZE;
+ const MAX_SIZE = formatHelper.MAX_SIZE;
+
+ // Before focus, disabled.
+ Assert.ok(
+ formatHelper.sizeSelector.disabled,
+ "Selector should be disabled with no focus"
+ );
+
+ formatHelper.focusMessage();
+ Assert.ok(
+ !formatHelper.sizeSelector.disabled,
+ "Selector should be enabled with focus"
+ );
+
+ let firstText = "no size";
+ let secondText = "with size";
+
+ for (let size = MIN_SIZE; size <= MAX_SIZE; size++) {
+ if (size === NO_SIZE) {
+ continue;
+ }
+ await formatHelper.assertShownSize(NO_SIZE, `No size at start (${size})`);
+
+ await formatHelper.typeInMessage(firstText);
+ formatHelper.assertMessageParagraph(
+ [firstText],
+ `No size at start after typing (${size})`
+ );
+
+ // Select through toolbar.
+ await formatHelper.selectSize(size);
+ await formatHelper.assertShownSize(size, `Changed to size ${size}`);
+
+ await formatHelper.typeInMessage(secondText);
+ await formatHelper.assertShownSize(size, `Still size ${size} when typing`);
+ formatHelper.assertMessageParagraph(
+ [firstText, { text: secondText, size }],
+ `size ${size} on second half`
+ );
+
+ // Test text selections.
+ for (let [start, end, forward, expect] of [
+ // Make sure we expect changes, so the test does not capture the previous
+ // state.
+ [0, null, true, NO_SIZE], // At start.
+ [firstText.length + 1, null, true, size], // In the size region.
+ // See Bug 1718227
+ // [0, firstText.length + secondText.length, true, null], // Mixed.
+ [firstText.length, null, true, NO_SIZE], // On boundary travelling forward.
+ [firstText.length, null, false, size], // On boundary travelling backward.
+ ]) {
+ await formatHelper.selectTextRange(start, end, forward);
+ await formatHelper.assertShownSize(
+ expect,
+ `Selecting text with size ${size}, from ${start} to ${end} ` +
+ `${forward ? "forwards" : "backwards"}`
+ );
+ }
+
+ // Select mixed.
+ await formatHelper.selectTextRange(3, firstText.length + 1);
+ // See Bug 1718227
+ // await formatHelper.assertShownSize(null, `Mixed selection (${size})`);
+
+ // Select through Format menu.
+ let item = formatHelper.getSizeMenuItem(size);
+ await formatHelper.selectFromFormatSubMenu(item, formatHelper.sizeMenu);
+ await formatHelper.assertShownSize(size, `size ${size} on more`);
+ formatHelper.assertMessageParagraph(
+ [firstText.slice(0, 3), { text: firstText.slice(3) + secondText, size }],
+ `size ${size} on more`
+ );
+
+ await formatHelper.selectSize(NO_SIZE);
+ await formatHelper.assertShownSize(NO_SIZE, `Cleared some size ${size}`);
+ formatHelper.assertMessageParagraph(
+ [firstText + secondText.slice(0, 1), { text: secondText.slice(1), size }],
+ `Cleared some size ${size}`
+ );
+
+ await formatHelper.emptyParagraph();
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_font_size_increment() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ const NO_SIZE = formatHelper.NO_SIZE;
+ const MIN_SIZE = formatHelper.MIN_SIZE;
+ const MAX_SIZE = formatHelper.MAX_SIZE;
+
+ // NOTE: size=3 corresponds to no set size
+ let increaseButton = formatHelper.increaseSizeButton;
+ let decreaseButton = formatHelper.decreaseSizeButton;
+ let increaseItem = formatHelper.increaseSizeMenuItem;
+ let decreaseItem = formatHelper.decreaseSizeMenuItem;
+
+ Assert.ok(
+ increaseButton.disabled,
+ "Increase button should be disabled with no focus"
+ );
+ Assert.ok(
+ decreaseButton.disabled,
+ "Decrease button should be disabled with no focus"
+ );
+
+ formatHelper.focusMessage();
+
+ Assert.ok(
+ !increaseButton.disabled,
+ "Increase button should be enabled with focus"
+ );
+ Assert.ok(
+ !decreaseButton.disabled,
+ "Decrease button should be enabled with focus"
+ );
+
+ async function assertShownAndDisabled(formatHelper, size, message) {
+ await formatHelper.assertShownSize(size, message);
+ switch (size) {
+ case MAX_SIZE:
+ Assert.ok(
+ increaseButton.disabled,
+ `${message}: Increase button should be disabled at max size ${size}`
+ );
+ Assert.ok(
+ !decreaseButton.disabled,
+ `${message}: Decrease button should be enabled at max size ${size}`
+ );
+ await formatHelper.assertWithFormatSubMenu(
+ formatHelper.sizeMenu,
+ () => increaseItem.disabled && !decreaseItem.disabled,
+ `Only the increase menu item should be disabled at max size ${size}`
+ );
+ break;
+ case MIN_SIZE:
+ Assert.ok(
+ !increaseButton.disabled,
+ `${message}: Increase button should be enabled at min size ${size}`
+ );
+ Assert.ok(
+ decreaseButton.disabled,
+ `${message}: Decrease button should be disabled at min size ${size}`
+ );
+ await formatHelper.assertWithFormatSubMenu(
+ formatHelper.sizeMenu,
+ () => !increaseItem.disabled && decreaseItem.disabled,
+ `Only the decrease menu item should be disabled at min size ${size}`
+ );
+ break;
+ default:
+ Assert.ok(
+ !increaseButton.disabled,
+ `${message}: Increase button should be enabled at size ${size}`
+ );
+ Assert.ok(
+ !decreaseButton.disabled,
+ `${message}: Decrease button should be enabled at size ${size}`
+ );
+ await formatHelper.assertWithFormatSubMenu(
+ formatHelper.sizeMenu,
+ () => !increaseItem.disabled && !decreaseItem.disabled,
+ `No menu items should be disabled at size ${size}`
+ );
+ break;
+ }
+ }
+
+ async function assertAndType(formatHelper, size, text, content, message) {
+ await assertShownAndDisabled(
+ formatHelper,
+ size,
+ `${message}: At size ${size}`
+ );
+
+ await formatHelper.typeInMessage(text);
+ await assertShownAndDisabled(
+ formatHelper,
+ size,
+ `${message}: At size ${size} and typing`
+ );
+
+ content.push({ text, size });
+ formatHelper.assertMessageParagraph(
+ content,
+ `${message}: Added size ${size}`
+ );
+ }
+
+ let content = [];
+ let size = NO_SIZE;
+
+ let text = "start";
+ await formatHelper.typeInMessage(text);
+ content.push(text);
+ formatHelper.assertMessageParagraph(content, "Start with no font");
+
+ await assertShownAndDisabled(formatHelper, size, "At start");
+
+ for (size++; size <= MAX_SIZE; size++) {
+ increaseButton.click();
+ await assertAndType(
+ formatHelper,
+ size,
+ `step up to ${size}`,
+ content,
+ `Increase step with button`
+ );
+ }
+
+ // Reverse direction.
+ for (size = MAX_SIZE - 1; size > NO_SIZE; size--) {
+ decreaseButton.click();
+ await assertAndType(
+ formatHelper,
+ size,
+ `step down to ${size}`,
+ content,
+ `Decrease step with button`
+ );
+ }
+
+ decreaseButton.click();
+ text = "middle";
+ await formatHelper.typeInMessage(text);
+ content.push(text);
+ await assertShownAndDisabled(formatHelper, size, "At middle");
+
+ for (size--; size >= MIN_SIZE; size--) {
+ // Use menu item.
+ await formatHelper.selectFromFormatSubMenu(
+ decreaseItem,
+ formatHelper.sizeMenu
+ );
+ await assertAndType(
+ formatHelper,
+ size,
+ `step down to ${size}`,
+ content,
+ `Decrease step with menu item`
+ );
+ }
+
+ for (size = MIN_SIZE + 1; size < NO_SIZE; size++) {
+ // Use menu item.
+ await formatHelper.selectFromFormatSubMenu(
+ increaseItem,
+ formatHelper.sizeMenu
+ );
+ await assertAndType(
+ formatHelper,
+ size,
+ `step up to ${size}`,
+ content,
+ `Increase step with menu item`
+ );
+ }
+
+ await formatHelper.emptyParagraph();
+
+ // Selecting max or min sizes directly also enables or disables the
+ // increase/decrease buttons and items.
+ await formatHelper.selectSize(MAX_SIZE);
+ await assertShownAndDisabled(formatHelper, MAX_SIZE, "Direct to max size");
+ await formatHelper.selectSize(NO_SIZE);
+ await assertShownAndDisabled(formatHelper, NO_SIZE, "Direct to no size");
+ await formatHelper.selectSize(MIN_SIZE);
+ await assertShownAndDisabled(formatHelper, MIN_SIZE, "Direct to min size");
+
+ // Type at min size.
+ text = "text to select";
+ await formatHelper.typeInMessage(text);
+ formatHelper.assertMessageParagraph([{ text, size: MIN_SIZE }], "Min text");
+ // Select all.
+ await formatHelper.selectTextRange(0, text.length);
+
+ for (size = MIN_SIZE + 1; size <= MAX_SIZE; size++) {
+ increaseButton.click();
+ await assertShownAndDisabled(
+ formatHelper,
+ size,
+ `Increase selection to size ${size}`
+ );
+ if (size === NO_SIZE) {
+ formatHelper.assertMessageParagraph([text], "Increase to middle size");
+ } else {
+ formatHelper.assertMessageParagraph(
+ [{ text, size }],
+ `Increase to size ${size}`
+ );
+ }
+ }
+
+ // Reverse
+ for (size = MAX_SIZE - 1; size >= MIN_SIZE; size--) {
+ decreaseButton.click();
+ await assertShownAndDisabled(
+ formatHelper,
+ size,
+ `Decrease selection to size ${size}`
+ );
+ if (size === NO_SIZE) {
+ formatHelper.assertMessageParagraph([text], "Decrease to middle size");
+ } else {
+ formatHelper.assertMessageParagraph(
+ [{ text, size }],
+ `Decrease to size ${size}`
+ );
+ }
+ }
+
+ close_compose_window(controller);
+});
diff --git a/comm/mail/test/browser/composition/browser_forwardDefectiveCharset.js b/comm/mail/test/browser/composition/browser_forwardDefectiveCharset.js
new file mode 100644
index 0000000000..083a3a8161
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_forwardDefectiveCharset.js
@@ -0,0 +1,109 @@
+/* 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/. */
+
+/**
+ * Tests that messages without properly declared charset are correctly forwarded.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_forward } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ mc,
+ open_message_from_file,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folder;
+
+// Some text from defective-charset.eml
+const SOME_SPANISH = "estos últimos meses siento";
+
+add_setup(async function () {
+ folder = await create_folder("FolderWithDefectiveCharset");
+ registerCleanupFunction(() => folder.deleteSelf(null));
+});
+
+add_task(async function test_forward_direct() {
+ let file = new FileUtils.File(getTestFilePath("data/defective-charset.eml"));
+ let msgc = await open_message_from_file(file);
+
+ let cwc = open_compose_with_forward(msgc);
+
+ let mailText =
+ cwc.window.document.getElementById("messageEditor").contentDocument.body
+ .textContent;
+
+ Assert.ok(
+ mailText.includes(SOME_SPANISH),
+ "forwarded content should be correctly encoded"
+ );
+
+ close_compose_window(cwc);
+ close_window(msgc);
+});
+
+add_task(async function test_forward_from_folder() {
+ await be_in_folder(folder);
+
+ let file = new FileUtils.File(getTestFilePath("data/defective-charset.eml"));
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+
+ // Copy the message to a folder.
+ let documentChild =
+ aboutMessage.document.getElementById("messagepane").contentDocument
+ .documentElement;
+ EventUtils.synthesizeMouseAtCenter(
+ documentChild,
+ { type: "contextmenu", button: 2 },
+ documentChild.ownerGlobal
+ );
+ await click_menus_in_sequence(
+ aboutMessage.document.getElementById("mailContext"),
+ [
+ { id: "mailContext-copyMenu" },
+ { label: "Local Folders" },
+ { label: folder.name },
+ ]
+ );
+ close_window(msgc);
+
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ Assert.ok(
+ get_about_message()
+ .document.getElementById("messagepane")
+ .contentDocument.body.textContent.includes(SOME_SPANISH)
+ );
+
+ let cwc = open_compose_with_forward();
+
+ let mailText =
+ cwc.window.document.getElementById("messageEditor").contentDocument.body
+ .textContent;
+
+ Assert.ok(
+ mailText.includes(SOME_SPANISH),
+ "forwarded content should be correctly encoded"
+ );
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_forwardHeaders.js b/comm/mail/test/browser/composition/browser_forwardHeaders.js
new file mode 100644
index 0000000000..1568f134b0
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_forwardHeaders.js
@@ -0,0 +1,175 @@
+/* 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/. */
+
+/**
+ * Tests that headers like References and X-Forwarded-Message-Id are
+ * set properly when forwarding messages.
+ */
+
+"use strict";
+
+var {
+ assert_previous_text,
+ get_compose_body,
+ open_compose_with_forward,
+ open_compose_with_forward_as_attachments,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ add_message_sets_to_folders,
+ be_in_folder,
+ create_folder,
+ create_thread,
+ get_special_folder,
+ make_display_unthreaded,
+ mc,
+ press_delete,
+ select_click_row,
+ select_shift_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { MsgHdrToMimeMessage } = ChromeUtils.import(
+ "resource:///modules/gloda/MimeMessage.jsm"
+);
+var { plan_for_window_close, wait_for_window_close } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var cwc = null; // compose window controller
+var folder;
+var gDrafts;
+
+add_setup(async function () {
+ folder = await create_folder("Test");
+ let thread1 = create_thread(10);
+ await add_message_sets_to_folders([folder], [thread1]);
+
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+
+ // Don't create paragraphs in the test.
+ // The test checks for the first DOM node and expects a text and not
+ // a paragraph.
+ Services.prefs.setBoolPref("mail.compose.default_to_paragraph", false);
+});
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("mail.compose.default_to_paragraph");
+});
+
+async function forward_selected_messages_and_go_to_drafts_folder(f) {
+ const kText = "Hey check out this megalol link";
+ // opening a new compose window
+ cwc = f(mc);
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString(kText, cwc.window);
+
+ let mailBody = get_compose_body(cwc);
+ assert_previous_text(mailBody.firstChild, [kText]);
+
+ plan_for_window_close(cwc);
+ // mwc is modal window controller
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ // quit -> do you want to save ?
+ cwc.window.goDoCommand("cmd_close");
+ await dialogPromise;
+ // Actually quit the window.
+ wait_for_window_close();
+
+ // Visit the existing Drafts folder.
+ await be_in_folder(gDrafts);
+ make_display_unthreaded();
+}
+
+add_task(async function test_forward_inline() {
+ await be_in_folder(folder);
+ make_display_unthreaded();
+ // original message header
+ let oMsgHdr = select_click_row(0);
+
+ await forward_selected_messages_and_go_to_drafts_folder(
+ open_compose_with_forward
+ );
+
+ // forwarded message header
+ let fMsgHdr = select_click_row(0);
+
+ Assert.ok(
+ fMsgHdr.numReferences > 0,
+ "No References Header in forwarded msg."
+ );
+ Assert.equal(
+ fMsgHdr.getStringReference(0),
+ oMsgHdr.messageId,
+ "The forwarded message should have References: = Message-Id: of the original msg"
+ );
+
+ // test for x-forwarded-message id and exercise the js mime representation as
+ // well
+ return new Promise(resolve => {
+ MsgHdrToMimeMessage(fMsgHdr, null, function (aMsgHdr, aMimeMsg) {
+ Assert.equal(
+ aMimeMsg.headers["x-forwarded-message-id"],
+ "<" + oMsgHdr.messageId + ">"
+ );
+ Assert.equal(aMimeMsg.headers.references, "<" + oMsgHdr.messageId + ">");
+
+ press_delete(mc);
+ resolve();
+ });
+ });
+});
+
+add_task(async function test_forward_as_attachments() {
+ await be_in_folder(folder);
+ make_display_unthreaded();
+
+ // original message header
+ let oMsgHdr0 = select_click_row(0);
+ let oMsgHdr1 = select_click_row(1);
+ select_shift_click_row(0);
+
+ await forward_selected_messages_and_go_to_drafts_folder(
+ open_compose_with_forward_as_attachments
+ );
+
+ // forwarded message header
+ let fMsgHdr = select_click_row(0);
+
+ Assert.ok(
+ fMsgHdr.numReferences > 0,
+ "No References Header in forwarded msg."
+ );
+ Assert.ok(
+ fMsgHdr.numReferences > 1,
+ "Only one References Header in forwarded msg."
+ );
+ Assert.equal(
+ fMsgHdr.getStringReference(1),
+ oMsgHdr1.messageId,
+ "The forwarded message should have References: = Message-Id: of the original msg#1"
+ );
+ Assert.equal(
+ fMsgHdr.getStringReference(0),
+ oMsgHdr0.messageId,
+ "The forwarded message should have References: = Message-Id: of the original msg#0"
+ );
+
+ // test for x-forwarded-message id and exercise the js mime representation as
+ // well
+ return new Promise(resolve => {
+ MsgHdrToMimeMessage(fMsgHdr, null, function (aMsgHdr, aMimeMsg) {
+ Assert.equal(
+ aMimeMsg.headers["x-forwarded-message-id"],
+ "<" + oMsgHdr0.messageId + "> <" + oMsgHdr1.messageId + ">"
+ );
+ Assert.equal(
+ aMimeMsg.headers.references,
+ "<" + oMsgHdr0.messageId + "> <" + oMsgHdr1.messageId + ">"
+ );
+
+ press_delete(mc);
+ resolve();
+ });
+ });
+});
diff --git a/comm/mail/test/browser/composition/browser_forwardRFC822Attach.js b/comm/mail/test/browser/composition/browser_forwardRFC822Attach.js
new file mode 100644
index 0000000000..2bcd136aa2
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_forwardRFC822Attach.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/. */
+
+/**
+ * Tests that attached messages (message/rfc822) are correctly sent.
+ * It's easiest to test the forward case.
+ */
+
+"use strict";
+
+var {
+ close_compose_window,
+ get_msg_source,
+ open_compose_with_forward_as_attachments,
+ save_compose_message,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ get_special_folder,
+ mc,
+ open_message_from_file,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gDrafts;
+
+add_setup(async function () {
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+async function forwardDirect(aFilePath, aExpectedText) {
+ let file = new FileUtils.File(getTestFilePath(`data/${aFilePath}`));
+ let msgc = await open_message_from_file(file);
+
+ let cwc = open_compose_with_forward_as_attachments(msgc);
+
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+ close_window(msgc);
+
+ await be_in_folder(gDrafts);
+ let draftMsg = select_click_row(0);
+
+ let draftMsgContent = await get_msg_source(draftMsg);
+
+ Assert.ok(
+ draftMsgContent.includes(aExpectedText),
+ "Failed to find expected text"
+ );
+
+ press_delete(mc); // clean up the created draft
+}
+
+add_task(async function test_forwarding_long_html_line_as_attachment() {
+ await forwardDirect("./long-html-line.eml", "We like writing long lines.");
+});
+
+add_task(async function test_forwarding_feed_message_as_attachment() {
+ await forwardDirect("./feed-message.eml", "We like using linefeeds only.");
+});
diff --git a/comm/mail/test/browser/composition/browser_forwardUTF8.js b/comm/mail/test/browser/composition/browser_forwardUTF8.js
new file mode 100644
index 0000000000..494e722bdb
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_forwardUTF8.js
@@ -0,0 +1,149 @@
+/* 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/. */
+
+/**
+ * Tests that UTF-8 messages are correctly forwarded.
+ */
+
+"use strict";
+
+var { close_compose_window, get_compose_body, open_compose_with_forward } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ mc,
+ open_message_from_file,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folderToSendFrom;
+
+add_setup(async function () {
+ requestLongerTimeout(2);
+ folderToSendFrom = await create_folder("FolderWithUTF8");
+});
+
+function check_content(window) {
+ let mailBody = get_compose_body(window);
+
+ let node = mailBody.firstChild;
+ while (node) {
+ if (node.classList.contains("moz-forward-container")) {
+ // We found the forward container. Let's look for our text.
+ node = node.firstChild;
+ while (node) {
+ // We won't find the exact text in the DOM but we'll find our string.
+ if (node.nodeName == "#text" && node.nodeValue.includes("áóúäöüß")) {
+ return;
+ }
+ node = node.nextSibling;
+ }
+ // Text not found in the forward container.
+ Assert.ok(false, "Failed to find forwarded text");
+ return;
+ }
+ node = node.nextSibling;
+ }
+
+ Assert.ok(false, "Failed to find forward container");
+}
+
+async function forwardDirect(aFilePath) {
+ let file = new FileUtils.File(getTestFilePath(`data/${aFilePath}`));
+ let msgc = await open_message_from_file(file);
+
+ let cwc = open_compose_with_forward(msgc);
+
+ check_content(cwc);
+
+ close_compose_window(cwc);
+ close_window(msgc);
+}
+
+async function forwardViaFolder(aFilePath) {
+ await be_in_folder(folderToSendFrom);
+
+ let file = new FileUtils.File(getTestFilePath(`data/${aFilePath}`));
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+
+ // Copy the message to a folder.
+ let documentChild =
+ aboutMessage.document.getElementById("messagepane").contentDocument
+ .documentElement;
+ EventUtils.synthesizeMouseAtCenter(
+ documentChild,
+ { type: "contextmenu", button: 2 },
+ documentChild.ownerGlobal
+ );
+ await click_menus_in_sequence(
+ aboutMessage.document.getElementById("mailContext"),
+ [
+ { id: "mailContext-copyMenu" },
+ { label: "Local Folders" },
+ { label: "FolderWithUTF8" },
+ ]
+ );
+ close_window(msgc);
+
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ Assert.ok(
+ get_about_message()
+ .document.getElementById("messagepane")
+ .contentDocument.body.textContent.includes("áóúäöüß")
+ );
+
+ let fwdWin = open_compose_with_forward();
+
+ check_content(fwdWin);
+
+ close_compose_window(fwdWin);
+
+ press_delete(mc);
+}
+
+add_task(async function test_utf8_forwarding_from_opened_file() {
+ await forwardDirect("./content-utf8-rel-only.eml");
+ await forwardDirect("./content-utf8-rel-alt.eml");
+ await forwardDirect("./content-utf8-alt-rel.eml");
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
+
+add_task(async function test_utf8_forwarding_from_via_folder() {
+ await forwardViaFolder("./content-utf8-rel-only.eml");
+ await forwardViaFolder("./content-utf8-rel-alt.eml"); // Also tests HTML part without <html> tag.
+ await forwardViaFolder("./content-utf8-alt-rel.eml"); // Also tests <html attr>.
+ await forwardViaFolder("./content-utf8-alt-rel2.eml"); // Also tests content before <html>.
+
+ // Repeat the last three in simple HTML view.
+ Services.prefs.setIntPref("mailnews.display.html_as", 3);
+ await forwardViaFolder("./content-utf8-rel-alt.eml"); // Also tests HTML part without <html> tag.
+ await forwardViaFolder("./content-utf8-alt-rel.eml"); // Also tests <html attr>.
+ await forwardViaFolder("./content-utf8-alt-rel2.eml"); // Also tests content before <html>.
+});
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("mailnews.display.html_as");
+});
diff --git a/comm/mail/test/browser/composition/browser_forwardedContent.js b/comm/mail/test/browser/composition/browser_forwardedContent.js
new file mode 100644
index 0000000000..7f8b25130d
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_forwardedContent.js
@@ -0,0 +1,70 @@
+/* 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/. */
+
+/**
+ * Tests that forwarded content is ok.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_forward } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ create_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder = null;
+
+add_setup(async function () {
+ folder = await create_folder("Forward Content Testing");
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ subject: "something like <foo@example>",
+ body: { body: "Testing bug 397021!" },
+ })
+ );
+});
+
+/**
+ * Test that the subject is set properly in the forwarded message content
+ * when you hit forward.
+ */
+add_task(async function test_forwarded_subj() {
+ await be_in_folder(folder);
+
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ let fwdWin = open_compose_with_forward();
+
+ let headerTableText = fwdWin.window.document
+ .getElementById("messageEditor")
+ .contentDocument.querySelector("table").textContent;
+ if (!headerTableText.includes(msg.mime2DecodedSubject)) {
+ throw new Error(
+ "Subject not set correctly in header table: subject=" +
+ msg.mime2DecodedSubject +
+ ", header table text=" +
+ headerTableText
+ );
+ }
+ close_compose_window(fwdWin);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/composition/browser_forwardedEmlActions.js b/comm/mail/test/browser/composition/browser_forwardedEmlActions.js
new file mode 100644
index 0000000000..609f6c1107
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_forwardedEmlActions.js
@@ -0,0 +1,171 @@
+/* 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/. */
+
+/**
+ * Tests that actions such as replying and forwarding works correctly from
+ * an .eml message that's attached to another mail.
+ */
+
+"use strict";
+
+var { async_wait_for_compose_window, close_compose_window, get_compose_body } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_tab,
+ create_folder,
+ get_about_message,
+ mc,
+ select_click_row,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { async_plan_for_new_window, close_window, wait_for_new_window } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folder;
+
+var msgsubject = "mail client suggestions";
+var msgbodyA = "know of a good email client?";
+var msgbodyB = "hi, i think you may know of an email client to recommend?";
+
+add_setup(async function () {
+ folder = await create_folder("FwdedEmlTest");
+
+ let source =
+ "From - Mon Apr 16 22:55:33 2012\n" +
+ "Date: Mon, 16 Apr 2012 22:55:33 +0300\n" +
+ "From: Mr Example <example@invalid>\n" +
+ "User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:14.0) Gecko/20120331 Thunderbird/14.0a1\n" +
+ "MIME-Version: 1.0\n" +
+ "To: example@invalid\n" +
+ "Subject: Fwd: " +
+ msgsubject +
+ "\n" +
+ "References: <4F8C78F5.4000704@invalid>\n" +
+ "In-Reply-To: <4F8C78F5.4000704@invalid>\n" +
+ "X-Forwarded-Message-Id: <4F8C78F5.4000704@invalid>\n" +
+ "Content-Type: multipart/mixed;\n" +
+ ' boundary="------------080806020206040800000503"\n' +
+ "\n" +
+ "This is a multi-part message in MIME format.\n" +
+ "--------------080806020206040800000503\n" +
+ "Content-Type: text/plain; charset=ISO-8859-1; format=flowed\n" +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "\n" +
+ msgbodyB +
+ "\n" +
+ "\n" +
+ "--------------080806020206040800000503\n" +
+ "Content-Type: message/rfc822;\n" +
+ ' name="mail client suggestions.eml"\n' +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "Content-Disposition: attachment;\n" +
+ ' filename="mail client suggestions.eml"\n' +
+ "\n" +
+ "Return-Path: <example@invalid>\n" +
+ "Received: from xxx (smtpu [10.0.0.51])\n" +
+ " by storage (Cyrus v2.3.7-Invoca-RPM-2.3.7-1.1) with LMTPA;\n" +
+ " Mon, 16 Apr 2012 22:54:36 +0300\n" +
+ "Message-ID: <4F8C78F5.4000704@invalid>\n" +
+ "Date: Mon, 16 Apr 2012 22:54:29 +0300\n" +
+ "From: Mr Example <example@invalid>\n" +
+ "User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:14.0) Gecko/20120331 Thunderbird/14.0a1\n" +
+ "MIME-Version: 1.0\n" +
+ "To: example@invalid\n" +
+ "Subject: mail client suggestions\n" +
+ "Content-Type: text/plain; charset=ISO-8859-1; format=flowed\n" +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "\n" +
+ msgbodyA +
+ "\n" +
+ "\n" +
+ "--------------080806020206040800000503--\n";
+
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folder.addMessage(source);
+});
+
+/**
+ * Helper to open an attached .eml file, invoke the hotkey and check some
+ * properties of the composition content we get.
+ */
+async function setupWindowAndTest(hotkeyToHit, hotkeyModifiers) {
+ await be_in_folder(folder);
+
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ let tabSelectPromise = BrowserTestUtils.waitForEvent(
+ mc.window.document.getElementById("tabmail").tabContainer,
+ "select"
+ );
+ let aboutMessage = get_about_message();
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentName"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+ await tabSelectPromise;
+ wait_for_message_display_completion(mc, false);
+
+ let newWindowPromise = async_plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(hotkeyToHit, hotkeyModifiers, window);
+ let compWin = await async_wait_for_compose_window(window, newWindowPromise);
+
+ let bodyText = get_compose_body(compWin).textContent;
+ if (bodyText.includes("html")) {
+ throw new Error("body text contains raw html; bodyText=" + bodyText);
+ }
+
+ if (!bodyText.includes(msgbodyA)) {
+ throw new Error(
+ "body text didn't contain the body text; msgbodyA=" +
+ msgbodyB +
+ ", bodyText=" +
+ bodyText
+ );
+ }
+
+ let subjectText = compWin.window.document.getElementById("msgSubject").value;
+ if (!subjectText.includes(msgsubject)) {
+ throw new Error(
+ "subject text didn't contain the original subject; " +
+ "msgsubject=" +
+ msgsubject +
+ ", subjectText=" +
+ subjectText
+ );
+ }
+
+ close_compose_window(compWin, false);
+ close_tab(mc.window.document.getElementById("tabmail").currentTabInfo);
+}
+
+/**
+ * Test that replying to an attached .eml contains the expected texts.
+ */
+add_task(function test_reply_to_attached_eml() {
+ return setupWindowAndTest("R", { shiftKey: false, accelKey: true });
+});
+
+/**
+ * Test that forwarding an attached .eml contains the expected texts.
+ */
+add_task(async function test_forward_attached_eml() {
+ await setupWindowAndTest("L", { shiftKey: false, accelKey: true });
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/composition/browser_imageDisplay.js b/comm/mail/test/browser/composition/browser_imageDisplay.js
new file mode 100644
index 0000000000..8d759832a9
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_imageDisplay.js
@@ -0,0 +1,172 @@
+/* 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/. */
+
+/**
+ * Tests that we load and display embedded images in messages.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var {
+ close_compose_window,
+ open_compose_with_forward,
+ open_compose_with_reply,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ mc,
+ open_message_from_file,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var gImageFolder;
+
+add_setup(async function () {
+ gImageFolder = await create_folder("ImageFolder");
+ registerCleanupFunction(() => {
+ gImageFolder.deleteSelf(null);
+ });
+});
+
+/**
+ * Check dimensions of the embedded image and whether it could be loaded.
+ */
+async function check_image_size(aController, aImage, aSrcStart) {
+ Assert.notEqual(null, aImage, "should have a image");
+ await TestUtils.waitForCondition(
+ () => aImage.complete,
+ "waiting for image.complete"
+ );
+ // There should not be a cid: URL now.
+ Assert.ok(!aImage.src.startsWith("cid:"));
+ if (aSrcStart) {
+ Assert.ok(aImage.src.startsWith(aSrcStart));
+ }
+
+ // Check if there are height and width attributes forcing the image to a size.
+ let id = aImage.id;
+ Assert.ok(
+ aImage.hasAttribute("height"),
+ "Image " + id + " is missing a required attribute"
+ );
+ Assert.ok(
+ aImage.hasAttribute("width"),
+ "Image " + id + " is missing a required attribute"
+ );
+
+ Assert.ok(
+ aImage.height > 0,
+ "Image " + id + " is missing a required attribute"
+ );
+ Assert.ok(
+ aImage.width > 0,
+ "Image " + id + " is missing a required attribute"
+ );
+
+ // If the image couldn't be loaded, the naturalWidth and Height are zero.
+ Assert.ok(
+ aImage.naturalHeight > 0,
+ "Loaded image " + id + " is of zero size"
+ );
+ Assert.ok(aImage.naturalWidth > 0, "Loaded image " + id + " is of zero size");
+}
+
+/**
+ * Bug 1352701 and bug 1360443
+ * Test that showing an image with cid: URL in a HTML message from file will work.
+ */
+add_task(async function test_cid_image_load() {
+ let file = new FileUtils.File(
+ getTestFilePath("data/content-utf8-rel-only.eml")
+ );
+
+ // Make sure there is a cid: referenced image in the message.
+ let msgSource = await IOUtils.readUTF8(file.path);
+ Assert.ok(msgSource.includes('<img src="cid:'));
+
+ // Our image should be in the loaded eml document.
+ let msgc = await open_message_from_file(file);
+ let messageDoc = msgc.window.content.document;
+ let image = messageDoc.getElementById("cidImage");
+ await check_image_size(msgc, image, "mailbox://");
+ image = messageDoc.getElementById("cidImageOrigin");
+ check_image_size(msgc, image, "mailbox://");
+
+ // Copy the message to a folder.
+ let documentChild = messageDoc.firstElementChild;
+ EventUtils.synthesizeMouseAtCenter(
+ documentChild,
+ { type: "contextmenu", button: 2 },
+ documentChild.ownerGlobal
+ );
+ let aboutMessage = get_about_message(msgc.window);
+ await click_menus_in_sequence(
+ aboutMessage.document.getElementById("mailContext"),
+ [
+ { id: "mailContext-copyMenu" },
+ { label: "Local Folders" },
+ { label: gImageFolder.prettyName },
+ ]
+ );
+ close_window(msgc);
+});
+
+/**
+ * Bug 1352701 and bug 1360443
+ * Test that showing an image with cid: URL in a HTML message in a folder with work.
+ */
+add_task(async function test_cid_image_view() {
+ // Preview the message in the folder.
+ await be_in_folder(gImageFolder);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ // Check image in the preview.
+ let messageDoc =
+ get_about_message().document.getElementById("messagepane").contentDocument;
+ let image = messageDoc.getElementById("cidImage");
+ await check_image_size(mc, image, gImageFolder.server.localStoreType + "://");
+ image = messageDoc.getElementById("cidImageOrigin");
+ await check_image_size(mc, image, gImageFolder.server.localStoreType + "://");
+});
+
+/**
+ * Bug 1352701 and bug 1360443
+ * Test that showing an image with cid: URL in a HTML message will work
+ * in a composition.
+ */
+async function check_cid_image_compose(cwc) {
+ // Our image should also be in composition when the message is forwarded/replied.
+ let image = cwc.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementById("cidImage");
+ await check_image_size(cwc, image, "data:");
+ image = cwc.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementById("cidImageOrigin");
+ await check_image_size(cwc, image, "data:");
+}
+
+add_task(async function test_cid_image_compose_fwd() {
+ // Our image should also be in composition when the message is forwarded.
+ let cwc = open_compose_with_forward();
+ await check_cid_image_compose(cwc);
+ close_compose_window(cwc);
+});
+
+add_task(async function test_cid_image_compose_re() {
+ // Our image should also be in composition when the message is replied.
+ let cwc = open_compose_with_reply();
+ await check_cid_image_compose(cwc);
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_imageInsertionDialog.js b/comm/mail/test/browser/composition/browser_imageInsertionDialog.js
new file mode 100644
index 0000000000..83a6e3d52f
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_imageInsertionDialog.js
@@ -0,0 +1,164 @@
+/* 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/. */
+
+/**
+ * Tests the image insertion dialog functionality.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_new_mail } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var { input_value } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+
+var {
+ click_menus_in_sequence,
+ plan_for_modal_dialog,
+ wait_for_window_close,
+ wait_for_modal_dialog,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+add_task(async function test_image_insertion_dialog_persist() {
+ let cwc = open_compose_new_mail();
+
+ // First focus on the editor element
+ cwc.window.document.getElementById("messageEditor").focus();
+
+ // Now open the image window
+ plan_for_modal_dialog("Mail:image", async function insert_image(mwc) {
+ // Insert the url of the image.
+ let srcloc = mwc.window.document.getElementById("srcInput");
+ srcloc.focus();
+
+ let file = new FileUtils.File(getTestFilePath("data/tb-logo.png"));
+ input_value(mwc, Services.io.newFileURI(file).spec);
+
+ // Don't add alternate text
+ let noAlt = mwc.window.document.getElementById("noAltTextRadio");
+ EventUtils.synthesizeMouseAtCenter(noAlt, {}, noAlt.ownerGlobal);
+ await new Promise(resolve => setTimeout(resolve));
+ mwc.window.document.documentElement.querySelector("dialog").acceptDialog();
+ });
+
+ let insertMenu = cwc.window.document.getElementById("InsertPopupButton");
+ let insertMenuPopup = cwc.window.document.getElementById("InsertPopup");
+
+ EventUtils.synthesizeMouseAtCenter(insertMenu, {}, insertMenu.ownerGlobal);
+ await click_menus_in_sequence(insertMenuPopup, [{ id: "InsertImageItem" }]);
+
+ wait_for_modal_dialog();
+ wait_for_window_close();
+ await new Promise(resolve => setTimeout(resolve));
+
+ let img = cwc.window.document
+ .getElementById("messageEditor")
+ .contentDocument.querySelector("img");
+ Assert.ok(!!img, "editor should contain an image");
+
+ info("Will check that radio option persists");
+
+ // Check that the radio option persists
+ plan_for_modal_dialog("Mail:image", async function insert_image(mwc) {
+ Assert.ok(
+ mwc.window.document.getElementById("noAltTextRadio").selected,
+ "We should persist the previously selected value"
+ );
+ // We change to "use alt text"
+ let altTextRadio = mwc.window.document.getElementById("altTextRadio");
+ EventUtils.synthesizeMouseAtCenter(
+ altTextRadio,
+ {},
+ altTextRadio.ownerGlobal
+ );
+ await new Promise(resolve => setTimeout(resolve));
+ mwc.window.document.documentElement.querySelector("dialog").cancelDialog();
+ });
+
+ EventUtils.synthesizeMouseAtCenter(insertMenu, {}, insertMenu.ownerGlobal);
+ await click_menus_in_sequence(insertMenuPopup, [{ id: "InsertImageItem" }]);
+ wait_for_modal_dialog();
+ wait_for_window_close();
+ await new Promise(resolve => setTimeout(resolve));
+
+ info("Will check that radio option really persists");
+
+ // Check that the radio option still persists (be really sure)
+ plan_for_modal_dialog("Mail:image", function insert_image(mwc) {
+ Assert.ok(
+ mwc.window.document.getElementById("altTextRadio").selected,
+ "We should persist the previously selected value"
+ );
+ // Accept the dialog
+ mwc.window.document.documentElement.querySelector("dialog").cancelDialog();
+ });
+
+ EventUtils.synthesizeMouseAtCenter(insertMenu, {}, insertMenu.ownerGlobal);
+ await click_menus_in_sequence(insertMenuPopup, [{ id: "InsertImageItem" }]);
+ wait_for_modal_dialog();
+ wait_for_window_close();
+
+ info("Will check we switch to 'no alt text'");
+
+ // Get the inserted image, double-click it, make sure we switch to "no alt
+ // text", despite the persisted value being "use alt text"
+ plan_for_modal_dialog("Mail:image", function insert_image(mwc) {
+ Assert.ok(
+ mwc.window.document.getElementById("noAltTextRadio").selected,
+ "We shouldn't use the persisted value because the insert image has no alt text"
+ );
+ mwc.window.document.documentElement.querySelector("dialog").cancelDialog();
+ });
+ EventUtils.synthesizeMouseAtCenter(img, { clickCount: 2 }, img.ownerGlobal);
+ wait_for_modal_dialog();
+ wait_for_window_close();
+
+ info("Will check using alt text");
+
+ // Now use some alt text for the edit image dialog
+ plan_for_modal_dialog("Mail:image", async function insert_image(mwc) {
+ Assert.ok(
+ mwc.window.document.getElementById("noAltTextRadio").selected,
+ "That value should persist still..."
+ );
+ let altTextRadio = mwc.window.document.getElementById("altTextRadio");
+ EventUtils.synthesizeMouseAtCenter(
+ altTextRadio,
+ {},
+ altTextRadio.ownerGlobal
+ );
+
+ let srcloc = mwc.window.document.getElementById("altTextInput");
+ srcloc.focus();
+ input_value(mwc, "some alt text");
+ await new Promise(resolve => setTimeout(resolve));
+ // Accept the dialog
+ mwc.window.document.documentElement.querySelector("dialog").acceptDialog();
+ });
+ EventUtils.synthesizeMouseAtCenter(img, { clickCount: 2 }, img.ownerGlobal);
+ wait_for_modal_dialog();
+ wait_for_window_close();
+
+ info("Will check next time we edit, we still have 'use alt text' selected");
+
+ // Make sure next time we edit it, we still have "use alt text" selected.
+ img = cwc.window.document
+ .getElementById("messageEditor")
+ .contentDocument.querySelector("img");
+ plan_for_modal_dialog("Mail:image", function insert_image(mwc) {
+ Assert.ok(
+ mwc.window.document.getElementById("altTextRadio").selected,
+ "We edited the image to make it have alt text, we should keep it selected"
+ );
+ // Accept the dialog
+ mwc.window.document.documentElement.querySelector("dialog").cancelDialog();
+ });
+ EventUtils.synthesizeMouseAtCenter(img, { clickCount: 2 }, img.ownerGlobal);
+ wait_for_modal_dialog();
+ wait_for_window_close();
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_inlineImage.js b/comm/mail/test/browser/composition/browser_inlineImage.js
new file mode 100644
index 0000000000..5b26b05ac5
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_inlineImage.js
@@ -0,0 +1,112 @@
+/* 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 sending message with inline image.
+ */
+
+var { get_msg_source, open_compose_new_mail, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ get_special_folder,
+ get_about_message,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { plan_for_window_close, wait_for_window_close } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gOutboxFolder;
+
+var kBoxId = "compose-notification-bottom";
+var kNotificationId = "blockedContent";
+
+function typedArrayToString(buffer) {
+ var string = "";
+ for (let i = 0; i < buffer.length; i += 100) {
+ string += String.fromCharCode.apply(undefined, buffer.subarray(i, i + 100));
+ }
+ return string;
+}
+
+function putHTMLOnClipboard(html) {
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+
+ // Register supported data flavors
+ trans.init(null);
+ trans.addDataFlavor("text/html");
+
+ let wapper = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wapper.data = html;
+ trans.setTransferData("text/html", wapper);
+
+ Services.clipboard.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
+}
+
+add_setup(async function () {
+ gOutboxFolder = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+});
+
+/**
+ * Tests that sending message with inline image works, and we pick a file name
+ * for data uri if needed.
+ */
+add_task(async function test_send_inline_image() {
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "someone@example.com",
+ "Test sending inline image",
+ "The image doesn't display because we changed the data URI\n"
+ );
+
+ let fileBuf = await IOUtils.read(getTestFilePath("data/nest.png"));
+ let fileContent = btoa(typedArrayToString(fileBuf));
+ let dataURI = `data:image/png;base64,${fileContent}`;
+
+ putHTMLOnClipboard(`<img id="inline-img" src="${dataURI}">`);
+ cwc.window.document.getElementById("messageEditor").focus();
+ // Ctrl+V = Paste
+ EventUtils.synthesizeKey(
+ "v",
+ { shiftKey: false, accelKey: true },
+ cwc.window
+ );
+
+ plan_for_window_close(cwc);
+ cwc.window.goDoCommand("cmd_sendLater");
+ wait_for_window_close();
+
+ await be_in_folder(gOutboxFolder);
+ let msgLoaded = BrowserTestUtils.waitForEvent(
+ get_about_message(),
+ "MsgLoaded"
+ );
+ let outMsg = select_click_row(0);
+ await msgLoaded;
+ let outMsgContent = await get_msg_source(outMsg);
+
+ ok(
+ outMsgContent.includes('id="inline-img" src="cid:'),
+ "inline-img should be cid after send"
+ );
+ ok(
+ /Content-Type: image\/png;\s* name="\w{16}.png"/.test(outMsgContent),
+ `file name should have 16 characters: ${outMsgContent}`
+ );
+
+ press_delete(); // Delete the msg from Outbox.
+});
diff --git a/comm/mail/test/browser/composition/browser_linkPreviews.js b/comm/mail/test/browser/composition/browser_linkPreviews.js
new file mode 100644
index 0000000000..5316d8904c
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_linkPreviews.js
@@ -0,0 +1,39 @@
+/* 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 link previews.
+ */
+
+var { close_compose_window, open_compose_new_mail } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/composition/html/linkpreview.html";
+
+add_task(async function previewEnabled() {
+ Services.prefs.setBoolPref("mail.compose.add_link_preview", true);
+ let controller = open_compose_new_mail();
+ await navigator.clipboard.writeText(url);
+
+ let messageEditor =
+ controller.window.document.getElementById("messageEditor");
+ messageEditor.focus();
+
+ // Ctrl+V = Paste
+ EventUtils.synthesizeKey(
+ "v",
+ { shiftKey: false, accelKey: true },
+ controller.window
+ );
+
+ await TestUtils.waitForCondition(
+ () => messageEditor.contentDocument.body.querySelector(".moz-card"),
+ "link preview should have appeared"
+ );
+
+ close_compose_window(controller);
+ Services.prefs.clearUserPref("mail.compose.add_link_preview");
+});
diff --git a/comm/mail/test/browser/composition/browser_messageBody.js b/comm/mail/test/browser/composition/browser_messageBody.js
new file mode 100644
index 0000000000..c9b16cc370
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_messageBody.js
@@ -0,0 +1,109 @@
+/* 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/. */
+
+/**
+ * Tests related to message body.
+ */
+
+var { get_msg_source, open_compose_new_mail, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ get_special_folder,
+ get_about_message,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { plan_for_window_close, wait_for_window_close } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var gOutboxFolder;
+
+add_setup(async function () {
+ gOutboxFolder = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+});
+
+/**
+ * Tests that sending link with invalid data uri works.
+ */
+add_task(async function test_invalid_data_uri() {
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "someone@example.com",
+ "Test sending link with invalid data uri",
+ ""
+ );
+
+ cwc.window
+ .GetCurrentEditor()
+ .insertHTML("<a href=data:1>invalid data uri</a>");
+ plan_for_window_close(cwc);
+ cwc.window.goDoCommand("cmd_sendLater");
+ wait_for_window_close();
+
+ await be_in_folder(gOutboxFolder);
+ let msgLoaded = BrowserTestUtils.waitForEvent(
+ get_about_message(),
+ "MsgLoaded"
+ );
+ let outMsg = select_click_row(0);
+ await msgLoaded;
+ let outMsgContent = await get_msg_source(outMsg);
+
+ ok(
+ outMsgContent.includes("invalid data uri"),
+ "message containing invalid data uri should be sent"
+ );
+
+ press_delete(); // Delete the msg from Outbox.
+});
+
+/**
+ * Tests that when converting <a href="$1">$2</a> to text/plain, if $1 matches
+ * with $2, $1 should be discarded to prevent duplicated links.
+ */
+add_task(async function test_freeTextLink() {
+ let prevSendFormat = Services.prefs.getIntPref("mail.default_send_format");
+ Services.prefs.setIntPref(
+ "mail.default_send_format",
+ Ci.nsIMsgCompSendFormat.PlainText
+ );
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(cwc, "someone@example.com", "Test free text link", "");
+
+ let link1 = "https://example.com";
+ let link2 = "name@example.com";
+ let link3 = "https://example.net";
+ cwc.window
+ .GetCurrentEditor()
+ .insertHTML(
+ `<a href="${link1}/">${link1}</a> <a href="mailto:${link2}">${link2}</a> <a href="${link3}">link3</a>`
+ );
+ plan_for_window_close(cwc);
+ cwc.window.goDoCommand("cmd_sendLater");
+ wait_for_window_close();
+
+ await be_in_folder(gOutboxFolder);
+ let msgLoaded = BrowserTestUtils.waitForEvent(
+ get_about_message(),
+ "MsgLoaded"
+ );
+ let outMsg = select_click_row(0);
+ await msgLoaded;
+ let outMsgContent = await get_msg_source(outMsg);
+
+ Assert.equal(
+ getMessageBody(outMsgContent),
+ `${link1} ${link2} link3 <${link3}>\r\n`,
+ "Links should be correctly converted to plain text"
+ );
+
+ press_delete(); // Delete the msg from Outbox.
+
+ Services.prefs.setIntPref("mail.default_send_format", prevSendFormat);
+});
diff --git a/comm/mail/test/browser/composition/browser_multipartRelated.js b/comm/mail/test/browser/composition/browser_multipartRelated.js
new file mode 100644
index 0000000000..0f35194193
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_multipartRelated.js
@@ -0,0 +1,143 @@
+/* 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/. */
+
+/**
+ * Tests that multipart/related messages are handled properly.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { close_compose_window, open_compose_new_mail, save_compose_message } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { be_in_folder, get_special_folder, mc, press_delete, select_click_row } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+var {
+ click_menus_in_sequence,
+ plan_for_modal_dialog,
+ wait_for_modal_dialog,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+
+var gDrafts;
+
+add_setup(async function () {
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+/**
+ * Helper to get the full message content.
+ *
+ * @param aMsgHdr: nsIMsgDBHdr object whose text body will be read
+ * @returns {Map(partnum -> message headers), Map(partnum -> message text)}
+ */
+function getMsgHeaders(aMsgHdr) {
+ let msgFolder = aMsgHdr.folder;
+ let msgUri = msgFolder.getUriForMsg(aMsgHdr);
+
+ let handler = {
+ _done: false,
+ _data: new Map(),
+ _text: new Map(),
+ endMessage() {
+ this._done = true;
+ },
+ deliverPartData(num, text) {
+ this._text.set(num, this._text.get(num) + text);
+ },
+ startPart(num, headers) {
+ this._data.set(num, headers);
+ this._text.set(num, "");
+ },
+ };
+ let streamListener = MimeParser.makeStreamListenerParser(handler, {
+ strformat: "unicode",
+ });
+ MailServices.messageServiceFromURI(msgUri).streamMessage(
+ msgUri,
+ streamListener,
+ null,
+ null,
+ false,
+ "",
+ false
+ );
+ utils.waitFor(() => handler._done);
+ return { headers: handler._data, text: handler._text };
+}
+
+/**
+ */
+add_task(async function test_basic_multipart_related() {
+ let compWin = open_compose_new_mail();
+ compWin.window.focus();
+ EventUtils.sendString("someone@example.com", compWin.window);
+ compWin.window.document.getElementById("msgSubject").focus();
+ EventUtils.sendString("multipart/related", compWin.window);
+ compWin.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Here is a prologue.\n", compWin.window);
+
+ const fname = "data/tb-logo.png";
+ let file = new FileUtils.File(getTestFilePath(fname));
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let fileURL = fileHandler.getURLSpecFromActualFile(file);
+
+ // Add a simple image to our dialog
+ plan_for_modal_dialog("Mail:image", async function (dialog) {
+ // Insert the url of the image.
+ dialog.window.focus();
+ EventUtils.sendString(fileURL, dialog.window);
+ dialog.window.document.getElementById("altTextInput").focus();
+ EventUtils.sendString("Alt text", dialog.window);
+ await new Promise(resolve => setTimeout(resolve));
+
+ // Accept the dialog
+ dialog.window.document.querySelector("dialog").acceptDialog();
+ });
+
+ let insertMenu = compWin.window.document.getElementById("InsertPopupButton");
+ let insertMenuPopup = compWin.window.document.getElementById("InsertPopup");
+
+ EventUtils.synthesizeMouseAtCenter(insertMenu, {}, insertMenu.ownerGlobal);
+ await click_menus_in_sequence(insertMenuPopup, [{ id: "InsertImageItem" }]);
+
+ wait_for_modal_dialog();
+ wait_for_window_close();
+ await new Promise(resolve => setTimeout(resolve));
+
+ await save_compose_message(compWin.window);
+ close_compose_window(compWin);
+ await TestUtils.waitForCondition(
+ () => gDrafts.getTotalMessages(false) == 1,
+ "message saved to drafts folder"
+ );
+
+ // Make sure that the headers are right on this one.
+ await be_in_folder(gDrafts);
+ let draftMsg = select_click_row(0);
+ let { headers, text } = getMsgHeaders(draftMsg, true);
+ Assert.equal(headers.get("").contentType.type, "multipart/related");
+ Assert.equal(headers.get("1").contentType.type, "text/html");
+ Assert.equal(headers.get("2").contentType.type, "image/png");
+ Assert.equal(headers.get("2").get("Content-Transfer-Encoding"), "base64");
+ Assert.equal(
+ headers.get("2").getRawHeader("Content-Disposition")[0],
+ 'inline; filename="tb-logo.png"'
+ );
+ let cid = headers.get("2").getRawHeader("Content-ID")[0].slice(1, -1);
+ if (!text.get("1").includes('src="cid:' + cid + '"')) {
+ throw new Error("Expected HTML to refer to cid " + cid);
+ }
+ press_delete(mc); // Delete message
+});
diff --git a/comm/mail/test/browser/composition/browser_newmsgComposeIdentity.js b/comm/mail/test/browser/composition/browser_newmsgComposeIdentity.js
new file mode 100644
index 0000000000..ad796dbfaa
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_newmsgComposeIdentity.js
@@ -0,0 +1,273 @@
+/* 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/. */
+
+/**
+ * Tests that compose new message chooses the correct initial identity when
+ * called from the context of an open composer.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var {
+ close_compose_window,
+ open_compose_new_mail,
+ save_compose_message,
+ wait_for_compose_window,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { be_in_folder, get_special_folder, mc, press_delete, select_click_row } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+var { click_menus_in_sequence, plan_for_new_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gInbox;
+var gDrafts;
+var account;
+
+var identityKey1;
+var identity1Email = "x@example.invalid";
+var identityKey2;
+var identity2Email = "y@example.invalid";
+var identity2Name = "User Y";
+var identity2From = identity2Name + " <" + identity2Email + ">";
+var identityKey3;
+var identity3Email = "z@example.invalid";
+var identity3Name = "User Z";
+var identity3Label = "Label Z";
+var identityKey4;
+
+add_setup(async function () {
+ // Now set up an account with some identities.
+ account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "New Msg Compose Identity Testing",
+ "pop3"
+ );
+
+ let identity1 = MailServices.accounts.createIdentity();
+ identity1.email = identity1Email;
+ account.addIdentity(identity1);
+ identityKey1 = identity1.key;
+
+ let identity2 = MailServices.accounts.createIdentity();
+ identity2.email = identity2Email;
+ identity2.fullName = identity2Name;
+ account.addIdentity(identity2);
+ identityKey2 = identity2.key;
+
+ let identity3 = MailServices.accounts.createIdentity();
+ identity3.email = identity3Email;
+ identity3.fullName = identity3Name;
+ identity3.label = identity3Label;
+ account.addIdentity(identity3);
+ identityKey3 = identity3.key;
+
+ // Identity with no data.
+ let identity4 = MailServices.accounts.createIdentity();
+ account.addIdentity(identity4);
+ identityKey4 = identity4.key;
+
+ gInbox = account.incomingServer.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+/**
+ * Helper to check that a suitable From identity was set up in the given
+ * composer window.
+ *
+ * @param cwc Compose window controller.
+ * @param aIdentityKey The key of the expected identity.
+ * @param aIdentityAlias The displayed label of the expected identity.
+ * @param aIdentityValue The value of the expected identity
+ * (the sender address to be sent out).
+ */
+function checkCompIdentity(cwc, aIdentityKey, aIdentityAlias, aIdentityValue) {
+ let identityList = cwc.window.document.getElementById("msgIdentity");
+
+ Assert.equal(
+ cwc.window.getCurrentIdentityKey(),
+ aIdentityKey,
+ "The From identity is not correctly selected"
+ );
+
+ if (aIdentityAlias) {
+ Assert.equal(
+ identityList.label,
+ aIdentityAlias,
+ "The From address does not have the correct label"
+ );
+ }
+
+ if (aIdentityValue) {
+ Assert.equal(
+ identityList.value,
+ aIdentityValue,
+ "The From address does not have the correct value"
+ );
+ }
+}
+
+/**
+ * Test that starting a new message from an open compose window gets the
+ * expected initial identity.
+ */
+add_task(async function test_compose_from_composer() {
+ await be_in_folder(gInbox);
+
+ let cwc = open_compose_new_mail();
+ checkCompIdentity(cwc, account.defaultIdentity.key);
+
+ // Compose a new message from the compose window.
+ plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "n",
+ { shiftKey: false, accelKey: true },
+ cwc.window
+ );
+ let newCompWin = wait_for_compose_window();
+ checkCompIdentity(newCompWin, account.defaultIdentity.key);
+ close_compose_window(newCompWin);
+
+ // Switch to identity2 in the main compose window, new compose windows
+ // starting from here should use the same identity as its "parent".
+ await chooseIdentity(cwc.window, identityKey2);
+ checkCompIdentity(cwc, identityKey2);
+
+ // Compose a second new message from the compose window.
+ plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "n",
+ { shiftKey: false, accelKey: true },
+ cwc.window
+ );
+ let newCompWin2 = wait_for_compose_window();
+ checkCompIdentity(newCompWin2, identityKey2);
+
+ close_compose_window(newCompWin2);
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Bug 87987
+ * Test editing the identity email/name for the current composition.
+ */
+add_task(async function test_editing_identity() {
+ Services.prefs.setBoolPref("mail.compose.warned_about_customize_from", true);
+ await be_in_folder(gInbox);
+
+ let compWin = open_compose_new_mail();
+ checkCompIdentity(compWin, account.defaultIdentity.key, identity1Email);
+
+ // Input custom identity data into the From field.
+ let customName = "custom";
+ let customEmail = "custom@edited.invalid";
+ let identityCustom = customName + " <" + customEmail + ">";
+
+ EventUtils.synthesizeMouseAtCenter(
+ compWin.window.document.getElementById("msgIdentity"),
+ {},
+ compWin.window.document.getElementById("msgIdentity").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ compWin.window.document.getElementById("msgIdentityPopup"),
+ [{ command: "cmd_customizeFromAddress" }]
+ );
+ utils.waitFor(
+ () => compWin.window.document.getElementById("msgIdentity").editable
+ );
+
+ compWin.window.document.getElementById("msgIdentityPopup").focus();
+ EventUtils.sendString(identityCustom, compWin.window);
+ checkCompIdentity(
+ compWin,
+ account.defaultIdentity.key,
+ identityCustom,
+ identityCustom
+ );
+ close_compose_window(compWin);
+
+ /* Temporarily disabled due to intermittent failure, bug 1237565.
+ TODO: To be reeabled in bug 1238264.
+ // Save message with this changed identity.
+ compWin.window.SaveAsDraft();
+
+ // Switch to another identity to see if editable field still obeys predefined
+ // identity values.
+ await click_menus_in_sequence(compWin.window.document.getElementById("msgIdentityPopup"),
+ [ { identitykey: identityKey2 } ]);
+ checkCompIdentity(compWin, identityKey2, identity2From, identity2From);
+
+ // This should not save the identity2 to the draft message.
+ close_compose_window(compWin);
+
+ await be_in_folder(gDrafts);
+ let curMessage = select_click_row(0);
+ Assert.equal(curMessage.author, identityCustom);
+ // Remove the saved draft.
+ press_delete(mc);
+ */
+ Services.prefs.setBoolPref("mail.compose.warned_about_customize_from", false);
+});
+
+/**
+ * Bug 318495
+ * Test how an identity displays and behaves in the compose window.
+ */
+add_task(async function test_display_of_identities() {
+ await be_in_folder(gInbox);
+
+ let cwc = open_compose_new_mail();
+ checkCompIdentity(cwc, account.defaultIdentity.key, identity1Email);
+
+ await chooseIdentity(cwc.window, identityKey2);
+ checkCompIdentity(cwc, identityKey2, identity2From, identity2From);
+
+ await chooseIdentity(cwc.window, identityKey4);
+ checkCompIdentity(
+ cwc,
+ identityKey4,
+ "[nsIMsgIdentity: " + identityKey4 + "]"
+ );
+
+ await chooseIdentity(cwc.window, identityKey3);
+ let identity3From = identity3Name + " <" + identity3Email + ">";
+ checkCompIdentity(
+ cwc,
+ identityKey3,
+ identity3From + " (" + identity3Label + ")",
+ identity3From
+ );
+
+ // Bug 1152045, check that the email address from the selected identity
+ // is properly used for the From field in the created message.
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ await be_in_folder(gDrafts);
+ let curMessage = select_click_row(0);
+ Assert.equal(curMessage.author, identity3From);
+ // Remove the saved draft.
+ press_delete(mc);
+});
+
+registerCleanupFunction(function () {
+ account.removeIdentity(MailServices.accounts.getIdentity(identityKey1));
+ account.removeIdentity(MailServices.accounts.getIdentity(identityKey2));
+ account.removeIdentity(MailServices.accounts.getIdentity(identityKey3));
+
+ // The last identity of an account can't be removed so clear all its prefs
+ // which effectively destroys it.
+ MailServices.accounts.getIdentity(identityKey4).clearAllValues();
+ MailServices.accounts.removeAccount(account);
+});
diff --git a/comm/mail/test/browser/composition/browser_paragraph_state.js b/comm/mail/test/browser/composition/browser_paragraph_state.js
new file mode 100644
index 0000000000..f6101de44c
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_paragraph_state.js
@@ -0,0 +1,888 @@
+/* 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 paragraph state.
+ */
+
+requestLongerTimeout(2);
+
+var { close_compose_window, open_compose_new_mail, FormatHelper } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+add_task(async function test_newline_p() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let firstText = "first line";
+ let secondText = "second line";
+ let thirdText = "third line";
+
+ formatHelper.focusMessage();
+
+ await formatHelper.selectParagraphState("p");
+ await formatHelper.typeInMessage(firstText);
+
+ // Pressing Enter, without Shift, creates a new block.
+ await formatHelper.typeEnterInMessage(false);
+ formatHelper.assertMessageBodyContent(
+ // Empty "P" block must contain some content, otherwise it will collapse,
+ // currently this is achieved with a <BR>.
+ [
+ { block: "P", content: [firstText] },
+ { block: "P", content: ["<BR>"] },
+ ],
+ "After Enter (no Shift)"
+ );
+ await formatHelper.typeInMessage(secondText);
+ formatHelper.assertMessageBodyContent(
+ [
+ { block: "P", content: [firstText] },
+ { block: "P", content: [secondText] },
+ ],
+ "After Enter (no Shift) and typing"
+ );
+
+ // Pressing Shift+Enter creates a break.
+ await formatHelper.typeEnterInMessage(true);
+ formatHelper.assertMessageBodyContent(
+ [
+ { block: "P", content: [firstText] },
+ // NOTE: that the two <BR> are necessary, the first produces the newline,
+ // whilst the second stops the new line from collapsing without any text.
+ { block: "P", content: [secondText + "<BR><BR>"] },
+ ],
+ "After Shift+Enter"
+ );
+ await formatHelper.typeInMessage(thirdText);
+ formatHelper.assertMessageBodyContent(
+ [
+ { block: "P", content: [firstText] },
+ // NOTE: with the next line being non-empty, the extra <BR> is no longer
+ // needed to stop the line from collapsing.
+ { block: "P", content: [secondText + "<BR>" + thirdText] },
+ ],
+ "After Shift+Enter and typing"
+ );
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_newline_headers() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let firstText = "first line";
+ let secondText = "second line";
+ let thirdText = "third line";
+
+ formatHelper.focusMessage();
+
+ for (let num = 1; num <= 6; num++) {
+ let state = `h${num}`;
+ let block = `H${num}`;
+
+ await formatHelper.selectParagraphState(state);
+ await formatHelper.typeInMessage(firstText);
+
+ // Pressing Shift+Enter creates a break.
+ await formatHelper.typeEnterInMessage(true);
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [firstText + "<BR><BR>"] }],
+ `After Shift+Enter in ${state}`
+ );
+ await formatHelper.typeInMessage(secondText);
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [firstText + "<BR>" + secondText] }],
+ `After Shift+Enter in ${state} and typing`
+ );
+
+ // Pressing Enter, without Shift, creates a new paragraph.
+ await formatHelper.typeEnterInMessage(false);
+ formatHelper.assertMessageBodyContent(
+ [
+ { block, content: [firstText + "<BR>" + secondText] },
+ { block: "P", content: ["<BR>"] },
+ ],
+ `After Enter (no Shift) in ${state}`
+ );
+
+ await formatHelper.assertShownParagraphState(
+ "p",
+ `Shows paragraph state after Enter (no Shift) in ${state}`
+ );
+
+ await formatHelper.typeInMessage(thirdText);
+ formatHelper.assertMessageBodyContent(
+ [
+ { block, content: [firstText + "<BR>" + secondText] },
+ { block: "P", content: [thirdText] },
+ ],
+ `After Enter (no Shift) in ${state} and typing`
+ );
+
+ await formatHelper.deleteAll();
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_newline_pre_and_address() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let firstText = "first line";
+ let secondText = "second line";
+ let thirdText = "third line";
+
+ formatHelper.focusMessage();
+
+ for (let state of ["pre", "address"]) {
+ let block = state.toUpperCase();
+
+ await formatHelper.selectParagraphState(state);
+ await formatHelper.typeInMessage(firstText);
+
+ // Pressing Shift+Enter creates a break.
+ await formatHelper.typeEnterInMessage(true);
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [firstText + "<BR><BR>"] }],
+ `After Shift+Enter in ${state}`
+ );
+ await formatHelper.typeInMessage(secondText);
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [firstText + "<BR>" + secondText] }],
+ `After Shift+Enter in ${state} and typing`
+ );
+
+ // Pressing Enter, without Shift, does the same.
+ await formatHelper.typeEnterInMessage(false);
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [firstText + "<BR>" + secondText + "<BR><BR>"] }],
+ `After Enter (no Shift) in ${state}`
+ );
+
+ await formatHelper.typeInMessage(thirdText);
+ formatHelper.assertMessageBodyContent(
+ [
+ {
+ block,
+ content: [firstText + "<BR>" + secondText + "<BR>" + thirdText],
+ },
+ ],
+ `After Enter (no Shift) in ${state} and typing`
+ );
+
+ await formatHelper.deleteAll();
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_newline_body() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let firstText = "first line";
+ let secondText = "second line";
+ let thirdText = "third line";
+
+ formatHelper.focusMessage();
+
+ await formatHelper.selectParagraphState("");
+ await formatHelper.typeInMessage(firstText);
+
+ // Pressing Shift+Enter creates a break.
+ await formatHelper.typeEnterInMessage(true);
+ formatHelper.assertMessageBodyContent(
+ [firstText + "<BR><BR>"],
+ "After Shift+Enter in body"
+ );
+ await formatHelper.typeInMessage(secondText);
+ formatHelper.assertMessageBodyContent(
+ [firstText + "<BR>" + secondText],
+ "After Shift+Enter in body and typing"
+ );
+
+ // Pressing Enter, without Shift, either side of the Enter get converted into
+ // paragraphs.
+ await formatHelper.typeEnterInMessage(false);
+ formatHelper.assertMessageBodyContent(
+ [
+ firstText,
+ { block: "P", content: [secondText] },
+ { block: "P", content: ["<BR>"] },
+ ],
+ "After Enter (no Shift) in body"
+ );
+ await formatHelper.assertShownParagraphState(
+ "p",
+ "Shows paragraph state after Enter (no Shift) in body"
+ );
+
+ await formatHelper.typeInMessage(thirdText);
+ formatHelper.assertMessageBodyContent(
+ [
+ firstText,
+ { block: "P", content: [secondText] },
+ { block: "P", content: [thirdText] },
+ ],
+ "After Enter (no Shift) in ${state} and typing"
+ );
+
+ close_compose_window(controller);
+});
+
+async function initialiseParagraphs(formatHelper) {
+ let blockSet = [];
+ let start = 0;
+ let first = true;
+ for (let text of ["first block", "second block", "third block"]) {
+ if (first) {
+ first = false;
+ } else {
+ await formatHelper.typeEnterInMessage();
+ }
+ await formatHelper.typeInMessage(text);
+
+ let end = start + text.length;
+ blockSet.push({ text, start, end });
+ start = end + 1; // Plus newline.
+ }
+
+ return blockSet;
+}
+
+add_task(async function test_non_body_paragraph_state() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ // NOTE: we don't start with the default paragraph state because we want to
+ // detect a *change* in the paragraph state from the previous state.
+ let stateSet = ["address", "pre"];
+ for (let i = 1; i <= 6; i++) {
+ stateSet.push(`h${i}`);
+ }
+ stateSet.push("p");
+
+ // Before focus, disabled.
+ Assert.ok(
+ formatHelper.paragraphStateSelector.disabled,
+ "Selector should be disabled with no focus"
+ );
+
+ formatHelper.focusMessage();
+ Assert.ok(
+ !formatHelper.paragraphStateSelector.disabled,
+ "Selector should be enabled with focus"
+ );
+
+ // Initially in the paragraph state.
+ await formatHelper.assertShownParagraphState("p", "Initial paragraph");
+
+ let blockSet = await initialiseParagraphs(formatHelper);
+ formatHelper.assertMessageBodyContent(
+ [
+ { block: "P", content: [blockSet[0].text] },
+ { block: "P", content: [blockSet[1].text] },
+ { block: "P", content: [blockSet[2].text] },
+ ],
+ "Three paragraphs"
+ );
+
+ let prevState = "p";
+ for (let state of stateSet) {
+ // Select end.
+ let prevBlock = prevState.toUpperCase();
+ let block = state.toUpperCase();
+ await formatHelper.selectTextRange(blockSet[2].end);
+ // Select through menu.
+ await formatHelper.selectParagraphState(state);
+ formatHelper.assertMessageBodyContent(
+ [
+ { block: prevBlock, content: [blockSet[0].text] },
+ { block: prevBlock, content: [blockSet[1].text] },
+ { block, content: [blockSet[2].text] },
+ ],
+ `${state} on last block`
+ );
+
+ await formatHelper.assertShownParagraphState(
+ state,
+ `${state} on last block`
+ );
+ // Select across second block.
+ await formatHelper.selectTextRange(
+ blockSet[1].start + 2,
+ blockSet[1].end - 2
+ );
+ await formatHelper.assertShownParagraphState(
+ prevState,
+ `${state} on last block, with second block selected`
+ );
+
+ await formatHelper.selectFromFormatSubMenu(
+ formatHelper.getParagraphStateMenuItem(state),
+ formatHelper.paragraphStateMenu
+ );
+ formatHelper.assertMessageBodyContent(
+ [
+ { block: prevBlock, content: [blockSet[0].text] },
+ { block, content: [blockSet[1].text] },
+ { block, content: [blockSet[2].text] },
+ ],
+ `${state} on last two blocks`
+ );
+
+ // Select across first and second line.
+ await formatHelper.selectTextRange(2, blockSet[1].start + 2);
+ // Mixed state has no value.
+ await formatHelper.assertShownParagraphState(
+ null,
+ `${state} on last two blocks, with mixed selection`
+ );
+ await formatHelper.selectParagraphState(state);
+
+ formatHelper.assertMessageBodyContent(
+ [
+ { block, content: [blockSet[0].text] },
+ { block, content: [blockSet[1].text] },
+ { block, content: [blockSet[2].text] },
+ ],
+ `${state} on all blocks`
+ );
+ prevState = state;
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_body_paragraph_state() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ let blockSet = await initialiseParagraphs(formatHelper);
+
+ await formatHelper.selectTextRange(0);
+ // Body state has value "".
+ await formatHelper.selectParagraphState("");
+ formatHelper.assertMessageBodyContent(
+ [
+ blockSet[0].text,
+ { block: "P", content: [blockSet[1].text] },
+ { block: "P", content: [blockSet[2].text] },
+ ],
+ "body on first block"
+ );
+ await formatHelper.assertShownParagraphState("", "body on first block");
+ await formatHelper.selectTextRange(blockSet[1].start, blockSet[2].start + 1);
+ await formatHelper.assertShownParagraphState("p", "last two selected");
+
+ await formatHelper.selectFromFormatSubMenu(
+ formatHelper.getParagraphStateMenuItem(""),
+ formatHelper.paragraphStateMenu
+ );
+ formatHelper.assertMessageBodyContent(
+ [blockSet[0].text + "<BR>" + blockSet[1].text + "<BR>" + blockSet[2].text],
+ "body on all blocks"
+ );
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_convert_from_body_paragraph_state() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let stateSet = ["p", "address", "pre"];
+ for (let i = 1; i <= 6; i++) {
+ stateSet.push(`h${i}`);
+ }
+
+ let firstText = "first line";
+ let secondText = "second line";
+ // Plus newline break.
+ let fullLength = firstText.length + 1 + secondText.length;
+
+ formatHelper.focusMessage();
+
+ for (let state of stateSet) {
+ let block = state.toUpperCase();
+
+ await formatHelper.selectParagraphState("");
+ await formatHelper.typeInMessage(firstText);
+ await formatHelper.typeEnterInMessage(true);
+ await formatHelper.typeInMessage(secondText);
+ formatHelper.assertMessageBodyContent(
+ [firstText + "<BR>" + secondText],
+ `body at start (${state})`
+ );
+
+ // Changing to a non-body state replaces each line with a block.
+ await formatHelper.selectTextRange(0, fullLength);
+ await formatHelper.selectParagraphState(state);
+ formatHelper.assertMessageBodyContent(
+ [
+ { block, content: [firstText] },
+ { block, content: [secondText] },
+ ],
+ `${state} at end`
+ );
+
+ await formatHelper.deleteAll();
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_heading_implies_bold() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ let boldItem = formatHelper.getStyleMenuItem("bold");
+ let strongItem = formatHelper.getStyleMenuItem("strong");
+
+ for (let num = 1; num <= 6; num++) {
+ let state = `h${num}`;
+ let block = `H${num}`;
+ let text = "some text";
+
+ await formatHelper.selectParagraphState(state);
+ await formatHelper.assertShownStyles(
+ "bold",
+ `Bold on change to ${state} state`
+ );
+ await formatHelper.typeInMessage(text);
+ await formatHelper.assertShownStyles(
+ "bold",
+ `Bold when typing in ${state} state`
+ );
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [text] }],
+ `${state} state, without any explicit styling`
+ );
+
+ // Trying to undo bold does nothing.
+ formatHelper.boldButton.click();
+ // See Bug 1718534
+ // await formatHelper.assertShownStyles(
+ // "bold",
+ // `Still bold when clicking bold in the ${state} state`
+ // );
+ text += "a";
+ await formatHelper.typeInMessage("a");
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [text] }],
+ `${state} state, without style change, after clicking bold`
+ );
+ await formatHelper.assertShownStyles(
+ "bold",
+ `Still bold when clicking bold in the ${state} state and typing`
+ );
+
+ // Select through the style menu.
+ await formatHelper.selectFromFormatSubMenu(
+ boldItem,
+ formatHelper.styleMenu
+ );
+ // See Bug 1718534
+ // await formatHelper.assertShownStyles(
+ // "bold",
+ // `Still bold when selecting bold in the ${state} state`
+ // );
+ text += "b";
+ await formatHelper.typeInMessage("b");
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [text] }],
+ `${state} state, without style change, after selecting bold`
+ );
+ await formatHelper.assertShownStyles(
+ "bold",
+ `Still bold when selecting bold in the ${state} state and typing`
+ );
+
+ // Can still add and remove a style that implies bold.
+ let strongText = " Strong ";
+ await formatHelper.selectFromFormatSubMenu(
+ strongItem,
+ formatHelper.styleMenu
+ );
+ // See Bug 1716840
+ // await formatHelper.assertShownStyles(
+ // "strong",
+ // `Selecting strong in ${state} state`
+ // );
+ await formatHelper.typeInMessage(strongText);
+ await formatHelper.assertShownStyles(
+ "strong",
+ `Selecting strong in ${state} state and typing`
+ );
+ // Deselect.
+ await formatHelper.selectFromFormatSubMenu(
+ strongItem,
+ formatHelper.styleMenu
+ );
+ // See Bug 1716840
+ // await formatHelper.assertShownStyles(
+ // "bold",
+ // `UnSelecting strong in ${state} state`
+ // );
+
+ let moreText = "more";
+ await formatHelper.typeInMessage(moreText);
+ await formatHelper.assertShownStyles(
+ "bold",
+ `UnSelecting strong in ${state} state and typing`
+ );
+
+ formatHelper.assertMessageBodyContent(
+ [
+ {
+ block,
+ content: [text, { tags: ["STRONG"], text: strongText }, moreText],
+ },
+ ],
+ `Strong region in ${state} state`
+ );
+
+ // Change to paragraph.
+ await formatHelper.selectParagraphState("p");
+ await formatHelper.assertShownStyles(
+ null,
+ `Lose bold when switching to Paragraph from ${state} state`
+ );
+ formatHelper.assertMessageBodyContent(
+ [
+ {
+ block: "P",
+ content: [text, { tags: ["STRONG"], text: strongText }, moreText],
+ },
+ ],
+ `Paragraph block from ${state} state`
+ );
+
+ // NOTE: Switching from "p" state to a heading state will *not* remove the
+ // bold tags.
+
+ await formatHelper.emptyParagraph();
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_address_implies_italic() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ let italicItem = formatHelper.getStyleMenuItem("italic");
+
+ let otherStyles = Array.from(formatHelper.styleDataMap.values()).filter(
+ data => data.implies?.name === "italic" || data.linked?.name === "italic"
+ );
+
+ let block = "ADDRESS";
+ let text = "some text";
+
+ await formatHelper.selectParagraphState("address");
+ await formatHelper.assertShownStyles(
+ "italic",
+ "Italic on change to address state"
+ );
+ await formatHelper.typeInMessage(text);
+ await formatHelper.assertShownStyles(
+ "italic",
+ "Italic when typing in address state"
+ );
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [text] }],
+ "Address state, without any explicit styling"
+ );
+
+ // Trying to undo italic does nothing.
+ formatHelper.italicButton.click();
+ // See Bug 1718534
+ // await formatHelper.assertShownStyles(
+ // "italic",
+ // "Still italic when clicking italic in the address state"
+ // );
+ text += "a";
+ await formatHelper.typeInMessage("a");
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [text] }],
+ "Address state, without style change, after clicking italic"
+ );
+ await formatHelper.assertShownStyles(
+ "italic",
+ "Still italic when clicking italic in the address state and typing"
+ );
+
+ // Select through the style menu.
+ await formatHelper.selectFromFormatSubMenu(
+ italicItem,
+ formatHelper.styleMenu
+ );
+ // See Bug 1718534
+ // await formatHelper.assertShownStyles(
+ // "italic",
+ // "Still italic when selecting italic in the address state"
+ // );
+ text += "b";
+ await formatHelper.typeInMessage("b");
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [text] }],
+ "Address state, without style change, after selecting italic"
+ );
+ await formatHelper.assertShownStyles(
+ "italic",
+ "Still italic when selecting italic in the address state and typing"
+ );
+
+ let content = [text];
+ // Can still add and remove a style that implies italic.
+ for (let style of otherStyles) {
+ let { name, item, tag } = style;
+ let otherText = name;
+ await formatHelper.selectFromFormatSubMenu(item, formatHelper.styleMenu);
+ // See Bug 1716840
+ // await formatHelper.assertShownStyles(
+ // style,
+ // `Selecting ${name} in address state`
+ // );
+ await formatHelper.typeInMessage(otherText);
+ await formatHelper.assertShownStyles(
+ style,
+ `Selecting ${name} in address state and typing`
+ );
+ // Deselect.
+ await formatHelper.selectFromFormatSubMenu(item, formatHelper.styleMenu);
+ // See Bug 1716840
+ // await formatHelper.assertShownStyles(
+ // "italic",
+ // `UnSelecting ${name} in address state`
+ // );
+
+ let moreText = "more";
+ await formatHelper.typeInMessage(moreText);
+ await formatHelper.assertShownStyles(
+ "italic",
+ `UnSelecting ${name} in address state and typing`
+ );
+
+ content.push({ text: otherText, tags: [tag] });
+ content.push(moreText);
+ formatHelper.assertMessageBodyContent(
+ [{ block, content }],
+ `${name} region in address state`
+ );
+ }
+
+ // Change to paragraph.
+ await formatHelper.selectParagraphState("p");
+ await formatHelper.assertShownStyles(
+ null,
+ "Lose italic when switching to Paragraph from address state"
+ );
+ formatHelper.assertMessageBodyContent(
+ [{ block: "P", content }],
+ "Paragraph block"
+ );
+
+ // NOTE: Switching from "p" state to a heading state will *not* remove the
+ // italic tags.
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_preformat_implies_fixed_width() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ let ttItem = formatHelper.getStyleMenuItem("tt");
+
+ let otherStyles = Array.from(formatHelper.styleDataMap.values()).filter(
+ data => data.implies?.name === "tt" || data.linked?.name === "tt"
+ );
+
+ async function assertFontAndStyle(font, style, message) {
+ await formatHelper.assertShownFont(
+ font,
+ `${message}: Font family "${font}" is shown`
+ );
+ await formatHelper.assertShownStyles(
+ style,
+ `${message}: ${style} is shown`
+ );
+ }
+
+ let block = "PRE";
+ let text = "some text";
+
+ await formatHelper.selectParagraphState("pre");
+ await assertFontAndStyle(
+ "monospace",
+ "tt",
+ "Fixed width on change to preformat state"
+ );
+ await formatHelper.typeInMessage(text);
+ await assertFontAndStyle(
+ "monospace",
+ "tt",
+ "Fixed width when typing in preformat state"
+ );
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [text] }],
+ "Preformat state, without any explicit styling"
+ );
+
+ // Try to change the font to Variable Width.
+ await formatHelper.selectFont("");
+ // See Bug 1718534
+ // await assertFontAndStyle(
+ // "monospace",
+ // "tt",
+ // "Still fixed width when selecting Variable Width font"
+ // );
+ text += "b";
+ await formatHelper.typeInMessage("b");
+ formatHelper.assertMessageBodyContent(
+ [{ block, content: [text] }],
+ "Preformat state, without style change, after unselecting font"
+ );
+ await assertFontAndStyle(
+ "monospace",
+ "tt",
+ "Still fixed width when selecting Variable Width font and typing"
+ );
+
+ let content = [text];
+ // Can still set other fonts.
+ let font = "Helvetica, Arial, sans-serif";
+ await formatHelper.selectFont(font);
+ // See Bug 1716840 (comment 3).
+ // await assertFontAndStyle(
+ // font,
+ // null,
+ // `Selecting font "${font}" in preformat state`
+ // );
+ let fontText = "some font text";
+ await formatHelper.typeInMessage(fontText);
+ content.push({ text: fontText, font });
+ await assertFontAndStyle(
+ font,
+ null,
+ `Selecting font "${font}" in preformat state and typing`
+ );
+ // Deselect.
+ // See Bug 1718563 for why we need to select Variable Width instead of Fixed
+ // Width.
+ // await formatHelper.selectFont("monospace");
+ await formatHelper.selectFont("");
+ // See Bug 1718534
+ // await assertFontAndStyle(
+ // "monospace",
+ // "tt",
+ // `UnSelecting font "${font}" in preformat state`
+ // );
+
+ fontText = "no more font";
+ await formatHelper.typeInMessage(fontText);
+ content.push(fontText);
+ await assertFontAndStyle(
+ "monospace",
+ "tt",
+ `UnSelecting font "${font}" in preformat state and typing`
+ );
+
+ formatHelper.assertMessageBodyContent(
+ [{ block, content }],
+ `"${font}" region in preformat state`
+ );
+
+ // Trying to undo tt does nothing.
+ await formatHelper.selectFromFormatSubMenu(ttItem, formatHelper.styleMenu);
+ await assertFontAndStyle(
+ "monospace",
+ "tt",
+ "Still fixed width when selecting Fixed Width style"
+ );
+ await formatHelper.typeInMessage("a");
+ content[content.length - 1] += "a";
+ await assertFontAndStyle(
+ "monospace",
+ "tt",
+ "Still fixed width when selecting Fixed Width style and typing"
+ );
+
+ formatHelper.assertMessageBodyContent(
+ [{ block, content }],
+ "Preformat state, without style change, after selecting tt"
+ );
+
+ // Can still add and remove a style that implies tt.
+ for (let style of otherStyles) {
+ let { name, item, tag } = style;
+ let otherText = name;
+ await formatHelper.selectFromFormatSubMenu(item, formatHelper.styleMenu);
+ // See Bug 1716840
+ // await assertFontAndStyle(
+ // "monospace",
+ // name,
+ // `Selecting ${name} in preformat state`
+ // );
+ await formatHelper.typeInMessage(otherText);
+ await assertFontAndStyle(
+ "monospace",
+ name,
+ `Selecting ${name} in preformat state and typing`
+ );
+ // Deselect.
+ await formatHelper.selectFromFormatSubMenu(item, formatHelper.styleMenu);
+ // See Bug 1716840
+ // await assertFontAndStyle(
+ // "monospace",
+ // "tt",
+ // `UnSelecting ${name} in preformat state`
+ // );
+
+ let moreText = "more";
+ await formatHelper.typeInMessage(moreText);
+ await assertFontAndStyle(
+ "monospace",
+ "tt",
+ `UnSelecting ${name} in preformat state and typing`
+ );
+
+ content.push({ text: otherText, tags: [tag] });
+ content.push(moreText);
+ formatHelper.assertMessageBodyContent(
+ [{ block, content }],
+ `${name} region in preformat state`
+ );
+ }
+
+ // Change to paragraph.
+ await formatHelper.selectParagraphState("p");
+ await assertFontAndStyle(
+ "",
+ null,
+ "Lose fixed width when switching to Paragraph from preformat state"
+ );
+ formatHelper.assertMessageBodyContent(
+ [{ block: "P", content }],
+ "Paragraph block"
+ );
+
+ // NOTE: Switching from "p" state to a heading state will *not* remove the
+ // monospace font.
+
+ close_compose_window(controller);
+});
diff --git a/comm/mail/test/browser/composition/browser_publicRecipientsWarning.js b/comm/mail/test/browser/composition/browser_publicRecipientsWarning.js
new file mode 100644
index 0000000000..8cf6aea9c7
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_publicRecipientsWarning.js
@@ -0,0 +1,734 @@
+/* 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/. */
+
+/**
+ * Tests the warning notification that appears when there are too many public
+ * recipients.
+ */
+
+"use strict";
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var {
+ close_compose_window,
+ open_compose_new_mail,
+ open_compose_with_reply_to_all,
+ setup_msg_contents,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+let publicRecipientLimit = Services.prefs.getIntPref(
+ "mail.compose.warn_public_recipients.threshold"
+);
+
+requestLongerTimeout(5);
+
+/**
+ * Test we only show one warning when "To" recipients goes over the limit
+ * for a reply all.
+ */
+add_task(async function testWarningShowsOnceWhenToFieldOverLimit() {
+ // Now set up an account with some identities.
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "BCC Reply Testing",
+ "pop3"
+ );
+
+ let folder = account.incomingServer.rootFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .createLocalSubfolder("Msgs4Reply");
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "bcc@example.com";
+ account.addIdentity(identity);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ let i = 1;
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "test@example.org,"
+ .repeat(publicRecipientLimit + 100)
+ .replace(/test@/g, () => `test${i++}@`),
+ cc: "Lisa <lisa@example.com>",
+ subject: "msg over the limit for bulk warning",
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+ let cwc = open_compose_with_reply_to_all();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warning shown when "To" recipients >= ${publicRecipientLimit}`
+ );
+
+ Assert.equal(
+ 1,
+ cwc.window.document.querySelectorAll(
+ `notification-message[value="warnPublicRecipientsNotification"]`
+ ).length,
+ "should have exactly one notification about it"
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the warning displays when the "To" recipients list hits the limit.
+ */
+add_task(async function testWarningShowsWhenToFieldHitsLimit() {
+ let cwc = open_compose_new_mail();
+ let i = 1;
+
+ setup_msg_contents(
+ cwc,
+ "test@example.org,"
+ .repeat(publicRecipientLimit)
+ .replace(/test@/g, () => `test${i++}@`),
+ "Testing To Field",
+ ""
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warning shown when "To" recipients >= ${publicRecipientLimit}`
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the warning displays when the "Cc" recipients list hits the limit.
+ */
+add_task(async function testWarningShowsWhenCcFieldHitLimit() {
+ let cwc = open_compose_new_mail();
+
+ // Click on the Cc recipient label.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_ccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ // The Cc field should now be visible.
+ Assert.ok(
+ !cwc.window.document
+ .getElementById("ccAddrInput")
+ .closest(".address-row")
+ .classList.contains("hidden"),
+ "The Cc field is visible"
+ );
+
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,"
+ .repeat(publicRecipientLimit)
+ .replace(/test@/g, () => `test${i++}@`),
+ "Testing Cc Field",
+ "",
+ "ccAddrInput"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warning shown when "Cc" recipients >= ${publicRecipientLimit}`
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the warning displays when both the "To" and "Cc" recipients lists
+ * combined hit the limit.
+ */
+add_task(async function testWarningShowsWhenToAndCcFieldHitLimit() {
+ let cwc = open_compose_new_mail();
+
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,"
+ .repeat(publicRecipientLimit - 1)
+ .replace(/test@/g, () => `test${i++}@`),
+ "Testing To and Cc Fields",
+ ""
+ );
+
+ // Click on the Cc recipient label.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_ccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ // The Cc field should now be visible.
+ Assert.ok(
+ !cwc.window.document
+ .getElementById("ccAddrInput")
+ .closest(".address-row")
+ .classList.contains("hidden"),
+ "The Cc field is visible"
+ );
+
+ setup_msg_contents(cwc, "test@example.org", "", "", "ccAddrInput");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warning shown "To" and "Cc" recipients >= ${publicRecipientLimit}`
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the "To" recipients are moved to the "Bcc" field when the user selects
+ * that option.
+ */
+add_task(async function testToRecipientsMovedToBcc() {
+ let cwc = open_compose_new_mail();
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,"
+ .repeat(publicRecipientLimit)
+ .replace(/test@/g, () => `test${i++}@`),
+ "Testing move to Bcc",
+ ""
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warnPublicRecipientsNotification`
+ );
+
+ let notificationHidden = BrowserTestUtils.waitForCondition(
+ () =>
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ "public recipients warning was not removed in time"
+ );
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {},
+ cwc.window
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll(
+ "#bccAddrContainer > mail-address-pill"
+ ).length,
+ publicRecipientLimit,
+ "Bcc field populated with addresses"
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll("#toAddrContainer > mail-address-pill")
+ .length,
+ 0,
+ "addresses removed from the To field"
+ );
+
+ await notificationHidden;
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test that all the "To" recipients are moved to the "Bcc" field when the
+ * address count is over the limit.
+ */
+add_task(async function testAllToRecipientsMovedToBccWhenOverLimit() {
+ let cwc = open_compose_new_mail();
+ let limit = publicRecipientLimit + 1;
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,".repeat(limit).replace(/test@/g, () => `test${i++}@`),
+ "Testing move to Bcc",
+ ""
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warnPublicRecipientsNotification`
+ );
+
+ let notificationHidden = BrowserTestUtils.waitForCondition(
+ () =>
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ "public recipients warning was not removed in time"
+ );
+
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {},
+ cwc.window
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll(
+ "#bccAddrContainer > mail-address-pill"
+ ).length,
+ limit,
+ "Bcc field populated with addresses"
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll("#toAddrContainer > mail-address-pill")
+ .length,
+ 0,
+ "addresses removed from the To field"
+ );
+
+ await notificationHidden;
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the "Cc" recipients are moved to the "Bcc" field when the user selects
+ * that option.
+ */
+add_task(async function testCcRecipientsMovedToBcc() {
+ let cwc = open_compose_new_mail();
+
+ // Click on the Cc recipient label.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_ccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ // The Cc field should now be visible.
+ Assert.ok(
+ !cwc.window.document
+ .getElementById("ccAddrInput")
+ .closest(".address-row")
+ .classList.contains("hidden"),
+ "The Cc field is visible"
+ );
+
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,"
+ .repeat(publicRecipientLimit)
+ .replace(/test@/g, () => `test${i++}@`),
+ "Testing move to Bcc",
+ ""
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warnPublicRecipientsNotification`
+ );
+
+ let notificationHidden = BrowserTestUtils.waitForCondition(
+ () =>
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ "public recipients warning was not removed in time"
+ );
+
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {},
+ cwc.window
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll(
+ "#bccAddrContainer > mail-address-pill"
+ ).length,
+ publicRecipientLimit,
+ "Bcc field populated with addresses"
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll("#ccAddrContainer > mail-address-pill")
+ .length,
+ 0,
+ "addresses removed from the Cc field"
+ );
+
+ await notificationHidden;
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test that all the "Cc" recipients are moved to the "Bcc" field when the
+ * address count is over the limit.
+ */
+add_task(async function testAllCcRecipientsMovedToBccWhenOverLimit() {
+ let cwc = open_compose_new_mail();
+ let limit = publicRecipientLimit + 1;
+
+ // Click on the Cc recipient label.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_ccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ // The Cc field should now be visible.
+ Assert.ok(
+ !cwc.window.document
+ .getElementById("ccAddrInput")
+ .closest(".address-row")
+ .classList.contains("hidden"),
+ "The Cc field is visible"
+ );
+
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,".repeat(limit).replace(/test@/g, () => `test${i++}@`),
+ "Testing move to Bcc",
+ ""
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warnPublicRecipientsNotification`
+ );
+
+ let notificationHidden = BrowserTestUtils.waitForCondition(
+ () =>
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ "public recipients warning was not removed in time"
+ );
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {},
+ cwc.window
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll(
+ "#bccAddrContainer > mail-address-pill"
+ ).length,
+ limit,
+ "Bcc field populated with addresses"
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll("#ccAddrContainer > mail-address-pill")
+ .length,
+ 0,
+ "addresses removed from the Cc field"
+ );
+
+ await notificationHidden;
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test that both the "To" and "Cc" recipients are moved to the "Bcc" field when
+ * the user selects that option.
+ */
+add_task(async function testToAndCcRecipientsMovedToBcc() {
+ let cwc = open_compose_new_mail();
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,"
+ .repeat(publicRecipientLimit - 1)
+ .replace(/test@/g, () => `test${i++}@`),
+ "Testing move to Bcc",
+ ""
+ );
+
+ // Click on the Cc recipient label.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("addr_ccShowAddressRowButton"),
+ {},
+ cwc.window
+ );
+ // The Cc field should now be visible.
+ Assert.ok(
+ !cwc.window.document
+ .getElementById("ccAddrInput")
+ .closest(".address-row")
+ .classList.contains("hidden"),
+ "The Cc field is visible"
+ );
+ setup_msg_contents(cwc, "test@example.org", "", "");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warnPublicRecipientsNotification`
+ );
+
+ let notificationHidden = BrowserTestUtils.waitForCondition(
+ () =>
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ "public recipients warning was not removed in time"
+ );
+
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {},
+ cwc.window
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll(
+ "#bccAddrContainer > mail-address-pill"
+ ).length,
+ publicRecipientLimit,
+ "Bcc field populated with addresses"
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll("#toAddrContainer > mail-address-pill")
+ .length,
+ 0,
+ "addresses removed from the To field"
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll("#ccAddrContainer > mail-address-pill")
+ .length,
+ 0,
+ "addresses removed from the Cc field"
+ );
+
+ await notificationHidden;
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the warning is removed when the user chooses to "Keep Recipients Public".
+ */
+add_task(async function testWarningRemovedWhenKeepPublic() {
+ let cwc = open_compose_new_mail();
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,"
+ .repeat(publicRecipientLimit)
+ .replace(/test@/g, () => `test${i++}@`),
+ "Testing dismissal",
+ ""
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warnPublicRecipientsNotification`
+ );
+
+ let notificationHidden = BrowserTestUtils.waitForCondition(
+ () =>
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ "public recipients warning was not removed in time"
+ );
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.lastElementChild,
+ {},
+ cwc.window
+ );
+
+ await notificationHidden;
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll("#toAddrContainer > mail-address-pill")
+ .length,
+ publicRecipientLimit,
+ "addresses were not removed from the field"
+ );
+
+ Assert.equal(
+ cwc.window.document.querySelectorAll(
+ "#bccAddrContainer > mail-address-pill"
+ ).length,
+ 0,
+ "no addresses added to the Bcc field"
+ );
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test that the warning is not shown again if the user dismisses it.
+ */
+add_task(async function testWarningNotShownAfterDismissal() {
+ let cwc = open_compose_new_mail();
+ let i = 1;
+ setup_msg_contents(
+ cwc,
+ "test@example.org,"
+ .repeat(publicRecipientLimit)
+ .replace(/test@/g, () => `test${i++}@`),
+ "Testing dismissal",
+ ""
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warnPublicRecipientsNotification`
+ );
+
+ let notificationHidden = BrowserTestUtils.waitForCondition(
+ () =>
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ "public recipients warning was not removed in time"
+ );
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+ EventUtils.synthesizeMouseAtCenter(notification.closeButton, {}, cwc.window);
+
+ await notificationHidden;
+
+ let input = cwc.window.document.getElementById("toAddrInput");
+ input.focus();
+
+ let recipString = "test@example.org,"
+ .repeat(publicRecipientLimit)
+ .replace(/test@/g, () => `test${i++}@`);
+ EventUtils.sendString(recipString, cwc.window);
+
+ // Wait a little in case the notification bar mistakenly appears.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ Assert.ok(
+ !cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ "public recipients warning did not appear after dismissal"
+ );
+ close_compose_window(cwc);
+});
+
+/**
+ * Tests that the individual addresses of a mailing list are considered.
+ */
+add_task(async function testMailingListMembersCounted() {
+ let book = MailServices.ab.getDirectoryFromId(
+ MailServices.ab.newAddressBook("Mochitest", null, 101)
+ );
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(
+ Ci.nsIAbDirectory
+ );
+ list.isMailList = true;
+ list.dirName = "Test List";
+ list = book.addMailList(list);
+
+ for (let i = 0; i < publicRecipientLimit; i++) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.primaryEmail = `test${i}@example`;
+ list.addCard(card);
+ }
+ list.editMailListToDatabase(null);
+
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(cwc, "Test List", "Testing mailing lists", "");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ ),
+ `Timeout waiting for warnPublicRecipientsNotification`
+ );
+
+ let notification = cwc.window.gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+ Assert.equal(
+ notification.messageText.textContent,
+ `The ${publicRecipientLimit} recipients in To and Cc will see each other’s address. You can avoid disclosing recipients by using Bcc instead.`,
+ "total count equals all addresses plus list expanded"
+ );
+
+ MailServices.ab.deleteAddressBook(book.URI);
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_quoteMessage.js b/comm/mail/test/browser/composition/browser_quoteMessage.js
new file mode 100644
index 0000000000..72f53d8315
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_quoteMessage.js
@@ -0,0 +1,91 @@
+/* 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/. */
+
+/**
+ * Tests that in the compose window, Options > Quote Message works well for
+ * non-UTF8 encoding.
+ */
+
+var { close_compose_window, open_compose_with_reply, get_compose_body } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ open_message_from_file,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var folderToStoreMessages;
+
+add_setup(async function () {
+ folderToStoreMessages = await create_folder("QuoteTestFolder");
+});
+
+add_task(async function test_quoteMessage() {
+ await be_in_folder(folderToStoreMessages);
+
+ let file = new FileUtils.File(getTestFilePath("data/iso-2022-jp.eml"));
+ let msgc = await open_message_from_file(file);
+ // Copy the message to a folder, so that Quote Message menu item is enabled.
+ let documentChild = msgc.window.content.document.documentElement;
+ EventUtils.synthesizeMouseAtCenter(
+ documentChild,
+ { type: "contextmenu", button: 2 },
+ documentChild.ownerGlobal
+ );
+ let aboutMessage = get_about_message(msgc.window);
+ await click_menus_in_sequence(
+ aboutMessage.document.getElementById("mailContext"),
+ [
+ { id: "mailContext-copyMenu" },
+ { label: "Local Folders" },
+ { label: "QuoteTestFolder" },
+ ]
+ );
+ close_window(msgc);
+
+ // Select message and click reply.
+ select_click_row(0);
+ let cwc = open_compose_with_reply();
+ let composeBody = get_compose_body(cwc).textContent;
+ Assert.equal(
+ composeBody.match(/世界/g).length,
+ 1,
+ "Message should be quoted by replying"
+ );
+
+ if (["linux", "win"].includes(AppConstants.platform)) {
+ // Click Options > Quote Message.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("optionsMenu"),
+ {},
+ cwc.window.document.getElementById("optionsMenu").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ cwc.window.document.getElementById("optionsMenuPopup"),
+ [{ id: "menu_quoteMessage" }]
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 50));
+ } else {
+ // Native menubar is used on macOS, didn't find a way to click it.
+ cwc.window.goDoCommand("cmd_quoteMessage");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1));
+ }
+ composeBody = get_compose_body(cwc).textContent;
+ Assert.equal(
+ composeBody.match(/世界/g).length,
+ 2,
+ "Message should be quoted again by Options > Quote Message."
+ );
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_recipientPillsSelection.js b/comm/mail/test/browser/composition/browser_recipientPillsSelection.js
new file mode 100644
index 0000000000..db593530ae
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_recipientPillsSelection.js
@@ -0,0 +1,264 @@
+/* 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 the various selection interaction with the recipient pills.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_new_mail, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { close_popup } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var modifiers =
+ AppConstants.platform == "macosx" ? { accelKey: true } : { ctrlKey: true };
+
+/**
+ * Test the correct pill selection behavior to properly handle multi selection
+ * and accidental deselection when interacting with other elements.
+ */
+add_task(async function test_pill_selection() {
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "test@example.org, test@invalid.foo, test@tinderborx.invalid, alice@foo.test",
+ "Testing recipient pills selection!",
+ "Testing testing testing! "
+ );
+
+ let cDoc = cwc.window.document;
+ let recipientsContainer = cDoc.getElementById("recipientsContainer");
+ let allPills = recipientsContainer.getAllPills();
+
+ Assert.equal(allPills.length, 4, "Pills correctly created");
+
+ // Click on the To input field to move the focus there.
+ EventUtils.synthesizeMouseAtCenter(
+ cDoc.getElementById("toAddrInput"),
+ {},
+ cwc.window
+ );
+ // Ctrl/Cmd+a should select all pills.
+ EventUtils.synthesizeKey("a", modifiers, cwc.window);
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills().length,
+ allPills.length,
+ "All pills currently selected"
+ );
+
+ // Right click on the last pill to open the context menu.
+ let pill3 = allPills[3];
+ let contextMenu = cDoc.getElementById("emailAddressPillPopup");
+ let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ pill3,
+ { type: "contextmenu" },
+ pill3.ownerGlobal
+ );
+ await popupPromise;
+ // The selection should not have changed.
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills().length,
+ allPills.length,
+ "All pills currently selected"
+ );
+ close_popup(cwc, contextMenu);
+
+ // Click on the input field, the pills should all be deselected.
+ EventUtils.synthesizeMouseAtCenter(
+ cDoc.getElementById("toAddrInput"),
+ {},
+ cwc.window
+ );
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills().length,
+ 0,
+ "All pills currently deselected"
+ );
+
+ let popupPromise2 = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+
+ let pill0 = allPills[0];
+ // Right click on the first pill to open the context menu.
+ EventUtils.synthesizeMouseAtCenter(
+ pill0,
+ { type: "contextmenu" },
+ pill0.ownerGlobal
+ );
+ await popupPromise2;
+
+ // The first pill should be selected.
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills().length,
+ 1,
+ "One pill currently selected"
+ );
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills()[0],
+ allPills[0],
+ "The first pill was selected"
+ );
+ close_popup(cwc, contextMenu);
+
+ // Click on the first pill, which should be selected, to trigger edit mode.
+ EventUtils.synthesizeMouseAtCenter(allPills[0], {}, cwc.window);
+ Assert.ok(allPills[0].isEditing, "The pill is in edit mode");
+
+ // Click on the input field, the pills should all be deselected.
+ EventUtils.synthesizeMouseAtCenter(
+ cDoc.getElementById("toAddrInput"),
+ {},
+ cwc.window
+ );
+
+ // Click on the first pill to select it.
+ EventUtils.synthesizeMouseAtCenter(allPills[0], {}, cwc.window);
+ // Ctrl/Cmd+Click ont he second pill to add it to the selection.
+ EventUtils.synthesizeMouseAtCenter(allPills[1], modifiers, cwc.window);
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills().length,
+ 2,
+ "Two pills currently selected"
+ );
+
+ let popupPromise3 = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+
+ let pill2 = allPills[2];
+ // Right click on the thirds pill, which should be selected, to select it
+ // while opening the context menu and deselecting the other two pills.
+ EventUtils.synthesizeMouseAtCenter(
+ pill2,
+ { type: "contextmenu" },
+ pill2.ownerGlobal
+ );
+ await popupPromise3;
+
+ // Only one pills should be selected
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills().length,
+ 1,
+ "One pill currently selected"
+ );
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills()[0],
+ allPills[2],
+ "The third pill was selected"
+ );
+ close_popup(cwc, contextMenu);
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Test the correct behavior of the pill context menu items to edit, remove, and
+ * move the currently selected pills.
+ */
+add_task(async function test_pill_context_menu() {
+ let cwc = open_compose_new_mail();
+ setup_msg_contents(
+ cwc,
+ "test@example.org, test@invalid.foo, test@tinderborx.invalid, alice@foo.test",
+ "Testing recipient pills context menu!",
+ "Testing testing testing! "
+ );
+
+ let cDoc = cwc.window.document;
+ let recipientsContainer = cDoc.getElementById("recipientsContainer");
+ let allPills = recipientsContainer.getAllPills();
+
+ Assert.equal(allPills.length, 4, "Pills correctly created");
+
+ let contextMenu = cDoc.getElementById("emailAddressPillPopup");
+ let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+
+ // Right click on the first pill to open the context menu.
+ let pill = allPills[0];
+ EventUtils.synthesizeMouseAtCenter(
+ pill,
+ { type: "contextmenu" },
+ pill.ownerGlobal
+ );
+ await popupPromise;
+ // The selection should not have changed.
+ Assert.equal(
+ recipientsContainer.getAllSelectedPills().length,
+ 1,
+ "The first pill was selected"
+ );
+
+ let pillMoved = BrowserTestUtils.waitForCondition(
+ () =>
+ cDoc.querySelectorAll("#ccAddrContainer mail-address-pill").length == 1,
+ "Timeout waiting for the pill to be moved to the Cc field"
+ );
+
+ let movePillCc = contextMenu.querySelector("#moveAddressPillCc");
+ // Move the pill to the Cc field.
+ if (AppConstants.platform == "macosx") {
+ // We need to use click() since the synthesizeMouseAtCenter doesn't work for
+ // context menu items on macos.
+ movePillCc.click();
+ } else {
+ EventUtils.synthesizeMouseAtCenter(movePillCc, {}, movePillCc.ownerGlobal);
+ }
+ await pillMoved;
+
+ close_popup(cwc, contextMenu);
+
+ let ccContainer = cDoc.getElementById("ccAddrContainer");
+ let ccPill = ccContainer.querySelector("mail-address-pill");
+
+ // Assert the pill was moved to the Cc filed and it's still selected.
+ Assert.equal(
+ ccPill.fullAddress,
+ allPills[0].fullAddress,
+ "The first pill was moved to the Cc field"
+ );
+ Assert.ok(ccPill.hasAttribute("selected"), "The pill is selected");
+
+ let popupPromise2 = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+
+ // Right click on the same pill to open the context menu.
+ EventUtils.synthesizeMouseAtCenter(
+ ccPill,
+ { type: "contextmenu" },
+ ccPill.ownerGlobal
+ );
+ await popupPromise2;
+
+ let pillMoved2 = BrowserTestUtils.waitForCondition(
+ () =>
+ cDoc.querySelectorAll("#bccAddrContainer mail-address-pill").length == 1,
+ "Timeout waiting for the pill to be moved to the Bcc field"
+ );
+
+ // Move the pill to the Bcc field.
+ let moveAdd = contextMenu.querySelector("#moveAddressPillBcc");
+ if (AppConstants.platform == "macosx") {
+ // We need to use click() since the synthesizeMouseAtCenter doesn't work for
+ // context menu items on macos.
+ moveAdd.click();
+ } else {
+ EventUtils.synthesizeMouseAtCenter(moveAdd, {}, moveAdd.ownerGlobal);
+ }
+ await pillMoved2;
+
+ close_popup(cwc, contextMenu);
+
+ let bccContainer = cDoc.getElementById("bccAddrContainer");
+ let bccPill = bccContainer.querySelector("mail-address-pill");
+
+ // Assert the pill was moved to the Cc filed and it's still selected.
+ Assert.equal(
+ bccPill.fullAddress,
+ allPills[0].fullAddress,
+ "The first pill was moved to the Bcc field"
+ );
+ Assert.ok(bccPill.hasAttribute("selected"), "The pill is selected");
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_redirect.js b/comm/mail/test/browser/composition/browser_redirect.js
new file mode 100644
index 0000000000..9375f3925e
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_redirect.js
@@ -0,0 +1,212 @@
+/* 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/. */
+
+/**
+ * Tests that the message redirect works as it should
+ */
+
+"use strict";
+
+var { async_wait_for_compose_window, close_compose_window } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+var { async_plan_for_new_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_message,
+ get_about_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+var i = 0;
+
+var myEmail = "me@example.com";
+var myEmail2 = "otherme@example.com";
+
+var identity;
+var identity2;
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(function () {
+ // Now set up an account with some identities.
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "Redirect Addresses Testing",
+ "pop3"
+ );
+
+ folder = account.incomingServer.rootFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .createLocalSubfolder("Msgs4Redirect");
+
+ identity = MailServices.accounts.createIdentity();
+ identity.email = myEmail;
+ account.addIdentity(identity);
+
+ identity2 = MailServices.accounts.createIdentity();
+ identity2.email = myEmail2;
+ account.addIdentity(identity2);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ // Let's add messages to the folder later as we go, it's hard to read
+ // out of context what the expected results should be.
+});
+
+/**
+ * Helper to check that the compose window has the expected address fields.
+ */
+function checkAddresses(win, expectedFields) {
+ let rows = win.document.querySelectorAll(
+ "#recipientsContainer .address-row:not(.hidden)"
+ );
+
+ let obtainedFields = [];
+ for (let row of rows) {
+ let addresses = [];
+ for (let pill of row.querySelectorAll("mail-address-pill")) {
+ addresses.push(pill.fullAddress);
+ }
+
+ obtainedFields[row.dataset.recipienttype] = addresses;
+ }
+
+ // Check what we expect is there.
+ for (let type in expectedFields) {
+ let expected = expectedFields[type];
+ let obtained = obtainedFields[type];
+
+ for (let i = 0; i < expected.length; i++) {
+ if (!obtained || !obtained.includes(expected[i])) {
+ throw new Error(
+ expected[i] +
+ " is not in " +
+ type +
+ " fields; " +
+ "obtained=" +
+ obtained
+ );
+ }
+ }
+ Assert.equal(
+ obtained.length,
+ expected.length,
+ "Unexpected number of fields obtained for type=" +
+ type +
+ "; obtained=" +
+ obtained +
+ "; expected=" +
+ expected
+ );
+ }
+
+ // Check there's no "extra" fields either.
+ for (let type in obtainedFields) {
+ let expected = expectedFields[type];
+ let obtained = obtainedFields[type];
+ if (!expected) {
+ throw new Error(
+ "Didn't expect a field for type=" + type + "; obtained=" + obtained
+ );
+ }
+ }
+
+ // Check if the input "aria-label" attribute was properly updated.
+ for (let row of rows) {
+ let addrLabel = row.querySelector(".address-label-container > label").value;
+ let addrTextbox = row.querySelector(".address-row-input");
+ let ariaLabel = addrTextbox.getAttribute("aria-label");
+ let pillCount = row.querySelectorAll("mail-address-pill").length;
+
+ switch (pillCount) {
+ case 0:
+ Assert.equal(ariaLabel, addrLabel);
+ break;
+ case 1:
+ Assert.equal(
+ ariaLabel,
+ addrLabel + " with one address, use left arrow key to focus on it."
+ );
+ break;
+ default:
+ Assert.equal(
+ ariaLabel,
+ addrLabel +
+ " with " +
+ pillCount +
+ " addresses, use left arrow key to focus on them."
+ );
+ break;
+ }
+ }
+}
+
+/**
+ * Tests that addresses get set properly when doing a redirect to a mail
+ * w/ Reply-To.
+ */
+add_task(async function testRedirectToMe() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: myEmail2,
+ cc: "Lisa <lisa@example.com>",
+ subject: "testRedirectToMe",
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ // Open Other Actions.
+ let aboutMessage = get_about_message();
+ let otherActionsButton =
+ aboutMessage.document.getElementById("otherActionsButton");
+ EventUtils.synthesizeMouseAtCenter(otherActionsButton, {}, aboutMessage);
+ let otherActionsPopup =
+ aboutMessage.document.getElementById("otherActionsPopup");
+ let popupshown = BrowserTestUtils.waitForEvent(
+ otherActionsPopup,
+ "popupshown"
+ );
+ await popupshown;
+ info("otherActionsButton popup shown");
+
+ let compWinPromise = async_plan_for_new_window("msgcompose");
+ // Click the Redirect menu item
+ EventUtils.synthesizeMouseAtCenter(
+ otherActionsPopup.firstElementChild,
+ {},
+ aboutMessage
+ );
+ let cwc = await async_wait_for_compose_window(mc, compWinPromise);
+ Assert.equal(
+ cwc.window.getCurrentIdentityKey(),
+ identity2.key,
+ "should be from second identity"
+ );
+ checkAddresses(
+ cwc.window,
+ // What would go into a reply should now be in Reply-To
+ {
+ addr_to: [], // empty
+ addr_reply: ["Homer <homer@example.com>"],
+ }
+ );
+ close_compose_window(cwc, false);
+});
diff --git a/comm/mail/test/browser/composition/browser_remove_text_styling.js b/comm/mail/test/browser/composition/browser_remove_text_styling.js
new file mode 100644
index 0000000000..f1be88b95d
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_remove_text_styling.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/. */
+
+/**
+ * Test removing styling from messages.
+ */
+
+var { close_compose_window, open_compose_new_mail, FormatHelper } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+add_task(async function test_remove_text_styling() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ const NO_SIZE = formatHelper.NO_SIZE;
+
+ let removeButton = formatHelper.removeStylingButton;
+ let removeItem = formatHelper.removeStylingMenuItem;
+
+ // Before focus.
+ Assert.ok(
+ removeButton.disabled,
+ "Remove button should be disabled before focus"
+ );
+
+ formatHelper.focusMessage();
+
+ Assert.ok(
+ !removeButton.disabled,
+ "Remove button should be enabled after focus"
+ );
+
+ async function assertShown(styleSet, font, size, color, state, message) {
+ await formatHelper.assertShownStyles(styleSet, message);
+ await formatHelper.assertShownFont(font, message);
+ await formatHelper.assertShownSize(size, message);
+ await formatHelper.assertShownColor(color, message);
+ await formatHelper.assertShownParagraphState(state, message);
+ }
+
+ let styleSet = [
+ formatHelper.styleDataMap.get("underline"),
+ formatHelper.styleDataMap.get("superscript"),
+ formatHelper.styleDataMap.get("strong"),
+ ];
+ let tags = new Set();
+ styleSet.forEach(style => tags.add(style.tag));
+
+ let color = { value: "#0000ff", rgb: [0, 0, 255] };
+ let font = formatHelper.commonFonts[0];
+ let size = 4;
+
+ // In paragraph state.
+
+ for (let style of styleSet) {
+ await formatHelper.selectStyle(style);
+ }
+ await formatHelper.selectColor(color.value);
+ await formatHelper.selectFont(font);
+ await formatHelper.selectSize(size);
+
+ let text = "some text to apply styling to";
+ await formatHelper.typeInMessage(text);
+
+ formatHelper.assertMessageParagraph(
+ [{ tags, color: color.value, font, size, text }],
+ "Initial styled text"
+ );
+ await assertShown(styleSet, font, size, color, "p", "Set styling and typing");
+
+ removeButton.click();
+ await assertShown(null, "", NO_SIZE, "", "p", "Clicked to stop style");
+
+ let moreText = " without any styling";
+ await formatHelper.typeInMessage(moreText);
+ await assertShown(null, "", NO_SIZE, "", "p", "Typing with no styling");
+
+ formatHelper.assertMessageParagraph(
+ [{ tags, color: color.value, font, size, text }, moreText],
+ "Unstyled at end"
+ );
+
+ // Initialize some styling for the next typed character.
+ // Don't select any Text Styles because of Bug 1716840.
+ // for (let style of styleSet) {
+ // await formatHelper.selectStyle(style);
+ // }
+ await formatHelper.selectColor(color.value);
+ await formatHelper.selectFont(font);
+ await formatHelper.selectSize(size);
+
+ await assertShown(null, font, size, color, "p", "Getting some styling ready");
+
+ // Select through menu.
+ await formatHelper.selectFromFormatMenu(removeItem);
+ await assertShown(null, "", NO_SIZE, "", "p", "Removed readied styling");
+
+ await formatHelper.typeInMessage("a");
+ moreText += "a";
+ await assertShown(null, "", NO_SIZE, "", "p", "Still unstyled when typing");
+
+ formatHelper.assertMessageParagraph(
+ [{ tags, color: color.value, font, size, text }, moreText],
+ "Remains unstyled at end"
+ );
+
+ await formatHelper.selectTextRange(0, 3);
+ await assertShown(styleSet, font, size, color, "p", "Select start");
+
+ removeButton.click();
+ await assertShown(null, "", NO_SIZE, "", "p", "Selection is unstyled");
+ formatHelper.assertMessageParagraph(
+ [
+ text.slice(0, 3),
+ { tags, color: color.value, font, size, text: text.slice(3) },
+ moreText,
+ ],
+ "Becomes unstyled at start"
+ );
+
+ await formatHelper.selectTextRange(1, text.length + 3);
+ // Mixed selection
+ // See Bug 1718227 (the size menu does not respond to mixed selections)
+ // await assertShown(null, null, null, null, "p", "Select mixed");
+
+ // Select through menu.
+ await formatHelper.selectFromFormatMenu(removeItem);
+ await assertShown(null, "", NO_SIZE, "", "p", "Mixed selection now unstyled");
+ formatHelper.assertMessageParagraph(
+ [text + moreText],
+ "Style is fully stripped"
+ );
+
+ close_compose_window(controller);
+});
diff --git a/comm/mail/test/browser/composition/browser_replyAddresses.js b/comm/mail/test/browser/composition/browser_replyAddresses.js
new file mode 100644
index 0000000000..0cf540297c
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_replyAddresses.js
@@ -0,0 +1,1143 @@
+/* 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/. */
+
+/**
+ * Tests that we get correct adressees for different type of replies:
+ * reply to sender, reply to all, reply to list, mail-followup-tp,
+ * mail-reply-to, and reply to self.
+ */
+
+"use strict";
+
+var {
+ close_compose_window,
+ open_compose_with_reply,
+ open_compose_with_reply_to_all,
+ open_compose_with_reply_to_list,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+var i = 0;
+
+var myEmail = "me@example.com";
+var myEmail2 = "otherme@example.com";
+
+var identity;
+var identity2;
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(function () {
+ requestLongerTimeout(4);
+
+ // Now set up an account with some identities.
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "Reply Addresses Testing",
+ "pop3"
+ );
+
+ folder = account.incomingServer.rootFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .createLocalSubfolder("Msgs4Reply");
+
+ identity = MailServices.accounts.createIdentity();
+ identity.email = myEmail;
+ account.addIdentity(identity);
+
+ identity2 = MailServices.accounts.createIdentity();
+ identity2.email = myEmail2;
+ account.addIdentity(identity2);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ // Let's add messages to the folder later as we go, it's hard to read
+ // out of context what the expected results should be.
+});
+
+/**
+ * Helper to open a reply, check the fields are as expected, and close the
+ * reply window.
+ *
+ * @param aReplyFunction which reply function to call
+ * @param aExpectedFields the fields expected
+ */
+function checkReply(aReplyFunction, aExpectedFields) {
+ let rwc = aReplyFunction();
+ checkToAddresses(rwc, aExpectedFields);
+ close_compose_window(rwc);
+}
+
+/**
+ * Helper to check that the reply window has the expected address fields.
+ */
+function checkToAddresses(replyWinController, expectedFields) {
+ let rows = replyWinController.window.document.querySelectorAll(
+ "#recipientsContainer .address-row:not(.hidden)"
+ );
+
+ let obtainedFields = [];
+ for (let row of rows) {
+ let addresses = [];
+ for (let pill of row.querySelectorAll("mail-address-pill")) {
+ addresses.push(pill.fullAddress);
+ }
+
+ obtainedFields[row.dataset.recipienttype] = addresses;
+ }
+
+ // Check what we expect is there.
+ for (let type in expectedFields) {
+ let expected = expectedFields[type];
+ let obtained = obtainedFields[type];
+
+ for (let i = 0; i < expected.length; i++) {
+ if (!obtained || !obtained.includes(expected[i])) {
+ throw new Error(
+ expected[i] +
+ " is not in " +
+ type +
+ " fields; " +
+ "obtained=" +
+ obtained
+ );
+ }
+ }
+ Assert.equal(
+ obtained.length,
+ expected.length,
+ "Unexpected number of fields obtained for type=" +
+ type +
+ "; obtained=" +
+ obtained +
+ "; expected=" +
+ expected
+ );
+ }
+
+ // Check there's no "extra" fields either.
+ for (let type in obtainedFields) {
+ let expected = expectedFields[type];
+ let obtained = obtainedFields[type];
+ if (!expected) {
+ throw new Error(
+ "Didn't expect a field for type=" + type + "; obtained=" + obtained
+ );
+ }
+ }
+
+ // Check if the input "aria-label" attribute was properly updated.
+ for (let row of rows) {
+ let addrLabel = row.querySelector(".address-label-container > label").value;
+ let addrTextbox = row.querySelector(".address-row-input");
+ let ariaLabel = addrTextbox.getAttribute("aria-label");
+ let pillCount = row.querySelectorAll("mail-address-pill").length;
+
+ switch (pillCount) {
+ case 0:
+ Assert.equal(ariaLabel, addrLabel);
+ break;
+ case 1:
+ Assert.equal(
+ ariaLabel,
+ addrLabel + " with one address, use left arrow key to focus on it."
+ );
+ break;
+ default:
+ Assert.equal(
+ ariaLabel,
+ addrLabel +
+ " with " +
+ pillCount +
+ " addresses, use left arrow key to focus on them."
+ );
+ break;
+ }
+ }
+}
+
+/**
+ * Helper to set an auto-Cc list for an identity.
+ */
+function useAutoCc(aIdentity, aCcList) {
+ aIdentity.doCc = true;
+ aIdentity.doCcList = aCcList;
+}
+
+/**
+ * Helper to stop using auto-Cc for an identity.
+ */
+function stopUsingAutoCc(aIdentity) {
+ aIdentity.doCc = false;
+ aIdentity.doCcList = "";
+}
+
+/**
+ * Helper to ensure autoCc is turned off.
+ */
+function ensureNoAutoCc(aIdentity) {
+ aIdentity.doCc = false;
+}
+
+/**
+ * Helper to set an auto-bcc list for an identity.
+ */
+function useAutoBcc(aIdentity, aBccList) {
+ aIdentity.doBcc = true;
+ aIdentity.doBccList = aBccList;
+}
+
+/**
+ * Helper to stop using auto-bcc for an identity.
+ */
+function stopUsingAutoBcc(aIdentity) {
+ aIdentity.doBcc = false;
+ aIdentity.doBccList = "";
+}
+
+/**
+ * Helper to ensure auto-bcc is turned off.
+ */
+function ensureNoAutoBcc(aIdentity) {
+ aIdentity.doBcc = false;
+}
+
+/**
+ * Tests that for a list post with munged Reply-To:
+ * - reply: goes to From
+ * - reply all: includes From + the usual thing
+ * - reply list: goes to the list
+ */
+add_task(async function testReplyToMungedReplyToList() {
+ let msg0 = create_message({
+ from: "Tester <test@example.com>",
+ to: "munged.list@example.com, someone.else@example.com",
+ subject: "testReplyToMungedReplyToList",
+ clobberHeaders: {
+ "Reply-To": "Munged List <munged.list@example.com>",
+ "List-Post": "<mailto:munged.list@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+
+ checkReply(open_compose_with_reply, {
+ addr_to: ["Tester <test@example.com>"],
+ });
+
+ checkReply(open_compose_with_reply_to_all, {
+ addr_to: [
+ "Munged List <munged.list@example.com>",
+ "someone.else@example.com",
+ "Tester <test@example.com>",
+ ],
+ });
+
+ checkReply(open_compose_with_reply_to_list, {
+ addr_to: ["munged.list@example.com"],
+ });
+});
+
+/**
+ * Tests that addresses get set properly when doing a normal reply.
+ */
+add_task(async function testToCcReply() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "Mr Burns <mrburns@example.com>, workers@example.com, " + myEmail,
+ cc: "Lisa <lisa@example.com>",
+ subject: "testToCcReply - normal mail with to and cc (me in To)",
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply,
+ // To: From
+ { addr_to: ["Homer <homer@example.com>"] }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply,
+ // To: From
+ // Cc: identity Cc list, including self.
+ {
+ addr_to: ["Homer <homer@example.com>"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly when doing a normal reply to all.
+ */
+add_task(async function testToCcReplyAll() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "Mr Burns <mrburns@example.com>, workers@example.com, " + myEmail,
+ cc: "Lisa <lisa@example.com>",
+ subject: "testToCcReplyAll - normal mail with to and cc (me in To)",
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From + Tos without me.
+ // Cc: original Ccs
+ {
+ addr_to: [
+ "Homer <homer@example.com>",
+ "Mr Burns <mrburns@example.com>",
+ "workers@example.com",
+ ],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From + Tos without me.
+ // Cc: original Ccs + auto-Ccs
+ {
+ addr_to: [
+ "Homer <homer@example.com>",
+ "Mr Burns <mrburns@example.com>",
+ "workers@example.com",
+ ],
+ addr_cc: ["Lisa <lisa@example.com>", myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that that addresses get set properly when doing a normal reply to all
+ * where when recipients aren't all ascii.
+ */
+add_task(async function testToCcReplyAllInternational() {
+ let msg0 = create_message({
+ from: "Hideaki / =?iso-2022-jp?B?GyRCNUhGIzFRTEAbKEI=?= <hideaki@example.com>",
+ to:
+ "Mr Burns <mrburns@example.com>, =?UTF-8?B?w4VrZQ==?= <ake@example.com>, " +
+ "=?KOI8-R?Q?=E9=D7=C1=CE?= <ivan@example.com>, " +
+ myEmail,
+ cc: "=?Big5?B?pP2oca1e?= <xiuying@example.com>",
+ subject:
+ "testToCcReplyAllInternational - non-ascii people mail with to and cc (me in To)",
+ clobberHeaders: { "Content-Transfer-Encoding": "quoted-printable" },
+ // Content-Transfer-Encoding ^^^ should be set from the body encoding below,
+ // but that doesn't seem to work. (No Content-Transfer-Encoding header is
+ // generated).
+ body: {
+ charset: "windows-1251",
+ encoding: "quoted-printable",
+ body: "=CF=F0=E8=E2=E5=F2 =E8=E7 =CC=EE=F1=EA=E2=FB",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From + Tos without me.
+ // Cc: original Ccs
+ {
+ addr_to: [
+ "Hideaki / å‰è—¤è‹±æ˜Ž <hideaki@example.com>",
+ "Mr Burns <mrburns@example.com>",
+ "Ã…ke <ake@example.com>",
+ "Иван <ivan@example.com>",
+ ],
+ addr_cc: ["王秀英 <xiuying@example.com>"],
+ }
+ );
+
+ useAutoCc(identity, "Ã…sa <asa@example.com>");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From + Tos without me.
+ // Cc: original Ccs + auto-Ccs
+ {
+ addr_to: [
+ "Hideaki / å‰è—¤è‹±æ˜Ž <hideaki@example.com>",
+ "Mr Burns <mrburns@example.com>",
+ "Ã…ke <ake@example.com>",
+ "Иван <ivan@example.com>",
+ ],
+ addr_cc: ["王秀英 <xiuying@example.com>", "Åsa <asa@example.com>"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that that addresses get set properly when doing a reply to a mail with
+ * reply-to set.
+ */
+add_task(async function testToCcReplyWhenReplyToSet() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers@example.com",
+ cc: "Lisa <lisa@example.com>, " + myEmail,
+ subject:
+ "testToCcReplyWhenReplyToSet - to/cc mail with reply-to set (me in Cc)",
+ clobberHeaders: {
+ "Reply-To": "marge@example.com",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply,
+ // To: reply-to
+ { addr_to: ["marge@example.com"] }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply,
+ // To: reply-to
+ // Cc: auto-Ccs
+ {
+ addr_to: ["marge@example.com"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly when doing a reply to all for a mail
+ * w/ Reply-To.
+ */
+add_task(async function testToCcReplyAllWhenReplyToSet() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers@example.com",
+ cc: "Lisa <lisa@example.com>, " + myEmail,
+ subject:
+ "testToCcReplyAllWhenReplyToSet - to/cc mail with reply-to set (me in Cc)",
+ clobberHeaders: {
+ "Reply-To": "marge@example.com",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: Reply-To + Tos
+ // Cc: original Ccs without me.
+ {
+ addr_to: ["marge@example.com", "workers@example.com"],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: Reply-To + Tos
+ // Cc: original Ccs + auto-Ccs (which includes me!)
+ {
+ addr_to: ["marge@example.com", "workers@example.com"],
+ addr_cc: ["Lisa <lisa@example.com>", myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly when doing a reply to list.
+ */
+add_task(async function testReplyToList() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers-list@example.com",
+ cc: "Lisa <lisa@example.com>, " + myEmail,
+ subject: "testReplyToList - mailing list message (me in Cc)",
+ clobberHeaders: {
+ "List-Post": "<mailto:workers-list@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_list,
+ // To: the list
+ { addr_to: ["workers-list@example.com"] }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_list,
+ // To: the list
+ // Cc: auto-Ccs
+ {
+ addr_to: ["workers-list@example.com"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly when doing a reply to sender for a
+ * list post.
+ */
+add_task(async function testReplySenderForListPost() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers-list@example.com",
+ cc: "Lisa <lisa@example.com>, " + myEmail,
+ subject: "testReplySenderForListPost - mailing list message (me in Cc)",
+ clobberHeaders: {
+ "List-Post": "<mailto:workers-list@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply,
+ // To: From
+ { addr_to: ["Homer <homer@example.com>"] }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply,
+ // To: From
+ // Cc: auto-Ccs
+ {
+ addr_to: ["Homer <homer@example.com>"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly when doing a reply all to a list post.
+ */
+add_task(async function testReplyToAllForListPost() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers-list@example.com",
+ cc: "Lisa <lisa@example.com>, " + myEmail,
+ subject: "testReplyToAllForListPost - mailing list message (me in Cc)",
+ clobberHeaders: {
+ "List-Post": "<mailto:workers-list@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From + original To
+ // Cc: original CC without me
+ {
+ addr_to: ["Homer <homer@example.com>", "workers-list@example.com"],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From + original To
+ // Cc: original CC + auto-Ccs (including me!)
+ {
+ addr_to: ["Homer <homer@example.com>", "workers-list@example.com"],
+ addr_cc: ["Lisa <lisa@example.com>", myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly when doing a reply to all for a list
+ * post when also reply-to is set.
+ */
+add_task(async function testReplyToListWhenReplyToSet() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers-list@example.com, " + myEmail,
+ cc: "Lisa <lisa@example.com>",
+ subject:
+ "testReplyToListWhenReplyToSet - mailing list message w/ cc, reply-to (me in To)",
+ clobberHeaders: {
+ "Reply-To": "marge@example.com",
+ "List-Post": "<mailto:workers-list@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: Reply-To, original Tos
+ // Cc: original Cc
+ {
+ addr_to: ["marge@example.com", "workers-list@example.com"],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: Reply-To, original Tos
+ // Cc: original Cc + auto-Ccs
+ {
+ addr_to: ["marge@example.com", "workers-list@example.com"],
+ addr_cc: ["Lisa <lisa@example.com>", myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Test that addresses get set properly for Mail-Reply-To. Mail-Reply-To should
+ * be used for reply to author, if present.
+ *
+ * @see http://cr.yp.to/proto/replyto.html
+ */
+add_task(async function testMailReplyTo() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers-list@example.com",
+ cc: "Lisa <lisa@example.com>",
+ subject: "testMailReplyTo - mail with Mail-Reply-To header",
+ clobberHeaders: {
+ "Reply-To": "workers-list@example.com", // reply-to munging
+ "Mail-Reply-To": "Homer S. <homer@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply,
+ // To: Mail-Reply-To
+ { addr_to: ["Homer S. <homer@example.com>"] }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply,
+ // To: Mail-Reply-To
+ // Cc: auto-Ccs
+ {
+ addr_to: ["Homer S. <homer@example.com>"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Test that addresses get set properly Mail-Followup-To. Mail-Followup-To
+ * should be the default recipient list for reply-all, if present.
+ *
+ * @see http://cr.yp.to/proto/replyto.html
+ */
+add_task(async function testMailFollowupTo() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers-list@example.com, " + myEmail,
+ cc: "Lisa <lisa@example.com>",
+ subject: "testMailFollowupTo - mail with Mail-Followup-To header",
+ clobberHeaders: {
+ // Homer is on the list, and don't want extra copies, so he has
+ // set the Mail-Followup-To header so followups go to the list.
+ "Mail-Followup-To": "workers-list@example.com",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: Mail-Followup-To
+ { addr_to: ["workers-list@example.com"] }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: Mail-Followup-To
+ // Cc: auto-Ccs
+ {
+ addr_to: ["workers-list@example.com"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly for reply to self.
+ */
+add_task(async function testReplyToSelfReply() {
+ let msg0 = create_message({
+ // Upper case just to make sure we don't care about case sensitivity.
+ from: myEmail.toUpperCase(),
+ to: "Bart <bart@example.com>, Maggie <maggie@example.com>",
+ cc: "Lisa <lisa@example.com>",
+ subject: "testReplyToSelfReply - reply to self",
+ clobberHeaders: {
+ Bcc: "Moe <moe@example.com>",
+ "Reply-To": "Flanders <flanders@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply,
+ // To: original To
+ // Reply-To: original Reply-To
+ {
+ addr_to: ["Bart <bart@example.com>", "Maggie <maggie@example.com>"],
+ addr_reply: ["Flanders <flanders@example.com>"],
+ }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply,
+ // To: original To
+ // Cc: auto-Ccs
+ // Reply-To: original Reply-To
+ {
+ addr_to: ["Bart <bart@example.com>", "Maggie <maggie@example.com>"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ addr_reply: ["Flanders <flanders@example.com>"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly for a reply all to self - this should
+ * be treated as a followup.
+ */
+add_task(async function testReplyToSelfReplyAll() {
+ let msg0 = create_message({
+ from: myEmail,
+ to: "Bart <bart@example.com>, Maggie <maggie@example.com>",
+ cc: "Lisa <lisa@example.com>",
+ subject: "testReplyToSelfReplyAll - reply to self",
+ clobberHeaders: {
+ Bcc: "Moe <moe@example.com>",
+ "Reply-To": "Flanders <flanders@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: original To
+ // Cc: original Cc
+ // Bcc: original Bcc
+ // Reply-To: original Reply-To
+ {
+ addr_to: ["Bart <bart@example.com>", "Maggie <maggie@example.com>"],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ addr_bcc: ["Moe <moe@example.com>"],
+ addr_reply: ["Flanders <flanders@example.com>"],
+ }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ useAutoBcc(identity, "Lisa <lisa@example.com>");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: original To
+ // Cc: original Cc (auto-Ccs would have been included here already)
+ // Bcc: original Bcc
+ // Reply-To: original Reply-To
+ {
+ addr_to: ["Bart <bart@example.com>", "Maggie <maggie@example.com>"],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ addr_bcc: ["Moe <moe@example.com>"],
+ addr_reply: ["Flanders <flanders@example.com>"],
+ }
+ );
+ stopUsingAutoCc(identity);
+ stopUsingAutoBcc(identity);
+});
+
+/**
+ * Tests that addresses get set properly for a reply all to self - but for a
+ * message that is not really the original sent message. Like an auto-bcc:d copy
+ * or from Gmail. This should be treated as a followup.
+ */
+add_task(async function testReplyToSelfNotOriginalSourceMsgReplyAll() {
+ let msg0 = create_message({
+ from: myEmail2,
+ to: "Bart <bart@example.com>, Maggie <maggie@example.com>",
+ cc: "Lisa <lisa@example.com>",
+ subject: "testReplyToSelfNotOriginalSourceMsgReplyAll - reply to self",
+ clobberHeaders: {
+ "Reply-To": "Flanders <flanders@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity2);
+ useAutoBcc(identity2, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: original To
+ // Cc: original Cc
+ // Bcc: auto-bccs
+ // Reply-To: original Reply-To
+ {
+ addr_to: ["Bart <bart@example.com>", "Maggie <maggie@example.com>"],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ addr_bcc: [myEmail, "smithers@example.com"],
+ addr_reply: ["Flanders <flanders@example.com>"],
+ }
+ );
+ stopUsingAutoBcc(identity2);
+
+ useAutoCc(identity2, myEmail + ", smithers@example.com");
+ useAutoBcc(identity2, "moe@example.com,bart@example.com,lisa@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: original To
+ // Cc: original Cc (auto-Ccs would have been included here already)
+ // Bcc: auto-bcc minus addresses already in To/Cc
+ // Reply-To: original Reply-To
+ {
+ addr_to: ["Bart <bart@example.com>", "Maggie <maggie@example.com>"],
+ addr_cc: ["Lisa <lisa@example.com>", myEmail, "smithers@example.com"],
+ addr_bcc: ["moe@example.com"],
+ addr_reply: ["Flanders <flanders@example.com>"],
+ }
+ );
+ stopUsingAutoCc(identity2);
+ stopUsingAutoBcc(identity2);
+
+ useAutoBcc(identity2, myEmail2 + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: original To
+ // Cc: original Cc (auto-Ccs would have been included here already)
+ // Bcc: auto-bccs
+ // Reply-To: original Reply-To
+ {
+ addr_to: ["Bart <bart@example.com>", "Maggie <maggie@example.com>"],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ addr_bcc: [myEmail2, "smithers@example.com"],
+ addr_reply: ["Flanders <flanders@example.com>"],
+ }
+ );
+ stopUsingAutoBcc(identity2);
+});
+
+/**
+ * Tests that a reply to an other identity isn't treated as a reply to self
+ * followup.
+ */
+add_task(async function testReplyToOtherIdentity() {
+ let msg0 = create_message({
+ from: myEmail,
+ to: myEmail2 + ", barney@example.com",
+ cc: "Lisa <lisa@example.com>",
+ subject: "testReplyToOtherIdentity - reply to other identity",
+ clobberHeaders: {
+ "Reply-To": "secretary@example.com",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity2);
+ ensureNoAutoBcc(identity2);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: from + to (except me2)
+ // Cc: original Cc
+ //
+ {
+ addr_to: ["secretary@example.com", "barney@example.com"],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ }
+ );
+});
+
+/**
+ * Tests that addresses get set properly for a reply all to self w/ bccs -
+ * this should be treated as a followup.
+ */
+add_task(async function testReplyToSelfWithBccs() {
+ let msg0 = create_message({
+ from: myEmail,
+ to: myEmail,
+ cc: myEmail2 + ", Lisa <lisa@example.com>",
+ subject: "testReplyToSelfWithBccs - reply to self",
+ clobberHeaders: {
+ Bcc: "Moe <moe@example.com>, Barney <barney@example.com>",
+ "Reply-To": myEmail2,
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: original To
+ // Cc: original Cc
+ // Bcc: original Bcc
+ // Reply-To: original Reply-To
+ {
+ addr_to: [myEmail],
+ addr_cc: [myEmail2, "Lisa <lisa@example.com>"],
+ addr_bcc: ["Moe <moe@example.com>", "Barney <barney@example.com>"],
+ addr_reply: [myEmail2],
+ }
+ );
+});
+
+/**
+ * Tests that addresses get set properly for a reply all to other identity w/ bccs -
+ * this be treated as a followup.
+ */
+add_task(async function testReplyToOtherIdentityWithBccs() {
+ let msg0 = create_message({
+ from: myEmail,
+ to: myEmail2,
+ cc: "Lisa <lisa@example.com>",
+ subject: "testReplyToOtherIdentityWithBccs - reply to other identity",
+ clobberHeaders: {
+ Bcc: "Moe <moe@example.com>, Barney <barney@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: original To
+ // Cc: original Cc
+ // Bcc: original Bcc
+ {
+ addr_to: [myEmail2],
+ addr_cc: ["Lisa <lisa@example.com>"],
+ addr_bcc: ["Moe <moe@example.com>", "Barney <barney@example.com>"],
+ }
+ );
+});
+
+/**
+ * Tests that addresses get set properly for a nntp reply-all.
+ */
+add_task(async function testNewsgroupsReplyAll() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "test1-list@example.org",
+ subject: "testNewsgroupsReplyAll - sent to two newsgroups and a list",
+ clobberHeaders: {
+ Newsgroups: "example.test1, example.test2",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From, original To
+ // Newsgroups: original Ccs
+ {
+ addr_to: ["Homer <homer@example.com>", "test1-list@example.org"],
+ addr_newsgroups: ["example.test1", "example.test2"],
+ }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From, original To
+ // Newsgroups: original Ccs
+ {
+ addr_to: ["Homer <homer@example.com>", "test1-list@example.org"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ addr_newsgroups: ["example.test1", "example.test2"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly for an nntp followup, when Followup-To
+ * is set.
+ */
+add_task(async function testNewsgroupsReplyAllFollowupTo() {
+ let msg0 = create_message({
+ from: "Homer <homer@example.com>",
+ to: "test1-list@example.org, " + myEmail,
+ subject: "testNewsgroupsReplyAllFollowupTo - Followup-To set",
+ clobberHeaders: {
+ Newsgroups: "example.test1, example.test2",
+ "Followup-To": "example.test2",
+ },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From + original To (except me)
+ // Newsgroups: <Followup-To>
+ {
+ addr_to: ["Homer <homer@example.com>", "test1-list@example.org"],
+ addr_newsgroups: ["example.test2"],
+ }
+ );
+
+ useAutoCc(identity, myEmail + ", smithers@example.com");
+ checkReply(
+ open_compose_with_reply_to_all,
+ // To: From + original To (except me)
+ // Cc: auto-Ccs
+ // Newsgroups: <Followup-To>
+ {
+ addr_to: ["Homer <homer@example.com>", "test1-list@example.org"],
+ addr_cc: [myEmail, "smithers@example.com"],
+ addr_newsgroups: ["example.test2"],
+ }
+ );
+ stopUsingAutoCc(identity);
+});
+
+/**
+ * Tests that addresses get set properly when doing a reply where To=From
+ * and a Reply-To exists.
+ */
+add_task(async function testToFromWithReplyTo() {
+ let msg0 = create_message({
+ from: myEmail,
+ to: myEmail,
+ subject: "testToFromWithReplyTo - To=From w/ Reply-To set",
+ clobberHeaders: { "Reply-To": "Flanders <flanders@example.com>" },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ await be_in_folder(folder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+
+ ensureNoAutoCc(identity);
+ checkReply(
+ open_compose_with_reply,
+ // To: Reply-To
+ { addr_to: ["Flanders <flanders@example.com>"] }
+ );
+});
diff --git a/comm/mail/test/browser/composition/browser_replyCatchAll.js b/comm/mail/test/browser/composition/browser_replyCatchAll.js
new file mode 100644
index 0000000000..b53be9bd1c
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_replyCatchAll.js
@@ -0,0 +1,271 @@
+/* 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/. */
+
+/**
+ * Tests that reply messages use the correct identity and sender dependent
+ * on the catchAll setting.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_reply } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_message,
+ mc,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { assert_notification_displayed, wait_for_notification_to_show } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+ );
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var i = 0;
+
+var id1Domain = "example.com";
+var id2Domain = "example.net";
+var myIdentityEmail1 = "me@example.com";
+var myIdentityEmail2 = "otherme@example.net";
+var envelopeToAddr = "envelope@example.net";
+var notMyEmail = "otherme@example.org";
+
+var identity1;
+var identity2;
+
+var gAccount;
+var gFolder;
+
+add_setup(function () {
+ requestLongerTimeout(4);
+
+ // Now set up an account with some identities.
+ gAccount = MailServices.accounts.createAccount();
+ gAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "Reply Identity Testing",
+ "pop3"
+ );
+
+ gFolder = gAccount.incomingServer.rootFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .createLocalSubfolder("Msgs4Reply");
+
+ identity1 = MailServices.accounts.createIdentity();
+ identity1.email = myIdentityEmail1;
+ gAccount.addIdentity(identity1);
+ info(`Added identity1; key=${identity1.key}, email=${identity1.email}`);
+
+ identity2 = MailServices.accounts.createIdentity();
+ identity2.email = myIdentityEmail2;
+ gAccount.addIdentity(identity2);
+ info(`Added identity2; key=${identity2.key}, email=${identity2.email}`);
+});
+
+/**
+ * Create and select a new message to do a reply with.
+ */
+async function create_replyMsg(aTo, aEnvelopeTo) {
+ let msg0 = create_message({
+ from: "Tester <test@example.com>",
+ to: aTo,
+ subject: "test",
+ clobberHeaders: {
+ "envelope-to": aEnvelopeTo,
+ },
+ });
+ await add_message_to_folder([gFolder], msg0);
+
+ await be_in_folder(gFolder);
+ let msg = select_click_row(i++);
+ assert_selected_and_displayed(mc, msg);
+}
+
+/**
+ * The tests.
+ */
+add_task(async function test_reply_identity_selection() {
+ let tests = [
+ {
+ desc: "No catchAll, 'From' will be set to recipient",
+ to: myIdentityEmail2,
+ envelopeTo: myIdentityEmail2,
+ catchAllId1: false,
+ catchAllHintId1: "",
+ catchAllId2: false,
+ catchAllHintId2: "",
+ replyIdKey: identity2.key,
+ replyIdFrom: myIdentityEmail2,
+ warning: false,
+ },
+ {
+ desc: "No catchAll, 'From' will be set to second id's email (without name).",
+ to: "Mr.X <" + myIdentityEmail2 + ">",
+ envelopeTo: "",
+ catchAllId1: false,
+ catchAllHintId1: "",
+ catchAllId2: false,
+ catchAllHintId2: "",
+ replyIdKey: identity2.key,
+ replyIdFrom: myIdentityEmail2,
+ warning: false,
+ },
+ {
+ desc: "With catchAll, 'From' will be set to senders address (with name).",
+ to: "Mr.X <" + myIdentityEmail2 + ">",
+ envelopeTo: "",
+ catchAllId1: false,
+ catchAllHintId1: "",
+ catchAllId2: true,
+ catchAllHintId2: "*@" + id2Domain,
+ replyIdKey: identity2.key,
+ replyIdFrom: "Mr.X <" + myIdentityEmail2 + ">",
+ warning: false,
+ },
+ {
+ desc: "With catchAll #2, 'From' will be set to senders address (with name).",
+ to: myIdentityEmail2,
+ envelopeTo: "Mr.X <" + myIdentityEmail2 + ">",
+ catchAllId1: false,
+ catchAllHintId1: "",
+ catchAllId2: true,
+ catchAllHintId2: "*@" + id2Domain,
+ replyIdKey: identity2.key,
+ replyIdFrom: "Mr.X <" + myIdentityEmail2 + ">",
+ warning: false,
+ },
+ {
+ desc: "With catchAll, 'From' will be set to second id's email.",
+ to: myIdentityEmail2,
+ envelopeTo: envelopeToAddr,
+ catchAllId1: false,
+ catchAllId2: true,
+ replyIdKey: identity2.key,
+ replyIdFrom: myIdentityEmail2,
+ warning: false,
+ },
+ {
+ desc: `With catchAll, 'From' will be set to ${envelopeToAddr}`,
+ to: notMyEmail,
+ envelopeTo: envelopeToAddr,
+ catchAllId1: false,
+ catchAllHintId1: "",
+ catchAllId2: true,
+ catchAllHintId2: "*@" + id2Domain,
+ replyIdKey: identity2.key,
+ replyIdFrom: envelopeToAddr,
+ warning: true,
+ },
+ {
+ desc: "Without catchAll, mail to another recipient.",
+ to: notMyEmail,
+ envelopeTo: "",
+ catchAllId1: false,
+ catchAllHintId1: "",
+ catchAllId2: false,
+ catchAllHintId2: "",
+ replyIdKey: identity1.key,
+ replyIdFrom: myIdentityEmail1,
+ warning: false,
+ },
+ {
+ desc: " With catchAll, mail to another recipient (domain not matching).",
+ to: notMyEmail,
+ envelopeTo: "",
+ catchAllId1: true,
+ catchAllHintId1: "*@" + id1Domain,
+ catchAllId2: true,
+ catchAllHintId2: "*@" + id2Domain,
+ replyIdKey: identity1.key,
+ replyIdFrom: myIdentityEmail1,
+ warning: false,
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Running test: ${test.desc}`);
+ test.replyIndex = await create_replyMsg(test.to, test.envelopeTo);
+
+ identity1.catchAll = test.catchAllId1;
+ identity1.catchAllHint = test.catchAllHintId1;
+ info(
+ `... identity1.catchAll=${identity1.catchAll}, identity1.catchAllHint=${identity1.catchAllHint}`
+ );
+
+ identity2.catchAll = test.catchAllId2;
+ identity2.catchAllHint = test.catchAllHintId2;
+ info(
+ `... identity2.catchAll=${identity2.catchAll}, identity2.catchAllHint=${identity2.catchAllHint}`
+ );
+
+ let cwc = open_compose_with_reply();
+
+ info("Checking reply identity: " + JSON.stringify(test, null, 2));
+ checkCompIdentity(cwc, test.replyIdKey, test.replyIdFrom);
+
+ if (test.warning) {
+ wait_for_notification_to_show(
+ cwc.window,
+ "compose-notification-bottom",
+ "identityWarning"
+ );
+ } else {
+ assert_notification_displayed(
+ cwc.window,
+ "compose-notification-bottom",
+ "identityWarning",
+ false
+ );
+ }
+
+ close_compose_window(cwc, false);
+ }
+});
+
+/**
+ * Helper to check that a suitable From identity was set up in the given
+ * composer window.
+ *
+ * @param cwc Compose window controller.
+ * @param aIdentityKey The key of the expected identity.
+ * @param aFrom The expected displayed From address.
+ */
+function checkCompIdentity(cwc, identityKey, from) {
+ Assert.equal(
+ cwc.window.document.getElementById("msgIdentity").value,
+ from,
+ "msgIdentity value should be as expected."
+ );
+ Assert.equal(
+ cwc.window.getCurrentIdentityKey(),
+ identityKey,
+ "The From identity should be correctly selected."
+ );
+}
+
+registerCleanupFunction(async function () {
+ await be_in_folder(gFolder);
+ while (gFolder.getTotalMessages(false) > 0) {
+ select_click_row(0);
+ press_delete();
+ }
+
+ gAccount.removeIdentity(identity2);
+
+ // The last identity of an account can't be removed so clear all its prefs
+ // which effectively destroys it.
+ identity1.clearAllValues();
+ MailServices.accounts.removeAccount(gAccount);
+ gAccount = null;
+});
diff --git a/comm/mail/test/browser/composition/browser_replyFormatFlowed.js b/comm/mail/test/browser/composition/browser_replyFormatFlowed.js
new file mode 100644
index 0000000000..8f4f286739
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_replyFormatFlowed.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/. */
+
+/**
+ * Tests that the reply to a format=flowed message is also flowed.
+ */
+
+"use strict";
+
+var {
+ close_compose_window,
+ get_msg_source,
+ open_compose_with_reply,
+ save_compose_message,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ get_special_folder,
+ get_about_message,
+ open_message_from_file,
+ press_delete,
+ select_click_row,
+ select_none,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gDrafts;
+
+add_setup(async function () {
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+
+ Services.prefs.setBoolPref("mail.identity.id1.compose_html", false);
+});
+
+async function subtest_reply_format_flowed(aFlowed) {
+ let file = new FileUtils.File(getTestFilePath("data/format-flowed.eml"));
+ let msgc = await open_message_from_file(file);
+
+ Services.prefs.setBoolPref("mailnews.send_plaintext_flowed", aFlowed);
+
+ let cwc = open_compose_with_reply(msgc);
+
+ close_window(msgc);
+
+ // Now save the message as a draft.
+ await save_compose_message(cwc.window);
+ close_compose_window(cwc);
+
+ await TestUtils.waitForCondition(
+ () => gDrafts.getTotalMessages(false) == 1,
+ "message saved to drafts folder"
+ );
+
+ // Now check the message content in the drafts folder.
+ await be_in_folder(gDrafts);
+ let message = select_click_row(0);
+ let messageContent = await get_msg_source(message);
+
+ // Check for a single line that contains text and make sure there is a
+ // space at the end for a flowed reply.
+ Assert.ok(
+ messageContent.includes(
+ "\r\n> text text text text text text text text text text text text text text" +
+ (aFlowed ? " \r\n" : "\r\n")
+ ),
+ "Expected line not found in message."
+ );
+
+ // Delete the outgoing message.
+ press_delete();
+}
+
+add_task(async function test_reply_format_flowed() {
+ await subtest_reply_format_flowed(true);
+ await subtest_reply_format_flowed(false);
+});
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("mail.identity.id1.compose_html");
+ Services.prefs.clearUserPref("mailnews.send_plaintext_flowed");
+});
diff --git a/comm/mail/test/browser/composition/browser_replyMultipartCharset.js b/comm/mail/test/browser/composition/browser_replyMultipartCharset.js
new file mode 100644
index 0000000000..e3e9982045
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_replyMultipartCharset.js
@@ -0,0 +1,149 @@
+/* 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 has become a "mixed bag" of tests for various bugs.
+ *
+ * Bug 1026989:
+ * Tests that the reply to a message picks up the charset from the body
+ * and not from an attachment. Also test "Edit as new", forward inline and
+ * forward as attachment.
+ *
+ * Bug 961983:
+ * Tests that UTF-16 is not used in a composition.
+ *
+ * Bug 1323377:
+ * Tests that the correct charset is used, even if the message
+ * wasn't viewed before answering/forwarding.
+ */
+
+"use strict";
+
+var {
+ close_compose_window,
+ open_compose_with_edit_as_new,
+ open_compose_with_forward,
+ open_compose_with_forward_as_attachments,
+ open_compose_with_reply,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ mc,
+ open_message_from_file,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var folder;
+
+add_setup(async function () {
+ requestLongerTimeout(2);
+ folder = await create_folder("FolderWithMessages");
+});
+
+async function subtest_replyEditAsNewForward_charset(
+ aAction,
+ aFile,
+ aViewed = true
+) {
+ await be_in_folder(folder);
+
+ let file = new FileUtils.File(getTestFilePath(`data/${aFile}`));
+ let msgc = await open_message_from_file(file);
+
+ // Copy the message to a folder. We run the message through a folder
+ // since replying/editing as new/forwarding directly to the message
+ // opened from a file gives different results on different platforms.
+ // All platforms behave the same when using a folder-stored message.
+ let documentChild = msgc.window.content.document.documentElement;
+ EventUtils.synthesizeMouseAtCenter(
+ documentChild,
+ { type: "contextmenu", button: 2 },
+ documentChild.ownerGlobal
+ );
+ let aboutMessage = get_about_message(msgc.window);
+ await click_menus_in_sequence(
+ aboutMessage.document.getElementById("mailContext"),
+ [
+ { id: "mailContext-copyMenu" },
+ { label: "Local Folders" },
+ { label: "FolderWithMessages" },
+ ]
+ );
+ close_window(msgc);
+
+ let msg = select_click_row(0);
+ if (aViewed) {
+ // Only if the preview pane is on, we can check the following.
+ assert_selected_and_displayed(mc, msg);
+ }
+
+ let fwdWin;
+ switch (aAction) {
+ case 1: // Reply.
+ fwdWin = open_compose_with_reply();
+ break;
+ case 2: // Edit as new.
+ fwdWin = open_compose_with_edit_as_new();
+ break;
+ case 3: // Forward inline.
+ fwdWin = open_compose_with_forward();
+ break;
+ case 4: // Forward as attachment.
+ fwdWin = open_compose_with_forward_as_attachments();
+ break;
+ }
+
+ // Check the charset in the compose window.
+ let charset =
+ fwdWin.window.document.getElementById("messageEditor").contentDocument
+ .charset;
+ Assert.equal(charset, "UTF-8", "Compose window has the wrong charset");
+ close_compose_window(fwdWin);
+
+ press_delete(mc);
+}
+
+add_task(async function test_replyEditAsNewForward_charsetFromBody() {
+ // Check that the charset is taken from the message body (bug 1026989).
+ await subtest_replyEditAsNewForward_charset(1, "./multipart-charset.eml");
+ await subtest_replyEditAsNewForward_charset(2, "./multipart-charset.eml");
+ await subtest_replyEditAsNewForward_charset(3, "./multipart-charset.eml");
+ // For "forward as attachment" we use the default charset (which is UTF-8).
+ await subtest_replyEditAsNewForward_charset(4, "./multipart-charset.eml");
+});
+
+add_task(async function test_reply_noUTF16() {
+ // Check that a UTF-16 encoded e-mail is forced to UTF-8 when replying (bug 961983).
+ await subtest_replyEditAsNewForward_charset(1, "./body-utf16.eml", "UTF-8");
+});
+
+add_task(async function test_replyEditAsNewForward_noPreview() {
+ // Check that it works even if the message wasn't viewed before, so
+ // switch off the preview pane (bug 1323377).
+ await be_in_folder(folder);
+ mc.window.goDoCommand("cmd_toggleMessagePane");
+
+ await subtest_replyEditAsNewForward_charset(1, "./format-flowed.eml", false);
+ await subtest_replyEditAsNewForward_charset(2, "./body-greek.eml", false);
+ await subtest_replyEditAsNewForward_charset(
+ 3,
+ "./multipart-charset.eml",
+ false
+ );
+
+ mc.window.goDoCommand("cmd_toggleMessagePane");
+});
+
+registerCleanupFunction(() => {
+ folder.deleteSelf(null);
+});
diff --git a/comm/mail/test/browser/composition/browser_replySelection.js b/comm/mail/test/browser/composition/browser_replySelection.js
new file mode 100644
index 0000000000..0abdd55194
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_replySelection.js
@@ -0,0 +1,64 @@
+/* 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/. */
+
+/**
+ * Tests that reply with selection works properly.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_reply } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+add_task(async function test_reply_w_selection_direct() {
+ let file = new FileUtils.File(getTestFilePath("data/non-flowed-plain.eml"));
+ let msgc = await open_message_from_file(file);
+
+ let aboutMessage = get_about_message(msgc);
+ let win = aboutMessage.document.getElementById("messagepane").contentWindow;
+ let doc = aboutMessage.document.getElementById("messagepane").contentDocument;
+ let selection = win.getSelection();
+
+ let text = doc.querySelector("body > div.moz-text-plain > pre.moz-quote-pre");
+
+ // Lines 2-3 of the text.
+ let range1 = doc.createRange();
+ range1.setStart(text.firstChild, 6);
+ range1.setEnd(text.firstChild, 20);
+
+ // The <pre> node itself.
+ let range2 = doc.createRange();
+ range2.setStart(text, 0);
+ range2.setEnd(text, 1);
+
+ for (let range of [range1, range2]) {
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ let cwc = await open_compose_with_reply(msgc);
+ let blockquote = cwc.document
+ .getElementById("messageEditor")
+ .contentDocument.body.querySelector("blockquote");
+
+ Assert.ok(
+ blockquote.querySelector(":scope > pre"),
+ "the non-flowed content should be in a <pre>"
+ );
+
+ Assert.ok(
+ !blockquote.querySelector(":scope > pre").innerHTML.includes("<"),
+ "should be all text, no tags in the message text"
+ );
+ await close_compose_window(cwc);
+ }
+
+ await BrowserTestUtils.closeWindow(msgc);
+});
diff --git a/comm/mail/test/browser/composition/browser_replySignature.js b/comm/mail/test/browser/composition/browser_replySignature.js
new file mode 100644
index 0000000000..6053171468
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_replySignature.js
@@ -0,0 +1,117 @@
+/* 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/. */
+
+/**
+ * Tests the mail.strip_sig_on_reply pref.
+ */
+
+"use strict";
+
+var { close_compose_window, get_compose_body, open_compose_with_reply } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ create_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var sig = "roses are red";
+var folder;
+
+add_setup(async function () {
+ folder = await create_folder("SigStripTest");
+ registerCleanupFunction(() => folder.deleteSelf(null));
+
+ let msg = create_message({
+ subject: "msg with signature; format=flowed",
+ body: {
+ body:
+ "get with the flow! get with the flow! get with the flow! " +
+ "get with the \n flow! get with the flow!\n-- \n" +
+ sig +
+ "\n",
+ contentType: "text/plain",
+ charset: "UTF-8",
+ format: "flowed",
+ },
+ });
+ await add_message_to_folder([folder], msg);
+ let msg2 = create_message({
+ subject: "msg with signature; format not flowed",
+ body: {
+ body:
+ "not flowed! not flowed! not flowed! \n" +
+ "not flowed!\n-- \n" +
+ sig +
+ "\n",
+ contentType: "text/plain",
+ charset: "UTF-8",
+ format: "",
+ },
+ });
+ await add_message_to_folder([folder], msg2);
+});
+
+/** Test sig strip true for format flowed. */
+add_task(async function test_sig_strip_true_ff() {
+ Services.prefs.setBoolPref("mail.strip_sig_on_reply", true);
+ await check_sig_strip_works(0, true);
+ Services.prefs.clearUserPref("mail.strip_sig_on_reply");
+});
+
+/** Test sig strip false for format flowed. */
+add_task(async function test_sig_strip_false_ff() {
+ Services.prefs.setBoolPref("mail.strip_sig_on_reply", false);
+ await check_sig_strip_works(0, false);
+ Services.prefs.clearUserPref("mail.strip_sig_on_reply");
+});
+
+/** Test sig strip true for non-format flowed. */
+add_task(async function test_sig_strip_true_nonff() {
+ Services.prefs.setBoolPref("mail.strip_sig_on_reply", true);
+ await check_sig_strip_works(1, true);
+ Services.prefs.clearUserPref("mail.strip_sig_on_reply");
+});
+
+/** Test sig strip false for non-format flowed. */
+add_task(async function test_sig_strip_false_nonff() {
+ Services.prefs.setBoolPref("mail.strip_sig_on_reply", false);
+ await check_sig_strip_works(1, false);
+ Services.prefs.clearUserPref("mail.strip_sig_on_reply");
+});
+
+/**
+ * Helper function to check signature stripping works as it should.
+ *
+ * @param aRow the row index of the message to test
+ * @param aShouldStrip true if the signature should be stripped
+ */
+async function check_sig_strip_works(aRow, aShouldStrip) {
+ await be_in_folder(folder);
+ let msg = select_click_row(aRow);
+ assert_selected_and_displayed(mc, msg);
+
+ let rwc = open_compose_with_reply();
+ let body = get_compose_body(rwc);
+
+ if (aShouldStrip && body.textContent.includes(sig)) {
+ throw new Error("signature was not stripped; body=" + body.textContent);
+ } else if (!aShouldStrip && !body.textContent.includes(sig)) {
+ throw new Error("signature stripped; body=" + body.textContent);
+ }
+ close_compose_window(rwc);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+}
diff --git a/comm/mail/test/browser/composition/browser_saveChangesOnQuit.js b/comm/mail/test/browser/composition/browser_saveChangesOnQuit.js
new file mode 100644
index 0000000000..6b9bc8c177
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_saveChangesOnQuit.js
@@ -0,0 +1,420 @@
+/* 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/. */
+
+/**
+ * Tests that we prompt the user if they'd like to save their message when they
+ * try to quit/close with an open compose window with unsaved changes, and
+ * that we don't prompt if there are no changes.
+ */
+
+"use strict";
+
+var {
+ close_compose_window,
+ open_compose_new_mail,
+ open_compose_with_forward,
+ open_compose_with_reply,
+ wait_for_compose_window,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_message,
+ get_special_folder,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { gMockPromptService } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PromptHelpers.jsm"
+);
+var { wait_for_notification_to_show, get_notification } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var { plan_for_new_window, wait_for_window_focused } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var SAVE = 0;
+var CANCEL = 1;
+var DONT_SAVE = 2;
+
+var cwc = null; // compose window controller
+var folder = null;
+var gDraftFolder = null;
+
+add_setup(async function () {
+ requestLongerTimeout(3);
+
+ folder = await create_folder("PromptToSaveTest");
+
+ await add_message_to_folder([folder], create_message()); // row 0
+ let localFolder = folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ localFolder.addMessage(msgSource("content type: text", "text")); // row 1
+ localFolder.addMessage(msgSource("content type missing", null)); // row 2
+ gDraftFolder = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+function msgSource(aSubject, aContentType) {
+ let msgId = Services.uuid.generateUUID() + "@invalid";
+
+ return (
+ "From - Sun Apr 07 22:47:11 2013\r\n" +
+ "X-Mozilla-Status: 0001\r\n" +
+ "X-Mozilla-Status2: 00000000\r\n" +
+ "Message-ID: <" +
+ msgId +
+ ">\r\n" +
+ "Date: Sun, 07 Apr 2013 22:47:11 +0300\r\n" +
+ "From: Someone <some.one@invalid>\r\n" +
+ "To: someone.else@invalid\r\n" +
+ "Subject: " +
+ aSubject +
+ "\r\n" +
+ "MIME-Version: 1.0\r\n" +
+ (aContentType ? "Content-Type: " + aContentType + "\r\n" : "") +
+ "Content-Transfer-Encoding: 7bit\r\n\r\n" +
+ "A msg with contentType " +
+ aContentType +
+ "\r\n"
+ );
+}
+
+/**
+ * Test that when a compose window is open with changes, and
+ * a Quit is requested (for example, from File > Quit from the
+ * 3pane), that the user gets a confirmation dialog to discard
+ * the changes. This also tests that the user can cancel the
+ * quit request.
+ */
+add_task(function test_can_cancel_quit_on_changes() {
+ // Register the Mock Prompt Service
+ gMockPromptService.register();
+
+ // opening a new compose window
+ cwc = open_compose_new_mail(mc);
+
+ // Make some changes
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Hey check out this megalol link", cwc.window);
+
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+
+ // Set the Mock Prompt Service to return false, so that we
+ // cancel the quit.
+ gMockPromptService.returnValue = CANCEL;
+ // Trigger the quit-application-request notification
+
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+
+ let promptState = gMockPromptService.promptState;
+ Assert.notEqual(null, promptState, "Expected a confirmEx prompt");
+
+ Assert.equal("confirmEx", promptState.method);
+ // Since we returned false on the confirmation dialog,
+ // we should be cancelling the quit - so cancelQuit.data
+ // should now be true
+ Assert.ok(cancelQuit.data, "Didn't cancel the quit");
+
+ close_compose_window(cwc);
+
+ // Unregister the Mock Prompt Service
+ gMockPromptService.unregister();
+});
+
+/**
+ * Test that when a compose window is open with changes, and
+ * a Quit is requested (for example, from File > Quit from the
+ * 3pane), that the user gets a confirmation dialog to discard
+ * the changes. This also tests that the user can let the quit
+ * occur.
+ */
+add_task(function test_can_quit_on_changes() {
+ // Register the Mock Prompt Service
+ gMockPromptService.register();
+
+ // opening a new compose window
+ cwc = open_compose_new_mail(mc);
+
+ // Make some changes
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Hey check out this megalol link", cwc.window);
+
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+
+ // Set the Mock Prompt Service to return true, so that we're
+ // allowing the quit to occur.
+ gMockPromptService.returnValue = DONT_SAVE;
+
+ // Trigger the quit-application-request notification
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+
+ let promptState = gMockPromptService.promptState;
+ Assert.notEqual(null, promptState, "Expected a confirmEx prompt");
+
+ Assert.equal("confirmEx", promptState.method);
+ // Since we returned true on the confirmation dialog,
+ // we should be quitting - so cancelQuit.data should now be
+ // false
+ Assert.ok(!cancelQuit.data, "The quit request was cancelled");
+
+ close_compose_window(cwc);
+
+ // Unregister the Mock Prompt Service
+ gMockPromptService.unregister();
+});
+
+/**
+ * Bug 698077 - test that when quitting with two compose windows open, if
+ * one chooses "Don't Save", and the other chooses "Cancel", that the first
+ * window's state is such that subsequent quit requests still cause the
+ * Don't Save / Cancel / Save dialog to come up.
+ */
+add_task(async function test_window_quit_state_reset_on_aborted_quit() {
+ // Register the Mock Prompt Service
+ gMockPromptService.register();
+
+ // open two new compose windows
+ let cwc1 = open_compose_new_mail(mc);
+ let cwc2 = open_compose_new_mail(mc);
+
+ // Type something in each window.
+ cwc1.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Marco!", cwc1.window);
+
+ cwc2.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Polo!", cwc2.window);
+
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+
+ // This is a hacky method for making sure that the second window
+ // receives a CANCEL click in the popup dialog.
+ var numOfPrompts = 0;
+ gMockPromptService.onPromptCallback = function () {
+ numOfPrompts++;
+
+ if (numOfPrompts > 1) {
+ gMockPromptService.returnValue = CANCEL;
+ }
+ };
+
+ gMockPromptService.returnValue = DONT_SAVE;
+
+ // Trigger the quit-application-request notification
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+
+ // We should have cancelled the quit appropriately.
+ Assert.ok(cancelQuit.data);
+
+ // The quit behaviour is that the second window to spawn is the first
+ // one that prompts for Save / Don't Save, etc.
+ gMockPromptService.reset();
+
+ // The first window should still prompt when attempting to close the
+ // window.
+ gMockPromptService.returnValue = DONT_SAVE;
+
+ // Unclear why the timeout is needed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ cwc2.window.goDoCommand("cmd_close");
+
+ TestUtils.waitForCondition(
+ () => !!gMockPromptService.promptState,
+ "Expected a confirmEx prompt to come up"
+ );
+
+ close_compose_window(cwc1);
+
+ gMockPromptService.unregister();
+});
+
+/**
+ * Tests that we don't get a prompt to save if there has been no user input
+ * into the message yet, when trying to close.
+ */
+add_task(async function test_no_prompt_on_close_for_unmodified() {
+ await be_in_folder(folder);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ let nwc = open_compose_new_mail();
+ close_compose_window(nwc, false);
+
+ let rwc = open_compose_with_reply();
+ close_compose_window(rwc, false);
+
+ let fwc = open_compose_with_forward();
+ close_compose_window(fwc, false);
+});
+
+/**
+ * Tests that we get a prompt to save if the user made changes to the message
+ * before trying to close it.
+ */
+add_task(async function test_prompt_on_close_for_modified() {
+ await be_in_folder(folder);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ let nwc = open_compose_new_mail();
+ nwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Hey hey hey!", nwc.window);
+ close_compose_window(nwc, true);
+
+ let rwc = open_compose_with_reply();
+ rwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Howdy!", rwc.window);
+ close_compose_window(rwc, true);
+
+ let fwc = open_compose_with_forward();
+ fwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("Greetings!", fwc.window);
+ close_compose_window(fwc, true);
+});
+
+/**
+ * Test there's no prompt on close when no changes was made in reply/forward
+ * windows - for the case the original msg had content type "text".
+ */
+add_task(
+ async function test_no_prompt_on_close_for_unmodified_content_type_text() {
+ await be_in_folder(folder);
+ let msg = select_click_row(1); // row 1 is the one with content type text
+ assert_selected_and_displayed(mc, msg);
+
+ let rwc = open_compose_with_reply();
+ close_compose_window(rwc, false);
+
+ let fwc = open_compose_with_forward();
+ Assert.equal(
+ fwc.window.document.getElementById("attachmentBucket").getRowCount(),
+ 0,
+ "forwarding msg created attachment"
+ );
+ close_compose_window(fwc, false);
+ }
+);
+
+/**
+ * Test there's no prompt on close when no changes was made in reply/forward
+ * windows - for the case the original msg had no content type.
+ */
+add_task(
+ async function test_no_prompt_on_close_for_unmodified_no_content_type() {
+ await be_in_folder(folder);
+ let msg = select_click_row(2); // row 2 is the one with no content type
+ assert_selected_and_displayed(mc, msg);
+
+ let rwc = open_compose_with_reply();
+ close_compose_window(rwc, false);
+
+ let fwc = open_compose_with_forward();
+ Assert.equal(
+ fwc.window.document.getElementById("attachmentBucket").getRowCount(),
+ 0,
+ "forwarding msg created attachment"
+ );
+ close_compose_window(fwc, false);
+ }
+);
+
+add_task(async function test_prompt_save_on_pill_editing() {
+ cwc = open_compose_new_mail(mc);
+
+ // Focus should be on the To field, so just type an address.
+ EventUtils.sendString("test@foo.invalid", cwc.window);
+ let pillCreated = TestUtils.waitForCondition(
+ () => cwc.window.document.querySelectorAll("mail-address-pill").length == 1,
+ "One pill was created"
+ );
+ // Trigger the saving of the draft.
+ EventUtils.synthesizeKey("s", { accelKey: true }, cwc.window);
+ await pillCreated;
+ Assert.ok(cwc.window.gSaveOperationInProgress, "Should start save operation");
+ await TestUtils.waitForCondition(
+ () => !cwc.window.gSaveOperationInProgress && !cwc.window.gWindowLock,
+ "Waiting for the save operation to complete"
+ );
+
+ // All leftover text should have been cleared and pill should have been
+ // created before the draft is actually saved.
+ Assert.equal(
+ cwc.window.document.activeElement.id,
+ "toAddrInput",
+ "The input field is focused."
+ );
+ Assert.equal(
+ cwc.window.document.activeElement.value,
+ "",
+ "The input field is empty."
+ );
+
+ // Close the compose window after the saving operation is completed.
+ close_compose_window(cwc, false);
+
+ // Move to the drafts folder and select the recently saved message.
+ await be_in_folder(gDraftFolder);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ // Click on the "edit draft" notification.
+ let aboutMessage = get_about_message();
+ let kBoxId = "mail-notification-top";
+ wait_for_notification_to_show(aboutMessage, kBoxId, "draftMsgContent");
+ let box = get_notification(aboutMessage, kBoxId, "draftMsgContent");
+
+ plan_for_new_window("msgcompose");
+ // Click on the "Edit" button in the draft notification.
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ aboutMessage
+ );
+ cwc = wait_for_compose_window();
+
+ // Make sure the address was saved correctly.
+ let pill = cwc.window.document.querySelector("mail-address-pill");
+ Assert.equal(
+ pill.fullAddress,
+ "test@foo.invalid",
+ "the email address matches"
+ );
+ let isEditing = TestUtils.waitForCondition(
+ () => pill.isEditing,
+ "Pill is being edited"
+ );
+
+ let focusPromise = TestUtils.waitForCondition(
+ () => cwc.window.document.activeElement == pill,
+ "Pill is focused"
+ );
+ // The focus should be on the subject since we didn't write anything,
+ // so shift+tab to move the focus on the To field, and pressing Arrow Left
+ // should correctly focus the previously generated pill.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, cwc.window);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, cwc.window);
+ await focusPromise;
+ EventUtils.synthesizeKey("VK_RETURN", {}, cwc.window);
+ await isEditing;
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ // Try to quit after entering the pill edit mode, a "unsaved changes" dialog
+ // should be triggered.
+ cwc.window.goDoCommand("cmd_close");
+ await promptPromise;
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_sendButton.js b/comm/mail/test/browser/composition/browser_sendButton.js
new file mode 100644
index 0000000000..304f1cb780
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_sendButton.js
@@ -0,0 +1,363 @@
+/* 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/. */
+
+/**
+ * Tests proper enabling of send buttons depending on addresses input.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { create_contact, create_mailing_list, load_contacts_into_address_book } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/AddressBookHelpers.jsm"
+ );
+var {
+ be_in_folder,
+ click_tree_row,
+ FAKE_SERVER_HOSTNAME,
+ get_special_folder,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ clear_recipients,
+ get_first_pill,
+ close_compose_window,
+ open_compose_new_mail,
+ setup_msg_contents,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { plan_for_modal_dialog, wait_for_frame_load, wait_for_modal_dialog } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var account = null;
+
+add_setup(async function () {
+ // Ensure we're in the tinderbox account as that has the right identities set
+ // up for this test.
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ account = MailServices.accounts.FindAccountForServer(server);
+ let inbox = await get_special_folder(
+ Ci.nsMsgFolderFlags.Inbox,
+ false,
+ server
+ );
+ await be_in_folder(inbox);
+});
+
+/**
+ * Check if the send commands are in the wished state.
+ *
+ * @param aCwc The compose window controller.
+ * @param aEnabled The expected state of the commands.
+ */
+function check_send_commands_state(aCwc, aEnabled) {
+ Assert.equal(
+ aCwc.window.document
+ .getElementById("cmd_sendButton")
+ .hasAttribute("disabled"),
+ !aEnabled
+ );
+ Assert.equal(
+ aCwc.window.document.getElementById("cmd_sendNow").hasAttribute("disabled"),
+ !aEnabled
+ );
+ Assert.equal(
+ aCwc.window.document
+ .getElementById("cmd_sendWithCheck")
+ .hasAttribute("disabled"),
+ !aEnabled
+ );
+ Assert.equal(
+ aCwc.window.document
+ .getElementById("cmd_sendLater")
+ .hasAttribute("disabled"),
+ !aEnabled
+ );
+
+ // The toolbar buttons and menuitems should be linked to these commands
+ // thus inheriting the enabled state. Check that on the Send button
+ // and Send Now menuitem.
+ Assert.equal(
+ aCwc.window.document.getElementById("button-send").getAttribute("command"),
+ "cmd_sendButton"
+ );
+ Assert.equal(
+ aCwc.window.document
+ .getElementById("menu-item-send-now")
+ .getAttribute("command"),
+ "cmd_sendNow"
+ );
+}
+
+/**
+ * Bug 431217
+ * Test that the Send buttons are properly enabled if an addressee is input
+ * by the user.
+ */
+add_task(async function test_send_enabled_manual_address() {
+ let cwc = open_compose_new_mail(); // compose controller
+ let menu = cwc.window.document.getElementById("extraAddressRowsMenu"); // extra recipients menu
+ let menuButton = cwc.window.document.getElementById(
+ "extraAddressRowsMenuButton"
+ );
+
+ // On an empty window, Send must be disabled.
+ check_send_commands_state(cwc, false);
+
+ // On valid "To:" addressee input, Send must be enabled.
+ setup_msg_contents(cwc, " recipient@fake.invalid ", "", "");
+ check_send_commands_state(cwc, true);
+
+ // When the addressee is not in To, Cc, Bcc or Newsgroup, disable Send again.
+ clear_recipients(cwc);
+ EventUtils.synthesizeMouseAtCenter(menuButton, {}, menuButton.ownerGlobal);
+ await new Promise(resolve => setTimeout(resolve));
+ await wait_for_popup_to_open(menu);
+ menu.activateItem(
+ cwc.window.document.getElementById("addr_replyShowAddressRowMenuItem")
+ );
+ setup_msg_contents(cwc, " recipient@fake.invalid ", "", "", "replyAddrInput");
+ check_send_commands_state(cwc, false);
+
+ clear_recipients(cwc);
+ check_send_commands_state(cwc, false);
+
+ // Bug 1296535
+ // Try some other invalid and valid recipient strings:
+ // - random string that is no email.
+ setup_msg_contents(cwc, " recipient@", "", "");
+ check_send_commands_state(cwc, false);
+
+ let ccShow = cwc.window.document.getElementById(
+ "addr_ccShowAddressRowButton"
+ );
+ EventUtils.synthesizeMouseAtCenter(ccShow, {}, ccShow.ownerGlobal);
+ await new Promise(resolve => setTimeout(resolve));
+ check_send_commands_state(cwc, false);
+
+ // Select the newly generated pill.
+ EventUtils.synthesizeMouseAtCenter(
+ get_first_pill(cwc),
+ {},
+ get_first_pill(cwc).ownerGlobal
+ );
+ await new Promise(resolve => setTimeout(resolve));
+ // Delete the selected pill.
+ EventUtils.synthesizeKey("VK_DELETE", {}, cwc.window);
+ // Confirm the address row is now empty.
+ Assert.ok(!get_first_pill(cwc));
+ // Confirm the send button is disabled.
+ check_send_commands_state(cwc, false);
+ // Add multiple recipients.
+ setup_msg_contents(
+ cwc,
+ "recipient@domain.invalid, info@somedomain.extension, name@incomplete",
+ "",
+ ""
+ );
+ check_send_commands_state(cwc, true);
+
+ clear_recipients(cwc);
+ check_send_commands_state(cwc, false);
+
+ // - a mailinglist in addressbook
+ // Button is enabled without checking whether it contains valid addresses.
+ let defaultAB = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ let ml = create_mailing_list("emptyList");
+ defaultAB.addMailList(ml);
+
+ setup_msg_contents(cwc, " emptyList", "", "");
+ check_send_commands_state(cwc, true);
+
+ clear_recipients(cwc);
+ check_send_commands_state(cwc, false);
+
+ setup_msg_contents(cwc, "emptyList <list> ", "", "");
+ check_send_commands_state(cwc, true);
+
+ clear_recipients(cwc);
+ check_send_commands_state(cwc, false);
+
+ // Hack to reveal the newsgroup button.
+ let newsgroupsButton = cwc.window.document.getElementById(
+ "addr_newsgroupsShowAddressRowButton"
+ );
+ newsgroupsButton.hidden = false;
+ EventUtils.synthesizeMouseAtCenter(
+ newsgroupsButton,
+ {},
+ newsgroupsButton.ownerGlobal
+ );
+ await new Promise(resolve => setTimeout(resolve));
+
+ // - some string as a newsgroup
+ setup_msg_contents(cwc, "newsgroup ", "", "", "newsgroupsAddrInput");
+ check_send_commands_state(cwc, true);
+
+ close_compose_window(cwc);
+});
+
+/**
+ * Bug 431217
+ * Test that the Send buttons are properly enabled if an addressee is prefilled
+ * automatically via account prefs.
+ */
+add_task(function test_send_enabled_prefilled_address() {
+ // Set the prefs to prefill a default CC address when Compose is opened.
+ let identity = account.defaultIdentity;
+ identity.doCc = true;
+ identity.doCcList = "Auto@recipient.invalid";
+
+ // In that case the recipient is input, enabled Send.
+ let cwc = open_compose_new_mail(); // compose controller
+ check_send_commands_state(cwc, true);
+
+ // Clear the CC list.
+ clear_recipients(cwc);
+ // No other pill is there. Send should become disabled.
+ check_send_commands_state(cwc, false);
+
+ close_compose_window(cwc);
+ identity.doCcList = "";
+ identity.doCc = false;
+});
+
+/**
+ * Bug 933101
+ * Similar to test_send_enabled_prefilled_address but switched between an identity
+ * that has a CC list and one that doesn't directly in the compose window.
+ */
+add_task(async function test_send_enabled_prefilled_address_from_identity() {
+ // The first identity will have an automatic CC enabled.
+ let identityWithCC = account.defaultIdentity;
+ identityWithCC.doCc = true;
+ identityWithCC.doCcList = "Auto@recipient.invalid";
+
+ // CC is prefilled, Send enabled.
+ let cwc = open_compose_new_mail();
+ check_send_commands_state(cwc, true);
+
+ let identityPicker = cwc.window.document.getElementById("msgIdentity");
+ Assert.equal(identityPicker.selectedIndex, 0);
+
+ // Switch to the second identity that has no CC. Send should be disabled.
+ Assert.ok(account.identities.length >= 2);
+ let identityWithoutCC = account.identities[1];
+ Assert.ok(!identityWithoutCC.doCc);
+ await chooseIdentity(cwc.window, identityWithoutCC.key);
+ check_send_commands_state(cwc, false);
+
+ // Check the first identity again.
+ await chooseIdentity(cwc.window, identityWithCC.key);
+ check_send_commands_state(cwc, true);
+
+ close_compose_window(cwc);
+ identityWithCC.doCcList = "";
+ identityWithCC.doCc = false;
+});
+
+/**
+ * Bug 863231
+ * Test that the Send buttons are properly enabled if an addressee is populated
+ * via the Contacts sidebar.
+ */
+add_task(function test_send_enabled_address_contacts_sidebar() {
+ // Create some contact address book card in the Personal addressbook.
+ let defaultAB = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ let contact = create_contact("test@example.com", "Sammy Jenkis", true);
+ load_contacts_into_address_book(defaultAB, [contact]);
+
+ let cwc = open_compose_new_mail(); // compose controller
+ // On an empty window, Send must be disabled.
+ check_send_commands_state(cwc, false);
+
+ // Open Contacts sidebar and use our contact.
+ // FIXME: Use UI to open contacts sidebar.
+ cwc.window.toggleContactsSidebar();
+
+ let contactsBrowser = cwc.window.document.getElementById("contactsBrowser");
+ wait_for_frame_load(
+ contactsBrowser,
+ "chrome://messenger/content/addressbook/abContactsPanel.xhtml?focus"
+ );
+
+ let abTree = contactsBrowser.contentDocument.getElementById("abResultsTree");
+ // The results are loaded async so wait for the population of the tree.
+ utils.waitFor(
+ () => abTree.view.rowCount > 0,
+ "Addressbook cards didn't load"
+ );
+ click_tree_row(abTree, 0, cwc);
+
+ contactsBrowser.contentDocument.getElementById("ccButton").click();
+
+ // The recipient is filled in, Send must be enabled.
+ check_send_commands_state(cwc, true);
+
+ // FIXME: Use UI to close contacts sidebar.
+ cwc.window.toggleContactsSidebar();
+ close_compose_window(cwc);
+});
+
+/**
+ * Tests that when editing a pill and clicking send while the edit is active
+ * the pill gets updated before the send of the email.
+ */
+add_task(async function test_update_pill_before_send() {
+ let cwc = open_compose_new_mail();
+
+ setup_msg_contents(cwc, "recipient@fake.invalid", "Subject", "");
+
+ let pill = get_first_pill(cwc);
+
+ // Edit the first pill.
+ // First, we need to get into the edit mode by clicking the pill twice.
+ EventUtils.synthesizeMouseAtCenter(pill, { clickCount: 1 }, cwc.window);
+ let clickPromise = BrowserTestUtils.waitForEvent(pill, "click");
+ // We do not want a double click, but two separate clicks.
+ EventUtils.synthesizeMouseAtCenter(pill, { clickCount: 1 }, cwc.window);
+ await clickPromise;
+
+ Assert.ok(!pill.querySelector("input").hidden);
+
+ // Set the pill which is in edit mode to an invalid email.
+ EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, cwc.window);
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, cwc.window);
+ EventUtils.sendString("invalidEmail", cwc.window);
+
+ // Click send while the pill is in the edit mode and check the dialog title
+ // if the pill is updated we get an invalid recipient error. Otherwise the
+ // error would be an imap error because the email would still be sent to
+ // `recipient@fake.invalid`.
+ let dialogTitle;
+ plan_for_modal_dialog("commonDialogWindow", cwc => {
+ dialogTitle = cwc.window.document.getElementById("infoTitle").textContent;
+ cwc.window.document.querySelector("dialog").getButton("accept").click();
+ });
+ // Click the send button.
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("button-send"),
+ {},
+ cwc.window
+ );
+ wait_for_modal_dialog("commonDialogWindow");
+
+ Assert.ok(
+ dialogTitle.includes("Invalid Recipient Address"),
+ "The pill edit has been updated before sending the email"
+ );
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_sendFormat.js b/comm/mail/test/browser/composition/browser_sendFormat.js
new file mode 100644
index 0000000000..8877b4d8a5
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_sendFormat.js
@@ -0,0 +1,565 @@
+/* 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/. */
+
+/**
+ * Tests resulting send format of a message dependent on using HTML features
+ * in the composition.
+ */
+
+"use strict";
+
+requestLongerTimeout(4);
+
+var {
+ open_compose_from_draft,
+ open_compose_new_mail,
+ open_compose_with_reply,
+ FormatHelper,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+var {
+ be_in_folder,
+ empty_folder,
+ get_special_folder,
+ get_about_message,
+ open_message_from_file,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var sendFormatPreference;
+var htmlAsPreference;
+var draftsFolder;
+var outboxFolder;
+
+add_setup(async () => {
+ sendFormatPreference = Services.prefs.getIntPref("mail.default_send_format");
+ htmlAsPreference = Services.prefs.getIntPref("mailnews.display.html_as");
+ // Show all parts to a message in the message display.
+ // This allows us to see if a message contains both a plain text and a HTML
+ // part.
+ Services.prefs.setIntPref("mailnews.display.html_as", 4);
+ draftsFolder = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+ outboxFolder = await get_special_folder(Ci.nsMsgFolderFlags.Queue, true);
+});
+
+registerCleanupFunction(async function () {
+ Services.prefs.setIntPref("mail.default_send_format", sendFormatPreference);
+ Services.prefs.setIntPref("mailnews.display.html_as", htmlAsPreference);
+ await empty_folder(draftsFolder);
+ await empty_folder(outboxFolder);
+});
+
+async function checkMsgFile(aFilePath, aConvertibility) {
+ let file = new FileUtils.File(getTestFilePath(`data/${aFilePath}`));
+ let messageController = await open_message_from_file(file);
+
+ // Creating a reply should not affect convertibility.
+ let composeWindow = open_compose_with_reply(messageController).window;
+
+ Assert.equal(composeWindow.gMsgCompose.bodyConvertible(), aConvertibility);
+
+ await BrowserTestUtils.closeWindow(composeWindow);
+ await BrowserTestUtils.closeWindow(messageController.window);
+}
+
+/**
+ * Tests nodeTreeConvertible() can be called from JavaScript.
+ */
+add_task(async function test_msg_nodeTreeConvertible() {
+ let msgCompose = Cc["@mozilla.org/messengercompose/compose;1"].createInstance(
+ Ci.nsIMsgCompose
+ );
+
+ let textDoc = new DOMParser().parseFromString(
+ "<p>Simple Text</p>",
+ "text/html"
+ );
+ Assert.equal(
+ msgCompose.nodeTreeConvertible(textDoc.documentElement),
+ Ci.nsIMsgCompConvertible.Plain
+ );
+
+ let htmlDoc = new DOMParser().parseFromString(
+ '<p>Complex <span style="font-weight: bold">Text</span></p>',
+ "text/html"
+ );
+ Assert.equal(
+ msgCompose.nodeTreeConvertible(htmlDoc.documentElement),
+ Ci.nsIMsgCompConvertible.No
+ );
+});
+
+/**
+ * Tests that we only open one compose window for one instance of a draft.
+ */
+add_task(async function test_msg_convertibility() {
+ await checkMsgFile("./format1-plain.eml", Ci.nsIMsgCompConvertible.Plain);
+
+ // Bug 1385636
+ await checkMsgFile(
+ "./format1-altering.eml",
+ Ci.nsIMsgCompConvertible.Altering
+ );
+
+ // Bug 584313
+ await checkMsgFile("./format2-style-attr.eml", Ci.nsIMsgCompConvertible.No);
+ await checkMsgFile("./format3-style-tag.eml", Ci.nsIMsgCompConvertible.No);
+});
+
+/**
+ * Map from a nsIMsgCompSendFormat to the id of the corresponding menuitem in
+ * the Options, Send Format menu.
+ *
+ * @type {Map<nsIMsgCompSendFormat, string>}
+ */
+var sendFormatToMenuitem = new Map([
+ [Ci.nsIMsgCompSendFormat.PlainText, "format_plain"],
+ [Ci.nsIMsgCompSendFormat.HTML, "format_html"],
+ [Ci.nsIMsgCompSendFormat.Both, "format_both"],
+ [Ci.nsIMsgCompSendFormat.Auto, "format_auto"],
+]);
+
+/**
+ * Verify that the correct send format menu item is checked.
+ *
+ * @param {Window} composeWindow - The compose window.
+ * @param {nsIMsgCompSendFormat} expectFormat - The expected checked format
+ * option. Either Auto, PlainText, HTML, or Both.
+ * @param {string} msg - A message to use in assertions.
+ */
+function assertSendFormatInMenu(composeWindow, expectFormat, msg) {
+ for (let [format, menuitemId] of sendFormatToMenuitem.entries()) {
+ let menuitem = composeWindow.document.getElementById(menuitemId);
+ let checked = expectFormat == format;
+ Assert.equal(
+ menuitem.getAttribute("checked") == "true",
+ checked,
+ `${menuitemId} should ${checked ? "not " : ""}be checked: ${msg}`
+ );
+ }
+}
+
+const PLAIN_MESSAGE_BODY = "Plain message body";
+const BOLD_MESSAGE_BODY = "Bold message body";
+const BOLD_MESSAGE_BODY_AS_PLAIN = `*${BOLD_MESSAGE_BODY}*`;
+
+/**
+ * Set the default send format and create a new message in the compose window.
+ *
+ * @param {nsIMsgCompSendFormat} preference - The default send format to set via
+ * a preference before opening the window.
+ * @param {boolean} useBold - Whether to use bold text in the message's body.
+ *
+ * @returns {Window} - The opened compose window, pre-filled with a message.
+ */
+async function newMessage(preference, useBold) {
+ Services.prefs.setIntPref("mail.default_send_format", preference);
+
+ let composeWindow = open_compose_new_mail().window;
+ assertSendFormatInMenu(
+ composeWindow,
+ preference,
+ "Send format should initially match preference"
+ );
+
+ // Focus should be on "To" field.
+ EventUtils.sendString("recipient@server.net", composeWindow);
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeKey("KEY_Enter", {}, composeWindow);
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeKey("KEY_Enter", {}, composeWindow);
+ await TestUtils.waitForTick();
+ // Focus should be in the "Subject" field.
+ EventUtils.sendString(
+ `${useBold ? "rich" : "plain"} message with preference ${preference}`,
+ composeWindow
+ );
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeKey("KEY_Enter", {}, composeWindow);
+ await TestUtils.waitForTick();
+
+ // Focus should be in the body.
+ let formatHelper = new FormatHelper(composeWindow);
+ if (useBold) {
+ EventUtils.synthesizeMouseAtCenter(
+ formatHelper.boldButton,
+ {},
+ composeWindow
+ );
+ await TestUtils.waitForTick();
+ await formatHelper.typeInMessage(BOLD_MESSAGE_BODY);
+ } else {
+ await formatHelper.typeInMessage(PLAIN_MESSAGE_BODY);
+ }
+
+ return composeWindow;
+}
+
+/**
+ * Set the send format to something else via the application menu.
+ *
+ * @param {Window} composeWindow - The compose window to set the format in.
+ * @param {nsIMsgCompSendFormat} sendFormat - The send format to set. Either
+ * Auto, PlainText, HTML, or Both.
+ */
+async function setSendFormat(composeWindow, sendFormat) {
+ async function openMenu(menu) {
+ let openPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ menu.openMenu(true);
+ await openPromise;
+ }
+ let optionsMenu = composeWindow.document.getElementById("optionsMenu");
+ let sendFormatMenu =
+ composeWindow.document.getElementById("outputFormatMenu");
+ let menuitem = composeWindow.document.getElementById(
+ sendFormatToMenuitem.get(sendFormat)
+ );
+
+ await openMenu(optionsMenu);
+ await openMenu(sendFormatMenu);
+
+ let closePromise = BrowserTestUtils.waitForEvent(optionsMenu, "popuphidden");
+ sendFormatMenu.menupopup.activateItem(menuitem);
+ await closePromise;
+ assertSendFormatInMenu(
+ composeWindow,
+ sendFormat,
+ "Send format should change to the selected format"
+ );
+}
+
+/**
+ * Verify the actual sent message of a composed message.
+ *
+ * @param {Window} composeWindow - The compose window that contains the message
+ * we want to send.
+ * @param {object} expectMessage - The expected sent message.
+ * @param {boolean} expectMessage.isBold - Whether the message uses a bold
+ * message, rather than the plain message.
+ * @param {boolean} expectMessage.plain - Whether the message has a plain part.
+ * @param {boolean} expectMessage.html - Whether the message has a html part.
+ * @param {string} msg - A message to use in assertions.
+ */
+async function assertSentMessage(composeWindow, expectMessage, msg) {
+ let { isBold, plain, html } = expectMessage;
+
+ // Send later.
+ let closePromise = BrowserTestUtils.windowClosed(composeWindow);
+ EventUtils.synthesizeKey(
+ "KEY_Enter",
+ { accelKey: true, shiftKey: true },
+ composeWindow
+ );
+ await closePromise;
+
+ // Open the "sent" message.
+ await be_in_folder(outboxFolder);
+ // Should be the last message in the tree.
+ select_click_row(-1);
+
+ // Test that the sent content type is either text/plain, text/html or
+ // multipart/alternative.
+ // TODO: Is there a better way to expose the content-type of the displayed
+ // message?
+ let contentType =
+ get_about_message().currentHeaderData["content-type"].headerValue;
+ if (plain && html) {
+ Assert.ok(
+ contentType.startsWith("multipart/alternative"),
+ `Sent contentType "${contentType}" should be multipart: ${msg}`
+ );
+ } else if (plain) {
+ Assert.ok(
+ contentType.startsWith("text/plain"),
+ `Sent contentType "${contentType}" should be plain text only: ${msg}`
+ );
+ } else if (html) {
+ Assert.ok(
+ contentType.startsWith("text/html"),
+ `Sent contentType "${contentType}" should be html only: ${msg}`
+ );
+ } else {
+ throw new Error("Expected message is missing either plain or html parts");
+ }
+
+ // Assert the html and plain text parts are either hidden or shown.
+ // NOTE: We have set the mailnews.display.html_as preference to show all parts
+ // of the message, which means it will show both the plain text and html parts
+ // if both were sent.
+ let messageBody =
+ get_about_message().document.getElementById("messagepane").contentDocument
+ .body;
+ let plainBody = messageBody.querySelector(".moz-text-flowed");
+ let htmlBody = messageBody.querySelector(".moz-text-html");
+ Assert.equal(
+ !!plain,
+ !!plainBody,
+ `Message should ${plain ? "" : "not "}have a Plain part: ${msg}`
+ );
+ Assert.equal(
+ !!html,
+ !!htmlBody,
+ `Message should ${html ? "" : "not "}have a HTML part: ${msg}`
+ );
+
+ if (plain) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(plainBody),
+ `Plain part should be visible: ${msg}`
+ );
+ Assert.equal(
+ plainBody.textContent.trim(),
+ isBold ? BOLD_MESSAGE_BODY_AS_PLAIN : PLAIN_MESSAGE_BODY,
+ `Plain text content should match: ${msg}`
+ );
+ }
+
+ if (html) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(htmlBody),
+ `HTML part should be visible: ${msg}`
+ );
+ Assert.equal(
+ htmlBody.textContent.trim(),
+ isBold ? BOLD_MESSAGE_BODY : PLAIN_MESSAGE_BODY,
+ `HTML text content should match: ${msg}`
+ );
+ }
+}
+
+async function saveDraft(composeWindow) {
+ let oldDraftsCounts = draftsFolder.getTotalMessages(false);
+ // Save as draft.
+ EventUtils.synthesizeKey("s", { accelKey: true }, composeWindow);
+ await TestUtils.waitForCondition(
+ () => composeWindow.gSaveOperationInProgress,
+ "Should start save operation"
+ );
+ await TestUtils.waitForCondition(
+ () => !composeWindow.gSaveOperationInProgress && !composeWindow.gWindowLock,
+ "Waiting for the save operation to complete"
+ );
+ await TestUtils.waitForCondition(
+ () => draftsFolder.getTotalMessages(false) > oldDraftsCounts,
+ "message saved to drafts folder"
+ );
+ await BrowserTestUtils.closeWindow(composeWindow);
+}
+
+async function assertDraftFormat(expectSavedFormat) {
+ await be_in_folder(draftsFolder);
+ select_click_row(0);
+
+ let newComposeWindow = open_compose_from_draft().window;
+ assertSendFormatInMenu(
+ newComposeWindow,
+ expectSavedFormat,
+ "Send format of the opened draft should match the saved format"
+ );
+ return newComposeWindow;
+}
+
+add_task(async function test_preference_send_format() {
+ // Sending a plain message.
+ for (let { preference, sendsPlain, sendsHtml } of [
+ {
+ preference: Ci.nsIMsgCompSendFormat.Auto,
+ sendsPlain: true,
+ sendsHtml: false,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.PlainText,
+ sendsPlain: true,
+ sendsHtml: false,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.HTML,
+ sendsPlain: false,
+ sendsHtml: true,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.Both,
+ sendsPlain: true,
+ sendsHtml: true,
+ },
+ ]) {
+ info(`Testing preference ${preference} with a plain message`);
+ let composeWindow = await newMessage(preference, false);
+ await assertSentMessage(
+ composeWindow,
+ { plain: sendsPlain, html: sendsHtml, isBold: false },
+ `Plain message with preference ${preference}`
+ );
+ }
+ // Sending a bold message.
+ for (let { preference, sendsPlain, sendsHtml } of [
+ {
+ preference: Ci.nsIMsgCompSendFormat.Auto,
+ sendsPlain: true,
+ sendsHtml: true,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.PlainText,
+ sendsPlain: true,
+ sendsHtml: false,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.HTML,
+ sendsPlain: false,
+ sendsHtml: true,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.Both,
+ sendsPlain: true,
+ sendsHtml: true,
+ },
+ ]) {
+ info(`Testing preference ${preference} with a bold message`);
+ let composeWindow = await newMessage(preference, true);
+ await assertSentMessage(
+ composeWindow,
+ { plain: sendsPlain, html: sendsHtml, isBold: true },
+ `Bold message with preference ${preference}`
+ );
+ }
+});
+
+add_task(async function test_setting_send_format() {
+ for (let { preference, sendFormat, boldMessage, sendsPlain, sendsHtml } of [
+ {
+ preference: Ci.nsIMsgCompSendFormat.Auto,
+ boldMessage: true,
+ sendFormat: Ci.nsIMsgCompSendFormat.HTML,
+ sendsPlain: false,
+ sendsHtml: true,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.Auto,
+ boldMessage: true,
+ sendFormat: Ci.nsIMsgCompSendFormat.PlainText,
+ sendsPlain: true,
+ sendsHtml: false,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.PlainText,
+ boldMessage: false,
+ sendFormat: Ci.nsIMsgCompSendFormat.Both,
+ sendsPlain: true,
+ sendsHtml: true,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.HTML,
+ boldMessage: false,
+ sendFormat: Ci.nsIMsgCompSendFormat.Auto,
+ sendsPlain: true,
+ sendsHtml: false,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.Both,
+ boldMessage: false,
+ sendFormat: Ci.nsIMsgCompSendFormat.HTML,
+ sendsPlain: false,
+ sendsHtml: true,
+ },
+ ]) {
+ info(
+ `Testing changing format from preference ${preference} to ${sendFormat}`
+ );
+ let composeWindow = await newMessage(preference, boldMessage);
+ await setSendFormat(composeWindow, sendFormat);
+ await assertSentMessage(
+ composeWindow,
+ { isBold: boldMessage, plain: sendsPlain, html: sendsHtml },
+ `${boldMessage ? "Bold" : "Plain"} message set as ${sendFormat}`
+ );
+ }
+}).__skipMe = AppConstants.platform == "macosx";
+// Can't click menu bar on Mac to change the send format.
+
+add_task(async function test_saving_draft_with_set_format() {
+ for (let { preference, sendFormat, sendsPlain, sendsHtml } of [
+ {
+ preference: Ci.nsIMsgCompSendFormat.Auto,
+ sendFormat: Ci.nsIMsgCompSendFormat.PlainText,
+ sendsPlain: true,
+ sendsHtml: false,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.PlainText,
+ sendFormat: Ci.nsIMsgCompSendFormat.Auto,
+ sendsPlain: true,
+ sendsHtml: true,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.Both,
+ sendFormat: Ci.nsIMsgCompSendFormat.HTML,
+ sendsPlain: false,
+ sendsHtml: true,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.HTML,
+ sendFormat: Ci.nsIMsgCompSendFormat.Both,
+ sendsPlain: true,
+ sendsHtml: true,
+ },
+ ]) {
+ info(`Testing draft saved as ${sendFormat}`);
+ let composeWindow = await newMessage(preference, true);
+ await setSendFormat(composeWindow, sendFormat);
+ await saveDraft(composeWindow);
+ // Draft keeps the set format when opened.
+ composeWindow = await assertDraftFormat(sendFormat);
+ await assertSentMessage(
+ composeWindow,
+ { isBold: true, plain: sendsPlain, html: sendsHtml },
+ `Bold draft message set as ${sendFormat}`
+ );
+ }
+}).__skipMe = AppConstants.platform == "macosx";
+// Can't click menu bar on Mac to change the send format.
+
+add_task(async function test_saving_draft_with_new_preference() {
+ for (let { preference, newPreference, sendsPlain, sendsHtml } of [
+ {
+ preference: Ci.nsIMsgCompSendFormat.Auto,
+ newPreference: Ci.nsIMsgCompSendFormat.HTML,
+ sendsPlain: true,
+ sendsHtml: false,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.PlainText,
+ newPreference: Ci.nsIMsgCompSendFormat.Both,
+ sendsPlain: true,
+ sendsHtml: false,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.Both,
+ newPreference: Ci.nsIMsgCompSendFormat.Auto,
+ sendsPlain: true,
+ sendsHtml: true,
+ },
+ {
+ preference: Ci.nsIMsgCompSendFormat.HTML,
+ newPreference: Ci.nsIMsgCompSendFormat.PlainText,
+ sendsPlain: false,
+ sendsHtml: true,
+ },
+ ]) {
+ info(`Testing changing preference from ${preference} to ${newPreference}`);
+ let composeWindow = await newMessage(preference, false);
+ await saveDraft(composeWindow);
+ // Re-open, with a new default preference set, to make sure the draft has
+ // the send format set earlier saved in its headers.
+ Services.prefs.setIntPref("mail.default_send_format", newPreference);
+ // Draft keeps the old preference.
+ composeWindow = await assertDraftFormat(preference);
+ await assertSentMessage(
+ composeWindow,
+ { isBold: false, plain: sendsPlain, html: sendsHtml },
+ `Plain draft message with preference ${preference}`
+ );
+ }
+});
diff --git a/comm/mail/test/browser/composition/browser_signatureInit.js b/comm/mail/test/browser/composition/browser_signatureInit.js
new file mode 100644
index 0000000000..f4206696b7
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_signatureInit.js
@@ -0,0 +1,50 @@
+/* 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/. */
+
+/**
+ * Tests that the compose window initializes with the signature correctly
+ * under various circumstances.
+ */
+
+"use strict";
+
+var { close_compose_window, get_compose_body, open_compose_new_mail } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+var kHtmlPref = "mail.identity.default.compose_html";
+var kReplyOnTopPref = "mail.identity.default.reply_on_top";
+var kReplyOnTop = 1;
+var kSigBottomPref = "mail.identity.default.sig_bottom";
+
+/**
+ * Regression test for bug 762413 - tests that when we're set to reply above,
+ * with the signature below the reply, we initialize the compose window such
+ * that there is a <br> node above the signature. This allows the user to
+ * insert text before the signature.
+ */
+add_task(function test_on_reply_above_signature_below_reply() {
+ let origHtml = Services.prefs.getBoolPref(kHtmlPref);
+ let origReplyOnTop = Services.prefs.getIntPref(kReplyOnTopPref);
+ let origSigBottom = Services.prefs.getBoolPref(kSigBottomPref);
+
+ Services.prefs.setBoolPref(kHtmlPref, false);
+ Services.prefs.setIntPref(kReplyOnTopPref, kReplyOnTop);
+ Services.prefs.setBoolPref(kSigBottomPref, false);
+
+ let cw = open_compose_new_mail();
+ let mailBody = get_compose_body(cw);
+
+ let node = mailBody.firstChild;
+ Assert.equal(
+ node.localName,
+ "br",
+ "Expected a BR node to start the compose body."
+ );
+
+ Services.prefs.setBoolPref(kHtmlPref, origHtml);
+ Services.prefs.setIntPref(kReplyOnTopPref, origReplyOnTop);
+ Services.prefs.setBoolPref(kSigBottomPref, origSigBottom);
+
+ close_compose_window(cw);
+});
diff --git a/comm/mail/test/browser/composition/browser_signatureUpdating.js b/comm/mail/test/browser/composition/browser_signatureUpdating.js
new file mode 100644
index 0000000000..1555703e27
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_signatureUpdating.js
@@ -0,0 +1,276 @@
+/* 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/. */
+
+/**
+ * Tests that the signature updates properly when switching identities.
+ */
+
+// mail.identity.id1.htmlSigFormat = false
+// mail.identity.id1.htmlSigText = "Tinderbox is soo 90ies"
+
+// mail.identity.id2.htmlSigFormat = true
+// mail.identity.id2.htmlSigText = "Tinderboxpushlog is the new <b>hotness!</b>"
+
+"use strict";
+
+var { close_compose_window, open_compose_new_mail, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { be_in_folder, FAKE_SERVER_HOSTNAME, get_special_folder } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var cwc = null; // compose window controller
+
+add_setup(async function () {
+ requestLongerTimeout(2);
+
+ // These prefs can't be set in the manifest as they contain white-space.
+ Services.prefs.setStringPref(
+ "mail.identity.id1.htmlSigText",
+ "Tinderbox is soo 90ies"
+ );
+ Services.prefs.setStringPref(
+ "mail.identity.id2.htmlSigText",
+ "Tinderboxpushlog is the new <b>hotness!</b>"
+ );
+
+ // Ensure we're in the tinderbox account as that has the right identities set
+ // up for this test.
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ let inbox = await get_special_folder(
+ Ci.nsMsgFolderFlags.Inbox,
+ false,
+ server
+ );
+ await be_in_folder(inbox);
+});
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("mail.compose.default_to_paragraph");
+ Services.prefs.clearUserPref("mail.identity.id1.compose_html");
+ Services.prefs.clearUserPref("mail.identity.id1.htmlSigText");
+ Services.prefs.clearUserPref("mail.identity.id2.htmlSigText");
+ Services.prefs.clearUserPref(
+ "mail.identity.id1.suppress_signature_separator"
+ );
+ Services.prefs.clearUserPref(
+ "mail.identity.id2.suppress_signature_separator"
+ );
+});
+
+/**
+ * Test that the plaintext compose window has a signature initially,
+ * and has the correct signature after switching to another identity.
+ */
+async function plaintextComposeWindowSwitchSignatures(suppressSigSep) {
+ Services.prefs.setBoolPref("mail.identity.id1.compose_html", false);
+ Services.prefs.setBoolPref(
+ "mail.identity.id1.suppress_signature_separator",
+ suppressSigSep
+ );
+ Services.prefs.setBoolPref(
+ "mail.identity.id2.suppress_signature_separator",
+ suppressSigSep
+ );
+ cwc = open_compose_new_mail();
+
+ let contentFrame = cwc.window.document.getElementById("messageEditor");
+ let mailBody = contentFrame.contentDocument.body;
+
+ // The first node in the body should be a BR node, which allows the user
+ // to insert text before / outside of the signature.
+ Assert.equal(mailBody.firstChild.localName, "br");
+
+ setup_msg_contents(cwc, "", "Plaintext compose window", "Body, first line.");
+
+ let node = mailBody.lastChild;
+
+ // The last node is a BR - this allows users to put text after the
+ // signature without it being styled like the signature.
+ Assert.equal(node.localName, "br");
+ node = node.previousSibling;
+
+ // Now we should have the DIV node that contains the signature, with
+ // the class moz-signature.
+ Assert.equal(node.localName, "div");
+
+ const kSeparator = "-- ";
+ const kSigClass = "moz-signature";
+ Assert.equal(node.className, kSigClass);
+
+ let sigNode = node.firstChild;
+
+ if (!suppressSigSep) {
+ Assert.equal(sigNode.textContent, kSeparator);
+ let brNode = sigNode.nextSibling;
+ Assert.equal(brNode.localName, "br");
+ sigNode = brNode.nextSibling;
+ }
+
+ let expectedText = "Tinderbox is soo 90ies";
+ Assert.equal(sigNode.textContent, expectedText);
+
+ // Now switch identities!
+ await chooseIdentity(cwc.window, "id2");
+
+ node = contentFrame.contentDocument.body.lastChild;
+
+ // The last node is a BR - this allows users to put text after the
+ // signature without it being styled like the signature.
+ Assert.equal(node.localName, "br");
+ node = node.previousSibling;
+
+ Assert.equal(node.localName, "div");
+ Assert.equal(node.className, kSigClass);
+
+ sigNode = node.firstChild;
+
+ if (!suppressSigSep) {
+ expectedText = "-- ";
+ Assert.equal(sigNode.textContent, kSeparator);
+ let brNode = sigNode.nextSibling;
+ Assert.equal(brNode.localName, "br");
+ sigNode = brNode.nextSibling;
+ }
+
+ expectedText = "Tinderboxpushlog is the new *hotness!*";
+ Assert.equal(sigNode.textContent, expectedText);
+
+ // Now check that the original signature has been removed by ensuring
+ // that there's only one node with class moz-signature.
+ let sigs = contentFrame.contentDocument.querySelectorAll("." + kSigClass);
+ Assert.equal(sigs.length, 1);
+
+ // And ensure that the text we wrote wasn't altered
+ let bodyFirstChild = contentFrame.contentDocument.body.firstChild;
+
+ while (node != bodyFirstChild) {
+ node = node.previousSibling;
+ }
+
+ Assert.equal(node.nodeValue, "Body, first line.");
+
+ close_compose_window(cwc);
+}
+
+add_task(async function testPlaintextComposeWindowSwitchSignatures() {
+ await plaintextComposeWindowSwitchSignatures(false);
+});
+
+add_task(
+ async function testPlaintextComposeWindowSwitchSignaturesWithSuppressedSeparator() {
+ await plaintextComposeWindowSwitchSignatures(true);
+ }
+);
+
+/**
+ * Same test, but with an HTML compose window
+ */
+async function HTMLComposeWindowSwitchSignatures(
+ suppressSigSep,
+ paragraphFormat
+) {
+ Services.prefs.setBoolPref(
+ "mail.compose.default_to_paragraph",
+ paragraphFormat
+ );
+
+ Services.prefs.setBoolPref("mail.identity.id1.compose_html", true);
+ Services.prefs.setBoolPref(
+ "mail.identity.id1.suppress_signature_separator",
+ suppressSigSep
+ );
+ Services.prefs.setBoolPref(
+ "mail.identity.id2.suppress_signature_separator",
+ suppressSigSep
+ );
+ cwc = open_compose_new_mail();
+
+ setup_msg_contents(cwc, "", "HTML compose window", "Body, first line.");
+
+ let contentFrame = cwc.window.document.getElementById("messageEditor");
+ let node = contentFrame.contentDocument.body.lastChild;
+
+ // In html compose, the signature is inside the last node, which has a
+ // class="moz-signature".
+ Assert.equal(node.className, "moz-signature");
+ node = node.firstChild; // text node containing the signature divider
+ if (suppressSigSep) {
+ Assert.equal(node.nodeValue, "Tinderbox is soo 90ies");
+ } else {
+ Assert.equal(node.nodeValue, "-- \nTinderbox is soo 90ies");
+ }
+
+ // Now switch identities!
+ await chooseIdentity(cwc.window, "id2");
+
+ node = contentFrame.contentDocument.body.lastChild;
+
+ // In html compose, the signature is inside the last node
+ // with class="moz-signature".
+ Assert.equal(node.className, "moz-signature");
+ node = node.firstChild; // text node containing the signature divider
+ if (!suppressSigSep) {
+ Assert.equal(node.nodeValue, "-- ");
+ node = node.nextSibling;
+ Assert.equal(node.localName, "br");
+ node = node.nextSibling;
+ }
+ Assert.equal(node.nodeValue, "Tinderboxpushlog is the new ");
+ node = node.nextSibling;
+ Assert.equal(node.localName, "b");
+ node = node.firstChild;
+ Assert.equal(node.nodeValue, "hotness!");
+
+ // Now check that the original signature has been removed,
+ // and no blank lines got added!
+ node = contentFrame.contentDocument.body.firstChild;
+ let textNode;
+ if (paragraphFormat) {
+ textNode = node.firstChild;
+ } else {
+ textNode = node;
+ }
+ Assert.equal(textNode.nodeValue, "Body, first line.");
+ if (!paragraphFormat) {
+ node = node.nextSibling;
+ Assert.equal(node.localName, "br");
+ }
+ node = node.nextSibling;
+ // check that the signature is immediately after the message text.
+ Assert.equal(node.className, "moz-signature");
+ // check that that the signature is the last node.
+ Assert.equal(node, contentFrame.contentDocument.body.lastChild);
+
+ close_compose_window(cwc);
+}
+
+add_task(async function testHTMLComposeWindowSwitchSignatures() {
+ await HTMLComposeWindowSwitchSignatures(false, false);
+});
+
+add_task(
+ async function testHTMLComposeWindowSwitchSignaturesWithSuppressedSeparator() {
+ await HTMLComposeWindowSwitchSignatures(true, false);
+ }
+);
+
+add_task(async function testHTMLComposeWindowSwitchSignaturesParagraphFormat() {
+ await HTMLComposeWindowSwitchSignatures(false, true);
+});
+
+add_task(
+ async function testHTMLComposeWindowSwitchSignaturesWithSuppressedSeparatorParagraphFormat() {
+ await HTMLComposeWindowSwitchSignatures(true, true);
+ }
+);
diff --git a/comm/mail/test/browser/composition/browser_spelling.js b/comm/mail/test/browser/composition/browser_spelling.js
new file mode 100644
index 0000000000..86a1ba9c84
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_spelling.js
@@ -0,0 +1,311 @@
+/* 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 { close_compose_window, open_compose_new_mail } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var { maybeOnSpellCheck } = ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+async function checkMisspelledWords(editor, ...words) {
+ await new Promise(resolve => maybeOnSpellCheck({ editor }, resolve));
+
+ let selection = editor.selectionController.getSelection(
+ Ci.nsISelectionController.SELECTION_SPELLCHECK
+ );
+ Assert.equal(
+ selection.rangeCount,
+ words.length,
+ "correct number of misspellings"
+ );
+ for (let i = 0; i < words.length; i++) {
+ Assert.equal(selection.getRangeAt(i).toString(), words[i]);
+ }
+ return selection;
+}
+
+add_task(async function () {
+ // Install en-NZ dictionary.
+
+ let dictionary = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ dictionary.initWithPath(getTestFilePath("data/en_NZ"));
+
+ let hunspell = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+ hunspell.addDirectory(dictionary);
+
+ // Open a compose window and write a message.
+
+ let cwc = open_compose_new_mail();
+ let composeWindow = cwc.window;
+ let composeDocument = composeWindow.document;
+
+ cwc.window.document.getElementById("msgSubject").focus();
+ EventUtils.sendString(
+ "I went to the harbor in an aluminium boat",
+ cwc.window
+ );
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString("I maneuvered to the center.\n", cwc.window);
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString(
+ "The sky was the colour of ochre and the stars shone like jewelry.\n",
+ cwc.window
+ );
+
+ // Check initial spelling.
+
+ let subjectEditor = composeDocument.getElementById("msgSubject").editor;
+ let editorBrowser = composeWindow.GetCurrentEditorElement();
+ let bodyEditor = composeWindow.GetCurrentEditor();
+ let saveButton = composeDocument.getElementById("button-save");
+
+ await checkMisspelledWords(subjectEditor, "aluminium");
+ await checkMisspelledWords(bodyEditor, "colour", "ochre");
+
+ // Check menu items are displayed correctly.
+
+ let shownPromise, hiddenPromise;
+ let contextMenu = composeDocument.getElementById("msgComposeContext");
+ let contextMenuEnabled = composeDocument.getElementById("spellCheckEnable");
+ let optionsMenu = composeDocument.getElementById("optionsMenu");
+ let optionsMenuEnabled = composeDocument.getElementById(
+ "menu_inlineSpellCheck"
+ );
+
+ if (AppConstants.platform != "macosx") {
+ shownPromise = BrowserTestUtils.waitForEvent(optionsMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(optionsMenu, {}, composeWindow);
+ await shownPromise;
+ Assert.equal(
+ optionsMenuEnabled.getAttribute("checked"),
+ "true",
+ "options menu item is checked"
+ );
+ hiddenPromise = BrowserTestUtils.waitForEvent(optionsMenu, "popuphidden");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, composeWindow);
+ await hiddenPromise;
+ }
+
+ shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ { type: "contextmenu" },
+ editorBrowser
+ );
+ await shownPromise;
+ Assert.equal(
+ contextMenuEnabled.getAttribute("checked"),
+ "true",
+ "context menu item is checked"
+ );
+
+ // Disable the spell checker.
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.activateItem(contextMenuEnabled);
+ await hiddenPromise;
+
+ await checkMisspelledWords(subjectEditor);
+ await checkMisspelledWords(bodyEditor);
+
+ // Save the message. The spell checking state shouldn't change.
+
+ EventUtils.synthesizeMouseAtCenter(saveButton, {}, composeWindow);
+ // Clicking the button sets gWindowLocked to true synchronously, so if
+ // gWindowLocked is false, we know that saving has completed.
+ await TestUtils.waitForCondition(
+ () => !composeWindow.gWindowLocked,
+ "window unlocked after saving"
+ );
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ await checkMisspelledWords(subjectEditor);
+ await checkMisspelledWords(bodyEditor);
+
+ // Check menu items are displayed correctly.
+
+ if (AppConstants.platform != "macosx") {
+ shownPromise = BrowserTestUtils.waitForEvent(optionsMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(optionsMenu, {}, composeWindow);
+ await shownPromise;
+ Assert.ok(
+ !optionsMenuEnabled.hasAttribute("checked"),
+ "options menu item is not checked"
+ );
+ hiddenPromise = BrowserTestUtils.waitForEvent(optionsMenu, "popuphidden");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, composeWindow);
+ await hiddenPromise;
+ }
+
+ shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ { type: "contextmenu" },
+ editorBrowser
+ );
+ await shownPromise;
+ Assert.equal(
+ contextMenuEnabled.getAttribute("checked"),
+ "false",
+ "context menu item is not checked"
+ );
+
+ // Enable the spell checker.
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.activateItem(contextMenuEnabled);
+ await hiddenPromise;
+
+ await checkMisspelledWords(subjectEditor, "aluminium");
+ await checkMisspelledWords(bodyEditor, "colour", "ochre");
+
+ // Save the message. The spell checking state shouldn't change.
+
+ EventUtils.synthesizeMouseAtCenter(saveButton, {}, composeWindow);
+ // Clicking the button sets gWindowLocked to true synchronously, so if
+ // gWindowLocked is false, we know that saving has completed.
+ await TestUtils.waitForCondition(
+ () => !composeWindow.gWindowLocked,
+ "window unlocked after saving"
+ );
+
+ await checkMisspelledWords(subjectEditor, "aluminium");
+ await checkMisspelledWords(bodyEditor, "colour", "ochre");
+
+ // Add language.
+
+ let statusButton = composeDocument.getElementById("languageStatusButton");
+ let languageList = composeDocument.getElementById("languageMenuList");
+
+ shownPromise = BrowserTestUtils.waitForEvent(languageList, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(statusButton, {}, composeWindow);
+ await shownPromise;
+
+ Assert.equal(languageList.childElementCount, 4);
+ Assert.equal(languageList.children[0].value, "en-NZ");
+ Assert.equal(languageList.children[0].getAttribute("checked"), "false");
+ Assert.equal(languageList.children[1].value, "en-US");
+ Assert.equal(languageList.children[1].getAttribute("checked"), "true");
+ Assert.equal(languageList.children[2].localName, "menuseparator");
+ Assert.equal(
+ languageList.children[3].dataset.l10nId,
+ "spell-add-dictionaries"
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(languageList, "popuphidden");
+ languageList.activateItem(languageList.children[0]);
+ await TestUtils.waitForCondition(
+ () => languageList.children[0].getAttribute("checked") == "true",
+ "en-NZ menu item checked"
+ );
+ await TestUtils.waitForCondition(
+ () => composeWindow.gActiveDictionaries.has("en-NZ"),
+ "en-NZ added to dictionaries"
+ );
+ languageList.hidePopup();
+ await hiddenPromise;
+
+ Assert.deepEqual(
+ [...composeWindow.gActiveDictionaries],
+ ["en-US", "en-NZ"],
+ "correct dictionaries active"
+ );
+ await checkMisspelledWords(subjectEditor);
+ await checkMisspelledWords(bodyEditor);
+
+ // Remove language.
+
+ shownPromise = BrowserTestUtils.waitForEvent(languageList, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(statusButton, {}, composeWindow);
+ await shownPromise;
+
+ Assert.equal(languageList.childElementCount, 4);
+ Assert.equal(languageList.children[0].value, "en-NZ");
+ Assert.equal(languageList.children[0].getAttribute("checked"), "true");
+ Assert.equal(languageList.children[1].value, "en-US");
+ Assert.equal(languageList.children[1].getAttribute("checked"), "true");
+ Assert.equal(languageList.children[2].localName, "menuseparator");
+ Assert.equal(
+ languageList.children[3].dataset.l10nId,
+ "spell-add-dictionaries"
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(languageList, "popuphidden");
+ languageList.activateItem(languageList.children[1]);
+ await TestUtils.waitForCondition(
+ () => !languageList.children[1].hasAttribute("checked"),
+ "en-US menu item unchecked"
+ );
+ await TestUtils.waitForCondition(
+ () => !composeWindow.gActiveDictionaries.has("en-US"),
+ "en-US removed from dictionaries"
+ );
+ languageList.hidePopup();
+ await hiddenPromise;
+
+ Assert.deepEqual(
+ [...composeWindow.gActiveDictionaries],
+ ["en-NZ"],
+ "correct dictionaries active"
+ );
+ await checkMisspelledWords(subjectEditor, "harbor");
+ let words = await checkMisspelledWords(
+ bodyEditor,
+ "maneuvered",
+ "center",
+ "jewelry"
+ );
+
+ // Check that opening the context menu on a spelling error works as expected.
+
+ let box = words.getRangeAt(1).getBoundingClientRect();
+ shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtPoint(
+ box.left + box.width / 2,
+ box.top + box.height / 2,
+ { type: "contextmenu" },
+ editorBrowser
+ );
+ await shownPromise;
+
+ let menuItem = composeDocument.getElementById("spellCheckNoSuggestions");
+ Assert.ok(BrowserTestUtils.is_hidden(menuItem));
+
+ let suggestions = contextMenu.querySelectorAll(".spell-suggestion");
+ Assert.greater(suggestions.length, 0);
+ Assert.equal(suggestions[0].value, "centre");
+
+ for (let id of [
+ "spellCheckAddSep",
+ "spellCheckAddToDictionary",
+ "spellCheckIgnoreWord",
+ "spellCheckSuggestionsSeparator",
+ ]) {
+ menuItem = composeDocument.getElementById(id);
+ Assert.ok(BrowserTestUtils.is_visible(menuItem));
+ }
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.activateItem(suggestions[0]);
+ await hiddenPromise;
+
+ await checkMisspelledWords(bodyEditor, "maneuvered", "jewelry");
+ await SpecialPowers.spawn(editorBrowser, [], () => {
+ Assert.ok(
+ content.document.body.textContent.startsWith(
+ "I maneuvered to the centre."
+ )
+ );
+ });
+
+ // Clean up.
+
+ close_compose_window(cwc);
+ hunspell.removeDirectory(dictionary);
+});
diff --git a/comm/mail/test/browser/composition/browser_subjectWas.js b/comm/mail/test/browser/composition/browser_subjectWas.js
new file mode 100644
index 0000000000..c76a719ce5
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_subjectWas.js
@@ -0,0 +1,65 @@
+/* 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/. */
+
+/**
+ * Tests that replying in to mail with subject change (was: old) style will
+ * do the right thing.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_reply } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ create_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder = null;
+
+add_setup(async function () {
+ folder = await create_folder("SubjectWas");
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ subject: "New subject (was: Old subject)",
+ body: { body: "Testing thread subject switch reply." },
+ clobberHeaders: {
+ References: "<97010db3-bd55-34e0-b08b-841b2a9ff0ec@test>",
+ },
+ })
+ );
+ registerCleanupFunction(() => folder.deleteSelf(null));
+});
+
+/**
+ * Test that the subject is set properly in the replied message.
+ */
+add_task(async function test_was_reply_subj() {
+ await be_in_folder(folder);
+
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ let cwc = open_compose_with_reply();
+
+ let msgSubject = cwc.window.document.getElementById("msgSubject").value;
+
+ // Subject should be Re: <the original subject stripped of the was: part>
+ Assert.equal(
+ msgSubject,
+ "Re: New subject",
+ "was: part of subject should have been removed"
+ );
+
+ close_compose_window(cwc);
+});
diff --git a/comm/mail/test/browser/composition/browser_text_styling.js b/comm/mail/test/browser/composition/browser_text_styling.js
new file mode 100644
index 0000000000..88c31c3040
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_text_styling.js
@@ -0,0 +1,609 @@
+/* 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 styling messages.
+ */
+
+requestLongerTimeout(3);
+
+var { close_compose_window, open_compose_new_mail, FormatHelper } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+add_task(async function test_style_buttons() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let buttonSet = [
+ { name: "bold", tag: "B", node: formatHelper.boldButton },
+ { name: "italic", tag: "I", node: formatHelper.italicButton },
+ { name: "underline", tag: "U", node: formatHelper.underlineButton },
+ ];
+
+ // Without focus on message.
+ for (let button of buttonSet) {
+ Assert.ok(
+ button.node.disabled,
+ `${button.name} button should be disabled with no focus`
+ );
+ }
+
+ formatHelper.focusMessage();
+
+ // With focus on message.
+ for (let button of buttonSet) {
+ Assert.ok(
+ !button.node.disabled,
+ `${button.name} button should be enabled with focus`
+ );
+ }
+
+ async function selectTextAndToggleButton(
+ start,
+ end,
+ button,
+ enables,
+ message
+ ) {
+ await formatHelper.selectTextRange(start, end);
+ await formatHelper.assertShownStyles(
+ enables ? null : [button.name],
+ `${message}: Before toggle`
+ );
+ button.node.click();
+ await formatHelper.assertShownStyles(
+ enables ? [button.name] : null,
+ `${message}: Before toggle`
+ );
+ }
+
+ for (let button of buttonSet) {
+ let name = button.name;
+ let tags = new Set();
+ tags.add(button.tag);
+
+ await formatHelper.assertShownStyles(
+ null,
+ `No shown styles at the start (${name})`
+ );
+ button.node.click();
+ await formatHelper.assertShownStyles(
+ [name],
+ `${name} is shown after clicking`
+ );
+ let text = `test-${button.name}`;
+ await formatHelper.typeInMessage(text);
+ await formatHelper.assertShownStyles(
+ [name],
+ `${name} is shown after clicking and typing`
+ );
+ formatHelper.assertMessageParagraph(
+ [{ tags, text }],
+ `Clicking ${name} button and typing`
+ );
+
+ // Stop styling on click.
+ let addedText = "not-styled";
+ button.node.click();
+ await formatHelper.assertShownStyles(
+ null,
+ `No longer ${name} after re-clicking`
+ );
+ await formatHelper.typeInMessage(addedText);
+ await formatHelper.assertShownStyles(
+ null,
+ `No longer ${name} after re-clicking and typing`
+ );
+ formatHelper.assertMessageParagraph(
+ [{ tags, text }, addedText],
+ `Unclicking ${name} button and typing`
+ );
+
+ await formatHelper.deleteTextRange(
+ text.length,
+ text.length + addedText.length
+ );
+ formatHelper.assertMessageParagraph(
+ [{ tags, text }],
+ `Removed non-${name} region`
+ );
+
+ // Undo in region.
+ await selectTextAndToggleButton(
+ 1,
+ 3,
+ button,
+ false,
+ `Unchecking ${button.name} button for some region`
+ );
+ formatHelper.assertMessageParagraph(
+ [{ tags, text: "t" }, "es", { tags, text: `t-${button.name}` }],
+ `After unchecking ${button.name} button for some region`
+ );
+
+ // Redo over region.
+ await selectTextAndToggleButton(
+ 1,
+ 3,
+ button,
+ true,
+ `Rechecking ${button.name} button for some region`
+ );
+ formatHelper.assertMessageParagraph(
+ [{ tags, text }],
+ `After rechecking ${button.name} button for some region`
+ );
+
+ // Undo over whole text
+ await selectTextAndToggleButton(
+ 0,
+ text.length,
+ button,
+ false,
+ `Unchecking ${button.name} button for whole text`
+ );
+ formatHelper.assertMessageParagraph(
+ [text],
+ `After unchecking ${button.name} button for whole text`
+ );
+
+ await formatHelper.emptyParagraph();
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_multi_style_with_buttons() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ let boldButton = formatHelper.boldButton;
+ let italicButton = formatHelper.italicButton;
+ let underlineButton = formatHelper.underlineButton;
+
+ formatHelper.focusMessage();
+
+ let parts = ["bold", " and italic", " and underline"];
+
+ boldButton.click();
+ await formatHelper.typeInMessage(parts[0]);
+ formatHelper.assertMessageParagraph(
+ [{ tags: ["B"], text: parts[0] }],
+ "Added bold"
+ );
+ await formatHelper.assertShownStyles(
+ ["bold"],
+ "After clicking bold and typing"
+ );
+
+ italicButton.click();
+ await formatHelper.typeInMessage(parts[1]);
+ formatHelper.assertMessageParagraph(
+ [
+ { tags: ["B"], text: parts[0] },
+ { tags: ["B", "I"], text: parts[1] },
+ ],
+ "Added italic"
+ );
+ await formatHelper.assertShownStyles(
+ ["bold", "italic"],
+ "After clicking italic and typing"
+ );
+
+ underlineButton.click();
+ await formatHelper.typeInMessage(parts[2]);
+ formatHelper.assertMessageParagraph(
+ [
+ { tags: ["B"], text: parts[0] },
+ { tags: ["B", "I"], text: parts[1] },
+ { tags: ["B", "I", "U"], text: parts[2] },
+ ],
+ "Added underline"
+ );
+ await formatHelper.assertShownStyles(
+ ["bold", "italic", "underline"],
+ "After clicking underline and typing"
+ );
+
+ await formatHelper.selectTextRange(2, parts[0].length + parts[1].length + 2);
+ await formatHelper.assertShownStyles(
+ ["bold"],
+ "Only bold when selecting all bold, mixed italic and mixed underline"
+ );
+
+ // Remove bold over selection.
+ boldButton.click();
+ formatHelper.assertMessageParagraph(
+ [
+ { tags: ["B"], text: parts[0].slice(0, 2) },
+ parts[0].slice(2),
+ { tags: ["I"], text: parts[1] },
+ { tags: ["I", "U"], text: parts[2].slice(0, 2) },
+ { tags: ["B", "I", "U"], text: parts[2].slice(2) },
+ ],
+ "Removed bold in middle"
+ );
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_text_styling_whilst_typing() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ await formatHelper.assertShownStyles(null, "None checked");
+
+ for (let style of formatHelper.styleDataMap.values()) {
+ let tags = new Set();
+ tags.add(style.tag);
+ let name = style.name;
+
+ // Start styling.
+ await formatHelper.selectStyle(style);
+
+ // See Bug 1716840.
+ // await formatHelper.assertShownStyles(style, `${name} selected`);
+ let text = `test-${name}`;
+ await formatHelper.typeInMessage(text);
+ await formatHelper.assertShownStyles(style, `${name} selected and typing`);
+ formatHelper.assertMessageParagraph([{ tags, text }], `Selecting ${name}`);
+
+ // Stop styling.
+ await formatHelper.selectStyle(style);
+ // See Bug 1716840.
+ // await formatHelper.assertShownStyles(null, `${name} unselected`);
+ let addedText = "not-styled";
+ await formatHelper.typeInMessage(addedText);
+ formatHelper.assertMessageParagraph(
+ [{ tags, text }, addedText],
+ `Unselecting ${name}`
+ );
+ await formatHelper.assertShownStyles(null, `${name} unselected and typing`);
+
+ // Select these again to unselect for next loop cycle. Needs to be done
+ // before empty paragraph since they happen only after "typing".
+ if (style.linked) {
+ await formatHelper.selectStyle(style.linked);
+ }
+ if (style.implies) {
+ await formatHelper.selectStyle(style.implies);
+ }
+ await formatHelper.emptyParagraph();
+
+ // Select again to unselect for next loop cycle.
+ await formatHelper.selectStyle(style);
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_text_styling_update_on_selection_change() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ for (let style of formatHelper.styleDataMap.values()) {
+ let tags = new Set();
+ tags.add(style.tag);
+ let name = style.name;
+
+ // Start styling.
+ await formatHelper.selectStyle(style);
+ let text = `test-${name}`;
+ await formatHelper.typeInMessage(text);
+ // Stop styling.
+ await formatHelper.selectStyle(style);
+ let addedText = "not-styled";
+ await formatHelper.typeInMessage("not-styled");
+ formatHelper.assertMessageParagraph(
+ [{ tags, text }, addedText],
+ `Unselecting ${name} and typing`
+ );
+
+ await formatHelper.assertShownStyles(null, `${name} unselected at end`);
+
+ // Test selections.
+ for (let [start, end, forward, expect] of [
+ // Make sure we toggle, so the test does not capture the previous state.
+ [0, null, true, true], // At start.
+ [0, text.length + 1, true, false], // Mixed is unchecked.
+ [text.length, null, true, true], // On boundary travelling forward.
+ [text.length, null, false, false], // On boundary travelling backward.
+ [2, 4, true, true], // In the styled region.
+ [text.length, text.length + 1, true, false], // In the unstyled region.
+ ]) {
+ await formatHelper.selectTextRange(start, end, forward);
+ if (expect) {
+ expect = style;
+ } else {
+ expect = null;
+ }
+ await formatHelper.assertShownStyles(
+ expect,
+ `Selecting with ${name} style, from ${start} to ${end} ` +
+ `${forward ? "forwards" : "backwards"}`
+ );
+ }
+ if (style.linked) {
+ await formatHelper.selectStyle(style.linked);
+ }
+ if (style.implies) {
+ await formatHelper.selectStyle(style.implies);
+ }
+ await formatHelper.emptyParagraph();
+ // Select again to unselect for next loop cycle.
+ await formatHelper.selectStyle(style);
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_text_styling_on_selections() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ let start;
+ let end = 0;
+ let parts = [];
+ let fullText = "";
+ for (let text of ["test for ", "styling some", " selections"]) {
+ start = end;
+ end += text.length;
+ parts.push({ text, start, end });
+ fullText += text;
+ }
+ await formatHelper.typeInMessage(fullText);
+ formatHelper.assertMessageParagraph([fullText], "No styling at start");
+
+ for (let style of formatHelper.styleDataMap.values()) {
+ let tags = new Set();
+ tags.add(style.tag);
+ let name = style.name;
+
+ formatHelper.assertMessageParagraph(
+ [fullText],
+ `No ${name} style at start`
+ );
+
+ await formatHelper.selectTextRange(parts[1].start, parts[1].end);
+ await formatHelper.selectStyle(style);
+ formatHelper.assertMessageParagraph(
+ [parts[0].text, { tags, text: parts[1].text }, parts[2].text],
+ `${name} in the middle`
+ );
+
+ await formatHelper.selectTextRange(parts[0].start, parts[2].end);
+ await formatHelper.selectStyle(style);
+ formatHelper.assertMessageParagraph(
+ [{ tags, text: fullText }],
+ `${name} on all`
+ );
+
+ // Undo in region.
+ await formatHelper.selectTextRange(parts[1].start, parts[1].end);
+ await formatHelper.selectStyle(style);
+ formatHelper.assertMessageParagraph(
+ [
+ { tags, text: parts[0].text },
+ parts[1].text,
+ { tags, text: parts[2].text },
+ ],
+ `${name} not in the middle`
+ );
+
+ // Redo over region.
+ await formatHelper.selectTextRange(parts[1].start, parts[1].end);
+ await formatHelper.selectStyle(style);
+ formatHelper.assertMessageParagraph(
+ [{ tags, text: fullText }],
+ `${name} on all again`
+ );
+
+ // Reset by unselecting all again.
+ await formatHelper.selectTextRange(parts[0].start, parts[2].end);
+ await formatHelper.selectStyle(style);
+ }
+
+ formatHelper.assertMessageParagraph([fullText], "No style at end");
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_induced_text_styling() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ for (let style of formatHelper.styleDataMap.values()) {
+ if (!style.implies && !style.linked) {
+ continue;
+ }
+ let tags = new Set();
+ tags.add(style.tag);
+ let name = style.name;
+
+ // Start styling.
+ await formatHelper.selectStyle(style);
+ let text = `test-${name}`;
+ await formatHelper.typeInMessage(text);
+ await formatHelper.assertShownStyles(style, `${name} initial text`);
+ formatHelper.assertMessageParagraph(
+ [{ tags, text }],
+ `${name} initial text`
+ );
+
+ if (style.implies) {
+ // Unselecting implied styles will be ignored.
+ let desc = `${style.implies.name} implied by ${name}`;
+ await formatHelper.selectTextRange(0, text.length);
+ await formatHelper.assertShownStyles(
+ style,
+ `Before trying to deselect ${desc}`
+ );
+
+ await formatHelper.selectStyle(style.implies);
+ formatHelper.assertMessageParagraph(
+ [{ tags, text }],
+ `After trying to deselect ${desc}`
+ );
+ await formatHelper.assertShownStyles(
+ style,
+ `After trying to deselect ${desc}`
+ );
+ }
+ if (style.linked) {
+ // Unselecting the linked style also unselects the current one.
+ let desc = `${style.linked.name} linked from ${name}`;
+ await formatHelper.selectTextRange(0, text.length);
+ await formatHelper.assertShownStyles(style, `Before unselecting ${desc}`);
+ await formatHelper.selectStyle(style.linked);
+
+ formatHelper.assertMessageParagraph([text], `After unselecting ${desc}`);
+ await formatHelper.assertShownStyles(null, `After unselecting ${desc}`);
+ }
+
+ await formatHelper.emptyParagraph();
+ // Select again to unselect for next loop cycle.
+ if (style.linked) {
+ await formatHelper.selectStyle(style.linked);
+ }
+ if (style.implies) {
+ await formatHelper.selectStyle(style.implies);
+ }
+ await formatHelper.emptyParagraph();
+ }
+
+ close_compose_window(controller);
+});
+
+add_task(async function test_fixed_width_text_styling_font_change() {
+ let controller = open_compose_new_mail();
+ let formatHelper = new FormatHelper(controller.window);
+
+ formatHelper.focusMessage();
+
+ await formatHelper.assertShownFont("", "Variable width to start");
+ for (let style of formatHelper.styleDataMap.values()) {
+ if (
+ style.name !== "tt" &&
+ style.linked?.name !== "tt" &&
+ style.implies?.name !== "tt"
+ ) {
+ continue;
+ }
+
+ let tags = new Set();
+ tags.add(style.tag);
+ let name = style.name;
+
+ // Start styling.
+ await formatHelper.selectStyle(style);
+ // See Bug 1716840.
+ // await formatHelper.assertShownFont(
+ // "monospace",
+ // `monospace when ${name} selected`
+ // );
+
+ await formatHelper.typeInMessage(`test-${name}`);
+ await formatHelper.assertShownFont(
+ "monospace",
+ `monospace when ${name} selected and typing`
+ );
+
+ // Stop styling.
+ await formatHelper.selectStyle(style);
+ // See Bug 1716840.
+ // await formatHelper.assertShownFont(
+ // "",
+ // `Variable Width when ${name} unselected`
+ // );
+
+ await formatHelper.typeInMessage("test-none");
+ await formatHelper.assertShownFont(
+ "",
+ `Variable Width when ${name} unselected and typing`
+ );
+
+ await formatHelper.selectTextRange(1, 3);
+ await formatHelper.assertShownFont(
+ "monospace",
+ `monospace when ${name} region highlighted`
+ );
+ // Select the same font does nothing
+ await formatHelper.selectFont("monospace");
+ formatHelper.assertMessageParagraph(
+ [{ tags, text: `test-${name}` }, "test-none"],
+ `No change when ${name} region has monospace selected`
+ );
+
+ // Try to change the font selection to variable width.
+ await formatHelper.selectFont("");
+ if (name === "tt") {
+ // "tt" style is removed.
+ formatHelper.assertMessageParagraph(
+ [{ tags, text: "t" }, "es", { tags, text: `t-${name}` }, "test-none"],
+ `variable width when ${name} region has font unset`
+ );
+ await formatHelper.assertShownFont(
+ "",
+ `Variable Width when ${name} region has font unset`
+ );
+ // Reset by selecting the style.
+ // Note: Reselecting the "monospace" font will not add the tt style back.
+ await formatHelper.selectStyle(style);
+ }
+ // Otherwise, the style is unchanged.
+ formatHelper.assertMessageParagraph(
+ [{ tags, text: `test-${name}` }, "test-none"],
+ `Still ${name} style in region`
+ );
+ await formatHelper.assertShownFont(
+ "monospace",
+ `Still monospace for ${name} region`
+ );
+
+ // Change the font to something else.
+ let font = formatHelper.commonFonts[0];
+ await formatHelper.selectFont(font);
+ // Doesn't remove the style, but adds the font.
+ formatHelper.assertMessageParagraph(
+ [
+ { tags, text: "t" },
+ // See Bug 1718779
+ // Whilst the font covers this region, it is actually suppressed by the
+ // styling tags. In this case, the ordering of the <font> and, e.g.,
+ // <tt> element matters.
+ { tags, font, text: "es" },
+ { tags, text: `t-${name}` },
+ "test-none",
+ ],
+ `"${font}" when ${name} region has font set`
+ );
+ // See Bug 1718779
+ // The desired font is shown at first, but then switches to Fixed Width
+ // again.
+ // await formatHelper.assertShownFont(
+ // font,
+ // `"${font}" when ${name} region has font set`
+ //);
+
+ await formatHelper.emptyParagraph();
+ // Select again to unselect for next loop cycle.
+ if (style.linked) {
+ await formatHelper.selectStyle(style.linked);
+ }
+ if (style.implies) {
+ await formatHelper.selectStyle(style.implies);
+ }
+ await formatHelper.emptyParagraph();
+ }
+
+ close_compose_window(controller);
+});
diff --git a/comm/mail/test/browser/composition/browser_xUnsent.js b/comm/mail/test/browser/composition/browser_xUnsent.js
new file mode 100644
index 0000000000..dd3be86294
--- /dev/null
+++ b/comm/mail/test/browser/composition/browser_xUnsent.js
@@ -0,0 +1,45 @@
+/* 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/. */
+
+/**
+ * Tests that X-Unsent .eml messages are correctly opened for composition.
+ */
+
+"use strict";
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+function waitForComposeWindow() {
+ return BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ await BrowserTestUtils.waitForEvent(win, "focus", true);
+ return (
+ win.document.documentURI ===
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ );
+ });
+}
+
+/**
+ * Tests that opening an .eml with X-Unsent: 1 opens composition correctly.
+ */
+add_task(async function openXUnsent() {
+ let compWinReady = waitForComposeWindow();
+ let file = new FileUtils.File(getTestFilePath(`data/xunsent.eml`));
+ let fileURL = Services.io
+ .newFileURI(file)
+ .QueryInterface(Ci.nsIFileURL)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+ MailUtils.openEMLFile(window, file, fileURL);
+ let compWin = await compWinReady;
+
+ Assert.equal(
+ compWin.document.getElementById("msgSubject").value,
+ "xx unsent",
+ "Should open as draft with correct subject"
+ );
+ compWin.close();
+});
diff --git a/comm/mail/test/browser/composition/data/attachment.txt b/comm/mail/test/browser/composition/data/attachment.txt
new file mode 100644
index 0000000000..e5dde9a9f2
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/attachment.txt
@@ -0,0 +1,4 @@
+"Attachment is the great fabricator of illusions; reality can be attained only
+ by someone who is detached."
+
+ -- Simone Weil
diff --git a/comm/mail/test/browser/composition/data/base64-bug1586890.eml b/comm/mail/test/browser/composition/data/base64-bug1586890.eml
new file mode 100644
index 0000000000..b79957b6c0
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/base64-bug1586890.eml
@@ -0,0 +1,25 @@
+Date: Tue, 31 Aug 2018 16:33:00 +0200
+From: From <from@example.com>
+To: To <to@example.com>
+Subject: Bug 1586890 - BASE64 MIME body and attachment (UTF-16BE) with invalid charset
+MIME-Version: 1.0
+Message-ID: <1dcZe4@example.com>
+Content-Type: multipart/mixed;
+ boundary="------------DA562B250842CC7332F16476"
+
+This is a multi-part message in MIME format.
+--------------DA562B250842CC7332F16476
+Content-Type: text/plain; charset=BadCharset
+Content-Transfer-Encoding: base64
+
+AGEAYgBjAGQAZQBmAGcAaABpAGoAawBsAG0ADQAKAG4AbwBwAHEAcgBzAHQAdQB2AHcAeAB5
+AHo=
+--------------DA562B250842CC7332F16476
+Content-Type: text/plain; charset=BadCharset
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="test.txt"
+
+AGEAYgBjAGQAZQBmAGcAaABpAGoAawBsAG0ADQAKAG4AbwBwAHEAcgBzAHQAdQB2AHcAeAB5
+AHo=
+--------------DA562B250842CC7332F16476--
diff --git a/comm/mail/test/browser/composition/data/base64-encoded-msg.eml b/comm/mail/test/browser/composition/data/base64-encoded-msg.eml
new file mode 100644
index 0000000000..e2a37fbe85
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/base64-encoded-msg.eml
@@ -0,0 +1,11 @@
+Message-ID: <4877F4BB.3060507@example.org>
+Date: Fri, 11 Jul 2008 20:03:07 -0400
+From: Joe <joe@example.org>
+MIME-Version: 1.0
+To: Jane <jane@example.org>
+Subject: base64 content
+Content-Type: text/plain; charset=ISO-8859-1
+Content-Transfer-Encoding: base64
+
+WW91IGhhdmUgZGVjb2RlZCB0aGlzIHRleHQgZnJvbSBiYXNlNjQu
+
diff --git a/comm/mail/test/browser/composition/data/base64-with-whitespace.eml b/comm/mail/test/browser/composition/data/base64-with-whitespace.eml
new file mode 100644
index 0000000000..f610203558
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/base64-with-whitespace.eml
@@ -0,0 +1,46 @@
+Date: Tue, 31 Aug 2018 16:33:00 +0200
+From: From <from@example.com>
+To: To <to@example.com>
+Subject: Bug 1487421 - BASE64 MIME body and attachment with empty lines in between
+MIME-Version: 1.0
+Message-ID: <1dcZe4@example.com>
+Content-Type: multipart/mixed;
+ boundary="------------DA562B250842CC7332F16476"
+
+This is a multi-part message in MIME format.
+--------------DA562B250842CC7332F16476
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+
+YWJj
+
+ZG
+
+V
+
+mZ2hpamtsbW
+
+5vcHFyc3R1dnd4
+
+eXo=
+
+--------------DA562B250842CC7332F16476
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="test.txt"
+
+YWJj
+
+ZG
+
+V
+
+mZ2hpamtsbW
+
+5vcHFyc3R1dnd4
+
+eXo=
+
+--------------DA562B250842CC7332F16476--
+
diff --git a/comm/mail/test/browser/composition/data/body-greek.eml b/comm/mail/test/browser/composition/data/body-greek.eml
new file mode 100644
index 0000000000..d2b1764e9b
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/body-greek.eml
@@ -0,0 +1,9 @@
+From: test <test@example.com>
+Subject: test reply to ISO-8859-7 encoded message
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: text/plain; charset=ISO-8859-7
+Content-Transfer-Encoding: quoted-printable
+
+Here comes some Greek text: =CA=E1=EB=E7=F3=F0=DD=F1=E1
diff --git a/comm/mail/test/browser/composition/data/body-utf16.eml b/comm/mail/test/browser/composition/data/body-utf16.eml
new file mode 100644
index 0000000000..2d72a8dc71
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/body-utf16.eml
@@ -0,0 +1,10 @@
+From: test <test@example.com>
+Subject: test reply to UTF-16 encoded message
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-16LE;
+Content-Transfer-Encoding: base64
+
+//5IAGkAIABNAGEAZwBuAHUAcwAsACAAaABlAHIAZQAgAGEAIABiAGUAdAB0AGUAcgAgAFUA
+VABGAC0AMQA2ACAAZQBuAGMAbwBkAGUAZAAgAG0AYQBpAGwALgA=
diff --git a/comm/mail/test/browser/composition/data/charset-cp932.eml b/comm/mail/test/browser/composition/data/charset-cp932.eml
new file mode 100644
index 0000000000..1e8fc33123
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/charset-cp932.eml
@@ -0,0 +1,11 @@
+Date: Tue, 4 Dec 2018 15:23:22 +0900
+From: from@example.org
+Subject: =?cp932?Q?=82=b1=82=b1=82=c9=96=7b=95=b6=82=aa=82=ab=82=dc=82=b7=81=42?=
+To: to@example.org
+Message-Id: <424F4F74-05B4-4575-8B0D-473334183C69@example.org>
+Mime-Version: 1.0 (1.0)
+Content-Type: text/plain; charset=cp932
+Content-Transfer-Encoding: 8bit
+
+‚±‚±‚É–{•¶‚ª‚«‚Ü‚·B
+
diff --git a/comm/mail/test/browser/composition/data/content-utf8-alt-rel.eml b/comm/mail/test/browser/composition/data/content-utf8-alt-rel.eml
new file mode 100644
index 0000000000..ccf457ad2b
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/content-utf8-alt-rel.eml
@@ -0,0 +1,46 @@
+From: test <test@example.com>
+Subject: test multipart, alternative first
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+--------------alternative
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+áóúäöüß
+
+--------------alternative
+Content-Type: multipart/related;
+ boundary="------------related"
+
+
+--------------related
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+<html attr>
+ <!-- This also needs to work when the html tag has an attribute -->
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ áóúäöüß<br>
+ <img src="cid:part1" alt=""><br>
+ </body>
+</html>
+
+--------------related
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <part1>
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+--------------related--
+
+--------------alternative--
+
diff --git a/comm/mail/test/browser/composition/data/content-utf8-alt-rel2.eml b/comm/mail/test/browser/composition/data/content-utf8-alt-rel2.eml
new file mode 100644
index 0000000000..0a2df0cd9e
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/content-utf8-alt-rel2.eml
@@ -0,0 +1,46 @@
+From: test <test@example.com>
+Subject: test multipart, alternative first
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+--------------alternative
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+áóúäöüß
+
+--------------alternative
+Content-Type: multipart/related;
+ boundary="------------related"
+
+
+--------------related
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+áóúäöüß<br>
+<html>
+ <!-- This also needs to work when there is content before the html tag :-( -->
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ <img src="cid:part1" alt=""><br>
+ </body>
+</html>
+
+--------------related
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <part1>
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+--------------related--
+
+--------------alternative--
+
diff --git a/comm/mail/test/browser/composition/data/content-utf8-rel-alt.eml b/comm/mail/test/browser/composition/data/content-utf8-rel-alt.eml
new file mode 100644
index 0000000000..3b43154516
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/content-utf8-rel-alt.eml
@@ -0,0 +1,40 @@
+From: test <test@example.com>
+Subject: test multipart, related first
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/related;
+ boundary="------------related"
+
+--------------related
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+áóúäöüß
+
+--------------alternative
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+ <!-- This also needs to work when there is no html tag -->
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ áóúäöüß<br>
+ <img src="cid:part1" alt=""><br>
+ </body>
+
+--------------alternative
+
+--------------related
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <part1>
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+
+--------------related--
diff --git a/comm/mail/test/browser/composition/data/content-utf8-rel-only.eml b/comm/mail/test/browser/composition/data/content-utf8-rel-only.eml
new file mode 100644
index 0000000000..17e773859e
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/content-utf8-rel-only.eml
@@ -0,0 +1,32 @@
+From: test <test@example.com>
+Subject: test HTML related only
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/related;
+ boundary="------------related"
+
+This is a multi-part message in MIME format.
+--------------related
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ áóúäöüß<br>
+ <img src="cid:part1" id="cidImage" width="10" height="10" alt="">
+ <img src="cid:part1" crossorigin="anonymous" id="cidImageOrigin" width="20" height="20" alt="">
+ </body>
+</html>
+
+--------------related
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <part1>
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+--------------related--
diff --git a/comm/mail/test/browser/composition/data/defective-charset.eml b/comm/mail/test/browser/composition/data/defective-charset.eml
new file mode 100644
index 0000000000..7b5c431294
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/defective-charset.eml
@@ -0,0 +1,28 @@
+To: me@example.com
+From: you@example.com
+Subject: Test message with Spanish text, no encoding, is windows-1250 (as detected)
+Date: Tue, 20 Apr 2021 15:20:34 -0500
+MIME-Version: 1.0
+Content-Type: text/plain;
+Content-Transfer-Encoding: 8bit
+Content-Language: en-US
+
+A lo largo de mi vida he sido testigo de muchas historias, experiencias
+e interpretaciones de la vida. Y me doy cuenta de que lo que más aprecio
+es la honestidad. En estos últimos meses siento que se ha polarizado aún
+más esta gran diferencias: las personas que son honestas consigo mismas,
+incluso en su deshonestidad, y las que no. Las personas honestas son
+aquellas dueñas y responsables de su vida. Que no buscan una autoridad
+externa que les guíe, que les proteja, que les cuide. Las personas
+deshonestas son aquellas que se creen víctimas de la vida, que culpan al
+otro o a los otros de sus “desgracias”. Que buscan fuera lo que solo
+pueden encontrar dentro. A lo largo de mi vida he sido testigo de muchas
+historias, experiencias e interpretaciones de la vida. Y me doy cuenta
+de que lo que más aprecio es la honestidad. En estos últimos meses
+siento que se ha polarizado aún más esta gran diferencias: las personas
+que son honestas consigo mismas, incluso en su deshonestidad, y las que
+no. Las personas honestas son aquellas dueñas y responsables de su vida.
+Que no buscan una autoridad externa que les guíe, que les proteja, que
+les cuide. Las personas deshonestas son aquellas que se creen víctimas
+de la vida, que culpan al otro o a los otros de sus “desgracias”. Que
+buscan fuera lo que solo pueden encontrar dentro.
diff --git a/comm/mail/test/browser/composition/data/en_NZ/en_NZ.aff b/comm/mail/test/browser/composition/data/en_NZ/en_NZ.aff
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/en_NZ/en_NZ.aff
diff --git a/comm/mail/test/browser/composition/data/en_NZ/en_NZ.dic b/comm/mail/test/browser/composition/data/en_NZ/en_NZ.dic
new file mode 100644
index 0000000000..9c4f5d1758
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/en_NZ/en_NZ.dic
@@ -0,0 +1,23 @@
+23
+aluminium
+an
+and
+boat
+centre
+colour
+harbour
+I
+in
+jewellery
+like
+manoeuvred
+ochre
+of
+shone
+sky
+stars
+the
+The
+to
+was
+went
diff --git a/comm/mail/test/browser/composition/data/evil-meta-msg.eml b/comm/mail/test/browser/composition/data/evil-meta-msg.eml
new file mode 100644
index 0000000000..82de0bac65
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/evil-meta-msg.eml
@@ -0,0 +1,11 @@
+From: Fuzz <fizz@example.org>
+To: contact@example.org
+Subject: test case 17
+Date: Wed, 11 May 2024 14:31:59 +0000
+Content-Type: text/html
+
+<html>
+<meta http-equiv="refresh" content="0;URL='http://localhost:8090'" />
+<meta http-equiv="refresh" content="1;URL='http://localhost:8091'" />
+<div id="demo">KABOOM!</demo>
+<object onerror="alert(1); document.getElementById('demo').innerHTML=parent.JSON.stringify(Object.getOwnPropertyNames(parent));" data="notarealaddress" width="400" height="300"></object>
diff --git a/comm/mail/test/browser/composition/data/feed-message.eml b/comm/mail/test/browser/composition/data/feed-message.eml
new file mode 100644
index 0000000000..66f81a65ff
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/feed-message.eml
@@ -0,0 +1,26 @@
+From - Fri, 27 Jan 2017 00:19:08 GMT
+X-Mozilla-Status: 0041
+X-Mozilla-Status2: 00000000
+X-Mozilla-Keys:
+Received: by localhost; Fri, 27 Jan 2017 07:52:26 +0100
+Date: Fri, 27 Jan 2017 00:19:08 GMT
+Message-Id: <http://www.selenic.com/mercurial/#changeset-c11fe7c58837ebf4266357d1f31896c7cc4a49b9@localhost.localdomain>
+From: john@example.com
+MIME-Version: 1.0
+Subject: Changeset c11fe7c58837ebf4266357d1f31896c7cc4a49b9
+Content-Transfer-Encoding: 8bit
+Content-Base: http://hg.mozilla.org/mozilla-central/rev/c11fe7c58837ebf4266357d1f31896c7cc4a49b9
+Content-Type: text/html; charset=UTF-8
+
+<html>
+ <head>
+ <title>Changeset c11fe7c58837ebf4266357d1f31896c7cc4a49b9</title>
+ <base href="http://hg.mozilla.org/mozilla-central/pushlog">
+ </head>
+ <body id="msgFeedSummaryBody" selected="false">
+ <div xmlns="http://www.w3.org/1999/xhtml">
+ <ul class="filelist"><li class="file">modules/libpref/init/all.js</li><li class="file">netwerk/base/nsIOService.cpp</li><li class="file">netwerk/base/nsIOService.h</li><li class="file">We like using linefeeds only.</li></ul>
+ </div>
+ </body>
+</html>
+
diff --git a/comm/mail/test/browser/composition/data/format-flowed.eml b/comm/mail/test/browser/composition/data/format-flowed.eml
new file mode 100644
index 0000000000..31e77cb465
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/format-flowed.eml
@@ -0,0 +1,12 @@
+To: test1@test.invalid
+From: test2@test.invalid
+Subject: test format flowed reply
+Date: Tue, 27 Sep 2016 13:00:40 +0200
+MIME-Version: 1.0
+Content-Type: text/plain; charset=windows-1252; format=flowed
+Content-Language: en-US
+Content-Transfer-Encoding: 7bit
+
+first first first first first text text text text text text text text
+text text text text text text text text text text text text text text
+last last last last last last last last last last last last last last
diff --git a/comm/mail/test/browser/composition/data/format1-altering.eml b/comm/mail/test/browser/composition/data/format1-altering.eml
new file mode 100644
index 0000000000..b635ef267b
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/format1-altering.eml
@@ -0,0 +1,21 @@
+Message-ID: <11111.11111@example.invalid>
+Date: Sun, 18 May 2014 22:31:12 +0200
+MIME-Version: 1.0
+To: test@test.invalid
+Content-Type: text/html; charset=utf-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <title>title</title>
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ <h1>heading</h1>
+ <hr>
+ <pre>
+ Pre line 1
+ Pre line 2
+ </pre>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/composition/data/format1-plain.eml b/comm/mail/test/browser/composition/data/format1-plain.eml
new file mode 100644
index 0000000000..c39900369c
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/format1-plain.eml
@@ -0,0 +1,22 @@
+Message-ID: <11111.11111@example.invalid>
+Date: Sun, 18 May 2014 22:31:12 +0200
+MIME-Version: 1.0
+To: test@test.invalid
+Content-Type: text/html; charset=utf-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <title>title</title>
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ Line 1<br>
+<p>
+ Line 2
+</p>
+ <pre class="moz-signature" cols="72">--
+ Signature block
+ </pre>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/composition/data/format2-style-attr.eml b/comm/mail/test/browser/composition/data/format2-style-attr.eml
new file mode 100644
index 0000000000..5893b72472
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/format2-style-attr.eml
@@ -0,0 +1,37 @@
+Message-ID: <22222222.2222222@example.invalid>
+Date: Sun, 17 Jun 2012 09:42:45 +0200
+From: John Doe
+MIME-Version: 1.0
+Subject: Testcase - style attribute
+Content-Type: text/html; charset=ISO-8859-1
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+ <meta http-equiv="content-type" content="text/html;
+ charset=ISO-8859-1">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ *** This text is "Variable Width" ***<br>
+ <br>
+ <a title="Get the best mailer now! (Caveat: neglected bird with lots
+ of bugs)" style="float: right; background-color: blue; font-size:
+ 18px; text-decoration: none; color: white; padding-top: 90px;
+ padding-bottom: 90px; padding-right: 50px; padding-left: 50px;"
+ href="http://www.getthunderbird.com">http://www.getthunderbird.com</a><a
+ title="Get the best browser now!" style="float: right;
+ background-color: orangered; font-size: 18px; text-decoration:
+ none; color: white; padding-top: 90px; padding-bottom: 90px;
+ padding-right: 50px; padding-left: 50px;"
+ href="http://www.getfirefox.com">http://www.getfirefox.com</a>Lorem
+ ipsum
+ dolor sit amet, consectetur adipiscing elit. Vestibulum
+ velit purus, egestas eu commodo ac, imperdiet pretium sem. Nulla
+ pulvinar commodo rutrum. Duis feugiat facilisis libero, id fermentum
+ neque molestie vel. Praesent vel nisi metus, a aliquam tellus. Cras
+ in
+ <pre style="color:blue;background-color:yellow;text-align:center;">
+ Vivamus accumsan bibendum arcu nec egestas. Suspendisse potenti.
+ </pre>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/composition/data/format3-style-tag.eml b/comm/mail/test/browser/composition/data/format3-style-tag.eml
new file mode 100644
index 0000000000..b0b27f7ae8
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/format3-style-tag.eml
@@ -0,0 +1,31 @@
+Message-ID: <33333.33333@example.invalid>
+Date: Sun, 17 Jun 2012 09:42:45 +0200
+MIME-Version: 1.0
+Subject: <style> element
+Content-Type: text/html; charset=ISO-8859-1
+Content-Transfer-Encoding: 7bit
+
+<html>
+<head>
+
+<style type="text/css">
+<!--
+body {
+font-size:11.0pt;
+font-family:"Calibri","sans-serif";
+}
+ul, ol, blockquote {
+margin: 0px 0px;
+}
+-->
+</style>
+
+</head><body>
+
+Type text here
+
+<div id="imageholder">
+
+</div>
+</body>
+</html>
diff --git a/comm/mail/test/browser/composition/data/iso-2022-jp.eml b/comm/mail/test/browser/composition/data/iso-2022-jp.eml
new file mode 100644
index 0000000000..79f289cc4a
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/iso-2022-jp.eml
@@ -0,0 +1,12 @@
+To: test1@test.invalid
+From: test2@test.invalid
+Subject: test quote ISO-2022-JP encoded message
+Date: Mon, 17 Aug 2020 10:10:47 +0900
+MIME-Version: 1.0
+Content-Type: text/plain; charset=ISO-2022-JP; format=flowed; delsp=yes
+Content-Transfer-Encoding: 7bit
+Content-Language: en-US
+
+hello
+
+$B@$3&(B
diff --git a/comm/mail/test/browser/composition/data/long-html-line.eml b/comm/mail/test/browser/composition/data/long-html-line.eml
new file mode 100644
index 0000000000..0c3adda83b
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/long-html-line.eml
@@ -0,0 +1,16 @@
+From: "Long line writer" <email@longline.invalid>
+To: <user@example.com>
+Subject: Long HTML line
+Date: Wed, 10 Feb 2016 16:45:36 -0600
+MIME-Version: 1.0
+Content-Type: text/html;
+ charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<!DOCTYPE html>
+<html>
+<body>
+
+We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. We like writing long lines. This is 998 long.
+
+ </body>
diff --git a/comm/mail/test/browser/composition/data/mime-encoded-subject.eml b/comm/mail/test/browser/composition/data/mime-encoded-subject.eml
new file mode 100644
index 0000000000..4b6ff2a263
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/mime-encoded-subject.eml
@@ -0,0 +1,16 @@
+Return-Path: <homer@example.com>
+Received: from smtp.example.com (smtpu [10.0.0.52])
+ by storage (Cyrus v2.3.7-Invoca-RPM-2.3.7-1.1) with LMTPA;
+ Mon, 26 Dec 2011 20:49:16 +0200
+Message-ID: <4EF8C1A5.1060708@example.com>
+Date: Mon, 26 Dec 2011 20:49:09 +0200
+From: Homer <homer@example.com>
+MIME-Version: 1.0
+To: Marge <marge@example.com>
+Subject: =?UTF-8?B?4oiAYeKIikE=?=
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+
+Because they're stupid, that's why. That's why everybody does everything!
+
+ -Homer
diff --git a/comm/mail/test/browser/composition/data/multipart-charset.eml b/comm/mail/test/browser/composition/data/multipart-charset.eml
new file mode 100644
index 0000000000..20827efe54
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/multipart-charset.eml
@@ -0,0 +1,24 @@
+From: test <test@example.com>
+Subject: test multipart mixed, attachment with different charset
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="boundary"
+
+This is a multi-part message in MIME format.
+--boundary
+Content-Type: text/plain; charset=EUC-KR; format=flowed
+Content-Transfer-Encoding: 7bit
+
+Hi there.
+
+--boundary
+Content-Type: text/plain; charset=KOI8-R;
+ name="attachment.tmx"
+Content-Transfer-Encoding: 7bit
+Content-Disposition: attachment;
+ filename="attachment.tmx"
+
+Just some text.
+--boundary--
diff --git a/comm/mail/test/browser/composition/data/nest.png b/comm/mail/test/browser/composition/data/nest.png
new file mode 100644
index 0000000000..5d5c0b2873
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/nest.png
Binary files differ
diff --git a/comm/mail/test/browser/composition/data/non-flowed-plain.eml b/comm/mail/test/browser/composition/data/non-flowed-plain.eml
new file mode 100644
index 0000000000..5f1cd3ebbe
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/non-flowed-plain.eml
@@ -0,0 +1,15 @@
+From: test@example.com
+To: test@example.com
+MIME-Version: 1.0
+Subject:plain text message not using format=flowed
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+line 1
+line 2
+line 3
+line 4
+line 5
+line 6
+line 7
+line 8
diff --git a/comm/mail/test/browser/composition/data/tb-logo.png b/comm/mail/test/browser/composition/data/tb-logo.png
new file mode 100644
index 0000000000..aac56e2546
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/tb-logo.png
Binary files differ
diff --git a/comm/mail/test/browser/composition/data/testmsg.eml b/comm/mail/test/browser/composition/data/testmsg.eml
new file mode 100644
index 0000000000..7aa1127836
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/testmsg.eml
@@ -0,0 +1,16 @@
+Return-Path: <homer@example.com>
+Received: from smtp.example.com (smtpu [10.0.0.52])
+ by storage (Cyrus v2.3.7-Invoca-RPM-2.3.7-1.1) with LMTPA;
+ Mon, 26 Dec 2011 20:49:16 +0200
+Message-ID: <4EF8C1A5.1060708@example.com>
+Date: Mon, 26 Dec 2011 20:49:09 +0200
+From: Homer <homer@example.com>
+MIME-Version: 1.0
+To: Marge <marge@example.com>
+Subject: why
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+
+Because they're stupid, that's why. That's why everybody does everything!
+
+ -Homer
diff --git a/comm/mail/test/browser/composition/data/xunsent.eml b/comm/mail/test/browser/composition/data/xunsent.eml
new file mode 100644
index 0000000000..dab78a0b7f
--- /dev/null
+++ b/comm/mail/test/browser/composition/data/xunsent.eml
@@ -0,0 +1,14 @@
+Message-ID: <b48374f7-d539-4547-b239-7217b8debed9@test>
+Date: Mon, 2 Oct 2023 14:31:45 +0300
+MIME-Version: 1.0
+User-Agent: Thunderbird Daily
+Content-Language: en-US
+X-Unsent: 1
+To: Alice <alice@test>
+From: Carol <carol@test>
+Subject: xx unsent
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0;
+ attachmentreminder=0; deliveryformat=0
+Content-Type: text/plain
+
+This is a draft.
diff --git a/comm/mail/test/browser/composition/head.js b/comm/mail/test/browser/composition/head.js
new file mode 100644
index 0000000000..7b37c05ca0
--- /dev/null
+++ b/comm/mail/test/browser/composition/head.js
@@ -0,0 +1,65 @@
+/* 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 utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+registerCleanupFunction(() => {
+ for (let book of MailServices.ab.directories) {
+ if (
+ ["ldap_2.servers.history", "ldap_2.servers.pab"].includes(book.dirPrefId)
+ ) {
+ let cards = book.childCards;
+ if (cards.length > 0) {
+ info(`Cleaning up ${cards.length} card(s) from ${book.dirName}`);
+ for (let card of cards) {
+ if (card.isMailList) {
+ MailServices.ab.deleteAddressBook(card.mailListURI);
+ }
+ }
+ cards = cards.filter(c => !c.isMailList);
+ if (cards.length > 0) {
+ book.deleteCards(cards);
+ }
+ }
+ is(book.childCards.length, 0);
+ } else {
+ Assert.report(true, undefined, undefined, "Unexpected address book!");
+ MailServices.ab.deleteAddressBook(book.URI);
+ }
+ }
+
+ Services.focus.focusedWindow = window;
+ let mailButton = document.getElementById("mailButton");
+ mailButton.focus();
+ mailButton.blur();
+});
+
+/**
+ * Get the body part of an MIME message.
+ *
+ * @param {string} content - The message content.
+ * @returns {string}
+ */
+function getMessageBody(content) {
+ let separatorIndex = content.indexOf("\r\n\r\n");
+ Assert.equal(content.slice(-2), "\r\n", "Should end with a line break.");
+ return content.slice(separatorIndex + 4, -2);
+}
+
+async function chooseIdentity(win, identityKey) {
+ let popup = win.document.getElementById("msgIdentityPopup");
+ let shownPromise = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ win.document.getElementById("msgIdentity"),
+ {},
+ win
+ );
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ popup.activateItem(popup.querySelector(`[identitykey="${identityKey}"]`));
+ await hiddenPromise;
+}
diff --git a/comm/mail/test/browser/composition/html/linkpreview.html b/comm/mail/test/browser/composition/html/linkpreview.html
new file mode 100644
index 0000000000..5b80bdc59e
--- /dev/null
+++ b/comm/mail/test/browser/composition/html/linkpreview.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html prefix="og: https://ogp.me/ns#">
+<head>
+ <title>OG test</title>
+ <meta charset="UTF-8" />
+ <meta property="og:title" content="Article title" />
+ <meta property="og:url" content="https://www.example.com/?test=true" />
+ <meta property="og:image" content="http://mochi.test:8888/browser/comm/mail/test/browser/content-policy/html/pass.png" />
+ <meta property="og:description" content="Description of test article." />
+</head>
+<body>
+ <p>See <a href="https://ogp.me/">https://ogp.me/</a>
+</body>
+</html>
diff --git a/comm/mail/test/browser/content-policy/browser.ini b/comm/mail/test/browser/content-policy/browser.ini
new file mode 100644
index 0000000000..24490fe53b
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/browser.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+skip-if = debug
+subsuite = thunderbird
+support-files = html/**
+
+[browser_composeMailto.js]
+[browser_dnsPrefetch.js]
+[browser_exposedInContentTabs.js]
+[browser_generalContentPolicy.js]
+skip-if = headless # clipboard doesn't work with headless
+[browser_jsContentPolicy.js]
+[browser_pluginsPolicy.js]
diff --git a/comm/mail/test/browser/content-policy/browser_composeMailto.js b/comm/mail/test/browser/content-policy/browser_composeMailto.js
new file mode 100644
index 0000000000..ceac2b2b8e
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/browser_composeMailto.js
@@ -0,0 +1,119 @@
+/* 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 utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { close_compose_window, wait_for_compose_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var { open_content_tab_with_url } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { input_value } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+var {
+ click_menus_in_sequence,
+ plan_for_modal_dialog,
+ plan_for_new_window,
+ wait_for_modal_dialog,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var folder = null;
+var gMsgNo = 0;
+var gCwc;
+var gNewTab;
+var gPreCount;
+
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/content-policy/html/";
+
+add_task(async function test_openComposeFromMailToLink() {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ // Open a content tab with the mailto link in it.
+ // To open a tab we're going to have to cheat and use tabmail so we can load
+ // in the data of what we want.
+ gPreCount = tabmail.tabContainer.allTabs.length;
+ gNewTab = open_content_tab_with_url(url + "mailtolink.html");
+
+ plan_for_new_window("msgcompose");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#mailtolink",
+ {},
+ gNewTab.browser
+ );
+ gCwc = wait_for_compose_window();
+});
+
+add_task(async function test_checkInsertImage() {
+ // First focus on the editor element
+ gCwc.window.document.getElementById("messageEditor").focus();
+
+ // Now open the image window
+ plan_for_modal_dialog("Mail:image", async function insert_image(mwc) {
+ // Insert the url of the image.
+ let srcloc = mwc.window.document.getElementById("srcInput");
+ srcloc.focus();
+
+ input_value(mwc, url + "pass.png");
+ await new Promise(resolve => setTimeout(resolve));
+
+ let noAlt = mwc.window.document.getElementById("noAltTextRadio");
+ // Don't add alternate text
+ EventUtils.synthesizeMouseAtCenter(noAlt, {}, noAlt.ownerGlobal);
+
+ // Accept the dialog
+ mwc.window.document.querySelector("dialog").acceptDialog();
+ });
+
+ let insertMenu = gCwc.window.document.getElementById("InsertPopupButton");
+ let insertMenuPopup = gCwc.window.document.getElementById("InsertPopup");
+ EventUtils.synthesizeMouseAtCenter(insertMenu, {}, insertMenu.ownerGlobal);
+ await click_menus_in_sequence(insertMenuPopup, [{ id: "InsertImageItem" }]);
+
+ wait_for_modal_dialog();
+ wait_for_window_close();
+
+ // Test that the image load has not been denied
+ let childImages = gCwc.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementsByTagName("img");
+
+ Assert.equal(childImages.length, 1, "Should be one image in the document");
+
+ utils.waitFor(() => childImages[0].complete);
+
+ // Should be the only image, so just check the first.
+ Assert.ok(
+ !childImages[0].matches(":-moz-broken"),
+ "Loading of image in a mailto compose window should not be blocked"
+ );
+ Assert.ok(
+ childImages[0].naturalWidth > 0,
+ "Non blocked image should have naturalWidth"
+ );
+});
+
+add_task(function test_closeComposeWindowAndTab() {
+ close_compose_window(gCwc);
+ let tabmail = mc.window.document.getElementById("tabmail");
+
+ tabmail.closeTab(gNewTab);
+
+ if (tabmail.tabContainer.allTabs.length != gPreCount) {
+ throw new Error("The content tab didn't close");
+ }
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/content-policy/browser_dnsPrefetch.js b/comm/mail/test/browser/content-policy/browser_dnsPrefetch.js
new file mode 100644
index 0000000000..2f29d0fa35
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/browser_dnsPrefetch.js
@@ -0,0 +1,233 @@
+/* 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 purpose of this test is to ensure that dns prefetch is turned off in
+ * the message pane and compose windows. It also checks that dns prefetch is
+ * currently turned off in content tabs, although when bug 545407 is fixed, it
+ * should be turned back on again.
+ */
+
+"use strict";
+
+var composeHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var { open_content_tab_with_url } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var {
+ assert_nothing_selected,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_message_window,
+ create_folder,
+ get_about_3pane,
+ get_about_message,
+ mc,
+ open_selected_message_in_new_window,
+ select_click_row,
+ select_shift_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder = null;
+var gMsgNo = 0;
+var gMsgHdr = null;
+
+// These two constants are used to build the message body.
+var msgBody =
+ '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">\n' +
+ "<html>\n" +
+ "<head>\n" +
+ "\n" +
+ '<meta http-equiv="content-type" content="text/html; charset=ISO-8859-1">\n' +
+ "</head>\n" +
+ '<body bgcolor="#ffffff" text="#000000">\n' +
+ "dns prefetch test message\n" +
+ "</body>\n</html>\n";
+
+add_setup(async function () {
+ folder = await create_folder("dnsPrefetch");
+});
+
+function addToFolder(aSubject, aBody, aFolder) {
+ let msgId = Services.uuid.generateUUID() + "@mozillamessaging.invalid";
+
+ let source =
+ "From - Sat Nov 1 12:39:54 2008\n" +
+ "X-Mozilla-Status: 0001\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "Message-ID: <" +
+ msgId +
+ ">\n" +
+ "Date: Wed, 11 Jun 2008 20:32:02 -0400\n" +
+ "From: Tester <tests@mozillamessaging.invalid>\n" +
+ "User-Agent: Thunderbird 3.0a2pre (Macintosh/2008052122)\n" +
+ "MIME-Version: 1.0\n" +
+ "To: recipient@mozillamessaging.invalid\n" +
+ "Subject: " +
+ aSubject +
+ "\n" +
+ "Content-Type: text/html; charset=ISO-8859-1\n" +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "\n" +
+ aBody +
+ "\n";
+
+ aFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ aFolder.gettingNewMessages = true;
+
+ aFolder.addMessage(source);
+ aFolder.gettingNewMessages = false;
+
+ return aFolder.msgDatabase.getMsgHdrForMessageID(msgId);
+}
+
+function addMsgToFolder(folder) {
+ let msgDbHdr = addToFolder("exposed test message " + gMsgNo, msgBody, folder);
+
+ // select the newly created message
+ gMsgHdr = select_click_row(gMsgNo);
+
+ Assert.equal(
+ msgDbHdr,
+ gMsgHdr,
+ "Should have selected the same message header as the generated header"
+ );
+
+ assert_selected_and_displayed(gMsgNo);
+
+ return gMsgNo++;
+}
+
+/**
+ * Check remote content in a compose window.
+ *
+ * @param test The test from TESTS that is being performed.
+ * @param replyType The type of the compose window, 0 = normal compose,
+ * 1 = reply, 2 = forward.
+ * @param loadAllowed Whether or not the load is expected to be allowed.
+ */
+function checkComposeWindow(replyType) {
+ let errMsg = "";
+ let replyWindow = null;
+ switch (replyType) {
+ case 0:
+ replyWindow = composeHelper.open_compose_new_mail();
+ errMsg = "new mail";
+ break;
+ case 1:
+ replyWindow = composeHelper.open_compose_with_reply();
+ errMsg = "reply";
+ break;
+ case 2:
+ replyWindow = composeHelper.open_compose_with_forward();
+ errMsg = "forward";
+ break;
+ }
+
+ // Check the prefetch in the compose window.
+ Assert.ok(
+ !replyWindow.window.document.getElementById("messageEditor").docShell
+ .allowDNSPrefetch,
+ `Should have disabled DNS prefetch in the compose window (${errMsg})`
+ );
+
+ composeHelper.close_compose_window(replyWindow);
+}
+
+add_task(async function test_dnsPrefetch_message() {
+ // Now we have started up, simply check that DNS prefetch is disabled
+ let aboutMessage = get_about_message();
+ Assert.ok(
+ !aboutMessage.document.getElementById("messagepane").docShell
+ .allowDNSPrefetch,
+ "messagepane should have disabled DNS prefetch at startup"
+ );
+ let about3Pane = get_about_3pane();
+ Assert.ok(
+ !about3Pane.document.getElementById("multiMessageBrowser").docShell
+ .allowDNSPrefetch.allowDNSPrefetch,
+ "multimessagepane should have disabled DNS prefetch at startup"
+ );
+
+ await be_in_folder(folder);
+
+ assert_nothing_selected();
+
+ let firstMsg = addMsgToFolder(folder);
+
+ // Now we've got a message selected, check again.
+ Assert.ok(
+ !aboutMessage.document.getElementById("messagepane").docShell
+ .allowDNSPrefetch,
+ "Should keep DNS Prefetch disabled on messagepane after selecting message"
+ );
+
+ let secondMsg = addMsgToFolder(folder);
+ select_shift_click_row(firstMsg);
+ assert_selected_and_displayed(firstMsg, secondMsg);
+
+ Assert.ok(
+ !about3Pane.document.getElementById("multiMessageBrowser").docShell
+ .allowDNSPrefetch,
+ "Should keep DNS Prefetch disabled on multimessage after selecting message"
+ );
+
+ select_shift_click_row(secondMsg);
+});
+
+add_task(async function test_dnsPrefetch_standaloneMessage() {
+ let msgc = await open_selected_message_in_new_window();
+ assert_selected_and_displayed(msgc, gMsgHdr);
+
+ // Check the docshell.
+ let aboutMessage = get_about_message(msgc.window);
+ Assert.ok(
+ !aboutMessage.document.getElementById("messagepane").docShell
+ .allowDNSPrefetch,
+ "Should disable DNS Prefetch on messagepane in standalone message window."
+ );
+
+ close_message_window(msgc);
+});
+
+add_task(function test_dnsPrefetch_compose() {
+ checkComposeWindow(0);
+ checkComposeWindow(1);
+ checkComposeWindow(2);
+});
+
+add_task(async function test_dnsPrefetch_contentTab() {
+ // To open a tab we're going to have to cheat and use tabmail so we can load
+ // in the data of what we want.
+ let tabmail = mc.window.document.getElementById("tabmail");
+ let preCount = tabmail.tabContainer.allTabs.length;
+
+ let dataurl =
+ "data:text/html,<html><head><title>test dns prefetch</title>" +
+ "</head><body>test dns prefetch</body></html>";
+
+ let newTab = open_content_tab_with_url(dataurl);
+
+ await SpecialPowers.spawn(tabmail.getBrowserForSelectedTab(), [], () => {
+ Assert.ok(docShell, "docShell should be available");
+ Assert.ok(docShell.allowDNSPrefetch, "allowDNSPrefetch should be enabled");
+ });
+
+ tabmail.closeTab(newTab);
+
+ if (tabmail.tabContainer.allTabs.length != preCount) {
+ throw new Error("The content tab didn't close");
+ }
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/content-policy/browser_exposedInContentTabs.js b/comm/mail/test/browser/content-policy/browser_exposedInContentTabs.js
new file mode 100644
index 0000000000..faf6ce975c
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/browser_exposedInContentTabs.js
@@ -0,0 +1,175 @@
+/* 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 purpose of this test is to ensure that remote content can't gain access
+ * to messages by loading their URIs.
+ */
+
+"use strict";
+
+var composeHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var { open_content_tab_with_url } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var {
+ assert_nothing_selected,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folder = null;
+var gMsgNo = 0;
+
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/content-policy/html/";
+
+// These two constants are used to build the message body.
+var msgBody =
+ '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">\n' +
+ "<html>\n" +
+ "<head>\n" +
+ "\n" +
+ '<meta http-equiv="content-type" content="text/html; charset=ISO-8859-1">\n' +
+ "</head>\n" +
+ '<body bgcolor="#ffffff" text="#000000">\n' +
+ '<img id="testelement" src="' +
+ url +
+ 'pass.png"/>\n' +
+ "</body>\n</html>\n";
+
+add_setup(async function () {
+ folder = await create_folder("exposedInContent");
+});
+
+function addToFolder(aSubject, aBody, aFolder) {
+ let msgId = Services.uuid.generateUUID() + "@mozillamessaging.invalid";
+
+ let source =
+ "From - Sat Nov 1 12:39:54 2008\n" +
+ "X-Mozilla-Status: 0001\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "Message-ID: <" +
+ msgId +
+ ">\n" +
+ "Date: Wed, 11 Jun 2008 20:32:02 -0400\n" +
+ "From: Tester <tests@mozillamessaging.invalid>\n" +
+ "User-Agent: Thunderbird 3.0a2pre (Macintosh/2008052122)\n" +
+ "MIME-Version: 1.0\n" +
+ "To: recipient@mozillamessaging.invalid\n" +
+ "Subject: " +
+ aSubject +
+ "\n" +
+ "Content-Type: text/html; charset=ISO-8859-1\n" +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "\n" +
+ aBody +
+ "\n";
+
+ aFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ aFolder.gettingNewMessages = true;
+
+ aFolder.addMessage(source);
+ aFolder.gettingNewMessages = false;
+
+ return aFolder.msgDatabase.getMsgHdrForMessageID(msgId);
+}
+
+function addMsgToFolder(folder) {
+ let msgDbHdr = addToFolder("exposed test message " + gMsgNo, msgBody, folder);
+
+ // select the newly created message
+ let msgHdr = select_click_row(gMsgNo);
+
+ if (msgDbHdr != msgHdr) {
+ throw new Error(
+ "Selected Message Header is not the same as generated header"
+ );
+ }
+
+ assert_selected_and_displayed(gMsgNo);
+
+ ++gMsgNo;
+
+ // We also want to return the url of the message, so save that here.
+ let msgSimpleURL = msgHdr.folder.getUriForMsg(msgHdr);
+
+ let msgService = MailServices.messageServiceFromURI(msgSimpleURL);
+
+ let neckoURL = msgService.getUrlForUri(msgSimpleURL);
+
+ // This is the full url to the message that we want (i.e. passing this to
+ // a browser element or iframe will display it).
+ return neckoURL.spec;
+}
+
+async function checkContentTab(msgURL) {
+ // To open a tab we're going to have to cheat and use tabmail so we can load
+ // in the data of what we want.
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ let dataurl =
+ "data:text/html,<html><head><title>test exposed</title>" +
+ '</head><body><iframe id="msgIframe" src="' +
+ encodeURI(msgURL) +
+ '"/></body></html>';
+
+ let newTab = open_content_tab_with_url(dataurl);
+
+ Assert.notEqual(newTab.browser.currentURI.spec, "about:blank");
+ Assert.equal(newTab.browser.webProgress.isLoadingDocument, false);
+ Assert.equal(
+ newTab.browser.browsingContext.children[0].currentWindowGlobal,
+ null,
+ "Message display/access has been blocked from remote content!"
+ );
+
+ await SpecialPowers.spawn(newTab.browser, [], () => {
+ Assert.equal(
+ content.frames[0].location.href,
+ "about:blank",
+ "Message display/access has been blocked from remote content!"
+ );
+ });
+
+ mc.window.document.getElementById("tabmail").closeTab(newTab);
+
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length !=
+ preCount
+ ) {
+ throw new Error("The content tab didn't close");
+ }
+}
+
+add_task(async function test_exposedInContentTabs() {
+ await be_in_folder(folder);
+
+ assert_nothing_selected();
+
+ // Check for denied in mail
+ let msgURL = addMsgToFolder(folder);
+
+ // Check allowed in content tab
+ await checkContentTab(msgURL);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/content-policy/browser_generalContentPolicy.js b/comm/mail/test/browser/content-policy/browser_generalContentPolicy.js
new file mode 100644
index 0000000000..eacb8fb309
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/browser_generalContentPolicy.js
@@ -0,0 +1,908 @@
+/* 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/. */
+
+/**
+ * Checks various remote content policy workings, including:
+ *
+ * - Images
+ * - Video
+ *
+ * In:
+ *
+ * - Messages
+ * - Reply email compose window
+ * - Forward email compose window
+ * - Content tab
+ * - Feed message
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var {
+ close_compose_window,
+ open_compose_new_mail,
+ open_compose_with_forward,
+ open_compose_with_reply,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { open_content_tab_with_url } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var {
+ assert_nothing_selected,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_message_window,
+ create_folder,
+ get_about_message,
+ mc,
+ open_message_from_file,
+ open_selected_message,
+ plan_for_message_display,
+ select_click_row,
+ set_open_message_behavior,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { input_value } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+var {
+ get_notification_button,
+ wait_for_notification_to_show,
+ wait_for_notification_to_stop,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var {
+ async_plan_for_new_window,
+ click_menus_in_sequence,
+ plan_for_modal_dialog,
+ wait_for_modal_dialog,
+ wait_for_new_window,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folder = null;
+var gMsgNo = -1; // msg index in folder
+
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/content-policy/html/";
+
+/**
+ * The TESTS array is constructed from objects containing the following:
+ *
+ * type: The type of the test being run.
+ * body: The html to be inserted into the body of the message under
+ * test. Note: the element under test for content
+ * allowed/disallowed should have id 'testelement'.
+ * webPage: The web page to load during the content tab part of the
+ * test.
+ * checkForAllowed: A function that is passed the element with id 'testelement'
+ * to check for remote content being allowed/disallowed.
+ * This function should return true if remote content was
+ * allowed, false otherwise.
+ */
+var TESTS = [
+ {
+ type: "Iframe-Image",
+ description: "iframe img served over http should be blocked",
+ shouldBeBlocked: true,
+
+ // Blocked from showing by other means. Network request can happen.
+ neverAllowed: true,
+ body: `<iframe id='testelement' src='${url}remoteimage.html' />\n`,
+ checkForAllowed: async element => {
+ await new Promise(window.requestAnimationFrame);
+ return element.contentDocument.readyState != "uninitialized";
+ },
+ },
+ {
+ type: "Iframe-datauri-Image",
+ description: "iframe datauri img served over http should be blocked",
+ shouldBeBlocked: true,
+
+ // Blocked by other means. MsgContentPolicy accepts the iframe load since
+ // data: is not a mailnews url. No blocked content notification will show.
+ neverAllowed: true,
+ body: `<iframe id='testelement' src='data:text/html,<html><p>data uri iframe with pic</p><img src='${url}pass.png' /></html>\n`,
+ checkForAllowed: async element => {
+ await new Promise(window.requestAnimationFrame);
+ return element.contentDocument.readyState != "uninitialized";
+ },
+ },
+ {
+ type: "Image",
+ description: "img served over http should be blocked",
+ shouldBeBlocked: true,
+ checkRemoteImg: true,
+ body: '<img id="testelement" src="' + url + 'pass.png"/>\n',
+ webPage: "remoteimage.html",
+ checkForAllowed: async element => {
+ await new Promise(window.requestAnimationFrame);
+ return !element.matches(":-moz-broken") && element.naturalWidth > 0;
+ },
+ checkForAllowedRemote: () => {
+ let element = content.document.getElementById("testelement");
+ return !element.matches(":-moz-broken") && element.naturalWidth > 0;
+ },
+ },
+ {
+ type: "Video",
+ description: "video served over http should be blocked",
+ shouldBeBlocked: true,
+ body: '<video id="testelement" src="' + url + 'video.ogv"/>\n',
+ webPage: "remotevideo.html",
+ checkForAllowed: async element => {
+ await new Promise(window.requestAnimationFrame);
+ return element.networkState != element.NETWORK_NO_SOURCE;
+ },
+ checkForAllowedRemote: () => {
+ let element = content.document.getElementById("testelement");
+ return element.networkState != element.NETWORK_NO_SOURCE;
+ },
+ },
+ {
+ type: "Image-Data",
+ description: "img from data url should be allowed",
+ shouldBeBlocked: false,
+ body: '<img id="testelement" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAC4UlEQVR42o2UW0gUURzGZ7Wa3fW2667XF/OyPQSmmZaZEYWRFN1QEYxeCi3CCCmxl6CHEMzQyAi1IAktLxSipkJghl0swswShbxFD+Hiquvqus7OzNf5T7uhslsN/OCc//f9vz17zpzhOM+PD2Mjg2doXPCumg/3H4+KzLcu++yvL+WeNZZxUy3lnEzQmGqkuQJVfwvRPrqhutd6Z4Mw+mY75qZzIcvFCjSmGmnkIa+nMFXmHs7YXK4aet0UhiXbGUjLpyHMZWLFnK5AY6qRRh7yUs/6MP5xmW/Hh44oQC6CuHAQw28TMdCzDc7ZNAX3nDTykLe+1LfNtXe/N7aiWHOss1Yji0IhpMUDEK2pOHdSj1MZgcp4/VzxMC/1lF/RHHEfAP/kprp7fCgFsjMP0tIuhey9/jiUqPY6Jy/1sN5O96oC2u/yM7b5PCZmQBZ2KhSfNeJ8jt7rnLzU01bFmymDgvTPazSiJF2CLLFfklIUrHNJsHw3QZiOwvJUGMyDBvz8qMficADsY0bYx0PhXMlGZ7XGQRkUFNz5wE90ChfYJrIgMRFOSwyWx4JhHwn0zqgeov04uu5rBcpQVtRd4z9vs+axE4mHgwU4vun/ifAjCgvmDHRXB1jcKwpoqdC9m/icAMmWiuprfqi6qsVEHzNPGtdANdLIQ96JwXg0V+j63HvEl+TrCnqbjLLI/vPiZDSKctUw+XF/OKrmkMVzKNzEKRp5yEs9JQW6fPep0TsQ/vR2yPuhVyZAyoFkNmGkJwS11wPxIi4OX9PYC8nojo5WNPKQt6XS2E+9qy8yH7dZm9JVGzo10BMDu+0EYN8HeXYr6vy0mI+MVGgIClK0Ty9jQV7qWf1muy+sPyPhYWloR29TmDjxZQesM4fREBEBISubkaWMSSMPeV09Ko+3nxFTkGu42FwZ3t9TF2pp321CY2ycQmvSFgdp5PF2+9d8jxgGhomRzEh3keyqGTx9j34B1t40GMHNFqwAAAAASUVORK5CYII="/>\n',
+ webPage: "remoteimagedata.html",
+ checkForAllowed: async element => {
+ await new Promise(window.requestAnimationFrame);
+ return !element.matches(":-moz-broken") && element.naturalWidth > 0;
+ },
+ checkForAllowedRemote: () => {
+ let element = content.document.getElementById("testelement");
+ return !element.matches(":-moz-broken") && element.naturalWidth > 0;
+ },
+ },
+ {
+ type: "Iframe-srcdoc-Image",
+ description: "iframe srcdoc img served over http should be blocked",
+ shouldBeBlocked: true,
+
+ body: `<html><iframe id='testelement' srcdoc='<html><img src="${url}pass.png" alt="pichere"/>'></html>`,
+ checkForAllowed: async element => {
+ if (element.contentDocument.readyState != "complete") {
+ await new Promise(resolve => element.addEventListener("load", resolve));
+ }
+ let img = element.contentDocument.querySelector("img");
+ return img && !img.matches(":-moz-broken") && img.naturalWidth > 0;
+ },
+ },
+];
+
+// TESTS = [TESTS[0]]; // To test single tests.
+
+// These two constants are used to build the message body.
+var msgBodyStart =
+ '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">\n' +
+ "<html>\n" +
+ "<head>\n" +
+ "\n" +
+ '<meta http-equiv="content-type" content="text/html; charset=ISO-8859-1">\n' +
+ "</head>\n" +
+ '<body bgcolor="#ffffff" text="#000000">\n';
+
+var msgBodyEnd = "</body>\n</html>\n";
+
+add_setup(async () => {
+ requestLongerTimeout(3);
+ folder = await create_folder("generalContentPolicy");
+ Assert.ok(folder, "folder should be set up");
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ registerCleanupFunction(() => {
+ folder.deleteSelf(null);
+ });
+});
+
+// We can't call it test since that it would be run as subtest.
+function checkPermission(aURI) {
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ aURI,
+ {}
+ );
+ return Services.perms.testPermissionFromPrincipal(principal, "image");
+}
+
+function addPermission(aURI, aAllowDeny) {
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ aURI,
+ {}
+ );
+ return Services.perms.addFromPrincipal(principal, "image", aAllowDeny);
+}
+
+function removePermission(aURI) {
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ aURI,
+ {}
+ );
+ return Services.perms.removeFromPrincipal(principal, "image");
+}
+
+function addToFolder(aSubject, aBody, aFolder) {
+ let msgId = Services.uuid.generateUUID() + "@mozillamessaging.invalid";
+
+ gMsgNo++;
+ let source =
+ "From - Sat Nov 1 12:39:54 2008\n" +
+ "X-Mozilla-Status: 0001\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "Message-ID: <" +
+ msgId +
+ ">\n" +
+ "Date: Wed, 11 Jun 2008 20:32:02 -0400\n" +
+ "From: Tester <tests@mozillamessaging.invalid>\n" +
+ "User-Agent: Thunderbird 3.0a2pre (Macintosh/2008052122)\n" +
+ "MIME-Version: 1.0\n" +
+ "To: recipient@mozillamessaging.invalid\n" +
+ "Subject: " +
+ aSubject +
+ " #" +
+ gMsgNo +
+ "\n" +
+ "Content-Type: text/html; charset=ISO-8859-1\n" +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "\n" +
+ aBody +
+ "\n";
+
+ aFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ aFolder.gettingNewMessages = true;
+
+ aFolder.addMessage(source);
+ aFolder.gettingNewMessages = false;
+
+ return aFolder.msgDatabase.getMsgHdrForMessageID(msgId);
+}
+
+async function addMsgToFolderAndCheckContent(folder, test) {
+ info(`Checking msg in folder; test=${test.type}`);
+ let msgDbHdr = addToFolder(
+ test.type + " test message ",
+ msgBodyStart + test.body + msgBodyEnd,
+ folder
+ );
+
+ // select the newly created message
+ let msgHdr = select_click_row(gMsgNo);
+
+ if (msgDbHdr != msgHdr) {
+ throw new Error(
+ "Selected Message Header is not the same as generated header"
+ );
+ }
+
+ assert_selected_and_displayed(gMsgNo);
+
+ // Now check that the content hasn't been loaded
+ let messageDocument =
+ get_about_message().getMessagePaneBrowser().contentDocument;
+ let testelement = messageDocument.getElementById("testelement");
+ Assert.ok(testelement, "testelement should be found");
+ if (test.shouldBeBlocked) {
+ if (await test.checkForAllowed(testelement)) {
+ throw new Error(
+ test.type + " has not been blocked in message content as expected."
+ );
+ }
+ } else if (!(await test.checkForAllowed(testelement))) {
+ throw new Error(
+ test.type + " has been unexpectedly blocked in message content."
+ );
+ }
+}
+
+/**
+ * Check remote content in a compose window.
+ *
+ * @param test The test from TESTS that is being performed.
+ * @param replyType The type of the compose window, set to true for "reply",
+ * false for "forward".
+ * @param loadAllowed Whether or not the load is expected to be allowed.
+ */
+async function checkComposeWindow(test, replyType, loadAllowed) {
+ if (loadAllowed && test.neverAllowed) {
+ return;
+ }
+ info(
+ `Checking compose win; replyType=${replyType}, test=${test.type}; shouldLoad=${loadAllowed}`
+ );
+ let replyWindow = replyType
+ ? open_compose_with_reply()
+ : open_compose_with_forward();
+
+ let what =
+ test.description +
+ ": " +
+ test.type +
+ " has not been " +
+ (loadAllowed ? "allowed" : "blocked") +
+ " in reply window as expected.";
+ await TestUtils.waitForCondition(async () => {
+ return (
+ (await test.checkForAllowed(
+ replyWindow.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementById("testelement")
+ )) == loadAllowed
+ );
+ }, what);
+
+ close_compose_window(replyWindow);
+}
+
+/**
+ * Check remote content in stand-alone message window, and reload
+ */
+async function checkStandaloneMessageWindow(test, loadAllowed) {
+ if (loadAllowed && test.neverAllowed) {
+ return;
+ }
+ info(
+ `Checking standalong msg win; test=${test.type}; shouldLoad=${loadAllowed}`
+ );
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ // Open it
+ set_open_message_behavior("NEW_WINDOW");
+ open_selected_message();
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+ if (
+ (await test.checkForAllowed(
+ get_about_message(msgc.window)
+ .getMessagePaneBrowser()
+ .contentDocument.getElementById("testelement")
+ )) != loadAllowed
+ ) {
+ let expected = loadAllowed ? "allowed" : "blocked";
+ throw new Error(
+ `${test.type} was not ${expected} in standalone message content`
+ );
+ }
+
+ // Clean up, close the window
+ close_message_window(msgc);
+}
+
+/**
+ * Check remote content in stand-alone message window loaded from .eml file.
+ * Make sure there's a notification bar.
+ */
+async function checkEMLMessageWindow(test, emlFile) {
+ let msgc = await open_message_from_file(emlFile);
+ let aboutMessage = get_about_message(msgc.window);
+ if (!aboutMessage.document.getElementById("mail-notification-top")) {
+ throw new Error(test.type + " has no content notification bar.");
+ }
+ if (aboutMessage.document.getElementById("mail-notification-top").collapsed) {
+ throw new Error(test.type + " content notification bar not shown.");
+ }
+
+ // Clean up, close the window
+ close_message_window(msgc);
+}
+
+/**
+ * Helper method to save one of the test files as an .eml file.
+ *
+ * @returns the file the message was safed to
+ */
+async function saveAsEMLFile(msgNo) {
+ let msgHdr = select_click_row(msgNo);
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ var file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithFile(profD);
+ file.append("content-policy-test-" + msgNo + ".eml");
+ messenger.saveAs(
+ msgHdr.folder.getUriForMsg(msgHdr),
+ true,
+ null,
+ file.path,
+ true
+ );
+ // no listener for saveAs, though we should add one.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ return file;
+}
+
+async function allowRemoteContentAndCheck(test) {
+ if (test.neverAllowed) {
+ return;
+ }
+ info(`Checking allow remote content; test=${test.type}`);
+ await addMsgToFolderAndCheckContent(folder, test);
+
+ let aboutMessage = get_about_message();
+
+ // Click on the allow remote content button
+ const kBoxId = "mail-notification-top";
+ const kNotificationValue = "remoteContent";
+ wait_for_notification_to_show(aboutMessage, kBoxId, kNotificationValue);
+ let prefButton = get_notification_button(
+ aboutMessage,
+ kBoxId,
+ kNotificationValue,
+ {
+ popup: "remoteContentOptions",
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ prefButton,
+ { clickCount: 1 },
+ aboutMessage
+ );
+ aboutMessage.document
+ .getElementById("remoteContentOptions")
+ .activateItem(
+ aboutMessage.document.getElementById("remoteContentOptionAllowForMsg")
+ );
+ wait_for_notification_to_stop(aboutMessage, kBoxId, kNotificationValue);
+
+ wait_for_message_display_completion(mc, true);
+
+ if (
+ !(await test.checkForAllowed(
+ aboutMessage
+ .getMessagePaneBrowser()
+ .contentDocument.getElementById("testelement")
+ ))
+ ) {
+ throw new Error(
+ test.type + " has been unexpectedly blocked in message content"
+ );
+ }
+}
+
+async function checkContentTab(test) {
+ if (!test.webPage) {
+ return;
+ }
+ // To open a tab we're going to have to cheat and use tabmail so we can load
+ // in the data of what we want.
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ let newTab = open_content_tab_with_url(url + test.webPage);
+
+ if (
+ !(await SpecialPowers.spawn(newTab.browser, [], test.checkForAllowedRemote))
+ ) {
+ throw new Error(
+ test.type + " has been unexpectedly blocked in content tab"
+ );
+ }
+
+ mc.window.document.getElementById("tabmail").closeTab(newTab);
+
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length !=
+ preCount
+ ) {
+ throw new Error("The content tab didn't close");
+ }
+}
+
+/**
+ * Check remote content is not blocked in feed message (flagged with
+ * nsMsgMessageFlags::FeedMsg)
+ */
+async function checkAllowFeedMsg(test) {
+ if (test.neverAllowed) {
+ return;
+ }
+ let msgDbHdr = addToFolder(
+ test.type + " test feed message",
+ msgBodyStart + test.body + msgBodyEnd,
+ folder
+ );
+ msgDbHdr.orFlags(Ci.nsMsgMessageFlags.FeedMsg);
+
+ // select the newly created message
+ let msgHdr = select_click_row(gMsgNo);
+
+ Assert.equal(msgDbHdr, msgHdr);
+ assert_selected_and_displayed(gMsgNo);
+
+ // Now check that the content hasn't been blocked
+ let messageDocument =
+ get_about_message().getMessagePaneBrowser().contentDocument;
+ if (
+ !(await test.checkForAllowed(messageDocument.getElementById("testelement")))
+ ) {
+ throw new Error(
+ test.type + " has been unexpectedly blocked in feed message content."
+ );
+ }
+}
+
+/**
+ * Check remote content is not blocked for a sender with permissions.
+ */
+async function checkAllowForSenderWithPerms(test) {
+ if (test.neverAllowed) {
+ return;
+ }
+ let msgDbHdr = addToFolder(
+ test.type + " priv sender test message ",
+ msgBodyStart + test.body + msgBodyEnd,
+ folder
+ );
+
+ let addresses = MailServices.headerParser.parseEncodedHeader(msgDbHdr.author);
+ let authorEmailAddress = addresses[0].email;
+
+ let uri = Services.io.newURI(
+ "chrome://messenger/content/email=" + authorEmailAddress
+ );
+ addPermission(uri, Services.perms.ALLOW_ACTION);
+ Assert.equal(checkPermission(uri), Services.perms.ALLOW_ACTION);
+
+ // select the newly created message
+ let msgHdr = select_click_row(gMsgNo);
+
+ Assert.equal(msgDbHdr, msgHdr);
+ assert_selected_and_displayed(gMsgNo);
+
+ // Now check that the content hasn't been blocked
+ let messageDocument =
+ get_about_message().getMessagePaneBrowser().contentDocument;
+ if (
+ !(await test.checkForAllowed(messageDocument.getElementById("testelement")))
+ ) {
+ throw new Error(
+ `${test.type} has been unexpectedly blocked for sender=${authorEmailAddress}`
+ );
+ }
+
+ // Clean up after ourselves, and make sure that worked as expected.
+ removePermission(uri);
+ Assert.equal(checkPermission(uri), Services.perms.UNKNOWN_ACTION);
+}
+
+/**
+ * Check remote content is not blocked for a hosts with permissions.
+ */
+async function checkAllowForHostsWithPerms(test) {
+ if (test.neverAllowed) {
+ return;
+ }
+ let msgDbHdr = addToFolder(
+ test.type + " priv host test message ",
+ msgBodyStart + test.body + msgBodyEnd,
+ folder
+ );
+
+ // Select the newly created message.
+ let msgHdr = select_click_row(gMsgNo);
+ Assert.equal(msgDbHdr, msgHdr);
+ assert_selected_and_displayed(gMsgNo);
+
+ let aboutMessage = get_about_message();
+ let messageDocument = aboutMessage.getMessagePaneBrowser().contentDocument;
+ let src = messageDocument.getElementById("testelement").src;
+
+ if (!src.startsWith("http")) {
+ // Just test http in this test.
+ return;
+ }
+
+ let uri = Services.io.newURI(src);
+ addPermission(uri, Services.perms.ALLOW_ACTION);
+ Assert.equal(checkPermission(uri), Services.perms.ALLOW_ACTION);
+
+ // Click back one msg, then the original again, which should now allow loading.
+ select_click_row(gMsgNo - 1);
+ // Select the newly created message.
+ msgHdr = select_click_row(gMsgNo);
+ Assert.equal(msgDbHdr, msgHdr);
+ assert_selected_and_displayed(gMsgNo);
+
+ // Now check that the content hasn't been blocked.
+ messageDocument = aboutMessage.getMessagePaneBrowser().contentDocument;
+ if (
+ !(await test.checkForAllowed(messageDocument.getElementById("testelement")))
+ ) {
+ throw new Error(
+ test.type + " has been unexpectedly blocked for url=" + uri.spec
+ );
+ }
+
+ // Clean up after ourselves, and make sure that worked as expected.
+ removePermission(uri);
+ Assert.equal(checkPermission(uri), Services.perms.UNKNOWN_ACTION);
+}
+
+add_task(async function test_generalContentPolicy() {
+ await be_in_folder(folder);
+
+ assert_nothing_selected();
+
+ for (let i = 0; i < TESTS.length; ++i) {
+ // Check for denied in mail
+ info("Doing test: " + TESTS[i].description + " ...\n");
+ await addMsgToFolderAndCheckContent(folder, TESTS[i]);
+
+ if (TESTS[i].shouldBeBlocked) {
+ // Check denied in reply window
+ await checkComposeWindow(TESTS[i], true, false);
+
+ // Check denied in forward window
+ await checkComposeWindow(TESTS[i], false, false);
+
+ if (TESTS[i].checkRemoteImg) {
+ // Now check that image is visible after site is whitelisted.
+ // Only want to do this for the test case which has the remote image.
+
+ // Add the site to the whitelist.
+ let messageDocument =
+ get_about_message().getMessagePaneBrowser().contentDocument;
+ let src = messageDocument.getElementById("testelement").src;
+
+ let uri = Services.io.newURI(src);
+ addPermission(uri, Services.perms.ALLOW_ACTION);
+ Assert.equal(checkPermission(uri), Services.perms.ALLOW_ACTION);
+
+ // Check allowed in reply window
+ await checkComposeWindow(TESTS[i], true, true);
+
+ // Check allowed in forward window
+ await checkComposeWindow(TESTS[i], false, true);
+
+ // Clean up after ourselves, and make sure that worked as expected.
+ removePermission(uri);
+ Assert.equal(checkPermission(uri), Services.perms.UNKNOWN_ACTION);
+ }
+
+ // Check denied in standalone message window
+ await checkStandaloneMessageWindow(TESTS[i], false);
+
+ // Now allow the remote content and check result
+ await allowRemoteContentAndCheck(TESTS[i]);
+ }
+
+ // Check allowed in reply window
+ await checkComposeWindow(TESTS[i], true, true);
+
+ // Check allowed in forward window
+ await checkComposeWindow(TESTS[i], false, true);
+
+ // Check allowed in standalone message window
+ await checkStandaloneMessageWindow(TESTS[i], true);
+
+ // Check allowed in content tab
+ await checkContentTab(TESTS[i]);
+
+ // Check allowed in a feed message
+ await checkAllowFeedMsg(TESTS[i]);
+
+ // Check per sender privileges.
+ await checkAllowForSenderWithPerms(TESTS[i]);
+
+ // Check per host privileges.
+ await checkAllowForHostsWithPerms(TESTS[i]);
+
+ // Only want to do this for the test case which has the remote image.
+ if (TESTS[i].checkRemoteImg) {
+ let emlFile = await saveAsEMLFile(i);
+ await checkEMLMessageWindow(TESTS[i], emlFile);
+ emlFile.remove(false);
+ }
+ }
+});
+
+/** Test that an image requiring auth won't ask for credentials in compose. */
+add_task(async function test_imgAuth() {
+ addToFolder(
+ `Image auth test - msg`,
+ `${msgBodyStart}<img alt="[401!]" id="401img" src="${url}401.sjs"/>${msgBodyEnd}`,
+ folder
+ );
+
+ // Allow loading remote, to be able to test.
+ Services.prefs.setBoolPref(
+ "mailnews.message_display.disable_remote_image",
+ false
+ );
+
+ // Select the newly created message.
+ await be_in_folder(folder);
+ select_click_row(gMsgNo);
+
+ // Open reply/fwd. If we get a prompt the test will timeout.
+ let rwc = open_compose_with_reply();
+ close_compose_window(rwc);
+
+ let fwc = open_compose_with_forward();
+ close_compose_window(fwc);
+
+ Services.prefs.clearUserPref("mailnews.message_display.disable_remote_image");
+});
+
+/** Make sure remote images work in signatures. */
+add_task(async function test_sigPic() {
+ let identity = MailServices.accounts.allIdentities[0];
+ identity.htmlSigFormat = true;
+ identity.htmlSigText = `Tb remote! <img id='testelement' alt='[sigpic]' src='${url}pass.png' />`;
+
+ let wasAllowed = element => {
+ return !element.matches(":-moz-broken") && element.naturalWidth > 0;
+ };
+
+ be_in_folder(folder);
+ select_click_row(gMsgNo);
+
+ let nwc = open_compose_new_mail();
+ await TestUtils.waitForCondition(async () => {
+ return wasAllowed(
+ nwc.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementById("testelement")
+ );
+ }, "Should allow remote sig in new mail");
+ close_compose_window(nwc);
+
+ let rwc = open_compose_with_reply();
+ await TestUtils.waitForCondition(async () => {
+ return wasAllowed(
+ rwc.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementById("testelement")
+ );
+ }, "Should allow remote sig in reply");
+
+ close_compose_window(rwc);
+
+ identity.htmlSigFormat = false;
+ identity.htmlSigText = "";
+});
+
+// Copied from test-blocked-content.js.
+async function putHTMLOnClipboard(html) {
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+
+ // Register supported data flavors
+ trans.init(null);
+ trans.addDataFlavor("text/html");
+
+ let wapper = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wapper.data = html;
+ trans.setTransferData("text/html", wapper);
+
+ Services.clipboard.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
+ // NOTE: this doesn't seem to work in headless mode.
+}
+
+async function subtest_insertImageIntoReplyForward(aReplyType) {
+ Assert.ok(folder, "folder should be set up");
+ let msgDbHdr = addToFolder(
+ "Test insert image into reply or forward",
+ "Stand by for image insertion ;-)",
+ folder
+ );
+
+ // Select the newly created message.
+ await be_in_folder(folder);
+ let msgHdr = select_click_row(gMsgNo);
+
+ if (msgDbHdr != msgHdr) {
+ throw new Error(
+ "Selected Message Header is not the same as generated header"
+ );
+ }
+
+ assert_selected_and_displayed(gMsgNo);
+
+ let replyWindow = aReplyType
+ ? open_compose_with_reply()
+ : open_compose_with_forward();
+
+ // Now insert the image
+ // (copied from test-compose-mailto.js:test_checkInsertImage()).
+
+ // First focus on the editor element
+ replyWindow.window.document.getElementById("messageEditor").focus();
+
+ // Now open the image window
+ plan_for_modal_dialog("Mail:image", async function insert_image(mwc) {
+ // Insert the url of the image.
+ let srcloc = mwc.window.document.getElementById("srcInput");
+ srcloc.focus();
+
+ input_value(mwc, url + "pass.png");
+
+ // Don't add alternate text
+ let noAlt = mwc.window.document.getElementById("noAltTextRadio");
+ EventUtils.synthesizeMouseAtCenter(noAlt, {}, noAlt.ownerGlobal);
+ await new Promise(resolve => setTimeout(resolve));
+
+ // Accept the dialog
+ mwc.window.document.querySelector("dialog").acceptDialog();
+ });
+
+ let insertMenu =
+ replyWindow.window.document.getElementById("InsertPopupButton");
+ let insertMenuPopup =
+ replyWindow.window.document.getElementById("InsertPopup");
+
+ EventUtils.synthesizeMouseAtCenter(insertMenu, {}, insertMenu.ownerGlobal);
+ await click_menus_in_sequence(insertMenuPopup, [{ id: "InsertImageItem" }]);
+
+ wait_for_modal_dialog();
+ wait_for_window_close();
+ await new Promise(resolve => setTimeout(resolve));
+
+ // Paste an image.
+ try {
+ await putHTMLOnClipboard("<img id='tmp-img' src='" + url + "pass.png' />");
+ } catch (e) {
+ Assert.ok(false, "Paste should have worked: " + e);
+ throw e;
+ }
+
+ // Ctrl+V = Paste
+ EventUtils.synthesizeKey(
+ "v",
+ { shiftKey: false, accelKey: true },
+ replyWindow.window
+ );
+
+ // Now wait for the paste.
+ utils.waitFor(function () {
+ let img = replyWindow.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementById("tmp-img");
+ return img != null && img.complete;
+ }, "Timeout waiting for pasted tmp image to be loaded ok");
+
+ // Test that the image load has not been denied
+ let childImages = replyWindow.window.document
+ .getElementById("messageEditor")
+ .contentDocument.getElementsByTagName("img");
+
+ Assert.equal(childImages.length, 2, "Should have two images in the doc.");
+
+ // Check both images.
+ Assert.ok(
+ !childImages[0].matches(":-moz-broken"),
+ "Loading of image #0 should not be blocked"
+ );
+ Assert.ok(
+ childImages[0].naturalWidth > 0,
+ "Loading of image #0 should not be blocked (and have width)"
+ );
+ Assert.ok(
+ !childImages[1].matches(":-moz-broken"),
+ "Loading of image #1 should not be blocked"
+ );
+ Assert.ok(
+ childImages[1].naturalWidth > 0,
+ "Loading of image #1 should not be blocked (and have width)"
+ );
+
+ close_compose_window(replyWindow);
+}
+
+add_task(async function test_insertImageIntoReply() {
+ await subtest_insertImageIntoReplyForward(true);
+});
+
+add_task(async function test_insertImageIntoForward() {
+ await subtest_insertImageIntoReplyForward(false);
+});
diff --git a/comm/mail/test/browser/content-policy/browser_jsContentPolicy.js b/comm/mail/test/browser/content-policy/browser_jsContentPolicy.js
new file mode 100644
index 0000000000..d5f749c1f6
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/browser_jsContentPolicy.js
@@ -0,0 +1,285 @@
+/* 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/. */
+
+/**
+ * Tests whether JavaScript in a local/remote message works. The test
+ * mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js does the
+ * same thing for feeds.
+ *
+ * @note This assumes an existing local account.
+ */
+
+"use strict";
+
+var {
+ assert_nothing_selected,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ select_click_row,
+ select_none,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var {
+ close_compose_window,
+ open_compose_with_forward,
+ open_compose_with_reply,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+let aboutMessage = get_about_message();
+
+var folder;
+registerCleanupFunction(async () => {
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ folder.deleteSelf(window.msgWindow);
+ await promptPromise;
+
+ Services.focus.focusedWindow = window;
+});
+
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/content-policy/html/";
+
+function addToFolder(aSubject, aBody, aFolder) {
+ let msgId = Services.uuid.generateUUID() + "@mozillamessaging.invalid";
+
+ let source =
+ "From - Sat Nov 1 12:39:54 2008\n" +
+ "X-Mozilla-Status: 0001\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "Message-ID: <" +
+ msgId +
+ ">\n" +
+ "Date: Wed, 11 Jun 2008 20:32:02 -0400\n" +
+ "From: Tester <tests@mozillamessaging.invalid>\n" +
+ "User-Agent: Thunderbird 3.0a2pre (Macintosh/2008052122)\n" +
+ "MIME-Version: 1.0\n" +
+ "To: recipient@mozillamessaging.invalid\n" +
+ "Subject: " +
+ aSubject +
+ "\n" +
+ "Content-Type: text/html; charset=ISO-8859-1\n" +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "Content-Base: " +
+ url +
+ "remote-noscript.html\n" +
+ "\n" +
+ aBody +
+ "\n";
+
+ aFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ aFolder.gettingNewMessages = true;
+
+ aFolder.addMessage(source);
+ aFolder.gettingNewMessages = false;
+
+ return aFolder.msgDatabase.getMsgHdrForMessageID(msgId);
+}
+
+/*
+ * Runs in the browser process via SpecialPowers.spawn to check JavaScript
+ * is disabled.
+ */
+function assertJSDisabled() {
+ Assert.ok(content.location.href);
+ Assert.ok(
+ !content.wrappedJSObject.jsIsTurnedOn,
+ "JS should not be turned on in content."
+ );
+
+ let noscript = content.document.querySelector("noscript");
+ let display = content.getComputedStyle(noscript).display;
+ Assert.equal(display, "inline", "noscript display should be 'inline'");
+}
+
+/*
+ * Runs in the browser process via SpecialPowers.spawn to check JavaScript
+ * is enabled.
+ */
+function assertJSEnabled() {
+ Assert.ok(content.location.href);
+ Assert.ok(
+ content.wrappedJSObject.jsIsTurnedOn,
+ "JS should be turned on in content."
+ );
+
+ let noscript = content.document.querySelector("noscript");
+ let display = content.getComputedStyle(noscript).display;
+ Assert.equal(display, "none", "noscript display should be 'none'");
+}
+
+var jsMsgBody =
+ '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">\n' +
+ "<html>\n" +
+ "<head>\n" +
+ "\n" +
+ '<meta http-equiv="content-type" content="text/html; charset=ISO-8859-1">\n' +
+ "</head>\n" +
+ '<body bgcolor="#ffffff" text="#000000">\n' +
+ "this is a test<big><big><big> stuff\n" +
+ "<br><br>\n" +
+ "</big></big></big>\n" +
+ "<noscript>\n" +
+ "hello, this content is noscript!\n" +
+ "</noscript>\n" +
+ "<script>\n" +
+ "var jsIsTurnedOn = true;\n" +
+ "</script>\n" +
+ "\n" +
+ "</body>\n" +
+ "</html>\n";
+
+var gMsgNo = 0;
+
+var messagePane = aboutMessage.document.getElementById("messagepane");
+
+add_setup(async function () {
+ folder = await create_folder("jsContentPolicy");
+});
+
+/**
+ * Check JavaScript is disabled when loading messages in the message pane.
+ */
+add_task(async function testJsInMail() {
+ await be_in_folder(folder);
+
+ let msgDbHdr = addToFolder("JS test message " + gMsgNo, jsMsgBody, folder);
+
+ // select the newly created message
+ let msgHdr = select_click_row(gMsgNo);
+
+ Assert.equal(
+ msgDbHdr,
+ msgHdr,
+ "selected message header should be the same as generated header"
+ );
+
+ assert_selected_and_displayed(gMsgNo);
+
+ await SpecialPowers.spawn(messagePane, [], assertJSDisabled);
+
+ ++gMsgNo;
+ select_none();
+});
+
+/**
+ * Check JavaScript is enabled when loading local content in the message pane.
+ */
+add_task(async function testJsInNonMessageContent() {
+ let loadedPromise = BrowserTestUtils.browserLoaded(messagePane);
+ MailE10SUtils.loadURI(
+ messagePane,
+ "data:text/html;charset=utf-8,<script>var jsIsTurnedOn%3Dtrue%3B<%2Fscript>bar" +
+ "<noscript><p id='noscript-p'>hey this is noscript</p><%2Fnoscript>"
+ );
+ await loadedPromise;
+
+ await SpecialPowers.spawn(messagePane, [], assertJSEnabled);
+
+ MailE10SUtils.loadURI(messagePane, "about:blank");
+});
+
+/**
+ * Check JavaScript is enabled when loading remote content in the message pane.
+ */
+add_task(async function testJsInRemoteContent() {
+ // load something non-message-like in the message pane
+ let loadedPromise = BrowserTestUtils.browserLoaded(messagePane);
+ MailE10SUtils.loadURI(messagePane, url + "remote-noscript.html");
+ await loadedPromise;
+
+ await SpecialPowers.spawn(messagePane, [], assertJSEnabled);
+
+ MailE10SUtils.loadURI(messagePane, "about:blank");
+});
+
+/**
+ * Check JavaScript is disabled when loading messages in the message pane,
+ * after remote content has been displayed there.
+ */
+add_task(async function testJsInMailAgain() {
+ await be_in_folder(folder);
+
+ let msgDbHdr = addToFolder("JS test message " + gMsgNo, jsMsgBody, folder);
+
+ // select the newly created message
+ let msgHdr = select_click_row(gMsgNo);
+
+ Assert.equal(
+ msgDbHdr,
+ msgHdr,
+ "selected message header should be the same as generated header"
+ );
+
+ assert_selected_and_displayed(gMsgNo);
+
+ await SpecialPowers.spawn(messagePane, [], assertJSDisabled);
+
+ ++gMsgNo;
+ select_none();
+});
+
+/*
+ * Runs in the browser process via SpecialPowers.spawn to check JavaScript
+ * is disabled.
+ */
+function assertJSDisabledInEditor() {
+ Assert.ok(content.location.href);
+ Assert.ok(
+ !content.wrappedJSObject.jsIsTurnedOn,
+ "JS should not be turned on in editor."
+ );
+
+ // <noscript> is not shown in the editor, independent of whether scripts
+ // are on or off. So we can't check that like in assertJSDisabledIn.
+}
+
+/**
+ * Check JavaScript is disabled in the editor.
+ */
+add_task(async function testJsInMailReply() {
+ await be_in_folder(folder);
+
+ var body = jsMsgBody.replace(
+ "</body>",
+ "<img src=x onerror=alert(1)></body>"
+ );
+
+ let msgDbHdr = addToFolder("js msg reply " + gMsgNo, body, folder);
+
+ // select the newly created message
+ let msgHdr = select_click_row(gMsgNo);
+
+ Assert.equal(
+ msgDbHdr,
+ msgHdr,
+ "selected message header should be the same as generated header"
+ );
+
+ assert_selected_and_displayed(gMsgNo);
+
+ await SpecialPowers.spawn(messagePane, [], assertJSDisabledInEditor);
+
+ let replyWin = open_compose_with_reply();
+ // If JavaScript is on, loading the window will actually show an alert(1)
+ // so execution doesn't go further from here.
+ let editor = replyWin.window.document.getElementById("messageEditor");
+ await SpecialPowers.spawn(editor, [], assertJSDisabledInEditor);
+ close_compose_window(replyWin);
+
+ let fwdWin = open_compose_with_forward();
+ editor = fwdWin.window.document.getElementById("messageEditor");
+ await SpecialPowers.spawn(editor, [], assertJSDisabledInEditor);
+ close_compose_window(fwdWin);
+
+ ++gMsgNo;
+ select_none();
+});
diff --git a/comm/mail/test/browser/content-policy/browser_pluginsPolicy.js b/comm/mail/test/browser/content-policy/browser_pluginsPolicy.js
new file mode 100644
index 0000000000..9fcb1bde0c
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/browser_pluginsPolicy.js
@@ -0,0 +1,245 @@
+/* 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/. */
+
+/**
+ * Checks if plugins are enabled in messages correctly or not.
+ * As of bug 1508942, plugins are no longer enabled in any context.
+ */
+
+"use strict";
+
+var { open_content_tab_with_url } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+
+var {
+ assert_nothing_selected,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_message_window,
+ create_folder,
+ get_about_message,
+ mc,
+ open_selected_message,
+ select_click_row,
+ select_none,
+ set_open_message_behavior,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { async_plan_for_new_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+var folder = null;
+var gMsgNo = 0;
+
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/content-policy/html/";
+
+// These two constants are used to build the message body.
+var msgBody =
+ '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">\n' +
+ "<html>\n" +
+ "<head>\n" +
+ "\n" +
+ '<meta http-equiv="content-type" content="text/html; charset=ISO-8859-1">\n' +
+ "</head>\n" +
+ '<body bgcolor="#ffffff" text="#000000">\n' +
+ '<embed id="testelement" type="application/x-test" width="400" height="400" border="1"></embed>\n' +
+ "</body>\n</html>\n";
+
+add_setup(async function () {
+ folder = await create_folder("pluginPolicy");
+});
+
+function addToFolder(aSubject, aBody, aFolder) {
+ let msgId = Services.uuid.generateUUID() + "@mozillamessaging.invalid";
+
+ let source =
+ "From - Sat Nov 1 12:39:54 2008\n" +
+ "X-Mozilla-Status: 0001\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "Message-ID: <" +
+ msgId +
+ ">\n" +
+ "Date: Wed, 11 Jun 2008 20:32:02 -0400\n" +
+ "From: Tester <tests@mozillamessaging.invalid>\n" +
+ "User-Agent: Thunderbird 3.0a2pre (Macintosh/2008052122)\n" +
+ "MIME-Version: 1.0\n" +
+ "To: recipient@mozillamessaging.invalid\n" +
+ "Subject: " +
+ aSubject +
+ "\n" +
+ "Content-Type: text/html; charset=ISO-8859-1\n" +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "\n" +
+ aBody +
+ "\n";
+
+ aFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ aFolder.gettingNewMessages = true;
+
+ aFolder.addMessage(source);
+ aFolder.gettingNewMessages = false;
+
+ return aFolder.msgDatabase.getMsgHdrForMessageID(msgId);
+}
+
+function isPluginLoaded(browser) {
+ return SpecialPowers.spawn(browser, [], () => {
+ let element = content.document.getElementById("testelement");
+
+ try {
+ // if setColor throws, then the plugin isn't running
+ element.setColor("FFFF0000");
+ return true;
+ } catch (ex) {
+ // Any errors and we'll just return false below - they may be expected.
+ }
+ return false;
+ });
+}
+
+async function addMsgToFolderAndCheckContent(loadAllowed) {
+ let msgDbHdr = addToFolder("Plugin test message " + gMsgNo, msgBody, folder);
+
+ // select the newly created message
+ let msgHdr = select_click_row(gMsgNo);
+
+ if (msgDbHdr != msgHdr) {
+ throw new Error(
+ "Selected Message Header is not the same as generated header"
+ );
+ }
+
+ assert_selected_and_displayed(gMsgNo);
+
+ ++gMsgNo;
+
+ // XXX It appears the assert_selected_and_displayed doesn't actually wait
+ // long enough for plugin load. However, I also can't find a way to wait for
+ // long enough in all situations, so this will have to do for now.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // Now check that the content hasn't been loaded
+ if (
+ (await isPluginLoaded(
+ get_about_message().document.getElementById("messagepane")
+ )) != loadAllowed
+ ) {
+ throw new Error(
+ loadAllowed
+ ? "Plugin has been unexpectedly blocked in message content"
+ : "Plugin has not been blocked in message as expected"
+ );
+ }
+}
+
+async function checkStandaloneMessageWindow(loadAllowed) {
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ // Open it
+ set_open_message_behavior("NEW_WINDOW");
+
+ open_selected_message();
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ // XXX It appears the wait_for_message_display_completion doesn't actually
+ // wait long enough for plugin load. However, I also can't find a way to wait
+ // for long enough in all situations, so this will have to do for now.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ let aboutMessage = get_about_message(msgc.window);
+ if (
+ (await isPluginLoaded(
+ aboutMessage.document.getElementById("messagepane")
+ )) != loadAllowed
+ ) {
+ throw new Error(
+ loadAllowed
+ ? "Plugin has been unexpectedly blocked in standalone window"
+ : "Plugin has not been blocked in standalone window as expected"
+ );
+ }
+
+ // Clean up, close the window
+ close_message_window(msgc);
+}
+
+add_task(async function test_3paneWindowDenied() {
+ await be_in_folder(folder);
+
+ assert_nothing_selected();
+
+ await addMsgToFolderAndCheckContent(false);
+});
+
+add_task(async function test_checkPluginsInNonMessageContent() {
+ // Deselect everything so we can load our content
+ select_none();
+
+ // load something non-message-like in the message pane
+ let browser = get_about_message().document.getElementById("messagepane");
+ MailE10SUtils.loadURI(browser, url + "plugin.html");
+ await BrowserTestUtils.browserLoaded(browser);
+
+ if (await isPluginLoaded(browser)) {
+ throw new Error(
+ "Plugin is turned on in content in message pane - it should not be."
+ );
+ }
+});
+
+add_task(async function test_3paneWindowDeniedAgain() {
+ select_click_row(0);
+
+ assert_selected_and_displayed(0);
+
+ let browser = get_about_message().document.getElementById("messagepane");
+ // Now check that the content hasn't been loaded
+ if (await isPluginLoaded(browser)) {
+ throw new Error("Plugin has not been blocked in message as expected");
+ }
+});
+
+add_task(async function test_checkStandaloneMessageWindowDenied() {
+ await checkStandaloneMessageWindow(false);
+});
+
+add_task(async function test_checkContentTab() {
+ // To open a tab we're going to have to cheat and use tabmail so we can load
+ // in the data of what we want.
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ let newTab = open_content_tab_with_url(url + "plugin.html");
+
+ if (await isPluginLoaded(newTab.browser)) {
+ throw new Error("Plugin has been unexpectedly not blocked in content tab");
+ }
+
+ mc.window.document.getElementById("tabmail").closeTab(newTab);
+
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length !=
+ preCount
+ ) {
+ throw new Error("The content tab didn't close");
+ }
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/content-policy/html/401.sjs b/comm/mail/test/browser/content-policy/html/401.sjs
new file mode 100644
index 0000000000..93757fe5a5
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/401.sjs
@@ -0,0 +1,10 @@
+/* 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/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="401test"`);
+ }
+}
diff --git a/comm/mail/test/browser/content-policy/html/mailtolink.html b/comm/mail/test/browser/content-policy/html/mailtolink.html
new file mode 100644
index 0000000000..4027a5248e
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/mailtolink.html
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Mailto Link Test</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <a href="mailto:nobody@mozilla.invalid" id="mailtolink">mailtolink</a>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-policy/html/pass.png b/comm/mail/test/browser/content-policy/html/pass.png
new file mode 100644
index 0000000000..4b0d444cf6
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/pass.png
Binary files differ
diff --git a/comm/mail/test/browser/content-policy/html/plugin.html b/comm/mail/test/browser/content-policy/html/plugin.html
new file mode 100644
index 0000000000..f8a3f4e241
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/plugin.html
@@ -0,0 +1,10 @@
+<html>
+ <head>
+ <title>Plugin Test</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <embed id="testelement" type="application/x-test"
+ style="width:400px; height:400px; margin-top:20px;" border="1">
+ </embed>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-policy/html/remote-noscript.html b/comm/mail/test/browser/content-policy/html/remote-noscript.html
new file mode 100644
index 0000000000..28445bfe11
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/remote-noscript.html
@@ -0,0 +1,19 @@
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>noscript test</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <h1>remote-noscript.html</h1>
+ <p>This content is served over http!</p>
+ <noscript>
+ JavaScript is OFF!
+ </noscript>
+
+ <div id="scripted"></div>
+ <script>
+ var jsIsTurnedOn = true; // variable to check in test
+ document.getElementById("scripted").innerHTML = "JavaScript is ON!";
+ </script>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-policy/html/remoteimage.html b/comm/mail/test/browser/content-policy/html/remoteimage.html
new file mode 100644
index 0000000000..f7a005cf57
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/remoteimage.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Remote Image Test</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <img id="testelement" src="pass.png"/>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-policy/html/remoteimagedata.html b/comm/mail/test/browser/content-policy/html/remoteimagedata.html
new file mode 100644
index 0000000000..aca2427376
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/remoteimagedata.html
@@ -0,0 +1,9 @@
+<html>
+ <head>
+ <title>Remote Image Test</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <img id="testelement" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAC4UlEQVR42o2UW0gUURzGZ7Wa3fW2667XF/OyPQSmmZaZEYWRFN1QEYxeCi3CCCmxl6CHEMzQyAi1IAktLxSipkJghl0swswShbxFD+Hiquvqus7OzNf5T7uhslsN/OCc//f9vz17zpzhOM+PD2Mjg2doXPCumg/3H4+KzLcu++yvL+WeNZZxUy3lnEzQmGqkuQJVfwvRPrqhutd6Z4Mw+mY75qZzIcvFCjSmGmnkIa+nMFXmHs7YXK4aet0UhiXbGUjLpyHMZWLFnK5AY6qRRh7yUs/6MP5xmW/Hh44oQC6CuHAQw28TMdCzDc7ZNAX3nDTykLe+1LfNtXe/N7aiWHOss1Yji0IhpMUDEK2pOHdSj1MZgcp4/VzxMC/1lF/RHHEfAP/kprp7fCgFsjMP0tIuhey9/jiUqPY6Jy/1sN5O96oC2u/yM7b5PCZmQBZ2KhSfNeJ8jt7rnLzU01bFmymDgvTPazSiJF2CLLFfklIUrHNJsHw3QZiOwvJUGMyDBvz8qMficADsY0bYx0PhXMlGZ7XGQRkUFNz5wE90ChfYJrIgMRFOSwyWx4JhHwn0zqgeov04uu5rBcpQVtRd4z9vs+axE4mHgwU4vun/ifAjCgvmDHRXB1jcKwpoqdC9m/icAMmWiuprfqi6qsVEHzNPGtdANdLIQ96JwXg0V+j63HvEl+TrCnqbjLLI/vPiZDSKctUw+XF/OKrmkMVzKNzEKRp5yEs9JQW6fPep0TsQ/vR2yPuhVyZAyoFkNmGkJwS11wPxIi4OX9PYC8nojo5WNPKQt6XS2E+9qy8yH7dZm9JVGzo10BMDu+0EYN8HeXYr6vy0mI+MVGgIClK0Ty9jQV7qWf1muy+sPyPhYWloR29TmDjxZQesM4fREBEBISubkaWMSSMPeV09Ko+3nxFTkGu42FwZ3t9TF2pp321CY2ycQmvSFgdp5PF2+9d8jxgGhomRzEh3keyqGTx9j34B1t40GMHNFqwAAAAASUVORK5CYII="/>
+ (that's smiley-tongue-out.png)
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-policy/html/remotevideo.html b/comm/mail/test/browser/content-policy/html/remotevideo.html
new file mode 100644
index 0000000000..2a4cad15b5
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/remotevideo.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Remote Image Test</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <video id="testelement" src="video.ogv"/>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-policy/html/video.ogv b/comm/mail/test/browser/content-policy/html/video.ogv
new file mode 100644
index 0000000000..61c179447f
--- /dev/null
+++ b/comm/mail/test/browser/content-policy/html/video.ogv
Binary files differ
diff --git a/comm/mail/test/browser/content-tabs/browser.ini b/comm/mail/test/browser/content-tabs/browser.ini
new file mode 100644
index 0000000000..ef654157df
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/browser.ini
@@ -0,0 +1,50 @@
+[DEFAULT]
+prefs =
+ mail.account.account1.server=server1
+ mail.account.account2.identities=id1,id2
+ mail.account.account2.server=server2
+ mail.accountmanager.accounts=account1,account2
+ mail.accountmanager.defaultaccount=account2
+ mail.accountmanager.localfoldersserver=server1
+ mail.identity.id1.fullName=Tinderbox
+ mail.identity.id1.htmlSigFormat=false
+ mail.identity.id1.smtpServer=smtp1
+ mail.identity.id1.useremail=tinderbox@foo.invalid
+ mail.identity.id1.valid=true
+ mail.identity.id2.fullName=Tinderboxpushlog
+ mail.identity.id2.htmlSigFormat=true
+ mail.identity.id2.smtpServer=smtp1
+ mail.identity.id2.useremail=tinderboxpushlog@foo.invalid
+ mail.identity.id2.valid=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server1.hostname=Local_Folders
+ mail.server.server1.type=none
+ mail.server.server1.userName=nobody
+ mail.server.server2.check_new_mail=false
+ mail.server.server2.directory-rel=[ProfD]Mail/tinderbox
+ mail.server.server2.download_on_biff=true
+ mail.server.server2.hostname=tinderbox123
+ mail.server.server2.login_at_startup=false
+ mail.server.server2.name=tinderbox@foo.invalid
+ mail.server.server2.type=pop3
+ mail.server.server2.userName=tinderbox
+ mail.server.server2.whiteListAbURI=
+ mail.shell.checkDefaultClient=false
+ mail.smtp.defaultserver=smtp1
+ mail.smtpserver.smtp1.hostname=tinderbox123
+ mail.smtpserver.smtp1.username=tinderbox
+ mail.smtpservers=smtp1
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+support-files = html/**
+
+[browser_aboutSupport.js]
+[browser_addonsMgr.js]
+[browser_contentTab.js]
+tags = contextmenu
+[browser_installXpi.js]
diff --git a/comm/mail/test/browser/content-tabs/browser_aboutSupport.js b/comm/mail/test/browser/content-tabs/browser_aboutSupport.js
new file mode 100644
index 0000000000..9be3cf0eab
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/browser_aboutSupport.js
@@ -0,0 +1,587 @@
+/* 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 utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { close_compose_window, wait_for_compose_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ assert_content_tab_element_hidden,
+ assert_content_tab_element_visible,
+ assert_content_tab_text_absent,
+ assert_content_tab_text_present,
+ content_tab_e,
+ get_content_tab_element_display,
+ get_element_by_text,
+ open_content_tab_with_click,
+ wait_for_content_tab_element_display,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+
+var { close_tab, mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, plan_for_new_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var warningText = new Map();
+
+add_setup(function () {
+ // The wording of the warning message when private data is being exported
+ // from the about:support page.
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/aboutSupportMail.properties"
+ );
+ // In HTML the warning label and text comprise the textContent of a single element.
+ warningText.set(
+ "text/html",
+ bundle.GetStringFromName("warningLabel") +
+ " " +
+ bundle.GetStringFromName("warningText")
+ );
+ // In plain text the warning label may end up on a separate line so do not match it.
+ warningText.set("text/plain", bundle.GetStringFromName("warningText"));
+});
+
+// After every test we want to close the about:support tab so that failures
+// don't cascade.
+function teardownTest(module) {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+}
+
+/**
+ * Strings found in the about:support HTML or text that should clearly mark the
+ * data as being from about:support.
+ */
+const ABOUT_SUPPORT_STRINGS = [
+ "Application Basics",
+ "Mail and News Accounts",
+ "Add-ons",
+ "Important Modified Preferences",
+ "Graphics",
+ "Accessibility",
+ "Library Versions",
+];
+
+/**
+ * Strings that if found in the about:support text or HTML usually indicate an
+ * error.
+ */
+const ABOUT_SUPPORT_ERROR_STRINGS = new Map([
+ ["text/html", ["undefined", "null"]],
+ ["text/plain", ["undefined"]],
+]);
+
+/*
+ * Helpers
+ */
+
+/**
+ * Opens about:support and waits for it to load.
+ *
+ * @returns the about:support tab.
+ */
+async function open_about_support() {
+ let openAboutSupport = async function () {
+ if (AppConstants.platform == "macosx") {
+ mc.window.document.getElementById("aboutsupport_open").click();
+ } else {
+ // Show menubar so we can click it.
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+ let helpMenu = mc.window.document.getElementById("helpMenu");
+ EventUtils.synthesizeMouseAtCenter(helpMenu, {}, helpMenu.ownerGlobal);
+ await click_menus_in_sequence(
+ mc.window.document.getElementById("menu_HelpPopup"),
+ [{ id: "aboutsupport_open" }]
+ );
+ }
+ };
+ let tab = open_content_tab_with_click(openAboutSupport, "about:support");
+
+ // Make sure L10n is done.
+ let l10nDone = false;
+ tab.browser.contentDocument.l10n.ready.then(
+ () => (l10nDone = true),
+ console.error
+ );
+ utils.waitFor(() => l10nDone, "Timeout waiting for L10n to complete.");
+
+ // We have one variable that's asynchronously populated -- wait for it to be
+ // populated.
+ utils.waitFor(
+ () => tab.browser.contentWindow.gAccountDetails !== undefined,
+ "Timeout waiting for about:support's gAccountDetails to populate."
+ );
+
+ utils.waitFor(
+ () => content_tab_e(tab, "accounts-tbody").children.length > 1,
+ "Accounts sections didn't load."
+ );
+ // The population of the info fields is async, so we must wait until
+ // the last one is done.
+ utils.waitFor(
+ () =>
+ content_tab_e(tab, "intl-osprefs-regionalprefs").textContent.trim() != "",
+ "Regional prefs section didn't load."
+ );
+
+ // Wait an additional half-second for some more localisation caused by
+ // runtime changes to the page.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return tab;
+}
+
+/**
+ * Opens a compose window containing the troubleshooting information.
+ *
+ * @param aTab The about:support tab.
+ */
+function open_send_via_email(aTab) {
+ let button = content_tab_e(aTab, "send-via-email");
+ plan_for_new_window("msgcompose");
+ EventUtils.synthesizeMouseAtCenter(
+ button,
+ { clickCount: 1 },
+ button.ownerGlobal
+ );
+ let cwc = wait_for_compose_window();
+ return cwc;
+}
+
+/**
+ * Find some element marked as private data.
+ */
+function find_private_element(aTab) {
+ // We use the identity name as an example of a private-only element.
+ // It is currently the second td element with class="data-private" in the table.
+ // The content string must be something unique that is not found anywhere else.
+ let elem = aTab.browser.contentDocument.querySelector(
+ "#accounts-table td.data-private~td.data-private"
+ );
+ Assert.ok(elem != null);
+ Assert.ok(elem.textContent.length > 0);
+ Assert.equal(get_content_tab_element_display(aTab, elem), "none");
+ return elem;
+}
+
+/*
+ * Tests
+ */
+
+/**
+ * Test displaying the about:support page. Also perform a couple of basic tests
+ * to check that no major errors have occurred. The basic tests are by no means
+ * comprehensive.
+ */
+add_task(async function test_display_about_support() {
+ let tab = await open_about_support();
+ // Check that the document has a few strings that indicate that we've loaded
+ // the right page.
+ for (let str of ABOUT_SUPPORT_STRINGS) {
+ assert_content_tab_text_present(tab, str);
+ }
+
+ // Check that error strings aren't present anywhere
+ for (let str of ABOUT_SUPPORT_ERROR_STRINGS.get("text/html")) {
+ assert_content_tab_text_absent(tab, str);
+ }
+
+ // Bug 1339436
+ // Test that the tables in the page are all populated with at least one row
+ // in the tbody element.
+ // An exception in the code could cause some to be empty.
+ let tables = tab.browser.contentDocument.querySelectorAll("tbody");
+ let emptyTables = [
+ "graphics-failures-tbody",
+ "graphics-tbody",
+ "locked-prefs-tbody",
+ "sandbox-syscalls-tbody",
+ "crashes-tbody",
+ "processes-tbody",
+ "support-printing-prefs-tbody",
+ "chat-tbody",
+ ]; // some tables may be empty
+ for (let table of tables) {
+ if (!emptyTables.includes(table.id)) {
+ Assert.ok(
+ table.querySelectorAll("tr").length > 0,
+ "Troubleshooting table '" + table.id + "' is empty!"
+ );
+ }
+ }
+
+ // Mozmill uses a user.js file in the profile, so the warning about the file
+ // should be visible here.
+ let userjsElem = tab.browser.contentDocument.getElementById(
+ "prefs-user-js-section"
+ );
+ Assert.ok(userjsElem.hasChildNodes);
+ Assert.ok(
+ tab.browser.contentDocument.defaultView.getComputedStyle(userjsElem)
+ .display == "block"
+ );
+ Assert.ok(
+ tab.browser.contentDocument.defaultView.getComputedStyle(userjsElem)
+ .visibility == "visible"
+ );
+
+ close_tab(tab);
+});
+
+/**
+ * Test that our accounts are displayed in order.
+ */
+add_task(async function test_accounts_in_order() {
+ let tab = await open_about_support();
+ // This is a really simple test and by no means comprehensive -- test that
+ // "account1" appears before "account2" in the HTML content.
+ assert_content_tab_text_present(tab, "account1");
+ assert_content_tab_text_present(tab, "account2");
+ let html = tab.browser.contentDocument.documentElement.innerHTML;
+ if (html.indexOf("account1") > html.indexOf("account2")) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "account1 found after account2 in the HTML page"
+ );
+ }
+ close_tab(tab);
+});
+
+var UNIQUE_ID = "3a9e1694-7115-4237-8b1e-1cabe6e35073";
+
+/**
+ * Test that a modified preference on the whitelist but not on the blacklist
+ * shows up.
+ */
+add_task(async function test_modified_pref_on_whitelist() {
+ const PREFIX = "accessibility.";
+ let prefName = PREFIX + UNIQUE_ID;
+ Services.prefs.setBoolPref(prefName, true);
+ let tab = await open_about_support();
+
+ assert_content_tab_text_present(tab, prefName);
+ close_tab(tab);
+ Services.prefs.clearUserPref(prefName);
+});
+
+/**
+ * Test that a modified preference not on the whitelist doesn't show up.
+ */
+add_task(async function test_modified_pref_not_on_whitelist() {
+ Services.prefs.setBoolPref(UNIQUE_ID, true);
+ let tab = await open_about_support();
+ assert_content_tab_text_absent(tab, UNIQUE_ID);
+ close_tab(tab);
+ Services.prefs.clearUserPref(UNIQUE_ID);
+});
+
+/**
+ * Test that a modified preference on the blacklist doesn't show up.
+ */
+add_task(async function test_modified_pref_on_blacklist() {
+ const PREFIX = "network.proxy.";
+ let prefName = PREFIX + UNIQUE_ID;
+ Services.prefs.setBoolPref(prefName, true);
+ let tab = await open_about_support();
+
+ assert_content_tab_text_absent(tab, prefName);
+ close_tab(tab);
+ Services.prefs.clearUserPref(prefName);
+});
+
+/**
+ * Test that private data isn't displayed by default, and that when it is
+ * displayed, it actually shows up.
+ */
+add_task(async function test_private_data() {
+ let tab = await open_about_support();
+ let checkbox = content_tab_e(tab, "check-show-private-data");
+
+ // We use the profile path and some other element as an example
+ // of a private-only element.
+ let privateElem1 = find_private_element(tab);
+ let privateElem2 = content_tab_e(tab, "profile-dir-box");
+ // We use the profile button as an example of a public element.
+ let publicElem = content_tab_e(tab, "profile-dir-button");
+
+ Assert.ok(
+ !checkbox.checked,
+ "Private data checkbox shouldn't be checked by default"
+ );
+ assert_content_tab_element_visible(tab, publicElem);
+ assert_content_tab_element_hidden(tab, privateElem1);
+ assert_content_tab_element_hidden(tab, privateElem2);
+
+ // Now check the checkbox and see what happens.
+ EventUtils.synthesizeMouseAtCenter(
+ checkbox,
+ { clickCount: 1 },
+ checkbox.ownerGlobal
+ );
+ wait_for_content_tab_element_display(tab, privateElem1);
+ wait_for_content_tab_element_display(tab, privateElem2);
+ close_tab(tab);
+});
+
+/**
+ * Checks if text fragment exists in the document.
+ * If it is a node tree, find the element whole contents is the searched text.
+ * If it is plain text string, just check in text is anywhere in it.
+ *
+ * @param aDocument A node tree or a string of plain text data.
+ * @param aText The text to find in the document.
+ */
+function check_text_in_body(aDocument, aText) {
+ if (typeof aDocument == "object") {
+ return get_element_by_text(aDocument, aText) != null;
+ }
+ return aDocument.includes(aText);
+}
+
+/**
+ * Test (well, sort of) the copy to clipboard function with public data.
+ */
+add_task(async function test_copy_to_clipboard_public() {
+ let tab = await open_about_support();
+ let privateElem = find_private_element(tab);
+ // To avoid destroying the current contents of the clipboard, instead of
+ // actually copying to it, we just retrieve what would have been copied to it
+ let transferable = tab.browser.contentWindow.getClipboardTransferable();
+ for (let flavor of ["text/html", "text/plain"]) {
+ let data = {};
+ transferable.getTransferData(flavor, data);
+ let text = data.value.QueryInterface(Ci.nsISupportsString).data;
+ let contentBody;
+ if (flavor == "text/html") {
+ let parser = new DOMParser();
+ contentBody = parser.parseFromString(text, "text/html").body;
+ } else {
+ contentBody = text;
+ }
+
+ for (let str of ABOUT_SUPPORT_STRINGS) {
+ if (!check_text_in_body(contentBody, str)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Unable to find "${str}" in flavor "${flavor}"`
+ );
+ }
+ }
+
+ for (let str of ABOUT_SUPPORT_ERROR_STRINGS.get(flavor)) {
+ if (check_text_in_body(contentBody, str)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found "${str}" in flavor "${flavor}"`
+ );
+ }
+ }
+
+ // Check that private data isn't in the output.
+ if (check_text_in_body(contentBody, privateElem.textContent)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found private data in flavor "${flavor}"`
+ );
+ }
+ }
+ close_tab(tab);
+});
+
+/**
+ * Test (well, sort of) the copy to clipboard function with private data.
+ */
+add_task(async function test_copy_to_clipboard_private() {
+ let tab = await open_about_support();
+
+ // Display private data.
+ let privateElem = find_private_element(tab);
+ let show = content_tab_e(tab, "check-show-private-data");
+ EventUtils.synthesizeMouseAtCenter(show, { clickCount: 1 }, show.ownerGlobal);
+ wait_for_content_tab_element_display(tab, privateElem);
+
+ // To avoid destroying the current contents of the clipboard, instead of
+ // actually copying to it, we just retrieve what would have been copied to it
+ let transferable = tab.browser.contentWindow.getClipboardTransferable();
+ for (let flavor of ["text/html", "text/plain"]) {
+ let data = {};
+ transferable.getTransferData(flavor, data);
+ let text = data.value.QueryInterface(Ci.nsISupportsString).data;
+ let contentBody;
+ if (flavor == "text/html") {
+ let parser = new DOMParser();
+ contentBody = parser.parseFromString(text, "text/html").body;
+ } else {
+ contentBody = text;
+ }
+
+ for (let str of ABOUT_SUPPORT_STRINGS) {
+ if (!check_text_in_body(contentBody, str)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Unable to find "${str}" in flavor "${flavor}"`
+ );
+ }
+ }
+
+ for (let str of ABOUT_SUPPORT_ERROR_STRINGS.get(flavor)) {
+ if (check_text_in_body(contentBody, str)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found "${str}" in flavor "${flavor}"`
+ );
+ }
+ }
+
+ // Check that private data is in the output.
+ if (!check_text_in_body(contentBody, privateElem.textContent)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Unable to find private data in flavor "${flavor}"`
+ );
+ }
+
+ // Check that the warning text is in the output.
+ if (!check_text_in_body(contentBody, warningText.get(flavor))) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Unable to find warning text in flavor "${flavor}"`
+ );
+ }
+ }
+ close_tab(tab);
+});
+
+/**
+ * Test opening the compose window with public data.
+ */
+add_task(async function test_send_via_email_public() {
+ let tab = await open_about_support();
+ let privateElem = find_private_element(tab);
+
+ let cwc = open_send_via_email(tab);
+
+ let contentBody =
+ cwc.window.document.getElementById("messageEditor").contentDocument.body;
+
+ for (let str of ABOUT_SUPPORT_STRINGS) {
+ if (!check_text_in_body(contentBody, str)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Unable to find "${str}" in compose window`
+ );
+ }
+ }
+
+ for (let str of ABOUT_SUPPORT_ERROR_STRINGS.get("text/html")) {
+ if (check_text_in_body(contentBody, str)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found "${str}" in compose window`
+ );
+ }
+ }
+
+ // Check that private data isn't in the output.
+ if (check_text_in_body(contentBody, privateElem.textContent)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found private data in compose window`
+ );
+ }
+
+ close_compose_window(cwc);
+ close_tab(tab);
+});
+
+/**
+ * Test opening the compose window with private data.
+ */
+add_task(async function test_send_via_email_private() {
+ let tab = await open_about_support();
+
+ // Display private data.
+ let privateElem = find_private_element(tab);
+ let show = content_tab_e(tab, "check-show-private-data");
+ EventUtils.synthesizeMouseAtCenter(show, { clickCount: 1 }, show.ownerGlobal);
+ wait_for_content_tab_element_display(tab, privateElem);
+
+ let cwc = open_send_via_email(tab);
+
+ let contentBody =
+ cwc.window.document.getElementById("messageEditor").contentDocument.body;
+
+ for (let str of ABOUT_SUPPORT_STRINGS) {
+ if (!check_text_in_body(contentBody, str)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Unable to find "${str}" in compose window`
+ );
+ }
+ }
+
+ for (let str of ABOUT_SUPPORT_ERROR_STRINGS.get("text/html")) {
+ if (check_text_in_body(contentBody, str)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found "${str}" in compose window`
+ );
+ }
+ }
+
+ // Check that private data is in the output.
+ if (!check_text_in_body(contentBody, privateElem.textContent)) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "Unable to find private data in compose window"
+ );
+ }
+
+ // Check that the warning text is in the output.
+ if (!check_text_in_body(contentBody, warningText.get("text/html"))) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "Unable to find warning text in compose window"
+ );
+ }
+
+ close_compose_window(cwc);
+ close_tab(tab);
+});
diff --git a/comm/mail/test/browser/content-tabs/browser_addonsMgr.js b/comm/mail/test/browser/content-tabs/browser_addonsMgr.js
new file mode 100644
index 0000000000..411ddb362d
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/browser_addonsMgr.js
@@ -0,0 +1,76 @@
+/* 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 { content_tab_e, wait_for_content_tab_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ click_through_appmenu,
+ plan_for_modal_dialog,
+ wait_for_browser_load,
+ wait_for_modal_dialog,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+add_task(async function test_open_addons_with_url() {
+ mc.window.openAddonsMgr("addons://list/theme");
+ await new Promise(resolve => setTimeout(resolve));
+
+ let tab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ wait_for_content_tab_load(tab, "about:addons", 10000);
+ let categoriesBox = tab.browser.contentDocument.getElementById("categories");
+ Assert.equal(
+ categoriesBox.selectedChild.getAttribute("viewid"),
+ "addons://list/theme",
+ "Themes category should be selected!"
+ );
+
+ mc.window.document.getElementById("tabmail").switchToTab(0); // switch to 3pane
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+});
+
+/**
+ * Bug 1462923
+ * Check if the "Tools->Add-on Options" menu item works and shows our add-on.
+ * This relies on the MozMill extension having optionsURL defined in install.rdf,
+ * however simplistic the preferences XUL document may be.
+ */
+add_task(function test_addon_prefs() {
+ // Open Add-on Options.
+ const subview = click_through_appmenu(
+ [{ id: "appmenu_addons" }],
+ null,
+ mc.window
+ );
+
+ plan_for_modal_dialog("mozmill-prefs", function (controller) {
+ // Add | await new Promise(resolve => setTimeout(resolve, 1000));|
+ // here to see the popup dialog.
+ controller.window.close();
+ });
+
+ // MozMill add-on should be somewhere in the list. When found, click it.
+ let foundAddon = false;
+ for (let item of subview.children) {
+ if (
+ item.tagName == "toolbarbutton" &&
+ item.getAttribute("collapsed") != "true" &&
+ item.label == "MozMill"
+ ) {
+ foundAddon = true;
+ EventUtils.synthesizeMouseAtCenter(item, { clickCount: 1 }, mc.window);
+ break;
+ }
+ }
+ Assert.ok(foundAddon);
+
+ // Wait for the options dialog to open and close.
+ wait_for_modal_dialog();
+ wait_for_window_close();
+}).skip();
diff --git a/comm/mail/test/browser/content-tabs/browser_contentTab.js b/comm/mail/test/browser/content-tabs/browser_contentTab.js
new file mode 100644
index 0000000000..194378e6af
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/browser_contentTab.js
@@ -0,0 +1,170 @@
+/* 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 EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { assert_content_tab_has_favicon, open_content_tab_with_url } =
+ ChromeUtils.import("resource://testing-common/mozmill/ContentTabHelpers.jsm");
+var { assert_element_visible, assert_element_not_visible } = ChromeUtils.import(
+ "resource://testing-common/mozmill/DOMHelpers.jsm"
+);
+
+var { be_in_folder, inboxFolder } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { assert_tab_has_title, close_popup, mc, wait_for_popup_to_open } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/content-tabs/html/";
+var whatsUrl = url + "whatsnew.html";
+
+add_task(async function test_content_tab_open() {
+ // Need to open the thread pane to load the appropriate context menus.
+ await be_in_folder(inboxFolder);
+ let tab = open_content_tab_with_url(whatsUrl);
+
+ assert_tab_has_title(tab, "What's New Content Test");
+ // Check the location of the what's new image, this is via the link element
+ // and therefore should be set and not favicon.png.
+ // assert_content_tab_has_favicon(tab, url + "whatsnew.png");
+
+ // Check that window.content is set up correctly wrt content-primary and
+ // content-targetable.
+ if (tab.browser.currentURI.spec != whatsUrl) {
+ throw new Error(
+ 'window.content is not set to the url loaded, incorrect type="..."?'
+ );
+ }
+
+ tab.browser.focus();
+});
+
+/**
+ * Just make sure that the context menu does what we expect in content tabs wrt.
+ * spell checking options.
+ */
+add_task(async function test_spellcheck_in_content_tabs() {
+ let tabmail = mc.window.document.getElementById("tabmail");
+
+ // Test a few random items
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "textarea",
+ {},
+ tabmail.selectedTab.browser
+ );
+ // Bug 364914 causes textareas to not be spell checked until they have been
+ // focused at last once, so give the event loop a chance to spin.
+ // Since bug 1370754 the inline spell checker waits 1 second, so let's
+ // wait 2 seconds to be on the safe side.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "textarea",
+ { type: "contextmenu" },
+ tabmail.selectedTab.browser
+ );
+ let browserContext = mc.window.document.getElementById("browserContext");
+ await wait_for_popup_to_open(browserContext);
+ assert_element_visible("browserContext-spell-dictionaries");
+ assert_element_visible("browserContext-spell-check-enabled");
+ await close_popup(mc, browserContext);
+
+ // Different test
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "body > :first-child",
+ { type: "contextmenu" },
+ tabmail.selectedTab.browser
+ );
+ await wait_for_popup_to_open(browserContext);
+ assert_element_not_visible("browserContext-spell-dictionaries");
+ assert_element_not_visible("browserContext-spell-check-enabled");
+ await close_popup(mc, browserContext);
+
+ // Right-click on "zombocom" and add to dictionary
+ BrowserTestUtils.synthesizeMouse(
+ "textarea",
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ tabmail.selectedTab.browser
+ );
+ await wait_for_popup_to_open(browserContext);
+ let suggestions =
+ mc.window.document.getElementsByClassName("spell-suggestion");
+ Assert.ok(suggestions.length > 0, "What, is zombocom a registered word now?");
+ let addToDict = mc.window.document.getElementById(
+ "browserContext-spell-add-to-dictionary"
+ );
+ if (AppConstants.platform == "macosx") {
+ // We need to use click() since the synthesizeMouseAtCenter doesn't work for
+ // context menu items on macos.
+ addToDict.click();
+ } else {
+ EventUtils.synthesizeMouseAtCenter(addToDict, {}, addToDict.ownerGlobal);
+ }
+ await close_popup(mc, browserContext);
+
+ // Now check we don't have any suggestionss
+ BrowserTestUtils.synthesizeMouse(
+ "textarea",
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ tabmail.selectedTab.browser
+ );
+ await wait_for_popup_to_open(browserContext);
+ suggestions = mc.window.document.getElementsByClassName("spell-suggestion");
+ Assert.ok(suggestions.length == 0, "But I just taught you this word!");
+ await close_popup(mc, browserContext);
+});
+
+add_task(function test_content_tab_default_favicon() {
+ const whatsUrl2 = url + "whatsnew1.html";
+ let tab = open_content_tab_with_url(whatsUrl2);
+
+ assert_tab_has_title(tab, "What's New Content Test 1");
+ // Check the location of the favicon, this should be the site favicon in this
+ // test.
+ assert_content_tab_has_favicon(tab, "http://mochi.test:8888/favicon.ico");
+});
+
+add_task(async function test_content_tab_onbeforeunload() {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ let count = tabmail.tabContainer.allTabs.length;
+ let tab = tabmail.tabInfo[count - 1];
+ await SpecialPowers.spawn(tab.browser, [], () => {
+ content.addEventListener("beforeunload", function (event) {
+ event.returnValue = "Green llama in your car";
+ });
+ });
+
+ const interactionPref = "dom.require_user_interaction_for_beforeunload";
+ Services.prefs.setBoolPref(interactionPref, false);
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ tabmail.closeTab(tab);
+ await dialogPromise;
+
+ Services.prefs.clearUserPref(interactionPref);
+});
+
+// XXX todo
+// - test find bar
+// - window.close within tab
+// - zoom?
+
+registerCleanupFunction(function () {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(1);
+ }
+});
diff --git a/comm/mail/test/browser/content-tabs/browser_installXpi.js b/comm/mail/test/browser/content-tabs/browser_installXpi.js
new file mode 100644
index 0000000000..e90b18bca4
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/browser_installXpi.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 utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { open_content_tab_with_url } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/content-tabs/html/";
+
+var gDocument;
+var gNewTab;
+
+add_setup(function () {
+ gDocument = mc.window.document;
+ gNewTab = open_content_tab_with_url(url + "installxpi.html");
+});
+
+registerCleanupFunction(function () {
+ mc.window.document.getElementById("tabmail").closeTab(gNewTab);
+});
+
+async function waitForNotification(id, buttonToClickSelector, callback) {
+ let notificationSelector = `#notification-popup > #${id}-notification`;
+ let notification;
+ utils.waitFor(() => {
+ notification = gDocument.querySelector(notificationSelector);
+ return notification && !notification.hidden;
+ });
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ if (callback) {
+ callback();
+ }
+ if (buttonToClickSelector) {
+ let button = notification.querySelector(buttonToClickSelector);
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, mc.window);
+ }
+ utils.waitFor(() => !gDocument.querySelector(notificationSelector));
+}
+
+add_task(async function test_install_corrupt_xpi() {
+ // This install with give us a corrupt xpi warning.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#corruptlink",
+ {},
+ gNewTab.browser
+ );
+ await waitForNotification(
+ "addon-install-blocked",
+ ".popup-notification-primary-button"
+ );
+ await waitForNotification(
+ "addon-install-failed",
+ ".popup-notification-primary-button"
+ );
+});
+
+add_task(async function test_install_xpi_offer() {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#installlink",
+ {},
+ gNewTab.browser
+ );
+ await waitForNotification(
+ "addon-install-blocked",
+ ".popup-notification-primary-button"
+ );
+ await waitForNotification(
+ "addon-install-failed",
+ ".popup-notification-primary-button"
+ );
+});
+
+add_task(async function test_xpinstall_disabled() {
+ Services.prefs.setBoolPref("xpinstall.enabled", false);
+
+ // Try installation again - this time we'll get an install has been disabled message.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#installlink",
+ {},
+ gNewTab.browser
+ );
+ await waitForNotification(
+ "xpinstall-disabled",
+ ".popup-notification-secondary-button"
+ );
+
+ Services.prefs.clearUserPref("xpinstall.enabled");
+});
+
+add_task(async function test_xpinstall_actually_install() {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#installlink",
+ {},
+ gNewTab.browser
+ );
+ await waitForNotification(
+ "addon-install-blocked",
+ ".popup-notification-primary-button"
+ );
+ await waitForNotification(
+ "addon-install-failed",
+ ".popup-notification-primary-button"
+ );
+});
+
+add_task(async function test_xpinstall_webext_actually_install() {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#installwebextlink",
+ {},
+ gNewTab.browser
+ );
+ await waitForNotification(
+ "addon-install-blocked",
+ ".popup-notification-primary-button"
+ );
+ await waitForNotification("addon-progress");
+ await waitForNotification(
+ "addon-webext-permissions",
+ ".popup-notification-primary-button",
+ () => {
+ let permission = gDocument.getElementById(
+ "addon-webext-perm-single-entry"
+ );
+ Assert.ok(!permission.hidden);
+ }
+ );
+ await waitForNotification(
+ "addon-installed",
+ ".popup-notification-primary-button"
+ );
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/content-tabs/html/blocklist.xml b/comm/mail/test/browser/content-tabs/html/blocklist.xml
new file mode 100644
index 0000000000..cc0a0c69df
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/blocklist.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="Test Plug-in"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/comm/mail/test/browser/content-tabs/html/blocklistHard.xml b/comm/mail/test/browser/content-tabs/html/blocklistHard.xml
new file mode 100644
index 0000000000..daeb1dc935
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/blocklistHard.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem blockID="p9999">
+ <match name="name" exp="Test Plug-in"/>
+ <versionRange severity="2"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/comm/mail/test/browser/content-tabs/html/blocklist_details.html b/comm/mail/test/browser/content-tabs/html/blocklist_details.html
new file mode 100644
index 0000000000..8d86ac8028
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/blocklist_details.html
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Plugin Blocklist Details</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <h1>Plugin Blocklist Details Page</h1>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-tabs/html/corrupt.xpi b/comm/mail/test/browser/content-tabs/html/corrupt.xpi
new file mode 100644
index 0000000000..0d9fb59d08
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/corrupt.xpi
Binary files differ
diff --git a/comm/mail/test/browser/content-tabs/html/dummy.xml b/comm/mail/test/browser/content-tabs/html/dummy.xml
new file mode 100644
index 0000000000..0261794f8a
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/dummy.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+</blocklist>
diff --git a/comm/mail/test/browser/content-tabs/html/favicon.ico b/comm/mail/test/browser/content-tabs/html/favicon.ico
new file mode 100644
index 0000000000..0815759685
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/favicon.ico
Binary files differ
diff --git a/comm/mail/test/browser/content-tabs/html/installxpi.html b/comm/mail/test/browser/content-tabs/html/installxpi.html
new file mode 100644
index 0000000000..51679600a1
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/installxpi.html
@@ -0,0 +1,13 @@
+<html>
+ <head>
+ <title>Test xpi installation</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <h1>Test xpi installation</h1>
+
+ <a id="corruptlink" href="corrupt.xpi">Install this corrupt xpi</a>
+ <a id="installlink" href="installxpi.xpi">Install this xpi</a>
+ <a id="installwebextlink" href="webextension.xpi">Install this webextension xpi</a>
+
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-tabs/html/installxpi.xpi b/comm/mail/test/browser/content-tabs/html/installxpi.xpi
new file mode 100644
index 0000000000..854e1bafdf
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/installxpi.xpi
Binary files differ
diff --git a/comm/mail/test/browser/content-tabs/html/plugin.html b/comm/mail/test/browser/content-tabs/html/plugin.html
new file mode 100644
index 0000000000..83c83bb4b7
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/plugin.html
@@ -0,0 +1,9 @@
+<html>
+ <head>
+ <title>Plugin Test</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <h1>Plugin Test</h1>
+ <embed id="test-plugin" type="application/x-test" width="500" height="500"></embed>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-tabs/html/plugin_crashed_help.html b/comm/mail/test/browser/content-tabs/html/plugin_crashed_help.html
new file mode 100644
index 0000000000..f938036d88
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/plugin_crashed_help.html
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Plugin Crashed Help</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <h1>Plugin Crashed Help</h1>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-tabs/html/plugin_update.html b/comm/mail/test/browser/content-tabs/html/plugin_update.html
new file mode 100644
index 0000000000..49c935f288
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/plugin_update.html
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Plugin Update Page</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <h1>Plugin Update Page</h1>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-tabs/html/test-lwthemes.html b/comm/mail/test/browser/content-tabs/html/test-lwthemes.html
new file mode 100644
index 0000000000..a43eb0062f
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/test-lwthemes.html
@@ -0,0 +1,44 @@
+<html><head>
+<title>test lightweight themes</title>
+</head><body>
+<script>
+var themes = [
+ {
+ id: "test-01",
+ name: "Test 01",
+ headerURL: "test.png",
+ footerURL: "test.png",
+ textcolor: "#fff",
+ accentcolor: "#6b6b6b",
+ },
+ {
+ id: "test-02",
+ name: "Test 02",
+ headerURL: "test.png",
+ footerURL: "test.png",
+ textcolor: "#bcf",
+ accentcolor: "#8888FF",
+ },
+];
+
+const INSTALL = "InstallBrowserTheme";
+const PREVIEW = "PreviewBrowserTheme";
+const RESET_PREVIEW = "ResetBrowserThemePreview";
+
+function setTheme(node, theme, action) {
+ node.setAttribute("data-browsertheme", JSON.stringify(themes[theme]));
+ dump("dispatching " + action + "\n");
+ node.dispatchEvent(new Event(action, { bubbles: true, cancelable: false }));
+}
+</script>
+
+<button id="install1"
+ onclick="setTheme(this, 0, INSTALL);"
+ onmouseover="setTheme(this, 0, PREVIEW);"
+ onmouseout="setTheme(this, 0, RESET_PREVIEW);">Test 01</button>
+<button id="install2"
+ onclick="setTheme(this, 1, INSTALL);"
+ onmouseover="setTheme(this, 1, PREVIEW);"
+ onmouseout="setTheme(this, 1, RESET_PREVIEW);">Test 02</button>
+</body>
+</html>
diff --git a/comm/mail/test/browser/content-tabs/html/test.png b/comm/mail/test/browser/content-tabs/html/test.png
new file mode 100644
index 0000000000..e3988bfc76
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/test.png
Binary files differ
diff --git a/comm/mail/test/browser/content-tabs/html/webextension.xpi b/comm/mail/test/browser/content-tabs/html/webextension.xpi
new file mode 100644
index 0000000000..d4e4f27507
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/webextension.xpi
Binary files differ
diff --git a/comm/mail/test/browser/content-tabs/html/whatsnew.html b/comm/mail/test/browser/content-tabs/html/whatsnew.html
new file mode 100644
index 0000000000..bb5dab4006
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/whatsnew.html
@@ -0,0 +1,13 @@
+<html>
+ <head>
+ <title>What's New Content Test</title>
+ <link rel="icon shortcut" href="whatsnew.png"/>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <menu type="context" id="pageContextMenu">
+ <menuitem label="Click me!" id="pageMenuItem"/>
+ </menu>
+ <h1 contextmenu="pageContextMenu">What's New Content Test</h1>
+ <textarea>Zombocom</textarea>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/content-tabs/html/whatsnew.png b/comm/mail/test/browser/content-tabs/html/whatsnew.png
new file mode 100644
index 0000000000..bb3ff3b069
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/whatsnew.png
Binary files differ
diff --git a/comm/mail/test/browser/content-tabs/html/whatsnew1.html b/comm/mail/test/browser/content-tabs/html/whatsnew1.html
new file mode 100644
index 0000000000..a1579dd3ee
--- /dev/null
+++ b/comm/mail/test/browser/content-tabs/html/whatsnew1.html
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>What's New Content Test 1</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <h1>What's New Content Test 1</h1>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/cookies/browser.ini b/comm/mail/test/browser/cookies/browser.ini
new file mode 100644
index 0000000000..d30977da39
--- /dev/null
+++ b/comm/mail/test/browser/cookies/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+support-files = html/**
+
+[browser_cookies.js]
diff --git a/comm/mail/test/browser/cookies/browser_cookies.js b/comm/mail/test/browser/cookies/browser_cookies.js
new file mode 100644
index 0000000000..3237e5d2af
--- /dev/null
+++ b/comm/mail/test/browser/cookies/browser_cookies.js
@@ -0,0 +1,57 @@
+/* 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 file to check that cookies are correctly enabled in Thunderbird.
+ *
+ * XXX: Still need to check remote content in messages.
+ */
+
+"use strict";
+
+var { open_content_tab_with_url } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+// RELATIVE_ROOT messes with the collector, so we have to bring the path back
+// so we get the right path for the resources.
+var url = "http://mochi.test:8888/browser/comm/mail/test/browser/cookies/html/";
+
+/**
+ * Test deleting junk messages with no messages marked as junk.
+ */
+add_task(async function test_load_cookie_page() {
+ open_content_tab_with_url(url + "cookietest1.html");
+ let tab2 = open_content_tab_with_url(url + "cookietest2.html");
+
+ await SpecialPowers.spawn(tab2.browser, [], () => {
+ Assert.equal(content.document.title, "Cookie Test 2");
+
+ let cookie = content.wrappedJSObject.theCookie;
+
+ dump("Cookie is: " + cookie + "\n");
+
+ if (!cookie) {
+ throw new Error("Document has no cookie :-(");
+ }
+
+ if (cookie != "name=CookieTest") {
+ throw new Error(
+ "Cookie set incorrectly, expected: name=CookieTest, got: " +
+ cookie +
+ "\n"
+ );
+ }
+ });
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/cookies/html/cookietest1.html b/comm/mail/test/browser/cookies/html/cookietest1.html
new file mode 100644
index 0000000000..6df57f2c98
--- /dev/null
+++ b/comm/mail/test/browser/cookies/html/cookietest1.html
@@ -0,0 +1,13 @@
+<html>
+ <head>
+ <title>Cookie Test</title>
+ <script>
+ document.cookie = "name=CookieTest";
+ </script>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <div align="center">
+ <h1>Cookie Test</h1>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/cookies/html/cookietest2.html b/comm/mail/test/browser/cookies/html/cookietest2.html
new file mode 100644
index 0000000000..9086c346d1
--- /dev/null
+++ b/comm/mail/test/browser/cookies/html/cookietest2.html
@@ -0,0 +1,13 @@
+<html>
+ <head>
+ <title>Cookie Test 2</title>
+ </head>
+ <body bgcolor="#FFFFFF">
+ <script>
+ var theCookie = document.cookie;
+ </script>
+ <div align="center">
+ <h1>Cookie Test Result</h1>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/downloads/browser.ini b/comm/mail/test/browser/downloads/browser.ini
new file mode 100644
index 0000000000..44b5dcd8d8
--- /dev/null
+++ b/comm/mail/test/browser/downloads/browser.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_aboutDownloads.js]
diff --git a/comm/mail/test/browser/downloads/browser_aboutDownloads.js b/comm/mail/test/browser/downloads/browser_aboutDownloads.js
new file mode 100644
index 0000000000..303c91a752
--- /dev/null
+++ b/comm/mail/test/browser/downloads/browser_aboutDownloads.js
@@ -0,0 +1,384 @@
+/* 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 about:downloads.
+ */
+
+"use strict";
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { gMockFilePicker, gMockFilePickReg } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AttachmentHelpers.jsm"
+);
+var { content_tab_e } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var {
+ be_in_folder,
+ close_tab,
+ create_folder,
+ get_about_message,
+ make_message_sets_in_folders,
+ mc,
+ select_click_row,
+ switch_tab,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, wait_for_browser_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var downloads = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+var downloadsTab;
+
+var attachmentFileNames = [
+ "Attachment#1.txt",
+ "Attachment#2.txt",
+ "Attachment#3.txt",
+];
+
+var downloadsView = {
+ init() {
+ this.items = new Map();
+ this.removedItems = [];
+ },
+
+ get count() {
+ return this.items.size;
+ },
+
+ onDownloadAdded(aDownload) {
+ this.items.set(aDownload, aDownload.target.path);
+ },
+
+ onDownloadChanged(aDownload) {},
+
+ onDownloadRemoved(aDownload) {
+ this.removedItems.push(aDownload.target.path);
+ this.items.delete(aDownload);
+ },
+
+ waitForFinish() {
+ let succeededPromises = [];
+ for (let download of this.items.keys()) {
+ let succeededPromise = download.whenSucceeded();
+ succeededPromises.push(succeededPromise);
+ }
+ let finished = false;
+ Promise.all(succeededPromises).then(() => (finished = true), console.error);
+ utils.waitFor(() => finished, "Timeout waiting for downloads to complete.");
+ },
+};
+
+async function prepare_messages() {
+ let folder = await create_folder("about:downloads");
+ await make_message_sets_in_folders(
+ [folder],
+ [
+ {
+ count: 1,
+ attachments: [
+ {
+ filename: attachmentFileNames[0],
+ body: "Body",
+ },
+ ],
+ },
+ {
+ count: 1,
+ attachments: [
+ {
+ filename: attachmentFileNames[1],
+ body: "Body",
+ },
+ ],
+ },
+ {
+ count: 1,
+ attachments: [
+ {
+ filename: attachmentFileNames[2],
+ body: "Body",
+ },
+ ],
+ },
+ ]
+ );
+ await be_in_folder(folder);
+}
+
+function prepare_downloads_view() {
+ let success = false;
+ downloads.Downloads.getList(downloads.Downloads.ALL)
+ .then(list => list.addView(downloadsView))
+ .then(() => (success = true), console.error);
+ utils.waitFor(
+ () => success,
+ "Timeout waiting for attaching our download view."
+ );
+}
+
+add_setup(async function () {
+ gMockFilePickReg.register();
+
+ await prepare_messages();
+ prepare_downloads_view();
+
+ downloadsTab = open_about_downloads();
+});
+
+function setupTest(test) {
+ downloadsView.init();
+}
+
+function open_about_downloads() {
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+ let newTab = mc.window.openSavedFilesWnd();
+ utils.waitFor(
+ () =>
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs
+ .length ==
+ preCount + 1,
+ "Timeout waiting for about:downloads tab"
+ );
+
+ wait_for_browser_load(newTab.browser, "about:downloads");
+ // We append new tabs at the end, so check the last one.
+ let expectedNewTab =
+ mc.window.document.getElementById("tabmail").tabInfo[preCount];
+ return expectedNewTab;
+}
+
+/**
+ * Test that there is no file in the list at first.
+ */
+add_task(async function test_empty_list() {
+ setupTest();
+ await switch_tab(downloadsTab);
+
+ let list = content_tab_e(downloadsTab, "msgDownloadsRichListBox");
+ Assert.equal(list.children.length, 0, "Downloads list should be empty");
+ teardownTest();
+});
+
+async function save_attachment_files() {
+ await switch_tab(0);
+
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ let aboutMessage = get_about_message();
+ let length = attachmentFileNames.length;
+ for (let i = 0; i < length; i++) {
+ let file = profileDir.clone();
+ file.append(attachmentFileNames[i]);
+ select_click_row(i);
+ gMockFilePicker.returnFiles = [file];
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentSaveAllSingle"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+ }
+}
+
+/**
+ * Test that all downloaded files are showed up in the list.
+ */
+async function subtest_save_attachment_files_in_list() {
+ await save_attachment_files();
+
+ mc.window.document.getElementById("tabmail").switchToTab(downloadsTab);
+ let list = content_tab_e(downloadsTab, "msgDownloadsRichListBox");
+
+ let length = attachmentFileNames.length;
+ utils.waitFor(
+ () => downloadsView.count == length,
+ () =>
+ "Timeout waiting for saving three attachment files; " +
+ "downloadsView.count=" +
+ downloadsView.count
+ );
+
+ Assert.equal(length, list.children.length);
+ Assert.equal(downloadsView.count, list.children.length);
+
+ let actualNames = [];
+ let child = list.firstElementChild;
+ dump(child.querySelector(".fileName").getAttribute("value"));
+ while (child) {
+ actualNames.push(child.querySelector(".fileName").getAttribute("value"));
+ child = child.nextElementSibling;
+ }
+ actualNames.sort();
+
+ for (let i = 0; i < length; i++) {
+ Assert.equal(attachmentFileNames[i], actualNames[i]);
+ }
+}
+add_task(async function test_save_attachment_files_in_list() {
+ setupTest();
+ await subtest_save_attachment_files_in_list();
+ teardownTest();
+});
+
+/**
+ * Test that 'remove' in context menu removes surely the target file from
+ * the list.
+ */
+add_task(async function test_remove_file() {
+ setupTest();
+ await subtest_save_attachment_files_in_list();
+
+ let list = content_tab_e(downloadsTab, "msgDownloadsRichListBox");
+ let firstElement = list.firstElementChild;
+ let removingFileName = firstElement
+ .querySelector(".fileName")
+ .getAttribute("value");
+
+ // select first element
+ EventUtils.synthesizeMouseAtCenter(
+ firstElement,
+ { clickCount: 1 },
+ firstElement.ownerGlobal
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ firstElement,
+ { type: "contextmenu" },
+ firstElement.ownerGlobal
+ );
+
+ let contextMenu = content_tab_e(downloadsTab, "msgDownloadsContextMenu");
+ await wait_for_popup_to_open(contextMenu);
+ await click_menus_in_sequence(contextMenu, [
+ { command: "msgDownloadsCmd_remove" },
+ ]);
+ utils.waitFor(
+ () => downloadsView.count == 2,
+ "Timeout waiting for removing a saved attachment file."
+ );
+
+ let child = list.firstElementChild;
+ while (child) {
+ Assert.notEqual(
+ removingFileName,
+ child.querySelector(".fileName").getAttribute("value")
+ );
+ child = child.nextElementSibling;
+ }
+ teardownTest();
+});
+
+/**
+ * Test that removing multiple files surely removes the files.
+ */
+add_task(async function test_remove_multiple_files() {
+ setupTest();
+ await subtest_save_attachment_files_in_list();
+
+ let list = content_tab_e(downloadsTab, "msgDownloadsRichListBox");
+ let firstElement = list.firstElementChild.nextElementSibling;
+ let secondElement = firstElement.nextElementSibling;
+ let removingFileNames = [];
+
+ removingFileNames.push(
+ firstElement.querySelector(".fileName").getAttribute("value")
+ );
+ removingFileNames.push(
+ secondElement.querySelector(".fileName").getAttribute("value")
+ );
+
+ // select two elements
+ EventUtils.synthesizeMouseAtCenter(
+ firstElement,
+ { clickCount: 1 },
+ firstElement.ownerGlobal
+ );
+ list.selectItemRange(firstElement, secondElement);
+ EventUtils.synthesizeMouseAtCenter(
+ firstElement,
+ { type: "contextmenu" },
+ firstElement.ownerGlobal
+ );
+
+ let contextMenu = content_tab_e(downloadsTab, "msgDownloadsContextMenu");
+ await wait_for_popup_to_open(contextMenu);
+ await click_menus_in_sequence(contextMenu, [
+ { command: "msgDownloadsCmd_remove" },
+ ]);
+ utils.waitFor(
+ () => downloadsView.count == 1,
+ "Timeout waiting for removing two saved attachment files."
+ );
+
+ let child = list.firstElementChild;
+ while (child) {
+ for (let name of removingFileNames) {
+ Assert.notEqual(
+ name,
+ child.querySelector(".fileName").getAttribute("value")
+ );
+ }
+ child = child.nextElementSibling;
+ }
+ teardownTest();
+});
+
+/**
+ * Test that 'clearDownloads" in context menu purges all files in the list.
+ */
+add_task(async function test_clear_all_files() {
+ setupTest();
+ await subtest_save_attachment_files_in_list();
+ downloadsView.waitForFinish();
+
+ let listbox = content_tab_e(downloadsTab, "msgDownloadsRichListBox");
+ EventUtils.synthesizeMouseAtCenter(
+ listbox,
+ { clickCount: 1 },
+ listbox.ownerGlobal
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ listbox,
+ { type: "contextmenu" },
+ listbox.ownerGlobal
+ );
+
+ let contextMenu = content_tab_e(downloadsTab, "msgDownloadsContextMenu");
+ await wait_for_popup_to_open(contextMenu);
+ await click_menus_in_sequence(contextMenu, [
+ { command: "msgDownloadsCmd_clearDownloads" },
+ ]);
+ utils.waitFor(
+ () => downloadsView.count == 0,
+ "Timeout waiting for clearing all saved attachment files."
+ );
+
+ let list = content_tab_e(downloadsTab, "msgDownloadsRichListBox");
+ Assert.equal(list.children.length, 0, "Downloads list should be empty");
+ teardownTest();
+});
+
+function teardownTest() {
+ downloads.Downloads.getList(downloads.Downloads.ALL)
+ .then(function (list) {
+ for (let download of downloadsView.items.keys()) {
+ list.remove(download);
+ }
+ })
+ .catch(console.error);
+ utils.waitFor(
+ () => downloadsView.count == 0,
+ "Timeout waiting for clearing all saved attachment files."
+ );
+}
+
+registerCleanupFunction(function () {
+ close_tab(downloadsTab);
+ gMockFilePickReg.unregister();
+});
diff --git a/comm/mail/test/browser/folder-display/browser.ini b/comm/mail/test/browser/folder-display/browser.ini
new file mode 100644
index 0000000000..591374afb5
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser.ini
@@ -0,0 +1,81 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.account.account1.server=server1
+ mail.account.account2.identities=id1,id2
+ mail.account.account2.server=server2
+ mail.accountmanager.accounts=account1,account2
+ mail.accountmanager.defaultaccount=account2
+ mail.accountmanager.localfoldersserver=server1
+ mail.identity.id1.fullName=Tinderbox
+ mail.identity.id1.htmlSigFormat=false
+ mail.identity.id1.smtpServer=smtp1
+ mail.identity.id1.useremail=tinderbox@foo.invalid
+ mail.identity.id1.valid=true
+ mail.identity.id2.fullName=Tinderboxpushlog
+ mail.identity.id2.htmlSigFormat=true
+ mail.identity.id2.smtpServer=smtp1
+ mail.identity.id2.useremail=tinderboxpushlog@foo.invalid
+ mail.identity.id2.valid=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server1.type=none
+ mail.server.server1.userName=nobody
+ mail.server.server2.check_new_mail=false
+ mail.server.server2.directory-rel=[ProfD]Mail/tinderbox
+ mail.server.server2.download_on_biff=true
+ mail.server.server2.hostname=tinderbox123
+ mail.server.server2.login_at_startup=false
+ mail.server.server2.name=tinderbox@foo.invalid
+ mail.server.server2.type=pop3
+ mail.server.server2.userName=tinderbox
+ mail.server.server2.whiteListAbURI=
+ mail.shell.checkDefaultClient=false
+ mail.smtp.defaultserver=smtp1
+ mail.smtpserver.smtp1.hostname=tinderbox123
+ mail.smtpserver.smtp1.username=tinderbox
+ mail.smtpservers=smtp1
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ ui.prefersReducedMotion=1
+subsuite = thunderbird
+support-files = data/**
+
+[browser_applyView.js]
+[browser_archiveMessages.js]
+[browser_closeWindowOnDelete.js]
+[browser_columns.js]
+[browser_deletionFromVirtualFolders.js]
+[browser_deletionWithMultipleDisplays.js]
+[browser_displayName.js]
+[browser_folderPaneVisibility.js]
+[browser_folderToolbar.js]
+skip-if = true # TODO
+[browser_invalidDbFolderLoad.js]
+[browser_mailTelemetry.js]
+[browser_mailViews.js]
+[browser_messageCommands.js]
+[browser_messageCommandsOnMsgstore.js]
+[browser_messagePaneVisibility.js]
+[browser_messageReloads.js]
+[browser_messageSize.js]
+[browser_messageWindow.js]
+[browser_openingMessages.js]
+[browser_openingMessagesWithoutABackingView.js]
+[browser_readMsgs.js]
+tags = vcard
+[browser_recentMenu.js]
+[browser_rightClickMiddleClickFolders.js]
+[browser_rightClickMiddleClickMessages.js]
+[browser_savedsearchReloadAfterCompact.js]
+[browser_selection.js]
+[browser_summarization.js]
+skip-if = true # TODO
+[browser_syntheticViews.js]
+skip-if = true # TODO
+[browser_tabsSimple.js]
+[browser_viewSource.js]
+[browser_virtualFolderCommands.js]
+[browser_watchIgnoreThread.js]
diff --git a/comm/mail/test/browser/folder-display/browser_applyView.js b/comm/mail/test/browser/folder-display/browser_applyView.js
new file mode 100644
index 0000000000..d5b5654fe4
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_applyView.js
@@ -0,0 +1,331 @@
+/* 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/. */
+
+/**
+ * Tests Apply Current View To…
+ */
+
+"use strict";
+
+var { be_in_folder, create_folder, get_about_3pane } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+// These are for the reset/apply to other/apply to other+child tests.
+var folderSource, folderParent, folderChild1;
+
+add_setup(async function () {
+ folderSource = await create_folder("ColumnsApplySource");
+
+ folderParent = await create_folder("ColumnsApplyParent");
+ folderParent.createSubfolder("Child1", null);
+ folderChild1 = folderParent.getChildNamed("Child1");
+ folderParent.createSubfolder("Child2", null);
+
+ await be_in_folder(folderSource);
+ await ensure_table_view();
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ folderParent.deleteSelf(null);
+ folderSource.deleteSelf(null);
+ });
+});
+
+/**
+ * Get the currently visible threadTree columns.
+ */
+add_task(async function testSetViewSingle() {
+ const info = folderSource.msgDatabase.dBFolderInfo;
+
+ Assert.equal(
+ info.viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "viewFlags should start threaded"
+ );
+ Assert.equal(
+ info.sortType,
+ Ci.nsMsgViewSortType.byDate,
+ "sortType should start byDate"
+ );
+ Assert.equal(
+ info.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sortOrder should start ascending"
+ );
+
+ const about3Pane = get_about_3pane();
+
+ const threadCol = about3Pane.document.getElementById("threadCol");
+ EventUtils.synthesizeMouseAtCenter(threadCol, { clickCount: 1 }, about3Pane);
+ await TestUtils.waitForCondition(
+ () => info.viewFlags == Ci.nsMsgViewFlagsType.kNone,
+ "should change viewFlags to none"
+ );
+
+ const subjectCol = about3Pane.document.getElementById("subjectCol");
+ EventUtils.synthesizeMouseAtCenter(subjectCol, { clickCount: 1 }, about3Pane);
+ await TestUtils.waitForCondition(
+ () => info.sortType == Ci.nsMsgViewSortType.bySubject,
+ "should change sortType to subject"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(subjectCol, { clickCount: 1 }, about3Pane);
+ await TestUtils.waitForCondition(
+ () => info.sortOrder == Ci.nsMsgViewSortOrder.descending,
+ "should change sortOrder to sort descending"
+ );
+
+ Assert.equal(
+ info.viewFlags,
+ Ci.nsMsgViewFlagsType.kNone,
+ "viewFlags should now be unthreaded"
+ );
+ Assert.equal(
+ info.sortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "sortType should now be bySubject"
+ );
+ Assert.equal(
+ info.sortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "sortOrder should now be descending"
+ );
+});
+
+async function invoke_column_picker_option(aActions) {
+ const tabmail = document.getElementById("tabmail");
+ const about3Pane = tabmail.currentAbout3Pane;
+
+ const colPicker = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ );
+ const colPickerPopup = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] menupopup`
+ );
+
+ const shownPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(colPicker, {}, about3Pane);
+ await shownPromise;
+ const hiddenPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popuphidden"
+ );
+ await click_menus_in_sequence(colPickerPopup, aActions);
+ await hiddenPromise;
+}
+
+async function _apply_to_folder_common(aChildrenToo, folder) {
+ let notificatonPromise;
+ if (aChildrenToo) {
+ notificatonPromise = TestUtils.topicObserved("msg-folder-views-propagated");
+ }
+
+ const menuItems = [
+ { class: "applyViewTo-menu" },
+ {
+ class: aChildrenToo
+ ? "applyViewToFolderAndChildren-menu"
+ : "applyViewToFolder-menu",
+ },
+ { label: "Local Folders" },
+ ];
+ if (!folder.isServer) {
+ menuItems.push({ label: folder.name });
+ }
+ menuItems.push(menuItems.at(-1));
+
+ const dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ await invoke_column_picker_option(menuItems);
+ await dialogPromise;
+
+ if (notificatonPromise) {
+ await notificatonPromise;
+ }
+}
+
+/**
+ * Change settings in a folder, apply them to another folder that also has
+ * children. Make sure the folder changes but the children do not.
+ */
+add_task(async function test_apply_to_folder_no_children() {
+ const child1Info = folderChild1.msgDatabase.dBFolderInfo;
+ Assert.equal(
+ child1Info.viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "viewFlags for child1 should start threaded"
+ );
+ Assert.equal(
+ child1Info.sortType,
+ Ci.nsMsgViewSortType.byDate,
+ "sortType for child1 should start byDate"
+ );
+ Assert.equal(
+ child1Info.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sortOrder for child1 should start ascending"
+ );
+
+ // Apply to the one dude
+ await _apply_to_folder_common(false, folderParent);
+
+ // Should apply to the folderParent.
+ Assert.equal(
+ folderParent.msgDatabase.dBFolderInfo.viewFlags,
+ Ci.nsMsgViewFlagsType.kNone,
+ "viewFlags should have been applied"
+ );
+ Assert.equal(
+ folderParent.msgDatabase.dBFolderInfo.sortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "sortType should have been applied"
+ );
+ Assert.equal(
+ folderParent.msgDatabase.dBFolderInfo.sortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "sortOrder should have been applied"
+ );
+
+ // Shouldn't have applied to its children.
+ Assert.equal(
+ folderChild1.msgDatabase.dBFolderInfo.viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "viewFlags should not have been applied to children"
+ );
+ Assert.equal(
+ folderChild1.msgDatabase.dBFolderInfo.sortType,
+ Ci.nsMsgViewSortType.byDate,
+ "sortType should not have been applied to children"
+ );
+ Assert.equal(
+ folderChild1.msgDatabase.dBFolderInfo.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sortOrder should not have been applied to children"
+ );
+});
+
+/**
+ * Change settings in a folder, apply them to another folder and its children.
+ * Make sure the folder and its children change.
+ */
+add_task(async function test_apply_to_folder_and_children() {
+ await be_in_folder(folderSource);
+
+ const child1Info = folderChild1.msgDatabase.dBFolderInfo;
+ Assert.equal(
+ child1Info.viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "viewFlags for child1 should start threaded"
+ );
+ Assert.equal(
+ child1Info.sortType,
+ Ci.nsMsgViewSortType.byDate,
+ "sortType for child1 should start byDate"
+ );
+ Assert.equal(
+ child1Info.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sortOrder for child1 should start ascending"
+ );
+
+ // Apply to folder and children.
+ await _apply_to_folder_common(true, folderParent);
+
+ // Should apply to the folderParent.
+ Assert.equal(
+ folderParent.msgDatabase.dBFolderInfo.viewFlags,
+ Ci.nsMsgViewFlagsType.kNone,
+ "viewFlags should have been applied to parent"
+ );
+ Assert.equal(
+ folderParent.msgDatabase.dBFolderInfo.sortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "sortType should have been applied to parent"
+ );
+ Assert.equal(
+ folderParent.msgDatabase.dBFolderInfo.sortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "sortOrder should have been applied"
+ );
+
+ // Should have applied to its children as well.
+ for (const child of folderParent.descendants) {
+ Assert.equal(
+ child.msgDatabase.dBFolderInfo.viewFlags,
+ Ci.nsMsgViewFlagsType.kNone,
+ "viewFlags should have been applied to children"
+ );
+ Assert.equal(
+ child.msgDatabase.dBFolderInfo.sortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "sortType should have been applied to children"
+ );
+ Assert.equal(
+ child.msgDatabase.dBFolderInfo.sortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "sortOrder should have been applied to children"
+ );
+ }
+});
+
+/**
+ * Change settings in a folder, apply them to the root folder and its children.
+ * Make sure the children change.
+ */
+add_task(async function test_apply_to_root_folder_and_children() {
+ const info = folderSource.msgDatabase.dBFolderInfo;
+ await be_in_folder(folderSource);
+
+ const about3Pane = get_about_3pane();
+ const junkStatusCol = about3Pane.document.getElementById("junkStatusCol");
+ EventUtils.synthesizeMouseAtCenter(
+ junkStatusCol,
+ { clickCount: 1 },
+ about3Pane
+ );
+ Assert.equal(
+ info.viewFlags,
+ Ci.nsMsgViewFlagsType.kNone,
+ "viewFlags should be set to unthreaded"
+ );
+ Assert.equal(
+ info.sortType,
+ Ci.nsMsgViewSortType.byJunkStatus,
+ "sortType should be set to junkStatus"
+ );
+ Assert.equal(
+ info.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sortOrder should be set to ascending"
+ );
+
+ // Apply to the root folder and its descendants.
+ await _apply_to_folder_common(true, folderSource.rootFolder);
+
+ // Make sure it is copied to all folders of this server.
+ for (const folder of folderSource.rootFolder.descendants) {
+ Assert.equal(
+ folder.msgDatabase.dBFolderInfo.viewFlags,
+ Ci.nsMsgViewFlagsType.kNone,
+ `viewFlags should have been applied to ${folder.name}`
+ );
+ Assert.equal(
+ folder.msgDatabase.dBFolderInfo.sortType,
+ Ci.nsMsgViewSortType.byJunkStatus,
+ `sortType should have been applied to ${folder.name}`
+ );
+ Assert.equal(
+ folder.msgDatabase.dBFolderInfo.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ `sortOrder should have been applied to ${folder.name}`
+ );
+ folder.msgDatabase = null;
+ }
+});
diff --git a/comm/mail/test/browser/folder-display/browser_archiveMessages.js b/comm/mail/test/browser/folder-display/browser_archiveMessages.js
new file mode 100644
index 0000000000..7f3aabccc7
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_archiveMessages.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/. */
+
+"use strict";
+
+var {
+ add_message_sets_to_folders,
+ archive_messages,
+ assert_message_not_in_view,
+ assert_nothing_selected,
+ assert_selected,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ create_thread,
+ expand_all_threads,
+ get_about_3pane,
+ make_display_threaded,
+ mc,
+ select_click_row,
+ select_none,
+ select_shift_click_row,
+ toggle_thread_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+
+/**
+ * The number of messages in the thread we use to test.
+ */
+var NUM_MESSAGES_IN_THREAD = 6;
+
+add_setup(async function () {
+ folder = await create_folder("ThreadedMessages");
+ let thread = create_thread(NUM_MESSAGES_IN_THREAD);
+ await add_message_sets_to_folders([folder], [thread]);
+ thread = create_thread(NUM_MESSAGES_IN_THREAD);
+ await add_message_sets_to_folders([folder], [thread]);
+});
+
+/**
+ * Test archiving messages that are not currently selected.
+ */
+add_task(async function test_batch_archiver() {
+ await be_in_folder(folder);
+
+ select_none();
+ assert_nothing_selected();
+
+ expand_all_threads();
+
+ /* Select the first (expanded) thread */
+ let root = select_click_row(0);
+ assert_selected_and_displayed(root);
+
+ /* Get a grip on the first and the second sub-message */
+ let m1 = select_click_row(1);
+ let m2 = select_click_row(2);
+ select_click_row(0);
+ assert_selected_and_displayed(root);
+
+ /* The root message is selected, we archive the first sub-message */
+ archive_messages([m1]);
+
+ /* This message is gone and the root message is still selected **/
+ assert_message_not_in_view([m1]);
+ assert_selected_and_displayed(root);
+
+ /* Now, archiving messages under a collapsed thread */
+ toggle_thread_row(0);
+ archive_messages([m2]);
+
+ /* Selection didn't change */
+ assert_selected(root);
+
+ /* And the message is gone */
+ toggle_thread_row(0);
+ assert_message_not_in_view([m2]);
+
+ /* Both threads are collapsed */
+ toggle_thread_row(0);
+
+ /* Get a grip on the second thread */
+ let root2 = select_click_row(1);
+ select_click_row(0);
+ assert_selected(root);
+
+ /* Archive the first thread, now the second thread should be selected */
+ Assert.ok(
+ Services.prefs.getBoolPref("mail.operate_on_msgs_in_collapsed_threads")
+ );
+ Assert.greater(get_about_3pane().gDBView.getSelectedMsgHdrs().length, 1);
+ archive_messages(get_about_3pane().gDBView.getSelectedMsgHdrs());
+ select_click_row(0); // TODO This should be unnecessary.
+ assert_selected(root2);
+
+ /* We only have the first thread left */
+ toggle_thread_row(0);
+ assert_selected_and_displayed(root2);
+ expand_all_threads();
+
+ /* Archive the head of the thread, check that it still works fine */
+ let child1 = select_click_row(1);
+ select_click_row(0);
+ archive_messages([root2]);
+ select_click_row(0); // TODO This should be unnecessary.
+ assert_selected_and_displayed(child1);
+
+ /* Test archiving a partial selection */
+ let child2 = select_click_row(1);
+ let child3 = select_click_row(2);
+ select_click_row(3);
+
+ select_shift_click_row(2);
+ select_shift_click_row(1);
+ select_shift_click_row(0);
+
+ archive_messages([child1, child3]);
+ assert_message_not_in_view([child1, child3]);
+ select_click_row(0); // TODO This should be unnecessary.
+ assert_selected_and_displayed(child2);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_closeWindowOnDelete.js b/comm/mail/test/browser/folder-display/browser_closeWindowOnDelete.js
new file mode 100644
index 0000000000..0927ab243a
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_closeWindowOnDelete.js
@@ -0,0 +1,319 @@
+/* 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 that the close message window on delete option works.
+ */
+
+"use strict";
+
+var {
+ assert_number_of_tabs_open,
+ be_in_folder,
+ close_tab,
+ create_folder,
+ make_message_sets_in_folders,
+ mc,
+ open_selected_message_in_new_tab,
+ open_selected_message_in_new_window,
+ press_delete,
+ reset_close_message_on_delete,
+ select_click_row,
+ set_close_message_on_delete,
+ switch_tab,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window, plan_for_window_close, wait_for_window_close } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var folder;
+
+add_setup(async function () {
+ folder = await create_folder("CloseWindowOnDeleteA");
+ await make_message_sets_in_folders([folder], [{ count: 10 }]);
+});
+
+/**
+ * Delete a message and check that the message window is closed
+ * where appropriate.
+ */
+add_task(
+ async function test_close_message_window_on_delete_from_message_window() {
+ set_close_message_on_delete(true);
+ await be_in_folder(folder);
+
+ // select the first message
+ select_click_row(0);
+ // display it
+ let msgc = await open_selected_message_in_new_window();
+
+ select_click_row(1);
+ let msgc2 = await open_selected_message_in_new_window();
+
+ let preCount = folder.getTotalMessages(false);
+ msgc.window.focus();
+ plan_for_window_close(msgc);
+ press_delete(msgc);
+ if (folder.getTotalMessages(false) != preCount - 1) {
+ throw new Error("didn't delete a message before closing window");
+ }
+ wait_for_window_close(msgc);
+
+ if (msgc2.window.closed) {
+ throw new Error("should only have closed the active window");
+ }
+
+ close_window(msgc2);
+
+ reset_close_message_on_delete();
+ }
+);
+
+/**
+ * Delete a message when multiple windows are open to the message, and the
+ * message is deleted from one of them.
+ */
+add_task(
+ async function test_close_multiple_message_windows_on_delete_from_message_window() {
+ set_close_message_on_delete(true);
+ await be_in_folder(folder);
+
+ // select the first message
+ select_click_row(0);
+ // display it
+ let msgc = await open_selected_message_in_new_window();
+ let msgcA = await open_selected_message_in_new_window();
+
+ select_click_row(1);
+ let msgc2 = await open_selected_message_in_new_window();
+
+ let preCount = folder.getTotalMessages(false);
+ msgc.window.focus();
+ plan_for_window_close(msgc);
+ plan_for_window_close(msgcA);
+ press_delete(msgc);
+
+ if (folder.getTotalMessages(false) != preCount - 1) {
+ throw new Error("didn't delete a message before closing window");
+ }
+ wait_for_window_close(msgc);
+ wait_for_window_close(msgcA);
+
+ if (msgc2.window.closed) {
+ throw new Error("should only have closed the active window");
+ }
+
+ close_window(msgc2);
+
+ reset_close_message_on_delete();
+ }
+);
+
+/**
+ * Delete a message when multiple windows are open to the message, and the
+ * message is deleted from the 3-pane window.
+ */
+add_task(
+ async function test_close_multiple_message_windows_on_delete_from_3pane_window() {
+ set_close_message_on_delete(true);
+ await be_in_folder(folder);
+
+ // select the first message
+ select_click_row(0);
+ // display it
+ let msgc = await open_selected_message_in_new_window();
+ let msgcA = await open_selected_message_in_new_window();
+
+ select_click_row(1);
+ let msgc2 = await open_selected_message_in_new_window();
+
+ let preCount = folder.getTotalMessages(false);
+ mc.window.focus();
+ plan_for_window_close(msgc);
+ plan_for_window_close(msgcA);
+ select_click_row(0);
+ press_delete(mc);
+
+ if (folder.getTotalMessages(false) != preCount - 1) {
+ throw new Error("didn't delete a message before closing window");
+ }
+ wait_for_window_close(msgc);
+ wait_for_window_close(msgcA);
+
+ if (msgc2.window.closed) {
+ throw new Error("should only have closed the first window");
+ }
+
+ close_window(msgc2);
+
+ reset_close_message_on_delete();
+ }
+);
+
+/**
+ * Delete a message and check that the message tab is closed
+ * where appropriate.
+ */
+add_task(async function test_close_message_tab_on_delete_from_message_tab() {
+ set_close_message_on_delete(true);
+ await be_in_folder(folder);
+
+ // select the first message
+ select_click_row(0);
+ // display it
+ let msgc = await open_selected_message_in_new_tab(true);
+
+ select_click_row(1);
+ let msgc2 = await open_selected_message_in_new_tab(true);
+
+ let preCount = folder.getTotalMessages(false);
+ await switch_tab(msgc);
+ press_delete();
+
+ if (folder.getTotalMessages(false) != preCount - 1) {
+ throw new Error("didn't delete a message before closing tab");
+ }
+
+ assert_number_of_tabs_open(2);
+
+ if (msgc2 != mc.window.document.getElementById("tabmail").tabInfo[1]) {
+ throw new Error("should only have closed the active tab");
+ }
+
+ close_tab(msgc2);
+
+ reset_close_message_on_delete();
+});
+
+/**
+ * Delete a message when multiple windows are open to the message, and the
+ * message is deleted from one of them.
+ */
+add_task(
+ async function test_close_multiple_message_tabs_on_delete_from_message_tab() {
+ set_close_message_on_delete(true);
+ await be_in_folder(folder);
+
+ // select the first message
+ select_click_row(0);
+ // display it
+ let msgc = await open_selected_message_in_new_tab(true);
+ await open_selected_message_in_new_tab(true);
+
+ select_click_row(1);
+ let msgc2 = await open_selected_message_in_new_tab(true);
+
+ let preCount = folder.getTotalMessages(false);
+ await switch_tab(msgc);
+ press_delete();
+
+ if (folder.getTotalMessages(false) != preCount - 1) {
+ throw new Error("didn't delete a message before closing tab");
+ }
+
+ assert_number_of_tabs_open(2);
+
+ if (msgc2 != mc.window.document.getElementById("tabmail").tabInfo[1]) {
+ throw new Error("should only have closed the active tab");
+ }
+
+ close_tab(msgc2);
+
+ reset_close_message_on_delete();
+ }
+);
+
+/**
+ * Delete a message when multiple tabs are open to the message, and the
+ * message is deleted from the 3-pane window.
+ */
+add_task(
+ async function test_close_multiple_message_tabs_on_delete_from_3pane_window() {
+ set_close_message_on_delete(true);
+ await be_in_folder(folder);
+
+ // select the first message
+ select_click_row(0);
+ // display it
+ await open_selected_message_in_new_tab(true);
+ await open_selected_message_in_new_tab(true);
+
+ select_click_row(1);
+ let msgc2 = await open_selected_message_in_new_tab(true);
+
+ let preCount = folder.getTotalMessages(false);
+ mc.window.focus();
+ select_click_row(0);
+ press_delete(mc);
+
+ if (folder.getTotalMessages(false) != preCount - 1) {
+ throw new Error("didn't delete a message before closing window");
+ }
+
+ assert_number_of_tabs_open(2);
+
+ if (msgc2 != mc.window.document.getElementById("tabmail").tabInfo[1]) {
+ throw new Error("should only have closed the active tab");
+ }
+
+ close_tab(msgc2);
+
+ reset_close_message_on_delete();
+ }
+);
+
+/**
+ * Delete a message when multiple windows and tabs are open to the message, and
+ * the message is deleted from the 3-pane window.
+ */
+add_task(
+ async function test_close_multiple_windows_tabs_on_delete_from_3pane_window() {
+ set_close_message_on_delete(true);
+ await be_in_folder(folder);
+
+ // select the first message
+ select_click_row(0);
+ // display it
+ await open_selected_message_in_new_tab(true);
+ let msgcA = await open_selected_message_in_new_window();
+
+ select_click_row(1);
+ let msgc2 = await open_selected_message_in_new_tab(true);
+ let msgc2A = await open_selected_message_in_new_window();
+
+ let preCount = folder.getTotalMessages(false);
+ mc.window.focus();
+ plan_for_window_close(msgcA);
+ select_click_row(0);
+ press_delete(mc);
+
+ if (folder.getTotalMessages(false) != preCount - 1) {
+ throw new Error("didn't delete a message before closing window");
+ }
+ wait_for_window_close(msgcA);
+
+ assert_number_of_tabs_open(2);
+
+ if (msgc2 != mc.window.document.getElementById("tabmail").tabInfo[1]) {
+ throw new Error("should only have closed the active tab");
+ }
+
+ if (msgc2A.window.closed) {
+ throw new Error("should only have closed the first window");
+ }
+
+ close_tab(msgc2);
+ close_window(msgc2A);
+
+ reset_close_message_on_delete();
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+ }
+);
diff --git a/comm/mail/test/browser/folder-display/browser_columns.js b/comm/mail/test/browser/folder-display/browser_columns.js
new file mode 100644
index 0000000000..836f5c3fa4
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_columns.js
@@ -0,0 +1,955 @@
+/* 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 column default logic and persistence logic. Persistence comes in both
+ * tab-switching (because of the multiplexed implementation) and
+ * folder-switching forms.
+ */
+
+"use strict";
+
+var {
+ be_in_folder,
+ close_tab,
+ create_folder,
+ create_virtual_folder,
+ enter_folder,
+ inboxFolder,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_tab,
+ switch_tab,
+ wait_for_all_messages_to_load,
+ select_click_row,
+ delete_messages,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+// needed to zero inter-folder processing delay
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var { GlodaSyntheticView } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaSyntheticView.jsm"
+);
+
+var folderInbox, folderSent, folderVirtual, folderA, folderB;
+// INBOX_DEFAULTS sans 'dateCol' but gains 'tagsCol'
+var columnsB;
+
+// these are for the reset/apply to other/apply to other+child tests.
+var folderSource, folderParent, folderChild1, folderChild2;
+
+var gColumnStateUpdated = false;
+
+var useCorrespondent;
+var INBOX_DEFAULTS;
+var CARDS_INBOX_DEFAULT;
+var SENT_DEFAULTS;
+var CARDS_SENT_DEFAULTS;
+var VIRTUAL_DEFAULTS;
+var GLODA_DEFAULTS;
+
+add_setup(async function () {
+ useCorrespondent = Services.prefs.getBoolPref(
+ "mail.threadpane.use_correspondents"
+ );
+ INBOX_DEFAULTS = [
+ "threadCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ useCorrespondent ? "correspondentCol" : "senderCol",
+ "junkStatusCol",
+ "dateCol",
+ ];
+ CARDS_INBOX_DEFAULT = ["subjectCol", "senderCol", "dateCol", "tagsCol"];
+ SENT_DEFAULTS = [
+ "threadCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ useCorrespondent ? "correspondentCol" : "recipientCol",
+ "junkStatusCol",
+ "dateCol",
+ ];
+ CARDS_SENT_DEFAULTS = ["subjectCol", "recipientCol", "dateCol", "tagsCol"];
+ VIRTUAL_DEFAULTS = [
+ "threadCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ useCorrespondent ? "correspondentCol" : "senderCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ ];
+ GLODA_DEFAULTS = [
+ "threadCol",
+ "flaggedCol",
+ "subjectCol",
+ useCorrespondent ? "correspondentCol" : "senderCol",
+ "dateCol",
+ "locationCol",
+ ];
+
+ // create the source
+ folderSource = await create_folder("ColumnsApplySource");
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ });
+});
+
+/**
+ * Get the currently visible threadTree columns.
+ *
+ * @returns {string[]}
+ */
+function get_visible_threadtree_columns() {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+
+ let columns = about3Pane.threadPane.columns;
+ return columns.filter(column => !column.hidden).map(column => column.id);
+}
+
+/**
+ * Verify that the provided list of columns is visible in the given order,
+ * throwing an exception if it is not the case.
+ *
+ * @param {string[]} desiredColumns - A list of column ID strings for columns
+ * that should be visible in the order that they should be visible.
+ */
+function assert_visible_columns(desiredColumns) {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+
+ let columns = about3Pane.threadPane.columns;
+ let visibleColumns = columns
+ .filter(column => !column.hidden)
+ .map(column => column.id);
+ let failCol = visibleColumns.filter(x => !desiredColumns.includes(x));
+ if (failCol.length) {
+ throw new Error(
+ `Found unexpected visible columns: '${failCol}'!\ndesired list: ${desiredColumns}\nactual list: ${visibleColumns}`
+ );
+ }
+}
+
+/**
+ * Verify that the provided list of columns is the expected list for the cards
+ * view.
+ *
+ * @param {string[]} desiredColumns - A list of column ID strings for columns
+ * that should be visible.
+ */
+function assert_visible_cards_columns(desiredColumns) {
+ const tabmail = document.getElementById("tabmail");
+ const about3Pane = tabmail.currentAbout3Pane;
+
+ const columns = about3Pane.threadPane.cardColumns;
+ const failCol = columns.filter(x => !desiredColumns.includes(x));
+ if (failCol.length) {
+ throw new Error(
+ `Found unexpected cards columns: '${failCol}'!\ndesired list: ${desiredColumns}\nactual list: ${columns}`
+ );
+ }
+}
+
+/**
+ * Toggle the column visibility .
+ *
+ * @param {string} columnID - Id of the thread column element to click.
+ */
+async function toggleColumn(columnID) {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+
+ let colPicker = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ );
+ let colPickerPopup = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] menupopup`
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(colPicker, {}, about3Pane);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popuphidden",
+ undefined,
+ event => event.originalTarget == colPickerPopup
+ );
+
+ const menuItem = colPickerPopup.querySelector(`[value="${columnID}"]`);
+ let checkedState = menuItem.getAttribute("checked");
+ let checkedStateChanged = TestUtils.waitForCondition(
+ () => checkedState != menuItem.getAttribute("checked"),
+ "The checked status changed"
+ );
+ colPickerPopup.activateItem(menuItem);
+ await checkedStateChanged;
+
+ // The column picker menupopup doesn't close automatically on purpose.
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, about3Pane);
+ await hiddenPromise;
+}
+
+/**
+ * Make sure we set the proper defaults for an Inbox.
+ */
+add_task(async function test_column_defaults_inbox() {
+ // just use the inbox; comes from test-folder-display-helpers
+ folderInbox = inboxFolder;
+ await enter_folder(folderInbox);
+ await ensure_table_view();
+ assert_visible_columns(INBOX_DEFAULTS);
+ assert_visible_cards_columns(CARDS_INBOX_DEFAULT);
+});
+
+add_task(async function test_keypress_on_columns() {
+ const [messageSet] = await make_message_sets_in_folders(
+ [folderInbox],
+ [{ count: 1 }]
+ );
+ registerCleanupFunction(async () => {
+ await delete_messages(messageSet);
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+
+ // Select the first row.
+ let row = about3Pane.threadTree.getRowAtIndex(0);
+ EventUtils.synthesizeMouseAtCenter(row, {}, about3Pane);
+
+ // Press SHIFT+TAB and LEFT to focus on the column picker.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, about3Pane);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, about3Pane);
+
+ Assert.equal(
+ about3Pane.document.activeElement,
+ about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ ),
+ "The column picker should be focused"
+ );
+
+ Assert.equal(tabmail.tabInfo.length, 1, "Only 1 tab should be visible");
+
+ let colPickerPopup = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] menupopup`
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popupshown"
+ );
+ // Pressing Enter should open the column picker popup.
+ EventUtils.synthesizeKey("VK_RETURN", {}, about3Pane);
+ await shownPromise;
+
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "The selected message shouldn't be opened in another tab"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popuphidden",
+ undefined,
+ event => event.originalTarget == colPickerPopup
+ );
+ // Close the column picker.
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, about3Pane);
+ await hiddenPromise;
+
+ // Move the focus to another column.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, about3Pane);
+ Assert.notEqual(
+ about3Pane.document.activeElement,
+ about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ ),
+ "The column picker should not be focused"
+ );
+
+ shownPromise = BrowserTestUtils.waitForEvent(colPickerPopup, "popupshown");
+ // Right clicking on a column header should trigger the column picker
+ // menupopup.
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.document.activeElement,
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popuphidden",
+ undefined,
+ event => event.originalTarget == colPickerPopup
+ );
+ // Close the column picker.
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, about3Pane);
+ await hiddenPromise;
+});
+
+/**
+ * Make sure we set the proper defaults for a Sent folder.
+ */
+add_task(async function test_column_defaults_sent() {
+ folderSent = await create_folder("ColumnsSent");
+ folderSent.setFlag(Ci.nsMsgFolderFlags.SentMail);
+
+ await be_in_folder(folderSent);
+ assert_visible_columns(SENT_DEFAULTS);
+ assert_visible_cards_columns(CARDS_SENT_DEFAULTS);
+});
+
+/**
+ * Make sure we set the proper defaults for a multi-folder virtual folder.
+ */
+add_task(async function test_column_defaults_cross_folder_virtual_folder() {
+ folderVirtual = create_virtual_folder(
+ [folderInbox, folderSent],
+ {},
+ true,
+ "ColumnsVirtual"
+ );
+
+ await be_in_folder(folderVirtual);
+ assert_visible_columns(VIRTUAL_DEFAULTS);
+});
+
+/**
+ * Make sure that we initialize our columns from the inbox and that they persist
+ * after that and don't follow the inbox. This also does a good workout of the
+ * persistence logic.
+ */
+add_task(async function test_column_defaults_inherit_from_inbox() {
+ folderA = await create_folder("ColumnsA");
+ // - the folder should inherit from the inbox...
+ await be_in_folder(folderA);
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ // - if we go back to the inbox and change things then the folder's settings
+ // should not change.
+ await be_in_folder(folderInbox);
+ // show tags, hide date
+ await toggleColumn("dateCol");
+ await toggleColumn("tagsCol");
+ // (paranoia verify)
+ columnsB = INBOX_DEFAULTS.slice(0, -1);
+ columnsB.push("tagsCol");
+ assert_visible_columns(columnsB);
+
+ // make sure A did not change; it should still have dateCol.
+ await be_in_folder(folderA);
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ // and a newly created folder always gets the default set.
+ folderB = await create_folder("ColumnsB");
+ await be_in_folder(folderB);
+ assert_visible_columns(INBOX_DEFAULTS);
+ // Now change the columns for folder B so we can use it later.
+ await toggleColumn("dateCol");
+ await toggleColumn("tagsCol");
+
+ // - and if we restore the inbox, folder B should stay modified too.
+ await be_in_folder(folderInbox);
+ await toggleColumn("dateCol");
+ await toggleColumn("tagsCol");
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ await be_in_folder(folderB);
+ assert_visible_columns(columnsB);
+});
+
+/**
+ * Make sure that when we change tabs that things persist/restore correctly.
+ */
+add_task(async function test_column_visibility_persists_through_tab_changes() {
+ let tabA = await be_in_folder(folderA);
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ let tabB = await open_folder_in_new_tab(folderB);
+ assert_visible_columns(columnsB);
+
+ // - switch back and forth among the loaded and verify
+ await switch_tab(tabA);
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ await switch_tab(tabB);
+ assert_visible_columns(columnsB);
+
+ // - change things and make sure the changes stick
+ // B gain accountCol
+ let bWithExtra = columnsB.concat(["accountCol"]);
+ await toggleColumn("accountCol");
+ assert_visible_columns(bWithExtra);
+
+ await switch_tab(tabA);
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ // A loses junk
+ let aSansJunk = INBOX_DEFAULTS.slice(0, -2); // nukes junk, date
+ await toggleColumn("junkStatusCol");
+ aSansJunk.push("dateCol"); // put date back
+ assert_visible_columns(aSansJunk);
+
+ await switch_tab(tabB);
+ assert_visible_columns(bWithExtra);
+ // B goes back to normal
+ await toggleColumn("accountCol");
+
+ await switch_tab(tabA);
+ assert_visible_columns(aSansJunk);
+ // A goes back to "normal"
+ await toggleColumn("junkStatusCol");
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ close_tab(tabB);
+});
+
+/**
+ * Make sure that when we change folders that things persist/restore correctly.
+ */
+add_task(
+ async function test_column_visibility_persists_through_folder_changes() {
+ await be_in_folder(folderA);
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ // more for A
+ let aWithExtra = INBOX_DEFAULTS.concat(["sizeCol", "tagsCol"]);
+ await toggleColumn("sizeCol");
+ await toggleColumn("tagsCol");
+ assert_visible_columns(aWithExtra);
+
+ await be_in_folder(folderB);
+ assert_visible_columns(columnsB);
+
+ // B gain accountCol
+ let bWithExtra = columnsB.concat(["accountCol"]);
+ await toggleColumn("accountCol");
+ assert_visible_columns(bWithExtra);
+
+ // check A
+ await be_in_folder(folderA);
+ assert_visible_columns(aWithExtra);
+
+ // check B
+ await be_in_folder(folderB);
+ assert_visible_columns(bWithExtra);
+
+ // restore B
+ await toggleColumn("accountCol");
+
+ // restore A
+ await be_in_folder(folderA);
+ await toggleColumn("sizeCol");
+ await toggleColumn("tagsCol");
+
+ // check B
+ await be_in_folder(folderB);
+ assert_visible_columns(columnsB);
+
+ // check A
+ await be_in_folder(folderA);
+ assert_visible_columns(INBOX_DEFAULTS);
+ }
+);
+
+/**
+ * Test that reordering persists through tab changes and folder changes.
+ */
+add_task(async function test_column_reordering_persists() {
+ let tabA = await be_in_folder(folderA);
+ let tabB = await open_folder_in_new_tab(folderB);
+
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+
+ // Move the tags column before the junk.
+ let tagsColButton = about3Pane.document.getElementById("tagsColButton");
+ tagsColButton.focus();
+ // Press Alt + Arrow Left twice to move the tags column before the junk
+ // status column.
+ EventUtils.synthesizeKey(
+ "KEY_ArrowLeft",
+ { altKey: true },
+ about3Pane.window
+ );
+ EventUtils.synthesizeKey(
+ "KEY_ArrowLeft",
+ { altKey: true },
+ about3Pane.window
+ );
+
+ // The columns in folderB should reflect the new order.
+ let reorderdB = columnsB.concat();
+ info(reorderdB);
+ reorderdB.splice(5, 0, reorderdB.splice(7, 1)[0]);
+ info(reorderdB);
+ assert_visible_columns(reorderdB);
+
+ // Move the tags column after the junk, the focus should still be on the
+ // tags button.
+ EventUtils.synthesizeKey(
+ "KEY_ArrowRight",
+ { altKey: true },
+ about3Pane.window
+ );
+
+ reorderdB.splice(6, 0, reorderdB.splice(5, 1)[0]);
+ assert_visible_columns(reorderdB);
+
+ await switch_tab(tabA);
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ await switch_tab(tabB);
+ assert_visible_columns(reorderdB);
+
+ await be_in_folder(folderInbox);
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ await be_in_folder(folderB);
+ assert_visible_columns(reorderdB);
+
+ close_tab(tabB);
+});
+
+async function invoke_column_picker_option(aActions) {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+
+ let colPicker = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ );
+ let colPickerPopup = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] menupopup`
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(colPicker, {}, about3Pane);
+ await shownPromise;
+ await click_menus_in_sequence(colPickerPopup, aActions);
+}
+
+/**
+ * The column picker's "reset columns to default" option should set our state
+ * back to the natural state.
+ */
+add_task(async function test_reset_to_inbox() {
+ // We should be in the inbox folder and have the default set unchanged.
+ assert_visible_columns(INBOX_DEFAULTS);
+
+ // Show the size column.
+ let conExtra = INBOX_DEFAULTS.concat(["sizeCol"]);
+ await toggleColumn("sizeCol");
+ assert_visible_columns(conExtra);
+
+ // Trigger a reset.
+ await invoke_column_picker_option([{ label: "Restore column order" }]);
+ // Ensure the default set was restored.
+ assert_visible_columns(INBOX_DEFAULTS);
+});
+
+async function _apply_to_folder_common(aChildrenToo, folder) {
+ let notificatonPromise;
+ if (aChildrenToo) {
+ notificatonPromise = TestUtils.topicObserved(
+ "msg-folder-columns-propagated"
+ );
+ }
+
+ const menuItems = [
+ { class: "applyTo-menu" },
+ {
+ class: aChildrenToo
+ ? "applyToFolderAndChildren-menu"
+ : "applyToFolder-menu",
+ },
+ { label: "Local Folders" },
+ ];
+ if (!folder.isServer) {
+ menuItems.push({ label: folder.name });
+ }
+ menuItems.push(menuItems.at(-1));
+
+ const dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ await invoke_column_picker_option(menuItems);
+ await dialogPromise;
+
+ if (notificatonPromise) {
+ await notificatonPromise;
+ }
+}
+
+/**
+ * Change settings in a folder, apply them to another folder that also has
+ * children. Make sure the folder changes but the children do not.
+ */
+add_task(async function test_apply_to_folder_no_children() {
+ folderParent = await create_folder("ColumnsApplyParent");
+ folderParent.createSubfolder("Child1", null);
+ folderChild1 = folderParent.getChildNamed("Child1");
+ folderParent.createSubfolder("Child2", null);
+ folderChild2 = folderParent.getChildNamed("Child2");
+
+ await be_in_folder(folderSource);
+
+ // reset!
+ await invoke_column_picker_option([{ label: "Restore column order" }]);
+
+ // permute!
+ let conExtra = INBOX_DEFAULTS.concat(["sizeCol"]);
+ await toggleColumn("sizeCol");
+ assert_visible_columns(conExtra);
+
+ // apply to the one dude
+ await _apply_to_folder_common(false, folderParent);
+
+ // make sure it copied to the parent
+ await be_in_folder(folderParent);
+ assert_visible_columns(conExtra);
+
+ // but not the children
+ await be_in_folder(folderChild1);
+ assert_visible_columns(INBOX_DEFAULTS);
+ await be_in_folder(folderChild2);
+ assert_visible_columns(INBOX_DEFAULTS);
+});
+
+/**
+ * Change settings in a folder, apply them to another folder and its children.
+ * Make sure the folder and its children change.
+ */
+add_task(async function test_apply_to_folder_and_children() {
+ // no need to throttle ourselves during testing.
+ MailUtils.INTER_FOLDER_PROCESSING_DELAY_MS = 0;
+
+ await be_in_folder(folderSource);
+
+ // reset!
+ await invoke_column_picker_option([{ label: "Restore column order" }]);
+ let cols = get_visible_threadtree_columns();
+
+ // permute!
+ let conExtra = cols.concat(["tagsCol"]);
+ await toggleColumn("tagsCol");
+ assert_visible_columns(conExtra);
+
+ // apply to the dude and his offspring
+ await _apply_to_folder_common(true, folderParent);
+
+ // make sure it copied to the parent and his children
+ await be_in_folder(folderParent);
+ assert_visible_columns(conExtra);
+ await be_in_folder(folderChild1);
+ assert_visible_columns(conExtra);
+ await be_in_folder(folderChild2);
+ assert_visible_columns(conExtra);
+});
+
+/**
+ * Change settings in an incoming folder, apply them to an outgoing folder that
+ * also has children. Make sure the folder changes but the children do not.
+ */
+add_task(async function test_apply_to_folder_no_children_swapped() {
+ folderParent = await create_folder("ColumnsApplyParentOutgoing");
+ folderParent.setFlag(Ci.nsMsgFolderFlags.SentMail);
+ folderParent.createSubfolder("Child1", null);
+ folderChild1 = folderParent.getChildNamed("Child1");
+ folderParent.createSubfolder("Child2", null);
+ folderChild2 = folderParent.getChildNamed("Child2");
+
+ await be_in_folder(folderSource);
+
+ // reset!
+ await invoke_column_picker_option([{ label: "Restore column order" }]);
+
+ // permute!
+ let conExtra = [...INBOX_DEFAULTS];
+ if (useCorrespondent) {
+ conExtra[5] = "senderCol";
+ await toggleColumn("correspondentCol");
+ await toggleColumn("senderCol");
+ } else {
+ conExtra[5] = "correspondentCol";
+ await toggleColumn("senderCol");
+ await toggleColumn("correspondentCol");
+ }
+ assert_visible_columns(conExtra);
+
+ // Apply to the one dude.
+ await _apply_to_folder_common(false, folderParent);
+
+ // Make sure it copied to the parent.
+ let conExtraSwapped = [...SENT_DEFAULTS];
+ conExtraSwapped[5] = useCorrespondent ? "recipientCol" : "correspondentCol";
+ await be_in_folder(folderParent);
+ assert_visible_columns(conExtraSwapped);
+
+ // But not the children.
+ await be_in_folder(folderChild1);
+ assert_visible_columns(SENT_DEFAULTS);
+ await be_in_folder(folderChild2);
+ assert_visible_columns(SENT_DEFAULTS);
+});
+
+/**
+ * Change settings in an incoming folder, apply them to an outgoing folder and
+ * its children. Make sure the folder and its children change.
+ */
+add_task(async function test_apply_to_folder_and_children_swapped() {
+ // No need to throttle ourselves during testing.
+ MailUtils.INTER_FOLDER_PROCESSING_DELAY_MS = 0;
+
+ await be_in_folder(folderSource);
+
+ // reset order!
+ await invoke_column_picker_option([{ label: "Restore column order" }]);
+
+ // permute!
+ let conExtra = [...INBOX_DEFAULTS];
+ if (useCorrespondent) {
+ conExtra[5] = "senderCol";
+ await toggleColumn("correspondentCol");
+ await toggleColumn("senderCol");
+ } else {
+ conExtra[5] = "correspondentCol";
+ await toggleColumn("senderCol");
+ await toggleColumn("correspondentCol");
+ }
+ assert_visible_columns(conExtra);
+
+ // Apply to the dude and his offspring.
+ await _apply_to_folder_common(true, folderParent);
+
+ // Make sure it copied to the parent and his children.
+ let conExtraSwapped = [...SENT_DEFAULTS];
+ conExtraSwapped[5] = useCorrespondent ? "recipientCol" : "correspondentCol";
+ await be_in_folder(folderParent);
+ assert_visible_columns(conExtraSwapped);
+ await be_in_folder(folderChild1);
+ assert_visible_columns(conExtraSwapped);
+ await be_in_folder(folderChild2);
+ assert_visible_columns(conExtraSwapped);
+});
+
+/**
+ * Change settings in a folder, apply them to the root folder and its children.
+ * Make sure the children change.
+ */
+add_task(async function test_apply_to_root_folder_and_children() {
+ // No need to throttle ourselves during testing.
+ MailUtils.INTER_FOLDER_PROCESSING_DELAY_MS = 0;
+
+ await be_in_folder(folderSource);
+
+ // Reset!
+ await invoke_column_picker_option([{ label: "Restore column order" }]);
+ const cols = get_visible_threadtree_columns();
+
+ // Permute!
+ const conExtra = cols.concat(["locationCol"]);
+ await toggleColumn("locationCol");
+ assert_visible_columns(conExtra);
+
+ // Apply to the root folder and its descendants.
+ await _apply_to_folder_common(true, folderSource.rootFolder);
+
+ // Make sure it is copied to all folders of this server.
+ for (const folder of folderSource.rootFolder.descendants) {
+ await be_in_folder(folder);
+ assert_visible_columns(conExtra);
+ folder.msgDatabase = null;
+ }
+});
+
+/**
+ * Create a fake gloda collection.
+ */
+class FakeCollection {
+ constructor() {
+ this.items = [];
+ }
+}
+
+add_task(async function test_column_defaults_gloda_collection() {
+ let tabmail = document.getElementById("tabmail");
+ let tab = tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ collection: new FakeCollection(),
+ }),
+ title: "Test gloda results",
+ });
+ await BrowserTestUtils.waitForCondition(
+ () => tab.chromeBrowser.contentWindow.gViewWrapper?.isSynthetic,
+ "synthetic view loaded"
+ );
+ assert_visible_columns(GLODA_DEFAULTS);
+ close_tab(tab);
+});
+
+add_task(async function test_persist_columns_gloda_collection() {
+ let fakeCollection = new FakeCollection();
+ let tabmail = document.getElementById("tabmail");
+ let tab1 = tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ collection: fakeCollection,
+ }),
+ title: "Test gloda results 1",
+ });
+ await BrowserTestUtils.waitForCondition(
+ () => tab1.chromeBrowser.contentWindow.gViewWrapper?.isSynthetic,
+ "synthetic view loaded"
+ );
+
+ await toggleColumn("locationCol");
+ await toggleColumn("accountCol");
+
+ // GLODA_DEFAULTS sans 'locationCol' but gains 'accountCol'
+ let glodaColumns = GLODA_DEFAULTS.slice(0, -1);
+ glodaColumns.push("accountCol");
+
+ let tab2 = tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ collection: fakeCollection,
+ }),
+ title: "Test gloda results 2",
+ });
+ await BrowserTestUtils.waitForCondition(
+ () => tab2.chromeBrowser.contentWindow.gViewWrapper?.isSynthetic,
+ "synthetic view loaded"
+ );
+ assert_visible_columns(glodaColumns);
+
+ // Restore default gloda columns for debug ease.
+ await toggleColumn("locationCol");
+ await toggleColumn("accountCol");
+
+ close_tab(tab2);
+ close_tab(tab1);
+});
+
+add_task(async function test_reset_columns_gloda_collection() {
+ let fakeCollection = new FakeCollection();
+ let tabmail = document.getElementById("tabmail");
+ let tab1 = tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ collection: fakeCollection,
+ }),
+ title: "Test gloda results 1",
+ });
+ await BrowserTestUtils.waitForCondition(
+ () => tab1.chromeBrowser.contentWindow.gViewWrapper?.isSynthetic,
+ "synthetic view loaded"
+ );
+
+ await toggleColumn("locationCol");
+ await toggleColumn("accountCol");
+
+ // GLODA_DEFAULTS sans 'locationCol' but gains 'accountCol'
+ let glodaColumns = GLODA_DEFAULTS.slice(0, -1);
+ glodaColumns.push("accountCol");
+
+ assert_visible_columns(glodaColumns);
+
+ // reset order!
+ await invoke_column_picker_option([{ label: "Restore column order" }]);
+
+ assert_visible_columns(GLODA_DEFAULTS);
+
+ let tab2 = tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ collection: fakeCollection,
+ }),
+ title: "Test gloda results 2",
+ });
+ await BrowserTestUtils.waitForCondition(
+ () => tab2.chromeBrowser.contentWindow.gViewWrapper?.isSynthetic,
+ "synthetic view loaded"
+ );
+ assert_visible_columns(GLODA_DEFAULTS);
+
+ // Restore default gloda columns for debug ease.
+ await toggleColumn("locationCol");
+ await toggleColumn("accountCol");
+
+ close_tab(tab2);
+ close_tab(tab1);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
+
+add_task(async function test_double_click_column_picker() {
+ let doubleClickFolder = await create_folder("double click folder");
+ await make_message_sets_in_folders([doubleClickFolder], [{ count: 1 }]);
+ await be_in_folder(doubleClickFolder);
+ await select_click_row(0);
+
+ let tabmail = document.getElementById("tabmail");
+ const currentTabInfo = tabmail.currentTabInfo;
+ let about3Pane = tabmail.currentAbout3Pane;
+
+ let colPicker = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ );
+ let colPickerPopup = about3Pane.document.querySelector(
+ `th[is="tree-view-table-column-picker"] menupopup`
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(colPicker, {}, about3Pane);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ colPickerPopup,
+ "popuphidden",
+ undefined,
+ event => event.originalTarget == colPickerPopup
+ );
+
+ const menuItem = colPickerPopup.querySelector('[value="threadCol"]');
+ menuItem.dispatchEvent(new MouseEvent("dblclick", { button: 0 }));
+
+ // The column picker menupopup doesn't close automatically on purpose.
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, about3Pane);
+ await hiddenPromise;
+
+ Assert.deepEqual(
+ tabmail.currentTabInfo,
+ currentTabInfo,
+ "No message was opened in a tab"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_deletionFromVirtualFolders.js b/comm/mail/test/browser/folder-display/browser_deletionFromVirtualFolders.js
new file mode 100644
index 0000000000..50566c62c0
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_deletionFromVirtualFolders.js
@@ -0,0 +1,383 @@
+/* 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 that deleting messages works from a virtual folder.
+ */
+
+"use strict";
+
+var {
+ assert_messages_in_view,
+ assert_selected_and_displayed,
+ assert_tab_titled_from,
+ be_in_folder,
+ create_folder,
+ get_smart_folder_named,
+ inboxFolder,
+ make_message_sets_in_folders,
+ mc,
+ open_selected_message_in_new_tab,
+ open_selected_message_in_new_window,
+ press_delete,
+ select_click_row,
+ switch_tab,
+ wait_for_all_messages_to_load,
+ get_about_3pane,
+ get_about_message,
+ delete_messages,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ plan_for_modal_dialog,
+ plan_for_window_close,
+ wait_for_modal_dialog,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailViewConstants } = ChromeUtils.import(
+ "resource:///modules/MailViewManager.jsm"
+);
+
+const { storeState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+
+var baseFolder, folder, lastMessageFolder;
+
+var tabFolder, tabMessage, tabMessageBackground, curMessage, nextMessage;
+
+var setNormal;
+
+/**
+ * The message window controller.
+ */
+var msgc;
+
+add_setup(async function () {
+ // Make sure the whole test runs with an unthreaded view in all folders.
+ Services.prefs.setIntPref("mailnews.default_view_flags", 0);
+
+ baseFolder = await create_folder("DeletionFromVirtualFoldersA");
+ // For setTagged, we want exactly as many messages as we plan to delete, so
+ // that we can test that the message window and tabs close when they run out
+ // of things to display.
+ let [, setTagged] = await make_message_sets_in_folders(
+ [baseFolder],
+ [{ count: 4 }, { count: 4 }]
+ );
+ setTagged.addTag("$label1"); // Important, by default
+ // We depend on the count for this, too
+ [setNormal] = await make_message_sets_in_folders(
+ [inboxFolder],
+ [{ count: 4 }]
+ );
+
+ // Show the smart folders view.
+ get_about_3pane().folderPane.activeModes = ["all", "smart"];
+
+ // Add the view picker to the toolbar
+ storeState({
+ mail: ["view-picker"],
+ });
+ await BrowserTestUtils.waitForMutationCondition(
+ document.getElementById("unifiedToolbarContent"),
+ {
+ subtree: true,
+ childList: true,
+ },
+ () => document.querySelector("#unifiedToolbarContent .view-picker")
+ );
+
+ registerCleanupFunction(() => {
+ storeState({});
+ Services.prefs.clearUserPref("mailnews.default_view_flags");
+ get_about_3pane().folderPane.activeModes = ["all"];
+ });
+});
+
+// Check whether this message is displayed in the folder tab
+var VERIFY_FOLDER_TAB = 0x1;
+// Check whether this message is displayed in the foreground message tab
+var VERIFY_MESSAGE_TAB = 0x2;
+// Check whether this message is displayed in the background message tab
+var VERIFY_BACKGROUND_MESSAGE_TAB = 0x4;
+// Check whether this message is displayed in the message window
+var VERIFY_MESSAGE_WINDOW = 0x8;
+var VERIFY_ALL = 0xf;
+
+/**
+ * Verify that the message is displayed in the given tabs. The index is
+ * optional.
+ */
+async function _verify_message_is_displayed_in(aFlags, aMessage, aIndex) {
+ if (aFlags & VERIFY_FOLDER_TAB) {
+ await switch_tab(tabFolder);
+ assert_selected_and_displayed(aMessage);
+ if (aIndex !== undefined) {
+ assert_selected_and_displayed(aIndex);
+ }
+ }
+ if (aFlags & VERIFY_MESSAGE_TAB) {
+ // Verify the title first
+ assert_tab_titled_from(tabMessage, aMessage);
+ await switch_tab(tabMessage);
+ // Verify the title again, just in case
+ assert_tab_titled_from(tabMessage, aMessage);
+ assert_selected_and_displayed(aMessage);
+ if (aIndex !== undefined) {
+ assert_selected_and_displayed(aIndex);
+ }
+ }
+ if (aFlags & VERIFY_BACKGROUND_MESSAGE_TAB) {
+ // Only verify the title
+ assert_tab_titled_from(tabMessageBackground, aMessage);
+ }
+ if (aFlags & VERIFY_MESSAGE_WINDOW) {
+ assert_selected_and_displayed(msgc, aMessage);
+ if (aIndex !== undefined) {
+ assert_selected_and_displayed(msgc, aIndex);
+ }
+ }
+}
+
+add_task(async function test_create_virtual_folders() {
+ await be_in_folder(baseFolder);
+
+ // Apply the mail view
+ mc.window.RefreshAllViewPopups(
+ mc.window.document.getElementById("toolbarViewPickerPopup")
+ );
+ mc.window.ViewChange(":$label1");
+ wait_for_all_messages_to_load();
+
+ // - save it
+ plan_for_modal_dialog(
+ "mailnews:virtualFolderProperties",
+ subtest_save_mail_view
+ );
+ // we have to use value here because the option mechanism is not sophisticated
+ // enough.
+ mc.window.ViewChange(MailViewConstants.kViewItemVirtual);
+ wait_for_modal_dialog("mailnews:virtualFolderProperties");
+});
+
+function subtest_save_mail_view(savc) {
+ savc.window.document.querySelector("dialog").acceptDialog();
+}
+
+async function _open_first_message() {
+ // Enter the folder and open a message
+ tabFolder = await be_in_folder(folder);
+ curMessage = select_click_row(0);
+ assert_selected_and_displayed(curMessage);
+
+ // Open the tab with the message
+ tabMessage = await open_selected_message_in_new_tab();
+ assert_selected_and_displayed(curMessage);
+ assert_tab_titled_from(tabMessage, curMessage);
+
+ await switch_tab(tabFolder);
+
+ // Open another tab with the message, this time in the background
+ tabMessageBackground = await open_selected_message_in_new_tab(true);
+ assert_tab_titled_from(tabMessageBackground, curMessage);
+
+ // Open the window with the message
+ await switch_tab(tabFolder);
+ msgc = await open_selected_message_in_new_window();
+ assert_selected_and_displayed(msgc, curMessage);
+}
+
+add_task(async function test_open_first_message_in_virtual_folder() {
+ folder = baseFolder.getChildNamed(baseFolder.prettyName + "-Important");
+ if (!folder) {
+ throw new Error("DeletionFromVirtualFoldersA-Important was not created!");
+ }
+
+ await _open_first_message();
+});
+
+/**
+ * Perform a deletion from the folder tab, verify the others update correctly
+ * (advancing to the next message).
+ */
+add_task(async function test_delete_from_virtual_folder_in_folder_tab() {
+ const { gDBView } = get_about_3pane();
+ // - plan to end up on the guy who is currently at index 1
+ curMessage = gDBView.getMsgHdrAt(1);
+ // while we're at it, figure out who is at 2 for the next step
+ nextMessage = gDBView.getMsgHdrAt(2);
+ // - delete the message
+ press_delete();
+
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+});
+
+/**
+ * Perform a deletion from the message tab, verify the others update correctly
+ * (advancing to the next message).
+ */
+add_task(async function test_delete_from_virtual_folder_in_message_tab() {
+ await switch_tab(tabMessage);
+ // nextMessage is the guy we want to see once the delete completes.
+ press_delete();
+ curMessage = nextMessage;
+
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+
+ const { gDBView } = get_about_message();
+ // figure out the next guy...
+ nextMessage = gDBView.getMsgHdrAt(1);
+ if (!nextMessage) {
+ throw new Error("We ran out of messages early?");
+ }
+});
+
+/**
+ * Perform a deletion from the message window, verify the others update
+ * correctly (advancing to the next message).
+ */
+add_task(async function test_delete_from_virtual_folder_in_message_window() {
+ // - delete
+ press_delete(msgc);
+ curMessage = nextMessage;
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+});
+
+/**
+ * Delete the last message in that folder, which should close all message
+ * displays.
+ */
+add_task(
+ async function test_delete_last_message_from_virtual_folder_closes_message_displays() {
+ // - since we have both foreground and background message tabs, we don't need
+ // to open yet another tab to test
+
+ // - prep for the message window disappearing
+ plan_for_window_close(msgc);
+
+ // - let's arbitrarily perform the deletion on this message tab
+ await switch_tab(tabMessage);
+ press_delete();
+
+ // - the message window should have gone away...
+ // (this also helps ensure that the 3pane gets enough event loop time to do
+ // all that it needs to accomplish)
+ wait_for_window_close(msgc);
+ msgc = null;
+
+ // - and we should now be on the folder tab and there should be no other tabs
+ if (mc.window.document.getElementById("tabmail").tabInfo.length != 1) {
+ throw new Error("There should only be one tab left!");
+ }
+ // the below check is implied by the previous check if things are sane-ish
+ if (
+ mc.window.document.getElementById("tabmail").currentTabInfo != tabFolder
+ ) {
+ throw new Error("We should be on the folder tab!");
+ }
+ }
+);
+
+/**
+ * Open the first message in the smart inbox.
+ */
+add_task(async function test_open_first_message_in_smart_inbox() {
+ // Select the smart inbox
+ folder = get_smart_folder_named("Inbox");
+ await be_in_folder(folder);
+ assert_messages_in_view(setNormal);
+ // Open the first message
+ await _open_first_message();
+});
+
+/**
+ * Perform a deletion from the folder tab, verify the others update correctly
+ * (advancing to the next message).
+ */
+add_task(async function test_delete_from_smart_inbox_in_folder_tab() {
+ const { gDBView } = get_about_3pane();
+ // - plan to end up on the guy who is currently at index 1
+ curMessage = gDBView.getMsgHdrAt(1);
+ // while we're at it, figure out who is at 2 for the next step
+ nextMessage = gDBView.getMsgHdrAt(2);
+ // - delete the message
+ press_delete();
+
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+});
+
+/**
+ * Perform a deletion from the message tab, verify the others update correctly
+ * (advancing to the next message).
+ */
+add_task(async function test_delete_from_smart_inbox_in_message_tab() {
+ await switch_tab(tabMessage);
+ // nextMessage is the guy we want to see once the delete completes.
+ press_delete();
+ curMessage = nextMessage;
+
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+
+ const { gDBView } = get_about_message();
+ // figure out the next guy...
+ nextMessage = gDBView.getMsgHdrAt(1);
+ if (!nextMessage) {
+ throw new Error("We ran out of messages early?");
+ }
+});
+
+/**
+ * Perform a deletion from the message window, verify the others update
+ * correctly (advancing to the next message).
+ */
+add_task(async function test_delete_from_smart_inbox_in_message_window() {
+ // - delete
+ press_delete(msgc);
+ curMessage = nextMessage;
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+});
+
+/**
+ * Delete the last message in that folder, which should close all message
+ * displays.
+ */
+add_task(
+ async function test_delete_last_message_from_smart_inbox_closes_message_displays() {
+ // - since we have both foreground and background message tabs, we don't need
+ // to open yet another tab to test
+
+ // - prep for the message window disappearing
+ plan_for_window_close(msgc);
+
+ // - let's arbitrarily perform the deletion on this message tab
+ await switch_tab(tabMessage);
+ press_delete();
+
+ // - the message window should have gone away...
+ // (this also helps ensure that the 3pane gets enough event loop time to do
+ // all that it needs to accomplish)
+ wait_for_window_close(msgc);
+ msgc = null;
+
+ // - and we should now be on the folder tab and there should be no other tabs
+ if (mc.window.document.getElementById("tabmail").tabInfo.length != 1) {
+ throw new Error("There should only be one tab left!");
+ }
+ // the below check is implied by the previous check if things are sane-ish
+ if (
+ mc.window.document.getElementById("tabmail").currentTabInfo != tabFolder
+ ) {
+ throw new Error("We should be on the folder tab!");
+ }
+ }
+);
diff --git a/comm/mail/test/browser/folder-display/browser_deletionWithMultipleDisplays.js b/comm/mail/test/browser/folder-display/browser_deletionWithMultipleDisplays.js
new file mode 100644
index 0000000000..1bc4e67e49
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_deletionWithMultipleDisplays.js
@@ -0,0 +1,787 @@
+/* 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 that deleting a message in a given tab or window properly updates both
+ * that tab/window as well as all other tabs/windows. We also test that the
+ * message tab title updates appropriately through all of this. We do all of
+ * this both for tabs that have ever been opened in the foreground, and tabs
+ * that haven't (and thus might have fake selections).
+ */
+
+"use strict";
+
+var {
+ assert_selected_and_displayed,
+ assert_tab_titled_from,
+ be_in_folder,
+ close_message_window,
+ close_tab,
+ create_folder,
+ get_about_3pane,
+ get_about_message,
+ make_message_sets_in_folders,
+ mc,
+ open_selected_message_in_new_tab,
+ open_selected_message_in_new_window,
+ press_delete,
+ select_click_row,
+ select_control_click_row,
+ select_shift_click_row,
+ switch_tab,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { plan_for_window_close, wait_for_window_close } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var folder,
+ lastMessageFolder,
+ oneBeforeFolder,
+ oneAfterFolder,
+ multipleDeletionFolder1,
+ multipleDeletionFolder2,
+ multipleDeletionFolder3,
+ multipleDeletionFolder4;
+
+// Adjust timeout to take care of code coverage runs needing twice as long.
+requestLongerTimeout(AppConstants.MOZ_CODE_COVERAGE ? 4 : 2);
+
+add_setup(async function () {
+ folder = await create_folder("DeletionA");
+ lastMessageFolder = await create_folder("DeletionB");
+ oneBeforeFolder = await create_folder("DeletionC");
+ oneAfterFolder = await create_folder("DeletionD");
+ multipleDeletionFolder1 = await create_folder("DeletionE");
+ multipleDeletionFolder2 = await create_folder("DeletionF");
+ multipleDeletionFolder3 = await create_folder("DeletionG");
+ multipleDeletionFolder4 = await create_folder("DeletionH");
+ // we want exactly as many messages as we plan to delete, so that we can test
+ // that the message window and tabs close when they run out of things to
+ // to display.
+ await make_message_sets_in_folders([folder], [{ count: 4 }]);
+
+ // since we don't test window close here, it doesn't really matter how many
+ // messages these have
+
+ await make_message_sets_in_folders([lastMessageFolder], [{ count: 4 }]);
+ await make_message_sets_in_folders([oneBeforeFolder], [{ count: 10 }]);
+ await make_message_sets_in_folders([oneAfterFolder], [{ count: 10 }]);
+ await make_message_sets_in_folders(
+ [multipleDeletionFolder1],
+ [{ count: 30 }]
+ );
+
+ // We're depending on selecting the last message here, so these do matter
+ await make_message_sets_in_folders(
+ [multipleDeletionFolder2],
+ [{ count: 10 }]
+ );
+ await make_message_sets_in_folders(
+ [multipleDeletionFolder3],
+ [{ count: 10 }]
+ );
+ await make_message_sets_in_folders(
+ [multipleDeletionFolder4],
+ [{ count: 10 }]
+ );
+});
+
+var tabFolder, tabMessage, tabMessageBackground, curMessage, nextMessage;
+
+/**
+ * The message window controller. Short names because controllers get used a
+ * lot.
+ */
+var msgc;
+
+/**
+ * Open up the message at aIndex in all our display mechanisms, and check to see
+ * if the displays are all correct. This also sets up all our globals.
+ */
+async function _open_message_in_all_four_display_mechanisms_helper(
+ aFolder,
+ aIndex
+) {
+ // - Select the message in this tab.
+ tabFolder = await be_in_folder(aFolder);
+ curMessage = select_click_row(aIndex);
+ assert_selected_and_displayed(curMessage);
+
+ // - Open the tab with the message
+ tabMessage = await open_selected_message_in_new_tab();
+ assert_selected_and_displayed(curMessage);
+ assert_tab_titled_from(tabMessage, curMessage);
+
+ // go back to the folder tab
+ await switch_tab(tabFolder);
+
+ // - Open another tab with the message, this time in the background
+ tabMessageBackground = await open_selected_message_in_new_tab(true);
+ assert_tab_titled_from(tabMessageBackground, curMessage);
+
+ // - Open the window with the message
+ // need to go back to the folder tab. (well, should.)
+ await switch_tab(tabFolder);
+ msgc = await open_selected_message_in_new_window();
+ assert_selected_and_displayed(msgc, curMessage);
+}
+
+// Check whether this message is displayed in the folder tab
+var VERIFY_FOLDER_TAB = 0x1;
+// Check whether this message is displayed in the foreground message tab
+var VERIFY_MESSAGE_TAB = 0x2;
+// Check whether this message is displayed in the background message tab
+var VERIFY_BACKGROUND_MESSAGE_TAB = 0x4;
+// Check whether this message is displayed in the message window
+var VERIFY_MESSAGE_WINDOW = 0x8;
+var VERIFY_ALL = 0xf;
+
+/**
+ * Verify that the message is displayed in the given tabs. The index is
+ * optional.
+ */
+async function _verify_message_is_displayed_in(aFlags, aMessage, aIndex) {
+ if (aFlags & VERIFY_FOLDER_TAB) {
+ await switch_tab(tabFolder);
+ Assert.equal(
+ get_about_message().gMessage,
+ aMessage,
+ "folder tab shows the correct message"
+ );
+ assert_selected_and_displayed(aMessage);
+ if (aIndex !== undefined) {
+ assert_selected_and_displayed(aIndex);
+ }
+ }
+ if (aFlags & VERIFY_MESSAGE_TAB) {
+ // Verify the title first
+ assert_tab_titled_from(tabMessage, aMessage);
+ await switch_tab(tabMessage);
+ // Verify the title again, just in case
+ Assert.equal(
+ get_about_message().gMessageURI,
+ aMessage.folder.getUriForMsg(aMessage)
+ );
+ assert_tab_titled_from(tabMessage, aMessage);
+ Assert.equal(
+ get_about_message().gMessage,
+ aMessage,
+ "message tab shows the correct message"
+ );
+ assert_selected_and_displayed(aMessage);
+ if (aIndex !== undefined) {
+ assert_selected_and_displayed(aIndex);
+ }
+ }
+ if (aFlags & VERIFY_BACKGROUND_MESSAGE_TAB) {
+ // Only verify the title
+ assert_tab_titled_from(tabMessageBackground, aMessage);
+ }
+ if (aFlags & VERIFY_MESSAGE_WINDOW) {
+ Assert.equal(
+ get_about_message(msgc.window).gMessage,
+ aMessage,
+ "message window shows the correct message"
+ );
+ assert_selected_and_displayed(msgc, aMessage);
+ if (aIndex !== undefined) {
+ assert_selected_and_displayed(msgc, aIndex);
+ }
+ }
+}
+
+/**
+ * Have a message displayed in a folder tab, message tab (foreground and
+ * background), and message window. The idea is that as we delete from the
+ * various sources, they should all advance in lock-step through their messages,
+ * simplifying our lives (but making us explode forevermore the first time any
+ * of the tests fail.)
+ */
+add_task(
+ async function test_open_first_message_in_all_four_display_mechanisms() {
+ await _open_message_in_all_four_display_mechanisms_helper(folder, 0);
+ }
+);
+
+/**
+ * Perform a deletion from the folder tab, verify the others update correctly
+ * (advancing to the next message).
+ */
+add_task(async function test_delete_in_folder_tab() {
+ let about3Pane = get_about_3pane();
+ // - plan to end up on the guy who is currently at index 1
+ curMessage = about3Pane.gDBView.getMsgHdrAt(1);
+ // while we're at it, figure out who is at 2 for the next step
+ nextMessage = about3Pane.gDBView.getMsgHdrAt(2);
+ // - delete the message
+ press_delete();
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+});
+
+/**
+ * Perform a deletion from the message tab, verify the others update correctly
+ * (advancing to the next message).
+ */
+add_task(async function test_delete_in_message_tab() {
+ await switch_tab(tabMessage);
+ // nextMessage is the guy we want to see once the delete completes.
+ press_delete();
+ curMessage = nextMessage;
+
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+
+ // figure out the next guy...
+ nextMessage = get_about_message().gDBView.getMsgHdrAt(1);
+ if (!nextMessage) {
+ throw new Error("We ran out of messages early?");
+ }
+});
+
+/**
+ * Perform a deletion from the message window, verify the others update
+ * correctly (advancing to the next message).
+ */
+add_task(async function test_delete_in_message_window() {
+ // - delete
+ press_delete(msgc);
+ curMessage = nextMessage;
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+});
+
+/**
+ * Delete the last message in that folder, which should close all message
+ * displays.
+ */
+add_task(async function test_delete_last_message_closes_message_displays() {
+ // - since we have both foreground and background message tabs, we don't need
+ // to open yet another tab to test
+
+ // - prep for the message window disappearing
+ plan_for_window_close(msgc);
+
+ // - let's arbitrarily perform the deletion on this message tab
+ await switch_tab(tabMessage);
+ press_delete();
+
+ // - the message window should have gone away...
+ // (this also helps ensure that the 3pane gets enough event loop time to do
+ // all that it needs to accomplish)
+ wait_for_window_close(msgc);
+ msgc = null;
+
+ // - and we should now be on the folder tab and there should be no other tabs
+ if (mc.window.document.getElementById("tabmail").tabInfo.length != 1) {
+ throw new Error("There should only be one tab left!");
+ }
+ // the below check is implied by the previous check if things are sane-ish
+ if (
+ mc.window.document.getElementById("tabmail").currentTabInfo != tabFolder
+ ) {
+ throw new Error("We should be on the folder tab!");
+ }
+});
+
+/*
+ * Now we retest everything, but while deleting the last message in our
+ * selection. We need to make sure we select the previously next-to-last message
+ * in that case.
+ */
+
+/**
+ * Have the last message displayed in a folder tab, message tab (foreground and
+ * background), and message window. The idea is that as we delete from the
+ * various sources, they should all advance in lock-step through their messages,
+ * simplifying our lives (but making us explode forevermore the first time any
+ * of the tests fail.)
+ */
+add_task(
+ async function test_open_last_message_in_all_four_display_mechanisms() {
+ // since we have four messages, index 3 is the last message.
+ await _open_message_in_all_four_display_mechanisms_helper(
+ lastMessageFolder,
+ 3
+ );
+ }
+);
+
+/**
+ * Perform a deletion from the folder tab, verify the others update correctly
+ * (advancing to the next message).
+ */
+add_task(async function test_delete_last_message_in_folder_tab() {
+ let about3Pane = get_about_3pane();
+ // - plan to end up on the guy who is currently at index 2
+ curMessage = about3Pane.gDBView.getMsgHdrAt(2);
+ // while we're at it, figure out who is at 1 for the next step
+ nextMessage = about3Pane.gDBView.getMsgHdrAt(1);
+ // - delete the message
+ press_delete();
+
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 2);
+});
+
+/**
+ * Perform a deletion from the message tab, verify the others update correctly
+ * (advancing to the next message).
+ */
+add_task(async function test_delete_last_message_in_message_tab() {
+ // (we're still on the message tab, and nextMessage is the guy we want to see
+ // once the delete completes.)
+ press_delete();
+ curMessage = nextMessage;
+
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 1);
+ // figure out the next guy...
+
+ nextMessage = get_about_message().gDBView.getMsgHdrAt(0);
+ if (!nextMessage) {
+ throw new Error("We ran out of messages early?");
+ }
+});
+
+/**
+ * Perform a deletion from the message window, verify the others update
+ * correctly (advancing to the next message).
+ */
+add_task(async function test_delete_last_message_in_message_window() {
+ // Vary this up. Switch to the folder tab instead of staying on the message
+ // tab
+ await switch_tab(tabFolder);
+ // - delete
+ press_delete(msgc);
+ curMessage = nextMessage;
+ // - verify all displays
+ await _verify_message_is_displayed_in(VERIFY_ALL, curMessage, 0);
+
+ // - clean up, close the message window and displays
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+});
+
+/*
+ * Our next job is to open up a message, then delete the message one before it
+ * in another view. The other selections shouldn't be affected.
+ */
+
+/**
+ * Test "one before" deletion in the folder tab.
+ */
+add_task(async function test_delete_one_before_message_in_folder_tab() {
+ // Open up message 4 in message tabs and a window (we'll delete message 3).
+ await _open_message_in_all_four_display_mechanisms_helper(oneBeforeFolder, 4);
+
+ let expectedMessage = get_about_3pane().gDBView.getMsgHdrAt(4);
+ select_click_row(3);
+ press_delete();
+
+ // The message tab, background message tab and window shouldn't have changed
+ await _verify_message_is_displayed_in(
+ VERIFY_MESSAGE_TAB | VERIFY_BACKGROUND_MESSAGE_TAB | VERIFY_MESSAGE_WINDOW,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+});
+
+/**
+ * Test "one before" deletion in the message tab.
+ */
+add_task(async function test_delete_one_before_message_in_message_tab() {
+ // Open up 3 in a message tab, then select and open up 4 in a background tab
+ // and window.
+ select_click_row(3);
+ tabMessage = await open_selected_message_in_new_tab(true);
+ let expectedMessage = select_click_row(4);
+ tabMessageBackground = await open_selected_message_in_new_tab(true);
+ msgc = await open_selected_message_in_new_window(true);
+
+ // Switch to the message tab, and delete.
+ await switch_tab(tabMessage);
+ press_delete();
+
+ // The folder tab, background message tab and window shouldn't have changed
+ await _verify_message_is_displayed_in(
+ VERIFY_FOLDER_TAB | VERIFY_BACKGROUND_MESSAGE_TAB | VERIFY_MESSAGE_WINDOW,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+});
+
+/**
+ * Test "one before" deletion in the message window.
+ */
+add_task(async function test_delete_one_before_message_in_message_window() {
+ // Open up 3 in a message window, then select and open up 4 in a background
+ // and a foreground tab.
+ select_click_row(3);
+ msgc = await open_selected_message_in_new_window();
+ let expectedMessage = select_click_row(4);
+ tabMessage = await open_selected_message_in_new_tab();
+ await switch_tab(tabFolder);
+ tabMessageBackground = await open_selected_message_in_new_tab(true);
+
+ // Press delete in the message window.
+ press_delete(msgc);
+
+ // The folder tab, message tab and background message tab shouldn't have
+ // changed
+ await _verify_message_is_displayed_in(
+ VERIFY_FOLDER_TAB | VERIFY_MESSAGE_TAB | VERIFY_BACKGROUND_MESSAGE_TAB,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+});
+
+/*
+ * Now do all of that again, but this time delete the message _after_ the open one.
+ */
+
+/**
+ * Test "one after" deletion in the folder tab.
+ */
+add_task(async function test_delete_one_after_message_in_folder_tab() {
+ // Open up message 4 in message tabs and a window (we'll delete message 5).
+ await _open_message_in_all_four_display_mechanisms_helper(oneAfterFolder, 4);
+
+ let expectedMessage = get_about_3pane().gDBView.getMsgHdrAt(4);
+ select_click_row(5);
+ press_delete();
+
+ // The message tab, background message tab and window shouldn't have changed
+ await _verify_message_is_displayed_in(
+ VERIFY_MESSAGE_TAB | VERIFY_BACKGROUND_MESSAGE_TAB | VERIFY_MESSAGE_WINDOW,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+});
+
+/**
+ * Test "one after" deletion in the message tab.
+ */
+add_task(async function test_delete_one_after_message_in_message_tab() {
+ // Open up 5 in a message tab, then select and open up 4 in a background tab
+ // and window.
+ select_click_row(5);
+ tabMessage = await open_selected_message_in_new_tab(true);
+ let expectedMessage = select_click_row(4);
+ tabMessageBackground = await open_selected_message_in_new_tab(true);
+ msgc = await open_selected_message_in_new_window(true);
+
+ // Switch to the message tab, and delete.
+ await switch_tab(tabMessage);
+ press_delete();
+
+ // The folder tab, background message tab and window shouldn't have changed
+ await _verify_message_is_displayed_in(
+ VERIFY_FOLDER_TAB | VERIFY_BACKGROUND_MESSAGE_TAB | VERIFY_MESSAGE_WINDOW,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+});
+
+/**
+ * Test "one after" deletion in the message window.
+ */
+add_task(async function test_delete_one_after_message_in_message_window() {
+ // Open up 5 in a message window, then select and open up 4 in a background
+ // and a foreground tab.
+ select_click_row(5);
+ msgc = await open_selected_message_in_new_window();
+ let expectedMessage = select_click_row(4);
+ tabMessage = await open_selected_message_in_new_tab();
+ await switch_tab(tabFolder);
+ tabMessageBackground = await open_selected_message_in_new_tab(true);
+
+ // Press delete in the message window.
+ press_delete(msgc);
+
+ // The folder tab, message tab and background message tab shouldn't have
+ // changed
+ await _verify_message_is_displayed_in(
+ VERIFY_FOLDER_TAB | VERIFY_MESSAGE_TAB | VERIFY_BACKGROUND_MESSAGE_TAB,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+});
+
+/*
+ * Delete multiple messages in a folder tab. Make sure message displays at the
+ * beginning, middle and end of a selection work out.
+ */
+
+/**
+ * Test deleting multiple messages in a folder tab, with message displays open
+ * to the beginning of a selection.
+ */
+add_task(
+ async function test_delete_multiple_messages_with_first_selected_message_open() {
+ // Open up 2 in a message tab, background tab, and message window.
+ await _open_message_in_all_four_display_mechanisms_helper(
+ multipleDeletionFolder1,
+ 2
+ );
+
+ // We'll select 2-5, 8, 9 and 10. We expect 6 to be the next displayed
+ // message.
+ select_click_row(2);
+ select_shift_click_row(5);
+ select_control_click_row(8);
+ select_control_click_row(9);
+ select_control_click_row(10);
+ let expectedMessage = get_about_3pane().gDBView.getMsgHdrAt(6);
+
+ // Delete the selected messages
+ press_delete();
+
+ // All the displays should now be showing the expectedMessage
+ await _verify_message_is_displayed_in(VERIFY_ALL, expectedMessage);
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+ }
+);
+
+/**
+ * Test deleting multiple messages in a folder tab, with message displays open
+ * to somewhere in the middle of a selection.
+ */
+add_task(
+ async function test_delete_multiple_messages_with_nth_selected_message_open() {
+ // Open up 9 in a message tab, background tab, and message window.
+ await _open_message_in_all_four_display_mechanisms_helper(
+ multipleDeletionFolder1,
+ 9
+ );
+
+ // We'll select 2-5, 8, 9 and 10. We expect 11 to be the next displayed
+ // message.
+ select_click_row(2);
+ select_shift_click_row(5);
+ select_control_click_row(8);
+ select_control_click_row(9);
+ select_control_click_row(10);
+ let expectedMessage = get_about_3pane().gDBView.getMsgHdrAt(11);
+
+ // Delete the selected messages
+ press_delete();
+
+ // The folder tab should now be showing message 2
+ assert_selected_and_displayed(2);
+
+ // The other displays should now be showing the expectedMessage
+ await _verify_message_is_displayed_in(
+ VERIFY_MESSAGE_TAB |
+ VERIFY_BACKGROUND_MESSAGE_TAB |
+ VERIFY_MESSAGE_WINDOW,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+ }
+);
+
+/**
+ * Test deleting multiple messages in a folder tab, with message displays open
+ * to the end of a selection.
+ */
+add_task(
+ async function test_delete_multiple_messages_with_last_selected_message_open() {
+ // Open up 10 in a message tab, background tab, and message window.
+ await _open_message_in_all_four_display_mechanisms_helper(
+ multipleDeletionFolder1,
+ 9
+ );
+
+ // We'll select 2-5, 8, 9 and 10. We expect 11 to be the next displayed
+ // message.
+ select_click_row(2);
+ select_shift_click_row(5);
+ select_control_click_row(8);
+ select_control_click_row(9);
+ select_control_click_row(10);
+ let expectedMessage = get_about_3pane().gDBView.getMsgHdrAt(11);
+
+ // Delete the selected messages
+ press_delete();
+
+ // The folder tab should now be showing message 2
+ assert_selected_and_displayed(2);
+
+ // The other displays should now be showing the expectedMessage
+ await _verify_message_is_displayed_in(
+ VERIFY_MESSAGE_TAB |
+ VERIFY_BACKGROUND_MESSAGE_TAB |
+ VERIFY_MESSAGE_WINDOW,
+ expectedMessage
+ );
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+ }
+);
+
+/**
+ * Test deleting multiple messages in a folder tab (including the last one!),
+ * with message displays open to the beginning of a selection.
+ */
+add_task(
+ async function test_delete_multiple_messages_including_the_last_one_with_first_open() {
+ // 10 messages in this folder. Open up message 1 everywhere.
+ await _open_message_in_all_four_display_mechanisms_helper(
+ multipleDeletionFolder2,
+ 1
+ );
+
+ // We'll select 1-4, 7, 8 and 9. We expect 5 to be the next displayed message.
+ select_click_row(1);
+ select_shift_click_row(4);
+ select_control_click_row(7);
+ select_control_click_row(8);
+ select_control_click_row(9);
+ let expectedMessage = get_about_3pane().gDBView.getMsgHdrAt(5);
+
+ // Delete the selected messages
+ press_delete();
+
+ // All the displays should now be showing the expectedMessage
+ await _verify_message_is_displayed_in(VERIFY_ALL, expectedMessage);
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+ }
+);
+
+/**
+ * Test deleting multiple messages in a folder tab (including the last one!),
+ * with message displays open to the middle of a selection.
+ */
+add_task(
+ async function test_delete_multiple_messages_including_the_last_one_with_nth_open() {
+ // 10 messages in this folder. Open up message 7 everywhere.
+ await _open_message_in_all_four_display_mechanisms_helper(
+ multipleDeletionFolder3,
+ 7
+ );
+
+ // We'll select 1-4, 7, 8 and 9. We expect 6 to be the next displayed message.
+ select_click_row(1);
+ select_shift_click_row(4);
+ select_control_click_row(7);
+ select_control_click_row(8);
+ select_control_click_row(9);
+ let expectedMessage = get_about_3pane().gDBView.getMsgHdrAt(6);
+
+ // Delete the selected messages
+ press_delete();
+
+ // The folder tab should now be showing message 1
+ assert_selected_and_displayed(1);
+
+ // The other displays should now be showing the expectedMessage
+ await _verify_message_is_displayed_in(
+ VERIFY_MESSAGE_TAB |
+ VERIFY_BACKGROUND_MESSAGE_TAB |
+ VERIFY_MESSAGE_WINDOW,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+ }
+);
+
+/**
+ * Test deleting multiple messages in a folder tab (including the last one!),
+ * with message displays open to the end of a selection.
+ */
+add_task(
+ async function test_delete_multiple_messages_including_the_last_one_with_last_open() {
+ // 10 messages in this folder. Open up message 9 everywhere.
+ await _open_message_in_all_four_display_mechanisms_helper(
+ multipleDeletionFolder4,
+ 9
+ );
+
+ // We'll select 1-4, 7, 8 and 9. We expect 6 to be the next displayed message.
+ select_click_row(1);
+ select_shift_click_row(4);
+ select_control_click_row(7);
+ select_control_click_row(8);
+ select_control_click_row(9);
+ let expectedMessage = get_about_3pane().gDBView.getMsgHdrAt(6);
+
+ // Delete the selected messages
+ press_delete();
+
+ // The folder tab should now be showing message 1
+ assert_selected_and_displayed(1);
+
+ // The other displays should now be showing the expectedMessage
+ await _verify_message_is_displayed_in(
+ VERIFY_MESSAGE_TAB |
+ VERIFY_BACKGROUND_MESSAGE_TAB |
+ VERIFY_MESSAGE_WINDOW,
+ expectedMessage
+ );
+
+ // Clean up, close everything
+ close_message_window(msgc);
+ close_tab(tabMessage);
+ close_tab(tabMessageBackground);
+ await switch_tab(tabFolder);
+ }
+);
diff --git a/comm/mail/test/browser/folder-display/browser_displayName.js b/comm/mail/test/browser/folder-display/browser_displayName.js
new file mode 100644
index 0000000000..5e8cf323b7
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_displayName.js
@@ -0,0 +1,244 @@
+/* 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 that the display names in email addresses are correctly shown in the
+ * thread pane.
+ */
+
+"use strict";
+
+var { ensure_card_exists } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AddressBookHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_3pane,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+
+var messages = [
+ // Basic From header tests
+ {
+ name: "from_display_name_unquoted",
+ headers: { From: "Carter Burke <cburke@wyutani.invalid>" },
+ expected: { column: "from", value: "Carter Burke" },
+ },
+ {
+ name: "from_display_name_quoted",
+ headers: { From: '"Ellen Ripley" <eripley@wyutani.invalid>' },
+ expected: { column: "from", value: "Ellen Ripley" },
+ },
+ {
+ name: "from_display_name_with_comma",
+ headers: { From: '"William Gorman, Lt." <wgorman@uscmc.invalid>' },
+ expected: { column: "from", value: "William Gorman, Lt." },
+ },
+ {
+ name: "from_email_raw",
+ headers: { From: "dhicks@uscmc.invalid" },
+ expected: { column: "from", value: "dhicks@uscmc.invalid" },
+ },
+ {
+ name: "from_email_in_angle_brackets",
+ headers: { From: "<whudson@uscmc.invalid>" },
+ expected: { column: "from", value: "whudson@uscmc.invalid" },
+ },
+
+ // Basic To header tests
+ {
+ name: "to_display_name_unquoted",
+ headers: { To: "Carter Burke <cburke@wyutani.invalid>" },
+ expected: { column: "recipients", value: "Carter Burke" },
+ },
+ {
+ name: "to_display_name_quoted",
+ headers: { To: '"Ellen Ripley" <eripley@wyutani.invalid>' },
+ expected: { column: "recipients", value: "Ellen Ripley" },
+ },
+ {
+ name: "to_display_name_with_comma",
+ headers: { To: '"William Gorman, Lt." <wgorman@uscmc.invalid>' },
+ expected: { column: "recipients", value: "William Gorman, Lt." },
+ },
+ {
+ name: "to_email_raw",
+ headers: { To: "dhicks@uscmc.invalid" },
+ expected: { column: "recipients", value: "dhicks@uscmc.invalid" },
+ },
+ {
+ name: "to_email_in_angle_brackets",
+ headers: { To: "<whudson@uscmc.invalid>" },
+ expected: { column: "recipients", value: "whudson@uscmc.invalid" },
+ },
+ {
+ name: "to_display_name_multiple",
+ headers: {
+ To:
+ "Carter Burke <cburke@wyutani.invalid>, " +
+ "Dwayne Hicks <dhicks@uscmc.invalid>",
+ },
+ expected: { column: "recipients", value: "Carter Burke, Dwayne Hicks" },
+ },
+
+ // Address book tests
+ {
+ name: "from_in_abook_pdn",
+ headers: { From: "Al Apone <aapone@uscmc.invalid>" },
+ expected: { column: "from", value: "Sarge" },
+ },
+ {
+ name: "from_in_abook_no_pdn",
+ headers: { From: "Rebeccah Jorden <rjorden@hadleys-hope.invalid>" },
+ expected: { column: "from", value: "Rebeccah Jorden" },
+ },
+ {
+ name: "to_in_abook_pdn",
+ headers: { To: "Al Apone <aapone@uscmc.invalid>" },
+ expected: { column: "recipients", value: "Sarge" },
+ },
+ {
+ name: "to_in_abook_no_pdn",
+ headers: { To: "Rebeccah Jorden <rjorden@hadleys-hope.invalid>" },
+ expected: { column: "recipients", value: "Rebeccah Jorden" },
+ },
+ {
+ name: "to_in_abook_multiple_mixed_pdn",
+ headers: {
+ To:
+ "Al Apone <aapone@uscmc.invalid>, " +
+ "Rebeccah Jorden <rjorden@hadleys-hope.invalid>",
+ },
+ expected: { column: "recipients", value: "Sarge, Rebeccah Jorden" },
+ },
+
+ // Esoteric tests; these mainly test that we're getting the expected info back
+ // from the message header.
+ {
+ name: "from_display_name_multiple",
+ headers: {
+ From:
+ "Carter Burke <cburke@wyutani.invalid>, " +
+ "Dwayne Hicks <dhicks@uscmc.invalid>",
+ },
+ expected: { column: "from", value: "Carter Burke et al." },
+ },
+ {
+ name: "from_missing",
+ headers: { From: null },
+ expected: { column: "from", value: "" },
+ },
+ {
+ name: "from_empty",
+ headers: { From: "" },
+ expected: { column: "from", value: "" },
+ },
+ {
+ name: "from_invalid",
+ headers: { From: "invalid" },
+ expected: { column: "from", value: "invalid" },
+ },
+ {
+ name: "from_and_sender_display_name",
+ headers: {
+ From: "Carter Burke <cburke@wyutani.invalid>",
+ Sender: "The Company <thecompany@wyutani.invalid>",
+ },
+ expected: { column: "from", value: "Carter Burke" },
+ },
+ {
+ name: "sender_and_no_from_display_name",
+ headers: { From: null, Sender: "The Company <thecompany@wyutani.invalid>" },
+ expected: { column: "from", value: "The Company" },
+ },
+ {
+ name: "to_missing",
+ headers: { To: null },
+ expected: { column: "recipients", value: "" },
+ },
+ {
+ name: "to_empty",
+ headers: { To: "" },
+ expected: { column: "recipients", value: "" },
+ },
+ {
+ name: "to_invalid",
+ headers: { To: "invalid" },
+ expected: { column: "recipients", value: "invalid" },
+ },
+ {
+ name: "to_and_cc_display_name",
+ headers: {
+ To: "Carter Burke <cburke@wyutani.invalid>",
+ Cc: "The Company <thecompany@wyutani.invalid>",
+ },
+ expected: { column: "recipients", value: "Carter Burke" },
+ },
+ {
+ name: "cc_and_no_to_display_name",
+ headers: { To: null, Cc: "The Company <thecompany@wyutani.invalid>" },
+ expected: { column: "recipients", value: "The Company" },
+ },
+];
+
+var contacts = [
+ { email: "aapone@uscmc.invalid", name: "Sarge", pdn: true },
+ { email: "rjorden@hadleys-hope.invalid", name: "Newt", pdn: false },
+];
+
+add_setup(async function () {
+ folder = await create_folder("DisplayNameA");
+
+ for (let message of messages) {
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ clobberHeaders: message.headers,
+ })
+ );
+ }
+
+ for (let contact of contacts) {
+ ensure_card_exists(contact.email, contact.name, contact.pdn);
+ }
+
+ await be_in_folder(folder);
+});
+
+async function check_display_name(index, columnName, expectedName) {
+ let columnId;
+ switch (columnName) {
+ case "from":
+ columnId = "senderCol";
+ break;
+ case "recipients":
+ columnId = "recipientCol";
+ break;
+ default:
+ throw new Error("unknown column name: " + columnName);
+ }
+
+ let cellText = get_about_3pane().gDBView.cellTextForColumn(index, columnId);
+ Assert.equal(cellText, expectedName, columnName);
+}
+
+// Generate a test for each message in |messages|.
+for (let [i, message] of messages.entries()) {
+ this["test_" + message.name] = async function (i, message) {
+ await check_display_name(
+ i,
+ message.expected.column,
+ message.expected.value
+ );
+ }.bind(this, i, message);
+ add_task(this[`test_${message.name}`]);
+}
diff --git a/comm/mail/test/browser/folder-display/browser_folderPaneVisibility.js b/comm/mail/test/browser/folder-display/browser_folderPaneVisibility.js
new file mode 100644
index 0000000000..0c74031457
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_folderPaneVisibility.js
@@ -0,0 +1,275 @@
+/* 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 that the folder pane collapses properly, stays collapsed amongst tab
+ * changes, and that persistence works (to a first approximation).
+ */
+
+"use strict";
+
+var {
+ be_in_folder,
+ close_tab,
+ create_folder,
+ get_about_3pane,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_tab,
+ open_selected_message_in_new_tab,
+ select_click_row,
+ switch_tab,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+
+add_setup(async function () {
+ folder = await create_folder("FolderPaneVisibility");
+ await make_message_sets_in_folders([folder], [{ count: 3 }]);
+});
+
+/**
+ * When displaying a folder, assert that the folder pane is visible and all the
+ * menus, splitters, etc. are set up right.
+ */
+function assert_folder_pane_visible() {
+ let win = get_about_3pane();
+
+ Assert.equal(
+ win.paneLayout.folderPaneVisible,
+ true,
+ "The tab does not think that the folder pane is visible, but it should!"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ win.window.document.getElementById("folderTree")
+ ),
+ "The folder tree should not be collapsed!"
+ );
+ Assert.equal(
+ win.folderPaneSplitter.isCollapsed,
+ false,
+ "The folder tree splitter should not be collapsed!"
+ );
+
+ mc.window.view_init(); // Force the view menu to update.
+ let paneMenuItem = mc.window.document.getElementById("menu_showFolderPane");
+ Assert.equal(
+ paneMenuItem.getAttribute("checked"),
+ "true",
+ "The Folder Pane menu item should be checked."
+ );
+}
+
+/**
+ * When displaying a folder, assert that the folder pane is hidden and all the
+ * menus, splitters, etc. are set up right.
+ */
+function assert_folder_pane_hidden() {
+ let win = get_about_3pane();
+
+ Assert.equal(
+ win.paneLayout.folderPaneVisible,
+ false,
+ "The tab thinks that the folder pane is visible, but it shouldn't!"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ win.window.document.getElementById("folderTree")
+ ),
+ "The folder tree should be collapsed!"
+ );
+ Assert.equal(
+ win.folderPaneSplitter.isCollapsed,
+ true,
+ "The folder tree splitter should be collapsed!"
+ );
+
+ mc.window.view_init(); // Force the view menu to update.
+ let paneMenuItem = mc.window.document.getElementById("menu_showFolderPane");
+ Assert.notEqual(
+ paneMenuItem.getAttribute("checked"),
+ "true",
+ "The Folder Pane menu item should not be checked."
+ );
+}
+
+function toggle_folder_pane() {
+ // Since we don't have a shortcut to toggle the folder pane, we're going to
+ // have to collapse it ourselves
+ get_about_3pane().commandController.doCommand("cmd_toggleFolderPane");
+}
+
+/**
+ * By default, the folder pane should be visible.
+ */
+add_task(async function test_folder_pane_visible_state_is_right() {
+ await be_in_folder(folder);
+ assert_folder_pane_visible();
+});
+
+/**
+ * Toggle the folder pane off.
+ */
+add_task(function test_toggle_folder_pane_off() {
+ toggle_folder_pane();
+ assert_folder_pane_hidden();
+});
+
+/**
+ * Toggle the folder pane on.
+ */
+add_task(function test_toggle_folder_pane_on() {
+ toggle_folder_pane();
+ assert_folder_pane_visible();
+});
+
+/**
+ * Make sure that switching to message tabs of folder tabs with a different
+ * folder pane state does not break. This test should cover all transition
+ * states.
+ */
+add_task(async function test_folder_pane_is_sticky() {
+ Assert.equal(document.getElementById("tabmail").tabInfo.length, 1);
+ let tabFolderA = await be_in_folder(folder);
+ assert_folder_pane_visible();
+
+ // [folder+ => (new) message]
+ select_click_row(0);
+ let tabMessage = await open_selected_message_in_new_tab();
+
+ // [message => folder+]
+ await switch_tab(tabFolderA);
+ assert_folder_pane_visible();
+
+ // [folder+ => (new) folder+]
+ let tabFolderB = await open_folder_in_new_tab(folder);
+ assert_folder_pane_visible();
+
+ // [folder pane toggle + => -]
+ toggle_folder_pane();
+ assert_folder_pane_hidden();
+
+ // [folder- => folder+]
+ await switch_tab(tabFolderA);
+ assert_folder_pane_visible();
+
+ // (redundant) [ folder pane toggle + => -]
+ toggle_folder_pane();
+ assert_folder_pane_hidden();
+
+ // [folder- => message]
+ await switch_tab(tabMessage);
+
+ // [message => folder-]
+ close_tab(tabMessage);
+ assert_folder_pane_hidden();
+
+ // the tab we are on now doesn't matter, so we don't care
+ assert_folder_pane_hidden();
+ await switch_tab(tabFolderB);
+
+ // [ folder pane toggle - => + ]
+ toggle_folder_pane();
+ assert_folder_pane_visible();
+
+ // [folder+ => folder-]
+ close_tab(tabFolderB);
+ assert_folder_pane_hidden();
+
+ // (redundant) [ folder pane toggle - => + ]
+ toggle_folder_pane();
+ assert_folder_pane_visible();
+});
+
+/**
+ * Test that if we serialize and restore the tabs then the folder pane is in the
+ * expected collapsed/non-collapsed state. Because of the special "first tab"
+ * situation, we need to do this twice to test each case for the first tab. For
+ * additional thoroughness we also flip the state we have the other tabs be in.
+ */
+add_task(async function test_folder_pane_persistence_generally_works() {
+ await be_in_folder(folder);
+
+ let tabmail = mc.window.document.getElementById("tabmail");
+
+ // helper to open tabs with the folder pane in the desired states (1 for
+ // visible, 0 for hidden)
+ async function openTabs(aConfig) {
+ for (let [iTab, folderPaneVisible] of aConfig.entries()) {
+ if (iTab != 0) {
+ await open_folder_in_new_tab(folder);
+ }
+ if (
+ tabmail.currentAbout3Pane.paneLayout.folderPaneVisible !=
+ folderPaneVisible
+ ) {
+ toggle_folder_pane();
+ }
+ }
+ }
+
+ // close everything but the first tab.
+ function closeTabs() {
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(1);
+ }
+ }
+
+ async function verifyTabs(aConfig) {
+ for (let [iTab, folderPaneVisible] of aConfig.entries()) {
+ info("tab " + iTab);
+
+ await switch_tab(iTab);
+ if (tabmail.currentAbout3Pane.document.readyState != "complete") {
+ await BrowserTestUtils.waitForEvent(tabmail.currentAbout3Pane, "load");
+ await new Promise(resolve =>
+ tabmail.currentAbout3Pane.setTimeout(resolve)
+ );
+ }
+
+ if (folderPaneVisible) {
+ assert_folder_pane_visible();
+ } else {
+ assert_folder_pane_hidden();
+ }
+ }
+ }
+
+ let configs = [
+ // 1st time: [+ - - + +]
+ [1, 0, 0, 1, 1],
+ // 2nd time: [- + + - -]
+ [0, 1, 1, 0, 0],
+ ];
+
+ for (let config of configs) {
+ await openTabs(config);
+ await verifyTabs(config); // make sure openTabs did its job right
+ let state = tabmail.persistTabs();
+ closeTabs();
+
+ Assert.equal(state.tabs[0].state.folderPaneVisible, config[0]);
+ Assert.equal(state.tabs[1].state.folderPaneVisible, config[1]);
+ Assert.equal(state.tabs[2].state.folderPaneVisible, config[2]);
+ Assert.equal(state.tabs[3].state.folderPaneVisible, config[3]);
+ Assert.equal(state.tabs[4].state.folderPaneVisible, config[4]);
+
+ // toggle the state for the current tab so we can be sure that it knows how
+ // to change things.
+ toggle_folder_pane();
+
+ tabmail.restoreTabs(state);
+ await verifyTabs(config);
+ closeTabs();
+
+ // toggle the first tab again. This sets closed properly for the second pass and
+ // restores it to open for when we are done.
+ toggle_folder_pane();
+ }
+ // For one last time, make sure.
+ assert_folder_pane_visible();
+});
diff --git a/comm/mail/test/browser/folder-display/browser_folderToolbar.js b/comm/mail/test/browser/folder-display/browser_folderToolbar.js
new file mode 100644
index 0000000000..41365f55c4
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_folderToolbar.js
@@ -0,0 +1,147 @@
+/* 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 that opening new folder and message tabs has the expected result and
+ * that closing them doesn't break anything.
+ */
+
+"use strict";
+
+var {
+ add_to_toolbar,
+ assert_folder_selected_and_displayed,
+ assert_nothing_selected,
+ be_in_folder,
+ close_tab,
+ create_folder,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_tab,
+ open_selected_message_in_new_tab,
+ remove_from_toolbar,
+ select_click_row,
+ switch_tab,
+ wait_for_blank_content_pane,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folderA, folderB;
+
+add_setup(async function () {
+ folderA = await create_folder("FolderToolbarA");
+ // we need one message to select and open
+ folderB = await create_folder("FolderToolbarB");
+ await make_message_sets_in_folders([folderB], [{ count: 1 }]);
+});
+
+add_task(function test_add_folder_toolbar() {
+ // It should not be present by default
+ let folderLoc = mc.window.document.getElementById("locationFolders");
+ Assert.ok(!folderLoc);
+
+ // But it should show up when we call
+ add_to_toolbar(
+ mc.window.document.getElementById("mail-bar3"),
+ "folder-location-container"
+ );
+ folderLoc = mc.window.document.getElementById("locationFolders");
+ Assert.ok(folderLoc);
+
+ Assert.equal(
+ !!folderLoc.label,
+ true,
+ "Uninitialized Folder doesn't have a default label."
+ );
+});
+
+add_task(async function test_folder_toolbar_shows_correct_item() {
+ add_to_toolbar(
+ mc.window.document.getElementById("mail-bar3"),
+ "folder-location-container"
+ );
+ let folderLoc = mc.window.document.getElementById("locationFolders");
+
+ // Start in folder a.
+ let tabFolderA = await be_in_folder(folderA);
+ assert_folder_selected_and_displayed(folderA);
+ assert_nothing_selected();
+ Assert.equal(
+ folderLoc.label,
+ "FolderToolbarA",
+ "Opening FolderA doesn't update toolbar."
+ );
+
+ // Open tab b, make sure it works right.
+ let tabFolderB = await open_folder_in_new_tab(folderB);
+ wait_for_blank_content_pane();
+ assert_folder_selected_and_displayed(folderB);
+ assert_nothing_selected();
+ Assert.equal(
+ folderLoc.label,
+ "FolderToolbarB",
+ "Opening FolderB in a tab doesn't update toolbar."
+ );
+
+ // Go back to tab/folder A and make sure we change correctly.
+ await switch_tab(tabFolderA);
+ assert_folder_selected_and_displayed(folderA);
+ assert_nothing_selected();
+ Assert.equal(
+ folderLoc.label,
+ "FolderToolbarA",
+ "Switching back to FolderA's tab doesn't update toolbar."
+ );
+
+ // Go back to tab/folder A and make sure we change correctly.
+ await switch_tab(tabFolderB);
+ assert_folder_selected_and_displayed(folderB);
+ assert_nothing_selected();
+ Assert.equal(
+ folderLoc.label,
+ "FolderToolbarB",
+ "Switching back to FolderB's tab doesn't update toolbar."
+ );
+ close_tab(tabFolderB);
+});
+
+add_task(async function test_folder_toolbar_disappears_on_message_tab() {
+ add_to_toolbar(
+ mc.window.document.getElementById("mail-bar3"),
+ "folder-location-container"
+ );
+ await be_in_folder(folderB);
+ let folderLoc = mc.window.document.getElementById("locationFolders");
+ Assert.ok(folderLoc);
+ Assert.equal(
+ folderLoc.label,
+ "FolderToolbarB",
+ "We should have started in FolderB."
+ );
+ Assert.equal(folderLoc.collapsed, false, "The toolbar should be shown.");
+
+ // Select one message
+ select_click_row(0);
+ // Open it
+ let messageTab = await open_selected_message_in_new_tab();
+
+ Assert.equal(
+ mc.window.document.getElementById("folder-location-container").collapsed,
+ true,
+ "The toolbar should be hidden."
+ );
+
+ // Clean up, close the tab
+ close_tab(messageTab);
+});
+
+add_task(function test_remove_folder_toolbar() {
+ remove_from_toolbar(
+ mc.window.document.getElementById("mail-bar3"),
+ "folder-location-container"
+ );
+
+ Assert.ok(!mc.window.document.getElementById("locationFolders"));
+});
diff --git a/comm/mail/test/browser/folder-display/browser_invalidDbFolderLoad.js b/comm/mail/test/browser/folder-display/browser_invalidDbFolderLoad.js
new file mode 100644
index 0000000000..61ccf08d08
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_invalidDbFolderLoad.js
@@ -0,0 +1,61 @@
+/* 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 that clicking on a folder with an invalid or missing .msf file
+ * regenerates the.msf file and loads the view.
+ * Also, check that rebuilding the index on a loaded folder reloads the folder.
+ */
+
+"use strict";
+
+var {
+ assert_messages_in_view,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ get_about_3pane,
+ make_message_sets_in_folders,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+var setA;
+
+add_setup(async function () {
+ folder = await create_folder("InvalidMSF");
+ [setA] = await make_message_sets_in_folders([folder], [{ count: 3 }]);
+});
+
+/**
+ * Check if the db of a folder assumed to be invalid can be restored.
+ */
+add_task(async function test_load_folder_with_invalidDB() {
+ folder.msgDatabase.dBFolderInfo.sortType = Ci.nsMsgViewSortType.bySubject;
+ folder.msgDatabase.summaryValid = false;
+ folder.msgDatabase.forceClosed();
+ folder.msgDatabase = null;
+ await be_in_folder(folder);
+
+ assert_messages_in_view(setA);
+ var curMessage = select_click_row(0);
+ assert_selected_and_displayed(curMessage);
+});
+
+add_task(function test_view_sort_maintained() {
+ let win = get_about_3pane();
+ if (win.gDBView.sortType != Ci.nsMsgViewSortType.bySubject) {
+ throw new Error("view sort type not restored from invalid db");
+ }
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_mailTelemetry.js b/comm/mail/test/browser/folder-display/browser_mailTelemetry.js
new file mode 100644
index 0000000000..d2d73627bb
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_mailTelemetry.js
@@ -0,0 +1,135 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test telemetry related to secure mails read.
+ */
+
+let {
+ create_folder,
+ be_in_folder,
+ create_message,
+ create_encrypted_smime_message,
+ create_encrypted_openpgp_message,
+ add_message_to_folder,
+ select_click_row,
+ assert_selected_and_displayed,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+let { SmimeUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/smimeUtils.jsm"
+);
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_setup(function () {
+ SmimeUtils.ensureNSS();
+ SmimeUtils.loadCertificateAndKey(
+ new FileUtils.File(getTestFilePath("../openpgp/data/smime/Bob.p12")),
+ "nss"
+ );
+});
+
+/**
+ * Check that we're counting secure mails read.
+ */
+add_task(async function test_secure_mails_read() {
+ Services.telemetry.clearScalars();
+
+ const NUM_PLAIN_MAILS = 4;
+ const NUM_SMIME_MAILS = 2;
+ const NUM_OPENPGP_MAILS = 3;
+ let headers = { from: "alice@t1.example.com", to: "bob@t2.example.net" };
+ let folder = await create_folder("secure-mail");
+
+ // normal message should not be counted
+ for (let i = 0; i < NUM_PLAIN_MAILS; i++) {
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ clobberHeaders: headers,
+ })
+ );
+ }
+ for (let i = 0; i < NUM_SMIME_MAILS; i++) {
+ await add_message_to_folder(
+ [folder],
+ create_encrypted_smime_message({
+ to: "Bob@example.com",
+ body: {
+ body: smimeMessage,
+ },
+ })
+ );
+ }
+ for (let i = 0; i < NUM_OPENPGP_MAILS; i++) {
+ await add_message_to_folder(
+ [folder],
+ create_encrypted_openpgp_message({
+ clobberHeaders: headers,
+ })
+ );
+ }
+
+ // Select (read) all added mails.
+ await be_in_folder(folder);
+ for (
+ let i = 0;
+ i < NUM_PLAIN_MAILS + NUM_SMIME_MAILS + NUM_OPENPGP_MAILS;
+ i++
+ ) {
+ select_click_row(i);
+ }
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.mails.read_secure"]["encrypted-smime"],
+ NUM_SMIME_MAILS,
+ "Count of smime encrypted mails read must be correct."
+ );
+ Assert.equal(
+ scalars["tb.mails.read_secure"]["encrypted-openpgp"],
+ NUM_OPENPGP_MAILS,
+ "Count of openpgp encrypted mails read must be correct."
+ );
+
+ // Select all added mails again should not change read statistics.
+ for (
+ let i = 0;
+ i < NUM_PLAIN_MAILS + NUM_SMIME_MAILS + NUM_OPENPGP_MAILS;
+ i++
+ ) {
+ select_click_row(i);
+ }
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.mails.read_secure"]["encrypted-smime"],
+ NUM_SMIME_MAILS,
+ "Count of smime encrypted mails read must still be correct."
+ );
+ Assert.equal(
+ scalars["tb.mails.read_secure"]["encrypted-openpgp"],
+ NUM_OPENPGP_MAILS,
+ "Count of openpgp encrypted mails read must still be correct."
+ );
+});
+
+var smimeMessage = [
+ "MIAGCSqGSIb3DQEHA6CAMIACAQAxggGFMIIBgQIBADBpMGQxCzAJBgNVBAYTAlVT",
+ "MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIw",
+ "EAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEoMA0GCSqG",
+ "SIb3DQEBAQUABIIBAByaXGnoQAgRiPjvcpotJWBQwXjAxYldgMaT/hEX0Hlnas6m",
+ "OcBIOJLB9CHhmBOSo/yryDOnRcl9l1cQYzSEpExYSGoVzPCpPOLKw5C/A+6NFzpe",
+ "44EUX5/gVbVeQ4fl2dOB3NbW5Cnx3Js7O1MFr8UPFOh31TBhvWjOMl+3CkMWndUi",
+ "G4C/srgdeuQRdKJcWoROtBjQuibVHfn0TcA7olIj8ysmJoTT3Irx625Sh5mDDVbJ",
+ "UyR2WWqw6wPAaCS2urUXtYrEuxsr7EmdcZc0P6oikzf/KoMvzBWBmWJXad1QSdeO",
+ "s5Bk2MYKXoM9Iqddr/n9mvg4jJNnFMzG0cFKCAgwgAYJKoZIhvcNAQcBMB0GCWCG",
+ "SAFlAwQBAgQQ2QrTbolonzr0vAfmGH2nJ6CABIGQKA2mKyOQShspbeDIf/QlYHg+",
+ "YbiqdhlENHHM5V5rICjM5LFzLME0TERDJGi8tATlqp3rFOswFDGiymK6XZrpQZiW",
+ "TBTEa2E519Mw86NEJ1d/iy4aLpPjATH2rhZLm3dix42mFI5ToszGNu9VuDWDiV4S",
+ "sA798v71TaSlFwh9C3VwODQ8lWwyci4aD3wdxevGBBC3fYMuEns+NIQhqpzlUADX",
+ "AAAAAAAAAAAAAA==",
+].join("\n");
diff --git a/comm/mail/test/browser/folder-display/browser_mailViews.js b/comm/mail/test/browser/folder-display/browser_mailViews.js
new file mode 100644
index 0000000000..e02f8e5e53
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_mailViews.js
@@ -0,0 +1,128 @@
+/* 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 {
+ assert_messages_in_view,
+ be_in_folder,
+ create_folder,
+ make_message_sets_in_folders,
+ mc,
+ wait_for_all_messages_to_load,
+ get_about_3pane,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { plan_for_modal_dialog, wait_for_modal_dialog } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailViewConstants } = ChromeUtils.import(
+ "resource:///modules/MailViewManager.jsm"
+);
+
+const { storeState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+
+var baseFolder, savedFolder;
+var setUntagged, setTagged;
+
+add_setup(async function () {
+ // Create a folder with some messages that have no tags and some that are
+ // tagged Important ($label1).
+ baseFolder = await create_folder("MailViewA");
+ [setUntagged, setTagged] = await make_message_sets_in_folders(
+ [baseFolder],
+ [{}, {}]
+ );
+ setTagged.addTag("$label1"); // Important, by default
+ storeState({
+ mail: ["view-picker"],
+ });
+ await BrowserTestUtils.waitForMutationCondition(
+ document.getElementById("unifiedToolbarContent"),
+ {
+ subtree: true,
+ childList: true,
+ },
+ () => document.querySelector("#unifiedToolbarContent .view-picker")
+ );
+
+ registerCleanupFunction(() => {
+ storeState({});
+ });
+});
+
+add_task(function test_put_view_picker_on_toolbar() {
+ Assert.ok(
+ window.ViewPickerBinding.isVisible,
+ "View picker is registered as visible"
+ );
+});
+
+/**
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=474701#c97
+ */
+add_task(async function test_save_view_as_folder() {
+ // - enter the folder
+ await be_in_folder(baseFolder);
+
+ // - apply the mail view
+ // okay, mozmill is just not ready to click on the view picker...
+ // just call the ViewChange global. it's sad, but it has the same effects.
+ // at least, it does once we've caused the popups to get refreshed.
+ mc.window.RefreshAllViewPopups(
+ mc.window.document.getElementById("toolbarViewPickerPopup")
+ );
+ mc.window.ViewChange(":$label1");
+ wait_for_all_messages_to_load();
+
+ // - save it
+ plan_for_modal_dialog(
+ "mailnews:virtualFolderProperties",
+ subtest_save_mail_view
+ );
+ // we have to use value here because the option mechanism is not sophisticated
+ // enough.
+ mc.window.ViewChange(MailViewConstants.kViewItemVirtual);
+ wait_for_modal_dialog("mailnews:virtualFolderProperties");
+});
+
+function subtest_save_mail_view(savc) {
+ // - make sure the name is right
+ Assert.equal(
+ savc.window.document.getElementById("name").value,
+ baseFolder.prettyName + "-Important"
+ );
+
+ let selector = savc.window.document.querySelector("#searchVal0 menulist");
+ Assert.ok(selector, "Should have a tag selector");
+
+ // Check the value of the search-value.
+ Assert.equal(selector.value, "$label1");
+
+ // - save it
+ savc.window.document.querySelector("dialog").acceptDialog();
+}
+
+add_task(async function test_verify_saved_mail_view() {
+ // - make sure the folder got created
+ savedFolder = baseFolder.getChildNamed(baseFolder.prettyName + "-Important");
+ if (!savedFolder) {
+ throw new Error("MailViewA-Important was not created!");
+ }
+
+ // - go in the folder and make sure the right messages are displayed
+ await be_in_folder(savedFolder);
+ assert_messages_in_view(setTagged, mc);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_messageCommands.js b/comm/mail/test/browser/folder-display/browser_messageCommands.js
new file mode 100644
index 0000000000..ab49d07df3
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_messageCommands.js
@@ -0,0 +1,802 @@
+/* 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 tests various commands on messages. This is primarily for commands
+ * that can't be tested with xpcshell tests because they're handling in the
+ * front end - which is why Archive is the only command currently tested.
+ */
+
+"use strict";
+
+var { wait_for_content_tab_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var {
+ add_message_sets_to_folders,
+ archive_selected_messages,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_popup,
+ collapse_all_threads,
+ create_folder,
+ create_thread,
+ get_about_3pane,
+ get_about_message,
+ make_display_threaded,
+ make_display_unthreaded,
+ make_message_sets_in_folders,
+ mc,
+ press_delete,
+ right_click_on_row,
+ select_click_row,
+ select_control_click_row,
+ select_shift_click_row,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var unreadFolder, shiftDeleteFolder, threadDeleteFolder;
+var archiveSrcFolder = null;
+
+var tagArray;
+var gAutoRead;
+
+// Adjust timeout to take care of code coverage runs needing twice as long.
+requestLongerTimeout(AppConstants.MOZ_CODE_COVERAGE ? 2 : 1);
+
+add_setup(async function () {
+ gAutoRead = Services.prefs.getBoolPref("mailnews.mark_message_read.auto");
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+
+ unreadFolder = await create_folder("UnreadFolder");
+ shiftDeleteFolder = await create_folder("ShiftDeleteFolder");
+ threadDeleteFolder = await create_folder("ThreadDeleteFolder");
+ archiveSrcFolder = await create_folder("ArchiveSrc");
+
+ await make_message_sets_in_folders([unreadFolder], [{ count: 2 }]);
+ await make_message_sets_in_folders([shiftDeleteFolder], [{ count: 3 }]);
+ await add_message_sets_to_folders(
+ [threadDeleteFolder],
+ [create_thread(3), create_thread(3), create_thread(3)]
+ );
+
+ // Create messages from 20 different months, which will mean 2 different
+ // years as well.
+ await make_message_sets_in_folders(
+ [archiveSrcFolder],
+ [{ count: 20, age_incr: { weeks: 5 } }]
+ );
+
+ tagArray = MailServices.tags.getAllTags();
+});
+
+/**
+ * Ensures that all messages have a particular read status
+ *
+ * @param messages an array of nsIMsgDBHdrs to check
+ * @param read true if the messages should be marked read, false otherwise
+ */
+function check_read_status(messages, read) {
+ function read_str(read) {
+ return read ? "read" : "unread";
+ }
+
+ for (let i = 0; i < messages.length; i++) {
+ Assert.ok(
+ messages[i].isRead == read,
+ "Message marked as " +
+ read_str(messages[i].isRead) +
+ ", expected " +
+ read_str(read)
+ );
+ }
+}
+
+/**
+ * Ensures that the mark read/unread menu items are enabled/disabled properly
+ *
+ * @param index the row in the thread pane of the message to query
+ * @param canMarkRead true if the mark read item should be enabled
+ * @param canMarkUnread true if the mark unread item should be enabled
+ */
+async function check_read_menuitems(index, canMarkRead, canMarkUnread) {
+ await right_click_on_row(index);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ await click_menus_in_sequence(getMailContext(), [{ id: "mailContext-mark" }]);
+
+ let readEnabled = !getMailContext().querySelector("#mailContext-markRead")
+ .disabled;
+ let unreadEnabled = !getMailContext().querySelector("#mailContext-markUnread")
+ .disabled;
+
+ Assert.ok(
+ readEnabled == canMarkRead,
+ "Mark read menu item " +
+ (canMarkRead ? "dis" : "en") +
+ "abled when it shouldn't be!"
+ );
+
+ Assert.ok(
+ unreadEnabled == canMarkUnread,
+ "Mark unread menu item " +
+ (canMarkUnread ? "dis" : "en") +
+ "abled when it shouldn't be!"
+ );
+
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+}
+
+function enable_archiving(enabled) {
+ Services.prefs.setBoolPref("mail.identity.default.archive_enabled", enabled);
+}
+
+/**
+ * Mark a message read or unread via the context menu
+ *
+ * @param index the row in the thread pane of the message to mark read/unread
+ * @param read true the message should be marked read, false otherwise
+ */
+async function mark_read_via_menu(index, read) {
+ let menuItem = read ? "mailContext-markRead" : "mailContext-markUnread";
+ await right_click_on_row(index);
+ await wait_for_popup_to_open(getMailContext());
+ await click_menus_in_sequence(getMailContext(), [
+ { id: "mailContext-mark" },
+ { id: menuItem },
+ ]);
+ await close_popup(mc, getMailContext());
+}
+
+add_task(async function test_mark_one_read() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+
+ curMessage.markRead(false);
+ await mark_read_via_menu(0, true);
+ check_read_status([curMessage], true);
+});
+
+add_task(async function test_mark_one_unread() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+
+ curMessage.markRead(true);
+ await mark_read_via_menu(0, false);
+ check_read_status([curMessage], false);
+});
+
+add_task(async function test_mark_n_read() {
+ await be_in_folder(unreadFolder);
+ select_click_row(0);
+ let curMessages = select_shift_click_row(1);
+
+ for (let i = 0; i < curMessages.length; i++) {
+ curMessages[i].markRead(false);
+ }
+ await mark_read_via_menu(0, true);
+ check_read_status(curMessages, true);
+});
+
+add_task(async function test_mark_n_unread() {
+ await be_in_folder(unreadFolder);
+ select_click_row(0);
+ let curMessages = select_shift_click_row(1);
+
+ for (let i = 0; i < curMessages.length; i++) {
+ curMessages[i].markRead(true);
+ }
+ await mark_read_via_menu(0, false);
+ check_read_status(curMessages, false);
+});
+
+add_task(async function test_mark_n_read_mixed() {
+ await be_in_folder(unreadFolder);
+ select_click_row(0);
+ let curMessages = select_shift_click_row(1);
+
+ curMessages[0].markRead(true);
+ curMessages[1].markRead(false);
+ await mark_read_via_menu(0, true);
+ check_read_status(curMessages, true);
+
+ curMessages[0].markRead(false);
+ curMessages[1].markRead(true);
+ await mark_read_via_menu(0, true);
+ check_read_status(curMessages, true);
+});
+
+add_task(async function test_mark_n_unread_mixed() {
+ await be_in_folder(unreadFolder);
+ select_click_row(0);
+ let curMessages = select_shift_click_row(1);
+
+ curMessages[0].markRead(false);
+ curMessages[1].markRead(true);
+ await mark_read_via_menu(0, false);
+ check_read_status(curMessages, false);
+
+ curMessages[0].markRead(true);
+ curMessages[1].markRead(false);
+ await mark_read_via_menu(0, false);
+ check_read_status(curMessages, false);
+});
+
+add_task(async function test_toggle_read() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+
+ curMessage.markRead(false);
+ EventUtils.synthesizeKey("m", {});
+ check_read_status([curMessage], true);
+});
+
+add_task(async function test_toggle_unread() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+
+ curMessage.markRead(true);
+ EventUtils.synthesizeKey("m", {});
+ check_read_status([curMessage], false);
+});
+
+add_task(async function test_toggle_mixed() {
+ await be_in_folder(unreadFolder);
+ select_click_row(0);
+ let curMessages = select_shift_click_row(1);
+
+ curMessages[0].markRead(false);
+ curMessages[1].markRead(true);
+ EventUtils.synthesizeKey("m", {});
+ check_read_status(curMessages, true);
+
+ curMessages[0].markRead(true);
+ curMessages[1].markRead(false);
+ EventUtils.synthesizeKey("m", {});
+ check_read_status(curMessages, false);
+});
+
+add_task(async function test_mark_menu_read() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+
+ curMessage.markRead(false);
+ await check_read_menuitems(0, true, false);
+});
+
+add_task(async function test_mark_menu_unread() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+
+ curMessage.markRead(true);
+ await check_read_menuitems(0, false, true);
+});
+
+add_task(async function test_mark_menu_mixed() {
+ await be_in_folder(unreadFolder);
+ select_click_row(0);
+ let curMessages = select_shift_click_row(1);
+
+ curMessages[0].markRead(false);
+ curMessages[1].markRead(true);
+
+ await check_read_menuitems(0, true, true);
+});
+
+add_task(async function test_mark_all_read() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+ curMessage.markRead(false);
+
+ // Make sure we can mark all read with >0 messages unread.
+ await right_click_on_row(0);
+ await wait_for_popup_to_open(getMailContext());
+ await click_menus_in_sequence(getMailContext(), [
+ { id: "mailContext-mark" },
+ { id: "mailContext-markAllRead" },
+ ]);
+ await close_popup(mc, getMailContext());
+
+ Assert.ok(curMessage.isRead, "Message should have been marked read!");
+
+ // Make sure we can't mark all read, now that all messages are already read.
+ await right_click_on_row(0);
+ await wait_for_popup_to_open(getMailContext());
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ await click_menus_in_sequence(getMailContext(), [{ id: "mailContext-mark" }]);
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ let allReadDisabled = getMailContext().querySelector(
+ "#mailContext-markAllRead"
+ ).disabled;
+ Assert.ok(allReadDisabled, "Mark All Read menu item should be disabled!");
+});
+
+add_task(async function test_mark_thread_as_read() {
+ let unreadThreadFolder = await create_folder("UnreadThreadFolder");
+ await add_message_sets_to_folders([unreadThreadFolder], [create_thread(3)]);
+ await be_in_folder(unreadThreadFolder);
+ make_display_threaded();
+
+ let serviceState = Services.prefs.getBoolPref(
+ "mailnews.mark_message_read.auto"
+ );
+ if (serviceState) {
+ // If mailnews.mark_message_read.auto is true, then we set it to false.
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+ }
+
+ // Make sure Mark Thread as Read is enabled with >0 messages in thread unread.
+ await right_click_on_row(0);
+ await wait_for_popup_to_open(getMailContext());
+ await click_menus_in_sequence(getMailContext(), [{ id: "mailContext-mark" }]);
+
+ let markThreadAsReadDisabled = mc.window.document.getElementById(
+ "mailContext-markThreadAsRead"
+ ).disabled;
+ Assert.ok(
+ !markThreadAsReadDisabled,
+ "Mark Thread as read menu item should not be disabled!"
+ );
+
+ // Make sure messages are read when Mark Thread as Read is clicked.
+ await right_click_on_row(0);
+ await wait_for_popup_to_open(getMailContext());
+ await click_menus_in_sequence(getMailContext(), [
+ { id: "mailContext-mark" },
+ { id: "mailContext-markThreadAsRead" },
+ ]);
+ await close_popup(mc, getMailContext());
+
+ let curMessage = select_click_row(0);
+ Assert.ok(curMessage.isRead, "Message should have been marked read!");
+
+ // Make sure Mark Thread as Read is now disabled with all messages read.
+ await right_click_on_row(0);
+ await wait_for_popup_to_open(getMailContext());
+ await click_menus_in_sequence(getMailContext(), [{ id: "mailContext-mark" }]);
+
+ markThreadAsReadDisabled = mc.window.document.getElementById(
+ "mailContext-markThreadAsRead"
+ ).disabled;
+ Assert.ok(
+ markThreadAsReadDisabled,
+ "Mark Thread as read menu item should be disabled!"
+ );
+
+ // Make sure that adding an unread message enables Mark Thread as Read once more.
+ curMessage.markRead(false);
+ await right_click_on_row(0);
+ await wait_for_popup_to_open(getMailContext());
+ await click_menus_in_sequence(getMailContext(), [{ id: "mailContext-mark" }]);
+
+ markThreadAsReadDisabled = mc.window.document.getElementById(
+ "mailContext-markThreadAsRead"
+ ).disabled;
+ Assert.ok(
+ !markThreadAsReadDisabled,
+ "Mark Thread as read menu item should not be disabled!"
+ );
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", true);
+}).__skipMe = true; // See bug 654362.
+
+add_task(async function roving_multi_message_buttons() {
+ await be_in_folder(unreadFolder);
+ select_click_row(0);
+ let curMessages = select_shift_click_row(1);
+ assert_selected_and_displayed(curMessages);
+
+ let multiMsgView = get_about_3pane().multiMessageBrowser;
+ const BUTTONS_SELECTOR = `toolbarbutton:not([hidden="true"]`;
+ let headerToolbar = multiMsgView.contentDocument.getElementById(
+ "header-view-toolbar"
+ );
+ let headerButtons = headerToolbar.querySelectorAll(BUTTONS_SELECTOR);
+
+ // Press tab twice while on the message selected to access the multi message
+ // view header buttons.
+ EventUtils.synthesizeKey("KEY_Tab", {});
+ EventUtils.synthesizeKey("KEY_Tab", {});
+ Assert.equal(
+ headerButtons[0].id,
+ multiMsgView.contentDocument.activeElement.id,
+ "focused on first msgHdr toolbar button"
+ );
+
+ // Simulate the Arrow Right keypress to make sure the correct button gets the
+ // focus.
+ for (let i = 1; i < headerButtons.length; i++) {
+ let previousElement = document.activeElement;
+ EventUtils.synthesizeKey("KEY_ArrowRight", {});
+ Assert.equal(
+ multiMsgView.contentDocument.activeElement.id,
+ headerButtons[i].id,
+ "The next button is focused"
+ );
+ Assert.ok(
+ multiMsgView.contentDocument.activeElement.tabIndex == 0 &&
+ previousElement.tabIndex == -1,
+ "The roving tab index was updated"
+ );
+ }
+
+ // Simulate the Arrow Left keypress to make sure the correct button gets the
+ // focus.
+ for (let i = headerButtons.length - 2; i > -1; i--) {
+ let previousElement = document.activeElement;
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {});
+ Assert.equal(
+ multiMsgView.contentDocument.activeElement.id,
+ headerButtons[i].id,
+ "The previous button is focused"
+ );
+ Assert.ok(
+ multiMsgView.contentDocument.activeElement.tabIndex == 0 &&
+ previousElement.tabIndex == -1,
+ "The roving tab index was updated"
+ );
+ }
+
+ // Check that once the Escape key is pressed twice, focus will move back to
+ // the selected messages.
+ EventUtils.synthesizeKey("KEY_Escape", {});
+ EventUtils.synthesizeKey("KEY_Escape", {});
+ assert_selected_and_displayed(curMessages);
+}).__skipMe = AppConstants.platform == "macosx";
+
+add_task(async function test_shift_delete_prompt() {
+ await be_in_folder(shiftDeleteFolder);
+ let curMessage = select_click_row(0);
+ goUpdateCommand("cmd_shiftDelete");
+
+ // First, try shift-deleting and then cancelling at the prompt.
+ Services.prefs.setBoolPref("mail.warn_on_shift_delete", true);
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ // We don't use press_delete here because we're not actually deleting this
+ // time!
+ SimpleTest.ignoreAllUncaughtExceptions(true);
+ EventUtils.synthesizeKey("VK_DELETE", { shiftKey: true });
+ SimpleTest.ignoreAllUncaughtExceptions(false);
+ await dialogPromise;
+ // Make sure we didn't actually delete the message.
+ Assert.equal(curMessage, select_click_row(0));
+
+ // Second, try shift-deleting and then accepting the deletion.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ press_delete(mc, { shiftKey: true });
+ await dialogPromise;
+ // Make sure we really did delete the message.
+ Assert.notEqual(curMessage, select_click_row(0));
+
+ // Finally, try shift-deleting when we turned off the prompt.
+ Services.prefs.setBoolPref("mail.warn_on_shift_delete", false);
+ curMessage = select_click_row(0);
+ press_delete(mc, { shiftKey: true });
+
+ // Make sure we really did delete the message.
+ Assert.notEqual(curMessage, select_click_row(0));
+
+ Services.prefs.clearUserPref("mail.warn_on_shift_delete");
+});
+
+add_task(async function test_thread_delete_prompt() {
+ await be_in_folder(threadDeleteFolder);
+ make_display_threaded();
+ collapse_all_threads();
+
+ let curMessage = select_click_row(0);
+ goUpdateCommand("cmd_delete");
+ // First, try deleting and then cancelling at the prompt.
+ Services.prefs.setBoolPref("mail.warn_on_collapsed_thread_operation", true);
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ // We don't use press_delete here because we're not actually deleting this
+ // time!
+ SimpleTest.ignoreAllUncaughtExceptions(true);
+ EventUtils.synthesizeKey("VK_DELETE", {});
+ SimpleTest.ignoreAllUncaughtExceptions(false);
+ await dialogPromise;
+ // Make sure we didn't actually delete the message.
+ Assert.equal(curMessage, select_click_row(0));
+
+ // Second, try deleting and then accepting the deletion.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ press_delete(mc);
+ await dialogPromise;
+ // Make sure we really did delete the message.
+ Assert.notEqual(curMessage, select_click_row(0));
+
+ // Finally, try shift-deleting when we turned off the prompt.
+ Services.prefs.setBoolPref("mail.warn_on_collapsed_thread_operation", false);
+ curMessage = select_click_row(0);
+ press_delete(mc);
+
+ // Make sure we really did delete the message.
+ Assert.notEqual(curMessage, select_click_row(0));
+
+ Services.prefs.clearUserPref("mail.warn_on_collapsed_thread_operation");
+}).skip(); // TODO: not working
+
+add_task(async function test_yearly_archive() {
+ await yearly_archive(false);
+});
+
+async function yearly_archive(keep_structure) {
+ await be_in_folder(archiveSrcFolder);
+ make_display_unthreaded();
+
+ let win = get_about_3pane();
+ win.sortController.sortThreadPane("byDate");
+ win.sortController.sortAscending();
+
+ let identity = MailServices.accounts.getFirstIdentityForServer(
+ win.gDBView.getMsgHdrAt(0).folder.server
+ );
+ identity.archiveGranularity = Ci.nsIMsgIdentity.perYearArchiveFolders;
+ // We need to get all the info about the messages before we do the archive,
+ // because deleting the headers could make extracting values from them fail.
+ let firstMsgHdr = win.gDBView.getMsgHdrAt(0);
+ let lastMsgHdr = win.gDBView.getMsgHdrAt(12);
+ let firstMsgHdrMsgId = firstMsgHdr.messageId;
+ let lastMsgHdrMsgId = lastMsgHdr.messageId;
+ let firstMsgDate = new Date(firstMsgHdr.date / 1000);
+ let firstMsgYear = firstMsgDate.getFullYear().toString();
+ let lastMsgDate = new Date(lastMsgHdr.date / 1000);
+ let lastMsgYear = lastMsgDate.getFullYear().toString();
+
+ win.threadTree.scrollToIndex(0, true);
+ await TestUtils.waitForCondition(
+ () => win.threadTree.getRowAtIndex(0),
+ "Row 0 scrolled into view"
+ );
+ select_click_row(0);
+ win.threadTree.scrollToIndex(12, true);
+ await TestUtils.waitForCondition(
+ () => win.threadTree.getRowAtIndex(12),
+ "Row 12 scrolled into view"
+ );
+ select_control_click_row(12);
+
+ // Press the archive key. The results should go into two separate years.
+ archive_selected_messages();
+
+ // Figure out where the messages should have gone.
+ let archiveRoot = "mailbox://nobody@Local%20Folders/Archives";
+ let firstArchiveUri = archiveRoot + "/" + firstMsgYear;
+ let lastArchiveUri = archiveRoot + "/" + lastMsgYear;
+ if (keep_structure) {
+ firstArchiveUri += "/ArchiveSrc";
+ lastArchiveUri += "/ArchiveSrc";
+ }
+ let firstArchiveFolder = MailUtils.getOrCreateFolder(firstArchiveUri);
+ let lastArchiveFolder = MailUtils.getOrCreateFolder(lastArchiveUri);
+ await be_in_folder(firstArchiveFolder);
+ Assert.ok(
+ win.gDBView.getMsgHdrAt(0).messageId == firstMsgHdrMsgId,
+ "Message should have been archived to " +
+ firstArchiveUri +
+ ", but it isn't present there"
+ );
+ await be_in_folder(lastArchiveFolder);
+
+ Assert.ok(
+ win.gDBView.getMsgHdrAt(0).messageId == lastMsgHdrMsgId,
+ "Message should have been archived to " +
+ lastArchiveUri +
+ ", but it isn't present there"
+ );
+}
+
+add_task(async function test_monthly_archive() {
+ enable_archiving(true);
+ await monthly_archive(false);
+});
+
+async function monthly_archive(keep_structure) {
+ await be_in_folder(archiveSrcFolder);
+
+ let win = get_about_3pane();
+ let identity = MailServices.accounts.getFirstIdentityForServer(
+ win.gDBView.getMsgHdrAt(0).folder.server
+ );
+ identity.archiveGranularity = Ci.nsIMsgIdentity.perMonthArchiveFolders;
+ select_click_row(0);
+ select_control_click_row(1);
+
+ let firstMsgHdr = win.gDBView.getMsgHdrAt(0);
+ let lastMsgHdr = win.gDBView.getMsgHdrAt(1);
+ let firstMsgHdrMsgId = firstMsgHdr.messageId;
+ let lastMsgHdrMsgId = lastMsgHdr.messageId;
+ let firstMsgDate = new Date(firstMsgHdr.date / 1000);
+ let firstMsgYear = firstMsgDate.getFullYear().toString();
+ let firstMonthFolderName =
+ firstMsgYear +
+ "-" +
+ (firstMsgDate.getMonth() + 1).toString().padStart(2, "0");
+ let lastMsgDate = new Date(lastMsgHdr.date / 1000);
+ let lastMsgYear = lastMsgDate.getFullYear().toString();
+ let lastMonthFolderName =
+ lastMsgYear +
+ "-" +
+ (lastMsgDate.getMonth() + 1).toString().padStart(2, "0");
+
+ // Press the archive key. The results should go into two separate months.
+ archive_selected_messages();
+
+ // Figure out where the messages should have gone.
+ let archiveRoot = "mailbox://nobody@Local%20Folders/Archives";
+ let firstArchiveUri =
+ archiveRoot + "/" + firstMsgYear + "/" + firstMonthFolderName;
+ let lastArchiveUri =
+ archiveRoot + "/" + lastMsgYear + "/" + lastMonthFolderName;
+ if (keep_structure) {
+ firstArchiveUri += "/ArchiveSrc";
+ lastArchiveUri += "/ArchiveSrc";
+ }
+ let firstArchiveFolder = MailUtils.getOrCreateFolder(firstArchiveUri);
+ let lastArchiveFolder = MailUtils.getOrCreateFolder(lastArchiveUri);
+ await be_in_folder(firstArchiveFolder);
+ Assert.ok(
+ win.gDBView.getMsgHdrAt(0).messageId == firstMsgHdrMsgId,
+ "Message should have been archived to Local Folders/" +
+ firstMsgYear +
+ "/" +
+ firstMonthFolderName +
+ "/Archives, but it isn't present there"
+ );
+ await be_in_folder(lastArchiveFolder);
+ Assert.ok(
+ win.gDBView.getMsgHdrAt(0).messageId == lastMsgHdrMsgId,
+ "Message should have been archived to Local Folders/" +
+ lastMsgYear +
+ "/" +
+ lastMonthFolderName +
+ "/Archives, but it isn't present there"
+ );
+}
+
+add_task(async function test_folder_structure_archiving() {
+ enable_archiving(true);
+ Services.prefs.setBoolPref(
+ "mail.identity.default.archive_keep_folder_structure",
+ true
+ );
+ await monthly_archive(true);
+ await yearly_archive(true);
+});
+
+add_task(async function test_selection_after_archive() {
+ let win = get_about_3pane();
+ enable_archiving(true);
+ await be_in_folder(archiveSrcFolder);
+ let identity = MailServices.accounts.getFirstIdentityForServer(
+ win.gDBView.getMsgHdrAt(0).folder.server
+ );
+ identity.archiveGranularity = Ci.nsIMsgIdentity.perMonthArchiveFolders;
+ // We had a bug where we would always select the 0th message after an
+ // archive, so test that we'll actually select the next remaining message
+ // by archiving rows 1 & 2 and verifying that the 3rd message gets selected.
+ // let hdrToSelect =
+ select_click_row(3);
+ select_click_row(1);
+ select_control_click_row(2);
+ archive_selected_messages();
+ // assert_selected_and_displayed(hdrToSelect); TODO
+});
+
+add_task(async function test_disabled_archive() {
+ let win = get_about_message();
+ let win3 = get_about_3pane();
+ enable_archiving(false);
+ await be_in_folder(archiveSrcFolder);
+
+ // test single message
+ let current = select_click_row(0);
+ EventUtils.synthesizeKey("a", {});
+ assert_selected_and_displayed(current);
+
+ Assert.ok(
+ win.document.getElementById("hdrArchiveButton").disabled,
+ "Archive button should be disabled when archiving is disabled!"
+ );
+
+ // test message summaries
+ select_click_row(0);
+ current = select_shift_click_row(2);
+ EventUtils.synthesizeKey("a", {});
+ assert_selected_and_displayed(current);
+
+ let htmlframe = win3.multiMessageBrowser;
+ let archiveBtn = htmlframe.contentDocument.getElementById("hdrArchiveButton");
+ Assert.ok(
+ archiveBtn.collapsed,
+ "Multi-message archive button should be disabled when " +
+ "archiving is disabled!"
+ );
+
+ // test message summaries with "large" selection
+ mc.window.gFolderDisplay.MAX_COUNT_FOR_CAN_ARCHIVE_CHECK = 1;
+ select_click_row(0);
+ current = select_shift_click_row(2);
+ EventUtils.synthesizeKey("a", {});
+ assert_selected_and_displayed(current);
+ mc.window.gFolderDisplay.MAX_COUNT_FOR_CAN_ARCHIVE_CHECK = 100;
+
+ htmlframe = mc.window.document.getElementById("multimessage");
+ archiveBtn = htmlframe.contentDocument.getElementById("hdrArchiveButton");
+ Assert.ok(
+ archiveBtn.collapsed,
+ "Multi-message archive button should be disabled when " +
+ "archiving is disabled!"
+ );
+}).skip();
+
+function check_tag_in_message(message, tag, isSet) {
+ let tagSet = message
+ .getStringProperty("keywords")
+ .split(" ")
+ .includes(tag.key);
+ if (isSet) {
+ Assert.ok(tagSet, "Tag '" + tag.name + "' expected on message!");
+ } else {
+ Assert.ok(!tagSet, "Tag '" + tag.name + "' not expected on message!");
+ }
+}
+
+add_task(async function test_tag_keys() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+
+ EventUtils.synthesizeKey("1", {});
+ check_tag_in_message(curMessage, tagArray[0], true);
+
+ EventUtils.synthesizeKey("2", {});
+ check_tag_in_message(curMessage, tagArray[0], true);
+ check_tag_in_message(curMessage, tagArray[1], true);
+
+ EventUtils.synthesizeKey("0", {});
+ check_tag_in_message(curMessage, tagArray[0], false);
+ check_tag_in_message(curMessage, tagArray[1], false);
+}).skip(); // TODO: not working
+
+add_task(async function test_tag_keys_disabled_in_content_tab() {
+ await be_in_folder(unreadFolder);
+ let curMessage = select_click_row(0);
+
+ mc.window.openAddonsMgr("addons://list/theme");
+ await new Promise(resolve => setTimeout(resolve));
+
+ let tab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ wait_for_content_tab_load(tab, "about:addons", 15000);
+
+ // Make sure pressing the "1" key in a content tab doesn't tag a message
+ check_tag_in_message(curMessage, tagArray[0], false);
+ EventUtils.synthesizeKey("1", {});
+ check_tag_in_message(curMessage, tagArray[0], false);
+
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+}).skip(); // TODO: not working
+
+registerCleanupFunction(function () {
+ // Make sure archiving is enabled at the end
+ enable_archiving(true);
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", gAutoRead);
+});
diff --git a/comm/mail/test/browser/folder-display/browser_messageCommandsOnMsgstore.js b/comm/mail/test/browser/folder-display/browser_messageCommandsOnMsgstore.js
new file mode 100644
index 0000000000..899a211de8
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_messageCommandsOnMsgstore.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/. */
+
+/**
+ * This tests some commands on messages via the UI. But we specifically check,
+ * whether the commands have an effect in the message store on disk, i.e. the
+ * markings on the messages are stored in the msgStore, not only in the database.
+ * For now, it checks for bug 840418.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+const {
+ open_compose_with_forward,
+ open_compose_with_reply,
+ setup_msg_contents,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+const {
+ be_in_folder,
+ create_folder,
+ empty_folder,
+ get_special_folder,
+ make_message_sets_in_folders,
+ mc,
+ press_delete,
+ right_click_on_row,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const {
+ click_menus_in_sequence,
+ plan_for_window_close,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let gInbox;
+let gOutbox;
+let gAutoRead;
+
+add_setup(async function () {
+ gAutoRead = Services.prefs.getBoolPref("mailnews.mark_message_read.auto");
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+
+ gOutbox = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+ gInbox = await create_folder("MsgStoreChecks");
+ await make_message_sets_in_folders([gInbox], [{ count: 6 }]);
+
+ // We delete the first message so that we have to compact anything.
+ await be_in_folder(gInbox);
+ let curMessage = select_click_row(0);
+ press_delete(mc);
+ Assert.notEqual(curMessage, select_click_row(0));
+
+ let urlListener = {
+ compactDone: false,
+
+ OnStartRunningUrl(aUrl) {},
+ OnStopRunningUrl(aUrl, aExitCode) {
+ Assert.equal(aExitCode, 0);
+ Assert.ok(gInbox.msgDatabase.summaryValid);
+ this.compactDone = true;
+ },
+ };
+
+ // Compaction adds the X-Mozilla-Status rows into the messages
+ // that we will need later on.
+ Assert.ok(gInbox.msgStore.supportsCompaction);
+ gInbox.compact(urlListener, null);
+
+ utils.waitFor(
+ function () {
+ return urlListener.compactDone;
+ },
+ "Timeout waiting for compact to complete",
+ 10000,
+ 100
+ );
+});
+
+/**
+ * Checks that a message has particular status stored in the mbox file,
+ * in the X-Mozilla-Status header.
+ *
+ * @param folder The folder containing the message to check.
+ * @param offset Offset to the start of the message within mbox file.
+ * @param expectedStatus The required status of the message.
+ */
+async function check_status(folder, offset, expectedStatus) {
+ let mboxstring = await IOUtils.readUTF8(folder.filePath.path);
+
+ // Ah-hoc header parsing. Only check the first 1KB because the X-Mozilla-*
+ // headers should be near the start.
+ let msg = mboxstring.slice(offset, offset + 1024);
+ msg = msg.replace(/\r/g, ""); // Simplify by using LFs only.
+ for (let line of msg.split("\n")) {
+ if (line == "") {
+ break; // end of header block.
+ }
+ if (line.startsWith("X-Mozilla-Status:")) {
+ let hexValue = /:\s*([0-9a-f]+)/i.exec(line)[1];
+ let gotStatus = parseInt(hexValue, 16);
+ Assert.equal(
+ gotStatus,
+ expectedStatus,
+ `Check X-Mozilla-Status (for msg at offset ${offset})`
+ );
+ return;
+ }
+ }
+ // If we got this far, we didn't find the header.
+ Assert.ok(
+ false,
+ `Find X-Mozilla-Status header (for msg at offset ${offset})`
+ );
+}
+
+add_task(async function test_mark_messages_read() {
+ be_in_folder(gOutbox); // TODO shouldn't have to swap folders
+ // 5 messages in the folder
+ await be_in_folder(gInbox);
+ let curMessage = select_click_row(0);
+ // Store the offset because it will be unavailable via the hdr
+ // after the message is deleted.
+ let offset = curMessage.messageOffset;
+ await check_status(gInbox, offset, 0); // status = unread
+ press_delete(mc);
+ Assert.notEqual(curMessage, select_click_row(0));
+ await check_status(
+ gInbox,
+ offset,
+ Ci.nsMsgMessageFlags.Read + Ci.nsMsgMessageFlags.Expunged
+ );
+
+ // 4 messages in the folder.
+ curMessage = select_click_row(0);
+ await check_status(gInbox, curMessage.messageOffset, 0); // status = unread
+
+ // Make sure we can mark all read with >0 messages unread.
+ await right_click_on_row(0);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ await click_menus_in_sequence(getMailContext(), [
+ { id: "mailContext-mark" },
+ { id: "mailContext-markAllRead" },
+ ]);
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ // All the 4 messages should now be read.
+ Assert.ok(curMessage.isRead, "Message should have been marked Read!");
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Read
+ );
+ curMessage = select_click_row(1);
+ Assert.ok(curMessage.isRead, "Message should have been marked Read!");
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Read
+ );
+ curMessage = select_click_row(2);
+ Assert.ok(curMessage.isRead, "Message should have been marked Read!");
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Read
+ );
+ curMessage = select_click_row(3);
+ Assert.ok(curMessage.isRead, "Message should have been marked Read!");
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Read
+ );
+
+ // Let's have the last message unread.
+ await right_click_on_row(3);
+ hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ await click_menus_in_sequence(getMailContext(), [
+ { id: "mailContext-mark" },
+ { id: "mailContext-markUnread" },
+ ]);
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ Assert.ok(!curMessage.isRead, "Message should have not been marked Read!");
+ await check_status(gInbox, curMessage.messageOffset, 0);
+});
+
+add_task(async function test_mark_messages_flagged() {
+ // Mark a message with the star.
+ let curMessage = select_click_row(1);
+ await right_click_on_row(1);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ await click_menus_in_sequence(getMailContext(), [
+ { id: "mailContext-mark" },
+ { id: "mailContext-markFlagged" },
+ ]);
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ Assert.ok(curMessage.isFlagged, "Message should have been marked Flagged!");
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Read + Ci.nsMsgMessageFlags.Marked
+ );
+});
+
+async function subtest_check_queued_message() {
+ // Always check the last message in the Outbox for the correct flag.
+ await be_in_folder(gOutbox);
+ let lastMsg = [...gOutbox.messages].pop();
+ await check_status(
+ gOutbox,
+ lastMsg.messageOffset,
+ Ci.nsMsgMessageFlags.Queued
+ );
+}
+
+/**
+ * Create a reply or forward of a message and queue it for sending later.
+ *
+ * @param aMsgRow Row index of message in Inbox that is to be replied/forwarded.
+ * @param aReply true = reply, false = forward.
+ */
+async function reply_forward_message(aMsgRow, aReply) {
+ await be_in_folder(gInbox);
+ select_click_row(aMsgRow);
+ let cwc;
+ if (aReply) {
+ // Reply to the message.
+ cwc = open_compose_with_reply();
+ } else {
+ // Forward the message.
+ cwc = open_compose_with_forward();
+ // Type in some recipient.
+ setup_msg_contents(cwc, "somewhere@host.invalid", "", "");
+ }
+
+ // Send it later.
+ plan_for_window_close(cwc);
+ // Ctrl+Shift+Return = Send Later
+ cwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.synthesizeKey(
+ "VK_RETURN",
+ {
+ shiftKey: true,
+ accelKey: true,
+ },
+ cwc.window
+ );
+ wait_for_window_close(cwc);
+
+ await subtest_check_queued_message();
+
+ // Now this is hacky. We can't get the message to be sent out of TB because there
+ // is no fake SMTP server support yet.
+ // But we know that upon real sending of the message, the code would/should call
+ // .addMessageDispositionState(). So call it directly and check the expected
+ // flags were set. This is risky as the real code could change and call
+ // a different function and the purpose of this test would be lost.
+ await be_in_folder(gInbox);
+ let curMessage = select_click_row(aMsgRow);
+ let disposition = aReply
+ ? gInbox.nsMsgDispositionState_Replied
+ : gInbox.nsMsgDispositionState_Forwarded;
+ gInbox.addMessageDispositionState(curMessage, disposition);
+}
+
+add_task(async function test_mark_messages_replied() {
+ await reply_forward_message(2, true);
+ let curMessage = select_click_row(2);
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Replied + Ci.nsMsgMessageFlags.Read
+ );
+});
+
+add_task(async function test_mark_messages_forwarded() {
+ await be_in_folder(gInbox);
+ // Forward a clean message.
+ await reply_forward_message(3, false);
+ let curMessage = select_click_row(3);
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Forwarded
+ );
+
+ // Forward a message that is read and already replied to.
+ curMessage = select_click_row(2);
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Replied + Ci.nsMsgMessageFlags.Read
+ );
+ await reply_forward_message(2, false);
+ await check_status(
+ gInbox,
+ curMessage.messageOffset,
+ Ci.nsMsgMessageFlags.Forwarded +
+ Ci.nsMsgMessageFlags.Replied +
+ Ci.nsMsgMessageFlags.Read
+ );
+});
+
+registerCleanupFunction(async function () {
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", gAutoRead);
+ // Clear all the created messages.
+ await be_in_folder(gInbox.parent);
+ await empty_folder(gInbox);
+ // await empty_folder(gOutbox); TODO
+ gInbox.server.rootFolder.emptyTrash(null);
+});
diff --git a/comm/mail/test/browser/folder-display/browser_messagePaneVisibility.js b/comm/mail/test/browser/folder-display/browser_messagePaneVisibility.js
new file mode 100644
index 0000000000..1cab2740e0
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_messagePaneVisibility.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/. */
+
+/*
+ * Test that the message pane collapses properly, stays collapsed amongst tab
+ * changes, and that persistence works (to a first approximation).
+ */
+
+"use strict";
+
+var {
+ assert_message_pane_hidden,
+ assert_message_pane_visible,
+ be_in_folder,
+ close_tab,
+ create_folder,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_tab,
+ open_selected_message_in_new_tab,
+ select_click_row,
+ switch_tab,
+ toggle_message_pane,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+
+add_setup(async function () {
+ folder = await create_folder("MessagePaneVisibility");
+ await make_message_sets_in_folders([folder], [{ count: 3 }]);
+});
+
+/**
+ * By default, the message pane should be visible. Make sure that this state of
+ * affairs is correct in terms of menu options, splitters, etc.
+ */
+add_task(async function test_message_pane_visible_state_is_right() {
+ await be_in_folder(folder);
+ assert_message_pane_visible();
+ Assert.ok(true, "test_message_pane_visible_state_is_right ran to completion");
+});
+
+/**
+ * Toggle the message off.
+ */
+add_task(function test_toggle_message_pane_off() {
+ toggle_message_pane();
+ assert_message_pane_hidden();
+ Assert.ok(true, "test_toggle_message_pane_off ran to completion");
+});
+
+/**
+ * Toggle the message pane on.
+ */
+add_task(function test_toggle_message_pane_on() {
+ toggle_message_pane();
+ assert_message_pane_visible();
+ Assert.ok(true, "test_toggle_message_pane_on ran to completion");
+});
+
+/**
+ * Make sure that the message tab isn't broken by being invoked from a folder tab
+ * with a collapsed message pane.
+ */
+add_task(
+ async function test_collapsed_message_pane_does_not_break_message_tab() {
+ await be_in_folder(folder);
+
+ // - toggle message pane off
+ toggle_message_pane();
+ assert_message_pane_hidden();
+
+ // - open message tab, make sure the message pane is visible
+ select_click_row(0);
+ let tabMessage = await open_selected_message_in_new_tab();
+
+ // - close the tab, sanity check the transition was okay
+ close_tab(tabMessage);
+ assert_message_pane_hidden();
+
+ // - restore the state...
+ toggle_message_pane();
+
+ Assert.ok(
+ true,
+ "test_collapsed_message_pane_does_not_break_message_tab ran to completion"
+ );
+ }
+);
+
+/**
+ * Make sure that switching to message tabs or folder pane tabs with a different
+ * message pane state does not break. This test should cover all transition
+ * states.
+ */
+add_task(async function test_message_pane_is_sticky() {
+ let tabFolderA = await be_in_folder(folder);
+ assert_message_pane_visible();
+
+ // [folder+ => (new) message]
+ select_click_row(0);
+ let tabMessage = await open_selected_message_in_new_tab();
+
+ // [message => folder+]
+ await switch_tab(tabFolderA);
+ assert_message_pane_visible();
+
+ // [folder+ => (new) folder+]
+ let tabFolderB = await open_folder_in_new_tab(folder);
+ assert_message_pane_visible();
+
+ // [folder pane toggle + => -]
+ toggle_message_pane();
+ assert_message_pane_hidden();
+
+ // [folder- => folder+]
+ await switch_tab(tabFolderA);
+ assert_message_pane_visible();
+
+ // (redundant) [ folder pane toggle + => -]
+ toggle_message_pane();
+ assert_message_pane_hidden();
+
+ // [folder- => message]
+ await switch_tab(tabMessage);
+
+ // [message => folder-]
+ close_tab(tabMessage);
+ assert_message_pane_hidden();
+
+ // [folder- => (new) folder-]
+ // (we are testing inheritance here)
+ let tabFolderC = await open_folder_in_new_tab(folder);
+ assert_message_pane_hidden();
+
+ // [folder- => folder-]
+ close_tab(tabFolderC);
+ // the tab we are on now doesn't matter, so we don't care
+ assert_message_pane_hidden();
+ await switch_tab(tabFolderB);
+
+ // [ folder pane toggle - => + ]
+ toggle_message_pane();
+ assert_message_pane_visible();
+
+ // [folder+ => folder-]
+ close_tab(tabFolderB);
+ assert_message_pane_hidden();
+
+ // (redundant) [ folder pane toggle - => + ]
+ toggle_message_pane();
+ assert_message_pane_visible();
+
+ Assert.ok(true, "test_message_pane_is_sticky ran to completion");
+});
+
+/**
+ * Test that if we serialize and restore the tabs that the message pane is in
+ * the expected collapsed/non-collapsed state. Because of the special "first
+ * tab" situation, we need to do this twice to test each case for the first
+ * tab. For additional thoroughness we also flip the state we have the other
+ * tabs be in.
+ */
+add_task(async function test_message_pane_persistence_generally_works() {
+ await be_in_folder(folder);
+
+ let tabmail = mc.window.document.getElementById("tabmail");
+
+ // helper to open tabs with the folder pane in the desired states (1 for
+ // visible, 0 for hidden)
+ async function openTabs(aConfig) {
+ for (let [iTab, messagePaneVisible] of aConfig.entries()) {
+ if (iTab != 0) {
+ await open_folder_in_new_tab(folder);
+ }
+ if (
+ tabmail.currentAbout3Pane.paneLayout.messagePaneVisible !=
+ messagePaneVisible
+ ) {
+ toggle_message_pane();
+ }
+ }
+ }
+
+ // close everything but the first tab.
+ function closeTabs() {
+ while (tabmail.tabInfo.length > 1) {
+ close_tab(1);
+ }
+ }
+
+ async function verifyTabs(aConfig) {
+ for (let [iTab, messagePaneVisible] of aConfig.entries()) {
+ info("tab " + iTab);
+
+ await switch_tab(iTab);
+ if (tabmail.currentAbout3Pane.document.readyState != "complete") {
+ await BrowserTestUtils.waitForEvent(tabmail.currentAbout3Pane, "load");
+ await new Promise(resolve =>
+ tabmail.currentAbout3Pane.setTimeout(resolve)
+ );
+ }
+
+ if (messagePaneVisible) {
+ assert_message_pane_visible();
+ } else {
+ assert_message_pane_hidden();
+ }
+ }
+ }
+
+ let configs = [
+ // 1st time: [+ - - + +]
+ [1, 0, 0, 1, 1],
+ // 2nd time: [- + + - -]
+ [0, 1, 1, 0, 0],
+ ];
+ for (let config of configs) {
+ await openTabs(config);
+ await verifyTabs(config); // make sure openTabs did its job right
+ let state = tabmail.persistTabs();
+ closeTabs();
+
+ Assert.equal(state.tabs[0].state.messagePaneVisible, config[0]);
+ Assert.equal(state.tabs[1].state.messagePaneVisible, config[1]);
+ Assert.equal(state.tabs[2].state.messagePaneVisible, config[2]);
+ Assert.equal(state.tabs[3].state.messagePaneVisible, config[3]);
+ Assert.equal(state.tabs[4].state.messagePaneVisible, config[4]);
+
+ // toggle the state for the current tab so we can be sure that it knows how
+ // to change things.
+ toggle_message_pane();
+
+ tabmail.restoreTabs(state);
+ await verifyTabs(config);
+ closeTabs();
+
+ // toggle the first tab again. This sets - properly for the second pass and
+ // restores it to + for when we are done.
+ toggle_message_pane();
+ }
+
+ Assert.ok(
+ true,
+ "test_message_pane_persistence_generally_works ran to completion"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_messageReloads.js b/comm/mail/test/browser/folder-display/browser_messageReloads.js
new file mode 100644
index 0000000000..e89793d865
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_messageReloads.js
@@ -0,0 +1,64 @@
+/* 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 that message reloads happen properly when the message pane is hidden,
+ * and then made visible again.
+ */
+
+"use strict";
+
+var {
+ assert_message_pane_hidden,
+ assert_message_pane_visible,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_tab,
+ make_message_sets_in_folders,
+ create_folder,
+ open_folder_in_new_tab,
+ select_click_row,
+ switch_tab,
+ toggle_message_pane,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+
+add_setup(async function () {
+ folder = await create_folder("MessageReloads");
+ await make_message_sets_in_folders([folder], [{ count: 1 }]);
+});
+
+add_task(async function test_message_reloads_work_with_message_pane_toggles() {
+ await be_in_folder(folder);
+
+ assert_message_pane_visible();
+ select_click_row(0);
+ // Toggle the message pane off, then on
+ toggle_message_pane();
+ assert_message_pane_hidden();
+ toggle_message_pane();
+ assert_message_pane_visible();
+ // Open a new tab with the same message
+ let tab = await open_folder_in_new_tab(folder);
+ // Toggle the message pane off
+ assert_message_pane_visible();
+ toggle_message_pane();
+ assert_message_pane_hidden();
+ // Go back to the first tab, and make sure the message is actually displayed
+ await switch_tab(0);
+ assert_message_pane_visible();
+ assert_selected_and_displayed(0);
+
+ close_tab(tab);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_messageSize.js b/comm/mail/test/browser/folder-display/browser_messageSize.js
new file mode 100644
index 0000000000..d62c3d808c
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_messageSize.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/. */
+
+/*
+ * Test that the size column of in the message list is formatted properly (e.g.
+ 0.1 KB, 1.2 KB, 12.3 KB, 123 KB, and likewise for MB and GB).
+ */
+
+"use strict";
+
+var {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_3pane,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+
+add_setup(async function () {
+ folder = await create_folder("MessageSizeA");
+
+ // Create messages with sizes in the byte, KB, and MB ranges.
+ let bytemsg = create_message({ body: { body: " " } });
+
+ let kbstring = "x ".repeat(1024 / 2);
+ let kbmsg = create_message({ body: { body: kbstring } });
+
+ let mbstring = kbstring.repeat(1024);
+ let mbmsg = create_message({ body: { body: mbstring } });
+
+ await add_message_to_folder([folder], bytemsg);
+ await add_message_to_folder([folder], kbmsg);
+ await add_message_to_folder([folder], mbmsg);
+});
+
+async function _help_test_message_size(index, unit) {
+ await be_in_folder(folder);
+
+ // Select the nth message
+ let curMessage = select_click_row(index);
+ // Look at the size column's data
+ let sizeStr = get_about_3pane().gDBView.cellTextForColumn(index, "sizeCol");
+
+ // Note: this assumes that the numeric part of the size string is first
+ let realSize = curMessage.messageSize;
+ let abbrSize = parseFloat(sizeStr);
+
+ if (isNaN(abbrSize)) {
+ throw new Error("formatted size is not numeric: '" + sizeStr + "'");
+ }
+ if (Math.abs(realSize / Math.pow(1024, unit) - abbrSize) > 0.5) {
+ throw new Error("size mismatch: '" + realSize + "' and '" + sizeStr + "'");
+ }
+}
+
+add_task(async function test_byte_message_size() {
+ await _help_test_message_size(0, 1);
+});
+
+add_task(async function test_kb_message_size() {
+ await _help_test_message_size(1, 1);
+});
+
+add_task(async function test_mb_message_size() {
+ await _help_test_message_size(2, 2);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_messageWindow.js b/comm/mail/test/browser/folder-display/browser_messageWindow.js
new file mode 100644
index 0000000000..ac8d900195
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_messageWindow.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/. */
+
+/*
+ * Test that we can open and close a standalone message display window from the
+ * folder pane.
+ */
+
+"use strict";
+
+var {
+ add_message_sets_to_folders,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ create_thread,
+ open_selected_message_in_new_window,
+ plan_for_message_display,
+ press_delete,
+ select_click_row,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { plan_for_window_close, wait_for_window_close } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var folderA, folderB;
+var curMessage;
+
+add_setup(async function () {
+ folderA = await create_folder("MessageWindowA");
+ folderB = await create_folder("MessageWindowB");
+ // create three messages in the folder to display
+ let msg1 = create_thread(1);
+ let msg2 = create_thread(1);
+ let thread1 = create_thread(2);
+ let thread2 = create_thread(2);
+ await add_message_sets_to_folders([folderA], [msg1, msg2, thread1, thread2]);
+ // add two more messages in another folder
+ let msg3 = create_thread(1);
+ let msg4 = create_thread(1);
+ await add_message_sets_to_folders([folderB], [msg3, msg4]);
+ folderA.msgDatabase.dBFolderInfo.viewFlags =
+ Ci.nsMsgViewFlagsType.kThreadedDisplay;
+});
+
+/** The message window controller. */
+var msgc;
+
+add_task(async function test_open_message_window() {
+ await be_in_folder(folderA);
+
+ // select the first message
+ curMessage = select_click_row(0);
+
+ // display it
+ msgc = await open_selected_message_in_new_window();
+ assert_selected_and_displayed(msgc, curMessage);
+});
+
+/**
+ * Use the "m" keyboard accelerator to mark a message as read or unread.
+ */
+add_task(function test_toggle_read() {
+ curMessage.markRead(false);
+ EventUtils.synthesizeKey("m", {}, msgc.window);
+ Assert.ok(curMessage.isRead, "Message should have been marked read!");
+
+ EventUtils.synthesizeKey("m", {}, msgc.window);
+ Assert.ok(!curMessage.isRead, "Message should have been marked unread!");
+});
+
+/**
+ * Use the "f" keyboard accelerator to navigate to the next message,
+ * and verify that it is indeed loaded.
+ */
+add_task(function test_navigate_to_next_message() {
+ plan_for_message_display(msgc);
+ EventUtils.synthesizeKey("f", {}, msgc.window);
+ wait_for_message_display_completion(msgc, true);
+ assert_selected_and_displayed(msgc, 1);
+}).skip();
+
+/**
+ * Delete a single message and verify the next message is loaded. This sets
+ * us up for the next test, which is delete on a collapsed thread after
+ * the previous message was deleted.
+ */
+add_task(function test_delete_single_message() {
+ plan_for_message_display(msgc);
+ press_delete(msgc);
+ wait_for_message_display_completion(msgc, true);
+ assert_selected_and_displayed(msgc, 1);
+}).skip();
+
+/**
+ * Delete the current message, and verify that it only deletes
+ * a single message, not the messages in the collapsed thread
+ */
+add_task(function test_del_collapsed_thread() {
+ plan_for_message_display(msgc);
+ press_delete(msgc);
+ if (folderA.getTotalMessages(false) != 4) {
+ throw new Error("should have only deleted one message");
+ }
+ wait_for_message_display_completion(msgc, true);
+ assert_selected_and_displayed(msgc, 1);
+}).skip();
+
+/**
+ * Hit n enough times to mark all messages in folder A read, and then accept the
+ * modal dialog saying that we should move to the next folder. Then, assert that
+ * the message displayed in the standalone message window is folder B's first
+ * message (since all messages in folder B were unread).
+ */
+add_task(async function test_next_unread() {
+ for (let i = 0; i < 3; ++i) {
+ plan_for_message_display(msgc);
+ EventUtils.synthesizeKey("n", {}, msgc.window);
+ wait_for_message_display_completion(msgc, true);
+ }
+
+ for (let m of folderA.messages) {
+ Assert.ok(m.isRead, `${m.messageId} is read`);
+ }
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("n", {}, msgc.window);
+ plan_for_message_display(msgc);
+ await dialogPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ // move to folder B
+ await be_in_folder(folderB);
+
+ // select the first message, and make sure it's not read
+ let msg = select_click_row(0);
+
+ // make sure we've been displaying the right message
+ assert_selected_and_displayed(msgc, msg);
+}).skip();
+
+/**
+ * Close the window by hitting escape.
+ */
+add_task(function test_close_message_window() {
+ plan_for_window_close(msgc);
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, msgc.window);
+ wait_for_window_close(msgc);
+});
diff --git a/comm/mail/test/browser/folder-display/browser_openingMessages.js b/comm/mail/test/browser/folder-display/browser_openingMessages.js
new file mode 100644
index 0000000000..ca898e6e10
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_openingMessages.js
@@ -0,0 +1,186 @@
+/* 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 that we open single and multiple messages from the thread pane
+ * according to the mail.openMessageBehavior preference, and that we have the
+ * correct message headers displayed in whatever we open.
+ *
+ * Currently tested:
+ * - opening single and multiple messages in tabs
+ * - opening a single message in a window. (Multiple messages require a fair
+ * amount of additional work and are hard to test. We're also assuming here
+ * that multiple messages opened in windows are just the same function called
+ * repeatedly.)
+ * - reusing an existing window to show another message
+ */
+
+"use strict";
+
+var {
+ assert_message_pane_focused,
+ assert_number_of_tabs_open,
+ assert_selected_and_displayed,
+ assert_tab_mode_name,
+ assert_tab_titled_from,
+ be_in_folder,
+ close_message_window,
+ close_tab,
+ create_folder,
+ make_message_sets_in_folders,
+ mc,
+ open_selected_message,
+ open_selected_messages,
+ plan_for_message_display,
+ reset_open_message_behavior,
+ select_click_row,
+ select_shift_click_row,
+ set_open_message_behavior,
+ switch_tab,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { async_plan_for_new_window, wait_for_new_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+// One folder's enough
+var folder = null;
+
+// Number of messages to open for multi-message tests
+var NUM_MESSAGES_TO_OPEN = 5;
+
+add_setup(async function () {
+ folder = await create_folder("OpeningMessagesA");
+ await make_message_sets_in_folders([folder], [{ count: 10 }]);
+});
+
+/**
+ * Test opening a single message in a new tab.
+ */
+add_task(async function test_open_single_message_in_tab() {
+ set_open_message_behavior("NEW_TAB");
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+ await be_in_folder(folder);
+ // Select one message
+ let msgHdr = select_click_row(1);
+ // Open it
+ open_selected_message();
+ // Check that the tab count has increased by 1
+ assert_number_of_tabs_open(preCount + 1);
+ // Check that the currently displayed tab is a message tab (i.e. our newly
+ // opened tab is in the foreground)
+ assert_tab_mode_name(null, "mailMessageTab");
+
+ let tab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ if (
+ tab.chromeBrowser.docShell.isLoadingDocument ||
+ tab.chromeBrowser.currentURI.spec != "about:message"
+ ) {
+ await BrowserTestUtils.browserLoaded(tab.chromeBrowser);
+ }
+
+ // Check that the message header displayed is the right one
+ assert_selected_and_displayed(msgHdr);
+ // Check that the message pane is focused
+ assert_message_pane_focused();
+ // Clean up, close the tab
+ close_tab(mc.window.document.getElementById("tabmail").currentTabInfo);
+ await switch_tab(folderTab);
+ reset_open_message_behavior();
+});
+
+/**
+ * Test opening multiple messages in new tabs.
+ */
+add_task(async function test_open_multiple_messages_in_tabs() {
+ set_open_message_behavior("NEW_TAB");
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+ await be_in_folder(folder);
+
+ // Select a bunch of messages
+ select_click_row(1);
+ let selectedMessages = select_shift_click_row(NUM_MESSAGES_TO_OPEN);
+ // Open them
+ open_selected_messages();
+ // Check that the tab count has increased by the correct number
+ assert_number_of_tabs_open(preCount + NUM_MESSAGES_TO_OPEN);
+ // Check that the currently displayed tab is a message tab (i.e. one of our
+ // newly opened tabs is in the foreground)
+ assert_tab_mode_name(null, "mailMessageTab");
+
+ // Now check whether each of the NUM_MESSAGES_TO_OPEN tabs has the correct
+ // title
+ for (let i = 0; i < NUM_MESSAGES_TO_OPEN; i++) {
+ assert_tab_titled_from(
+ mc.window.document.getElementById("tabmail").tabInfo[preCount + i],
+ selectedMessages[i]
+ );
+ }
+
+ // Check whether each tab has the correct message and whether the message pane
+ // is focused in each case, then close it to load the previous tab.
+ for (let i = 0; i < NUM_MESSAGES_TO_OPEN; i++) {
+ assert_selected_and_displayed(selectedMessages.pop());
+ assert_message_pane_focused();
+ close_tab(mc.window.document.getElementById("tabmail").currentTabInfo);
+ }
+ await switch_tab(folderTab);
+ reset_open_message_behavior();
+});
+
+/**
+ * Test opening a message in a new window.
+ */
+add_task(async function test_open_message_in_new_window() {
+ set_open_message_behavior("NEW_WINDOW");
+ await be_in_folder(folder);
+
+ // Select a message
+ let msgHdr = select_click_row(1);
+
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ // Open it
+ open_selected_message();
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ assert_selected_and_displayed(msgc, msgHdr);
+
+ // Clean up, close the window
+ close_message_window(msgc);
+ reset_open_message_behavior();
+});
+
+/**
+ * Test reusing an existing window to open a new message.
+ */
+add_task(async function test_open_message_in_existing_window() {
+ set_open_message_behavior("EXISTING_WINDOW");
+ await be_in_folder(folder);
+
+ // Open up a window
+ select_click_row(1);
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ open_selected_message();
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ // Select another message and open it
+ let msgHdr = select_click_row(2);
+ plan_for_message_display(msgc);
+ open_selected_message();
+ wait_for_message_display_completion(msgc, true);
+
+ // Check if our old window displays the message
+ assert_selected_and_displayed(msgc, msgHdr);
+ // Clean up, close the window
+ close_message_window(msgc);
+ reset_open_message_behavior();
+});
diff --git a/comm/mail/test/browser/folder-display/browser_openingMessagesWithoutABackingView.js b/comm/mail/test/browser/folder-display/browser_openingMessagesWithoutABackingView.js
new file mode 100644
index 0000000000..d5459b8692
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_openingMessagesWithoutABackingView.js
@@ -0,0 +1,248 @@
+/* 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 that messages without a backing view are opened correctly. Examples of
+ * messages without a backing view are those opened from the command line or
+ * desktop search integration results.
+ */
+
+"use strict";
+
+var {
+ add_to_toolbar,
+ assert_message_pane_focused,
+ assert_messages_not_in_view,
+ assert_number_of_tabs_open,
+ assert_selected_and_displayed,
+ assert_tab_mode_name,
+ assert_tab_titled_from,
+ be_in_folder,
+ close_message_window,
+ close_tab,
+ create_folder,
+ get_about_3pane,
+ make_message_sets_in_folders,
+ mc,
+ plan_for_message_display,
+ remove_from_toolbar,
+ reset_open_message_behavior,
+ set_mail_view,
+ set_open_message_behavior,
+ switch_tab,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { async_plan_for_new_window, wait_for_new_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { MailViewConstants } = ChromeUtils.import(
+ "resource:///modules/MailViewManager.jsm"
+);
+
+// One folder's enough
+var folder = null;
+
+// A list of the message headers in this folder
+var msgHdrsInFolder = null;
+
+// Number of messages to open for multi-message tests
+var NUM_MESSAGES_TO_OPEN = 5;
+
+add_setup(async function () {
+ folder = await create_folder("OpeningMessagesNoBackingViewA");
+ await make_message_sets_in_folders([folder], [{ count: 10 }]);
+});
+
+/**
+ * Test opening a single message without a backing view in a new tab.
+ */
+async function test_open_single_message_without_backing_view_in_tab() {
+ set_open_message_behavior("NEW_TAB");
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+ await be_in_folder(folder);
+
+ let win = get_about_3pane();
+
+ if (!msgHdrsInFolder) {
+ msgHdrsInFolder = [];
+ // Make a list of all the message headers in this folder
+ for (let i = 0; i < 10; i++) {
+ msgHdrsInFolder.push(win.gDBView.getMsgHdrAt(i));
+ }
+ }
+ // Get a reference to a header
+ let msgHdr = msgHdrsInFolder[4];
+ // Open it
+ MailUtils.displayMessage(msgHdr);
+ // This is going to trigger a message display in the main 3pane window. Since
+ // the message will open in a new tab, we shouldn't
+ // plan_for_message_display().
+ wait_for_message_display_completion(mc, true);
+ // Check that the tab count has increased by 1
+ assert_number_of_tabs_open(preCount + 1);
+ // Check that the currently displayed tab is a message tab (i.e. our newly
+ // opened tab is in the foreground)
+ assert_tab_mode_name(null, "mailMessageTab");
+ // Check that the message header displayed is the right one
+ assert_selected_and_displayed(msgHdr);
+ // Check that the message pane is focused
+ assert_message_pane_focused();
+ // Clean up, close the tab
+ close_tab(mc.window.document.getElementById("tabmail").currentTabInfo);
+ await switch_tab(folderTab);
+ reset_open_message_behavior();
+}
+add_task(test_open_single_message_without_backing_view_in_tab);
+
+/**
+ * Test opening multiple messages without backing views in new tabs.
+ */
+async function test_open_multiple_messages_without_backing_views_in_tabs() {
+ set_open_message_behavior("NEW_TAB");
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+ await be_in_folder(folder);
+
+ // Get a reference to a bunch of headers
+ let msgHdrs = msgHdrsInFolder.slice(0, NUM_MESSAGES_TO_OPEN);
+
+ // Open them
+ MailUtils.displayMessages(msgHdrs);
+ // This is going to trigger a message display in the main 3pane window. Since
+ // the message will open in a new tab, we shouldn't
+ // plan_for_message_display().
+ wait_for_message_display_completion(mc, true);
+ // Check that the tab count has increased by the correct number
+ assert_number_of_tabs_open(preCount + NUM_MESSAGES_TO_OPEN);
+ // Check that the currently displayed tab is a message tab (i.e. one of our
+ // newly opened tabs is in the foreground)
+ assert_tab_mode_name(null, "mailMessageTab");
+
+ // Now check whether each of the NUM_MESSAGES_TO_OPEN tabs has the correct
+ // title
+ for (let i = 0; i < NUM_MESSAGES_TO_OPEN; i++) {
+ assert_tab_titled_from(
+ mc.window.document.getElementById("tabmail").tabInfo[preCount + i],
+ msgHdrs[i]
+ );
+ }
+
+ // Check whether each tab has the correct message and whether the message pane
+ // is focused in each case, then close it to load the previous tab.
+ for (let i = 0; i < NUM_MESSAGES_TO_OPEN; i++) {
+ assert_selected_and_displayed(msgHdrs.pop());
+ assert_message_pane_focused();
+ close_tab(mc.window.document.getElementById("tabmail").currentTabInfo);
+ }
+ await switch_tab(folderTab);
+ reset_open_message_behavior();
+}
+add_task(test_open_multiple_messages_without_backing_views_in_tabs);
+
+/**
+ * Test opening a message without a backing view in a new window.
+ */
+async function test_open_message_without_backing_view_in_new_window() {
+ set_open_message_behavior("NEW_WINDOW");
+ await be_in_folder(folder);
+
+ // Select a message
+ let msgHdr = msgHdrsInFolder[6];
+
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ // Open it
+ MailUtils.displayMessage(msgHdr);
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ assert_selected_and_displayed(msgc, msgHdr);
+ // Clean up, close the window
+ close_message_window(msgc);
+ reset_open_message_behavior();
+}
+add_task(test_open_message_without_backing_view_in_new_window).skip(); // TODO
+
+/**
+ * Test reusing an existing window to open a new message.
+ */
+async function test_open_message_without_backing_view_in_existing_window() {
+ set_open_message_behavior("EXISTING_WINDOW");
+ await be_in_folder(folder);
+
+ // Open up a window
+ let firstMsgHdr = msgHdrsInFolder[3];
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ MailUtils.displayMessage(firstMsgHdr);
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ // Open another message
+ let msgHdr = msgHdrsInFolder[7];
+ plan_for_message_display(msgc);
+ MailUtils.displayMessage(msgHdr);
+ wait_for_message_display_completion(msgc, true);
+
+ // Check if our old window displays the message
+ assert_selected_and_displayed(msgc, msgHdr);
+ // Clean up, close the window
+ close_message_window(msgc);
+ reset_open_message_behavior();
+}
+add_task(test_open_message_without_backing_view_in_existing_window).skip(); // TODO
+
+/**
+ * Time to throw a spanner in the works. Set a mail view for the folder that
+ * excludes every message.
+ */
+add_task(function test_filter_out_all_messages() {
+ set_mail_view(MailViewConstants.kViewItemTags, "$label1");
+ // Make sure all the messages have actually disappeared
+ assert_messages_not_in_view(msgHdrsInFolder);
+});
+
+/**
+ * Re-run all the tests.
+ */
+add_task(
+ async function test_open_single_message_without_backing_view_in_tab_filtered() {
+ await test_open_single_message_without_backing_view_in_tab();
+ }
+);
+
+add_task(
+ async function test_open_multiple_messages_without_backing_views_in_tabs_filtered() {
+ await test_open_multiple_messages_without_backing_views_in_tabs();
+ }
+);
+
+add_task(
+ async function test_open_message_without_backing_view_in_new_window_filtered() {
+ await test_open_message_without_backing_view_in_new_window();
+ }
+).skip(); // TODO
+
+add_task(
+ async function test_open_message_without_backing_view_in_existing_window_filtered() {
+ await test_open_message_without_backing_view_in_existing_window();
+ }
+).skip(); // TODO
+
+/**
+ * Good hygiene: remove the view picker from the toolbar.
+ */
+add_task(function test_cleanup() {
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_readMsgs.js b/comm/mail/test/browser/folder-display/browser_readMsgs.js
new file mode 100644
index 0000000000..1f69bfbe54
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_readMsgs.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/. */
+
+/**
+ * Tests various special messages.
+ */
+
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var {
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ inboxFolder,
+ press_delete,
+ select_click_row,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+/**
+ * Tests that a message containing an invalid vcard can be displayed.
+ */
+add_task(async function testMarkedAsRead() {
+ let folder = await create_folder("SpecialMsgs");
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", true);
+
+ let file = new FileUtils.File(getTestFilePath("data/test-invalid-vcard.eml"));
+ Assert.ok(file.exists(), "test data file should exist");
+ let promiseCopyListener = new PromiseTestUtils.PromiseCopyListener();
+ // Copy gIncomingMailFile into the Inbox.
+ MailServices.copy.copyFileMessage(
+ file,
+ folder,
+ null,
+ false,
+ 0,
+ "",
+ promiseCopyListener,
+ null
+ );
+ await promiseCopyListener.promise;
+ await be_in_folder(folder);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(0);
+ // Make sure it's the msg we want.
+ Assert.equal(msg.subject, "this contains an invalid vcard");
+ // The message should get marked as read.
+ await BrowserTestUtils.waitForCondition(
+ () => msg.isRead,
+ "should get marked as read"
+ );
+ await be_in_folder(inboxFolder);
+ folder.deleteSelf(null);
+ Services.prefs.clearUserPref("mailnews.mark_message_read.auto");
+});
diff --git a/comm/mail/test/browser/folder-display/browser_recentMenu.js b/comm/mail/test/browser/folder-display/browser_recentMenu.js
new file mode 100644
index 0000000000..d87b119b0f
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_recentMenu.js
@@ -0,0 +1,195 @@
+/* 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 tests the move/copy to recent folder menus to make sure
+ * that they get updated when messages are moved to folders, and
+ * don't get updated when we archive.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var {
+ archive_selected_messages,
+ be_in_folder,
+ create_folder,
+ get_special_folder,
+ make_message_sets_in_folders,
+ mc,
+ press_delete,
+ right_click_on_row,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ click_menus_in_sequence,
+ close_popup_sequence,
+ click_appmenu_in_sequence,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folder1, folder2;
+var gInitRecentMenuCount;
+
+add_setup(async function () {
+ // Ensure that there are no updated folders to ensure the recent folder
+ // is empty.
+ for (let folder of MailServices.accounts.allFolders) {
+ folder.setStringProperty("MRMTime", "0");
+ }
+
+ // Try to make these folders first in alphabetic order
+ folder1 = await create_folder("aaafolder1");
+ folder2 = await create_folder("aaafolder2");
+
+ await make_message_sets_in_folders([folder1], [{ count: 3 }]);
+});
+
+add_task(async function test_move_message() {
+ await be_in_folder(folder1);
+ let msgHdr = select_click_row(0);
+ // This will cause the initial build of the move recent context menu,
+ // which should be empty and disabled.
+ await right_click_on_row(0);
+ let popups = await click_menus_in_sequence(
+ getMailContext(),
+ [{ id: "mailContext-moveMenu" }, { label: "Recent" }],
+ true
+ );
+ let recentMenu = popups[popups.length - 2].querySelector('[label="Recent"]');
+ Assert.equal(recentMenu.getAttribute("disabled"), "true");
+ gInitRecentMenuCount = recentMenu.itemCount;
+ Assert.equal(gInitRecentMenuCount, 0);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ close_popup_sequence(popups);
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ let copyListener = {
+ copyDone: false,
+ OnStartCopy() {},
+ OnProgress(aProgress, aProgressMax) {},
+ SetMessageKey(aKey) {},
+ SetMessageId(aMessageId) {},
+ OnStopCopy(aStatus) {
+ this.copyDone = true;
+ },
+ };
+ MailServices.copy.copyMessages(
+ folder1,
+ [msgHdr],
+ folder2,
+ true,
+ copyListener,
+ mc.window.msgWindow,
+ true
+ );
+ utils.waitFor(
+ () => copyListener.copyDone,
+ "Timeout waiting for copy to complete",
+ 10000,
+ 100
+ );
+ // We've moved a message to aaafolder2 - it should appear in recent list now.
+ // Clicking the menuitem by label is not localizable, but Recent doesn't have an
+ // id we can use.
+ select_click_row(0);
+ await right_click_on_row(0);
+ popups = await click_menus_in_sequence(
+ getMailContext(),
+ [{ id: "mailContext-moveMenu" }, { label: "Recent" }],
+ true
+ );
+ let recentChildren = popups[popups.length - 1].children;
+ Assert.equal(
+ recentChildren.length,
+ gInitRecentMenuCount + 1,
+ "recent menu should have one more child after move"
+ );
+ Assert.equal(
+ recentChildren[0].label,
+ "aaafolder2",
+ "recent menu child should be aaafolder2 after move"
+ );
+ hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ close_popup_sequence(popups);
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
+
+add_task(async function test_delete_message() {
+ press_delete(mc);
+ // We've deleted a message - we should still just have folder2 in the menu.
+ select_click_row(0); // TODO shouldn't need to do this
+ await right_click_on_row(0);
+ let popups = await click_menus_in_sequence(
+ getMailContext(),
+ [{ id: "mailContext-moveMenu" }, { label: "Recent" }],
+ true
+ );
+ let recentChildren = popups[popups.length - 1].children;
+ Assert.equal(
+ recentChildren.length,
+ gInitRecentMenuCount + 1,
+ "delete shouldn't add anything to recent menu"
+ );
+ Assert.equal(
+ recentChildren[0].label,
+ "aaafolder2",
+ "recent menu should still be aaafolder2 after delete"
+ );
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ close_popup_sequence(popups);
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
+
+add_task(async function test_archive_message() {
+ archive_selected_messages();
+ // We've archived a message - we should still just have folder2 in the menu.
+ let archive = await get_special_folder(
+ Ci.nsMsgFolderFlags.Archive,
+ false,
+ false
+ );
+ await be_in_folder(archive.descendants[0]);
+ select_click_row(0);
+ await right_click_on_row(0);
+ let popups = await click_menus_in_sequence(
+ getMailContext(),
+ [{ id: "mailContext-moveMenu" }, { label: "Recent" }],
+ true
+ );
+ let recentChildren = popups[popups.length - 1].children;
+ Assert.equal(
+ recentChildren.length,
+ gInitRecentMenuCount + 1,
+ "archive shouldn't add anything to recent menu"
+ );
+ Assert.equal(
+ recentChildren[0].label,
+ "aaafolder2",
+ "recent menu should still be aaafolder2 after archive"
+ );
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ getMailContext(),
+ "popuphidden"
+ );
+ close_popup_sequence(popups);
+ await hiddenPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
diff --git a/comm/mail/test/browser/folder-display/browser_rightClickMiddleClickFolders.js b/comm/mail/test/browser/folder-display/browser_rightClickMiddleClickFolders.js
new file mode 100644
index 0000000000..5c69265127
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_rightClickMiddleClickFolders.js
@@ -0,0 +1,276 @@
+/* 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 the many horrors involving right-clicks, middle clicks, and
+ * selections... on folders!
+ */
+
+"use strict";
+
+var {
+ assert_folder_displayed,
+ assert_folder_selected,
+ assert_folder_selected_and_displayed,
+ assert_folders_selected_and_displayed,
+ assert_selected_tab,
+ be_in_folder,
+ close_popup,
+ close_tab,
+ create_folder,
+ make_message_sets_in_folders,
+ mc,
+ middle_click_on_folder,
+ reset_context_menu_background_tabs,
+ right_click_on_folder,
+ select_click_folder,
+ select_shift_click_folder,
+ set_context_menu_background_tabs,
+ switch_tab,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folderA, folderB, folderC;
+
+add_setup(async function () {
+ folderA = await create_folder("RightClickMiddleClickFoldersA");
+ folderB = await create_folder("RightClickMiddleClickFoldersB");
+ folderC = await create_folder("RightClickMiddleClickFoldersC");
+
+ // We aren't really interested in the messages the folders contain, but just
+ // for appearance's sake, add a message to each folder
+
+ await make_message_sets_in_folders([folderA], [{ count: 1 }]);
+ await make_message_sets_in_folders([folderB], [{ count: 1 }]);
+ await make_message_sets_in_folders([folderC], [{ count: 1 }]);
+});
+
+/**
+ * One-thing selected, right-click on something else.
+ */
+add_task(async function test_right_click_folder_with_one_thing_selected() {
+ select_click_folder(folderB);
+ assert_folder_selected_and_displayed(folderB);
+
+ await right_click_on_folder(folderA);
+ assert_folder_selected(folderA);
+ assert_folder_displayed(folderB);
+
+ await close_popup(mc, getFoldersContext());
+ assert_folder_selected_and_displayed(folderB);
+}).skip();
+
+/**
+ * Many things selected, right-click on something that is not in that selection.
+ */
+add_task(async function test_right_click_folder_with_many_things_selected() {
+ select_click_folder(folderA);
+ select_shift_click_folder(folderB);
+ assert_folders_selected_and_displayed(folderA, folderB);
+
+ await right_click_on_folder(folderC);
+ assert_folder_selected(folderC);
+ assert_folder_displayed(folderA);
+
+ await close_popup(mc, getFoldersContext());
+ assert_folders_selected_and_displayed(folderA, folderB);
+}).skip();
+
+/**
+ * One thing selected, right-click on that.
+ */
+add_task(async function test_right_click_folder_on_existing_single_selection() {
+ select_click_folder(folderA);
+ assert_folders_selected_and_displayed(folderA);
+
+ await right_click_on_folder(folderA);
+ assert_folders_selected_and_displayed(folderA);
+
+ await close_popup(mc, getFoldersContext());
+ assert_folders_selected_and_displayed(folderA);
+});
+
+/**
+ * Many things selected, right-click somewhere in the selection.
+ */
+add_task(async function test_right_click_folder_on_existing_multi_selection() {
+ select_click_folder(folderB);
+ select_shift_click_folder(folderC);
+ assert_folders_selected_and_displayed(folderB, folderC);
+
+ await right_click_on_folder(folderC);
+ assert_folders_selected_and_displayed(folderB, folderC);
+
+ await close_popup(mc, getFoldersContext());
+ assert_folders_selected_and_displayed(folderB, folderC);
+}).skip();
+
+/**
+ * One-thing selected, middle-click on something else.
+ */
+async function _middle_click_folder_with_one_thing_selected_helper(
+ aBackground
+) {
+ select_click_folder(folderB);
+ assert_folder_selected_and_displayed(folderB);
+
+ let originalTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let [newTab] = middle_click_on_folder(folderA);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(originalTab);
+ // Now switch to the new tab and check
+ await switch_tab(newTab);
+ }
+ assert_folder_selected_and_displayed(folderA);
+ close_tab(newTab);
+
+ assert_folder_selected_and_displayed(folderB);
+}
+
+async function _middle_click_folder_with_many_things_selected_helper(
+ aBackground
+) {
+ select_click_folder(folderB);
+ select_shift_click_folder(folderC);
+ assert_folders_selected_and_displayed(folderB, folderC);
+
+ let originalTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let [newTab] = middle_click_on_folder(folderA);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(originalTab);
+ // Now switch to the new tab and check
+ await switch_tab(newTab);
+ }
+ assert_folder_selected_and_displayed(folderA);
+ close_tab(newTab);
+
+ // XXX Again, this is wrong. We're still giving it a pass because selecting
+ // both folderB and folderC is currently the same as selecting folderB.
+ assert_folder_selected_and_displayed(folderB);
+}
+
+/**
+ * One thing selected, middle-click on that.
+ */
+async function _middle_click_folder_on_existing_single_selection_helper(
+ aBackground
+) {
+ select_click_folder(folderC);
+ assert_folder_selected_and_displayed(folderC);
+
+ let originalTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let [newTab] = middle_click_on_folder(folderC);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(originalTab);
+ // Now switch to the new tab and check
+ await switch_tab(newTab);
+ }
+ assert_folder_selected_and_displayed(folderC);
+ close_tab(newTab);
+
+ assert_folder_selected_and_displayed(folderC);
+}
+
+/**
+ * Many things selected, middle-click somewhere in the selection.
+ */
+async function _middle_click_on_existing_multi_selection_helper(aBackground) {
+ select_click_folder(folderA);
+ select_shift_click_folder(folderC);
+ assert_folders_selected_and_displayed(folderA, folderB, folderC);
+
+ let originalTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let [newTab] = middle_click_on_folder(folderB);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(originalTab);
+ // Now switch to the new tab and check
+ await switch_tab(newTab);
+ }
+ assert_folder_selected_and_displayed(folderB);
+ close_tab(newTab);
+
+ // XXX Again, this is wrong. We're still giving it a pass because selecting
+ // folderA through folderC is currently the same as selecting folderA.
+ assert_folder_selected_and_displayed(folderA);
+}
+
+/**
+ * Middle click on target folder when a folder is selected and displayed.
+ */
+async function middle_click_helper(selectedFolder, targetFolder, shiftPressed) {
+ select_click_folder(selectedFolder);
+ assert_folders_selected_and_displayed(selectedFolder);
+ let originalTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ let [newTab] = middle_click_on_folder(targetFolder, shiftPressed);
+
+ if (shiftPressed) {
+ assert_selected_tab(newTab);
+ } else {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(originalTab);
+ // Now switch to the new tab and check the tab was switched.
+ await switch_tab(newTab);
+ }
+ close_tab(newTab);
+ assert_folders_selected_and_displayed(selectedFolder);
+}
+
+add_task(async function middle_click_tests() {
+ select_click_folder(folderA);
+ assert_folders_selected_and_displayed(folderA);
+
+ // middle clicks while pressing shift
+ await middle_click_helper(folderA, folderA, true);
+ await middle_click_helper(folderA, folderB, true);
+
+ // middle clicks without pressing shift
+ await middle_click_helper(folderA, folderA, false);
+ await middle_click_helper(folderA, folderB, false);
+});
+
+/**
+ * Generate background and foreground tests for each middle click test.
+ *
+ * @param aTests an array of test names
+ */
+var global = this;
+function _generate_background_foreground_tests(aTests) {
+ for (let test of aTests) {
+ let helperFunc = global["_" + test + "_helper"];
+ global["test_" + test + "_background"] = async function () {
+ set_context_menu_background_tabs(true);
+ await helperFunc(true);
+ reset_context_menu_background_tabs();
+ };
+ global["test_" + test + "_foreground"] = async function () {
+ set_context_menu_background_tabs(false);
+ await helperFunc(false);
+ reset_context_menu_background_tabs();
+ };
+ add_task(global[`test_${test}_background`]).skip();
+ add_task(global[`test_${test}_foreground`]).skip();
+ }
+}
+
+_generate_background_foreground_tests([
+ "middle_click_folder_with_nothing_selected",
+ "middle_click_folder_with_one_thing_selected",
+ "middle_click_folder_with_many_things_selected",
+ "middle_click_folder_on_existing_single_selection",
+]);
+
+add_task(() => {
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_rightClickMiddleClickMessages.js b/comm/mail/test/browser/folder-display/browser_rightClickMiddleClickMessages.js
new file mode 100644
index 0000000000..50033f0e7e
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_rightClickMiddleClickMessages.js
@@ -0,0 +1,564 @@
+/* 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 the many horrors involving right-clicks, middle clicks, and selections.
+ */
+
+"use strict";
+
+var {
+ add_message_sets_to_folders,
+ assert_displayed,
+ assert_message_not_in_view,
+ assert_message_pane_focused,
+ assert_messages_not_in_view,
+ assert_nothing_selected,
+ assert_selected,
+ assert_selected_and_displayed,
+ assert_selected_tab,
+ assert_thread_tree_focused,
+ be_in_folder,
+ close_popup,
+ close_tab,
+ collapse_all_threads,
+ create_folder,
+ create_thread,
+ delete_via_popup,
+ expand_all_threads,
+ focus_thread_tree,
+ get_about_3pane,
+ make_display_threaded,
+ make_message_sets_in_folders,
+ mc,
+ middle_click_on_row,
+ reset_context_menu_background_tabs,
+ right_click_on_row,
+ select_click_row,
+ select_none,
+ select_shift_click_row,
+ set_context_menu_background_tabs,
+ switch_tab,
+ wait_for_message_display_completion,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder, threadedFolder;
+var tabmail = document.getElementById("tabmail");
+
+/**
+ * The number of messages in the thread we use to test.
+ */
+var NUM_MESSAGES_IN_THREAD = 6;
+
+add_setup(async function () {
+ folder = await create_folder("RightClickMiddleClickA");
+ threadedFolder = await create_folder("RightClickMiddleClickB");
+ // we want exactly as many messages as we plan to delete, so that we can test
+ // that the message window and tabs close when they run out of things to
+ // to display.
+ await make_message_sets_in_folders([folder], [{ count: 20 }]);
+ // Create a few messages and one thread (the order is important here, as it
+ // determines where the thread is placed. We want it placed right at the
+ // end.)
+ await make_message_sets_in_folders([threadedFolder], [{ count: 50 }]);
+ let thread = create_thread(NUM_MESSAGES_IN_THREAD);
+ await add_message_sets_to_folders([threadedFolder], [thread]);
+
+ registerCleanupFunction(function () {
+ reset_context_menu_background_tabs();
+ });
+});
+
+/**
+ * Make sure that a right-click when there is nothing currently selected does
+ * not cause us to display something, as well as correctly causing a transient
+ * selection to occur.
+ */
+add_task(async function test_right_click_with_nothing_selected() {
+ await be_in_folder(folder);
+
+ select_none();
+ assert_nothing_selected();
+
+ await right_click_on_row(1);
+ // Check that the popup opens.
+ await wait_for_popup_to_open(getMailContext());
+
+ assert_selected(1);
+ assert_displayed();
+
+ await close_popup(mc, getMailContext());
+ assert_nothing_selected();
+}).skip();
+
+/**
+ * Test that clicking on the column header shows the column picker.
+ */
+add_task(async function test_right_click_column_header_shows_col_picker() {
+ await be_in_folder(folder);
+
+ // The treecolpicker element itself doesn't have an id, so we have to walk
+ // down from the parent to find it.
+ // treadCols
+ // |- hbox item 0
+ // |- treecolpicker <-- item 1 this is the one we want
+ let threadCols = mc.window.document.getElementById("threadCols");
+ let treeColPicker = threadCols.querySelector("treecolpicker");
+ let popup = treeColPicker.querySelector("[anonid=popup]");
+
+ // Right click the subject column header
+ // This should show the column picker popup.
+ let subjectCol = mc.window.document.getElementById("subjectCol");
+ EventUtils.synthesizeMouseAtCenter(
+ subjectCol,
+ { type: "contextmenu", button: 2 },
+ subjectCol.ownerGlobal
+ );
+
+ // Check that the popup opens.
+ await wait_for_popup_to_open(popup);
+ // Hide it again, we just wanted to know it was going to be shown.
+ await close_popup(mc, popup);
+}).skip();
+
+/**
+ * One-thing selected, right-click on something else.
+ */
+add_task(async function test_right_click_with_one_thing_selected() {
+ await be_in_folder(folder);
+
+ select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ await right_click_on_row(1);
+ assert_selected(1);
+ assert_displayed(0);
+
+ await close_popup(mc, getMailContext());
+ assert_selected_and_displayed(0);
+}).skip();
+
+/**
+ * Many things selected, right-click on something that is not in that selection.
+ */
+add_task(async function test_right_click_with_many_things_selected() {
+ await be_in_folder(folder);
+
+ select_click_row(0);
+ select_shift_click_row(5);
+ assert_selected_and_displayed([0, 5]);
+
+ await right_click_on_row(6);
+ assert_selected(6);
+ assert_displayed([0, 5]);
+
+ await close_popup(mc, getMailContext());
+ assert_selected_and_displayed([0, 5]);
+}).skip();
+
+/**
+ * One thing selected, right-click on that.
+ */
+add_task(async function test_right_click_on_existing_single_selection() {
+ await be_in_folder(folder);
+
+ select_click_row(3);
+ assert_selected_and_displayed(3);
+
+ await right_click_on_row(3);
+ assert_selected_and_displayed(3);
+
+ await close_popup(mc, getMailContext());
+ assert_selected_and_displayed(3);
+});
+
+/**
+ * Many things selected, right-click somewhere in the selection.
+ */
+add_task(async function test_right_click_on_existing_multi_selection() {
+ await be_in_folder(folder);
+
+ select_click_row(3);
+ select_shift_click_row(6);
+ assert_selected_and_displayed([3, 6]);
+
+ await right_click_on_row(5);
+ assert_selected_and_displayed([3, 6]);
+
+ await close_popup(mc, getMailContext());
+ assert_selected_and_displayed([3, 6]);
+});
+
+/**
+ * Middle clicking should open a message in a tab, but not affect our selection.
+ */
+async function _middle_click_with_nothing_selected_helper(aBackground) {
+ await be_in_folder(folder);
+
+ select_none();
+ assert_nothing_selected();
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ // Focus the thread tree -- we're going to make sure it's focused when we
+ // come back
+ focus_thread_tree();
+ let [tabMessage, curMessage] = middle_click_on_row(1);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(folderTab);
+ // Now switch to the new tab and check
+ await switch_tab(tabMessage);
+ } else {
+ wait_for_message_display_completion();
+ }
+
+ assert_selected_and_displayed(curMessage);
+ assert_message_pane_focused();
+ close_tab(tabMessage);
+
+ assert_nothing_selected();
+ assert_thread_tree_focused();
+}
+
+add_task(async function test_middle_click_with_nothing_selected_fg() {
+ set_context_menu_background_tabs(false);
+ await _middle_click_with_nothing_selected_helper(false);
+});
+
+add_task(async function test_middle_click_with_nothing_selected_bg() {
+ set_context_menu_background_tabs(true);
+ await _middle_click_with_nothing_selected_helper(true);
+});
+
+/**
+ * One-thing selected, middle-click on something else.
+ */
+async function _middle_click_with_one_thing_selected_helper(aBackground) {
+ await be_in_folder(folder);
+
+ select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let [tabMessage, curMessage] = middle_click_on_row(1);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(folderTab);
+ // Now switch to the new tab and check
+ await switch_tab(tabMessage);
+ } else {
+ wait_for_message_display_completion();
+ }
+
+ assert_selected_and_displayed(curMessage);
+ assert_message_pane_focused();
+ close_tab(tabMessage);
+
+ assert_selected_and_displayed(0);
+ assert_thread_tree_focused();
+}
+
+add_task(async function test_middle_click_with_one_thing_selected_fg() {
+ set_context_menu_background_tabs(false);
+ await _middle_click_with_one_thing_selected_helper(false);
+});
+
+add_task(async function test_middle_click_with_one_thing_selected_bg() {
+ set_context_menu_background_tabs(true);
+ await _middle_click_with_one_thing_selected_helper(true);
+});
+
+/**
+ * Many things selected, middle-click on something that is not in that
+ * selection.
+ */
+async function _middle_click_with_many_things_selected_helper(aBackground) {
+ await be_in_folder(folder);
+
+ select_click_row(0);
+ select_shift_click_row(5);
+ assert_selected_and_displayed([0, 5]);
+
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let [tabMessage] = middle_click_on_row(1);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(folderTab);
+ // Now switch to the new tab and check
+ await switch_tab(tabMessage);
+ } else {
+ wait_for_message_display_completion();
+ }
+
+ assert_message_pane_focused();
+ tabmail.closeOtherTabs(0);
+
+ assert_selected_and_displayed([0, 5]);
+ assert_thread_tree_focused();
+}
+
+add_task(async function test_middle_click_with_many_things_selected_fg() {
+ set_context_menu_background_tabs(false);
+ await _middle_click_with_many_things_selected_helper(false);
+});
+
+add_task(async function test_middle_click_with_many_things_selected_bg() {
+ set_context_menu_background_tabs(true);
+ await _middle_click_with_many_things_selected_helper(true);
+});
+
+/**
+ * One thing selected, middle-click on that.
+ */
+async function _middle_click_on_existing_single_selection_helper(aBackground) {
+ await be_in_folder(folder);
+
+ select_click_row(3);
+ assert_selected_and_displayed(3);
+
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let [tabMessage, curMessage] = middle_click_on_row(3);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(folderTab);
+ // Now switch to the new tab and check
+ await switch_tab(tabMessage);
+ } else {
+ wait_for_message_display_completion();
+ }
+
+ assert_selected_and_displayed(curMessage);
+ assert_message_pane_focused();
+ close_tab(tabMessage);
+
+ assert_selected_and_displayed(3);
+ assert_thread_tree_focused();
+}
+
+add_task(async function test_middle_click_on_existing_single_selection_fg() {
+ set_context_menu_background_tabs(false);
+ await _middle_click_on_existing_single_selection_helper(false);
+});
+
+add_task(async function test_middle_click_on_existing_single_selection_bg() {
+ set_context_menu_background_tabs(true);
+ await _middle_click_on_existing_single_selection_helper(true);
+});
+
+/**
+ * Many things selected, middle-click somewhere in the selection.
+ */
+async function _middle_click_on_existing_multi_selection_helper(aBackground) {
+ await be_in_folder(folder);
+
+ select_click_row(3);
+ select_shift_click_row(6);
+ assert_selected_and_displayed([3, 6]);
+
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let [tabMessage, curMessage] = middle_click_on_row(6);
+ if (aBackground) {
+ // Make sure we haven't switched to the new tab.
+ assert_selected_tab(folderTab);
+ // Now switch to the new tab and check
+ await switch_tab(tabMessage);
+ } else {
+ wait_for_message_display_completion();
+ }
+
+ assert_selected_and_displayed(curMessage);
+ assert_message_pane_focused();
+ tabmail.closeOtherTabs(0);
+
+ assert_selected_and_displayed([3, 6]);
+ assert_thread_tree_focused();
+}
+
+add_task(async function test_middle_click_on_existing_multi_selection_fg() {
+ set_context_menu_background_tabs(false);
+ await _middle_click_on_existing_multi_selection_helper(false);
+});
+
+add_task(async function test_middle_click_on_existing_multi_selection_bg() {
+ set_context_menu_background_tabs(true);
+ await _middle_click_on_existing_multi_selection_helper(true);
+});
+
+/**
+ * Middle-click on the root of a collapsed thread, making sure that we don't
+ * jump around in the thread tree.
+ */
+async function _middle_click_on_collapsed_thread_root_helper(aBackground) {
+ await be_in_folder(threadedFolder);
+ make_display_threaded();
+ collapse_all_threads();
+
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ let tree = get_about_3pane().threadTree;
+ // Note the first visible row
+ let preFirstRow = tree.getFirstVisibleIndex();
+
+ // Since reflowing a tree (eg when switching tabs) ensures that the current
+ // index is brought into view, we need to set the current index so that we
+ // don't scroll because of it. So click on the first visible row.
+ select_click_row(preFirstRow);
+
+ if (!aBackground) {
+ wait_for_message_display_completion();
+ // Switch back to the folder tab
+ await switch_tab(folderTab);
+ }
+ if (tree.getFirstVisibleIndex() != preFirstRow) {
+ throw new Error(
+ "The first visible row should have been " +
+ preFirstRow +
+ ", but is actually " +
+ tree.getFirstVisibleIndex() +
+ "."
+ );
+ }
+ tabmail.closeOtherTabs(0);
+}
+
+add_task(async function test_middle_click_on_collapsed_thread_root_fg() {
+ set_context_menu_background_tabs(false);
+ await _middle_click_on_collapsed_thread_root_helper(false);
+});
+
+add_task(async function test_middle_click_on_collapsed_thread_root_bg() {
+ set_context_menu_background_tabs(true);
+ await _middle_click_on_collapsed_thread_root_helper(true);
+});
+
+/**
+ * Middle-click on the root of an expanded thread, making sure that we don't
+ * jump around in the thread tree.
+ */
+async function _middle_click_on_expanded_thread_root_helper(aBackground) {
+ await be_in_folder(threadedFolder);
+ make_display_threaded();
+ expand_all_threads();
+
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ let tree = get_about_3pane().threadTree;
+ // Note the first visible row
+ let preFirstRow = tree.getFirstVisibleIndex();
+
+ // Since reflowing a tree (eg when switching tabs) ensures that the current
+ // index is brought into view, we need to set the current index so that we
+ // don't scroll because of it. So click on the first visible row.
+ select_click_row(preFirstRow);
+
+ // Middle-click on the root of the expanded thread, which is the row with
+ // index (number of rows - number of messages in thread).
+ let [tabMessage] = middle_click_on_row(
+ tree.view.rowCount - NUM_MESSAGES_IN_THREAD
+ );
+
+ if (!aBackground) {
+ wait_for_message_display_completion();
+ // Switch back to the folder tab
+ await switch_tab(folderTab);
+ }
+
+ // Make sure the first visible row is still the same
+ if (tree.getFirstVisibleIndex() != preFirstRow) {
+ throw new Error(
+ "The first visible row should have been " +
+ preFirstRow +
+ ", but is actually " +
+ tree.getFirstVisibleIndex() +
+ "."
+ );
+ }
+
+ close_tab(tabMessage);
+}
+
+add_task(async function test_middle_click_on_expanded_thread_root_fg() {
+ set_context_menu_background_tabs(false);
+ await _middle_click_on_expanded_thread_root_helper(false);
+});
+
+add_task(async function test_middle_click_on_expanded_thread_root_bg() {
+ set_context_menu_background_tabs(true);
+ await _middle_click_on_expanded_thread_root_helper(true);
+});
+
+/**
+ * Right-click on something and delete it, having no selection previously.
+ */
+add_task(async function test_right_click_deletion_nothing_selected() {
+ await be_in_folder(folder);
+
+ select_none();
+ assert_selected_and_displayed();
+
+ let delMessage = await right_click_on_row(3);
+ await delete_via_popup();
+ // eh, might as well make sure the deletion worked while we are here
+ assert_message_not_in_view(delMessage);
+
+ assert_selected_and_displayed();
+}).skip();
+
+/**
+ * We want to make sure that the selection post-delete still includes the same
+ * message (and that it is displayed). In order for this to be interesting,
+ * we want to make sure that we right-click delete a message above the selected
+ * message so there is a shift in row numbering.
+ */
+add_task(async function test_right_click_deletion_one_other_thing_selected() {
+ await be_in_folder(folder);
+
+ let curMessage = select_click_row(5);
+
+ let delMessage = await right_click_on_row(3);
+ await delete_via_popup();
+ assert_message_not_in_view(delMessage);
+
+ assert_selected_and_displayed(curMessage);
+}).skip();
+
+add_task(async function test_right_click_deletion_many_other_things_selected() {
+ await be_in_folder(folder);
+
+ select_click_row(4);
+ let messages = select_shift_click_row(6);
+
+ let delMessage = await right_click_on_row(2);
+ await delete_via_popup();
+ assert_message_not_in_view(delMessage);
+
+ assert_selected_and_displayed(messages);
+}).skip();
+
+add_task(async function test_right_click_deletion_of_one_selected_thing() {
+ await be_in_folder(folder);
+
+ let curMessage = select_click_row(2);
+
+ await right_click_on_row(2);
+ await delete_via_popup();
+ assert_message_not_in_view(curMessage);
+
+ // Assert.notEqual(mc.window.document.getElementById("tabmail").currentTabInfo.browser.contentWindow.gDBView.selection.count, 0, "We should have tried to select something!");
+});
+
+add_task(async function test_right_click_deletion_of_many_selected_things() {
+ await be_in_folder(folder);
+
+ select_click_row(2);
+ let messages = select_shift_click_row(4);
+
+ await right_click_on_row(3);
+ await delete_via_popup();
+ assert_messages_not_in_view(messages);
+
+ // Assert.notEqual(mc.window.document.getElementById("tabmail").currentTabInfo.browser.contentWindow.gDBView.selection.count, 0, "We should have tried to select something!");
+});
diff --git a/comm/mail/test/browser/folder-display/browser_savedsearchReloadAfterCompact.js b/comm/mail/test/browser/folder-display/browser_savedsearchReloadAfterCompact.js
new file mode 100644
index 0000000000..262e4b3d94
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_savedsearchReloadAfterCompact.js
@@ -0,0 +1,105 @@
+/* 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 reload of saved searches over local folders after compaction
+ * of local folders.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { gThreadManager } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+);
+
+var {
+ be_in_folder,
+ create_folder,
+ create_virtual_folder,
+ get_about_3pane,
+ inboxFolder,
+ make_message_sets_in_folders,
+ mc,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var otherFolder;
+var folderVirtual;
+var synSets;
+
+/**
+ * Add some messages to a folder, delete the first one, and create a saved
+ * search over the inbox and the folder. Then, compact folders.
+ */
+add_task(async function test_setup_virtual_folder_and_compact() {
+ otherFolder = await create_folder();
+ synSets = await make_message_sets_in_folders([otherFolder], [{ count: 2 }]);
+
+ /**
+ * We delete the first message in the local folder, so compaction of the
+ * folder will invalidate the key of the second message in the folder. Then,
+ * we select the second message and issue the compact. This causes saving the
+ * selection on the compaction notification to fail. We test the saved search
+ * view still gets rebuilt, such that there is a valid msg hdr at row 0.
+ */
+ await be_in_folder(otherFolder);
+ select_click_row(0);
+ press_delete();
+
+ folderVirtual = create_virtual_folder(
+ [inboxFolder, otherFolder],
+ {},
+ true,
+ "SavedSearch"
+ );
+
+ await be_in_folder(folderVirtual);
+ select_click_row(0);
+ let urlListener = {
+ compactDone: false,
+
+ OnStartRunningUrl(aUrl) {},
+ OnStopRunningUrl(aUrl, aExitCode) {
+ this.compactDone = true;
+ },
+ };
+ if (otherFolder.msgStore.supportsCompaction) {
+ otherFolder.compactAll(urlListener, null);
+
+ utils.waitFor(
+ () => urlListener.compactDone,
+ "Timeout waiting for compact to complete",
+ 10000,
+ 100
+ );
+ }
+ // Let the event queue clear.
+ await new Promise(resolve => setTimeout(resolve));
+ // Check view is still valid
+ get_about_3pane().gDBView.getMsgHdrAt(0);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
+
+add_task(async function endTest() {
+ // Fixing possible nsIMsgDBHdr.markHasAttachments onEndMsgDownload runs.
+ // Found in chaosmode.
+ var thread = gThreadManager.currentThread;
+ while (thread.hasPendingEvents()) {
+ thread.processNextEvent(true);
+ }
+ // Cleanup dbView with force.
+ get_about_3pane().gDBView.close(true);
+ folderVirtual.deleteSelf(null);
+ otherFolder.deleteSelf(null);
+});
diff --git a/comm/mail/test/browser/folder-display/browser_selection.js b/comm/mail/test/browser/folder-display/browser_selection.js
new file mode 100644
index 0000000000..09885f1f92
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_selection.js
@@ -0,0 +1,202 @@
+/* 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 {
+ assert_nothing_selected,
+ assert_selected_and_displayed,
+ assert_visible,
+ be_in_folder,
+ close_tab,
+ create_folder,
+ delete_via_popup,
+ enter_folder,
+ get_about_3pane,
+ make_display_grouped,
+ make_display_threaded,
+ make_display_unthreaded,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_tab,
+ press_delete,
+ right_click_on_row,
+ select_click_row,
+ select_column_click_row,
+ select_control_click_row,
+ select_none,
+ select_shift_click_row,
+ switch_tab,
+ wait_for_blank_content_pane,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+// let us have 2 folders
+var folder = null,
+ folder2 = null;
+
+add_setup(async function () {
+ folder = await create_folder("SelectionA");
+ folder2 = await create_folder("SelectionB");
+ await make_message_sets_in_folders([folder, folder2], [{ count: 50 }]);
+});
+
+// https://bugzilla.mozilla.org/show_bug.cgi?id=474701#c80
+add_task(async function test_selection_on_entry() {
+ await enter_folder(folder);
+ assert_nothing_selected();
+});
+
+add_task(async function test_selection_extension() {
+ await be_in_folder(folder);
+
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=474701#c79 (was good)
+ select_click_row(1);
+ select_control_click_row(2);
+ press_delete();
+ assert_selected_and_displayed(1);
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=474701#c79 (was bad)
+ select_click_row(2);
+ select_control_click_row(1);
+ press_delete();
+ assert_selected_and_displayed(1);
+
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=474701#c87 first bit
+ press_delete();
+ assert_selected_and_displayed(1);
+});
+
+add_task(async function test_selection_select_column() {
+ await be_in_folder(folder);
+ mc.window.document.getElementById("selectCol").removeAttribute("hidden");
+ select_none();
+ select_column_click_row(0);
+ assert_selected_and_displayed(0);
+ select_column_click_row(0);
+ assert_nothing_selected();
+ select_column_click_row(2);
+ select_column_click_row(3);
+ select_column_click_row(4);
+ // This only takes a range.
+ assert_selected_and_displayed([2, 4]); // ensures multi-message summary
+ select_column_click_row(2);
+ assert_selected_and_displayed([3, 4]); // ensures multi-message summary
+ select_column_click_row(3);
+ assert_selected_and_displayed(4);
+ select_column_click_row(4);
+ assert_nothing_selected();
+}).skip();
+
+add_task(async function test_selection_select_column_deselection() {
+ await be_in_folder(folder);
+ select_none();
+ select_column_click_row(3);
+ select_column_click_row(3);
+ assert_nothing_selected();
+ await right_click_on_row(7);
+ await delete_via_popup();
+ assert_nothing_selected();
+ mc.window.document.getElementById("selectCol").setAttribute("hidden", true);
+}).skip();
+
+add_task(async function test_selection_last_message_deleted() {
+ await be_in_folder(folder);
+ select_click_row(-1);
+ press_delete();
+ assert_selected_and_displayed(-1);
+});
+
+add_task(async function test_selection_persists_through_threading_changes() {
+ await be_in_folder(folder);
+
+ make_display_unthreaded();
+ let message = select_click_row(3);
+ make_display_threaded();
+ assert_selected_and_displayed(message);
+ make_display_grouped();
+ assert_selected_and_displayed(message);
+});
+
+// https://bugzilla.mozilla.org/show_bug.cgi?id=474701#c82 2nd half
+add_task(async function test_no_selection_persists_through_threading_changes() {
+ await be_in_folder(folder);
+
+ make_display_unthreaded();
+ select_none();
+ make_display_threaded();
+ assert_nothing_selected();
+ make_display_grouped();
+ assert_nothing_selected();
+ make_display_unthreaded();
+});
+
+add_task(async function test_selection_persists_through_folder_tab_changes() {
+ let tab1 = await be_in_folder(folder);
+
+ select_click_row(2);
+
+ let tab2 = await open_folder_in_new_tab(folder2);
+ wait_for_blank_content_pane();
+ assert_nothing_selected();
+
+ await switch_tab(tab1);
+ assert_selected_and_displayed(2);
+
+ await switch_tab(tab2);
+ assert_nothing_selected();
+ select_click_row(3);
+
+ await switch_tab(tab1);
+ assert_selected_and_displayed(2);
+ select_shift_click_row(4); // 2-4 selected
+ assert_selected_and_displayed([2, 4]); // ensures multi-message summary
+
+ await switch_tab(tab2);
+ assert_selected_and_displayed(3);
+
+ close_tab(tab2);
+ assert_selected_and_displayed([2, 4]);
+});
+
+// https://bugzilla.mozilla.org/show_bug.cgi?id=1850190
+/**
+ * Verify that we scroll to new messages when we enter a folder.
+ */
+add_task(async function test_enter_scroll_to_new() {
+ // This should be the default anyway:
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true);
+ await be_in_folder(folder);
+ get_about_3pane().sortController.sortAscending();
+ await select_click_row(1);
+ await enter_folder(folder2);
+ // When a folder is switched to, a new message should be visible and
+ // selections are not restored:
+ await make_message_sets_in_folders([folder], [{ count: 1 }]);
+ await enter_folder(folder);
+ await assert_visible(-1);
+ await assert_nothing_selected();
+});
+
+/**
+ * Test that the last selected message persists through folder changes.
+ */
+add_task(async function test_selection_persists_through_folder_changes() {
+ // be in the folder
+ await be_in_folder(folder);
+ // select a message
+ select_click_row(3);
+ // leave and re-enter the folder
+ await enter_folder(folder.rootFolder);
+ await enter_folder(folder);
+ // make sure it is selected and displayed
+ assert_selected_and_displayed(3);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_summarization.js b/comm/mail/test/browser/folder-display/browser_summarization.js
new file mode 100644
index 0000000000..6861a7e605
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_summarization.js
@@ -0,0 +1,462 @@
+/* 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 that summarization happens at the right time, that it clears itself at
+ * the right time, that it waits for selection stability when recently
+ * summarized, and that summarization does not break under tabbing.
+ *
+ * Because most of the legwork is done automatically by
+ * test-folder-display-helpers, the more basic tests may look like general
+ * selection / tabbing tests, but are intended to specifically exercise the
+ * summarization logic and edge cases. (Although general selection tests and
+ * tab tests may do the same thing too...)
+ *
+ * Things we don't test but should:
+ * - The difference between thread summary and multi-message summary.
+ */
+
+"use strict";
+
+var { ensure_card_exists, ensure_no_card_exists } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AddressBookHelpers.jsm"
+);
+var {
+ add_message_sets_to_folders,
+ assert_collapsed,
+ assert_expanded,
+ assert_messages_summarized,
+ assert_message_not_in_view,
+ assert_nothing_selected,
+ assert_selected,
+ assert_selected_and_displayed,
+ assert_summary_contains_N_elts,
+ be_in_folder,
+ close_tab,
+ collapse_all_threads,
+ create_folder,
+ create_thread,
+ create_virtual_folder,
+ make_display_threaded,
+ make_display_unthreaded,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_tab,
+ open_selected_message_in_new_tab,
+ plan_to_wait_for_folder_events,
+ select_click_row,
+ select_control_click_row,
+ select_none,
+ select_shift_click_row,
+ switch_tab,
+ toggle_thread_row,
+ wait_for_blank_content_pane,
+ wait_for_folder_events,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folder;
+var thread1, thread2, msg1, msg2;
+
+add_setup(async function () {
+ // Make sure the whole test starts with an unthreaded view in all folders.
+ Services.prefs.setIntPref("mailnews.default_view_flags", 0);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("mailnews.default_view_flags");
+ });
+
+ folder = await create_folder("SummarizationA");
+ thread1 = create_thread(10);
+ msg1 = create_thread(1);
+ thread2 = create_thread(10);
+ msg2 = create_thread(1);
+ await add_message_sets_to_folders([folder], [thread1, msg1, thread2, msg2]);
+});
+
+add_task(async function test_basic_summarization() {
+ await be_in_folder(folder);
+
+ // - make sure we get a summary
+ select_click_row(0);
+ select_shift_click_row(5);
+ // this will verify a multi-message display is happening
+ assert_selected_and_displayed([0, 5]);
+});
+
+add_task(function test_summarization_goes_away() {
+ select_none();
+ assert_nothing_selected();
+});
+
+/**
+ * Verify that we update summarization when switching amongst tabs.
+ */
+add_task(async function test_folder_tabs_update_correctly() {
+ // tab with summary
+ let tabA = await be_in_folder(folder);
+ select_click_row(0);
+ select_control_click_row(2);
+ assert_selected_and_displayed(0, 2);
+
+ // tab with nothing
+ let tabB = await open_folder_in_new_tab(folder);
+ wait_for_blank_content_pane();
+ assert_nothing_selected();
+
+ // correct changes, none <=> summary
+ await switch_tab(tabA);
+ assert_selected_and_displayed(0, 2);
+ await switch_tab(tabB);
+ assert_nothing_selected();
+
+ // correct changes, one <=> summary
+ select_click_row(0);
+ assert_selected_and_displayed(0);
+ await switch_tab(tabA);
+ assert_selected_and_displayed(0, 2);
+ await switch_tab(tabB);
+ assert_selected_and_displayed(0);
+
+ // correct changes, summary <=> summary
+ select_shift_click_row(3);
+ assert_selected_and_displayed([0, 3]);
+ await switch_tab(tabA);
+ assert_selected_and_displayed(0, 2);
+ await switch_tab(tabB);
+ assert_selected_and_displayed([0, 3]);
+
+ // closing tab returns state correctly...
+ close_tab(tabB);
+ assert_selected_and_displayed(0, 2);
+});
+
+add_task(async function test_message_tabs_update_correctly() {
+ let tabFolder = await be_in_folder(folder);
+ let message = select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ let tabMessage = await open_selected_message_in_new_tab();
+ assert_selected_and_displayed(message);
+
+ await switch_tab(tabFolder);
+ select_shift_click_row(2);
+ assert_selected_and_displayed([0, 2]);
+
+ await switch_tab(tabMessage);
+ assert_selected_and_displayed(message);
+
+ await switch_tab(tabFolder);
+ assert_selected_and_displayed([0, 2]);
+
+ close_tab(tabMessage);
+});
+
+/**
+ * Test the stabilization logic by making the stabilization interval absurd and
+ * then manually clearing things up.
+ */
+add_task(async function test_selection_stabilization_logic() {
+ // make sure all summarization has run to completion.
+ await new Promise(resolve => setTimeout(resolve));
+ // does not summarize anything, does not affect timer
+ select_click_row(0);
+ // does summarize things. timer will be tick tick ticking!
+ select_shift_click_row(1);
+ // verify that things were summarized...
+ assert_selected_and_displayed([0, 1]);
+ // save the set of messages so we can verify the summary sticks to this.
+ let messages = mc.window.gFolderDisplay.selectedMessages;
+
+ // make sure the
+
+ // this will not summarize!
+ select_shift_click_row(2, mc, true);
+ // verify that our summary is still just 0 and 1.
+ assert_messages_summarized(mc, messages);
+
+ // - pretend the timer fired.
+ // we need to de-schedule the timer, but do not need to clear the variable
+ // because it will just get overwritten anyways
+ mc.window.clearTimeout(mc.messageDisplay._summaryStabilityTimeout);
+ mc.messageDisplay._showSummary(true);
+
+ // - the summary should now be up-to-date
+ assert_selected_and_displayed([0, 2]);
+});
+
+add_task(function test_summarization_thread_detection() {
+ select_none();
+ assert_nothing_selected();
+ make_display_threaded();
+ select_click_row(0);
+ select_shift_click_row(9);
+ let messages = mc.window.gFolderDisplay.selectedMessages;
+ toggle_thread_row(0);
+ assert_messages_summarized(mc, messages);
+ // count the number of messages represented
+ assert_summary_contains_N_elts("#message_list > li", 10);
+ select_shift_click_row(1);
+ // this should have shifted to the multi-message view
+ assert_summary_contains_N_elts(".item_header > .date", 0);
+ assert_summary_contains_N_elts(".item_header > .subject", 2);
+ select_none();
+ assert_nothing_selected();
+ select_click_row(1); // select a single message
+ select_shift_click_row(2); // add a thread
+ assert_summary_contains_N_elts(".item_header > .date", 0);
+ assert_summary_contains_N_elts(".item_header > .subject", 2);
+});
+
+/**
+ * If you are looking at a message that becomes part of a thread because of the
+ * arrival of a new message, expand the thread so you do not have the message
+ * turn into a summary beneath your feet.
+ *
+ * There are really two cases here:
+ * - The thread gets moved because its sorted position changes.
+ * - The thread does not move.
+ */
+add_task(async function test_new_thread_that_was_not_summarized_expands() {
+ await be_in_folder(folder);
+ make_display_threaded();
+ // - create the base messages
+ let [willMoveMsg, willNotMoveMsg] = await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1 }, { count: 1 }]
+ );
+
+ // - do the non-move case
+ // XXX actually, this still gets treated as a move. I don't know why...
+ // select it
+ select_click_row(willNotMoveMsg);
+ assert_selected_and_displayed(willNotMoveMsg);
+
+ // give it a friend...
+ await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1, inReplyTo: willNotMoveMsg }]
+ );
+ assert_expanded(willNotMoveMsg);
+ assert_selected_and_displayed(willNotMoveMsg);
+
+ // - do the move case
+ select_click_row(willMoveMsg);
+ assert_selected_and_displayed(willMoveMsg);
+
+ // give it a friend...
+ await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1, inReplyTo: willMoveMsg }]
+ );
+ assert_expanded(willMoveMsg);
+ assert_selected_and_displayed(willMoveMsg);
+});
+
+/**
+ * Selecting an existing (and collapsed) thread, then add a message and make
+ * sure the summary updates.
+ */
+add_task(
+ async function test_summary_updates_when_new_message_added_to_collapsed_thread() {
+ await be_in_folder(folder);
+ make_display_threaded();
+ collapse_all_threads();
+
+ // - select the thread root, thereby summarizing it
+ let thread1Root = select_click_row(thread1); // this just uses the root msg
+ assert_collapsed(thread1Root);
+ // just the thread root should be selected
+ assert_selected(thread1Root);
+ // but the whole thread should be summarized
+ assert_messages_summarized(mc, thread1);
+
+ // - add a new message, make sure it's in the summary now.
+ let [thread1Extra] = await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1, inReplyTo: thread1 }]
+ );
+ let thread1All = thread1.union(thread1Extra);
+ assert_selected(thread1Root);
+ assert_messages_summarized(mc, thread1All);
+ }
+);
+
+add_task(async function test_summary_when_multiple_identities() {
+ // First half of the test, makes sure messageDisplay.js understands there's
+ // only one thread
+ let folder1 = await create_folder("Search1");
+ await be_in_folder(folder1);
+ let thread1 = create_thread(1);
+ await add_message_sets_to_folders([folder1], [thread1]);
+
+ let folder2 = await create_folder("Search2");
+ await be_in_folder(folder2);
+ await make_message_sets_in_folders(
+ [folder2],
+ [{ count: 1, inReplyTo: thread1 }]
+ );
+
+ let folderVirtual = create_virtual_folder(
+ [folder1, folder2],
+ {},
+ true,
+ "SearchBoth"
+ );
+
+ // Do the needed tricks
+ await be_in_folder(folder1);
+ select_click_row(0);
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+ mc.window.MsgMoveMessage(folder2);
+ wait_for_folder_events();
+
+ await be_in_folder(folder2);
+ select_click_row(1);
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+ mc.window.MsgMoveMessage(folder1);
+ wait_for_folder_events();
+
+ await be_in_folder(folderVirtual);
+ make_display_threaded();
+ collapse_all_threads();
+
+ // Assertions
+ select_click_row(0);
+ assert_messages_summarized(mc, mc.window.gFolderDisplay.selectedMessages);
+ // Thread summary shows a date, while multimessage summary shows a subject.
+ assert_summary_contains_N_elts(".item_header > .subject", 0);
+ assert_summary_contains_N_elts(".item_header > .date", 2);
+
+ // Second half of the test, makes sure MultiMessageSummary groups messages
+ // according to their view thread id
+ thread1 = create_thread(1);
+ await add_message_sets_to_folders([folder1], [thread1]);
+ await be_in_folder(folderVirtual);
+ select_shift_click_row(1);
+
+ assert_summary_contains_N_elts(".item_header > .subject", 2);
+});
+
+function extract_first_address(thread) {
+ let addresses = MailServices.headerParser.parseEncodedHeader(
+ thread1.getMsgHdr(0).mime2DecodedAuthor
+ );
+ return addresses[0];
+}
+
+function check_address_name(name) {
+ let htmlframe = mc.window.document.getElementById("multimessage");
+ let match = htmlframe.contentDocument.querySelector(".author");
+ if (match.textContent != name) {
+ throw new Error(
+ "Expected to find sender named '" +
+ name +
+ "', found '" +
+ match.textContent +
+ "'"
+ );
+ }
+}
+
+add_task(async function test_display_name_no_abook() {
+ await be_in_folder(folder);
+
+ let address = extract_first_address(thread1);
+ ensure_no_card_exists(address.email);
+
+ collapse_all_threads();
+ select_click_row(thread1);
+
+ // No address book entry, we display name and e-mail address.
+ check_address_name(address.name + " <" + address.email + ">");
+});
+
+add_task(async function test_display_name_abook() {
+ await be_in_folder(folder);
+
+ let address = extract_first_address(thread1);
+ ensure_card_exists(address.email, "My Friend", true);
+
+ collapse_all_threads();
+ select_click_row(thread1);
+
+ check_address_name("My Friend");
+});
+
+add_task(async function test_display_name_abook_no_pdn() {
+ await be_in_folder(folder);
+
+ let address = extract_first_address(thread1);
+ ensure_card_exists(address.email, "My Friend", false);
+
+ collapse_all_threads();
+ select_click_row(thread1);
+
+ // With address book entry but display name not preferred, we display name and
+ // e-mail address.
+ check_address_name(address.name + " <" + address.email + ">");
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
+
+add_task(async function test_archive_and_delete_messages() {
+ await be_in_folder(folder);
+ select_none();
+ assert_nothing_selected();
+ make_display_unthreaded();
+ select_click_row(0);
+ select_shift_click_row(2);
+ let messages = mc.window.gFolderDisplay.selectedMessages;
+
+ let contentWindow =
+ mc.window.document.getElementById("multimessage").contentWindow;
+ // Archive selected messages.
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ contentWindow.document.getElementById("hdrArchiveButton"),
+ {},
+ contentWindow
+ );
+
+ wait_for_folder_events();
+ assert_message_not_in_view(messages);
+
+ select_none();
+ assert_nothing_selected();
+ select_click_row(0);
+ select_shift_click_row(2);
+ messages = mc.window.gFolderDisplay.selectedMessages;
+
+ // Delete selected messages.
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ contentWindow.document.getElementById("hdrTrashButton"),
+ {},
+ contentWindow
+ );
+ wait_for_folder_events();
+ assert_message_not_in_view(messages);
+});
diff --git a/comm/mail/test/browser/folder-display/browser_syntheticViews.js b/comm/mail/test/browser/folder-display/browser_syntheticViews.js
new file mode 100644
index 0000000000..4e114531cd
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_syntheticViews.js
@@ -0,0 +1,292 @@
+/* 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 {
+ add_message_sets_to_folders,
+ be_in_folder,
+ create_folder,
+ create_thread,
+ delete_messages,
+ inboxFolder,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { SyntheticPartLeaf } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const { GlodaMsgIndexer } = ChromeUtils.import(
+ "resource:///modules/gloda/IndexMsg.jsm"
+);
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+ Services.prefs.setBoolPref("mailnews.start_page.enabled", false);
+ Services.prefs.setIntPref("mailnews.default_view_flags", 0);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("mailnews.mark_message_read.auto");
+ Services.prefs.clearUserPref("mailnews.start_page.enabled");
+ Services.prefs.clearUserPref("mailnews.default_view_flags");
+ });
+});
+
+function createThreadWithTerm(msgCount, term) {
+ let thread = create_thread(msgCount);
+ for (let msg of thread.synMessages) {
+ msg.bodyPart = new SyntheticPartLeaf(term);
+ }
+ return thread;
+}
+
+async function waitForThreadIndexed(thread) {
+ let dbView = window.gFolderDisplay.view.dbView;
+ await TestUtils.waitForCondition(
+ () =>
+ thread.synMessages.every((_, i) =>
+ window.Gloda.isMessageIndexed(dbView.getMsgHdrAt(i))
+ ),
+ "Messages were not indexed in time"
+ );
+}
+
+function doGlobalSearch(term) {
+ let searchInput = window.document.querySelector("#searchInput");
+ searchInput.value = term;
+ EventUtils.synthesizeMouseAtCenter(searchInput, {}, window);
+ EventUtils.synthesizeKey("VK_RETURN", {}, window);
+}
+
+async function clickShowResultsAsList(tab) {
+ let iframe = tab.querySelector("iframe");
+ await BrowserTestUtils.waitForEvent(iframe.contentWindow, "load");
+
+ let browser = iframe.contentDocument.querySelector("browser");
+ await TestUtils.waitForCondition(
+ () =>
+ browser.contentWindow.FacetContext &&
+ browser.contentWindow.FacetContext.rootWin != null,
+ "reachOutAndTouchFrame() did not run in time"
+ );
+
+ let anchor = browser.contentDocument.querySelector("#gloda-showall");
+ anchor.click();
+}
+
+async function clickMarkRead(row, col) {
+ await openContextMenu(row, col);
+ await clickSubMenuItem("#mailContext-mark", "#mailContext-markRead");
+}
+
+async function clickMarkThreadAsRead(row, col) {
+ await openContextMenu(row, col);
+ await clickSubMenuItem("#mailContext-mark", "#mailContext-markThreadAsRead");
+}
+
+async function clickSubMenuItem(menuId, itemId) {
+ let menu = window.document.querySelector(menuId);
+ let item = menu.querySelector(itemId);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ menu.openMenu(true);
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.menupopup.activateItem(item);
+ await hiddenPromise;
+}
+
+async function openConversationView(row, col) {
+ let menu = window.document.querySelector("#mailContext");
+ let item = window.document.querySelector("#mailContext-openConversation");
+ let prevTab = window.document.getElementById("tabmail").selectedTab;
+
+ let loadedPromise = BrowserTestUtils.waitForEvent(window, "MsgsLoaded");
+ await openContextMenu(row, col);
+ menu.activateItem(item);
+ await loadedPromise;
+ await TestUtils.waitForCondition(
+ () => window.document.getElementById("tabmail").selectedTab != prevTab,
+ "Conversation View tab did not open"
+ );
+}
+
+async function openContextMenu(row, column) {
+ let menu = window.document.getElementById("mailContext");
+ let tree = window.document.getElementById("threadTree");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ mailTestUtils.treeClick(EventUtils, window, tree, row, column, {});
+ mailTestUtils.treeClick(EventUtils, window, tree, row, column, {
+ type: "contextmenu",
+ });
+ await shownPromise;
+}
+
+function closeTabs() {
+ let tabmail = document.querySelector("tabmail");
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(1);
+ }
+}
+
+/**
+ * Test we can mark a message as read in the list view version of the global
+ * search results.
+ */
+add_task(async function testListViewMarkRead() {
+ let folder = await create_folder("ListViewMarkReadFolder");
+ let term = "listviewmarkread";
+ let thread = createThreadWithTerm(2, term);
+
+ registerCleanupFunction(async () => {
+ await be_in_folder(inboxFolder);
+ await delete_messages(thread);
+
+ let trash = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ folder.deleteSelf(null);
+ trash.emptyTrash(null);
+ });
+
+ await be_in_folder(folder);
+ await add_message_sets_to_folders([folder], [thread]);
+
+ await new Promise(callback => {
+ GlodaMsgIndexer.indexFolder(folder, { callback, force: true });
+ });
+
+ await waitForThreadIndexed(thread);
+ doGlobalSearch(term);
+
+ let tab = document.querySelector(
+ "tabmail>tabbox>tabpanels>vbox[selected=true]"
+ );
+ await clickShowResultsAsList(tab);
+ await clickMarkRead(0, 4);
+
+ let dbView = window.gFolderDisplay.view.dbView;
+ Assert.ok(dbView.getMsgHdrAt(0).isRead, "Message 0 is read");
+ Assert.ok(!dbView.getMsgHdrAt(1).isRead, "Message 1 is not read");
+
+ closeTabs();
+});
+
+/**
+ * Test we can mark a thread as read in the list view version of the global
+ * search results.
+ */
+add_task(async function testListViewMarkThreadAsRead() {
+ let folder = await create_folder("ListViewMarkThreadAsReadFolder");
+ let term = "listviewmarkthreadasread ";
+ let thread = createThreadWithTerm(3, term);
+
+ registerCleanupFunction(async () => {
+ await be_in_folder(inboxFolder);
+ await delete_messages(thread);
+ let trash = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ folder.deleteSelf(null);
+ trash.emptyTrash(null);
+ });
+
+ await be_in_folder(folder);
+ await add_message_sets_to_folders([folder], [thread]);
+
+ await new Promise(callback => {
+ GlodaMsgIndexer.indexFolder(folder, { callback, force: true });
+ });
+
+ await waitForThreadIndexed(thread);
+ doGlobalSearch(term);
+
+ let tab = document.querySelector(
+ "tabmail>tabbox>tabpanels>vbox[selected=true]"
+ );
+ await clickShowResultsAsList(tab);
+ await clickMarkThreadAsRead(0, 4);
+
+ let dbView = window.gFolderDisplay.view.dbView;
+ thread.synMessages.forEach((_, i) => {
+ Assert.ok(dbView.getMsgHdrAt(i).isRead, `Message ${i} is read`);
+ });
+
+ closeTabs();
+});
+
+/**
+ * Test we can mark a message as read in a conversation view.
+ */
+add_task(async function testConversationViewMarkRead() {
+ let folder = await create_folder("ConversationViewMarkReadFolder");
+ let thread = create_thread(2);
+
+ registerCleanupFunction(async () => {
+ await be_in_folder(inboxFolder);
+ await delete_messages(thread);
+
+ let trash = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ folder.deleteSelf(null);
+ trash.emptyTrash(null);
+ });
+
+ await be_in_folder(folder);
+ await add_message_sets_to_folders([folder], [thread]);
+
+ await new Promise(callback => {
+ GlodaMsgIndexer.indexFolder(folder, {
+ callback,
+ force: true,
+ });
+ });
+
+ await waitForThreadIndexed(thread);
+ await openConversationView(1, 1);
+ await clickMarkRead(0, 4);
+
+ let dbView = window.gFolderDisplay.view.dbView;
+ Assert.ok(dbView.getMsgHdrAt(0).isRead, "Message 0 is read");
+ Assert.ok(!dbView.getMsgHdrAt(1).isRead, "Message 1 is not read");
+
+ closeTabs();
+});
+
+/**
+ * Test we can mark a thread as read in a conversation view.
+ */
+add_task(async function testConversationViewMarkThreadAsRead() {
+ let folder = await create_folder("ConversationViewMarkThreadAsReadFolder");
+ let thread = create_thread(3);
+
+ registerCleanupFunction(async () => {
+ await be_in_folder(inboxFolder);
+ await delete_messages(thread);
+
+ let trash = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ folder.deleteSelf(null);
+ trash.emptyTrash(null);
+ });
+
+ await be_in_folder(folder);
+ await add_message_sets_to_folders([folder], [thread]);
+
+ await new Promise(callback => {
+ GlodaMsgIndexer.indexFolder(folder, { callback, force: true });
+ });
+
+ await waitForThreadIndexed(thread);
+ await openConversationView(1, 1);
+ await clickMarkThreadAsRead(0, 4);
+
+ let dbView = window.gFolderDisplay.view.dbView;
+ thread.synMessages.forEach((_, i) => {
+ Assert.ok(dbView.getMsgHdrAt(i).isRead, `Message ${i} is read.`);
+ });
+
+ closeTabs();
+});
diff --git a/comm/mail/test/browser/folder-display/browser_tabsSimple.js b/comm/mail/test/browser/folder-display/browser_tabsSimple.js
new file mode 100644
index 0000000000..d0a514c616
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_tabsSimple.js
@@ -0,0 +1,195 @@
+/* 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 that opening new folder and message tabs has the expected result and
+ * that closing them doesn't break anything. sid0 added checks for focus
+ * transitions at one point; I (asuth) am changing our test infrastructure to
+ * cause more realistic focus changes so those changes now look sillier
+ * because in many cases we are explicitly setting focus back after the thread
+ * tree gains focus.
+ */
+
+"use strict";
+
+var {
+ assert_folder_tree_focused,
+ assert_message_pane_focused,
+ assert_messages_in_view,
+ assert_nothing_selected,
+ assert_selected_and_displayed,
+ assert_thread_tree_focused,
+ be_in_folder,
+ close_tab,
+ create_folder,
+ focus_folder_tree,
+ focus_message_pane,
+ focus_thread_tree,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_tab,
+ open_selected_message_in_new_tab,
+ select_click_row,
+ switch_tab,
+ wait_for_blank_content_pane,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folderA, folderB, setA, setB;
+
+add_setup(async function () {
+ folderA = await create_folder("TabsSimpleA");
+ folderB = await create_folder("TabsSimpleB");
+
+ // We will verify we are seeing the right folder by checking that it has the
+ // right messages in it.
+ [setA] = await make_message_sets_in_folders([folderA], [{}]);
+ [setB] = await make_message_sets_in_folders([folderB], [{}]);
+});
+
+/** The tabs in our test. */
+var tabFolderA, tabFolderB, tabMessageA, tabMessageB;
+/** The message that we selected for tab display, to check it worked right. */
+var messageA, messageB;
+
+/**
+ * Make sure the default tab works right.
+ */
+add_task(async function test_open_folder_a() {
+ tabFolderA = await be_in_folder(folderA);
+ assert_messages_in_view(setA);
+ assert_nothing_selected();
+ // Focus the folder tree here
+ focus_folder_tree();
+});
+
+/**
+ * Open tab b, make sure it works right.
+ */
+add_task(async function test_open_folder_b_in_tab() {
+ tabFolderB = await open_folder_in_new_tab(folderB);
+ wait_for_blank_content_pane();
+ assert_messages_in_view(setB);
+ assert_nothing_selected();
+ focus_thread_tree();
+});
+
+/**
+ * Go back to tab/folder A and make sure we change correctly.
+ */
+add_task(async function test_switch_to_tab_folder_a() {
+ await switch_tab(tabFolderA);
+ assert_messages_in_view(setA);
+ assert_nothing_selected();
+ assert_folder_tree_focused();
+});
+
+/**
+ * Select a message in folder A and open it in a new window, making sure that
+ * the displayed message is the right one.
+ */
+add_task(async function test_open_message_a_in_tab() {
+ // (this focuses the thread tree for tabFolderA...)
+ messageA = select_click_row(0);
+ // (...refocus the folder tree for our sticky check below)
+ focus_folder_tree();
+ tabMessageA = await open_selected_message_in_new_tab();
+ assert_selected_and_displayed(messageA);
+ assert_message_pane_focused();
+});
+
+/**
+ * Go back to tab/folder B and make sure we change correctly.
+ */
+add_task(async function test_switch_to_tab_folder_b() {
+ await switch_tab(tabFolderB);
+ assert_messages_in_view(setB);
+ assert_nothing_selected();
+ assert_thread_tree_focused();
+});
+
+/**
+ * Select a message in folder B and open it in a new window, making sure that
+ * the displayed message is the right one.
+ */
+add_task(async function test_open_message_b_in_tab() {
+ messageB = select_click_row(0);
+ // Let's focus the message pane now
+ focus_message_pane();
+ tabMessageB = await open_selected_message_in_new_tab();
+ assert_selected_and_displayed(messageB);
+ assert_message_pane_focused();
+});
+
+/**
+ * Switch to message tab A.
+ */
+add_task(async function test_switch_to_message_a() {
+ await switch_tab(tabMessageA);
+ assert_selected_and_displayed(messageA);
+ assert_message_pane_focused();
+});
+
+/**
+ * Close message tab A (when it's in the foreground).
+ */
+add_task(function test_close_message_a() {
+ close_tab();
+ // our current tab is now undefined for the purposes of this test.
+});
+
+/**
+ * Make sure all the other tabs are still happy.
+ */
+add_task(async function test_tabs_are_still_happy() {
+ await switch_tab(tabFolderB);
+ assert_messages_in_view(setB);
+ assert_selected_and_displayed(messageB);
+ assert_message_pane_focused();
+
+ await switch_tab(tabMessageB);
+ assert_selected_and_displayed(messageB);
+ assert_message_pane_focused();
+
+ await switch_tab(tabFolderA);
+ assert_messages_in_view(setA);
+ assert_selected_and_displayed(messageA);
+ // focus restoration uses setTimeout(0) and so we need to give it a chance
+ await new Promise(resolve => setTimeout(resolve));
+ assert_folder_tree_focused();
+});
+
+/**
+ * Close message tab B (when it's in the background).
+ */
+add_task(function test_close_message_b() {
+ close_tab(tabMessageB);
+ // we should still be on folder A
+ assert_messages_in_view(setA);
+ assert_selected_and_displayed(messageA);
+ assert_folder_tree_focused();
+});
+
+/**
+ * Switch to tab B, close it, make sure we end up on tab A.
+ */
+add_task(async function test_close_folder_b() {
+ await switch_tab(tabFolderB);
+ assert_messages_in_view(setB);
+ assert_selected_and_displayed(messageB);
+ assert_message_pane_focused();
+
+ close_tab();
+ assert_messages_in_view(setA);
+ assert_selected_and_displayed(messageA);
+ assert_folder_tree_focused();
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_viewSource.js b/comm/mail/test/browser/folder-display/browser_viewSource.js
new file mode 100644
index 0000000000..63ce81daa3
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_viewSource.js
@@ -0,0 +1,222 @@
+/* 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 that view-source content can be reloaded to change encoding.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { be_in_folder, create_folder, get_about_message, mc, select_click_row } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+var {
+ click_menus_in_sequence,
+ close_window,
+ plan_for_new_window,
+ wait_for_new_window,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var folder;
+
+// Message content as stored in the message folder. Non-ASCII characters as
+// escape codes for clarity.
+var contentLatin1 = "Testar, ett tv\xE5 tre.";
+var contentUTF8 = "Testar, ett tv\xC3\xA5 tre.";
+// Message content as it should be displayed to the user.
+var contentReadable = "Testar, ett två tre.";
+// UTF-8 content displayed as Latin1.
+var contentGarbled = "Testar, ett två tre.";
+// Latin1 content displayed as UTF-8.
+var contentReplaced = "Testar, ett tv� tre.";
+
+add_setup(async function () {
+ folder = await create_folder("viewsource");
+ addToFolder("ISO-8859-1 header/ISO-8859-1 body", "ISO-8859-1", contentLatin1);
+ addToFolder("ISO-8859-1 header/UTF-8 body", "ISO-8859-1", contentUTF8);
+ addToFolder("UTF-8 header/ISO-8859-1 body", "UTF-8", contentLatin1);
+ addToFolder("UTF-8 header/UTF-8 body", "UTF-8", contentUTF8);
+
+ await be_in_folder(folder);
+});
+
+registerCleanupFunction(() => {
+ folder.deleteSelf(null);
+});
+
+/** Header matches the body. Should be readable in both places. */
+add_task(async function latin1Header_with_latin1Body() {
+ await subtest(0, contentReadable, contentReadable);
+});
+/** Header doesn't match the body. Unicode characters should be displayed. */
+add_task(async function latin1Header_with_utf8Body() {
+ await subtest(1, contentGarbled, contentGarbled);
+});
+/**
+ * Header doesn't match the body. Unreadable characters should be replaced
+ * in both places, but the view-source display defaults to windows-1252.
+ */
+add_task(async function utf8Header_with_latin1Body() {
+ await subtest(2, contentReplaced, contentReadable);
+});
+/**
+ * Header matches the body. Should be readable in both places, but the
+ * view-source display defaults to windows-1252.
+ */
+add_task(async function utf8Header_with_utf8Body() {
+ await subtest(3, contentReadable, contentGarbled);
+});
+
+function addToFolder(subject, charset, body) {
+ let msgId = Services.uuid.generateUUID() + "@invalid";
+
+ let source =
+ "From - Sat Nov 1 12:39:54 2008\n" +
+ "X-Mozilla-Status: 0001\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "Message-ID: <" +
+ msgId +
+ ">\n" +
+ "Date: Wed, 11 Jun 2008 20:32:02 -0400\n" +
+ "From: Tester <tests@mozillamessaging.invalid>\n" +
+ "MIME-Version: 1.0\n" +
+ "To: anna@example.com\n" +
+ `Subject: ${subject}` +
+ "\n" +
+ `Content-Type: text/plain; charset=${charset}\n` +
+ "Content-Transfer-Encoding: 8bit\n" +
+ "\n" +
+ body +
+ "\n";
+
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folder.addMessage(source);
+
+ return folder.msgDatabase.getMsgHdrForMessageID(msgId);
+}
+
+async function subtest(row, expectedDisplayed, expectedSource) {
+ select_click_row(row);
+
+ let aboutMessage = get_about_message();
+ let displayContent =
+ aboutMessage.getMessagePaneBrowser().contentDocument.body.textContent;
+ Assert.stringContains(
+ displayContent,
+ expectedDisplayed,
+ "Message content must include the readable text"
+ );
+ Assert.equal(
+ aboutMessage.document.getElementById("messagepane").docShell.charset,
+ "UTF-8"
+ );
+
+ plan_for_new_window("navigator:view-source");
+ EventUtils.synthesizeKey("U", { shiftKey: false, accelKey: true });
+ let viewSourceController = wait_for_new_window("navigator:view-source");
+
+ utils.waitFor(
+ () =>
+ viewSourceController.window.document
+ .getElementById("content")
+ .contentDocument.querySelector("pre") != null,
+ "Timeout waiting for the latin1 view-source document to load."
+ );
+
+ let source =
+ viewSourceController.window.document.getElementById("content")
+ .contentDocument.body.textContent;
+ Assert.stringContains(
+ source,
+ expectedSource,
+ "View source must contain the readable text"
+ );
+
+ let popupshown;
+
+ // We can't use the menu on macOS.
+ if (AppConstants.platform != "macosx") {
+ let theContent =
+ viewSourceController.window.document.getElementById("content");
+ // Keep a reference to the originally loaded document.
+ let doc = theContent.contentDocument;
+
+ // Click the new window to make it receive further events properly.
+ EventUtils.synthesizeMouseAtCenter(theContent, {}, theContent.ownerGlobal);
+ await new Promise(resolve => setTimeout(resolve));
+
+ popupshown = BrowserTestUtils.waitForEvent(
+ viewSourceController.window.document.getElementById("viewmenu-popup"),
+ "popupshown"
+ );
+ let menuView =
+ viewSourceController.window.document.getElementById("menu_view");
+ EventUtils.synthesizeMouseAtCenter(menuView, {}, menuView.ownerGlobal);
+ await popupshown;
+
+ Assert.equal(
+ viewSourceController.window.document.getElementById(
+ "repair-text-encoding"
+ ).disabled,
+ expectedSource == contentReadable
+ );
+
+ await click_menus_in_sequence(
+ viewSourceController.window.document.getElementById("viewmenu-popup"),
+ [{ id: "repair-text-encoding" }]
+ );
+
+ if (expectedSource != contentReadable) {
+ utils.waitFor(
+ () =>
+ viewSourceController.window.document.getElementById("content")
+ .contentDocument != doc &&
+ viewSourceController.window.document
+ .getElementById("content")
+ .contentDocument.querySelector("pre") != null,
+ "Timeout waiting utf-8 encoded view-source document to load."
+ );
+
+ source =
+ viewSourceController.window.document.getElementById("content")
+ .contentDocument.body.textContent;
+ Assert.stringContains(
+ source,
+ contentReadable,
+ "View source must contain the readable text"
+ );
+ }
+ }
+
+ // Check the context menu while were here.
+ let browser = viewSourceController.window.document.getElementById("content");
+ let contextMenu = viewSourceController.window.document.getElementById(
+ "viewSourceContextMenu"
+ );
+ popupshown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ { type: "contextmenu" },
+ browser
+ );
+ await popupshown;
+
+ let actualItems = [];
+ for (let item of contextMenu.children) {
+ if (item.localName == "menuitem" && !item.hidden) {
+ actualItems.push(item.id);
+ }
+ }
+ Assert.deepEqual(actualItems, [
+ "cMenu_copy",
+ "cMenu_selectAll",
+ "cMenu_find",
+ "cMenu_findAgain",
+ ]);
+ contextMenu.hidePopup();
+
+ close_window(viewSourceController);
+}
diff --git a/comm/mail/test/browser/folder-display/browser_virtualFolderCommands.js b/comm/mail/test/browser/folder-display/browser_virtualFolderCommands.js
new file mode 100644
index 0000000000..39ca926877
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_virtualFolderCommands.js
@@ -0,0 +1,83 @@
+/* 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 that commands on virtual folders work properly.
+ */
+
+"use strict";
+
+var {
+ be_in_folder,
+ create_folder,
+ create_virtual_folder,
+ expand_all_threads,
+ get_about_3pane,
+ make_display_threaded,
+ make_message_sets_in_folders,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var msgsPerThread = 5;
+var singleVirtFolder;
+var multiVirtFolder;
+
+add_setup(async function () {
+ let folderOne = await create_folder();
+ let folderTwo = await create_folder();
+ await make_message_sets_in_folders([folderOne], [{ msgsPerThread }]);
+ await make_message_sets_in_folders([folderTwo], [{ msgsPerThread }]);
+
+ singleVirtFolder = create_virtual_folder([folderOne], {});
+ multiVirtFolder = create_virtual_folder([folderOne, folderTwo], {});
+});
+
+add_task(async function test_single_folder_select_thread() {
+ await be_in_folder(singleVirtFolder);
+ let win = get_about_3pane();
+ make_display_threaded();
+ expand_all_threads();
+
+ // Try selecting the thread from the root message.
+ select_click_row(0);
+ EventUtils.synthesizeKey("a", { accelKey: true, shiftKey: true });
+ Assert.ok(
+ win.gDBView.selection.count == msgsPerThread,
+ "Didn't select all messages in the thread!"
+ );
+
+ // Now try selecting the thread from a non-root message.
+ select_click_row(1);
+ EventUtils.synthesizeKey("a", { accelKey: true, shiftKey: true });
+ Assert.ok(
+ win.gDBView.selection.count == msgsPerThread,
+ "Didn't select all messages in the thread!"
+ );
+});
+
+add_task(async function test_cross_folder_select_thread() {
+ await be_in_folder(multiVirtFolder);
+ let win = get_about_3pane();
+ make_display_threaded();
+ expand_all_threads();
+
+ // Try selecting the thread from the root message.
+ select_click_row(0);
+ EventUtils.synthesizeKey("a", { accelKey: true, shiftKey: true });
+ Assert.ok(
+ win.gDBView.selection.count == msgsPerThread,
+ "Didn't select all messages in the thread!"
+ );
+
+ // Now try selecting the thread from a non-root message.
+ select_click_row(1);
+ EventUtils.synthesizeKey("a", { accelKey: true, shiftKey: true });
+ Assert.ok(
+ win.gDBView.selection.count == msgsPerThread,
+ "Didn't select all messages in the thread!"
+ );
+});
diff --git a/comm/mail/test/browser/folder-display/browser_watchIgnoreThread.js b/comm/mail/test/browser/folder-display/browser_watchIgnoreThread.js
new file mode 100644
index 0000000000..b0036aee4d
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/browser_watchIgnoreThread.js
@@ -0,0 +1,150 @@
+/* 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 that "watch thread" and "ignore thread" works correctly.
+ */
+
+"use strict";
+
+var {
+ add_message_sets_to_folders,
+ assert_not_shown,
+ assert_selected_and_displayed,
+ assert_visible,
+ be_in_folder,
+ create_folder,
+ create_thread,
+ expand_all_threads,
+ inboxFolder,
+ make_display_threaded,
+ mc,
+ select_click_row,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var folder;
+var thread1, thread2, thread3;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+ folder = await create_folder("WatchIgnoreThreadTest");
+ thread1 = create_thread(3);
+ thread2 = create_thread(4);
+ thread3 = create_thread(5);
+ await add_message_sets_to_folders([folder], [thread1, thread2, thread3]);
+
+ await be_in_folder(folder);
+ make_display_threaded();
+ expand_all_threads();
+
+ registerCleanupFunction(() => {
+ document.getElementById("toolbar-menubar").autohide = true;
+ });
+});
+
+/**
+ * Click one of the menu items in the View | Messages menu.
+ *
+ * @param {string} id - The id of the menu item to click.
+ */
+async function clickViewMessagesItem(id) {
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("menu_View"),
+ {},
+ mc.window.document.getElementById("menu_View").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ mc.window.document.getElementById("menu_View_Popup"),
+ [{ id: "viewMessagesMenu" }, { id }]
+ );
+}
+
+/**
+ * Test that Ignore Thread works as expected.
+ */
+add_task(async function test_ignore_thread() {
+ let t1root = thread1.getMsgHdr(0);
+
+ let t1second = select_click_row(1);
+ assert_selected_and_displayed(t1second);
+
+ // Ignore this thread.
+ EventUtils.synthesizeKey("K", { shiftKey: false, accelKey: false });
+
+ // The first msg in the next thread should now be selected.
+ let t2root = thread2.getMsgHdr(0);
+ assert_selected_and_displayed(t2root);
+
+ // The ignored thread should still be visible (with an ignored icon).
+ assert_visible(t1root);
+
+ // Go to another folder then back. Ignored messages should now be hidden.
+ await be_in_folder(inboxFolder);
+ await be_in_folder(folder);
+ select_click_row(0);
+ assert_selected_and_displayed(t2root);
+});
+
+/**
+ * Test that ignored threads are shown when the View | Threads |
+ * Ignored Threads option is checked.
+ */
+add_task(async function test_view_threads_ignored_threads() {
+ let t1root = thread1.getMsgHdr(0);
+ let t2root = thread2.getMsgHdr(0);
+
+ // Check "Ignored Threads" - the ignored messages should appear =>
+ // the first row is the first message of the first thread.
+ // await clickViewMessagesItem("viewIgnoredThreadsMenuItem");
+ goDoCommand("cmd_viewIgnoredThreads");
+ select_click_row(0);
+ assert_selected_and_displayed(t1root);
+
+ // Uncheck "Ignored Threads" - the ignored messages should get hidden.
+ // await clickViewMessagesItem("viewIgnoredThreadsMenuItem");
+ goDoCommand("cmd_viewIgnoredThreads");
+ select_click_row(0);
+ assert_selected_and_displayed(t2root);
+ assert_not_shown(thread1.msgHdrList);
+}).__skipMe = AppConstants.platform == "macosx";
+
+/**
+ * Test that Watch Thread makes the thread watched.
+ */
+add_task(async function test_watch_thread() {
+ let t2second = select_click_row(1);
+ let t3root = thread3.getMsgHdr(0);
+ assert_selected_and_displayed(t2second);
+
+ // Watch this thread.
+ EventUtils.synthesizeKey("W", { shiftKey: false, accelKey: false });
+
+ // Choose "Watched Threads with Unread".
+ // await clickViewMessagesItem("viewWatchedThreadsWithUnreadMenuItem");
+ goDoCommand("cmd_viewWatchedThreadsWithUnread");
+ select_click_row(1);
+ assert_selected_and_displayed(t2second);
+ assert_not_shown(thread1.msgHdrList);
+ assert_not_shown(thread3.msgHdrList);
+
+ // Choose "All Messages" again.
+ // await clickViewMessagesItem("viewAllMessagesMenuItem");
+ goDoCommand("cmd_viewAllMsgs");
+ assert_not_shown(thread1.msgHdrList); // still ignored (and now shown)
+ select_click_row(thread2.msgHdrList.length);
+ assert_selected_and_displayed(t3root);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+}).__skipMe = AppConstants.platform == "macosx";
diff --git a/comm/mail/test/browser/folder-display/data/test-invalid-vcard.eml b/comm/mail/test/browser/folder-display/data/test-invalid-vcard.eml
new file mode 100644
index 0000000000..6ee2f4865a
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/data/test-invalid-vcard.eml
@@ -0,0 +1,25 @@
+From - Tue Jun 21 20:58:09 2020
+MIME-Version: 1.0
+Message-ID: <vcard.invalid@link.invalid>
+From: <meister@mail.example.com>
+To: Hugo <hugo@example.com>
+Subject: this contains an invalid vcard
+Date: Tue, 21 Jun 2020 20:45:48 +0200
+Content-Type: multipart/mixed;
+ boundary="------------B16B2089EF5F4ADD84A4E66F"
+
+--------------B16B2089EF5F4ADD84A4E66F
+Content-Type: text/plain; charset=UTF-8
+
+has an attached vcf which has invalid data (null)
+
+--------------B16B2089EF5F4ADD84A4E66F
+Content-Type: text/vcard;
+ name="contentisnull.vcf"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="contentisnull.vcf"
+
+bnVsbA==
+--------------B16B2089EF5F4ADD84A4E66F--
+
diff --git a/comm/mail/test/browser/folder-display/head.js b/comm/mail/test/browser/folder-display/head.js
new file mode 100644
index 0000000000..9ac7d044ec
--- /dev/null
+++ b/comm/mail/test/browser/folder-display/head.js
@@ -0,0 +1,76 @@
+/* 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/. */
+
+function getFoldersContext() {
+ return document
+ .getElementById("tabmail")
+ .currentAbout3Pane.document.getElementById("folderPaneContext");
+}
+
+function getMailContext() {
+ return document
+ .getElementById("tabmail")
+ .currentAbout3Pane.document.getElementById("mailContext");
+}
+
+/**
+ * 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"
+ );
+}
+
+registerCleanupFunction(() => {
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "only the first tab should remain open"
+ );
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+ tabmail.currentTabInfo.folderPaneVisible = true;
+ tabmail.currentTabInfo.messagePaneVisible = true;
+
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ Services.prefs.clearUserPref("mail.pane_config.dynamic");
+});
diff --git a/comm/mail/test/browser/folder-pane/browser.ini b/comm/mail/test/browser/folder-pane/browser.ini
new file mode 100644
index 0000000000..90ec2e2b31
--- /dev/null
+++ b/comm/mail/test/browser/folder-pane/browser.ini
@@ -0,0 +1,49 @@
+[DEFAULT]
+prefs =
+ mail.account.account2.identities=id1,id2
+ mail.account.account2.server=server2
+ mail.accountmanager.accounts=account2
+ mail.accountmanager.defaultaccount=account2
+ mail.identity.id1.fullName=Tinderbox
+ mail.identity.id1.htmlSigFormat=false
+ mail.identity.id1.smtpServer=smtp1
+ mail.identity.id1.useremail=tinderbox@foo.invalid
+ mail.identity.id1.valid=true
+ mail.identity.id2.fullName=Tinderboxpushlog
+ mail.identity.id2.htmlSigFormat=true
+ mail.identity.id2.smtpServer=smtp1
+ mail.identity.id2.useremail=tinderboxpushlog@foo.invalid
+ mail.identity.id2.valid=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server2.check_new_mail=false
+ mail.server.server2.directory-rel=[ProfD]Mail/tinderbox
+ mail.server.server2.download_on_biff=true
+ mail.server.server2.hostname=tinderbox123
+ mail.server.server2.login_at_startup=false
+ mail.server.server2.name=tinderbox@foo.invalid
+ mail.server.server2.type=pop3
+ mail.server.server2.userName=tinderbox
+ mail.server.server2.whiteListAbURI=
+ mail.shell.checkDefaultClient=false
+ mail.smtp.defaultserver=smtp1
+ mail.smtpserver.smtp1.hostname=tinderbox123
+ mail.smtpserver.smtp1.username=tinderbox
+ mail.smtpservers=smtp1
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ ui.prefersReducedMotion=1
+subsuite = thunderbird
+
+[browser_displayMessageWithFolderModes.js]
+skip-if = true # TODO
+[browser_folderNamesInRecentMode.js]
+skip-if = true # TODO
+[browser_folderPane.js]
+[browser_folderPaneConsumers.js]
+skip-if = true # TODO
+# skip-if = os == 'mac'
+[browser_folderPaneHeader.js]
+[browser_folderPaneModeContextMenu.js]
diff --git a/comm/mail/test/browser/folder-pane/browser_displayMessageWithFolderModes.js b/comm/mail/test/browser/folder-pane/browser_displayMessageWithFolderModes.js
new file mode 100644
index 0000000000..71d448a068
--- /dev/null
+++ b/comm/mail/test/browser/folder-pane/browser_displayMessageWithFolderModes.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/. */
+
+/*
+ * Test that displaying messages in folder tabs works correctly with folder
+ * modes. This includes:
+ * - switching to the default folder mode if the folder isn't present in the
+ * current folder mode
+ * - not switching otherwise
+ * - making sure that we're able to expand the right folders in the smart folder
+ * mode
+ */
+
+"use strict";
+
+var {
+ assert_folder_child_in_view,
+ assert_folder_collapsed,
+ assert_folder_expanded,
+ assert_folder_mode,
+ assert_folder_not_visible,
+ assert_folder_selected_and_displayed,
+ assert_folder_tree_view_row_count,
+ assert_folder_visible,
+ assert_message_not_in_view,
+ assert_selected_and_displayed,
+ be_in_folder,
+ collapse_folder,
+ display_message_in_folder_tab,
+ get_smart_folder_named,
+ inboxFolder,
+ make_message_sets_in_folders,
+ mc,
+ select_none,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var folder;
+var dummyFolder;
+var inbox2Folder;
+var smartInboxFolder;
+
+var msgHdr;
+
+add_setup(async function () {
+ assert_folder_mode("all");
+ assert_folder_tree_view_row_count(7);
+
+ // This is a subfolder of the inbox so that
+ // test_display_message_in_smart_folder_mode_works is able to test that we
+ // don't attempt to expand any inboxes.
+ inboxFolder.createSubfolder("DisplayMessageWithFolderModesA", null);
+ folder = inboxFolder.getChildNamed("DisplayMessageWithFolderModesA");
+ // This second folder is meant to act as a dummy folder to switch to when we
+ // want to not be in folder.
+ inboxFolder.createSubfolder("DisplayMessageWithFolderModesB", null);
+ dummyFolder = inboxFolder.getChildNamed("DisplayMessageWithFolderModesB");
+ await make_message_sets_in_folders([folder], [{ count: 5 }]);
+ // The message itself doesn't really matter, as long as there's at least one
+ // in the inbox. We will delete this in teardownModule because the inbox
+ // is a shared resource and it's not okay to leave stuff in there.
+ await make_message_sets_in_folders([inboxFolder], [{ count: 1 }]);
+
+ // Create another subfolder on the top level that is not a parent of the
+ // 2 folders so that it is not visible in Favorite mode.
+ inboxFolder.server.rootFolder.createSubfolder("Inbox2", null);
+ inbox2Folder = inboxFolder.server.rootFolder.getChildNamed("Inbox2");
+
+ await be_in_folder(folder);
+ msgHdr = mc.window.gFolderDisplay.view.dbView.getMsgHdrAt(0);
+});
+
+/**
+ * Test that displaying a message causes a switch to the default folder mode if
+ * the folder isn't present in the current folder mode.
+ */
+add_task(
+ async function test_display_message_with_folder_not_present_in_current_folder_mode() {
+ // Make sure the folder doesn't appear in the favorite folder mode just
+ // because it was selected last before switching
+ await be_in_folder(inboxFolder);
+
+ // Enable the favorite folders view. This folder isn't currently a favorite
+ // folder.
+ mc.folderTreeView.activeModes = "favorite";
+ // Hide the all folders view. The activeModes setter takes care of removing
+ // the mode is is already visible.
+ mc.folderTreeView.activeModes = "all";
+
+ assert_folder_not_visible(folder);
+ assert_folder_not_visible(inboxFolder);
+ assert_folder_not_visible(inbox2Folder);
+
+ // Try displaying a message
+ display_message_in_folder_tab(msgHdr);
+
+ assert_folder_mode("favorite");
+ assert_folder_selected_and_displayed(folder);
+ assert_selected_and_displayed(msgHdr);
+ }
+);
+
+/**
+ * Test that displaying a message _does not_ cause a switch to the default
+ * folder mode if the folder is present in the current folder mode.
+ */
+add_task(
+ async function test_display_message_with_folder_present_in_current_folder_mode() {
+ // Mark the folder as a favorite
+ folder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ // Also mark the dummy folder as a favorite, in preparation for
+ // test_display_message_in_smart_folder_mode_works
+ dummyFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ // Make sure the folder doesn't appear in the favorite folder mode just
+ // because it was selected last before switching
+ await be_in_folder(inboxFolder);
+
+ // Hide the all folders view. The activeModes setter takes care of removing
+ // the mode if is already visible.
+ mc.folderTreeView.activeModes = "all";
+
+ // Select the folder to open the parent row.
+ await be_in_folder(folder);
+
+ assert_folder_visible(folder);
+ assert_folder_visible(dummyFolder);
+ // Also their parent folder should be visible.
+ assert_folder_visible(inboxFolder);
+ // But not a sibling of their parent, which is not Favorite.
+ assert_folder_not_visible(inbox2Folder);
+
+ // Try displaying a message
+ display_message_in_folder_tab(msgHdr);
+
+ assert_folder_mode("favorite");
+ assert_folder_selected_and_displayed(folder);
+ assert_selected_and_displayed(msgHdr);
+
+ // Now unset the flags so that we don't affect later tests.
+ folder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ dummyFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ }
+);
+
+/**
+ * Test that displaying a message in smart folders mode causes the parent in the
+ * view to expand.
+ */
+add_task(async function test_display_message_in_smart_folder_mode_works() {
+ // Clear the message selection, otherwise msgHdr will still be displayed and
+ // display_message_in_folder_tab(msgHdr) will be a no-op.
+ select_none();
+ // Show the smart folder view before removing the favorite view.
+ mc.folderTreeView.activeModes = "smart";
+ // Hide the favorite view. The activeModes setter takes care of removing a
+ // view if is currently active.
+ mc.folderTreeView.activeModes = "favorite";
+
+ // Switch to the dummy folder, otherwise msgHdr will be in the view and the
+ // display message in folder tab logic will simply select the message without
+ // bothering to expand any folders.
+ await be_in_folder(dummyFolder);
+
+ let rootFolder = folder.server.rootFolder;
+ // Check that the folder is actually the child of the account root
+ assert_folder_child_in_view(folder, rootFolder);
+
+ // Collapse everything
+ smartInboxFolder = get_smart_folder_named("Inbox");
+ collapse_folder(smartInboxFolder);
+ assert_folder_collapsed(smartInboxFolder);
+ collapse_folder(rootFolder);
+ assert_folder_collapsed(rootFolder);
+ assert_folder_not_visible(folder);
+
+ // Try displaying the message
+ display_message_in_folder_tab(msgHdr);
+
+ // Check that the right folders have expanded
+ assert_folder_mode("smart");
+ assert_folder_collapsed(smartInboxFolder);
+ assert_folder_expanded(rootFolder);
+ assert_folder_selected_and_displayed(folder);
+ assert_selected_and_displayed(msgHdr);
+});
+
+/**
+ * Test that displaying a message in an inbox in smart folders mode causes the
+ * message to be displayed in the smart inbox.
+ */
+add_task(
+ async function test_display_inbox_message_in_smart_folder_mode_works() {
+ await be_in_folder(inboxFolder);
+ let inboxMsgHdr = mc.window.gFolderDisplay.view.dbView.getMsgHdrAt(0);
+
+ // Collapse everything
+ collapse_folder(smartInboxFolder);
+ assert_folder_collapsed(smartInboxFolder);
+ assert_folder_not_visible(inboxFolder);
+ let rootFolder = folder.server.rootFolder;
+ collapse_folder(rootFolder);
+ assert_folder_collapsed(rootFolder);
+
+ // Move to a different folder
+ await be_in_folder(get_smart_folder_named("Trash"));
+ assert_message_not_in_view(inboxMsgHdr);
+
+ // Try displaying the message
+ display_message_in_folder_tab(inboxMsgHdr);
+
+ // Check that nothing has expanded, and that the right folder is selected
+ assert_folder_mode("smart");
+ assert_folder_collapsed(smartInboxFolder);
+ assert_folder_collapsed(rootFolder);
+ assert_folder_selected_and_displayed(smartInboxFolder);
+ assert_selected_and_displayed(inboxMsgHdr);
+ }
+);
+
+/**
+ * Move back to the all folders mode.
+ */
+add_task(function test_switch_to_all_folders() {
+ // Hide the smart folders view enabled in the previous test. The activeModes
+ // setter should take care of restoring the "all" view and prevent and empty
+ // Folder pane.
+ mc.folderTreeView.activeModes = "smart";
+ assert_folder_mode("all");
+ assert_folder_tree_view_row_count(10);
+});
+
+registerCleanupFunction(function () {
+ // Remove our folders
+ inboxFolder.propagateDelete(folder, true);
+ inboxFolder.propagateDelete(dummyFolder, true);
+ inboxFolder.server.rootFolder.propagateDelete(inbox2Folder, true);
+ assert_folder_tree_view_row_count(7);
+
+ document.getElementById("folderTree").focus();
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-pane/browser_folderNamesInRecentMode.js b/comm/mail/test/browser/folder-pane/browser_folderNamesInRecentMode.js
new file mode 100644
index 0000000000..a5f99f32d1
--- /dev/null
+++ b/comm/mail/test/browser/folder-pane/browser_folderNamesInRecentMode.js
@@ -0,0 +1,117 @@
+/* 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 that the folder names have account name appended when in "recent" view.
+ */
+
+"use strict";
+
+var {
+ assert_folder_at_index_as,
+ assert_folder_mode,
+ assert_folder_tree_view_row_count,
+ be_in_folder,
+ make_message_sets_in_folders,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(function () {
+ assert_folder_mode("all");
+ assert_folder_tree_view_row_count(7);
+});
+
+add_task(async function test_folder_names_in_recent_view_mode() {
+ // We need 2 local accounts that have pristine folders with
+ // unmodified times, so that it does not influence the
+ // list of Recent folders. So clear out the most-recently-used time.
+ for (let acc of MailServices.accounts.accounts) {
+ for (let fld of acc.incomingServer.rootFolder.subFolders) {
+ fld.setStringProperty("MRUTime", "0");
+ }
+ }
+
+ let acc1 = MailServices.accounts.accounts[1];
+ let acc2 = MailServices.accounts.accounts[0];
+ let rootFolder1 = acc1.incomingServer.rootFolder;
+ let rootFolder2 = acc2.incomingServer.rootFolder;
+
+ // Create some test folders.
+ rootFolder1.createSubfolder("uniqueName", null);
+ rootFolder1.createSubfolder("duplicatedName", null);
+ rootFolder2.createSubfolder("duplicatedName", null);
+ let inbox2 = rootFolder2.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox);
+ inbox2.createSubfolder("duplicatedName", null);
+
+ let fUnique = rootFolder1.getChildNamed("uniqueName");
+ let fDup1 = rootFolder1.getChildNamed("duplicatedName");
+ let fDup2 = rootFolder2.getChildNamed("duplicatedName");
+ let fDup3 = inbox2.getChildNamed("duplicatedName");
+
+ // Close the inbox folder if open. This might happen when running multiple
+ // tests from the folder-pane.
+ let index = mc.window.gFolderTreeView.getIndexOfFolder(inbox2);
+ if (index != null) {
+ if (mc.window.gFolderTreeView._rowMap[index].open) {
+ mc.window.gFolderTreeView._toggleRow(index, false);
+ }
+ }
+ assert_folder_tree_view_row_count(10);
+
+ // Create some messages in the folders to make them recently used.
+ await make_message_sets_in_folders([fUnique], [{ count: 1 }]);
+ await be_in_folder(fUnique);
+ await make_message_sets_in_folders([fDup1], [{ count: 1 }]);
+ await be_in_folder(fDup1);
+ await make_message_sets_in_folders([fDup2], [{ count: 2 }]);
+ await be_in_folder(fDup2);
+ await make_message_sets_in_folders([fDup3], [{ count: 3 }]);
+ await be_in_folder(fDup3);
+
+ // Enable the recent folder view.
+ mc.window.gFolderTreeView.activeModes = "recent";
+ // Hide the all folder view by passing the value to the setter, which will
+ // take care of toggling off the view if currently visible.
+ mc.window.gFolderTreeView.activeModes = "all";
+
+ // Check displayed folder names.
+ // In Recent mode the folders are sorted alphabetically and the first index is
+ // the Mode Header item.
+ assert_folder_at_index_as(0, "Recent Folders");
+ assert_folder_at_index_as(1, "duplicatedName - Local Folders (1)");
+ assert_folder_at_index_as(2, "duplicatedName - tinderbox@foo.invalid (3)");
+ assert_folder_at_index_as(3, "duplicatedName - tinderbox@foo.invalid (2)");
+ assert_folder_at_index_as(4, "uniqueName - Local Folders (1)");
+ assert_folder_tree_view_row_count(5);
+
+ // Remove our folders to clean up.
+ rootFolder1.propagateDelete(fUnique, true);
+ rootFolder1.propagateDelete(fDup1, true);
+ rootFolder2.propagateDelete(fDup2, true);
+ rootFolder2.propagateDelete(fDup3, true);
+});
+
+registerCleanupFunction(function () {
+ // Hide the recent folders view enabled in the previous test. The activeModes
+ // setter should take care of restoring the "all" view and prevent and empty
+ // Folder pane.
+ mc.window.gFolderTreeView.activeModes = "recent";
+ assert_folder_mode("all");
+ assert_folder_tree_view_row_count(7);
+
+ document.getElementById("folderTree").focus();
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-pane/browser_folderPane.js b/comm/mail/test/browser/folder-pane/browser_folderPane.js
new file mode 100644
index 0000000000..5b4ca546ff
--- /dev/null
+++ b/comm/mail/test/browser/folder-pane/browser_folderPane.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/. */
+
+/*
+ * Tests for the folder pane, in particular the tree view. This is kept separate
+ * from the main folder-display suite so that the folders created by other tests
+ * there don't influence the results here.
+ */
+
+"use strict";
+
+var {
+ FAKE_SERVER_HOSTNAME,
+ assert_folder_mode,
+ assert_folder_tree_view_row_count,
+ be_in_folder,
+ collapse_folder,
+ create_folder,
+ enter_folder,
+ expand_folder,
+ get_about_3pane,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Assert the Folder Pane is in All Folder mode by default. Check that the
+ * correct number of rows for accounts and folders are always shown as new
+ * folders are created, expanded, and collapsed.
+ */
+add_task(async function test_all_folders_toggle_folder_open_state() {
+ // Test that we are in All Folders mode by default
+ assert_folder_mode("all");
+
+ let pop3Server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ collapse_folder(pop3Server.rootFolder);
+ collapse_folder(MailServices.accounts.localFoldersServer.rootFolder);
+
+ // All folders mode should give us only 2 rows to start
+ // (tinderbox account and local folders)
+ let accounts = 2;
+ assert_folder_tree_view_row_count(accounts);
+
+ let inbox = 1;
+ let trash = 1;
+ let outbox = 1;
+ let archives = 1;
+ let folderPaneA = 1;
+ // Create archives folder - this is ugly, but essentially the same as
+ // what mailWindowOverlay.js does. We can't use the built-in helper
+ // method to create the folder because we need the archive flag to get
+ // set before the folder added notification is sent out, which means
+ // creating the folder object via RDF, setting the flag, and then
+ // creating the storage, which sends the notification.
+ let folder = MailUtils.getOrCreateFolder(
+ pop3Server.rootFolder.URI + "/Archives"
+ );
+ folder.setFlag(Ci.nsMsgFolderFlags.Archive);
+ folder.createStorageIfMissing(null);
+ // After creating Archives, account should have expanded
+ // so that we should have 5 rows visible
+ assert_folder_tree_view_row_count(accounts + inbox + trash + archives);
+ // close the tinderbox server.
+ collapse_folder(pop3Server.rootFolder);
+ let folderA = await create_folder("FolderPaneA");
+ await be_in_folder(folderA);
+
+ // After creating our first folder we should have 6 rows visible
+ assert_folder_tree_view_row_count(
+ accounts + inbox + trash + outbox + folderPaneA
+ );
+
+ let about3Pane = get_about_3pane();
+ let oneFolderCount = about3Pane.folderTree.rowCount;
+
+ // This makes sure the folder can be toggled
+ folderA.createSubfolder("FolderPaneB", null);
+ let folderB = folderA.getChildNamed("FolderPaneB");
+ // Enter folderB, then enter folderA. This makes sure that folderA is not
+ // collapsed.
+ await enter_folder(folderB);
+ await enter_folder(folderA);
+
+ // At this point folderA should be open, so the view should have one more
+ // item than before (FolderPaneB).
+ assert_folder_tree_view_row_count(oneFolderCount + 1);
+
+ // Toggle the open state of folderA
+ collapse_folder(folderA);
+
+ // folderA should be collapsed so we are back to the original count
+ assert_folder_tree_view_row_count(oneFolderCount);
+
+ // Toggle it back to open
+ expand_folder(folderA);
+
+ // folderB should be visible again
+ assert_folder_tree_view_row_count(oneFolderCount + 1);
+
+ // Close folderA and delete folderB.
+ collapse_folder(folderA);
+ MailServices.accounts.localFoldersServer.rootFolder.propagateDelete(
+ folderB,
+ true
+ );
+ // Open folderA again and check folderB is deleted.
+ expand_folder(folderA);
+ assert_folder_tree_view_row_count(oneFolderCount);
+
+ // Clean up
+ expand_folder(pop3Server.rootFolder);
+ folder.clearFlag(Ci.nsMsgFolderFlags.Archive);
+ pop3Server.rootFolder.propagateDelete(folder, true, null);
+ MailServices.accounts.localFoldersServer.rootFolder.propagateDelete(
+ folderA,
+ true
+ );
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/folder-pane/browser_folderPaneConsumers.js b/comm/mail/test/browser/folder-pane/browser_folderPaneConsumers.js
new file mode 100644
index 0000000000..a6c88f9c7c
--- /dev/null
+++ b/comm/mail/test/browser/folder-pane/browser_folderPaneConsumers.js
@@ -0,0 +1,143 @@
+/* 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/. */
+
+/*
+ * Tests for other dialogs using the tree view implementation in folderPane.js.
+ */
+
+/* globals gFolderTreeView */
+
+"use strict";
+
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { NNTP_PORT, setupLocalServer } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NNTPHelpers.jsm"
+);
+var { plan_for_modal_dialog, wait_for_modal_dialog } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var { click_menus_in_sequence } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var nntpAccount;
+
+add_setup(function () {
+ gFolderTreeView.selectFolder(gFolderTreeView._enumerateFolders[1]);
+
+ let server = setupLocalServer(NNTP_PORT);
+ nntpAccount = MailServices.accounts.FindAccountForServer(server);
+});
+
+add_task(async function test_virtual_folder_selection_tree() {
+ plan_for_modal_dialog(
+ "mailnews:virtualFolderProperties",
+ subtest_create_virtual_folder
+ );
+
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("menu_File"),
+ {},
+ mc.window.document.getElementById("menu_File").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ mc.window.document.getElementById("menu_FilePopup"),
+ [{ id: "menu_New" }, { id: "menu_newVirtualFolder" }]
+ );
+
+ wait_for_modal_dialog("mailnews:virtualFolderProperties");
+});
+
+function subtest_create_virtual_folder(vfc) {
+ // Open the folder chooser.
+ plan_for_modal_dialog(
+ "mailnews:virtualFolderList",
+ subtest_check_virtual_folder_list
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ vfc.window.document.getElementById("folderListPicker"),
+ {},
+ vfc.window.document.getElementById("folderListPicker").ownerGlobal
+ );
+ wait_for_modal_dialog("mailnews:virtualFolderList");
+
+ vfc.window.document.documentElement.querySelector("dialog").cancelDialog();
+}
+
+/**
+ * Bug 464710
+ * Check the folder list picker is not empty.
+ */
+function subtest_check_virtual_folder_list(listc) {
+ let tree = listc.window.document.getElementById("folderPickerTree");
+ // We should see the folders from the 2 base local accounts here.
+ Assert.ok(
+ tree.view.rowCount > 0,
+ "Folder tree was empty in virtual folder selection!"
+ );
+ listc.window.document.documentElement.querySelector("dialog").cancelDialog();
+}
+
+add_task(async function test_offline_sync_folder_selection_tree() {
+ plan_for_modal_dialog("mailnews:synchronizeOffline", subtest_offline_sync);
+
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("menu_File"),
+ {},
+ mc.window.document.getElementById("menu_File").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ mc.window.document.getElementById("menu_FilePopup"),
+ [{ id: "offlineMenuItem" }, { id: "menu_synchronizeOffline" }]
+ );
+
+ wait_for_modal_dialog("mailnews:synchronizeOffline");
+});
+
+function subtest_offline_sync(osc) {
+ // Open the folder chooser.
+ plan_for_modal_dialog(
+ "mailnews:selectOffline",
+ subtest_check_offline_folder_list
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ osc.window.document.getElementById("select"),
+ {},
+ osc.window.document.getElementById("select").ownerGlobal
+ );
+ wait_for_modal_dialog("mailnews:selectOffline");
+
+ osc.window.document.documentElement.querySelector("dialog").cancelDialog();
+}
+
+/**
+ * Bug 464710
+ * Check the folder list picker is not empty.
+ */
+function subtest_check_offline_folder_list(listc) {
+ let tree = listc.window.document.getElementById("synchronizeTree");
+ // We should see the newsgroups from the NNTP server here.
+ Assert.ok(
+ tree.view.rowCount > 0,
+ "Folder tree was empty in offline sync selection!"
+ );
+ listc.window.document.documentElement.querySelector("dialog").cancelDialog();
+}
+
+registerCleanupFunction(function () {
+ MailServices.accounts.removeAccount(nntpAccount);
+
+ document.getElementById("toolbar-menubar").autohide = true;
+ document.getElementById("folderTree").focus();
+});
diff --git a/comm/mail/test/browser/folder-pane/browser_folderPaneHeader.js b/comm/mail/test/browser/folder-pane/browser_folderPaneHeader.js
new file mode 100644
index 0000000000..1a66df37d5
--- /dev/null
+++ b/comm/mail/test/browser/folder-pane/browser_folderPaneHeader.js
@@ -0,0 +1,945 @@
+/* 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 { add_message_sets_to_folders, be_in_folder, create_thread } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+
+let tabmail,
+ about3Pane,
+ folderPaneHeader,
+ fetchButton,
+ newButton,
+ moreButton,
+ moreContext,
+ fetchContext,
+ folderModesContextMenu,
+ folderModesContextMenuPopup;
+
+add_setup(async function () {
+ tabmail = document.getElementById("tabmail");
+ about3Pane = tabmail.currentAbout3Pane;
+ folderPaneHeader = about3Pane.document.getElementById("folderPaneHeaderBar");
+ fetchButton = folderPaneHeader.querySelector("#folderPaneGetMessages");
+ fetchContext = about3Pane.document.getElementById(
+ "folderPaneGetMessagesContext"
+ );
+ newButton = folderPaneHeader.querySelector("#folderPaneWriteMessage");
+ moreButton = folderPaneHeader.querySelector("#folderPaneMoreButton");
+ moreContext = about3Pane.document.getElementById("folderPaneMoreContext");
+ folderModesContextMenu = about3Pane.document.getElementById(
+ "folderModesContextMenu"
+ );
+ folderModesContextMenuPopup = about3Pane.document.getElementById(
+ "folderModesContextMenuPopup"
+ );
+ registerCleanupFunction(() => {
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ });
+});
+
+async function assertAriaLabel(row, expectedLabel) {
+ await BrowserTestUtils.waitForCondition(
+ () => row.getAttribute("aria-label") === expectedLabel,
+ "The selected row aria-label should match the expected value"
+ );
+}
+
+add_task(function testFolderPaneHeaderDefaultState() {
+ Assert.ok(!folderPaneHeader.hidden, "The folder pane header is visible");
+ Assert.ok(!fetchButton.disabled, "The Get Messages button is enabled");
+ Assert.ok(!newButton.disabled, "The New Message button is enabled");
+});
+
+add_task(async function testHideFolderPaneHeader() {
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForCondition(
+ () => folderPaneHeader.hidden,
+ "The folder pane header is hidden"
+ );
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderHideMenuItem")
+ );
+ await hiddenPromise;
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPaneHeaderBar",
+ "hidden"
+ ) == "true",
+ "The customization data was saved"
+ );
+
+ // Can't access the menubar in macOS tests, so simply simulate a click on the
+ // toolbarbutton inside the app menu to reveal the header. The app menu
+ // behavior is tested later.
+ if (AppConstants.platform == "macosx") {
+ document.getElementById("appmenu_toggleFolderHeader").click();
+ return;
+ }
+
+ let menubar = document.getElementById("toolbar-menubar");
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ let viewShownPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("menu_View_Popup"),
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("menu_View"),
+ {},
+ window
+ );
+ await viewShownPromise;
+
+ let viewMenuPopup = document.getElementById("menu_View_Popup");
+ Assert.ok(viewMenuPopup.querySelector("#menu_FolderViews"));
+
+ let folderViewShownPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("menu_FolderViewsPopup"),
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ viewMenuPopup.querySelector("#menu_FolderViews"),
+ {},
+ window
+ );
+ await folderViewShownPromise;
+
+ let toggleFolderHeader = menubar.querySelector(`[name="paneheader"]`);
+ Assert.ok(
+ !toggleFolderHeader.hasAttribute("checked"),
+ "The toggle header menu item is not checked"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(toggleFolderHeader, {}, window);
+ await BrowserTestUtils.waitForCondition(
+ () => toggleFolderHeader.getAttribute("checked") == "true",
+ "The toggle header menu item is checked"
+ );
+
+ let folderViewHiddenPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("menu_FolderViewsPopup"),
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await folderViewHiddenPromise;
+
+ let viewHiddenPromise = BrowserTestUtils.waitForEvent(
+ viewMenuPopup,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await viewHiddenPromise;
+
+ await BrowserTestUtils.waitForCondition(
+ () => !folderPaneHeader.hidden,
+ "The folder pane header is visible"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPaneHeaderBar",
+ "hidden"
+ ) == "false",
+ "The customization data was saved"
+ );
+});
+
+add_task(async function testTogglePaneHeaderFromAppMenu() {
+ Assert.ok(
+ !folderPaneHeader.hidden,
+ "Start with a visible folder pane header"
+ );
+
+ async function toggleFolderPaneHeader(shouldBeChecked) {
+ let appMenu = document.getElementById("appMenu-popup");
+ let menuShownPromise = BrowserTestUtils.waitForEvent(appMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("button-appmenu"),
+ {},
+ window
+ );
+ await menuShownPromise;
+
+ let viewShownPromise = BrowserTestUtils.waitForEvent(
+ appMenu.querySelector("#appMenu-viewView"),
+ "ViewShown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ appMenu.querySelector("#appmenu_View"),
+ {},
+ window
+ );
+ await viewShownPromise;
+
+ let toolbarShownPromise = BrowserTestUtils.waitForEvent(
+ appMenu.querySelector("#appMenu-foldersView"),
+ "ViewShown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ appMenu.querySelector("#appmenu_FolderViews"),
+ {},
+ window
+ );
+ await toolbarShownPromise;
+
+ let appMenuButton = document.getElementById("appmenu_toggleFolderHeader");
+ Assert.equal(
+ appMenuButton.checked,
+ shouldBeChecked,
+ `The app menu item should ${shouldBeChecked ? "" : "not "}be checked`
+ );
+
+ EventUtils.synthesizeMouseAtCenter(appMenuButton, {}, window);
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ appMenu,
+ "popuphidden"
+ );
+ // Close the appmenu.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("button-appmenu"),
+ {},
+ window
+ );
+ await menuHiddenPromise;
+ }
+
+ await toggleFolderPaneHeader(true);
+ await toggleFolderPaneHeader(false);
+});
+
+/**
+ * Test the toggle that shows/hides the buttons on the folder pane header from
+ * the context menu.
+ */
+add_task(async function testTogglePaneHeaderButtons() {
+ Assert.ok(!folderPaneHeader.hidden, "The folder pane header is visible");
+ Assert.ok(!fetchButton.hidden, "The Get Messages button is visible");
+ Assert.ok(!newButton.hidden, "The New Message button is visible");
+
+ let folderPaneHdrToggleBtns = [
+ {
+ menuId: "#folderPaneHeaderToggleGetMessages",
+ buttonId: "#folderPaneGetMessages",
+ label: "Get messages",
+ },
+ {
+ menuId: "#folderPaneHeaderToggleNewMessage",
+ buttonId: "#folderPaneWriteMessage",
+ label: "New message",
+ },
+ ];
+
+ for (let toggle of folderPaneHdrToggleBtns) {
+ let toggleMenuItem = moreContext.querySelector(toggle.menuId);
+ let toggleButton = folderPaneHeader.querySelector(toggle.buttonId);
+ let shouldBeChecked = !toggleButton.hidden;
+
+ // Hide the toggle buttons
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ Assert.equal(
+ toggleMenuItem.hasAttribute("checked"),
+ shouldBeChecked,
+ `The "${toggle.label}" menuitem should ${
+ shouldBeChecked ? "" : "not"
+ } be checked`
+ );
+
+ EventUtils.synthesizeMouseAtCenter(toggleMenuItem, {}, about3Pane);
+
+ await BrowserTestUtils.waitForCondition(
+ () => !toggleMenuItem.hasAttribute("checked"),
+ `The ${toggle.label} menu item is unchecked`
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => toggleButton.hidden,
+ `The ${toggle.label} button is hidden`
+ );
+
+ let buttonName =
+ toggle.buttonId == "#folderPaneGetMessages"
+ ? "folderPaneGetMessages"
+ : "folderPaneWriteMessage";
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ buttonName,
+ "hidden"
+ ) == "true",
+ "The customization data was saved"
+ );
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ // display the toggle buttons
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ shouldBeChecked = !toggleButton.hidden;
+
+ Assert.equal(
+ toggleMenuItem.hasAttribute("checked"),
+ shouldBeChecked,
+ `The "${toggle.label}" menuitem should ${
+ shouldBeChecked ? "" : "not"
+ } be checked`
+ );
+ EventUtils.synthesizeMouseAtCenter(toggleMenuItem, {}, about3Pane);
+
+ await BrowserTestUtils.waitForCondition(
+ () => toggleMenuItem.hasAttribute("checked"),
+ `The ${toggle.label} menu item is checked`
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => !toggleButton.hidden,
+ `The ${toggle.label} button is not hidden`
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ buttonName,
+ "hidden"
+ ) == "false",
+ "The customization data was saved"
+ );
+
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+ }
+});
+
+/**
+ * Test the default state of the context menu in the about3Pane.
+ */
+add_task(async function testInitialActiveModes() {
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ let shownFolderModesSubMenuPromise = BrowserTestUtils.waitForEvent(
+ folderModesContextMenuPopup,
+ "popupshown"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(folderModesContextMenu, {}, about3Pane);
+ await shownFolderModesSubMenuPromise;
+
+ Assert.equal(
+ about3Pane.folderPane.activeModes.length,
+ 1,
+ "Only one active mode"
+ );
+ Assert.equal(
+ about3Pane.folderPane.activeModes.at(0),
+ "all",
+ "The first item is 'all' value"
+ );
+ Assert.ok(
+ moreContext
+ .querySelector("#folderPaneMoreContextAllFolders")
+ .getAttribute("checked"),
+ "'All' toggle is checked"
+ );
+ Assert.equal(moreContext.state, "open", "The context menu remains open");
+});
+
+/**
+ * Tests that the menu items are correctly checked corresponding to the current
+ * active modes.
+ */
+add_task(async function testFolderModesActivation() {
+ let folderModesArray = [
+ { menuID: "#folderPaneMoreContextUnifiedFolders", modeID: "smart" },
+ { menuID: "#folderPaneMoreContextUnreadFolders", modeID: "unread" },
+ { menuID: "#folderPaneMoreContextFavoriteFolders", modeID: "favorite" },
+ { menuID: "#folderPaneMoreContextRecentFolders", modeID: "recent" },
+ ];
+ let checkedModesCount = 2;
+ for (let mode of folderModesArray) {
+ Assert.ok(
+ !moreContext.querySelector(mode.menuID).hasAttribute("checked"),
+ `"${mode.modeID}" option is not checked`
+ );
+
+ let checkedPromise = TestUtils.waitForCondition(
+ () => moreContext.querySelector(mode.menuID).hasAttribute("checked"),
+ `"${mode.modeID}" option has been checked`
+ );
+ moreContext.activateItem(moreContext.querySelector(mode.menuID));
+ await checkedPromise;
+
+ Assert.equal(
+ about3Pane.folderPane.activeModes.length,
+ checkedModesCount,
+ `Correct amount of active modes after enabling the "${mode.modeID}" mode`
+ );
+ Assert.ok(
+ about3Pane.folderPane.activeModes.includes(mode.modeID),
+ `"${mode.modeID}" mode is included in the active modes array`
+ );
+ checkedModesCount++;
+ }
+ Assert.equal(moreContext.state, "open", "The context menu remains open");
+});
+
+/**
+ * Tests that the menu items are correctly unchecked corresponding to the
+ * current active modes. It verifies that the if every item is unchecked, it
+ * returns to the default active mode value and the corresponding menu item is
+ * checked.
+ */
+add_task(async function testFolderModesDeactivation() {
+ let folderActiveModesArray = [
+ { menuID: "#folderPaneMoreContextAllFolders", modeID: "all" },
+ { menuID: "#folderPaneMoreContextUnifiedFolders", modeID: "smart" },
+ { menuID: "#folderPaneMoreContextUnreadFolders", modeID: "unread" },
+ { menuID: "#folderPaneMoreContextFavoriteFolders", modeID: "favorite" },
+ { menuID: "#folderPaneMoreContextRecentFolders", modeID: "recent" },
+ ];
+ let checkedModesCount = 4;
+ for (let mode of folderActiveModesArray) {
+ Assert.ok(
+ moreContext.querySelector(mode.menuID).hasAttribute("checked"),
+ `"${mode.modeID}" option is checked`
+ );
+
+ let uncheckedPromise = TestUtils.waitForCondition(
+ () => !moreContext.querySelector(mode.menuID).hasAttribute("checked"),
+ `"${mode.modeID}" option has been unchecked`
+ );
+ moreContext.activateItem(moreContext.querySelector(mode.menuID));
+ await uncheckedPromise;
+
+ Assert.ok(
+ !about3Pane.folderPane.activeModes.includes(mode.modeID),
+ `"${mode.modeID}" mode is not included in the active modes array`
+ );
+ if (checkedModesCount > 0) {
+ Assert.equal(
+ about3Pane.folderPane.activeModes.length,
+ checkedModesCount,
+ `Correct amount of active modes after disabling the "${mode.modeID}" mode`
+ );
+ } else {
+ //checks if it automatically checks "all" mode if every other mode was unchecked
+ Assert.equal(
+ about3Pane.folderPane.activeModes.length,
+ 1,
+ `Correct amount of active modes after disabling the "${mode.modeID}" mode`
+ );
+ Assert.equal(
+ about3Pane.folderPane.activeModes.at(0),
+ "all",
+ "The first item is 'all' value"
+ );
+ Assert.ok(
+ moreContext
+ .querySelector("#folderPaneMoreContextAllFolders")
+ .getAttribute("checked"),
+ "'All' toggle is checked"
+ );
+ }
+ checkedModesCount--;
+ }
+ Assert.equal(moreContext.state, "open", "The context menu remains open");
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ folderModesContextMenuPopup,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ menuHiddenPromise = BrowserTestUtils.waitForEvent(moreContext, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+});
+
+add_task(async function testGetMessageContextMenu() {
+ const shownPromise = BrowserTestUtils.waitForEvent(
+ fetchContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ fetchButton,
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ Assert.equal(
+ fetchContext.querySelectorAll("menuitem").length,
+ 2,
+ "2 menuitems should be present in the fetch context"
+ );
+
+ const menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ fetchContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+});
+
+add_task(async function testTotalCountDefaultState() {
+ let totalCountBadge = about3Pane.document.querySelector(".total-count");
+ Assert.ok(
+ !moreContext
+ .querySelector("#folderPaneHeaderToggleTotalCount")
+ .hasAttribute("checked"),
+ "The total count toggle is unchecked"
+ );
+ Assert.ok(totalCountBadge.hidden, "The total count badges are hidden");
+ Assert.notEqual(
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "totalMsgCount",
+ "visible"
+ ),
+ "true",
+ "The customization data was saved"
+ );
+
+ const rootFolder =
+ MailServices.accounts.accounts[0].incomingServer.rootFolder;
+ const inbox = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox);
+ await add_message_sets_to_folders([inbox], [create_thread(10)]);
+ await be_in_folder(inbox);
+
+ about3Pane.folderTree.selectedIndex = 1;
+ let row = about3Pane.folderTree.getRowAtIndex(1);
+ await assertAriaLabel(row, "Inbox, 10 unread messages");
+
+ about3Pane.threadTree.selectedIndex = 0;
+ about3Pane.threadTree.expandRowAtIndex(0);
+ await assertAriaLabel(row, "Inbox, 9 unread messages");
+});
+
+add_task(async function testTotalCountVisible() {
+ let totalCountBadge = about3Pane.document.querySelector(".total-count");
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ // Toggle total count ON.
+ let toggleOnPromise = BrowserTestUtils.waitForCondition(
+ () => !totalCountBadge.hidden,
+ "The total count badges are visible"
+ );
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleTotalCount")
+ );
+ await toggleOnPromise;
+ // Check that toggle was successful.
+ Assert.ok(
+ moreContext
+ .querySelector("#folderPaneHeaderToggleTotalCount")
+ .hasAttribute("checked"),
+ "The total count toggle is checked"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "totalMsgCount",
+ "visible"
+ ) == "true",
+ "The customization data was saved"
+ );
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ let row = about3Pane.folderTree.getRowAtIndex(1);
+ await assertAriaLabel(row, "Inbox, 9 unread messages, 10 total messages");
+});
+
+add_task(async function testFolderSizeDefaultState() {
+ let folderSizeBadge = about3Pane.document.querySelector(".folder-size");
+ Assert.ok(
+ !moreContext
+ .querySelector("#folderPaneHeaderToggleFolderSize")
+ .hasAttribute("checked"),
+ "The folder size toggle is unchecked"
+ );
+ Assert.ok(folderSizeBadge.hidden, "The folder sizes are hidden");
+ Assert.notEqual(
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPaneFolderSize",
+ "visible"
+ ),
+ "true",
+ "The folder size xulStore attribute is set to not visible"
+ );
+});
+
+add_task(async function testFolderSizeVisible() {
+ let folderSizeBadge = about3Pane.document.querySelector(".folder-size");
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ // Toggle folder size ON.
+ let toggleOnPromise = BrowserTestUtils.waitForCondition(
+ () => !folderSizeBadge.hidden,
+ "The folder sizes are visible"
+ );
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleFolderSize")
+ );
+ await toggleOnPromise;
+ // Check that toggle on was successful.
+ Assert.ok(
+ moreContext
+ .querySelector("#folderPaneHeaderToggleFolderSize")
+ .hasAttribute("checked"),
+ "The folder size toggle is checked"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPaneFolderSize",
+ "visible"
+ ) == "true",
+ "The folder size xulStore attribute is set to visible"
+ );
+
+ Assert.ok(!folderSizeBadge.hidden, "The folder sizes are visible");
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ let row = about3Pane.folderTree.getRowAtIndex(1);
+ await assertAriaLabel(
+ row,
+ `Inbox, 9 unread messages, 10 total messages, ${row.folderSize}`
+ );
+});
+
+add_task(async function testFolderSizeHidden() {
+ let folderSizeBadge = about3Pane.document.querySelector(".folder-size");
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ // Toggle folder sizes OFF.
+ let toggleOffPromise = BrowserTestUtils.waitForCondition(
+ () => folderSizeBadge.hidden,
+ "The folder sizes are hidden"
+ );
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleFolderSize")
+ );
+ await toggleOffPromise;
+
+ // Check that toggle was successful.
+ Assert.ok(
+ !moreContext
+ .querySelector("#folderPaneHeaderToggleFolderSize")
+ .getAttribute("checked"),
+ "The folder size toggle is unchecked"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPaneFolderSize",
+ "visible"
+ ) == "false",
+ "The folder size xulStore visible attribute was set to false"
+ );
+
+ Assert.ok(folderSizeBadge.hidden, "The folder sizes are hidden");
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+});
+
+add_task(async function testTotalCountHidden() {
+ let totalCountBadge = about3Pane.document.querySelector(".total-count");
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ // Toggle total count OFF.
+ let toggleOffPromise = BrowserTestUtils.waitForCondition(
+ () => totalCountBadge.hidden,
+ "The total count badges are hidden"
+ );
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleTotalCount")
+ );
+ await toggleOffPromise;
+
+ // Check that toggle was successful.
+ Assert.ok(
+ !moreContext
+ .querySelector("#folderPaneHeaderToggleTotalCount")
+ .getAttribute("checked"),
+ "The total count toggle is unchecked"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "totalMsgCount",
+ "visible"
+ ) == "false",
+ "The customization data was saved"
+ );
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ let row = about3Pane.folderTree.getRowAtIndex(1);
+ await assertAriaLabel(row, "Inbox, 9 unread messages");
+});
+
+add_task(async function testHideLocalFoldersXULStore() {
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleLocalFolders")
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPaneLocalFolders",
+ "hidden"
+ ) == "true",
+ "The customization data to hide local folders should be saved"
+ );
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ Assert.ok(
+ moreContext
+ .querySelector("#folderPaneHeaderToggleLocalFolders")
+ .hasAttribute("checked"),
+ "The hide local folders menuitem should be checked"
+ );
+
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleLocalFolders")
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPaneLocalFolders",
+ "hidden"
+ ) == "false",
+ "The customization data to hide local folders should be saved"
+ );
+});
+
+/**
+ * Ensure that the various badges and labels are updated and maintained when
+ * folders and modes change in the folder pane.
+ */
+add_task(async function testBadgesPersistentState() {
+ let totalCountBadge = about3Pane.document.querySelector(".total-count");
+ let folderSizeBadge = about3Pane.document.querySelector(".folder-size");
+ // Show total count.
+ let toggleOnPromise = BrowserTestUtils.waitForCondition(
+ () => !totalCountBadge.hidden,
+ "The total count badges are visible"
+ );
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleTotalCount")
+ );
+ await toggleOnPromise;
+
+ // Show folder size.
+ toggleOnPromise = BrowserTestUtils.waitForCondition(
+ () => !folderSizeBadge.hidden,
+ "The folder sizes are visible"
+ );
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleFolderSize")
+ );
+ await toggleOnPromise;
+
+ // Hide local folders.
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleLocalFolders")
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "folderPaneLocalFolders",
+ "hidden"
+ ) == "true",
+ "The customization data to hide local folders should be saved"
+ );
+ // The test times out on macOS if we don't wait here before dismissing the
+ // context menu. Unknown why.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 250));
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ // Ensure the badges are still visible.
+ Assert.ok(
+ !totalCountBadge.hidden,
+ "Folder total count badge should be visible"
+ );
+ Assert.ok(!folderSizeBadge.hidden, "Folder size badge should be visible");
+
+ // Create a folder and add messages to that folder to ensure the badges are
+ // visible and they update properly.
+ const rootFolder =
+ MailServices.accounts.accounts[0].incomingServer.rootFolder;
+ rootFolder.createSubfolder("NewlyCreatedTestFolder", null);
+ const folder = rootFolder.getChildNamed("NewlyCreatedTestFolder");
+ await be_in_folder(folder);
+
+ about3Pane.folderTree.selectedIndex = 3;
+ const row = about3Pane.folderTree.getRowAtIndex(3);
+ Assert.equal(
+ row.name,
+ "NewlyCreatedTestFolder",
+ "The correct folder should have been selected"
+ );
+ // Badges shouldn't be hidden even if there's no content.
+ Assert.ok(
+ !row.querySelector(".total-count").hidden,
+ "The total count badge of the newly created folder should be visible"
+ );
+ Assert.ok(
+ !row.querySelector(".folder-size").hidden,
+ "The folder size badge of the newly created folder should be visible"
+ );
+
+ const currentTotal = row.querySelector(".total-count").textContent;
+ const currentSize = row.querySelector(".folder-size").textContent;
+
+ await add_message_sets_to_folders([folder], [create_thread(10)]);
+
+ // Weird issue with the test in which the focus is lost after creating the
+ // messages, and the folder pane doesn't receive the folder size property
+ // changes. This doesn't happen while using the app normally.
+ about3Pane.folderTree.selectedIndex = 0;
+ about3Pane.folderTree.selectedIndex = 3;
+
+ await BrowserTestUtils.waitForCondition(
+ () => currentTotal != row.querySelector(".total-count").textContent,
+ `${currentTotal} != ${
+ row.querySelector(".total-count").textContent
+ } | The total count should have changed after adding messages`
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => currentSize != row.querySelector(".folder-size").textContent,
+ `${currentSize} != ${
+ row.querySelector(".folder-size").textContent
+ } | The folder size should have changed after adding messages`
+ );
+});
+
+add_task(async function testActionButtonsState() {
+ // Delete all accounts to start clean.
+ for (let account of MailServices.accounts.accounts) {
+ MailServices.accounts.removeAccount(account, true);
+ }
+
+ // Confirm that we don't have any account in our test run.
+ Assert.equal(
+ MailServices.accounts.accounts.length,
+ 0,
+ "No account currently configured"
+ );
+
+ Assert.ok(fetchButton.disabled, "The Get Messages button is disabled");
+ Assert.ok(newButton.disabled, "The New Message button is disabled");
+
+ // Create a POP server.
+ let popServer = MailServices.accounts
+ .createIncomingServer("nobody", "foo.invalid", "pop3")
+ .QueryInterface(Ci.nsIPop3IncomingServer);
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "tinderbox@foo.invalid";
+
+ let account = MailServices.accounts.createAccount();
+ account.addIdentity(identity);
+ account.incomingServer = popServer;
+
+ await BrowserTestUtils.waitForCondition(
+ () => !fetchButton.disabled,
+ "The Get Messages button is enabled"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => !newButton.disabled,
+ "The New Message button is enabled"
+ );
+});
diff --git a/comm/mail/test/browser/folder-pane/browser_folderPaneModeContextMenu.js b/comm/mail/test/browser/folder-pane/browser_folderPaneModeContextMenu.js
new file mode 100644
index 0000000000..1e571efa40
--- /dev/null
+++ b/comm/mail/test/browser/folder-pane/browser_folderPaneModeContextMenu.js
@@ -0,0 +1,208 @@
+/* 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 tabmail,
+ about3Pane,
+ folderPane,
+ folderPaneModeContextMenu,
+ folderPaneModeNames,
+ folderPaneModeMoveUpMenuItem,
+ folderPaneModeMoveDownMenuItem;
+
+add_setup(async function () {
+ tabmail = document.getElementById("tabmail");
+ about3Pane = tabmail.currentAbout3Pane;
+ folderPane = about3Pane.folderPane;
+ folderPaneModeContextMenu = about3Pane.document.getElementById(
+ "folderPaneModeContext"
+ );
+ folderPaneModeNames = about3Pane.document.getElementsByClassName("mode-name");
+ folderPaneModeMoveUpMenuItem = about3Pane.document.getElementById(
+ "folderPaneModeMoveUp"
+ );
+ folderPaneModeMoveDownMenuItem = about3Pane.document.getElementById(
+ "folderPaneModeMoveDown"
+ );
+
+ folderPane.activeModes = ["all", "smart", "unread", "favorite", "recent"];
+
+ registerCleanupFunction(() => {
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ });
+});
+
+/**
+ * Tests that ability to swap a folder mode for the one above it, and
+ * ensures that if it's the last element, the option to swap is disabled.
+ */
+add_task(async function testMoveFolderModeUp() {
+ // Find the "Recent" folder pane mode text element as that is the
+ // last folder pane mode.
+ const recentFolderModeName = Array.prototype.find.call(
+ folderPaneModeNames,
+ element => element.parentElement.parentElement.dataset.mode === "recent"
+ );
+
+ // Grab the options element which is next to the text element to open
+ // the context menu.
+ const recentFolderModeOptions = recentFolderModeName.nextElementSibling;
+
+ // Make sure the context menu is visible before continuing/
+ const shownPromise = BrowserTestUtils.waitForEvent(
+ folderPaneModeContextMenu,
+ "popupshown"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(recentFolderModeOptions, {}, about3Pane);
+
+ await shownPromise;
+
+ // Assert initial folder mode positions
+ Assert.equal(
+ folderPane.activeModes.at(-1),
+ "recent",
+ "Recent Folders mode is in the incorrect position."
+ );
+ Assert.equal(
+ folderPane.activeModes.at(-2),
+ "favorite",
+ "Favourite mode is in the incorrect position."
+ );
+
+ // Ensure that the move down element is disabled asit is the last element.
+
+ Assert.equal(
+ folderPaneModeMoveDownMenuItem.getAttribute("disabled"),
+ "true",
+ "Move down element is enabled."
+ );
+
+ const hiddenPromise = BrowserTestUtils.waitForEvent(
+ folderPaneModeContextMenu,
+ "popuphidden"
+ );
+
+ folderPaneModeContextMenu.activateItem(folderPaneModeMoveUpMenuItem);
+
+ await hiddenPromise;
+
+ // Folder mode that was moved up should be swapped with the folder mode
+ // above it in the activeModes array.
+ Assert.equal(
+ folderPane.activeModes.at(-1),
+ "favorite",
+ "Folder pane mode was not moved up."
+ );
+ Assert.equal(
+ folderPane.activeModes.at(-2),
+ "recent",
+ "Folder pane mode was not moved down."
+ );
+});
+
+/**
+ * Tests that ability to swap a folder mode for the one below it.
+ */
+add_task(async function testMoveFolderModeDown() {
+ // Find the "Recent" folder pane mode text element as that is the
+ // second last folder pane mode.
+ const recentFolderModeName = Array.prototype.find.call(
+ folderPaneModeNames,
+ element => element.parentElement.parentElement.dataset.mode === "recent"
+ );
+
+ // Grab the options element which is next to the text element to open
+ // the context menu.
+ const recentFolderModeOptions = recentFolderModeName.nextElementSibling;
+
+ // Make sure the context menu is visible before continuing/
+ const shownPromise = BrowserTestUtils.waitForEvent(
+ folderPaneModeContextMenu,
+ "popupshown"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(recentFolderModeOptions, {}, about3Pane);
+
+ await shownPromise;
+
+ // Assert initial folder mode positions
+ Assert.equal(
+ folderPane.activeModes.at(-1),
+ "favorite",
+ "Favourite folder mode is in the incorrect position."
+ );
+ Assert.equal(
+ folderPane.activeModes.at(-2),
+ "recent",
+ "Recent Folders mode is in the incorrect position."
+ );
+
+ const hiddenPromise = BrowserTestUtils.waitForEvent(
+ folderPaneModeContextMenu,
+ "popuphidden"
+ );
+
+ folderPaneModeContextMenu.activateItem(folderPaneModeMoveDownMenuItem);
+
+ await hiddenPromise;
+
+ // Folder mode that was moved down should be swapped with the folder mode
+ // below it in the activeModes array.
+ Assert.equal(
+ folderPane.activeModes.at(-1),
+ "recent",
+ "Folder pane mode was not moved up."
+ );
+ Assert.equal(
+ folderPane.activeModes.at(-2),
+ "favorite",
+ "Folder pane mode was not moved down."
+ );
+});
+
+/**
+ * Tests that the Move Up menu item on a folder pane mode is disabled when
+ * it is the topmost folder pane mode
+ */
+
+add_task(async function testCantMoveFolderPaneModeUp() {
+ // Find the "All" folder pane mode text element as that is the
+ // first folder pane mode.
+ const allFolderModeName = Array.prototype.find.call(
+ folderPaneModeNames,
+ element => element.parentElement.parentElement.dataset.mode === "all"
+ );
+
+ // Grab the options element which is next to the text element to open
+ // the context menu.
+ const allFolderModeOptions = allFolderModeName.nextElementSibling;
+
+ // Make sure the context menu is visible before continuing/
+ const shownPromise = BrowserTestUtils.waitForEvent(
+ folderPaneModeContextMenu,
+ "popupshown"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(allFolderModeOptions, {}, about3Pane);
+
+ await shownPromise;
+
+ Assert.equal(
+ folderPaneModeMoveUpMenuItem.getAttribute("disabled"),
+ "true",
+ "Move down element is enabled."
+ );
+
+ // Make sure the context menu is hidden before continuing
+ const hiddenPromise = BrowserTestUtils.waitForEvent(
+ folderPaneModeContextMenu,
+ "popuphidden"
+ );
+
+ folderPaneModeContextMenu.hidePopup();
+
+ await hiddenPromise;
+});
diff --git a/comm/mail/test/browser/folder-tree-modes/browser.ini b/comm/mail/test/browser/folder-tree-modes/browser.ini
new file mode 100644
index 0000000000..9ca3fc18e1
--- /dev/null
+++ b/comm/mail/test/browser/folder-tree-modes/browser.ini
@@ -0,0 +1,51 @@
+[DEFAULT]
+prefs =
+ mail.account.account1.server=server1
+ mail.account.account2.identities=id1,id2
+ mail.account.account2.server=server2
+ mail.accountmanager.accounts=account1,account2
+ mail.accountmanager.defaultaccount=account2
+ mail.accountmanager.localfoldersserver=server1
+ mail.identity.id1.fullName=Tinderbox
+ mail.identity.id1.htmlSigFormat=false
+ mail.identity.id1.smtpServer=smtp1
+ mail.identity.id1.useremail=tinderbox@foo.invalid
+ mail.identity.id1.valid=true
+ mail.identity.id2.fullName=Tinderboxpushlog
+ mail.identity.id2.htmlSigFormat=true
+ mail.identity.id2.smtpServer=smtp1
+ mail.identity.id2.useremail=tinderboxpushlog@foo.invalid
+ mail.identity.id2.valid=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server1.type=none
+ mail.server.server1.userName=nobody
+ mail.server.server2.check_new_mail=false
+ mail.server.server2.directory-rel=[ProfD]Mail/tinderbox
+ mail.server.server2.download_on_biff=true
+ mail.server.server2.hostname=tinderbox123
+ mail.server.server2.login_at_startup=false
+ mail.server.server2.name=tinderbox@foo.invalid
+ mail.server.server2.type=pop3
+ mail.server.server2.userName=tinderbox
+ mail.server.server2.whiteListAbURI=
+ mail.shell.checkDefaultClient=false
+ mail.smtp.defaultserver=smtp1
+ mail.smtpserver.smtp1.hostname=tinderbox123
+ mail.smtpserver.smtp1.username=tinderbox
+ mail.smtpservers=smtp1
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ ui.prefersReducedMotion=1
+subsuite = thunderbird
+support-files = test-extension/**
+
+[browser_customFolderTreeMode.js]
+skip-if = true # No longer supported.
+[browser_customSmartFolder.js]
+skip-if = true # No longer supported.
+[browser_modeSwitching.js]
+[browser_smartFolders.js]
+[browser_unreadFolders.js]
diff --git a/comm/mail/test/browser/folder-tree-modes/browser_customFolderTreeMode.js b/comm/mail/test/browser/folder-tree-modes/browser_customFolderTreeMode.js
new file mode 100644
index 0000000000..49189c9254
--- /dev/null
+++ b/comm/mail/test/browser/folder-tree-modes/browser_customFolderTreeMode.js
@@ -0,0 +1,142 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/*
+ * Tests for custom folder tree modes. The test mode is provided by the test
+ * extension in the test-extension subdirectory.
+ */
+
+"use strict";
+
+var {
+ assert_folder_mode,
+ assert_folder_visible,
+ FAKE_SERVER_HOSTNAME,
+ get_special_folder,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window, plan_for_new_window, wait_for_new_window } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var gInbox;
+
+add_setup(async function () {
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ gInbox = await get_special_folder(Ci.nsMsgFolderFlags.Inbox, false, server);
+
+ ExtensionSupport.registerWindowListener("mochitest", {
+ chromeURLs: ["chrome://messenger/content/messenger.xhtml"],
+ onLoadWindow(aWindow) {
+ let testFolderTreeMode = {
+ __proto__: aWindow.IFolderTreeMode,
+ generateMap(aFTV) {
+ var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ // Pick the tinderbox@foo.invalid inbox and use it as the only folder
+ let server = MailServices.accounts.findServer(
+ "tinderbox",
+ "tinderbox123",
+ "pop3"
+ );
+ let item = [];
+ let inbox = new aWindow.FtvItem(
+ server.rootFolder.getChildNamed("Inbox")
+ );
+ inbox.__defineGetter__("children", () => []);
+ item.push(inbox);
+
+ if (aWindow.gFolderTreeView.activeModes.length > 1) {
+ item.unshift(new aWindow.FtvItemHeader("Test%20Mode", "testmode"));
+ }
+
+ return item;
+ },
+ };
+
+ aWindow.gFolderTreeView.registerFolderTreeMode(
+ "testmode",
+ testFolderTreeMode,
+ "Test Mode"
+ );
+ },
+ });
+});
+
+// Provided by the extension in test-extension.
+var kTestModeID = "testmode";
+
+/**
+ * Switch to the mode and verify that it displays correctly.
+ */
+add_task(function test_switch_to_test_mode() {
+ mc.folderTreeView.activeModes = kTestModeID;
+ // Hide the all folder view mode.
+ mc.folderTreeView.activeModes = "all";
+
+ assert_folder_mode(kTestModeID);
+ assert_folder_visible(gInbox);
+});
+
+/**
+ * Open a new 3-pane window while the custom mode is selected, and make sure
+ * that the mode displayed in the new window is the custom mode.
+ */
+add_task(async function test_open_new_window_with_custom_mode() {
+ // Our selection may get lost while changing modes, and be_in_folder is
+ // not sufficient to ensure actual selection.
+ mc.folderTreeView.selectFolder(gInbox);
+
+ plan_for_new_window("mail:3pane");
+ mc.window.MsgOpenNewWindowForFolder(null, -1);
+ let mc2 = wait_for_new_window("mail:3pane");
+
+ await TestUtils.waitForCondition(() => mc2.folderTreeView.isInited);
+ assert_folder_mode(kTestModeID, mc2);
+ assert_folder_visible(gInbox, mc2);
+
+ close_window(mc2);
+});
+
+/**
+ * Switch back to all folders.
+ */
+add_task(function test_switch_to_all_folders() {
+ // Hide the test mode view enabled in the previous test. The activeModes
+ // setter should take care of restoring the "all" view and prevent and empty
+ // Folder pane.
+ mc.folderTreeView.activeModes = kTestModeID;
+ assert_folder_mode("all");
+});
+
+registerCleanupFunction(() => {
+ mc.window.gFolderTreeView.unregisterFolderTreeMode(kTestModeID);
+ ExtensionSupport.unregisterWindowListener("mochitest");
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+
+ // 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;
+ window.gFolderDisplay.tree.focus();
+});
diff --git a/comm/mail/test/browser/folder-tree-modes/browser_customSmartFolder.js b/comm/mail/test/browser/folder-tree-modes/browser_customSmartFolder.js
new file mode 100644
index 0000000000..0f0e663445
--- /dev/null
+++ b/comm/mail/test/browser/folder-tree-modes/browser_customSmartFolder.js
@@ -0,0 +1,211 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/*
+ * Tests for custom folder tree modes. The test mode is provided by the test
+ * extension in the test-extension subdirectory.
+ */
+
+"use strict";
+
+var {
+ assert_folder_collapsed,
+ assert_folder_displayed,
+ assert_folder_expanded,
+ assert_folder_mode,
+ assert_folder_not_visible,
+ assert_folder_selected_and_displayed,
+ assert_folder_visible,
+ collapse_folder,
+ expand_folder,
+ get_smart_folder_named,
+ inboxFolder,
+ make_message_sets_in_folders,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+// spaces in the name are intentional
+var smartParentNameA = "My Smart Folder A";
+var smartParentNameB = "My Smart Folder B";
+
+var rootFolder;
+var inboxSubfolder, subfolderA, subfolderB;
+var smartFolderInbox;
+var smartFolderA;
+
+var nsMsgFolderFlags = Ci.nsMsgFolderFlags;
+
+/**
+ * create two smart folder types and two real folders, one for each
+ * smart folder type
+ */
+add_setup(async function () {
+ rootFolder = inboxFolder.server.rootFolder;
+
+ // register a new smart folder type
+ mc.folderTreeView
+ .getFolderTreeMode("smart")
+ .addSmartFolderType(smartParentNameA, false, false);
+ mc.folderTreeView
+ .getFolderTreeMode("smart")
+ .addSmartFolderType(smartParentNameB, false, false);
+
+ // Create a folder as a subfolder of the inbox
+ inboxFolder.createSubfolder("smartFolderA", null);
+ subfolderA = inboxFolder.getChildNamed("smartFolderA");
+ inboxFolder.createSubfolder("smartFolderB", null);
+ subfolderB = inboxFolder.getChildNamed("smartFolderB");
+
+ // This is how folders are marked to match a custom smart folder
+ // The name is added to a cache, as msgDatabase access in nsITreeView is
+ // bad perf.
+ mc.window.setSmartFolderName(subfolderA, smartParentNameA);
+ mc.window.setSmartFolderName(subfolderB, smartParentNameB);
+
+ // The message itself doesn't really matter, as long as there's at least one
+ // in the folder.
+ await make_message_sets_in_folders([subfolderA], [{ count: 1 }]);
+ await make_message_sets_in_folders([subfolderB], [{ count: 1 }]);
+});
+
+/**
+ * Switch to the smart folder mode, get the smart inbox.
+ */
+add_task(function test_switch_to_smart_folder_mode() {
+ mc.folderTreeView.activeModes = "smart";
+ // Hide the all folders view.
+ mc.folderTreeView.activeModes = "all";
+ assert_folder_mode("smart");
+
+ smartFolderA = get_smart_folder_named(smartParentNameA);
+ SimpleTest.expectUncaughtException();
+ mc.folderTreeView.selectFolder(smartFolderA);
+});
+
+add_task(function test_cache_property() {
+ if (mc.window.getSmartFolderName(subfolderA) != smartParentNameA) {
+ throw new Error("smartFolderName A cache property not set");
+ }
+ if (mc.window.getSmartFolderName(subfolderB) != smartParentNameB) {
+ throw new Error("smartFolderName B cache property not set");
+ }
+});
+
+function _test_smart_folder_type(folder, parentName) {
+ let smartMode = mc.folderTreeView.getFolderTreeMode("smart");
+ let [flag, name] = smartMode._getSmartFolderType(folder);
+ if (flag != 0) {
+ throw new Error(
+ "custom smart folder definition [" + parentName + "] has a flag"
+ );
+ }
+ if (name != parentName) {
+ throw new Error(
+ "custom smart folder [" +
+ folder.name +
+ "] is incorrect [" +
+ name +
+ "] should be [" +
+ parentName +
+ "]"
+ );
+ }
+}
+
+add_task(function test_smart_folder_type() {
+ _test_smart_folder_type(subfolderA, smartParentNameA);
+ _test_smart_folder_type(subfolderB, smartParentNameB);
+});
+
+/**
+ * Test that our custom smart folders exist
+ */
+
+add_task(function test_custom_folder_exists() {
+ assert_folder_mode("smart");
+ assert_folder_displayed(smartFolderA);
+ // this is our custom smart folder parent created in folderPane.js
+ mc.folderTreeView.selectFolder(subfolderA);
+ assert_folder_selected_and_displayed(subfolderA);
+});
+
+function FTVItemHasChild(parentFTVItem, childFolder, recurse) {
+ for (let child of parentFTVItem.children) {
+ if (
+ child._folder.URI == childFolder.URI ||
+ (recurse && FTVItemHasChild(child, childFolder, recurse))
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * test that our real smart folder is in fact a child if the correct
+ * smart folder parent
+ */
+add_task(function test_smart_child_parent_relationship() {
+ let folderIndex = assert_folder_visible(smartFolderA);
+ let folderFTVItem = mc.folderTreeView.getFTVItemForIndex(folderIndex);
+ if (!FTVItemHasChild(folderFTVItem, subfolderA, false)) {
+ throw new Error(
+ "Folder: " +
+ subfolderA.name +
+ " is not a child of our smart parent folder"
+ );
+ }
+ assert_folder_mode("smart");
+});
+
+/**
+ * test that our real smart folder is NOT a child of the smart inbox in the
+ * tree view.
+ */
+add_task(function test_real_child_parent_relationship() {
+ smartFolderInbox = get_smart_folder_named("Inbox");
+ expand_folder(smartFolderInbox);
+ // the real parent should be an Inbox
+ let folderIndex = assert_folder_visible(subfolderA.parent);
+ let folderFTVItem = mc.folderTreeView.getFTVItemForIndex(folderIndex);
+ // in the tree, subfolder is a child of our magic smart folder, and should not
+ // be a child of inbox
+ if (FTVItemHasChild(folderFTVItem, subfolderA, true)) {
+ throw new Error(
+ "Folder: " + subfolderA.name + " should not be a child of an inbox"
+ );
+ }
+ assert_folder_mode("smart");
+});
+
+/**
+ * test collapse/expand states of one of our smart folders
+ */
+add_task(function test_smart_subfolder() {
+ assert_folder_mode("smart");
+ collapse_folder(smartFolderA);
+ assert_folder_collapsed(smartFolderA);
+ assert_folder_not_visible(subfolderA);
+
+ expand_folder(smartFolderA);
+ assert_folder_expanded(smartFolderA);
+ assert_folder_visible(subfolderA);
+});
+
+/**
+ * Switch back to all folders.
+ */
+add_task(function test_return_to_all_folders() {
+ assert_folder_mode("smart");
+ mc.folderTreeView.activeModes = "smart";
+ assert_folder_mode("all");
+});
+
+registerCleanupFunction(function () {
+ inboxFolder.propagateDelete(subfolderA, true);
+ inboxFolder.propagateDelete(subfolderB, true);
+});
diff --git a/comm/mail/test/browser/folder-tree-modes/browser_modeSwitching.js b/comm/mail/test/browser/folder-tree-modes/browser_modeSwitching.js
new file mode 100644
index 0000000000..dee646c754
--- /dev/null
+++ b/comm/mail/test/browser/folder-tree-modes/browser_modeSwitching.js
@@ -0,0 +1,338 @@
+/* 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 the ability to switch between multiple folder modes.
+ */
+
+"use strict";
+
+var {
+ assert_folder_visible,
+ inboxFolder,
+ make_message_sets_in_folders,
+ mc,
+ toggle_main_menu,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { MailTelemetryForTests } = ChromeUtils.import(
+ "resource:///modules/MailGlue.jsm"
+);
+var { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+var { click_menus_in_sequence, click_through_appmenu, close_popup_sequence } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var rootFolder;
+var unreadFolder;
+var favoriteFolder;
+var modeList_menu;
+var modeList_appmenu;
+var view_menu;
+var view_menupopup;
+var appmenu_button;
+var appmenu_mainView;
+var appmenu_popup;
+var menu_state;
+var about3Pane;
+
+add_setup(async function () {
+ rootFolder = inboxFolder.server.rootFolder;
+
+ // Create one folder with unread messages and one favorite folder.
+ inboxFolder.createSubfolder("UnreadFolder", null);
+ unreadFolder = inboxFolder.getChildNamed("UnreadFolder");
+
+ inboxFolder.createSubfolder("FavoriteFolder", null);
+ favoriteFolder = inboxFolder.getChildNamed("FavoriteFolder");
+
+ await make_message_sets_in_folders([unreadFolder], [{ count: 1 }]);
+ favoriteFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ modeList_menu = mc.window.document.getElementById("menu_FolderViewsPopup");
+ modeList_appmenu = mc.window.document.getElementById("appMenu-foldersView");
+
+ view_menu = mc.window.document.getElementById("menu_View");
+ view_menupopup = mc.window.document.getElementById("menu_View_Popup");
+ appmenu_button = mc.window.document.getElementById("button-appmenu");
+ appmenu_mainView = mc.window.document.getElementById("appMenu-mainView");
+ appmenu_popup = mc.window.document.getElementById("appMenu-popup");
+
+ // Main menu is needed for this whole test file.
+ menu_state = toggle_main_menu(true);
+
+ about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ Services.telemetry.clearScalars();
+});
+
+/**
+ * Check whether the expected folder mode is selected in menus and internally.
+ *
+ * @param {string} aMode - The name of the expected mode.
+ */
+async function assert_mode_selected(aMode) {
+ if (aMode != "compact") {
+ // "compact" isn't really a mode, we're just using this function because
+ // it tests everything we want to test.
+ Assert.ok(about3Pane.folderPane.activeModes.includes(aMode));
+ }
+
+ // We need to open the menu because only then the right mode is set in them.
+ if (["linux", "win"].includes(AppConstants.platform)) {
+ // On OS X the main menu seems not accessible for clicking from tests.
+ EventUtils.synthesizeMouseAtCenter(view_menu, { clickCount: 1 }, mc.window);
+ let popuplist = await click_menus_in_sequence(
+ view_menupopup,
+ [{ id: modeList_menu.parentNode.id }],
+ true
+ );
+ for (let mode of about3Pane.folderPane.activeModes) {
+ Assert.ok(
+ modeList_menu.querySelector(`[value="${mode}"]`).hasAttribute("checked")
+ );
+ }
+ close_popup_sequence(popuplist);
+ }
+
+ EventUtils.synthesizeMouseAtCenter(appmenu_button, {}, mc.window);
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_FolderViews" }],
+ null,
+ mc.window
+ );
+ for (let mode of about3Pane.folderPane.activeModes) {
+ Assert.ok(
+ modeList_appmenu
+ .querySelector(`[value="${mode}"]`)
+ .hasAttribute("checked")
+ );
+ }
+ appmenu_popup.hidePopup();
+}
+
+/**
+ * Check whether the expected folder mode is unselected in menus and internally.
+ *
+ * @param {string} mode - The name of the missing mode.
+ */
+async function assert_mode_not_selected(mode) {
+ Assert.ok(!about3Pane.folderPane.activeModes.includes(mode));
+
+ // We need to open the menu because only then the right mode is set in them.
+ if (["linux", "win"].includes(AppConstants.platform)) {
+ // On OS X the main menu seems not accessible for clicking from tests.
+ EventUtils.synthesizeMouseAtCenter(view_menu, { clickCount: 1 }, mc.window);
+ let popuplist = await click_menus_in_sequence(
+ view_menupopup,
+ [{ id: modeList_menu.parentNode.id }],
+ true
+ );
+ Assert.ok(
+ !modeList_menu.querySelector(`[value="${mode}"]`).hasAttribute("checked")
+ );
+ close_popup_sequence(popuplist);
+ }
+
+ EventUtils.synthesizeMouseAtCenter(appmenu_button, {}, mc.window);
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_FolderViews" }],
+ null,
+ mc.window
+ );
+ Assert.ok(
+ !modeList_appmenu.querySelector(`[value="${mode}"]`).hasAttribute("checked")
+ );
+ appmenu_popup.hidePopup();
+}
+
+/**
+ * Toggle the folder mode by clicking in the menu.
+ *
+ * @param mode The base name of the mode to select.
+ */
+function select_mode_in_menu(mode) {
+ EventUtils.synthesizeMouseAtCenter(appmenu_button, {}, mc.window);
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_FolderViews" }],
+ { value: mode },
+ mc.window
+ );
+ appmenu_popup.hidePopup();
+}
+
+/**
+ * Check the all folders mode.
+ */
+async function subtest_toggle_all_folders(show) {
+ let mode = "all";
+ select_mode_in_menu(mode);
+
+ if (show) {
+ await assert_mode_selected(mode);
+ } else {
+ await assert_mode_not_selected(mode);
+ }
+}
+
+/**
+ * Check the unread folders mode.
+ */
+async function subtest_toggle_unread_folders(show) {
+ let mode = "unread";
+ select_mode_in_menu(mode);
+
+ if (show) {
+ await assert_mode_selected(mode);
+
+ // Mode is hierarchical, parent folders are shown.
+ assert_folder_visible(inboxFolder.server.rootFolder);
+ assert_folder_visible(inboxFolder);
+ assert_folder_visible(unreadFolder);
+ } else {
+ await assert_mode_not_selected(mode);
+ }
+}
+
+/**
+ * Check the favorite folders mode.
+ */
+async function subtest_toggle_favorite_folders(show) {
+ let mode = "favorite";
+ select_mode_in_menu(mode);
+
+ if (show) {
+ await assert_mode_selected(mode);
+
+ // Mode is hierarchical, parent folders are shown.
+ assert_folder_visible(inboxFolder.server.rootFolder);
+ assert_folder_visible(inboxFolder);
+ assert_folder_visible(favoriteFolder);
+ } else {
+ await assert_mode_not_selected(mode);
+ }
+}
+
+/**
+ * Check the recent folders mode.
+ */
+async function subtest_toggle_recent_folders(show) {
+ let mode = "recent";
+ select_mode_in_menu(mode);
+
+ if (show) {
+ await assert_mode_selected(mode);
+ } else {
+ await assert_mode_not_selected(mode);
+ }
+}
+
+/**
+ * Check the smart folders mode.
+ */
+async function subtest_toggle_smart_folders(show) {
+ let mode = "smart";
+ select_mode_in_menu(mode);
+
+ if (show) {
+ await assert_mode_selected(mode);
+ } else {
+ await assert_mode_not_selected(mode);
+ }
+}
+
+/**
+ * Toggle the compact mode.
+ */
+async function subtest_toggle_compact(compact) {
+ let mode = "compact";
+ select_mode_in_menu(mode);
+
+ if (compact) {
+ await assert_mode_selected(mode);
+ } else {
+ await assert_mode_not_selected(mode);
+ }
+}
+
+/**
+ * Toggle the compact mode.
+ */
+async function subtest_toggle_tags(show) {
+ let mode = "tags";
+ select_mode_in_menu(mode);
+
+ if (show) {
+ await assert_mode_selected(mode);
+ } else {
+ await assert_mode_not_selected(mode);
+ }
+}
+
+/**
+ * Check that the current mode(s) are accurately recorded in telemetry.
+ * Note that `reportUIConfiguration` usually only runs at start-up.
+ */
+function check_scalars(expected) {
+ MailTelemetryForTests.reportUIConfiguration();
+ let scalarName = "tb.ui.configuration.folder_tree_modes";
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+ if (expected) {
+ TelemetryTestUtils.assertScalar(scalars, scalarName, expected);
+ } else {
+ TelemetryTestUtils.assertScalarUnset(scalars, scalarName);
+ }
+}
+
+/**
+ * Toggle folder modes through different means and sequences.
+ */
+add_task(async function test_toggling_modes() {
+ check_scalars();
+
+ await subtest_toggle_all_folders(true);
+ await subtest_toggle_smart_folders(true);
+ check_scalars("all,smart");
+
+ await subtest_toggle_tags(true);
+ check_scalars("all,smart,tags");
+
+ await subtest_toggle_unread_folders(true);
+ await subtest_toggle_favorite_folders(true);
+ await subtest_toggle_recent_folders(true);
+ check_scalars("all,smart,tags,unread,favorite,recent");
+
+ await subtest_toggle_compact(true);
+ check_scalars("all,smart,tags,unread,favorite,recent (compact)");
+
+ await subtest_toggle_unread_folders(false);
+ check_scalars("all,smart,tags,favorite,recent (compact)");
+
+ await subtest_toggle_compact(false);
+ check_scalars("all,smart,tags,favorite,recent");
+
+ await subtest_toggle_favorite_folders(false);
+ check_scalars("all,smart,tags,recent");
+
+ await subtest_toggle_all_folders(false);
+ await subtest_toggle_recent_folders(false);
+ await subtest_toggle_smart_folders(false);
+ await subtest_toggle_tags(false);
+
+ // Confirm that the all folders mode is visible even after all the modes have
+ // been deselected in order to ensure that the Folder Pane is never empty.
+ await assert_mode_selected("all");
+ check_scalars("all");
+});
+
+registerCleanupFunction(function () {
+ inboxFolder.propagateDelete(unreadFolder, true);
+ inboxFolder.propagateDelete(favoriteFolder, true);
+ toggle_main_menu(menu_state);
+});
diff --git a/comm/mail/test/browser/folder-tree-modes/browser_smartFolders.js b/comm/mail/test/browser/folder-tree-modes/browser_smartFolders.js
new file mode 100644
index 0000000000..47fdca0058
--- /dev/null
+++ b/comm/mail/test/browser/folder-tree-modes/browser_smartFolders.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/. */
+
+/*
+ * Test that the smart folder mode works properly.
+ */
+
+"use strict";
+
+var {
+ archive_selected_messages,
+ expand_folder,
+ FAKE_SERVER_HOSTNAME,
+ get_about_3pane,
+ get_smart_folder_named,
+ get_special_folder,
+ inboxFolder,
+ make_message_sets_in_folders,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var about3Pane;
+var rootFolder;
+var inboxSubfolder;
+var trashFolder;
+var trashSubfolder;
+
+var smartInboxFolder;
+
+var inboxSet;
+
+add_setup(async function () {
+ about3Pane = get_about_3pane();
+ rootFolder = inboxFolder.server.rootFolder;
+ // Create a folder as a subfolder of the inbox
+ inboxFolder.createSubfolder("SmartFoldersA", null);
+ inboxSubfolder = inboxFolder.getChildNamed("SmartFoldersA");
+
+ trashFolder = inboxFolder.server.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Trash
+ );
+ trashFolder.createSubfolder("SmartFoldersB", null);
+ trashSubfolder = trashFolder.getChildNamed("SmartFoldersB");
+
+ // The message itself doesn't really matter, as long as there's at least one
+ // in the folder.
+ [inboxSet] = await make_message_sets_in_folders(
+ [inboxFolder],
+ [{ count: 1 }]
+ );
+ await make_message_sets_in_folders([inboxSubfolder], [{ count: 1 }]);
+
+ // Switch to the smart folder mode.
+ about3Pane.folderPane.activeModes = ["smart"];
+
+ // The smart inbox may not have been created at setup time, so get it now.
+ smartInboxFolder = get_smart_folder_named("Inbox");
+});
+
+/**
+ * Test that smart folders are updated when the folders they should be
+ * searching over are added/removed or have the relevant flag set/cleared.
+ */
+add_task(async function test_folder_flag_changes() {
+ expand_folder(smartInboxFolder);
+ // Now attempt to select the folder.
+ about3Pane.displayFolder(inboxSubfolder);
+ // Need to archive two messages in two different accounts in order to
+ // create a smart Archives folder.
+ select_click_row(0);
+ archive_selected_messages();
+ let pop3Server = MailServices.accounts.findServer(
+ "tinderbox",
+ FAKE_SERVER_HOSTNAME,
+ "pop3"
+ );
+ let pop3Inbox = await get_special_folder(
+ Ci.nsMsgFolderFlags.Inbox,
+ false,
+ pop3Server
+ );
+ await make_message_sets_in_folders([pop3Inbox], [{ count: 1 }]);
+ about3Pane.displayFolder(pop3Inbox);
+ select_click_row(0);
+ archive_selected_messages();
+
+ let smartArchiveFolder = get_smart_folder_named("Archives");
+ let archiveScope =
+ "|" +
+ smartArchiveFolder.msgDatabase.dBFolderInfo.getCharProperty(
+ "searchFolderUri"
+ ) +
+ "|";
+ // We should have both this account, and a folder corresponding
+ // to this year in the scope.
+ rootFolder = inboxFolder.server.rootFolder;
+ let archiveFolder = rootFolder.getChildNamed("Archives");
+ assert_folder_and_children_in_scope(archiveFolder, archiveScope);
+ archiveFolder = pop3Server.rootFolder.getChildNamed("Archives");
+ assert_folder_and_children_in_scope(archiveFolder, archiveScope);
+
+ // Remove the archive flag, and make sure the archive folder and
+ // its children are no longer in the search scope.
+ archiveFolder.clearFlag(Ci.nsMsgFolderFlags.Archive);
+
+ // Refresh the archive scope because clearing the flag should have
+ // changed it.
+ archiveScope =
+ "|" +
+ smartArchiveFolder.msgDatabase.dBFolderInfo.getCharProperty(
+ "searchFolderUri"
+ ) +
+ "|";
+
+ // figure out what we expect the archiveScope to now be.
+ rootFolder = inboxFolder.server.rootFolder;
+ let localArchiveFolder = rootFolder.getChildNamed("Archives");
+ let desiredScope = "|" + localArchiveFolder.URI + "|";
+ for (let folder of localArchiveFolder.descendants) {
+ desiredScope += folder.URI + "|";
+ }
+
+ Assert.equal(
+ archiveScope,
+ desiredScope,
+ "archive scope after removing folder"
+ );
+ assert_folder_and_children_not_in_scope(archiveFolder, archiveScope);
+});
+
+function assert_folder_and_children_in_scope(folder, searchScope) {
+ let folderURI = "|" + folder.URI + "|";
+ assert_uri_found(folderURI, searchScope);
+ for (let f of folder.descendants) {
+ assert_uri_found(f.URI, searchScope);
+ }
+}
+
+function assert_folder_and_children_not_in_scope(folder, searchScope) {
+ let folderURI = "|" + folder.URI + "|";
+ assert_uri_not_found(folderURI, searchScope);
+ for (let f of folder.descendants) {
+ assert_uri_not_found(f.URI, searchScope);
+ }
+}
+
+function assert_uri_found(folderURI, scopeList) {
+ if (!scopeList.includes(folderURI)) {
+ throw new Error("scope " + scopeList + "doesn't contain " + folderURI);
+ }
+}
+
+function assert_uri_not_found(folderURI, scopeList) {
+ if (scopeList.includes(folderURI)) {
+ throw new Error(
+ "scope " + scopeList + "contains " + folderURI + " but shouldn't"
+ );
+ }
+}
+
+registerCleanupFunction(async function () {
+ about3Pane.folderPane.activeModes = ["all"];
+ inboxFolder.propagateDelete(inboxSubfolder, true);
+ inboxFolder.deleteMessages(
+ [...inboxFolder.messages],
+ top.msgWindow,
+ false,
+ false,
+ null,
+ false
+ );
+ trashFolder.propagateDelete(trashSubfolder, true);
+});
diff --git a/comm/mail/test/browser/folder-tree-modes/browser_unreadFolders.js b/comm/mail/test/browser/folder-tree-modes/browser_unreadFolders.js
new file mode 100644
index 0000000000..ffc6dc96ac
--- /dev/null
+++ b/comm/mail/test/browser/folder-tree-modes/browser_unreadFolders.js
@@ -0,0 +1,91 @@
+/* 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 that the unread folder mode works properly. This includes making
+ * sure that the selected folder is maintained correctly when the view
+ * is rebuilt because a folder has become newly unread.
+ */
+
+"use strict";
+
+var {
+ assert_folder_visible,
+ be_in_folder,
+ delete_messages,
+ get_about_3pane,
+ inboxFolder,
+ make_message_sets_in_folders,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var about3Pane;
+var rootFolder;
+var inboxSubfolder;
+var trashFolder;
+var trashSubfolder;
+var inboxSet;
+
+add_setup(async function () {
+ about3Pane = get_about_3pane();
+ rootFolder = inboxFolder.server.rootFolder;
+
+ // Create a folder as a subfolder of the inbox
+ inboxFolder.createSubfolder("UnreadFoldersA", null);
+ inboxSubfolder = inboxFolder.getChildNamed("UnreadFoldersA");
+
+ trashFolder = inboxFolder.server.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Trash
+ );
+ trashFolder.createSubfolder("UnreadFoldersB", null);
+ trashSubfolder = trashFolder.getChildNamed("UnreadFoldersB");
+
+ // The message itself doesn't really matter, as long as there's at least one
+ // in the folder.
+ [inboxSet] = await make_message_sets_in_folders(
+ [inboxFolder],
+ [{ count: 1 }]
+ );
+ await make_message_sets_in_folders([inboxSubfolder], [{ count: 1 }]);
+
+ // Switch to the unread folder mode.
+ await be_in_folder(inboxFolder);
+ about3Pane.folderPane.activeModes = ["unread"];
+});
+
+/**
+ * Test that inbox and inboxSubfolder are in view
+ */
+add_task(async function test_folder_population() {
+ about3Pane.folderTree.expandRowAtIndex(0);
+ await new Promise(resolve => setTimeout(resolve));
+ assert_folder_visible(inboxFolder);
+
+ about3Pane.folderTree.expandRowAtIndex(1);
+ await new Promise(resolve => setTimeout(resolve));
+ assert_folder_visible(inboxSubfolder);
+});
+
+/**
+ * Test that a folder newly getting unread messages doesn't
+ * change the selected folder in unread folders mode.
+ */
+add_task(async function test_newly_added_folder() {
+ let [newSet] = await make_message_sets_in_folders(
+ [trashFolder],
+ [{ count: 1 }]
+ );
+ assert_folder_visible(trashFolder);
+ Assert.equal(about3Pane.folderTree.selectedIndex, 0);
+ await delete_messages(newSet);
+});
+
+registerCleanupFunction(async function () {
+ inboxFolder.propagateDelete(inboxSubfolder, true);
+ await delete_messages(inboxSet);
+ trashFolder.propagateDelete(trashSubfolder, true);
+ about3Pane.folderPane.activeModes = ["all"];
+});
diff --git a/comm/mail/test/browser/folder-widget/browser.ini b/comm/mail/test/browser/folder-widget/browser.ini
new file mode 100644
index 0000000000..be7ffca8f5
--- /dev/null
+++ b/comm/mail/test/browser/folder-widget/browser.ini
@@ -0,0 +1,44 @@
+[DEFAULT]
+prefs =
+ mail.account.account1.server=server1
+ mail.account.account2.identities=id1,id2
+ mail.account.account2.server=server2
+ mail.accountmanager.accounts=account1,account2
+ mail.accountmanager.defaultaccount=account2
+ mail.accountmanager.localfoldersserver=server1
+ mail.identity.id1.fullName=Tinderbox
+ mail.identity.id1.htmlSigFormat=false
+ mail.identity.id1.smtpServer=smtp1
+ mail.identity.id1.useremail=tinderbox@foo.invalid
+ mail.identity.id1.valid=true
+ mail.identity.id2.fullName=Tinderboxpushlog
+ mail.identity.id2.htmlSigFormat=true
+ mail.identity.id2.smtpServer=smtp1
+ mail.identity.id2.useremail=tinderboxpushlog@foo.invalid
+ mail.identity.id2.valid=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server1.type=none
+ mail.server.server1.userName=nobody
+ mail.server.server2.check_new_mail=false
+ mail.server.server2.directory-rel=[ProfD]Mail/tinderbox
+ mail.server.server2.download_on_biff=true
+ mail.server.server2.hostname=tinderbox123
+ mail.server.server2.login_at_startup=false
+ mail.server.server2.name=tinderbox@foo.invalid
+ mail.server.server2.type=pop3
+ mail.server.server2.userName=tinderbox
+ mail.server.server2.whiteListAbURI=
+ mail.shell.checkDefaultClient=false
+ mail.smtp.defaultserver=smtp1
+ mail.smtpserver.smtp1.hostname=tinderbox123
+ mail.smtpserver.smtp1.username=tinderbox
+ mail.smtpservers=smtp1
+ mail.spotlight.firstRunDone=true
+ mail.startup.enabledMailCheckOnce=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_messageFilters.js]
diff --git a/comm/mail/test/browser/folder-widget/browser_messageFilters.js b/comm/mail/test/browser/folder-widget/browser_messageFilters.js
new file mode 100644
index 0000000000..3477f1579b
--- /dev/null
+++ b/comm/mail/test/browser/folder-widget/browser_messageFilters.js
@@ -0,0 +1,396 @@
+/* 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 various properties of the message filters.
+ */
+
+"use strict";
+
+var { create_ldap_address_book } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AddressBookHelpers.jsm"
+);
+var {
+ be_in_folder,
+ close_popup,
+ create_folder,
+ make_message_sets_in_folders,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { NNTP_PORT, setupLocalServer, setupNNTPDaemon } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NNTPHelpers.jsm"
+);
+var {
+ close_window,
+ plan_for_modal_dialog,
+ plan_for_new_window,
+ plan_for_window_close,
+ wait_for_existing_window,
+ wait_for_modal_dialog,
+ wait_for_new_window,
+ wait_for_window_focused,
+ wait_for_window_close,
+ click_menus_in_sequence,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { gMockPromptService } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PromptHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folderA, NNTPAccount;
+
+add_setup(async function () {
+ setupNNTPDaemon();
+
+ folderA = await create_folder("FolderToolbarA");
+ // we need one message to select and open
+ await make_message_sets_in_folders([folderA], [{ count: 1 }]);
+
+ const server = setupLocalServer(NNTP_PORT);
+ NNTPAccount = MailServices.accounts.FindAccountForServer(server);
+
+ registerCleanupFunction(() => {
+ folderA.deleteSelf(null);
+ MailServices.accounts.removeAccount(NNTPAccount);
+ // 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;
+ });
+});
+
+/**
+ * Tests the keyboard navigation on the message filters window, ensures that the
+ * new fitler toolbarbutton and it's dropdown work correctly.
+ */
+add_task(async function key_navigation_test() {
+ await openFiltersDialogs();
+
+ const filterc = wait_for_existing_window("mailnews:filterlist");
+ const filterWinDoc = filterc.window.document;
+ const BUTTONS_SELECTOR = `toolbarbutton:not([disabled="true"],[is="toolbarbutton-menu-button"]),dropmarker, button:not([hidden])`;
+ const filterButtonList = filterWinDoc.getElementById("filterActionButtons");
+ const navigableButtons = filterButtonList.querySelectorAll(BUTTONS_SELECTOR);
+ const menupopupNewFilter = filterWinDoc.getElementById("newFilterMenupopup");
+
+ EventUtils.synthesizeKey("KEY_Tab", {}, filterc.window);
+ Assert.equal(
+ filterWinDoc.activeElement.id,
+ navigableButtons[0].id,
+ "focused on the first filter action button"
+ );
+
+ for (let button of navigableButtons) {
+ if (!filterWinDoc.getElementById(button.id).disabled) {
+ Assert.equal(
+ filterWinDoc.activeElement.id,
+ button.id,
+ "focused on the correct filter action button"
+ );
+
+ if (button.id == "newButtontoolbarbutton") {
+ function openEmptyDialog(fec) {
+ fec.window.document.getElementById("filterName").value = " ";
+ }
+
+ plan_for_modal_dialog("mailnews:filtereditor", openEmptyDialog);
+ EventUtils.synthesizeKey("KEY_Enter", {}, filterc.window);
+ wait_for_modal_dialog("mailnews:filtereditor");
+
+ plan_for_modal_dialog("mailnews:filtereditor", openEmptyDialog);
+ // Simulate Space keypress.
+ EventUtils.synthesizeKey(" ", {}, filterc.window);
+ wait_for_modal_dialog("mailnews:filtereditor");
+
+ Assert.equal(
+ filterWinDoc.activeElement.id,
+ button.id,
+ "Correct btn is focused after opening and closing new filter editor"
+ );
+ } else if (button.id == "newButtondropmarker") {
+ const menupopupOpenPromise = BrowserTestUtils.waitForEvent(
+ menupopupNewFilter,
+ "popupshown"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, filterc.window);
+ await menupopupOpenPromise;
+ const menupopupClosePromise = BrowserTestUtils.waitForEvent(
+ menupopupNewFilter,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, filterc.window);
+ await menupopupClosePromise;
+
+ // Simulate Space keypress.
+ EventUtils.synthesizeKey(" ", {}, filterc.window);
+ await menupopupOpenPromise;
+ EventUtils.synthesizeKey("KEY_Escape", {}, filterc.window);
+ await menupopupClosePromise;
+ Assert.equal(
+ filterWinDoc.activeElement.id,
+ button.id,
+ "The correct btn is focused after opening and closing the menupopup"
+ );
+ }
+ }
+ EventUtils.synthesizeKey("KEY_Tab", {}, filterc.window);
+ }
+
+ close_window(filterc);
+}).__skipMe = AppConstants.platform == "macosx";
+
+/*
+ * Test that the message filter list shows newsgroup servers.
+ */
+add_task(async function test_message_filter_shows_newsgroup_server() {
+ await be_in_folder(folderA);
+
+ plan_for_new_window("mailnews:filterlist");
+ await openFiltersDialogs();
+ let filterc = wait_for_new_window("mailnews:filterlist");
+ wait_for_window_focused(filterc.window);
+
+ let popup = filterc.window.document.getElementById("serverMenuPopup");
+ Assert.ok(popup);
+ EventUtils.synthesizeMouseAtCenter(popup, {}, popup.ownerGlobal);
+
+ let nntp = popup.children.item(1);
+ Assert.ok(nntp);
+ // We need to get the newsgroups to pop up somehow.
+ // These all fail.
+ // EventUtils.synthesizeMouseAtCenter(nntp, { }, nntp.ownerGlobal)
+ // filterc.mouseover(nntp);
+ // filterc.select(popup, popup.parentNode.getIndexOfItem(nntp));
+ // filterc.select(nntp, popup.parentNode.getIndexOfItem(nntp));
+ // filterc.select(popup, 2);
+ // let nntpPopup = nntp.menupopup;
+ // EventUtils.synthesizeMouseAtCenter(nntpPopup, { }, nntpPopup.ownerGlobal)
+ // filterc.mouseover(nntpPopup);
+ // filterc.select(nntpPopup, 2);
+
+ // This one initializes the menuitems, but it's kinda hacky.
+ nntp.menupopup._ensureInitialized();
+ Assert.equal(
+ nntp.itemCount,
+ 5,
+ "Incorrect number of children for the NNTP server"
+ );
+ close_window(filterc);
+});
+
+/* A helper function that opens up the new filter dialog (assuming that the
+ * main filters dialog is already open), creates a simple filter, and then
+ * closes the dialog.
+ */
+async function create_simple_filter() {
+ await openFiltersDialogs();
+
+ // We'll assume that the filters dialog is already open from
+ // the previous tests.
+ let filterc = wait_for_existing_window("mailnews:filterlist");
+
+ function fill_in_filter_fields(fec) {
+ let filterName = fec.window.document.getElementById("filterName");
+ filterName.value = "A Simple Filter";
+ fec.window.document.getElementById("searchAttr0").value =
+ Ci.nsMsgSearchAttrib.To;
+ fec.window.document.getElementById("searchOp0").value = Ci.nsMsgSearchOp.Is;
+ let searchVal = fec.window.document.getElementById("searchVal0").input;
+ searchVal.setAttribute("value", "test@foo.invalid");
+
+ let filterActions = fec.window.document.getElementById("filterActionList");
+ let firstAction = filterActions.getItemAtIndex(0);
+ firstAction.setAttribute("value", "markasflagged");
+ fec.window.document.querySelector("dialog").acceptDialog();
+ }
+
+ // Let's open the filter editor.
+ plan_for_modal_dialog("mailnews:filtereditor", fill_in_filter_fields);
+ EventUtils.synthesizeMouseAtCenter(
+ filterc.window.document.getElementById("newButton"),
+ {},
+ filterc.window.document.getElementById("newButton").ownerGlobal
+ );
+ wait_for_modal_dialog("mailnews:filtereditor");
+}
+
+/**
+ * Open the Message Filters dialog by clicking the menus.
+ */
+async function openFiltersDialogs() {
+ if (AppConstants.platform == "macosx") {
+ // Can't click the menus on mac.
+ mc.window.MsgFilters();
+ return;
+ }
+ // Show menubar so we can click it.
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+ // Open the "Tools | Message Filters…", a.k.a. "tasksMenu » filtersCmd".
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("tasksMenu"),
+ {},
+ mc.window
+ );
+ await click_menus_in_sequence(
+ mc.window.document.getElementById("taskPopup"),
+ [{ id: "filtersCmd" }]
+ );
+}
+
+/**
+ * Test that the address books can appear in the message filter dropdown
+ */
+add_task(async function test_address_books_appear_in_message_filter_dropdown() {
+ // Create a remote address book - we don't want this to appear in the
+ // dropdown.
+ let ldapAb = create_ldap_address_book("Some LDAP Address Book");
+
+ // Sanity check - this LDAP book should be remote.
+ Assert.ok(ldapAb.isRemote);
+
+ await openFiltersDialogs();
+
+ // We'll assume that the filters dialog is already open from
+ // the previous tests.
+ let filterc = wait_for_existing_window("mailnews:filterlist");
+
+ // Prepare a function to deal with the filter editor once it
+ // has opened
+ function filterEditorOpened(fec) {
+ fec.window.document.getElementById("searchAttr0").value =
+ Ci.nsMsgSearchAttrib.To;
+ fec.window.document.getElementById("searchOp0").value =
+ Ci.nsMsgSearchOp.IsInAB;
+ let abList = fec.window.document.getElementById("searchVal0").input;
+
+ // We should have 2 address books here - one for the Personal Address
+ // Book, and one for Collected Addresses. The LDAP address book should
+ // not be shown, since it isn't a local address book.
+ Assert.equal(
+ abList.itemCount,
+ 2,
+ "Should have 2 address books in the filter menu list."
+ );
+ }
+
+ // Let's open the filter editor.
+ plan_for_modal_dialog("mailnews:filtereditor", filterEditorOpened);
+ EventUtils.synthesizeMouseAtCenter(
+ filterc.window.document.getElementById("newButton"),
+ {},
+ filterc.window.document.getElementById("newButton").ownerGlobal
+ );
+ wait_for_modal_dialog("mailnews:filtereditor");
+});
+
+/* Test that if the user has started running a filter, and the
+ * "quit-application-requested" notification is fired, the user
+ * is given a dialog asking whether or not to quit.
+ *
+ * This also tests whether or not cancelling quit works.
+ */
+add_task(async function test_can_cancel_quit_on_filter_changes() {
+ // Register the Mock Prompt Service
+ gMockPromptService.register();
+
+ await create_simple_filter();
+
+ let filterc = wait_for_existing_window("mailnews:filterlist");
+ let runButton = filterc.window.document.getElementById("runFiltersButton");
+ runButton.setAttribute("label", runButton.getAttribute("stoplabel"));
+
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+
+ // Set the Mock Prompt Service to return false, so that we
+ // cancel the quit.
+ gMockPromptService.returnValue = false;
+ // Trigger the quit-application-request notification
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+ let promptState = gMockPromptService.promptState;
+ Assert.notEqual(null, promptState, "Expected a confirmEx prompt");
+
+ Assert.equal("confirmEx", promptState.method);
+ // Since we returned false on the confirmation dialog,
+ // we should be cancelling the quit - so cancelQuit.data
+ // should now be true
+ Assert.ok(cancelQuit.data, "Didn't cancel the quit");
+
+ // Unregister the Mock Prompt Service
+ gMockPromptService.unregister();
+});
+
+/* Test that if the user has started running a filter, and the
+ * "quit-application-requested" notification is fired, the user
+ * is given a dialog asking whether or not to quit.
+ *
+ * This also tests whether or not allowing quit works.
+ */
+add_task(async function test_can_quit_on_filter_changes() {
+ // Register the Mock Prompt Service
+ gMockPromptService.register();
+
+ let filterc = wait_for_existing_window("mailnews:filterlist");
+
+ // There should already be 1 filter defined from previous test.
+ let filterCount =
+ filterc.window.document.getElementById("filterList").itemCount;
+ Assert.equal(filterCount, 1);
+
+ let runButton = filterc.window.document.getElementById("runFiltersButton");
+ runButton.setAttribute("label", runButton.getAttribute("stoplabel"));
+
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+
+ // Set the Mock Prompt Service to return true, so that we
+ // allow the quit.
+ gMockPromptService.returnValue = true;
+ // Trigger the quit-application-request notification
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+ let promptState = gMockPromptService.promptState;
+ Assert.notEqual(null, promptState, "Expected a confirmEx prompt");
+
+ Assert.equal("confirmEx", promptState.method);
+ // Since we returned true on the confirmation dialog,
+ // we should be allowing the quit - so cancelQuit.data
+ // should now be false
+ Assert.ok(!cancelQuit.data, "Cancelled the quit");
+
+ // Unregister the Mock Prompt Service
+ gMockPromptService.unregister();
+
+ EventUtils.synthesizeMouseAtCenter(
+ filterc.window.document.querySelector("#filterList richlistitem"),
+ {},
+ filterc.window
+ );
+
+ const deleteAlertPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ "",
+ "chrome://global/content/commonDialog.xhtml",
+ {
+ async callback(win) {
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ },
+ }
+ );
+ EventUtils.synthesizeKey("KEY_Delete", {}, filterc.window);
+ await deleteAlertPromise;
+
+ Assert.equal(
+ filterc.window.document.getElementById("filterList").itemCount,
+ 0,
+ "Previously created filter should have been deleted."
+ );
+
+ close_window(filterc);
+});
diff --git a/comm/mail/test/browser/global-search-bar/browser.ini b/comm/mail/test/browser/global-search-bar/browser.ini
new file mode 100644
index 0000000000..e88cc8c00f
--- /dev/null
+++ b/comm/mail/test/browser/global-search-bar/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_globalSearchBar.js]
+[browser_clickResultItem.js]
diff --git a/comm/mail/test/browser/global-search-bar/browser_clickResultItem.js b/comm/mail/test/browser/global-search-bar/browser_clickResultItem.js
new file mode 100644
index 0000000000..d76d05203b
--- /dev/null
+++ b/comm/mail/test/browser/global-search-bar/browser_clickResultItem.js
@@ -0,0 +1,142 @@
+/* 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 {
+ be_in_folder,
+ create_folder,
+ inboxFolder,
+ make_message_sets_in_folders,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+const { GlodaMsgIndexer } = ChromeUtils.import(
+ "resource:///modules/gloda/IndexMsg.jsm"
+);
+
+let folder;
+let threads;
+
+/**
+ * Tests the 3 global search bars found in the UI:
+ * 1) The one on the mail tab.
+ * 2) The one in the search result tab.
+ */
+let tests = [
+ {
+ selector: "#unifiedToolbarContent .search-bar global-search-bar",
+ isNewSearchBar: true,
+ tabCountBefore: 1,
+ tabCountAfter: 2,
+ },
+ {
+ selector: ".remote-gloda-search",
+ tabCountBefore: 2,
+ async before() {
+ // Run a search so we can search from the results tab.
+ let input = document.querySelector("#unifiedToolbarContent .search-bar");
+ EventUtils.synthesizeMouseAtCenter(input, {});
+ EventUtils.sendString("us", window);
+ EventUtils.synthesizeKey("KEY_Enter", {});
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.document.getElementById("tabmail").selectedTab.browser &&
+ window.document.getElementById("tabmail").selectedTab.browser.src ==
+ "chrome://messenger/content/glodaFacetView.xhtml",
+ "search result tab did not open in time"
+ );
+ },
+ tabCountAfter: 3,
+ },
+];
+
+/**
+ * Tests clicking on an item in the various global search bars opens one tab
+ * only. See bug 1679113.
+ */
+add_task(async function testClickingGlobalSearchResultItemOpensOneTab() {
+ window.focus();
+ folder = await create_folder("SearchedFolder");
+ await be_in_folder(folder);
+ threads = await make_message_sets_in_folders(
+ [folder],
+ [
+ { from: ["User", "user@example.com"] },
+ { from: ["User", "user@example.com"] },
+ { from: ["User", "user@example.com"] },
+ ]
+ );
+
+ await new Promise(callback => {
+ GlodaMsgIndexer.indexFolder(folder, { callback, force: true });
+ });
+
+ let tabmail = window.document.getElementById("tabmail");
+ for (let test of tests) {
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(1);
+ }
+
+ if (test.before) {
+ await test.before();
+ }
+
+ Assert.equal(
+ tabmail.tabInfo.length,
+ test.tabCountBefore,
+ "tab count is as expected before"
+ );
+
+ let input = document.querySelector(test.selector);
+ if (test.isNewSearchBar) {
+ input.reset();
+ } else {
+ input.value = "";
+ }
+ input.focus();
+
+ EventUtils.synthesizeKey("u", {});
+ EventUtils.synthesizeKey("s", {});
+ EventUtils.synthesizeKey("e", {});
+
+ await BrowserTestUtils.waitForCondition(
+ () => input.controller.matchCount > 0,
+ `"${test.selector}" did not find any matches`
+ );
+
+ let target = document.querySelector(
+ "#PopupGlodaAutocomplete > richlistbox > richlistitem"
+ );
+ Assert.ok(target, "target item to click found");
+ EventUtils.synthesizeMouseAtCenter(target, {});
+
+ // Give any potentially extra tabs time to appear.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 1000));
+
+ Assert.equal(
+ tabmail.tabInfo.length,
+ test.tabCountAfter,
+ "tab count is as expected after"
+ );
+ Assert.equal(
+ tabmail.selectedTab.browser.src,
+ "chrome://messenger/content/glodaFacetView.xhtml",
+ "current tab is the search results tab"
+ );
+ }
+});
+
+registerCleanupFunction(async function () {
+ let tabmail = window.document.getElementById("tabmail");
+ tabmail.selectTabByMode("mail3PaneTab");
+ await be_in_folder(inboxFolder);
+ folder.deleteSelf(null);
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(1);
+ }
+});
diff --git a/comm/mail/test/browser/global-search-bar/browser_globalSearchBar.js b/comm/mail/test/browser/global-search-bar/browser_globalSearchBar.js
new file mode 100644
index 0000000000..58c573a893
--- /dev/null
+++ b/comm/mail/test/browser/global-search-bar/browser_globalSearchBar.js
@@ -0,0 +1,43 @@
+/* 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/. */
+
+/**
+ * Tests Gloda search bar is focused after a search. What we are really
+ * interested in however, is the search input not causing an error when it
+ * gains focus.
+ */
+add_task(async function testGlobalSearchInputGainsFocus() {
+ let searchInput = document.querySelector(".search-bar");
+ EventUtils.synthesizeMouseAtCenter(searchInput, {}, window);
+ EventUtils.sendString("Bugzilla", window);
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+
+ let tabmail = document.querySelector("tabmail");
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[1]);
+
+ await TestUtils.waitForCondition(() => {
+ let browser = tabmail.currentTabInfo.browser;
+ return (
+ browser &&
+ !browser.webProgress?.isLoadingDocument &&
+ browser.currentURI?.spec != "about:blank"
+ );
+ });
+
+ let activeElement = document.activeElement;
+ info(`<${activeElement.localName}>`);
+ Assert.equal(
+ activeElement.getAttribute("is"),
+ "gloda-autocomplete-input",
+ "gloda search input has focus"
+ );
+});
+
+registerCleanupFunction(function tearDown() {
+ let tabmail = document.querySelector("tabmail");
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(1);
+ }
+});
diff --git a/comm/mail/test/browser/im/browser.ini b/comm/mail/test/browser/im/browser.ini
new file mode 100644
index 0000000000..cba4d07872
--- /dev/null
+++ b/comm/mail/test/browser/im/browser.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.shell.checkDefaultClient=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ messenger.account.account1.autoLogin=false
+ messenger.account.account1.firstConnectionState=1
+ messenger.account.account1.name=mozmilltest@irc.mozilla.invalid
+ messenger.account.account1.prpl=prpl-irc
+ mail.accountmanager.accounts=account1
+ mail.account.account1.server=server1
+ mail.server.server1.imAccount=account1
+ mail.server.server1.type=im
+ mail.server.server1.hostname=prpl-irc
+ mail.server.server1.userName=mozmilltest@irc.mozilla.invalid
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_chatTabRestore.js]
+[browser_toolbarButtons.js]
diff --git a/comm/mail/test/browser/im/browser_chatTabRestore.js b/comm/mail/test/browser/im/browser_chatTabRestore.js
new file mode 100644
index 0000000000..e80ecc0461
--- /dev/null
+++ b/comm/mail/test/browser/im/browser_chatTabRestore.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/. */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { assert_tab_mode_name, mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+/**
+ * Create a new chat tab, making that tab the current tab. We block until the
+ * message finishes loading. (Inspired by open_selected_message_in_new_tab)
+ */
+async function open_chat_tab() {
+ // Get the current tab count so we can make sure the tab actually opened.
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ mc.window.document.getElementById("tabmail").openTab("chat", {});
+ await wait_for_chat_tab_to_open(mc);
+
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length !=
+ preCount + 1
+ ) {
+ throw new Error("The tab never actually got opened!");
+ }
+
+ let newTab = mc.window.document.getElementById("tabmail").tabInfo[preCount];
+ return newTab;
+}
+
+async function wait_for_chat_tab_to_open(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ utils.waitFor(
+ function () {
+ let chatTabFound = false;
+ for (let tab of mc.window.document.getElementById("tabmail").tabInfo) {
+ if (tab.mode.type == "chat") {
+ chatTabFound = true;
+ break;
+ }
+ }
+ return chatTabFound;
+ },
+ "Timeout waiting for chat tab to open",
+ 1000,
+ 50
+ );
+
+ // The above may return immediately, meaning the event queue might not get a
+ // chance. Give it a chance now.
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * This tests that the chat tab is restored properly after tabs are
+ * serialized. As for folder tabs, we can't test a restart (can we ?), so we
+ * just test the persist/restore cycle.
+ */
+add_task(async function test_chat_tab_restore() {
+ // Close everything but the first tab.
+ let closeTabs = function () {
+ while (mc.window.document.getElementById("tabmail").tabInfo.length > 1) {
+ mc.window.document.getElementById("tabmail").closeTab(1);
+ }
+ };
+
+ await open_chat_tab();
+ let state = mc.window.document.getElementById("tabmail").persistTabs();
+ closeTabs();
+ mc.window.document.getElementById("tabmail").restoreTabs(state);
+
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length < 2
+ ) {
+ throw new Error("The tab is not restored!");
+ }
+
+ let tabTypes = ["mail3PaneTab", "chat"];
+ for (let i in tabTypes) {
+ assert_tab_mode_name(
+ mc.window.document.getElementById("tabmail").tabInfo[i],
+ tabTypes[i]
+ );
+ }
+
+ closeTabs();
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/im/browser_toolbarButtons.js b/comm/mail/test/browser/im/browser_toolbarButtons.js
new file mode 100644
index 0000000000..960443a85b
--- /dev/null
+++ b/comm/mail/test/browser/im/browser_toolbarButtons.js
@@ -0,0 +1,147 @@
+/* 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 { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+/* This test checks that the toolbar buttons of the chat toolbar are
+ * correctly disabled/enabled, and that the placeholder displayed in
+ * the middle of the chat tab is correct.
+ */
+add_task(function test_toolbar_and_placeholder() {
+ Assert.notEqual(
+ mc.window.document.getElementById("tabmail").selectedTab.mode.type,
+ "chat",
+ "the chat tab shouldn't be selected at startup"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("chatButton"),
+ { clickCount: 1 },
+ mc.window
+ );
+ Assert.equal(
+ mc.window.document.getElementById("tabmail").selectedTab.mode.type,
+ "chat",
+ "the chat tab should be selected"
+ );
+
+ // Check that "No connected account" placeholder is correct.
+ Assert.ok(
+ !mc.window.document.getElementById("noConvScreen").hidden,
+ "'Your chat accounts are not connected.' placeholder"
+ );
+ Assert.ok(
+ mc.window.document.getElementById("noConvInnerBox").hidden,
+ "the 'No conversation' placeholder is hidden"
+ );
+ Assert.ok(
+ mc.window.document.getElementById("noAccountInnerBox").hidden,
+ "the 'No account' placeholder is hidden"
+ );
+ Assert.ok(
+ !mc.window.document.getElementById("noConnectedAccountInnerBox").hidden,
+ "the 'No connected account' placeholder is visible"
+ );
+ let chatHandler = mc.window.chatHandler;
+ Assert.equal(
+ chatHandler._placeHolderButtonId,
+ "openIMAccountManagerButton",
+ "the correct placeholder button is visible"
+ );
+ Assert.equal(
+ mc.window.document.activeElement.id,
+ chatHandler._placeHolderButtonId,
+ "the placeholder button is focused"
+ );
+
+ // check that add contact and join chat are disabled
+ Assert.ok(
+ mc.window.document.getElementById("button-add-buddy").disabled,
+ "the Add Buddy button is disabled"
+ );
+ Assert.ok(
+ mc.window.document.getElementById("button-join-chat").disabled,
+ "the Join Chat button is disabled"
+ );
+
+ // The next tests require an account, get the unwrapped default IRC account.
+ let account = IMServices.accounts.getAccountByNumericId(1);
+ Assert.equal(
+ account.protocol.id,
+ "prpl-irc",
+ "the default IM account is an IRC account"
+ );
+ let ircAccount = account.prplAccount.wrappedJSObject;
+
+ // Pretend the account is connected and check how the UI reacts
+ ircAccount.reportConnected();
+
+ // check that add contact and join chat are no longer disabled
+ Assert.ok(
+ !mc.window.document.getElementById("button-add-buddy").disabled,
+ "the Add Buddy button is not disabled"
+ );
+ Assert.ok(
+ !mc.window.document.getElementById("button-join-chat").disabled,
+ "the Join Chat button is not disabled"
+ );
+
+ // Check that the "No conversations" placeholder is correct.
+ Assert.ok(
+ !mc.window.document.getElementById("noConvInnerBox").hidden,
+ "the 'No conversation' placeholder is visible"
+ );
+ Assert.ok(
+ mc.window.document.getElementById("noAccountInnerBox").hidden,
+ "the 'No account' placeholder is hidden"
+ );
+ Assert.ok(
+ mc.window.document.getElementById("noConnectedAccountInnerBox").hidden,
+ "the 'No connected account' placeholder is hidden"
+ );
+ Assert.ok(!chatHandler._placeHolderButtonId, "no placeholder button");
+
+ // Now check that the UI reacts to account disconnections too.
+ ircAccount.reportDisconnected();
+
+ // check that add contact and join chat are disabled again.
+ Assert.ok(
+ mc.window.document.getElementById("button-add-buddy").disabled,
+ "the Add Buddy button is disabled"
+ );
+ Assert.ok(
+ mc.window.document.getElementById("button-join-chat").disabled,
+ "the Join Chat button is disabled"
+ );
+
+ // Check that the "No connected account" placeholder is back.
+ Assert.ok(
+ mc.window.document.getElementById("noConvInnerBox").hidden,
+ "the 'No conversation' placeholder is hidden"
+ );
+ Assert.ok(
+ mc.window.document.getElementById("noAccountInnerBox").hidden,
+ "the 'No account' placeholder is hidden"
+ );
+ Assert.ok(
+ !mc.window.document.getElementById("noConnectedAccountInnerBox").hidden,
+ "the 'No connected account' placeholder is visible"
+ );
+ Assert.equal(
+ chatHandler._placeHolderButtonId,
+ "openIMAccountManagerButton",
+ "the correct placeholder button is visible"
+ );
+
+ while (mc.window.document.getElementById("tabmail").tabInfo.length > 1) {
+ mc.window.document.getElementById("tabmail").closeTab(1);
+ }
+});
diff --git a/comm/mail/test/browser/import/browser.ini b/comm/mail/test/browser/import/browser.ini
new file mode 100644
index 0000000000..319e841379
--- /dev/null
+++ b/comm/mail/test/browser/import/browser.ini
@@ -0,0 +1,14 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ mail.import.in_new_tab=true
+subsuite = thunderbird
+
+[browser_exportProfile.js]
+[browser_importProfile.js]
diff --git a/comm/mail/test/browser/import/browser_exportProfile.js b/comm/mail/test/browser/import/browser_exportProfile.js
new file mode 100644
index 0000000000..d3537d266c
--- /dev/null
+++ b/comm/mail/test/browser/import/browser_exportProfile.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 https://mozilla.org/MPL/2.0/.
+ */
+const { MockFilePicker } = ChromeUtils.importESModule(
+ "resource://testing-common/MockFilePicker.sys.mjs"
+);
+
+add_task(async function testProfileExport() {
+ const profileDir = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "profile-tmp"
+ );
+ const zipFile = PathUtils.join(profileDir, "export.zip");
+ const filePath = Services.io
+ .newURI(PathUtils.toFileURI(zipFile))
+ .QueryInterface(Ci.nsIFileURL);
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([filePath.file]);
+ registerCleanupFunction(async () => {
+ await IOUtils.remove(profileDir, {
+ recursive: true,
+ });
+ MockFilePicker.cleanup();
+ });
+
+ const tab = await new Promise(resolve => {
+ const tab = window.openTab("contentTab", {
+ url: "about:import",
+ onLoad(event, browser) {
+ browser.contentWindow.showTab("tab-export", true);
+ resolve(tab);
+ },
+ });
+ });
+ const importDocument = tab.browser.contentDocument;
+ const exportPane = importDocument.getElementById("tabPane-export");
+
+ ok(
+ BrowserTestUtils.is_visible(importDocument.getElementById("exportDocs")),
+ "Export docs link is visible"
+ );
+ ok(
+ BrowserTestUtils.is_hidden(importDocument.getElementById("importDocs")),
+ "Import docs link is hidden"
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#exportButton",
+ {},
+ tab.browser
+ );
+ const progressPane = exportPane.querySelector(".progressPane");
+ await BrowserTestUtils.waitForMutationCondition(
+ exportPane,
+ {
+ attributes: true,
+ },
+ () => BrowserTestUtils.is_visible(progressPane)
+ );
+ ok(
+ BrowserTestUtils.is_hidden(importDocument.getElementById("exportButton")),
+ "Export button is hidden while export is in progress"
+ );
+
+ const finish = exportPane.querySelector(".progressFinish");
+ await BrowserTestUtils.waitForMutationCondition(
+ exportPane,
+ {
+ attributes: true,
+ },
+ () => BrowserTestUtils.is_visible(finish)
+ );
+ ok(
+ BrowserTestUtils.is_visible(progressPane),
+ "When export succeeds and finish is shown, progress is still displayed"
+ );
+
+ const tabClosedPromise = BrowserTestUtils.waitForEvent(
+ tab.tabNode,
+ "TabClose"
+ );
+ // Using BrowserTestUtils fails, because clicking the button destroys the
+ // context, which means the actor from BTU gets destroyed before it can report
+ // that it emitted the event.
+ await EventUtils.synthesizeMouseAtCenter(
+ finish,
+ {},
+ tab.browser.contentWindow
+ );
+ await tabClosedPromise;
+
+ const exportZipStat = await IOUtils.stat(zipFile);
+ info(exportZipStat.size);
+ ok(exportZipStat.size > 10, "Zip is not empty");
+});
diff --git a/comm/mail/test/browser/import/browser_importProfile.js b/comm/mail/test/browser/import/browser_importProfile.js
new file mode 100644
index 0000000000..0e07a3ca30
--- /dev/null
+++ b/comm/mail/test/browser/import/browser_importProfile.js
@@ -0,0 +1,323 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+const { MockFilePicker } = ChromeUtils.importESModule(
+ "resource://testing-common/MockFilePicker.sys.mjs"
+);
+
+function checkSteps(importDocument, currentStep, totalSteps) {
+ const stepNav = importDocument.getElementById("stepNav");
+ is(stepNav.childElementCount, totalSteps, "Expected amount of steps");
+ ok(
+ stepNav
+ .querySelector(`*:nth-child(${currentStep})`)
+ .classList.contains("current"),
+ `Expected step ${currentStep} is active`
+ );
+}
+
+function checkVisiblePane(importDocument, activePaneId, activeStepId) {
+ const panes = importDocument.querySelectorAll(".tabPane");
+ for (const pane of panes) {
+ if (pane.id === activePaneId) {
+ ok(BrowserTestUtils.is_visible(pane), `Pane ${activePaneId} is visible`);
+ const steps = pane.querySelectorAll("section");
+ for (const step of steps) {
+ if (step.id === activeStepId) {
+ ok(
+ BrowserTestUtils.is_visible(step),
+ `Step ${activeStepId} is visible`
+ );
+ } else {
+ ok(BrowserTestUtils.is_hidden(step), `Step ${step.id} is hidden`);
+ }
+ }
+ } else {
+ ok(BrowserTestUtils.is_hidden(pane), `Pane ${pane.id} is not visible`);
+ }
+ }
+}
+
+add_setup(() => {
+ MockFilePicker.init(window);
+ registerCleanupFunction(() => {
+ MockFilePicker.cleanup();
+ });
+});
+
+add_task(async function testProfileImport() {
+ const profileDir = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "profile-tmp"
+ );
+ await IOUtils.writeUTF8(
+ PathUtils.join(profileDir, "prefs.js"),
+ [
+ ["mail.smtpserver.smtp1.username", "smtp-user-1"],
+ ["mail.smtpservers", "smtp1"],
+ ["mail.identity.id1.smtpServer", "smtp1"],
+ ["mail.server.server2.type", "none"],
+ ["mail.server.server6.type", "imap"],
+ ["mail.account.account1.server", "server2"],
+ ["mail.account.account2.server", "server6"],
+ ["mail.account.account2.identities", "id1"],
+ ["mail.accountmanager.accounts", "account2,account1"],
+ ]
+ .map(
+ ([name, value]) =>
+ `user_pref(${JSON.stringify(name)}, ${JSON.stringify(value)});`
+ )
+ .join("\n")
+ );
+ const filePath = Services.io
+ .newURI(PathUtils.toFileURI(profileDir))
+ .QueryInterface(Ci.nsIFileURL);
+ MockFilePicker.setFiles([filePath.file]);
+ registerCleanupFunction(async () => {
+ await IOUtils.remove(profileDir, {
+ recursive: true,
+ });
+ });
+
+ const tab = await new Promise(resolve => {
+ const tab = window.openTab("contentTab", {
+ url: "about:import",
+ onLoad() {
+ resolve(tab);
+ },
+ });
+ });
+ const importDocument = tab.browser.contentDocument;
+
+ ok(
+ BrowserTestUtils.is_hidden(importDocument.getElementById("exportDocs")),
+ "Export docs link is hidden"
+ );
+ ok(
+ BrowserTestUtils.is_visible(importDocument.getElementById("importDocs")),
+ "Import docs link is visible"
+ );
+
+ checkSteps(importDocument, 1, 4);
+ checkVisiblePane(importDocument, "tabPane-start", "start-sources");
+ ok(
+ importDocument.querySelector('#start-sources input[value="Thunderbird"]')
+ .value,
+ "Thunderbird profile is selected by default"
+ );
+ ok(
+ BrowserTestUtils.is_hidden(
+ importDocument.getElementById("startBackButton")
+ ),
+ "Back button is hidden in first step"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#tabPane-start .continue",
+ {},
+ tab.browser
+ );
+ const appPane = importDocument.getElementById("tabPane-app");
+ await BrowserTestUtils.waitForMutationCondition(
+ appPane,
+ {
+ attributes: true,
+ },
+ () => BrowserTestUtils.is_visible(appPane)
+ );
+
+ checkSteps(importDocument, 2, 4);
+ checkVisiblePane(importDocument, "tabPane-app", "app-profiles");
+ ok(
+ BrowserTestUtils.is_visible(
+ importDocument.getElementById("profileBackButton")
+ ),
+ "Back button is visible"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ '#filePickerList [value="file-picker-dir"]',
+ {},
+ tab.browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#profileNextButton",
+ {},
+ tab.browser
+ );
+ const itemsStep = importDocument.getElementById("app-items");
+ await BrowserTestUtils.waitForMutationCondition(
+ itemsStep,
+ {
+ attributes: true,
+ },
+ () => BrowserTestUtils.is_visible(itemsStep)
+ );
+
+ checkSteps(importDocument, 3, 4);
+ checkVisiblePane(importDocument, "tabPane-app", "app-items");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#profileNextButton",
+ {},
+ tab.browser
+ );
+ const summaryStep = importDocument.getElementById("app-summary");
+ await BrowserTestUtils.waitForMutationCondition(
+ summaryStep,
+ {
+ attributes: true,
+ },
+ () => BrowserTestUtils.is_visible(summaryStep)
+ );
+
+ checkSteps(importDocument, 4, 4);
+ checkVisiblePane(importDocument, "tabPane-app", "app-summary");
+ ok(
+ BrowserTestUtils.is_hidden(
+ importDocument.getElementById("profileNextButton")
+ ),
+ "Can't advance from summary step"
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#appStartImport",
+ {},
+ tab.browser
+ );
+
+ const progressPane = importDocument.querySelector(
+ "#app-summary .progressPane"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ appPane,
+ {
+ attributes: true,
+ },
+ () => BrowserTestUtils.is_visible(progressPane)
+ );
+ ok(
+ BrowserTestUtils.is_hidden(importDocument.getElementById("appStartImport")),
+ "Import button is hidden while import is in progress"
+ );
+
+ const finish = importDocument.querySelector("#app-summary .progressFinish");
+ await BrowserTestUtils.waitForMutationCondition(
+ appPane,
+ {
+ attributes: true,
+ },
+ () => BrowserTestUtils.is_visible(finish)
+ );
+ ok(
+ BrowserTestUtils.is_visible(progressPane),
+ "When import succeeds and finish is shown, progress is still displayed"
+ );
+
+ // We close the tab ourselves instead of hitting the finish button, since
+ // restarting Thunderbird within the test is a headache.
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function testImportLargeZIP() {
+ // Writing the fake zip and deleting it can take some time.
+ requestLongerTimeout(2);
+ const profileDir = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "profile-tmp"
+ );
+ const profileZip = PathUtils.join(profileDir, "profile.zip");
+ // This block makes sure the ZIP file's fake contents go out of scope once
+ // written out.
+ {
+ const halfAGigabyte = new Uint8Array(2 ** 29);
+ await IOUtils.write(profileZip, halfAGigabyte);
+ info("ZIP is 0.5 GB big now");
+ for (let i = 0; i < 3; ++i) {
+ await IOUtils.write(profileZip, halfAGigabyte, { mode: "append" });
+ info(`ZIP is ${(i + 2) * 0.5} GB big now`);
+ }
+ }
+ await IOUtils.write(
+ profileZip,
+ new Uint8Array(2), // These are extra bytes beyond 2 GB
+ {
+ mode: "append",
+ }
+ );
+ const filePath = Services.io
+ .newURI(PathUtils.toFileURI(profileZip))
+ .QueryInterface(Ci.nsIFileURL);
+ MockFilePicker.setFiles([filePath.file]);
+ registerCleanupFunction(async () => {
+ await IOUtils.remove(profileDir, {
+ recursive: true,
+ });
+ });
+
+ const tab = await new Promise(resolve => {
+ const tab = window.openTab("contentTab", {
+ url: "about:import",
+ onLoad() {
+ resolve(tab);
+ },
+ });
+ });
+ const importDocument = tab.browser.contentDocument;
+
+ checkSteps(importDocument, 1, 4);
+ checkVisiblePane(importDocument, "tabPane-start", "start-sources");
+ ok(
+ importDocument.querySelector('#start-sources input[value="Thunderbird"]')
+ .value,
+ "Thunderbird profile is selected by default"
+ );
+ ok(
+ BrowserTestUtils.is_hidden(
+ importDocument.getElementById("startBackButton")
+ ),
+ "Back button is hidden in first step"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#tabPane-start .continue",
+ {},
+ tab.browser
+ );
+ const appPane = importDocument.getElementById("tabPane-app");
+ await BrowserTestUtils.waitForMutationCondition(
+ appPane,
+ {
+ attributes: true,
+ },
+ () => BrowserTestUtils.is_visible(appPane)
+ );
+
+ checkSteps(importDocument, 2, 4);
+ checkVisiblePane(importDocument, "tabPane-app", "app-profiles");
+ ok(
+ BrowserTestUtils.is_visible(
+ importDocument.getElementById("profileBackButton")
+ ),
+ "Back button is visible"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#appFilePickerZip",
+ {},
+ tab.browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#profileNextButton",
+ {},
+ tab.browser
+ );
+
+ const notificationBox = importDocument.getElementById("errorNotifications");
+ await BrowserTestUtils.waitForMutationCondition(
+ notificationBox,
+ {
+ childList: true,
+ },
+ () => notificationBox.childElementCount > 0
+ );
+
+ document.getElementById("tabmail").closeTab(tab);
+});
diff --git a/comm/mail/test/browser/junk-commands/browser.ini b/comm/mail/test/browser/junk-commands/browser.ini
new file mode 100644
index 0000000000..9366f31ed2
--- /dev/null
+++ b/comm/mail/test/browser/junk-commands/browser.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_junkCommands.js]
diff --git a/comm/mail/test/browser/junk-commands/browser_junkCommands.js b/comm/mail/test/browser/junk-commands/browser_junkCommands.js
new file mode 100644
index 0000000000..498395ff75
--- /dev/null
+++ b/comm/mail/test/browser/junk-commands/browser_junkCommands.js
@@ -0,0 +1,105 @@
+/* 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 {
+ be_in_folder,
+ create_folder,
+ make_message_sets_in_folders,
+ select_click_row,
+ select_none,
+ select_shift_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { delete_mail_marked_as_junk, mark_selected_messages_as_junk } =
+ ChromeUtils.import("resource://testing-common/mozmill/JunkHelpers.jsm");
+
+// One folder's enough
+var folder = null;
+
+add_setup(async function () {
+ folder = await create_folder("JunkCommandsA");
+ await make_message_sets_in_folders([folder], [{ count: 30 }]);
+});
+
+/**
+ * The number of messages to mark as junk and expect to be deleted.
+ */
+var NUM_MESSAGES_TO_JUNK = 8;
+
+/**
+ * Helper to check whether a folder has the right number of messages.
+ *
+ * @param aFolder the folder to check
+ * @param aNumMessages the number of messages the folder should contain.
+ */
+function _assert_folder_total_messages(aFolder, aNumMessages) {
+ let curMessages = aFolder.getTotalMessages(false);
+ if (curMessages != aNumMessages) {
+ throw new Error(
+ "The folder " +
+ aFolder.prettyName +
+ " should have " +
+ aNumMessages +
+ " messages, but actually has " +
+ curMessages +
+ " messages."
+ );
+ }
+}
+
+/**
+ * Test deleting junk messages with no messages marked as junk.
+ */
+add_task(async function test_delete_no_junk_messages() {
+ let initialNumMessages = folder.getTotalMessages(false);
+ await be_in_folder(folder);
+ select_none();
+ await delete_mail_marked_as_junk(0);
+ // Check if we still have the same number of messages
+ _assert_folder_total_messages(folder, initialNumMessages);
+});
+
+/**
+ * Test deleting junk messages with some messages marked as junk.
+ */
+add_task(async function test_delete_junk_messages() {
+ let initialNumMessages = folder.getTotalMessages(false);
+ await be_in_folder(folder);
+ select_click_row(1);
+ let selectedMessages = select_shift_click_row(NUM_MESSAGES_TO_JUNK);
+ Assert.equal(
+ selectedMessages.length,
+ NUM_MESSAGES_TO_JUNK,
+ `should have selected correct number of msgs`
+ );
+ // Mark these messages as junk
+ mark_selected_messages_as_junk();
+ // Now delete junk mail
+ await delete_mail_marked_as_junk(NUM_MESSAGES_TO_JUNK);
+ // Check that we have the right number of messages left
+ _assert_folder_total_messages(
+ folder,
+ initialNumMessages - NUM_MESSAGES_TO_JUNK
+ );
+ // Check that none of the message keys exist any more
+ let db = folder.getDBFolderInfoAndDB({});
+ for (let msgHdr of selectedMessages) {
+ let key = msgHdr.messageKey;
+ if (db.containsKey(key)) {
+ throw new Error(
+ "The database shouldn't contain key " + key + ", but does."
+ );
+ }
+ }
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/keyboard/browser.ini b/comm/mail/test/browser/keyboard/browser.ini
new file mode 100644
index 0000000000..e267f0cbfc
--- /dev/null
+++ b/comm/mail/test/browser/keyboard/browser.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_spacehit.js]
diff --git a/comm/mail/test/browser/keyboard/browser_spacehit.js b/comm/mail/test/browser/keyboard/browser_spacehit.js
new file mode 100644
index 0000000000..72f84059b2
--- /dev/null
+++ b/comm/mail/test/browser/keyboard/browser_spacehit.js
@@ -0,0 +1,96 @@
+/* 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/. */
+
+/**
+ * Tests that the space bar only advances to the next unread message
+ * when mail.advance_on_spacebar is true (default).
+ */
+
+"use strict";
+
+var {
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ make_message_sets_in_folders,
+ mc,
+ select_click_row,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+// Get original preference value.
+var prefName = "mail.advance_on_spacebar";
+var prefValue = Services.prefs.getBoolPref(prefName);
+
+add_setup(async function () {
+ // Create four unread messages in a sample folder.
+ let folder = await create_folder("Sample");
+ await make_message_sets_in_folders([folder], [{ count: 4 }]);
+ await be_in_folder(folder);
+});
+
+registerCleanupFunction(function () {
+ // Restore original preference value.
+ Services.prefs.setBoolPref(prefName, prefValue);
+});
+
+/**
+ * The second of four simple messages is selected and [Shift-]Space is
+ * pressed to determine if focus changes to a new message.
+ *
+ * @param {boolean} shouldAdvance - Whether the selection should advance.
+ * @param {boolean} isShiftPressed - Whether to press Shift key.
+ */
+function subtest_advance_on_spacebar(shouldAdvance, isShiftPressed) {
+ // Set preference.
+ Services.prefs.setBoolPref(prefName, shouldAdvance);
+ // Select the second message.
+ let oldMessage = select_click_row(1);
+ wait_for_message_display_completion(mc);
+ // Press [Shift-]Space.
+ EventUtils.synthesizeKey(
+ " ",
+ { shiftKey: isShiftPressed },
+ get_about_message()
+ );
+ // Check that message focus changes if `shouldAdvance` is true.
+ let newMessage = get_about_message().gMessage;
+ shouldAdvance
+ ? Assert.notEqual(oldMessage, newMessage)
+ : Assert.equal(oldMessage, newMessage);
+}
+
+/**
+ * Test that focus remains on current message when preference is false
+ * and spacebar is pressed.
+ */
+add_task(function test_noadvance_on_space() {
+ subtest_advance_on_spacebar(false, false);
+});
+
+/**
+ * Test that focus remains on current message when preference is false
+ * and shift-spacebar is pressed.
+ */
+add_task(function test_noadvance_on_shiftspace() {
+ subtest_advance_on_spacebar(false, true);
+});
+
+/**
+ * Test that focus advances to next message when preference is true
+ * and spacebar is pressed.
+ */
+add_task(function test_advance_on_space() {
+ subtest_advance_on_spacebar(true, false);
+});
+
+/**
+ * Test that focus advances to previous message when preference is true
+ * and shift-spacebar is pressed.
+ */
+add_task(function test_advance_on_shiftspace() {
+ subtest_advance_on_spacebar(true, true);
+});
diff --git a/comm/mail/test/browser/message-header/browser.ini b/comm/mail/test/browser/message-header/browser.ini
new file mode 100644
index 0000000000..786f724775
--- /dev/null
+++ b/comm/mail/test/browser/message-header/browser.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+support-files = data/**
+
+[browser_messageHeader.js]
+[browser_messageHeaderCustomize.js]
+[browser_phishingBar.js]
+[browser_replyIdentity.js]
+[browser_replyToListFromAddressSelection.js]
+[browser_returnReceipt.js]
diff --git a/comm/mail/test/browser/message-header/browser_messageHeader.js b/comm/mail/test/browser/message-header/browser_messageHeader.js
new file mode 100644
index 0000000000..f198a36f3f
--- /dev/null
+++ b/comm/mail/test/browser/message-header/browser_messageHeader.js
@@ -0,0 +1,1237 @@
+/* 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 functionality in the message header,
+ */
+
+"use strict";
+
+var {
+ create_address_book,
+ create_mailing_list,
+ ensure_no_card_exists,
+ get_cards_in_all_address_books_for_email,
+ get_mailing_list_from_address_book,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/AddressBookHelpers.jsm"
+);
+var { wait_for_content_tab_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_popup,
+ create_folder,
+ create_message,
+ gDefaultWindowHeight,
+ get_smart_folder_named,
+ get_about_3pane,
+ get_about_message,
+ inboxFolder,
+ mc,
+ msgGen,
+ restore_default_window_size,
+ select_click_row,
+ select_none,
+ wait_for_message_display_completion,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { element_visible_recursive } = ChromeUtils.import(
+ "resource://testing-common/mozmill/DOMHelpers.jsm"
+);
+var { resize_to } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let about3Pane = get_about_3pane();
+let aboutMessage = get_about_message();
+
+const LINES_PREF = "mailnews.headers.show_n_lines_before_more";
+
+// Used to get the accessible object for a DOM node.
+var gAccService = Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+);
+
+var folder;
+var folderMore;
+var gInterestingMessage;
+
+add_setup(async function () {
+ folder = await create_folder("MessageWindowA");
+ folderMore = await create_folder("MesageHeaderMoreButton");
+
+ // Create a message that has the interesting headers that commonly shows up in
+ // the message header pane for testing.
+ gInterestingMessage = create_message({
+ cc: msgGen.makeNamesAndAddresses(20),
+ subject:
+ "This is a really, really, really, really, really, really, really, really, long subject.",
+ clobberHeaders: {
+ Newsgroups: "alt.test",
+ "Reply-To": "J. Doe <j.doe@momo.invalid>",
+ "Content-Base": "https://example.com/",
+ Bcc: "Richard Roe <richard.roe@momo.invalid>",
+ },
+ });
+
+ await add_message_to_folder([folder], gInterestingMessage);
+
+ // Create a message that has multiple to and cc addresses.
+ let msgMore1 = create_message({
+ to: msgGen.makeNamesAndAddresses(40),
+ cc: msgGen.makeNamesAndAddresses(40),
+ });
+ await add_message_to_folder([folderMore], msgMore1);
+
+ // Create a message that has multiple to and cc addresses.
+ let msgMore2 = create_message({
+ to: msgGen.makeNamesAndAddresses(20),
+ cc: msgGen.makeNamesAndAddresses(20),
+ });
+ await add_message_to_folder([folderMore], msgMore2);
+
+ // Create a regular message with one recipient.
+ let msg = create_message();
+ await add_message_to_folder([folder], msg);
+
+ // Some of these tests critically depends on the window width, collapse
+ // everything that might be in the way.
+ mc.window.document.getElementById(
+ "tabmail"
+ ).currentTabInfo.folderPaneVisible = false;
+
+ // Disable animations on the panel, so that we don't have to deal with
+ // async openings. The panel is lazy-loaded, so it needs to be referenced
+ // this way rather than finding it in the DOM.
+ aboutMessage.editContactInlineUI.panel.setAttribute("animate", false);
+ await ensure_table_view();
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ // Delete created folder.
+ folder.deleteSelf(null);
+ folderMore.deleteSelf(null);
+
+ // Restore animation to the contact panel.
+ aboutMessage.document
+ .getElementById("editContactPanel")
+ .removeAttribute("animate");
+ });
+});
+
+/**
+ * Helper function that takes an array of header recipients elements and
+ * returns the last one in the list that is not hidden. Returns null if no
+ * such element exists.
+ *
+ * @param {HTMLOListElement} recipientsList - The list element containing all
+ * recipient addresses.
+ */
+function get_last_visible_address(recipientsList) {
+ let last = recipientsList.childNodes[recipientsList.childNodes.length - 1];
+ // Avoid returning the "more" button.
+ if (last.classList.contains("show-more-recipients")) {
+ return recipientsList.childNodes[recipientsList.childNodes.length - 2];
+ }
+ return last;
+}
+
+add_task(async function test_add_tag_with_really_long_label() {
+ await be_in_folder(folder);
+ await ensure_table_view();
+
+ // Select the first message, which will display it.
+ let curMessage = select_click_row(0);
+
+ assert_selected_and_displayed(mc, curMessage);
+
+ let topLabel = aboutMessage.document.getElementById("expandedfromLabel");
+ let bottomLabel = aboutMessage.document.getElementById(
+ "expandedsubjectLabel"
+ );
+ if (topLabel.clientWidth != bottomLabel.clientWidth) {
+ throw new Error(
+ `Header columns have different widths! ${topLabel.clientWidth} != ${bottomLabel.clientWidth}`
+ );
+ }
+ let defaultWidth = topLabel.clientWidth;
+
+ // Make the tags label really long.
+ let tagsLabel = aboutMessage.document.getElementById("expandedtagsLabel");
+ let oldTagsValue = tagsLabel.value;
+ tagsLabel.value = "taaaaaaaaaaaaaaaaaags";
+ if (topLabel.clientWidth != bottomLabel.clientWidth) {
+ tagsLabel.value = oldTagsValue;
+ throw new Error(
+ `Header columns have different widths! ${topLabel.clientWidth} != ${bottomLabel.clientWidth}`
+ );
+ }
+
+ if (topLabel.clientWidth != defaultWidth) {
+ tagsLabel.value = oldTagsValue;
+ throw new Error(
+ `Header columns changed width! ${topLabel.clientWidth} != ${defaultWidth}`
+ );
+ }
+
+ let fromRow = aboutMessage.document.getElementById("expandedfromRow");
+ // Add the first tag, and make sure that the label are the same length.
+ fromRow.focus();
+ EventUtils.synthesizeKey("1", {}, aboutMessage);
+ if (topLabel.clientWidth != bottomLabel.clientWidth) {
+ tagsLabel.value = oldTagsValue;
+ throw new Error(
+ `Header columns have different widths! ${topLabel.clientWidth} != ${bottomLabel.clientWidth}`
+ );
+ }
+
+ if (topLabel.clientWidth == defaultWidth) {
+ tagsLabel.value = oldTagsValue;
+ throw new Error(
+ `Header columns didn't change width! ${topLabel.clientWidth} == ${defaultWidth}`
+ );
+ }
+
+ // Remove the tag and put it back so that the a11y label gets regenerated
+ // with the normal value rather than "taaaaaaaags".
+ tagsLabel.value = oldTagsValue;
+ fromRow.focus();
+ EventUtils.synthesizeKey("1", {}, aboutMessage);
+ fromRow.focus();
+ EventUtils.synthesizeKey("1", {}, aboutMessage);
+}).skip();
+
+/**
+ * Data and methods for a space.
+ *
+ * @typedef {object} HeaderInfo
+ * @property {string} name - Used for pretty-printing in exceptions.
+ * @property {Function} element - A callback returning the DOM
+ * element with the data.
+ * @property {Function} expectedName - A callback returning the expected value
+ * of the accessible name of the DOM element.
+ */
+/**
+ * List all header rows that we want to test.
+ *
+ * @type {HeaderInfo[]}
+ */
+const headersToTest = [
+ {
+ name: "Subject",
+ element() {
+ return aboutMessage.document.getElementById("expandedsubjectBox");
+ },
+ expectedName(element) {
+ return `${
+ aboutMessage.document.getElementById("expandedsubjectLabel").value
+ }: ${element.value.textContent}`;
+ },
+ },
+ {
+ name: "Content-Base",
+ element() {
+ return aboutMessage.document.getElementById("expandedcontent-baseBox");
+ },
+ expectedName(element) {
+ return `${
+ aboutMessage.document.getElementById("expandedcontent-baseLabel").value
+ }: ${element.value.textContent}`;
+ },
+ },
+];
+
+/**
+ * Use the information from HeaderInfo to verify that screen readers will do the
+ * right thing with the given message header.
+ *
+ * @param {HeaderInfo} header - The HeaderInfo data type object.
+ */
+async function verify_header_a11y(header) {
+ let element = header.element();
+ Assert.notEqual(
+ element,
+ null,
+ `element not found for header '${header.name}'`
+ );
+
+ let headerAccessible;
+ await TestUtils.waitForCondition(
+ () => (headerAccessible = gAccService.getAccessibleFor(element)) != null,
+ `didn't find accessible element for header '${header.name}'`
+ );
+
+ let expectedName = header.expectedName(element);
+ Assert.equal(
+ headerAccessible.name,
+ expectedName,
+ `headerAccessible.name for ${header.name} ` +
+ `was '${headerAccessible.name}'; expected '${expectedName}'`
+ );
+}
+
+/**
+ * Test the accessibility attributes of the various message headers.
+ *
+ * INFO: The gInterestingMessage has no tags until after
+ * test_add_tag_with_really_long_label, so ensure it runs after that one.
+ */
+add_task(async function test_a11y_attrs() {
+ await be_in_folder(folder);
+ // Convert the SyntheticMessage gInterestingMessage into an actual nsIMsgDBHdr
+ // XPCOM message.
+ let hdr = folder.msgDatabase.getMsgHdrForMessageID(
+ gInterestingMessage.messageId
+ );
+ // Select and open the interesting message.
+ let curMessage = select_click_row(
+ about3Pane.gDBView.findIndexOfMsgHdr(hdr, false)
+ );
+ // Make sure it loads.
+ assert_selected_and_displayed(mc, curMessage);
+ // Test all the headers with this message.
+ headersToTest.forEach(verify_header_a11y);
+});
+
+/**
+ * Test the keyboard accessibility of the toolbarbuttons on the message header.
+ */
+add_task(async function enter_msg_hdr_toolbar() {
+ await be_in_folder(folder);
+ // Convert the SyntheticMessage gInterestingMessage into an actual nsIMsgDBHdr
+ // XPCOM message.
+ let hdr = folder.msgDatabase.getMsgHdrForMessageID(
+ gInterestingMessage.messageId
+ );
+ // Select and open the interesting message.
+ let curMessage = select_click_row(
+ about3Pane.gDBView.findIndexOfMsgHdr(hdr, false)
+ );
+ // Make sure it loads.
+ assert_selected_and_displayed(mc, curMessage);
+
+ const BUTTONS_SELECTOR = `toolbarbutton:not([hidden="true"],[is="toolbarbutton-menu-button"]), toolbaritem[id="hdrSmartReplyButton"]>toolbarbutton:not([hidden="true"])>dropmarker, button:not([hidden])`;
+ let headerToolbar = aboutMessage.document.getElementById(
+ "header-view-toolbar"
+ );
+ let headerButtons = headerToolbar.querySelectorAll(BUTTONS_SELECTOR);
+
+ // Create an array of all the menu popups on message header.
+ let msgHdrMenupopups = headerToolbar.querySelectorAll(".no-icon-menupopup");
+ let menupopupToOpen, msgHdrActiveElement;
+
+ // Press tab while on the message selected.
+ EventUtils.synthesizeKey("KEY_Tab", {}, about3Pane);
+ Assert.equal(
+ headerButtons[0].id,
+ aboutMessage.document.activeElement.id,
+ "focused on first msgHdr toolbar button"
+ );
+
+ // Simulate the Arrow Right keypress to make sure the correct button gets the
+ // focus.
+ for (let i = 1; i < headerButtons.length; i++) {
+ let previousElement = document.activeElement;
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, about3Pane);
+ Assert.equal(
+ aboutMessage.document.activeElement.id,
+ headerButtons[i].id,
+ "The next button is focused"
+ );
+ Assert.ok(
+ aboutMessage.document.activeElement.tabIndex == 0 &&
+ previousElement.tabIndex == -1,
+ "The roving tab index was updated"
+ );
+ msgHdrActiveElement = aboutMessage.document.activeElement;
+
+ // Simulate Enter and Space keypress events to ensure the menus in the
+ // message header buttons area are keyboard accessible.
+ if (
+ msgHdrActiveElement.hasAttribute("type") &&
+ msgHdrActiveElement.getAttribute("type") == "menu"
+ ) {
+ let parentID = msgHdrActiveElement.parentElement.id;
+ for (let menupopup of msgHdrMenupopups) {
+ if (
+ menupopup.id.replace("Dropdown", "") ==
+ parentID.replace("Button", "") ||
+ menupopup.id.replace("Popup", "") ==
+ msgHdrActiveElement.id.replace("Button", "")
+ ) {
+ menupopupToOpen = menupopup;
+ }
+ }
+
+ let menupopupOpenEnterPromise = BrowserTestUtils.waitForEvent(
+ menupopupToOpen,
+ "popupshown"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, about3Pane);
+ await menupopupOpenEnterPromise;
+
+ let menupopupClosePromise = BrowserTestUtils.waitForEvent(
+ menupopupToOpen,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menupopupClosePromise;
+
+ Assert.equal(
+ msgHdrActiveElement.id,
+ headerButtons[i].id,
+ "The correct button is focused"
+ );
+
+ let menupopupOpenSpacePromise = BrowserTestUtils.waitForEvent(
+ menupopupToOpen,
+ "popupshown"
+ );
+ // Simulate Space keypress.
+ EventUtils.synthesizeKey(" ", {}, about3Pane);
+ await menupopupOpenSpacePromise;
+
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menupopupClosePromise;
+
+ Assert.equal(
+ msgHdrActiveElement.id,
+ headerButtons[i].id,
+ "The correct button is focused after opening and closing the menupopup"
+ );
+ }
+ }
+
+ // Simulate the Arrow Left keypress to make sure the correct button gets the
+ // focus.
+ for (let i = headerButtons.length - 2; i > -1; i--) {
+ let previousElement = document.activeElement;
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, about3Pane);
+ Assert.equal(
+ aboutMessage.document.activeElement.id,
+ headerButtons[i].id,
+ "The previous button is focused"
+ );
+ Assert.ok(
+ aboutMessage.document.activeElement.tabIndex == 0 &&
+ previousElement.tabIndex == -1,
+ "The roving tab index was updated"
+ );
+ }
+ EventUtils.synthesizeKey("KEY_Tab", {}, about3Pane);
+ Assert.equal(
+ aboutMessage.document.activeElement.id,
+ "fromRecipient0",
+ "The sender is now focused"
+ );
+}).__skipMe = AppConstants.platform == "macosx";
+
+// Full keyboard navigation on OSX only works if Full Keyboard Access setting is
+// set to All Control in System Keyboard Preferences. This also works with the
+// setting, Keyboard > Keyboard navigation, in addition to
+// Accessibility > Keyboard > Full Keyboard Access.
+
+add_task(function test_more_button_with_many_recipients() {
+ // Start on the interesting message.
+ let curMessage = select_click_row(0);
+
+ // Make sure it loads.
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ // Click on the "more" button.
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("expandedccBox").moreButton,
+ {},
+ aboutMessage
+ );
+
+ let msgHeader = aboutMessage.document.getElementById("messageHeader");
+ // Check that the message header can scroll to fit all recipients.
+ Assert.ok(
+ msgHeader.classList.contains("scrollable"),
+ "The message header is scrollable"
+ );
+
+ // Switch to the boring message, to force the more button to collapse.
+ curMessage = select_click_row(1);
+
+ // Make sure it loads.
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ // Check that the message header is not scrollable anymore
+ Assert.notEqual(
+ msgHeader.classList.contains("scrollable"),
+ "The message header is not scrollable"
+ );
+});
+
+/**
+ * Test that clicking the add to address book button updates the UI properly.
+ *
+ * @param {HTMLOListElement} recipientsList
+ */
+function subtest_more_widget_ab_button_click(recipientsList) {
+ let recipient = get_last_visible_address(recipientsList);
+ ensure_no_card_exists(recipient.emailAddress);
+
+ // Scroll to the bottom first so the address is in view.
+ let view = aboutMessage.document.getElementById("messageHeader");
+ view.scrollTop = view.scrollHeight - view.clientHeight;
+
+ EventUtils.synthesizeMouseAtCenter(recipient.abIndicator, {}, aboutMessage);
+
+ Assert.ok(
+ recipient.abIndicator.classList.contains("in-address-book"),
+ "The recipient was added to the Address Book"
+ );
+}
+
+/**
+ * Test that we can open up the inline contact editor when we
+ * click on the address book button.
+ */
+add_task(async function test_clicking_ab_button_opens_inline_contact_editor() {
+ // Make sure we're in the right folder.
+ await be_in_folder(folder);
+ // Add a new message.
+ let msg = create_message();
+ await add_message_to_folder([folder], msg);
+ // Open the latest message.
+ select_click_row(-1);
+ wait_for_message_display_completion(mc);
+
+ // Ensure that the inline contact editing panel is not open
+ let contactPanel = aboutMessage.document.getElementById("editContactPanel");
+ Assert.notEqual(contactPanel.state, "open");
+
+ let recipientsList =
+ aboutMessage.document.getElementById("expandedtoBox").recipientsList;
+ subtest_more_widget_ab_button_click(recipientsList);
+
+ // Ok, if we're here, then the star has been clicked, and
+ // the contact has been added to our AB.
+ let recipient = get_last_visible_address(recipientsList);
+
+ let panelOpened = TestUtils.waitForCondition(
+ () => contactPanel.state == "open",
+ "The contactPanel was opened"
+ );
+ // Click on the star, and ensure that the inline contact editing panel opens.
+ EventUtils.synthesizeMouseAtCenter(recipient.abIndicator, {}, aboutMessage);
+ await panelOpened;
+
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("editContactPanelEditDetailsButton"),
+ {},
+ aboutMessage
+ );
+ wait_for_content_tab_load(undefined, "about:addressbook");
+ // TODO check the card.
+ mc.window.document.getElementById("tabmail").closeTab();
+});
+
+/**
+ * Test that clicking references context menu works properly.
+ */
+add_task(async function test_msg_id_context_menu() {
+ Services.prefs.setBoolPref("mailnews.headers.showReferences", true);
+
+ // Add a new message.
+ let msg = create_message({
+ clobberHeaders: {
+ References:
+ "<4880C986@example.com> <4880CAB2@example.com> <4880CC76@example.com>",
+ },
+ });
+ await add_message_to_folder([folder], msg);
+ await be_in_folder(folder);
+
+ // Open the latest message.
+ select_click_row(-1);
+
+ // Right click to show the context menu.
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.querySelector(
+ "#expandedreferencesBox .header-message-id"
+ ),
+ { type: "contextmenu" },
+ aboutMessage
+ );
+ await wait_for_popup_to_open(
+ aboutMessage.document.getElementById("messageIdContext")
+ );
+
+ // Ensure Open Message For ID is shown and that Open Browser With Message-ID
+ // isn't shown.
+ Assert.ok(
+ !aboutMessage.document.getElementById(
+ "messageIdContext-openMessageForMsgId"
+ ).hidden,
+ "The menu item is hidden"
+ );
+ Assert.ok(
+ aboutMessage.document.getElementById(
+ "messageIdContext-openBrowserWithMsgId"
+ ).hidden,
+ "The menu item is visible"
+ );
+
+ await close_popup(
+ mc,
+ aboutMessage.document.getElementById("messageIdContext")
+ );
+
+ // Reset the preferences.
+ Services.prefs.setBoolPref("mailnews.headers.showReferences", false);
+});
+
+/**
+ * Test that if a contact belongs to a mailing list within their address book,
+ * then the inline contact editor will not allow the user to change what address
+ * book the contact belongs to. The editor should also show a message to explain
+ * why the contact cannot be moved.
+ */
+add_task(
+ async function test_address_book_switch_disabled_on_contact_in_mailing_list() {
+ const MAILING_LIST_DIRNAME = "Some Mailing List";
+ const ADDRESS_BOOK_NAME = "Some Address Book";
+ // Add a new message.
+ let msg = create_message();
+ await add_message_to_folder([folder], msg);
+
+ // Make sure we're in the right folder.
+ await be_in_folder(folder);
+
+ // Open the latest message.
+ select_click_row(-1);
+
+ // Ensure that the inline contact editing panel is not open
+ let contactPanel = aboutMessage.document.getElementById("editContactPanel");
+ Assert.notEqual(contactPanel.state, "open");
+
+ let recipientsList =
+ aboutMessage.document.getElementById("expandedtoBox").recipientsList;
+ subtest_more_widget_ab_button_click(recipientsList);
+
+ // Ok, if we're here, then the star has been clicked, and
+ // the contact has been added to our AB.
+ let recipient = get_last_visible_address(recipientsList);
+
+ let panelOpened = TestUtils.waitForCondition(
+ () => contactPanel.state == "open",
+ "The contactPanel was opened"
+ );
+ // Click on the address book button, and ensure that the inline contact
+ // editing panel opens.
+ EventUtils.synthesizeMouseAtCenter(recipient.abIndicator, {}, aboutMessage);
+ await panelOpened;
+
+ let abDrop = aboutMessage.document.getElementById(
+ "editContactAddressBookList"
+ );
+ // Ensure that the address book dropdown is not disabled
+ Assert.ok(!abDrop.disabled);
+
+ let warningMsg = aboutMessage.document.getElementById(
+ "contactMoveDisabledText"
+ );
+ // We should not be displaying any warning
+ Assert.ok(warningMsg.hidden);
+
+ // Now close the popup.
+ contactPanel.hidePopup();
+
+ // For the contact that was added, create a mailing list in the address book
+ // it resides in, and then add that contact to the mailing list.
+ let cards = get_cards_in_all_address_books_for_email(
+ recipient.emailAddress
+ );
+
+ // There should be only one copy of this email address in the address books.
+ Assert.equal(cards.length, 1);
+
+ // Remove the card from any of the address books.s
+ ensure_no_card_exists(recipient.emailAddress);
+
+ // Add the card to a new address book, and insert it into a mailing list
+ // under that address book.
+ let ab = create_address_book(ADDRESS_BOOK_NAME);
+ ab.dropCard(cards[0], false);
+ let ml = create_mailing_list(MAILING_LIST_DIRNAME);
+ ab.addMailList(ml);
+
+ // Now we have to retrieve the mailing list from the address book, in order
+ // for us to add and delete cards from it.
+ ml = get_mailing_list_from_address_book(ab, MAILING_LIST_DIRNAME);
+ ml.addCard(cards[0]);
+
+ // Click on the address book button, and ensure that the inline contact
+ // editing panel opens.
+ EventUtils.synthesizeMouseAtCenter(recipient.abIndicator, {}, aboutMessage);
+ await panelOpened;
+
+ // The dropdown should be disabled now
+ Assert.ok(abDrop.disabled);
+ // We should be displaying a warning
+ Assert.ok(!warningMsg.hidden);
+
+ contactPanel.hidePopup();
+
+ // And if we remove the contact from the mailing list, the warning should be
+ // gone and the address book switching menu re-enabled.
+ ml.deleteCards([cards[0]]);
+
+ // Click on the address book button, and ensure that the inline contact
+ // editing panel opens.
+ EventUtils.synthesizeMouseAtCenter(recipient.abIndicator, {}, aboutMessage);
+ await panelOpened;
+
+ // Ensure that the address book dropdown is not disabled
+ Assert.ok(!abDrop.disabled);
+ // We should not be displaying any warning
+ Assert.ok(warningMsg.hidden);
+
+ contactPanel.hidePopup();
+ }
+);
+
+/**
+ * Test that clicking the adding an address node adds it to the address book.
+ */
+add_task(async function test_add_contact_from_context_menu() {
+ let popup = aboutMessage.document.getElementById("emailAddressPopup");
+ let popupShown = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ // Click the contact to show the emailAddressPopup popup menu.
+ let recipient = aboutMessage.document.querySelector(
+ "#expandedfromBox .header-recipient"
+ );
+ EventUtils.synthesizeMouseAtCenter(recipient, {}, aboutMessage);
+ await popupShown;
+
+ const addToAddressBookItem = aboutMessage.document.getElementById(
+ "addToAddressBookItem"
+ );
+ Assert.ok(!addToAddressBookItem.hidden, "addToAddressBookItem is not hidden");
+
+ const editContactItem =
+ aboutMessage.document.getElementById("editContactItem");
+ Assert.ok(editContactItem.hidden, "editContactItem is hidden");
+
+ let recipientAdded = TestUtils.waitForCondition(
+ () => recipient.abIndicator.classList.contains("in-address-book"),
+ "The recipient was added to the address book"
+ );
+
+ // Click the Add to Address Book context menu entry.
+ // NOTE: Use activateItem because macOS uses native context menus.
+ popup.activateItem(addToAddressBookItem);
+ // (for reasons unknown, the pop-up does not close itself)
+ await close_popup(mc, popup);
+ await recipientAdded;
+
+ // NOTE: We need to redefine these selectors otherwise the popup will not
+ // properly close for some reason.
+ let popup2 = aboutMessage.document.getElementById("emailAddressPopup");
+ let popupShown2 = BrowserTestUtils.waitForEvent(popup, "popupshown");
+
+ // Now click the contact again, the context menu should now show the Edit
+ // Contact menu instead.
+ EventUtils.synthesizeMouseAtCenter(recipient, {}, aboutMessage);
+ await popupShown2;
+ // (for reasons unknown, the pop-up does not close itself)
+ await close_popup(mc, popup2);
+
+ Assert.ok(addToAddressBookItem.hidden, "addToAddressBookItem is hidden");
+ Assert.ok(!editContactItem.hidden, "editContactItem is not hidden");
+});
+
+add_task(async function test_that_msg_without_date_clears_previous_headers() {
+ await be_in_folder(folder);
+
+ // Create a message with a descriptive subject.
+ let msg = create_message({ subject: "this is without date" });
+
+ // Ensure that this message doesn't have a Date header.
+ delete msg.headers.Date;
+
+ // Sdd the message to the end of the folder.
+ await add_message_to_folder([folder], msg);
+
+ // Not the first anymore. The timestamp is that of "NOW".
+ // Select and open the LAST message.
+ let curMessage = select_click_row(-1);
+
+ // Make sure it loads.
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ // Since we didn't give create_message an argument that would create a
+ // Newsgroups header, the newsgroups <row> element should be collapsed.
+ // However, since the previously displayed message _did_ have such a header,
+ // certain bugs in the display of this header could cause the collapse
+ // never to have happened.
+ Assert.ok(
+ aboutMessage.document.getElementById("expandednewsgroupsRow").hidden,
+ "The Newsgroups header row is hidden."
+ );
+});
+
+/**
+ * Get the number of lines in one of the multi-recipient-row fields.
+ *
+ * @param {HTMLOListElement} node - The recipients container of a header row.
+ * @returns {int} - The number of rows.
+ */
+function help_get_num_lines(node) {
+ let style = getComputedStyle(node.firstElementChild);
+ return Math.round(
+ parseFloat(getComputedStyle(node).height) /
+ parseFloat(style.height + style.paddingTop + style.paddingBottom)
+ );
+}
+
+/**
+ * Test that the "more" button displays when it should.
+ *
+ * @param {HTMLOListElement} node - The recipients container of a header row.
+ * @param {boolean} [showAll=false] - If we're currently showing all the
+ * recipients.
+ */
+async function subtest_more_widget_display(node, showAll = false) {
+ // Test that the `To` element doesn't have more than max lines.
+ let numLines = help_get_num_lines(node);
+ // Get the max line pref.
+ let maxLines = Services.prefs.getIntPref(LINES_PREF);
+
+ if (showAll) {
+ await BrowserTestUtils.waitForCondition(
+ () => numLines > maxLines,
+ `Currently visible lines are more than the number of max lines. ${numLines} > ${maxLines}`
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !aboutMessage.document
+ .getElementById("expandedtoBox")
+ .querySelector(".show-more-recipients"),
+ "The `more` button doesn't exist."
+ );
+ } else {
+ await BrowserTestUtils.waitForCondition(
+ () => numLines <= maxLines,
+ `Currently visible lines are fewer than the number of max lines. ${numLines} <= ${maxLines}`
+ );
+ // Test that we've got a "more" button and that it's visible.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !aboutMessage.document.getElementById("expandedtoBox").moreButton
+ .hidden,
+ "The `more` button is visible."
+ );
+ }
+}
+
+/**
+ * Test that activating the "more" button displays all the addresses.
+ *
+ * @param {HTMLOListElement} node - The recipients container of a header row.
+ */
+function subtest_more_widget_activate(node) {
+ let oldNumLines = help_get_num_lines(node);
+
+ let moreButton = node.querySelector(".show-more-recipients");
+ Assert.ok(moreButton, "The more button should exist");
+ moreButton.focus();
+ // Activate the "more" button.
+ EventUtils.synthesizeKey("KEY_Enter", {}, aboutMessage);
+
+ // Make sure that the "more" button was removed when showing all addresses.
+ Assert.ok(
+ !node.querySelector(".show-more-recipients"),
+ "The more button should not exist anymore."
+ );
+
+ // Test that we actually have more lines than we did before!
+ let newNumLines = help_get_num_lines(node);
+ Assert.greater(
+ newNumLines,
+ oldNumLines,
+ "Number of address lines present increases after more click"
+ );
+}
+
+/**
+ * Test the behavior of the "more" button.
+ */
+add_task(async function test_view_more_button() {
+ // Generate message with 35 recipients to guarantee overflow.
+ await be_in_folder(folder);
+ let msg = create_message({
+ toCount: 35,
+ subject: "Many To addresses to test_more_widget",
+ });
+
+ // Add the message to the end of the folder.
+ await add_message_to_folder([folder], msg);
+
+ // Select and open the injected message.
+ // It is at the second last message in the display list.
+ let curMessage = select_click_row(-2);
+ // FIXME: Switch between a couple of messages to allow the UI to properly
+ // refresh and fetch the proper recipients row width in order to avoid an
+ // unexpected recipients wrapping. This happens because the width calculation
+ // happens before the message header layout is fully generated.
+ let prevMessage = select_click_row(-3);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, prevMessage);
+
+ curMessage = select_click_row(-2);
+
+ // Make sure it loads.
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ // Get the sender address.
+ let node =
+ aboutMessage.document.getElementById("expandedtoBox").recipientsList;
+ await subtest_more_widget_display(node);
+ subtest_more_widget_activate(node);
+});
+
+/**
+ * Test the focus behavior when activating the more button.
+ */
+add_task(async function test_view_more_button_focus() {
+ // Generate message with 35 recipients to guarantee overflow.
+ await be_in_folder(folder);
+ let msg = create_message({
+ toCount: 35,
+ subject: "Test more button focus",
+ });
+
+ // Add the message to the end of the folder.
+ await add_message_to_folder([folder], msg);
+
+ for (let { focusMore, useKeyboard } of [
+ { focusMore: true, useKeyboard: false },
+ { focusMore: true, useKeyboard: true },
+ { focusMore: false, useKeyboard: false },
+ ]) {
+ // Reload the message.
+ let prevMessage = select_click_row(-1);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, prevMessage);
+
+ let curMessage = select_click_row(-2);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ let items = [
+ ...aboutMessage.document.querySelectorAll(
+ "#expandedtoBox .recipients-list li"
+ ),
+ ];
+ Assert.greater(items.length, 2, "Should have enough items for the test");
+ let moreButton = aboutMessage.document.querySelector(
+ "#expandedtoBox .show-more-recipients"
+ );
+ Assert.ok(moreButton, "The more button should exist");
+ Assert.ok(
+ items[items.length - 1].contains(moreButton),
+ "More button should be the final button in the list"
+ );
+ let index;
+ if (focusMore) {
+ index = items.length - 1;
+ moreButton.focus();
+ Assert.ok(
+ moreButton.matches(":focus"),
+ "The more button can receive focus"
+ );
+ } else {
+ index = 1;
+ items[1].focus();
+ Assert.ok(
+ items[1].matches(":focus"),
+ "The second item can receive focus"
+ );
+ }
+ if (useKeyboard) {
+ EventUtils.synthesizeKey("KEY_Enter", {}, aboutMessage);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, aboutMessage);
+ }
+
+ Assert.ok(
+ !aboutMessage.document.querySelector(
+ "#expandedtoBox .show-more-recipients"
+ ),
+ "The more button should be removed"
+ );
+ items = [
+ ...aboutMessage.document.querySelectorAll(
+ "#expandedtoBox .recipients-list li"
+ ),
+ ];
+ Assert.ok(
+ items[index].matches(":focus"),
+ `The focus should be on item ${index}`
+ );
+ }
+});
+
+/**
+ * Test that all addresses are shown in show all header mode.
+ */
+add_task(async function test_show_all_header_mode() {
+ async function toggle_header_mode(show) {
+ let popup = document.getElementById("otherActionsPopup");
+ let popupShown = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("otherActionsButton"),
+ {},
+ mc.window
+ );
+ await popupShown;
+
+ let panel = document.getElementById("messageHeaderCustomizationPanel");
+ let customizeBtn = document.getElementById(
+ "messageHeaderMoreMenuCustomize"
+ );
+ let panelShown = BrowserTestUtils.waitForEvent(panel, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(customizeBtn, {}, mc.window);
+ await panelShown;
+
+ let viewAllHeaders = document.getElementById("headerViewAllHeaders");
+
+ let modeChanged = await TestUtils.waitForCondition(
+ () =>
+ document
+ .getElementById("messageHeader")
+ .getAttribute("show_header_mode") == show
+ ? "all"
+ : "normal",
+ "Message header updated correctly"
+ );
+ EventUtils.synthesizeMouseAtCenter(viewAllHeaders, {}, mc.window);
+ await modeChanged;
+
+ Assert.ok(
+ viewAllHeaders.checked == show,
+ "The view all headers checkbox was updated to the correct state"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ aboutMessage.document.getElementById("expandedsubjectBox").value
+ .textContent,
+ "The message was loaded"
+ );
+
+ let panelHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ await panelHidden;
+ }
+
+ // Generate message with 35 recipients.
+ await be_in_folder(folder);
+ let msg = create_message({
+ toCount: 35,
+ subject: "many To addresses for test_show_all_header_mode",
+ });
+
+ // Add the message to the end of the folder.
+ await add_message_to_folder([folder], msg);
+
+ // Select and open the added message.
+ // It is at the second last position in the display list.
+ let curMessage = select_click_row(-2);
+
+ // Make sure it loads.
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ await toggle_header_mode(true);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+ let node =
+ aboutMessage.document.getElementById("expandedtoBox").recipientsList;
+ await subtest_more_widget_display(node, true);
+
+ await toggle_header_mode(false);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+ await subtest_more_widget_display(node);
+ subtest_more_widget_activate(node);
+ await subtest_more_widget_display(node, true);
+}).skip();
+
+async function help_test_starred_messages() {
+ await be_in_folder(folder);
+
+ // Select the last message, which will display it.
+ let curMessage = select_click_row(-1);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ let starButton = aboutMessage.document.getElementById("starMessageButton");
+ // The message shouldn't be starred.
+ Assert.ok(
+ !starButton.classList.contains("flagged"),
+ "The message is not starred"
+ );
+
+ // Press s to mark the message as starred.
+ EventUtils.synthesizeKey("s", {}, aboutMessage);
+ // The message should be starred.
+ Assert.ok(starButton.classList.contains("flagged"), "The message is starred");
+
+ // Click on the star button.
+ EventUtils.synthesizeMouseAtCenter(starButton, {}, aboutMessage);
+ // The message shouldn't be starred.
+ Assert.ok(
+ !starButton.classList.contains("flagged"),
+ "The message is not starred"
+ );
+
+ // Click again on the star button.
+ EventUtils.synthesizeMouseAtCenter(starButton, {}, aboutMessage);
+ // The message should be starred.
+ Assert.ok(starButton.classList.contains("flagged"), "The message is starred");
+
+ // Select the first message.
+ curMessage = select_click_row(0);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ // The newly selected message shouldn't be starred.
+ Assert.ok(
+ !starButton.classList.contains("flagged"),
+ "The message is not starred"
+ );
+
+ // Select again the last message.
+ curMessage = select_click_row(-1);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ // The message should still be starred.
+ Assert.ok(starButton.classList.contains("flagged"), "The message is starred");
+
+ let hdr = folder.msgDatabase.getMsgHdrForMessageID(curMessage.messageId);
+ // Update the starred state not through a click on the star button, to make
+ // sure the method works.
+ hdr.markFlagged(false);
+ // The message should be starred.
+ Assert.ok(
+ !starButton.classList.contains("flagged"),
+ "The message is not starred"
+ );
+}
+
+/**
+ * Test the marking of a message as starred, be sure the header is properly
+ * updated and changing selected message doesn't affect the state of others.
+ */
+add_task(async function test_starred_message() {
+ await help_test_starred_messages();
+});
+
+add_task(async function test_starred_message_unified_mode() {
+ mc.window.document.getElementById(
+ "tabmail"
+ ).currentTabInfo.folderPaneVisible = true;
+ select_none();
+ // Show the "Unified" folders view.
+ mc.folderTreeView.activeModes = "smart";
+ // Hide the all folders view. The activeModes setter takes care of removing
+ // the mode is is already visible.
+ mc.folderTreeView.activeModes = "all";
+
+ await help_test_starred_messages();
+
+ mc.window.document.getElementById(
+ "tabmail"
+ ).currentTabInfo.folderPaneVisible = false;
+ select_none();
+ // Show the "All" folders view.
+ mc.folderTreeView.activeModes = "all";
+ // Hide the "Unified" folders view. The activeModes setter takes care of
+ // removing the mode is is already visible.
+ mc.folderTreeView.activeModes = "smart";
+}).skip();
+/**
+ * Test the DBListener to be sure is initialized and cleared when needed, and it
+ * doesn't change when not needed.
+ */
+add_task(async function test_folder_db_listener() {
+ await be_in_folder(folderMore);
+ // Select the last message, which will display it.
+ let curMessage = select_click_row(-1);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ Assert.ok(
+ aboutMessage.gFolderDBListener.isRegistered,
+ "The folder DB listener was initialized"
+ );
+ Assert.equal(
+ folderMore,
+ aboutMessage.gFolderDBListener.selectedFolder,
+ "The current folder was stored correctly"
+ );
+
+ // Keep a reference before it gets cleared.
+ let gFolderDBRef = aboutMessage.gFolderDBListener;
+
+ // Collapse the message pane.
+ aboutMessage.HideMessageHeaderPane();
+
+ Assert.ok(!gFolderDBRef.isRegistered, "The folder DB listener was cleared");
+ Assert.equal(
+ folderMore,
+ gFolderDBRef.selectedFolder,
+ "The current folder wasn't cleared and is still the same"
+ );
+
+ // Change folder
+ await be_in_folder(folder);
+
+ // Select the last message, which will display it.
+ curMessage = select_click_row(-1);
+ wait_for_message_display_completion(mc);
+ assert_selected_and_displayed(mc, curMessage);
+
+ Assert.ok(
+ aboutMessage.gFolderDBListener?.isRegistered,
+ "The folder DB listener was initialized"
+ );
+ Assert.equal(
+ folder,
+ aboutMessage.gFolderDBListener.selectedFolder,
+ "The current folder was stored correctly"
+ );
+});
+
+/**
+ * Remove the reference to the accessibility service so that it stops observing
+ * vsync notifications at the end of the test.
+ */
+add_task(function cleanup() {
+ gAccService = null;
+ // The actual reference to the XPCOM object will be dropped at the next GC,
+ // so force one to happen immediately.
+ Cu.forceGC();
+});
diff --git a/comm/mail/test/browser/message-header/browser_messageHeaderCustomize.js b/comm/mail/test/browser/message-header/browser_messageHeaderCustomize.js
new file mode 100644
index 0000000000..67ca412587
--- /dev/null
+++ b/comm/mail/test/browser/message-header/browser_messageHeaderCustomize.js
@@ -0,0 +1,388 @@
+/* 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 the message header customization features.
+ */
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_popup,
+ create_folder,
+ create_message,
+ gDefaultWindowHeight,
+ get_smart_folder_named,
+ get_about_3pane,
+ get_about_message,
+ inboxFolder,
+ mc,
+ msgGen,
+ restore_default_window_size,
+ select_click_row,
+ select_none,
+ wait_for_message_display_completion,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+let about3Pane = get_about_3pane();
+let aboutMessage = get_about_message();
+
+var { MailTelemetryForTests } = ChromeUtils.import(
+ "resource:///modules/MailGlue.jsm"
+);
+var { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+var gFolder;
+
+add_setup(async function () {
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ Services.telemetry.clearScalars();
+
+ let account = createAccount();
+ gFolder = await createSubfolder(account.incomingServer.rootFolder, "test0");
+ createMessages(gFolder, 1);
+
+ registerCleanupFunction(() => {
+ gFolder.deleteSelf(null);
+ MailServices.accounts.removeAccount(account, true);
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ });
+});
+
+add_task(async function test_customize_toolbar_buttons() {
+ be_in_folder(gFolder);
+ select_click_row(0);
+
+ let moreBtn = aboutMessage.document.getElementById("otherActionsButton");
+ // Make sure we loaded the expected message.
+ await assertVisibility(moreBtn, true, "The more button is visible");
+
+ // Confirm we're starting from a clean state.
+ let header = aboutMessage.document.getElementById("messageHeader");
+ Assert.ok(
+ header.classList.contains("message-header-show-recipient-avatar"),
+ "The From recipient is showing the avatar"
+ );
+ let avatar = aboutMessage.document.querySelector(".recipient-avatar");
+ await assertVisibility(avatar, true, "The recipient avatar is shown");
+
+ Assert.ok(
+ header.classList.contains("message-header-show-sender-full-address"),
+ "The From recipient is showing the full address on two lines"
+ );
+ let multiLine = aboutMessage.document.querySelector(".recipient-multi-line");
+ await assertVisibility(
+ multiLine,
+ true,
+ "The recipient multi line is visible"
+ );
+ let singleLine = aboutMessage.document.querySelector(
+ ".recipient-single-line"
+ );
+ await assertVisibility(
+ singleLine,
+ false,
+ "he recipient single line is hidden"
+ );
+
+ Assert.ok(
+ header.classList.contains("message-header-hide-label-column"),
+ "The labels column is hidden"
+ );
+
+ let firstLabel = aboutMessage.document.querySelector(".message-header-label");
+ Assert.equal(
+ firstLabel.style.minWidth,
+ "0px",
+ "The first label has no min-width value"
+ );
+ await assertVisibility(firstLabel, false, "The labels column is hidden");
+
+ Assert.ok(
+ header.classList.contains("message-header-large-subject"),
+ "The message header has a large subject"
+ );
+ Assert.ok(
+ !header.classList.contains("message-header-buttons-only-icons"),
+ "The message header buttons aren't showing only icons"
+ );
+ Assert.ok(
+ !header.classList.contains("message-header-buttons-only-text"),
+ "The message header buttons aren't showing only text"
+ );
+
+ MailTelemetryForTests.reportUIConfiguration();
+ let scalarName = "tb.ui.configuration.message_header";
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertScalarUnset(scalars, scalarName);
+
+ let popup = aboutMessage.document.getElementById("otherActionsPopup");
+ let popupShown = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreBtn, {}, aboutMessage);
+ await popupShown;
+
+ let panel = aboutMessage.document.getElementById(
+ "messageHeaderCustomizationPanel"
+ );
+ let customizeBtn = aboutMessage.document.getElementById(
+ "messageHeaderMoreMenuCustomize"
+ );
+ let panelShown = BrowserTestUtils.waitForEvent(panel, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(customizeBtn, {}, aboutMessage);
+ await panelShown;
+
+ let buttonStyle = aboutMessage.document.getElementById("headerButtonStyle");
+ // Assert the options are in a default state.
+ Assert.equal(
+ buttonStyle.value,
+ "default",
+ "The buttons style is in the default state"
+ );
+ let subjectLarge = aboutMessage.document.getElementById("headerSubjectLarge");
+ Assert.ok(subjectLarge.checked, "The subject field is in the default state");
+
+ let showAvatar = aboutMessage.document.getElementById("headerShowAvatar");
+ Assert.ok(
+ showAvatar.checked,
+ "The show avatar field is in the default state"
+ );
+
+ let showFullAddress = aboutMessage.document.getElementById(
+ "headerShowFullAddress"
+ );
+ Assert.ok(
+ showFullAddress.checked,
+ "The show full address field is in the default state"
+ );
+
+ let hideLabels = aboutMessage.document.getElementById("headerHideLabels");
+ Assert.ok(
+ hideLabels.checked,
+ "The hide labels field is in the default state"
+ );
+
+ let openMenuPopup = async function () {
+ aboutMessage.document.getElementById("headerButtonStyle").focus();
+
+ let menuPopupShown = BrowserTestUtils.waitForEvent(
+ aboutMessage.document.querySelector("#headerButtonStyle menupopup"),
+ "popupshown"
+ );
+ // Use the keyboard to open and cycle through the menulist items because the
+ // mouse events are unreliable in tests.
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("headerButtonStyle"),
+ {},
+ aboutMessage
+ );
+ await menuPopupShown;
+ };
+
+ // Cycle through the buttons style and confirm the style is properly applied.
+ // Use the keyboard to open and cycle through the menulist items because the
+ // mouse events are unreliable in tests.
+ await openMenuPopup();
+ EventUtils.sendKey("down", aboutMessage);
+ EventUtils.sendKey("return", aboutMessage);
+
+ await BrowserTestUtils.waitForCondition(
+ () => header.classList.contains("message-header-buttons-only-text"),
+ "The buttons are showing only text"
+ );
+ Assert.ok(
+ header.classList.contains("message-header-large-subject"),
+ "The subject line wasn't changed"
+ );
+ Assert.ok(
+ header.classList.contains("message-header-show-recipient-avatar"),
+ "The avatar visibility wasn't changed"
+ );
+ Assert.ok(
+ header.classList.contains("message-header-show-sender-full-address"),
+ "The full address visibility wasn't changed"
+ );
+ Assert.ok(
+ header.classList.contains("message-header-hide-label-column"),
+ "The labels column visibility wasn't changed"
+ );
+
+ await openMenuPopup();
+ EventUtils.sendKey("down", aboutMessage);
+ EventUtils.sendKey("return", aboutMessage);
+ await BrowserTestUtils.waitForCondition(
+ () => header.classList.contains("message-header-buttons-only-icons"),
+ "The buttons are showing only icons"
+ );
+ Assert.ok(
+ header.classList.contains("message-header-large-subject"),
+ "The subject line wasn't changed"
+ );
+
+ await openMenuPopup();
+ EventUtils.sendKey("up", aboutMessage);
+ EventUtils.sendKey("up", aboutMessage);
+ EventUtils.sendKey("return", aboutMessage);
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !header.classList.contains("message-header-buttons-only-icons") &&
+ !header.classList.contains("message-header-buttons-only-text") &&
+ header.classList.contains("message-header-large-subject") &&
+ header.classList.contains("message-header-show-recipient-avatar") &&
+ header.classList.contains("message-header-show-sender-full-address") &&
+ header.classList.contains("message-header-hide-label-column"),
+ "The message header is clear of any custom style"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(subjectLarge, {}, aboutMessage);
+ await BrowserTestUtils.waitForCondition(
+ () => !header.classList.contains("message-header-large-subject"),
+ "The subject line was changed"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(showAvatar, {}, aboutMessage);
+ await BrowserTestUtils.waitForCondition(
+ () => !header.classList.contains("message-header-show-recipient-avatar"),
+ "The avatar style was changed"
+ );
+ await assertVisibility(avatar, false, "The recipient avatar is hidden");
+
+ EventUtils.synthesizeMouseAtCenter(showFullAddress, {}, aboutMessage);
+ await BrowserTestUtils.waitForCondition(
+ () => !header.classList.contains("message-header-show-sender-full-address"),
+ "The full address style was changed"
+ );
+ await assertVisibility(
+ multiLine,
+ false,
+ "The recipient multi line is hidden"
+ );
+ await assertVisibility(
+ singleLine,
+ true,
+ "The recipient single line is visible"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(hideLabels, {}, aboutMessage);
+ await BrowserTestUtils.waitForCondition(
+ () => !header.classList.contains("message-header-hide-label-column"),
+ "The labels column style was changed"
+ );
+ await assertVisibility(firstLabel, true, "The first label is visible");
+ await BrowserTestUtils.waitForCondition(
+ () => firstLabel.style.minWidth != "0px",
+ "The first label has a min-width value"
+ );
+
+ await openMenuPopup();
+ EventUtils.sendKey("down", aboutMessage);
+ EventUtils.sendKey("down", aboutMessage);
+ EventUtils.sendKey("return", aboutMessage);
+ await BrowserTestUtils.waitForCondition(
+ () => header.classList.contains("message-header-buttons-only-icons"),
+ "The buttons are showing only icons"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => !header.classList.contains("message-header-large-subject"),
+ "The subject line edit was maintained"
+ );
+
+ let panelHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, aboutMessage);
+ await panelHidden;
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.xulStore.hasValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "messageHeader",
+ "layout"
+ ),
+ "The customization data was saved"
+ );
+
+ MailTelemetryForTests.reportUIConfiguration();
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "subjectLarge", 0);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "buttonStyle", 1);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "hideLabels", 0);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "showAvatar", 0);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "showFullAddress",
+ 0
+ );
+
+ popupShown = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreBtn, {}, aboutMessage);
+ await popupShown;
+
+ panelShown = BrowserTestUtils.waitForEvent(panel, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(customizeBtn, {}, aboutMessage);
+ await panelShown;
+
+ await openMenuPopup();
+ EventUtils.sendKey("up", aboutMessage);
+ EventUtils.sendKey("up", aboutMessage);
+ EventUtils.sendKey("return", aboutMessage);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !header.classList.contains("message-header-buttons-only-icons") &&
+ !header.classList.contains("message-header-buttons-only-text"),
+ "The buttons style was reverted to the default"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(subjectLarge, {}, aboutMessage);
+ EventUtils.synthesizeMouseAtCenter(showAvatar, {}, aboutMessage);
+ EventUtils.synthesizeMouseAtCenter(showFullAddress, {}, aboutMessage);
+ EventUtils.synthesizeMouseAtCenter(hideLabels, {}, aboutMessage);
+
+ panelHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, aboutMessage);
+ await panelHidden;
+
+ await BrowserTestUtils.waitForCondition(
+ () => header.classList.contains("message-header-large-subject"),
+ "The subject line is large again"
+ );
+ await assertVisibility(avatar, true, "The recipient avatar is visible");
+ await assertVisibility(
+ multiLine,
+ true,
+ "The recipient multi line is visible"
+ );
+ await assertVisibility(
+ singleLine,
+ false,
+ "he recipient single line is hidden"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => firstLabel.style.minWidth == "0px",
+ "The first label has no min-width value"
+ );
+ await assertVisibility(firstLabel, false, "The labels column is hidden");
+
+ MailTelemetryForTests.reportUIConfiguration();
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "subjectLarge", 1);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "buttonStyle", 0);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "hideLabels", 1);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "showAvatar", 1);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "showFullAddress",
+ 1
+ );
+});
diff --git a/comm/mail/test/browser/message-header/browser_phishingBar.js b/comm/mail/test/browser/message-header/browser_phishingBar.js
new file mode 100644
index 0000000000..f4b429725e
--- /dev/null
+++ b/comm/mail/test/browser/message-header/browser_phishingBar.js
@@ -0,0 +1,307 @@
+/* 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 that phishing notifications behave properly.
+ */
+
+"use strict";
+
+var { gMockExtProtSvcReg } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_message,
+ inboxFolder,
+ mc,
+ open_message_from_file,
+ select_click_row,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_notification_displayed,
+ get_notification_button,
+ wait_for_notification_to_show,
+ wait_for_notification_to_stop,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var {
+ async_plan_for_new_window,
+ close_window,
+ plan_for_modal_dialog,
+ plan_for_new_window,
+ wait_for_new_window,
+ click_menus_in_sequence,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+var folder;
+
+var kBoxId = "mail-notification-top";
+var kNotificationValue = "maybeScam";
+
+add_setup(async function () {
+ gMockExtProtSvcReg.register();
+
+ folder = await create_folder("PhishingBarA");
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ body: {
+ body: '<form action="http://localhost/download-me"><input></form>.',
+ contentType: "text/html",
+ },
+ })
+ );
+ await add_message_to_folder([folder], create_message());
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ body: {
+ body: "check out http://130.128.4.1. and http://130.128.4.2/.",
+ contentType: "text/plain",
+ },
+ })
+ );
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ body: {
+ body: '<a href="http://subdomain.google.com/">http://www.google.com</a>.',
+ contentType: "text/html",
+ },
+ })
+ );
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ body: {
+ body: '<a href="http://subdomain.google.com/">http://google.com</a>.',
+ contentType: "text/html",
+ },
+ })
+ );
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ body: {
+ body: '<a href="http://evilhost">http://localhost</a>.',
+ contentType: "text/html",
+ },
+ })
+ );
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ body: {
+ body: '<form action="http://localhost/download-me"><input></form>.',
+ contentType: "text/html",
+ },
+ })
+ );
+});
+
+registerCleanupFunction(() => {
+ gMockExtProtSvcReg.unregister();
+});
+
+/**
+ * Make sure the notification shows, and goes away once the Ignore menuitem
+ * is clicked.
+ */
+async function assert_ignore_works(aController) {
+ let aboutMessage = get_about_message(aController.window);
+ wait_for_notification_to_show(aboutMessage, kBoxId, kNotificationValue);
+ let prefButton = get_notification_button(
+ aboutMessage,
+ kBoxId,
+ kNotificationValue,
+ { popup: "phishingOptions" }
+ );
+ EventUtils.synthesizeMouseAtCenter(prefButton, {}, prefButton.ownerGlobal);
+ await click_menus_in_sequence(
+ aboutMessage.document.getElementById("phishingOptions"),
+ [{ id: "phishingOptionIgnore" }]
+ );
+ wait_for_notification_to_stop(aboutMessage, kBoxId, kNotificationValue);
+}
+
+/**
+ * Helper function to click the first link in a message if one is available.
+ */
+function click_link_if_available() {
+ let msgBody =
+ get_about_message().getMessagePaneBrowser().contentDocument.body;
+ if (msgBody.getElementsByTagName("a").length > 0) {
+ msgBody.getElementsByTagName("a")[0].click();
+ }
+}
+
+/**
+ * Test that when viewing a message, choosing ignore hides the the phishing
+ * notification.
+ */
+add_task(async function test_ignore_phishing_warning_from_message() {
+ let aboutMessage = get_about_message();
+
+ await be_in_folder(folder);
+ select_click_row(0);
+ await assert_ignore_works(mc);
+
+ select_click_row(1);
+ // msg 1 is normal -> no phishing warning
+ assert_notification_displayed(
+ aboutMessage,
+ kBoxId,
+ kNotificationValue,
+ false
+ );
+ select_click_row(0);
+ // msg 0 is a potential phishing attempt, but we ignored it so that should
+ // be remembered
+ assert_notification_displayed(
+ aboutMessage,
+ kBoxId,
+ kNotificationValue,
+ false
+ );
+});
+
+/**
+ * Test that when viewing en eml file, choosing ignore hides the phishing
+ * notification.
+ */
+add_task(async function test_ignore_phishing_warning_from_eml() {
+ let file = new FileUtils.File(getTestFilePath("data/evil.eml"));
+
+ let msgc = await open_message_from_file(file);
+ await assert_ignore_works(msgc);
+ close_window(msgc);
+}).skip();
+
+/**
+ * Test that when viewing an attached eml file, the phishing notification works.
+ */
+add_task(async function test_ignore_phishing_warning_from_eml_attachment() {
+ let file = new FileUtils.File(getTestFilePath("data/evil-attached.eml"));
+
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+
+ // Make sure the root message shows the phishing bar.
+ wait_for_notification_to_show(aboutMessage, kBoxId, kNotificationValue);
+
+ // Open the attached message.
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ aboutMessage.document
+ .getElementById("attachmentList")
+ .getItemAtIndex(0)
+ .attachment.open();
+ let msgc2 = await newWindowPromise;
+ wait_for_message_display_completion(msgc2, true);
+
+ // Now make sure the attached message shows the phishing bar.
+ wait_for_notification_to_show(
+ get_about_message(msgc2.window),
+ kBoxId,
+ kNotificationValue
+ );
+
+ close_window(msgc2);
+ close_window(msgc);
+}).skip();
+
+/**
+ * Test that when viewing a message with an auto-linked ip address, we don't
+ * get a warning when clicking the link.
+ * We'll have http://130.128.4.1 vs. http://130.128.4.1/
+ */
+add_task(async function test_no_phishing_warning_for_ip_sameish_text() {
+ await be_in_folder(folder);
+ select_click_row(2); // Mail with Public IP address.
+ click_link_if_available();
+ assert_notification_displayed(
+ get_about_message(),
+ kBoxId,
+ kNotificationValue,
+ false
+ ); // not shown
+});
+
+/**
+ * Test that when viewing a message with a link whose base domain matches but
+ * has a different subdomain (e.g. http://subdomain.google.com/ vs
+ * http://google.com/), we don't get a warning if the link is pressed.
+ */
+add_task(async function test_no_phishing_warning_for_subdomain() {
+ let aboutMessage = get_about_message();
+ await be_in_folder(folder);
+ select_click_row(3);
+ click_link_if_available();
+ assert_notification_displayed(
+ aboutMessage,
+ kBoxId,
+ kNotificationValue,
+ false
+ ); // not shown
+
+ select_click_row(4);
+ click_link_if_available();
+ assert_notification_displayed(
+ aboutMessage,
+ kBoxId,
+ kNotificationValue,
+ false
+ ); // not shown
+});
+
+/**
+ * Test that when clicking a link where the text and/or href
+ * has no TLD, we still warn as appropriate.
+ */
+add_task(async function test_phishing_warning_for_local_domain() {
+ await be_in_folder(folder);
+ select_click_row(5);
+
+ let dialogAppeared = false;
+
+ plan_for_modal_dialog("commonDialogWindow", function (ctrler) {
+ dialogAppeared = true;
+ });
+
+ click_link_if_available();
+
+ Assert.ok(dialogAppeared);
+});
+
+/**
+ * Test that we warn about emails which contain <form>s with action attributes.
+ */
+add_task(async function test_phishing_warning_for_action_form() {
+ await be_in_folder(folder);
+ select_click_row(6);
+ assert_notification_displayed(
+ get_about_message(),
+ kBoxId,
+ kNotificationValue,
+ true
+ ); // shown
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
+
+registerCleanupFunction(async function teardown() {
+ await be_in_folder(inboxFolder);
+ folder.deleteSelf(null);
+});
diff --git a/comm/mail/test/browser/message-header/browser_replyIdentity.js b/comm/mail/test/browser/message-header/browser_replyIdentity.js
new file mode 100644
index 0000000000..fa1c1a7dae
--- /dev/null
+++ b/comm/mail/test/browser/message-header/browser_replyIdentity.js
@@ -0,0 +1,231 @@
+/* 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/. */
+
+/**
+ * Tests that actions such as replying choses the most suitable identity.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_reply } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var testFolder = null;
+
+var identity1Email = "carl@example.com";
+var identity2Email = "lenny@springfield.invalid";
+
+add_setup(async function () {
+ addIdentitiesAndFolder();
+ // Msg #0
+ await add_message_to_folder(
+ [testFolder],
+ create_message({
+ from: "Homer <homer@example.com>",
+ to: "workers@springfield.invalid",
+ subject: "no matching identity, like bcc/list",
+ body: {
+ body: "Alcohol is a way of life, alcohol is my way of life, and I aim to keep it.",
+ },
+ clobberHeaders: {},
+ })
+ );
+ // Msg #1
+ await add_message_to_folder(
+ [testFolder],
+ create_message({
+ from: "Homer <homer@example.com>",
+ to: "powerplant-workers@springfield.invalid",
+ subject: "only delivered-to header matching identity",
+ body: {
+ body: "Just because I don't care doesn't mean I don't understand.",
+ },
+ clobberHeaders: {
+ "Delivered-To": "<" + identity2Email + ">",
+ },
+ })
+ );
+ // Msg #2
+ await add_message_to_folder(
+ [testFolder],
+ create_message({
+ from: "Homer <homer@example.com>",
+ to: "powerplant-workers@springfield.invalid, Apu <apu@test.invalid>",
+ cc: "other." + identity2Email,
+ subject: "subpart of cc address matching identity",
+ body: { body: "Blame the guy who doesn't speak Engish." },
+ clobberHeaders: {},
+ })
+ );
+ // Msg #3
+ await add_message_to_folder(
+ [testFolder],
+ create_message({
+ from: "Homer <homer@example.com>",
+ to: "Lenny <" + identity2Email + ">",
+ subject: "normal to:address match, with full name",
+ body: {
+ body: "Remember as far as anyone knows, we're a nice normal family.",
+ },
+ })
+ );
+ // Msg #4
+ await add_message_to_folder(
+ [testFolder],
+ create_message({
+ from: "Homer <homer@example.com>",
+ to: "powerplant-workers@springfield.invalid",
+ subject: "delivered-to header matching only subpart of identity email",
+ body: { body: "Mmmm...Forbidden donut" },
+ clobberHeaders: {
+ "Delivered-To": "<other." + identity2Email + ">",
+ },
+ })
+ );
+ // Msg #5
+ await add_message_to_folder(
+ [testFolder],
+ create_message({
+ from: identity2Email + " <" + identity2Email + ">",
+ to: "Marge <marge@example.com>",
+ subject: "from second self",
+ body: {
+ body: "All my life I've had one dream, to achieve my many goals.",
+ },
+ })
+ );
+});
+
+function addIdentitiesAndFolder() {
+ let server = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "Reply Identity Testing",
+ "pop3"
+ );
+ testFolder = server.rootFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .createLocalSubfolder("Replies");
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = identity1Email;
+
+ let identity2 = MailServices.accounts.createIdentity();
+ identity2.email = identity2Email;
+
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = server;
+ account.addIdentity(identity);
+ account.addIdentity(identity2);
+}
+
+function checkReply(replyWin, expectedFromEmail) {
+ let identityList = replyWin.window.document.getElementById("msgIdentity");
+ if (!identityList.selectedItem.label.includes(expectedFromEmail)) {
+ throw new Error(
+ "The From address is not correctly selected! Expected: " +
+ expectedFromEmail +
+ "; Actual: " +
+ identityList.selectedItem.label
+ );
+ }
+}
+
+add_task(async function test_reply_no_matching_identity() {
+ await be_in_folder(testFolder);
+
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ let replyWin = open_compose_with_reply();
+ // Should have selected the default identity.
+ checkReply(replyWin, identity1Email);
+ close_compose_window(replyWin);
+});
+
+add_task(async function test_reply_matching_only_deliveredto() {
+ await be_in_folder(testFolder);
+
+ let msg = select_click_row(1);
+ assert_selected_and_displayed(mc, msg);
+
+ let replyWin = open_compose_with_reply();
+ // Should have selected the second id, which is listed in Delivered-To:.
+ checkReply(replyWin, identity2Email);
+ close_compose_window(replyWin);
+}).skip();
+
+add_task(async function test_reply_matching_subaddress() {
+ await be_in_folder(testFolder);
+
+ let msg = select_click_row(2);
+ assert_selected_and_displayed(mc, msg);
+
+ let replyWin = open_compose_with_reply();
+ // Should have selected the first id, the email doesn't fully match.
+ // other.lenny != "our" lenny
+ checkReply(replyWin, identity1Email);
+ close_compose_window(replyWin);
+});
+
+add_task(async function test_reply_to_matching_second_id() {
+ await be_in_folder(testFolder);
+
+ let msg = select_click_row(3);
+ assert_selected_and_displayed(mc, msg);
+
+ let replyWin = open_compose_with_reply();
+ // Should have selected the second id, which was in To;.
+ checkReply(replyWin, identity2Email);
+ close_compose_window(replyWin);
+});
+
+add_task(async function test_deliveredto_to_matching_only_parlty() {
+ await be_in_folder(testFolder);
+
+ let msg = select_click_row(4);
+ assert_selected_and_displayed(mc, msg);
+
+ let replyWin = open_compose_with_reply();
+ // Should have selected the (default) first id.
+ checkReply(replyWin, identity1Email);
+ close_compose_window(replyWin);
+});
+
+/**
+ * A reply from self is treated as a follow-up. And this self
+ * was the second identity, so the reply should also be from the second identity.
+ */
+add_task(async function test_reply_to_self_second_id() {
+ await be_in_folder(testFolder);
+
+ let msg = select_click_row(5);
+ assert_selected_and_displayed(mc, msg);
+
+ let replyWin = open_compose_with_reply();
+ // Should have selected the second id, which was in From.
+ checkReply(replyWin, identity2Email);
+ close_compose_window(replyWin, false /* no prompt*/);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/message-header/browser_replyToListFromAddressSelection.js b/comm/mail/test/browser/message-header/browser_replyToListFromAddressSelection.js
new file mode 100644
index 0000000000..b72d28de45
--- /dev/null
+++ b/comm/mail/test/browser/message-header/browser_replyToListFromAddressSelection.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/. */
+
+/*
+ * Test for the most suitable identity in From address for reply-to-list
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_reply_to_list } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var { assert_selected_and_displayed, be_in_folder, mc, select_click_row } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var testFolder = null;
+var msgHdr = null;
+var replyToListWindow = null;
+
+var identityString1 = "tinderbox_correct_identity@foo.invalid";
+
+add_setup(function () {
+ addIdentitiesAndFolder();
+ addMessageToFolder(testFolder);
+});
+
+function addMessageToFolder(aFolder) {
+ var msgId = Services.uuid.generateUUID() + "@mozillamessaging.invalid";
+
+ var source =
+ "From - Sat Nov 1 12:39:54 2008\n" +
+ "X-Mozilla-Status: 0001\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "Delivered-To: <tinderbox_identity333@foo.invalid>\n" +
+ "Delivered-To: <" +
+ identityString1 +
+ ">\n" +
+ "Delivered-To: <tinderbox_identity555@foo.invalid>\n" +
+ "Message-ID: <" +
+ msgId +
+ ">\n" +
+ "Date: Wed, 11 Jun 2008 20:32:02 -0400\n" +
+ "From: Tester <tests@mozillamessaging.invalid>\n" +
+ "User-Agent: Thunderbird 3.0a2pre (Macintosh/2008052122)\n" +
+ "MIME-Version: 1.0\n" +
+ "List-ID: <list.mozillamessaging.invalid>\n" +
+ "List-Post: <list.mozillamessaging.invalid>, \n" +
+ " <mailto: list@mozillamessaging.invalid>\n" +
+ "To: recipient@mozillamessaging.invalid\n" +
+ "Subject: a subject\n" +
+ "Content-Type: text/html; charset=ISO-8859-1\n" +
+ "Content-Transfer-Encoding: 7bit\n" +
+ "\ntext body\n";
+
+ aFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ aFolder.gettingNewMessages = true;
+ aFolder.addMessage(source);
+ aFolder.gettingNewMessages = false;
+
+ return aFolder.msgDatabase.getMsgHdrForMessageID(msgId);
+}
+
+function addIdentitiesAndFolder() {
+ let identity2 = MailServices.accounts.createIdentity();
+ // identity.fullName = "Tinderbox_Identity1";
+ identity2.email = "tinderbox_identity1@foo.invalid";
+
+ let identity = MailServices.accounts.createIdentity();
+ // identity.fullName = "Tinderbox_Identity1";
+ identity.email = identityString1;
+
+ let server = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "Test Local Folders",
+ "pop3"
+ );
+ let localRoot = server.rootFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder = localRoot.createLocalSubfolder("Test Folder");
+
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = server;
+ account.addIdentity(identity);
+ account.addIdentity(identity2);
+}
+
+add_task(async function test_Reply_To_List_From_Address() {
+ await be_in_folder(testFolder);
+
+ let curMessage = select_click_row(0);
+ assert_selected_and_displayed(mc, curMessage);
+
+ replyToListWindow = open_compose_with_reply_to_list();
+
+ var identityList =
+ replyToListWindow.window.document.getElementById("msgIdentity");
+
+ // see if it's the correct identity selected
+ if (!identityList.selectedItem.label.includes(identityString1)) {
+ throw new Error(
+ "The From address is not correctly selected! Expected: " +
+ identityString1 +
+ "; Actual: " +
+ identityList.selectedItem.label
+ );
+ }
+
+ close_compose_window(replyToListWindow);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/message-header/browser_returnReceipt.js b/comm/mail/test/browser/message-header/browser_returnReceipt.js
new file mode 100644
index 0000000000..f48fa04313
--- /dev/null
+++ b/comm/mail/test/browser/message-header/browser_returnReceipt.js
@@ -0,0 +1,208 @@
+/* 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 return receipt (MDN) stuff.
+ */
+
+"use strict";
+
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { assert_notification_displayed } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+
+var folder;
+
+var kBoxId = "mail-notification-top";
+var kNotificationValue = "mdnRequested";
+
+add_setup(async function () {
+ folder = await create_folder("ReturnReceiptTest");
+
+ // Create a message that requests a return receipt.
+ let msg0 = create_message({
+ from: ["Ake", "ake@example.com"],
+ clobberHeaders: { "Disposition-Notification-To": "ake@example.com" },
+ });
+ await add_message_to_folder([folder], msg0);
+
+ // ... and one that doesn't request a return receipt.
+ let msg1 = create_message();
+ await add_message_to_folder([folder], msg1);
+
+ // Create a message that requests a return receipt to a different address.
+ let msg2 = create_message({
+ from: ["Mimi", "me@example.org"],
+ clobberHeaders: { "Disposition-Notification-To": "other@example.com" },
+ });
+ await add_message_to_folder([folder], msg2);
+
+ // Create a message that requests a return receipt to different addresses.
+ let msg3 = create_message({
+ from: ["Bobby", "bob@example.org"],
+ clobberHeaders: {
+ "Disposition-Notification-To": "ex1@example.com, ex2@example.com",
+ },
+ });
+ await add_message_to_folder([folder], msg3);
+
+ // Create a message that requests a return receipt using non-standard header.
+ let msg4 = create_message({
+ from: ["Ake", "ake@example.com"],
+ clobberHeaders: { "Return-Receipt-To": "ake@example.com" },
+ });
+ await add_message_to_folder([folder], msg4);
+
+ // Create a message that requests a return receipt to a different address
+ // using non-standard header.
+ let msg5 = create_message({
+ from: ["Mimi", "me@example.org"],
+ clobberHeaders: { "Return-Receipt-To": "other@example.com" },
+ });
+ await add_message_to_folder([folder], msg5);
+
+ // Create a message that requests a return receipt to different addresses
+ // using non-standard header.
+ let msg6 = create_message({
+ from: ["Bobby", "bob@example.org"],
+ clobberHeaders: { "Return-Receipt-To": "ex1@example.com, ex2@example.com" },
+ });
+ await add_message_to_folder([folder], msg6);
+
+ await be_in_folder(folder);
+});
+
+/** Utility to select a message. */
+function gotoMsg(row) {
+ let curMessage = select_click_row(row);
+ assert_selected_and_displayed(mc, curMessage);
+}
+
+/**
+ * Utility to make sure the MDN bar is shown / not shown.
+ */
+function assert_mdn_shown(shouldShow) {
+ assert_notification_displayed(
+ get_about_message(),
+ kBoxId,
+ kNotificationValue,
+ shouldShow
+ );
+}
+
+/**
+ * Utility function to make sure the notification contains a certain text.
+ */
+function assert_mdn_text_contains(text, shouldContain) {
+ let nb = get_about_message().document.getElementById(kBoxId);
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ let notificationText = box.currentNotification.messageText.textContent;
+ if (shouldContain && !notificationText.includes(text)) {
+ throw new Error(
+ "mdnBar should contain text=" +
+ text +
+ "; notificationText=" +
+ notificationText
+ );
+ }
+ if (!shouldContain && notificationText.includes(text)) {
+ throw new Error(
+ "mdnBar shouldn't contain text=" +
+ text +
+ "; notificationText=" +
+ notificationText
+ );
+ }
+}
+
+/**
+ * Test that return receipts are not shown when Disposition-Notification-To
+ * and Return-Receipt-To isn't set.
+ */
+add_task(function test_no_mdn_for_normal_msgs() {
+ gotoMsg(0); // TODO this shouldn't be needed but the selection goes to 0 on focus.
+ gotoMsg(1); // This message doesn't request a return receipt.
+ assert_mdn_shown(false);
+});
+
+/**
+ * Test that return receipts are shown when Disposition-Notification-To is set.
+ */
+add_task(function test_basic_mdn_shown() {
+ gotoMsg(0); // This message requests a return receipt.
+ assert_mdn_shown(true);
+ assert_mdn_text_contains("ake@example.com", false); // only name should show
+});
+
+/**
+ * Test that return receipts are shown when Return-Receipt-To is set.
+ */
+add_task(function test_basic_mdn_shown_nonrfc() {
+ gotoMsg(4); // This message requests a return receipt.
+ assert_mdn_shown(true);
+ assert_mdn_text_contains("ake@example.com", false); // only name should show
+});
+
+/**
+ * Test that return receipts warns when the mdn address is different.
+ * The RFC compliant version.
+ */
+add_task(function test_mdn_when_from_and_disposition_to_differs() {
+ gotoMsg(2); // Should display a notification with warning.
+ assert_mdn_shown(true);
+ assert_mdn_text_contains("other@example.com", true); // address should show
+});
+
+/**
+ * Test that return receipts warns when the mdn address is different.
+ * The RFC non-compliant version.
+ */
+add_task(function test_mdn_when_from_and_disposition_to_differs_nonrfc() {
+ gotoMsg(5); // Should display a notification with warning.
+ assert_mdn_shown(true);
+ assert_mdn_text_contains("other@example.com", true); // address should show
+});
+
+/**
+ * Test that return receipts warns when the mdn address consists of multiple
+ * addresses.
+ */
+add_task(function test_mdn_when_disposition_to_multi() {
+ gotoMsg(3);
+ // Should display a notification with warning listing all the addresses.
+ assert_mdn_shown(true);
+ assert_mdn_text_contains("ex1@example.com", true);
+ assert_mdn_text_contains("ex2@example.com", true);
+});
+
+/**
+ * Test that return receipts warns when the mdn address consists of multiple
+ * addresses. Non-RFC compliant version.
+ */
+add_task(function test_mdn_when_disposition_to_multi_nonrfc() {
+ gotoMsg(6);
+ // Should display a notification with warning listing all the addresses.
+ assert_mdn_shown(true);
+ assert_mdn_text_contains("ex1@example.com", true);
+ assert_mdn_text_contains("ex2@example.com", true);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/message-header/data/evil-attached.eml b/comm/mail/test/browser/message-header/data/evil-attached.eml
new file mode 100644
index 0000000000..24e3874ff4
--- /dev/null
+++ b/comm/mail/test/browser/message-header/data/evil-attached.eml
@@ -0,0 +1,22 @@
+Date: Mon, 10 Jan 2011 12:00:00 -0500
+From: phisher@evil.com
+To: user@example.com
+Subject: Please look at this attachment!
+Content-Type: multipart/mixed; boundary="BOUNDARY"
+
+This is a multi-part message in MIME format.
+--BOUNDARY
+Content-Type: text/html
+
+See below.
+--BOUNDARY
+Content-Type: message/rfc822
+
+Date: Mon, 10 Jan 2011 12:00:00 -0500
+From: phisher@evil.com
+To: user@example.com
+Subject: Please click on this link!
+Content-Type: text/html
+
+<form action="http://localhost/download-me"><input></form>
+--BOUNDARY--
diff --git a/comm/mail/test/browser/message-header/data/evil.eml b/comm/mail/test/browser/message-header/data/evil.eml
new file mode 100644
index 0000000000..938ade296b
--- /dev/null
+++ b/comm/mail/test/browser/message-header/data/evil.eml
@@ -0,0 +1,7 @@
+Date: Mon, 10 Jan 2011 12:00:00 -0500
+From: phisher@evil.com
+To: user@example.com
+Subject: Please click on this link!
+Content-Type: text/html
+
+<form action="http://localhost/download-me"><input></form>
diff --git a/comm/mail/test/browser/message-header/head.js b/comm/mail/test/browser/message-header/head.js
new file mode 100644
index 0000000000..2a2c6e1b6a
--- /dev/null
+++ b/comm/mail/test/browser/message-header/head.js
@@ -0,0 +1,190 @@
+/* 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"
+);
+
+registerCleanupFunction(() => {
+ let tabmail = document.getElementById("tabmail");
+ is(tabmail.tabInfo.length, 1);
+
+ 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();
+
+ Services.prefs.clearUserPref("mail.pane_config.dynamic");
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view"
+ );
+});
+
+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;
+}
+
+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 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;
+ let browser = tab.browser;
+
+ await promiseMessageLoaded(browser, msgHdr);
+ 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;
+ let browser = messageWindow.document.getElementById("messagepane");
+
+ await promiseMessageLoaded(browser, msgHdr);
+ return messageWindow;
+}
+
+async function promiseMessageLoaded(browser, msgHdr) {
+ let messageURI = msgHdr.folder.getUriForMsg(msgHdr);
+ messageURI = MailServices.messageServiceFromURI(messageURI).getUrlForUri(
+ messageURI,
+ null
+ );
+
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ !browser.currentURI?.equals(messageURI)
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ null,
+ uri => uri == messageURI.spec
+ );
+ }
+}
+
+async function assertVisibility(element, isVisible, msg) {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(element) == isVisible,
+ `The ${element.id} should be ${isVisible ? "visible" : "hidden"}: ${msg}`
+ );
+}
+
+/**
+ * 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/test/browser/message-reader/browser.ini b/comm/mail/test/browser/message-reader/browser.ini
new file mode 100644
index 0000000000..efa2035078
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/browser.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+prefs =
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+support-files = data/**
+
+[browser_androidMMS.js]
+[browser_bug594646.js]
+[browser_convertToEventOrTask.js]
+[browser_detectCharset.js]
+[browser_printing.js]
diff --git a/comm/mail/test/browser/message-reader/browser_androidMMS.js b/comm/mail/test/browser/message-reader/browser_androidMMS.js
new file mode 100644
index 0000000000..bde6dbbbf0
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/browser_androidMMS.js
@@ -0,0 +1,75 @@
+/**
+ * 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/.
+ */
+
+/**
+ * Tests that opening a message with bad Content-Location is able to show
+ * images correctly.
+ * The test messsage has a bad Content-Location. This should not prevent
+ * the html part from referring to the image parts by cid: correctly.
+ */
+
+"use strict";
+
+var { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+add_task(async function testMMS() {
+ let file = new FileUtils.File(
+ getTestFilePath("data/bug1774805_android_mms.eml")
+ );
+ let msgc = await open_message_from_file(file);
+
+ let imgs = msgc.window.content.document.querySelectorAll("img");
+ // There are dottedline600.gif, tbmobilespace.gif x 3, footer.gif.
+ Assert.equal(imgs.length, 5, "body should show all images");
+
+ let lines = msgc.window.content.document.querySelectorAll(
+ `img[src$="dottedline600.gif"]`
+ );
+ Assert.equal(lines.length, 1, "should have one dottedline600.gif");
+
+ let spacers = msgc.window.content.document.querySelectorAll(
+ `img[src$="tmobilespace.gif"]`
+ );
+ Assert.equal(spacers.length, 3, "should have three tmobilespace.gif");
+
+ let footer = msgc.window.content.document.querySelectorAll(
+ `img[src$="footer.gif"]`
+ );
+ Assert.equal(footer.length, 1, "should have one footer.gif");
+
+ for (var img of imgs) {
+ Assert.ok(
+ !img.matches(":-moz-broken"),
+ `img should not show broken: ${img.src}`
+ );
+ Assert.ok(
+ img.naturalWidth > 0,
+ `img should have natural width: ${img.src}`
+ );
+ }
+
+ Assert.ok(
+ msgc.window.content.document.body.textContent.includes(
+ "This is a sample SMS text to email"
+ ),
+ "Body should have the right text"
+ );
+
+ let aboutMessage = get_about_message(msgc.window);
+ let attachmentList = aboutMessage.document.getElementById("attachmentList");
+ Assert.equal(
+ attachmentList.childNodes.length,
+ 1,
+ "should have one attachment"
+ );
+
+ close_window(msgc);
+});
diff --git a/comm/mail/test/browser/message-reader/browser_bug594646.js b/comm/mail/test/browser/message-reader/browser_bug594646.js
new file mode 100644
index 0000000000..2d63629b6c
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/browser_bug594646.js
@@ -0,0 +1,92 @@
+/**
+ * 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/.
+ */
+
+/**
+ * Tests that opening an .eml file the body of the message is correct,
+ * that it hasn't been UTF-8 mojibake'd.
+ */
+
+"use strict";
+
+var { open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var gReferenceTextContent;
+
+add_setup(async function () {
+ gReferenceTextContent = await extract_eml_body_textcontent(
+ "./bug594646_reference.eml"
+ );
+});
+
+async function extract_eml_body_textcontent(eml) {
+ let file = new FileUtils.File(getTestFilePath(`data/${eml}`));
+ let msgc = await open_message_from_file(file);
+
+ // Be sure to view message body as Original HTML
+ msgc.window.MsgBodyAllowHTML();
+ let textContent = msgc.window.content.document.documentElement.textContent;
+
+ close_window(msgc);
+ return textContent;
+}
+
+/**
+ * Checks that the text content is equal for the .eml files.
+ */
+async function check_eml_textcontent(eml) {
+ let textContent = await extract_eml_body_textcontent(eml);
+ Assert.stringContains(textContent, "árvíztűrő tükörfúrógép");
+ Assert.stringContains(textContent, "ÃRVÃZTÅ°RÅ TÃœKÖRFÚRÓGÉP");
+}
+
+/**
+ * This test exercises the bug for reversed http-equiv, content order:
+ * <head>
+ * <meta content="text/html; charset=ISO-8859-2"; http-equiv="content-type">
+ * </head>
+ */
+add_task(
+ async function test_original_html_characters_head_meta_content_charset_httpEq() {
+ await check_eml_textcontent("./bug594646_reversed_order_8bit.eml");
+ await check_eml_textcontent("./bug594646_reversed_order_qp.eml");
+ await check_eml_textcontent("./bug594646_reversed_order_b64.eml");
+ }
+);
+
+/**
+ * This test exercises the bug for newline delimited charset:
+ * <head>
+ * <meta http-equiv="content-type" content="text/html;
+ * charset=ISO-8859-2">
+ * </head>
+ */
+add_task(
+ async function test_original_html_characters_head_meta_httpEq_content_newline_charset() {
+ await check_eml_textcontent("./bug594646_newline_charset_8bit.eml");
+ await check_eml_textcontent("./bug594646_newline_charset_qp.eml");
+ await check_eml_textcontent("./bug594646_newline_charset_b64.eml");
+ }
+);
+
+/**
+ * This test exercises the bug for newline delimited and reverse ordered http-equiv:
+ * <head>
+ * <meta content="text/html; charset=ISO-8859-2"
+ * http-equiv="content-type">
+ * </head>
+ */
+add_task(
+ async function test_original_html_characters_head_meta_content_charset_newline_httpEq() {
+ await check_eml_textcontent("./bug594646_newline_httpequiv_8bit.eml");
+ await check_eml_textcontent("./bug594646_newline_httpequiv_qp.eml");
+ await check_eml_textcontent("./bug594646_newline_httpequiv_b64.eml");
+ }
+);
diff --git a/comm/mail/test/browser/message-reader/browser_convertToEventOrTask.js b/comm/mail/test/browser/message-reader/browser_convertToEventOrTask.js
new file mode 100644
index 0000000000..d4f2a73155
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/browser_convertToEventOrTask.js
@@ -0,0 +1,129 @@
+/* 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/. */
+
+/**
+ * Tests that converting an email to an event/task works.
+ */
+
+"use strict";
+
+var {
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ mc,
+ open_message_from_file,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var folder;
+
+add_setup(async function () {
+ folder = await create_folder("ConvertToEvent");
+ // Enable home calendar.
+ cal.manager.getCalendars()[0].setProperty("disabled", false);
+
+ registerCleanupFunction(() => {
+ folder.deleteSelf(null);
+ cal.manager.getCalendars()[0].setProperty("disabled", true);
+ mc.window.document.documentElement.focus();
+ });
+});
+
+add_task(async function test_convertToEvent() {
+ let file = new FileUtils.File(getTestFilePath("data/multiparty.eml"));
+ let msgc = await open_message_from_file(file);
+
+ await be_in_folder(folder);
+
+ // Copy the message to a folder.
+ let aboutMessage =
+ msgc.window.document.getElementById("messageBrowser").contentWindow;
+ let documentChild = aboutMessage.document
+ .getElementById("messagepane")
+ .contentDocument.querySelector("div.moz-text-flowed");
+ EventUtils.synthesizeMouseAtCenter(
+ documentChild,
+ { type: "contextmenu", button: 2 },
+ documentChild.ownerGlobal
+ );
+ await click_menus_in_sequence(
+ aboutMessage.document.getElementById("mailContext"),
+ [
+ { id: "mailContext-copyMenu" },
+ { label: "Local Folders" },
+ { label: "ConvertToEvent" },
+ ]
+ );
+ close_window(msgc);
+
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ // Open Other Actions, and check the event dialog popping up seems alright.
+ let dialogWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ let win = get_about_message();
+ let otherActionsButton = win.document.getElementById("otherActionsButton");
+ EventUtils.synthesizeMouseAtCenter(
+ otherActionsButton,
+ {},
+ otherActionsButton.ownerGlobal
+ );
+ await click_menus_in_sequence(
+ win.document.getElementById("otherActionsPopup"),
+ [
+ { id: "otherActions-calendar-convert-menu" },
+ { id: "otherActions-calendar-convert-event-menuitem" },
+ ]
+ );
+
+ await dialogWindowPromise.then(async dialogWindow => {
+ let document = dialogWindow.document.querySelector(
+ "#calendar-item-panel-iframe"
+ ).contentDocument;
+
+ let startDate = document.getElementById("event-starttime");
+ let dt = cal.dtz.now();
+ dt.month = 5;
+ dt.day = 30;
+ dt.year = 2023; // message.date is used...
+ Assert.equal(
+ startDate._datepicker._inputField.value,
+ cal.dtz.formatter.formatDateShort(dt),
+ "correct date should be preset from extraction"
+ );
+
+ // TODO: add more checks for times etc.
+ //Assert.equal(
+ // startDate._timepicker._inputField.value,
+ // formatTime(expectedDate),
+ // "time should be the next hour after now"
+ //);
+
+ Assert.equal(
+ "I'm having a party on Friday, June 30. Welcome!See you then. Call me at 555-123456",
+ document.getElementById("item-description").contentDocument.body
+ .textContent,
+ "body content should be correct"
+ );
+
+ await BrowserTestUtils.closeWindow(dialogWindow);
+ });
+});
diff --git a/comm/mail/test/browser/message-reader/browser_detectCharset.js b/comm/mail/test/browser/message-reader/browser_detectCharset.js
new file mode 100644
index 0000000000..daa0a7ca2d
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/browser_detectCharset.js
@@ -0,0 +1,99 @@
+/**
+ * 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/.
+ */
+
+/**
+ * Tests that opening an .eml file the body of the message is correct,
+ * that it hasn't been UTF-8 mojibake'd.
+ */
+
+"use strict";
+
+var { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var gReferenceTextContent;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false);
+ Services.prefs.setIntPref("mailnews.display.html_as", 0);
+ Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", 0);
+
+ let { textContent } = await extract_eml_body_textcontent(
+ "./correctEncodingUTF8.eml",
+ false
+ );
+ gReferenceTextContent = textContent;
+});
+
+async function check_display_charset(eml, expectedCharset) {
+ let file = new FileUtils.File(getTestFilePath(`data/${eml}`));
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+ is(aboutMessage.currentCharacterSet, expectedCharset);
+ close_window(msgc);
+}
+
+async function extract_eml_body_textcontent(eml, autodetect = true) {
+ let file = new FileUtils.File(getTestFilePath(`data/${eml}`));
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+
+ if (autodetect) {
+ // Open other actions menu.
+ let popup = aboutMessage.document.getElementById("otherActionsPopup");
+ let popupShown = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("otherActionsButton"),
+ {},
+ aboutMessage
+ );
+ await popupShown;
+
+ // Click on the "Repair Text Encoding" item.
+ let hiddenPromise = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ let reloadPromise = BrowserTestUtils.browserLoaded(
+ aboutMessage.getMessagePaneBrowser()
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("charsetRepairMenuitem"),
+ {},
+ aboutMessage
+ );
+ await hiddenPromise;
+ await reloadPromise;
+ }
+
+ let textContent =
+ aboutMessage.getMessagePaneBrowser().contentDocument.documentElement
+ .textContent;
+ let charset = aboutMessage.currentCharacterSet;
+ close_window(msgc);
+ return { textContent, charset };
+}
+
+/**
+ * Checks that the text content is equal for the .eml files and that
+ * the expected charset was detected.
+ */
+async function check_eml_textcontent(eml, expectedCharset) {
+ let { textContent, charset } = await extract_eml_body_textcontent(eml);
+ is(textContent, gReferenceTextContent);
+ is(charset, expectedCharset);
+}
+
+add_task(async function test_noCharset() {
+ await check_display_charset("./noCharsetKOI8U.eml", "KOI8-U");
+ await check_display_charset("./noCharsetWindows1252.eml", "windows-1252");
+});
+
+add_task(async function test_wronglyDeclaredCharset() {
+ await check_eml_textcontent("./wronglyDeclaredUTF8.eml", "UTF-8");
+ await check_eml_textcontent("./wronglyDeclaredShift_JIS.eml", "Shift_JIS");
+});
diff --git a/comm/mail/test/browser/message-reader/browser_printing.js b/comm/mail/test/browser/message-reader/browser_printing.js
new file mode 100644
index 0000000000..0b80d031d2
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/browser_printing.js
@@ -0,0 +1,114 @@
+/* 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/. */
+
+/**
+ * Tests that printing works.
+ */
+
+"use strict";
+
+var { close_compose_window, open_compose_with_reply } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ assert_selected_and_displayed,
+ be_in_folder,
+ create_folder,
+ create_message,
+ mc,
+ select_click_row,
+ open_message_from_file,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var folder = null;
+
+const SUBJECT0 = "How is the printing?";
+const BODY0 = "Printing ok?";
+
+add_setup(async function () {
+ folder = await create_folder("PrintingTest");
+ await add_message_to_folder(
+ [folder],
+ create_message({
+ subject: SUBJECT0,
+ body: { body: BODY0 },
+ })
+ );
+ registerCleanupFunction(() => folder.deleteSelf(null));
+});
+
+/**
+ * Test that we can open the print preview and have it show some result.
+ */
+add_task(async function test_open_printpreview() {
+ await be_in_folder(folder);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(mc, msg);
+
+ // Trigger print using Ctrl+P.
+ EventUtils.synthesizeKey("P", { accelKey: true }, mc.window);
+
+ let preview;
+ // Ensure we're showing the preview...
+ await BrowserTestUtils.waitForCondition(() => {
+ preview = document.querySelector(".printPreviewBrowser");
+ return preview && BrowserTestUtils.is_visible(preview);
+ });
+
+ let subject = preview.contentDocument.querySelector(
+ ".moz-main-header tr > td"
+ ).textContent;
+ Assert.equal(
+ subject,
+ "Subject: " + SUBJECT0,
+ "preview subject should be correct"
+ );
+
+ let body = preview.contentDocument
+ .querySelector(".moz-text-flowed")
+ .textContent.trim();
+ Assert.equal(body, BODY0, "preview body should be correct");
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, mc.window);
+
+ // Wait for the preview to go away.
+ await TestUtils.waitForCondition(
+ () => !mc.window.document.querySelector(".printPreviewBrowser")
+ );
+});
+
+/**
+ * Test that the print preview generates correctly when the email use a CSS
+ * named page.
+ */
+add_task(async function test_named_page() {
+ const file = new FileUtils.File(
+ getTestFilePath(`data/bug1843628_named_page.eml`)
+ );
+ const msgc = await open_message_from_file(file);
+
+ EventUtils.synthesizeKey("P", { accelKey: true }, msgc.window);
+
+ let preview;
+ // Ensure we're showing the preview...
+ await BrowserTestUtils.waitForCondition(() => {
+ preview = msgc.window.document.querySelector(".printPreviewBrowser");
+ return preview && BrowserTestUtils.is_visible(preview);
+ });
+
+ Assert.equal(
+ preview.getAttribute("sheet-count"),
+ "1",
+ "preview should only include one page (and ignore the CSS named page)"
+ );
+
+ close_window(msgc);
+});
diff --git a/comm/mail/test/browser/message-reader/data/bug1774805_android_mms.eml b/comm/mail/test/browser/message-reader/data/bug1774805_android_mms.eml
new file mode 100644
index 0000000000..86f398188f
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug1774805_android_mms.eml
@@ -0,0 +1,166 @@
+From - Fri Jun 17 09:13:31 2022
+X-Account-Key: account1
+X-UIDL: UID293195-1163731112
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00000000
+X-Mozilla-Keys:
+Return-Path: <SRS0=rROjpr=WY=tmomail.net=+1XXXXXXXXXX@mydomain.com>
+Envelope-to: david@mydomain.com
+Delivery-date: Fri, 17 Jun 2022 10:12:34 -0400
+To: david@mydomain.com
+From: +1XXXXXXXXXX@tmomail.net
+Content-Type: multipart/related;Type="text/html";boundary="-boundaryRMS123"
+Date: Fri, 17 Jun 2022 14:12:27 GMT
+Message-ID: 20220517141230751612@mavenir.com
+Sender: +1XXXXXXXXXX@tmomail.net
+Subject: Bug 1774805 sample MMS
+User-Agent: Android-Mms/2.0
+
+---boundaryRMS123
+Content-ID:<0000>
+Content-Type:text/html
+Content-Location:smil.xml
+Content-Disposition:inline
+Content-Transfer-Encoding:base64
+
+PGh0bWw+PGhlYWQ+PHRpdGxlPlQtTW9iaWxlPC90aXRsZT48L2hlYWQ+PGJvZHkgbWFyZ2lud2lk
+dGg9IjAiIG1hcmdpbmhlaWdodD0iMCIgbGVmdG1hcmdpbj0iMCIgdG9wbWFyZ2luPSIwIiBiZ2Nv
+bG9yPSIjZmZmZmZmIj48dGFibGUgYm9yZGVyPSIwIiB3aWR0aD0iNjAwIiBjZWxsc3BhY2luZz0i
+MCIgY2VsbHBhZGRpbmc9IjAiPjx0cj4KCQkJCSAgICAgPHRkIHdpZHRoPSI2MDAiIGNvbHNwYW49
+IjIiPjxpbWcgc3JjPSJjaWQ6ZG90dGVkbGluZTYwMC5naWYiIHdpZHRoPSI2MDAiPjwvdGQ+CgkJ
+CQkgICAgIDwvdHI+PGJyPjx0cj48dGQgd2lkdGg9IjYwMCIgY29sc3Bhbj0iMiI+PGltZyBzcmM9
+ImNpZDp0bW9iaWxlc3BhY2UuZ2lmIiB3aWR0aD0iNjAwIiBoZWlnaHQ9IjIwIj48L3RkPjwvdHI+
+PHRyPjx0cj48dGQgY29sc3Bhbj0iMSIgYWxpZ249ImxlZnQiPlRoaXMgaXMgYSBzYW1wbGUgU01T
+IHRleHQgdG8gZW1haWwuIEl0IGNyZWF0ZXMgYW4gYXR0YWNobWVudCBuYW1lZCB0ZXh0MDAwMDAx
+LnR4dC4gIGh0dHBzOi8vYmxvZy5idWd6aWxsYS5jb20vPC90ZD48L3RyPiA8VFI+CiAgICAgICAg
+ICAgICAgICA8VEQgd2lkdGg9MzUwIGNvbFNwYW49MT4KICAgICAgICAgICAgICAgIDxJTUcgc3Jj
+PSJjaWQ6dG1vYmlsZXNwYWNlLmdpZiIgd2lkdGg9IjM1MCIgaGVpZ2h0PSIzMCI+CiAgICAgICAg
+ICAgICAgICA8L1REPgogICAgICAgICAgICAgICAgPC9UUj4KICAgICAgICAgICAgICAgIDxUUj4K
+ICAgICAgICAgICAgICAgIDwvVFI+CiAgICAgICAgICAgICAgICA8dGQgd2lkdGg9IjI0MCIgYmdj
+b2xvcj0iI2YyZjJmMiI+Jm5ic3A7PC90ZD4KICAgICAgICAgICAgICAgIDwvdHI+PHRyPjx0ZCB3
+aWR0aD0iNjAwIiBjb2xzcGFuPSIyIj48aW1nIHNyYz0iY2lkOmZvb3Rlci5naWYiIHdpZHRoPSI2
+MDAiIGhlaWdodD0iMTA1Ij48L3RkPjwvdHI+PC90cj48dHI+PHRkIHdpZHRoPSI2MDAiIGNvbHNw
+YW49IjIiPjxpbWcgc3JjPSJjaWQ6dG1vYmlsZXNwYWNlLmdpZiIgd2lkdGg9IjYwMCIgaGVpZ2h0
+PSI0MCI+PC90ZD48L3RyPjwvdGFibGU+PC9ib2R5Pg0KPC9odG1sPg==
+---boundaryRMS123
+Content-ID:<text000001>
+Content-Type:text/plain;Name="text000001.txt";Charset="utf-8"
+Content-Location:text000001.txt
+Content-Transfer-Encoding:base64
+
+VGhpcyBpcyBhIHNhbXBsZSBTTVMgdGV4dCB0byBlbWFpbC4gSXQgY3JlYXRlcyBhbiBhdHRhY2ht
+ZW50IG5hbWVkIHRleHQwMDAwMDEudHh0LiAgaHR0cHM6Ly9ibG9nLmJ1Z3ppbGxhLmNvbS8=
+---boundaryRMS123
+Content-Type: image/gif; name=tmobilespace.gif
+Content-ID: <tmobilespace.gif>
+Content-Disposition: inline; filename=tmobilespace.gif
+Content-Transfer-Encoding:base64
+
+R0lGODlhAQABAPcAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwMDcwKbK8AAAMzMAADMAMwAzMxYW
+FhwcHCIiIikpKVVVVU1NTUJCQjk5Of98gP9QUNYAk8zs/+/Wxufn1q2pkDP/AGYAAJkAAMwAAAAz
+ADMzAGYzAJkzAMwzAP8zAABmADNmAGZmAJlmAMxmAP9mAACZADOZAGaZAJmZAMyZAP+ZAADMADPM
+AGbMAJnMAMzMAP/MAGb/AJn/AMz/AAD/MzMA/2YAM5kAM8wAM/8AMwAz/zMzM2YzM5kzM8wzM/8z
+MwBmMzNmM2ZmM5lmM8xmM/9mMwCZMzOZM2aZM5mZM8yZM/+ZMwDMMzPMM2bMM5nMM8zMM//MMzP/
+M2b/M5n/M8z/M///MwAAZjMAZmYAZpkAZswAZv8AZgAzZjMzZmYzZpkzZswzZv8zZgBmZjNmZmZm
+ZplmZsxmZgCZZjOZZmaZZpmZZsyZZv+ZZgDMZjPMZpnMZszMZv/MZgD/ZjP/Zpn/Zsz/Zv8AzMwA
+/wCZmZkzmZkAmcwAmQAAmTMzmWYAmcwzmf8AmQBmmTNmmWYzmZlmmcxmmf8zmTOZmWaZmZmZmcyZ
+mf+ZmQDMmTPMmWbMZpnMmczMmf/MmQD/mTP/mWbMmZn/mcz/mf//mQAAzDMAmWYAzJkAzMwAzAAz
+mTMzzGYzzJkzzMwzzP8zzABmzDNmzGZmmZlmzMxmzP9mmQCZzDOZzGaZzJmZzMyZzP+ZzADMzDPM
+zGbMzJnMzMzMzP/MzAD/zDP/zGb/mZn/zMz/zP//zDMAzGYA/5kA/wAzzDMz/2Yz/5kz/8wz//8z
+/wBm/zNm/2ZmzJlm/8xm//9mzACZ/zOZ/2aZ/5mZ/8yZ//+Z/wDM/zPM/2bM/5nM/8zM///M/zP/
+/2b/zJn//8z///9mZmb/Zv//ZmZm//9m/2b//6UAIV9fX3d3d4aGhpaWlsvLy7KystfX193d3ePj
+4+rq6vHx8fj4+P/78KCgpICAgP8AAAD/AP//AAAA//8A/wD//////ywAAAAAAQABAAAIBAD/BQQA
+Ow==
+---boundaryRMS123
+Content-Type: image/gif; name=dottedline600.gif
+Content-ID: <dottedline600.gif>
+Content-Disposition: inline; filename=dottedline600.gif
+Content-Transfer-Encoding:base64
+
+R0lGODlhWAIBAPcAAAAAAIAAAACAAICAAAAAgIAAgACAgICAgMDAwP8AAAD/AP//AAAA//8A/wD/
+/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAZgAAmQAAzAAA/wAzAAAzMwAzZgAzmQAzzAAz/wBm
+AABmMwBmZgBmmQBmzABm/wCZAACZMwCZZgCZmQCZzACZ/wDMAADMMwDMZgDMmQDMzADM/wD/AAD/
+MwD/ZgD/mQD/zAD//zMAADMAMzMAZjMAmTMAzDMA/zMzADMzMzMzZjMzmTMzzDMz/zNmADNmMzNm
+ZjNmmTNmzDNm/zOZADOZMzOZZjOZmTOZzDOZ/zPMADPMMzPMZjPMmTPMzDPM/zP/ADP/MzP/ZjP/
+mTP/zDP//2YAAGYAM2YAZmYAmWYAzGYA/2YzAGYzM2YzZmYzmWYzzGYz/2ZmAGZmM2ZmZmZmmWZm
+zGZm/2aZAGaZM2aZZmaZmWaZzGaZ/2bMAGbMM2bMZmbMmWbMzGbM/2b/AGb/M2b/Zmb/mWb/zGb/
+/5kAAJkAM5kAZpkAmZkAzJkA/5kzAJkzM5kzZpkzmZkzzJkz/5lmAJlmM5lmZplmmZlmzJlm/5mZ
+AJmZM5mZZpmZmZmZzJmZ/5nMAJnMM5nMZpnMmZnMzJnM/5n/AJn/M5n/Zpn/mZn/zJn//8wAAMwA
+M8wAZswAmcwAzMwA/8wzAMwzM8wzZswzmcwzzMwz/8xmAMxmM8xmZsxmmcxmzMxm/8yZAMyZM8yZ
+ZsyZmcyZzMyZ/8zMAMzMM8zMZszMmczMzMzM/8z/AMz/M8z/Zsz/mcz/zMz///8AAP8AM/8AZv8A
+mf8AzP8A//8zAP8zM/8zZv8zmf8zzP8z//9mAP9mM/9mZv9mmf9mzP9m//+ZAP+ZM/+ZZv+Zmf+Z
+zP+Z///MAP/MM//MZv/Mmf/MzP/M////AP//M///Zv//mf//zP///yH5BAEAABAALAAAAABYAgEA
+AAhFAFP9+yeQ4MCCCA8qNMgwYcOFDiNCnPiwokSLFC9qzMgRo8eNHzuCHCmyZMiTJFGaTMlypUuV
+MFvGfCmzJs2bM3Pa9BgQADs=
+---boundaryRMS123
+Content-Type:image/gif;Name="footer.gif"
+Content-ID:<footer.gif>
+Content-Disposition:inline; filename=footer.gif
+Content-Transfer-Encoding:base64
+
+R0lGODlhWAJpANUAAOMhfq+vr9fX1/mUwP7K4PBcoISEheHh4bi4uPX19aOjpZmZmo+Pj8LCw3p6
+e+vr7Pyw0HBwcVpaW8zMzecxhvaGuP/x9//X5//l8O1OmPV4sOs/kGRkZfNqp/uhyP282P///wAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C1hNUCBEYXRhWE1QPD94cGFja2V0
+IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4
+bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4wLWMwNjAg
+NjEuMTM0Nzc3LCAyMDEwLzAyLzEyLTE3OjMyOjAwICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpy
+ZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRl
+c2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94
+YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlw
+ZS9SZXNvdXJjZVJlZiMiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIg
+eG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjAxODAxMTc0MDcyMDY4MTE4OEM2RDU3
+QzcwNzNBMDg5IiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjUwOTMxNEYwQTZGRDExRTNCNDIz
+RjZGQjBFMURGOEYzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUwOTMxNEVGQTZGRDExRTNC
+NDIzRjZGQjBFMURGOEYzIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzUgTWFj
+aW50b3NoIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MDI4
+MDExNzQwNzIwNjgxMTg4QzZENTdDNzA3M0EwODkiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6
+MDE4MDExNzQwNzIwNjgxMTg4QzZENTdDNzA3M0EwODkiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwv
+cmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4B//79/Pv6+fj39vX08/Lx
+8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQz87NzMvKycjHxsXEw8LBwL++vby7urm4
+t7a1tLOysbCvrq2sq6qpqKempaSjoqGgn56dnJuamZiXlpWUk5KRkI+OjYyLiomIh4aFhIOCgYB/
+fn18e3p5eHd2dXRzcnFwb25tbGtqaWhnZmVkY2JhYF9eXVxbWllYV1ZVVFNSUVBPTk1MS0pJSEdG
+RURDQkFAPz49PDs6OTg3NjU0MzIxMC8uLSwrKikoJyYlJCMiISAfHh0cGxoZGBcWFRQTEhEQDw4N
+DAsKCQgHBgUEAwIBAAAh+QQAAAAAACwAAAAAWAJpAAAG/0CQcEgsGo/IpHLJbDqf0Kh0Sq1ar9is
+dsvter/gsHhMLpvP6LR6zW673/C4fE6v2+/4vH7P7/v/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaX
+mJmam5ydnp+goaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8DBwsPExcbHyMnKy8zNzs/Q
+0dLT1NXW19jZ2tvc3d7f4OHi4+Tl5ufo6err7O3u7/Dx8vP09fb3+Pn6+/z9/v8AAwocSLCgwYMI
+EypcyLChw4cQI0qcSLGixYsYM2rcyLGjx48gQ4ocSbKkyZMoU6pcybKly5cwY8qcSbOmzZs4c+rc
+ybOnz/+fQIMKTYiBgNGjSJMSGMoUUAEAUKNKnQpAC4EBWLFeOHI164ClSyBg1VAh7IAPQ7IWgNCU
+5VOoHdBGJXBhAIWoWiC8harhSAepGdguqbABagElF6JaEFLgLoABbVe+FQxirpALjrlAiLrhiGMA
+Hp5gMKyEQFQMQzxAhRw55dO+QywL+QCVi+moW4nQdgzWCWklGjKUHXKbdeuTT1HHhtobRGHbADKs
+LlKBwtvmTH5DKX4cZYEMRWQLGVB1i2ny0Yts0HAdivYn3Klg8KC8e8MCsJcDaE4bOgHHi10GwAft
+EQFBBwUUUEF9QhhmgQcadDBAfXVlRUR8QxyYoAcBIvH/VAEd2qfQBdiJBwIGaJm3318AUKaaBQWC
+cEFhGwygwXTLZZCBdYW1KAQEPZYnBIYwAkBBBU9lECJ11uUmYkQmemEaAZsBkF8GhxVowV1K/ggV
+ZVF1CQKLYG1ZG3E4glCBkbn9BR4SHnQw3JMKIZmgYJZdkGCCHu5JmYF7zjmlBVBRIAShoRW45oBE
+FEZBgOKNlh5jZw6Jo6RzJubjj4HS6dBerFl2W6XhRWUcEegBcJilS0m3HwibbVVgZkQsiuekQ/SY
+21sX4rhoig0C0EFaUa3q6UKgBvvqqEKWmiaqxaK5FHpladAZCO3d9mZqcAWbH6Wb8ormY+A66Vyl
+qRp7/2xCyVbGHAjMIiHVqcRqN6WMUIF3JLhL3aYuvL89C8KN5GJLanyO7ZkggONFu65CYmEFlmUY
+eIWEV9gNmRVl94Lg2GYptlflv/4GSy96rIlracGmepWVcl0N8OfDDUXZRccEP8cvwKoWUbK79LKY
+8sE4MkyzRzb7N1tUw+6sbRGqCettEW+FTHTBMR69UVShhdExoV8OMSupakLVdb7qQRWgyjyzZmsR
+F8ysNUSj0tsFbcC+VV+BBAMLgnSPKkshX2JfzZqm32L779wOYbAXBX538SG324JdsJnG0gaafsb+
+RUF9PdYXdX5kDnGjuYwzlCpVi18BJGdlJTbcXvmyhf+ZqhBUB8Cco1XgAQUbEMamEMJDtUEBekq1
+FghFCjuAdHKnrlDMLst8s8uCTThe9WV6sKcGzVUsBAZ2cphh9RW73JuG+DEo/fvwxy///PTXb//9
++Oev//789+///wAMoAAHSMACGvCACEygAhcohQMI4IECOEACDkAECeKCgg2EYASHcIAHDOEBAjgC
+BocQwgd48AkhTIIJsWBBBrqhAQEwgAEC0AABGIAIBkjhJBJwQiGMMAs3lAIMZUjDIRhgAUNYgASO
+EMQhLDEAAYDCEpMARSzk0IVwqCIIbLgJBOjwAFHcQhOloEUjOsCIUyzCGIdQRiy0kQpXxKIbtCgA
+BzT/QAETAIECKAhFBHwQATAUQAD2CIIEIEABfkyAAgKQxwMskoIHWMACToiASjagkIf0oxAakEc8
+6vEBimRACB0ZgBGSkoIPGGQeBdCAQx7gABFwAAYdEIFLwhABCRBCAoLoRUMWEQRBJKQQDNBKPwKy
+kEj0oSR7KIQ3HnGUSsTkL4lZyWGCoIqGROQQOKnHTj7ghgKYwAI6qMprRjGbiVxkHiewyFwKQQCV
+DEAuZwjFXAoyAB5kpSsxqU05aoGOHEhAAjgAzAcqQI/uFEBAJxCBBEyAASBYgB9FyciIAnOCfozA
+K5HYAI7eUKIgEOU7keiAg55Roru86AE0qdKMStAB/wmIIQgeek0dChIEDYBoR4fAAArCFKQLuOQU
+4wjMiTbgABBl5xA0GskiOFOpjFwiUC9pAKOC4IlRBKlIt0hSk171mhyYQAI0mgCYVlGrgswjSXGq
+wwBAFAFInKEeEfCAhtb1mjekKVr9mQU6BnGJORwrPkkIzsKCIAIPhKIAapnLGOZxiyD8aApviNh7
+LjUBC3DAAyDawTri9bHNNEAeH+CAB1I0ilwMgE2juFWCblIBmwWBawUA0aHqMIi0PSxmRyiAyDo1
+iqmMImVBANMlzhaiuK2tOQ+b2DDqNrOxfeJBSWtay1ZWsYwF5jiJoFohRKCg5kRAGA0ARtRStrl8
+df9jGLn41SsaMgIn5KJ8b8gBCHrwAQo44xYZYFLV3jCO9LWvEVtJzEsqQJT6pe1BScjfA5T2gTE1
+b01JyNoUuraQDjjmcb8KXmvu95oN0K8QHODf34IguMAMKReN+07ktvirVazvA3tIzQLDOIoOhmCE
+ZStg/Oo3xKDtbntD2MfxlneLAZ5xeq/gVyEA9oEG1uR8kRxSPlbSp00d6JTFS+WeXpOlCJBlAOD7
+1YFa1MxCQKKZvwvMI6d2tSDgcm6T2FAVf5nD9TUikf1Y1wVTmb1sdK41Q+zHJYo0AH68IqJvXOU7
+CyHMYCQzVr07zCN7GdEI8KkADkrTZuIYuUQu5Q3Cx7pcLl4alzpcchSaPORdyjChhuUiKRcwQQeU
+FKe2viQDEM0AUEpUATectTtBcIAlMrSZEl0AK3O9TWazc5ClzqtGhVDXBmA2ANsdQqcjie3GStTL
+QlDAt90pSyLsGgG95q6gU/wACVBwidymtR7HzWhhc9DYbJ70TBd50CoK28G3zm+5m8kAuFJQ0VEc
+pAIuWUVZL5LWAkijqgHRgEs+IJl7+CUVysoJIU+cExfH9g/xAOxhN9COHU/1x1fO8pYHJQgAOw==
+---boundaryRMS123--
+
diff --git a/comm/mail/test/browser/message-reader/data/bug1843628_named_page.eml b/comm/mail/test/browser/message-reader/data/bug1843628_named_page.eml
new file mode 100644
index 0000000000..c1563b6343
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug1843628_named_page.eml
@@ -0,0 +1,28 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+ <head>
+ <meta http-equiv=3D"content-type" content=3D"text/html; charset=3DISO-8859-2">
+ <style>
+ @page mypage
+ {size:612.0pt 792.0pt;
+ margin:70.85pt 70.85pt 2.0cm 70.85pt;}
+ div.mypage
+ {page:mypage;}
+ </style>
+ </head>
+ <body>
+ <div class=3D"mypage">
+ <p>Hello there!</p>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_8bit.eml b/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_8bit.eml
new file mode 100644
index 0000000000..969283f111
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_8bit.eml
@@ -0,0 +1,23 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta http-equiv="content-type" content="text/html;
+ charset=ISO-8859-2">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ <tt>árvíztûrõ tükörfúrógép<br>
+ ÁRVÍZTÛRÕ TÜKÖRFÚRÓGÉP<br>
+ <br>
+ </tt>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_b64.eml b/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_b64.eml
new file mode 100644
index 0000000000..9bd0063a6d
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_b64.eml
@@ -0,0 +1,16 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: base64
+
+PGh0bWw+CiAgPGhlYWQ+CiAgICA8bWV0YSBodHRwLWVxdWl2PSJjb250ZW50LXR5cGUiIGNvbnRl
+bnQ9InRleHQvaHRtbDsKICAgICAgY2hhcnNldD1JU08tODg1OS0yIj4KICA8L2hlYWQ+CiAgPGJv
+ZHkgYmdjb2xvcj0iI0ZGRkZGRiIgdGV4dD0iIzAwMDAwMCI+CiAgICA8dHQ+4XJ27Xp0+3L1IHT8
+a/ZyZvpy82fpcDxicj4KICAgICAgwVJWzVpU21LVIFTcS9ZSRtpS00fJUDxicj4KICAgICAgPGJy
+PgogICAgPC90dD4KICA8L2JvZHk+CjwvaHRtbD4K
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_qp.eml b/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_qp.eml
new file mode 100644
index 0000000000..5db957232d
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_newline_charset_qp.eml
@@ -0,0 +1,23 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+ <head>
+ <meta http-equiv=3D"content-type" content=3D"text/html;=
+ charset=3DISO-8859-2">
+ </head>
+ <body bgcolor=3D"#FFFFFF" text=3D"#000000">
+ <tt>=E1rv=EDzt=FBr=F5 t=FCk=F6rf=FAr=F3g=E9p<br>
+ =C1RV=CDZT=DBR=D5 T=DCK=D6RF=DAR=D3G=C9P<br>
+ <br>
+ </tt>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_8bit.eml b/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_8bit.eml
new file mode 100644
index 0000000000..dd1babc066
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_8bit.eml
@@ -0,0 +1,23 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta content="text/html; charset=ISO-8859-2"
+ http-equiv="content-type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ <tt>árvíztûrõ tükörfúrógép<br>
+ ÁRVÍZTÛRÕ TÜKÖRFÚRÓGÉP<br>
+ <br>
+ </tt>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_b64.eml b/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_b64.eml
new file mode 100644
index 0000000000..8051dd1287
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_b64.eml
@@ -0,0 +1,16 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: base64
+
+PGh0bWw+CiAgPGhlYWQ+CiAgICA8bWV0YSBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9SVNP
+LTg4NTktMiIKICAgICAgaHR0cC1lcXVpdj0iY29udGVudC10eXBlIj4KICA8L2hlYWQ+CiAgPGJv
+ZHkgYmdjb2xvcj0iI0ZGRkZGRiIgdGV4dD0iIzAwMDAwMCI+CiAgICA8dHQ+4XJ27Xp0+3L1IHT8
+a/ZyZvpy82fpcDxicj4KICAgICAgwVJWzVpU21LVIFTcS9ZSRtpS00fJUDxicj4KICAgICAgPGJy
+PgogICAgPC90dD4KICA8L2JvZHk+CjwvaHRtbD4K
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_qp.eml b/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_qp.eml
new file mode 100644
index 0000000000..36bd831222
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_newline_httpequiv_qp.eml
@@ -0,0 +1,23 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+ <head>
+ <meta content=3D"text/html; charset=3DISO-8859-2"=
+ http-equiv=3D"content-type">
+ </head>
+ <body bgcolor=3D"#FFFFFF" text=3D"#000000">
+ <tt>=E1rv=EDzt=FBr=F5 t=FCk=F6rf=FAr=F3g=E9p<br>
+ =C1RV=CDZT=DBR=D5 T=DCK=D6RF=DAR=D3G=C9P<br>
+ <br>
+ </tt>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_reference.eml b/comm/mail/test/browser/message-reader/data/bug594646_reference.eml
new file mode 100644
index 0000000000..3f34acf3ea
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_reference.eml
@@ -0,0 +1,22 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+ <head>
+ <meta http-equiv=3D"content-type" content=3D"text/html; charset=3DISO-8859-2">
+ </head>
+ <body bgcolor=3D"#FFFFFF" text=3D"#000000">
+ <tt>=E1rv=EDzt=FBr=F5 t=FCk=F6rf=FAr=F3g=E9p<br>
+ =C1RV=CDZT=DBR=D5 T=DCK=D6RF=DAR=D3G=C9P<br>
+ <br>
+ </tt>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_8bit.eml b/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_8bit.eml
new file mode 100644
index 0000000000..8497a6739e
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_8bit.eml
@@ -0,0 +1,22 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta content="text/html; charset=ISO-8859-2" http-equiv="content-type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ <tt>árvíztûrõ tükörfúrógép<br>
+ ÁRVÍZTÛRÕ TÜKÖRFÚRÓGÉP<br>
+ <br>
+ </tt>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_b64.eml b/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_b64.eml
new file mode 100644
index 0000000000..7199ffbf28
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_b64.eml
@@ -0,0 +1,16 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: base64
+
+PGh0bWw+CiAgPGhlYWQ+CiAgICA8bWV0YSBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9SVNP
+LTg4NTktMiIgaHR0cC1lcXVpdj0iY29udGVudC10eXBlIj4KICA8L2hlYWQ+CiAgPGJvZHkgYmdj
+b2xvcj0iI0ZGRkZGRiIgdGV4dD0iIzAwMDAwMCI+CiAgICA8dHQ+4XJ27Xp0+3L1IHT8a/ZyZvpy
+82fpcDxicj4KICAgICAgwVJWzVpU21LVIFTcS9ZSRtpS00fJUDxicj4KICAgICAgPGJyPgogICAg
+PC90dD4KICA8L2JvZHk+CjwvaHRtbD4K
diff --git a/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_qp.eml b/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_qp.eml
new file mode 100644
index 0000000000..c33de13696
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/bug594646_reversed_order_qp.eml
@@ -0,0 +1,22 @@
+X-Identity-Key: id1
+X-Account-Key: account2
+Date: Tue, 18 Dec 2012 13:42:01 +0100
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0
+User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20121217 Thunderbird/19.0a2
+MIME-Version: 1.0
+Subject: html test
+X-Enigmail-Version: 1.5a1pre
+Content-Type: text/html; charset=ISO-8859-2
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+ <head>
+ <meta content=3D"text/html; charset=3DISO-8859-2" http-equiv=3D"content-type">
+ </head>
+ <body bgcolor=3D"#FFFFFF" text=3D"#000000">
+ <tt>=E1rv=EDzt=FBr=F5 t=FCk=F6rf=FAr=F3g=E9p<br>
+ =C1RV=CDZT=DBR=D5 T=DCK=D6RF=DAR=D3G=C9P<br>
+ <br>
+ </tt>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/message-reader/data/correctEncodingUTF8.eml b/comm/mail/test/browser/message-reader/data/correctEncodingUTF8.eml
new file mode 100644
index 0000000000..b64fccd9c3
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/correctEncodingUTF8.eml
@@ -0,0 +1,11 @@
+To: decoder@example.com
+From: encoder@example.com
+Subject: Test Encoding
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf8;
+Content-Transfer-Encoding: 8bit
+Content-Language: en-US
+
+メールを簡å˜ã«ã€‚
+
+メッセージã®é«˜é€Ÿå…¨æ–‡æ¤œç´¢ã€ã‚¿ãƒ–表示ã€ã‚¢ãƒ¼ã‚«ã‚¤ãƒ–。設定も簡å˜ã§ã€ã‚«ã‚¹ã‚¿ãƒžã‚¤ã‚ºè‡ªç”±è‡ªåœ¨ã€‚ãã‚“ãªãƒ¡ãƒ¼ãƒ«ã‚½ãƒ•ãƒˆãŒ Thunderbird ã§ã™ã€‚ \ No newline at end of file
diff --git a/comm/mail/test/browser/message-reader/data/multiparty.eml b/comm/mail/test/browser/message-reader/data/multiparty.eml
new file mode 100644
index 0000000000..3b9d13612b
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/multiparty.eml
@@ -0,0 +1,5966 @@
+Content-Type: multipart/mixed; boundary="------------kCCPQfdkG5hSF08pCIKQevhQ"
+Message-ID: <5c7265f2-414a-b568-0662-e695606b3ba6@test>
+Date: Thu, 15 Jun 2023 21:47:11 +1000
+MIME-Version: 1.0
+User-Agent: Thunderbird Daily
+Content-Language: en-US
+To: John <john@test>
+From: Jane <jane@test>
+Subject: welcome to a multiparty
+
+This is a multi-part message in MIME format.
+--------------kCCPQfdkG5hSF08pCIKQevhQ
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+
+I'm having a party on Friday, June 30. Welcome!
+
+See you then. Call me at 555-123456
+
+--------------kCCPQfdkG5hSF08pCIKQevhQ
+Content-Type: image/jpeg; name="kitty.jpg"
+Content-Disposition: attachment; filename="kitty.jpg"
+Content-Transfer-Encoding: base64
+
+/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgK
+CgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkL
+EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAAR
+CAUABMkDASIAAhEBAxEB/8QAHQAAAQUBAQEBAAAAAAAAAAAAAAECBAUGAwcICf/EAFUQAAIB
+AwMCBAQDBQUGAwQCEwECAwAEEQUSIQYxE0FRYQcUInEygZEVI0JSoQgzYrHBFiRDcoLRU5Lh
+JTRjovDxVHOyCURWk8LSFxgmVYOVJzV0ZP/EABoBAAMBAQEBAAAAAAAAAAAAAAABAgMEBQb/
+xAAyEQACAgICAQQCAQMEAQQDAAAAAQIRAyESMUEEEyJRMmFxBRRCgZGx8KEjM8HhUmLR/9oA
+DAMBAAIRAxEAPwD8qh3p1IB7UtBSCiiigYUUtFA6EopcGigKEooooEFFFFA0wpRSUooGL+VF
+JQe9TQBRkmkpRTASnDtSGlBoAOfKjNH9aO9IBaM0DtRSLQUUUUAFOHam0UFIdkUmcml8qaO9
+AD6X35pKKC0HnzRRRQAUnalooE0FFJS4oGgHenD+lGOKKLGkFKO9JS8VLKF8qQmgnikoHYUU
+AUuCKBCAeVKBQPWnUDSFHeg0CgnNBfgQik78UtIBigkDn7Ugzn7U7FGKB0IKDS0nGaADijAp
+AB70ufXvQIQjmj2pT2pKAbHUd6QUtAwxSEDvTgKSkDQ3jJFJTsUYpiobQKX9aMUImhMeVPpA
+AKUUFxQvFIadSEeeMUFUAHnQfelH2ooGkApaKKCkFIymnY8xSmgKOeCKBntTjSEedBLQdqBz
+RRg0gehMelJjNONIfypiasSinDv2pMelAqEopaT70DEIpMGnUUEUMPvSYz5U/ApNvrRZLiJi
+k86cR9qSixNBRkUUU7CgowKXtRSsYlBGaWkNNbExh7U2nnvScUyGhuKKdmigmhtFKTSUB0FF
+FFAmJgGjHpRS0E0NxR+VOA96TFOxUJQKXjypMU+xC5FAIpO1KOeaTQ7F70UUdqRQmKTFOpD2
+oJ0AoOKQmlzTSAPelzSZpaQ7AEilB5JNN86XNA0x2aQnNJ386KCmxD2pCMUp70ACqWjPsWil
+C+tGMdjSLURKUe9Lj1NB4pDoWkxSA+9OoKWxuDSU+igTiNwPegjNOooAQDFLRSHtQOqDI9aQ
+486TtQc0E2FFFFBNhSHvmlooExDQKMd6WmxIKTFLmjvSH2FFKBml2igpI5UUUVZiFFFFACii
+kpQcUFWAIozSUUAFFFOwPKgKEwaKXH50e1KwE5pQDSGge1IANJThSU0AlLSUUwClA86SnZpP
+QB6Cjy4o96WkNIBRRQKQ0FFFFAwooooKCncH86bTh60DCloooGgoo4ooGAo/KgUfnQAuO1Ow
+fSgUvtSZaQmDSUppKVjFAzSkedAHGKX7UDrQzuPvRTm7Ugx50C6FHeloooKQUUUUAFFFFAAK
+KOc8UCgBc4GKDRSUDCiigUC7DFFBpRQFCUu3PPpS7eakW1tLOH8NCwCnOPLjP+lBXGyLiinE
+UmMUBQdqPajHrRigBKKUikxQKgxSflS0UCoAKPv2opecUDSFFOCgU2nikWkNIxRTqQimVQlL
+jtxS4opBQYpO1LR70WDQ00Up5pBQIKTHnS0UwEopaSgTQY5zRmjmj86CRDikpT37UlABRRxQ
+aCaCiiikAUmBS0UwoTHkeaMClooFQhH9KSlNITzQKgxikp350n25oG0NIpjV0YHFMIqkZSG0
+4Y9aQA+lLzQSIT5UlKQaAKBCUUUdqAoMUUZ9aKBMKKKKBBjNGPWig58qBMTFGOaWimKgo88U
+UUhsQZ9KMnHNL50hpkiGjNJS44zVAKD7UGkAJpcVI9sAKWiikVEXFJRmigphjNKozSU4dqBR
+WxfKmmnk02gtgBig8UtIRmgKG04HNIRSUE7H0UgpaB2NJoB9aUim4oAdQe1IDjilNAXobRRQ
+eOaZDYhIpBR3oAzTSIF86cFzSU8ECpNIr7E202uhINMJ5oHJJDcD0oHalooIoeB5Uu33pAaM
+mg2RwyfSjkilzxmjHrVnIA7UUUUDSCiiigApQKSnDipbKigwKXFFFIvoSjy5ooxQJ7DvR2ox
+zS0CoQ8eVJ2H3p2KTHNNAIKCKXtxQBQIbTh5UYpaG7HQUUUUikgxmiiigAooooABmilAGKDj
+FBXgSlWkxk04DFAIWiiigpBmiiigYoxRSUD70AOziikxS+VJlJhSgUnFLk+tIpUOGBSkikz7
+0UF+BGpADStSd6CGLnzNFJjnvSj3oGhaKOKKB9BRRRQHYUUvtSUBQUfnRSCgBaUUlH3oGjoi
+hjjtkUm0nsuafAMzR57FgD+dWNnA1vqTFUUm3kztbswzjBpFVaDSunNU1i3a6sbdpIo5o4ZG
+Xnwy7BV3egJNeg/DvpSCLqCytNZLx6P1ZeT9P2swAJ8dTgSKT2KyGMfZq9B+DPStpbXN5dmC
+3uND1a1MFyJThrRi4iQknhXSSRGB7EDNaVfhFqOjdNz6TLau7dNW17eQrImJoLq2JEsnHIPi
+BSW7EYI4reONV+zlnnp14Pk7UNNvNLuJbO/iMU9vNJbyxnukkbFWB+xFRDxXpHxW6avzrOqd
+UW+ye0vrxr5vDP1x+OiSksvcDLkZ7ZrzkgGsOjrW0NwO/lR/lS7TQFP50CpieYo+1LigetMd
+CUmBTqKBUJjiilxRigdCYpQaOaAKQUOFLigelLz9zUmiQACkPenflTSPOgBKKKPP71SQmIQB
+SU6mkUEhn0oo86DTAKPOiigApODS0lABRilo4oFQmKTbTjjvRQFDcCilxR5igVCYIFIQRTj6
+0h96BCUUfnRQKqENG30paKBBRRRQA096ZTyc0wj3pozkJS0UUyKENFHnQaBDaKKKCWFFOA70
+EZoHQ3GaKXBoOfOgKENJ9qWk/OgzYtFFFBQUUZzR50CsDSY57UtIQDQDQmM0uKWigEgFKRSC
+lzx3oLSEoopPbvQQ3QtFFFAwpwPFNooK6HZFLTBTgaB3YtFFFBQh5pCMU6igTQgpaKKBBSGl
+ooHQzGTTsUuKQnFAqoQjBpp7GnE5pMA0yJCAUtAoosSQUAmiikPoMmijmigLYUUUUDQo+9Lk
+etNooHYzFFFFWYUL2oJzRSUDQUoHvSUooGLilpOKWoKSCiikzzQDFopB6UtAIKKKKBhRR50U
+BQUUUUAFFFFABxQBThjyooLryNooPeiglhSqKMUvagaQUYoooGAFLRRQAUUUUDCiiigAopKW
+gEL+dGaSigY7NGaQUoFJ0UrFzgYozmgd6XHHakUN9qKXaaXGB5UCEGPOnCk20AAUFIWiig0D
+ClFNBzThQAdqO/FGaMZoKDA+1Jg04e1Ljz8qAURv3pQvPFKBmuiIScBcmlZSiPhjKhpvKMbv
+zHatzrfTkmndcX1hHGAG0+HUkB5HhvAkp/oSaqdA0WPVNU0rRbhWEd1IUlKfiyykj/KvqYfA
+nW9Q+Keg9TarpEqaHe6Tp1tFPw8M8JsTFhiPwyBgNyn0q4QlN6Mc2aOH8mWvw86WTTLLUekG
+WOT9oWj22HiLbJAiXA3Du+F5VhnIIA7V7T0jby3vxTfqWa1Mtp1VYPJpoOGt7mG5tlSeGM9m
+CzxhmU4I3GtP0L8KrC66f0vMoXU7OaCxSeBtzxXMESoDg8oGQZHPftXoOn9H6GLXTJIXWOy+
+abULdYvpSC8DN4hwP4Xzkj1JroapnnvIpro+CPjR8Jda0zR+nR01ZHV0S9vrXVry0jKCNkcR
+Lb+C+JAABuOfpJOFJ4r5i1/pLUtG6hudDltmjeJpeGBXAQEt39hmv2C66+DnR/XdjLZpfw29
+2DLIVk5RvFGJRkfUuQO/PKg9xXhfx6/svalcdBXmuRp87qOmaNLZ2uor9TyGNFANwQPqbwQU
+WTz4B5pywrIrT2Xh9Z7T4zWj84bTTWudG1HVFB22clvGTjjMhbH/ANyahBcBmH8I/wA69p6C
++HWpat8FfiZsFtBd2ur9PWsa3LeHmVpJgqhzwuQ2Mng+teaa905qXT63Njq1hPZ3ltfmymgn
+Ta6MiBiCPuw58/KuaWNwSbPQhmjkbSfRnWpCRT3QqSD5UzHvUmgUUc+lHegBRQc+tH5dqXjv
+SAQCloooGhRSg+VNp3AFSULSHtS0jdqCmNooopogKQ0tFMBuKMcdqXFLQSNopSOKSmAUUUUA
+HejvRjtR2NIApaTyopgFFFFACUh9qWk4IzQSxKKXvR580E0JRRRQFUFNPpinU00EsQ0mMe9L
+R5U7IoTHlSYxS/fzpDimSNP2oz60p5pPtQSGPU0ADzpTQP8AOgBRRRS+uaChKKXOKSgKGnvS
+U7GaQighoSg80dvKigT6oT3FLQOaXbQJREopdppKBtAO9KcUlGaAsUDjtQRigHnNKe1BQ2ii
+lwaCaEpcZ7CgCloGlQhHpSU4+9Ic0AJQPWilAoAUGlpO1LQUgNFFJkUDsMU2nE02ghsXdShg
+aYTz3paBch2R600kmikz50wchaKM+tAOaRKYUUUUFWFFFFAhKKO1FBItFJ3FLQNMKKXHnSYP
+pQVQylo7+VH51ZmKBSH1oyPSkpIG6ClHekopiQ7FLSD1pag0QmPejmnCjGO9AcRopaCMUUBV
+BSge1JSjgc0DQuKbSk+VJgigbCiiigkCaKQ0uaBXscDS0ylB8qC0wbvQB+VKTxSZFAwPB70D
+NITSg+1AhaWiigqgooooGFFFFAkGPSjBpce/aj/SgYlGKUfalxQMbilxS9+KMUMAxS4pKKkp
+IcD6mjIptLQOx1FNDeVOoGFFFFAwopeKPYc0h0IBiijFFMKClB5oBApQM9ucUDFH+dSCkUkA
+kjG2SMYde4YfzCuABz2roNyNkcFaVlpDVX9KuLCxZraWcKwaFVctj8P1CpOg9MXeputy6Mtp
+slmZ8fwxoXbH9B+dfRfwY+DD6lpl9qut2rQafqFqitLIn0+A2D9IPO4sUx64qoK+zPLkUEbr
+4I/BLTuvevLbrC50qNbDTora5EQTarzBEMvb8SZOR5gg+VfXE82hwRjpqG0WOC4iEe5FwD/I
+wPswzmqL4cWVp0x1NpOjacpSz0uymjk3DHjSNGIgWH3OfvSTxTIpUEM2nqbRxn6htkP1f1/r
+VvLSqJ53tc5cplv0fby2+uawsN14Qe+RwCSACYRt+31DOfyqT0/e31henS7yANAyTXOxjkfQ
+OQPzyD9xUfTipa51Lbsa4IeTByCyjaD7eVd9SiuneUWrD5tbOWVUBxklNxBP+LH60km1ZdJM
+ttPuYltJdVXdKlh4uQRkiIDcyn1ODxUjprqO46dt723lnN5atKlxGmd/7qQ4JAPDKVYfSfME
+VV6Ky3ui3L242xay5gwT/djwS2T/AJflVDZ6jJFqsViHAtbXT1Yk9x4MhDE+uSD+VK3FpoFF
+STTOvxT/ALM3THVOmdfH4eW0GjXfUOhPaX+nrj5ae6gk8W3ljT+Bhhhx/N2r4h+NXwV1/UOm
+bW5/Z9xb6rp7afaLHdALJdzXNkJoYQO+8xo+M9toB5IFfpP+1ZLDrjXNIRlyLhdSjKZz4TwR
+go3ry2arfit8JtI+NPR+t6NDO9h1Hf2ECaXeiQqsd9agtYsR/CQ4C7u+D6V0qSzakc0VL024
+LR+K2v8ATt3pllpWryxlbfV0l8LIIYNEwWRSD2IJFUDLgnjsa+v/AO1/8Ori36f+E2vWPTk+
+ly9RDWF1O0PMUGuLcxpdW6H0Dq2PbmvnL4qdGr0V1WNOt3L2l7p9lqdsxOSYp4Vf/wC63D8q
+5skPbkergzrNFNmNNJinlCAD60mKizoobilwKKKBUFLikpQaLGGMUe9BOaSpGPHNIfem0UDs
+KKKKaJCiiinYB2oFFFAmgNIR50tFGxtDaKCKKZIUH7UUUAFFFFAAPvRSUZoFYU004k02gmTF
+FHajtR/rQAcedHlRil7f50AIRimnzp2KQ0CaGUmfalII5ooMmmJk0ntSkc9jSY5qiQppp1JQ
+Sw7jmgZpaKAoKWkox70FBRQaKACkPrS0jUCY096MHGaKMnGKCEOGKWmZpd1BSYpI7U2g5pc0
+CEwaKd2oIzQDQg4p1Jijmga0GBRRRmgA4FAOaDQBigAoPrS0UBQylHNOxRjFAUJS0UUDoQmk
+z6ilIzRigQ096KXHNB4NAqEoooBwaBaCkPFLnNIfemhSEFKPWgYpaGJBkUhpcedFIbQnalop
+QuaASEpPelooBoT/AFpRRRQJIdS0UUGtHKj/ADoAoqrMBKKKKYmFFFFAkOBzS0gxS1Bogpdx
+ppo/WgfIWiiigAoH+VKMYpQMUFICR5Uh5NLikwRQMAKCBQD5UAZ86AEopdvNJg+lBNBRRRQI
+KKKKB/oKPOilHegF2OooooNAoooxmgVBRRQKBoXP60vtSfaloGgooOaKBiZ5oFLiihuhUFOx
+SDvzTvtUmiG45oA55p1KBQOhu3jFL96UDNAANAJCcUY9KU4pewpFUNpQKCPWgfpTAPLmj2pR
+R+VA6E57+tPjxuGfOm/rTkHNJgls6CM8g9watLjTZGgtrxUIE+6P/qXGR+hrjZwCXa47p39x
+61778PvhBP1jqul9PyW0vyV1f2d208a5WC3Ee26YnsDsKlQe5oirKnNY1bND8B/g7N1/0Zot
+hObq1sbS/u7/AFW9xtjSykTYIVP/AIjMpOPIV9fW3RtmvTmhaDFFHHbx3IO0Z/d20SDGR5kK
+OPc1y6d6f0/SNG0zpLQrYWWjacVSKFe8hAwZJD/E5/QZrY3Ugi1I3SIpVLRkUA/hZgA39BTu
+ziknJ2x8+mIvUD3tptRbnUTDsZSCsRAZcex4NO6gtAnVssYyvzC5mwOCdmMn3yBVlp7x3ltb
+3E8uZFsxEzeaypJ3/wDJgVxlia9uBcyk75mIJ9OM00kyNlVYKYLCSOTAZZvDwPNWXP8A91Uy
+53x9SpfM5LpZRxyDyJKtz/lUr9nbI7gOuUuAjAnurr2IoliEs6yMRlkCH321rWiWRtGjktLe
+50iFv3Z0xiCf4ZU3EMPf6sH2qH1PaYvHvLKHIv7mGIRqv/DEYyfbLMSa0FjBkyNx+8iZScdg
+e4ptwn+9xyKOI4lAB9hj/SpaHHs7anM03W2pa3A5LSw2tvEw7MFijDH7ZUitNBqL3UWmiCQp
+KuowsMfxlQ7D9CKx3Ue6Lp29ntiRJcwRSQ+obxVAH9Caur2cWsMdwQMw3NvIi55dzKVXHr/F
++lZu0VxUkP8AjB8JIvjR0JbQWwdtT0e+m1ixtoCq7L/w5VD7T33SMGYeZ5r83vi/8FZNf+HP
+wD68smkjfqHpOSx1t3JItxpzTM0rA8r+5Rs+WVFfqF0h1Pc6ZdteQ5JhaN1ZuAyszuG/LivE
+P7WPww0my+Ct8+mxR6b09YXugW3hwKTLBHdX7peRQ+njNNGDkgDLeXFbxyRyx4y7MFGeDIpR
+6Pyx13pYN0z091PpSO6a7cXFpFbAZcSRsAqjHcnco+9V3Vmn6doM8PTtqFlvbFSup3KuGV7o
+n6o0I42R/hz5tuPbFfUXTumWnTXRcANlFaaj0H1n1RbW1u+H2TXelq1mikj6/C8GaUn1UGvk
+7UdPks7rwjvfxFSRCeS+4Ag/c5rCaSej1McuZX0V1nge3laCVSskfDqRghvMH3rnjNTZqJRT
+sDFJjFFgJRRSgZo8ALikxg806g0h0N4zSUv3pKBBRRRToAoopcUWAlFFFCYBRRRT6FQUhpaK
+AaG49KMU7FIfUUyRhHtRmnd6bx5UEsOKPOjyopE+QzRkUlLz6femMP0o4pM0etAWL+VIRSik
+NACHOKafSn0hFBDQ0ig9qXHrSUEjSMUUuPakIqiKFx5ijtR7CjNAxO1OwKbSnNAgIHkKSg0U
+AH5U1qUnFITQS2JRRRQQJzmlFFOA4oHFCY9e1JT6TA9KCqG5JNOH2pcUe1AUwooooKCk86Wk
+oE0JmlzmiloF0FA9qSlHegYoHnSn0zRSZ8qChDRQeaKBBRRRQLoQnFN58zSnPekoIYUUUooE
+hKDS7aTtQNoQDzpQM0UoOO9AJULSEelLnNBx50FOgAo7UgNBbPagViUnnmloNAmrAfejg0UU
+CQ+ikB4o/KguxnvSUvrmkNUjFiUUUUyQoopR3oHQlOBzSUUqGKKWkFLUlIKKKKBjhyBS00Hy
+p1BaCiiigaQhHNAGKWl4oChKQ0uaKAY3GOaMc9qXB9aKBCEUAU6igdCbRQBilooCgopRRgUD
+EFKAO1GKWkxpCEUAU40nnSsbQD8sinYHamj3pw70FIbRTiOOBSYosKADI8qXHGKXGKKB0Jil
+FFFAwpR70lLz+dIaDFHagfbFL5UygPNH+tAFLjPagKEopTSUBQUd6O9GMnNAWFdY1JOKYoye
+asbSzaVUZASWO3Hn7VLLijV/DrQbnWtYtbK002W/kumNutvEMs7SfQoHuSwr9F/h70Q/w80O
+Hoe1vnuXtGR9Rucg/M3QiVWUf4EChQPMgmvBf7Ivwp6h6Y1FPiJqtsI7O50lU01ZMZe6lb8e
+0+SICd3qRivrfSLGKzlchSyNCqhjyc5G5vueTSvwYTl7jtbSOenRxBZraQFAoDIVHKn1rv8A
+J3H7y2lb6j9SOvZuKkWkLQ3SysMn8Lj1Gf8AtVu0ETLHheVzgj0ql0ZtfRFtV+XtbdQm3xSd
+y+4HP61Z2Fq1xLavu2JGMtx3rkiCQfLMeR9aN7+lT7RtsY2jsuAKuL2ZyTO1xaM8SlAWEbM7
+f8oIqPcWSw3zExZEeSAPRhmrq2eJXhkkGYpDh17ZUjkUyaIPNulGN4CgA+Q7VrZnRVRReDAk
+yj6dwXnzyeBTDb5LZHK5GfQg1Z3EKCxZdvKlXH3U0zwd6vMDyzc8evNJ9h0Q54lks4I5kBCP
+bqFHqpbH5c5rhrEI+Us4wN72t4jq3/2sNkn1wWP51OaMlWGORHuX/mHnXHCvfWqSDMUt3FGR
+6B8k1nIuBJtQt3Z2kFsSJWlitn5wTGoD5Hr5irfrDQ9O6/8Ahn1X0bqtkb2PdbSyQ8l3aOdL
+hSgH8amMFfcVmdHjuGsrm2D7ZYry3ijdTggufqOfTANaQ6/b6brMl/byMItVuliXHYsq8H9A
+ahS9uXIc8fOLifIf9oX4Y3fQ2k6xBbaO2oa5rPxF17ULcwxlXmtJunYobc7f4Vje5O4+Tbq+
+X+uvh3b9GX2jdUW0q39pp2h2Zt7lU+i+1WUyJAijz2LC0p/wqD/FX6kfG3QLLWOl9T1VrWd7
+i003VbyGSKQo6mW3VeG8gXjiz7Bq+OPjN8Npun9KtejrXwg/wy6d0/V9QVn3lriTQ5ZLpvTG
+6VAuewXjvW3FTtkYcjhUWz8/JnkmleaZ2kkkYu7t3Zickn71zxg1IMWFUdsqP8q5tGRzjzrm
+s9ahlHegiimAgFLRRQFBRRRQA096KXH2pMGgmhKXGaXaKXFAUNooPFJQAUUUVQBRRRSYBRRS
+E0CFpKU00/amSH3pDS+VFMBp9gKPtS0uPagmhuKD3pwFGKAoZS9zSkZo5oFQnNHc0Y5z2pRQ
+AmPKkp3GMgU09qBMQmm0tKAaCGhtFOIwKTHnQKhMUh4p3kKQimmJoQGjvRxR50yQ7/5UlKaT
+zoAQgmkwacBXTw/pY+lFi42cKKUjB4oCk0EU7EAyafSBfSnYoLSoT2xT0QsQFBJPAxSohY7R
+zVhLbPpagzrtuXGQh7xg+Z9DSbNFGyHNEIW8MkFh+LHr6VHPeujHPnmmMKEKQlFFFMkKKKPy
+oBBRS9+1J3oAKKKKAFzSZopcE+dAB/pRijPrzS80ANooPekNAmBptKTik5PFBLCnAYoAxS0A
+lQU05zSkk9qOfWgY0/eigjmjHOaCWFFFFAIKDRSHvQJig80p586TmigEwoooAzQAqjzpcUoF
+LtFBokcjik+1Bo9xVLRgGM9qSl4pTRYCfl2pe1HnS0mxpWJ37UD70tIOOKLBqhaKB60UhoKK
+KKBhTgabRQNOh9FMyaUe9A7HUoNJRQWmLgedJTvyppzQMKKKUD1oJSEopSMDGaCPOgYlFKAR
+5UEUAJyeKcKbSihjFoxRR61Iw8qM0Uo5NGhiU4CjA9KWgpIOPSiilx+VBVCUU7HlRigdDaKM
+Ue1BIUuP60AUpzmgqgAPrRR+VLQNBRnFFJ+dBQpopPOlFIAxmjGa6xJGxw7Fc9j3H50rwlCA
+wxnsfI0WFDEGK9o+CHwt1/rnV/ktHsRLcyWs0tt4oAi3xhCS5PG0BiT7dua8ghtJZJFjKFd4
+BU/ev0P/ALLXQFxoHRttqF/AY7vqtTdWJLEeDp5iVG4/+I8W7PoBSutsWV0kl5PYun9IsILO
+ztNLthb6bZRLaWcIJOyJBjOfPJBOfStJBC4JDjgL/Wl6dskEVv4i4XcYintzVvFZBQO42EjH
+rURXkmklSIiQYA4wcY+9doshNpOR5VPNqAMgcEcVyFrtJwvBFVdEpWR41YMAc5U1Phxjcp4z
+zTGiXb4oGG4Vvc+tBfYCw8qOQ3Anxz7QYSfMsvsKc83MfJzGCDmoBfcG75j5B9qVLhWIJPJB
+xVcyPbLVbgPHx3OBT0RdoReO5NV0MpBCgjDcD2NTIZwDyOBwTVqRlLGdpYACSo4AUZ+3eoD2
++y5twy4WKUS5HqO39KshJvRkPc9ufI1yk2hi20Ek8fpQ3YlGiHaWx+VhU/TNc3v1DHfaGI/o
+w/SuMMR3xXUwDJAMRIRwj425x7CrBFkkjaOEkSbtsTfyseC3+dSdWsLa0vJ54JxNb3IU26Dg
+7vDwfy3jNZyV7Ki60Xtxpll1bobaPfLIbeV41lVXK5CnJU47qccjsa8Y+M+jtrV58aL28EcK
+XulW/TaqIwBGz2ojjmbA+olJAoB9BivXelrp4Hkt5mAYRRFh/jbjH+dV3X1pa6YZrmx097m9
+6y6r6dsdiru8WYXUShm8giwxPn2FaYMlaObPjado/GnVvhjrVr0xe6j8u/iaT1RD0u0DRlZG
+uJYGkUYPY/u2BHcVhryDwmeIgZicoSDxkcH+tfot8ZuiLPRekfihr9gsxe/+KOs65aSzyADx
+I7KSLdgDAMe+4Yf8or8/rPSRqHT3UGrq7D9lpaTKPVZJvDwf1FPLj4dHf6XO8sd+DOsOeKYQ
+K6t3wa5msEdzQ2ilPrSUyGgooooBBRRRQIUDNPmGHI/l4p1qm+eNPVgaZKxZ2c/xEmgfg5Gj
+B9KdRQTQyilPekqiQooopdjCkOM9qU80YPrTEFGBRRSsKDFGKKKYUGBRRRSbCgoo96KZI0jH
+FFKf/oKSmAn5UZpab54oJ6AnypOD3pffyppoIYYpaQGloADSYp3likoGxMDyFBHFLRQJoZj1
+pMUtA5NFkUJj1NGM0/bQq880WHEETJzjtUiaMxx7CPQfn3qXpeny3kyxIhIA3vx2UVe/7H31
+3fLaSssEUFodRvZ34WCHvk++MADzJApds0UVFbMaIyTXZbWRk8TwztJwOO9bzQfhP1J1BdtF
+ZabJbrsjmKz/AEskTglC/oxUFseSgk16FonwYuOqup9I6Z6dtpv2dFaGSS/cBBcRqx8e8G7h
+IQ30KT3xT/kikujxDSOm9b1+5a10fSrq9lVdzJBEzlV9TgcCtBafCrq2SQfP6a9mh8pSFJH2
+7/619e9HfDDV4INa0D4fXOnaT01p00aXXUd0WSG4k25cQDAMoTkvKxKg8KKmab0Lp95d2F5I
+8+pPNN4UD/3S3bj+JWbkR9iXIHHbk1qoxr9mTyvx0fM0HRMXw6srjqTUoY5NWjtythaEBxbs
+4wJ5F5y2Cdi+vJ7V5NqBuPmna8MnjOdzeJ+LJ8zX1f8AGy86n0mO9fpazt9M0KyT99exW4jF
+3LnBkVm52k5Ea5JKjce+K+SbmeS5nkuJpGkd2LMzHJJ9aMiSqh48nJWc/P1pvnxilyaSs0Nu
+xO1KfWkJ9KQtzzTFYtFNLelAJoFyHA+VKeewpoNLzQMKMUe2KXz70AApaQc0tA+gNFFGKTH2
+NNFKR6mkpksbtpQMUtIaCaDPelpvINKPegApaKKBpCH7U2lJHrSUEsKXPrSUUCsKTnzpaKCW
+FFFLjHagpIQU4Cm0uTQMcDS59v60zJxS/nQVZzNL/lSUZ+9U0YC8Dy5pKDzSUUA5a6DA8q5L
+XQNSZpCgIpuPOn5zTWpFPYlFFFBAUUUoAxQNbEooooCgpRRznOKSgaH0UzJpcmgaY4Gl8qYP
+uRTqBp2FKBz6UlFAxe/NL70g9/KloGJn9KPPJpcUn2oAAKUemKKBUsdBRSnikpoYoANLigDH
+nS0ikgooxS/lQVVBzS+1JSigYooPFJmjOeKCrA/f9aQjypaU0CoQA+dLSfaigaFpQPPFIME8
+0pP60DEoNFJn3oEw+9KO9Apwx5Uho6IuRle/mDU2xmhjcR3kJmtXOHUcMB6qfIioCMQeDg1b
+6PbPqNylpEqvLKcIhIG8+gPrUldHvPwJ+DsXxC1uz6cuYBc2Qikn+dVfxaew5kzxtdGyMfzc
+V9/9O6ZaRmwNomy2srZbG0UjHh28a4Ufnj9c141/Zc6Lj6X+Elh4Vw8t9rQcxtJHte3hSUl4
+seRJHPkcCvoLTIQvhsqYDgcY7c80ZJbUTnxx5NzZO06waJYfp/AdwP371PFvszj1qdawIIBx
+zkjHpSSx4BA700h8rZEUgDae4ppRfxL/AJV0cHPofeo7PtPFJlJDJcDgcg1wdhjB4B45p80m
+18Z4riWDgg4xUNmqiNSaSCTuefpI9RTA21iu76Scg+ld5QpUSYyMgEVGdPDcx8kd1P3qSkrJ
+sTbSwbtjKn3qajHP1H71XW8p2lHGSPw1LWTETDjcMYP+lWmZyiTYpeBn1wBUnGYJNq5ZQCft
+mqyCXc6ccEgn29aso2xu+obW4HvVKRlKJ2CC3SR0P0lSB65qNco8zWI5VbaOWWUj029qmnDx
+AADJ/pUe6UmzbwCfEI2HP8pP1fqBTshaJcabJi8WQJVjkU+ZJHFaLTJIr2K3mIV7iwbdbSDv
+FNtZQ49wGaqEzJCsgXhVi8RB6ELgf1IqT0yZbHTrSGQkyLAGm93zyf1rNfGWhTXKOzxf+0D0
+kby8XTG1Nf2fd6Frt3pNtGQ0l7qrweDLvXH1KCx59ZQM5r8xdA0Oez+F3xJnuTtls4NBtJEB
+DYeW7LEEjsR4Z4r9h+sujNPvNb6W1syTq1hC2kTTIxd1t57wXDlF7by6oN3oMV+c3WXwb0z4
+a9M/E7p3qfW5LLQoettGOozgZnuLRbG5uoLeJc5M0rMoHkm4s3au1pzxqRy+mye1kcF/ofJM
+sboAzKQH5UnzHrXE8HFXfVGpTazqr6jLbRWquqrDaxfgtoVGI4l9lXAz3JyfOqVuO4rkR7ad
+qxmPQY96Q/en0w96YmFFFFAgooo4oBE3SwfmmkHaGGWT9FNQ+dozVroYjW31ieUHEemyBf8A
+mZ0Uf5mqk/btQIKKKKAEIzSEYp1IRkUCaG0UpFJTQgpcH0pKdupsBMGlA9aM0tSNIQgUbaWi
+gdDTSU7ApCKCRKKKKdgFJjNLz5UY8hVEsaftSf0pTjv5U00EsM5pKDnzoNIzYUU3kflThzTA
+M0UUcUDuwpDz2p2KMetAmmc6ei+dLsHpVrp+mNLZ/NtGSslwlvGfVsbj/SlYKOyvdMSEEcAY
+/pXW0sZrkjYhK9yfICtX0j0FfdVtqV+CsGn6YN1xO54LYLbF9WwM19DfCn4H3Ca1p/7T0Lfc
+PBBPDaSx7hG0gyjSr/ESv4U9SCeBTUWypTjC35M30D8Ho7Dp3p601UtFrPXkM11PCU+rS+nY
+juuLyTzVnSNljHBO4eordW/w3s+oddlDy21pb6zfJqCW8w+iO3jGYmn4z4VtFtJjHMkmBX1N
+onwMdojeatfn57UISmqTqoM9wpK7YVf/AIcKJGi7QOSM16V038N+genJ/wBr2PS8U2q30XhS
+3E7mZlTcCQu7hASATjuRWsUovbOKeRyR8tWPwY13rjWtN6W6O6d1bTukCslxqGpagnhXepc/
+XO7fijM5AGz+CMBRXujfBnRLSLT9Pl0HTINOs7f5ZVgBDOm7cFZsjK7iTjGK9mW5SGDEhAyC
+Cq+tcvGguCEmRP0BAo0n2Z8pM8o1PpC11u6+XuYimjWEQCQsAUnlByPp7bB/pVDr/Tun3kN1
+dzykKFeP5k4XZtH8PlhRk8e1ezXfTX7XthBDfm2lV8n6dysvpjyrxf8AtN6jddHfDS/tNPg3
+T3cfyscixnEAzl3x7geferxpvbIyTS0fmx8f+vLrrHrfVILKSSLQdOmFnp1vuO0xwqI1kb1Y
+hc/nXk5OKu+prxrvUZ5CSQXJye5OeSfc96pGOeaxb5Ozt4qMUkJRRRQIRjim05hTaCHbCgDJ
+oA9KUd6ASHUUUUFIdTTRRQMUUuKQUtFlIKKKDSbGIaSgiimQFFFFABTSfKlJ96bQSxc0ZNJR
+QTYUUUUCWwoowaKAoKKKKAClJzSUuOKBoOMUE8cUlFA7HDtSY+/6UcY70Z/xf0oGc6WgDNL9
+6psxE7ikpxHp50mKLAPtS59qQd6U0VYC5ozmmg4pwpNUUmFFFFIYDvThTaKBocQKBjNNyaUU
+DsdTSPSnUUFUMNLilI4xRQKqEUd6dRRQCCilA47UY86ChKcM03FKKBi0UUUmNCgZpcDFA9KW
+kWkhCPSkAz507FFIbQUf50UUx9C5zSUdqKAsKUe9JRxQMd/lRSYpe1Axc0UlLQMTFLRQKASA
+UUc9qKBiZpBS5opEt2Ap49KaDTlBbgDJPpQyoir3rX/C/RYeoet9G0W5jkkivbuKCRYzhyrN
+g7P8QzkDzIrI425zxjvnjFe5f2QOkpOqfjJp7mJntdLtrjUpynfESbk58svsGRSXY5Oos/RT
+4edJ2vTeh6X05BLJIunRyDx5TlpCzck+h9q3tlprFirKQrcioWlOs0Vq92gSWeMSSnGNsjYJ
+H6mtfYadLHBtuHDlHPhsB3WlFcnZlbgqGxIdgAH04pkqcfVVg8QQdqgT53MCRjParaoyTtkC
+eP0PIqBMCD9Q7cZFWUq5z3qLImf+xqJG0NFfKuec9q47cDIqc6rxuXjyri8QHOOKyZ0JkMSb
+GZSTtbginkBgMj8PGR6U2eIq5yMqTwaWNTjjn71K+i2lVjz+DcpG5CePauiTb4GfOGJGR9hU
+eX6V3Dkr3HoKEP05/m5pt0TVk6BwWR1UA4Oas0nQMyscIuB71S2pJnSMnAZgOKlJMJMkNkli
+DihOtmc42XavuVo88jGD7U8AOQoNQrWVWuMM30tkDH2qXDEz3PguMKcnPt7VopWjGSpkx7Zr
+qzaMArJIMKfVRzj+lT9PaNyF3YaVdn/0/Sq1blpQHX6dzqEHoM8CpMTMl6g4WO2kMGPVu5P9
+aH9mdOtmj0JY5pYnlVZUik8XB8iGyP8AKviX/wC+GdCHWdR+GXTFlJtOraz1Dr+oyMeVtIY4
+QXbA7In7tAeckKO9faXTLSxRXSuuGWUZ+xJJ/wBKwXxk6Kn1rU7brCyW3m1zTdN1PRulIZ3A
+iF/dQNO0zKQfEaNoY2UHIBUHGa6cTuPE5Mnwmp/R+NHxA6XPSt4lpqUoj1a6BuJdPXk2EDcx
+JMfKZlwxT+EYzycDGuMVuPibod7ofVVxZardzXGqTxQ3l+J2LTQzyxq7pMT/AMXJJYeWQO9Y
+qYYbGKwapnsY3yimccim040hX0oKYlFGMUUCoKX8qAKdQNF/o1uP9jOqL1k+pXsLZW9N8rMR
++kdZ0VpYVlg+G924YhLvXoIyM/i8K3kb+niD9azVBnHbYUUUZFBQUE4pCcUmSaBWBOaSiimh
+BRS0AUNgFKD60hxRzSAdRketNBp2POgdh3oxxiiigY0ikpxpDxQS0JSEjv3paQ1RLEPem+eT
+2pcUEc0yWhuPejFOxSGglobil+9FFBIUDvRSgedAIcBmnBfOkUVLtLWS6uYbWNctK6oPuTip
+ZslZyEP1qvP4cmvoP4c/C2bqOb4YdNxwAvqVxqeqXu4ZUKFIjDY5wPDOT5A5rG9L/CvUL+/t
+5Jrdp5pt84s4hlxEsojQt6B2Bx7Amv0P+BPwPtem+kLeTrCENeXGlyacy2xKu8cspknO78S7
+8rGMYwqt/NRF0zLLJcddmA+AX9m22vtC0rUjZx/s/S7qcQyTJmC8uQ+XuMf8VA4ULkYPhjuK
++mel+iNF6NgneGMzX1xK0t1dNhpZpD3YsfM+g7dhWuVYLCxhtvl44YoYlWG1twFRFUYRFx2A
+FVfjlpNxILnk47KPQCqlL6OKKcuxAuxjmMIknO3POPellu44wD/KfypGYnLPyTUO6BdcKpIq
+LNeK8j5NSUsWJJ9/9Kcmp7QSqYZhjPpVbLGQCMYAH2qOxKjl8A+9LkPgjQWerrBKoVXO38Td
+68l/tmXGlt8Nrc6nrZ02O9mWGWRo2kOzk/TGvLnOOM1uoZWWZGy3hggYHma+bv7Z/wAQNUiu
+rnSpNauIbKxs4hZ2dsy7VlOd7TZ/C+cYHfHNdPp8nGzl9Ti5uKX2fnf1ZpsGm6rPbx6gbsBj
+sfwjESPUo3Kn2NULDnvU3UJLia7mmuSWlkcs5LZJJ881EIzWbezucdHPFFONNOaZDQUh7Zpa
+KCWMzmilxz2o79hQTsUdqWkAxS0FISloooEgBwadnim0DPrQ0WmOooBzQe9SUIabnnFONNqk
+RIWiiigXga1J5UUUEWFITRk57UmadE2LmlptKDTaBMdnPFIe9FFSXYUUUh8qCWxaUHim5oB4
+pgpC0YJpQOacBSKSvY3b70u0UpxRxQVQwUdhRRQZAKKKKBiYpCKdSEU7E0JSg0mPOlHnQ2SL
+RSDmlpFhRRRQAU4Cm0o70FJUOHrQePOiigsKKKKBPYjUtFFAl2KOeaWkFLSZohDSU4jNJimK
+hRRR70uCeallJCZpRSkDA9qCozmgrYE47mlpoWnCgEHNJzjil9qMUDaExmloopAFKOOaQUo7
+96ZSFpaKKBoKKKKCgAzS4pPej2FABSYzS0Ug7CkzS4owaYqCnKSMHOPtSYp6qD9/Kk2UkXOg
+a5PpV9HdKQHU43mNZMj0KsCrD2Ir7f8A7F+kWeuzdR9cDS9D0+6tYLTTo5NHtTbx3SM++V5I
+8lVfaFU7QB34r4MjAVsuCAOT9q/Rn+w90nddPdFdS2N8Ueb9sWxR4+VeKS1ikTB8/pf9amUn
+xomcVas+qBZWrm3kIb98QwwfwnH+VbO2U+EquMbQMe/FU2mWCyw24YAMiBT/APT9K0+zbEq4
+/AMD7VtjiYZJWqK64ITP2qonbmrK8f6ivaquRgzZA+1KQRRyYeZrgy8mpLCuDHzrJm0SJMm0
+n0PaozOVbB7nvU+Qbhgjj/Kok0XGSM4rJmsd9kV9pG0jjypEhfOOD70kig5HIz2zT0JxgnPH
+BHlU/wAmrWtCPBJu5iYqeMiuDnw22scZ4AIxQ9yd2NxVh5ihr6YKY5Vjnjbghxn/AOqh0CjI
+WOfw2ViAArCkhuPCmII4JJzXNjbP/dO0bHsrHK59vSuDxkMzYIfHapbGkmW8M27YivjDd/at
+HBJ4olKtlo7eaZDn+VCQP1NYy2aSMshHcYGfI1pdMfiU7toSylY+eQcCnCW6M8sKVk+2y0Ky
+qCWijVz/AM3A/wA+ac9wUa6lz2B2j/GfOpFikb28rRnDbgNo7YUDP+dcru2e1ivGGHXdvhYc
+5Bxj+ua2fRypq6ZajUXsLOaUkNO0Ctt9Oykn9T+lWOp6FZ6vrnRvUtwzyf7JPqN/bwg4ikll
+tfDDOPNgMBfTcazMsjTyQHB2BY7abJ7rnn9Sa3XTtxHPH8iyAxK4j+rsQOD/AJVWKbjMw9Rj
+XD9n4r/2iujdU0fr3XdW1Qqt3q3UV/bGJW37Wi8MSfV2JDsV4Pka8d1qwm07VLqwnjMcttK0
+Uin+Fh3FfqX8b/7PGmRQwQ3sU5ubu7/YllLxKsFu17FdX92MjImZFEe48AOwGSa/MXqqc6r1
+Hq+rhcrf6hc3KkjGVeViv/y4rXNjcd/Zv6PMskeP0Z9oyBn1pjDBqVOAAmPNcmopFYo7RpAx
+SbeKdRTABxRRg4oHpQBpbuBofhnpcrdrnXb4rz32W8A//OrMEYrX9QW8tt8OejN/CXc+rXae
+6+LHHn9YyKyWKCIU1YymkU8r6UlANDe9JT+3lSAUE0NopTSVSAUHFBOfKkoooBaSiil4AUZp
+e/FA4/OkIpAKAKWm58qdmgaCmnvTs00nNNAxKSlpO3bNMhiYoxnzozRTEHGO1IaWkPPFAmNo
+xS4PejHrQRQAZNdPCI2jHLDNEMZdguO/erW6tCtzHsQYMKsMfpSbKiitjjJIC85OPzr174C/
+CrWOtuvtKt4bBp4rfxNRlQg4McC7sHA43NtH513+CPwV1n4i/FXR+jbawMqWkiXOpFlJVEXD
+sGx7EDHvX6a/Bz4YdN/CbTrnTNAtUN9ey5u75owJJV7+EoH4I19O586VJbZOTLx+MezP/CL4
+F2PR/TmijqO1hl6he0glvCsYAt4kLtHGT3LbpGwM9hzXsCxLESEjAVmyzevoq+gHn607H7wl
+QcnmRie57AfkKeFOMlskDA9qTlZzKPkR2bYWkOXb6Rz2FMWAA7QB7+/3rvHG55C9+5Ndlj2f
+wnJ9KKsoiNbH0ANNa3TGcdvap7KMY28j3rlJFuXn6RRQ6KK8hcklV/M1VyIoYs6klO+RV/eM
+cHw1AzwCaqZ4mkYKXCjuSe5pNFpHXRrMT6jao+NrSqcEcADk/wCVfk3/AGiOo7TUviB1DeJq
+N5f3F7qtzOjSz7kjhLkKNv8ANwfyxX666aIrZxPckJDDbXE8jN5IkTMx/QV+HnUt38/q1zeg
+n9/K8gz3AZiR/Sto6x/6mEVyz/wv/kqWYkk5zmm0pBpufapOlhtFKsZY4AyafDE8sgjjXLNT
+7l4ox8vAcqD9cg/jPt7UEtKrIzlRwv60zNI5yaSqMXIXv96WkUU6gEFLmko9qCgooooJfYUU
+UlA+h3NOzTKUHnFJlJge1NIzTj6UmDQgYUUUUyXoYe9FBooIYUmPKlopktDTn7UChu9Ap9ok
+cKKKKktBQfejvSgZoHQwiinUmBTslocop496avFOHbtSNY9B58UZPrSceVLigZyHnS0oWkIx
+QZJUFFFFAWFFJnjijNArClpPOg0CDyzS0nmOKUcUDQUDmj8qKC0g+1OHagCjtxQMKWiigEgo
+A4oFKRQVQlFLyaBQAflS0Uo9xz6VLLSE/KjFOwO1KaB0JjmloooKoKKKKAAClxSdqM0FJUKf
+Sko9qKACl70YPpQPQ9qQBgZ70opPYUv+tMYuKMUq0tIuhuCPKjGeadRRYUNxRinYox60wobS
+/elooBIMUUUUrHQU9fKmU5akaLjRdKOsX8WnqHMlwdsW0Z3n+Wv1F/sw6RquhfDjSItXjZZL
+6GK7UsCDtVRGO/qFFfnR8Flsr/qq20q61VNNujMlxptyx2qLlSCI3b+FXG5c+pFfqb8ONE1b
+Sui+m9I1W4kmv9Js5LS4aVsu4WRmBJ5/hKj8qbXxMckryKJ7B0zCtzGuWwR2xWilgRI9zLyK
+oukFMcPibcDarDI9q0d2Ve0yPMc5rph0cmT8jIagTvYDtUAgbvSpd46+KeeM1DZx3FYy7OiP
+QhxXCQ47invKM4rhI+fvWTNYoRmB59aizEqMiujMRwKiXLfxH7ZHlWbZtGJzMo3c4O7vmk/d
+7SyEjnnFRZCy5IyaSOQhueKizfgJKil9ydj5Gmkblwo3HtgU+Re4FcTOdgjUhfXHnSHTOaxj
+xds0uAvGBz/WpNtOltvt5MFgMCQ84yM4FRCGywxncT2867LA34pCqISCcnLfoKF+gl+xkCOZ
+kUuQXIJJ8q0+lst5JcPbqfDudJucZ7l0ZSAPyBrNM0SSNJHJIxbIy8ZUVb6ZdSWDNLGN3ycU
+jAA9wRkj86I6ZnkjyWi70nUHiuYBC30uvII/WruTbKDbk5Tc3/dazKRpDdF4GUxMQ0eSPpB5
+xVxbT4yr5DJgY9fQ1cJPo5skFdoFja3vJXLEwsVjQHn6+4/rWn0lmhAMZ2ltoH3bvWaZpJ3E
+US/XvjmK+Rw2P+9aKK5i3JNCVaJlWRNpyNwY7hn2Iq19mORNof8AEO38XovW9bsrR7q+sdKv
+PAjjXdIWIIAVfNizKR9vavyg/tFfB7QPh71Z1703oMbXb6FeSWdlsXCtMqxNeOo81gjkii9D
+IzHyr9cNKuhHPbWnDGdUkfPbDNuGfzxXyN/ag+CGnx/GFv8AZqzkD9TaRevNNMrvHbz3CXDS
+IjHgbmhknfnP0jsMV3Y37sK8o4FL+3yqV6Z+V2owGCRY2HIQCoJHlwRV3r6l7hJgMCSJXGfQ
+1TsOa4npnvR2hgGTTtuFzjv2oVc9u/lXScBZCo/gAUUD8nA0AYIx60/b+frSedOwo1/XCvb9
+LfD+zLDH+z8tyo9PFvrg/wD5tY6th8RZM2nRMQ7Q9J2i/rPO3/51Y7POap9mWP8AEcRmuZHn
+TwaQj05pFjKKUjFJQSIcedNp9NI54oJYlFLRx3oEJRRRQAopSRTaKaAWjJpKKKAXJpKKKKFY
+UYoHb1opgIQKSnYppFMkQk0D0owc0uMUE0AGacFoRfU1KhtJrm4itIULSSssaKO5YnAH6kUm
+y4otemNBm1a+tbZRt+ZkKqT5Ko3Mf0r0v4VfDvVOrOt+noINDudQSaIX5tol+qWJZyqL7BmA
+GT5ZNXXQHw+uV+I9jocdq8lrBaTWsE6fUk80UDvcFSOM7xt59K/RT+zb8C7b4Q9DWsupW8M3
+Ul5aw3N1Lt+q3TYBFBn0TJOBjLMc0kr7M80+CpeRfgV8HdN+DHSN8ZIIZeo9T8OG+u4m3CWR
+meWZlJ5A3OFHtGK39papAPHlB5BKg92JPc+1WFwI2jhh/gjPJ8z/AOpphj3MZSu49hk8Cobt
+mEY1saykbQ+Bxlq6QopwfMniuZUu/wBILEgZ+9SVjfGPwnz57U0OjtGg4LeXkKfsJGVGCaIY
+wBuyTjzPnUlFUcbc57ntWiRJBYY45J9cVwmJK4IzU+62KeGGfSoMjdwDQUivuTgFmAyOB7VW
+GEPIDgkgk1Y3JPJP6VEjcCTHrWbNa0ZH45a5e9MfBTrzqLTs/N2fT1wkJzja0pWLP6Oa/HbV
+rS3tp2Dz+NKf4V7L96/VH+2n1TDoP9nPXdJi1H5fUeoLm2sYESQK0kKv4k/Hmu1QD/zCvymu
+7mHcwSPdk8k1q1UUYYLeST/gr3HrRFBLPKsUKF3c4VR3qXaC6urlLWygV5pDhVCj9ST2HvUi
+/u4LON7GylSaZxtubpRgN/gj/wAPqfP7VJu0uyFPJHbI1tA4ZjxJIPP/AAr7f51CY5NK3vxT
+apGUnYhFIBjypaWmZ0JjFLRRQV0FFFIDk0CsWiigUAgpKWigGgpRxR25pKBi7qBzSUo+9FDF
+xTCcV0PpXNvYUkEhDSCjmimZhRiiigQhGaAKWimnRLSAUUUvkKRSDA9aUdqQnNJQOxW70lKD
+60ox2oAKWkyKQnNBV0KGpdw9qZRxQJMfTSfSkz7000yXIU98UmfSjjzowadEC8kUnnS/ajmk
+IB70vNJ+poNAw4paKKRQUoHnQO1KO1BYtIaWkNAAO1KMedJS0DTF7UvemUoz60DsXHvQO1KK
+KBoPtSg80lKKkpaHUUnb86XNBdjSeeKXNIeaSgmx/eimg0ozQUKB60GlBxQefKgdB/2pR6Um
+KUcdqBoMe9GPKlopFUJRS0UwQDvTqbil7UikxaKKKVDTCiijNMAooPakoQkxaKKKBhT1HlTK
+7RFAw8Rcqe4HepKRouh1T9uW2+18QtcQRxuWIEUjSAK3Hfk4wa/ZbSrEaa0WnYYvZwLbknuT
+4SKQf0P6V+SHws0/S7vUHspbiV3vJ7FbcIgyJEu4mAb0yNwzX66+K76vPNGmJDdFHHcZUkf5
+VX0YzdyPRtIWIWUUiEYK7Rj9Kn6luhtQMYBXIqp0MGK2EJ/gbPJqw1Ni1kzZyCMZroXRxV8j
+F6hKwlIzgKahs7HOe1d9RceIZDhfqxz61WyXQB2KPesWdsFaOzyMK4GQ96BOH701iCOKwkbR
+Q1pc/l51xlIYEFsA+VOk7nAqLM5UgeVZs2ihSq4/FnI9K5+EM/jNDSsFztziuZmVjymeak1S
+ZJSKNAQ057eS1EuTbJ/7rKM553LzTpGaVfDY7QezCq9y6zeDNgMODnzobCMfs7SyPvAVlwea
+Q3ciruDso7ZHAp8Gn3Mo5jZUByWcbRj86530dzNnZ4RXH92HHAFRbDTdHM3cyLIxd5AwChCc
+5J7YqxjuBCLi0LA7YhE7A93wNwz98iqm1LhhIDskTkZ52keYHnXWO1CJhJGJP1AsMefeptmn
+FMu7e5U2xYscA5IHlV9ZXBkRnLsZo0XCDzHvWStQWDDPHGfvWksnuLTZ4QYyyD62/lXyFXBs
+580EkaAHEhkidYxNGF3H+Ent/rUvSLJtO03T9NMhIivAQ+MZRmZm/wA6z8dwUmhUr9Ls4/Ne
+RWgtNQjmnTT3G1JLdsZ42sAef1raMk9HFOLSouNLYftqcD8FlaWp9+XfH9BXf4naNP1L0jq2
+o2ZEt1pXT2vNYwL/AMW8ms3hj59t7D1y1U5vJtP1vVL1lDQXFhbOrZ/gjAzkeuWI/KtpoJVr
+WUOBIpXePRyxJP8ApXTgnxlRwepx8on4N/EnonVOnZYri50+SGyVm0+3kKkKzQYVxn1B7juM
+j1rCXFm8UayOpG4bhnzB7Gv0d/t7/AXqvq3rWwTpaxi03o7QprHpuyzhI5dT1GYS3k6KBl2J
+kQMx7bOTXxH8aNMsrPrzUrXSIYYrCSXfYRx9ls0/dQk+hZY9/wD1586rPj4uzt9H6j3YKL7R
+51boDKuRwPqP5U0guc45Y5qdFbNHaz3RBK7lgXHOWP8A6VxuI2t2MLABx+Ieh9K5zusiuAOA
+OB5+tcjzmuzDjd2B4FMCnIFIZqviIhUdKZ8+lrAj9ZayOPOt38UrcQ2vQU4bPzXRdjL27Ynu
+Ex/8tYWrk6ZliVwQedFFFSaDSMU0iuhppU4qhNDKTA9KcQRSUEUNPsKMU6jvQTQyinYFIc5o
+FQlFFFNCCiiimHYUYoNFAhKXOfKkwTjNLihh2AOaKAKcFJGaVjSEAzSumHK47Uqg9qny2b/V
+IPNRge5osKG6Np0mo31vbxoSJJVQ4Hqa9F+HXS1z/tfdXiWclzNpk8kdnGiFi16xMduuByf3
+hB/Kufwq0B11XS9RlgZwJp7kIq7mdbdM4UeZLlRiv0b/ALM/9myPpaGL4g9R6bFFqt466hBC
+y8xSvEN7Nn+JWZsemPWkvkTln7Sv7On9mD+zknw/0q21LrKITdQ6ii3EtlL9a2kC5AeTy8R2
+LEgdxwa+hL2XbvhhOAxyWJyWPqa7hILRZAiqZJiC7dycDAGfQDtUSYb5CSfuPT2pSd6OSK5O
+2R9gfY3kvYe9dFUs+5iQAMkDzNORc8fw10BJH0jA8qmKNWJHEPtnk12jhLEMR9J5OaEJX0/O
+u8b5OADmtUQxyIFAOM7ewpecZwOacoPnTyAB6CrJIEqZJLd/M4qO6An6Vwo7e9WDpuOODXGR
+SAMkD7VLLKa8gAUtnt2qthjV5+BjuTmra/BZSoHJqJYWrT3SQpjdIwTJPkTzWfbNW6ifFn/3
+yLWFsp+nNFS6CvDpvjNDt5LSSMQSfTao486+B7PT9Q17UFtLCEPM4LMchVRB+J3Y8KoHcmvp
+X+2HrWu/Fr47dSXllcwroGnSfL2l27bbaCzhATxXb1LBsDuTwBXzxrWvWNtYydOdLeImnMQb
+m6kXbNfuOxf+WMH8KfmcntvkVOjm9M//AE7+9kfUL2y02B9J0SbxQw23V6AQbj/CnmI/6nuf
+SqXNNzzyaDUGrlYE/wBabSk00niqRm2ITSg02lGaCEx1FFKBmgtbGkZFJginke9IBQLiFFKf
+OkoH0FFFKBzQwQYPFIe+Kd9qTFJDoSilIpKYhd1Ic0UUAxpGO1JSk5pKDNhRRSGgmwz60frS
+ZozzzToVjs0e1NyaSig5D8iikH2oJIoodjgcUZpm6jdRQch1FIDRSC2xaKPyooCxtGD5UCnU
+2SNAo/zpcUYpoA4pRSY8qWkxoKQ9uaWikNoKKKXnHnQUkJmlFLjFFBQtFFFA0gooooCgoFFK
+KBpC0UZxSE+lLsroWjypBz3pRSaoE7F9PvRmlxQRmgsbSj25oHPOKcO1AkrADFFFKOKC0H50
+L3xR5UA4oGLjyNAo/wBKM+VABS0n50tIsTn0paKKYuhRS4pAaUc0ihKDz96WjFHYUFAoooAK
+MUUUdDoKKKUDNKxiU9aTHrT0QnsKQ0jcfBqSVfif0usRJzq9oWUfxKJlJ/yr9kLLA6k1C3HI
+aWS5TjsXOa/H/wCAOmXGo/FbpsWyF2h1CCYgDyVwWz+QNfr9YTpb63NK/wBSFhg+q44NOL2j
+DK/k6+jW6fMVdATkE8kVdXXNhKq84zgVn1URKxViVwGq0iumlgO5/q2kEetb2czV7MLrDPti
+G78Vw/8AQCoKKCSfM+dTNdfZmJeSZiV9sjFV/iqMKvYcfesH2dUdI69jTtwI5rj4go8Rc4zW
+TN4sewB5HeuMsSkUrE5yO9LnPes2bxdEMjghTj3rkFJbt/2qTLtqO5pM2R3VYlQNLufz2rwP
+1pZLpUtj4cCxsHA3KPqA+55rgJMfn3przsSUYbgeKE/BDjZwdjOSfmi5/wAZ70yZOSzIVfbt
+78EmpC2EzvuUZjPYA81JFjO1oluFCyyyMd7LlYYx3c/YZwPMkUuLYpTjEpbAeMGm1CGR7a3c
+oIogd07gZ2HHZfNj+XnViltqmoJJKIleWRg7HIWONO2M+QFdr2S6gjSzi0fbpS2xlQuMyNls
+DcQc5bkmo+l6lp1/aPA161np1kWfUJOxI/hjXzLtnAHl3qeKvixuba5JF3oOkwXDSOtw00UH
+0yXO3bCH/lQd3Iq5S0FqFCuzq5+t5OCcdsCmS6h8j+z9PttOjtt7KJIT+CwhIyEPrJggu3mT
+jyrpBcwau3y7MFSPgsTg8ttB/XFdHGKVI4pTnJ8n0RZQzPHIsbFVQnCqSQ2Sc1YSuYoY9TB3
+tFtib/rcE/oDUe9srm0kilsZHLNK3hgH6v3f40b3Bx+RqdlLvfFE6FZpPEdfIhowf6EVCjto
+HJNJlwTHc9TRWhBaGW0R3545IO37EVr+lpHNrOGOPClnUZ8h4hxXnOgXgkk1WeY5mt7cFxnl
+QmFKj9RW26Uu90s2ng7jNMqhvTeuT/8AN/nWmOW0zlzwdNHTrv4e2nxCj0BLmYRJpGozakVK
+ZEsxtpIoifQK8gf7oK/Gr48/DrXOmup2s7qySbWta1i50uK1slLpb/K+HDFaoezPsMZbHYk1
++3eg6nHJGt2OA0hVc9sowB/rXx3/AGtPgg2gdM3+tdC6TPqXUmma0b/TDGgzDc6srxmaRj5+
+Jly38IVfSvQ/93FXlHn4pv0+bl4Z+aetdML0xppupZUmWxuZbS3deUu71cCV0Pmkfbd5nFYF
+o3lYyPk7m5J7sa+hf7SXTmm6R17o/wAIukZ0vrH4f9NafpFzdRL9El66ma7lLdmZpZDz6ADy
+rx++01LLE3h/Uf3dshHkODIR7ntXLKJ7WKXKNmaktikRlccg7QPSom36hnPetG9k8kLxKu76
+dwPv601NBMujvqsRJFrKYpR91yp/XIqGjZSrss/iNO130z8N7llxs6VNqD6iPULr/wDSrDV6
+f8RLCBPg78Ib1P7+Sx121uPZotTcqPvtkH615q0LAMSPwgE0pdk4XcdHKkp200mKk1Eoxmlw
+c4owfSmgEIpp45p1FUS0MIFIRinhSzbVBY+gGTUiPTb113fLlF/mk+kf1oEQ8H0pdue9TflL
+eP8Av7oH2jGT+pp9/c6asoXSbR4owgBaV97s2OT6DJ8hQS1RXtEVG5wV9B5muZp7vuJJ5J86
+Yc+tUjNhRQKKXQdhilxxnyoHeuirujfj8ODRYzlS4oIxQBk0mwHxoWIAqQINwyBxTreB/CVw
+PqlbYv8ArW00fpKW80DWtYUcaW0II9VZsMfyytNKwtIxdraPPexWyISzuF2gc16Xo/Q0upW0
+Mq27yyLNFGkSLkyyTNthT8zgfmKd8J+jJde6wttsDOStyUHqVhdv8hX6Cf2bf7McFvr/AEv1
+r1Dawvpken6fq3yciZ23OXkKEdiVIhIPtVQVsxz5eESv/s3/ANlC66en0jqDqq0S2m0WOO08
+LG4yETNLcA/4ml8NeOyp719aTNDCy6dbhVitFHibe2e4Qfrk+9SrxkhMcML7VUu2fMsf4v6m
+qsKqRiIcEnJ+9TN1pHLBPJ8pnNnc5bH1yE4/w1zZDwoBPPJqT4W0Y3f9+aR0z+5BPH4sVmkb
+9Efco/0pGkUdgT9q6rbNJlsrGnYDHJrrHbBclf1q0mLQyIFhuwftU2NG4zgYpY4cEYFS4oU4
+OM1okS2cBEQf/SlKt5LUxosDgcVwZSDjOaoS2RGU8kjk8VwlQ44qwZMjtUadQozipaLRR3gI
+yqjnGCapuodfi6O6X1vq2UNjRtOnu1CruYsEIXA8zuI4q9u1LSjsADnmvMf7R13rFl8EupbP
+Qoy2r68ItHscOqBZJHBJLMQq4VW5NPHHlNGfqZOOJtH5SfFrrK51K6fp20V7Syina4ng8Xe8
+twxy0kz/AMb5JPoucCvNW71ruu+mpOn9Wmtp9RtrudXPitBKJQGzyCw4JrJP34olfLZcYqME
+ojP86CfUUGkoJEPNNzx70+mkedBAlAzRRQQPpc8d+9MWnUGiYpNJk00k5pefOgLFJpM/1owf
+WloC2wA9qXtwcUgpceWPzoGLQaQd/tSE5pJFXoDRRRTJEpAcmlNNFBIHvSE++KUmm+dMzkGf
+OkooqiLCiiigEwooooCgooooGFFFFBIU4Ug70vHakykHelpMj1o+r0NKgEzxilB8qbSim0Av
+Ao70dqWpH2JilopVoLSEpdtOooKobtp1FFA6CiiigVBRRRQUL96SiigApR60lKPtQxi5Bo86
+POlFSMQLTseVLS4z2oLURKKUik86BgBS49TRjHfNOx7UikhpBoxTsYpMUx0IMdzS49KWkoCg
+PrQPWg/+lIBzzmgQtKKSlzQWhcCkIxThRSGhAM+VKM+dKBmjFFjoSiiihDCiiihOwCiilxzi
+k2AAZp1GBRSGkFSLbYZUV32fUPqxwPvUeusYO4H3FBR9b/2W+j5um+vdC1m7sITYa0ZLIXaL
+4kXiyRsYZI5BxguuwqcMCRxX39pCzS3Tb+TbqqYPcgDmvhj+xPJfQWeoTn5g6fPI1uYS26FL
+sJvilCn8L8EAjvmvt7pqO/sobVryQu07gF85JyOQan3IymlE5pY5RtyNp8wsQiBJKSLn7VIg
+uPqGD55+4qqvHzDsTtGwHvjFdLaYhVPGQMAfeuhmcVoqeql8O9t5VGFZyPYGs6t2VLAtyCRW
+q6oUS6XPIFy0a+IuPUV5u96yzuC3JIx+YBrDI+LOrFHki+N0o/ip8VyuSN2aoVuGzjNd4ZnB
+HNYuR0Rxl/4y9weKa0xxwc1XRTM3HcjipSg9gcikWo0K8jHIz3rgzknB4J8q7+HzgCmsm4YO
+cjtSasu6OQY5oD4IOeR5etKIicheCacttLIQMDNKmDkvJMtbu2IA3eEe24gNg/atPYaPfX1s
+QEeQMBjbhHI/mOeFH3qn0mw0jTnF3q08jN+JYkTcxPsB2+9a5YF1iLwra8jgEyhkinjePP6c
+/rXZixtrZ5fqcqT+JSXWgabpsRMdyz3GAhKDxmXAx7AgVBOg6e3gatPcQyCxc3UFi8YjWa/O
+BHJKBnKrjdt9hWxstC1u3sTHcaXbXPgcEfNh5JGBzlUGDiqe/tr64u4bzQNbWykB8OXTdQt0
+eCQlucOPrjY9s8inPEu2jPHml1ZhNSutYi1GE38sk0jzszzL9RmkPJJA825NXEM81rELprBf
+m32gxOOCCwyMA98f1xUvTNe0zqX9sWN9YXGma1pBMF/Cn0zxR7sJcRj+JQSCf8JqN+zZrHqJ
+lnljljmaCWOZOEkAKAkA/hJbPHvXLwcXaZ2PKprhJU0b7VY4r2HqO50lNlxpfjaimDg7tgCv
+j/FHkH7Vl9X3Q3i3dnhYjZ6de7V/hcr+9H2yR+tXmi362+vG+ukIivH+TuBj6ZLZndfsSuar
+dcgXpzqLQNVuj4WlLdnRNbRvwwtKoVGb/C6lGB8iK1ybXI5cXxlxCxtlj13WtSQMkOoW77lJ
+yElaNcD2yAK0HSF4bOewF07Lc3Tq7nyVQWwT+QrMWElwlzqvSt8zC4a6Ni0n/hSQq7BvzEYH
+/VV0ZYxe39w4aNIGt15H4EePcD9suKzS3oubtUz0LxobK5n0dDgi7nVQPJdoct+pqfrOmw6/
+0zeERYub23iDSRKC+9BhGGfNQePTNZPU9RibqedXyks0Dywny/AEkH+Va/pjUFezt4W4PgAH
+nzBxXXhnUjzs+P4n5+f2lOjem9N6k616vn01Y+nemgh1KWKLaLrVJV2WVhD2DZVFZ25JZ85A
+r4n0rQdZ6muZNYlhUu7+Ccfgj4+oLnyUfSPev1a/tmfC1eoehIIheyWuiWF/+2ru0ii3Gdre
+1kOQfJjhVBOQCc18lXXwhn6O+DnTGnPpUw1vXJLvULoeAd0EO1Ws45T2j3B/pB5diMV0ZFcr
+fRXpsyhBq9nzI/Rd4NVghhtWzdSeDAGXhkxw3+WcVZjpe3XUeo+mbTTWKXmhfO2ttuxIJI1W
+YSKfPhZu3lXr+qaFqWg3/UNhBaF4+kktIrRFGXMiTiG7ZBySVO9iO20V6NP8N7DTviD8Oetb
+Ixx6PH1/H0Y80BEiPpd6snyk28Z+h1mlQZ5zgVi0ukdPvOrZ8n/Eezt0+BvSWnWyu66H1nrs
+RmZQA0d5Z2dxEPvhWyK82h0GWXTLC8aJsapfzW0Qx+IQxgtj1+pgK+hfih0j4PwE6n1sgW9t
+ovWmms9oi8iQpd2Vw7DuhzDFgdstWA17RD0f8Wug+itRiYDQYdMF/b+cdze4nlU+jDxkB+1Z
+Tds3wytO/s8gFjI1rLcBRtRUbP3OP9KglT6VurfR3k0G7tkRswyAkY7g3bxp/kRWUu7Uww27
+shUyoX5HcbiP9Kho6IyvsgKpNP8ADx3HJpyLhua2HQ/Q131n1BZ6TDOLSDabi+vHTKWdonMs
+7DzwOAP4mKr50krKckjPaP0xqmuzSraJHFb2qh7u7uG8OC2U9i7ep8lGWPkKkXEPSWl/u7dr
+nWZ17ySAwW+fZB9bD7kfatP1Xqq6nqcvSWhafJY6FpkM5s7NzmRmH4ric/xztjJJ4XO1cAVh
+Gt33hGHckA03ohPlskSa3dqCtokFon8sEYX+vf8ArUAzTXDgSSM7Me5OasrbQrm96e1bXYiB
+FpU9pFKCCf78uF/qlS+idBXWbvVbqUHwNG0i61OXnGQihVH5u6ihbG5JIzsh3OQBjyrkwINd
+kjYhQe/nTZoyrkYpks4UU7afWukcEkudi5Cgkn0wKdknGnAU9VzjH6U8pt4xye/tSGkctvni
+pNrCZvFjXuYmb9Oa72NibkSkf8Nd33p+iwCfU7aEvtV22sfRSOaBlc6cLnzGaIoWklSJQSzs
+FHvk4rtOMzuoXGGKgemDitl0d0rLqPUvT9vLE2DcRyy/TnCBs5Pt9OKBOkLb9OkawNNZcfJM
+UckcAqhLf1r13pzQDB8NLgSgxxazFf3DTbeMRi2bBPsc/lUnQeh7fWbS76kCO5nliJ44aa5c
+4T9VPHoa+hfgR8Lout7G56W1u1ljgtZGFopJETR3M7Rzqw890MYHByvFUlswnNUSv7OH9mC5
+e56b6u1K1+XsJ7K5llbxMGQyExgJjuhjBbd6NxX3DZPZ6fYoLGBYYovohiAwMABV/QDOKrtE
+0uHQtG03p/TkAt9Ns0sLdccBEGFH5AVNvcrEgh+psBUP+bU266OV3keyvuWLXGD9bvmuWAXw
+eyAkn3qSIiCAp/eNwzHyFKbfaNirhV9e5rJqzZNIjxq342XHpTmL9oyAT3Nd2Qj8XmO1NVBj
+AGB/WqSFdnFIwzYJLEe9WFvboygEEnz4psFuH5KgD/OrGCJV7H8q0jETEjt4zkAD712EEajA
+wK6qAFHApkxUDDDIrQzI8xjT/wCuojsh4ANLOwBwM1zUE8jv6VDezSK8jgua5XCjYRUpUxgd
+yf6VyulURMSOw/WgLM7cAmcsB9PqTwDXzf8A2ytV6buuldO0TqLUZI9M0+WTUL6OCdY5p5Cm
+I4k3d+MknHGa+kmjN1dx245DuFwPc1+Zn9tzX16g+L3U99dJcjTtMuBZW6HIR2RQoCnsRwTx
+61WLVyM81SlGH3/8HzR1/wBZJ1XqWNN0q30rSbXKWllAvCL/ADMx5dz3JNZJu9SLmUSys6oE
+BPCjyqMT51m3bs3klFUhCaSkJxSCqMWx1IaWkPagGNooooIYo706mU6gaAjNJmnUmBQMWiii
+gEw9KX7UAUopWWIaQ0HvTW70yWLS0gNLQJDSeaSg80UEsRqbSmkqkZyCiilX1pkoMDFBGKdS
+HFSi6oQUGgHFHFNoQlKRikpcetMBKKKKCaClGaMcUoFJlITtR+dBHFJSqwFoxzSmlosaEx70
+tFFIoKcvam0qmgaHUCgUUGiQH3opSfQ0lABRRRmgEFFHegd6ACinUmPagBKXnOBRilAoY6DN
+FLgjmkpMdDlNOHbikFPUZqTaKALS7acBQaVmnFDdvpRS0hNNMVUBORTaXNIfSgTYZpO/nQR6
+mgdqZIGjijny7UtAB5Uvfmk86XFBSFApaPKip7LQ4UHmm0tFDEooopoQUUUUmMctLSAUtIaQ
+UUUUDHJ37Zq70nQptVikuLNHeK3Aa52qW8FSQN7Y/hyRzVPCm5sedet/ArqPQOlNfk1PXekJ
+tWlgtZPl5LdifCP87xH6ZlGM7T96Kb0gbS2z7T/sl/Dq66C6JSPqA237Q1SUXUNmDmSGHbw7
+jyJ8vMCvpKxjYW9uso43huPU14X0Asmq63pGpWer3OoJLZ2+qG6lXw3kWY5GUH4QBuGPLFe3
+2F2C0UZI2nke2DXPijLnyyd/rozyyjxqHX77J8swRSrc7jz9qbFcBZNhPJUEGoryGTEh74bP
+/mqHLOYmDZz9ZAzXZZlBFzdyeJbtHgEFcYryXVpRaavJatkeHgn37Yr0lbzIwcfUuM5rzvri
+JINTjvFGN6AMc8Eg8Csc242dXp+6HW7kgynPf6R61Oi3bdoIHnnFUlreBgCzD1+3tVpDOp+k
+Eds1zdnXRbW53AYBz5mpqNtqBayqcc9xUoyADORVpEtncTx4Kuv6V3hFtN9K3Ajb/GOP1qnm
+nXGQ2B6iuaTu7fSwB9TWiYnG1o1ltpkkwYNZC4A84mDceo86nQaBa+IHa7e3QjgPESQftWTs
+31uORZLEM0gOVKNz+npXq2iyatqekJdXWmQtNHxPFIOSuPxp/qK6cSU/B53qpSxbsoV07qOy
+lQWT2txFJz4qSbNv3DCr61160hC2Gvtbl3GyIzHBY+gYdvyru2nwsnh2NzLBuOfDjYFT6ghq
+z+vdNDUozBHK22HLPFKmCG8iP/StXFw3E41NZdTNBc6dperGP9iaxPpV1ENgZGE8e8HP1b+V
+qkvNWvTfT6F1TYWt3eQp4j+HH4dz4flIoP8AeL7qTWTu9Qk0lH6l2SRvGUtdTRXJw5OI52Hb
+awG0n1Aq/t7/AED4taBFpct1Ja6tpwLWN2rYubKQ+YP8cLYG5fzGCKz5qeuma+04K+4/8Ge6
+uE1nr9n1RpQ8XV9PjZ7OXOBqmn4/e20me7qvIB5BAxVnL8h1BbW13o1wSl3ax3tsM8gJJ6eq
+kFWHftWdtZ9Y161uultdUQdUaTNtj3DCzSRglSp/xpnB8xiuHQmsxRhb+wzcRWeoteeCTzDF
+NlZAP8O9e3rXK38q8M7OPwvyjS9F6pF1DBA0U5ZUeSR0PfZIx8vZh+tabqyO2121bT7gkR9S
+2jw3DZ4W5twfCkP6AE1kdEtl0jrW51fTijWhgMM9oDgKGbesyD0zkEeROauOqdRm0i+jtYoh
+KbS6eVF/njP41B9dhb8xVRdRdmGRKWROJT3N8dP+Jlo+p5EfUmpQXM8hOdmyyALD1+sHPsTW
+2ht7ieaxhu2AbxLjSL0H+KNiPDlP2UAA/as/e2Eb9TaVp10VbDhdNuRzvkXcSCfdCauNMuDP
+ZxagWbxDaxRFfXB4b78YpRVNlZZ8kv4H380t7fWrhNtwsy2pAPZNw3H/AOXNbjTp1F6iI2Io
+5ZAB/hIyP8s1i9TMS6hHbRIwvLuxu57UqOGcRbiPvtOR9qudAuHbUHkaQmO4tIJ0yfwuUww/
+RM/nVQ0znybib3qrSbbqjQ2024jWSJ03PGRkSLjlG9VPmPMcV4Nr3R8zLqq6tKl/qlw8WpyS
+EFYo5oH8S1QD04xjyAHpXu+l3+62jcj8SbvsO1Yjq+wh+d/e5ENxJEWwOWk3YVfsRkfnXZJ3
+GzhgqlR8oQ9EQaD1TqGuRxT3V9/sx1L1Omc77j57U/lljYDuVMrYwOwBrZ9A9E6Vo+s6D8M5
+BPLpbaroNnpUkzhy8mmW9u3iegkO1wGx33V6zFo8c3Tuoz3Mmy9shcLM8KASGK4kEkCBj2UE
+HA9TmsFLo99Z2uizyt4V/oVzp8izgbT8xHqECyYHluR2X3Fc/KtnUo3aPI9Q+DMHVMnxf6d1
+Ge5udM6nRHUhQmbtdY3RzjuQRJHsbHv618z9b9Ct1H/aG+IGsTyzMtl1Lp3UdtLht3yP7St7
+W5hfOPrg8VAw/wAOexr79jsLC26y1/VZGmWw0m/vY4ZMbTLZ3j3E7oQP5JyMe6ivCdb+Gd43
+9pzrLo+/jnl0Xr25vtU0bUtv7p11LS47lSGH4WjuLdSynvlWGRzVJqUb/f8A4KUnjlX6/wCP
+/o+XYuibgdcdW/CWKNG1C+0tNJ06TfjZqKXc93bgn+ZhHt+7ish8d+krRZeneo+nIS8N1psG
+kanAiFRaavBAsksePIOkgfB8w/pX0B8YOgr7X/in8XOoOiUlt72x+J+k6Vp62yNvtpYIVcSj
+HZcq/wCZ969R+JfwL0/qu/1PW+nbWSXR+s5oOqdR2RfXZapYvKjBF7AXELSqwHmiZohjc7Ro
+/UcGpH5wXWnSR389ukZxCQpwOxCgmvrD4bfDuPTtFTTytrNJp2p6Bq3U5bOILC5tpJ7S0k7b
+ld1Rm8izoPKpvws/svah1Ta3cOr2bW15ealJqRkKHDWHjhFVW7fUqtx3+qvrTpn4L2puvidr
+DJGk/WNxoqSCKECOCCxMMaBAfIKpAHlk0/Ya2GT1kXpbPziu+l76x+JfUNxPpssctto00ssD
+L+G8njI2f+cnA9qqPiH8Nbjpq/shBA3hX0NvqkQLA4tbmCKaM5+0n9a/TD4gf2dumNd1mfqQ
+acovte6g0350xttVLaJXLnH8mB9X3rzLUf7POrdSdPSv1jowgnl6CXp62kXAFrdW+pvEkiIO
+Prtkt1UnsCTWv9vezKPr1GotbPhLpXT53+HnxS01Ij+7s9NvxxyPAv1U/oJea0Pwr0PT9F+D
+3xg6z1o/VaW+j6HZRH/8IuLu4eTZ7gLAXPsvvXu+ifAjWrbqb4hdKWmlGObWdL1HSoo3HEmo
+SsrRxqex2iNn4PlV/wDHT+z4bD4eHp/pbTWW2uuoY9cmRUAZ44LFbaMHHDASSO3/AFmsY4Z2
+zbJ6vG0lfZ8UfDno5+r+rtO6cZnUXQld3VclY4onkdsewQ1A6i6eaz0TRtejOY9SkvbY4HAk
+gkUH/wCV1NfQXwu+GXUvQ56p+IWp2c1jH05pV7aq0ybC89xayRiNc98h6peuOibhOjT01Jp6
+xzad1bYXihFICQarpiYXHkBLbn8zUcXbi+zZ5VqUXaZ4FommS6pqlvYQrl5XwB61svhnoEOp
+9XxadcQ+Ja3trfCRf8KWsrn8xtyKn/CjpW5uNfstW2ERnW4tJQEcl9rvIB/yomT9633w/wCk
+LvpzWdP1iT6YrzTdelZGU/u3i0maRCD2IZZFPFJGknZ5TJ0lLpHRkl/cxNNPqEkh05/DIZrS
+IBpJx7HIHtg1n4dHuLq1guLeJm3K5OBkfSef6EV9fdS9Jw2PV3wl6UgsTcw23QV30jfwgDI1
+KbTnumB8s4uEPrxXlXw46agt+g9b1GJJJXstOie33KPrmnbwGx7ZBx9s1XFeTKOXVo8v6a0x
+pL65twhcqgUYHuKbL01daT03F1MwXZd30tvEucN4cBXxHx/LuYIPcGvSeieitURutbuGJdmg
+WSG482d3Y7ET3JRjn0U1c/FPoK5s9S0ToPSVkvJv2RYGC3iXdmW7thcuoAHnLKcn29qmmmW5
+r77MPqvw7XTOoeorxEZ7DTb+O3gZl/vZJ1WSFB6kqxbHoK9n+FHRQgboLqe4hYwx6b1DpWpf
+TkCaB0ManHm0d0D6nHFdutekpbmXQdA0W+S6i/2YtL8XUP0R3erwxmNpxnuuYRGD/KPevrH4
+Q/DnTNL6c12w8B5dP1zWf23ZCVMSW5a1XxUHmBu4+wFaUmYTyNIwnw3+DtvYdM6v07q0AEQ1
+p2sZUOR4ULK0UynyyOB7Zr6r+H/R1t07p5h+WSGXHjMFA+g+g++cn71U9DdHWk93Bpstuj2l
+nZIsme6zx7MY+4zXp1zbG23Iv1PN3b1pU1s5ZytqJDt4A4cYbgFAfc96J4GLZwFK9/YelToI
+QpxjtyzeS1zuMEgDnPNQWmQUtwg2oorjI8cZI7kVLkcouxMknuRUMxk8EbRTopEaRnc5AJ+9
+LGhI57muzQu34QB710SBhjDLn3FCRdhCGzw3NTI/FHGwE1zijkU8qrH/AEqZHvH8GM9vOtER
+JiB5McRkfeo080vI4xUyTIGXJH2qvuJEOSCM+mavwStsiOWDZJzXaAZbA71GeQbsYIPoanWQ
+Ujdisu2a+DuIiq8Cq3VXMcOc+2M1d7VKc9qodfKoqpj3xWnEldmcutRNhb3d/CAXtIJJQWYK
+qkA8sx4AHcnyAr8q/i9YdT/FaebV9J1/ShoUU8qie9uTDDPNuy8kbMMEeQI7gCv0t+KLrH8L
+uqkfbsvNLntgpON2/AYe/B7V+Uvx16o1TUNal0u6nidNOYW8UdspS0tY1GBHEnsMZPrmqWsd
+szrnn14R5HqtgNNu5LM3MNw0R2mSFtyE+x8xVew74qRLyfX3rg3FYI6Zo5NQKcaaRVGDHUhp
+M48qCaAsSiiigkKXJzzSUDvQCH0Ug9KKC0xaKKKBjhzRSA4FITzmlQ7oD3prd6UkUhxTIbsS
+l3GkooJ2gozRSE4piYhOaSiiqI7FxSikzS5FS7GqFppPNOJAplCCTClFJSjFMlBijPlS0n2p
+W2MXAo86TJpc0UAClpKM0iuhGIpKU5POKSqSJH0U0n3pRSGhaTn0paSkNi0DvRQPagaH0Zoo
+oNQoopKBNi0UnNLQCYUopKUCgoWjjvRQPelehi44paUUUi0gpAPLFLSDtQAv5V0FcxT1NJlx
+Y6iind6k2GEmkpxFMINVZD0Gc0maQ0femQL96BR/rS+/+lAB+f8ASk9sUv2pMc0AKBS486QU
+7Hl5UixQMUUdqKKGmFFFFMoKKXFJUtiFGPOlA7UClpFJBRRRQMKUDJpKVcUDR1hZkcOp+pSC
+K9u+EkTwa7oHV2ifLyWl7fppGrWUnPy80nCuP8LjJHuCK8q0jpTU9ftJbjQUF5PbgtNZp/fh
+AM71X+NfXHIr3P8Asw6Qr9SXWjXbqsN9pyaoqtHuzLbSKyDnswY/cZqoNrZlmpxo+5ujdLtr
+XUp7uyUr4NsNO2Y/CYSRgVuHceMt1bAlCoO30OMEV590jq6ePPfPlIp7ktIP5XYZJ+3/AHrY
+aLI1yk7g9mOBnt51imm9Gag1tl277YQy5xKSM+lV186u0fPMfDfc10aUy6XI+TtUBsAdiDzU
+SeTY4lk4WRljY5yN54/7VtdhBUdYpmX8R7cVkeu3b5aOQDdIswCA9ufP8q0VzKYGIIwyjsfW
+sz1bIJ9NnUEElcjH8JxWeXcWjswfnZmre+RLZmSQsC+Nx8yO5/WrOx1MNgbvbNYw3aQWsaIc
+LEoTP8x8zUjTLsx3Hibyd2BjNeepNM9F401o9JsrsZABJzzViZiy7vyrIW17MyfunAcjg9xV
+3Zzu0SiVhvxyR2roUr0c8oUTJnyD9XNRjciM/V/nT5G+kY5964tGHDZ/KtECryWVl1DdWbK1
+k6xP/MByK2nS/wARLu2vILfUtS3wu4Bd1zsB4zn715h8qUl8VXKnGD6VMhguJ02C3MykHPhE
+Mf071UJyizLNgx5VR7ve6jDezSzafcpBd27FbiFkyhYefqPL9aq5NVyyuFAdT9QUkZz/AKVh
+LHWr6SK21203Nf2qi01GJhgy7OI3I90wD7itH8zYanZm+tLjw4J+HjP4reUdx9jXWsnJHjzw
+LGycP2VNdeJfxxFJY2hlWQY8RGGCpPmPv51h7npSDpXW2vLDULiK3tN0jJywWFvwyIw+raDj
+d6VbPdmBzZ6lIstlP+7d42+uFvIj0rpaftlIjFc3ME7WW5rO+XA8SPzWRTwMeY8xWbSZpCUo
+dPR06xsbrqLRx1RohCdQ6bbxXSeH9SX0UbB1ZGHc7dwH3IrE6VcWlv1RcajZqqWurrKv0HKB
+ZsSflh+fbJr0K20PWLDTRJpdt8t8s/j2RWTMa7zl4gw48MnJA/hzis3dWmka5qHg21qdO1U+
+KRCy7Y5CgyynH4Tzx5Gsskd2a4cqScH0Ovbu/wBKubHq3S2Uy2JDSoeVkjXh4z9xW2+INnbJ
+qena/ES1g+uwXDLxuthOoV4if5VbJ+zVgIFvLHVdZ0q9A+T1CCHUbE9woZSrp996kGvR9Jut
+O1SPWdIvlMkOoBBJFjtjC7l9CM/0qY7TiRk+MlJeP+DO9J3E95Jo9jdOGkg1WVrO4J7XlvIy
+8HzWWFtuPUVc6bcx2+jWV/E6paLqUFo4fum4yB1PsSVwfb2rOHp2+03R9S6bmndNS0u8huLR
+0+lZAq4iuFxzh2xu915rV6ZqUM95BKLWI2mryIbi2ZcKRuO7HoyyA4NNN9MU6e0QbrXE0/qf
+SLC6YoYJTBDJ3DSJH4Zz7NuxWitbv9nS2zzAgW0Tlsdsg8j8g2PyrHdVaYtprNjdXEzvZW15
+vacd/DkXMLj3EiqGHoTV6bpsNYzncZf3OB3Dgbm5+2aSbTdiklJKj0m0uUh8JQwEUCSQOfLI
+wQf0aq/U1V5Io7nloW3Ee4OVqv0y4jvdKYRSkx36yCFh38VfpI/Ra76pOXjtdRLBvEQRP7OO
+x/MCtVO0c3t0zhcWiTWt/BCgQ30UbOf8SPuXP5DFZPqQi4Gn25Q4utTtLeWVBnw2MniKW9ty
+KT+VaqyvI2CyytuG9kcjyHaq17ZI7mO17jxw24/zE8N+VRJ2qRrH4u2ed61p15JpN3KFc3k0
+qXIMZI3lLhZig/5hvGKvoenoLfqbROq7Mb3t506f1GBsbW0+GSQ2bYPZxFcsgcc4UDtV1Pax
+3lqkiwgu161jtAxko5K/meRXeERzxJKCAJ4EEpA43xsAp9jlRWmJKLIyyckZy++Hmi9P/FLX
+dXtbJFi6sNvrk+R9PzkcawOw9G/cg/8AUa0NnpljbxXECRAxsRIOBwdxJP3Oal65dxzXV/dA
+hvlZHEZ9AyhsfqTTbnbb3hgJ+to3bZ/ygHH5Zrb3PKMfb8MjwaJZ2gWO3iRFVWCgDt9ROPtk
+mrW0gijM8SDCyiMHA74BP+dRrRXXbuIZSxbv2B7CpiDc684O4gY8wDVSyuREcaid5bSNwD4Y
+J24A9B2P+dRtV0SDUtONpLCCDMqSeu36SP6qKt4IvHs3GPqbOD9jU+OyWaaSNuEmMU//AJRy
+P6VcJEzRlp+k9PTU/nfk4WNjqUl7BhBhHaMrkf8AS7j86qdc6YtNW0KTQnzDFNE6pIo+qINw
+dp8jt4z5VurtQyeJ2Z8k/rVdPD4hC7eNpIpLJQuCaPN+vvhF0/1hbzWdza5s7ya2M8BH05hX
+6M+uSFHvivK+uv7N8uu6v1PrVj4JutVj0+S1t5CfCM9qiiAN7I+4/bNfTExdrW3TGGEqk588
+GmxpDOzMyggcAep7f5ValHlbE4y48Ys/N/4c/A2Ruqvhz07pZupJ7DqpdQ1VWi+t11IyRxzs
+P4UEcBbnkB1rRdNfDy21ePRdM1BRG9itlZTEk8xajp62cm1fMoo3N6DmvujSvhxoGldYzdX2
+luI7y/ntZbiRRgsbdCiZ9cIQPyrJx/DC10bTNZmsIIXmg0qS0tpn/GZJISjN9wAMGufJj8o7
+MXqGvyPlv4bw23Xnxtk6lWRMaV8XGt41jH7sWsmm3UEOB6tb28Cj1PNYz4JfD+SHR73T79Tc
+W8l4baFVTJmNhBJdsoHoG2IR6tX018JOgL3pTrqVNR0dLXS9Y1i118hBgiWx0+GLxycecm8A
+ehY1k/h505e9L6Z0vfatC/zdnY6jfpbKnMs9/cHEhPoIGjH3IFZSThr9lwan19HnunfDe2l6
+n+M2h2dtbWbzy6W0EKMTFvTTZZ5VHn9Utx2/wgVprfomLp/qmXqD5WSXXDbaTplmhAJsZIdI
+hNznP4ZMZz/IGUdzXpPSXR9po/U48Nkub2KfXRqMjpkS3saxZQnzWEEKPfNXPU3TthAX1q1t
+j87e6ndNIzNuDPczK0jnPm21B/yqBQpKSE8bg0zzLoP4OWc/xA+Geg6vAZLTp3oyygvFb8L3
+BAmYf+ZmGPevadAtprbpkareRqvhRTYTsGMs4EY/NVJx6VI07TrltZubuyUSXEurQIXxgpaR
+HB59PoWr7raKSDS0s7eABZ79YbSNRy7CPYD+ZZj7Yq5folN6iXfR9ibSzjui313a+M5H8W45
+H9KuriUNONvJUYVfPv3qNZ2o06ztbBXLGGFYsjywP+9do1KEyHP1HAPt2qb1Qkt2yQxUQiNc
+BclmPqarpfEaQg+uT7D0qbnICnHHNR58lj5jzwO9IaIkjNykSj7muQUZ5OTXZhjvxiuLsd3D
+hQO/FM0Q8KCc+QrpGUAyMHmuGxXxwzeuTXZI2VR4agA1SAko0Q9R+Wa6h4gv0bh71E2yfz4o
+U8kE5PtVxTM5NC3LNjKEsO3JqjvWnByrAD1zV2ynGQBkdveo00CupBhjBPfI4qnG0KM6ZQQv
+eC4fxXi2HBQqefzq9sCwILZBNRzbwoQEjT7qvarC3i5GBjisYxaZ0OaaJEjkIScHNUWq7pcZ
+weMZPlV5cIEjyxrO6lG7Kztkrnt2NbGdrs85+J8Vhf6Ne218WlttLs5dQuIEyN0callXP8zO
+AK/K34yWegWdzJca1fSHV5nMkGlwurfLqx3Fp2HZjn8I/Ov09+NusXUHQd/oulpFpz61Kti9
+1Kcv4AO6QqD+IkLtHoCTX5VdffDnUdDlm6j616gsbSbUZZLiGzjk8W6kUscEqPwjGOTWk01j
+0jn9M1LM22eYzyFycgKPJR5VHbIqXcz24Yi2h2qPNzkmobNurkSO6bG0lLRVGQhFJjPlTqSg
+mhuKMU+igKGdqKcRmm8UCqhRTqQUtBSQUUUlAOwNITzR96TvQIKKMUUE7Ac04CminZ4oGIRj
+yppzzSk+ppO5oIk7EoxQeKCKqyRKKKM470yboXNJRmigLsKXNJRQCHcfem0UUkqKbClFNHua
+dQyU7FJ7U2lOKShFC58qSiimKx1GKKWpLQYopRnOaMYPrSKoQDNOAx50gHbinUDSCijAoIxQ
+U0FFFFAUFFFLQMQd6d50lHn7UDFzRQD60vHnUspCj75p2KQcdqcMUjRDDij2zSmkIFMTQCnC
+kooBHVTThXIHFPBz2qWjaLHd+KQr60oNLSKqzmV9KaVrtjzppFOyXE5ngcUZGe9OK+lNI/Kn
+ZFMKAe9FApgKKdTaUZpMpC0UUUFUFKKSlWk2NCmm04jNJikHYozS0UcUFJBRSgetLgUFUNpy
+cmk2+9OQc0AkXnTV/daTqUOp2U88M9u4eOWGQpJGw7FSPOvtn4Iar011poZ6j2NB1LYzPFcG
+OJEhuEmUDcyqOJPp5I4NfFPTIt4tZsU1FG+VmlVZCO+0nBI+1fbfwg0Dp3RrPUBpdjNa3pij
+Mr5JiuAR9DL/AMvII9apWoM5c+5JHqVnNcaf4wHMbHZKgP8Ah/ED6itl0BqslxcLbO+7xwSz
+MeQQOP6VlrlIxHJeoTtMYlCEcZC8/wCX9a4dK6k1pqWSxKCSMyHyXccf0yK4FJxmjsUVLGz1
+rT7xNL1JortA1qx8B0b+Rxjd+RqHNaSW6XGlXHJsZ7e93Hu8ZlwD/Sn6tmeZ5mGPEAXb5DB/
+9KuHtk1CdrvIZZ4DbP5fu1YMn9c13x2cEnx2Z3rRDpt4XkOBI9zKMn+HxBt/o1Y3WJmksZ0X
+uQuPcYrXfE3fe22oLHIM2t/FajA5Ecgj3j+orGajcwy3NxIhBjjuCI1/+HEFV2+2c81E/J2e
+mfxTPPZuNkOfoQkj1JqTZnL5JOeAKrzK1xqt8VGI4ziP3J8/0rtbThpAFOecZ8q819nsRZt7
+DaqrzjiriznJGGGCPLPlWas5XaIAtjAwCKn2CSQ3iOZ2O9cYNaRlsxmrNMkgYA08PggEcGqx
+LlROLcviQ9l+3erBAzKCwrqgrMGqJAiSRcm7jQnsChNVcz2EOsw6f/tNHDcSL4ojjt5Mqo8y
+QeM9qmuSFweTVULOBtQN/Ig8ZVMSyY5CnyqprqhJfs9J0K+s78C1uHt7mYKFEpkIZseucGlm
+uJ9GvJPBsIVhuCFcBPPzB/zrCIBFKEuA8T8EMpwceRHrXoGlW8l5p6q+qpcZRQm8AN+dbRd6
+PO9RjWP5eGU+sarBCzXTxKVH07QBkqP8zUvTNSsr62jvdFvkDygRtbyj6JV7FW9D6GqTqPp7
+UWjkjMRkhJ274zkwyjsxHp5fnWJtLq+0O+t1dHWK9jYso52srlc/0wazlJxewhjjkh8Xs9g6
+W1zUumpri1Mgl0tmXxLaZvERBuw4K/iRgDlWXjjBFX3xC6bTRviH0rqTt4ula8zaTd3ipmNb
+t0ZYi5H4PEUxkN/OmPMV5xZalJq1uHgQtqlsdu1jt+ZiPH0t5sBxg969J+HXV9v1B03Fpl5J
+LewmyktZorgbWM0MuWjZfJ1TaynuMZrSLUlxZyZVLG+a/wBTLXN1D1N09pvUMEYiugH3xntF
+dxsYrqEjyViob2bJ86tdLvXtZ7fVYiDHdXLMcHkQOArn7q2f0p/W+nWvQX7Q1eLM+k3WuLqB
+Nuh3JZ3o4k2ju4dc57ZrhqrrBqd10wy+Fqmkwi48MJtS6tZU3ePF64O1yB25rOSpuxxlcVRo
+ZpXmnhjupA8tvI1lDJ5EuSNjeqllBHowFVd6lxp2sWtrMhjlto11YJ5SQAN4pB8sOc496bMU
+kiiiu5dljq8sEd0+cG2aVAgmU+REyxuD7kVz1PUZtd0qzv8AUong1nTzd9O36N+KO4gdopBx
+3DoyuPYioe0OOmWmhSLqllrWnamzJAjWstqfxeEXyj8fygsrfYGndLRym7FpqQ3zW7+HKV5A
+nDFCR6jC5H3qt6U1US6wlpIFWPUVFpIuAQGETKSD74z96l6FdtaXpLH94bxrdwBnM6hg35cZ
+/OpbTSZVNNondD6lNDYy6YNhuen9S3Hb2KyAgn/L+laO5k8SC+tkOV8QXEZB4AGQwrA2d7Fp
+/V2vTwKEfUUDO3k5VVBH3I+rPtWp0O5huLa7iLkNCheP/GjNh/0JFKL8Dkt2TUUW6xMoyl62
+4r6Hbkj9abeRq0mo2cZG7JaBs+fhjC5+5qLDcsLfT5pv4ZnlUeZVcqx/XH61zurgnRr9yNvy
+jRzyuf5QACB+WKvlRFNklLtmXxgdrJcw6gyk8q/hhT/8wNTYNLE1pcadACss0k4tnPluxKo/
+XcKzk12dNhuZpXyggP2YIS5/zzWttLkRabHdplvDnhuBg5JXgP8A0fNXGSInF1ZTpDDPrEVo
+xITUtWtonHfAYqD/AJV2wpaCTA8V5b1ue5UzN/8Am4/SpLxj/aG3bsxvopHXH4ZFbbx9xg/n
+XDYbicuBtkjMkQHkuck1a0iHbpskQRxxrGynjawYegXmpceFVJFXPHGarVZo2kXurKqZ924N
+TY7oCLnAMDFCM/4qOf2Liyza5FnCsijciROxHrxmrezmEtnFc5wxjbg+WQcVnzKJRJG2PqBT
+HoexqZPM66W/gkqY0jCY4BwcNQpu7DgmqLF4xMY4QeHUAn045qHNGN7gDhfqx6LnFddIna5j
+EhbIVfpx65xUi8hRJpUY/VcN4aj/AAxqWP8AU0+XkmqdFNqCEWpuE+pogzgeox/2qotLhlkt
+T/BsLP8AcgYq2nyDDCSdwjUceY7GqS5f5CCRpOSJDGo9h5/pSct2Wo6o0kDmWNRn8ce/7EnH
++VOt4I3tpVlUESSkL+pqrtbqVLe0BIEiyRgg8ZDDgfrVlDdpK7bCMI7hQPPHFaRyIzlja6Om
+paTb3NsVIH0xNGTjnB7gViZ+jDLdS6rLbIzxjdBGP4AjxmJcDyUJnHbgVuvmZFViwBC53V2t
+/DlhEvGXGR9qttT7Jjcejz7Qel7YSan+58OZbnV7gyY5Z7sl5G/MhapdX0y4bT4brwC3yMvi
+onfxrhh4aqfYMd33Fer/AC6xxSsihWljIbjvVZHpKDVLK1cArDIs7enH1ZrmcbOlZPsqYLOz
+0bUH0+2AItxbwSTecjKo3f8AzE0t9aSajeWlzAwC6ZJJIHf8IZ127vcgbse5pRZyPdC4kyFe
+4eXb/N3wP8q6WcziO6a9cHYBtQdlPajk7HWrRa2Z8NFjjJbB/Ee5rrcgxBLeE5buWqvsJ3XZ
+NLlcqXVf5YxwM+5qZ4v/ABJATtBYindonjTDIUrGT2XLfemyykJwMcmucTHJZ/xMdxolPO09
+x5elCHWyIQB9Rbv5mlSNSR9BYnk+1dUtwzbiMt79hTzFgHDH3xVpByE37fwkUG4VQCRQIsj6
+uPt3p3ym7LADB7GrSZLaFjkBGXRWBP8AFTXurdVZB3H8KVzntYIgPm5mAPYbv9BVfO94qMlj
+YgA/hKgnj3rTaJpSJM94qHYLaYg85UgVEa+tw37ycrz2Lgmq5rbWGkO8hVxg5Wiy6e1q5mV3
+MMkRzgnis3KT6RrHHBdsuBcWzEMZCc9sGp9sVx9IwPU1Dg6d1CJy0oRh5ZOMVPjgeKMqSn09
+/qoinexvjWmJcNl+ATx396qdQ3SQlGGAwILegqykYbSSRgCqmd/FkCrIVB7/AGrRGU+jxr4z
+aXoB6Zfq/VtfOjRxFtOtdQuYPHWEyAjKJ2DYB+rHAJr8q/jbDo+n9S3EVjri69JKQw1BX3Iy
++QUeX519tf8A3wL4lJGnT/QwhtJbXTUl1eaJ1YNmTMUZ44xjdjPcmvzn1u9hvrlpkiaMknjd
+kAe1P1EqSixejhdz8FQ5zkmm0rUlYI2lsKKKKBbCiikoDoWgc0lKKGNCkUw966GmMOKSCSGg
+4p2QabRmmQmdB+tIcelNDetOzQVaYzPNKDzmg4pOcUEjvyoA9aQU6gY0DB7UpPlS0096BMaf
+WjmlopkUHvQaKD2pBQym06m1aMpAKXntmko5oJFz/SlptOHagpMKKKKCmAFLSUUCWhTSUZFF
+AWFFFFAx9AxRRUGqQvbjNLnjik780o4oKAg80o70UCgaHUYoH3pTU2X4GYoxTiPUUGnYqG0u
+TS4oxTEIBg0uccUUe9A6DzzTu/nTfKgVLKQ+l49KaKd5UjRB+fFJS/akxTBhRS84xik+1A6F
++1LnHJ4pAD+lA7UDOgPHenBq5DtSg+hqaKUjrketJnNM3U4HiiirsKQjNLnNJSAb2opx5ppq
+yQpR9qSlxjnFAC5oooqWUKBTsUgBpaRVCewpaKKB0FFFLz50DoFp4GaYKeMdqGUg2g+VKoxR
+x6U5FyanYz2L4E6fpXU8k/TV7a2V1dxSLexwXS48eBR+8SJwQyyfxDHcA8Gvszp/R7DTNEt3
+0i5uJbGdTJB8w4eSJj+KNmHfB/pivh34WdOtd6ppuqWHUCabqFneDY5hMuW4KDaPJjla+4rF
+LKBru+tZonM0MctwkNu0SCQDBIVjwc1s3/6dM87N/wC8i+uLkw2cKFQUJkUn147f1qi0ZCkb
+w27F/HVo3z3LYJU/fipOt3Zt5IbVWzGkLzlf5SyjFHSVvI2qu0uMG4sZ4v8Ak2sGH5edec1c
+6R6OOXHG2z1Gy1Q3mifOOd81tbIkgPfxVUbs+9a/p+Lw9ME1wTsRAc58yMgf1rC9JQJe9R6l
+pjgrHdXT3Lr5YGSD9iFre3LSQ6TbQPtiaeYyyqO0aBeB+ldkLqzzszSfH7Mv1FCkr6nKzBI7
+54LgyO2FjkyFB/IKCftWA0u2S5h1p5AYrUQfs2B2/G6rL+9cemfXzzW46hmgvtFu7JmKfN28
+iJzzhgVB/Q1jtYvGdbaxtU2KdP8Amr2XyEgAjiiH/leQ/lUzfk6sCdUjy/qW+a21B7WBBHHu
+dyo/iGcLz+WaiadehWzwcHn71A1e/S8vLmZGJDuViz3CDgf5ZpmlyAqUbjYea8+XZ70I/FHo
+mi34mkEGPqxn8q19jahlVmAJHI9q8/6bIjmDD+Luc16VpzI0QKcjHJrXCrezl9Rcejhf6Rcy
+32n3+n3Kxtb3K/MhhnxLcjDqPQ9jn2q4ZFUZzkGmqvpTyDtII967YRSujkcm+yGQRv5znnny
+rgG2nBxXaaQbijHAI4NV93LswTwO26mzaKs1vT9lpXUI/YGpXAtWl5srwNzbzeQb1jbsw/Mc
+1MsrJ9Ju7rRdWeSzvNPl8O4glH1Rt3GCPxIe6sOCDWAVpGyImZWHP0nn716toNza/EHS20Xq
+N/D6q022WTSb/blru1TO+3k832jkDuOcelXCpa8nF6uLx7u0/wDwcL3EsPiwPv8ApClg2c58
+zWI6ivpLRdPtryyxLHc3EAYcBwAkgOfcN29jWlurO4027sr0qY7OeSO3mC/UI88Zz5qRyD7V
+Wa1YXWq22paLe27S32jXZuIQh+rZjBK+v0YYeoJoybOSDUWUmi6ok+oRrbGO1MkgjRnlOFlP
+4c57KTgH0zW76N16C1mtZLnSPkJ9avI7i8jk+l7S83GAu3/lCt6hlavEPmHW/U28pZFbbJt/
+jBOTx+tekXLJ1FoN91bYzNGfk/ldSIOWF3GAsU2PLfEI2J/mRqxhJ7Ns8FpeGe09RG1vtG1W
+zjO240HTpTt3YM9jE/iOvu0bow+xrOftSK+Gk6xdsslzpn0LMqAi90u6jLKAf4SpIH24qHJ1
+JcanonSXxDhYG7vgIbuIjCbZdkc4Ze2x5EUnP/i0ugwJZdUS9JBFRLRrhtOifkG1x4iQ++38
+IHoDVzlZxQjxQluxvOndbmuo1mi024ksbu3X8NzbGJZEkT0DRsrgjs0ZIqNrjhjLd2k+6TWn
+W5nnUn97cxxRASgeRZIlz6nNSNGkg6d63sdKaIvDqlu406KY7o5Tb5nijPqTA11D9goqv1rS
+jo0N9oumXDTWWnaksukzE5JtXDERuf5lGFI9MHzrJp0ap7OYvxbpL1GiGP8AZ03zyxDnP0sC
+Ptk1tNbtDFM2pWTNGdS1RNZsSMDKCCFpB9iGcY9awmhzQ6t05fXJwxWeeKSM+Ue1WA/+atN1
+tqk+m9NaLdxncdNtL5VzznbDHwfyU1knpo1ktpjdTay07rG2068jJj1D5a4Rz5qZflz9sbxx
+7Vd6JObae6hlA3QzSWjn2Mxh/L6gDVd8RdOm1CHQNcsUUnQbye2vhuG4W7yxyxzY81VwAfTI
+Nd5dQguuqLrTkYBdWhi1KFV/8QnxGH/5RM01pk/kjrqGqjT9G0vWpxn9nXE3jw+bJOq4XH/N
+mra9iTStJ1u2ldJYWAuFLHh7fIBJP3BB+1Z25t5NaHUTSoDbyX9oijOABFKzN9h7+ldLaaTq
+LpO602LD3k2j3ny2T+KzdPF/MqzD8jRY0kNtYpNS6LjFzKJJhbtvOOdrZUf/AClf0rU9LX5a
+TT9OlRiHhKIcZzIoEbj8wA35Vk+kLmMafm42BNQhsoZAw/APAVSR+bVJeXUNHfQhET87p3VZ
+jmUEZeDwST+q7j9xUxk1TKlFO4ml1m4fTrS51gv9ZmgK8ZKuEyR78ipc7W9rrF5a7sCed7mA
+54ZGRWX9QxxUDqV7abTp3hbctjd/tSDPaVXtmVPy3MPzFVd5fpeWPT93asZZP2Zb+Ko77kJG
+P0rbns5+FotpL3/eHi2jDKpx5hlPeuTGR9PW7jLM17qEjZz+FYyq4/rVTDc7+qbXbIpF4b/T
+5o92ds1sRIv2JjkU/are2jVLG1UzEwWEN1du3mS7o2cf9I/WnbY640XqjbdXYRsopO33YtUt
+2aSze2DbQkTKD57m7VzVUm1WOSJR4HhwHHsFJJ/U12t4t625I53ybvcAYq1+jK6O3TZFuYI5
+eFjm249QB/qau9QjifWUMbcizkmUeX1EAn+n9az6qzQwSxkgi4ZiQcfQqE//AHWKmS3Ey3+o
+PJysFpDCrZ4ClVLfnuJo6jQdysrox8zcBsEPbRRRD/EWTcf6VH1OxF/byW/4AzRyE/5irOwK
+TTrFkZlIwfXCBc/oBXNGWW3Z1yG8fweR5h+f6ULqy9oo7mULq2pIuV+Tultm8tr+GGX+lTtL
+KLbmbyQcf96ZHax3ut9UXGMibUklx6tFFt4+4NdrdBHbuhUKgmRMf9QpLUrG9qi0j2TgJu5K
+7mH3pYP3m7YuFL+GvoMVxsUaPcSdxEc4bP8ACVbC1NsgI1hi4yFkkYehyKu7MuiTHD4jCMgk
+Fuc0s9iDcy3EYPOVP2AwKlIQkikcAKWP+ddwFaPaOxAxVxrozd9lFLYxr4IZMPGjBRjtnkn/
+ACqgawBsrjDAZkUE+vrWxnUSeNsP1OCi/wCEVUXVuIbKNIUyMlR7+p/WpkaQk06KfbulyFPh
+7ck+voKdJM5Hf8Qy3v6V2eLYFXnnG7HkAKjyMdqgpjIyorn2dHY6N8Sc/hA3H7U+FHZdzfxn
+d2rkmCVQDgZLn+Y1NWQKpkbAGPpFaQZnM5sSuAFwM45pjuXcR7uO/FBZmIXlmNHH4h+tbRds
+hqh/4foiwMdyaUSrEeSzP3z6UibS2xSCe5AHb700yRw8u4DnjJPatkZj5JZACywl2bgcdqgz
+TShW+ZUxgfhzLyT9hUe8123CSGKcskIy0hyFWqF+qJ5edMiWVyf7zwycfmaHNI1x4pS6NBb2
+sUg8YyOxAyV5Az+dSGvZUxHBKiDGPpXJ/WsrBqmtySsJZ8sw5MjY/QVf6W92VRZHWT7DtULI
+paR0f27juROWS4ZR4skjf5UkkuwADdzya7vJ4a/VwxHlUCaY9/E3Z/WtOkQEk+8FXHFUmqyO
+kElwJUhUKR4jnARfM/pVpJJhTlu/esj8QdN1jWOnYLGw1JtPgmuDPqF1sVlisYlLzBge+4Lt
+H39KcFylRy55cYtn5ff2wPiV0z1N1VdQaZqralqkshN9PuAhhVMpFbxDuyoo5Y9ySa+Y5SSM
+5zXr/wAYfi+Oq+rNTu9N6f0OPTrmd5ILd9OiJhXJxhwAScYzXlV5fQ3RLHTbWFj5wgqP0zWW
+VqU27On08XDElRWn0pKcwzTcetQmDCijnFFMQU05pTmgigliL3p1NXvTqBoMmkY0HPtTaAbC
+jBFKBSnNBNDaKKKCQHenUgpc+tBQmccUA+tIaKAsdkGkPekHFB5NAXoKKSloITsKDRSGgGNp
+tPNJVoyaEAoPelooChuKcO1FFAJbFIpKX70lBTCkPelooJexBxS58qBTTxQLodRSDNLQMfye
+BS486VRRUHQAGKWiigYUCiigqxwzS0g7c0uPapZaQYz5UmKdSNQUxKKPuaB2oI8hQQeKKX2o
+sYgFL28u9ABHlS9/OgdAKXJ9aTjvS0FLQuPM0YoFBxjigoBj7UUnfypRQAf1oxz2pRS0DoTF
+KBxxSDk08DApNjihNtKKKKVl0FGaQmkp0Kxc80YoHP3paLEGKKKXvRY0gAzTsAUUuDUt2aJC
+UU7FNNA6CiigUAhQKXbQBxxTiOaChmOadS44oxRYHSKF5TtXHPHJxV7ZdIapeKjQ3OkDcfwy
+ajEh/Qms+ox5VItiQw24yaVqwd+D3b4c9D9eWdxFHN0utzaSOpD2EscyvjsQynO8Ht+dfU9j
+JfCzSK+sbiJXRQrTLhmAxwR3718r/BDVNWF7DBbx3heOaOWJo5CImZD2YD1HHFfWoj3WcVw3
+h7ZSMBHLFcdwc9iD5VtKvb0eblv3dkfUnL6pcL324jx6gKCf9av+kNtzomma8g/FPkY5BiV9
+rg/rWX0+Y3XUdpE7ECS6MRHqpUjNeidEaBLD0b0Va26nwzpmoy3RI/4pulEan3+l64cceUmz
+syz4QSNn0ZaxwX/7UfJN0ht+RyiQO6A/9RIqV8QdXn0P5iZrdZLa2tok57TXc0hUR59FQbj9
+6ndNJbjcNo2pIIkBHAy+SfzINYj4o3N31Jo8PyYJNv1Y6NGrDLR7MQsR57tjkexFdXUKOGK5
+5U30Un7bmuPFjaQSyEfU3+QFYvrrqP5ORdLs5SrKjB8HlVI5/M4x9qW81Wbpi0aGWL/fy373
+f/wO5OR7eleTvqM95I95PIztcyPIzseXyeK4sk6VHuYMSk78EsEgrk9vKpdq/gNlQMN+Kq35
+gEA55IzzUiOfco9R3+9YHpxN307IrR5Ygg8Ct705cusXhkHBPfPlXl/Sk8qRFZQCS5K8+XlX
+omizjChv4hitsarZzZ1ZsIpfEHHYCpKjIx5FTiq6yIjVRnAB71PeTw5AhYgnCgfeu2PR5snT
+oqLnAYjPHeq+dw37vGfQHzqxuctK8YHYHJ96zmpXJSQrnaR2PvRLSOjG+TNH0lHFcXbSyY2L
+mF8+QYcH9RVpr19e6Rqdte2bNDc2Ekc0TjjZtIOftjNZfpS9kurq4e0CyTGExXNsDy+fwSqP
+ZsBvQHNbjX7S91PoXTesLWCK6lhMtjdrz+9t0OAT/jAJH5CiLtaOb1Hxy/LybO4ez1n9pRMy
+Lp7LJ40aD6IJxt8aIH+UiSOVD7kVl5be6tYLW+jndNW0hBZXknfxBEd1vI3nnYRz5jIPajp2
+8sZNK05bvUGi0/qyxm0S8nkbm0ulQQRXJP8AMrrCCfQ11hvNTvmeXVLCW31C405DdQTAjdcx
+NskQ+f1FZMVpN2rPNa4to8/6q0CJ9VTqnRLMx2Oo3MjPaEbZLS9UDx4Mea/Urr6q/tVt8Ori
+2sOsIOlb0iTSes1bQ5tzbQlx4beET6HPGf8AFWpfQmvp7/pKO7aS51m0XUrCUn6jc2n7uNlH
+8xiJjf1wK8vn+au0+Wt9ttqFzifTps4MGowMXQZ8g+Ch/wCYelZJJOzVTc48Wb74f3d5Bay/
+DrUE8W8tpb3T4S2F/wB7hQmOP7OqgZ/mArQdYyXDDR+qrM/Lahb4lDrkOJYiu9T/AOYZHoxr
+Oa7fCXrG66t0tF/9qfsvrazHfY/hql1GR6CRJM+5NbXq8R6lp17rELGb/ZjURd3tupyZNOmH
+heKB5hFZSx9IwaXhozepKRnPiRqt01rF17pVu8bdO3MN/GgOQZ7Z/wDerdT/AAh4pRIPUKwq
+6mmjutT1izsriGW0iNsUkb/httSaFie2Htpf1jqutrqwttTvun9ZRJ9I1i6lsNTGf7vxYQIb
+hP8ArGCfRqqYNL1O3l1Lpkzs97P0jb20hVsLPdWjN4UnPmYht/OspStWi0qdCdIzpZ6N1PYE
+Fby31KWPaR/EkkcTcenINam5urTWtEtbTUW2x3VzexOB/BFMGi3c+jtj9Kq+qVSPXYerrWDZ
+Hr+mrPeW4xmO7hlhZz95IQp+6GuvU2lxjpuLVdBmabTNSjurVJQObZ55IriBuOy74GQH1bHn
+WdfRf1Zo+krhkeSLVkW5USy2s6NzkmFUcH7hM/c1luoPmdA6s6biS7O+0SJUmUcyQtLlD/5T
+g1L0TWDaa786rFrTUp5j24WbeZEJ+6MR+VP6407xp9E1S1fL6ZPEzh+8kbEug9ipXFJvlD+B
+xXGdvyavTTbyXHWfT6sN8FnBdRyZ/dyJf2z+CwPoG4JrBaB1RP0po+kaxcxPHedIWci6lEcH
+xFgghSSP3DHco/Kt90lcRatBc6TcrHFcahoZscgbQrwFpF2+eMORj1NeZdZfOQaRruqSxZj1
+fTI7K+yOfnGniXcPTekY/wCoNRLS5IcPk3Fm11u0k6Zu9ejW1KaXY61ZS6dK/aWwu7UTrj/k
+Mfhkeo96s9IefU+ptVs7tl8Wa7ttfsjjlIbm3Z4x9gxIql0241f4gfD+fptinz9x09Y6zpZk
+wDK9nNJbXUAP8zKIyPc1J6R1VDdQdRSyGSaTpPTrZN57vBcSKOPXwzj/AKaulaa6ZFumn2iZ
+qN3Lq/wsvOrkX5ZLHQfAuIUOdkiSxyAH3wXA9hUW2vLbQbSwmMgUS+JYRKBnaJLsEyD2VJUF
+RYYI5+h/iBojTtGpsXjiVQcmaAM7cD/4Tn/yVnNb6vt/21b6GsYU6dY21wgyMFmWGU5/5lU4
+9cVlKdUzXHDlaNboRih66k0JhifUda1S9s2J/CVhXeD/ANMRFavRo0udA1+WOQMmlvPE59YV
+Cup/Nc/pXnXxIlk6X6/tuotLuy03SPUMup3sQU7rjSL0NMGAHcxATAj+U+1bnQrWw07qu/6S
+tLpjYXHTzCaUsG3pco6QSehxlOfet4y3RjNWlIutM1OC7HTd3DJ+41SJNrDs4D7V/UVoNMBm
+EMjnGzxo29yXK5/pXlHRp1S1+Gfw2tr2Ive2moLY3RDYKR2heWc59hHt+5xXpfSmoNrGn2jP
+D4Ut5If3ecmORy8gT7gVpB7oxyx8os4Lfw9LLElQApYffdn/ACFc7rMl7q9kXx+0GjaM/wAo
+EaA/6mnPMk0dxZKSyz3cOCPMbhnHtjNRNauQnU8XG1ZkkRWPbhAc1pLozh2drLxDrNnbxLtj
+W0ZlPqS2Cf6CnFFRLa3Vdpd/GP8A1v3/AKGusTuNb1GWMfubVo7aEj+XwlLY/wCpqkXqxxXF
+tcNypuo4M+iqpH+dZ1SNE7aIOjIkLMThnubmeQHHn4ixj+gqvvLhU+UhC8S3isxHn9X/AGFW
+MkJWwiMchjmhffkDsAzM36nbVDYlmFlJglowG+r1DAUXQ0rL2KZWl1KVeUEs8i/8hmGB+lSw
+6xidwMMFVR+fJqujuIoLm7GMo0jQqB6H6v8AOnNdH5Vh5zOOfTK/+lNMTiXiOJACAcPx/kKm
+IdkDSZ5UFQP8qhWWDFHIOcqgUfdcmp4XMcWPw7s4q0ZS7o5SxeEiKPxOdoqPKiGOWEYIiIX8
+yKmzOoJkblgfpHue1QiD4RKkElsEjnLZpsSKye3OXAXdjj7nFV89u5ZT2O3FXk0ZIOBjJ2j1
+JrjLbosaoAASMBvP71m4msZUUKgIDznb3PrUhCJF2leMDnyz6V3ngjQZUDamMZ8zTSyoyKPx
+9h/hzUR0y3tAYQAxMmxVGXb/AEFcJSBsRVI3EALjy9TUtxGG3XD5VDuCf5fnUVA80puZkwpB
+CoD61smZs4t4s5kjil8JFXLODyfYVGltTd+HbxkpboCxIGSfTJNSrhJ5k8CzSNQThpD2UDuf
+eoF/bSSRJpltcusTAZbOCV82Y+VbXolK2QLq2u77xV067itrK1wJiEBH5t/E58gKgPbXiu8V
+jOsNqoAWIKu9iB3JPmalXrbIodP02COHTbTPhAD6pG85D7+ntXK1VJZF3qJCpzktk1LpnZjT
+WztpemSOFkNgUVh+J2BLD1rUW1tb2sYAAT7Go1jAVXxdzBF7eQp15doowE4rSEEicmRydHLU
+r+CEYXOcdzVKJ3mfEQLEcknsKbfXO0lt2fOq8TzFiZCRnnbmpyS8FRhouI2IBywOffIrzj+0
+jrUGlfCbWBdXVzbwS2UplNqdr7MYOT2VScA/et/bbpFwo4xXz7/bY6suLH4fL0hHpoltNQVb
+i/naQrvWNspAAO6lvqb/AJQK19Pt39Hnes64/Z+UfUkGjQXA/ZF1dSL2ZLlQGU/ccGqFsZ71
+a6/dC81S4mCgAucAdu9VZzntXM+2eml8UNI9aaQBTj3ooIaOePejFOIpp700zJqhKKDRTENA
+xzTqKSgXSA+lIBQftSjjzoEANBOKTjNL3HegBtFKaSgnyO7U2iigGwooooBbCiiigQUUYooA
+KQ9qWkOKBNiHHlSUuPOkq0QwpR96SloBCUv2pSM8ik7UhiUUHtTaZDY77UU2nUAnYU0c048i
+gDFAMKKUUY9/6UrKo6dhRTaUGpNh1FJn3paCgoHvRRQCHDtS5xTQacKk0QtGPyoAFLigtDPS
+gZxzSsOc4pB2wTQRVMUUuB3FJ25oBxQNDqKTPFLQUFAoooAWj+tJmigdhx60ozSe9Ln2oGHb
+ypT370nuP6UuPOgaFA8qcKQDFPxUstKhD6YpCeOKU0lCGxvAoFOoosVCfaloophQuDSgYNA7
+YpQPKky0hQMmnAUKvFOwKmzRDaQgd6dj3pDTAZTh/SjFKBxxQIB+lOpB6UtJlJBRS4IoqR0K
+BVhplp48gJzt9h/lUBBnitD0vpl3falb28Eixs7gBi30j3PtVJWxS0j2r4H6nc2d9BaafZ77
+eV/DuIXJ5bH0vuHbzFfT1iA1kGS1e3WUktCz7wrdsg+leU/CDp7R4r24s4tPjGyNW+aYMss+
+eSTj6doPAHfHevZbhLe1twiqQqgdj3recWonkzmpZCgghmTU4pYYycXccLuO0RfhST5AsAM+
+pFe9dJCEaXp1qmzCObYR55DAs7j9Xz+VeY9IWaxatdzXIcWWpKiNLs3CJ1YFWPtkCvSbCC/s
+b9UkgDRFxewyIc/vx+ME+jDtXLjjxRrmnzpGguHi0uJkQhF+YS3iyeXdjgD7/irNQRRwRaii
+RGT5vVkeLzZnYLEoX7DcePQ1capH+1ZQVJ8KG/try0UDBVgrLIGPn+Lj3qpuLy30u+6LlikL
+LDeteiNmwZ47VCrD3P7wMR9z5VoRFHzj1sdR1OJ7qbeG1G+mhuHQ5+hZXXOfsmK8+1DUBLq0
+8cOFjjPhoo7Iq8BR9hXs50qNtXXR52yZ31GfDDIS3SUuCfT6iT9q8Ll0y5tOoLnRpRuuUndT
+jkHjcT9tvNcWZbs+g9JJNUWaz4XJPFS4JyRx61SGdSgZfwkAj0wan6TKFuF8TJQfiFYnelo3
++gA+EGX+Ec1v9M5hRgMYAz7Vh9Kik0+WJXUmOZfEicH6ZE9j7diPKt5pSIm2ZBmKT6GGPw57
+V1Y1aOTNNGlgk8SyMgH1x4DD1HrUrX7rY0V6nAdFwPLIH/eqzTibecqRlCNrL/MpqXqU0EGn
+IbtPFit3KOnntxuBH5dq6fB5WR1IZDPpsU1wb64ZAJVmkVeSVkG7j0Hesp1TNarfSvY5+Vdi
+YcnP057E1N6zgutOuZrqMCWxv+nbS7srlO0wS4AfjybY3I8qrOnbD/ajSNT0yFGk1KzsJdQ0
+lFPM9xFIVktvfehBHnuArOTb+J04JRhH3LOXT2oNouo2+sREC4EqW9uCeFaRwm5vUc9vOvZO
+lLm3in1y9slEvTuuq2pfJrlzp2pwv4N7akeSMh8RD2+gjvXzmmr25jjvJZ2FnNdJA8nnEFXO
+7/pbBI9jXqnSmtx/D/4j3PTt7P4ltqV7491GD9KW94iEYP8ANkl8+hFPA6eyPX4+auPZea5p
+trpHROsdNybWgstat9TikGcJb3G6F8kf/F8Ln7Vtr7UbrU9J03qeZHF5CirKoHLF1CuT9pAW
++xqt1bp9b5L/AEmApdQa1o95psMp53f8WByvYsJoVBHcE+9VXw76jg1Xo9JbiRgVuWE4f8KN
+OUH5Ddn7Vq/pnkt8lyLdL57zW9U0mdo7WS3tdO17SJQPqj+YQrcID3wJY8kehqo+JFhpc1u/
+WAPyBuQh1Ixp9FleZwl2P/hl1w/kNwPnU7WYmXVbdfDYzPps6xeTB4BuC5/N+KVri11CC40+
+e1W8stU0ee1ltmbiZJkDqpPvg/mBU3RKvTRT6teSp0hpXWssUMFzokmqaffWpO5dsiiYRHH8
+OQ5HfhwRW/6XuksZo9VsjvS9sDbxhucx7RvgcHvx2z3BzXl3TkkesfC/q3oyW/N1qeiQQ6gs
+jLue4tHQosmPNkiYA/arr4Sa3NN0kbWR2b9lxTaW0uBlxEhWKb3YEAk+hxWUpI24tpl4dDs4
+es06biD/ACdxbwy6dO3JUuGUxP67cD9KZo+pLPrwkunV57SxaaykQ7ku7Y4Em0+qMGBXuMmu
++pjVDqWm61DNGPntPjnQIOIpRHvDL7Fg/wCRqLHc6fptzqetXYk/YVxJ4sFvGCJIbosPEKsO
+UiPJbHcEgVD0Cbey7uOnpuqNDv4NNZWaORrQXDMqpbTpiRSzH+Bo2wQOeMVE+GCaYujRfDfV
+9em1WLU5JLdGt7Vre2iIbxUWN3JkfDL3IH2qZb63PZ9YaPPd3cbaRql8unm0UhLdYWt2dJUU
+cA7tvPdvOstqkc3Q+qQQ3agw3MrX9i7k7XZScxlhyj91DeRx5VLfHaNEm/ixulvbWV1rPTuo
+6JqEd1o00N+v+/7mJEYgZR9PYrtOexwe1aLV7my1e2Sylur2NRHPp9ycJKTg+JBMvbkZIGPT
+Brt1la282sW/Vmn2snjtBHA4kBBkgf6/DkHqrcq3sfWqK5Et5o8k2nrvjYtuVfxwTKeYz6hh
+yp/Ko3EtVLZoNKnlXT9C6t0O7XWhpOurZarCV+Wme3eMwyOqtwXG9W2g84yK6/EjQbbU9N6l
+0GZ3trmC2hvVuFj4eOGYGOfHmVYlXX0IasH0dem+0/WdEuC5s+oNN+cs2Q/WtzbsN6D0cKNw
++wr07o3qdOodOsbHqbZc3zacTKxBXxI9xiMinuAxUbh71pCpoyyXjdrwZPpe11GPpnpvU7O4
+CX+lpqEQaJt20CRHMigdwJI1YjzDVsrmKzvZrTqTT7URW2vaf83FCuAkV7DK7TxL7MWZgPRq
+ofhzazdNXz6JBcNfWNrfXcBluIlW5sJpArIkoHDRuV4kX6SCAcEVqrbTrV9Fl6ZtrpdOe41J
+rjTZpsbLC6Kh0jJPAQyhozz2kq4Q+NGWTJcrMXY63b6R1RazXbmSyfXbK71LP4VtyLmzuc+x
+EsZP2rzGTS7yy6/17p3Urgtd20M+lxyliSz28Uiw8+pTwz+dbLULTxNYuLXUbCW0tdbl1We4
+t5T+8tpI50jkhI9A+/B7ZAIrGdQ3M+qQaH8QJLpP2hORZ6oi8H5i2bYJR/zQCMk+ZBrnyRtb
+8HXilTTXk3nxA1edb6y121ZfndV6X0G8jLDImKxyK6keYJMinPka39o0DdbdP9S6VEqWGqaI
+NAeL+WS2DFFUexjB/L3ryTrG+SfpL4da2kZwlrqehue+HtZ22qffaxI+9bH4Y3cn7MspZpf3
+2k6qursG5IDxvHIPtyDThNqdEZIXjTXg9Bu7RrfT7C3jgxbvdqd69g15KDJ/QN+pq30vwrDr
+iDSrWJoW/bVxeyDB2hFRok/UAY+9IjQz6fc2jyHbprW15Cc99kki4PsQf6VKNo1zf9L9UxzN
+uvUsI5c/+EpmkZj/AMwZBn/CK7K1aOFT3TONvdG2udJSXIa50Z7lf8TeEcY985/Su1xHFdnQ
+boS5MEbx+oYMvhk59ihrvNpcCazpZdS0UEDxA+YCWTBR9tzZx7VA0RfB0PRY3BLx2EBcY/BI
+7FiP1djRK1oqDvZJ1HWJIuoNd09VCR2un6ckB82uZ3VpD+ShQKtOpkZbuxggbcsfUMaTf8gD
+s+fYZFQH09LjqW0vmwyyzrM6k9/DUIufb0rt1HKI9Zt4o+WuvmLiQ88FsKW/TA/OpldOyl+S
+olCaEXVykzDZa2fzUg9iN2PzA/rVC06BYJTgLs8c49TIMD+v9K7vP48moMF2m/FtbAZ/hSML
+j86ppDINRktQ+5p3jt40PZVjBJP5k/5VDlqjSMbZd2s8P7Rt0JGZ38TnnLEkAD8hTbth4vgo
+Mb2dVXPkBgmoGgCKTqcJKilLS4kvbdc8CKM+HGPfncTTdOmebVrG3aRTm1lh3E8+ISTn70rd
+FNLwaa3vx4CMPPxTGB/KuFH+Rq7aUBLaNeGlAI57cZNZ2xjaS0tECYkeN0UeqmQ4q0u3Ly2r
+IcjBHB4PG3/IVtF6Oeatjpb3ehdTjYjO3sTwtMtHMNnbsecoAB/ibzP9TVbctJHFcRjLFxvw
+P5i42r+lXcVt4PgQMQxzntwMCq7DSFWESSZXJSMd/VvOuE6ZbefsB6VbCELCYwAPX/6feok0
+eNxxjAyM02qRKkUtymSmQQqnP6dzXG2tZWIuJsAHJy3v2xVlMqYYY3YG32zRNBuMYfnapPHY
+cVlWzXlorJo1VMRx8n6vc+lcWUgCJnCKimSdyeFUeX2qbdTC1AEYLOwznzqoupn+S8NIjJ47
+l2XzYZwq/maroS2d5LlIoo1tojtdS+9+CQfas7q2qJHcfKRo8k8igFE7t7D2q5ube43JG0bT
+SsP3smcLHx2H+VV9noksl3OkQUXNwf38/lFH5KK1Tb0i4KEdyKzxpIZFgtbOOWYLuuZZCSse
+eyL71e2OmfLQC8vUSEycpEPxMPUjyFXIstH0OBEiiWWYDIMnP1fzt6n09KqruWaWVpQRNI3v
+gCtYwrbH7qnqJ0nvUA2gcjy8qodW1UxRNIk0aN57j2HriuWpNeD8Y8POf4sVktR+cluUjtnS
+WXP4F+skflQ5teDfDiTfZO/acjHe9w0pbz24FT7BmuD9JJAOGNVNrpF1IctEznP4V+lF+7H/
+AErR2axWojg8e2QL9TBZM/V9hXO229muVxX4lzYWqlo7dUBLEf518Xf2y5Na62e6FtMdM02x
+vH02ybVLV47O7dULSzideUVcYBZSvBORX2jaMDMHEwLH6Vwh/wBa+fP7ZcXSNn0mL7q7qzqG
+0TWpxpdlYaTbW80kqbcyRxxynG5uSZD+Ee1dnp+meJ6l1JN/Z+S/WPR3UfSV4E17S3t45yWg
+uEZZbacesUyEpIPsfyrOsD6V9Myal8DOgLFYr/Q/irdade7x8hca3pi2lwBwW8EwOMdvqGPY
+1j2uP7LOryzy6d0Z13p05bMdrN1RaiFhj8Kv8qSpJ/m49653GntnpRyOS6PE+M5pD717Dft/
+Zjhxb3HRXxV0+bjLf7R6fMp9cA2q5/Wocui/2ar6OJLLrb4haRKxPiNe6LZ3caj2MUysf0pV
++w5N/wCLPKG700j3r1Bvhd8Nr6OZtC/tAdOvIp/cw6rpF/ZPJz5sI5EX82xXRv7NnxIvYxP0
+pP011XGQSP2Fr9rcSEAZJELOsv8A8uaaRlKVdnlWKKuup+i+r+irpbHrHpbV9DuG/DFqVlJb
+s3nxvAyPtVI2fSmK76DIpCc0AGkpEtjgP0oPakBozxQMSlB8s0lFBK7FJzSUUUCewooooAKK
+KKACiiigApDilpOaBMKP60tJ50xBxSGjHlSVQmFKKSigSF8s5o5oBxSUDCjA9KKKBNBilopK
+ApIKKKKBC0bjSUZHrQOx+aKMH0pdtQapCU4dqTbTqCkgopQPUUYouikgFOAHnSY9qdgVJaQo
+pSPOkooNEIaTI7CnGm9zQSxKPyo96UDmgnsUCloxRj8qC0hM+1LRRQAUUuKMUDoMUYFGPSjz
+oGANO58qTy75opDQ8U7OeK504HNDRaYUUtJSQwpcUlKO9DGhcCkxzinUh+1IdC0opKB3oKR1
+FB+1NB5zQTxSooMn3pKCaAfWmAuOM0oHnQO3ApfvQMKUAGgU4VLZQAeVG32pVrvEsZPJOaBH
+FQc1b6Rcw27q0sO7nJBzyPSp2ix9J3NxHb9QtfWsLHBurRQ7J7lD3H9a9V6S+E1tczrcPfS3
+Glsu6HUIbVLqOQ9woQYO49trY861x43Lo58ueME+Ru/gn8QF1e+t9Okiiha327Y/APCEgZjc
+eXqDX0RdwiRGUDPt5Gonw2+DPS/T9q95pvS8cQu0RpT4DxF32jlY8kp9ge9aHW7GDTLpYIxI
+DjJV/L2rpz43COzx1ljOdxDoeUw3U1rKUaNoiRC/OeQDgfat3dpf6OIY4W8RHkRoDngoRhoz
+7juKyXSlpbXF34yLH8xH9Sc4OPMVs557GOFIr9plVJBIHU/gOfLPlXG9I2UrkPe9Ro7y33GO
+TayA9sHyaqHVpYNR6n0a6uI8jQp7x7VvIzSxBGVvYq/HuKutQsDe6dcXWlPHdrOC8boRnPfa
+fzrF9dSz2cGlXMdu/wArPqNktwVGSjpJlg3oCAMfbFQ5NHRBJujI9RwS2eswhodtzJZ3NleS
+sfqVnkwuD5Arkn7CvPOq7K3h6z1TqOFdiQaXdJGOM+JJGIo2Pvt3H869R60lXUNSV1XE8qKZ
+sdt5Y/6V51LbwapHrizHct1bwzwvn8IDna322q3Fc8ny0epgbjs8yNv4l8mnRnGZFjPsiL9R
+/IA13srnxHMqjAZs/lnj+lckZzp19rzKF+ZkWzhJ7l3y0hH2UY/OmWu1SGQ5U9veudrZ7MGm
+j1XorU4p4DoWo/TbzP4lrcHk21xj/wC5YcEfnW8tZZbdELR7GRfDlTyYjv8A96846QVLcQXU
+0JliJ/eqvkteuae+latLPaox3qA8DseXTGAfuOxFd+NfE8v1E1CTfglWzQPAt8sw8IdyT+GT
+sEP3rvFbPqUbh/8A8KhIBB5WRT9OfuOKorOd9D1BoLiISwTjwbmE85B7OAfQ45rUaagQC1ky
+jscqCMYPpVPZwZtdDOmo9O6l0eT4a65cm1a4EiaVeN3sLo52rnvsbsV86zms6T1N0fpd1qdz
+pkmnanZfLagbi0IZS5G2Qg+u5MkehBqz1+1fRdSXVY1/cXJiud2PwSRuA/8AmDXotnPcdSWi
+eEyfPOZLGRHUMvip2yDwytkf+aiLt7MObhtdM8a+J3SsceutqmjRQLoPxDFvewqifRp+tqNk
+6DH4UkYhyO2JT6VW9RWGo9RaZo+t3Je31eysj09qaou2RL+zJUOfPPhsu0+i16Fpul6T1b8O
+G0aWNtPt9STwkiAb/wBm38blUK5+oKGUA+xIqNcWeqapYx/tmOKwvtQKWt5cLho01CJcRy57
+EPtVT96F3ZrHO0kn4NT0B1M+q2Fq0ZDSxyRXQ3+UwUCZfsxXJ9zmsZpFheaRpnWPSjSGCPWM
+x2zuPoImkcxMp8mVgo9s1a9A30NvBdahZwJZW76hJBJCxz+y9UUKTbSE/wDBlGSkh45xUnqq
+1jW6mgjvVt4pxDcQPMpISVCGxgdhng/bNEpUc6Scmiym19bp+neo3TINpFFqEZPIchfrX0ZZ
+EZW9Qar76Zek+qNOuYxL4AtoJkJwVe2Ez7HHkcK20/ao1pHJNaSwGIWdyJXVY3OYzKG35U/y
+sex7c1LuRLrXR9/pc8Tre9I3Tazp5JyY9OmkDSRMP4ljZ5AR5KR6Vm3y0CSi7HdNW9jZfEDX
+tLmSOKC3Y6ZM6DmazcEH/wAolYD2FR+gYm0PWYdCNzEkd9faroTM6/T4nhJLFIceZ2YP/NSW
+MB1Lr+fwJfDudVtmeRWPDyCPb9H3ADgVX9Zh7bW4L+ycQ2z3lj1FDKDwAbRYpj658SJsj1OK
+ju2zTyka3RtVTTdH0GTWXZbHR9Ru9Dut6/WIgytbp9xulBPoK66pp94bvqnRGG+K0igLRE/x
+xTsCV9/DYg+uKi9dpbSWt9OpVrS8W21dMDgyBNsv3JOKLOVm1rULS6LtFrmlm6imz9ccqMAQ
+fUZHOfWpk7pCjGvkchaDUulLXREUHUdNt/nrCVvwsbbvGfP6oyQPsKvXi0/4idKz6XJJ4l5a
+PdXOl3A/iO7c9oT/ADbFRh61UaXezWpS+cIt5okvy+pWzLgNbOQ0c4Pockbu3AqPrSXXw+6j
+1W70uMz6c10L2IofpkgfGBj1Ujhh61PStlOm6RP6T6jvNV0l9EW6Ek1nZrPA7fja3yF2MD/E
+pYc+ldOndbS41DV7a0tCmo27y2zWTrh3uoYjIIdvn4gG5fM84rN65Y3endYW3V/TZkks9ak+
+fs0X6tjvGYr2yYeWGKuo9G47Vsnn0Lx7vVIJFOox2sSrrVsf94jji+kR57NsBKmQ/WRkAgVc
+U/JMmqteTnoXTaaXqNzqk1wtrDfRrqdvpRizdWdy0X7xWOdqA7mGw859K6fMdK2aaZcy3lza
+xWw8G2uhc+E6GWQgxMcEEFx2PtTdP1i7sJJfl7mF0tQs5ccrJGxAY+65PJ8sio/UVlYalpWp
+6eiCOGchyhUPs5DK6g+YYf5irTSVIhxbeze2UemxXKXZupjIbePePpMs0SPnxEYcMRkgipfX
+fSTahpuqqlv81p2sabPDdW6sRudcNHcQ45WRSASPIqDWF0HXFg0+0tNWtkk0y6lzFNDFh7G7
+GFYgD+AsBkdua9e6b1YXlmYb1h4g4kZOdrjKiRfvjBHnXRj4zVM5MilB2ecdVadN1Fe9HdT6
+xqKfte2sm0fVjt2RXAnc+Def9bIFc9stXllxZTW3T2u6a0DRXEEsEaQkgkNMxiDA/wBCfavp
+DWtGivoo7NYLaO7iDQxylf3aKRuwB5oWwfY9q8sup7u4up5riMC+t3EV9bSqGIBbch4/Em4c
+EeTCs8+Pyb+nyNqjF66un6h8LEgtX/3rR9ZMU8YGQs17YoglX0y8bH75q7+Heppa3Woy3GXt
+Y3u4ZQB+JV+XhX/5jKQPY1Vy2ltpVv1bNbrJ8pqk2jzfLv3tZkuyCMn+EiVip8sFT5V16ZT9
+k6prkZKzQ3ZtkRGPI8ZXfcT5fvF/+YVyNbTO3uLR7Z03dCTUJLC5dWxeSadKRjDwq5aMn7it
+f00zTaVaae2Ctqngrx+ERyEL/wDKMV4f0BrbnXLUFjJ8wHndmzkCM+EmfzY/oK960SJYnuJA
+MK0jOg/PmuzC7POzx4sdfRSzSlosjFmNu3uZDN3/APJkVSC8tbm1uZ4G2rDezW0Qx+N40AAH
+sCQK1uEEiANjhs+WCBx/91msfbW6w9HQJAvhyRvPdWobuZDIyrn1LMAa0mZ42XlkYv2jOhAL
+WttG6/eTOP8A7k1Xa4VFzNdO3/vESQp6pGJtz4+4UVbTLHZWT3YYLNJFBFJJ6iNCF/qx/Wol
+1a41azsJhkw2Cu+R5nIP9RUPqi4OnZwuoo7bT3lZQq2kdzezN5hY1A/ox/pWfSLxdQmvIY33
+QG1jVcYPj3CqY0+53g/lWq1m2jXQNVimOXmtodLRh3YzSAyYHvux+VUugWst1rL3Y/8Ad362
+urxuMjwrK3aJQfQBwuPcVEltI2hLTZVSSLB1Fptrauo+RsbprmQcCTZIYVGf/tjOfyrjo6Kk
+VrqEUobLX14cnJGyMsv5YFSNNsfElkVI9sKaFZ27S5zudriSaU/ch25qH03HcS9GadNdRmKf
+UdPaJwf+EjylVz6Ex4qGrZonSNvYzOlrCcjx4beIL7GUZH+tSX2R2bbAStkpjTHdmwFH6moP
+iLHNJNnENvLAVBH4gkLAA/8AUwqXbRlYEjfgO0Xn+Ijkn9f8q1TMJLdki3tQs0qzAO8UkRkb
+yDogz/U1ZBC7gA4+vB+w5NQJQ0Ns7B2Ml/dtcN6gEg4HtwKvLeKJkB4IXnP9TW0V4MpN9jvD
+YQl5e55A9BVTez4KovduavbldtozHkkZx/pWcvFZHG78b8ED+EUp6DHt7OSHMmM8Dk49a7MG
+C8jvwPemxRiKDxZOATwPM06WRgQq43MMZ8lHnWaRo39FNfRsrBgcu2Tn0Wlt7ZQwnl5YYCD3
+xxUmeIyzvKO2AoHlgClERJUZ5KnkDt6012VehjKoiL7vqJAGKgSssCmKMhMNk+uam3EyxApE
+CWAxgcke/tVFcXckbOYdqbTyxGST+dapmai2LOGYtOXZi3m5x/nVXdBwC7ylkH/hDOPzPFT4
+WmvJCskiIqqWJ8uPXNcbjRdMhs1vZZJLeGRuJI3IeY/4V7VrdqzWL49meefULmXw7CGBwp/H
+M7SlP+ngZqLLrJj8eY3/AO4tXFtstlCiaYjPhlgPqbnsvAHerTU/2fq4/ZFv4+l2ttCzStbq
+P3aY5d37lzwMeZqntdIs2vor2wDfs/S4BBplpJxtc5LyMf4nJOST51nJutHZj4tfLRxlmKyp
+BcIm7HiOB2QelGmzTXV8sdtAGzn7KPU046VeBjthMk7jBOc5J/8AWtZ07o0OkW+1iHmblyRi
+sVFylseTLHHHXZLsrSQhFVwCoyzZ7V4n/aM1v4fx6HO3UyrqGm2SsqrHxLeXTf3dlCw+o+KS
+BIE52ZyRXqXW2vQafYfsuKQo1yrAlVycfavkz4mfFnTulOlLf4w6R0tm7gmuNN6bfUlLXDPt
+IlvEQ/u4Y1YDkjexwM4r0MS4bPDzP3HR8X/2htLuunLsWvVlg8PWF6yy30LDYmmQbQYrOOIf
+3aqpUBe4XGec14fnb51s/iRqPUOv6i3UvU08091qcsk5uJWLNOxOWk3Hlsk9+1YsiuTI7mz1
+8EXDGkyZDqt0kPy0jiWD/wAKQblB9vSuchtJAWQNC3cL+Jf+4qNTSTjFZ0XJjSxB4JH2NCzS
+I4dXIYHIbzz96aefKkx/WrSOdtm66e+OPxU6Ztn0+x611CfT5V2S2F8wvbSRf5WhnDIR+VWg
+61+EfVpROtfhp+wrllCvqfSM5gyf5ns5y0J9xGYxXl57U3vVW6qzOVeUemP8G4uomMnwq610
+nqrdjbp8pGnaoPUfLzHbIR2/dO5J7CsFrWh6107qUuj9QaRe6ZfwHEtreQNDKn3RwCKhpI6s
+GUkEcgjyr0HRfjb1jZaYnT3UIseqtEXgadr9uLxI/eKQ4lhPoUcUfyLjfR53RXpFzYfBzrGP
+fol7e9C6oRxaag732mSHHZZ1HjQ/9auP8QrNdUfDzqzpCNLvVtM3afNjwNRtJVubOYHttnjJ
+TPsSCPMCnX0Q7XZnKKDmkqR8haUDmkpwA8qBrYhFJinYooHQ2ilPA4pKBNUFJS0hNBLDPnRR
+5UH700IB+VLmm/lS54oaCwPakNGaSmkJsXy7UlFFMTYUUUUB4CiiigVhRRRQPwJkilFFFBIU
+UUUDOoFLRRUHUlQfnSgUnanD3oY0FFLR9hUl0KOaWkBpaBoKM0Ug7ZzQAtJilooATbRilxRi
+gfGgFFFHtQAUUUo+1A0gGRSkZoxmgCkVQflSZpTSDJ5pgLx2oGTRSgZNAISlFGKUDypFC0UA
+eVKBSZYlKPvRigH+lIB1FFFBQUUUUALnigmkooK7Clxz3pKVfWgSHDvjinUgpaC0FPH3puM+
+lOAqChygdzU2zhjZgTNCR6PnFQwp71Jt49xG6VU+9NEy6PQekejLDW7mOSaCyu7aPmeK1vil
+yBjuqYyfyr6S+BvQmh2esWE/Q/Vs1kb0sj2w1BbmJ2UZZZoWUMhA9Rk+VfP/AMKOgNH6k1ES
+ydbWUE0WGS2UvHOT3yMjBA9jX6AfC34XdI6fo1tq2l3tne6i0XhtqEGzxyhHKu2OWyf4uRXp
+elivyo8T1+Vr4o3thpQ0uAJEm+4KgMyuwQN7Kf8ATivP+s5Wk1Twm2Ew53Fc5JPlmvQJotbs
+rYxtdyXKQrhWfBPbgV5frn0S4k5kZizMTyzE81PqpeDm9Oq2TOmJ4YNTjaTL57Ad69CvLG1u
+rYxuSFP1EN9Q/OvLdEUy30brIqAMAa9V0+SeONTBcxz+ex1w32FcVWjoupWigtTJo9ybXTpf
+DQtkqp4Ibn/vXHWJE1i0utJuGJYwrdg+TMjYwfzIq2v57Nmc3FkVwc4I5X7EVmNTvFgkF5Ep
+ICtGFz3QsD/pXPJ8dHbj+Tsx+vMbSW5giXfLBPAJZHPCZQkn/KvNdeuYtL0j9n2jHxpbQW5Y
+9wrElV/Qk/nXqHUNlb3/AO1pWyov7NWXkgLIB9LH7KuPzryTr1FsdaJmHFskcjDvkvCAtYt/
+R6GDbpmU16RflNP0qDEaWdubls9gzHaGP9a4TRC1misipR7aJQ4J/iPJz781cWstjpHT171L
+q8cc8100S29qwz4qxnMan/DuG4+uKw6ancMTcXUjSy3BZ3Y92YnJJ/Oof7PYwPkqR6P0zr1x
+Zf7u0hMTkbl8j6V6z0hdWF3MYpL4Wcq/vIZCCVD5/CSOwNfPmga3bRXEa3cZkhzhlDYP5Gvo
+npfSun5unf8AaLTIJ9VimguIFjIzJDIsfbav4mBIbcvIHlW+GTZxevioL+TSdVaI13Pd2eoI
+dPe8j8SwvXGbeOcrjw3cdkY4znGDg0aLqN9daRa2mtQPFfW6qsytw6NgFefP+PnsQBSdNdRz
+ax09NDbyCa7SEQTqxDCRcfS208HI4was9Ens+qdHSxNlBZ65o15Hp73EeRBc2jgvATnJH0l0
+9AyEdq2bT2jxnKSXFlpd2UOs6RNEQZAWXaM9ty4J/XH6U/p/WZdMSK8uDsKXCrO/oGQAtnyI
+cA1V6NeXOnvPb3UZeNVZZY/MqCQ35jGaj64xjhv7J2Bt9Smja3cHH7qVOGU/8wNZt1slK9Ft
+awltT8OWUW8s089vcchIzLJh1fHkS3OR3zVQ011D+07bV4JfkdWWNbqEsR4d3BJmKaNuwJHf
+HoKsrPUoNZ0XVrfU0We502dLK9cABwCoeCYEeeCR91pqst5LLpdrqQF7LHtWGYfTNImMnPbJ
+HNPkFeGUNj+1NH6pvb9RHfadq9vt1G27R3ir2JHk+Dw3ltFarVLK31rREsobl5Wt0LWN4345
+IccpJ6OmcH1HNUVvk3o0m7hbTtRH4EkB8O4X+dT6+oFPttUj0i6NrqCSWviN4TAkFFLjAb7H
+uCKly+yqt2jtoWol4JemdeUpqCKjwSNys6KO6n1HHvitC14dH6m0PqFlD6ZqIbp/WY8bvAeV
+SY3PokgOM9sis1rlrPNppvIk+afS5Ru8H6nj4/EuOQR3x2K1NttfgsjZancQx3+m6nZCxvoe
+63EYcEdvNWPB7ikmKUfog32mahpl5LbxhjeaEPH06Qttklsg+1lYDu8LcH1Rs1O6jSTq7QL+
+z00BrvRJRNbspGLuGY7yvA8m3YHqferTqhZdLezvrVfnru1nWeOeQ5Nzb7SrI/oWT6H9doNZ
+q2iXTL64m0Uy3NhcQ/uM/jSFjuVT6tG2R+QpN0NO9otraaPUukL/AEO4jmZ4LOFrcqP3nhvw
++0eqMuceYyKgSSTW/wAheC6I8NwlpdwtlJ1cBgy/mpDKeQeDUuC/nM9lqNq2Lh0/dnslyFJ3
+KD/C4Jzio17PYwWxie1LW12/zKQ58P8AegneYz2WQcgjzqWrBOmaRPltXhttRgYRXlvFJAZV
+O5Z4iMSW0o81z9Snuh7cVy1G9S11H9iazLKmh6hAxjvWUyiymKgLISOTGSMMo7E7hWI0m9i0
+fUZJbfUbhbOc7kdl/A3lvA7EdjWytLq/sVS+je3mhfPhxsd0UjE+Y8h/Q8U+1sOmT9Ja8WO8
++HOv2fh6ncAXumbWCyw3Sr+7KOPxpKuVDj2DcmoPhT2VtD1LoYjeNn3tGwwHydsiMOwPcHyz
+U6RB1VDHoE8nyOt2s6zadJcEpLaTkjAR/NG4O3O1scYNWkOj9ST3t7rdpZwQ2upRNc32m5yt
+lrULhZ0AHKw3ABbBHdjnyq0rWiOfF7KqeWDT5LHqu2052sog9rqFi4wohbiVfQKysMHyZRV5
+d2djbW8Frc3DzWc0IbTr7AU3USnBQnsXCYOPPBrrquj9QN0lY9W/DjTrTV47G/uY9U6daRTL
+qNjcRrvgjbOBcQsu5O25cjvT+jEsZ9MtNEtr2TUekdQuZxazMhjl0K8QJLAt0p5jQkSRu2AB
+kZxmji0xOaaKTp61vIIdX0LVrmCaXT9RiuLOWI4E9nPxv2+RDLH7gsc8V6HoMN1bwahHa3Xy
+1xdyNJBvXIgl3BmYjzRuFwO2TWa0DQH8ew1XWYDJ40c3TOrBky8N2ZN0BbH4fpyu7scJzzWy
+0jTLu+13V+m5Xks9a0FA8MrIfA1GzmAeKQN2cDDDI5BDA1WNNbM8rjK1ZYabqCX0kvzFs8Vx
+bri6st31xHPOw+a/xKw8qx/xB0CWHUpda0qTImtCISSAFkRt2GH8SOpI57NWlmgGozvFcwtY
+azp0pjhlQ87CB9JPdoz39ql6jbNrPTU9v8qfn7V1mkhH/GVc7wnuULceoFdEvnGjng3jlZ4H
+dzSa7b9TaYgYXk+leIIF4PixuLm3B8yd8LR/9VZW46kit+qOrNQkgb9lX9pozaTGh5eRYhOT
+jvu5VPfNehT6VcWPxH07U4PBW31CNOn7yZMeHJHLIJbW5ye0ishRl7/VXnOt6fewa9eJcafC
+15oVhayJAzcR3Utz4Cg/8oDt9lBrhlaPVxtPo9H+H4mutW09QiiKxg8K6f1uncFkB9FJYe5F
+fSFrst1c5J8AMpJPGTx/Q4rxfoXpiLS5dLSHxhYOXvN8pBkJDbkD4/iIG7Pnvr1zQUlNhf8A
+zZPiTX93Jg/yeIAg/Ra6MFrs4fVtOVImktKbmwYEudPZi3+Iuq/6VXR+AYLWVk3Rw3CKnHmG
+bBx9zmrixOLp7oAbhGxJ9R6frWWtrzwIWguONt2VPsck4/IEfrWs3o54K2aO9jh1C1NnP9Ki
+RGODjlJVdf124/Oo06zXF3eX0hBZY5FyPNVfIH6vXI3ojUsVxhg8mfPPH+lWFkiywSrJ3cqX
+HoN+4j88CoTs0a4jdegddOSGM/vpruJhnnB3cfmAKrrKJLfSUt4VZdlzJIzZ5YPIrSH3LFW/
+WrO9lkurmK0ABlQvMQPJiv0j78ioEO/9jW0bqC/1bcef7wqv9OaTduy46VFHdldBtNM064fM
+k1tKWfPJW3s+WPqNzEVI06wcQ3NgwzFDcWtsjH0WFCcD0BP+dQPiGFg16zZGJ8PSdaCc+Qig
+BP8AmP1rUWPhtq2oW4IZbedOw/iCqtSu6LeopnG4hW5i1KNVAEd7ASew8NUJI/8AMAKt1t43
+uLLK5CK8/Pl9IA/zxUW3tnaWW0diyM6s3GNxLMx/QYFWEfi/NXU8i8eL8vGAOAm4Hj9K0SM3
+IZfkJfGcAFbO2LY8g2eKsNI3G2jEn1Fzlj96iX1uri4jLbRIdrN6gCp9ihEUbEBTIwOPQY4H
+6VpHszl+JYzKCnPPoPtVBeiPeZZOynke/pWgnK+Dkd+QtZu84l2ckJ6+ZqshOMhvM7kPICFH
+IA/yp8avKxZ12hu2fOnKhBycewAqUFwqjbvkPbHYVmkaOREaBTjHC+nrTdjdjgAckY7CpYIc
+lTg7fQcA1yklRAwjUMQSWPrTqgTsqrkpFAyOQjOc4xyRWQvhdXl1I0ZMFoh2RnzkbzP2rXX6
+ExiXZvuGJwPID3qGbHehRkLeROP8qXZpBqOzMCGGC3d/Dlkt0dWdVyWmbOAoHnzj2p2rQarq
+F3bXaxNLfzr4Gn2YIKxjsznyVVPdj38qtLq0uZL+KG2QLGn96xkAG3yjUeWT+JvyFdL7S9Uv
+Ibuy0/ULe3muUCTXgzkKB9KL6KozgepyapK0XySaPN9dnFvqbdI6DdyXskbbr66QbjPN2bb/
+AIQeB5cGr7TNMlgt45NRkCeGpzGhyAT70+1s+ltAhg0Lpq+sL3UWB+anaZi2R+Ji2OQPaut1
+ISRDbXFpMOxxcqCcefNR0zqlNSiox0jtFexxDZAojX+bzNd4tQySkYJPbjyqsjtrwvk2xOD/
+AMNlf/I1Jt1MEgSQMhzkl1Iqk2YTUSJ1F002rhbguFljQ5JJYDjvtHcgE8V8m/2odDgluNCs
+Y4YtZEb/ALN6e6ZVGAN1IAIjIF/vQDlynnn6sAV9k38Ws31jNbdOFUuXXak8gyqk9249PKvm
+H4+3Ghf2fOhtU6q6Vnn6r+Kk0R0fTr3dv/ZJvtwnnTyWRkXYpHr3rvx/JUeVP4ys/O7+0NqF
+u/WsXTlnefPJ05aLp1zeYAW5vdxa5dFHCxiQlFA42oK8ravVv7QvRkHw66q07oV/r1bR9EsB
+rkhOSNRmi8aWIn1TeFPuDXlJ+1ceTU2exhaeNNDTmmn2pxx+tNqQkMPFIacQe+aTFNMyaGGm
+ninmkwKZm0NA5p9JgUtAIT7GrvprrPqbpOSQ6Hq09vHNxPb8PBOPSSJso4/5gapaAMnvigDa
+fO/D3q041WxbpPUnOTd2EZmsHJ/nt/xxfeMkD+SqXXui9b0GEag6Q32mucR6jYyCa2ft3Yco
+efwuFPtVLVho3UGs9Pztc6PqE1s0i7JAp+iRf5XU8MPYg1V32JxT2VqjJp2P6VpDcdMdRP8A
+77CmgXrd57eMtaOfVohzH90yP8NV2qaDqGkBJbqNJLeXmK5gcSQyD2Yf5HB9ql/oqMXRWEYo
+pWx6YpKENiHtTcU4nFNJzQQw7UhznilzTTj1pkMM0ZpKKZNhRS0lMGFFFH5UEhRRRQCCl8qK
+DSsqhKKKKZIUUUUAFJ54paMc0CYUuKBS/kKWykjpRRRUnSA+9OB9KQd+aXz7UMaHDHHNGKQG
+nVJotiYpaKKBhRRRQAUDvRRQAtHHkKSlA/LFBQnvQKU96AKBUHvQKPLvQBigaFxS0lKO9BSE
+oHFLjjijHHakMKUUYHFGKYC+1FFLUspCrS0CikUkGOMUnGM4paMD0oHQUUUUDCij70UAGKXB
+pRS0DoZS4PalwPSj+lAUKO9Opo704ZxSZSHDFPB+1NA8s09RUlHaLwyPrB9sVOtflVdco7kn
+t61AQHvg1OsIWluY41ViSwGB3NNdky6Pavg/0Bpmu65beCktre4L+BcTKrD0eP1+xr7O6D+H
+V/oqxaqNddLphsLiFAso9HUEB/vivIf7O2qapBbC1vOlNXis4EVjd3WmQzQs+AAm7O5ePqHe
+vqXTtX0y6shEkIbacbjGEOQO2PWvXxxjHHo+b9TklPLT8DLue5it9ksyl0HO0YB/KvOuo2ea
+fxSq+vet9fSWMkZeKcMf5Qwz+YrCa7HAZA0Ydc/iLedcWdl40QdNty0qkgAqQc7u1emaTexT
+W6BlwyDGfWvONMjPzChBkE5zmtrYb440D4II+2DXM3RrVlnqChgGD8EEMM8EVjNQlVSIGP8A
+dEnd6nHY/nWjuLx43KlMjzXPce1ZnXI1W7inhI8C4+k58m78iubIztwIz98zPdaemSqyxSW8
+o8slhtP+YrybqK1l6q6/1yxlcxW8NxDb3D/yJGAD+fIH517Jdwxzi3cJ9Vu4DA+m7d/pWCuN
+OihvtQ1JMrLqswnlJ82JGQP0rFbO7FLi7PJ+qr5764mtZ4SkFpK0UEeOAgOAQPsB/Wsm6ySS
+XmAR8vCHXj/EB/ka2HWWnajP1TNGls0cExCWzcnGBwh+/r71N6K6QutekmaGBpWggms9Stz9
+M0MMi4E208sEfbnHkaOLkz1MWaOOFtmW0WwlupREfpBYAtjhckDJ9ua9/wDg3pd+vT+q9LXM
+klveQ6vJdW7BypjureBCwH/PGxGR3IFZ/oHpm3+XsLPXNJjeWWZ7G4fyjlUkq3HcEYr1rfZ2
+VzLrf7MT5mWeO4l8NmUl1XYWGOM7f1rSMeOzi9Z6v3VwSIukm4m1Gx6naNbbUrdpLG/t4VAS
+/UOGilA8pMZBx3xWrigi03qnW9LhCxCJklRV7MsgEoYfYuaodYt7C4t52i8ZA7pLG8Te+4H2
+wR3q5N8NVs7HqASlrkQ/s+7Vu5KfgYf9Jx+Va8lTPMnbdjX1FYNUWR13FiXHn34OaiahbR3e
+mtpfiNEbZxJaOP8AhjOSn/Lu5x5UlwhlDD+XJRh5j/664C+jfal6h8JxtMg7xv5Nn71lyLpC
+Wkk1lrA15N/762FlqkA5W5h7pJj+eN+x8wSKlXFrBdvLfaXMiyRyK6Hn924GBnHI9QaY63Wm
+3AWSRA/r3WRKm2Ijup826xLdEbSjcb1HOPcUm70P9ku36nF7ANH60tlcHGLhD9ayDtKp9ft3
+qXNbaIuYdelElleKY1utviW5J7FiPqhbt6qaqLq2guY2imiKHOR9X4D9/KoSz6lody1vFMJY
+JO6OuA3+lUpN9kOKXRaN01rXTV7DqunXnzNq8fgzeGQy3NvnKsCOCyHz8x3rhcW1nI9xbZNs
+1yxk8SIfSk3/AIqDsGIxuHY/erbR7tFtmFnH4McrZeFW/d5HmoPAP2qPqEqwXMbrbLLbswLx
+sdp9wD6U9Vom3ezlomuyOP2b1BEq3li4hn28qQ34XX/A4/rVc1uba4lsYbt4JoJPFtZwMo6t
+/Cy+YI49jVle6TB4a3en3LNEw2m2uMeIiHyV/wCIDyqFazWTymzu2MbRnKtg5X/uKVeB2mXl
+kdL6hs5NB1CT9n3u7xoZAfpjmHO9D5Z7YPHJrqLW4id9J6htfmLa6w0qxnY4lHaeBuwcemcH
+zqgurS4titzAkd7CSGRopgrL+R/yp46ouYIo8RztGxy0E34QfVTVJ/Zm0/AXnStzbObm0ii1
+G3bKeOhw2R5SRnG1vbt6UsdjrE0ITTrX5iRAEECSCOVQPJUbAYewOas7XXIbiRpBFFDIww6P
+kq/5irS00zRNbSTbetp9zBsKukzBWB8/Pz9qripdCeRxWyusBqmqQSaRqtg/zlrhrUsNs9vz
+nK55/EB9JGDXrOg6lrNp1Yr6hbpLZXkYKX8IC3NjIF3bJUbiaMncOeQOKg6LpWtXEHyHUEGg
+9XWDEGGWWcRX1uB5KxwT+tSre1tLOR9NXTrkWYcyRx3LsskKk5wkwJzj0rohDjs5sk+Ze6v0
+Ns+f1Do25g0+41CeG7nhC7rWaROCCndQy8ZGCDjvV5c6DpU16dX8G00nqC4tmiFwyAx3gMe0
+pKO0h5xzyRWbsNWuNJyDqB1GzkJj3smyaP8AwuOzY/mGM+lSn6hsNYWSygvI5JIoyyRsR3Jx
+5+YxxWnw7MqnR20/SZLK8kuI9PbTL1Hig1LSpG3Q3MYI2z28n8QU4IB5Xt2rTWny0t1b9P6i
+wjKbjo9yeCtyN3jRfmpDFT6kiqC363uYZbWz6k0uSKZYdysZAySEcDbIOMkDsfMVLmuemert
+IuXXUZ7RrzY8chBjeOWP+7mU+TqfpJHccHNFwrQOMu2Xlz07b6tKlleR/L6tZxeNAVOGmtwe
+ce6H/OoF1bXmmbZbiHDghmmQeQPEmPbPI+9WelXt7rei6ZPrgWDXdNxtuYyNksgyBIhHBR1H
+K+WcGr29ntrmzk8ZESVY96gfwk+X2zx+dUoJrRDm1pniPV3R7HUOoNCtMpZa/awaxprDkQX0
+M6vIqeg+ncPZzWU1fpkdS9Tv1JbSxrBqdpdWDOAMrPay7gW8vo8Qgk85IFe+alpcV7pUcUEY
++bgVprfIwUfb+D8+V/SvP+kumDbaZrFtGqi31XWtTn0524LG8SKUpjyO6JwR6iueeK2deL1H
+FHPoLRZ30zWINU8RLjStSWwmWRcFZQzSbf8A8l4f5Yr0HTHVnunLfS1yyj/q5A/IYpup6bBY
+32s6rb/TFruowakwDZ/efKLC3Hl+EZ9646hL8pZrFHFhp7yJyRxhGQk//coK0jj4Iwnk5ys6
+Wdw8mlG8QbVeBic+R35/yrNy2oWfTrLxS++WTUJGPfaWzuP3JP5Cr6SNJtNWySRkjuLk27Ff
+NCHLY/8ALWN1HVZrq6vvBUCW6aPT7fnG1QMMfbt/Ws8i0Vjey8imfUdCv5IEDzvdQLGCcERv
+cDn8kU1e6beRSyWkXi5a5hN/cn+SEAbf1JArHdL6gGa/EUmYVnhtVHr4QJZv/Mx/StXoFsl7
+qV3ar9Pi/KWoHmsMR8R8fngUobNJuiTqD/I6tNqTLkQ2kkrZ7E5zj7/u6ZopklTSd5BLRRyy
+L5bfDbt/1tTL2dNU06ydkxFeMYXBPIVxIOa46YzWc1pHKTujuHsFPqmHdf8A7gVMtMuO4kXW
+dJtdQ1GLUZ2LpaWF3CcnhmkbYAfuWJ/6RVtYywrcz6iMFRGHfjjKhmY/rVTHJHFDdmUM620U
+csi/8sZnx+rj9K76gWtbO7to+Pn4o4FI7AzTJk/+XdS/ZVWqL/YyvKI+QH3ZPoBuP/ap4QbF
+GP8Aw3bz5phjZL6CwYfvCkzs3+APtB/OpNuniz3K4+kbMefGT/2rZGDY26iDqSw+kknHt/8A
+VXa2yBvYYCg4HvSzp4gwePqX/wAvn/lTo8ZAPY/V+XemtMl9DrgssLyckgYUegqgmVlO6UHL
+ntWg4ni8wD/3rO6xIPmfAUnOew8hTmPGt0PQpkAfi8qkIspAVGUD+Nz3/Ko1sOw8zU9VXYeM
+DNOOxS0RrmSOMFY1JVRwewJqtaVmXJHHt5mrCb96MCMlfMmorxKP4N2T69qmRceiO28kkkYA
+x37VFe1eVyZrkjP8i8/qamtHwR3GeTTPBKvlwSMEgD2HmftSRV/RHEFnbQt+NIlH1EKP/oay
+etX1te2MjfParYwTMbaOKC3RnlPckAkZ4/zq2uYbnXyHt5wtmI2/B2Iz2X1J/m9B70xbArK3
+y5EEVuhCtncR/M2T5k/5VVvpFxSW2ZA6FoOkxSWlpq+owvdIscudPRmEYOdmQ/meT64Aoh0e
+DwhJHrUEFuo+t5bOSNiPuNwzUnXeoNH0BVeW1Sa6uGChY13Syv7r2Cj25rJa5fdY30fiz2sf
+ywywuROsdlCPQfxFx/LjNRJxijrgp5O3/uaUG2idk0650uVRwrLeKsr/AJPip+lT3sWHlhuI
+lzjEhyM+ueRivIGu7a3d7m6WS8YAMATsX24POK2nTfUNy+1Y0MEa4yiHge1Yxzpuma5fSuMb
+Wz0xp2uLVoWginSRcFcmMkf8y8isPrvT3QsE9u+rQQWtw1wskHzlp81GCgLFtyjcFQAkEggd
++a3Wmnx7VJZ4VZm5+j6SP9DVH1dZFbe71Swt0vbqK0mjSKVtgBYYGAeGPoBye3nXq+nbZ8/6
+hVZ+SH9qvpD4ndUdT6p8VV6XsNQ6Tu7iW7TWOm75dVtFVicyXUkf1wyEAZEqpjsK+cOCMggj
+1BzX0P8AEnrvqPpTrvW/iN0Dfap0ey6t+xtNht99lcTpAu6aSaI4JBZh9LqR9WPKqWfr34Uf
+F92j+LHTcHSfUVwwx1b0xYLFEzc/VfaamIpQSctJB4cnnh+1YZknN72el6ZyjiVLR4e3amn2
+rafEP4V9T/Dt7a61A2Wp6LqIDabrulT/ADOnXy4zhJRja484pAsi+aisYe/NZO1pm1qW0NJp
+tP8AvTDQQxvrRS/lSVRmJ50c0tFAqEpaKKAoKQ0tBoBjQSKkRXlxDE8MU7rHIMOoP0t9x2qP
+inUBFtCE47UgY+dB70lBLYE5oPFFFBL2Jn7UlLjmjHNMmhtFLg0Y5xVCaEooooEFFFFAqClH
+ekpeRQUgoyaKSlQWFFFFMkKXikooGgooooEFFLikpDo6A/pTqaBThUnQhwoopfL71JaFAx2p
+aQUtBYUUUef3oGFFFAFAC+dJS49+1GKChO1KKKXHnigBcCkpaMe9IqhKOTS0lMKF/KlAptOA
+NAxSKMUUUiqCiiilYUFOApMexpew7UrGkLRSDNLQUFHnRSYoAWjNFFAwzRRSigOx1KBxSCne
+1BSGkUmKceaTvQACnj3poFOWkykPXvXaIFmCqOa4getLnmpGWkdvbrjx3liz3/d5rXdD2XSU
++pRQajql8HdsKIoEyT7FjisVaaldWxGxwy9irgMD+tbjo7Vuir27iteo+k7l2dgPF0923Z/5
+P+1bY6bRjlvifanwv+Epl0uBG1LUrW0MZljnV4vG5HB25I/Ot/p/w2bSZYpF6t1q4jR/EaG5
+mXDn7qOPyryv4Saa+l2C/wCwx1aBJSGH7WV3iTzOxScexHrXs+n6lq6x7tWe1d+Nxt4DGD+W
+TXpZJJRR87xbk3YTx3ECGOeDjPEnibuPvVJfkncMbgKvrzUA8ZUAkH1qguCrffuQa87JV6Oi
+HQaYAJgWiA5GDjtWmjmyNqnj0zWbtGIfcveryJ9ygnFYSKXZzvHfJAck9lJPao06pMm1kyAD
++tPu3WQYB5FR97tGQ2eRyD51zSWzsg9FatuQ4z9f0lWzwT6VSal0pLfYhDkqkhkQ9vLABrUG
+Asc7TjHepUVsrLtOSRyKIxNHka6MlF0Ppt5AljrFuBIHBil7NxgjkehHFW46TiF1Z61D+41j
+T3IF3Efqmjz2fPc4OK0JhB+mROO4NPUBSCRwa1qjN5Gylk0y3iu3ngt0iaR/FZVGAX9ceR+1
+OuLQ3MbJypHIx5571cSRI6sjdieD6VEwUbDHlT/SpY1KyptpXjtRbyna8f0g+ortZOsAmiUD
+96ykLnjIHf8ArVvdQPbwC4a1S5tXP49hOxvRiOVPoe1RG0+zvMPbSGMtyqk5qaZXJMdFKpAS
+RWUA4zjtT20+C5lbw55IxIhB8Ijlc85U9/8AOo5sLiCQym4aE4/CRuVvX3pwgunXxo7cSxqc
+nYc4qN+SjvZ29uIP2Pr0sk1urbYzDCGeM/zK2QfTiulrpsMEpuLe/F1DESuXRkkQ/wCJT2P9
+KgLNLzhHkjB+pWGSvv61c2FzqscRurVobiKDafAnO7ep4I55/rTTsHpHVtPjkIMchfK43ZBw
+Pv8A965/JRSwnT9TiZ4jhkkXhl91Pl/lU83GnOwngsHsHPJRGJjPttbsftUiPxJCAf3i9huH
+GKtIi2U0Oj6tp6Gazdb63Ru64SUL6lex98VOMSXi7hL4RPdGXcqn3HcVbQWVxBKssBMa5wyd
+wPsasbkwXC4vbNZJF7SRrhiPfHenRLZjMmzMmmXqGPjdEGGVI89p9PSol3o9pc3KCW6FpMw+
+iQTct91IwfyNa27trG5ieCWQlQdwyMsp8uKrr3TNUt18C40+PUbHO5VUjz8x55+3NFDso106
+8tVSCO8sbzeTl9wXcQcYYHz96kyxfs0i31LRp7facBkjaRCD5hlyMVxnt7w3W+xsZ3jzmKJx
+kgjuuT7881o9MbqmGMpC1vGrJhonJbn/AKeAfzpxWxSZV6PHpV/fi00i/VZ2TehNupBI7qd3
+IP5Vp7SWwurl9MvbK8h1G2zvNuTG0gJ7kY2n7Cp0Wo3jsltqNnqREEYBmhjiO4447E/1q8Tq
+jQen7Wyvrpr69u5VVAZHXfH5DKDg49a6IQXZzSbboqbPQ3dStrqt7bFWV0S6iwuM87ZMf0NS
+5tRjtenltbu9Z9Sh3pLcJwspMhMZGPQYB+1c9a1i/wCobpJbp0eNQWjKn6VweD96xHW/xH6I
++HoWLqbUvEvZhmKwt18S4f0+kdgfU4rX232gi15LvVNe1vVLURxp4MrD948Yxz3J++ao7iDq
+GV0lTYhRCodV+sdyDke5rzvVPir8T9Zt7dukel7TSBcsTGL797MEzhcr+EEjnnsKzGr2Pxqv
+pA+s/EW8gbaWKWsqQj8gopx9PfaE/V44OrR6pqMvXPgMLnV7qRQ24YzgH/Ssn/tB8RNFWaK2
+1W9kt1leVE3ZC7iCa83utM+K1ipmsfiVrxweA10HJ/Iiqx/iX8Zel5PmNQubXXbaPG5bmARS
+bfPDqO/3FLJ6OL8M1xf1CL0mj3rpf+0j1LoqyWN9t8NP3zK6Y3YI3bR2yRk8feveOh/7SnQf
+WVzaaO2pJaX+oQukcUvG4gZKq3mfPHtXw7pPxW6A6zm/Zuu2baJqcx4S4/CxPfY3Y/0qfqXS
+8mlump6fIdts/jW86HJRwpwcj71z+1lw7i7R1NYPUKpKmfpNZahBeW1td292kjAiORlP4gfw
+n8j/AJ0mn2dtcTzRJtEljfNcwr/4chcSAj7MX/Jq+Kfgl/aJ1XR7iPQOprppP3gMUr/xrjlT
+6EHtX1x0n1lpmqX/AMzBcKRdhWbHckjgn8sGt8eVT7POz+nlglRrtSszcW81vHkIFkCj+Xd/
+2OaqdXk8OaCMr9E6RuceXhR/99tXt3KY4Zi3DyKRgeWeM/rWc1B2dLANgBYXVm/xsrAf/c11
+tI4OTTIcl9Z6NpNi1wSFmfO7PEUKQ75nP5ED7vWJ0smOa1urwMkirE5QjLCacF0X/pjwx+9X
+esSw6hYQ31ySbe4heVU8gjtjb+axiqTV7ieCN5Sqteu/zDMBnapXBx6E5RAPRa5Myo6sMrY3
+QJreJ1AGF3tO23z2nJ/U4rcaNfiG7ikjkCzSpHbnyPiOd8h/IECvN9JlWKW5hDDw9PgCzOf4
+mJ3OB64OB+taTTrqeZ7e4hJWaWQRIO+0nLsf04rnizpkjXSSQXUghtBst7a5WSMfzwwsYyR/
+1MP1oc+G8l6xGxcSxEnsXJQH9GNcS1tZaUkKfTJIkUaY77fEyF/M5Pvium1ru1hsuVae5ihB
+IxiFCNpH3JP6VUkhRk6O/wAgzatq1gz4S5+XGT5q0DJj9NtcYWOs9PaRqohKsLa1vWjfvmOQ
+owPvjmpcm+XWtRljYbSkKrj1ViAf0zUyJlIdSAYkh2uAexZgw/pmp4l8tIurnY+oy3KHJS2M
+ajzwGyf86lWiBYS+MDIyftk1X2Z8ZwP4yCnbyPP/AGq3jj22yr555+9bxV7MJOtHJ490gU54
+j/ShYmYMyqcyYRakRwl5C/P1HB/7VKgg3OHPkTgVSjZLdHB4RBDzzsHNYa7eW4v5JIl4PY+o
+9a9Bu4t6GPbkN3qoh0W3R+VyTRODekGOajtlRY2cjIG5AHc5qZIgXgcADz7VcG3RU2qoIHYY
+qsmsTMcSgvk/hHYU+PFUg5cnbKuaTI5J298gVEEjuSuwhR5niriSwnIIWML5DJqO2nspzI+c
+e2KhxZanEh74hztJxwoHbNctStIb6xl0+4llEdymybwm2sU7suRyM9j7Gpr24QFguMevaoz/
+ADDMFULgDLNjA/KjopNMgOsSIkNtEsUUahI404AA4AqDqCS+C1ra4a427gp/CjeRPr9quPAZ
+wWSQDnuewFUmuRX0Nm1pol2lo8313V/IN0oTzEeeAT2z5DtTSGnbPNeorPTegbe41fXVS81M
+x+LHJMSVizxvcDkkscKgxzXnV51D1JpV/b3fUFjeTzawfC0zSZgvjahJ5yeEv93ax8Bu7Mx2
+g969HtPhINf6qTqnUNTzotnKLizspNztd3X8V7cSMeSB9KRj6VHOM81qZ9A6e6cudU6h0+18
+TWrmDZe9QXmN9pB2WG38kwDhVUZJyazlBv8ASO+OeOOkts8pg6aiW1TU+qUnsZZpGc6SJ95U
+g8F2PMQJ7Rkkjir7Rreb5uMm3it4eBFDCPoQepP8Te5pusalp8OnpZ6ZpsVnYxsMZG6Wdz/E
+zHkknmu/TNlcG+TVZpZGjRfojB/FIeMn1wOwrDik6Rq5ylFuR6bYYjslQcHGBWL+NehXnUPw
+81XRoda0/R4r2Erc6hfOwis4ACXmIXliABhRithp8v7ty5HtjyrK/GTTr7XuhrnRNNspLy6u
+pIwtvGxXxQGDBGI/g3BS3sK9X03aPn/V9M/PD479X/BLVbzSPh3q0t91JoOh6ZDpdpr7o37d
+VogTLcW0jceC0jE+FPu3KMArjNfMXWvw3i6Y08dSaJq/+03TU0gii1mzjMaRyEZENzE2Xt5v
+8LZU/wALMK+kfij8M+kuldf1y7+Ib6zr2p48bVtStJVsrfeRlbGzRgWkIY5d+OPSvm+567bp
+TX7jUOhTcafDcAxS2d1tmjlhPeGVSNsqezDzpeoVfkdvomuK9v8A1K/or4na90K9zaWIh1DQ
+9S2rqeh6inj2N+g7CWPj6h/DIpDqeVYVe6/8N+n+sdIuOtfgqLq5trSJrjV+mLiXxdR0hFAL
+SxtgfNWvPDgeIg/vF43msk0DpLr4CboyWHQtecsZNCu5ttpcHj/3Odz9LE5/cyY9FY9qzNrd
+9V/D/qaO4hbUNC13SZg65DQ3FvIOxwcEcfkQfMGuZP72jrdN/TKQ9sjkGmV6F1BbaZ8RNPu+
+s9As4LHXbVTPrelwIEinX+K8tkHYZ5kiHC53L9OQvntIzf0GPSkx60UtUyRpFFKTSUCCiiig
+QUUUUDCiiigOhrUlOoAAoIqwC+tJt9MU7FFA6GUUpGKSghoTv2paKKBUJjigjzApaKY3EZRT
+sUYp2RxG0uaXHlSY9aLASinYxRj2osKG0UpGKSmTQUUUUAFFFFA+xwpaKKgtIcPvSikFKO9B
+qh1L54pKfgCpNUhPSgUtJnnGKChaKTNKOe1IE0FFH50edMYuTRz7UAc0v5UDAgetHHtmkJx2
+86MmgBaKBS0DtiUtH3oxxQUgpw7U2nA0AFFFKKllgBmlAxQKWkNCds0ooooGJwOaWiigAooo
+oAKKKKBh2pVpcA+VAGKB0OWlpBnFLSK7EPp6UnckZ5p2KCTtCcYByOOaYAM08Dyx96aBmnjN
+S2NIXHpS0c9wKMZpFUd4CykMse7B9M1vuhOrpNHvYbmC4ktZYjwQPpI/IZFefxSyR4KyFfsa
+2HQfUF9Z6vDbmFLmKZtjIVG7njI960xy4yRhmjcWfV/RnxQvprSGNNau4rdgWMaOLqAk9+Mg
+p9u1e06Bq1rfwKdJ1+B5PDDNbSLh/c7Sc4rCdBdEaXqdhZ6jaabdW8qqUEixLHDgjOGBXkn/
+ALVvbDpm5sZlnWaGB48qVeAEkH0cV6E06tngWr0SneZyPHADH+UcVCkRi3BBGe2KspFmRcSl
+D5cDFQGP1GvPl2bR6CEOjbvSpiyscHdzUQNx5mu8JG7AHNZS2NdkgjxAQ3B8qaVZeWB4qVGi
+soDRqw9+9OKIOVDD1Gcis2jaMqOVvExACjg96npHs5+lvsea4R/TjbXeMu2QBkY7UIpjyAc4
+HvTPBU57jzrqEQrlgVb2prDb7igSZGfcj7SAQeQa4llJIz28q7ybWBDHHvUOVPqyefcd6llo
+7w6jLYP8zaTgEHDo4yrj0I8xXS9lt72Br7RImeILmeyY5ktz57G7uh7g9x2NU875OBzXKJ3t
+5VngcxyofpK8UuXgtR8ircu7sqOzRseFc7io9z51N01NzlZY2UMCUdTyKjTlbqb5kR7Jiv1A
+DAb3rvBebCFPB7EMOalFPa0WsdldRndCyXEeOSBhvzFW2n2iPHuEwgYcMAoYH7iqeznOQYpG
+XJxkHtV5FciNd00cczLzu/CcVrGjNtkuC0REeKQRzZOQw8vyPautnBaxSbQm8NkD2qMt1bSS
+CeLxIXbggHIPFdfmY0KpGVD4zjsT+tPQqbLBRLGM+EdhIyFYEj8u9HjMpk/d7fMMTk/mKheK
+3iCSKcI5GDHkZP5U251FEBjKoH7nIJJpWUona5aKWYYhEZYgFhznP+VPt7HbG2C6bGzjcfq+
+1VUk0U2RNNKsg5Dx9h9x6V0tNVu4z4MeqW06AgiOcAMv2PcUJlOOi7ksYfDUxtI3O5l2HcR5
+/UKckPTZso5NSslumhbK2/jMoBJ4DBSAePWoP+0N9dHwUspFkUAboUL9/QikJe6vAkdjMibg
+7q6qSXxjPfOc1qv0Yv8AZw6r6nXRdLgsbHRrdJJk8Rra0AQIDwAxHc15pp3VOtXTtAlvcLHy
+HEwJYMO/B7V7lF0VFqMyyyyPMGw0kcmCyH0yKgdU9L6fb3M1ykQw9urEY5DdgR69sYrrx4nJ
+rkZr1EIxaijwX4g/2g9R0m/l+Hvw9toptVjtY2vNSlOYrGaRQSqD+NlB+2T7Vz+GnwyhgLdS
+dSXUl9ql03jXNzct4kreedx5GfTyry/obpyaH4l9UWOoJuubTWLiKXzLEtuTn/lIr2zXdbGg
+aZq81omwXl58vBuJOFjjUvj9RXp48VR5s8T1PqHKftRIPXXXel9LRyzWyRhzuGSc7R/9XFfL
+/XH9ozVLu5aPTbnlW/Hj/L0qF8e+vZo7RdPguCZJzzzyBXgHjs2SWzmiMPd2+jNZFhXx7Pat
+N/tBdQQ3AkvbxpVXjG0CvXeiviz031kBZXFxGJ5Rgq4AIPt618atIT511s9Ru9PnS5s53ikj
+YMrKcGreCl8Sfe5upo+vuufh7p+pIZI7MFX5wODnHcen5VQ/D34mdV/D/qWx6R1bdqenX9zF
+Y2puMEh5GCrE5bjaxIG49qsPg38Sf9stEj0/UyPm4l2q5P8AFjzqP8ROkoWga78LdHKSJEzy
+rr9QcHy/7iuXJhclaOz0/qnglwk7RuesNEsCr6108WtJIZ3jurGU5ktJ0Y71B7FQVIzXs/wf
++KJm0y3gmYeNbANkfiOE24J+2RXx38KOsb24utR6M1q7eW4RmuLeSRstKjHJBz3PvXrXReov
+pWrQpEcDfnAPcedeVkjU2fRJe7iW7P0p0PqG16qs7wWMplNtHAHbPbfGNw/Jgf0qq1e83ubW
+FtzGB2XHoPoB/UmvEvgh8RbrQLaf5jIS9vLYlQcp4KMwk/6sP/SvX3K2+p2NrI31OrW24nui
+Tk5/8nOa68M3KOzxs+JY50jjqYggs5YeSluJCB6KgWONQPcljVN1OZYJ9RhjBaaS8igTHYbY
+VPf/AAlsE+uavHS6t3t4LkLJPLZy3rIw4BkusRA++0Z+1UuqSwS6gbBFka4hDSTRAZdpZDlF
+9OI8Mx/xAUZl8ScL+Rn2uIrOJpZY2a3upxEMd5GHI/I/Ufyq+0+VbSzkU3BQ29rNKZe5EkmA
+W/JTxWd1yOT5uxjiG4hJpUXP0hVUpu/TcfuamaXqaW8yvdw72ltZbu7X+GP6VEUePXKniuJK
+jtbtG7u7xL26v7W0clbOQQwN5G5eMRoFPn4akk/4mPpUnStSE73N5giKO+Nva85zDbwbB+rs
+zGszZw3FppaQhHeR57aGNgeVmndzI/8A5QfzIrRWfy1vro0u4TwrPT5bwv8AwjYluxbP/lzm
+qf2Ea6L25RrW8vGjGBHBDkDyIJz/AEqyKRRXFzI3CXJh4B7AqV/zNVkF8uq6VpmqMB/7Ys7e
+42g5+mVS4/pVqkLtHLCib3jgAA9WGSAPf6aqrZP8llpse2QZOMNgZ8//AKYq7Ur37YYiqXSW
+F3DYXUZ3JcolyD7MmSP1NWqyr4W9hjJ5rWKpGcuyfBGCVYdicmpKgKSSBwK4WpDIpJwPSuzL
+w3ueftW0Voxk9iY3rk/xUnhoO47dqeSiKWcgBRn7V5B1t/aD6Z0G6n0rTDLqF5C2xlgXcoPp
+mlKSgrZUMcsj4xR6nO6dice1RPHjJxu4HGAMV87X39oDrC9kH7N6dkUgclsHJ96hj449cxSK
+t/pJjz3A/wDpxXO/UxT6Z1L0WT7X+59KMyMcCQZPPeuTqmcsQce1eJ6H8aHu2/3y3wAOSK2u
+l9dQaiFZdq7+cbq0hnhPoxn6fJi7RrpU7ktwPLbVfPEpfncfvTodRWVRhhzTnZnH+oParcb6
+M1JoiTRu6gEKFHl61W39lDdoUuU3I3dQeCPTirNg4TbuYnPJbmo0i7s/uuO2RUNUbRZWi0Le
+FbRqBHEAI4UGFGO2fasV1zrOmoY4I7J9YurMs8VpG2IPGPAeQ9iF/OtjqszqGSNto2kGstd6
+bDHaxxxBYe7Ty452nsoH/wBM1k230dWNJO2YBrPUNR1NZdQCiCFfpITb4j/xMB5L5CtVafuY
+0VAFQDCgCo7rJLN4VvGXGcYbyqXBEYiVIJ28ZNZKNbOick1Ro9NAjgVTXW9s5rq0aG2d4pZA
+VVo/xDPmDXPT1KhePvUfqi4sl0i6F0s5iELeI0H4gAMgAgjBr0vTK2jxPVukz8+v7WehXKam
+On+lZ7PVZ9MaSKeK41ob4nJ3t9ErAlmJ5x2xiviLqrU9YS4fTdW0aOxniJVkaAIw/wC496+g
+f7S3UHwH1PqLUF6S0XqqPVS7/NXGqXQcyS+ZRW+pUBzjzNfMWoX11eOBc3EsoiG1PEYsVXyH
+NL1krkdv9NhxxWyE1aux+INxcWcOj9Z6bF1HpsOFiW6crdW6+kNyPrQf4TuX/DWSb700n3rj
+Wjtkze6TpGnPqcGtfDbqwQajA4ki07VisE4P8qy/3Uw8sHbkeVV/XfT8VsU1+y0qfSluW23u
+myxlTZzn+Qn8UL4JUjtyp7AnJZPbyrXdM/EnWdGg/Y+qBNa0GZDBcaZfDxIzETz4bH6omHdW
+UjB5qteTNq1oxxzSeVa/qjpXR4pDfdLXkj2dyvzFrb3LAyNET2RwAHZezKQGGPPNZFgRxjGK
+ZFV2IaKBR+dAgoooFAIKO9LigjiiwEooxmjvQDCiiigBfvSGijvQAhFNxT6SgmhAKXA70UtA
+CYpDinUh96AY2ig0UEMKTilooFQg96WikzigLoOKQ0oNIT7UyRKKKKoQU4e9Ie9HHpS7GOzS
+Z9jQPtS1JWxQaetNAxTxwKGbRQo75p1NH2p1SaoPvTcU6g0AN704cdqTApRQMKXAowQaXFBa
+Qc0tFGaBpCbaMeVPNIR/60BQg9qdxSDHFLSGtBijFFFDKoTApQMUUUBQUUUUeAsUU6minVJS
+CiiigYAZp20UqjyNBFBSQyinNTaBUFFFFADqWmg96WgoXNOFNpRSGhaMZpRzS7aVlAB504fn
+QBTselIaQq47V1j8I8MhJ8jniuQ7YpcHz7Ur2DLKC0nilX/2ez+n0lgf0r0fojT41mhmk0k2
+pDg+I0bbf69q8rju7uEYjuJFUdtrEVcaR1Ff2t1FPJfXJKMCAzllP3B4NbQmoswyY3NUj75+
+FuuxwaX4VrB1TZBnAZ4bxby2kY8D93jMYz7HA869SkluokYPPvPZg3BBr4++FvxLHhPBf/D/
+AEvWVkKqhkeSKVD6oVYYr6X6d6oOspHbt0hqOluke5RJIJYyo8gwOf1rsc1KB4eTG4TaLu4m
+LKSR254NV8jZJIzipc0kRH7xSPXbUYPGTxnk8CuGfZcegjJAGRgVLiAzmo/A7Z/KukbsCMDt
+2qGMsoztA5+1dg24c1DiJbAJqZFGeBgVDLFwM4ArtHGCdynkUgAABbjHoKPEA88c+VSWno7D
+OccHPrXdITIduApPmexqGZlzhjnNMYgjBLEexosErJF3ZIrYKFT2/FxVbNCYzySMedT4532h
+JX3p6HuK5zq6nKSEoR/EAaT2UtFG4AfD4B8uaBEhcKzsmf4gMjH2qVLaxO5dWbPmM8VwKIjY
+jcnA7Fgajo2TOiW8qjO9JFP8aD/TuKd4LzvsMSso4GO9c4JUU7o5NuPJhx+tWSBSPFjdc+np
+TSsltofp6WkZ2SJIj/zdwatJ7hFjCCMADz9aopJ4N+d6+Ivbmucstw/MUgIySRuqm6BRvZfR
+sZQxYfSew7Uwyy2y+HO5MSn6Hbkg+nPNUMeo3enKzSRptJ7SkkfkfKuUevCd94WF15AjJ3AZ
+9MdqhzRsol5F1FbyM1u1mJRG3dhz+Walpq2mX2wwMcr+OOZCGA9iayjz2ssmYB4T55XJINS2
+uHkQRm6dNp+kOu8D/UVKl9g0kaW6sLdUY/tOW0jcZ8T8QArtaaKlwEc6sLpe275DII9NyNn9
+RVRo2s3lrmK9s4NRtB+JAckDzxnkVptHsOmY2Nx0zeG1aZtz27Kcg+2D2+1awqRjJySLrTLa
+0VhZ3KrEe0bxjA/PzFbXRel9LJzCVS7l+re4BWTHp71lYrK9hCpLPG0EoLhs7wpz5+YFbHpd
+5VSK21IQsZCTFnJXj3rrxNJ0ziyptXZpI9IaR1keNVI43KMH86ynXnTx/Zk2oRkqLcEsAPxL
+kZ/716VaxoiKUQlew5z/AFpdQsFureWBkQho2UB+3IrtvycsXT2fnnJpcPT3x+6tHhj5fVrG
+06iiLt+PAMU2PYMoJ9M1w+Jt0z6BpQsnMyP4100ijKt40ncenCgflXp/9ov4fXOkXmm69BER
+Po0jLP4P/F06f6Z0HrtIV8e1Y3XFtr/T7fTkEZhj0hDGFH0nLkKR+X+dduGalj4vwef6yHt5
+Oa8nwf8AHFLhOoIzOO48u3tXnCv5V9FfHboeR9Pa9jhLXEIy5UcHbzn9DXz9Npl9axRXFzY3
+MMcw3RSSwsiSD1UkYYfY1pj6oybUlZzX6qRk9a6Q7QcGt98OPhZq/wAVOteneg+lpLZdS6jv
+DZwPcsViRghcsxAJwFVjwM8V0JctGEpqL2XPwAW5bVLlIWK7SrgZ7kGvojqe0SSwjMrDw5Yc
+qxPd/OvpT4Vf/exOkOiumJJtV+IGq6x1HeWsqQzWsa2ljBcMh8NwhDSSBWwPqYZBPAr5n6uH
+ymnR6XqCvFe6fH4NzCe8c8bMkin3Dq1Y8VTfZTyOUo6o+cuoLiXpD4gafrVscCCYeJgZyh4K
++/Br32wuohcJdQvujmUOjjzU8g188/EFxqGsQpF+ISAH9a9W6L1KSXRreGaTLQr4YJ9BXheq
+VZtH2H9OuXp9n0d8P9YWP5U+IXa2kDEZ4I8gR5+Z/KvprpnqWPqG70iSKTxJrO4uomkPZowh
+Bf7H6QPfNfDvS+r3FpcDw5CCFz+lfS/wa1tb2ySdYGRbNFiTw8YcM+Dx6hiSamEuDM/U4buR
+71c3JOqJqEkazsln4ccZGQ85JClv8Kg5+9Q20pdNW4u52LTyBnfPLuzD6nc+QwO1SbeQ7FJZ
+PEcAxlvIfzH8+1RNZuYHjn0mxkJAVfm7lucc5KA+ZPc/pXTNqtnmRVPRiYpcSXGpyncyAwhj
+2Xd2AH2H9a5Wr2+n6VqGu35d4WvEnkEa5aRUXZBbR/zM8jZJ9OfKuOsOrxSnLGKFsRxjgs5B
+5P38/YYqBezT3iWNlby7orSeKGFM4Vrjbl29goB5+9cHk9GtHo9jqssFrplxe+G841WzacKc
+Kyqx/CPJQTjPntJpbIWeuWev67rbvBp8SXJlKHaXt5mCBc/zyqu0eztVM8tol3f6q5zY6Zbr
+cSk8B5dmIokHm23HHq9Wkl1bjQtI6Z1CCYRWzDXtQto+J5xD9UER8h4ku0DPZEFa+NmUe9G2
+hBkvPl5Y44LmG2DyRJwkChQkcagdsA/oBVvpl6rSQys20XILAnyKsRmsZ0ZrkmpyP+0LYLd3
+qjxFRshnwXkVT3KrlV3eZye1at9PluHudOEmyfwI7i2bH07lmG5f0/zppa0D06ZotHPhWzW7
+RgG0leFQO23P0/0NS3X6vBwR/wBsVxjdDdEwgeHPkyf4SBUiWRWdfc9/yrVdGb7J1t9MYXkl
+QOfWpHicAgZ4qPbMGRW9DinyMFwucZ862j0c8tM8p/tCfE6w6I6cks7jWE0xbpP94ujkvHGc
+gLGo5aRyCABzjJr5ttetdOkt4ouiulri9lmUN81qGIE57fSMuxPPfFVPXOvN8VPitr/Ut5ct
+c2ljfSWGjQFv3cVtEdniKO292BJbvjA7VPvtV0zo+x8VNkEm0Dfj05x/nXdj9PFLlM5MnrZR
++GPstLOf4rFvHx0vaAAkD5SRivt+LmnL1H8Q9PmE+qdOaVrEcZJzZytCw+4bIrwfrX+1RpPT
+oeE3h3hztXOWPOapOi/7ZWkjVYvnZpBE7EOXxxmolwWuOjXHlzN8nJWfVmg9RdD9T6hHpuqW
+9x07qNxkCC5RULt5bGz4cn2BB9q3cfTM+hQxXEeoRXsL5IaIlWTHkynkGvLtD1roD4n6Hvgu
+4JxIA0ttKmQfMFWHnnGCMEVq+lb/AFno64Gnahdy6hpbKVhmuDumgU/wOf41x2bv61x5fTR/
+KKtHpYfWPJ8J6f0/J6t09qUVwgj/AI0A5bvWphEbjGWyee9YCyjSDU47+3IaG4AP0ng8VudP
+8R41LqOexFZwbWmTmirtEh4hjdk1xddwIHOPKpDbwAG/Q0xu31L38wOKJNEwRQ6pBbo4meIu
+F7KO2ftVJNpIv5Vid2O45YtlQufOtg9oWBICgHsTVXeQtEDtUlvPFYUdMZUZO8tLbTQ1vYxq
+B/HMTyfYVwijjBGccc/eraXTWuz4k52rngKw5ojsFjP0xKPUnk0KNlSnSH2kW5eeRWC+N2ra
+honSdzNp4jgVYnYSzoxjVh/ExAPAGeMHJxW9nuYNPgM15eQ2sY/jdgAK+Vv7T3UmtXvT14vQ
+Fz1VqupIxkN2t9HBZWPONyICC/l6+ten6aFKzyfUy5Pifn58Yevbwa1fCzt7BPnGc/MfK/v3
+TJwSzjK/kBXikrMzFmOS3JPrXrHXfw8+Mt3LL1B1PZXmovKSZLlphMxHuc5rym6t5raVoZoy
+jr3BrhzNylbPb9OoxxqKZwPfmmGnGmkVmUxKKPWiggutHvo7m3bQdQn8OCV/Etpif/dp/Jv+
+VuA35Hyqt1GO5hu5YL2Mx3ETFJVP8w/+neo/51ZyyHV7MO53XlmgBPnLEO33K/5fahA/kVXa
+jilPt2pVU9zVECYHoaUCntGyD6uD6Uw+1TZVUL3oI70D70uOMUFDfWjHtS0096aZLVBn0pMU
+Cl/KmSJRS4pO1AVQUUUUB2FFFFAgppp1NagGJRRRQQgooozQAUh5/KlNJQSxKUnjFFBFNiEx
+S7aD70tOwSEoxR96PyqQDnvRk+lGKMfegR0FOoFAFJnShQSKUGjHsKXtxSLQUUUUFBTh3wTT
+aWkNC0UZozTKClpOPKj2oHYuTRnNJS0hij1xRSUopjQtFFFIYUUUUAJjnPpS0UUgocPvS0mM
+e1AxSKQtAoooKHilNNHvTjQUJSYpaKAGUUGigkKUEUlFAJj6UU0UtBQ8GnCmDypympaLR0pM
+89qOTTTSoqzoDjvXWOSNRhog33rgDmlGT2NLoXaLe2Og3AVbuzu42J5aCVT/APKwq+sum+lb
+kbo9V1KNu4V7dc/51krclZATj86vdPvLcMBcSuoz3jJzVp2ZSVdHt/wm05Vube2kv2ntVbKE
+wBSv35r6c6f6bNmyTQ9QXzFTzGAuxh7HuK+Rvh91tZaVcLFBr18IQRujuYItntz+LvX070J1
+nrWsW+yd7JoFAVXgB+r/AE/SuuMlwo8j1MZe5ZvbhQq8sxz3J86ghircDAB5rp8xvADEfYGm
+IVMnfgnzrll2Zx6JcWSBnz5qSi88A9u9R+edrfau8JYdzkelQCZKiX2qYpC96ix8Z5p+Rngc
+1JoiUrBhwP6V1WGPH4cfaoaA4xkj0qQu8Dv+VQyqHNCoPfmuL7wfw5HqDSyPIB5k1wLbuNxx
+UstIcbpoSPoyM12XUIXU+JEq544qHJGuCfEY+2KjEgZIO4GldFcUydJLG2QjiMjscZFQbgQz
+FjNEgYYw8bUB3D4Hh9uxGa7pLIhP0qD6gDFF2WtEEQ5IGJVxyGxwanWe0MA1xIjdgAOD9xXO
+W9uThJJnePtjdx+lMa4FspdozGwHDg5H5r/2oWge0F2JHYm0u4GfJ/dywZH69xUWOHU1n3Xd
+pA0DZLGIef68UXMFxqQ8SHBUjJkgkww+6d6gxWt7BcqV1ORI887ifq9iPKpb2aRWiwY3tqW+
+XlnCk/Sc+IAPsaiXDG7lCyxIsinIlVBEx++Kt4FaIArPg/xDBwaltBLKu4gOBwUZA2R6g9xS
+cbDlRSRiQS/7zOwYAYym4frU5fCdM55Hl5H/AFp0kJiy0f0pnt6U2MXL/SqLOF/h4pJUDdjo
+WSCVJPEaNweGVuP+9XlpFcNHv8PxVB3qUIVl88giqeKzknYxi3ZyPqCtwR9jUy3tCGc28k1t
+Ig/g4ZT7g8Ee1NNgb3prq57eNTqMDalZKxVpYMCeHI5DJ/EPetnp3UGjXcJ+Uuobi3Y8o2UK
+HH9D7V4/o93e2kvzNxCYyeHlg5DZ8mA/Cc1rtC1/S5b35XUmCSzn93NtAYeWM9iPY1pDI12Z
+zwqXR750rcwzaarwSbxnzOTitEsKSI25QwYYGO9eMdM3mp9MXkVrPqSfKTSYV5YiBz/iXI59
+DivXNKvJLm0ja5RA24/vI2Dqw8iCO1d+HLyVM8/NhcHaMd8RugoOodMmt4kJnXLoG5Deo/P0
+r476x0O46UurDSpbFoQLExxM2T/xX+gn/DgY+9foDcCNotszl0zjcvJFeOfGD4PQdY2Fy9oo
+FzHiW3Y8cnuP1Ga7Mc3B2jmyY/ehwb/g+J/iP0vaa101qFrHGW+atZFVQpGHZQBz9/8AKtf/
+APfIdc6c6x/swfDVOkWSOLpTV7SwuLGSAwTqsmmlY3jjIy0QKkFhxkY5qXJbWnSUF3oPW+kX
+trfwtI9hq8WZYWQjiC4iPkDkrKnIzhs1X65o+l9WtBd3N1FdIkSW4beGXahyo59CTXfGUctS
+j4PEksnpW4T8/wDdH5rwW9xPJ4UMEjuf4VUmvov+yrYaz0Z8X+hOvdVSS0sdE123uZiUyRGw
+Mb59Btc161J8K+m7TUfmfkYkDEsdoAUt5Z9fWtNDp/TmmQJLM8SeCQy8eldOOLa+ZhlzxbXA
+/Qzqf44dEdJdASdT2WrWuqGDMNrDA+fEmC5UN6LwCTX5V/EPqG2Qazq16y+Pqt3cXjt2+uV2
+diPQbmPFabW+uYrhJrXT9Z07RtNhG6a8vpWYA9hshTLSNjPA/WvGfiJaaxrFla9R22mapD0z
+PK9taajqESwHUZ0Xc7Imc7QORjIHbOa5pyxYotw35Oz0+LL6mcVk14/7+zzdw+p6q9634Q30
++9b/AKcuTbfugPp2hh7YrO6Rod/fuptbZmUH8WOK9D6R6EudR1WG1ufwEhpgPJB3H+n514Gf
+Lb5M+09NBYo8V0bXo62eWy/aEzZEpMcefQdzXqnw/wBfvNInk06zmEfzMkLAsf5WzgegOefy
+rO6jps+nW+hN+zJbS3vbS6nt2aLakkMUojDL6jcGGfUGodhb3964udOhkKRSDE2QkYYHsGbg
+n1xWak6sTamfaXTl9Hqto19cXICr+IhuFAAyM101K6R7V4reIwWxH0g8NI3kT7V4x8K+oX0d
+bj575yaaUFntQ37sqe21uxOeSRxitRrvV19eRNaPJGbic+Kgj7RK3AB9gB+dWsjcdnBkxcZ6
+6G6jeR3GpLD4n7uAGWXaf7xs4RQf5RySfPFcrC7STS2geT8V0zGTOCCwAAX3I49gTVDaeLd6
+3fW9rDJMIIFy4PckhUA93JwPzrc6To1vYwIqxR3V5HFvbAB3SE87AeASeM+gqYxbZUmkjWad
+PZaVodvGRJNdkvJiNQZGlbOCM9jjgE9hzToklkg8W/hieZ2M06KxdQcYQM/dyP0rN2HjX1/C
+glwluGadgeSxP4fsBWluGu7mKSKzmjiULsZygfbnvgZwDWy2ZpUcuitW0vTOsCZ9WW8uYCzT
+rGMmR2Awox5DgYGAMV63pd8upaRZ61aq3h3MbzDd+JG3HjFeS2UYsrXx7aRpJ0UAyyRKufcA
+Vs+jOpnuFGn3FxGi26YjT8JwT3A/Oqh8XTHkXLaNxbTrOBPFjawKsP5TipzKxVOeWXA9jWfs
+7mSWaQ2yghkBdf4W54Kn1Iq7t5hIFwcrnnPcGtUjFlrasNg/SofVEklv09qdxAcTRWFw0X/O
+EO3+tOhm2uI9px/lUHrC7WHprVpGOQLC4Ix3LeE2Me+cVrjezDLtM+COjFtLDTobx0IZI2kk
+byyTk/nmvNfjR1850m7vnIWFMrGueeBkmvTOmpgOh9PN9EEluLCFSp7l2XnPvmvAvjxYSnpm
+4idSjjcpz5tt7fnXt5YKMT56E5PKkfI+t6vc6zqVxqNzJueZy3fgD0qvErKfpJB75phJB2nu
+ODSop9KzUVVG7k7s9n+A3xw1roPX7azu7yR7GZhGSzH6M8fpX6S9EdaW/VfT8VwzCSdUXaT/
+ABp25r8eVVkIYZBHYjyr7z/sldZTdQdJRWVzfSR3VojRkHOJFwcEH2PJFc8sft5E10zsjm97
+E+T+UT7c6e1CNrE2CZEYkEkLeinhl/I4NemdO3i3loo24KcH7189dC65JZSXOi38nil4ZJLZ
+278YJ/oDXrfRmuFpVwd8U6jG318q5fU4eDtHo+lyf3GNt9noTxZXb3HlXKO3y3GRj+tJDcll
+BBwD/C3eu8bhuc1xOmbK0Bi2jvuz6iqm8j3MyhB757Vbkk9zmuTQoTyvepaGnRmprViwyAfs
+cAflXAxZYhFAHYVe3UChTgBPf0qqlQR5CgnPn61UY7FKRQdTX2kaJpM99q7x+FGNxVhu3HyA
+HmT5V8H/ANpj4lw9TRTaLpUCWYkmjiSOJShZifqTw1GcgYyT2zX2H8TEl1ZE09LmK3UHeJXb
+CxheWdsc4Ffnh8ZviVL1Lr1xo3wu6CkFnl7ddbniPzN6Q37xkY4EUZYeQyQOSa7+Sxw0ckcb
+y5T5h6p1DWNI1K7gTVLknxGBVbhyg558+axV3PNcSmWeRnc9ya23Wmk3mlySQXlzazXJ5cQS
+iQL7ZHc1hpBXmTbbPfglxOB70UHvRSIY0/503NOOPWm/emjNgT710t55LeVJ4mw6HINc/SnI
+pJHGSe1MSu9Eq6t0JS5hTEM+SoHO1vNaAi2oywzMRwPJfv71aac8NnFJp9wQZrgbkJ5EEn8P
+5ntVLMHDsHBDAkNn186js1aUdjHYseeT5n1ptIeO1GaqjOxwJoyO9NJpKKCx+aQjyFIDSil0
+F2Jj9KBnvmlwKTHnVIXQE+lJSkduKCKBCUUUUAFJwKD2ptBLdC5pM5o88UUE3YZooooEFJmj
+NLigXYneloA5p22gajY2ilPFJQN6Ez50GjNGKZAClpByOaWgEFJk+opaTHsKQ2zsOacPSm0p
+PlUnStDu4pAaaT6k0ZoHY+im5zTqBhRSGgZBoAXPOaM0nfypaCkLz3paTv2oFAxaWkpaCkFO
+FNpQaTKQtFFFSUFFFBqhBSikoo62CY4mgflTaUceVSMdRRSigtCilFA9qUCgoTFFOPFNNAUN
+PHajFBNAoFoAPWjFLS0BQ3sO9KKWigYCnjvTBTh96TGjqO1BpAcUhPrUlCg4pQeaYWx9qAaA
+LGxtWumEcQyx4xmtro3ww6w1OJZ7LToCjHAMtwkf/wB0awMMjK2c9/Q1bWcdrMwa8+YCjzV9
+x/rVxryY5OXhntvTXwb6u0y8iGppocRf8JF9BIwP2ZsV9C9EaZf6bYpYXfVltfmMYEMaxRxx
++wKDk/evkLRNR0ZFWCHUL7GeYzCTn3yua9r+HMXUUkSyaQ1zNbFuWlRkUe2WAxW3JVSRwZoS
+e2z6BjWZAFLR8ejbv8q6R7GkAdTuHPFVOl/OxRJ+0JbfxMciLLD9TVtBIzYw+cnzFYtHMixQ
+nuBUiEHjmoiMfI5qVEzDGQagSJQDDvUiFQzYc8VFRs84I5qTG20EnFSWiQXiQcIv3pfGgIyV
+bI7YNQpJN3IJNNXtnv8AnUNmiRKkkU52Ej/m5qJJMQMZBpWYkYxUZ2w2MYNQzSKOwkfybbTi
+u9syxx5J/EpxUcOCd2TTiwIJTI9vWkimdlsN+Nu5mP8AK4z+hplzDcWkJLRy7R5smP8AKuJJ
+wSc4HPfmmvcyMQouJFHku84pjSZHlaeQAQPhR3UjmoFzcSvlnZgQfI9qmXMsCx/vOWGfqUjJ
+qub5Z3BTfkHuTjNZyZrFD7KeV28RtQuUX1Q7sfrzVvZPcElZJku0c4CyJ3HqPOotjDGpUEqq
+t/MM/wBavLPT3uJBiKGQxnCsjkMP6elEVYpySO1nbW4KrJviVjwUTeF/KpiWjM30TrJj+aMq
+cfcV0jsHhiXhgDyCxzXaGDYPpkZTjkLW1GDkNXTo5gUQFJO5HkaizaW44+TjlbPBT8VWirMi
+F1eVgO+Is4/So6LicXEdztC4BQHJ/Sk0giymcXVo5WazuIz5OV4P5ipUeumQoZYy7R5UPtyc
+emfOrC9sI7xg0jFHZSVkTJx6bhVVdQlSzSQhm8pIGxk+pU/6VnK0bQVlzBq+m6rdxyPYmGXh
+HltPoaQjsWU8HH61e3mlaXfq0LKrbgTtPBU+qkcishbrbzlY4rprK6XmNyuUdsf0NXMAvLqQ
+xXV4HuIuSwBKHjz8x9xS5PyaKKNJpXzulRoLbqOZlQBRHdfVlc52h/MfcV6Fouq6jp7vJEBJ
+C/HB/AcZzgf6V5fFdpJGpudNtL5cYZI5iHX3B8/zrRaVqhwgtQjFV4huxtdQPLcvf9K0xyrZ
+nkjyPYdJ19ZMiWRXAOCytuBJ/rVjHrELqd0RwcjBIIrEQvAkMd69myO6gSbW3KD9+/61P0/U
+bBCuHnU+ZIyM12wytHBkwrtFT138MYergbiextXV43AjwN5J7EHP0n/vXy71X/Zd6wsi/wDs
+9rkOkSTSALFcs4jJJxglQfL0r7U/ad6GS3SKO4BPEhjCkAfbiuNwkVywuPBV25GQ5G0/auqM
+92mc0o3HjJWfntr3wJ+PGnFopdQ0e9CAhBbNJucDHYEcDnzrCat8H/jfqEklnc2JhEfZllwj
+Dz96/S2/s47pNm1Sy5BOcAn/AFqgudEEsUoMMBjiwZJioxuzwufPFXKc5qrMceHFjd8T837P
++z78RZXImgtbVlYFpHYueGHYY5rQ3XwS6n1bU4rnqq8vNbutxEbSvlUHGeCQFUL5DAr7pk6R
+tYpJr6K1We5vDkSMmFRewAUeQGTgcse9Us3RGmyu1xd2uZPFBSNTgpGD/HjjJHJ9M4rFxnVJ
+9nVHJFO+Oz5Mv/hT+xmvbLazzWfyzwfLxbongk35dj3XBXH3qx0PR06RfU4MwXN2ZERJiuVw
+mN4U+eCT+lfSWoWmnaDYza0+0tJH4Ktj6rgrnaFH8qg8E+ZzXzvrzXWp6zFplvLGL2aUWUYk
+4HzErlpGx6KoGT6Ka482FLs7sGeU0zjr+s6j1Aw1TWrlrmx6dtGsYI1wqsoLSrbIB7sWYjyN
+UnUOseBrCWdwywvp9rbQKq/Si+JEJfpXtjDY9eKjdRXnz8babpMzx6XYSGzszkBpvrxJcHHm
+x3HPuB5VUXGm6pqtzeXVhb/P3AxP4Zf96wL7E2578YH2pP8ARtHSNz0Lrl/Petp+nySLdXIN
+tG4kOR/ExAHH4R6dga3Vtq011ctNLCQ19PcxQFThVSK3RkdieyAEk/YVUdDaTodpp/T/AE7M
+jxdTjUXhujnDrDcQOkQbnBClnXA54qZ8P11TqC6Mt9GI31i1ub+K2TOIz40CfLAnspMMoz/K
+xpxjowySu2eh9N3MEd9qOqQITOFt4re3PDTT+CE8RvRUUM59Nw8zWoim+StUFs5NxKu0ygc5
+P8o8z5Cs7pd1aR6hqngKYxql3aS7Ad22a4tlkMS+28t+nPaperanJbzyzpMAtqFQlThlGQDt
+/M1stI5Xt6LO0vI9KPh3Wnw2cPC7rmTdLI3+FQe+TyTxVxaCW+VEjYxxRsH8FiMSt2yT3HtV
+Fo8drOiJc2HiLMxBdo9+RnsWPNarStPhn3vuEzeIXEUnLBU5wmMYORWsIibo6pGspVVBUn6Z
+IGHDEeakeftUy001LieGK6gi2zICrRuclSeCCeQaqbq6tvAu76O9EEHhm8jd/wAVsJHCjd7K
+c/1zV3aeJfX0SRBWJO6BYzhWPO3Hrkg9qtxRUW60aK1GpaAsElos11pkJSB4ycyIWYAOP5q0
+2l6ilxPGYVcKxYSowwVYdv6+fvVTol693YxyNalJAVDxljhSB6/fNWEluU3vbMA5ACknGOc4
+zVpfRk34ZpBIroZYyDnzrM9ealNa6RKbZsyNHIE/5ipAP5ZzUuxvmjk2EsFAYOrDzJ7/AOdQ
+erbUS6a05+oWqvdBd2MiIbyM+4BpXWyUldM+OtLiaO0t4JYxP4eVCNycqSD+ea8/+KvTct5Y
+6jc3IbwLtvDOACyhhy4HljGM+9eoa/HqfTOpS9UXOmyTW11fzvHCrjPyjyhwykdiUZsZ9BWh
++KOhdGXOpy6b0ZcveaQ9nbXEM81x4zySSoWKknsQCnHlXv4Z4/URVeUfMevx5PSy2vJ+SfVe
+gS6F1FdafIhVTIzR5/lJq46L+G/UnXnUGldJ9J6e+o6vrU5t7O2RlXxHC7jlmICgKCST5Cvo
+f47/AANubuKS90y2IngYlT648j7Vif7KfUY6J/tJfDSbWQtrHY9SJHcNOdqok8bwkknsPrHN
+UoPE6ewU1njcT6L0X/71B1JD8P8AUdc6t6/hfqg6fcS6bo+jw+JbrdLGzxrNcvy4O0KQijBP
+civMv7JdncjS7O4YtGpzIeSPq3YKkfqMGv15g6k0SCysXbV9NGx1U/75HgkccHODX50/CjoW
+PQrW4hCFf/aepznj1vJmC/YAisocsv5eC+UcNqLu0v8Av/fo9ihS1im0/UHdoT8zbANnjY7h
+WT+uMVt/g91ALq0tA74Vbm7s/XbJBO8Z/wDuR+teayu/hRG4Ui3tX+aZycj90u4fbnFN/s/6
+zd3HRP7X8Zmkl6l1aZSfRp8kj7nNcn9QThGDfls9b+kfNZEvFH1/HatLGJo5NykZBIoWQx/Q
+/BBp3Rdw+o6LHM5LFskZ9KmX1gEBwAMnINeZKHlHoqW6ZHRgRnePQU7xOPw5PvUdSUGx12sO
+/nmk8bAwTz51ldF0OkG8klRUOayRhlh/9VTUd2PkB5Us0DlCQcE9+KuOyZa7PJ/iZZaDZ6Xd
+S3t7Z6ZGkZb5idWKEnsHCgkjntXxX8WelurNet1v+jun7vWdPa3a31HVxcpp+n2zqc+KPEwy
+gqeQFJ44FfeHV3SvTl7NFPrWhz6pLGxMEKB2+o8ZIU4/WvnX449P2ltq0kVzb6XpjNE0NtbT
+3l09xISM4t7GJTu4ABYkd66oNNUzn3GVo/OfrqL4aaHYXNvY6tY6lfHdGZNHWWWEyef76ULk
+Z9FrxC6CBjtjKgnP1elfZ3WXSPR900t18StCh6a8IiP56ZFtZmDZwq2zkyynseFDepr5q+IG
+m6Jo929v010rqYtQSIr/AFHcWmX+ZVwAAa5cuO/kuj0/T5rXF9nnTd6Q09h3phrA3aGkf1pM
+YpxBpKZmxFGTg1ZpGNNhWZwPmpVzGp/4a/zH3PlTdNtlKTahMoMNpgkH+Nz+Ff8AX7CotxPJ
+PK80rlpHO4k0PZSSjsYXJY5JJPc+9SJz81F81kF1wso9fRqiGnRyvG2V8wQR5EUCsY2RSAet
+HHaiqMw9zSGgDNBHNABRk0lJkjigVjwcUZFNBpaB3Yo9OxoNGMgUnaigCijvxRQA1vSk70pO
+aSgzlsKKQUtBK6EzS0lLQC2FApQKXB70FJAKd27UlJmgtMDzTaXdQ1BL2N7Gjv3oIzRTM6Fz
+70nelH2pMYpDDy4owfWlowPSgDp2oFJ9jml7HvSo3sXGaKBQKRQdjmnjPemedOAwMUDQEGkH
+Jp3ekxQOrFpccUmKUfegpABS0tFBaCiijNAwooooAXJpabThmlQ7CiiijyUGKKKKCaClzSUU
+mUOBpw70wdxTx3pFRHr9hS0DtRSZqgPam0pPvTe3rTExpoHeiiggcDS00Z8qM475oKQ6im5O
+e9OoAPzpwptOFJjQuaTORRRSQ2FPReeaZXaCRkzgDnuDzQ1QrO0LhCCUU1b6drRs2zGihvUo
+G/zqDB8vM4V7NmLcZjODWj0/pXSbhUa81R7AHuZk3YH/AE1UU30ZzaXZc6N1zdWR3Wt7EjsM
+A/L4I+xHavUehLrrLqeeEubhbNWwXe5fYfsDz+lYrpzproizuES16qt9QuGP0RLbSKx/NwBX
+tXQOna3LMr6dPFFCpwXZWZkA74Hb9K0Sd7OTNJVo9Q6d0pbURPcSXU5xgvIDjHtmthbw2wGV
+iY+mTVRYpKkYjuNQmlYeTA4z9qs0lYDg8f0qJdnCnZMEcY7LT1RBwGPtxUQzyZwMZ+1L47+f
+NZsomF0XkH7UCQHnuagG4JOBGT+ddY3z+meaykzWMaJe8D/LNOGztvwajidR9Pf2pGuhyBEf
+Y1BaR1lAVcq5b1FQJZpFGdmT6V0+YL58vXmlTJyVXjsTjipe+jRa7GReMwDtAxXscdxUiONW
+7McDjkZrpAwXIjcqSORng/lTnlaM4J+o8j6apKiW7I0xiiUjxl9yTjFQJ5bhuIDDLH2IEq8V
+11DWPDQwzW1rNnj95CGqibVLZtwOgaYG7bhDjj8jWc5U6N4RdWSjDeSYaK2Vx2BEqDH6mpFv
+DcphbmFY2PYBgR/So1rNE67/ANnQxDzMYIDfqasIEiJGFZfP6T2qC22iXb2ckn4HA5zyK0Gn
+W30AtLKrg53KSKjaVDaMcPqDx/4ZI8j9RWjhtxFH+5USpj6mBzW8I0cuSbeiNcx3FuVeSTeC
+OCfU+1RhcyOdluXeU9xGpb/Sp8kwUgpGGI7EjOKh/M6pkXFtfXETqcgJIVA/Km3QkrQ6L9pQ
+yCUxXMY8w0Tjn74qagE31tGpceTj6vyYc/rXH9rarKo+Y1C6kPniYgiu8VyJPxtKx8iXBotB
+TJKWRmSSOKZVuMZWN/pzj+VuxNVUltapMkbBknQ5ZJF+r/1q+hktvCKT2okwOGzg5pfn4ZAL
+bU9Pj1CyX/hyoAyHyKuOVokkyoNogXGk2t7Z5CxmRMMo27WH5+dNiMkU++FGE4XKgMVLY+/F
+X1vpulamFOj3c3igHZYzoPGz32oxIWT7ZB9qrLPUZhdG0uQY3RtsitAY5Iz6Mh5FZzhRrDIn
+oI7g3k0Z1Xp+cn8PiQttbHqR2NaeziSCPEQW5hI/iQB1H5n/ACNUgnElwciJ1IxuJZWz9x/q
+Knx3E8Wxf3qIBj6cOfz9qqKoJSs1em34tov93nuLcj6WR3LqfcA5q70/VbiblmjJXk7ohz+l
+YzTbtJ5ls1fxpGOcupTP2zV60ttCixXdnMC2CdvbPpwa1iYzNZ8340aOJ1LKBmNMrgH/AOqu
+1td6c0k7HCyeEeMnGfestaah8vK8geSIH6Nsq4G09sVIF9eSu0KaYrOhO9ifp5GfL1rojLyc
+0oeC2nvYoXj8SQFMZCBuCfPOKrW1iG5eY3UgEFum5EBwq/l5mlZbSdF3DEiKSEzhQx+1UurR
+QW8Ek9ud8oZWwuMnAwR/rVObRCxpky461sra1khLlJGQEbVOVHoT5E+nesvqnVlvE3yyWYLM
+Pqi3AAeeXY8D1xyartQeWW+QhBDkFyXOSDjGR71ktZu7WxS5jODIzeBGXG8hm5JUe3r6mp92
+Y3iiVvXvVN9fzlY5Fd4ITsjjOEUk8E+gHf1NeRTGWyu9U1WUHxYBHo9tKp5NzOPEmdc85WFC
+M+XiV6fdaPO9qRND8pazybpbiY4kmP8AngeQFZDq+2tIrPRrDToWKmC51GRmBdUkmk8IOxHd
+9kWAM8CspW9s6MbUVSPPbi3ME8sdohfxRFEi+UZ5IUfqSa9B0PRoOk9OPVGpkwvKgt2DIXXa
+cAM47ooODnsK49NdPeNqKy3lni3tN1xEJGCvLKeAWHkBV5rF3q9sFvlleUwgF4o8Sx7ex474
+IyD6+lJaVlSk5OkVuno97dzWWshbfU7G6k1LR9TjkBEN9GDhWkHDwvtxhuxNd369GiaTLJpk
+f++6dp00sKxHd4exZHdifJcMzHyGKknSPnLK71PSrFREkYjntochSj9ii+RBHaokHS9v1JY6
+7bWvgLqcnS2r3+ksilJG/wB2ZJrOTjDqVYMuOfrq1fgS4/5Hol9CNN6z0HQNKmAbXLvT5Hu4
+RvMIjs2eWVDyAZI5UAJ7c4q7vt12VtLhFtrUWDXN5LsDbvFciKNB58R59STXl3Q/VPUTWUUs
+yXGn6jpEOjPKsygJc6ZNbwxtc8//AAYjyPwsWBrcwa1pNvp8/T9pctc6hBezSxhBv2yRRZiV
+/dYwfp7ZY4rSLM3ja/0LCzvTaf3CToYZDBNA+5ShHHPvWlstaurG6huWfdGW3Okg/EoH1ADv
+uwc8HPesp1Nf6fZg6veXslwupXjTgrLh2lAUvn+Vcc9vLHeq2fqSLR7q66wlWQ6dcSRx3dqU
+JWUbMK8W7I8VSQR2yK1i+I1DmtHo+qzKp6s6fu5VuIrR5bLw45FEslpOyx5yAcFA27cQQ2B7
+1N02OKC0+RkTxtOF3DBPKgKkGEgiVSp+luxyP0qsh1+yvLPT73RpC11eafbfKi6QR3D4RjL4
+oXnGVz9zmp0dwng6hDbXUg1K0sv2lBDGiiHUSGCbT5KxZiM+YNVKSHDHKqaNxpmrIl9lGeYy
+qzHIxvUvkHI43f51srZ4niEuQytwyny/7V5jaXC2kM0ltFFAYpraGaJydhMjhB4beR3Nj71p
+tE6iaS2huAGKsSkqufqUE/QSR5HtntTx5FdMzy4G9o1N7AFzcqc7E2578E/6Vxhuob62+Tlk
+WW3uRLAz5zkEFT/2qZayxXKAg/STzxg1V3ls1jcRh2WKEbvpC8PnnPsc1rL7RzJXpnnvxD6T
+WGLxBbho0RYwh/CQBgfngCvLLjpqexY3tjDmOYhmB7xkf6V9NapaRanZGK4BORt+zeRrJ2fT
+UC3E9qSr4B4K45pwyTwz5wZObHD1OL28iPBtZ0Wx1u3/AH+zL5TsDknvXgfXP9mDSurL9r6E
+m2uFY4dBgnBODmvtTqr4SpP/AO0tBIguV+oxH+7c+fHkftXmerwXeiXCrq1hLCVJDALkMfLB
+r2cfqFnhXk+ayein6SfJdHlHwl+AU3STm61fWL++2SZgSaUsqDywOx555r12bRksIrqWFf3k
+kgkZdvHJAYDHbNTNN1J7qOS6OxERSUjBwcqP08qwXxK+Nug9GaTcyG4ia+kUDxC2ViP+rewr
+oxwcY3Lr9nPKTyTqO2Vfx762i6J6Jl0uzk33uokARqcttLYjj9cvJtH2rX/CzRp+mekenul7
+hwbmxtEW8ZezXLnfKR/1s36V4J0D091F8R+rbbr/AKwhnjsbKQT6ZZTD65ZewuJgfQfgXy71
+9i/C3oPUdSngv7iBhDuEilu7DyJrwPW+p/uMyjDqJ9h/T/S/2Ppm8n5S2fQPRNjFZ6NbxRLh
+VQf5Vd3ES7eVrlpduLO3SIDhQABXe5cBSTWfWjG7dlFfWqE5QFTVPMkinGc471obhw2SR3qs
+uo4wDtwG7+5rKcbOiEmuyNCGwA44+9WA3sNqjHFV6vsIyjYz3qyiAK5OefWjGEyh6hS5FlNH
+b3HhSMpClZTGxP8AzDlfuK+fus+iOh7eRtb1LQNQvb2bMcj6Q1zc3T7RyPGZg4X124r6G6mb
+U47YnTLC3unbj97KUA/MV4D1dpGs3Fyy3vSFhf3YOE365LbBEOchTtx3710Rg2YOSR4J1n8Q
+eguj7C/i0T4C6nbXK2hT9pXml+A8BOQWaZwZmPPfPNfEnxI+Jmg35ktB0c0tyAQLhmmhXPqF
+YZ/U19n9ddPfC2W/m0/rHS+mND1GRSDHB8Q7xJVYZ+pR4bDJIx6V4h1d0LplpGl3LpWkXtos
+ZOdY6vnu45BngqhQduDyQK1kpOFJjwSjCfKS3/J8YXLLJM8ixCMM2dgOcVwI969C+IUkV9L8
+rp19oYt4HOIrIRqM/dQC1efyKVYqcEj0rzZR4uj2E+UeRzPaulnaXV/dw2NlbyT3FxIsUUSD
+LO5OAAPU1zPpnHvXoEMMPwx0AX9yv/74axBm1iPfSbNxzMw8p5FP0juikseSMIh9me6qig0a
+SLpi0nSb5DPzcqHKvdEfWAfML+EHzwT51n8mkLeeMU0mmkKUrFyKM5OMUlKKfRAUZo58jR9q
+AFFI1L9jR7UqK8DaSnECkpktCUtFJQJaHZoNABxxSedAwpD2paQ57UCY2ig+9FBmwxSGlo5o
+ABTgMU2nUFREPHY0o7U3FKDigLA9+9J50GigGwpceuKAuaXigBpGKTPNOPNJQJhRRRQIKKKK
+AH/lS+lLjjFJQbUL2oBpvl7U4UgQ4ClpB/nS0jRC0uBTacMGgtB9qMUc0UDAcCncelJQD70h
+oDSE+eaXyxSEdqBhzS0n2FKPyoEgxTsUCigpBRRSjvSZQoGKQ06mn7UhtCUUUVQhwFOWkFOq
+S4ocKUnFIPelPak0XehrEGmZpx5pCMedMliUUUUEhRRRQAe9PFMpwPFBSHUD2pMmlHfvQMWi
+gc0hJpUOwBrrGQO+a409TQwRYwXUqLiJOB6CrCy6h1CyP7jws9/qjDH+tVFqSHDK+0+taTSR
+oZcHXIDPH5mIlX/IinG30yJ0i40n4h6vEyrc2tvOnp4e0/0r1roD4ryQXUSxxwWwY/vPFkZy
+x9doxxXm2kWXwqa7Qy3PUkKN3xHHIB+XBNexdF6D0tJLG+lWxlRV+mWaz8J/zHrWlNbbOPK4
+NdHt+h9TQ3sSm4uI/EIBGwMRz960cZWTBE4/SsTomhp9MkUhkZfIHO2tjbwzBFVsDA7+dZyZ
+wqvBMJTjJx70iuN2O/pkUwI2OXBp6qxwQMms2Whdxz5c9q6xlhkAjHnzUdt6seQBXaNgoBPO
+O9ZmyO4WQbSWAPnSsCAd0gx5YpFbOQCDntmkaMsAwyCe/nUFIUeHknA59abMQwwzk+g9KYIp
+SxbxMgfwkUk22MYLHJ9qCkcHuNp2lj9hTkvkQHfKQMYz51AuXK7ipwCeCKiSyTMpVWGWGN3n
+WblRtxssL22h1KFWh1AMQfqjMYB/LJ5qmfTzFL4Zl8PHODF3/rUOS0xNl7gSEY+kjyqdbsRH
+tZj7Cs27ZqvitFlZ25CAmTdnkADirWxVEOZoHIbge9V9hDLKv0REFT3/APWru0trhZBIIiSn
+fHb9a0gvJhOXgsLNrZSpW2T/AJsknFXkAMilLfxPtnmqmAx7Fdo40z3JXOKtbZ7RnG24yf8A
+DkVsjmk6OotJC21lMbH8ILg10bTblgfoSQHz8/1qZbXBgcSLKSR5uNwqUdRLgsSCf+XFVxXk
+nm/BRS2rR5DJtI701YXfDNH2HcVZTyW9x3hT74wa4tGT2OQeOBU0ilMiO9xvUIynyGSAPzqT
+IuULRyAORyFbg8UkmlSz/vgFKhfKVQf0NQwZLTaI5Cx7cjFKqLUk+jvC1zsBVI5ISeQ3LD0x
+V5batc3ccVtrNodSt0yschfFxCP8Enfj0bIrPi63Hd4aIT3wNuab83NFg23hA5+oEnBpKVF1
+Zr2sBNEk+leHq0ZwfBcqt5H6/SOHAx3X9KbBLaSsdkjLsba8ZHI9vUGqO1ubj6ZwjIUIOUGM
+Y8wfWr9bnTNS2vq6PKy/gvoXEN1GfQn8Mg9mH51apktuITXFjbA7oZOM5ZpSMD8xThqkpiR1
+ujsLbUJYH3rnrGkauLCe706U67YwA+JLbrvmjXGf3sA+ofdcisLD1VphJgVJISTuUtA6rwfU
+1MvjplxantG7XqElvlUdZp2O3GCRkmtVp8ks8C2Et1m+hBMcavzKmM7PcjyrySPrCE5lgcSL
+I2ANvOe3FdrXqG7kljntZPDa2IKydnDDsdw5qoTSInFtHourdXW9hbBbGeJ5XAwrPwvrketZ
+6XqvxSk4juJ5VyrbPpU574rlq2l23VFnP1b07eRLeRJv1iyiUSbSBzcxp6ebgfcedQNETWnj
+SS8mtLu3Zcxz26sq/n5Vcm7Mo01o4y6lqdzePNMwVScpHj9MsaF02YuoZH8SZsmTwcqCfPPe
+r/8AYgnfd4SNk5wzZH6ir7RtHjsVD3gMu5eI92dvvmrivsib+jzu96BvdTlaWe7W4UKdzSgq
+i89gM80nUvRa3EVvDaXws7WKOKEof3aDamAF4yTyew869WisLeJhcCNWDcxlzuK+wpsmjWV4
+Wa6/epMwaT6trKR2574+1XxTM+bXZ4pcdDpZWTzWdkLeKRAZJEBlmdh/E27t+dUEukaPeyxW
+6LqXiFgDLGAit/zEcd6921HTRh7fKGOQ4SKMHATtg+ufWshqenWwuhaabZfXEp3DGAmPXzol
+EcchhtHsdY0TUZJtOllhhjiVsXBDMkqNuVsdmUgcj8xTI2mtL9tU020mjmj8W8SKNOFDENLG
+nsQDgdvLzrWiHdbvNqV0kZKkZRd/GOxA5NVdtbwXEccNlfPKQfBI2FQoPB9xU1Raleyr05tM
+lEunau0rdO6481lpNzGw/wByhuJ901qxbmIZIaMNkfiWs90/ZX2nWlpquh6lFFrUF3qtjbQX
+OTEqNLtjmdRz4qjxAuTtIGD2qT1JcS6Lq8trBO9ojW6m5gKCWPxFbGcHjvhhWfhvojf/AD8m
+pyLKkzyh1jKZkZixPHkSTx70pTo78OFyV+C+1XqCz1KRdUs7meOx1G18ZHcAyRxQTSKFCHgN
+NKXdmJztCqOKhTa/FqMFnaTPJ4Oj2p+UVu7TuwaSVscM3kuewrMCxuShispre6iUjKJMFbAO
+QNhxwM+VORpkkBljZGJxhlIrhz55LSPd9J6GDqz1noq+l0++a/v3kmmgRZLe2JGxGJwW3dwN
+uMr2JrfStaxQXV9YZih0ezaGNQuFkg+ZjlRFXzOMf1rybpuSYKkj5HiAA85yteqabEl9bfLo
+WVQxYKTw5wMZ/pUYc8q4ofqPSKMuRu3s5H022UDN3a3qXscBbck+Z1nLfdQgAHqakWM9vDbP
+NawM8N3YkLAMhkYzmR0Hujfw9wDUOAXDi1lztO0eIMfhwCuR98/0pdMtru0X90fE26h88hY8
+o7LtfHsRjiulTcaPL9lNNNm7t9Ujt5jeQStJbXBUgHnAIHYj0IrRTpDqWmFlxIdu+M+uPKsB
+bTGC2lt1cqWGUXAwD/8AXUjSuqBpYTx3IEX0yAfhK/byNd+HOupHm5vSOSuHaNTbvuwWOQc4
+zXKe2MMq3qxDevDqO+K6s8L7Z4D9MuJIzngKauLK0X5Vll43cknzrqPPeiPaRpNECSNx5AqP
+qvSWj64hh1GwjljIx9S9j6iurxtZA4O5CcqQeRUu01SNtombY3ofOhT4szlDkjwvrz+zTLeM
+970ZqcdhKQ4AniMyrkEZVScZ58xXjmmf2IrnTNSh1XWpJdUuouVuLmTxufUKeF/IV91pLFJw
+GU57DNKbeJu4HNbZfUSzx4zejHBij6WfPGkmeB9J/Ayzsjay3IfEQG5Dwpx5Y8q9z0XSrbT4
+EiiiWMAdhUgRxR8FRila8t4e7qPzrnSjDo6JTnlfyJxljjXLNgVBur6FgQjE1AvNQUkqkoPG
+RzVM939WRIxNYyy+EaQxWW01wHPGcVAmn5LNwPcVyikMhyCG4z3p0zROhDgMex4pcrRfGjhv
+QuRH3+9WtkxKAFzVOrBX/mCng96t7aQBAT2Ip43sUx95GssTI2MEV511BY9O2UrzSdPnUJNh
+YRIvMh9Mk4H3r0C8kYxNtxxWJ6intYpg83gh1BADqDx3wM8V0xvwc8q8nnvWfVf7F0h3Xp7S
+tDURljEPCkkRDwzO6rx+tfGvxov+mdfujNfaX0Zqd26Ox+V0KTULgIPwhjLIquTgY4r3f4nf
+GD4adM6u1zqvSPUXU5t45I7qKx6b+ajh3eZcja3bA44r5213+1bBrIuNM+Efwy1uCSSNt5u7
+WK2eJRwrmZo8Kv5+1bxnWmZKFy5RPlbrnovqgmS5tOlZ7SxlH0PJo6WMYGfLBI49jXl93aNa
+zGCR42dTgiNtwz6cV7trWpaz1X1Athruq2+r61eSBIdF0e3+flZvPdMSI09Sew7kiqfqDWui
+vhq+zRdB0u+6tQFXu4pfHttMfsRG34Jpx/OBsQ/hyea4ssYt2mezinNJRaMhb6RafDeCLWup
+beOfqKRRLp+kSAMLPPKz3Q8m81i79i2BwcTqOoXuq3s+o6hdSXFzcyGWWWRss7HuTTtQvLnU
+bqa9upXklmcu7OxZmYnkknkn3qIR5GsfJcuqEpKdTaZmFKPWkAyadQAfekz5UH0JpKEFi5oz
++tGSKM0ABpMe1O4PNHegGNooNFBIoNIaPzpfLNJFCUUUlMQYFN86dSEeeaCWJS4NApaBUIBS
+5FIRzmkoDoD3ooooJYUUUUAKKDSCl+woKEopPtS0EhnFIKWigVBRRRQM6A0uKbRmg1Fx6dqU
+D1FNHfvTxjzpMpC+1LRRSNApRSUoIoKTHhaXHrQDRUmiGnFFKe9JTQgNFJkUtMApQDSUooAU
+ff8AKiiik2UhRzS4oGaWpKQUhpaQ0DY2iinAe1BIq08UynA5FBohc+goJzRSUDA0mOM+tLRg
+ZzQA2ilHJzigjmgVCUYPpTsCigKG04elJ5+lKDmgEgFKKKBxQMcPekIo3UAigBMc8U4etIef
+SlpMDvA6hxv7e1Xljc2wX+8jz/ias6DXaGVkOaSdA1yPR+nuopdLlE1neJbyAcSKFLD7ZBr1
+zpL4n3gETTXq3jKNrePGpP8AlXzjaahIvBWNvyxWp6ZspNcu0gaRosYwUkxz+fFaKTao58mG
+LWz7L6Z67026iUO0Fsz8YfAz+fnWzh1BbiISI4b3B4r5t6Q6EmjdTe9T3CqBn5bwhlh9ySP0
+r2XpmzjsUVIjOQR9ILZ496Uonnyiouka/wAaTIIjU1JjlBOCcZ8qhQSL2kHf071MQw8FVPfz
+rFgSViWQcDdTzaDuvGaSOQhSewHP5VIWaHYSfT0pUUpM4GJlHcjHnT1KryzMM+e00NLkgxnA
+8vWuckrtn6sn35rN6NYnfxUxksuRUK78WQfu4mdj24yBXOSOfdv+Zkx5InH60eBfOu3xnUen
+ByPyNQ2aKKRFe0vFP70BRjP1cCnLbw5zK3HkVHnUgwmIgkv75BoLlAXUZ44BqKLsqn0+GSTM
+MTsw7ueKm2dgq4kaFZCDwG5FS1e5cgySNz+FB5Cp8Ns4Quy4QevrTUAeStHS03qVSONAigZ9
+zViV3rkJIH8lBJ/pXOytEDqZLmID0Q7iP14qxikhhb+5nc99xfAxWyVHPJ2yuWz1Y/VBYzuo
+5O1M/wBKsrCHUfxSaXOAR5rj+hqbb65cRKqJbNgHAy2DU0Xl9cOGWWTnjBwQPzNUor7M3JiQ
+LekgHT5mx2CoR/nT5LcyMVntpFc84OBimyWk7SbtQ1iC3QDO1CZZD+mAKRZokwqyMyjscjJF
+MSOq6bLICVUBQP4pAtNa2W3X6p0U4/DG27H50NfW5YL4MjnsMycUxo4Hz9EUJHkZQ2fyFLQ9
+nOS42AhSzcd+KgAiZwVkBGOQa7NcRREgToAD2AzmuRv4N28RRsD2DxZ5qXstKhViiJ3uGbBx
++LAFMa2tnOUUrn1Gc/pS/MRyOpWBFbtiOMjdXbbAjbvDdWHrxSpDToZAk9q42SMwb6fokIA+
+6mu8s0iDaUlYvzg4Gah3N8turKsYkfIOdpJFVcuvTNN4ss53KABj6dvtUtqJrG5bLux1TXNM
+vY9R0mR4nibIKz7HX3FXd7qHRHWIL9UXEWl6tcjwReWoGZSe3j2+dkgz/Eu1vc1jTqQnjw14
+7Mf4C8Zx+R5qpu4I53IkEhLHK7cDFHucVXaB41J30yb1L0V1x0Sh1BNP0290TxMxavYBp7IE
+9hIOJIG9nGPc120+TV5Y3dtJhaU7W8W0cunbuV74+1Suk+uNe6ZuQ+lX0qqBsaKTlXXzVgeC
+PYjFbSz03ojrKdrjTzH0trjHMq231Wc7H+Jo+8Z9049quMIydxdfoynklDU1f7InRbGPVIdV
+sryGO8gViksMmfr/AJTkA4PmCKudX0FfBu+puly1lbGXGp6esmFs5j/xFHbwWJ/6Tx2xVLqn
+S+rdM6mE161FtBcsxinz4lvMw/8ADlU+ffB59qnaPrWq6Jc/P6YI3k/BLA5zHPH2ZWB9Rmtl
+r4yMbcnyiyfpGpFPDguoZdyKQ5YDGfXIq2TXLGMjdPhcY5OTiqnV7C2tLFusukonfR0YDULA
+HdJpZPc47mD0b+HODxzUci2vYzNbOI4iviPJtBCp/r7UNuGioxU9mi/bejPamS2umKoxO4MP
+pY/f/KvJ/i78T7npi7tobKX5YXIi23Eyt4OZF3IHYfgPfk8Gs/8AEHVruytYreITWtmH3RRh
+uSqn++B/iz2PpUrSLvqLW7uzi1zS49RtJ9JhsdThVlwZIpH8KZR/F+7dQfcGs/cctM6I4Yx+
+XZqvhz1p1J1EHstfeyuJrXDx3thKWilB7AqeQw9uOK30+mtJJJcpHl7j+8bAHOPM15zJdL0L
+oF/qek2AWcvGtvbhVDDaSXdfIkA4A8yab8L/AIpat1TrdxpN7ay20E0a3MDXKYY7uCF+x5P3
+NarIrUTCeDlc49Gj1HRoheLZ2ogW5ZdwG8pn86h2lmtnLOs9nDA04K+KfJj33GtXcavYWuqJ
+pWo31qtwUDxh2ADc4AViO/tUm5sIbtvmYLSGVCq/VKQ3Pn2q1vozUWuzwD4v6PPaXNrrBgEk
+N4jW7MjMxeeJNxf7Fc9v5a8Lsddvta1a10rQpkmu9QmEFpGZkjEjscKu5iAM+pIFfZHVWg6j
+qUtnPbD5ZNOMjRyACVQZI2RhsHbhuCa+cutf7MOhS2a3WjFbG7gSNhFC2Ygw45x2OfOsMuNz
+2j2vQZ4wjxkjzpde1JLtLe+tzEXGcspBxkjIPZlyDhhkHBrdaHdzNcR2wunaMgfQzbhn7GvH
+Oo4upempbTRdZvpZ/wBjW4tLSORyRDbq7MqJnsoLNjHHNXuj/E22s40uJFxMqgbgfpxXmZMc
+r0fYejw+7BNH1T09YGe1WeDYwjH1AeXlXp3TmluIEkUYwMZ9K+Mulf7WWj9K67bWXU9vG+lX
+UyQyTRDEkAJwX9wO5Br7M0HUkjjSW1uo7i2uEWWKWJgySxsAVdT5gg5p4Goy+ao5/X+nnBUj
+Z28yRGNdp4AHbyFc3mjNyWJIRG5VeKgftceLhWXaytj2JHFdLe6ieeWQDhmyP0Fd7yJ6R4y9
+O1tosZp2ZGEMZAAJBPeqe7WRrS5TBHiKcH3q2V4yBlsBm+qqjqjqK0iszaRmNJe3fnvxTtJW
+2ZqDuoos/h31sL7p4rLIGuNMvWs5o27lCAy49cV7hZRxzWqTRkFXUNXyl0LHPaXmqR/LlC9z
+4ziQFdwC9yO/c19MfDa7e+6Vt5JJDIAzBSfIA8Cuz0mVyilI8T+qenjiyNx6H3UIkd1245x2
+qmv9NuUbfEpbdyMeTCtfc2URYvjmooiIPC5BrolFSPMjKjMxz3Mf1oGVwOVbyNd49avFIW4w
+Pse9T7+yVHM8MZJOM+lVHgROmXVt0bZGO/f+tc8k4ujeNS7LGS8mkACTHDc9/wClQJS5YgnB
+PbPINMw2V2E7TnPHalZWQ+GRwpyGIxkVDbZoopdDJFmC5MbLjsQRUZgwfZLDMB5MD3/Suk0k
+qFV34Xtya7RsmERxlu+anyV0gitVIGAUGOQByakGBQo2vgeh4ohcZOcZ866EhiQT371qujJs
+5CBycbFA9c1OhiZRgt2GAKheIofaHORyB51NjkcrgRfqcGtYGcxJ8hSrAYrzD4k3JsYZb5bD
+Wbq2jG+ZNL0xryXaMfwqcjz5r0m7WQofDGODkMa8917V9X0md5E0yYxj6o7uFvpTjuxJG3He
+umK1o5pPZ41rhuL7RptU6U+HVxfJATIF6hujpI743Bn4Xk+eK8Q+JOj/ABP6wtLqP4haZ0zp
+XTSRtvuNQ1kava20Y/hghiwzuOMKp/MV631N8cOgtNS+03X/AItdFys6bpYzdRM3iZzteIhm
+JyPXFfPHxW/tMfCd5I5rXpXpjXtStUKQXN9oSXETAjsrllCD/pNaXS2yYxfLSPnbrrr7pfQ7
+O/6b+DPS11YwXatFe6iLTwrm8jxyrsvEcPn4anH8xNeA3M0k0haQ5PbA7D7V9B/EL+02vWul
+3Gg3nR+n6LFPsEo0u2ijjcKMAbVAwPz5rwDUZree6eS1hWKIn6VUY4+2a4c3G1xdns+n5KPy
+VEM00inH2pCPesjRo5nikx96cRSY5p2YtB25pTRSduKYgJpKDRQAUUUDHnQFjhR3oHbik4pW
+OhD70fnRRTEFLmkooCxe4pppaCeKBMaCaSjOaKCWKO9LTRxTgaBiGkwacRSDvQAbTSdqfSEc
+0CobR96XFKR6UCoRR70tLQcUFJDSCKSnH1po5oFQpFJThSEUA0JRRRzQSPoo96KDUUU4d6aP
+zp3lSZSHfnRSClpGgUUUUDQ4GlzSCj7UFWLRSD0paChDR9qDRj2oJD86cPWminikxoKUD2oH
+NOqTRKwooooLSCg0HPpRQJoKKTNLQAUo48qSnUFIWijGaXbQMSkNOIpvPnQAtIefOjzpMc4z
+QAoOaDS0UAN9qAaXHekxQA6ikFLQAlLQBml2e1ACDvTqNuKXHrSbHQqrmp1mlvvHiyAflUIH
+HnXWMjPfikOtGotYbJVEkKWUzDssmRVvYrq02VtLaOFT3McZCfrWRtJEVgQMketaXS9ejsCA
+5ubgN3RH2iqTsxlo3HTPTGpTXHzep6zaWVtFgsyzFpHJ8lX1r3bofUi8EcNpLPKvYO+cnFeJ
+aBqmmX/hGXSRco3rnxE/TvXs3Qf7lIhb28kEe4blZCuP1qqZxZn9nptpNOQN/p3qfE5bz71w
+tQrA42nNToUQMAq8mspHOmSrRRw31E9s1NKy4+hFGPMmuEWFAG4gV3aVewBb3qRo5P8ASfqx
+n1pv7s8gZxTmf1Q5pEaN1yWQD3NZs2iIiKDwFHr613j2oODhh59q4AKASdmF8yeKkWtvc3iG
+4toT8uPxXLnZEPsx/EfYZrOjSxwVZGJGST60w2OD4m0HHYA1JC2EYIW6e4btnG1R9vM1xluE
+T6Qy/YU6rsVt9HNV8MDEfJ8wK7RMGOyRsAtwPypI5E3DNwg9QBTxKQ2Y7xV577KtCZLSCMNj
+wgxHsTUxLW627fAVQMHzH+dQYLu4A+q+dx6Ku0V1W4XcWa4bfnz54/OmRTLCCWT6ioUcYHP/
+AHpJZpgMOshI7HyzULx435BBA8/euNxdOgIMhVfWpcqKULZPWWSXLOAzDyVs097lLWMyTy7c
+c7R5fnWde6Vn3BmGe1Qru8LAKspJAxhu1Q8hqsRpLnWYMNmRTuGUVRk1m7u9M02IbvwWcnjO
+T/SoBuYwxMgXgfhyTupVewe2Cpp87SE5LRzDH2x5VDlyNVDgDPeWpWSWcSLu4kGcf+lXFlqK
+vBvMviY5yG5NUyXmnJJ4UkEsUY4IO6QfqKlodFkkMlnbIoPfDMn9O1StBJX2aS11S0hAeKeQ
+OR/NwPb1qVJqtq2SIy29e4kyTWOmKACNYhIC3YNkj8xSpDaSje9rIzZwVMzKMemBWiyMy9td
+l3eaozLsjfDkAAAFsD8qhK8NwQUhV/5sx7Dn7U5IYUiURRG2XthRmuZUw/h3D3A5NTK2XGlo
+lpptvKA5hjBPGQMmmpE0BwSQAdoJxzUVLm48RYyzDuQDxUhGTdhjuyfaloTLG0lWy2MNMZzn
+LSrIAT+RqyD2N0GltpTHLgE7kwR+lQbWSN22rLlVUfarCGEso24/Jc/5VvE55PZeaN1t1Do9
+tNpd1Nb6npcq/Xa3Sb42H2Pn7jBqJLBp+oS7+nhc2kUkihrVZ/EEWe5Vm52+x/Woi2kqyqgc
+MT3AUir7RILKFssGjeQHsoPY1qm5aZk0o7RTXv8A+snpC5N7oTreRyEwmRYyN8R4KunmMexF
+W3Tuj3MOjzQTW6RtfTyXLQxykLAF+mNFB/hP1Nj3FayGGJwNoDjt37GnPZHZ4dsM3IB8JC4H
+i/4efP0q+ILJZh7zQodRhj03WbSO8tHz+7dOYs+aMO3NWPReiabo++we3t5rVlZInJ2vG+eC
+Qfvjipel6na68Xgh+YtJo3MbNsH0SD+BlPIPqD6U3q/VrTRtKa611YBAjInzVuQgGTjcw8uc
+cUUkjW5SdFjrvStrq1sttPGxCKQfDfDqPUH+vvVn8O+ntH0ewxf3Bu/lh4QlkhUMV+w7Ht+l
+Yrp3rO+SRRewyobS7ltbgON/iQsEaKRWXIwPqHNbbXNX+T0A6zaJEY24fa4XPODmnH7FKMl8
+GZ/rz4Pad1nDHkvqFusyzrEZWjCup4yVIJXn8P51stD6bl0LTkiSLwdoH7oN9IHbGKxvTfW+
+pzSxNYIzwTjKK42jdn8BbkA49a0dp8WoYdSGm39ixVywA4LqFOH++KacY7CWPK/j3RZGOC4u
+5Nln+/Xu0U21hn2PFV2s6THDDdLJAjyTjMjPGA7LjsSODWx3aFqMSX9rFBeQn1X6sn+oNRns
+0d5IYHWYRnKxyeQP8Pr+tDQQlR819S/Avo/qTXJupNU0ySQtFFCLdidoVSfqGe2SSOK8B+I/
+wC02Np59JiuNPkD7D4QymT2BU8Gv0B6ktxb6DcvbaeUn8PZjbyoJ7jHoapLvpnTp1Dy28TiR
+ImkCgEFsYJ+9ZSxqbPc/p/8AUsnpXyT0flxoXwG6nu/iBbQdRwxyaVDmVpFBHiY7KVPIye9f
+efw2L6N0ZpOiMzFdNia3jJ4IiDEoPyBxUD4g9KR6J1EL3wBHHckldo+k44/I1P0reiKqjjA7
+V42Z5Fm4y8H3UJY/U+lWRf5O2bCPUCU+hh3q10y9Z5MjnzrK25cLtPJ/yrSaBFuPiNzjuKvF
+cppHB6nFCGJyKfr/AOIR0a9TpLSI55tVuoRNO0KFvlYm7c/zsAcDyHNUei6lrum2sN9p+gI9
+/C0ix3t8xk8EE5BVOxfv9R7eVbuz6Vga+udYlBMt9KZZXPJJwAB7AAAVewadbW0TRxQHcBkB
+RnFenHHJvkzwM3q8eKHtxV/Zkul9G1iW4XUtWui8kpZnyMNI7Dv9ua+jvh7bpYaHDbpvGEBI
+Pk3nXnOi9PSz3EF5NHnJ4H8vvXp1pKLOBQgwANvFdXp48T5r+oZvfZaXl48P1Bdw7VAbU7Uk
++PKFx2HlVRe9Q/vDE4Qe7ZH/AKVBuLoTAeI4H2Ga3eReDgjj+zU/NxSDCNuBHAAqJe2auNwI
+U44FV+mT/SFZsY86ti/iplMHjG7zqb5dlNcWVjA7wqD618gODSywOUyzKfUemakFQqnYuCv6
+mmSMvbsD3zWbVFpkG4t3C4YKQfWozREDbkqPLHlVkW3ZRhlccHviozqxfAyoHtU0XyOVvGTI
+EZs8ZyTzmrMRoicjJPrUWLYpyCpPlmuxeV+FAx71rFUjKbtjZIkZwdv1LyDjiu6OgHfd7Dmq
++SOVWO5iR7H/AErtFPHtIdvDI9exrWHZlLZE1SSUqw3FVPcA814R8Vemvhvr1wg6jl1a0um/
+cRp+0J0hyTuLKEO3yAO4e1e26zJGsTtK5Cbckg5x/rXnPUFxrijxuk9Z0mOKZGhuoNQ0zxvF
+Hkd+Qy9z61tGTRm4pnyb8RvgTLqnzVx0/wBVjU9Nxu8G5vop1QLwq7EhDDGfvXzp1j8AevHj
+uUg6OntrODl7o6JPFEQB3R2AD/lX3X1rpSx6fEl71nqfS1zGMJ+ytKLRTYONzu6k7duf4u9f
+PvxX1X4eaHpQuH6wtNdvXIaYa1Ldys65yVMazCNBkAY21rUZbaCE5waUT4X6g+HHVFjqMltb
+6ReShe7yRCLP2QndUGb4c9X2tuLzUdNSxgIyJbqdI1/qc16X1p8RetoHm/2R0vprR7KRmydB
+Cu7gnOS0mZB9ga8e1jVtX1WfxdXvLqeUH/juSf61581FM9iLm1sr5U8KRow6ttJG5DlT9qZS
+nk0Y4rMoQimkYp1NbvTIYlAFFKBmnZFWxpz2xSU+mkZ70ITVDaUUYopi6HZ88UhxQOKD2zRQ
+7EopDxRmgli0hNFGPWgQmR5UEk0Y9qUCgBtFOwKQHFAqEpwPlQeRTaA6HGm804GkIoGGfU0u
+c8U2nAUALRRRQAUYoFLQUIfSkx2HpTsUlAqCkPalpGoBjaKKKDM6cfrSUuKSg1FFLQOBSj+t
+JlIXypaBRSNApR7UUDvQUhfKlpB9qUUikAGaXbThyaXA7UrLUTnSV0ZaaVHpTsTQlKKUDFAp
+WCQq06kAxS0jRBinYpB3p1BSGn+lJinEelNoE0J2+1LRRQIAcU4Gm0UDTOi9qcMVzDYp24Um
+i0xaae9GfU8UlMQH2pAOadSedAC0YooGRnigAxQRinc0d6B0Nop+KTb7UrCgUdjXUDFMVee1
+dKQ0qGkDFMNPb0Fc6QxckGnK2O1Mpw/OmxFnpV1apcKLxmEfnhc/0re9Py9ArLG9/rZPOfCF
+gx/U5rzaBAx4I/PirO0tpJGCo6rz5d6qMuPgyyQUvJ9D6LrHTW4Q6NpOglCcLNLBJ4i/9Oa9
+S6cu0uGaC112xMigHwmtWj3ewJ4r5r6U0q5sil5JLeE91Cdifc16dpXUvUEnhwx213dHIwGK
+jbj781vy5LaPPyY6eme8aXdShVWaMAkfizwT7Ve27llBz9+a8f0rqT4hQXkYvenohbZxhpdz
+49h2FeoaPqhu4Va7smgfHbOSPvXPJGPFouwwyDg4romSAC+c+orhGVcZR/tmpEUM0jxxRRPL
+JKdsccSlnc+gA5NZjs6q69gSxrpaWk+qtJFZwLKsP99MzBIoAf53PA+3J9qnW2k21gQ+vksc
+f+4wSDcf8Msg/CPVV59xRfa3cXUcdpHBDb2kJIit4U2xJ9lHn7nJpUvJSk/A1bfRNIO/Z+2L
+sc7pwVs4z/hi7yfduPaoOoard3r/ADF9M8zLgDPAX0Cjso+wprvIeSoJrmw3DlRj3rOT8I2j
+3bIj3jAHjk+Q5zXJSRh5Lcg+RzUmZc9nXA9qdBb5zvmUZ4HFYU7o3tUJDI+cEAZP61a26SSJ
+tWJBxkl+KhpDbxMCZgGzksxDEfYCpi6nFBGwSNZXxw0oG0e+POtIOuyJpvo6M5J2RFXI42oP
+P70G2yplubyBMd44zvYfc9hUG61i+vAF2xkKMKAoA/QcVBkknVgJJERR5KOacpoI42y0kuVt
+48QI5Hlx3/Kq6S4lnYu7E/4T2Bp0JDY3TvKO4yuMUh3BGjhAy57lN2Kzbs0SohtNcSNtiZCf
+JQfOmTTS4KbT254qY1jFJEvzF7DHsOfoBZz+Q4rh+zrJzmXULgxrgkYC5/IVDTNFJFa1reSK
+zi2mcL+XFMVrlYxAlrMjbiQcCtHDJaRr+4QjHALdz+tdvHO1mkuERF7lwAoHufKnQOb+jMrH
+qQm2O8pPfg4qdaLcu53xgt25NavR+l9W16A6jp1qkdiuQ+pXMot7NP8A+a4w59kDGpC2Pw90
+4n5/W7vqGdfxR2iGztN3/Of3rj3AXNWsUnt6MZZo9LZnR8tF/wC8yxwnzOP+1XWj6Bqet4fR
+dJ1G9UkDfFbERj33tgVa6f1ZBZMR070/oulRkY8SCzLT9+5kkLE12uNf1G+l8W7v7mduymSR
+sfkOwrWMILzZzznPwqFXoXXYHYXkemWwAyVutViXH3C5NSF6IgZSZutOm4c/+FHcyY/MKM1G
+8SaVSVg3cfiXyrm/jb+JpEJH4c8GtKj9GNzb7JMPQvThJF98S9OOc48HSp2I/NmqbbfDrpBH
+BPxCWeLB3Rtpskecf4gScetUhJcbWycn1xXSGWa3YPC4yO4PIIppQX+KBub/AMmaez6J6Yi+
+mLVek5seU0d8vHrjdVg3TumwIu2LpN1HYwavdwn+oNZS7nke3hu7U7YGOyQKeYpD2B9j5GoU
+ks34WuHDEc4OKvnFeCVCUvJsGm6RtpPDk8aCUHG6C+W6Qfk6qf61OjitWA/ZvUemIe5FxayR
+n9V3V59Y2li1yHurdX5xvJOQK2FhbWrjw4jtHkMYz+lOM78BKHHyP/a9/pV4LISadqBmJ/8A
+crkkL7sGUEVA6g0vXtShScTxW2clSqklWHIOTWosdB07Tw223LyuQ58gDUm4MQQl4izYxjni
+qSspTS6RnrCz1W61aK+1PwmvzFGk8sabTcuucSHHdiCBk88VeaxoEGr6fPaX1iJI58CSHAIY
+A55U8EVHjdUlWWRvDWL6t+cc+VW9tqM9zEfmZHDEcqV/h9ceVOi3J9ozthaQaZ8xZQCI+LtW
+UICm0+RxVpPpenXWjrYX0ng292zRyyYwMkEDn24OagatcWlnfMIFZpHAJjXtxzkitF0rfN1K
+rq8kcXyTs6xrjDZHZs9hTLd1yIXTnTkWiabYaVZzLdRQAwNcE7jIx/jJwOSMVB134e6Xf6kd
+XvrUxXSbX3JlEyPM7T3PnVhqXWtpY3klmbNtkchBbGEJHmPKr3Q9Ug1rTxeWkqyqWIaJuQre
+X5UtBc4/IyV5p3UVjBbfLXvy0CtiGW1cSHPdVI7kZ9a9B6d6g1a/WOLXtI8CaLcjTnALgdjt
+HYH71wudUs4wttdJDuDgOqjG3nj7VM8Wyggke3YKdwYkHJznml5G5OcaaO2p2r3Fo8gchQ+H
+J74zkColzaxw4cYCyJ6Yz5iu66xb+KGlRXRhg475Haqu61ElpbMsWjjbchPfyOKVG2KEujMf
+ELpz9r6OsLxlvCl8UDzwRjP29qw2l2IWGIhSv08n37V7K0vzm6WUA7hgL5Aelec9WQf7Px3q
+Ku1JF3W5xxljjGfXJrl9Th37h9T/AEn1cuH9u/8AQgWVuJkaUEjeeD7eVaTQoNrGEt+IYzVb
+pduPk4oYk3FY1GfQ4rSaTZGF1dj9Z5GeKzxYqakdXrvVJQlBlw8MSKyxZCqOKutK0zeyzumS
+wzkeYqBBaq+AV5YDJJ71rNJjEcKpg/Svau/8j43Pl1SJ1jbiJRwMgelWAZSpVeDVbLcpABIS
+AfIHioN1rrrkKpXHfHJq0+J5zTk9ErUrXx2xxjuaqpxMjCNlAAHDUx9b3/iR93lT49TBGJEB
+A9aVplpSiSbCbYylyW281pdMmWRclcbvKs7byxuQVHlwMdqtbS5RQFD5b28qcVsiey3mhAGR
+ge9VV0rodjlXBH3qXNKTF9RJB71VTSqjt4ZDEnkZycVpJaIixoYqoEa7ME49KN0jsVkQle2V
+bHFOCqwG1mB+1dooRgDgc1nRdoRLeM43Zxnj2qWkUirw/b1oUbR3De1dfEQDk49K1SM2yFMG
+J5GB/MDnFRpvwESRhwO2DUm4dFYjaefaqy+vhCoKAbhwFI71SRDZkeo+orGNmsrfVJLS6D+H
+HuRSSx7AbsA/rXh3WnxB620qK5lm0nrlYlSUyz23TUF0sAQ/jbEg3cZIxkVtfiQnQGqTG36r
+VtJvFLCDUzbyJIjEZPhvnaxHoRivGuoJNB+HkUd9P8dOsLG/uI3t7G5msPmbWYPk/TEpCylR
+g7e4xWqTrRLSPnz4of2i5bidVn6l19xKDGs6wmyhZccGS1YnDe4PFeA6v8Sra9t5bPUEsNdR
+9xZb2ASyBj5rIAHU/Y19O6p11qiutrdf2x9B1YxsJBFrvRLq4PPIDA+vke1ef9eapo/Vb+NH
+/aL6S0u8TAzDot5FE/HkPAO0eeAaTl96OnDBJKlZ81a5edGX0fj6P0xqWkXHdtt48kJPsHXI
+/WslNeTSDa7l15A38mvZtY+D6atMZ5P7R/w4v3k+pmnub+JvzDW2Kppv7Pl+bhYbX4t/C+53
+AHenUXhrz5HxI15rllbZ2qaSPKM+gxRmvWpv7M/XZwNL6n+H+queyWfV9iW/+d1rlN/Zd+Ok
+a7oeiI7wf/8ABrFjdH9IpmP9KXFk+5H7PKjSe1eh6l/Z5+OmkIZL74R9VhFxlo9Nkl79vwA1
+ltV6J6z0NvD1zo/XdObGcXemzQnH/UopUx8kUuM+dH/1UN9DFXIVh3B4P9aUDI+nkeooDvoZ
+Se9OIIpO9CJYhznNJS4/Okwc9qokXnzNJmlPFJQAUnpS0UCYmOKWigCgEgooxRQMaefOlx7C
+looFQmMCm5p9NoExKKKKBIUHjml+1J5YxSgUDDyxS0UUAGaXPlSUCgpBx60opKBQApphpxNM
+PegmQUUUUEWPz5Uo5paMelBsA5pw+1Npc1JS0OyKKbRnigqx1LnHamg9+aWgaY4faikFLQUP
+U08GuQznvT1zUtGsZCntTafTSCKQ2IPYUoGaSnDtTYIMUtFFIoBTvzptKKCkxwGe9IR709Ri
+lK8Ur2VVnEg0U5hzTaZAUUUq0AhMUvNO+9JigdCeRpaMU4CgYgGaO/anYoosdCY4wKMelLSh
+amx0IPSnAZ5pwU04J5nHtQ2NIZjHFArptNNK0rHQ3jzpe1BBFHl6UCEPNNNOppzk/wBKYCUv
+pgdqSigR1Q81Ps5ArA7jkenFV6kA1JhkIIAGKQM9C6Z6l061QQaha3U8fkEnMfP3HlXpendb
+aXGsUlpdR2USjBXPiOD58968U0PTpL+UDxv3f8e3lgPt51vtN6BluIhc9P6hBebuGCgh8+Y2
++VbRto5MkIXs9g0vqRpdj2nUekzl13COWYq9am165v7DYt9psU0bdpIJDgj79q8QsOlHju7a
+wOh3FxqN1IIbeGGPxJZpD2RFHJY+leuafB0x8M4dur3Z6k6lidWTSJP3ul6Uw/8AHMf/ALzO
+D/Ap8NSMNuNDics1GLpHsnSEN3remRa1f27aTpEhxHc3IPjXOO4gh7sP/iHC/etJ+1rayha0
+6etDYRuMSTs++4mH+J/4R/hXArx3prrbqPqK/e91u4W4mlwTKA4OPQA8KB5AYAr0G3m3oGDH
+DedZSMWtlkJHC7dykfam4LHAH51wQEnkZ/KpcOAcnI9azey46GEYH4TgdjSqkjZxGQCe5qbF
+JGpBXa4HkaHkYnLqoHmAKlotMgtbDJJUVHntySuFOParMru5xT0g3cDAFZONm6nRTC1VMnYB
+jnnzpFjB5xtHn55q0kswp3EcD1phjVSB4fHkfWp4FKVla4kf8IAXsBUd7OXeWdcFu2D2q5KR
+sAdmD5cUq2x+onk+ppcSlKiutwwH0OcDg5WujPKuMKB7lv8ASrA25IAAOfPmmrYxFsyqW9h5
+UU/Acr7IIjmkAVGjwTls/Tn9KFiQSNHI6Egc45OasmSGIDCce1TLPp7Snso+oeqLeW10m4Yi
+2jST/e9T28MIF7Rxg/imfgfwhjTUHLoTyKJR6LpN31JqNxYdPWqslhGJtQvriQR2Vih/inmb
+6Uz5KMs3kDVta3XRuhyiTTIX6s1GJwy6hqdv4WnwsP8AwLPvLjykm++wVTdR67qWsWcfT2kW
+cGl6DZymaDTrMbLWOTGPEbP1Sy47yuST5YHFZ4ia3Ad7vHOCI880nNY/x/3K9t5F8v8AY2mt
+dSa11FOs+r6pLOUG1A34Yx6Ig+lB7KAKgw2ySELIrMvkzDvVDBd3CKDGjsBgj/1q2spZZdrO
+25yfwq2MVn7nN2x+2oKkXCQR/wDhE+XBqbAg+nAPHNcbK0uWiSSXait2Hmamxqqn6n7d8Cui
+KOSbJkLJ4e0zSLnH0gU93bduVcDGFJpEUFRtHA9q6FQD9RHNbIwOAhIbJkx60qxxsNw+1SUV
+GPI9uaZLsicKWALHCKBksfQAck/ahktiQy/LkukQmicbZYj2kU9wf9D5GuOoWbWzRtEXmtLg
+FoZW/FjzVh5Mvb37+dX1r0lrLqlxqkltotu/Ilv32uw8tsK5c/nin3UfSthbtaS3N/qqSnEk
+k2IIU9GWNctwfMntVKDrYRmk9GetFgMq8s+3kKq5/WtPp+vafZRhrqSISOVVE3clv+59BVRZ
+dOdQaokq6Tb29hYof3upXTmOzjB7FT+KU45Crn7ip1ja9JdKSpPo6yaxqRyrarfqMqccm3h/
+DEPc5b3ojFrbLk09F9NrMjOWdyu0fhUY/U1TjqiGfVf2bcXq2oYHKk5kc47D09643F/CksVp
+EWnuJ3UBfxHk9z+tXGkdH6dpMtzc3UKTa1ecXly31fJw+VvEf4WbvIR/y+VaK30NKMexj38k
+cMiWaRuMhBK3l/yg9/vUbpq5vL2A6ijzeHczNBIN+CoQkMxJ554q+Nskn0wRJvPChVwEX/Si
+1tIo5Ba2gDI77TtBA3dyf/WrKUlVFJeac63/AO0oLv8A+2LIMkgehq76Klt59clgOmrtNg0s
+jKcYYsAOPWr+3sIHt5LiWzjunH4A/KjBAGce57e1Lp9jZ2N3KQskly/+8STFhysh2quPTCkg
+VaVA8lqikn6Qi/ackk91cSWkv1LE4yiP6j2Iq40XT30eOb5URGKX94viZU++Pap15qFhAwku
+p+GIjiUKSCxGfzOOajS30RhAuZlB5aJsEKkffkedCSoOUpIyupS6lJr1xGoYxIqNuVcjceTz
+VyNROTN3CREjjGW7Cu8kAEn95GDKgdWRsqykZDg+eR51HmspyFCw7kRvxp9W79KSidsZRkkm
+OtLh2VRLnPBJqzjjV3JPfvmqdFSMN9eT5kjFWMF4wA5wMVtGKWmbW/Bd21uqopXGSaxfxW8C
+Szs7BiN01wi49ADkmtAusC3TMki4A7mvJeuurIdU6ihhjmDJbg5we7H/ANKx9VKMcbX2eh/S
+sE8nqVJdLZ6DolvDBZoFUAtyauYiUKt5JyPWsf0/q4ntgFfJC8VqbK7WSNS4A2nJ5p4oRcUZ
++s5qb5dljDeyRZBXOTkeWK0GlayW+nBHqT5VQpGjAMOM1KtkMJBRvKnLHx2jycqjJGqMySgq
+SGPnUWa0QqVAAXGcjzqsgu3inX6twbggmp015iM/SFx6VDONxceiBPa/UHVvwmmeE4BwOxzz
+U6P94A3GD2NDRjcABU8R82uzrZOWRd5OMcDtV5YBJGUrgKPSqZIiBxwfarTTI2DAnO0+/nWk
+Vsym72XEgCRZyAT61R3bhWLZCk8ZArQ7U2ZChfuKqL9FSQnaCredaSRlF7IMc7L9LRluM53e
+dS4DnlABn3zVe5BICELz51Lgm2jDAA9+9Zpl1oswkxG5MCmu84XaxX9KWC8XBB7DyJp0kiY4
+ArWNGTsg3Ak2lvqYAfwisd1BNPcW04sZ/rQMSybTIgxyQrcHFa+5u4Y2IY7QOS3lWC6v0e11
+m2e6sprqLxgcXtg6GQDt9OCGB9atE2eUdQa1LPZJo19pa6zpd0oSW7nWSGWM+pRQy/mOK+ZO
+rtQs7q71DQtP13ozUYXIZ+ktWhYvKACVeCe3ClZMDvgNnivpXqKPrLRo49L17pGXrXT0AaLU
+LK9X5yJeQo8L93KrjB+pSa+eviT0/wBHao13F1Ne/EZLaG4E1uZok8S1H8OXdFmQA5HcjirX
+VUGnu9HhXUPw1086dFc61b2/T9jOm7bqHU8NxcwDGcC3K+KME4AYhvWvCerem+ntMluDo/V9
+zdRRsQvjQqpb7BWNendf9C9E37SXvTnUVzcaiFO/ffIrXeD3O9Ruf1O45xXgutWc1hfyW88E
+sRB4WQDOPyrDM9U0ej6ZXtMgtPL28Vsf8xpvzEo7Stn701qaftXOdLZ0+Zm/8Q/nQt1Mhyjg
+HvwAK4mm555oohyZcWXVvUun4+Q1/UbbH/gXckf/ANywq6svjD8UdOJNl8Q+o4sjBxqcxyPT
+ljWM5oyadEcz1Sw/tJ/Fi1ga0vNcttXt3YO8Wraba3yuR2z4sZJHtmkn+Mui6zKX6p+DHQN/
+vffK1pp0mmO3sDayIq/kteWjjmng5otlKn2j0qTV/gBrMoN70J1V03nOTpGtJdxKccYjuU3Y
+/wD5madcfDj4W6qqno/422ayyDIteotIn09k4/CZo/FiJz55ArzPJ9aXcw7E0WHGN2jfa18A
+/inpFo+qWvTR13TFG4ahoNxHqduV/mJgLMn/AFBT7V5+8ckUjQyIySIcMjAhlPmCDyKm6XrO
+raJdJfaNqV1Y3EZysttM0Tg/dSDXo1r8f9b1O1/ZnxK6W6f67s2xmTWbTF8g/wAF7CUuFP3Z
+h7UITj5R5URmm16rqmnfAHq21W46V1XqDofVnP1WOsY1LTCccBLmILNEM/zxv371lNf+GfWH
+T2nnWptNS+0fdtGqabMt3aH7yR52H2fafaqSIf7Mrj1pSKBz2wRS0AtiYApPanUmPL1oBoKX
+Ao+9HPrUsaExSEU7zpDTQmJSGl96KYmhoHrS0tFAkgooGPSlAoGJRQe9FABRRRQJdBRSUtAJ
+2Ie1Npx7U2gmQUn/ANO9LRQRR1pD2oz6ign3oN2wz7UZpKPegVjhR3pAfbtThSZSAD86dSY8
+qWkWhR96cKYKeo5oLjscB7U4CgCuirUs6IxG7aQjyrtt7eXtTXGKmy3E44p1GKUCmQlsXHGK
+TFPxRt5zQXQ0DHelC88UoAHfj86cBSsaQKKdz6UAZp1SUcnFc9pruwphHrVWS0csGnqKdgUo
+FFiSG+1GM07HNAFFlUIF9qXac808LTgp4BpWOjntowK7BPP+lIUpAcsCnqvNLjFPUe2aBpAF
+p+zyxTlXNdfDyO1SUlZHKUhXvUkx+oppj9sUBREIxTTxUho++a5FfammTRzoNLg+dFMQwjHN
+AHPanEe1J2PfvQAqipEC7iAeMnvXBa6xPsOfOgRc2FpeGZZLK5RZF/CfE2mvRuienOsOrdYb
+TYNE23NvB8xc6o90LW3s4B/xrmUHCpxx/Ex4UE1mOhekr3qwXN9PdQ6VomnFRf6tcR7kiJ7R
+RqP72Zv4Yx9yQOa9LverYDpFt0f0pppsOmLFzMlskwNzdzn8VzdP/wASU44H4EHCgdzrFHPk
+l4XZr5Pirpvw4SLRem9Mn1S5uLQ2modVXhaG9uQ34kt4/wD8HgxkAH9445YjOKl6F1/02gju
+I9W0SFQMYL7JF9sAGstpuoTgI1xZ/tCLu8V0FMuP8LHvWp0O/wCmr64Bsun5rSYdzJagoPzx
+VN2cjil4PQ+k+r+mdczb2PUtneXWfqjjDllHkOVFeg20YEakYxjyrLdJfKpbpFa3tokj/VhQ
+m5h6ds49q1caugw+B9hispo5vJKjKDBIPHpUuLwmIJbj3qAmSR9dT4oGIHINZlolpBGezL9w
+K6fLqRgOCO9cUBVgPOpCvjgilY9nMxBTySa6KBXRYw5GcAV0W3XucHny71JaZy8EyEHaSR2o
+azBGGcL9u9TUtWC5ZjGuOxGCa5ExIv0/SvckjJNKhpkRrS2X6A5LeYVSf605LVTwpK8dzXQu
+/ZJFGeck0pJBBaQEn07VJaOYg2gksDg44FBg4BxjPrT/AJiJf41A7DnmpVhDDdyM1wzLawjd
+My/ib0Rfc/0HNFWO67G6dZWcESazrNutzblytpYEkC+kHcyY5ECn8WOWP0jzqNrFxe6tfSaj
+qc5uLmYBGcgABB+FEUcIijgKBgAVNvryS6umupNqsVEccaDCQxrwqL6AD9Tk1FkQk9jk+eaT
+fhBHu2VE1lFINjjCqeeO9VlzprEn5eIjJwOOwrSNEqsTtUsRgE84rk4Z1K7j35NZuKZqptGR
+eyEcpMssjsv4gvArrb3ciy7IIQhx3NX7acjBmIwDyT55rimkK04IBX096xcWjVTTWzvYTXj7
+AzgjGDngCrKOYq4CEEeu3k/rXG2tQPpUcipy2ce7IHB7CuiFnNOrOsUmAR60+PxHJIT6fM+V
+EMDM6RpHJK7kIiKpLM3oAKu44YNHJ+e8OS8AO2EEMkB/xHsz+3YVvHZyzdHCy0aRoxdaldJZ
+Wv8ACSu6WT/kTz+54qV+37fSW29O2ny8gGGu5cSXLf8AV/B9lxVdc3cs58SWTe3Yk1Bm4GQC
+FHcLyTV3XRm48uzpc3N9ezByZ5JbhwqkZeWR2PYDuSavU0jSumSJupPD1LU15TTA+6CA+tww
+/Gw/8McDzJ7UsUkXSUXhRYbXpkInkzk2KMP7pP5ZCD9Tdx2FUhBZyrcvIdxye3vTvjt7YJct
+Lomatr+p9Rvs1K5YsmfAXgRoP5VUcKPTAqhNz8rukkjIKH8PmTVgCrEqFwg4Bx3NS7HQT1Hq
+NrYC6FmX3PcXBGRHbIN0kn3Cjj3IqLcmaqoL9HfoiCaKdtamWNp7SCW6Xd2kuFKqi+6ozgn3
+FamK6tvDKNMWVcrIxP8AeSAZb7nJyaz8uoRztrMmjwfLW0GjLBp9qOTBbLcx5ZifxO2dxPvS
+6VayQaTJqdyx3XjtaWoPO2GM7p3+7OwXPsa0TrQqvbLm21E3F18nyiuhkwvZEHf/ADFWFzqM
+mlWSz20QEzRs+0j+6QYAz7ksCag9O2rJdSajcJkG3bamP4c/SPzP+VWlxpz3FzcNflUWSO3g
+Kd931b2+wGAK0j9j1ZorZo9N06KwVSxs0YORz4rg8H8zmqK0k1uXVL95I0HiGKFWH4Q0Q+nH
+sUc/mK0GqzpFcwyFQsN3ncAOTjHI9AMj8zSWsQFmhfAKSGVufT/6CtV2TF0rKHrdrmWGwi0l
+ESaPEEIY/TEuAGY++Bj865F/9yOlyMZVuGKvJnDEHvz+RrvqFu5uI2d/3ClmwO5IyT/Uj9Kq
+ZrpY2jeZgjISfy24qXo6YbSSHxERQparIfl4SwijDZABOSB+flTpbkRyCZJGib/AcYqjOsGN
+FjUBlRmwfeoMl7d3JZ1YqOQePKhWjux4nLs0bdTMo/fsJM8Z4JqNedXMqnwNIW6Hl3jbt7Go
+2l6f8wN7wgrHyT61fWulW+FYqACOK0Sk1pnQo4oP5KzCatruo6vayzTaB1HbwW6lmNk8Uoby
+xtYAnGfWvL7x9P8AmpLmPqqW3mdi3h6ppctuwOe25Ny19R2tpGsbw4GxlKkY9axHWvQen6ra
+vGsKh2G8Edwe2cVlm9O5K7s9f0H9Tx4JOKVJ/wDfJkujJ7+S2Xwb7Sr3Pc2V6G/+VsHNek6T
+cXgjRbu1ljJHJZeP1FfNV9Yat0PqnzMSExK2Xjx9Mi+ZHvXqPR3XdrqFsklneHw2AG3cQUPm
+CK5cOXg+LNf6j6f3F7kNpntdjcAoqqTx61axNlSCcCsdo+rTPEJBNuxj8WDWltLw3BDooLDu
+O1ehzUkfJ5oOL2W0NqsjhiBx2qXJaFgQgAGMYqJazIqDeeWOftVjDPEwy3K9uKzaOKTaI4Hg
+r4YPIHY02CUs7NtzjuDVhKI5BuwDjsa5CNAAQKOJF/Z2hkWRlCqRnvmrW2IjUKvcVV2exWLm
+M4z2qxWeJ/pU8+nY1SMpE9bpSuCcCqi/XIykgwfKpgOO39ahXpR8gkqcZ4qn0QtMrnSTO4c+
+RGKelweBjkVFlGAGjY5HocGmx3EnH74OCcbZUBx+dYm5aRXDK25T27g1IF4jqSp2sPeqsSsD
+h4CD3yjEV0DxYJE2xv8AEAf1q42ZyOOuLcXVnJAq4eRSqH/F5c14B17pXxI0m5l1XpSzs5bp
+IViurO4fwxeNk/VHJkBG9Nw5r2/VNQ1ayUtaaPPqcYOdtlMniAeyuRn8jXlfUXVr6vFJpujd
+aS6Hfxysxs+pNCco+B+AiRe2f4lJrZNeTPa2j516uXqPW7eW0646g0LprqSGB/2Q9jqDtLIx
+YkwPg+G5PbDHAOMEZrwXqn43670hElrf9Qaxq8lu6g5QwxJKucxyZZijA5GMDPOK90+Jfwgk
+1K6utb+JWg3K+PGWi1fpbxZUjw2d0lrHvDjy3bVPbNfMnxPsPhg1xdJ051nd/Ptn5iS9tyRM
+McF4iA0Z8uxx3puTS0bY4KclYzUf7RHw46usTY9X/C3T9O1AxlP2xpHE8hxwZUb6H55yAD71
+4Z1eugyzmbRbmSRATh3GNw9cZOD7VD1jQr+ylMpSOeF/qWS3kEi4/wAx+dUzkjjJ9xXK5yku
+LPQjijBuUfJzfHlTaUmkqQexD3ptOP8AlSHvTIYmOaQUvNJn1qjMXvRmgUcUmWhwOaXypo4N
+PFItbG4pDTzTDnNApKgyRyCasdE6k17py5+c0LVrqxmIwzQSFQ49GHZh7EEVW0U+iFZsJ+p+
+luqCv+1XTyWF6RtbUtGjWEv6GW24jc+pXYT71HuugtRlhe+6YvLfqGyRDIzWOfHiUdzJA31r
++QI96zGPWutreXVlOt1Z3EsE0ZyskTlWU+xHIou+x0cT3IxyO49KK07dX2utL4XWOlLqDkYF
+9ARDeJ7lwNsn2cE+4rjN0ot6DcdK6iurxAbmtwnh3cfsYj+PHqhP5UUBnqO1PZGRjG4KspKl
+SMFT6EUYwKQ0hlJ504+tJTsloQ0lK3emg0yW6A8UtIe1AINBKHD1oNJ5d6KCgpKKWgnsKQ0t
+I3+VA30A5paQUueO1AIQim0+kPagGhtFO4ApMj0oJY786KB6UUFhRRRQCVCj3paTzzS0MaHA
++dLTKcP86k0Q4V0XtXMV0WkzWJ0UV3ReK4oKkJUM6orQEVzceVdSa5McUkUciKFoNIOKoz6O
+o7UY880wN6U4EZ7UqKTHe9Opgb2xTgeaQx4ozSDJGKTmkMCaTzz+dLjy9aTGPbNAB+VAPnjt
+SE80lADhzmlAzSLT1xQB0VaeF8sUIK6omaSK8DAnnTXXHapghGK5SxY8u1VRFkPH6V0jUk4p
+SnPtXaNamy0Ojjz5VKWHI7UQxgjmpiRjHkaRSIhg/wDpimmD9KsfCBo8EHsKdBaKiSA+lR5I
+cDtV69uO9Q57bA4p8SLRTsuK5t/nUqeMrnNRiPakhMbRS7a729pLcZ8FN5H8IPP6UxXRxx54
+rT9J9JQ6lay9SdRXMtj09ZSiKWdAPGupsZ+Xtwe8hHJbsg5POATpTpG31WWfVOobmbTtB0xg
+L+5CgSu5GVtoA34pn/RRljwOb6bUIutNVhtiY9O0+wjFvp+m24LRWsOc7F82YnlnPLMSTVxj
+e2ZTn4Q3Vep4+oxb6ba3EOjWOngx6dp8QIt4FPc5/ikbGWkOSx/SrPTdE6hQqtxFFKrKCkkN
+wobHr71a2HQnwzndLbU7nqqxmAw8kFtG8ZPrhsECtLpnR3ww0OQ22vdf9WWdgdpgb9ipcZye
+SrRvlCPf9K29t+TmeaK0jj0/onVcLLcQ6lZ7AMFZ2WTj0Kg5H3Fb23sFugi6xaJG45BE5EZP
+qp4P61Fsuhvhzd3LTdK/FE6ikaFhI0cdrdrgdmik8z65xUuHUenLIwpq/SPWbhh+7nvk+YiO
+feLIx/Sk41swlkUtmj0/SNN0nZqDi7eXgBIlEgx/zelel6Vepd2yPGk3AAPiAg4rDaLNpsgD
+6dp0yRN9OIixUjy4at9Y6bOkKOmUULgA5yRWUjJljFIoIPpVrbzRMAAuPU5qiCOpA3HA9DUq
+3OOA+D/WsSqLoncOD9qE3gDPNRYZCccDHrUsHJ9qllJHeJ2Xzzip9u5UHZEAf5jyar1BXu2c
+e1SY5FBwX78UA0SGG4/UclvXzo+XBGEGSPLHapMVuQolmkEaH8II+tvsP9aJzK30Rx7U9PX7
+06En9ECSMAkhcmokgbH1ZH5Zqz8OcqGaPj1JpjWzckL789qho0Torooo3dVW3LOxABbgffFS
+ri8VwLe1OLeAkBVGAzebH1J/yro8DxJub+8kGAPMD7etNNnbW8QuNVLBOyWsZ+uT/mPkP61L
+tItUzlBukDFSu1e7eQ9q6/SPpUM4HnUeTUTKQy2yxQr9KRIMKo/OuiXak55FRyRfEf4G7JA7
+0q26r3FOS6TP93njg05nDDl8A+dOxUzk8MY+nb7n1pUtw5OARgd812jCnlufT3qRtZgF7eig
+UuxW0Q44jGTlu/oKm2yj6VSFndiFRVGWZieAPc1z8NQxDN2OcVe2FkdOgW/kUrdzx5gXzgjP
+BkPo7DgegyfOrijOctHeIx6DvhhdJNScFbm4U5WAecMR9f538+w4qrkVSd5xkknGK6CMgBFG
+MdqcIGbkYOPWtbswqiEwLsQo3Nmp2lwC2ll1VlVv2ftMakcNcNnw/wAlwW/IURwFSd2OewHn
+XS8JTR7SBQN0t3cyuO3ChVGT7DP61cdOyZfRTbxGXJkaaVnZ5JGOSzHkknzJNc/F2hpJHAJH
+1N6D0ps7CTJQjGe47VBvJnHhgD92jeNK3r5Bak0SJaXXizxxphskKq+58601tvt+n5ZLdl8X
+V52tWk7COytyC+P+eUge4SsFC7aVbg7XknlOVHdivJCgepJx9q1GuahLolpaWFykUk+laZb2
+wg3fTJeNmWQuf/DjMgHqzDFEdWxyjdIttOgtvnkilCBtQia1kQnnEo2qftv2VcTWUSXdtos8
+yrbaLbLb3MmeN6/XMR93LD8qw/SF5ddQdX6fbcrD4vzN3cP+Jlj+oADyUYzj7VoY9RTqS+sN
+aeNrTR9csbvXr0yDHy6wPteEn1dyhA81cVcHoUlxls2OhbJLWW+mQogj8ZM8YH8P9MVPt4Uu
+9Vtkn/uw+W9wFBP+eKzlvr8F9BY/LjLanc29sydvDWQn6yPT6DipVt1dpp1IeDNuEe5lbHZQ
+5A/XFbJoin2be4t41d45lVjBFAgJ/h8STJH+VS73ToYLO4uGYLHDbyAj1bP/AKAVl9Z6r0+1
+1iw0h5kM1+Z7mUE4HhxRq3f7sP0rDa18RNb1zpa502TNtd30lz4gGQVBvZQiqPTw1jwfetOS
+QQxznVGi6m1+xsgtvCVkuZo422g8KWQE1gbi9vroSTXD4duQMcceX6VYmyzI0jAiOLgMxySq
+jA/KnQWJumwsTEDkk9hUN2z0sSjjRG02N3t4p5IwTJwFPIGasbKxJmSEncxJXB9PWpqWcNtZ
+RtKGXY2FXGSSewxU/TrYyzGQRYZVCqpHJPbmnF26OmOVVaJNhZLEpiQ49c+dWcUaqyt5DuPS
+uMaFXEePLdwMACpyQsE5yBwwIPBFbXRm5WTLONSm0gZJzjNOudNtpkcle/bjmm2pTcNp2tjH
+5irBELAkfY1dmU207PMetPh9BqtpJmIHAJDAdj5V4O2gXfRXUke0yJa3T7XBB2g+vtzX2BcW
+qTIUYcEYOK88636LTUrOSVUKsmSTtBz6Vxepwc/kuzu9J/UpYvhN6Ml0zr08QVXctEG2D1Ne
+naNqQHhr/E2VPFeKaS7QsY2Uhlm8MEjHbg/1r0XRbiaJonLbsMCSfXFRhbSMvW1J6PRbOQ3K
+TbjkRN29vKpgY26JJbn6WXkHzH/eqCynktnW4EgMbsYm+xPGa0FsFkDWz43ocqP9K6ezyJui
+XZztJFkk5PYnzqygtnkbAyCf86r4IyhygOB5EetXVq4j2M4znz9DVJHPOX0dEt9qAkYPamsA
+GJK58uKkySAJlSCCc1Fkk5+k1TMrYrEqMrkgcEelQ5Z35VM5H8J86Vp5kl3xHPsfOu3zdvcJ
+su4Cp9RSexrRSSsJSTGdjDOVYedR1nlQHei4yQy+R9xV1c2UMg3xyrIB5+Y9veq9beaGTa6j
+ax7e1YuLRvGaCAsCo/hPYqeKTU7r5SyeUwiVl7L23UvyaRuyxlYw3bJOKzfU2tXFhEUI8UDj
+YRnd9vetYquzOb5dHm/XXx60HpG2B6j0ee3ieVoxGJQJOM5wP4jxkAcmqPqr449F9H9HaV1s
+ertT1Tpq/k2pdwxi4gjfjMVxDKreFIOcAlc44NVHXmnwdcQ6lbW2gaF1PotuPHuU1B2gayfB
+AM8UqjO05IePt5183r1PcdCX82ndF3tpPouqo1vc9P38cVzo9/EAA4jbOXzjHqDyMEVT+xxi
+nryez6x8eP7NvxLvvkG+Jd5oN0SslnqOlGXT7mGQdvpOYn9wcA1T9YfDjqnWrGW/Pxa+GnxL
+0kqdlh1500trcMnGFGoxZKt2AO9RXyj8cPgTpMHT9z8ZPgss79MW8qxdR6FLKZLzpi6ftuP4
+pLNiQElIypO1vInxvRPib1n0y6jS9fvINhDDw5mGD9ux/MEUlk4upo1j6fmrxuj6Z+IvwD+F
+XT9n87198NOuPhdJIpMepdO6j/tFosh/mBZSCOQcCYEV5FqH9mm/6kRrz4O/E/pD4kR4/wDd
+rW+XTdTT2azuipc//a3endM/2tfiV0pctc6deNbXEgxJc2ErWbv6l0jPhSE+ZZDmr+9/tG/D
+Drxd/wAWvhFouvXsrZk1azQaNq6nGAwubUeHIR/8SI1E3CXRrCGXGeB9VdHdWdEam2i9ZdM6
+poV8uf8Ad9RtHt3OPMBwMj3GRVPjvX2RoXVXR2u6TF058P8A+0Kx0iYgnpH4t6Uuo6cHA42X
+kSuqAHIB2RkVnviT8BujLLT21/qf4f618ObWZtkPUvTV1/tL0lO/BJLKTPbg57b2I/k4xUNF
+KfhnyvTT3r0Hqn4J9ZdPaTL1RpZ0/qnpuP8AFrfT1yL22iBH/GUfvLc+olReTisB3HHINIq1
+LoYaSlopohhnzxzS5pKKTQ0HnTt1N86KKGnQ4tSZpKQf1NNIHKxaUU05HnRuNFCTH8Ypp4pN
+1GaKG3YlPileJhJG7K6nIZTgg+1MpQPOmSjSHqldVhFt1TYrqBVdsd4p8O7jH/OOJB7OD9xV
+ddadGA02m3PzcAGfwbZE/wCZf9RkVXAgcV1ilkikDxsyuvIYHBqTRM5kdvMGk479qtZpNO1K
+0B2C11CPuw/u7ge4/hf+h+9VZHt+VAmMam8+lONJTRnIaT96dTfbHNOpkhRiilBoKEoxSgCl
++1DY0huPOg08j0ppHvSTBoSlGKTFFMQH70hpaae9AmxKKKT6qDNs6UUUCg1QfkKXt60lGaAH
+D1ooFKTnmgpCU5c03z9aevvzUspDlBroopiCuqDtUtnRBHRBXUHFNUYFLUM6UqAnHNc2NPOa
+Ye1NAxhpME0p5oWmRWxO1OB4oIpO1AdDgfLNPU5xXLOe9dEz6UmNHUUY5pAeKXNSWGOKQg06
+koAYRSHPnXTFNoAQU9aaBmnqPKgDvGe1SYR5Y/SosZxipEUgUiklQ29FikY21xniXBI710Sb
+imu4NWzEgNGA3ausS5wcV0KgmnovNTVlp0dYVOBgVNjHtUWPvUpGwBmmog5HcKOKcE9qbG3G
+K7p96pIhyYzwQR2qNcW4xwM+tWKr2Ncp0Gw1VE8mZu9iwTxVa45q8v15PaqiROaykqNIO0Rz
+6edS9PtpLif6ZTCkY3SzD/hr6+59BXOKB3bamAfNjwB71MLoYxZW3ESncxPBkb1P+lCKfRfX
+3Xuo3VtbaIlvBLpNjuFta3CBsFiN8jN3Ltjk/YdhTdIh0ie+R7hZLEM3BhbKr+tUy6XflfFS
+1BUefiL/AJZqZp1vqYcCCaCHH/iSLirtvsyca6PoPoTR9WvYjHoPxR6VntgAp0vqOQI7ny2N
+jt+dep6V0mTZv+1+kNImtFB+Yk0KSS8jUDuzFMso/LivmPpvqRtKkWDVI+mr2JWyfnLUyfkC
+vNem6B1f8Irm/inuri+6audpX57pjULi3kXP+B/pYexrqU4SjR5+XHNOzeaP8L/gnqN3JPpP
+VJgvVOIoblyuGxyoYjII9GHNbS3+F8q2SRw/EVdNbA+q0QqwA9ccH9KynTui9MXU7X1h8QrD
+q2ExlZLDV9JjE8kZ8g6uG3+jjmrnStG6eNlHaaF1zqOj3sTvu0zXomurGVc5URXI/fQnHHJY
+cVk0ZOTNP09o+uabOY73WrLUEtxsW6DNmQf4gR3rSCTKggg59DkCspFbdUWJUyRwXMD9hHcr
+Kp+zjn9Rmrq2kkcYKmNx3Q1k3oVbskuPqOMZpq+IcHHHrT1znJAqVFtbGQfTFYtGidDbeRlO
+Gq0guP50rnDbQkbsDI7EVIjhVW+nue9SVZLiKNj6Ac+tT7fZHjbEjMDlWIzg1Xx5GMAZHepC
+Me4OKAasmM1w77lALeZPelEUxxvLAnyoiIByMiu6zSDnII9xTFQ0QyMMd89/aukVk7sDjdjs
+PWu0P7x9igAnufICpQKLFtUlYVPJz9Up9PtT0S3RXSIsH7+QBiMgH+Y+YX0HqaqJozcSNI+4
+sfRe1Xc5M8niOvA/CAO3tQYEYLxgeYrOSs1g+JnpYGUgAO3qTXNYJyARD7nJxxV69oWzsXz5
+OKQWBXLMDuIxk9qxcGbqeijAZWOBj0FS4YZGO4oTx3JwKly2gU7lYAkeflRFGfc7RyaVbKbs
+bAuwfUR96mL4IjYiTao7kDJb2qFJsjYbzuOcqKdHKzHk7vYCmpURKNk6yit3n8WVf3MI3uD3
+b0X8/wDKpk95NeSPNLkGQ5PqajSuIljt9wBQbnPkXP8A2HH60xJAeATx51qn4MXG9ktUAAOc
+nz9qfhB559hXFSuMnOKkRqeFHA86tGUkKpZTuK49K53cSyafbxNkrHLNuPs5Bx/Su4jGM9hX
+WOE3MctsAMhTKg8yUGTj8s1aIZRT2zuwVEXGBhR2UVyezjP0BNyg5zjuatjgqSpwHHfzNNaI
+qufXsPSgdlba2CrqltdtGskzukUYI/Cucs33xxVdrlu9/qN3czHd49w0zHyxnAH27fpWp09E
+S+VjyYoZJQPsuM/qajtpwkjCAc4C/wBO9J7Q1KnZG6WsFsbm3vFXY968iL6/LxxPn/zSYH2W
+uXTNnJc9IXGh3LlH6mlWWywc7TZqoRTnssz7k4/lFTYpT+2bYWo2pG8dtED2CnKj/PP51W6k
+/g3S29mdqaeqWtsc9jGfxfm+WqlLig3J7OGjS3Kprl3Zo3jLaQSQK3BQwygHv2IR2GPvUhIF
+S8jKuxWOTc/kPDXsP0qfNPAdS/aVvEsVrqsTTzegnJ2zoPbcN2PRhSi3Jm2jgIoT74Peiy1a
+2Vd+lxqs1trM87m6txc2SlvOG5IY/baQcexqz0Xxr0B7SMtcSQpAM8fUuMn+nerPT9FV3M8j
+gKDkn054+5rQ6HoVppEO6JHLOxxuPmxzWkWy/cSRxOhRoBbjM0hISZwTgEAHAHpU210oWcMn
+B4XjjzPn/pV9bWqQRiEAcEk+rMTyf1rr8uO23cQD38zngfrVGbyvoz9zZJtRnAGNqovmM92+
+/NXdho4SSaURHbE4jVQPxNkD+gNPeHwnJ2q0gkRWyOEHf/LJrQWMOxLWJjy5dm+4+o/6VcWD
+yyS0Vd1omJJmjXahlBHuuRx+lRLiCW1tN8vASOSQ/wDKDkD/ACFbSK18Qcjl2LHPp5VD1fRY
+LuK7jwfrWOMAHGFDbjVUOHqK7MxptvNfzPIiBUUZ47g8Z/zq4S1eN9uCNwB7dj6VZ6JpMMSy
+bFA8QYLenGB/pVtHYBlXcoyODTux5PUW/wBFB8mWXleGH4fQ1F1jSdtth48DGR7itWlmqSsC
+OUz/AFrhq1ot1ZmCQH2K9xmndow912fLfWmnXOj9RxPBmW1lJkBC8xjPOR9/OtNo8bSmSNCc
+87fy5Aq/6z0LxU8RgFuE+kepA8vzHNQOnVWDcWQnNwQM98bRXNXGTO55+WNfo1emWZuNN3OM
+FSOCPLt/qP0q90y32Xau2SFiQk+uTj/Sodm/hhbQ4BdXkyPQKuf6tVsgUmMg4DoR/wDT862i
+zglNssoxg4BGR/WmTSSCMiFsHHA96dCpChyfwjNdgi4wR25BFadmN0c7OaWWBJHUqQNsi/yn
+/tXbgkBPqx+tDukQ3bcEjBx51CkuNl0vigqNowVPBHcEUNqKBKzqZEJKlefMf9q5vbuWzDOc
+Y7Gu5jWRs5w3kR2NG5VwDgkVPZV0RQ00WQQVB88UvjBxiZe3n3H6eX5VIkZGIOfLGDUG8yij
+Z5/0oWgdMSfaY8LOsbNnaZGAUnyG7t+tfOfxM6+S2v30jVzq2mXRcsbG70/wpLhR/FFKWA2+
+Yx3r1HrbqrW9Dt2uNHmtiIgzXMdzAJUaMDngkY9M+9ZO0606Z+INpDpGqafDbXKOdmj6ldIF
+lOBu+SuWOYs54jbKnyrRxUkTGTjKzwbXYum9cjj13oj4g6zb3mneIfGtoVuFKIviSW0sLSKx
+5BCkHg/evIPiDqXwA+Ldg8en9XWvTfVM86y3D3Fg9raXsm0gtLAc+FJnA3pgk5yD3r6P1boX
+RrK/v10jpW116yskmutRtflIbbWtKUj6mubRyvzUW3/iRZ4GSPOvE+sPhZ8B+qI5XTV+n7OS
+aNGt5mE0tpJO3eEyBj4R4yMNx2xUqMkbKUWeC2kfxo+A3UcPV+kpaa3ZLG0U0+nzDULS8s3G
+2S2u4T9RidSQVde3Y8Vn/jN8KOldS6TT4/fA2KRuh7ydYNb0UuZbjpPUH/4Eh7tauf7mU/8A
+I31AFvRNf+EAsojH8PJ7J7y0GyRbDrNbiQ+ii3lKSL9vq715t0l8QOtPgH1xLqOq9I3cWm6t
+C9hrek6hbMbTWLF+JoJVI2tkZw3dTgjkA0Tx8NPo2hPl8ovf/J4gxx3phJ9a9b+Onwk0PpIW
+HxH+F17Lqvw16qkc6RcyPun024A3SabdeYmizwx4kQBhzuA8jrBpx0zdZFkVj45ZYm3RyMhH
+mpxW36B+NXxJ+Gl4bvo7qzUdNLkeKlvOVjmHpJHykg9mU1haKQ/5PpDpv+0T0TrutLrHW3SL
+9NdRvx/tX0O40m+U55Mtqv8AutyD5gqpPPNavX/gp0j8U7GfqPp+ey6mhXLTdR9F2HgapCSA
+d+pdPEgsASS01ofchjxXyGeDmrXp7qvqDpbUrfV9A1a7sLy1cSQT28zRyRsOxV1IKn7Gq5fZ
+Dj/+Oi166+G3UPQrw3F6ba/0m8dksdXsHMlpcsv4kBIDRyr2aKQLIvmorJmvqfob+1d0j1XF
+c9Nf2j+gIeqdN1aPwdR1TT2Wz1Sb+SWVwPDuJUJysjqJBgDfjNZL4q/2UOpOndHi+JXwj1A/
+ED4c6msk9jqlig/aFmiY8SG+swfEili3AOyhoyMMGAPDpP8AEltxdTPBM8c0vccUYHcc0nlm
+gBRQTikFLQNB2NGKKKViqwpp70400nNCYCc0oNFApkoWg/60A54oNAxQacDmmD2FL50qKUqH
+5HrTSe9Jx3opUVYGm048im4pohiHFJg98UpFFMgBnzpaKKB9BShsc03NLQHIXJPnSE5opM0U
+OxaKKKBNiZ703vTvypO3nQISiiiggfRzRil9xQaiY8/KjtzTvKk7UWAvajNJQBmgrYo96eOB
+TBinCpKiPXiuyHmuAOKcrdqTN4SolhuKXNRwaeGOO9RR0KVnTIpGxTd/nSM2e1CVDbAmmjvS
+EijIpmbkOJ8qSkyKN1AWOHenqea5ZpwODQUmSARTq5BqcGqS7FJ96TcRSk00mkA7IopmaUH1
+oCx4AyaWm5pMmgZ1V8GuivUcGnA0AS1mPkTXVZCeTUNHrujdsUColrmuiHnFcEOa7Jzg1SJe
+juldQ3FckNOpkHZJD61Kik96grXeNsUFVZYI4xSStkcmuKPxTmfirM2Vt8gOeKqJRgnGKt7w
+9yKp7hsPx51lIuBzkkwpVeF78edchL9X1dqDz2zxXJhzkVKNSbGttIRi4MZ/xDirCy0q4uCD
+BcW8h8g0gGf1qniPIDf0qzs42BGIhKp5wO9WjKWjU6bYapAyibTIsD+JXUg/oa3Ok9Oarewe
+MPh6dRT+e2+sn9GyDWE0XT2v3FtpWqQw3Ddre4bw2J9FY8frVsDd9NXsdr1P+29JYn6ZgjFf
+uMHDD7GtYviYzVo9H0Ky0HVLpNJ1H4e3tjNkpFLNGyFH8suDkCvWtM+H/wAbtKiawj6EtdR0
+wgYWfVAwVT2YEgnt2NecdPdX9c6rBHb6L8XrKWBFCrFLDF4oHluVlyT75raafrHU4hWw6j1O
+0vII/qjkgUwyRv68HBFP+UcclXk9D6a6Yt0mXxb290y/hb97apcl4GHpkgGtrNChVY50+pRj
+eO4968+0Lr3x5I9KvYLm7YABWCiVwPUsP9a3So7xr+/G3GQrd6zlRlK72cjHIpAYA89/UVIi
+Q89wainxFOUHI45qXbXCyAl+47gjtWLKRMiYgDPGalJk8bc/auEaqcc8mpUajgY4qWUdY4mJ
+yRipKIV4OKSMAgetdtpxSKHRkjjOM1LgjeU4BH3NRokYsMKTmrGELHH+8b6fPHdz6D296SA7
+pGixcsVizyccufSuEsrMQckADCjyAp0kgkO5uPIKOwFIiB+T39KYkhFBOTkEjy9a6qfpwPPz
+pPD2jdt5HakYgDG0/bFAznNOU4AYkc4piT7wFbLE9lHJFSUtXlJL/SB2GeTXeO2jh5Vgmf5R
+k1NNlJ0iI8BIXxAIgTjbxvI+3lXGd1A8MBYVxgL3P51PbwlJ2wrnzkbvUeSSzjZTKoLHgKox
+n7mpkqNIsgpb7+fDJX1bipVskYkVcA7QXbA4AAzTkjlkTcLcxxDuWHJp0exUmLMinZtAHuR3
+/SpikmW7aI6xXE7mSQDvnaDk/wDpXUQ+H9PBJ7Ac11UO2CGIjxgtjAp8YYLiBAAO8jD/ACpo
+h7GiJkALkDHkKkIyAYxnHrUZsDBO5iTwPU10VTH9LYB88mrTMpRJG/c3OAAMADsKdFcSWskd
+zF+OBt6k+3l+fb86jGVU4/ibgD1p6b3YHGT/AJVomZOJKvbeKK4ZrYboZh40AHkh/h/I5H5V
+wEZ4LcDvjzNSUYm2Ntu+qPMin1/mFcXzsMijBYDGfemyVa0FqVimu7w4OyyliVfTc680KMR5
+J52dvOuULfTdJg48Dn3w611aXAJIAJxuPt6UWKiPbRi2v4bh0Draf7y6nsWX8IP54NU9/Gxu
+LiVcsfmJGUeqk5H+dXbsPBdyeJCB9zmoEyLKUmcfQoZW9yP/AEqX0XHTsjaXC0yHR53BknYz
+2uTwk2OF/wCoDH3Aqfa3qyQeKoJZiBz/AFzVf4TRok6ttnMm9T/IRyP0qZfGNbt7qI/u7g72
+UDAWT+IfYnn86EaGmtbhFhYQMPolEQz2JHLN+Xar6wvFCxsecyELnufSvPrPUJYoXhVQWZ8x
+n0z3P61pbbUQzxxxMD4aBpWPlx2q1IlxNpZzKFzI2ZGGcegps2ppAtxM6lxaqZdq93IBO0e5
+xj86pbfUizQRxcSTDJJ8lHc0yxv4buZojgRswkXPdkHBNWmRRbdKPd3Wn28+rKFu7mVrmZc5
+w+OF/wCkcflW4thG4hlwAV4x6ZHNZHTUEcAK5BHKZ74J5rU2CEznY2VkUED0x3rWJnN27L+3
+iBy3BOP0GaZMinICjnNFnKOX/hZFxRISp255POf61q0Z2LZoqFkGBvH9asAMKCO4qFEF3hlP
+DcgVNwfD47mkkJs5kjcGJ7cH3HlUa4Jy2Py+1d3ye3OR5VwcBgT3BoCzJdS6NFqUOQuHHbis
+XHpEtvPAMEMJQWHr9Qx/SvU7uEksQODzVS2jrcXPibcAcjiplG3o0jkcVRnkMwkD7CSu9PsD
+/wDVV7aiU2qhh9ceWHsDVnDpESrkLj8q7paIhBx3HpT4US5WJCgCqTghwCPzFdAqrgKOP8qZ
+GuIkQd1HeuqoZFypG5T29auyKsiXKNsBUHg5B9aakG5BkYCdlxkDNT3jx9S5ORyDXPaFJCju
+DxUvZd0R8EcjgUN9a/Wv1Ds4PcU9lwwUng1DuLhIcpIxVl9vL1oSE2NklEZ+uQfftUO8vFQE
+iZVXHJbtVffaxa3EF1LFIJflIy7gd8Ad/wBay8/Utgsg+YvPBcBSduWABHn6VVJPYrZ26oWS
+9t2l0+2S4nxt+uLxIXU+TDzBr4m+JN90Wmr3Bk6M6m6RuDv/AN9sFWK0ujkhZMFWVVyOQCp4
+r3nq6TqKVTf9F9T6hiC4Dzy9N6hHNf2rMCN3ykhAniwQWUcjGRXk5/tAfEPS7WWH4naHfa9p
+9m8ts94On0uFxuIMjbcSRgjkqwII860viq8CguT5eTE6R/aBaG607pL4zX92Le3ZToXVdtM1
+rrukkYw9rdYKTRj+KF/pI4GKtPiX0TovVVnb33xJudHt7fqC4a20r4r9IxJ+ztZnIysOt6Zk
+BbpRyzR7ZO5Xf2qh1H4tfAO9srjpvVelLGbS72ZpZbe1u57NScEB7dJ98SyDPb6O2MVmOm+q
+vhp8MpNRs+kuv7zUultbT5fV+meodHS7sb6377ZlicYYc4ljw6EArg1KVLs2at3VHj3xm+C3
+xT+HL2v+2Wix39jckro/VOlTLdadqaAZCpcD+LHPhybZF8xXlFz1D1PHD+zrzVb/AMJDkQzS
+s6A+wbOK+yZm1X4d6LqfW39n3qHSuvvhJqUMbdV9H6y/ziaOjttEd3HIFaS3BwI7wBZEJAZl
+IBbz/q3+zVonxY02Tq3+znp2oWWqojXFz8PNSvFurkRgbmfSbvP++xqAT4D4nUAY8XvWcsbq
+4nVDMl+f/f5PL/g78V9N6ZuNQ6I+IdlJqnQHVgS316xiwJIWB/dXtuTwlxCTuU+Yyp4Jqi+M
+Xwp1b4RdXHQbq/h1bSr6BNR0PWrVSLbVtPkz4VxHnt2KsvdHVlPasbc29xaXEtrdQSQzQSNF
+LFKhR43U4ZWU8qQRgg8ivV/h/wBY6P1v0evwO+JOppbWUcz3HSmtTnI0S+f8Ubnv8rMQA687
+TtcDIOc1vTNJqvlE8gJxQD7VY9S9Oa30lrl90z1Hp0tjqWnTGC5gkGCjDnI9VIIZWHBBBBIN
+VmeaRPJeBc570nmKKKfgLHDNe2/2feu+oy118MtN1y6sLvUW/aHTN5DO0cuna7ChMMkTgjZ4
+qhoW8irAGvEQSPOpWm6leaTqFrqmnTmK6s5kuIJFPKSIwKkfYgUk6djpSVM9pk6w+EvxmQwf
+FHS16M6xkYj/AGp0ezxbXUpP1HUbFcAnJbM0AV/Nkc1538Q/hT1j8NZrZ9es4rjStQy2ma3Y
+SfMadqKD+KCcfSTjujYdc4ZVPFdPilYo+vQ9YWK4sOrYf2xAV7JM7EXEXHYpMHGPQipfw7+N
+PV/w9tbvQoHtNV6a1Qr+0+n9Vh+Z069A7F4j+Fx/DKhWRTyGqm7ezPil1owGKXFeyz/CrpH4
+r202s/AaWaHWI1Mtx0NqFyJb0gZLNp05A+cUD/hMBOAOBJ3rx6aGW2keCeJ45ImKOjqQysDg
+qQeQQfI0mvJUWro5YxxRRR50kDEJ9KTGRmlIo/POKYhPypT9qT7UoNMQYPlRjnNKPaj7Umyk
+GMUh/SlPakPNCE0L2oo70UNjQeVGOO1HfikBoQMSiikJApk3QtIaTPn2pM5oJFHcU6m+4oJ9
+KAHUU0GnUB2Jk+lLRRQAhOKb3oyaKCbCiiigKOg7nNLjNH2pf9POk2bpBSdqU96b6mhBIKUe
+9IKcPahiQYpQPWijtzSL6FoFJz60ooGjoproDmua04VLN4vQpOKQnNJ96CcUhthmjNNoqqJs
+dRSZozzRQWLTgc02ipopM6An8qcrc0wciigtM67hRketc8kUbqVDsfmjP3pu6k3U6Cx4cilz
+6mueacDSodnTPvSimBuwNPBpFJjwcV1jYefNcA3rTgw86QycjjsTUiNx/rVcsvq2K7JPTQmr
+LJX86fvFQBcY5yRThc8Zz/WnZNWTg4B4rqkg9arPmcef5iui3R8qLKotUkA9Kc0vGM1WJdDP
+JpxueOTVJmMlsddPnJqouD9VTZZd2eahSANyaiWyoaOUTMj70OCPXzq4s9KtNdYpZ3MdrdYy
+YZThXP8Ahb/Q1VxwSSttjG5vIDualWMaNOIZmWJs4DOPwn/SiJUmW69DdRW9sb+TRLq4tFJV
+prYB1U/4scj8xVlo+h2eFu0i1mFoz3jhWRQfetX0lonVunXMV/YalOYdmXktJgrKvqQ30uPY
+161L8N5+o9FfUrbR4eqVhjEjXnT6mw1a1J7mW05WZR57c5rpjiclaRxz9Qk6bPPdD0DpnqhP
+AvHt/m1OEjnQwlvzHIP9K3lv0TrfTWniCG9kaxkHMNzEL22/5WRuR91IrLWXwnm6oYw9P9Q6
+fqGow5E2m3kzWF/Dj0D4VzjyBB9q3/SGldc9Iva6R1VrGrWA7QGbTjOFB7AyfgkXywafF2ZZ
+Jpq0yq1D4SfDO/06HVL/AFq76anYbjJYxFrct54DfUn2r0Lo/wCGkWi6bDc2ms6p1BbSKAHu
+GTDIfMVuun+jNI15ZDq+nWlxcP8ASRE/yqTj1aLJAJ9q0eh9CaPoCy22i6I2m4P1J4pkUn2B
+7VXt72crz6orem4tLSMQW1hJaMONrRLjOfUd6tb6OWMYcwkDttGD+dS/BuImZZbuQhO0bKuP
+yxVbqMmR9RGR3rOaS0JS5OyIZFJG4YIP0sprqJirE+Z/rVJLO6S/Q3Y8VKtdRWYYY7XHGDXH
+Jm8UX9vOjgMGyDVhFn+FgPPGO9Z+KVgy7gAc54Pera1mAABbk1NlOJcwTFeG2nPtUpHRgPo/
+SqxJEyCXUHtU2Fd+QWwB3oCqLG3VMbzGQD357iuznfghOwwMDsKhox3bs8Y4A9KmRyYHbPrz
+2oFQqxMwGVNdVRhj6Dn7Ugnl4VWIHqK6CaQjDSE+VAxyjnkc10AQDjGfWuBY57+WO9PD0AK2
+f4T3pVDnsKEDO2FXJ9TUhIsDG/nzIoA4Pas4yWXj1NIIJY2DJDGXPOT5CrGKAsoITcB3ZzgU
+oiy2Iv3hPGQuBQ1Y1Kipntb2deAxGckntXW3shEjGULkkH6uxP8ArVrJDIOGdcgeRziok0IC
+8uWOfOo4JbLU21RxdbZSWkUTOpyob8K/9I4qJcTySFmLZJ7DsBXaS2cncAOfSmeDtzwoHckn
+mpZSojiaRFwnB9QK5gybh+JieOfKpO1pDtiTA9T2/wDSusVv9OFwc/ic+nt6UIHSOUELu4TI
+3HO5z5LUkMAu2LgDt6mlURrlUUsTxuNdRbuSAeAOc1ojGSsZCD5ELt5Y5/pXaeLBB7BhnHp7
+U9IURVYvhcl296RWE2Y1G9mOV+9WjNp2NSBAkikDMiYPsM5qPKqM25uI1GfvUtgSrDdliCCa
+juisAp7ZH5AUCIkoYxx5XBZixHpxXFYlED255JZXBPsc/wDapTZdw5GQpwK5MjIp5ye9IpaI
+jx7mZgOVXd2x51ylLeEEzhSdxBq0MJR3Kr/eIBUWa2VSynvGCT+VFDTI1uf3q4J+nv71aQgx
+w3DlsEvk89+2P6VAt7Vxc7CMqHj/ADyOf6mrOSIbvBwdrT7eP6f5UJFWW9qzAuQcNJCSp89p
+qw0mPxJ72ZVUCPdBFxjgAcfrVO6Si1vGjJVnQW8ZHdRjA/OtToFn4ng2+BkGWPH+JFXP+Vax
+VmUnRbWxZXVcg7OWH+daLTt0VxbsTyInyD78mqC1Q3G8FSu4NhvfuDWktRutzJtwyDg+zd63
+itmMmS55njcCH8QySKmSSeLiVPLBH/aoksLtKsikBz29/apUCnCnH0E4b2rUzsm2sZORjlR9
+NSS305Bxn/OmW58JRkZMfGfUU15fqMfocinVIm7Gu2GyDwf865E5zxweMU4nIoxzSodnPwTI
+B6edd1t1UH6fKnxDBPlmuzICuatKhXZHVBgcDIpGjRQRgU/GDhxg+VcppCARjKtwR6VDZSVn
+F7YogGOMZzXGF9jkEYqUZAycYZf6iq+4IU+JHyp478ipf6KSJc0qiNnVcHsfeufjQscltpPb
+NQWusgKHPHNNFyhB8TDA9yBS5lcSRcFkGQAy9zzVVrDS3tlKmnxo1/HGzQRSZCz4GdmRznFO
+u5dsLN4oCqC2VP8AD55FeM9V/F2+s9efRrbqW16YvoXjksDqdj4qXGf4hJnAQ+a8H3rWLpWZ
+S26KjqL4h6LDqFtMtrFb3MSPKYt8guHZMho0jYDxVb8JGTxXnfV991n0FCmtdI6zqvUnQPVS
+usFpHqEU11ZEKGltdsyFxsJ8iTtxntU7+0V1h1HpFno3xQtVttQ6ZuJTpuraMIRJFpt+/wBR
+aBk+sRzqpdSD9LKR51498Pdfn+Jc178NelOubG7t+onNxo9te2iQ3eh63EjNbSKzELcQS8wS
+Yw4DqSDitJRjJbDHy/xIHWHxbhshPruiaxJYsXMskB06ITWoGBsKv3AA7xn14rzu++PmmdX2
+Tw9S2NnqUunf3OpJJPZ3HgHuhaFwGXOOSDj7Vh+oP7R/WUepXWm9QaBaadqdnM9peRtYxpPb
+zxkpJGwIwCGBBGKxWofEddR1q216bSLCaWI7ZVSFY1njIwVdB9PIJHH3rGc1VJ9Hdjwv8mj2
+Dqz4qdFa3YC01jQL+VUUM0ctxFMwGMBtzJmRcdm7ivAerdJgiD630pqpvtKY/vIygSe0OeBI
+o4I9GHH2rrrl6ujhI7UNd6JeZksJXP7yD1iJ7hl7Y7EYNZJL+5tJzcWk7KXyGA7EHyI9KynN
+tUzfHjUXaNR8Mfi51p8JOq7bq7pHVDDcw7kljkRZIbmJhiSKWNsrJGykqyMCCO9esdadK6D1
+RoFz/aE/szxz6NDpjC76s6OtZ3aXp2Td/wC+2ZzvexZuT/FbsQCSmGHztM8czeJGojY/iUds
++1aH4b/Enqz4U9W2XWXR2pPaX1mx4wGjmjIw8UiH6XjZSVZGBDKSCKlS1T6KyQUnyj2e52XW
+Pw7/ALVlj+wPi3qFn0v8UEiSLSOuGXbBqrKAqW2sKvDAgBVvFHiLx4gdeR4D1z0N1b8N+qb/
+AKL630O40nWdMk2XFtMBkAjKujDh0YEMrqSrKQQSDXqnxX6C6R6v6S//AGgvgnYjT9GaZYuq
+emoWLN0zfOeJIvM2EzZ8MnmJj4bH8Bax6K+J/Rvxi6Wsfg98f9Qa0bTojB0r1mIjLc6E3cQX
+AH1XFgx/FHy0RJaPzUt//sZJ1uP+q/8A4Udlcw/H/ouy6UuiD8SOl7Yw6NO+M69pqDPyTse9
+xEMmIn8S5TuFrxR0aNijqVYEgqwwQR3BHrWw636J65+C3XB0LqCJtN1nTWivbO7tZg8NxEfq
+hu7aZeJInGGV1PscEEDR9e2ln8TOnJPjB0/aRw6pbMkXWFhCAAlw5wmoIo7RzHh8fhk57OKG
+/sTS/JdHldHajHcedFF2OqDtS5xSGj86KCzX6fMmufD7UdGm3NdaBONUs+MkQSER3Cewz4b/
+AJGsgRzzWh6GnZNdWzDAJqNvPYyZ7FZIyOfzx+lUDAqdpGCOD9xS7Ka8nayv7vTrmO7sriSG
+aJg6SIxVlYcggjkEeRHNewx9adI/HGBdL+K9/DovWCosdj1kIfpuiAAkWqIgzIuBj5pQZF43
+iQDjxbPpQGZTleD7VStEumX3W3Q/U/w86hm6Y6t0xrO+iVZUwwkiuIW5SaGRcrLEw5V1JBqh
+xmvTei/iNouqaFF8NvipDPfdOBj8hfRKGvdDlb/i25P4o88vATtcdtrANWc+IXw71n4d6rBa
+X09vf6dqMXzWlatZsXtNRtieJIm8iOzI2GRshgDQ42riJOtMyppMf/TNBpKEDYedFFBoEGaM
+0UCgB33ox6Un50pOKB2FFJml7VI7ENJmnGm1QmIe1NpxFIQPKggSil/SgAUCDJoIxR24FLig
+YgHPanUUlA+haKSigVjaKDxRQS+wooooHZ1HFKPSmA4p6/ekzaIuOKQg9qdkeVFIqrGhTSgf
+rS0nsKAoTnPOKUnA+9GMUYHrQAgNOHNN7U5BzQCOijjtXTFIorqyhQPqBJGTjyqDqgtHIimG
+ujdjTD2poUhtFBxScmqM2LQD70lLmgEOopuT60o70qKsdk0Z9KSil5KDJpcmkooSCx2aM5pt
+OGKQ7FpR+tJ7UUFIUOfSlD8802kzgcmgXI6hx5Ubz2zXINSg+lKhqR2VziugkqOrYpwbNFFp
+nfxMedBmOe5riGz50hJ9aQWdvG96d8wfU1GyaATToLJYuDxgmnLc5Ocn9aiZxSqx8qXQiYZt
+1CyorZdAy+hrggYjODxS7cdqQJF7p2n6fqTKLHVUs7peVjueFJ9nH+talNGs9XWLTuo7CbTd
+QyFi1GECWCQf4wv+YrzlGZSGBwR2rQabdX7jxdOnkSWPl4wxwR64rSLM5xd9nsfTHQ3XfTOn
+x6nFpiavoe8o2o6PdkNF7Sx9x+Yr0DQZdPvIo00PrnRdP1CFgsVpq6yWN6rHjMV5AdhB9Wx7
+ivLfh3191dYzNaIJ0+a2hmMRMUmOwcryPuK92hi0bXdNa3666Fm1SxYBn1CytPA1TTnx9Tpt
+/dzr7HBI8q64STjo8zMmpfIvZNM6h1PwrX43fD5NbNrECmrWSpFqyxfzJdRfTPgdi2fLNW/S
+/RHUx1CWH4ZfG201HSF2yW+l9RhlvdrDJRsDZkdsjvjNROjrXrPprTsfDrW+levumpn3JDcX
+bWskJHbtzDN6gjB969B0e9hfTvC6j+HmpaZIXxHdWdt8zGp9GK8j/mHerdPTMLktoqb74VdY
+/NJqF5daXayOwLQ5cwyc/wAy9q9A0mx1qxsxaaj8rPGoHhyRTMSo9Pq5/rVp0/M9vCI4JrQ2
+vGI1VsH3wexqVftblN5hwG80OMUml2iHJvTM7qqwFOUO4cZWstegtu+vcPQjmr/US6t4cciO
+Dzg9xWfuCHYrna2fPtXFklbNIIppoMMWV+3ka5C3BbJTBP8AEKspoT+Iofv5GuAiYDBB78e1
+YM6V0dLaQJt3tkDira1k8UEpkfbyql2ZzxnFd7eZ42yrFWHesykaa3cqRxux61ZQT7FAB471
+Q2V40yjfgMOCRxmrOGTC/UwX0z50dFVZcwzoe64xzUjxlyAHGSO2aq4pEBGSTxUyLY31qOfW
+hMTiTkYeX9a7A8eVcI8jsOK6oSSBgfeixUdQM9q6Ko82yaagHnz9q7oMnJApiOYjZjuD7AO5
+qZEYEZQWLNXBguOOQO9CyOuVjwKFoZOM6yE5LYAxyacsuMKoAx/lUBC5OXk7ngCpKj707sVE
+jfnIAz70xoGI7hR5nFLEDk+nkBXZgxUDHB75quxXRDZFDYUHA555/wDqphhQ9x39TUxYHY5I
+2r704wJjtzU0Oyt8LJz4e4DyPaldQQFkyxHkDxU14gOM8egrmYCwwMKPPHelRd2Roid4+nt2
+HvUo4xtAzg884GfSmpBs/ehTkcRjzLetdY7KSPFsg3zuMnnhAfU00hOiquJZbmfw41O0cD39
+6lRxGHKhsMOBjyNTY7eC13PFhlhONxH43/7Coc7CJHmkznnt7+VHQu9EqXw5oUvIsASkJKg/
+gk/7Ec1GaIeG31YC9z/Si0lOZYyMfTuYfb/tU1EUg5xsXa/P8WRkf1qk7IlGiFJAwj2gYY52
+/wCX/elltlLgKMA8k+1S7tSsygj8K7T7Huf6mnww+NLtH8KnmgX7IE0YVBJjhiwH5Vxe3LJN
+5Fo/DPt9W41c3VoWcRBc+G78DtwP+9IbHC3C+Y4z6MeKdElZb24FwZVxgqpA9KmxwKsqxAcl
+fF/XtXWGz2yxpjAESxn3PapccQaTxgMHGwY9M/8ApVJA2OS2RLeOTO5jM8wHrwAo/LBNWuhe
+Jb3SBcE+JPN/5hmo1rD4kNuzL9IwR9yTVtZW6xncBj+HPpnitUtmTZdW0CrdyOgIgEeEHoQM
+1bWeGgETnG/NQLJCSyjzGR/rVlbRsjQue24BuK2iYtkwDxCAe4I2mrJURQ7DG08nFRokVXCn
+tjj70j3OIgAeR3HrWvRJKLKFIH8NciSST71wSVmJXccMM10iyQB5igDomDnJx6V0xhsEYBGQ
+a5BgM57A4pzkhQe+eRQFHb8JBB5A/WlWcoSByD5Go8cxZNsg2sDxTLhiVyucj08qhyLUSRNM
+h3YOMeVRXlB8+w8qhvdsM+Jk4GPzpVcuoKtu9RWV2acaOwLJ9ajtzwahXbK5ARu4zgGpKSbT
+uVsGq7U5o2iJTCyk4/LzqlG9Et1sitOGbOeW7faoWo39xYQTXESo6pHuAGc+44rL9T9a2PTj
+Imralb2JcssUlw2xX2jJ2t2Jx5d68p6h+MK3gll0PWdLkvLdTNFBqNy9u1yAM/R4fn2AzxVL
+GHNmj6y+LWoaDfi3tnEEkoVRFdWpuFk3AkY2kEfnXhnxO+OlsjQQSNp2nmCB1uLTVdJkltpz
+nko3LJ5eZXJqq62/tI9Ma0qwdedOXtpfIm2O90fVws8Bzyy7lAP2IOcVgda1TqPWrr5v4cfG
+nQuuLAxMn7M6stks7+MHBKZwY5PQFSPtTt9eDSONaZfdGfE/pbUb2bpPq3V7KDovrWMaNrDW
+85aCwZ2Py2oRgf3bwSlSe2ULA18y/EDoXrD4a9Yatocmoz6VrfT1/Ja3MMnBhuIiGWSKVScq
+4KSI3mrA5NWHWxu9NW5bVejNQ0C5lDLIsMHzFk6nIOGXkA8+tan4pzH43/2btF+PGnSibqz4
+czW3RXXTwsd1zp5BGkai4AxkLm3diSSVXPanXKNGyrHLl4emRfj9Nofxh6H6W+PWqabDYatr
+yt091JqVqvMfUlogJkuFH4ku7cxyhvxb0k7818wXUM9jcNBKw3oeGRsq3uD5ivcv7OJPxE/2
+0/s+X8oeTr/R2m6fMjYEfUVgDcWWD/CZUWe2yOT4wFeFbyRsuAwXnIIOUPmPyNc8tpM6ofFu
+BJi1m5FlLplwfEtpiGKnkqw7MPeq9ie2c+lLNGYnwsiyLjIZf9a5bs+dIbYuTRknvSUUybZt
+vhN8VNc+E3VadQaVFBeWk8UlnqmmXaeJa6lZyDbLbzJ2ZGUkexwRyBVv8XPhvpOgWunfEn4c
+TXF50B1HMyadLK++fS7tRuk066I7SoOUbtJHhhyGC+YE16P8H/iXpnSk2odHdc2M2qdDdUxr
+a63YxMBLFg5ivLcnhbiFjuU9jyp4Y0L6ZEu+S7/5NV8PPiR0t1/0rbfBH423rxaPA7npvqMR
+mS66auX7kDvJZO3M0H/WmHHOdjtOsP7OHxRfSurNIguU8Ew31sr+LY63pNwuC8Mg4lhljO5H
+HYgdmUgZ74nfDvUPhl1Q2izajBqmn3MKX2kata58DUrGTmKeP0PdWXujqynkVueheudA+InS
+EHwX+K1/HbQ2rO3SfUc2S2hXLnLQykctZStjevPht+8UfiBfWhJJ7X+piPih0Pb9F9Qp+xbq
+S96c1iBdS0G+Yc3Fk5O0N6SRnMbjyZDxgisaePKvX7XQddjXVv7O/Xtn8hrmnXj3WhNKQRBf
+lQWgVwcGC5j2lWBKlvDYZBzXkcsUsMrwTxPHJExSRHUqysDggg9iDTJvVDMmj/OgUcetAE3R
+7k2eqWl0P+DOj/oa53203c5QYUyuR9smo6ttIbzFK7lssecnJqei71Q05opCaKokXOO1bvo7
+4ixWWg3XQHWVm2rdK6hJ4xhz+/0+6xgXdqx/u5BwGH4ZF+lh2IwdLTTadoXemWnUWgTaBerD
+8xHd2s6+LaXkQPh3MXkw9D5FTyCCKqq2fSOuWN9pNz0D1K6Jp99IJrG7YAtp95jCyA9/DYfS
+6+Ywe6isrqem3mkX0+mahCYri2cxyJ3wR6HzB7g+YNKwaojUZ9aSlFAkxcZ5pPOl796SgYo7
+ZpfPypPvzSGgBfPzxRSUZoAUmkJopKAYZ9KQegpD9qBQQOwKTzoPpmgccmgYY86WjPnQD60A
+LSZ5pAaXFAC0UUUDQhGaTb706igGkIAKMD0paKAoKUGmZxThQCY8GlB8qYDmlqTRMfR3pueK
+AcUFWOzTWIoJ9qQn+tAmwz709D/nXMU9aKCL2d1NKW9K5BvKgtmpo6FI6E880003NLk06C7C
+igmkzTEFLRRQAUoxR296AaAFooopdFBRRRRdDClA5xRS4qQFoopcY70FiGmmnYpNtBLTE7Uo
+PrSYxSge1AhaPOk5paC7Hg0vcYpi06goKKcBkYFKFPkKVgIBn86cke84B596VVOR5VfaLDpe
+pt8pqkUsDnhLu2ALIf8AGh/GPtg0K3oHrY7Q+k9a1pcaMomuc4W3zh5P+XPBPtXC+tr7TL19
+O1zSpba7jO14J4jDKuPVTzV9faD110bCmtWLyT6aWxHqFll4SfR+MofZgK9H6V+PnXer21ro
+/wARehdC+IGixgIv7Y08vcwp/wDDuY8SLgepIrVRi9PTOdzndraPK4NI6RvolDdQXWk3B423
+tqXhJ/8Atidh9xVxa/DfrO0jGq6FDbazajnxtMmWfH3QfUP0r3KbSvgfqMmzVekNQ6UsJF5v
+7NzqNkrEcHYcSIvsw8u9aLTv7MOiXlqepPhh8T9E1KHb4y3GjGWGWPHYNFk859O1aRw8ujGX
+qVHs8M0Hrm10UmHXdHuYZfwybQYZoseaq2AftXtvw96+0S4EM/S/XWtPKrAywuSxJHk0L5Df
+rVlYdLfE6aKS31OHozq1rVtjW+qxvb3+M4BWZk2N+dMu+jNMv76PRNdtdf8AhpqUjfTaXkLW
+1vIT5x3MaNGR75rSMJQ0zmyThk2j2XpzXdC10m7vNM07TtST6JdQh0w27XDY4WWPA/Xmr+x6
+f1Gyvv2k2valD42XFvFP/u7qfILyNuPKvLtD+FPxQ0az2aH13bdUWRwTYavfC43J5+HOgDIc
+dieK9P6R0fWNKBtL2xuNKjH1GzmnW5jz/gYHj/6cVrt9nLJqPTNPpogUMse5Gz22lP8A66kX
+V00UJUk813h3uv7yFJAOAVOD/WqzUpDseMI6em4VEnSJTtmW1Z8zMykHcfI1SSzMSQwOR796
+tb+A5ZyASf5aoLjIcL9QOfPivPn2deNaO63zxMCpxzyDyDUu3mhuV/AUPmRVSYS47kqe59KW
+NZYjgMwHmRWTNaovPl4iQcncPMedIscW7kDjknFc7SYyAHfnHBB4OakFGZsbQSfyxSGiXb7Q
+v0YIP9a6+P4R4Qn0OPOuFuQJfwuCuPpxU54TKmVIyfLGMVnO60aQq9joL1iQQMn71Z21znGc
+D86oltpASG7Z7Cp1pCAVCggA4rBTZs4I0UEmRgkk1IR1B98VBt4SCFjLsAf4ualpbTBh4k6k
+HnHmK2TbMWiXHKxGAuK7Kx5O3n71Hjih4LSsT5elTAqJxvByO1WtkMQ5Jw1PVCSM+vlTgE8x
+/Su6qDyozVEjI4STwAT71JWIkkZGaFQfxkj2Heuo7HACgH9aAEWMDgt9q6uyxkKuWYjgCuZY
+tnb5f5V2uQIZDHGcFUVWYdzxTQntnNpmBAcgsf4R5UniFjtAyTTREuMKvJ5yacqyBsIMZ86L
+BUKIzjnk5p5iBBDEc9z6UjZAwCeOCTSIWJ5HH+dIZJ4VvDiGGHBYjkU6OIojRxcBuXY9zRbx
+ZUYBAPOfP3qSE3EIgwi1aJZBe3V9sKDEcOW/5j60yXT+N79wM4PkatLW3wxZkyScgGussRJw
+efenxsXKihsdLYyPIeFKsvPds96sxYKt/p8DAeF48ZYeqKMkVPsrVmmi3IclsEe1SPliwjk2
+8oJMf5f5URjSCU7Zl5oZmgFxMCHmJkZfTc5wP0rpbKUu5VweCy59MHFXZsxPdJDsxk7jx2AG
+a5RWgYNdbcLIobj1IqHplLaG2ES3F0hzkPkH74Of61HXIsmuZB/eXCPj1Ujn+v8AlVtY2phe
+PauByc4/+nnTp7BZCiY+jdu/pVrozapkD5Qi4C44ji8Ue59K6W1o4SIkcZcf61b/AC4KQOgG
+WTGfPNSYLMbQhTsWb7Zq4ibI1tYkAIOwIP5Yq1trApviI5A3A/lxXXToQVVnGd54+1WcMYZ2
+ODzwfsK1SMGFnCqGJ/8A6cipSFVkYYzt5NJ4RhAUjmJwuPY9qRhsJYjz+r7GtOXFE8bOks7A
+REEbv4vSkV9zcjv51zAPgsCMtERmnLgFSew8/Q0chpUSFyMkegFSIcd+1RlOSQpwy+R8xXQO
+VCbRwaakgcbHSSEKD2wcUzx9wCgYpsxJO5Tx2Ipu0nkCk5WOMTqsing85rjOZFcSISBjDD/W
+nKpDeo/1rrjfkHvjzqHsvogMPGHJ5B4NNDFGGfxDt71Mki2A5TFVF/fwRb0l3JtHBx604xsU
+pCXV/FC4SSTYW7Hyqk1XVbeMiG7dlDjiQDIP6VSa1qCRwyW/7SSS5BztGCUGe3fk1571Fr9z
+p8kc7dUadZuVcR21/ciNJmHb3znHFbqNGTdmh6zh0TXdEmt5tGtupbTdvazBR5FI5JUFhhh7
+c18s/FnT+lLkpJp+mzafPAqwtDNZgNGwPDKMcqAfLOTWk61g6e6pLap/tXZaF1PZZklbTbpo
+EncDBQsw2tkYwSOD3ODXh3WGv9T3lu+lzdQ2esPazENCs0dvfx+6rkq3bOVPNaJ8RqHI806/
+6b1S4jn+V630W+tgOVe2ZCp74JGdp8uQK8IurnUdPnLxXLRMh4McnH5EV6j1V1FdteSrdTPB
+dxkqJdvhykej/wA1eYa1fCWRluIopCST4kY2k/fFcs6fR6eG1plvp/xb6306H5cazcSRgYAZ
+yfywa9Z/stfGzSNP+KcnR/xJt7VujPiXYSdIdS/ulULDccQXJwOGhnMbhu4G7GK+cZCCfpzi
+m5PqR7g9jUKckXPHCSars9c6w0a6/s7/ABeuun7vSbzSer/h/rSPFdWt2Wjkmt5A8Uyq4ztc
+BWHPZqf/AGoOnendJ+MOsa50q0kPT3WMdv1doatGQvyeoRifYP8A7XK0sX3jI8q9D/tCXtt8
+ePgP0R/aUtzu6m0MxdF9ac5aaWKP/dbtv+aMAEnuSKwvXN3d9bf2ZPh51NKUeboDV9R6KuXP
+Mnytx/v9lk/yhnvVGf5cCnL6XRjCTpN9rTPFpMcgHOfyrnnnND96bmpSLk9js0U3NLmnQuQZ
+FIT5ikz70UybPXfhv1DofXnSp+CXXt7DaBpmuOk9auDhdJ1B8BoJG8rWchVfyRgr44OfM9d0
+TWeltaventf06Ww1PTZ3trq2lGGilU4IOOD9xwRgjg1Xg45B5r2K/EPxt+HcmtRAHr3oexX5
+/wCr69b0WPgTY/inthgMe7Q4P/D5XehbXyRItdaufjb8P7fRLlmbr7oCxMui3kYPj6ppEWXe
+zYjlpYMmSI8koHTyXGI6wVerNLj+Itoo+YmkFvrkaDAS7I+mfH8soGT/AIw3rVD0t1LrHR/U
+WndT9PXbW2o6ZcJdW0qn8Lqcj7g9iPMEivVOsl0DpbrCy610rTdnQHxN097o2aEEWwd9l3br
+6Pb3Clk9F8P1premN1dnixzzSHnmrLqLQ7rp3V7jSLk7zCwMcoH0zRMNySL7MpBH3qtoEFGT
+RRQAmaM+tB7U3HtQTY+jNIDS0FIcp9a08Nu/WGnOokL6zp8WY1PJu4FHKj1dR28yOPIVmAak
+Wd5c2NzFeWczRTQOHjdTgqwOQRU+TSNNURO5yKUZq76nutK1SWHWbACG5vATfWoXCpMO7p5b
+W748jnyxVGKoy2h2PPFH2pPbmg0DD7UUh9KTPOKBWOopM5paAsKKKSgBD3pKd702gQufajHG
+aQU7igBufKlApcD0o7HNABS0nlQO1AIWiiigdhRSZFA7UA2LRRRmgdjKcDmm0A4oM0x9KD6m
+m5NAoLTH5o3U2iiirFJpM0meKBnzNFEtignNOFNpQTQyk6OntS+gpqnjtTs1JqmLnHnRSZoz
+QXdC0UgPqaXNAWGeCaM0gPNH2oCxckeVKDTMmlB5oCzoKUDNNHtTgcVLNEKV9KQjFOzTKRTF
+pQabSjvQIcKeBmmU8NQaRF200inbqQnzpDYyiiimSFGKKcKASAClpKUDPFBQ9VzU2C2ycPE+
+T2rlbwrIQFmVW/xCtDH091fBZfP2thcXNpjmS3XxlUe4XJH50cbE3WiHBp9scCW2lZif4XAz
+VqNH0qOINcaJraP3DIQVP9KrbPVr2OTbsjfnBV4hkfrXpvR091qJFpcx6ZfQOMPB+0RbTAf4
+RnP6VUI2ZznxRQ9MXWiaRcCePVus9IZuC0Fv40JHnuRhhh963mgXmmWmo/P9OdU2kD3BCzLD
+AkazD1lgkIAPrtI9qsrfRenNNmt2v+ser+mHkbETSSRXlufZZCP6MK2M3wh03qqxhuJ+u9I1
+S2ZWCzavaFSG9DLbfg/OtVB3rs5J5o+ei56e6j17S3MMuoaKI2AKXKllgmHmGUq3B9P6VrND
+s/h91FBJqr29l0h1CszH9u9MOLu3YAcNPAm1o+eDlPzrE9I/AfrPS3W66I626atbkA5sZOpI
+7i2nQ+W1wrj9DivU9G+G/wAZbQxdSnozovV5lXwvCu9ZiE4APIjmhySPTePvXVFT8nBOUL0S
+9NXr3VrZbhOsul9atkcxyTw27COUDgGQRkFSfdc1uumJuoLU/szdc29lGpYJb3DTWxb/AApJ
+kAe2Kyema71Lf39/aw/C7SE1XTwqzx2mqm1mVT5q2zwrpPcefpXsugaRJ8pHNdWpjkkUORkA
+p7EDg/eqgpLyYTnGuiNp+gxySm8ntI5GkXGQio0fuABj8qlrpCW0b+G0M5zkidAPyyKuwrRL
+jwVdf5geR+VVmpnMbbByOx7GlN0iYqzN6nbWSSEtDJHIexSTKiqO5mkCkb2bHqan3kk25g4D
+Bj33Gqi5cEHap4/hzXDknbOuMSpvplBIZG/5qpbgq5yTuA7cd6s754E7zNGT3DDOKpbnahVk
+lyzHzXFc05HTCBJgW1I2u7xnz8xSyWwORb3UTEDgE4NVviuGwwHPnXQbHILjBPnWfJMviWUd
+tMSC6RnHchh/pU+2WUchiD71Sqk6cxYYea5x+dSIp5Mldzr7g0Co0q6oiosdxa7scbicf5VI
+gvIJThYCoJxxzVFbPMSA93FIp5AYFWH59qt7RVjP0ZVm7nb2/Oky0W8dg0gyQig9gx5Ndk06
+WMhyIMDj6pv+1crJSezlwDgnvVj4QABxgVDiuylJhDGVyS6k4/hqQFAHBPp96jJE5bK4B9ql
+xo6jJ558hSQM6R5GMAY/rXRC2Mk8nge1MBYdh2p67ieRmndCaskx/Txnt51IVgfP7ZqIkcxI
+wDUyOEj8eCR71aZDVDw7ZwBk12ySoU+Q59zTEjUdjjNSY4cnkZqiTnskeJkQAAgjNT72NPn7
+kr2D7VH/AEgU2OLkegqQ8TvIxC5LHJJql0S+yGkIwATn3NSFtyBnAyeBUmG0fGW5A8xXUxHI
+IXt61SRLf0Qmt1/D/CO9cjb8lscDmrNYG7kU4WxY/h4NLiNSoiQRt4e8j8WAPtUyC3wozzgE
+/epUVrwAV4HpXYwn95IVwOy/aqSE5WcIYNvJ8+K7i1B2jH4T2qSYNjxof4Blh7nmpcVqfD3E
+cscCrSM2/JEgt/DG8d67S2oXG0DABUDHqeam/L/VgLwozXUQ7mBI521aiQ5FL8vNGWe2TM0r
+eGCRkKDxS2elCGzS2cltiqmT545/zq+jtgFTaB+LdXVLQF8beEy5PqfKs1h+XJmnvPjxRVfs
+3hGxySBx+tdF0wSNOeRhTjHqTVsIyFUn1rssIBlCr5E59604Loz5sqJbNBKm0YULwPLI4rsk
+GJlORghs++BzUqaL6VbHIBH502ZWeNvDG1niIU+hI/74oqh3Y2GLYAV8n49sVMhAW5BAwpbk
+eoNRrCbxhtkXEnhK7L6c4P8AX/Op4jztPpTTtCa2PkIlfgeYU/lQIS4cMO42j3FPCd/1roD9
+OM84waffYujhDGQxLjGVwwpphC5jPnyp9xUhXVirLjacqR70jAEbCMnOVoQzjGD4gGeQO3tX
+aZNqDa3B5FclyH8QDJHFd8iRdp9cj2NC6A4BjySMetdogAR/lR4bDnHfvTGcIAScA+dCA7si
+4wB/6VGku4ohl5Fx25NRb2/dYysZO4cgjmsP1H1TfJbTS6Xpgv7qHl7cuYnKZwWUkEEj086s
+k2eoa9Fax4hMcsnGxGbAf2BHnWQ6j6x0y1LRJrFrp2qsjCOGaRDtk8u/Brzy71bULqeBrSym
+JkHiy2/iHxUHmU8t3ofI815RrOp6lpzzaFqPzmt6Q7s8mnarbq9z4fu5BJYfzjvVppC4ts9D
+6u6p6X07TvkddutG0jV2YpcJPbt4M43BmYbvpY481PGa846v1LoXqizuNFfpi01e2nxcLNpV
+3BcIgX8O6N28RGB5wP1rxzrHqPXeki8fw9616is9NMbyNpusRfNQISfqKCQM6L+ZH2rw67/t
+EdT6FqGy/wBO0+RlO9Z4rdPqHkQcYo51pmsMLn+J7Xrd9oOgXK2d1Y3ENnMfBlnt7VCkAY7V
+MqOx24+3ka8g686m6W1JJrSTSornVNPlMEd68cFvKoU4KMidxxx580Sf2vtYvJ2fWtC0HWop
+E2GO/wBNQHGc53pgk+XNZvq34jfCjrWaLX9Y+FWnW94xdbuTTL6a0eRjjYx5ZTxx28qfKPhm
+kcc4P5IymodVz3CmyvoNPnhXO35kl2QezDkfrWF1yWykuC9mceoXlfyzWr1eb4PXIb5K06os
+Jc5GL6O5Qe31IDWOuotIEzCz1C4aP+EywgE/fBrGVnXDXgrmPP3oBrpKiKfomVx9sVyJqB2f
+Qv8AY76i0PU+qNd+AfW13Hb9OfFrT/2F8xM+EsdUDbrK554GJAEJ9Hqi6L0HWtJ034w/AvqW
+F7bVbXT5L8Wzg7l1PR7jcwVT/EbdroepB47145b3NxazxXVrO8M0LrJFLG21kcHIYEcgggHN
+fW/V/VWlX/xp+CP9qaQ25s/iVHDZ9Xop2rHqluw0/VQRzt8S3linye5mJqltUYZdSteT5AZg
+TkdjTc85q46z6ffpLq/XOlZHZ20XUrnTyxGC3hSsmfzCg1TZGaOhcr2LmjOOaaSfKkyaBOQu
+6gNSUUE2PB9KuujurdW6I6lsOqNElCXVhLvCsMrKhGHjceaspKkehNUYNLmguzdfFXpPStE1
+Gw6o6Tyel+q7dtS0obtxtTuxNZuf54ZMr7qUb+KrbpK6Xq/4R9TdB3CB73pp/wDarSHZsFI1
+Cx30I9mjMcmPWGl+Fkn+3XTuqfBW8fNxqLnVemWb/h6tGmDCCey3EQMfpvEZrJdCauOnur9P
+u7sMkBlNpeI2RmCUGOVWH/Kx49qAS8F41sOtfhpJfRkvrXRWxZgOWn0mV8K/qfBlIB/wyj0r
+z88VtuktXb4YfEZ4tYt2nsLeefSdXtu4uLGTMUye+UO4e4U1U/EHpG46E6u1HpeaXx47WRXt
+bgdrm1kUSQTD2eNkb88U61YpSsz4OaCQKbn3pOKRPIcTSUUUAw8+aeKZSg0DTHjuKePSuYNL
+n/0pNGqdCnPam4pSeaQU0IKD2xSk00/egTACjFApaBITt9qKWigKCiiigFsKTFLSGgGhtKG9
+aQ0mcedBDlQ8HNLTQcU6gpbExnvS0UUDoKTmjIpNwoEwPpS4xzTT7Uo70CsWlpKMigY2iijO
+aCAoBoooHYuT5GkzmiigLDNLn2FJRQMdSjvSDgUo9qCkO7UuTTf1pfv3pUWnQuTQD60lFIqx
+4o/KgcCigoKKKKAClHPGKTtS0FDqUGmZpQT6Uikx9FNyaXNFFWLRRRQx2KO9OplKDSoaY/Jo
+JJpm40u4UUVyFoBzTc0oPFKhWOzxS02lz7UFpjqfGoJ5HA98VyDV3t0EzbPFVD5bjgGgdk20
+XSZWC3U1zbgn8aoJAPyyKvtJn1rS7ky9L9XtE4H0tDcNbsR6YP8AlWans7m3wZYWCnsw5B/O
+nWvhbx4qsw/w96V0S0mepaf8TOqriL9mdQwW184zid4IxNn3cLgn7ipsAi1+8SGxvrW21GM5
+SDUtNhjG708QAD7E4qs6Kh01EzaWFxfXLfhgNxHGc+wfAP2zXqC/E/4aWqWOk/FT4BSXBsYv
+CS6lEsdyR5b2jZRIo9Dnv3reMW1tnNOfH8UZ2PVPilo0x0u76d03V0Yb5dIu9PXwp1x+NCDy
+2OzKcipXTV50peSuvT3X3VHw61gc3GmXQEkZb/4JIBZc+RyR716t8MetfgpeXqJpV91joguZ
+SsUGmyWs9sgz9KiO4jLqR6h/1r1Rrb4cdSXdtDD/AGi76wlK4ay6i6fsbpFx5CXZhPQDdWnt
+qrTOOXqHfFo8A6V1/wCLNvqSWHT/AMYY9ZnVtsUdxaQO5zzgCZQ65Neq9O/Grqbp/UU03r3S
+4LPUI5QZJfk5LZyf5sD6Tn2yK9UboP4OyJ+ytWuLG+nMSrHqUzxSeMCMh0IG1e/ABq+6Y+Gf
+S1lClpb9cNrVsq7EsdQhtz4QJ7Rtx37edaQjxejmyZVJdFv01b6T1y9r1Dpt5Na3XhgiWxuP
+oHvsIwCfMDvXogmniiAu3SZgBulVApb3wOKodF6X0PQf3FtayaeuQV8NNqE+2OKtrsGLLeKG
+TGdwPeuhtJHH2zjdSDbmPGO9ZzULpgxQlufPPArvqUzIC8E7YPdTxVRJqjIQs6AoeD5VyZcn
+g6ccSLcRROMMTVPfwKj7gykY4Iq9MkEw2CaOEt+BnH0k1UanYa9F/eaNDOjDKy2zFl/Tyrjm
+zqxrZRXVna3iNDKyHIwCfI1Rah0/qFpt8W2kVH4STG6M/Yir59OW5DWt7FLGG53DIOa52vT2
+pWMudJ6kk29/DuY9wPsR51g1yOqLSMtJp17GCJbR3jIzvQZxTrNHWcR7N6kfxjtW3e3Yrm5i
+S1mHd7UFVYjz29v0pIbfczJdCG4VSCsuMNU8aFyKJLASoJfl2IHBK9x+Vdk02RxwjH7CtL+x
+LcET2EjID3Td3romjTsSwkII7gjFULszAsPCwWt3OOTjKmrOxggOMwSqx/ijYscfapk2mXlu
+xdJnOOCpzg1x+d1S2wId8anjKjH9e9Q58ezRQ5LRa2iXEICxRzhfJnTGfzNW8cVwQDlDxzzV
+Tp+sXiuBOrSADH1ZPFaKznhuQpiiZHPBBGKakpEOLj2corZwPrUZ/wANdzAwU4TsOTVjFBk/
+UoUg/rXZbMORhxz5UcRWU0dqznLKRnvxUqKxJ8yo9TVkdOdTuJ48gD3pphbszAD0FLiO7Ioh
+YDh8j7UqABh9RyalGJm5B7V1tkAfO3JoAbFbK5wcnz48qsYrY4wpyT6+QpY0VhywGfIGpMO1
+MtnyxVIhnOOwlzl2GD5VPitACMjJxSxnIHO4GpcUI/E277CtYpGUmcxBwATgDyFJ8uuclcmp
+iQ+QUgU7YB+XmauiLoii3B7Lg09bYelSUQOCcHHr61IhhkdgVjOOwyP61XEXI4RW3Bz3J7V3
+jsgzKCOAdx98VYQ2hOAcZqdFZZyMADAHatFEhsqFsC+Wcctyamx2vA47DP51ZC2AUfTjyFdY
+7Xjt25pqImyuS1zzg896ebXaRjtjmraO17jHftT2tOBwD61fEVlVHb9gc8cV2EABf3Aqatsw
+HuDXVbQlVJUA45BooLK97cE4AzjtXSGDa2cd/wDtU+O0yfwgYrp8qwU4xzRQrKh7bKmP15/O
+mNajKqB+EdquPl/Lb2pptSSDjmoastOirjskSVZdv4cjPs3cV0hVw/gPGe/DVbJagjawyD3p
+xtFIBx5edLiFkBYzuAYexpkse1h+oqxMOB2wajTpkgY5FNqgREiTDFj54JHvTtoI58u1dVAH
+FMIOchvyqLoqrOZUZ3die+POlCV02g5pNuPOhMdCMfpPP61UapqMltGTFEJPVS2Ks5920kH3
+OKwPWnVMOgwCeRoyXfbsc8n2UetUKvBF13q3VrIMYNCuJrcIX8RCAVx3BJ4rKydcGe+W11Z4
+4Y5IWkinglAkXAycg8MR6A544qh6i+J8EoEbxapbIUJYw2+9Cvnuwf6VhtX1O1Fr831HqenR
+aXfYKW01i7Tzp6pEec++B34pxlbobi0rZbfE3WNGm028me+uJppIle1u0klty+fQrgj8vzr5
+26m66tksm0666p1myvbYneZL3NxB/iRjzg+nINaqb4xaDaSXOn9BXVxf2tozCeG8t2FzaqG+
+oeHLxt4H4TnFeRfFbTOgustPl6n0IXGo3OTJJdaawjntl/llgYbZEHqoBFa1QoLZhtW+MnxK
+0i4kax+IY1KI/Ti8hQtt9O3n54ryjrrqj/aWdr3UNN060nk/F8jGI0Y+bFRwD9sVx6lsIdxk
+0jVIrnA52/QW/LtWKujPvPjBg3oc1hJ+D0oRSWjlOVViFIYetEM+0NET9Egwfv5VwbI4OaYT
+z7VNDcqOjHnmmbsedJSEjy/WmQ5WKWJoBpp4oJxQTY7PNe5fCTQrz4nfAP4p9EQ3iC56EtoP
+iLpiMCW2xOtrfKpHIBimic+WYVz6jwrNexf2SOsB0j8fulY7qQ/svqSeTpXVos/TNY6lGbSV
+W4PAEob7qDTS2RL5Kik/tCg3PxQvOoFg8OPqLT9N1xDuDb2ubOKSRs+8hkNeaV6z8eum7zpy
+TpHTr6B0udO0e40S5ZjnxJrHUbqDI9BsEVeUED0qmqdEd7G0lLRSM2FFJ+VAoBMWnL2ptKtB
+aJNhe3em3tvqNhcPBc2sqTwyocNHIpBVh7ggGt98Zray1TU9M+Jmj28NvY9cWrajJBFwtvqK
+NsvIgPIeKPEA/llWvOhWz0O6l1v4ea10qy+I+kzpr9n3JVQBFcqPQFWjc/8A2qj9FV5IXXl4
+dW1az15gN+p6bayy485UQROfuWjJ/Otn1FbN8RfgdpfXEbPNrHw9mi6c1gbRzpcxZtPnJ/wS
+eNATycGIVjNZWKborp65RTvgnvrORs8YDJIg/wDnatx/Zi1zRIviUOgesL423S3xFtJek9Yk
+ONsAucfLXJB4zDdLBLnyCGnGVPZOSNp14PHDn0oHHNWnU3Tuq9JdQ6n0vrtsbfUtHvJtPvIi
+fwTxOUcfqpqs8/8ASndGQtFA+1FSWgpwHrSAUtBSQtFFFBaCiiigE7Ckzmg0maCA7dqUGk70
+UDHUU3POaAeaAsdSGlpD3oAQk80EmkooJsQ8UD7UUtBIe9OBpuKBQWh9Ie1LSE+lBTG0UUUE
+MKKOfSigQpORikoooHYnNA9hQT3xSdqbIsXNLSCjNIExaKQUtBSFI5oHBpKcB60FC0dqKUfa
+gtBnmj3FHHnRQAvNHPnRSipZaQ6iigg+tBYUUDmigApefSkooGmFKDikooCx3FL2NJn8qWgt
+Cg80tNpc0FC0goOaT70gsdRSZpc0wsKKPOik2CYuT60oNNpaTRQ6lU89s032866QSSRSrJGQ
+GByv3pDsstMuZonC/OCKNuPr+pfzFWzaakkgNxbpEW/DLC26JvfjtVtoo6N6m22nVGlXGiXp
+AxqenoXgb3lt/wDMxnPsautV6Htuk4ba6u1WfT7sbrbU7C7aa2mHuAN0beqsARWixtq0ZPJu
+g0Do3qtreO8temV1ey3ZBhZZkPscHIP6V690h111h0rbGwsrGLSYJfxWupaaklsf/PkdvcVh
+Oh9TutSuTpWgdKprbQpvLaZe+Belftx4n2INeq9L6Fpdy0Sap1BrvR91I2Fj1ex8BFOP4mkB
+jYflWkV9HPmmvJe6P8aepdME7waBokNvcYW5vNI0mzhdkAwRsBZG/wCoV6X0v130V1jbW9pa
+a9qemPDh5NNvdFsVs5wO5xGgXk9zxWa6a6M+IenTzXXTXXHRevWryARPJ02YpGOOyzwAL7Hj
+Fek2Wk6lcWiL1rN0cqvzPFe2SXCHzAKxlXA9M1pG4nHkqXRdaDO94N1p0L0reWsW592kSW3j
+Ac/WbVgrdv5N1bfSNJuJHS+gtLbwwATHcW211z5K/wD3rD6B0J0RNfWctx8pclCPko9IZ4Ir
+YDnKscsBn+HOK9j0+GOwtVt4bmWRR/4j7j+ea1jT7OWba0jgZrm0i8PxVWNuSmcgfaqy81VQ
+SD27HHpUvU5cZHiLjzBGcVmbxCzfTyTyCrf6Gs8k3HoqEU+xl/czTkvZFLqNRllU4kQ/bzFV
+RuSdwUcHujVz1GJowbjwZBsGdy9/6c1AXUYrvKvKGbyLKQx9s1xylbOmMSzijLEg2peM/iTG
+cipcNnZnbJY39zb85MbsSo+x8qqYYNZTM+lzvIFGXVGBdf8Ap7kVY6ZrRmkVNato2UtjxUXY
+49mU96i/Boi1a3uHj2yOswXnL4JP51Bl0yB8s0ZBU84Gce9WyaLo19MZrHUZ43XH0xzEY/I1
+Keza0ZfEkcZO0SuQN33I4pNFRM7+yVlUhLj6ccEDNR20S8UFrZrW7Qn6tjgMPupxWhl0u7Zz
+IbZAwP0yW7YJ+47GmSaTczoZVEcxHcKNkg/LsahoszYuPl5jBewTWkg4BZPpPp2qyincgZIY
+58h3qWltPMfCSQl1/wCHJwTjy5rnMBYKZZ7SUoThtvBU1I0IYRKwYnafQiur6KkkBDHKnkMo
+7VKs10e/CG2vSjNxtlH+tXMGk3UA3QyB4z2ZKXGyuVGHfSXgchi4APBWrSxtrkKCt0ssXbDc
+EVpJID+CWMhu+SP9a5C3VTuUpx+VZqHFmjlyQltblArD9O9T44mIIAOfUU2JQqgykEcYI71N
+iIX/AIch9OBWqMWc1tDwB3I5yactllgPp/WpPJXKwvj3xiuYZ9xKW77/AGGc06Jsc2nIVwCC
+TXAWMoIAt3x6ntUhXuCuJYWQ45FSIYp3IKSOB6ZpcbHyoipb7W27D9yO9TYLdGwWhOR5VMS3
+JxlvyqSiMhBHYe1Wo0S5jIrZSoOME+VTIoQvJwD71x8cqMkc+1Me7PIXg/etNGL2SpZEUEAc
+ep865KrSsEUYHmajB8/UQas7C2diCeCaYqJFtaB8A9vbzq1gsxz3xXS1tQm1QM4GTUwp4SEs
+cewrZIhkcQonCjmpUMII9ADyfWuAJeRQAeasYIDgZFNCZzEI7he/tXdIc4rqEKt28s12gXNW
+TRzSADPFPWIMMY71KEeP0xSpHyPeqTomiOtuCAcHmuqQ+WO1SlQGnrGMjjvSGRRbDvjmnCDI
+xipgTFNZcHigCGbcEdqYbcDyyKnYBppAHapZaIQiKjt9qRsDNTGQbDg4xyKiSjBzUtgjk78Y
+4qFMTk4rq4YcAVxKkkbhisnI1UTg0oDbWjP3HNBIJOCCa6mIkZHauTREggcZqGy0hAx+9Kcn
++A0hs9+ASeOxzXdLYKOc1UbEzkIlcZwRVNq3TPzp8eOG38QcqZIlbDeR5FadIVOM1INrE6FW
+GQRW0VZjJnzz1N8PuuoZp7qDqDSLrepC26W1tZuR6GQ5yPcc14h1rB1VpN1eJL8QtF0m4D7m
+k1bXLecRnjCrF4W7I7DvX2f1L0Pp2q2jImm2M0p5Q3CFsN68HP6V5d1B0T13ZM72WkfDV7xY
+ytvLcaO1xOg8uZASxHkM8muiONd2Ze4+qPijXdP/ALUPU8oi6W6t6d6khYHaFbwhs9QPDGCf
+Y1geoPgP/aQuZn1vVOg9T0K8hzL83YOksDt/iQMCo9x+hr6r6t6X/tPXcIt3/tCS6BbqCxit
+Oi4bM7e20GE5YD1LD7V8p/ErojouXVQvxO/tf9e32o7/AK4Y+mLgqPVldpQpHlmqag9vY4zm
+nSpI8V6++H+uQahPJ1Tor6Hrq4M0cahYL0kZDjyRz+h9q8yur1YQ9nPY+HIrFS8g+ta951Hp
+b+z3bF0//aU6gZAQWS86YmupZD/0uQMfes3rt18GbK2VdK1m26juuyXGpWF1bOo8vo/Cf1Nc
+0sbTs9PHlTR4dcLhyDIrHPfNcSCO1bzVev8AVIkaxsdP6chgQlVkttOTew9dzZNY6b53UJml
+MbzO3fZH/oKyaS6NO9kM5ppbFXFn0h1XqUbS2HTmpTon4nS2baPzxVdfWF7p05tr62eCVe6u
+MEU6+zJsj5FBNGDSEY4poQZqTp9/c6Xe2+p2UrRXFnKlxC6nBV0YMpH2IFRqUDIx68UMR9O/
+2xETUNB6G6ogkSVNSvtedpEGAWlkt7oD3G25HP3r5gYcGvov4t6zqHW39mv4e6xeFXfR3aFc
+IF/dKi2jH3O6CHJ96+dWHrVS7szimk0/t/8AIyiiipEJijyzS0UCoKcKbSr6UFIdWs+F06p1
+tp+nzSFYNX8TSZ/eO5Roj/VgfyrJipFldyWN7b3sLFZLeVJVI8mVgR/lQzWOmWt0JbXpltMu
+CRJa6q6svofD2n+q1RpI6OGjco4IKsDgg+RrW/EK1/Z3UWvWWMbtWeZR/hdd4/o4rH8A5pJE
+ydHp/wDaF6lg6+6zsfidGQbrq/RrLUdS2qFUakkfgXZAH80sLSf/AMyvLiMVKluZJbOG3kkY
+rAzeGpPChuTj86jHGcVVmbQnb1paKKQIcKWk8qKC0LRRRQV0ITijPFIe9Jkc0EinnikoooF5
+FzSetFFABSgc5pKcCO1ALYtISPOjvSHvgUDENFFFBAUoHNJSjvQUkLgUh47UHikzQA7PFBP2
+pM+lJQDYUUZooJCiiigBBzS/nSdhS/lQLQ2l57U2irIsd60Z9KTNHc0qGOopM0vvSKQU4dqb
+SjvikWh1FJmjNBVi+VLSUUDHUv8ArSCl4PapLQZp2eM5pnbzoBooFIf7UU0H3p1BSCgelLxx
+RigqhMGl/wAqXHtRQNIP60o+9FAoKSClHaj3HNHfvSGLTadTTTAKKKKAFB5pabTqQBTgpI7E
+02usU8sJzHIVIoZSGcZ5P612hRWbljirSw6r1KwBU22nXiEY23dlHIP8s1Ng6n0eUEXfR1is
+h7SWkzw4Prt5FKkFv6Jegz6HbNGNQmu54gctHEwRvyY9q9/6A6w+EtjZiC00PWPGfBnN1DHd
+kf4sKQzfocV4lpM+i6k6o6xxIBkpJF4h/wDlwa9T6Ut+krKJXsL2zsrplwpuLLxVDZ8vE7fr
+WuOXg5s0b7PdunOmujOvdr9Iaxo93L4odbcA6LexN6RyleT/AIW71vNM+HHxA0m6dI/i1qip
+H9MendWWVreJGD3CyEbefY814bYQ2esQtpnVXUvR7vNIvg6itv8ALX0Bzj/ht9YxnjvXtnTP
+w51zRLBr74V/2serLDTnUZMduuq6eZQOSySjcg9iMj1raKXg4srcezQxdF6/qbsus6Wt06KF
+jvOleon0udWH8TRMTF9sCqgfCLqy/wBSjaPr3rW3kST6V1qwstVHH8LTRYJHf8Q863vRcvxf
+ksxF1d1v8N/iBp0SkJdWelNZakGxwZQp2n3zg1ura1s7uAJe6BFE7YDeDdOVx9vKrcFLyYLK
+4boqel+nNR0RF/aGnWck4+n5m3QRl1x/J/D9qt76aOOFjLbvIAclDuH+VdoNJ0+2LfK/Nx47
+K8pZR9uai6hf3VsAou5CG4KuBih/FURfJ2VgvY5CUitpQ3liXOP1qNdXAUkO+rQnvmCMSZ/o
+aZcahIHPhPDGx8ynf9Kr59e1e1yqzAkH6XCgjP8AnXNJ/Zsl9FrZzySTZinllY9vnrIpx+mK
+kXNlHcDN1o8DYz9cKg598cGsovVXUEUmWhtZ8j/iSSg/oDirnSeoLi6IWS4lilz+CNyV/Q5r
+K0y+LR1XSVllAt7KwkhXuu9oZlPrzxRqGlOqeJFPdxSKOEmUTL+Td6vluJJ1EWoWkUhxxIRh
+qisDH/czFRnsTxUT+jTG3ZmI3u5R9V9ZSheP4kcVPsNa1LT4jHdySSW5PO6Iyxge/tVo1tbX
+h/3uBG9SB9Q/OpMOnWVsQ1s0i48jWNPwdKkuqLbSL63u7fxLWOKFsDaYzlfzFXMNlDcYkkgx
+Jj6innWUtrBVuzcWUphm7sigYkH2/wC1aXSrtJFw4beDguh7ffzFWpX2RKKW0d59AtLvmRN5
+HAbGHX86gz6RcW5aNkEsO0jJXn8xWjEbSEsj8qPPuajLfyRZjcBxnjeM805UTG2Y2fpuB2IS
+wQK3J8McZHt5U610/XdPk/8AZgMsbf8ACOVI/Xg1sCtpcncx8JjwWj8j9qs7LSXlik+Unjui
+ADt/C35ZpRSfQSbXZhbLqCK6kazvbd4Z1O1kdMEGrA2NjJIv7wQkjlu6n71pbrpmx1RcXMEk
+M3kzD6gfvTbXpSaxQgP4iZ/FjPFNwYvcS6M3+xLuNS9o4dPNc5U/aukOnak7AKpwfNeSK1Me
+kQFiUdkY+Q/7VYwW7W4ww/6wKXCinOzL22lzqw8QvIfPK1PFiXXabfaPbINXrlnHcFfUcUwL
+xin1om2yjXRY8lstz/Mc13SyigBCKT61ZSLxknj2qMzIBtI5NGkIjHIyMY+9cZZtg5JxXado
+8nP6VCubiKP8TqD5CnYhj3GWKqTz7U+GJ3527m8uahPfxqfxKB558/yp1u9zf5Co0cXqTgt9
+hQmgotraMeIN8u5h5A5ArQ2EcagM2TgZzVJY2ghjHG0dlUeVW8TyHaIwCferTIaLRbpgFCDA
+7nHn7Zp0Zlkx4vLHyFR7eB2+pjwBz96trW2wM+p86tOyaoW0tzncy8+XtViqBRwKaibU4xzT
+mxjBq1oljNwLE59qlWuNucYzURELMNx4FTIeACaaYmiQoycULwe1NiYkZ8+1dMHINVYqOqji
+n47GmoDjHfFPbsMUWKhRQQCc0gOKUnFKx0c2HBrgzMK6u4BrixHmalspIaZMZ5rm5BFJMCAQ
+tRWlw2CCKylKjRRsVxjtTBz3GacHLHkcU4IDzildldDfDDetMEHOcGpIXAxSUVYWcfCHkSKc
+Fx2p5FJtarWhPYgBJ711jYg45Prmue3jmkyfI1V0RVk1VVhyBmol9pcN5GQRgsMbseVPikPm
+RmpitxxzW8J2ZTgeC9ff2aPhb1LeSaprvTUs1zK5cy2Nw0LknuTufZ/SsFffBDQNMsVtuluo
+JulAqbStxfaczoPferMT9q+pNagSe2dDbRz+eyT8Oa8V630iG7lnt/2T0fp2oyQuLe9WG3lu
+UXHcG5winPbzrpj8jBzcH8T5y6n+EHw5tIpY9c/tH9SI43eLb6RFpzsT3K7ii7c14F1j0Z/Z
+2tnNvf3HXOvYcqn7V1i0hWUD+VLdC/P5V9EdY/2dOn+uIl0u6vda1W6tCy3Go6T0VagpI4y6
+tcQsFdvfBryfqr+whrukOuo9MwdbaVYSJ4a6lreoWFn4shHkPEDqmOe2cUpxl4RtinFflLZ4
+F150j0XfaR4XQPQmhaJOhURJBJLLcOB5vLdOBz/hArxDUbbriC4e1ae6MkZKmO0jLAfnGMf1
+r6AuP7OtxpnUdxpOl65o3VmuwyeEkNtbXOpJux+IMgKtj145FbLWPhL8Qek9CSL+0P8A2hdA
++G2gGL/dunmtt+oTxDODHYwZlUHsDIR3rm9vm9nd7ixx7PjK+veprMGC+1HUYQe6SXDD9Rmo
+v7N1B4Re3EciQsMiWYEB/wDlJ/F+Vey9R9YfAPpaR/8A9Wugaprd8gwNV6jVZW3+bxWwPhxj
+PbeWIrx/X9e1HqLUZNR1O9nuZG4Blb8I9ABgKPYACs5xUdXZcJc9tUQHMajagyfNj/pXI8ml
+OTSYNQkDdiUClI9sUAc5PamI90t0uNR+BXRmmu/iJeHqmwjRv4WQW11Fj7vG4HuTXhROefXm
+vXbrVE0npHpnS38SN9Jt7HWG3HG3x7iYPx6GN4z9jXlurWg0/U7uxHa3neNfsCcf0xTbsSVE
+IjmkpxAxzTePIUiQNFFFAgoHfiijJFA9D6PI/nTd3tXSNS5IyO1BS3pG0+Lh2dVybmJee2s7
+h+PNrWL/ALVhq2/xpVYviVq9rGcx2wtrdR/KEt41x+WKw5oqiHKxc+9IaD+VHagixc0DvSEi
+lpjQuaUUc4+9JzSNBScUbqb3ooFYpOaSiiglsKKT3oz5imibFooyKKRQUUUUAAOKMn1o7UcU
+BYUZpP6UCgVi0oIpKKCroKKKKBNiH70A+VHbNFBItB4o8xSZ9qB9C0mRSE0GnQrDNLn/ABf0
+ptLxToQlFICKWmRdhRRRQMcPWlpoOOadUstBS5pKKRVi5GKBSZNFBQ+ikHaloKTFzTvKmUuf
+Sk0UmLSfajvSijwOhQadTRRkmkUh2fKlz600c8dqWgpMcBQKBQaRYClzSdqKY0xaKKKBhmg5
+86BRQAUUUUAHaj7CiigB1FNz2pwzmkxoeBzjvUu1s5p3CRQF2PYdqiozL2496vNEurN5BDqO
+nNdJ6xsVcfYjzpJW6G3Ss0Wj9FdZSwx3kOmLBbueJBJGM/qcj869H6P6O12efwLXT9AvJ+Ab
+bUNXSISk+S7Tkn2rH6P09pN+wXpLrCXTr1uTZatGYw3/ACyDKt+YFem9I9MfFLTJLdLjUtAW
+Ff3wmmhjKYHpIils+mOa3iqZyZJSa0bHROjfiLeyHTOkdH0SHUV4msrTXbN5O+P3ZlAbI/lb
+Brd9O9N/2iukrlLvVdO6ihRMGQ3M4MscY/8ADkgfBHsQRVj0prd7qFjHpXWvRGn9SRqcBvkp
+Gm2HzSfasjjjt5V6p09qujdMwj9kaXNo1i6gm3nlkliRz57n+pB/hIrRpNnHLJJaaHab1D1f
+qcKSaZ0T/tBMoyRcN8tc5x2EqjDH/mFbTpvVNf1CJJJen9c0GSNttxZarDGWVsfwSIxDLzwe
+D6iq2bXzNaj5mCGe3lXJNnL4qqM92ERDqPfFXGj3WiGALp01xHxlhDftIPzV+RTTrpmUt7Zf
+3N7dpFlGjAH/AIsWR/SqS711lZob6zsbiNuP3BOcepBqVNO/ZZyyn8O4Yqru4bu5ffa20M8g
+7qfoYj/mxzSlL6JUaOY/YsmTDq0FvuyTEz4P2IIps2im4A8CfT5w/wCHFyqsfsD3qruzsJh1
+HSr62x3XwVmH+hFcUu9LhwEupI1HkbI/5E1zN/Zqk/A660eazcpc20yYPG5eD9iOKbBLLDti
+TOz+VTj/ACq3067a4i22GppIT3jkTa3HoDxU+CBLljFLZOknkwhIDfnjFTxvork12cNLmRyG
+YtDt7Ayd/wBa0Frb2k4IR1ZgMkE96rG0KR+fCOV8uxrpFBe2irJ4gIHADjB/I0mvsIv6LFtN
+iJJwRjyxzXeOxWVWUMx/5hzTrHURcIsdzEA68fUefyNXMYkGMxcAZ5U/51HFM15sy13pN8rg
+wTH1GR/ke4qdYSyO4+eiJmUY8WPOWH+KtGkML8SJgt2IPamPpYl5iKuR5n6G+2RxUuFGiyWq
+ZI0q7iYqrKdwP0uT2HvU+7tbW7IaWECT+ZOzfcVSR2TWrlHWRCfJh/r2q7tg5A2ShhjlT3H/
+AHofVAtO0V76WIWDQSEZyNuchv17V0ijkgAKsyODlWWrVoCy4aPHoKSK1aTKeHke/cVFUaWm
+tkrTdTuZU23Kibb34+oVbQPbyA7CRx2Iwapo7fZJkDaR2OOampO6Y8ZQ6g9/OtozfTMJwXgs
+GtlYAugPvjmkFuq/xH0KmnRXkagHJKkeddDLFICVcc1rSZltFfPCpzkDA9KgT71GU+sH+Hzq
+beSBV27ux75qjvLofU/zGAO2CKzlGioyHz3YjThduBkA1T3GsqpIC8+ZFRb/AFJJFKxtkHzz
+WdvJizFC2ABluazkbRVlrda+sZIjZd3c5PaquTVGmYMzlixwoHJJqE1uZxgAqvc4FW2kaIRu
+uSrO2Nqn+Ue1SmXxofZ21xK4UrukdscdlrY2tktuiqfqfA7+1cNI0+O2USbOSOB6VbQxlmJc
+kZ7CqTIYsETMBjvnvV3ZWYwFx+ftUa0twgDHB9Kt4YiECZ5Pc1aMmdraJCAVH0g4FWMUIABY
+c1wtkJA2jAFTFG0VpEhjXKqMnyrkWLEAfnRIwY8du9LEhYZI86diOkK8E7TTxxx6V0RMcDyp
+rD6wBnnvT6A6If612jIODmo6nge9dIzj8jRyFxJQPlS5x3pq8jmg8cGnYqF3UFqYWNNLZ/Ol
+yK4g/NcHyDxXTOePWmkZ7UrsaVHJmB4NczEHAP6V1ZOe9IM496hlXQ1YCDk4p+0AcilDYpGa
+jQN2MY44xXMnmnFwTik25oAbmjIwM0pTHJprEdhVIRzdgDxXFnIyO9dWIJxTDGDyPOh2NDoZ
+ST3A96mRyvwN/wCVQFCp3UAedSIZ07iqg2mKaJUiCVSr9iMGsNr/AMLvhhqU76tr3Q2k6ldg
+7vHvbfxnz7biQP0rcbgVyW2iqjWjI0TJFE0zkEKittLfnXdjZxZFuzz3Vjpd3py6Nokmv6bB
+Gu0Weg3K6YGB45mVNwH2Irw7rn4a9OS6k/yv9nvSeprp1Dx3nV3U15qspYcYVJH2oB5nzrff
+EToHqHWLSQzfCDT+onUbk0tOpDDcTZP/AIjMEU45548q+VvjboHS+hMmndU/2Xeu+mBabHX9
+ia0mpAyMPqOFRlY475bijkylCLqzNfGf4mf2l+lNOn6Y6R6N0/4f6SxeIvow+V+YA7hDH/CP
+XOa+EOrL6+1LVJp9XWc6gzk3Ms8zSySP5lmYkn9a+ro7+9urpbf4U6jr8cuD/wCy9W6Pu5JA
+QeBvBMWfXIA4ry/4l9HdWahqxPWHTtrbatMMiIT28Er+/wAvESwHucVnkjKa7Oz03HHLo8CK
+enlXMgitbrHSV/pk5imsiSOT4YPhr7GQjGfUCszcR+HIy5XI8wciuVpx0ztdPaI5pD9hStny
+5pM0kZsOe9WnTGg3HU/UOn9P2pxJf3CQbvJFJ+pj7KuSftVVnmtp0lc/7L9M6z1awQXd7C+i
+aZn8SvKv+8TL6FIvpB9ZapCsXqTqGLqTqbqS9hP+5y2jW9mo7LBb7Eh/+SMfqazeuN488F9/
+9l26OT/jA2t/UVGtZ/l2kIHDwtF+orvMRNosJz9VrcNH/wBLjI/qDSB7K44pKU9zxSUzJsKK
+BTjk8nuaAobRTscUmD60DoSr/oXR5OpOsdB6chTe+qapa2YX18SVV/TmqACvS/gMx0fq676+
+eQRxdGaTd62rEfS1wqeHbJnGMtNJGBnvzR2EdGc+Keowax8SeqdTtU2wXGsXbQrnOIxKwUfo
+BWW5FPcsxyxJbuSfM0ymQ1Qc8UlLj2oxk5oJE86cvekApy96GVHsdSEUtKePvSNqOeMUU49q
+bQZtUFJkUHtQBQR2FBAyaXB70U0NqhtOpKWkEQopSDikoKaE7UUtJjNBFCEc06kFLQNISgjj
+vS0UwoTtxRS0YpBQnegZHpS0mKZIn50vftRj2xQfWmMQ0Y9KX3zS0mA0gikp+KTAosOJzoBx
+SkYpKox6HZz2optKM8UDTHCjP50lL5UnRoheaWm59aWkNC0UUUihQe9LSDNLQWLS9sc80lKT
+5UFB3oFJThQNBRQKKTKFFO9qZ9qXPPvSKTHZ9KC1MyfWjt2ooOVDwfOlB9655pymgFMfmim5
+pc0F8rHUlJnHNFA7FzRSUAk+XNAWLz50n2oyc0qjJoABk1Jt4PEI3K5XzK966WmnS3DABogD
+5mQCtLY9F6nuDCWylhI58O6VnH2A5oS5dDvj2VUPT012pk0yeO5Kjd4R+l8fY9/yrS6LDBag
+SSaReRyqMvHEpDcd8bhzUmKLqfR9idIT3NoY8F5DFsnkfPfkdvQCvW+hvi91/cwLpvVOv6Fe
+MmFB1COJZSP5WyuCfc4PvW2PGrp6McuVqNxVlX0LP091ASttNcWc0JDFL+xM4J/mVk74/l71
+730z030pc289+tlcwQxgJPc6HLqEAV9vEjQkMpz5gjvXPSx8MdX8O867+CvTxWQIn7R0fWW0
+75hv5mi3eCz+pBBNenaB8GPhEAOoemNKiguFGBcyXlx46huAmBKyP6ZrRqns4Z5OfRSadadD
+qLWz1Trm1nuH4W5tr94GAP8AA0UgBDjzwcVtenenuirtUtbbrS5uvCchhJqTSr/yZYH9OagQ
+fDa10TUkcWs8MisctK7OrefO7P8AWr3TYOm7eUpPdwSzNgK/hoFB9yuDnn0rC3doG1VFrpvw
+46Qs7kX+kadbtMoK+LbXPhS898sO9XIjXTlVDptxsXgSSOshx6bsZrlaxWcMX+7wRqqjO+F8
+8+vNEpnKN4V0xQ99wGKptpWRVi/tWzR9hvbmIn/hO6lD+RFMkutHmcpPq2p2rAcGGcoAfyGD
+UG6mCxNFfpazQj/xU24z6Ec1XFrAox0+82he8auJF/rg1m5Mqi7kljRA1rqkt/n6cXUhbA88
+ZqGYre9kxGkfiL3RwCKqWMTN9U6IW+68/wCVTks4pY0+Zhhk44YN/rWXKykqOyaddRyMBZqW
+H4ghI4+xqy0zWbvStsbzE24/4bsSF+wPaoENpe2e2eynmO087HJxU1NZmkGzU9NS5Tt4hjG4
+fmKnlXRdX4NppHU2hzhTcW1wVYfigcMP0Naq0g0/UV/3Ka1uUPaKUbJMf5GvNdNh0u5wLO2t
+tw5KuGiP6r/rV+iy2mJG0i4iIOfEjJkQj8ua3jlfTMZY0ujUDpzTpZCrWs1q3mG5X8ql2+kL
+Dxb3+4Dy/wC4qt0bqWNisblSM4CsTn9DWiilsZ33spjbyIXIp1F7QvkuyMLGYfU8SH1PcV3+
+UdACbeNlH8UdT0ikXDWtwrA9wRiklY93gVH8yvnQ4oFJlcWk2nwzjHcMO9FvPCMrJbJz54wa
+nPHDNg9yPUVHe1RGLquDWMk/BrGR2LRvjYQBjIp0EhVicZFQzJKoKhlBHqK4vdTJ9WAfXBqJ
+G0ei7LB8fxDGRilRUGXAIPnu7VW2uoMQGzjI7HyqWLncu5iOfSkUPeTYcq+w5rnJcju7KGHB
+2nvXGSTxCBIrFhwCK6GzaUKFVS3nuGauMiJRKvUNXW3XA3Oefo7Vm7y+mu32JZhNx5Lc1sp+
+nXljDO+0Kc/Qa4totsn0LEcn+I81TsmNIws8V1I7fK2csyr/ABZ2rmoyaVqczZmjSMk58ziv
+Qk062hUrDGxPm57CopsRLKe7sOPuaxlZtFoz+naECVjcllJ3MT5/+laKG1UAKgAAOAPOrLT9
+BH/GfZnkgVOFssYPhR4TsvqalRbG5oiW8D7fwYA7VLjtju+kDArrBA643AjJ8+9WVvaBiSeT
+3q1FmTkc7W2yRkcD+tWsNvnj1OTTYbYoc81MhUFiAeB3NbxiYyZ2ihx5UTfSMcZrrnaM1DuZ
+RuyfIVVUTdjVxI5IHA4qTGmMcVytUIUA9+9ScY5/pSGPBGAa5vzyfI0M2cA/en4BFDYI5jhs
+D0rquNoPpXJhhq6L2FIo7q/pSk59ea4jjPvS7sdzSsKHE+1MwfOuqqDTjH7UUFnFRilyKSQ7
+DzxXPfzTSoB7FT2Nc244pHbjvXIyk/xVNjqxSccU0sPWhjxzXMt50AdO9LuAqO0xUd81Ge8b
+1ougSbJ7Ovrmo8jg8Y/rUJ75zwD+YqOblj6/maTmWoMmO58mxXRJQTnPA9TVcZz/ADAGgzyZ
+4YfalzHwLMyK3GM+9OjIz2/QVBjmbHl9qlRSE912kdjmtYszkqJ4+pOMfnWP61stbu7OaDSr
+rUonkUqDYeGJV48jJ9P61sIiSgLCoOpJdSxYgcKfUrmuzG15OTIj5S1f4Z/Ew38+qz9ZfF/p
+a2LjdcQ6bpl20h8iiRq0hP3wKjafpfxq6Sujq1pr3xw6t06SB937em0/TbRj3DGCKN7ggen0
+k16919058ZNXSaDpXVNOkjfGyGe7ltiWBzuJClB6CvMeoegP7Sl9o88PUPVvU9m0a5X/AGcv
+rYeGmDuy0gUSYHka1agvxZCc32jzzrXX9V1Tp5LXqD+zf8S9Zmc75V024u9Ks5Xwf71gxaRc
+HhcCvCOo9X+LtlZT9OdEfBax6BsrgqJ49J6Club64A5AmvphJJIPUZAPpW362+Fnx004Ley6
+98bNQedX2JrPUdjp8Lxd8nwpCQvrgeleDa1adba6rWX/AOsPROk5WJZrq96+upQAB2J34B4P
+YVk7WzqxRjJ9/wDkxnX3w6+K2uONQ6s/2+1oE7VjTQJo0j/wgEBV/IV5frfSep6KHif4e6/a
+Mn4pNSgdMe+MAV6u/Q7abbi86g/td6fqC7jmw6d1e6vrph93KRr+bVlNTs2luZBaaZq/Vca8
+Ry65rwK48iYon/purKVPs64trS6PIZw3isHUI2cFVHb2rnJFJHjfGy5/mGM1vtak61sbYuNM
+0XRLcD8NmkKsf+oksf1rC3E13eT755ZJ5WOAWJYk+grNriDd9HTStOutWv4dOswDLcOEXJwF
+9WPoAMk+wqz6s1a1vLm30vS3J0zSYvlbUkY8TnLyn3dsn7YHlTrgr0zYSWKSA6nex7bkr/8A
+g0R58If42/i9Bx61ni2eaQm+KHBqkwyD5S6jJ77GH3B/9ah5xS7jzz3GKBWB70lHFFBA4UtI
+KWg0QUlLSe1AMUDmvYepIR8NPgFo3S0ngprnxIuI+pNQTH7230i3Lx2MT55HiymWfHmqwmvO
+ej7fp+bqC0k6rknXRoH8a+EH97JGvPhpn+JyAufLOfKl666x1Prvqe86l1UhZLplWKFCdlvA
+ihIoU9ERFVQPQUA9KygPJzScUZz+VLQZXYlHnS0UBQnOaUHFJijJqmJOh+R60pI9a5588UtS
+achSc03NBzSfeghuxacBzTRTh50xxHU00ZpD96RYYzTgMU0cU6gSQhz2pKUn0pKBMKKKKBAO
+KKKKACikpaBWAopOO9LmmCYUUUUhhQaKKBdid/PilpPOjtQJC0UUUFHInNFFFWc4UoPlSUoo
+GhaUUlLSqzRC0DFIc0oPrSaGLRSA0tIpMAcUoNJS/egpDqKT/SjNBVi96UGk+9LQMDS5GfWm
+0ZooY7yozSZ9KMnPFTQ7FP3pMYo/OjiqE3Yc0v2o4/WjtQwQ7dxzS0ztS57VJaY6iim5zQNs
+dRSA05cUFIMegpVzXZraQJ4oG6M/xL5feuarz3oKSHJj2rR9JaW+s3yWmnanbWV8SPCW6fZH
+IfTf2U/euGh/sm6lSz1eDwhIcR3Cfwn/ABDsRW10zpHRLi5FlBq+nx3rcLBcTG1c+mN/0N+R
+qoRt2KcqVGz0LWL/AKOuk0/4n6NHBa5DRXfiSRNGfJklUEEfrXrnTPT/AMJviZBMun9Y9HdT
+XLQ5W21SO5stRgcHstzEgWUY8mBzXnWk6r8Veg4rbp/U2jTTZlDQ2uv2wa3kRv8AwpSChB8u
+cVohadB6/aPB1F09BpV5KQkR02R7Fc9/pUFoJefUit+VKmcc1b0z3Ppv4D9A6XBBqWi69qvT
+t9KcC3uruOWLaOCfCmTw5UPowzitha/2fuhrZVfUL9JpXk3fN2rnTgAPLYjGM4P8uK81+G+i
+2XT9mtrF8SOvobPaA1nqOmJNZoAOQGRnwPyFexaB1sLVEg0/UunNXsg+5g80j4z/AIVXKH2x
+zRyjJUzmfJMtNA6V6o6d8SLp7qq3u9PAxHHez+M+3PbBG7HuDWnsY7lY9lzaWDyOPrZizIfu
+GGagx29pquLm00ySB3w+y1uShU+qhvL2qxWZoh/vNyzEDs6YI/71PGhORIhs0bAOm6Yrdh8r
+Lj9Q2Kj32k6eMtd2rJ5FkfaRXGRjMpaO6jde5HhgnH+dVlzb207bBLfGRfwrG2wA/ZqlyoSQ
+99H02PebLqZFzyEnkOR7Zxio0/TWoXQEk9xaXKryGRY3x+Ywa6mOQRiOXxpTjJWXH6cCuMc+
+nwttRIbaRfQ7f86xbTNFZBk0a9iLKunvMg52xjlvsM1GS/h0yQpNo+ox7hlklt3KMPtj+tWd
+xqRkyWiecDsDOhH+fFdLS+jtZA7WdxG7cK8d7yB6Vm68FobYt0/qVyraRrN9odwQc21zbtLE
+3/IxwR9jVh+z7RSXveqHGccRWJDD8y2Ka2rWEw2XEWoyjIJLXKn+uKkC7sSuItJtpAeR8xI7
+H/Ok6KTa6HQWuhLIvg9bam7jnEulxqw/6g3Na3TNSg2qtprU7yYHJl8Mn/pxWZhvXQBYbPT7
+cY7RWqj+pzUu0udQeRRJcxMp7ZiQY/pQpV0Djy7NalxcQkbo43Zud8iB8/matLLUJVCtMkQB
+7hEK8VRabNdsUi+cKDyAcFPzBrQW9lqTYbworjJ+l4plBPttNaRbfRk0l2WtprGlFtkhkjPl
+5D9asGWORQYblWB7Bh/rWffTryMnxYDGfMEY/rUi2gnGFZRJ7A81om/JLSXRaGM9mANI5kUH
+CjaKIYLo4OFUeh5NSjaP32vn1B4ooadFLPg8hTzVfcFxzG4Xywa0c2mSSgjGR9qgXOjbPJhW
+E4M3jNIp4bqSNsSbSCOGBqfZ3wI2SH8/KodzYmNCQjcelV6vLE29CVIPbvWW0bqma2ZJUQSW
+8ow3cHtS2084K+OBjP4lPNRNKvvHi2Sthl/h9am/u1bODn78Und2NV0ybJe2zL+9DbTxuFRX
+VA+8XAZR+EZ71GmdIIWOzKn+EHio9vIsZ2bQAeeWprI+mJ4tWizFtLMMbgo9BUqDSigGzzHL
+edcbK5DYG0bFHJJyTVvbzxuPQY5xWqpmDuJxgtGXdwOTx9q7+FEhxu3PjyrpLdQhQiKQT69z
+UfxD2GACcDHnVUkTbYscBeQ55wO/pVnbxbFJ9sD1rhax8A4x7VL3FVIXk1SRLY8MB2ySBUm1
+Xd25Xz+9RIwdqjOeeTVjFtVdo8hVxIZyuHCD8qrF3ySc/wAwz9qkXdxudgD9IOPua5wqSw9M
+80PYIsYEB7fapPhDaePauNr5cceVSwfpxVUS3RDkUg8inIQcgnHNLcYJFcC+HyO9S0UmSGjJ
+BwO1IoxxiusZDIKYwxnyxUtDTExSfenjkUm30qWi7HxcD713GNtRlJUkD+tdA5xwaaJYk8W9
+SOCKrXLRNtzjH9asWc9qh3IDc45+1DGjh4uaaX5zXGZzHyBn2rkb+MfSzYx61FlpEp5QBXB5
+M+dMMyuM5FRppguTmk5UUo2PklGPxY/Oo7yKSctkio7zA98ZHbNcy4P/ABDzWfKzThR0kkAG
+d1c/HX1/WuMgz+LOa4t9PfOPSpcmWoolmQe/NPVwCMsaix/X/GwJHbzFSY4GGMPk+9NWxOkS
+oW3EY5z51Y2+3+LkCq+NHQc4/SpsEhBA3A/lXRDXZzz2WkQi44xmujBMZOKjRSZX8OfzqSi5
+5BAP2rqiznkjzn4oapc6Fpj6hBqken87A1xaSTRlvIERncPuK+Xus/jdost1LonUB6Jv1IyL
+m51DVNOMj4ycwupBUH9a+2dZ0uzvrbw72ISJ5jOM141118E/g31JJJe9S9D6fcXCnMV7eaab
+7wXxgN9R5/PitHfgmNLs+S7/AOI+rW8jDTekPhRqn07FudL+JkNmcN/CI7pV59Qa8h6mGs6r
+qb6jrP8AZ36Q1G2AKl4dQ065YAnzMEil/vjNfU/VHwCuxDPp/SPwj+DvX0SqStlc6lLpcjZ/
++C0RVSM+T/nXx58dPgn8YelA2oX39jTpbonS7cruvdLgm1SPGO5ZXYn1PAoyNNF4bUqRlOr/
+AISDq5bi86Q6E0rSZUAIsY7cxqT54d3OD+orxfqj4U9cdLK131F0hqeiW/dZLqBlSTnH7s4w
+/wCXFTdU6x0PSrH5TSem/mtVbcJr7UY9kMBPlb2g4XH80hY+wqpg+LnxLhgnspOtdWurS4XZ
+LbXVw00TL6bXJCj7YrmbTfyPQSkloykyRh9kcbfd+5/LyqTbXSaUfHg2vd4+l8ZEXuPf38qT
+VNT/AGlIJ3to4pAOdhO0/lVcxOMVInKglkaRmZmLMxySeSTTMcUUUzFuwo7+VFIfvQSw86Wk
+86PtQJMcDilz7U2jn1oNExSaFJzSUUCs6PKCoVThR5eprn370n3paBN8hD3owaDRQIWgiiig
+YhFJ50pPFJTRIZ5pwpv2p1DBBSHNOIHlSUimhBS0UUCWg70pWkpwoLQgFKTikzQTkUDEoopM
+jNBDFooooAKKKKACk86WigXaEOaAMUtFOxBRRRSKD70hoJxRmgVgO9LSUUCFopCaTI9KB2Mo
+ooqzAAM04DFNFOyKBoKKKKChc0UlFA7CnCkApR6VLKQtOHam4p2cUjRB5c02lOPWkoBjh270
+7jFMBxS5oGmLSedGaTdzQNsdk0UmRS0BYUDOaKBQNOwycUox+dJk0CgBxpDml96KmigB4oPv
+RRToY5aUU2ngevlSKid7e4lgYtE2CRgjyP3q90u00DWsQXd7+yLo8LK6F7Zv+bHKffkVE0CH
+QZpvC1pLvaxGHt5FUj/zDFek2Hw36anWO80TqHV4Yz+I3OmpKqt7sp24++KqEXIqeRQM6nRW
+udNL89q2kRalpD43TRsXhYHsVkT8J74zXpPQmj9JdQ2baRb6/pF/azYMem6+ywzQPj8Mcv4W
+B8sEH2rY9C/7T9EkJa9SWkdkSGk39Pl45h7hHKuOfSvZenbT4TdSXEWodRdGanZmSIg6j03o
+saWzMD/eOspCqO5PGa6YY1Wjiy+od7PP+hulNT0EDpzQetJdIiOBcaBrskd5Ysz/AITbux+k
+H3xjNe79MfCzT9Pgin1TpiwsrckZMNsoinz3KsGKtz6Vo9A6G0bUIH0Gz+KVxqXT5GYItY0j
+TL21Jb+BNjCRPfkVe6F8MOndAt7m10TXunrNJyEltYdNd7WU+RaFpMIfdDVe2+0crzqTH2/R
+Vhb22y30o3Ua4Yq6EKq+QAUcVZ2/TNmhElvZW9qx8vA2/wBQM1aW/QKWtvALK6fTmUD6tNuZ
+PAYjuQrElR7E1dW+jaqq+HLq5uj2DSYVse/kfvRx/RHufsqF06OCMEiISdgyk5/pXOafBCzx
+wTRjgNsJOanX1pq9sCdpZB+J15H6Cs/M5GTJbqh82UlSf14qJOkOOyf4llJwq258yr25B/XN
+R7yHU5ULaRe6XCQP7u5s2ZT7Bw2RVa91NkGF1KnykiPA/wCZT/pSPvmAEsBjfOVe2uSCf/p6
+GsJM0SLCOPWCoeTS2t2/8SH97Cfsc8fnUee7m/DqEtvd4OPCgQSt+h7VWTxXCM0d3qVyFJ3L
+mNkdffcvDfmKjyXU0UmW6iSZPJ3sS7D7suDWLZpGNkrxOlZ5MP0/dLIO5liEQP8A5OaUppSr
+uttGtlH8wDsf6mu9g4u2EY1/TZi38wMf67uas2t7GBAt2LVhn/hNIQftgVLtjumUYuUGVUAE
+H8IjxXVHmYb0ViPU+VXTroDIWkcr/wArSf8AauAXRZCFids+WC39c1DRpFlctztIUS4PqTUy
+K6kGNp3N6etWcVhpYws8cMvHm2TS+HoiymJbJwfItKVqaNE0zhFqF8AHYiPnzFXmmdR3tuFV
+irow/Dj+tQYYbAjdDYxnHmWL/wCdTY5RgKkKKufJacW0KST7NJpvUt9LhE1D7Ry9quIdZuc7
+rqBY189q8fqKxsW0At4rgn/4WAPzq5026mQAs8ZGO7NtrWM35MZY0ujYWOrWk0YIkix6Dyq0
+t5o3IKOCD5A96yCGByRHNbqzHOPf8qnWkU8b7o5hnHYHitVNkOCNnDJGQAYVz278U2W2SbO1
+AM1TW9xcLlXJTjuWGKmRX8uMRMHPnmrtMiqONzpUSqXEW33zWf1axj2gIgG7z9a1ZiN1gzMR
+7A8Gm3NhAU8NkGMeVZShfRrCdMwltA0MkTFiQD+IedXkRuHJC7XAXjyNd7rT4LePMaFccAEV
+XK7xuCjEgHmsHGtM6FK9ofdWs34oyzKxwyE9q4MpjJMsZx5FTUlZ2nIXAXccDnyrqti8Nwdx
+JVRx55/OspQ+jeE/DOVk4Zj4RIwf4mz+lWQuWjXaOWJGee1R1eMKwcbR64xn7UsZ3tgN9XZV
+z2oUqFKKlskC+YbhghgC7knhQO1T9MdbhfEUEKv8Z7kVXxafEIzGZNzSMDIfNseVWVuAsLAD
+6OAPtW0W32YTSXRZiQGMEAgeVdkAAwtRoWLr7kcV2aURruHJNaowZ2V0QqDzg/qa6zXXhx7m
+4z5CqaK9aedQgHB7+lMvZpbjMaSZPcn29KrlQuOyTHIZpdxYEE8Y7ACrGEAAY7d6qLfIwF4G
+MVaQsobg8DihMbRaQjsR6V2ZsKaiwyD6RnvTjJyfvitEzM53EgBBx+Go8j/VkHBYZFErZLLn
+IxUIz5YIzfaobKSLe1nBAXPPpXWVuCRVTHcNFJuJ+k8fapLXOUIDc96VjolRzLu2k13yCPvV
+Q1zgg8ZHepKXg24J+9JNDaJLNtP3oEtRHuQT3+1NM5ABBzRYUS2kBri0nOKiPdg+f3FcXnB4
+D1LkUonS5wykA4qkuPEVuw4qe10OVcg5NRbgBicHORkVlJ2bQVMjrcEDDd/vXJ59xxg02QqG
+y4I9K5MrE5yCKxbZukh2187n5p5XHt7iuaIxIUc1NjhUqOCPvTimxSkkcVRjwD+tPFvu5J7+
+1SBbgjjvXaOBsDIrVQMXMjx26r5c+tSI48c8V2WEgds10EXH4a1jGjJyI5wpH1YzXSLHlg48
+66+H6efqKcsRzkACqonlZ3h8sf0NWUAHbzquhXbjb3+1T4Sccjit8ZlM6XCI0TCQDGPvXmXX
+qdVWXh6l0t1zrHT6wbvEW10+G9tpj/8AFifDAD+ZTx6V6kACvI4qn1q18WFmtjslHIYAE+4A
+PFdCZg0n2eIXHUt71DomzrG00nWrXwvDl1vRrZzGPUyIhEieXavEeq77qb4cXk190r1x1pDB
+cyAWkukyrrVjI/fEkFxieIeqgkcGvqTUYLnULZ7W1XXtOu5vphmj0YJsbPBLpww/pivM/iXo
+nXVhZXFp1Z1F0w2gJDvubsaOxaJSQACrln3n1A21SX0JNds+HvitJ0t8SZ3PxT6W6Q1fUXLC
+XXOlJhaatuY8NPYyFe3oBmvmfqn4DWUE0k3RnVi38AyRa3tu1vdoPQqfpJ+xr60+LXT/AMEd
+C1CWTT/gbddX3EbEtfTSRWFpyu7ekVsxmnXkZyVAxjivnHqrSfidrdsToyNaaRJkppOm28UF
+sq//AGsMXP3ck1nkjF3Z1+nlKqTPBtX0e40e4NteY3g4wPKqxzzW06m6U62sYjPf6PdLb9ix
+j4B98VjHjYMVKncO4rlqmdbdq0c6AKUgilAoM0hNppCDT6aRQDiJRRSgGgmhKUjFOApDQVxG
+0lLRQSxMY8uaD24paKBUIB/SlHajFLg0AkIaQ+lLg+lBBoHQhxTTS/lmgimiWAp1NApwoYIK
+AM0U4DFItIMCmkY4p9IwoKaG0ufSkooICiigd6BoKMUp7dqTBoBoTnypaKKCVYUUuDRtNBVM
+SilwaTB9KAqhD2oxxilwfSlwaBVY3HFB/pSkYooE1Q09s0vb86KcFpglY3tQc07aaNppD4jR
+mjn2p232o2/amHFnGiiiqOcKKKUCgErAZpRzRRQUkFFFLQVQClGaACO9HsKkYoNFFJzmkXyF
+opPKj3oFYtFFFAwooooHtiilpoBzTh70DQflS0UCgpBRiginY9aChM0tFL5/apKSEpRil5ow
+e+MGgdEi3tDcDEDqzj+AnBqRbWmycR3JaFs9nTIIqJEzKwIOCPOvUPht1nFp8q2WvdL9N69Y
+S5QpqVi0joT/ABI6MGU//TFOKTdMptxVpEnpf4eaPrcccuoaLqc1pJwb3R2WUwnzMsR5A9+1
+eu9E/ArRbi5trXon4rrbaxK+E0zV7g6a0i542yMpQn/CftVl0bY/2e472JZH6s6E1kyZEas9
+zYTBsYVGdRgHnjNe2dMaF8OerI59Ls+q9F1QtiJtO1y2+UkODx4ciH6c+p54rthiSPOyZ5Po
+wtp8HOoob2ew6s0+wtdQtcxLqelyNBNG482BBguF57gKTXpHS3w56hu1m0/SeptO1t0UNNbw
+OttcnHmYnUpIftWnsPh/8U9Hlt/2M2q3uipOT+zL6/UTwx+kFwrMk6diFkCn3rUWWlX1zerN
+qvRl1JIFwkphKT259JF4P/UpNNpJ0jDm3sptH6c1+xufButdTcPoNtPbrC6nHYjYFP8A05rW
+W1yLTFvqV9EdnC7oBx/Tn71azafrrW4hubu5mEy/TJa3Jju4kxjCmQbWx6c1P083kEMcGr6q
+2rmFNqPdW0cVyF9G2gBj71D0K+XZ00tljUSRyqQexi+kH9KuIb1cgNy3uRk/rVS9zpeSIUlt
+8eTLsNQLy6Kp+41EyDtslQFW9s0+fFEcOTNHJcwSTsnzBgbH93JGVDn2YZFVmrXKQxO15ctD
+EoILGAzqB7qFJx9qqY79bVTsKhD+IqTjNEmrSoglcO0BOBPBIVYH0z2z9xWc52i4xpkSWC01
+CNLm0TTLhXyEuLCXap9nQ8ofY1UX7S2BMUoCSHgpKMZHqCeD+tWniWl3cGXTtSs7i5/jS8tz
+bXRHoXUbZPvinyXmkSxC21e2udMYcC5SLxYgfLxFGQV9xg1zN2bLRmI7ueJT4N5dQg90B3xn
+7rXSKOK5Utbal4Dj8R8MOhP27irO90O9tcSWs9vNDJyksEg2OPUH/vUWOC8O7xzocjDj/eZA
+G/8Ak5rJp+TVSRGk0yU5luYrd4hz4sKshz7jsa62+23YiGcqBzhv/Su8cK2rhw4gdj2tpHMT
+/wDm4qSlrc3yO9vpPzJjIDwthJMHzU9mFQ19FqhsU2WBEe5iPIkg/lVlbzRYDyPAhGfpYZqv
+ispt4BsHsP5g94hP/lBrrFYWkUm0zWhkP8zZyfsKSsrss0ugcOsOixqDkMUBb86n22o2kr7G
+isJ9v8SRgmq6PTmbEjabanaMeJEysPzHcVMgtLdB++t+TxkDAFGw0XItrG4i3HwYvYZB/pSN
+aQouY5Q2exx2rlA48MRpChKjg+ddySVwe3nTYJiBymEY7wOMEV1Rohy0KMfLNQ2bnBzj1pBc
+qnKuCRxg1BdWX1nciLhbdR6nNWtveREgb8Ac/UuazMFyjDLOW+w4qdHeRFdrpgZ4NaRkZyia
+mLUDwsiW8ik/xjJNWlvJ45BA/JeAPyrK2M9s7q3hk47AjtWksLxEUPJMka5xgdzW0ZX2ZSjR
+cQRMeSmFHb1NTBGwXnAPbtXG0uY3AZMk+pOBUiSXjkgY9K00ZkOeFGzvbcCOxFUeoWnnABjz
+5xVtd3CqCC4DHy86irbGYEzDC44T/vWclZpF0UHytwJJJYWIz9KDPAI86vNMErW6fOuGkAw2
+0cE1JisP3ZTaFGeBjjFKsIikz3Qf0qOFGnOyLeWdxIpdLU7F7YqvmeSLhIcOR3NadJ1WPazD
+Pp61T3lq0snixOBkcjHNYZcfmJvhy3qRDtruV1V+R4S8+7HgVcWjFtsOc7eM+p9aqo4HX6Nm
+Ap3Nj+ldo7hoXQRncf4j51EJOPZc4qXRpLY7t+0jAbaDTb6VYoiFOW7YqNbXISNA2A/cgdhT
+Gk8VyzjgtlSfOunmjm4NMjWpdZmUKVREVT7v3NSWxG4Ud2XtXT92DvI9TUZ2LbZM5JbFFhRK
+twQy7Tk55zVlGPoOarITiVl8hVnC6qm/HPkKpbIkdzIUdcdwcGnPOVYk9jyKhNMIyMt54/M0
+5mDADPYYIq7ohoeZQzNHx9QBHvVXcThtp49D7Gulw0j26yj6WilHIP8ACD/rVdO/72Ur+BiG
+/OolIuEbJ0F94kZTILLUiO6HAPY/0qiMrAq6jBb8X/eui3a89mGew7isuZrwLaScrhcHIpEu
+QFJX86qnv0AMZlJx2JqPLqBh+oMRk4qXOi1jvRdPfBRleR6CnRXoI5PvWZF54j5Riu30Pepc
+byMAwbj0rNZXZp7OtlncXRLF0IIzio51ArgtkY9K5wiVs5/CwBoMPm6g+wptvtAoxWmPNyr4
+JX6W5DU9GLADOMHg1zWJjhFQgfap0FqQcYwD/nVRTZMmkRvBfGTgg0oszjIGKso7R1b6sEfa
+uvgAcDk+9aLHfZk8v0VKaeGk3fUPM4NThaE4xmpscSr3rttA8sVrHGkZPI2QVtSvma6pCRjJ
+4qTx7UhIHNXxRDkMEWR3NKIz6U4OvYGlDAYNUkS2NMTCkDbOa6Fx6kUm458j96KCzpFKSOFF
+d1eo6EZGYj9wa7rz2P61pHRL2SFckdj9s1AvrgxruABwPMZNTIywAzj3IrncRK65GMjsT5Vq
+jM8r6j+Jfw80+Y2/VfUXVPScygs8sUFxbp3wMsqtG2fQ1wtbrQ3tlutI6pvtSa4UmK4+bVZJ
+0Plt/A/lx3raXIFlO1xHqc7SgYLSxrMF/wCkg/5VhNXtOn7N0tL/AFmytobmRnhVbV7CYSHk
+7NqlWJ9MDNClXYUvB5Z8QfiFpuu3R6S1rpDTeuzBCwWW0nWw1G0k3Y8ONJtjMw7/ALtyD5Cv
+k34j9B9Aatq89ppHwy17StUgXcItc0eS9Z382cghiD54avr34kdZfC03idFdRf2mU6bu74xt
+DFrMS2FzDKp3Ruk5iKYBAOG9B61herOlUvtLfRvi78aLbr3pW9V/lfEtp4764dvwyQTW5EUn
+GeeAc1vdqkZRbi7PzO+LvTHVHS+qLa9S65p3gzDxIbHS18EIucDdEP7v7MSa80nMrAxQxJEn
+cgHLH7mvqD47/Cv4T9IsbLpvXfiHFcNMVgs+r9FW2tPC7/ublWcv6DNfOmtaK1tOUi0swx9x
+IJvFDD1BHFcOWLjI9nBJTgkjOMv9KbipckeVAEWCCec9x9q5eCe+Kz5Fe20ciKSuxiP/AGpB
+EaXITxs4kUuK7iM+YpPDp2HtM5c4xSGupjPpR4Rx2osfts47aNtd/CPpQYvQUWL2mR8GlArr
+4R9KURH0osSxM5AUu012EWPKniL2pORaxMjbecUvhmpHhE+Rp3hH0pci1hIhjPfFN2Gphi9q
+QxH0p8iXgIgT1pQvlUoRZ7il8E+lHISwMi7fKlCnvipJhPmOKPB/w0uRSwMj7fypClSvCPkK
+Qx+1CkP2iJtpNlSjF6ijwT6U+RHssi7OaUL7VJEPntpRD7UcgWBkbaaAualeCSc4pRCfICjm
+NYGRdhpPDwKmeAT/AA5pfAP8tLmV/bkMJTvCNSxAfSl8E+lLkUvTkPwjikMZqb4J9KPAJ8qO
+QewQhGaBHU4W7elL8sfT+lHMa9OQDEaTwD5irIWx9KeLRu+3FHMP7ZFYID9qeIcDmrH5Ug/g
+pwtfalzKXp0irMPpxSeEatGtif4a5m1PpQpg8CK/wqTwj/L/AFqwNufQ03wT60+Qv7cpaAM0
+U4dq3PHQAAUUUooLSFH2owKP9KKllIXzooopFJBRSkUYPpQOhKMU4DilxQNRGUUpFGOeaBUJ
+ijBp+KKCuI0DNLgUtKBkUDSG4paeFB5pCvtSsriNoFLtNKFJOaLEkHl3pQKdilA5pGqiIF9B
+QFqTCkLYD7vcrirqw6Vm1Nf9xuIZGOPpkbw2/U8ULfRbil2UkMSyYG/6fM+lSbvRb+yVJZYS
+YphuilXlJB54Pn9u4rQN8Nes7aRM6fBGzjMe68iXxPtlsGt38KLxundUueluq9O8BLtl+Y0z
+VrUPbSr/ADrnDRv6Oh586qGNt1LQpTSja2eUWGiXepOsVkgklY4Cbhkn2redM/D57KRZOorr
+UtBnB/Hc2LGAjyO44/OvddV/soaF1ZbR9QdBXa6f46h/lLq/AWN/NY7nGzOeyybT6E1K6N0r
+r/oO9k0HW+thZXNjhV0zVrNZzMmO+2TcrL7jg1tDBwlUkcsvVKUbgyN0fcdW9K20dtd9VQX+
+gXgAt5lkZokbP44ZCrIGHnG30n2717Jouk9ZfI2+rHprQ+u9F8cFr/QLqK01i0PfaYmJikcd
+1XIB7A1nun9T6OmllRuifh5cXEnN3FYXmo2HzLDzeyRNmfdPvXp/QetdNWMkEvRfVOmdKMXZ
+F0tLKG6t5ZWIBVp2QSHJ7bgCPWupJLo4ZybZY9P9WdRWN2raDc3et2sv0taSQPb3ac8o0X44
+Zv8AC2UOMhjXo/S3V9j1L4i3l9rYuFfw2tNZsHs7iFh3VZQNsg9D51OsopdWla41zT1S+CrG
+biCQbgAc8OncZ5ANXh02WaILLeTsVPDb84H2Pei97Mq1ok2tsqHFjqBGf4JGKk+2DwaL5tRj
+XazIOMMk1usin/X9DXDHyylZcTpj8R4NcP2okK+FCH2543NkD8jUSaBJka4voTEYrjSdgQcS
+2Z8dPzjY7h+RNU9zaLODc2MMEgAzut5yrqP8UTc1aXj2VyGk3PA+D+8j8j+VUN7G3Es0SXLL
+wt1AdkyDPnj8QrCbRpFEGe4sQM3VhdYDZEttdop49VPeu9vqujAbYb7UIc8MsluGLjPntPIq
+FqUcBjC3Wy5RjhWKnP5kdj71Sz6OrR4tj4sTHlJZPqX2z6e9c0pNPRvFJ9mwvdLt79Y3hWGQ
+ScrktGw9MbhS2cWvRSeHDZ6fqsUZAkiEn71VP/Kc59MisrpN9rvSs8ZkivbjS53C+DO3iCM5
+x+7k5wRnseDWouL3SNTU3t1phuLdCQNQsFC3Fvz3Ze7AHuM8VKdg1Wi0a4fTdLnE2kXttaW8
+mXEcC3KiN/wuDwQQ3cVCni1fbFLaJoGrpcL4kLRxpFJIvn9DYyR2IHY1e9OXF94Mljc6nDf2
+l44t7aeI7wyuvZweVIIzyM1VRz6dOs3T/UWnLbGF2AkZPFjil8pUbuAe5x71bVpELTKt/EJW
+KbQRbuxbMaxMpGD5Cuci6iFWG56Wvr61DE7kjffGf5lby+3atDejULTTktJppg8CzGSRJhIp
+jwpR1DdwfLmqBUnv0FjeQXV14pzCxaSCQnvwVPH6kVm1RrF2SRplrfx+La6fLMw/Fut/DuVx
+/Mn8X3FSoNNt2gjmspIpi2VxJEUKuvcENjntVXFpypa/OxyapavAWP1SNNIMea85atFpN3Yd
+VaPMkWoLf3IUNLFJb7Nzpy7DJ+glT2PmKSjZTlXRzh0+eAhhDDbyP+Jlhbt91yKtLWWeHPiX
+clxgZVNuB29GqqtIvA3Wtjc3NrLHz4LS+Gc+mDxV1Y399FBGLt7iOQ5IlkgDgYPnjNCQNkef
+XNGtsNPCI2fyJVCfyBqRDfWVwgMZOD2+oMP6VLubaVT4hjgeOVRIJFhVgQfTiuSNgYiVgp7b
+EAH9KTTKTVaHGyszITI8v1DkKOKeLKDy3KB2LLT0iuJABAuw9uTkmpcNrJbLm6Twhjgyygux
+/wCUdqmirpEVbYJjJXHtXRY4/wCFvpz3qVhTgtGSPLjFSYZbSNgpRDKfLZniih8iNbRtEd4+
+gHuD51YQXMUR3iDccfzEVKhCy58K2RQP4s9zXT5FH/HFhj3PYVXFrohyT7O1jq4V9ig8+S81
+dw6o7Jnbx6twP1qgFrFarwAvPGKiXUkrZUSljngnyH2quTj2LipdFvfaraQuUhnPjOcNIg/C
+PQE/51eWLRGLcmTnncTkmsJFYtcSBmckRkZOOa2FrIVij3cBQPzpwlfYpxSWiyZuyA++aYUV
+1JAAX19aI1yF28+uaeU3oQDwDxWjM0QpnKuuTgY7ChGDoJTHyTgCi4DthwAMeWe9cPEZcAHt
+yRWZqhzxvJIEUHOctgd6aLRmwwUZzge1SbclxhSQR3967lMJkeXBxUSgmXGbRFjR4wQ2W5zn
+0prykqueNjHHp2qTKWJ8BQewJI8z3NR2gkAZpEBABI+9ZNVo1Uk+xUuCLFZJGJdv8s06BjkA
+kZGSajTDMEZLYIXkAduadYviXaeSF2n75ot2LjqycsgRgR6HNSbW5PhhGOTk4JNV9ydkRCjk
+kbvzpIJVRFDEngkVXOnRLhask3lw4mCA8AZz75qWJt6H6sM3p6VUSToZFLk8gn+ldUuAIxnO
+Rx96pTJcCbI4kWSLOAy8VUShhnnKtzzSz3UiyLtIA7UH6n2rz6fapk7KiuJxcb0/FtYcZAqD
+Ozh+OPPNWrwsOw964PaAkPIp7VnKLZrGSRVtJI5553ADk0iCRiYJeAnIc9x7VY/I5GNgPPlU
+l9PiMe5s5A8uCan22y/cSK62t3Rtx5Hlx3qwt49rAcgE+fNJZQHbslyBnAq0itQOBirjiM55
+vA8QIVXC4AGK6R2ygeldol+ninH6SDXQoHM5+DnHbqvbkd6kRRqfI+tMVue1dE4PpVpJEOTZ
+1FG3J/1pePWjcBwDViE2kcg/0pAf8VPLDGeK4tIOwNADy1MLt5GuRkJxik8QjOc0rChXZvSm
+rOQOKVpAR3ri/r5UX9AkSVn486US57MP0qKM49qUR4Ix+lHJhxROSXBxk/ka7rOo7k/pVem4
+DB/712DuOQQM8cjvWieiWiyjcNg4/PNPkhWZcMCR6ZxUGGUhsFSTU+OUY+oce1axZk0Zi/0f
+ULPLWMBud5JKmdIiPsxrLa0b2OJILnStWkieQeNKutW4aBfVQcsftivSbqIyrlJNpHIBXNZi
++sHacPJa6a2DkvJZgyH7N5VWrsWzxnqfpuy6sQ6HedNaN8VNNut4ubDUrixhktUBxh1+mR/+
+YHkivn/4g/2fNF6Piuj8K+nbPpi0uEK6n07q+swXuiMnJMoPjNLbnOFG3HPavrrqnpG66n0+
+90z5fQxaTKNu63RXOBnBYDdnPY14pqHwQ6X1nUA1x/Z6TWbuEqlxrNrdfsBLqNQeWkRi1xj1
+ZRnGa1+LWjNWpb6PjvUNH13QI10npXWOntKF8vhyaNedWxanaXTNwDEsrsy+2ACPWvH+uvhv
+1fPqclvr82maBFCd09ubqFnVfNlSIliuAT9WPLNfUnWfTWi6LZ6hpWi9K/CfT7EXXitLpEl9
+Jq2AchJL2XIVcZB2YBPNeG9daf0p9Uug6T0Zp9ndYEiR6r4807nk7pWYuVB7g4zjmsMkfB6P
+p5nzXrNlZfPyJpssklsh2RvIuGcfzEDtmoJsfLbW/wBd6ZvpJ2ubcabJCuQosplZRj2BzVMv
+T+svALpdGv2gJ2iUWsnhk+m7GM1xNSvo9aLi12Zg2WOSKYbXHFaO40u6tlVrm2eLdwN4xmoU
+kAHkKkviin+W+9J8vz2P6VaNEPMcmm+CPMUrHSK0W/PY0fLH0PvVoIB6UGL/AA4o5MXFFX8v
+SfLn0qzMQPkKTwR5cUuQcUVvgetKLcVPMQHlQI6dhxRCFv7U8Qe2al7PalCe1DsdEXwBnkUe
+APQVL2jzpCoFCAjeAfOmm2HtUvHtTgo70ARBbe1KLYd8VMCilwPSgKIfy4xyKaYFqYa5sPLN
+KxkUwA54oMHHapO0YpcAcgUrAiC2HpS/LD3qUFBPlTjj0p2xUQjAKUQVJIzjPBpUHbIGaVsZ
+xW1B5xTvlhUoD0ox6eftRYNURfAApRCorsc9qO1AHHwQfKm+EB3HNSRikwBQBwES/rQI18xX
+YnNICAaEAixA8UvhDA4pS4pN+T3p0AqxjPauioMc00Gng+9FAIyD8qbtA9vyp5b2oxnyFIBm
+wH/66PBHfFPyBSbxzTQHJ4hTPDFdHbNMyPSmBlcUtdxDQYvaunkeH7MjhS8128E0oho5DWJn
+HFLiu3g58qUQmlZosMjhg04DzxXbwj6UoiPnzSstYmcdueaNvniu4i9qeISRS5FrC2RcU4Lx
+jFSfAPmKPB9qXIr2WRtufKl8OpPhH0pfCNHIawkbZkdvypCnvUsQ54pRAfSlyH7NkLYR705V
+NS/l/tS/LMOMUcgWBkULmlCGpYtz504Wx9M0uRosJC8PzpdmKm/Kt5U4WjelLkP2SCEIpQhz
+U4WhPcV0jsyx4xg0cx+0Q4YpHYJGpZj2GKv9Kj1iwcS2MU/iDkw7Tlh7Dzq26X0HSdaY6bfx
+zw3D/wBxdQ8hW9HUclfccirWWw6g6G1NbW6u9RsZkxJGTGsiOO4dCeCPcVcb/IiddGm6B+JM
+jWQ0W9sbK6sGz4i3VmJ/ByedwH1NH645Fey2Oo/CPqTRLOx+IXw31XT9HllCWupaZO+oafGM
+4Ox8eNbEdyBnH8tecdO6r0Z1VLDNrukabZ62/wBUer2Ky2LSsPKZU3Jz/MFznuK9e6U6c1zp
+qM61ov8AtRpMMsgkN9YW0Wr6JfED8Eptzvjc+rxj7iuzG213Z5uaoy0qZr+mvgV1b0jF/tF8
+J/ig2q6FIUltbaVPHM8XfYZkI3j/AAyR5/OvbtP1LpzqpLfp/rfRLSxvUjVY4dQsTE8Eu7JN
+rc4GFIz9OeK8r6Cm6bt9eElrq+udHdQXbtI8cEbGyuSeTmOTjB78c817foWoy3enmx1Mx31v
+gvMSsd1Y7ycEMuSYs98cYrojpaOGdt2zrqnwcszaxnTup9bmgQ/QsgWQw58/FGHIH3zVZa/D
+jqzT5EFx1JHf6bEFKKyl5QQcjLEZx981rdF06LTSF060m0xQ+WjhkJhPuuewPpWoj34EjLGz
+DuQMZH+tT3shTcVRnrOK5giEfy6tEo5VV5HuKnJeRKu0sDx58Gp7i1CnAaM+ezg1T6lJvG1D
+DPkdmO2T9POk9AnZGvLvjaRzzjnHFUlzcRlyztJHxyVwcflT7u+MR2tF4iY5G7DL/wB6qLjV
+rNgTMuYzxkHlTXNOaN0iPc6lcRSkGQR5/A6c/qtcv2jJM3hM6wXGcYTiOYeo81Nc9Uii8H5o
+yNPbY+m4h5aEf4h6VQXjTW6stwRLEAWSeM5BHt6fauaTaZrFWXwvJpJXs5YAJD+FJOx+zCuV
+tKk07x6bIYL1PpktLkAlh6xv5/bg1UwatK5tIxMshZgI5QfxDGRn3q66t0drrT7bXrBlieUb
+1YH+6m/lHscZx65pK2rHVaY2xv5LS9Elo7RbpAJ7dxuikGeTt8iPIirWLTbmw1V7jSpliaYs
+yEndbXqea/4JB796yQ1FnvBcw4SWaJWYYyEfHPHpmtVpV49+ipaTfI6jMC6RAho53XvtzwT7
+d8VMXY5RcSZaoj2t9p9nO+n3MgS7t0LYYSo31IjDsMcip17qs1y8N3dbQ8kam4VkClpAPTyJ
+5+9cba8juL22uI7MLeiQifTw2PHIH1tbM3/ExkmJu47VI1yxtLuSxsDcxW816HfSr0A+HO4P
+NtKp5jf09+KrwSnvZe2GjSXlk8emx280YtJJLS3lP95xkxAnt5ge/FYyLT5f2na3WiM0Vjqs
+PzVjFOWintp0OJLZx+HcuOPUVdaHqdzDpUNsQ8N1A8o8NuQCpz9J9COR75p76qs97b3AMY3y
+eP3wu4jBP3ok1oqKZLl1S7S2+altYikOPFcrgRsT+IexHNTLZ9F0/XLeW509DHcwtJN4C7Vm
+gZCJQcd8A7x9jSNNbalplxY3tv4cjxNCAuNr8ZDexqB1KIobbQ7tsKhiW3+k7Sufocn8qLrY
+kr0MsNKi0r5ax1RzPLps7QvIy5lZAMAg9iCpB58jUrTdIQL+zrLU3A+d8BDnw28Rk3BOPVR+
+oq2jt4rzp4BJWa9tJhDBMWy26IhSsnqWT+gFcJ9JaNj4Txh7uSC8RmHEd5A2QAf8Uf8ArT0A
+ZubXpvTr+C5kV7a6u7ZjKpc7AVb88ZxToNQuHO4SRMG5J8LH9BWwXTYJNNin0hl+SnuLi8VC
+MlTJEMoPT6l/rWYks7q2mu7ZY45plTCuCc7mXIH9OaJRoIysk27ichVttxP8aMUz+VT4re2s
+mynysQHLOz7nY/n71RmRpYrq6aceDZlCgYf3hYYHP/Nn8hTI4XQSM2yZkK5XGBk9hz+tSXRo
+VltZWz82spJy2w5/KpMPycTE/KEkjBIXkiqrTPmVdmMunREDCQxrz7E4qe4uvwyXmW7/AEYU
+D/WkNfRZJcQkhQJUVeyBcYrv40QXmYDP855qhR5WYK1xM5Bz+Kp0D5Us5HfgnGTTTsGqJ0hR
+xsJUjvxUN4lZ/qXkdlUVNhGQASp3eWMU+SKQHcGVPPgd6GrBOhlpCYdo8NVByTu75rsl4qSr
+4rtI3nt4ANcD+DaARuOMnzqK2ckKOPNuwqXcehqpdmqtLuN4w4AXnzqaso8PB27j5D0rL2Vy
+8YwSMYGKsbS88STYAcnP1E+XtWsZfZDjRLmdYyoC5J4B9KgtD4QYEnOck1YEoSZXOQPw+xpk
+kauOR5eYoYk6OVrKMYOETt7mpkbZIOABVf4YwrKpz2A9Km2qYJycKDz70htneONd2SOSc/ai
+4RCGTnjk/wDaug4O7Aycsfaq26uXT6M4wwY49D2H+tDVAns5zRts4U+p96h2xZLlACdwckj2
+q1jwzqjj8K7v+1RRCVummwMM2eKycPJtHJ4HzkuWjB7cmmpA0i5GQVz+lPQbi/BIyf0zU2NA
+RgdyKn272P3KRUSREFWAPJK4rpEp2FB3U5/KpMse6Vc91UgemTSBAp29j2NJQor3LRVXjP8A
+3inhScV3tJ1fwznGRj86fdW+V2g4APHvVYXNoCB3L/T6CqSoltNGgjIkGT3HOKkyQI8R24GQ
+KrILhTtYZIC4qXFcDaMEnHatFRk7Ejt8dz2qT4SlCpHekjO5t2MZ70SMIwrHnjafeqUUiXIh
+uvgtuU8A/epiSNgnPIFQpZ13hGB57edC3DA5z7EetLod2XEcgO3Pn/SnZDEjI4qDDMWA4x96
+7iTnkd6tEM6mQL3GDTo5s9j+tRpJVbgE5Fc/FYdmBNK9jqy0WT2FcjI24k9qjRXIIbnkdxQ0
+38pFNsVHZ59q7s8VyNwDznNcmmDdx2rluGSR5VLZSiSvFHmRXN5fI8H2rgXBGNwB8qbg55J4
+pFUdGmcHjFPV89yR965YJPb86ePp54oEd1GRwcU7e4GCoI+1cA4yAHBpwJbsuPfNWSyVHMc4
+4+xrsvhuv1L29KiqW7lifepET8AZP6VcSGSIjGpA3f0qVGTjhhj7VCLMPKMmu8UjD+EH7Voi
+WSHB888+9V94oC/RuYnjip29ScclvSuMq71IYkZGMjyrRGZnhZwzmRf3kUYYhjLwG9cDPb3r
+CdWdAfDG7gutW1LonU+pL2zjYCC0vZImI9FLSJGvfPJ7VqupOmNb1eVUsddliQEZVQqnAPb8
+J7+ftVDqPwz0S6twOpultO1NzOJVggkmhRcZ5lO8CQc5IwM9sVpEiVtHinV4+D2m25TSOhrW
+/wDkgXSHVdZuYNNsjjhSIkdpDz+CMNuweRXj99rmg32kXGsaf/ZH6HmitZPBfW5+kp7SxaTd
+kJALphcXbtjGQFUZGTX131bZyaFa3Gv6bpWm6dbwDdLdX17ItsiKvLRWy5aRgB2yo4xXyD8Z
+Oq/hXqOp3HUN91JHq2t3LjxNVGi6ubiSNV2mCJ5BtUYIASFQBjvWj+xY3fxZ5j13/ah636V1
+F9J0jp7pfoGO1QRQ6bo3TmntfKvn4kvhssZ9skjPc1411z/aF+JnWkYt9V6pupogdyi4VWYH
+1AUBFP2Wr/qHp3pbWdRlsuiui+ok8cD6F0SaF5D/ABM0krMwHmScV57r3TemaFDKiQ+DcZK+
+NeXyEKR3CRgbifc/lXJllk+9Hs+mx4UrrZlr/W7i+P8A7QghuJB/xWXa/wCZFRGl0WSNhJaX
+cEmOGikDqW9wef0pt1CY3wWDD1AxUNlJrhZ6Y4RK4zE4c+a9jXPYB/60AEeeD6inzSK8pZBg
+YAx70rAZ5UhGaCcdzQDQ2AzAHtijAPOKfkYpmaQDSvP+dJtGadkfrSZ/+nrQAmABSUvB7kd6
+McVQCYpdmaBgedPBAHJoAZso24roSPKmkjFAhKDSbqaXHrQMU0096MhqXI9akBoU8UGguKQu
+uaKELn8qMg0m4etKSB5UUFiH0xmgMAcVzaQUzxQOKdBZLV+KUv5Aioglx60vjgfeigciSWz6
+U0n0NRxOO+aesmfOihckdSTSUwsRyaXf5UULkKe+aaT96GkA4pgcGih8hST5Uqg5pyJnmuqp
+xjFULkIBxTwKXafIUbGPIFA+SEzigN/WlMb98GkCH0/OpoOSEbOOKYSfXFdthIoFu7HGKEmL
+kRiCfvSYap6afIw4FO/ZkvpV8WL3EZoR+1IYx6VIx5Um2ixcUR/DHlSiP2rttpQoosOCOaxA
+nmniFfeugGKcO1S5FKKOBhHpQIMjtUkDsKeFFHJj4ojJb+1d0th6V0A/zrqpx6Um7HRxNtn+
+EU0W4POKl8EH3pRg+XvSsZF+WA7ij5Yd8VLIFKFzx5UAQxb+opy249Kl7RRt9KAOAgH8val8
+BcdqkgUbSc4oAi+CvkOaURD0NSfDwOO1KE8sdqAOIiGO1OEY9BXYRmnrGTimkI5RwKSAwx71
+OttGu52/dWzupHDKuQaZHDIveMkH2rXdI6NdalK0Oi6lbSzDk2M8/wAvK/8A9rY8E/Y1UY26
+M5SortM0i3MyLLqH7PulP0MxwufuOVNev9O9ZWEelL0F8ZrOPU9HnULp2rNH/vWnnyeGZfxL
+k8g5GKi6HbXFzex9N3F+sOplgo0fqGFLdpMntFcMAjj/AJiK9QtOiurtMsUtNf8AhPompaWz
+FJrUQyhh5ErJGxUEd9y114sT8HB6jOumUlv8G9PgvrZtP1+36gsruFZ7K5SMQtIuf42U7QVO
+MsOK9X6F0O90G58a+6W1HT71hhLgTrHB4eOcyxPtkyewdfzrt8OegvhxbIp6K13Xumb6CN0k
+0fUmiuYwW/E9v4gAkXP8Oc+o869h6a0HQTCljp2tWM8qjElldWzRRynzwpJAB5OAcV0xg4Po
+87JmU0Vtnokt9Y//ALzdHRa1p85CRI900Yh5BLxSA/S/54rV9KfDLpKwUy2mnXluAxaGT5oi
+4VDyEeRceKB6MDVpofS40qST5Jp4YJOZLKKYyQZ/whu1aWKOzhAMavbSAY4/CfuD/pWqu7Oa
+UlVI6oUjVQ31qBg7hXGS5iJwgwfIA1zupSM78cDOVOM1T3F4FLK7gjuD2K1EnQoqyXeXRALG
+TaazGpakviNCZFLDkE8f1qXeXa3cICShsd2HJH3FZTUnRW23TlVfOyZQdufQ+lc2TLXRvjx3
+2Ou9UMojinJ8POPE7MPz86zt+5t2LCcqxP8AeYyjD/EPI1MnunWPdhZB2OexqAGtryKRLaQ8
+8PGxztPtXHN2dUI0cYry/s5TLYTiOUDMZHKMfQjsQaZ+1INSWS40+3+UuAp+asicoT5sgPdf
+buKWzt3uT8pZvsuIydqMACrDngHuPtXXWNAvfFhvo7WKG6ADsocASN54zyD/AN6h3Rek6ZUg
+MCPAgcZPioUAZS3njHatr07rbW8j6feOLrTb1fEWNvQ8PG2eVdW5HqCKy9to2o6nePa6a8Nr
+qK/vVgu2EYZgOyt257U+9t+oNO3LfWlzpiMqB4bhN21wO6uOGXyyDQm47CSUtFj1poFzoC/t
+G2eR7NR4izRjdJHGT+Mgd1B7+nnxVd0/rVjrCXug6xdfLy3EQkiuIG/dyMD9NxCe6ODjcB/k
+an6B1fqlqjaZrmm2up28X120gkKSA+aHHkR38jVTedO6O2+56PvWgTxTcHQdXOx7WRvxfK3I
+42E/wNjHrUuu0WlSpl3qN7f6hZLZawok1y3jUzBW2JqsA/DNE4/46EAgrg981Z6V1NJrdr+z
+tTVLiS6QKJpF2/MEfhY/+HMD/EO55rG2Gq3MA/2f1S3urYpIHhiukOVf1STsT6EGrC2lgcsz
+zfS5Lbg3Ib19iKSextJo391eudOj1G+uGSZUQ3VwqDHjDjewHY4wG/Wqm5uWCwW9yTDLCF3C
+MBoycnkfcf51AtOota023lvIp4Z7lAAwniDwXkbfwuvYkjgkc1ZxRaF1Dp6zWofT2hcGSzlc
+srDzVX7j2zVN2Slx7LXpzUs3HyN/PhInDRTE/iU/wnNay9gEtqljO0DIkguInABzjOR9iD5e
+dea3dlLYbmtJDNbon1QyHMkbDtz/ABA0um67d29qY5jJdWn1HgnfbN5EHvtz5UKXHTG4qTtH
+p2mt4cFwbZmIecTSxN+IEADI9sYzV4EQboywliL7w3mrDsR9u1ef6fqeq2OmLqs31RKEZ5F5
+dI2OFldByqE8ZP37Vo21xVVp4lQwyLuWQH6ScdvbmtIyVGUoNM1VlIbS1FlnZCrBkIP8RGCP
+1qOgfMs0igyxk/vB59wMj86qbTVDqVrHLExLZIlj81I5yPWrI3Akw6MMEYYeZOatNGdUNTSk
+/wBnJ7K0i3mRYyEc7SpTsf8AOuk+lqi6fGLRCsT+LcbTw52/1+9dEvEM5yxJYbgR2PlipMd8
+skaxtgEDBp6Y9oqIYFmu7ia5hghDHcViYksPb0FdoX3A+BbpGpGAFJLfnmu720YkcpH/AHqA
+hgOxzUqC1t1ijRXw8jZY49uBUcbLU6KeVmBIJOz1FMh1J7TaIUWR/LPIFTJbSR5njjlhWNT9
+IZ9pz+dMXSbKP6mlhMh5ZjJuxWfCSejXkmtky01C44a6uBu7gKOCamx3shIberDtzkVUKbKP
+Py8rS5x2Q/nyak20xbGFIA7e9XshpFwoZ0XccscsT96Uwq64YZPtVf8ANynIABx3wew+9S47
+zwoxhVT0z3NUiOjrsSDcQpHBrnHqCWzLCZP3kjbRnyA70x7mSfBR1UDvld2ftUWVY9xdM/mP
+qJ/0oquhlxb6x4zSDdtiR1QE+eO/9asLa68eIyDk7trD0FY25v4bBcyBiET8K8n1JxUzTtRl
+tFaSN/EMg+nPbJHc0WHHRqSwByCMj+lSrNmwQQOOw9qpLK8DwhXYliw3D0P/AGqys5gzTPuz
+j6fyxmhdiZPaTMZPr5+1VU7KZXLty/1fZRU9n/cLIcKXGFXPl61R3sxDhYgXDMsakffk1UhI
+sEmkd2LYGR5U7dvPB4J5+1cCQzHbyGAX7GusUkfMe78H4qVBZ1QlNxHmfp9wKlpKAVIwfWq4
+yky44CsNqn0FdY3wVz/Bkn3peRo6ysJGbnADZH2rkZTuYt9Q4FcA7CMk4yW7e1MaTazHB2il
+Q7JM2GiWMHtznzqo1KLeGjYYRx3HkanfMxkZyfQ1xuFWdARyQeD96TVgnsqrO6ngjQM+73PF
+XltKvhLnuSMH71n4tySmJgMZIx71LjuDEoTOQOMelEdFS2aGK4RH2swAPalnuEkTA8qpYr6O
+SMsrZIGcGkW6LKrKeTg4quRPGyTJNtc92we1Mkk3EkHb51GkkcuGVsZI3A+lIJRuBYHANRZV
+US4dRZH8OQ+fBz3FT1vg4BWQc8VRTxopDRuWTurA9vauqvlQykHJyfvRbQ6Rei6VgQSAQM8+
+dcnn3YGBk+YqvWbKnPYefrT1lXPsKLYqLATYbny96cJSwPl6YNQ/GBGMD3zSeMmcAkE800wo
+ltISeWI9aCwA4fvXFZU82NDSD8XrTAkbvXFLnPcg1F8VVxkEn0NMM57n1wAKCXsmeIox9WMU
+njJzkkD1NRHlCYUr9RGcelCyq55UMB3DUBRLEy44I/Kuiu3bOfTmq8eGCGPB9FqTFIjYG/8A
+r2qkwaJgLYH71h7dxXQTSrx4majiVNm4nA8veuMl0CSqntVp0Z1ZYpI5wS+0Htg1ISRv/EOf
+vVMt0RyBn3Nd0uG/CGxjnNUpCcS4jnI+lcgebHzp5mBHm1Vsdwx4YhvvUlZN481+xrSLsho4
+apM4tZDJeXtvGRylioMz/wCFSexP3ryTVuoPjfqd6tr0B8M7LS9OinMZutd18LKVHdnUK3J9
+EJ/5q9auYQ6k7scHGRnJrF6x1H19b7rPRND0C0kfKR3V3PLL4a+bsiLwexCg49TWilRDivJ5
+/wBR9NfE+MQ3GvXfRmh3QYM+p6nq8rJbxtnLWtouXlK8YMhAZiOMVi9Q6T1ronddXXVerdS3
+E82IEfWxJf3k2C52xSOqxljtGwcKCOOKu+v7nStGSK56y6y1HV+o9XDpb2PTukXEt1Oy4BSJ
+0jcQL2Bkbtkkc14r1R8E/it8R9RMun6HHoSJEsVvZaPYaj+0Yov8dxIWO85JZ2KliST7bqls
+ySb0jzb40dU/2wdQkubfqHR7/onpxcodO0u6jupyp7+N4BLzuR33EIPSvmDWfiFqtreO0OmB
+rpSRJd6tYI1zIe2dpXCflk+9e19YfADojoDWZNP1X4rN0drskpMy9Sak2oXRJ8ha2LPLvJ/n
+/PFYDrLSukLW5hsU6g1n4g6g0YjjknY2FshHcCEAyFRzkswwO+KxywlJXZ3+nz44fFHlF91B
+JqTMLiwtDK38UabT+gqplcIcORnzGe1P6l6jjW4ksNNhsYkjYqz2UeI/srHJb7k81nPnHY5J
+J8+9cMo2enDK2ui6adf5hTDcr/MKp2umPemG5fzqHE091lwbhfMjikFwv8wqnM8nn386Y1yw
+zRwJeai8a4GO4rm1yB5iqNr1/wCamfOOTyeKpYmYv1aL4Tg8ZFdFkz+dUcV2xPfGKsLaYN51
+awi/ukWGeK5PKF4zSl/p9qr7qfbkCn7Iv7sl/MD1pPmfLIqrWcnj1p4c+RqfaaLXqOXRZC4z
+/EMU/wAXPZhVaj81JjkA70uFFe6yQXPrXFpGB708EHzrnItLiCyMcJT7UeKcjkVw/DSBucZp
+KJfJnfex4zimlnFPiAYDmnsi+RoopMbESx5rs0Z2kgHimxYBqQzLjv5UqHZXygiuR+9SJ2Hb
+NcRye1HRSi2AzmlKGuka5NdgmRzUWX7dkQKc8VOtrVpMYXjzpiRAEVZ2RVB3osax/ZyksGVN
+2M1XyJtJq+mmTwznzqmnILdsiiw9sjlSR3p8ceTzSkjFOSQA0WHtkqOL6QcU7ZzgCmC4AUCh
+ZQT3/WjmV7NkqGDcR9Oc1ZW2mq4BK8ntUS0kAxVvBcqoBrRO1ZnLHR2h0KKXCle9W1n8OJ9S
++m2Vtx7cVFsdXtluU8RwFzXtXRzWktnHNEyHIyMedaRpnNluJ4pqXw11rSSTcW52+wqrk0dr
+YfUuPvX0p1J8vLppScqWH1AH0xXgfV17BBOYY3XvzTlFR2Tim5umQLS0QpnYDmpPyUX/AIdQ
+bC6Y8mrD5lfQVCmjd4tnl+R60orkH9KcGGO9ZNFWh4xSjHNcy49aUOPI0qHZ0xTgCRiuYbyp
+6tmlQ00PAOeBXRRkcniuamuobz4oHYpoHFJk475NAOe9FBZ0BPrThTARTt1FCscWwKQSD1pj
+N51zL806DkSlfPOaeDz34qIr12WT1pUHJEgV1jTJBxUXxK6RzFcYNFD5IltEUOGXB9DTcKDX
+N7xn5Y5Nc/FzkcUULkiSAtSLeISsArDPvUFXy2CQuanxJLCVeWBmRuzLyp/MVSQnNGr0LSNQ
+tHjuL3p6bUrFuHQblOPVXXsR+depdL9J9D6xtUtexwsuTDqFgLnn07KR/wAwNeW6Ld6xYPC9
+lexQqzfQZW2jP3r3TpTqK8vGg8TWbyO/tyjC3t984EgPDBBGdy/b8668MV2cHqcjXRs+nvhN
+0h1DbRWF58Q3kscYj0rUdPaeNT5iNpDujPoQ+K9C0T4U6v8ADmNLvoH4h6roscreGluNRWMF
+jjjw5wY2U/yg9q6dM6bYa08Fz1F0locs8shkGo6Ve3Omz575ltgDFx7AZ8xXoegaHYx3MVj0
+/wDEPqCyMmS1pNbW93ZY8stIMj7YrrglXR5OTLKT27JWjz9RWCRJ8SvhkgYcnWdDiCtI3k72
+53IT67CK3WgxaFrELXmj363KglXiksvlbhMeqkc/lXXQtH1ixJRv2bLIy7SbKZ0WQDkfQ30A
+/arb5pVO29imhYf+JHjH5itTmbt6OZijtsBZAPIA96hX11LEv7yIuvqCD+oNSrq6XaUjljkH
+qDniqm4cFRswfUedTKQ0rK6W9IJAk3KewU/h/Kqa/vWmRghLEd8V21fTrPUEeS3u4IbhM43u
+UOfQ+lUU/wA3aAC9V4XXtJncP/MODXJkkzeEV2cZr2Xd4kBKup5GcZ+9V1/qksZ+tAfF4eCQ
+ZQn70+4vIJHEjOpJ4MiYwRUW8DAeC5t5YmGdspI/QiuWTOiKIkHiaiJLfS54/mFyyWlw2GlX
+uRG/Ykeh5qmmWOWYXQ+ZsbuI4d4+GVh5Edj+dWEsFt4SvFPE207hndhT6hsZFcLi5WYj5i+g
+d2/j3ZLAdt3rWTN4j3ktJgR1Fpnzkcg4vLORoXRvJyoyAfcVJtbu+m0mWwurm11hVYGJLtsF
+sdgH78g9xg5qJb2OqOrT6TFOzrx4lsviKPv5U8WXXEbmVba2iVR9azeHFu/6T50Jg0V97a6N
+dPHGby50eWLAMF/uliUgeTAZA9DzVjp7dR2Ns0ltcxXdnyuFmLRt7DORTZNW1a3KC5MilcEm
+KZWRfywc1JWeCWAXCanqQlPO+MIEU+hUYqf4BkK5xdzg/JywSMMbrQDxAT6r2NdJv2zaRI7t
+NJHEuwRTREeIh77gRzxTb+V2VZf73d3dV2n88VEhiviWlt5LiSPvkbgyn9cYqTQ6PNLCyW0a
+yy2bDcsJct4R8gue2KfHBaJKfmbC4TvhkAUkn18qaRczoI7iH5pXONu7w3HuCvNSrKz1pYmT
+Tun7ydQcHNxGvHr9bDIoQzpZXzWlvJpqWNzcxDM2BIJCMeYU4xSWFxaxSv8As7XJoDOAQLlc
+BePwn/Ko5upIGYz28dhMgO03MayLnyP0kginQ6zqd4qx6nrEcwZ8K8cEaRr+QH+dMC8fWLuK
+AR6jaSK24fWrAhx5bT51PsZBqwYaF4l1fheLGYLHJIQfJc4fHfGecVQWtvpzQ/N6vr4tbQki
+KJQPGuD6qP4V98c+XrWiitPhdLawpcdM6lIrwh2uNXmuUjJzgkCDBjwexJzTSbJcqIVjqGrW
+mqTx6jd3Njf3kn79rlthuT2wwP0njAx24AFXmna0lvC0tvdxtaNP8rMjriOO47iNwfw5H4T2
+rgH0O1sza9Natp+pQKviPZX+uPclPVVMyZA7YXOaqYLa4Ed1LcWKRxaohS6t/m0dGTOU+nOc
+r5N3pP4lr5KzZPfWvhyQW16+nXR2yQK+cQzDsVI8iMgg8EGuLdU3sjr4l4lnKynxBu3Dfnuo
+HOO9ZWS7DxRJeXEzqMwicDJ2rwNw8/uK72i2klxcW8EV5fIbcqZ7K3eaOGU4wJNoyAec4zjN
+K22NQjWz0zT+orCO1IurgvIMeHNnaBkc7k74zSx9VNE0viBXUHCFOxFeX6I8EV8PAkl8G1Ro
+4Ypi0kcLnyYnnA5IBqxlv2tHdZ9ruAGeVTlWB9Kr3GkQ8Sb0ek2fVVu6gySAFckjPP2qxXqO
+2O0r2XyJ7V49Y6rIJZNsLOChZVJ5U+td9O1Wa6hNut2yPEQrkruYn19hQsrB4Eexvq9mIvmA
+quWP07vWmLMzAtLOS5P1IFCqteZ6Zf3S3LNdTfSxCQIpJGR/EfSr6LWLgyATt9L85z51ayWT
+7VdG1VZGXwvE3t3zjhR6V2hjSJyoO5VUnOKz1rrQUgCXBPke1T4NQDqRHKBuOWOeafJMhpln
+JemCP6Qi58qfHIzFndDI5457CoURiBM8gB2Djd3++KkvKqAIVbAUE48s/wCtNMlndpFH98+A
+eAq8ZpTJk4EYRfIY71FWX+UZ2jknk59K5tPKOFZUY/Ud3IUVdiK7V45Jr+OIRBI3ITeTyxPk
+BVrEI4owsalmVRgk9uPSotnp8sLie5la5uny6s/ZAexx5far6w09Ik3ykM7ck1KRVoi2pkQr
+IQ31A5q0tbmRIliTAaRy59s8VwuiocLGOB3wK4KfDmaZj9CIST7+QppUJ7RNk1Jd00sjkqz7
+IUzjdjj+prmLqP5qJOPowML2yazsN5JJJJJcyqZVfMaDsF7Cu+mXIMy+ExYNJ9TZ86OxNUad
+5hC0gHZSSvPc1BfUQjCEkgy/jPpUWS+Db0U5cE59sGqy4usXImc/TkUS/QRRqluV8NZN2fJc
+0i3mAql+ScE+orNtfuu+FRwy7sscAA+9S7O5Fw8UQORHgFvInFCY2i+SUO0iN/EuR9q5CdHT
+hsnzz96g3VyYoWlBIf8ABmqtNQKKTIcDjAB8/Om9E7Zeu4H0DlXz+VPgnQj6jjwxz7iqdb9C
+ok35PYY8qhfOSQ4AfOTh+aVjqyfPceHdszoMMS4Hn3/7UlxIs5fa5A7jB7CqmW8Mo8SRuU4B
+9fWnQ3gCgM304Pao7L6R2kvxDKGDfiJwPapVrepMNyy9uKyWvagsTxOkm3E5jUA9ztzj9K6W
+OqxttMh25wQ6+dT0yqtG0a4CsA8ikMAc0JcBwfDbJHlmqX5oCPLuFZlyMf51Fh1cLcCHxshw
+QDjzxkdvtVaXZKNRubG5ABkcKe2aZ80oAQD95yXGMAVnIeopGk+X8EB+CozyQfOpf7aifSlv
+JI90c8hUfVztBI4P3B49qE0waaLYXaAIEfcGbaDnz9KBqJBaMY3qcMvmKo9YMv7Ca4jXdEJw
+6NtOSrAd/Q9v1ovJi1yWWQAyxkgZ5LMpKg/pUyZcVZfPqATI8UfSiscn1rpDdvNC86DcxIjT
+Hr3J/TFZlpY1+WVin/uiE4/iIAyP6Gp8d7s06Aq6+M7uwA5CAngn8qSkDgaBpTBEJTNuXIUY
+7M3pUi2ZzEZrny8j5n0qntrw3M8VmsW6OwdN7HzfbyP61KN9+9LSMzhshY1X6RjliT5+QxVc
+hcWTZ5hGS8rAvjIHkB605pDbD983+8MBhP5C34VI/mP9BUG6klG68UFThFiLcqPPdjzx/nUe
+GKS1uZrxnZ0SMlM5ZnmPAY+pJJPoAKXMaholy3sZupLePLtEuZG8lPZR9yc1Lg/unJYKFGZH
+Pliqq0tJIIwm3kt4shc/iP8AMx8gPTuewrnqs91PGlnbSsEkYBpCMFvMnA7fbypKTW2VxTdI
+sxcicl04iQdyeSalK5G1chW4JHng1VQERwIpjBEZBCj2rql1BbGW5u3Bk/GwzwPQf+lCkJx+
+i0kkydxBwOFBNc1LOcAfY+tQReu6q7ZDzAMzPwVB7ADy4rss65QAZ2jCD3PnWnJMz4ksnD4y
+eBn7U5XKrknAPeoqXG92jXDeHw3Pn6UpnCnLjkenarTJotLZ93ZQMcE1NjK+ZNU8NwxxlvcA
+VJS4OQM9+ABWsGZSRZMy4yeTVHr4f5ZiZVgiXLsx7H7j0qzSQ7fxZOah3xBDFmXGMkkD/Wui
+NGElZ4R1r1j1HoUMMXT9prOoacZ1jk+Vtbi/v5iMnatvH4MEceQMF5G+1eXa6P7RnXWl/I30
+PWBtr1F2ya1qDQMu1zuW20+xVY1IXGXlZzgHBr3zqb4gRaDLJBo2i9T63fuSUi0fTJLog4/E
+z8RooAycsT6A14f1r1H8YdUsn6g13p3qPp1Z5fH0vR26zgstZ1JNu0fMRxk/JQ/USEQPM+Dn
+bxVpbtCrVHjV98PvjJBFLD8O/h3J0RpoUnVerb/Swbtl5JEEQzMc4GMje5IwAOa+d/ip0b8T
+LK3uNO6d+FvXdhpEq4v9Z1fS3Go6u55Jk8MFbeH+WBfu5Y9vfviBc/HPTtWa462m6C6N0+G0
+C6eNb6muEkFp6wr4jPkkn6mBlY+1eVa11/c6c2T1K1wVjAF1pep6jZRbj3w05y4HrjmjLTjR
+v6Tkp9I+VdR0y+06Qx31jcWzfyzQtGR/5gKhDB7EEfevoDqT4x/EfSCy3PVt3NFtBEN9suCV
+xn/iKSfuarbPTOoesrJOquvNE6b0Lp+6+qLUrvShHd3o8/lIYtrzn/FgRjzYVx+2rpM9T3NX
+JHjNlYXmpXSWVhayXE8v4Y0XJPv7D1J4FdLq2is3NtHOs0q8SSIcoD6KfP71s+purenrayn6
+b6C0SXRtPk+m5meYS3l/g8GaQcIvpFHhR5ljWGIHl2rN60jWK5K2jiVrjIOakHvXKQc047Zn
+lXxIjCm4PvXdk5xTSlbpHmyQiNg1KhmZT61FxjiukZxWq0iUWLXrbcVAnmLMck0rGuRBJpWE
+kKjEHvmpCScc964LHT/P3pNWXBuJIV/OneIwrgpPrQz+tRKOjojPZLSdgMZpxm96hq4p+eK5
+22jsik0dWlz27UwyH3NNJzSUkU9I7xTle7V2NwT51DB8qdux2psIskicg96UzMR+LiooJ4Nd
+AcipNYj9xY0+Nc4pmR3p6yhfKpds2VEtI8V1AwMVDF0e2KPmT3FTTKtEz7dqeHZcYP3qELg0
+vjn0FKmUiU8jN/EaZtyKj+MfOui3AxyKOgYkoxXHdj1p8ku6uJNNAjp4ldopMnOai5FPQkfa
+hopSLeCfaMZqQLkkcGqhJGGKlRuxGaVktHS5uHALK2CPSrfpv4j67042yC4LxZ/A3aqKcZTN
+VzodxxTi6ZEoRkqkei6z8YtX1SPa21MjGBWJuNVuL2czTSEljn1qu2t2xUi2t3dgAKuUm+2T
+jxQh+KNBpc8jjHerXL/zf1qDpNmUA75q4+UPrSSZUmkzyreacGNdxYyN2U04WTr3FW6OFWR8
+kjNKAxIGKlC1PoTUyysFaVSwoWwk2iLFZyuM7TT/AJOZea1NtpxYdu3tTrjS1z9INDihpyZl
+hBJnzroIW7YNXY039PtSjTAD2pUhXIpVgYnkHmu62Uh5wauItPQEHHapAtI15xRSH8ihWxcc
+nNBsj5Cr9rdMYoSyjPenSFUigNiefp5ph09u4U1rY9LhZckClksIlHA4p0g2ZNbEjnbTvlSB
+nFXz20Y5x3rl8untmp0VxZRmFh2GKBG2RxV2bONs4WlXSCxBVeDT0KnZUpAz4wK7pZMf4avI
+NGPmtWVtpUSfiGaVFqLKnSNOs2lW21i0nNtJx4sA/eRH+YA/iHtXoWlfByUwx6jZ9WWr6bMe
+JYxv3D0Kdlb2bFQLCSGD6ZbaOVDwUkXKn9Of61sumpLG1Ro9Cu59EvpXDZI8e1lGOVkjI/Cf
+zIrbGlezHLyitGp6P+FXw2aQ22p3KROy5kjvL5DayN5nwhhkPurH7V7L0z8MbTToIjpOpTz2
+akmHTtMuIHMYHOY5E/eyjGfpODXmNl01Z6tarc32hnpm+cjbeQwnUumtQY8fWF/f2Ln+YAr6
+gV6BpWi6V0JbWa/E7pfVenijb7LXtPkN5ptwAfpaKeIbo/8AqIrtVNaR5ORtO2zd6TLpRmKx
+ag8UsZwyujtKMd9yj6gR55FaqztOqEfNvY6VrFsuG5lKuVPbOBwa4dL6x+1U/aydX6T1bY42
+wy32mNBerH2x8ynLY7fWCa9E0Q6GqLLZQxRMygMMgsT/AMw7/mKfBNnO5teDrok89rEFSxuL
+JsY8F5PFRf8AlJq1/a06/Sx3DsyONy/1p/i5UnBYd+DnFVl9JDJ38MeWWOCKtmK2c7qewcsU
+hWJs84JGKgTsYxveRgnk6oXH54rndzyphJUhdQeHDf61Am1KCBGkV2s5FP8AeL2/OueU0uza
+MfobeX2mzo6PPZyM/qSpP3zWVv7xrQMLZn8Nf4dxII/7VY3mtG8bI6m0uUAE7BMS7fddvf8A
+Oqi81JY1/wB6kieMc747bmP7sO36VyznZ0QjRntRu7YlvCZImfnKjaBTbXVoyPl5dTgPHZkL
+EGpV5b2t79cMpfPZvDBBPrkVUy6NcxNlraGbH4WVskiua3Z0pKidcS2WSJLvUfqH/wCDwRlC
+PXLNUAr0vYOtxPPqYRv+H4Ucif8AlU7vy7ULY320pbWI2+a5AH6k11i0W5ZGJkVWTujTKif+
+fvS78B0SI5NJukBt7q8niznYkDRIv/SCBmm3djbbTKmnPLj8L+ERt/5ucUsfSd1fFY4ra7IP
+ObKWP/N+fzqO3S9xZXCWl8lgVjGVF5P81cnz7ZCj/wAtPbFaEi0TUbqbFrbWG0jlnvkiH5nO
+Kk2tpJpty1tfap0lZyYBc/Oy3r48s+EpRf1zUttMKQqksVvsbGXmgzjHoCQB+lQJbCKNCInT
+w89omCr+S0mq2NPlokzalok5a3/bYllB+j5a2lhiI/6s5+9RCYIHGwTiTGd24hcffzqK8qxn
+YqkseAB3qNPdyOu3ecD1GDUt2aJC31zYgsLu7ERbsqNlj+QqomubVZN0NnYup/B8xb+IfuST
+xUe6kkeRjZwxAn8U8ziOMfme9Q08J2IjumvGzh5LdSIE/wAIY/iP2FRZsoovmnk1FkRrqyhM
+YG4Rru3fYDtXT5O4t42v4PHdFBDADYreQyD358hUPTJb1foiJt0ORuLIpI9zjNaK01e86fgX
+VNKis4LiNwX6i1hWkEJ/8GxgPAPrJtLk9ioppLyS21pDrHQ+qr0NeXGh6zHK4ypfS8M+O2zx
+CvHHccVf6npGmppNrZ3cWr2lq8vzYiu96zm5Iw/Csdy+i4wM5rH3l/b6/O+qa5eQ2D3Tbi0k
+019rN4M4LrGXKwj03nj0rraXul6U7QaJ034Bl73OoXRmvXx5sQQqAegFDaSElJ9l3G0Fiojs
+tEsLqEd5ry2nWQHyGScfrUyUWBVbmLQLK0LcnwnkZtvbPfA9arbK8166t5Jk125gsFcLcSmU
++Ah8gc53N6IMn8uas4NW0u102FLC2k1HUrtvEZp8RW0EXKhXX8TnjccewqU7KqtnbR9V0PT4
+WWLXtXt2XcAmn2zzRg57u7jZnmp0OrG+tgB1H1M/O5XDJGEx2Y+HtH5VMsPiFFaWP7J1Cziv
+IUGN0dquxvX6SQAvoK6TX3R+v2rve9VXcF05AEF1aJDaK3kMxnOMdgB96r+GS39oiX+q9S6p
+ZCK5uL7UooGCxstuBjPcuVH1H3PNQbgXzQ29w8UnhoMShhjZjtmul1Y39vaLbQaeXt/G3xPZ
+T7o5JDxnIPPHr2FIDodmZLl72TUbgkobSMnwIz/FJIP4znhR28+al/spOuhthFP+8votcsLY
+W4MoUzhppF/lVB58+dMt57i4lt9Wljki+YWR4doAEyqcFgP5c8Z88HFQJrpZLrLruhwcoQBh
+fTgVLg1Ga4m8Sc/MSbQu8nGEAwFUdlXGBgelZ2ujSi60nVJU3XUUoJ2MHLHhQwxx5ZqRaTFQ
+Io3cuqYJB4APGfvVNEontBvieWVpRtTO1ADz28wMedWCy3C5cJHJG3IMbf8A53mftVpio1Vt
+qcbNEobw0hUbgRjIH+eatrW9mZhLOiiR8sAgwEUe3/0NYq2vlmBJQrKBjwyd2at7G9jICSXx
+UDg5HK+1UpGbiahNYETZdw7KeFPJJ96tWvmIJmkJmk5fA4TjtgVk9PeBbsStdxy87kjA+otj
+ufWpE+sxwKkuU2yEld7ZOPM4Hb860jKjOUU+jVG9iS2At8lmO1N3Pfuxpongkk2ySHw0wMY5
+c+pPl9qygvtQm2ylWjRuQWHl7AVLimuFUNLbugY/i8yPtVqZm4UbFb2HYZirbmPmQBiun7at
+kx9YYY55rC6hq10MK0bBBwAy4UfYDkn3qsm1ZGdUQXDjvIxbbn2H8o/Un2pvISsbPQ1161ml
+fMhIj/4UQ3MT7nsv581G1LXJ3tHht4o4i5AUM27Huf8AtWUhvp0t1SOWOGIfgtokO5mPmB/q
+xpslzqEZDSWz2iqCRLdkGR/dV8h74FPmHEkfLxhmWV5p5G+py8hVWx6gc4+5xWhsrhLYQLLI
+u5VztVeFHlwKy7arZ28HivcHK/XJKYy/bsoUfiY+Q7epp9l1IxZZLeBow2S5m75J5LHzP24o
+UkhuLkaoSTzIxOFbLMTwoGfWl8HxnSWIiURuB4rqfCQ/4V/FI39K5WIS6ZWgVp2J3PI52xp+
+Zq8tWihkBULIw7sB/wDQ4p9kPRX3unyqBcsjzuo+qN5FXA/meQ/Sg/woCfeq23vPB1FXmvg4
+chljghKxovkNx7/fFa7wrW5+h4hJg52lciuk2kpPGCjmNskuyxKxx7elVxvoSlrZQ3+oCa3P
+hA5J44zzVbcicxJLHG+WGThffmrXULO0sDH+1te1K3SdgkavGgLn7J9R/SqDVJ9GuZFsbL5y
+/uoR+7jCt4cQPBJHYtj1NDX2OL+jiLqfOVik2vna4GFGDTJ9TCwgyOu9cg4ycmoMWy6nbTI4
+NQa6QNusQ+6cADLEIo+nA55xVa13A0/yEUfy0hUOqXUy+I2ex253Dt5isujWrLRtZKEQ4TeB
+uIYZ4NSP9olt4t0sNu0J5DKpU/bIPFZqG2vtSlm/ZQ+flt3EcqwKxKNjIH1AZ49M0t9petWn
+iWt0tiBJHuktmZpWK55yEBUH23Z47U0FLolX+r6VrksFjB4lvqCytPDDKNyzvtxhX/mxnCnv
+UK11SaNAmzeqEjaO455yDyCDVPa6dJeQSajpGqftKwjJV7VNPkuVJAyUJJV1YDkAAHjjJqf4
+jaza6fe6dqlzDJfJvs7lJY7y21LaPqKuMMxU8EECRTw6+dRL7NI0lRs9G1aPUrY2bSvFPbnd
+DIuNxQ9154JHkPPt512jF/si1JoLXVdKlkEaatpRwockACaLOY3H8SHBHpWSsdI6piMGo267
+bzmeK1Zcw3SA/UoY/gfvx5ECt4LbSXN3rGkQpFd3oWTVVAEZun/8S4hGMzD8JkXk45pp2tkt
+JPRSavod8Xa/0XVreWK0dWJLqk9qd3BIPEiDPPtTNYvGj0my0+VliIht2JHAM7MzMR6DJyPv
+VnIZzBHqLsotpN0YndO5PG2N+7gjgq3Yjmp2i6faX1q5mhWae2hEap5vCM9h2yAf6VPG9Irl
+XZC0meXU9Pm0HVUkighZxG6S7XUjaVBPYqTx/lUfSrl57G3vL9THJb2qzzYGcyxM0YAHkCCK
+lGS0ik8PBELfTFKp54HY+orhc4S0uLyPKPPGN8fcbwcnHsQKQ0rOrGNikZz4kVugHntyD/Wr
+G3VXEcjRsIiwZUH4jjgfrVJDL4kfjs5O+DJ8vqJ4FaCxHhMJpiD9IVEDYJPmftU9ltUixtld
+kaErtVS0ssgP0Bie2fUCka5DDcxWKNc7STywz610U/MAQHxHSMkBE+ld3vXV7NZH3vDErD8K
+v2X7CqJOsUouSLu4ZTEgxBCfNsfib2qUEbYhlbcQd7HsWb/QCo6286qCnh5PLF+B+WKkJ4rP
+skwu0Y+mqRDFyrcv+H+Ud29yfKok6M/IdUBBO7GcfapLRMjcnk+tc3CKpd8MRyAvOPv5UPYD
+YGVWCAmTI49WP+gpPAQfvAwcg8nHFPSYKOUILDsvc/c+VcZZiMMw244CjypBuxkyRo4k275m
+JIJOefWn25nRTGmWmkzlu+3PkK5GbLYVCCRyxOSKGaMqUDvjGD9qBs6LdhWFtbkMqfideVz9
+/M1ITa7eIx3H+EHsKg2wj24i8QBeBvTaMewqVudFHYA9variRKvBYxMFVQDyeSTXUSAZI/pV
+XFKyE4GfU+Zp5uJSMbtnuO+a1jIxcS2S5xgZwD5eddPGLLsXZk88jNU8EpLiNCzEcliasI1+
+gbnwPauiDsxkqIXUWo3Rs54jZx6lEYGUWL3C26zeqtISAi+pr54z1XK97d9N9LfDW0u3LATa
+LoTanMAeBAt0zqAR2Mm9V74FfQ2q6Ppmp27W+oWa3KEcpIAQR7g8H86y+uWLvAthdeOLa3jC
+C3sLNDBDGT+HH0ruPqAcV0JWc7lT2fM3UmhdOQFo9Q6H0Wx6je1j+cvdBW2vL1WB24LyiRVb
+vjDcGvO7n4KDqB7rVej+hbnRrBJR871H1DeCZrZM8k3syiKFvPZbxM5zjNezdW/Ev4FdAXX7
+L0H4c6pJqcUjF1t7dJo7fJz4jjP1MTzg5J54rw/4lx9a/Ge6tLbVIfiXrjTxvNZ2N7qEOmw2
+duDw0di0aosbY/G/p3q3VDUpJrj/APRjOqde/s0fBVZrnS9BsevurxL4n7T1kSXkEb+sNq5M
+eQezzl/Xwx2r57+I/wAbusviPPObvU5RDKCHWKJd7J5K8pGdoHARdqDyUVt+pvhn8Iul2M3V
+3xCe+1EOQdC0/Ured4iO4urqMMkfP8Me9vtXjHVUlhJdyR6bqemxWQP7u3skfYo9CW5Y+571
+y5XKtaR6fpoxu5O2ZtiAcDyppbimyMoOAciuZkrlSO6cx5OaaaZ4h7ZpC/nWkY0znyTTQ4gY
+Nc2Ud8Upkz2o3cVujjkcmWlX3pWpAfXH/aqshKhx5pMc5pcikyOKQeTqDximN6UoPFcmPpQU
+3Q/djzpC1ct1OGSaCeVnVDzXUHiuaAgfen/niuea2ehhl8R26jcKZml3elQa8h9LmmbvelBz
+Qxpjs+lODYHBplLUmidD/EPbNIXz51zLegoDetOhc2dAx8jXVTzwajhhnvXWN14yRSoqOQ7U
+p/pSB17Zp24N2xU0X7owtijxCOM0NXJmpj9w67/ejcO1cN9Ac+Rp0HukgGusYJ4FR05qfaou
+7J5JpcQ96jpDC57CrKz0q9uP7qEt9hUrS7aOWQBhxXpPT2l25hRlQc9+KFAiXqKR52vTl9/x
+YyBXKXp+RfxR16xqtlBFbhwozmslqdxbxAqCM1agjJeokzFHSVjPK9vWu8FpHHjge1SLy9hX
+zAqvl1Afwke1LiUs32XdtcxQEEt2qb+1o/UVkvnSf4hS/N/4v61XEh5LLAacuM4H6Vzl04Dy
+/pUyO6HAJFPaUOPLBqWaxRTGy5xgfpUi1ttjgheRUzwg/OKcke0g0r2DimWFrtVQMc0+dc5I
+8qjRzbR5U57nKkU2KjlJgGhSD7VHmlJrmsrAikVxJwTdyOaUxyY45rnBKTjmra1jSQYOOaAe
+ipMcgySDSKzIfq7CtJ8jEUJC96qr60CLkLj0qkjKcqRwW8RQMt+Wa5S3yHgNz2qrvWliztzx
+UKKeQv3yPvVuJgs26Lh5i3rXDxwDzTEYkVGnkKtUJWbudKy3tp0Y4Jx96uLQI2MEVjorgqQN
+2KnW+pzRchuRV8TH3WbqGFSOAP0qSlmW/DxWVseoZSQsmK0Vlqscu3M6qD6jOKTjRrHJfRYR
+6fMzBY4y5PYKK2PTlhPaxY1G4t4EZgwaR+U/Tms1Y3NuzArf+Hn+JY2P9BW06et9L1KYRXet
+WqgDJMlpMmB6lwvFXFeUZ5clqmehdNWnw3F1Hey67qVnqTkeLdaVfyGA+WZIu334xXt/SV+m
+nwTW9prZn06dwwkVNsEjAY3bOV8/TnzFeOaH8OejHt1u4+tUlkT6yQokt1z24AD5r1Po+aCD
+fZaZ1BpmowSY3Rb2jdDjG4q6gf1rri5RVM8jOk+mek6THol++bfWtPsrgj8cCJ+99d6jAq+i
+0uXTP3iy2zbvq8SMAg/lVDpeg2lsVuoNJXxW/vGEilT/ANJ7fkas7uRbJG/dIikHGB29sVd/
+ZzdvRJk1n5fIWNd38xyP6VR6h1g8G4SQxsF9U71ButVL8GJmx/IDxVRcSXkxDxQzAE/w5Dfn
+mubJlfg3hjS2yTcdaW8n95ZXcYbuYsY/MHFQrjUluThbvapGfqyCPy5qvvJdWDmJrWRtwyMx
+nJ9ecYqpaRxIRKjIw5Kk/wDauSUm+zoUEWlxcSRgHdvXJ5LBf9KjDUlVy0enxzN6/Nsmf6Go
+Yv8AT40PzQlA9VAwP61Ha46fkG4Xl5B3+tJUx+hH+dQ2Wkdp9TxIzpo0EPGCvjkrn1J4qFFd
+vPLtSONnHlEGx9ue9OkubZgywahezJngPFGw/UYqMssIISS6Q57bEKZ+9Zmjqi1MMEcfiarr
+NlbqMhYEf95n3xnFRnvdNWI+El3dMDxnAB/6mrlDBaIPG3RRAcZ8MNn9a7OLW4+m2sbi7kx9
+U0771X/ljXCj86ZKEaS/1dBY2ugyz27Z8SFNUMSH137MMf8AzAVzjeewQW8T6Zp0Yzmz0hRN
+KP8A7ZKCcfmxPtUCe+siDbXBNy4P/u6viLPqwXg/YmuA8Z0Z4NP2RL+JpCI4VHoOwP5ZpWVx
+sthfQsBG0WS2MtI298/mcCnsk12cblkCD6gSRtH38qz63Cl8Rr4hH/hJhR+Zp8zSz4iLbI+D
+sycE+p9TRyspRouJDDtMKXkjzBfwRlQqD1Zj2FUOoWt5eyRx2KvNzj6D9P3JPl71IXw0j8IM
+GVuWGMZ+9RbrVJzILLThJPePxDbWsJkJPrgd/ueKllLRyvtC0vR3jfqLVTNdTLugsbePxpdv
+beVPCp/icgHyBrpZW0euyiGK3vGt4wscdvb5muJG9lQAZPHAGB61K0/oXRtKik1b4j6tdIkx
+LyWthcAzzy/wRGQZy59FztA5Iqyj13VL2wGk9O9OR6JpsP7s20DhVfnO2WQHfKT3YZ7/AIj5
+Ucfsbn9EaOxt7GT5eGyVZgdrJDIbiQN2KvIuV3DzVSQD51Jm06xF7bNqVkjTIhO69uC0u3+W
+GFeEX1kf8q5Q6dq18yWsKyXExG1YYJBbQQIO5LjCog86kGxMVpLaaZqen2cE6FbzUeWuLhBy
+Y4Ex+7j45kc7j5AA4oCzhLd2XiyGPSfDhYBRD/7tG/vhf3kh92I+1WtjpFhplv8At3X9O0fR
+bFAClvPlHu27lpCSZDGPJRt3H2qrs5ba2xdaZJO5jx4U7rlzjs25v9KmWurpZTs9h09Yajr1
+w48O7v0+Y+SPm5Zzh5TnIGMIMedSq8lO/BN1G51zWkj1SeSdbRFIsvmbYWsKo3I8GEc5PryS
+POo9hPagyy3d0gs7dws06jIX/AD5uf5RzUiy0e31G3m1rXf2h1PPIxT5i4vGsdOt5B+JnmJ8
+SfgY2RADyzVtpPyN7ILqO6aBLICOW5sNMDlWb8FtYwt9KyN/MckAF5GAFLjbsrmkqGqnztzF
+aWPRdjpsU6ZWa/keS7dPVlydpPoF4qW9l0xYKGk1JZJcEGO20cNGp/55DnPuAKgXOrajIsml
+6SVs1kLG5g06Tx5Qc9rrUDzcP/Ns2xKeFGKro7e0gO+YeJgEAI2Mep3HufeiTErJqXGmQCQ2
+El1DDHlpZ3AjDE/wrycny4rpLFNbWaeLatYFt3y+nxQElx/4kjfi3H1b8gBU+LUZNG02x+Q0
+S3bVdRiNzarIu97SInbEAh5MjgGQuxAVSoHJzXKaCWysri/vtTOuazcN4bTTXBttJtCD9QDD
+95clM8rGArMCCxooOVFdPZ3McMbXSCJ7kfQrNl9oPLEDsCe3riuMCTyZtYow3gkcA8jJzjHp
+Ut4443S3+a+bk2/XMIvCUgDuF52r6A1EiW4WUah4DJLdYVVU/U+OMr5496yfZqtotIbk2TST
+kmSN0Mblx9JduMD9f61Psbe7Om3VuYoxIt0bK1hd8M0qKJJSB/hXav3ao+raffaTex6OWjSS
+1WFp3OCsMzLvYH7EgD1INcIpUBexhlFsYol8N9xMmJG3Oyn+Zz+Jj5DFUlxexPfRoreCWVkt
+xaCGdFByeGO7kj8qlM9tbRqtwDHM7FXVgGY+uPLB9arYo7iK5kuIJfGlSJRiRyBwOAfQU7VL
+8WsVq184FxNJsjCqWAXbnz9+1UnSE1bJkRjt5ZWjd9+7EQ7hD747/au0Msk9w0t5JC8zthVI
+7gfxEDtUW2maNBbLhdxyWGM89zmplvaGa7Frp8RupoU3Sq77FJPqw8v6U0J6Jxku4Ve5Kvv2
+4JV/pwfMCq99TdXG4Mkaj6myfLyAzlmPkBUm61bQLIK+rx25uUjKRyW0bv4R8yvkxB8zVTJq
+9nLM9/oqC9eMFUOpxrGq54LGMHnPq3eqbohK+0S7W/bUIRd29tE8jBseHIVt4xngPLyGf1C5
+x2rmUM8yAXgBcEu8SFEXHcKTkke5xVfJr17cCFNTtZbl/wAKtbSJDAq+gjQbQPtU+Oa2nYqk
+oSRRgxy4CD2yO9FphxaO8N1EiMulNIJdu35lBuYA9wmeFPq3f0xXMaolqwjbLTNhdqDe5Pux
+ySa43dvFAqNqEEQUndHFaxvcAn7LhSfYmpFtJfPloLGG3jXG0XVwZnX1PgwqFBPoXOPelsGk
+WUcl45JnmkMsYDtEjAmMHsGA/wBcVYWcZllXx3B88HnHtx3rOpq1vAGtY7bwIfELOVg8Lex7
+swPmfc1oNImS4KNHHN4bc58iPYinaDg6NTp8eceI+dvHhqOB96ukILEbdq+3nUS3jjSJSgRB
+j8Cjk/eplspeQZwc8/SeB9zW6Zg0WdorBMHIB8h3q2tkDYAiL4/m4FV0WMDJJ8uOKnwv9AXG
+1fvya0ToyassrZIonDrbwB8EB/CBYA+h70l7Bp88TQTW0LJICsgyVBB4PC45965JKE+rGM+Z
+7k0O4UFjkY5bHJPtWimZuJk9f6E0vXdLTRLfSYdOsYWXwHtd8Um5fws4VgGC+Qct71RS6Bo3
+Td/PdN07a27Xe2MakY0mv9Vl89tugxFGDgB5CMnP045r0P5iQqEX6ZH9edg9q4SFSr28Yw0g
+y3OHYdsk9xQ6Y02tHnF3c6nPIdN6j1SDRon2Q2stncxnUIHY/hlJ+kq/AKLg48+ahaX091Ro
+N+0PUt5a3DBnls73TrZohOpP1QTWxJUEcEOOe+a3cHR+ghBEuj2MsK8LFJEDGOc5wcknPOe9
+T7iO5jLKWaSNvxFRgE+32qK+yuddGOm0mC1uv26l5Jpjuw3SRRs0kY8vGC8hfc59qpH6K0fU
+bm+YaZZXNtqrCXVrWNcR3bg8ToFKlJsdpE2vnzNbeW0ukkkkEiQxIn/vMjAbcnsfPb6qcjPa
+s/fWHTYv45JNWl+clw6x2m7wgRn61TgsP5lU++Kzkvo1hL7EE03TwNhFfSyLtAha5b946Z3A
+Ox7uO248+tSbmzTWbB7y1QrdWzhwoYKz7sAwk/wv2Kk8EketdI/HeN5UMWoKi4lZW2yD3CPy
+Kqo7+zh1eSw1GOdoJIWhu4GiMM7W7DDJtONxH4lI8xwah6KW+h8moTahGltBqBKbubW7RYmM
+inuvqw7MO+RTFiv4LkpH8xbS2372RET6wDzuTzbz7U5xfKZre/e31RJwGj1Hwvpu7fH0S+qy
+AYDdjkGoh5aCESOYS2EmfLtC38p88e9Q3RrFE1ZLe6ZLyZGaOQ71cps3582U8KftUg6csNut
+zKx3Fs7Fw28Z/CPU4/IVItpCymze8ivUikEZHh+GVUjkgng9q6PZ20c3zTGRmVNq7/4R6DHH
+50lsfWiG1pBGr+AhjlcDajMML+g96fbWaCa3SVmVoz9T5yMVLVkf6XHiDPKn6TUhLRZIlKss
+W1icMe350VY7JFs0u+QBgYzhVZG+oDzJ9zUy3KEBguCDjk+XvUOGCWFXDOjAncDG+RmnhDvD
+YcjuMnjNNaJZOaRCSXfIz2H/AKUocr+FiQPWuKkj6TgHFPUng+Q4I96LFR02iQjeu/HHfkU2
+S3YKTENwPGCcV1hwWJCjKnvXV0jc9ypHb0q0rRDdFckM6nBJUDyAFcXRWO1UGV/EdpzVhIDG
+N24spOO3aueVZvDZzJn+A0UO/JCWTYGBRifLAyKYWcnGUBHfHnUqRcEs6naOAq8KPyrk0MYB
+8Mq3PIHBoSBs4rKd+12Jx5DOa6m4T+Ekn3pjLtBXwiue4xj+tcSGUbkixj9afRLpndpQoG4n
+nyHnSeK5I2oxz5Yxio4kI7BhnnkV2jx/EWLd+OM1SJZMtyyAbmxnn7VM+bAU9hjjJqilumRw
+kK8+bd8ChLn95v2hyBxz3raM6MpQsvklUpk52nuTxmqXqNNOvrU21wcxRnxGUE/U3kDyM8+V
+d0lnlJPt27AVBvrCzlgkjvJcxOCJCc8579ua68cmzlnBHgnVXWPT3SUzdOR65rtuHR5S2iwQ
+fOXcpJ3BrggmEfwgIm73r5/61vLu7tby3074fdTu99NuuLVJ5Z8SHOPmp2y80hHPhbioHcDt
+X0V1x1H0v0BCNcs9S6P6P09ZTCqNoZn1XUH7FIEUmVnbnDYwO9fOvxR+Lfw3a4ubfV+oepLi
+5gtwHi0BFs4dOD8+G0rsV8bB+vgsDnPPAumTGUXXbPnn4mdKdUN9PVV3070haxpiGxn8Jbpw
+PSGEFh/1Yrw3VRpVpK0NlcSXJBx4rjYpPsterdTdd/2fIr35rp34Ma1f3AOZLnXuqJbgTt/M
+yRqpP/mrI658VXvIWt+nukOlen0bj/cdKDSj/wDmSl2rmyKL7Z6GByj40YRlduQhOfPFMZSp
+wzACn3eoXd5IZrm5eRyckn/0qMz+p5rOKVmk8g4sPKmlqaWGPOmlhWnRg5WO30okrjk0ZNHI
+iztvB86Td71z3GjdT5BZ03c80oauW72pQ3vT5BaJAbIx2rm57/akDU0nmiwYAU9e9NBFLnFC
+Ed1YAYpS+a4b6PEqJo3hOjtuppNM30FvQVlRq8iY8P5V0Vs+dR9xzT1b3oaKjkJG4UheuBkI
+86YZD60lEp56O5bypN/ua4+J70b/ADq1EyeW9nYye9KsmDxUcv6Ugbnz/Wihe6S/F96cJ9p+
+1RQ5o3GlRSyaJfzOe/FMaXPY1FLmgFj5U+InmZJDZp6sD3qMpNOyRSaLjkZMWQA5BrvHeBDn
+JquDn1NJvapo09w0dtrngMCh7d61uj/ERrSHw9oJ8iTxXmHiNSiV/U0EuSZ6VqfX9zcggyDJ
+8qzN51HcTOSzVnDM58zmmNK5Pejsm0i2fUmkOWcn86b83nvVUHf1NKHb3phyRaG59TSePVcJ
+JPWjxJPU1aJ5I2EbOD3z6VOtlaTGfKpbaSUySP0qRBZlFACmudo9GLQ2KA4rsbbIzUyK3+nl
+cmnrFuGQKYrKeaEqcDNcvCfHPnV6bUEZKjNRZoFUgY7UAmV6WpfsKcdMnH1bDgVZ2KqJMkVb
+7YymcDmmlYcjHlGhP271LstREbbWPFSNWhiTOOD6YrOSSFGJBwRS6ZX5G2i1ONlClvKo97PG
+68cmsxDfsoxurob9iPxmqUjGSFvow+R61WrblXqYZw/c5p8aqx7VVmKhbEWPC8jNRLqIk5Aq
+5ihUjGBXO4tFOTS6NHB0Z0KwPNSoWXAWVCU9QeRXZ7QA8D86WO3AxkVXIy9plpY9Manfwm60
+cx6ii8skDZmj/wCaP8X6ZFMWK+hl+XETiUH8DKVYfkeaLSCCORZVEscyco6MVIPsRyK9D6Z6
+y1OMR22uahZXtmvHh6hbiWQjzxJjeP1q7TIcZR2Z7RrPqAXEaPcSachIPiS5Cn7Dufyr2Pp2
+111YIhb/ABN1SxQEELDBhT75bmrbpe16S1G1aXTPlEkkGRFqClY847LICK11h8OPFm332q6D
+p8MiCQBLpm3jOCFYAg8+9Vwa6MJZV50SNM1V44oFu/iJqOpywqCRGgVyfMkqo49s16R0vrV/
+dzRI3UbXkI+p4XsFbAzyN7DP9ap9N6M1LSkT/ZrpjTIbd8btT1G+aUP7pCo5PpkgVt9Ki1W1
+hxd6kt0Tjd+7VFH2UdhVxTT2cmSaa0aqwm06NNse63d+cqTtP5dv0qPqdzJG5kXVLaQgcKfx
+fpVbcX84GLgAoB9OCMD8qzt1cWpJe3nWQk8rtIxVznowhHZYXuptHJxbszHzjuCgP5Gq2SSS
+Y+KdS1K2J7quo7wPsPKqa/ackCQxqmO3ic/pURUjEgYTyk+sSgn9a4JS2dsVo0S6heqfDt9U
+1pycgmSVyP8APH9Kr57258UifWppGPGyS2HH6jmoslw68LDOvo8nBpyajdqDGLqRl77Sdw/Q
+1DkikhJb1INrFdhByryooB98CuM1/Oyl4JbNjnkeDGhP54OaW61SfBQSxcjlHQZ/7VAkuJnG
+CsLKOy+GAf1FZs0Q93u5yGmXnH4Q+BXe3aS3GfESIHIAiBZvzY8D8qgqLzcGggt7TB/GyEtX
+bxoox+9uJbiTH4mP05/wgUkhtkvxYy67YTK/8znJ/U9qcEtL2Ii8top48keFl2BP2BANQxPO
+52Ig3e6bsfYetMn1ZYFEQQTy9mWLc2D6ErwD7LTJO02orZRmz0vSLexI537VeYf/AJqf1PvU
+SUyzyr83eS3Ddwv4sfr/AKUxEniTfNbLbFzkRnKk+5zk5rpBtiYlrkIcclFyT7ZPb8qktaOx
+tRL+5hdSBjPiNsXPso7n70yS1WANhlJ4B2nOPuT/AKV2W5DwhocgNkK7/iPqQPIVxcRyALDF
+Ky5yTn8R9famxoguHY4DD8QVnY4jQerHyHsOT5VNXqvRND0+XTdEt7+4kl4ub9tsJuDnkBR9
+SoOwBOce5qs1OVmUqv1LGMRxocKpPn7k+ZqDO2n6Naw319bSXUjyCKKESbQ8hGcKByQO5PpU
+8vo0UUywbVjdXUeo60hkCIUht5CMqh8gMfSD6Dy71cRat85bK7yLY2NvHgw2kIjHPk78nk/w
+rj3qr09NPkMl3cWUaxxfXcyhS2XPaJM8ljj+hJ4rUWiW2iWMPUWtxWwvAFk0jSo8GGzRuVuZ
+/wCeZv8Ahp2Ay7fw04pvZMmo6Ov7NvLiyNpFp7Q2yBXufmXEaLjssme+OCF7DPOW4FJPc293
+fNB48V2VAY/iS1gjA+nP8T89lUZJ86532t6jrR2vuu13bsSAlWb1Pryc1c9M9MGKNda1yw/a
+E08zG002YlY7iQfx3JXkRjuwU52hUHLHCfydIafFWwtdNnvLKPVrmd7fTnDGKYhY5rwqcMsO
+fpjjX+JxlU7ZZuKsNAtIZYRr1wlvb2SRsbRSp8N4+xkjQ/UwPbxZD9ZyQMVIvrK9v7i4vtVt
+F1u62qyxXkq29idv4Z76QfRbWUeMJbpgvgLg/Uaz13dnWJF3anJrEuWdr1oTb2zMR/8Ag0Pk
+gHAdgOAMACm0ogm5kqGf/abUEF7JfNbw4hhityDKxJ+mNONq57YUAVoL5dTntU0+WBLHSbQP
+EtnDMIoAp/H49w2DIWI+oLw2PMAVltM1F44mh0ncyRyCJp7RQ0k87cCCNm43bc5x2GSSKfPa
+3txdJBJA15dOwRYoi0w3eSqTy337ZzWfKkacbZYXerWkdp4S3JmiRAVjt08G0RR2OPxP+eKt
+jo1xodhbQ3atF1FrY+aFo2BNp+nD+7ebPEJk+qQ5+oIF45plt0z+x4FvuodbtbLUpbpLWzs4
+VFxco5I3FY/weIq/xOdsec4LYqVdyW+s9TaybRL2Y3M5mvPBk3crwkckpyWfaF+kZJIJJFNL
+VslvdI7XmnatqfzGu2s9slrPIImuZ51j3AYVQCecHgAAEmoWoWyR3UnianBdzQlba4vmJaCA
+oP7iH12+ePPNILjTtPnMmp6GdVniLLBp6zkZlP4FwnIyfxHOcduaiXL6kUWfXri3SfgfJWiA
+RWi5+mMKvC+gBJbzNKRURzyotq0VsjCN3/ezufrmA5CgdlXPl34Gan6Gkn7Ztnt7ZGuJCjFn
+I+iMMM9z59gBUX5dIoGvLpHBEwghjC7nMm3LhR2yAQCT2zTrWJ5ryOSKz3SBvFj2ZdocAjI/
+mYAn2yalaaZT2ie9+JOpZJbo+LBrl/JFcqzZ5bdtYe+4AA+QJqPqMB0zVoJby33Trpln8zEh
+wVl2Zbv5Bjz9q69OWty3ViRX9lKkaRNPFHMNrKVXK5+wAFQtavoNd6weOwkYpJC/jHP45EVn
+IB8wAOabdxv9iS+Vfo0FhdRxWBuXj8e6MqRLGq/3srjKLn09u9RuoXTUNcTSoZImk04/75Mp
+BSNgv1qPXbyM+tN6ee/g6bu5tPnQ3WqNiOVAFEQ8PGEPk2CRuHPeqazZLLTYbKyKrPdE2aFg
+BsECglCfPcx/OhvVDS3ZaaffWVlcWkyjZbht62xOWZScMxz/ABEHjyFXQ1e3R5NLgRLeyngd
+lZWySc/S0jn8XbseB6VktRkN1GbQ2oS8iXiRG/CRyw57gjPFOs9ShumEJ2mG52ptUYZSOcfY
+4/rSUqG42Wel6nFqEq2FvaSXRjjCyBbWSY5J5ywHP5Gp03Tc0bTTTKYQ7fRHIqqCvoQxyBVL
+qU11fQiSa/jQKSEtZL1hG4P8OEIGTVdY6XcxSTNplizRK2DIzIiRnGSjSMR29qfLwFeU6NJP
+p95pkiM0hEcygCOzlwwJ7ZxwafYyWFsFGorZI4P9y9yySsx/CC2NqZ9TVINSu9PmO62iZe0j
+QkyIPuw+mr2wmtY4FvL+FEspXPjTSElfEIwqZxjtnGaE03QnaROe/wBW06B536c1ayt5CBGd
+PK3McjHgAMvGfMknFdxdXc4YXGsyQuo/A3huVJ7BljOarrO7ttGubmTpy8urASqAyGTdB4Y7
+kquBz55Nd7G7sLyQS2MNixBLObeyEasf8ROSfyxWj0REsIdKuLqWE33jXsUbblSOMrGW9Src
+nFXdtv8AFMQlmV0IHhlcFfyqks4bnUJpLdEjWM8N4MbYPqMA5J+9aK0t3hhWCSK6VBwWnOAV
+9B5/kKmrKv7NBaMzYFxMzAYBUcAf9/zrS2qRKg8FgFI8hgCslaSQRII7NCsaHsQQD9s1eafq
+LlSJY3ZQccL/AJVtF12Yyi30X6ghcduO9doWKsOSR6VWxX0bEEkj2PJqQk+cgEg5yMnHFaWZ
+UXMDnId15GcegrvIdkRbhV9W55qtt7gKf7wKMV3e4RlMj7QF5Bbn+lUmS4nOKYoxW3TxpW/G
+7cKvt/6CuYlibfDA6Nhj40mcKW9C3+lcZ7uWeALamSKDO2S4Y+EuPMBu/P8Ah5rnbzWcSRxW
+53xp9KrGn0ilyQcCztpS2di4ReC5GAfsPSpaqjDaMkenbNQoD4jZ5kPljstTYEVecEnuSewq
+4shqiNeaELm3YQNHCcZ2+CJOfUZz9Xv5VitQstYhlt7e6tJE0+Nm3mPUVjlBPZkCIWLA9xkZ
+FepW0auPPAqF1FHPBpdzPpkQWcoQzCIO2Mehxn7ZFW4KSsUZuLo82mkure3adzbX7KR4Zlke
+EjnBUtJ9QyM8HAJrveava3XT4RYrbxLXdEwG5zGCfpwZMsoI8gdue1V6wz3dvLjSbm2lmiMh
+TaALhkGWQKckblB+kn7Vn5rqC1vXvLWBliktlPy4m3FYC3p371zSbR0ximWEcqxvBbSzmCU4
+WMrxHNGe4I9QfMVcnTbwweLc2EkciZWSIrhmQ8eIp8x/UVUWOpy2xm+WjZVVBIHcYkAHfHfH
+Pp3qwtbmSJ4rwiaNpYwX3xsAMjJ71no02mdmtzbIDIrGIJtwBygHck1NMkDPCkDR4blwecjH
+GD2rjADPEVV98kpywz/38qkpbRKXiERCJgKxHGMc4pDbOkOnxsPmVk5yOPt5V1MCguxXKr3O
+e1c0keGTaFwmM5z59uKkAtIBtLRuDhgQCHHpzRoWxFhVCCjMqnyzxXVVO4lpBuPfilihLABB
+tIGCPJvTHoaRCO6xqcnBJ7rQAi7i/BHHA+rNdTJHg+TA8ilSFZAxaNX4+kHvn2NKsRLKcEE8
+bWooLQKwADndgnBxU1BuQMH4PZqjAGMkN9LdsGpCSbVzsGD/ABDy+9aR/ZEmL4YcHkZHfyzU
+d4EaTAmMUg52OOD9jUpMyMCqZYD6oycFh6g+ddHhV4vEjy6DurD6kPuKujO6ITQOoy8RAPmD
+kGodwjFtqgADzxn+tWKvJD+Ahk9KcI4LokMfCPkfWnSYXRRi1Z12p4uAc/u22k00R7RkNMAf
+OSTeatbi12kQlNsqfhZeM/8AeoksEuNzjcT2JHanxoXIhsRyDIzZ8hxUeadC2x2lX2OMVKeA
+E4ZDz5D1qHIipkeGyH35xSC7BTbSLsDyAdjhc5rvEI4/pgWJR5NIDn+lQTI0eVyq588U9Hk4
+J/D6nuapEyRYq/qTgdyfP7Ul1Hez2kkenGGGZhhJJlJVT64HJqIC2cqy8Yzluf0qREZHGSjY
+HmSK6ccjCas8j6/6E16COfV7HVOkJ9fYEJqes2rQW2ngjBkATLvIBwMuAM+tfGnWf9kz4ufE
+SSTUNJ6x6Gv9KtGcI1tqSW1pE2fqKRqvLE+bEknzr9EtatNLmiLapEJwDlI3wef+U8H868t1
+7Rugbe8knl0q0t7hwJJPDgeViO2XU/QvbjgV0NqSpmMW4O0fmxrv9mTUOl7cjqbWHudTn/uL
+LSbdrpUzkAzSqCkeccKCx9cVgr74K9axXAtbbRLiSVuQgjkZyP8Al25H5iv0N6p6v0OOZ7W2
+134nPZxnMVppt1BpVhjP4mWNVdlyCOG5xya8Z+IfW1lqovdKn1PWUttWk/exadpKLdTtjA3X
+AkMrHHHfBrN4oVb0bR9Tkbrs+MeqeitR6Q8OLXLq1jupeflY5A8qD/GB+H7Hms25XOEUge/e
+vpS7/s4aHdbrjT9Yvbe4kUyjT9Rtgtwgzx4sisUj/PLe1ZS++FWmaYWt1ZbycH8UGfCX23EA
+t+gFYzqJormeJ496NteqT/DZmJxbbR7Vwb4aSYysOfyqeSH7bPMcUY969Pj+GczBj8vnb3wK
+UfDcntFjHnijkh+0zy/Bowa9NPw3YHiIn8qcnw67Zh/pRyQ/ZZ5jtOM0oQ57GvUU+HfJ/dkm
+nj4ensIOfXFFoXtM8t2N/Kf0pNjdiDXqp6AZQP3I9+KQ/D/1gxmjkhe1I8rWNz2B/SniJyMb
+TXqQ6A8lh7e1d06A8vB5+1Pmh+1I8mML/wAh/SlW3lbsjfpXrydAAf8AC/pXWPoIYwIPzIqX
+NFLEzx/5SX+Q0nysgONp/SvZT0CmMeAM/ahegYw2TAPzFHMr2zxtbSckYjP6V2XT7k/8I17H
+F0BGeRCPzFSR0Gh5MX9KVplKFHijaddeUR5po0y7P/Cr3J+gYoxHhA3iIH4X8OSeD+lNXoeI
+Z/dii0Jws8QGlXR/4dL+ybr/AMP+le4joeIH+64+1KOiLf8A8IfpT5B7Z4cNHu/KP+ld06fv
+X/gP6V7YnRUCniEfpUhOkYhj90Bik5FLGeHf7PXw/wCGf0o/2evf5TXup6SiPAUD8qaOkYs/
+3Y/Sp5D4Hhq9N3zEYQ/pUqHpO8P4lP6V7WvSVuO6/wBK7J03AoA2CnzEoI8XTpGcjlT+lO/2
+QuMcKa9rGgW4/wCGPenDQLYc+GKXIukeInpC4AP0muY6SuX/AIW/SvcToFsf4B+lNHT9uB/d
+j9KVoZ4g3SV0vGGye1MPSt0Dgof0r3Fun7bvtGa5NoFvn8CmqtEHin+y1yfLH5U9OkblhnYT
+969mOhW458NaUaPbDvGKVgeOr0hPnlDj7V0HR8v8p5r1/wDZduBjwxSDTrcH+7FCdAeTDo6U
+j8B7edL/ALGy/wAhr1kafBj8Apf2fB/4Y/Sq5EcTPTiPuBgVGiZW3YP4WxVjJ0n1Nq+mx3Oj
+xwySsWBtzMFlIXzCH6jx7Vz0LpjV5pFTUUijRlI8S3lWbY3kHVTlDn1FZN0z0I00dYbcuiyY
+4BxXQwoCSExk8irOz0W/gUvcRi4jQ7DHHnJYjjH+daHpmz6d1N4tH1ua3ht7p1gsdV3bTbzM
+2PBuF7YJIwxxiknexOVGIeIKB3qp1CdY8gHtXrnVXwW610WK9Om2L6pqGkOUv7O2iYy2jL3j
+bjHigcmFsNtIZdynNeOa3d22qxteWitBeR/TcW7jbuI4LAeR9RVR2LmkQ11PwGyaknqeNVxt
+58jWVluGOGOQG7VHebHn2quLF7iZf3erfMkknvVbLIp7HtUOKTxG8PON3b7+lJJKUJVwVYcE
+HuD6VLiy45UdzIRzmmtc4+9RDcDPfvSANIwUSKpPYngGkk0TKakS1vMdzUq3v1B5Yc1RX6Xd
+i4W6iaMtnBPY/aoyXr4JXJA7+1Psz5JG6t79COSK7tco4PIrDR6pIoLBuB+lS49WkKbtxxnB
+57UbH7qNJNKrHgiiBwWyRwKoRfEqJN2VPGR5VNtb+1hXM5aXGDtU4/rRRXuI12n3ESARy20N
+zGf+G2VP5EcitpoHTVlqUsX7O0u9ju5T+7EwDxe53ccfesHpHX9zo6GG0RIFPcCMFgR7kGtn
+peqL1LbJqA1qG4LyeDLBdWrK8bYyCXQ4CnyP3FapoxnK+j0jTej9G0m4SfXOpdHkuIXVvCvL
+tlyMjhVCkY/KvTBa6loQi1LTfiBph024kxFPoOmKFAzkpKQCFccZU49RnNeR9PaFNPHGtvpc
+7N/BLZXHiKP1zivUdB6U0fRIpNTvurNVtHkXbJHJIjGQkfhMS43Dyy3arSVHFklvZ6FoepR3
+bpcXl3PfyDhpFke3kPpwPo9+3Nat7q1ilSWGe5KzxtHIlwi/SV5B3L34J8qz3THV1pd6GvjX
+k92ygIy3UaBgwHKrt/FjH3xUu76k0C8iV4rfT5XjbJtLu3lhftzhkbkEVspKuzjkm5VRNvXg
+RPGVXlx2EMox9uapbzUHjX97YeArnsW+r7mnG7hAFxH0bBNbODsjhuiGB/wM5JyKoLvU5UQw
+rLeWs45ZZApf+grnnOzWMSRJqFlG+4wM3oS2RSrcxPnbFKMjkq+Kp21pYlBubufcDnAhJJ/P
+FPttZsWKPc390Q2d0YhQKP8AU1zPs3XRLmnhRsjeX/l37j/SocuoSZ8NbaWKM92MoGfyxUsa
+hpxIhtn0pDJ2Lwsr/mUp7JeY3RyaW6kf3sckrZGfcVLiVFlUyhxujJIHGK72lqxy0iFhnJI4
+xXcvh8LarcN5qt0EP9as4rOSSJXZLy3T+U27OP8AzDk0lAbnoifJzyLhICw7ASyBAfsM5NDW
+iwqJdT1NbYdljjQNK/sq/wDc1InS1RcRFd6n+K2ZST7sAW/KmfJ3IQSeNEu7BCqjhj7DctU4
+kqdnFIInH02ZihPcyuXd/v5D7CiSWaJRHBO0aefh/QAPQYrsLS9j/eG0MKY5eY4/QHk1wd5D
+IILS3W8nPkOyCpKTs4hI4x4rW7STNwHc4wPWnNbSom9owueWY8D+tSWt7mBh4jW73bDIUHdH
+F757uf6Vylso5JBLdMbp17K7YjU+bEeZ9qlotM4xRtI29XwpGPXP5+ldHhmkXbGpAUZZvIe1
+ODDY0kjgqnYDt9qiTzu8LFZCsajLEnBc+gpFJ2QrgEzEYYleSMcVEttD1bVtVikEccM858C2
+WWQB1j7swXtGvmWPJ/SpaqpGxZcs3O1VLfauunWS2lnf3DXLgzjwJnVv3z7u6jHbHYD/AJj5
+VKVmnKkT7Zen5bgWtt4mradpO4zmEMkd5OQGMW49gSBvcdkAHc10ku9a6gnOp67PFhpNkUdt
+bqAzHskKd24wAT5DPalW2N5a2ukQtBpWi2FsL25jDDbbQHlBI5/E7nLc88gYqQIt1uurXbva
+WLJi3gUn5qdG/Co84g/r+Jx2wtWZ6uzvpMQkvLe3W2eaWRjti37VXbyxPkqIoO5z5k4FWd5c
+xNetqGpGa98aICK0gcxm7RT9Ck8fK2meQF/eSYJON2ahONZ0+2mt4Y7ePUbmNBKZEDJbxd1i
+VB3xgFsnk4zwK7aVpM8he41G5vr+RpkWfIJlnkYEpbpwB4jdyOFRPqYgAZE/CE6e2Pe1vddg
+/a3VF5FHpST+IkSxEQTTjhVih/4pHAyeBU3WtMtNNgNld209xe3GJLm0ZtiR5OcXco5GRjEM
+fPGDgU+81OPRrxNReeO96gICW7r9VrpikbdltH2Zh2EhGWPKgDmq3wdSuUa30sK8qufHu71w
+oSQ/iJByWYc596UioN/6Eiw1Ca3ljg0y3t7/AKhuU+Usw8Ii0/SLbP1EIOGkP4j5ADkmrDSH
+uZ459O6Kv5p/CzDqfU067A8hyWitUHCL3Ax9T+oFVugaH09eTXaXevyLo+nqJtXvYopJbm5Z
+jiO1hQAAyyH8EK5J5Y8AmrPWdcmS0hl1Jl6bs4kaHStGhYO9jETggRj+9uJP4ppSFQ8KGxSS
+pWym7dIi3t9Y9P4stDHiXccZElzKdzqO5+ryOclscA8ZOK6WdsthpsFzr+siztivjWlhHkzX
+e7tIVGNqHvvbk+XFRVhh0N4572wjGpyKstppcp8RLNSMpPeMfxyY5SDgD8bjGAU0PQ7bVDP1
+l1RJd3OmrOwlkEhFxrFzjPy1uTztxjxJjhUXtyRUrbK8Es3NrYaeNceF0XUDJBp8MTbZr9hw
+z7hzHAh/G45J+kck452ZdLSRQIzcIYw5VdkduGICqqjvJIey8lV57mmzTXXUWtPf6iRGoVFk
+SxjG2GBOI7S2HYeSg9uWY5NazSYIVsNTea4OlpDtTVNSsYvGlsYnOP2fYxnhriX+6M5Oclm4
+VaajyeuhOXFbKhtPk/ZT65q7raWNsGR5HYqyoWJKoP4pppMgHyRDTltljV59TCQy3sJ+UtI2
+Ifw9m5pWA5SNVHngk/rXbV9TsNMk03VNUtbBP2e8j6P0pp8xvba1YrgT3l2fpmlXjOwEZUKC
+BWU8e9ndyzs890REwUZMmTzvP8vr7CpnSLhclbJFlPJLdPfWctz4l3AYoTcEBguMF+Oy49at
+un9P0mzuJJ7jUobKJo2WW/mG+TayEOsMfdmIOB+p7VA0TT7jWdQaK2t5JIZpTbyzqpZFQNgA
+N2yQCSBnyqzvoF0W6ubeURHUtcdktCw4s7M9mUeRKrjefU4qYp9sttdI5QTR3U/h2dsbOzR7
+eys7beQBLn9zFnuXYZLH3Y1yRIk0+7muLZmvIdZbTrTjKmd23XLqO52KpUH1IqPeafcacnRr
+kSxzzQ3GsbdxBUi5Ko3sxRAcnyrjYavcapcmSIeFEtzcMjY/A03Ejr6HHAPvmm34YoryjncX
+TXN5cXB8SPxHJj2nJUj+b/I0gieKWO9EBjaKJpSkP1eJMp5Azx5g4HlUbfHBY+IxKKFLNnkg
+Dnt+Vd7q7jsRcW9yWjuUKpEMHkuA2/7Be33rJGpJRbK7vZzc2QluvCSZIUY5GeMIvbOe9Jqk
+tulzJbPaSssR8VLe5ARncjALg549Kp7ieSe1UqC0zSc45LRYyB+vNDT2txMsmkxNHEoVZzNL
+9cb/AMWQ3b2qr0TWy6k1TUobNdOXqG6gs7dCHgh/upXJ3Nye/PA9qkWHUM1jLumtxJayAeKX
+Zpty+ZZRwPvjiqSPUbq23C6t47lE48aE+FIR647ZqVp+oS3Lu+mXs1zNkBnuFCSRrjkeHjbL
+j1z+VUhNFzHfpfzNcaU4uIpGIQgbeB5Eef2q7tb+0aMNfWtrZvjY5lj2F/0bJ+2KoJuoLiwt
+4P23eXSJMdsUJt/Dmk91t0Gdv+I4X71Lsuo7eRvl5LKGzVsbZY1zx/jB+rPuDV2iNmnttUW3
+QeEIxbdgyvsyPQCrC01mNvrSFYdx4Pibs/lWOe5jd2lF3c3Ug5Co0KIB9guR+Zqda3zeGomT
+cxGfCW5QHH/Mox+tNug42b601BpyRsYbecgbR+hqwt7hiMsXbngHj/KsTpMolbMVpDFgbi8k
+/jqv32Vq9Mjlnhz8xbzg8kw5GB6YNNOxSjRoobliqho9pPkKs4p/oHiEr74x/SoOmWSRRhpi
+25h9PtViUBX8R47EgVslowb3R3ju0BAXOf8AlrqbrfGfxFh5YqHtcD6j5d8U88IMkj7c0xCM
+TOQbuaKLHC5TxCB7DsKm28luhUA3Vwx4Hirgf9hVbFL8u+QwBPckeVW9pdeIv0q0mfLOwf1p
+LY2qLBGOcNhMeSnIqZAyZGMt9zxUCIHIyiRse/Of61MhyDgkducVtEyZZQyN3JJHpUDWZppL
+V41Z/q4IVuwqdboZFAAJ9xVb1J03LrFmbc3d5bx5+o20xiLj0JHOPtW6i5LRk0r2eQdX6tPp
+t3ajSYfmLiCaJ4ykkYnO1gdqh2UnOCO+aTqqyvbG7DR2ObaSbfFFfQtBcQxyjdt3LnIBO3Gc
+ZHaqbqzoewiku9PuovAubZQxN1DJdQFHH0t4hXBz5+Yq76ftIHs7TTtUZ7my+X8KOBrjagXH
+LDaTvXjgk5HnXBLtpnoQSSTRws5Io5jIWaIBAkxlG3wsDJJ/9Pap1vcw6qkklneXzsrCMCaN
+geeQQD/CQCc1zS3jgla3s4RNbxD+8d9zYHbg5zjtnPYVLt7wACESbgOHKcFARwM1k76K76LH
+ThPHDJdPCIpE2AAtkZZsD+gNTopnmlPDRxIzcMcBueKqtNeXwXtVXKh9+XOM486u1hG4sx+g
+5JHc5xTTsl6Oq27OBGp8U53fSc/cAUoYqVERO4dj3IP2rjIADtiYr/GvcYIrskksqCYxKZo+
+Dt/iwfamSSYJA+C+/wATt9BwMe9Plh3N46E5xzx+Ie/vXNooo5CFkKIR9Jxng88/5U9iyrtZ
+gV7jA49qf6ZP8HWJcbXUAY7Z8q7BS27K4YnPH+mKjwssr4lyjH+JTw3pmu5j2EnB9c00Jjxn
+btbHJ/i866CKQ7hCuGHdG4JHsfOuSuH+ksD7MO/610jkwQdnK8YPmPvWhJ0ijL/SsbK6/UUP
++h/0qT+NRKjBWHDBuM/emRsjEYB7fSCeR+fnT5gHBkwDtH1j/M1okZN7OE9uj87Qrefoahsr
+xPtQiN+2yQcNU1WkVRuUMB3xzSThWTcVVom4ORkof9R/UU0rC6K+S7dyYJIj4qjPh5+rHqp/
+iqP86qLv3hoydu4gjB/lYeRqVPDFKghmw2zmNskEe4aq6e2ntXNzG/jRuMS5HP5jzHvVbJ0L
+MqyIXVtpHcdx/wBxVbc3pgTwrjZOp8nbkD2Pep7yQKBLtGPWq272tH4kIV42ByG8j6g/6UpI
+IsbDPY3JIs5Nz4z4Ehw3/SexrnJ8uMs1uUcHDfvCSD9qjC0abLf7tcKeCN2CPyxXUQpkRLqE
+LOBlcXH1qR5FWHNJKypUhFu8sNs0B/P6ql28zSsMv9voHNR2t0z4ss8GGz+9QDH54pyC2dfD
+Eyt6FTgg+ozWsVRlJ2S55930eEgI5DMATivOevNGS7sZbtXuxNHdRyeGufCkQttYSgY+jB5b
+nbjNbky3THwJlDOnKygABh9vI+1Qb6xtJ963NkG3gg4cgn7n09q6O0c71s8qfoforr7RrLpG
+4stDEmmXN8+iW8EgupvrPiXVmqk5Lsy+LCpJBYtj8QFeX/tP4UaIQ3Q9tqV2ZFC+N/s80Kkg
+4IM0rblIwfpUDBBBrd9T22k9HdSXGp6Z0vBPEYRPdRW7yRXMMcbgpcWzqOJYWUvle68MOK7d
+YaFB8VTH1FD1gINV16d2nstKjtz8zsg8QXq5IKxSqpMhTO2XOQA1Ty5Lj5RtTi+X+LPBepNS
+ttTRrCTW7u1gRmc29taRLlic7juOSee9Yq+0rTmyImu5/wDHMUB/Ra9h134JdU2Flavb9F6z
+qUU6GUXFrfRTSzOcFtyhcqAMAfY155e6GmnzmPU1OmlmEYt5p/EmVvPOwYH+lY5IzX5G2Fw6
+iZL9kWx4EQrm+kWpG/wl4OD7Vob9bC18MfJ3Jjk5hupcKkn/ACkcEceZzUIxrMQVjfxSeRgF
+W57j0rB6OpFPHp1ujq6xKcHsRwadeaNaLbxX0IhMU7umxWy8Tr3DA9gQQQe3erGdDEQjcFgT
+j7HFR1QyrIkJV9qGQgeWBzSsZVDT7bsyDHqBQdOt1IzCjD0PY1Lf6eR/FyKfE8T/ALpgOQ20
++jEcUwK39nw7SfDUAHsBSyaXGjbWUAlQw9CDzVxYaZeau1zBpds017aWk99JbIRvkghXdK0Y
+P4mRcsVHJUEjOKjJLG8EcyMHXO0MDkEcEf0NAisOnw4/AKa1hDnPhirCUqGIA48s1wLr2z70
+hkQWUQ/gpwtIv5BUksvrTDIo7mgBi20f8gFO+Vj4+kGk8ZO24U/xlzjdT/kAFvGB+EUht09M
+4pTMoHLUz5mPn6u3pTQh/gJ/KKTw19KBcxkcMMntTWuEBxuHFAhxQEDPlwPtTTEPSm/Mx/zU
+03cX84zQIeYx24pAi1yN5F/4namG9iz+OmMkbRRtHpUQ38Xk9H7Qi/8AEFJhRL2ikIFRDqMJ
+/jprahCBktUlImYGaaVFQ/2jDv27124/rTG1SEfxj9aAJxwOM004B71XNq8HI3iubazCOC9A
+Fp9IpDjvVUNbgOAHH51YWL2+qaVq9xb3UnzmmW6XotwgxLa7tk7hs8NHuRseYJ9KOxNDmYDB
+yOexrmzD2qa9/N13dWtn010TaQ6vY6dI93Fo+4HVFgXJnW1JOLgRglxEf3m0sFyDWZGuWsqL
+NDMro4DKwPDDyxVJCotWI9aYWFVba1DjO6uD67ADjdye1MKZclgfemnH51RN1DCDw/3po6ih
+PG6gKNBH8v4q/M+L4RP1GPBcD1APBx6edWX7M0T/APG2P/8Apsv/AHrJprdrKpxcBZPJSMBv
+zo/aM/8AIP1H/egXFn0T0/a9QWv7Z0K61O11rTtSCgwTaVDJeIyD92Vkx4kYU+aHBHFMm6Y6
+E1q0stG6qvp9SdwZDq6afFZazpl6BjwROoxdRj6Wy4IYEqQCM03pW70nroTw39gljcPbl7We
+31JZQSi7ZLuIMEkJjbDtGh4XcOa0cjaxpVlfdOdW6jaazZROIr5EkKC2coSksUhAbcMhgpxk
+EjvWErOmLT0uzzy4+H2u6Ncuv7STULC6yYriG1xE88fOWTJKsOcgcDNd9K6F6e1qOVNSkuLa
+S4AEzCzSWCZSp3R9wd+fJu4PHNbG61cP0zc9XnUNNlvtInjeVfm1hee3+lTOkQzljwj5wQSD
+zk1Z3FppN1cJfGY6Zb6nBFdpNJCXRo2AKNOqHK47M4BCnvUJuOilUjK3UfW9g9trcOoftWe2
+tktpL2zuzZX19bx/TELsNlZ2jXaqs43BEADcV36hsNK6zuvmL7RbCx6nLpcx6j8qk1rNtBVx
+cRkZVcBfrjJ+olsVr36Z17SLi6nRrG7g3eNtjlDxyuQCohkHDI2cjBFN8HR5WhNsZ7a2imcN
+A6EPaSkDciN3A75HY1jLNKMjWOJNaPEesv7LmjdQWi6z8OdUSbUpIDLd6FMygi6UZZYGXhon
+OdvIPFYP4jfDf4c61d2lh8NtJ1LpHXhbxLNoWs3jT213cFQHFpdSKro27P7mbPltcjAr6/g6
+VguzHqK6pZ6Rck77DUp90EFyyMP3ZkXgMCQMkAjPpU3VrPReq9M1DpXqPo2ztL17eXS7qO9s
+0u2067DAx3S4IOTz2yuDkHtWsPUOvkZZMSvR+cmufDvq7QFj+f0G+gJC71lj2tFITjYfQ5HH
+nVfD091BqljqurwaTdz2+iiNtUmWPPygdtqPIO4UtxnGPWvsC46Euukr5I9TEFwzzytc3OnT
+GWLUEyA8d3BOCjZGNjrhl9a46l8FbhntutOlendVtpElkW4tL5PBxYtjY6SZIZSDlkYkY7V0
+e+mrMfZd6Z8SyOyOUYEFf8qn2FzLaqj/ACqzK+XXHfI8vSvd+pfgqvUnUF109H0pcdMaxbqJ
+rW2jBkS/Qkl3hVvxgqMqi85zisbD8GrrTdbsbTWdegsentTuAsWtNE7xQQ52m5kVRuCoxAdc
+ZAyfKtYzjJUYTjKOyutJ9F6v0E6bLpZu0s4wzNaJtvrTOfqCE4mQHvjkCs7L8NdXjiuNU6cv
+7fWrS2gE5e3VllUEgbXhb6gfXuK0XVHQus/D/qV7eV4VuLd42XwJBNDKGP0XEEyHDRPwwIOR
+2IBr0LpvVtJXW4IerbY3oaTZfFYv76LBzIjRkGUZ27s/VgE1D+PQ0uX5HhEvTF6yGe3tZEhk
+ZQyupBhc+R9s9jVdapdW1w8Ese1iGGGX+JfI19c650RpesWpvNGaGxnmcmN7eZ5tNexYcrEH
+y/DDIOTzkHBrG6v8IYV1mQ9V6BfwzXQFwLrT3RI7yDAUTRI+cTZwWXPJ9M1Cyp9lvDLweA21
+xFdXANjbCNmGJoGf6G9dpPapgsRdXcenWxaPxWwGk7qx8m/71vNV+C2s9NteWkkWna/bQyh4
+lTxLW6aFlDCRGdQAw7NExLAjtjmtp090Np93Z2131DpF/DFExCrJbeFfWcgAP7yMjM0RGSdu
+TjkYPFNzS2gjCfTPPum+gupepNNuL2HS7nxNHuEt7q6CbrdkbIUlvI8Y9+K3nSHQkukfMSXk
+F0JD9I35MRTzAUck/wBK0lx8PrvQJzqehzQ3Vrfqo8S1uCbe4jyGVgvHKnup5BrXwdP2V7Db
+TyarcWWqwufCuy7fKPn8MMmOUJPAfkDPPFJTXY3F9EHRdQOjCOXp6IINnh+JcLsAyfJRjitr
+0/ffETU5nmhtrS5EGH8N/D3zKDzhXJzx/Sqw2etyTJpGsWJtjIMCG4wWweC0Ug4dfcEg1faZ
+pMUGmQzR28bzxOYrhGGXdB2YeakD071SyfRlLFb2WnR+qGWwvrSWCws5kuJryOOdN0kiM2HW
+IjsU/ER/L27VOv8AVnQRTS29nfWowMhy45HfIwRxWYmsNW1O8SG30R9QjknjtbX5KIFt7ZKo
+VX6vEPOCRzjGapJdXmtobgwazLiBvDlt5QdrqTj6Tjv3yDjBFS8lKhf2/J2jWXV/bw3JkhY2
+4lzJGiy7uPMUXGtq3hGaXxeNrbxkqv371g5dbu4YxHcQSuASYlzwc+YNRBrzxpukjOCaxedG
+n9q2ehXWoaM8TR/tC4R8fSdpeJz6ZOCp/UVHiYyYlRVdCdpeNtwH3xWLg1rxYmjZ2jyd30nK
+/mO9Pj1y5gYXCSWisPpEttOYpMehVhtb86PdT2T7Djo9FshdvGYrWwW925aSJywjK+gwdwPu
+KkB9Pt23mx1a1IGTbyXTFRz3ViMEfeqHRdSv79DLFDdXscY3SPDFskUeuYyRx68Vp7LUIrq3
+ktZ9RnnhlxiO8+iWCTyIfzHqK2VNHO04vY9b6W2jF5ba1cWMZBKutsspA/5iCB+lchf6zOBI
+ettXghYkm4+byv3CxAH8qgo7WHh6jpd48kDF/GEMn72CQfiBX37jyIq6s3hlEN1qNk97bTAM
+jvJHHkHsc/Tn7UlsJKtk/S9YvYF2H4jx6iF7eNphLj2DOQc/epU+s2DAxJHJcMRzJNcMuPf6
+Bn8hSJDoiMSmpWkTH8MUduZJP+pnO0flmu3zHVEMIm0N4ZWfiOSZQdh9Qqdz9zWtOjFtXZFh
+tbCWIOOnZpWPLMpmGT6kyk/1pt0qNGbaCS604D8UcLxsp/5iBk/rTJbz4lXrG0vurri3jyS0
+aw4iJ78ryK6rNIpS3m1eK4mA+vbEVGazlRabRDS3iWNhbo/1D65nGHf2HoPanCJWx+7bYO+/
+ufyqeVdzksVPsoH+dR5VGOdpIOM7sk1m0aKVkC5YOfCICogyFUcZqvvVbAjb6F4z5HHtVo7R
+I2d5Zs52gYA/9apNRkLO07sTzxg8Z9M1EjWJyZyQ0aTC1gUZkkB+raOWOftUjTYBrOsaXpKW
+3gWjB7jY5K7bZVJaV/QbAeT/ADe9VsULXc3gKpIyGkb+EHyBPpnmtJHBHb2k0c8m35/at9cy
+E7p0zkQqBlmyQBtUdqUUXLRKeCO6WO6urVL97+8FxZ6WE+m7uO0cs4PBjRcBYz3xk8Zrvezt
+ZXnzMt9by3VszGS8k+tEm/jcfzMMYGPIADipkUUNkZbzVJWgnuIxBHGyETqG5Zgi/wB0m3AA
+J3HNcLCzsL8xajcxm20mGYW9tvG+a7mH/ChTsTnGSM44HeraM00nbOGj2ut6/dm2sJ5NPtXV
+p7rVrtS0ohz9c2wdh5KvcsQBmrrqDVotHiXQrFbiEwW5VLIzGS5jV+73cg4MsmAWRf8ACOy1
+01Pqa60iB9G0wpaXDyGS4ZB4jJJjEcbHsWQZJx9IY4HYk0UbQadGUglkh+ozTTM2+Z285Hbz
+c+g4ApN8VSGrk7fRIt7KTTws95K66jO3hQQwgNPvYYCRjsrnzc/gAJpsmm3rxpYQXUNhbEiK
+a8kOUUn8YiXuyoM7n7uRxxU17E6XES0vyWo3kJFxMw3Pp1mRkxRqfxTyDlifwggdyarLSC91
+m6h0/S7OVpbj/drK1X6mC9yT5byBlj2GPICofdGq6ssDrd5FFa6N0Ra3dnptsxjsZCP98up3
+4e4PkksnqvKRgLkAGu1nFY9LTi+aNdQ16eNrmIt9YjTODPluFRTwHb6nP4BgZMqGSz09ZxYX
+EFza2LmC71BjvXU73BxYW3rbp+OaQYLlduQnflaadG4N7rlxNBp0zi91C+kUF7oqOMZ/G20Y
+UY2IvPpVOxRaI+naRNeo2q6pm5e8kJtLIMwe7kc/VNMx5WPIJJ/HJjAwozS3OpPf3QlupRPb
+wg20O3CIyDuE8kjyOT51L1vVraWNLO4c6V83bC9u4hzNY6U3Ea7u6y3H0jJ+ohgABmmXWmDQ
+hFddSQpb313GBbaWqb1srcdoyn8Ug43bvoXJyS3Ahr6KTvs7RXz6PpK6mGkgub/Mul2VogEi
+2o+kXTlslXlfKxee0bgBnNJqVhquuXGndEWd2JXE2bgQttjN0Uy6DzKwxbt8jeZdvSqxL67k
+uZ+qb2KQM8gngjfLySuMJFJJj+EcCONe5wAMVaW10/TWm3ukPYr+09TiMN4ZwPFgtSd7p6Rm
+Rsb27kAAUJp/wOv9yu1O4tDdmexlElireBa7F2q0KfSuxf4UOPpHv6mn6vb3GlltNjkjaWJw
+t0APpV+CYV/ncZG4+R48jTdLu2fWbSa6RHmiYS28AGFLdo0CjnavfA9OTUiK+WG5m18JmdHN
+tpMTIAIRks8oXsGzzn3yeair2aW1oudJtNV+Yu7vV9de3t9JtHs7d5pC0VvPMgVzFCuBujjL
+AAD8bDNRpbgzdTw6rNatLbl45RaznJSzhVVt4HUdt30Fl9DjuaiztNZ6Tpk/iWiancr85cG4
+uBGFDtmCKNQCTkAyufMlR2oguVitNMuL2+Audb1N7qVsY8PT4SYkcn/4lwZG/wCgVp+iP2SN
+Wa9utZXToLuS+fT+nbaOS5fAEkkrPK5HopaT9E9Kz/hLaaXpnydy/hXcLzeKO8hE7Jn7ErwP
+SrjWybaa+0eJ/CSOeOyuWQfXIYkCohPkMDOPPiqO+dbHo3pvCl5Va7tNv8ixXW8k+xMmM+1R
+Om2VDSR015UsdLaKR1ZpEeBi3DEn6mfHp5CkvbWfX7e5v7WELNNYQ6gIgef3QVAufQqP1Nct
+et5bi8l0yQlm8QwQHz2g9v0zio0WoyaPrtmbSZo5LeyntoWj7BSwG1we/IwRWdbNVdCRXgS3
+tJnYGCdVuYpAc+GqviRT7+lTbK607U735nXYPl7F22w6pbMGkVS2Uimj/i9N3cZJ8qrNckUX
+z20EMa2Cxh4UiGNviDMp++7P2pNKt0CMkc0MkMgwFkbH2HPFCdMKtEu4+YWe5aUW8Es0zO0Z
+cvGEJ4KsvBGPSktZNbsp1uNP1JfCfOGRhwPbPb86ZcQXtkLa7jtVaBiY53aTBtlAyN4HBUns
+w4rus1ur/Ly2jrBJHvZ9mFBPp6+vFNgiQJbhpd6p8xcSdyxJLeZ5Hf7VPsrJtTaM2Eos7qV/
+CWO6/u5XxnbG/qfIH9ar7a10+Ge3j1KaFrd+ba9s5yGiP+IccetS9asdWSQx3d5pcoDgrJDc
+eFIy9wdjcE+fBoX2xfpHSKC4t5pIr2N4GibEsLIUYN/iB7irmyuHiHBl2vwof8OPYU+Jn1jT
+IV1I6gl3aJsF06iTfGey5BO7HlnmuOn6WYHQrqcbnd9KS2skRA9+SooaroqO+zZ6Ok8m14bm
+2kUYyYUKMPXPkRW40XeyEbhtJxkjkmsVo2gXLFWM4lkQ/wB2XAB+3vXoWjIUwj2rxMBjDDtW
+uO72ZZa8FvaQcADkLxU1UK43oAaZaxKFGH5zyPWrCCMMdqtx6GutHK3s5pbk9wPYih7cgZyf
+cVYpBEoB24PnS/LBgQh3D3HanRFszd3EyZCtED6uDg+2fKoUN7cxTfXCIsfytnP2NX91YO6n
+6du30FUd5psoBmig3r/8I8ge6/8Aas5p9o6MUk9Mv7O/8aIbnyw9sGrSGQkcVkdJnTIUOwYe
+Tf8AetZaurqMN+VOErJyQ4vRKW8kiwc8CpSax9O0vkeZ8q4PGTFxisf1RdamljIulSWkd6GH
+hRXm7wZMd1LL2JHbPGa6VkeNWYKCyOh3VFzLfaj+0entQK3QtjE6yOxtn2nKgp2z35xVL0/a
+SSRftS06YuIo7hGkms5ofl/l5c8mNnxlTjI486jW2q3NzbRNqFqtnOyjxI92FVvQE9xUiXU5
+bD/e7hWZVGA64Yp7+w965HLnLkdFcI8UdG0vxbOe9ksoYHDhUW1JKEn18s+XHFVViy3hSeOR
+ou6yYGd7g4wQOMD1qbN1a1xstJorq+fYJ/DddvhA52kODxkDPIPFU2g6josjyx2HjQwpIXYX
+DK6oznB+tODznyqJJWXG+y4MMUJUySyQMv1K0UnAH+JSORV3bX/zCrJFcxSpINyOMFXX1z3H
+51XPHcRo+o4UrCp3EShRgD1PGPc0zT7yO6gaQIsUhPG+P6XUjsCOx/pUdDrkXTqX4SSNHU+m
+QR6YrvbtGP3qkg8YyP6Uy3jLKrbOQAa68E+ICBzhkxgj3Aq0Q/o6yligkdN5UYAAzx70wSMu
+1gD25x/2p5LK5AwScA8YBpG8NOQXVgeSOR/3ob8khlWK5OAx/LNS1kbb+8PI7kVAYMrNJESw
+I5XP+ld7e5SQBWyjDswH+Ypp0xNEgNEw2sFIPrTo+CURg4xyr91/P/WuZUgfvU+huzoMjJ9v
+KnBZFIO/6kP7tx3x6fb2q0yGTRC6p4igMowSp/Eg/wAjUkOY41mYb9owzd8r71CguVt2AXbE
+ScGM52Z9vT/KpEpCHxbYnaTzGfI+oraJlI5uHiZbiyIkgYchedp9KeJIWyrRZVlxxwfzFNt8
+IWkgKmMnkA9j6V1dQymRAyv5jb396tIhsr54khwRdhEJwviIcD2z2ripQkQNKkEmfolUgxuf
+/p5VONz4OULJjHIKnn71XXcNjcHaJIFVj+BlZV/UcH7GtEkRbKu5F9bSPDeaXCSWI3xsQknu
+PQ+xFUd3c2llIbhV1K0CvtuUULIgXHD4xnj+orQXdlMqeBJPbyxgbVQzFP0by9s5FZp9QaLU
+fl7qV4yFCrJNja+O6ZHG4d/fyrKejWC5EZtc0AmaW41FbUQAMZZfpQqezh17D7j71IjlN/ax
+3dnrMeoWMyeLDJgOuPVWz9SHyYcGqXWNMTT7mPU4NRhsmOWincn5fcTko3n9X8p88VhBdWWh
+3c503EUVzcK503TnkMCth3me23fVFNkgGLmNhuIwRzm5uPZssXPo9NFzbfMGGLVoWaRS3g/h
+JI7r9XeucF7YFlt1IinmJ8KKfKpKR3CntmvOdH600fUek4Nc1HdewWd1Ik2pJB4kiROc2hkR
+e4DiWJjwRtB5rlL1JBrWm3kIvNtsQktud/jITnPiDHMfI9xVLKkH9u3o9JfWtOMVzBqEslmm
+1kkwCJYzjuvuO4x3qgi6ztLW8l07XtdeBJIIvldQiVZ7QyMSFSQn8BkGGRiQMhlOCBWUtupI
+Op7yPR0LXVzKnhBlJVvFUjIUN+JeRg/pWch6x07WL6a/0y4e2h3fKy6duzPL4Qb5m1niK+HL
+jiSIHOAWPeq9/wAoj+28M0HW+s6/o1tadRabNDPeaHJLJiJTDMkchUMxRgyuo5yBxhjmvNl6
+jutA1v8AbWkdH32nW0BvIobVLkPFBcywsrywyKMCNlbOw4A7g1trbrl9IYPoujX99HNGXNpp
+243ENhGuWnMUgKypgqCoP0YOeDVPBrydM2hstN1fRdR0W/QXWnTXLtHNFbyJIZYGjQAo8JDF
+Qd2UZRk4oyS5bsrHBw1R5Xqep9QahJNPYP1ELi0haS6kv9ckSOGNVDMAFyHGORg9vKs5qXWO
+mvdxaV1boV9eXtyuYZLa7Ns8q8ZZXI2yLyOcZ5rX3Nomna/dWWqKgl0UFont2BE2FDpuXOJF
+aNhuA5Hpwa4voep217HY6ZrIs7dVkleyvt8s8cU0ZMtxZXRQjw3UhTER9ODzSWRyjtl+yoyu
+KPN11zUJEv4dFuCRBIHFlPGvimLHJKHIYqeCRycV2TUNH13RTJJptxoOtQukEF3bXniadqch
+AAjkgmO62kbk+LGxTI2lATmr2/8AhtqVzc203R13bdUajISltbxxC2uiqg78M21ZTsAwPxFv
+auen9NdV9NdQ2fU8/Tc8MawtBqUOr5htLpHjbfDeKPrhVgpUSJghtp7jnHmro6FjdWUllb3m
+oQQAsqXVxC89luYNHdpG5WZA/wDBIpxlWxyPeoOm6ost1JFJpdw8sGRskQwucPho2cZUZ5AL
+efGauusYdNsrlI+nYtRs7m3uI9QtmvbQWhjlkgUSgqv0y2rZRGkHLHbJ5mtL1L07outdMWmv
+6Fea9Da6tZx22qpEI1fTL/aRPC4JO+MlQ4RuGUEqdwqL3Vl8fjZ5xqaTw26hUkge1le28O6G
+yRh+JR6EgH/KqyO7cy+FM6xna30SfSWYdlB7ZPke1en6LovT3UWgP0r1xP8AVcWy3llrOmzJ
+FvkEfhR3qq2UaM/QkgLDG3J5qhsujda6amuTrtvGwtJYrWeyngPhPc8bR4fLbyCCGjJBU5FO
+E77JlGjK6VqOtb7fqnp0XQvNBv7SR7iK3aRLd5JMW8rkZAVmBRgeDyPOtR1JLoesaUvVPT+k
+6fplre6xLpkttZXORpWpgFmsJo2+oxSfXLbXA+nZmJvqTnYfD/py/k1C/wCotCttU0qHXLSX
+T+oNH0TUg37QVpiY3gZgC6xsPFXj8WVOAa2WnfCzprTJtRvdG1HQ5ItPWWz12z1iweH56wCg
+wamLbl4LtbhAjCMlA/1EBWNU8lJiUFyPmqSe8S9trG9t5oZrhGMG8YE6DOChPDcgjA5qlvOo
+xZzvbzDY6HBBGMH3r2nqXoWP9iX2g/t7Uuo4bSRbq40y4tovmrBGYZlAT61cPkh4gVKYOM5F
+YnqH4K6j1Jpdp1FoWq217BqNs8CXPzcYS6mQnhmOCso+kAAH6iQ2AcghOM+ip4nDtGLs+o7e
+7aYfNxxG3he4Jf8Ai2/wj3NRJupzHliygffvWc0rpnrWz1I6VqWgo0kM3gSwahblNrZ5G9f9
+DxmpSdIW+oX0VrbXssVldSi2KNOniWdw7bEJc/S8O8jJ4OPem3ugjCyyPVREYm3DYSVyD2NM
+l6u8BjFLlGGMgnB5GRWcvOiesNH1J+ltSsJra7nv5NMmgu4mj8K7hfa6b/whvz7EetT+t+nJ
+dG6mvYzdQXsFsI0CZZZpoSgHibGGRjtnJ7U0tWDgi8fqiOXRBqK3EO+C9W1ZN2H2uhdWI9Mq
+R+dcIOoJLvPy8gP0lmOe2BmqRdLmsNGvOnL67Nnaa6kOp2lzLwk4gJIXOOCu4hv5fOpun/D/
+AFHR7NtXn1JfloSguS0byC2RhkF3iB2ZHYkEe9AuGzu3UkqSeFIxV1/h8yParaabV4E3XFpM
+q/ThymB9QyufuDxUMdL/AD9lBq1nZ6hcafcTG0N5GVlg8fBYGKRPw/TnIIHPpWi6S0FUjttX
+1W1nv00sCLVrKR5fD1HTe0buSQMo37tSpGMjkECpbZagnozM/UBtS0V5vgKLucSKQVHrjzHv
+S6vrU+mz2cN5G8JvbOO9gZlIWaFydkinzBx5favf7f4UdDWNxPDqV9/tBoWhSpdLc2+pmDUt
+FZyJYf2fcOpNztP97bzRErtODnBNp1p8MumOuen9L606r0aG0xePNJf6Ywe1WVz/ALxJdWo+
+q0WZirF4/wB2XywVTkE5pdicN9Hy/f6xeafNHBdr4bTRrJGxYbXRuzA9vY+hqBc9U3FpcSW1
+yrwzRNteOQFWU+4P/wBDX1v8Sv7KHS2lfD3RdCgW2TrASi96Y1ONA0Gv2cx3T2l3ID4E0keB
+4E8eCwYLInnXkWvf2ede6n1ODVLi5lBieG01G+vBuBjdliDMAPoeMkDHbCEA9hVOSXklQu68
+HkNx1PdWfgm7gnhF1CLi3MqFRNESRvQnhlyCMjzBqPH1fvkVQ5+o4zmvT7X4Dde2zN8M+pun
+rjUbK7iuL/QFguka606YOwV4iSFWK4WNmMTHBwpwrd8N1z/Z8+I/w+1iOzm079qQyadY6nDc
+2hISWK7z4CqH2l5Dggom7DAjORVT10TGLfaKZ+sWVioc5zigdWSEHLnIGe9Tr/4Oa7ofUWi6
+f1ZrOmaVpmt+HcvqkTG9XTrYyGOZ7mGLMsbQMP30ZAdRyARirjq/4MvouvWNtod7DdafqTSW
+PifM+LHb30RUMgnxh4Z1ZJ7eQ4LJKAfqVqni2rKuKfEyMnV8mOSxBpo6skYAb/v7VLt/hB19
+rvyOmaHoUs91eG5e3hZljNw0KlpERmIVmVFJ2g7sggDNV+jWegdLQaX1P1Lpo6ltL5ZJIrOO
+4ltbZyvBV5lG4spIyq/rTUX2DcVo6N1JdyHCK5B7Eg4NXugzaZr/AE/rjDU7q26i0xEvLG1k
+CC0v7VeLiMMfqW4XKugGQyhxwQKTRNJ6Oeyv9T6ntuoL7VdLSO8bSI79La3ktZGGGjcK0rqg
+Zd2ADg5qrklmg6htdVi6Rh0TTLi6NvLbfNPIixFgJU8SXJB2NnPcAg1SVbIbvSLtumOskg8e
+WLSYwAuY5tat45E3DI3IxBU48jVn0vZdX6fe/tq1g0yY6VDLcXiLqUM6/JFfDmZ1Uk7Nr4PH
+civTuv8A4Y9P/EefSodE1S81W6SN9Ih1i4sI4JNUMI3W9lcozDxLpYSqwXH0rKihCdwFVfwt
++Fel2c0cjaxBJBLcSaPdzWeYbtLOdGS6tzA45ZB9WD2cDBxRSi6Y7clZ5VZ6zJ01qNpqPTmv
+3LXOl3EcsF9AHt2imRso0T53BhgHdxUzX9Wj12WfqVLlRqF/cyT6hFFarDDFO7FsqqfSA/f6
+QBkngV6lefCvW/8AZtdF1600bVdQnRGsdQto9t3BGkzLHNujYxuZAhieKVVbIyOcE53qn4QH
+So9J6m6RW7kiurVF13ToyfF0yYOVdHRh/dkBWV+RkkHBFKxqC7PLX1WSQSNjaYgd69h70wSX
+dzFBPbFDHdSGGNmkCqZP5Sx4U/fFek658NU1XVlh6TWG5vhNHbzWHiKgZm+kj6jgHkdj5jyr
+v1X8Ntb1DWNUA6Xn0nWtKtbWDqbp25hcyeMigJexowH0OqgPtyNwOCQacVyCTrR5DONXhv5t
+MvLKe2ubfPjRyoVMY9T5YPkex8qiy6hdadqJs9Rs5PFt5gk9sWIY4I3JleRkeY7Zr6Jl+Fel
+dYdFatcLZ6tp91o941pqUFshe30y0uNptngLZfwBLujeJyyrvjcEciqrqXoEXPW3S/xS656H
+jX4c6jbnRNQv+hmSynE2nwLDPctDISYrlS0ckoYBJG3EHkkae1fTM3lSdNHnUVn0t1nfSxfD
+rWJtKvmO636f6huV3TH+S3vQAjn0WYIx7Asa5/sL4g//AIjSf/lE/wD0q9WX4fXHS1xoOna9
+0Bb6Zc9J/NWF98nfQzalJqx/eRX5GCTC26ABFZ03MwX8VZ//AGM+If8A9gXH/wDR5P8A9Gk4
+JOmKOS1aKDT9ZK6TaXVml7PJp17tvoIB4ipbuci4UE5UkkKfL171vtU+Idxc6fpb3N28vg2w
+s7a6Zj4qwKxZYJ+f3m3d9BbkKcA4ry/R3PSl0Ln5Gyu7jb8zbrfqzWt1ZujIQyoRuZXwwyeG
+UHFRnSUdPw31pZ3fhwyLbXt07h4zO2TGAO6naD96xmuSKi+O2etWXW2sWlpH+y9Whmit0kxG
+1sji4V/xJICDvA5A9q2/SfxPkupLV5oLKCa18NUa1gVIhgAbgAMISR9aEFX/ADrwPpPqC3n0
+MqYZ4tQ09AsMltcr4kzGTJ/dsOcADG3n2rT6R1Pdm+g0u+lfVJZT4Mu2JI5I8jK4IwrYHfOC
+CKylGzRT6PpM6lYtaC40m4t7S6FxsubMPm0mUk/vYom4TOQdo4HYV1074hPYa5LrM0ZMLwG3
+mKQrLlQNok8FsqWUAMR3IBFeL2uo3nyUsF5MskNvE5EucGXA4JHYHkA/atx81Le6rb6nqWnX
+MwawgVorCQQocIBukbBK59AOfUVyTjXZ0wke3SahaWVtBZnpDpy6TXjHPadVdN31wLW6LjaS
+bdi1uXXO2RCEde/lULUXS+2aR1SljHqOmbrWO8Em5tmfpieaM/VHnJDnOM4NeXaN1Dd6TY3V
+vpunxTdOC68W+0dbl0ju4lIw+Cclxnhh9Q9xW2j03Qb7T4n6UvL6706Rmmmt7lIY7iwdwCE3
+E/WhAOWH05HvUt3oEkuzpJo0cFlJ1CwsNd02G4SG/t5VkPhK3CncuMvkYwMEjkZqN1H0bo1l
+oj6t0trV7Z6bdkqYZd93BDNj8IePkKB/4oAYHHcV2Fpd6Wkot5L/AEyKUxpJbylW+bcfgYbS
+UfAPGeRzXewl6g6Ut5bvS7uL9k6zObK8BhFzGZbeQ7YZoSCWLFm2j+LdjINEZcQa5PQt9oum
+S2dh1L0/aXEFksJll0qCTZcaHqS4LfJyyZ3RyKRLHGxAILrwRWav9I0novWTZdXWkV3p2s3Z
+nJJMLE7fqlA/gkBO7K8Nk1a6J1F0dKEFtY3+lajM08d5Zx3En7NlAJSIrBLlonQqTtyyjcVG
+MVMsutpIbG40TUY21O0dH+lly+8Y2zRO2fCdcAY5U4BxWkcrUu6E4fE811voi3kMs+n6/Y6n
+pVvcMg0/VDGHG7hZYZYuSvOShGfPJrP3v9nC8glhvYbYW0cUiSYiuDNPp+5ciXwlBZrc5AEi
+52k8jFe1XWqacurr1DaWemLIpM09vbWEMbfvF2sRgbSwOTjGCSSMVU39zcaddWmtwXUVrbm6
+BtL+CYxopJwuccx54U5GBnniqWaV2yHijVJGJ6LsbzTx/s9ql7bO9xFKdP1C3jBa3uQdw3R8
+I4O0jjuScjJrYLpH7XsRrEdjbx3sbbpYLSYrBOGGHkjB/CW7lD+E9qk6leafrKXMXVuiyX8k
+K9ra+isZbaZHyJQyqY5fQnsRzXPSbDwbq5sLW/mtzMnirNIQs1vORzGY84cg4ORwQciieS9h
+GNaCCe5eS3PyjXMOmtHHDbahFGzkKuFEjIP3mVYrlvq7c8VVX/QUVncRW1lc3ttFajfayR75
+FtVcEbQGDM6jJGO/HNai5upLh/G1LT4YL3b4F4sYKLMQOGA8jTrOJDbvGJJlkJBRfH2kkcgq
+3Y8fwnvj1rJZGaSh5OnSvTurWWqR6TZ22mamweKdzokiT22pllIb6HAeGdQpJZRhX/ECDUS/
+g0ONZbeG2kurWG5uABfSLFcz2u8NEiug2C4CbxyNpYY7Gus15PdJ4cUVrJeWql1hZhvmTPLQ
+v23DHK8EfY1Y6bqCyo76XdPazXkCb4ZrFJvGQMGG0MMoysMbl5xx2NDm2Sokq/sOk7mztNK0
+Pqm/vbGBnTTrjVdOEN1ZhcnwpE5WVCD+IcgjFUN1o62zyPZTbWk+ncrlou2AV3DcoJ/hPapv
+UkcEtvbz2t1CrRECWANtlQNk71QdxnHI9aZPeXUxjlmtJ7yMQlXMEiBmUdzsfH1LyeO+KTy0
+7RSx8lsx873ivOLtlt7pIwjIMxiQBv4XXs2QCCfTIOax+rabNHrMdzfNMbW5TeOCpkjJwwJH
+8Q5w3r3rfa6b6bTpL6C1j+VtPpurt5ESaBy3AZSclSNp4HHNVtysthMxvbaK6ghI2zeAZY2y
+M8YJGPel7rY/a4nnk2lXNvfR2tjNeXWm3N2LW1uJ0wRIx/dq+PwEnA571BuEhu7eWOXxbO+t
+t4MQXeryIcNG6915BGR51vYmjNrdR6dJFJaXJ3vbY3qGHIx6EeXmKwGuafDHeyXVhdGJnUMq
+k7WLff8A70ntgkVDSyqjFNyM3AxxjimX2pytOZzGRhQJI93fgZNSrhDMJLdwVnRfEQ+TY7g+
+49fSqLU7qYSG72qFJw23yBGDxS2gaTLOyuWt5o9Qt7meEKfouIGKOoI7HH+RrbaR8RJ7mE2c
+90qz4wlyy7mJ8iQcgGvPdPvFNpeW7jNukKLJtP1Kc5jlH2PB9jVL8xN8wXibDg54PJ+1Usjh
+0YyxRn2e2QancXEqKssS3m0AOrbRKvlkHg1N0XqO2sLl7K5EZs5hiRXxmBz/ABJnupPdT9xX
+l+m9S2nykUd3FLLcqJECPyn1D6XGMEMp8uQafc9QIzRtA4crHk7k/jHBDCtlmrZk8HJcT6L0
+yVkija3aeFe7LH+9Rz6hGyuPtVtaWoLmQ2VuxHBJiaF8/bsK+c+nPiMbdltfFngg3ZEZkLpC
+/rHnsp/lPHpXrekda9QeFGw1ia5jIGPEbenbgYPIrrx54zRwZfTzxs9CDPFAIF+bhVTkFbgE
+e4O7JP5U2XUrAN8vPMSpHZoGJ++7AFUem9YxTsZLuC13Dh9krKf0q4j6htXAaG6ZN2OBlxj/
+AEq20YJMcLizkANukYUea/ib9e1R3LvysO0Dje2K6ftaOdirKJwOzAfV+QxUaeWwH1XMrIXG
+VDJ5/wDKM1nI1iQbkxKTi5V9x5ZRuC+wHmfuaiTR2MEXiQWK3FwCAlxeyFlX1IiXAPsCa7yN
+DczE25ll2+fgsEUfmMCo/hvdSiOJJJ5MbhHHkkD39KyZvGhqa1eqqiSQvEo+oRwpCpPkBgcf
+lU2LUbm38G5CG2nk+qCKNiZCD5575NOg0RlYS3rQRug3GLfvK+i7RxnzJJwAK6rGtvOXsIjN
+eTD67qZudp81x+EY7D2oHcRthYC+1FpdUaU2lr9UkMb4ZieRFu/mY9yewyfQVcxzzrcftING
+l3sNvAUGI7OH/wAOLyQY7sOTye5qvgYyIbeCQfLQOQ20YBfzJPmasobIzj5qV0htYSFMshKx
+g+Q45c+y1X8Eur2Rdttb/VY2j3co4RivG71VO559astL0+W1kGr6pHDdXaMGtrEEODJ5PLjj
+ap5x5nFQhKkv1Wkc0cLNxM/EkwBwSAPwKfID8yalSzTzKLa3At43G36FwxA9T3P27VP7K2cL
+q1udQuTawh7q7upB4jDhQck7Vz5dyzHgd6mq4sbCey6fkVluF+WvdVJKLIGIHgQHuIyccj65
+T7YrjLcwxxSQRsY7ZY9lw6n6pE84lbyUn8R8+1Sbm61LpyztdXhm8LXbgyJpiKoKaLb4xJdh
+Dwbl9wSMkZTljyAAJeQdukhlxYJpF2llqUUE1xpwENvpso2xQnuDcgcRqPxeAMu3Bc44pkkz
+ahdy6xq0zy2tjIjTyTEE3M/eK3CDjaWAd1HAjQA/iqFo0cdrAI4XSGCNmmnlkzIzZ5JJOS7s
+3JJySe9M168W9mstMtotlrYqW2bgxaZ+WJPme24+uAOBSb0XTui00RYUubnqa8ubTfb3DXdm
+t/KP981Bvx39yAMsIlyYoQMBtoGMZquttvUWr3GoM9xPDDCbiSeYHxDECFVQi5+p2IAXkksQ
+Oa4i1tLmQxXl4y2+zM+wYkMQ7gem7sP1q7g1aPSenBe6VZLpGq69cSXsbRyNLLYWEeYYZueP
+GZt6xIoABJfyBqbtbK/HomahrP8Asr4mm6bdQRa7AQdQ1Fo/GTRS42+BbIMia8IyCw/uySAQ
+cmqldMsLDTWvtRiuoopZCqtdSeJdXk+NxG0HuoILknC5APPFR7XQJ9OFnAZEtZ38T6T9X7PT
+OHnfHDTkHZGvJDFmPIp2pMNUuLo6dA/yunxpBJcEYSCHP0Qrnksxyzt3dsk9hQ7fY1S8jtJv
+JbWw1bVokT594kgtmYBijSvtZ2P+FAeBgZIqHDEsi22lW80rzag62lxfPw5i7ypCvaOMDO5j
+9THPYUloGlik0+IMJLlw07dxDBH9X5szcY9BUrRNM1DXXnNg8MSx2radFJNJsSAzZ8SQnv8A
+Sm45HPPFQt0i9K2yy1uQ/NyyhVnuLfTbeeBEAGJbncYkJ8gIli58lHvVVrSvPBK8REsGm2tv
+pkLBfpkEABYr/wAzl2P3qX1LPYyagbfTJ3kQiPfMw27wkKxRkDyGxcgHnBGeTUTUdQkcWdjH
+EFijRUCqMA44J9yeSaqTVsUU6RpNRs9PudUuIr1/3d/rS3Ee18bzIVw0jeSBc588VkZJHuId
+Stbssf2fqM0pZVwo+v8ACo9C2DUvWLt71WtELB2mJL9tsajC/mSf6VK1JYb61kvrdGxqi213
+Lu4y6ko/HoWUH86H8ugVx7K9pDHOdRumJuDmXkfhyMg49ao3eN2WcYT5ufDH0DDJxWh1BP2j
+aw6eYyjM3hyuxOSoGOPvXF9Cgaxlkh5ltYxcFd3Cw5wz/lxn2NLi30Wml2QdYvI9Plt7uaFZ
+bKe2gcbD+9iJTD8HhgSM8etdpbaHTEs5Xdryy1HxRbMjCF22qCVwcg4JxnzxU6JLeYGa4Vfl
+ILcwKCBlnPA2/mc1VC0mS1h06Sdz4TtJGG5+onDEemTSarZS3olSG0kit/lJbu1nLES20ypt
+ZcDAY+Yq2t7GC/09ZLaO5lbTEaRY42y6w5y6EHyB5H6VH0m2uZf3pmWMIpdd6hwVB+o4PYA1
+M0s3TXcup2Dmw1TTmGSGzDNFI+wZVuCp8xmkv2Jr6OA0iK+vXeyEcUV1EZIXMeQzqBuU/wAp
+5zXSHT7+C2L23++wo+2W3bkxt5gqex88iru0h3aodQt7Bk066kaK+s1wzWN6qlXC/wCCRfqX
+8xUWFJNPk0+SeZgWkWCO6XKxzrzt3E9mGNuDScRxkQo9PhmLXOjGWK5i+trZiQXj/iCk9yO+
+KttHjS7haSVZG2kj922048z+Vae107Rb24lne2CzQfvSu0Ymi/jx6MKrZdJ1XRtcjtZXDQXX
++8abOkaxpdRN/D6eIOzD7etTKLirNISUnRq+n5UFosF3KJsHCziIrJjyDY4NbDTXaEqszb+M
+KwXGR/3rNdOoi4+Yhlt7jdtYMhj5HkR2P3rUJb3AcmMjGfwnnjyxXRj6swmldF9aur7eQT5E
+jBH3FTlbaMsCT5486qEiKlXjX6scrirKCQqo3cj34IroUjmaLGKQhchtyj17ipUbLw3rzuFQ
+bc45X9Sc8VLVQG3KwUN/CTgH9aLJo74YkLIBk9iOxqPNZRzElVXxE5xnBqTEyjchyvs3+lPa
+OJxtKkk+eeapbIboqTpqTscoFfuc4NTbW0kiABjA47r2qXsj4L5yP4vP866Rl43zuVlPbFUo
+oTyPofbhDGVbnyOaynUJhsZ8zidBJxGV7E+5rYtcK4wyHJHlxWV6lhumSXYeylkwpfY3kdvn
+Vy/EmD+Rg9f06XUFCx6mtvIo3uWTIx5A45JryzXerNZ6bkNm95Y3kJH1R7+/qpB5BHGCD7Vt
+9Ql1Gyima5hNxA7lQTCxeQ+pxz+lZu4ntrdl01YIpLu4Bla0nUMyqTgF/JQT2BwTXJKaR6WO
+F97Omj3sPVUdhqNrI6Twxskkbg7VYkbV3DhgAO3lXodjZSXNx4tk2wyHAKBQvHkPXFZbpq0u
+5FkQ2axzxgBlQBVjBOAMDjt51v8ARrRrC2DRg5bKBjxz/Ecf0/OoXyYTqK0d9MtHjhe8uIYi
+24xvE+FDjsc+XIqFHpU0EjtAXaMnKRuwJVfTNW3yok/dvkoSGEeeN3qD5Z9KsHh+kG4Dx7R5
+DcAKvjZjzohWUYgQeJu48zwKnJH4pHCHGecjJH+tRmiceI1o8bY58Mnkj1Un/KgtCXQupRhy
+D2Gf8qKoTdnS6ibYSrFljHIH4vuK5QuZRkHccfUB3x5Gp8cqYWZUBYDvUdo8u3KtycZH1AH0
+NOiUxoz/AHhwGB758q5sojkBwCrHDMprrHjcY4ge3Icd6Uqpb6QFYdwDiigs7RIIgJEdsds1
+2Lo/DBSw9D3rnGpYeIkmM9x6/nXCXcJMSKQf4XU4zVLRD2T1wU/czKR2ZGIPNPWVkzG8JTI4
+xyD/ANqgG4U5WUFHI4dlBH5gUhmdB9V2NmPJSf6itIszkibPAsxEtsuJf4tzbc49xwfzrkLy
+a3ys6yRuD542kVyS9eNQrywzIRwVPP8A3qNPqXg8wxkgfi3Ec+5rVNGTJc92ki+IhL/4Q2CT
+96rpbsIhcxvbknDbwHBH5cVFl1mJm2AIc84RMY/OuaX0RDvLMAoGSZSWX7H0oc/oaX2RZWgM
+jK8jxiY7QviDY/5MRt/I1m7290q10/ddpO0CkvMzxM6oinBLY7Fe4YeXfirOe/sruOUwR+La
+rgSnC+GsgPKKT396yNxfJGy2sF4kMF3IRbuZgqCVeDC+7sroxUg9xzXNknXZ14cdkV7mykut
+U0zV7hzb2Xh+PIqF9oI3RzMo+pogCNzKMqDkjANZ+HTLe9v7K1v9afpu0v8AUkaDV0uIxFaT
+FS0J5ykkbKMK3nuPIIrnba1Jea1Lf2yeHNJKtrdrAoL2ciLsViByYyCFJHGM5rKa2deudL6n
+0u8sLVoLV4r2CJY9sNmyybOEHIVP5OAd2fMVyrJe2dzhWkyL0rqs9v1MOqR42h6V1PpV9oOq
+WbSsLA6i8DSw3Im/8WOeFecYKO3nmslpnVtre211f6RJYyGGC1MumSlkmQvGC88W04kQSZVl
+7gfVjk1TfEzqqx1a+v8AXun9NudMtb02E8ek2czR2q3D/RcBEz2LIXQrx9bKR3rxuDqrV3EW
+u2FyItokaSMD6gBzk/8A08qtpyQQqLtnuVx8RpLq7UOsRhs18O2mhd1nQn8SxMAS+MZwewBx
+iukPU9ja9RWevWN5O8N/bqqRoUbD5yNwbv8AUzgFsEq/4vKvH9I+Kc1g8U0FtFbajBdWup2e
+oW8225s7yFiUuI1/Dh1OGUgq65BzXe068N3qt7rk8dr4Mt58zcxQoII4FmyJFSLGFQt9QUDa
+vlxSppF3Fvo93tr+e61+LU7QywXVtIYYY2DWzW9wSd0KjOBI0YB2Z+tBkZANM1/QGvtkNnqV
+xZtbT/O2gkjAZB/GoU5DgZONp4yePKvPrDqDqnStO1PTLPSjrfTOq2C2Vz8nMWu41jkE1tKd
+pLia3ky0L4wA7R/hYirLpv4papbaml1Y291d2l3Ak01veRt8tMPqXeFwPBlJGSVwe47UNtr5
+EJb+KN9fvc6+83+0OlW1teTFdQigu8Wy3oePtux9L4AIUYyCRWP6bv4NC6i0jUlt7nw7CYyw
+2M0+ZYWYMvhtngqG2tt7FcetbPQrzT+roI10pZPC8HItJnMj2bqDwpbl04JGPt5VS9V6Pe6n
+bW4hxLqEcb28QXDRzqV+htwH1ZIwCPqU8Gp5pIaW6ZH6w07SNSs0nbUrfUrOa6mhEaps2XDo
+hYsg/uRv5BU47qarY9Q1S2vVi0eyhgto4EhWyuHN3mDwsXEJ8TJkTeWdM/UmQAeKqI3hvrdt
+SJwzMLO7UHBcKuN+1f4x2cHBO0HvVh09Fd6tDHZLai7vbcySQG3w3zQVfpMR7qzAH6e+QamW
+RtlRgqom3GnQatoOg9EazpLNpuhmS80kWNz4t2Y7hSvy8ckxJMQwC0efpCjGKfpOmHS75xqt
+jHDbam6QC4KsIN6x5jglGcrtycFsjGCDipd0NLutL0ieDSrkatotq0g2oYZblxlwn1d25xnj
+I486rbTV7/xtQsbldtvY2lmbCd5gI3iZg4PhE8OC0in2+k1MpvIuxqKxui10aKHo/qqw0TT4
+baHwbxNSsVvWEtpBdOpL/QRsdGOAVOODXe71m/6hv9Su1tbewNxcTPBbCU4j2vuijWRsusis
+NqseRhe4qy1PS9T1IS6ULM3N5p8rTiCMr4sUJIMkK8/WoJGF7qeKj3lrq4vILxNCuLu3vpQU
+kitjndnARivJYevkftU480pfEc8StNjNH0jp2G1uNTtYJLGfUPAvIfDl3fIXoYm4IB/CJ1J3
+qMAEkgCr61sLeWN9Skn1Ky1uSwm8FrWFbi11Gzdts0TlvwkgFgQRIpABGKh3kujw6heXOu6f
+eyR2GmQC7glgZH3EkCeJ1wfQMSMjkngVF6W67Xpu4eNbeS906y1G2vLOK4x4r2rkloCwyolR
+ByxP1A1pKS1yZEU/Bp4Z+ktSd7eF107TRb3KxXUVsssxQqFtLdnH74YmYDxA20gqxwRWNudN
+uJtMl0XqBtIjseoYPCvrhlXEl7H9UFzhMeG4kURvLGQTuy9U+p3mnwanOqRGW3fTHW0CsfDj
+diZVTIP0jJI/IVtdL1W66cl0NbvSrS/stVthd2SX9qHjeZ8JcxsCQwO9l54/HkZFRjncteDS
+caV/Z5vfW3ixMLppC0MRknaW3M0Qt2/BMSoPhGNgQXJIdT6iszY29tY3GozWT2bPaxQt9NpE
+UNi/0ySDcCj8nGO+MGtb1frbaHNedQ9C3Fw+mSRiyuTZM0UtpPC7O9ncJ2kj7gblwQikZxXl
+kl/FHdWN/qvTUhgupJUvLe3TADYz49sQP3J2vh4n4OCRXbGWjA3t70H0Rq2h6fLfdbaiL65u
+N+o2oujbTwFwzRT2ySDbdLlVjm3diUZCRmu9l0j0rbafqXRXVM63Np1FPJfWt2NFnfqHS5Qg
+Ml1aykFLqFolZZLV22NjKkNzXndz8QdZ1nSdP6b1zXoZLWwtTZWZu4MtDbl9ygSDnCkfT6Ak
+VqOi+pb1tTt9C1vXtMt7WZEtLdNQtZ5IEjZZSi27xnciTMGDY83BIwK3hJPoxyRaTs1PTvUP
+U3wg1O/selbrTb/SJI3vtP1Gw23X7a01pgrGKznRijvhhJCuHCMVOcCsTc/B/WL3ruJ/hD07
+MiXsyoqWV8yWCu+SyRrLtmgTLBWhnBRTj6tpFGsdX2+j9Iw9J6F1HYalp+mX5uYLqMBWO5UV
+zbuyiSPPJIOMvGpFZJOub296hliv57xzZyXFzHqjXpWcqBnBPd1bAUoxIPnTcVdkqbXZ6h0j
+pnRulTXo6u0RtR0i8ktYtUTTpnsdU6fniZlklgVWEXjK4YSI42yKCuRkGtnqGrGz6ojvNN0S
+5uI7S9fwYNQi8S2uYo0O6Paw3MJI3VihyV345GCPCG+JdjfXsmtXsN1dXN8puI72NFglUyYz
+JKgLCQH6gynz5BrSDqvqDT7Oa6a00zUdJtjLDAJJZLmFLV4uFwCGhdHwySqQVIAOVGKl09Mu
+2tm/0Tp7UY9SPU2m9J29/bajpcWtaba2tyniQ2wlaEptLZRl2tiNuTtXFW/Tus6SILdYINPe
+W/d1uopt9rLM5VkaGWN/pQ5ZWxkhjgqa8NtupdQtGS51e+vNMvIDF4i3PC3W/GEkdB/GAMMM
+jIFSNR1OdLmdbqYS2F7GSJbq5it/l93K+IZOeHOMgduRiuaeO3o2hkrTPpax62vOkun20HRd
+Ggh0R2kj1TS9ct/nbK5KFdoaKXLwMuW+pCDyDntSrrOmXl5A9rey6EphmVNPm/320KyBWCpP
+nMsDY2kOdynB7rmqDo+6k62tTpehPdtc2VpLMmnNr8Opss7xhMwTxBLiRQVyUKOQAMGqbR7n
+WrPpxJNW6bLqgSNL2xkVxyzKwn24eKTcpwJo1LDzrOSmlsacLf2ba7sdG1Wyk6bj0mS6j0m3
+u9UtNImjC3EMcoC/Mw3CZE0aOq8Z3KF+oDNQbxbbXtC1XT+pdZ0m/wCnArTR2s8Q+e02aZod
+8kCON8aHYXV48oSpGATmsnrGtnWFED3slheRIkNtMIzHvUDBRwuChZCVJXIOBkVpDqX7ct7e
+x1fpq2mlRSlrc2rOlxHHyRGV7P27LweMDmlDIxyhow+q9O6X4j6ld2S6brWn6rcK95YyOHkF
+3GRBMJMclQp3Kww6ykMDgVSNolxKtlZstpey6ULTTY720Vbe6Wx8Qbba5gUeHcJES7Rybd6k
+kEkdruDUpL+9uLqSS4uYXj+ZZFG76YDt2mMgElVPHmMEYpgudIu7eSB431OGB0eLAWOeONjk
+eHIfxBT5HtuxVxzPqyJQWnRgut+i4Li5vZ7G5kuoYb+SOa1uHeOCYJJ9N1DJGM2t0CN29eDg
+1TdV9G9J9V9IpLeXd2dU0+1urrU5xARDdXyXL+DKscP0EyQmNXYAebYPNek3clz0fqtnq37Q
+Mmi3IZre+a1F1bzDPMVzb5H1K2Q8WQSDlTzV5qOiaDpUdre9KaWq2OpHwNT0bxS02lzSbjFN
+ayMP94spV3hEc+KjKUO7ANdWPK6cTCcWqZ866v0re6ZHaalPcJZ6jYJZ3Mup2H74aaJ9yxLM
+B+ONgFzxgZx7Vs9Vt9L1DpaazvbKztru5SG/kaAiW0bULclJAQRllcMm05BXz4q61S4ludVi
+1KwtNMvZrVH08yyRGJL+zRcG1uFwCsoCgqzDIYBh2rB9Q6ldpay67Y2OoWsMC75VDxzQyKGI
+PiYABOCNxwKtZE2ZyUmrYvwm1jp3SuoodF6p0qY2TXE1pNGl2YGkikYF4I5TkwscEq4OAxHl
+XpEOvvqcGsXWt6rdanqlpBFZ6ZeTWsMLvYWWY4IJVUASXEWUzMp3yYJO4V5Hol7c6pHcW0XT
+dtfxT273mElXxLeJW2ynk/vFGQePqA5Har1rW/1v5q8tbWCRLb5bxYjLHbsg/DHtR2DSMduS
+UB9aVVpD5cnbPToLrSL3p793pkMW4tb3yW7FHlkDBi7qPrzyHDDsVI9a1Vnrdlpdmmj6lf6h
+dWfTV62n3etW1mbi3lspgr27zOjYdjMv9xINpUHnOa8l0O40exsvmbrWJ9M6kN3KZbmS4leC
+S2ZQFiZFBRWVssJAQSG2kHFTxrV5bapcWAzFOscnjruI8dJAMkkfTIrcEZBwRxg1jz4vZq4u
+S0ar4ldG67r2mN1DYRW2o2mmXV3c3j6TaRwRxyXchcy+EBvkiYgFO/hYI4Bqm0TVdQvZtJ1i
++1KW7XpvHhq7tNfWUTKd8fP1yWzEAkHIRuRio1rfazPFDo3TljLHqwtZrq3d5CiXcUIBaNHO
+FEiLkgE4YZA54qshvNNeGZLyyFpcwkSW1xbErLEx4kjfBO4EHjnAIqfcqVoFC40zZdRrcdP2
+v7c6T1W4k/aVq63+lPdNJHf2TjL/AF95Np7A/WvBHArFnVrmya61wajbmbV9NFvI7wpJ4tsS
+BLG4I4GzALY3ZFaHprXpLnqHTNKub+1AvIxaBzEv0sBiN1P8D4/rXf8AZWj2+jXsuq6jZ2zv
+qM9lF84Xt/ldVB3pNBOAVSKVB9UUo2k5XIyDVxyy5XZnPHHjvs8Iv7i70/VrvXdJjj/Z9nem
+aaBZSoVfEBjHhghiu7+X8PBNaz/9pW3/APxRvf8A+uT1R6t07e6JPeRa5pM06PI8yzRMqiFn
+Q7Xyu4AAj8OcEccV5h+wbv8A/iMP6r/3rRTfaM9LTR0iSG0slsOpWlispDJ+zNUhP0wS8b43
+Q/UAT+JDgjhhkVyuLe90gCz8VnN1F4jiLlWQH6c+R55FXWtdadW67aNpuqyXeraVdbT8vIkV
+w6hTkFWIEgxkgHcTjis3PdWtrbLZWl86qrFPDulKPCoOQufQHNW0K9EK4s5IbdJFDxESiRW5
+DZHORW46O6508X8ep9R6I15MiTQTtabFkuFdSu8xv9DMAeQcZwCDms9PZyXuiSarFdJ4ti5k
+mi3Agw44Knsfcd6oLC/hsp3nTT2muQxMDOXUBu4bA7j/ADrOSHs9+6W/Yuo6fet0tBqGoR27
+fLPY3QjjlcldyRGMH+MBgApPavSNa0zT+muobIXV3a3FheWdlqunajDLI0M1ncLmNGdSDG6l
+XidSPpZPevIvh71lpelafZ9Y/wCzcekXFnfL88bEGaRSPqFyIZjtZWI27OACCARmvc+i9a6Q
+6wsriaDqia7e7guI47K00M2pjEkniGXIk2tFu3BwBlC1c+SFbN8ORydEGCfS7i3ENxeabDce
+NxZQXxS6mGGIkji2srKoGSWK5B4yabYzadDosNza9R6ffMg8Z2SJo5VjJ8wMiQADORg9xip7
+aA8MFp1NDqHw+t9R0+4mghgtdambU/lY0wIZoxF4JzkeGWO9snPas9b6PpnSmq2/VPR3Vupa
+ZJpd5bSmPWdOhW38bnfFGY5Gd4yp7smO+cVytHVyNjNrOo9O9QX/AE66W+oaFNFZajb/ADDE
+2s0FxkxvDtO9CrRsysD6Airl9ctbnStY6Vu7+e56Z1CdGuBEFFxCwnWSO8jPBE8Z+sEHLEYw
+c15r1Xqdvbpb6EZ1iSNpr7RpC26DwpHMht/pHmzsUOcLgjjNd5NQuYLjWLESHwfDtDaTNhAm
+xPEJx/Gd7MD5gYx2oboVckaPrbWb++626i+ZntNWktdYe7Gp29uU+eLRgNOiHmNZVKuU8n3V
+VaL1JY3nj6ZDf3ahuH2vtkj5+nJPlnzqC3V3TvUQjivNDmTWUy76np974DSjAxmOQbSQR+Ie
+vapmlvoevXUcWuap1lDqMLPbmCx0qzmurjP4SPozIcd8Ht5Uqvo05a2aD9qw6Vplvq2n3t/H
+dFXsjciSNvl7tVzsk2crvU5RuQ2DzxWZtutY7G2l0zUdHN/bSSFbi5imJkkVhyJYiMFlPIkT
+DHsQavYumdE6TNr1Te67fXWlTPHaX1j1FozWMdxG395G9zG2xJFOGQMAwYdwDVD1H8LNaS0X
+qzo43Wo6RdwrJE9tGLvw2OS8TvCSNyjackZAyD6m+MqtGfKL0znLqeh2FnaW8XTuhX6/NvKm
+qziUvcRAAeCY/E2RLg4bK7ifaqu61mykvDNpsNvaqrBhaJJ9UIB7Lyfp8qTSr9tAnE+raPZz
+QXMUtmbbURLbH5l0BQiQKSy8E8jt9quNM07XOptNhh6ZttDhtI83LWE0lusM7DIfbeINzlQc
+EOVyMEVBdJGi0frS3mhkfVpVjgLHe1zNgpGDwSx5JB863dvb2ZtTFDqSPLMVwu1QrDOVKPna
+T2YY8xXj+iW+qaZdSJd6RcaMkjqiKrxX0BQ8MbeRtyzKMfhJyD51sLBdQsbe1NhrljJYyqUt
+zc2QQKCTuR0iDeExOcrjjORWb0yqtFvrltfaTeTWWrQRNcSEXKPgDxSeBOn/ADA8j7g1S6bf
+xabJCt3c3E1q4DzhMZhIz9cLd1Ze+M9sitbok95DBDbwa5aaU03iq6NcRvDvA4EEUyg5b8JU
+Ec/eqi86W1GSeJra3jVL0sny9ynyhjcHOYlk5ccnKAkg+1NryhR+mcptdeWNND1K5t5vAZ5L
+aWNQI7kPgko3dWIAO0nGQRXJNXfTJ0s7zRF1SOL6Vmi3M+GOQ0kWdrEdsgj7Gud503rUV1D0
+5d6XbadKVCJFfoIYpEzgFT225/iByDWcjs9RQ3tlfm0/ZUwCPJDdLiBwSY2BPcFhtYHntjkU
+5Ra2yo09FxrOs3zrMskMsUGASBCYQAeAxUjvjgjtUOz1BIbaLWNO1ewhv9LkinaO2jYXCHcQ
+sjcmNsHGRtI55pmlah1DDZT6NearHLLYMZBbXVwzQ3VuQCQhcYwMgjBpttatqwu/9mtGS5m0
++x+ckQRuQ1huHjPG6f8AEjOCEI+oZI7UuHJaFyp7JXUnWlz1LFGL7Qum478bQNU0/TflLhlG
+cKwibw3J822hvesdqLWWoROHhBZ+UdEASRceY8881LvL/TtL8Ka0k1OS72/M2oZYvC8dWyI2
+X8WG8/vxWW1GWIajO2nxNapPIZ0t85RN5zhD3IBJGO4o29smlHSK3xV0u5ijjEdzp80pR7e4
+XLI2wk+Gw5XIBrMXk2nzT+JZyzbTlXWQclfuPQeddeopp7FW3H8Eyz9yNjLkYz+dY/V9bT6X
+WLYJcMQfImtUnJUZtpM0V6unxRRXWmXTS+HHtuEmb692SQUxwUxjg85BqAusgt9IjG8gghPq
+4BGM+nNY2bqKNCY2aVmB+oDAFShq25lItAvigYxQ8TJU0auHUFfdcBcSRdwTjcPX710i1INB
+OwUCQEMg7555qnjvjDBDPIbTG/a9ufx7cfiPl/8AVU60DXk5lW9gRguIlYhQ5bjH5Cl7dIOY
+DUFhn8cRLJC3dWJxk1senuq7/Qba3ure6SfTpwGkty5Jt2J8s8jnngmvOWvbWNzaSSB23bSU
+5H2q8sL3SDaizuYrthK+Q0DruAx2AYYoinF6FKpKmfQmidWaTr0KtFOBOoHI2vv9geDn2IrV
+W00qISk0MOT+AoVJFfOGmQ9PmeN9P1XqOGaIYUyxRBgfYgEGvW+lesLWSFdP1h79JIlAFzJY
+MVc+Wdmcfeu2DcuzzcsFF6PQofFdDgRuP4QCCT/WnSK6MH+UuEkI7x45qFYJaZ8eG1MrE/34
+wo/LIqckcG/cLi5eNT9SJN4Z+24D/KroxTJNuX2CO6u5lBPaVyigeefOpNtcW6YhjnDDPFtb
+KyRsfI7Vy7n3Y/pUNbiRci20q3c5wBzOy+5YtjNWVmNXUM2oGOFn7pH9LY99oGPtUUWpHSZJ
+RD+/tVgiU5aIBUB9to7Z8y3kDTLO3utViuDbWj3NvHzdTqRHbw/88zYUfbOfQU6a7hWeNbNE
+RY1O1pow4LH8TbD39i1c7q5ub8x3OqXU91Fa5aJJmATd5BYxhBzxwO1ItNlxHBpumwRNLEmq
+XA4t7aDdFYRsR3ZzhpCvmQAK5PqS3UDXt8RqHiSeFCix+FbF1/4UCfiKKfxsePvUX9lvOwjv
+bouzjEqpKQZU7lN3aGPPcj6iB5ZrpeTW8d1HMoa9lRBDAhiEVtbRr2Eajlh7nj796GC7O6xy
+SBJr+7P1/UzIoBmI4EcKdljXtnt966Ry+IvisiCPOyKPPB8s+p/Oo6MS73VxIxeRMFzyWA8h
+6CiJdxV5F2QxKXYBsEKO5H+WfepLRJC+FOqQ7ZL3IeIsv7uD0k2+e3uAeM4pmqQpeXniNcER
+wIsESklpJUXu59MsWYk8ktUSy1Ca7u54NOSSeUncyohLSsB9IOPwov8AXGa7CJLGF1knDurA
+SneGO/y3EcLj+XPFHaKXZwnle3he4EQEjtstoFHG8/hX7AZZjUZIWt4UunDvJcnw7WNVy0n1
+bTIfTc30r6nJ7CrG7g8OL5rUma1t402kLzcS7j+FEP4SxwAT+hqcY2sr44jiOtMkcRjg+qHT
+N4wkKsf7ycpwWP4AGIGSanjZSkVLWXhs9hOdrs6ifaNzeIeFjGO7EnGPWrvqK6srPqS80nTw
+99q1iyWxeF9ttpSRRhFhjx/eTqAS7n6ULEDJqJYXP7Cv1vrKOO8vLUu9tl/oa5xtRj/hUnOf
+PHvVTb28VlYi0W8naOQur3QAM+ozk5llHkFLEgeQ9zSviqHXJ2xltFc3Mvh2VxDAiIdssj7L
+e1Qd5XbvtGSfNmJwOTUyXUbee1t9J0eGaLSrUk2izLtmu5jw93P5FjzsTsi4HJyar5f3syWi
+Ww2q4EdsrbgX7Dcf4jXfUHC3g0PT5PHu7l47e5nXnfK+AIo/REGScd9tSnSLatosNLSOKynf
+xNsLQTFn8xGOGk9ySNo+1chdzQ6FLY2CKk9/MsKLnHhRqn1t9lXufVqnXtrG1rbWttPFFb3n
+0I8pKotrEC7zN/gCIMepb3qpRDf2D3jTraRXb+Faxzcy+ATkuEXljjnHbLe1U1Qk09s4x20m
+DgkeJIqRu38QA3SSH25UD1qRtM+q6VYrnDylcHvjBPPvUhDDbWeoalcJlbdoraGF3BZW27vr
+A4U+ZX7Zrnpl0Y9Ys9ZuAhSCZV3HsXkYA/kFJP6VPH7L5Ee8bxJbx1DbEn8PPqAOR+tT5roJ
+pE+xXKxW0XghVzuPjoMD1+piarrxfFe5iUnL3szRoT+GMOQCfuf8qvzPBZdH2HU7x7ZE04WW
+mJj+9vN7b58eaxDnJ4MhUeRpxW2TJ6SOeo23yuoS2pBxZyvGy5yWk4OPyB/U1DS6ksJjqDQp
+NiNoGjIyHjJAeP8ANeKtGubeVJbnBdLO1WVizbmeaTGMnzOcZPtVYse/w7WRAJIfCkkLcgeI
+xwePLin1sa3pne80y0uJIdP0x3eF0tLmBmOHLsGzG2O5GAPypdQt4vn7x1TZHYH5X6/IJEr7
+/wDqLn86kiRPn7KWyRwlnLEFDYzJtyAT/wAxY/au3WCINX1HTrQqYdQns5ODnbi2Xcp99w/o
+Kb3FsSfyo46LaQ3E8Gm3RmEcnjWt3sOGUSIcbT7YFRtG0t77TxDPct80O0q5AdeCgIrWdJRW
+1x1Aly1soiu5Z7mxTj95Jbpkgjz4FVOkaa+nXusxvMrNDNGkJAx9LoJEHthWx96hrSZadtov
+tK8V3mmm3IdTBik2gBY7kDKu2P4WwQD5Ee9LZaSz6clqib3fdGYWwVmUtuA5885watOmktIe
+obEzpH8rfq0lsFb+8mHMkZ98c49uKmwaSf2ldNPFujuZWZeMYUmra8gnuigsmuDCkdlN4Nzb
+TEBLofVDL2589ucZHoa0On6/Y63p0fTuuaYHinlOLeTGbO5XiVEPfB4ZD6EVw1q0ujO891pz
+3KkgfNRnDEDtuA7kDiuWu6PNLAqzSBLpo42E0bYJK8owPrjjPtg1aXgl0aCwQ23/ALMmvvmo
+QN1tLN/ebPIE+eOxHetDpszKqx3IIP4Qw7GvNrXrK88MaV1HapK3eC7UYkhlHGSB3BHcVren
+dfstVIsZWHjKMMhb6o28mH8yH18qSi4g7fZtkZDHsGUx9QYd8+9dPHIUNvZQOGcDIqqhnlRQ
+Jxll575DD1BqxjkfaHtnUhxwue/2NaJ2ZNUT7W62kAusvepQuMgoR25KY/0qiWdmDMkDOF/v
+YsfWvuB51MtnlRBNazGWBfxKw+qP2IPP51PIHFF6hLRB4z4sZ8l/Ev5GnxzIcIkxz22MNrfo
+ah2twsh3KAGxyVP+dSnDNn60IPIzwwrVGL+jrvHO0Nn0JoWXHAAHqPeuLOcBWck9xnyqOZQC
+ctuOe3eq5UTxssfmWU4O3b/WuF1BBdgNIZFK9tshUN9wveuSuG7ZU+ea7QHacNFuGecU1Ji4
+0ed9dx9Racskuj2c6xvhVmgd34xzvU/3fsy5z5153oPS1vexXL2Ng/hxXEkd1qczGNElB+sS
+SN+Ij15PPFfRNxbRSKd7FoyP7kqG/XzxWB6h6dfUr+3uL7Q9X1eWMt4T27r4NkhIJIjOBuOB
+kgZx51GTHezpwZq+JD0KysrKFG0y9kvjK37+VwVzt4AUNg4I7HzrUwojqqgupU7hu8jXG00h
+4zsS2uIywDE3CAbT9vL/ANakQui/VwuWI2+n61EYUE58mSFSMhssAxP1A/6V18RUiBRyQBx5
+j/0qDLcHdMhZQYWA4/iyKiXWpy2wFxC2wM2yWMDgg+dPolJsmvCHJa3bw5l5Hoc9xSRTo+I5
+RsYZJ7YBrjFfAAoHUucMF7ZHpST3EVwwdEL5HPlj70v2VsnGRAhdAnh5w2w8Kf8AtSE5Ks35
+MK4WkywuWKHw8YOwZ49x6VLyiwtJEUmgY8Mvb7H0ppWiboRGMpH1Z28qSKa4mBB27T5+9PCj
+sVyFXO32risqk/TyF9SeKKAeCqlnMKpuP1BeMn1p7SRMCpJA79vP1zTPpcGSP6WXuQe9cX3A
+fQVI9ACKBHK7vba1wJJpED+aruT8/SuBnRh9ClVP8Q4z9jTJbhkLMGBQnBD8Y/0qKrwmQm1n
+8Nj3CNlG/wCk/wClCYmkcbpb2MkpqELI3YSNtP64xVa+q63ZghntShOM/NKxH22jNW88bqu+
+6VGVuBtXOP8AQfnWE6o1yPR7pBZal01YeAhkuX1KYll54IRUIIx71fRMdui0vupLKdX8UCJg
+MmZGZdo7cAck/wCdVN5qD3FutzeajNBYxnKtMwt9zD+IhjzWWl6/1XVblLuPrrpC6szK23Gn
+ASvEq/ULdYQrEg8bnYgA881i/iJ8Ven9JWS7isTrWrwoDHZW8yEIrfh8Ugssee/1YPHaspS8
+m8IeD0XVuspNIvnlsNQspvnII9kyS5jI5H8OA2e3HOaxF91ML22u4NQsLHx4VW6JtbqSIlsH
+aAu0hicFRnnyrwqHr59ZurvqJvmobm6cfOQT4deQD4Y2/TtAUcpjtU+91+7/AGhd36KyW13A
+kY3HgEMGAVux/wDWvPyZZtnpY8MYq32bu/6iWbV01m3Rbmwlt447yGGTwp0d2ZGeNG/EAcBj
+Wi6rvV6r6ZjkFzdab1J05bPZarLGPo1TT1CiGdo875HAbZKBnKBX/hOPBbrWYYr2wtr27MED
+Bo5ZyGKxqW57c7Rkk4961/SPWN301dTW2qwSahb2SzW3+6SBvm7dhgorHvGyZYMDuGRz5UYs
+r6ZWXEpR5fRR9X2vX56Ohu9D6UTUdMtZ7I2OqacY7toP3j7raVclkWRmYKXUEMAB3ryf/ZPq
+6PVbixPTXy146TIthPILW6G9SUzHJgHdkgepzXsEnWXRWi/Ec2+r6o46W1zTG6M6lu71fBNx
+bSSEQ3mcHZLGVtH3YGGhZj3Ofkr4laLd9DfEHX+jOo7SVtV0PUXsJ2lY7naM7TIpzg7uHDLw
+dwI4NepCMVG0ebKc26Zv+gOrupOk4Zen9b+HfReu6TbXkqXOn9T2StcQyJxIkU0UkVypX+RZ
+CoJzitRrXXnTuu20VzpfRmi6NDc6WLT5aDUG1F7ARkqvy/i7nU7CoxIxK84PNeY6hqcmq6Za
+nqTTrbqCOGSOaz1SMmK9tmUY8ObH4x2/EDnHepOiafcXt3d6bp1vc3Fhc3QvbKS2VY3uBjEi
+RZ4WTuAh4JAHmKJxTqgi5J7Nx0sfh7Y681/pHXvUuhXUX7yG91HRWtTEFwCztDI52K20ng8e
+Rr1ezGvXF0Oq7XXOjZ9KtyEvNR07UJ9RivIwNzSLtUMjByGZTGMA14Eot76SGf5i3sLfVEWw
+8eAFY2JkUCQq393l1USp/AcntWj+H+mX2i6zqOlPdK1tZtcSN4DsWtrxFZMArwFYjbkcHiue
+SN4s9+ulvrg6f1p0n1DZWrag8s1mY2aNhJA2yRQCAUAYZVvfB71ptJ1qfrfTEtbi0stM1KFm
+LGOTwbed+NjwqPqilPJfGVzhh3xWe6Mv73qD4L3fUl6zz3vTN1PNEj4AmgjSE3cRbuZFSUyq
+3dvCcHgVC0B7K3caneSRyx21zE0EIXh43Iwc/wAJG5GAxyGrkyx49HRB8ts0U+jQ6gNYtba2
+S81HV9PL6PexkwXUmpW0qnwHVfpd5E3rvwCwHrXHpu/02bToL6JpoJ7Ux2l/4kDRSRszkLcR
+4G1vrBQnIPfIrZwz2jaV0ja2MFtM2n67NqdtOkbeK9vOyFlZuxKzIwKjlO44NZzWtOktupOo
+oLpGtmuLwQ6vEoEkZuAWUyITyu/YCcY5XPnSbfDfYl+bro7v09Jdx2LSalBaXMMCSS2858In
+ErqTG4BDMwAIB8wPWs7qvStxNrc2mvb2f7QsbkPbJMMC5kP1IC44aKVSrA91Y4PFbmHWNE1i
+wa8imuLm98a3Qyou0o8QyvD8EMDuz584qLq73WqpfTadFunhtjcCFbcSyyohAbagO5iASSFy
+QoLY4rHnXRvxvTMLbXEtlBbamFupXi1N7edJoylxtkQeJHIP5wxILA4JUEVt7ktqcXy95d48
+G6kKORnbOnZwwH4XyM5yCRXfXNF0e51mf5LVIbUrK0jK0UjIjJEMEMoO4cAlhnuT3Fcelol0
+PXdYivNVsLmP5CeRFimaQQoiZWUMV2mMlhhhx9PNTtMptcSJfhLIRy6e22aCJrK7ihynihm3
+EOpyNyksAR3BFZy8ja11HTdDSaCewvl+kTW4WdFU8DxFxuG7gE5I5Hatld6VvSZ7a7t57jd8
+wrSTrGLhWOScnjawOVI7fas/qejQprGhyTSDU7Nr9FjkgmVGhlddpDsDhWXP/K2B61qm5PZj
+UY9GNl8VoY9YjEcdpdeGq7h/czKWjAby2sRgH3weauLi7tINQ6YluL2BpUtrN03Tu2+CWZkk
+tO2d8Eyqy8/SrkcjFY7X9Q1/p67u7nS5riyubNpdPv7Ip4sUiF8EOj9o24J81yCDxmoV7rgu
+47ZtMjijNy6zxqr5eCWBg7RkHsWA4PmKqCq2VJ3R16x1J4OqeoPCmnLXVzcWVw6vhnjBKg5H
+BBXjB7c1lnn1ATW1poXUstpq2m2txcRxWaNFf3UcuDlnDfvtuCF2gsAxXBFc+qdTM2uXdzFd
+rPDct8yrp2cSfV9+DkEHkEGsl1M8GoQrcSgQTxgJFeRuyzhhgx7T5bCOCMHmuyF1TOWdeCxf
+RdZ1fp2Wex066vIrcJ4nhqGKbmKjY48ywK7eCCO1Qul+oJ9ODwaxoNx1t0pNPD8zpVy7QSFE
+UnwxKhDwzLlijj6dwAIIJFQuk+onn0/V7S+1RLK5vb3TpXYOI3mlhdv3qY5yCdxI8zk1sJra
+XqHUru5s7lk162Xw0iZQltq0eTunJztjnA5KfgfGRg1cZcZUJq42ZLrW46i0PT9KuTpEP7E1
+2336PqrWzeFqsUbcksMILqLIimVcYYZwMg1U6TqF/PepcwTXFjbahYamIbp8OrLDDumhyc8E
+fSRjPY+9ekdI670ta6Nrvwm6hgez0fqUyPPoGqtILXT9ajT/AHTWLScAvaSAkoxGUkQlHBG0
+jEJp/UvSlrL0ncINR02R7iO0ltoESbx54/DZjkblOA3HYg1vys5qqzHWOoW8Ot2OjzRNAZI2
+khmibEyh0yhBB55Hat1oPxJbpG4sNA1FZkvbcM0sstyUVWfkxOqjJJ9Djk4qp0eGK6MOp2qa
+Xe6dol0tvdtd2pa5tzGwVXJXDIScMCDg4ORWYkCde6zffsvUPmNWglleeN1LfMKGz40Td/uD
+zjmjsG/B7to3Vuh65Y3NtfXOnukpMMllYl1IRTn95ERhHHkyMRkZwK72vw80nW+mreyg13Td
+UvrCdnebVFlhkFu7ZiSV1yjqF4VxtYHg5FeR/DOw1TUn1O1tdV02PWPrjNleyiIvIhzs3HAV
+mP0q2Qckdwa9J6B16OeC8BlvYbqyIGp2c1ruvNNAcEyqFwXjB5ZeRgnyrmm5Rezqx01Zz12x
+0nSph0n1LouqaJBAm/SS4We6SXgqyTDC3MO7jcCsijGc4q+0f4kdS6eslnra23U9rhYmF4zi
+WNwDtKXKfWrY37dxbgnirG56v0z5T/Z3WpdMutP1gj5O+jtfmbcyA5WSNX/u3IyjeGckeRrn
+H0do0ei6ruj0bT4rCXxZbNdTZbpLmG2Z4ZPlGAkMTbioYHCs2GA4qW21oql5NLpetaNe6Y2r
+9PXltdb2WG80TqGx+dFtEjh47iG5UqY23Ao/YlT6E1oIL3pfUtS1K7uejNU6eK2S3N3Ho0z3
+tjY7WAkYW0zF448lXEkbkjJAyO3jrGfRbew1SxjuHguxJI7W0Z3wzKAWXb3I7+2K2ugdW3Me
+lrqujXUkM2xEymMuhO76P8Pt28iKwUqey5x+jrrt7qOl6181aapaXAjuWRL2xZvDMg5yAwBU
+suGww8z3rn8yt7PcG4xukicsyrjcDyeB9jVXrGsG4vRcXjQQzzp4bXUXEN2eWjEydkdckBh6
+kGpWmXC2l5pzzzoreNG/hAMTknlMjjH/AHoW3oHpESzkvdAintrOdLmwvgHa1mj3wXEZ/C5z
+/GOBkYIwKn6Ve6pDp4axs7bUrNJ4HvNN1B2Fpe+E+75abaQwJJyjAggkEdsVW63MmnzbUgkW
+2kmfaAMphifpB7AjnFU0c7g+El3sWdAUlQn96ueN3rg8EHkUrcJaKpSRqNR1Szu/GxrjQPpa
+rEi6gu5nglYkW0nH7xoWOPFOCRggnFdobXp/XfClbQYbTUDA8E9xZqz2koKFXW4hU7kLqM+I
+oKnHIzWY1nVlD213qmh2uoyW8TxXBSRkvEiA/GoztcDHZvyqPo/UUSyQ3mn6jOHLbreZVCOv
+/MvkPUHIrWWS6kZQhVxZRP0TN0tNDY6V0/Y6nb2pDwYYuwiJJJiYYZWAJyvIIrlpd5cJdXXT
+evw6DrFrosrEaW194OrfKS4bdbLIB4qoG3oEbepz9JBxXp0fVZstP1CFoYbzT9QCm9sf+D4i
+5KuEHKNnOHQhl8j5V5919oOhatLpurW0YleCMxxrfWYvXVV+oI0yFWdByA3fBwcEVtjz83TM
+Z4ODtbRIubXTVmuLnT9VmmthGiW12sZikB8t8bDG/HDjkeYq61y4hstJ6Nv9sUtzPps82opB
+dLKU/flI2AwDGpUZMeTjOcjOKp9N1bSNV0ZLO2t7K2kMJdrZjKywSjIwjMctGwwRnlTkc966
+QahcGytFFvboto0sMbwxgx5YZ4b+cHn38xWc57o1hFtWi1mub+4+Su+mesby3trRWklW6kKQ
+ibB3/Rgg7gSCRjPequZLUy3N7ahVUfvSUcbWHmoP+VKs7m4js1hhjN/FndFlF8VQR+HkfVyf
+uT5VDsr680ea2lt5bWRIGDpDPCpVv8JU8EeRwazc0VxIzBYrpNpDJMhdHB+mVD2ZfQjsfMEV
+uby/W/6btOqtD1O5TUFn/ZXVWlzLvidGQiG7hPYo6gKynlXXIyDxm7vUNPvG3QQJbLduJI42
+GBazEYaJieNh7huPI1EuoNVgt/nUtn8Lm2uBHKMcNuAcA5Ug4YZHuKFk4vQnBSWx+m38nTep
+r+yb2S2nu4wsm9N8UkbZDQup4ZGU4wfyqo/2D6T/APxc0/8A/Jv/APpU64nvbrUPGjmdHVlW
+aNwMuoxj8/tV/wDtGH0b/wApreORUcuTE7PnHW+nDpNxa6b1NqS6IsjzBp54ZXWPaFIZRGCW
+VgTtx598VkorzSLTULmKEtfxrJm0uZlKBiDwWQk/Sf5Sa9y09vh1qN5HbX/Wtx0/qunTxPaw
+zWMmo2c0hbLBh38Pj6lJGM8Z7Vx6h+BHT+q9TLd2WrWEekSufmJ9OtLiEEn8LRW8m5wX8hgo
+COSK7bMJxafxZieltS1vQZVv7FLS3sdZgkiUoqmJbhSNyHOfDIODg/wnI4qD1PrXWGt9RtZd
+W3V+l/DGscQlf6UjAygTGBsx+EjivS1+DUek2ktzp/UCS6U8oWaO5srn5mFtuIxNGqbN/oyk
+486qNa6b6h0qTSuqJL2G+25hsbmwuVklgMDYEcsTfVGBz9DDkE44rOTrstRdaMZHres9Pasm
+o6RO062qZ2XaGRN4HIeM8MN3keK9wh6zF5crren61FpFtIlpfQyxwt8vEJoh44RFGYjHMpGF
+4bIrFdUdKXms6VF8RtA002d4yrFrGjy/Tvfyng5P0PzgdwQR6Vw0Owtr7pWSGKS8jl0nU1WS
+GLaxhsrxADIwJztjnQE4zw1ZT2XBOL6PoKLqmHXLWS7n1RNXv3YtcTiyEFtLEANjBTjDkZOR
+2xzXaPWOltHQXfVHS1vqtnLaNBHcSRSXMADyKVIEW2aF1IYFlJ7njFeYdJS69oWix3x0K7vv
+k5pYrs7ZFtpLbcB4owMvxuII8q1avcW1xrWk9M9X2E0FvMnyC2d8kAnQr4iMQD9DMDtIzjIB
+71zpNHVdonahdfD7VLKO2tOvptMXTJm+VWPT21SGNJWO6Nd+wmPsOeVIqxtenui2vY/2Z1HL
+rV4fBEbWTrbTghv3irbTkBmKZ25baD5mstp3QvWGrxXHUWpaNbyAM3iQEw6hfySY+mURqwXw
+gcBud58lNJY2Xxd02W+02x+btllt1gW4ubW2s2hic/UISEaSIMMhuVbGM1SSmJ2tI9N6w+A2
+srpMXUGgTSXGgXkiC0PUPT3y96hmYrFGZbaZwFOGBlA2ZHOMisfcLrMFut11Hp17aXVmWjS8
+6fuGcXkqjakUsp+mFjgAu3YDyNaAad8WOhtMXSujtAubDpvVNLUN07rWoi4v3UPkTQEYSZGI
+yuNpAOOa2EXX/UfUsUev9bfB+ax2xG2+W0zV9MWR8cKflZ28Vo2Od53E8sBim4wshPItNM8l
+6Z6912w1OTSdW6v1HRLCdmJe/E10wbzXwORcDIxuwVPrg1sOldd6aS7v7mxt4UTUEZ317TtX
+W1h1ksCEe50mdRGCWZlK27q6ELwcVR6zdydd6VCuuaRfCKPVRK+oppdtFDYMp+q3iZDuWEAZ
+TBBUAghu9V2p6bpVnrd6dG07U5LFbhZLN2heUQ4J2stwMgjsc4GDWLfF6NuPJbPRdJe7X525
+uJ9bl06yi8YNptxaSMzKcBSjksXC5+oYPHao/wCw+ldWtLrWNWOrQ2uoQ+DHdWWqLH4xI3KX
+so+TIgyxkQqCRjntWHdejmibUL3UuoZtRvJlmjutNv4o41I/GWVkJD7xkMf0NamHrHobqywl
+sL2103TNatY2giuCfC1PYx3i7hZMLLGCCJoHGFDbk4zibt0h00rZM0TTDpsV1o/Tep6X1Da2
+0Zd7bVEHzrx8ElrJlRsgDduiOSCec1Ik1CyurdbjQtStrW309ZLdtK8CZo3JO6NIpHIeJBzy
+WfuQfKqvU7meyttNu7LquXqKQTk2Vtp5E99YzR//AAWTxR9LFlMZZCp5qHHcan1DcQvaNdu1
+zKY4J422meRW2+GU43SbuwAzkEVDjZcX9suNc6bv9TsY7nVOltY0KV5EufmSZXtriPbhJlf6
+gkkeQ3GA6/SfWoej3HUF3HeaEuteM8crK8MEkgiu7iMZDmFgWXdncDxzkHtWdtZvGvJLWCe5
+sWjnaC5kt5mRQ4yGSWI4AJPByBzVlqGqaQGjttRstRutQtowk+ozXCrOikgbUUfUig4yxJOC
+ewpoGnZp7XrfqLQZYdH17X4YtMg8X5jT7y3e4mWQ4ZRAVdWhLAYJ3cYBxUPTde6B1KbxdTgv
+PDnI+W1G1umgl355guMErIueRvBBHnWY1S86fmgk0PrbQbyHSZJjbvefM+IbS4U5UNJjKk8F
+WOV75qnuP2DpGoPp2qGeBNqK62+kvMR/i3q+2QD8W4AHvxRdgkqPdDp/Qus6mdM6J6ka1uZg
+0yi7i8KGPA/eMiSHABwT4a4wckHsKz+t9O3NvJAdX1OW0hEyvZXiQSXUOVztmSe3O9ELD+7c
+MBkg15nLrGlafb2twZfDms2X5e8EUiRz2rE5XkbtuMkZ5HYipug61a2MOo6bomsTaa5ZrmzN
+t4r2twT+JXQL9RIHYbT5jPai3Lolx49l71VaaHDcXVvFNNcaZclJClra/TBKVy+0vh1Af6lI
+45x5V5v1BYSNp0F1CYrqSwHgPdK+0Tx7sqTGeRMA2T/MBnyr12e10vU7g3/TqXZ0i6bNvE7B
+5o12jchYgbju3dwDjGRWE1zTZbS+abSem7aBJAtrMst3Jc5KNlVeJvpjbB525HpTjXLYn1aP
+DupbuYWUsIdr+CVTh+VZWB+5yawxZ5oUEsiwx7ztlnBw2B+HjmvYfiMdc0+1mtre48R4kV2t
+SieB4WefDdUBO3PY8jzzXl3U0OladHFKnV1hq006AyR2VtcBbc/yM0qqCf8AlBHvXTGJz5H5
+Kx5NFtwkkGkF3ZAJTe4mjdvNlUbWUfnUt9c6fs1VtP6aiudpDM1rdOpH2DliKy9/fYhVw3di
+N3c48qqmntgxLrtcHIZcg10xVo5nKtm+XqPpCVpJJNK1Qsy5ES3UaEH7MnNOTqDo+SJUK9QW
+qHPI+XkKt5dyOKwsmrW0saLJC7yqeHcgginrqNtJbufmTDMpyIzECjD2Ycg/epeK10NZUvJu
+7STpcSBX6hvo3c5BudPBUe+6NjVxDHY3d0LfSdTsLkt+BUuBExOPIS4/TNeSnUpW+l5iw9/K
+pEdy0oAwN2N3/MBUvH+hrJ+z2L5aSwXOsL1HYBcAf7ooRh6h1bBrW9MdUaTZSwxW/WWuNGD9
+SXGmRFdv/M0wz+leH6L1rrmm27QafqtxDG3aJm3xnHkVORWu6f690KeMQdUdD6JdP2Ey2wV2
+z5lSwU/liqiqMp72z6T0K508vmbXI0a4OY3mt7eBWH3EjqD9sVsnt1swsh1PTkB4zJco5Prh
+Rx/SvAenx0tJcC56X0LRVN0cG3+QLbGx/IXwM+xNadJbq0hf5a0MDs6h0tLJ48Ac555wKsw4
+ts9ltZtOSP5q66laWMHHh2UalffLYG38gTU46pCcStI4i7qxYnI+3c151oJu20+DUWmhiR1y
+Dflgc884Hf8AStLbX8skCyRXlrIxGxnQHap9B6VL6JqnRfJdwgtJsaBSP4xln+wHYfen295b
+vdfMXGXSEGTB82/hA/OqSMylN7gTervyBn0FSoBqF6XtdHtjPIB4lxcSjZFboO2WP0r/AJ1B
+qi5ivJyoVXjiabJLONxA9cennUqzt57xmaJXEX8dzcPtLqO7Y/hXPYdzVBb3ekadKUm1Vb+d
+jl1tzkyEdlLHgKD6VYJqFzfRy3l4kcduhEaRpwJWPnz3Ax3OBmpdFfwXD+BFCs0cyNCTsaZ2
+Kg+y+bfYdq5mWC+ghsLLRH1Ga6kEi+PcNFD9PAaQJyUXkgFgPOqqWO81md44dsdlC/gPezEr
+E7rjxNmRuZQSF+kckGpVzcvd+J0/oZlFtZxiC7uDwzsf+Hx5+wzxx60hk0aj88BpNjdLFagl
+JWsLcW8Tj+LYo5J7jexPqKQX0ULmLTYY4EtV37gAwgXyIJ4Dn+bkk9qjwIHim07RzFHZ22I7
+y9YcM/fw1b+VR+IDknAquLJeQRW8Bmj09pn8HahaW5YcSSlR3xnavkC2B2NDsqJaWWpC3f8A
+bbp8xqExb5FZCWMTdmuDnuR2B75PFd1hNhcx2Wo3UjXkwd0s4G/fkOBvklYcRFhhQOXwTjGT
+XOFV0ubx7tVhvGISGyiYSTR8fREcfxAYJHr3rnE0ukWM93ZRbL/UXMMcobdtQHDMD5tuJGfU
+GjoZLjtEu5XedUSGHMfgxZVBxyoPrjOT3qJNcyswlsYVN7cr4cISPi3i7KkYPC4HJanRGaJY
+bGBjttbaUhc9yeC7fmTyajafeCWC6vYpPAso5Pl4bhv+IFGXlA/kGCB6mk0WnR2t5FtrprTT
+YN01pEjTTLl5JJn4SME/h3EngeQJJqZ0nFp/Txu9d1NROujWk93csAW33Mn7mCFfUvI5GfQM
+ah6Tdmx6bveoFR4UcLMhP4hPNmO2Q+rkB5D6ACpBuo9J+Gs5vkZo1mt7+UBubifnwQfUIhJ+
+75oiq2xSd6RykmNzpt0124FuzpDqFwBjxSuD8rB5BQQAQvAUc0yaa6tpGuZE8O9eIYU/it4z
+2A9DjGBUi1L2Ghadr+uWZutQuBv0zS/D2wqHP7ttvbw1AJA7sVJJxjPSztZraSPVr9VvtSvW
+L6fZucfMSE83U2fwQKeeeWxgDHNDVjTSGjTLTTNGkbVkaS3s73xDaLIVe8uniBjRj/Lk7nPf
+Ax50mk2moatJd2SeC1zHbSSs8hEUPzW5XdyeyRxqDz6DA5NRb+4V5QFu/nFtHeZrp1OJZ2/H
+IB5DyX2FSLKOOKexgupD4d5FHLcJkjxYQxIB9QW5x24qdWUrSOlxa2N88/yl68Gnw3HhXV+8
+eyWRVAeecKfwk7kSNPcZ5zRqGpvqqgvbpbwBUis7Vfw2tupPhRD1PLMzd2ZiTUG/uJLuzgsX
+IFtZ3Es0jg8zzsxZmJ8wCQB9q66VceLdWk8q7mUyvt8siMgfpkVLfgpLVssVQp01q8YXM7TR
+OMntEv0jH2JzSSvtu7gREMl1aQBHP/wwp/zBFQ4bsySGxjI3XumXjJ5k4xsY+nKmudpdLPDp
++z8MUU0a8/i2tkn88mhvoajuy0sZGkvYo/EMYMgkcjPCqCxPFNkvXu511EZDvKJxznAGQP6U
+mkTG21Fp2+vwfxeycE/0qOYpdF1u90S6+t7W7eMnyKbsqR/0kUuToaSs3lmkVl0TpPUAYJNY
+XUssLgc5AO8fYjNJ1RpM1j1Nc6hAx8G9s7W8cgjb4vhqwUY8tlVst47aBqnT6j91ZXbLbuvd
+kJBcH7rnmrjWkwmpXUE4BebTkGD+CGG28J8/qtaSpomFqRLtDbLZyaHqjG0SS4jutMvou8bf
+iWTI7FW4J8wSK2jJJe3KC4hW3nnGSFOUEvmAfMHuPvWP0oRyW9vFNCJLBrdYChHKSK34h6YB
+/Stzp8cigWdyC6OnhTRg8qy/3cyn88HHlinDaoc9Oy50u2BsWgeIgjnHr61F1fpiLULZYoIy
+Cv1RlTgqfMD2PpVhponkTw5lIlhcL35ZP5vuKu7UBSSyqMHA471qlWjFypnhWp6NJb302nat
+aSTGJfGSS34lEf8AOF/i2+Yqt8CS2mS5s7gNNbkPBcRk/cH7HzBr2vrHo4a3El7p9x8tf2rm
+a1mA/C2OQfY9iK8avbgfOXH+6/KXUT7bm37Kr9mA+/cVaNYy5I19l1HiW3vJo1NpqKEqRn9x
+cKcSRn2JwR6ZrVWd/bXUULupiWc7QMjKSDupI49wfOvK7K6WWzuLS2lAMn76MMeBIo5H5jj9
+K66d1VNAngzZjWYjcPRuPqX3rOaa2g43o9UmvfAvjFfB1bAKzRnbvXyYejDzFXVo16km+cre
+QFfpmVcSLn1I7g+9ZnSNVsdds0s7yRVkTmKb0/8ASr7ToCxFqjm3u7TLBCeCvoR5qe49KUdv
+RnPS2WMkLHE1nnxYvqMY43D2qRHfRvCPmYRg9n7c/fyqCkh+a+RmDQyyDxLOVWwHPcxg+o9D
+3qRuZt8gCKzf3kZXhj5kjyNadGXfZI8UJ/FnjIPfj7jvTfxfSdwDeY75qEkSxsVjkcIf4d34
+TU2Bm/ASSf8AEKFsXR0j2I2Npz33HmpMbKSMHv6ZzUR2kBGFOPMVOtFZvqRSwA58sflVIlk2
+OGN4wJDnHbNZzUNBjnvWki1K4BL+J4ckhHOe2R5e1aeF9q4QAHHmO1V99LydoXcOWXJXd9z/
+AKVrJWjOLaeiuvVaysMST/Sv1yPuJZvPAHfFZ6HXJLy8ZMOqyAhd3Plxx9679R6rcWcM91ca
+TfS2sYVZGjUFRkeQySSPyrE3HVUVw0xtpzp1ytxG8NvfJ4UjoR6c/ScEA8dqwk0jpxxbRor7
+Vdq2TZVzNOTNt4xBgDcPs2ePao13rNlPMs8c25HDKxPClc+nrj/Kszruo3IktJfko4rM8woY
++V/8SBm8znLAg85B8qTT4L+4l+VitbhURXBW4XduHcMjDlvseawlO9I6I49WbGyl+YUwLCSk
+J+l93cf61b2trJkXSSbg+MxlRlT5nP8AED6VUaRBClrELdmikCKUVjgH2Pnn71cWkzqwbw3R
+27gkFTWkV9mU/wBEiWKKOM3AV0A4cR/UUPrj0pIFYky+JGFkPEisVB9mBqSqJy6qUfgNtAx/
+9VMWIpu8MLtHB2ncGHuKujO/B1IdcEjbgcP3qPcxtGBKqHnk7fX7VJjXahECHa3O3uo9a5ys
+rRMqEKSMbc4HP/086fgm9kXczbZYguT3B+k/9q4yyMVLKD2JBBzilhcoTbySBXz+CQ7d368Z
+pt21upZmc28wG4hlwGHr6EfapSspsrr65WQBJkWQduQcn/y8j+oqjlvrlQ837yONTgQ7V498
+r3/MZp2qXkMblri3F3GufEtSFeRB5yxYId0x3CksPeqGUWsNzJZC6uo4cBhIxNzGm4ZAPIkX
+PkCTnyqXdlKki6lstW1CBrrTUlkXGD8tKFcED+Vu59vOvPrm81BrlU1bWLCFDuaK7e1OxFB5
+LpGcq3B4bvjtVbrV3renaoRb6BJrkRUyrL07c+KYdp48WCYCaJh3wu/ntWW1vqfpvqe5c9QW
+eqPNDMk1zeXmyWMvjiOYN4cgPohDNk5FEpqK2PFByl8eiz6j6n6wGhPqWk3HSPVVlbOlvFNc
+WYW4nldjkFd6yW8OcAbhuYjgYNeVan17JAManoeg2d8yyQT2em6OniFQOGleZlQA9gRuJ8zx
+UDqX4i6jolwt893qentF+6t3TUjI7DJIUouWQAcDecgYryDqfWW6nt72/vLBpNMEaRS+KPEU
+sXGMZ57nnHnzXHN8z0McOJq9X6tn1p5JntH054xHbQWyvDsVBuOR4OUAxxiokur3M/T9vpay
+qjabdMkMkjAZgYM+WJ9GLYPoQKwkd1ZdO6NN8jb/ACoSRnSFyDtJ4APrxk4qLa9XrrlpNa6p
+bW4COHt9oxh0RiN3rnGOahQbRo8m9GivNet7rUZiHD/ugiMpO3dt8vXJrrZdRzafPZTWjl4Z
+JY45odxyEIO5Qfy48hxXm93qsy3ckkA2SbvHypwFUjIUfapem67bQQRTanqJt7USAF/CMhTI
+zu2ggtjvgHk08cN7HPIq0aH4gdQ2tzZXsRjW5nljeKYSJ/ebwRhE5+orznsDkgVTdZ/GzqXq
+vRLfqLXtL0bqOG3+S0a5g1S1EkwS3tVjt7lZlxJFKY4wjMDhyikisHqvVOn4xa3d006TF45p
+VAzycMcHgkc48u1U2jo+q3tzo37ShK6lHiIq4G2ZclMg+p4/Ou/HcE0edlak7NXH1LY6raT6
+lo9nN4gcO9hOy7lX+NEkUDfjuNwB9uKsOnp+n7r5rp5rq9Okaoi3Bkjfw57C5RgyAkdyOeR3
+BBxkVh9BF5Zah4kUIspEPhyxOcqXHDcHse9ezfDbra+jOt9N6ha9I280tggsruTQLeeV7lJU
+VIpTIDgFS31IAx9ac5fQ477JSSdK3Qu7HXtbJtNRkitbgQaXM53FCq3ahctHIo2mQ8iXk4BF
+evaV0x+yenZxqUcdtqNhZQT6y/7Oley1WOPbFHK7L9TMUZWLJ2yGK5BqVZ3V7bXlmvUt1qEN
+lNYXsuY7ZUhurcJhLqB7dFZ47dt4lQEuq7WAOCK76V0t1d0rcWmjTddS3Ctbm60m1ubh7mzl
+tnP7pVdsi8gnUNgj608wDkDmlNo6VGLpGm+G/R/Q3TvV97+w5NYvv2Vqdh1Pp+lalLEYrmKW
+OS3ks4Zm/dahDLu8NQNrhsqw3EVM6q6ZT4aagmm+DP8AsPUoJIJbiVPFg+VuSZdNYcBg0SFo
+T5qUAODis7p/7JRzZXPTOpDp7qWBrO+0nSy0l3o94rAvJCvlOhWOVewcBD3Ga9Djn1DVejrn
+9qww6hqm0JqGu2275a+KFV+ZSN/wGeJlNwhAMcwLcqcjOdShYK1Og6VGi6n8ULv4fXWqx2A1
+jTc6BCwxbQ6rJbBlD8jKtKuznGS/qKzt+JxolhOl7JqOo6wrLqhk+ma2mtJsTKSe7hHPJ7hT
+613686dn0KJdXt2V9X0g+JFOIx8xsjZZDtX+J4ThyOcqNwzVJ1B1Q2raLF1VDoVg02t3Fss0
+0Uvibdbgldb+1kBw0YngKTIh/myKx5rjs04Ny0afoy3adei7aDwxHrNrqd3bgqMTfIzAeCD/
+AOJ4MikKfKjRrpdT0ldQkWaLUdPv5oMwzmKa2nRykcoYcjBG8eTDep71jtE1eLp6z0tWkltL
+K06k03XLCGXcZYopE8CaNSe7FBHwSMr2zzTI9Wl0DqDqS1urYobHUbtJG/CQTICp57gBgf1r
+GajFa7N48m2pHpF5Nb6LczNNffPCCRJri5t7d0JkkLK5RW5Kkhj257Ua1pFvoOnP1Dp12vj6
+Dd7L9YJWMWr9P3sQUXVqpOAYHzujXvnJHAqg6+/aWp9MaZqd3qdxJq3yLWce9tyNHHcCWFht
+/k3OvrjFXGhxz6p0db6fqscu/T4Vv7CZlwoikuiNiH+OPO4EeRxUrXgH42cerWvTps/yKRga
+ZJJa+MICwjtcARSrEfqcbdrYXsr5qDfWGm6pnZBaqda09bC0ltJNsS6hCFKyOGH1RuFbk/hz
+VXqepajp3Uhs4rgpDcKphQglI0UlUI9MYKkjyxVnaXJbVtK/Z3iQ22pvLNaXc5BtY4zjaPpG
+Qd25eR2IrbH8jPIq0YPruPTuptJ1rquyiX5nRtQWz1GzuXZZ57WVxFDqdq4P1BJP3UsLbto2
+ODg4rC30VuLOFtLj8K5NuHmzMFjlkh48VUx9DFW2k5OTg8VtfiB+yk6l6g8fp65uNP1f5eXT
+tUu5GiuVSRsT28qRt4TlSP3bYBIUd+a8x6w1ZtIs5dHuItkUUpYHsyh1CyZ8sEAEDt2rppNo
+wUnTRn5LuSW8aJ4gkk84RDn8bMfP9e9ZbXdSubnUYbGaYyCFDFJ/8LaSAM1Ls7q6g1mPT7G7
+iuLqJ0mtnP8AdyqDnbz2OPXzqt6og/ZnU+pwWN086PPLEkrxeGZIy2clfIgnH5V1qJzuf2Y+
+9vXXW41hJVBKsYyc4Ge9er9L9USW+k+LfW105tmV454ZMvBtbh1X+LzBB7gmvNdT0C8awi6m
+jtLl9O+dWyuJ0G4RTnkbsfhz5ZwD5Vqre8n0e8ZLEtFLYRRyRPkHxFPP1D79xSyx6YYp1aNz
+13DYa21newrHdrLaJJAeYjJGB9UltMv0svbdE4ypzVp0rqc+s9OXPT3UUFtdPpFnLqFndTN4
+d9BJCpaNAw/vBngAHsRWciga40aSXSgIre0kGqWcGchInAMqqP5d2cj3q76UgJtbiztNIe+u
+Lu4juCIl3ZhKncoH8wP9OKwc29o1UV5OOkya50RqHUkujyWjzzWGmXVrNHtnt7+GTcZXcONy
+uTxtOCpB9a79CdXdN9W3jueldCsOo9KuxJbPpcn7KvdWtzGzSxI31J4oUHbuGGwVxkio3WMs
+Ol6Pcw2F7cSSTQLDEsn1YhBDYRvTNebaNGl5rOmz9PpOvUsFyHt7dgNsjo3iBom8n4xtI58q
+1TtNmNVKj3y46Y0DXOsx8Qem7q8hhuYzBrOi6hpcSvLFGgZLlJQTHKXCYZRjDDPngZHSr3qf
+RdSl1RXaIGQjTriN1maGAksg8QDccL5NwM47VvP/AGO2h6h1P0+8l10/PcuY4p5FF7pV54Km
+4iuYlACxTEyNGVyMo3Y8VndTsfB0+e50b5iALaGe05DHagByCO4AyM98d65ZZm2lI7FhS6O8
+HVlzdaAlldWOgSPcPi4ik06B7WaTP42h7I54+tcHvmtX/wDrK+JGmTj/AGr0qPUNNuLPwZPn
+RulRSAAba6y5iyqgBvqAwAy4FeZvdTmC007V7GCc3gLCRV3huR9SbcN2Oe571JvtDg0cyz6Z
+caXqdg8GI7y0uc7ZUwWhlt2AkhJX8LEEHB5p8r7Fx2emdSTdL6ro8et9N6zax6hb3Ns8llLA
+9rf+JKW+rCZgkCYw0qFQwIO0VT6DpdzqEsCRXdurSXSuLYKkZRnbEmSDg+RBH2xWY0XWLpIo
+PHFveWRfakTtzF7CQcgjI7+laO2VP27JpGo2rrLbLJCSSF+sLvjcsewyME+9YqVs1cdENd2o
+WhiaEW7yO6OsqEYdHwNwPI8s/rXS3iu4ZhZapasktj4cp+sNJC5Tcit6q6kMp8+3cVQadrHV
+i6Wz67a3hubbccXUYYTIM7fr7kYyuc1rNYX/APud83cSRRWc+k6XJP8AxbRFAoUAfZQM1pBW
+zObpIk6hHGwSwupWhdikmPPcASCR+dYmw1S2uNUsRfRpDm42u0Z2rzlScdgeRyK1OrX8uoTN
+cMMSuXKuvG4c/wCYNYPWTBbrcSzxoE+YUiJMFlOzByO2PWqlHeyYypHW+vjPay2GpWrTNb3G
+I7nfiSMDKuvuMgHBqnicQMk9lMI/BcoT2JHbPtUrqdJop1mimaeIpEX55WRkBCt68dj5455q
+kgmIjMphDB9ziMkjdg81jL6NYu9noei3UF1tiN3b2F6Iz+8ZiIJh/K38h9D2+1NuI9QsL6W2
+urHf8uPGkjVgC8R/EUIPJwfI8is9aXLXUUMjR4Jj3MU7bexyK0aSuzRszK+Y0WPj8PlSUtjl
+HRBudFuunI7RtHje7ivL0q+0Yc25UlMbvMNxjOeMc1f+JBBpltazn90EcXD/ACpidQWG0yIc
+ZZOcHG7aSKpptZlXZbzSiU8x/LMf4ck/Sp4PmasU1O5vbBNNv3+cLKUimcEytGR9ILd2xyBn
+mtJTTZnGLoalhHb67p/zis80Mny88CnaZI3P7uSNu245GM8HNZTUZ5Irp0mtJrd0JilimXBW
+QHB48u1XlxcNqNgsFvJKZYyqQnzXafoA+xFVutXFzrF9Jf3UmHvcXL7yW+r8Ln1/ED+dTyTK
+ppldBNJIXQ/UUA4POR71atdTahJE88gaQoI9wAVto7Akfix5ZqBPafKQ+LnbI48N0I4Yjkc+
+4OauraKxnlRYYmimVQfCc8n3Vv4h9+aXkqlRXTJNI6TykblGA/mQvkfWm+LJ71ezvAkCC5gT
+wGkKeLHjMbEefqPWov7Jg/8As2KrTZk1s8+1fofV/l0bp+/jmEsJkvLI27peGLaczRyJuSaA
+/wAwxjsQKxmkyapo+uydPajHcXKAiOFvl/Ek29/xAdscgg0aJ1N1HqFxFeLJdaaNKkM27T7p
+oY4pSATJtDAgN/EF49qtr34uda3UUkF5qNtJJCxEamFQhyck70wSfvXq+Dgbt2jUaN018UF+
+Y1Tpibq2wtjMXd9JuJwwbZhWa3UkKSv8RGCM1cR9d65fiwsfiBrq609svyUVzdSLFc24zlEm
+CoA2D/GSW57kV51oPxI6kvNWJbVb62vlVWeCC8kiju0A/AwDAMCMjntVpplzpk4ME1xZRWX1
+MxuEb5gZzlVbuGHYg+xrNttUxqlK4nqlvpF3pWp/tOxtblhIQ8trJsNtdqe4LcoPUOO1cr3o
+XSYtVuNXtIo10zqOxkzb3CqzWNzG6s0R28h2ZCPNSpBHesZ0z1f0/ptvBo+spq1xLJLnT9Qj
+dI+EJAADcSAAkMrcE/avUNF1PQ7W2t4pWmuLKSXlmtUQkMvJbYSCwIGe2QeO1c7k4s3pSVFX
+quiWOuQWeo6P1ZZWQay/3e0uJ/GS1aMjMYKMZhlGI8NgNpHBINZm51e40qA6PbdV21soUXG6
+zsZ1UhchWdQm4Y578DNW3VPSNzYzzah09aWl+omEkktsWLssnCg8A8AcN3OCDyKoDq+pXdnb
+DTNTE9pdXMa3NqFIkkdWCSJvA3EAc7fzotTdlRi4KjOdRwaz0RrMtpr3w7h1aa8toryOa6Ei
+xkTqHjmDocEspPcgg54zUC2+JMGnXkGoS6PeaXZW8oe8j03qK8cXCLwUKF8KTx9QOeK3N50N
+rttoV3F1l1DeatMXuJrDRNCvnvdRiWKR4zLdPkxwRj6WWLDO4OQAMmvIrjpaafNtZ6aPD3s0
+93OjRuVIwUYeeDzgDIOa6VjcKswclLo9Q0b4taFZ30PUXSfTo1RUkEwxrlwLy3JGHQwO248/
+xqeeORXeX4r6dNbQy2vT9po0VxFMkMdpoUMhhLH6mDykyYzn6s984rwnSYuhtK1L5a8GrX98
+rmMSQN8rDC2cbuMyPgZOBtzWz6u+KM2tLL0v0fpmmWnSmmCO1sbq6tA1/LBFkBmlclhuJZto
+7bsc4zRKC8GcZPps9Jg+JerIYpbDqK3kkWA21xDNpsNvKQO4MYyroR/N9War7y912Uxy6F1V
+caRa2AU2aSX7wxpnlgqqckk985HNeLJ1To1o7slk1/LjCuxMaRt/MP4m/Pg13s/iJqIuf3wj
+nBOBvUHH2zxXNLE27o6YZUlR9DaT8RornYNaLardswZ7i2Ij8PjDfXgFwSANpJ9sUdTx2lxZ
+2fUeh/DbQbuOGRJYdbttSurh1cE/RPEWXw/MFSO3GcGvJ7PqLqO8slSPqq5Ngwy1pNlVUd87
+ADn7jFTNKngm1I276mEmukHg3Mcm2Myfwg4P4WP05PYkGspRpmkZ2qPULbrxrlYbvTobLQb3
+SLRbVbPp29fwTAciXljvUsTkqDwBwcDFddD6x0O5eE9c6fcapp1lbyWdtb2k7WpkYYaESzp9
+abWJcso3vjv515O3VF4mpKLi2+XETMoD48eMjgqzYGcHI5qcnzNxZyX9oJfCdgspeNk59m7N
+jPbvWbk07L4pqj19/iFaa7iz6z0ay162kxHa6mswXVbFM4ERmODdRDt+/BYDswq8t9MnvYbK
+K4aDU4GuUsNPvZpPl2bxCFitnmP0BvqwFlK5A/ERXlHS0mrXCHSHv742JQs6Q6d46N25nA+r
+w/Ujtwa3fQ9h1GdZbpGz+Q0/XLlnGlXW+GfT7mRkO6zu45CVWOXACrIuN/C4JFXCpv5Ey+Ct
+GrvNE1PToHhs/Badnk02+0LVdPVZ5J4+HEUiM0c+ByFD7wOQCKfolrpuoR2zWclxBdojRn5f
+UZbVGKD643LqSjE8YP5GshpnWumdO6v/ALBz2yaLqckCoiahEbTRLy7ZcGNJNxa2AbhZHP0k
+ckVeR38Gr6o0WoaStnrgK2qhbd93jE/VBOA20knCrLkhgQw71Mo0XGT7ZJlkn0NriWz1W5lv
+LiNPFgiuZ74xKmWJMfABA/i7edOl1fWXnW6e+utRg1SwS7FraKIZQ+MFomH0NIMfUp+5HNQ7
+LTLXXIbOyWPUNOeOeRbG7FrJFLY3B/FFJIQCVJG1gT5A9qlar81azzWfU/Tnj6y8bOiu3ysV
+7NGcMV2nak4HBCttYEGo2jTUuypvOo7K52S6N1bfPMHRkgvbV1jmHZgpQkRzA8MpADYyMVY6
+tqsGjX3+0Ok6FqN5cs0+mTS6e3gXtuhQgs5CO5DruXcFHAOGFZi51i0ubqPU4+kgXvpflbmH
+UbiZZ7aWJfpDhCu5CP4uTkYzVfo3xW1rRDHN0rdRaMlwSjtp0KxTTNyCJJn3OT/hJC+3NVF3
+tmclS0J1PfdMdUWsT6Vb6ZbQW6oEhSSQTgBcFmMjZc8YPkfSvFesOn3s7lr3aiRZ3D+KNx6e
+xxXuUnUtr1IkttqduidQteLMmpxw5V4x+JWtiNiPnH1Agd6zXU8t20MmlX2v6ZBGjsR83ou4
+3Qbuy7QQMHgeY710RdbOeas+bdbErRGaRNp3Y8uR5GqN2Oe+a9H650iGQQw2ngt4I2OyR+Ge
++RxXnl1byW8hjkXkdj613Ymmjz8yaZwyc96XcT3NAAHf8qVk2ttJGR6VqYCBj512a5dljXOP
+CXauPvmuJFJRQ7OyTuDkSEHvUy11K5jnUpO4yeBnI/Q8VXA45xT1JVgR5cg1LimUpM9C0nri
+yspla/0aW6khBXMN00KEe4Fbe3+Nl7HItvPo0trGiho1MQnIU8gh2O4/ma8RimiC4dW+o53K
+e33FTFmuBiOO4dsDMZDHBHpWEkzohGLPobQ/i1p7TCWWPxQzZbxYxvx3ypHYg+XavVumuudI
+vt/gySXBx4jSbHYDPbOBtFfF9lq0sbo0iBgD/X3rQJ1lrsMAtbPVri1iB3GOBygJ9SB3NZJy
+i9lSxRltH2Xa9Y6H4wjiuA1w2QI3BjUn+UFvOuWq6ymohbO91BhBCd3yaNkbv5mUHH618j2f
+xD12xiDC+nui45ady+G7dj5+9avpf4i30csSavdBTK+cQAGYD7nILeQJz9qG70R7ajs+otF0
+9LKFr24haNSB4KSfTvJ7cd9vtjmrVtTs7Czh1eezeSCIs0c90g2y3I4jjtrbP70hjku52r6V
+4BqXxZ6f6UIGsC/vNVlQFNJN2yg5/CspGWjX1H96/kFFa6364vLCaDXevvDOstDGLLTEGItP
+XH0tJGOAQpysYPBwW5quPgzl3bPWodVvTKba8ElzqEUCzXUjSZMjkkgeiovthc/au66pbaZp
+trFLdfvhGZ5CEwiO57gd2OPwjz7k14ne/Ey4uRKmkW9w9xe6lDA0Dvul1BBuKKx/4aAh3xwM
+AFuBXoR+X1DVLK3j1Dx4pI0uHktiZiEZtoK+Rzg7fUAt2oaYJmpt7trnSLc3Ec3yHisi20fM
+tw2cJGMdsnLOR2GB3NXGlm4a5c3t0tlBGqtdi2P1rGn93AuO3JGeQO/fms2LwXNyLeGECSJf
+AtoYjmO2iyeNx/iIGWc+ZNWoubbS9Et7SFo5bq+DXzDcfDSAHZHJJjnYWBKju5244qa8lcl0
+iWLtrm6eHT7fwJrokNMxBlCseTnsGPoOBzXW7ukubqS4s4kgsLQLa2Sp2KIMEj7nJJ96obXV
+IbeWW8LvcSxqRDCDhmc8bnPl9h2qZvLJHFLKGJwh2jaucZIUeQ8vsKXZXlHO5uVS1uhPIRDI
+URAR+8uDn6mIHZB2A/OpGoqEi0/RpYC6W8Avb+FW2tLLM37u2XH8TAIpP8KhjUEWyiVbhyvB
+WIMw4LseAPXAyePIVL1K88Keecx7RK4eJScyTZ+gOSOQzgYVR+FVJ86SRd70GpeJdWOmaXPe
+u8UMsl/qksceyOW9kPhpHEB3WOEbFPqzHFX1mlgTu1a1jmsrMC8ezdjs2R4CK/mQDjjzOBWY
+F6lmU1m9g8Z7NHFtaphUM7nagA82PAGew3Gr3UIb2202x0e4nEGoayq3l9OybjFax8BwO+Gc
+PsXzwpoS8g/obez6xrl2dY1W433c/wC/lwo2wbuEhUDgFUAyBwM4qRPEUu2ginZ7i5lW1mmJ
+Jd5iu6TH+GNByewyBUY6m9vPHb2EAidQGRZDvMXGE3nsXx9Te+B5VGtBssLq+hlYbM6ZC0p+
+pI2HiXMpPkSNq59XpMZ0ktnvA1rbv/71dw2kY81UnBY48goJ/KpGoRC/1tZ4m2WlxcR2sGBz
+8vGcDnyGxSx/5q5WN2PFhsbRdzHcxccbmCkkD8uAPeppVViudSfb4VvHLYWqhsjdtBllP3yE
+X86VWVeyuvxm0D7R+9El0EHHhxGQhM+5UZ/MUWBZLK4uUUM9gjbmPmZO/wDpUu+tHlmS1dhv
+lEayOF9AOAPv/lXDT7eaaG4sA+yK9iuTGF5Z/CZN7flgr+tRXyNE9EKzuBp9xcXTHIit7WzV
+85KruzJ/QjP3qTEpjltrVFwsMu7A8lYn/Sqi9t5JodbSA7TFaXew+RKqjkj8l71aaTc/tbVo
+pPpRNT+mPH/Dk2/Sp/T+tRs0+2W+nyrs1e/K4ig02Z5PVSAVP+Yp+uxXD6npevSAO2p6VZTO
+y+c8cYjlB9/pUn/mqq6XuUk1nW9I1IO0V1aSWAjUfjuJZAq9/TYa0PT5bVNN06yvC5ubq4ub
+mzXvtQkHA9BgEY9q0UbVGTfGVllpmySYjcCJLiEzE+pYJg/cNUy6SSawGnMHEnzN40jcjhpF
+KD9FIqlgLftW5sFAUvcW2/dwQfL+uM1tkijvtJh6lhIVJJleVBzseByJgfvtH3zVLaoE6dlv
+Y2CJYQ2yjYjbZcfxFtv1DFafQVZZI4pSWMKbYWbk7Cfwn1qBaWrnqXS48EQ36zyKcdleEsv5
+8/0q46fjN1GLlRyJdgP+EAcVpGPEiUrRfJGXkLxjBHIarJDyu5c7jkH0NcLYIsJcDCrzj1Ga
+nKgYLjH085rYxskrtYNvUYYYx71438YenHtNRg6lt0Cpcr4N0VGMuo+lj9xkfcV7NCoP0nue
+1V/UOi2uv6XPplyn0TKRn+Vh2P60BCfB2fMVtcGOQyR8gEFvY+v5iu9w8RzkZR+wx/8ATFVW
+qxS9P9U3GiXQKywHwx3G9fL9PL2qyIM0Y5VvX1ApdnW/DL3ovqT5G7fTdQk/dHLwSd8sO4+5
+Hl7V6tbu9z8sol/fw4ksrhT/AAnBMbeqsO3oa+eL22ms5CYmK5GV9D+fka9B+HfVlzfyjT7t
+y0sA3ZLYJUen+RH2NY/gwyQ5Lkj1C4liv4XvLdRNEzFZFTnaytg8d1ZTn3qXazBoEmeUyqfo
+aTsQPI/kaqbtIbTU7nUbAGKPVWS4cE4Am2hWxjjnAJ96srHDSs5XwmI5Udmx3B/71pezmqlo
+sYGe4cxSgC4gOGH8My+TKfWpdugYmNiR5jPcUQwIwXC8ryM9xUkliwfw8t2ZT5+9aJWZNjhH
+GD9S547+tS4QowCC2ex7VF8Rjyq7l8wRyK7W7qQpGcE+vnWiVGZMw5Gc9/I9xVZqSwzwyRXA
+ZFCktJyFAHqRzirmLxGTOC3+YrDdf3dpN8vo1785CLuTAlihZkUqC3LIdyHjv2qmtBBXKjLa
+hZ3qwi6+HWnWN9dKH8SSC9Y3CMx5IikccnGN3cDtXntpD1Be31+OtOmQ91M0aNd3Bmt7zKKQ
+v1PnfjdgZyMVreodH0NQt7d6tdQ2lsI9s1w8TsJMZ3RvgS5we3IpvT0VpdqVi1++uYYGYSpL
+DIN3Y4HiHucjlQK5MlvR6GKkrKmzhaONNHlubua0nPhoJIwcsB2JHcd/rGCPOtfpdhFGEu7a
+5kMqSlVi2hlHGTkn2wePOq+Ga21a+il+VhtXtpHSDAJZdp7+hHsK0EEPhReKEC7yAfLseOKi
+EdlZJE63sW+p3fcx5POcGp8CCOIZXdz3PlXGzcsmAct3GfOrS0U5BBzxhlrdUc0mx0aMw2jg
+Y4wO9J4cUxDCP60PcDBqXHD2KcYNOkgzlkUMw7jsfyqqM7IpUcl1BPqpwRTWtxOguLchXUYI
+Izke47V1kUgLNCDIPNTww/70WuN5lgcEnup4OfT2NCW6E3SsrWhLoVaGFQfwyIMjP8rqe33F
+U+oXwt5Wsll+UuolEngzLviljH4ipPfHmO4HtWpkMRV7iEMVH96AMlT7jy+9Z3qCdI7B5Lyw
+TUNLUl/mInKz2kg4DKRyh8j5Y75FXw+iOddmO6m1HR006S5ubAWsCr4oMEfzETOoz9CkjBPk
+UdT6V5TqytqQvdZ6R02C9W1j8SWKPVZQIA+HaK9gdfEtGbnY5DROe5U03rPqa903WruPofrZ
+1kZfmYtObVI4oZZo/pe3aAj925xnChkOcnGa8Q6x+KGp/tC9d4eotFmvZFukvbDqXbdW0hUb
+vDi2usUYbOYN5jfgqFNYzlGPZrjUpOom/PxW6wMMiaj0PqUOmeCYy0uqkWhXjCzOYh+7I80b
+dxwawUfxE6kvPmL9emenpLHSYJryC31TVWmtJIXfYojWcEsMkj6CW471ltd68ikvbJEs7e6m
+v3RLbVNQgae1aYDAD2SEL43fLt9OedvGa8m+KfX02l2Udq1zPPq+pN411cysHY2iNiJF8kVn
+DMFXHCg+dctym/0d1QgqXZuuperugbWV7/qbpPpxppWIgt9OEtu2z+FWWMlNqk92weMkGvO+
+oes9JurO2hj0eC3MrNK80c7Mxj7JCMHAVeWPGSSM9q8quOo/nZQ0skqEcnc2QT5ke1cbXUf2
+hfLHJdRwQoMtLKcJGg7k+f6ck1Xt29j9yjYdW9VW02lyiIsJZHiWNu4Xbnd+vH6GqPSNYXEU
+kRwxIEv1fSzdtw9ODzWTu763muJVt5JJovIuNpPvj/Kplrbqtms8cxRScjdxz5in7fFUTzvZ
+ea1rhtfEtjKvD4wvnzUDU9TlmtYoXbbsUtgHv61lZrtp5iWc5ye5pbjVJ5HVyP3aL4eAOD68
++taxwmM8wy5mYuzjz5+wqIpcuCpIYHIIPY+VdvnGWOSBUR434+pcsPzpLVY/EXeCx3Dz8s8m
+t0uKOWUubo9V6e676WurBbDq3QNPttYkTb+3ILVrhiMAfv4icbzj+8j5B7g16Ju0mDSVhuus
+tVudJvQsiXOnaNGluxzxukOGDqRnb3wM18+Xt/LFPPPbokkIYIHKZHbj869h6Q651m46buYr
+3XLOJ7Zobe3s3tolguom+kB4wo3+hkzuBIOa5csa2d2KS/Fs9/8Ahlq8nUfR76f0f1Isd/od
+wJLrQruR3gmMn0JdWbyfUm8/S6ZGGkxyGAqcOodV0jVbH4Xy3KSdFdQT3N/baZdlBcaVqYXM
+kahgHSMtgDw2BDAkDNYzp5DYao/VWnWjINrBbeaXxBLbsqrJCzAAMcqCme5A8xW0+IvT+iz3
+2nRdYSaXLBJIl3b3Ts9lqsAdA0UsIIZLpCp5Awc8HGK45z2dcI8lZuNN0ubq/SriyF/qcnVa
+WaxWV5e3JVI4oiG2STJtZ3jZE2u2GMbOjZwDVHpHUGq6Pq02qWFr8rdLHKNRsLhsrHI0hSey
+mQfSVbDNFKBuA2k1n7C51G3s7K+0vUo9WEauTIYZFLBSU2XEbdty5DqcqQQcgit11Aui6vKe
+mrDTTb6ho1hb3WiapEy77i3a2R5dLvS5zP4eWaByd45XLCseWns147Vos44ra86e1IaDcySy
+JL48Fveu0rQTqo8OFyRko8bMgkGMggnzqsHhagvVnTmuXV9L01bfIo5vkX5+wV1DW88Ug2md
+7aXdHIsn1iMjDeVN6Jmu5tSm1HSpb+31CziMHzVsS0csOThCh8wCwKHIx2xivTYxoHWFlrth
+rMCXGoX9jFFqcXhgWmoW4TZHqSY/eEspEUy53LgP5GlicZPix5VKC5I8L0S3OpTaRpGs9QRS
+wardTaHeWt3DJF8o8jkI7MQA6hyuG4ZM47VAuEN9bRSaqXf52C7trp7gcrcqDAysx4JDoCre
+f51Z9S6V1l0VqXyV7psuuPp80a3yld/7UsIPqtbu3fubiNFCSBfqYxAkHNW3xCEt5ZwdX9Ed
+TwXdqdLuNVu7a4g3i5Vthl2hAVeIqTIoYBl2lTyM0NXoafnwd+jtVttR6Kii1S+eLUNLnmns
+5ZYQV8NQI5oJSPqXcybgxHDD0NbHTb630zRtCmubm4j0HqS4TT7skZOnXsu9o7hV7+C/hr4q
+LxnDrkk15vp0+iNd6B1Vo8User3FvJa9RaU0QSB5QuPGgZSVZZIdhPqQScVY6FqMidAt0mWk
+vdNgmTUIZlUbo4re68ViOCY3hiZip7FVI7Ul2KS1o22uaSdNFi17Y20zWlw0JeTDxpMygrhh
++KOTKspzhgeOaxfUejW/TOvx9PaXfXkY1G4fVLKGRfGtoJS26WFdv7yMB43Kkbh5HFbqHVpb
+vSrzR9QG++tdTm6e2BPou7bxd9u5TkBUVgyMpIKhSMVjuputb3R5LW81tlTXug9X/a2kaYbX
+wP2hDMsiSGG4B3BCVEgQ5G5ic9xW2OKToxnJ0mZK9tbrV9I13VLo6faWEUVvcxXUkiSLIDL/
+AHkWTl1bcAXA+ng1491rqI0q+/ZPUllL/vlqY5vEUF4nzjd/ijb6WRxyAeOK9TN/0sbG16Wi
+W41vS9NvNSgnnX9219pN3HuhUggmFsERnGVDxbvOvErzUtR1LTDcyWUV+dFt1gMt0d8sUYYI
+oYk844Ht5cV08aSo51Lbszd3q8Gn61oiJpUFsdKLR3NyjOG1BJJMiSQHgFUOzK4yBzzTOqks
+bPqKKD5xpbWWdQsue6O/1k/r/SonV85muNNul3WyGVY54nJcBiccMRnt/CagdU3+nxNBL4kk
+k0Vwskgk5LB1/eKD6BlBH/Ma60c0tE7Vup7DS7S96Kg0DQxf6VdT2cvUOjX0xXVYlmDxmVCf
+ClCjIVwqnB5zgVG065lku5JImDSSKSYnYBmyOcZ796xM9yjakTCm1ZmLFf5R3q5iukRIbs/j
+V8IT6gU5vn2RD4nqvTuqQzz2dgVMSWWmTw3Tqckxli2APtjj1Ar0QibpaSWXpSSW9j0uz/bC
+XdkSDLAiLunYNgoqBwrKeQxHfivDOk9Tfxp0Zdz3IEUh9Vzzj04rdQ9W6taaddvYXvhwdRQX
+2i6jHgMJLMmPMXIyv1RxnK4PHvXHKFNnWp2i41WPSJIrW3mUsqwTsrFt2wNHiJPchsmvM7/T
+jp66DpXyySX9pFNd311DLh/qm/cE85+lVJGO2avZr9tngxO7fL4UF+5I8z+lR47Rr3qO51S7
+e2kmu1Xw/DBXbHtACn7GpU+MXZSx8pJnrPTfUNwmnSSR3Rjmv3V5TtBWSSP8JdT+Lu33yfWr
+tdUsJL1btLf5a0kVI9kH4bfjB2q38Gece+Kx8uo6XNpdlaabby20tnGIpUcAs0hBLyBh/Dna
+ADzjNW6EQWi296ojuYI4p0ON29HXI47d+CPevOls9OLJEHSWiyPa2V60sbae9wsLoQNtvJhg
+U9SrDjzANQr7pm+uo9K1ewthPJdz3MfixvuXbD9XhlzgklWBHqM+lW9vPbl4DLmS3f8AAynl
+Wx29iOKtJILiPo7UbCzlVbzQNZ0/W1jVAS1u++GaQKe4BkjDKPXPat8c3LTOfLDi7Ri5p3vG
+js76ULcCFYowoAKqOQpx3wD3PPrW81JptS1ea+nhgEstuu91G07Qi4OP1/WshHpyarcfOgLF
+NllzjHn/AJVr9PlMVmNQV/FaxlS1lLc/iXBUg+xoitlSekY6+vtMttTsLTV5Z7qFbgSwzL9N
+1YneAyYP0TRvx9PB9Oasdfh1GLWbi8sbkXSzxOLIqrHFuM4iBYfiHp5Vn/iFb2n7NbVre/Zp
+LA20Hy/h4Z987bvsU+j77hVppmsXg1eW6hn22F3ZSMsjNzbTwjf4gTsfQgc+flXVgVu2cnqH
+SKbVeo0/YtpJY3A8VHWTDDkREleR7c1l9Zuvlfnmf6i0vyqEnt5k5+xrjqt0rWTRzzLHIVEk
+si/V4ueSD6ZJzWK1HU5ZnEEbo0TYdSh7nzznzracdmUJUjU3Wt22rvLMxkijwu0M3OVUDv8A
+lTn1VEsEiSdBGsrESE4wODkn0rMXMLafpGmal8/Y3cOrxTusMMoM1s8b7GWVO65yGU+Yqy6Q
+6km0K/sNYso4TcWNylxbieFZoi6MDteNuHRsbWU9wSKwnDVm0Ml9Ho9tEDb3UUZy1tBFypzk
+NznjyqxW4T5WIx4ywXHHIOeaqNK1f9o3+qXLWFvZS3t9NLPDajZbosx3rHHH/AiMSFGSAMDy
+qZpVousXNrpqT+DLJdGMybdwUHtwO/OK55Kto3UrdMt9M12GzsdRsbuO2ksdXg+UuFkhVpYi
+G3JNC55ikRwCCDyCQcg1Zad05qWn2dut5NbyNHHutJkcFmB5GQOcCsYCZYb+1bb48TFAB23K
+xDDn7VZaJem4gsSJdlxA4jVwcEqDxz6VF/Y0vJzncJLvuQ3gPJiQwn6o3Dcsp9jSX2pw3UUF
+vJO7fJyStEEGI5UdtznBG5WJ5IzjPbFWGrnxfmp9hVrhmlmUfwueTgVkbSZ7mcwx8Mgc4P8A
+EAM8flQviX32XXUFhqMAdxZSNp93Ihsbkfglk8IMyA9wwU5I9KNOuGS7iikHCbXQtwyH+Jea
+66hcC/0fp68aRPGCEJAhwThcBiPPK5Ge+RUSBbqaEzQoDkMSWP4toyR7HHb7U5akRDcSa8s1
+xBNHKimaeVXwrbQCD6dqb8pd/wA0X/mX/vUu4htrmBzJEdwjWQkN5Ed/yqu/ZsP/AITf+WtU
+Yyuz5k1O4095DpDKYxanETzDDSHzZyOxPl7YpkExtYTFLDxL9O/fkImeTx3qX1Ho11d32rz2
+9o0k+nIk1woPMcKkIXx5rkrn0yKzQmkYsTKwz+IV6y2jy7adM9C0/QNNtp4vB1yDUb2wuiIL
+mCVPlWgxkK+4hic9scYzmtVqXSMur2rSWWvaV80IPGS3k3xXXg4LOmSuyYR44KksVI9K8csL
+u4s38WNzGO27GcGvXPhr8SNE1ERdDfEiI6lotywMd3Mw+Y0y4yDHc2+cdiAskZ+l0yODg1Et
+FwHXGgfEDROnpOnruHpu/sNM1KK7gle/jaeJpIgXSNdwcRuNrMNuAVzWu6U68voQmgXgiuYr
+RTss4Zj+9iPcxh8fUmcgZ55xXP4u9BX2iRWvVdmZ5pTOLHVDE2+0ubV+ba5t2/EqDlNj/UvA
+5FYLo/qKz0PWHl1OC2kiuf3Md7PG0jQLgq8ZA7pIPpJxleCK5pxUjpTcHZ9H3HUM/R9po11a
+GaK4utLtr0GeJo1m3M2V2vhXXAOVPI7g1W3stlrt/f8AUnT6jTtXv4PD8Cxi2yPMpycYyHLY
+P1rhzyOazehfETXbKDojQoOpLiGK20RdNSGOYSCBfmJWQkSBlYDccccikTrbV2kuY76K0Op2
+8rNDeC2VPEYHsQuAM4BGBWd0zVPWzF/Ee3u+k+rtQg6e1e6Ols8VxA8U7AhJIw6PGeG7ll9i
+prM23xB1BYb2z6o0xdYnuAyW+q3N3Mt3bsR/hYK+MfxDIya9m1O4g646dsuptRhu7XWLVoNO
+u43RLix1e18VmRJiCGilVt23GMg9xXivW9rFLrVwlvafLvbO4Ee4kKuTt5PfjjnnjmuiEne2
+YTin0UdzNpd2PnD48d/GAj+GAQ/o2T5+VUl5ez27j/eLmPI+ndjJFSY/CNtHa210rNcTFXkb
+CgMRwC3kM1S3U93bCWwumLMHwQTu2keYNbJWYzdbZHecs2JCSuc4Hcn3pYG3yAcKBzz2HvUZ
+nDeX3qz0nRdT1CGa8tLJZ4oNu5WlVCxJwNqkgv74zjHNaNaMFPZpujzaW+pw3kWsXVjJyTez
+CQRMx42hYwSR/wA3BFejQxXOqx29uNJ0a4ZHPy66TaLbeOW4xt759M8ZrD6Va6Po2m2Wr6nq
+ls980zq2lW0jM9qiAbZpGx4Y3EkKoYngkgcVa6X11Y2QublIbjbO4UNFNhmOTu+s8jj09a48
+ybZ3YmorZquqLXUrW4uFm+HkGn3UZxJJdLNLdjb3aSTcVZtuMkDFX2j39jHNbfsLqGC48W3d
+i8umXGLSUr9IDuxBAPfagz61W9KfEzU4Wu4I1llj1C1S2zcTKJl8M7o5I3wArpyBn8QJBzWr
+0nq7ozUtOXVerejOl9RDajHa79SluNMFyyqDIp8D92fIfUVJJOM81zONs6VNJFzqEnT+uSwz
+6b1Na6yJ4I1kCF1ZXVAJVMZAATduI55HfmpDz6FoUUGlal0+buG4WKdYrWOM3Mjgfu5ncfWF
+x+BRwv3qq1LTdUhkOvWGkI2nrICo0tlMUcTNhUVYyV2qo254JAyeTVnfeNq8ENvaagFtpf3y
+NBkNBIe6ZGGUEgceR57GsZfBmsXyRrYdR0TUpYb676O6i1GYsk8V3rEtrcFgvfMV0BGeSeSG
+5Ge9aG563l1BtQtdZ68N093G8KyX+nwi4sHJHhmOeI+GoUAA5UAEDbgV85dVRzwrJqt3ci5k
+slQt8xueQ5YKEAPJ59O3fNXPzd9Y6fYaxYalfqt3C++Ay/vIHVyrKO4kQjb3APNVba0TpOme
+xdQ9J6zr9lLev1bB1bdR28NpbtcXvzi3QXlorjw2IS5RScEKPEQLg7hXn0+rwR6ZJPJqEuq6
+XaFoLuIRyyR2oUZjkWFvrRl8nPkCDUWw6r6e1XSrF9a1e70C6mbwk1G3h/cMynAZyn1KfIgj
+gjIqfq9uZ/G1S91WHVrjZuF9A5ElyvYlwoB3Ad8gZFJspV0MvtZj1PXbjTxBqN5cJZM/hRDK
+uXiVkmSQcjnz5HOO4rF61Fpuu6jBYm9iF/f+Gri6j+XEjDsWlT6DJ6tgZxzWo0i86dTUraTQ
+ZtS1RLaJIZbRmTwWgZgZoRuKlEb6lJ8s5qRqum9JT6bJY6XbdT6VFBIyw6deSwywRLyfDMsY
+3OoJyrDJ9TVpfEzbt0VL6TqlravpmpabILdGHyd3O6ybyhw8QZSVBwSdrEE44rMa3pz3X763
+haBIciTxSUjAHrkkg1tdN0C/vrc6x0Tqtvf3dzbY1K2t2kllkfBDLJFMAHGAvYHHrVS7Ralb
+rdy6LbpqFkBbtcWmpKJBtPYwy5GR5989s1opRqiXF9nk+sWc8MrFreFw4DM55DA+a47j7VmN
+at4JLV0ktwzL9SqiDtXtVzpTatJLaS6ml1epytvcxLZzSNjsCcIrdh5A15H1FLLp+oXGmX+k
+tZX0EhSVZO6EeXHGf6VvCe6OeePRgLmOEMfADgfyv3FR6tTqd27PDuXxJfoDYC4+5qrfhiMg
+kHBI7Gu2PWzzp1eiSsds0S+I8kbPyG2hl/71wmhMZxvVwRkMpzmpNqROvyjAsSdyEDsasLXS
+oLiZYbWeB5pfpEc8ojCN7k8Gly4lKHNWUiIT504o23d5CtC/Teq2SpcahpLx4f8AvAQY3HoC
+MipcugR3UMk9pHhmGTEeD+VRLKovZpHC2jJozKcYyG8qm2odXiBZgm717UXOmzWk/hMhB/EA
+e+KtEtULPkFwoLEA4IHr70pSVaHig09kW6iMMzxybldTlW8m96Ys74yTz65q4Nst9pau05Yx
+HCOV/h9DVO0fhyhJQFzwG8jWSd6OkkeLgAvLsB5Pr+VSY+r7zSj/AOwFFnN2+axunH/KT+H7
+jn3qoYsHYS90BNMiAlk8R1O3OAifikbyUVpCG7MMkzXdNXlt0vAnVEs6nWriTfaSzDf8tzkz
+YOd0me2fPmrb/aHVb6OTVk1IpAHLz6rfkkbidxCJ3dicnAySe+KyMOqw6Kblr62h1G8uUEbW
+8n1QQAHIzj8TDHABwPPPaof+0l5JqCalfEXk0QHgeIMJCR22oPpGPTtV8DDmme7aLrOkdFdN
+6XrmuQXFwL+aaW202aXN7qO5QDJckf3UJAGIl42j6ic4PqvTWp6xrGs3klxdNb/tCz+cV9y7
+/oUB7g4P7uNQwVVOPw57Cvj7TNWa81pdb6gmku0g/eyh2yXxyqAehbHH3r1/4e9aapLpuqax
+e3pWSVU/aj4AiW17QWQJ4Bkk+tvRY/tS42yG6Vn0RovVltpOqadoGk6GNSvbmWKMvfORBGkj
+hY3KDlh4avId3HBJHIq91zqyHU9R1LVYWa40+C9jtbIn6W1K+b6FJA/4UKj6V7Dk14donXOm
+2T6rrdxebYz4EUt+B+9kjiVswWoJz9Z2qXPnnsoq26e6+g+T0eXV/pmtrS91uXwvqa0iwIo4
+kB4LuW2Ke4y7dyKhpPQk6dnqcNysSG5L/u/F2b8YEknko9gATj2q9hu0sIDc35xP4e22tu5L
+v/ER7LwPvWGXV76HrHRukGQRXkEcMU1uJFKx3VzlhESezLGpZ/RVHrWhuNZsrHULd4LWCUyu
+5tJLk5V3VsNMU7FEALHJ5JUc81PGjTmmaW3sZ7/UZJJSp07p2Mvc3U8ng2/zUgyy7+5CjAIU
+EkcDvUKK4ilvnuEuJbhYo1lvNQ8LwiHf8MFtGfwkrgbj9WD5Vk7TqKbqS6sbUS3EkV9czSxL
+LybkxyYaYgfSkakFue+Kn2XUmm6l1WujWVyZrTTYpLzJwC2fpEnPeWRsgZ/CoOKXEayWzV6f
+YwCVtY1QpiziLBNp8KFnyAee8m3CIPL62PcU57y71PXBMMm6vpEeYntFaxoAkY9FVATj1NVs
+d9NdkfNBFVJjMVPC7wv428sKo86ttBiWDT7rVlgeW6u4yY0/iKyfREvPYtkufQYpOJalu2LA
+YpPmdQClIXkZ0JHLMx4UfYU66lht9OSWSAt8tG0xjUZMkjuCB9+EH5UjqVaCySbC2sQh8UDh
+pD+J1H5YHsK63Atxv2Iht43jVEYnEhUDCsfQtksR5Coou9nWe0l0PSW1O7fdqkn+6wqjfu7a
+SUEvj+eQIMZ7Lk45ouYJGuNP0zcERYIIxGOyBmGMgdzzk+fIqRq7O0HT0N9M9xJa2lzrd+xA
+Bllnk2W8YA4VdkfA/hX3Nd7HT786xZG4Ia6mvYZJ5CPpAU+I447Y+gY9wKK8FJ+WLcTrcald
+TWzhUikZYvRcMeM+Z+nJPlwKnRWyRdR2EIzFFZWMFs5xz/vMbNIR/wDlATTbDQYp5ZNLG5bK
+2DLczKfrcFvrVf8AE7MVz2ABNX8lib1dZ8KPw72/voHaQDJt7QZ3Ip/mZURM+lNRbDkkYfRt
+Iez1LSbaVt0ssl5o0mDlfDFsUVj7knP2NVGiSNFb216P3LLcQKjED91IR9Jx6ZH6V6a2l2qS
+3N2siKj6il4ygYNuvhgEKfTco/rWIurHeZjbIVhluS6A8YA7H7ispwcaN4SUrRFaKSw6wttX
+ijaOGS+hvcd9rJJ+9Q/8pJ/I1d6ddte2WqWMEyW950ysc+nyjIZiJXUqT5gkr+tQdTkkePxS
+Q5R1eM47OAAwP3pujx2w6pglmlCWOqyfJXGO8fjMgRv+mQKfcZpwdsJrVmu6psI7PrOe+tnZ
+I7q6hlKnhkykZII9nyPtitd0wWk068hMBKm6M8kePMvtIHsRWY1SeDU+qeqtNuNzzza5rK2y
+pglTDBHIpHooaJ8Vu/htbvfHRI3TZDrUE8MsvkJoZt5/MxkmtIwfMwc/grNKFlXVrWfztAkS
+gfwAIM/0NXugKEvHVl2otwS2OwBFUGmTNqsV7NICryahcW6DOMRgHb/RRWi04hFicNzMxnc/
+lgf5VqtszZZ2/iCztBKoWS5wSo5wgY1Pt+Q+RydxzUC3LPd7QoCx7VTHkuM/61M0mRZLXx27
+EuDn2zVE+LJlvCxDSBsk4OCO1OmiLRkAgN3XH+VdowsduGIx9Iya5qu9PCQ7j+Jfy8qZLZ4R
+/aA6bQiw6ujURtO3yc+B+JhynI7N3wfPmvONOvSQd5JxgPxzj1r6F+KOhx9R9G6hpz21xN48
+DTQR25Al8aM7lKE8bgQcZ+3nXzDa6g6pHc+Is0vgrcLIiFUvLZjgTqp/CQQVdDyrAis5txZ2
+YHyhT8GtmWGWARyp4kT9j6e496g9P3SaTriSSSEwNujcnyB86fbXsUtsG3Fom7bcfSfWuE1u
+hkKkh8nPPpiufJI2gq0z3PRpzPp76c7PMtuUcsxydknYg+oIB9wa1dpCviKuBkfocV5/0FNL
+Pp0wmkLNDZJFyMkhSNmfyOPsK39hKxMUgGdvdf8AMVvB2kzjyLi2i5tIzt8PAIB4z6V2Nu8q
+yJE4Ei8qfX2pbQeY+4pZJjC4X8JByprrxpPs5pP6KC61Oa2ulSVGjduPpztJ/wBKt7SUTKDg
+hm59q63kEN3tleME+fHeukAEK4VQARwKuceMqEnyR3lvIbePZNM6FuPpGTWD6wbVTcJNcSi6
+06bKQ3lvztx33gcqR2rSajcKqSSuS6kBVAPY+dZmMmK6lvLOPwZB9IIJ3Fj5+lRLaorGqdmM
+6y/aMltsjka4mj08TxxyKJAE8bluRzlffPFTdD+S0aGTW7xUhhuI5po1TOGPAwAf+UAV31id
+rvUpLwxIbgR+A06fT4iElQrL+E49Rg09oLS7tY1uM+FZwM6KDjd9YDH/AMxA/WuSa3Z3wl8U
+mdrSwWOSJTG4mjEjJg/jyuSD6/i/WrhowzxMrlSse0g8bh5EfbzFQrRkEpjnl3PDIZBnv9Q/
++5IJz9q7w3QS5VJ1GGzGvOPqzxz71MXSFK2WNuAnB5D8+6tV3ZDAXcefWqe2WaSQqUCSeYI4
+NXdooZMqO/Dp5qa1TswkToxjg8DP6U+RGyXQ4ZexFOgUHG7OK7FdmM8qTj7HyrTsybIUe0v4
+irjP4l9D60skS7y+wMp8x3FPlhEbkE7N3IYcjPvXMSsrEOuGHcA5B9xVxVsiToiXkXiIVtrk
+2+oAH5eUMFZj5K2eGB7YNeW6v8QtLjmvYrmGDTNf02Y217YT744ZGKEhJV7xq6glJRlDgjuK
+u/iX1BpemvBadRTnTI5pNllqUj7bQXLDEUE79omkOfDZvoYggkHFfKvXXxZ6JvGaz6qvof27
+occumt4l40F9GpyfCZpV8KaHJUoRKSuO1atqKsxjc3SMp8eOkejeqb9eqtJ1ZenNckm+WiuV
+u1iE08SAmKTcpSK9QYKupUSxhTnIryu30+zsoZIruczTRAu8l5L4MDYGWnmmGQ/P8Kkk5wOa
+9EHS0Go2F+L7qTR7rQLuNFFjeSBtQQ4Bjljk3tHiNyWAGDgkdjivI/jZqHU8SR6JY6xoGh6f
+axxv8y94kvjADuIVzgsRuJwMk+ledkxvK7SPTw5Y4Y03swnVnxEtIbt/2RY2NyYRILjW7xWW
+CRDjbFBbA4OOVV2y2DzXjmraxqvUWqzahqF8HkuGyQCAqDyVFHCqBgACmazeRajdyLJeSX91
+vI8eNPDj7+S9gPsBUFrm3sGKWr+LLj65R2z6L7e9dEcaiqRg8vJ2Xw0a6s7YO9sW3L4g4yzg
+9u3b7d6pdUktTHC8McaltxZUY5BHrmny9Ya5ILSKO58CCwx8tFCNgiYfxjHO8nkscnNVs88l
+y7SySl3Zi5Zj9RJOSf1qlCmHvWqGLIVkD4IIqemqTR2xgRsozAkd8Gq4pMy79rMo8/Shd4+p
+cjyJpuKZnGckKZBuZie+cUwuxGNxx3xnjNIwx38qQDnPlVJGbbHoxByGK1axQy6rIlrpWlSJ
+GqgEx/UzHzZmPH5cCqj/ACqU0l6lssEkskcBG5IySA+fPHnQ1Y4ujTR9JTi3WCfUBLukDm2t
+WDsxA7ZJ27gPLPY1qNLvunbeQWt70THqElvaxlGudRnt5FI5IIj4B/7V5xbXFx8t+z5GY2pb
+xPDzwGxjcPQ44zXq3Set3HWl7C/VF01xPLJBaJcmNRJFGihIz9IGQoABz3Fcmd0rO7ArZ7J0
+71Npdxf29lq+nXKafdxLEGhvpPESNogyZz9Jw5xnHvXo9lqVv1H8O9CAl+an0NpYIfm08WMr
+JIfEt5Yif3MiMAymM42uSPSvFvkrmFLTxYmjchVDBcKyoMZB/wCYGtj8PtVsNNl12PUo1l8U
+xkws2BIv4Tj3AY89xgV5M5W2exBUkTdet/8A95ReaW93pWqIGmihs5GCTQqvIwARuXkHPDD3
+rbNf3+qz6XLY/wC5zasLeK3t5xmO7nihUvEG/DHLsBdA2Ny5xzxWL6jtr24UWdrd366rY2st
+xDC52PeQ5G2WGQHkgEblbggZFVV31Ha9L39trjaxBqek63bW0+sQWSTQiDWrVVJaBJAGiuYg
+wOR+7fDjlTSjG0Nvjo9Csteg1uNdTimgt2uZJ7Vzazt49pMv9xO8Z/HAW4cjlRntxXoXT97N
+qOt6Fa6tYy2ltqyKlvf2s217HUIwFmaCQ8y27kruUcZcHvmvCupLiPS9c0rXtJvEDfN/tG2n
+izGksU6hXYEZADMMFD+E5B4rddL63qWuxDoS4lIgu53m6clhbZHomrq24HA5ihuCuGUfSrlW
+xgmsX8Wa6lE9n6gHUENtD8PepdRu9MvdSiS66c1lEUGadc+GpPI8UFSoPBdQVOWAz5jpVz1D
+HqN709c2lotxLpt2biJlNsBFjLKkIwyBsSKCAR9Zz51stZ641rqbQ9R0jV4dO0p7Vrhoxd6f
+JJaxTzqFm024wd9pL4qFkdeAxWRMcg5W+6u6f16znn6qS11S5iijjfUYAnjW1yFyHfOCWlX9
+24HBmUMBhzjr+Mopo4Vyg2mZf4SXOi9K9eWSxaSur6BqVyUigmyskRlUxA7fwMVDEHyYDyNW
+elNF0drmm3/Teowat8vIskmn3H+7C+toW8NtrN9MVx+Je+GB5GDWVHzui6jqNilzzpl4LmK4
+jt327SVZJNjfUqkHac/hYYNS42QwxM8ccyQyZBk/AUkz5diGORnyPpSRo/kb7RNYjt0m0VdK
+eG+SVmjhurdhLbkK3yrrzhUeNlU4yPp4OBWa+IXWMPVVpbi8vZXjvbDwTDPb4ubMqAZREcDa
+6zqSyE4Ibcvem6N1VdaZ07e2FnDePrWgQm+0GW0cFjYM2buBg3MixJl1Tk43gdhXH4gRm60e
+86y0m6j1eG9tvn5boFFuLW+batxbyxjnAG2VHUYMbc9q1ir2jGTrTMT1DImk9A6X1XfySTTj
+UbiIRwAx77KcZSMbO22RWP1cjdXkOs3WkW97eXmnyzRJMcBQ2VdfIuPXPl616Tr3WMCXUkjT
+fuL67SW/gRvpHhIEQhe3bJFZV36U13TOobSe7j069vIraG0up1/3eQrKX2yYyYywATcMgcE8
+ZrqirpHM3W2YKTU3ZHSUK0FwiNJG67kd0OUfB/C/uKzfUJjj6psL3WbZjbiXxLuBSAuzIICn
+0Ydq9H+K3w81PoTU4beCy1WPSOodPi1XQbq9ijUXcJUeIqvGWjl8OTdGSp5+k4Ga836hWTXu
+n7L5OOSTU7VGWWMDLtEhyMDz2jP5V1RTWmcspXtES80C2kjvta0e6eXTJJIvl5HGGRpJNpib
+3X+o5pLmSGOOPSnlG+3utpHmSOMj24rnYXl9pvTdytw+LK9vLYywbcEyqGKuB5EDOa0eq2c9
+78M9Rkh1TSol0DWIdUa3uWIvLhLseFvtzjDoGQF1JyMqR50+NmfOuwmv10HUrW4jEchKr9H8
+JY/iU/bitTr2q9OLo+mPpV1crdXd7eXV5aSKMWo+jaA//E3HJ9gADzXkmo6hLcNGkDl0t4lf
+nuD/ABVI/wBo9TaOztHn3Q2TvLAhA+gvjdk+ecCsJROlS+j0a2ui3iKkkVxFOpAdeCrY4yDy
+K0vSdvZ3fUVhZ3kiiKZkjYscZGOa8w07U3uLmIwN4ZZgzKtehdIXdnb65pmuSos1tbTiO6hd
+iPMAjI5APr5VyZI0jrxPZsp7CLTol8cItxKviDwwQgV8/Qyt/EBg8HHIq4gupdR1BI7cLuKK
+i+KPp4UYB/Sn6t1C2p6D+zU0uI3sF9PfxSKQUEJx+7ZTzuXPBBwQMEVA6dkB19RPKJIkTxZJ
+s8/SCSQPc+Vcc0l0dsG/JZJeRy3EURiiiZY1YOkeFIJ7Eeoq8a6ks/nfGL7hbli6ncCu4HBP
+kN2B+lZu4iP7Sjht0G8KqMDxlidy/wBDWssYr+76Z6r1SK2EotlW2ukzgxxyOGRvcBkxWadb
+KltDdBj0TXdbuEi1R7ZBYyF7e5hLYmSMkFXByAWA79qp7PUxFcatqLIYf2nDZXMKeQljY5P2
+5PPtTekbyaw6mt9dhdnjt1f5tFXez20iGOUhf4toble+OaudC6Nj1PQpZLa+NyLG3uJLgQEe
+JFBHGzCVR6YUHntzW2GXN15M8sfbWzzrrqKWPq3qGfXbuK4uzcxLLLbMCk0oVHDLxgjb5+or
+Lx9V3VjqCaW9w37MlvDIgbGU8TAZs+4/KtL1PY3WsanpV/8As6dEvbdL29/dnYsoQBwDjyTD
+Y8gawHUemzw6bFfyx43T3EIIOQdh+kj7rXpYY0ebml4KvXbvOpXYt3dYQWijQvu+kH186o7G
+ZbW/tp7tN9vFKhdM/jTcNy/mM06aVv3EmCVlU7SRwxxUS9ka1ura13Y8T8Q88+lb0Y2xby4j
+i6gmaziC2oupBCnpHuO1c+wwKnWOqGG5ebaAifVGvcBs9jUK7jX52a3CL4yoHjPYnjPPvXBH
+VJNztzOAwHlmolFVRcZbPedLFjDdxX8DMr3aIbuBuUQnB3I3mp9DyKsdM1AWmqQXFncfLMt6
+t1FKACI5AwYH7ZHb0rD6VrDT6XYyyqsQAxuB5YgY2mu9rq0wvdsiDwpTwP5SDXBkTWjug7dm
+zhjtpusNQi1m8FvZ34+cluk52M7MXcD7ntVL05qpkmtXZgqsxUuwwCOQf+/51K65hnTTTdWy
+BHLomB5B1yF+2R3qg6ctLzVrmHShcG2QEsWwPobsG/Wud7RstM9OvbNZQZ1cEuU3AnIkXb5e
++KwSS3Fvcu6SiOUTExMP4QTwK2VxPMsaLMwM1uqpKy8fWqgbse/+tZrXrSHTHtpmt2msdQkK
+PtbDxPjPB8vUZ47iqWxdGg1nqPpm26Hj6em0bxdbj1C11LTr9GAj+WjWRLm2cfiViWRlZfTB
+FUsWsxQAz2kIa3uGG+OVsspx9QyP86z/AFJPLHexTQIp8BQImC8SY7sR7+dV2g3YUu9zu8SR
+g4/lyTg/6VcrkkRFKLf7PQLDUE3pbh9ySfugC2N6N5Z8jj+taj5/pL/+EdSf/l4P/wBGvKjf
+3Np4bR7TLbyiQEjg4PpVt/t9d/8A2PH+lLslp2YC41O60zqOy1T5O1unkjlsbhGVQk9pKjI8
+ZOcMMMcE85ANeZ3ehQ2Gow2UVwkyXLnwpY2yHiJONw7qwx2NavVdKt7KO50+115J7duY0uIi
+jIc/wkZ7fpWLu5USSJ1nWW6GQ7J5HyP3xXqwdrR584pPZEuVSAFBKGKnBI9abZ6jJb3DTuqy
+qRh1ZQcj2zVhpOsXtlbaxpNrBYzwatbLDLHdRKzqVcOskTHlJAQeQeQzA5zVVbo8M6u/0FG5
+ye1a0qOdyd6PoHpL4jSHTYLSQqxi0YaXqFvIweGSEHesiZyBJjYQe4YMKyPXUlromvxtaWC2
+91Hia5srhT4To/1LtI/HG6HnsRk4rL6K11ZSym0TLYG9VIAPn271uzHddSaZaWOuafbwtCDD
+b6jcowdUbLLCT6FidrHhc47VxSXGTO6EuUVZXdeaja/OdP650rpd3pulyaTFEsEjb/AfxHOw
+P3dVJIVj9RHfmr7p65HUSQWHz+L512SrKdrI3JVkPZl7Ag8g1d9CdTTaL0lqfw36k0Y650zb
+3g1qfTJIEeWzkXbHJNG3EgVgw3xq204VhyOctc9NwaZfT3cUgKyyN4aZOY4yx2kEncQBgcj1
+onKMthBNFlJfXdtbzOl2dMvkZUnjjkaMzYBxJgjH/wBM1QwdR/Pzy6Tf2cd3JNBJEtyse1+Q
+fpk8ifRhz60s9zeHxLK+WS5t4ztWRo2wP+Vsf0qNpzXmgapZatbahd2VkblFuZo4lfEZOMsr
+AggZ/SlB7HLoxE0cFrEbaR41CP8AgdDn75FVmpbHf914QVQPwggn9a0HViOmr3kl3BEkyyku
+sQ2xsfUDyyMH86y9zdTSkl2AyecCuvH9nJlaSpkXHNPXj6s4HrXPPNLn18q3OVOjsFaUqqjk
+9ue1bHpfX9Jg07UNM1HQ7O4jWOKdb7BE1r4TElIj2/ellVsjsBjFY1X8MEDBYj6ifT0qdL1B
+fSaFD02iwxWMVw12wSJQ8spULud8bmAAwq5wMnAySalqy4z4uzVadHc9RrJNpscV0mxzPZ+J
+sa3UDPiH1RcZLfrV5o9/1PaaVc6ZYdQiSx1eD5e7sM+IkqBtwDZ+kEMMqw+oeR715rZapc2Y
+Kx42lSDj6SQe4yOa2Gjah071Cph1CebTrxcHeozE+PPA5UjjkVzZMbj0dePIpafZp+ltb0zp
+6ezgudQlhmdpC8UbtGPqyqqSvmMdjXo1nrl1pUyG4v3EWxZY5AGdsHkYK9/vXk13H1BpwTrf
+RS8n7OKRz3VsQZIMggSeeM8jJFWuj9Y6ne2jPLqFxLcB/GS68T6yp7rx981x5sb7R1Yp1o9z
+0u1tephHZXOvdO3Ky7pYrXURJFKAQWbEm3C9i34vfFZowzW6Qy2mkyP+z3nZPlbhLq3kt8gy
+LuU/Uq993pzWZsddukt7JdXvbq2t5opJbS4tRlzJGcBhnjcCc/lVzq+vdE37xdX6Rq/UOndU
+tcEanBZ6Za2mnLhAqzplnLmXBLpsVFJ471lFKqNnK3Ysenard6NqWuaHo82oaRDL4moxR4Kx
+K2MyMM5Xyw/YnjvVKuq9Q2d3HplpcRT4w1iLlNouRn6VDjH1c4HOMitJY6x0pa3TazYXmsWV
+7ND4bSWVjD4DE/3kc0ZkZHVxn6SAD5Vq+nruw1q1TTNAi6Y1CzvJPr0nUF8NgwORFbAtuiLD
+kAEDPANUlF9i5OzyS0lu1vP2tNaXFpHDPJHfSSLsFtIO68925zt860thcarPcxvfJBp01nOY
+f2r4x+UJHYSd1TcMdwO9arq3pfp7qewt1m0iXTbyzn+XuLmPUHukDHgRzQ43o+OAxz6HNZzW
+uhpLCzuOoemNXsnEbeC0cXiTM5Qj+X6lI5/GvBHemoXpByp2TOpdPvenrzx7m0jt7lgfGslk
+3wzrjie3mjOGQ57hjg5+1KkK6/bQXDajbzpZRC18G5Ci5tVOWTcQBvi78nkZrF/7U2kUC6Pd
+yyyQW5Py8xKgxljlhtXjBPvUKPVrezuW1FNNlnhRCsrpIQoHdSy9jg9qiqZXJNbNBqXU2rpq
+L2Wvw2k+nGNUgW4so9wQDEZDrncByM5+9eeda2tvd+JeW0axNbybNnkobsB7A1p0vdHm0ovc
+W+o28yq8sVwwEtrcDI+gqcNGw5+pSQeOBXn+r6rHceM9uWHi8FTzj0rfG3dswnVaMRdIfEdm
+XaC2MH1qMSc1P1QHxU4P1KGqEEcgybTtHBPpXpwdxs8rIqkzrFdTJAYEbajHLbeC3sT3q006
+JdVjkt0jJlhUyBAR9aDlu/mO9VARVXLh93BAxgEVNtVuUuEvbb/dQrAo5btj796mSQ8cmmaj
+TrySCDdBISp4e3kbCuvoPIGtFYQ20lsvhqWRmyrH8SEjgEVntL1G5tfFvVlikgmAjnR4g43Z
+7jI45q6s50SUz27xsjHjwxgFfTHlg1xZEd0GGqaLO9lbS3FskwnVpIGBDEgNggkdjnyNUttZ
+KsqNtYNExOG7hfNfcVqGvZIbciEYiMm4A+T/APrUe7FvegajaqsMuQJEA+kn1Hpn0rOM6VFu
+Pkz7WwsJJktmZ7SY7owR+EnuKpLqJoWMTEMpOcelam/iljjICFXT6sY4I9qzl2zyKMj6v0rS
+MrYqoiXEHiQmXOFGAx7kCofzhtgRanEjDaZfNR5hfTPrU0guoX1GCp86gSWjhymGHmMiunG1
+5OXNBt6InOOO1CKxIVQSxOAB51Y/KQmDLhlctjPkv/pXERSW5IUYkI4b0HtWqmmYvFJE5bbS
+7MIZJprmRgp+UjIBWTsQ7+Qz2A5+1WHUPUt1dW1joNssVtp2muZXt7YbY2um/HKfNiBhQWJI
+A96pLPdA5mA+pPwff1qVHp6SW7PJOFZ+ygZJPqfSpckhrE2i0ttZvHtlRZJHM12sUKKCxK8b
+sD7cfma9f6b6ksOhrXUOodb+S1G9sY4pI7GRi8YlMmYUfH4ir4fYDgFct2ArxNb5khA08/Lp
+AQoIP1keZz5ZPpTJL65u4pLeeY7ZJFk/JRwB+ZNK0Htt7PcOnOtxe2t5ryXM8lzI/hSXUp3S
+T3Up3Xc7N5kx4Qf8+BitfqHVssnUeuamLkRtZ2MWiaen4vAjKb3IHkWkfj/lFfOzdQ3GjW8N
+tG4QWeRBCvbdkMZH9ST/AJCpOia/qN0zf760rSO90zM/PzOOGb19vIYoZCjs+hZevUtbe+1L
+S7cvb2tkNF0yNsjx/AOJMsP4TIzO59qmdA9UR6LquoXRvLSf5ZfGimuk51S+aMEyEfw28a/h
+BwMAE968Sv7xIdHg6UtLxnnOn/LTZY74o5HMjkD+aRjlvRQo86mafrFnb3EepXEyQQOyxfUN
+7M4woyvZuOw/CO5zik6QcLPpfozUm1/QTtuJorWWR4pLy6b65LXcHnupF42CR8qieYXjitxb
+9d6JNHa3EvzMdpfSNHp8IT95eOi/VITnhEXBYngblA5r5S6q+Jm2JNG0wzW2jWHhxQQNLvn1
+CUn6riYj33YHpgDFel9I65bNqy61chlj0yGKwRCS8YuZiny9tCPN1yZpv8Xhr61OnoTg0rPe
+pTJbx2yXO2K91AhooAf7pD+BT/jxkn0FdLSKC7eUzF1sLGD94VPMkrsFCj3IyB+dZGDqKxk6
+2uYRdl7XShJCjPJ4jxKi/VvP/iMxOT6nFa3SDJeaLaEYi/aN7JcKueEjQBA5/wCUlsepNTxo
+uMtFqtoL+fTYRuke4lhFyG7ACQ+HGPb8TH2AFWlq0t31O4slVVSZQzudqAqxeR2P6sf+UVG6
+ZP7S1PTJ1aOOKXUmjtEbhpBEpyx/wqByfvV10ZaWd/pVxrNzu/Z90ksvij6d9vuLM/2ZVx/1
+Y86KstSouNMghXTS9vbtCk8sSRq4wwBywLe+36j96fpts0VpqOoROQ07LHFxznnOPsueferO
+zgkuLEXF4pRnNxcSrGu4q7KFVFHmVU4A9aeNNk0+xstPmZf2mPrmgQhlt1k5Tew4LBRkgdvO
+q6EmUctn41rKDAEjuYkTZn8IVsgmq690uN7O4uEQ/LwmKVCV4ZTlXUerDvj0rZ6np0MGmzXM
+QbEYEk8rHGxSeB9yB296qtVhNqE090Mb/LFypGRG8nZSO2e1RNX2a45eUed6jCI7GeYqAovp
+YE/xFMLn9aq4cieJmXAV0lHrlXBBHvkV6Dreki7MVt4aRxTXrWg//wBhhvZv/lGfvWMNs5Vr
+dwBJYSxiT1IfJP6ED9a5lFpnXyTR0i1C6PUdv1CXBu2naWRyOXchkYt670Yg/eva/hzP4HQe
+nXMSFZorie2D4yEPqM9iy8frXjCWK+MpeQqhYsz99q+w8z3r0H4fahJKL3R1ka2jvVM8Sb8i
+N4gBFwe2cAk+5roxvds5MqtaPQbZxDqMUUCHwrmPxYgBj95vKtn7DvWkwqjbFyGHgx4HB2jn
+H61ToMW7au4DywQTSIpOPqlcEj8sGp0N1ug0q7tRlb62kFqnn9J+psf+WtaoxUrL5EaFROOF
+aPwy3qxwMj7AU6ymRNOt2H1K8byH0Khsf6Vleo9cnQ23T9q+EsrYxzMnJeYjK8+uT/lWrjg8
+LToYMYAgWHHocjcP1NLzofhNljcTN8ihAG9izY9NqFsfpS25cQkg4li8N2z/AIlyVp9oYrnC
+xHlXLZI8mXaP6A1Hs5InnuAzEx3LsjZ/hKrgn8qom/BBvle70C4hts+PaTtPAc/3jDBK/mP8
+q+VfiXp1r0z1SlzA/wAhpPUl093p9wTiLTNZf8ayZ7Wt2oww/hlXcO5r65MckU21SFaIggY4
+YjHP6V5N8YugE6i6e1TS7a2SUXMbXVkhA+mZTuaIZ4PY4HvU5I8kbYJqM6+zxDSb9HjJWNoV
+DmKaFjzBJ5qf64PmKv7aPxZFXHY/0rzXSri70W6j+fkZ7abZE7uPLOEYn1HYk16jo+2W5sju
+yshdXIOQApwf0rjXyO6fx2brolmjngZWK/MI9qynkMAdyn7jH9a9K012W8gh28XC+Kn/AC+d
+ebdLs8UfhIV8exvmCEjujDIOPTkivTLFl/3R9v1JEAhHkwPI/Q1041o4s0tmntAFUxrkFTkH
+yPrS68DbaVcXyxNKLZfFKIuWKjuQPPHei1B35Uk4JxVhOWa0lCKSwQkD1xXZDo5G9mI0frCz
+1O0ijjsryJ45wZHljAjaPHGxgfqyefatEHPhNMwyQPpqki061eCOKwhSGIPuREGFXJycDyq9
+mjKR+Go5AwfuazjNy7NZRjHop7iNmhXPG5u49fWqa8c26mRtu4OGHvgf+tauaEBQp42rWS6s
+QwpbtGvZm49QBzVt6smO3RQQiMXDWTtjxYy6cc5B8vz7UMzvc3SogAa3AUBcDwfxlR+ff71w
+1F5I9QLRj95FGqx54yc5JHrxn9K72N2t9Y3UsJTxltGQDzDFgOPyrkyPwd0Ps6md7Jlv3geY
+BV3Be4X+Ic+Yzn86sprFHssW5N9bxBXjdeXeIjII9x/mKLO3F/ZQtDLtmlDLLF/GwBwWT/EO
++B5Cpug4eCe1nYQ32mzNbyRLgfV+JWUfysDkY96hbHKXlHXSjKsSrK/zNpKu+3uFH1gfyuPI
+iry1kdHEqnep43eY/wC4qsjiW3mzEpQP9ZAH07j7e9WMGw87Np80NXExm7L61ClcgcN3FdpE
+VlZHJ2suDj/Oo1gcRqF7j/KpUvHngMP610wVo5W9kSZTsBY8mqTqDUrbRdLk1LUFujZQDNxP
+bR+I9snnKyD6io7kjJA5xVzJOsEZDc7FJwBksg8wPUedeIfFL4t9BaZcT9MdQ9V/s1bzfbyS
+29x4dxbgqDHPHkjIz/EuR5HBFb44eWYZJ+EeZ/2h/jje9N381i0csUWgvHaag7yJ40tndJvS
+SAPmC7tnG0tG+NwP0MjrmvnHrf4g6jILrV+hLfRdW1e/iWS3sJ9MV475MEstvPtEruAP/d5W
+Ei4IDOMGrb4iRdXPpUen6d1v/tR4Nvc2tvFdxrbTaxo0rlpbcKx8K7MbfVhcOFb8IxmvmjVU
+6Z6du5rXTNc17piCZI5vCs/Fkt1nBP44n5idD/KzCsJ3OXyOnFHhCl2ZPqT4n2/WtzPJ1F0t
+caZHGQsiaFdvaxxMODmGXeM+xIqnubjonWbX5Cz128shCF8KbVbUb3z3RmhJyM9iRWp6o1uf
+qeI6h1lpnT+oXAC7Nd0+ARTXLDgC6jUgOSP4yocd8msLb2+oambqwttC01BGhnc21uGlES8s
+VYngAdzTVLSG07s4XmkajcRm204abcxxglmsLlTkDzYHB/pWc2O27bGWEffaM4HrVrqIvLfT
+0t20gWtrJIWE2zJlYeXieYHoKqFleNt0blCOMjimiJaG4HcZzXRRt5eLP3OK5biTnPNBYnJP
+JNMlaL7R7jpBorxNfttYifwT8o1jNGw8XyEiuOU+xz96q5JIuRFK7J5BhUTJ70uaHtUNSaOn
+iuGJ3cmld1lcyFVTPdUGB+Vca72l3NZ3UN5AVEsDrIhZQwyDkZB4I9jQKxhZQxMQIHlnk1It
+42m3OW3MBgbjT7tWvZZdThtUihlkOUjH0Rsedo9B3wPSuyaVqUcCSyW5gjY5WSY7Af15NRJm
+mOO7Y7TI03mSUFlP08V6F0ZpsseuQrbTPsaNiD+WaylvbW3gx7ZvElxuJjX6P1rZdMk2No2p
+Mx8Qr4ceewJ/9K8/Pkuz0sUKo9Ulu9RvLsia2ns28RmgDRMqiLC7wMjntn8zV7Fb2tqxuPCN
+x48QZXLYUDPOPU/esToOs31vFHDJezyYTaEnmZ1b1X6s4B7ce1b+01fQotVghXUDbQXE6x2o
+kCiSBjENjNkFCC+VORXnvs9FPRcWGqpfppvUGnsh1PTISiZTJngDYKtn8YUnGfTjNVus9Gwa
+vo99b9PwySGz1EaldWFvmR1i2HdLCDy0ajIkXkqBkZFVdnrUtjFew3uirZano0huzFaSGG4s
+pUP/AL3YyEkBSQRJbvujbvxW713XtLv9HtIY7C80/qe2D3MuoWSiNdRWQrLBN4Sn91NGTuBQ
+7XViCorWCXZE5Po8fs9Xtbt57X949pLEJ2jjcGRQ38caMQrqc8geXI5q6nsbjVtaOp6VqW2e
+URKscTFTIsagDwmJysygZEb8MPM1x1zUNN1nU5J73Tobe+uG+uZI0RJrpmwSEGFhDd+PpLHs
+M1Iijkuo/mrrw7V7cmBpFtzA8ciYPhyqeVZcggkZweCRWXHZpyNJ1b8VurrjVrXqo3s9tqd9
+Ellr0W9ntNXeFh8vNLE3KPsCqVPKkZVq4T9VWMeoHqe1sofB1OUwXcEiCWBjt/eRTIf1z3HB
+HrVJqovoJ5rW4nBW4KFJ2bfGzjkxy/yN/Kw4/WqR9P8ACluXurpE025K3cyK3PiLk4/wt5fY
+06kT8T0L/aCSJlubzUL82c+YopIz4k0ZkwrFWPLjYPqU53BQe/NWnSB01Aml69etPDfw3UWn
+6lYgmOaUxu0Fxg+QdQkqcFSQcVjeltb6e6jlaaytrhryO3MV/osrjM8B/wCNZydvGjIDhMZ4
+OCQTS6pNe9MavFBBtu7Hw1+d2xGE/MvykiHP7qQADafM5B4rVJ9syk60jQl9RgWxuZobiW4s
+plaaS0QNLaSqA7ZH4l4yQeQRuBpenuoB05rN/wBJal8tNp3UUMlms7xbWtJwhe1ulI7LtfY4
+80b2FXln+2uuLu3m0TVLS0164szCz3sWyPVFiy0KSFeEnLApvHnjPBqg64sIbjQ9C67uNLuL
+Y3Ns9lPE0hhbTby3cKk0rAY2Es0ZPkCMjGKcbW0J1LTPK+sdH1DRbHWNTu9PuQmnahYQpLEQ
+9vLFcBhgOOM7gCucDG4dxVTfC2u+ltK1JdSNk91qN3pjPcbWhtWiRXSOYD603qWYNgjg+Qrf
+apLf6Jbpd395Ne9Na9Bd6a7tDvEilA0thcRdo3VtsiOv1AESp51R6m/Sb9NdM6T1VFb3vTkV
+1dwXOr2I/wB/tZJkAikaRRmRUIU4cc7XHG6vQxRVWcOVtaMBpusa/rWiP0jply93Z2t1PqXy
+ET74VkERDXCKew2A524zgEgmsfe6lqdrqcUrzmDUYIzhozgspBU49yD2869I6S0HSejel9Q+
+IWqXUOsR22p/sWKKOEiIyzQM0MpbPDI6kvGQMrtIPNUPVvQupXUGndU3tvOLTVmuRb3XhFEu
+GiVS3hnswBIB29vOtU6owq1oxmo3Nx+ybOLw38AyC4XeCcjG3OT+dcr2a4vIktZl/dRbhGW7
+HnOP1q6fSdQh0SC4nhljsrvxU0+d1+ibZ+NVPngnn0Jq51fQtK1jTxcWmLS4WwtS1oH3bJVU
+iSYN7sBle4zmnYlE83tJkTUIyw3KzeG6keR4NTdVsLnT764juI2RQAVOOCh7EUj24S7tmkaP
+95IB4vYcHua9BfoHrTqTpWLWrfT0vLe/1aLp+ymjnjLG7nHiQwMpbKFgpAJGM+dJtWOKpWzM
+dHTQLqtlbXQZreWVcNjB25rcdJ2/zN1FDJlY5bx97fy4JJz+lUGlaJd6fqM+l39pJaajpW60
+e3nTEkNzC5EsbDyYEEVvNE0pZ9Z1KRAFlv5GuokziNYjDiRR6Nv7CuPP5OzBuj0K+0mSyvBb
+XULQGa0QxkkEqzNnLehxz9jV1FoWmQ3o1uzUxwXdp8vJErfQkyt+8IHkCNpHPrUTqKaRdeeL
+UyzCHYqKxAOzwVCgn05/pTbyye3gsbSSXMGpwwXyGPIBzkGNvQjawP5VwuOjtTIt7a6o+uw3
+ckKPCJCN6NlXQDCsPPI9KvpLudLS505L1obTUNvzYjYgOQQylx5gMAQfLJqmup3lj1C2gilg
+SxWS6ijVslmUbgc98bcnA9KtY72C8j0q6trRYmureP5go5ImmUHfLj+EMCAR2+nNQ4UWpXoO
+moDGg1jT5o7WSzlS5SZ24SVWBXPsWAFX2qa/d6zr95qW1NK1LqPRpIru6tfoC3QDxySALxh1
+ZQwx5VT6PbzW3SGv3YIb5O7tS7cFEieRlJPsSwHscVJtZ1uU0+ziRnW4CzQrIPrWTBDEEc4P
+6HinBuPQppT7InTzmPWn0fVr1m0yO1Z4YxGZPAuPkjbyKV7hCdpyPQHyr54tNXv7q1SK6uDL
+bWEjziNyCCzDDHPnmvqLT57VdaeU6dPPqmqafcWFlGkmzwL/AICO4I+pSgYYHmRXiHVfwsv+
+lNCg6ttHtbiwiurKx1ZVn/eW810z+E2wgExt4cik/wALrg969TBLkqPKzpRlbMFremSWumWt
+qW2G2T52EMfxIxx3FY+UXM158/dsX/fYds/hbGcV6N11AHsunJxmNZtIjhc7uWYTOMY+wrzP
+UUWLUbmaA/SspVh/h7ZrpWznbslNciRVv57hXlDY2tncQKgyXPzFwZoQRGOVz5H0qPcn6JH3
+MFUAoPXJqXpkET/MI3CKFZfY4okgizY9IXF1eJ4UTM0MKmWRWYDbg8nk8/lWmja2eGSBx4Tz
+RO6S9/rHZT6Zqi6B1KxsrDqSDUdAivV1LRJrOxmdwnyd9vjaKdcg5ICsCBjIbvV5a273hWKN
+1FwRvVQcg+o9q4s0aO7DK7R6Lq3UD9XRR3M9rbRSTWNpA6QKEUvDGEzjtkgZPvWY0K2uLbXX
+V+BCCCuPywatemb+yk6H1dZbNBqFlJG4kUHDRSOVYHPAZcBgR3FGn2Ml9f3cVveLbTXkKhJH
+4wDj6vfGBXHI64/ou9Nmkvo3CoyhL2O3lkPO4EYx9x/pUjVrFFgawBJW6zEyt6jlZB/lUvxE
+uku70W6QSXN1azyQx/hEgTZIR7FlDfnULXpp7eydpiRGsxikbAJ+o5GPTB8xRBNOhTerM91L
+A0dlAtqUlk03LyjHO08EEVmn6k139lw9PtqXjaRbzrOtsYkyGGQPrA38AkYzjtWtuRBqt1cx
+XV1Ek0+l3BWWNxiWVIy0e4erAEYryT5yZLWO5GeQCfUZrpeOkc/O3Rf31yIpsMZBAzk7jzsz
+2/pRiz//AIrB+p/7VTXnUUN/s2LsLQIsoY/xrxkfcYqF85H/ADf1qOBXI1XxC6a0qO08fTb6
+G6urWfwNQsZLRrW+sWx9IkU8SKR/xE4JrxDWraG0vD8vKWjf6gCCCh9D/wB6+ieqjqT6Y51Z
+jcz2cJWyeaIm4MRBOwtj60X3PArw/qDT21B4Jba2AlZPD2xnIyBnn35rq9PkRzepxtrXZnFe
+J0TxEyynH3FWDgCMTJI0mwASLn6l981WKDFLtlQ5B2suMGrPTJoEuZHvjMVEZyIsbyw7d+Pv
+7V1yRxRY5pLm+kWZrwvJtCq7/iOOByK13R3UGoabI1hqM8lyk67YkmkLpF9l/wBKobvSLxdL
+/a1nbb9IecW63MeSiXDKW8I55BwM+nFd+nf2vqF1HFp0MNxeQozoHkVMqiktksQM4H3NZTVr
+ZtBtdHsGi2931FK1zZdMSam0KiOZrO5FsWRvXe6kjjsM9qt4NElv9G1fQbjp/UrfV7K4/aGl
+OY1vfpKYuLFmiJ2jAWVHYAKQwJ5rybS59E1l7a71G3kgvFdi0Ly4jlUL/AWB2SZ8j9J7Veab
+cxQXkq2paeMQSPFtjFvJIAMlWKYPseea5ZRXR0xk7tmm0rTbnXLaF9Xs779kq/g3dzCwDW+R
+yyiQhW45AznIqJfdC3fRiS6pFq1xqunkyRW8UsPgG7TPdo2yrbRglQc88VBk1j527i02HqTT
+72WSMSeCiyLbRSY4iXfgM4HBGO/YmuWndZS6VMdN1OzvZYvGWaXT4JcRz7DlfxAlORnOKmMW
+nSLlJGa6id9QLFrYOQgAhaRS0IHG0nufb2rzu9ADlQoHJ4x2r1DrHXuktWnl1TQdHuNKvZ2L
+yIZFliQ+qkAEn1zxXnWog3DtO5LOxyTjGfeurE6ZzZVyRUUVKNhcABgi4K7vxDge/pXFkVcf
+WGJ8h5V1nC012MBIpQc9wDRldhXZ9Wc7s0gPPFAh+Bgc8H+lTLPTtRkKzW0ciyAF4zgjfgZO
+09iQPKudj8uZQLy2llhJ+rwjhx9icj9a9M6ZvI9PX9k9Kdd2JsJyJxpHUcLW6NOUKnbIMxhh
+2D7kBOM1Em1pGsIqW2UvSnVutaBeR6ppE/y85BWVSMpIp4ZHU8OjDIKngg1d2mpdOPqM1zo1
+kdOlkctLp7ZeFM9wh77PTPIrjPdWWo/Lw6lpUem6kxwzEeGsuDgMrAbGH+dW+h9BRX9+JLfq
+3R7G6BVQLjxHLqTyMRgk4715+SvJ6OO/BOfTtVsI0lS0nitnHjRR3ADREtzjvxn1GDUtNM0f
+VrYXtkW0+/iI8fSzN4iyAcs8JPJX1QnPpmmaPBPp2pXFhq82qpbTs0dwLSy+YaZAfxKsnn2I
+4BGautQ061065/8AZWmzWzFR4UerYLzIfIhOEb2zkVzWkdKVmdmggacyafvtHuRhouwR181/
+w+gPbtUW2tdQ127e2axY3kcZeZk+iN1AzvZuyAdySa2+taEt1aW+pWmnvEbgjFlu8W4Q7fxZ
+7Bc5HPOMVSxau1tBm7gvLaS0V1SKOYBJC2QyshB3KRwQaQ6vZe6R8UZ9Ti0tdf1e4v722tVs
+pdWa1CSiMH6YpWQ7ruJBgKzYdedpNaXqjrPVLu+nvbnWYpGkKJFc2Ua26OqnKMuxV3uRkFm+
+o/xc15Lpum3UPUNra/INYpcSJI8Lg7HT8Q2k8H7ZzXoiPeWRTx9MgvLWUmK5gZRcW00WfxBQ
+dyumc5GCMedacjPiYHqOCHra8Z7NY7HU4E3TR4CR3WD+MAYAcjv61Ux2eoR2RiEsil5FSdFz
+kKchcjzA7/nXo/UnQGq6foK9cwaFcXGhyXj6VDrdo/jWNxcqATFvUbkfaQ22QKSMkZrGSSO7
+G4jkCS7hvxgq6+YznvxVUR5M/fX2q6LaWlhdJcC0mnlla1ckIzqQrMAe2Rjkd6zepQwR73tH
+JQncATyM+Rr0C504azpkEnUuuw6dY6dlLLxFMs8iMclEHmM88nAz3xWD102cBkFt4nhk5XeQ
+SR+XFXFOxSqiqmt9JkjU3N3cQzZwdkIkUL+oNVzGLTL5LmynS6VSHQyR/SSDxlTwcHyPFcmm
+/e5ckA8ZrpcRIY0SKQSHJ+pRxXdFcaTOGdT6G32o3ms6jNqeq3bTXN1IZJ5nHJJ88Dt9hTxb
+PbziCeF/FU4XPIb0xXFbWZHAaNgG4z5Ve21k19apZXd4gFtkwXC87f8AA3n9qqUlREIO+h9s
+ShMUbBWC4kjzwRUrSbiWFgittKnPNTYbSFJJRcQRzR3FupWcfjimXz91YZBH2qH8nPbTFXw8
+LLuV+5K+Vcc9nZDo1duUuNOlkVwwOGkTs0b54x6gjzp+laRqV7d29npthPJPfyLBbKYyFuZG
+ICojNhS2SBjPnUDpqwvtX1OHT9ItbieaQELEi7ncAZOFHJxjOBzxXW8n1draLTp9QvLmwhLi
+C2a4doYtx+rYmfoJPfABzXPqzVJ0JrOm6vpd3daVqVjcWdzZytBc21zEYpYJAcMjK3KkHyrL
+3GnSNN8ukTkv+DFen6B1NoGqTW+l/FxdQvLB1WGPWbP97qljGAQvDkLcxrx9Dndj8LeVVHVn
+T8Oha1e6bpPUWma5Bauoi1PTWc208RUFXXeAwJBGVYAqcg9qpfHaY+9GCfTJIGxKpMiZ3Jjn
+8q6RpIV8OZNysPoPpV5DDc3sioEWSRMje7KuBj1NSVs7RoWfw2+kDLjkA+4o9xhwMjNbqsrR
+kFo2/oaSTTXdI2Cgn/KtJf6G7wC5tJkcAfUqnkVwtbdplFuzjDcNx2q1k0S4bM+LbY+1lwwO
+DmpCR+FbtGpwXGM+1dL6D5S58ItlCMq2O4pYiCmGGcNihyfY1FdFOsLQzEHBU966qFgKyAcg
+5TI7VPuoDtaQoBsPp3FRLjCJgnnBK1rGblRk4KKKe5kaSVnZiSTkknvU/SLr9mK1+WAkY7YV
+xkk+Zx6VA2DB3YAX6i3n9q5SyF23enAHoK7ErVHny07NJp+vpaXoukiJZtzPKxyzMRjk/nUn
+pOeV7+4d7jc9vBLOqSN9J2qTgZ86yCOwPDEfY1f9Pt4sdwrKuWTYHPBGaznGkaQdmj043F22
+mSwRCScuqRNKfpacfxN7L+I/avaNHuojc9PaNprOtpYWs2rOXPDBclrt/Tc5DD1LKP4a8e0K
+a3sdSgnuciLwpIbuEpuxARiVkH8zjCg+RJq2m6h17qCWbpnRSIr/AKpnT9oGMgLBDH/c2wbu
+IolG5/Uj2rHpmrjaPZuhdYMVhPe2jpI1xqaafp9vJyLmXG9tx9ATvbn+ACvbunOprfWeoNO0
+OyaQaRpMMXjzMMNLKS7xRbe5L7Wc+igZ7183abc6RBotnc6Xb/OWOn3H7G0WNOPmZSMy3Dn+
+dhvfjtuX0rUdJ6/qKzXck6zpLZ6Bf3Npa2xBcXFwot0mlzyz7ZG2rwTkdhVXejFwrZ9D6bqT
+y2NheWc6pca8sml6bgYEFsTi7uB+RcA+1bm/nubuN+jNIkhs7Nba0sGndhHDYQ/+8z7yeP3d
+rDBnz3S47mvJ7XVdF0jrC+cLNLo/w/sE0sQohJkls4AoiX3mujMWP8oPoKsbHU7rWJf2XqGo
+tu1i4hivCnPi3FxKrXDgduSyLz2VAKbVaEnez35tatrLp621W0iuJLe9SEaVBIp+avGk+pXZ
+O4L9wvfBBNLsbTrmXTrmdZtRXdJqc8RBSKUgbolP8RQYTjuxasPqHXgl+JGu6tZsJ10Od9I0
+C3i+lWeNvCEijyJC5LdwBxWm6fuodMtLbUTDFf3eEkggVTsmnJ+jP/ww2XcnuqYHJpeRpaNd
+1JbQWl5ovTs6eILYDVdRhHLNMR+4if7ZBI9qr7nTp9S1lXDqzyuqxqR255kY/ma56NbzrM80
+9zLqGo6jeOZrpyP305+qSRsdkQEYA4BKr5VPvYY2N1aiQyFbaWZgPxBSNqKcdsnnH2pv5bHF
+8dGXuNkp0jVoyWi1HW7qW3DDGE5VHx7qhNZa70meLU7e4aLH7ZtpZEPq0eHOR/ytn7V6Lqdo
+JTp9pbRBRbW++MAZEbY2pj7gtVNqUNqNSsF2EuZ5rK1Y/wAJFuytj2IBH5VhJbOmEjFXyAWx
+lxhWUOPsKkW081hNBewttdc8+zLg/bua6TwO1l8ptDGJFiB9Tj/uKYsXjwMuMlQFOe+cVSId
+Ps9f6T1d9Z6PtL26ZfFizYXQXjdJ8yNjEe8TZqLBfsv+yeoRu6NP07JptrGGwEeW7kklmPuI
+YwM+RYVlPhVqsUOrXOmznFvrNrEYNx4SeNCqH7scj74rs13NFBpM8TFZrbR10uMn+BvHdpGx
+64x+lbXpM5kmpUbnRLaK4me+mkAiGpx21qvc3DgbnwfP6iq/lW0iu457ia2imEhtdiMR2Mx3
+O/8AUgflWN1WdOn+n7CK3YvNpYUW4Xu19ONqt77VaR/uBTOn7z9jdL+LI26We6kKjuwBKxoC
+ffk/ehfHRT+WzeaBfmKwvb6dSVfwoYBn+JVK8fmaj2MjSXmrWUrDwmlvdh9MrEBg/bdVHaXy
+2tpYWG4BLaaV5SexVM7j+pA/KpekSMdSmhfJChxu8s4IP9MUX0FdmjSVlwJc+NEAre4Axn9B
+Tbi2jukFncgKjsAGHdW7q4Pr2pkkomKMpzIIVLDyPlx+hqUkZWKFguRwrE+Q8v8AtWiIf2eA
+fET4bwJqV7E2nq0N+Wbw8YUO34lHpu7j3rH9N215px/ZF1I8klnvEcjrtd028bx/MMAE+fev
+pXrTTPnLKS5wGKckHy968lu7C1vtUtFLCOVgTG6jll7Ee4HmPKsMmDi7R1Y8/JVIv+iLKK6g
+/aLBVaSOPKZ8jwG/zra2EL26XkMshzaXhGT/AMuKo+l7J47P5GBMSRwNbyqf5UcsGHqDkYNa
+2RYJ2kO5cXM31H/FgD/MVrCFLZz5J3LRf2o2RxEc/Tzx51LaUCBihIYrwffzqot9QhKPsc/u
+pDBJn+F1FT497o4PPhjd962bSRik32VElm9teiKMbYyF2qKmTTCRX2/h8UKPtUrU4wqwyoMk
+YINUckzql8sf4gg8Ie+P/rrH8Wbt8uyZdSn+Hkv2GKxXV2piFGkKAvFGkSDyBYnn/OtZNMsl
+xbguVjMTSMB37cCvLviXey2WiTTxDEr3EBTJ7guEH9DTlKohjVySKvXNVupjpt5bON1tO0cg
+XnDRjK598E59qTTrqJOo0kibNoWlP4sBSELIT65OF/MVQ2lyJR1DEzcWs73pbk/wujH/AO5q
+Vo6vcalpFhnnWC9pExHAmjiLqD99p/SuOUrdnowjSaPRbP5XWNKgt45ZYXuSHsJ0bbIkqkOY
+8+UyYOP5lrRhxc3aXc6J4zL4E5jXAZs/Q+PtXmFpda7ptq8dnapMS4Nxp7t4YukB4WOQ/wBz
+MDzHJ5MNrcE1seneqtI6ks5b/RdVS/ZWa3cMBFNHdLx4U8R5hl3DawOBuGRwa0hvswnFx6NP
+E6H+8OVVtrbTyPcVNiAU4ByO2QM8VWaS8Oo20eoxBojIuHU4IEg4ZT6ENkEe1W9vGAQQNvnx
+2BrRIwkydYuyMI+CB6eY9a7ahqEFhaPc3k6xQwjc0rfhVfVvQDzPlSW8YGGxkgd/Wsj8ROpL
+fTbWTRGuDDe3FvLNZnblZvDUvIgPbIQMSp7rnv2rqxqlbOWctlB1/wBXXJjm0vStQh0/WBMr
+6JqBVpLVL9AXSCYr/DKgKjyYOPvXxb8TOpfiTdj/AGk6a6oENhZIblLaRYtSspw/ePwHjbwC
+sodAGA5Aya3d91xYdN+Kr9W2FhpuoRx2tj82zfuUicSHTpvEIDQKHLW8gw6ggBiowKnr+bo/
+qG7vup+g9RtpbrUiHa2dxDdWs77fFk8ESbHViBKNpYctxk1jly81pnRgw8XctnzxqvxC6l6g
+sY0m0HRbq+E6fO2VxYNFC+0/RKqIUWGVB9PiRgEgjPas9qGpS6jdaqjXmgrBawq89jfh4503
+ZCG2u4t2592FKuPTOc5re9WdNatIx1DW9W6ba3uj4Ru9Qu5wzHgH6Yw208gnIAFeZX/w/wBS
+i1GdbK70qeJEZ5ri3mZYgqfUTkqB6cedYxz72zreDWkef6zY31zGkS6nILmdTOLKaJEZlHG5
+JF+mT8ufasne/tiz0ObS9M0e5gikYHU7lSWabn6Eb+VB6eZ5PlW5u9A6fsHa0t9PnkEBVory
+S4dQ2Rl2WPyBPYe1ZfWtQtbmyXSLeW20xIWJlmVGd7ls/ikcnPA4AAAq1li2ZSxvyYSYzxL8
+vIXVQd3hknAPrioxJJ5qw1KSzku3MEKwooC4jYlXI43DdyM98e9V5rdHJLsVlKnBxn2pKXaS
+pYA4Hc0lMkDz5U5FLfamjOakRISjNtIXik3RUVyZzWIsTgHgZP2pGTbyOx5FaPp3TUvFebhp
+Iid8RH40PofWqzVbMR3ssdqjGNGO37VCmro0eJqNojWdy8L4DsFYgkA8ZHY4qz06xfWnkE05
+M+QF3tkN7VW2llNcMdiHCnk+hrbaboMdpp9vPARNdSy/jUnaBjtj17ms8s1Hrs2wQbW+jvBo
+s9lpMEyxld7ldxHf2q3S3ZbayuN4MFupLxrzl8/9sVHv5HBWFZSUAweeAfM1a9PNE0Be4XEJ
+bwjuHBHnXmZJtvZ6cII0OmIJEV/DD4USKc+Xn2q01e2iudMe5wDgogG7GMmqrR3OmlB38OXa
+i+qe9WVtd288MltL4paEtIQuNpGc4rla2dcdIvtViutb0LpzqGG9vIdWdGsLttgBa5j4DJJ+
+EFk2cN3YHIqx1Hqq31m7WaeK4hvRb2iSSoyxESxR7CQgGFyFBOD3zWN6U1CV11Czha5glmw8
+n15V2BO1yn4TwcfatXdC51Ozgt75Y/EKFLW5SMK0TgZKsBw6Hz8xnIraLvoxaozySxXmsCw3
+C4jvQ0RecjaWYcbs9wW4+5pTrumxWg07qaWWETSCzmtfAYz/AC4BDos5Od64G1XB47ECq+5n
+t7lRpl1DardK2YfmJTAkrZ5VZPw9+Rkioev69d36tpuqaZplvP42ROluGn8QDAzLuOR7jvTj
+CxSyV0eidFy6Z1TcL0/e9W2IVbOURXN7ZPC180fMEM0q7hE+MIJ2UoMDfgfUK/U9Amsb6/6f
+12C/tJInWKaynt1EtvcAco/OF3A/iBKMMFSRXmVrqdxbSLPBevHdw52tvKMG7HBXtxnI7EHF
+afSPizqaWP7D1RrN7yIQLp95K+6SBI35i/8AiRlMrsfIXgritqbVMz5RT7I9zZx6Jqk9tdaN
+eQXVhIjBvmTGY1/hcYXkf4hW2k1bUtetbbVLS1VNSFs0FzhlkOpwKSyybclXkjHGBhiOcE1c
+T3umavoceuaEIFlid4pA2WNkcZ2R5ycDIbB8s49Kx1vcxx3SQXV/4YulKFtvhta3GfpuAFGM
+o/IPmDzUpNaKlT2i16c6h1C6kt4bvWI7XTri4itvmon8OKylkcCG55/CgfG8eQ58q9h0fX9T
+h1jW+gviKkUltNezi9ZU8UaTqcWYrndGMiWErh2UcOh3qcrXgNvrdlfa5e6X1toLfMXeY9R+
+WHgM9yo+i5jT8GXHJxgMa0N3rur9Oata9RaBql1bzJHbkXDIVmEsabUkOcgnaMHyPI86Lomr
+KTX16m6T8To/XH/Z9uLr5e6VsTRi4t2IgmGD+JA4UMDlo3xyKo72zveo+j7G8sLW1s55ZG0+
+e0QhRdX8I3O8fOQzxgPtbgsCF54raa71ToPVcOoXGtdMJLHfQGF1t5NhjaSPb4kROcOrgOgP
+A5XtisdpuiaRrFl8vNLcQa/pMSyLGpRbS/x+J95I8OQp2Hm6jFdOPJZzzjozOm6xqGkx3Wle
+At1Zajps8Utq/wBMcssUgdXIP8YYHDdxn8q9O1670rXvhF0bp1i92bfT9Nv+oIRKo/3RvG2u
+EA7CSNPrHmVBFYbrSaKbp7Ruolk3XsEc9peTlRi4RjujkcDtKPwsccjaTzWj6Slvv2pp2k6Z
+Z+LAdKtrNTsGIn8MzEc8EEbsjzzSyPqSFBJtxMjrL6m2j6H0BqLQLp3Tsl7qFmFQb3nv/Ddz
+vHdCiR7R5ZNZ3pC1m1Xqy60mS48GX/eIhKw+lTjjPtjivUPiRpUWmXcE2l2Nu5vNKivLSKM5
+VHAUBDnnheMetZ2zsINO6gu9VFsF+auY7oBV/uyykMgJ8snP5VrKejGMNmE17pm5bXLLSbiz
+S2UyNZFoVJDFSd0uPNvX1rTdNWOtWNtcdMXDosN9NFp127RB1uVQmS2mjY8qyMcZBzhsVpLi
+G61NjLJaRpPcXkV9DMFPiR7IzE6qc42twxzzkVFv9OlXWrYNc7ZrIq1yzk7AXGc5HYgY5qeV
+ori06M/0kZrPqq3s9WiRYL7UIhLNOclYicPJvPPOc7vavRrWxu9M1OWHVEZbiKWWxhy4JVYZ
+dpVvMHG0gnuDWQ606ZubZLq2ZJItR0rFvdiY/ggzlSD6EsMHsQRXpOr9Pan1bFYdWWbRtqWo
+rai6sY/plnLRYS4TyIYoUPowGawy3Lo3w/F7O/WmsjqDUb/XPlktodQtERIowSI5kAB5PrtH
+6mrW+kaw0e21e45SFN0RByBHIgLfmrCqqxtXl0K9Ehjc2VvJO0DH94Xz9Kj9CD7ijqJ7PUeg
+7CC3vEN3NbyePCzgmOJimwMPX8Rz6CsYxbNpSUejTdKSaTbfFPR9Ku5vCj1+0GmmSeQJ8rcT
+ROscgJ+kDeVUg8Yc1UmH9m6fY2U9g9jqekXS29+pkJaNthG1fIxEjcv51mbfVLS96b0aDUIQ
+9/pF/tuDu/eSRIQUbPsMcf4RV5rdx851I+oXNxHHHNdQwSTLkh4VTekhHlnd5e9Vx+FCt8jX
+6DFeydDda6ZbWwl+ft4oJEGCVYSpMjqfJgEPHmD5VXxarp17qOg3l8rppgIsZDAMvFE5O2QA
+c7kc5+wNJ8M5b9tF6nbT3VWvJI8KD9QkUnBB9xx9jVTo6y6hYRNbbiEvirqx4CsP/wBLNc7j
+VUdEXtnot3ZarbQieHSZ5bzRtd02ee5wVlsL2C4EUsWPxBZY3RgG4IYV5N8cr9+l+oNa6Olh
+aex1C8vdLmYttKrFcpc2s2PUNv48txr2zTfiFaahrPxLh1tFuXvOn7a5lSebE0zWBRCyEfjL
+RBQ38RCKQcivn34qG01fXLbUrh2n/bOhQ6tbsCdiOTsyCefwqc55z516GBUuSPL9Q+cuMjx/
+rHqZr3V9InEQaDRrQ2kaZ/F+9Z13e/1VmrvxEuRdXKKvzLuSoHvyP6101doDdmPxGWLxC7MR
+yDjimyRTT6Np9/K6FIJykg3fXhzwQPMcV1o5mQpSoDw/iwvIPlU3TsOGCgAsAuf8qXVbC0st
+XnXSb1ry3mjTY8sewksoLKR6g8ZrnaSM134EEZAMeCvkWHeqaBFxa6pPHZfIFUjSG4L8Hlm4
+H516NpNjc29rYdVW8kVwPmmWeFQQ1u0YVwX/AMDgkZrzjTtMW+P1MyRxqXOBzlfKvcvgt05c
+9aaHrlpayWSzQ2s140Vy7Ri4jhiZ5FRgCBIEUsAeDiuPMkjsxu0VNvcQ29/rsEUbwW19b+Mk
+RbIVmbcFHrjsK0fRyaTeano+na1FJPFIVjyspjcA+Qb/ACrKWckkdgupQHxI2wysRztxnBB4
+/KtCkFxLILaaKSC9jEc8IKFGwRuUjIHB4INcE9TO6H4mrt9G1Gy01dQ/fGGW9ubD5hUDR+NG
+wJicZzG+0q65ADKTjkGs5r1xcSvfaU0iiRJPpUtlWIIORV4bl7iW5uoYgLm42fOJISsVyTyr
+tngMGzz78Vhuopvk7qPVLe8ju0mdklERIls7gZ3Qyqfb6lYZDL9q1ildozm9Uyln6wntUGjv
+pVhvilAg1AApcQRtkSRk/hkRs/xDKnsa88vdR+WWSyduY2ZRzmpfVuoFYQ0Z/eBtrjyx61hX
+uZHbLMWC8V2qDaRxtpOy2e/ZsMfpAPYedJ+0X96pluyEIznnlc8035qH/wCJ+tV7TF7kT6am
+ZdTE76XfyTzxqzxQNNsU8YIUE+fbFeadVaPDp+pXLW6NGjqjTxg/3cwH1KCO2M9quJdRa3vo
+7tbaOZon8GePP1AeTqQefvWrtrfp7q+waaa5kt9UigKkmEeFeRpnJk53LKvAyAdy8+Vebjm4
+s9GcVNUeI3fj2FlcXaCOS4JwsnhAsi+Z5/z8qyayAymSTOT5r5mtXfq2jdRy2dwjwru/eLJn
+w2z6E9gR59qrOpOm59FuRIgzZXK+JBJnOB5ocfxD/sa9iDTR4+WLT0ddE0fT9Rikjk6iNhdE
+qUilhcpIPUsvCn7ip03TGqR2japBa291ZwMEluFJIjY9i3pn17VQaT8zJdoluVYn6SD2P3ra
+dMXvUGi3Ukeg6pLb+NA0cwlhPhbT3VxzkH+nes8lp9mmOmuiLpCs5Ed1c28oZgUAYYU/etYb
+yWDTLu2WFRHdJHaTMmCdiSbwAfLLenfFc00/pXVFWRrWex1QKBJCieNbuw8wxwQCOas5NHY6
+XJa20EcgkUHekmefceRFcspHRGJj5Fgt5S+xhvGGZxkc+f396fc6nezQRRsf2jGqFFkkQ+Iq
++m5fq/XNW0umwsqCPdEsYCsSQwLevPlUK909bNQ8WqwyzAcfLsRikpopwZSzQREJFLZzQDG4
+Z8s+/wD3rvH0le3d7aLbBJmuWVY18RV3Z9c8AepPArjqdzqUt00uqahPK74yHbJI9Kr7zU53
+QyCQovYc+VawezKcdE3Wb3p7pe9vbW2sLXVdVVljhvPE32dqV/EY0xiZs8Bm+nzAPesTcSyy
+zPJM5aR2LMx8yfOnXLFpWYnOTXGu6KpHnTbbDmil3cYpKogmacLuSdYLFJGlfgKnJNXEukS3
+Bkjjntne2VR4fiA7s98epqktbiW2ZpIpGj3oYyVOCVPcVbdPX2mWLTfOLfSTPtSAQeH4ZBP1
+BwwyfLGMVlNPtHRjkkqZtOjdSPT9s9pqFimt2U2wS2k670jUH6vDz2b0Ix2r0mws/h7d7L7S
+dKtWiAaMwXkbzbww4O1SCjDOO5FecaRf20enyXDTfLs1wtskRAYPlck47gD1HrV/YWt40jXF
+t4MAUAs2QuQfTn2ry8k5J7PUxRTNbLeahDm3thYaarI0aXMc0ixJGB+Ejk54xyeabp82rzXA
+ngttPiKBVluNH/upiRncY5OUYj8WCQTzxUC21KWeLhBI27acLuOfLt61LOr3p8Nn8JDG4ZWe
+24GD2JHJHqK5+fhnTwa2jSfMyLItvbz+BeOo23EsJkWQg/wqoOSO1TTfyxaTPearqls19KHg
+UT6V4DRjg5LNhWHcAgZFZ+6u/l7qUW187QTO7q1vnwHY8ldvDJz2PkRzVTqunaZ1TPYyvp6/
+OGQJdxhS4m5+p1U8q+OTjv3q1JENNFxcWfR5t5rW+utXttTnQMJyhMew/wAIQuQCR2bAwKj2
+Nva2USzWskzMmELO6jIA7MFqiuY5Y4jDdzJNJbSFYZBndtz+Bj5445qNYLrqXN3qejzI93FC
+08iIVMzx8bisf/EA88cgUn+hp72aO4u7yKKS50/ULk2STx3Fza+KxgSZOEm8LO1iBwCwOAcd
+jUi565+Heq2Vzb3/AECI9feQyJ1LpDi3ZmLZYXFgwMMi+jR+Gw781ntO6l1CbUBLdzqbkReE
+P3QVXXHAK4A8yCDTuqpjfaHFdaDdyRyJPtu7JCDtTb+NfPAI7eVaY7WjPJXgpdS09NTDNeTX
+byMTulMeFX0x5Y9uMVhep9NsrG1jKX7zuxYE+GAFHlyDk5+wrtrN60UbRTXkqocqCpJzWRNy
+yArwR512YoXs48kqRFcclg2cUsTuAwB4xkjNNZs5GMAnNL4RxuLKB7mu1fs4G92izbV1ltIr
+SVQ4jYkSAfUBjG37edXGi6ZaXcVx4dzElwsQmt4SpzcnP1IGHAYDkA98GssqJuXdIMZ52+la
+XR9P2WwvrCwuLmcH6S7bUU+2POsZxSWjfHJy7La1eOWOKXaY/wB5s2t51PuLWSwO+e38WGTI
+Tk/T5lT6EVWR3hnPiLB4MzMd8Ug7N54NXmn6nc3NuUmXJXCc+Q9q48mjsgrLDpfVxps8Md9c
+xadbIzNb3ZjZnW4Aym11w8fP8Y/DTnvJtSkbdBb7nYuZFJBZickn1J75864SCOcqpiUwgfT4
+gwVPng11ZoLaHcz4LfhUAHGPMnyrFyvRrGJIa0tIghurd7jY38A5BPlXODUdMVLwnS5PGmkA
+VpJT9KAcjaOMn1NFvf8Ay81t9Z2zkLKMZAz5/lVtqNnpk8fyr6YLO9QmRpUPDnAGOTynn6jP
+es2zVJMoNQto/GEkQOzgqdowR71KFyPlt0FrpwlT6Wk+XKSEfcHB/MVOi02QKbZZGjmjYBd3
+0/qO2K6Xtq8KpNcQARt9DOExu/8AUUJjlGipVLa5Ko6QWLuMeKikop8iwHIBPfHaqua3ljnJ
+mgjSZSVJibKtjzB8we+atbpZ7SNoMMFYbycd17ZFVNrfRu8kcgDMg28/5iqsz1eyDqunG50+
+SeEEG2IZyecKxx+mar4LbMQYHg8NnyrQsN5eBMgXSNGQTxg+RqphjCssLdye/wBqvk6ohrZw
+vYRBmMjgdgfMVSXcLEgKeOea0Oq4OZR7A1UShcrFjlhkk1pjbRE6M7PwCM8Z7VGPJqZfSgyu
+sYAUe3eoXc16cOjy8tctDlGCM/eptnLK8iwQZLznYV8u/FQdxx3p8UzwvvRirAEAjyptWQnT
+NpFqUVvYyz2yMbwEwlu6iMfxD3zSac8un6NM1pJIb7VVeBCGw0cBOG/N2+n7A+tZ7TTPeXEd
+rCSFVDvPkFHLE1orJJLqdbuFCVUBIgPJR2rlyfE7ce4mxi1CKF9B6UtMC30ZQGkDcG5c7riU
+e/ZAfILxXpvwo1MDrC01m4nAae+Oq3iPj99b2mbgIM/wD5eJSTwdxFeKae3y9yrAjeRjJ7Ln
+ua3/AE/qi6c13BYMs97qely2s0r8iKGV1URqPLKjJPviojLY5QtUaiTrHUb6L5lpZFTULwwa
+dAMgXt7KRLczsM843IgH+LHFehdCfESC++K/Smhx3K/JW+oWlpOI9ri+uDcp4rBx2DyZwRwF
+Rcd68GuNXmjuX1qycg6asuk6EhGQJGJ+Zux6FdxwfUr/AC1a/C2ZOn+qtE1tCslzYX1pNbQl
+sRxRRzIWeQ+QPYH1Gaan8iXiXE+vuh4LfU9Tka/1IWtn81qV5ql4DtMVqk0j3Dg+wwgx3ZgK
+9B0TqObU4bzW2Jg8bBRHUKlpCVHhR8fifwwuVHCggclq+fPh51Qv7I1np2WOSZp7/wDZsksk
+oG63W5a8mGMfhb6M9uAPWvXtO1W4uAmnJiOOyt4pZJWGcXEzblAHmQu0458hVOkc+/J6Pb9X
+Wmni20zS2V76ceCXYENEo+t/ZTlmZz34Aq9ljm0ToKXUJYnbV7zTBeThvxh5CTAjD1+pXb04
+FYPoaK2l1iTUokL+BEbeDc34s5ZmJP8AhDSOftXoekXR163t5WPjW8ri5lZuzxqfpUemWC/9
+Ipx2irp6J0FkYdauorpTm0+VhJJ4LRW0ZbB/5iM+5qs6h0Ey3OlSwRuGtL9pxgcHfC78n7sR
+VuhuNRt2lsmO68vIIQ7HJ2yTeLPJj0EMOP8AqFaRrZLqKOWRTgiN9q/4ozjPvjFVwsPco8Y1
+1IYbrVFYbWjWCVCB5lwD/mBVVM8cWp69GgbZBZWl2nl+Phh+RrW9baDcSRw3MEf1XFpqNqzE
+4DSpJHJH+m1sfesLNeM8Oo3Z5lk0hXc4wMRyruBz6lqnjWi27Vi6XqHgzWC2rsJop4WRl8ts
+u7FehLbxzDwwA2ZpZZSD+FVkyyg+5+n8zXifSGqm4LzNHgWc8uPq5ZmyEX8j/lXpcOpXEeh2
+FtboWa6Z0eQcfRguW/N8URdETRqtN1xdc1y51S+G6y06ZTbKAczShdm/HmWkO1R5KtWUu6z0
+6xsmx48rxlifJhISB/nVD0wrw6nbNA6mK2c3OX/DuWMqGPsGZj+VW+mXian1vHZF/wDdbGx+
+dLkc8ZCn8+9U1aEnTLCZ5Hs5J3ANxcWjWqrnAWJWLu2PckVqNDK3FxFcgFY3nlY58laMAZ/O
+svp97DeavZROVxq+mF7NQefAYbkc+74Y49BVz0XcG9sGvg5UPI0Ww+R3/SP/ACgmpimnRTdq
+zUaAjw26RXKbtl3dW+/zMbMGQn7HIrR2MPiWu6RQQ52kennVPZqglkCklhKJkX1FX9rthtfD
+bjLk/bNdONGE5Fbq2nXF/azafa7TPMpSEMeC5/CD9zXzwmv6LqkNgHsnW1u/HvrLUS+02t5F
+K0DwgDuVdHVvbaea9/1XqnT9Bv45LxnJUhxEn43I549PvXy7Y2up6jq2t/DnqTUrVJrzqS8i
+sL1LYxQ2eoJtuo3RGORbziYQEepD96rO0o67K9PFye+j27QtQEWmWusz3SIY0CFnwAVcZMf9
+Dj3qXb3bXcbxpKoDJNIEYYYkgMpHsG/zrEXt3e3fw+0fTb+xkhknCvcLLjeitJNH9Q8yoUY+
+2as9EkWCy0u5afxJrOyn0+dX537jujce+3vXLLL4Nli8m3srppo5bjbs+ZSOWT0LEd/v5GtJ
+pN54kKLKcs4KZ+wz/lWG6bka3zo73LSKqYhZjzxgqp/LIrS6VLPFIIrgjEUj+C2MExnsD6kZ
+IzRGQpQouJZfFRYi34V4Pr/9OKzkswjlfHG1z4h/LNXBkUhNhyUGR9u1Z7U2jt7xkYERzMpY
++hIwD9s/51Vk0PkmJLLH34QZ8gR3/Q15f8XJGksrcRsY47m5tynrtEhx/VRWzutU2TzROp8V
+VPh47Ngf0ODWF+KV7bajdaVb2xYraSpZlcngoFbdn1yxpT3FhjdTRm9HvEZNbslAD6uLqMZ/
+kjCHP6k1dWMLroM/gNsmsLq1voXz+F1cgD7Egg/c1jOlr7/ebR2BO1bxXyPxF2OefyFbrSnk
+e3uLeJSyXdpck4HOUcSJ/UEfnXOlbSO7lqzZWslrq9hJqCori4Hiqp/hII3oR+tP0vpLp46v
+e6/DpFlFrN86SXt/HCEnuyqBFMrD8ZCgDJ9PWqfp6RrWAwt9VvKwGR/C59fTitlbRFNlxCQQ
+PokAPYev+RrdK1sxnKtEnTLc6bqQhitpBaaqXkzGhKQ3SAF9/kokTkHzZSPOtNCgXLAcHFQt
+Pgj3M7HcxUqCGOMd6tosNGMMAy8du9axic05D3nitLSS7n3GOFS77RyFAyT+Xevlf+0nc69r
+mpDR4lvF0y/NpqOga9Z2hlhsr9D4kG65iLKsc4DIVmUAgsATjFfQnxA1ttH6bl+Uu4IL6Tat
+q87lYjMT9KSYziNvwscEAHJ7V8A6t11baTEeh7u1vfh7r0Ety7QQ3ymGKViVuIWglIjms34l
+VVYHndGFatclRhxZjjTlkuui+nEd1e3eidX3mndLWGpLJeafH1FpVxZ2li7JukjW8QSIYQd4
+AKsqg9lrC3Xwr139m3mp6J0p0j1R0zZzCODVOj7+G6e2BxsLDO4pk434Hf8AKqPVumdU1++t
+J9A+KOo29xp9sM6dY3739vLICwEsNvMwbawIBxubHftXTpXpuwstSlvtL+Lc/SutoRC1zcQS
+pbdssJv7uWPLfhKbsH8q8vLKEX8j2cUZyVxNbp2rdMW/7P6QuUfVLBEd21JrfwpkaTmeJrVz
++8SCTnxA2XXgCsz8QZtCtU1CXTW6U1K3VYzZJp10buNVcgfxhXjcMM7JU7naDxWg1C1l6ldW
+1jqd+sbXUrQ23i21tY2d3bMowqm8lZHZSwYMrqW2sM5zmslrPVvSnSXUIVuj9f065tIBavZR
+mzmljhxjbLcyhhcAgnAI4UjGCM1yPi+jqqS7PCeqokmsri4ihmSWJtpGRgt3J29xXk95ZrOi
+vIJSsj4Dpgjd6Gvftc1votJrxdM0TWRDcOAsOpXKS7EY8kbec+QANYfVOl7ex1l9MmsZLKIx
+h1RlP1BhlHXPdSPzFb4pqJhlx8jx27s3jZsMrAHAxUQo0bbWBBHkRXpN30pdC4F3bWgCq2xl
+bjy4Iz71ltX0G5sriW2uEY3CkmRj23egNd8MyaPPyenaeimF1P8AJGwWQiF5BM64HLAYB/Qn
+9aW3thMSGzwOwHP3qTbWLSt9IyfStFpPTF3IJJbLbJLGm5l9qc8yj0KHp23syYtH8Xwj39a1
+Ol6Et9ZRW8auXMhJCIWZvYAc13tNGdZmS6tXUkHhh51uNF6RFtbpLfXN1ZSZ8VjDGTIiY4IG
+RXNkzt6OrF6dRM9YdK3+lTjNjMrkjIcYIHlkVobfoc2+n+PqkXzU1wS8a+GQI0PkzAcH2rXa
+Zo9ppOlSagt9JPPKjvbw3QxI57BmwTwRzis/qnUmtCGPOrx2soXcQwYmTy/5QB7CsJTl5N+M
+V0VDdEabNbRYRrfYd0q/VsAz3ya5DU4NCupNNdA0EkfhuqYO3zRgfIj/ACJrTxarLd2iXNxd
+rIJQEDdyzHg9/I+hrza5Et7q00rqOZCOD2wcVKk/LHxXg0M2mnwbC5cDbewtKAPIByv65FSo
+TFH4dlFzGowPc+ZpNUmSLTdCSN9xhsf3v+FjM5x+mKpY72QSkRZaRicY8qxkrN41E2B1BZLi
+LxD9Wwr7ZAwK7dO6tHZanC5VXDttcN+AnBBB9iCfzArIpdNuDuSGUY58qs9JdJbm3s4julun
+2kY7EdsVHEvmajQkvtL1ETxmSTYWgmdM5deQrceeMZ8q1Npr0eoS2mhX0kcU0i4glZgpa4jy
+yZ9MjKn1zVCRf2l5PZxS3KxI/wC7uIYnKNxwV7faock1oFnt570GVnjcpcWgV8oTtKM3I5PO
+O/FXFNCdSRG6hvulJpz8/pV8rs+4mGb90xHOGQg4z2OKzNzq1paeLBYW+yAtvjimbxPDHoKn
+XkKSuxE6rJnDbx2NUOq6f8gVF3HtBUsVU8kffsa6IUznnoZqzaBrM3jWl9PYXARfEWXiKT12
+MCSD7Gs1NBp6XJMN42B9J3YOT61V6je77kROzpGrYJXuFP8ArXbV7iC71YS28UaRi3jXdEuF
+kKIAXA8ixGSPIk12RxurZxSzK6R6v8Per5undVtLH/e9X0y/dbW8jtl2y4/h2k+YJIBNeqa9
+03o8dhc219a6xb6fPKjq6xG5ktpM/iLRgfSRwc8c182dN6gh0nUTd3EkM8Ma+A4zyzNgduMj
+vzXq3RnVd1Fow0mTWXikjj3lyxB2DuqnzJ7gH7VnPHxfI2xZOa4mnto20iK1ttT0+41/TvmG
+tLS5kspHYZwxj8QZGADuAbt5Vs9Nax1jwdNurFGQMthqETzJHPDC5PhS5YgbN+Bu7A8EivJN
+ev8AVhJJajWLh4zKSrx7oUmA+pJdvbOD27jkdq18l7rOmdPG/wBW0d2tNQiRpZJrXf8AvX2q
+ZI5QMeG6kb42O0sueDWbSLTrs5al0je6dq15othDeEwBiY723a3niUNzHNGc7ZEb0JBGGBwa
+yEkw0W5hldhJ47PDdxqSyMowVcZ8weR9q34uZNYsbbTr/ULpbyZRHpGqi6yYpl4S1mYnLRkg
+KpblDxyMYwN7dy3hPzilLhcxPuXBRgTuVh65zWVpdFU72epdNfCzSfiP0JqfTt9e6fbza3cz
+RdPTvN4Ih6jhthMlvK5woguoEaJcniUpWf8AhfpT9QLJq6PJHJZ3q2JhSE4KC1IXI/hZADkn
+yz6V30XU5pug9Q0C6Ufs3VFFzbBVzJDqVsE2yq3oU8vIgGp+l6o+nWNxf6Mh0+41C7sNSvLV
+FKBbqKNopgh/8OUNkj/EafO4pCUGpuXguPjFpstrLZanY28DHTNJto4lOM7HIDvg8E4K+/nW
+I+Ssj0nFcXksZurTVZLQhVzIbdgGBc57Bj9JHuPKvZfihpU/Xuqat/slopR7LSop5bZWJKlE
+U7TjIzsBAPAJGO9eba5o1imi295ukMl/O6xPJFsDKARJA2ODLC4Vs/xK4pKdxE407Omj29ik
+qWF7DJ85CsM9m/YBpNweJh7jke/3rC6Jpb6jqWowX8RUyXEluzS5DbUB3kj7cVuNOlOqaXq+
+p6goku7O3Dlg+2UspG1vXIx/lUPV0v0e36uuvAuJtQure3uRwDGk6sviuuOC2MZHdhzU8vor
+h5Y34nQ6dp+kRdUq1z+1+oWGmTRygPBPYLYhZcDyaOZIcf8AN7VougLl7PS+kp47QGKDWIvn
+IyQf9xljZXXB5+liHAzjNZLra4+Zg6UtZfrtZ59Qg3yghYpS6YIP328elb3oPTkvdKsNElu7
+e11G+025utONy4SK4ubV/qttx/A7oH2MeCy7e5FVCXOgkuKdmL1a9is5JLyCRRHFcmAY443k
+N3+2azGnasNa1C4jhilmbfhEC/UYuc9u+F/yqv6lvHk1mWKCZnjFzLcSQkFWQF8nIPfg+VZu
+71GDRrqS7tLuWD/eSFKNhlGScg+1axxtIyeQ22hm4uNMvda0+CWa00OWBdRZVLm2SRikU7kd
+omY7Sx4DFQe4rSW1zGbuHRopC8N0gt/EkHCu34AD7NgfavMNJ6o1Dpqe51WxvZxb3lu1hqUc
+L+Gt/p8pHiQSDsynAIz2ZQRyK9B6wtYumf8AZ+3b5lHuRM0sbgL9KFTHIPXKsDkcEg03DWil
+P7NF8KNfudJhu7q5UYlvNl0pxlBGxUj8jzn2rV9RyW2g2+rWkNoQke3VgVGN8Zk2ttPoCf61
+5npV6enr/UhtSYQ5luSWwu1iPEf34fPtW2+L2rQ6N8R9K0Yxo1sNCskeATiRQlzCHcMVODna
+px6gedYrG+zV5EqRnLrqnQZnmuNRt7rclpdCFohk+OceEJfMoQSCR2781R6zrmk6z0r08ba5
+P7R0i2TSp7OdCrGIBz4qP2YZYDbwRwfOsbJrBjad2JeEr4XHBH1Hb+naszquuSK4SKYlTgcn
+mujHCjlyO3Zm+q4jYa1dWuBPDExG9chSSP8ASuVsryQ/KluUVSBmlvFuZp5rqG4LDO2Zf8iR
+UiKNp7F7+FP7o4coOxHtXT4OWhZojdzWqu23kAn055qvhumtdWaG3m3RRzH6sY3AHvWps4dx
+guI1jkadRj6c9+DWZhtk0/qowXtsJ4rW4KSxbseIoPK5HbPbPlTtUNRaaNlpJjhujLbkYlUt
+sPbd/wCte5fATW4+ieqdN1LVdD+e0n5u3vLhFn8MtZHMNzgEYY+HI3fzUV4n1bp/T/T3Vy/7
+J3k02gXMMV7YeLKJJ4YpFyYZSAMujblJwM4Br2D4MWmiarous2d/qDwX9havdaez72ju7eQh
+ZoCoGAwLBlJ4xkVxZ2dmCpJo5aj03H01d6p0pBNLLa2WoXNpAJlxIIUlbwmPlkpsPpzUj9o3
+sq2UN1dzzpZwrHEsh3GFFJwinuFHp5eVT+pI7O+uNPubKOeO9mskF5DK4bdcxZjcow7qyhWG
+fUiqczxRyRNOHVIztl4wy5OOfzribd2zsitJGha+jS8MjSojTW6tGDyCMEqcH9K8yF6iancw
+XEPgpf8A0SnOPqB+hvuDx9jXps9nA7wLqSeFHEohifyCs3GT5YY+frWL6v0yxt9YNvbt9RMf
+iwvw8UucblPpnuD6104o7MMkq0eW9b2N3b2f7WuYGSyvLiW2juFGVE0ZG9CfJsEHB8jmvNJH
+2s21ux7+tekdaxrGNRiMkvhSzmV4MnwxOOPEA7ZI4zXmj/iJxgV6cEeZlkxTIc5HB9aXxn/n
+/pXOirpGFs9tZoNk0BiLMBujxweDyvvxVnp+m6pqV1Z2+l3MUMyTxvb3bvsWPLYDuT2VSRu9
+BnyqvvZHQYwMhw4cDt5Yqbo0Wn6pBLp1xcvBeQjfFCU+ieP+LY+eHA7L/Fzg5rwIn0TKf4l6
+RrUd3cabrllJaapo0strfW0y/UjhuVHqp/EvlhhisZosV/dxtpCmS5tJGDiJgSsLY75P4fIG
+vXtWsYriykm1UT3cYjXw7ouTK6YwqsG5bHbOc4qhm0C01LUbqwha4sNJe1SUQKRJIWGNzDAG
+/wCrnB8q68WXiqOfLj5MxVl0ReWl3He3UgsFEoHg7llkmU9/DC5z9zgCrC5vje2wFoJLFVJd
+QrZBOcfV5k//AEFREurvSeo5oL6RLie0ZoQyfSGI8/bPfFdkivpZY59Mt5L8TzJGiQDdKsjd
+lKDkn7ZFbSk5GMYKBL03Xb202Sr4EmD4Zjn+uNm+x9fY1dSa7Z/LgTadHC5Jy8DFNv5VWahb
+m7uzprWcKyxv9S7Qrhh5kDg8gjIqzsn0u7vvltctwViI3AP4R2+algD+uK55G0VRKhljv7Yx
+RkXdquJGQY3AjjP/AKVW6jaL/extsj/C0bLhhV7Bo9nop8fQ55XQsZHLMCpU5woGM4x5mmSw
+BY2j3QlJxvCz9nHoCexHsaybpmqVrZ5jqkUgkcwu0jAksSOB7VSTO7RtuGfLFeqOdFtbgRXn
+TlxH9YG+2vO6+fDg5rA6tpfhXFyLGG6mETFmDR8opPBbHbiuzDLRz5EZaTvwMUzFS5rcliyI
+wXucjtTJIGVBKqHwzxk+tdykmeXKDTI7DBIz2o5NBB744oqjM7Rpn8HLeYqTbo0UiSg4wcj2
+qCrEEY4x512E7nGTnFRJNmuOUV2aLTbaZ3EkJLRA988qa3+nbII44dQt2lXZhDgNz7eh9Kwu
+itGti91FdSI6MFlVSMAEcEg9we1aPpy8k1Lx47bLtBglBzuB9K83PFt7PUxSro3kGtR6Mba9
+6I6r1aKd4PDvra5sBbgnJzH+Jg6+WeM+WKdHqHy7FrjVbq3gmw8duYfGC+vY5Az2zWckgug6
+BQ6S4P0uOW/w/eo4lOnzuHhdYJyAVdcMjd+CffyNcvFXs6uTo2EOrTEy6fDLFqenuxl8JB4d
+xG+PxxseUkHl3B7Hg1W3urlZra95Rbo/uryNTEspU43Mv/DkHZgDjOaxuvzTRzMEuxPbyHej
+BdrK57g47H27UaXf30tu1kuoSC3yWeCU7ow3mQD2PuKvh5IcjX3V86i9kkj/AHoXlWHGc8ke
++KpxeMskN7BNJBPAwZWjOGUjsykcg0yKMXKtbrq1oZcbUWWYR59gW4P61GuLHVdGuAk1pIjE
+blEgzHJ6gMOM/nSUaFysnXerXV5dvPqM5kuZ2LyTtwzsfM48zXE6vEGYTI52Rsh28Nz51W3U
+/jGOV1ZM8lT5c1xhmMrHch3wtgPnuD5GtIolsqtQEE6tGhaRDkjf+IVmLpUDsACPfNba5063
+uGIRijNnkjAFc9E6V6W106pZ671gdCnS2B0eWWz8S1vLzxUXwbiXcPlkKMzCUhlBXBwOR2YG
+2+Jy5kuNnn5yDRnzxWm+IHw86r+GHVWodGdb6WdO1fTWUTQCVJlKuodHWSMlHRlIZWUkEHIr
+MkjPArt60ea00dI1ZjhRk+QqdaqIkW4aaSJS2BhiCTUOGNpfoVWZu/HfFTFhfxVMriFIVyPE
+7kD0FRLZrF1s11mI28OK6ubYSBQY2EwYnPkaube3vbK6eG5u8R4KeG4wVNedLqEIYbLYPk5Z
+5eWb8uwrcQ6/d6rYRW0gWU2irHbyH+8EYH4M+a+fPIrhzY3FWd2HJGTpE+5lCrmBUYdyqtzU
+jQwbxJRIco5OwH+YeXtVU5huAhU+FOv0sM8MfI1a6Csosr2NUJuHVSEYDujbiR9wK5H0da7L
+y3sbQxtcs5WNGVWBH1An0FWlk62tzE0sRuxA4cxycBlByRkcjIqmsdVtomF0FYF1ZXDDcpyP
+T2zVq+oWV1Ao8WTxo41DsfxDjsfJx796zvZpVnOeHT49Zl/Zt7PLaXRUxfMDAg3H+7JPcKSA
+WBx58Vb65a31pPqfRvVGjvp2uaGu97SWMxujZAK9+QQQ4PZgcjIOaZpOj/trpy6kAhKWsy7U
+ZizFXLCQqo9NoLL5g5qw601HqPqTpXpi16is5dQ1PS4XsNF19XbxLnRlU/8As2Un+9MDfVC5
+JYRsUP0quLjxd32TJSTVdGOT6rOOGZlCYKDf2HJwCfLzrJT6ZKl5M0Ee0RHdKpPkDjj1q8jv
+ZLSPMzCSFhs3DyyOD71RyXNzPLEJ2+pNwE6fSdvv600iG6OtwkkukNIm3/dZxvJOG2nGPvzU
+efY92r+AiZUdic/eleQT5aZjtBBIA/EK4G8YTBioUHjb6Cqoi9jNSKLO8YXKAnzz9qo7pgJN
+yjOOB7VbX0ySF84z2Deuao7tgJvDJ7CtsaM5uiiuARIwPrzXNkKplhjd2qZeohcSJlQe+RUJ
+254/KvRg7R5k1TdjaByaUKT5V0jj3Egj86puiEm2TdNkMIcq5VpVMeAe6nvWp0u6mskje3co
+QhXI96ycCeHJkjcB5Cr+1YpEibss3PPlXHnfk9HAqjTLRpmeXeVBPcn1q7069Nnpk96GK3N7
+utoRjO1B+Nv8gKzQzy27IHOPWll1CSPwUAOwZxn+EVzxb7NWi9a6kaNQgCK+FVe/Gef681Jk
+1Q21otha7R4lwjzu2SZtpBAb/CD2XtVO8xRIvqwGU4NOMzo0LqAZMcH0JPeqsXE9/wDh/rVv
+B1VavqDK8OtTTXs4VuRG4BceigLH9THsDgDJr1bpL4hQ69pT68k5cWrXF4B2aW5lYxx7h/CF
+Abav8q5r5c0nWZ7XQLpLN2a7ktm06FzgJBA77pWJ/mbtk9hmtT8PtSjj1TSdNikkawN6+o6j
+ccqTFFGTKFX+TavJPJ7DGa2TtHLLGrs+xdC1Q2XSVrZSXDK/U+ox6faGP6XS2RA13L6kOzRx
+fbdXquk3Ult01c6aki/OXU6W8ew4ETSf3cY/L07ZxXzF0/1xp3V/U41KEvDJYWsdrHb4AFlE
+F3hRg/jJcFj/ADHHlXuPw41aNdI6fv7iKSaQ3zXk4Y8mQA7QPUD6R96tPdGLi0eyzQW9haXv
+7MYlmvrSxhkX+FY/Dil9ueRnzwav7N45ri4jjUkxuWDgcFR9K/0rN3EqNpunadboyPPqADEd
+ldsBfvtL5/KrfpO+gvOotVtbZt8FtZW1y0hHdJZGCfntiJ/6q6V2kZXqxOq+noL260uMSY2m
+4Zo8fiypy2fvivn7rmyuNNttUtLdP3koRUkU53wSOvA9cmPB96+k9em3W63kWFME7xgnuFYj
+j9K8Z+IkVjDp6wtdwm40xppQpA3NbrcfUf8AoZ4//NRkx+UKGTwz590a8trTqMWl1ePaRXl6
+CsirkiAJunZR/Nj92v8Aikz5V6tcdVW0URi2QLqNynzU1urZTTbXgRxkjgHbtAHc5Jr5l6/6
+uh0LXZb4zb306GZ4Y1bapeQbQ7HyC5JA8zXfpjrPU9TsJ+pNduGht51TT7eMybTZ6dComuJn
+XHMssnhR5OSCwA7Yrn6dG7batn1rp2olrbfLMsfzEJQ5PAgXmWZvRFAx9zVW99ex2jXAmmTU
+OpdqlQ/KW/8AwYhjthCHb3celY7pfq6fri3W2S9j8GaB2vZ5JPChktoFD3Msh/4duPpjB8wr
+eZqy0bq2z1TqDSdf0+JLm1uLOa7sYWBUSwFikL48hLJl+eyIo86bRMW12eqaxqceidVy3otI
+4zYC1vLOEDiPT44ltYAfQMyyYHua0nRk9xpdrpenXbJ9MUd7cbef3jbsjPmQHUGvJ21S41Lq
+/Ubi/mWeK9ubcyCIfS1nZITGiDyR7g/T6hDXoct5JbWlwJXCvHAtu7KeDJIysxU+i4x+Rppb
+BvVHqdpdhPlZJRtlnUkgd1A5/StBNMgjOGHCf6Vh7S+Z7tJJQ235W22Ke+Cm4/0xUnWep7Lp
+/QNU6l1G4WOx0y0eaSVzgAkEKufUsVA9zW8L8GMnXZgviL1rpUPUEFrJdxQ5jIWaWTaGdJAk
+sZ/J42B88kVierdHt+p9fjcH97caVC08iNkg+KUVsj+IInB8gBXicHVt9190nZLrUwkuba9B
+njK/SLaQuIo/upxk9zhSa9o+HRi0i2gt7lvwWR4Yk/X4pUDJ8gr9q5sz5Ojtwp442+zY3Gp6
+hrMlg15cPN85qIs5Tx3S1wrduM4LH3YmpHTEayZMpwokVgTzwE24P9ak6fC6WESKDGLi/jnj
+XucRxfVz7qcV30/TRZ206wN+Ikx5bJK7sgn+tZNOzRSVUWNmPDeF5sLvl8PceNrA8A+mcjFX
+9jfTuz72PjWpwVbGSM4yDVBLEMJHKjSQ3kaxzjH4HByjg+R7VOsxNDI9xKwOcKxznPPP9acd
+ESqrNEs4JAVcZBB/PnFV+uMBbG6eIlQuNoPdfb3qQjFoCEzuBBHsRXG6eHw5mlhEiOhjde2P
+etkrOdyPNuo9ZfR7lNa+Y8e2IUXcOPqAz9Ey+5z9Q9jWG1rUGnsVm8dZHS63JIuDuABXPv2/
+pWw6msy1oywwTzy2yyKbeMAtNEV3PGT3V9uXjPZmUr/FXyRqvxRfo3VNUsGv7Ny06XdozKcY
+kA25HYxTxHO4dnTyOaHB1ZKnT0eydMXcLQQzwSK6oXZSP4suc4/PNen9DziTT4ZZGEh8aePH
+mFBJA/QkV82fBHqSwPw5tbkxThLW+aJFkUgyRszOCD+ZH5Vf3fx0tOhYtLLW/wA3GNStZSLe
+TaTbrIxncjuzbTjA4ytZwSW2dDbapdn0hZxpHG8av9cMpaPH8cZBIB+3atFY6oGtLecuuLje
+FyfQ4z+Rx+Rrzex6n06/a4i0rWbWU2hlS2k8VULpnfExDEYBQ+dcekPiZ0b1Pda1oOja3YX0
+2msRi0nWTa5XdhCDhsEMpx54rp4pK0Yyk+me16ZqBeJXI2n8LLnhXHcfn3FW0F6A+1h58155
+oGu2d7CJIZ1mSRd0cgO3xVHkwP4ZF7MKubrXIbKH5szFVUcBxgc88t2/9KqKMpSR5H/aR1nR
+H1CfSNU6insLG7hWHWLa9UvaWvinbbamhX6ki8TbHP8AwhCSV86+SupG1Pqa3h6Q6nd7TqDQ
+Hls7NdaeOe6gZOHtxcYMd/Zf+C3EsZKhtynNe+fGX4vWOl9ax2HWsVmdB1mzaCO6+SW/sJg3
+0vE86cR45U7Scd2ArxD4j/D68urb/abpLW+mL2xjtyDZzdQwxTXFtGP3EgVwBvVf3YYHLhR5
+8VzepdvXg6vSJRVvyYrQepNUgu06Tu9SaPwJFh06A6ZBBulJ4QuFIj5JPHvzVn1B1reaXcW0
+dzE2kXAuCIGtof3jSA4J3ytJHIynzTBGe1YG+utFuY1udVk17pe/nyJ5ZNt9Yt6uJEO9fLuD
+96yMuqS9N+PY6P1FZahps3JW25glGc7vCcZjk45IAPua8ua5dnsRlxo9V626qt+pLjT9Ymha
+eSG2FlO11LEzzXIb94ZI15G4/wAXc1mdW6l8QQ2sWnafcsCEcXCEP4a9kLqQc+RJ5xiqCzv9
+N1GUzPKscow4G3cCPNiTyKdrF3o8GRb2M0k02H+aR9m/jtjkd/OsulRrrsnHXrCwM8r2bWs8
+sTKnyqrMsQbAIBflePMZOfSq3/d9SnSd08WGM4VtxDJ9881yuPlmkj8KWQFgN6TR/UCe4yOK
+LAyJK1zcRv4e7wvGAJUN3Cn0yBTUhNIsNbXSreOE2tsTMzcSPJlivkQvb868x6tSFr+VTchV
+3EqvOWPma0+vXcjXrXDT+G8Z3Rr349Pasa6y3krozLIxYsmfLNb43WznyDdG0aOR/FdgoIDb
+fY1ttN0KGJPEVipZQQP5ves9aQ3sWDJZuGAA7eVarRr2KIlDEZD2ZQOw8zU5JyKxwT7NDpek
+SwrFq5s4nltmDbZEEg9mwe/btUW4OpXK3dykoaVVLLGoy0zE5wQeSPYd+1PkhXwI7oo4VlZk
+OSD9JwR/lVVqOqPFJCZ5MtgFJexUryOR6HzqYtsuS4j9Av8AUuo7uSK5t0+ZJ2Oygoe+CCD2
+wB24rLdV3yy6iBa7dkamNcnyB7/nVhrvX1ykzXNveO17KD4k7H62JGMk+Zx5nmslpV5cT35n
+CiaX8Y8RQygj1B4I9q2irWzCT3o7T61cmBbPcy7D+X5VJsIdx37dsajc/qeO9TLTQbrU41t4
+rQyXW1nRUXLOBlioHngcjHpWefWb6zNxp2BHFNgOAv1EA5H2o48tISlxLa7EkjlI5FVF5PvR
+b4xlAct/EB296ror9plVQfq4G6ra1mSBJLmTJiTgAjh29PbNQ40XzOVwyxF55VLRoQMA4Lsf
+KuMUs9qolQNFLkOGDfUD7Gpt6q3BinQhItxaNCfqAP8An9/auT2clxiRJYkWBMje+0sc+QPe
+haE2au81iDXIFa11jUHmESM63MJidMD6uQxVuc/UAOMU21u77UrXwbq/M5XAgE53nOe3POKy
+092NIs2t7V/GvJVLO5/hT0A9amWN0s1jFLGoaWWTYPXsM1UvsItIn62k/wAwUuYPl7iMbGHu
+KzepXTQRs7NhYxkqedze1TtTv1soXkuWY7fpRc5LVjdX1uS8O8gfSSAvpWuKDZnkmkVWqPHN
+ctcR5Hik7lP8J9varnpKbRllca0LuJIlLw3VqEeSA477H+mRfVcjjODWblkZ2JY5P+Vd7KW4
+t99zCsmI+HIGVwfJh6V6HHVHmOS5OjV21yjWl9d3enQS226NZ/AGUA3fiAByv+VbJLnQI7K0
+jaG8jkxiK7iYSW93btyA4/EjqeB5Vh/h/wBU9R9AdU2XXfSd7JYXFhJuS5S3SdISRj645A0b
+rzyrgqwyDWlv+qk681qZdO6c0nQpLybxYtO00NHZGcj954KMSYg7ZYRg7VJwuBgDLIlVo3xS
+Z6Dea9pt8tvBFHbjUhBCkoZy6bVTYtzEvYnGBIvfAyO1d+h+sdc0OPUOntdu9QsbW4SaxvIr
+SfxIEkBGZPBOUmjKkb4zjKsGUgivMJJtdgt449a0u6tICTLYXxhIjBU4dQ/YjPBAOVPetla9
+RXXUOipp1ytrBqthbs0EkkZHzyICVAI/4igso9Rj0rGTfk3VeCztXt7uSOwnvo47WcyQySqp
+CgA43be/HBx3wKXULRLzx4rhvA1GBgszjJWZl+guPvgMaxVrr7ahAJ54FEsqbZCowGZezY8j
+jg/atHp3UkF3B4lz4ss0UiRs7D8angH7gY++K5pLybRa6Nh0Sq3F9Z6ZNOLW3SQlwfwxzMhU
+EH0Y7efOt5o1uNH1O1kuY4SyabcSSQ3EAkjuFcFUZR/hfHoR3rzrR2ZdUeGydi8lo0kidyYl
+OSq/4lYbh7E1urPW7a+s9A1xb2Frie3vI72EjHgy27btuPNZFZD/AErNPwaNbJPw66pfR/ib
+pzS36x2t88Ony3U0pW3SOSRY53lxyQquz+20nyqsjWPp7Ub7RNdRb3TOnuqtQS/eQ+PAyMzW
+zSbl7BlWJlcd8Ajmqq2024k6x33Nmr6VrPzxiOSPAnjQMykeW3cvB7q1W9zE1hpeuXug28SW
+/VOmNpV7apL4fy87ANG4PYkSopANF06FXkfo+galPrF9oeneEl20iJbGch0kjD5VWZc5VlON
+3uCfOqnqu2hltZorMtJYtN4UYB5Ubt0S5/wuPp/MVc2keoWfSPTvxL0uVLKG7UqI4yGDrCn7
+wqQcqwlR1KnscUzqaJNX6evuotI3BV1Gzvry2wAsqzttLRFeNqOfQYLYNLwVpmX122n1HR77
+R7e5dhpd++sWcLAKYp5Y0EgPmNwQr9xWvOrdNw9Q6WI4Ztf0q4064nhLxfL3KQygtLG65xuS
+RN6sMc/eqvRNG1bVNcvoIIPFl1EQLDHJkNLiViT/AEOayUkXUOg3yavNbSfJ2N/Lpl4UYObQ
+gYdXx5FG3KwOGAOK1xKzPK6Rmvi2tpe9YXGudNSyfs+/gtr21DJseIGMB4yD6EHnzzXnGszz
+XmVVfpf6x9/OvS+tbXT7hYLrp3SptH03WI47LTraS+N6Y5YU23UquQG8MsA2Dym7GTjNeV28
+8sci6fcMqyuxSN+6h/I+4P8ArXdFaOK0+ie+rP8A7PWLQyBvBuDFIjDyxwD/AFrZa31JdnQ9
+Lg1NpJxZ2wNmJCS0UTnkBv5fas9q+n9L6nLp46LuNRaSewSTVLLUURGtdSRiJIoXU4liZQGR
+iA3JUjjNR+oLgJGkK72W2xHslBVlXvjB7YOapxVBGVmsn1J7t4HhuZCbgrGzSsOXKgc+2AKm
+9d3T3fxEubi90q30BsW6T2FrMzxWrrEoIjYknDY3+xc1l+nXk1MTWFzfQ28VtF4njS4wijJG
+fM84H51X3GqQtZxm1m3+KC77zllbtjPf7VnwKc7YmraqkaS2MT7nllDEjtgE96z4nlZ2B7Kc
+hvIH0rvdy28k6rKisZgWDKamabpyXNjcQom51KugAyTg+X5U0qJbsrTuacuq7JJuG2djT9Jn
+Gk3HzLKwDko4HYj/AFqxt9MeS7jVATtQy49Rg8feq7VYlngtNQt0IidjHNH2Mcq+R9iOR+dW
+iHp0XunLIbuJIZ1ALfu2Ttk8j7YNOvtJnv8AUpNSurcRzZPjMowGb1/OonRto2rzSWNs58YJ
+4saE8sARnHuAc16PZaabPThqko3wC5FvOp5DHs2fTyINY5JcTaEbR5/ZWkt3GVlgO5JNxOOd
+hr1v4dSy6YYbi2YRywQbc9iQzcqfUEcEVmbHT7C06mRZIfHghl3SxEkB4yMYyOQRnIPtXoGh
+21jY3moaLJEZ3n0xmtplO3ZcxnchPsVyCK5c0rOrDHjsup7QahcGGOACNmaa2Df8OTI3x578
+f5Yqvv7XTppylxLKYX3QtIBk8A7X9SFYYI8xVlZ3k8N1whUFVmRuGCyAYYe+RVcby5jmivrM
+7JYbtZIyFyMg+h8j5jzzXOjoZYQaiunaaIr1o7mwlt/Cd87jHG42NuB77G5HsBXlXxJfUNNu
+LG4uVJuRG1hcsDlZJIiBuB8wykMPvW4NzHHFdLOka29zcSkRDtFk8pz5c8VkOqYZb7QzDI/j
+GycvEx+ottGMZ/5cfpXViZx5Fs821W6VkmgucjKsQrHOOK8+Iz2Fb3X44tW6ZTVrYqL3S3Nt
+exg8vCxzHMB54J2Nj2rB7yjErx5Y9q9LH0edlqxlFFFaGJ7lcWkiyTWTSb1lBMbAcOp7Eeh9
+RVRbXeq291BNp9zcQ3Vo4aIw8tweCB2NWcuoNbyRMqKoRQolzja5PBP+RqDrkgs7v5u0VrVp
+kBManBTPf7V4EVR9FJ6N7ba7Drmhi8vbGKx1dZpC1rEMrNGMb7gr/wAM5/h7HJI4qFM5jkUP
+OiPIDJC0hxwecIQOc+lYvQbydbpbi2nMN7btujc87vv6+4PBFbC6+ZbTf2tYssVhNJ4Uts6i
+SK3u8ZMZU8qjfiRhjg48qcuwhtbM9rOkWOsz/PXVuYbxiB8xHxvx2LL5n3qtbRtRsC15FHdQ
+K6tCt3a8BvIjI5B+3PNWg1GUfSyGOQHDwnna3kQf8jVlpWrJFp93atNGPGmjm+UuIC2WHBeK
+QHCNjuCPqH2q4ZHHQpYlIw9pobG1khe5SCOzfh5pCpZn/CgPfvkk9hW9suoLjVbK10e5lgsL
+jS4Y0ZfBjaK+UceJIwAkLYPPJB4I7UyO1tJzJHbxR6ijHIgB2zAEfUFzweO3nXFem7hrJv2V
+bXF7ZQMT49wyxzaWBzskJIwvfz+1aPIpKjJYpRdsvdO0Z9b+Zg0MfvbaIzS2iy73iQcF083j
+z6ds81XS6UzWklvf6dcIVO1Zo/qQHth17r/zCn6Nqmo6JN8vFJ+1o2H0XFiNssBOCfCkbB9M
+4ODWmbq3UrqYS6g0N/HIpSS31qxWXfHngboysgBPc7ieODWLRsjzO5s7nT5ns7hhcwZysb/i
+jPcbWqv1+wvb1YtT0WY+PKDDcgNsbI7Z+4r0jqbR9NW8jePQrfTrWaENHBpEz3qMf5kMrbsH
+tgnIPFUOtabpln4t5omvRX9vKVtkljEkDRuyjessUgDIykkeh7jitcUmjPJFUeXXlnd6ZILS
+9tFjmWNX8ORf5hkf0rpeav0nc6J+yZ+lGtL2P6/2lBds8hfB+gxthBGc5wPqBHfyrc9S6a3W
+V3cC3uNKtryAOIt52NIqqoERPnwv09sZNeX65ZGxvZLS7HhyRjy+rJxXbie6OHN+NlM2PI0l
+BorrPPCnxAbgSM80ygHFA06dmg0K/XS7hpktreWQKwHjpvjdTwQV9a0GlaldrcQ3sFzHA0eY
+hDbxCMpH33Aj8XPHrWHtplRx4i7kP4h/rWs6c1/UoYf2ZBcq6I5kWMxKXzjurEZ7eWa5M0PJ
+3YZqSo3ul6na6hZoYLi7nu/rF1bzgFMg5V0bOTkceRBHoal3c8OqxmHcZXUZNvcNseRPRG7B
+h71SDqdtQWIvfC3uFXYWCKEf74GQ39K72s09xKYr+GOcKMq5cLz9x/nXBLs749HXqHoqCx6f
+i6q0XqCwvbWZ/DnsXk23to+ThZEIw+QM7kJA4zg1mdDtdIg1aPUdY0+/v9MO+OaCwuFiuEdl
+O1lLAjg4OCMHtXo1j1PqaaeNPbTrq3sLcsVEASRnYjuWYEqScZPnWeit11C4kvLYSLdx5YwJ
+hZG8yAfTHnS58XRXDkrKi0F5a2oRJYNrjEiXECOjDP8AED2OPMV3gvGs44rb9pTWcZYOIUlZ
+raTnnCn8JxU2LUzFMwmjVzyP3ifUpxjBJFQJ7e2mG2OcJGf3gWZTkH2YcEUWiaZL1PSrl/mI
+QqTbx4sU9v8AWu3PGcdqzaLdWu+G6O1pTyzDj9a23Q13o+j6nPf6hHZ39pfW72t5azTy27GN
+sfvIJo8+FKpGQSrKexBBpvUPSbQSLPZKLq0uI/HgkMgbfHnH1bcgN61SlSFVujEXUk1vJHE2
+5QBwc5U1T6hJKsZVTlW/Eo7H7itLdWz+GxdCFwFwRlRVBqlv4LgRNvQjIOCMn05rbHJWZzXg
+qbm+u305dPW5c2viCYxHBCvt2gjzHHGO1XF78NtTtugNO+I9prOh6jp99czWk9paX6tqGnyR
+4x8zbHDojg5SQBkIBG4HiqHxYYLlGubUTwqSTFvK7vzHNRUmkiYyQu0bcjKnBwfKvRh0eZlS
+5UW1tK2laW9x8qhmvSBHPn+6UZBGPU5z+lVLu7Mzs7Ox4LE5qXp9yYzKktr8zbyLtkjzgg+T
+L6MP/SpstppC6WkdreTRX0m1biK5UCMYOdyOPLtwRn70k6exNWtFbZXHgFtlrbySZ3BpQWIA
+8gM4q60nUTFsM5ZSwIQheM5qs1HRb/SNS/Zl4sXjKUIaKZZEYMAVYOpIIIIq1vLKS1Y2gIPy
+wKn3z3P61lm4tUa+nUk7NGuzsoVopAAwznnyYelW2ilniuYvBMlwYw1pMsgBVweVKk/UrLx6
+g4rJWM8kLqcsI1QKWzjHvmtpqXSOoaNqaaRr8UUFwUWZCkizRSoy7o5FZTgqwwQwP+RrzpKt
+HpxbZJubKSCa3LDwxdQrMIyDxkH/ACIxiuNlLM8QuCuPCOx8fw89iPIVpIraO6t7P5iaN2ib
+Y6sxLJnun3Byaiw2cNvq/wC0NMuFt7pWKNBOm+O4U/wsPMGsWtmyeh+lazd6JcsYVw0zx3BB
+GPpAIyPQ8/mOK2Nlq+n/ADkmmXDXFvp97JG0sdt+8UN38WFG4ilALYIxkEqaw9zcCWBrZY44
+HUkoEYssfPKc87c+R7VY2L3U0Ivo8rcBSGiJ2qwH4SjeRFCtdB2azr7oGw1i31bWJNb0eDU7
+SxF5ptxbK0Vj1PCrKhS3ULtt7uNcO0TEeJhsYI58HmnRJzaXccrFOPpOzn0r0631jULSynt7
+W5ZbedSJ7fdlJMc5K9iR3z34rJPp0SXrXNwUV7piJYZx+7kBOd2e6n3rZNNdGMk7KJpcxkmI
+RK4AAB3dveosvLcjJHJz5irXUrW2tCtvFKgVSX5fdkH0PnVLdzJHAN4OScq2fKqSsz6CWATJ
+t/CHcYXPYfeqTU2kgvJBJEUI/CG9PWpsWotG/cHcMndyB6V3j1y3nsrjTtX0+O9RlxbjcVkt
+2/mRu/8A09jW+NV2Y5GZW5uDO3PAqOV55rbfD34aX3xT6pseiejryGTXdTDpZWV44g+ZnVSw
+hRydviOBhFP4mIUckVntW0i80e6lsby0MN1azNbXKPkNFMpIKMpAKsCCCCAcg12ppLRwSTb2
+Q7VUOVbuRip9jY+PIAMbiOFxT9F0PVtce5Gi2Ml69pCbi4ijwZVjB+p1XuwHGdoOByeKmaXE
+0zBFIEiP9I7Z+1YZZNbOrDFUcpNImRyduT3GKsYrdliDAZxx9q0C27ai2Sn76LAJA7jtTJbI
+FRGYSsobbnH4vauKWRy7OxRop41dWV4yQy8iustmLiPxG+mVRk8cEVZ6faxyM287WXICkc5p
+kissxBTlR6d6lSobjZEa2t4dLinWWZpvEKmNkAQR44O7vnORj0ptvdwidDOpYrjCq+3+td7m
+IyLuZjuJxjGePaqueIWpkuZScRgYHr6VpB2yJR0aS+6oM0S2lvFCiRHYqJHgfcse59zUnpvq
+ffcyaeDvjljKuoYqzszqB9Q5wOTjsT3rGwSRXlvJun8FsbgSuRnyB9M+tR7S5FpfxXWCpWWJ
+mIPowrrSOVo9i6A6w1GLrp7izm2QX9wY5VC5JUscceo4+2M+VfcnQWoqIrG3tZ1KoivGq8gj
++bPY571+bfTutwaFLealPA12tmXzCH2CTcSAGI528845Ir7I+FPxY023W3u9Ycz3nyFnBBa2
+UYRIr24lESwYP8MaYYkZ4I86fHZjkZ9gWutSSMujpKJEsRLdTSAcpJJhFUnzOSW9sAVzn64s
+uhOmeoesrv8Ae2trOyXGzyht02Bc+i7h34HNeWy/F3oTpToO/wCs9V6osFsFu4ol8KT6pvCQ
+yNGF7l3bBx6DnFeXax8ddD1/+z315aQ3Wy6MMcrqp3Ii6mCcLn0P0n059K6ILZyykmqR9OdS
+/E6w1mbqHp3R5FlFnoWk65MwkG4LPOqMqj02N38jXzX8Tvi1qXTvxG0Kw1a1tW0m4lks75Ij
+gzW13boBcgnOArIC685ZB2yK8N+F/V3UE3V1vBbzNHqLacmlPIpYkobB9qMpP1fvUjYe9aL4
+kdadN/ELoDoy+1GaTTuprbTrG/063WF28cySEToG84nZMnP4Soolnbi6NI+lSaUjzX4kXkFz
+1JdaejkhJVjuZgf72JTlNi/wrtP3yfatNps2o3elfL6fZpDqmo3hmhXcPFhtcKsMCqeAcKZG
+Y8DOTVFqOjG51bXtT+YX9zdPvXH8Ge4J57+XpT0ZY7Oe2llmVpoT4zRY8Wddv1IWPZSPLz86
+4Xkk22d6xJRUTZ6T1ZLqltc9C9O6pHPZX4Ees6grZW8kWVI1t4eP/dYd7cdpZAWPAFet/BfW
+5b99R6l1grDbRvLb6VA7gMLJEZLONQBgsQkjgeXc+VeE9EQ6dpdjqekaTFZ2pWxa8uLl7gL8
+pbgiMvJcPhVx4jsEjGSQMZIq5sesbCHTNc+Tv7i8lt9OGqyN4fy9pAYV8C1gtU/GqJG+Wc4L
+sTxzmtYy6bOfJCrUT6n0wzyMkUDiO5aARR7AcRMcYA/5UPn5n1rdTzQWGm2ulRNtW4kgs0kP
+bxXOAM+p+ok14pY9e2GnWV/dXWrKiT6dpiRySBla4acRl5Iz5SHvg+QzV31F1xFea9cdP2t0
+YpundQ8S+DED5XY2y4lK+aw+Ijs3bGO4Naqkc7bZ7V1J8VNL0lJ57SLx0sYnHi5wreEgBx64
+XH5kV4X/AGgviX1bq2i9X9HicW9tY6bp3hxQkgGSS+hYE/zYCMP1qs13raxudANjESLO4uLh
+LYNgtJbxsu8gju7lll5wMdqwfxH69tp4epdRuWE8t+LOdASPEeCzQFvuGlcn3pynrQscVyV7
+LPoKJbTULDT3G6K4uYjKCfxMXXGf1Ne/aQ6jSQVRXf5iUkAZbw1Yqpz5fWf6V8xfDHrO21K8
+0+e+e1twmt2VqshfCsTcRgKPTIIr3T4e9Q+Mt+0LRzRWN1JExVt2ZWv5UCcf48DmsrjJaOuT
+ake66LK0118ncEB7KW6twAMdkTafvknNXdgZp7K0s5GDSsz24YIMtIM7VOPcEVQ6HEdQl1C8
+iOZSkjxgYy8jAsp/PHetAwheNJkVkM8gnXBx4UxwxI9Pqz+pppGbkPt3eSUqwAVwE5458vzz
+UiVWNs3kDxIg7+mfuCKfLJbT3rPLEnhTsHkUjGCeG/ryDTzvt/pmcOIyY2cjJI8i3v701Bk8
+0h9pJIbZSJAygDDEd/8A1qDq14ltHI424dCxYnj8/wDvUhporYNI8iQwsN7qzfSuBy4P8uO9
+ePdZ/Eq40z9oXV9aSXHTqO9tqUlnJHLcaQ8bKRdeA2HkjdWwyrnBXOCrA1vjxNnPkypFZ1f1
+Pr0Yi6j0e1N/cWsUdui22oi2lu3V8RrG7AhXzx9YwCADgMCPir42dXaZ8R5rq905Lqx6n05m
+0nUtHurL5W4c+MXVvBA2oykkMi9jlgMNW/686ttPiJNrsvSWp2NtdzyPPqFikrJDMdv7u6tJ
+CSjwTpkqhwyMrLnKgV5B1j1TafEbUJNC+Kccuk9RafFG0HWMTNHMLePYqLehRmcK2wpcDLgE
+hiQARvkiuNGeKTcrbF+E/wAXDoHTWp6a7yRzxX8NxHuyViuYY2UEg/wjxMlPYVK1HqrSo5tA
+nnuWWzt44VllBLKy7z4rEdzy5JrD9WdK6r0+txc9R6wJbu/vlaWSC2KvKzKRuODtIcJuDqSD
+nNUVw9ubF7YTybV5UOdo5HJ9QftXkeoTT4ntem4v5I+5OubO3fobUdJMUPzeoW0dpbTNAJjG
+xYFHC8Fjt8h5V5Z090Zr3wru9M6h0fVFP7ct/wBpaNd2i5t9St432SOi8NvjcBZIW2yIeCCC
+CctrX9oG76k1vRb2PRpLJemms71rCaQP82cKpkO3yCfhz681e3vW+r6dpuqaHLqovtMXV5Jj
+CdrbZz9MV7bjuk3hkIzAgOMBwe9c2TLTXF7R244Jp2uz1jQf7SMtnezzx+AkF+kZntp4j4Yu
+QCJhG/eNtwDISMYba2aj3/xa6ruuorHqbSJ5b+1ieSK3S7h+at7SRv8A8Hv4l2l1fH0ttPDY
+VgRivn/U+p4EQ3F/c216LolV8SPw2dRwdwx/X1qIusx6X4dzaRtcW92gaG5BP1xhgfCbBwGU
+jt3HBFVH1mR6kZS9HiW4ntMupdRXPT2qT6Losdjpt1ONVuulJbZorqyDDwpGjt5ArXUQC5Es
+Z3DP1LkZPmN9cnx5l07TE0/T7zbaOwkSe2DnlG8KQboznBJH+dRNX6x8ZhqUeqXt3CHE1st3
+NJPcWvGSm9ycgHOCMcVIXWdM1lQNctluYJ9rO/HiKM91Y87gean3k5WyvZajSMDPdT2MImu+
+p7hVdS/ysALlfUNuAUD9aoOodesrq4Syv7Bd0SgR3cQCyrnn6wBhgM1e6/oTXmqJFNdJHa2y
+vcySsPqaNcsSfU4AGfesReR3JL3k5SJnG9iVPAY+f2o7E9FlbxNOq2wl3OvKsoyJF9v/AFq8
+0LSpLTxJJ2aWFl8QBs5DZ4wB296ytlcSWE9xbJd216Y5mUTxMWjkA/iQnBwftWw0DWoksNTu
+J9+yO35Abb9bMNvPpwc1lKDukaRml2d7C3vWmku7xfpmzny2+4puqXdzp9mYoiViuW2yRYxw
+p/F9811a7n1yOOKxlitEaJpoVubkQRXBzyqSP9JbzCk1nry/nlV7l7rELHYyBgy8ehFOMK7J
+lO+jle6ZdXUsnyoEu9S3iOwXC4znJ7VSWlvqDXFs8VmSoIEmCCfv7VpdOkhugfl4JpGK7NxJ
+YDPt5Vf2WkaRpWk6lqmoCW6aIRRRQwShCZZCfxEj+Hbk1sokN+SusLeKVEtr2Qi4myQU58Ie
+WR51dafpg01/nIJobnH0rsXgnzLZ5H2qpjltYH+YDOC2CNvO0D3qbf65G6RLp0Qtwp3ytG3L
+N7j1rJmqRavZyalBFFpEkjStKU8IrkqwG58nts2gnPlisTq9xBqbiLTjHItuzBmYEIT7VcXs
+81zbraowhSV2kIdipfIwd2PI+ntVRIskJFtLdo6RDkRR7QAOwye9CdPQSt6ZQSdNLMxlkuYI
+efwBSR+oq307p2PTwksd3bTq2H3RHJHsQeRXc3VrcB/F04uGChJI8rKpAx+IcY88EGudoXDq
+F3N33Fjkiqcn0LiWECo920s7PHNuLwSxsUKOOwBHbI86zOsabHc3/wAvPNHa/MMf95kBIHoG
+PcDyzV5d3phQLuG4Ak47VS6lcWl1KsLyyqgRWL7Q37wj6h9qSlQnEo4dNuLe9eLbjYSDnsce
+YrW6r0X1VoGm6FeavaW0GndQILyylF7DIJUViu51Ri0eGz+MA+1VgKSSqqykSIv0MB6V1tIc
+ia4kjyAN0mAAXPlk1TlZPGjvK9rLZPaXMGLy0uP92uYm3JJERhoz7Zwyt9xXC1MclzELiJXI
+ICr5Z961Gn2FpLa+LA9uIlALGRgGXzz71QNNYLfFobncinIKkOVPuPKlTY9IzVyD+12knDhl
+kyMex7VN+bgtY7i7uGZVeVngVDg4J5+3FSNQuZ4tV+au2hEdyN8b7ANwPGf86y+qahCZGt4Q
+QEyFHlW8I8lRjOXE66trsep/7uqtHIv93ISPqHoazNw0viFZSQwPOfWlkJkkWFfxcLwfOrjV
+tM1CbRItcmFvm3uDYXUS8TQyBcqzr/K4zhu2VYffshFY6X2cOSbnZQAMx7ZNXekaTZ6h4lmm
+vw6feNGR4d5lIpSOdviDIU8cbsDPmK66Pc6RDpc1tLZJPfSsHhkJ2mLggqfIg8EehHvXB9Gu
+bqNruEcAhJMjjce2fTPb71bmrolQdWtj+n7rWdFkk1nSb8201tIiOhQOrgn+NGBV1yOxBFaA
+6v0fr2oi/wBTs5OntTeQSNc6SSLYv3EgiJzGwPOFOPQCsxpVrPcRXKwLILmIK0YHng4YH14r
+rPp9o5KLdGKaLIlRlJGR5qf9KmTt9lw0tHp911R1PqEq6PJ1DKIr9/Eu0tZPEsNQcqR8yYCM
+JIy534AJPPevQekvhWl50houtW5Ns17q0+mQkyfu/HSJZI2V27RyK4UZ7NgGvEk6b1zQ4YtU
+hmjuYYwk3jWz7lA4Oc+ffB9DXtPw5vJDpt/pmpX0lx03rFqbmO2mfPys+cCVDn6AT9LY7YBr
+hyTaf2d0IKjCWVhaafrU2n3kGba+M0UDyRFDb3a5Gx1/h3eXvVdYi902UiQqdhMMiMOCV8iO
+9e2fFDpGeXQoepZJFS/jeC2vfFXIlmKb4ZWOMEso5bz8ua8w1WKz16zbUrbbFewR+LcwFsMS
+vLAeo4OD6YqIvloco8TTfDW8gv8AqmxOozm2jDhWlSPxGQOdm4J3bGQSBzirS3M9m9pa3Ajj
+W7uJI0mBG10aTw2z6MrKARwQCKy/TVlDOiXunXTxXlrtvEjDZMsA/Ewx/Go5I9BkU2ZrmXV5
+YkuV/wB7R720DEn5hwcyR5/nIGR6kVm4miZqJdc6g0O71OFBLFY63JLqNnKygxXEsaNHIsMj
+cblzhkU5wRkcCvQOo9Eg6eEkDajFeWHXHRVv1DbXkClWsbnKyDGfwTQXEDKcjkN6GvJekOqt
+SurG96XaK+1TRUun1a/0lF8WNoQhWW6RD9UciIcl0wcDDZHb022jvE6X0KeLU/21Ho8E3yWZ
+FkW60sD94pIGSDGS3PK7ee1Ol2HfRndY1PX9fXU+rNaMAk1e9kWV7WNYreS8cK8pWJPpiZh9
+eAAOTip3R+pw6Ut105eIXimaVSFbHGAy48sEqrD/ABCokKQ6Vb33Tt9YzXOm3pW5t7uFQJku
+YwfBdW7YaMlGHn3qttNC1LU9Vgtrabw1/DPcmNiiRsuVeULlgo82xhe54qGmUmj0XrGG+Non
+XGly/su4fUYLJJouEa62iVJWX+ZlwSAADtb1NZD4radY6X1Bb9UQXelf7PfE/S/2gkOkFo47
+C9DeHd2zQMd0ckU/1hTwyTfSSK2J6k0bXrC90/TJplS4021MsRKuHuIMBioGckcsrDupPPlX
+lvUEF7qFzPpupz2qJBeeJaXCKAPFH93ub0x9B/KtMUuLpmWaHNJoqbnpHqDUusekDFcWsuh/
+Nfs2M28q7oZJo90hkUcqHOMMeK8W1l2tdZZZIVjkglO0DsrA4Kn7EV7PoSwaj03dar8nKL+2
+My3MlurGSFkDPFhk5AOG7jHevKr5IZNcxLc2pg1OFZd7j6Q7LkgnyOa74StHG4uLorrbUJbW
+aZZo42NxGVYEZ88hlPkwPYitL09r0N9PGb62+ZuYI5YnEg3CeJlI+r/EpwRWY1rS76xuY4jF
+lBhkKncCD71aaIJtLlbVYpdrxyKwfbkIPPI8/tVt6EuyNfXkcMrsyeESTFIB5rjggelQotC1
+l7lUsrSa6Ywm7VYlLb4B3cY7gY5x2rfa50xZdSXlteCUWgmKJcsAGSFz/Fkd42ByPMdvKu3w
++1u96PubSSeOCRdN1BrfMy7jaxsxWR0I8ipbK9iDUch02O+HN78K9W6b1/oL4hWDaZPfxDUO
+m+ooI2mn0vU41OIZUX+8tLnhH7mN9kgyAwNPZWL2Kfs66jNrfQurTRE/XGxHI9xml6y0Gyt7
++PXdDthAIJ3gvIIz9A2v9M0X+B1wceRyKg9U6i131oNTsLlJFkgtnLg5yfDUEH3yDn3pt8o6
+ISalsuPlbgawqQxZa7TbEobG2byAz3yfL3qF+y0uLe7hk3wxXYA8KRfqgmU4/ocir+9hhlnM
+YkZfEiS5s3H8EwAPHsefzrYa3pMfXHSOoavZ2S2vUdhPbm7gXIjv4nU5lj8g425ZcjzIzWKm
+zWUNWjFfC7o+117Wbe1S4mt9Y020urhUVOJniXeFJ9GAYZHIr0jpWzhveiOpIJHZWgE07q0Z
+ydoV8exxis58GtLfV+tLPT4tZGnG4guLi3vTHv8Al5Y4W3IwHJVzhT/z5rX9I2S6rqOv6fqt
+ydNhvLy2ebYSRGkikMpx3HODU5GVjTrRjrJC121zIgG6MIcd85459a2elWLWOq2mpCfczoxO
+9fI8effvVX1T05L0dqNzpc7GREuEWGQ8GSF+YZV8iCOCfUGrnXma30jQepbe3ykVw9pcEH6d
+rLhCR/zedcb26O2P42iNayPaatebZN0FtJGJRn8B3FQfse1T57aM6k0D3HhRSDepHIBPBB9M
+GqW+1V21O5l3IV1Szjtb6Ip/FvBDcfxqygg+9X0l4tqtwxKGeKaGeNHUYlCnDRk9wGB/pS02
+O2kVutRaWt2kN4JY2mjzuXlZWHBOewI4PvWKkt2tjeW5QsUk2BT3IK+n51u9Qa2EL2sayyQp
+JthZ/qaInJCEew+n8hWW6pu4mj064s1UyxWIWc4+oOjHbn8q2x7MJnh9zp88GuvCzP4DkqwH
+/hnvkedVHU2hT9PavLps08U6gLJFNE25JI2GVYH7HkeRyK9Q1DSo7rWWvVTbHdIrrtIAUkds
+/esB1fp72105e2mt5IZDFJFIhVkPfkHt3z716GKfg4c0NWZlkKnHmKTB9KlIBKuxx9S/hb1H
+oaTZH6t+lb2c3E9YS72BpYIhMzEBVYZx74qNronKfPRuJFQqjvwcN5/1oul+REEWFMk67pG8
+0QngffzqvvPEjllt7hmEUg7j18jXiqJ7rkTrK7t2/wB+sIUV1ULPGP4W7bh7H+laXQNWkvYL
+u0mbbG6BJSB+Jc5XcPY9q88tLu70y6ju4RuVW2sV7EH1rQafrTWN+kkecSEbkP8AGM8qff09
+6JxoIyNWuj3GtM0EfhRfKq0kk0p2hYxyXPsP154pt9pVjMnhaWZgsB2O74R5mIyHVc/h8sd/
+M0271WS1ZLizlMLmRWV/P8JG1s8EcmptvFaavZGVJEt7skmJWOFkPcqD/Ac/hHn2qGqRsuyu
+02KDwpLW2M0GrO/hulzENhQHcHilyDG+Rgg9x2PepZ1q503UEvry3FvdzR+E0236LpDzslQ/
+Sc+uKbeQ3LzQ3eoKyPGyxqZeFZgOFbzp9tqVziS1ntESEMd6XAEsSj1+ryz6YqIsH9FtbSaJ
+f2kgs7AWsUqlzBHuKeNjBA81HmPLNU88N3ZLHHE1xAUfw3XuY18sg+Xeo41qO11Ke1+UuLES
+DMTWuWRvupOcfY8VdQ388yJb6rO0m9Ctrckneh7bCfNfPnODVPQKmctL6gvdAW9jazs7u1lX
+ZOlyv0zRZyQCPwn/ABDkGqjq670ybU/2volw8qMqu8Vw4YugUYD/AOIDj7c1MuptRhYxyXwS
+WMkFH2DH3yMVW65Pe6l4TTfs+XZEYmMCQQyPkjJO0hWI5wSBWmOVMiatHn19qRW4kvVysspy
+wB4FZvVdQe/l3sOe+49z9zXoHUfR9m2sjTtM120ksZnKwXk8qRuq4yDMqlgp7jANee6pp8+m
+X01jcFWeFyhZeVPuD6V6OHi2eZ6hSS6INFOMbZ7Yo244PlXUcNMRcZGe3nTwoIPfNL4WCoZg
+A3mOcVKhhgdiDcNjzIj5A9hUtlRjb2RDGVGecetTdKd0v4irFXJG1gcbT5GpkLaXEGjaRpdw
+84in681cyaJp5iiurIs0b4C8cq3pWGTLSpo6ceHdpllZyDU2eafab0HfJuT6ZfvjzqfBp/gK
+Y0B/eZbax+kD2qitZmtZhcksFB2Oo71p4tR0+6RI9NuplkU8/OIqhvbgkV50k/B6UKZIs5f3
+SqZGIOQHDEHjyOKlxJCJkmvlKyQjMVxH+L9R/rXKa2vbZR87Yy2jTDxE3RkRyD1Rvwt+Rruk
+ngeE93p6zQTxExq5I3r2JBHmCKwb2dCWiyuntr3Tg9xo9vqwJO2cztE0Y7jbt8/Ihs1V2tho
+Egnj1CDU4AyYhFttbw5Bz9St3U9u4x3p+iTNbPJ8lO6pkNJGQQ3Hng0mtX62dzH410QSc7FG
+eT/MfXHl7U09ktHEaXopgVIb66juiN8cUsChGAOCC4PB+4qaYNQ07TIjaWFk6738QpdkyKCR
+glOBg+oz71wm02e8hE1qwl2jgDhs9+PWltTc3dgLn9ns8Fn9MsnI8JmyBkeQPrjGapNeSXFk
+eee6u0aN7SGdWT+DG5P04NZjVrN1jw8DDyUEVu20LT5Pk7iG4+RvlGHYqfDuEPABxxvU+Y/E
+PQ1J1PQtO0O5vrDWVt7zDO6tBM0aOw4H1EZCnvx9q2ivKMpM8I1CIiTwNjeIp54qvaN4m+oF
+T71r9dsrqfUJIoWt7N1jeZUBKeIoHYE8kkdh51AttNMr20F0NxuEZEI558vzr0ccviedlhyk
+VdmsXMswfcpBDJ3rvG6zF0ZTKJDlSx+qn6PZpHrcVnqExtkSZUmkxnYhOGOPtXJb63gneMwC
+ZFYrG+cHAPBpu29ExaS2TlsWhijTYFAO5iF5HNW8t4ou/ntRsxPDNkOUbAwfwnI8xSaZq1nL
+YNbmIfMSOMTMfwr6Y8/vV7ZdGatNok+v22nTXOnxTeDdtFh/C4yWdB9Srjs5G0+tczbfZ1Rr
+tD7LTbWOOKeJS8ci8fxDB8jVpDpYaGGKNZokgRngjbJRFzk7R5KTk4HFV1jL+zESCzvIxHb4
+KlsMrRtyD+RrR9PXc8E8rwXUYlYLLDBOm+CXvlA38OcnFcsrOmJIs5C1gqyGNwSA5B/EfX9K
+ebWO6t7lJbO3mubZPEg8eWSN2ZTwqOn8RHkwwcetWFloUwle1uLCexhum8WESQn60yRlM/iA
+IIyPSrjT4rLTIJbHqHT1v9E1F0trieBDHdQMnKTREHko3JTz9ahd7LekYW/vra5uXupbO8ha
+TDGOSRJcN54ZQpx9xUvTNXsLr/cjdTQLu/GRgI3kfUVO1npDXLZvnLCaPVrQyssxiAjuIjzt
+LQNh1z54BHvVdDoFrrtnOkO601O3USReI23x9v4kA82Izge1Ju3ZSVdF1c3Sagsmn35trO9R
+I/E3W58KR1/DcKU5DMvBA4PfvWb6hjt44wqXMWpvtwzIJI1jH8uGFXekwW0+ki01XUo41VHF
+nepPsmtWB/u5EYYkjIycA5B7VyhtriwzFdXcNzbTBxK0JDuMHG4L/EuOciqWtkPboyUOn6De
+2Yt7++n054QHhTwzMJMn6gG42jzGeKp+odDtkHhWF1JNEgH1sqg5/wCkkEVrb+zh8BUgEjzR
+FtoEmY3hxn6V7j1/pis1IJWhN0hKEMVWMD861hIykqMXc2xgchWdgvc4qFLITiQsxYHIyK03
+UDutqLmS2CM5+lxgE/fFZOeVrh/PHkB5124rkceaSWiVHqil1e5tt0iMCk0TFJFI7HI8xXp9
+58YbT4h2GmaF8VdNW8e3ubeG56ztrTdrPyKLsENwu9Y7zYNpV5MS/TjxCMAeX2Gh6vqStLp1
+m8+zkhCNw/LvXIvqFq5SQTIw7o4P9Qa3tdI5WpdtG+n6f06x1I3egar4iRkvZX9vui8Redrg
+Z3IxGMqexyOatH0yz1a2jvbyZl1WPsuABIwPIOB5jBBrLdJMqZbVJGgtXZWJCk7Rnkgefrit
+9fwTaXqdvfytFfWM2JEuLd8xvGTgOh8seh5B4NefkdOj0catWL0fob6xfWltGn7ya7ihfYeQ
+GDc/qAKkXVpbyzhGyBJ9SyqMfUO4I8iOa1lnZ6dqbXlzYxWWnSWIDx3kBKSKWdc7wDlgASys
+Bxkg0yOw0u1vbiHqO5F3bglALciKUSZ+mRJBkEjvhhhhxxXLNpI7McbMhdaZJPB8wERZosoj
+HjxVH29Kpwku4rcxCF4/objnmvRtV6Xuk0+PVdLhkvrSCURXMluDuTPGZI+TE2CAc/Se4Y1T
+9S6PPo+lmGWGK60+4lxYXXBmjZT+8QsPxKCcYPbIpR+S0OXxMvLatbtd2iFXZV8WJmTBYD0/
+ImqXXrDx7NrjGASFceSMexq3vrpkdVWQs8cbBW9iKpr/AFaTSdNJlRSmoRGOSNxkgAjDD0OR
+W+KOznyy0ZJXltg8TOVaPO/H9KWCeKe0mjLKDt3liPqG05wPvTb/AFC2umNwndlCmPkfnmq6
+dHiZdwCsRkbT5V6MY/Z505/RPF4t1aagXdlbEcqL5MQwBB/LmvVtD6vaJrAR3qK8WmzXsih/
+ogeOCRhI5823uMDywK8WErgOQcb+GHtUuyubhEujGxAa2eNue4OM/wCVXS8mP5F/rHWmq69o
+ej9OzXUh03RklEEZPCmQjJPqeO9aTVdXubPp7TtNtL4RW+vadDczrnAd4pHQJ+RGR96znWHQ
+evfD79laZ1TY3On6vqllFqklhcRFJILWVQ9szZ85IyHwcEArkc1Z6tqA17pPpO0ayhtx07Fe
+Wz3QxuuQ8viICB3K7iufQCssmns3xpOOkb/4a6rqB1Ww1OO9FveWLwXElwELOGikXaxXueBg
+48sV6ZeWFpqPV+h9Oaekwa1t1gtZMYXZLelmGe6iNZCcemfSvCOktQuba7OqW4ZHtVKDIJAD
+DB/M4r1DS9Ru9XttC1WO9aSW0uJ42hOSVQGNgwbjP4mGM5rklKjrjC6LHTNHvLLV+pHleMxS
+3k1rbKW/vEEu07c9yTjaO/NU95Hc6Vd6jZ3amJ4WaxWOQfWs2TvB9No7+9bDqvU1+e1GXwo1
+k2vfWscQIMZnBKbfQDaoA8iCa8yu7uXU+s2l0xjCk8mo3iRzMZSH8Mv9R/iJ9azb8FpeSLeq
+moobMtkyOp2c7WCnAyPMgjNbvpCystDt57nXGkmGshba63jLJArB+B5E7RwfLNVWt2Frpk13
+q+lNboLVbVLNlbxDJdGJXnbH8qs5A9xV7ayW95DJ4cLbYzHbTWsR3OZCcBlJ7lmP9cVUGxSV
+9lzq/Vl7NpPw8uNRtYhHa2M888OSUd1Z4IdwPO7wNuB+dO03q280rTrDqe/v239ZSQ6X1Jdz
+4wkE0L280ee/LQQO3bhB3zVD1ch0WC66WuILNr7TrqJJZlkLvE0I23EIHY/UQM+qtioWrx6f
+N0F+ypLl0huNdS8gcjdiIQbJ8p/Fh2Qj7GtJZWnRksKa2Xmo6xqtp09BqESzw5u7exuYWb6Y
+ZltpYmdT/CpMIPuDWUs9Svry3gu7gq8tqsjQ+MP7yIcOCP5WJNb7TNG1XqP4JQ9Q/JzGOTqC
+w0u5lEgALIkoGFJ3HIKuXA25JXOao7ezsYre4SALLLHhC2cqIucrk+fcn8qjLkaZphxR22UF
+rrkHR6WxS2kbTnu0vcMMssqMCu7/AJQMHHlzW76S+IV1oHTXxA1vTL2Y/KaGNft41OF3DW7Z
+nOD6mQ815/q+mreqmnSq6RXEjpPKDwqoBnb7ncP0ptk0uqaR1LZaVEsUWt6Dc6IA2QIYYpYb
+lWP3aLHuTWmFryZeoi2nR+hnw9+KGgXmjfDzqS1vYvluqNIkaMqwz48aM7RFR/Ep4I8hz516
+fY61Fd65caUzK1vqGn6df6XMhykySjEoTHfDgc+9fk31N19r2m9DdLX3SEqafJ0h1XcpaOp4
+OLK22sR5qzRPu9Sa+geuf7QPUNz0npXXOk295p+lDWYrhrWykMcnT63cUb22wHloUuo7tTER
+tdZccHaa7IOD0cGSORPo+7U1C2e9NoLlFVJ2sSZTtCy5+lCT2Of86m6hc+DE06B1kaLcVON4
+IGGYKSN23GWA8q+N+of7SVt1LoGrzXV49hqSCKV7mynzHLby4WW7jUD6JYJY4ZxyQ0YkQ4NY
+bXvjZ13aaVr8XUV1+05tQjV5tJebw1mureUeN8nIMtBKEcSqV+maCUccV0wjCO2zknLJLSR7
+Z1b/AGpW0mFrhbb5a30q4ikubi0mEkcsTkgXNrI4ANrKVKB3ACyK0b7T3+cfiL8bdft7651K
+a+vo7S5kFhPdTRlHaIN4ltb6gq5NrcwA4huY8rJGELAgV59b/EyylvRr2kWsl7Ctu66305rF
+wqrc2dwfDdbaQ8DJ4aP8JO18AjNZfqPUU07QWXTb+a70u3KWe+clLnwG+u3inU87oiCqk5H0
+4BwcUPN4gOPp93MtP29d/tc6jcRG1nUNcG6tFQicO+Vnwv0ESNhzs+nxBIMDdWJ+JutatHqp
+i0nUA9nOy3UdizCWOBn/ALxY88rGXBOzOBnGKpLTqW+6biubW5QS2LSgKAMI8Uo3MVH8JyA3
+HYg1Va4k1vqsV7FKsoidGQ+Tg4II9QRWUp8kdEMaizft1bG/TF1oEOlBdJ/aNtNBLOfGlspY
+VPiQRt/DGxkY7T7Y86qdUksZQZIry3CtyANyMB5ZByD+RrFNrMkN7dRgF47iZi65xnJq4tXi
+kPiRSmRUXBUjkVwZk29npYWq0XvS+pHR9at9StP95hTMVxA3AmhbhkJ+x49wK1F11Jpxjs7V
+b25lPgvGzsgDAlsjIHsB+lY2KfTo3AtNN2Oo+qVpCcn2XsKk2tnJJby3JGETaAT+IknjFck4
+puzrg2ui61E3t5bhYykj+LmMK3IDLz39xUrpCVrTRNT0bV7R5jeAeAZHZW067V1ZbqIdiSga
+NgeCrZ7gVGgsbd4hAC4E4xv9D/pVjFNc2rR2l0+7+BS31EccHPeslRtVbOVpdE3c0U9wYyEM
+vKllc5/CKDfG1Mqsz/KNIJVCjJiPbAz5V2WSwnlWCRmtZHbaJEYPGCe4YdwPcVFXR57+9uFt
+phKlrbS3Vy0J3KYoxk5HlnsPUmtYw5Mxlk4okXF/DLpHh6jcbVuSW3scYgByFH/Mwzj0FZ6+
+udJk02dYZZUa4dE8ac5wASc4HYVUa11THqUjJDpyBdoJldySMDGFXsB24qt0e4h1HWbbS5pM
+QTs0byyNtSPKnBJ8gDjmuhYq0jmllvbL5rRrVfCa1hkmiiEgPlIp54I9vOuF91Dpxs5rLTLK
+YXGFLobgyxsAM4AwORWaveoLyKGG7sZcboflZcnOCvGR7HyNUguZYZIZY3O9P3mc8kk1pHB5
+ZlLOukatupL28ltxc30jwQRhPBDfu1Uei9hV0kEaaTFrJeNbWaZoooy48Rio3MxXuFGQMnuT
+xXnwlRXMSgqpJZgP8q7jWLsTmeQhht2lD2K47UPBfRpHLqz0a76jmg06GCKV4FkT93HE2E7/
+AIiByfzo07q2eC3FlFah7BR/vKNlhK/87ehHkR296yHU+pWo1aQWhIt444ltlPcx+GMH/Oqt
+NXuVjaJJXRZO6g4Dfeo9llLKj0CbUIJd81lNIYGOMD62jJ8j6/euCXaxXQSWQlUUHAHnWQsN
+YmtN0yHbxjHkTVjc3F24065kKqmoK7ptwM7XKEH8xWbwvo0WVdmsl1SKRFB3CQyeIs2DnbjG
+3HpnmuXirLIpluC4JA5NZwak6gxmTHh/SSW/0q3gjhij8e/v4rVfp3NJy4zzhUHLHH5Vk4NG
+qnZoY0W4/dK6oq8uWbCge58qmajYNp1/JpPgeHPZnZco/GWxnH2wQfzqi1fqlLmOPSNEtDba
+dbDeBIQ0tw5HLyMO/sOwHFc9H1mebV4RcqoDfUzYwMAY5/KocdmnLQzVpVtY0nhhJWRsAj0P
+rVOzIZCwIYfynuK1Gry+NdSNNhYyAoRV4CgfSB+VUVzbqXGIdykkKw4I/wC9NCZHR5lJKyfY
+YxgVZ6dcs0DgupD/AEsvrjmoEVpMDu/ix9Xtj1qH4zRTvuHEmdpFVFWRJ0Wt9ry6RDPA9osi
+yg7QzlWj9CPyqhsb6xiRmjkcGY4YOQf61F1G5SffLIhkkRRnJPIqitzNLMFjXO7y9q68ePlE
+5MmXjKi+1e4LQRJ8yNjj93k5CYPKn0z39Kp51ljjZLlCD+JX9fzrhczksVH1IBjB8q4pPKil
+Edgp7juK3hCkc08uzmw575q5GvS3tiunakqvtj8JLgL+82A5Csf4gD2J5H2qn3nPKqfyrpFM
+ytv2ISDnkcVrJWjCL2T7O0WWMoIS5Y/jXutaPQrk2h+XvrZhKE2SRkYW4hPk48iO6sPMVV2r
+vIUuIcQpjBC+RrVWd5+0dNjSe2jmW1J8KQACa3z3Gf44j/L/AAnkedcWWZ6OOCSTLey6QL3E
+V3YSKou42aGGTCeNxwUftuPbB8xWWuBC94ba5hEEwYxy+OmGBrVaPIyWUmn3ZaS2Rt0QP1FG
+Pp7H0q40PUrW21tbTUrSN47wC3Fy0aO1uD+GQ7gchWAz7EisI5JXRpLGnso9JuNW0nS3uFeO
+4trYr4iqM7EJwGwfLnBrf9ERSXdjqsfTjRv4NuXtbfI3b2UloVz3P0gj1qmuYLDSdZtOp7GN
+Ljp/V99tdRsMLaTMTHcQsP4Qcb0PbkY7UdI6KW6yt+l4ZmWdZmtWi8UIZzsLIFOeX4GB5nt3
+pS26Ki6R7P0vr1hrXwiuoNUslv4tBu0lutFmLQm6speEMbnOwpJuUcfQxB7V5T170To+h9UX
+Fn0RrN3qulPFDd6fJqFsbe+gEgyLa6i7B1zt8RcxyDDKcHA0fwl+If7J1y3uoYwl9AklnqFv
+cxbxcwSKUmjkhf8AC2DkMOzKDTNUtUvI00t3kaXSI2gUTSYlktQ30+G57gZyFPbOKlOrQ2rp
+nl2nazeaB11YItk+nyWcqpLbuhDW0u761IPJXaSMHyNaLXRcW93caZCY0bT7/wCZsrhIwGeN
+vqjO7zAzj9aoeqdPuk12K4k1E3M8csfhtNGRJJF2BOeTjtn2rWalpZm0XSuq7TUbe5tLvxop
+rYHE1m8bbWjceYPDBh5NTe6aGtWiDodw9lqUnUGkXJstStbiK4huYzt8J2Yh19CjEkEHghua
+9P6N6ttun7rTzqvTxjsIpBcwwxHY1qSSGeA4w8LbmBVs7Tle2K8y6QksrfQrnUb5Vltl1F4b
+hNv1KjxnYCfMFgMe4rZdE3ujdRw23Sl5qiNIzzR6fdtk+EjfUjr/ADZXcCvfKkd8USVJNDjs
+0OtaPN07davoUsbXMsCRzaVNC21C6yb1kwQcoVJVk8j2rp0rqeu9LfELXdT6fiuoz03bRahq
+kkcW6S1sZGRfGwe8GZDHJwRtfy71IRbvrLQb0JKV1/RIo47eXfzctHIVjnX1WReGU8ZHPes9
+011zrWp/EjR9c0vVBpWo3+k3GlX0vgxsJIXiaK6t9j5Vw8e7CH+IDGCAadK7ZnbWojoNLi6f
+60tbSOMpa2xaOyhRsGWEn6VRzwfpY4BOeMVI1q202PWG0ie0Z9N1AsLyJyQxbcQkgBAMciA5
+44NHV8Om6fpclnpt0NTsNH1H5EXLKf31oqYil55AYFT6gg+lW+oGHUtM0bTb+5aW+txNFLNI
+MOy7R4O6Ts/GQG/XtUdGn5UZD4NdQp8N/in0l1hqkEn7EXXf2drS2pyWtXzDcBkHokjSAH8q
+8w+Mnw3T4SfEnU+go7uLWLXTdSlhsrhOUlgV/oVs8/gIz98it7JbS3HULy6m4hj1KMyzImEI
+urYbc8cbmjOc+eKrP7QetQdV6w3UwmklvruCzPzTAId8UYjcsB3YhV5866oTOOcXyTPMNf0a
+ZL2ci38BfEZ0jVy3hpn8IPniocWo/JgRzQCSOUeG4HBIPG4e9V+tdUapc3s8jyiSSY7pPpxk
+gAZA8iceVTorC71PRo7xoisiybbeU8Kz4z4Z9z3FbN8VbJVN0jS9OSvMm5ZGmRoSknHIC8Dc
+B6DFdOm7YarqOoaZPKItQup4zaFiFSU7tpTPbJB4zxSfDm8trnVDbywNDdyQsCU7K+CCHX0P
+bitq/TFr8zb32nWTzWVzateRvjebWRDtmibHkrDIJ8iK55zp0bY4t7KhdHtNMvmj162kmsVn
+8C8UEo0aH6T9sEH8xWJuOjbi26vvdNtmmf5KQNDvwPEhJyjHy5BHNex2th/tJFbX945WcI9t
+eRltwkU/x+x4Brp1L0dd2Om2HU9gboTWgkgupQgkCwN9Kkg/iQ5IP8pxWWPLxfE0yYk48igk
+6U1D9nuZI1nt1cBUU/XC4HPHkR2x71rNDmn0nQEv4Ymms7aPw9TUHB2kEK2PVTgj7Vy6G8O4
+0jVEvJSkwvIbclW5RzGWDNnyITH5VaWIvo9InihhLGQzW91F5mQZkQEdirKcg+fPpVy0yY7Q
+34ZdK2+m/FTQmubprezvXvlFxHGCBKbYsqkejgflXWeO26eTUNVuwVlNwba4liO6JyrAozKe
+xxkcVddFQR9Ra1o9q8axCZTdw4YrgxQOeD3BAGKzehX+na9d61omsukcV7eS2MpnU7YnVSYZ
+h/iDHDDzDe1S34Y0qdo59aTS6lpEUc7AwQTBbd05WIZ3hfYZJx96rtJu5tT6dl0F5Y8ANPCZ
+m2qZN25Rn3xXXTJEt7L9napEz2k9igu1C5McsbGMsvvt5z7Cq+MraPJZXnhL4DLEjKciRQv0
+v/1DmsuOrZqn4JOqRqI7e4kh2zpmKXByAy4K/wCdE1xcXdgb2LZJLC2G9SO4BHoRXSe+064m
+lhija3lKghC2Vcbe6k9/tVOd0NoZYGYhyQP+de4/y/I1DVMtbRrI7ezu7C8103Uavay2kMtq
+VKuwlBCyjyKgjBPkaxur3imyjW/t5FudLlkV5B/x4HPP32sMj7mu1nrUUls8M/ieJtKkZ7DO
+R+Wahm+bJlTEqgnKsc5B71cHRnNGYg1FXX5SRRMjR+GM8ZXOQc+tROrrhusZ21i8tEjZrSG0
+cpIWLGEbVc589uB+VTLyCwOqQpYuIkmchoyMNbsf4T6jPIPoa4JEbQzRtHua2lHiY5DKfP8A
+OulScXZg48keTXlu9lctCQRtP0kjGa5ePJ6ivQOsOmop7yGZPoRgcsF8vL86of8AZa0/+y2/
+SuuOaLRxSxSTo2EkqXjxqz7pcbnz/Fn/ALVA1mV4YvCmLB3KlWYdxjir290Gzs9RGnLcNctd
+xxfKTwDIw/dZFPOR54PlWW6kRhBhbrxzbylA4BAKjjIzyBXDGNvZ6TkcFmVXJhkOxlw4Ixiu
+Mc4uCNr7iG+rHl71W/NMVaMDhhyVODxXBdRlSaKVlQCMbDtXBI962WFtGbyJHoUd6NSsJVmy
+JYysqN6heD+o5q80nUm8BbbxFCsMI5Iww79/X3rEdM6kks/y8suVQExk+ftWx01NY00Tz6es
+MekkqJXjiTxIHb8G/OSFJ7EcVy5I06Z0Y5Xs1y6qmp2DNNa/OQ4EVwDw+4evv51U32lSSQeL
+YxS6hYXI2Sws22SI/wDN5Edweal6Lex3E777z5S+cCNw5xHdp5EH+GRTzzwRUuXS5pWm328o
+lRS7NFklWH8Y9vUVyv4s6krRh7/S9Y0WMXLRXclgSFhuimHjb+R8cfn2OKkWHVWu2kyWYtkv
+7fALQzAOu7POB5flV/qkSanF83cX0kUczEXJgGVBxgsE7d+SPOsjcrdQTQWV5NHI9r9MTqmA
+yE5B9ff2rRPkjOSaZtLj9iarCZoJJoY7hfDmjnXxGt381OfqKHuG8uxrH3XT7MXjWSAbM7Ax
+wX9gf+9drbUr5rrw2xMTwCmSSMdvU9quLVrC/EcI8NpXICozgZJ7EE8UfiGpIxUUaJH4EsZi
+k7ANgbjn17Vx1bp6SeVbe+iliuVjzFHKv94vkVYcMPcVtby7v9Amksb+w2CXOILuBcsv5ja6
+/bketSbPUbG6sltHto1tImMpiViPCc8b4c52Nj0O0gds1rHK47M3iT0zxq50qa2BWWFxtOR5
+4puoRCV47hbVI1lQZZAeGA5zXqV/0dAvjX9jevcwRp4xnwd0AzjbMuMA57MOD7VRyaQqkxmI
+iCYbXcDOD610R9R5OeWDwYmzW32vHc2qzIe7A/Uv29K6TWSlVmtVBixgEDDD7jzrTab0Vpy6
+htuuoE0uPZlbmaB5bdifKQplkHvg4rmdNtrW4aK4dHKsypNayZR1zwwyOVP2BrR5V2jP2/sy
+qQ+NJtkyRkBiOCtbPpnQ9UKTvHYy3VoG3KUIyAPbv25rtNpj2MltqVtCEimX97tAw4PYEH7U
+q3DWYF5Cs9teI48GaCQoynPmvYjFZyyc1oajxZY6t0d8x4Fzpl4k3jfUit9MmM4IYduDxmol
+t01fmS4ja3ChAC6swUjnGRnvWt0nVYdTt5m6itY5Ut5USK6t08OScEZPA449fWpduunaBfC7
+v47m+aMmW2hEwVZFI+kStzgA4yg5PbIrnlrTOiMrGaDpvXQjj6Xnvro2VkzRx22pTBrW2U5J
+ZA/4VPqvetBG9h0oIBZwaL1rZ2ryvNaz2ay2YnKlSmdwfIBBVsgZA9KqrHq7Wtfu5zq8Wl35
+DKkllJBtUoOMxfykDJwTziut/omipcHUdDILXcbqGtm3RnaQMFWIKN7Z71DrwbRbfZW6x+x7
+Cezm06QSRxxB55BGyGORufDYEkkpnbu7GnXmiQXKLdNZ2zxTwtcRyo5Utg/UPMZz5GszcSzl
+5LwRylBlHZUKnAPIKn/Wr3o3UYNRjm0wXQCs37tpAQEkx9OD5ZIwfI5qUg5b2RrB5bW3kTTr
+wQXEUm5YZlzvH+RqwhXT9akF3FatYXOzZN8uxVd2cEMCTnPfipQ15lYx6zZ/NspEbKYgSh7Y
+3cH9DXbRoooNTkl02L5a1DAiKaYSGNu+3LYPrgdxSod0dzqOgx6O9nedHafPdW+Qtzvn8S4T
+1kCOE+k85C8+dddNu7Ga3A02/itbkRug0/VLf5q1uYyDuEbHkMP5Cc+YNVkrrpsxt7i3cXFs
+/i+MmUdlck4KElSvlwaizXFleuLe+sJbMTgiNk3CIP8AwsAeVIOO3rWmN0Z5EQ5NP0/qezj0
+3VtI+Rlthttpogw2gH8IzliBz64qnm6TtGuLnRIL92maD5y02ocrIn4wx4KZGCD5nirXUptV
+0lrKa5nivYyp8B5eWgdTyjhTnBJ4bzqXqPxJtGZL2fpmykAX5e4hBZZIlJGSpz9anuAeQQK6
+oSrycs1q2eYdSyaPrlhp+p2qSw6+iywaujkCK42keFKgxlXK8Op4yMjuRWPlVlcgqV9RW36w
+lsbq/k1OxkMttNISuVw4U+T+reprH3ZDZA5Cngk84rrxzs4skaRY6NMssJgSKEspyyn6WK+o
+z3+1a3p/Vdc0fEmmXl3bPHkxXduxSWNT3TI7ofNTkH0rzmMZcDHfGK0fTMd4dUjsnuJYFcNh
+hypIGQCPMHtU5YVtFYpN6Z6PDrQ6hEVlq+haVDKocrf21iEklc4IEoUhcE+YAqygvNH0qJNM
+ktbi1mUsbl44fETJHAZCchR3yOeeag9PaLDMi6qJHs8riRsZSybOPEnQ8tDyMlcle+Dim6vo
+/UktxLdSzNHqllM0UscT4cMBlnU/xIRhgfRhXE1Z6CdI9M6Rv4NV0dIZjHftparHHPb3BZ1t
+2J+gK34cE5BHbkYrrqFzLYwS+HcGAyukr200YkiukI2+MvluHAYDH615vpPUF/pMtn1Lqaq8
+Ukj2VwtoAkuduVkdRwc57+or0bS9b0rWtOKxTQzK0xXZJ9FzEQMn6TxnnuO4yO9YOLTN1JNF
+RrmnQCCy6hgiuEuoWKOrTnap7x+FJ+IL3wMnFQLm6nnhknuLwJKxV5DPKDNk+atjDHP2NbDU
+emrGCZbXp6fUnvdQgnS66bu7ZZEmCL4kc9lJuGQMMSDh14wGzXluv6ubWWIXCJJbINwdSXZ8
+j8D57FfUAGq412JS+i2uEtdZYXNvex291Cj7jLGyJdso/DgfTvPqO9coLaC7t3uba3aF4Qrq
+SMMh89p/zWq+HqTRWtYo5rT9n3CFQ8Zffb3C4+l1zzHJ/T7VsIo7S91SexstRF5p2xbmG7SM
+o0YYDPjKc4ZGJVu4I5BqlH7J5WYi6kmtJVkhlZY8lkRhh43zyB6qe+Pes9rt54sbyliXzvVk
+G3HtgVruotTuunlvtD1a2UTpccv4SuAF/CUf8QQjnHmMV51rmpw31zOtttSBGLK6qVDr648q
+0hjdmeSaqiGGckkIFyMMsmNrj7UmnaVoWsSTIdZh0vUY1zAk64t5iO6mT+Bsds8H1qAj3LsM
+xg+P/dhvT2rkfkLwpG14YHPB3J9H611qLRxuSfZrNP6V6gsil98rCx8LxVuI5dyhAfPHINaC
+4uhrNpHMV+bnl/dOJcNJHJ/IfI57qR3HuKotGfVtOEen29611sUOsO7dEynuo9Qa9R0q40Pq
+LRruWbpi0ivrW3e3urG1YW97JEoDLcxfwy+H+LGA3BwTyKxk72bJfR5+PEsmMbu2YAMxSoMg
+HzFa3p7XOpIA1vpNnpepWiuY5Le4t1eFw4wd3Yrn1HY4NNTRz1Hovzl1etItnsjXUCqt4TNj
+Al2/8NuDz+FvPmoD6DfdPQ31peG4sdX0xYrxWjbaJoZDgSxn+JDx/rWEpX0axhXZ6J05pGka
+21v1F0dpkqTWkotdV0e8nzc21sWIlRT/AMZVOSDw23IIPeud3pphkuYba0IdGeNiQMweisG8
+sYwfMYrOaDrWn3ywdczz2sWq2dxFZa1ZF2VtRD8LeRAcKwUbZRng7WHc16nqR0DUzc3VzJdy
+lYQbe5jjAuBAGCtGQfouEXduAba4G7BrGas6IviYrQ7vVrW/Z7HU7mx1OGBoYbm3faZoTw8M
+mch0K5OGyOKbqNql1OIDaLHC5lurqEoVVTHHiRl/kB+jKjucGrLXtJvNOvtR1vTr/TZYNIja
+6+UV2jlniDrHNBsP1RSxq/iKG4IVgCagaxeyJ09qtubgNctJHE23lvBZd+QfLIRcjz4oxppB
+kaZ5vc2elw3kyXEz2mwYCv8AUDxzyP6V591g0g1Jo/F3IFG0jkduMe2K1fVMsk85mVmlRhuL
+Ac7fLNUd5aW9z0lBqspja5t5JkKbsl4A4VSfQqxx7gj0rvwx8nBmfgyoW0e3bf4sdwOUIGUc
+enqD781GKtuwd2R6+VSniuDKImBBxuQV3igvGkRn4MgOS3JxXXdbZxcbeiLbWZnmSNHH1ZOc
+dsAmvQej+krPpj9o9Y9bQW11pehx2zw2RO5dVvpoxLb22f8AwwCJJT/KhXuwqF0Bodlr/Vuj
+6beyNHZXd7HbzNEQrLGx2swJ4yM5/KtP8U9OvtasenrTp43M2kaXbyaZa2zookZYpGjNy5HB
+aQoc+gCjtULJbL9qlZ5x1H1RrnWWvX3VXVWsT6jrGqztPd3ly5d5HPHJPYAYAA4AAAGAK2XQ
+x0AdQWnRfUuri30bVBLZvqdtB4rRTMC0EqA4OPECK3Y7WPpVLpXQsV3r2kaTFfQTLqVxsFxI
+/hQQLwA8rYO1FY5c+QBxVh/s3BD1TLYLqkU6aTuit57LLRT3IOFdSwB2luRkZxipnJSWy8Sl
+Fovvh5p9zca23TWoRrHc263QaBkJaS6iidljI44BTPsK23R+oPawvaRJ41gCt1KXGMuOCV/l
+JzgflVLcXF51F8Tm6xlFwt7qF2bm6W1wJXuAgWcKRwGI3n3yav7HTxZaVa/JXsV0LqK+W/t0
+BEsQiKSRuQe6FVByOzBga4ckkd0Im46yhhuTaanNZPC7wLbK0WWWGJVzFuA4Ay4yT615BoCt
+a9cyx3ULpdWUN3HLGw5jfwmB/rXpS6gmoaSdNutQ8C30+JZLmJs4uLchQuT6qyocH8q886gu
+5JL656hmEUdxNEEk8NgzSFEEalj6sO/2qYPkmyppx0WutXsMmtyreurCOEksDtUTMoIOPMDj
+ip3w71SZ+rRqUrFbe0tZr24K+bRKDGecjPibT+VeYXl3dXF84Ls3jtu2nk5xXoumMNA+GFn1
+DdSn9o9RmS0CBODBG+E+zFhz7LVxVOyGzjLqnTN3Hqsur3V8nUMKWd7p925MsV3JvkW8hnxy
+GffG6N5FWB7ipF4t3bdM6VqNzPCLNZp9PgCZaVpldPGDL/AQrhgD+IDPlXm8urCx6m/eu0kF
+qDKwTkvKp3Lj7NivR9S6svL/APb9jqREkWpahbajLGQDi8MbBpFI7HbJjjjiik+w+z0jpuP5
+Xou/0+aaObUNSk+YtZWBVo7W2EksMagfhDurHg4Gfes50xYNq3Tl1rMy3ENrppglu3AAi2Sf
+uyzknI/esi8A9znHFUWp9XX16l3fxssHytlZWCbBw/hQ+ExH/Nk5AqRpvUKnQ5Do92tqlzp8
+2jajaTfXv8WRZWmXyx4iRkeY2+dTJ21Za1F0M6uOqaBqOhIkltdxSSSzSJs2+FKZ9pUtnklV
+BHoGqnmvGsr6/v8ATZGFtaFY0RhxKGO0q3ryx7+maTW5L7U7zUIL+5ZZWiGoRxlh/fLhHA9D
+hQfyrhrd5cTfNRPDbwSzrHNJHbjEYZVB4Hqckn3Jojk4iePlsz11p6x6VaaE1w0ttHfXF0zM
+TtZyqqP0AP6mtdZavHeRXGgawJJra/VrW5lBLNCjRho5EHmY3RXHtuHnWZuxNLFFBbruG5iM
+jzYYJ/Sp1oFhubZpS6xRTRLIycsRjGf/AC0LK7sXtpKiBY32t6faWWmyzLLcWE06gAfup7aU
+BZYz/gcYYehzV3qnUNpdaPpiX+qLA1vCtq7ly8qSQ8QEP3wI9qj0AINVUMDHxEuCUVFYpt/k
+U5wfyxWdNvJ1HcppWkaep1ByxysgWMKvLs7MQEVRkljxiujFkcnRz5cUY7OvUCaPqOuWUun2
+st7ctGY7mKU7ElkYHLx7fwjzwe5FUh6m1Gfp640y/uROqvAqmb+9RFYsFDdyMkjBqru7q806
+/dbfUI3+WkZY54XJVtp7q3mD61Z6pa+PaW2tS6Whtb9XEUyNxJJHjxFOOzDIPPkc1upUYuCl
+2Qr5/mNOW2EglhhJdTjGMjgflzUGR7i4s7a02s7QISGJxtXPr7VZafDHd2yWy4jSUFmJ5xjt
+zVaVmlma3lVgCCI2A7eQz7U+QcEQLiUhw4wwOcHHJI71d6DeBbdt8KqykMGP8QPGKrJ7WS6l
+tYgoV2XDKPI5xVrfafPHYxxrH4XIVV82x5ms8jVKJeJSuy1IaFw8kDAE+RBHNXljsijR5Q8i
+o4aVRgHb7Z4zWe0e1ZGSOWRyD+IGtY0sxQ2u7MYUKqkDnHrXBPujvgvJOilWAs1tP4seMozJ
+tP2I9arepdQ+TkhmMnYoDg4JI7kUsl2EMNpbo0k7fu0HkXPA/qaynX+tWWo9Q3cdvatZLAwh
+SNZjIgKqFOGPJyQTn3p4cbmxZsvBHZtbHiuwfJdvpUH3q0ueoZ9K0ib9nMjzEq95PHLtJJ4W
+MfzAdz7mvPvH8Jxnco243E/1FcLi6EiRwRM/hRA4DeZPnXbDDTODJm8Gr6eutD1bVoU1a+h0
+hnlG27aMtbKxPHjKvIXOMsvIHkaqOsenuoekNUl0HXYUjZTuSWCVZbe6Q8iWKVSVkQgggqSP
+zqteaGGySNU/fOSzn0HlT7XXtSt7JtMaUz2LZ/3aX6kUk5LID+A58xj3rohFIwyTvSIG5hgZ
+PPlXSPJIVm5Xt7c0kqQ7gLdpDGQMlwAQfSultIkUoJUH3NVIiC3s6XlnLZ3UkBYNsPLqeCPU
+VzIijiDltxfOPUYrtc3JlbeB9QGDz5VCfAOFORilHfZc2ov4nWe9uJwgmcN4ahFJUZAHYZrm
+knlnOf6Vz2k8jmkBxVUjJSZYJIvyhd2HD7QPPtXO4vZJY4EDOBbghfq7ZOePSo+V28E7s9vK
+reHSNNh02a/1TVdkhAFrbwJvaVj5sx4RR+Z9qniky+baorYJ5Y5RKrncp3ZNTHv5LmczzSNJ
+K5yzNzVcGycAAeXFdYchwAoJPbNKUU9muLI1pGk0+88NfDkYnI7+laWyvIbfT0uDKjPOxiRM
+chByzfmcCsLFI6tsbGfOrf5p3S3GTshQqF+55rhnjSdnoRla0bK+6g0PUNPs449LvotVt5Cj
+zGdDbyQkcZX8W4Hsc9uKqYLtprjwrxcAHAaPyNUsa+IwDOygnitBYRiGAyvlY0OHYjIX0rGS
+RcW7LpLWKRJWiP0ooMjEjkH271k9UWENm2jIEbbtzHlv+1aGPWNNhlkXc7lkK7ivGD5e9ZjV
+5oIoz4cjKjDdhu+PSiEXYpyVFPe3McFwjKm5GHJP+VVqyLC5nQc7voGcCnxvG7bCzNC7f/Q1
+EuGwxQY+kkcedelCNaPMySvYTSmWR3AVd3JVe1cwSvKnGaarYIPpTzt2bh3yQR/lWvRz3Y4x
+scgptI7iru307Sb63EYeWyvkXJU4eGXHoe6n25FLoM+lyrNpWr2qymdAbe6RtskLAZxnsQex
+BpLiPw3CbeVH0kj8QrGc2tHXhxp7L7p7SvDt3uprVpVjTxDtbIaPsWAPfFXOlaTYu4NveFLk
+uBEx4gnQ+We8cgPrw3lWf6Zvm0/UbWW7MkllkxsqnJjVuGwPTnOKvNQsL7p7VW+VvIb+G1O6
+OaGMyxOowQJFxkAjgqa4pt2dq0jRxadqFldQQPbO0n8SBCznHc7Rzj8qj3McsQDzON0u/wAJ
+sYIIJWRc+RHmDzUnqK6i1O0tPiL0hqN1ZzxMqXEEUrCfSbsD6djfxQN/ATyOVbyJnavqQ1aJ
+7jV7eOabUUhvTd242FrgriR2j7ByR9RGAeDWVUVdl7oGt2GnWltruoaZa3WjajCLa8s7mPxL
+eS4QbSJQDkK64JPDKfqXkVedfdBaDPY6X1H0NdPfaJcQbIFuXX57T7i3G97K6ZcFpoQfomA2
+zRBHByGAwPSt7E0V7oj3sSLdKTGs67oTcKPpEi+W7lc+9bXp/qLTX035Oz0j9l6lA2y/tZmD
+re2Un4SrD/iwuW2t5xtg8iqjKnvoTVrR5nrnVGoNq6X2tTSPd8ZvcDxmb+FmYfj48zzWytut
+rfXtI8LV3RmtWDGaKENdW4bhpYzn94g7tEfInBzXjvVTy2fVd/pZnaW2imZIWbj6M/T/AEo0
+y/ngIcg7o+OPNa14U7M+To3PVtxrGkWC6TdrYahbWlyLzTL60kL+AG5YRnv4TnBMbfhbkY84
+1tr1tc2Y1C2IiW4+qWPssU3Yj/lP+tZe41+WwJs5G8S3kGFB/hz5j3zVRZ3r/KXlpCxPHi7M
+/ix54+1CxthzPcfgzfaVa/Eqfo/X9PjuNM6r0vUNFuYpSCYZpoG+XmXPBeOUIyn+vNZj4e61
+/szqmlo1n8xfafdf71bXH4dyOUkjH5jIPrXm5vp7lLe6N0ZHTEZGfqVR2rSQQpppiuNRjurZ
+Z8tZ3wB8PcvDKf5sEjPOec1o4Jw4MnnKMuSPdOotQn6Q1HS9X8KKC01WGa9tTaXSmRUjuMyx
+Sop/dyIyqwVvxK2RkV55q+rHT+qPmxCG8O5edVkHDiQ7wceRKtkH15qHperaj1No9zHGyz3m
+kqLjwDjdNbhSjkH+LaGz9vtUW/lm1XT7HUbW4SfbZRwywn+9HhEjP+Irx+Rrn40mjTnbTPU4
+eoxqOl3euaSVmihto0vLN1DEpuxJuXvgAg7hUXT7y+FlMbW5LW8eGGyTflc5GPbHn6iqD4ba
+n8iNQvLaZY9kUSO7pvB3N+HH5HI9KmWPy0KCGGElJLkgQxsVxu7qCKxlaZtF2i61hbXUdO+a
+t4HiuLUGaNvDG11TLbW9TjI+xrz/AKvSbqKxhg0y0ICbnSNX3HBGdgzycc4HfyreaNq62E0r
+vY/O6W6PDNA7cqGXCOD6qeD68isVquh2Wia7eLa6p4unzCKWPO5ZbYuM7Gz/ABRkYJHBGDWk
+JdMiULMJf9ES39tBqGnSRtcW1obyaAnDvFG+2UAd96ZBK9ypz5Vrfhla22t9EdZdPa3CPl7d
+LbVI5FkCzwGGXYZIgeW+iUkqO4Wt1pWm23UmuWE1/cW1xfi3ntJtQYKkGDHtglkZfxOGIRn7
+kFc9q81mtLjp+00zqaKJFYXcunzQschdoBZHHmCDx9q6XO1RzcOLtF/a6Jd2XUljqnyscmra
+ZCZJZLRPp1KzC4eQL5yKn1Edzj1r13pFT0nr2mdQWLpPp1y8kk1vG5VZo5kMc8RHo0TFh7/a
+vM7afVLOS26j6buAptWN1FbuchABl4wx/hZcgjzBr1fS9S0HUunNN1jQmCxwyIkUJ/hQ5Phk
+fzDLAN6DFcknZ1Q0RbrocaHc38umzG5t7YGPIKuTGSDE7Fe/0kZPrntV0z+DKJ0mC221yI/I
+xlMFSD3B5BFTOmYdMXX7bTdduorLT9Y8SwlumJKWpfhJHx2jLbN3p38qpet7Vul9s19ayi/0
+a7xNCG3AYOyZfRlPDA9uQRWdWamBsNJTQNXuL+yu2+W1cW8xiZzmGZM7U9xgnGfLIq7iv5NO
+0G+1VYW8XS9Qs4lkydptZdwCuvmFfG1j2zipnVvTPzOi3er6ZiaKW7htcQnCx7kMsDkEZG4q
+6Z7ZrO6W2q3HTOsytZ3EumTW66VqE4HEDSndB4i9x9acHtkVo25UzNJRtGl6S1ODQbnSZtRJ
+EdpeDdcbj+6gnVkKn2V2Bz6Zqj17T5tL1HU0vo0S6DKuoRK2AZ1YgSqfRhjn3BqR0y37U6bu
+oL/LSvE8DZ/jKgEH88VX/Eq0vtOl6fuyWxqdoY5Jmfdv8MAIG9Dgjv6VpGNqzKb4yJE2rQza
+xpstkqtHcyCB0AwDuHOQeQc/61gdVup1WeK63jw53iRvQhiMflXU3tzp91vlkQ3ETB1ZDyrD
+kVnuoNXWS4uZZCweSZpXGfpJY5zj861jHkQ5Vsuun+o4rKa4sNVYy2kyeFuXBaCVTlJFz5Z4
+I8wali7mkkmicNArHxF2nKhiOCfYjjNef67BrXTmp2Y1axkgg1G3W8sZTzFdQMSBJGw4Ybgw
+PmCCDyK01hrMdxYrMwJaEbCVbB2HyPrWc8LRpHJZbXBaNo5FjC5XccdiK4tdWlpe2sF6rC1n
+OyV0/EsbDhx7qfL2q21U6fcdI9P61Y3UUs0815Z3luow0TJh42Psykj7iqvqCfT7/S9IutIB
+jvYC0dymc7iPwsPuvBHbIqYwp7CU7RWzRNa6uLTVljmezm8N5EOUliPKsCPUcitDrN3pU+rW
+UK2K2++zFhczofpl2sWhmx5HB2t64BrL2bxXEk0ssZuI4kHzMSnbKkecFl91zVsI7aeE26zm
+SSB90DngvH6H8uaqeiI7JF5pkkEiWmsWW1Z4SdrghWjcELIrDyyOD2yCDWK/Yd5/9kf0Feg2
++r6idLs9OARltJXkg3jcUV/7yL3jY/UVPY8jFSf2va//AIqaP/5H/wC9OMq6JlG/BV6f08NQ
+0G11kSm4hE04hmIKxwypGSbaVv4ZMYYDyBBrCatJM2mtBfKpChkJ/iGeV3f969Ct7rVbK+1a
+30yVjpuqxi6mtS+YxcBdomA7bsbgfUHmvPNav47wZeBVKq0UjJxvXJPPuPWkpJtUbU0tmBmV
+4CN3cHjFRzIGJDDnyNWNyYt7ZjLRMCBz9SnyNVbKQcd69GG1s83LcXosdLuLiOTxLY5li+tV
+xnIHfjzr2+1H7b6WsviJ0/8ALLtnbTNQs0dibdgoYQzAjGyUBmib1V1PIGfEunRpp1FV1W7m
+tUKlY7iPH7mU/gdgfxID+IDnBr1LoLWrb4e9b256v0e6m035iEdQaPbXXhfPWh7vC68ZAO9D
+yNwHkTXP6jEns6PS5nVEqPx9gmhjL20xLRkn6oyDypHnitPoHUl/BcW0wikma2bdHywIPbHH
+3rpc33Sd5qOpaLb7tQsVuJP2fq1uvyr3UWf3U7w8qspQgOowCQaS8tDpcB1nTZmutPMwt7jw
+zskimAyAfNWIGQ3Y8juK8zJHdHqwerLzUrbS9TnS9to3jmuEyZrZQ3iMM7g8fY/cd8EHmqG8
+0h10j5jUGt5tOMiLa6lFkpG5BwhYcKSeNj4PpTLHU7/SboTW9/BPaXZNxYXC43LIuNwK98Hs
+ynz5FXkmp20sM2raDbNp5vw6X1oHD20sndo3jYYZD+JdwyuTtNRFUy20zEGHU7J47m1EcdxZ
+zLcQOF5Lr2BHYg5NQ20WeXp+PW7azdojd/LT2848KMyn6xFHMPwM652qwB9CcVoxLYQSi50+
+UWOwhjBNultyfMc/Ug/UVPmvJ4p76aC0+Uk1WBra+tUCPbXMDYOI15TI7rjDA9sVsv2YO70c
+eptF0qTpU9QdB9Rajd9NiVRqXT+rlX1Xp6XjaX4xPbMThJ48H+F1B5Oc0nUE06KUThUhuQAL
+pV3KhByCT2APatFY6X0xcEx3GpXUMaRsLO7VfEBlOMRTA4eMHGNwyAfLFVF2LmykubK2v9Qt
+BIMTwoQpTzw0Z+mQeeR3oVMLfZYabqep6beC8tbm2E3gOmWAaO5gP4owBw+fTuKmFNP6waGx
+sodNs72YhVd7gxHJH4M/h59D2PnVDH1ZdTzPb6hLpF94pSNJ4oVgVSOMsqgBTjzHNcL+206z
+la1nn+WvFIZZhJmNvPgjv9waz4tFqaZZWvTdvd3f7NlRbfVoSR8rMdkd8o/lPk/sOD3FZu/6
+ehsdTkFjaSKbckTWU4PiRN6Dzx6GtRY3Os6zp8lhbC21WRI/F2CNjJGi95F44A4ycjAHNS7y
+KTWI4J9Surix1OFQsV74gZomHY7x+KMjyPbuKam4icFIpNJi0W6tYE1ArLZyfQxSZfFiUnDf
+T5lThgPb3qF1L0jqvTcUi3721/Z8S2eo28gaGVccKSPwPxyjYINa0S9RxzpZ67cJd3MYxDcy
+W8Tb0xhVJUYcY7E81Y2mo3kjrM2k28zQEeNAE2Q3wQ5RZos7Wx2yPqx50lOVhLHFmY0WLUre
+whk6f1eEXVuvhSWwiG8scOxXI55A7VW/PC6u5Rq9sYpJJCWl24Ac99w+9aTqDp2zaQdQdFRz
+20ayGa40+7k8b5eU8kIeHC+jdvzqCdQ0jU18PUoWtbgtlJFfkP754YH0NaOV9GSjxKqSK2tb
+2K+vIEMCgI5yVD+24fhPvUtbPEbXvTOtLKn8cMrKsykjv/K48sjn2qy065t4p5Iri901oguQ
+kiMshPoFIKn86kXuhW1vE1/d6EuRLkFIdpcY74Q7QuPOop+DVNeSNBY6/wBS3P7VntpX1JIT
+FJe+DhZVVQFDns2BgZIz60+OxWCJJNR0xcHIeOJTg8cFCMZx5iuz9Qy2C/7klveMVbEM3/AV
+h2X1/OpWmddTTp+ypJGiUneYEK/vlP4gjHOxsD/tTQitSaS7Cppd5b+PLuU2e/65Fxkphv4h
+jseaohcWDGVbmOYQ5y+Sdw9WGfMGtlruk6d1BOmr6DqMcl5bnxI0WArcxxhcEzLja2O24ckc
+1nNd08y2Nvq50We0nz8rdTQgm3uiCSr4P4JMcEDhhg8HNUkSzOaobq/t5o7e4ZzbqHt5Qx3l
+R3/pUfTNT1i0tEf/AGhLMXJktrhRIhXyKk8g966XovbaZYobSOziI3KzOd+POqS71G3WRvFj
+3/w5Awf6VpFeDOTNPc36Xim5s7m2lndArRNHyxx5HjFZm+lhuo28JTG8KhX3vnmofz8CeLbP
+EC1wp8InvGfIg1WXWut4a2/gRwTR8GRSTv8A+bNawxt9GEppI5PFcF3WIZBG4ljgCq97dXnW
+GW8t18QgF9xKp7nApl1ezXLnxpSRnnFRWYFuGwPXvXZCFHJPIiwWztraQ77oy7GILxj92fsT
+V7Y3um2MIiutVcI2GzCqvgenqTWRWVk4Byuc48qlRypdYjuWK/yOq52n0I9KqUL7Ijk49Hp+
+kdb29vcW82gyy3kllLughkwkhBBDAA5Bzkgr51tm+Itj1fqUdvqUVzpeoz2620st2iKmyNQI
+fpwMMoG3OfqXFfPlmCtykbxyFyRsA4bPlitfptlrup3sbSQXDshAaS4RkKoDzkt3x6d65smK
+tI7MWW+z1DqTQ+h7aYmyv9b1kJ4Ym3WkdiC+MgBVd275wSRmoOn3lgLVDY7EMf0vE4YTBT5l
+n4Ye4qFL1D1d07fSWcyzLpceVtXjRRHKnkyPjLDOTz2q80u90XqiVbW41SMXjozJ8wpKscf3
+fHKN6MMg9iK5pQrs6oz5dIstL6lv4ZPAsNUcAAOqXcSy7D5YLZP2IIo11bHqZhdXenwSTiPw
+TJFtR5mz9JYjgn3PPrXPSNBuXvNQ0y3srC+l1KFLKwunnbxrWdTv3xFCFJIBQhwQQfI1COpG
+XQIepYmRBcGW2ZRCW8O6j5KOo5VmQq4OMHmhLWwcqK+fQulnntbfqTV4YRCxSXYyxvGvBUqQ
+CHPPbH50zTX0Wynu5tO1q9t4mVlnWPxHeaLB8hjPYHb2ql6k6nuuotUfWtVCzyy7d8tsoXYF
+UAAJgYAAHFSLLqCFdN06DS578SJL4l8ZDHJHM6vuR4cqHiIX6WQkg9/atKVmfJ0S+u9bvL63
+sF1jUZbpDYxS2sj2yoWiYYAYjk4AwCecV59PbxtbHUrkwQWjN4cSu+0vjyA/zrd6zLpuqWs7
+TwNbq8jyQK0mTEuTlFHn37dhzWI6mi0+La17Czybf3UcA2ID/wB/WtYJWYzbor7S3lkme5TW
+7RXVGEO1iVj3DHPGQMH0qvn0bVtIRbqXTo5oGJVZlIlib81P+dS9H0iLUopTbuYpUIZeQ+R5
+gjuK0I6UvmspL2aFgtqFlea2mXcqlscp3bnitZT4dmEYc+uyl0+9h3QjejLgMr2gZZIWHqD6
+H0r2OH9o9SaJpvUvTs0cmt6LMkNxa3uIXVZSAtzE3G5d/Dc5BOcVQaNoFgJ4IJ5kOnXqbrbU
+4oFMsMh5GcYz2wVPbOa1N1bQx6nNrAkVbTU7BbWVkUhTcx8CTb2XdtBIHGRmuOeRXaO3Hjaj
+svNCulubm51/TfkLfUY4ZLTWtNjkUSRAkpIHibiWIthg65xx2qLbafoklrYaZqjTx6fHdyWb
+Gb95DaPKnO1wdyxyEq208AjIrOaH1j0V+2oI+temHkmlUQnULC7ktvCJyN8iBWyp88Y9a1rw
+3ekyiJ3PgSptWeKZZYpFGcLu/DKo7g/5Vg7ezoikZOfpXVYkOjS3cCtphZ4pCoaO5i/jAbyc
+eQNem6brWiX+grM63OY4kg1DxpVEkTDjDgDH1D8Mg4IxnB4rMmCbWYhE08M14hLWzRR+G8h2
+/VFtPBJAyPVhjzq00W11B9Zh1Oz06C9iiUWurW6bQ72ki4Mqo5G4Acle6uBSr6B9GU1vXLOw
+v4z1Et94IL2i6vphVp8FQVWaJvplXacEAg8ZBq2tdW0q9ihuNc079pWF9ayKpk3W03bZBcx4
+J2SR7RgNlSNynvmofWOgxdOW+vdP30Fw9tY6jFJGZIyHSIKypKpP8JDKc/cHtVMllPpmm3Ka
+fAk8kVtFbeK3DqrYmxgnuMnB8xWsY6MpS+jK6jGLWOCS8O65CPDI6p9Nwmecjtx5EfnWb1iD
+TbC30TR41dGl0e4NzO7cSTyO7gAeQXCD3xW1gs4bu7u9CkVBJeFnspZGx4U4GSMejqeR64NU
+130ncp03d3WoJCJNO1CC0ZGJ8VBLG7Bl/wAP0Yz2ziumGRRdHPkhzRh7OwfVrIufpZMbZD2L
+9tn3I5FWN9YvbwLtiwItm7jlgR/3BrTdJ/DTXrzTvlJUWK3vJ4bm1utwwe6k49AM9/SpzWdh
+/tNJDcWj32iQTs8583t0cBifMDaSc1U8m9dGUMelZz+GMMsV1Zu1tGBFfxzxlRullRmCtEvl
+nkEZ861sNw2n26teaBFqEegXEls9hM8kbMJ9zlpNhBwr5yM+dWvTfT2g9NG+0a4WW40+wvg0
+cm3bN8uzAZ++0q4+1W2v3+k3d1rusacrRTpYw3sZGU+YngIPhlD/ABKA5ZhwwOMZFYLJbbR0
++3SVnn2tdPz6bocz6cyweDMlvPCO4Vhwme557/lmqrRbG2jN0+nSfN/s2QS3UmcFH48u5XPA
+Nei3OhafddM3OtpPcCw1eeJkaQZeIujM7AeisNufQZrz+OK36SuILh7bOn6lOttNKW2tPEw+
+krnkj6g5bse1EW2EkkywbUbzRJItfuLSR9NuJP315asGWMk/iDD8Lrzw2O2K9H0m01Lp6HXt
+N1I28lzb7PAvoiCJYp1UjORlCVYZX8/evIPh1bX1xe3/AE89xHBbXAjW+ifhbpo5i3hn1JHr
+6V61pSjU+lptfvS8g1jS7aMI8mWSS3mwrk5ycKqqM+VRk70aRutmS1u6Jg1nTriaWNzBAwHl
+IY5ASrH3B49cVjrq9e9Tx22/uXKMo7Ensf8ASrHrS9ImknM5IiREYgfjfGAPyFU/TthJdyix
+VdizxsefxEjkUofGNilcpUXPT9rI+qxTzgYZCr8dsqec+XlUnrfVk0jp/RomztgBaKM9vG28
+n7c1b6Lp5mmtpCQsSK6zKeMKByc/YEVhviDO/UnVOkdPiZIYw2x3PIjDtyxx6A/0qsXzkGX4
+Ii6NZXMgs2uYn2XbB3kK/iUvkH8+a1luqGf5VpT49zNKsYyMuqg459cCqyGSSCL5WC4ZxJeI
+tmrLsMltG3hxEr5ZwWx71X6zqUVr1DcSiXxU09FGRkBZA31AHz58/etKtmd0XM+oiLT44bC9
+jubdtzMwjIkiYnkOD2qvt7xojJGzExuCJAGx5Zqj1i6h03W7saZckwT3LNbuBtPhHkDH5kfl
+UvTrzTbm9VNWuZbZG4eeCPftz5lPMeuKicCoyPQ7K50nX/lJILg/MxxbSs5CylB3Gez8Zwe/
+rVXr+kX2n6lL4UE0lspDB8DgN2zjuPeqvW7K20vV/ltNEnyjBDavK+8sjDhg2BuUnOD/AKip
++mdV3cNoun3jLKkWUQlcbcnsT5jNYyibRkOsZY4Ct7fo/gROPEROGaIYL499uce9a/qXp+xt
+NH0OfTb+N4GbUfl5o0z85bPLFJDcZHntYoVPIIIrCtrkhuGSRIprRgUeJh5H0PkannUv/ZGn
+WtviO3t55Y1AGMIQrEcdz70loe30c5BLfyX0dqoUI/hLu7sCd2B7nFZ/Xo+l7Sae31fTtRhl
+baDNptwBgMPqDK4IPPlxV+WmuJ42tEcyRyGSGJO/u59Wqt1PTIp8SXUb/S7E4JBZmPII+9aw
+momM4OWjBahocAk8LTLi4ntmG5Hng8NiPtk16F0rBb29jJpuoaZFeW13awxFMHckqA7ZVA/i
+GcZ9OK5WyfL2pgk/exrlkR8Hwvsf9KtbPVJbK2+ZRFc+MoEZACuMfUpx5YqpZm9CWFdmeutO
+1CxsmbURHI5AOYVAUHzGRVW+nRXklukl4lpHIGWWQgnYcEqSByeePzq+122hjKS2zu0BUMwY
+coW8j647ZqrRACkB/mDscdzUe6yuCIWn6GkSJMzhx/E38p9OalyR+MRJ4RypIUH+QdqtdmQY
+nTiTaVx5ginPZK7BYxkkEhT5gd6HNvbGopFfBHsRpZlADfRGPPPmat8K7LknaqDBHmT/AOtV
+LytJMu/G5/wJj8IqxN+slqFcBcttwowNv/11BoutFTfw3zSEsGYKxVCh5U1mL/RpUnkGzxGJ
+wo8yf/rr0uwjj1e+07Sl8O2jmuRCHxuKhsAsfXHevP8Aqe5t4DNFbyF5Y2yW+9dOK7qJz5dK
+2Zq6tLi0ZkuG2MO6sDkVz8K28AP81iQn+7KHt65rnLeXE0hklmaRj/Mc0zfnuPLH2rvSfk8t
+yT2glIZztYkeWaRQmCSTu8h5Uxs5oGaqiG7ZKuEs1Vfl7iR2wCwKYGcc4NcvFfw/CyNuc9ua
+YVbsQc/au6TQxTJLHZowVApSQlgzY5PGP0oods5cE8vjjg+tIcsAf5eKnWOszWEzy29lYkSI
+UZJbZZFKnyw2cfcc0r+DcgSpbJCWJLBM7fyB8qTfEqMeZDtrO5vJVgtLeSaRjgJGpYn8hRd2
+V1Y3D2t5byQSx8NHIpVh9wavLWC5s7WO+SXwTeFxCyMVYFCATx2712gs0mTxZLH52aViv1ux
+2nzJOf8AOoeVI0WBszcaMTtxjPcnyFLIzFQi52L+HP8AnV9fvodkvy/ybSzEEs0cn0qfTnvV
+DK0bOSisF8gTyKqMnLdESio6s4555qXbMHBSQMUXkso5UVFbaTxnHvUmyeWGQSxHB7HIyCPQ
+05dCxtqWiakSKyGJmIPOWq0t1uZiH/hHAyMD7VWvcB5vEI27wPpx2qwtLpiFhMuD39s1x5LZ
+6UGifFHJGQ5TeV5Kk55qwtiXs7pW1aC2kKq7W0qsPHAPZSBjI9DXG3mVoGWaIpLx28/ei4t7
+b93PFeiSRjhothBX8zXMu9mpBmkIk3sGweCqnmqzWy5tkfflSdqk98VZTQvJbXFzHuE0E8Sj
+02tnP+Qqu1xShjgGAqLn2JPJroxaaMMv4lLEztIiqRkkAc8UyRWLMG5YHBpXjKMVYYI5pHDK
++CCrL68V2rvR57uqZyxXSCKSZxHGu5j2pCufvXS2LLIpXOc9qbdImK2XFlax6ZdRm7eGRRgu
+g+vGft51sP8A2VNb+HLo48HAMcwcq4+4PFV+g6fp+nQtf6hbmVpMGKLyBPmaNR1i4gmMk9vC
+9uzlQpB5I8ga4pycuj0YQUTo1nDEQqAXEQbKSIdrp7EdjUq1vdRsrpbvT76a2u4fpEkbYLxn
+urDzFV8epWErM1rbPGmOVeTdz7GpM5RmCtuDqgdJAe3qDXO0zWzW6VqsNxIi3trBCZQ0VxPb
+xhJJoGHKso+mQA4bBGeOKZbxNb3K6TAwuBCpxGD/AHg/wHz47fpWcstSUxm3uiT5o61Y2d2b
+yUSW8xS7gGYyOGYjtj3qWWmT9T0WL5FeptClma5tpAt3C64HhH8Dj3ByCK4pqcsd5FJNIbWZ
+AJA2NwBPY1ptP1h7u1n1a1tFDXkAiuAoyBKh+osvluGc+hrPazLo9reSzxTGK0voFiO1NzWr
+ryGx6Zxn2qou9CkvKDqHRugJrjf1d+29NvLtPDh1DTxHcWYmByrMpIbYwPIyCp5GRxXnF0Zd
+IvrrTZZlaW0kZUaM5DY9D5gjmt0s1ldaWl7q1pLew2W6xvltZAssUrqTBcIDw48ipHI4yKxW
+tadYSWMWo2jzQXsCBJ0b6o5iDgMh7ocd1Pn2NduNJpWceSTT0U1xetcKzGXzyFb/AErhb3ck
+EqyISCpz3/UVwfcTyOaQZzzmt1FUczySs0BZLZYr21AaFjvVX7Z/iQ/avSPhj8Qum5bvUOkP
+iPp15c9O6xaP8odPYC507UVX91dQhvpZuAroeHTjvgjzizl0+WzWCJWXeAJYnORvHZ1PlnsR
+XFVIjdTbuPCIaORH2sjZ4I/Os+mbfkj1jWOnLjonX7W9tdRt9SguLY3mmXumuY4rl04lg5G6
+J+4KMMjOORXGf9iXU00mlahOkD5nsZDGoyWGdkg/hIOVJHpms3p19edSahE1tLK1y+Z7pLic
+lnlAy7qT3Ygdu5xUFzc6bqt3pqXIZFbfbyr+E+Y79vT71lKKfRcZNVZfWupst8qo7xPdptYq
+2EmK84PluB5r0T4RXpn+IPTtle3Mduk+rQ+LJPgRgDvuzxtwMn7V5bpF3BqLy214gVywmRgu
+Akg7kemfP1rQ2UkzMlwoxcQXUfGedhP4h/8ATzrkyR2deOWjadJlns+p47+0nmsoop7V5IsF
+YXknxExOc7cgEEe1cNQ046pYSzzKsk8cbiTced4Xv9jirjRdB/aDaxoEdxNH486XCyKyiOWN
+QXIbzDIeVPY9qZ0FawdU63YaHqGsw6N+2HSwN7cwsYo5pMiB3A58N32AkfhDE84rJrdo1i1V
+MXoIWFnoFtp11by3Nlq1yxhu412i1vPDGIXz5NggjscgjkVF6m6Rjjsb7TflZGs5rh7uJmGG
+eMoNwH8sqEZwe+PeptnDfx21/wBP6jZyafc6bqDwX9k5wYrmMmOQEeo28HsRgjvV1qlpqF5o
+0t9ps51BrLwbiRNvPh85yD3I5B9iD5U+fGWyWuUDCfDlba+jjsb+cMs4MUbfhEwXgZz2yDWn
+6cNzoc970sC0AtJP3TEZH0nlGz5jP3rJfs6CGVZoN0cCTMTF5KjHOB6EZNb6KJ+okuruDfLd
+Wlp4hmL/AF3KIMfX6vtP4vPbSlJWOEXRpms9Q2WeradYXUlvuHjKIi8Ycchd2MAkZ4PcVp9d
+tBruj3F3cREG2iWQN3KR/hwfVQMg+lZLS9f1iMwXVleMs09laxz26u0cckkBbw5toOC21iGU
+jkN61qNN6neAz4RAsiORHjcEWRdsifYjkemKzjSdGrurMbrcl5b6NqOlq5YWavFCyfS88SES
+gFf49pJII5AzWK6R6g1a5e40bp2Ym716COE2xwy3uxxIsPP8e9QVPfPHnWy65vE0afSdSXdN
+aadOjsU/EEI29/P6Se/evLNb0+56J6iuTlbiFd11p1wOMFvrif37itoxs55yXk9JtkeHpWZr
+e1A2X6XfigfVDJhlkiP8uD9JU+YrMfEnUFtdLMQumBtlZY4f5WkCtkVO6c1BzD1JrWlr41tK
+LbUJIN+Qvi4Eo2nv9WawnW+t211e3dvcgybjxg/hwBjFdUUc8nemYldama6WVZSX7SRv61Ev
+dWikjufGB35BX7+lVepXlvBLhWYyqSN+eGXy/OoD3LTRlpMnB5I74rdRM2zY6n8Tor34Zt8N
+ta0C3vEtb4aroOoK+yfS5pAFuoRx9cEwVGKcbXQMO5BpumNRmiDwyyDwpk2kHy9DWavLfw0E
+qEvGx4PkKWyu5YJUG7KscEZrSac1sxhJRk6PSdO6xt7XpeTRLqzJuvnluYLkHsuCroR7jn8q
+52d5HKWUuwQZZWzyPSspcgkAxsRjB2k9/tU3S5XKFQ/LIQwPlXNKGzpT1Zpku8TQ6kXPjQkr
+Iy/8SM+vripC3otbU2c0aStbSb7edDyFPIB9qoNP1KHT7uFLgAxMwWXIzhDwTiu9/E9hf3Fo
+LhHaBto2nIkQjKsPYgiplG0ClRa3etzRvlkeE71fB4Kn1+xq0/2qtf8A7MH6Vir+8kulWbJ3
+BQhBPPHaq3xG9TU8UVzZ7jbpLeJJfpHEs87uJ4QpQIc4wPQHGfzrBda9Mm1g/bdijiGWTbKm
+3HhHzDD79vIit/ofUi3M/wAhrtg09qyEeNnZPFxjKt/EP8LZH2o1U3bXkGlM1vdo1kVtpGQi
+O9tl4SIg9tvIHmCT7VzJ0+SOym1xZ86atFJbzyRy4SRSPo75BHfNQovCldI7icxRDncse4j8
+q2XWnT0MGntq9qYo1juTBJamTM0KkZVjx9SHkbvIjBrG7IfDZssCR9OfI16mKScbPLzpqVEu
+I6ZCwePTprwHIUzuUU/kv/etHpvVGqQWb2U1vbRmzYC0RoM+Ej58RATyVPfHr2xWRSZ2VIGm
+2oGJUnsuav7SXUWjjW/dbq3HCPuyV9s9xVT2qZGKrtG70iDxo4tQ0lpoLm1T5me0U7m8HuZ4
+f/EQHkr+IDJ9a21prpvG/aNvp9reSMub+32nw7qH0YKRlSeQRyrcivLtOEez5vTZ5reWwO9o
+mk2ywk9pIm7EZ4IrRaZPJfyrcW0kJuIj4rxAeGZQfxBV7Z88Dg81w5cFq0ejiztaZeavpuj2
+V7KdJjcaRfFZ4FnlPj2jeal8fiU8E4+oYNaDR9NU2lvqvTGq/tK9cEXFhMojdwv1Fc9vEQfU
+D2dScc5FUA1vTdWk+WiQK0p8EWsqsGhz3JJ52g85PYfaosPUdz0Lq0X7Mim+dsJts96nIj8w
+ip2ZOe55INcvB3s6HkR1610q6tr157e0lsTI2+O33BlZSMsoYcbTyV57ceVZaDrXqDQQsejX
+awQMTvtbqMSIx8wQex44I7V6rZ9ZdNa3pzWGpaO8MUzGSN7AAfKsxy4RW4MbZzsPY9iKoeqe
+jtLvNITUba4t7uESbJbuDjIx9BkjP1RuOQR28xmnGST2TKPJWUtv1lbXjRTXdh8tJMn735Rl
+ZGYdiVOf+9W51WPU1icyLLIo8NA6jcAOdufMDyrFr0XBIFns9SZZU+oeGAwGPPv2q9tG1BXj
+nvY5bx4RgRRRgFmxwTnGKUkvA4yfktk0Kz1OyuF0srbO7IbhNgZXI/Dgd1Oc8/kado/UnWfS
+NpFa6Z1Hqltb24kjt4UKqsIdsyYBHBJ96bCun3V6Z7VLuB5PpDkCMyHzBHYc8VeNpNxd6clx
+dx3MMDbtwZEaaPacZVjwV/rSHSaO0nxp+Jg0+4t7LrnqSSHUNOl0q7We6MuIJGDSLGeNm/aA
+2PLisbaapNB9It7mAM4B3QsI9vbPNXEvTQgJnN3KsSrl3kfantyB6Vwa1t4Zo7nS/mJnRSCn
+zIe1Y9w4J5GB3Q1L+XYJKHSLES6bJhTqgSJCuGjXn9DxWk0zWOmLaO4s7qCS/lZPFsmhYJIC
+T+8SRHHK47MhyOK83tdGi1UzbLsrcoxbwV2L4nP8GT96g6hos0gS3msbhCGO0MSNo/7/AGpx
+hx6E5t6Z6U3h2f1WZhu7Eu22SSFoNUiBxui3n93Knnsb8iKz2sWWhW00onluZ7aIlvChiG1c
+jkfVyp9uRntXDRhqekQJbPqVzNn6RZBhJ4inupByP9asbSWAyhY7Ge2U5+nIQgn/ABc/oap7
+Eilng1HTLZLObT7iebxnt/lbyDHhrtV423eQKtkMCRxXO41aa0RA8aB7UjbIjNuTA4284wPQ
+8Vq75LjUGWxvNQv53t4Y4rV7pgyIqDiNiMYHkDishrUbePIrjZOVIEarjJ9aP4E7K/56K+u2
+leTdcucqchdxJ/QUn7Qt9HneabT5PGjl2ta3cWxkYfiUE1HGk38CNfahhbZAC6lxkL5nFTta
+v7q3u57d9MaKZiC730LfN8gY3hxlTjHpxinS7E5Mnpq+jXsrW8zXmnqg8VJJCRdWjH8WxgcS
+x88qecc1D1DqDUbeWaxv9RaQjaUcgqki91bHbkefespJJ9bqb4M0jbhkYKP6ipt1qSaxo0t1
+cLAl5pjxQmOSbElxHJkb1Xz2kc47ZFWoEubR06gvL4QrJeWcew8rNGwKt+lZC7vbfeJVChu3
+FdbkzPZLJJqEXhmRtsO4llx5keVVC2Ml7MYIjuLD92R23ehNdGOCMZyfg6XMjTSfL2s4RynB
+c43n0B8qqJo5YpWjnVkcZyD3zU+O2kt4Zxf2026PCovYq2ec/lVkOmpdQEiWtveSXcagtAy4
+ZAeQfcYrpTUDklCWTfkzP1MfU+9IODk9xVx+wLqOQpNbuGH8JrhdaXMsSSLH9ZB3oB25/wC1
+WssW6sh4JpWV+AecnJPbHlSBiCQDipFpZyXMrQJgOELAH2rm0LKMsp9jV2jPi+yxtNaeKOO3
+lVWRf49oLofUH2q5uuppl0y30W7khurQuZhOgcTKx8yxPP2rKopRipX6iMYI9al2MYnJtWbG
+8ZT71Ekls0g5dGggncyrYz6isMDxu0M53ONwGVGB2yePz5rhZ65fWd1FIXZLiNhgj6WVgf8A
+PNWfSvyFvKllf7o1ckRTh9uyRuO//fitL1f0dcaPLa6hLZIkVxJJbmZ2GZJYwDkgeqngjvg1
+yycfo7Yp1ZaW/W1/dRw6vaiG3uYpRMQqbV3/AMXA7ZP1cdjmtAeobfWhdyS2qxLq0cRnEa8p
+cRHKykDgnlhu7lWIOawojhhSOS1tw1tcpviZWOGYdx/zA9xVdb9dzQO4ZAiudrsOefL86xp+
+DW15NBrNpo8g+auITp8qT7PFR2+Xkz/BIV+qM+hwRWnupPgRPpWnRal091T8PuoZV/8A8jDe
+jW+n75OxkMWFuISO52M4z/DVbOJdZ6MXqvQLZJfCNvadRWlzD9cEmSIryEj/AIMi4Viez8eY
+pNOvemksr3QNYt5rPTZpBNIuDLHDOvAkQ94mHPI4I4NEcnHQShy3dCy9FX9xqWn6VoOuaN1Y
+b59mn3mgyvKXYk4jkhdVeNzj8JGcGqDU9At9O1cQdR2F2yRlldEQwzROTjcEccspzlD35qTq
+/TOr6BKbTRbyLVLW+2PDd6fL/fxscoRg5VwR27g8VK0vq7Wr2zudO1bUpL2NgSzXaeMy+RyW
+yR9wcjFTObu0VBKql2UWsfDiSwX/AGj6T1GLVLESBfFtBueMnsJY/wAcR9mGPepUF1eaRFEY
+51UQOgnt7pds8G78QAPDxN347Vc2OrC3vIb9bhUAUwyT2y7ZDEVIKvj8QxVrdafLJbPJp8Ka
+lYW9sshDwK7ND/EV82C+eORSlltbHHF5RI0yfT3jmQ2gNrcYV4ofpxg/S6nyIrZ6Zptrb2Vx
+p9yBfaWYt8tq/wCN4ye0bj8EgJyrcgNjPBrB6Te6HGgGnWi2QKYdVdpA3uGPYegxxitNpOpy
+Lbtpc4DK5Lq5GMrj9e48q45OnR2RqrKrU9Fh0xb3Q7LUPovxnReoI5DFiUDi0uVPAEgO0n+G
+QA5waxmhaxq+mQfLLOV8Gd1uLOfACP2J29gc5zXoOpXMi6eECqyOCJEkAPI8wffNV2qaXadR
+xxay3h2kxC2OpT7MCCbGLe6kA42PgRv6Ng+dXCd/Fkzx1uJVy6s8kYe5t3yqgK0XDA5yGHuP
+L3xW51bSdPttKh620rWW6hsJwbW4laDwmlX+JXGT4c3BU9gTg4I5rzwQa5psM8Wo6eFlsY7a
+WYypmNYbk7Y2JHH4qmfD/qax0/VHs9c06O80bULhLHU4Ecxo3jKY47gY7PCxDg+xBp8WuxJx
+dGyjntJbE2MWpR6rp194yadc3rEywo4x8rcg/wAPOMDgOoYcGqXrKOTTNcutQ+SM+k6zFbXz
+pNGWeNYIRFLGSOMqQTkd+Kg3ukaj0p1HrnSussk0uju6tJE26ObY2wyIexDDBB7EGvT4by2v
+elNPtbed7g6TDNcx+NGHZ4pEZzlfJkfII7Y5ohkadSJnBVaPPLbR7KDqjRNP6iMsVgouJ4pk
+RZJjNJbF7YOw/FGz7cN3AODyKa2jaj1ZYpHqNkItVDLZMJHwhMLEZ57Ag9j51qbubpiz+Rtb
+2S5NrGtmZmkYHwUmVZOAuG2BjwQeK0CdP32v6vqHSmtaYP8AaSK5WQSWhAfUbKQ5Sbj6TIoK
+5xjIOfKtHkdkKCo8r6f0S9tP/Zun29zNdQT/ACMcC5Mkjux2xgeuVJFc59NRNPstYtBI51rQ
+Z7idSoAQm52FeOx2qc+9XfUFvcdNz6hpt5HcK1vPbzu0g2TRyRFlO7HbnGPIjzNS+uNJi0C2
+0vS7ZpIYre0g08RvgNNF4jySOcdssaTm6bYlBWqItqI00YXKoJZr4GHcT+N0VSpGfMKMVx6l
+1O+1C5tL+Rzcm0jEMbuPJAeM+efEySeab87JLp0+lQ20LJYXYu7R9v1xuxCyjP8AEjJtOD2K
+5FTWW3vpdXhtzuhs1E0ZA4d2lCFue30+npSVVQ2tlRd3+oxfC2W2V5JIUl8OTy8EOGKfkSDj
+8xXnOrvqfUC6daahO00VtGy2sjAZRRz4ef5QRwPKvTtdngi+HIsITJ+0NQlb5iLZ9MVvC5Cc
+/wAzcH1FY3RNMc2uZcLCw/dHOSp5zn04zVqXDpmfDk9ll0LNcWi6o88cTx6tFHNJ4sYZhJAd
+6tG3dWPIJ8wcGrCG/Z/nGliRIohvQL2VQPwfaoEE9vDCAGb6ExGB6Z86n/KLJBZ2USBTPZrM
++TjxQxJDf0xSdyNFoxWrx3Mz28d0p+rExGOWZu36CtF0/a/L2cNyCDKpaZmHcbjgAfYCoiWN
+1PGfmlc3bv4cG7tCgO4knzwO33rR6dax7YbaE4VdqHPcrnJP+dZZJaorHHdnZ4v2dpc9sObi
+7nLoxwdsCrksR/iPGPasP1wtvHrsmrpawWtzqR8aO2txgRpjlyP4NxGQo962msRl79rVn2h5
+CgdzhdnpnyzjFYP4gJbP1Ffa7pUkslpqs/jBZSPFt1wAqNj/AJeCO4ro9OqTsw9Q7aSIqTyN
+qqNJuaRRBEu3uFAyTUbUIYTpt/cGNg93J4UY8iA+S35mrHp+2E2pIww3yqCb6uz4I4NU+p3L
+wjdJJ9EbmTb5Z3HA/rWsXbozapGbuL557qFmGBCnhH8jVzp4doPFUjePoXJ+pgTXD5Cxu7fT
+7lHZZLiSaO6GMiOQHcMexQjH2NTba58NCkQQKv4B5getaTpkR0aC4u3uflrWaZngtMm1BOfB
+BOSg/wAO7Jx5EmuTMI45QrBpWYHkZBGeaiQOESzZpUMd5lQF7o4OCG/ofsa6PN9QfZn6sN7c
+1g0bRY87nHhxBd0rZXHr6Vofl1igtLe4cLHbrukOO8jHJA/LFUtliSdlPCId6epbyFXOpyx2
+tvbRmPdLcxCYsT/dt2bj1rKZtAgahLJJcpcpM0QhO6IqcEY9/WpcrzHTjf3MjZvgWTPng4Lf
+rVVc33ioI2IxjP3NSJrmWSw0zeR4cduURfs7ZP8AWovyWqFK7EELZBPJOe/nU0Oy2ZEpUIh4
+45NRY7mNdNMU0EbPJMHin7SR4yGQeRUjHfsRxVPfaiZY2UzFWjYhVz3pq2S3RdCeOXu+YkBE
+gJznPb+tVs0TxTeCB9ZG4n2NNa2eztbS6uG3x6hE8sOxshtjbTn0wa5sWlbc8xdW5BHcexoa
+onss1lWG1lvmYs6bYLdc4yx7n8hXW2uFtreSZ5AhtXRd3nlu/wClV8dzCWiWVsCNgyEeo9a7
+ur3zbEKhbks7sOBk+f8ASnY+JECJNeNcFgzu2WI9++Ksr63iMMEOn2M0LiIJNJLcB1lkz+JB
+gbBjAxzVdb28toN8wBiyAZEOQalS3+Xa5I8QRH6c+ZNMCM2uW+lX3i+Ntmt4nxj+FiuM/wBa
+w+tKILdXYPi8HiRsw/EoOD/WrTWbeW6aS8wVkmc/T7CqnUbi7gazsLxVeK1VmRW5wHHIrt9P
+FLZxeok6KQ4/OnROFySitkYwRSFfLvXaKxuZgvgxli5wAvJ/Su20eckzkQhP0K2Pc05AVzwM
+sNoz5VdQdOXcFne6jdoq21moBYnG+VuFRfU+Z9AKpmyYvFJ7NiknZbiktjXJQ7Q3KjFNDEMC
+CQV7GkDlcgYwRg5FNJqjNs6KB68mp9srsVU/Sp8vWq5c1Y6cx8RB6Hv7Vlk6OjBt0Xsibora
+IqTHbISuPLJyT+dWVrFNdWM1lbHw1RfHODzJjuCfsaqYJ5ZFZTkhl2L9s5qwsZ5rUloXAchk
+55yCMGuJuuzurWjNXFu0187vGVRnwAPT1qFcxYkKgYOa16aUHkeSVSsYG5m9Pb9aqZLOZrhV
+WLY6ng4yCPWt4ZTnnh1SKVLZmTxTjbnHenqAo8MkAmrXUoiLZViBxnc3qfeqpoXQK8hC7+xJ
+5ArWMuezNx9t6FllCnI5IGCfKhZijjk4pILc3MhgjbJYcYHBNTbnTWX5JoyAbi2EhDHHIYg/
+5U2kuwUpN6LjTJZ54WdQ8qqBk9ytd7p1ZdsanfgFl8xUOytpoLcZYLuOMK2T/SuEkrxTbjOT
+n3ziuJxV6O1SLCKaZoJImBVXIyT57c4qFeZhjSWRVlRWCknsfPBpk2pOAgBB2gjBrmJfER1Q
+ErIPqWqjFrbJlsjajHa+GrWzMyNyA34k9VNQS25lmdiSCAfPOP8A0qXcoF+hR2HY1FaLBYHP
+qK6YPRyzhsakL3DnavJJPpU+ytR4iSyN+A5+xFcLeAo6u+eOQB51Y27ZJ+jO78QNTknWkPFj
+rbLC1u5biOQSSBSD9APoD2qXf6m0nTkujTwIQl0LmGTGGRtpVufMEEfoK5W1tEEMzYwwwAf8
+qbL4Tr4MxVSfLNc97tHU1opkwoQB8jG1h2Iq1h1FTbrKt28NzCNoPcN6d6rbqWO3R0Kx717Z
+HNRGuv3SyiSGTeMPGQQyn7Vrwc1Zj7iTpmhg1C3uCEniETt3eIYBPqV7Z9xVvp0QklXZcIWD
+KfGQ4AOeNwPI+9efR6hLBN48DbGB8u2KvLHX7N1W0urVY3JOy7jYhkJ8mHYrUTwyXQ4Z4s9f
+aw1jS+pL3p3WNMuen+q7UIskF1H4S3IZA0bFexLqRz5hhUXqbVIdehstbtUgsde09VhMTQjb
+MIv+FIh4dlHAz3Tg9qgW2pJqhtb/AFi2luLm3gS2urmKRjJOifgc7ifqVcLx5AVZa3oFndyD
+S7i4HzN0vzenXcn93fIOR9Q7TDsfX71z9SpG+62Zy4m6c1Sxl13TIv2Lft+61HSrcFreRc5E
+0GTlAp/gOdp7HFZzqOZLdduoeJdyX/7yK/ztz7MuME+pq51TQmt7iDVdNtntoJtySo0niIJc
+YZC3cHzAbyNTOienem+p+o7PpXrPrCz6W028d4l1PUo5HtLa5I/dCYxgvFGzcGQKwXIJBGa6
+oy2c81q2eTyAqxVxkA1Z6HZ6ZLdPHrkt1bRPCzW7xw790g7Ag44PPIrW9YfD+86R12S11qzD
+I4JglSVJYLhM4EkU0eUlQ+TqcHINZnS9Rl0bVtlyJ5bbJR4mXednsD5/auhStHJxppi3GkG3
+mLWEi3MZXO1fxffHrRC9xa2L6gyRXMMgMZRycqfXjkGtd1L0Vd2OlW3VWiOLjTbyIXEE0YYO
+i7iu/HmAwKtj8J796rRYx67o7XdxA1vPHJsu40XBJxxKB6HzrNTT2zdwfSIeg65DCYJTdBLi
+znilhaZAWXDdtw/EvqDU3VkvbXV5V2ARTs09pIvMbRscmPP+EkgelUk3TklskgiuI542GFde
+GRu4BHofUVtOlrya4jfT7vSvCZbZDOjDdE+eFmj/AJWPYjsSPLtRKkm0KNtpMldNdH61rcGo
+fsZIJ7vTYUu5bAP/ALzPbscPJEn/ABAh5YDkZBxitd0zolvdxaXezyROst8NLm2sBLHI0bPE
+Sp/hbYw9iMHvVPFZXNjPJdWVpvvobR4lwzJNFk8SRFcEMO3271b6FrA126sL6YRi8kPgyXAX
+a8jeTSD+ZSCM9+a55tNWdMFJN30bHopli6gspxHlXm+SuP8ADuJCufVSCVIqs620R+mdXm0C
+GV0jguEl06UP/wC7hZQwjLdyqt29Biomlaxd2Fg95Bu3RkCaNRu3IGxk+w758q0vUB/2k0tb
+a+YC4CM9hIeSGHJQnzBBx7ECuWWtHRFN7OXxC6lkvfirrfU1wzyx390lzqbzMNxLBY5i3qOQ
+cjnzq/06z1vQ4uoNOvLd47zQJo4JwwyQjMDHIR5qVK5I7h6yV3Na6zoNt1Fd2ki6lp837L1E
+kBkeB42CmQfoobz7GtHorX2o6Do3UEVzLJqLaOLC/cjDXLQkqhbyb6Agz3wB6VnJW7LWlSKa
+40SGPWb+7eEjStSszdWrBtyo6n61/wCn074xXbSB8jd2d3aiSOO5XwyVP0sMd/8A6eVEEuta
+n1DYaP06Ldn1yIQ/J3WFjmvFyFCscbHbO0epbBqXpSNbxWlhcQstvPEXhR8rJayoWSWFgezI
+wYYofVjj9E+5hIljuoCcQsGZEXuo7ke4rr+0Ut3JSRGB+uOTPO01Js7prbSodfvbYukM/wAn
+eKnKbipxz5MV+ofY1jnuIZA0ILCUtm3Zjgd+x/70pKnZSleib1DfjUrWW1eUR+NEyK5/CreW
+favN5ZrrUrBNC1GYpNZq0cIc5GAc7R/pWx8eSOeSHULVmtpsCYIBvjx/EueKynVXTd1071Il
+lc3XzNtMo+VuNpUNuXKqfRsHGK3xt2YZInHSdQn07TZLS3uCk5jZcA48RM5KH/MV5zr+riS8
+YhmMkZ2t6mvTtJs9Gt9VsbDrNruDS9RiuIlu7Mr4kMuw+Gw3cHa4GV9M141qrObo3DlfFU7Z
+gB/Fnk1241o45unRW3d0fmzKFzt4w3pSpKqDcHARs4H+lR7zcXD7g6nsRXONJHVgqMwQb2wC
+cD1+1dKWjl5O6JFxPuUQKCqg54PGKP3fy4kjiAkiYHd6j3FR+DtJ5x3+1WGkxxyXqWlx/dyn
+aD7nsaH0C2yxuNQS+umZY1jEsaOoUcBgOcV1kufAzcQjO5fq9j54p8nTd/YyiC4gkRWJ8Fsc
+qw8qrL+OXI8+Npx/N6Gs3tmibSB9WchSVzzxmpY1/wATwzNksoChvQDtVJN+BGzg8/TTEPO3
+tnnmjgmg50y8l1Xerlgck/iqL+03/n/pUCVjGcbgQRzg5rhvP81CxofuHuUdzcxzJcSzu0hO
+ck5xWvN7BdaXbie3kltPEZo5FO0wzAD6gR/UdjWPuQiJtkKSCHI3xnhlJyD/AFrQaTqcVvaf
+KAF7dHLrhuQGxn7jivIiezIzfVGk297bOI22wSyi3uwSAInkOImB74Ld/IHvXkr2M1jLd2Fw
+nhXdnIQ4YfiA4OP8+K9r6w0q0EJv4YPEhljAuGzwsmeDjyzxwfOvLepYCZo2bDSqu2OT+ZMf
+hPrivQ9O/jR53qI27MoVQbgH5BG0Y4I+9aXp7qC3tNLu9NubOJpXKyQT7cvEy9x6FWBwQfYj
+FZxo3jIDIVz2OOKkwtbtGzS5jmH4WHKv7H0rre0ccNM3fTXUsWnajba/o7Q2uoWgkEltdwC5
+tZ43Uq6lG7oykgjyOCKhavf6YJ1v9MtXt7Z22vArkiE+YRjzjzAPNUNtcNhZIYI2ljP4QcZH
+nU63bpu+a/Gq2+t2zyWzGyisQsiC6HYyhudmM/h5rNqzVuton2nUVxpbSoZPmraYjMh/vNhH
+Iz3+4qbF1de6RqYmnUS28oG2WP8ADJH5Zz5j+hrHRRXDr4aSrMF+rg8j1471Y2VxaSp8iboF
+ZRhYiuPDk8zz5Gs5QTRSm0ekW/VnTmo4CTzoT3BCqfsCtXtpqmlJC9vbXs084XMbp9DIO/1H
++IfevJItHs7lREsj22oEnaoG2KTHlz2J8vKr7T7hvl1jm8eGWJAsjEfUUzycedcmTCvB1Y8z
+Nlb2ljdsmrW6Kbs7w0aJ4Lkdsg/gfPmKmWcVy6tazW8M0WQFk2lZI/Zl7H8qbplzZ2VjHp8t
+jdXE0RLFQwgkCnkFFlA3HHOK0+m32j3+miS1a7Oo/NYmaO32PHAqZRZ7c5BIfP1qexrJxrs2
+530UWr6Ut9bI0Ec9lJNuKXtopaKYgcxyL2DjGfpxkeVdLHWS9kH1YrLg/TNEm/ywcr/XFbuD
+TtMfS5NT07UrSNJZ9l3CYpPCTjhnK8oS2QHA4zzxWd6mtWtZH1GzNtd2sw3TJDtW5gZeCQyg
+K+R2OMkdxUyTStFKS8nO4gKLFb2GrRyyhFmQpbum0YyOSSpHlj71HurDqDWQ1zdX1lazROqR
+wGNYHkz3ZTgKQD3yak6dNaXOnRzw6hBNAHwkpTbMpx5sOx8sEYq7sDPNJdJbeBdJDZSz3Re3
+3qIh3LLnvj0zUxVjk0ZV7BRMLLWdHuxdBwoa3stkjfYYKP8AcYqNd6kNNjSxmuWlXYPFgnPi
+qSTns34T9iK1c91PYJ/7H6ge12jfHEsjx+DkA/SG7d81mNZ064126juL/qCzmuZQcyEKrSED
+ndjgn38600ZlUJOmWlMqRy2ZPd43YqGz6Zz/AFq/ilhu5IYLXVZdSYBvDFvBtO8nJ3bwCx4r
+MXmhS2L+Ba3MJYc5Q8/14qERqsD/ADEl7PGinbgSHv8AlQhNm1kv7W2hhim1K6kJJedJFXZE
+c5UA/iDd9ynioUZ0/WtaTTWvIYVuH8K11B1EaxlvwGVT2TdwxB+nOcVnp9WuAv8Avjw30ZbP
+mH49ahXmqi6dmjjZZWGGRsAbfsODToHIsL5fBvWsNSu1WaKUJKdwKqUPIUjgjI4I71n+p9av
+dc1u61vUL+a6urh98s80xkklbAG5mbknAH6Vy1zVbaQRSIrI20788hj7emKy9xes7EFjnvjv
+iqUWyeR0kJSVmx+InBpJVDbZoij5XDE/wGoWXkYsjEAHuTU+2jLrsyQz5H3NaJURLZJ0t7EK
+51Oya5iYjO3AI9SueAfvxXe8tILLUWj0LVG1GxZVeKR4PCfBGdjofwuvY4yD3FddcutPi1C5
+TTrEJbFgsAYklFCgZz7nJ59a421y1zEEkf8AeJwjefsKfKhKJNlt7LU7ZrqdMlMIwBw27y+9
+TdH0wNPHqqam0VzAATI0pR2A7KfXFS+noVWVbH5WG+stTKxtk4aKf+E57gg+XYg13t9GHz6w
+3CvFIC5XJyjhc5X2IIxiocmi1Eu9Qu/2jax/M6fplxeRt+8JCrJIh7MOBn0P61mtS0rSbtsW
++k+FPjds8Tbn7Z4NSJZolWWC7OYRKArkZNu57MD5DyI7UhikZPBvpNoU71kH1AL54x5VDk7s
+1UUY7X9Ljik+bt4TFLGNkqMuCD68e1VVn05+1p5IrOQ5IyUPkfLHrW41PTL2zv203ULLMhRJ
+MghlkiYZV1YcEYqEOmm3JPayNCUb92/ibMHy5rWGVpUZSxxbKPRulb27uzZXVussg+mBFI3+
+J5fceWDWlg+Flta6OupXF8q3V3tnhgEbLJE4dkltpA34e6Or8hgTjsaW40xWZrlrmMXcjhXj
+jfLOw8zjjvV10x1pqEurjQ+rSLy01AG3klbAkRB+EZ8mQ4KnyIx50SzSphHFC1o88ng1Ozuv
+l3sBPsYrJbyLwT2IJrddIdStdWh6XvLl7u3OBDHc4eS3I5VQx9D2bvg47Vo9f8EanB0v1fbJ
+NaeEps9fs4xFNGrcL4vG1wWx9TYPOM1QT9GzC6uZYL6N7iz057jeimGTMbDkg8E4PYHy70Kf
+ONDePiztp0dtoOpvZapbwpIzGeG4li3Qn+Vtp4GcYNUnXPSurqLrqHpm1judIlfdd20USl7S
+RuSTtGfDJJKt5dj2raW9hcdXR2Vh8xYi6YM1o119MLylOYWx2WQgD0DEGpPTmrQ30vy11ZyW
+GqW2Lea1UlZwi/8AD8t+3nGe44zWKyOMrNfbTVGV6E6yhtVQtA1uvhC21CHxy0V3bn8UTKew
+IGcjgEZxW01Lpv8AYmtPfotvqWg38ZiimKBiiSKGRXx3xwN/ng1luoelLe11pNStI7RbO6LJ
+vt1Kxu49UP4T6r7GpmipqVlpoaOSSS107ZE8IflYWfyz3VWJAz23CpbV3EaXiRV3ui3PSd+J
+tMJn0u4kVhb7/wB5BIDkbT5kHsR386iahBfXC3mu2ErJeWUpa9jCeFJFk43bT5HPII7mtxrs
+3TlzPNo8yyraSANElywWSRcfjjlUYV/ZuCRWTaTU9G1F7yx1i5knaLYZ54lLTQt/DIDkOCMd
+/Si7DiZtbie6JurTInjAO5F+l+fMDt9q0vSPVd7o12YrQstuH3mF/wDgu3BMbeQz3HY1RkLD
+OJY0ERByQh2gk1Kj1e9WdopLSIW7EeKyp9Z8vxe9DpoEnF2bGQaVqlxJcSLLpV23954MYMMx
+P8QBwFJ8x254rpoqTx6gFxOHQMo3jHBHPB9qrtNuJYo4DqlmLuARmNlcBxKnOcgdmAwQfarJ
+YGWBvl7l5BDGzo0jZfC9hn7YrGWns3W9lvqGlX1uCkjR3FpcRqVZCGDKeQVb18vuKboTwaDd
+iXUtMN/b39tPbXVrcSmI3do6/WIz2LKAGAOfqQYqpg1+/wBJiWTw0PzPDxMu4LhgxKD+EsPO
+pWp39jclbeyvYZ45SsscVzgxo5PGGP8Adtj/AFqlBMnk0qLK01jVLfS4dIOoJdaZaWkmkXCv
+GGjv7Iy+NAXBz+8RjkMDkHPPlWHvtCW2hu7PS7GTwb1o5jLuyI2UEFR5jknNaL9saXZ3U6QC
+S6dfpCYHglgeAACSR3796i6nbXlwgura5On3IfBYL9IbGQCo9a1lZmqfRsrc6H1t0noKS6Vb
+6drFlolz0/JqgZ2hvpostBLdDkhSdqCRfwuuGBB4elu3SmoCztJkllj0+PV7HE4eRbmOMG5s
+pWGV+sbtp5GcepFSPhladN6p1dpOmap1OvTNhdibx7hbUz26SvHxuUciLx9hYjlFLHBxXO+6
+X1nojTjb9RWBa60y4urKS1LAycTFTGpH8O4Nz5gqaykn2XF+LO/XWh2TdbajouoPCltcS2At
+HlkWNZLCeKNo5fRVUsAw/hbIqJba9qctpo2gapPPDf2Ms0aNFJgOYpWhhAccg+EuA2cMDzWi
+6ottI1fpG461s42hvNGMlhKixgyNp125yZuPq8KdAueCFcHtWTv7I3UjarNObVFsYSm05RCv
+MUgA9X4PswolvoaVMs9d1WCddPunKm2it3js1kjWVrSOVgzwOvcx5O5QSQpJ298Vw+JM9pql
+8JfkhK8WnPcoMujqkabiyjOGLKc48qz2s3+paVZW/UsVrGIZLyWyv7LH0jxYvERSf5SFYrjs
+yVG1W8Z9ItpFmLrHHABc5JdEcFJB684wR2qktUQ+0QLxDYLPBNHiS3SKVpY5twCyKrI3uCGA
+q20eGK9SS3jbJkgIO0/iKOHUZ9+1Vd2gu9KkuZb6Nme1SxEWfrxAVCMR6bcY+1demtQutFhu
+9KgvIZnSS3uI541z+8U5IyRnG3GR2zQv2Nsf1krWsUbMmBej5hD5Mu8r5dwSKzVjCIbcsCSh
+kXeqjzJ/7ZrRdZzSNZaZCd8iFZHtif8AgwB2JT3G9mI9Ko7KSNrYRRkguSXkPp2z/nih9ivR
+Ua3e/KXKwwZAuHYRqR/D5GtsqImk6RO0viu1pGEA/FGkWQVI8uSapOn9O6bvppp9XjuI3P02
+Vwko+iXuIiDxtcDGf4SQak3WsJFpsc8cE0bh/lYImbLbmPK/YY5q3oiOwmdtj28OfFuRuI80
+Q+XtxVhpXhW9690wYxQRgjz3P2AFUVhZTtFI5mbdJO0lxPJ/CoH/ANfFWkly9lohuYICwaYW
+8T5/jP4ePXFQ42WnWyBeXt04mlRDLcylvoJ4RPMZ8ie2fSs5qOnhpArStK8kn++SuQqIFXcF
+UeSg8DzNXkivFDNIZVjSNfqcnP1eg8ye9VWpW86wQ2k0ciPdDxCp4+g8q35jtW0WkjnabZx0
+QGK1uL/BIKBFHn9R7/0rI9Q3KTXFvZrgnxF3sOPYCvTUsY4tMisbXYLvUSYIA5AAdFJJz2A7
+DmvMrbTPHnF6Zfpt8yNuHJIH+dViab5BkTSot9KjmtZ9X0tYk8KVYt5IBKFeQVPkSCRkVn7y
+cBpFgG0s2FXPZBWxl0afTLWCW4PgTX8QaRS2XHGRnHsayGtmzs7Z7e1UNNKw3TE87fNQK2g7
+dGUlxRDgv2inBWbhefYGr+yut6kqwVZRhj6454rEo4DDnPPNWVrdy+OioSWBwi1rkxfRGPJy
+Nxpzb7mJpLmOONJFYsz4xg5rRdQpoU8Fret1fYTTyIzPBaW8srByx4ZiAo4xXncFww37jknA
+LA8d+amw325ri2Vv3bgFR6EdiPeuOUTqjJpaJubS4mdI5nZkB4I7VOlnjaC2jTP7qFkPPGSx
+Of61S2kJj3Hne471Yx5kQYxhfpArGSXg1ixXlYwFB3GdpqmKSAAkgFzyTWhWBXVEi5Znx+tV
+l7amO4e0/Eyvtc+9OLoJIhQySRQuhkwRwo9s+XpVpYatp8MdxBe2ErsyfuZY5QDG/qVI+r7V
+BisLi6uGgSM+MA+xAMkhe/8Aka5NEBZm7jkDLu2e/wB/tWlWZ3R2N0CAw544Jqz0+6Py5jlx
+tKn6wMlcDj/tWTlmIYhTwOKtdLu7eSLwhLsl7YY4U/Y0PHSspTL22Tda+ILWeaISDeUYAKMd
+ue9XsFtp2DqFlCzW20bHYqxVv5WHqDWatnCJL4iOHUfSpPB5qXFdwyRrHHc+CiIS67e9SKyR
+qemG+guZILQSSQxNcEx/yj8RI/OsBr9tJLKlw6EOFCFft2xW/tHMtrlZDGJHCMxzgRk4JIHO
+B3PsKldffD1+iLi1luuoumeobK8Ej2N7oGpi8hnVGCsWBVXiOT+F1B9q2wycdoyypS0zx8WM
+zExqv1qfqHpU8Wx0i3W6eRxdyHESjsi+ZJ9a0NrbW6XD3Bi2pIOxHNQtTtH/AGdczhCxjII+
+xPGK6Fm5OjlliUVZm7y/up4hBLMzKrF8E9yfOoWTT8Mx7ZNNYAdq61rRxS3sSiiimSdUUld3
+GM4xnmptvA55XI4qFGyjAPNW2lMhfk5AHPPYVjkbSOvAlZeppkkGnW87H6XDZby3ccffmn2U
+TGVNi737KAO5qytLyW66RnsxaI9rbalFMtz5xM8bJtYejcHP+Gq61kltJUO76lJBI8q4ZHei
+yvUaNWsNw3JxIQeN3mPyquuy0MeUwZMbVJ8hUq1ktS5a4udqkHG1dxz5fqaguXlJkwDgkAZ4
+qL2Oiuu8qoGfwH9apLlnkkDN3Ix9qvbsbQSq85/pVdOiGX93yMc/eurDKjDLDlojWSzlw0Uj
+Ic4JBxxVzMBPbQlwS0AKnPpnOc1AtYQhy7njnAqeZfDQQSEFZe3tTnLk9EQjxR2ilCwhfDGO
+3I5qHcW3iBpkQgDhj3pJXjifiXfgcDyoN4GBxgHy57Vmk7tGhCniIPLDkZBHNOtcIRlznyAr
+rGkc7HxXAwM5xnPtXR49sInjVNp4J8x+XlWjlqhKrJMa20gJlVd+OAf4qi3MMYkDhdw81bjF
+NTwlVnkkOR2GOTXaUF1MgxIJOc47Gp/EfZwmYTMWChGHAUelMgJ3FiTwRmpsEDNF8zLCngow
+RnPkT24rrHHFlgIAueCwORQ5KKGlZMivYbfYjIXjbDZPaoutXsazSzQW0cSO+8RrnC57gZ8s
+05YSihGO5QcflUXWYvD3W7LyBlG/mXyqIU3Q5KkUdzJ4rmQOTu5IPcGo5JFdtinfmQIV5AI/
+FUi20jUdStry+srFpIbCNZbox8+GhO3cR3xkjJ8s13qkqPLlcmQRya7IqgKXyVPpXHBHanqx
+A86bFHTNjpetSiC3tradxs4P39a3ll1KH6fbQb60hm8Kbx7Z2GfDJH1Bc9s9/vXl+heDcCQN
++7liwcjswrUQOy2jA5YgZBHY1wZIpOj0YS5KzQ3V1BexLPpdyYL8YSe0mGY7r0Ppu8qnrofS
+XV2h3L2cZ07Wo9sM+nTyHaXHZ43byYcYP4WGOxzWVsNQ083Ea6vC7RSZRzG2GQ4+lwfY4qcZ
+HuJwsr5JQIJBzu48/UVKdF1Z30TQ9Zi0fUunbpry7fQlbVYdFuiyLdQLxOIfOOVEO8gcFQ3B
+xWN6hsbK98LVulbuaa0UcLMAJoM/wPjg48mHBFbKy1Hqa01Wy1fSNTml1XRXWezLSbnAQ/gG
+fby7YyO1XXXOjaLLNZfFfoayt9Mt9YJe50pkItlvAMz2yg8ISMuingjIHbFdEZXtdnNNVqRJ
++BOrRdT2Nt8ML27UXD3ksunRznb4ErpiaME8eHKApKnjeoPnXbWOjzo0t7o1mzNdIwjRGO4h
+kOSgz/EBkbf0rFXdpHb6xZdT9NWD2UiSi6ghMnOUOTsbyAP8J7Z8xXvFnBb9fdP6l1FaXccu
+oaTa2+pz2hQCd7dm2yTIR/xIWILcH6c5rmyS3cTpxxtVI8bl0Gw6w0WLSflk0/qC1mf5S5if
+EV0uP7p18mJ7H14PeqXo3WTpt/L071NDKqDdGsxj/eWob8QI/iTzx5HkV6X8SentU6itLjrn
+pmC0j1DSbiJNVs7RBD8wjj91exIPwliCrgcbwD/FWB6l1Sbqm0h6x0myaO+tf3OqRKuGR1HE
+u3vtP8Q8jn1oU21QSgrtI1eq2kiRwyXupRyYUC11K0k3b1/hl48/JgeahpO96wu5YRHcB1a4
+kj4VpgRlxj1HNZ3StbvIVtzqVi0Vtcr4gYLiOQZwWA7HB747VqV0y5EL3dhbyyWjK7ymIF8K
+BksQOcY5z7Vm20i47Zpbh49I1uRHtvDV4vEJB5KMAGG08EHOauYoLe+8HS0vzLIJG8Jm+glH
+XgexVsYq01ePQerOhP2pPZxWOrW9taWpuY3zFL9SqJh5rujILDOCQexrJGwuY8QTmSK4028E
+Nzt5VomH0sD/AJGsZK6ZvGtom2N/daVfXk8i7XntjBfQSp+6nRW53L9x3/OtZ8Oo2eW46XVp
+Rb3khe2Ujds3KXXaO/A3EY5IyKz1wbfWLO81CS7Dy6dG0t5AqHxLiEYDyxEdiAdzIf5SRSdP
+6ld2iWt3peobtU0O5ha0wuGl2NvgkB9VPBB7hsUxWjnq0c1kIJyR49hfiZSQcOOzDPrjDCu+
+mSTvYRRku0kN1PKjM2SzMd2c+hBNWvWeoPr7/wC0VjDFHb38LXlzCi7Vj/eYcY8vDJYY7gfa
+qjpG0vbzrK26Ue54uIpGtFYj9/KilkjQ9izKOPXtU05R0NSp2y60/q60knuNO1Lc+n6jbrZ3
+j+HlUCncjlR2kTkq/f8AEDkGs02kXba0nSbSwWt7ZTmzkluJMRfWQYJWbsI5AyYk7DdzgUtw
+WsNbN7ZhUnOBJG/4W54DA+YPFHUF3Z6nptnq6yZu9Nt5bVlDndPZZ+q3b/k3Hbn+HjyoS1sb
+au0c7n9o6fcx6VrtrNY3lrdy2N5byRnxI2Xh1YfzIe49ORwa79aaRp1royT6jH89DZutvqVu
+kmT4DnMdzC47MuQwPsQadrd9q+uaDL1XcXT6pBYGxhurwAePb7B4UFxKe7NtCRl/MKoNWWko
+nU2mTyAjxo18GSDH0spzuA8sE8ge9aQaTtES+S2eQdReCnS9uZrqV7uC8cxk9pI8YD/cjHbz
+zXl2qoFuJZYWMiTjecdwfOvoJulLW56R1np6+8EXdsfndImlJBeJWxNEjeTjP4G4OOK8Avlv
+rC6ksowrgMWXI/ED5iu7B0cGd7L74Y/Di0+IurNpN51FFoMV6r2umahdqPlDqZXdBa3D5HgL
+KQUEp+lWKk8biMpO+oaPdyadIXtrm1aS1mU43KQdro2OCM5FR2eeJ5IUZ40uMCWMMQDzkBh5
+4PNcIow8oiZwm443N2z711qqOLdk0WiqjISEkXg7jwR5EGrvRelrzVtP1LU7Lw5W0qNJnRXG
+4xE4LAdyAcZx2yKpRI74gnUb4xsb3AqVoz3FjqC3FrKY+ChIPcHgqfY1nI1j+j1HpNbDrnpv
+UbWTXIrHqfQLdr20guuI9SgQfXEG/wDFUcjP4hxWU1XSrS82axpxKi5XfLCedjfzD1U/0p9h
+Hai6iuePHjPDKcHB8jWhgsIpIDAgVXL7lbP4QfKueU6N4xs8w1HSLqwKG4jPhsCyN5NUZ7Mo
+yK8oxIm4Ee3lXo+qaPdLHJaylGi5kiDcgnzA9DWTOjPeQtJbJsaEncrelaxyWTLGUc9uimMo
+cJLHnPvUXwz6Gp95GVhRcf3RIIzUXxz/ACLWsd9HPJU9ntZWzlikkS4DFXCNH2DIR+PP34p+
+m3h0rwXxHKPCdWjk5jlTzUn7eY5FVOl3ccgMNwu+BBvdA21mx5Z7iukGojS4GIgBR5N4QwLO
+y+mC3A/SvFS8H0Dfk2FhrcFjqFvqegJJd20yiWWyvEDZUcSRHP0yAgkA/bzrJdVdJaWNVVNG
+uPF0u7BliZkKvASOVYHsVOAccVKuNes+oW8NxeB0O5JJJw7p7YUAAewFdoJrl5fDluUnGOBI
+CQ3GMf6VtjnxZhkjyR5LqVpcW0k2m3UeyeJ8bSPxf4lqHAzRZIQBFOHRvI+tep9WWC9RxpHc
+aL8hfxRBI54WJR0XgZVuc4xWQ1uws9OspLHUNGmiudsZs9RVtgJGfESRcYfdxtOQR75rvxzU
+tHBlg1tGclKCTLSK6sMLgc/apVn1De2EttNb3d3BcWYZYbiGTbIisORnzGCRz5GoElrNHaLe
+nwzE8jRlQ43owx+Je4zniuMUgVxuG4e3c1rRz8r0anTNK0m9t0Y6lBISu7YufGj/AO9cotE0
+1bxgboTIuSR2JFVMlvLPL87pFuUQAArExJVgOTjuM9601nrQ1Hp82Wt6LCwR1cajawYuInUE
+AS+ZBHnxnA7ms3F9pmsZJupInWllYvBb747kWkjFUcvliR3AJ8x6VYWjae83yutm7aNBhZYo
+wZUHqR51iRqGqWMc8en3bS2tzgzQH6hkdjg9j7jmuln1J1DZ7dXkQ3Nuj+Fvdc7W/lJHY47Z
+71k8TZosqj4PY9J6j1i7U2r9SyazYs4Pg6gqydhtV8nlGAGAc+laGDUNMuhPYa/oY1UIqpBe
+mUpPCBzt8RCu9fZs15z0b1dbi6i1SCIG6aULLbTpsjMZ/wAQ7g49K2F9qui3Woz6rpNzc6XH
+Od0tvcR7ohMTghdvBjI5DcY7Vx5IuNnXCUZF1aS690tdmawiuAgQpHJbOWzGVwVOe4KnHc09
+NcilAW6tfF3SLuMjFGCdu+Mg9hzkVyns9ZsLdTdvcaYswzDcRZkt5uM/Swyp47jvWfjuNUW4
+adr5GdDtMDREpKpHcEjB+3es4SrRc43tCa1psVtfzT6dJLYu7fXGOCyn1A+k1BturrLSnhDT
+mK9tCNs1rKcn7qeDkHBHY1O+W/aLDdpZjOAiywBgv5kniuGo9DiO68e1ig1KIJ/f25CyoduS
+sqEZT78g1rGKTM22dW1zRtTgaVRdO7nLx7QpUnuUBPOKiT6xYoVWz8d32mNzPGAeeMDnFVja
+LNbSPaz3SRCNS8SIpbdx5Hz+9RHkbCp4QLKO/b9afFCt+SxudQKYSW/MZiH4JFBx+QFU17rs
+6ARwYRGP4m5ZvfHYCi6EiN4i7JRt5LMCf6VQ3DMssm9j9QOAe35UUhWW15qzi3OB+9K4BAxz
+Wbkv7yVhKsriRT2VuTU+JreSNYluEVzyUYkE/nXREdco0RHPIA5H2pr4h2Onklv9NtLRFMc0
+TShSVxu34OP1H9a4RdJ67BJ4srW43/SyvICpX0+nNWEkKwkRpOroeVYAjI+x7VW3l6+8xxO6
+7ePpbGaam/BSimSItE1HTL9pbGe0ARvoS4kQ7l91PBHepttpKoWuby6trTc2FCsGBJ81A8ve
+qVZhJIpuWD8fiIzirKySJoygWBssPqBzx/pUylY0iUYmMTW8iK7AkGTjaceea4R21pE30S5k
+J4IX6c1AZzHPIINwy3ZjxUqJiw2uoyAPbFQykXummG3uFPBncgNsyVUep9/8q28+ldQ2usaf
+ca9YXUdtfo7RzFcwSHY2JY5B9LK23nByD3FefaaGkMU0UmWY4ceg9a9N6e6i1CDp+5022ulu
+bSWBfDs58vAjLLuICH8JILgsuD2pJp6ZdNK0YKW6SaeS4CsFnBSVe6yxnjOPUHzrrpjWsMT2
+2oRNNbo4Xcp2uAeMqfXt34q06w16xvr4HSrO4itEYrJZ36xs0Uh5cIyAfR2we+O4yKpxdR3t
+pcQRQsf3bs4zkx4XOc+gxmlQJ+Sa8SQXccdlM72YP0Njlk7FSP4WHp2rOwa6puW0i4sVtg85
+USMSCwB43A8Z7cipmk6hcRywQ3K7lYhopwfxKf4W+361o9F1u1N1Edc0s39jFdMkxthGtw9v
+2ZULKfrUHcp7nbjzqofsiX2UFlNBFdXFneo0gTElvNG4DphvqRh2JAzipt30k9z4t7p17Hct
+lJPDP0zbG/jA7FfcVb6xp2qdJRrf6fPBqOnXDrF849iI/GDqWilTdztkQH3V1dT2qZ0ldteQ
+Qx2M6rd2++NFbAJiJ3FRn8XngVnNtM3gk0d9N17qFYLO2lTxY7dBbwvKoYGEkrJBIp/vIzvz
+g8jPFcYy/wCztUsH2RzabaXUCvy20B1XYfMqM1Nv0mgktRPBNZNcvFJHK6EJuckD7biMemTz
+Wz6r+EWuWXT2vdX9NyQ9S6GIxZ3k2lEyz6ZfS7N0N/AcS2+COH2tG5wQ1Rj5N6KnSWzyjSo0
+sLWK0nvcCNjKroCwjPkCPMA45HkaudUgm1W1GpAQC5yngyyD6cjsniDkA+R8jjNZRtRe4IjY
+GOW1d/o24PoykflV10xrb6VPNI0yC3ePhJbY3MEqscOkiDlVxn6xyveqcd2Zp6osrY6lr9vJ
+o1zCI9T3AvZT4S4mZf44/JnB7gdwciuWiaxfaVrkLy2kFwlwvgS2lzCTBcD8OJE78kYOOx5q
+Zquj6Rqtmgiu44rYAzWEwnaWSA5/AZPxHYeQx524z2p19banc2scupyDULjcAk8KlC7ebED6
+g3GffuKXH6By+xZdBj1hbuXR4zay6e5iuNOnLSC0iLYUq55KbiVO7lcjNU2paRfaAYX1Y/MW
+s0XhxumCEOeYz6Edx5Edq0lh1xfNq8GpXmoRauI4DbszIE8a3IIeKUADdnIOTzuAPeocevlF
+uNNhtoUspm8K5tp8vMGxwxducEeQxgj3q6VC5NmevtA091tWh3wRSFreSeYbwtyQWjTCnJVg
+AN2OCap7+C7l02DS3HhSQO00+O4LfhRvYAf1qw1OxhW+W4gu99mE8WaCdCrLg90f/iZI7dxX
+HqeC90yf5rVgolvIknWa1lWWGRCONrLxkeanBHmKqr6Fddmbe6v9JZJoncIzeG+GPHuK1/SW
+tTf7zDcyrcROmz6uCcjIP9MVhNT1bELxm3TG0PHKGOT9/WoWh65PBcblY7lGUOTj7Grlh5Il
+ZKZ67FFaPcQvqt9LZWC+Mq3htjKGk2Eon0+rDBPkOaxd9qz6VqE1hPIjodrkqdysQe4PpVho
+et3d7HdWsOoxQwSRiZkueYWdQSO/Zu4z51gtWnS6d5wVAU7cBjgD2pRx1pic7dmhXXkVpLqy
+ZkUfgOcFT/2q20brHUNPnV5Xhu4bpNsiTDIkGRxnuGB5BHINebR3ohfAbK+hrUdKWb9T6tpX
+TtkpE+ozfKw5BObhz9AAHqcL+dXLC0SsiPaOkjN1HaSajYXKpqVhFcXSLGn76ZYgDPsUD6nW
+JzIT5qrelehm5Gt6Vo2qNrLQSdT6dIty14pjiOu2YVB4UxyHS5t/CY5xzyO1fPfSupavol7F
+MbuXS9S069kjmjDFJ7SVMqwYHlWB4IPcZB4Nem6TqcepdMQaZdxx3RimLtZiQrHEDnLRAnCH
+JHYYwQPKsJx4G0JcjYdI9Q2dpNrVlcJdLFNp6GaUW+5reVZFdo2UEh42ddpJ7q/lmqLXoNF0
+LVJLWTx7exvtKghEMy71jhaTajK3mFVlwe4K89q4aRPqHTM9lqSpDPDGWC7xgXUGVEltMPJw
+OVz3xxVv1dpCSaDe9OxlRapfSnSX3hpEtTKzoD7DCAj2NRHa2XLTsy2uWqRdOXtvPNJMYpLW
+58QPlJSgYJgf8rNk+tYqS7OnWbNFM7RqyzQKR5g9iPatJDNe6ppVxosFo0MiyhDK2SpkC7go
+J4GecCvN7/WZ5NZmgZ1WFneNVcZUEfSA3oD/AE71eOLbojJJLZcJrx+VlWe1RJZ4mmgmXOGC
+sdyn0rn0/wBRbms7Tw8TKz+JKO7qeQD9uRWTv75rcWluqmB7MPFKhk3ksTlj9s+VRtN1URXD
+GMHKHINavGZ8/s9G6j1Ge8t7aBDiOJXjUL2Klt39Of1qh+dkSdUDBESPwlAP9TXE3V2mlHWb
+ybat1N4FlETnxAo/eyeyrwPcnHlUC7u1kTZasOQCzMAM854qeNdhyLy1mad7e0K4RXZhjtnH
+er2awnngttTIRo9NnRXBbnxpMmNj7fSayVjeC1kjfkhBgZ9cVc2Vx+0LSdTIQROsjjPBABx/
+U/1qZaLjZO1XUI40e4DAqME88bz51O1qaPR+j9Ehv7gm51K/e/Fmww0VokZCzsfLe5IUeik1
+T3JEVyFAQABfobt9PY1mtT1K+1e5mvLmR555WCbiew7Kv2A4AHahbB6Roba2XVdIsruR3AvD
+czfScbfDbBP6GrLU7VYbiwild5ZPk4lZickAD6E/JMZpLJbaz0u3tZ42BSFLWyJO36DkzMV/
+xOe58hUC8vxbXrvcEi4jYq3OQcccflQwiiD1nqCyvBaWf9zGgVPVifxN+oqo03ThOj4Ysoj5
+8sktz/TNS7qKWeZ57cDAQuPY5rvDG0VtAF+qS4JwMc4HemnSpEtXK2dpYzMJZDyuC6k+W0cD
+9K8x16Xx74qndjj2r1fqJP2VZ21vB9c1+yCNB/KV5x9u5ryXXZYpNQaC3JbwSY8j+Js8mur0
+0W3Zy+qkoxpFWFJYKB9WcYqxFwbCGS1iMbSyNtedTn6R/Cp/zNQsrEu0H62yG4/CPSm8EYHI
+Xsa7ZKzhxui5s7lViWIdkOfc1PttrPv/AA7gRVBaNltoBOavdPt5jPGr8ALuPuPSuPNGj0cU
+uSLeKUNEsqv6qD61Kt1mdcohIPc47VZazHp97a2CaVaLGLeCOF2UEF2zkk/bOK7aYTatKmxW
+heMo4YZDDOK42lZ1XQaTZyS3Edm6hZLgAxHP4SMkE/kDUXVoobu/mv7UOYJmMqnH1BRjy/Kt
+f0taaUNW0vVdTMklnPFKJhHwVeFirBW8jskVh9qyF14UVtp9rcTC3NzKbeScjIiUNt3kf1NN
+RFKSGda3lrY9Vanf6aghguJjLAFOBFC6jgH8yKxUl84cohDxsvKoc4UVoviP4c9y0mnypttm
+SzkjRtwyqgK4bzDdxWIthNHNuQYZQVbPuMGu3HiVWziyZmnSJxKSW7SI/wBcLZYeqHzq50zS
+rafRdQ1a81BLQW8aPao4z8224B40/wAQU7vTissZGt5MxkkYwcjgiub3Mz7Q0jFYxhATwo9q
+29qzJ52jUWupSKnhB90Z7Z7gVKTUVjJgBAWQ5VvPPpWWtr7wlCk855+1SF1OMEb1yoOeO9YS
+wOzaOdPyaq2vpzOAjuNnJCnAq5tPlLv5c6nMhthvE53kPEGxl09WXG7Hn2rIJq0FxGRCWRyd
+zDHBrrEk7lpSWdD9S7TwP/oaycOJqpWbDqfovU+mdam0SaeG9aNI7iC5gbdFdW0iB4poj5qy
+EHHcHIPIqoETCyd3gLhSQ0bA/UPQ+dd9IuBJbxwO7wSW6/unDEgDuV+2at0n+YjksptqOw3r
+JjBGP8walOmNqzH6r0XZrpsGsaDfGVrkmOTT5hi5tGHJJ8niI/C45zkEA98XdQTWsz286FHQ
+4INelarp1383GpheNgoUEccfzA+ea5a901da8nyTus2r28Qa0ZUw11GO8Jx3cD6lPnyPSurF
+m3Ujky4LVo81oqXFp8zytE6lCoJOR6VGZCO4xziupST6OJxaVsaCakW7uDkE4BGTmuGD3qVa
+TSIktshXZcbQ2Rk8HIx6UpdFQ0zW9N/NLcXFjE4MOqxC2kRj9LEkFCfcMBg0+ZCYzuzvGd2e
++VODUWDNrCJAxVlYNuz2NSJp/nAJRKMkZJz+Jj359fOvPltnqRZxicyHKj6R34qTOFSFokGS
+Rn86i20c1lcbJYiDjdz6eVJN4okaUOzL6AVm47Lsi3txIFVVbj2qAjhmJwCV5x61Ju4ztBHB
+kyR9hUWFc4bI44PvXTFJRIb2Wl1aw2YilimDx3MCSBh3BPdT7g1XzXJmwO7DhTUpyz2m3P8A
+d/hHoDVXJmF9uefP2ogrZnkdFhezK2l2rRJHtheSN3H4mY/Vz7VVeMTz38qexPy5QE7WYNiu
+YkgTIKFs9/vW0VoxlJo5iVlOe1So70/cdiPWoEjb2Lf0poJ8q04JmCyuLLv9wyhoCWUDLAjl
+T/2qREiSKkZkCNnIJ7VT2dxPC4MLAHOefP2q5WSFJJJFjDRXMWAPON/X8q55x4nXinzVjZI1
+ikLKm7J+oeVSUkZIi4xhz+AdxjzpoMU0IjOUkHn3BpuGcbduJIxyB5isXs6FonW5Ei5ByP4h
+Tdah8SAggEp/dyj09DUSObwnDLkDtVzF4V1pdxdQLtmtHjMiHlJY2ON3sQe496hJxdhLowk5
+5zjnsaLa7uLWQyW07xMylGKsRlT3B9QfSrTW7SKR2vbSHZG34kByFb/tVQIyewr0YSUonl5I
+yjMnRXtpN4aX9mCqfxwna2P8quRpmjvG8vT99cOGTMiXEa7gvnjHmKzKqRkkZ4qXZXktq/iR
+SeG4GAT5ipkn/iXja/yOzW8tiwmhuRux5HBx6VOsdcniV4HlVVcDaSPwkelc9Tg1O1EM2o2j
+xfMxCWJiu0SxHgOh7MO4yPMVUyhlYj05BqFHnqRq2sfRqWvRdQJciMK6jDsB9Le/tV3oV1Ir
+ozwh0iIY+g/Osjo09zZBLnw1nh35eLduDDzDDyFW6TGMnUNCctbjPiWrHLxeq+49DWMsdaRr
+DJezZavpen3UsOq6bM1rOsgcAHBVgeVP+E+RrQaddyCHUOlr24R7HXYlW5tZeY5WU5jnj/kl
+Rs4YepByDWQstYg1GzWYlFRFAkVmwcds8+ea2dtb6fqnT0GoiMGTT5BHLMhwYh/DID6eRB4r
+nbcTZJS7I2kaJfvoT6RNCZ/AuHMF1EPrDr657Nt5x2Iq50XqS/6W1DT+pLG0jh1DS5AZYxwk
+yn6ZCB5K6Eh0PGGNTTo46k+T0jTHiTWZPDghZH2RXkpOI0Y5xvJOFf1ODxVbe2d7a38mlajY
+3VnqttM1neWd5EYpre4Q4khkVuVcHyPsfOok32XFVo3dxc9NW+q3x6buxNpuoWhhVJ4Ss9tB
+KQ/hvjjdG4wGHBAB86yXVnQN5a6fc9UaBcw2+r6Ksd1dw5/99tHO35hQODtOFfyIIPrUf5qb
+S7OK8EHiJZ5MuFPiRQ55BA/Env8Aw/ar3QOoLiRLfp69MTJBKzabenB8AS9on/mhckA545qY
+vZTjo8qgv49Y0646Z+VKCO8+fs0RSTbFlxMqDzRgASvkVBFa/otOqNF11On9H1s2ep3tr850
+7fQP+5vXA3LGrHjEih0wf4vpNd5tGsYNXj1jTgtq2n3SLLA5w9qxfa8LeoUnKt5qaj6za2tj
+fT6bpcrxvpN419YqcxvAGYPJEuezK43L5Vo+iFSL/pXq6XV7W607VrdbVLxPlrgRRhFV1Ysp
+ZB2wxI48jXdtbjsriG/nVgqZs7pF80x5g9ypwwHsaqbi/Goavd38bxifUs3BLIFDyd2BA4BY
+Z7edSNP1LT/2rIutQGSyvofDu0I3Mmf41PfcpAIP5edZVZqiDd32qaVeNfQbrFklDggf3UhU
+5XB7pIjZAPdWI8qhWc8rRXOoW6mB9P2/MRRcEQk43geinHbyIq06xsrC806WU3rteaTZCSC8
+hkBt9RtVb6Q0Z+pHGW/5eR2IrNabq00FxHMsiDxF8ISf/CcYZGHmMVcoPsmM7dHsNlNLqCxT
+WrrHLdwNdCM8xzSKmJgB6OmSR681W6nZw29zEVtxHb/JRXEMe7EluQxCsp/EMEDDD7VW6Zrq
+CCysJlMEtm2Le4gbIGOx+/kfUGufU+uS6TqWn61NHBf6fNBJZXdrDJ+8ELk+JtHdHRsOF7fl
+U49Oh5EmrRB6q1O5vzFfTR/74VKXUnYy4P0uR/N3z696zU2oeHEs8ZKvNvDg9jkEVdmW3vhd
+6e10s1xaRiWOUHiWHHBx69s1g7y+G/aVwpJDHyHoari2Z8jR9L9Q3ukdJatcnUYY5YJoreS0
+llw1zC5IwE7OAw59Ac1rvhnrXyWqm0tZBHb6lLCts8xARWb6QjMeAQSMZ71iLazsotIuZNTu
+LW4t7+1dba5tZBI9tdKcqssfcAjPPvVh0dcXdv4V3owLb0a3utPm+uC6jbhlGfwnzB9QORT4
+0OMt0z0Hrbp64ttYvEeD5d4ZJWntCfwSA7ZtvoQRuxXzprmkDULu9MpAeJ2aNh/EO/Br6a1P
+qufX7sazqcC3bwWMNvfQPiKeZY18MS88NJs2q5/i2gnnmvEeuNFtNGeWTTL43lhMcwSMhSTb
+5K6+TDsfI4zXRjfHSObKr2ePTlpCzSqTIPxEd8etSZNHhfTF1K21mxnYttktixSZeeDtIwfy
+NLrKokivBkBsgnz+1VRRihcKSoOCfQ12w2rOGap0XQss24uLSRWnjI8QH0Pn71Z6vZ6fp0On
+31gZ9t3CReQyYYQzg87GH4kIwRnkHIqq1eCDSJIv2ddtdWt9ZxzKzrtZGI+pfurZGfMVEsr+
+WFhFLcS/LucOBzj3waHEIzLjdcsq3FmzSbAWIXJO0ck4HoK2vR96uo6pbQTmR47q3eI+Fywb
+blGGe/IrJ9NaxcaBrlnrOjXK2+pafMt1auQGjcg5wQeCD2KnggkVd3+tLB1gvU2laZb6bFez
+m6Flb8RQMxyyID2TJOB5A48q5Zo7MTs1lj8tqOnLDc6c63ayEggnEuO4x5OMeXcd66pZadZ+
+FcJKPDnIa3uHUFSDwYpPbPBNdenryG6E0EN34Mok+Ytn7NHKDnBq6nsLG/S7iu7SO3jvAXPh
+A+Gkx7kfy5Pl2rm9ynTOjhatHkHWvS5hv5JtOt/Dic5MAOfCPmPtWX/YF5/L/Q162vzHj/L3
+EKGWBdqyA/jA8j71z+Wh/wDC/pW8cziqRhLCpOzOQOgG92GPwnHetPYx2kMEUscE87LGJZ8M
+PoX/ALdqwkVwz3KDOFJ7e1aPQNUl0/U473hlicHYx+lx6H2PIP3rikqPQgy3n1kTSvmTZESC
+wWFV2jyAKjkVOs1tSnhS3RSOSMOj4zg59vWo9zJYpp872ehW0jSSFjJ4sgeFCc7ducEehxWe
+S+ME5AhBUD6kblWU0rKao3GLpVcfMb5IVYguM7tvOOezcgj1qMp/aNhc2sysl5dgCxunYNFF
+JjDRyxNwUcHG7uhwRUbSNYkvootMvZk228fhxyun7yOMZOxsfjAzwTzjirq30pVSRL+Szkjc
+jwGdiBIvmEbtu/wnvWkMjg9ESxqaPEupNJu7W9kQRlZYUENzERgoVGDn1586zZVlbGCDXv2v
+dEy64qEKEuFcW8UwGTNFj8LHzKnABPlxWK1Xo97a1u9MWyMNzCjOdykyPtPKgHkD27124/UX
+o4MvpqdoyOiTWESTS3ouDIFARoWwF92xyam2V/LZXi3Wiak8G5SrBlysgPdWX+IexrOywTwM
+dwZM+fqK62plR0ZXGHbaCT5+9bteUcybTpno8/SvTnU1jZ3HT2p3OmatIfDvLS6Ae1kbH95D
+KSGQHzjcHB5DY4p2hdF3PTmpBtQ17TkjKspSYBopcd0bkjHof0rKXGmX1s4uVuBPCVU3EZBW
+S3J/mX+XPZhkGtR0pqup24n06bRYtY0+4UGSAsDIv8rxNnhh5jsaynI3hGzUfsC2vne60O40
+bW7dQPFs4rzZcwNnkROMHHbAOfSplnf6TpYudP1Pp3qcLkqIrmePCIR23FPqGcHNcF062iMD
+ahpF2MRhkkhwhXPlxycdiCaurNNXNoLWDqm+dAzSRWk37xQ2OwB5HFcTdHWlYyz1SG0iSBLK
+7tLNJd8sNy+9GJ/jXZlQceeKnzXw8PdvZNOm/dnw7hbjZHnKtIi8++VHn2qGdRhs42t+otPv
+YbaUYL2hEbK3kyE/ST6q3f2qOZ7K0uJdOMNhew3EQa3v5LYx3EBPIy6kFHGPdaik9l2xkMDQ
+kp40H0kqFd96EeRVh5UlxdapapvSO3QDs6s28/Y96qNavGsrJ5mszfwoeJYpBvR/5Wx6+XFQ
+rxdOhgurnTdYs70QR2s00sE7B4zKpJj8KbDNsOVcrkA4xkGmoye0S5R8hqnUV7NuMtosq8gq
+rkOB7ZrLXmpQrKVjSVY2H8Z+oGi/1WGaYus0aS427c4H3ANUt/qI8PwnkBC5JPcn2rWEG3sz
+lJIky6vGEbcpWQZGB2NQmvJSQHYHIzVbLcNLg+GPqP51ykuS0rsTgHjA8sV0rEYPKky1iuYk
+kDSAncO/kKsLHU9sqoLncg7kis0077t/ke2PSptv8lIUCXPgSYwVkBwT9xSlitFLKro1Vxc+
+KxZQdzcgkYqtkhzL4hDZI4A4q1tRqhs1XU5VS0iXCzkj6x5KueT+lQZ7iGfd4WwLnC7P4R71
+zuPE2jKxFYpGEhhQjHJYedcnuvDlITaOPIetOEm4YQcJjGfPyqJcxSRzHcvLKMe/FJKykydD
+dmQiKQtwRhhU6B4numiZ+JBgL/lVHDJzsIIJFWdg0cgWSRdj25+kj+L2NTKPkpMsoZWs4kmt
+mKFJMNk+X/atB03ry6bfGAIssV40cAY/8Pe6gsPTvzWLvL1o2K/wyAqwp9nJIS1uWwSNyn7c
+is3HVlqRtOq9OOkavfadNMJ3srma3aQDG8o5AP6VVaZELwzpaeLE00TxOGcFCSOAPSpur6k+
+rSy6rOuWuHMj4/nPeqWzd0N8LeMtbqIhIcfSjs30ZPlkggUo7HL9C2hulG+A4aE7Hhx3wO/P
+mOauUh+c+WktonaZI3DxIOWYjO8Ae3f7Zqz+Ztf9mZ7W3lSWUa1DfbDhWeI2RWQAnzDt28yK
+pbK+ltJENnKyGOVZIX7MjZ4/LFO6ZKVosrXqe7v+k10K/mkubWK78aAuSHgYA5Rc/wAOWZse
+pPrSaXLbR3YgtCyscvHOxIYY5GR2yMcEVEtbgNNcQzRqVZGJHmsm7IYf9vemWSlb2aRmCtO3
+7oeQHpUy27Ljo3cmo6jq1kLO51H9/KHltg5BiE6/UMDsM8gjscmps/Udpd6dJ1JYSXWl6pca
+U2lzGKXw3ScOGR0dcNtATs2eax63E3gxwmTYVYMq+WahXbzePNAqLN80d6tGcjJ5z96mK3aH
+KSqmdNS6mbWL75rqiL58lTFLNb7ILoH+Fy4G1ye53Dn1psGlzTLLL03qO/5aJGl8Jik4DcZK
+fxejbcjzrJ387WM1zHI2JexB96E1L5G/SNLqRfl0XxDHJtY5AJAPlW/BtGPI0mk9U6tok8Nk
+8yqqSeEYpYxheex48z5+9bi71NdKj0/VrHUDNHNHIfBlHhy2r78+Ex7SKO6sMYBxXluqo2tX
+Mdza6v4sbsEeS7UrNb+ni4yCP8Q8q4TftuyujDcyJOiEAvFJ4kZHYFTS4PsSkbGy1ywXqItd
+Rs0TEh1XA3RuMHBPBYehqTPJC2qztJOZx4myOdgVMqj8LOCTg9s81m7Jop7aWS6X6gQI9vOC
+O+fuP8qmpK8ghjWFpFZdmCcZHrSZUSf1RFcy6bYtdLI8st5PZW7Qg7Y/CVW5A/hO/wAvSsMd
+T/Z4Md4zyr4jpPasCFxjG9T2zW+up1ksLWKzu5Yrq0uzKv1YyjIqtg+eNtVOrafaXttPDOIh
+DeShWmcYNrcY+l+OQr/hPlyD5VpCa6ZMoPs89vZrZ7IrazO4jkKxl1wSp5A9ODUKxvksLtJJ
+YjImD9OcYPr+tWt3YC0jldHlNs+wor44l7NjHHBH6VVX9svhiYBmeU4T0Arqi4vRzytKyZNr
+RVPCt8qs2Tt/lqilvZdzgn6W4K1N02CO5120tLiQR27XEUcrscBULDcSfLioGpRxR39wts4a
+ETOI2HmoY4NbQgjlyTdaGxy5J3AnK4GK0PRd/wCD1FpvzF14MJdo2lHBjVlI3gjswzkEcg4N
+UGnLuvbcN+HxBu/5fP8Apmp0y/srU5WTw5ltpvpwcqyHlSPyq5RROOb7PXNFvT8RzqupXN0I
+OuNLt/El8TAj6gSIDLf/AO34YdmJP73bn8Wc31lexW8J1C3lXw7y0Csh7o5IwR/hI7141aPL
+Bfm7AKpdQxzgg8qu7G78iK9JXVEuNGiku4NtxGTGrJwd4wdw9UK849a87Ovkelglpm56H6l+
+RsLhNQhS5t5J4PFSZd5KKMlVz5gHPrxW06jsJbWF5TNEGtnEoiVuRHlgWZe65LBsehzXmOjS
+w3lnc6ZHCbiac2erSSqCHgRSYbuLAOCu1o33d69Asb24m0w6jHaxXN/AyPGZ3G2YQOsbo2e4
+ZcriufjTN27RRaxZ3snS131BZlvD0/T43khVOElf934qgcH6sAnuCRXgPWl0wliAhdPGLSyE
+92Jr6pvbqPSjrl3OF/Zl3E0NoY41QyxSRgtnbwVU8ZPYrmvnD4iWa6nqX+4KrJbBYyMEEAAE
+lgeRnO4HzBzXTjioytnPkk2qMfPqL3FtFczRk3EZ8Jz/ADgDgn3xx+VOsJdxW4mSTwV+u4Mf
+H0Zx37AnsDUnTLXTry+Wzv7l4LOQsklwq58NiOHI7lQcZxzjNWXVCXvw51GPo/Ura1ufCeK4
+1C3STdHKmPoQuO+Vbf7Fh5iupRU+jkeTh2Rdc6iF1cvPLCkPhqsUFvEf3dvEOyL/AJk+ZJJq
+FBq0UhV34wNoyazd1LG9xI0DSGMsSnifix5Z98UkUrLj2oeBUKPqNm8tLppot6nIP9DVrFqy
+2MccaON7kSMvv5VirDVGgCnvnIAqX8w6jMrZaXkNXJLFTO2OSzQyanNcXAMrlscFs11FxDbx
+QPAqBgSWHlx2rMG/wSiMTgd/erKBzvhgXgqm6RieBmp4UVys11pPPexiWaQySnPB8lH+VQmI
+ub4Ru30RnHFLa3cViskbYfYu3Oe+fSuUNwpuI5SF/FkrnyrOjRSosRCBC2UAWQlcew86csyL
+IIIwCQmGb0J5IH2Fc9RvVcRww4Dt6ehNRTK5hWfPLSFfc8gVUI+TOcvCOWsaxLFbW947EtbB
+0jb+UsME5+1eWzsolLIzZJyT6Gtv19dixtV0tXCyGTdtH8oFYJm+rn869HAvjZ53qZW6ELc0
+9W7Y7+dc66Qxs77UUk+1bPo5496NB0tLBHdvFdW8cizjwzvHIHqvoa037PkWZlCnbHEBGe27
+J4rO6HZu17E6AFh5Gt1DFLeRC1jO6RnURDzLeQrz80k2erhTUTtaJNFplq5RfpVgfU5PnXeZ
+EjeO3Vh4ZABPv/8AXTrxbjT9Pt12lJYyrjPmPSuU7pI4nAPhFA7jH4VNciOkvemra5CQ6S0a
+zPeX0aW8KNjJJ/eNnyJUAZrD9S3EF7JcTRho1aeSSMMc92ORV/BeXGnXbatbTPFJp8LywMDh
+vFK7YwPfLZ/Ks/q9xFexxNcRIkkMKxySpx4xx+Ijyb1I710QWrMJ6MgyOrM8ZKhx9QPZh9q7
+rbaRchY5biW0M3HbcN3/AGNOu7a5W5CsuVZdyY7baZ4UbQz2kygbyJIZcfgfzH2I/rXTFmEq
+aKZrW5AuUaEsLbiT/DzjP2qIxUH1q2myu+9dvpaPgZ5Zu3P6Zqnb3rpi7OKfxOsRUuNwBHof
+OuquVypWJc5ySuajxAM4VnCA/wAR8qUkEDAxxz7mnRHIttKgvI0GofLSGz3+E85QmMH0J8jz
+51ptN1m3sdQEYgiSM4WWCZCUdT35HIz5EdqzPTuv6t03dG+0jUJLV5R4T4IKOh/EroeHUjyI
+qamoQXOWaNHljY5+nCOuc4A8selc2aNnXhk9I3lxZRNbyatpO6ezgX9/GQGktee74/Ehzw+P
+vg12sriAwEyp4sTrmN1ALRH29QfSqnpnUbOG8jvtO1GexvFXO1l8SKQHhkYeakdwa0mn6bas
+kkZWNFuMgeA2Y1J5yB3Az/SuGXxO6OyNa6xbRqBexvIqNtSQNwnoAp7U2USRzm4s3eKZf7uU
+nDKV5BVh+Fveo+rWvyjQWt1CyXRy20A4kGe49cVa2lrbXMSY1WGWZVEjqI2QgA8hs8Ej1FOM
+tClEx+o6eCst0qKwk3byEwQ3r+dYrV7BrVY59pCT5IB9RXr99J4O4QQozMPq3Dg/4SPtWJ6p
+09L63R7IqFgJ3QN+JM/ynzFbYclS2Y5sXKOjCKSDxwacWJbO0Kw9OK7NbMuQQfYU+CxllYgs
+qAd2Y13cl2cHty6Li0nj1HTGtJXK3A5Vj2bHrUrSUSCJ98e9SNrxse59ar4YVh2eCCzKfPzq
+1s43mlJVDxyw9K45v6PQhGlvsllIxCHAaSJcLg91HvXdLeN4mCYXfgEnzFQsTWrxTo7ZcHge
+R9DUqOUXAwQEYAkqvr5VkyyNqOnsPDZo8bEJAH3qgSM7yM43N9KjyrUPewpGxuGYqB+IVUXN
+ws7JLFEsUQ7DzPuT61pFtEPZEMqpDtEZUrkFi3eqwsrSfXkDuTU67IclQDt9PeocqeEpYjkj
+gelbY0ZZG2iLNMzsWPGew9K5En1oY5OScmkrpSOBuwpVAzz2oA5p7BVxtbJIyfvTEdoNucHI
+9DVjG+ABjIHl6VVwg98j7VZ2DRSTqtwGKdvpODXPkXk78D0SomWQA918iO4rs+CVmUkspw3G
+K5TReDIyROSpOFYjBI96QXBJLP3UYf3rmr6OlDjIIZ1lTblTna3Y+1WttFC1vc3NhOVHhnxL
+djzt75HqP61TPKJFCsoynY4qRB+5kDq29CORSa0UQpZs7wvAfhh5Go1pZG6maKMHxACQPWp8
+9pE0paP6HPDIf8wa66ZayrfYPEiDC58zWsZKK0Yyhy7K2w02W6kuifp+WjLOD98VXOG3kEcg
+1sbiSGKd5Y4+ZT+9PkT2NUj2cTSNGF3MzfSB6VcMtt2YTwaVEafU9Qu7G2sLm+mlgsAwtYpH
+LLCGOWCZ7AnnA86ZbTIS0Uy5Drt3elWqdPzy4ihXfLgsI/4iPOok2g6ito1/BZTtGknhy4jP
+7tvQ+lXGcZEThKCK5ZZbd90MhVlP4lODUqz1K6guVuomxPnk/wA33prrvdY7+JoXPAfbtz9x
+UoaLO5MSKC4yykHlh7euKttVsiPK9G5gg0rUoE1HR1ki+Zjxd2U2PxNwxjYcEZ5xwaOlOptX
+6J1UNbTPHG7GOWKZN8Mg7FHQ8MpHcVQ9NT6pp9z4IhkkWZwJYvPPlIno48/Iitr1TAupWkMy
+xIYSPDlITbJFIPJx6qfXyPpXFkq9Hbjutmskk0bWbdktNPWC1uQFmtcktbN3+hvND3XzXtUv
+X77qjq/Uxc9R9QftfUobW3tVnvZFS6mhhXZCWkOPEdVAXcxJIABPArG9K6u0d6ljc3cLsF8N
+gRgexz7167Do+ka/pMFnqlnHKWSQRyoxD4HcH0dD2PYiuKbcdM7IJS2iPo9nFqGhfL3260vo
+9yzXEibjblfwzgrngZ2sOxViDXnF4moaFc3eg6rbGz1HTnxGmfoKnDLsYd0IIK+xFb3orpL4
+hW3W+ndP9FvDF1jAJb3SPFuERNa8JS3gQh/3csske5Vib8Zyh5Irj1u3TPxCtYbvTrWHR9ah
+nlT5cxyR26RjJ+X+okx7X3qEb8ByvbGHTVOtDddeRumdRdKdR3NhrOtWk9vraj5W+W1mEaav
+b7cbTn6EnXkq54fG1sHmoXxF0uODqLTjZ6jb3Kahp/jQ3a5VZyjFFLofqhkwNro2cEZ5BBrz
+i3W4sr2S1jljErnmCU8Mvoff3HnWqXXIIvAi17THu4FJD5bbcJnGGRv51xxnhhwa6K5Kjmen
+Zq7jp39raJc9T6Tq1pLfaTHBcXeiOuy5kscbXvLZs7ZfDfCyxfjCkOAVBxl7qeO01Fpc7onT
+6GHG5SKpbzqdIoo0jhS6jgY7GmGG4zjt2PPlRrus6XLpdpPZTNIksRL28n97byjuARwynuD3
+9ajjbopS8l/pHUos7qw8O0jnkS7ChZF3qYpAUkjK+YIOR9qzcnTt+LXXLfTYg8mglpZbcuVu
+EgV8OwQ8uEBG7HIHPaqK31d5myZCrFcgg4wR6HyNXb9WaxqOuN1zPLIl4ZVd761QZWVVC/Wv
+bJA5zw2T61tGDqiJTLjp24W+1XTNK+e8N7q5S0lVj+BnxscHsQcitFeWFhqlq+l6hqBsrozS
+wG42ErbXkZIjeQd9j7ShI7ZBrzzqu40GWca30mklqLyNbiexKkCwuUYE+Cx7wsfqUd15U9hT
+f9vZ7zUJ9Q1AeLLdEysSOCx5bP3PNHtJMTy2hxvb20ddQRzDPBG0ThTnIPBH271WXN2qW7w3
+OIxJyrA/V6jPrTNS6iF1NsiVY7dt2zj6lDclSfMZ7ZrLXV6cYf6tpwDVcGyFI0cN8qovhvln
+Gw+WRWp0W6e1VLm3u/CO7Gwn6Sa8vt9UCnwyCR3FaTT9YJ00qRlFb9KUsbRSmerHVHuJYryZ
+XKIMOqNyue5HtWW64uZ7gC3X6k/vEx6//VVLpnUNxEVRZj4ecjd2Ht9qt9Q1a1uZ0meDYowS
+p5C+uKUU4hJ8kZWztbWeS40nUcJDqEOEkKgmKVeUcHuOeDjyNYyWO5tZLjT5ocMWG4eYK+Yr
+caraNNbrLbOT4Mn0yDyBPGar7+yk1uH5gW5j1CyUM4C/3iD+L3xW8MnHRz5MfJaMtcytLaWs
+JL5tgy7T5ZOePamLazeEjhQY5WIB9CPWvQrrp+x13SE1uKE20tqim6wPwIeA5H8ueN361SnR
+fkXaISK8F0u4KR9LEeYPkRVrNZHsUQHsbZreF1keK5Q55/C6+x9jU211ZblPkb4DxIiTFJj8
+WPI120/SJBPFa3l0ttbtIFEsoJjQtwCSOw9a7a50pHYXqzWVykkO/COpypYcEZ/yPmOalyT7
+NIxcejX2ny6PY3URVor6LBdT+CQeRrRWMtzNasd5CrlZPPOPOsjo2nMYvk92HUh9p7r7/bNb
+Gzu304RyiELLCds4IyG9MiuCa2dkGRZrSOWNjI2Bu4cdwfI1D/Z13/Ov/mrQ6tY2t7YTXGjo
+iK/72OIN/dP/ABIP8J7r+lZPN/8A/Yc//lNCTE2rMNYQ5mO49xhau7GJC7qxAWNSxb3HOKrr
+JS85lOOeFHoKtF2ICAMRplc55YnuTUTds6ILRaW11JA0dwJAA4XtzxnGCK5arpMeowXOoWex
+flow0kCsRIVzhmQfxBTgkdwDXfSoIJJRJg/SpBiPOc9sH8qhW+pXdneCaLasttK7bSM8Nwyn
+2qIs1ZE0vUJFiW9VsyxDwpcHG4H8LH/KtXoHV01urQXNtHJbyjbLA6b1JHmPTPqKohpiW0d1
+d2NkkxI2PET/AMM8kj3ziqiy1KazuQGtisUpOAQQOO4B88VdcujO+LPWbc6Rq5ii0+6k01J8
+KjG5bwlk/kk80B7BvI4rhOsPUZGla1dx6Xr2l+HDYapcMWimdD9MF+O6jacCcfw4ByMYymla
+tpUgSRZRZyyHw2DtlHwcjd7Z9a9Ba403rm+Z+rUk0nVZVCw6lp8CCIuBwskQ+l4yB2GD6VMc
+jh2U4qfR5Z8UOmdNtbsa1pGlTabbXjmO602V/F/Z90P7yJJf+JE344n7lTg8ivPJLSzt7We2
+mtpGfcHguYznb6o6+Y9xyDXvmrw9SaVbTaHGtvf22rYgQQwGaC6OfoKKw3RSryB6ZIrEdY9B
+6jHop1aPp6/02W3l8Jo7q2aISEDJXnG2QDnB4Ydu1duLNr9HDmwXtdmQ0Hqxo7f9l6jDnYuL
+a7QfvITnIDZ/Gnqp9avNKtunNYv431PGkqx2yTWoJjZ88ErngHnle3pWaXp3UZrc3zadd26K
+u53aFthGcZBxz9hU/QdT1Lp6Ca9Gh299ZBWt55ZrczW6+IMAHyR/NTwQe1aTSf4kRtakenRa
+LqGlSJaw6ncwQnki5fKS/wApSQcOuCPqHI7EVAW4+VdrfU9Na5XfkMspWQe6sD3qr6U+IF7p
+sAsmeK50xm4srlfHgb1xnlD/AIlINbSY9L3FpEJbiKS6kjE5ihZg1sTyFyw+oY9DXJI6INC2
+moXMgibTuoRdoxAWDU41+pgPwPu4z6E96Zq9uNVhM9tpghuIv3cyRApx5DaT2HI4qveO1QsF
+to5UYEZZ9wORwSD6etQp+oLeRGsnieG5iG1t0pbcR9/KszRlJqFrdWBZ5oZooS212H1AeYJx
+WT1m33XhdZhPG4yH88eme9aHVbyW5LSW2I7gDYyqSN4Hkw9ffzqge58VQ1xEY3GSBjBc1vC+
+zGRCn1WCxeaWzslWOQARJMRK0OPcjmqp9TuSxcPFk85Ma5Nd9UTfIZIwTuzkHyqrBAYErkDu
+PWu7Gk1Zw5pNOh0s80jmR5CWPnXPJ866boScGNghOcA8j2rs9mGspdRRgkaziFYict2Jz9hx
++tbI5WxltJGH3TOQi+QGSfYVf6XrujW7tmyezK8xyIBK5PuW/wBKzGcV3gtri6WVoU3+Ahkc
+DuFHc/lScUyo5JRNMmvtdSyTTXFtdFxtCXYPHoVZexqMbmNJv7sI3ZlDbh9waoIJI43zLCJV
+II2liPzyKsFggmeMaZOZS67mjkG1kI7jPZh6VjLEjpx5r7Li3n3AqxBT1x5Us8yTI8MSndHy
+p7k1WRzFVOMq6n8Jqz0Wa3gvI7m5UOiMCylsbvbNczhR2JjIAnh/V377ie1W+nWs04aGOEvO
+/MaL3fjsB5mq2W2S3YoG3MecHsRUvSJJfmY9kjpJGQyOpwVYcjFZS3tFnRtOEiyTPIGAAH2Y
+/wDpXe0tFSaAyEosnCyHkc8c1Y6lBbx6Amrvq1qbm+uHJs0JaXgYLNgbVHPYnJz2qLaCKWxj
+RnYjeUGPLj/vUNspOy4s9OuZob63ViLiGESRxAZBZW5/+XNRtO1S6ttA1PRFjXw7+/tb6SPb
+9Tm2STw1z3AzKSR7Cr7Tb6zZP3CFblIVjYHkyY74PqazlxfWFxrtsYrV4dm+SU+JkE9s+32p
+R/QMkXyxtHCsYKuqb3byycYH+dcUCM2+UEROCAy8nIOM4qU/yyhobq5EDyYKMykqSB248j61
+za0bakyFHSFAGaMggtUSLidHtpFkWWBhKCRiRQQDx5+9TzaxSWsQkJjnkkAjYY2DH41Y/wBR
+TunL2ysdTgvdQgW4t4oJZZLV1JS5mVSIomxyqliCSOwFdHvLaSN4tPjlggmCzPDMwciRQQQr
+Yzt5OD3xjPahdDfZX3l9PYWl1KbcPNG+SwBPhqoIP5cg/fFVug9TXdxbRxWsFsG0+SVhNJxK
+0T8lPsDuwe/1GpXV8V4mgRi1lSASQpHesGwZY1O5SR75GfUrWM6bu5NL1aDXFCSfLSpIsci5
+jkCsCVYeYIGCK6McE42zGbpmivupdKuraTS9Q/cNvVobhUDjbn8L55wPIiqSbSVnjurtNRha
+QMpRF58VD3IPt6Go3V8MD6lPNYQNFbzOZ4IiclI2OQPyzj8qpLKeaO4AjYnaCRiumOO42mcz
+ypSpmhnuJoICWLCfbs3rn6wPWoUOs3EIAmAkQnknPH6VJtNZtJnFneSGJAmTOBuKt5HHmM96
+oprl2MqPIp+rPC8HHpRDHemgnlS2jXabrlqtxC8lqZoyT4kYfYWX2PrWntp7HVRh7rjJCsq7
+Xj9MpXmNizyNsVTzwqryxPtXofQ2nJqUGpC7WYXFtYyzxKv0mcqORk/xLlW2/wAQz6Vjlx0a
+Yp2WGpXDWSWEAi/fIzruzkOpAwR+eaSGeW5iFu0iCSdfDKkfiwdy/mD2NQBfRMYGcmZSjkKx
+5R8DP2qzk8WO3iHheJCcMrbfqRsZ7jmuaqZ1J6KW80hZbGS33MXimaVCRwWGBj/OuMCBLlI7
+yyV7e5UtE23G0g4P6Vp7WNnZ4AytDL9Yw3Kv/iHcVIu9Hd9PF7bMHsy8gCMuAkq43pnyyCCK
+tS8GbjvRgNZ6c0K3V5Iri5jnDY3KA0YOfMef5VldatIbS4WNAVfb+8XyDex8we9b6+t41kmg
+uiluscmVEjcg+XHnXCXpKC+jbUbxbie3iY/M/LRfWinzQH8RB8vSuzFka7OTLj5WkYGwAkl2
+55VGI/SrLU9Pkg0vT9SmgdDfxHw8jh1Q43fl2rX670doehoq2nj3gkDSW93Ioj8ZBwQ0P4kO
+fLJqJd6fqmqdEOspWX5G9a4Up/BCVAKKPIA84rZ5E9nOsUloNFv7S76PspRBGt5otzPaznbk
+ywXADR5+zLIPbIrR6VrOiy6K+nySXKXdk6s5fBULz9aEcjggbWz61h9LnitI7y3hDrZ6paKW
+DL+CaNwwwfyI/wCqrlBpNrAl5ay3ZuriAQahHOqiNXLMQ0JH4kKbPxchs+WK5s0U2dWGTqjb
+aIL2x1G11G2kZ0kR2huozhZEx+8iYeTFT+E9/KtJBd3en2sV26yCQX8hQSKQPBbnG0/r+VY3
+ROpBY6lMdOnkWKJY0kiRtiyxRxAZU+T5zithqUe+KCdNSkuobpBLvkk3bX27tmfUA8iuWemd
+Ueh3WeuLZWjQ2cnhgjbbhWBCArg48jyxqu+IXUek9S670xrmogSftro7SYtSe3Chhc2oktGI
+C/xBIoiQeT51neotzWy4G4I4U8+WODUPSLVrjQtOuVs3c2FxNBJLn6Bv+tR7Hvx596uEtGc4
+7TRAueitT0mK+vYEN3aW8/ysNxANyyDbu3+xI8vKsx1VoUsdraarLbT293eQG7dZGL+NEXK+
+Lk/hbcNpU+xHevSepm1CDpu3Wyu5I4w7SyCGQqxLDA/oMVU9RXOoSdIyWepT+OLuNWtS/wBU
+kfAJG7uEOPw9sjNdeLIjky470eQdu9KpwwOM11kt3QorAhnGcfnXLaQSDwRXX2cXTJdtNCkh
++aLhShCFP4W8jjzFdTfuqG3yrIGyCPL7GoeCxX34pwjKbTMpVSfLzqOKZqskkaOwsZjo0msz
+eEsNvIiYZvrkL5A2r3IGOT5U1NQIdiZNuf8AKqW51GWVdgJReAFB4AHYVHjnZTnJOO9ZPDe2
+dC9Slo10uum4jEEQwmRgnuamW+oIrgDAOMZz3NYhJ2DDHarCKZ0jMzPyTtVaylgo0jn5I2L3
+D3KKkUqiSNdrN6DOc1Nhvokj8RMbYSFiz5nzNY+11a2WPwLmMxucgToxzz/MPOmapr2yBbK0
+JBUY3+opLE26CWVJWyD1Jem+1Oa4MviZPHtVWEyRkikJyf8AvTkK/wAS5B44OK7EuKpHBKXO
+Vsd4e0bm48xkd6WHfvAQ8k9s11nnlutsk0m51AQDGMKO1IsRXbuXG4+vbFJvRUY29Gx6UtGg
+kWUrkIxcj2xWv0a4i+Zknn+lbc5QgYJOMA/rWc6ck0+S02icxyKBgsODWgijtLdml+aDhuGR
+UJGD715eVvlZ6+NKkWer3KzaXHDc2qRSWztIZlzukR1ACEeinkfeodjb2l5NDor36Wb6pbGK
+KSQFkDqNwVscgMeM+WaleHbXKAm+Q7RkBgcMvbFVNxbM1wkqHmNgEZe6gVin9mrX0T3eVdLs
+3ntRFO7BJVPIZIQV59ck4z/hrOT2NtdSCSR2jTe2F8u9a/WDFctZSRx/u47SOLav/iDO8/mT
+msjeytDFIFw6E8g+WOxFdMX9HNJfZEuJYrd7g4jkQ8BG7Y+/lWa1G/tDGkdqjk7tzK38PsD5
+1OCXN7FIkSs+AWIHJFVs2k3UIhD2k4nlYEAIST6D3NdWNV2c2W/A6U6fJawS3MpXxSd6KCSh
+Bxz9xzVTcJF4z/LsXjBOGIxkVqNdtrTTrD5U6akN3BJvaUqT4iOg+kgnA2kZyPMkVmI1nuGE
+EKF2IJCqOSAMn+groiqOTI7Zz2EY3DGe2a6RKq5lmi3p+HG7HJHBpEfghk3hvXy+1drO8ksp
+hJEkbgjaySrlWHuKbZKRIFgY4IbmO6gYv2G8H8mHcGkMaIWZUaIg4KZyAfautxqejXhXfoUd
+ngHc9rIxJPrtYkfpTIJyCI94lgz5rhv/AErOVm2Npsv9GcbogCVkZsqx4BFbXT/m9PuHunHg
+naGVSeQf4hWas7dI7JDp10ZbCVthS+jCGKbvhG7A/nzV2TeCw2X0ckrLyk4OVVe3JHlXDlgd
+2ORsNO+KOi6LqYteuvhnYda6DcMxn0+4vJbJwzIVDxTR/VFIuQysOMjBBFYDRGgivpJdO1O7
+iVGYCCUq0vhk9iTw5AxyMZqy1zQtdt2lW9t1uI8K3j2kq3EW3H0sGQngiq63tg9zvS1ZmTBy
+PLFZ3S4mqVuy7EYnkaeK5EqNnORtYH3BrP6uny9vLJIF7kkg+daK1cMziQYH8YxgVkupJFv7
+mSO2PCkYQefvUR7HJaM+6yzIshABb8z96ZHZsJFH1Hdzk+dXq6fHHb72wr45VjzUZ3RkSJ1w
+YzlWx/Sunn4RnxRwkRVSJe5UfVx71YWl1blQGYx55z61XSB5SOcFT+ornukQB1IIz2b1pDaL
+w3EdzlIo178Mp749qd4aKWnVSMDGPU4qjjmWJt2/k88Gu8uqpHagu2WBIT1o430Q3RAu7xUY
+rJuAJOB6Vxe+DfSQDHjGOxFV9xdtJMZN27J865m5bngD8q6o4qRyyzKyc8g2Avk+nrio11IN
+oAfd5UyS6LoobkjjNR2bJq4w8sjJlTVISiigDNanMAz5U7JLc0qoScc08KcgAdqTdFRi2dY4
+1Ee8uP8Al86l2JRZQzcqvNQwjHKt3HrU+xgMsbEcbRnNY5NI7cS8EmaUOcq2fPFMADMfNvMe
+orgxIcnGMDtUhJFwko9t2PKsGqOlHQQgFG7bu/3qXZWzyMQU4Jxj1pITC/iRsco3Kt6UQym1
+nGJOG4yPI+RrK29FdHaaFfE8OSIkHsccqRVloEA1LWdszBzBbSuoPByqZFMWVL+1IVVF0h55
+/vB/3rv0rfWuka6bu8A2G2ngwR2dlwv9aOyWVhKbZ4pVzkhlPp61AeE5G7IwcAg1NvYJ4Lh1
+kB2yDejDlWU+Y9R5flUdhLGyB1OGHBoEiS0zWsm6OVm8JsLIPxAetXpngkhjljvJlvHXBljY
+qsqfyOvYkdww/OqvT9Kvr23eaG2aSOM4faM4B7GughCRG3k3DacxsDx9jUuVdD4pkm20aW/k
+e2a7Ie4OyFpcMqOewOfI9s1ws7DW7GZ7eURiW3bEsBUbkYHB4P8AmK6weLMGtWJ3MPpPqfKp
+2oytc+HcTwzPPGoDOWw4qllb0yfbS2gg8OXUYvFtXkWT8QQcgeo+1aXVNP8ABleeSZo3u41S
+ZnH0S9grMPLjHPlVBbWtnf2cBjujbXcbGM5fG8eR54z5e9aPTNSh1dY+neqrd4r23Xw7W7Vv
+DWRfJJCeAfRjx6+tZSb8GsVZjbyzuNN1LcsZSaE7ZEY8lT6+oI7N516h8OutUjktbW/KutlP
+4ywS8BweHXPcZX+orM6toVy00drcOJ4QALeV/wB3cWreaOv8SH0zweV71nANS6c1gw3ls+Yi
+rqH/AIkPYg+Y9DUyVlxfE+hdTtdF1e1vtAZLmWe1uI7yx8GXbMv8Qe3cYaKdThlxwxBHesv1
+A+ofEBbvWrvUTf8AUdxukvLsReH+1SnBmlUAeHcgAeJkAPjd3zmn1PVotVtdP6hikeCXwvDN
+xDkKyZ4LY7FTwfyNUU3XMmjaoJIpsXqMRNLE/wBNwjDBDL2bKkj3qldUS6T/AGZLrGCVIrd9
+jxzW5YSBhh155B8+DXGHqy5mjitr2bxYmjEZYgErg8E+4q06zKm3t72OYypcgmOXYQGX+X7j
+sR3GKwa3Cm4ZJiA3bgcZ8q6IK0YOW6NRqhmikJns2tVlUNsbO1jjIZSfIjmqf51XhZQ2Sp+k
+Z7VGHUd20a2V43zdsgCokjHKAeSnyrm9lulju7IyG3mbYN45Vsdjj/OtFj2Q56Ot480Fqt/E
+21WbYwHkak9O6lqJmll0S4caikbN4A/48YGWGDwxA52+eDUC8nEFm1ixWUK+ZAW5A9vcVSS/
+7vN4lvKylTuRgcEfpW0YI55ZXRobzrGbUUUpbx20iklhEMI5PBIX+HPmBx6VGF5HHCpEgaJ+
+Ce5SqOQGN/xZJGc/emiVsnPn3HrVOCZHutdlncXEy/ugQ65yrLzkVzeOdrWSVo2XYQTuGMg+
+fvUSMlkY7yNnIH3pReThViaRnjXOEZsimoCeVi22GmVCRhzjNWFvqElp4kSrt8mBOQaqgVYk
+kbec8dhViltHP4kUzOkwTxIyPqVvalJBCeiVZao6zKjHIY4wK1Nq813GdsbSqi5dVGSFHnWI
+tbVZJE/e5yR28q1Om3epaNMNRsHdZLZuW/oQR5gg4x71z5EvB045N9lzYKwjkzG0tjL+7dgu
+Wjz2yPSrEWLQywPbXSZUExsRjP8Ah/PtijTry0v4l1CwKwNKPBu4RwFYnhgPSp8Nt81BPp8i
+gkZdc90YDPH3rnct0b8SbovhMi3CSG3VSwB2BjbyHhgVPEkbDgoeOcjmusHSek6rpFxZWVrF
+FaPIZIvDcsLS6HOFJ58N1J+3auWiXErWYmtiTI7ANgAnI88ef2qP07r97pPVc8jpDHFeMQ1s
+I9sEh81x/DnnHpTslo46j0teaDGLbUwpDrtJ9VYcE/8AequOS400/I6jZ/NWEn0FScNj1B9f
+MGvUp9Ph1i3Rre5luLGZXa1WYbngI/HDu89pzgGqiawtJNOTTpPBAdvDjmkHCv8Awhv8J7e2
+an3K0WoXsz0el32lrbappNxI8cTGSzuWjBOB+KNh2cY4ZTVxeXa39pBdMEhXxEaZFBwFyNwB
+74xnB8qfoS3umtd6ZfQTS2N+pS5tM8290v8Adyp6MRwfUVwvHis3ikADxSxgqQOD5Zx5e4o8
+2OvBpfix03p3w267vektN1qfUbC3tLK8t7mRVV3guoEmCOBwShfbuH4sZrM/tCw/8Ufqa4fE
+HrPVertX03U9cvYLieLR7bSjMkIjLR2+Vi3gd2C4Xd5gCs/4lt/9kwf1q5KN/HoxxufFc+yu
+tLFLWILLIC7YdyPIen+VdEwke5og6oDLKjHGRntVhY6RNJos1wIpnMEnizuFykcIUYLHyy2Q
+KqWlDCaRgeXGfcY7Vxd7PSWkX1mlotvqjW0jHwTGYs/iAPLKf9DUK9Mt7cfNMULqoUbVA3IO
+wPqcefeq+yuJYiZ0YZmjLuPUA4xUoXNuDtlH09+O+PSjaZS6JtrZ3t1FFBYSobxSWSF3C78f
+w88Nmu2oanoWuaa9vqdrdadq1q+2UKoWJ+MZaL+BxgDcv4hjNVK3FoiLBduJYn+oOFJaEj1H
+p9qtNZ6fTV7WPWNP1FZbp7d5MO5f5hEIUhT33AHsfIVvDrRzye6M8bIWMgF0rT2khyJYjgn2
+9M1oLa+SytYVjvZ4UBGxrmLb9ssD5euKptITVA6wBAv8JVjuVx3HB9K06HxJGWC1hF+q5khj
+H7xwB+OEtkZx3T9KHGxKTT0a7Ruohe6WsWu+FLbgH60faSc8EOPP37is5qeiapaXTT6Pqt7r
+dlM3iS2d6zyXECgkgDJPiAAnBHPJ4rP/ALSnczy2d0EB+nxIF2pIw/gnh/hY/wAwqy6X6xey
+G7UopSS+5JYZAs0Y/lGfpIB9cVi047iaqSkqZGCiO4FpNqd1psc8e+FoJmMLo3ZsZxjyK9wR
+Vto93oOlvHZadq+u9HaikPh6hf2kn7Qs9QbkrLLbNjuCB9O4D0qw6jttD6niXVdJSMzynM6A
+bAX8yy/wk+3Gaxup6LO0LLHcC2eHAjjdiGVj3Xnj/wCurhk2ROGtEa6ha81B7lbKwuBIw3ta
+BYQzr/Gsf8BbuQPOpps7a7ZIZrm4tZSTtctnZ54wBk8+VVZ0+S3WKW8t1Z8BnIkGQc+Vc7y8
+ljHjWV0RNnmOVeCPY1ffRl12axo9Ft4FhuZ7+S8VRvEUW1XJ8+e32og0bTr9iZi4DA4JP1H7
++lZKw6h1lp4xFOm+LJELAKc+zDufvU+Dq2+sb1b0Xz2N0rbjvjJJ9R2wQfcUuDL5l9b6D0Xb
+3qjWby+kXDKwtJYzIHx9P1NxgHGQOcVWdQ6fosLLK8pOT9cm3LjzHeq6edZZ5ZILW3MbfWQO
+xzzkY7flVRq2qTi12Gcsg4CNzge1EbukS6Zndda0Fw3yszMmTtJGD+dUjA96kTzNJIc/lQIS
+crtOQM16cFwR5+Re49EUZHOSK7T4URoQ4cLufd5k8j+mKY6YOPM11vby61K5a7upDLM4UEgY
+yFUKO3sBWy2cr1ojgZ9/tXSGWSCQSRMVZRjIPcYwR+YpoJTsSpPvTWYk5oEPkiaMIzFcSLuX
+a2fyPpTA1J9hRQB2WaTIJc596nx3X04Y5J9KqwSOQaeHO3bgZB71nKCkb48zgXsd+DtDOGA4
+571ZadeQxKZ0bc6k4B9KySO2cip9tc7WGO4Pn2NYTw/R1486kaSe+gkisrXxN4iRmYBsBnds
+kfpip1pMflwyxLHuOBGPKs2BvkWWNMBjnaPI+1aeyVQYQzbWD7uRwR6VyzjSOmLJ8HzBdCgK
+sTtUg+Y7VX6gySXZkZSnifSSODknsfzqx3lWaVMr9QZ180b1+xrnqGnvPLJcB1ETlZS2fw4H
+NYrRfZWl55misyS0gkCRIeSSxwAPuakWtxJJcvE42yJKyn29jXHcFuYZUm/eQukyMvcFTuH9
+anW1u99e3t9gK1zI1xtJxuJbJA9+c0PoadE+N90bRsAXjyOO+DXS0dVCxsc47YrjK4i+YjyA
+fpJPmVHFcJZvAbttKALwe9ZU0ap2Wt2dNe1e0v4mnQDBAbbz6ZrN29nb6f48EltHJazNmNH5
+K59+/wCdSUuTKpRSclsk57VxumJjAPIHA+9aQk1oicVIr9XMCWc0pt2kaOH5ZZCcCLJBz98c
+VS2lroMiNMp1BYba38ScMUJMu3+HHG0sQOecZrRS6fPqEVxGqM6EhnjXsT5VltSubr5KW3k/
+dlpF/dqAoEajj+telhkpI8zPFx2UySFGDcZHOCODTpfD4ZJMluWXbjb7VyYEHPr2pVYYIIyD
+XUcVk/TLlraTxIyVmByjg8r61o+n9ZubK8BluJ1iBzuEhGxs53D/AF9qycJxkiUKQOx86t7M
+TCIhcDecnJHaufLGzqwzaR69rdgtnquo237Y/aUd1Z216jpGql45oQ0cn/SSykd+M+dQ7UzG
+ZbRZGUzQoShOATjFUmgXD3mkWXi7luNOPgwSJj64STuRiOTjPHoOK0V1bxTtFd7fEWS0nDoR
+gxtHgqR9wa87It0j0YO1Yr+FB4d9cQyh4wlvKwUg4Y4Vjn0rYdN6lC/SOr9I3T27XM7/ADFo
+7pslUrkeID/FuXKEen2rJ6XrK6laQRXiPHYoY1l8JFaRFBBO1W4ZvMZNLrGopYX6qt0s6BXa
+0mRSoWMcgAH8J55HrmpSG2ZS+lsbHWZoL/8A3qNVwYhxiXHm3mM1baN1Bca9P8tErWtxErNE
+ivhWcYwo8hnGPeqDqIS6re2gt4I0kvOW2Ljt3J+wrrokM9pONTtZVltBmIypzlxzgjyOORW9
+tRM+Ns1upC3eJbe8QCxvrxld2GfAk2gpg/w5ORVfp2jLot21hqN58r4bqksEynMkLHBK47/S
+wI9dtaa5t0t7cR6mkTW91GtzCpQkXkbHDlT2BXzB5qXc2tvqcUlkrB7jRkWW1LckQqeFJ7lM
+HHPY48jWcMvLRU8XFWeTLaieOexuYVt5Y5yIpACFdAe/tV309ptvd6vedH6rsFvrMSWUEzNj
+5W8c4t5gf5QxVWH8rH0rpfafHZ3GWYSGdTujfja5YnH5jFOurGxuDbzIJXMca5jfhlcdgGH4
+hnFaqW7M+NqiDpel3Wm6vd6fqULw3lgfCuI2HIkT6ZF/UGt1JJLHo0qwhGt4czOgI+kFcbh+
+VVV5qV9f6ja6jqkf+/Rwi0u5pAQ8zJldz+r4IBPngGnRXgSCe3BBjdGt2TH4hnv9/SsZ7kax
+6orZpIzaZLHZvXedudi4wSanQT3Oi6bfaGl7i2u5oHktgAFeSPJikHvtY8+YNVd/st7S4t1b
+LTQ7S3lkHOB+XFQonaVbRJpQ6qncHJVQeB+VOKFItI83qfJzu/gq4MgHfGc4FROobk3NzHd3
+MIWOZ9m1RxEBwpA9hRbzrEpO7ILDcfXmuBuI7+2xMV+l2Kg+Y3cCtIvRm1bMB1FFtvjGgykQ
+27h96qlhd2+lCa1FyLSbVp7WXxEXw5WD4z9YUlB9ieKiW9psJZCCsgAc/wAp8xXWslLZySxK
+UitmXw7ZYQvAG73ye5qEsTyHEalyBk45wKtbo5miCDAwc+4zStGTEzIfCxxhB3qlkoUsPIqf
+CcqWCkqO5pqrkge9TBbyssgQthFywHpUbbtYFu1aKVmMsdHaK3yzFkbavGfepgjZvHgEscTx
+xGRfFOC+O6r/AIv+1V7yOy4YnaDwK5YZyAoJJOAPU+lCV9j58dIDI2c5NdFuMRSRhFZ5cAuw
+yQPQelF1bT2czWtzE0cyHDowwUPoR5H2rnGCWB96oz23QCMtyBnyqX8l4cCzMwO7jaP4T5Zr
+tp8kVoGkubIXCPlNu8rj3yK7WcRupGjjjLK+cjPO0f61nKbOiGJESGGVS30Eqg+s47U8xH6E
+3EgnIPtWlvtCW30u3vLHVInWcfvY0Vg654w3l+lUsKYvzbIwJV9uccd+az58kaqHHRbaXII4
+lxHgEhM++a0QLi2W53Kg/C5J5H5Vx0Xp+yvLaeP9v2NpMB4kMNwH/fyKchFIBAJGe/FSJImu
+PmPlrfw5FBEsZJ4Pfse3FceReWdmN30NiuyF4kJjJxkV30lJVnkhdyVZv3ZJzx3NX+s9KdJ6
+JYaVPY/EWy1Y3+mRX8tvDpN1BJb3TOVktGaQbWdBht6/QQcDmm9P9PaxrGoJpOh2C3N9cBhF
+H4yIz4GTtLkAkgcDuaxcGnRrGSa5I6RyIrswYDw8uuR+lUOp6AdUu/lUvoLLxw8vizsRF+Es
+q7h23EbQT5kVZpcSl5JZoXhlCsjRyJtKMDjBHliqu+WW6spIlcLGh2M7HCpntmrhakRkqrPM
+J7i6s7wvBcMkiHurdj/rUlepLqS7lu76JLkyLgLkxhG8mXb2Nc9Q09ILt45NRt3JOd6klf1x
+UCWJ4jhsc9mByD9jXrRSaPGm2pFjqF8mq3CPHJMoK7dkjFtuB/N5/nVajsjh0cqynIKnGKRp
+WbBJ5AxkDFMJNNKieVkqOPfz4mG78U4xkgrjluQfeuEbYyO2a7/MOrLIvDA/lUNNM2i4uJHM
+bZwBzT4ZGUnHn3qdbIkqMhTduOc+Yrvb6UoY+I2dvIHrUyyxXZUMDtNGg6S6g1LTILi2s7st
+BfQta3du6hkkiYchgQR9iOQexpiNc2UvgQyyooyFZWO0g+R9a7W1kILGBrdQGlYlvsKkwu8T
+vDAd8X4tjdwPOuOUk7o7Un5O2l6lcWkoMdw1u4OcxfR/l3rQN1jOq/K3txHcIp/CyDGT7jBz
+WXmj2knGAoyD5ioMarI7Su3Hf0xWKVmnRu7uS2TTkukP1yg4Gc8A4/zrGX8Mi3bOgUt6q3Bq
+baXVwxVJwwiEWVyOGHnUO+AhAMoPBxj1oqnoE/sjy5WMmQglh3BzUKcll34PHFS54JJLbxI2
+yobGAe1cG2mM5B8u1VHQMjQXoBFvJECc4znmm3OzdjkeXNQ3YpOZPMHiu8spnYHgVu4pO0Z2
+Mf6kDIQdo8qrruUsFTnK5zVjGEV8FuB/WoN5byByWBOTkGtcVJ7MM1tUiAc55oqXNalMfUOV
+B4qIRg4rpTT6OKUHHsKKKXHGaZAlKvekwadGpLYoGtkyFWcYCKoA4Hmfzrm30sTjFdgCg2jG
+RXOTa3ngk1ins7eNRJMV6hj8GaGOQEDDEfUn2P8ApU63fdbeDbfUzcsMgHHpVIUOODkV0jZs
+ggnj0pSjfRUJtOmWWrppsV5u0e8muLZkVlM0YSRDjlGA4OD5jg1CE7ZNG4FQpUZB4PnTGwGI
+z2pJeGW5MmwXX8JOKlAr4QklO0McCqwKdgYYJU5GPMU4zM6YBOB2HlUOCb0NTZZx3KI30SEg
+96tY18cJMhV2GM88496y6NIBuHIFTbec+DvDlJFI2geY86zlj+ilIvDczLCdOmB2I5kiBH4M
+9wD6H09ab8tLMBsJLD8Kd932p9nNbyohuUDeN9Kt2Kt96c2tnR7k+BAsjrx9fIB9vtWVOyhI
+HmtgEZnT+ZDkV1klkhkXeA0bjIxzxU19f0TqO3SXUGXT9WjARpgv7i7XyLAfgk9SODXObTrq
+3jSJogyyrvjkBBV/dT61Mk0NM5TqkbEI5PGcEcirPTdWjFuLK9RLhDkxs/DKfQN5j2NVElw8
+sSSTxEmHCNKB9Sj3HnQWKs9uxUkfhIOVYeRFS0Umaq60iewgGs6MsWoae5X5m0lXLwhuMN7Z
+4BqVfavobkPb2cmmaaWSJ0MjXLQAjkHd9RGcnB7ds1WaJq2raTbre2iiW3k328sZIdWBH1I6
++YIwQfUCnSJb6tBcyWWfHjQyeGTzIg7gHzI74NHig8mgGo6amlRSQa+lxdRObc2c1szKYDys
+sc3Yr5GNuV7j0qq1O0fVPDjs0m+YRcJA8m4jJz+7J/EpPaq/ovWuk2nuNL6q0q4mR42SOW0l
+8OSNj2YA8N9jVtq/TUsls110pqq6xY231qyAx3VsO+JEPoe5XIzTp3Qn+iBoXW09laXOg6lY
+dyyvgbdzDjO09nGMH1xzWW6kjt7rD2z8RD6JBxuHfB+1T7lrie6juriMfMXBbljje44JJ9ao
+dWmubdnt5YTHzllYcg1rCNPRnKTI8mr381t8rc3EnhqQ5T+Hd23Y7Zx51Qz3DLIxVs7u5q/i
+EFxYzzNdok6YUQupxKp9COxHfms7PbshbB3Ad/UV144rycuaTrRy8Q5zk1Y6dqLQFV8cqNwb
+DHKHHqPL71VkVYQaHqlzps2r2lo9xaW2PmJIhv8AAycAyAcqCexPHlmtnFM5lJoS7uRLePOk
+QY7y7L3GM8/lURmySVGFJ4Gewq86I6d6i6p6nstC6STxdbuXxYQCRUe4lAyIk3cF27Kp/EcA
+ZJAqNewG6vriC5tU0y/hLJNbuhiXxFOGXafwNkHK9sjyo6Fdlf4h2sCFcMACSOVx6UgjyAcj
+BOCfShI2L7APq9K7tEVj3OccYx6Gk3RSjfZxSF2cqAfTIFNkhkjOJFI9/I1Jt3aN1kjLBs7X
+A8xXO4mPjPtcspOMGmmS1Ru+mOlehOrfhprPyuo3dl1/oUp1G3tZDvtdY03AE0cYxlLmHHiA
+ZIkjL4AZBuwqPJbsrIfqQ5BByCKSwvLqxuo7yyuJIJ4Tujkjbayn2NNOGZSoC7jj0GaTLvkr
+omSQb0+ct2Oxjk47qa0mmQ6hNcSWrti9eJWMLn6bmMjhlPriqzRY5bPUJtF1a1aF5h+CRdpD
+eXf1FaW5vbGLWumJNSnMduIRYyTY/Agfhz/y7uftWclejSMqF6SAttfW1lVhiTwri3dfq2Hz
+A9QfKvVBpEcNxE06HwC4ilkj/Gi+uPPjnB71jdf0e5/2hn0u7Xdq+mtsFxHw0qr+En+YEYIP
+fBredPXUeo20D35ZJH2wTsq8o68A/euOcdnZjlogjRJei+rrGbU7a3vundaZ447hJNttex9i
+occwzDuA2CrD0pL/AKegtNUutPvH+YSOTEcrjbIYycxyg+RxjPuDU7X7aBnn0LVIY1gVxOwZ
+cbyeBKAPUDuKnav03qF307qZ0y6iuNQ6cs11COHfmS904f3rxH+PYGDEd8c+RqKvZfKlTHw2
+A0f5e8YtFDczrFcH+BZiMJJjyLDgnzIqsvLRknuoLpQFLHcp7MM//TBrv09r2m67oxtb4Sv8
+xAbabn6HHeJx6MG4PpwRUa5uEQQTTNJLb7vDaXzBHr7+tTJJ9Di2uwh1FvGez5S4hjCrIRlL
+mHttcfzKezflWamuPBuJdHuhiF5tkZbhonPbn0J4qz6r1H9n3Nrqdldxos8TROinj6u4I9Dw
+aymrauupwu93F/vESbZHH/EA7E+9VGLTJlJUN1DTrkyywCM+PASrREc8d6qdtz/9jN/5TU7V
+uohdxWVwJN9zGgRpQcMwXtn38s1w/wBrrn/xJP6VrxMeX2a+2a4i0HXtHVn8O/soS6gkAmCZ
+WU48/wATVhr6YJMYVbGcuc8fat3dXTLHc+GdhSzdWz5/VXm10ztN4hOWfJ5rggr7PSbLHbE2
+kWdzEVZ5Li5gYeYACMufbk1FnnAAGcDsfvSRFRAsYXDBy5b7jFNmRmjMwXfg5GfWq8gcxMzD
+dkjDY4qVou+EzS6VrUdreWsiyRJPkq7HuoA7Z7HyqE4ZfEYqAWOeO2armEqsJrPcswOcg4IP
+rXRjMchs7241ezZLq40/5OK5DOIl+qEN5hD3A9K46nDdQxwata2720M2GSWJiUEo81bsD54q
+r0nVb2aGaDU76RISgEbuSQHHlj866Wkj26PHIzJHcEYUMTE5/mI7A/lVSRkmuzU6PNpvVd3F
+p+vyx6Vq5UpDqkMexZj/AArMo4JJ/iqu1np7VLK++Wv4BDdg+GxX8LEeY+9V9t8ibt7a7v5L
+eUN9U0ce8A+Rx5itoddg1LSYxqN8l5d27C3nuRGYw8f/AA2ZTzkDjI9BWLVGqd6MvbRXun3b
+6feNNZzbvDLqdoB8t4ParbqnT+rejNVl0jqjSjBeQKnjI8iTLJG6gq4kQsrIykEEE1H1i71L
+U1OLm2u7i0TwDKwBM8YP0hyfxYHAb04NUR0+9njMmkw+A8S5nhY7Ygv3PA+36UKKf8kybX8E
+h3tL2N5I/plcMoUyZK+hqNcpdLBGuoRCaKMbY5E/D27HHnUe8utKhn2R3YlmQAb402gHHr5j
+Ncxcoyv4Uww5+tWXg1dOJDdirEBKlupBY8iTGOKtLfqnqGw3rYapLbI6GJ9kScr2wSwJ/SoE
+978wI7e/jUCMHbJEBu/P1qFqNukIintr1JllByFyGTHkwPahbKpFtDPbWuJLyK+1CMrh44WE
+SuPQvgkfkKe/UOkC7img6G0SFUj2rHd+NcqT6sWYZP8ASsnLd3C/RG8hJ/kJFddOvRLIqXkj
+NFnDAd8e1aqDSslu9F9daol7KznSNAtGcYVbOyWNV/I5P9ax16wEx+abH1HeIx5ewrUXEUMK
++Fa3CSxTjOSm1k+5rK6xCI5jsk3Z7HFXhlctmea1DRwe7swqiKwBdVwZJXLZOe+3sOK5XF/P
+ciISMo8IELsULgH7VHOc80ldx5b32BOTT0QscAc9/tTVUscKMnyFSbuK3t9kMMviuFzI4yBk
+j8I+3r50AMeMYzGwYEeXrTrqOAOHtkkETIp+sjIOOe3lnNcUlZG3IcEeddxOrgDb4cm45dTw
+QfLFLaLVM4FVHruH6Uw+lWi6Wbi0NxBJvlUkyxhcbV9feq+SJly207c4Bx3pKSeglBxGA+nB
+FSYF3MNjYI+og8Zx6VFrrGCVJ2k4GcjyokrCDplzFcGdtw/dOoAAFa7TLtb4vO1lHEsZVpIU
+4HbG5c9ueayuiww3NtPcXCtLJA8eVzjdEScnP6Vq7Z1W32yxbCI2WCQd2Xvsb1I9a4sqSdHo
+45WiRe282xL+zkWaJ5BDKQcFCeRuHcVJ1LbDpbGGTLlAp/X/AFqv6dvZEi1OBoVdL2IwF2HK
+HIIYf4gR39zU+7zdW8a7RhQQwHfGf/rrmao6EzPkxm6baxKBgFJHPar7SQAsLMONxyfQVXWk
+YjupLOeHxYlXMhHdVB7qfXHatG8Vpa6dGkSuEZ2VS342DDcM/YUmg5eCBfRmQbBljKSGPtmu
+Otx+GkVyD/fA4H/LwasI4o7p+MqViLJz+Igdq561aNd6HD4BXxbNmaZSfqCNzkevPBqPJaZQ
+2e1WRizES45x2qdcjZHHsbJ3sM4qPEGiEO8DacFQPI4rqzq8gHI28n3NIsWSDdbSmM4cLwc9
+zmsvBNbWusQPq9s15ax74pI1O0lSpBwR5gnP5VtYohHH4kmChPb1qo1HSIIoI9QFyGufHl3W
+3hHAgEfEm/tySRt7+ddWCfHRyZo2zzqRcNgNuAOAfUUypNwIkYiIlgfXyqNXpJ2eTJUxQwHl
+U6zkdmhhiDF8kYHnnyqBXSJ2U4U49xSkrRUJUz1XSLUWVrDbDlo4mkOO3GDkH88VNfULez6d
+1iB52a5nSOKJtp/d/Vlirfbg+xrI6B1LFHYNbTwkzQxGGOQsTlW5I5qRcav/ALiWkVWDNk8e
+1edKDUqPSjL46Lax6h0v5eHT9kyL4QSWdu6yfzY819u9cm1W+h5ikWaNztIdQyn357VmILvx
+XeVo1QHyAwKuNDvYTcbJo98eDuQnv9vepcaZd6L+eVIEtbqwhCRX0REUky5Ee1sSIPz4+xqy
+6Os4rvW7jQUv7Swk1O3YWjTLtimvE+qKMnspflQTx9QFdbGS1hl0LXtNRhY6Vf23i2rfV4Mh
+lBeQj+JXGfz49KqrmxIvLu0uMkFpMc7WUiUgfYjA/SobpFJNujbaZGp+cs9Qs/GiWQLd6VO5
+t5VfszwSfwzLyuw8NUzWls47/QOtdK1eXTrXUbH5e3llh8SSG5tMQTRzqnGGARuR9QbOKoet
+us9Z16/tepb50lvbnT4Le9kEY/ftH9HiN6uQq5buSM1YeLpsnR4s5d7ut5cXIx5lxHtPsQAc
++tYKkzpk7jRUdZ2MGp3U2pWJjSCWVWSOEkrA3dl552+npWeuBJ8jGkqkxXVs+Mdwwbj86u7a
+/iE6RXLOIJMNKF/EMcAj3qsv1nhUJKEI8AmMRn6VLMf0raLbWzCUUnol2uoyakk1vqbmVZpP
+FZm/ErsgXdn7AVVi6NlM1pM+WLEBwfxe4qFbzuZGUMVPlnzINQbm6ae4cu31I2FxTq2F0TJZ
+Z3lW2iLOEJILD1pouCiMqssYbu2OeKfFcK0Mp2/vNoJA/lqtaZbiXe4JGcADsKpKiLLO1Jmn
+RWORKoGM9sedPkU20WyUYaMtu8u1Qre5QSiTBbamAe2DVrrEUgtLO9YqWuozn7rgHPvS8DM7
+dRNJlUUK7/UzY+ogVGhSPBWOMqjcYI7+9WscQ3iSQgbvpJPO0fapuqzaNNHpaaVoslnNbWhi
+vp3uTL8/PvYicJgCEbCqbBn8Oc5NXzpEKFszk2kySMHL4yoC8c486itbFMxYzg96v5I2MYmc
+gbOMe59qiCEGN3dcDaealZG+zVwSWijtztExUZO4B/Qr6VDksn7RjJzuGPSrhIPCt5Y1X8ZL
+M3rxXLTzvZ7jblo4mAB9RXUp1tHNKCemU8i+JKzDClcADyqVpmo3miXtvrmmyJDe2Vwk0BKB
+grjlXwcjIOCPekWLdO7lfoPn7mmXVrJFHlh3rVTV0YPFpsjyi6vbh7i5d5JZ3MjyOcl2Y5JJ
+8yTT4rV1fB4Ga0Gm2EMuh+L+OWNt3HkD5VGnsmEgQdy3H2qJZt0aY8CpMgywDhEzkqST7VIt
+3jdIY7NDGIyDvP4nbz/+qrK9sl8OC7UYZ02FfTFcLaIW7ghB9A4HfmsnktG6huy+kG+CCEph
+pPr7d6Ww02yubgyfL/XE31qTyTnjmuTLNLJGpJIQ7TjjjvxV7HBCbVdSsNyOD4N3Gw4J/hkX
+7jgj1GfOuZzaRqo2x0dl4N5vSPagG5F8h/8ATmrK0ebUbmOG7dGubdPCS4fA3xD8KsfPHYE8
+44rnb3CiIYfMZADD1xXLFzHCs7QDw1YxBs5OR5H071k5N6NFGizv5RfWcFveoWbTyUQ7sbY2
+OSg8jzyP0quvLa1mRiN3BBjBHv5+9FxqY+XS32/Q7hmHmG7d6W7lRHMEojG1i8cgzuPHK+nv
++VIpfQ+UWksO1538SQHeQh+kjz96ouu7rp7S9LsLXpxr5Z5tOgi1Vrq4WWOW8UtukgAAZUKl
+cq2cMDgkVPe5dpkkjRmOAqoO+Se9Y/rTUGkddJS0s2XTpHZp1hAmkLd1Z/4lHlXX6dW6OT1D
+aVmSnbBCgk4759a4k+Q7U923EjHfmmMpXBPnXpLSPKk7dgmM/VnHtXSSNVP0NlSMg/6VyBwa
+6hwFVQgyM7j/ADUCQ1OCM10KktgEnJ4pUCqyyKNy5/Cal28DeNk4z3FRKSjs3xwctFhYWMjb
+XiRmyOV9KtvlljnCnhSADgU2y2oqBM7GG1h71OvbQqsMmDgrnj2Nedkm29npRjS0SrR99s0S
+gKqrxjuKbZ2w8YTEjd+IH1FN0wuIneQEGQlSD2qXZKWVbZsRyZO0vwB+dZNl0RtTwsAYEAc5
+zVN41tsCKpALZJz3qy1uOePxI3XgdsHg1QYd38NoypXgEVpBWrJl2aXRtQjNpcW8yxtuZWiL
+uQYSOSVxwcjgg111Mw3UIUxqSoByCMgH2qkiAigZhy1QrvUEDFpWYDyGeaqK5PRLaii1tXEV
+yjIizRgDxIWO3xVzyufI47HyNRupbaLStSeG2mMltKBNA5H1GMjIDDyYdj7iq2LVQ0itLGJF
+U5xkgEehNWPUk/SWoW8VzoUep6ZMB9VnezLcxH1MUwAbv/Cw8+9axx72RLJSIV/pg/Y2n6/B
+cBxdzXFtLFjmKSPaRz5hlcH8jVcH2r+IDB5og1OVbGXTHbMTSCdR/LIARkfcHFRJJBntz51v
+w8GXuKrJ0JV5FXcAPMmus5jZD4eSBxzVbHKwIwakRyZRgT55+9S4Uy4zUkcZWB44J7ZqJL+L
+tjFSJQxOQDiuDLnjFbQ0cubY1E3MBkAE4yadJG0ZZHUh1bBrtFApR2PkMilm8SV0ZySzALn7
+VXIy9t1ZH27ufQU+BMuo966xx7FcMPqyBj2rvHAv0EcZ5PtUymjSGF2mdJIvoBXy7mkjkZRs
+2R4752/UfzqYQCCvDJIPzBqDIu1jg9qxjK9HXKJ2jjsSsj3LSo+AUEahgfv6Vy8OAZaN2wR2
+IpPFJwuO1NwQO/nxTpi0cmO2uRfnIp8gLFgOD3qMSa1ijmyTcXR38TA3K2DT0mLc/rUTNKHI
+7Gq4maytMuI5YTEobjdn/tXMSqiqhIGxjyarhMxAQsQP8qVpNx9fes/aNPfRprfULZrB7Zcj
+DK6bu4Png+lLKzPH+/iyR3IOD96zsNw6Kyt2I4q10/UYvAMFyXcHjjuBWUsbWzaGVSHZCN+H
+eMYIPFSrW6aOMGGWQqpy0ZbsfUVC8Pwrgws+9GGVZeD7VJh8OKTKsC4HIPY1Dj4NFIso1dpA
+0UrDxBkejDzFdpbRmhXOMH8Ei8EN/Kai2MwmLWciFGLboXU4wfMVpIbiKNt886I+0Bw68n8v
+M1i40XGRm45LiCUSxuUkHDjPetHpU0V3fxwzzRRTsD4MjttSQn+Bm8s+R8qbPP05fbCySJOo
+wcDCvXeCx0m6t2VYA0fbcH7VNl9lbcaOY9YkmVXhKkhlkGCrehruJZ7TF3Z3EqZyJdh5x5/c
+VsdL6c07Wbee2/ba215aQh0iuEIadR2KN2YjzB5xWZ1Cyktp5UK7HiYq4VdoPvjtVJ3sloi/
+OW0kBtJMzrMC6qwwQw7lT6+1Z3U5LeZlljuWbH0kP3x6VcyRpK6CYABWyCnBB9RVD1Hp13YT
+mcYkhl+pXXtg+daw26MpIhNLZJZyw+EXYMGSVWwQPNSPP1BqCZFKEcEnI3j8WPQ1GeRgxKng
++VNL4IYDj0NdaVHJOVsaRg8jt5etWeiftQXE02kNcwAQslw1sx3CFuGyByy+oqtB3Ejbkk5z
+ntU63t5IWDlnjc/gKNhlNU3RnGNvRpU6R03VLayv9A6r062u45IoGiupzAxbP0yo+MYHG4Eg
+r35Ha71vR+p77Vv9j+v7aK+1l5H+R1i3u47mW4bv4fioxW43E5XJ3ZOAecVmdO0lCUlcS7zy
+4Y5Vz6/err9jQ3AMdoiruYN4a/QyOOzqfI5rF5UtM2jifaKu36au7HqDTodS/e288nhrLEpy
+QDhlKnBDryChwwIxTdX0eLprXLrp/U3trqCb97Z3dvJujkjb8DA9x6EHBBBBGRWr1fqrXJR8
+t1nDHqUUjoWuvBEdwkigKJWK/ifAG5u7EAnmqTXtPm6mlVhJF8/EAsb4CrdITwxPbd65o5Jg
+4yvRUQdLTOslxaXQPg/iiK/WOODjzHvWal5ckgZ8x6Gtzocs8WrrYXaTWt1bcDnDAjuP/p3p
+3VVjomsFXitk0/WVz4rxH/dr5fJwP+HJ/MOx78GqjOnTJnjvowkMe5lUnYWP0se1aC00Jrm1
+lgMoS6jYB4ZPwtnsQfI+/ajSdLa5tp7C5xhHDop7q3Y/katrLTpYrhLO6EizBdqlu0sJ7c+R
+FKc/plY8ddldH83dyRWWsi5f5dRHFK3MkKr2APmB6VqpdBgvtOXTdWZBDfr4llffwxXAH8Xo
+rgYI8jXbQZbS5vJenNfXw71VIs7sceJx9Kt5Z9D59q0mn6dD8gbW9YNZXpOU28w3K/y/y57j
+y7isJTfZuoR6KGCHWb+1sLi/d/2rZKtruzzIiDCZPnxwD5gCt903rBntUW5TwpifpmC5JdP4
+HX1/r96zz6NdWlpcJKS5tisgdeC0Y8/uKmWV+0BkVXE0Mu11Mg/eI3/MO/PY1jKVmsY0brW7
+3SNX02BbmFQL5P8Ad2DD9xMDiVUbvtPfwzwDyPOs/oeo7Fj0WS9lg1DTH+a0q7i/HGRkPFg8
+MCCfpPBBIpmrTRanp11FFJDEkoF1GmeI7pRyR6BsVl7m+ga+tNStI3jN3GrsjNnwpl4cA+YP
+f86S0gL1Ug06+kNqENpcE7hGNojY84x5DPb0q36Insuof210pfT26X00XztiZn2GWZOJI1Pb
+LLg49RWV1fXYJl8eK38G7QeG5Q5SZfIkeRFVFncFbqK5iPhTQyeIjHnB8x9qKQ7aNL1dZRar
+074MwFvPDkcgDDg859Aa86Md1aO1leqUuYFBYH+OM9jnzFehahrFvqOmy6fNYjezBhIX5H/f
+/tWI6wa4Gk2moQlmn0t/lpiw+pYz+HP8y1eP5ETtGL1q58C6Bgb6GGRg1C/asv8AMKdcA3CS
+B8Ak71x5H0qv8J/5GrsjFNHJKbiz/9k=
+
+--------------kCCPQfdkG5hSF08pCIKQevhQ--
diff --git a/comm/mail/test/browser/message-reader/data/noCharsetKOI8U.eml b/comm/mail/test/browser/message-reader/data/noCharsetKOI8U.eml
new file mode 100644
index 0000000000..0b6db3ce7d
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/noCharsetKOI8U.eml
@@ -0,0 +1,10 @@
+To: test@example.com
+From: test@example.com
+Subject: Test message with Russian text, no charset header
+Date: Tue, 20 Apr 2021 15:20:34 -0500
+
+íÏÑ ÓÅÍØÑ
+
+õ ÍÅÎÑ ÂÏÌØÛÁÑ ÓÅÍØÑ ÉÚ ÛÅÓÔÉ ÞÅÌÏ×ÅË: Ñ, ÍÁÍÁ, ÐÁÐÁ, ÓÔÁÒÛÁÑ ÓÅÓÔÒÁ, ÂÁÂÕÛËÁ É ÄÅÄÕÛËÁ. íÙ ÖÉ×ÅÍ ×ÓÅ ×ÍÅÓÔÅ Ó ÓÏÂÁËÏÊ âÉÍÏÍ É ËÏÛËÏÊ íÕÒËÏÊ × ÂÏÌØÛÏÍ ÄÏÍÅ × ÄÅÒÅ×ÎÅ. íÏÊ ÐÁÐÁ ×ÓÔÁÅÔ ÒÁÎØÛÅ ×ÓÅÈ, ÐÏÔÏÍÕ ÞÔÏ ÅÍÕ ÒÁÎÏ ÎÁ ÒÁÂÏÔÕ. ïÎ ÒÁÂÏÔÁÅÔ ÄÏËÔÏÒÏÍ. ïÂÙÞÎÏ ÂÁÂÕÛËÁ ÇÏÔÏ×ÉÔ ÎÁÍ ÚÁ×ÔÒÁË. ñ ÏÂÏÖÁÀ Ï×ÓÑÎÕÀ ËÁÛÕ, Á ÍÏÑ ÓÅÓÔÒÁ áÎÑ - ÂÌÉÎÙ.
+
+ðÏÓÌÅ ÚÁ×ÔÒÁËÁ ÍÙ ÓÏÂÉÒÁÅÍÓÑ É ÉÄÅÍ × ÛËÏÌÕ. íÏÑ ÓÅÓÔÒÁ ÕÞÉÔÓÑ × ÐÑÔÏÍ ËÌÁÓÓÅ, Á Ñ - ×Ï ×ÔÏÒÏÍ. íÙ ÌÀÂÉÍ ÕÞÉÔØÓÑ É ÉÇÒÁÔØ Ó ÄÒÕÚØÑÍÉ. âÏÌØÛÅ ×ÓÅÇÏ Ñ ÌÀÂÌÀ ÇÅÏÇÒÁÆÉÀ. ëÏÇÄÁ ÍÙ ÐÒÉÈÏÄÉÍ ÄÏÍÏÊ ÉÚ ÛËÏÌÙ, ÍÙ ÓÍÏÔÒÉÍ ÔÅÌÅ×ÉÚÏÒ, Á ÐÏÔÏÍ ÕÖÉÎÁÅÍ É ÄÅÌÁÅÍ ÕÒÏËÉ. éÎÏÇÄÁ ÍÙ ÐÏÍÏÇÁÅÍ ÂÁÂÕÛËÅ É ÍÁÍÅ × ÏÇÏÒÏÄÅ, ÇÄÅ ÏÎÉ ×ÙÒÁÝÉ×ÁÀÔ Ï×ÏÝÉ É ÆÒÕËÔÙ.
diff --git a/comm/mail/test/browser/message-reader/data/noCharsetWindows1252.eml b/comm/mail/test/browser/message-reader/data/noCharsetWindows1252.eml
new file mode 100644
index 0000000000..8186b7d3be
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/noCharsetWindows1252.eml
@@ -0,0 +1,22 @@
+To: test@example.com
+From: test@example.com
+Subject: Test message with Spanish text, no charset header
+Date: Tue, 20 Apr 2021 15:20:34 -0500
+MIME-Version: 1.0
+Content-Type: text/plain;
+Content-Transfer-Encoding: 8bit
+
+Aquí tenemos texto en español:
+
+El 12 de octubre es el día de la Hispanidad que celebra el descubrimiento
+de América en 1492. Este día coincide con la fiesta de la Virgen María del Pilar,
+que es el patrona de España.
+
+Actualmente, la Hispanidad se celebra dentro y fuera de España, aunque es
+una de las fiestas que más polémica generan. En muchos países de Latinoamérica
+el descubrimiento de América se asocia al comienzo de la colonización española
+y a la destrucción de las culturas locales nativas. Por este motivo, en América
+del Sur la fiesta se percibe como una reivindicación.
+
+En España la Hispanidad se festeja con un desfile militar y una recepción,
+encabezada por los Reyes, para el cuerpo diplomático en el Palacio Real. \ No newline at end of file
diff --git a/comm/mail/test/browser/message-reader/data/wronglyDeclaredShift_JIS.eml b/comm/mail/test/browser/message-reader/data/wronglyDeclaredShift_JIS.eml
new file mode 100644
index 0000000000..b63c68d185
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/wronglyDeclaredShift_JIS.eml
@@ -0,0 +1,11 @@
+To: decoder@example.com
+From: encoder@example.com
+Subject: Test Encoding
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf8;
+Content-Transfer-Encoding: 8bit
+Content-Language: en-US
+
+ƒ[ƒ‹‚ðŠÈ’P‚ÉB
+
+ƒƒbƒZ[ƒW‚Ì‚‘¬‘S•¶ŒŸõAƒ^ƒu•\Ž¦AƒA[ƒJƒCƒuBÝ’è‚àŠÈ’P‚ÅAƒJƒXƒ^ƒ}ƒCƒYŽ©—RŽ©ÝB‚»‚ñ‚ȃ[ƒ‹ƒ\ƒtƒg‚ª Thunderbird ‚Å‚·B \ No newline at end of file
diff --git a/comm/mail/test/browser/message-reader/data/wronglyDeclaredUTF8.eml b/comm/mail/test/browser/message-reader/data/wronglyDeclaredUTF8.eml
new file mode 100644
index 0000000000..f559d76cc4
--- /dev/null
+++ b/comm/mail/test/browser/message-reader/data/wronglyDeclaredUTF8.eml
@@ -0,0 +1,11 @@
+To: decoder@example.com
+From: encoder@example.com
+Subject: Test Encoding
+MIME-Version: 1.0
+Content-Type: text/plain; charset=windows-1252;
+Content-Transfer-Encoding: 8bit
+Content-Language: en-US
+
+メールを簡å˜ã«ã€‚
+
+メッセージã®é«˜é€Ÿå…¨æ–‡æ¤œç´¢ã€ã‚¿ãƒ–表示ã€ã‚¢ãƒ¼ã‚«ã‚¤ãƒ–。設定も簡å˜ã§ã€ã‚«ã‚¹ã‚¿ãƒžã‚¤ã‚ºè‡ªç”±è‡ªåœ¨ã€‚ãã‚“ãªãƒ¡ãƒ¼ãƒ«ã‚½ãƒ•ãƒˆãŒ Thunderbird ã§ã™ã€‚ \ No newline at end of file
diff --git a/comm/mail/test/browser/message-window/browser.ini b/comm/mail/test/browser/message-window/browser.ini
new file mode 100644
index 0000000000..a4cd1cf740
--- /dev/null
+++ b/comm/mail/test/browser/message-window/browser.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+support-files = data/**
+
+[browser_autohideMenubar.js]
+skip-if = os == "mac" # mac used native menubar
+[browser_commands.js]
+[browser_emlSubject.js]
+[browser_vcardActions.js]
+tags = addrbook vcard
+[browser_viewPlaintext.js]
diff --git a/comm/mail/test/browser/message-window/browser_autohideMenubar.js b/comm/mail/test/browser/message-window/browser_autohideMenubar.js
new file mode 100644
index 0000000000..4d7c07d479
--- /dev/null
+++ b/comm/mail/test/browser/message-window/browser_autohideMenubar.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/. */
+
+/**
+ * Test that the menubar can be set to "autohide".
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var {
+ be_in_folder,
+ close_message_window,
+ create_folder,
+ inboxFolder,
+ make_message_sets_in_folders,
+ mc,
+ open_selected_message_in_new_window,
+ select_click_row,
+ toggle_main_menu,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var menuFolder;
+var menuState;
+
+add_setup(async function () {
+ menuFolder = await create_folder("menuFolder");
+ await make_message_sets_in_folders([menuFolder], [{ count: 1 }]);
+
+ // Make the menubar not autohide by default.
+ menuState = toggle_main_menu(true);
+});
+
+/**
+ * Set the autohide attribute of the menubar. That is, make the menubar not
+ * shown by default - but pressing Alt will toggle it open/closed.
+ *
+ * @param controller the mozmill controller for the window
+ * @param elem the element to click on (usually the menubar)
+ * @param hide true to hide, false otherwise
+ */
+async function set_autohide_menubar(controller, elem, hide) {
+ let contextMenu = controller.window.document.getElementById(
+ "toolbar-context-menu"
+ );
+ let popupshown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown",
+ controller.window
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ elem,
+ { type: "contextmenu" },
+ controller.window
+ );
+ await popupshown;
+ let menuitem = controller.window.document.querySelector(
+ `menuitem[toolbarid="${elem.id}"]`
+ );
+ if (menuitem.getAttribute("checked") == hide + "") {
+ EventUtils.synthesizeMouseAtCenter(menuitem, {}, controller.window);
+ await new Promise(resolve => controller.window.setTimeout(resolve, 50));
+ }
+}
+
+/**
+ * Ensure that the autohide attribute of the menubar can be set properly.
+ *
+ * @param controller the mozmill controller for the window
+ * @param menubar the menubar to test
+ */
+async function help_test_autohide(controller, menubar) {
+ function hiddenChecker(aHidden) {
+ // The hidden attribute isn't what is set, so it's useless here -- use
+ // information from the box model instead.
+ return () => {
+ return (menubar.getBoundingClientRect().height != 0) != aHidden;
+ };
+ }
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == controller.window
+ );
+ await set_autohide_menubar(controller, menubar, true);
+ utils.waitFor(hiddenChecker(true), "Menubar should be hidden");
+
+ menubar.focus();
+ EventUtils.synthesizeKey("VK_ALT", {}, controller.window);
+ utils.waitFor(
+ hiddenChecker(false),
+ "Menubar should be shown after pressing ALT!"
+ );
+
+ info("Menubar showing or not should toggle for ALT.");
+ await set_autohide_menubar(controller, menubar, false);
+ utils.waitFor(hiddenChecker(false), "Menubar should be shown");
+ Assert.ok("help_test_autohide success");
+}
+
+add_task(async function test_autohidden_menubar_3pane() {
+ let menubar = mc.window.document.getElementById("toolbar-menubar");
+ await help_test_autohide(mc, menubar);
+});
+
+add_task(async function test_autohidden_menubar_message_window() {
+ await be_in_folder(menuFolder);
+ select_click_row(0);
+ let msgc = await open_selected_message_in_new_window();
+ let menubar = msgc.window.document.getElementById("toolbar-menubar");
+
+ await help_test_autohide(msgc, menubar);
+ close_message_window(msgc);
+});
+
+registerCleanupFunction(async function () {
+ toggle_main_menu(menuState);
+ await be_in_folder(inboxFolder);
+ menuFolder.deleteSelf(null);
+});
diff --git a/comm/mail/test/browser/message-window/browser_commands.js b/comm/mail/test/browser/message-window/browser_commands.js
new file mode 100644
index 0000000000..32ff6d5bdc
--- /dev/null
+++ b/comm/mail/test/browser/message-window/browser_commands.js
@@ -0,0 +1,103 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var {
+ be_in_folder,
+ create_folder,
+ get_about_message,
+ make_message_sets_in_folders,
+ mc,
+ open_message_from_file,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var folder1, folder2;
+
+add_setup(async function () {
+ folder1 = await create_folder("CopyFromFolder");
+ folder2 = await create_folder("CopyToFolder");
+ await make_message_sets_in_folders([folder1], [{ count: 1 }]);
+});
+
+add_task(async function test_copy_eml_message() {
+ // First, copy an email to a folder and delete it immediately just so it shows
+ // up in the recent folders list. This simplifies navigation of the copy
+ // context menu.
+ await be_in_folder(folder1);
+ let message = select_click_row(0);
+ MailServices.copy.copyMessages(
+ folder1,
+ [message],
+ folder2,
+ true,
+ null,
+ mc.window.msgWindow,
+ true
+ );
+ await be_in_folder(folder2);
+ select_click_row(0);
+ press_delete(mc);
+
+ // Now, open a .eml file and copy it to our folder.
+ let file = new FileUtils.File(getTestFilePath("data/evil.eml"));
+ let msgc = await open_message_from_file(file);
+ let aboutMessage = get_about_message(msgc.window);
+
+ // First check the properties are correct when opening the .eml from file.
+ let emlMessage = aboutMessage.gMessage;
+ Assert.equal(emlMessage.mime2DecodedSubject, "An email");
+ Assert.equal(emlMessage.mime2DecodedAuthor, "from@example.com");
+ Assert.equal(
+ emlMessage.date,
+ new Date("Mon, 10 Jan 2011 12:00:00 -0500").getTime() * 1000
+ );
+ Assert.equal(
+ emlMessage.messageId,
+ "11111111-bdfd-ca83-6479-3427940164a8@invalid"
+ );
+
+ let documentChild = msgc.window.content.document.documentElement;
+ EventUtils.synthesizeMouseAtCenter(
+ documentChild,
+ { type: "contextmenu", button: 2 },
+ documentChild.ownerGlobal
+ );
+ await click_menus_in_sequence(
+ aboutMessage.document.getElementById("mailContext"),
+ [
+ { id: "mailContext-copyMenu" },
+ { label: "Recent" },
+ { label: "CopyToFolder" },
+ ]
+ );
+ close_window(msgc);
+
+ // Make sure the copy worked. Make sure the first header is the one used,
+ // in case the message (incorrectly) has multiple when max-number is 1
+ // according to RFC 5322.
+ let copiedMessage = select_click_row(0);
+ Assert.equal(copiedMessage.mime2DecodedSubject, "An email");
+ Assert.equal(copiedMessage.mime2DecodedAuthor, "from@example.com");
+ Assert.equal(
+ copiedMessage.date,
+ new Date("Mon, 10 Jan 2011 12:00:00 -0500").getTime() * 1000
+ );
+ Assert.equal(copiedMessage.numReferences, 2);
+ Assert.equal(
+ copiedMessage.messageId,
+ "11111111-bdfd-ca83-6479-3427940164a8@invalid"
+ );
+});
diff --git a/comm/mail/test/browser/message-window/browser_emlSubject.js b/comm/mail/test/browser/message-window/browser_emlSubject.js
new file mode 100644
index 0000000000..f8fc12c4de
--- /dev/null
+++ b/comm/mail/test/browser/message-window/browser_emlSubject.js
@@ -0,0 +1,48 @@
+/* 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/. */
+
+/**
+ * Tests that opening an .eml file with empty subject works.
+ */
+
+"use strict";
+
+var { open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+async function check_eml_window_title(subject, eml) {
+ let file = new FileUtils.File(getTestFilePath(`data/${eml}`));
+ let msgc = await open_message_from_file(file);
+
+ let brandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let productName = brandBundle.GetStringFromName("brandFullName");
+ let expectedTitle = subject;
+ if (expectedTitle && AppConstants.platform != "macosx") {
+ expectedTitle += " - ";
+ }
+
+ if (!expectedTitle || AppConstants.platform != "macosx") {
+ expectedTitle += productName;
+ }
+
+ await TestUtils.waitForCondition(
+ () => msgc.window.document.title == expectedTitle
+ );
+ Assert.equal(msgc.window.document.title, expectedTitle);
+ close_window(msgc);
+}
+
+add_task(async function test_eml_empty_subject() {
+ await check_eml_window_title("", "./emptySubject.eml");
+});
+
+add_task(async function test_eml_normal_subject() {
+ await check_eml_window_title("An email", "./evil.eml");
+});
diff --git a/comm/mail/test/browser/message-window/browser_vcardActions.js b/comm/mail/test/browser/message-window/browser_vcardActions.js
new file mode 100644
index 0000000000..8be0246c3e
--- /dev/null
+++ b/comm/mail/test/browser/message-window/browser_vcardActions.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/. */
+
+/**
+ * Tests for attached vcards.
+ */
+
+"use strict";
+
+var { get_cards_in_all_address_books_for_email } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AddressBookHelpers.jsm"
+);
+var { close_window, plan_for_modal_dialog, wait_for_modal_dialog } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+async function openMessageFromFile(file) {
+ let fileURL = Services.io
+ .newFileURI(file)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ );
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ if (win.content.document.readyState != "complete") {
+ await BrowserTestUtils.waitForEvent(win.content, "load", true);
+ }
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+ return win;
+}
+
+/**
+ * Bug 1374779
+ * Check if clicking attached vCard image opens the Address Book and adds a contact.
+ */
+add_task(async function test_check_vcard_icon() {
+ // Force full screen to avoid UI issues before the AB gets fully responsive.
+ window.fullScreen = true;
+
+ let newcards = get_cards_in_all_address_books_for_email(
+ "meister@example.com"
+ );
+ Assert.equal(newcards.length, 0, "card does not exist at the start");
+
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+
+ let file = new FileUtils.File(getTestFilePath("data/test-vcard-icon.eml"));
+ let messageWindow = await openMessageFromFile(file);
+
+ // Click icon on the vcard block.
+ let vcard = messageWindow.content.document.querySelector(".moz-vcard-badge");
+ EventUtils.synthesizeMouseAtCenter(vcard, {}, vcard.ownerGlobal);
+ await tabPromise;
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == window,
+ "the main window was focused"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(
+ tabmail.currentTabInfo.mode.name,
+ "addressBookTab",
+ "the Address Book tab opened"
+ );
+
+ let abWindow = tabmail.currentTabInfo.browser.contentWindow;
+ let saveEditButton = await TestUtils.waitForCondition(
+ () => abWindow.document.getElementById("saveEditButton"),
+ "Address Book page properly loaded"
+ );
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(saveEditButton),
+ "entered edit mode"
+ );
+ saveEditButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+
+ // Check new card was created from the vcard.
+ newcards = get_cards_in_all_address_books_for_email("meister@example.com");
+ Assert.equal(newcards.length, 1, "exactly one card created");
+ Assert.equal(newcards[0].displayName, "Meister", "display name saved");
+ Assert.ok(
+ newcards[0].photoURL.startsWith(
+ "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/"
+ ),
+ "PHOTO correctly saved"
+ );
+
+ tabmail.closeTab(tabmail.currentTabInfo);
+ // Reset the window size.
+ window.fullScreen = false;
+ await BrowserTestUtils.closeWindow(messageWindow);
+});
diff --git a/comm/mail/test/browser/message-window/browser_viewPlaintext.js b/comm/mail/test/browser/message-window/browser_viewPlaintext.js
new file mode 100644
index 0000000000..a9af4caecd
--- /dev/null
+++ b/comm/mail/test/browser/message-window/browser_viewPlaintext.js
@@ -0,0 +1,138 @@
+/* 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/. */
+
+/**
+ * Tests that the plain text part of multipart/alternative messages can be correctly viewed.
+ */
+
+"use strict";
+
+var { open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+/**
+ * Retrieve the textual content of the message and compare it.
+ *
+ * @param aWindow Message window.
+ * @param aExpected Expected content.
+ * @param aDontWantToSee Content of other MIME parts we don't want to see.
+ */
+function check_content(aWindow, aExpected, aDontWantToSee) {
+ let messageContent = aWindow.content.document.documentElement.textContent;
+
+ if (aExpected != aDontWantToSee) {
+ Assert.ok(
+ messageContent.includes(aExpected),
+ "Didn't find expected content"
+ );
+ Assert.ok(
+ !messageContent.includes(aDontWantToSee),
+ "Found content that shouldn't be there"
+ );
+ } else {
+ let ind = messageContent.indexOf(aExpected);
+ Assert.ok(ind >= 0, "Didn't find expected content");
+ if (ind >= 0) {
+ Assert.ok(
+ !messageContent.substr(ind + aExpected.length).includes(aExpected),
+ "Found content a second time"
+ );
+ }
+ }
+}
+
+/**
+ * Load a message from a file and display it as plain text and HTML. Check that the
+ * correct MIME part is displayed.
+ *
+ * @param aFilePath Path to the file containing the message to load and display.
+ * @param aExpectedPlainText Expected content when viewed as plain text.
+ * @param aExpectedHTML Expected content when viewed as HTML.
+ */
+async function checkSingleMessage(
+ aFilePath,
+ aExpectedPlainText,
+ aExpectedHTML
+) {
+ let file = new FileUtils.File(getTestFilePath(`data/${aFilePath}`));
+
+ // Load and display as plain text.
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", true);
+ Services.prefs.setIntPref("mailnews.display.html_as", 1);
+ let msgc = await open_message_from_file(file);
+ check_content(msgc.window, aExpectedPlainText, aExpectedHTML);
+ close_window(msgc);
+
+ // Load and display as HTML.
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false);
+ Services.prefs.setIntPref("mailnews.display.html_as", 0);
+ msgc = await open_message_from_file(file);
+ check_content(msgc.window, aExpectedHTML, aExpectedPlainText);
+ close_window(msgc);
+}
+
+/**
+ * Tests that messages with various MIME parts are shown correctly when displayed
+ * as plain text or HTML.
+ */
+add_task(async function test_view() {
+ // First the straight forward tests:
+ // 1) multipart/alternative
+ // 2) multipart/alternative with embedded multipart/related
+ // 3) multipart/alternative with embedded multipart/related embedded in multipart/mixed
+ await checkSingleMessage("./test-alt.eml", "Plain Text", "HTML Body");
+ await checkSingleMessage("./test-alt-rel.eml", "Plain Text", "HTML Body");
+ await checkSingleMessage(
+ "./test-alt-rel-with-attach.eml",
+ "Plain Text",
+ "HTML Body"
+ );
+
+ // 4) HTML part missing
+ // 5) Plain part missing
+ await checkSingleMessage(
+ "./test-alt-HTML-missing.eml",
+ "Plain Text",
+ "Plain Text"
+ );
+ await checkSingleMessage(
+ "./test-alt-plain-missing.eml",
+ "HTML Body",
+ "HTML Body"
+ );
+
+ // 6) plain and HTML parts reversed in order
+ await checkSingleMessage(
+ "./test-alt-plain-HTML-reversed.eml",
+ "Plain Text",
+ "HTML Body"
+ );
+
+ // 7) 3 alt. parts with 2 plain and 1 HTML part
+ await checkSingleMessage("./test-triple-alt.eml", "Plain Text", "HTML Body");
+
+ // 8) 3 alt. parts with 2 plain and 1 multipart/related
+ await checkSingleMessage(
+ "./test-alt-rel-text.eml",
+ "Plain Text",
+ "HTML Body"
+ );
+
+ // Now some cases that don't work yet.
+ // 9) multipart/related with embedded multipart/alternative
+ await checkSingleMessage("./test-rel-alt.eml", "HTML Body", "HTML Body");
+
+ // Bug 1367156: Rogue message which has an image as the last part.
+ await checkSingleMessage("./test-alt-rogue.eml", "Plain Text", "HTML Body");
+ await checkSingleMessage("./test-alt-rogue2.eml", "Plain Text", "HTML Body");
+});
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("mailnews.display.prefer_plaintext");
+ Services.prefs.clearUserPref("mailnews.display.html_as");
+});
diff --git a/comm/mail/test/browser/message-window/data/emptySubject.eml b/comm/mail/test/browser/message-window/data/emptySubject.eml
new file mode 100644
index 0000000000..f099e06c86
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/emptySubject.eml
@@ -0,0 +1,8 @@
+Date: Mon, 10 Jan 2011 12:00:00 -0500
+From: user@example.com
+To: user@example.com
+Subject:
+Content-Type: text/plain
+
+Hello there
+
diff --git a/comm/mail/test/browser/message-window/data/evil.eml b/comm/mail/test/browser/message-window/data/evil.eml
new file mode 100644
index 0000000000..8b1d386518
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/evil.eml
@@ -0,0 +1,16 @@
+Date: Mon, 10 Jan 2011 12:00:00 -0500
+From: from@example.com
+From: evil@example.com
+Sender: send1@example.com
+Sender: send2@example.com
+To: to@example.com
+Date: Tue, 12 Jan 2011 12:00:00 -0500
+Subject: An email
+Subject: Dup subject
+Message-ID: <11111111-bdfd-ca83-6479-3427940164a8@invalid>
+Message-ID: <22222222-bdfd-ca83-6479-3427940164a8@invalid>
+References: <aaaaaaaa-3d7b-22d2-f859-4c1e6a2164d6@invalid> <bbbbbbb-3d7b-22d2-f859-4c1e6a2164d6@invalid>
+References: <ccccccc-3d7b-22d2-f859-4c1e6a2164d6@invalid>
+Content-Type: text/plain
+
+Hello there
diff --git a/comm/mail/test/browser/message-window/data/test-alt-HTML-missing.eml b/comm/mail/test/browser/message-window/data/test-alt-HTML-missing.eml
new file mode 100644
index 0000000000..87a814a27d
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt-HTML-missing.eml
@@ -0,0 +1,17 @@
+From: test <test@example.com>
+Subject: test multipart, HTML missing
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+--------------alternative
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+Plain Text
+
+--------------alternative--
+
diff --git a/comm/mail/test/browser/message-window/data/test-alt-plain-HTML-reversed.eml b/comm/mail/test/browser/message-window/data/test-alt-plain-HTML-reversed.eml
new file mode 100644
index 0000000000..9287e3ec34
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt-plain-HTML-reversed.eml
@@ -0,0 +1,31 @@
+From: test <test@example.com>
+Subject: test multipart, plain and HTML reversed
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+
+--------------alternative
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ HTML Body<br>
+ </body>
+</html>
+
+--------------alternative
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+Plain Text
+
+--------------alternative--
+
diff --git a/comm/mail/test/browser/message-window/data/test-alt-plain-missing.eml b/comm/mail/test/browser/message-window/data/test-alt-plain-missing.eml
new file mode 100644
index 0000000000..76bfd50f7c
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt-plain-missing.eml
@@ -0,0 +1,24 @@
+From: test <test@example.com>
+Subject: test multipart, plain part missing
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+--------------alternative
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ HTML Body<br>
+ </body>
+</html>
+
+--------------alternative--
+
diff --git a/comm/mail/test/browser/message-window/data/test-alt-rel-text.eml b/comm/mail/test/browser/message-window/data/test-alt-rel-text.eml
new file mode 100644
index 0000000000..058bd9f73f
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt-rel-text.eml
@@ -0,0 +1,50 @@
+From: test <test@example.com>
+Subject: test multipart, alternative first, with image
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+--------------alternative
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+Plain Text
+
+--------------alternative
+Content-Type: multipart/related;
+ boundary="------------related"
+
+
+--------------related
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ HTML Body<br>
+ <img src="cid:part1" alt=""><br>
+ </body>
+</html>
+
+--------------related
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <part1>
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+--------------related--
+
+--------------alternative
+Content-Type: text; charset=UTF-8;
+Content-Transfer-Encoding: 8bit
+
+Unspecified text type (not text/plain)
+
+--------------alternative--
diff --git a/comm/mail/test/browser/message-window/data/test-alt-rel-with-attach.eml b/comm/mail/test/browser/message-window/data/test-alt-rel-with-attach.eml
new file mode 100644
index 0000000000..f3c6572358
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt-rel-with-attach.eml
@@ -0,0 +1,66 @@
+From: test <test@example.com>
+Subject: test multipart, alternative first, with image and image attachment
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------mixed"
+
+This is a multi-part message in MIME format.
+--------------mixed
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+
+--------------alternative
+Content-Type: text/plain; charset=windows-1252; format=flowed
+Content-Transfer-Encoding: 7bit
+
+Plain Text
+
+--------------alternative
+Content-Type: multipart/related;
+ boundary="------------related"
+
+
+--------------related
+Content-Type: text/html; charset=windows-1252
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=windows-1252">
+ </head>
+ <body text="#000000" bgcolor="#FFFFFF">
+ HTML Body<br>
+ <img src="cid:part1.DE278D8E.600E563A@jorgk.com" alt="">
+ </body>
+</html>
+
+--------------related
+Content-Type: image/png;
+ name="llpfblmjefjfhhce.png"
+Content-Transfer-Encoding: base64
+Content-ID: <part1.DE278D8E.600E563A@jorgk.com>
+Content-Disposition: inline;
+ filename="llpfblmjefjfhhce.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVQImWOwCKjBihiGlgQA
+VT9BAeZezQ0AAAAASUVORK5CYII=
+--------------related--
+
+--------------alternative--
+
+--------------mixed
+Content-Type: image/png;
+ name="attach.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="attach.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAkAAAALCAIAAAAiOzBMAAAAAXNSR0IArs4c6QAAAARnQU1B
+AACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAASdEVYdFNvZnR3YXJlAEdyZWVuc2hv
+dF5VCAUAAAAtSURBVChTY3Dx6wYiZ/9OTMTg7A+UIEsOXQiOaCkX0I6JiJBzDMCCcMsFdAIA
+xwttYrvggeAAAAAASUVORK5CYII=
+--------------mixed--
diff --git a/comm/mail/test/browser/message-window/data/test-alt-rel.eml b/comm/mail/test/browser/message-window/data/test-alt-rel.eml
new file mode 100644
index 0000000000..ae7121ff23
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt-rel.eml
@@ -0,0 +1,45 @@
+From: test <test@example.com>
+Subject: test multipart, alternative first, with image
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+--------------alternative
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+Plain Text
+
+--------------alternative
+Content-Type: multipart/related;
+ boundary="------------related"
+
+
+--------------related
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ HTML Body<br>
+ <img src="cid:part1" alt=""><br>
+ </body>
+</html>
+
+--------------related
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <part1>
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+--------------related--
+
+--------------alternative--
+
diff --git a/comm/mail/test/browser/message-window/data/test-alt-rogue.eml b/comm/mail/test/browser/message-window/data/test-alt-rogue.eml
new file mode 100644
index 0000000000..0b5f31b4ad
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt-rogue.eml
@@ -0,0 +1,42 @@
+From: test <test@example.com>
+Subject: test multipart, rogue message, last part image
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary=--FIRSTboundary
+
+----FIRSTboundary
+Content-Type: multipart/alternative;
+ boundary="--SECONDboundary"
+
+----SECONDboundary
+Content-Type: text/plain; charset=utf-8
+
+Plain Text
+
+----SECONDboundary
+Content-Type: text/html; charset=utf-8
+
+<html>
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ HTML Body<br>
+ </body>
+</html>
+
+----SECONDboundary--
+
+----FIRSTboundary
+Content-Transfer-Encoding: base64
+Content-Disposition: inline;
+ filename=blue.png
+Content-Type: image/png;
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+
+----FIRSTboundary--
+
diff --git a/comm/mail/test/browser/message-window/data/test-alt-rogue2.eml b/comm/mail/test/browser/message-window/data/test-alt-rogue2.eml
new file mode 100644
index 0000000000..f774d8fcbd
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt-rogue2.eml
@@ -0,0 +1,36 @@
+From: test <test@example.com>
+Subject: test multipart, rogue message, last part image
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary=--FIRSTboundary
+
+----FIRSTboundary
+Content-Type: text/plain; charset=utf-8
+
+Plain Text
+
+----FIRSTboundary
+Content-Type: text/html; charset=utf-8
+
+<html>
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ HTML Body<br>
+ </body>
+</html>
+
+----FIRSTboundary
+Content-Transfer-Encoding: base64
+Content-Disposition: inline;
+ filename=blue.png
+Content-Type: image/png;
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+
+----FIRSTboundary--
+
diff --git a/comm/mail/test/browser/message-window/data/test-alt.eml b/comm/mail/test/browser/message-window/data/test-alt.eml
new file mode 100644
index 0000000000..5a952b4051
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-alt.eml
@@ -0,0 +1,30 @@
+From: test <test@example.com>
+Subject: test multipart, correct plain and HTML
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+--------------alternative
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+Plain Text
+
+--------------alternative
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ HTML Body<br>
+ </body>
+</html>
+
+--------------alternative--
+
diff --git a/comm/mail/test/browser/message-window/data/test-rel-alt.eml b/comm/mail/test/browser/message-window/data/test-rel-alt.eml
new file mode 100644
index 0000000000..5e965f3d8b
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-rel-alt.eml
@@ -0,0 +1,41 @@
+From: test <test@example.com>
+Subject: test multipart, related first, with image
+To: test2 <test2@example.com>
+Date: Sat, 27 Feb 2016 17:11:45 +0100
+MIME-Version: 1.0
+Content-Type: multipart/related;
+ boundary="------------related"
+
+--------------related
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+Plain Text
+
+--------------alternative
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+<html>
+ <head>
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
+ </head>
+ <body bgcolor="#FFFFFF" text="#000000">
+ HTML Body<br>
+ <img src="cid:part1" alt=""><br>
+ </body>
+</html>
+
+--------------alternative
+
+--------------related
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <part1>
+
+iVBORw0KGgoAAAANSUhEUgAAAAYAAAALCAIAAADTMGvBAAAAEUlEQVQImWPgsi5DQwxDWggA
+lCEwN+YGfiYAAAAASUVORK5CYII=
+
+--------------related--
diff --git a/comm/mail/test/browser/message-window/data/test-triple-alt.eml b/comm/mail/test/browser/message-window/data/test-triple-alt.eml
new file mode 100644
index 0000000000..8232d27ad0
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-triple-alt.eml
@@ -0,0 +1,36 @@
+From: "Test tester" <test_mail@example.com>
+To: "Robert Recipient" <robert@example.com>
+Subject: Alternative with 3 parts
+Date: Wed, 26 Sep 2001 09:16:49 -0400
+MIME-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="------------alternative"
+
+This is a multi-part message in MIME format.
+
+--------------alternative
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: 7bit
+
+Plain Text
+
+--------------alternative
+Content-Type: text/html; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
+<HTML><HEAD></HEAD>
+<BODY>
+
+HTML Body<p>
+
+</BODY>
+</HTML>
+
+--------------alternative
+Content-Type: text; charset="iso-8859-1"
+Content-Transfer-Encoding: 8bit
+
+Unspecified text type (not text/plain)
+
+--------------alternative--
diff --git a/comm/mail/test/browser/message-window/data/test-vcard-icon.eml b/comm/mail/test/browser/message-window/data/test-vcard-icon.eml
new file mode 100644
index 0000000000..cc925b7c45
--- /dev/null
+++ b/comm/mail/test/browser/message-window/data/test-vcard-icon.eml
@@ -0,0 +1,800 @@
+From - Tue Jun 20 20:58:09 2017
+X-Account-Key: account1
+X-UIDL: UID111111-1111111111
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00000000
+X-Mozilla-Keys:
+Delivery-date: Tue, 20 Jun 2017 20:46:01 +0200
+MIME-Version: 1.0
+Message-ID: <vcard@link.invalid>
+From: <meister@mail.example.com>
+To: Hugo <hugo@example.com>
+Subject: Click on inline vCard and hang
+Content-Type: multipart/mixed;
+ boundary=sgnirk-111111111111111
+Date: Tue, 20 Jun 2017 20:45:48 +0200
+
+--sgnirk-111111111111111
+Content-Type: text/html; charset=UTF-8
+
+<html><head></head>
+<body>
+<div style="font-family: Verdana;font-size: 12.0px;">
+<div>
+<div>
+<div>Hallo Hugo</div>
+
+<div>Set attachments to inline display and click on the addbook: link in the vCard.</div>
+
+</div></div></body></html>
+--sgnirk-111111111111111
+Content-Type: text/vcard; charset=UTF-8; name="meister.vcf"
+Content-Disposition: attachment; filename="meister.vcf"
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBUkQNClZFUlNJT046My4wDQpQUk9ESUQ6LS8vU2FicmUvL1NhYnJlIFZPYmpl
+Y3QgNC41LjMvL0VODQpVSUQ6NDM3YzU0MjAtZDQ1MC0xMDNkLThhN2UtZjk0MmE0NmIzZGY3
+DQpGTjtYLU5DLVNDT1BFPXYyLWZlZGVyYXRlZDpNZWlzdGVyDQpFTUFJTDtYLU5DLVNDT1BF
+PXYyLWZlZGVyYXRlZDptZWlzdGVyQGV4YW1wbGUuY29tDQpQSE9UTztFTkNPRElORz1iO1RZ
+UEU9SlBFRztYLU5DLVNDT1BFPXYyLWZlZGVyYXRlZDovOWovNEFBUVNrWkpSZ0FCQVFFQVlB
+QmcNCiBBQUQvL2dBN1ExSkZRVlJQVWpvZ1oyUXRhbkJsWnlCMk1TNHdJQ2gxYzJsdVp5QkpT
+a2NnU2xCRlJ5QjJOaklwTENCeGRXRnNhWA0KIFI1SUQwZ09EQUsvOXNBUXdBR0JBVUdCUVFH
+QmdVR0J3Y0dDQW9RQ2dvSkNRb1VEZzhNRUJjVUdCZ1hGQllXR2gwbEh4b2JJeHdXDQogRmlB
+c0lDTW1KeWtxS1JrZkxUQXRLREFsS0Nrby85c0FRd0VIQndjS0NBb1RDZ29US0JvV0dpZ29L
+Q2dvS0Nnb0tDZ29LQ2dvS0MNCiBnb0tDZ29LQ2dvS0Nnb0tDZ29LQ2dvS0Nnb0tDZ29LQ2dv
+S0Nnb0tDZ29LQ2dvLzhJQUVRZ0NBQUlBQXdFaUFBSVJBUU1SQWYvRQ0KIEFCd0FBUUFCQlFF
+QkFBQUFBQUFBQUFBQUFBQUJBZ1FGQmdjRENQL0VBQmtCQVFBREFRRUFBQUFBQUFBQUFBQUFB
+QUFCQXdRQ0JmDQogL2FBQXdEQVFBQ0VBTVFBQUFCNllUM0FrQUVrSkVKRUptRk16QkNVb1RC
+Q1JTbUVJbEtBUkV3VXhWVE1JbUpJa1FZRk9jMWZSTFANCiBxL09ZbVBYcXp3NjU2MzFXYVd1
+NjFMcERrZERycjBjMTJseHNNd2N6RXhNb2tpRWlJbUVSRXBBWE5VVFYzSk1BQ1FUQVNnVEFC
+Qw0KIFJFU0lURW9Fb2lTSWlZbEVURW9pcUNsS1lwaXJRRSt1aVQ3WGJrVEZtaDFma2xOR1Bm
+ZE84S3V1L0gyTEx3bnFMUElkVG95YXZ2DQogOE9NY2lTSkVBUk1BVEVKZ3V5YXUweE1FZ2tn
+UnFDZHd3L0xjUjNiditDd2szWGU5clc2czhmU29tOHltdnVlTi93QnM0bDQ4VmQNCiAvY2kz
+MnZQbjRxaU9hVXhLSXFpVVJWQlNuSFN3SE9KOWJ0b1c2UUFBQUVTTXQwL2pLakYyMXJHejha
+a3dtQUFBRVRDQkpjekUxOQ0KIHBJU2lSaU5lNXpObVZ4ZnBPbmFGbGdBQUFBRHg5a2M1ZnB2
+R3FLTTNlV3FiWFZsaE1TaUtvUkhKZDc1TDFkN2pUdkNaQUFBQUFBDQogOGQ3MHJ4cno5eG5V
+ZHRwd1NpWkFBQUVpNG1tYStxa1RFUnpYejFQdlJIcWF0Z1RJSkFBQUFBQUE4dWlhQjQxWis5
+TmEyWFBpaE4NCiBpY3Z4RnRkYWQ0V1hBQUFBQUFBQWVmYU9NN3BSaDN0RTg1Z0VTSWlZSlJK
+Y1RFMTlUenpMOHY2dG4xTlc4SmtBQUFBQUFBQUJSWA0KIGk2NnZUTlloa3diRmRhbjR6MWtw
+TnZvaE1nQWdFZ0FBQUFNbGpWZFBiQlY1d0FDSkVTRnhZM3ZJcSs4TlhFNi9SRHJzQUFBQUFB
+DQoganhpUGVNdnNWV2ZRZlBzV1RycjRYNjk3aHo4LytQUWRCcnJEbUUrVjlmb3JHbllBaXgy
+aW1yRE5qeG5NNDk0ZXRzMUR2c0FBQUINCiBiM0Z0eFgzU1lxcDh5QVJJQ1NKRFZlYitseTEy
+ZFc0YVQzWmNDN1FBQUFBQXBuTDExZUhVY3Q2NU1VUlZFY1VqcFRGVk11ZGFGMA0KIGptdktx
+aXE5dTB4VWFOZ1NBaTV0L092anNsZXFiVjVYRnRwMjlKY1k5ZXFjNjJyQjUrbXEwRWhJQmJY
+TnR4VjNTcW11anpZRWtTDQogQkpDUnlmcS9wcjlmWE5mRHg5dFhvaDNZQUFBQUloUjNMbW5W
+OG5ueVJYVk1Ja2lZbEZOWGthMXlYSmVOK3FSZnJBQThxTG5wbU8NCiBqbUZXMjZmWjFsT284
+WTdOazRrd3RQZWFpcUlqUWRTNmZnZFBHcnJlNDMzaDEwQkhoY1czRmZkSzdhNm84MkVpRWlB
+QWV2TytpYw0KIHE1N3dFbXYwZ21RQUFBRk5YbHp6MGpkdGIyVEY1c2tJbUVBaVlhZHVQSjU3
+MXowTm5wQklFZ1JlMmR2VlgybTJzY3g1ZkhKdWo1DQogRHg2aTQxclpiU0pvdnRSekU4WTdK
+YlQ2MjU5UjVmMzdCV2RjaFRHdmFIZmEzdUtlZWVzNWpUTjF6ZWJDWW5sRXdBUUQwNUQxN2oN
+CiAwV1k0YS9TQUFBQUFqejlmUG5ucm13NnB0V0x6WmdRUWtJSEYrMGNiNnN4bzJlaUFBQWlV
+UnMrOTh3NmY1ZElWZExPOHcvWEZydg0KIFNxL0hDUkVWSllmajNkOGQxMXhaNzIrdmRNUzc3
+emZWdUQ5dXkrZmRJUlVJbEtrU2lTcmtYWE9XUlpnaHI5SUFBQUFCVFVpTjkzDQogcmwvVDhm
+bXloSEV3SkFjMTZUaXBuajArSHZyOUVPdXdBQVBYcjNGZXorZFJVTTNWdGM0ZmJiczFHcDNu
+Qis2ZnB5dkc1S1VnUk0NCiBSRmx5SHRQbjExd2V2S1lYVnU5TnIxYnhjOTFZdktVNElFa0Vo
+QjZjNzZIcTNNODVlZnBxOU1KNkFBQUFBeVBZK0NkNHllZDZJag0KIG1xcFRNek1RSmhKei9S
+KzdjeDZ1MXQ1K21uY0V5QWVYckVXM1crVmI3aXEyZ1k1c3NwNFl1eWpiTFM4bTdOVE1rZ0lq
+SHd2OVNvDQogeTFkM2xwRzk0anFkRHhmZHNicTQ1VDJURTVlSzZ4UElJaE1GZHY3dVhCN3E2
+dGRIcEIzYUFBQUFCNGRhNVh0OUdUb2swelhrbEMNCiBGVUFJS29tWW5uT2xkNzFicTNtYUdq
+YkpSMTFSVFJhMVZaVE40VHhkZHBXTjk1bksydVJpOG5QaDNUbWZYVmZQcmpZc2I0WGNkWQ0K
+IDMweUxpd1lQbnZPYTVuOWN1cDNNYXNoTUpSTUlDU0FrUkhQTlY2bHlidlo3b20vWUFBQUFB
+dnJHMzRwN3hOcmM1L1BxUkpLSkltDQogS29KZ21ZSWkwNWgxcUo2NE5ucjNiK05WMWg4UFlV
+TmNyM0RCYXU3WHFuTEw2bmpwYWl2UGFBQUFNUk1aZnhqM05IekdSeEhmR3kNCiBYK21ibHF4
+eU91WUJFVlJLRXdFU1J4ZnRQUFZtcW9uUjZRVElBQUFEejlFYzlFMnptSFRzdm0xSWx5QlVU
+QWlVa1NJbURUOHhlYQ0KIExUYm5iZk42N1ZmbUx6SDRoTzFEanNBQUFCUldtTkx2Tm90K3Vk
+Y3ZNdlpPckxZdGR1cnF0a1F2eHlCQklRUUppY1prMFR3djN5DQogRmhkNlFkMmdBQUFBVmRz
+NFIyVE5neWswekZGVTBWUXFRaEtJVFVoS1VTUEQzUTBpbmVaNG5TZlhjTkk0czJZWjlZQUE4
+ajF0c0oNCiBqZTlXZDhNVEU2Y3Q2NFNTL3g3TjljZWUxUk9qeFpRN3p5UVRFd0FpbVlTbWFa
+VHFYUHUxY1duVFVMOXdBQUFBRWJ4cEdjcXk5Uw0KIG1tYXNVMVV5bVVJU2dUTk1rb2xJRW9t
+RFROejFQbnJOcUs4bTBJa0JyV3k2MTFmcmVFdTlOczUzbllPYjlSbTNMZXViWCtaYmUxDQog
+VWRVUkV4UEpCRlNCTUFCQWtBNTUwVEhSMXlTYmU0djlNT3V3QUFBRXhiY1ZkMW13djZQT21x
+aXBFeElBQWxFd2xFcEJNNFBPWXoNCiBsWlgrRnpXUGNFZEFNRm5mQ2U5RDFYb2RuMTZPazlE
+OE1qYm0yMkRSNGNFSUNTSkZLcUNCTUVDWVNRQURsT0w2SnpYdmQ3QzNVQQ0KIEFBQTgvU0k1
+Nk50R2g3M244dFhSVkVTQklFb1JNSlNoQ3FCTTR2SjZ6ek1aanc5OGU4SWtBQnJXeTYzWlh1
+VVZVNjhaQ2VSQVNsDQogQUVoRk5Rb21ZQ3FKVXpJZ0ZQRisxODVpeldVVHA5SUVnQUFCRWJG
+MHZsZlZNL214VkJYV0lFa2lJU2hNQVJLQ3JUZHExV20zWXgNCiBsMmdBQVRyR3pZRHZqYnFi
+YTUyWVlFeENSRWtwZ0FnQWlvVXhWRXlpWVFCR0h6STRaN2wzcEIzYUFBQUVNbjFya3ZXcy9t
+SW1GZA0KIFZWRmNFa1NCQW1BQ05UNW0zMlhVOXV5NndydUFBQWVYcVJxR3g0WFpOV0s5blI5
+MHQ0OUJNSmlaUklCQ0V3U2lTa1NFa0VwaVlJDQogNWRnOTAwcnZiSXUxQUFBSW1JWnZxWE91
+alovTEJ3bUVQUlJWRXpBQUlqVW9tK2VOdlJkcytxN0JTNTltRXpkT3NJa0FBREM1L3kNCiB3
+TnVmeDhzejdkUjU1blhMV3l2YjFOVmxRU0pnQUFwRWdFb2dFdGI1ajEzajNXcTRrdjJnQUFQ
+UDA4T2VPajdaYTNWSGxrQ1VTVA0KIE1DcWFKaFY0dEw0N2JMN1RrMkJ6M3JHOFlyRjNacS9m
+WnRDYzdLb3JwMWdBQU5iMlMzbm5NNlBsZlMvSmZXdmhrYU5lcWJwWmFyDQogb3o3M052NzNV
+VENaaUFBVXdTbEFxaEJNd01aeHpySEt1dFhzTDlvQkhoenpjRHJwbU1CMXluSmw0bHhqaElp
+UUFUR3V3eG0wV2QNCiAvaTNCeFlBMXpZOWI2NDMyMzlQVFJqNS9zL2xqYWRHWEZkNEFDWWsx
+cmZOSDJ5L0pwdXkzdW54M3NsTlNtL1VkejhkVDBaOTZlZg0KIHBmbkppWUFvRW9rQUJKcHVo
+N0JnTE5vVzZoQmJldHJmMTFLR2VML29kTlZQbm9tSmdnaVlSTVNnVHB1Wm96MzVNWmRnQURG
+WmF6DQogbUw3TGEzc3VuREdpYjVieE9KOU5ZMmVqWUhQUUFGR055dUR0bzNUVzlsbXpQcUdW
+MS9OMDZ2WHo5SEZtcWJ4aDhUcHk3ZkV4ZlENCiBCUUpKZ0FLYXRTaWVmcVBTL3dCSU83Rmw2
+K1ZWTnpUY2RCaXZYZWxTNHlTazVoTUVSSWdURUExUFpOYTJYSHNDbThBQlI2VVRHUA0KIDJ2
+VWR2MDRVU1JyWGh0V2g4VzdJb3JvMUthaWNma05Sei9BRnpmNEhONHZ1cmNKaWJNL2pvZlFj
+UkhWTEJaMmpZeEdYRmhuTkwyDQogdlhodVJaeFFKUUJNU1U4ZDIvUkowZWcwYjNqRy9WWjlM
+Mi9lWXF4VWVzSjVrSmtoRWdoTUNKaENKUzFEWnRhMlhGdUNxMEFFYWwNCiBzODZKM3h0ZTM2
+bHRsK1NRTGE1ZzU3dE41cGxkMnpzSGUxMzMybjVERzlWM1crZU41Wm5rZFFpWU5Jek9ZMGF1
+N1poVHBwMWphYw0KIFozWHNEWDlnMllxRXVvaUtoVGpyM2tEdXk5STg3ZDNyT3g5QjRvd214
+ekhPWkZSRVJJUklBa0NKRVJNQWlZMUxaZGV6MlBaV0tiDQogd0FGajdhMzF4ZDdMcXVRc3B5
+M3BpUEpHMSsyaGVzODd4SGw3ZDEyTmpuVVRiWEVwQUFBTkkzZkFSUHF4K1F6N1FpZFczUEhZ
+ZlQNCiBrMnNYMG9rYUpoT3JFNlB0MXpLSUVrVENCQ0pDUUFKSklJRVNJVENNWlk3QnA5Tisw
+REpzQUExYmFOWXMrNjgvaU1qbTRuVjhwbA0KIElUUG5hMWN6Z3Q4eE90M1orZ1RSWDNURTJ1
+b1RHOGVYSzhMTHJHTTVlN2pwZHh6bnpoMHJGWEdXbzBXT1gxcUs3ZG1heFU2MlhXDQogTmtp
+SXlDVy9EQ1lBRUV4RXdTUVFJbUpSTVRNd0pSSW1KSUJCSkFHbzdkNGN6NDE2enMyTGVIUFFH
+cjdOcnRIZkd6NG5XTTBpMW4NCiBiRWRZRE0rMFJNMnZscFhWZWRzZGJ1dFdPMjlZMWEvbklZ
+bUhxOGlkTVJ0bXI3ZDVIVlBVT1piRDVtbmNOUHozaFRvekk1NkJONg0KIGw2SG5RbUJFd1FK
+aUFSRlZJRXdCS0pKUk1USUFJU0lTSWlxRFVjL2Rham4wYlNNMm9CVFVtTlgyakNXRThiUnJm
+bnNjdE53dHo0DQogWDVyZkg1cnkwVTRMWXZEM21OZHh1ejY5dGVDbXJkRStsMW04TXJ1M3A4
+OWRWV1ZsejF2RzNZak1ZOTRjOWdmLy9FQURFUUFBRUUNCiBBQVFHQVFJR0FnTUJBQUFBQUFN
+QkFnUUZBQVlSTUJBU0V4UWdRQ0V4TWhVaUl6TTFVQ1EwRmlVMlFmL2FBQWdCQVFBQkJRTCtt
+Yw0KIDVHcEx1NHdGUGN6VEs4MHArSWtjOHVSWFJHdzQvOXpaMllZTFpjeVROVmpHdDRRb3hM
+QThPTU9LSEV1NGlSbExtRWk0ZmEyTHNKDQogYTJUY0J2NURNUTdlTEsvczdlNDVGYXo4M0FR
+bnk1QVVqVjBhWG1BYmNTWk1tWGhvMnQ4WG9oRnBBMll2N0c5czExRzFHcHhHcFINCiBFY3h6
+MVJFVHlpeGl6endhOEVOdnN5cktKRndmTWpNRnU3QXVIUzV6OEwxVnh5dXcxeDJZWlBzQjRI
+ZnpXTEh6SEdjb0pRVHB1WA0KIDlqMndtTjAzRitsTFlzZzRFVmhtZXN2MG4zc2VPc3F5bXkx
+UVRkZGhVUmNJeFdMRXVaY1hFRzJpeTlxZkpiRGpLNTV5N3FvaTRqDQoga1BEZlYydzVucTJG
+Z0NFT2RaU3A2c1lqTjV3MFhFQzNrUWxoVEF6QitkN0w3eVo2RDJhNHByWHFyNlZ2ZE5CaFVj
+UW5valVrY3QNCiBUYnNtZVYzTDdTQ0pORytpOXV1S0d4N2tlK3Z3bDFjS1JXTTVmVWMzNW9y
+WHVFOEw2VDNWajZhRWRHUEhLMHdkNjh0Vk01akVZbg0KIHF2YXVLT3c3Mk53bW5TTEdCcnkr
+bTVOVXl2STFEdTVoczFUREdvMVBXY3FOUUp5c08rem5QeEh1Wm9uWGRvMmJEK25xMGhPbmI3
+DQogbDlZOW9GamRQWWV2VmZ4RW1wL1ZDdkpQMjVzcGtPTzhqNVIvVFVqRXdwbTRRaXV4K291
+RDlWckdwb25HT254NnIvM2R1N21kOU0NCiA5SHFhckdxSjBsQVpiSGdWUEJHaklvQjhjMnI4
+OGZsNnRUbGI0RjZpWWgxS1NndW9TWU5WelJZYzV3MVJkZHduM3QrM1p6Rk83ZQ0KIE9Odksx
+ejJ0d2lvdTg1ZVZLK3VQWXJCclkwTnZsbTc3dUNJcE1JaU5UeXI1U3daU0tqa3dZQXpObDBt
+bUg4d1NiUlB2Yjl1eklrDQogTE1sUW9jbWU2VEJnMVVVYWZPN1MxNjJCbU5SalBQTlE5WXly
+cGhqT2JaZTNuYmw2VDFvZkdYRkZLSE9yVFFzTWNqazJDZmUzN2QNCiBtbXFuVE1EWXdJNThs
+Wjg3ZDVISExFQXlNRFl6Q2NRcTRRL05GZTl5azVWUmRVeFNrNk5wNEttcVc5V2pFRVZDSjVs
+KzhhNnMyVQ0KIFJFUy9QMjlZSnZLemR5dURyVHRoNm8xczJTNndtZWNZeElSbzVBVDQwdWpZ
+dUN0TEhmemNoa1hWTU5zQjk3d3UxMHEzVlRaRmVqDQogbFkveU45SXE4MGJhemEvZWQ5dVV4
+NlYyeG1hVjBJQTI4ck5pcms5bk54SWpqa0R0YTBrUEZhL3FRTVhnT2VOQk4xNG4weFBWOWcN
+CiBHUFZQNmMya2puQVJoWXh2RS83Vk8vbnJOck1xODFudWsvYnk0bWxSc1psSjFMUFpPM21I
+VXlPNWc0VkVjZ0JOQXpFcE9hTFNuWQ0KIEdzRkZKTlViR2pid3M0QTU0SkFTUWplQy9US3Bl
+YUZ0WHE2M202WDdLRDVxZGkzL0FKM2F5d1RUeGxQYXlOUjFxdFo0MkVJVTBNDQogcU9XQWZ3
+eTZmb1dPMWQvd0E3dXY4QXN5d3V0UHNYVE9TNzJxTi9UdGZCamU5bko1ellncGdKMFF0Y2I2
+OFh1VWI0eFduQnMzaWENCiBYZTZ2MHlrL1dGc1pyRHlrMm9UdW5hY1pEK2tDcEYwb0pTTkVN
+V1lZUkRJdXFlTWdBNUlyS3VMV3Zhdk1tRitVeTFMNmI5bk1qZQ0KIFd6M3NxUDVaT3hZeGts
+eEdJNWp0bHk4cFdyek40VzM4ZVA3TXhDSWFyWUo3bjE3SERoZVJHdGUyeWc4a3RwRWR3Y3Jt
+UHJwYlprDQogYll6WXo4MjlUUDZWeHM1aXJsWERIY3liQi8yNjkvUEI0VFdkU05YbDZzSERZ
+NG12OGxYUkR5WHpYQ0V3QTROVXlmRWt3SmNSVU0NCiB4Y1YwN3NKUTN0SXp6ektIcTFiRjVt
+YnIzOUVqVjFUWVhGMVZMSGV4eU9UeDEwZGg2YXR5NlhucitNQi9hU2ZPVEtGR1k3cnoxWQ0K
+IHhyR2tUVm1YVi82N0JvTVl1RzFVSnVHTlJqZk9RUHJCQjhKdW1UbUhSSDY5YnM2ZkZ4VEtq
+bVAxWHdJbXJSUDVreGw4M1JtOFRoDQogYWRnenlvbUIyVVYrR2xHN0d1SHk0NDhQdEE0Y2Fi
+SndDSXdidU5GK2svY3N4ZEMxM3NxRjAzTFNuRE1RNHpSQ0p3YzVFVHJKam4NCiBScHNQVlJ2
+aHlHeVkvZzRJMzRXQkhYSDRkR3cySUJ1R3RhM3duVGxpRlk5cEdML2pYdTVtb09qdDZwTDBM
+ZmNsUnh5UjJOUWFIaA0KIENJNUtlQWt4ekdOWTJaV1I1VFZhNEJjVjh4MWVZSldHWnRtRzBv
+eHVmVXliZ1hWaHdqcEppYmR5RHVLNEM2ajNTTHlMR0oxWSs3DQogbVN0R0xFSWFCaVRMSXBY
+UWtzaXhXakRjeHkxRTBlRnI1MklFYXpobFlxcTNZc1phd3hoSzB3OFRZN1pNZWlLcG9OVzd0
+WisycWENCiBwTEYyMWp1dlRWdVdqZFN1M2N5cC9pQlZIQm1CUmtHaWMxMWFHSU1Nam1UbTIz
+b2ptdmlTSzhnN3FQZzF3TlVwNHI0d0xjUzhrUQ0KIDdaRWZielFEbGR2WlpMeVRkMldCc21P
+Q1FTcWUxelNNL0MrUTgyV09HQ29FVXBOMTRSUFZnaGp3V1l4aXVtcnBTUjNnQnQyVWZ1DQog
+b1lWL0x1eGlkdlBUZU1KaDJPbytrdllXbUdVYkd0cEhLc0haSWRqTU9uTngzcnNkNjdDVHNI
+bE9Kd2d4bEk3ZHR3ZHJaN3NoTlINCiBWaCt2QjlHdi9Sc3ZONzBZaHBibjQvOEF2Z256aU5D
+VnlvaUltN21XUDFZVEY1bTdpcHJqS3BlYUg2TmduUXVmT3cvZW1Ta2pKKw0KIEt1MWl6Mm5J
+SmlsSWxjM0RhOFNZWUpqUFFJMUNESUx0cGU3bDBuVHN2UnpFbkxGYjh0OHAvNzl5M1Zpb3VL
+NWY4QU1oZjdYcDVuDQogajhwTjBCT2hZZWpkczU2dUEvcVEvS3hiOHloZFVid3VURU1DcEto
+ZjdYcHo0NlNvZ2RVVGNQOEFESUwrcEM5Q3kvMEtiK004ak0NCiBRakNSaU13djduU0xpR0Fx
+SDlTOEIyMWp1RVRWbVhDZFNyOUMwWFN1cUU1YTdZSithLzliTUVidUlBbmN6TnpLai9BTkww
+TXd2NQ0KIGE0RE9RT3d2eG1MMW5JaXRPSHRadTVsaDNMTTlDeFh1YmJac0Y2Vmw2K2FBY3BO
+ekx5Lzl2dnlDdEFDb1k1eWJOMnpXRkZKMVkzDQogclc0TzRyd3JxemJvZjVuaW01UElzK2Ex
+RWEzWkt6cUNyYklVUU1heWl5SGVzY2ZienR1aS9tZDRraVJQUERhdFZKMi9pTGR6YTgNCiBF
+dGdiSjBHU3g3WHQ5VE1ndW5ZN2VYVzgxdHUzTXdjZUhsNGdIUUpzVWNzRUU1QW0ycmNDbmhW
+MGxKY095RzF0MHNVOEYwR3dITA0KIDlUTmJOWXUzbFZtcGR0eW8xRHp5eVh3NEE0N3BzSmVy
+VnptelEyc0ZKb2E2V3BrMnFkZTFuWmdDcElVWXFIQk9nc2s0aldCQUVSDQogZFU5SE1ET2Fx
+Riszc3ZYUm1XaGRPdTJpa2FJZk1hM2NJVEFzNFRtdWh5UlBRb3JpRzdXSEpiS0JzMnFLRXJW
+YVVVSFdGTnhJQU0NCiA0eG1MVlBZNUNOOUMxVG1yZ2Z0YkwwVjdvb3VoRzJTa2FJYUkrMk1t
+bW5FakVJeWhlbzF4TUd0Wk5hcU9ic1NCb1lHWHpLc2E3aQ0KIE9NR0JKU1ZIdzlxUGJvU29L
+QXJEQzM3UC9Rai9BTFd6UUE3aXcycERuV2N4alVZenhLdmJYbURpWVlVTno0VXJaMTdPN3hN
+YitHDQogVDlkZURrUnlQUWxTVUJXSEZ2WHJ1V3FEKzM0TDhZRTVYdDRFVmNWY1JJY1BadVpU
+Z2hneG14SS9sZS9FUWE2anhid3U3alZzbnUNCiBBN0YyTlh3b2hra1JwUUdTUVZ4SGdMd1ZO
+VS9QVVNCdmFSbTdta25KQWI4SjRTSGFNRzNsYmh6a2FtWG9ITXUxRC96TEx6dFdjOQ0KIGRV
+azZ0Znd0QkxCbHNjajIrYjJvOW1XbnFzSEZ4QzdnVmZLN29IQjdXdmJIZTZxbGJ1WlM5U2I0
+dC9VTGh5bzFLaXRkTWUxTkUyDQogYmMzUWdWb2VoQzg1U2F4c3VyclVjQ2phVVVCWFE1T3hU
+ZmxzK0ZvRllVb1QwSXpoS0F5U0Nza3ZqbTIzdVJyU2s3aVo0R2ZxckcNCiA4clh2UnVLcW5k
+SVZFUnFiVjJ2UEkrbXcvNVpscitNNDNNTlpBSUVwSmNmaTVlVnNTWUdWeHF2NXZnUnFQWUht
+clp2R3hpZHlHcA0KIG05MExhekhLNk1NYmVWbkVwT1JvazVFamhQTGRXMDRvdTdJL05tRFlk
+OXVXUDQ3aXVKNDFycHJISTV2R3poS05ZVWhKVVo3a2EzDQogTHJWZUxqWlJHekkxWEljUm5H
+eEU2T2FNWmtnT3c1VWFrK1FzMmR4ZTdUQ1ZNOTc0VkN4dUJzWU5tNi93RDlINXFjOWNmbVI0
+OHMNCiBmeDNnWVRUQ0M5OVpJNHI5S0g5SlpEM1daUWphSWZGY1hRVmpHRzlDTTRPUkhKRWN0
+Yk8yTXh6dW1OamVWdkI3L21ucU9pdm9FLw0KIDhBUStaUnRLTnhDVTU4dGZ4ZmpLakRsQzZj
+eXN3S3pqUHdzeU9pUHRCcTZGVHlpdWpSeHh4ZUpCdElPc1ZZOGpqWVJVbHg2ZVdzDQogaVA1
+V0V0a09QelBNWENycGlPTTB4OVpWQ2hwNk5qK25iN0V5TXlVQ2pPa1hEN0NJekg0cEJ3bGhE
+ZGhoR1A4QUEwU09iQ1ZVTFUNCiBZbUNUWXZtOUEvaE9SWVV4amtlM3dLUm9oejVickNWcWlZ
+WXJpdWgwUkNLQUl3TTlLK2JveGk4elBJNVdBRTBzMmRodE9GWE1ybw0KIGpjTERpcGpzSWIx
+ZlZDVEFyQ1JEZXh5UFp1WGpPcFYxN3VlRHhPTkRDcFRLTi9oZmdteW5obzVyc1I4dkJhb0FD
+anQ5U3lGMW9ODQogU1hxd1BLMC9XbW9taVRKd0l1T3JZUzhOcWxmaUpBQkZkaDdHa1kxeEtj
+b2lOS3p4ZVJnMEphd2g0ZG1DR21QK1F4c0N2b0w4TGYNCiB3ZGJLN2lGZzFieHJDOExacWdm
+N3NEL0dzL0s3UlJvMmRMbjRpVndnY1Ywd1NXQWVJOGtVbEhOUnpZejNWTXBQbmdZd3d0azVn
+Qw0KIDNCYk93UGg2YTQ2MGR1RWxod01veVlNTFhGV1lNdU80QW5ZbFZyVVNQYkI3WkxkcnNm
+aXFNdzF5UGE5aUViN3Q0eFFxMXlQYjQzDQogdjVtdFJHdHhMbmdqWVF0aktYOEpjYkFxaUdQ
+QXhzRW1KUUd5QVFMUmtKa203a0h3NFNsZXF0RzAweFZ3dXJsNHhuSzhBaXJEbE0NCiBjajJZ
+cUJETEkvb1RqYVlWTVJ6VytOMngzVFc2aTZXRXVjOEZiV2pBM3dseXd4V1NMT1RKd3dUVVhC
+U0lOaFN1S3ZneGlrZXh2Sw0KIHh5Y3pjdkhWUjJKdTNoMUllakMvb3JZYXg1STNvUWZpaUlt
+TDM1NE9jaldtcytaUW5sU1FkL05WNkMxZllPVk1RSEwxY1dDcjF2DQogQWJIRWRIQ2dXOElS
+T2phdlg4U25mMFpXSVVkYzlZa255dG96cEVaTEUvSzJISWx1RUpnVzVnamRBcmROTUVHMGpS
+aVlQaEpBaGsNCiBJSjQ4YTQrdUJSOWNNY0lhZFllT3VMSGNDeEtLSjQ2aEdKWCtQLy9FQUNV
+UkFBRUVBUU1FQXdFQkFBQUFBQUFBQUFFQUFnTVJJQg0KIEFoTVFRU01FRVRNbEZBSXYvYUFB
+Z0JBd0VCUHdIK0licGtEanluUnNZTjBUZkNEU1Y4UlJhUi9TQmFpaXJkZWs5am5sTmdBUWFB
+DQogclVzdnBIeUNFbERwMThUVjhUVVlBblFJc0l4cFF4MW5KQ2J0RVY0bVJseWJFRzVsb0tr
+Zy9FUVJyQ3l6ZmhraXZkRVY0STR1NUENCiBWNHBZL2FxdElXOW84VTdLM3pqajdrRzlvOGNq
+NkNKdE1GbER4VGovT1RSM0dreHZhUEFYZ0l6b3psZk9VNTVkcEEzZlV5VXZrLw0KIFVIQTha
+Uy9YS09tYmxNbDdqbkkvdFRuWGdOMUhEK29iYXVhaHVpRUhFYzR5L1hJa2xSdDdSbHdwVFp4
+aGo5bkR1S0QvQU5RVnJsDQogWDJwcmc3V1g2NU41QXpkd2p6Z3dXVUJRd2VQYTVDRGQwUXUr
+Z2krMHg5Rk1lSGFPNFI1eGkrMmJ1TVl2dGszUjVvSTZzY1dwa2cNCiBjcVVyYU9NZjJ6S2Rz
+Y0JzVXc5d3g5NlRZdGQybE1rVXpMRm80Tk5HMDNqT1lVY1lwTzFBMk5RVTduU1Z1RkprZjZw
+ZHVFMmNnSg0KIHp1N0dJMjNQcUJreVR0VEhoeUpSZFJUeGU2QnRIZEdKZkVVSVVJd0Z3cFJZ
+ejZkM3JPWVczTnJpRTE1Y3RrMTFCWG1RbkNqV1VaDQogcHk1eUlzSjRvNXhPclM2eXBkdjRt
+eGx5NmlJczNPUUtoZFl6bkcvZzdpb2p2aFY4S1BwcjNjaDB6QWowN0U2U09FS2VZeW04K24N
+CiBkUno2Z2VGdk9IVC9kZFZMSXgxQlF6dWRFU25kVkorcHppNzdlQnBvcGpyYmVVb3R2aENi
+ckc3dGNDdTlqK1U0c1l3aHFQaTZkLw0KIHJJL1ZPNThBNVRjSlRzcjhjVHUxMlhwU2Zid1Jp
+empJTEhsaU5qS1g3ZUNMakVwNG8rVHB6azg3NUFFcjR0azEzYWdieG1DWTBPDQogQ2ZIWGk2
+Zm5GNW9JNHRiM0pyQTNTVnRicU45WXZGaE5ORmNoU1Ivbmg2Zm5XMWE2aCtJRnBqYUdzZ3Nh
+UnZ4bEZGUlA4QVdra2YNCiB2d2RPTkNtN215cFpBMGJKeHZHRnQ0SGpRR2sxMTRUTGhNZGFP
+NmtaV1FDamIyalI3aytZRFlJdXZLRWJZRkhTTjFhMnBqbzExSQ0KIEd3aUxUMjFqQkhadlNT
+YXVFWEh3UThZRTBqcXlSZHdUbm9tOVluK3RKRzJFUldyRzJnV3hoUG52aEUzNFlUdGdUYStG
+ZkNpd2pTDQogOFJ5aHBLelVHa1RmamhkUnd2ZGJsVUJwSXo4MXRXclFqdmRBMXN1NU9GK2Ni
+Rk1OalgydTYrRjJxazk0cE9jclY2d1A5TDJxL2gNCiBpZjYxcFhSWEtlU0ZkNjBxMFpzVTNY
+LzhRQUpSRUFBZ0VEQlFBREFRRUJBUUFBQUFBQUFRSUFBeEVnRUJJaE1ERUVFMEVpUUZGaA0K
+IC85b0FDQUVDQVFFL0FmOEFHRUpoWGJyYWVkMTliOUtyb1FURnB5Mmp0ZnRXbVRCU0Urc1Q2
+eFBxRU5LRUVaSXViSmJyVkMwVkFNDQogeXQ0MU8yQ0MvU3k5S0plQVc2blRXbU9weGJOVXZB
+TGRidGFFeGVUQjFWUE12VEZGaDBGZ0lhMCswbWJ6R2ErbEZkV3I3ZllQa3ENCiBZR0RaVlBN
+bC9rUlh2bTdiWXhKeFNuUE5haWJoQ0xHQmlKVHIvd0RZT2NIOHpRV0dYa3FHNXhwcFBOWHFN
+aGlWd2ZaWEZqclNxbA0KIFlHM2F2NWtQWVBNbTh4SHNHRlZMaWNnd3R1OWdtM21BV2l2YUsx
+OUd5SHViZVlwN2xXSDlhS01GWXJFZThNYmc0ajNNdys0cnlNDQogZmtEK3RFeEJ0RXFBeDF2
+ait3WnNPY1VhMEhPb00rVU5FT0xOS1UzbkpEblVIT1N0YUJydzhld3VMOFNzbTVidyt5OER6
+ZlBzaGINCiBTbHdjMFA1blVIR1k0bFdvVEFEN0VyV0ZqS3RqNWw1QkZOeGtPSitaSG9xTGVm
+K1MyZDVlVXNqRVBHYmpubzJ5b09NYlMwdEZTOA0KIFZiWm9iSE9wMHRnSUxTM00yaVc2Vk44
+bTVIU1lkUkxSUWV1bWNqNTAva09DZTlnNE9mNzBWRGJGUGUxRHhsKzVreHpmSlc0Z1BYDQog
+VHkvY2lRSWFrdGVIakZEQ2JSS2wrcFBjV05oa3pXak51MFF4aGlEYVd1SjVFcWRLZTYzMGM0
+azJqdGM2cWRIR0tHOFpkS2RUODYNCiBLZW81NWp0bFZiQWFrV3dwNk1zdktiNW9MRFJqekMx
+dk02dnVBMVlRNkVSTkdGNGVJRGFJMThVRnpvenkzUlY5d0hPRExOc1ZZQg0KIGJWaG9qV2dO
+OWZaY0xHcVg2cXc1d0UzemZBM1FkS2I5OVVjZEN0cGFiVFByTVpiUXZhRVhsb0Ridkl2Q0xI
+QzAzUzhWVEFrcFVpDQogNXNKUytJcWV6YXMrVmJkeEtxL3N2eEwvQU9HcXVIdWlLREVzclJ6
+dU0rTFdWT0REOGhCK3l0OHpkL0t5L3dEMlB5SWRmLy9FQUUNCiBVUUFBRUNBZ1lHQndVR0Jn
+RURCUUFBQUFFQ0F3QVJCQkloTURGUkVDQWlNa0ZoRXlNelFFSlNjUlJpZ2FHeFEzS0NrY0hS
+QlNRMA0KIFVGUHdraFZqazRPaXN1SHgvOW9BQ0FFQkFBWS9BdjdOTlJBR2NTUjFxOGt4MVlT
+MG41eDFsSldZcTBkUzdNVndHa21mRW5uL0FIDQogcmEybk9DWTZ4WlEzNVJGZzBkRzNZMk41
+VUJ0a1M1NXh5aVNsMWxaSmpxS1BabVRGamlVL0NPMVFmd3gxeklXTXhBQVhVWGtyKzUNCiBs
+aWlTVTV4VmxCV3MxbG5FNlUwZHJFNG1BM1hDQW5uQkZFVDBpc3lMSTY5ZFZQbFRoRmcxYXFF
+bGF2ZGdCeVFaeVhiL0FIRTBXaQ0KIG5iOFNod2pubnFLVXk1VkppYnppM1B2R0xKYTNSTmJL
+UnZLaVRTTGZNY2U5ZGM4a2Vsc1NvektsY3poRm5SdGZkamJwU28ybmx4DQogMnE0MkgxaUxL
+UlAxanJHMjFwaVQ2Vk5jNG0wNmxWNzBMVnJ5L2tJdHRVY1RlcWJmVEpLanZ4V2FVRkRsM2lx
+MTF6bVNZdGNMS00NCiBrR0puYU9adWJRREUybHFiVm1reWlUdlhvK2NTU3VxdnlxdWxPcjRj
+TTRVKzlhczMxc1ZxTXNqTlBDS2l0aDd5azQ5MW02cTNnaw0KIGNZSW4wYlBsRVdYMlI1UlVl
+NjFuNWlLektwOHNyam9VSHFtOGVaN2pOSmtyZ1k5bnBVZzV3T2ZjeXpSWkxkekJzRUZ4OVZk
+WjRuDQogdVhTMFpSUXI2eDBic20zOHA0Nnl6NDFiS1l0eE5wN2xaWW9XZ3gwTDNiSStmY0xZ
+TkdvUjVLWEhQdWdVa3lVTFFZNkNrbVR5ZU8NCiBlcjBZN05yNjkwUSszdkpOdk9FdUozVGZt
+alVSV3g0bGd4SWQyQ2traFl3TVNYMnlkNGFWdXE4SWlzcTFTdTZTaGRIWHZObXowdg0KIHZa
+S01yYU8rckx2RXpoSFNNS3FSdFVnL2tJbXRmU3B5SUVNdHRUU1ZHYXU3SkgrUVN2ZWpidGZY
+aHlncVZhczJrOTQ5MGFpamwzDQogYWpyNTNpblhNQkNxUS92SDVkMHRWSEdMRzFHTEdseGEy
+cE04OVVuTTkyWisrTHpva2RpMGZ6UGNnbEFLMTVKaVpIUXA1NHgvTVANCiBMWDZSMkNGYzFD
+TmhwSTlCcG9nKzkrbXBKUHhNU0dHcmpaeWdPSXBjL2hoR3pTdmxGZ0RpWWs4MnBzOHhldGZm
+RUM2NkZvOWE3Wg0KIEhPTFRGbDlNeE1kV3g1czQ2cHNWdk1kZWgvai9BRTA1Sml6WEIreFdi
+UkFPZWlUaUVxSE9DcWhxcSs2WTZOOU5WZDIxOThRTHBkDQogSmN3NFJKb1ZXL09ZcnVwNlow
+NEJjS1ZJSnJHZFVjTDdwSExLT2svbkFTa1NTTEFMaHB6eUg2eGJHM2h3dVpSMGF0OXN5MUty
+cVoNCiB3VkltNHg5SXN1V3Z2aUJkQjE3Wm93NFp3RW9TRXBFS2QrelRZa1h5R0VieXpDV214
+Skl1VnBkMmlzU1NPY0FyL0xYSWFRVlN4aQ0KIFRnS2ZXTE5GWGc2TldSd2hWSW91ekxlVEhP
+NGIrOElUNlhVaERoR0t0a1FCeHZuWHpnMktvOWJrazRDQzZyc3h1aTQ2Um0xUGlTDQogZU1C
+VlZLa25FWlFWVVZSUXJ5OElxVWhGVTU1d3k0bmdxSmpBNkZVWllLRmVFbnhhWCtZaGxiZXk4
+RVFXbmJGalhCNXd5YzBpN28NCiB6UEJSbmZsei9JcTU2TkoybkRLQUxrQW5xbkxORlI1SVVQ
+cEZadmJaLytNTUs5d2FPbVIyck8yRERUbVkwR2owTk5hMjFmaGdDbA0KIFVoYXBlRkJxaUpJ
+RlJ6Z3ZqSFEwa1ZWL1hXTVVia2dENVhiWThvdmpESHgrdHloc2ZacHUyMW5ld09nZzJpS2lN
+SjZIaHdLREcyDQogWlZTUVB6aXZTWm9aOExlZnJBU2dTQTAxVjJLOEtzbzZLa0NXU3M5WlRY
+RkNydHdaSkgwdmxReDZYTksrSDBGMjh4a2EycTZWR1ENCiBxbUE3U2JlS0VINjYxUjRlaXVJ
+am9uL3dxejFWdG5kZCt2OEFwdTN2dWo2WHl2U0dmVS9XNWVKOFlGM1Yvd0FnMVpmWU1HM21y
+Lw0KIFJjRnAwZi9VVkhRUzBkMWVvMjhuZVFad2h4Sm1GQzZjT2FSZnVJOGlybG1rajdodTZP
+djRhaTFaUTNQZVdLNnZXQ3R4VlZJeEpqDQogbzVxRXpLWmdFYXhROGtLU2M0bUp1VWZ6WlJN
+YVZVTnc4MFhTRmVZWDlJYXoycmxiU3VNS2Fjc1dneU4wMHJKVUE1alM1OFByQ2MNCiBvY1Mx
+YWNvQ1VwTll3MGxlOEU2NVNzQWlGLzlPQldrV3JHVVpIUWwxdmZSQ1hFNCtJWkc1b3p1Umxm
+dC84QWNGVzY5cllHMG5mQQ0KIDR4TVhKT1VVYys0UHBwY1RESzgwRFJYRFNLL21sYnJ6TUZx
+aTdMUGlkejlJcW9FZ0lXOHBSUzhwZGlvMm15NDM1a2lMVCtjVENxDQogeks5NFRnTFFacE53
+cytUYWdHK2JlR0tEQUl3dVpIakJwRkZIVm5lUmxGbXRWT2d3bEoza21Xb3VqdVdOck5adjhB
+YjZYRTNGU2kNCiBidlZVZmdqaXFLcUJJUW9RT1JNV3gxaktURmpDZnpNQktSSUM0VzJjRkNV
+RktzUWI0dzBlSXNOMmFSUVJieGJFVlZDcXNZZzZ2TQ0KIFJ6R2h4azRPV2pVcXE5UVJ3NXhW
+Y1QwN1F3V01ZdGREWnljMlRHeXRKK09qYnBEU2ZWWWlUU1hIVDdpWmo4NDJRbWpJL014WE5a
+DQogeDN6cnRPcFNxTWNVcm45TDExUEJlMEw5K2o1R3RlVms5Vzk1aEhSMHBNajVzOU5zYnFq
+RlljY2RDWFc5OUJuQ1hFWWNkWGFRay8NCiBDTncvOEFNeHVyL3dESXI5NHNiSHh0alpBR28z
+WGJQUW5GZVVCYUROSnRoQ3ZDK21SOWIxbWtBWWJKdjJqNFYyRzlMYnlheVRCWA0KIFI1dXNa
+Y1JCUDFqcDN4MVEzUm5Fa3BBR1VHYUFGK1lZd3BoNnhRK2VqTmhXTUJiYWdvRzhVaFlta3dH
+MTdWRVVkaytXT2tSYXRCDQogQzB5aHQxUGlUZVBJNHluQ2I1RG5rVUREYm5tU0RmSmZZMkt4
+a3BNTklIQkloYmY4UEc2TnB3NENCU20zd29ud0VZeFhVT2pmVFkNCiBZMkZJV21KRmljQ29u
+WU9LVEFtSkhqY3BYVUtrenQ1UUZvTXdkQzIxY1JaQmJjeGJOUXcvUTE3cE5kdjB2SlErMGND
+YXd2akNVbg0KIGVRWlh6YStDRmcvT0VFWUZNNGZTeWtDWW5aRGRYaFlZY2RRTnRlTVNtSjVY
+aFNvVEJzTTRMbEIyMlR2Tm1KUGhUU3NsQXhWb2FWDQogUEw5SVVYZTBXYXhoRkphN1ZnMXZo
+Q0hVbkc4YXBJKzZxL2ZZNExGYStXMDVnb1I3TlRleDhEa1RCQlNxQzVSM2xOcEptVVFWdW4N
+CiAwR2NLcHRJTnJtNk1oZlRXMGcrcVkyRUlUNkppUXRQS0xVQ1VLcm14U3BwR1Y0NDF4SXM5
+WWtjUlpmTU84QWJiOG9kU0ZKeWdxbw0KIE5JVzBjdUVmMWlmV1VMY3BLeSs5Vk5waEtWWW9O
+VDhycTFVYktTWXNSRzdGcUlra1ZSb3JyM0JmS2x1T0NzTDQ4b2FkNGtkeXByDQogQjRxcnAr
+TTdpYWpJUkp1d1p4UFZrTFlyUFdES0pEQys2VkkyMnJZQnZsdG5GQys1VVYzL0tDZy9LNEhw
+QXNuT054TW9DU20yRW8NCiB3bkcrcUxTcFhyR3drZHdLVGdSS0htRDRWYlBwZkxiL0FNZzdr
+Mjl4YmNCZ0hsci9BQWhPaHYxaEhkRzZVa2U2cStvN3ZPWGNxUg0KIHlRVERTdVd1bGNTNHhJ
+b01JTWpJUTMzUnhzOFJaNndVbkZObDdXOHBoaFdhQjlPNDBqN2hoajAvWFhJTVdiUWdJSU5a
+WGhqczEvDQogbENWRkpDUjNYcEJ1T2k5UHBEYzhVMmR4cEp5UVlaRnpSazhBZ242OTNVVWpi
+UmFJQnZhUWp5cS9mdUtrakZ6WWhDY2hjc2U4M0wNCiA2OTNJT0JoNWs0VG1uMHZhUWpPM3VO
+SFlHRFcycjVYVkJlNFRxOTRZcEE5RlhxaDduY0Z1ck1na1E3U25kOTh6SEljTHF1TjVzaA0K
+IFEvT0dsK1pJUGQza2NaVEhyRnVJdlB3SHVBb2pYWXQydUg5UHBBQ2NCaGRMUWZFSlFxajBn
+bXUyb2dXYzRxb2Myc2ozZDluSXp2DQogUHdYNm1LQ2VqYVFaTGUvYURScVJ1T0dhWGNKM2lI
+Q09yZkZYME1iYUFGOEZqRVFhSFQ3YXVEbktLeUNGSnpIZFczQmdzVzNpMVoNCiBJbGZMVFg2
+MVFra0NFSmEzMDc0NHpndHVmQTVRYUZTKzBUdUs4d3UxVk8wVHRKOVliZXp4aGxTMDFrUEpx
+L1NLOUFWTnZpMG8vUw0KIENtMUR5ZDVCN28wc2NGM2xLWHpBK3Q0U29nQVp3V3Y0ZUxCdk9u
+QVJYVVM0OGNWcWoybWg3RklIL3VpM1pkVFlwRVdiTHlMVUt5DQogTUZwNFZhUWl4U2J0K2ht
+eEt1c1IvdndqcFcrMFpOY1FoeE9CZ0xCcVBEQlk0UUdQNGlLcXVEdkJVQWpEdVQyWXQrY0o5
+THBSZ0sNCiBPS3pPN0szREpJeE1jV3FIODFRRXRwQUEwcHB6R0FzY0dZaERpTjFRbUk5c290
+ajdlUHZDRXVJNDRqSTNURk1SaTJaSzlEL3dEcw0KIEE0cFVKd3VncjNUdHQrbitqUVVPcHJD
+S2o1TGxESjJWY1VRRklNd2U0dmpsQXVrTkozbG1VTnRqd3BBdWl0WmtrY1lydVRUUkVuDQog
+WVQ1b3N3MUZKT0JoK2hyK3lWczh4bzlvYkg4czZlczkwNXdDbkEzSzJ6NGhLRlVkZSt3YXY3
+UWw1cnQyYlJ6aEsvRjRoa2RCU28NCiBUQmlzMEN1aUhlVDVZRGpTcHBOdmNIdlNCZGRNZHhx
+ejF1K2hRZjVWcmZQbWdKU0pKR0d0Um5lRHc2UDZhRk51Q2FWQ1VHZ3ZtWQ0KIE5yU3M3cHRZ
+M0tSc24xMGRNbittZU8yTWpFeG9rb1RFZEt6TlZFSjIwZVdBNDJacE4rK2VYNnduMDFpVHBD
+RVdyVndoTFkzc1RkDQogQnBtMTl5d0NFdEo0Y2M5ZExveGJXRlFrOHRFaFk0amFRWTI3SFVX
+TEhPNUtrN3plMkliY0dDaEMybmQxVUdoVW5mVHVIekRUSTQNCiBSWFJNME5lSThzQmFETkp2
+Z2p6cWxBMVpjVEFHaVpqMnQ4ZmNCdTNxVWV6UnNJNVhENDl3d3l2TWFVMDFyczFtVG8vWDZ3
+Rkp3Tg0KIHdVbkF3cHBXTFM2dWdPTldVaHUxSmkyeHhOaWhrZEpTc1RTZUVCbFpKb3Joa2cr
+VythWkdDUk02MCtBMFRNQjk4U1pUZ000QUdIDQogQzZlSTNpS28rTU5JNVR1SFI3c01jcC9Y
+U3B0WTJWQ1JoZEJld0ZyWnpGelQyK2RiU0thd05nOXNQMStzQmFUTUhTcHR6QXg3RFMNCiBq
+YU96VjVoZUZSd0FoNTgrSTZ0Uk1Tak04SUQxTW1HL0NpQUVnQVpDN29UUEJTNW40U3VWUVB2
+SFVDMnJIMjdVbUF2eGVJWkhVSw0KIGpnSVBSS3RHSTAwMCs2TkpTc1RTY1JIc3JoNmhkclov
+VFUyZGwxTnFWWlFVdWJMeUxGaTc2Sk8rN3NpQU5UbkZaZU1WYU1na2VZDQogeFhjNngzTzla
+SGxibmNtUHhuVkZLYkhVT25yQmx6Z0tUZ2RUMnVoMk9wdEl6aExxZU1GU2pKUEV3OVNsaVNu
+VnpIcHFGczcyS1QNCiBsQ21YckgyZGxYNzZpYWRSOFU5b014Q1hXek5LcmtrNFF0ejdORmlk
+VENhamdJQ3VqeHo0UUYwdFpjT1hDS3JhUWxJeXZ2L1JINg0KIDNIOHlTN1IxSGY4QUxGWkJt
+a2ljZmlPcXB0d1RTb1NNZXpVZzlRZXpjUDAxYVN6UFpRcWNHaTBic2dldGM0ZWtKUWdTU05W
+dW5zDQogakR0UU9JaEtrNEVhU0NMREhRTC9wbmR3NUhMNjNQc3pSNnh6SGtJQTAxVWlhandF
+ZFBTclh1QTh2Y1U4MnYzdUNoWW1EQmJWYlINCiBGN3A4c0pPWkoxaTI4SnBNU2thVFJoeDRp
+TFZsQnlVREUrbFRLS3RIU3Q1WHVpRnFwSytpYmNNMUlHTUJEU2FxZFpTRjJwVUpHSA0KIHFF
+dndXbzlOUW9PT0lPVVZYYkgyN0ZEWExpL2dNNFUrN2F0V2pHS2xHUWVhK0Fpc3FTM3VLaU81
+VU56elRUY3FhY0ZoNDVRcWcwDQogZ2hDMFdwSndJamFwVEkvR0kvcTJmK1lpeWxzZitRUnNM
+U3IwT3AxckxhL1ZNNG43TTEvd0VTYlFsQTVDNW8xTVRpbFZVK21xaW0NCiBON2gyWFAzZ0tT
+YkNOVlMxbVNSRmY3Rk82TkZWaEJXcmxGZW1xcWp5Q0FocEFTTzVzUC93Q0p3ZlVRRlo2NWNj
+TWtpSnNTWVo0RQ0KIDRtSzc2bE9yekpsRmpQemkxdEVka240R0owZGJqU3hrWUNQNGlLN1or
+MlQrc0JTRE1HOWZITDlZWVZtZ0hVVTJyQWlVT1VKN2ZhDQogdzVwMVV0TUk2bmpiakVuRkpi
+SEsyQWFRNHAweFZaYlNnY2gzVjVIdW1VTms3dzJUOE5laVVkVzRvelA1UklZUkphcHI4aWNZ
+NnANCiB2MmRCNHF4aitacFRybnhsQkxTYmM5QlNzVFNZbUt5NkVyaDVJQzIxQlNUeEd0TnhT
+VWptWlJiU1dqNkxCaXpwRmVpSTdOMy9BSQ0KIG1KRlpRZmVFbzMxSDBURHFHbEtya1dUVEtH
+VXRyU3Fxa0N3NnJWT2EzbXp0RE1kK3BORzRIYlQvdngxMnFVakZvMnhWb2JmUnA0DQogclZG
+WlhXTzhWcTFOdHdDQ1dWMWdJa29URUJwUm5SSERzbnltTE5FM0ZoSWlyUmtxZVY4bzJWcFpH
+U1JPS3o3cWwrcGl5Mk1KUnMNCiBtQXRBSFNKd2dLRGFBc2J3cXhhMmo4b0x0RDZwNFdpUnNN
+QTBoVlYwV0ZNZFZSMzFmQWZ2SFhVZDVIT3o5NENrOFlLVmlZT0k3OA0KIHpURVl0bVN2U0Fw
+T0IxcU8xd1d1MkFCWUJva1ZWbDhFQzB4MWJZWWJ6VmpIODFTblhJN0lMKzlFbTBoSTVhRk5y
+d01MbzFOVWF6DQogUmtrNDFoQkZGYjZKSG1WakZaOVNuVis5RnNnSWsxWnppYWpQVFpqQ1Nj
+WUQ2TncyTDlJQ2tteFdpbFBGQUpyeUIvc1NtMUN4VmsNCiBMb2p2YU0yZkRXYWVSOWtxWmdW
+UTRvNUFRVklUMERlRnVKaExydTIrUk1sWERWclBMbEVtQjBMZVp4aXNyYlZtZEZZeGJobHFo
+SQ0KIGdBUVFlTUxveWphM2g2UTZ2alZzOVlicmI2dG8vMk51bk4rSFpjSEtBdE5xVHJHVVVV
+Zjl6Uk5SQUhPQ21oTktlVjV1QWhUeTZVDQogMVIwcE1pSllmT0NscDZ1anp5bEZkMVJXdk13
+bE9DWXE4SmFBT0dySk1jK0owdEtuSkt4VkpoS1VmMDdKbVQ1ai9aRklWZ29TaHkNCiBoTzRE
+YWI5UDhBVHI5WDJpRFdUQUFvYmhkK1U0cjA5eVNPRFNJazJrSkhLQThuc1ZxMmh6aXpSSlFu
+R3dOSHZSdEQ0NkxJbTRvQQ0KIFJKSkViNGpmRWI0aXhkb3dsREZRQVRTSjYzLy94QUFxRUFF
+QUFnQUVCQWNCQVFFQkFRQUFBQUFCQUJFaE1VRlJFREJoY1NDQmthDQogR3h3ZkRSUU9IeFVQ
+L2FBQWdCQVFBQlB5SC9BR3NlUlJNdFRRUnM2TjdIcktRd3lRZnRIWDFDVytOVmtSZVR1MXJ1
+NDZUUGg3Zi8NCiBBRXlVSlc1ZWZuTE52RCtkNExoSGViN1RQcjcyejljSFNEekxySEF4d0Va
+TUxPbFpkbFN5eDNwRnNDMnIvSXhpSGFuOGlGYTRXdg0KIHFZOHhnWWE5cHQxeTVkU3Y4QTRy
+V3ZuTHBwN0VwWWNXbTgvWEdidEJzRXBVcTFjUzd5elRLdUgwbHYreWRadk51K0J4SUMwdHBs
+DQogTGdWOEVGWFNuamYrbjEveWp4R3daWFpNRllyTlo4ZjJjUnNXbE01WWtkVmhsRUhUeE1s
+emUzS010NFl2UC9Tb0dNQXhEWUs5b1oNCiBVT21IOUlIUkRjZjFabmg4aitSWnI4NS82c3oy
+TzVIY1U3QWg3ZmlEZnpCWFV3czlvSEZQWEgwbmZoWEt4R0I0ZHpQMlpXWFdFMQ0KIFk0OHNX
+cXpxWnErVUZydlVPTk9xbngvbVFXVURySHg3YVlYM25RYldqNXBWd0N2WHF5cXlLOEp4eTJP
+cE9zOUI4SlZXRFdLL1BHDQogNFNDVFVwbmZrTXhiOU5xdHRFSmZZbk9Cb1dTa1d6dHhRZ0Vt
+SGJtbmp6RnQ1aDZJb0ZvNXJCc0htNTg3RzZkeFF4WU5IUmd3Ky8NCiBJOGhya2EvRHZESXJB
+eS93V0JodUJwRUw4bStjeDFsY3M4T1p0RkxMemtVeVk2eTRmNEhFeHg3d0NFN05IUkRpZ1lY
+OVU4Umk1Rg0KIEY3NGZjV3pGam0vd0RpcmlzZ0lseGg2dUozbW5ncnhuZ1lpcURXWVFndjJW
+SHZBTjU2blYveUs1QzQzbEpVV0MweStOeExhOEt2DQogTmo4VFgvSFlIaU9oR2ZzZkhqNCs4
+MllqMjRuckUvelhNV1VjNEZ4V0hkaTlaVUpTZFZtT3NzVEtMVi95QTZ5WTJ2Z05zSEtzNEUN
+CiA3eDdCZVVOcFdqenY4QXpxVWk3NWxZbXNaMGVsZjFCWWR2UGdtQkVKZEtzcUJTcXIvTG9R
+T3ZJWDZtdk1HOUFVUnhPL3pIU0x0cg0KIFYvMEpScmVzQ2lqS2FkZUcyQXZ6L3dBM2xoOW5t
+TlhzQm11MGZDOGcyZjQxck5JcGdUUjI3RStFcUdzdklsVFU0Umd6Q3ZML0FGDQogSEhUbjRZ
+WmNxd0ZYTEY2UnJES1laZmk0WkZaZjRVS0MwTHVBVHJrL1ZCMHUrWCt3S1hEY00wcTRhUm5T
+cXA4REpXRUJId3k1WXUNCiB1R2dsTGFobS9YLzdnTmxOUng5TG5kT2tRQUlpYm5NL2MzbnQr
+VXZYQU5EWDdsRWVaM21RUnREYlFuTjI2d1ZXZ2w1cU5XZlpCQg0KIHVHSSs4ckRqWEhNY0Jh
+RmNLanVGN3U4TUJvOFNZZEpabHdtY0hXR1RJV2NFajFzeDlZNjd1MVBMT0ppQzlHR09YSy9Z
+M250K1UxDQogdFpMNkg1bGpldmsrVWFaSDQ2M3kwaWljS01nZE9iZFp0RytrcFc1TnYvVkta
+ZzdEeHNZc3dVZlIvSUdMSkZxNE5DR0JnVjA1QksNCiB0VEI2eEZYY002enYzNDVmc3BSdU14
+MUdaZ1ZidkZyWGM1UDVHOFBwOHBFNnpEOTVUTGlPQmhIRmVadFAxODUwbjY0S0xJK05qQQ0K
+IHN0dlpIeXNqVXUzVFo0ODdiNlRIejlFSzUzUEtWdDhTZmYxNFFZQlJTTVZvV0xSWnRBNlRM
+eDVuYjVwWXR4eXFrMGRKYWF2THJIDQogK2M4cFMrL1lEK2NsVjZDMlpvNm0wZVB0bkxaZVFJ
+cGFESDFiVHJUWkZ6WmVXTWVNUXg2UWp4Z3M0VUF6b096ajVCZmVZVi9VNlANCiBSZ1dEY2V2
+anc2SUw3d3hjbCszQ3BqeUxkd1Y3Zit6VG1xazlHYU5LUG9wRGtOWnF4ck1NNzlwMEpPUm5o
+K1k5UU5ZMEdEZUo2Ug0KIHNrZE1leUhyWkwxVnpGcTNGNzBYdzBhd0NZNFl2eENONjNsaEZB
+cmdFWlI1UzZYRVV3QmFMMGNZaXJtT1o1b2pxdVRzOFFzenY3DQogeXlHcitRNUZjZjI3bHpz
+SFpaUnEzZTdrM2N3eE8vNW5iTGsyTzVpU3dGMTgzZ1JkbUNTbXRYTmJjTGJxaGZTQ01OZ2hW
+eFFrR2YNCiBGUFArRUVpS2dPTHk4U3d6VVNQeFZwRHdtMk8wdGI2MTMvQURqWElSTEt0Nk9h
+NVQyOFFnM0h2eVcwN0ZlVmI5WlFiL3hCNGY1Rg0KIGdtdVVRTUMzUnY4QTJCVXJqVVhGWFlo
+VmRSN0luNDhBSTZ5Y2RHWExmdjdPZGo3eVlzMmZkeVcxZ0QwRDY1YWRVbm9MOWVGZHFYDQog
+dXNEeStVRkZCWGljNWVKSEoxaTJRbldzZ2dzeTQ1RExyMm1PV0MrVitnRkhPRm8zSnNuUjZy
+TlkrTi93QVF0aDA1WFJlL1VQOEENCiBmQWFPakNZQjRSZXN4aFNCM0FCQTZCVVFzUkNXVkR3
+TTBZNUM2aXl3WlU5REJzQnZUZ0xCTDNtV2pqWSszelB6eVVhMi9TdWRwTQ0KIFg4dUh0LzdM
+OGR6QitwZzdNQlRCaWZqa3NGb3d3OHJPTnl2UDhBbkFCR2lDTFRTalVqZjdBRmF4TjhCY1BF
+cEltTjVRVTJmWnc3DQogYnoxalZaT1k4RVVwTE9zei9nSHFISm8wVFc3L0FQblB2bDE5ay9r
+UCt5K0Z5K0Y4V2RkSXNtL3pDOXMyNVBmS1B2TjVXLzBjWE0NCiB6Uy9SdjZtOVNIbzFNNVJV
+MkRGZUltU2d6V0wwamt2by9xNFJEbXV2VmpJMkpHa2JZenpqaHU3UkNxTnpCRElxU3ZKMTky
+RzZMWQ0KIGpmSXBneGZ0SCt6cmh6bDEyU0E0dFpja0NJV1lFbHM5TUI5UisxZ3RmVHhiVjFI
+aFFIYVdReFhZYWNYRVIxbHBzVGVybkE4VE95DQogT0RGWGFaZzZKN3JwNndHQTlJZ21hVVM0
+RE1VUWNsOThvdmF2VENJMlI3bjNNdmxnNUFhcENYd2RTcm5VcldwWTdaK2ZEa29hSmgNCiBL
+ZEdZbUQybUlCRllKSis3ZURMK2xMWWNNN2hZR3ErNGFlODc0Y1hwMTBCV2dkeUcyK2tQeXh1
+R2Q2SHNNRkU5MkRLaGFnZDVkWA0KIFhTZmNOZWlkbm9KaFhVRGY4QUZTMys2cWZQanJIc24r
+eVJSek1ENnducCtPYzZ5cXJwL01ENjRISU9EQXpRMU84ZjdBR1VZc3BwDQogY3N6b2o4NENW
+aTRaRTdTellnTjlhS0d6UG5oNjhQV1BjVVN1bnNQM0tuTm0vU0h6Z0ZkdEozNDNNTXUxZnFs
+UmdVU082d3UzUmINCiA5SE03NVJNZk1YMC9NTVM5K2JuTDRhTjlCcWIrTThIbkJvZ3JFaTRH
+T1V2K2tLcmlIa2poV1JCMForTUpkTXRDclErTGVhVlNaNA0KIFBwR2hTM0R0MWdFanNSNWdO
+aTBrYk54WjFMKzlJMk0rZEFYN1hGbUdoYTBheG12MzRLOGVvQXA4bS9xWXN3ckNhYzJwL3dE
+c3lpDQogMzdFNXFZVTRqQUtTQWpnMjFmdkRsb3NWdlVPd2pJc2pIQ01pQmFHVHlpS3k3dFIv
+TVpNZllVdC9FUllMZFpuQnhZcy81RVoweWMNCiBqOWpMeHhWbVFsRXAyY0RiTlE2Tzh0RlUy
+WnY2NDdLbERwWmJQY2g0Y1BFU3ZKS2k0QWIySEg3NTFXNlMzVnVmUXRyeEhJWW9ZaQ0KIGZK
+S0ZFSFJpUzJxeXByK0lJb3cwYk1TTWVRbVNhM0ZqektWQm95VkxtVzNvdTBZSDVlc0pmaWNB
+SFJGQnZ0ejlVcjVkRFdveFQyDQogZ0lrUE0waWovRDRnMldaYzYrR3ZZZjhBMkhnT1RvNVR6
+ZzIxdFJiYk1IR2IxaTYyRThTWHRXc0RWRWVHUnJTOVBUbld3UFFZbGYNCiBWU0pqQWt0aGEw
+WE1nZmliYXVZR3FhRE90N2M2MW1pajZSV1hETGljTDVEMDNtbzYzc2JVL0NLSVlQVi9zYWJh
+TWpMYVo3V1p0aw0KICt1VGxNam9Cd0gwamRnZHJoZmlZT1RIbkFlc2QyWkVBRlNXZGVzTVBF
+K09sR3NFWkg2dWQxUmlsbGJ2Z09QQXk1djNNZE1LVG9rDQogL1hJV2doY1hlK21kbHJ1NHl1
+Tk1zcVZiR2NPWUI2b2JFQVZ6ZEl0NFVCV3BkZmNwWFV2bTBLY2tpNW5IcFJOdjhBRFVtWHdC
+eUUNCiBwYUVXRmREK2crWlVGZ1NPSk5YWnJWZnlOMzNBZnlFMEEvd0dQYWxHeUVuZGJEblkr
+NFhoMngrcHAwaDRMOEJ5S24vWitvK3JCOA0KIGVUTm5ONmdNeVVSZkwrSThLbFNwWE5JMkhG
+L0RYM0RFdzV0UzVudjJmY0c2VEo0YThEaWNxbWFnZVJMaDE4ZHdSaGtzd1preEpjDQogV25h
+NVZTUzhTZksrT1JYTDBtSUd2aTZNR0xBTnJHdk55RE1IM20vYTMwZUY1bncvMG1MY3Q4dkdJ
+ZGo3UmxvN3dXeXdGbFpUR2INCiBYbFJ1dVVLMDVWY2w0VUkxWTgvd0FjMmhzYmdENmVKSG1p
+eHEzMGk2c0Q4dko2K1Q2ZnhLTk9GOHBJbmlPRE1jeHo3L3Vkak9ZbA0KIDRieXpiM3Z4SE9W
+ZmpVTjdJUm1oeWMrMWZRUzg2dkd3VXNDbzJEaS9NTmN6Nm5TS2ZMKzhUblp4Ny9XcDk4b05j
+TnZmRDc0UE9UDQogdzZjSEtVYndWZjByN2czanZ5OVpYdjIvRWZDY29XMXRpd1FuQXpuaXZz
+ZVVqbjdIQitwaUxmd0grUFdNSXdhaXkrMk1KV213ZVoNCiBrL3JMbmlPOWlWdGRvQ0VCQURU
+bEZqVVgxUXU3TkJaYW9LcFBJa1oyL3dBSndPRGxVN0xEenIrOHpMNy9BSzhKNFh3S0YzaFdj
+Yg0KIHhRRGY4QUdiR1UxRldUMXc1WkV2bGEzbGcxOE1lRkF3MkwwbHkyVGZub3Z5aEpZeVN4
+L3dBdVJhajNZL3ptYlJ0K2VuaEhrcW9iDQogb201aHJoaWViNzNEZXg4eGJ4WGRIWmN2cmxt
+R1p0UHplRURCT0RaaFlrMkpyRDdXTGo2bS9tWVVZUUhFWWZxL3g2N2l2dFRCczUNCiBXa3dO
+d0FQaURMOEhlL1NZcFJsVlFreW1BOURlT3NZV1lpUXg2Zm9Zb1JjdFlKQ044ai93QWJTNnhi
+R0Y5ZVYzeW1IVlZPNDRRYw0KIERNbGFBNFI4Y0c1YUZpYy9IN0tsT3o1d0VWckxyL2lOMVlJ
+UFJGZHZLZEFDVk14aSt1UVBHci9yMklrdUZjc3YrZVdzSU8xQWNODQogYmhwVWdQSk1MOS9h
+UGhaKzA0eFFEUkR6RDNtRlZROXdPVlZtSFRyVjlvVHlubzZqQ3F1Wi9EM2NBWUx2bVFkUWxt
+OUIvYVRUcUoNCiAvd0FONGZ0a1Y5dmxZeUFLRzF3djRhVGszMEZsRSsyNkpUL3VZN3Z2QUtV
+UEFBdXhxWWFUYzNWV3ZZSWxpT1V1d2NFNm45TVdLeQ0KIHM1SmkySkNLMjNIdGI4Q1lTS1ll
+alU5TGdWd3lhdXNjRGxEcE4ra3h5UDFldURvUFJOdjdMNTdyOTgrVitrdytzRWRWUDk1ZHA0
+DQogaFJxdkwySms3b0RieGJnUmQ3QndGYTRoaUVzUU5HM3pQbHk1THBZVkhiVDh4TEtqamgx
+Z3kzL2RnQVpIQlNSQ2tZOHhGYkhjOS8NCiBTVWlqWTgvZDZud2dydHZDcVc1VGZnNHBvMktC
+T3NnZDNsWWdwN2JlWWx1cmM4WjBwQjJxZGFBeG1KdHJvSDljV3NEdW4zZlBKeg0KIFcvMEpy
+VnlDQllWRjYxMy9BRHZ4Snh0RkowbVJGOTVlL3dDM2hFOGNKenFTY1RYejlRVXRpdkRnRFlu
+YVRIZ3owSW1yY3RrYjhwDQogYXRjQTFsOFc1cnF3dDlSaCs2ZU1xckpEdlVzWnQrMnVKSTBR
+L2Yrb2Zpd3M1QkMyTk16U1o1YUg3bWxSTkpmWjlUMHVGWU0vOEENCiBSejk1cndOeVNsRXZ2
+S1d2S3UySkJzRXBPWnI4UWxGOWt1UDlqNEhLM0lndWZTbnhIYjBFZSs3djRCbWhnTnVWcXFS
+ZGNFSE5IRg0KIDd1UDN5T3RpUGFVSG9lN2pjSVVYUm1HMWM3VXhyM2ZUa3JSRENQSVByaTJT
+K3Vqc2ltMDdzNGdOWTFobWRZeXdUN3BUMXg5bzh0DQogSGFVcnNSZHVoMktQcncwR1k0TlE2
+M3JLVXpjaHJBb3pDNnd5QXlDZ242dVVnT05INkpRQUFaSElOQjFJOEhiNVBCamh2ZjhBYjMN
+CiBnUXdPR3JzZUE4a0ZzQnh5MG1aeHhHeW9lOXc0SHNEU0ZpUzZRNDd5UHk0OFNLMW10Z3FV
+RnQyZjNmbDRzWjRkTW41bnVSNE1xeg0KIFlCQmRzZnROenNZQVNuYld2a2RvZE5KMnk1YXNj
+UnFkNy9uSjlsTTN3akpyUXRWLzE3Uk5yR3g0NTZZTWJlUm1mMVR2eWphVVlqDQogeUNYeUQ4
+Z0gxRGpmcURBWnFZdHFyMzArRDRDODJuODd5Z3dMNUtsMEMxMmw3TVp4N004L1loeHdZSk5E
+cXlselVOcTdvcUVjY08NCiBDR3NoQWFPY3Nuak92OWowN3R2UiszZ2FTcUppSkJpZC9tOEpl
+WEFaYUU3YzYyOS9TR1JXUFdmUEFXaEJOczR6Y3hMMElsYmxCdw0KIEd6Zi9rQ21HZ1BCa2pB
+QzlkQi93RFkxVjJoT0lsa0tSMUlyUnZ0c3BIMXlLRExTdnh0QUp5UGZqUUVaU0NDT3BiaC9o
+WUxPUU5RDQogTXJsNWQzVy9NWjVVSHJCNFh4K0tqSmEyd0NWKzBwalZJVDhTMDVCai9wVDd0
+UmoxeVpiei93Q3djNGJhK0ZoS1dBYmtmNjE5NVUNCiAvZmdhNnZPa3p0aTk4K05kaGF6ODIw
+c0NyMTA0QUxhRzh2SWwxUkdweXVqZG9mNGZ5L2wvZVNITFNwZHQ0UGl5bFhSZThTcHJaLw0K
+IHRQdzMzTSt2US9wRGJJN25nekhPbEFhcitzT2xVYXByeUtCT1M5dzUrZFFwTEtwOEF2WWdK
+Z0pmOVM0OWFIbytEV0VHRGF4Z3NGDQogVDcvYUlMWENXMmFEZlZqajV3V1JNanhuTTZuTjdN
+Qkt5QStPdFU4V2Rpc3JMM2pYZDRmQ3BrdDVwK1daQnU4YVMxNnYweFF4eUMNCiA2WDUzTE9n
+b0Q0ZjhoNGNVSnJETG1Vam5SUFJQM2NIZ0dlMVEwV0piZHpENVBEa1pBNU5yL3dESVVhYnYv
+U2JDUVpIM1BKSUJmRA0KIDhUVC9FTlhhK2NyQ0kwd1VOcko5ZUxXRzlOa3Q2VDlRQXBCaGhB
+T2pFdGVVeklkZFU5cGlqZlJEN2xDUjVyT01JWVdrbThWTTY5DQogenBuNndhQVdKWStHNTF5
+VVFuNmlqck02OTRxYmY1dWtYb25NKzVBME9yWkpXTTR0V3hJcjJVQmFhOEpjdUNMM2ZRLzIv
+Yy9EL0MNCiAvZnhONGYxMyt4dys0Ymkvc3ZtTTUvdUgweW54d1F6VHZ3bmpoWVJTSWlMZ2py
+Rkhic2REdGxFQVZnOEtCcHF4SjBZUS9xWjRIUQ0KIEwzSmpsK3RvZWhGK0E3Q1hxdU8wd0FI
+NmhvUWxxckh2S09MbFRCL1hBc2QzTXVvemNXYUZaU3FXeld4c1pnK3lWUkNMejBJaGlCDQog
+TWtxdU92OXdCYjU4eC9kUm9CS3l2RW0wd1lOd3grb0tZQUFOQ2IvTUlwNU9KSHltT2o5WDZ4
+TmhPdzBIekNUMEFaMDZjZUFiaWUNCiBicDFtQ1pZWXN3OXFsZWlZWjMwbGc1MVZ5K3IwY1lR
+cHVqc05kWVljTXNjQXhsMGRwbU9OUHkrWmY4Q3hKMTB6bUltbEdsZjA0Tw0KIE9lVTdaZjd4
+UkpXR001alU2NlB2eFVvdkR0ekorWmppQmxiK1plVjA2dStjTmhPeEswN1RialNoc0RON1Mx
+Nkp5eDd4eTZ1Yld6DQogNGlKLzdNYWNIaFp3Ym03RUFsUUVISVFwbGdKMTcyUDNCc2F1UEt3
+akdPSStZcjkvL0NZR3JWQWEzejkyTitCc2QvRmhoVnN3Ti8NCiA2ck40Mk85VlJFeFVZakR6
+dFppNm92YWlxN0l1L2t6SEk0NWdKZUJCMUx0ZWZCQ01tVTA4RktyNjdTa0dMOHppU0FZOTE5
+VE4xWA0KIG1WbVh0N3dLTU1Odi9oakhiRkVxelYwYlplTlpzWS94NngrVkprZkpCV0k4UlI1
+NVNvLzZDT3NRMWVXSC9rd0dHdEE0WUVUNG1PDQogMDNybndBbVF5WXVubHBXRGtDc1B0cjFn
+VU1pVVV1SC9WeFpHTVVqVlUxYThYLzJnQU1Bd0VBQWdBREFBQUFFRW1EVEFIT3B2SksNCiBM
+MFd3OTFrL21vWHZKODUxTm1rKzE0a3YxRk54VG8yZ0xDeHpWcU5HRk9GWVVQdmQwdXRqZjBx
+b1phRnVZc3M5aGQ1MFJ4Q1lRZw0KIEFBQUZXb3N2ZzF5MVBuYjJnQUFBQUFBN0xaZFd3d0FB
+QUFBQU5aKys5L3BoR2F3UVFBQUFBQUFBRUo0L3dBQUFBQUFBQWhSdnkvDQogbnNFSXdBQUFB
+QUFBQUFsNVJvd0FBUUFRQUFBSkJQb2toZzZBQUFBQUFBREZVSUgvQUJRQUJnUWNBQUFBQVNF
+S0pxMHFNQUFBQUENCiBDOFJ1bkQ5OG9BRkdicWtzSUFCVXN2WG5VdWdBQUFBQVp6OFZCWlVF
+QUJRUFB4V1lvQUNkblhjTU1LTUFBQUFBZmNNZTBvSUVBRA0KIFd2N1VmTllJQkxJMVV2OEFt
+QUFBQUFBbmVTdTIrQUFBQVRzKzliU3c5TlJEcmxOcnFJQUFBQUFSWVBlK0dyQkFBQU44SjJV
+QURMDQogNDZoejRzTUJBQUFBQUNUSFljSVBJQUFMMjlHeHdBSVRLYWs5cyt5SkFBQUFBQW9o
+TEwzRUxPSDRzNHA3VjJydjBOUFg3K3pwSUENCiBBQUFBVjg1QkxFZkp5SWUxMi84QWZmTEdD
+UjcwWWttUXdBQUFBRVlqL3dDc2pqUStGaTUvL3dEL0FQWkhMajAyUnNlVVFBQUFBQQ0KIEV1
+RDkzaU9LS3cvd0QvQVA4QTZQTFBsbDk5ZzdZREFBQUFBQWdTUVdwd25DS25QZjhBOUNadmh4
+NzhmLzhBcFJtakFBQUFBUkxODQogUkJ0UGQ0M2Yvd0Q1OWJQYjZEU3VUV1pmVEFBQUFBQlJa
+dmRjNlQxRXRQOEEvd0RkbUxtdzhnc21Bd0lpcEFBQUFENGhSeDN0MWwNCiBldi93RC9BTWdL
+SkxRdzh2OEF3VUV0TEFBQUFDeFgzM2Y1UmFEL0FQOEEvd0Q3L2kyNHMvbmYzcFpObkFBQUFX
+d0h2ZmY3OHllMg0KICsvOEEvd0FzUjBiOFU4ZlBPZjNTSUFBQkhILytYUEE0NXBQNy93RC9B
+Q2xYYkptL3oybmxKR0FnQ3dGQ2NvdnF4djhBNjRUZmIvDQogOEFycFkvdzIxL2ltQzVBQVVU
+Q3RlS1Y5cWYvd0Q3bVN2UC93RDl6djM4bFg4SVphOU1JTU16R05LazZUNy9BT2d4dEw3cWR3
+K0cNCiAwOFIvcStxamdhY2NhbDFpWFJVLy93QzlTY0ZKZU9qM1BHZmVtSWVhZjFqRm12OEE4
+VmxhMzcvL0FQTlRMVDVUdzg4OEVkL1pmLw0KIFI3bGRIQysvUjFhbjVYL3I0N0phT211dTBR
+QTd3dnozLzVwcXQ2aUNKOTY5SzQvdnVaeG1qS0dkNUxtYXZlMTNEcGgyNzlaYTJpDQogNjl4
+Ri93RDdUMVZqTjNmWFovZmZmdi9FQUI4UkFBTUFBd0VCQVFFQkFRQUFBQUFBQUFBQkVSQWdJ
+VEZCTUZGaGNmL2FBQWdCQXcNCiBFQlB4RGFmb2o0UjFCL1FCTFFlQ1I5UjdDSHo4cGlNOTh4
+RGh3ZWt3N2xDVm9yWEdFZDFuaElTSTdOQnF5NW1peWxYRWUyS1hvbA0KIGVvdjRoa2V1bzlu
+RXlxNElTcy93OFA4QWg3N2lKOFpjUVkzUzNla0hQUlZXVlBaSjAraDRESnlqR0VTbjRMdEJq
+Ujd3ZDB4ZmtuDQogZDF6RjFBMmFNYTdGaUorSHlDbWdXem5vaUMvTlpXM0g4SkxOV0pxaDJU
+VnNBbVMvQlgwVXZCcmlHUHNDOGcxVTFpeERHa1A4ajINCiBCTHQvWGxZaCtnM3c0UHZtclhP
+QzE1Nk5ldkZ4MGlLT2hJbWFxb1lJK1lnbW40VzU5aCs2c1ZSU09IcjF2VEd2MGdrRnp3dEg1
+dw0KIGFXME9TaDlqajBhU2x2d1NjTGhLdy9YcXRJbE9iUEhIcmFjTW1MS09NQzloaUhLc2Fn
+NFBMd01MWFFzYlhwVC9kdTNINjlJU0Z1DQogalY0Y1ZDbm9sbEd1V3RURkE2VXdiMGViMHFh
+Rm90SDZDMHRFMDhaSHdaOFI4MFp3RnRWK2xKRGh4bmhTNEExVjdQekkwcFN6b3UNCiB5d3o0
+Q3l4RG1xaGNXZWkzUWswUURGTmRQcHpCYmQ3ckJ2M3dROElMb2xFUEF3RlNSaTM1aVo5eEQ0
+NGRORitiZW02cEQ3TkVOWA0KIDBhOEVzRy9FNmVyMDRmQlJySGNOb2JUUXhjS0xiVEJPZWJX
+RVVWdW5sbmJWOUVqUkVUMWREWkIzL292U1Bkb01SUGVkZmdrZlQ2DQogOUV6Qi95SHFkSWEx
+RGt5N2VJcFI1UnpHTDNibmMzQzBlVUo4eW5GUGpVY3Q5UTYzUTM2cDNiNk90dVN0dGNJYU02
+V2pqVmFnWjkNCiA2ZTJQRTNzakpxdFlTTWhiTDBTaEl0R0xrdC9SN3pQYUU2cnI5SVNNdC9v
+MFZ6VWZHMFc2d2c4cHhyWi9qUFhkQ3JyVktpb05UOA0KIHZHYzJqN28zR3l6aTFhY0d5b1k0
+dGVhL1U3QVkzQ05iMDlPNFA5eXVpMnRuVHVhVWN3b0llUU9lTVhlNkpZTW1jS1hRMVBkMGZj
+DQogK1ljdVlmQlBXaUM4VHd2MG9veEwrNTlPb2ZRY2ZHZk1QbXpYQ2FwZWtKWENJQVYxanhT
+OExEL05KK2xsRkxvbktKMVVUOUVncXENCiAyWTJrSVhmUkZIRXhDTG43bDRZcFVUdHd6eERo
+NHFnbmVvYi9nbWZHUVV3eDZkQkMxZzV4UFJsZ21sNks0SHVUUE9sdjRyR0Qxag0KIHdoeGkv
+dUpUNHg3MTVnNkZocklURkpQTWNEVy9CbG8wVndWL1NuNDhhNml0WGlNZXE0ZzdsdmtaNnhm
+d1p3QzU1aUNXRU5wa2VpDQogTXN3WlZPRVNRWXRDUmlGL1pEdWdtaXhZTnBVeHMwQy9vU0lU
+aHNvK0REL2c2eElhM1EraUR6ZC9qSjV0Q0NkdG9mK2padWo0U28NCiBzVUk2T3lTcTRaLy94
+QUFnRVFBREFBTUJBUUVCQVFFQkFBQUFBQUFBQVJFUUlDRXhRVEJoVVVCeC85b0FDQUVDQVFF
+L0VIbWxLWA0KIEZ5dEZqbjBiVHhDMDZOTDRSc1RzYWZvdDNlYmh1RUZMaGFMS3hUM2dsS256
+Zy82ZjZDVkhGMkNPRUpUVlBXa2I4T3F4SVdQYmh5DQogOFBVRThKbFBUNnNYQ3cvOE80ZlZC
+alZDM2VHT0JMMFRYelpLZFBrSFZsckNVVTNsUHVoYk1iZ3g2SThma3Bxb3M0TmtGVDcrRjUN
+CiBDam1yR1cwVXNXOXpCYWxYY1JJczNkUFF2TkdKUGdKM21JdWlGeERjaVA3alBRaURyV1ZQ
+RXhQbU04MlY1ckZHUGZteTZKVCtqVw0KIGlGaHF1SXYxaVVUSEJYUmRIU0h0VFBtV1NWRnVl
+d3RYZkJPeGZXWFdmcXorQzArRnkvQnBBdWlFcjRScDhIRWJGcFZvSjZwUjRiDQogTkdHNjNv
+dFJDUlRLWWltQzBPV0ptRmh6L3dTdkJDVkhqTHA1bnpiMkozUm91cjZvUW5ER3hLRXcwNElV
+UndUVTVQelpLbUpOQ2MNCiBkR2c5WlVKaWNMbWozcU9hMlNWSGp6TUhxMlhTUWhDRUtlakpL
+c002SFVhbXA0ZkZGdDBTT0ZiZWpYU2kzOWhOR3FQSzBHU1Vlcw0KIE9BT0lZb1ovcVNOQmpF
+cU56ZXpPd2s1dFNoZEpwQm1GRUVQUXNGSFV0RVJFUkNSR3cxNkw1YXREVlJPMXNsVUpHMWg2
+czhDOG9mDQogMlBWT0NSK2tQd3ZaS1VqWkVBME5FSVREUTFmd2p4b2tKV1RqUWVIZjBOazRM
+TzVoQ1pZdFVHbzlDdDZMbHdSSkY0SWhDRXhUeDANCiBpMlVGMWZnbFBlaW02aTFDelJiUWMy
+NllYQjdNZkI3eXp6RnNpNk4wRTZycS9HTVRlS0Q3bzBSaTh2NDBvL0NtcjR4K3NQV1NITg0K
+IHFuT2xrRVBaNGVQZy93QUZvK0pqN1dHaVpTZEtPRFVvOVh5SHZVSTVaejUrOEN6QnIvQlNq
+MkVWNFhWUFBkTEJnNndyeGlkNnZ6DQogbWljRnpyS09DMGdvNkdaTTlSOVVYS0lJVzZKUStn
+VDV0OEY3Y05wS3NXcUlxSVRiZHpDRTFDMTZVTlZRZTJqRFZJT2lobEZHWFYNCiB0em1JWE9s
+WVBBSDNyMHVIODZjc1dMSVNDbEVTb1VTaEVJMktLaUhUekFKVHBiaU92b2tQOGV3Z0xEVjlL
+K0RaREd1NlM0ME9jaw0KIHVFZW9KSGd6aEMvQkVLMFdFWUpZaUltalZRa2VQZzlJSmZqUzlD
+eFlOYzRWTHdiYkZVVTlPQ1ppWjhFN3hNOGYwdGhSVVdoemJ3DQogbThrSGRNUTlYRG4wNDhH
+N0cybWpsMFNnNlhiRWhTUkQ0d1RkRlA4QTRFVDdsTm9hZmc0dlJYMGhOcmhkYXhDbnVGRzFy
+Q0lPWEINCiBZLy84UUFLUkFCQUFJQ0FRTURCUUVBQXdFQUFBQUFBUUFSSVRGQkVGRmhjWUdS
+SURDaHNjSHdRTkhoOGYvYUFBZ0JBUUFCUHhBNg0KIEhTcFVxVktsU3BVcVZLbFNwVXFWS2lS
+T3FRNk0wbWtlakg2R2V6M3VndXBXTXdxNUdxb2pmckdtM2dDZVhEOFJBYmVBVS9pQ3l1DQog
+V3Y4OEZ0b1VUU2lpcllMMDAxMHZHYStNeHBGVzMzYWpsZDY4eGJlenoyZzV5WWFPOFdHdWox
+U1YxU1ZCbUlRZ1FQcHFWS2xTdXQNCiBTdWxkTDZKRTYxR01lMFlrZWpBdXVid1YzbVZhYzFa
+cUtXRmJabFc4OEdUSmNaOEF2QlBJcjVRQVZoWTdmTVVNbG9XOTViVkhFYw0KIDJhVGxDaGlH
+dzdOTWh0VnRaVnNpN2NCNzh4M0cwSVRYWlNPMGdxcS9BbjVsMmI1TGRlOENIeGczQWp2UTQ5
+Z0VTdFJnMmVoYmdpDQogSzhmSk9PZmZveXBYbUJIRVdYRVhubzlVQ29RaERyVUNCS2xTb0Vx
+VktqVXFWR1ZLaVNvblZqSG9rVG9rRzJyZ2pXT0lZU29HTG4NCiAwT0JhTjVNeExvSE50OHRl
+N1BiRVpHY243Z1gzNkNzdDNHSlNxcXh3MnJYQkZoZmd5UE5pTDh6QnRlT2g4cS9NS0tYbHlR
+S3R5SA0KIGljV1pZRFY1UFJsRXRzN01mbnpCbDkxVkZrRUFROXVtMTNKUjZTNHhkOWJucEc1
+UkU3ZFFnUUlkS2xRSldPaENWRjQxNlpuQmhlDQogMFQzOEJOdEZlNUFIdVY0eEVzOGZFeHAz
+NUlsY1I2TVl4NkpLbFJJa3J2S3hmNVhCN3hqN2RpNVZVVEk1M2pVeWh1RXlmUFhDMVENCiAz
+aEdsbk00Qit2bU1EZDhFSmxyZC92MWdZS2xheDFNTU1CVFFaWTRzRjBhTmhWbk9kWHhDM0o3
+QkVyQ3REeGRiN3pPZ3Flc0t2TQ0KIFVPSmNZL1lJZENIVW5IYVcyNDR4YnVXaEFHMWloNFJl
+WGJGbkVPWXM5bFVjT0dtWEZaQXJzQmdCVVhkRC9xSS84QVgvNmxLb3VWDQogUDVCanhkUS9B
+UW84TkI4ZitZaTN3TDcrYUFzMFZDQ2k5MVA0bWk4UHpLYTduaU5qR0htNGtTSkdNWStKbHhF
+c1R4QVlSUWJOeFYNCiB6Qy9TbTh5bVg5eFd1ODYraHlBNUJuRkdQcVlQTXFQdGJUeGJwWlRM
+b25kb1dQazJTOExtbnpFWlR6UGVYTDZWMHFWR1ZLaERvUQ0KIGh1R3I2S3pBdGRDVXpHNkZQ
+WW02ejRqU3pOMGk3V0hMY0toSzFMcjd3QUFBY0d2aU9lTDlaWG9lS252K0o2NWczL1lBT1Ax
+Szd1DQogT3dRSUFHZ0daUjdoUWZJbWg1RjRBMnFGN29xVnFTWCtDN1R6TStxNmlSM09Za3FW
+R2lhV1l6Vnd4Z0NzeFNvSHUweGRPMkNyMmcNCiA3RzhIZGg1bzhIMjl6bXlPQkR3bDNDQncy
+WFQ2WFMrM0JEN0VCQVcxaHd0NGRZdUN2aDZWTHpVdjdES2xlT2crajFoUTdUeEZ4MA0KIE5n
+anNOYkROYmlPMjBBQTdxMi9EQXJuTXJXbUhodjdsWGZic3hBSUdxaWVubUpxZ0NoVXhoeGRt
+N3ZVQ3ZKa3RYWkduM2xmUFBtDQogSjBlaVJERm9jNTE3eHJndlV3WVIrR2tvVWZLbFZqdDk3
+RHViTERPbFNrbjNoUUlYdTZNSHNZT1lXNVc2MVRMU21VeXBSM2xITEsNCiBPOHhDb3N2NkJI
+Q2R2MzRteS82anpEMEFqemFEbkdzYmlFWnNvbk5GM1FjVERCZ05mOVF3M1I5NG80TDd3Q0Zj
+RXZMSGIwaEJmeQ0KIE1IbFVsbmp4QVpERUxMeWhkcG8xanZHN3F1TXhKWGRsSGVZN3pKdXNs
+RlVKUFF0N1F1N2FYdExlZSs0NXYvZ2MyNXhXWTdPODJRDQogbXYxSGRBSXVJNHBlVndYdmNG
+MjMrNWw1Z1BlVTlGZUo3U3ZXYWc5RjBKWlQ0ZzNBV3JRY3l1OVY1ZzdsK1RYQlRHbXczYXFk
+OHoNCiBYUDhBd2ZqM2prcDQrSllBMW1pREorU0NDSzFxTHNheGRKMjFHTjhUMElvTFlZVUc0
+MDN0NksvQ1lFSC9BTi80VzhjN3QwU3VzQQ0KIFR1Q2xlMHk1dngzLzhBc3VzTG54TGx5NWNW
+dVh5akNYRGlIVFdVSEFyWHRCWERDeUNqYTVnMEMyM2FTaUhZU1pVLzR0MExkaGhEDQogY3ZZ
+TzNvUDdsUEFUZk5jZDVSeDQzTk9FOHRWS2NXa1U0TUlxdDJJZWZSSzRsR2ZHUCtIb05FWXRG
+c2M3emg2ckNxTzMwRlJxUFMNCiArZ1JaenFXMHZ1TzNwRzFUWVRlYVBEbktaeEZZdHVWc3M0
+cmovaXQ0cmJLZ3d6N3k2NkUwT0MxWTJNdUpNS0pQYXIvTUlCWGRROA0KIEpOK3JNZjhBZXk2
+a3dYQ3R3d0FCaWpqL0FJaHN6VUV0ZktDQWJ0RHE5VWxRSU1PMkc4VmV2TUM3TWtocFlaTUE0
+MUx0UlpHMXRmDQogVzF6UEZyWEx0LzROZE9MNDZlNUxCdGFEY1RDenR0djljQUFvWGcvRWJ0
+VDRMbW9oYnpXYlp2V2dFeHcvOEFaUVlLcndVZjhUMGENCiBtWEtyUDJzUDdBRFRpZTh1WEYr
+a2hFSGg0VjBscDV3eTJ6NjRPQUF4eS9QL0FBcitZR205WlRnK0M0Tnk3V1dHTlVjbGY3R1A0
+Uw0KIC9zSWp3bEFQRHl2TVQ1SnV5NTN6MVVDM1J1SnpiSzlCU0pXUHB4eS9mWXppdWYyUkNF
+MDYraGgwQ1ZLaVhRREppbmw0aisyaEt3DQogTUs5NmFYNW5BMEhIMzgyVjhkNTJGcFgxbUl1
+QTVlZ0xtZURZOEI3cEVsWXh6VlQ4TUVtcFlmeVVSRTdlb0lBQU1CeDBLang1a28NCiBaSEw2
+NXZ4MHdGbzBkcFRMeDBhOXBqZ0JzNy93Q3VPK3JWaGs5V1lsTGxnMkhldVlSZzRxS1RJaTMv
+QVBJc1ZnY08zQzYvbGlIWA0KIGF4Ym5ERERNL0pFNXprc2p2ZCszMi9pQitFQjdIOVJPMFJt
+ZTAzeEtsUTZocVJJb3QyaytFcGU0TnBtV3psY3I3eGx0NktQT1pnDQogTkdFNW43KzVXUjdQ
+TTVidVhpRmxzcm5MclFiMDdTUDhqSWIzd3ByMmpvR3Z6RzY4OTV3MS8zTk9DT0lzOWdmMGsw
+WHdRR0lObDQNCiBod2ZJRzQ4UVNJYkwwTjFNdTk5Y0hINWx6SFBLelVHWkVOV1JwZXF0bVlN
+SEJjeUltSzRTY0FJQmZac1FVenkzajJFMjhUa2o0Lw0KIElmL0pvTWx0cWVPZTMyZjlic240
+SDlTcFV6MHMra3lERmVGZG9aOHFaMFBScTJyWkxkNmgrczlDL1JnQWdNNTdPWmZPZ0hJVm9X
+DQogMEZtR1ZUVkdPVG43aUNvY0N4N29Lb3p4b0VHaStNOE84SEFHUW9BVUh3UTEwV09KY1po
+SGFLMTJFWVBFMHdkMk5pUWI3dnJLMDANCiBOVW13ckFjZlVieHVBUGFnbkVYVjA1c3NSK2lZ
+dFM4dHQ5K21nMGdZRTM0RXBPVGFyeWhlY3NiS1N3RmtPRkRkZDZyRXB5L0lUdA0KIDlqL1c3
+SW5zZjFFcVZLbGRTK0piMmxuYUJ2R01vMXlYWGNuUGVHZUFBZ2RuL3dCaUJZYkdBSzNXcnl6
+T2I1KzZ1dFpGb1BQNGcvDQogUnFobDh2ZG01Y3hIeEdNcHpxQmNKbTc5Q0pQQWZiV3RuSklv
+aFY2ais0K3BRRlVBTnhuZmdtdzgzc2dXVTZBUDdCbnFBV1NzWGwNCiBXMjQ5MHZKd0lNZkVy
+cFhlTmZVQllqd25hWno3Q3dOV0dRY0dNYmxWRkQ4UDdPQXBieWZyNm1ZY0JYNFJCOUsrUWl2
+TXVZbU9sUw0KIG5pWmpEUUR3SEE3UjZqRnp5Ulo1aTlNVXQ5ZlAzYnhmcExBS1FkV1VQNXdi
+dnc5R0dZeFlzUzdsNXlWV2dEekYzS0RpQU9IUE9yDQogOUoyNTR6OVZwbjBQV0N4WDJhdzAz
+U2EyZVNWSENvcllaUnZkbCtJUWVXN0JmZ3dKNnJHeW9RcFpISWxqOHdZcFFVZExEKzJQclMN
+CiBOM0hNdU1RbFFVUzByREhHKzhlbE9kZ1BoRnRVOXdPRjJPdmszRk1tQ05pN2lXT0dPR3Vl
+WmszOURwZzdtcDlreU9qSHdsbERybw0KIHJzaVBQU3BYZVVSV0IzajVyWmVEcEJuTFcxN2Zj
+NW5ndHZndWR4UW96eHBQSFlqbnBxTEhvcWEzS2tWOHF5RjNkbnZCQm9vR3VXDQogY1ZXdnE5
+STdJbzVJYStFaDJqS2F4YkFCWW41ZjhBa3NXMENoczd0ajZkb3RPeXpQa040MW0rWUlOd3M4
+aHpQZnBlbWFzZ1l5bnANCiBpSjI1M2RTdnlSeTZGcjJKY0lRUGh6YUY1dml1R0RLeFNRY0tY
+eUkyUjlwYkFUSzdUUEx3VFlkYTZTeXl3c2FRbU9Mcno5SERFNw0KIFdBK3lIbVNzOGdnVnJw
+bnZGcmVaZmlWRXZvU0RxOVVSYnU3MzB1OE1MNjM5eldadWVQMVJjUXg4aVI0NzFGbHhqR0ww
+Y0lRMTdLDQogMzlSaTJsV3g2ZlpBWUt6UjNqRHdLamdPdml1bTJQVitvNVdZWDNPb1FmeUlz
+eVA0ajZ3TDBBSyswc3pZREViNVh0UUszYzRKVEcNCiBDR2dXMkpaUkNuUkxoc0hEaFRQaTlS
+YmtJVlBBUkxEQ2JuS0s0MVd1cHM5Wnllc0VaY0tpK3hVZ3BjWThTdlBSVmRLbFJPbkhqVQ0K
+IC9xOEpZNys1czlKbVozc01HdTQ3S09kOTR4YWw5RjZPQmprSzFQa3pQUDJVeGhwR2ZLc2Jk
+RFNyMm5NNHI5VG44ZEFTRVYwWlZVDQogQnl2SGVGN1Z4Z1VSTEdDNXJXMHBPYlhLczdCbzdH
+SUhtVkFqNGhSWTJCVnB5T3d6M205Y3hIZTc4MlBlSTRBVmRPOHc2dXZQUkwNCiBWbERYaW1n
+bzArVkFhdDkrbnZOZVpaMm1POHhHdUpVSDk5S3ZMOXd5d2VvL1V6Tm1SUjJvNWNXYkl2UjZl
+cy9sbnI3V05HZTBzUA0KIGFrQnpudFgwQnZpdVZ2YzVmdEF3QVlENmc1YXJFckZMSGhMc1I5
+U0s2enZXUmk2MGc1c002aGhoWEoxMVpzN3JDZnFBaUFvN21IDQogOGpMbHkrbU9sVkw2TzNx
+djdIS3crdjNQSGVkdjFQeExwY3ZiVm1hUnZtNG9OUjZzNWd2NEhDWXpRcjdCRUlPbjJlL2ts
+a05DdTENCiBweFdXK2Jlbk1ZaTg2dlM0UHl3SFpJQmxFdDgzTEVrQ0FEM2duUkQ3YnJMZW83
+QWNtUWpwK0pwMzY1Z2FhM0RlNGlDelZsbUhlWg0KIGRYWWc2dTFSa00xenpEd2dhTjE2K1o2
+NzdRNklKUWRJN0l5bGRYUWFGSjhzU3V6VmN1enhIcXN1WExoMmQxWjZ3VHNPUFlZb3I2DQog
+NCs1eVBhY0E0ZzhHUXViQXpTc2FtNDU2Wmx4aUROMTVnS1h1c3lUWStyL1pmSEIzTk5ZK0dP
+UEk1WDJIK0pNSTExZkZoL1puREkNCiBIMUI2RFc5UjZPbDQvd0I2Z29sRFFla1RwYVZTVG8v
+RDdTM3BOWTJhUFRNZWNpTmNuaGdxL29TeVZXYVVsUGw1NHBTN0NoZmRncQ0KIGV3dW95ZDRR
+cVNacWd4ZDczRUhrOXdjbmJTeWpxb0dLREJ2emZ4Rml5NHg2OGoybHNZUjhOSGNLT2RVYSs3
+VlRFcXprMXRZV0hzDQogdFBTWVM0ZEM0eHYvQU5qZWdzM21XRDBXNjc4ajVOTUhwbm44aFhy
+ekhDbkpoUHNGQ2JBK0VDNUZHamVjblRrOVpwUDk2dlJBaVgNCiBHdDJvVkh6S09BcDNsejJ5
+aEI5YXVOcXlpN3JtRjgvUTA0c2d5VFhnQVRCUjVwcnRycXh3VVcwckVMMTFvQVh5bks3dVhl
+SVlBQQ0KIEtjbG1Ibm1Pb1pBc3B3QTZacTQwQ2pDYkh1d3pRVlZ2Z2hlUVQrdzNNQndmY2p4
+MFk5U2FJTGZtZ1ZIZUEvZUNWcS9BL3dEVXJwDQogREx2QnpOWm5udkxqT0llZWdNQldTeE9Z
+d2toc3NqTjNGbmZlbFQ0ZWpQdlBUNlBMbzNDamtKNVhqOFJ0YzdnR3JGUnJBVStCcjgNCiBE
+cVF4WUtTYk1JT1N4emNWWUw3eDQ3K2U4ZnByWm5NWUhEcktXQUFYdnZHUUxOcW91RXJSVjZH
+OHpFcGNFWlNuL0JBVFpKNHpDYQ0KIEVQQUgzRVZ1TzI1L0NRci9zQm1BeDRiMTMvQUxIZWQ2
+K3E0c0JjQjVHVjRYRXNJWSs5VWRvU0UyYXlYZU5MK0Vpem1EWk9QU0VjDQogelhTNW04TEV4
+TGxJbGo3UVJOVGpEdlhiVjRCNGhKc2dJZWptZHU5Wk83c3pIUXlnNHVPeVVTN0dTRUowU3VZ
+bGxMWWxwRER3S1cNCiBpNW9IZC9XWnpvT2U4OVpVZTBoMFM1NEJNajNKekc4R3JzbXp1ODRn
+OWJJVjd1MW96V1ZwTWZjWTkxTVVVU21CdGxMOHd4eWFOMw0KIDBFSW1xVzJxZUtXdWVZbVNO
+MDUyRmtONDhzUzk2NU9HY1ZRdlpjMTNsZ0RZODFMNlU0TmVLOVZuWUFsSFltdUNPYXZpT2Zx
+dVdjDQogbDAzWGVJNEtCTzZ6Ky92YXFyc3F2RVcvQ3BZRWRSZENER1dTOHdtMkt2ekVGQUNr
+NTBUYzNDRUtGVURIWnVyeEQ0M2M4djk0aUMNCiBLR3kvYUdhTlJGT3VjOXhpc3ZZTWFGVnFG
+OGl1SHhGTkJoNGFiSDB4TXV3aHdUVlh3bkY4VHRkcWJFL1V6elh0b21mY2NWUDg5ZA0KIHhn
+cm0veFlFaGFMdDhYeEJwY3JsdjdXVm9IakQ4VGhzdnRNMGRDUXRvSTJxQnZ2bDRZWUUzUUls
+M0tlVy9CS0s1WnRsblNudDlGDQogeDY0OXlvZzY2b1lMS0src3NBTmdzaG84L2NyTHpSS1dt
+R3RDb2o1U0Jmb3VvSjFJa3FDRU54Wm9hQmN1OWlBV2RrWFNPWmxhS2INCiB5ZEdlV2FJeFZ5
+REpybjJqZ0xLY2l1bGI0dzhjeGJyRXIvQUFod0hXRXdNY1pQRTFuTzBCaHZuMThUUkFieDVS
+bVdLcTVJSHVzTw0KIElSMmhCVVM4bkRMTHM5L3QzMHhBODNqMGMrMFZ0c3Y3aEhnMzR1Tzcw
+UUtGUy9jaUlLenJ2Q1BaWW1SZHhWUzQweDhvOUhwY05uDQogbHFEWEFMdkNUL0FCQ3NGaGM5
+TU1OT2ZQM08zaU50bUEzZ1FMZ3ZEdlF2N2h1SE5RWU11TzhRMTlGWXFhUCs0U2hOZ1lvQ3RD
+MFENCiBESmZJd1loSlUzQW9LK3E1aFNqc05CUUVic0ExdG1TMEdFSndaMHZGUHhMSVJCVUZU
+dzVLNWQ0WEYxdkp4ZG45eXBTRk5xdjFIUQ0KIFZMQ285MFhaMUFuTmE4UCsvRXI2OEcydTND
+S2dNM1lRdlozbEYwVDVmRDVtOUNoMjJSR3RiVmFrTU8xeXpOc1dHaHZ5VDJoQ2dRDQogczNn
+RTNGci9BRlRmUkpxUGhIeEtqMDE2OFFHYll2STRZYlRaRXdNZ1BZVFM4ZU8zM1JYTHRWOFNq
+NEkrQXQ3RXZJczE3elcraGMNCiAwektiNkV2bzZtOXpjVVNMQVQrSTVtRFRrQTQ3Ymk2RWRV
+TkMydTZVaHlLMmtrMDMyeU1hTCtZaFFEQmp4M2x5SUpnRlBLYnJ6Qg0KIGE0R2VtTy9abFp3
+RjkzN0R6V0ZLdUR3ZXlDWGlEOGdTS0c5RTgxaDNFSXprdnpRalBIK1YzbGFxcjh5MVVvaUxZ
+Q3J6eEQwakhNDQogcUo1YnJSTEFUbXRpTkkrNHdlbHhxSjB1TUhyV2NMWWJiRDlGQ05MUmo3
+dUhEcGpzRkM3V3Fvbkk5SU5oTndMSnREaU1KeExneGUNCiBoYkN5bnlIMmFmYUN3ZEFDRjlq
+VGdUdlZTNWtrYnhiRkV0WjlWVzIzQzR6ZnBjd3hjVkxzb0RWOTN4Y0VhSnVRSU9BMk5ITGo3
+Zg0KIE14ekc5byswVWR6Mm41STBiY29nODBSSWRic1MvTGNjZFpIRThtSlI2SXVSNkt0enNX
+dlBTNGRGbHhycmN1Rzh0aHVnQ0g3ais2DQogMEx2RTFudjl6elBWWWtMQ2o4UUtXM2oybHNJ
+YWcwK0p0TDdUMFJjd2U4NWk1eDlHbUFOc2Y5Y1RqMmI2RUFPdk1ZSnNPUWUwbmMNCiBnbzFi
+MVZMSFBlS1lkMVZsZnJFZDRjODNGcG5CdzdmeERNVk9ETDhFZWhqbTh2Wkk5SVR1eS9jTjhD
+UC9zQmt6bkVXTmNCZll2dA0KIDdRUks3Mjl2YU00QUZ4WTE2UDNjMkJRT2ZYaTVmVy9wTFVN
+ekVMUk9IL1hBQURCMk1pZXUvdkFNWnBwdSsvM2NVeHM4M1hFdTVlDQogdzNkcEJ6aUpDNE9n
+TlMyWEZxRFpPWWJpZFBUamk2aDI0M1hhWDNCOXBWaU9zSEhFRFNGS2FzZytmclBpRVVCbFkx
+M081RCtSdEwNCiBUdEczek1yYVgzanhaN2MvTWJHbER4VXlaRkRobVRoYW92NVFIaktEZCty
+VlFsb0FCaWpSNlJiYnJQZm9NMzB1TGZTNDdnNGx3ZQ0KIFhFcFdMK1FVZXdtRFhHcGVxZno3
+Z1haQWZnREdLTVNkblgrVmw0MDcrOGRXUTNVSEV2TXVYRnpPWmQ5TGx3ZWx6OVFCVkxYQTJS
+DQogK1g2N215ZDNMcFY0alF1c2R2TTBuckJ1R2dNWWI4a0VlbXQ3VVgvSlEyQmtoKzQ5SGJj
+ZmdRZlFYQTRscnlxY3N1WExneGVqMHYNCiBxd25GUUJ3aFN5a3FCaW1WZDFiNHI3eUpMZGVM
+Q212U1J2RDBSV1ZCekhjdU1YSFVJcGVZZDVobFRpVkF4blVybnZ4dWkxejdKZQ0KIEQvUUov
+d0RmcGVaenpRVHpsaTUrbnZXTGNWcENveERTQkpiTHkvc2czS094RVZFSlN4Y1ViamlFMzlh
+d2w4emExMXFPc0RaUDhhDQogSVdUSnE2TzNlYkxOUDNIZVZlZWgvcmhGTmxZOTdoaGc2UjFN
+aG1vbk15aVRpb2FnNXFEbU91cDVnVlZnSGxhak5MY25zcC9Kd1gNCiB1ajZTWWdTKzI3Zmxs
+eU9jNE0xLzdIcW9PUDJFTHBnMUN2bVlkMnhYdWptUFJxTXFVUjdXVW5FdnJjdjZIVUt0ZkdZ
+VndIUXVrag0KIDJiaEZxYk51Yi9BRDkzdVFnOWtEQ1Vidlp2S2taNW5CREUyNmFtNVdaZlRj
+SGpwZVlKenFLdVp4UGZHdXZLMkRtbjZlWUVRZFpODQogdUVpeHBvdFBVL0V5OE1WRUZ1dlps
+MjR2QzRpaHdidVdVYXJ6SGl0am1OUklrNGxkTERVY3hybm85V0Vaek5KeE1lYko0TXl2d2wN
+CiBWNzUrNE9qT1hyL3FqZ1dZbllWSDRJNWptcHc2Tmx3Ymg5QXk1Y3ZwWlRlcG85K1dZcHNZ
+TDFiK3psUHJvWFVKWDE3dEREc0FYTg0KIGRGM0xxR1dKbG1wdm9kU2lFcnB4TmRHMmNDS1ls
+QUxVc0JmR0ZRUzNnZW1OVGp6eWR2dDVMMEc1WkRkb0w3dUhHb2MzQnJFeklIDQogUUlTcFVU
+SDBYMFFwdkoybUxsQU9ieFhtRnZRTFZacWNIZjY5YzE1aVY0Z3gyVUorWnBqbzQ2WDBPbGRL
+cnBtR3N4NXlwVWVKeksNCiBocURHSmF3YTc4bFFLcUFlWDZGVGFwdXFmdDhOYnBFTWNPbmtZ
+VVhUS3pGU0U3U3BVRFAwVjBaY0dHV29TK3dQaGJDL0VCUUU1Kw0KIHZQRzR3YjlSZ3ArWlZW
+dVlNM0VsVFVJU285YWdFcDZGUklFY1JLaDBUTVFrUUE1Z09kbkI1QzcvbjJ3c0hFN0UrOWov
+MmdhZk9ZDQogd3hCc09ta1plT3F3aEhvMWdDUUJYQWU2aERlMEV1WGtPYVI3YSt6cGltV21E
+Tk1maUNJTFVSdktGUG1KaWN4M0tsUSttdnBXdWcNCiBSekhaT0VHWmhOdWQwZnRFM0FmV0d2
+dG1Ja3Ivd3pxbzZoa2l2REF1SFNzOUZxWDBPaDB1QzZ6ejZSMktEbmhiWjZpeDNtajQ1UQ0K
+IENnK3p1REZZUjJvbUlXM01FblRUeFU1MHhOVVh5VjM1aTZzSTZlSTdoNWxrNTYxMVlRNk1a
+eE1ub0lrQXBGanM4UjFScTJTckFzDQogZmJkcGYwbC9NcnIxZzVtbW9ZWnBINlIwSUNrQVds
+cWp6RmRFWWJiVWlFeUprTHFNRUs4eTlMY2g1VzhrdTB5QzB0R1BzK2tzSlgNCiBGMTZROUZt
+aWc1bDFxSXc2S01JMkszdUlWV0JwRjJPZVJiZUlIdWJMOUltSUl6aTZ4MHI2V3BVUG9ZRlFs
+UU14TkdNcEtybG1uSQ0KIG9mZ1FLWHdYOXB5K0VoVUYvSlVndTdhdzRoR0VvWU45SDZGbm1s
+ck9KWXNNYTlCZmkvaVBUVGw4QzJjdGlFZnlHL1VYY2dVUUprDQogeUVZQWtPQ3NSTGpOZHVi
+ekRQMnJFQUNGSUJEUHVpbW1qZlVQaW9NN09TVVNiMWdkVFBmc2tUVmdSdzFUVUJIRkNCTU5V
+b2xqejINCiBtV1JBTUtyejJSaHFIVmxTcFVxYWpDYmpDT0lialRaR0tZWkhIL0FHbUJjYVNp
+UG43TjJUUmZNUkpEMDBsMi9VNzNYaWFpSFFsZQ0KIEpTWDA0WEdNN2wwNkxMQzM3aFl3b29E
+eXVvemdBcGl3andLOUl1bUtFeEN0MlZRMWczOHN2bGt0VlZoM21GRE55d0MxcXNyTk5ODQog
+Wk02bVI4TkJleXI3V1c5SVFhc0JRS29qd2lkNWQzV1Fhdno5bmRudUxxWHd0ZjJDd2NZR3k2
+a1ExbHNCNjJmRU5JNVljWHF2eEUNCiBkRHFDR2g1VEJqR3BZYzhWT2pBc21CMzJRa0pSUTNW
+MGpEbTJ3eGZlVjJsU3V1NWNlbWlFV0VlaTFjSjF2QXJSK2xpUFYyRmZZaA0KIHI3SnVOWkw3
+TGlid2d1VXRmd1l1YlBpTExnOUE3eTZ6QllDdGhGb3ZIdkRzM2hkSXNXMU4rNTQwc1BkanVk
+aEFsWU8vZjFabm5JDQogODgvSGFacGhqOG5hbzdoUldVMGFzUzhSWmVKTzBRWTFwbFZpRDZY
+UTAwSnRMeEc4QUxtb3llamoybkljdjJNNW9GMW5VcFVHM04NCiBpZHNXUTFBb0ZnUlB3eG5w
+RkhLS2U3Q3N2dFZ2eEJXaGhHUzdzZG5zelI2UkFYaEdtcVROcVFNazlseVFhMzlWVE1NeGhH
+R285QQ0KIHVCcENQM1R6TEg4VGo3SnZQSmgzUHd6TGRjUGRBWDhSWXNIRXZNSmMzTHBtV093
+WHhCZXF1Y0FMOTJCbnVhS1J4bTQ0bEdHRXFUDQogaWlHQ3VPSHFFWkYzMlNPTXJxNS9IWHlJ
+UkswWkl4WStzc2ExSzR6NmRRelJDRGUrZnM0SVNuWlREN05NdXFpR3lRWHdTMXNCNlcNCiBE
+bThXeDh4VnlpOElIQ0RreVB4MFF1aG1nbEtpNVk4bVVlRmNXSER1RUFTL3lMMXhUamlaYS9N
+T2o5QmduTTEwNGwzMElUbG9wKw0KIGtGWmMwZlZoYUZ2ZGRQUEVTdkxkeDBDa1V3b2Zpckh4
+cVBRSWgwT3BkNE9PWTB1dVdnVTY4ZVRtSEJBQlZRaFZHRDIraDFtRG1NDQogQTZ6RlBkZ0c1
+eklwR0VwcnM1M0RlazdqYlZjcVB4Sm13OUE2dnRjM2src2dLbmpvQVNuK2JtYUFqczd4RXU5
+d2tUUFEyNGx4Q3cNCiBmS1BKNGxlYi9zb3NlR3hQTUN1bGxWc3R2NHBWV0dFL2tkNVA1R1o3
+VjIvd0IzbC9WekdFWEVKY0lGMVZNaGlSVFkvSDBkL0VCZA0KIFF1VmgwVUs3VDBodVB1MTJa
+V1pia3JxejBjZUFpYXp4cnRFaEt6SG9kUDhBcTRjOWVmWWhYMjBHK1pyTC9QUUN2eEtLUVhP
+MXpMDQogdml2SDBjeGhxZXRBWDlFekhlY1BTYVVGeTdVQ0NzTzc3Mkk0M0ZkeGp3amhwMnM3
+dFFiejMrUHJaaC91NXV5NC9NenRqRkNjMXoNCiA2U3hMclRDV0paNUxncnl3S2padlNucDFM
+Mzh4eVkzQjJOTzVXRW1ia0o4andyaXpzd09LYi9DT1p4ZkVxUFZoRmd4WVBTMmxPRQ0KIFBk
+MUR3NUY0QmdKT0FQaDlGWnZpb2pyYTQrUDhRUFgza3hqdFk0Y3NlbWNEbUt4amcwVStZUjYz
+TGx5OHk1UlFXY3VPN0FJNWU2DQogQlErUDY0dXhROGQzMU1RWVgwZWRSRXBMTDRYOFR6NGpB
+aVlVbUVVRFRoSFVvT0loMlp2NnZXRHdRSk9IY2JSSzJ2WTJVdXIwdUkNCiAzaVdRZ2RobWxD
+dFd3NWdIYVlCcnRSK0pxa1pRNTVKeDVnRnVwTFkwYS9rOGFoTzBtRTBqbjY2Z0Vkd09oZ1BQ
+RHYzbUY4ZDNjUA0KIHdKd04zdVZXT3FCNE1qNlJMK1kwY3VQL0FHT2RZNFRDS1Y5MThTNmtI
+R0pGM1dYazdhZzdpZ09CM2h1TzJQUjNPT2x5NHdvZTVBDQogazE3cExsT1d1MVYvbFRiYnY2
+M1BMdzN1bVZMUTh3bFEvWEhRZ2orR05ZcTdPQ0RxeDdFVnhwNStybUl1cGpncCtJeEF2REVJ
+bzMNCiBWZVRJYUROaFozc3hBNVZqVXplU2MzekhDcWh3VktwNU5rSVNxdk1JbGpWTEhFV3pW
+WWFYajFoMy93RGtlaG5yeE9ZZEFiS0xvaQ0KIDFpNU9qYzRMQ09LNnVDNmFqUDFlM1FJU2NR
+eTkzZUdkZzVUMlJsNktCRjN0MVJ2aDNDanZRQVBBYWpaYWY1NnczSGNZd09qS1ptDQogSWo2
+b0FsbnpETEFBQTZNUHBES2hUOFJGVGdKNHpocnBUWnF1MG9xRE5oYkZwbXFYeERHcWp0SXpT
+YTNncTdkZExpNlpkWFlMWm0NCiBNS3JHRk5DOXA1TWtHNnFBWU5QU2x2MGt0YThkRms5SklG
+SWpDOThYMjF5NWF5T2hpeVZxNjZac1NQcDB5MEZtek5JdVBNVHZxaw0KIFhXNnhoVDRTN3Ax
+NDRJeTRFdm9zNWgwTFdZanZDMzR3UzIwUGxkOVdFQ2g5cHdaNWxiZFBIbmNIYnBvazdscGY1
+amxZdVR4OEtIDQogdnU1a0ZNYU9xaG11REh2T0liakdNT2owenh1Zm9vMkM0UHJOejgxK28x
+SHNKOHczNDZNNERwdzNIWW9jNFBoWGZOeGNHenJ0cEkNCiBWeHptZWlqM0k0SmFqTnBTTTRp
+cW5GT1R2alI3eE1xVDVseVBzd2NSN1RnM0VSMlE1QnZ5b3RkK2VqbGRKeEROb2txQzJVNjRy
+UA0KIGVHV0IxYXdzZGpsc1E2dlJWTFJ0dGE4QzRKcWpKaW5TVTV3aWUzVTFHRVlUaGduRTBZ
+QWYyWHVOOTJBUUhnVVRFRy9CVDBvV2xxDQogK1BIZnhEckFOZzJnUEt6S3FJNVFUR1JqRVd3
+aHBwMmJVVDJtSS94d2V3UVlCQ0V1UEgwc2VsRG9aQWUzMWh3cmdjS2w5SmVHQUsNCiA3VW9R
+M1JZY25LSEJpdFpGaVBrWXd3d3VEcW1JY3lhQndpZk9keE8vbTJndDJ1MWtiME1kcWNRR0Zp
+L2FIZCtFbzdHcWdzd055ag0KIDZrckFFbkVCSFh0QktjaHVDb1lUYml5SzJubkFEeFVEUFNq
+c1RrMVV3Y2ppckZLMTJETnZjRnVjNVpYL0FOeE9jLzQ2SU9jZlloDQoga3JzeThlamRMUmNz
+QXRzUEdwVldMVHAvZlhveFlTNW91TkEzbngzaVhxQVJ5WDgvb3d6TUJrL0tQSzR6bU9GTzNN
+dGUvQXRYejQNCiBoRDhGTStMRzIzdW1KcEtjYy84QVVweGdHK0psVzZoQ1htRGljdzZKSzZN
+U0JuTXJya3dmUS83ZlhXZjlpQklPZ2hNWFUyV1d1OQ0KIEFOMjR4R3RLaDUzRjVsM3RpWDBM
+SEppQU56SEtLT1JOUzFCTzZoY1UyMlFwMHkyQTZVUFpjUGhpV2haVVg5VkZ4bGhoUFdsK1pa
+DQogM1Q1U0dFQ0EwR082QmtHcVN2a3JsWUVyUFUyVkNEdk55Q0o4TVpPa3JxMEE4MHY0aHJI
+VGpkdFZieEttV2xtcVJCUFpmbU9Ec1QNCiBxVkFyRGk3K0piVGpQYnJVcVZockxBUDlDQXBN
+ZjRRUk5TT2FORy9CQXh2UE1SbUxhYXFDbUlXQVM3VUs3OG1vR0NOaERmaTErTQ0KIDNCeG5I
+ZGlTdWhDTzRhaENFWXhqR01xc1ZYc1hnRW5tYy9WVm1TenRLdFJXb25HdlpmaUk0eEl0bzNh
+aTdkRjB4T1Q0bzM0aDBXDQogelZBdzhpeHlZaXlQNFo1aHVQT0l4Nm1ydmtnS3JONVIrcHdD
+aWtmZ01RdmI0djZubzlEaFNxOHl1MXR2RVFNWXNSMzF0RVRLYUkNCiBvN2FjTkJWMnU1N2Jn
+K0JETjJBa3FWRWxRdjE2djU3MUU0dmlLc3ZidmRIUHRNYlk5S0lDZUZGS0Y3S29RL0cwekhZ
+UVZYb3dhcQ0KIGc4WEtyYkhXVVlZbDlIcWVwMVk5T09qMm01OXJyOEROWXJmUkwrcldRcVpK
+b1VXdHRBSGxROTR5cTFzcFBTQ0RYZXR5cEQ2Y003DQogRWd4LzNQWk14NFpKL1pWZ2R0QVQx
+Z3AxUGhLTWE4U3JpSW10MWdxcXc0NU1OK2hxaE5WM2ZtUFUzOWhDdzM1NFJQNUZVdHRyN28N
+CiBXSFJ4a2x2QXozS2xGMW1Zc3NMT3dmU29rTXRmNGhxbmpZS2M4VmhlMENCaXFicDhBaFE2
+M2hkNml4ZjFRRUwzSzNEaWxEMXpLcg0KIFZGY1A3M2xVQXhIUDBuMEgwTXFNOVpjWTlBV3Bi
+K1ZReFRZVEtwWHRpQnY2YXNEcTRnZ0J6UlNoOXl3bnd3QnhWYWlnMkdvVnhoDQogYitKaThD
+aHRhYXpNVEtwYkRIc3NORE9DUmUrYTh5empYNHFPc004c1J3Mzh3cGJTWmtHMml4c2NETzNl
+b3c1b1BDWWcyNnJwZGINCiBsOThScHhPZGZmbmhZUzNwc1kvRVhRd2NwWHVDUVpwQzY1SVVq
+eTA2KzlJN3JuL1pSQ1pSa0RheStMalhZMUFBcEJ4cVc2clBieA0KIEx6cHJ2eEt2UStyZ2xq
+bkh1eDZiU2tVVnc3SXc2MmRyTk1MTVlLeG1VRFFVOHR6aTF1TjBYRG82Nk1ZYWh1Ry9vSXg2
+UFNwUnpxDQogSmltc0twaFVyUk8wV1ZIdVFBYU5CWHY5TGw3Q0Z6T0c4dDdEam5EdWpSbmpk
+ZTUxeE0zc2hxMThDdytDWU1HQm8xS2IxWjJKU3ANCiB1NmFqN2o4SzUvUkc5VlFKWG5KQy9y
+UjNGVlk3T1hnczUyT1Y1aG5FQkh1TXVQZDRsMTdFcm4zQ2hmS2gxNGp5YlBxRHl2WHN3bg0K
+IHVBdmxnSDRsUDh2Yis0cW52aWEvY0R0NXNDbUxuSUJRbXdObXI0bUMxQnFKc0txcklybk1O
+REhzZnFVcUtnc1dVYTVKV3VZNWp3DQogU2Jnc0tyTlhudk1XdDNzUGtNdVphMEZMNXBQNGhF
+Q0ZIbjFnVkw2YVQ2N2p2cGNXWG1MTDZFR0xCakhpUFZsZFhVVHZJQnBCSysNCiB5VGo2Vlhq
+dWZTNnhEUGJmYkJyOHdFK2lNQVVIb1RLTDVISHRMV3RhUFpDckpiNGw0MUpZSG1yWDRpRHZq
+eTVoalBBREN5K3JVdA0KIFJHTVVQaU1ad0VvdGZBOGpuMmxEbkxCZTZBdlNIdEV3RXRZbmtN
+aWJCMHdCN09RbGJueUFSdzBCc3Q5REU5YlJMWUM2RlhITXpWDQogbkJNVVJHMjNuQ21QaVg3
+VUE2YmR2SUtnTGlIckE4TXhZYkRRb3R4ek9VY0U2QWFoamlBRlFBQ2hDRHVWS2xkWG9zZWpM
+NjNVR0UNCiBZVGZUZlZZUDBYZ2xnMVcvNUhoT2ZkbWxlOWtYa3ZUajBaNjlhbGw3RXBhdXIy
+VDdRVFl2b2l3dzJNd1JZVmhlQVlvR3pTYWpGRg0KIFVjVzBCYTI3cmlKUm9ISGlDMVlRV2kr
+WWxGT1BmY3ZCZGV0NHVKT0Y1UzdyeTlhbHg3RktqdkZBeDJlWXRnODBqN3VEMGdBVlhvDQog
+TVJOUUdBTm4vRXViR1lEaUdDdUplWnVlZjNCaGJQRjNKam1GVnZ6Q3hGZ1ZzWW1JVU9Xd3Mr
+OVl2RUZOeWtmTnFIVXNVNVRmd0oNCiBqdDFNZlN4akdQUitoNkV1NFE2SFJqRHF6VEJidjJq
+YU5RYnNwcG5GM3REa25NM1J6bjAxS3I2SFNOMTI3eXhEbEp6ZnIyaHU3dw0KIGxEd2Z5UXc3
+WmRyZHJOdVFDQjdzSjZheXBNQmhRM3A0alMwVUlPQThrTDF6RnlYWVhlb2U1TERjSE1yL0Np
+VjBnSndDOXZNc2lKDQogcWRhWnJwWUxSZzZjRU9qblVFSEpDcTc3cGozVFZVaW5MdDZabUhQ
+Smc4RXFNNklUUTBJdDlrUktpWllvUU9rTzI0RXdBQUs4VmoNCiA2U1BSakdNWTlHY3g2SFVs
+d2gwM0RvZFdNQ0dZMHZDVi9aa0d5RGFnNU9iS0ZaTVNxeG4zK29CbER1QlJMTE81U1ZwdGNR
+NEZ2Sw0KIGg4UjlHY3doWE1GdTNuaURLNGdBejNlOHRNR05LR0RXdFFqdWVTVUIweExtYjE3
+RXpML3VleXZTM2lFdDUvclhMK0lac2FoaTRqDQogTUd1eFVCTnFadHZHcDR1QzIrc2FMUHhj
+V3ArV091cjNsbkZTeFdOT1BDemtQaGlsYS9WLy9aDQpPUkc7WC1OQy1TQ09QRT12Mi1sb2Nh
+bDpEZWxpdmVyaW5nIENyZXcNClRJVExFO1gtTkMtU0NPUEU9djItbG9jYWw6Q2FwdGFpbg0K
+WC1TT0NJQUxQUk9GSUxFO1RZUEU9TkVYVENMT1VEO1gtTkMtU0NPUEU9djItcHVibGlzaGVk
+Omh0dHBzOi8vc2VydmVyLmludGVyDQogbmFsOjQ0My9pbmRleC5waHAvdS80MzdjNTQyMC1k
+NDUwLTEwM2QtOGE3ZS1mOTQyYTQ2YjNkZjcNCkNMT1VEOjQzN2M1NDIwLWQ0NTAtMTAzZC04
+YTdlLWY5NDJhNDZiM2RmN0BzZXJ2ZXIuaW50ZXJuYWw6NDQzDQpFTkQ6VkNBUkQNCg==
+
+--sgnirk-111111111111111--
+
diff --git a/comm/mail/test/browser/moz.build b/comm/mail/test/browser/moz.build
new file mode 100644
index 0000000000..00347ed7e4
--- /dev/null
+++ b/comm/mail/test/browser/moz.build
@@ -0,0 +1,63 @@
+# vim: set filetype=python:
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "account/browser-clear.ini",
+ "account/browser.ini",
+ "attachment/browser.ini",
+ "cloudfile/browser.ini",
+ "composition/browser.ini",
+ "content-policy/browser.ini",
+ "content-tabs/browser.ini",
+ "cookies/browser.ini",
+ "downloads/browser.ini",
+ "folder-display/browser.ini",
+ "folder-pane/browser.ini",
+ "folder-tree-modes/browser.ini",
+ "folder-widget/browser.ini",
+ "global-search-bar/browser.ini",
+ "im/browser.ini",
+ "import/browser.ini",
+ "junk-commands/browser.ini",
+ "keyboard/browser.ini",
+ "message-header/browser.ini",
+ "message-reader/browser.ini",
+ "message-window/browser.ini",
+ "multiple-identities/browser.ini",
+ "newmailaccount/browser.ini",
+ "notification/browser.ini",
+ "openpgp/browser.ini",
+ "openpgp/composition/browser.ini",
+ "override-main-menu-collapse/browser.ini",
+ "pref-window/browser.ini",
+ "quick-filter-bar/browser.ini",
+ "search-window/browser.ini",
+ "session-store/browser.ini",
+ "smime/browser.ini",
+ "startup-firstrun/browser.ini",
+ "subscribe/browser.ini",
+ "tabmail/browser.ini",
+ "utils/browser.ini",
+]
+
+DIRS += [
+ "shared-modules",
+]
+
+TEST_HARNESS_FILES.testing.mochitest.fakeserver += [
+ "../../../mailnews/test/fakeserver/Auth.jsm",
+ "../../../mailnews/test/fakeserver/Imapd.jsm",
+ "../../../mailnews/test/fakeserver/Maild.jsm",
+ "../../../mailnews/test/fakeserver/Nntpd.jsm",
+ "../../../mailnews/test/fakeserver/Pop3d.jsm",
+ "../../../mailnews/test/fakeserver/Smtpd.jsm",
+]
+
+TEST_HARNESS_FILES.testing.mochitest.resources += [
+ "../../../mailnews/test/resources/logHelper.js",
+ "../../../mailnews/test/resources/MessageGenerator.jsm",
+ "../../../mailnews/test/resources/MessageInjection.jsm",
+ "../../../mailnews/test/resources/smimeUtils.jsm",
+]
diff --git a/comm/mail/test/browser/multiple-identities/browser.ini b/comm/mail/test/browser/multiple-identities/browser.ini
new file mode 100644
index 0000000000..6757185bf5
--- /dev/null
+++ b/comm/mail/test/browser/multiple-identities/browser.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_displayNames.js]
diff --git a/comm/mail/test/browser/multiple-identities/browser_displayNames.js b/comm/mail/test/browser/multiple-identities/browser_displayNames.js
new file mode 100644
index 0000000000..66344d8147
--- /dev/null
+++ b/comm/mail/test/browser/multiple-identities/browser_displayNames.js
@@ -0,0 +1,224 @@
+/* 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 that we can open and close a standalone message display window from the
+ * folder pane.
+ */
+
+"use strict";
+
+var { ensure_card_exists, ensure_no_card_exists } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AddressBookHelpers.jsm"
+);
+var {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ get_about_message,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var folder;
+var decoyFolder;
+var localAccount;
+var secondIdentity;
+var myEmail = "sender@nul.invalid"; // Dictated by messagerInjector.js
+var friendEmail = "carl@sagan.invalid";
+var friendName = "Carl Sagan";
+var headertoFieldMe;
+var collectedAddresses;
+
+add_setup(async function () {
+ localAccount = MailServices.accounts.FindAccountForServer(
+ MailServices.accounts.localFoldersServer
+ );
+
+ // We need to make sure we have only one identity:
+ // 1) Delete all accounts except for Local Folders
+ for (let account of MailServices.accounts.accounts) {
+ if (account != localAccount) {
+ MailServices.accounts.removeAccount(account);
+ }
+ }
+
+ // 2) Delete all identities except for one
+ for (let i = localAccount.identities.length - 1; i >= 0; i--) {
+ let identity = localAccount.identities[i];
+ if (identity.email != myEmail) {
+ localAccount.removeIdentity(identity);
+ }
+ }
+
+ // 3) Create a second identity and hold onto it for later
+ secondIdentity = MailServices.accounts.createIdentity();
+ secondIdentity.email = "nobody@nowhere.invalid";
+
+ folder = await create_folder("DisplayNamesA");
+ decoyFolder = await create_folder("DisplayNamesB");
+
+ // # 0
+ await add_message_to_folder(
+ [folder],
+ create_message({ to: [["", myEmail]] })
+ );
+ // # 1
+ await add_message_to_folder(
+ [folder],
+ create_message({ from: ["", friendEmail] })
+ );
+ // # 2
+ await add_message_to_folder(
+ [folder],
+ create_message({ from: [friendName, friendEmail] })
+ );
+ // # 3 - a message I got with a custom address to myself
+ await add_message_to_folder(
+ [folder],
+ create_message({ to: [["Customized", myEmail]] })
+ );
+
+ // Ensure all the directories are initialised.
+ MailServices.ab.directories;
+ collectedAddresses = MailServices.ab.getDirectory(
+ "jsaddrbook://history.sqlite"
+ );
+
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ headertoFieldMe = bundle.GetStringFromName("headertoFieldMe");
+});
+
+function ensure_single_identity() {
+ if (localAccount.identities.length > 1) {
+ localAccount.removeIdentity(secondIdentity);
+ }
+ Assert.ok(
+ MailServices.accounts.allIdentities.length == 1,
+ "Expected 1 identity, but got " +
+ MailServices.accounts.allIdentities.length +
+ " identities"
+ );
+}
+
+function ensure_multiple_identities() {
+ if (localAccount.identities.length == 1) {
+ localAccount.addIdentity(secondIdentity);
+ }
+ Assert.ok(
+ MailServices.accounts.allIdentities.length > 1,
+ "Expected multiple identities, but got only one identity"
+ );
+}
+
+async function help_test_display_name(message, field, expectedValue) {
+ // Switch to a decoy folder first to ensure that we refresh the message we're
+ // looking at in order to update information changed in address book entries.
+ await be_in_folder(decoyFolder);
+ await be_in_folder(folder);
+ select_click_row(message);
+
+ Assert.equal(
+ get_about_message().document.querySelector(
+ `#expanded${field}Box .header-recipient .recipient-single-line`
+ ).textContent,
+ expectedValue,
+ "The expected value matches the found value"
+ );
+}
+
+add_task(async function test_single_identity() {
+ ensure_no_card_exists(myEmail);
+ ensure_single_identity();
+ await help_test_display_name(0, "to", headertoFieldMe);
+
+ await help_test_display_name(3, "to", `Customized <${myEmail}>`);
+});
+
+add_task(async function test_single_identity_in_abook() {
+ ensure_card_exists(myEmail, "President Frankenstein", true);
+ ensure_single_identity();
+ await help_test_display_name(0, "to", "President Frankenstein");
+});
+
+add_task(async function test_single_identity_in_abook_no_pdn() {
+ ensure_card_exists(myEmail, "President Frankenstein");
+ ensure_single_identity();
+ await help_test_display_name(0, "to", headertoFieldMe);
+});
+
+add_task(async function test_multiple_identities() {
+ ensure_no_card_exists(myEmail);
+ ensure_multiple_identities();
+ await help_test_display_name(0, "to", myEmail);
+
+ await help_test_display_name(3, "to", `Customized <${myEmail}>`);
+});
+
+add_task(async function test_multiple_identities_in_abook() {
+ ensure_card_exists(myEmail, "President Frankenstein", true);
+ ensure_multiple_identities();
+ await help_test_display_name(0, "to", "President Frankenstein");
+});
+
+add_task(async function test_multiple_identities_in_abook_no_pdn() {
+ ensure_card_exists(myEmail, "President Frankenstein");
+ ensure_multiple_identities();
+ await help_test_display_name(0, "to", myEmail);
+
+ await help_test_display_name(3, "to", `Customized <${myEmail}>`);
+});
+
+add_task(async function test_no_header_name() {
+ ensure_no_card_exists(friendEmail);
+ ensure_single_identity();
+ await help_test_display_name(1, "from", friendEmail);
+});
+
+add_task(async function test_no_header_name_in_abook() {
+ ensure_card_exists(friendEmail, "My Buddy", true);
+ ensure_single_identity();
+ await help_test_display_name(1, "from", "My Buddy");
+});
+
+add_task(async function test_no_header_name_in_abook_no_pdn() {
+ ensure_card_exists(friendEmail, "My Buddy");
+ ensure_single_identity();
+ // With address book entry but display name not preferred, we display name and
+ // e-mail address or only the e-mail address if no name exists.
+ await help_test_display_name(1, "from", "carl@sagan.invalid");
+});
+
+add_task(async function test_header_name() {
+ ensure_no_card_exists(friendEmail);
+ ensure_single_identity();
+ await help_test_display_name(
+ 2,
+ "from",
+ friendName + " <" + friendEmail + ">"
+ );
+});
+
+add_task(async function test_header_name_in_abook() {
+ ensure_card_exists(friendEmail, "My Buddy", true);
+ ensure_single_identity();
+ await help_test_display_name(2, "from", "My Buddy");
+});
+
+add_task(async function test_header_name_in_abook_no_pdn() {
+ ensure_card_exists(friendEmail, "My Buddy");
+ ensure_single_identity();
+ // With address book entry but display name not preferred, we display name and
+ // e-mail address.
+ await help_test_display_name(2, "from", "Carl Sagan <carl@sagan.invalid>");
+});
diff --git a/comm/mail/test/browser/multiple-identities/readme.txt b/comm/mail/test/browser/multiple-identities/readme.txt
new file mode 100644
index 0000000000..8105f8bd54
--- /dev/null
+++ b/comm/mail/test/browser/multiple-identities/readme.txt
@@ -0,0 +1,4 @@
+Caution! The tests in this directory modify or outright delete the default
+accounts/identities. If you are writing new tests to go into this directory,
+don't assume the existence of any account except Local Folders (with a single
+identity).
diff --git a/comm/mail/test/browser/newmailaccount/browser.ini b/comm/mail/test/browser/newmailaccount/browser.ini
new file mode 100644
index 0000000000..ae76ec3122
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+prefs =
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.setup.loglevel=Debug
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.auto_config.addons_url=about:blank
+ mailnews.auto_config_url=about:blank
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = html/**
+
+[browser_newmailaccount.js]
diff --git a/comm/mail/test/browser/newmailaccount/browser_newmailaccount.js b/comm/mail/test/browser/newmailaccount/browser_newmailaccount.js
new file mode 100644
index 0000000000..a3fab8d4f3
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/browser_newmailaccount.js
@@ -0,0 +1,682 @@
+/* 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/. */
+
+/**
+ * Tests the new account provisioner workflow.
+ */
+
+"use strict";
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { gMockPromptService } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PromptHelpers.jsm"
+);
+var { wait_for_content_tab_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { remove_email_account } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NewMailAccountHelpers.jsm"
+);
+var { openAccountProvisioner, openAccountSetup } = ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+);
+var { input_value } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+var { click_through_appmenu, click_menus_in_sequence } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+// RELATIVE_ROOT messes with the collector, so we have to bring the path back
+// so we get the right path for the resources.
+var url =
+ "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/";
+var kProvisionerUrl =
+ "chrome://messenger/content/newmailaccount/accountProvisioner.xhtml";
+var kProvisionerEnabledPref = "mail.provider.enabled";
+var kSuggestFromNamePref = "mail.provider.suggestFromName";
+var kProviderListPref = "mail.provider.providerList";
+var kDefaultServerPort = 4444;
+var kDefaultServerRoot = "http://localhost:" + kDefaultServerPort;
+var gDefaultEngine;
+
+Services.prefs.setCharPref(kProviderListPref, url + "providerList");
+Services.prefs.setCharPref(kSuggestFromNamePref, url + "suggestFromName");
+
+// Here's a name that we'll type in later on. It's a global const because
+// we'll be using it in several distinct modal dialog event loops.
+var NAME = "Green Llama";
+
+// Record what the original value of the mail.provider.enabled pref is so
+// that we can put it back once the tests are done.
+var gProvisionerEnabled = Services.prefs.getBoolPref(kProvisionerEnabledPref);
+var gOldAcceptLangs = Services.locale.requestedLocales;
+var gNumAccounts;
+
+add_setup(async function () {
+ requestLongerTimeout(2);
+
+ // Make sure we enable the Account Provisioner.
+ Services.prefs.setBoolPref(kProvisionerEnabledPref, true);
+ // Restrict the user's language to just en-US
+ Services.locale.requestedLocales = ["en-US"];
+});
+
+registerCleanupFunction(async function () {
+ // Put the mail.provider.enabled pref back the way it was.
+ Services.prefs.setBoolPref(kProvisionerEnabledPref, gProvisionerEnabled);
+ // And same with the user languages
+ Services.locale.requestedLocales = gOldAcceptLangs;
+
+ // 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;
+});
+
+/**
+ * Helper function that returns the number of accounts associated with the
+ * current profile.
+ */
+function nAccounts() {
+ return MailServices.accounts.accounts.length;
+}
+
+/**
+ * Helper function to wait for the load of the account providers.
+ *
+ * @param {object} tab - The opened account provisioner tab.
+ */
+async function waitForLoadedProviders(tab) {
+ let gProvisioner = await TestUtils.waitForCondition(
+ () => tab.browser.contentWindow.gAccountProvisioner
+ );
+
+ // We got the correct amount of email and domain providers.
+ await BrowserTestUtils.waitForCondition(
+ () => gProvisioner.mailProviders.length == 4,
+ "Correctly loaded 4 email providers"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => gProvisioner.domainProviders.length == 3,
+ "Correctly loaded 3 domain providers"
+ );
+}
+
+/**
+ * Test a full account creation with an email provider.
+ */
+add_task(async function test_account_creation_from_provisioner() {
+ Services.telemetry.clearScalars();
+
+ let tab = await openAccountProvisioner();
+ let tabDocument = tab.browser.contentWindow.document;
+
+ let mailInput = tabDocument.getElementById("mailName");
+ // The focus is on the email input.
+ await BrowserTestUtils.waitForCondition(
+ () => tabDocument.activeElement == mailInput,
+ "The mailForm input field has the focus"
+ );
+
+ await waitForLoadedProviders(tab);
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+ Assert.equal(
+ scalars["tb.account.opened_account_provisioner"],
+ 1,
+ "Count of opened account provisioner must be correct"
+ );
+
+ // The application will prefill these fields with the account name, if present
+ // so we need to select it before typing the new name to avoid mismatch in the
+ // expected strings during testing.
+ mailInput.select();
+ // Fill the email input.
+ input_value(mc, NAME);
+ // Since we're focused inside a form, pressing "Enter" should submit it.
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ let mailResults = tabDocument.getElementById("mailResultsArea");
+
+ // Wait for the results to be loaded.
+ await BrowserTestUtils.waitForCondition(
+ () => mailResults.hasChildNodes(),
+ "Mail results loaded"
+ );
+ // We should have a total of 15 addresses.
+ await BrowserTestUtils.waitForCondition(
+ () => mailResults.querySelectorAll(".result-item").length == 14,
+ "All suggested emails were correctly loaded"
+ );
+
+ // The domain section should be hidden and the buttons should be updated.
+ Assert.ok(
+ tabDocument.getElementById("domainSearch").hidden &&
+ !tabDocument.getElementById("mailSearchResults").hidden &&
+ tabDocument.getElementById("cancelButton").hidden &&
+ tabDocument.getElementById("existingButton").hidden &&
+ !tabDocument.getElementById("backButton").hidden
+ );
+
+ // Go back and fill the domain input.
+ let backButton = tabDocument.getElementById("backButton");
+ backButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(backButton, {}, tab.browser.contentWindow);
+
+ Assert.ok(tabDocument.getElementById("mailSearchResults").hidden);
+
+ let domainName = tabDocument.getElementById("domainName");
+ domainName.focus();
+ domainName.select();
+ // Fill the domain input.
+ input_value(mc, NAME);
+ // Since we're focused inside a form, pressing "Enter" should submit it.
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ let domainResults = tabDocument.getElementById("domainResultsArea");
+ // Wait for the results to be loaded.
+ await BrowserTestUtils.waitForCondition(
+ () => domainResults.hasChildNodes(),
+ "Domain results loaded"
+ );
+ // We should have a total of 15 addresses.
+ await BrowserTestUtils.waitForCondition(
+ () => domainResults.querySelectorAll(".result-item").length == 14,
+ "All suggested emails and domains were correctly loaded"
+ );
+
+ // The domain section should be hidden and the buttons should be updated.
+ Assert.ok(
+ !tabDocument.getElementById("domainSearch").hidden &&
+ tabDocument.getElementById("mailSearchResults").hidden &&
+ tabDocument.getElementById("cancelButton").hidden &&
+ tabDocument.getElementById("existingButton").hidden &&
+ !tabDocument.getElementById("backButton").hidden
+ );
+
+ // Go back and confirm both input fields maintained their values.
+ backButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(backButton, {}, tab.browser.contentWindow);
+
+ Assert.ok(
+ tabDocument.getElementById("domainSearchResults").hidden &&
+ tabDocument.getElementById("mailName").value == NAME &&
+ tabDocument.getElementById("domainName").value == NAME
+ );
+
+ // Continue with the email form.
+ tabDocument.getElementById("mailName").focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ // Wait for the results to be loaded.
+ await BrowserTestUtils.waitForCondition(
+ () => mailResults.hasChildNodes(),
+ "Mail results loaded"
+ );
+ // We should have a total of 15 addresses.
+ await BrowserTestUtils.waitForCondition(
+ () => mailResults.querySelectorAll(".result-item").length == 14,
+ "All suggested emails were correctly loaded"
+ );
+
+ // Select the first button with a price from the results list by pressing Tab
+ // twice to move the focus on the first available price button.
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ EventUtils.synthesizeKey("VK_TAB", {}, mc.window);
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ tabDocument.activeElement ==
+ mailResults.querySelector(".result-item > button"),
+ "The first result button was focused"
+ );
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ // A special tab with the provisioner's API url should be loaded.
+ wait_for_content_tab_load(undefined, function (aURL) {
+ return aURL.schemeIs("http") && aURL.host == "mochi.test";
+ });
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.account.selected_account_from_provisioner"]["mochi.test"],
+ 1,
+ "Count of selected email addresses from provisioner must be correct"
+ );
+
+ // Close the account provisioner tab, and then restore it.
+ mc.window.document
+ .getElementById("tabmail")
+ .closeTab(mc.window.document.getElementById("tabmail").currentTabInfo);
+ mc.window.document.getElementById("tabmail").undoCloseTab();
+ // Wait for the page to be loaded again...
+ wait_for_content_tab_load(undefined, function (aURL) {
+ return aURL.schemeIs("http") && aURL.host == "mochi.test";
+ });
+ tab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ // Record how many accounts we start with.
+ gNumAccounts = MailServices.accounts.accounts.length;
+
+ // Simulate the purchase of an email account.
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "input[value=Send]",
+ {},
+ tab.browser
+ );
+
+ // The account setup tab should be open and selected.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ mc.window.document.getElementById("tabmail").selectedTab.browser
+ ?.currentURI?.spec == "about:accountsetup",
+ "The Account Setup Tab was opened"
+ );
+ // A new account should have been created.
+ Assert.equal(
+ gNumAccounts + 1,
+ MailServices.accounts.accounts.length,
+ "New account successfully created"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ mc.window.document.getElementById("tabmail").selectedTab.browser
+ ?.contentWindow.gAccountSetup?._currentModename == "success",
+ "The success view was shown"
+ );
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.account.new_account_from_provisioner"]["mochi.test"],
+ 1,
+ "Count of created accounts from provisioner must be correct"
+ );
+
+ // Clean it up.
+ remove_email_account("green@example.com");
+ // Close the account setup tab.
+ mc.window.document
+ .getElementById("tabmail")
+ .closeTab(mc.window.document.getElementById("tabmail").currentTabInfo);
+});
+
+/**
+ * Test the opening and closing workflow between account setup and provisioner.
+ */
+add_task(async function test_switch_between_account_provisioner_and_setup() {
+ let tab = await openAccountProvisioner();
+ let tabDocument = tab.browser.contentWindow.document;
+
+ await waitForLoadedProviders(tab);
+
+ // Close the tab.
+ let closeButton = tabDocument.getElementById("cancelButton");
+ closeButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ closeButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // The account setup tab should NOT be opened.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ mc.window.document.getElementById("tabmail").selectedTab.browser
+ ?.currentURI?.spec != "about:accountsetup",
+ "The Account Setup Tab was not opened"
+ );
+
+ tab = await openAccountProvisioner();
+ tabDocument = tab.browser.contentWindow.document;
+
+ await waitForLoadedProviders(
+ mc.window.document.getElementById("tabmail").currentTabInfo
+ );
+
+ // Click on the "Use existing account" button.
+ let existingAccountButton = tabDocument.getElementById("existingButton");
+ existingAccountButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ existingAccountButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // The account setup tab should be open and selected.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ mc.window.document.getElementById("tabmail").selectedTab.browser
+ ?.currentURI?.spec == "about:accountsetup",
+ "The Account Setup Tab was opened"
+ );
+
+ // Close the account setup tab.
+ mc.window.document
+ .getElementById("tabmail")
+ .closeTab(mc.window.document.getElementById("tabmail").currentTabInfo);
+});
+
+/**
+ * Test opening the account provisioner from the menu bar.
+ */
+add_task(async function open_provisioner_from_menu_bar() {
+ // Show menubar so we can click it.
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("menu_File"),
+ {},
+ mc.window
+ );
+ await click_menus_in_sequence(
+ mc.window.document.getElementById("menu_FilePopup"),
+ [{ id: "menu_New" }, { id: "newCreateEmailAccountMenuItem" }]
+ );
+
+ // The account Provisioner tab should be open and selected.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ mc.window.document.getElementById("tabmail").selectedTab.browser
+ ?.currentURI?.spec == "about:accountprovisioner",
+ "The Account Provisioner Tab was opened"
+ );
+ await waitForLoadedProviders(
+ mc.window.document.getElementById("tabmail").currentTabInfo
+ );
+
+ // Close the account provisioner tab.
+ mc.window.document
+ .getElementById("tabmail")
+ .closeTab(mc.window.document.getElementById("tabmail").currentTabInfo);
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Test opening the account provisioner from the main app menu.
+ */
+add_task(async function open_provisioner_from_app_menu() {
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("button-appmenu"),
+ {},
+ mc.window
+ );
+ click_through_appmenu(
+ [{ id: "appmenu_new" }],
+ {
+ id: "appmenu_newCreateEmailAccountMenuItem",
+ },
+ mc.window
+ );
+
+ // The account Provisioner tab should be open and selected.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ mc.window.document.getElementById("tabmail").selectedTab.browser
+ ?.currentURI?.spec == "about:accountprovisioner",
+ "The Account Provisioner Tab was opened"
+ );
+ await waitForLoadedProviders(
+ mc.window.document.getElementById("tabmail").currentTabInfo
+ );
+
+ // Close the account provisioner tab.
+ mc.window.document
+ .getElementById("tabmail")
+ .closeTab(mc.window.document.getElementById("tabmail").currentTabInfo);
+}).skip();
+
+/**
+ * Test that names with HTML characters are escaped properly when displayed back
+ * to the user.
+ */
+add_task(async function test_html_characters_and_ampersands() {
+ let tab = await openAccountProvisioner();
+ let tabDocument = tab.browser.contentWindow.document;
+
+ await waitForLoadedProviders(tab);
+
+ // Type a name with some HTML tags and an ampersand in there to see if we can
+ // trip up account provisioner.
+ const CLEVER_STRING =
+ "<i>Hey, I'm ''clever &\"\" smart!<!-- Ain't I a stinkah? --></i>";
+
+ // Fill the email input.
+ input_value(mc, CLEVER_STRING);
+ // Since we're focused inside a form, pressing "Enter" should submit it.
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ let mailResults = tabDocument.getElementById("mailResultsArea");
+
+ // Wait for the results to be loaded.
+ await BrowserTestUtils.waitForCondition(
+ () => mailResults.hasChildNodes(),
+ "Mail results loaded"
+ );
+
+ let searchedTerms =
+ tabDocument.getElementById("mailResultsTitle").textContent;
+ Assert.notEqual(
+ `One available address found for: "${CLEVER_STRING}"`,
+ searchedTerms
+ );
+
+ // & should have been replaced with &amp;, and the greater than / less than
+ // characters with &gt; and &lt; respectively.
+ Assert.ok(
+ searchedTerms.includes("&amp;"),
+ "Should have eliminated ampersands"
+ );
+ Assert.ok(
+ searchedTerms.includes("&gt;"),
+ "Should have eliminated greater-than signs"
+ );
+ Assert.ok(
+ searchedTerms.includes("&lt;"),
+ "Should have eliminated less-than signs"
+ );
+
+ // Close the account provisioner tab.
+ mc.window.document
+ .getElementById("tabmail")
+ .closeTab(mc.window.document.getElementById("tabmail").currentTabInfo);
+});
+
+/**
+ * Test that if the search goes bad on the server-side we show an error.
+ */
+add_task(async function test_shows_error_on_bad_suggest_from_name() {
+ let original = Services.prefs.getCharPref(kSuggestFromNamePref);
+ Services.prefs.setCharPref(kSuggestFromNamePref, url + "badSuggestFromName");
+
+ let tab = await openAccountProvisioner();
+
+ await waitForLoadedProviders(tab);
+
+ let notificationBox =
+ tab.browser.contentWindow.gAccountProvisioner.notificationBox;
+
+ let notificationShowed = BrowserTestUtils.waitForCondition(
+ () =>
+ notificationBox.getNotificationWithValue("accountProvisionerError") !=
+ null,
+ "Timeout waiting for error notification to be showed"
+ );
+
+ // Fill the email input.
+ input_value(mc, "Boston Low");
+ // Since we're focused inside a form, pressing "Enter" should submit it.
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ // Wait for the error notification.
+ await notificationShowed;
+
+ // Close the account provisioner tab.
+ mc.window.document
+ .getElementById("tabmail")
+ .closeTab(mc.window.document.getElementById("tabmail").currentTabInfo);
+ Services.prefs.setCharPref(kSuggestFromNamePref, original);
+});
+
+/**
+ * Tests that if a provider returns broken or erroneous XML back to the user
+ * after account registration, we show an alert dialog.
+ */
+add_task(async function test_error_on_corrupt_XML() {
+ // Register the prompt service to handle the alert() dialog.
+ gMockPromptService.register();
+
+ let tab = await openAccountProvisioner();
+ let tabDocument = tab.browser.contentWindow.document;
+
+ // Record how many accounts we start with.
+ gNumAccounts = nAccounts();
+
+ await waitForLoadedProviders(tab);
+
+ // Fill the email input.
+ input_value(mc, "corrupt@corrupt.invalid");
+ // Since we're focused inside a form, pressing "Enter" should submit it.
+ EventUtils.synthesizeKey("VK_RETURN", {}, mc.window);
+
+ let mailResults = tabDocument.getElementById("mailResultsArea");
+
+ // Wait for the results to be loaded.
+ await BrowserTestUtils.waitForCondition(
+ () => mailResults.hasChildNodes(),
+ "Mail results loaded"
+ );
+ // We should have a total of 15 addresses.
+ await BrowserTestUtils.waitForCondition(
+ () => mailResults.querySelectorAll(".result-item").length == 14,
+ "All suggested emails were correctly loaded"
+ );
+
+ let priceButton = tabDocument.querySelector(
+ `.result-item[data-label="corrupt@corrupt.invalid"] .result-price`
+ );
+ priceButton.scrollIntoView();
+
+ EventUtils.synthesizeMouseAtCenter(
+ priceButton,
+ {},
+ tab.browser.contentWindow
+ );
+
+ // A special tab with the provisioner's API url should be loaded.
+ wait_for_content_tab_load(undefined, function (aURL) {
+ return aURL.schemeIs("http") && aURL.host == "mochi.test";
+ });
+ tab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ gMockPromptService.returnValue = true;
+
+ // Simulate the purchase of an email account.
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "input[value=Send]",
+ {},
+ tab.browser
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ mc.window.document.getElementById("tabmail").selectedTab.browser
+ ?.currentURI?.spec == "about:accountprovisioner",
+ "The Account Provisioner Tab was opened"
+ );
+
+ let promptState = gMockPromptService.promptState;
+ Assert.equal("alert", promptState.method, "An alert was showed");
+
+ Assert.equal(gNumAccounts, nAccounts(), "No new accounts have been created");
+
+ // Clean up
+ gMockPromptService.unregister();
+
+ // Close the account setup tab.
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+ mc.window.document
+ .getElementById("tabmail")
+ .closeTab(mc.window.document.getElementById("tabmail").currentTabInfo);
+});
+
+/**
+ * Tests that when we pref off the Account Provisioner, the menuitem for it
+ * becomes hidden, and the button to switch to it from the Existing Account
+ * wizard also becomes hidden. Note that this doesn't test explicitly
+ * whether or not the Account Provisioner spawns when there are no accounts.
+ * The tests in this file will fail if the Account Provisioner does not spawn
+ * with no accounts, and when preffed off, if the Account Provisioner does
+ * spawn (which it shouldn't), the instrumentation Mozmill test should fail.
+ */
+add_task(async function test_can_pref_off_account_provisioner() {
+ // First, we'll disable the account provisioner.
+ Services.prefs.setBoolPref("mail.provider.enabled", false);
+
+ // Show menubar so we can click it.
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("menu_File"),
+ {},
+ mc.window
+ );
+ await click_menus_in_sequence(
+ mc.window.document.getElementById("menu_FilePopup"),
+ [{ id: "menu_New" }]
+ );
+
+ // Ensure that the "Get a new mail account" menuitem is no longer available.
+ Assert.ok(
+ mc.window.document.getElementById("newCreateEmailAccountMenuItem").hidden,
+ "new account menu should be hidden"
+ );
+
+ // Close all existing tabs except the first mail tab to avoid errors.
+ mc.window.document
+ .getElementById("tabmail")
+ .closeOtherTabs(mc.window.document.getElementById("tabmail").tabInfo[0]);
+
+ // Open up the Account Hub.
+ let tab = await openAccountSetup();
+ // And make sure the Get a New Account button is hidden.
+ Assert.ok(
+ tab.browser.contentWindow.document.getElementById("provisionerButton")
+ .hidden
+ );
+ // Close the Account Hub tab.
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+
+ // Ok, now pref the Account Provisioner back on
+ Services.prefs.setBoolPref("mail.provider.enabled", true);
+
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("menu_File"),
+ {},
+ mc.window
+ );
+ await click_menus_in_sequence(
+ mc.window.document.getElementById("menu_FilePopup"),
+ [{ id: "menu_New" }]
+ );
+ Assert.ok(
+ !mc.window.document.getElementById("newCreateEmailAccountMenuItem").hidden,
+ "new account menu should show"
+ );
+
+ // Open up the Account Hub.
+ tab = await openAccountSetup();
+ // And make sure the Get a New Account button is hidden.
+ Assert.ok(
+ !tab.browser.contentWindow.document.getElementById("provisionerButton")
+ .hidden
+ );
+ // Close the Account Hub tab.
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
diff --git a/comm/mail/test/browser/newmailaccount/html/badSuggestFromName b/comm/mail/test/browser/newmailaccount/html/badSuggestFromName
new file mode 100644
index 0000000000..4ff4f2769f
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/badSuggestFromName
@@ -0,0 +1,4 @@
+
+[{"product": "personalized_email", "addresses": ["green@foo.invalid",
+"green_llama@foo.invalid", "gllama@bar.cbar"}, {"product":
+, "price": "20.00", "provider": "fo"]w
diff --git a/comm/mail/test/browser/newmailaccount/html/config.xml b/comm/mail/test/browser/newmailaccount/html/config.xml
new file mode 100644
index 0000000000..f268177fb5
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/config.xml
@@ -0,0 +1,33 @@
+<clientConfig version="1.1">
+ <emailProvider id="%DOMAIN%">
+ <domain>%EMAILDOMAIN%</domain>
+ <displayName>Provisioned Account</displayName>
+ <incomingServer type="imap">
+ <hostname>imap-provisioned.%EMAILDOMAIN%</hostname>
+ <port>993</port>
+ <socketType>SSL</socketType>
+ <username>%EMAILADDRESS%</username>
+ <authentication>password-cleartext</authentication>
+ <password>Håhå</password>
+ </incomingServer>
+ <incomingServer type="pop3">
+ <hostname>pop-provisioned.%EMAILDOMAIN%</hostname>
+ <port>995</port>
+ <socketType>SSL</socketType>
+ <username>%EMAILLOCALPART%</username>
+ <authentication>password-cleartext</authentication>
+ <password>Testing</password>
+ <pop3>
+ <leaveMessagesOnServer>true</leaveMessagesOnServer>
+ </pop3>
+ </incomingServer>
+ <outgoingServer type="smtp">
+ <hostname>smtp-provisioned.%EMAILDOMAIN%</hostname>
+ <port>465</port>
+ <socketType>SSL</socketType>
+ <username>%EMAILADDRESS%</username>
+ <authentication>password-cleartext</authentication>
+ <password>Östad3</password>
+ </outgoingServer>
+ </emailProvider>
+</clientConfig>
diff --git a/comm/mail/test/browser/newmailaccount/html/configCorrupt.xml b/comm/mail/test/browser/newmailaccount/html/configCorrupt.xml
new file mode 100644
index 0000000000..edb53019bc
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/configCorrupt.xml
@@ -0,0 +1,25 @@
+<clientConfig versi">
+ <emailProvider id="%DOMAIN%">
+ <domain>%EMAILDOMAIN%</domain>
+ <displayName>Provisioned Account</displayName>
+ <displayShortName>Provisioned Account</displayShortName>
+ <incomingServer type="imap">
+ <hostname>imap.%EMAILDOMAIN%</hostname>
+ <socketType>SSL</socketType>
+ <username>%EMAILADDRESS%</username>
+ <authentication>password-cleartext</authentication>
+ </incomingServer>
+ <incomingServer type="pop3">
+ <username>%EMAILLOCALPART%</username>
+ <authentication>password-cleartext</authentication>
+ <password>Testing</password>
+ <pop3>
+ </pop3>
+ </incomingServer>
+ <outgoingServer type="smtp">
+ <hostname>smtp.%EMAILDOMAIN%</hostname>
+ <por465</port>
+ <socketType>SSL</socketType>
+ <username>%EMAILADDRESS%</username>
+ <autddhentication>password-cleartext</authentication>
+ </outgoingServer>
diff --git a/comm/mail/test/browser/newmailaccount/html/configError.xml b/comm/mail/test/browser/newmailaccount/html/configError.xml
new file mode 100644
index 0000000000..967533b666
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/configError.xml
@@ -0,0 +1,6 @@
+<clientConfig version="1.1">
+ <emailProvider id="%DOMAIN%"/>
+ <error code="USER_CANCEL">
+ You have cancelled your order.
+ </error>
+</clientConfig>
diff --git a/comm/mail/test/browser/newmailaccount/html/emptySuggestFromName b/comm/mail/test/browser/newmailaccount/html/emptySuggestFromName
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/emptySuggestFromName
@@ -0,0 +1 @@
+{}
diff --git a/comm/mail/test/browser/newmailaccount/html/providerList b/comm/mail/test/browser/newmailaccount/html/providerList
new file mode 100644
index 0000000000..9d8af7492b
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/providerList
@@ -0,0 +1,63 @@
+[{"id": "foo",
+ "label": "foo",
+ "paid": true,
+ "languages" : ["en-US"],
+ "api": "http://www.example.com/tbReg?first={firstname}&last={lastname}&email={email}",
+ "tos_url": "http://www.example.com/foo-tos",
+ "privacy_url": "http://www.example.com/foo-privacy",
+ "sells_domain": false
+ },
+ {"id": "bar",
+ "label": "bar",
+ "paid": false,
+ "languages" : ["en-US", "fr-FR"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/bar-tos",
+ "privacy_url": "http://www.example.com/bar-privacy",
+ "sells_domain": false
+ },
+ {"id": "French",
+ "label": "French Provider",
+ "paid": false,
+ "languages" : ["fr-FR"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/French-tos",
+ "privacy_url": "http://www.example.com/French-privacy",
+ "sells_domain": false
+ },
+ {"id": "German",
+ "label": "German Provider",
+ "paid": false,
+ "languages" : ["de-DE"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/German-tos",
+ "privacy_url": "http://www.example.com/German-privacy",
+ "sells_domain": false
+ },
+ {"id": "corrupt",
+ "label": "Corrupt Provider",
+ "paid": false,
+ "languages" : ["en-US"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registrationCorrupt.html",
+ "tos_url": "http://www.example.com/corrupt-tos",
+ "privacy_url": "http://www.example.com/corrupt-privacy",
+ "sells_domain": true
+ },
+ {"id": "err",
+ "label": "Error Provider",
+ "paid": false,
+ "languages" : ["en-US"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registrationError.html",
+ "tos_url": "http://www.example.com/err-tos",
+ "privacy_url": "http://www.example.com/err-privacy",
+ "sells_domain": true
+ },
+ {"id": "multi",
+ "label": "multi",
+ "paid": true,
+ "languages" : ["en-US"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/bar-tos",
+ "privacy_url": "http://www.example.com/bar-privacy",
+ "sells_domain": true
+ }]
diff --git a/comm/mail/test/browser/newmailaccount/html/providerListBad b/comm/mail/test/browser/newmailaccount/html/providerListBad
new file mode 100644
index 0000000000..8faf0f7cd0
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/providerListBad
@@ -0,0 +1,15 @@
+[{"id": "foo",
+ "label": "foo",
+ "paid": true,
+ "languages" : ["en-US"],
+ "api": "http://www.example.com/tbReg?first={firstname}&last={lastname}&email={email}",
+ "url": "http://www.example.com/api/orde"http://foo.com/tos",
+ "privacy_url": "http://foo.com/privacy",
+ "search_engine": "foo"
+ },
+: "http://example.com/",
+ "tos_url": "http://example.com/tos",
+ "privacy_url": "http://example.com/privacy"
+}
+
+]
diff --git a/comm/mail/test/browser/newmailaccount/html/providerListIncomplete b/comm/mail/test/browser/newmailaccount/html/providerListIncomplete
new file mode 100644
index 0000000000..1dfa9be2c3
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/providerListIncomplete
@@ -0,0 +1,41 @@
+[{"id": "foo",
+ "label": "foo",
+ "paid": true,
+ "languages" : ["en-US"],
+ "api": "http://www.example.com/tbReg?first={firstname}&last={lastname}&email={email}",
+ "tos_url": "http://www.example.com/tos",
+ "privacy_url": "http://www.example.com/privacy",
+ "search_engine": "foo"
+ },
+ {"id": "bar",
+ "label": "bar",
+ "paid": false,
+ "languages" : ["en-US", "fr-FR"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/tos",
+ "privacy_url": "http://www.example.com/privacy",
+ "search_engine": "bar"
+ },
+ {"id": "French",
+ "label": "French Provider",
+ "paid": false,
+ "languages" : ["fr-FR"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/tos",
+ "privacy_url": "http://www.example.com/privacy",
+ "search_engine": "French"
+ },
+ {"id": "German",
+ "label": "German Provider",
+ "paid": false,
+ "languages" : ["de-DE"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/tos",
+ "privacy_url": "http://www.example.com/privacy",
+ "search_engine": "German"
+},
+ {"id": "corrupt",
+ "label": "Corrupt Provider",
+ "languages" : ["en-US"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registrationCorrupt.html"
+}]
diff --git a/comm/mail/test/browser/newmailaccount/html/providerListNoOtherLangs b/comm/mail/test/browser/newmailaccount/html/providerListNoOtherLangs
new file mode 100644
index 0000000000..e2fa454fa0
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/providerListNoOtherLangs
@@ -0,0 +1,28 @@
+[{"id": "foo",
+ "label": "foo",
+ "paid": true,
+ "languages" : ["en-US"],
+ "api": "http://www.example.com/tbReg?first={firstname}&last={lastname}&email={email}",
+ "tos_url": "http://www.example.com/foo-tos",
+ "privacy_url": "http://www.example.com/foo-privacy",
+ "search_engine": "foo"
+ },
+ {"id": "bar",
+ "label": "bar",
+ "paid": false,
+ "languages" : ["en-US"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/bar-tos",
+ "privacy_url": "http://www.example.com/bar-privacy",
+ "search_engine": "bar"
+ },
+ {"id": "corrupt",
+ "label": "Corrupt Provider",
+ "paid": false,
+ "languages" : ["en-US"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registrationCorrupt.html",
+ "tos_url": "http://www.example.com/corrupt-tos",
+ "privacy_url": "http://www.example.com/corrupt-privacy"
+}
+
+]
diff --git a/comm/mail/test/browser/newmailaccount/html/providerListWildcard b/comm/mail/test/browser/newmailaccount/html/providerListWildcard
new file mode 100644
index 0000000000..5644013fa3
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/providerListWildcard
@@ -0,0 +1,37 @@
+[{"id": "universal",
+ "label": "Universal",
+ "paid": true,
+ "languages" : ["*"],
+ "api": "http://www.example.com/tbReg?first={firstname}&last={lastname}&email={email}",
+ "tos_url": "http://www.example.com/foo-tos",
+ "privacy_url": "http://www.example.com/foo-privacy",
+ "search_engine": "universal"
+ },
+{"id": "otherUniversal",
+ "label": "Other Universal",
+ "paid": true,
+ "languages" : ["*", "fr-FR"],
+ "api": "http://www.example.com/tbReg?first={firstname}&last={lastname}&email={email}",
+ "tos_url": "http://www.example.com/foo-tos",
+ "privacy_url": "http://www.example.com/foo-privacy",
+ "search_engine": "otherUniversal"
+ },
+ {"id": "French",
+ "label": "French Provider",
+ "paid": false,
+ "languages" : ["fr-FR"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/French-tos",
+ "privacy_url": "http://www.example.com/French-privacy",
+ "search_engine": "French"
+ },
+ {"id": "German",
+ "label": "German Provider",
+ "paid": false,
+ "languages" : ["de-DE"],
+ "api": "http://mochi.test:8888/browser/comm/mail/test/browser/newmailaccount/html/registration.html",
+ "tos_url": "http://www.example.com/German-tos",
+ "privacy_url": "http://www.example.com/German-privacy",
+ "search_engine": "German"
+ }
+]
diff --git a/comm/mail/test/browser/newmailaccount/html/registration.html b/comm/mail/test/browser/newmailaccount/html/registration.html
new file mode 100644
index 0000000000..901c16fef7
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/registration.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ <title>Fake registration page</title>
+ </head>
+ <body>
+ <div class="title">Local version</div>
+ <div class="content">
+ <form action="config.xml" method="GET">
+ <p>
+ First name: <input value="Green" id="first" name="firstname" type="text"><br>
+ Last name: <input value="Llama" id="last" name="lastname" type="text"><br>
+ Email: <input value="da.green.llama@foo.invalid" id="email" name="email" type="text"><br>
+ <input value="Send" type="submit">
+ </p>
+ </form>
+ <a id="external" href="target.html" target="_blank">Should open externally</a>
+ <a id="internal" href="target.html">Should open internally</a>
+ <p id="newtab" onclick="window.open('target.html');">
+ Should open in a new content tab.
+ </p>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/newmailaccount/html/registrationCorrupt.html b/comm/mail/test/browser/newmailaccount/html/registrationCorrupt.html
new file mode 100644
index 0000000000..a0a1d6d8dd
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/registrationCorrupt.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ <title>Fake registration page to Corrupt XML</title>
+ </head>
+ <body>
+
+ <div class="title">Local version</div>
+ <div class="content">
+ <form action="configCorrupt.xml" method="GET">
+ <p>
+ First name: <input value="Green" id="first" name="firstname" type="text"><br>
+ Last name: <input value="Llama" id="last" name="lastname" type="text"><br>
+ Email: <input value="da.green.llama@example.com" id="email" name="email" type="text"><br>
+ <input value="Send" type="submit">
+ </p>
+ </form>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/newmailaccount/html/registrationError.html b/comm/mail/test/browser/newmailaccount/html/registrationError.html
new file mode 100644
index 0000000000..9f802355d9
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/registrationError.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ <title>Fake registration page to Error XML</title>
+ </head>
+ <body>
+
+ <div class="title">Local version</div>
+ <div class="content">
+ <form action="configError.xml" method="GET">
+ <p>
+ First name: <input value="Green" id="first" name="firstname" type="text"><br>
+ Last name: <input value="Llama" id="last" name="lastname" type="text"><br>
+ Email: <input value="da.green.llama@example.com" id="email" name="email" type="text"><br>
+ <input value="Send" type="submit">
+ </p>
+ </form>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/newmailaccount/html/suggestFromName b/comm/mail/test/browser/newmailaccount/html/suggestFromName
new file mode 100644
index 0000000000..9e066a2a06
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/suggestFromName
@@ -0,0 +1,13 @@
+[{"product": "personalized_email", "addresses": ["green@example.com",
+"green_llama@example.com", "gllama@example.com"], "succeeded": true, "quote":
+"b28acb3c0a464d33af22", "price": 0, "provider": "bar"}, {"product":
+"personalized_email", "addresses": ["green-bar@example.com", "me-bar@example.com",
+"green-bar@madeup.invalid", "green@bar.invalid", "green@barexample.invalid",
+"greenbar@greenllama.invalid", "mebar@greenllama.invalid"], "succeeded": true, "quote":
+"3f93e48679ab46a49475", "price": "20.00", "provider": "foo"},
+{"product": "personalized_email", "addresses": ["corrupt@corrupt.invalid"],
+"succeeded": true, "quote": "abcdefg", "price": 0, "provider": "corrupt"},
+{"product": "personalized_email", "addresses": ["error@error.invalid"],
+"succeeded": true, "quote": "abcdefg", "price": 0, "provider": "err"},
+{"addresses": ["default@example.com", {"address": "cheap@example.com", "price": "0"},
+{"address": "expensive@example.com", "price": "$20.00"}], "succeeded": true, "price": "$20-$0", "provider": "multi"}]
diff --git a/comm/mail/test/browser/newmailaccount/html/target.html b/comm/mail/test/browser/newmailaccount/html/target.html
new file mode 100644
index 0000000000..36c0492d66
--- /dev/null
+++ b/comm/mail/test/browser/newmailaccount/html/target.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ <title>Well, how do you do!</title>
+ </head>
+ <body>
+ <h1>Testing, testing, 1..2..3..</h1>
+ </body>
+</html>
diff --git a/comm/mail/test/browser/notification/browser.ini b/comm/mail/test/browser/notification/browser.ini
new file mode 100644
index 0000000000..2ea2e1e0eb
--- /dev/null
+++ b/comm/mail/test/browser/notification/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+prefs =
+ mail.biff.use_system_alert=true
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_notification.js]
diff --git a/comm/mail/test/browser/notification/browser_notification.js b/comm/mail/test/browser/notification/browser_notification.js
new file mode 100644
index 0000000000..5e97cf1684
--- /dev/null
+++ b/comm/mail/test/browser/notification/browser_notification.js
@@ -0,0 +1,720 @@
+/* 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 { be_in_folder, create_folder, make_message_sets_in_folders } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+var {
+ plan_for_new_window,
+ plan_for_window_close,
+ wait_for_new_window,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+// Our global folder variables...
+var gFolder = null;
+var gFolder2 = null;
+
+// An object to keep track of the boolean preferences we change, so that
+// we can put them back.
+var gOrigBoolPrefs = {};
+var gTotalOpenTime;
+
+// Used by make_gradually_newer_sets_in_folders
+var gMsgMinutes = 9000;
+
+// We'll use this mock alerts service to capture notification events
+var gMockAlertsService = {
+ _doFail: false,
+ _doClick: false,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]),
+
+ showAlert(alertInfo, alertListener) {
+ let { imageURL, title, text, textClickable, cookie, name } = alertInfo;
+ // Setting the _doFail flag allows us to revert to the newmailalert.xhtml
+ // notification
+ if (this._doFail) {
+ SimpleTest.expectUncaughtException(true);
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ this._didNotify = true;
+ this._imageUrl = imageURL;
+ this._title = title;
+ this._text = text;
+ this._textClickable = textClickable;
+ this._cookie = cookie;
+ this._alertListener = alertListener;
+ this._name = name;
+
+ if (this._doClick) {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(
+ () =>
+ this._alertListener.observe(null, "alertclickcallback", this._cookie),
+ 100
+ );
+ } else {
+ this._alertListener.observe(null, "alertfinished", this._cookie);
+ }
+ },
+
+ _didNotify: false,
+ _imageUrl: null,
+ _title: null,
+ _text: null,
+ _textClickable: null,
+ _cookie: null,
+ _alertListener: null,
+ _name: null,
+
+ _reset() {
+ // Tell any listeners that we're through
+ if (this._alertListener) {
+ this._alertListener.observe(null, "alertfinished", this._cookie);
+ }
+
+ this._doFail = false;
+ this._doClick = false;
+ this._didNotify = false;
+ this._imageUrl = null;
+ this._title = null;
+ this._text = null;
+ this._textClickable = null;
+ this._cookie = null;
+ this._alertListener = null;
+ this._name = null;
+ },
+};
+
+var gMockAlertsServiceFactory = {
+ createInstance(aIID) {
+ if (!aIID.equals(Ci.nsIAlertsService)) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ return gMockAlertsService;
+ },
+};
+
+add_setup(async function () {
+ // Register the mock alerts service
+ let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.registerFactory(
+ Components.ID("{1bda6c33-b089-43df-a8fd-111907d6385a}"),
+ "Mock Alerts Service",
+ "@mozilla.org/system-alerts-service;1",
+ gMockAlertsServiceFactory
+ );
+
+ // Ensure we have enabled new mail notifications
+ remember_and_set_bool_pref("mail.biff.show_alert", true);
+
+ // Ensure that system notifications are used (relevant for Linux only)
+ if (
+ Services.appinfo.OS == "Linux" ||
+ "@mozilla.org/gio-service;1" in Cc ||
+ "@mozilla.org/gnome-gconf-service;1" in Cc
+ ) {
+ remember_and_set_bool_pref("mail.biff.use_system_alert", true);
+ }
+
+ MailServices.accounts.localFoldersServer.performingBiff = true;
+
+ // Create a second identity to check cross-account
+ // notifications.
+ var identity2 = MailServices.accounts.createIdentity();
+ identity2.email = "new-account@foo.invalid";
+
+ var server = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "Test Local Folders",
+ "pop3"
+ );
+
+ server.performingBiff = true;
+
+ // Create the target folders
+ gFolder = await create_folder("My Folder");
+ let localRoot = server.rootFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ gFolder2 = localRoot.createLocalSubfolder("Another Folder");
+
+ var account = MailServices.accounts.createAccount();
+ account.incomingServer = server;
+ account.addIdentity(identity2);
+});
+
+registerCleanupFunction(function () {
+ put_bool_prefs_back();
+ if (Services.appinfo.OS != "Darwin") {
+ Services.prefs.setIntPref("alerts.totalOpenTime", gTotalOpenTime);
+ }
+
+ // Request focus on something in the main window so the test doesn't time
+ // out waiting for focus.
+ document.getElementById("button-appmenu").focus();
+});
+
+function setupTest(test) {
+ gFolder.markAllMessagesRead(null);
+ gMockAlertsService._reset();
+ gMockAlertsService._doFail = false;
+ gFolder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail;
+ gFolder2.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail;
+
+ remember_and_set_bool_pref("mail.biff.alert.show_subject", true);
+ remember_and_set_bool_pref("mail.biff.alert.show_sender", true);
+ remember_and_set_bool_pref("mail.biff.alert.show_preview", true);
+ if (Services.appinfo.OS != "Darwin") {
+ gTotalOpenTime = Services.prefs.getIntPref("alerts.totalOpenTime");
+ Services.prefs.setIntPref("alerts.totalOpenTime", 3000);
+ }
+}
+
+function put_bool_prefs_back() {
+ for (let prefString in gOrigBoolPrefs) {
+ Services.prefs.setBoolPref(prefString, gOrigBoolPrefs[prefString]);
+ }
+}
+
+function remember_and_set_bool_pref(aPrefString, aBoolValue) {
+ if (!gOrigBoolPrefs[aPrefString]) {
+ gOrigBoolPrefs[aPrefString] = Services.prefs.getBoolPref(aPrefString);
+ }
+
+ Services.prefs.setBoolPref(aPrefString, aBoolValue);
+}
+
+/**
+ * This function wraps up MessageInjection.makeNewSetsInFolders, and takes the
+ * same arguments. The point of this function is to ensure that
+ * each sent message is slightly newer than the last. In this
+ * case, each new message set will be sent one minute further
+ * into the future than the last message set.
+ *
+ * @see MessageInjection.makeNewSetsInFolders
+ */
+async function make_gradually_newer_sets_in_folder(aFolder, aArgs) {
+ gMsgMinutes -= 1;
+ if (!aArgs.age) {
+ for (let arg of aArgs) {
+ arg.age = { minutes: gMsgMinutes };
+ }
+ }
+ return make_message_sets_in_folders(aFolder, aArgs);
+}
+
+/**
+ * Test that receiving new mail causes a notification to appear
+ */
+add_task(async function test_new_mail_received_causes_notification() {
+ setupTest();
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+});
+
+/**
+ * Test that if notification shows, we don't show newmailalert.xhtml
+ */
+add_task(async function test_dont_show_newmailalert() {
+ setupTest();
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+
+ // Wait for newmailalert.xhtml to show
+ plan_for_new_window("alert:alert");
+ try {
+ wait_for_new_window("alert:alert");
+ throw Error("Opened newmailalert.xhtml when we shouldn't have.");
+ } catch (e) {
+ // Correct behaviour - the window didn't show.
+ }
+});
+
+/**
+ * Test that we notify, showing the oldest new, unread message received
+ * since the last notification.
+ */
+add_task(async function test_show_oldest_new_unread_since_last_notification() {
+ setupTest();
+ let notifyFirst = "This should notify first";
+ Assert.ok(!gMockAlertsService._didNotify, "Should not have notified yet.");
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, body: { body: notifyFirst } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ gMockAlertsService._text.includes(notifyFirst, 1),
+ "Should have notified for the first message"
+ );
+
+ await be_in_folder(gFolder);
+ gFolder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail;
+ gMockAlertsService._reset();
+
+ let notifySecond = "This should notify second";
+ Assert.ok(!gMockAlertsService._didNotify, "Should not have notified yet.");
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, body: { body: notifySecond } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ gMockAlertsService._text.includes(notifySecond, 1),
+ "Should have notified for the second message"
+ );
+});
+
+/**
+ * Test that notifications work across different accounts.
+ */
+add_task(async function test_notification_works_across_accounts() {
+ setupTest();
+ // Cause a notification in the first folder
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+
+ gMockAlertsService._reset();
+ // We'll set the time for these messages to be slightly further
+ // into the past. That way, test_notification_independent_across_accounts
+ // has an opportunity to send slightly newer messages that are older than
+ // the messages sent to gFolder.
+ await make_gradually_newer_sets_in_folder(
+ [gFolder2],
+ [{ count: 2, age: { minutes: gMsgMinutes + 20 } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+});
+
+/* Test that notification timestamps are independent from account
+ * to account. This is for the scenario where we have two accounts, and
+ * one has notified while the other is still updating. When the second
+ * account completes, if it has new mail, it should notify, even if second
+ * account's newest mail is older than the first account's newest mail.
+ */
+add_task(async function test_notifications_independent_across_accounts() {
+ setupTest();
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+
+ gMockAlertsService._reset();
+ // Next, let's make some mail arrive in the second folder, but
+ // let's have that mail be slightly older than the mail that
+ // landed in the first folder. We should still notify.
+ await make_gradually_newer_sets_in_folder(
+ [gFolder2],
+ [{ count: 2, age: { minutes: gMsgMinutes + 10 } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+});
+
+/**
+ * Test that we can show the message subject in the notification.
+ */
+add_task(async function test_show_subject() {
+ setupTest();
+ let subject = "This should be displayed";
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1, subject }]);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ gMockAlertsService._text.includes(subject),
+ "Should have displayed the subject"
+ );
+});
+
+/**
+ * Test that we can hide the message subject in the notification.
+ */
+add_task(async function test_hide_subject() {
+ setupTest();
+ Services.prefs.setBoolPref("mail.biff.alert.show_subject", false);
+ let subject = "This should not be displayed";
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1, subject }]);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ !gMockAlertsService._text.includes(subject),
+ "Should not have displayed the subject"
+ );
+});
+
+/**
+ * Test that we can show just the message sender in the notification.
+ */
+add_task(async function test_show_only_subject() {
+ setupTest();
+ Services.prefs.setBoolPref("mail.biff.alert.show_preview", false);
+ Services.prefs.setBoolPref("mail.biff.alert.show_sender", false);
+ Services.prefs.setBoolPref("mail.biff.alert.show_subject", true);
+
+ let sender = ["John Cleese", "john@cleese.invalid"];
+ let subject = "This should not be displayed";
+ let messageBody = "My message preview";
+
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, from: sender, subject, body: { body: messageBody } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ gMockAlertsService._text.includes(subject),
+ "Should have displayed the subject"
+ );
+ Assert.ok(
+ !gMockAlertsService._text.includes(messageBody),
+ "Should not have displayed the preview"
+ );
+ Assert.ok(
+ !gMockAlertsService._text.includes(sender[0]),
+ "Should not have displayed the sender"
+ );
+});
+
+/**
+ * Test that we can show the message sender in the notification.
+ */
+add_task(async function test_show_sender() {
+ setupTest();
+ let sender = ["John Cleese", "john@cleese.invalid"];
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, from: sender }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ gMockAlertsService._text.includes(sender[0]),
+ "Should have displayed the sender"
+ );
+});
+
+/**
+ * Test that we can hide the message sender in the notification.
+ */
+add_task(async function test_hide_sender() {
+ setupTest();
+ Services.prefs.setBoolPref("mail.biff.alert.show_sender", false);
+ let sender = ["John Cleese", "john@cleese.invalid"];
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, from: sender }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ !gMockAlertsService._text.includes(sender[0]),
+ "Should not have displayed the sender"
+ );
+});
+
+/**
+ * Test that we can show just the message sender in the notification.
+ */
+add_task(async function test_show_only_sender() {
+ setupTest();
+ Services.prefs.setBoolPref("mail.biff.alert.show_preview", false);
+ Services.prefs.setBoolPref("mail.biff.alert.show_sender", true);
+ Services.prefs.setBoolPref("mail.biff.alert.show_subject", false);
+
+ let sender = ["John Cleese", "john@cleese.invalid"];
+ let subject = "This should not be displayed";
+ let messageBody = "My message preview";
+
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, from: sender, subject, body: { body: messageBody } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ gMockAlertsService._text.includes(sender[0]),
+ "Should have displayed the sender"
+ );
+ Assert.ok(
+ !gMockAlertsService._text.includes(messageBody),
+ "Should not have displayed the preview"
+ );
+ Assert.ok(
+ !gMockAlertsService._text.includes(subject),
+ "Should not have displayed the subject"
+ );
+});
+
+/**
+ * Test that we can show the message preview in the notification.
+ */
+add_task(async function test_show_preview() {
+ setupTest();
+ Services.prefs.setBoolPref("mail.biff.alert.show_preview", true);
+ let messageBody = "My message preview";
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, body: { body: messageBody } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ gMockAlertsService._text.includes(messageBody),
+ "Should have displayed the preview"
+ );
+});
+
+/**
+ * Test that we can hide the message preview in the notification.
+ */
+add_task(async function test_hide_preview() {
+ setupTest();
+ Services.prefs.setBoolPref("mail.biff.alert.show_preview", false);
+ let messageBody = "My message preview";
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, body: { body: messageBody } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ !gMockAlertsService._text.includes(messageBody),
+ "Should not have displayed the preview"
+ );
+});
+
+/**
+ * Test that we can show justthe message preview in the notification.
+ */
+add_task(async function test_show_only_preview() {
+ setupTest();
+ Services.prefs.setBoolPref("mail.biff.alert.show_preview", true);
+ Services.prefs.setBoolPref("mail.biff.alert.show_sender", false);
+ Services.prefs.setBoolPref("mail.biff.alert.show_subject", false);
+
+ let sender = ["John Cleese", "john@cleese.invalid"];
+ let subject = "This should not be displayed";
+ let messageBody = "My message preview";
+ await make_gradually_newer_sets_in_folder(
+ [gFolder],
+ [{ count: 1, from: sender, subject, body: { body: messageBody } }]
+ );
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ Assert.ok(
+ gMockAlertsService._text.includes(messageBody),
+ "Should have displayed the preview: " + gMockAlertsService._text
+ );
+ Assert.ok(
+ !gMockAlertsService._text.includes(sender[0]),
+ "Should not have displayed the sender"
+ );
+ Assert.ok(
+ !gMockAlertsService._text.includes(subject),
+ "Should not have displayed the subject"
+ );
+});
+
+/**
+ * Test that we can receive notifications even when the biff state of
+ * the folder has not been changed.
+ */
+add_task(async function test_still_notify_with_unchanged_biff() {
+ setupTest();
+ // For now, we'll make sure that if we receive 10 pieces
+ // of email, one after the other, we'll be notified for all
+ // (assuming of course that the notifications have a chance
+ // to close in between arrivals - we don't want a queue of
+ // notifications to go through).
+ const HOW_MUCH_MAIL = 10;
+
+ Assert.ok(!gMockAlertsService._didNotify, "Should have notified.");
+
+ for (let i = 0; i < HOW_MUCH_MAIL; i++) {
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ gMockAlertsService._reset();
+ }
+});
+
+/**
+ * Test that we don't receive notifications for Draft, Queue, SentMail,
+ * Templates or Junk folders.
+ */
+add_task(async function test_no_notification_for_uninteresting_folders() {
+ setupTest();
+ var someFolder = await create_folder("Uninteresting Folder");
+ var uninterestingFlags = [
+ Ci.nsMsgFolderFlags.Drafts,
+ Ci.nsMsgFolderFlags.Queue,
+ Ci.nsMsgFolderFlags.SentMail,
+ Ci.nsMsgFolderFlags.Templates,
+ Ci.nsMsgFolderFlags.Junk,
+ Ci.nsMsgFolderFlags.Archive,
+ ];
+
+ for (let i = 0; i < uninterestingFlags.length; i++) {
+ someFolder.flags = uninterestingFlags[i];
+ await make_gradually_newer_sets_in_folder([someFolder], [{ count: 1 }]);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 100));
+ Assert.ok(!gMockAlertsService._didNotify, "Showed alert notification.");
+ }
+
+ // However, we want to ensure that Inboxes *always* notify, even
+ // if they possess the flags we consider uninteresting.
+ someFolder.flags = Ci.nsMsgFolderFlags.Inbox;
+
+ for (let i = 0; i < uninterestingFlags.length; i++) {
+ someFolder.flags |= uninterestingFlags[i];
+ await make_gradually_newer_sets_in_folder([someFolder], [{ count: 1 }]);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ someFolder.flags = someFolder.flags & ~uninterestingFlags[i];
+ }
+});
+
+/**
+ * Test what happens when clicking on a notification. This depends on whether
+ * the message pane is open, and the value of mail.openMessageBehavior.
+ */
+add_task(async function test_click_on_notification() {
+ setupTest();
+
+ const tabmail = document.getElementById("tabmail");
+ const about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.paneLayout.messagePaneVisible = true;
+ const about3PaneAboutMessage = about3Pane.messageBrowser.contentWindow;
+
+ let lastMessage;
+ async function ensureMessageLoaded(aboutMessage) {
+ let messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+ if (
+ messagePaneBrowser.webProgess?.isLoadingDocument ||
+ messagePaneBrowser.currentURI.spec == "about:blank" ||
+ aboutMessage.gMessage != lastMessage
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url != "about:blank"
+ );
+ }
+ }
+
+ // Create a message and click on the notification. This should open the
+ // message in the first tab.
+
+ gMockAlertsService._doClick = true;
+
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+ lastMessage = [...gFolder.messages].at(-1);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ await ensureMessageLoaded(about3PaneAboutMessage);
+
+ Assert.equal(tabmail.tabInfo.length, 1, "the existing tab should be used");
+ Assert.equal(about3Pane.gFolder, gFolder);
+ Assert.equal(about3PaneAboutMessage.gMessage, lastMessage);
+
+ gMockAlertsService._reset();
+
+ // Open a second message. This should also open in the first tab.
+
+ gMockAlertsService._doClick = true;
+
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+ lastMessage = [...gFolder.messages].at(-1);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ await ensureMessageLoaded(about3PaneAboutMessage);
+
+ Assert.equal(tabmail.tabInfo.length, 1, "the existing tab should be used");
+ Assert.equal(about3Pane.gFolder, gFolder);
+ Assert.equal(about3PaneAboutMessage.gMessage, lastMessage);
+
+ gMockAlertsService._reset();
+
+ // Close the message pane. Clicking on the notification should now open the
+ // message in a new tab.
+
+ about3Pane.paneLayout.messagePaneVisible = false;
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_TAB
+ );
+
+ let tabPromise = BrowserTestUtils.waitForEvent(tabmail, "aboutMessageLoaded");
+ gMockAlertsService._doClick = true;
+
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+ lastMessage = [...gFolder.messages].at(-1);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ let { target: tabAboutMessage } = await tabPromise;
+ await ensureMessageLoaded(tabAboutMessage);
+
+ Assert.equal(tabmail.tabInfo.length, 2, "a new tab should be used");
+ Assert.equal(
+ tabmail.currentTabInfo,
+ tabmail.tabInfo[1],
+ "the new tab should be in the foreground"
+ );
+ Assert.equal(
+ tabmail.currentTabInfo.mode.name,
+ "mailMessageTab",
+ "the new tab should be a message tab"
+ );
+ Assert.equal(tabAboutMessage.gMessage, lastMessage);
+
+ tabmail.closeOtherTabs(0);
+ gMockAlertsService._reset();
+
+ // Change the preference to open a new window instead of a new tab.
+
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_WINDOW
+ );
+
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.location.href == "chrome://messenger/content/messageWindow.xhtml"
+ );
+ gMockAlertsService._doClick = true;
+
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 1 }]);
+ lastMessage = [...gFolder.messages].at(-1);
+ await TestUtils.waitForCondition(() => gMockAlertsService._didNotify);
+ let win = await winPromise;
+ let winAboutMessage = win.messageBrowser.contentWindow;
+ await ensureMessageLoaded(winAboutMessage);
+
+ Assert.equal(winAboutMessage.gMessage, lastMessage);
+ await BrowserTestUtils.closeWindow(win);
+
+ // Clean up.
+
+ Services.prefs.clearUserPref("mail.openMessageBehavior");
+ about3Pane.paneLayout.messagePaneVisible = true;
+});
+
+/**
+ * Test that we revert to newmailalert.xhtml if there is no system notification
+ * service present.
+ *
+ * NOTE: this test should go last because if
+ * nsIAlertsService.showAlertNotification failed for once, we always fallback to
+ * newmailalert.xhtml afterwards.
+ */
+add_task(async function test_revert_to_newmailalert() {
+ setupTest();
+ // Set up the gMockAlertsService so that it fails
+ // to send a notification.
+ gMockAlertsService._doFail = true;
+
+ if (AppConstants.platform == "macosx") {
+ // newmailalert.xhtml doesn't work on macOS.
+ return;
+ }
+
+ // We expect the newmailalert.xhtml window...
+ plan_for_new_window("alert:alert");
+ await make_gradually_newer_sets_in_folder([gFolder], [{ count: 2 }]);
+ let controller = wait_for_new_window("alert:alert");
+ plan_for_window_close(controller);
+ wait_for_window_close();
+});
diff --git a/comm/mail/test/browser/openpgp/browser.ini b/comm/mail/test/browser/openpgp/browser.ini
new file mode 100644
index 0000000000..5c094046b6
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = data/**
+
+[browser_collectKeys.js]
+[browser_keyWizard.js]
+[browser_openPGPDrafts.js]
+[browser_perm_decrypt.js]
+[browser_viewMessage.js]
+[browser_viewMessage2.js]
+[browser_viewMessageSecurity.js]
+[browser_viewPartialMessage.js]
diff --git a/comm/mail/test/browser/openpgp/browser_collectKeys.js b/comm/mail/test/browser/openpgp/browser_collectKeys.js
new file mode 100644
index 0000000000..d87df03ba3
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser_collectKeys.js
@@ -0,0 +1,318 @@
+/* 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/. */
+
+/**
+ * Tests for the collecting keys from messages.
+ */
+
+"use strict";
+
+const { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+const { waitForCondition } = ChromeUtils.import(
+ "resource://testing-common/mozmill/utils.jsm"
+);
+const {
+ assert_notification_displayed,
+ get_notification_button,
+ wait_for_notification_to_show,
+ wait_for_notification_to_stop,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { CollectedKeysDB } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/CollectedKeysDB.jsm"
+);
+
+var aliceAcct;
+
+/**
+ * When testing a scenario that should automatically process the OpenPGP
+ * contents (it's not suppressed e.g. because of a partial content),
+ * then we need to wait for the automatic processing to complete.
+ */
+async function openpgpProcessed() {
+ let [subject] = await TestUtils.topicObserved(
+ "document-element-inserted",
+ document => {
+ return document.ownerGlobal?.location == "about:message";
+ }
+ );
+
+ return BrowserTestUtils.waitForEvent(subject, "openpgpprocessed");
+}
+
+/**
+ * Set up the base account, identity and keys needed for the tests.
+ */
+add_setup(async function () {
+ aliceAcct = MailServices.accounts.createAccount();
+ aliceAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "pop3"
+ );
+ let aliceIdentity = MailServices.accounts.createIdentity();
+ aliceIdentity.email = "alice@openpgp.example";
+ aliceAcct.addIdentity(aliceIdentity);
+
+ // Set up the alice's private key.
+ // We need one key set up for use. Otherwise we do not process OpenPGP data.
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ )
+ )
+ );
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", id);
+});
+
+/**
+ * Test that an attached key is collected.
+ */
+add_task(async function testCollectKeyAttachment() {
+ let keycollected = BrowserTestUtils.waitForEvent(window, "keycollected");
+ let opengpgprocessed = openpgpProcessed();
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/unsigned-unencrypted-key-0x1f10171bfb881b1c-attached.eml"
+ )
+ )
+ );
+ await opengpgprocessed;
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ await keycollected;
+
+ let db = await CollectedKeysDB.getInstance();
+ let keys = await db.findKeysForEmail("jdoe@invalid");
+ Assert.equal(keys.length, 1, "should find one key");
+
+ let sources = keys[0].sources;
+ Assert.equal(sources.length, 1, "should have one source");
+ let source = sources[0];
+
+ Assert.equal(source.type, "attachment");
+ Assert.equal(source.uri, "mid:4a735c72-dc19-48ff-4fa5-2c1f65513b27@invalid");
+ Assert.equal(source.description, "OpenPGP_0x1F10171BFB881B1C.asc");
+
+ close_window(mc);
+});
+
+/**
+ * Test that an Autocrypt header key is collected.
+ */
+add_task(async function testCollectAutocrypt() {
+ let keycollected = BrowserTestUtils.waitForEvent(window, "keycollected");
+ let opengpgprocessed = openpgpProcessed();
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/unsigned-unencrypted-0x3099ff1238852b9f-autocrypt.eml"
+ )
+ )
+ );
+ await opengpgprocessed;
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ await keycollected;
+
+ const carolEmail = "carol@example.com";
+
+ let db = await CollectedKeysDB.getInstance();
+ let keys = await db.findKeysForEmail(carolEmail);
+ Assert.equal(keys.length, 1, "should find one key");
+
+ let sources = keys[0].sources;
+ Assert.equal(sources.length, 1, "should have one source");
+ let source = sources[0];
+
+ Assert.equal(source.type, "autocrypt");
+ Assert.equal(
+ source.uri,
+ "mid:b3609461-36e8-0371-1b9d-7ce6864ec66d@example.com"
+ );
+ Assert.equal(source.description, undefined);
+
+ // Clean up to ensure other tests will not find this key
+ db.deleteKeysForEmail(carolEmail);
+ keys = await db.findKeysForEmail(carolEmail);
+ Assert.equal(keys.length, 0, "should find zero keys after cleanup");
+
+ close_window(mc);
+});
+
+/**
+ * Test that an Autocrypt-Gossip header key is collected.
+ */
+add_task(async function testCollectAutocryptGossip() {
+ let keycollected = BrowserTestUtils.waitForEvent(window, "keycollected");
+ let keycollected2 = BrowserTestUtils.waitForEvent(window, "keycollected");
+ let keycollected3 = BrowserTestUtils.waitForEvent(window, "keycollected");
+ let opengpgprocessed = openpgpProcessed();
+ let msgc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/signed-encrypted-autocrypt-gossip.eml")
+ )
+ );
+ await opengpgprocessed;
+ let aboutMessage = get_about_message(msgc.window);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "signed icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+
+ await keycollected;
+ await keycollected2;
+ await keycollected3;
+
+ const carolEmail = "carol@example.com";
+
+ let db = await CollectedKeysDB.getInstance();
+ let keys = await db.findKeysForEmail(carolEmail);
+ Assert.equal(keys.length, 1, "should find one key");
+
+ let sources = keys[0].sources;
+ Assert.equal(sources.length, 1, "should have one source");
+ let source = sources[0];
+
+ Assert.equal(source.type, "autocrypt");
+ Assert.equal(
+ source.uri,
+ "mid:e8690528-d187-4d99-b505-9f3d6a2704ca@openpgp.example"
+ );
+ Assert.equal(source.description, undefined);
+
+ // Clean up to ensure other tests will not find this key
+ db.deleteKeysForEmail(carolEmail);
+ keys = await db.findKeysForEmail(carolEmail);
+ Assert.equal(keys.length, 0, "should find zero keys after cleanup");
+
+ close_window(msgc);
+});
+
+/**
+ * Test that we don't collect keys that refer to an email address that
+ * isn't one of the message participants, and that we don't collect keys
+ * if we already have a personal key for an email address.
+ */
+add_task(async function testSkipFakeOrUnrelatedKeys() {
+ let opengpgprocessed = openpgpProcessed();
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/unrelated-and-fake-keys-attached.eml")
+ )
+ );
+ await opengpgprocessed;
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+
+ let db = await CollectedKeysDB.getInstance();
+
+ let keys = await db.findKeysForEmail("alice@openpgp.example");
+ Assert.equal(
+ keys.length,
+ 0,
+ "the attached key for alice should have been ignored because we have a personal key for that address"
+ );
+
+ keys = await db.findKeysForEmail("stranger@example.com");
+ Assert.equal(
+ keys.length,
+ 0,
+ "the attached key for stranger should have been ignored because stranger isn't a participant of this message"
+ );
+
+ let bobEmail = "bob@openpgp.example";
+ keys = await db.findKeysForEmail(bobEmail);
+ Assert.equal(keys.length, 1, "bob's key should have been collected");
+
+ db.deleteKeysForEmail(bobEmail);
+ keys = await db.findKeysForEmail(bobEmail);
+ Assert.equal(keys.length, 0, "should find zero keys after cleanup");
+
+ close_window(mc);
+});
+
+/**
+ * If an email contains two different keys for the same email address,
+ * don't import any keys for that email address.
+ */
+add_task(async function testSkipDuplicateKeys() {
+ let opengpgprocessed = openpgpProcessed();
+ let mc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/eml/eve-duplicate.eml"))
+ );
+ await opengpgprocessed;
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+
+ let db = await CollectedKeysDB.getInstance();
+
+ let keys = await db.findKeysForEmail("eve@example.com");
+ Assert.equal(
+ keys.length,
+ 0,
+ "the attached keys for eve should have been ignored"
+ );
+
+ close_window(mc);
+});
+
+registerCleanupFunction(async function tearDown() {
+ MailServices.accounts.removeAccount(aliceAcct, true);
+ await OpenPGPTestUtils.removeKeyById("0xf231550c4f47e38e", true);
+ await CollectedKeysDB.deleteDb();
+});
diff --git a/comm/mail/test/browser/openpgp/browser_keyWizard.js b/comm/mail/test/browser/openpgp/browser_keyWizard.js
new file mode 100644
index 0000000000..5b63bb1da9
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser_keyWizard.js
@@ -0,0 +1,363 @@
+/* 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/. */
+
+/**
+ * Tests for the creation or import of an OpenPGP key.
+ */
+
+"use strict";
+
+const { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const {
+ click_account_tree_row,
+ get_account_tree_row,
+ wait_for_account_tree_load,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/AccountManagerHelpers.jsm"
+);
+var { open_content_tab_with_url } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+const { wait_for_frame_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { MockFilePicker } = ChromeUtils.importESModule(
+ "resource://testing-common/MockFilePicker.sys.mjs"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+var gAccount;
+var gIdentity;
+var gTab;
+var tabDocument;
+var tabWindow;
+
+const EXTERNAL_GNUP_KEY = "123456789ASD";
+
+var gCreatedKeyId;
+var gImportedKeyId;
+
+/**
+ * Set up the base account and identity.
+ */
+add_setup(function () {
+ gAccount = MailServices.accounts.createAccount();
+ gAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "pop3"
+ );
+ gIdentity = MailServices.accounts.createIdentity();
+ gIdentity.email = "alice@openpgp.example";
+ gAccount.addIdentity(gIdentity);
+
+ Services.prefs.setBoolPref("mail.openpgp.allow_external_gnupg", true);
+
+ gTab = open_content_tab_with_url("about:accountsettings");
+ wait_for_account_tree_load(gTab);
+
+ // Open the End-to-End Encryption page.
+ let accountRow = get_account_tree_row(gAccount.key, "am-e2e.xhtml", gTab);
+ click_account_tree_row(gTab, accountRow);
+
+ let iframe =
+ gTab.browser.contentWindow.document.getElementById("contentFrame");
+
+ tabDocument = iframe.contentDocument;
+ tabWindow = iframe.contentDocument.ownerGlobal;
+});
+
+/**
+ * Test that we don't have any initial OpenPGP key configured in the
+ * End-to-End Encryption page.
+ */
+add_task(async function check_clean_keylist() {
+ // The OpenPGP Key List container should not be visible.
+ Assert.equal(
+ tabDocument.getElementById("openPgpKeyList").hidden,
+ true,
+ "The openPgpKeyList container shouldn't be visible"
+ );
+
+ // The OpenPGP Key List radiogroup should only have 1 item (the None).
+ Assert.equal(
+ tabDocument.getElementById("openPgpKeyListRadio").itemCount,
+ 1,
+ "The OpenPGP Key List radiogroup should only have 1 item"
+ );
+
+ // The "None" radio item should be currently selected.
+ Assert.equal(
+ tabDocument.getElementById("openPgpNone").selected,
+ true,
+ "The openPgpNone radio option is currently selected"
+ );
+});
+
+/**
+ * Generate a new OpenPGP Key.
+ */
+add_task(async function generate_new_key() {
+ // Open the key wizard from the "Add Key" button.
+ let button = tabDocument.getElementById("addOpenPgpButton");
+ EventUtils.synthesizeMouseAtCenter(button, {}, tabWindow);
+
+ let wizard = wait_for_frame_load(
+ gTab.browser.contentWindow.gSubDialog._topDialog._frame,
+ "chrome://openpgp/content/ui/keyWizard.xhtml"
+ );
+ let doc = wizard.window.document;
+ let dialog = doc.documentElement.querySelector("dialog");
+
+ let keyGenView = doc.getElementById("wizardCreateKey");
+
+ // Accept the dialog since the first option should be automatically selected.
+ dialog.acceptDialog();
+ await BrowserTestUtils.waitForCondition(
+ () => wizard.window.getComputedStyle(keyGenView).opacity == 1,
+ "Timeout waiting for the #wizardCreateKey to appear"
+ );
+
+ // Change the expiration.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.getElementById("keygenDoesNotExpire"),
+ {},
+ dialog.ownerGlobal
+ );
+
+ let wizardOverlay = doc.getElementById("wizardOverlay");
+
+ // Move to the next screen.
+ dialog.acceptDialog();
+ await BrowserTestUtils.waitForCondition(
+ () => wizard.window.getComputedStyle(wizardOverlay).opacity == 1,
+ "Timeout waiting for the #wizardOverlay to appear"
+ );
+
+ // Store the wait event here before the SubDialog is destroyed and we can't
+ // access it anymore.
+ let frameWinUnload = BrowserTestUtils.waitForEvent(
+ gTab.browser.contentWindow.gSubDialog._topDialog._frame.contentWindow,
+ "unload",
+ true
+ );
+
+ // Confirm the generation of the new key.
+ let confirmButton = doc.getElementById("openPgpKeygenConfirmButton");
+ EventUtils.synthesizeMouseAtCenter(confirmButton, {}, dialog.ownerGlobal);
+
+ // Wait for the subdialog to close.
+ info("Waiting for subdialog unload");
+ await frameWinUnload;
+
+ // The key wizard should automatically assign the newly generated key to the
+ // selected identity and close the dialog. Let's wait for that change.
+ await TestUtils.waitForCondition(
+ () => gIdentity.getUnicharAttribute("openpgp_key_id"),
+ "Timeout waiting for the newly generated key to be set"
+ );
+ gCreatedKeyId = gIdentity.getUnicharAttribute("openpgp_key_id");
+
+ // The OpenPGP Key List radiogroup should only have 2 items now (None, and a key).
+ Assert.equal(
+ tabDocument.getElementById("openPgpKeyListRadio").itemCount,
+ 2,
+ "The OpenPGP Key List radiogroup should have 2 items"
+ );
+
+ // The "None" radio item should NOT be selected.
+ Assert.equal(
+ tabDocument.getElementById("openPgpNone").selected,
+ false,
+ "The openPgpNone radio option should not be selected"
+ );
+});
+
+/**
+ * Import a previously exported secret OpenPGP Key.
+ */
+add_task(async function import_secret_key() {
+ // Open the key wizard from the "Add Key" button.
+ let button = tabDocument.getElementById("addOpenPgpButton");
+ EventUtils.synthesizeMouseAtCenter(button, {}, tabWindow);
+
+ let wizard = wait_for_frame_load(
+ gTab.browser.contentWindow.gSubDialog._topDialog._frame,
+ "chrome://openpgp/content/ui/keyWizard.xhtml"
+ );
+ let doc = wizard.window.document;
+ let dialog = doc.documentElement.querySelector("dialog");
+
+ // Change the selection to import a key.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.getElementById("importOpenPgp"),
+ {},
+ dialog.ownerGlobal
+ );
+
+ let importView = doc.getElementById("wizardImportKey");
+
+ // Accept the dialog to move to the next screen.
+ dialog.acceptDialog();
+ await BrowserTestUtils.waitForCondition(
+ () => wizard.window.getComputedStyle(importView).opacity == 1,
+ "Timeout waiting for the #wizardImportKey to appear"
+ );
+
+ let ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ let chromeUrl = Services.io.newURI(
+ getRootDirectory(gTestPath) +
+ "data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ );
+ gImportedKeyId = "0xf231550c4f47e38e";
+ let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl);
+ let file = fileUrl.QueryInterface(Ci.nsIFileURL).file;
+
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ let importButton = doc
+ .getElementById("importKeyIntro")
+ .querySelector("button");
+ EventUtils.synthesizeMouseAtCenter(importButton, {}, dialog.ownerGlobal);
+
+ // The container with the listed keys to import should be visible.
+ await BrowserTestUtils.waitForCondition(
+ () => !doc.getElementById("importKeyListContainer").collapsed,
+ "Timeout waiting for the #importKeyListContainer to appear"
+ );
+
+ let keyList = doc.getElementById("importKeyList");
+
+ // The dialog should display 1 key ready to be imported.
+ Assert.equal(
+ keyList.childNodes.length,
+ 1,
+ "Only 1 fetched key is listed in the #importKeyList container"
+ );
+
+ // Be sure the listed private key is checked to be used as personal key.
+ Assert.equal(
+ keyList.querySelector("checkbox").checked,
+ true,
+ "The imported key is marked to be used as personal key"
+ );
+
+ // Accept the dialog to move to the next screen.
+ dialog.acceptDialog();
+ await BrowserTestUtils.waitForCondition(
+ () => !doc.getElementById("importKeyListSuccess").collapsed,
+ "Timeout waiting for the #importKeyListSuccess to appear"
+ );
+
+ let keyListRecap = doc.getElementById("importKeyListRecap");
+
+ // The dialog should display 1 key ready to be imported.
+ Assert.equal(
+ keyListRecap.childNodes.length,
+ 1,
+ "Only 1 imported key is listed in the #importKeyListRecap container"
+ );
+
+ let keyListRadio = tabDocument.getElementById("openPgpKeyListRadio");
+
+ // Accept the dialog to close it.
+ dialog.acceptDialog();
+ await BrowserTestUtils.waitForCondition(
+ () => keyListRadio.itemCount == 3,
+ "Timeout waiting for the #importKeyListSuccess to appear"
+ );
+
+ Assert.equal(keyListRadio.itemCount, 3, "The 3 keys are listed");
+
+ // The previously configured OpenPGP key should still be selected.
+ Assert.equal(
+ keyListRadio.selectedIndex,
+ 1,
+ "The previously generated secret key is still selected"
+ );
+});
+
+/**
+ * Manually set and external GnuPG key.
+ */
+add_task(async function add_external_key() {
+ // Open the key wizard from the "Add Key" button.
+ let button = tabDocument.getElementById("addOpenPgpButton");
+ EventUtils.synthesizeMouseAtCenter(button, {}, tabWindow);
+
+ let wizard = wait_for_frame_load(
+ gTab.browser.contentWindow.gSubDialog._topDialog._frame,
+ "chrome://openpgp/content/ui/keyWizard.xhtml"
+ );
+ let doc = wizard.window.document;
+ let dialog = doc.documentElement.querySelector("dialog");
+
+ // Change the selection to import a key.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.getElementById("externalOpenPgp"),
+ {},
+ dialog.ownerGlobal
+ );
+
+ let externalView = doc.getElementById("wizardExternalKey");
+
+ // Accept the dialog to move to the next screen.
+ dialog.acceptDialog();
+ await BrowserTestUtils.waitForCondition(
+ () => wizard.window.getComputedStyle(externalView).opacity == 1,
+ "Timeout waiting for the #wizardExternalKey to appear"
+ );
+
+ doc.getElementById("externalKey").focus();
+ EventUtils.sendString(EXTERNAL_GNUP_KEY, wizard.window);
+
+ let keyListRadio = tabDocument.getElementById("openPgpKeyListRadio");
+
+ // Accept the dialog to close it.
+ dialog.acceptDialog();
+ await BrowserTestUtils.waitForCondition(
+ () => keyListRadio.itemCount == 4,
+ "Waiting for the newly imported key to be listed"
+ );
+
+ Assert.equal(keyListRadio.itemCount, 4, "The 4 keys are listed");
+
+ // The first key should currently be selected.
+ Assert.equal(
+ keyListRadio.selectedIndex,
+ 1,
+ "The external key is selected and listed at first position"
+ );
+
+ // Confirm that the currently listed key is correct.
+ Assert.equal(
+ gIdentity.getUnicharAttribute("openpgp_key_id"),
+ EXTERNAL_GNUP_KEY,
+ "The external key was properly set for the current identity"
+ );
+});
+
+registerCleanupFunction(async function () {
+ mc.window.document.getElementById("tabmail").closeTab(gTab);
+ gTab = null;
+ tabDocument = null;
+ tabWindow = null;
+
+ Services.prefs.clearUserPref("mail.openpgp.allow_external_gnupg");
+ MailServices.accounts.removeAccount(gAccount, true);
+ gAccount = null;
+ await OpenPGPTestUtils.removeKeyById(gCreatedKeyId, true);
+ await OpenPGPTestUtils.removeKeyById(gImportedKeyId, true);
+});
diff --git a/comm/mail/test/browser/openpgp/browser_openPGPDrafts.js b/comm/mail/test/browser/openpgp/browser_openPGPDrafts.js
new file mode 100644
index 0000000000..8a7ab4f945
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser_openPGPDrafts.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/. */
+
+"use strict";
+
+const { save_compose_message } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+const {
+ open_message_from_file,
+ be_in_folder,
+ get_about_message,
+ get_special_folder,
+ select_click_row,
+ open_selected_message,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+function waitForComposeWindow() {
+ return BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ await BrowserTestUtils.waitForEvent(win, "focus", true);
+ return (
+ win.document.documentURI ===
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ );
+ });
+}
+
+let aliceAcct;
+let aliceIdentity;
+let initialKeyIdPref = "";
+
+/**
+ * Setup a mail account with a private key and an imported public key for an
+ * address we can send messages to.
+ */
+add_setup(async function () {
+ aliceAcct = MailServices.accounts.createAccount();
+ aliceAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "imap"
+ );
+ aliceIdentity = MailServices.accounts.createIdentity();
+ aliceIdentity.email = "alice@openpgp.example";
+ aliceAcct.addIdentity(aliceIdentity);
+
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ )
+ )
+ );
+
+ Assert.ok(id, "private key id received");
+
+ initialKeyIdPref = aliceIdentity.getUnicharAttribute("openpgp_key_id");
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc"
+ )
+ )
+ );
+});
+
+/**
+ * Test the "Re:" prefix remains in the compose window when opening a draft
+ * reply for an encrypted message. See bug 1661510.
+ */
+add_task(async function testDraftReplyToEncryptedMessageKeepsRePrefix() {
+ let draftsFolder = await get_special_folder(
+ Ci.nsMsgFolderFlags.Drafts,
+ true,
+ aliceAcct.incomingServer.localFoldersServer
+ );
+
+ await be_in_folder(draftsFolder);
+
+ // Delete the messages we saved to drafts.
+ registerCleanupFunction(
+ async () =>
+ new Promise(resolve => {
+ let msgs = [...draftsFolder.msgDatabase.enumerateMessages()];
+
+ draftsFolder.deleteMessages(
+ msgs,
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ })
+ );
+
+ // Test signed-encrypted and unsigned-encrypted messages.
+ let msgFiles = [
+ "data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml",
+ "data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml",
+ ];
+ let wantedRow = 0;
+
+ for (let msg of msgFiles) {
+ let mc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath(msg))
+ );
+
+ let replyWindowPromise = waitForComposeWindow();
+ get_about_message(mc.window)
+ .document.querySelector("#hdrReplyButton")
+ .click();
+ close_window(mc);
+
+ let replyWindow = await replyWindowPromise;
+ await save_compose_message(replyWindow);
+ replyWindow.close();
+
+ await TestUtils.waitForCondition(
+ () => draftsFolder.getTotalMessages(true) > 0,
+ "message saved to drafts folder"
+ );
+
+ let draftWindowPromise = waitForComposeWindow();
+ select_click_row(wantedRow);
+ ++wantedRow;
+ open_selected_message();
+
+ let draftWindow = await draftWindowPromise;
+
+ Assert.ok(
+ draftWindow.document.querySelector("#msgSubject").value.startsWith("Re:"),
+ "the Re: prefix is applied"
+ );
+
+ draftWindow.close();
+ }
+});
+
+registerCleanupFunction(function tearDown() {
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", initialKeyIdPref);
+ MailServices.accounts.removeIncomingServer(aliceAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(aliceAcct);
+});
diff --git a/comm/mail/test/browser/openpgp/browser_perm_decrypt.js b/comm/mail/test/browser/openpgp/browser_perm_decrypt.js
new file mode 100644
index 0000000000..10dd0823c3
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser_perm_decrypt.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/. */
+
+/**
+ * Tests for permanent decryption of email.
+ */
+
+"use strict";
+
+const {
+ be_in_folder,
+ get_about_3pane,
+ get_about_message,
+ get_special_folder,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { EnigmailPersistentCrypto } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/persistentCrypto.jsm"
+);
+
+const MSG_TEXT = "Sundays are nothing without callaloo.";
+
+function getMsgBodyTxt(mc) {
+ let msgPane = get_about_message(mc.window).getMessagePaneBrowser();
+ return msgPane.contentDocument.documentElement.textContent;
+}
+
+var aliceAcct;
+var aliceIdentity;
+var initialKeyIdPref = "";
+var gInbox;
+
+var gDecFolder;
+
+/**
+ * Set up the base account, identity and keys needed for the tests.
+ */
+add_setup(async function () {
+ aliceAcct = MailServices.accounts.createAccount();
+ aliceAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "pop3"
+ );
+ aliceIdentity = MailServices.accounts.createIdentity();
+ aliceIdentity.email = "alice@openpgp.example";
+ aliceAcct.addIdentity(aliceIdentity);
+
+ aliceAcct.incomingServer.rootFolder.createSubfolder("decrypted", null);
+
+ gDecFolder = aliceAcct.incomingServer.rootFolder.getChildNamed("decrypted");
+
+ // Set up the alice's private key.
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ )
+ )
+ );
+
+ initialKeyIdPref = aliceIdentity.getUnicharAttribute("openpgp_key_id");
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", id);
+
+ // Import and accept the public key for Bob, our verified sender.
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc"
+ )
+ )
+ );
+
+ gInbox = await get_special_folder(Ci.nsMsgFolderFlags.Inbox, true);
+ await be_in_folder(gInbox);
+});
+
+add_task(async function testPermanentDecrypt() {
+ // Fetch a local OpenPGP message.
+ let openPgpFile = new FileUtils.File(
+ getTestFilePath(
+ "data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml"
+ )
+ );
+
+ // Add the fetched OpenPGP message to the inbox folder.
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFileMessage(
+ openPgpFile,
+ gInbox,
+ null,
+ false,
+ 0,
+ "",
+ copyListener,
+ null
+ );
+ await copyListener.promise;
+
+ // Select the first row.
+ select_click_row(0);
+
+ let aboutMessage = get_about_message();
+ Assert.equal(
+ aboutMessage.document
+ .getElementById("encryptionTechBtn")
+ .querySelector("span").textContent,
+ "OpenPGP"
+ );
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+
+ // Get header of selected message
+ let hdr = get_about_3pane().gDBView.hdrForFirstSelectedMessage;
+
+ await EnigmailPersistentCrypto.cryptMessage(hdr, gDecFolder.URI, false, null);
+
+ await OpenPGPTestUtils.removeKeyById("0xf231550c4f47e38e", true);
+
+ await be_in_folder(gDecFolder);
+
+ select_click_row(0);
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon NOT displayed"
+ );
+});
+
+registerCleanupFunction(function () {
+ // Reset the OpenPGP key and delete the account.
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", initialKeyIdPref);
+ MailServices.accounts.removeIncomingServer(aliceAcct.incomingServer, false);
+ MailServices.accounts.removeAccount(aliceAcct);
+ aliceAcct = null;
+});
diff --git a/comm/mail/test/browser/openpgp/browser_viewMessage.js b/comm/mail/test/browser/openpgp/browser_viewMessage.js
new file mode 100644
index 0000000000..9d131d4cb2
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser_viewMessage.js
@@ -0,0 +1,931 @@
+/* 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/. */
+
+/**
+ * Tests for the display of OpenPGP signed/encrypted state in opened messages.
+ */
+
+"use strict";
+
+/*
+ * This file contains S/MIME tests that should be enabled once
+ * bug 1806161 gets fixed.
+ */
+
+const {
+ get_about_message,
+ open_message_from_file,
+ wait_for_message_display_completion,
+ // TODO: Enable for S/MIME test
+ // smimeUtils_ensureNSS,
+ // smimeUtils_loadCertificateAndKey,
+ // smimeUtils_loadPEMCertificate,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { async_plan_for_new_window, close_window, wait_for_window_focused } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+const { waitForCondition } = ChromeUtils.import(
+ "resource://testing-common/mozmill/utils.jsm"
+);
+const { get_notification_button, wait_for_notification_to_show } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+ );
+
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const MSG_TEXT = "Sundays are nothing without callaloo.";
+// TODO: Enable for S/MIME test
+//const MSG_TEXT_SMIME = "This is a test message from Alice to Bob.";
+
+function getMsgBodyTxt(mc) {
+ let msgPane = get_about_message(mc.window).getMessagePaneBrowser();
+ return msgPane.contentDocument.documentElement.textContent;
+}
+
+var aliceAcct;
+
+/**
+ * Set up the base account, identity and keys needed for the tests.
+ */
+add_setup(async function () {
+ aliceAcct = MailServices.accounts.createAccount();
+ aliceAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "pop3"
+ );
+ let aliceIdentity = MailServices.accounts.createIdentity();
+ aliceIdentity.email = "alice@openpgp.example";
+ aliceAcct.addIdentity(aliceIdentity);
+
+ // Set up the alice's private key.
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ )
+ )
+ );
+
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", id);
+
+ // Import and accept the public key for Bob, our verified sender.
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc"
+ )
+ )
+ );
+
+ // TODO: Enable for S/MIME test
+ /*
+ smimeUtils_ensureNSS();
+ smimeUtils_loadPEMCertificate(
+ new FileUtils.File(getTestFilePath("../smime/data/TestCA.pem")),
+ Ci.nsIX509Cert.CA_CERT
+ );
+ smimeUtils_loadCertificateAndKey(
+ new FileUtils.File(getTestFilePath("../smime/data/Bob.p12"))
+ );
+*/
+});
+
+/**
+ * Test that an unsigned unencrypted message do not show as signed nor encrypted.
+ */
+add_task(async function testOpenNoPGPSecurity() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/unsigned-unencrypted-from-bob-to-alice.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that a signed (only) message, signed by a verified key, shows as such.
+ */
+add_task(async function testOpenSignedByVerifiedUnencrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "verified"),
+ "signed verified icon is displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that a signed (only) message, signed by a verified key,
+ * but with an mismatching email date, is shown with invalid signature.
+ */
+add_task(async function testOpenSignedDateMismatch() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/signed-mismatch-email-date.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "mismatch"),
+ "signed unknown icon is displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that opening an unsigned encrypted message shows as such.
+ */
+add_task(async function testOpenVerifiedUnsignedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that opening an attached encrypted message has no effect
+ * on security status icons of the parent message window.
+ */
+add_task(async function testOpenForwardedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/eml/fwd-unsigned-encrypted.eml"))
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ getMsgBodyTxt(mc).includes("wrapper message with plain text"),
+ "wrapper message text should be shown"
+ );
+ Assert.ok(
+ !getMsgBodyTxt(mc).includes(MSG_TEXT),
+ "message text should not be shown"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentName"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+ let mc2 = await newWindowPromise;
+ wait_for_message_display_completion(mc2, true);
+ wait_for_window_focused(mc2.window);
+ let aboutMessage2 = get_about_message(mc2.window);
+
+ // Check properties of the opened attachment window.
+ Assert.ok(
+ getMsgBodyTxt(mc2).includes(MSG_TEXT),
+ "message text should be shown"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage2.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage2.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc2);
+
+ wait_for_window_focused(mc.window);
+
+ // Ensure there were no side effects for the primary window.
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is still not displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is still not displayed"
+ );
+
+ close_window(mc);
+}).skip(); // TODO can't open message attachments yet
+
+/**
+ * Test that opening a message that is signed by a verified key shows as such.
+ */
+add_task(async function testOpenSignedByVerifiedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "verified"),
+ "signed verified icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that opening a message that is signed by a verified key, but the From
+ * is not what it should be due to multiple From headers, will show mismatch.
+ * Here it's signed by Bob, but Eve inserted an From: Eve <eve@openpgp.example>
+ * header first. Only first From is used. The second From should not
+ * be used for verification.
+ */
+add_task(async function testOpenSignedEncryptedMultiFrom() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-multi-from.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "mismatch"),
+ "mismatch icon should be displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon should be displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that opening a message signed (only) by an unverified key shows as such.
+ */
+add_task(async function testOpenSignedByUnverifiedUnencrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "signed unknown icon is displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that opening a message signed (only) with extra outer layer
+ * doesn't show signature state.
+ */
+add_task(async function testOpenSignedWithOuterLayer() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/signed-with-mailman-footer.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that opening a message encrypted (only) shows as such.
+ */
+add_task(async function testOpenUnverifiedUnsignedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * -- FUNCTIONALITY NOT YET IMPLEMENTED --
+ * Test that we decrypt a nested S/MIME encrypted message
+ * (with outer S/MIME signature that is ignored).
+ */
+/*
+add_task(async function testOuterSmimeSigInnerSmimeUnsignedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/outer-smime-bad-sig-inner-smime-enc.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT_SMIME), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+*/
+
+/**
+ * Test that we decrypt a nested OpenPGP encrypted message
+ * (with outer S/MIME signature that is ignored).
+ */
+add_task(async function testOuterSmimeSigInnerPgpUnverifiedUnsignedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/outer-smime-bad-sig-inner-pgp-enc.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * -- FUNCTIONALITY NOT YET IMPLEMENTED --
+ * Test that we decrypt a nested S/MIME encrypted message
+ * (with outer OpenPGP signature that is ignored).
+ */
+/*
+add_task(async function testOuterPgpSigInnerSmimeUnsignedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/outer-pgp-sig-inner-smime-enc.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT_SMIME), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+*/
+
+/**
+ * Test that we decrypt a nested OpenPGP encrypted message
+ * (with outer OpenPGP signature that is ignored).
+ */
+add_task(async function testOuterPgpSigInnerPgpUnverifiedUnsignedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/outer-pgp-sig-inner-pgp-enc.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that we DO NOT decrypt a nested OpenPGP encrypted message
+ * at MIME level 3, with an outer signature layer (level 1) and a
+ * multipart/mixed in between (level 2).
+ * We should not ignore the outer signature in this scenario.
+ */
+add_task(async function testOuterPgpSigInnerPgpEncryptedInsideMixed() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/outer-pgp-sig-inner-pgp-enc-with-mixed.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(!getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "signed unknown icon is displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that opening an encrypted message signed by an unverified key is shown
+ * as it should.
+ */
+add_task(async function testOpenSignedByUnverifiedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "signed unknown icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * -- FUNCTIONALITY NOT YET IMPLEMENTED --
+ * Test that we decrypt a nested S/MIME encrypted+signed message
+ * (with outer S/MIME signature that is ignored).
+ */
+/*
+add_task(async function testOuterSmimeSigInnerSmimeSignedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/outer-smime-bad-sig-inner-smime-enc-sig.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT_SMIME), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "signed unknown icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+*/
+
+/**
+ * Test that we decrypt a nested OpenPGP encrypted+signed message
+ * (with outer S/MIME signature that is ignored).
+ */
+add_task(async function testOuterSmimeSigInnerPgpSignedByUnverifiedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/outer-smime-bad-sig-inner-pgp-enc-sig.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "signed unknown icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that we DO NOT decrypt a nested OpenPGP encrypted message
+ * at MIME level 3, with an outer signature layer (level 1) and a
+ * multipart/mixed in between (level 2).
+ * We should not ignore the outer signature in this scenario.
+ */
+add_task(async function testOuterSmimeSigInnerPgpEncryptedInsideMixed() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/outer-smime-bad-sig-inner-pgp-enc-sig-with-mixed.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(!getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * -- FUNCTIONALITY NOT YET IMPLEMENTED --
+ * Test that we decrypt a nested S/MIME encrypted+signed message
+ * (with outer OpenPGP signature that is ignored).
+ */
+/*
+add_task(async function testOuterPgpSigInnerSmimeSignedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/outer-pgp-sig-inner-smime-enc-sig.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT_SMIME), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "signed unknown icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+*/
+
+/**
+ * Test that we decrypt a nested OpenPGP encrypted+signed message
+ * (with outer OpenPGP signature that is ignored).
+ */
+add_task(async function testOuterPgpSigOpenSignedByUnverifiedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/outer-pgp-sig-inner-pgp-enc-sig.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "signed unknown icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that the message is properly reloaded and the message security icon is
+ * updated if the user changes the signature acceptance level.
+ */
+add_task(async function testUpdateMessageSignature() {
+ // Setup the message.
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted.eml"
+ )
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ // Verify current signature acceptance.
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "verified"),
+ "signed verified icon is displayed"
+ );
+
+ let popupshown = BrowserTestUtils.waitForEvent(
+ aboutMessage.document.getElementById("messageSecurityPanel"),
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("encryptionTechBtn"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+ // Wait for the popup panel and signature button to become visible otherwise
+ // we can't click on it.
+ await popupshown;
+
+ // Open the Key Properties dialog and change the signature acceptance.
+ let dialogPromise = BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+
+ if (
+ win.document.documentURI !=
+ "chrome://openpgp/content/ui/keyDetailsDlg.xhtml"
+ ) {
+ return false;
+ }
+
+ if (Services.focus.activeWindow != win) {
+ await BrowserTestUtils.waitForEvent(win, "focus");
+ }
+
+ EventUtils.synthesizeMouseAtCenter(
+ win.document.querySelector("#acceptUnverified"),
+ {},
+ win
+ );
+
+ let closedPromise = BrowserTestUtils.domWindowClosed(win);
+ win.document.documentElement.querySelector("dialog").acceptDialog();
+ await closedPromise;
+ return true;
+ });
+
+ // This will open the key details, the domWindowOpened handler
+ // will catch it and execute the changes.
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("viewSignatureKey"),
+ { clickCount: 1 },
+ aboutMessage
+ );
+
+ // Wait until we are done with keyDetailsDlg.
+ await dialogPromise;
+
+ // Wait for the signedHdrIcon state to change.
+
+ // Verify the new acceptance level is correct.
+ await TestUtils.waitForCondition(
+ () =>
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unverified"),
+ "signed unverified icon should be displayed"
+ );
+ close_window(mc);
+}).skip(); // TODO
+
+// After test testUpdateMessageSignature acceptance of Bob's key
+// has changed from verified to unverified.
+
+/**
+ * Test that a signed (only) inline PGP message with UTF-8 characters
+ * can be correctly verified.
+ */
+add_task(async function testOpenSignedInlineWithUTF8() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/eml/alice-utf.eml"))
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ getMsgBodyTxt(mc).includes("£35.00"),
+ "UTF-8 character found in message"
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unverified"),
+ "signed unverified icon is displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that a signed (only) inline PGP message with leading whitespace
+ * can be correctly verified.
+ */
+add_task(async function testOpenSignedInlineWithLeadingWS() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/eml/signed-inline-indented.eml"))
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ getMsgBodyTxt(mc).includes("indent test with £"),
+ "expected text should be found in message"
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unverified"),
+ "signed unverified icon is displayed"
+ );
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that an encrypted inline message, with nbsp encoded as qp
+ * in the PGP separator line, is trimmed and decrypted.
+ */
+add_task(async function testDecryptInlineWithNBSPasQP() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/eml/bob-enc-inline-nbsp-qp.eml"))
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ getMsgBodyTxt(mc).includes("My real name is not Bob."),
+ "Secret text should be contained in message"
+ );
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "Encrypted icon should be displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that an inline message, encoded as html message, with nbsp
+ * encoded as qp in the PGP separator line, is trimmed and decrypted.
+ */
+add_task(async function testDecryptHtmlWithNBSP() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/eml/bob-enc-html-nbsp.eml"))
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ getMsgBodyTxt(mc).includes("My real name is not Bob."),
+ "Secret text should be contained in message"
+ );
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "Encrypted icon should be displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that opening an encrypted (and signed) message with non-ascii subject
+ * and body works.
+ */
+add_task(async function testOpenSignedByUnverifiedEncrypted() {
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/encrypted-and-signed-alice-to-bob-nonascii.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ // Check the subject was properly updated (from ...) in the message header.
+ Assert.equal(
+ aboutMessage.document.getElementById("expandedsubjectBox").textContent,
+ "Subject:Re: kod blå",
+ "Non-ascii subject should correct"
+ );
+ Assert.ok(getMsgBodyTxt(mc).includes("Detta är krypterat!"));
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "ok"),
+ "signed verified icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+/**
+ * Test that it's possible to decrypt an OpenPGP encrypted message
+ * using a revoked key. (Also signed, unknown signer key.)
+ */
+add_task(async function testOpenEncryptedForRevokedKey() {
+ await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/carol@pgp.icu-0xEF2FD01608AFD744-revoked-secret.asc"
+ )
+ )
+ );
+
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath("data/eml/enc-to-carol@pgp.icu-revoked.eml")
+ )
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(
+ getMsgBodyTxt(mc).includes("billie-jean"),
+ "message text is in body"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+ await OpenPGPTestUtils.removeKeyById("0xEF2FD01608AFD744", true);
+});
+
+registerCleanupFunction(async function tearDown() {
+ MailServices.accounts.removeAccount(aliceAcct, true);
+ await OpenPGPTestUtils.removeKeyById("0xf231550c4f47e38e", true);
+});
diff --git a/comm/mail/test/browser/openpgp/browser_viewMessage2.js b/comm/mail/test/browser/openpgp/browser_viewMessage2.js
new file mode 100644
index 0000000000..eef86e5825
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser_viewMessage2.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/. */
+
+/**
+ * Tests for the display of OpenPGP signed/encrypted state in opened messages,
+ * when OpenPGP passphrases are in use.
+ */
+
+"use strict";
+
+const { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+const { waitForCondition } = ChromeUtils.import(
+ "resource://testing-common/mozmill/utils.jsm"
+);
+
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const MSG_TEXT = "Sundays are nothing without callaloo.";
+
+function getMsgBodyTxt(mc) {
+ let msgPane = get_about_message(mc.window).getMessagePaneBrowser();
+ return msgPane.contentDocument.documentElement.textContent;
+}
+
+var aliceAcct;
+
+/**
+ * Set up the base account, identity and keys needed for the tests.
+ */
+add_setup(async function () {
+ aliceAcct = MailServices.accounts.createAccount();
+ aliceAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "pop3"
+ );
+ let aliceIdentity = MailServices.accounts.createIdentity();
+ aliceIdentity.email = "alice@openpgp.example";
+ aliceAcct.addIdentity(aliceIdentity);
+
+ // Set up the alice's private key, which has a passphrase set
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret-with-pp.asc"
+ )
+ ),
+ OpenPGPTestUtils.ACCEPTANCE_PERSONAL,
+ "alice-passphrase",
+ true
+ );
+
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", id);
+
+ // Import and accept the public key for Bob, our verified sender.
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc"
+ )
+ )
+ );
+});
+
+/**
+ * Test that opening an unsigned encrypted message shows as such.
+ */
+add_task(async function testOpenVerifiedUnsignedEncrypted2() {
+ let passPromptPromise = BrowserTestUtils.promiseAlertDialogOpen();
+
+ let openMessagePromise = open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml"
+ )
+ )
+ );
+
+ let ppWin = await passPromptPromise;
+
+ // We'll enter a wrong pp, so we expect another prompt
+ let passPromptPromise2 = BrowserTestUtils.promiseAlertDialogOpen();
+
+ ppWin.document.getElementById("password1Textbox").value = "WRONG-passphrase";
+ ppWin.document.querySelector("dialog").getButton("accept").click();
+
+ let ppWin2 = await passPromptPromise2;
+
+ ppWin2.document.getElementById("password1Textbox").value = "alice-passphrase";
+ ppWin2.document.querySelector("dialog").getButton("accept").click();
+
+ let mc = await openMessagePromise;
+
+ let aboutMessage = get_about_message(mc.window);
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "signed icon is not displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+ close_window(mc);
+});
+
+registerCleanupFunction(async function tearDown() {
+ MailServices.accounts.removeAccount(aliceAcct, true);
+ await OpenPGPTestUtils.removeKeyById("0xf231550c4f47e38e", true);
+});
diff --git a/comm/mail/test/browser/openpgp/browser_viewMessageSecurity.js b/comm/mail/test/browser/openpgp/browser_viewMessageSecurity.js
new file mode 100644
index 0000000000..19d4a6865e
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser_viewMessageSecurity.js
@@ -0,0 +1,312 @@
+/* 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/. */
+
+/**
+ * Tests for the display of the Message Security popup panel, which displays
+ * encryption information for both OpenPGP and S/MIME.
+ */
+
+"use strict";
+
+const {
+ create_encrypted_smime_message,
+ add_message_to_folder,
+ be_in_folder,
+ get_about_message,
+ get_special_folder,
+ mc,
+ select_click_row,
+ press_delete,
+ plan_for_message_display,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const {
+ get_notification_button,
+ wait_for_notification_to_show,
+ wait_for_notification_to_stop,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+const { SmimeUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/smimeUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const MSG_TEXT = "Sundays are nothing without callaloo.";
+
+function getMsgBodyTxt(mc) {
+ let msgPane = get_about_message(mc.window).getMessagePaneBrowser();
+ return msgPane.contentDocument.documentElement.textContent;
+}
+
+var aliceAcct;
+var aliceIdentity;
+var initialKeyIdPref = "";
+var gInbox;
+
+/**
+ * Set up the base account, identity and keys needed for the tests.
+ */
+add_setup(async function () {
+ SmimeUtils.ensureNSS();
+ SmimeUtils.loadCertificateAndKey(
+ new FileUtils.File(getTestFilePath("data/smime/Bob.p12")),
+ "nss"
+ );
+
+ aliceAcct = MailServices.accounts.createAccount();
+ aliceAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "pop3"
+ );
+ aliceIdentity = MailServices.accounts.createIdentity();
+ aliceIdentity.email = "alice@openpgp.example";
+ aliceAcct.addIdentity(aliceIdentity);
+
+ // Set up the alice's private key.
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ )
+ )
+ );
+
+ initialKeyIdPref = aliceIdentity.getUnicharAttribute("openpgp_key_id");
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", id);
+
+ // Import and accept the public key for Bob, our verified sender.
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc"
+ )
+ )
+ );
+
+ gInbox = await get_special_folder(Ci.nsMsgFolderFlags.Inbox, true);
+ await be_in_folder(gInbox);
+});
+
+/**
+ * Test that the encryption icons and the message security popup properly update
+ * when selecting an S/MIME or OpenPGP message with different signature and
+ * encryption states.
+ */
+add_task(async function testSmimeOpenPgpSelection() {
+ let smimeFile = new FileUtils.File(
+ getTestFilePath("data/smime/alice.env.eml")
+ );
+ // Fetch a local OpenPGP message.
+ let openPgpFile = new FileUtils.File(
+ getTestFilePath(
+ "data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml"
+ )
+ );
+
+ // Add the fetched S/MIME message to the inbox folder.
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFileMessage(
+ smimeFile,
+ gInbox,
+ null,
+ false,
+ 0,
+ "",
+ copyListener,
+ null
+ );
+ await copyListener.promise;
+
+ // Add the fetched OpenPGP message to the inbox folder.
+ copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFileMessage(
+ openPgpFile,
+ gInbox,
+ null,
+ false,
+ 0,
+ "",
+ copyListener,
+ null
+ );
+ await copyListener.promise;
+
+ // Select the second row, which should contain the S/MIME message.
+ select_click_row(1);
+
+ let aboutMessage = get_about_message();
+ Assert.equal(
+ aboutMessage.document
+ .getElementById("encryptionTechBtn")
+ .querySelector("span").textContent,
+ "S/MIME"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "S/MIME message should be decrypted"
+ );
+
+ let openpgpprocessed = BrowserTestUtils.waitForEvent(
+ aboutMessage.document,
+ "openpgpprocessed"
+ );
+ // Select the first row, which should contain the OpenPGP message.
+ select_click_row(0);
+ await openpgpprocessed;
+
+ Assert.equal(
+ aboutMessage.document
+ .getElementById("encryptionTechBtn")
+ .querySelector("span").textContent,
+ "OpenPGP"
+ );
+
+ Assert.ok(getMsgBodyTxt(mc).includes(MSG_TEXT), "message text is in body");
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "verified"),
+ "signed verified icon is displayed"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+
+ // Delete the two generated messages.
+ press_delete();
+ select_click_row(0);
+ press_delete();
+});
+
+/**
+ * Test the notification and repairing of a message corrupted by MS-Exchange.
+ */
+add_task(async function testBrokenMSExchangeEncryption() {
+ // Fetch a broken MS-Exchange encrypted message.
+ let brokenFile = new FileUtils.File(
+ getTestFilePath("data/eml/alice-broken-exchange.eml")
+ );
+ let notificationBox = "mail-notification-top";
+ let notificationValue = "brokenExchange";
+
+ // Add the broken OpenPGP message to the inbox folder.
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFileMessage(
+ brokenFile,
+ gInbox,
+ null,
+ false,
+ 0,
+ "",
+ copyListener,
+ null
+ );
+ await copyListener.promise;
+
+ // Select the first row, which should contain the OpenPGP message.
+ select_click_row(0);
+
+ // Assert the "corrupted by MS-Exchange" notification is visible.
+ let aboutMessage = get_about_message();
+ wait_for_notification_to_show(
+ aboutMessage,
+ notificationBox,
+ notificationValue
+ );
+
+ // Click on the "repair" button.
+ let repairButton = get_notification_button(
+ aboutMessage,
+ notificationBox,
+ notificationValue,
+ {
+ popup: null,
+ }
+ );
+ plan_for_message_display(mc);
+ EventUtils.synthesizeMouseAtCenter(repairButton, {}, aboutMessage);
+
+ // Wait for the "fixing in progress" notification to go away.
+ wait_for_notification_to_stop(
+ aboutMessage,
+ notificationBox,
+ "brokenExchangeProgress"
+ );
+
+ // The broken exchange repair process generates a new fixed message body and
+ // then copies the new message in the same folder. Therefore, we need to wait
+ // for the message to be automatically reloaded and reselected.
+ wait_for_message_display_completion(mc, true);
+
+ // Assert that the message was repaired and decrypted.
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is displayed"
+ );
+
+ // Delete the message.
+ press_delete();
+}).skip(); // TODO
+
+/**
+ * Test the working keyboard shortcut event listener for the message header.
+ * Ctrl+Alt+S for Windows and Linux, Control+Cmd+S for macOS.
+ */
+add_task(async function testMessageSecurityShortcut() {
+ // Create an S/MIME message and add it to the inbox folder.
+ await add_message_to_folder([gInbox], create_encrypted_smime_message());
+
+ // Select the first row, which should contain the S/MIME message.
+ select_click_row(0);
+
+ let aboutMessage = get_about_message();
+ Assert.equal(
+ aboutMessage.document
+ .getElementById("encryptionTechBtn")
+ .querySelector("span").textContent,
+ "S/MIME"
+ );
+
+ let modifiers =
+ AppConstants.platform == "macosx"
+ ? { accelKey: true, ctrlKey: true }
+ : { accelKey: true, altKey: true };
+
+ let popupshown = BrowserTestUtils.waitForEvent(
+ aboutMessage.document.getElementById("messageSecurityPanel"),
+ "popupshown"
+ );
+
+ EventUtils.synthesizeKey("s", modifiers, aboutMessage);
+
+ // The Message Security popup panel should show up.
+ await popupshown;
+
+ // Select the row again since the focus moved to the popup panel.
+ select_click_row(0);
+ // Delete the message.
+ press_delete();
+}).skip(); // TODO
+
+registerCleanupFunction(async function tearDown() {
+ // Reset the OpenPGP key and delete the account.
+ MailServices.accounts.removeAccount(aliceAcct, true);
+ aliceAcct = null;
+
+ await OpenPGPTestUtils.removeKeyById("0xf231550c4f47e38e", true);
+});
diff --git a/comm/mail/test/browser/openpgp/browser_viewPartialMessage.js b/comm/mail/test/browser/openpgp/browser_viewPartialMessage.js
new file mode 100644
index 0000000000..53edd7d10a
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/browser_viewPartialMessage.js
@@ -0,0 +1,239 @@
+/* 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/. */
+
+/**
+ * Tests for the display of OpenPGP signed/encrypted state in opened messages.
+ */
+
+"use strict";
+
+const { get_about_message, open_message_from_file } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+const { waitForCondition } = ChromeUtils.import(
+ "resource://testing-common/mozmill/utils.jsm"
+);
+const { get_notification_button, wait_for_notification_to_show } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+ );
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const MSG_TEXT = "Sundays are nothing without callaloo.";
+
+function getMsgBodyTxt(mc) {
+ let msgPane = get_about_message(mc.window).getMessagePaneBrowser();
+ return msgPane.contentDocument.documentElement.textContent;
+}
+
+var aliceAcct;
+
+/**
+ * Set up the base account, identity and keys needed for the tests.
+ */
+add_setup(async function () {
+ aliceAcct = MailServices.accounts.createAccount();
+ aliceAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "pop3"
+ );
+ let aliceIdentity = MailServices.accounts.createIdentity();
+ aliceIdentity.email = "alice@openpgp.example";
+ aliceAcct.addIdentity(aliceIdentity);
+
+ // Set up the alice's private key.
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ )
+ )
+ );
+
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", id);
+
+ // Import and accept the public key for Bob, our verified sender.
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc"
+ )
+ )
+ );
+});
+
+let partialInlineTests = [
+ {
+ filename: "partial-encrypt-for-carol-plaintext.eml",
+ expectDecryption: true,
+ expectVerification: false,
+ expectSuccess: false,
+ },
+ {
+ filename: "partial-encrypt-for-carol-html.eml",
+ expectDecryption: true,
+ expectVerification: false,
+ expectSuccess: false,
+ },
+ {
+ filename: "partial-encrypt-for-alice-plaintext.eml",
+ expectDecryption: true,
+ expectVerification: false,
+ expectSuccess: true,
+ },
+ {
+ filename: "partial-encrypt-for-alice-html.eml",
+ expectDecryption: true,
+ expectVerification: false,
+ expectSuccess: true,
+ },
+ {
+ filename: "partial-signed-from-carol-plaintext.eml",
+ expectDecryption: false,
+ expectVerification: true,
+ expectSuccess: false,
+ },
+ {
+ filename: "partial-signed-from-carol-html.eml",
+ expectDecryption: false,
+ expectVerification: true,
+ expectSuccess: false,
+ },
+ {
+ filename: "partial-signed-from-bob-plaintext.eml",
+ expectDecryption: false,
+ expectVerification: true,
+ expectSuccess: true,
+ },
+ {
+ filename: "partial-signed-from-bob-html.eml",
+ expectDecryption: false,
+ expectVerification: true,
+ expectSuccess: true,
+ },
+];
+
+/**
+ * Test the notification/decryption/verification behavior for partially
+ * encrypted/signed inline PGP messages.
+ */
+add_task(async function testPartialInlinePGPDecrypt() {
+ for (let test of partialInlineTests) {
+ if (!test.filename) {
+ continue;
+ }
+
+ info(`Testing partial inline; filename=${test.filename}`);
+
+ // Setup the message.
+ let mc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/eml/" + test.filename))
+ );
+ let aboutMessage = get_about_message(mc.window);
+
+ let notificationBox = "mail-notification-top";
+ let notificationValue = "decryptInlinePG";
+
+ // Ensure the "partially encrypted notification" is visible.
+ wait_for_notification_to_show(
+ aboutMessage,
+ notificationBox,
+ notificationValue
+ );
+
+ let body = getMsgBodyTxt(mc);
+
+ Assert.ok(
+ body.includes("BEGIN PGP"),
+ "unprocessed PGP message should still be shown"
+ );
+
+ Assert.ok(body.includes("prefix"), "prefix should still be shown");
+ Assert.ok(body.includes("suffix"), "suffix should still be shown");
+
+ // Click on the button to process the message subset.
+ let processButton = get_notification_button(
+ aboutMessage,
+ notificationBox,
+ notificationValue,
+ {
+ popup: null,
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(processButton, {}, aboutMessage);
+
+ // Assert that the message was processed and the partial content reminder
+ // notification is visible.
+ wait_for_notification_to_show(
+ aboutMessage,
+ notificationBox,
+ "decryptInlinePGReminder"
+ );
+
+ // Get updated body text after processing the PGP subset.
+ body = getMsgBodyTxt(mc);
+
+ Assert.ok(!body.includes("prefix"), "prefix should not be shown");
+ Assert.ok(!body.includes("suffix"), "suffix should not be shown");
+
+ if (test.expectDecryption) {
+ let containsSecret = body.includes(
+ "Insert a coin to play your personal lucky melody."
+ );
+ if (test.expectSuccess) {
+ Assert.ok(containsSecret, "secret decrypted content should be shown");
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "decryption success icon is shown"
+ );
+ } else {
+ Assert.ok(
+ !containsSecret,
+ "secret decrypted content should not be shown"
+ );
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(
+ aboutMessage.document,
+ "notok"
+ ),
+ "decryption failure icon is shown"
+ );
+ }
+ } else if (test.expectVerification) {
+ if (test.expectSuccess) {
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(
+ aboutMessage.document,
+ "verified"
+ ),
+ "ok verification icon is shown for " + test.filename
+ );
+ } else {
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "unknown"),
+ "unknown verification icon is shown"
+ );
+ }
+ }
+
+ close_window(mc);
+ }
+});
+
+registerCleanupFunction(async function tearDown() {
+ MailServices.accounts.removeAccount(aliceAcct, true);
+ await OpenPGPTestUtils.removeKeyById("0xf231550c4f47e38e", true);
+});
diff --git a/comm/mail/test/browser/openpgp/composition/browser.ini b/comm/mail/test/browser/openpgp/composition/browser.ini
new file mode 100644
index 0000000000..794baf5250
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/composition/browser.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+head=head.js
+prefs =
+ browser.tabs.remote.autostart=false
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = ../data/**
+
+[browser_composeSigned.js]
+skip-if = debug # Bug 1673652 - L10NRegistry throws NS_ERROR_FILE_UNRECOGNIZED_PATH
+[browser_composeSigned2.js]
+skip-if = debug # Bug 1673652 - L10NRegistry throws NS_ERROR_FILE_UNRECOGNIZED_PATH
+[browser_composeEncrypted.js]
+skip-if = debug # Bug 1673652 - L10NRegistry throws NS_ERROR_FILE_UNRECOGNIZED_PATH
+[browser_composeSwitchIdentity.js]
+skip-if = debug # Bug 1673652 - L10NRegistry throws NS_ERROR_FILE_UNRECOGNIZED_PATH
+[browser_editDraftTemplate.js]
+[browser_expiredKey.js]
+skip-if = debug # Bug 1673652 - L10NRegistry throws NS_ERROR_FILE_UNRECOGNIZED_PATH
diff --git a/comm/mail/test/browser/openpgp/composition/browser_composeEncrypted.js b/comm/mail/test/browser/openpgp/composition/browser_composeEncrypted.js
new file mode 100644
index 0000000000..bd58241bd0
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/composition/browser_composeEncrypted.js
@@ -0,0 +1,976 @@
+/* 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/. */
+
+/**
+ * Tests for OpenPGP encrypted message composition.
+ */
+
+"use strict";
+
+const {
+ open_message_from_file,
+ be_in_folder,
+ get_about_message,
+ get_special_folder,
+ press_delete,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const {
+ close_compose_window,
+ open_compose_new_mail,
+ open_compose_with_reply,
+ save_compose_message,
+ setup_msg_contents,
+} = ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+const { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let bobAcct;
+let bobIdentity;
+let gOutbox;
+let gDrafts;
+
+let aboutMessage = get_about_message();
+
+// Used in some of the tests to verify key status display.
+let l10n = new Localization(["messenger/openpgp/composeKeyStatus.ftl"]);
+
+function waitForComposeWindow() {
+ return BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ await BrowserTestUtils.waitForEvent(win, "focus", true);
+ return (
+ win.document.documentURI ===
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ );
+ });
+}
+
+/**
+ * Closes a window with a <dialog> element by calling the acceptDialog().
+ *
+ * @param {Window} win
+ */
+async function closeDialog(win) {
+ let closed = BrowserTestUtils.domWindowClosed(win);
+ win.document.documentElement.querySelector("dialog").acceptDialog();
+ await closed;
+}
+
+function setAutoPrefs(autoEnable, autoDisable, notifyOnDisable) {
+ Services.prefs.setBoolPref("mail.e2ee.auto_enable", autoEnable);
+ Services.prefs.setBoolPref("mail.e2ee.auto_disable", autoDisable);
+ Services.prefs.setBoolPref(
+ "mail.e2ee.notify_on_auto_disable",
+ notifyOnDisable
+ );
+}
+
+function clearAutoPrefs() {
+ Services.prefs.clearUserPref("mail.e2ee.auto_enable");
+ Services.prefs.clearUserPref("mail.e2ee.auto_disable");
+ Services.prefs.clearUserPref("mail.e2ee.notify_on_auto_disable");
+}
+
+async function waitCheckEncryptionStateDone(win) {
+ return BrowserTestUtils.waitForEvent(
+ win.document,
+ "encryption-state-checked"
+ );
+}
+
+/**
+ * Setup a mail account with a private key and import the public key for the
+ * receiver.
+ */
+add_setup(async function () {
+ // Encryption makes the compose process a little longer.
+ requestLongerTimeout(5);
+
+ bobAcct = MailServices.accounts.createAccount();
+ bobAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "bob",
+ "openpgp.example",
+ "imap"
+ );
+ bobIdentity = MailServices.accounts.createIdentity();
+ bobIdentity.email = "bob@openpgp.example";
+ bobAcct.addIdentity(bobIdentity);
+
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc"
+ )
+ )
+ );
+
+ Assert.ok(id, "private key id received");
+ bobIdentity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/alice@openpgp.example-0xf231550c4f47e38e-pub.asc"
+ )
+ )
+ );
+
+ gOutbox = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+});
+
+/**
+ * Tests composition of an encrypted only message shows as encrypted in
+ * the Outbox.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedMessageComposition(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Encrypted Message",
+ "This is an encrypted message with key composition test."
+ );
+ await checkDonePromise;
+
+ if (!autoEnable) {
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+ }
+
+ Assert.ok(composeWin.gSendEncrypted, "message encryption should be on");
+ Assert.ok(composeWin.gSendSigned, "message signing should be on");
+ await OpenPGPTestUtils.toggleMessageSigning(composeWin);
+
+ Assert.ok(
+ !composeWin.gSendSigned,
+ "toggling message signing should have completed already"
+ );
+
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "message should have encrypted icon"
+ );
+
+ Assert.equal(
+ aboutMessage.document.querySelector("#attachmentList").itemChildren.length,
+ 0,
+ "no keys should be attached to message"
+ );
+
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "message should have no signed icon"
+ );
+
+ // Delete the message so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+}
+
+add_task(async function testEncryptedMessageCompositionAutoEncOff() {
+ await testEncryptedMessageComposition(false, false, false);
+});
+
+add_task(async function testEncryptedMessageCompositionAutoEncOnAutoDisOff() {
+ await testEncryptedMessageComposition(true, false, false);
+});
+
+add_task(async function testEncryptedMessageCompositionAutoEncOnAutoDisOn() {
+ await testEncryptedMessageComposition(true, true, false);
+});
+
+/**
+ * Tests composition of an encrypted only message, with public key attachment
+ * enabled, shows as encrypted in the Outbox.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedMessageWithKeyComposition(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Encrypted Message With Key",
+ "This is an encrypted message with key composition test."
+ );
+ await checkDonePromise;
+
+ if (!autoEnable) {
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+ }
+
+ await OpenPGPTestUtils.toggleMessageSigning(composeWin);
+ await OpenPGPTestUtils.toggleMessageKeyAttachment(composeWin);
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "message should have encrypted icon"
+ );
+
+ let attachmentList = aboutMessage.document.querySelector("#attachmentList");
+
+ await TestUtils.waitForCondition(
+ () => attachmentList.itemChildren.length == 1,
+ "message should have one attachment"
+ );
+
+ Assert.ok(
+ attachmentList
+ .getItemAtIndex(0)
+ .attachment.name.includes(OpenPGPTestUtils.BOB_KEY_ID),
+ "attachment name should contain Bob's key id"
+ );
+
+ Assert.ok(
+ OpenPGPTestUtils.hasNoSignedIconState(aboutMessage.document),
+ "message should have no signed icon"
+ );
+
+ // Delete the message so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+}
+
+add_task(async function testEncryptedMessageWithKeyCompositionAutoEncOff() {
+ await testEncryptedMessageWithKeyComposition(false, false, false);
+});
+
+add_task(
+ async function testEncryptedMessageWithKeyCompositionAutoEncOnAutoDisOff() {
+ await testEncryptedMessageWithKeyComposition(true, false, false);
+ }
+);
+
+add_task(
+ async function testEncryptedMessageWithKeyCompositionAutoEncOnAutoDisOn() {
+ await testEncryptedMessageWithKeyComposition(true, true, false);
+ }
+);
+
+/**
+ * Tests composition of an encrypted message to a recipient, whom we have no
+ * key for, prompts the user.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedRecipientKeyNotAvailabeMessageComposition(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "carol@example.com",
+ "Compose Encrypted Recipient Key Not Available Message",
+ "This is an encrypted recipient key not available message composition test."
+ );
+ await checkDonePromise;
+
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+
+ let kaShown = BrowserTestUtils.waitForCondition(
+ () => composeWin.document.getElementById("keyAssistant").open,
+ "Timeout waiting for the #keyAssistant to be visible"
+ );
+
+ composeWin.goDoCommand("cmd_sendLater");
+ await kaShown;
+
+ await BrowserTestUtils.closeWindow(composeWin);
+}
+
+add_task(
+ async function testEncryptedRecipientKeyNotAvailabeMessageCompositionAutoEncOff() {
+ await testEncryptedRecipientKeyNotAvailabeMessageComposition(
+ false,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedRecipientKeyNotAvailabeMessageCompositionAutoEncOnAutoDisOff() {
+ await testEncryptedRecipientKeyNotAvailabeMessageComposition(
+ true,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedRecipientKeyNotAvailabeMessageCompositionAutoEncOnAutoDisOn() {
+ await testEncryptedRecipientKeyNotAvailabeMessageComposition(
+ true,
+ true,
+ false
+ );
+ }
+);
+
+/**
+ * Tests that we turn on encryption automatically for the first recipient
+ * (key available), and that we turn off encryption automatically after
+ * adding the second recipient (key not available).
+ */
+add_task(async function testEncryptedRecipientKeyNotAvailabeAutoDisable() {
+ setAutoPrefs(true, true, false);
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Encrypted Recipient Key Not Available Message",
+ "This is an encrypted recipient key not available message composition test."
+ );
+ await checkDonePromise;
+
+ Assert.ok(composeWin.gSendEncrypted, "message encryption should be on");
+
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(cwc, " missing@openpgp.example ", "", "");
+ await checkDonePromise;
+
+ Assert.ok(!composeWin.gSendEncrypted, "message encryption should be off");
+
+ await sendMessage(composeWin);
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasNoEncryptedIconState(aboutMessage.document),
+ "message should not have encrypted icon"
+ );
+
+ // Clean up so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+});
+
+/**
+ * Tests composition of an encrypted message to a recipient, whose key we have
+ * not accepted, prompts the user.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedRecipientKeyNotAcceptedMessageComposition(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc"
+ )
+ ),
+ OpenPGPTestUtils.ACCEPTANCE_UNDECIDED
+ );
+
+ for (let level of [
+ OpenPGPTestUtils.ACCEPTANCE_UNDECIDED,
+ OpenPGPTestUtils.ACCEPTANCE_REJECTED,
+ ]) {
+ info(`Testing with acceptance level: "${level}"...`);
+ await OpenPGPTestUtils.updateKeyIdAcceptance(
+ OpenPGPTestUtils.CAROL_KEY_ID,
+ level
+ );
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "carol@example.com",
+ "Compose Encrypted Recipient Key Not Accepted",
+ "This is an encrypted recipient key not accepted message composition test."
+ );
+ await checkDonePromise;
+
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+
+ let kaShown = BrowserTestUtils.waitForCondition(
+ () => composeWin.document.getElementById("keyAssistant").open,
+ "Timeout waiting for the #keyAssistant to be visible"
+ );
+
+ composeWin.goDoCommand("cmd_sendLater");
+ await kaShown;
+
+ await BrowserTestUtils.closeWindow(composeWin);
+ }
+ await OpenPGPTestUtils.removeKeyById(OpenPGPTestUtils.CAROL_KEY_ID);
+}
+
+add_task(
+ async function testEncryptedRecipientKeyNotAcceptedMessageCompositionAutoEncOff() {
+ await testEncryptedRecipientKeyNotAcceptedMessageComposition(
+ false,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedRecipientKeyNotAcceptedMessageCompositionAutoEncOnAutoDisOff() {
+ await testEncryptedRecipientKeyNotAcceptedMessageComposition(
+ true,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedRecipientKeyNotAcceptedMessageCompositionAutoEncOnAutoDisOn() {
+ await testEncryptedRecipientKeyNotAcceptedMessageComposition(
+ true,
+ true,
+ false
+ );
+ }
+);
+
+/**
+ * Tests composition of an encrypted message to a recipient, whose key we have
+ * accepted (not verified), shows as encrypted in the Outbox.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedRecipientKeyUnverifiedMessageComposition(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc"
+ )
+ ),
+ OpenPGPTestUtils.ACCEPTANCE_UNVERIFIED
+ );
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "carol@example.com",
+ "Compose Encrypted Recipient Key Unverified Message",
+ "This is an encrypted, recipient key unverified message test."
+ );
+ await checkDonePromise;
+
+ if (!autoEnable) {
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+ }
+
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "message should have encrypted icon"
+ );
+
+ // Clean up so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+ await OpenPGPTestUtils.removeKeyById(OpenPGPTestUtils.CAROL_KEY_ID);
+}
+
+add_task(
+ async function testEncryptedRecipientKeyUnverifiedMessageCompositionAutoEncOff() {
+ await testEncryptedRecipientKeyUnverifiedMessageComposition(
+ false,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedRecipientKeyUnverifiedMessageCompositionAutoEncOnAutoDisOff() {
+ await testEncryptedRecipientKeyUnverifiedMessageComposition(
+ true,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedRecipientKeyUnverifiedMessageCompositionAutoEncOnAutoDisOn() {
+ await testEncryptedRecipientKeyUnverifiedMessageComposition(
+ true,
+ true,
+ false
+ );
+ }
+);
+
+/**
+ * Tests composition of a message to multiple recipients among whom, one key
+ * is missing, prompts the user.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedOneRecipientKeyNotAvailableMessageComposition(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example, carol@example.com",
+ "Compose Encrypted One Recipient Key Not Available Message Composition",
+ "This is an encrypted, one recipient key not available message test."
+ );
+ await checkDonePromise;
+
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+
+ let kaShown = BrowserTestUtils.waitForCondition(
+ () => composeWin.document.getElementById("keyAssistant").open,
+ "Timeout waiting for the #keyAssistant to be visible"
+ );
+
+ composeWin.goDoCommand("cmd_sendLater");
+ await kaShown;
+
+ await BrowserTestUtils.closeWindow(composeWin);
+}
+
+add_task(
+ async function testEncryptedOneRecipientKeyNotAvailableMessageCompositionAutoEncOff() {
+ await testEncryptedOneRecipientKeyNotAvailableMessageComposition(
+ false,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedOneRecipientKeyNotAvailableMessageCompositionAutoEncOnAutoDisOff() {
+ await testEncryptedOneRecipientKeyNotAvailableMessageComposition(
+ true,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedOneRecipientKeyNotAvailableMessageCompositionAutoEncOnAutoDisOn() {
+ await testEncryptedOneRecipientKeyNotAvailableMessageComposition(
+ true,
+ true,
+ false
+ );
+ }
+);
+
+/**
+ * Tests composition of a message to multiple recipients among whom, one key
+ * is not accepted, prompts the user.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedOneRecipientKeyNotAcceptedMessageComposition(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc"
+ )
+ ),
+ OpenPGPTestUtils.ACCEPTANCE_UNDECIDED
+ );
+
+ for (let level of [
+ OpenPGPTestUtils.ACCEPTANCE_UNDECIDED,
+ OpenPGPTestUtils.ACCEPTANCE_REJECTED,
+ ]) {
+ info(`Testing with acceptance level: "${level}"...`);
+ await OpenPGPTestUtils.updateKeyIdAcceptance(
+ OpenPGPTestUtils.CAROL_KEY_ID,
+ level
+ );
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example, carol@example.com",
+ "Compose Encrypted One Recipient Key Not Accepted Message Composition",
+ "This is an encrypted, one recipient key not accepted message test."
+ );
+ await checkDonePromise;
+
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+
+ let kaShown = BrowserTestUtils.waitForCondition(
+ () => composeWin.document.getElementById("keyAssistant").open,
+ "Timeout waiting for the #keyAssistant to be visible"
+ );
+
+ composeWin.goDoCommand("cmd_sendLater");
+ await kaShown;
+
+ await BrowserTestUtils.closeWindow(composeWin);
+ }
+
+ await OpenPGPTestUtils.removeKeyById(OpenPGPTestUtils.CAROL_KEY_ID);
+}
+
+add_task(
+ async function testEncryptedOneRecipientKeyNotAcceptedMessageCompositionAutoEncOff() {
+ await testEncryptedOneRecipientKeyNotAcceptedMessageComposition(
+ false,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedOneRecipientKeyNotAcceptedMessageCompositionAutoEncOnAutoDisOff() {
+ await testEncryptedOneRecipientKeyNotAcceptedMessageComposition(
+ true,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedOneRecipientKeyNotAcceptedMessageCompositionAutoEncOnAutoDisOn() {
+ await testEncryptedOneRecipientKeyNotAcceptedMessageComposition(
+ true,
+ true,
+ false
+ );
+ }
+);
+
+/**
+ * Tests composition of a message to multiple recipients among whom, one key
+ * is not verified, shows as encrypted in the Outbox.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedOneRecipientKeyUnverifiedMessageComposition(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc"
+ )
+ ),
+ OpenPGPTestUtils.ACCEPTANCE_UNVERIFIED
+ );
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example, carol@example.com",
+ "Compose Encrypted One Recipient Key Unverified Message",
+ "This is an encrypted, one recipient key unverified message test."
+ );
+ await checkDonePromise;
+
+ if (!autoEnable) {
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+ }
+
+ Assert.ok(composeWin.gSendEncrypted, "message encryption should be on");
+ Assert.ok(composeWin.gSendSigned, "message signing should be on");
+
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "message should have encrypted icon"
+ );
+
+ // Clean up so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+ await OpenPGPTestUtils.removeKeyById(OpenPGPTestUtils.CAROL_KEY_ID);
+}
+
+add_task(
+ async function testEncryptedOneRecipientKeyUnverifiedMessageCompositionAutoEncOff() {
+ await testEncryptedOneRecipientKeyUnverifiedMessageComposition(
+ false,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedOneRecipientKeyUnverifiedMessageCompositionAutoEncOnAutoDisOff() {
+ await testEncryptedOneRecipientKeyUnverifiedMessageComposition(
+ true,
+ false,
+ false
+ );
+ }
+);
+
+add_task(
+ async function testEncryptedOneRecipientKeyUnverifiedMessageCompositionAutoEncOnAutoDisOn() {
+ await testEncryptedOneRecipientKeyUnverifiedMessageComposition(
+ true,
+ true,
+ false
+ );
+ }
+);
+
+/**
+ * Tests composing a reply to an encrypted message is encrypted by default.
+ *
+ * @param {boolean} autoEnable - set pref mail.e2ee.auto_enable to this value
+ * @param {boolean} autoDisable - set pref mail.e2ee.auto_disable to this value
+ * @param {boolean} notifyOnDisable - set pref mail.e2ee.notify_on_auto_disable
+ * to this value
+ */
+async function testEncryptedMessageReplyIsEncrypted(
+ autoEnable,
+ autoDisable,
+ notifyOnDisable
+) {
+ setAutoPrefs(autoEnable, autoDisable, notifyOnDisable);
+
+ await be_in_folder(gDrafts);
+ let mc = await open_message_from_file(
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml"
+ )
+ )
+ );
+
+ let cwc = open_compose_with_reply(mc);
+ close_window(mc);
+
+ let replyWindow = cwc.window;
+
+ await save_compose_message(replyWindow);
+ close_compose_window(cwc);
+
+ await TestUtils.waitForCondition(
+ () => gDrafts.getTotalMessages(true) > 0,
+ "message should be saved to drafts folder"
+ );
+
+ await be_in_folder(gDrafts);
+ select_click_row(0);
+
+ await TestUtils.waitForCondition(
+ () => OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "message should have encrypted icon"
+ );
+
+ // Delete the outgoing message.
+ press_delete();
+}
+
+add_task(
+ async function testEncryptedMessageReplyIsEncryptedAutoEncOnAutoDisOff() {
+ await testEncryptedMessageReplyIsEncrypted(true, false, false);
+ }
+);
+
+add_task(
+ async function testEncryptedMessageReplyIsEncryptedAutoEncOnAutoDisOn() {
+ await testEncryptedMessageReplyIsEncrypted(true, true, false);
+ }
+);
+
+registerCleanupFunction(function tearDown() {
+ clearAutoPrefs();
+ MailServices.accounts.removeIncomingServer(bobAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(bobAcct, true);
+});
diff --git a/comm/mail/test/browser/openpgp/composition/browser_composeSigned.js b/comm/mail/test/browser/openpgp/composition/browser_composeSigned.js
new file mode 100644
index 0000000000..a2a4a4b21d
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/composition/browser_composeSigned.js
@@ -0,0 +1,423 @@
+/* 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/. */
+
+/**
+ * Tests for OpenPGP signed message composition.
+ */
+
+"use strict";
+
+const {
+ assert_selected_and_displayed,
+ be_in_folder,
+ get_about_message,
+ get_special_folder,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { open_compose_new_mail, get_msg_source, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+const { EnigmailPersistentCrypto } = ChromeUtils.import(
+ "chrome://openpgp/content/modules/persistentCrypto.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let bobAcct;
+let bobIdentity;
+let initialKeyIdPref = "";
+let gOutbox;
+
+let aboutMessage = get_about_message();
+
+async function waitCheckEncryptionStateDone(win) {
+ return BrowserTestUtils.waitForEvent(
+ win.document,
+ "encryption-state-checked"
+ );
+}
+
+/**
+ * Setup a mail account with a private key and import the public key for the
+ * receiver.
+ */
+add_setup(async function () {
+ bobAcct = MailServices.accounts.createAccount();
+ bobAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "bob",
+ "openpgp.example",
+ "imap"
+ );
+ bobIdentity = MailServices.accounts.createIdentity();
+ bobIdentity.email = "bob@openpgp.example";
+ bobAcct.addIdentity(bobIdentity);
+
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc"
+ )
+ )
+ );
+
+ Assert.ok(id, "private key id received");
+
+ initialKeyIdPref = bobIdentity.getUnicharAttribute("openpgp_key_id");
+ bobIdentity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/alice@openpgp.example-0xf231550c4f47e38e-pub.asc"
+ )
+ )
+ );
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc"
+ )
+ )
+ );
+
+ gOutbox = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+});
+
+/**
+ * Tests composition of a message that is signed only shows as signed in the
+ * Outbox.
+ */
+add_task(async function testSignedMessageComposition() {
+ let autocryptPrefName = "mail.identity.default.sendAutocryptHeaders";
+ Services.prefs.setBoolPref(autocryptPrefName, true);
+
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Signed Message",
+ "This is a signed message composition test."
+ );
+
+ await OpenPGPTestUtils.toggleMessageSigning(composeWin);
+ await OpenPGPTestUtils.toggleMessageKeyAttachment(composeWin);
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ let msg = select_click_row(0);
+ assert_selected_and_displayed(0);
+ let src = await get_msg_source(msg);
+ let lines = src.split("\n");
+
+ Assert.ok(
+ lines.some(
+ line => line.trim() == "Autocrypt: addr=bob@openpgp.example; keydata="
+ ),
+ "Correct Autocrypt header found"
+ );
+
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "ok"),
+ "message has signed icon"
+ );
+
+ Assert.equal(
+ aboutMessage.document.querySelector("#attachmentList").itemChildren.length,
+ 0,
+ "no keys attached to message"
+ );
+
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+
+ // Delete the message so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+ // Restore pref to original value
+ Services.prefs.clearUserPref(autocryptPrefName);
+});
+
+/**
+ * Tests composition of a message that is signed only with, public key attachment
+ * enabled, shows as signed in the Outbox.
+ */
+add_task(async function testSignedMessageWithKeyComposition() {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Signed Message With Key",
+ "This is a signed message with key composition test."
+ );
+
+ await OpenPGPTestUtils.toggleMessageSigning(composeWin);
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "ok"),
+ "message has signed icon"
+ );
+
+ let attachmentList = aboutMessage.document.querySelector("#attachmentList");
+
+ Assert.equal(
+ attachmentList.itemChildren.length,
+ 1,
+ "message has one attachment"
+ );
+
+ Assert.ok(
+ attachmentList
+ .getItemAtIndex(0)
+ .attachment.name.includes(OpenPGPTestUtils.BOB_KEY_ID),
+ "attachment name contains Bob's key id"
+ );
+
+ Assert.ok(
+ !OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "encrypted icon is not displayed"
+ );
+
+ // Delete the message so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+});
+
+/*
+This comment documents Carol's and Alice's keys encoded as autocrypt
+headers.
+
+Autocrypt-Gossip: addr=alice@openpgp.example; keydata=
+ xjMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/Ub7O1u13N
+ JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+wpAEExYIADgCGwMFCwkI
+ BwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXaWfOgAKCRDy
+ MVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnOdypvbm+QtXZqth9rvwD9HcDC0tC+PHAs
+ O7OTh1S1TC9RiJsvawAfCPaQZoed8gLOOARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzg
+ qbXCpDDYMiKRVitCsy203x3sE9+eviIDAQgHwngEGBYIACAWIQTrhbtfozp14V6UTmPyMVUM
+ T0fjjgUCXEcE6QIbDAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW
+ 4xN80fsn0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE=
+Autocrypt-Gossip: addr=carol@example.com; keydata=
+ xsFNBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJPaNoLbqNP
+ efuFg9ib3J0rKcZfqgnqC4usPVSTdmC4w0MdmHvh+1tUoXcxnrjYNRRbP+lC7zaLRRnEEioi
+ mC0Mkh+ow1u4F2QFBjwcV9bD7i0T1DRfR5k5kh3kcaYFnGnwMjwjJzLtvu3OZbXYsofCw789
+ 0TP4LkqLEQVOw1OrxBnRd5QNBVojcQi6rnKOQ7AUBGRKSXI3QVrbP+x1oImXpQSqIyaRFbtx
+ 57QafDdkyHBEfChO9X96BtMndyry8XgYtcgmwKKWg8Js4TJgghus6Sng5dA7/87nRf/9//Np
+ tXh9mdW3AiHsqb+tBu7NJGk6pAPL4fUjXILjcm5ZXdlUeFVLmYmqTiOJcGFbqHEBGcwLKPob
+ a2JsBEpnRj0ZEmo2khT+9tXJK3FUANc4w/QfxTXMwV17yYvocDPEBkoKcbxE8b2sSK/L7Vi+
+ h21XX6fA6B3zKFQ3hetFvOjEGTCkhFD9asL8KnwQdJmYo4Bd45AVoMZFxBxpmuo9MxPdiF2A
+ GbKHgrKpqDw2pUfelFwMZIVQ4Ya1wdtLe8gEJAMq6YnuuQcq+jjGKubNRywld7xXIsxJCpHt
+ qbCQM9P+gqp1VDBnbsk4xGX0HgILXF2JfyceGMGy1Lku0QA+ywARAQABzRlDYXJvbCA8Y2Fy
+ b2xAZXhhbXBsZS5jb20+wsGJBBMBCAAzFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTUC
+ GwMFCwkIBwIGFQgJCgsCBRYCAwEAAAoJEDCZ/xI4hSufjB0P/0+yaZknO8dS5o7Gp1ZuJwh6
+ +vgTGWrTxcBtsU1JR4BFobPKtMmw45FKsNIiK+AQ7ExCtqumGoTJ6hlclBFMlDQyyCxJG/Zp
+ PdrFUFyg6JUVf05/LWsd4Fwy/hQY1ha8R81QinSHqv9DJk6fKZG2rz7YUE47LFfjugbwUj9y
+ 8naTxj823Vm6v36J2wgl/1/PHoZTwi3vQRA70SoIDt4tSjqBzuclt2k/zlkJmOpBYtQb+xGw
+ pfnh2gBJdYurLwJO9rQlzYjy/+1qB0CZsE95WlkTrqQw8V5S6ULcnyACbETdF5HF/geHL367
+ p/iWULD907E4DJlQBOWjY6fdsJIBj96NfQiG+cXYTNGqaB/FgW8jyoS9vyg4PDOr0nGHLvzP
+ w7xTDUkuoJiWXMJ9kDYTZ+MsWreA885i1JSE32CsqqP3+kI7XQD3d3T3pIPhKOo0/bzbLY6y
+ WBXh809Ovi9fMxaZkrlrmA3lFcY+FbzDjZB+UYOXDB6TRu1jvISVMiXnYf4X21xWyl8AWv1q
+ ANMSXFKUwBSR88I06QZiJBmm9wHcyVtK/Hb6pgH10LydZvIfRDLrDBc2z31rswjNj9UhNp0Q
+ fGdNz/gXdxc8HP7Pf4kHkjIxLrWUNlDpYddX+iz1Z//VY9h2XTmSail5pMyyXdiGm90AGfVh
+ IcaOoeKK9UslzsFNBF9GZTUBEADWPef8E4OUoxU+vhwCxy/4nDfxzV4ZMFYkqp8QgpLzTVgT
+ v6xGVHFx/waNjwR6G34tD0aYhkDrumv9QsMdiQnMw9pLAoc3bnIkL8LkXnS8fVeiuzkXd4lg
+ vpxFlce7KYuXos9Ew7Nm2tOx4ovoygFikjliFTKn+QOVJoTr4pxJL9RdzYQ/pV/DI/fc2cmR
+ Wy0uivP+F+LBtYW6ZOMY1aXzsJEvun2i5ZxV2jqNDhXpD3m6/Y/28WItKbmT80hvTivxO2DS
+ Q1kqNcwB8Z0XWZJoz6iyYUu27dKB0L4S/x4UASlC6J2Db8bIL3Tdhuy+N0BN8sS1TDWb7Oi1
+ Ad8huVxfrRSyOYj4fkksvAEgDEDH6JEvJBU3CGQtfXCoX6d64db2cGp85GDfNHTREJ0mbRjL
+ AKL1RKrcKOG1790OZU2veF5qiN2eN08OLfJURL8+P4+mDWbaOcZasqNrg3YhYcPX3ZZzKfEI
+ vvTOdqMk00JU3zaUZhJvGOR9tJ27NBTrCEIOHz7yzOJltTDjdfNZNLqSYFp08+vR/IjSDv8h
+ l6PRjkomkbfdPdwPczKS0dG9Cf8cU+NZQrEgE0Un4tvb7p55j9R5OVgHUACLFTlDIRV4veD5
+ RnM2hUFRtBONymXEDjoPGZXaHhv16MckFpZ1IEAkMIZ3Ti/NIZcS7IA9jRgBUQARAQABwsF2
+ BBgBCAAgFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTYCGwwACgkQMJn/EjiFK5/Q3hAA
+ mzMu7EOeWG0xAHAQ4b/ocCSlZqg/MSf6kJIkzUxdnX9T/ylEmrS8cEg5mdJMQMVvCecyDpNK
+ 9MgJPV7MTnR6x/4qgdVUTtknd6W7RrQ7Oai150nMH5U9M8GrFtbQjc/fOw17agoT06ZGV4um
+ IK41IIGwQZ2/Z/cElHkQZll9//hYS8/E8xOBlweVxsMZhfcLFrbx2hC2osRt0vMlGnYSnv29
+ ligVG+2PwwnHXB6Tn7eslzoowY78ANCTvA6Rc6zR+RIs/CIiaDNgWCRBJcueZVpA+JkyL6Km
+ C+JiiF6Hsm07DDDjgLVJ0s660GNe8sWw4IZ8wpvYq1goqXLu+CMqbCsBrEDwfguClxGSQnLw
+ AUIVxuyKprToLJ6hmuubsVcv9fzf/GoYFnT9hge1YZpptKi/zrQqy2CZuSZEHWpUZcwPE3Ow
+ qbHKty3UhZPJU50kmEOd/UQNJYNWxxxx5593X96jLLDOxm5M5jNNRvGZPgn8RbA1e7VC2XFg
+ V2KGJHq/gxCpwkWs8+0sYUtcFuu+RQWTKbJpFcxfAIEDKS+fyLRAFdYqUA3yQIA1UYco10l8
+ RYPLY0+IXiArqjql8+k8PBT0U4P59lfcKlY2GaJe4aoWLPOdNZAJgLzoxd5zgnz0vI3sn+3v
+ meCtpxz2PoYBJfxGPEzu9xTLV6k9wSVTCgE=
+*/
+
+/**
+ * Tests composition of a signed, encrypted message, for two recipients,
+ * is shown as signed and encrypted in the Outbox, and has the
+ * Autocrypt-Gossip headers.
+ */
+add_task(async function testSignedEncryptedMessageComposition() {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example, carol@example.com",
+ "Compose Signed Encrypted Message",
+ "This is a signed, encrypted message composition test."
+ );
+ await checkDonePromise;
+
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+
+ await OpenPGPTestUtils.toggleMessageKeyAttachment(composeWin);
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ let encryptedMsg = select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "ok"),
+ "message has signed icon"
+ );
+
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "message has encrypted icon"
+ );
+
+ Assert.equal(
+ aboutMessage.document.querySelector("#attachmentList").itemChildren.length,
+ 0,
+ "no keys attached to message"
+ );
+
+ // Check that the gossip headers are inside the encrypted message.
+ // To check that easily, we decrypt the message to another folder,
+ // and then get its source.
+
+ // moving to decrypted message in same folder (deleting original)
+ await EnigmailPersistentCrypto.cryptMessage(
+ encryptedMsg,
+ gOutbox.URI,
+ true,
+ null
+ );
+
+ let msg = await select_click_row(0);
+ let src = await get_msg_source(msg);
+ let lines = src.split("\r\n");
+
+ // As a sanity check, we check that the header line, plus the first
+ // and last lines of the keydata are present.
+ let expectedGossipLines = [
+ "Autocrypt-Gossip: addr=alice@openpgp.example; keydata=",
+ " xjMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/Ub7O1u13N",
+ " 4xN80fsn0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE=",
+ "Autocrypt-Gossip: addr=carol@example.com; keydata=",
+ " xsFNBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJPaNoLbqNP",
+ " meCtpxz2PoYBJfxGPEzu9xTLV6k9wSVTCgE=",
+ ];
+
+ for (let egl of expectedGossipLines) {
+ Assert.ok(
+ lines.some(line => line == egl),
+ "The following Autocrypt-Gossip header line was found: " + egl
+ );
+ }
+
+ // Delete the message so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+});
+
+/**
+ * Tests composition of a signed, encrypted, message with public key attachment
+ * enabled, is shown signed, encrypted in the Outbox.
+ */
+add_task(async function testSignedEncryptedMessageWithKeyComposition() {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Signed Encrypted Message With Key",
+ "This is a signed, encrypted message with key composition test."
+ );
+ await checkDonePromise;
+
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "ok"),
+ "message has signed icon"
+ );
+
+ Assert.ok(
+ OpenPGPTestUtils.hasEncryptedIconState(aboutMessage.document, "ok"),
+ "message has encrypted icon"
+ );
+
+ let attachmentList = aboutMessage.document.querySelector("#attachmentList");
+
+ Assert.equal(
+ attachmentList.itemChildren.length,
+ 1,
+ "message has one attachment"
+ );
+
+ Assert.ok(
+ attachmentList
+ .getItemAtIndex(0)
+ .attachment.name.includes(OpenPGPTestUtils.BOB_KEY_ID),
+ "attachment name contains Bob's key id"
+ );
+
+ // Delete the message so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+});
+
+registerCleanupFunction(async function tearDown() {
+ bobIdentity.setUnicharAttribute("openpgp_key_id", initialKeyIdPref);
+ await OpenPGPTestUtils.removeKeyById("0xfbfcc82a015e7330", true);
+ MailServices.accounts.removeIncomingServer(bobAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(bobAcct, true);
+});
diff --git a/comm/mail/test/browser/openpgp/composition/browser_composeSigned2.js b/comm/mail/test/browser/openpgp/composition/browser_composeSigned2.js
new file mode 100644
index 0000000000..608afea82c
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/composition/browser_composeSigned2.js
@@ -0,0 +1,213 @@
+/* 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/. */
+
+/**
+ * Tests for OpenPGP signed message composition,
+ * when OpenPGP passphrases are in use.
+ */
+
+"use strict";
+
+const {
+ assert_selected_and_displayed,
+ be_in_folder,
+ get_about_message,
+ get_special_folder,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { open_compose_new_mail, get_msg_source, setup_msg_contents } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let bobAcct;
+let gOutbox;
+let kylieAcct;
+
+let aboutMessage = get_about_message();
+
+/**
+ * Setup a mail account with a private key and import the public key for the
+ * receiver.
+ */
+add_setup(async function () {
+ bobAcct = MailServices.accounts.createAccount();
+ bobAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "bob",
+ "openpgp.example",
+ "imap"
+ );
+ let bobIdentity = MailServices.accounts.createIdentity();
+ bobIdentity.email = "bob@openpgp.example";
+ bobAcct.addIdentity(bobIdentity);
+
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret-with-pp.asc"
+ )
+ ),
+ OpenPGPTestUtils.ACCEPTANCE_PERSONAL,
+ "bob-passphrase",
+ true
+ );
+
+ Assert.ok(id, "private key id received");
+
+ let initialKeyIdPref = bobIdentity.getUnicharAttribute("openpgp_key_id");
+ bobIdentity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/alice@openpgp.example-0xf231550c4f47e38e-pub.asc"
+ )
+ )
+ );
+
+ gOutbox = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+
+ kylieAcct = MailServices.accounts.createAccount();
+ kylieAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "kylie",
+ "example.com",
+ "imap"
+ );
+ let kylieIdentity = MailServices.accounts.createIdentity();
+ kylieIdentity.email = "kylie@example.com";
+ kylieAcct.addIdentity(kylieIdentity);
+
+ let [id2] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/kylie-0x1AABD9FAD1E411DD-secret-subkeys.asc"
+ )
+ ),
+ OpenPGPTestUtils.ACCEPTANCE_PERSONAL,
+ "kylie-passphrase",
+ false
+ );
+
+ Assert.ok(id2, "private key id received");
+ kylieIdentity.setUnicharAttribute("openpgp_key_id", id2.split("0x").join(""));
+
+ registerCleanupFunction(async function tearDown() {
+ bobIdentity.setUnicharAttribute("openpgp_key_id", initialKeyIdPref);
+ await OpenPGPTestUtils.removeKeyById("0xfbfcc82a015e7330", true);
+ MailServices.accounts.removeIncomingServer(bobAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(bobAcct, true);
+ await OpenPGPTestUtils.removeKeyById("0x1AABD9FAD1E411DD", true);
+ MailServices.accounts.removeIncomingServer(kylieAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(kylieAcct, true);
+ });
+});
+
+/**
+ * Tests composition of a signed message is shown as signed in the Outbox.
+ */
+add_task(async function testSignedMessageComposition2() {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Signed Message",
+ "This is a signed message composition test."
+ );
+
+ await OpenPGPTestUtils.toggleMessageSigning(composeWin);
+ await OpenPGPTestUtils.toggleMessageKeyAttachment(composeWin);
+
+ let passPromptPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ let sendMessageCompletePromise = sendMessage(composeWin);
+
+ let ppWin = await passPromptPromise;
+
+ // We'll enter a wrong pp, so we expect another prompt
+ let passPromptPromise2 = BrowserTestUtils.promiseAlertDialogOpen();
+
+ ppWin.document.getElementById("password1Textbox").value = "WRONG-passphrase";
+ ppWin.document.querySelector("dialog").getButton("accept").click();
+
+ let ppWin2 = await passPromptPromise2;
+
+ ppWin2.document.getElementById("password1Textbox").value = "bob-passphrase";
+ ppWin2.document.querySelector("dialog").getButton("accept").click();
+
+ await sendMessageCompletePromise;
+
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "ok"),
+ "message should have signed icon"
+ );
+
+ Assert.equal(
+ aboutMessage.document.querySelector("#attachmentList").itemChildren.length,
+ 0,
+ "there should be no keys attached to message"
+ );
+
+ // Delete the message so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+});
+
+/**
+ * Tests composition of a signed message is shown as signed in the Outbox,
+ * with a key that has an offline primary key. Ensure the subkeys were
+ * imported correctly and are no longer protected by a passphrase
+ * (ensure import remove the passphrase protection and switched them
+ * to use automatic protection).
+ */
+add_task(async function testSignedMessageComposition3() {
+ await be_in_folder(kylieAcct.incomingServer.rootFolder);
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Signed Message",
+ "This is a signed message composition test."
+ );
+
+ await OpenPGPTestUtils.toggleMessageSigning(composeWin);
+ await OpenPGPTestUtils.toggleMessageKeyAttachment(composeWin);
+ await sendMessage(composeWin);
+
+ await be_in_folder(gOutbox);
+ select_click_row(0);
+ assert_selected_and_displayed(0);
+
+ Assert.ok(
+ OpenPGPTestUtils.hasSignedIconState(aboutMessage.document, "ok"),
+ "message should have signed icon"
+ );
+
+ Assert.equal(
+ aboutMessage.document.querySelector("#attachmentList").itemChildren.length,
+ 0,
+ "there should be no keys attached to message"
+ );
+
+ // Delete the message so other tests work.
+ EventUtils.synthesizeKey("VK_DELETE");
+});
diff --git a/comm/mail/test/browser/openpgp/composition/browser_composeSwitchIdentity.js b/comm/mail/test/browser/openpgp/composition/browser_composeSwitchIdentity.js
new file mode 100644
index 0000000000..fdcfecf087
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/composition/browser_composeSwitchIdentity.js
@@ -0,0 +1,821 @@
+/* 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/. */
+
+/**
+ * Tests for OpenPGP encrypted message composition.
+ */
+
+"use strict";
+
+const {
+ open_message_from_file,
+ be_in_folder,
+ get_special_folder,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { open_compose_new_mail, setup_msg_contents } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+const { click_menus_in_sequence, close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let bobAcct;
+let bobIdentity;
+let plainIdentity;
+let gOutbox;
+
+// Used in some of the tests to verify key status display.
+let l10n = new Localization(["messenger/openpgp/composeKeyStatus.ftl"]);
+
+/**
+ * Closes a window with a <dialog> element by calling the acceptDialog().
+ *
+ * @param {Window} win
+ */
+async function closeDialog(win) {
+ let closed = BrowserTestUtils.domWindowClosed(win);
+ win.document.documentElement.querySelector("dialog").acceptDialog();
+ await closed;
+}
+
+async function waitCheckEncryptionStateDone(win) {
+ return BrowserTestUtils.waitForEvent(
+ win.document,
+ "encryption-state-checked"
+ );
+}
+
+/**
+ * Setup a mail account with a private key and import the public key for the
+ * receiver.
+ */
+add_setup(async function () {
+ // Encryption makes the compose process a little longer.
+ requestLongerTimeout(5);
+
+ bobAcct = MailServices.accounts.createAccount();
+ bobAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "bob",
+ "openpgp.example",
+ "imap"
+ );
+ bobIdentity = MailServices.accounts.createIdentity();
+ bobIdentity.email = "bob@openpgp.example";
+ bobAcct.addIdentity(bobIdentity);
+
+ plainIdentity = MailServices.accounts.createIdentity();
+ plainIdentity.email = "bob+plain@openpgp.example";
+ bobAcct.addIdentity(plainIdentity);
+
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc"
+ )
+ )
+ );
+
+ Assert.ok(id, "private key id received");
+ bobIdentity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+
+ await OpenPGPTestUtils.importPublicKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/alice@openpgp.example-0xf231550c4f47e38e-pub.asc"
+ )
+ )
+ );
+
+ gOutbox = await get_special_folder(Ci.nsMsgFolderFlags.Queue);
+});
+
+async function testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+) {
+ await be_in_folder(bobAcct.incomingServer.rootFolder);
+
+ bobIdentity.encryptionPolicy = prefEncryptionPolicy;
+ bobIdentity.signMail = prefSignMail;
+ bobIdentity.attachPgpKey = prefAttachPgpKey;
+ bobIdentity.protectSubject = prefProtectSubject;
+
+ let cwc = open_compose_new_mail();
+ let composeWin = cwc.window;
+
+ // setup_msg_contents will trigger checkEncryptionState.
+ let checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ setup_msg_contents(
+ cwc,
+ "alice@openpgp.example",
+ "Compose Message",
+ "This is a message."
+ );
+ await checkDonePromise;
+
+ Assert.equal(composeWin.gSendEncrypted, expectSendEncrypted);
+ Assert.equal(composeWin.gSendSigned, expectSendSigned);
+ Assert.equal(composeWin.gAttachMyPublicPGPKey, expectAttachMyPublicPGPKey);
+ Assert.equal(composeWin.gEncryptSubject, expectEncryptSubject);
+
+ if (testToggle) {
+ if (testToggle == "encrypt") {
+ // This toggle will trigger checkEncryptionState(), request that
+ // an event will be sent after the next call to checkEncryptionState
+ // has completed.
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+ await OpenPGPTestUtils.toggleMessageEncryption(composeWin);
+ await checkDonePromise;
+ } else if (testToggle == "sign") {
+ await OpenPGPTestUtils.toggleMessageSigning(composeWin);
+ } else if (testToggle == "encrypt-subject") {
+ await OpenPGPTestUtils.toggleMessageEncryptSubject(composeWin);
+ } else if (testToggle == "attach-key") {
+ await OpenPGPTestUtils.toggleMessageKeyAttachment(composeWin);
+ } else {
+ Assert.ok(false, "test provides allowed toggle parameter");
+ }
+
+ Assert.equal(composeWin.gSendEncrypted, expectSendEncrypted2AfterToggle);
+ Assert.equal(composeWin.gSendSigned, expectSendSigned2AfterToggle);
+ Assert.equal(
+ composeWin.gAttachMyPublicPGPKey,
+ expectAttachMyPublicPGPKey2AfterToggle
+ );
+ Assert.equal(composeWin.gEncryptSubject, expectEncryptSubject2AfterToggle);
+ }
+
+ if (switchIdentity) {
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("msgIdentity"),
+ {},
+ cwc.window.document.getElementById("msgIdentity").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ cwc.window.document.getElementById("msgIdentityPopup"),
+ [{ identitykey: plainIdentity.key }]
+ );
+
+ await checkDonePromise;
+
+ Assert.equal(
+ composeWin.gSendEncrypted,
+ expectSendEncrypted3GoneToPlainIdentity
+ );
+ Assert.equal(composeWin.gSendSigned, expectSendSigned3GoneToPlainIdentity);
+ Assert.equal(
+ composeWin.gAttachMyPublicPGPKey,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity
+ );
+ Assert.equal(
+ composeWin.gEncryptSubject,
+ expectEncryptSubject3GoneToPlainIdentity
+ );
+
+ checkDonePromise = waitCheckEncryptionStateDone(composeWin);
+
+ EventUtils.synthesizeMouseAtCenter(
+ cwc.window.document.getElementById("msgIdentity"),
+ {},
+ cwc.window.document.getElementById("msgIdentity").ownerGlobal
+ );
+ await click_menus_in_sequence(
+ cwc.window.document.getElementById("msgIdentityPopup"),
+ [{ identitykey: bobIdentity.key }]
+ );
+
+ await checkDonePromise;
+
+ Assert.equal(
+ composeWin.gSendEncrypted,
+ expectSendEncrypted4GoneToOrigIdentity
+ );
+ Assert.equal(composeWin.gSendSigned, expectSendSigned4GoneToOrigIdentity);
+ Assert.equal(
+ composeWin.gAttachMyPublicPGPKey,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity
+ );
+ Assert.equal(
+ composeWin.gEncryptSubject,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+ }
+
+ await BrowserTestUtils.closeWindow(composeWin);
+ await TestUtils.waitForCondition(
+ () => document.hasFocus(),
+ "waiting for focus to return to the main window"
+ );
+}
+
+/**
+ * Each function below tests a specific identity e2ee configuration
+ * (see initial variables named pref*),
+ * then opening a composer window based on those prefs,
+ * then optionally toggling an e2ee flag in composer window,
+ * then switching to a default "from" identity (no e2ee configured),
+ * then switching back to the initial identity,
+ * and checks that the resulting message flags (as seen in variables)
+ * at each step are as expected.
+ */
+
+add_task(async function testMsgComp1() {
+ let prefEncryptionPolicy = 0; // default encryption: off
+ let prefSignMail = false; // sign unencrypted messages: off
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = false;
+ let expectSendSigned = false;
+ let expectAttachMyPublicPGPKey = false;
+ let expectEncryptSubject = false;
+ let testToggle = null;
+ let expectSendEncrypted2AfterToggle = undefined;
+ let expectSendSigned2AfterToggle = undefined;
+ let expectAttachMyPublicPGPKey2AfterToggle = undefined;
+ let expectEncryptSubject2AfterToggle = undefined;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = false;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = false;
+ let expectSendEncrypted4GoneToOrigIdentity = false;
+ let expectSendSigned4GoneToOrigIdentity = false;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = false;
+ let expectEncryptSubject4GoneToOrigIdentity = false;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp1b() {
+ let prefEncryptionPolicy = 0; // default encryption: off
+ let prefSignMail = false; // sign unencrypted messages: off
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = false;
+ let expectSendSigned = false;
+ let expectAttachMyPublicPGPKey = false;
+ let expectEncryptSubject = false;
+ let testToggle = "sign";
+ let expectSendEncrypted2AfterToggle = false;
+ let expectSendSigned2AfterToggle = true;
+ let expectAttachMyPublicPGPKey2AfterToggle = true;
+ let expectEncryptSubject2AfterToggle = false;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = false;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = false;
+ let expectSendEncrypted4GoneToOrigIdentity = false;
+ let expectSendSigned4GoneToOrigIdentity = false;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = false;
+ let expectEncryptSubject4GoneToOrigIdentity = false;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp2() {
+ let prefEncryptionPolicy = 0; // default encryption: off
+ let prefSignMail = true; // sign unencrypted messages: on
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = false;
+ let expectSendSigned = true;
+ let expectAttachMyPublicPGPKey = true;
+ let expectEncryptSubject = false;
+ let testToggle = null;
+ let expectSendEncrypted2AfterToggle = undefined;
+ let expectSendSigned2AfterToggle = undefined;
+ let expectAttachMyPublicPGPKey2AfterToggle = undefined;
+ let expectEncryptSubject2AfterToggle = undefined;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = false;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = false;
+ let expectSendEncrypted4GoneToOrigIdentity = false;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = true;
+ let expectEncryptSubject4GoneToOrigIdentity = false;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp2b() {
+ let prefEncryptionPolicy = 0; // default encryption: off
+ let prefSignMail = true; // sign unencrypted messages: on
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = false;
+ let expectSendSigned = true;
+ let expectAttachMyPublicPGPKey = true;
+ let expectEncryptSubject = false;
+ let testToggle = "attach-key";
+ let expectSendEncrypted2AfterToggle = false;
+ let expectSendSigned2AfterToggle = true;
+ let expectAttachMyPublicPGPKey2AfterToggle = false;
+ let expectEncryptSubject2AfterToggle = false;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = false;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = false;
+ let expectSendEncrypted4GoneToOrigIdentity = false;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = false;
+ let expectEncryptSubject4GoneToOrigIdentity = false;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp3() {
+ let prefEncryptionPolicy = 2; // default encryption: on (require)
+ let prefSignMail = false; // sign unencrypted messages: off
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = true;
+ let expectSendSigned = true;
+ let expectAttachMyPublicPGPKey = true;
+ let expectEncryptSubject = true;
+ let testToggle = null;
+ let expectSendEncrypted2AfterToggle = undefined;
+ let expectSendSigned2AfterToggle = undefined;
+ let expectAttachMyPublicPGPKey2AfterToggle = undefined;
+ let expectEncryptSubject2AfterToggle = undefined;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = true;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = true;
+ let expectSendEncrypted4GoneToOrigIdentity = true;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = true;
+ let expectEncryptSubject4GoneToOrigIdentity = true;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp3b() {
+ let prefEncryptionPolicy = 2; // default encryption: on (require)
+ let prefSignMail = false; // sign unencrypted messages: off
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = true;
+ let expectSendSigned = true;
+ let expectAttachMyPublicPGPKey = true;
+ let expectEncryptSubject = true;
+ let testToggle = "encrypt-subject";
+ let expectSendEncrypted2AfterToggle = true;
+ let expectSendSigned2AfterToggle = true;
+ let expectAttachMyPublicPGPKey2AfterToggle = true;
+ let expectEncryptSubject2AfterToggle = false;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = true;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = false;
+ let expectSendEncrypted4GoneToOrigIdentity = true;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = true;
+ let expectEncryptSubject4GoneToOrigIdentity = false;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp4() {
+ let prefEncryptionPolicy = 2; // default encryption: on (require)
+ let prefSignMail = true; // sign unencrypted messages: on
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = true;
+ let expectSendSigned = true;
+ let expectAttachMyPublicPGPKey = true;
+ let expectEncryptSubject = true;
+ let testToggle = null;
+ let expectSendEncrypted2AfterToggle = undefined;
+ let expectSendSigned2AfterToggle = undefined;
+ let expectAttachMyPublicPGPKey2AfterToggle = undefined;
+ let expectEncryptSubject2AfterToggle = undefined;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = true;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = true;
+ let expectSendEncrypted4GoneToOrigIdentity = true;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = true;
+ let expectEncryptSubject4GoneToOrigIdentity = true;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp4b() {
+ let prefEncryptionPolicy = 2; // default encryption: on (require)
+ let prefSignMail = true; // sign unencrypted messages: on
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = true;
+ let expectSendSigned = true;
+ let expectAttachMyPublicPGPKey = true;
+ let expectEncryptSubject = true;
+ let testToggle = "attach-key";
+ let expectSendEncrypted2AfterToggle = true;
+ let expectSendSigned2AfterToggle = true;
+ let expectAttachMyPublicPGPKey2AfterToggle = false;
+ let expectEncryptSubject2AfterToggle = true;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = true;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = true;
+ let expectSendEncrypted4GoneToOrigIdentity = true;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = false;
+ let expectEncryptSubject4GoneToOrigIdentity = true;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp5() {
+ let prefEncryptionPolicy = 2; // default encryption: on (require)
+ let prefSignMail = false; // sign unencrypted messages: off
+ let prefAttachPgpKey = false; // attach key to signed messages: off
+ let prefProtectSubject = false; // encrypt subject of encrypted message: off
+
+ let expectSendEncrypted = true;
+ let expectSendSigned = true;
+ let expectAttachMyPublicPGPKey = false;
+ let expectEncryptSubject = false;
+ let testToggle = null;
+ let expectSendEncrypted2AfterToggle = undefined;
+ let expectSendSigned2AfterToggle = undefined;
+ let expectAttachMyPublicPGPKey2AfterToggle = undefined;
+ let expectEncryptSubject2AfterToggle = undefined;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = true;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = true;
+ let expectSendEncrypted4GoneToOrigIdentity = true;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = false;
+ let expectEncryptSubject4GoneToOrigIdentity = false;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp6() {
+ let prefEncryptionPolicy = 0; // default encryption: off
+ let prefSignMail = false; // sign unencrypted messages: off
+ let prefAttachPgpKey = true; // attach key to signed messages: on
+ let prefProtectSubject = true; // encrypt subject of encrypted message: on
+
+ let expectSendEncrypted = false;
+ let expectSendSigned = false;
+ let expectAttachMyPublicPGPKey = false;
+ let expectEncryptSubject = false;
+ let testToggle = "encrypt";
+ let expectSendEncrypted2AfterToggle = true;
+ let expectSendSigned2AfterToggle = true;
+ let expectAttachMyPublicPGPKey2AfterToggle = true;
+ let expectEncryptSubject2AfterToggle = true;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = true;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = true;
+ let expectSendEncrypted4GoneToOrigIdentity = true;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = true;
+ let expectEncryptSubject4GoneToOrigIdentity = true;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+add_task(async function testMsgComp7() {
+ let prefEncryptionPolicy = 0; // default encryption: off
+ let prefSignMail = true; // sign unencrypted messages: on
+ let prefAttachPgpKey = false; // attach key to signed messages: off
+ let prefProtectSubject = false; // encrypt subject of encrypted message: off
+
+ let expectSendEncrypted = false;
+ let expectSendSigned = true;
+ let expectAttachMyPublicPGPKey = false;
+ let expectEncryptSubject = false;
+ let testToggle = "encrypt";
+ let expectSendEncrypted2AfterToggle = true;
+ let expectSendSigned2AfterToggle = true;
+ let expectAttachMyPublicPGPKey2AfterToggle = false;
+ let expectEncryptSubject2AfterToggle = false;
+ let switchIdentity = true;
+ let expectSendEncrypted3GoneToPlainIdentity = true;
+ let expectSendSigned3GoneToPlainIdentity = false;
+ let expectAttachMyPublicPGPKey3GoneToPlainIdentity = false;
+ let expectEncryptSubject3GoneToPlainIdentity = true;
+ let expectSendEncrypted4GoneToOrigIdentity = true;
+ let expectSendSigned4GoneToOrigIdentity = true;
+ let expectAttachMyPublicPGPKey4GoneToOrigIdentity = false;
+ let expectEncryptSubject4GoneToOrigIdentity = false;
+
+ await testComposeFlags(
+ prefEncryptionPolicy,
+ prefSignMail,
+ prefAttachPgpKey,
+ prefProtectSubject,
+ expectSendEncrypted,
+ expectSendSigned,
+ expectAttachMyPublicPGPKey,
+ expectEncryptSubject,
+ testToggle,
+ expectSendEncrypted2AfterToggle,
+ expectSendSigned2AfterToggle,
+ expectAttachMyPublicPGPKey2AfterToggle,
+ expectEncryptSubject2AfterToggle,
+ switchIdentity,
+ expectSendEncrypted3GoneToPlainIdentity,
+ expectSendSigned3GoneToPlainIdentity,
+ expectAttachMyPublicPGPKey3GoneToPlainIdentity,
+ expectEncryptSubject3GoneToPlainIdentity,
+ expectSendEncrypted4GoneToOrigIdentity,
+ expectSendSigned4GoneToOrigIdentity,
+ expectAttachMyPublicPGPKey4GoneToOrigIdentity,
+ expectEncryptSubject4GoneToOrigIdentity
+ );
+});
+
+registerCleanupFunction(function tearDown() {
+ MailServices.accounts.removeIncomingServer(bobAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(bobAcct, true);
+});
diff --git a/comm/mail/test/browser/openpgp/composition/browser_editDraftTemplate.js b/comm/mail/test/browser/openpgp/composition/browser_editDraftTemplate.js
new file mode 100644
index 0000000000..e1e49927a6
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/composition/browser_editDraftTemplate.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/. */
+
+"use strict";
+
+/**
+ * Tests that drafts and templates get the appropriate security properties
+ * when opened.
+ */
+
+var { open_compose_new_mail, setup_msg_contents } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+
+var {
+ be_in_folder,
+ get_about_3pane,
+ get_special_folder,
+ mc,
+ right_click_on_row,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let aliceAcct;
+let aliceIdentity;
+let draftsFolder;
+let templatesFolder;
+
+/**
+ * Helper function to wait for a compose window to get opened.
+ *
+ * @returns The opened window.
+ */
+async function waitForComposeWindow() {
+ return BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ return (
+ win.document.documentURI ===
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ );
+ });
+}
+
+function clearFolder(folder) {
+ return new Promise(resolve => {
+ let msgs = [...folder.msgDatabase.enumerateMessages()];
+
+ folder.deleteMessages(
+ msgs,
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+}
+
+add_setup(async function () {
+ aliceAcct = MailServices.accounts.createAccount();
+ aliceAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "alice",
+ "openpgp.example",
+ "pop3"
+ );
+ aliceIdentity = MailServices.accounts.createIdentity();
+ aliceIdentity.email = "alice@openpgp.example";
+ aliceAcct.addIdentity(aliceIdentity);
+
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ )
+ )
+ );
+
+ Assert.ok(id, "private key imported");
+
+ aliceIdentity.setUnicharAttribute("openpgp_key_id", id.split("0x").join(""));
+
+ draftsFolder = await get_special_folder(
+ Ci.nsMsgFolderFlags.Drafts,
+ true,
+ aliceAcct.incomingServer.localFoldersServer
+ );
+
+ templatesFolder = await get_special_folder(
+ Ci.nsMsgFolderFlags.Templates,
+ true,
+ aliceAcct.incomingServer.localFoldersServer
+ );
+});
+
+/**
+ * Create draft, make sure the sec properties are as they should after
+ * opening.
+ */
+add_task(async function testDraftSec() {
+ await be_in_folder(draftsFolder);
+ await doTestSecState(true, false); // draft, not secure
+ await doTestSecState(true, true); // draft, secure
+});
+
+/**
+ * Create template, make sure the sec properties are as they should after
+ * opening.
+ */
+add_task(async function testTemplSec() {
+ await be_in_folder(templatesFolder);
+ await doTestSecState(false, false); // template, not secure
+ await doTestSecState(false, true); // template, secure
+});
+
+/**
+ * Drafts/templates are stored encrypted before sent. Test that when composing
+ * and the reopening, the correct encryption states get set.
+ */
+async function doTestSecState(isDraft, secure) {
+ // Make sure to compose from alice.
+ let inbox = aliceAcct.incomingServer.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ await be_in_folder(inbox);
+
+ let cwc = open_compose_new_mail();
+ let type = isDraft ? "draft" : "template";
+ let theFolder = isDraft ? draftsFolder : templatesFolder;
+ let subject = `test ${type}; ðŸ¤; secure=${secure}`;
+ setup_msg_contents(
+ cwc,
+ "test@example.invalid",
+ subject,
+ `This is a ${type}; secure=${secure}`
+ );
+ info(`Testing ${type}; secure=${secure}`);
+
+ if (secure) {
+ // Tick "Require encryption".
+ // Encryption and signing should get turned on.
+ await OpenPGPTestUtils.toggleMessageEncryption(cwc.window);
+ }
+
+ if (isDraft) {
+ cwc.window.SaveAsDraft();
+ } else {
+ cwc.window.SaveAsTemplate();
+ }
+
+ await TestUtils.waitForCondition(
+ () => !cwc.window.gSaveOperationInProgress && !cwc.window.gWindowLock,
+ "timeout waiting for saving to finish."
+ );
+
+ info(`Saved as ${type} with secure=${secure}`);
+ cwc.window.close();
+
+ await be_in_folder(theFolder);
+ select_click_row(0);
+
+ info(`Will open the ${type}`);
+ let draftWindowPromise = waitForComposeWindow();
+ select_click_row(0);
+ await right_click_on_row(0);
+
+ let about3Pane = get_about_3pane();
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ if (isDraft) {
+ mailContext.activateItem(
+ about3Pane.document.getElementById("mailContext-editDraftMsg")
+ );
+ } else {
+ mailContext.activateItem(
+ about3Pane.document.getElementById("mailContext-newMsgFromTemplate")
+ );
+ }
+
+ let draftWindow = await draftWindowPromise;
+
+ Assert.equal(
+ draftWindow.document.getElementById("msgSubject").value,
+ subject,
+ "subject should be decrypted"
+ );
+
+ info(`Checking security props in the UI...`);
+
+ if (!secure) {
+ // Wait some to make sure it won't (soon) be showing.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 100));
+ Assert.ok(
+ !draftWindow.document.getElementById("button-encryption").checked,
+ "should not use encryption"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => draftWindow.document.getElementById("button-encryption").checked,
+ "waited for encryption to get turned on"
+ );
+ }
+
+ draftWindow.close();
+ clearFolder(theFolder);
+}
+
+registerCleanupFunction(async function () {
+ MailServices.accounts.removeAccount(aliceAcct, true);
+ await OpenPGPTestUtils.removeKeyById("0xf231550c4f47e38e", true);
+});
diff --git a/comm/mail/test/browser/openpgp/composition/browser_expiredKey.js b/comm/mail/test/browser/openpgp/composition/browser_expiredKey.js
new file mode 100644
index 0000000000..bf5c7e195d
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/composition/browser_expiredKey.js
@@ -0,0 +1,137 @@
+/* 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";
+
+/**
+ * Tests for composition when a key is expired.
+ */
+
+const { close_compose_window, open_compose_new_mail } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+
+const { get_notification, wait_for_notification_to_show } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+
+const { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gAccount;
+var gIdentity;
+var gExpiredKeyId;
+var gNotExpiredKeyId;
+
+add_setup(async () => {
+ gAccount = MailServices.accounts.createAccount();
+ gAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ "eddie",
+ "openpgp.example",
+ "imap"
+ );
+
+ gIdentity = MailServices.accounts.createIdentity();
+ gIdentity.email = "eddie@openpgp.example";
+ gAccount.addIdentity(gIdentity);
+ MailServices.accounts.defaultAccount = gAccount;
+ MailServices.accounts.defaultAccount.defaultIdentity = gIdentity;
+
+ [gExpiredKeyId] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-secret.asc"
+ )
+ )
+ );
+ [gNotExpiredKeyId] = await OpenPGPTestUtils.importPrivateKey(
+ window,
+ new FileUtils.File(
+ getTestFilePath(
+ "../data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+ )
+ )
+ );
+ // FIXME: ^^^ should use a non-expiring key matching EDDIE!
+
+ registerCleanupFunction(async () => {
+ await OpenPGPTestUtils.removeKeyById(gExpiredKeyId, true);
+ await OpenPGPTestUtils.removeKeyById(gNotExpiredKeyId, true);
+ MailServices.accounts.removeIncomingServer(gAccount.incomingServer, true);
+ MailServices.accounts.removeAccount(gAccount, true);
+ });
+});
+
+add_task(async function testExpiredKeyShowsNotificationBar() {
+ Services.wm
+ .getMostRecentWindow("mail:3pane")
+ .document.getElementById("tabmail")
+ .currentAbout3Pane.displayFolder(gAccount.incomingServer.rootFolder);
+ info(`Using key ${gExpiredKeyId}`);
+ gIdentity.setUnicharAttribute(
+ "openpgp_key_id",
+ gExpiredKeyId.replace(/^0x/, "")
+ );
+ let cwc = open_compose_new_mail();
+
+ wait_for_notification_to_show(
+ cwc.window,
+ "compose-notification-bottom",
+ "openpgpSenderKeyExpired"
+ );
+ let notification = get_notification(
+ cwc.window,
+ "compose-notification-bottom",
+ "openpgpSenderKeyExpired"
+ );
+
+ Assert.ok(notification !== null, "notification should be visible");
+ Assert.equal(
+ notification.messageText.textContent,
+ "Your current configuration uses the key 0x15E9357D2C2395C0, which has expired.",
+ "correct notification message should be displayed"
+ );
+
+ const buttons = notification._buttons;
+ Assert.equal(
+ buttons[0]["l10n-id"],
+ "settings-context-open-account-settings-item2",
+ "button0 should be the button to open account settings"
+ );
+ cwc.window.close();
+});
+
+add_task(async function testKeyWithoutExpiryDoesNotShowNotification() {
+ Services.wm
+ .getMostRecentWindow("mail:3pane")
+ .document.getElementById("tabmail")
+ .currentAbout3Pane.displayFolder(gAccount.incomingServer.rootFolder);
+ info(`Using key ${gNotExpiredKeyId}`);
+ gIdentity.setUnicharAttribute(
+ "openpgp_key_id",
+ gNotExpiredKeyId.replace(/^0x/, "")
+ );
+ let cwc = open_compose_new_mail();
+
+ // Give it some time to potentially start showing.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 200));
+ let notification = get_notification(
+ cwc.window,
+ "compose-notification-bottom",
+ "openpgpSenderKeyExpired"
+ );
+
+ Assert.ok(
+ notification === null,
+ "the expiry warning should not be visible if the key is not expired"
+ );
+ cwc.window.close();
+});
diff --git a/comm/mail/test/browser/openpgp/composition/head.js b/comm/mail/test/browser/openpgp/composition/head.js
new file mode 100644
index 0000000000..b0b9b54d76
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/composition/head.js
@@ -0,0 +1,18 @@
+/* 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";
+
+/**
+ * Uses the "cmd_sendLater" to store the message in the passed compose window
+ * in the outbox.
+ */
+async function sendMessage(win) {
+ let closePromise = BrowserTestUtils.domWindowClosed(win);
+ win.goDoCommand("cmd_sendLater");
+ await closePromise;
+
+ // Give encryption/signing time to finish.
+ return new Promise(resolve => setTimeout(resolve));
+}
diff --git a/comm/mail/test/browser/openpgp/data/eml/alice-broken-exchange.eml b/comm/mail/test/browser/openpgp/data/eml/alice-broken-exchange.eml
new file mode 100644
index 0000000000..52c38a2668
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/alice-broken-exchange.eml
@@ -0,0 +1,64 @@
+From: "Alice Lovelace" <alice@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: broken exchange test message
+Date: Wed, 28 Oct 2020 01:23:45 +0000
+Content-Type: multipart/mixed;
+ boundary="_003_38fbdabc0a957344544c1642f2e764cd1234567890_"
+MIME-Version: 1.0
+
+--_003_38fbdabc0a957344544c1642f2e764cd1234567890_
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: quoted-printable
+
+
+--_003_38fbdabc0a957344544c1642f2e764cd1234567890_
+Content-Type: application/pgp-encrypted; name="PGPMIME version identification"
+Content-Description: PGP/MIME version identification
+Content-Disposition: attachment; filename="PGPMIME version identification";
+ size=12; creation-date="Wed, 28 Oct 2020 01:23:45 GMT";
+ modification-date="Wed, 28 Oct 2020 01:23:45 GMT"
+Content-ID: <ECC4CBEC65D5CD42A8BF99DC0A800A0F@namprd00.prod.outlook.com>
+Content-Transfer-Encoding: base64
+
+VmVyc2lvbjogMQ0K
+
+--_003_38fbdabc0a957344544c1642f2e764cd1234567890_
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message.asc
+Content-Disposition: attachment; filename="encrypted.asc";
+ creation-date="Wed, 20 May 2020 19:11:08 GMT";
+ modification-date="Wed, 20 May 2020 19:11:08 GMT"
+Content-ID: <3A449618AD0B6A43AE8979E0E971B0FF@namprd00.prod.outlook.com>
+Content-Transfer-Encoding: base64
+
+LS0tLS1CRUdJTiBQR1AgTUVTU0FHRS0tLS0tCgpoRjREUjJiMnVkWHlIcllTQVFk
+QS9ZM0VaZlp4SVM1WlRPcmx3NkVnTkFMKzhENnV2cGVpTWdSRUxzSDFQemd3Ck1N
+d3ZxMnFUT01DaGJUd0RQSGY0WDVETGpZbXowWnN5TWlJY3BzNXo4ZU5XQ0Jyd01k
+ODBtWGlKem8vM0ZCaGkKMHVrQmVaSkFTOUlvTE5DbnRZNUpqWWljbTZZSnBHSGVH
+azA3VUN5dENYUTFEZmF5OWk1cHc5Nk54UTZicE5PSgpFamttRmlEYk1JaExnM25C
+TjVRN2ZzZ01WbFBxMTNTbmJaMmVVZXdRVHVESG10dlByUzFHRmFROExBclFRNVdU
+CjJyampkUmZ1Q040MmNXeUszSHNUNVNMZDRkOTVjZm1CeTIrRnkwQ09tZU5obmRq
+aGxPQWlEL2dOUWE4TVAzYW4KWlQ0OVlmbDdYdHp2Yjh0V0dSL1IzSDFBMU9wYnEz
+d3BrQm1hNlpXMzBvcG9rRlNkYkRqK3pTNlhmNWVmTkVLWApJZFpVQTg1U1hYRjAw
+RWFSZU9tdFQ1WTJLZWMyS1dlUXYybmlVMGRNN0FMNjFGQmw3VXVvaUJDc1IwaGRa
+OCtTClR1UElQa0daWnlSdk1nU3ZTMjduV0lNSUZvdThzeVlTdTg0MElzeHRWSThk
+SEFqM0RvbUNXa20rMnJXaDZ3cWkKM29xZDhOK1QvdGZ3ZFdoMUJnNVpRTm40SFF6
+WXhuYVlqZk1mNFVYMS9RbjFVUmtISnErMXkwYm1reDI4c1RXTwpXVS9mZndQZjBB
+WGVyNG9LNEdtMDNtanFQbkd2UFFYa2xSb2NDQ0prMG9sdmZ6M0dQRDZSNGNiNkc4
+SlFvTyt5CmNMRmhMdytCZFdqd0ZCWU1POTl5TFhJM3lEbDFGdWd5R2JvU3dNNFRT
+aHA3T1ZwYUlXSE1oa2VhVFJIQlVQUnMKNXNqUzR5QVI0OVppTDNMU1lHR2FJbmQr
+QUlzR2I0NTB0enZENWJPT1dUM2JBK0pRWXZ4dzRxa240WnI0OVdCYgptZE9nWmhD
+eUxET3BsVUo1VFJtY0ZkdFdrRFI2MUsyN1VPeno4RVcxcExYazZjQ1RFcU5ZanlT
+bDJCeE53R283ClpGV3IwTW1qbVJydnQvSmNvcm56cjl3WW9HdXJ3bERnc2cxNmpW
+aFZpY09qTUg2ellqcXN0amJRejREeDJqU2wKbGdabjdPUXY5dTR1SGZ0SXNaQk50
+RnFjUlJaSFZHVWpXMjNQK0xUWGNkQzhDSWFaZzMvNUN2blZyTU9jcGd0MwpWdmdB
+TDloeUJRNHV6UWVacVlpRCtHRmhDQmp6QUxtMGNRSzJQZnE3N1AwTjJBS1k2NUNP
+SWVnbjFubXJvNTF4CldIS09jKy9FdURmNU5VY0lieW5sQ0NycUJXenAzZWt4bE5D
+eFZNTkdJeThObEhyS1oxb0RQSlNMZi9vc0Jyb2EKTExCd0V1bllqRlY1czE1NVkv
+TFUybnQzUUY5NzBxTGdHMEVTWm1mV2ZTcEZzaEhuSVZBMXdXV3lMM3o3Ukd2YwpJ
+ZVFnYk9LSklDWlNSTDVlNDBpSHBMcEpERWlyUGpTeEQrYUpuazd1VVZGUDk4aTJu
+Z3FNVjNadUtlM0paV0VpCnc3ZDJvbmEwditscWVUUUp5OHcydUIvaGNpTFBOR0tN
+QUpBeEpSeUFFUDRTaWpXWmNDQjMKPUR2RkwKLS0tLS1FTkQgUEdQIE1FU1NBR0Ut
+LS0tLQo=
+
+--_003_38fbdabc0a957344544c1642f2e764cd1234567890_--
diff --git a/comm/mail/test/browser/openpgp/data/eml/alice-utf.eml b/comm/mail/test/browser/openpgp/data/eml/alice-utf.eml
new file mode 100644
index 0000000000..a468526614
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/alice-utf.eml
@@ -0,0 +1,23 @@
+From: "Alice Lovelace" <alice@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: partially encrypted test message
+Date: Thu, 30 Nov 2020 14:40:34 +0100
+Content-Type: text/plain; charset=UTF-8
+MIME-Version: 1.0
+
+-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA256
+
+1.00 Month, Services
+Home::1 £10.00
+Home::1 £35.00
+Home::1 £10.00
+Home::1 £0.00
+Domain: £1.20
+-----BEGIN PGP SIGNATURE-----
+
+iIwEARYIADQWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCX8T2UhYcYWxpY2VAb3Bl
+bnBncC5leGFtcGxlAAoJEPIxVQxPR+OOIFQA/jiCOQN1362aOgywAM/Rm7HGEIN2
+UgxLkUGnoNZEBXOoAQD6EYFBkP5AOHqQZ5a2AEwufxbC4mrJ2UVeyarD8KHKAw==
+=Pxz5
+-----END PGP SIGNATURE-----
diff --git a/comm/mail/test/browser/openpgp/data/eml/bob-enc-html-nbsp.eml b/comm/mail/test/browser/openpgp/data/eml/bob-enc-html-nbsp.eml
new file mode 100644
index 0000000000..7cde190f5f
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/bob-enc-html-nbsp.eml
@@ -0,0 +1,38 @@
+From: "Bob" <bob@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: Encrypted for Alice, html with nbsp separator line, not blank
+Date: Fri, 4 Dec 2020 00:35:52 +0000
+Content-Type: multipart/mixed;
+ boundary="_004_33f9d332df5c463d984f9ef386761a91VSMBX02_"
+MIME-Version: 1.0
+
+--_004_33f9d332df5c463d984f9ef386761a91VSMBX02_
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+<head>
+<style><!-- .EmailQuote { margin-left: 1pt; padding-left: 4pt; border-left:=
+ #800000 2px solid; } --></style>
+</head>
+<body>
+<font face=3D"Arial" size=3D"2"><span style=3D"font-size:10pt;">
+<div>-----BEGIN PGP MESSAGE-----</div><br>
+<div>&nbsp;</div><br>
+<div>hE4DR2b2udXyHrYSAQdAINgYcnZM2bAYVKuB30JR5bPNGYtFFj5EEZuHzU7B1TAg</=
+div><br>
+<div>Q0qb&#43;biPpakzFZH5xXDLJVrZFo4H76bR0ds7UROgqjDSVAFVyLyVzbXQGBf8krPa</=
+div><br>
+<div>WuIrCJRc&#43;/GBk0CHwc3cV47F8kdbeH/7uHsTbanaz3yn5gtVXBhoR5iOanSaHfCE</=
+div><br>
+<div>vmaIc6oiurKdS9PuTnqbD91uW8PKdw=3D=3D</=
+div><br>
+<div>=3DVp7p</div><br>
+<div>-----END PGP MESSAGE-----</div><br>
+<div>&nbsp;</div>
+<div>&nbsp;</div>
+</span></font>
+</body>
+</html>
+
+--_004_33f9d332df5c463d984f9ef386761a91VSMBX02_--
diff --git a/comm/mail/test/browser/openpgp/data/eml/bob-enc-inline-nbsp-qp.eml b/comm/mail/test/browser/openpgp/data/eml/bob-enc-inline-nbsp-qp.eml
new file mode 100644
index 0000000000..da39c6e1a1
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/bob-enc-inline-nbsp-qp.eml
@@ -0,0 +1,17 @@
+From: "Bob" <bob@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: Encrypted for Alice, inline with nbsp in separator line
+Date: Thu, 30 Oct 2020 02:34:57 +0000
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: quoted-printable
+Content-Language: en-US
+
+-----BEGIN PGP MESSAGE-----
+=C2=A0
+hE4DR2b2udXyHrYSAQdAINgYcnZM2bAYVKuB30JR5bPNGYtFFj5EEZuHzU7B1TAg
+Q0qb+biPpakzFZH5xXDLJVrZFo4H76bR0ds7UROgqjDSVAFVyLyVzbXQGBf8krPa
+WuIrCJRc+/GBk0CHwc3cV47F8kdbeH/7uHsTbanaz3yn5gtVXBhoR5iOanSaHfCE
+vmaIc6oiurKdS9PuTnqbD91uW8PKdw=3D=3D
+=3DVp7p
+-----END PGP MESSAGE-----
diff --git a/comm/mail/test/browser/openpgp/data/eml/bob-to-alice-signed-damaged-signature.eml b/comm/mail/test/browser/openpgp/data/eml/bob-to-alice-signed-damaged-signature.eml
new file mode 100644
index 0000000000..8cafc92e0a
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/bob-to-alice-signed-damaged-signature.eml
@@ -0,0 +1,55 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Subject: Signed Message Damaged Signature
+Message-ID: <848aa9eb-7cd0-8673-9a07-af43d04c0ee8@openpgp.example>
+Date: Mon, 2 Nov 2020 17:35:44 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:84.0) Gecko/20100101
+ Thunderbird/84.0a1
+MIME-Version: 1.0
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="50nOnYT10Ofoj91bgJF5Ao17vVWnb3oq6"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--50nOnYT10Ofoj91bgJF5Ao17vVWnb3oq6
+Content-Type: multipart/mixed; boundary="N2qZSO8OxbEQYeawxVekJvYNUvD0dwxtb";
+ protected-headers="v1"
+From: Bob Babbage <bob@openpgp.example>
+To: alice@openpgp.example
+Message-ID: <848aa9eb-7cd0-8673-9a07-af43d04c0ee8@openpgp.example>
+Subject: Signed Message
+
+--N2qZSO8OxbEQYeawxVekJvYNUvD0dwxtb
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+Content-Language: en-US
+
+Sundays are nothing without callaloo.
+
+
+
+--N2qZSO8OxbEQYeawxVekJvYNUvD0dwxtb--
+
+--50nOnYT10Ofoj91bgJF5Ao17vVWnb3oq6
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsD5BAABCAAjFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAl+ge7AFAwAAAAAACgkQ+/zIKgFeczAw
+dwv/ZTuLMSj073z92I2WStlFzmrCc1ux3FQ107mTFR4+l2zTlE2nxWKEp1clmn+MqZwTLCQquOlX
+YEeYCyXa2z/bSFxm7++mhB2uf5/ahnGs8ZMN5SD+xWoV3WXqOgDiPjz2BNWBrd9VN5Azr8akSEwG
+EJK+i9wMRXTvCtrc6MnuXBVTt5rN35s/HpznpVgtU8+zRQ7MSd6BTD0PkIqrhgl7qV2XTtmlGEM4
+JwHa56QrIg82ebudv/tIm4NlLCj6bvLKBiJ2Q+TvZaPwVuCAYpurFlzJJfKLiNzBi/G+90XNG276
+69C6cm5VlSQe6/gqfB9bHpfkzItRdLYW8rHYTuN6YuctTvPr5hoTmLUYq1YIL9MJHhUmxNnfgq/E
+XLYE0d0fzfopUHzPkmbMdJ3DU14HD4JcyS2623iwRooecxzydSZUPm497t6a/kbBae2p1nk2QZ7I
+muRSPlbVHSi3HClk3aqiDkyTXAkvWbX8UqbY7/fBL24YJzf2k/8yuzzAo+Gl
+=PLaI
+-----END PGP SIGNATURE-----
+
+--50nOnYT10Ofoj91bgJF5Ao17vVWnb3oq6--
+
diff --git a/comm/mail/test/browser/openpgp/data/eml/enc-to-carol@pgp.icu-revoked.eml b/comm/mail/test/browser/openpgp/data/eml/enc-to-carol@pgp.icu-revoked.eml
new file mode 100644
index 0000000000..3f05f75d1d
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/enc-to-carol@pgp.icu-revoked.eml
@@ -0,0 +1,73 @@
+Message-ID: <31ae4f7b-7e56-3813-3348-70cbc0709b82@pgp.icu>
+Date: Thu, 15 Jun 2023 20:46:43 +0200
+MIME-Version: 1.0
+From: Jessie J <jessie@pgp.icu>
+To: carol@pgp.icu
+Content-Language: en-US
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="------------n4vy4UcZCgGrvyso1j5oTOFL"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--------------n4vy4UcZCgGrvyso1j5oTOFL
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--------------n4vy4UcZCgGrvyso1j5oTOFL
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcDMA86TSwrk7IP6AQwAzTFINFqMQ6jvdswuR0T564wuRtCv9D6nnYL2mDUWp32nFkGAZoV9+8Y2
+gJT5onvLoCX7YOwXtuzJzTtpGLM+p7FpelcV1zA80z/eLMRmMc8mpkfgFP+KHpwwlXi+DnDsliKQ
++DBLT880fHRldOSpkWuqeqgf8LLgi6Fj6Ofp6DbySfwSQJEjEOvnjyyoie/xtBM8AiNNB31Oq1mp
+RIkYpQJuATegaHUU3rennKiEoWDAiwXHVLaLSFbaC86LMoRyOjCAk3a5mjCCTp9fOJl/ovWKCE15
+Z+ppGMuqjkWz5XM9lQ+OUmKI0ezFE4oOQLXB3MzKQHdv/WScGTmk5qvhnwFdpm4EUAEkQT97tRXr
++zIFooD6HBHrztLFpyrvfVDKuGoWcpwJw5MyEHocweCBCFMSV+6BQl+EBD9cHm0oqnQquwiN+JnC
+gCkfCFJiDZVPr701nuSJDgC3IVX96cLwQPaEBlpXQGS07uZKv6hFIHoG5xrp1+WtEVitqFzpk3b7
+wcDMA3eBrGUxxyz5AQwAkoeAiGRc6+nBbVjcqJIJIxyBGUh55TzM//3R2S4mTOWVdVXo8biV4CHi
+OAFde+87ud6ysZ0mjpGTJqPBXcHpf5U3u/yZgWXmj0GhXVMukcqRF6GR9yQqRUKZdTUs+D0jzFek
+kzYFlh9eBoYDd/0QxanypaLaMK85kdOLtbn2fq119leTt3kqa7ZHPOB0FktQW4tnw4oT8XFcDmnu
+qhgGB3s5SRdoH6KvCVwb/ZXkHwp3F7a3o4UnvOzpjysYdM+lolIuv1ZiM1H4HsIzqt8n0ubqPIRJ
+IRpOgNchua5Hzvh8APQyeaxN14B3izyFFqAhC9lGlQTfUHC84UMZqkcgWLqydEdUlZ9TlV8sY68t
+qkA8fxT4Fc0nnOVrGdK3ToZCtulNYGsaDZ9yZxhcbSsJHjHIyRHE0rbf1moRpcINHFJCWPQAOat9
+OQdOpTxDQOAZRvmICsdFJSPB+2oSJkKMjt+MGfRFotKFCI02EtV4D7yS5B+Oq51Py1c5wwxXRlqr
+0sXpAZR/aQoVnbRHkoqP2VoREHxxHR0rOaeNIr9sCkGq3IVsrd3G4Cv8khmSZRAPYMtHrgo1GpAi
+pDnzcSSOkkHXItzs4AoyWYJvod7tmVJGWTxIu3ZhP7z235+n4fbD5WkUSXybqNU8D5eqNdiglEJZ
+KpJ7EGAEh686LUwCv4XmTddeBWVSD8yvLW/ySTlGsxw9wUn8zKWbMcp3o/K/yCGlW3d6OJYwn/Ev
+jGcIfhaMiuSTBLSvfwbxP5CDMJT59j/I2/SFzQpNNDn9BXG56U7h2LISgn5w59vheif5LTTdTUap
+QbI+GcEo9NNnf1WknjlgFSCBc9nqO+XzHrDkaGG7kGyM9RJ1lQw03KdLdUGPXF4Sgbd75qu9RQb3
+bUzxud4lnnC8Cp4YyECeXbV+XF/Aylq6EtaFyrNqgmcTjsMkT4SHGj4ZtOjEBNY8y6RS/nH4LYgd
+MDmtzL/XdR1UpBMYzzRMCS1HfYXf6hbBRQDkYIeD22OifU6vBMeVqUrFfPmOw7a4QLnnnKX7CGWJ
+nrrgqHdDEmpFFaKYLyRsoU7wTFbLFJAGIVZttLQ7ETkX2QPKT3gpoEi9tm9/cHqbjeIAqVp4em3v
+I6JzuPFmTKq4vo3aAQJceaZFGIZCIjGUZ3mOlaXhbRJp2rU132Uw6VrmdsRkGxFl7ZR0bxkGgkea
++9qnZ7geWk4HmKfMoQNFcLq8hMzjDrP6XJ14L9c3VygDY3SesMJnsaRAkVk9pOCxvC6jSGzgJ3pG
+kdgijbFRIQhHig+3Lp3vMShd4Bu+sucgPpDZ5t459S5b0wdOyqGkiu5KDhTYGyr3NqNKDlp4K8Kf
+lV4A9flTnpSIB7hC9+enhUaRt+Z0nBnSOgoJHy2qiSZz/ajdQcmBV+dNDT68FvuD0ZPXDZc7Yl7F
+/IE1kuvVgl3Qkae/AmvgEvApo385XYrTY5jtNhXphzY/WiIH2V5jNKiREFMz2GbHjQqsbW8aPpjS
+05Z+PR7ziE7v9WrJUK68qXwHS0z60e/YwUc3eYpd6CFirLnK3ZLfrsXIus+fQlAVej6XrOWkscl8
+7abWGn76aW1Dr/7DpHRAqIbw2TIVe2uISaWcBfjACED/sy25Xo2Rslh8oBjo05qUWo9p3LTZ+MMx
+H+GfZhB8YgINi/JHDSvIpSidCQjlYIQS0ljhyM83yqjTlyK2uOK8ioCf/3lXngvMGU8UGnF5Vqfi
+kZx6dp1jTB1k5VvCEFfPYEItu5GiPwM+S67vspzUYwXDU6wdqcgVYTqYFg3TzVRFS4KnGimSmLvE
+bKMTihQmTtCMAlS0i5IM/ZTcKZ3EViBEVqNJqAJKaMnaxNuqNJPU51p8+HsW9CGGAciAK+wLZ9EO
+PUbJJsfnamGlj4GjQDxkdKex0S1++unXgMmf5iKFuSrWgOFWfxTGjGX3uod0A00sz6CvarcIskSh
+Xnxp2t3nzIt+lO1Apf2Yp2iOcuAndPr8GtF7nzit/ptVYSlEMis+apaj0kY7sZbxai6rl5dlUWgZ
+ZtuFSd8YlzdAvfZNw24TJVK+wb9E2DoVeXenKjnLeHadSEqqsyKugsWbExjpxtAJW2R8VNmK8vOg
+02KwD2Hrjbdx2snKk+vmZ+TOPko+7QBFdLAbRDuekKICIfkJL4TvCoqHf8sTmItoQD1WxH34W08/
+xLdcgnOGSFpWB39BCC8l8uaZ1hrnIeVFTV38bXdWX8he+knL0MfPO5/ZyauwwwKpbvis2PemXTa1
+Z5Ebqd11ie1CWDKfGDu9MSb7myTwd4Qavx5SW5+gzLbyBaFEj7dSRZdKI+wu/Gj1Qg/1DY0QmYIf
+6BNESN5/6p15Wm3F+ZIlxXsCNYHQT5nHSnsRbuihAguZIa3cs9HcfcyTprYjfEfrHa0RwW2vSwWk
+gEITLS2X60lKCypdA8dfIlUrpGHBB9NjT+gymwktyKWECv7zhkxb7gygfkr5xjdIyvy73p7we09Y
+Y62AO4FO7b/iG4kkKSsy8m0r6yIP/YD4ffFSpsfb/DHHhE+e14nh3Y4HO2pm/3jmFwRFp5Z5LmJi
+lihgf1c8pH8wlcbV0MmBmR61w04/g2m1ce9pcJMNG9rCM5Fi6/XclcZ/HdFflxzim+TXtGPxPoGh
+HJOZ47Uw5tFLOx8o5Con3Kfj7aZKVoMrScCLYI7B7OhWbwi1JF8kNfpXThpWRlynwWYkAk33on1i
+K9V7rt1C05L27G4MZWLshquZqdSYtNp7nUbS3dM9sirI/+ljtQhqSyCTcCgH5JZcAlYGspQraw==
+=Hwus
+-----END PGP MESSAGE-----
+
+--------------n4vy4UcZCgGrvyso1j5oTOFL--
diff --git a/comm/mail/test/browser/openpgp/data/eml/encrypted-and-signed-alice-to-bob-nonascii.eml b/comm/mail/test/browser/openpgp/data/eml/encrypted-and-signed-alice-to-bob-nonascii.eml
new file mode 100644
index 0000000000..52ab4c9b27
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/encrypted-and-signed-alice-to-bob-nonascii.eml
@@ -0,0 +1,94 @@
+From - Thu, 06 Apr 2023 05:20:15 GMT
+X-Mozilla-Status: 0800
+X-Mozilla-Status2: 00000000
+Message-ID: <20805252-5f4c-ec09-76c7-f775cca2414c@openpgp.example>
+Date: Thu, 6 Apr 2023 15:20:15 +1000
+MIME-Version: 1.0
+User-Agent: Thunderbird Daily
+Content-Language: en-US
+To: bob@openpgp.example
+From: Alice <alice@openpgp.example>
+Autocrypt: addr=alice@openpgp.example; keydata=
+ xjMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/Ub7O1u13N
+ JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+wpAEExYIADgCGwMFCwkI
+ BwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXaWfOgAKCRDy
+ MVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnOdypvbm+QtXZqth9rvwD9HcDC0tC+PHAs
+ O7OTh1S1TC9RiJsvawAfCPaQZoed8gLOOARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzg
+ qbXCpDDYMiKRVitCsy203x3sE9+eviIDAQgHwngEGBYIACAWIQTrhbtfozp14V6UTmPyMVUM
+ T0fjjgUCXEcE6QIbDAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW
+ 4xN80fsn0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE=
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="------------zN0m7YjOAg029Y0ZI4XrGe77"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--------------zN0m7YjOAg029Y0ZI4XrGe77
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--------------zN0m7YjOAg029Y0ZI4XrGe77
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wV4DR2b2udXyHrYSAQdAegvR3rjk4VMmnQ5TqZL1I7m8Ri8zqY/h4bbR8vrm+w0wg7DcFP1UChG2
+1j13kQ58S+zeKWL9QFSOP7bD9BTT4zsuyau4EOwittGoWFmqpka3wcDMA3wvqk35PDeyAQv/SH0u
+KooZCLi6+fLVAez/M84sJj+qiD4PDwHCTvUixuXjm815N5KWpuctIl8i3NhVpoPqS8IWBhKHvdwx
+IOuxDlFQ5rnKgzRD68hYhPAi6SKnTH7fzzdZff++ruK5SUralEhcfxxOPSLh6k1niLTv6WqcZY55
+LwzmK7kkJncaGpOMUzL0jmoCBI0B8BrNL54MiXdj34xVRzS9Feoe2AGs7ejBy3C88l5AGJ6PqU/h
+EhEVg261NxQ82vVlctBo74ZsOZvCsaPErIRYYLY4EmyQfUgbUe+7FZGun+bLR7vgC4XA4v0ZuTRn
+P6W7ACaQaDCFC11tqaLeBGuP2rTtt/C1lNEu7qBJPBiTzsDhkxoNVSEf48bZM5RmNHOTxIchuT98
+WFOWTNLR9u1X0szqFRWFPsV2F4g2SoY6FOYnpytbSufoXMEKMEMZ1OyRyn1UlVrCAunTZsTlbYyK
+zclr9BT9vb+JoLWelYGmbKAUoU7F3JjyVVCXimaHVko5G/y0W3sE0silAQOP3eGJG2jjqhFX9SWr
+5pqICXcDAKOnR+2BLWnW+Egs8nfoL+yPSCQEdBS0GaADy/CfqK1r4IRFgbQBxGS2DEtgJ3D6t3MR
+SqT5huy50XI/lWR2FdM08fWnFxM/cYwlEHW9yS2+/FabhQl6DjjFnAlKOh82Cx4HcF2RM9BMvaiF
+vVrjc8MR0IjRUXAU8wmlzbZRyb/F4eL4OlUqB192bxHKPKMCCMhvQ7zYU2nBguyJxt9bO0lpp4/n
+Q0isq35H3Hcx+nVs1YjdbGSmQd6hTu/o206EmeIews+TjDAfOhXAjD6Wz59WT8WgKdZGMJ5rxM8s
+9EgSDj8PO0tQPhLUAsL7jZBShsS8VoJrWC9LeE3FJX44E4ZNN3u61OkBzyq2NgKCpXByec/dPcku
+D3cQKbF964/s2+C0WQwGRJC00/UBhuwSieLP1NCY+WTP83xss3wBD4dNW5I1rIfzed+Yg7BPOWPG
+4ghFEz+ZXJQ5+HGHVcjXc1uUyKs82886k48LJxi3Zx3nbj5HnUrcqrSYwQR2Acq7an5KN6qZd8VC
+qLyZMVtczNtoZR4hMzbVsWsa/y4LDmudUdnSLNmuQF8HPIgYvrk8eDv0dXx862T8wfjJA+aMZUn4
+xhruJifC0a4+tPCYlceoPAuifpX4hhojITYqlRhv85srfsl8aZYgO4LXtgG3VIJKTsewDK2O5ogy
+HEm+ui0G2rfFZQKfkjE4PeHpDjqaZf7nvgHXlV6MG/VkEnpTDLDyhPfA2pYMmONXA3Wv0cKKxBO3
+W2j1X4+HqII1t+MSCDnbUO08AEdFZfNrbM/2MmhIJQSglpFB+te0TXLjNl/Ds1SG+gWHyd0iidjw
+uPeY+ZYyEcAt2N06zT2IitWleGa5tMKSxL8wLR4NwBnP7X19NZ/QrJ8RkfgbQ/FmA7m3j7Nka0PJ
+v8aJqx8QiaJIe8u0O3DHcobQQE52z8RRakLdQmWKD2a9PslyTD/d/gGc0V/eeHHmzqhl3iEc0G9f
+5YwDvlkO4HcK8GQjsym7Lx25p7YbKjVt2+zZG5bvatoQB54D2THMzEdl9B200VgZP0znDJr1F75A
+mYtiR5XJm/zHphbtlxJyGlJyl0I2bcPmHTKt5dlphoNqtmgLu6/QumUfaJJ6jvz0goCXBNGLjb7d
+VJU0wCUyw0U/UF4whtcqoWa4Hue/6rs0IzZb1vsXZ9z3IEBdm6k0rGyMtiJ1FOjNf3VdQosPc9xO
+m/yXLzbKzA65y6OvRZrV6kt/byzBWDKn073ah1kezzKqm7LN8q/9X1p6Ty6VVQGRAwQ1kDimNCzM
+3q5yKy75v1nmKv7r9VONwd8pWAVjyIiMj3FQhF/b3qOh1lmyjTxs3vfBzlgELmjjSNN0m56wDbK0
+gYe5EYzpFdrewdRVfU/bTYBNLJ7HknjQYGSjoxaSsbpl0Ehiz+c/5oGPesAfD2ztCviZD4kqWGBO
+/MIc8JJ0IuVEZ1jHbgeuVgAfDfC5h/cXi1wYWBDWgt84F8fO37xHOL7JBG9VgahYS/f3oR6C34yb
+nCCx+q/trTuhYyA5cJLO+8F+Sdg5meEZJ2sW6DhueGV0WOlGqEuLHLU7oygXjMjjVdh92a6lh193
+XCw1OSeTIdOM+OEdw4fSbwPKEg3o8Y5ExPDT7FetRXTprI5LYwm5fjc2rj7g90F61+TQSuwQHrgA
+4QGwSRaJXaJyq89UcdiaLhlz5wL78tpOyEQCjte8j2gc2E0cRBDq2vDOyAh+hbIgrhsDf5bcQnNB
+8TucakRkikggNvTNdf7gzcrO1G7da8/WlENVh+xl2YJcBCH0H4e+cCyCnwlLuzWmvJmIjbS9mgQF
+a0uNfxncDjsxFK9VH4hjTdWcLTZgc4FKRkjTPFCAYSN+a6knyhH6PZZjmDm84HylqrXf+6mafUbY
+Qk2EQPLzO9eQJZy5+VZXffjmQ4glNYY2qie08ck/ocsegwq6kIFug1RfA5cEVKg9EfaC76D5bPLk
+TLs+Tb+kzUqyJ3rSlTFhV5GxuwpqxZbQTSblK5Nf3D6ENhD0m1ydOXFRqgpsYWhsgItjJomdkW+K
+iU9Q6gYkoi/2+0zhFz9I+P7fCc63ezgE7pi8lfY3K54+I9oiVMoSCMoczj8WmPni+lWEEvZ2e117
+58ABa/j/m+gbP2wgymFDvUYJSzSOElI4G7r7Ayu2R5PtfRey5CTh3CCl4VODQ4vXDdE/K55dVmde
++yb4NoVscPjua19aBlBh/fknYaQVVmekV6FSwjGdkgSmuBYB4sSghIqu1+bnQGT7TstszZmPrqMK
+7BOJJATCWKK2+PDQvsFdd+J/VpW5xXc0NlRsfHaMl/Q2KVDmNWI1oI/wj+e7LZtxKnePD0VK0GrN
+S15jsXLEbpMB5AGvk7FmuoIMBcMthfbOmL5WCwgV0fm0IrvzUtTRLFnPpaOSx9QIudGPakXknmWo
+WbnYaJK13DeqstcZzBmLsgaUSRTvifBV+lundsJxA7UiBdy3n9sltXDucyiT2MkeIDgVY7JpeTBy
+AvvWJc6MwWWvRMUEl338qlLcTaDbx452x5bKMO9kjn6qpSyv1WldwKi7yFn+20j8JK0bRKJUkLIL
+3jrmiF8dDVz+PxCad0peJW+cSYDLTehnOmnJNf+iDv/TCaXFjZOBTA52wi4ftzzcF7CHxMO83meV
+CWwRVKoDdLyWC7b8IwkK/2vYMKWF83MEiiy3/i1RgAP6iO44sf6QIngCogGMPPe9C2CahUN+6HAO
+Ml3C9RMPNq6jQMbfCCN+4VItHXvsxs9PkvU8rdRmMw/A0kOGlFNN5XQ/pNNILoZQ79uEr0DaD7NK
+UpkYzLBtRmWhUVegzlSXTBpa5FowcxdEBVXUIshUhBjOk+UH+xww+wXJ5FM4Dr5QxaTVV+6PKymq
+a+PKmL8tyuLYPGyc+6OZed0oc3r7Rb3By65HwI4IU17eWkiXIFXtLpjU3l11RCxNo0XVxgaRbEli
+41BCrXsJvBg775TkRWscrCqBhJkmgS2zVtL3g45sAdhpuqZUXFCyIK9+x0SHifUpMO7mP0n2f613
+tXB+ZdLXH381CnPpMwWj9sbhREj6GOIlsSnlh92Rnwqqp4k+8by+Zk6kwMxUOF+wQ4SGhC33GSNy
+QVUXGwGOUdIOKHwK7eaJMqVp/toyWMIxwDSRqarivGyL8X2DII8S4Qk/WWnMs6dqqqLmdfk=
+=PWTp
+-----END PGP MESSAGE-----
+
+--------------zN0m7YjOAg029Y0ZI4XrGe77--
+
diff --git a/comm/mail/test/browser/openpgp/data/eml/eve-duplicate.eml b/comm/mail/test/browser/openpgp/data/eml/eve-duplicate.eml
new file mode 100644
index 0000000000..2a582937a5
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/eve-duplicate.eml
@@ -0,0 +1,122 @@
+Content-Type: multipart/mixed; boundary="------------CELMaEqsZnUdIj6gwK6SX0gy"
+Message-ID: <cf596e2e-5ad8-28d2-e230-f9a377101ae1@pgp.icu>
+Date: Wed, 9 Mar 2022 15:24:01 +0100
+MIME-Version: 1.0
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:99.0) Gecko/20100101
+ Thunderbird/99.0a1
+Content-Language: en-US
+To: Bob <bob@pgp.icu>
+Cc: Eve <eve@pgp.icu>
+From: Alice <alice@pgp.icu>
+Subject: duplicate keys for Eve
+
+This is a multi-part message in MIME format.
+--------------CELMaEqsZnUdIj6gwK6SX0gy
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+
+
+--------------CELMaEqsZnUdIj6gwK6SX0gy
+Content-Type: text/plain; charset=UTF-8; name="eve1.asc"
+Content-Disposition: attachment; filename="eve1.asc"
+Content-Transfer-Encoding: base64
+
+LS0tLS1CRUdJTiBQR1AgUFVCTElDIEtFWSBCTE9DSy0tLS0tCgptUUdOQkdJb3Vwd0JEQUQw
+ME9DTTE3RnV5ekRaRittdWphMEhZMDJ0Tm16L0QveE0xSHNTM2tCQ3VvVzVzOUxqClZGdjJ4
+eU1JMElTUHcvenFyT3oxaU9veGtKdU4zUVFhT2FhbHd1aHRRTmowWVViT1dTWjNQMStydEhj
+MSs0TkQKZWkvTTQwZWNSZTZUcGJOaFh0dkhHTFU2akVWd3JjTXdkci8wRVp2WGYxZTNNMksr
+dG93Y2FXV2hZTUExUStXQQpuOU9lVmJqaHYvaFd3N21NNFRzQzZPaVFIL29UNW5JU3gvOW1k
+YnBGUEZoNTFlem5vVHdwaysrWlpybGt1Q2dsCnpBRUZLSGxqbFFTWWZyTGpIdlh3djRwaTBV
+bHR6OERNUWc5bTlZQTZ3NWRCS0pyZElHZkNpTEVFMjRLbndlb24KcHJCb09UTmhCK084bFRU
+d3BtSXlsdlBBZlcrNGtTU3RWTHZsY1VmSUhVQ1VNMTl2M2RxUFVVQ20rR3BiaVFXNQo2SHNq
+Q3FIRXkzRGFqQWtVOHJZdUY1WEVZVExlSWxVaWdCZUh1SU1vL3FQZ29kWHh2azJUZHo1b0ZK
+cHFwc0VSClBjRUlXSTlpWW14TVI0YzZTd2pvUlVHK0xQL1M3Kzk0bVNPdkVVdDlQVTFwUHpi
+TVU4blpRbUZrczRiazVPUTIKY2kxNkxGd3ZDZXN0Q3pVQUVRRUFBYlFYUlhabElERWdQR1Yy
+WlVCbGVHRnRjR3hsTG1OdmJUNkpBZFFFRXdFSwpBRDRXSVFTdmd4czJ1QUp5RFp4NGRLRmhh
+RFBKOEVha09nVUNZaWk2bkFJYkF3VUpBOEpuQUFVTENRZ0hBZ1lWCkNna0lDd0lFRmdJREFR
+SWVBUUlYZ0FBS0NSQmhhRFBKOEVha090b0tEQURnaWFqZ0xXZU9JYTduSmJYRkh6LzgKWlNS
+LzNLMlA3MlJKWDFiWnliMzIyRExidzRUejJtQnlVZlNyVXNSRGNFSjloanY4ZXZmRXpEU2pS
+MVk1MitsegpEWjlTNXVyNDdmVGlWTVM5emErYjh4K3EzazB4UnNleG5xS3RtUVNBbWJTS2Rq
+dHNKK1RHMXRGLytoUkthbGo1CkhPeW9VMjQ3WVlhL1pHWnc2MjZ3THhvMlljYmNUYVhzMjg2
+V0Vrc2xtbWN2TURQclNiem53WmRRU2dtQ1lsazAKc0NUOEcyS1Z4QkF3MDZ5aEJiTm84eDMr
+d0Vvam9uMGRwUU0wMHJiK1pxV2FrK1ZpTUpPQk54Q0c0cnhKMytXQwpFQjF3T3RvQmZZMVla
+ZXN4Mkh3azNxdzdBYkdQbHQ0a0VKVms1WGV4Y0hqaGxtWkd5bUs3S1JxS2pJUzV2byttCmNz
+WERWbHFOeHJyeDhpR2NRSWg1N2JkMGpqeTBzODZWUVJnRDZST3k4M2hZQ1Z1SGpCU2JkcG03
+V3d1U092bVQKMDdzV3U3NHVJSURTMjFPTGo2TjZadVlnNjlWRUdvaE9hUnI3NWxDTkE2OFpr
+MmM5MHJnUUdpZHl3d25mZEZqdQovenNwdStQOCtmKzJFQ1dVWHFyR09TMmk5azJDMGY1RE4v
+R1lLNU9nUll1NUFZMEVZaWk2bkFFTUFOcGxwRzVkCnFFckxiV0VzbHZWV25Ta3ZaampWbXBa
+Wi9la203TEpDNUdsM1hQUEh6VE1CejNId0dtV1VzWnJqQUdsZnRzamoKaVpMcjhGQ0dkdDJG
+SVVCdm5qMGV4YnJGOUthRDRVY3dtbTNVQ2x3cEMvRFg2SEF3d2lGU0N3dHZadTdnZXlzVgpD
+RDFkbHRGWHUybnNBZEJSeVFzYWtVUnJKZndRRzFqS0NLUjhRT2JwR05zSitTcVFuWURibG5v
+Z1MyeC8waGtEClB0eTd6M2p3QWY3eC94dWhtcEwrS1R2eSs3Y3dGR0RXRVBSM1FOVjA5T3Bp
+NC9hYm9XTG1oQlpqNWJSMnNtbGcKY3pGZ1JhYWZGZlZYQ3FDM2t3R3owYmliaWVaK3ZaeDZP
+YnBQOTRlZHpmc2dJb0gwSjQ5WmplNXBCQU1KekNuNQo1dTJGbWJVTUR2bHB1d0x0OHVubWdy
+bjN2NEc2TmZsTlcrY20xQzRrWC82ZnlLSGx2bExIMDVxS2lTZHFPcTJ0CmMxSFFFUVB2cWg3
+UXpqQlZwZFdyUnB3UmhrVU5iSk5GeFAxRDE3ajBha09WUWpXUmNnVmNHZUd2bFd3YTl0YkIK
+TFpPWXpzaXppaVlXMCt0LzlXU3pvYzhkZ1BveVVHcE0xMDYyNHhIdHc0cmkrS2ZxUWdxK3NM
+MFp1d0FSQVFBQgppUUc4QkJnQkNnQW1GaUVFcjRNYk5yZ0NjZzJjZUhTaFlXZ3p5ZkJHcERv
+RkFtSW91cHdDR3d3RkNRUENad0FBCkNna1FZV2d6eWZCR3BEcTg3UXdBejc3Y1JUVGZoTHJF
+QWIrVEpuUnUzb0Z3eE5YbzJhR3RCRldsWTZWd20xMWgKRmFhamxod1liNlZUWHVFZVRjeGRI
+ZDBQSjJEaHlnYXpRekdOaWdFSE1ONWtjQnNRMkJWZGlKRkFXWmNMMkpBTgpEaEdQYXBkd0kv
+MTFodG8wZ2s1aGZrYmE2OWFBVjdXOGg3WG1ISi93UVhtcWNkaDhlclkrejczWWRoS0kveHdE
+Ck1HcFZINFAxbEp6bUJtQ0ZaYkhQTTd4Qkc5RU5rV2kzRUlXaGUyVzBNOFlMMjBEZUdzTEtw
+cU1PMEpUVnBaWksKWWhFdlk3N0htYkJTTjJUVU1tNGxUcXA3cWpMd1FNUExQWkpmOWlBWUhV
+bXp4MTRsL21xMG05a1dOWXpBb25nSQpJb3ZlUkREWmZ6dktXRDVsd0xpNURIbTJxV3Fic0VF
+SWc4bktrVU5qQXpxblZLRHh4UTBpaG5UMzhGZWhRSWsvCmdjK1A0WGlMcHBPdEJMR1ZmeW1y
+VXRUTGtheXFLVlAzajI0OWEzQmh3VUptQTZ4SE9vRWJtdVA4Wk4xb1BLejUKUHlWWWZUTGZh
+NmNCZXRLNUFKSTdqY2RIQjR3MHZUMzZZWkE2NStRWjBsazBaR2dvMnc5djVQZjNkYzhXTTVX
+LwprRzZNVVhQczdibXhLbm1BTytXRwo9c3J0SAotLS0tLUVORCBQR1AgUFVCTElDIEtFWSBC
+TE9DSy0tLS0tCg==
+--------------CELMaEqsZnUdIj6gwK6SX0gy
+Content-Type: text/plain; charset=UTF-8; name="eve2.asc"
+Content-Disposition: attachment; filename="eve2.asc"
+Content-Transfer-Encoding: base64
+
+LS0tLS1CRUdJTiBQR1AgUFVCTElDIEtFWSBCTE9DSy0tLS0tCgptUUdOQkdJb3VxZ0JEQURE
+b3JVY3JlSzR6QVNXQ1o2TmRVMFU3N3NNemhpNmRNRDJ0WU40SHg3SmxDbzhQY3h0CjMzV1Yv
+NmRlZS9HRFU1d1BLdEpsOVZYNHR4TUhacEpxaE9QbEtMekdXWjZTUG5yYXJaUjluMnVjckVH
+a25SVE8KaE9qemZxZWo0ZGNHMXVxOHhuM2kzRlBja1JrVVRHUE1tQndoc08yRzZWbU1MbkF1
+Wmlpb2VESGgrc3VFaVRJRwovRWR4WThkOTZqQTlDeWYxcldybDVGd2twT01rS0xvVjRQYkhS
+bEpzMkM0U3owR0FKWHE3aHUyN1h5eFB4bkd3ClVHajE5bUhZWmNLbnFYTDNEczJQTDEyT3Y1
+VlZ6Y2p2andWVHpvT0tXMnJWeWlTNFdzN2JBaGQxUVZ6K3FIRjgKMkt3VWhOZGsrNVhhb0dy
+S2xtb1dRRXJTSnhTcWhYM0ZsaWUxcnZpSVNmU0thRFQ4TmdIYnFFdjBobm9rQWIvdgo5bHF0
+eURYdTREcGpSUnYvaFMxeHJJTnVFdWExT3VhRGFmbmhuWkRQNVZCZ0Z5Z3Eycko4ZFN4bmVL
+Z0xJSkpFCnZqMTdiS3drb0VZUGp4RVlZUzFOOFBsTUIvTnZoL1k3ajVkcmErKzE5a3VRWDdq
+cjF5bWc0cS8yQW9BdEVHTTgKdy9UMkk1K0ZLZmxoYVg4QUVRRUFBYlFYUlhabElESWdQR1Yy
+WlVCbGVHRnRjR3hsTG1OdmJUNkpBZFFFRXdFSwpBRDRXSVFTKzZYVlJUc09vbWx4K1pORGZv
+ZUhzZlRLNkt3VUNZaWk2cUFJYkF3VUpBOEpuQUFVTENRZ0hBZ1lWCkNna0lDd0lFRmdJREFR
+SWVBUUlYZ0FBS0NSRGZvZUhzZlRLNkszeFlDLzl2WkFyc3RQUGE2VzNuUElPT2k4aHMKc09l
+cUVPbHhwdXc1TXU0bW85Z3VSNGdGUTd5VTRGeGdHT0YxcmhyY1ZiVE1ZZmJBd3ppZ28vbE5j
+R045cmdZZAoxK3N0dHdiMmRmUXk4aHNKR2tQV010SXVBcDYxVXNOVVpYM04rTXJjeHlqNzBG
+SEpXQlJOYWFDWHRQZkpiUHpCCktnaGxnVFRObWtOQkprS1FLa0lYWVdoQVZsUklUU0tqUWhK
+c3cra2xDZlU3bWdBWGJLeHN4L1MyUk5SWmJ2V2gKa21nQ0xtS3A5ZkZ6Z28wNDI5UThtSDJr
+NkhEWGlFTitrUUtYQnRhWXhVZStZbWJsT2YraXZWMVJmSXZwOXg2YQpsQzNmY1E4bzdpSTZq
+MXQyMnQ5WGU2TkZoV0FlL0YxS2RTc3JZRmZtNFlzeXZjS1NseXo1R2htdW5Ub1JhUkhTCkpO
+ZUIvN0F0QU1xSzBaUWdvVjZmeXZwVEtyT2ppL2E4dDFzdFhFdHh4cG5UZmNuTE44ZE5GWFVp
+T3RuQU8waUEKcVpyYVdXZ09xMjJ4dGxPUXY5RTIrS3Y1K3EraFJUV3pxSDYwTjZhWGtIRjI4
+TFFqZVlReTMvWnA4ZTh4MmsvLwpYU251SkszL3Y4WVhxdzZ2UUdYNkthOHk3REFlT2JPUU1G
+QW1EenNJVUhhNUFZMEVZaWk2cUFFTUFMTWFzcVJ0CnY5eEIrRWl5ME1BMnJpZGV5aGErQmpO
+STJDemMrOEZJajZNTzJJcWRtcXBPaXJZRWNjdUFtekZWSjlzZEJDcFcKMjJCQkk2TkFZMG5p
+NnFxRTd5djU0ME02OU5xbXE5SkthSzZjb2t3N0RjK21wTDNWUXpxbmdwalFUZzlqK2tZNgo4
+UXpJcno2dDdzYWorTmxkYzh3WE4yS0doYkgyaXQwaVVSRjAzOEpEU2V6TFZ6SEZnbTRYb0tu
+RjVhajFNYlUzCjdXblpiaVNZaWNwWU1OR2F3S1krMjVkWmxtaVRNcnljYTNmOCsrNTh5Z0Ni
+RERyb1pCUmswVGMrMGZWRzZzcGEKTTJGdERaZTR4SjJEVDl2YkRMVkMxN3ZaQ28xemsxdHYv
+UkNmS3dyZjJucytKeTVPczk5a0ZROGhZUll6Zk5OTApTVWRBbXNpMGh3T0h0YkFHRFdWYUhC
+eEQ0V0pRWHBSSEs0NmoyczdSQ0dFajk1OS9HTXNmcm9RQ2FLSm1mZmFoCjUrRGNVRG8yVitI
+a0Rodlo5VHFrTGE0b1VYWnlSVlpqUVVlTFhWalhCRVBDazEzbnlYdFg3WHZEb016QVlwNTkK
+ZTc0UmpxZmhhcGFtQjJMai9TbEpobjNCcDFvY0NsdUkrZFcvcmFUdGp1c3R0UjFtcm5td2N0
+Qk16d0FSQVFBQgppUUc4QkJnQkNnQW1GaUVFdnVsMVVVN0RxSnBjZm1UUTM2SGg3SDB5dWlz
+RkFtSW91cWdDR3d3RkNRUENad0FBCkNna1EzNkhoN0gweXVpdmtMQXdBbXVzZWE4OE1sZmFY
+Y3JZQTNDN3JzR3EvSnluSmtnekdkeTg0SUsvZGo1L24KMWxSZUhsUll5WnhuUm5Od2JLUHdu
+M0IyZjJXRDNhRTBxOCtnT0lsMVdPY0xRWDRiZTkzWDdTNTUwL29UZDlnSwo5Y25tb3k1cXFk
+dFhZWkp6MWdXZGw5Ym5IZjlQcmJHdjMxNXZSOWxmbnFQQ0ordE01b3I2elFZMTQ3S1dzWG5T
+CklqMXNOci82bnZrV0lwV3FneEhxWVlEa1l6RHlLN3gvSm1CNEhja2I4R2dWaFlNZHFid2E5
+akd2WDUzM2tHNlQKU2RRTkQzaFRnTjJnSTJMRnRrVytYelNHTE9VMXQxK21RU1E5UEkxL1gv
+dHg4NWxuUlorL1Y0Yk04N3NFTklTZgppLzhpazJYY1hZN01tK1MzSnhObktIWVBmMzBkeVlM
+MXc2R1FrZVVnWnc0aFRPMlRWWFcvQmozNmxqK0F1d1h2CkZSRFNPdnFSZ055WXpDLzBSRW55
+ZFpHM1YrOHluTmozRFU1dTlZMFNUTXlWMS84eG1rWnBhcmZZYWluUmZwTzAKZFNtWkxqRzYr
+dFQvSVhtOS93cmdFejA3SHJMQWZlT3RMMmM3U1lpTkdMZ3pQV05OcDgxbzQ2aWZEYXZqWW5C
+RAorWXZ3ZDQ0R0tpTUFQNkd5dzl5dQo9Z3BkNwotLS0tLUVORCBQR1AgUFVCTElDIEtFWSBC
+TE9DSy0tLS0tCg==
+
+--------------CELMaEqsZnUdIj6gwK6SX0gy--
diff --git a/comm/mail/test/browser/openpgp/data/eml/fwd-unsigned-encrypted.eml b/comm/mail/test/browser/openpgp/data/eml/fwd-unsigned-encrypted.eml
new file mode 100644
index 0000000000..d5f37bdee8
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/fwd-unsigned-encrypted.eml
@@ -0,0 +1,75 @@
+Content-Type: multipart/mixed; boundary="------------LkTnU6PdkUwoxLbEONGg5YWI"
+Message-ID: <51b2b268-52a6-c8a6-3a9e-6222653b9338@openpgp.example>
+Date: Wed, 15 Oct 2020 14:46:19 -0400
+MIME-Version: 1.0
+References: <41b2b268-52a6-c8a6-3a9e-6222653b9338@openpgp.example>
+Subject: encrypted message forwarded as attachment
+To: alice@openpgp.example
+From: alice@openpgp.example
+In-Reply-To: <41b2b268-52a6-c8a6-3a9e-6222653b9338@openpgp.example>
+X-Forwarded-Message-Id: <41b2b268-52a6-c8a6-3a9e-6222653b9338@openpgp.example>
+
+This is a multi-part message in MIME format.
+--------------LkTnU6PdkUwoxLbEONGg5YWI
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+
+wrapper message with plain text
+
+
+--------------LkTnU6PdkUwoxLbEONGg5YWI
+Content-Type: message/rfc822; name="attached-message.eml"
+Content-Disposition: attachment; filename="attached-message.eml"
+Content-Transfer-Encoding: 7bit
+
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Message-ID: <41b2b268-52a6-c8a6-3a9e-6222653b9338@openpgp.example>
+Date: Wed, 14 Oct 2020 14:46:19 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="2W16lTBQJ4ZzVzryxZDTtDj0yHOSWfd6l"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--2W16lTBQJ4ZzVzryxZDTtDj0yHOSWfd6l
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--2W16lTBQJ4ZzVzryxZDTtDj0yHOSWfd6l
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcDMA3wvqk35PDeyAQwAx/6/zGSIvT4IyNpRmcM00iVQhUfdfxUTL/o1hV+MdB7rKZIr+qWJEgiJ
+F7IyNPJW0ps2W4myyCkDQtIy1682ahq6D6kHCNmDFxMSpElrG5Xup4Ibf1es3g9n/OytGXx8699m
+RymR4EA5yAiLEiGYO37N+nwnWhP5BNpk8jgzDSNTD9qbOrXb7Tx32rvOwFCrBHqZsx6LbaD5BWp9
+WdeSqNjJ6c35dhBxy7MlIZWOK27y5TQArsgyoq//3645cQX3jYV0jJbJeWHuPMoMxYfdRHi8oEIm
+P3HnqjtSLUtOTwAcc7Vmp5k9/+PG0IZGLtoR0QLNqrJO607mWrCgYowXZofqt3Bs+Nrtf6cuetWd
+pBcGkfdYldCWgG55dER67jP7rKyx6QjFPgPBPbYPFl/H0lqLBH4YrwyVTQDFDcmXe11k1s9JdrlJ
+JXEqITi4gFFF9E4mj7voC97Fhy2GLPdKH+343gCgTVU5stz8+NyNX7wC2QSogtxEIcBd6FQbTj/j
+wV4DR2b2udXyHrYSAQdAJwk2G0weJUqgb4P+/9f76USsiwNpQO9m0k0FxS5OPGowdVTr0bB/bHyV
+fo2DKIkfmgYKnmoiL99VsigzSMIVh1+visa0mDW2a2oVfJBnHS/D0sF5AfHIvERSb7+yLgpMQPkk
+4cizr+7wiQ6BNbTN6FwG/yhrFbBXp+r//y3ZcTGh6G6IDlAbkAwj4VhTTnxdvBHJCpfnAj2G3AZR
+arZ3nC1IC9RLccV66K0oUOdvehgOMBF37Y+BLHXSL6RMc9PZIvtwH4gVMzATUeOQ1SYENGf5LSYq
+5zXs0sfRCXmC74FwM+PF9h4mBm0zOvEbyL6uqxTEMYDwAACkl8QzsHqhUe8VEhZTu2c1BcGhES4b
+9ajkctWgzG/bA4a8kTwyXDaREZoywIHro0iR5+gzbf3aUm+akWGlCRHCOmaF4ZcYpvFfH65tKgwv
+pRzYheCdjK367qiAOwPXh16vBYB1YOZtm7tSot/jBZ60qaIi5BP9FHXAFoR4Y+VWfx8lZYuE1ZNE
+k/VMN47PJPXgK+f8aMXDbalXuuq+sFl1XezGW3osppOkcL7reOZ/0heH1Say3wLLADnb3NyYaBg6
+ihl8FrVMdvzCFt59ytXn+H33BbrrYb2PfiEABPjzEPoeFItpQxltY5E0SGRYSOCKnpN2G7M1yoKf
+eG7/fXa0EUf1KLLzz+Pj88i4Ht6MQkkb19rwYHgxrxPKhmbV8zJfID5ne2PaE28XPa69wzRIyM2+
+DD5IF7iYLF4KcPURqrF7wYuAtTmOQTSWVv6mlHCxjz/ECeCXJhA+24W0m4/O55h0C3dG4looraOD
+JJMITsjObyRasT5sgS1y7axqlJY8NmJrEdZMn735+kjR1HPPinZiat4=
+=s0kk
+-----END PGP MESSAGE-----
+
+--2W16lTBQJ4ZzVzryxZDTtDj0yHOSWfd6l--
+
+--------------LkTnU6PdkUwoxLbEONGg5YWI--
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc-sig.eml b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc-sig.eml
new file mode 100644
index 0000000000..6e6f5bc75b
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc-sig.eml
@@ -0,0 +1,106 @@
+X-Info: File is based on signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e.eml
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Message-ID: <1241106f-5ef5-ae8a-36ed-02d6f8f84d62@example.com>
+Date: Wed, 14 Oct 2020 14:29:03 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="------------rvEyIV4on03ewe0w3mDNOC3U"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//X7sl/QCVhaLmJVnPaF35yxDXmW5ACOdtKmyAAn0uaVKNRTdLontNFHRy
+DFeKhfDkl7ia6Emf4T1cP45/ViEJ4UphUwB550Anrzkhpqbmz3Sst0IuOxIrlQ+SDADzaMJIpsB4
+g2tsq7BNlfahe0J3h0CoVUZ+IBIZrj/d5nn1iLAJWwd4A8pMVBZ7lpPsalLDuzfJnWNJzD0atGYl
+GURSnrYWtK8df+tWmkSMlZIAqPQiH28r+seHmWdr8h7Q7zRPB0M7ElbDrJVl8bEeAlWogMXo3BP3
+55EfncyxWmShK16Rw6xrQ8Tgxu1s+zmw5LhhLA2poWXaeXWiYS0joKcFiEzvlplb+N7Wbvxr0D6w
+pKpJPG8fYCe4jSkuw4yHVSYkQVhMHsOfvULfHCffDR60DlcHrPTQLdvsaNJeKyhK1F0HNTaA4I5S
+bavbPMyxqhBLHw05CD27jLIK0slVPTTBhPUjsoGs44sGrpB9jz+IbeN085oEbtczm/crd2zh30Ip
+b14Y5BJae1Wzh5b/fTHF0KpKIc8OetwnoyBVE5eGtGFfJyTBXXbp9GsSS4rCI3aKPPnDJYNVMwEa
+qFPJpJJjWxUVcekLFOTeFhJtWrOmtNOVzt7tCHG/q8Kr+UvryoP5QdYBstGfizkTH88+WMsFVddm
+uju5rw4pM+Adu7yJgT7BXgNHZva51fIethIBB0D5OnsPEPF1mMxPEGZyMfNY60RBItwGlQd6sqi7
+GHOJBTDzPAoWQifXA/qk0nDqC3ikKFIypRnnYWXS0yiO8Qi7fCYh05NUBdwwJYgfy8cZYSDSxrgB
+k2FqB3EGXqcsrPW225CkmfGGrUeYosnUUsXdrChTxK/cfAW9f4N3kr0MA9R0VkD7BF6Lmir0Veum
+AcCkBVqrXPOu6os6N2Tl2ZOU+yq4JQJAgnndhGA2U4+TyFYs90BI3ifpr397t7HSKgQCb1F/QW4i
+KACweORJE3Rx2x0ispkZ4wfosOmT4JT9F7ykdkIN6JLtCoLXIokoUiW6R1eQkwFE8gEb+smlZ+PJ
+uS3HoTaE3FB6GbccYwAg/5H1oMT92nlx2x+tI/ocD136HOnVOPQv/vOa42O2Ipw77fKdZRQg40jq
+ZsB9poO6irjb4tjUDoeVil+MjwffqrytoJS9x/EQ2XTCG+FYyk9iP62N4LVbcMQCObKllGjL/fCb
+EzZcJyEKuQcW914PnZ4vXk8HpUaMdjACNWgDbPwvX7toeb7M09bZR+2MtDCtJFlhUq+fZDXRNGYT
+4wKNQxVCEX/AfuJkcy2uPmzo6yRyrmfaIdug+ypRMMenD3mf/do+rmqZsRL1O13YjH+X2Q6YreKN
+rSShWhHOdZlWuE2X9vyHqbdV6MH4IuypNVtIPdiC339/qeQgCBev10eHQPikdCA0JXgjdSTptUcy
+RRafRK0+FcguBcmsH8O1EIsflAtSCcqPA6y5omYj4uQ3xvwU7aXyzan0ZiYxhMj/ZPYremLSpFTh
+D6s1fO5jprvkZCD4V/Ix5YysOmldJ0X7uZ3wBPCheXNGu/q0qR9ksaWc2V3+Tt7UAPYPPINr7UUF
+69pbU1K2PGaUpSFZpDmrakCF8PgLzcEJpFaNAkstdA7/70w57GHWYu1QgU7dVeb5MXuKerPW5vr0
+scbDXGAWo2wXWvEYQhnPwq6PNwU65M6+5+Wvsfvb6nYIlEEIgeBzzcyHbYPVL004pxWUL582bzkL
+9U9dNMNfldzOr0riziblNxBdO4Fd7L38HAK/Ce8CEHuoorC80GU1CZGYacxuysBnFZnJo5iLSYjE
+XcIsjqRMYk+ZnlkGv5m6hj9zb61PLWVdepnKFmeDVqu97V4kqUR3KLCFJoyZ9UG3F3tz75xhV25F
+lusX9tQ3ddpOqOQH3wZVtdNFSzD3y8xem83aAe128at5jCPlGecKcmqLoA3tJwjst5BVhvcw7+7Z
+aDUDf4bNrli+l//UqoxslWP2TLfH6ZcdI4wdTpEhYQ21vKoavNq0i8k15GN6ENeK4+KFQXOuKzLW
+c7DDZOtgJ7aX2F2j2/FCgS1wjmhthMr3pWgEmg4KdDpnhrVpxzz6/rEYQdU2KTmKy4pTp9nvgTxI
+FBWRT1llRSoQLpSbD/2EHyIJAgf0GpEoQaEavyMN2oIvtDYOJqSGtBCXq7z4mI9qteUUIu8f7eMF
+NGxxFXjst70kYK+SMuT96h9to0TZUQQFtdymiIEVwke4T1a//jN/vkXa9VN3Y45ZuVlA2Y/ORhXK
+n+PaeXR3dNKLpiQUCdHoaJL0vOqXf+TbfQTauCF6jcLMJ4OsVauKBXLzUsadWhZuro6tiHpQL/J0
+ftco43xUFOFMcSjYFZXoKhjUt9I6jdLivG8CuxZebpbwV7TmW8XXKfVDnjHavSj4IpJgA2jS6K0H
+pBK4on+iH7FtehMK8tSVLzUNXy8MvZnvklC2b6XEfNUOq/H1m4VKM9bZhNsba0us5F3lOtX6vS1M
+k9krC8FFvwT8HDYxbBHUFO99FxlIqyVLbhFT7j008NcJv4QNBTmziHY/yZTNUp2/Rlcz5kSRFCNo
+LB+iLx5tawWYaGLT0O9mQpG73zd3cK1oTc4c9uJ2/AtMZOt+nYv5GaUqGPFazxCcZ0HlR6c1TCTZ
+gn7Pe2UgRlCYsAl3768WOxVCcMl/8mCQ0QBzm9tR1mS2JFmQgNhUpjshJkVTJeVaZPOfUNmONfYt
+LEXZ8aLqRqkQIOTcY9uBr3f55WaBDSKpO8VBn442EGn8uIE1FQOEJmmjFnJ5VyU/H1IumyLhvt9o
+EgPBQd8W0+3uyKBhC5sILPru41STAMV6n1+dcxxPpOkUMwoD3RQjrOtpQNk9KMr6wIgfIvPSskMO
+72amqSpq7Bmqf3RNQL4hZuqS0XfYIWD7gAzWHyIPXngp3UXmMDANOsRbPPDyrdm7U0Gwt33ub5DM
+Y9woXbDDZKvk7W0uwlCzJZ2bn4EpK56Yh80laN/V3Rn5fZVP9quN3+3+/lRVeaGGi8Us30MKXYHN
+StHU0DMonSyt/Ef2+aIiVEJp5vuTJiH9dkM4sVg+jQ8/LtwLnAZtRvVCNvZagX/ZPm9J1eH5E6aH
+NKcukVY3iTMQpLkeyZXhJnw+TYJSkpefPLxvCNwD/qewjN7+VcLtXDkrRsrwnjhu4TU2EQK61xK1
+aaVbH91T4GMLZsP4IO8TocnmBHuuyL8LBOcOWeOqiCFLEKHK/4jDWWcQMW9zqqKB+P82JYkEt+gT
++0sOTWHgQjOn4wHrrvCUbQaDQRYwpAsINVQ8N4fFazbUGw/xXdh7MKrfE/azHzcWB6d1XT5rSgtu
+kOQyLfxPJVevYf/JTG8/jtGDHQeb6p2GuIhCBn9m
+=d8b2
+-----END PGP MESSAGE-----
+
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm--
+
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCAAdFiEEuPL29L060/gtxEaDMJn/EjiFK58FAmObKoQACgkQMJn/EjiF
+K59Tyw//dahQthlNdE+XZjvpcGjyYzba8zdMCrjNb6bqlkzUKSAFBADwpuaMgukB
+o6z5LkUASuVBHl99bQgULij6KzMz/XoUqv+72xvTkYg2cbHtPK4KEuCQ2u18vMJ5
+ZGrnBa7ziHLsZRm7xc14sWvhjArPwpsqlaeEZxtNumGRdDPCMYAMD4cvYECfxF97
+lPzGEbKJUKRGC/XZRRonp3iz+v3vaVrt7ukNzuM3mSYvPK7ua7feYL0o/ov+u1Td
+1X9Zm8CJuYqpQYgvDtTjbw4QDOsP2GCnKcB2yZcEBVz7GWTe2Zkz3U8w2Qw+/B/g
+CHFN1tAP/CCsut/brkbbK9wHHdiAmTPNGe6DriB6QHiMzdr5WMiZvudnBYUIleaN
+Pat3K9zlHumHyQo7XjdOu8hZerqbyGRvimwQiNX6eOmtEFTECWk0pPMV0aSCul9O
+UTh0Hz2ZTquk3qBcJZaPyhK6ely2JcIxjJDHMfNgqOWzekcDhj96OvUuNQGS21jU
+d2aA9JbzikbmWws21lczkkHRob9zOCMVBJBsdsVuu+WxB854bXD2HJGtywFwsK6B
+9yLNV1FbqqTOvditrFTfXB9VJkhuGxIzZGBlX0biq2NfufCw9qJEwhwkEZZ/HJ/R
+9xtMVMGJFraPXABos7F8+Ixt5Hw3Ms9YAk+wrbJ3GDE/ngcZhYw=
+=rxUj
+-----END PGP SIGNATURE-----
+
+--------------rvEyIV4on03ewe0w3mDNOC3U--
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc-with-mixed.eml b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc-with-mixed.eml
new file mode 100644
index 0000000000..3432e3de57
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc-with-mixed.eml
@@ -0,0 +1,92 @@
+X-Info: File is based on unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f.eml
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Message-ID: <de515a63-a4fe-022e-4a3c-96f07536dbf8@example.com>
+Date: Wed, 14 Oct 2020 14:57:39 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="------------rvEyIV4on03ewe0w3mDNOC3U"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: multipart/mixed; boundary="MIXEDBOUNDARY"
+
+--MIXEDBOUNDARY
+Content-type: text/plain
+
+Additional text
+
+--MIXEDBOUNDARY
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//WTEFHnI2KYZJbgTfk8CaREcQpE/beaO1ysXdzCqpdRGWtU2UlbqmPxbu
+PmGDWg5f43qUEgO9mG2zsEvnGKlEoJmBFYaXXGhz/6+OoFY7VI+9DDtAWD5Oi8jzzKnUbyjPQO2a
+16PbLeOs/ydjt5eRNVaUVtnyTXMhp4JMLET1ISQF1FxjJJ00XRnaYzRRt/U6MHzIFLnZGBZYr+tY
+K1z+5vtsU6P0ZfWV/Hh8tFR6oqJ0Tiwji+zKwgUupKwC2QQIFy3j4GGrqJTejXiFfo5U/P4i5h4O
+X5qcnKzCX2spi7CTIJdx+uXKYAW2e9zsQIEQyIFoe8mZLgZcR0OLoH7ledfAeMBmVgS8GlM9uitj
+SWkiLa98gnudZbKiL7KXQ/e/TBLKVTPFtoorpGBmfYeJ6/YV42kQXPwK+ABHbxX52T7Tm7d12LRa
+Q27sp/SwnJYoi3hASA4NKViBi8B2gdV/DHzgsSfvHtEpMvN1LgaREolwESQ6U68yg/EDfohGdPdW
+eRiyo/p4jQ3Yo9v6n/boIxEb7xhkymhwQi2sZ9lyzU4HO18xrZ4sSpTjoMYyQV4ebA8nMqwbNpWn
+ACxWYeMtMdE4p6wJmMY232LlNtEAXkJbJbY+BDlKb9y6uMLBGHhXH4v7G9zaA3nDBWHNHAvP1cAg
+kgqURvqhxkgZqPz40cHBXgNHZva51fIethIBB0AykUD/87/8UHaKZX7MYUWr/CNBP+N68qFTgGp7
+UzMgSTAdpz+xzeC7S4BNoVh2IAg40r+ie38dJDxYJbEyvkhkr2wRhZf8A8z0/eGJczjEP/vSwW0B
+TkGuH9zZrlqH03jXZ0RUTGnA6oBq2wpGrBniHNZRJ7+ImS/cJT5D4uuITVDXl51EgTJQENxmSdyo
+YGe/lNoB4MVTxzmPfjWdOC2FqkGoc4jVzSwGaZ+OfLA/GviucholvaNz/LobZJ/AMXBvCbc3jh6y
+YvcZnjtDFFdUJHPCA4M8staEIVCz63UT5fdoXLWdr62H1NOhxWQDlyoZle+a2oM3FVEdyVKLt98b
+mTIP71YGhVXU4oRCujtiopVxQXzVugXXTEioebMw1+QLZLr663Xo1Kr+nlZlDDFBY9+NGLB7lX7g
+QqNkFUfw55jWFYWsj1N4U3/IzHplh/xGF9KH296ZKnzi66w6YRfp5QVKCT+fahOhxKWkKeTOl9Lf
+saUhPs93QMcVFRSW0igZrTh/fPZcplsgakpYchR9QcevkeHdCizk3CY5uULqMY6blUz3NU/aOMWw
+6fuLtwo1svBm1Vg0yh/mMA7HsRsIIB5xmkXEaP6PwM3WKLN4AZrcErQTwdvJ8HPGnIECCePugHOK
+EPB5JRj3aSp997Xwv+3z74bmp5GisjjtK3wFn8zYr0QI0hivRd9vz943rdh9iIMxCSAglawaqa0i
+BhUfhPIQyOfEWu5MBoIofW97oxnHaQ8/A/Bj2uvCIDUDPD2C50BHuVdtjsW9GlOmQ3ZUwj7llbuq
+O3oUeDzqaMdPgFt1QmfowXEkFAQcwRb0EbNboHX1q3F1QCXLklw3Dww/lw==
+=rZjL
+-----END PGP MESSAGE-----
+
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7--
+
+--MIXEDBOUNDARY--
+
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCAAdFiEEuPL29L060/gtxEaDMJn/EjiFK58FAmObKrAACgkQMJn/EjiF
+K59pjQ//bAJQpE1at9sRVUinYMxPLsbmwKhjmEms007hXYVNNivywDUi3Mc14l/p
+z7wOPs2iQeP2CSSBoJjzku3f8cjD75MEYnbDVLD48GJJU7vEIQtlXP4CJiK76dsV
+4WnjzLGbZd08pGr02ERgvgR+PdxppLtj8W7SVvVd60V8fwWQFpF87uFCrTGnfTZk
+wWQui6MqTsSXJ/dBeqjvkZ95cSlWjVqunmLNzJsesnf1k9iq68NqUzsRT8IEqFo7
+PttIK11hX3b6b71XGiP5XLz+ZXx+2O6lDo91TRZ7VzP/I2oJkxjwcTTI4Kqk6ypS
+ZkH9+0XZLpcesVIjYnrY0lBLjh+dxe63lw1BY3JmHOsAX6glwqLywTra1EVEdxw2
+0aqO2KJ9yJ4nSZj6kJiRwE/YOUX0gOOdJ64hL8G7ZCPkYA/xS8lfOQiUaMO3baS/
+lmtkymT6nkm3ub+U0VlEkvpmhw3Y6tfRQ/6aFhMDqB+GFLKDRP/UopHPWxPUmkRy
+j36eFqRTjKULmBAGwfQGB5A+By850tlQ9gtDyy7GcNDnsAAeVV5WC0Spqp/Hhm5A
+n/9VfYvyikTzBN07Of1JL2fNgH+/8WcaWnjDP1mlFGVNhZLqkTE/VsjKZN/LKKNM
+Z2VKNuhbe1PryrvR72M7QJ/ymGFkCx0k3vjOCcN+SuApCRBj38E=
+=/SQd
+-----END PGP SIGNATURE-----
+
+--------------rvEyIV4on03ewe0w3mDNOC3U--
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc.eml b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc.eml
new file mode 100644
index 0000000000..c17628190a
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-pgp-enc.eml
@@ -0,0 +1,82 @@
+X-Info: File is based on unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f.eml
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Message-ID: <de515a63-a4fe-022e-4a3c-96f07536dbf8@example.com>
+Date: Wed, 14 Oct 2020 14:57:39 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="------------rvEyIV4on03ewe0w3mDNOC3U"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//WTEFHnI2KYZJbgTfk8CaREcQpE/beaO1ysXdzCqpdRGWtU2UlbqmPxbu
+PmGDWg5f43qUEgO9mG2zsEvnGKlEoJmBFYaXXGhz/6+OoFY7VI+9DDtAWD5Oi8jzzKnUbyjPQO2a
+16PbLeOs/ydjt5eRNVaUVtnyTXMhp4JMLET1ISQF1FxjJJ00XRnaYzRRt/U6MHzIFLnZGBZYr+tY
+K1z+5vtsU6P0ZfWV/Hh8tFR6oqJ0Tiwji+zKwgUupKwC2QQIFy3j4GGrqJTejXiFfo5U/P4i5h4O
+X5qcnKzCX2spi7CTIJdx+uXKYAW2e9zsQIEQyIFoe8mZLgZcR0OLoH7ledfAeMBmVgS8GlM9uitj
+SWkiLa98gnudZbKiL7KXQ/e/TBLKVTPFtoorpGBmfYeJ6/YV42kQXPwK+ABHbxX52T7Tm7d12LRa
+Q27sp/SwnJYoi3hASA4NKViBi8B2gdV/DHzgsSfvHtEpMvN1LgaREolwESQ6U68yg/EDfohGdPdW
+eRiyo/p4jQ3Yo9v6n/boIxEb7xhkymhwQi2sZ9lyzU4HO18xrZ4sSpTjoMYyQV4ebA8nMqwbNpWn
+ACxWYeMtMdE4p6wJmMY232LlNtEAXkJbJbY+BDlKb9y6uMLBGHhXH4v7G9zaA3nDBWHNHAvP1cAg
+kgqURvqhxkgZqPz40cHBXgNHZva51fIethIBB0AykUD/87/8UHaKZX7MYUWr/CNBP+N68qFTgGp7
+UzMgSTAdpz+xzeC7S4BNoVh2IAg40r+ie38dJDxYJbEyvkhkr2wRhZf8A8z0/eGJczjEP/vSwW0B
+TkGuH9zZrlqH03jXZ0RUTGnA6oBq2wpGrBniHNZRJ7+ImS/cJT5D4uuITVDXl51EgTJQENxmSdyo
+YGe/lNoB4MVTxzmPfjWdOC2FqkGoc4jVzSwGaZ+OfLA/GviucholvaNz/LobZJ/AMXBvCbc3jh6y
+YvcZnjtDFFdUJHPCA4M8staEIVCz63UT5fdoXLWdr62H1NOhxWQDlyoZle+a2oM3FVEdyVKLt98b
+mTIP71YGhVXU4oRCujtiopVxQXzVugXXTEioebMw1+QLZLr663Xo1Kr+nlZlDDFBY9+NGLB7lX7g
+QqNkFUfw55jWFYWsj1N4U3/IzHplh/xGF9KH296ZKnzi66w6YRfp5QVKCT+fahOhxKWkKeTOl9Lf
+saUhPs93QMcVFRSW0igZrTh/fPZcplsgakpYchR9QcevkeHdCizk3CY5uULqMY6blUz3NU/aOMWw
+6fuLtwo1svBm1Vg0yh/mMA7HsRsIIB5xmkXEaP6PwM3WKLN4AZrcErQTwdvJ8HPGnIECCePugHOK
+EPB5JRj3aSp997Xwv+3z74bmp5GisjjtK3wFn8zYr0QI0hivRd9vz943rdh9iIMxCSAglawaqa0i
+BhUfhPIQyOfEWu5MBoIofW97oxnHaQ8/A/Bj2uvCIDUDPD2C50BHuVdtjsW9GlOmQ3ZUwj7llbuq
+O3oUeDzqaMdPgFt1QmfowXEkFAQcwRb0EbNboHX1q3F1QCXLklw3Dww/lw==
+=rZjL
+-----END PGP MESSAGE-----
+
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7--
+
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCAAdFiEEuPL29L060/gtxEaDMJn/EjiFK58FAmObKrAACgkQMJn/EjiF
+K59pjQ//bAJQpE1at9sRVUinYMxPLsbmwKhjmEms007hXYVNNivywDUi3Mc14l/p
+z7wOPs2iQeP2CSSBoJjzku3f8cjD75MEYnbDVLD48GJJU7vEIQtlXP4CJiK76dsV
+4WnjzLGbZd08pGr02ERgvgR+PdxppLtj8W7SVvVd60V8fwWQFpF87uFCrTGnfTZk
+wWQui6MqTsSXJ/dBeqjvkZ95cSlWjVqunmLNzJsesnf1k9iq68NqUzsRT8IEqFo7
+PttIK11hX3b6b71XGiP5XLz+ZXx+2O6lDo91TRZ7VzP/I2oJkxjwcTTI4Kqk6ypS
+ZkH9+0XZLpcesVIjYnrY0lBLjh+dxe63lw1BY3JmHOsAX6glwqLywTra1EVEdxw2
+0aqO2KJ9yJ4nSZj6kJiRwE/YOUX0gOOdJ64hL8G7ZCPkYA/xS8lfOQiUaMO3baS/
+lmtkymT6nkm3ub+U0VlEkvpmhw3Y6tfRQ/6aFhMDqB+GFLKDRP/UopHPWxPUmkRy
+j36eFqRTjKULmBAGwfQGB5A+By850tlQ9gtDyy7GcNDnsAAeVV5WC0Spqp/Hhm5A
+n/9VfYvyikTzBN07Of1JL2fNgH+/8WcaWnjDP1mlFGVNhZLqkTE/VsjKZN/LKKNM
+Z2VKNuhbe1PryrvR72M7QJ/ymGFkCx0k3vjOCcN+SuApCRBj38E=
+=/SQd
+-----END PGP SIGNATURE-----
+
+--------------rvEyIV4on03ewe0w3mDNOC3U--
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-smime-enc-sig.eml b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-smime-enc-sig.eml
new file mode 100644
index 0000000000..4851385105
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-smime-enc-sig.eml
@@ -0,0 +1,113 @@
+X-Info: Based on alice.env.eml
+MIME-Version: 1.0
+From: Alice@example.com
+To: Bob@example.com
+Subject: clear-signed then enveloped sig.SHA256
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="------------rvEyIV4on03ewe0w3mDNOC3U"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: application/pkcs7-mime; name=smime.p7m;
+ smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7m
+Content-Description: S/MIME Encrypted Message
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggGFMIIBgQIBADBpMGQxCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIw
+EAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEoMA0GCSqG
+SIb3DQEBAQUABIIBAHexOdzTP5dR9Th1QeZisCXDy1nfKdQWV8jgoQK9Dp99xCks
+7ch5TS8dy8mx08pZ4Fhcd1nPmSGeLLsvPwl/gSrpF3zbet6RcohfjzbwDN+wqsym
+wEsDqL5Kaq/kvO4o66hP0VZY9T6O9rknWByAoILMVSPrE+8EoTJtxDaDtRh5C222
+cLESR2Op3sRL+kXUDLg42Fw2XSifK/9jdm7+U2sXX21GJzs98pzXGVBpBjjSyYrL
+AABje4PaI7RKeMmBwJ5Z39XMIbRcdpGax8YPrbl0wRdIP6kOmb9T2yo77xu4xOnV
+pqklrkkoagtNfA6Rx0ccj293z55nZdAFxZw525wwgAYJKoZIhvcNAQcBMB0GCWCG
+SAFlAwQBAgQQbHD/jpGgTyDRzqaPrf2mRKCABIILcPpHQ5TPhOWBqqzdXIwcy3Ci
+OpOkRTGMB05Q0aO93zcaiPUt01ccHN7VQ30gh5WjthTORFBv3N7GbUIT0bnVLjFk
+aZ8/VTpYj8zPUIAhgnehoOmkCrUkOX7WFgzhaBoZqcIEhF2B3MGmZVZYm0uPth/P
+it8SD2xfcqpf3nmo+rHP1E419ywhLDT1gzZ/jAohutj+iO9wolSNI5gsdtYeBiWj
+ghSkq9CPTdYpM3xD3nYs7XJl4QXEDw273TIPA0DSVU6j7VUu7d1m/7FJYgZCfVdL
+sA57BX5d2DEif0kpparwRARpGBSdwrfu7ztF9xh0mljNvY/dH8xe0ZuMcNBSuCgZ
+5tw+O/J5JQaoZT0XFUcPkILfk7JoR4eELLAV/tEWgPkAgpr53TX5Lqs6JZRdxXK4
+7Hb3fMIt1dN9lrleSqrcbpiy4527yMsFhT7n5IWmFzQUcF70Rk/+SXJ3yL9yNk0x
+e+2xASqhJxW82iGRwbzyoehhaXv/FxExMsGgRTL0gaP4GM0Dyer3/CE++oB6qhyw
+DLMFqEdC8JUnfpprdh2liYP1ClccjaZCZDhmLqRlby7z0uk5f5sNvqer4l/74yPV
+fs71AMaOBx1NlB7r2STCtjH7B+XZSugxUIsWj0rWBigzKlNzM3H4uefFAqfex18B
+WWqi8zPwhc1zIfX3zxGckeuepWBThhg/9BPZhmNE/fl0b3S9p0JGmUQ/LgBMR8KX
+SBpuf2vhlWNci0EsK8gEE8xgb8UChB2ecy2OJSTkPZMMGGP7QjT0T9zGqsEUWEEq
+8hqh/y3s0Ccg04T5Dfwhz3KYVu2XxkIatOS/ZjVLie5rKQZdNtnFdHhX9FbEkMfn
+2AV0mZMg4WKdySP9b0MmUBcxV1P/1IUOes+Gwm4H5csZjyhKiC072/7jmordxed7
+7txyGSk6/0KyST2pfcTla8LM9pRf92FCj2ggRuGSJT//SO+wbE77G6FJlG3jpl9D
+ZujaEKWJBCvADI5UxDJEzGaCEFU1tWi+jrlaB7Jsg8cndsS8zGPCdXcePI1J+FYS
+9u2Xcit5gH5vX2W35gkU8e+vGUSEm7sVPQUwO/2PKUx5eu25KL1I3CqVl+mFnyuZ
+FXihjnoi2P+GGHRBrh2o17RMYnBjPgMpwXfTbZv4ORanmGuDXYQ5CZUZOKQ6FpyD
+YznElmG6AD3MLxKqDalfeigpYB2zLouAsma2Dxa74bD0lJ5ymtnNBcCigJVeidEV
+tO/RYjLRZAoAJi20TD8cLP0u56YqW65499Bk6DjJlKL4cQXNLJ+Cw8e0GTxN1a2k
+I+7dvfuMksz6IVegXHOKlGDLtK0ar1x/6ZjTxUoN8OU58GgnQZyttplTuZy+oOa8
+MdMJHFt0VNC0nXUu8mqor3EbgjnGFgkaEMsevjmi1fXYPwi0/UAooL8FFbfRUx4P
+doPFtOGNYtjgFWLnaVUKtC06JHhaSCF8UtVMykniY2athWPNc2SMcBU20CfZNJE4
+vtCDkMa2uHDIThYWUCXMoW6nTyEDPTjsblBXoFundj/eAUJgiJT3F3RUIj/MX4XL
+olic8lJVcPke1O+twKQNrfSW3/wH/RmOk8YjKew7G7y4Avw/T3jCgv5bt2U+jy25
+KJYOI8V/bFTMxOQR4CzS4/ztBGlLh5sHD8yVOP7i5YuO28fp239dJWKoYhXBtbt4
+PEzHK4evOMbbu41ysUz/tej/u6fhnEERAYJH6isFE2Kvo3V2j/FLokb1JgW2IPaM
+E6kbgI4r6FQW1oghGMyDMiIQoufocyZWzy9qY4U8WjxZ+cLysxcl0ff/5gs+d0Aw
+8awIKxQPbhHvJXtZQVMB0GKTmtVjTYKVcE0NTnwKRt77Na14pTRaCsIeZ/ZKSYeV
+lxQ4eShv8+bTAiCRknNu6hagrZmOXFdbtWiJTUIIofTFE7RqtG+kuD39dKP6TXHH
+V658uSE3cpnSl47j4KxC2214ZZFs12TsAXU9eaKqPdxVFH2Ef5q95uhEB55aDOl+
+29LcgxyBP1tyIAx4tjZItIefOkquwZtcTqyUbymr56dKA02vFgmuFLHGA6Eh4aYP
+YE4j/4AzG3Cv1G0q7Yo6sGTNkv0VvoR5B52JtU9M0EjoN0gPtjHaQHJI0KFIKvHP
+SEUzmszHhDLVsTdvlQBteXT4WOMg94mFS5rb7JI7XwsCFkR6z7ufz2ignmwpFhya
+Ucl+jOn/mkk1Ct8dJqbTvuhxXOiIURQq071msiV8ImLBHZY9Rc5DAXia84ltFvr6
+k0Vh5/rMzKUnGqw8BG+I3iJ5nXmp/q4zuR3dPr/M7/VTwGpO16sD9pN6XGxYoWqv
+YW1QP7z3ODVO4S8nyaQea6E8GaSg2zozzR1KFIAwEybBLvSWHUS0T94SWtV4RmTA
+mYsSvV015Z/TCgxuQJe23iG/UO1ACcEsFmjlBgM9KPi18joGtUiD48hzT68jQomQ
+n4a6GfrNYhE1NZqNisG33U89djnkmKNfS2e1TMS47S8fJF3PXwyofM123tVXwfUm
+i7Z+TzfC+5g4ERpgGWT8Qkycqroj7VK6qZTn5nBLUmmNqFs2G8FGfwgwgiVGeIMj
+HqoxqwfWxnRkHw3+K5f5+HOycs9q6/DR6tam4gSrnm3tCSnBpFtJZ3oUqOUu7CrZ
+l5PPnwOyoQRiiZQFyIB9SaN+XjdC0aAl5Cxrr99zGUM2WOjjPT5z/eo9cIgVdiRe
+ywNqxtPXZEvk2k5Fwk+NIkgDLNg9w+2eA+TGaRtSFQiqWAz9jg4/UEgwU01piwoZ
++1AjO0zySeEQv4CFzhrpbE2e2T+xeowZhaT8p03zLfui89Gmt8nDrGLvahesZXWW
+PoBmbGU1z4p8+swl2TYjTQrxUrqiUiN6fK4QkxxlnyoaGKWr/zRrmBPMWsWFyTj1
+KO0VoB0gvkINgFPq4T9P1eIQNYiZ528Z+FiMXUxurhUSmp6bzKloHwF5KWR3jCtI
+tW+x8s9f/d/xLn3QU/1086ptS/zRSdoV64PEGUy4esYCI21/oSMseSb2+/gTNTTe
+W2gF2xaTdet2s90w7bETmLiCcwniQ6FdkDo5BGys9B3BA0g/johagcGb3umIggb/
+/W+WxrLDVnUp/SjeLhnrKTwyiBVnHbYNAh8Olyj9s9exkVaR2n2E5PydTKYamtdp
+7jXnMgCqj3Xp4TUtmTkJc6WDYm73E1h88pcHtmaMiQpDcaCmhnDxNuF3nSSNnt5T
+K3N+Il7+WuFFSFOBNxdI67gbNwZJGIyjltDkbj+gLdtFE9f+MWutU+jy4YOB66OX
+8p0LV1eToy1zBYytfa0OTsQJE2CRQzc4dZIPjgyM+ZRk2mUBgn7SHhkIp5kSMGT+
+OTa47iMm7uMpetxjf4Usi14nH0zU6vdShEsoBuaha/EVQD0u0JfYlrHtISqLW2b/
+kvtwaFO+YIYSDwYg5iW0G9YEpGG7uFUzObyp/aWyJlzA0AhWoHnOPj9hS+QhFav+
+buk+TXlSQy5ByUMKbw/D0ouiviZGY5xpgtSxholTsG2xYAOPDfSqupKlhMzbPJ3o
+10GKfz3TCjI17YvsneytR4kCzvlEfOFI/XDrCWOza5i1XjTw5lfHwFDJ9aEQipyk
+X5tPqWDqrltH2RvQMHC475jM6c/eRlZC+CyvgGfGyw9T8cll6jzKMe4UKYcJ6YHS
+SSqLh248eQk+8DKsN1NrvtUCnxwACquYUrkFCxHjD0qQlr1/rVD4OmvyGWRl6f3i
+SuRnjnnjc1hz0C6lwzsVFRIO6muKbQjCzJ/oEtMnoewIPsOai2QRiW5DPJo8dqHv
+pjCLcCQWo8vt6xO3eQTyQzrqIB5RkBApXdC+LVv+RDbX8FaJaPB0RxYgv+tyPyeS
+3icHKSDKE4SBVX85wiplV3Cp0wiT+v7g2kvRWpIArAQQ1pReRfeSBHpV6v0LFy6h
+eQAAAAAAAAAAAAA=
+
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCAAdFiEEuPL29L060/gtxEaDMJn/EjiFK58FAmObNfwACgkQMJn/EjiF
+K59/CxAAlQ0dqIpo0k+pUTGmL9mFeO8e6lXjg8GwXe8tXA8oTaQ7J/z81XgE5D5v
+7uaQWkF86nD5m7PMZeHJbioNHeXv/KJKvp9LjQriZPcifRTefXdkFgDzNA3QzBS7
+yub/7ovD3RqeLZtPWZh8im/wzp0nA1dzFqh+bGd0E2kdHVnEwrnrIaHMO25UH65H
+RIQSNNyDw0G+JhDGohMkpuMFnZ5/6HR0T9m/OQwdMhYRbXVJAFd+kWbP2nrdTuG2
+qpOhmzxiGAARI1BmG5v7i1nxmrGPqpl7YQ1CkiDWx82g1qk/TGbi4I9lJJrywAgB
+G2nbTwTloxVo1KHI3YmzqS/vJ2uV2/QRprdVssDYfYIR4WSgCbVLEcy9EvMMiO83
+bm1cIenuVbSYb8wu1vlPCgRpHmp/zx2kzq8Ww6+yjslWPfChBS3BSBpS2S8HYNXU
+2mglUaaQYOhnQakjKXrFQqNarmKIBCSCVHUI6dqVYaN5btdpTkz0ZYMAa78Jg1fz
+g39kaCTL0eoSNuo7yUklw3bMqfj7dr4UrusAyFxtAAVb3pXMC8ltSs6XJa9Bt/kf
+STt2LIV1NnqhO6XmGQK/pusLHEeHa3vyKwIMmV9iM1QifDUv09xFez1o9roOyVep
+wJ0i374E169X8SCesq046Pq5meWIumV4U+ayghFnWFv2TzBOoh0=
+=df2O
+-----END PGP SIGNATURE-----
+
+--------------rvEyIV4on03ewe0w3mDNOC3U--
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-smime-enc.eml b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-smime-enc.eml
new file mode 100644
index 0000000000..dd78fc1630
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-pgp-sig-inner-smime-enc.eml
@@ -0,0 +1,55 @@
+X-Info: Based on alice.dsig.SHA256.multipart.env.eml
+MIME-Version: 1.0
+From: Alice@example.com
+To: Bob@example.com
+Subject: enveloped
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="------------rvEyIV4on03ewe0w3mDNOC3U"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: application/pkcs7-mime; name=smime.p7m;
+ smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7m
+Content-Description: S/MIME Encrypted Message
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggGFMIIBgQIBADBpMGQxCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIw
+EAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEoMA0GCSqG
+SIb3DQEBAQUABIIBAFcGoL24XUZv8ZnBG1ld76tZ/AT9ZXCiNLexfkVSp/1hr9CU
+Ilz/fOQ7nOdNqYWaiLEzXDrgyjVHlLbOEKwXVXLVwy+RQgsTSbFYhFweqa4IinoT
+g8Q4/xkXquoQkk8XHPwavkKenjZljbwab0c4D2CwpfsKV0JeWpCNAOIZRiCrG+Aj
+M4KTkIgXFMuWYDGX6EhfTxqgCEMNnfKwhwYafBI+m/O8yW7MjBoSEIOae6tEk31E
+Jt4UEC4E7x2IXaU8yIZb0X5Knl72KcWP4RqO/Ym29xssTzXhW6ocxLgPPKY7OUMf
+MW6PkJuHkgTGwHK42FhX6xDsBx75MKfNTYQA3CUwgAYJKoZIhvcNAQcBMB0GCWCG
+SAFlAwQBAgQQQnhVDuViXS7I+UouwGJhmqCABIGgJ/I1Q7RWQEsw+9NwBxeDhfJg
+AMNHdjoKxe6UgP10Cag2A+P/37OFQ6avwSQXcFOnoVgr+ewn+AmmeTGBbxcHmmuX
+1lRd8TyZJcf7NqKaE/pqlSReKbBwTthBIxhP44T652CWSlZkINBPvmRHLZiymxG1
+ggTnmOsoUt2IR5R7KVaxJ/zQBFD0Q0Tug7wF/py3YHlqKeL0VFhA1/VfvLO1MgQQ
+dialx9mgeNqWNvJQ7r3j7wAAAAAAAAAAAAA=
+
+--------------rvEyIV4on03ewe0w3mDNOC3U
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCAAdFiEEuPL29L060/gtxEaDMJn/EjiFK58FAmObNeAACgkQMJn/EjiF
+K58aPQ/+OYoMsHjVrAHhxKZ4HiK9hz+rt5mbI9udUx0krf0/QRug6Lz+zw4SLfjM
+jHRCHCBImTWB9LooIKQvFngM1Y9f2sA8i5xRLWR326ZJgDNkiUMpRokpzNCH7Xf3
+ybdFpKJTFEqMIEfuNiWWKbwla+O2ZwvfE60b2QnSRq/ICbqZfzvGNpbVOHfyuKWn
+gd/fyWqDBFmRx6+PJuuP6GmZH7h4wnkHDF7faem+ZgW4qZbly53s80H5tsQ2Bydi
+BCk00eqbNPJqUNqFgYH91ANfn9qZPD9o3mydCIyPcRnR/3QIQa35ZgM7AWApBdPc
+uOIdNkoVCDSFQZUuuPtrK+Me9dohxzdQ656pI/Z1NIO8hAUOG5tWM857RXGHrIxA
+7LKYd5nm2m95I0xOsL3KqnZkonqH6XTuUucUt1oKO5x8yjJnedlYhazolaCARB9R
+YSALXbJkDEs52M4B0NTQq6i/mD8pO644wKuxqD8es3CaRkKREPphxzcWWCjo19ex
+7e8N0P6cAg6RfNql82OLgBGiQ2UvZSY73kwbNAZ3ZKORleRNE9kjOvGIixxhVoG2
+vszvZbdDo4NtQD4BcXtR6faAnSiOCW5RRNN39NGcQMXVZf1DY2ryo6rD7B2HQThI
+KkbOWzlJ9sYt5g8khTUxNipmnNe9kMyim8VpKdMnOV8TxBHb0r4=
+=b2z0
+-----END PGP SIGNATURE-----
+
+--------------rvEyIV4on03ewe0w3mDNOC3U--
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc-sig-with-mixed.eml b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc-sig-with-mixed.eml
new file mode 100644
index 0000000000..413e48ba22
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc-sig-with-mixed.eml
@@ -0,0 +1,135 @@
+X-Info: File is based on signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e.eml
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Message-ID: <1241106f-5ef5-ae8a-36ed-02d6f8f84d62@example.com>
+Date: Wed, 14 Oct 2020 14:29:03 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256; boundary="------------ms030903020902020502030404"
+
+This is a cryptographically signed message in MIME format.
+
+--------------ms030903020902020502030404
+Content-Type: multipart/mixed; boundary="MIXEDBOUNDARY"
+
+--MIXEDBOUNDARY
+Content-type: text/plain
+
+Additional text
+
+--MIXEDBOUNDARY
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//X7sl/QCVhaLmJVnPaF35yxDXmW5ACOdtKmyAAn0uaVKNRTdLontNFHRy
+DFeKhfDkl7ia6Emf4T1cP45/ViEJ4UphUwB550Anrzkhpqbmz3Sst0IuOxIrlQ+SDADzaMJIpsB4
+g2tsq7BNlfahe0J3h0CoVUZ+IBIZrj/d5nn1iLAJWwd4A8pMVBZ7lpPsalLDuzfJnWNJzD0atGYl
+GURSnrYWtK8df+tWmkSMlZIAqPQiH28r+seHmWdr8h7Q7zRPB0M7ElbDrJVl8bEeAlWogMXo3BP3
+55EfncyxWmShK16Rw6xrQ8Tgxu1s+zmw5LhhLA2poWXaeXWiYS0joKcFiEzvlplb+N7Wbvxr0D6w
+pKpJPG8fYCe4jSkuw4yHVSYkQVhMHsOfvULfHCffDR60DlcHrPTQLdvsaNJeKyhK1F0HNTaA4I5S
+bavbPMyxqhBLHw05CD27jLIK0slVPTTBhPUjsoGs44sGrpB9jz+IbeN085oEbtczm/crd2zh30Ip
+b14Y5BJae1Wzh5b/fTHF0KpKIc8OetwnoyBVE5eGtGFfJyTBXXbp9GsSS4rCI3aKPPnDJYNVMwEa
+qFPJpJJjWxUVcekLFOTeFhJtWrOmtNOVzt7tCHG/q8Kr+UvryoP5QdYBstGfizkTH88+WMsFVddm
+uju5rw4pM+Adu7yJgT7BXgNHZva51fIethIBB0D5OnsPEPF1mMxPEGZyMfNY60RBItwGlQd6sqi7
+GHOJBTDzPAoWQifXA/qk0nDqC3ikKFIypRnnYWXS0yiO8Qi7fCYh05NUBdwwJYgfy8cZYSDSxrgB
+k2FqB3EGXqcsrPW225CkmfGGrUeYosnUUsXdrChTxK/cfAW9f4N3kr0MA9R0VkD7BF6Lmir0Veum
+AcCkBVqrXPOu6os6N2Tl2ZOU+yq4JQJAgnndhGA2U4+TyFYs90BI3ifpr397t7HSKgQCb1F/QW4i
+KACweORJE3Rx2x0ispkZ4wfosOmT4JT9F7ykdkIN6JLtCoLXIokoUiW6R1eQkwFE8gEb+smlZ+PJ
+uS3HoTaE3FB6GbccYwAg/5H1oMT92nlx2x+tI/ocD136HOnVOPQv/vOa42O2Ipw77fKdZRQg40jq
+ZsB9poO6irjb4tjUDoeVil+MjwffqrytoJS9x/EQ2XTCG+FYyk9iP62N4LVbcMQCObKllGjL/fCb
+EzZcJyEKuQcW914PnZ4vXk8HpUaMdjACNWgDbPwvX7toeb7M09bZR+2MtDCtJFlhUq+fZDXRNGYT
+4wKNQxVCEX/AfuJkcy2uPmzo6yRyrmfaIdug+ypRMMenD3mf/do+rmqZsRL1O13YjH+X2Q6YreKN
+rSShWhHOdZlWuE2X9vyHqbdV6MH4IuypNVtIPdiC339/qeQgCBev10eHQPikdCA0JXgjdSTptUcy
+RRafRK0+FcguBcmsH8O1EIsflAtSCcqPA6y5omYj4uQ3xvwU7aXyzan0ZiYxhMj/ZPYremLSpFTh
+D6s1fO5jprvkZCD4V/Ix5YysOmldJ0X7uZ3wBPCheXNGu/q0qR9ksaWc2V3+Tt7UAPYPPINr7UUF
+69pbU1K2PGaUpSFZpDmrakCF8PgLzcEJpFaNAkstdA7/70w57GHWYu1QgU7dVeb5MXuKerPW5vr0
+scbDXGAWo2wXWvEYQhnPwq6PNwU65M6+5+Wvsfvb6nYIlEEIgeBzzcyHbYPVL004pxWUL582bzkL
+9U9dNMNfldzOr0riziblNxBdO4Fd7L38HAK/Ce8CEHuoorC80GU1CZGYacxuysBnFZnJo5iLSYjE
+XcIsjqRMYk+ZnlkGv5m6hj9zb61PLWVdepnKFmeDVqu97V4kqUR3KLCFJoyZ9UG3F3tz75xhV25F
+lusX9tQ3ddpOqOQH3wZVtdNFSzD3y8xem83aAe128at5jCPlGecKcmqLoA3tJwjst5BVhvcw7+7Z
+aDUDf4bNrli+l//UqoxslWP2TLfH6ZcdI4wdTpEhYQ21vKoavNq0i8k15GN6ENeK4+KFQXOuKzLW
+c7DDZOtgJ7aX2F2j2/FCgS1wjmhthMr3pWgEmg4KdDpnhrVpxzz6/rEYQdU2KTmKy4pTp9nvgTxI
+FBWRT1llRSoQLpSbD/2EHyIJAgf0GpEoQaEavyMN2oIvtDYOJqSGtBCXq7z4mI9qteUUIu8f7eMF
+NGxxFXjst70kYK+SMuT96h9to0TZUQQFtdymiIEVwke4T1a//jN/vkXa9VN3Y45ZuVlA2Y/ORhXK
+n+PaeXR3dNKLpiQUCdHoaJL0vOqXf+TbfQTauCF6jcLMJ4OsVauKBXLzUsadWhZuro6tiHpQL/J0
+ftco43xUFOFMcSjYFZXoKhjUt9I6jdLivG8CuxZebpbwV7TmW8XXKfVDnjHavSj4IpJgA2jS6K0H
+pBK4on+iH7FtehMK8tSVLzUNXy8MvZnvklC2b6XEfNUOq/H1m4VKM9bZhNsba0us5F3lOtX6vS1M
+k9krC8FFvwT8HDYxbBHUFO99FxlIqyVLbhFT7j008NcJv4QNBTmziHY/yZTNUp2/Rlcz5kSRFCNo
+LB+iLx5tawWYaGLT0O9mQpG73zd3cK1oTc4c9uJ2/AtMZOt+nYv5GaUqGPFazxCcZ0HlR6c1TCTZ
+gn7Pe2UgRlCYsAl3768WOxVCcMl/8mCQ0QBzm9tR1mS2JFmQgNhUpjshJkVTJeVaZPOfUNmONfYt
+LEXZ8aLqRqkQIOTcY9uBr3f55WaBDSKpO8VBn442EGn8uIE1FQOEJmmjFnJ5VyU/H1IumyLhvt9o
+EgPBQd8W0+3uyKBhC5sILPru41STAMV6n1+dcxxPpOkUMwoD3RQjrOtpQNk9KMr6wIgfIvPSskMO
+72amqSpq7Bmqf3RNQL4hZuqS0XfYIWD7gAzWHyIPXngp3UXmMDANOsRbPPDyrdm7U0Gwt33ub5DM
+Y9woXbDDZKvk7W0uwlCzJZ2bn4EpK56Yh80laN/V3Rn5fZVP9quN3+3+/lRVeaGGi8Us30MKXYHN
+StHU0DMonSyt/Ef2+aIiVEJp5vuTJiH9dkM4sVg+jQ8/LtwLnAZtRvVCNvZagX/ZPm9J1eH5E6aH
+NKcukVY3iTMQpLkeyZXhJnw+TYJSkpefPLxvCNwD/qewjN7+VcLtXDkrRsrwnjhu4TU2EQK61xK1
+aaVbH91T4GMLZsP4IO8TocnmBHuuyL8LBOcOWeOqiCFLEKHK/4jDWWcQMW9zqqKB+P82JYkEt+gT
++0sOTWHgQjOn4wHrrvCUbQaDQRYwpAsINVQ8N4fFazbUGw/xXdh7MKrfE/azHzcWB6d1XT5rSgtu
+kOQyLfxPJVevYf/JTG8/jtGDHQeb6p2GuIhCBn9m
+=d8b2
+-----END PGP MESSAGE-----
+
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm--
+
+--MIXEDBOUNDARY--
+
+--------------ms030903020902020502030404
+Content-Type: application/pkcs7-signature; name=smime.p7s
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7s
+Content-Description: S/MIME Cryptographic Signature
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExDzANBglghkgBZQMEAgEFADCABgkqhkiG9w0B
+BwEAAKCCA2IwggNeMIICRqADAgECAgEeMA0GCSqGSIb3DQEBCwUAMGQxCzAJBgNV
+BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBW
+aWV3MRIwEAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBMB4X
+DTIyMTIxNTA5MDk1N1oXDTI3MTIxNTA5MDk1N1owgYAxCzAJBgNVBAYTAlVTMRMw
+EQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYD
+VQQKEwlCT0dVUyBOU1MxIDAeBgkqhkiG9w0BCQEWEUFsaWNlQGV4YW1wbGUuY29t
+MQ4wDAYDVQQDEwVBbGljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+ALUryBlKMhddXWPI5hrNm7yrY13tIWVuDxMd+Ytq6tFIS9+5Py3RuGTtBOCx3Tkf
+F4EahfdeA5PC6aXCHNaXnwFLLiA+Eq1EzM/ANr2SHR+iWwuvOZComNYaWigswyWU
+rCGJigGB93bC7i1WczgTwQc0zA3K3PbFai8J7bUAwJ39fUqGE6xeM2+RCtVcdU+d
+tkYBFy1nHz2N9K9XIwTNg4aUqCOONwQvZgcKy+HrUQIBhnAnfODjyyqlGRZunj0E
+zA/0D00LCzUtvdaNA5HV3xWRW6Njt4Sn5WrmAfFsTOf8Xm3l/sgrYaiXd3fmo2c+
+JjBgQ0NV2nzczDbihf6dVYUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAj84R6KF8
+Ve9ZTAw9LdcYnx6+u6emacM/HUESAETBYF2f0f97EASKzUIgtALh0fFXNbzWfc/a
+TzZYek0ilIBFN2LYhkWi69OSLXvQCrYiVBgkmJwf1IFFK+fqr+ZUihdp/URhTuyk
+fH5wnAkYc/Vq4RJWgouujpZVdhsJlvQS+WmnrGLIKRCMQtFfsJ6e6GCgSmhxED4O
+Ds2TnTL1Tq/pECwIwl7iToB2E95RiFRYZz28twV+OmmSY/DQoxKk9Encn5K5BEId
+27iiouVjDMqh+M4qtIrGQiI2Vdcwvb+AUwyrMC6YTKqdnQXC2EsA8g0Dx2tkIx/a
+Gwlma96Op9gWXjGCAtkwggLVAgEBMGkwZDELMAkGA1UEBhMCVVMxEzARBgNVBAgT
+CkNhbGlmb3JuaWExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEjAQBgNVBAoTCUJP
+R1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRlc3QgQ0ECAR4wDQYJYIZIAWUDBAIBBQCg
+ggFBMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwLwYJKoZIhvcNAQkEMSIEILJr
+dzzDNYWQV/M1oK41/rfKXs+hx4nk6HPGaJpiwfmGMHgGCSsGAQQBgjcQBDFrMGkw
+ZDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDU1v
+dW50YWluIFZpZXcxEjAQBgNVBAoTCUJPR1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRl
+c3QgQ0ECAR4wegYLKoZIhvcNAQkQAgsxa6BpMGQxCzAJBgNVBAYTAlVTMRMwEQYD
+VQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYDVQQK
+EwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEeMA0GCSqGSIb3DQEB
+AQUABIIBAAxt7bvYNUpTx8FStpcQ9Z2Zrzw9pwZ2tWlPyFXRZpQMuD5mJKqyvXOL
+X9TBCEJEIloIeFTo0x1KE4iUAtfgv7D8E+YPpVt6sRdGjsn3+htnE/FfAySTSANr
+2I+BQaU05fSdvIHHCJ2OvK4O6JcoG1YzhEvuReBGdzg5NkQnFCp6CtV/vULO5Q7k
+NIjeeCEBVjJY37w6V6iSEmulYfA/0mv0ABKCu513xFVZP8qXQCJ32OrzB1vYwUfL
+bcMDcaubQT+5W6JTnX9VBszRe73Ayo8CCA0WBOnDxY02p1ncs8cRFhmX1kAfZ3qe
+n2eTTmS8ztANppAPYuc9sISwaMkgWqAAAAAAAAA=
+--------------ms030903020902020502030404--
+
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc-sig.eml b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc-sig.eml
new file mode 100644
index 0000000000..aa55af42c0
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc-sig.eml
@@ -0,0 +1,125 @@
+X-Info: File is based on signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e.eml
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Message-ID: <1241106f-5ef5-ae8a-36ed-02d6f8f84d62@example.com>
+Date: Wed, 14 Oct 2020 14:29:03 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256; boundary="------------ms030903020902020502030404"
+
+This is a cryptographically signed message in MIME format.
+
+--------------ms030903020902020502030404
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//X7sl/QCVhaLmJVnPaF35yxDXmW5ACOdtKmyAAn0uaVKNRTdLontNFHRy
+DFeKhfDkl7ia6Emf4T1cP45/ViEJ4UphUwB550Anrzkhpqbmz3Sst0IuOxIrlQ+SDADzaMJIpsB4
+g2tsq7BNlfahe0J3h0CoVUZ+IBIZrj/d5nn1iLAJWwd4A8pMVBZ7lpPsalLDuzfJnWNJzD0atGYl
+GURSnrYWtK8df+tWmkSMlZIAqPQiH28r+seHmWdr8h7Q7zRPB0M7ElbDrJVl8bEeAlWogMXo3BP3
+55EfncyxWmShK16Rw6xrQ8Tgxu1s+zmw5LhhLA2poWXaeXWiYS0joKcFiEzvlplb+N7Wbvxr0D6w
+pKpJPG8fYCe4jSkuw4yHVSYkQVhMHsOfvULfHCffDR60DlcHrPTQLdvsaNJeKyhK1F0HNTaA4I5S
+bavbPMyxqhBLHw05CD27jLIK0slVPTTBhPUjsoGs44sGrpB9jz+IbeN085oEbtczm/crd2zh30Ip
+b14Y5BJae1Wzh5b/fTHF0KpKIc8OetwnoyBVE5eGtGFfJyTBXXbp9GsSS4rCI3aKPPnDJYNVMwEa
+qFPJpJJjWxUVcekLFOTeFhJtWrOmtNOVzt7tCHG/q8Kr+UvryoP5QdYBstGfizkTH88+WMsFVddm
+uju5rw4pM+Adu7yJgT7BXgNHZva51fIethIBB0D5OnsPEPF1mMxPEGZyMfNY60RBItwGlQd6sqi7
+GHOJBTDzPAoWQifXA/qk0nDqC3ikKFIypRnnYWXS0yiO8Qi7fCYh05NUBdwwJYgfy8cZYSDSxrgB
+k2FqB3EGXqcsrPW225CkmfGGrUeYosnUUsXdrChTxK/cfAW9f4N3kr0MA9R0VkD7BF6Lmir0Veum
+AcCkBVqrXPOu6os6N2Tl2ZOU+yq4JQJAgnndhGA2U4+TyFYs90BI3ifpr397t7HSKgQCb1F/QW4i
+KACweORJE3Rx2x0ispkZ4wfosOmT4JT9F7ykdkIN6JLtCoLXIokoUiW6R1eQkwFE8gEb+smlZ+PJ
+uS3HoTaE3FB6GbccYwAg/5H1oMT92nlx2x+tI/ocD136HOnVOPQv/vOa42O2Ipw77fKdZRQg40jq
+ZsB9poO6irjb4tjUDoeVil+MjwffqrytoJS9x/EQ2XTCG+FYyk9iP62N4LVbcMQCObKllGjL/fCb
+EzZcJyEKuQcW914PnZ4vXk8HpUaMdjACNWgDbPwvX7toeb7M09bZR+2MtDCtJFlhUq+fZDXRNGYT
+4wKNQxVCEX/AfuJkcy2uPmzo6yRyrmfaIdug+ypRMMenD3mf/do+rmqZsRL1O13YjH+X2Q6YreKN
+rSShWhHOdZlWuE2X9vyHqbdV6MH4IuypNVtIPdiC339/qeQgCBev10eHQPikdCA0JXgjdSTptUcy
+RRafRK0+FcguBcmsH8O1EIsflAtSCcqPA6y5omYj4uQ3xvwU7aXyzan0ZiYxhMj/ZPYremLSpFTh
+D6s1fO5jprvkZCD4V/Ix5YysOmldJ0X7uZ3wBPCheXNGu/q0qR9ksaWc2V3+Tt7UAPYPPINr7UUF
+69pbU1K2PGaUpSFZpDmrakCF8PgLzcEJpFaNAkstdA7/70w57GHWYu1QgU7dVeb5MXuKerPW5vr0
+scbDXGAWo2wXWvEYQhnPwq6PNwU65M6+5+Wvsfvb6nYIlEEIgeBzzcyHbYPVL004pxWUL582bzkL
+9U9dNMNfldzOr0riziblNxBdO4Fd7L38HAK/Ce8CEHuoorC80GU1CZGYacxuysBnFZnJo5iLSYjE
+XcIsjqRMYk+ZnlkGv5m6hj9zb61PLWVdepnKFmeDVqu97V4kqUR3KLCFJoyZ9UG3F3tz75xhV25F
+lusX9tQ3ddpOqOQH3wZVtdNFSzD3y8xem83aAe128at5jCPlGecKcmqLoA3tJwjst5BVhvcw7+7Z
+aDUDf4bNrli+l//UqoxslWP2TLfH6ZcdI4wdTpEhYQ21vKoavNq0i8k15GN6ENeK4+KFQXOuKzLW
+c7DDZOtgJ7aX2F2j2/FCgS1wjmhthMr3pWgEmg4KdDpnhrVpxzz6/rEYQdU2KTmKy4pTp9nvgTxI
+FBWRT1llRSoQLpSbD/2EHyIJAgf0GpEoQaEavyMN2oIvtDYOJqSGtBCXq7z4mI9qteUUIu8f7eMF
+NGxxFXjst70kYK+SMuT96h9to0TZUQQFtdymiIEVwke4T1a//jN/vkXa9VN3Y45ZuVlA2Y/ORhXK
+n+PaeXR3dNKLpiQUCdHoaJL0vOqXf+TbfQTauCF6jcLMJ4OsVauKBXLzUsadWhZuro6tiHpQL/J0
+ftco43xUFOFMcSjYFZXoKhjUt9I6jdLivG8CuxZebpbwV7TmW8XXKfVDnjHavSj4IpJgA2jS6K0H
+pBK4on+iH7FtehMK8tSVLzUNXy8MvZnvklC2b6XEfNUOq/H1m4VKM9bZhNsba0us5F3lOtX6vS1M
+k9krC8FFvwT8HDYxbBHUFO99FxlIqyVLbhFT7j008NcJv4QNBTmziHY/yZTNUp2/Rlcz5kSRFCNo
+LB+iLx5tawWYaGLT0O9mQpG73zd3cK1oTc4c9uJ2/AtMZOt+nYv5GaUqGPFazxCcZ0HlR6c1TCTZ
+gn7Pe2UgRlCYsAl3768WOxVCcMl/8mCQ0QBzm9tR1mS2JFmQgNhUpjshJkVTJeVaZPOfUNmONfYt
+LEXZ8aLqRqkQIOTcY9uBr3f55WaBDSKpO8VBn442EGn8uIE1FQOEJmmjFnJ5VyU/H1IumyLhvt9o
+EgPBQd8W0+3uyKBhC5sILPru41STAMV6n1+dcxxPpOkUMwoD3RQjrOtpQNk9KMr6wIgfIvPSskMO
+72amqSpq7Bmqf3RNQL4hZuqS0XfYIWD7gAzWHyIPXngp3UXmMDANOsRbPPDyrdm7U0Gwt33ub5DM
+Y9woXbDDZKvk7W0uwlCzJZ2bn4EpK56Yh80laN/V3Rn5fZVP9quN3+3+/lRVeaGGi8Us30MKXYHN
+StHU0DMonSyt/Ef2+aIiVEJp5vuTJiH9dkM4sVg+jQ8/LtwLnAZtRvVCNvZagX/ZPm9J1eH5E6aH
+NKcukVY3iTMQpLkeyZXhJnw+TYJSkpefPLxvCNwD/qewjN7+VcLtXDkrRsrwnjhu4TU2EQK61xK1
+aaVbH91T4GMLZsP4IO8TocnmBHuuyL8LBOcOWeOqiCFLEKHK/4jDWWcQMW9zqqKB+P82JYkEt+gT
++0sOTWHgQjOn4wHrrvCUbQaDQRYwpAsINVQ8N4fFazbUGw/xXdh7MKrfE/azHzcWB6d1XT5rSgtu
+kOQyLfxPJVevYf/JTG8/jtGDHQeb6p2GuIhCBn9m
+=d8b2
+-----END PGP MESSAGE-----
+
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm--
+
+--------------ms030903020902020502030404
+Content-Type: application/pkcs7-signature; name=smime.p7s
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7s
+Content-Description: S/MIME Cryptographic Signature
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExDzANBglghkgBZQMEAgEFADCABgkqhkiG9w0B
+BwEAAKCCA2IwggNeMIICRqADAgECAgEeMA0GCSqGSIb3DQEBCwUAMGQxCzAJBgNV
+BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBW
+aWV3MRIwEAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBMB4X
+DTIyMTIxNTA5MDk1N1oXDTI3MTIxNTA5MDk1N1owgYAxCzAJBgNVBAYTAlVTMRMw
+EQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYD
+VQQKEwlCT0dVUyBOU1MxIDAeBgkqhkiG9w0BCQEWEUFsaWNlQGV4YW1wbGUuY29t
+MQ4wDAYDVQQDEwVBbGljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+ALUryBlKMhddXWPI5hrNm7yrY13tIWVuDxMd+Ytq6tFIS9+5Py3RuGTtBOCx3Tkf
+F4EahfdeA5PC6aXCHNaXnwFLLiA+Eq1EzM/ANr2SHR+iWwuvOZComNYaWigswyWU
+rCGJigGB93bC7i1WczgTwQc0zA3K3PbFai8J7bUAwJ39fUqGE6xeM2+RCtVcdU+d
+tkYBFy1nHz2N9K9XIwTNg4aUqCOONwQvZgcKy+HrUQIBhnAnfODjyyqlGRZunj0E
+zA/0D00LCzUtvdaNA5HV3xWRW6Njt4Sn5WrmAfFsTOf8Xm3l/sgrYaiXd3fmo2c+
+JjBgQ0NV2nzczDbihf6dVYUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAj84R6KF8
+Ve9ZTAw9LdcYnx6+u6emacM/HUESAETBYF2f0f97EASKzUIgtALh0fFXNbzWfc/a
+TzZYek0ilIBFN2LYhkWi69OSLXvQCrYiVBgkmJwf1IFFK+fqr+ZUihdp/URhTuyk
+fH5wnAkYc/Vq4RJWgouujpZVdhsJlvQS+WmnrGLIKRCMQtFfsJ6e6GCgSmhxED4O
+Ds2TnTL1Tq/pECwIwl7iToB2E95RiFRYZz28twV+OmmSY/DQoxKk9Encn5K5BEId
+27iiouVjDMqh+M4qtIrGQiI2Vdcwvb+AUwyrMC6YTKqdnQXC2EsA8g0Dx2tkIx/a
+Gwlma96Op9gWXjGCAtkwggLVAgEBMGkwZDELMAkGA1UEBhMCVVMxEzARBgNVBAgT
+CkNhbGlmb3JuaWExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEjAQBgNVBAoTCUJP
+R1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRlc3QgQ0ECAR4wDQYJYIZIAWUDBAIBBQCg
+ggFBMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwLwYJKoZIhvcNAQkEMSIEILJr
+dzzDNYWQV/M1oK41/rfKXs+hx4nk6HPGaJpiwfmGMHgGCSsGAQQBgjcQBDFrMGkw
+ZDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDU1v
+dW50YWluIFZpZXcxEjAQBgNVBAoTCUJPR1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRl
+c3QgQ0ECAR4wegYLKoZIhvcNAQkQAgsxa6BpMGQxCzAJBgNVBAYTAlVTMRMwEQYD
+VQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYDVQQK
+EwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEeMA0GCSqGSIb3DQEB
+AQUABIIBAAxt7bvYNUpTx8FStpcQ9Z2Zrzw9pwZ2tWlPyFXRZpQMuD5mJKqyvXOL
+X9TBCEJEIloIeFTo0x1KE4iUAtfgv7D8E+YPpVt6sRdGjsn3+htnE/FfAySTSANr
+2I+BQaU05fSdvIHHCJ2OvK4O6JcoG1YzhEvuReBGdzg5NkQnFCp6CtV/vULO5Q7k
+NIjeeCEBVjJY37w6V6iSEmulYfA/0mv0ABKCu513xFVZP8qXQCJ32OrzB1vYwUfL
+bcMDcaubQT+5W6JTnX9VBszRe73Ayo8CCA0WBOnDxY02p1ncs8cRFhmX1kAfZ3qe
+n2eTTmS8ztANppAPYuc9sISwaMkgWqAAAAAAAAA=
+--------------ms030903020902020502030404--
+
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc.eml b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc.eml
new file mode 100644
index 0000000000..a5d8f8b0c8
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-pgp-enc.eml
@@ -0,0 +1,101 @@
+X-Info: File is based on unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f.eml
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Message-ID: <de515a63-a4fe-022e-4a3c-96f07536dbf8@example.com>
+Date: Wed, 14 Oct 2020 14:57:39 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256; boundary="------------ms030903020902020502030404"
+
+This is a cryptographically signed message in MIME format.
+
+--------------ms030903020902020502030404
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//WTEFHnI2KYZJbgTfk8CaREcQpE/beaO1ysXdzCqpdRGWtU2UlbqmPxbu
+PmGDWg5f43qUEgO9mG2zsEvnGKlEoJmBFYaXXGhz/6+OoFY7VI+9DDtAWD5Oi8jzzKnUbyjPQO2a
+16PbLeOs/ydjt5eRNVaUVtnyTXMhp4JMLET1ISQF1FxjJJ00XRnaYzRRt/U6MHzIFLnZGBZYr+tY
+K1z+5vtsU6P0ZfWV/Hh8tFR6oqJ0Tiwji+zKwgUupKwC2QQIFy3j4GGrqJTejXiFfo5U/P4i5h4O
+X5qcnKzCX2spi7CTIJdx+uXKYAW2e9zsQIEQyIFoe8mZLgZcR0OLoH7ledfAeMBmVgS8GlM9uitj
+SWkiLa98gnudZbKiL7KXQ/e/TBLKVTPFtoorpGBmfYeJ6/YV42kQXPwK+ABHbxX52T7Tm7d12LRa
+Q27sp/SwnJYoi3hASA4NKViBi8B2gdV/DHzgsSfvHtEpMvN1LgaREolwESQ6U68yg/EDfohGdPdW
+eRiyo/p4jQ3Yo9v6n/boIxEb7xhkymhwQi2sZ9lyzU4HO18xrZ4sSpTjoMYyQV4ebA8nMqwbNpWn
+ACxWYeMtMdE4p6wJmMY232LlNtEAXkJbJbY+BDlKb9y6uMLBGHhXH4v7G9zaA3nDBWHNHAvP1cAg
+kgqURvqhxkgZqPz40cHBXgNHZva51fIethIBB0AykUD/87/8UHaKZX7MYUWr/CNBP+N68qFTgGp7
+UzMgSTAdpz+xzeC7S4BNoVh2IAg40r+ie38dJDxYJbEyvkhkr2wRhZf8A8z0/eGJczjEP/vSwW0B
+TkGuH9zZrlqH03jXZ0RUTGnA6oBq2wpGrBniHNZRJ7+ImS/cJT5D4uuITVDXl51EgTJQENxmSdyo
+YGe/lNoB4MVTxzmPfjWdOC2FqkGoc4jVzSwGaZ+OfLA/GviucholvaNz/LobZJ/AMXBvCbc3jh6y
+YvcZnjtDFFdUJHPCA4M8staEIVCz63UT5fdoXLWdr62H1NOhxWQDlyoZle+a2oM3FVEdyVKLt98b
+mTIP71YGhVXU4oRCujtiopVxQXzVugXXTEioebMw1+QLZLr663Xo1Kr+nlZlDDFBY9+NGLB7lX7g
+QqNkFUfw55jWFYWsj1N4U3/IzHplh/xGF9KH296ZKnzi66w6YRfp5QVKCT+fahOhxKWkKeTOl9Lf
+saUhPs93QMcVFRSW0igZrTh/fPZcplsgakpYchR9QcevkeHdCizk3CY5uULqMY6blUz3NU/aOMWw
+6fuLtwo1svBm1Vg0yh/mMA7HsRsIIB5xmkXEaP6PwM3WKLN4AZrcErQTwdvJ8HPGnIECCePugHOK
+EPB5JRj3aSp997Xwv+3z74bmp5GisjjtK3wFn8zYr0QI0hivRd9vz943rdh9iIMxCSAglawaqa0i
+BhUfhPIQyOfEWu5MBoIofW97oxnHaQ8/A/Bj2uvCIDUDPD2C50BHuVdtjsW9GlOmQ3ZUwj7llbuq
+O3oUeDzqaMdPgFt1QmfowXEkFAQcwRb0EbNboHX1q3F1QCXLklw3Dww/lw==
+=rZjL
+-----END PGP MESSAGE-----
+
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7--
+
+--------------ms030903020902020502030404
+Content-Type: application/pkcs7-signature; name=smime.p7s
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7s
+Content-Description: S/MIME Cryptographic Signature
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExDzANBglghkgBZQMEAgEFADCABgkqhkiG9w0B
+BwEAAKCCA2IwggNeMIICRqADAgECAgEeMA0GCSqGSIb3DQEBCwUAMGQxCzAJBgNV
+BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBW
+aWV3MRIwEAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBMB4X
+DTIyMTIxNTA5MDk1N1oXDTI3MTIxNTA5MDk1N1owgYAxCzAJBgNVBAYTAlVTMRMw
+EQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYD
+VQQKEwlCT0dVUyBOU1MxIDAeBgkqhkiG9w0BCQEWEUFsaWNlQGV4YW1wbGUuY29t
+MQ4wDAYDVQQDEwVBbGljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+ALUryBlKMhddXWPI5hrNm7yrY13tIWVuDxMd+Ytq6tFIS9+5Py3RuGTtBOCx3Tkf
+F4EahfdeA5PC6aXCHNaXnwFLLiA+Eq1EzM/ANr2SHR+iWwuvOZComNYaWigswyWU
+rCGJigGB93bC7i1WczgTwQc0zA3K3PbFai8J7bUAwJ39fUqGE6xeM2+RCtVcdU+d
+tkYBFy1nHz2N9K9XIwTNg4aUqCOONwQvZgcKy+HrUQIBhnAnfODjyyqlGRZunj0E
+zA/0D00LCzUtvdaNA5HV3xWRW6Njt4Sn5WrmAfFsTOf8Xm3l/sgrYaiXd3fmo2c+
+JjBgQ0NV2nzczDbihf6dVYUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAj84R6KF8
+Ve9ZTAw9LdcYnx6+u6emacM/HUESAETBYF2f0f97EASKzUIgtALh0fFXNbzWfc/a
+TzZYek0ilIBFN2LYhkWi69OSLXvQCrYiVBgkmJwf1IFFK+fqr+ZUihdp/URhTuyk
+fH5wnAkYc/Vq4RJWgouujpZVdhsJlvQS+WmnrGLIKRCMQtFfsJ6e6GCgSmhxED4O
+Ds2TnTL1Tq/pECwIwl7iToB2E95RiFRYZz28twV+OmmSY/DQoxKk9Encn5K5BEId
+27iiouVjDMqh+M4qtIrGQiI2Vdcwvb+AUwyrMC6YTKqdnQXC2EsA8g0Dx2tkIx/a
+Gwlma96Op9gWXjGCAtkwggLVAgEBMGkwZDELMAkGA1UEBhMCVVMxEzARBgNVBAgT
+CkNhbGlmb3JuaWExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEjAQBgNVBAoTCUJP
+R1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRlc3QgQ0ECAR4wDQYJYIZIAWUDBAIBBQCg
+ggFBMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwLwYJKoZIhvcNAQkEMSIEILJr
+dzzDNYWQV/M1oK41/rfKXs+hx4nk6HPGaJpiwfmGMHgGCSsGAQQBgjcQBDFrMGkw
+ZDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDU1v
+dW50YWluIFZpZXcxEjAQBgNVBAoTCUJPR1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRl
+c3QgQ0ECAR4wegYLKoZIhvcNAQkQAgsxa6BpMGQxCzAJBgNVBAYTAlVTMRMwEQYD
+VQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYDVQQK
+EwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEeMA0GCSqGSIb3DQEB
+AQUABIIBAAxt7bvYNUpTx8FStpcQ9Z2Zrzw9pwZ2tWlPyFXRZpQMuD5mJKqyvXOL
+X9TBCEJEIloIeFTo0x1KE4iUAtfgv7D8E+YPpVt6sRdGjsn3+htnE/FfAySTSANr
+2I+BQaU05fSdvIHHCJ2OvK4O6JcoG1YzhEvuReBGdzg5NkQnFCp6CtV/vULO5Q7k
+NIjeeCEBVjJY37w6V6iSEmulYfA/0mv0ABKCu513xFVZP8qXQCJ32OrzB1vYwUfL
+bcMDcaubQT+5W6JTnX9VBszRe73Ayo8CCA0WBOnDxY02p1ncs8cRFhmX1kAfZ3qe
+n2eTTmS8ztANppAPYuc9sISwaMkgWqAAAAAAAAA=
+--------------ms030903020902020502030404--
+
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-smime-enc-sig.eml b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-smime-enc-sig.eml
new file mode 100644
index 0000000000..5b8924f050
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-smime-enc-sig.eml
@@ -0,0 +1,132 @@
+X-Info: Based on alice.env.eml
+MIME-Version: 1.0
+From: Alice@example.com
+To: Bob@example.com
+Subject: clear-signed then enveloped sig.SHA256
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256; boundary="------------ms030903020902020502030404"
+
+This is a cryptographically signed message in MIME format.
+
+--------------ms030903020902020502030404
+Content-Type: application/pkcs7-mime; name=smime.p7m;
+ smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7m
+Content-Description: S/MIME Encrypted Message
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggGFMIIBgQIBADBpMGQxCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIw
+EAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEoMA0GCSqG
+SIb3DQEBAQUABIIBAHexOdzTP5dR9Th1QeZisCXDy1nfKdQWV8jgoQK9Dp99xCks
+7ch5TS8dy8mx08pZ4Fhcd1nPmSGeLLsvPwl/gSrpF3zbet6RcohfjzbwDN+wqsym
+wEsDqL5Kaq/kvO4o66hP0VZY9T6O9rknWByAoILMVSPrE+8EoTJtxDaDtRh5C222
+cLESR2Op3sRL+kXUDLg42Fw2XSifK/9jdm7+U2sXX21GJzs98pzXGVBpBjjSyYrL
+AABje4PaI7RKeMmBwJ5Z39XMIbRcdpGax8YPrbl0wRdIP6kOmb9T2yo77xu4xOnV
+pqklrkkoagtNfA6Rx0ccj293z55nZdAFxZw525wwgAYJKoZIhvcNAQcBMB0GCWCG
+SAFlAwQBAgQQbHD/jpGgTyDRzqaPrf2mRKCABIILcPpHQ5TPhOWBqqzdXIwcy3Ci
+OpOkRTGMB05Q0aO93zcaiPUt01ccHN7VQ30gh5WjthTORFBv3N7GbUIT0bnVLjFk
+aZ8/VTpYj8zPUIAhgnehoOmkCrUkOX7WFgzhaBoZqcIEhF2B3MGmZVZYm0uPth/P
+it8SD2xfcqpf3nmo+rHP1E419ywhLDT1gzZ/jAohutj+iO9wolSNI5gsdtYeBiWj
+ghSkq9CPTdYpM3xD3nYs7XJl4QXEDw273TIPA0DSVU6j7VUu7d1m/7FJYgZCfVdL
+sA57BX5d2DEif0kpparwRARpGBSdwrfu7ztF9xh0mljNvY/dH8xe0ZuMcNBSuCgZ
+5tw+O/J5JQaoZT0XFUcPkILfk7JoR4eELLAV/tEWgPkAgpr53TX5Lqs6JZRdxXK4
+7Hb3fMIt1dN9lrleSqrcbpiy4527yMsFhT7n5IWmFzQUcF70Rk/+SXJ3yL9yNk0x
+e+2xASqhJxW82iGRwbzyoehhaXv/FxExMsGgRTL0gaP4GM0Dyer3/CE++oB6qhyw
+DLMFqEdC8JUnfpprdh2liYP1ClccjaZCZDhmLqRlby7z0uk5f5sNvqer4l/74yPV
+fs71AMaOBx1NlB7r2STCtjH7B+XZSugxUIsWj0rWBigzKlNzM3H4uefFAqfex18B
+WWqi8zPwhc1zIfX3zxGckeuepWBThhg/9BPZhmNE/fl0b3S9p0JGmUQ/LgBMR8KX
+SBpuf2vhlWNci0EsK8gEE8xgb8UChB2ecy2OJSTkPZMMGGP7QjT0T9zGqsEUWEEq
+8hqh/y3s0Ccg04T5Dfwhz3KYVu2XxkIatOS/ZjVLie5rKQZdNtnFdHhX9FbEkMfn
+2AV0mZMg4WKdySP9b0MmUBcxV1P/1IUOes+Gwm4H5csZjyhKiC072/7jmordxed7
+7txyGSk6/0KyST2pfcTla8LM9pRf92FCj2ggRuGSJT//SO+wbE77G6FJlG3jpl9D
+ZujaEKWJBCvADI5UxDJEzGaCEFU1tWi+jrlaB7Jsg8cndsS8zGPCdXcePI1J+FYS
+9u2Xcit5gH5vX2W35gkU8e+vGUSEm7sVPQUwO/2PKUx5eu25KL1I3CqVl+mFnyuZ
+FXihjnoi2P+GGHRBrh2o17RMYnBjPgMpwXfTbZv4ORanmGuDXYQ5CZUZOKQ6FpyD
+YznElmG6AD3MLxKqDalfeigpYB2zLouAsma2Dxa74bD0lJ5ymtnNBcCigJVeidEV
+tO/RYjLRZAoAJi20TD8cLP0u56YqW65499Bk6DjJlKL4cQXNLJ+Cw8e0GTxN1a2k
+I+7dvfuMksz6IVegXHOKlGDLtK0ar1x/6ZjTxUoN8OU58GgnQZyttplTuZy+oOa8
+MdMJHFt0VNC0nXUu8mqor3EbgjnGFgkaEMsevjmi1fXYPwi0/UAooL8FFbfRUx4P
+doPFtOGNYtjgFWLnaVUKtC06JHhaSCF8UtVMykniY2athWPNc2SMcBU20CfZNJE4
+vtCDkMa2uHDIThYWUCXMoW6nTyEDPTjsblBXoFundj/eAUJgiJT3F3RUIj/MX4XL
+olic8lJVcPke1O+twKQNrfSW3/wH/RmOk8YjKew7G7y4Avw/T3jCgv5bt2U+jy25
+KJYOI8V/bFTMxOQR4CzS4/ztBGlLh5sHD8yVOP7i5YuO28fp239dJWKoYhXBtbt4
+PEzHK4evOMbbu41ysUz/tej/u6fhnEERAYJH6isFE2Kvo3V2j/FLokb1JgW2IPaM
+E6kbgI4r6FQW1oghGMyDMiIQoufocyZWzy9qY4U8WjxZ+cLysxcl0ff/5gs+d0Aw
+8awIKxQPbhHvJXtZQVMB0GKTmtVjTYKVcE0NTnwKRt77Na14pTRaCsIeZ/ZKSYeV
+lxQ4eShv8+bTAiCRknNu6hagrZmOXFdbtWiJTUIIofTFE7RqtG+kuD39dKP6TXHH
+V658uSE3cpnSl47j4KxC2214ZZFs12TsAXU9eaKqPdxVFH2Ef5q95uhEB55aDOl+
+29LcgxyBP1tyIAx4tjZItIefOkquwZtcTqyUbymr56dKA02vFgmuFLHGA6Eh4aYP
+YE4j/4AzG3Cv1G0q7Yo6sGTNkv0VvoR5B52JtU9M0EjoN0gPtjHaQHJI0KFIKvHP
+SEUzmszHhDLVsTdvlQBteXT4WOMg94mFS5rb7JI7XwsCFkR6z7ufz2ignmwpFhya
+Ucl+jOn/mkk1Ct8dJqbTvuhxXOiIURQq071msiV8ImLBHZY9Rc5DAXia84ltFvr6
+k0Vh5/rMzKUnGqw8BG+I3iJ5nXmp/q4zuR3dPr/M7/VTwGpO16sD9pN6XGxYoWqv
+YW1QP7z3ODVO4S8nyaQea6E8GaSg2zozzR1KFIAwEybBLvSWHUS0T94SWtV4RmTA
+mYsSvV015Z/TCgxuQJe23iG/UO1ACcEsFmjlBgM9KPi18joGtUiD48hzT68jQomQ
+n4a6GfrNYhE1NZqNisG33U89djnkmKNfS2e1TMS47S8fJF3PXwyofM123tVXwfUm
+i7Z+TzfC+5g4ERpgGWT8Qkycqroj7VK6qZTn5nBLUmmNqFs2G8FGfwgwgiVGeIMj
+HqoxqwfWxnRkHw3+K5f5+HOycs9q6/DR6tam4gSrnm3tCSnBpFtJZ3oUqOUu7CrZ
+l5PPnwOyoQRiiZQFyIB9SaN+XjdC0aAl5Cxrr99zGUM2WOjjPT5z/eo9cIgVdiRe
+ywNqxtPXZEvk2k5Fwk+NIkgDLNg9w+2eA+TGaRtSFQiqWAz9jg4/UEgwU01piwoZ
++1AjO0zySeEQv4CFzhrpbE2e2T+xeowZhaT8p03zLfui89Gmt8nDrGLvahesZXWW
+PoBmbGU1z4p8+swl2TYjTQrxUrqiUiN6fK4QkxxlnyoaGKWr/zRrmBPMWsWFyTj1
+KO0VoB0gvkINgFPq4T9P1eIQNYiZ528Z+FiMXUxurhUSmp6bzKloHwF5KWR3jCtI
+tW+x8s9f/d/xLn3QU/1086ptS/zRSdoV64PEGUy4esYCI21/oSMseSb2+/gTNTTe
+W2gF2xaTdet2s90w7bETmLiCcwniQ6FdkDo5BGys9B3BA0g/johagcGb3umIggb/
+/W+WxrLDVnUp/SjeLhnrKTwyiBVnHbYNAh8Olyj9s9exkVaR2n2E5PydTKYamtdp
+7jXnMgCqj3Xp4TUtmTkJc6WDYm73E1h88pcHtmaMiQpDcaCmhnDxNuF3nSSNnt5T
+K3N+Il7+WuFFSFOBNxdI67gbNwZJGIyjltDkbj+gLdtFE9f+MWutU+jy4YOB66OX
+8p0LV1eToy1zBYytfa0OTsQJE2CRQzc4dZIPjgyM+ZRk2mUBgn7SHhkIp5kSMGT+
+OTa47iMm7uMpetxjf4Usi14nH0zU6vdShEsoBuaha/EVQD0u0JfYlrHtISqLW2b/
+kvtwaFO+YIYSDwYg5iW0G9YEpGG7uFUzObyp/aWyJlzA0AhWoHnOPj9hS+QhFav+
+buk+TXlSQy5ByUMKbw/D0ouiviZGY5xpgtSxholTsG2xYAOPDfSqupKlhMzbPJ3o
+10GKfz3TCjI17YvsneytR4kCzvlEfOFI/XDrCWOza5i1XjTw5lfHwFDJ9aEQipyk
+X5tPqWDqrltH2RvQMHC475jM6c/eRlZC+CyvgGfGyw9T8cll6jzKMe4UKYcJ6YHS
+SSqLh248eQk+8DKsN1NrvtUCnxwACquYUrkFCxHjD0qQlr1/rVD4OmvyGWRl6f3i
+SuRnjnnjc1hz0C6lwzsVFRIO6muKbQjCzJ/oEtMnoewIPsOai2QRiW5DPJo8dqHv
+pjCLcCQWo8vt6xO3eQTyQzrqIB5RkBApXdC+LVv+RDbX8FaJaPB0RxYgv+tyPyeS
+3icHKSDKE4SBVX85wiplV3Cp0wiT+v7g2kvRWpIArAQQ1pReRfeSBHpV6v0LFy6h
+eQAAAAAAAAAAAAA=
+
+--------------ms030903020902020502030404
+Content-Type: application/pkcs7-signature; name=smime.p7s
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7s
+Content-Description: S/MIME Cryptographic Signature
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExDzANBglghkgBZQMEAgEFADCABgkqhkiG9w0B
+BwEAAKCCA2IwggNeMIICRqADAgECAgEeMA0GCSqGSIb3DQEBCwUAMGQxCzAJBgNV
+BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBW
+aWV3MRIwEAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBMB4X
+DTIyMTIxNTA5MDk1N1oXDTI3MTIxNTA5MDk1N1owgYAxCzAJBgNVBAYTAlVTMRMw
+EQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYD
+VQQKEwlCT0dVUyBOU1MxIDAeBgkqhkiG9w0BCQEWEUFsaWNlQGV4YW1wbGUuY29t
+MQ4wDAYDVQQDEwVBbGljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+ALUryBlKMhddXWPI5hrNm7yrY13tIWVuDxMd+Ytq6tFIS9+5Py3RuGTtBOCx3Tkf
+F4EahfdeA5PC6aXCHNaXnwFLLiA+Eq1EzM/ANr2SHR+iWwuvOZComNYaWigswyWU
+rCGJigGB93bC7i1WczgTwQc0zA3K3PbFai8J7bUAwJ39fUqGE6xeM2+RCtVcdU+d
+tkYBFy1nHz2N9K9XIwTNg4aUqCOONwQvZgcKy+HrUQIBhnAnfODjyyqlGRZunj0E
+zA/0D00LCzUtvdaNA5HV3xWRW6Njt4Sn5WrmAfFsTOf8Xm3l/sgrYaiXd3fmo2c+
+JjBgQ0NV2nzczDbihf6dVYUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAj84R6KF8
+Ve9ZTAw9LdcYnx6+u6emacM/HUESAETBYF2f0f97EASKzUIgtALh0fFXNbzWfc/a
+TzZYek0ilIBFN2LYhkWi69OSLXvQCrYiVBgkmJwf1IFFK+fqr+ZUihdp/URhTuyk
+fH5wnAkYc/Vq4RJWgouujpZVdhsJlvQS+WmnrGLIKRCMQtFfsJ6e6GCgSmhxED4O
+Ds2TnTL1Tq/pECwIwl7iToB2E95RiFRYZz28twV+OmmSY/DQoxKk9Encn5K5BEId
+27iiouVjDMqh+M4qtIrGQiI2Vdcwvb+AUwyrMC6YTKqdnQXC2EsA8g0Dx2tkIx/a
+Gwlma96Op9gWXjGCAtkwggLVAgEBMGkwZDELMAkGA1UEBhMCVVMxEzARBgNVBAgT
+CkNhbGlmb3JuaWExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEjAQBgNVBAoTCUJP
+R1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRlc3QgQ0ECAR4wDQYJYIZIAWUDBAIBBQCg
+ggFBMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwLwYJKoZIhvcNAQkEMSIEILJr
+dzzDNYWQV/M1oK41/rfKXs+hx4nk6HPGaJpiwfmGMHgGCSsGAQQBgjcQBDFrMGkw
+ZDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDU1v
+dW50YWluIFZpZXcxEjAQBgNVBAoTCUJPR1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRl
+c3QgQ0ECAR4wegYLKoZIhvcNAQkQAgsxa6BpMGQxCzAJBgNVBAYTAlVTMRMwEQYD
+VQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYDVQQK
+EwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEeMA0GCSqGSIb3DQEB
+AQUABIIBAAxt7bvYNUpTx8FStpcQ9Z2Zrzw9pwZ2tWlPyFXRZpQMuD5mJKqyvXOL
+X9TBCEJEIloIeFTo0x1KE4iUAtfgv7D8E+YPpVt6sRdGjsn3+htnE/FfAySTSANr
+2I+BQaU05fSdvIHHCJ2OvK4O6JcoG1YzhEvuReBGdzg5NkQnFCp6CtV/vULO5Q7k
+NIjeeCEBVjJY37w6V6iSEmulYfA/0mv0ABKCu513xFVZP8qXQCJ32OrzB1vYwUfL
+bcMDcaubQT+5W6JTnX9VBszRe73Ayo8CCA0WBOnDxY02p1ncs8cRFhmX1kAfZ3qe
+n2eTTmS8ztANppAPYuc9sISwaMkgWqAAAAAAAAA=
+--------------ms030903020902020502030404--
+
diff --git a/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-smime-enc.eml b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-smime-enc.eml
new file mode 100644
index 0000000000..f698617aea
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/outer-smime-bad-sig-inner-smime-enc.eml
@@ -0,0 +1,74 @@
+X-Info: Based on alice.dsig.SHA256.multipart.env.eml
+MIME-Version: 1.0
+From: Alice@example.com
+To: Bob@example.com
+Subject: enveloped
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256; boundary="------------ms030903020902020502030404"
+
+This is a cryptographically signed message in MIME format.
+
+--------------ms030903020902020502030404
+Content-Type: application/pkcs7-mime; name=smime.p7m;
+ smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7m
+Content-Description: S/MIME Encrypted Message
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggGFMIIBgQIBADBpMGQxCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIw
+EAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEoMA0GCSqG
+SIb3DQEBAQUABIIBAFcGoL24XUZv8ZnBG1ld76tZ/AT9ZXCiNLexfkVSp/1hr9CU
+Ilz/fOQ7nOdNqYWaiLEzXDrgyjVHlLbOEKwXVXLVwy+RQgsTSbFYhFweqa4IinoT
+g8Q4/xkXquoQkk8XHPwavkKenjZljbwab0c4D2CwpfsKV0JeWpCNAOIZRiCrG+Aj
+M4KTkIgXFMuWYDGX6EhfTxqgCEMNnfKwhwYafBI+m/O8yW7MjBoSEIOae6tEk31E
+Jt4UEC4E7x2IXaU8yIZb0X5Knl72KcWP4RqO/Ym29xssTzXhW6ocxLgPPKY7OUMf
+MW6PkJuHkgTGwHK42FhX6xDsBx75MKfNTYQA3CUwgAYJKoZIhvcNAQcBMB0GCWCG
+SAFlAwQBAgQQQnhVDuViXS7I+UouwGJhmqCABIGgJ/I1Q7RWQEsw+9NwBxeDhfJg
+AMNHdjoKxe6UgP10Cag2A+P/37OFQ6avwSQXcFOnoVgr+ewn+AmmeTGBbxcHmmuX
+1lRd8TyZJcf7NqKaE/pqlSReKbBwTthBIxhP44T652CWSlZkINBPvmRHLZiymxG1
+ggTnmOsoUt2IR5R7KVaxJ/zQBFD0Q0Tug7wF/py3YHlqKeL0VFhA1/VfvLO1MgQQ
+dialx9mgeNqWNvJQ7r3j7wAAAAAAAAAAAAA=
+
+--------------ms030903020902020502030404
+Content-Type: application/pkcs7-signature; name=smime.p7s
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7s
+Content-Description: S/MIME Cryptographic Signature
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExDzANBglghkgBZQMEAgEFADCABgkqhkiG9w0B
+BwEAAKCCA2IwggNeMIICRqADAgECAgEeMA0GCSqGSIb3DQEBCwUAMGQxCzAJBgNV
+BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBW
+aWV3MRIwEAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBMB4X
+DTIyMTIxNTA5MDk1N1oXDTI3MTIxNTA5MDk1N1owgYAxCzAJBgNVBAYTAlVTMRMw
+EQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYD
+VQQKEwlCT0dVUyBOU1MxIDAeBgkqhkiG9w0BCQEWEUFsaWNlQGV4YW1wbGUuY29t
+MQ4wDAYDVQQDEwVBbGljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+ALUryBlKMhddXWPI5hrNm7yrY13tIWVuDxMd+Ytq6tFIS9+5Py3RuGTtBOCx3Tkf
+F4EahfdeA5PC6aXCHNaXnwFLLiA+Eq1EzM/ANr2SHR+iWwuvOZComNYaWigswyWU
+rCGJigGB93bC7i1WczgTwQc0zA3K3PbFai8J7bUAwJ39fUqGE6xeM2+RCtVcdU+d
+tkYBFy1nHz2N9K9XIwTNg4aUqCOONwQvZgcKy+HrUQIBhnAnfODjyyqlGRZunj0E
+zA/0D00LCzUtvdaNA5HV3xWRW6Njt4Sn5WrmAfFsTOf8Xm3l/sgrYaiXd3fmo2c+
+JjBgQ0NV2nzczDbihf6dVYUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAj84R6KF8
+Ve9ZTAw9LdcYnx6+u6emacM/HUESAETBYF2f0f97EASKzUIgtALh0fFXNbzWfc/a
+TzZYek0ilIBFN2LYhkWi69OSLXvQCrYiVBgkmJwf1IFFK+fqr+ZUihdp/URhTuyk
+fH5wnAkYc/Vq4RJWgouujpZVdhsJlvQS+WmnrGLIKRCMQtFfsJ6e6GCgSmhxED4O
+Ds2TnTL1Tq/pECwIwl7iToB2E95RiFRYZz28twV+OmmSY/DQoxKk9Encn5K5BEId
+27iiouVjDMqh+M4qtIrGQiI2Vdcwvb+AUwyrMC6YTKqdnQXC2EsA8g0Dx2tkIx/a
+Gwlma96Op9gWXjGCAtkwggLVAgEBMGkwZDELMAkGA1UEBhMCVVMxEzARBgNVBAgT
+CkNhbGlmb3JuaWExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEjAQBgNVBAoTCUJP
+R1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRlc3QgQ0ECAR4wDQYJYIZIAWUDBAIBBQCg
+ggFBMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwLwYJKoZIhvcNAQkEMSIEILJr
+dzzDNYWQV/M1oK41/rfKXs+hx4nk6HPGaJpiwfmGMHgGCSsGAQQBgjcQBDFrMGkw
+ZDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDU1v
+dW50YWluIFZpZXcxEjAQBgNVBAoTCUJPR1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRl
+c3QgQ0ECAR4wegYLKoZIhvcNAQkQAgsxa6BpMGQxCzAJBgNVBAYTAlVTMRMwEQYD
+VQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIwEAYDVQQK
+EwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEeMA0GCSqGSIb3DQEB
+AQUABIIBAAxt7bvYNUpTx8FStpcQ9Z2Zrzw9pwZ2tWlPyFXRZpQMuD5mJKqyvXOL
+X9TBCEJEIloIeFTo0x1KE4iUAtfgv7D8E+YPpVt6sRdGjsn3+htnE/FfAySTSANr
+2I+BQaU05fSdvIHHCJ2OvK4O6JcoG1YzhEvuReBGdzg5NkQnFCp6CtV/vULO5Q7k
+NIjeeCEBVjJY37w6V6iSEmulYfA/0mv0ABKCu513xFVZP8qXQCJ32OrzB1vYwUfL
+bcMDcaubQT+5W6JTnX9VBszRe73Ayo8CCA0WBOnDxY02p1ncs8cRFhmX1kAfZ3qe
+n2eTTmS8ztANppAPYuc9sISwaMkgWqAAAAAAAAA=
+--------------ms030903020902020502030404--
+
diff --git a/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-alice-html.eml b/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-alice-html.eml
new file mode 100644
index 0000000000..43be338d8a
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-alice-html.eml
@@ -0,0 +1,31 @@
+From: "Bob" <bob@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: Inline Encrypted for Alice, with extra text in multipart HTML
+Date: Wed, 14 Apr 2021 01:01:01 +0000
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494"
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+prefix
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+-----BEGIN PGP MESSAGE-----
+
+hE4DR2b2udXyHrYSAQdAq+9AhQzI4XpD9WtuB7f3OZHNFvdHza5WND3yLgxX8kwg
+eXs+jZGr3TNUpR+XRxCf9+7Er2JyJk7fvL4suUHpHEzSbQG57r4TxneCcV9pukK3
+wzSqNt2o/q/eVO6WwOs3Lo5+31gs9+z6lrVhVjO2cynPdjlNLCQlwRudsQfpNgrF
+4pO7n0tCrX0qWaKYgdQuJwIt1HS2nLYd+ryb9eLWO/Xhy3quo8YpD0yueSHjexI=
+=rSoz
+-----END PGP MESSAGE-----
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+suffix
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494--
diff --git a/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-alice-plaintext.eml b/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-alice-plaintext.eml
new file mode 100644
index 0000000000..9fe370de30
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-alice-plaintext.eml
@@ -0,0 +1,19 @@
+From: "Bob" <bob@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: Inline Encrypted for Alice, with extra plaintext
+Date: Wed, 14 Apr 2021 01:01:02 +0000
+MIME-Version: 1.0
+Content-Type: text/plain
+
+prefix
+
+-----BEGIN PGP MESSAGE-----
+
+hE4DR2b2udXyHrYSAQdAq+9AhQzI4XpD9WtuB7f3OZHNFvdHza5WND3yLgxX8kwg
+eXs+jZGr3TNUpR+XRxCf9+7Er2JyJk7fvL4suUHpHEzSbQG57r4TxneCcV9pukK3
+wzSqNt2o/q/eVO6WwOs3Lo5+31gs9+z6lrVhVjO2cynPdjlNLCQlwRudsQfpNgrF
+4pO7n0tCrX0qWaKYgdQuJwIt1HS2nLYd+ryb9eLWO/Xhy3quo8YpD0yueSHjexI=
+=rSoz
+-----END PGP MESSAGE-----
+
+suffix
diff --git a/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-carol-html.eml b/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-carol-html.eml
new file mode 100644
index 0000000000..c05375c6b3
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-carol-html.eml
@@ -0,0 +1,41 @@
+From: "Bob" <bob@openpgp.example>
+To: "Carol" <carol@openpgp.example>
+Subject: Inline Encrypted for Caron, with extra text in multipart HTML
+Date: Wed, 14 Apr 2021 01:01:03 +0000
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494"
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+prefix
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+-----BEGIN PGP MESSAGE-----
+
+hQIMA7L9So5P9bk9ARAA01Dr6NF4RrED8YADJx3WOOhIgUd55axpniQBPr66Xp6l
+mOCmPJnpSTA6DJzu28AyilrOedVx4rBfY9Bs4WS6d9joFo+zj+aoo9p8nBt2tu/8
+Ox8DbwWgVoQ4Po72LfZdH9FsPzfi4I2ytp/tjfHqjgl9Aakkm3vj09OM15aEfYxd
+oOO0DFk4vyuWojE6u1Y1bdg20hqPvmciyRFfgZC/51swcwFIDm34R20ASRzrwu1h
+kRUHEiGuz3JXPQnzADmK9OcLso9OxGX80WDe136BJG/wgFKqxCwmv0ykplOTJcm3
+VFWAJzguT27JGvJ+2/93IA2uyz1b7Rr4Ly6s6k9zhAPsDudyI2pOiIVpWWSGwiPW
+kX78JB5jvgjNs3Wh1dHp/TyKqivQz1qbgH2M78sKUdmbCHiD3Ak5b3F8h3JhrU7X
+3zwamZMmove1bkScL4TGXBMMZhwUe6BPg3fq42UHLtXccHlfm+XRWgHbT0su4ezZ
+2n3+5YDYozyfmytxZ7jeL9OSbQTNbNZmA+GTf10RX8ww9wwiffLNe8LGdwNaL96i
+F2lsC1Hfczw16fThBIEx7UF9LmJzzPNN7aOf+fbOLvkQFSPiGZg9tmXGzzRjDboT
+VKhpbV3GNFsCoOcry3Q7xzDgKiWXkdqCTqP9AGJfksQ0mdLQB94tfBYouX+g7PLS
+bQEyf6is0CReegpCSbSUZXURLPo381LrYdpV/PA0L+MDZse3xEKpc69zmE+H4oAT
+t/zF/Bh0ezxbN0AAHyldj82I7CP+Vtat0qSnUGsbd7G5nswwswxHXTA+FfDo+qBl
+U70ypxk/ZqPWcAAEkBo=
+=zsMo
+-----END PGP MESSAGE-----
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+suffix
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494--
diff --git a/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-carol-plaintext.eml b/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-carol-plaintext.eml
new file mode 100644
index 0000000000..d9d6450402
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/partial-encrypt-for-carol-plaintext.eml
@@ -0,0 +1,29 @@
+From: "Bob" <bob@openpgp.example>
+To: "Carol" <carol@openpgp.example>
+Subject: Inline Encrypted for Carol, with extra plaintext
+Date: Wed, 14 Apr 2021 01:01:04 +0000
+MIME-Version: 1.0
+Content-Type: text/plain
+
+prefix
+
+-----BEGIN PGP MESSAGE-----
+
+hQIMA7L9So5P9bk9ARAA01Dr6NF4RrED8YADJx3WOOhIgUd55axpniQBPr66Xp6l
+mOCmPJnpSTA6DJzu28AyilrOedVx4rBfY9Bs4WS6d9joFo+zj+aoo9p8nBt2tu/8
+Ox8DbwWgVoQ4Po72LfZdH9FsPzfi4I2ytp/tjfHqjgl9Aakkm3vj09OM15aEfYxd
+oOO0DFk4vyuWojE6u1Y1bdg20hqPvmciyRFfgZC/51swcwFIDm34R20ASRzrwu1h
+kRUHEiGuz3JXPQnzADmK9OcLso9OxGX80WDe136BJG/wgFKqxCwmv0ykplOTJcm3
+VFWAJzguT27JGvJ+2/93IA2uyz1b7Rr4Ly6s6k9zhAPsDudyI2pOiIVpWWSGwiPW
+kX78JB5jvgjNs3Wh1dHp/TyKqivQz1qbgH2M78sKUdmbCHiD3Ak5b3F8h3JhrU7X
+3zwamZMmove1bkScL4TGXBMMZhwUe6BPg3fq42UHLtXccHlfm+XRWgHbT0su4ezZ
+2n3+5YDYozyfmytxZ7jeL9OSbQTNbNZmA+GTf10RX8ww9wwiffLNe8LGdwNaL96i
+F2lsC1Hfczw16fThBIEx7UF9LmJzzPNN7aOf+fbOLvkQFSPiGZg9tmXGzzRjDboT
+VKhpbV3GNFsCoOcry3Q7xzDgKiWXkdqCTqP9AGJfksQ0mdLQB94tfBYouX+g7PLS
+bQEyf6is0CReegpCSbSUZXURLPo381LrYdpV/PA0L+MDZse3xEKpc69zmE+H4oAT
+t/zF/Bh0ezxbN0AAHyldj82I7CP+Vtat0qSnUGsbd7G5nswwswxHXTA+FfDo+qBl
+U70ypxk/ZqPWcAAEkBo=
+=zsMo
+-----END PGP MESSAGE-----
+
+suffix
diff --git a/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-bob-html.eml b/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-bob-html.eml
new file mode 100644
index 0000000000..2ba377e24d
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-bob-html.eml
@@ -0,0 +1,41 @@
+From: "Bob" <bob@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: Inline Signed by Bob, with extra text in multipart HTML
+Date: Wed, 15 Apr 2021 17:55:59 +0200
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494"
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+prefix
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA512
+
+Insert a coin to play your personal lucky melody.
+-----BEGIN PGP SIGNATURE-----
+
+iQHIBAEBCgAyFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAmB4Yg8UHGJvYkBvcGVu
+cGdwLmV4YW1wbGUACgkQ+/zIKgFeczC6twv/fYtlE8oNqhP5OzR48/rCEmJQ/U8Z
+NIp2Mvg3fpIMY1m2z4nwufCj4xNHM4okyqXnVouWBSLkRL3oPlkXj+syY1lV3Bv2
+Gbl5JMmpMbdSjKAEg7VaYg9C6ELbb25EhBLok1JYMXn5o+wfmm+UN+EU8IbXck5Q
+roFNueM6wFv6nvM64jQIkqoyJ2OvNYg1lTJXp7EXEnwRRIW9IDd1XInVrx4jou3Q
+Ax4/VbyJQiE37JC6NAJ9hBh/noO36IGAXvBeyN/TVOBySBFC1XoZdhjVoA7eWbZY
+m1Pxtar5P1Pb6Nac2c4b8Z1FHZFd81zYbJZkJYG6oApbOBFsn+Lf1+LkVKAiewos
+A91QVSP9pqiJmWFZ17tCxRM5YPIPRT35nV3TN3snHGsNvvAJ9mc3YOO7aM0aitx7
+1p3IqdFUz3G8qUlMDthV4WDBj7N1LnRyKCRU6W58hoDXLEjYXMBYSP8+UHqS953M
+ILOfsOglDqxjdwrNf2TK9y+zpyXX16yI1eHB
+=1Be6
+-----END PGP SIGNATURE-----
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+suffix
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494--
diff --git a/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-bob-plaintext.eml b/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-bob-plaintext.eml
new file mode 100644
index 0000000000..94efb8d3b3
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-bob-plaintext.eml
@@ -0,0 +1,29 @@
+From: "Bob" <bob@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: Inline Signed by Bob, with extra plaintext
+Date: Wed, 15 Apr 2021 17:55:59 +0200
+MIME-Version: 1.0
+Content-Type: text/plain
+
+prefix
+
+-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA512
+
+Insert a coin to play your personal lucky melody.
+-----BEGIN PGP SIGNATURE-----
+
+iQHIBAEBCgAyFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAmB4Yg8UHGJvYkBvcGVu
+cGdwLmV4YW1wbGUACgkQ+/zIKgFeczC6twv/fYtlE8oNqhP5OzR48/rCEmJQ/U8Z
+NIp2Mvg3fpIMY1m2z4nwufCj4xNHM4okyqXnVouWBSLkRL3oPlkXj+syY1lV3Bv2
+Gbl5JMmpMbdSjKAEg7VaYg9C6ELbb25EhBLok1JYMXn5o+wfmm+UN+EU8IbXck5Q
+roFNueM6wFv6nvM64jQIkqoyJ2OvNYg1lTJXp7EXEnwRRIW9IDd1XInVrx4jou3Q
+Ax4/VbyJQiE37JC6NAJ9hBh/noO36IGAXvBeyN/TVOBySBFC1XoZdhjVoA7eWbZY
+m1Pxtar5P1Pb6Nac2c4b8Z1FHZFd81zYbJZkJYG6oApbOBFsn+Lf1+LkVKAiewos
+A91QVSP9pqiJmWFZ17tCxRM5YPIPRT35nV3TN3snHGsNvvAJ9mc3YOO7aM0aitx7
+1p3IqdFUz3G8qUlMDthV4WDBj7N1LnRyKCRU6W58hoDXLEjYXMBYSP8+UHqS953M
+ILOfsOglDqxjdwrNf2TK9y+zpyXX16yI1eHB
+=1Be6
+-----END PGP SIGNATURE-----
+
+suffix
diff --git a/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-carol-html.eml b/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-carol-html.eml
new file mode 100644
index 0000000000..2e1e69038d
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-carol-html.eml
@@ -0,0 +1,44 @@
+From: "Carol" <carol@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: Inline Signed by Carol, with extra text in multipart HTML
+Date: Wed, 14 Apr 2021 01:01:07 +0000
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494"
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+prefix
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA512
+
+Insert a coin to play your personal lucky melody.
+-----BEGIN PGP SIGNATURE-----
+
+iQJGBAEBCgAwFiEEuPL29L060/gtxEaDMJn/EjiFK58FAmB4YicSHGNhcm9sQGV4
+YW1wbGUuY29tAAoJEDCZ/xI4hSuflLwP/1wmmla7bXjzbyIGFnSiC+xMT0vcos+s
+uv4jdcC1cPxpCj51EZEQGLzmKUMJaD1ruK7AnimhA55tb22NetDW0OHA917VeuoI
++cY1Hm8YqJI9LF9KbnzfbTtqeAcFKPjQe7OBFIvru3Z38Ng2JTnRXkM0xolZjpOz
+m14f241+LT62xQwKW3rlG3FLW1yWdVQ5vi8jptbZrhC4J7B2Mzhgt1BX0aV/IK69
+3heQKQIttjslwy2ka8IusfSgPiioSBSULcmlN+FV9kKPNCVAoFvjpGRR9hJfZ92E
+6ESuYdphCH+M8FTSKBrKrX6hvl21SpHS0qExr1Xh3MYJvE+8jX0egjuf32Rf/io8
+LYJ/aiBpkDbikCY8rQUD7+HmHGvCiN8tGakeIbjkS3V0vMA3WsZJPtUt/dmVaVHw
+TPuXUMnhbQpuqXI6K175WnzHFaOXoV67AVhLqM6CZTdhJLUz1NNVvaSJ1P8nxAz+
+wEEh33138gtuWfsT4xfaitbQ2KqmkVLvu1CJ7k1+GiEOxWNiPgSHo5Z/Iimi4VB3
+Bwxk77iZOOinqmlhd680s9UK/AnZxJ1I+5NYKx8yuATWYmYoorDKZLvJxZH5DPz5
+XTu+v79iGvIXcSLyOsvcMDLLnA6tj4pBRB+MgxweiVjHfrdvG7ohDwXgklI39I62
+eO84IXMU+peK
+=Kjsi
+-----END PGP SIGNATURE-----
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494
+Content-Type: text/html; charset=utf-8
+
+suffix
+
+--32989E6E4C7AEB7775BAD49432989E6E4C7AEB7775BAD494--
diff --git a/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-carol-plaintext.eml b/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-carol-plaintext.eml
new file mode 100644
index 0000000000..70aef875e4
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/partial-signed-from-carol-plaintext.eml
@@ -0,0 +1,32 @@
+From: "Carol" <carol@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: Inline Signed by Carol, with extra plaintext
+Date: Wed, 14 Apr 2021 01:01:08 +0000
+MIME-Version: 1.0
+Content-Type: text/plain
+
+prefix
+
+-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA512
+
+Insert a coin to play your personal lucky melody.
+-----BEGIN PGP SIGNATURE-----
+
+iQJGBAEBCgAwFiEEuPL29L060/gtxEaDMJn/EjiFK58FAmB4YicSHGNhcm9sQGV4
+YW1wbGUuY29tAAoJEDCZ/xI4hSuflLwP/1wmmla7bXjzbyIGFnSiC+xMT0vcos+s
+uv4jdcC1cPxpCj51EZEQGLzmKUMJaD1ruK7AnimhA55tb22NetDW0OHA917VeuoI
++cY1Hm8YqJI9LF9KbnzfbTtqeAcFKPjQe7OBFIvru3Z38Ng2JTnRXkM0xolZjpOz
+m14f241+LT62xQwKW3rlG3FLW1yWdVQ5vi8jptbZrhC4J7B2Mzhgt1BX0aV/IK69
+3heQKQIttjslwy2ka8IusfSgPiioSBSULcmlN+FV9kKPNCVAoFvjpGRR9hJfZ92E
+6ESuYdphCH+M8FTSKBrKrX6hvl21SpHS0qExr1Xh3MYJvE+8jX0egjuf32Rf/io8
+LYJ/aiBpkDbikCY8rQUD7+HmHGvCiN8tGakeIbjkS3V0vMA3WsZJPtUt/dmVaVHw
+TPuXUMnhbQpuqXI6K175WnzHFaOXoV67AVhLqM6CZTdhJLUz1NNVvaSJ1P8nxAz+
+wEEh33138gtuWfsT4xfaitbQ2KqmkVLvu1CJ7k1+GiEOxWNiPgSHo5Z/Iimi4VB3
+Bwxk77iZOOinqmlhd680s9UK/AnZxJ1I+5NYKx8yuATWYmYoorDKZLvJxZH5DPz5
+XTu+v79iGvIXcSLyOsvcMDLLnA6tj4pBRB+MgxweiVjHfrdvG7ohDwXgklI39I62
+eO84IXMU+peK
+=Kjsi
+-----END PGP SIGNATURE-----
+
+suffix
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e-with-key.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e-with-key.eml
new file mode 100644
index 0000000000..4bfd511641
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e-with-key.eml
@@ -0,0 +1,187 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Autocrypt: addr=carol@example.com; keydata=
+ xsFNBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJPaNoLbqNP
+ efuFg9ib3J0rKcZfqgnqC4usPVSTdmC4w0MdmHvh+1tUoXcxnrjYNRRbP+lC7zaLRRnEEioi
+ mC0Mkh+ow1u4F2QFBjwcV9bD7i0T1DRfR5k5kh3kcaYFnGnwMjwjJzLtvu3OZbXYsofCw789
+ 0TP4LkqLEQVOw1OrxBnRd5QNBVojcQi6rnKOQ7AUBGRKSXI3QVrbP+x1oImXpQSqIyaRFbtx
+ 57QafDdkyHBEfChO9X96BtMndyry8XgYtcgmwKKWg8Js4TJgghus6Sng5dA7/87nRf/9//Np
+ tXh9mdW3AiHsqb+tBu7NJGk6pAPL4fUjXILjcm5ZXdlUeFVLmYmqTiOJcGFbqHEBGcwLKPob
+ a2JsBEpnRj0ZEmo2khT+9tXJK3FUANc4w/QfxTXMwV17yYvocDPEBkoKcbxE8b2sSK/L7Vi+
+ h21XX6fA6B3zKFQ3hetFvOjEGTCkhFD9asL8KnwQdJmYo4Bd45AVoMZFxBxpmuo9MxPdiF2A
+ GbKHgrKpqDw2pUfelFwMZIVQ4Ya1wdtLe8gEJAMq6YnuuQcq+jjGKubNRywld7xXIsxJCpHt
+ qbCQM9P+gqp1VDBnbsk4xGX0HgILXF2JfyceGMGy1Lku0QA+ywARAQABzRlDYXJvbCA8Y2Fy
+ b2xAZXhhbXBsZS5jb20+wsGJBBMBCAAzFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTUC
+ GwMFCwkIBwIGFQgJCgsCBRYCAwEAAAoJEDCZ/xI4hSufjB0P/0+yaZknO8dS5o7Gp1ZuJwh6
+ +vgTGWrTxcBtsU1JR4BFobPKtMmw45FKsNIiK+AQ7ExCtqumGoTJ6hlclBFMlDQyyCxJG/Zp
+ PdrFUFyg6JUVf05/LWsd4Fwy/hQY1ha8R81QinSHqv9DJk6fKZG2rz7YUE47LFfjugbwUj9y
+ 8naTxj823Vm6v36J2wgl/1/PHoZTwi3vQRA70SoIDt4tSjqBzuclt2k/zlkJmOpBYtQb+xGw
+ pfnh2gBJdYurLwJO9rQlzYjy/+1qB0CZsE95WlkTrqQw8V5S6ULcnyACbETdF5HF/geHL367
+ p/iWULD907E4DJlQBOWjY6fdsJIBj96NfQiG+cXYTNGqaB/FgW8jyoS9vyg4PDOr0nGHLvzP
+ w7xTDUkuoJiWXMJ9kDYTZ+MsWreA885i1JSE32CsqqP3+kI7XQD3d3T3pIPhKOo0/bzbLY6y
+ WBXh809Ovi9fMxaZkrlrmA3lFcY+FbzDjZB+UYOXDB6TRu1jvISVMiXnYf4X21xWyl8AWv1q
+ ANMSXFKUwBSR88I06QZiJBmm9wHcyVtK/Hb6pgH10LydZvIfRDLrDBc2z31rswjNj9UhNp0Q
+ fGdNz/gXdxc8HP7Pf4kHkjIxLrWUNlDpYddX+iz1Z//VY9h2XTmSail5pMyyXdiGm90AGfVh
+ IcaOoeKK9UslzsFNBF9GZTUBEADWPef8E4OUoxU+vhwCxy/4nDfxzV4ZMFYkqp8QgpLzTVgT
+ v6xGVHFx/waNjwR6G34tD0aYhkDrumv9QsMdiQnMw9pLAoc3bnIkL8LkXnS8fVeiuzkXd4lg
+ vpxFlce7KYuXos9Ew7Nm2tOx4ovoygFikjliFTKn+QOVJoTr4pxJL9RdzYQ/pV/DI/fc2cmR
+ Wy0uivP+F+LBtYW6ZOMY1aXzsJEvun2i5ZxV2jqNDhXpD3m6/Y/28WItKbmT80hvTivxO2DS
+ Q1kqNcwB8Z0XWZJoz6iyYUu27dKB0L4S/x4UASlC6J2Db8bIL3Tdhuy+N0BN8sS1TDWb7Oi1
+ Ad8huVxfrRSyOYj4fkksvAEgDEDH6JEvJBU3CGQtfXCoX6d64db2cGp85GDfNHTREJ0mbRjL
+ AKL1RKrcKOG1790OZU2veF5qiN2eN08OLfJURL8+P4+mDWbaOcZasqNrg3YhYcPX3ZZzKfEI
+ vvTOdqMk00JU3zaUZhJvGOR9tJ27NBTrCEIOHz7yzOJltTDjdfNZNLqSYFp08+vR/IjSDv8h
+ l6PRjkomkbfdPdwPczKS0dG9Cf8cU+NZQrEgE0Un4tvb7p55j9R5OVgHUACLFTlDIRV4veD5
+ RnM2hUFRtBONymXEDjoPGZXaHhv16MckFpZ1IEAkMIZ3Ti/NIZcS7IA9jRgBUQARAQABwsF2
+ BBgBCAAgFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTYCGwwACgkQMJn/EjiFK5/Q3hAA
+ mzMu7EOeWG0xAHAQ4b/ocCSlZqg/MSf6kJIkzUxdnX9T/ylEmrS8cEg5mdJMQMVvCecyDpNK
+ 9MgJPV7MTnR6x/4qgdVUTtknd6W7RrQ7Oai150nMH5U9M8GrFtbQjc/fOw17agoT06ZGV4um
+ IK41IIGwQZ2/Z/cElHkQZll9//hYS8/E8xOBlweVxsMZhfcLFrbx2hC2osRt0vMlGnYSnv29
+ ligVG+2PwwnHXB6Tn7eslzoowY78ANCTvA6Rc6zR+RIs/CIiaDNgWCRBJcueZVpA+JkyL6Km
+ C+JiiF6Hsm07DDDjgLVJ0s660GNe8sWw4IZ8wpvYq1goqXLu+CMqbCsBrEDwfguClxGSQnLw
+ AUIVxuyKprToLJ6hmuubsVcv9fzf/GoYFnT9hge1YZpptKi/zrQqy2CZuSZEHWpUZcwPE3Ow
+ qbHKty3UhZPJU50kmEOd/UQNJYNWxxxx5593X96jLLDOxm5M5jNNRvGZPgn8RbA1e7VC2XFg
+ V2KGJHq/gxCpwkWs8+0sYUtcFuu+RQWTKbJpFcxfAIEDKS+fyLRAFdYqUA3yQIA1UYco10l8
+ RYPLY0+IXiArqjql8+k8PBT0U4P59lfcKlY2GaJe4aoWLPOdNZAJgLzoxd5zgnz0vI3sn+3v
+ meCtpxz2PoYBJfxGPEzu9xTLV6k9wSVTCgE=
+Message-ID: <8a0f64bf-41b6-f20e-6caa-eae0fb5d32e8@example.com>
+Date: Wed, 14 Oct 2020 14:33:24 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="faEswfPorznMRhd02nPybB0ktMdKqd8R7"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--faEswfPorznMRhd02nPybB0ktMdKqd8R7
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--faEswfPorznMRhd02nPybB0ktMdKqd8R7
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//daIeGVhI8TuGWrsJvYQ8EWG/7HFc86NTh9oFbJwgpadnBCAACVP/pI2N
+ywfiin8z4zb+6+Z8rJBinXu7x2AnEuu82zXqFqZP0PnqbjFe6MErUIN6ZI01L8FxIdcWDrQHbHjZ
+vwKlcuDHg819/f8BhFESXrm1TEtNrSNe+F3UG294mFJyScv/pvEbcw+L7Q+LG4Q1FE9DpiO4VYv8
+auM+nlfJoe33WdHEk0Hd9pOx61n7BG8Hwyq/jXYmz55Q7ykaodTptIuVZbSrZjDCamZ35VfYBihz
+tEvUalnjO0Tav1OJ75v19f4fZ4XYdsyXBdOUi40jVxuO2/5pgYtu5tA8PZaKrdXq0tinnjH1LdN1
+EkI40MaWuA/Ufav4O/fzyheKE7FqLjqkq9R5bH/AWPzebyw7K4qrIICEQd4kwtsvMTC3dEshW1N8
+LBzHygCwwRmK+uTvEW7DN6XEuv6dYVxvNHc1Ui+5RE1J0Wgdw4prb6MpLDqBukhdnTyKRq795YzR
+W6sgN0mFI2fVAxWenumADSC/w/ucBEPJ17xKyXVKhqcM2ECo7CUv2nRFiN0hMaqSF1kCvE6UgPDj
+cDtHpwtLuZliOq9gX0oBQoLP9+dwFJ3bz8/SVx6suhpl9kFmLuQrwKQakuRcI1tA4jUpWROPga8S
+2gfy5pFxgyD7SKZ1dVXBXgNHZva51fIethIBB0BqERv4MpbrVeAN946/LQhON9nszGC36bqUgHVv
+ylNhHTAnpEXJoEm8R6ZieqY/6mmSSrMguB07AT+EYiSMBHtUgBS1QWz8EcvzsFidYqi4ueXS1VEB
+9y/J1XXxBRX0qpErI57gmD42tG/XPTNOv9rt2dam4Pll775C6ZaFQS7VMfjKptJCTuluQ8bYsHPh
+i23K87ZJDkviH6oy3A87BOJTYmSrovv4kRMjJf+lr6xCfHfGLkbOvu9Al5tAb7I9B8+9A5dPN5KH
+aeN54e6fFn7K+xCPzZk5tzd1UDchd52chUK7j+WwPuKp0+E83aW680rUbpcIg/dKuanL2XnuV7Bh
+7Y9TjVXjp7Gxdz36e7eIeeHZOrOCnGvszssKhWo/jRA5IQKJDn5QCUxaZ6p/rht9SZqUKxb4UcrP
+Je4aSoXkFjZANXgES6tBY19lQfJefaDsHOkKXAbkFfwuI7PzfYcEzvOqasp8PPjDf4UI4NVvWKji
+teJrUSEjf867xZP1APPJsApMRKOJDw++UOngN215fGNmmAtI/qQlOsIlIB9T38KZotSeoR0ycpkN
+XmJR1jfIqvNa4QKWpJhMOs8zyev+8qAiWcwxjUSLtfugarTsRukQptajKXHqtojfwrvnrf5tRUyB
+nmpgvF3/aO6DPOcpqaHmYCYZ/oT/gYvef8mv2G/WaWOZ4sYDniPzlggA6KrofQJtGIdg8qbIO26f
+spId5S00k7o+Lbt39Q/PUR4JZ/wLClpR5MGhI4dHvOrkavuku1m1mXEcmSjrhCKKTtZRk1Cthiro
++5Vd+Yfi9X6lX3npWmWlTQwRcIOX0yZkjnoeof2GTx1ZW+iJ7iqkJohI0dQRUtq11wvmPfgKU0Lt
+r9Z34EGf3lyQjLcKJJ/XlE+r0C7jKWg80ryv7PjYjI2qreN1ticYL7BJAg5WA8NV1x4VGtzmMhwL
+qpzhumfkMP6BgXNw/4uOhNpLn2bg8uAuoHTDJGXP73j/yVE/wM587GUNX8XSGQIL6sqmsH4bSSL0
+CyVK+b0vcUa6suiwHTcP1iW/Czo4cSY5sYoGejW3uV7PtjV3EeV21rDtQAbiRzCjcNAjI7/7V7bZ
+qykSBj5ncoLl6/K5CMVyuwylTL6rrlUXE9gL84Er9wg1vSrrM8bLGCyFRBelDd5m5U3V6o6Dj4zP
+YAKKF1qtTM0uoKS2PAckhfv9PzDi/C7i5kOsduYDTxu3MWHKJWw42+2YU3UetScS1mmzbe7mK9AH
+WifO1bCjTfv7N4cA/e6sn2qcO2208+CwTd+cx8zaWKpXWfBdcC3OW+aVL1/FB0S0nxgup7rrEqh5
+l9L3/ZydXEjbFjOU9DYN7qCDDd+DmYgsSviAVS1ynTBxXie6ta2U6i280iQ9M1HzuIxZOayxsQxO
+gxTGqDfPX6e03bSFA7chVR8U14J0dcqoMhYm/5KzvKrokxGIM911TPEkkiVehPUCvkQftnM3+BTW
+EterQ6nUDKdB58UEzqOXmfTF7VC/cAYsilhvkQRdKDRgYFfpI43ZO1nH42Uyd5Ev1tubisWZfoYH
+UuQWnj8haFL0vt89wVf4u62uRhThz2tVxsAsn2tfCw9J6/TKGHizs9ovgiYORQ9bPtOqTXluklw1
+ATMJhPVKXoupRFgr9MvI9SGz72Oi5/lA2dzFaaWX+jBau4EAl6xqjbRAsvNfDV5wQNyOTsk1jDbC
+nDomToIeFuGCmTVY7zsHAh+dqKzAds2VJ75bh2ANnaS6w6NWg+7ZoxYTCiRSO+8Kkp4NNH4gai+c
+Lrbat0zjHKqC3SOtkaRkjATGwMB40/2IgnXLM0Fn2lHo1ac1Q8RTgHaRTP0pkK3Cy7L15hiy6Lim
+ncyaB2tqdmOUWKcjRKuQ4Ws1ZCccIKTbSFR1douGQkiYV/Si9rO2ICllwq+B0lfr4T7WpvBs7C9F
+2c7+T1QXMEniavrsSzabuyVrbfAMoc7WVXtU9YCEisilIyyjdiJmwrOA3RjuB6mvX8K7vRbw9Lrg
+ErS6JUc3eYPJwL5yhlU/gjAl2QaZ0COWVdvbU6SEwoK81j4aqAfVozvycwIBu30LKfTdPlfQGF1P
+TW66v75+2ZrJIhqmVb1UjwjBbazI/dSNWKEr55LXrMlJ4guPzSzD7cyWUi+m/TjbUQ5zgqxLnPRA
+US45YJF+kvzVMLWjFgaTtUlknvAHgjpSdXqKQ+RRCKOnMK1Zn7+/hfKrbs4Us0a54jM5LN0u762c
+M670xZu4Jsv4uWvTAWlI80IL2sjpPh51wFEBxcrbhwCfZ67dm1j5d9xt2fRsbLXdt414sr/4aYYJ
+a2XyY62U6WmBoqcvCwC1weTJBgb5Max3HvadZSXNcIsL7/wTeaGZPz7rVgnzgZJMGiQr+/RfDB3D
+oNXOSBprJ0ADk4+Yfu2dNDlf3D4SqqbuGqlX9XXHNe6U8PSj9C3DQM8wov+irl15F4Zs4kcq8+kE
+l5IuOFkF0MiuEzsn0/cT4VzVI6E944oEgALl4ptRNaul70MXVpKvvYOyE1JlZcVvKXLjrHn65J2w
+sA1lXOpUO68FbkykhNVkmRtpx2pUrriMrAGB5ydq9svxQIICbjAAmoFCPK5iipRyRFoU7vgguSLl
+76c/2UT2ZeFdix0Ccl9KJ3bgclQJgWgcx8Ih/zyK+9xsE264n1lZVPiHW0a3ekPIdE3968bEyo6c
+DNTwiMVr+jRZMcMg4nQnVtUJG1SVTjNQEs0r4cePqhAKoHHCU+ulIS7qhPNXJlS7XEIaaNHzbdeO
+2Oc0htfGJYaRcElOzxLqEOCAcNffHKhdzw0fqU56BMGZlKqMmTNnRE4RSX9ldZUiAvTRFnJ0ymOM
+qXh1Z2AVG+5hiDn8I04v/GaYfVgeg9K6rD3Hft8X+nWb718w/g3VldCf6LF/YAEuNAmr+fENp7tw
++EQxi/9KdNC/QOAJy8rcvMcl5okApR8vLOXrOSX3u9o69+e1JVzvv75cKD91tpOHJOh7K79/rqFJ
+1je5AB6xeHcnSXaMUedwoGV7zs8VnjN8tn8JZkr8vT+d4rKOOgmuv7llfNYGXiiwv7oitBBc2+nZ
+c27kyE5wBAwP7nU4NXGuT8Os9Q8yACAVDqlgTDWVoSoovz89YLHmnEXGLtYyUAskp0mhFW1sYt/I
+WvGHpF4tevXthtJwwwXVECIv059G4XaszW1wXQMavimgjV7SnnxZdO2inAhZUqvZpj8+xeMoVQbU
+vnMr9O1Z6i4FT0sCe9RiZhLqzGT+7lFMfvSBFK9rPNCWc3hwPfbp/L4Q3vK1xZF0WR93uDBHsFkz
+RYvxT0XMrz8YJdTbCNCpBLE4dUqJLvv8xtB00vJY/i8tX9Az6dO0GQz7KHCbl9n6dVPkxA9zxIAQ
+S9KFr+sOI2oeBEIPgsVb3ytHdGdIYwtYyKehC+LxIU3LvRP3zUF6h3YCdccoVNO921ObwCXp2pjW
+9DIPN2pRj3YNFr8Z8/pwdvqcGl/WGAt1f/10sqVc521tkqNdRRBn9HSl3i0y8mF//lm2pFtfRlai
+YoBj6XEugXtZzWH5u8vXTeoSeYW50uucuuGWN4faTApFrW/aDf0WLud9xYkTzSF4ZEin8bIsIpSn
+HcWGQPzvHMLi4dcIT2XddJBFY4wyRAQPV33Qu6GDtka4Zs52SCMdpMLc+elOg6tucRRNFJcJ6oFs
+duwTU7An9ubbqhNSoPMVyRiTDvI/FBwnpK1RklcDaZamU0A7oTqon4Nq3pdITAuQ00Fq0amePpyh
+zlwmVDLaLP7cuRUd7kyHD+G/nyaomuaV4aVPjj6f9x7FqePdg5Eg4rt0a5AFKe4geCiKelyCyzMk
+MlKuugcr7s1C4rc0z2ZbRWjvHa/FnFxhdetqU/3FVBdyJYkgUkf21E1RNwPAxl2CQEtaOE0626pr
+SjVMBRNaxFAm6xCt+TOYaNYB3EhOo869+IKNVFAiVwgOh4BSsZs8r8hYiHjzHeWv/z9IGnBch+zl
+pJU/V3MN7E+TOBZySQ0D21ZujhzCbfZS73jzFkxEUW9kxwDdy4fH1RY1/WYdFIBRHqDodfaCSFSQ
+6aDz5nZZDqu4dP/Cyi6g0KSJ1AxvyhBM/e+gbNgEoaWzTzNDltBsr9wEoYp43MIxd6GN+3PwfBUn
+RqH3bbvmY41GxCyht4CNLVvrdYH1iaj6EqgtfUZNtqtH9NVGeMirMBZ7lJVslQzwgYwrUKfnqpEg
+3Z7EarzYc5BG3+wdiTN0eJn3xlG1ey76InXqKaOBL4dJIGstIumbgFD2NIe4KcXXYiEDSactcyG+
+y7VijJL7AY3gLKosDPHjdi5NysQOrec4lr373QgzuVpbTp2YTU5kyHPl9Wz8H++yW+ut950KEFL+
+587PHjCl45/LFyitdywshz4KfxhCUbm1SSKxJyDIBng7jvD6WAadRN+YMRjaY195sOqja+/H7LOq
+eweSVR4MD0mdhgUFEJNC/BXpLOVsEijL2dBmnxZrcWqLgv4y33hIKt5HKlRYnmcSgK3LjbEOwNwl
+EWpSOQ45m672KBlG7xq3x5FoEL8Rkp6u9n61BNTWxArcYHEMMGOIJggsZdVptkdGeaAPyF3aKpNi
+8bPaxVrVSUnwi+h+X+owzOLCJT6Xq8sbkVoRZCQpAaEXF4RtX4MBQySe2ZL2vA0/kj8fckheMwMm
+9YetkXNy3/k5W29zKX0sBDFHQW21WGCMhNZh3VEQPm3GoopoKQebz+8OioPn0OhsMYo1yr91gcTo
+prRfouI+jIiuALIRt53JIqm4ltaNIl52Rusz8JG1Hjgr2Sw5Tfv2z+gN19nRGSgh9jMZYUusRKbd
+yeg1j1Hf+LtUAQzWEnZSGkHiqrlbfj6UPZffrOF3xnnKw/aGxNlw+WGh/ZKrXd80OefYTzPdojR4
+X3w/tZ/JyvGfzIa1cTsBDlsA4dWFkLevE9mwr+8fUeMADfNPgycLXs8fBsMlXlcn45nod/RgplDq
+iZTMLwbrM4qflPvp5oM4dt/MoKqb6huHF6LS1cgTaHgKrRUFTDi/PnrlzGamV412AlkSQK/XdAhJ
+MOwaP8q2OfS4m+gL114CoYhMUPTPuZycFRu+rJ1O6eK5mSgVqCWbDHs987y4IQYN/CqZ8hHP1klc
+P6oWdNKeicLpzl8V/qyFOtCLG/KqAgnciIeF433GHY+YzRmfq3o1h//CQzOWNTOfObCNjJdKokIQ
+QR7PdPxkNeIBKgcjwtbgd9U/qnNh7TbhLn/flOUYGawCzUuxPrVqRyaendMPetb/AXpP7+BmliFY
+vxsRuVrx6+v30NJZarLwtidHFinjWit0d0SJgkv1i02G35B+avTYHBiGyFiPaXKvWVAhxZlJoM2R
+GkDqboJgshKLv3kdZSmK5F4c1vmZCPLPvHmddcAzfFn/pmNaLgvmSxDQRTi+/5iBgxyc9bmgM11z
+pEL1XYpAZQdh29tigN835ooKKk4tgVmDP7q63cf7detaN0XslXsz0OXzI1xhhFflr/0mms1xY9r0
+Hxl+gG2mAyuc9dflDAzVXYGuaHWSegES7UmXzda3TgiwU2H6sGKuUnBhWrhbH/cgcjSsFVl2AZG6
+dk5raBR8bYQuVc+C8xOi5k+FUUhQP8yBO9gb8ZNpRBaxw4XYIKZsZlE9jQxUtmXOAEg0Z3ZTh5ZA
+pPmBgppi7H5vS+4/7ys5Y26RrJ37bwfWXviq7kGg5ZEXHG4ZuhbfVfFDYwacb2Ux1/FqcTvTk8et
+h00imLgwFEWqT/X83U8z0zZ9+ZK9nmY4cZHlLM7XEDNUZAn3iLyao43vyZeXxCjIqR78cbuYioXn
+oZvsMSiadR8mAuRzliwmlYld0H6FUXMhajLbFWLbSxR/X/sg+UF7dSdfeuviknQy9DkQM+wVcW7f
+eRKBx8Xl+Y0PzwZ9xFCOKh/KA0O3M45JDeLu6w67/963gzWX0TK962JmOdJ8h0dzR79yKOe18GQ6
+BHVpabZ69AD7kPtBIrujF/GZcw0bHE+WdTFa+1H3UM/e4E/t7O/LOkpMNI6Tu2ZPqlp1vcbMYtNG
+ahkJuMfCKPQPDsm/gkXIGuAhpDz8ovbXfaI/3ImRUkBi3+0U65sqBzwSXuYymNjVW6zli5mMAsel
+gIi4AhyMqhTnWDMUBgTUuBr+T3vEQrlb2gsMbW/cdda41EmEq7x4gQFp4F05O+N0xbdpzxErds//
+EFthXxFf0zPfr32zcmD7Jt7ys1IQJEKhKO1+8C8Kkml3X3EZ5E1l06tzbXVIZbFHMJMO4ysbQcQ2
+HdUYvQv3BK6Pja8RSMy4vGsD2pzKVIdUvoUUaFeIeZitGMZIM04Bu3VZ/oibJrbk5rNgm+MJEeOn
+DJ4k+pKWFzebUmXc+0JFb0X3rxiR7Xr7Pkug4mIeN76Wre3cOIqY2IePaZcXG7RYLPO3MzCgWKc7
+WTBrFouOD0ih8bPmKlOChMtO/7VzJ9/XQWbeqtKQwiJzlavFD23zjRGyaV9ATex0GjHuYy+nm15y
+NAPN3YCcAV8+XiYLeYSzTnrNZxL6/Dp/zgpYJSAtk8CJLHAFsIOKMdY2FP0ohrsPVh0MJlFDcKYH
+QWA1GXPhNY01QkZ5/kh+p19mwYxHso4cK1q3/l/umLLi66wZ29Z3qRVgHB9cZuWzRR+9REiXOnnN
+SjqtJpnyvl+cD1ykoTSxF93K3bXLZ7t7S6YYZmc5NTMkZTAHD/T0hNsE9IT7iPE2szcMQ2Vx6FAe
+17FWe82MC6VuTF8dTwMsww3rXc0HPwaM+Ai0keIQvLp4ojE/dYBMBGJ/Y4Eo+d/VgZoFFpr6TA+W
+htuZ6ga+ty2WrmX3vCAol/ue/SS5c4YlRyi/pV8N7QqJrgCLWZdxSpsMYlQ2J05kILdMikJT3Pu3
+xsdDK5lLPqFAyIMAb+L+F4qJ1Idz/kQPhDFQ1/cx5GCYHcj2IRuAASggE/N3Aio/PumUXAc3iqGR
+dg+y9NohHSjIgQbTOKZkx/3Qzn/dwgUT+zj6JcKivtqlj5DVxrlKC1NfYuhfCrJsVfPhk3qt38VK
+bIfvFhX+CnbXDTtOaWP5H4D2sPDsbnTArrOKjo3klwlfp5nzmGz/f5u//MdXdPlYw0vJn9e1Wacn
+QtxYPZsedj+zDuBp4xKEzBWgbDBaOBEysJT5GGvPB63kUORKcyN1hmOjvKqG/I486Dw/n3d/6e6w
+2+yO7KP6c12hQAvwrBNQzmZyo0H5s53VNvcYP9B9AGTEX5POLydXH+VLHMCU83ByBZ4cA4OJwGre
+ZCnFkwO6EnSNmUeLR6D0Uetxugx1MvHlb+yREQMbnvkrfR6PdTexslI5wu+IQNiBqlAWChwc3PmW
+PNLGF+NaiQeU38JleBeUaQkLDYyLJS9aCmrtFXSjerDk7/0ZW7ARoQIyFus/qTWzr+MbShruGf7/
+LK4CZPZzWLppRfRFKBCBYZH+NsnYVw5jwL9VqPVOq6enCHii7BiGlHIGq8n2mEPxcb7tsk78Wpac
+w8/D+SrpA7Yckr3453Z3Q3h069Cq7wBcKhKFMqOcgDw0q5jjc8YWLDJ8vOzSmMmSDJlfGVpp87TD
+hx18HDprIpghSAbNa42oRVSwnSO9HM955/tfGx7VRKPJB3iFIb7UZU/rNTuejRGKXQvR2TjYQKfT
+z+Dcu+E=
+=MZd0
+-----END PGP MESSAGE-----
+
+--faEswfPorznMRhd02nPybB0ktMdKqd8R7--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e.eml
new file mode 100644
index 0000000000..854d9549a7
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-encrypted-to-0xf231550c4f47e38e.eml
@@ -0,0 +1,78 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Message-ID: <1241106f-5ef5-ae8a-36ed-02d6f8f84d62@example.com>
+Date: Wed, 14 Oct 2020 14:29:03 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//X7sl/QCVhaLmJVnPaF35yxDXmW5ACOdtKmyAAn0uaVKNRTdLontNFHRy
+DFeKhfDkl7ia6Emf4T1cP45/ViEJ4UphUwB550Anrzkhpqbmz3Sst0IuOxIrlQ+SDADzaMJIpsB4
+g2tsq7BNlfahe0J3h0CoVUZ+IBIZrj/d5nn1iLAJWwd4A8pMVBZ7lpPsalLDuzfJnWNJzD0atGYl
+GURSnrYWtK8df+tWmkSMlZIAqPQiH28r+seHmWdr8h7Q7zRPB0M7ElbDrJVl8bEeAlWogMXo3BP3
+55EfncyxWmShK16Rw6xrQ8Tgxu1s+zmw5LhhLA2poWXaeXWiYS0joKcFiEzvlplb+N7Wbvxr0D6w
+pKpJPG8fYCe4jSkuw4yHVSYkQVhMHsOfvULfHCffDR60DlcHrPTQLdvsaNJeKyhK1F0HNTaA4I5S
+bavbPMyxqhBLHw05CD27jLIK0slVPTTBhPUjsoGs44sGrpB9jz+IbeN085oEbtczm/crd2zh30Ip
+b14Y5BJae1Wzh5b/fTHF0KpKIc8OetwnoyBVE5eGtGFfJyTBXXbp9GsSS4rCI3aKPPnDJYNVMwEa
+qFPJpJJjWxUVcekLFOTeFhJtWrOmtNOVzt7tCHG/q8Kr+UvryoP5QdYBstGfizkTH88+WMsFVddm
+uju5rw4pM+Adu7yJgT7BXgNHZva51fIethIBB0D5OnsPEPF1mMxPEGZyMfNY60RBItwGlQd6sqi7
+GHOJBTDzPAoWQifXA/qk0nDqC3ikKFIypRnnYWXS0yiO8Qi7fCYh05NUBdwwJYgfy8cZYSDSxrgB
+k2FqB3EGXqcsrPW225CkmfGGrUeYosnUUsXdrChTxK/cfAW9f4N3kr0MA9R0VkD7BF6Lmir0Veum
+AcCkBVqrXPOu6os6N2Tl2ZOU+yq4JQJAgnndhGA2U4+TyFYs90BI3ifpr397t7HSKgQCb1F/QW4i
+KACweORJE3Rx2x0ispkZ4wfosOmT4JT9F7ykdkIN6JLtCoLXIokoUiW6R1eQkwFE8gEb+smlZ+PJ
+uS3HoTaE3FB6GbccYwAg/5H1oMT92nlx2x+tI/ocD136HOnVOPQv/vOa42O2Ipw77fKdZRQg40jq
+ZsB9poO6irjb4tjUDoeVil+MjwffqrytoJS9x/EQ2XTCG+FYyk9iP62N4LVbcMQCObKllGjL/fCb
+EzZcJyEKuQcW914PnZ4vXk8HpUaMdjACNWgDbPwvX7toeb7M09bZR+2MtDCtJFlhUq+fZDXRNGYT
+4wKNQxVCEX/AfuJkcy2uPmzo6yRyrmfaIdug+ypRMMenD3mf/do+rmqZsRL1O13YjH+X2Q6YreKN
+rSShWhHOdZlWuE2X9vyHqbdV6MH4IuypNVtIPdiC339/qeQgCBev10eHQPikdCA0JXgjdSTptUcy
+RRafRK0+FcguBcmsH8O1EIsflAtSCcqPA6y5omYj4uQ3xvwU7aXyzan0ZiYxhMj/ZPYremLSpFTh
+D6s1fO5jprvkZCD4V/Ix5YysOmldJ0X7uZ3wBPCheXNGu/q0qR9ksaWc2V3+Tt7UAPYPPINr7UUF
+69pbU1K2PGaUpSFZpDmrakCF8PgLzcEJpFaNAkstdA7/70w57GHWYu1QgU7dVeb5MXuKerPW5vr0
+scbDXGAWo2wXWvEYQhnPwq6PNwU65M6+5+Wvsfvb6nYIlEEIgeBzzcyHbYPVL004pxWUL582bzkL
+9U9dNMNfldzOr0riziblNxBdO4Fd7L38HAK/Ce8CEHuoorC80GU1CZGYacxuysBnFZnJo5iLSYjE
+XcIsjqRMYk+ZnlkGv5m6hj9zb61PLWVdepnKFmeDVqu97V4kqUR3KLCFJoyZ9UG3F3tz75xhV25F
+lusX9tQ3ddpOqOQH3wZVtdNFSzD3y8xem83aAe128at5jCPlGecKcmqLoA3tJwjst5BVhvcw7+7Z
+aDUDf4bNrli+l//UqoxslWP2TLfH6ZcdI4wdTpEhYQ21vKoavNq0i8k15GN6ENeK4+KFQXOuKzLW
+c7DDZOtgJ7aX2F2j2/FCgS1wjmhthMr3pWgEmg4KdDpnhrVpxzz6/rEYQdU2KTmKy4pTp9nvgTxI
+FBWRT1llRSoQLpSbD/2EHyIJAgf0GpEoQaEavyMN2oIvtDYOJqSGtBCXq7z4mI9qteUUIu8f7eMF
+NGxxFXjst70kYK+SMuT96h9to0TZUQQFtdymiIEVwke4T1a//jN/vkXa9VN3Y45ZuVlA2Y/ORhXK
+n+PaeXR3dNKLpiQUCdHoaJL0vOqXf+TbfQTauCF6jcLMJ4OsVauKBXLzUsadWhZuro6tiHpQL/J0
+ftco43xUFOFMcSjYFZXoKhjUt9I6jdLivG8CuxZebpbwV7TmW8XXKfVDnjHavSj4IpJgA2jS6K0H
+pBK4on+iH7FtehMK8tSVLzUNXy8MvZnvklC2b6XEfNUOq/H1m4VKM9bZhNsba0us5F3lOtX6vS1M
+k9krC8FFvwT8HDYxbBHUFO99FxlIqyVLbhFT7j008NcJv4QNBTmziHY/yZTNUp2/Rlcz5kSRFCNo
+LB+iLx5tawWYaGLT0O9mQpG73zd3cK1oTc4c9uJ2/AtMZOt+nYv5GaUqGPFazxCcZ0HlR6c1TCTZ
+gn7Pe2UgRlCYsAl3768WOxVCcMl/8mCQ0QBzm9tR1mS2JFmQgNhUpjshJkVTJeVaZPOfUNmONfYt
+LEXZ8aLqRqkQIOTcY9uBr3f55WaBDSKpO8VBn442EGn8uIE1FQOEJmmjFnJ5VyU/H1IumyLhvt9o
+EgPBQd8W0+3uyKBhC5sILPru41STAMV6n1+dcxxPpOkUMwoD3RQjrOtpQNk9KMr6wIgfIvPSskMO
+72amqSpq7Bmqf3RNQL4hZuqS0XfYIWD7gAzWHyIPXngp3UXmMDANOsRbPPDyrdm7U0Gwt33ub5DM
+Y9woXbDDZKvk7W0uwlCzJZ2bn4EpK56Yh80laN/V3Rn5fZVP9quN3+3+/lRVeaGGi8Us30MKXYHN
+StHU0DMonSyt/Ef2+aIiVEJp5vuTJiH9dkM4sVg+jQ8/LtwLnAZtRvVCNvZagX/ZPm9J1eH5E6aH
+NKcukVY3iTMQpLkeyZXhJnw+TYJSkpefPLxvCNwD/qewjN7+VcLtXDkrRsrwnjhu4TU2EQK61xK1
+aaVbH91T4GMLZsP4IO8TocnmBHuuyL8LBOcOWeOqiCFLEKHK/4jDWWcQMW9zqqKB+P82JYkEt+gT
++0sOTWHgQjOn4wHrrvCUbQaDQRYwpAsINVQ8N4fFazbUGw/xXdh7MKrfE/azHzcWB6d1XT5rSgtu
+kOQyLfxPJVevYf/JTG8/jtGDHQeb6p2GuIhCBn9m
+=d8b2
+-----END PGP MESSAGE-----
+
+--PAOkQ1PqAvtLhLyHxuarNAvN1z6Qj8TMm--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted-with-key.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted-with-key.eml
new file mode 100644
index 0000000000..3ff97b6fd1
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted-with-key.eml
@@ -0,0 +1,197 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Subject: Signed Unencrypted With Key
+Autocrypt: addr=carol@example.com; keydata=
+ xsFNBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJPaNoLbqNP
+ efuFg9ib3J0rKcZfqgnqC4usPVSTdmC4w0MdmHvh+1tUoXcxnrjYNRRbP+lC7zaLRRnEEioi
+ mC0Mkh+ow1u4F2QFBjwcV9bD7i0T1DRfR5k5kh3kcaYFnGnwMjwjJzLtvu3OZbXYsofCw789
+ 0TP4LkqLEQVOw1OrxBnRd5QNBVojcQi6rnKOQ7AUBGRKSXI3QVrbP+x1oImXpQSqIyaRFbtx
+ 57QafDdkyHBEfChO9X96BtMndyry8XgYtcgmwKKWg8Js4TJgghus6Sng5dA7/87nRf/9//Np
+ tXh9mdW3AiHsqb+tBu7NJGk6pAPL4fUjXILjcm5ZXdlUeFVLmYmqTiOJcGFbqHEBGcwLKPob
+ a2JsBEpnRj0ZEmo2khT+9tXJK3FUANc4w/QfxTXMwV17yYvocDPEBkoKcbxE8b2sSK/L7Vi+
+ h21XX6fA6B3zKFQ3hetFvOjEGTCkhFD9asL8KnwQdJmYo4Bd45AVoMZFxBxpmuo9MxPdiF2A
+ GbKHgrKpqDw2pUfelFwMZIVQ4Ya1wdtLe8gEJAMq6YnuuQcq+jjGKubNRywld7xXIsxJCpHt
+ qbCQM9P+gqp1VDBnbsk4xGX0HgILXF2JfyceGMGy1Lku0QA+ywARAQABzRlDYXJvbCA8Y2Fy
+ b2xAZXhhbXBsZS5jb20+wsGJBBMBCAAzFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTUC
+ GwMFCwkIBwIGFQgJCgsCBRYCAwEAAAoJEDCZ/xI4hSufjB0P/0+yaZknO8dS5o7Gp1ZuJwh6
+ +vgTGWrTxcBtsU1JR4BFobPKtMmw45FKsNIiK+AQ7ExCtqumGoTJ6hlclBFMlDQyyCxJG/Zp
+ PdrFUFyg6JUVf05/LWsd4Fwy/hQY1ha8R81QinSHqv9DJk6fKZG2rz7YUE47LFfjugbwUj9y
+ 8naTxj823Vm6v36J2wgl/1/PHoZTwi3vQRA70SoIDt4tSjqBzuclt2k/zlkJmOpBYtQb+xGw
+ pfnh2gBJdYurLwJO9rQlzYjy/+1qB0CZsE95WlkTrqQw8V5S6ULcnyACbETdF5HF/geHL367
+ p/iWULD907E4DJlQBOWjY6fdsJIBj96NfQiG+cXYTNGqaB/FgW8jyoS9vyg4PDOr0nGHLvzP
+ w7xTDUkuoJiWXMJ9kDYTZ+MsWreA885i1JSE32CsqqP3+kI7XQD3d3T3pIPhKOo0/bzbLY6y
+ WBXh809Ovi9fMxaZkrlrmA3lFcY+FbzDjZB+UYOXDB6TRu1jvISVMiXnYf4X21xWyl8AWv1q
+ ANMSXFKUwBSR88I06QZiJBmm9wHcyVtK/Hb6pgH10LydZvIfRDLrDBc2z31rswjNj9UhNp0Q
+ fGdNz/gXdxc8HP7Pf4kHkjIxLrWUNlDpYddX+iz1Z//VY9h2XTmSail5pMyyXdiGm90AGfVh
+ IcaOoeKK9UslzsFNBF9GZTUBEADWPef8E4OUoxU+vhwCxy/4nDfxzV4ZMFYkqp8QgpLzTVgT
+ v6xGVHFx/waNjwR6G34tD0aYhkDrumv9QsMdiQnMw9pLAoc3bnIkL8LkXnS8fVeiuzkXd4lg
+ vpxFlce7KYuXos9Ew7Nm2tOx4ovoygFikjliFTKn+QOVJoTr4pxJL9RdzYQ/pV/DI/fc2cmR
+ Wy0uivP+F+LBtYW6ZOMY1aXzsJEvun2i5ZxV2jqNDhXpD3m6/Y/28WItKbmT80hvTivxO2DS
+ Q1kqNcwB8Z0XWZJoz6iyYUu27dKB0L4S/x4UASlC6J2Db8bIL3Tdhuy+N0BN8sS1TDWb7Oi1
+ Ad8huVxfrRSyOYj4fkksvAEgDEDH6JEvJBU3CGQtfXCoX6d64db2cGp85GDfNHTREJ0mbRjL
+ AKL1RKrcKOG1790OZU2veF5qiN2eN08OLfJURL8+P4+mDWbaOcZasqNrg3YhYcPX3ZZzKfEI
+ vvTOdqMk00JU3zaUZhJvGOR9tJ27NBTrCEIOHz7yzOJltTDjdfNZNLqSYFp08+vR/IjSDv8h
+ l6PRjkomkbfdPdwPczKS0dG9Cf8cU+NZQrEgE0Un4tvb7p55j9R5OVgHUACLFTlDIRV4veD5
+ RnM2hUFRtBONymXEDjoPGZXaHhv16MckFpZ1IEAkMIZ3Ti/NIZcS7IA9jRgBUQARAQABwsF2
+ BBgBCAAgFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTYCGwwACgkQMJn/EjiFK5/Q3hAA
+ mzMu7EOeWG0xAHAQ4b/ocCSlZqg/MSf6kJIkzUxdnX9T/ylEmrS8cEg5mdJMQMVvCecyDpNK
+ 9MgJPV7MTnR6x/4qgdVUTtknd6W7RrQ7Oai150nMH5U9M8GrFtbQjc/fOw17agoT06ZGV4um
+ IK41IIGwQZ2/Z/cElHkQZll9//hYS8/E8xOBlweVxsMZhfcLFrbx2hC2osRt0vMlGnYSnv29
+ ligVG+2PwwnHXB6Tn7eslzoowY78ANCTvA6Rc6zR+RIs/CIiaDNgWCRBJcueZVpA+JkyL6Km
+ C+JiiF6Hsm07DDDjgLVJ0s660GNe8sWw4IZ8wpvYq1goqXLu+CMqbCsBrEDwfguClxGSQnLw
+ AUIVxuyKprToLJ6hmuubsVcv9fzf/GoYFnT9hge1YZpptKi/zrQqy2CZuSZEHWpUZcwPE3Ow
+ qbHKty3UhZPJU50kmEOd/UQNJYNWxxxx5593X96jLLDOxm5M5jNNRvGZPgn8RbA1e7VC2XFg
+ V2KGJHq/gxCpwkWs8+0sYUtcFuu+RQWTKbJpFcxfAIEDKS+fyLRAFdYqUA3yQIA1UYco10l8
+ RYPLY0+IXiArqjql8+k8PBT0U4P59lfcKlY2GaJe4aoWLPOdNZAJgLzoxd5zgnz0vI3sn+3v
+ meCtpxz2PoYBJfxGPEzu9xTLV6k9wSVTCgE=
+Message-ID: <b4609461-36e8-0371-1b9d-7ce6864ec66d@example.com>
+Date: Wed, 14 Oct 2020 14:38:44 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="vrdqJBVucR4QNMOtZVYVRGIjyNweikpUw"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--vrdqJBVucR4QNMOtZVYVRGIjyNweikpUw
+Content-Type: multipart/mixed; boundary="hZdstSX9kVVgqQC8ECJGhlR1aVjuEMpuI";
+ protected-headers="v1"
+From: Carol <carol@example.com>
+To: alice@openpgp.example
+Message-ID: <b4609461-36e8-0371-1b9d-7ce6864ec66d@example.com>
+Subject: Signed Unencrypted With Key
+
+--hZdstSX9kVVgqQC8ECJGhlR1aVjuEMpuI
+Content-Type: multipart/mixed;
+ boundary="------------83F8BA9FCE9C945124B915CA"
+Content-Language: en-US
+
+This is a multi-part message in MIME format.
+--------------83F8BA9FCE9C945124B915CA
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+
+Sundays are nothing without callaloo.
+
+
+--------------83F8BA9FCE9C945124B915CA
+Content-Type: application/pgp-keys;
+ name="OpenPGP_0x3099FF1238852B9F.asc"
+Content-Transfer-Encoding: quoted-printable
+Content-Disposition: attachment;
+ filename="OpenPGP_0x3099FF1238852B9F.asc"
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsFNBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJPaNoLbqNPe=
+fuF
+g9ib3J0rKcZfqgnqC4usPVSTdmC4w0MdmHvh+1tUoXcxnrjYNRRbP+lC7zaLRRnEEioimC0Mk=
+h+o
+w1u4F2QFBjwcV9bD7i0T1DRfR5k5kh3kcaYFnGnwMjwjJzLtvu3OZbXYsofCw7890TP4LkqLE=
+QVO
+w1OrxBnRd5QNBVojcQi6rnKOQ7AUBGRKSXI3QVrbP+x1oImXpQSqIyaRFbtx57QafDdkyHBEf=
+ChO
+9X96BtMndyry8XgYtcgmwKKWg8Js4TJgghus6Sng5dA7/87nRf/9//NptXh9mdW3AiHsqb+tB=
+u7N
+JGk6pAPL4fUjXILjcm5ZXdlUeFVLmYmqTiOJcGFbqHEBGcwLKPoba2JsBEpnRj0ZEmo2khT+9=
+tXJ
+K3FUANc4w/QfxTXMwV17yYvocDPEBkoKcbxE8b2sSK/L7Vi+h21XX6fA6B3zKFQ3hetFvOjEG=
+TCk
+hFD9asL8KnwQdJmYo4Bd45AVoMZFxBxpmuo9MxPdiF2AGbKHgrKpqDw2pUfelFwMZIVQ4Ya1w=
+dtL
+e8gEJAMq6YnuuQcq+jjGKubNRywld7xXIsxJCpHtqbCQM9P+gqp1VDBnbsk4xGX0HgILXF2Jf=
+yce
+GMGy1Lku0QA+ywARAQABzRlDYXJvbCA8Y2Fyb2xAZXhhbXBsZS5jb20+wsGJBBMBCAAzFiEEu=
+PL2
+9L060/gtxEaDMJn/EjiFK58FAl9GZTUCGwMFCwkIBwIGFQgJCgsCBRYCAwEAAAoJEDCZ/xI4h=
+Suf
+jB0P/0+yaZknO8dS5o7Gp1ZuJwh6+vgTGWrTxcBtsU1JR4BFobPKtMmw45FKsNIiK+AQ7ExCt=
+qum
+GoTJ6hlclBFMlDQyyCxJG/ZpPdrFUFyg6JUVf05/LWsd4Fwy/hQY1ha8R81QinSHqv9DJk6fK=
+ZG2
+rz7YUE47LFfjugbwUj9y8naTxj823Vm6v36J2wgl/1/PHoZTwi3vQRA70SoIDt4tSjqBzuclt=
+2k/
+zlkJmOpBYtQb+xGwpfnh2gBJdYurLwJO9rQlzYjy/+1qB0CZsE95WlkTrqQw8V5S6ULcnyACb=
+ETd
+F5HF/geHL367p/iWULD907E4DJlQBOWjY6fdsJIBj96NfQiG+cXYTNGqaB/FgW8jyoS9vyg4P=
+DOr
+0nGHLvzPw7xTDUkuoJiWXMJ9kDYTZ+MsWreA885i1JSE32CsqqP3+kI7XQD3d3T3pIPhKOo0/=
+bzb
+LY6yWBXh809Ovi9fMxaZkrlrmA3lFcY+FbzDjZB+UYOXDB6TRu1jvISVMiXnYf4X21xWyl8AW=
+v1q
+ANMSXFKUwBSR88I06QZiJBmm9wHcyVtK/Hb6pgH10LydZvIfRDLrDBc2z31rswjNj9UhNp0Qf=
+GdN
+z/gXdxc8HP7Pf4kHkjIxLrWUNlDpYddX+iz1Z//VY9h2XTmSail5pMyyXdiGm90AGfVhIcaOo=
+eKK
+9UslzsFNBF9GZTUBEADWPef8E4OUoxU+vhwCxy/4nDfxzV4ZMFYkqp8QgpLzTVgTv6xGVHFx/=
+waN
+jwR6G34tD0aYhkDrumv9QsMdiQnMw9pLAoc3bnIkL8LkXnS8fVeiuzkXd4lgvpxFlce7KYuXo=
+s9E
+w7Nm2tOx4ovoygFikjliFTKn+QOVJoTr4pxJL9RdzYQ/pV/DI/fc2cmRWy0uivP+F+LBtYW6Z=
+OMY
+1aXzsJEvun2i5ZxV2jqNDhXpD3m6/Y/28WItKbmT80hvTivxO2DSQ1kqNcwB8Z0XWZJoz6iyY=
+Uu2
+7dKB0L4S/x4UASlC6J2Db8bIL3Tdhuy+N0BN8sS1TDWb7Oi1Ad8huVxfrRSyOYj4fkksvAEgD=
+EDH
+6JEvJBU3CGQtfXCoX6d64db2cGp85GDfNHTREJ0mbRjLAKL1RKrcKOG1790OZU2veF5qiN2eN=
+08O
+LfJURL8+P4+mDWbaOcZasqNrg3YhYcPX3ZZzKfEIvvTOdqMk00JU3zaUZhJvGOR9tJ27NBTrC=
+EIO
+Hz7yzOJltTDjdfNZNLqSYFp08+vR/IjSDv8hl6PRjkomkbfdPdwPczKS0dG9Cf8cU+NZQrEgE=
+0Un
+4tvb7p55j9R5OVgHUACLFTlDIRV4veD5RnM2hUFRtBONymXEDjoPGZXaHhv16MckFpZ1IEAkM=
+IZ3
+Ti/NIZcS7IA9jRgBUQARAQABwsF2BBgBCAAgFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZ=
+TYC
+GwwACgkQMJn/EjiFK5/Q3hAAmzMu7EOeWG0xAHAQ4b/ocCSlZqg/MSf6kJIkzUxdnX9T/ylEm=
+rS8
+cEg5mdJMQMVvCecyDpNK9MgJPV7MTnR6x/4qgdVUTtknd6W7RrQ7Oai150nMH5U9M8GrFtbQj=
+c/f
+Ow17agoT06ZGV4umIK41IIGwQZ2/Z/cElHkQZll9//hYS8/E8xOBlweVxsMZhfcLFrbx2hC2o=
+sRt
+0vMlGnYSnv29ligVG+2PwwnHXB6Tn7eslzoowY78ANCTvA6Rc6zR+RIs/CIiaDNgWCRBJcueZ=
+VpA
++JkyL6KmC+JiiF6Hsm07DDDjgLVJ0s660GNe8sWw4IZ8wpvYq1goqXLu+CMqbCsBrEDwfguCl=
+xGS
+QnLwAUIVxuyKprToLJ6hmuubsVcv9fzf/GoYFnT9hge1YZpptKi/zrQqy2CZuSZEHWpUZcwPE=
+3Ow
+qbHKty3UhZPJU50kmEOd/UQNJYNWxxxx5593X96jLLDOxm5M5jNNRvGZPgn8RbA1e7VC2XFgV=
+2KG
+JHq/gxCpwkWs8+0sYUtcFuu+RQWTKbJpFcxfAIEDKS+fyLRAFdYqUA3yQIA1UYco10l8RYPLY=
+0+I
+XiArqjql8+k8PBT0U4P59lfcKlY2GaJe4aoWLPOdNZAJgLzoxd5zgnz0vI3sn+3vmeCtpxz2P=
+oYB
+JfxGPEzu9xTLV6k9wSVTCgE=3D
+=3Dt/qV
+-----END PGP PUBLIC KEY BLOCK-----
+
+--------------83F8BA9FCE9C945124B915CA--
+
+--hZdstSX9kVVgqQC8ECJGhlR1aVjuEMpuI--
+
+--vrdqJBVucR4QNMOtZVYVRGIjyNweikpUw
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsF5BAABCAAjFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl+HRbQFAwAAAAAACgkQMJn/EjiFK58u
+hhAAmMd8eBmmgXm/qLv1XrOsVOrV5wV39BgdrP0KNHN1Rd0jJmr5HWS81DbV4Yk3UPiOH0Ncj/Cg
+1EFwio+QLnn4SUMTijClTb9V9MyNPIx3IL9Vuh4VOtJb7Yk3skuTqYTk5uExwlwRxRiU40N7pO3z
+nvu/YKPHJZzndWP+p0PuEql8t+Hy5Qm/ibz/364TreLpL3lAKOS11LDQqV4HnzI4YznSlqA8E8LE
+aYJPNJb/ycWyyNohJcMqbNmPxA+V5razCeLlFJyaXw8kykctKOfvFJ2K8WtOBYfAP2/echKGeVQ9
+v0Z2/XH1Ons5vJvc+IB466CYIMAkwHS+1Yq5O/esAAvt/FVY3YBGQIOowZOy1396lykrXhKkKKoG
+Mcp1jU0Bvhyv95sqRbpRfQixGZjvmPnIcpcX8jJz+/mvKpqcFTWjDrxJX3bbeuw0nGQ9ncJD10sZ
+XB77OUyT9ye0iNxvrj6EJ4LWs96Ofq6V4Szdcn+iUHHMXdkLXoONDoF3CIt13BrIWs3p8h+hxfo3
++9gMPREXS8P9yHOX4rNT2I6hIQMNXXa7U4AV5+hn46fvgRdYLz7YHmIdD6QDhFKRujD9TqdgyYSP
+kaQTxLw0WsVXbh6mBlfyvoMREhHpCszoCFls+eIkFfxQilY5EwQffFqVDR72yTE8+AkQCw8/Es1h
+LBU=
+=dmTh
+-----END PGP SIGNATURE-----
+
+--vrdqJBVucR4QNMOtZVYVRGIjyNweikpUw--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted.eml
new file mode 100644
index 0000000000..9d63ba3fde
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0x3099ff1238852b9f-to-0xf231550c4f47e38e-unencrypted.eml
@@ -0,0 +1,57 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Subject: Signed Unencrypted
+Message-ID: <d9c78fbc-8373-4596-d806-20857e15a1af@example.com>
+Date: Wed, 14 Oct 2020 14:36:08 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="hUAWHTUaWZ5wnWnHjj7a4qhWdRkydquhh"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--hUAWHTUaWZ5wnWnHjj7a4qhWdRkydquhh
+Content-Type: multipart/mixed; boundary="oIVAE9YPgX7lo5koqmIMk4gv1QFBbuMEk";
+ protected-headers="v1"
+From: Carol <carol@example.com>
+To: alice@openpgp.example
+Message-ID: <d9c78fbc-8373-4596-d806-20857e15a1af@example.com>
+Subject: Signed Unencrypted
+
+--oIVAE9YPgX7lo5koqmIMk4gv1QFBbuMEk
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+Content-Language: en-US
+
+Sundays are nothing without callaloo.
+
+
+
+--oIVAE9YPgX7lo5koqmIMk4gv1QFBbuMEk--
+
+--hUAWHTUaWZ5wnWnHjj7a4qhWdRkydquhh
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsF5BAABCAAjFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl+HRRgFAwAAAAAACgkQMJn/EjiFK59T
+xRAAoG7+tqzXKQH2S511jRudl0HaKs+AE7kfyqbqBpWsCzcuxWIvCY3cX9ypIEhLllYWTs15aQq+
+f0GCXIK6PFGukhoQ/m49MmiGh4D7oGYxmPQyn9yZVcijqmzF5f4s7oiSKVl9/4y8H0JCHaWuelkN
+fizcAyXUWdPdefL8pIQkng+EtBM1sZ25HBJYFU6du88u0LuA3A7SNRPcRc+LhrGticIPBsDcRntm
+41bcf9QKo36EnltJjSGv3Rtp/PacyMqsmlR/UDHzVP7yWNvPboPCIB2CHVN9J1URxE2S3hjfrbY0
+fuNTgA3TlJ7crTCztIrqugZT4RxhyP3orDhp5TKYLO8q9bof6C1Zo8VbvGzVrl4eVgP0YRNN19vm
+mPeH7rF7wTPhvht0sLKcFMFTXU458SokWZW94EpTBIGNWjCKlzE8TtQPyhViVpo1RUpJQx/tr6Pb
+9r81aKJ0hnrAcDqL+PMd4UWSAONCpr9YpOEY6hj4ppqI09b0HGnBDMvLwsm+PdZ1cLsRlqzCsYfj
+tsU9QpMBV4lJoAnMkGM7pqucovyHSNcgXU/z+OLH1LmPOfPeG3kCGlbRyaQPOt2ZhQZH2f0C6Dnh
+wvmVUqGG8GWDnfVP4hzKzMQQOyWHa/F+J1nwFlbdEBH640jxPdz80/uACXwkhdn+rssEfCeB7SDP
+Cfc=
+=Q8yQ
+-----END PGP SIGNATURE-----
+
+--hUAWHTUaWZ5wnWnHjj7a4qhWdRkydquhh--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-multi-from.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-multi-from.eml
new file mode 100644
index 0000000000..fca90cbd80
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-multi-from.eml
@@ -0,0 +1,74 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Eve <eve@openpgp.example>
+From: Bob Babbage <bob@openpgp.example>
+Message-ID: <ef4ee59f-bb76-3407-ffa5-9b46eb756ae3@openpgp.example>
+Date: Wed, 14 Oct 2020 13:55:14 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="0SJCM8f3etpwdMBIl6eGvaijNVWPX6KJj"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--0SJCM8f3etpwdMBIl6eGvaijNVWPX6KJj
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--0SJCM8f3etpwdMBIl6eGvaijNVWPX6KJj
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcDMA3wvqk35PDeyAQv/W2cW1d7kc258IbJaOKjXUnhR83yOENUbSwRfhU4AOhF++W/gLSeBF/+j
+tlZsCt1AvpbBrHq+ZqgOX6+jx2FqIb9Z//iNod4QJDqIad4bmsFH2v59nf+YN1v5K8fqLV4rFZLa
+9I8SOR/k+wIaQJ9Vip81Ush1zMDP1p3h0tQJ66I7rQKqRrCE8nVeyyqbmMs9S3IHj9uZEkPzf+TB
+0BTDx1VwkequLyerODK4X0CvM/7HpDOTDht8P20fHMnzRs8/YBlx8gaKCGrH6wEws7p32FxccpNB
+03tueJtb+23yNLkWbU5zQmi6PJ2mpkyAMQoEX6hTOqB5rir8bMIfWOAJARVyivhVJlDFK1o2fFXc
+UaiiHr99S4wFLraCWbq+ma1wnZ2z+TkvaCrHw42kb+Q/riKO6A4zZXOywW9GUAaV1iL/RtLiIB7X
+TfCfCM7A883xNGuBBZO01DMVyOiwfC0WQMjjtcNHGMd5UpNiqtwi6egvcg/5gpKDxg799wMm/nnz
+wV4DR2b2udXyHrYSAQdAOOEV2h23DSYuaJJgHQLeLjt2NjUikwaNm2F+jtC2vVQwkc5MlSGDAkTp
+nzFeg2a2NLrbq043UtPrRPJjHliWE/eCDAdvdoxEzFToTstMFpNI0sYSAYBt2tSSE4gqljumdLfY
+mOsvuB3r3Q79jkvc5ZY15ip1lhtjBYRHM8eZXkJMlq38Fmq8u6E69mRcPrzYiyauCRrEAb4gQjkJ
+Rbx3H78uYzA3ILhdCyGSdZTsFD4mIRxkY208wf2EBYXgEMr6dlimDNB13JsMKihSvzu0IlKoOywE
+ViY5Qh71/Dz9ctAAE/VdFkFOJWlpZmLTrqH5J0+sS3TmumA9Y7MLrk8ERCYBCVmnXhB1ZT3Mp/26
+oOv2TnHdFIiWi4Pe1w0yuu58udBf9Z+AisSbrkPB/Z8ORdCocc+YXtzUFApLP/iKN1HabbATA/Rd
+md5SSsOnnWMlbT8n86nRKrqg6qNHnZT+BAE+fOwq5gcgC1eDdbCLthnfiXl0QTErnXdsYn4p5JTG
+MfBksBQN7KE+WIjcAHER3eMtip/5s1WBCOtmVNr1xGyHTDqE7iKsDwqjdpklOVRxtWwaxR1EiZJC
+tL3Rwsx//MevFsVgZ11pRpist07Iov67YnublHaRqNcIAx+jwaiRgnY2zJ+uCD7NNNUBh3F6QpJq
+hRMb7z4hERIl/xUBWvgOiq2nl1mEVdhe5G/e/rS0nxX3Tq3y6uKN0uHda+WhW9sX7OrMK5tx6GIV
+sfTEobGnn1eAhZ9jrz5k215FAoA9vk2VBB3M1RWi7OTl54OAA6Id13SA4rQgxgZUN79CwTNz/shS
+m8Npqg6kO4bo9tVBNdWqrzF/bGFxzXcdA00xpLy2Jx1j47HsKck0j/9Ex8VeMp8g+27DA5KeO7iw
+I7p80uA2rhhajBqE6MB0XnoCGwTApYGmU5v2gQKyUpZjeeXcByGMXc06HawxkhsLAHJTwpEn6wrW
+3fp32HVuU2l2475o+QcBovSZz2fTd4e6hug3Kk0qpzqYqx+vTo3DHWcyXFB3Q5I409axOs1KrOUp
+a7dYYNBDNTbq/+gankUTmOxo5tGANzBKLPYvbks/25Y8mAK0c5ubEk1EMMWrQUOJXaW2aEUVQDk3
+4nspxkn9K/igsp1N34soh1m91Dp7cnmIcUEE30udW8VINIPTYDPqPt/4mHTEORpT6qmZCsjF10/k
+g7tVUyifP/5DDUmdIgBeCT8XSzIgR3wVL+iAJ9MaMVwefykFQrx/8pOZFRGDt4kOHtEuJVmsDH2N
+5P+yCRcW+Pl+R9z7nyQ5+AM/AC89nFwhJO4mkSVrJndKA9CLwo0GfmDdfHPvYc2YMJr690z/yU+t
+Fcu4hH8TWGB0b7NsgX7ed+IZr84lwJ1XfubJT9ubp0ef39og91YNrxeSrZHJtmCQRur9eNOgX6Up
+MQwWeDIXWMYrbDhoUGa2awmhYOFzTJ7iO+4Z1aJP0Bl4SbXavJqRXLWEuCwkAXtCMVASDbBhGUx0
+iLNHWit04SaSAiME23+ddQkWvlGh4iJ/H8SiwHbfurSztVdIN55/T56oPW/IWOOa9PY3i+/5H1Oz
+IGXlPHkjs5ADQVneWB6kxrdGG4eKaSd4WGt4gFOvvP2Sx0V3ohen7DAUlHHoBG8q+A1098ip55hV
+P8w0QPyexV3vvXtWBZ+RziC91RhTMnFbNo+2FylS8GtZzZ6CJjHVMHi8ugrXwtFksIfGwbO2FuRF
+45D3MxW2ugAfOcVZzeTHwqROTpkpcG8cXmIvSFL4HP6F26i+AGKdYXhM7jIb60GApp+dFeqMCK2E
+6KWWmzsI5CQOi+3l0gNfTBdSl8N6qx5/HBq0bWtH4NKXCSM3sDtk0DYOu60yHioZSZJDkkJD3exv
+PGZNsO6LOJJzAzC1KmfF+Q2PN8q8f6N3O629oMT6tp4fmJFw57hGqAQqHPpCfUg3fB6/kBRmlbOA
+dRqMSnffpIF5jisERXAeomr+ouS5BoXFIqI70arXeEJnMUxXLt0Y50IXBdXwNXpbr6jkgcVmgiQn
+HLaFo9UV5RwxYMxwOjd2iJxN2Ez9S9MpHUNA9vFQMKzP5CmvmlKg2zRkhc7nSwR1dU5ukwWKu6RY
+y1c1g0UBV1zPKiuqo468DLRFzWFjCdaNZqpmzWdXAVbSVs8q/bzt9Z2GGUiWP4dGnQi1C0MZ5efh
+gjENP08KCuLJ9Ol5RPnRW/e55f6mFuHzbgVOEPQHBjh23IvXnbcTHBUSR1scMs+KAthPF/6tjEh6
+SAoc0tMxapi0tVYLT4p08aigVN2lj+qGgeXDlccOCnsbFgxDCUngnegObpYoRbi7xCNHxF3Ly79h
+089aTtiVT9ghiEqLVCiOJntKyWo06fGMFeWmyoSFwRGSQO411XhKGk0jlZ6xGLhphQi2vSouHslx
+KvoyvWLzJj8vqyGdD8NRB24JJAQo
+=ci+H
+-----END PGP MESSAGE-----
+
+--0SJCM8f3etpwdMBIl6eGvaijNVWPX6KJj--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-with-key.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-with-key.eml
new file mode 100644
index 0000000000..f21173a49f
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e-with-key.eml
@@ -0,0 +1,160 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Autocrypt: addr=bob@openpgp.example; keydata=
+ xsDNBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv/seOXpge
+ cTdOcVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz/56fW2O0F23qIRd8
+ UUJp5IIlN4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/5whqsyroEWDJoSV0yOb25B/i
+ wk/pLUFoyhDG9bj0kIzDxrEqW+7Ba8nocQlecMF3X5KMN5kp2zraLv9dlBBpWW43XktjcCZg
+ My20SouraVma8Je/ECwUWYUiAZxLIlMv9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku
+ 890uk6BrewFzJyLAx5wRZ4F0qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI
+ 2og5RsgTWtXfU7ebSGXrl5ZMpbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9
+ /0Dca3wbvLIwa3T4CyshfT0AEQEAAc0hQm9iIEJhYmJhZ2UgPGJvYkBvcGVucGdwLmV4YW1w
+ bGU+wsEOBBMBCgA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEE0aZuGiOxgsmYD3iM
+ +/zIKgFeczAFAl2lnvoACgkQ+/zIKgFeczBvbAv/VNk90a6hG8Od9xTzXxH5YRFUSGfIA1yj
+ PIVOnKqhMwps2U+sWE3urL+MvjyQRlyRV8oY9IOhQ5Esm6DOZYrTnE7qVETm1ajIAP2OFChE
+ c55uH88x/anpPOXOJY7S8jbn3naC9qad75BrZ+3g9EBUWiy5p8TykP05WSnSxNRt7vFKLfEB
+ 4nGkehpwHXOVF0CRNwYle42bg8lpmdXFDcCZCi+qEbafmTQzkAqyzS3nCh3IAqq6Y0kBuaKL
+ m2tSNUOlZbD+OHYQNZ5Jix7cZUzs6Xh4+I55NRWl5smrLq66yOQoFPy9jot/Qxikx/wP3MsA
+ zeGaZSEPc0fHp5G16rlGbxQ3vl8/usUV7W+TMEMljgwd5x8POR6HC8EaCDfVnUBCPi/Gv+eg
+ LjsIbPJZZEroiE40e6/UoCiQtlpQB5exPJYSd1Q1txCwueih99PHepsDhmUQKiACszNU+RRo
+ zAYau2VdHqnRJ7QYdxHDiH49jPK4NTMyb/tJh2TiIwcmsIpGzsDNBF2lnPIBDADWML9cbGMr
+ p12CtF9b2P6z9TTT74S8iyBOzaSvdGDQY/sUtZXRg21HWamXnn9sSXvIDEINOQ6A9QxdxoqW
+ dCHrOuW3ofneYXoG+zeKc4dC86wa1TR2q9vW+RMXSO4uImA+Uzula/6k1DogDf28qhCxMwG/
+ i/m9g1c/0aApuDyKdQ1PXsHHNlgd/Dn6rrd5y2AObaifV7wIhEJnvqgFXDN2RXGjLeCOHV4Q
+ 2WTYPg/S4k1nMXVDwZXrvIsA0YwIMgIT86Rafp1qKlgPNbiIlC1g9RY/iFaGN2b4Ir6GDohB
+ QSfZW2+LXoPZuVE/wGlQ01rh827KVZW4lXvqsge+wtnWlszcselGATyzqOK9LdHPdZGzROZY
+ I2e8c+paLNDdVPL6vdRBUnkCaEkOtl1mr2JpQi5nTU+gTX4IeInC7E+1a9UDF/Y85ybUz8XV
+ 8rUnR76UqVC7KidNepdHbZjjXCt8/Zo+Tec9JNbYNQB/e9ExmDntmlHEsSEQzFwzj8sxH48A
+ EQEAAcLA9gQYAQoAIBYhBNGmbhojsYLJmA94jPv8yCoBXnMwBQJdpZzyAhsMAAoJEPv8yCoB
+ XnMw6f8L/26C34dkjBffTzMj5Bdzm8MtF67OYneJ4TQMw7+41IL4rVcSKhIhk/3Ud5knaRtP
+ 2ef1+5F66h9/RPQOJ5+tvBwhBAcUWSupKnUrdVaZQanYmtSxcVV2PL9+QEiNN3tzluhaWO//
+ rACxJ+K/ZXQlIzwQVTpNhfGzAaMVV9zpf3u0k14itcv6alKY8+rLZvO1wIIeRZLmU0tZDD5H
+ tWDvUV7rIFI1WuoLb+KZgbYn3OWjCPHVdTrdZ2CqnZbG3SXw6awH9bzRLV9EXkbhIMez0deC
+ Vdeo+wFFklh8/5VK2b0vk/+wqMJxfpa1lHvJLobzOP9fvrswsr92MA2+k901WeISR7qEzcI0
+ Fdg8AyFAExaEK6VyjP7SXGLwvfisw34OxuZr3qmx1Sufu4toH3XrB7QJN8XyqqbsGxUCBqWi
+ f9RSK4xjzRTe56iPeiSJJOIciMP9i2ldI+KgLycyeDvGoBj0HCLO3gVaBe4ubVrj5KjhX2PV
+ NEJd3XZRzaXZE2aAMQ==
+Message-ID: <753a4ab6-3513-2755-aa8f-ead42493bd01@openpgp.example>
+Date: Wed, 14 Oct 2020 14:14:07 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="4IIKWMFJjYkYUMahG01XE9Ywam1bmgvri"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--4IIKWMFJjYkYUMahG01XE9Ywam1bmgvri
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--4IIKWMFJjYkYUMahG01XE9Ywam1bmgvri
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcDMA3wvqk35PDeyAQv7Bd8ipRQy5MQbgotGZqFzedjlxiD+stG0AMwAhGDZfYWZv+obzjy15a+p
+boFFjedLlyV/kwzuhKLMuCQKKLFToyzF36bXpdoy0kek50Xahy2+cEX/OmCN9mf5+NeZsMisJQ3j
+PfEG7xlMxQUFNlSHVpJm5cM7M+2ycQWfA+MeOUbvetayAUbFLLLGTMGbfMQFhJrIPE4g6dk6Xxgy
+vlTwecjb1qFNSo1tnrHeWJpY0oeiq+6s11ab9bEnbHbxi8NMSEXt3wEbiMNCQ9SSOs4PN3AB4tQb
+Sua64joWPzxIK2pXzc1K5Od0Qw57r5BC2M+w61TqI1ANILmtqAVK8MBh62W05cq6Vyx6eSfLe/8q
+ZvJTZy44D0IsB4dxg0eOnrCK4kLosMOOsCzIC1Irk5Sq6WschSYwyG8sq4F50FCeHHWwXrKWoksI
+8O4Z5TagTrHfuYT2FdUqaTANP7O1GJhWGQ43tN8sUeVWfVed6r9bfsO1b5h/thrKVs0urxXWtxIs
+wV4DR2b2udXyHrYSAQdAA03WmjsJVYngUahv5KffsuuaHLZT+Vngb37qLSz+emcwkYsXXwVVatni
+2TDfMu89GDcWYXy54dR/m997nhjG4mu616nOgvRM4OwmLOATuGho0tHoAT8SgbwKeBMT6wsNuUow
+TfvSvyylSKcuj7hLzlC96oSmpC4/+PqqlWVJ4vZpkIMhMfwgdJz+naFGDDQ1klvl2ptMQXCmbfNm
+9vMNnOb8KRvxxmiJD+CeitdP2sAwDLqpxogTDJPTfOli7cmMF+Nm8IG4T7bdKS5Q77zwBIU9NlkD
+OyEaqsn9jq/vwYnYl0ClTtobGflCqo914XP6TkufiAFnOBK1e3wa0pbTfXob42sGPRnX18fEobwI
+MM9qJ4UF8PHYpVSCO5xRFRCNRCHoUXF5s4zc4W5+gPH4xEA4Htm8lRD88rmOEK29pi217LSKnNdX
+adG2PLr40twUbgRP7PcOT99alhfW2FCY5h/p5kw2+S5dn6jJqdaLrwkEJiOynbQQ3TpiW23yNCvL
+TTnY5qPLjRC+e2gzUMmDWOPtZpBfHL5juWG4JEXrSgDYUd64SB6OA5I01Xj/23CUKElB/Ky9XnyP
+Z9t1AN/PQrQOfIdpr0ESHqpJarkfMbEAat84I4fyb01LJqRYnmsKbhPzeA5lsVKqVMgRwcp71mX3
+V6R0K4jc1AgtSokyUcX249zGbrGNKpyvLdcXEWcCV2mddjJNn50ixZajOh2thRVGX6gP87QaAlhr
+Jh3z3omObLEm5MKEFvSc3/hykOf22cea8FK05qoE6EhNiq8T0sr2mgKz2pT7FAnajkGSdJvA3f8m
+bREoRbzuM3gJ9EjwmhcdfpCAIJuwRMN6HcXyl7r0kd+4G1BZZTMi2ivu5Y/AOtkZ8qk4xoX/vtn9
+pYoRJaonNcbE51LkgAgxkzbDynFEPMp/8iH4eOcAgHzpI0L9nett46DVr6srYHLZFjkagXoLDog0
+hHfQ1Gfd8S2gm7w1lGaxIVxCmhPM8YFbkdFwWcKhi0KTIGsvJyFVOPDHkzxo0P3YP78aE/5rFgK6
+uaYniAVJ24ixuri1ZxG9jhoSjy267WCh/Dhd6dDo24KymoHAQdcXRvPsWYhhJc5zaokRb4qIzOu+
+cG65k6s9NarkRlqTNtMJ27dr+MpyIWGciSA3tGOHzaafF24cgjLHINMhwPWatvxttQmOjNEJg0wS
+hC2jEWFTpO7BV7CJUP27j6AdmHF54ov9aK3rGlgd8vxE01QFikGZM3AgF4YWKBZXoSQ241+F4pfQ
+4criaTrr/zDNGKuwTkIfqM1dP6b1Y/DlNwIMXyh+RRhYRhJoYNNqs+xRTIfztpRyWyPWweDN4UKi
+XJY5awnc5UeSv4z5LmuBwxwfpAylZjfH1Whto0eeelSUc9hm1etbchC1C1EnbQg2jZ++Cs8g3r+P
+TkfRSVMwkf3p//6iyewEUYBHufx/tmmuemk61SG24i0d2/a2jaqYN4OSVNfs7eOvAU3CPKXMwcEM
+4oU6kNQ/WoXJAz6BRbE84Ukd/0/btAYoIuAize6pC5q/w2+CJsqJG0KZxJGRllS5NP0MpB+aUZQ0
+jP7RwR03m0giHd/E1vieBZVwmrUi8i4AUJotIBJaAavRYV+gTLQK+T3s/8xntIB19U4MRz6ZrVRL
+o3VMMnwvkyj+U9j19N19GuqFGf8FeDentVvVD1TNV0dmCB8BpaNqj2uNz7XpE6VI2p9W7bMJpc8G
+ESr0/e1BEDHqfSRjpKtuKQUFK24Ru1w8cIdFrKbj8S3CZVxG318QPcyXlR3pjB2n4DzD3TeIWIYF
+r/CoEercHbLjI1N1aN/IAx8JzPBZpmVirjnOpGGUcTAi8wgV6339Ssym3I5W9fYKsytYBjSGrKTf
+FHa73ig8KOqfVdj/vZNu9RyMnj3l5NrGP5MB+k26d8esnal4kh8IQD/mx5Ydt/0nH2e4dLj1XdB9
+pW8jJ5mCemfOuRaSXjzZRf3wL2vcX7+D+eepSK85iJbnk4iZ7/ba/ws7tuJKoDRmoDnKLbQH/pFc
+8TMyTvO0JZiPrbGQjJEPnGDF0CH+y55GDDr0DhBpcchloI1mypCwXOKOffMMd5lFUKtOTsWyhOEY
+SnThbsca4/du4W1cgJXtNqA+wa6tlZX7io0kvgvza6rXy86NzTWiT5aj/4XNWeM26ms/PEv166Ld
+GZ151dAbL+5Sbzl7or05llf/nTJT3eatz5djOTS9iz3CsyBFAgOZZdQ/lrY5zS6jQpz5n7hXmOjU
+SdxFyqv2NeqQ6gv2lfwyl2w4L8Yab5vce4aeEapYthvOF1xTlhinn4OJPQxke6WM5tL0x6Dp4BXw
+aZL55B4PSixAYbDfFO72jhtfee+JlOXCfWf7vxW3Dx7gs/XzMxcKE9JpJswlVR8X0vfaxcCiHp4W
+dGT1rF10UXElmaykiPPBFG2ehsY3N/W8TxOMwq93mKgunTgJS9wfTKs7TYL4DpCqIrBYjYTNenFy
+m2YAFA25q9f6E/bfhDl6erkSTBLu5IKM2dpnilMmH9S/zxOpdutx6nzEeSAdTfSk0Nak8+0qYn+T
+msfbW0KDtzySCHv4re1ivZiGROd14Ksq8/MNPi17HiErMzz4oHBEZ/+RRINAwZ4MTKNbDNIcyDvJ
+Rcp9QtQrvdnyTvZEoAOxfLtH3XNeBR3bCYMIGu3cliivsZffVB7xaAPuXs6G8zvd4JX1KEE45Lod
+7+CchEXCz446iz5lcee5z0XEaJl209vHiJtE8DvtdVFCBN0jdHm/nXjjEH/O6QrUQelsMdVBLAkt
+Ru4D4rRxr2TX5QmxW1zWtbM0fTXSzXxRTCa4Y1bzCG/v3X/wlebrkNCSgt6f1k+LGqlE6w7E+GfM
+j+FIjC1yLXgx+CMiIo/pP885oDhpIcW6NB4pIbcnBMMJH24z3J0NlAIPgxzaQwgPcO52UOoKrHmQ
+wKSFvjPryy8tWqCjxXJ6nGLRgUq8nryxoiaOBN5Lrs0L0XxEUYADCRJjT7mJuOUj69jOCGmEYINZ
+dBhVO3Medph38/MW/4ixLRgj8VfkJ5fVAJiUcUMwkKvdhJotklnbCWfFjNXH3D/bo6dmm5FapwRa
+pUniSXJNxmGYwWIwu6U3CgDSx6jwaosx6GXG4h/lfXxvLna6eF/gtKzGmiU1YdY3h5izNR7PzgT3
+QU9/3SVPizILon/9bPYVDcvQoKX3vdcbKo4/kJuLQh3j+qoyiNJBnpu7gVM0T1aZ4NU3VLdfxpjH
+3juaF9MayOzpgXAm9C2TR87v1l6kVHuPyYO6DDvjmHedrTVTq0wkKUz8nqetisJ3DpbEdhsfD2TV
+mlDY/4zoZdqgQWbvr45Tu1tZ1Shgik5TuneWe+dDcm/WeL8pBH6nHL0Gb65mc5rzsS7HZGppFquh
+LnOIBz7XCPHn9rCYEq1yXMB0QyhCuzW3Y1nLjoXYO+BsvKSXgejzZqkGeHP3t7PzYPDKL76GPml1
+wc85BUEB3YJj/yZ4tJMGw+HcI+b8RvFdmM+/GzSNkmpVdmtMnpLr7PHJhVo8kKtygJdTERAjAqQZ
+PmXuxwMMz5BByTw4lLGi9jeb4oWCKgFYtmUUcQZOpEU+Hrk8H85Iin1B35YcLv76bxpQN+8Qg7W/
+XivM2VtpG4kQsaWInE1ROVWFpA1gLAggVpXbq00LPAhf//st+aLq5qlRd5C0jpM/ljqO/HLHHqwi
+8w1+Cwk2WWffxRLVzz36zEdbR9Lxg7MPKpWMTz4WkPxVDWkQspwjnp5TkBiroQTAiY3UgUfwMga7
+feJnPwmDubA3P73A5RAMTnm2/SLMB3GkcE7DgWUPBEElZ5D2KVQV6a3IeuOgZXo8uYBPAq0ae/8i
+GY7oUgyFaDNXAV4eaZcyGzrqd+tzKsfiCHcQVhnuqUnFN1yUrHIfkwjCA9y3OLfIk29NeW6PCDpw
+hNo0zKc2WQ+LubhRrcpAoP/zZnruzEp4AYSq6fv+SGGm2Q9BwlW+DnUiV8zWwlZI6klywowdbhQf
+L/3Bff1KGTQ/cip35s2IdgzCisujwji+q/trYP6qOBxD1cBbNCN4/8jTGI7HQPZAMahu273wSrUp
+UPzx49JF3677D1P25Iin1++gpFOXh4wGwxEBnzxaXEVnJEGraBCkWIz+QcN+N2oO6WGX92gysXyv
+OxtmivFQhaN3so6PwIdmuzQMStNUbL36g1zjLCT3DPFybz2E6KXFsFsiOTRGwydU3WiRRkasXc2p
+vyBJKD8Izxh2S6ffxk+Uy0xCUtKxHgaK1xyOFvWE9v/67XQHvhK72NkZEg2jWPHu8LWFK3SWZ6iJ
+fIDkEcOJ1Z5SX2cMdEMHfQurteHkjqWKrmg6nDekHWh7PZiuX5JzDgySigzFB06HPGvBceHT3x5J
+pS4ZeMMDLRK1mDqTzpe87rQOeMoxJIueJsAmTtu/MFn0JScA/RehtghOUM03ukrEWiufqyNGuUrn
+rDRUaAVCW8xKh6pPmKcnKwT2q+VPoYf4L3wX072p1Xylipj7Tfac5k/FMTRW7N7rxUImOlsXEU60
+1g0QXW6qqJpKekVeWNnbvZqPJVQdKV/CGGTR3BmFKC6QNqfRJn+YrMjROX/sXxvtREZ4cV0W8GYf
+qgWuBA/zvhBfDqkGUqfquKpVQVI/OuiR4PuW9RdZNy/k4QITg49+I1vU/PnJp99DudfOgZFDBFrT
+qJLNUaqoewtnE0tANaerC/Bf9jnACMr0ZF6VBT+PKiEmPAxpW+n53quly+QSwkPpH9LjpYbpFRAE
+0yOPmrclmRXVF26FSU6QDpuGbMVJRWaS1oldaL90QeBxpwn3kj0vJBJzt2hdu4AOz8MyT4tnmtnp
+zr3WLMaT5LMSegmT3giEpDC9AZ7nt8BrkEbm+P9DKyXWEtkmQfTw9CUR4iZXrJXy1u8VIoPXNwDw
+QxbClRv6+HpGpoiHoxNpUICNbTf/YtsBt7tAAdIdcae2BxZ8DmftOu4fL8yIeeDwPFW3ZxY0xW97
+miidU4FMR0Q26WLYthAH0sRn0W5IOxPJbWH/JNVv+8emjoDgcjx9zbrPm2KyK+ic3yyvLYRkjZ04
+zNuEBdJWz73BjJiTFwftpquIkYTMvBa9oQE6mbOEQXyEORAGoBi9tq7ocgS34/WmEMZHr3d8sbLB
+e4ulJg4LQXR7pyq7secZUyAkKjiLxRVThVyZd2nnGBg1J6uQCMMF7LwpWO/bvcU1LTJ3GCRSwV68
+tL1U6F13rwp1Fs4OmzdxvggsUqDPWtS9eO18iCqMW3O6d1GLdNW6jFbavduFZFlBNcv00wywT5E3
+EVRBAoJFahmq2mXAosiEZAdoHnG1Xdr5iUXODAiDHQrSEl/5JXWkqdKEiClTUptKSryl93jngHDo
+Yb23A3yDCi6C4scrvlZYeaQWuUDs58wtFWNFCJnveIAUaZli6rqTXXGFwPf1ordb94/uYQS4vcjX
+1FE8CPDRcWFeqNHEIuVe3YFQVyfmBBZAbHQmjvBXUoDPA8pPXRECTmnu9/8kzVrdA7PcCpBxKAdZ
+TenC1qhSPCBKL7NNlsyGnc6E0v/xy5AhE4AUtwF7u+v36Xqqic+kTDhDe7pJ51WT8R29qs8BJv8G
+Mlh9skUb8lFCPZTg5sJt6y+I8x4lTo14HAyuipnUdOolAvfQx3G9OhVszhFpEdrVoXZQW8BaJceE
+auufFdWtH7jW1ocYoxdIC3l5tvuzdfXxyIXZSG976dqUQ4albKqdFoxyf1KsVrtiiF/PdB0gFRcS
+z94nB3b1kbmxMaObJnZmyNer6TqGBDBYG7dJqDVTdGeNVB42E1Ez/KzBdwZf7TXYcCoClmLVXJZs
+xaR8tn8hACyU+6GwVOj3tF2YBMkENKNb5HM7zu/2NZydSi6VV9AVXuJcd/SdJOkCBrTNJ3ly6r6H
+A105pd1yaxespTf5NcCHPTgThfRRhtD4kgpXkWH+YXL2BlpaZgBAuE/2bOM4kQDjuK9gznCiMJur
+kGOPXhrLCqv2v+ypK4TGAerXyFYr/lNU2OIM4inhK3csHvoU+rlU3Usb4U1UicXLrO3TuQAMBQ7x
+BeRuigowlOcLThTetHKfHSNvhXEoj1aPtEacM+WmvS6ttN7E3Vk8tx/UHiX4tDnEJgACoY6+HM6a
+1p+e92jpc76TjfUN18Tj4E9JAX5AsGWO1iCRf1wC3GH3umYj2JZr1eZe+hAAN/rl2LdI2zO+6RmE
+wBWPj16wIeNBx2yG+UoQPYcJapPMO9rpGzvaNaXrI0rLFBKwEu0DF+ISzZHfHQa8ot+Nx2aFEz00
+iezou+9Br9gTRUiPZZb3qMek8H1vPo9cTpJLCCiP1IRQoFr6fN0/QtzsEQ4di2Y2bLAKDTJn86oq
+BgCSH4MJvMV0fydDDLYO2e8z3LzVnl8EO8u8O9LfQOQWsbnfGkraF5bRXPLUjsYjYNkf/rFO01/s
+Njzlx9VJmY9jITEFIn7Mw1tpMU0vuiNtJazLZDWU
+=G2xi
+-----END PGP MESSAGE-----
+
+--4IIKWMFJjYkYUMahG01XE9Ywam1bmgvri--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml
new file mode 100644
index 0000000000..643f90c76f
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-encrypted-to-0xf231550c4f47e38e.eml
@@ -0,0 +1,73 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Message-ID: <ef4ee59f-bb76-3407-ffa5-9b46eb756ae3@openpgp.example>
+Date: Wed, 14 Oct 2020 13:55:14 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="0SJCM8f3etpwdMBIl6eGvaijNVWPX6KJj"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--0SJCM8f3etpwdMBIl6eGvaijNVWPX6KJj
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--0SJCM8f3etpwdMBIl6eGvaijNVWPX6KJj
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcDMA3wvqk35PDeyAQv/W2cW1d7kc258IbJaOKjXUnhR83yOENUbSwRfhU4AOhF++W/gLSeBF/+j
+tlZsCt1AvpbBrHq+ZqgOX6+jx2FqIb9Z//iNod4QJDqIad4bmsFH2v59nf+YN1v5K8fqLV4rFZLa
+9I8SOR/k+wIaQJ9Vip81Ush1zMDP1p3h0tQJ66I7rQKqRrCE8nVeyyqbmMs9S3IHj9uZEkPzf+TB
+0BTDx1VwkequLyerODK4X0CvM/7HpDOTDht8P20fHMnzRs8/YBlx8gaKCGrH6wEws7p32FxccpNB
+03tueJtb+23yNLkWbU5zQmi6PJ2mpkyAMQoEX6hTOqB5rir8bMIfWOAJARVyivhVJlDFK1o2fFXc
+UaiiHr99S4wFLraCWbq+ma1wnZ2z+TkvaCrHw42kb+Q/riKO6A4zZXOywW9GUAaV1iL/RtLiIB7X
+TfCfCM7A883xNGuBBZO01DMVyOiwfC0WQMjjtcNHGMd5UpNiqtwi6egvcg/5gpKDxg799wMm/nnz
+wV4DR2b2udXyHrYSAQdAOOEV2h23DSYuaJJgHQLeLjt2NjUikwaNm2F+jtC2vVQwkc5MlSGDAkTp
+nzFeg2a2NLrbq043UtPrRPJjHliWE/eCDAdvdoxEzFToTstMFpNI0sYSAYBt2tSSE4gqljumdLfY
+mOsvuB3r3Q79jkvc5ZY15ip1lhtjBYRHM8eZXkJMlq38Fmq8u6E69mRcPrzYiyauCRrEAb4gQjkJ
+Rbx3H78uYzA3ILhdCyGSdZTsFD4mIRxkY208wf2EBYXgEMr6dlimDNB13JsMKihSvzu0IlKoOywE
+ViY5Qh71/Dz9ctAAE/VdFkFOJWlpZmLTrqH5J0+sS3TmumA9Y7MLrk8ERCYBCVmnXhB1ZT3Mp/26
+oOv2TnHdFIiWi4Pe1w0yuu58udBf9Z+AisSbrkPB/Z8ORdCocc+YXtzUFApLP/iKN1HabbATA/Rd
+md5SSsOnnWMlbT8n86nRKrqg6qNHnZT+BAE+fOwq5gcgC1eDdbCLthnfiXl0QTErnXdsYn4p5JTG
+MfBksBQN7KE+WIjcAHER3eMtip/5s1WBCOtmVNr1xGyHTDqE7iKsDwqjdpklOVRxtWwaxR1EiZJC
+tL3Rwsx//MevFsVgZ11pRpist07Iov67YnublHaRqNcIAx+jwaiRgnY2zJ+uCD7NNNUBh3F6QpJq
+hRMb7z4hERIl/xUBWvgOiq2nl1mEVdhe5G/e/rS0nxX3Tq3y6uKN0uHda+WhW9sX7OrMK5tx6GIV
+sfTEobGnn1eAhZ9jrz5k215FAoA9vk2VBB3M1RWi7OTl54OAA6Id13SA4rQgxgZUN79CwTNz/shS
+m8Npqg6kO4bo9tVBNdWqrzF/bGFxzXcdA00xpLy2Jx1j47HsKck0j/9Ex8VeMp8g+27DA5KeO7iw
+I7p80uA2rhhajBqE6MB0XnoCGwTApYGmU5v2gQKyUpZjeeXcByGMXc06HawxkhsLAHJTwpEn6wrW
+3fp32HVuU2l2475o+QcBovSZz2fTd4e6hug3Kk0qpzqYqx+vTo3DHWcyXFB3Q5I409axOs1KrOUp
+a7dYYNBDNTbq/+gankUTmOxo5tGANzBKLPYvbks/25Y8mAK0c5ubEk1EMMWrQUOJXaW2aEUVQDk3
+4nspxkn9K/igsp1N34soh1m91Dp7cnmIcUEE30udW8VINIPTYDPqPt/4mHTEORpT6qmZCsjF10/k
+g7tVUyifP/5DDUmdIgBeCT8XSzIgR3wVL+iAJ9MaMVwefykFQrx/8pOZFRGDt4kOHtEuJVmsDH2N
+5P+yCRcW+Pl+R9z7nyQ5+AM/AC89nFwhJO4mkSVrJndKA9CLwo0GfmDdfHPvYc2YMJr690z/yU+t
+Fcu4hH8TWGB0b7NsgX7ed+IZr84lwJ1XfubJT9ubp0ef39og91YNrxeSrZHJtmCQRur9eNOgX6Up
+MQwWeDIXWMYrbDhoUGa2awmhYOFzTJ7iO+4Z1aJP0Bl4SbXavJqRXLWEuCwkAXtCMVASDbBhGUx0
+iLNHWit04SaSAiME23+ddQkWvlGh4iJ/H8SiwHbfurSztVdIN55/T56oPW/IWOOa9PY3i+/5H1Oz
+IGXlPHkjs5ADQVneWB6kxrdGG4eKaSd4WGt4gFOvvP2Sx0V3ohen7DAUlHHoBG8q+A1098ip55hV
+P8w0QPyexV3vvXtWBZ+RziC91RhTMnFbNo+2FylS8GtZzZ6CJjHVMHi8ugrXwtFksIfGwbO2FuRF
+45D3MxW2ugAfOcVZzeTHwqROTpkpcG8cXmIvSFL4HP6F26i+AGKdYXhM7jIb60GApp+dFeqMCK2E
+6KWWmzsI5CQOi+3l0gNfTBdSl8N6qx5/HBq0bWtH4NKXCSM3sDtk0DYOu60yHioZSZJDkkJD3exv
+PGZNsO6LOJJzAzC1KmfF+Q2PN8q8f6N3O629oMT6tp4fmJFw57hGqAQqHPpCfUg3fB6/kBRmlbOA
+dRqMSnffpIF5jisERXAeomr+ouS5BoXFIqI70arXeEJnMUxXLt0Y50IXBdXwNXpbr6jkgcVmgiQn
+HLaFo9UV5RwxYMxwOjd2iJxN2Ez9S9MpHUNA9vFQMKzP5CmvmlKg2zRkhc7nSwR1dU5ukwWKu6RY
+y1c1g0UBV1zPKiuqo468DLRFzWFjCdaNZqpmzWdXAVbSVs8q/bzt9Z2GGUiWP4dGnQi1C0MZ5efh
+gjENP08KCuLJ9Ol5RPnRW/e55f6mFuHzbgVOEPQHBjh23IvXnbcTHBUSR1scMs+KAthPF/6tjEh6
+SAoc0tMxapi0tVYLT4p08aigVN2lj+qGgeXDlccOCnsbFgxDCUngnegObpYoRbi7xCNHxF3Ly79h
+089aTtiVT9ghiEqLVCiOJntKyWo06fGMFeWmyoSFwRGSQO411XhKGk0jlZ6xGLhphQi2vSouHslx
+KvoyvWLzJj8vqyGdD8NRB24JJAQo
+=ci+H
+-----END PGP MESSAGE-----
+
+--0SJCM8f3etpwdMBIl6eGvaijNVWPX6KJj--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted-with-key.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted-with-key.eml
new file mode 100644
index 0000000000..bf3efec380
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted-with-key.eml
@@ -0,0 +1,167 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Subject: Signed Unencrypted With Key
+Autocrypt: addr=bob@openpgp.example; keydata=
+ xsDNBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv/seOXpge
+ cTdOcVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz/56fW2O0F23qIRd8
+ UUJp5IIlN4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/5whqsyroEWDJoSV0yOb25B/i
+ wk/pLUFoyhDG9bj0kIzDxrEqW+7Ba8nocQlecMF3X5KMN5kp2zraLv9dlBBpWW43XktjcCZg
+ My20SouraVma8Je/ECwUWYUiAZxLIlMv9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku
+ 890uk6BrewFzJyLAx5wRZ4F0qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI
+ 2og5RsgTWtXfU7ebSGXrl5ZMpbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9
+ /0Dca3wbvLIwa3T4CyshfT0AEQEAAc0hQm9iIEJhYmJhZ2UgPGJvYkBvcGVucGdwLmV4YW1w
+ bGU+wsEOBBMBCgA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEE0aZuGiOxgsmYD3iM
+ +/zIKgFeczAFAl2lnvoACgkQ+/zIKgFeczBvbAv/VNk90a6hG8Od9xTzXxH5YRFUSGfIA1yj
+ PIVOnKqhMwps2U+sWE3urL+MvjyQRlyRV8oY9IOhQ5Esm6DOZYrTnE7qVETm1ajIAP2OFChE
+ c55uH88x/anpPOXOJY7S8jbn3naC9qad75BrZ+3g9EBUWiy5p8TykP05WSnSxNRt7vFKLfEB
+ 4nGkehpwHXOVF0CRNwYle42bg8lpmdXFDcCZCi+qEbafmTQzkAqyzS3nCh3IAqq6Y0kBuaKL
+ m2tSNUOlZbD+OHYQNZ5Jix7cZUzs6Xh4+I55NRWl5smrLq66yOQoFPy9jot/Qxikx/wP3MsA
+ zeGaZSEPc0fHp5G16rlGbxQ3vl8/usUV7W+TMEMljgwd5x8POR6HC8EaCDfVnUBCPi/Gv+eg
+ LjsIbPJZZEroiE40e6/UoCiQtlpQB5exPJYSd1Q1txCwueih99PHepsDhmUQKiACszNU+RRo
+ zAYau2VdHqnRJ7QYdxHDiH49jPK4NTMyb/tJh2TiIwcmsIpGzsDNBF2lnPIBDADWML9cbGMr
+ p12CtF9b2P6z9TTT74S8iyBOzaSvdGDQY/sUtZXRg21HWamXnn9sSXvIDEINOQ6A9QxdxoqW
+ dCHrOuW3ofneYXoG+zeKc4dC86wa1TR2q9vW+RMXSO4uImA+Uzula/6k1DogDf28qhCxMwG/
+ i/m9g1c/0aApuDyKdQ1PXsHHNlgd/Dn6rrd5y2AObaifV7wIhEJnvqgFXDN2RXGjLeCOHV4Q
+ 2WTYPg/S4k1nMXVDwZXrvIsA0YwIMgIT86Rafp1qKlgPNbiIlC1g9RY/iFaGN2b4Ir6GDohB
+ QSfZW2+LXoPZuVE/wGlQ01rh827KVZW4lXvqsge+wtnWlszcselGATyzqOK9LdHPdZGzROZY
+ I2e8c+paLNDdVPL6vdRBUnkCaEkOtl1mr2JpQi5nTU+gTX4IeInC7E+1a9UDF/Y85ybUz8XV
+ 8rUnR76UqVC7KidNepdHbZjjXCt8/Zo+Tec9JNbYNQB/e9ExmDntmlHEsSEQzFwzj8sxH48A
+ EQEAAcLA9gQYAQoAIBYhBNGmbhojsYLJmA94jPv8yCoBXnMwBQJdpZzyAhsMAAoJEPv8yCoB
+ XnMw6f8L/26C34dkjBffTzMj5Bdzm8MtF67OYneJ4TQMw7+41IL4rVcSKhIhk/3Ud5knaRtP
+ 2ef1+5F66h9/RPQOJ5+tvBwhBAcUWSupKnUrdVaZQanYmtSxcVV2PL9+QEiNN3tzluhaWO//
+ rACxJ+K/ZXQlIzwQVTpNhfGzAaMVV9zpf3u0k14itcv6alKY8+rLZvO1wIIeRZLmU0tZDD5H
+ tWDvUV7rIFI1WuoLb+KZgbYn3OWjCPHVdTrdZ2CqnZbG3SXw6awH9bzRLV9EXkbhIMez0deC
+ Vdeo+wFFklh8/5VK2b0vk/+wqMJxfpa1lHvJLobzOP9fvrswsr92MA2+k901WeISR7qEzcI0
+ Fdg8AyFAExaEK6VyjP7SXGLwvfisw34OxuZr3qmx1Sufu4toH3XrB7QJN8XyqqbsGxUCBqWi
+ f9RSK4xjzRTe56iPeiSJJOIciMP9i2ldI+KgLycyeDvGoBj0HCLO3gVaBe4ubVrj5KjhX2PV
+ NEJd3XZRzaXZE2aAMQ==
+Message-ID: <62904db5-6147-c67f-502c-c24b396d5688@openpgp.example>
+Date: Wed, 14 Oct 2020 14:23:58 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="SnZKl30WhaDQBele2hNh7m1E1rjNUzy75"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--SnZKl30WhaDQBele2hNh7m1E1rjNUzy75
+Content-Type: multipart/mixed; boundary="zMuh5ZSCGRiyd3u4jmAM5Q7pZNJ1JNY0i";
+ protected-headers="v1"
+From: Bob Babbage <bob@openpgp.example>
+To: alice@openpgp.example
+Message-ID: <62904db5-6147-c67f-502c-c24b396d5688@openpgp.example>
+Subject: Signed Unencrypted With Key
+
+--zMuh5ZSCGRiyd3u4jmAM5Q7pZNJ1JNY0i
+Content-Type: multipart/mixed;
+ boundary="------------64A7DAE7CD7D84EBF8E46B7E"
+Content-Language: en-US
+
+This is a multi-part message in MIME format.
+--------------64A7DAE7CD7D84EBF8E46B7E
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+
+Sundays are nothing without callaloo.
+
+
+--------------64A7DAE7CD7D84EBF8E46B7E
+Content-Type: application/pgp-keys;
+ name="OpenPGP_0xFBFCC82A015E7330.asc"
+Content-Transfer-Encoding: quoted-printable
+Content-Disposition: attachment;
+ filename="OpenPGP_0xFBFCC82A015E7330.asc"
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsDNBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv/seOXpgec=
+TdO
+cVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz/56fW2O0F23qIRd8UUJp5=
+IIl
+N4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/5whqsyroEWDJoSV0yOb25B/iwk/pLUFoy=
+hDG
+9bj0kIzDxrEqW+7Ba8nocQlecMF3X5KMN5kp2zraLv9dlBBpWW43XktjcCZgMy20SouraVma8=
+Je/
+ECwUWYUiAZxLIlMv9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku890uk6BrewFzJyLAx=
+5wR
+Z4F0qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI2og5RsgTWtXfU7ebSGXrl=
+5ZM
+pbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9/0Dca3wbvLIwa3T4CyshfT0AE=
+QEA
+Ac0hQm9iIEJhYmJhZ2UgPGJvYkBvcGVucGdwLmV4YW1wbGU+wsEOBBMBCgA4AhsDBQsJCAcCB=
+hUK
+CQgLAgQWAgMBAh4BAheAFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAl2lnvoACgkQ+/zIKgFec=
+zBv
+bAv/VNk90a6hG8Od9xTzXxH5YRFUSGfIA1yjPIVOnKqhMwps2U+sWE3urL+MvjyQRlyRV8oY9=
+IOh
+Q5Esm6DOZYrTnE7qVETm1ajIAP2OFChEc55uH88x/anpPOXOJY7S8jbn3naC9qad75BrZ+3g9=
+EBU
+Wiy5p8TykP05WSnSxNRt7vFKLfEB4nGkehpwHXOVF0CRNwYle42bg8lpmdXFDcCZCi+qEbafm=
+TQz
+kAqyzS3nCh3IAqq6Y0kBuaKLm2tSNUOlZbD+OHYQNZ5Jix7cZUzs6Xh4+I55NRWl5smrLq66y=
+OQo
+FPy9jot/Qxikx/wP3MsAzeGaZSEPc0fHp5G16rlGbxQ3vl8/usUV7W+TMEMljgwd5x8POR6HC=
+8Ea
+CDfVnUBCPi/Gv+egLjsIbPJZZEroiE40e6/UoCiQtlpQB5exPJYSd1Q1txCwueih99PHepsDh=
+mUQ
+KiACszNU+RRozAYau2VdHqnRJ7QYdxHDiH49jPK4NTMyb/tJh2TiIwcmsIpGzsDNBF2lnPIBD=
+ADW
+ML9cbGMrp12CtF9b2P6z9TTT74S8iyBOzaSvdGDQY/sUtZXRg21HWamXnn9sSXvIDEINOQ6A9=
+Qxd
+xoqWdCHrOuW3ofneYXoG+zeKc4dC86wa1TR2q9vW+RMXSO4uImA+Uzula/6k1DogDf28qhCxM=
+wG/
+i/m9g1c/0aApuDyKdQ1PXsHHNlgd/Dn6rrd5y2AObaifV7wIhEJnvqgFXDN2RXGjLeCOHV4Q2=
+WTY
+Pg/S4k1nMXVDwZXrvIsA0YwIMgIT86Rafp1qKlgPNbiIlC1g9RY/iFaGN2b4Ir6GDohBQSfZW=
+2+L
+XoPZuVE/wGlQ01rh827KVZW4lXvqsge+wtnWlszcselGATyzqOK9LdHPdZGzROZYI2e8c+paL=
+NDd
+VPL6vdRBUnkCaEkOtl1mr2JpQi5nTU+gTX4IeInC7E+1a9UDF/Y85ybUz8XV8rUnR76UqVC7K=
+idN
+epdHbZjjXCt8/Zo+Tec9JNbYNQB/e9ExmDntmlHEsSEQzFwzj8sxH48AEQEAAcLA9gQYAQoAI=
+BYh
+BNGmbhojsYLJmA94jPv8yCoBXnMwBQJdpZzyAhsMAAoJEPv8yCoBXnMw6f8L/26C34dkjBffT=
+zMj
+5Bdzm8MtF67OYneJ4TQMw7+41IL4rVcSKhIhk/3Ud5knaRtP2ef1+5F66h9/RPQOJ5+tvBwhB=
+AcU
+WSupKnUrdVaZQanYmtSxcVV2PL9+QEiNN3tzluhaWO//rACxJ+K/ZXQlIzwQVTpNhfGzAaMVV=
+9zp
+f3u0k14itcv6alKY8+rLZvO1wIIeRZLmU0tZDD5HtWDvUV7rIFI1WuoLb+KZgbYn3OWjCPHVd=
+Trd
+Z2CqnZbG3SXw6awH9bzRLV9EXkbhIMez0deCVdeo+wFFklh8/5VK2b0vk/+wqMJxfpa1lHvJL=
+obz
+OP9fvrswsr92MA2+k901WeISR7qEzcI0Fdg8AyFAExaEK6VyjP7SXGLwvfisw34OxuZr3qmx1=
+Suf
+u4toH3XrB7QJN8XyqqbsGxUCBqWif9RSK4xjzRTe56iPeiSJJOIciMP9i2ldI+KgLycyeDvGo=
+Bj0
+HCLO3gVaBe4ubVrj5KjhX2PVNEJd3XZRzaXZE2aAMQ=3D=3D
+=3DF9yX
+-----END PGP PUBLIC KEY BLOCK-----
+
+--------------64A7DAE7CD7D84EBF8E46B7E--
+
+--zMuh5ZSCGRiyd3u4jmAM5Q7pZNJ1JNY0i--
+
+--SnZKl30WhaDQBele2hNh7m1E1rjNUzy75
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsD5BAABCAAjFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAl+HQj4FAwAAAAAACgkQ+/zIKgFeczCg
+Cwv/SjU90dImzX5R7jbdaU/VwjUfPJ8XyAfmsVIaE3UXrNxgTQ1bsDIqqHdkPSkU5FCR1k34P/Kz
+kmqXvZPpdd+3aoxuUZPPqUd7e0uVWGBRiR70IyvgLLNP5ixh6Ct/tbc+Tf6lpg0jw1w06GW9rUxm
+VpzVt3tEty70P3kD6dlf4eNpswT7PgqWuQGKNRA0xylEfDXOKRTDG8VIX+AGZI0tlpmm3FtJ3XKG
+tvvj9L2ENz4GGWNHUZBAvjLiRfkEX3uKywELX/F1wVZDZGjOEBMVHEy8m5ZkLP1F+pw4GvOVNgIa
+lUPT0q7U5kw9/P6T1NEE1nxpOcqnfNTJvXt1rOr1vDLSoDk+9JlMUn68jT95XwGEoflq6WEuXSwp
+Iu+S3MwKL4un7dLW7xRjdBkORSQD6QOVpoXlUiGzqpFQm4XQT8Jw7HcyIPWfZdVtwJJKd3+uCvGu
+tWEXqmIdXaDGRd4oHlGBjqSPaZ20PzfrWk7VqLp5locKAO32Zkm+fEBxmN/p
+=AeKW
+-----END PGP SIGNATURE-----
+
+--SnZKl30WhaDQBele2hNh7m1E1rjNUzy75--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted.eml b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted.eml
new file mode 100644
index 0000000000..a01faf34fc
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-by-0xfbfcc82a015e7330-to-0xf231550c4f47e38e-unencrypted.eml
@@ -0,0 +1,54 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Subject: Signed Unencrypted
+Message-ID: <de411532-9bd9-a30b-37a5-294171a0e1f5@openpgp.example>
+Date: Wed, 14 Oct 2020 14:21:06 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="rj47z8rbyl2MhEfaEZQptaxOOrVHArWV4"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--rj47z8rbyl2MhEfaEZQptaxOOrVHArWV4
+Content-Type: multipart/mixed; boundary="PNIE8wtZaKiN071KjiT0rwF4fNeu8GK6i";
+ protected-headers="v1"
+From: Bob Babbage <bob@openpgp.example>
+To: alice@openpgp.example
+Message-ID: <de411532-9bd9-a30b-37a5-294171a0e1f5@openpgp.example>
+Subject: Signed Unencrypted
+
+--PNIE8wtZaKiN071KjiT0rwF4fNeu8GK6i
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+Content-Language: en-US
+
+Sundays are nothing without callaloo.
+
+
+
+--PNIE8wtZaKiN071KjiT0rwF4fNeu8GK6i--
+
+--rj47z8rbyl2MhEfaEZQptaxOOrVHArWV4
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsD5BAABCAAjFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAl+HQZIFAwAAAAAACgkQ+/zIKgFeczBX
+Pgv8D77zOWXaX3xsREJr6KNHkD6Gj52illymZBY1Km5+WMW0JmEdt5T0UOpAoVTbxHr/1z+G7iw6
+vgoBRNdzMoFTGYHy6wqW+2A3pKiGc2CD/789GYL/c3dVEIJuBr0FzDRMO8VQhjq/56s0FnaFjm7L
+JtEUjBZzaafQaxzmbnSXjSZLfCLh1YRRmgxeOA5CcyMY1HlgsvvSfdNH32o+ZPgSG4C7s4z2iinH
+uZFE2SbQ/qvyBWnslr1+2/r8YFhtknLsK14memZb8bIAtrh8Hxq9BRg3A4+spD4uR7C+1hcLbWRD
+x5b46ehcULzoTcnDLmQ34/zymIANCzSdww7Ofmhw7c/5sK9BL/fjgiT4qKxFOr/12/Ym6cXqtUgN
+Eh9XsQt10ZP707BodH9ga9rIR5NIZr6xYZx0iX7FPKxXw8Kf8ovxiI+ATYb4B6fv6PRxyD6yYfLx
+ZKBnZfk5UOe/GqktKFFHO2EVe3z8eYgS8k1hWXuvkKlXDb6PqDRUE42K6yAD
+=/kuV
+-----END PGP SIGNATURE-----
+
+--rj47z8rbyl2MhEfaEZQptaxOOrVHArWV4--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-encrypted-autocrypt-gossip.eml b/comm/mail/test/browser/openpgp/data/eml/signed-encrypted-autocrypt-gossip.eml
new file mode 100644
index 0000000000..23aca2fa21
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-encrypted-autocrypt-gossip.eml
@@ -0,0 +1,174 @@
+X-Mozilla-Status: 0800
+X-Mozilla-Status2: 00000000
+Message-ID: <e8690528-d187-4d99-b505-9f3d6a2704ca@openpgp.example>
+Date: Wed, 11 Oct 2023 14:35:24 +0200
+MIME-Version: 1.0
+User-Agent: Thunderbird Daily
+Content-Language: en-US
+To: alice@openpgp.example, carol@example.com
+From: bob@openpgp.example
+Autocrypt: addr=bob@openpgp.example; keydata=
+ xsDNBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv/seOXpge
+ cTdOcVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz/56fW2O0F23qIRd8
+ UUJp5IIlN4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/5whqsyroEWDJoSV0yOb25B/i
+ wk/pLUFoyhDG9bj0kIzDxrEqW+7Ba8nocQlecMF3X5KMN5kp2zraLv9dlBBpWW43XktjcCZg
+ My20SouraVma8Je/ECwUWYUiAZxLIlMv9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku
+ 890uk6BrewFzJyLAx5wRZ4F0qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI
+ 2og5RsgTWtXfU7ebSGXrl5ZMpbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9
+ /0Dca3wbvLIwa3T4CyshfT0AEQEAAc0hQm9iIEJhYmJhZ2UgPGJvYkBvcGVucGdwLmV4YW1w
+ bGU+wsEOBBMBCgA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEE0aZuGiOxgsmYD3iM
+ +/zIKgFeczAFAl2lnvoACgkQ+/zIKgFeczBvbAv/VNk90a6hG8Od9xTzXxH5YRFUSGfIA1yj
+ PIVOnKqhMwps2U+sWE3urL+MvjyQRlyRV8oY9IOhQ5Esm6DOZYrTnE7qVETm1ajIAP2OFChE
+ c55uH88x/anpPOXOJY7S8jbn3naC9qad75BrZ+3g9EBUWiy5p8TykP05WSnSxNRt7vFKLfEB
+ 4nGkehpwHXOVF0CRNwYle42bg8lpmdXFDcCZCi+qEbafmTQzkAqyzS3nCh3IAqq6Y0kBuaKL
+ m2tSNUOlZbD+OHYQNZ5Jix7cZUzs6Xh4+I55NRWl5smrLq66yOQoFPy9jot/Qxikx/wP3MsA
+ zeGaZSEPc0fHp5G16rlGbxQ3vl8/usUV7W+TMEMljgwd5x8POR6HC8EaCDfVnUBCPi/Gv+eg
+ LjsIbPJZZEroiE40e6/UoCiQtlpQB5exPJYSd1Q1txCwueih99PHepsDhmUQKiACszNU+RRo
+ zAYau2VdHqnRJ7QYdxHDiH49jPK4NTMyb/tJh2TiIwcmsIpGzsDNBF2lnPIBDADWML9cbGMr
+ p12CtF9b2P6z9TTT74S8iyBOzaSvdGDQY/sUtZXRg21HWamXnn9sSXvIDEINOQ6A9QxdxoqW
+ dCHrOuW3ofneYXoG+zeKc4dC86wa1TR2q9vW+RMXSO4uImA+Uzula/6k1DogDf28qhCxMwG/
+ i/m9g1c/0aApuDyKdQ1PXsHHNlgd/Dn6rrd5y2AObaifV7wIhEJnvqgFXDN2RXGjLeCOHV4Q
+ 2WTYPg/S4k1nMXVDwZXrvIsA0YwIMgIT86Rafp1qKlgPNbiIlC1g9RY/iFaGN2b4Ir6GDohB
+ QSfZW2+LXoPZuVE/wGlQ01rh827KVZW4lXvqsge+wtnWlszcselGATyzqOK9LdHPdZGzROZY
+ I2e8c+paLNDdVPL6vdRBUnkCaEkOtl1mr2JpQi5nTU+gTX4IeInC7E+1a9UDF/Y85ybUz8XV
+ 8rUnR76UqVC7KidNepdHbZjjXCt8/Zo+Tec9JNbYNQB/e9ExmDntmlHEsSEQzFwzj8sxH48A
+ EQEAAcLA9gQYAQoAIBYhBNGmbhojsYLJmA94jPv8yCoBXnMwBQJdpZzyAhsMAAoJEPv8yCoB
+ XnMw6f8L/26C34dkjBffTzMj5Bdzm8MtF67OYneJ4TQMw7+41IL4rVcSKhIhk/3Ud5knaRtP
+ 2ef1+5F66h9/RPQOJ5+tvBwhBAcUWSupKnUrdVaZQanYmtSxcVV2PL9+QEiNN3tzluhaWO//
+ rACxJ+K/ZXQlIzwQVTpNhfGzAaMVV9zpf3u0k14itcv6alKY8+rLZvO1wIIeRZLmU0tZDD5H
+ tWDvUV7rIFI1WuoLb+KZgbYn3OWjCPHVdTrdZ2CqnZbG3SXw6awH9bzRLV9EXkbhIMez0deC
+ Vdeo+wFFklh8/5VK2b0vk/+wqMJxfpa1lHvJLobzOP9fvrswsr92MA2+k901WeISR7qEzcI0
+ Fdg8AyFAExaEK6VyjP7SXGLwvfisw34OxuZr3qmx1Sufu4toH3XrB7QJN8XyqqbsGxUCBqWi
+ f9RSK4xjzRTe56iPeiSJJOIciMP9i2ldI+KgLycyeDvGoBj0HCLO3gVaBe4ubVrj5KjhX2PV
+ NEJd3XZRzaXZE2aAMQ==
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0;
+ attachmentreminder=0; deliveryformat=0
+X-Identity-Key: id2
+Fcc: mailbox://nobody@Local%20Folders/Sent
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="------------PVS0UUCXkX51e6TO2TaXFns1"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--------------PVS0UUCXkX51e6TO2TaXFns1
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--------------PVS0UUCXkX51e6TO2TaXFns1
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcDMA3wvqk35PDeyAQwAi0t/ENMIzcOg1I0D4o0/L25FD/2iayaaK/gyA3LXzpcLrL4VmVksX5/W
+nJexWvr4LoUJGG9TLFYg7sDzWyH1D98D43WaEUzy6L/b+5HKekcZhdp8SPKpBOR8E26dsp+44GMn
+R/sv3i9VvdrvR5umfCF20T21NCK4Oy/BFhzDK41Z2p8b9LCsqopH8e8Fmd4Dg6+pSBLOMw82hSkO
+clKzWfnMtQhagitZyNENAh/ehAYhZbyhN5WiBldn71jVbRxroo5UbMkF1rluBP+UFqm1F9phCoxm
+URa0n5gl0PElCTJr6pyWYZLUz14l9Fa+3HgPZrP7wBD1v956L8N5Y9JV/BxGXMyieq86XhV9vp7r
+cAg8uRKvWMOX6UPmbC5KS90GTaM59h92EU+CTCgJ5TYEQLrbuzj/2R7p1n+/XlcrMdMyN9uM0ydS
+UYifYTNY6Qw5RYKTZ+kBSCHte4/GG96IrC9sNPnu15pfPhRGNvPUIuXAJ8mWgECYnW2N8ZotIVOE
+wV4DR2b2udXyHrYSAQdAv78EcwfZJm1uUEjrZSNAtvXE9Ibv+Lk0uqP+KIY6F3ww+vG4f+NnQRmV
+gEr1+u7KNcVhjU+0veNYvaiitcBK48NwntToMxTvEQsw1Ae8JCp5wcFMA7L9So5P9bk9AQ/8D37S
+dtZ0z+K2BxnvNDIJVjoRdtNLP6x6ffc/gTBrSmtja4F6fltQQvjexAQBC16Kf+Zqi4RPNboX3S7v
+ZnQpOBFV0RP/Ra+aUoXGiwzreWqpCWyEX5cy56iuaoyXkvz7+poIkKnizdDTyyBSUWhiChzu3ZKn
+DYZi4B1Nn09uU+wxBVGfLcYh2ax3u61JgQRgYdhcBANYF2hD5FfR38IwXFaI1ggmaWddQl3j4T7E
+VcMm8QjteGyUHY2paEDGE2LKimAV2a4qXwbvGssEMz9AStlYGi1SdviQAlE19lyLFutt9V3W8drZ
+OuAgEUZJrCDT36VLZ8O0hJHTQPOCikag0eFMYUaQzP3/KtBjpASScqeVbUucQhFuTJUhggXegD/H
+U5zYDBzgUO8wIPMsLMBBbMKommxlzmw4fVLIjkiSALGqmdQZImoQu6ouGf7o76hbzhx24vURgdHQ
+my9jhwTlImgWP7QBXpC0Nkd2eWA/IQEJkKo6UnQu61Wwlta+ou464iAW1cCAKej1eWbBZYGRGMQo
+S8X2YoQlmyGrnT7ZBP7c05dDmTIF3hCQ1OtEDtJJAnrNdqKTaww367dL1lOakNRRfUYp6cng78+q
+VWAv753yfxcs+2Ex0Jn9N/KU5j+kM7n9VP/TdrxUMVkJVmJLMSYPTv0AFN5UUwlW73UbZiPS0kAB
+++LJotfp+wLj3b6HmHWI2lF46BLNeejpUM0JWz+7JFwKim2nLfX0LmxD80asGGIVc7y/ZoOsy7Oo
+uzlRylz6jF97r91CD6q9odGGCwoe2HYgkOSexLj3z/M6oFfek/AWLUnUkCpHVkQ0v1wgeo8IzkSi
+xgqc77M2Y4xr+1JQu8BAV1s8zWT7X8zcOfTspLZlRKdMN+bXVSHjBOCwrspgXGyov7vBDW3YBTDF
+DFU/B37SJe32zYlrvzr9598j3ThJMjpkTWMM/T0mogbKY8mMvbSRXq1AOzK9ZiwLrbo5o0mfu5PO
+WVJKt6UybWRMRO1rOeg/iF7lf16ixNYnmss22PXRDfSGnbXMc3UEGNOKaRVkw4xbR8e+ILiK3r24
+qrRaYGgGvUdCssQoC3mH/nJWbOkLRRtgsO2+nQZMZ98bHeHmyVIZ+oiklBgm2WLidT8vkijgBud5
+qHu2dQoMyKYnK6/quuzBsm7tNicF9k9abaHUu2hkeqej7AIhK9iTSYWxPfPjurQ7U8ET7tI8cMFx
+wCCttioWfJb/ofTP/s+mOBp2gKTWxOrFCfKajSbhvIe8uMBAe+nbYzPtTPwHYY4Ut1iuKSvxBZRA
+O4w1N7bNDPaYcoFZvRyBQCFBEkonPqJGHoERDaiLgvRwtu3B605YHH7BnSxo+znTJy+IIEWdyCkM
+YLf7N1w5SabXrbEJegAyRCQl7BrYpvoyetgRULTMEXRn1YfgCz4xJgZ2AFd5H+YnwjMFM41kcjUw
+Ykxt3WjclK3hJjeiJ1VTLew+5XWmSyBJMrGKb4tpg2Gab18aiDwqaTzItLFMtKrBkBWJZWHGZsfG
+JQZFvHzysTGJxUXiXVkmmMzBOg449wZwFLW529JyWCLdyjocWk/84LXOPyQ9sHIMVMfaxgcH0Fqy
+YZor0lOoZJynn/4tJQ7jE8vMcDmLikDo3qMPZY8m+a7JDXm2mhzZc/7wI8fHZ2o/gA15f7x4zKBA
+nnibUUyhTM7YmhIrhPUkbsNCXEA4ZmjwqkdGNgWvkDY7ltwwT+TpMpAY4LnoVgBG3DmCiNoNbYCW
+bqCvx3jo0SGPoQ/ySHFwrjjQvkZKkYnEKrFwBAT77EJLso2jRxiIpBMa+xnMs+BbxY7TUOii6m4d
+epP7ZmrUszlfYiIGBvBQQyp2v4O75/wFSazhkyEp7McmG5U2xFa0nyIkBWfmEK2q6SXZ5Z7L2YpF
+sSepPQJ+v9tMQ4/qtPBCfFwAoaQ/MGoUhU3OLdwmr8R6nvbRx+2HzQ24XSPPCMyooxvH7S1r7FZk
+4OlPtVQYttOhxu83HPywgPI27gCKNxBah12TgCNsVOZZIXzT+OqX9i/ZVqCLBuGYd/lAhxE7jE9/
+b6ypYb8hkewhhjbz66KAQvpQAxekTQZVKZpdiGoGVvzZTFH2ibH0DlEuyy7lmroNGC8MAKbFuyjW
+e5kv9eM7SH9g5A+BIYzUKWnGzLpEEAFX7UUst16tg4wCTDA4ndJBYyj+J1f2k/uzdCoieCMcoJvd
+gDGa1t9G7oN4th4IPVevDHMia/m2ClyN0V8u1N1RnCEDfaylutb+WE31ckvrRMGvo9hOTVl3YMw+
+ZbLd5p9ypbgtzOHGB2DflIfqLR9u3mQkipt96NQZWCug1g5FOFqrk89GUeEv/qQPzLarfCnQwc5Z
+Bp17FaKS8ZXsk/+fkmcLMPphrR954Ckn636sA4TL7hJztncWcyl96u+HYrKXKRb/5iXC8GwJkgWr
+/cCsPtqScbDpUdoT5f1EKCSG/eWRQiXVuW7JfD2pLmUL08PVD9rBaQxASikXKP0INcmmg4RAJg9g
+2k7bnM4tbYYg7CxJkHSfyzNBkWq5SUNQULugarRyattk9Ha0dMzexhj67WEBWFxbiwo4yYpQVXsE
+P85OZYP/q1mL8SLEb8Ype8FS/3bIz42VnM6Rd+QlqjMi/leP6ys4nKuFlnU6WPUxTq9LADbG+7/N
+fGFoNIVxP2MJPJK8cpmnd3AOVrVKT0+63HA4z6NdlG0UBxVq+P+fENW5Kp8K/Ru1d6E+0PpQpBnz
+yQriSJhPT9PJT69wlhpppKF1WVqL4L00phYZf2t7NFfLTiD+W8st9/mNyExzixjfqJVjWlEwqyq8
+9yHAF1hCb6tqS6+VlIYIYxp7LOJdc8lhSYFCKc4UjallJe2GpeNGaLIaoUvvbpj5u2rURgZgFRBE
+DvlW0i6sReCVvvVkbDrKoPWF6+bkAhkyzTqp4W6SZmK0HqoEWZ701eKXu8xZOI49X6AXK+ghf+9C
+P0JA85MrgkeTfyXu5hbr+fzN/oc+jDulu6S51OWFfoW9psgKsW3jjzCNTsnErhWFXczF4B17NzPg
+tyfrdpZCn5DrLYE0I5mUiaf87FFx4RzVunnih+IG58jPQA51vc5hx+76QxypFS3d1uGoHdDVXRxi
+pgR7rt/1N+HWoQ0Gb8ej/ElP6hDgwIpX/jc0/vL74Xo767gOpIrYmqpjOjn5OxJtOqpZ4bnV9/Sb
+gmgGMDsDn3GPJJOBNGsw9kQeG0zCvUQWRp/YsM2tqxvJIgB8J0siuJy8WOkZvbEalGPmO90lGoY1
+1SseY6l0p2p+gQnZ8L1qfia3J4ezAYHQtdfrdURkE9YGz4lXQ7NbLpJk6nEod8PF34lBiL0hbovq
+P5AnV15sv9QtnM5eYMS1IwO46Ri+cQx9iCglztHGRnEUumVRmTlLSYz2mM0gBetTR5mFqP0RvNoU
+gwP26De7SOcBDUS8qIdPohvbxmPP0rnskXuz6S6aQZO2xxe2HWXwHaqYgy7WO6i9r8/vmbUcHNij
+jHi9i+6dysIKz9CJ15mZWFi+qJRw84YW9YuLVLPvURp8P4u6jqwu1f04oHr3cpfbSjYx+5AKc68f
+uNQgx2D9c/7tJlOLXP+GA6ReAYEudBC6EAM/ihhK0QM3cb8hwPRlDnOA/5seQGVU+OEVvVTwFSOr
+8/GtfDjG7fLMk62/l+iUj4nr9uhxK9kX9RxfDJgOFnkD/bx5VHC44HnAwqiU3PnM9NSSepL+q55E
+5vi6gbg5d8fpRXVoKLrbCiu3p9Uus3QK+xblfyGGaOLR3yFL69BNd38ePY/TgwnAaTIieLJte9d2
+fipwOOgapjrDRjXFl9R4KOvCbZJDXL5C/07ADQG0lh3Xd4akePMhEgsm9GSoKvmg5RiAGcykNY6G
+5XZaQ4CPHYVTQ2dlhrtLuH73xX419v3NU+pGEqUdNG5tCejjzOcOEfC/gNUfGZrp9XhxYdwFHDoR
+tMSbqlv5OVk8n9KKb+7aLRpDc3kzROaXuSq8i1EHXCY+lAFmin8SCGGC900lGvLPC/iTHTNOQ+be
+jsNziD0cxmr7fM1M9OY1kc9irBzAIo2J8dAKddjjd4bHjiqgkkNG4XBoyW8VM3Ag5OTDcaho0Ohb
+jcRCjuuvcTDFzLpGvL7uHC6sDdtAcis5+nIvOllKzrd7l5DXs9IM4mHQldteyRA+Cx1cIk3mIhaD
+ETCSBjGFpNEVf5k3hwYRNLt9JYkF/H3iU4fly5ggS09UAl5Y1+rTGk19u0ztZ9NmcG0E6TUx23Ii
+Lw7zsnPzNyHyJ/+bBEq/zNkH6dXgF1VKZ2SicPPAG5/5Ug2SYkl8SNvzzj7Cvodqe7PCcPITxP6U
+suKgRQ++fSnXW+lqhBUtGy55PFR3hH5//E3yWakhSpJbrmpMxStTMIvDBhdEfL7wLRH94N5QnQQm
+sN7Xvj/BGPYu8ZaUXf3val/aHPj4Pbl7l583kllOEghpwOD/tJxJasXPuvSMWSLLJ9Syz1tDXmhO
+cF18RT1wkO0cXRXtdeNYqHwHUaz/8VrJOvur+/ZiXNhnpiXoCs2MUztIuvl6eZ8Rf9aT8injgaKY
+axRCfuQGprcMh8BnAHouNJtpXWor0bJxIEJUA1N3Jn3n29YB72DbGsB55WPQhra2XXN24sz7xAAe
+6mNv1G/sRxpHj7FqYSfiy5jifUdzMK3JXdA9tH0ITAbubMUDOvKdLbTAgFUnSevuFiuTO70ifWSO
+lxHLIPAkx2bpLoRS6LtrE3TKpdTvoTqcVGUTLq5YGhzo3YiENDHplHqc6/zXEnBfVLsSxsoYAJvD
+D063Vo3PekX2Xc5Ar5LkF+kRLGx0K1fA8BayTzA6YV9NxlBDSJGWt1MakrUPwKymSCgfMVicWzIa
+RTzKRQFGTSmybORhQkkuDHIqJnrh1dKP8HXbempiQfG57uZOqff3TAYO8dfBeMI3ZPH2ofeNWK/B
+073YkSCyR+k00EOMkkUhrhMDi/jbH53Uc/Q9vUjeRiFeTnbVUCNkwNA7BDxIMQZ0hyqjywxE8OI3
+DB8MIiF2CXS22JUdeRkdVlx2P3yR0iD5hcnUNpP6WzCZv1/x4FYxTzTsP0LfnpHn2av5F+WtrjZx
+hpfnH9bDNQzCT/O0S+SsdwyUeK593QlfH38s95p9L+vtO910kGccOdgCvvcgw6IYMe04sP5oShBj
+QSZVrDYfLI9+vAt4M9hy8zN43LCuuD9XkemlSlm48I1lucOs+BJg4EFP9vB0oX9yYjtmiMERnTP+
+s1lLI8Oe2JFsiUPSQg/gBI+WJW5H5+tyaEnhEfTB3UMBQUjaeAiJZ3LvHJIBky8x+MNqEOJryniH
+n8Kw50w+UOaZH6TKPjhPwA6ROIXWKpZuEQlNnvU7d7SEYLRbBGAsBz0+Wht3iShA/vJhYB0+if17
+75MloHBfBriimuRyze3eaKH4krV6BNZ+jGqOeuhyoorYBPslOYzpoFhFButZvYSL9RSMPHBU7lUk
+TiF7eIiMmJO6q2jVXykA8klqge04yjSXDNdAJLB2bTftTcJC+kxrs9vol+pYLU0BIuE07Wfx2TWT
+/sfgdXd4g/pmeKNFe3q80mFkAKFu9ohYPumyfFyVygVMIV2pTmfwIkVohYPCttsAoG8A5exBHBEc
+4JXmKfrWAV344RwH9C1DOy22Wt3cTpDi2gjyJe1OCSpUUhmp+IVuNqtw9HeXiSPSq3AiprAxD5nT
+UbsBIjagYnWF5stZf5YP21SK9uHJwv2AWx4EzDANRqyj1Yrq/DSNpZergYuBJGFcpe1qlGAxaRet
+ad052sUNQ6vfxLj6AdMGXzjDHD8Q00ZirNE2iRa5AxzwtEOyfioonjGOSyqPr860KQOIG/YQfdHa
+nQUq+s1N//DzXHX72CN8L7gCgRZ8b8g6puuMnWe4kqu6mdBmQkzdBjk7ZiNpN+/IhwlzhSofTTp+
+at8CbdmRmOvBYaavvtc9AIz8rnOpHiB3M0/0jNdSG0z+PyVv16Ec0GYNH6uG7f4tjTg6nHnMepEW
+zKN80Qx+mWRTVxIDbj8+ITxuJYH6NvwWf/kAhaToKu3v+fQu2S177XlCGGU6JL+ZA9+dryoI+cWe
+NZt6sBB/9BLaQTp3ukuSwUtIJp8kFZFAw+A0ckC5O+FdqVJNcuYVM7dqim9+3Uop2vR/oABC8eT9
+BA1iMXKsFyeRn2yOQoijA/1bchdFzlIpdfCIvnTysENw/iLCj/pc5iVJA1dVfUgWeh1IYHMJjAlj
+ziSEVvzHEwpYsG1pyGFyxCESUNBuLz2wp+0/vJoNkNL6KLdgGNMJvXfJYiRjv/KJUpBH/0+f2Ghe
+9XHvn91HpuF1Wj2RZMrgGwYzvN6hkB7GF+ZK8+T1DnlLcY+DFb8WfNrsuNbGwW6ls3bYoz8ob/uJ
+rz3POrfa0ctJTAboyh16vvRmk7iXYWzOjILHfPM3kpXM+66c/KatQj6XutIgwiSKbSAUGCRM8I3r
+jwaVH5ZT7f8qpyVkpsBHSg1joG8rrMO/h745Ke2JI3ZWrG2ihY6lpbhVguVuomAjxXFkBseNzVwW
+6rPQXOL2XH0ut3bcydEIhOxv5iY7HCEM7mQ0fTT3otZ71WoDfCUlTPZCkKaFVoJzf04YVRq4Ee+n
+tMunPXAjBpFLmzpauwox7gjdzj5SF61kYi2q755xwpuCbdDbn1FR7g4OwM62/D0J9k0d/oYC60/o
+DD1gurMJ3PaOErO5knUu8pDTZmBN+eCXJw7aBTp0c59iAtU3nZSoG9g4GNVZnv5FAchRmxZu/2ED
+T4VaOvHa1ymoNmXuGWgjLCAFCzmVh843Bnd/YksejbZuHc9MAe2l92o0C/Hhh5Dh/yM5R762EbTn
+KX0Wzsl5DKpGWmZtwDtSZslLsuE6INyFrtivcgEl96ZZUENgdl5zpD7zBlzdQ0W8shh/mNNzyaZs
+Fz8p6us9vzApghzdcR7nD/MikH5Vmy1qj4kuakIOYE0eSViodIv31/J99GG3PWzTBe3NEFvmkr+j
+OZdgjbCNKKrHCFnlWf1g1c2bSVSagQdiqN3bxtThcVyEm7Q3pJ23h5T87CIYmOG4/mScZs7agqCe
+269AVjU0pxlMcORU499Xggu9C/qEt3CRSH7GJAHRU6WnIVaKz6/2oNLmkvsISeqDvQUox4gUa2zR
+/uXB41pKNfwTr8Uq/Nt3db/H
+=jflm
+-----END PGP MESSAGE-----
+
+--------------PVS0UUCXkX51e6TO2TaXFns1--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-inline-indented.eml b/comm/mail/test/browser/openpgp/data/eml/signed-inline-indented.eml
new file mode 100644
index 0000000000..a6c58e55fc
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-inline-indented.eml
@@ -0,0 +1,23 @@
+From: "Alice Lovelace" <alice@openpgp.example>
+To: "Alice Lovelace" <alice@openpgp.example>
+Subject: signed inline with leading whitespace
+Date: Thu, 15 Dec 2020 11:11:03 +0100
+Content-Type: text/plain; charset=UTF-8
+MIME-Version: 1.0
+
+-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA256
+
+indent test with £
+ £ 2.00
+ £ 4.00
+ £ 7.00
+ £ 5.00
+end indent
+-----BEGIN PGP SIGNATURE-----
+
+iIkEARYIADEWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCX9iLtxMcYWxpY2UtdGVz
+dEBrdWl4LmRlAAoJEPIxVQxPR+OOXiQBAPrpjL5tI0ZCrWkNt8VFm9+PF0T8DOgH
+bH7ZaD9RblvUAQCOtx/6kEkGmMwteaGQatVNIwfnfFuiidwlKOtXXxIjBw==
+=Z5ya
+-----END PGP SIGNATURE-----
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-mismatch-email-date.eml b/comm/mail/test/browser/openpgp/data/eml/signed-mismatch-email-date.eml
new file mode 100644
index 0000000000..f673055c42
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-mismatch-email-date.eml
@@ -0,0 +1,54 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Subject: Signed Unencrypted, signature date differs from email date
+Message-ID: <de411532-9bd9-a30b-37a5-294171a0e1a6@openpgp.example>
+Date: Wed, 14 Oct 2019 14:21:06 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="rj47z8rbyl2MhEfaEZQptaxOOrVHArWV4"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--rj47z8rbyl2MhEfaEZQptaxOOrVHArWV4
+Content-Type: multipart/mixed; boundary="PNIE8wtZaKiN071KjiT0rwF4fNeu8GK6i";
+ protected-headers="v1"
+From: Bob Babbage <bob@openpgp.example>
+To: alice@openpgp.example
+Message-ID: <de411532-9bd9-a30b-37a5-294171a0e1f5@openpgp.example>
+Subject: Signed Unencrypted
+
+--PNIE8wtZaKiN071KjiT0rwF4fNeu8GK6i
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+Content-Language: en-US
+
+Sundays are nothing without callaloo.
+
+
+
+--PNIE8wtZaKiN071KjiT0rwF4fNeu8GK6i--
+
+--rj47z8rbyl2MhEfaEZQptaxOOrVHArWV4
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsD5BAABCAAjFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAl+HQZIFAwAAAAAACgkQ+/zIKgFeczBX
+Pgv8D77zOWXaX3xsREJr6KNHkD6Gj52illymZBY1Km5+WMW0JmEdt5T0UOpAoVTbxHr/1z+G7iw6
+vgoBRNdzMoFTGYHy6wqW+2A3pKiGc2CD/789GYL/c3dVEIJuBr0FzDRMO8VQhjq/56s0FnaFjm7L
+JtEUjBZzaafQaxzmbnSXjSZLfCLh1YRRmgxeOA5CcyMY1HlgsvvSfdNH32o+ZPgSG4C7s4z2iinH
+uZFE2SbQ/qvyBWnslr1+2/r8YFhtknLsK14memZb8bIAtrh8Hxq9BRg3A4+spD4uR7C+1hcLbWRD
+x5b46ehcULzoTcnDLmQ34/zymIANCzSdww7Ofmhw7c/5sK9BL/fjgiT4qKxFOr/12/Ym6cXqtUgN
+Eh9XsQt10ZP707BodH9ga9rIR5NIZr6xYZx0iX7FPKxXw8Kf8ovxiI+ATYb4B6fv6PRxyD6yYfLx
+ZKBnZfk5UOe/GqktKFFHO2EVe3z8eYgS8k1hWXuvkKlXDb6PqDRUE42K6yAD
+=/kuV
+-----END PGP SIGNATURE-----
+
+--rj47z8rbyl2MhEfaEZQptaxOOrVHArWV4--
diff --git a/comm/mail/test/browser/openpgp/data/eml/signed-with-mailman-footer.eml b/comm/mail/test/browser/openpgp/data/eml/signed-with-mailman-footer.eml
new file mode 100644
index 0000000000..8a48a016c5
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/signed-with-mailman-footer.eml
@@ -0,0 +1,75 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Subject: Signed Unencrypted with unsigned mailing list footer
+Message-ID: <d9c78fbc-8373-4596-d806-20857e15a1ag@example.com>
+Date: Wed, 14 Oct 2020 14:36:08 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="===============5120840899925357875=="
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--===============5120840899925357875==
+Content-Language: en-US
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature";
+ boundary="hUAWHTUaWZ5wnWnHjj7a4qhWdRkydquhh"
+
+This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
+--hUAWHTUaWZ5wnWnHjj7a4qhWdRkydquhh
+Content-Type: multipart/mixed; boundary="oIVAE9YPgX7lo5koqmIMk4gv1QFBbuMEk";
+ protected-headers="v1"
+From: Carol <carol@example.com>
+To: alice@openpgp.example
+Message-ID: <d9c78fbc-8373-4596-d806-20857e15a1ag@example.com>
+Subject: Signed Unencrypted
+
+--oIVAE9YPgX7lo5koqmIMk4gv1QFBbuMEk
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+Content-Language: en-US
+
+Sundays are nothing without callaloo.
+
+
+
+--oIVAE9YPgX7lo5koqmIMk4gv1QFBbuMEk--
+
+--hUAWHTUaWZ5wnWnHjj7a4qhWdRkydquhh
+Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename="OpenPGP_signature"
+
+-----BEGIN PGP SIGNATURE-----
+
+wsF5BAABCAAjFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl+HRRgFAwAAAAAACgkQMJn/EjiFK59T
+xRAAoG7+tqzXKQH2S511jRudl0HaKs+AE7kfyqbqBpWsCzcuxWIvCY3cX9ypIEhLllYWTs15aQq+
+f0GCXIK6PFGukhoQ/m49MmiGh4D7oGYxmPQyn9yZVcijqmzF5f4s7oiSKVl9/4y8H0JCHaWuelkN
+fizcAyXUWdPdefL8pIQkng+EtBM1sZ25HBJYFU6du88u0LuA3A7SNRPcRc+LhrGticIPBsDcRntm
+41bcf9QKo36EnltJjSGv3Rtp/PacyMqsmlR/UDHzVP7yWNvPboPCIB2CHVN9J1URxE2S3hjfrbY0
+fuNTgA3TlJ7crTCztIrqugZT4RxhyP3orDhp5TKYLO8q9bof6C1Zo8VbvGzVrl4eVgP0YRNN19vm
+mPeH7rF7wTPhvht0sLKcFMFTXU458SokWZW94EpTBIGNWjCKlzE8TtQPyhViVpo1RUpJQx/tr6Pb
+9r81aKJ0hnrAcDqL+PMd4UWSAONCpr9YpOEY6hj4ppqI09b0HGnBDMvLwsm+PdZ1cLsRlqzCsYfj
+tsU9QpMBV4lJoAnMkGM7pqucovyHSNcgXU/z+OLH1LmPOfPeG3kCGlbRyaQPOt2ZhQZH2f0C6Dnh
+wvmVUqGG8GWDnfVP4hzKzMQQOyWHa/F+J1nwFlbdEBH640jxPdz80/uACXwkhdn+rssEfCeB7SDP
+Cfc=
+=Q8yQ
+-----END PGP SIGNATURE-----
+
+--hUAWHTUaWZ5wnWnHjj7a4qhWdRkydquhh--
+
+--===============5120840899925357875==
+Content-Type: text/plain; charset="us-ascii"
+MIME-Version: 1.0
+Content-Transfer-Encoding: 7bit
+Content-Disposition: inline
+
+_______________________________________________
+Mailman mailing list
+Mailman@example.com
+https://example.com/mailman/listinfo/mailman
+
+--===============5120840899925357875==--
diff --git a/comm/mail/test/browser/openpgp/data/eml/unrelated-and-fake-keys-attached.eml b/comm/mail/test/browser/openpgp/data/eml/unrelated-and-fake-keys-attached.eml
new file mode 100644
index 0000000000..8c3c017574
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/unrelated-and-fake-keys-attached.eml
@@ -0,0 +1,176 @@
+Content-Type: multipart/mixed; boundary="------------274vgv3mTEV0d72rt4aKcMCU"
+Message-ID: <17d788da-b2c1-ed92-24c5-4caa29ad9db2@example.com>
+Date: Mon, 7 Mar 2022 18:45:18 +0100
+MIME-Version: 1.0
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:99.0) Gecko/20100101
+ Thunderbird/99.0a1
+Content-Language: en-US
+To: Alice <alice@openpgp.example>
+From: Bob <bob@openpgp.example>
+Subject: unrelated and fake keys attached
+
+This is a multi-part message in MIME format.
+--------------274vgv3mTEV0d72rt4aKcMCU
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+
+
+--------------274vgv3mTEV0d72rt4aKcMCU
+Content-Type: text/plain; charset=UTF-8; name="alice-fake-pub.asc"
+Content-Disposition: attachment; filename="alice-fake-pub.asc"
+Content-Transfer-Encoding: base64
+
+LS0tLS1CRUdJTiBQR1AgUFVCTElDIEtFWSBCTE9DSy0tLS0tCgptUUdOQkdJbVIwa0JEQUR0
+QytLWlFYMWtNZlVKZThENVJldzZ5aWovT1ovS0s5bWUzMkc1U0d5akFiUFF0eDdLCjRuaE5H
+Q3RXREVoV3VUeXJ0MTlNMmhWaWpqRkU3bGYzYURuZ3F1TVdBSTJCYzM2UjZvZDI3WnArbDBo
+Nm1neEwKME1wZnpxbXRXOFZtMHJncW12VlNGV0dUektjbkphUnoxckFGVi9BR2daVTRKNU1U
+TTBsKzZ1ZG9kcUN3T2dXcApVa0svVmo1aDE3NjZlRjBIbDhzOHR3NnFQY3JwV2ZqQ2s5VlpK
+cUZ3Z3JCdXkyUmtWMy93ZFRZejVJSzhHTk9WClBpdXNaS0RLaWYwQzlybk5HbVI5S3RVRWpw
+RGgyOFBkWE9XbHNaU2dFUGtOQ2tiZWxRRGxPVlRmNDdLekswcGkKNUY4L01iTlhnT0tLMklO
+UTh0MU9qbE04TlBPSnQxZGpNYm1WQWN4Rm1TZGwxWTdkdGVxNG01bEV4dGZJeTdlaAo3SjJn
+RkU1ZGZ6WWo1YnFycitkNXozbU5objJTQVVkak45c3ByZ0FaOEh5V243N3RUb0I2TGZFNGlj
+QXF2NlJMCnIydFVkcXZBb1BEUWpnbytYU2lhK3ZCVXEvQ0tOckYvVWNsZG1leGpTNm1rRHJG
+ajRPRlp0dWZDZjVCbTg0cy8KTUxVczlmM3lhcjExcUxzQUVRRUFBYlFpUVd4cFkyVWdSbUZy
+WlNBOFlXeHBZMlZBYjNCbGJuQm5jQzVsZUdGdApjR3hsUG9rQnpnUVRBUW9BT0JZaEJCTExk
+d1UxdlEzL3dnYVF0eGFWcjNHbXpNSGVCUUppSmtkSkFoc0RCUXNKCkNBY0NCaFVLQ1FnTEFn
+UVdBZ01CQWg0QkFoZUFBQW9KRUJhVnIzR216TUhlWkt3TC8wOVo0bWRYVnFIdHZtZHMKc1l4
+RGM2dGNFTnJaVkFMTUhRWVNiRGJva20zcWg4Mit2ZCt1b2xBNkFHMldkbjZkSUwxV2lIQ0R1
+TjlQM1U0Two1dnpyUFhIdXBsaS9kNVRhbldKNDRXZm53S2V1MDEyL3l5Rk01RHN1cURGeGJa
+bG5GQzg2Q0JCay9SRG5WQkdKCmhFb2xpYW8wM1QwRTZPWWlmUUVOYmptOFo3dE9wSG5pMCty
+ZDg3QjM3UktiMU5TM0ErWVdndUtCUGE1Qmw1dFcKb1drb2VESmZjdDl1NE54bHpNVjcwSUw2
+eDRKeU1SWUQyK2xZdEJCOWIxbUQyRndQaGphRSsycXhncHFyQzJaVQpoUDdEczZMOC9QYU0r
+Q2lZOEZkaW5icUFSb1J1OEY5NWQrelhXZUFxdERIMVFtSndZY2Y3OHhLUUIrSVNxN2E2ClIw
+VTVkWUVla1lzSHlrL0xTaml2OXVLRnlXUU5xWEJjYVBrT2tOQloralZIZzNVS05tazh6YlJS
+WCs0NmJHeXcKTFJCNnBpcFoxZXE3Z1ZJVFI4MWtFZGZnZFZIRFVjcEQvOGRUaHBPVHptYkZv
+Vmh4cEc5UHlzcElsZll5elpuRgpYNEFnYVZZczFuZkcySGMvcmZpMHlrZjJFblRGUWVYRm91
+eER4KzNJWVk4L21KY0xmTGtCalFSaUprZEpBUXdBCitlcGJHaTJhRU95SDcydWp1MmJEVW9X
+VENIdVB3NSs2R3E2TVEvc1BEQlAyaXppL3RQeCtpQmNaQnZwWXM3WngKcHZnWFRnRDNZOGF4
+MTBGdk40Rk45eUlrVGwzMmJtdG9mNjVxOFBEMnhQZTM5ZzNsQTFUd0tvNDdwS0tzdE92cgpj
+ek8vb1d3WWJRcUMvcnMzQ3BiT016TTVQdEhsQVU3WEY2V3FxSFJ5UTdjbUo5V0JDdTYrV1li
+OEt4eHBIQ29ZCnJKa3NmMzVKMjNNbXRMdkliUVJnWVIranFyWHhubU1LazFtaVZZUmh5TXB4
+RXREQzNnSjFOUXFWQ2JQTWRFdTYKU25pdFBiYncrdVBKL3N2NEc4SW1MUUV2cmtORlh1dTRi
+VS82bnV3RmVBbU5mRzNma3Vsb1dOaWswUkxsSFNJawpyNUo1OUhFQ1NiRGhDellubTFNa0V1
+dUZteDVjMEhGejZVcnZxZ0gyYW9zMFYvRHFYQ1YvODNTSmJ1ZE9hSU1PCkhlR3dnSkRSeUpx
+eUpta0JKdnBNOUtDWFd6Rmx1YjRzWjdYR2dwamZzYVpQd2JiRWNkaUp1b1FnSnlNY3V0S3cK
+ZXlZb3BMdDNXTklkU2lwT2pRdFZRRVJjRlJJUmdtcjFBQTVST3JUaktySmRkdFVUZXNFK3ZR
+ZWU5ZzNQR0ZjeApBQkVCQUFHSkFiWUVHQUVLQUNBV0lRUVN5M2NGTmIwTi84SUdrTGNXbGE5
+eHBzekIzZ1VDWWlaSFNRSWJEQUFLCkNSQVdsYTl4cHN6QjNnZExDLzlNcHI4TjJmS09WVUky
+c0tIMm8zOHZCZkxUN2VDcmNIV3dRWnYzYmxwOGkzQUsKZnQzNnVSWmJOQmRDaEF1a28wZUR2
+M1o3S1ZzS3lTdyt4bWZxM2pLWlMxMmlUQWltQzY4Y3dFeHAyL3B0R1k2dAp3V1E2b0t6SW5u
+QXduV2R5STdYS2VqYmg5NENSd1d4SjdZNk9YamFQRTZwY01xLzVaai9GVFBYUUQrWktkNmpS
+CmVRekxzdkVRVW4zbTlaSG9OQ0dtOUhQMVBzemk4M2IxT3hQMGVDNmhlaUo1TWdaWUliRDR1
+RTJqN3I3dnpoTE0KTDVKV2hhejJaNUxBVG9TV2VvMjIzaHFiSnc1TXVCOVowOURZTzlUUVRo
+bFNSUG5VTWl4RStkUnBPcnFQbERJRwpHSFJ5VDJidXNYbGlZSU5Xd3p2Y1lkelpzUG1hTWtX
+aHlSMFZ4cjR6UmoyTm04ZmhqZ1pGRUdEUlBIQWdtUmlFCkJZTHNiMGtwOGJxUUxDQlVrUURH
+eURxUnBoUHgwem1YVk5FVzVIT3B1S1JjMlNhYlpzR3UxZXM0S3prR2dJVCsKdmtaL28wTWFi
+OWc3dTZaUUdHbnF4U0V3bU0xZllLMGdqd0cyK3YrN2MrdzBESXQzeThVN2Vad3UwMHNtYkZ5
+TQpycDI3WUxUZElQOFpjOTV5eXE0PQo9ZWcveAotLS0tLUVORCBQR1AgUFVCTElDIEtFWSBC
+TE9DSy0tLS0tCg==
+--------------274vgv3mTEV0d72rt4aKcMCU
+Content-Type: text/plain; charset=UTF-8; name="stranger-pub.asc"
+Content-Disposition: attachment; filename="stranger-pub.asc"
+Content-Transfer-Encoding: base64
+
+LS0tLS1CRUdJTiBQR1AgUFVCTElDIEtFWSBCTE9DSy0tLS0tCgptUUdOQkdJbVF1UUJEQUM4
+amI5bEpHSEF3VEMyeDdFc0YrbHZrZjhPWkJta2pYR0pEQUtYa2gxenQxRUNvTjZmCnc2clpQ
+aG5SSmtaZUVPc3FRY0U2WklDT0R3dW5xekhLdzhIdk1lOTViWVhCY3hHRUpiZ2NVeW55SUha
+ckdNMGwKa0cycHJQaG11K0t4Q2VIT1lXVWFvNjVKalZuY3puWjZpWWNvL2g0WTZKUzczVGZt
+dlVBaWhDQ0IzaFJHVnRnQwpWdUdKLytKUjRaejBCM2JHa2FCTXNTLyt0VTlSdzFBRHFmemZl
+OHFnTENzY1dQRnBDWU1FRS9zRzRldEJTUkFCClBGRUdHaTViTjcrQzJxSFIveHpKd09haXJL
+Zjd2MGxtRG1URVQwNnIyUzZLS1JpeDVBaUVFak92cEd5NUJISkIKMnlUVWRmUzRVbjJJNThV
+eml2bXpnRGdwbkVYOWdXcWxxeHlONFZDL2xPN3p2dndzMDBJYWIrU3Rzd3g4K1N1dQp0NEM0
+YjI1alpKOGFBSEg4dmpDbVREdlpFTk1BSFdnZjRlVk9sSzFFbTdURHZ4ajZpcUNLdUZVY0Zt
+dFRONUExCllaamZKbW5DMHA3ekdhaXRyUEdwUlh0aVhQT05RWG55WXA2ZEFMTzh0MkpwS1cy
+OEM1M3V5TUJRY292VzFTNnUKV3VVNUxRTUZEV1ExUU1rQUVRRUFBYlFmVTNSeVlXNW5aWEln
+UEhOMGNtRnVaMlZ5UUdWNFlXMXdiR1V1WTI5dApQb2tCemdRVEFRb0FPQlloQkpSQXFqV2Rw
+bVVqNXNVTVYwWEVYVDhhYlUyUEJRSmlKa0xrQWhzREJRc0pDQWNDCkJoVUtDUWdMQWdRV0Fn
+TUJBaDRCQWhlQUFBb0pFRVhFWFQ4YWJVMlBDTU1NQUx0bVpKZnk5U05PQTFyMlowdFoKVVJt
+WTBYTGpaUjVObitGWlgzWjZaWXVCMEwwb2VTL0trVytkZEpad2E4T1pYZHo2TDF6YWo5cVBu
+RktWUDkrWgp0UTY3MS84RFc2YWNTR0cxeW9JZDhUalQrUHM4d3JXMzcwdzdVc2NYNUgrSkpn
+cG83aXdYTS9GeFFhUkVpSHFJCm9CTE15a1ZVQjAwS2NLNW15QlB0ajJOT1JLSzJQNmNKUkdB
+WWFhTzVhNXFYcW90c1o1Zm9CbmN4a0JuWXBkY2sKb1dhZXNDWnRLMVNOUzZMNEh5VmNYaDRt
+M3h0M1lKZzczbWRKMkFjb1NVWXhuckl3Si9rMzlmTGF2NW4zdDZIdwpnRXBKYmkxVlVzV1FN
+OTVkcnBwRkc2bUZQM2YwYVdkeFVGMDl3QU1kODJ4bTNUMkcwUWdUNFpnTlBIWDIyQTVSCmNT
+MGtLeGxrKzhWVzYvWm1xUzcrcmI1UkFqSFhsYjJBM1lPZmdKeGZtbWZ1eHNIQktURDJnQVJD
+S0ticTJkaW0KVW1iMUVRSFl2NTNYU3VzSzNPWGt5VWZReExqUTRHWG43KzBaVHdQbk9ta3pK
+VVJwczNkN1NDWTBsQU9vVXRpQwpBOTFSOTZXdFVlVFdWSGVSNXNuRFJjZjFvZGRGNlRjc0xP
+d3B2aDRkUUFKRkY3a0JqUVJpSmtMa0FRd0FzcnpYCnZGYnlHbndNWW1jeU0zWXNnVkZYQ0Qy
+bHhBOCt4Z081VEtnUUM5S3hNTWZTc3poekhoYmlrUU5BTlhIRncyQTAKbTB3VExqbTl4SDZM
+Mlp0ek9iWWRubS8wajhGNG9lWmowWjJnRTMwOFJDMUFteWZGY2w3Y2N2MkhaZmk1bXg5eQpq
+TXFCMlVwM0F1bEpjMFpEWW0zWFBuMTBET2V2WUV3MmJEeTF1LytCVWZrOVpsK09qQ29KTHRR
+TjNUZEtwSlgyCjh4ZitUc0d3TEtkUUQvYkEzaWMxZVF0bWR1Tm13TVo2RTV3QnFjOGExdHBy
+S2RBbEdPOWV0cnhIS0l6eXJ3aVgKUWU2R2xwUVlTZHAyQWNtdG9kVkRsQllxTjBSdjJhVm84
+RnpJWVhlb0ZzcGxaWGt0WHJRZjhXaDNiWURxdlVtagpSSVpJR1BaUmxidlA1TFJ5c08zclk0
+dHMvTFl0NkloaDhqZjZvQ3pmUjRPV1kza244bDZ0RlR6NEpIK2N6U0hTCk80QnlIV0JkQ0xk
+T1NSK2paVC9mZXdkTFgvSVVieFUrY2UxSmw1YlVQTVBsQk9hWmtUejNUVTBjaGllN1NaTmUK
+akM0bHd5dmVDaGJRSnBQOVhaTjBJMWZraXozTExPNW8rbzV4b01XcnY5Q1ZjYWNMYTREdGlt
+bkI0RWl4QUJFQgpBQUdKQWJZRUdBRUtBQ0FXSVFTVVFLbzFuYVpsSStiRkRGZEZ4RjAvR20x
+Tmp3VUNZaVpDNUFJYkRBQUtDUkJGCnhGMC9HbTFOanlRQ0MvOUFqem9kRThCWmwvckdFOWt4
+MUFjaVZ4Wkd0RTBtYkhrNzgzN01BdlBSTGF3UjBlMXkKVGdNQ3dMK2Ywd0NaQkVIUHJPdys4
+ZHRIZEthWEh4MS9PM1g0S0VzRmdSUE1NTUYzdUluVFZzc1RPM0hhb29ocQpnOUhtcmdkdnMy
+dmtFeVNnOGRPUFlXTldRTkpGNndDRmplbUp6QXdSVldDSXZFU1d1a2R1NUkrczlMcm11T2J1
+CjdTbzc2QlJaNnJlSm5IcjZydVVTNDlYNEl3ZWdVMHdPc3U2STJhTFFoQ01NS295L3Exaldw
+RFp1WXRVMER0OHMKNXJzQlNpN09NUG80em0zdGV1SGdDNVlucXp6UGxGajAzZjRMQXRZWXJF
+cHdzS3FlbG04RGJId2EvbHNjaWRxZgplaitYWmhxVnQ1cVhYcmpGcDMzbGg4SFFCUDJremU0
+SElUZG1YQTVCazRzZ255bFUwWnUySmpJUER1c2VXQXBZCkpMeFQxYi9lUzM2K0VMNFVzZGtP
+QUIzMkUwRkE3aFB0SGxQRlYrMUl3cUZXcEt2VzlLL0dpVVYreXhHL0xGTnoKTzVRcW4waHdH
+YjhJMFNZTytzdFVBOXREYmpTTUUzRURtV3Q4UG1wUGJHc214blhpY2VoWm1aSmdRQzlZNUtR
+OApwcUNIeFJhN3FabHhPMkU9Cj0zOXRyCi0tLS0tRU5EIFBHUCBQVUJMSUMgS0VZIEJMT0NL
+LS0tLS0K
+--------------274vgv3mTEV0d72rt4aKcMCU
+Content-Type: text/plain; charset=UTF-8;
+ name="bob@openpgp.example-0xfbfcc82a015e7330-pub.asc"
+Content-Disposition: attachment;
+ filename="bob@openpgp.example-0xfbfcc82a015e7330-pub.asc"
+Content-Transfer-Encoding: base64
+
+LS0tLS1CRUdJTiBQR1AgUFVCTElDIEtFWSBCTE9DSy0tLS0tCkNvbW1lbnQ6IEJvYidzIE9w
+ZW5QR1AgY2VydGlmaWNhdGUKQ29tbWVudDogaHR0cHM6Ly93d3cuaWV0Zi5vcmcvaWQvZHJh
+ZnQtYnJlLW9wZW5wZ3Atc2FtcGxlcy0wMS5odG1sCgptUUdOQkYybG5QSUJEQUM1Y0w5UFFv
+UUxUTXVoamJZdmI0TmN1dW8wYmZtZ1BSRnl3WDUzalBob0ZmNFpnNm12Ci9zZU9YcGdlY1Rk
+T2NWdHRmekM4eWNJS3J0M2FRVGl3T0cvY3RhUjRCay90NmF5TkZmZFVOeEhXazRXQ0t6ZHoK
+LzU2ZlcyTzBGMjNxSVJkOFVVSnA1SUlsTjRSRGRSQ3RkaFZRSUF1enZwMm9WeS9MYVMya3hR
+b0t2cGgvNXBRLwo1d2hxc3lyb0VXREpvU1YweU9iMjVCL2l3ay9wTFVGb3loREc5Ymowa0l6
+RHhyRXFXKzdCYThub2NRbGVjTUYzClg1S01ONWtwMnpyYUx2OWRsQkJwV1c0M1hrdGpjQ1pn
+TXkyMFNvdXJhVm1hOEplL0VDd1VXWVVpQVp4TElsTXYKOUN1ckVPdHhVdzZOM1JkT3RMbVla
+Uzl1RW5uNXkxVWtGODhvOE5rdTg5MHVrNkJyZXdGekp5TEF4NXdSWjRGMApxVi95cTM2VVdR
+MEpCL0FVR2hIVlBkRmY2cGw2ZWF4QndUNUdYdmJCVWlidGY4WUkyb2c1UnNnVFd0WGZVN2Vi
+ClNHWHJsNVpNcGJBNm1iZmhkMFI4YVB4V2ZtRFdpSU9oQnVmaE1DdlVIaDFzQXBNS1ZabnZJ
+ZmY5LzBEY2Ezd2IKdkxJd2EzVDRDeXNoZlQwQUVRRUFBYlFoUW05aUlFSmhZbUpoWjJVZ1BH
+SnZZa0J2Y0dWdWNHZHdMbVY0WVcxdwpiR1UraVFIT0JCTUJDZ0E0QWhzREJRc0pDQWNDQmhV
+S0NRZ0xBZ1FXQWdNQkFoNEJBaGVBRmlFRTBhWnVHaU94CmdzbVlEM2lNKy96SUtnRmVjekFG
+QWwybG52b0FDZ2tRKy96SUtnRmVjekJ2YkF2L1ZOazkwYTZoRzhPZDl4VHoKWHhINVlSRlVT
+R2ZJQTF5alBJVk9uS3FoTXdwczJVK3NXRTN1ckwrTXZqeVFSbHlSVjhvWTlJT2hRNUVzbTZE
+TwpaWXJUbkU3cVZFVG0xYWpJQVAyT0ZDaEVjNTV1SDg4eC9hbnBQT1hPSlk3UzhqYm4zbmFD
+OXFhZDc1QnJaKzNnCjlFQlVXaXk1cDhUeWtQMDVXU25TeE5SdDd2RktMZkVCNG5Ha2VocHdI
+WE9WRjBDUk53WWxlNDJiZzhscG1kWEYKRGNDWkNpK3FFYmFmbVRRemtBcXl6UzNuQ2gzSUFx
+cTZZMGtCdWFLTG0ydFNOVU9sWmJEK09IWVFOWjVKaXg3YwpaVXpzNlhoNCtJNTVOUldsNXNt
+ckxxNjZ5T1FvRlB5OWpvdC9ReGlreC93UDNNc0F6ZUdhWlNFUGMwZkhwNUcxCjZybEdieFEz
+dmw4L3VzVVY3VytUTUVNbGpnd2Q1eDhQT1I2SEM4RWFDRGZWblVCQ1BpL0d2K2VnTGpzSWJQ
+SloKWkVyb2lFNDBlNi9Vb0NpUXRscFFCNWV4UEpZU2QxUTF0eEN3dWVpaDk5UEhlcHNEaG1V
+UUtpQUNzek5VK1JSbwp6QVlhdTJWZEhxblJKN1FZZHhIRGlINDlqUEs0TlRNeWIvdEpoMlRp
+SXdjbXNJcEd1UUdOQkYybG5QSUJEQURXCk1MOWNiR01ycDEyQ3RGOWIyUDZ6OVRUVDc0Uzhp
+eUJPemFTdmRHRFFZL3NVdFpYUmcyMUhXYW1Ybm45c1NYdkkKREVJTk9RNkE5UXhkeG9xV2RD
+SHJPdVczb2ZuZVlYb0cremVLYzRkQzg2d2ExVFIycTl2VytSTVhTTzR1SW1BKwpVenVsYS82
+azFEb2dEZjI4cWhDeE13Ry9pL205ZzFjLzBhQXB1RHlLZFExUFhzSEhObGdkL0RuNnJyZDV5
+MkFPCmJhaWZWN3dJaEVKbnZxZ0ZYRE4yUlhHakxlQ09IVjRRMldUWVBnL1M0azFuTVhWRHda
+WHJ2SXNBMFl3SU1nSVQKODZSYWZwMXFLbGdQTmJpSWxDMWc5UlkvaUZhR04yYjRJcjZHRG9o
+QlFTZlpXMitMWG9QWnVWRS93R2xRMDFyaAo4MjdLVlpXNGxYdnFzZ2Urd3RuV2xzemNzZWxH
+QVR5enFPSzlMZEhQZFpHelJPWllJMmU4YytwYUxORGRWUEw2CnZkUkJVbmtDYUVrT3RsMW1y
+MkpwUWk1blRVK2dUWDRJZUluQzdFKzFhOVVERi9ZODV5YlV6OFhWOHJVblI3NlUKcVZDN0tp
+ZE5lcGRIYlpqalhDdDgvWm8rVGVjOUpOYllOUUIvZTlFeG1EbnRtbEhFc1NFUXpGd3pqOHN4
+SDQ4QQpFUUVBQVlrQnRnUVlBUW9BSUJZaEJOR21iaG9qc1lMSm1BOTRqUHY4eUNvQlhuTXdC
+UUpkcFp6eUFoc01BQW9KCkVQdjh5Q29CWG5NdzZmOEwvMjZDMzRka2pCZmZUek1qNUJkem04
+TXRGNjdPWW5lSjRUUU13Nys0MUlMNHJWY1MKS2hJaGsvM1VkNWtuYVJ0UDJlZjErNUY2Nmg5
+L1JQUU9KNSt0dkJ3aEJBY1VXU3VwS25VcmRWYVpRYW5ZbXRTeApjVlYyUEw5K1FFaU5OM3R6
+bHVoYVdPLy9yQUN4SitLL1pYUWxJendRVlRwTmhmR3pBYU1WVjl6cGYzdTBrMTRpCnRjdjZh
+bEtZOCtyTFp2TzF3SUllUlpMbVUwdFpERDVIdFdEdlVWN3JJRkkxV3VvTGIrS1pnYlluM09X
+akNQSFYKZFRyZFoyQ3FuWmJHM1NYdzZhd0g5YnpSTFY5RVhrYmhJTWV6MGRlQ1ZkZW8rd0ZG
+a2xoOC81VksyYjB2ay8rdwpxTUp4ZnBhMWxIdkpMb2J6T1A5ZnZyc3dzcjkyTUEyK2s5MDFX
+ZUlTUjdxRXpjSTBGZGc4QXlGQUV4YUVLNlZ5CmpQN1NYR0x3dmZpc3czNE94dVpyM3FteDFT
+dWZ1NHRvSDNYckI3UUpOOFh5cXFic0d4VUNCcVdpZjlSU0s0eGoKelJUZTU2aVBlaVNKSk9J
+Y2lNUDlpMmxkSStLZ0x5Y3llRHZHb0JqMEhDTE8zZ1ZhQmU0dWJWcmo1S2poWDJQVgpORUpk
+M1haUnphWFpFMmFBTVE9PQo9TlhlaQotLS0tLUVORCBQR1AgUFVCTElDIEtFWSBCTE9DSy0t
+LS0tCg==
+
+--------------274vgv3mTEV0d72rt4aKcMCU--
diff --git a/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f-with-key.eml b/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f-with-key.eml
new file mode 100644
index 0000000000..c3ea9a5d49
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f-with-key.eml
@@ -0,0 +1,163 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Autocrypt: addr=carol@example.com; keydata=
+ xsFNBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJPaNoLbqNP
+ efuFg9ib3J0rKcZfqgnqC4usPVSTdmC4w0MdmHvh+1tUoXcxnrjYNRRbP+lC7zaLRRnEEioi
+ mC0Mkh+ow1u4F2QFBjwcV9bD7i0T1DRfR5k5kh3kcaYFnGnwMjwjJzLtvu3OZbXYsofCw789
+ 0TP4LkqLEQVOw1OrxBnRd5QNBVojcQi6rnKOQ7AUBGRKSXI3QVrbP+x1oImXpQSqIyaRFbtx
+ 57QafDdkyHBEfChO9X96BtMndyry8XgYtcgmwKKWg8Js4TJgghus6Sng5dA7/87nRf/9//Np
+ tXh9mdW3AiHsqb+tBu7NJGk6pAPL4fUjXILjcm5ZXdlUeFVLmYmqTiOJcGFbqHEBGcwLKPob
+ a2JsBEpnRj0ZEmo2khT+9tXJK3FUANc4w/QfxTXMwV17yYvocDPEBkoKcbxE8b2sSK/L7Vi+
+ h21XX6fA6B3zKFQ3hetFvOjEGTCkhFD9asL8KnwQdJmYo4Bd45AVoMZFxBxpmuo9MxPdiF2A
+ GbKHgrKpqDw2pUfelFwMZIVQ4Ya1wdtLe8gEJAMq6YnuuQcq+jjGKubNRywld7xXIsxJCpHt
+ qbCQM9P+gqp1VDBnbsk4xGX0HgILXF2JfyceGMGy1Lku0QA+ywARAQABzRlDYXJvbCA8Y2Fy
+ b2xAZXhhbXBsZS5jb20+wsGJBBMBCAAzFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTUC
+ GwMFCwkIBwIGFQgJCgsCBRYCAwEAAAoJEDCZ/xI4hSufjB0P/0+yaZknO8dS5o7Gp1ZuJwh6
+ +vgTGWrTxcBtsU1JR4BFobPKtMmw45FKsNIiK+AQ7ExCtqumGoTJ6hlclBFMlDQyyCxJG/Zp
+ PdrFUFyg6JUVf05/LWsd4Fwy/hQY1ha8R81QinSHqv9DJk6fKZG2rz7YUE47LFfjugbwUj9y
+ 8naTxj823Vm6v36J2wgl/1/PHoZTwi3vQRA70SoIDt4tSjqBzuclt2k/zlkJmOpBYtQb+xGw
+ pfnh2gBJdYurLwJO9rQlzYjy/+1qB0CZsE95WlkTrqQw8V5S6ULcnyACbETdF5HF/geHL367
+ p/iWULD907E4DJlQBOWjY6fdsJIBj96NfQiG+cXYTNGqaB/FgW8jyoS9vyg4PDOr0nGHLvzP
+ w7xTDUkuoJiWXMJ9kDYTZ+MsWreA885i1JSE32CsqqP3+kI7XQD3d3T3pIPhKOo0/bzbLY6y
+ WBXh809Ovi9fMxaZkrlrmA3lFcY+FbzDjZB+UYOXDB6TRu1jvISVMiXnYf4X21xWyl8AWv1q
+ ANMSXFKUwBSR88I06QZiJBmm9wHcyVtK/Hb6pgH10LydZvIfRDLrDBc2z31rswjNj9UhNp0Q
+ fGdNz/gXdxc8HP7Pf4kHkjIxLrWUNlDpYddX+iz1Z//VY9h2XTmSail5pMyyXdiGm90AGfVh
+ IcaOoeKK9UslzsFNBF9GZTUBEADWPef8E4OUoxU+vhwCxy/4nDfxzV4ZMFYkqp8QgpLzTVgT
+ v6xGVHFx/waNjwR6G34tD0aYhkDrumv9QsMdiQnMw9pLAoc3bnIkL8LkXnS8fVeiuzkXd4lg
+ vpxFlce7KYuXos9Ew7Nm2tOx4ovoygFikjliFTKn+QOVJoTr4pxJL9RdzYQ/pV/DI/fc2cmR
+ Wy0uivP+F+LBtYW6ZOMY1aXzsJEvun2i5ZxV2jqNDhXpD3m6/Y/28WItKbmT80hvTivxO2DS
+ Q1kqNcwB8Z0XWZJoz6iyYUu27dKB0L4S/x4UASlC6J2Db8bIL3Tdhuy+N0BN8sS1TDWb7Oi1
+ Ad8huVxfrRSyOYj4fkksvAEgDEDH6JEvJBU3CGQtfXCoX6d64db2cGp85GDfNHTREJ0mbRjL
+ AKL1RKrcKOG1790OZU2veF5qiN2eN08OLfJURL8+P4+mDWbaOcZasqNrg3YhYcPX3ZZzKfEI
+ vvTOdqMk00JU3zaUZhJvGOR9tJ27NBTrCEIOHz7yzOJltTDjdfNZNLqSYFp08+vR/IjSDv8h
+ l6PRjkomkbfdPdwPczKS0dG9Cf8cU+NZQrEgE0Un4tvb7p55j9R5OVgHUACLFTlDIRV4veD5
+ RnM2hUFRtBONymXEDjoPGZXaHhv16MckFpZ1IEAkMIZ3Ti/NIZcS7IA9jRgBUQARAQABwsF2
+ BBgBCAAgFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTYCGwwACgkQMJn/EjiFK5/Q3hAA
+ mzMu7EOeWG0xAHAQ4b/ocCSlZqg/MSf6kJIkzUxdnX9T/ylEmrS8cEg5mdJMQMVvCecyDpNK
+ 9MgJPV7MTnR6x/4qgdVUTtknd6W7RrQ7Oai150nMH5U9M8GrFtbQjc/fOw17agoT06ZGV4um
+ IK41IIGwQZ2/Z/cElHkQZll9//hYS8/E8xOBlweVxsMZhfcLFrbx2hC2osRt0vMlGnYSnv29
+ ligVG+2PwwnHXB6Tn7eslzoowY78ANCTvA6Rc6zR+RIs/CIiaDNgWCRBJcueZVpA+JkyL6Km
+ C+JiiF6Hsm07DDDjgLVJ0s660GNe8sWw4IZ8wpvYq1goqXLu+CMqbCsBrEDwfguClxGSQnLw
+ AUIVxuyKprToLJ6hmuubsVcv9fzf/GoYFnT9hge1YZpptKi/zrQqy2CZuSZEHWpUZcwPE3Ow
+ qbHKty3UhZPJU50kmEOd/UQNJYNWxxxx5593X96jLLDOxm5M5jNNRvGZPgn8RbA1e7VC2XFg
+ V2KGJHq/gxCpwkWs8+0sYUtcFuu+RQWTKbJpFcxfAIEDKS+fyLRAFdYqUA3yQIA1UYco10l8
+ RYPLY0+IXiArqjql8+k8PBT0U4P59lfcKlY2GaJe4aoWLPOdNZAJgLzoxd5zgnz0vI3sn+3v
+ meCtpxz2PoYBJfxGPEzu9xTLV6k9wSVTCgE=
+Message-ID: <c88d0689-fc5a-f753-f976-32927c541ddc@example.com>
+Date: Wed, 14 Oct 2020 14:59:26 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="puWBzOWjTqkkgqqMFAKcbmFaxBYZsSBXQ"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--puWBzOWjTqkkgqqMFAKcbmFaxBYZsSBXQ
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--puWBzOWjTqkkgqqMFAKcbmFaxBYZsSBXQ
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//fuRTrk7/tyNEQqKQP39XsEN6aJwov6wo5Pfu3N6kcqbGUwvnsyyPnAnJ
+joVTPVftpywGlywI54/Ljfl0AKiRbgTfUD9s1lfRlnb2VYmSYTcHiYNRXoiKvVM+XU/KfGDoMSxR
+3gFLQO5GsenGaltmJ2qwarsUpW9cMw4Rw69sI/+otURqkx0awt5HnJuk27S+++jRXdoKwFMzenUS
+cLcaIif+baEWUWTGv+OKcWZrgQRscjpxzMUeQLCxu89UI7t2sR9Vfw1amtXQ1i4kSWJ8ZUIvzg+P
+uwRseSobADfHZjm/tmiM/qfNmqs//RBqQuwuEea2VkqZu2AnIxA839NkluXyhbmCMky1joDi+yPI
+BFzZnoZfCKrD2kCumrGWZJlDAZDoU3EOyQZgN5sLxcxjEDb3JL4rp55qhkGMbjnGAWLlE6EGIcP7
+oBAWrrdSrCrCrirn/o+y6/qZJzccgeUI2ViUpuKXvk4iZ8Afx6wgULsetTmHmHn44Gon6XUTr3zG
+GJ7hPBvfKn5/gTyoQAN6Cf12F6pzY7wXNylRO/4tfMw2rQIbm1L/WdGMt1vgRzwaY6iIlA1TkOm+
+9aTDWsDPuA96U7bPMIAz8yvhCIWtF/96QER14E+y3ImuAAx4sX1qkyM5Mcin8J7EDUCX1nNXk3x4
+W2JBvRKDx5gLSTBTxVfBXgNHZva51fIethIBB0BGEv6bE96A5iRj1WP15RPoizOsBaZZU2AOlYEn
+KBauIDCkASBr+X7Fe7iVucVU4FK0TG8IdK1gudM+EA3mHvnKjbcmiTHgxWIOuWA7+qvH3J3S0AYB
+SOnStat91TJWgBXt44bRLtc46DjkHKe9BuXjblpEL9dSqZF9qIYvNMi5qggzSbJApDVaAH9i+Qgs
+PSuCzis+MVvLzQnklo/0lxLxfu/AZxNmM//faYPSUZBxTeqU4uSDqeaHyGZjJenasuJC3H2FLbER
+tbjfHcE20hbqEN3jqD9/qKJzTmXYgvf6jqA3izDt0LDzbcI62LJu5Z4JrJ6NbzdqhtAPkzEgrc5I
+HELl6DQSGdOloQ7hIllPSuDKQsxNg2LtMl75mNh6D+72tR0mRpApccSvdjbA37vUiMeXlzjdZ0C+
+EBeKvWgI7+t2OYWXwFO2THrerLK3KWuVqqaTbhw3VMKMDTuS58qDEV8e1VrPJzEIR4hhGrNJxmTx
+WdR2MCJmZb2ejfCYdHlqWT1UnOcc+dak6ciV0U71Kq7XNkYyQ36CmpsYRPMuDKv+HeW7ZqC6L2GU
+3lmCsasGebtKbZs83IX6JMWvRBtqBrByw96cT+CKvSvD4hjJ49FoQ8gpoDuGhKKi1hiqKtHnsE5s
+ykHGVNE6mMnm3cZdxmdphacO3o/h1Vi8k3d+cFbitM3pS93/qPaNOrr/dYBf7Vwy/B4LT3kTFhXz
+hMPdObivf2nBQ8VkORimtCx7PJONU194bmEXJTxEjRZvlNRFIX8uX3bUTM6clAbcAbQ6Bb7cJqIg
+r8VsVuTJrd6joT/D+2WR97YPjqfswSn7S5CCrzTNvRlPzq7xYKMgJOukiF/Gvpfc6nXont/+7WAv
+iMSFFqhQgl7o3VQDzBv1L32Y41rz7Pb22J5thZmKJRmo1kTCdu+nXiNywe+f29RfTYkhubBsRNX4
+IhyCYYbbPOp2mExTRN17LGBTAUT6LD2To+o63BPIOzeNnC07e1kxp1YXDGVNsoIpcV07ASAjG7Me
+t6cJ3wgEKmmwm3VvZZXBSOaFTAIQYNIiCEEjyZ7OWkVyROSj/RtASBYCOGhAI9UZfU/MwsZ6JWMc
+ahPS5wyKFH/Hsx1CR/oZgbzALtmePRdaPKZ7Uiga/mlHX1OdWHaT7rMPsp5h8pJxwn3xgYySme7S
+F8XFV2jcmzbwrL9j9uU0zJwN1yNd94ETuBFtLuG1H4woHeS8POnZW9pdtyQqkRTVwoQux305D5MM
+JtGofpM+prh255zAxYGGusxgMQJdG/LabRDFLJHgI6emiOU+VJ0O5KAVDAg8BZCSPz+p+gbWBOip
+haIA9o20uUK9mfAcdw11krZjoA46CcLD8o9B90gXkMK45BO0zqwGWJO+0h29wA+AzrNtbM4j40hQ
+xDUbb4vfJpsdflL9/DnHFu591ZjDlkF5qxR38A5Pz+OyXTvKxo4s17zfxTmDmJsEUzygVxNZFsti
+yX3AZPICZeXxnzIAmaPOpxTYRApCijM7v7b+XmqrRATUfXWBTYjvxeYx9v3hRZFttp6FLr0L6waK
+IU1GwEnryE8XGxOrh2gbkbjwBuSzMXGdWncu+ALMhDYu4JEGPMcCdOHNG+Ly7sHT05IIHBpb5cDl
+WMUWSi1oZ1iwYhJg3V5ACQ3/H+GIUjaGhsOrY9yLoHz61LzhqGltyPCnNT/09iZW2y2IU7T8bhp5
+UKkH33h0BtinWSFrj3R3M+frC3lgw9E4hD0pCJ10hLz7N/vBlG/3fETqhp41LguOj1AuXOZLw3S6
+P8MLXqV6ZsDAZRMHwPpAUDPX936Gn6WydlxeZT5Vn69SAyzs0ei7fY6t6Ql64HHAbb0g3Y0AMrvz
+FBKnTAOSgTt6jHk9w569ehkTQE4pOLh7csUvV6m0iGj81Bn24oAybNHmbFJb58XjeR0eo/6ObaXN
+4y1+6gwyr/+xrF7IV34+I4DUr33HG8sgB58QepspkhNJ7rArg7HVEf/tv3IuK6BAYK1Vm4EXQk3+
+TVJzoNcj4AszGpq/QmwqnL5vwspFiYFcSB/IYX8AtzBQTcfsatIMG5bZiEfg8k5AM8OQQWY6TQLF
+L+vtmgvsDyJ528kjxy+nht4hBxlIQZIZxq2t/0GdGx1ulwatMg3cNHC7xeDESAsHakYH1Ph6zBX0
+KiEkDbUsfI6BgdAK2mPXuwDpTUc+FglBAvuq268I9DGRdvPFix5ikDTgRStqAChsssa2WpX8xBW4
+z4tz3U9DoJsLYK7fgq6wQoKiMJYyPYk4Ofoqx+lG1tMkuQPMRGPbRq1/caMko/lZ61jYTCTsM0fO
+r6Qu7lBkH+d3DbXUKGqTCQLVyGDRLoa+fT2YT0yw5FrzYCNwZep1dvNDhv57g46lPbVZYZg5b8bg
+tKd9QEIpmQ7FtktWnPEJfOSGIdDRcEVJW9PZ3Qpb+XDbu/7ulR0yA52rlF6M6hK10h51CCok+jtc
+0yrtmSEDearTVe7uSWogc/l2ZvAMca5qFiOg+JSqZD9aGsHFtzKV6kpUUDsbYc2vt9rPb+N+y0PN
+Jdnbw7XBC7XXH2jqkPw9gLoCj8mSQNUH2HzHBSdp/Swp4gANaHvqYJYHAbQXFk+vcu6NyZ2zWtl9
+VfDNK0uj4Xknx9qAwzg23nDxsUthEzgvrQZT5ibRU9trcjIwTxU5c9mMTzyP896MCGWQy5ygvGgO
+4TicVM9FibfjLAkLlAgjqgXiI1AnniTuqAxf51heCdaIEHhIWF7iWKhHXr6i2jE5U28i/ra/7qGw
+ik/GULxwDrtO9UapoXJ9/LSDAiafv2hP5J+b+ExDjGm3X6Gr7Qz3Icet+H1I/Y3ECARH9PUGayEw
+Y0ntV654sEFWKKH9o9FqBSCEjJg0T0yAbtVQfPk+j6lRIqvn6FgMjjAVImHl6pNz4KHOC4eNgchX
+lA8iKPQQxz2ib8c+OKoByBCxO+FGsUtwM2fD/X2Fd9v6c7UUTtjz38+VJO5BUUw6ip/1mHW2X7Bn
+JY7JDkLC6QYI3Nn5hpsSK5RxLs8wpXk4wxTtDXeiHjWTBaabUx7PynjJmyhQE07Qe8xDhhzckt4x
+FmTgAEEMxSvwaJHiuF7ocmo99kRG9H0qtPT/z0ce+aCVzj8vuYL5sZfJ/eJvrvclcXCCRu/90Bol
+iB8EvqGQ+5Dx7ju4fqrX4WH3dSJFcsSwKkwc9f3YK06e8j9PLXHDCrXv2Dy/Rr0pCp/56vUprjoL
+Iao/zxqiXqsEwXd32c4F2wM+WbuREKveF/OKHm8yV0onOI+6PpguSTP7puT6iQ1zz8FEfWqcI1pb
+gm69dIEuWqsME6YBPF4+Y1VVRYRI+/QBxVHHlNk5PaKvmydWFtTNtiLKhxFlNWA0ZVyn/rqXoNhp
+wWIm7xJOcv0wgn+NNG66WPK+tg+CikawXWfRmCRvYTTzHQsXkz4fa9Wej3h1SydFwy3u1U0sU+we
+vYwuwvYChpdi5jnZpYCiSVv1ZMMKwEN8xqNfZ41GyQOvAnyK/rfHphu0YkusFHJ/k4sh4wsFJXiJ
+SWx8P/0oUGlLMmTtH//9Qmq2t9oUWdNGaEmkz6tTorAfZXCI/xGrIzHesApJ9Q1xvQjsQzsYm81Y
+1zDwBu+oc2UxtaTcWcEwAiKqUvB9Z04kzu3J0VuUhAoVnFgUM4eGnFnzPq7DhTJTH/Bjj8400wuM
+KubCTjDD39w6V1J6h5PDjRxOQ1rcNCx+sOotj35O5CzXNHq2b8rupbpNd5RMPC3s+Sh6M4l8Svo0
+J66Ti1uUidUuCktyRgVL2rFRg/HIIxaqeGD1m8OnprjS0bdaMKS7HZHQo8DIjMHeD6Fs6uFkgfGQ
+8iIakHaBltQorr1B0O3V4pOp8Yn/RnjY5p3MHumZPzzxcQEpr3ZzRt86Ka7qo3Gx5TlNxQqPFSFT
+jQZFbkPTt/l1S2eYQjDZNToxe6mZzub+iqITkJxEvFJTywyC74UlgV2FLm4OmnZgHmU2mkbsLkjn
+Hcb0LHoJ4WdVWNk1OBHjxh62PX8lnxQVDzDAIVXKQE6yF9dAFFVM3gxv00+zHNntaWdp84QzYQgC
+d5VGSI1OzlUbKccpps6q+6MgBCvMAbX8lWYN8oqeXRXMDu+ubNERGv19uwH2dlEajUn2O/QN7hBT
+C6Dg0T/aOwFJ2YEzwAAvvFEHmgXlLyMISPHOuGd0xZtXA8eQhI+TN0yjqP8tJ4zrFneAbgXRmtrZ
+Z8x+rLN+MnDOCcOJVM8VjcPGEXDWnCTrbXOxSoT0mh1Ob21tSQrPvx1IL/wHeUMHQqAiUw2FaT7n
+qFvRsiqdVf9b0fQaAPJ9ylbwV1e9GH91UDRplCaz0GhWCEurDQBh/81X/tZo56+D3EscHScuHUh3
+ej/4gb/F1RWANWHUwpYZSOuRbCCRyG81rTNkaaj4VAonRlnWMyGHmJJy7OFtNxoMDgXHIC1ZePEn
+a2toMivoFf3sHAnwzTduqD/J1SVceek+X6NiQ+i7TUqkkoArQ4syRD3RmbQJPt7ejDS6ka4WnDrp
+tTEdPtaDC5XnVkMEXSwFin1NHUc4vExNmbAEX9QhPecJZXig4PiXhde32U4tIjD39JG0DIDiKS1y
+TDNX86m/uuhGEkDk96egaCW55gQah95Si93EySBOepXiEUXBuElMbi7JDr3PWpPo+J07+LmOkgf+
+ZTtVm3f1mPnHIv/9krQaPIr+61gugZFyhup1iCnH5/U9FgxtMu99V9QfNe9AS89a6abyChT/AqPJ
+ARXpyyQfjywuCCN+WjTOw+8kPEuXi+rwkenVKiiz/f1AOYOZti38rhSuO2Htf6hi0k457wosputN
+XaKbUVcBHTYC6NoLlFlmmTgtco6z6Lx1+PDvthB1AH3B7cfVUkM+KhV/cmlRCRNTFwk7+RbOo9/o
+oTwCVbVzWu/od7QKPb9wdRkevYdE5wIItHc6aJ06k0ZDZuWtHPW/g64B4I+IBilvv/N/NYz1AMsN
+O5IsOU1Gx9wQfbDhaDK8Ianhv/ZO18O/kFCT0Ku8QZULn+EeTV1nFPhr6Gg9rLakB4gqXYM5CvjY
+6LmG6n2hJDAZ8OVb+QqtezZ+9NtQ+gBsIl7cdqEAgadhQ6FqHX7I7qqX7khCs2eOxd7bDWqJ3fA0
+mRB4+Bs1RRHizdMU2QftRACdjGyOMSxp14M9Tv9uzMZ5bw/j5xf9A6SEnnDgsBN7EbIinTOpWnRM
+vcDe83x8x5NNt5k53dfn3h7rjctG0vyUdGoLuqx46z6b1/nfgC5iVGEzJ+FOS5WOALB7Hw13gx5Y
+iw5HvYrMFb5q0+l9OedV0r7Q1Mf+Q2FkzAPvTxIUwOPnmaQt0OMHJ6QNycNNa0iN96Q9uyVK8Emz
+7oAZAnI4IXNQNf1D5IGkAyPiHu4ycz+qaNUPNARha6DKJqDtZbSw1VBdh6F6/264D5hFKssDvvGy
+DxhmJD6VGJ7kNLZfQ7MZsQPQy+6x/ED1P9S57p05/RdOeCe8p24mTgVFjlcCDq/WqFN6iL7AQG9+
+2qr1M1qByS+WxBIgp7i6Ekv16bIsxMKvuIMqXCm09v6x10NzWMjvV/cKdG3dc8o+3kGueEkFlKXs
+q9yY4Fw3dGgxf7RChf3gvMGw2tFle3WNpFOmT+IgIdEbDbGWROZEb7Vxn6Qi7OdCfMAaOr31djj1
+75vi9gfsRYIDSUiwX78USUmxo//I9AeVDibkAYN/e0oRaVNIoAVCxXQazKmTATTKueLgX53U52ow
+4qB3B+lXttp0nTSr+mSQYOFPyGPxopOxIDqiipfnXNj2ugF43lx6Tb1vJPT6xL6OoyPJYn0tqFP2
+bszxXXCea8sXQ3WQ78TLvMde
+=V84z
+-----END PGP MESSAGE-----
+
+--puWBzOWjTqkkgqqMFAKcbmFaxBYZsSBXQ--
diff --git a/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f.eml b/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f.eml
new file mode 100644
index 0000000000..edaf4c34e0
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0x3099ff1238852b9f.eml
@@ -0,0 +1,54 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Message-ID: <de515a63-a4fe-022e-4a3c-96f07536dbf8@example.com>
+Date: Wed, 14 Oct 2020 14:57:39 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcFMA7L9So5P9bk9AQ//WTEFHnI2KYZJbgTfk8CaREcQpE/beaO1ysXdzCqpdRGWtU2UlbqmPxbu
+PmGDWg5f43qUEgO9mG2zsEvnGKlEoJmBFYaXXGhz/6+OoFY7VI+9DDtAWD5Oi8jzzKnUbyjPQO2a
+16PbLeOs/ydjt5eRNVaUVtnyTXMhp4JMLET1ISQF1FxjJJ00XRnaYzRRt/U6MHzIFLnZGBZYr+tY
+K1z+5vtsU6P0ZfWV/Hh8tFR6oqJ0Tiwji+zKwgUupKwC2QQIFy3j4GGrqJTejXiFfo5U/P4i5h4O
+X5qcnKzCX2spi7CTIJdx+uXKYAW2e9zsQIEQyIFoe8mZLgZcR0OLoH7ledfAeMBmVgS8GlM9uitj
+SWkiLa98gnudZbKiL7KXQ/e/TBLKVTPFtoorpGBmfYeJ6/YV42kQXPwK+ABHbxX52T7Tm7d12LRa
+Q27sp/SwnJYoi3hASA4NKViBi8B2gdV/DHzgsSfvHtEpMvN1LgaREolwESQ6U68yg/EDfohGdPdW
+eRiyo/p4jQ3Yo9v6n/boIxEb7xhkymhwQi2sZ9lyzU4HO18xrZ4sSpTjoMYyQV4ebA8nMqwbNpWn
+ACxWYeMtMdE4p6wJmMY232LlNtEAXkJbJbY+BDlKb9y6uMLBGHhXH4v7G9zaA3nDBWHNHAvP1cAg
+kgqURvqhxkgZqPz40cHBXgNHZva51fIethIBB0AykUD/87/8UHaKZX7MYUWr/CNBP+N68qFTgGp7
+UzMgSTAdpz+xzeC7S4BNoVh2IAg40r+ie38dJDxYJbEyvkhkr2wRhZf8A8z0/eGJczjEP/vSwW0B
+TkGuH9zZrlqH03jXZ0RUTGnA6oBq2wpGrBniHNZRJ7+ImS/cJT5D4uuITVDXl51EgTJQENxmSdyo
+YGe/lNoB4MVTxzmPfjWdOC2FqkGoc4jVzSwGaZ+OfLA/GviucholvaNz/LobZJ/AMXBvCbc3jh6y
+YvcZnjtDFFdUJHPCA4M8staEIVCz63UT5fdoXLWdr62H1NOhxWQDlyoZle+a2oM3FVEdyVKLt98b
+mTIP71YGhVXU4oRCujtiopVxQXzVugXXTEioebMw1+QLZLr663Xo1Kr+nlZlDDFBY9+NGLB7lX7g
+QqNkFUfw55jWFYWsj1N4U3/IzHplh/xGF9KH296ZKnzi66w6YRfp5QVKCT+fahOhxKWkKeTOl9Lf
+saUhPs93QMcVFRSW0igZrTh/fPZcplsgakpYchR9QcevkeHdCizk3CY5uULqMY6blUz3NU/aOMWw
+6fuLtwo1svBm1Vg0yh/mMA7HsRsIIB5xmkXEaP6PwM3WKLN4AZrcErQTwdvJ8HPGnIECCePugHOK
+EPB5JRj3aSp997Xwv+3z74bmp5GisjjtK3wFn8zYr0QI0hivRd9vz943rdh9iIMxCSAglawaqa0i
+BhUfhPIQyOfEWu5MBoIofW97oxnHaQ8/A/Bj2uvCIDUDPD2C50BHuVdtjsW9GlOmQ3ZUwj7llbuq
+O3oUeDzqaMdPgFt1QmfowXEkFAQcwRb0EbNboHX1q3F1QCXLklw3Dww/lw==
+=rZjL
+-----END PGP MESSAGE-----
+
+--INcRzoKbgw6NbXSE5JAUq7uEbtRvQ6Hp7--
diff --git a/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330-with-key.eml b/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330-with-key.eml
new file mode 100644
index 0000000000..cf210bf602
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330-with-key.eml
@@ -0,0 +1,139 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Autocrypt: addr=bob@openpgp.example; keydata=
+ xsDNBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv/seOXpge
+ cTdOcVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz/56fW2O0F23qIRd8
+ UUJp5IIlN4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/5whqsyroEWDJoSV0yOb25B/i
+ wk/pLUFoyhDG9bj0kIzDxrEqW+7Ba8nocQlecMF3X5KMN5kp2zraLv9dlBBpWW43XktjcCZg
+ My20SouraVma8Je/ECwUWYUiAZxLIlMv9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku
+ 890uk6BrewFzJyLAx5wRZ4F0qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI
+ 2og5RsgTWtXfU7ebSGXrl5ZMpbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9
+ /0Dca3wbvLIwa3T4CyshfT0AEQEAAc0hQm9iIEJhYmJhZ2UgPGJvYkBvcGVucGdwLmV4YW1w
+ bGU+wsEOBBMBCgA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEE0aZuGiOxgsmYD3iM
+ +/zIKgFeczAFAl2lnvoACgkQ+/zIKgFeczBvbAv/VNk90a6hG8Od9xTzXxH5YRFUSGfIA1yj
+ PIVOnKqhMwps2U+sWE3urL+MvjyQRlyRV8oY9IOhQ5Esm6DOZYrTnE7qVETm1ajIAP2OFChE
+ c55uH88x/anpPOXOJY7S8jbn3naC9qad75BrZ+3g9EBUWiy5p8TykP05WSnSxNRt7vFKLfEB
+ 4nGkehpwHXOVF0CRNwYle42bg8lpmdXFDcCZCi+qEbafmTQzkAqyzS3nCh3IAqq6Y0kBuaKL
+ m2tSNUOlZbD+OHYQNZ5Jix7cZUzs6Xh4+I55NRWl5smrLq66yOQoFPy9jot/Qxikx/wP3MsA
+ zeGaZSEPc0fHp5G16rlGbxQ3vl8/usUV7W+TMEMljgwd5x8POR6HC8EaCDfVnUBCPi/Gv+eg
+ LjsIbPJZZEroiE40e6/UoCiQtlpQB5exPJYSd1Q1txCwueih99PHepsDhmUQKiACszNU+RRo
+ zAYau2VdHqnRJ7QYdxHDiH49jPK4NTMyb/tJh2TiIwcmsIpGzsDNBF2lnPIBDADWML9cbGMr
+ p12CtF9b2P6z9TTT74S8iyBOzaSvdGDQY/sUtZXRg21HWamXnn9sSXvIDEINOQ6A9QxdxoqW
+ dCHrOuW3ofneYXoG+zeKc4dC86wa1TR2q9vW+RMXSO4uImA+Uzula/6k1DogDf28qhCxMwG/
+ i/m9g1c/0aApuDyKdQ1PXsHHNlgd/Dn6rrd5y2AObaifV7wIhEJnvqgFXDN2RXGjLeCOHV4Q
+ 2WTYPg/S4k1nMXVDwZXrvIsA0YwIMgIT86Rafp1qKlgPNbiIlC1g9RY/iFaGN2b4Ir6GDohB
+ QSfZW2+LXoPZuVE/wGlQ01rh827KVZW4lXvqsge+wtnWlszcselGATyzqOK9LdHPdZGzROZY
+ I2e8c+paLNDdVPL6vdRBUnkCaEkOtl1mr2JpQi5nTU+gTX4IeInC7E+1a9UDF/Y85ybUz8XV
+ 8rUnR76UqVC7KidNepdHbZjjXCt8/Zo+Tec9JNbYNQB/e9ExmDntmlHEsSEQzFwzj8sxH48A
+ EQEAAcLA9gQYAQoAIBYhBNGmbhojsYLJmA94jPv8yCoBXnMwBQJdpZzyAhsMAAoJEPv8yCoB
+ XnMw6f8L/26C34dkjBffTzMj5Bdzm8MtF67OYneJ4TQMw7+41IL4rVcSKhIhk/3Ud5knaRtP
+ 2ef1+5F66h9/RPQOJ5+tvBwhBAcUWSupKnUrdVaZQanYmtSxcVV2PL9+QEiNN3tzluhaWO//
+ rACxJ+K/ZXQlIzwQVTpNhfGzAaMVV9zpf3u0k14itcv6alKY8+rLZvO1wIIeRZLmU0tZDD5H
+ tWDvUV7rIFI1WuoLb+KZgbYn3OWjCPHVdTrdZ2CqnZbG3SXw6awH9bzRLV9EXkbhIMez0deC
+ Vdeo+wFFklh8/5VK2b0vk/+wqMJxfpa1lHvJLobzOP9fvrswsr92MA2+k901WeISR7qEzcI0
+ Fdg8AyFAExaEK6VyjP7SXGLwvfisw34OxuZr3qmx1Sufu4toH3XrB7QJN8XyqqbsGxUCBqWi
+ f9RSK4xjzRTe56iPeiSJJOIciMP9i2ldI+KgLycyeDvGoBj0HCLO3gVaBe4ubVrj5KjhX2PV
+ NEJd3XZRzaXZE2aAMQ==
+Message-ID: <3e18d436-f9b8-71ed-bb0c-752146d1e80e@openpgp.example>
+Date: Wed, 14 Oct 2020 14:53:58 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="aVkMKgklfJ97EKIy8RDysG4bsu8ptjSHJ"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--aVkMKgklfJ97EKIy8RDysG4bsu8ptjSHJ
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--aVkMKgklfJ97EKIy8RDysG4bsu8ptjSHJ
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcDMA3wvqk35PDeyAQv/YbXu9VlagveR/Tohj081HUeqpB/+0LQBRKLWm7nB6RVdHLj3CPxAgMfm
+pFH55mvg6zCZmRWJd7tGkF290djFT1Gwc1iQfN7yIg1RmXbYVAqIwtPEl+jNHT5akE3LiFgiDD6Y
+OvSklzwgaNCjotg/ijcXOw1RCIGtDY7/+O8R9vF9ufcqbhza8o8tAIiETWC9fPA3ByG1Lct3dQ7Y
+g5HOezYVgnYhE7P/S6PQYqVHNhY0end/jLkqWPIZJvrms1QJ45cIanzA4yYVJVeDekH8ye3A/oTJ
+NNRgmsP9qebXEorb+yJherpaF24yCs2VEHd3GBKOBmL6JKvSTQcXTuxGZufhDXFEnrm7+v+ERst6
+DUq0T9VRDl0ZiB5W/Fsb79SSMUX0yR1HHg61qb0/NUZwQaeqYVbjJRUKZnvOR/cCfEC12BzVPQTG
+Yd6FDAco2o3r8gPGHszRzM+90HdbM/l4CB0rcWprrX/XlWYHUxHoj77INK1JL6nh+KW+rKPnSsLL
+wV4DR2b2udXyHrYSAQdAGyjJPzETAocN164cfvFFzpD4n/4i2KwJKI34LzabliIwK/5kQ1WZmtJX
+Qupb1Pb1R02eHETSdaxbWxM9iQcEc0hUvEJ0Ma8R6raCtAZMpfCR0s1PAcKHG/cOanGVb0lvTDcB
+7A2pX51ANARMBgtG8Hu0r9FSyh0U5DvU9SYGvGVQawaieWqIqkrKCOgzVTvrAL3pM76nSbyLdeaX
+zzMW93YJ2Cyteo2cRoO3DNIDrj9OGjMkVThO70vZSwQ2J04bFb7Y8FvEt0+8tpzB1NyacguRgKNp
+tuVWVC7KtTV3C4qLLO1Q1WORIMYat05XtG3UKnBWrJz5xR90F4v2pEuZVnGJOx/3Ig/EwkUJZBIv
+DmoWWsWA70/tBSWV/rLIK4xmN5p0Zq/cPqzPtvFMiJxMzH4FcbJB3Ul9TLRfQWaywlA4pRfRclJO
+2yTAMKF3l4j3FFr08LUh0gqmpH2oExV+szn6ZDWN5tdBOZ3NSmxhXScimc2Q4EkarAuE2yMZpWuY
+5AYQeiv9M3OmQolOAOnqMXOjMrFdiU6ELgh5yeKfOfBtpVulvkN0x42cqf4m6C/rNzYerQXVt9Lt
+t2ck1PDYy57B+tE1hIF51vgUw8zfYIZZA5t3i34igdFdS8PFdvsgzUwTwi4+OsUz4ZsvKbmDcju6
+wZGVUsKqZBxPBk6oY1NzqHGBOXDdMvDviK2e7XOuqqP2D9bgFxsQc+vki3UOMiH2a2Pxc4zj3MW/
+u2/LA96ywdZq4AY8R1fIsKs59O4qsdr9oSsPFpSgCkygOlxl0xvYmSQgZV4pIt79BdgLpmgHZS1W
+azTP1aOpZ04PgKCr3Z0kKkgJkq3FEOWfMcP6d7YbT4ZIkYVWjAmHYj8wVld2fGs2LM3BlOC8+UtS
+X+GPUdBxLKoFIf6YzXlYiZqJgdyJHRc245fTM2lZK9zTXR1N5blysj9XUpO164IU650fazjGyPQs
+eYwKRmAuDyfYkv/j749/lC/fdFugqp36mK0XWB32KmToe5C7IAGHxK6/+fSYahJ/eJHcg7mVUsX8
+DiwH3E1zySvqJbMVgIekjMrb7gmZm1T3vVti3aVoiFEeYzDTb9qSuNGBluKO7QEol8SLyIlq2SWG
+cEL/Nx9f0mAOPzn5/0vC/dmph9wKkG1m8iXYt5wq2ag00vvUH9YldGS1EyT199T9tF4BLQ7UciCQ
+1tbHnFrH1+pFMFzwF2wC+nfSxuPd7B+ApK6LMwBslfotm7XeZqZlfdtuC3ED4xi2P+g5iUvQYVl8
+OWf7vBjFFHW5qH25stdoSyWT0Rf8xN1jfAztqtto8S33+SRuWTsheibRLT8d7y0rBGqQSSphAzWX
+XVC/LUPZ9All10LjWY1hcBX1ngK7FgNX1S0ObLRD/qjC2dN6SjEXQPCtczrcbojX43VWhds79uBD
+gLqjFCDWhAwsvtzEYhrPS1QIGGOCM/0X99h/SUbHlxgQdPiWzowt9+2FimyvoE4IRssBc6peJtuv
+8akYO7zSzqryyZ2soHQm1bVwcpmfMXcS6Ku0T/Ooq5NaW19nWajRHDTQzTkXdqi+ii1IGfTeDeIy
+GQQi+TCdi/POy0K68FAR93pkwhs3KMfj+DXIN7vp1uevd0KRTfR9vi+Xh3fryIG+MwCcqksMiGR0
+eTYhil1xWZlQqZDwtmEQhJ5Vul5G1cWzoQ90nl4EMUgMrKUogZ83p3JP7445lQINUcBeXE9fHAFM
+PLM/ArMG4GIqA+gyWZP9KhM51lE7lzzUoX2XfSwdsRpwRIeRXbmkfK6wmMnu8GcpYZabq9kAi2fa
+SC+LUTQECvyy+dOKFo2AEz8dqZ9bqz5lnRQDpK8NaEynvWJDF9YCJVqfr7JMQFxsTbnT2r7nf9tG
+Tu+NBX9neXQIUYDqzU+/09Pm1F6aiKKu2xLdJ6i/EHXNfK87USZNZ0JVgO0oId/KkZBtQvYBm3X/
+oETwwz0Un82rXDSoMx+uxmIyZGK4A9KKwa03JEkHMF6mTxqW4zKtkiekMh+xR8Cg7vTBxnkxbcpt
+DwY+Vft4R4cv4lG278WxcSUDc1Qpix7TZhcLp9aX3Y/61LgpmgkrmavTZqwMOVCRbXMKKqpij+OS
+V8QR+74nwDAWja+ceOSFIk2s+QoxzurM+vsZi3QHk33XXPMOKK+F58jKaDS9GuyC1WmgPZKJm1nl
+DQwKmZN5SZ69HKPuP9q73YCboddRgh1hJ9zI1XpWyJxRMcTsq/8bEGgCcEuAgdrunMRDJxRhTxbH
+Wm6w6w9T5XGNeQmz46ed4ujlGgZZHMvtidk3XAvFMvU0qZ1bQmZGyXJcobs0dMHPb0kyIWpF29T+
+0Cl/Ii3/FucUeV/BlhJeE+oPsGZE1+9sOJzQXAncBwgRQeBf34+mQvTHL6a0H92KnfSKP/dGPsLT
+GBDSh9HBSuO7z9AMD7IVWC0RVnSYfe4MvGqZ0TkEukTQcly2W89y+FbNUo7N3dpE2hUQm+0/6gVb
+4Yl9aMqQPfUXsH9ipS6a/l3MM8w+yNv947irTiiYaQMM/DcbvdoWauwsG5+cm/j8uMYultNFWaqJ
+4SIUzINI6BejEiLSXc6F0uSF9f9R7rRCCyxbVSpXEOWzac9WAhytvuP40Lpc26gTBfW+KGYRXSCO
+BA85/SKJOexYzPEX5Cyovp/JYdNpNYCCk/NvGaA0U27AlpqYn01b4Uv+SsxWn9cGiXkrIztWfVu0
+g9oQdQN7MRXUBooDc0J+lIn4fizefvrZgJa6J7uP6dCRwUE8HDIiRCYZqr1uGArKnJVgShzrsR4o
+vdGxqt/Ee7+ZXNjTH1Pyj8o9Js1/Q6SkoFTx8zUi9/sqptJZOl2TKAeLmV3w8vVLQg1r3VOlB73X
+tM62nRtGFLAUNUzEQl0hr0Y21fmf/vVtYgi6JVvpledeYmFAn5cXSfyCGXj4p3RryNP5syr7Xot8
+3B3TmGXdIbuN+AU78zUip0ygHN60/+UE3fNi/IfQfWli93qpgmXfAul/UQp1/XV04D60vFp86fu5
+Bo2Ssbfxf3pl4gsvabAyWZxjTjG0RjbCS1YOdBkj8lRSAyhby6tAq+gqtaR2VAOn6Qq22N9dSSOo
+b5O1LvYz//Ux8Qwj71Zxw/4aBnN8gbqMuNWNvlnwnEiu5bRfn7z/dEucEh0KiTRcQ+rUQXK30PdE
+HmXoQYPuNz+IffSg1CinJ3b0Dg4nTO8Ara6WvPstypFXuxB1N/gnwKXDzWQLPq3+Cx2ntBPdOha6
+zKAdle5tn3B6R2s0wrC6B260ZMEr8vrn4Kc4LGSX2Fxcnd6+UXD3atLJGxPvp/mYnJQcfk4Qzhbm
+DYx5XL8Km4zifX/EP1TrjmBy3qyXualgo2YEBSRtrkx5aLCCbPWFe3LIqLxBYSW86U1JYWgELQ+c
+Pr4ZV4e7r6GY1CI4reeEsQ/zn8etUTkO+3UAsffxYACYCn81xixDZMlVlGsXaoLVo42RJK/DaG3A
+Lf16l/NgYsf4lR7AsYFxfjsHBvpkxpVrTbwcmgDwbgVblwSOmHBmvzpQSVdAeneOGcnHncsZjY8F
+hsFcG7sQZZ5JlnGASbbfhgNyy32aosFnB9g2akEy2npAlAU4sItkKeuM0YChe9m2PELSL4AVUZqR
+Q6I/0cV/NscEzn0VL7jZeVuakoIOPC17iHn2JTPPX8w5Y+JmGLtJzJue7dOuoAqODXFmKW4Rwwdv
+v5BGDaDQnxLzAHTQNsPbkq4GSkI2KDeA31htISifHNwGJSAhty9CsrP7pgz6jm1f99utn7qLPvnU
+mk4XUjJR/KsJWOUK4idL5yOX1aZZTSiCFYv8VwYOzpknh3+vtV9Mdh+BHgTtzZdfsxQhT9mhv68m
+T4bn4kYpyUAlq3ecCwPmspSp1jhrcoXt1wD+cml/vJW8Gz8ta9G88xZQLm4XD6eJb0C7AXyKsnye
+KaNm6H2uToPsWe7LfaBgNIqJ9CmXHnYadyC163rYkPZT2OT8naqiZqUsvr5QkFkMLIGna8veJ7aq
+l2z0w4M62wt1Gk9oa41ANENf/LuVzd8FqawtgQ7YOUUaNQgIFB98Ne0+LurXvbKXHPFScfeohji9
+HDKhBVDAIa1J9hwhtmtilAOwpETtcAE6K0dngmDwjX8nMPi/LYDB3vqsz+rS50VK/COdHJqyo0Ac
+jv9DqZvdQV0+TxC+5mzXPFmPwhxKjtU0PlEz6KunwmlQIT80ZXKNINicPrL1xZhpgcQjrrLkbzrr
+VKeE2fEcWj8uEBLFga4f/WW4wv95f63mHQlJk3Sbm1n2OTGeuYchTdQ57p5gKop2IxiZeGp2K1p7
+pZhzxIXOGZHOlKmTbkQMCFwHl7/nWYD15LkAS7CKCK/kRQKwxheZ+NexCYZ0nmKeCvWLj38hxpU9
+7ivQaakfgOQr8Oka1NpT1uxdEEu+3TPiZe8S4f+pA14KQ4idK6WlPKsvSFkHo6tWqnrbeBIqSNTt
+cjSfNJhu2xzZylamZv66cvYK6uBZGuD6Gs08g1FfwlfE+rolVRywCSwdW30ksvmhpSmU2KR3t4Ph
+DCXZpmK3ZXnGcy80qtavnDWudv0NgKfokCkv/2s+db24DAsvSL8Hjwr7SPjcAbZizKbWeXEqJhyk
+V0uXhbA9eFtEg03oAGu5+4go/7RmjUzv5C0+Rk9sdf367lYTKG1WiyxfKG8G/ZHSXZP52i8PY8v/
++1WswjTAIln3bc0JPEZXz7W3V5O2PtQqEoUmqCcDx9KA8rPRBARrT7nBFJAr39WUv3mUVYuAZ4zV
+zumJvU36bZ9PPlVsgVl08B2rqweCK5452M/UK0CPwEpsBTh6T4ZP60hGPGbgrL5qdLegqoZP9B4w
+dH2vDgGLRsUetAyHW3L5TwQVEgGRNNz8mBX3XaeJW+k9tnvrjW8GR6p7/AcaOhv4Ubg=
+=1/9V
+-----END PGP MESSAGE-----
+
+--aVkMKgklfJ97EKIy8RDysG4bsu8ptjSHJ--
diff --git a/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml b/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml
new file mode 100644
index 0000000000..ab3e0a0f9a
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml
@@ -0,0 +1,52 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Message-ID: <41b2b268-52a6-c8a6-3a9e-6222653b9338@openpgp.example>
+Date: Wed, 14 Oct 2020 14:46:19 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Subject: ...
+Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ boundary="2W16lTBQJ4ZzVzryxZDTtDj0yHOSWfd6l"
+
+This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+--2W16lTBQJ4ZzVzryxZDTtDj0yHOSWfd6l
+Content-Type: application/pgp-encrypted
+Content-Description: PGP/MIME version identification
+
+Version: 1
+
+--2W16lTBQJ4ZzVzryxZDTtDj0yHOSWfd6l
+Content-Type: application/octet-stream; name="encrypted.asc"
+Content-Description: OpenPGP encrypted message
+Content-Disposition: inline; filename="encrypted.asc"
+
+-----BEGIN PGP MESSAGE-----
+
+wcDMA3wvqk35PDeyAQwAx/6/zGSIvT4IyNpRmcM00iVQhUfdfxUTL/o1hV+MdB7rKZIr+qWJEgiJ
+F7IyNPJW0ps2W4myyCkDQtIy1682ahq6D6kHCNmDFxMSpElrG5Xup4Ibf1es3g9n/OytGXx8699m
+RymR4EA5yAiLEiGYO37N+nwnWhP5BNpk8jgzDSNTD9qbOrXb7Tx32rvOwFCrBHqZsx6LbaD5BWp9
+WdeSqNjJ6c35dhBxy7MlIZWOK27y5TQArsgyoq//3645cQX3jYV0jJbJeWHuPMoMxYfdRHi8oEIm
+P3HnqjtSLUtOTwAcc7Vmp5k9/+PG0IZGLtoR0QLNqrJO607mWrCgYowXZofqt3Bs+Nrtf6cuetWd
+pBcGkfdYldCWgG55dER67jP7rKyx6QjFPgPBPbYPFl/H0lqLBH4YrwyVTQDFDcmXe11k1s9JdrlJ
+JXEqITi4gFFF9E4mj7voC97Fhy2GLPdKH+343gCgTVU5stz8+NyNX7wC2QSogtxEIcBd6FQbTj/j
+wV4DR2b2udXyHrYSAQdAJwk2G0weJUqgb4P+/9f76USsiwNpQO9m0k0FxS5OPGowdVTr0bB/bHyV
+fo2DKIkfmgYKnmoiL99VsigzSMIVh1+visa0mDW2a2oVfJBnHS/D0sF5AfHIvERSb7+yLgpMQPkk
+4cizr+7wiQ6BNbTN6FwG/yhrFbBXp+r//y3ZcTGh6G6IDlAbkAwj4VhTTnxdvBHJCpfnAj2G3AZR
+arZ3nC1IC9RLccV66K0oUOdvehgOMBF37Y+BLHXSL6RMc9PZIvtwH4gVMzATUeOQ1SYENGf5LSYq
+5zXs0sfRCXmC74FwM+PF9h4mBm0zOvEbyL6uqxTEMYDwAACkl8QzsHqhUe8VEhZTu2c1BcGhES4b
+9ajkctWgzG/bA4a8kTwyXDaREZoywIHro0iR5+gzbf3aUm+akWGlCRHCOmaF4ZcYpvFfH65tKgwv
+pRzYheCdjK367qiAOwPXh16vBYB1YOZtm7tSot/jBZ60qaIi5BP9FHXAFoR4Y+VWfx8lZYuE1ZNE
+k/VMN47PJPXgK+f8aMXDbalXuuq+sFl1XezGW3osppOkcL7reOZ/0heH1Say3wLLADnb3NyYaBg6
+ihl8FrVMdvzCFt59ytXn+H33BbrrYb2PfiEABPjzEPoeFItpQxltY5E0SGRYSOCKnpN2G7M1yoKf
+eG7/fXa0EUf1KLLzz+Pj88i4Ht6MQkkb19rwYHgxrxPKhmbV8zJfID5ne2PaE28XPa69wzRIyM2+
+DD5IF7iYLF4KcPURqrF7wYuAtTmOQTSWVv6mlHCxjz/ECeCXJhA+24W0m4/O55h0C3dG4looraOD
+JJMITsjObyRasT5sgS1y7axqlJY8NmJrEdZMn735+kjR1HPPinZiat4=
+=s0kk
+-----END PGP MESSAGE-----
+
+--2W16lTBQJ4ZzVzryxZDTtDj0yHOSWfd6l--
diff --git a/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-0x3099ff1238852b9f-autocrypt.eml b/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-0x3099ff1238852b9f-autocrypt.eml
new file mode 100644
index 0000000000..1c1fe5795d
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-0x3099ff1238852b9f-autocrypt.eml
@@ -0,0 +1,55 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Carol <carol@example.com>
+Subject: Autocrypt only
+Autocrypt: addr=carol@example.com; keydata=
+ xsFNBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJPaNoLbqNP
+ efuFg9ib3J0rKcZfqgnqC4usPVSTdmC4w0MdmHvh+1tUoXcxnrjYNRRbP+lC7zaLRRnEEioi
+ mC0Mkh+ow1u4F2QFBjwcV9bD7i0T1DRfR5k5kh3kcaYFnGnwMjwjJzLtvu3OZbXYsofCw789
+ 0TP4LkqLEQVOw1OrxBnRd5QNBVojcQi6rnKOQ7AUBGRKSXI3QVrbP+x1oImXpQSqIyaRFbtx
+ 57QafDdkyHBEfChO9X96BtMndyry8XgYtcgmwKKWg8Js4TJgghus6Sng5dA7/87nRf/9//Np
+ tXh9mdW3AiHsqb+tBu7NJGk6pAPL4fUjXILjcm5ZXdlUeFVLmYmqTiOJcGFbqHEBGcwLKPob
+ a2JsBEpnRj0ZEmo2khT+9tXJK3FUANc4w/QfxTXMwV17yYvocDPEBkoKcbxE8b2sSK/L7Vi+
+ h21XX6fA6B3zKFQ3hetFvOjEGTCkhFD9asL8KnwQdJmYo4Bd45AVoMZFxBxpmuo9MxPdiF2A
+ GbKHgrKpqDw2pUfelFwMZIVQ4Ya1wdtLe8gEJAMq6YnuuQcq+jjGKubNRywld7xXIsxJCpHt
+ qbCQM9P+gqp1VDBnbsk4xGX0HgILXF2JfyceGMGy1Lku0QA+ywARAQABzRlDYXJvbCA8Y2Fy
+ b2xAZXhhbXBsZS5jb20+wsGJBBMBCAAzFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTUC
+ GwMFCwkIBwIGFQgJCgsCBRYCAwEAAAoJEDCZ/xI4hSufjB0P/0+yaZknO8dS5o7Gp1ZuJwh6
+ +vgTGWrTxcBtsU1JR4BFobPKtMmw45FKsNIiK+AQ7ExCtqumGoTJ6hlclBFMlDQyyCxJG/Zp
+ PdrFUFyg6JUVf05/LWsd4Fwy/hQY1ha8R81QinSHqv9DJk6fKZG2rz7YUE47LFfjugbwUj9y
+ 8naTxj823Vm6v36J2wgl/1/PHoZTwi3vQRA70SoIDt4tSjqBzuclt2k/zlkJmOpBYtQb+xGw
+ pfnh2gBJdYurLwJO9rQlzYjy/+1qB0CZsE95WlkTrqQw8V5S6ULcnyACbETdF5HF/geHL367
+ p/iWULD907E4DJlQBOWjY6fdsJIBj96NfQiG+cXYTNGqaB/FgW8jyoS9vyg4PDOr0nGHLvzP
+ w7xTDUkuoJiWXMJ9kDYTZ+MsWreA885i1JSE32CsqqP3+kI7XQD3d3T3pIPhKOo0/bzbLY6y
+ WBXh809Ovi9fMxaZkrlrmA3lFcY+FbzDjZB+UYOXDB6TRu1jvISVMiXnYf4X21xWyl8AWv1q
+ ANMSXFKUwBSR88I06QZiJBmm9wHcyVtK/Hb6pgH10LydZvIfRDLrDBc2z31rswjNj9UhNp0Q
+ fGdNz/gXdxc8HP7Pf4kHkjIxLrWUNlDpYddX+iz1Z//VY9h2XTmSail5pMyyXdiGm90AGfVh
+ IcaOoeKK9UslzsFNBF9GZTUBEADWPef8E4OUoxU+vhwCxy/4nDfxzV4ZMFYkqp8QgpLzTVgT
+ v6xGVHFx/waNjwR6G34tD0aYhkDrumv9QsMdiQnMw9pLAoc3bnIkL8LkXnS8fVeiuzkXd4lg
+ vpxFlce7KYuXos9Ew7Nm2tOx4ovoygFikjliFTKn+QOVJoTr4pxJL9RdzYQ/pV/DI/fc2cmR
+ Wy0uivP+F+LBtYW6ZOMY1aXzsJEvun2i5ZxV2jqNDhXpD3m6/Y/28WItKbmT80hvTivxO2DS
+ Q1kqNcwB8Z0XWZJoz6iyYUu27dKB0L4S/x4UASlC6J2Db8bIL3Tdhuy+N0BN8sS1TDWb7Oi1
+ Ad8huVxfrRSyOYj4fkksvAEgDEDH6JEvJBU3CGQtfXCoX6d64db2cGp85GDfNHTREJ0mbRjL
+ AKL1RKrcKOG1790OZU2veF5qiN2eN08OLfJURL8+P4+mDWbaOcZasqNrg3YhYcPX3ZZzKfEI
+ vvTOdqMk00JU3zaUZhJvGOR9tJ27NBTrCEIOHz7yzOJltTDjdfNZNLqSYFp08+vR/IjSDv8h
+ l6PRjkomkbfdPdwPczKS0dG9Cf8cU+NZQrEgE0Un4tvb7p55j9R5OVgHUACLFTlDIRV4veD5
+ RnM2hUFRtBONymXEDjoPGZXaHhv16MckFpZ1IEAkMIZ3Ti/NIZcS7IA9jRgBUQARAQABwsF2
+ BBgBCAAgFiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTYCGwwACgkQMJn/EjiFK5/Q3hAA
+ mzMu7EOeWG0xAHAQ4b/ocCSlZqg/MSf6kJIkzUxdnX9T/ylEmrS8cEg5mdJMQMVvCecyDpNK
+ 9MgJPV7MTnR6x/4qgdVUTtknd6W7RrQ7Oai150nMH5U9M8GrFtbQjc/fOw17agoT06ZGV4um
+ IK41IIGwQZ2/Z/cElHkQZll9//hYS8/E8xOBlweVxsMZhfcLFrbx2hC2osRt0vMlGnYSnv29
+ ligVG+2PwwnHXB6Tn7eslzoowY78ANCTvA6Rc6zR+RIs/CIiaDNgWCRBJcueZVpA+JkyL6Km
+ C+JiiF6Hsm07DDDjgLVJ0s660GNe8sWw4IZ8wpvYq1goqXLu+CMqbCsBrEDwfguClxGSQnLw
+ AUIVxuyKprToLJ6hmuubsVcv9fzf/GoYFnT9hge1YZpptKi/zrQqy2CZuSZEHWpUZcwPE3Ow
+ qbHKty3UhZPJU50kmEOd/UQNJYNWxxxx5593X96jLLDOxm5M5jNNRvGZPgn8RbA1e7VC2XFg
+ V2KGJHq/gxCpwkWs8+0sYUtcFuu+RQWTKbJpFcxfAIEDKS+fyLRAFdYqUA3yQIA1UYco10l8
+ RYPLY0+IXiArqjql8+k8PBT0U4P59lfcKlY2GaJe4aoWLPOdNZAJgLzoxd5zgnz0vI3sn+3v
+ meCtpxz2PoYBJfxGPEzu9xTLV6k9wSVTCgE=
+Message-ID: <b3609461-36e8-0371-1b9d-7ce6864ec66d@example.com>
+Date: Wed, 14 Oct 2020 14:40:44 -0400
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8; format=flowed
+
+This is autocrypt only.
diff --git a/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-from-bob-to-alice.eml b/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-from-bob-to-alice.eml
new file mode 100644
index 0000000000..4cba7b0b4c
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-from-bob-to-alice.eml
@@ -0,0 +1,17 @@
+X-Mozilla-Status: 0001
+X-Mozilla-Status2: 00800000
+X-Mozilla-Keys:
+To: alice@openpgp.example
+From: Bob Babbage <bob@openpgp.example>
+Subject: Unsigned Unencrypted
+Message-ID: <838593be-05d6-0579-8112-30f2f82b798e@openpgp.example>
+Date: Wed, 14 Oct 2020 15:01:06 -0400
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:83.0) Gecko/20100101
+ Thunderbird/83.0a1
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+Content-Language: en-US
+
+Sundays are nothing without callaloo.
+
diff --git a/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-key-0x1f10171bfb881b1c-attached.eml b/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-key-0x1f10171bfb881b1c-attached.eml
new file mode 100644
index 0000000000..8cdd89a0f2
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/eml/unsigned-unencrypted-key-0x1f10171bfb881b1c-attached.eml
@@ -0,0 +1,69 @@
+Content-Type: multipart/mixed; boundary="------------8uVAxTvHI06bBILkEFaO2Vfu"
+Message-ID: <4a735c72-dc19-48ff-4fa5-2c1f65513b27@invalid>
+Date: Thu, 30 Dec 2021 23:06:03 +0200
+MIME-Version: 1.0
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:97.0) Gecko/20100101
+ Thunderbird/97.0a1
+Content-Language: en-US
+To: homer@raspberrypi.local
+From: Johnny <jdoe@invalid>
+Subject: my openpgp key attached
+
+This is a multi-part message in MIME format.
+--------------8uVAxTvHI06bBILkEFaO2Vfu
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 8bit
+
+Hey, the key 0x1F10171BFB881B1C (for jdoe@invalid) attached!
+
+  -Johnny
+
+
+--------------8uVAxTvHI06bBILkEFaO2Vfu
+Content-Type: application/pgp-keys; name="OpenPGP_0x1F10171BFB881B1C.asc"
+Content-Disposition: attachment; filename="OpenPGP_0x1F10171BFB881B1C.asc"
+Content-Description: OpenPGP public key
+Content-Transfer-Encoding: 7bit
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsDNBGHOHuIBDAC8naJV8LpawpabNUkmBlzObReLLSnPRrqhMPBCgE6/AMOvClXE
+urw5vmBwtB+Iv5IJPh0Cn1lbOX8enw9kqggs9+6lpuykC0B6rrlK3HJVJwb9vsCY
+A8Y1zzqUa1kwaVTZ0pYohgx5h/vPGbMaeeuez1LMEOQZnEn20eP2ERmhMGCxNbo5
+lYVmvEBDafCG7lBp+zHD8AH59WjoP3Vn56zgJmbG2DieQeV6DRF6/DCPaT4Sx3Ve
+eVx/YJ6ZjoCodAvlxY6Q6gGbxj6OhO7YxOj3xU92VlwOXwnt1ugoF14wn6MSKdBf
+JDoIasA5Y6TDccFWluXCwACfHvjZXyXpTIGBFI672X6iQhTRJM7YKrgdg8qqQYnw
+hfD3l4n01dn9T0ETwQxpoAxXA5tYtYa/QSY5iADSEVd2Vnd6Lvyr6wEgLUKQm8v/
+1/xha0CnGJzcTMBwRHFsi+pVd+5rUsq8/KmzGJZ6KIrL1PfENzbkFtICiYClMd1i
+5cWxXaRDV86U01MAEQEAAc0VSm9obm55IDxqZG9lQGludmFsaWQ+wsEJBBMBCAAz
+FiEEvLXEzP6x8af7kcBWHxAXG/uIGxwFAmHOHuICGwMFCwkIBwIGFQgJCgsCBRYC
+AwEAAAoJEB8QFxv7iBsc/h4L/0I0nU0b0UbBlTNESWaRbWnaOviHNoEamocUG5bJ
+0ICBve0FXvm3igsY6hSSYzaOyCtpfWDPyNIesAiWUdeDw8hHtfoZlu7rHzeD/spA
+l3FwP4DgfPB9giUIi9yPScjNnu2CetsndgpKLRUNiXYz8NhX9QqyQeVNroxepr2h
+TdCH/12Z40bVgjrR8ghw+LB6eX5l2ZQFJq6DiVSL8VYYGKZUJAQcG3nApxo5YCch
+4cAOb3hNWXrooASjfnFd0nRV3EcnR6j6SNWjT4TaPPa38kIqIr0gTuhYl/eQt+9u
+5LXXEBq5WMWWLqRh1WdsPzMWFeUWe7j1mY71mbFWGfDhHIxF/0f9lQlgjwgfN7li
+ejzZQtQXFlXCTbjbP+mOUzq8MNy7OeJRkwURw96JBtHQh0xTm6kSVyrrNj/mvXX5
+J6cjIjYcdkT9lyaykknOxWpGuORODAW8uE7tK8rQYmow4JssyLahCw5ch0Gqdq8c
+UXJ8YYWTGwq9/Ln/LFGFpOIks87AzQRhzh7jAQwAoYiZI+y7UdCvFIeAJcOKMcUN
+P9Ek87bjZtiWiAGlpCDg1SKZEKsTyfqDljaq67ZA6U7bZ9SBVPUGOUT1gqjxW611
+Ydf4TzQdJv0OaSt9Nl5suKgWMooR9SeOLJYgA+9lB5aqVDYKxaB/HkTmNnQzQNkQ
+pDGx7d9QOgTteW0LkI+cTYeI4/QW6wVIinPLWZ35zWR8rjOBim5AFyuP2YUXvrGG
+zecG8vJUh3mPY/RA7zVKfZ6N510HlY4fgSStjlRy1JErlaL8XlUDA12uodp/4fAS
+ifUfjSy3f1g32jf/B0O6vs9WWdW0MTP4/oQUzXvhvOUkXZVZIFiG5x9+L0IDoMM8
+cIghbOIwN34vKYT5wmUY6cGGOabN4xnJKXxRnttjM2tnzldfJ3L9P11ZFhDL5jjD
+Pyz4gb2+tWrKM9WhfULcl1fWXu8oi125Z0MzWbV0o5ibdVt2u773n9XdxzI9SlT1
+ptRt2OU2oNH3OB7OK2bhqSEwVkVP7ZMOoVZcJdBlABEBAAHCwPYEGAEIACAWIQS8
+tcTM/rHxp/uRwFYfEBcb+4gbHAUCYc4e4wIbDAAKCRAfEBcb+4gbHH9fC/95w7Yo
+Uvc5rJEDfA2tKwAf80Nnk2T2/T2FSw9LoznhVrdeuAzhpScLN8Y1D5Tiqgu9m7Xv
+xb+4fvH8uNeH/q8vNSOZUlxSBpOl/FeH3oKoC8iKtUNWAxPfD+a2+5JVKjTD6DBn
+Dkm92GvmSiIuVInYnGDscWcyO6T8kXmEcxlgf2HvOBfNS9PMFU8sF27Xe2cPXY/u
+oBXKWS9UgFQItkQEfLGbh8JIX5sZ1ZzyxFjn56FeCzQitoMQrT6O/Y0/97sJdgIW
+9B9TwZW8dZheWdFRPQo2yG3v9UH1aLPd/siuEZ0onNd1EqwZJSQ4cOjrlHHG/PPR
+fWw7W2nJ01FakgphReaFW+DkGGP7FYd4fUkV/nt5izkbjYMr/Lrz/OzTBIQ/YWu7
+bHjh+z+MTUHiYWgXob45axAwPKGmQtm/sHmcY5c7oV0NHMEY34LvFiVaAedVdpbO
+t3wCHi+hRWKpSIfiyplmuvYSXdKyNsljyOgmNwvRTPb8m5m8vV94EtxuqHE=
+=MfFc
+-----END PGP PUBLIC KEY BLOCK-----
+
+--------------8uVAxTvHI06bBILkEFaO2Vfu--
diff --git a/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-pub.asc b/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-pub.asc
new file mode 100644
index 0000000000..68fdb39324
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-pub.asc
@@ -0,0 +1,15 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Comment: Alice's OpenPGP certificate
+Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html
+
+mDMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/U
+b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE
+ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy
+MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO
+dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4
+OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s
+E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb
+DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn
+0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE=
+=iIGO
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-rev.asc b/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-rev.asc
new file mode 100644
index 0000000000..5e67de7a7c
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-rev.asc
@@ -0,0 +1,9 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Comment: Alice's revocation certificate
+Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html
+
+iHgEIBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXaWkOwIdAAAKCRDyMVUM
+T0fjjoBlAQDA9ukZFKRFGCooVcVoDVmxTaHLUXlIg9TPh2f7zzI9KgD/SLNXUOaH
+O6TozOS7C9lwIHwwdHdAxgf5BzuhLT9iuAM=
+=Tm8h
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret-with-pp.asc b/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret-with-pp.asc
new file mode 100644
index 0000000000..3d2081573f
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret-with-pp.asc
@@ -0,0 +1,17 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lIYEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/U
+b7O1u13+BwMCd/mqK8s+deb/8ZESR0hHYFgUdnYXaG+iwi6g345EZT7r2HxUgaeb
+Qs/f5Bn4zvUYXsnU5p7BN0QxRWMbzago/JFCYKlHuBQABQ0WN4gaKLQmQWxpY2Ug
+TG92ZWxhY2UgPGFsaWNlQG9wZW5wZ3AuZXhhbXBsZT6IkAQTFggAOAIbAwULCQgH
+AgYVCgkICwIEFgIDAQIeAQIXgBYhBOuFu1+jOnXhXpROY/IxVQxPR+OOBQJdpZ86
+AAoJEPIxVQxPR+OO6SsA+gOccFKiA6awc6x32oayJmmBGc53Km9ub5C1dmq2H2u/
+AP0dwMLS0L48cCw7s5OHVLVML1GImy9rAB8I9pBmh53yApyLBFxHBOkSCisGAQQB
+l1UBBQEBB0BC/wYhratJPOCptcKkMNgyIpFWK0KzLbTfHewT356+IgMBCAf+BwMC
+Q/WYlB23rWL/ldyhBBysb6VudEy48vqp2niO4qZSDcbQiL+tk56SfrnMmP0V/w3M
+I8YuUVsIOPHklnJH3NB0oLbR8HVQq3s14KSImVUOcIh4BBgWCAAgFiEE64W7X6M6
+deFelE5j8jFVDE9H444FAlxHBOkCGwwACgkQ8jFVDE9H445Z0AEAxR1LOkyHMazU
+7RGRY1BZtTNDydQ54P3A1uMTfNH7J9EBANtiq+1ZAo3gBAtPFUk3lfkBbJs+ef5Z
+7VpMGFoZrzoB
+=mz9G
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc b/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc
new file mode 100644
index 0000000000..d9252bafd6
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/alice@openpgp.example-0xf231550c4f47e38e-secret.asc
@@ -0,0 +1,17 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Comment: Alice's OpenPGP Transferable Secret Key
+Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html
+
+lFgEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/U
+b7O1u10AAP9XBeW6lzGOLx7zHH9AsUDUTb2pggYGMzd0P3ulJ2AfvQ4RtCZBbGlj
+ZSBMb3ZlbGFjZSA8YWxpY2VAb3BlbnBncC5leGFtcGxlPoiQBBMWCAA4AhsDBQsJ
+CAcCBhUKCQgLAgQWAgMBAh4BAheAFiEE64W7X6M6deFelE5j8jFVDE9H444FAl2l
+nzoACgkQ8jFVDE9H447pKwD6A5xwUqIDprBzrHfahrImaYEZzncqb25vkLV2arYf
+a78A/R3AwtLQvjxwLDuzk4dUtUwvUYibL2sAHwj2kGaHnfICnF0EXEcE6RIKKwYB
+BAGXVQEFAQEHQEL/BiGtq0k84Km1wqQw2DIikVYrQrMttN8d7BPfnr4iAwEIBwAA
+/3/xFPG6U17rhTuq+07gmEvaFYKfxRB6sgAYiW6TMTpQEK6IeAQYFggAIBYhBOuF
+u1+jOnXhXpROY/IxVQxPR+OOBQJcRwTpAhsMAAoJEPIxVQxPR+OOWdABAMUdSzpM
+hzGs1O0RkWNQWbUzQ8nUOeD9wNbjE3zR+yfRAQDbYqvtWQKN4AQLTxVJN5X5AWyb
+Pnn+We1aTBhaGa86AQ==
+=n8OM
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc b/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc
new file mode 100644
index 0000000000..732ad2fef5
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-pub.asc
@@ -0,0 +1,43 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Comment: Bob's OpenPGP certificate
+Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html
+
+mQGNBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv
+/seOXpgecTdOcVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz
+/56fW2O0F23qIRd8UUJp5IIlN4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/
+5whqsyroEWDJoSV0yOb25B/iwk/pLUFoyhDG9bj0kIzDxrEqW+7Ba8nocQlecMF3
+X5KMN5kp2zraLv9dlBBpWW43XktjcCZgMy20SouraVma8Je/ECwUWYUiAZxLIlMv
+9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku890uk6BrewFzJyLAx5wRZ4F0
+qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI2og5RsgTWtXfU7eb
+SGXrl5ZMpbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9/0Dca3wb
+vLIwa3T4CyshfT0AEQEAAbQhQm9iIEJhYmJhZ2UgPGJvYkBvcGVucGdwLmV4YW1w
+bGU+iQHOBBMBCgA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEE0aZuGiOx
+gsmYD3iM+/zIKgFeczAFAl2lnvoACgkQ+/zIKgFeczBvbAv/VNk90a6hG8Od9xTz
+XxH5YRFUSGfIA1yjPIVOnKqhMwps2U+sWE3urL+MvjyQRlyRV8oY9IOhQ5Esm6DO
+ZYrTnE7qVETm1ajIAP2OFChEc55uH88x/anpPOXOJY7S8jbn3naC9qad75BrZ+3g
+9EBUWiy5p8TykP05WSnSxNRt7vFKLfEB4nGkehpwHXOVF0CRNwYle42bg8lpmdXF
+DcCZCi+qEbafmTQzkAqyzS3nCh3IAqq6Y0kBuaKLm2tSNUOlZbD+OHYQNZ5Jix7c
+ZUzs6Xh4+I55NRWl5smrLq66yOQoFPy9jot/Qxikx/wP3MsAzeGaZSEPc0fHp5G1
+6rlGbxQ3vl8/usUV7W+TMEMljgwd5x8POR6HC8EaCDfVnUBCPi/Gv+egLjsIbPJZ
+ZEroiE40e6/UoCiQtlpQB5exPJYSd1Q1txCwueih99PHepsDhmUQKiACszNU+RRo
+zAYau2VdHqnRJ7QYdxHDiH49jPK4NTMyb/tJh2TiIwcmsIpGuQGNBF2lnPIBDADW
+ML9cbGMrp12CtF9b2P6z9TTT74S8iyBOzaSvdGDQY/sUtZXRg21HWamXnn9sSXvI
+DEINOQ6A9QxdxoqWdCHrOuW3ofneYXoG+zeKc4dC86wa1TR2q9vW+RMXSO4uImA+
+Uzula/6k1DogDf28qhCxMwG/i/m9g1c/0aApuDyKdQ1PXsHHNlgd/Dn6rrd5y2AO
+baifV7wIhEJnvqgFXDN2RXGjLeCOHV4Q2WTYPg/S4k1nMXVDwZXrvIsA0YwIMgIT
+86Rafp1qKlgPNbiIlC1g9RY/iFaGN2b4Ir6GDohBQSfZW2+LXoPZuVE/wGlQ01rh
+827KVZW4lXvqsge+wtnWlszcselGATyzqOK9LdHPdZGzROZYI2e8c+paLNDdVPL6
+vdRBUnkCaEkOtl1mr2JpQi5nTU+gTX4IeInC7E+1a9UDF/Y85ybUz8XV8rUnR76U
+qVC7KidNepdHbZjjXCt8/Zo+Tec9JNbYNQB/e9ExmDntmlHEsSEQzFwzj8sxH48A
+EQEAAYkBtgQYAQoAIBYhBNGmbhojsYLJmA94jPv8yCoBXnMwBQJdpZzyAhsMAAoJ
+EPv8yCoBXnMw6f8L/26C34dkjBffTzMj5Bdzm8MtF67OYneJ4TQMw7+41IL4rVcS
+KhIhk/3Ud5knaRtP2ef1+5F66h9/RPQOJ5+tvBwhBAcUWSupKnUrdVaZQanYmtSx
+cVV2PL9+QEiNN3tzluhaWO//rACxJ+K/ZXQlIzwQVTpNhfGzAaMVV9zpf3u0k14i
+tcv6alKY8+rLZvO1wIIeRZLmU0tZDD5HtWDvUV7rIFI1WuoLb+KZgbYn3OWjCPHV
+dTrdZ2CqnZbG3SXw6awH9bzRLV9EXkbhIMez0deCVdeo+wFFklh8/5VK2b0vk/+w
+qMJxfpa1lHvJLobzOP9fvrswsr92MA2+k901WeISR7qEzcI0Fdg8AyFAExaEK6Vy
+jP7SXGLwvfisw34OxuZr3qmx1Sufu4toH3XrB7QJN8XyqqbsGxUCBqWif9RSK4xj
+zRTe56iPeiSJJOIciMP9i2ldI+KgLycyeDvGoBj0HCLO3gVaBe4ubVrj5KjhX2PV
+NEJd3XZRzaXZE2aAMQ==
+=NXei
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-rev.asc b/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-rev.asc
new file mode 100644
index 0000000000..ed22c45d1c
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-rev.asc
@@ -0,0 +1,16 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Comment: Bob's revocation certificate
+Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html
+
+iQG2BCABCgAgFiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAl2lnQQCHQAACgkQ+/zI
+KgFeczAIHAv/RrlGlPFKsW0BShC8sVtPfbT1N9lUqyrsgBhrUryM/i+rBtkbnSjp
+28R5araupt0og1g2L5VsCRM+ql0jf0zrZXOorKfAO70HCP3X+MlEquvztMUZGJRZ
+7TSMgIY1MeFgLmOw9pDKf3tSoouBOpPe5eVfXviEDDo2zOfdntjPyCMlxHgAcjZo
+XqMaurV+nKWoIx0zbdpNLsRy4JZcmnOSFdPw37R8U2miPi2qNyVwcyCxQy0LjN7Y
+AWadrs9vE0DrneSVP2OpBhl7g+Dj2uXJQRPVXcq6w9g5Fir6DnlhekTLsa78T5cD
+n8q7aRusMlALPAOosENOgINgsVcjuILkPN1eD+zGAgHgdiKaep1+P3pbo5n0CLki
+UCAsLnCEo8eBV9DCb/n1FlI5yhQhgQyMYlp/49H0JSc3IY9KHhv6f0zIaRWs0JuD
+ajcXTJ9AyB+SA6GBb9Q+XsNXjZ1gj75ekUD1sQ3ezTvVfovgP5bD+vPvILhSImKB
+aU6V3zld/x/1
+=mMwU
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret-with-pp.asc b/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret-with-pp.asc
new file mode 100644
index 0000000000..8675bb5679
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret-with-pp.asc
@@ -0,0 +1,83 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQWGBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv
+/seOXpgecTdOcVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz
+/56fW2O0F23qIRd8UUJp5IIlN4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/
+5whqsyroEWDJoSV0yOb25B/iwk/pLUFoyhDG9bj0kIzDxrEqW+7Ba8nocQlecMF3
+X5KMN5kp2zraLv9dlBBpWW43XktjcCZgMy20SouraVma8Je/ECwUWYUiAZxLIlMv
+9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku890uk6BrewFzJyLAx5wRZ4F0
+qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI2og5RsgTWtXfU7eb
+SGXrl5ZMpbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9/0Dca3wb
+vLIwa3T4CyshfT0AEQEAAf4HAwLAeopJQm+lq//epgc3hfKy5Gp0d5Mn8ajmwGbO
+wY3ywBcpZcSyGzdU7hXj4n9xDh4hG+OTHinYgDfD48gnyAlpD31onmSQ03alTWL9
++Im20D08CmlSgwKAN9LHB82cAfUJWWjIatGc0zTXjsZATNGPkkm90epNsjsfD1vX
+F4sOQ2xi9nz+auQZySHegEP7GN88Ex0gXupWdOP3VgtXwVb9sJ/SEZ69dPe38i3H
+KgEsh/vixLjOzSpCwqG2u+ITJO/y7Go54w3uaGOFGAG8hN2P7U9+mIkT6vGsqQ5K
+NltOwqptdYVQK2KyXCeY1r6aw4yUAFvoVFtsIA+ylq+PyqOUVAkXYV1nSGiSjDDl
+I6iX+gbfhRCgjYmpXLi1qsTMJebv8zuG4rFkjVb+C+H+pZjp5dOFo4texC2P0EDO
+rL1yHP7urGkzZPXAcEGL4bnjqImwAt+bRlZzh3sJsjPhCZIJcRM2CNzdABQk9TuS
+QJQ6fGB+NAliu/QApAqZ3Pu47n6RFB3mQMiXzwybRUBDcZMwkXyYBWNJxqK2r27e
+XcVKgZAwbNH3IvHb64qQq1AIY1M+y+2TsmJ1wABXOreMVoe02ET/p+LLGRsvAiT1
+Aw1rJsMmOb4SNPmLjxHnftxF3vtl0wFW5AiM6rBLgYFgquzcici2wMJmwqfq5F37
+hZjsYeJKyfFRt2fSAyL1GSwx69vJa72oi0t4MHS7uzPvQFAXjVhJaz9sGUrBkPMK
+abJITNEuPz7zRC1ZSekgvqkcr6uebKdTYp6qWVVgEc05SRhWgTS9mAkG0ikTUiHd
+izazEX6rs5vbO/0Fq9KEM/3+zBhb/EE7zqy/tuk8pJ2hHSZtmjrlA2XJIhmPIxcK
+HmjumEeajoI2+J6sW8hWA0jdurXAVYo2TX6Idyl2KRVPaZEldIVDFkvFjN3zB80M
+78C4BuZQFJaWC7F6LQO8P0Ry99HPHIrn6JekyPIH9174GwENuvbkbRWchTHx+fLT
+IGejy5lc24BtcpHIw0eDnidoDNAWZHfCYMCDcRl4QRW76xjT2IILty+VVRiuyQJj
+bqGymBHpyqmBvOrnyAUlZiN30VHf1Fju1ZQWmtrpzFXEUXDTSm8f6Yv4OXP/WQvf
+M3gPIt91V+DAXGI5tIr+1mWTnYsTzrqI6Q33oYgJwuFNtd4TWEYj6tcd76nw0yoe
+TAZjnEJAFAZaz6hphTHIoy2u35r+oBfq67gRVJVb7CtB9cMTc3fjZ8XdsbKsG7Z1
+9yhFYFOjJ8LAuPfa8Uv+tns+sbU6kwOeTuINPbWLUkpgY+Y7gEy7MdScTpPAWxDv
+8199XK0XaqjK5+jgKQddB23a49rfZiSQQLQhQm9iIEJhYmJhZ2UgPGJvYkBvcGVu
+cGdwLmV4YW1wbGU+iQHOBBMBCgA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
+FiEE0aZuGiOxgsmYD3iM+/zIKgFeczAFAl2lnvoACgkQ+/zIKgFeczBvbAv/VNk9
+0a6hG8Od9xTzXxH5YRFUSGfIA1yjPIVOnKqhMwps2U+sWE3urL+MvjyQRlyRV8oY
+9IOhQ5Esm6DOZYrTnE7qVETm1ajIAP2OFChEc55uH88x/anpPOXOJY7S8jbn3naC
+9qad75BrZ+3g9EBUWiy5p8TykP05WSnSxNRt7vFKLfEB4nGkehpwHXOVF0CRNwYl
+e42bg8lpmdXFDcCZCi+qEbafmTQzkAqyzS3nCh3IAqq6Y0kBuaKLm2tSNUOlZbD+
+OHYQNZ5Jix7cZUzs6Xh4+I55NRWl5smrLq66yOQoFPy9jot/Qxikx/wP3MsAzeGa
+ZSEPc0fHp5G16rlGbxQ3vl8/usUV7W+TMEMljgwd5x8POR6HC8EaCDfVnUBCPi/G
+v+egLjsIbPJZZEroiE40e6/UoCiQtlpQB5exPJYSd1Q1txCwueih99PHepsDhmUQ
+KiACszNU+RRozAYau2VdHqnRJ7QYdxHDiH49jPK4NTMyb/tJh2TiIwcmsIpGnQWG
+BF2lnPIBDADWML9cbGMrp12CtF9b2P6z9TTT74S8iyBOzaSvdGDQY/sUtZXRg21H
+WamXnn9sSXvIDEINOQ6A9QxdxoqWdCHrOuW3ofneYXoG+zeKc4dC86wa1TR2q9vW
++RMXSO4uImA+Uzula/6k1DogDf28qhCxMwG/i/m9g1c/0aApuDyKdQ1PXsHHNlgd
+/Dn6rrd5y2AObaifV7wIhEJnvqgFXDN2RXGjLeCOHV4Q2WTYPg/S4k1nMXVDwZXr
+vIsA0YwIMgIT86Rafp1qKlgPNbiIlC1g9RY/iFaGN2b4Ir6GDohBQSfZW2+LXoPZ
+uVE/wGlQ01rh827KVZW4lXvqsge+wtnWlszcselGATyzqOK9LdHPdZGzROZYI2e8
+c+paLNDdVPL6vdRBUnkCaEkOtl1mr2JpQi5nTU+gTX4IeInC7E+1a9UDF/Y85ybU
+z8XV8rUnR76UqVC7KidNepdHbZjjXCt8/Zo+Tec9JNbYNQB/e9ExmDntmlHEsSEQ
+zFwzj8sxH48AEQEAAf4HAwJdNHZoxWnWq/8Wny+KGRfVj573VfwaaGR+EX1xYlqq
+rMcZVueUzAs4nADvNmN9CRXbav9g0BDQhpMAIEXOntuGClAMr8mYVOqaYeptSbUx
+c6hmLx7cG2MDo3W+r2b8qb3/oiQB5JoJw4xLKUW+qIxXmYY9+q8Q+UUShLO/CKPr
+RT7n98vOFB0uNI7DFrQmm+GrKZAhCpPXpwc4trUo0ZiJAhxTkB5osDf9bq8T/DVD
+Egb201REDynS0Jn9rbmlV1KZvi5gkpqxNg3KK5jpp/RFPS0frcVI4ZhgFMVFUraA
+hpTKyuLNOIloezRk1Yl8kto6k+whUmjN5aMWn3JokBLGvoaAa6WeH0KNgaCWTnWQ
+AAKZMU1h6rOvGF5OnrO8/P2zPR4XTTZe4jlJOeEsGj8Pa/RH1Nn+j52p1/rBw5BF
+P4tfDQEI7df3FpmzUOHZV1LFRtDk50AogHN+SeQLB1EjsFh02YV22VYftsnk4Ra9
+mJA6632LlhZQQZ2kzyrij7vjaWbP56GsMZQbwOGXhUgYE8O9dnEyyaLVNnwsfWhZ
+DI+lwCxwLkG+PdZ+a5Q0CPsMsoTtH2/uht3HbVMyJ3iuUX9DYupCWjbMk06kTv9i
+CtbnOlCx5+cB9kf6hUvKblDGpsENvEInho6kakEkMDWjzdLPBDcEHzdpY/jEiLtz
+URRX8i4o87Q45goylqyi6bt5dG7ae63xXo7gQNh9F3ykK4dBd7brBdTp6o9f9aLm
+MaGh6s8W2Q9Lx+Om5g4mboV3bx34PMw50NI3uxkMKv/FSBGDAW0cqW6WD48zVmhI
+Lq8af1sFsgxFH+Dkx/JbKMrgREz1XcOj1/9H+eppgCtoV85b+hxSlNsMBrwOQ+BN
+GipVbj8UDpvJEo6w82VU/R7dP43AD3gUs8uw/KhygjwUmTAJx46k+sSVU4oN4hzc
+udR1RtDebpcxe0WI69bS0PDixqMkB03wTSpz1sgWyveLpq5WsgTZ3bnI0+ZOeIry
+WdTcl5B4PB1Xi0k6dW8itQUidGcPWMmeW76C+97SLy3xWOeMgkV6XFGTcreufbbb
+nOiIb5tfFP4j05TVTILG3Y3cA4eucCTFvR/RZ9RK8uPe0vDz0ZstTf3NCDMrCvXZ
+m7vDszCaWpe54X12s02WCWCMyVOVPj1DcJvmttrUz7rMwsls2hajWDFzRNwUgrpy
+L/1YTRiSsWH50S+YXfUazW5OfPSh8ojGLWgHJ7GWFY3Ee39v/yvd/DLhcx2w2oWa
++xaypI7KhI0SoWkHNKgFDIK+N+Voejx5lZ/i3yud+htCcKi5Ss+aVdJc0/jK6+z2
+cLWjFKCV0D3KAuc20Sy/sM28Pfpza4kBtgQYAQoAIBYhBNGmbhojsYLJmA94jPv8
+yCoBXnMwBQJdpZzyAhsMAAoJEPv8yCoBXnMw6f8L/26C34dkjBffTzMj5Bdzm8Mt
+F67OYneJ4TQMw7+41IL4rVcSKhIhk/3Ud5knaRtP2ef1+5F66h9/RPQOJ5+tvBwh
+BAcUWSupKnUrdVaZQanYmtSxcVV2PL9+QEiNN3tzluhaWO//rACxJ+K/ZXQlIzwQ
+VTpNhfGzAaMVV9zpf3u0k14itcv6alKY8+rLZvO1wIIeRZLmU0tZDD5HtWDvUV7r
+IFI1WuoLb+KZgbYn3OWjCPHVdTrdZ2CqnZbG3SXw6awH9bzRLV9EXkbhIMez0deC
+Vdeo+wFFklh8/5VK2b0vk/+wqMJxfpa1lHvJLobzOP9fvrswsr92MA2+k901WeIS
+R7qEzcI0Fdg8AyFAExaEK6VyjP7SXGLwvfisw34OxuZr3qmx1Sufu4toH3XrB7QJ
+N8XyqqbsGxUCBqWif9RSK4xjzRTe56iPeiSJJOIciMP9i2ldI+KgLycyeDvGoBj0
+HCLO3gVaBe4ubVrj5KjhX2PVNEJd3XZRzaXZE2aAMQ==
+=ftGn
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc b/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc
new file mode 100644
index 0000000000..f1d746db3f
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/bob@openpgp.example-0xfbfcc82a015e7330-secret.asc
@@ -0,0 +1,83 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Comment: Bob's OpenPGP Transferable Secret Key
+Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html
+
+lQVYBF2lnPIBDAC5cL9PQoQLTMuhjbYvb4Ncuuo0bfmgPRFywX53jPhoFf4Zg6mv
+/seOXpgecTdOcVttfzC8ycIKrt3aQTiwOG/ctaR4Bk/t6ayNFfdUNxHWk4WCKzdz
+/56fW2O0F23qIRd8UUJp5IIlN4RDdRCtdhVQIAuzvp2oVy/LaS2kxQoKvph/5pQ/
+5whqsyroEWDJoSV0yOb25B/iwk/pLUFoyhDG9bj0kIzDxrEqW+7Ba8nocQlecMF3
+X5KMN5kp2zraLv9dlBBpWW43XktjcCZgMy20SouraVma8Je/ECwUWYUiAZxLIlMv
+9CurEOtxUw6N3RdOtLmYZS9uEnn5y1UkF88o8Nku890uk6BrewFzJyLAx5wRZ4F0
+qV/yq36UWQ0JB/AUGhHVPdFf6pl6eaxBwT5GXvbBUibtf8YI2og5RsgTWtXfU7eb
+SGXrl5ZMpbA6mbfhd0R8aPxWfmDWiIOhBufhMCvUHh1sApMKVZnvIff9/0Dca3wb
+vLIwa3T4CyshfT0AEQEAAQAL/RZqbJW2IqQDCnJi4Ozm++gPqBPiX1RhTWSjwxfM
+cJKUZfzLj414rMKm6Jh1cwwGY9jekROhB9WmwaaKT8HtcIgrZNAlYzANGRCM4TLK
+3VskxfSwKKna8l+s+mZglqbAjUg3wmFuf9Tj2xcUZYmyRm1DEmcN2ZzpvRtHgX7z
+Wn1mAKUlSDJZSQks0zjuMNbupcpyJokdlkUg2+wBznBOTKzgMxVNC9b2g5/tMPUs
+hGGWmF1UH+7AHMTaS6dlmr2ZBIyogdnfUqdNg5sZwsxSNrbglKP4sqe7X61uEAIQ
+bD7rT3LonLbhkrj3I8wilUD8usIwt5IecoHhd9HziqZjRCc1BUBkboUEoyedbDV4
+i4qfsFZ6CEWoLuD5pW7dEp0M+WeuHXO164Rc+LnH6i1VQrpb1Okl4qO6ejIpIjBI
+1t3GshtUu/mwGBBxs60KBX5g77mFQ9lLCRj8lSYqOsHRKBhUp4qM869VA+fD0BRP
+fqPT0I9IH4Oa/A3jYJcg622GwQYA1LhnP208Waf6PkQSJ6kyr8ymY1yVh9VBE/g6
+fRDYA+pkqKnw9wfH2Qho3ysAA+OmVOX8Hldg+Pc0Zs0e5pCavb0En8iFLvTA0Q2E
+LR5rLue9uD7aFuKFU/VdcddY9Ww/vo4k5p/tVGp7F8RYCFn9rSjIWbfvvZi1q5Tx
++akoZbga+4qQ4WYzB/obdX6SCmi6BndcQ1QdjCCQU6gpYx0MddVERbIp9+2SXDyL
+hpxjSyz+RGsZi/9UAshT4txP4+MZBgDfK3ZqtW+h2/eMRxkANqOJpxSjMyLO/FXN
+WxzTDYeWtHNYiAlOwlQZEPOydZFty9IVzzNFQCIUCGjQ/nNyhw7adSgUk3+BXEx/
+MyJPYY0BYuhLxLYcrfQ9nrhaVKxRJj25SVHj2ASsiwGJRZW4CC3uw40OYxfKEvNC
+mer/VxM3kg8qqGf9KUzJ1dVdAvjyx2Hz6jY2qWCyRQ6IMjWHyd43C4r3jxooYKUC
+YnstRQyb/gCSKahveSEjo07CiXMr88UGALwzEr3npFAsPW3osGaFLj49y1oRe11E
+he9gCHFm+fuzbXrWmdPjYU5/ZdqdojzDqfu4ThfnipknpVUM1o6MQqkjM896FHm8
+zbKVFSMhEP6DPHSCexMFrrSgN03PdwHTO6iBaIBBFqmGY01tmJ03SxvSpiBPON9P
+NVvy/6UZFedTq8A07OUAxO62YUSNtT5pmK2vzs3SAZJmbFbMh+NN204TRI72GlqT
+t5hcfkuv8hrmwPS/ZR6q312mKQ6w/1pqO9qitCFCb2IgQmFiYmFnZSA8Ym9iQG9w
+ZW5wZ3AuZXhhbXBsZT6JAc4EEwEKADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgEC
+F4AWIQTRpm4aI7GCyZgPeIz7/MgqAV5zMAUCXaWe+gAKCRD7/MgqAV5zMG9sC/9U
+2T3RrqEbw533FPNfEflhEVRIZ8gDXKM8hU6cqqEzCmzZT6xYTe6sv4y+PJBGXJFX
+yhj0g6FDkSyboM5litOcTupURObVqMgA/Y4UKERznm4fzzH9qek85c4ljtLyNufe
+doL2pp3vkGtn7eD0QFRaLLmnxPKQ/TlZKdLE1G3u8Uot8QHicaR6GnAdc5UXQJE3
+BiV7jZuDyWmZ1cUNwJkKL6oRtp+ZNDOQCrLNLecKHcgCqrpjSQG5oouba1I1Q6Vl
+sP44dhA1nkmLHtxlTOzpeHj4jnk1FaXmyasurrrI5CgU/L2Oi39DGKTH/A/cywDN
+4ZplIQ9zR8enkbXquUZvFDe+Xz+6xRXtb5MwQyWODB3nHw85HocLwRoIN9WdQEI+
+L8a/56AuOwhs8llkSuiITjR7r9SgKJC2WlAHl7E8lhJ3VDW3ELC56KH308d6mwOG
+ZRAqIAKzM1T5FGjMBhq7ZV0eqdEntBh3EcOIfj2M8rg1MzJv+0mHZOIjByawikad
+BVgEXaWc8gEMANYwv1xsYyunXYK0X1vY/rP1NNPvhLyLIE7NpK90YNBj+xS1ldGD
+bUdZqZeef2xJe8gMQg05DoD1DF3GipZ0Ies65beh+d5hegb7N4pzh0LzrBrVNHar
+29b5ExdI7i4iYD5TO6Vr/qTUOiAN/byqELEzAb+L+b2DVz/RoCm4PIp1DU9ewcc2
+WB38Ofqut3nLYA5tqJ9XvAiEQme+qAVcM3ZFcaMt4I4dXhDZZNg+D9LiTWcxdUPB
+leu8iwDRjAgyAhPzpFp+nWoqWA81uIiULWD1Fj+IVoY3ZvgivoYOiEFBJ9lbb4te
+g9m5UT/AaVDTWuHzbspVlbiVe+qyB77C2daWzNyx6UYBPLOo4r0t0c91kbNE5lgj
+Z7xz6los0N1U8vq91EFSeQJoSQ62XWavYmlCLmdNT6BNfgh4icLsT7Vr1QMX9jzn
+JtTPxdXytSdHvpSpULsqJ016l0dtmONcK3z9mj5N5z0k1tg1AH970TGYOe2aUcSx
+IRDMXDOPyzEfjwARAQABAAv9F2CwsjS+Sjh1M1vegJbZjei4gF1HHpEM0K0PSXsp
+SfVvpR4AoSJ4He6CXSMWg0ot8XKtDuZoV9jnJaES5UL9pMAD7JwIOqZm/DYVJM5h
+OASCh1c356/wSbFbzRHPtUdZO9Q30WFNJM5pHbCJPjtNoRmRGkf71RxtvHBzy7np
+Ga+W6U/NVKHw0i0CYwMI0YlKDakYW3Pm+QL+gHZFvngGweTod0f9l2VLLAmeQR/c
++EZs7lNumhuZ8mXcwhUc9JQIhOkpO+wreDysEFkAcsKbkQP3UDUsA1gFx9pbMzT0
+tr1oZq2a4QBtxShHzP/ph7KLpN+6qtjks3xB/yjTgaGmtrwM8tSe0wD1RwXS+/1o
+BHpXTnQ7TfeOGUAu4KCoOQLv6ELpKWbRBLWuiPwMdbGpvVFALO8+kvKAg9/r+/ny
+zM2GQHY+J3Jh5JxPiJnHfXNZjIKLbFbIPdSKNyJBuazXW8xIa//mEHMI5OcvsZBK
+clAIp7LXzjEjKXIwHwDcTn9pBgDpdOKTHOtJ3JUKx0rWVsDH6wq6iKV/FTVSY5jl
+zN+puOEsskF1Lfxn9JsJihAVO3yNsp6RvkKtyNlFazaCVKtDAmkjoh60XNxcNRqr
+gCnwdpbgdHP6v/hvZY54ZaJjz6L2e8unNEkYLxDt8cmAyGPgH2XgL7giHIp9jrsQ
+aS381gnYwNX6wE1aEikgtY91nqJjwPlibF9avSyYQoMtEqM/1UjTjB2KdD/MitK5
+fP0VpvuXpNYZedmyq4UOMwdkiNMGAOrfmOeT0olgLrTMT5H97Cn3Yxbk13uXHNu/
+ZUZZNe8s+QtuLfUlKAJtLEUutN33TlWQY522FV0m17S+b80xJib3yZVJteVurrh5
+HSWHAM+zghQAvCesg5CLXa2dNMkTCmZKgCBvfDLZuZbjFwnwCI6u/NhOY9egKuUf
+SA/je/RXaT8m5VxLYMxwqQXKApzD87fv0tLPlVIEvjEsaf992tFEFSNPcG1l/jpd
+5AVXw6kKuf85UkJtYR1x2MkQDrqY1QX/XMw00kt8y9kMZUre19aCArcmor+hDhRJ
+E3Gt4QJrD9z/bICESw4b4z2DbgD/Xz9IXsA/r9cKiM1h5QMtXvuhyfVeM01enhxM
+GbOH3gjqqGNKysx0UODGEwr6AV9hAd8RWXMchJLaExK9J5SRawSg671ObAU24SdY
+vMQ9Z4kAQ2+1ReUZzf3ogSMRZtMT+d18gT6L90/y+APZIaoArLPhebIAGq39HLmJ
+26x3z0WAgrpA1kNsjXEXkoiZGPLKIGoe3hqJAbYEGAEKACAWIQTRpm4aI7GCyZgP
+eIz7/MgqAV5zMAUCXaWc8gIbDAAKCRD7/MgqAV5zMOn/C/9ugt+HZIwX308zI+QX
+c5vDLReuzmJ3ieE0DMO/uNSC+K1XEioSIZP91HeZJ2kbT9nn9fuReuoff0T0Dief
+rbwcIQQHFFkrqSp1K3VWmUGp2JrUsXFVdjy/fkBIjTd7c5boWljv/6wAsSfiv2V0
+JSM8EFU6TYXxswGjFVfc6X97tJNeIrXL+mpSmPPqy2bztcCCHkWS5lNLWQw+R7Vg
+71Fe6yBSNVrqC2/imYG2J9zlowjx1XU63Wdgqp2Wxt0l8OmsB/W80S1fRF5G4SDH
+s9HXglXXqPsBRZJYfP+VStm9L5P/sKjCcX6WtZR7yS6G8zj/X767MLK/djANvpPd
+NVniEke6hM3CNBXYPAMhQBMWhCulcoz+0lxi8L34rMN+Dsbma96psdUrn7uLaB91
+6we0CTfF8qqm7BsVAgalon/UUiuMY80U3ueoj3okiSTiHIjD/YtpXSPioC8nMng7
+xqAY9Bwizt4FWgXuLm1a4+So4V9j1TRCXd12Uc2l2RNmgDE=
+=miES
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc b/comm/mail/test/browser/openpgp/data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc
new file mode 100644
index 0000000000..d85b176003
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/carol@example.com-0x3099ff1238852b9f-pub.asc
@@ -0,0 +1,51 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJP
+aNoLbqNPefuFg9ib3J0rKcZfqgnqC4usPVSTdmC4w0MdmHvh+1tUoXcxnrjYNRRb
+P+lC7zaLRRnEEioimC0Mkh+ow1u4F2QFBjwcV9bD7i0T1DRfR5k5kh3kcaYFnGnw
+MjwjJzLtvu3OZbXYsofCw7890TP4LkqLEQVOw1OrxBnRd5QNBVojcQi6rnKOQ7AU
+BGRKSXI3QVrbP+x1oImXpQSqIyaRFbtx57QafDdkyHBEfChO9X96BtMndyry8XgY
+tcgmwKKWg8Js4TJgghus6Sng5dA7/87nRf/9//NptXh9mdW3AiHsqb+tBu7NJGk6
+pAPL4fUjXILjcm5ZXdlUeFVLmYmqTiOJcGFbqHEBGcwLKPoba2JsBEpnRj0ZEmo2
+khT+9tXJK3FUANc4w/QfxTXMwV17yYvocDPEBkoKcbxE8b2sSK/L7Vi+h21XX6fA
+6B3zKFQ3hetFvOjEGTCkhFD9asL8KnwQdJmYo4Bd45AVoMZFxBxpmuo9MxPdiF2A
+GbKHgrKpqDw2pUfelFwMZIVQ4Ya1wdtLe8gEJAMq6YnuuQcq+jjGKubNRywld7xX
+IsxJCpHtqbCQM9P+gqp1VDBnbsk4xGX0HgILXF2JfyceGMGy1Lku0QA+ywARAQAB
+tBlDYXJvbCA8Y2Fyb2xAZXhhbXBsZS5jb20+iQJJBBMBCAAzFiEEuPL29L060/gt
+xEaDMJn/EjiFK58FAl9GZTUCGwMFCwkIBwIGFQgJCgsCBRYCAwEAAAoJEDCZ/xI4
+hSufjB0P/0+yaZknO8dS5o7Gp1ZuJwh6+vgTGWrTxcBtsU1JR4BFobPKtMmw45FK
+sNIiK+AQ7ExCtqumGoTJ6hlclBFMlDQyyCxJG/ZpPdrFUFyg6JUVf05/LWsd4Fwy
+/hQY1ha8R81QinSHqv9DJk6fKZG2rz7YUE47LFfjugbwUj9y8naTxj823Vm6v36J
+2wgl/1/PHoZTwi3vQRA70SoIDt4tSjqBzuclt2k/zlkJmOpBYtQb+xGwpfnh2gBJ
+dYurLwJO9rQlzYjy/+1qB0CZsE95WlkTrqQw8V5S6ULcnyACbETdF5HF/geHL367
+p/iWULD907E4DJlQBOWjY6fdsJIBj96NfQiG+cXYTNGqaB/FgW8jyoS9vyg4PDOr
+0nGHLvzPw7xTDUkuoJiWXMJ9kDYTZ+MsWreA885i1JSE32CsqqP3+kI7XQD3d3T3
+pIPhKOo0/bzbLY6yWBXh809Ovi9fMxaZkrlrmA3lFcY+FbzDjZB+UYOXDB6TRu1j
+vISVMiXnYf4X21xWyl8AWv1qANMSXFKUwBSR88I06QZiJBmm9wHcyVtK/Hb6pgH1
+0LydZvIfRDLrDBc2z31rswjNj9UhNp0QfGdNz/gXdxc8HP7Pf4kHkjIxLrWUNlDp
+YddX+iz1Z//VY9h2XTmSail5pMyyXdiGm90AGfVhIcaOoeKK9UsluQINBF9GZTUB
+EADWPef8E4OUoxU+vhwCxy/4nDfxzV4ZMFYkqp8QgpLzTVgTv6xGVHFx/waNjwR6
+G34tD0aYhkDrumv9QsMdiQnMw9pLAoc3bnIkL8LkXnS8fVeiuzkXd4lgvpxFlce7
+KYuXos9Ew7Nm2tOx4ovoygFikjliFTKn+QOVJoTr4pxJL9RdzYQ/pV/DI/fc2cmR
+Wy0uivP+F+LBtYW6ZOMY1aXzsJEvun2i5ZxV2jqNDhXpD3m6/Y/28WItKbmT80hv
+TivxO2DSQ1kqNcwB8Z0XWZJoz6iyYUu27dKB0L4S/x4UASlC6J2Db8bIL3Tdhuy+
+N0BN8sS1TDWb7Oi1Ad8huVxfrRSyOYj4fkksvAEgDEDH6JEvJBU3CGQtfXCoX6d6
+4db2cGp85GDfNHTREJ0mbRjLAKL1RKrcKOG1790OZU2veF5qiN2eN08OLfJURL8+
+P4+mDWbaOcZasqNrg3YhYcPX3ZZzKfEIvvTOdqMk00JU3zaUZhJvGOR9tJ27NBTr
+CEIOHz7yzOJltTDjdfNZNLqSYFp08+vR/IjSDv8hl6PRjkomkbfdPdwPczKS0dG9
+Cf8cU+NZQrEgE0Un4tvb7p55j9R5OVgHUACLFTlDIRV4veD5RnM2hUFRtBONymXE
+DjoPGZXaHhv16MckFpZ1IEAkMIZ3Ti/NIZcS7IA9jRgBUQARAQABiQI2BBgBCAAg
+FiEEuPL29L060/gtxEaDMJn/EjiFK58FAl9GZTYCGwwACgkQMJn/EjiFK5/Q3hAA
+mzMu7EOeWG0xAHAQ4b/ocCSlZqg/MSf6kJIkzUxdnX9T/ylEmrS8cEg5mdJMQMVv
+CecyDpNK9MgJPV7MTnR6x/4qgdVUTtknd6W7RrQ7Oai150nMH5U9M8GrFtbQjc/f
+Ow17agoT06ZGV4umIK41IIGwQZ2/Z/cElHkQZll9//hYS8/E8xOBlweVxsMZhfcL
+Frbx2hC2osRt0vMlGnYSnv29ligVG+2PwwnHXB6Tn7eslzoowY78ANCTvA6Rc6zR
++RIs/CIiaDNgWCRBJcueZVpA+JkyL6KmC+JiiF6Hsm07DDDjgLVJ0s660GNe8sWw
+4IZ8wpvYq1goqXLu+CMqbCsBrEDwfguClxGSQnLwAUIVxuyKprToLJ6hmuubsVcv
+9fzf/GoYFnT9hge1YZpptKi/zrQqy2CZuSZEHWpUZcwPE3OwqbHKty3UhZPJU50k
+mEOd/UQNJYNWxxxx5593X96jLLDOxm5M5jNNRvGZPgn8RbA1e7VC2XFgV2KGJHq/
+gxCpwkWs8+0sYUtcFuu+RQWTKbJpFcxfAIEDKS+fyLRAFdYqUA3yQIA1UYco10l8
+RYPLY0+IXiArqjql8+k8PBT0U4P59lfcKlY2GaJe4aoWLPOdNZAJgLzoxd5zgnz0
+vI3sn+3vmeCtpxz2PoYBJfxGPEzu9xTLV6k9wSVTCgE=
+=bMNH
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/carol@example.com-0x3099ff1238852b9f-secret.asc b/comm/mail/test/browser/openpgp/data/keys/carol@example.com-0x3099ff1238852b9f-secret.asc
new file mode 100644
index 0000000000..71162efa56
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/carol@example.com-0x3099ff1238852b9f-secret.asc
@@ -0,0 +1,107 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQdGBF9GZTQBEACjK8Db1095rU74k/RwLhmp9rmFBZR6qyEHANlHSVwqARxa4aJP
+aNoLbqNPefuFg9ib3J0rKcZfqgnqC4usPVSTdmC4w0MdmHvh+1tUoXcxnrjYNRRb
+P+lC7zaLRRnEEioimC0Mkh+ow1u4F2QFBjwcV9bD7i0T1DRfR5k5kh3kcaYFnGnw
+MjwjJzLtvu3OZbXYsofCw7890TP4LkqLEQVOw1OrxBnRd5QNBVojcQi6rnKOQ7AU
+BGRKSXI3QVrbP+x1oImXpQSqIyaRFbtx57QafDdkyHBEfChO9X96BtMndyry8XgY
+tcgmwKKWg8Js4TJgghus6Sng5dA7/87nRf/9//NptXh9mdW3AiHsqb+tBu7NJGk6
+pAPL4fUjXILjcm5ZXdlUeFVLmYmqTiOJcGFbqHEBGcwLKPoba2JsBEpnRj0ZEmo2
+khT+9tXJK3FUANc4w/QfxTXMwV17yYvocDPEBkoKcbxE8b2sSK/L7Vi+h21XX6fA
+6B3zKFQ3hetFvOjEGTCkhFD9asL8KnwQdJmYo4Bd45AVoMZFxBxpmuo9MxPdiF2A
+GbKHgrKpqDw2pUfelFwMZIVQ4Ya1wdtLe8gEJAMq6YnuuQcq+jjGKubNRywld7xX
+IsxJCpHtqbCQM9P+gqp1VDBnbsk4xGX0HgILXF2JfyceGMGy1Lku0QA+ywARAQAB
+/gcDAkMIRvKFmpwU/3Fco4ZdqSn8nrCMDl+qflWKLxMlLh3klTuzHZgMS3RGrjsJ
+t3G0WEcjPuhKxgdUFP7D2q71UILQ5dwCX9HxWKdc8mxuDk5zaAbGL4ppSNusUhOQ
+4PEuBOsiSySc7biaLI/lYgkFNtFBDYXZZB261otNqgsEr1U6A//qlDp9/HIYElyx
+ACfqQRg8w96ZPj/mBJ+MIkq/VNg9GPND04mUpbuJvNtDqSG9l2nRow8Gm8yYUtkD
+fOibvTLjlTaeA1dOq+cy2JJmz4AUHMPy/u6AI5Wja1axEwy3WiQq4ATkS42UetVs
+Kwvae7GKlo1NACbuJ1g992s40M+QMrAsAcxk0PBtEbMb7XojiZpipl6pIuOK2Zi/
+lN57HzVKjs3zPFtTyfVqBQJnY8hrW9/1/vVLig888xfwxOmNv4X2TwymY3/g4d+y
+TVsMR7Y/t0NqVDFEr5bmbMSiMlceVv3m4GM7OtOXQgpjnRRH7xltGaBI02yKt+ki
+V6ZBHjuMsnmem9BQjme7bKuRZgabp/XXUQE1DPVG2GC+X9wq4CIY59wpyDAuWW/Q
++B5RpN8kCIscyCJaLM5b4vCg1bFv6xGswc28tIg3q5BrCvU6+9NcCOJYhJHR98SJ
+4hnCqNo3oyODbo6FMWZBLOoU3DUzjEp5fkVpRzs2aCkuNl1OJoZ6xTNkqfeUQSfr
+Wuwee6LF4IVty6Lj1R+/uZcg1hziCHiKvL16TKY5u02YCNvplqAND0EyLgp5jXNr
+vcc+JAEGGxBF7B52+b7L12O1ApcPKEqawnbkoIr+lyCmHmm2Iigo5G16VHnuvx+0
+cIMH4QTjPYGMQOlqvC34roCfOXhYEtbDj/VZW0IM6K1efDSX45uuAG7TD3kYVq+I
+KEJV5S8hHsPXzBUqa7uchdlZLek96dr0e2eOF5RrqLxTD3CyD3+KiXckSDQ3Vn0B
+vY/8v0fEIFl/RS8QolbMK80hUpkJNRd9gnq9QrwcgCPeJg8lbiPqSGjFOIVge5Y0
+lUNXfRc/fQ0a5A9esjc+dQ4OSpR9Nh5qTpZqaM8HH+FMWhhVERHfoGIp5bA2yY0p
+S5OAIaBOlpdZRO1jqQZuUvkah1O9ou/gt4wEJ9rNFpWX6MAM2VavT71CV3ive84T
+ZbwLmKGd/fmvmYzTDhm5S0oqJ5TUhEWlJu8sBqsOUo+6lXqQfgOuA5PtuH8XZIaW
+k8iSBO4P35p51sXbTRj6yT8uUhZvvMb59T7jzstxvfc2MNxrdrIoxfWMXe6WG6Zr
+gbY6nlMubKIFjkp3ailF0MFM+CePSIux7bi3R+/qY7iqf0Zrg821vYeSCAGYNFO+
+htpKNQAbekjAOwWPB8Q9OoKo2BFxQ8BsRcM/Br1a4hIXrAvalCwfL3rxfoJS0Vp+
+WLTBJd8OMRHIfKO2y88C/pisvBXu8ScbqcBimc5+GcB3nS7xFErsIHGUH0TvGSUb
+tLdhP3WbBjqYWno4UBABtWzLIcpbc//9v6WOCf2ht3QabQKksfYCw7ebbrkYCHEF
+mPetsVewozS6DYpsHT+HVDcA3R35cbDnV87Hl7wO62zmDA/ee4txtj2irp07W2ca
+7l2uFpBvMt/aFzGH5MHwtZhqGs3dDTbRxmZoRAWfm8ukdeHGlywTlDEvngqw+Yed
+y5ZMt9tF3OsFt61PYr5naOl3eqWJBXouqW2uq26Du9TyVfeDMxYk/ngNs/TYnYCk
+JmgKuHDFs7e8l2XO0tgIicm8zAw52TmQ5cAITWrmfuD8x8z2J3K5Q8i0GUNhcm9s
+IDxjYXJvbEBleGFtcGxlLmNvbT6JAkkEEwEIADMWIQS48vb0vTrT+C3ERoMwmf8S
+OIUrnwUCX0ZlNQIbAwULCQgHAgYVCAkKCwIFFgIDAQAACgkQMJn/EjiFK5+MHQ//
+T7JpmSc7x1LmjsanVm4nCHr6+BMZatPFwG2xTUlHgEWhs8q0ybDjkUqw0iIr4BDs
+TEK2q6YahMnqGVyUEUyUNDLILEkb9mk92sVQXKDolRV/Tn8tax3gXDL+FBjWFrxH
+zVCKdIeq/0MmTp8pkbavPthQTjssV+O6BvBSP3LydpPGPzbdWbq/fonbCCX/X88e
+hlPCLe9BEDvRKggO3i1KOoHO5yW3aT/OWQmY6kFi1Bv7EbCl+eHaAEl1i6svAk72
+tCXNiPL/7WoHQJmwT3laWROupDDxXlLpQtyfIAJsRN0XkcX+B4cvfrun+JZQsP3T
+sTgMmVAE5aNjp92wkgGP3o19CIb5xdhM0apoH8WBbyPKhL2/KDg8M6vScYcu/M/D
+vFMNSS6gmJZcwn2QNhNn4yxat4DzzmLUlITfYKyqo/f6QjtdAPd3dPekg+Eo6jT9
+vNstjrJYFeHzT06+L18zFpmSuWuYDeUVxj4VvMONkH5Rg5cMHpNG7WO8hJUyJedh
+/hfbXFbKXwBa/WoA0xJcUpTAFJHzwjTpBmIkGab3AdzJW0r8dvqmAfXQvJ1m8h9E
+MusMFzbPfWuzCM2P1SE2nRB8Z03P+Bd3Fzwc/s9/iQeSMjEutZQ2UOlh11f6LPVn
+/9Vj2HZdOZJqKXmkzLJd2Iab3QAZ9WEhxo6h4or1SyWdB0YEX0ZlNQEQANY95/wT
+g5SjFT6+HALHL/icN/HNXhkwViSqnxCCkvNNWBO/rEZUcXH/Bo2PBHobfi0PRpiG
+QOu6a/1Cwx2JCczD2ksChzduciQvwuRedLx9V6K7ORd3iWC+nEWVx7spi5eiz0TD
+s2ba07Hii+jKAWKSOWIVMqf5A5UmhOvinEkv1F3NhD+lX8Mj99zZyZFbLS6K8/4X
+4sG1hbpk4xjVpfOwkS+6faLlnFXaOo0OFekPebr9j/bxYi0puZPzSG9OK/E7YNJD
+WSo1zAHxnRdZkmjPqLJhS7bt0oHQvhL/HhQBKULonYNvxsgvdN2G7L43QE3yxLVM
+NZvs6LUB3yG5XF+tFLI5iPh+SSy8ASAMQMfokS8kFTcIZC19cKhfp3rh1vZwanzk
+YN80dNEQnSZtGMsAovVEqtwo4bXv3Q5lTa94XmqI3Z43Tw4t8lREvz4/j6YNZto5
+xlqyo2uDdiFhw9fdlnMp8Qi+9M52oyTTQlTfNpRmEm8Y5H20nbs0FOsIQg4fPvLM
+4mW1MON181k0upJgWnTz69H8iNIO/yGXo9GOSiaRt9093A9zMpLR0b0J/xxT41lC
+sSATRSfi29vunnmP1Hk5WAdQAIsVOUMhFXi94PlGczaFQVG0E43KZcQOOg8Zldoe
+G/XoxyQWlnUgQCQwhndOL80hlxLsgD2NGAFRABEBAAH+BwMC60XTwOohAEn/0j+8
+RoHHyL51yuerEHLjRz8YSgV99UzsCp/6DUbRYrtH9cikNAW/HP5KKbL0dSrQ3C+N
+ITD9znohvMyu2avlhu4x0blJXeLjhwq7nemADuaM7DD7fwLSkBI+ybxK4jyDRvH2
+We7+VN7Gny3Uq1nwIGE/v1ZUCo9nDKKzYlTLO5C6jP0ooX8ZzpMdKg/qGuhnEeKU
+VnAWlHbslOjCZayNptUkzzKDCBAujXz7FUDfmpMQLzEGSbLQSfnnbeRB9aiRofwK
+rQ7rKFy5SGvI41c+de0GOFF0gfO5rlj51DjSYP7T01hObAZ/UjeCbUm5mjC3bR0d
+jGpq0ccaCAGQ7PExi6HyE1LCKS3zNQzuhKY8chxbWybL30gG4byEUR0XPcNwQVGa
+pvhnvZ/d3W1TALL/tITsh858jFMLuqL7ljzXnACX26QF9wJNmPXagzyTVJbI2FkA
+Na80XqIbGHiOzaaoVBHws0rHrmk2EHN897zb+xXsZPSRhrH4/4+syySQaZ/TEqoX
+R/D4BnAQf93Vui2PXgBGufNqK0Ttfbz0TiZ5VY2ZvT4IG5vly3rC1xBLNrY+QgIP
+N6wmVPb5+ho0jV7CN/dTwoxofOyAddWVIIH00EfR2ueb2WgQJ8YFyKSNn+myxf63
+gB8Li1zYYnBMFpK/e+2IzEzcdkDXQ2iqZ/FNqgiYyf+QihHSOJP/QZ+mtu5C2e6z
+fy2QmjwZdTJmnzo3iZ6An8PsR1pmWmctJjjImrAtY/n285zkeKtgG/jo++KNIbYC
+Jj9DvFesQgkhSSld0NATrYesnvTJA6k4vKuIgameJSZ1DnJHkrCZ9cBzMWlzQMZy
+dCKq6zrzYzeaPbvIIPN2MD9fzys9VLWSFFX8XKD1QVuKY3SjtZ4TCjOddZaoJ4la
+qsJz/3xHBmfhm2jhEZDvmRDgfAmm9OfZEfntdnZZkuCh4XfCfTBIW4aO54u/3DQs
+e+feH8mlQFeWHUcbJ4/+4rna2jTaGakj/lb+T30W+O7xcv6FYLwul9jRf8zyb4i6
+5qEyUQfqZwz56sJUSZc15ZUdvVj7S159ssoB8Qwkz0mQkfi5zwcYaWl7hK1q+ELb
+T4c8EqbYAPnwsmyjgnNWE09Xum2JZq+1qxekb+8BJGs9b0cN4iR6kHDvpsgdSHZV
+Shau+D5jI0wEg8Cbn14bB6OpH6wwrEpX3U2wf6B7Ax/1PannJjboi4SCs6m8f/v4
+uSfl7Y1R42SvmxjNwgmbUmlAz+XsP2Cgxx4EJ0dPLclDjZWxg0u2Ozj5Mw1AfQJp
+zvlcEPfDXTaiv8TKmAhK+z91RThddBjGPaQAVDbyWCQ0Hk0hnc3rlSniGFE0rb29
+Qbp6T/NDcZq8/PNqrBEQUudjb5SuXAzz/kd8PUNo7c9TPjYzfS2zcE9ybfmiYM+I
+jVRQKcY1j8lQUaW99CJpI8kW3M4srADCYRFG8uZDA9Uyqf+b6eYEb8eda/eJlmxK
+t9EwTDpbrtuZDAmszCoymJvTTFoBfjbgub4V62xjiIU9jBjSpD2d6zZnjizUZhaI
+p0f6Lj8v1/89W99wV/dyNb3X/fHhgTRF6Pw8YdVgE1mnkub23xevLzYLT3NuTbWy
+A+ROEVTVtChpFezaKt51MZeOLn/Us+JhPuJUU4w2HwAvVDfr0Yh86F2gOLV5fT4R
+X4bExqspe3z8g+bHnJ3FFg3yLGRhjmhZnsi4NxmqlTk1R6vwPCpke5EEbbhRhJDP
+fOUPY0ONojhUtJzEMLA2i5TDpIn6k5dodYkCNgQYAQgAIBYhBLjy9vS9OtP4LcRG
+gzCZ/xI4hSufBQJfRmU2AhsMAAoJEDCZ/xI4hSuf0N4QAJszLuxDnlhtMQBwEOG/
+6HAkpWaoPzEn+pCSJM1MXZ1/U/8pRJq0vHBIOZnSTEDFbwnnMg6TSvTICT1ezE50
+esf+KoHVVE7ZJ3elu0a0OzmotedJzB+VPTPBqxbW0I3P3zsNe2oKE9OmRleLpiCu
+NSCBsEGdv2f3BJR5EGZZff/4WEvPxPMTgZcHlcbDGYX3Cxa28doQtqLEbdLzJRp2
+Ep79vZYoFRvtj8MJx1wek5+3rJc6KMGO/ADQk7wOkXOs0fkSLPwiImgzYFgkQSXL
+nmVaQPiZMi+ipgviYoheh7JtOwww44C1SdLOutBjXvLFsOCGfMKb2KtYKKly7vgj
+KmwrAaxA8H4LgpcRkkJy8AFCFcbsiqa06CyeoZrrm7FXL/X83/xqGBZ0/YYHtWGa
+abSov860KstgmbkmRB1qVGXMDxNzsKmxyrct1IWTyVOdJJhDnf1EDSWDVsccceef
+d1/eoyywzsZuTOYzTUbxmT4J/EWwNXu1QtlxYFdihiR6v4MQqcJFrPPtLGFLXBbr
+vkUFkymyaRXMXwCBAykvn8i0QBXWKlAN8kCANVGHKNdJfEWDy2NPiF4gK6o6pfPp
+PDwU9FOD+fZX3CpWNhmiXuGqFizznTWQCYC86MXec4J89LyN7J/t75ngracc9j6G
+ASX8RjxM7vcUy1epPcElUwoB
+=BUDZ
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/carol@pgp.icu-0xEF2FD01608AFD744-revoked-secret.asc b/comm/mail/test/browser/openpgp/data/keys/carol@pgp.icu-0xEF2FD01608AFD744-revoked-secret.asc
new file mode 100644
index 0000000000..3ac661f8d8
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/carol@pgp.icu-0xEF2FD01608AFD744-revoked-secret.asc
@@ -0,0 +1,90 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQVYBF4Me94BDADdkytpaErSDmRE82dXHVBY+0jHt0oq0zDF28RRhS7NhJeTYPnn
+gxOpUhwn6JMAFJ4JxaKbh5ukZL22S/DZBX8tlc0CEm0LtSgTnsH9i92+a6GafWka
+JnG246ivX90fKBLIsXy9WYiPaYjlu6PrRcKW+vGM21boVXgYTH/iichLKt08/JOx
+S6ACRLzxi8AJK/8HfddXmM5bSt1VibbZHX7uY/7CR9UDywGPfVYxbyW4jIpBBLkn
+drrdvZv6gnroPeGCJHoeV4KmBWLQ8zgJaWOG8Q9M7SiF9ctYONhLg+PfmOCVwUlu
+hW4RksYqa8bOCtq/RP2xYvfIa6dtTtasUQm0sBezqxsvogPyK/wLD7OLUMXZa0Gj
+eGSpSlW7lpuYuYqwjQsndIDR6seLAi7uSsqS6knxt4QWktXHBBRm4M0MnbVaEpJL
+en9Uy55MIlaTIR2xU6Nx+WcACkSdV+ia7tZaeDYzFZfhW8wSwLQzbwTPn+fvQMib
+5CJ3F1iZ/b+0NwcAEQEAAQAL/i9xUhPFRXP5qeWLmn5wI+KRl3FP4R0PUDulRUFR
+O4a807m2Q3wbOEwCbU3sQgF76KTeAOW9YQmPmoFcfErzntoTX6yIZnnhxZ2B2jag
+So/5usX6AVWckq+eymEWrSF9MpxvSG7Aq3lNlwbdqQj4zGQIgkzvMAoaNtc5Fg7b
+Apx5pppm2HbXYwDpV+1+R1WPapFflArJCLJKct7cWF7frmx707IksEsaHhDrvNdj
+3ZdI3Fd0m/KDvRo6fZnjc06j5O2cbD6dFel2dNr51BefIY9bT62hxnSXqmCHxp9J
+hxrcjmblGCA0IZcMByF7e8y9txvOEKYAZQP5/CKQpCBArJvx1cfI3EutQJ6K0/pq
+cY752L5KRCzQx/k5lknLqC6ehd18unmrWSih3R333hZc+AVVIHgTBBCTXK8NiEcQ
+Fo8V5CP18e2U+Q5xV1zREi/68ozrJqOEksMmQALr7P6rNfD3XfMhEzH9fovaEhe4
+n2Ra+bx89ea+yrPFQ33xSgt8CQYA4tojKjFLSJV2+kcJ+te5MZ0zH1YxUJBacRcW
+CTsNErzQhusMxx4lplkC6ygNwjaSYzr8+bfT0zMpXop0l9tRQVCyFmdZwWKADVM8
+pXl0vOVsB8CmEV2tW9rG5YZdvOsLzHucKd8TS27P4L0fYVZEr37iiwdM1/2XOZMx
+hheFfjluZApOMxh2XjSl2QJXEXIiYIGWMsgcsJHU/+EcLj72cR16QjzlaB8lDDJy
++aQfRLb9fLlVDAvSu2nR0izHsrD5BgD6C3LD2mMjmVibUtTwYZvpgvtnE0080bBG
+18d5si2fVuiii1j4p5qHaymXmx8kHrfj1JP1W5AGWecxeq0r6lNTVpvQK5KSZ9Pg
+Z8Z2MJWsbwXcTVknfa5V9E44Z8FluQOvJHP0YhXEF3glpmEDG3cwFaeT3klwx+tv
+8lXKkXrfI0NJqIbf5OkOoJHv9y2wT6QtwMrf2U55mBqBtTqV28jP7TrZ0FOgCX9U
+svF134cf3or0Af59j8SGp3St6inAJ/8GAKSQWSPXRoAzbidTaBKR1UtoDsrHmEOQ
+lvLJ+D2c1A+YA1Wpyw+vftxgLOtD0+IAUBvqysTZwmaHzYTUDrtfrnyka3ZIxLeW
+mqQFo6rSM8PpTs86rhiiI1GalpdkFcA/XYh3RvjgfkWvzgJ8p8xDD5SggpUdk5dl
+Aj2biZ+PVdhjgTXafBEvit44N35LddYX9EH9OAGhLF10+PpzxErdw/s1URFCIfOj
+Nqmxj2/5hO+PgjZBeuM5SS8AxjQ4QbRsAN9/iQG2BCABCgAgFiEErS+kKxKUNZjZ
+774v7y/QFgiv10QFAmDle0ACHQAACgkQ7y/QFgiv10SxYQv/S+BdfaDbXAQygUcp
+nSgBrSpxhX2oTmw3hAtaP6iwKpDR1ztPtwy8IRYWBRmd9NrgWTv4a+7hgnO/OiE2
+Pbe4pwDcnNQ1LlKBizE1bhNcY5A4RI2MYQQGjLd5ECIbg23A1m1/lca+BfD88r2a
+KFETFfM5CNjhJDnwiwRvo6XXKMQ9GhpRND8ZrP3Cbb+zVGMkn/QEaELrB5opc/Ar
+KAM3kN0qn0wsZG5PnFpSjQNR77ZQdrJqK8+GsV2zDoeuevMLg1qy0JNAVwTxJtsF
+86RWhNSbRiTpGM1s3Frs3CAx82copiE2qwTN0GtFxHKfeN0cHM2gxd3aPWqu6hqd
+F/239Hh+AlpvOjiEjHkM7mAtzh5Fue/tuMjbNgu62Uwnl9cbuzq7EYDHfAbgEVhw
+VQdAFPNKBD0MmKxrSvwJ/P5W084uvZWV7fXaFpZMGtYyb6rEyk4U4t6LrMp0mQOA
+U96Pt3yVtS9nMWIUp827EI4g6ZGMYOq00iLHx1sRwaR//nzutBdDYXJvbCBDIDxj
+YXJvbEBwZ3AuaWN1PokBzgQTAQoAOBYhBK0vpCsSlDWY2e++L+8v0BYIr9dEBQJe
+DHveAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEO8v0BYIr9dED9cL/0+Z
+rKU0wPrDvzG9AoWbx2tu/HYt3GOU7PsssVl33ul6Bu1KmydEn/NKZnrKpo5GrhSh
+davcVpUP2JDh2Euz+v22R7GAH4e5FkpXXHtlC3YW5m+WoagjLz7PlsBGiClrC8GQ
+oYryMrkRBCbQQB/hU8Lxo8Gt2dqpG/f6HyajH+yDDVWlANRQVXEZlprT81NDFJ/4
+7z3dXekCNw8g4M7+bvMNSiqjQ/pbl0DnDRnSVKRMojxujZsnC5Z5MvXOEAs4kn6i
+fXdABwYRwTmtdUO0QFQshJj0Oa3GwJEiJaYpX1Yz5xAjhTOIfGGvO294xtzBVq64
++zVpxVkiBTMosNENOWD2QVo6XTfAy1s1INmHHLvAOmLoODNbtbGhpCF1RoajBtSS
+7Av0gsEn1Qy4X4+mpSltH18h3xMyhOSTKDle9+G8gdJamKN4ddJI/VGCW9wkSe37
+yXFMZy21ygweo1uQ47F1Ox+iXanFXzdznEuc7MsV73vIW4QIfkSApWFjH5XqdJ0F
+WAReDHveAQwAnM4KkVysjFe9Lstc3TH0jrkZmOnfuAWPYm2mBzjUYBWPM1ccG0Nm
+TDOemMcNqHPwUz3693vBjOpuEqnkiiZwYLXiJ/Z7wR+PlgskFkqec/66pSTHzGVw
+MJ8FtStaSODe7d2bZttD0y8WrGNjTMv0R72XuFkGTnOoO9iMV4aNKLj7Vr6lZTdr
+BX2LNiVgrpPmvp40kxV33xA2DGBfAT169YHmxAnu8dfXgzD+nRRehpYbGPxwiXnJ
+RosHULy8vE5q9CAH7fEn7+1Pyyb3d1qzFDjAEApCVidVNOEXzFR/AmvIFggU1SxH
+mbTmT8vRi0ioRen3zX8PiVOsSUZ0shvJ6QXoYl75IBDQHaUwY4B4wzSXIM+xCBC7
+OL2e2RsTwcgIGMrchkcEonM2JQDiih8Mrt3+2k6gEwmrFrcuJtiv7hqEbqipGCF5
+yUyhqc/G0gBkXd4/w93Rp/gxofxsAsJhi+v7spx9BuATi7fvc3q7FwXHYXzoBJDQ
+De7RII0E02PxABEBAAEAC/kBkmSuJJqjslj2hD9x8Fy3hguSRMzom7X+X6OYOLBT
+UWqs3MjkDT10J9klW6sgwZ8apy6cpr2lKkVFyV9zEuknOlzczPEcZF2PJzKFSS9J
+sczgl9aWD1HBYQS2AQFf5TuC26j27jLXEKamk31Pi/oEHUEijfznfNa7w9h9+PQH
+C5n/D9HutUWiFFTjYJr1oQyr+OrFay6Mj+1qG24JtYUF0JerO9TB+/Q1z9V2O7nU
+rvebp7h/6PaU2b2uPY1wSffEkP0ZnGq3QqICjDxaTWxhpMj6cftau+3ZrbqPHvYE
+JH42If1DkRlv1L0eZR5gQf0RAWdF7spcWpIb4gjeCkpQx+9lF4gPuuxAYHj4cLra
+1Ia1GWsTNz8ZqKlS+QRrFcWdalZt4Dl4dDxkdM4P0kwMihND0kIQ7WB1bb9PzV2D
+RTQzV90zlrzmGB/dM+kYZJdNQCiE5r4uI3GVZWvbc3NAH1f1O5DVWjZ7jkMzoTOZ
+TsEmqrI46MUAnRZnPj7pzCkGAMNe226f3s+bLskcUr5Jo6tvsUsIP8c/GsHQbvEQ
+pRXazXgD8/nO8w/UGi4b8x9ly93Hb3WniarktdJ4yLd9XlRANgYd6NFjb6NDj1ET
+EfKxeJtKqpBaFbPhrjWqlVMYrLUyFrsBAV+vwbrnzlPzd2VMmtTNF9tQBXWilqVX
+K4CW6R4emLRzFwZcPxD+CcpVxd0OgavkLWmlNlfEAyo8tmu0rmf+OFOgawlRqmLi
+liZVmUomH4Bk5KN3uj/907IZ+QYAzXdYyLMc3JONzY0DG1HFnvuSCFscyxlLX+Q5
+qbhHL/4g5wjLxbWwAmXTb0DeSn3sLLikwXveEu+zJku/Zq0GOMKTYw2uN+t9COo8
+t0uYknAboZvA15xJCmY7gEjORpqw9pqSC+ZLN1FKjAUqgPICr/SKsDT9GLg2wusH
+vs+KYeba7UborVpyvRW2dea+8PXU6INTfpGM6MIt/mkVx/y/zIuimbqsMNHNQu7h
+XcmgBmvGmSkB2tQhj60y5v7vY1e5BgCOt2NN4nPXk/mqe+RAZKPuSFBs3LIzMnzH
+8G7lW0De7gqFip++2RLRbyGat0Maf/m9eK9c5+wvChlbXp3lHriylzeBIPJwUj/5
+lOfx9Nz1BZHQnG/z++7HlvZV7eiWhOW+yAVrWoZ8U0atGTUZvcN6PPedWDJRZyIR
+NMKmR5BGCdbvN6F+B0vXYNVi6mDQbzazl7yYH012i6sUEHS2/yuPpDlJ0JjdPMkH
+8ih27RwgmXFn1BN9dZW9Bk8/Sj/Pjajrs4kBtgQYAQoAIBYhBK0vpCsSlDWY2e++
+L+8v0BYIr9dEBQJeDHveAhsMAAoJEO8v0BYIr9dEYlcMAKeEAdAGEyrWX2DH+Pli
+K+obyFMyHW7FqAot+pu7SndyT/3nSXeZwvtYzEdiJuXbTRRDYa12GsGhmAWpVzft
+1nLfcEvkFw5V2ODFDtuzGrjQ2ZxFlgNOFZcY2lfgHUsMaAT/Nr0AsLknFByWANMt
+GQ9/zIEGcji6JXBgVMdDOH2MGnucazjyP7I7MEN4mGBQNhGzX99xlOnvPvGtZqjR
+/ub3KvWlzhBUaZ5zSmFP0a2cXw6juD+DpAFrFwmw1W+o8UfD3Qy1JcXu7ANoZc4X
++QkXrLzzDFYU80/Q5eGfKn3CDXJjvkF2k1fQQAo/OT4Gyk/mlHDX1qCsqBMgxiiC
+xoH66Fnqlp1Dc7FwizCLD/iA/NOXZm9Olf5AUAZImcR0GO8mww1K4FydBFtljQi/
+O4AZKos/zEAQOb4xMig7nCekpBb0IzrAiR0lsiVfokyGjf0bMRubOYMImdd8sZrT
+zteP19nZWvEvDPm72X3sK/8Gllwvq+u/UR17OBYHyxuliQ==
+=gfjr
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-pub.asc b/comm/mail/test/browser/openpgp/data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-pub.asc
new file mode 100644
index 0000000000..0924b55332
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-pub.asc
@@ -0,0 +1,13 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEVKScpxYJKwYBBAHaRw8BAQdAcluj90h0Z2/+tKbb8v3lGLaZ1/P75HJnJsoe
+XNW48TG0JUVkZGllIEV4cGlyZWQgPGVkZGllQG9wZW5wZ3AuZXhhbXBsZT6IlgQT
+FggAPhYhBEzDeY/d9NC7FHzcLhXpNX0sI5XABQJUpJynAhsDBQkDwmcABQsJCAcC
+BhUKCQgLAgQWAgMBAh4BAheAAAoJEBXpNX0sI5XA+mEBALOHJDxAY6NnPIFuwsb+
+3paF2V11GdWnQfDiuAJnAJZyAQDhOi700EYgv2hRTvY8BhGE2S9iVxwcPndXK2Ks
+UyflA7g4BFSknKcSCisGAQQBl1UBBQEBB0B8IMA2fUQt9krpM6Qt4gtAaPv2HE0/
+wwot19NzcMvaIgMBCAeIfgQYFggAJhYhBEzDeY/d9NC7FHzcLhXpNX0sI5XABQJU
+pJynAhsMBQkDwmcAAAoJEBXpNX0sI5XA6rEBALHFJbvzYwQ9FlxnoxJNRoiwnQpI
+cb8VSxvutKwmXG0CAQCTRed9yINA2DkYYfNXZntWNzxfx5QpqKaa18NsvC6TAQ==
+=CBRt
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-secret.asc b/comm/mail/test/browser/openpgp/data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-secret.asc
new file mode 100644
index 0000000000..eda1355c5f
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/eddie@openpgp.example-0x15e9357d2c2395c0-secret.asc
@@ -0,0 +1,15 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lFgEVKScpxYJKwYBBAHaRw8BAQdAcluj90h0Z2/+tKbb8v3lGLaZ1/P75HJnJsoe
+XNW48TEAAP9MIfv9mSj3K/xOQNSeSDpA66wvudvPjlY/7CIYKL73ehHOtCVFZGRp
+ZSBFeHBpcmVkIDxlZGRpZUBvcGVucGdwLmV4YW1wbGU+iJYEExYIAD4WIQRMw3mP
+3fTQuxR83C4V6TV9LCOVwAUCVKScpwIbAwUJA8JnAAULCQgHAgYVCgkICwIEFgID
+AQIeAQIXgAAKCRAV6TV9LCOVwPphAQCzhyQ8QGOjZzyBbsLG/t6WhdlddRnVp0Hw
+4rgCZwCWcgEA4Tou9NBGIL9oUU72PAYRhNkvYlccHD53VytirFMn5QOcXQRUpJyn
+EgorBgEEAZdVAQUBAQdAfCDANn1ELfZK6TOkLeILQGj79hxNP8MKLdfTc3DL2iID
+AQgHAAD/Uhh/K3jAQLD8LZ2IPmhXPkbTzmQ2vWOwR+QNjW8gXkAPB4h+BBgWCAAm
+FiEETMN5j9300LsUfNwuFek1fSwjlcAFAlSknKcCGwwFCQPCZwAACgkQFek1fSwj
+lcDqsQEAscUlu/NjBD0WXGejEk1GiLCdCkhxvxVLG+60rCZcbQIBAJNF533Ig0DY
+ORhh81dme1Y3PF/HlCmopprXw2y8LpMB
+=nm40
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/encryption-subkey-bad.pgp b/comm/mail/test/browser/openpgp/data/keys/encryption-subkey-bad.pgp
new file mode 100644
index 0000000000..5ea124082f
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/encryption-subkey-bad.pgp
Binary files differ
diff --git a/comm/mail/test/browser/openpgp/data/keys/heisenberg-signed-by-pinkman.asc b/comm/mail/test/browser/openpgp/data/keys/heisenberg-signed-by-pinkman.asc
new file mode 100644
index 0000000000..388539b907
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/heisenberg-signed-by-pinkman.asc
@@ -0,0 +1,37 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBGKhpjABCADkFK62K1bMOtffYUcXda9Z8vRN/D6mxcY7S2jyAqx1RfL8f43d
+n+xsvR5cp/gd20CW0d95hb0yaBzC4NZ6zgH727CbV+KEXZHwy9DW0a0rd6k94cRX
+o2j6ajHHnzYRc+VivXggvzp8HvbEMzT9sY2AoQn/SWaa4awXzUiHsaz1f2wQwxgb
+xKMXC4QiAHQkEspmx0g0f8INc77jGe1nTiJ2r8SLjCTvgGJJ6saTixN3yXcFxIF8
+THU3XtNMk+cfKAVQYU8wMVcB2uqxWTexCh5FcjCNGlCHXEuTNlGiz0+VkjRq/NZl
+BBjHSOMbKGJZmmnhZJ4IPdC6dvGMcUktXMb5ABEBAAG0I0hlaXNlbmJlcmcgPGhl
+aXNlbmJlcmdAZXhhbXBsZS5jb20+iQFOBBMBCgA4FiEEjj0y5lKiVPBb6p9mzz60
+r8rCk0AFAmKhpjACGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQzz60r8rC
+k0Bb4wf+OrAMolbFLirRukYnwPowIf7LK1cs7Df+ZFOGwSVq4SLKpjYuxcF33eAL
+1t1Jf+b+O+fC9vj1SsphHD3JqhlYRKCfyl1CYS79Lo57K9AL2FTk+EWjwwpTY1aa
+bi/nKAH4SpKu6nKtJbC069RWOh5eXbM0PFCh7hkTukuVXWKZHzyFndsrDTivxdLv
+aKiRQixkYmvBlXsh8rWN+YVnDuCfOnYSiJZMMr9rAcB1y/bsaGsyUxRIFrvJET7d
+DCY42plADri9/C1w/vROo8qYrf1vjul+R/3FPGF6DivQhdmxckAHp8JnGqtV8XYM
+ThndG4GhlTlRprrcToJ1/dP5z+EQqYkBMwQQAQoAHRYhBP83N7oYJtppW0oGGxz6
+EVncyTjtBQJioaZvAAoJEBz6EVncyTjtJVAH/RvRPBtSM3MlZGpLBSob9Fm+XxLY
+HnhVx2i4jGAU7haKsLNlr7QF3taX8erpz9Cn4qJ9CkXrlwvSZPGZPG59rdxV8p9X
+yc6H2uIdpr1TwEEfRN/e1Da4O+Nx6JhIZBIhVAksxZUuv3j2rFEj+yTAeZ2ViOHx
+1b/AfFdiKJ1Paran7FYqNyTDmX/gxVX8K8WeEzO/64kg2Va6v2I05trRVRaWBVwC
+sQw2HfZO7JMhcOUctAS8dFLwEOQPlKIJTHOnD7jlD82nWaKHPk6cZ/KlBIjDvNHI
+7Gr/8zfvxbztO+ZU/iMXdgOZD4oo9JqYfqes9ZI7p7Q3Wj7o/6jWuUz4AdC5AQ0E
+YqGmMAEIALnfKFskQ6JwBI6hhE5iMljK4+Axojlb45QkiXSCRs4hrulk8Xg9YHTy
+tbDQweIS9XJy+8y9/l3J63UyHLbBnbKHH/ZwcPKlE0Xj8EfBwkaC27CsaoDJzGPy
+DLipjJ/If7aOQQV/1ehoRXFg3lvEjRid+IkSpTOJUdfXuwJTrGKK6C17Wo+VQQcR
+9iyqD9ILHbF7rzxsnbLGNKjVicuXFc1/yeerIuJXIaLDJ2VeVVpsK9YhpGNeEyjy
+mJWcKbv3urBOSUPj5odMtKKsoSKwsAgMvbIHNb+h/IFGVS77NphTMCKJWYaeEfNs
+mJg1EkqkjGT+16S1TTAg4pbFYGu5AtUAEQEAAYkBNgQYAQoAIBYhBI49MuZSolTw
+W+qfZs8+tK/KwpNABQJioaYwAhsMAAoJEM8+tK/KwpNAtRcH/01kPzW3TneZAgT6
++uaFO0LxhSMU/YshXkh1DNXVgSIV09OBsCWGfK+KfwyStzXUEOO7lyzLbaW3DxD6
+X41CeyEmqqAHFAcy4NeSIdRYgOWbr9/NeAD1tVSakGeWXhEApTbr+uqKwMI7IsrF
+982N+4g5FCczUIAwdbHkKvAsa09HudrTqYdEG45rlbu4H44LfIqQynLpCBNfSMUj
+HPnJUiPjS3x7jxY0nAHnOAFcEsgQ8eIpEMnBXtfk3FYFNztGFsIGPptFEzdEKFYJ
+4MDe8V3VtwRdTpQMqS9u12QwwWUeRJmk1c36fMpomTz3KOCplITPYtECZ16YToni
+AOAHSyc=
+=M6Ba
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/invalid-pubkey-nosigs.pgp b/comm/mail/test/browser/openpgp/data/keys/invalid-pubkey-nosigs.pgp
new file mode 100644
index 0000000000..a844ae58f2
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/invalid-pubkey-nosigs.pgp
Binary files differ
diff --git a/comm/mail/test/browser/openpgp/data/keys/key-binary.gpg b/comm/mail/test/browser/openpgp/data/keys/key-binary.gpg
new file mode 100644
index 0000000000..3d99fc04c1
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/key-binary.gpg
Binary files differ
diff --git a/comm/mail/test/browser/openpgp/data/keys/key-with-utf8-comment.asc b/comm/mail/test/browser/openpgp/data/keys/key-with-utf8-comment.asc
new file mode 100644
index 0000000000..5006f29196
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/key-with-utf8-comment.asc
@@ -0,0 +1,15 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Comment: 💌 (unicode symbol: love letter)
+Comment: 😀 (unicode symbol: smiley)
+
+mDMEY4d9vRYJKwYBBAHaRw8BAQdAhoLFPAnuWEK0GKQqJfZLeIMJ8mzzo0Bxm47G
+04P6qHC0H3Rlc3QgPHRlc3QtdW5pY29kZUBleGFtcGxlLmNvbT6IkwQTFggAOxYh
+BHJRT0PQBg/FiOgCOIUsVebSr9fvBQJjh329AhsDBQsJCAcCAiICBhUKCQgLAgQW
+AgMBAh4HAheAAAoJEIUsVebSr9fvSqIA/1I5cpEa2UdGGKVXndz3HFoUq5TrRVZd
+1el8bq177HbaAQDkJlNvBxwcjW3yDVo4+nxoqm8nK1b8yPwQet2NXobcArg4BGOH
+fb0SCisGAQQBl1UBBQEBB0CqqMW7jKUygeB9+DmqMWBoWPZXiSLe4imAGj3t+h/c
+JgMBCAeIeAQYFggAIBYhBHJRT0PQBg/FiOgCOIUsVebSr9fvBQJjh329AhsMAAoJ
+EIUsVebSr9fvRbwA/ApVf9/S9YjFEcR74W/R5G+PVaL15ERHfiR0f7AYqDgiAPsG
+N+POP/0TWKb+uT/jz2QYhjxbdQsELGvWQePLhOb0Aw==
+=pPjp
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/kylie-0x1AABD9FAD1E411DD-secret-subkeys.asc b/comm/mail/test/browser/openpgp/data/keys/kylie-0x1AABD9FAD1E411DD-secret-subkeys.asc
new file mode 100644
index 0000000000..d830e8b4f0
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/kylie-0x1AABD9FAD1E411DD-secret-subkeys.asc
@@ -0,0 +1,23 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lDsEZTeZ2RYJKwYBBAHaRw8BAQdAqq4XimYIM2vl7huc6QqER7mrVmVLrqUvaRlj
+KDGZ6WX/AGUAR05VAbQ8S3lsaWUgKE9mZmxpbmUgcHJpbWFyeSBrZXksIHR3byBz
+dWJrZXlzKSA8a3lsaWVAZXhhbXBsZS5jb20+iJMEExYKADsWIQT0kImOl5Bh2zJ6
+cYcaq9n60eQR3QUCZTeZ2QIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAK
+CRAaq9n60eQR3Sg/AP0Q1v7QqEdUWmPtMLDmfQq8TM2P5SfxyXdX64YSwSkUJAD/
+bkw2GBTtOpFlZspQiOUqZH0rx64uY3Ol4N2c+HoQjweciwRlN5nZEgorBgEEAZdV
+AQUBAQdAjGGp9Wk2VnkXL6LxrO0pzpDy18pLLoTKbd+OT8+O4HcDAQgH/gcDAjtw
+AvX3bkqb9mr9Z90GFVmc7RTh4DZlrim+XfaUub/U0yBmVvBHO8i/qURr+Y8KIylQ
+emvDPcQ2R6rIyrDSNBMyRhAGvxCSyNlcJqTGqYWIeAQYFgoAIBYhBPSQiY6XkGHb
+Mnpxhxqr2frR5BHdBQJlN5nZAhsMAAoJEBqr2frR5BHdZuYBAMFwcpxybxqWW8lc
+CgisqDFr/6wXrVPVWtPb9F6LrgowAP4zoFdQe+tprNXul7pF/i8MCGB1mW6KPrw4
+xOgce/uaD5yGBGU3mhUWCSsGAQQB2kcPAQEHQBeDD103qyEq9S3nMuGyH7fz375d
+Tg58FRQ5/dgEvuQN/gcDAj1ky4HDCu3h9jxnNzgjSL9r6o/DP+HLIw10eXdVOwZ8
+ILOCq3PBye8cR7MEN+rh93/uggTNo5pzSop4YDcMIqSxKZP/FjCU8QMUEz+ygNSI
+7wQYFgoAIBYhBPSQiY6XkGHbMnpxhxqr2frR5BHdBQJlN5oVAhsCAIEJEBqr2frR
+5BHddiAEGRYKAB0WIQQPgSADIsBrXb3vr6o4nLzJ01hGJQUCZTeaFQAKCRA4nLzJ
+01hGJYEkAP4u6ExG6VqTTm2E40zLxhIo3A8aFwxzjBDtF7KGIiuc9QEAzUa+YLXc
+ztnu4IWhnhefw0u7W3mv6lcOnSv5VkohvA6XkQD/a5Vn3r9t1c89yqJ70MPsqOJk
+nCmjmwAiazsPzEGtZlUA/iBb3C9obolzxtjeEY4uL2DW34LR/eHEaprAd+MXnwAO
+=Yxqq
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/ofelia-public.asc b/comm/mail/test/browser/openpgp/data/keys/ofelia-public.asc
new file mode 100644
index 0000000000..3f14c948b3
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/ofelia-public.asc
@@ -0,0 +1,68 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQGNBGCIokUBDADHq7bIIV5CJrKuMrQVOAWcFRE21Y8tupL24fPZBTtU+Lq/zkQO
+SOKjjoHCBxj00bQXa4e0Dz1+cWVzsFahM2y52tpbR5J7HuRnoAbSuNDis0NtCnrT
+mWdyktm0zHwNeWdGVpp43RwdZEfb55jeqrymtCjD8RruQvUHbfwubKBed3/PjxHK
+vJTYJ2HzLS+iB2PE6q0sSup5D7RkICU+wpTooRwJXDtkreLWDCO/60b/4EsG7fPJ
+og2EFGqMI3R5PUpdosunT3d6y2/TmPtU8yLxShIHxHD+E4lOhNKfA346W7nE4QHI
+RMhhe3WvBoc8DUaxtlQKj0l363ugCniefXgVlXYWq8aHb3V+9WYXdVAMimZklGaZ
+BnMGUN266LKOdZTq4fPOVgp2mt/xsEdf+LAsdSiSaseOeps2LmVts7VK23yi0PfB
+aNB1IvnPWs/sLaYsGjZU/upcTbliEiRdffTPvPo3b+6Xbih6LCLSJnS5tkRf1EI3
+ZDFMijtAQuSYI90AEQEAAbQ1T2ZlbGlhIChvZmZsaW5lIHByaW1hcnkga2V5KSA8
+b2ZlbGlhQG9wZW5wZ3AuZXhhbXBsZT6JAc4EEwEKADgWIQRXddeAGvauKfso91CX
+3NpeVuu4IgUCYIiiRQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCX3Npe
+Vuu4IjCQDACmZffN3qoPvh98g4yClfLzPmntrHGTtUwpDUOw//HwOGSWTAO6Jix/
+R9PJ1AHv2XFeYRILkEMOP1SSDLLMOX2Svw82rtqLDENnGqdumQq/+hrvNc8jYLiV
+g7XTmU5u1JsE5sB3rbYJUmvMoW4TciZ1N73qV3oxrc7+6Q8HPi6pxC32HzMMaYyp
+HgPP7nzt2noW4hpN/SgJNT6c1w6JPyOtF+oZ29ZqCxn2SD1LQwp8PtvtRyuBOTjX
+m+v8qTWYhjlUVjmgquP0kwwcMXY+4PduJ09zk7suwZQEHcv22Br/YN1wJU+/1+I2
+eDU4XOubpMJvgIcLq2tWp3JWvOoC6CvnAY1h6yh8lZUbs3aOaqD1cyX3N6fK3oKS
+ddIANjsZqwbQNZ34gJOaKHEuHTDMZP3Jh2ZyekdqI+G48phV4IKbUYOr/JV3PdGc
+W97pnnA6ql+jy/p9PqejdUCOPnwHW1+y/hp8oIRQuoLWkhr0y29h+Q2M5mXXInQQ
+ao5bZB5FByu5AY0EYIiiYAEMANGGD8QQtpdJKq+cbTewpYLwCjotGFEFfwGOcseU
+lGNwjE6La3aQiUgs/8rbvHke0V26Y973/7jYwzwf6d6VxXJbURM0AKRIF+DTp3lU
+v2oqs7rE83S00iNmG+OZKFbCuS4Sg92kMeDL7JPb8lf1I9+mvXRI7u1kdZcR2Ed/
+OYjsntZwOzGPxvmxQef4v0u15m5U3zb4knkDDQZ1tQBq9KFWYyWkJqS6TfPHAHh8
+WMx1bOazMBg38FIKQGyxmHWYbt/TWmJOIJzL/JQXV3B80g64bvpRASX6j3HGyBQ3
+2HMSR/QQ5B6fhcuYn6/B+Z0F0Ue/1PorP+o1N5vx0+KBCQloz4jBbB3M938DXjVf
+B/2KXdzP86+nerhprVe1TB0/lzUql+kszpmnJ9KIKoTye+0MVYyYE1P4JBuTW4/h
+AlRi15WNtT2x+diA4+oIORYCddGsDLmYfSyQlaTPzp1Tr9M/ZWjvCgFHvo9gnk2f
+exCxCVh+WDdCtX23FRM91U9tBQARAQABiQNsBBgBCgAgFiEEV3XXgBr2rin7KPdQ
+l9zaXlbruCIFAmCIomACGwIBwAkQl9zaXlbruCLA9CAEGQEKAB0WIQSlh+CuHqWs
+bkDdhPkbyPV2TTSP4QUCYIiiYAAKCRAbyPV2TTSP4TwgDACff2r3rWsUS3+r0/+g
+jwBqOZX+3bpWaIRliP4Ncq5cCqxpRvx3weo/388v8h9qU8SVsFr/pqqYDAwXIzoD
+99NGLwtSoJNONR5tYguatDnWH32EDqn7CRUmjhgtBNC/p67phis4ljs1+S/JMcL4
+4WYm9Vul/IHf47ZGwF6IZkkqGXg2gdu/fVNiX/MbdUP3EwknfZTtAXo6PhR6j3YT
+BTv0hc+g/fuy0ufaagNbbl6ZntKEuthQqbUwLLaZRM/jfr1EdD7aMW/qKHegJ1M5
+uoRTeQK9B1B+XQI0dhTDvJDsge9cMN8xCAFJxPT/x/cN5WqHMTt1aluBH7cnxUnQ
+EfvB/W1bnJOmgc2x9aq2/OmZfS4l3fPWLrQGnbEIfHop3tP5xn+1O2fDugLByw+i
+dIDhO5zm0/5e41WvsVteUE8dDo3Hbt2MDBRUXWD611ItllwomAYinsX6juIUXMO0
+HqN11eL/pnSxi560R6T89h9m7JhQt+5POJPZlSGYWu1uWii9bAv/S2hgpIrbAM+9
+gS65nLYYE/ARo6vWuh3Hu1zCWLiEDiL/yJ163Jm4e3UhX6EIuxu+iEHPnlh3XTsW
+39dfdXoWnTKu+N+bbYJnuXWn0lAgF+TOElp0TPVHh0V5gGgt6w4gNKYdvf7uyXNI
+WEiNM5dDbL6in375gDE7usf23XOL6EiICFoH6W8r1AcrFbDUNWB4LmirURq9+da+
+nJV4kQRWvXdkYLovfAKfAJIMf8th5xxCHBOAjhGJkevHOpibKURaXTzTmWhAapdE
+mJDeolXj13oPBunGobmHfjyrdf38wGt/fdSUTpVwaWcD6MlatMMsVV++rje2QrmK
+PAIQBHHLaBltJ7bIIkhKNRuP3V0pmh1lKR79inlvvv3qEXcGBh9zj+cfBnf/LVP1
+CI/1o2qN7B2C7mVXS8JZ5K7EKUOGw3qPboeYBxcayRpapIlAyQUTxeIcylQJ7hd2
+pFFcPnCEqzqncr/77dNjeIUODZGRrOq/WVaiKhXDuZW+g1+NF14RuQGNBGCIom8B
+DACez7KeCQaCSsBY6o0w9C2lJtyGyIpCgaJ5pg1/Hk91dbbbUMtQdNgNAqA3TDqY
+8KdiofSgWmjN8vzfrxkT6wi6alEzWcTKFKrHZ+JO/RNf9wzViQqlKRJ+T+wE3xU2
+IKEbhR6yxWevHN7XBo2CKSn3VttJJtuOkr9sG+fe/WuJ8WLXsr7keDxhUsWubqns
+JsZ2NVlszMOuRCXN6WXRuJyvBm7cvlg8Qw2jNVgUi7YQwHqNWH8OKCrYQaizDRbW
+nTxv1lIRwC4czlYSQCtnV6M6mAo3wmFLVuaw21o8x1C/Fru+dpIEIjyZCYbr/wb/
+WDN6uNXTQNWy+xWJB4Itl1ay8tUeXTSXN62dkYiR34S6SOdAPiNBO33qZra14ggp
+trcRe8NormmWwj/Md/K9WqjF//L0mf3SQ8Fl/EFErfHg6K7RDdsdwPOgIqOLkeUw
+GxbMPb9rKjwIvImqQvnP8HceTh/BRKJTyGMmsqbCh4VBRsNtjfG7FfVuliQZsvT8
+fikAEQEAAYkBtgQYAQoAIBYhBFd114Aa9q4p+yj3UJfc2l5W67giBQJgiKJvAhsM
+AAoJEJfc2l5W67gin9gMAMbF890cE1CaNghv2nN5SscmZzAZSc8qTjqWLYMmxftu
+MNaoL8rjuowp++k/oRXGwW31MyqwKrAP3ezv88o7cNDFjriGHKN5ZZxNJRqsiF7V
+cyMbi/7OoHONAJXF4NcY6+2K/F8moxsG50BfX6B1gV1sezlI0Uli275karr7DNHB
+bpDMBMPBOESBJbF1vwxCgHrRCdgNHcQqe43s0goLMsB+8fM+Ge9XrkfJuVS48Bl4
+QUecddYzzgjZQqB8VcnBQTQfbZ+h+TgBIhayKP8EFtDshbiWSTQe8bSjGXHwWwF7
+BdJQsjEz8nq87oCS5WyCR38D1gfTOWhsnZqPFv5n4qZuyT3GSEGPVPa2AH+5ddQ3
+R58o60eovi0oNScrxk4zXjkGaipJhhajGXrzutAyLoesRLnCnhoLYYWAw5wC+Ioa
+2M3scYa3AG1ZejrE5KZ+tDB2iuB6Sp9Aho91nhoGO1ktcqfaUlDX2HmhdGkMdY6p
+pU+YaR+fAGsRti5YsTIyMQ==
+=NsF4
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/ofelia-secret-subkeys.asc b/comm/mail/test/browser/openpgp/data/keys/ofelia-secret-subkeys.asc
new file mode 100644
index 0000000000..f324bda81f
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/ofelia-secret-subkeys.asc
@@ -0,0 +1,108 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQGVBGCIokUBDADHq7bIIV5CJrKuMrQVOAWcFRE21Y8tupL24fPZBTtU+Lq/zkQO
+SOKjjoHCBxj00bQXa4e0Dz1+cWVzsFahM2y52tpbR5J7HuRnoAbSuNDis0NtCnrT
+mWdyktm0zHwNeWdGVpp43RwdZEfb55jeqrymtCjD8RruQvUHbfwubKBed3/PjxHK
+vJTYJ2HzLS+iB2PE6q0sSup5D7RkICU+wpTooRwJXDtkreLWDCO/60b/4EsG7fPJ
+og2EFGqMI3R5PUpdosunT3d6y2/TmPtU8yLxShIHxHD+E4lOhNKfA346W7nE4QHI
+RMhhe3WvBoc8DUaxtlQKj0l363ugCniefXgVlXYWq8aHb3V+9WYXdVAMimZklGaZ
+BnMGUN266LKOdZTq4fPOVgp2mt/xsEdf+LAsdSiSaseOeps2LmVts7VK23yi0PfB
+aNB1IvnPWs/sLaYsGjZU/upcTbliEiRdffTPvPo3b+6Xbih6LCLSJnS5tkRf1EI3
+ZDFMijtAQuSYI90AEQEAAf8AZQBHTlUBtDVPZmVsaWEgKG9mZmxpbmUgcHJpbWFy
+eSBrZXkpIDxvZmVsaWFAb3BlbnBncC5leGFtcGxlPokBzgQTAQoAOBYhBFd114Aa
+9q4p+yj3UJfc2l5W67giBQJgiKJFAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
+AAoJEJfc2l5W67giMJAMAKZl983eqg++H3yDjIKV8vM+ae2scZO1TCkNQ7D/8fA4
+ZJZMA7omLH9H08nUAe/ZcV5hEguQQw4/VJIMssw5fZK/Dzau2osMQ2cap26ZCr/6
+Gu81zyNguJWDtdOZTm7UmwTmwHettglSa8yhbhNyJnU3vepXejGtzv7pDwc+LqnE
+LfYfMwxpjKkeA8/ufO3aehbiGk39KAk1PpzXDok/I60X6hnb1moLGfZIPUtDCnw+
+2+1HK4E5ONeb6/ypNZiGOVRWOaCq4/STDBwxdj7g924nT3OTuy7BlAQdy/bYGv9g
+3XAlT7/X4jZ4NThc65ukwm+Ahwura1ancla86gLoK+cBjWHrKHyVlRuzdo5qoPVz
+Jfc3p8regpJ10gA2OxmrBtA1nfiAk5oocS4dMMxk/cmHZnJ6R2oj4bjymFXggptR
+g6v8lXc90Zxb3umecDqqX6PL+n0+p6N1QI4+fAdbX7L+GnyghFC6gtaSGvTLb2H5
+DYzmZdcidBBqjltkHkUHK50FWARgiKJgAQwA0YYPxBC2l0kqr5xtN7ClgvAKOi0Y
+UQV/AY5yx5SUY3CMTotrdpCJSCz/ytu8eR7RXbpj3vf/uNjDPB/p3pXFcltREzQA
+pEgX4NOneVS/aiqzusTzdLTSI2Yb45koVsK5LhKD3aQx4Mvsk9vyV/Uj36a9dEju
+7WR1lxHYR385iOye1nA7MY/G+bFB5/i/S7XmblTfNviSeQMNBnW1AGr0oVZjJaQm
+pLpN88cAeHxYzHVs5rMwGDfwUgpAbLGYdZhu39NaYk4gnMv8lBdXcHzSDrhu+lEB
+JfqPccbIFDfYcxJH9BDkHp+Fy5ifr8H5nQXRR7/U+is/6jU3m/HT4oEJCWjPiMFs
+Hcz3fwNeNV8H/Ypd3M/zr6d6uGmtV7VMHT+XNSqX6SzOmacn0ogqhPJ77QxVjJgT
+U/gkG5Nbj+ECVGLXlY21PbH52IDj6gg5FgJ10awMuZh9LJCVpM/OnVOv0z9laO8K
+AUe+j2CeTZ97ELEJWH5YN0K1fbcVEz3VT20FABEBAAEAC/sH9aQoWkuFcxsdsWnu
+FquC+OQlPPdShKHu3jQlZCiVn9NEse6ILN96Osi7h8jLf8yLrKavSWk74sgv7Ypw
+kUH/S9Fy/aO/RJnbnvlwFjRwr7nvhhyK5MpNhqCCmfE6sopfDLGuZtbE58T6AlNc
+3LbbKotsnSSufLAsPVpOWlqbhVmsuHYXNj4PLZkHsJ8bx4eUeMGYu1gHzteHf6CQ
+/n8tQzSLBocNSk08eI/froxKYB+d+AADZi9JU+hfTgkxAR8LiIca+mMHPmTjo3UE
+lrrXw2bzJsT3PQBVaulvGOjydZo7pBr0tvjy8WEFfNpsAl97Wii+2Xlv0r7z9gq1
+BVuwI7DSWpTMMaCVPzMjqkz7zfNbKr64HH15MLVtj14D3YZY98aDd9J4gP4chjnb
+1+/J0uKiRkGBR9IPbHpzMcriZIMT3+Dmnh6QHZ41PwIz2Z2MmYIISpyBHxL2Ulpz
+XdaZfGghdqKhHbE5WzYtKG8fuCBqAZQuDLivWo4GH3A6VxUGANpczIKzcwX2E7ns
+ZXpQtUC/hKYklRThhaCSJMzzGj24IgbGP3kcOwJQHxtMJxWZ6QstS3qk/E+7siTe
+WsbKZa59HHAss1Qw5VG63tJcmiEQm9NrVOFSgSx4qEkmOLT3lrk7FGTui2sM7HZ/
+Sq681OUeg+nEhkaS0wnln+wfMvykts6VYHY/UMansnblX9czhJToQP9/cLZwVGHm
+rHKDO9a9ilHA0OaHlQaHUYHMxOzWMrpxFyj18kQtFBQ8jDcqQwYA9aNAbbEpbbJI
+wBVIz0vOic71PWjWP/1JC7jaDIUkI3j+NiAdcz0hqjsJLfTjnLFvV7iJvLeObe7D
+zqY0RxCJfrL6fHxEpxXWuQKsinL0sDL2q8cR4cdTGxbmC204tXtKKLQICHEQdK51
+m67ULRVedg3I4SZQQwl1em+shhWR5ySP22cHO3d5gZD6CoGs+gC8tLixu5u52hS6
+3Ndvy2523cvTkDrK3g0BkJ5lILGbbI+Fv76tO1AmGbUMeiRQE0sXBgDb2AxkmLlc
+p/n7NM8dDpeN5qJ7CSA9lcVNKmGWfEkaXRzPYbh+bXWIawLqB4pdKSCmrYEgEdFk
+1aFerxKwKlZwL2plw3erhQ5auw+zGH5yumPvrQr0jFSYzuzfqzS3WtEWMcrc95Ex
+e6ij4NpgRJI+MVM5ePvpCO4MJFSK5ZRTICkPYFRRvQe/Cm22HyQzdbIToq6sRw21
+DcbFrDZnCKtaPdSx3iV72ZJsu/w9FiURRypoJR1YusNa4pOYEdlf7UXTpokDbAQY
+AQoAIBYhBFd114Aa9q4p+yj3UJfc2l5W67giBQJgiKJgAhsCAcAJEJfc2l5W67gi
+wPQgBBkBCgAdFiEEpYfgrh6lrG5A3YT5G8j1dk00j+EFAmCIomAACgkQG8j1dk00
+j+E8IAwAn39q961rFEt/q9P/oI8AajmV/t26VmiEZYj+DXKuXAqsaUb8d8HqP9/P
+L/IfalPElbBa/6aqmAwMFyM6A/fTRi8LUqCTTjUebWILmrQ51h99hA6p+wkVJo4Y
+LQTQv6eu6YYrOJY7NfkvyTHC+OFmJvVbpfyB3+O2RsBeiGZJKhl4NoHbv31TYl/z
+G3VD9xMJJ32U7QF6Oj4Ueo92EwU79IXPoP37stLn2moDW25emZ7ShLrYUKm1MCy2
+mUTP4369RHQ+2jFv6ih3oCdTObqEU3kCvQdQfl0CNHYUw7yQ7IHvXDDfMQgBScT0
+/8f3DeVqhzE7dWpbgR+3J8VJ0BH7wf1tW5yTpoHNsfWqtvzpmX0uJd3z1i60Bp2x
+CHx6Kd7T+cZ/tTtnw7oCwcsPonSA4Tuc5tP+XuNVr7FbXlBPHQ6Nx27djAwUVF1g
++tdSLZZcKJgGIp7F+o7iFFzDtB6jddXi/6Z0sYuetEek/PYfZuyYULfuTziT2ZUh
+mFrtbloovWwL/0toYKSK2wDPvYEuuZy2GBPwEaOr1rodx7tcwli4hA4i/8idetyZ
+uHt1IV+hCLsbvohBz55Yd107Ft/XX3V6Fp0yrvjfm22CZ7l1p9JQIBfkzhJadEz1
+R4dFeYBoLesOIDSmHb3+7slzSFhIjTOXQ2y+op9++YAxO7rH9t1zi+hIiAhaB+lv
+K9QHKxWw1DVgeC5oq1EavfnWvpyVeJEEVr13ZGC6L3wCnwCSDH/LYeccQhwTgI4R
+iZHrxzqYmylEWl0805loQGqXRJiQ3qJV49d6DwbpxqG5h348q3X9/MBrf33UlE6V
+cGlnA+jJWrTDLFVfvq43tkK5ijwCEARxy2gZbSe2yCJISjUbj91dKZodZSke/Yp5
+b7796hF3BgYfc4/nHwZ3/y1T9QiP9aNqjewdgu5lV0vCWeSuxClDhsN6j26HmAcX
+GskaWqSJQMkFE8XiHMpUCe4XdqRRXD5whKs6p3K/++3TY3iFDg2Rkazqv1lWoioV
+w7mVvoNfjRdeEZ0FWARgiKJvAQwAns+yngkGgkrAWOqNMPQtpSbchsiKQoGieaYN
+fx5PdXW221DLUHTYDQKgN0w6mPCnYqH0oFpozfL8368ZE+sIumpRM1nEyhSqx2fi
+Tv0TX/cM1YkKpSkSfk/sBN8VNiChG4UessVnrxze1waNgikp91bbSSbbjpK/bBvn
+3v1rifFi17K+5Hg8YVLFrm6p7CbGdjVZbMzDrkQlzell0bicrwZu3L5YPEMNozVY
+FIu2EMB6jVh/Digq2EGosw0W1p08b9ZSEcAuHM5WEkArZ1ejOpgKN8JhS1bmsNta
+PMdQvxa7vnaSBCI8mQmG6/8G/1gzerjV00DVsvsViQeCLZdWsvLVHl00lzetnZGI
+kd+EukjnQD4jQTt96ma2teIIKba3EXvDaK5plsI/zHfyvVqoxf/y9Jn90kPBZfxB
+RK3x4Oiu0Q3bHcDzoCKji5HlMBsWzD2/ayo8CLyJqkL5z/B3Hk4fwUSiU8hjJrKm
+woeFQUbDbY3xuxX1bpYkGbL0/H4pABEBAAEAC/wKMkQh1OsD0QhV/SM5DihjFuJo
+TfZgjEGyBUkPDRNlc3wcyyxumz3m4fEG8+A8QxFAKi1SYVOiy3PUacHWr0u1ak+R
+2DTkE50eZetYDnQgwHQkvqJ+FavAC+IXsvoB6mjloy+kIzwDuHsPO7a4sWtmG7/D
+C9ljZ0UejBEgVk2CAwtJVYrfkN+xkParuyOyS5AI9WZrL59tsCbsOEzHAQ8gRq22
+AwuXvOdif/GKiijTnQQRUKoBru8HSPnrmw7JEzm9AK3RrVQPMgKgzttjPDqU7k6e
+S7PHmeWJScRqt9Viq4L8zpHOn1KM+NHN4LgP123MFPSYCXys3Ph6B1RjiePTR0kl
+rFWf7T4y3SEPOBgC9QthJjDl4IhvP+dHJBeFOnz4qbaQNLSSzn86tBmnfljOhTu9
+bkV+aHnfN+Fz00RrZDr37s+Z5459vhL+drvrvTHDzRp3Y5Ywhn5uk5t7oK/csrRv
+fwPoOz7Pns4a9xTjLo7ykPEtq6rHwtW8y8BZxOEGAMcfYszVoZd4LGHh+8zGqTQA
+QRypRmBZ7bNt2MOOWUBIXo1w70hmc3VuuAWBfNI683EDJA49gA/EwRuhmGHnbuUz
+VRi1AYmc8fGk542c94+3ak3KQZP3Pvx9gpzjTeB3aqFtNCtfXQIlcPKF//IwKZUR
+duNXjuw35o17czPfTJiU/Uda3OXko2r4Mjg+oqJhKqFLh93yWvlLxJgHThFt3BrV
+GwJdxXOFOPp6vrdEYjvWtCIYx+VwWl6UvKkHe6DE8QYAzCyYVmIxG2zGk4Pwx96y
+IikF7Kw6J/RXFUn+OiGc8ydk2mmienG3sML905U5tiRYRFBW3BTra3lLSUFx07N5
+EH+EGdOfA2QuqwmUDT1hliFW2XFuC8kxY/NctKmSdkS3ZpHegP4GBjZCgux3W8hr
+ZU/+2TW44m2bnvqJHwuvDYvXnSapyhhuAEqwUrfZ1Y5kJQ18zajyGpvU/ej8TCK4
+3NXOikcxnH4OLeJpjmBlCC3IInkt36JSxgwcrMPsQOy5Bf98SjLxzBN7WSvDfIF8
+D49+pPqhTuetUrgro6nQtcDDgG5FaJPo7KSHBfWFuBingFe7EvrBMjfsJX+RbW7D
+xa/waujiHyfeQaLLdM9tRD5E29cvIHPpit0CJgZlNlrr2hlSNMX7Ymo4S+c0JzWr
+kZUxZBVTwOGUAhZPpSKesq9aISOaTMrpSMmRqoCYYYc5Su+0RP/ah0fZaCmg8kZB
+JUmXPgWT/wv/YjOV8Qq5I/LX8xqf1p+Pg9ej75P+PYfQB//jPYkBtgQYAQoAIBYh
+BFd114Aa9q4p+yj3UJfc2l5W67giBQJgiKJvAhsMAAoJEJfc2l5W67gin9gMAMbF
+890cE1CaNghv2nN5SscmZzAZSc8qTjqWLYMmxftuMNaoL8rjuowp++k/oRXGwW31
+MyqwKrAP3ezv88o7cNDFjriGHKN5ZZxNJRqsiF7VcyMbi/7OoHONAJXF4NcY6+2K
+/F8moxsG50BfX6B1gV1sezlI0Uli275karr7DNHBbpDMBMPBOESBJbF1vwxCgHrR
+CdgNHcQqe43s0goLMsB+8fM+Ge9XrkfJuVS48Bl4QUecddYzzgjZQqB8VcnBQTQf
+bZ+h+TgBIhayKP8EFtDshbiWSTQe8bSjGXHwWwF7BdJQsjEz8nq87oCS5WyCR38D
+1gfTOWhsnZqPFv5n4qZuyT3GSEGPVPa2AH+5ddQ3R58o60eovi0oNScrxk4zXjkG
+aipJhhajGXrzutAyLoesRLnCnhoLYYWAw5wC+Ioa2M3scYa3AG1ZejrE5KZ+tDB2
+iuB6Sp9Aho91nhoGO1ktcqfaUlDX2HmhdGkMdY6ppU+YaR+fAGsRti5YsTIyMQ==
+=dKRC
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/ofelia-secret.asc b/comm/mail/test/browser/openpgp/data/keys/ofelia-secret.asc
new file mode 100644
index 0000000000..f8a63699d8
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/ofelia-secret.asc
@@ -0,0 +1,129 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQVYBGCIokUBDADHq7bIIV5CJrKuMrQVOAWcFRE21Y8tupL24fPZBTtU+Lq/zkQO
+SOKjjoHCBxj00bQXa4e0Dz1+cWVzsFahM2y52tpbR5J7HuRnoAbSuNDis0NtCnrT
+mWdyktm0zHwNeWdGVpp43RwdZEfb55jeqrymtCjD8RruQvUHbfwubKBed3/PjxHK
+vJTYJ2HzLS+iB2PE6q0sSup5D7RkICU+wpTooRwJXDtkreLWDCO/60b/4EsG7fPJ
+og2EFGqMI3R5PUpdosunT3d6y2/TmPtU8yLxShIHxHD+E4lOhNKfA346W7nE4QHI
+RMhhe3WvBoc8DUaxtlQKj0l363ugCniefXgVlXYWq8aHb3V+9WYXdVAMimZklGaZ
+BnMGUN266LKOdZTq4fPOVgp2mt/xsEdf+LAsdSiSaseOeps2LmVts7VK23yi0PfB
+aNB1IvnPWs/sLaYsGjZU/upcTbliEiRdffTPvPo3b+6Xbih6LCLSJnS5tkRf1EI3
+ZDFMijtAQuSYI90AEQEAAQAL+wVfDiy2ERYQenALNyL2/dekDXF/LznYsgloLKoi
+5OS1SDjOsK/9r/Mca0sv67DyTzjuEJl8a3gSTttc3Ae8HWmmhIc+FqevPg+3k1dp
+11yx29d8F6/HiavgXXDqq0+le2y9+avUruPvhatZwJgE2cxWPl5/Bu1v6a2IfOc2
+zt2bs1l/DMh6aDqkXJMxHP3r2vg6I+x0G3ikPoMPBlF45I6ZfuqVi5d6wgZmDzQj
+fSZ2/y2xiwRakqiB8BfTAFgemOxf4dEHkO+/tXtGFOWVYIMs5NZweYzLeYbo6f9f
+OQbDYcVScGG5cO76ScqsR8hvHEnO6/LDj+t4TUBjYASvNkb7o89h4YusiyKfNb4g
+bl9AHoEDFqxLgORT63omYv0j4pI/8vrmWjKhkLAWMfOvu7QRb9rEK38ByJBTsqF2
+1Pr2a4Hkp23yafmFEr3UQlk1DVHFe1hbnYJ1/8ZMOH9UtKj+IWf8k+DH6bGIXcPl
+h9mDSSlLsZIWdLA0879u5ZJp4QYA4RCx+XHORMDynctQYGAJASoCLOnr/ChYuZbH
+XS60QnCA0GM9gFmSiTb48t0HQx868H0FS/h+BG8krw0X4S4MRgjQWoeuKmDQSA5O
+BOLvwfg3upuOwH4KIAbZHu2FjXOKnI+uNJ4Ebg5/KtVjn0fiKUnY9lFUpbxDL4wV
+P36MakgceNEg1/EKxtx87+rpudxziobv0F8lUo1AsZKXPgWxmLLmw9SOhIkK8Ye6
+j+Cl8ToXQzEh2yAUIUzwKZBMxO71BgDjHXioStyRkqjrhVR16hEACf0TJgddQBbT
+A7F6SOWPT3ul52AQebVGl7lv6v6iZuYDQzeOmLp1DQGayn8UQXygXoBp2nxUn0yd
+OTpwD1O0WwoNf37sZoylHlbJTjbNL/ezKilOmTm6wHYHDbTeNqEG7Je/hsBsjQkp
+l0ibMOJWIqBx/6W+p46C/yc2/zFYl/t6ZfQ1ffSnqcjyIewnSA4c7rgZb4rbmfl1
+LQD6f1i5N/nqIFB0qDGxLCpcctiwAEkGAMfE6X2kXqQzCRpDstURFhJ5hqQDDbQF
+5s/2aFGYvtCeEXbk1POlMO2W6VNaQQPyMzWxBh32XBvTskLAlOu0xQ9kZHnHknRs
+HwNxoxkqf8/SK0Mok7KceylzkNTF0b5Nt3acyGhFCCU3nu0liwqii0CCRtwfxk3v
+C3ncf65kY+b81by1MwYOfMPxicwBivJxmuzSAw/hkZ0fP6ZBLYwhbdBcUcCs3yQC
+uoiJ4eJ9LDJOeucWJ4otBiuG58nLJ5B0K+EptDVPZmVsaWEgKG9mZmxpbmUgcHJp
+bWFyeSBrZXkpIDxvZmVsaWFAb3BlbnBncC5leGFtcGxlPokBzgQTAQoAOBYhBFd1
+14Aa9q4p+yj3UJfc2l5W67giBQJgiKJFAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B
+AheAAAoJEJfc2l5W67giMJAMAKZl983eqg++H3yDjIKV8vM+ae2scZO1TCkNQ7D/
+8fA4ZJZMA7omLH9H08nUAe/ZcV5hEguQQw4/VJIMssw5fZK/Dzau2osMQ2cap26Z
+Cr/6Gu81zyNguJWDtdOZTm7UmwTmwHettglSa8yhbhNyJnU3vepXejGtzv7pDwc+
+LqnELfYfMwxpjKkeA8/ufO3aehbiGk39KAk1PpzXDok/I60X6hnb1moLGfZIPUtD
+Cnw+2+1HK4E5ONeb6/ypNZiGOVRWOaCq4/STDBwxdj7g924nT3OTuy7BlAQdy/bY
+Gv9g3XAlT7/X4jZ4NThc65ukwm+Ahwura1ancla86gLoK+cBjWHrKHyVlRuzdo5q
+oPVzJfc3p8regpJ10gA2OxmrBtA1nfiAk5oocS4dMMxk/cmHZnJ6R2oj4bjymFXg
+gptRg6v8lXc90Zxb3umecDqqX6PL+n0+p6N1QI4+fAdbX7L+GnyghFC6gtaSGvTL
+b2H5DYzmZdcidBBqjltkHkUHK50FWARgiKJgAQwA0YYPxBC2l0kqr5xtN7ClgvAK
+Oi0YUQV/AY5yx5SUY3CMTotrdpCJSCz/ytu8eR7RXbpj3vf/uNjDPB/p3pXFcltR
+EzQApEgX4NOneVS/aiqzusTzdLTSI2Yb45koVsK5LhKD3aQx4Mvsk9vyV/Uj36a9
+dEju7WR1lxHYR385iOye1nA7MY/G+bFB5/i/S7XmblTfNviSeQMNBnW1AGr0oVZj
+JaQmpLpN88cAeHxYzHVs5rMwGDfwUgpAbLGYdZhu39NaYk4gnMv8lBdXcHzSDrhu
++lEBJfqPccbIFDfYcxJH9BDkHp+Fy5ifr8H5nQXRR7/U+is/6jU3m/HT4oEJCWjP
+iMFsHcz3fwNeNV8H/Ypd3M/zr6d6uGmtV7VMHT+XNSqX6SzOmacn0ogqhPJ77QxV
+jJgTU/gkG5Nbj+ECVGLXlY21PbH52IDj6gg5FgJ10awMuZh9LJCVpM/OnVOv0z9l
+aO8KAUe+j2CeTZ97ELEJWH5YN0K1fbcVEz3VT20FABEBAAEAC/sH9aQoWkuFcxsd
+sWnuFquC+OQlPPdShKHu3jQlZCiVn9NEse6ILN96Osi7h8jLf8yLrKavSWk74sgv
+7YpwkUH/S9Fy/aO/RJnbnvlwFjRwr7nvhhyK5MpNhqCCmfE6sopfDLGuZtbE58T6
+AlNc3LbbKotsnSSufLAsPVpOWlqbhVmsuHYXNj4PLZkHsJ8bx4eUeMGYu1gHzteH
+f6CQ/n8tQzSLBocNSk08eI/froxKYB+d+AADZi9JU+hfTgkxAR8LiIca+mMHPmTj
+o3UElrrXw2bzJsT3PQBVaulvGOjydZo7pBr0tvjy8WEFfNpsAl97Wii+2Xlv0r7z
+9gq1BVuwI7DSWpTMMaCVPzMjqkz7zfNbKr64HH15MLVtj14D3YZY98aDd9J4gP4c
+hjnb1+/J0uKiRkGBR9IPbHpzMcriZIMT3+Dmnh6QHZ41PwIz2Z2MmYIISpyBHxL2
+UlpzXdaZfGghdqKhHbE5WzYtKG8fuCBqAZQuDLivWo4GH3A6VxUGANpczIKzcwX2
+E7nsZXpQtUC/hKYklRThhaCSJMzzGj24IgbGP3kcOwJQHxtMJxWZ6QstS3qk/E+7
+siTeWsbKZa59HHAss1Qw5VG63tJcmiEQm9NrVOFSgSx4qEkmOLT3lrk7FGTui2sM
+7HZ/Sq681OUeg+nEhkaS0wnln+wfMvykts6VYHY/UMansnblX9czhJToQP9/cLZw
+VGHmrHKDO9a9ilHA0OaHlQaHUYHMxOzWMrpxFyj18kQtFBQ8jDcqQwYA9aNAbbEp
+bbJIwBVIz0vOic71PWjWP/1JC7jaDIUkI3j+NiAdcz0hqjsJLfTjnLFvV7iJvLeO
+be7DzqY0RxCJfrL6fHxEpxXWuQKsinL0sDL2q8cR4cdTGxbmC204tXtKKLQICHEQ
+dK51m67ULRVedg3I4SZQQwl1em+shhWR5ySP22cHO3d5gZD6CoGs+gC8tLixu5u5
+2hS63Ndvy2523cvTkDrK3g0BkJ5lILGbbI+Fv76tO1AmGbUMeiRQE0sXBgDb2Axk
+mLlcp/n7NM8dDpeN5qJ7CSA9lcVNKmGWfEkaXRzPYbh+bXWIawLqB4pdKSCmrYEg
+EdFk1aFerxKwKlZwL2plw3erhQ5auw+zGH5yumPvrQr0jFSYzuzfqzS3WtEWMcrc
+95Exe6ij4NpgRJI+MVM5ePvpCO4MJFSK5ZRTICkPYFRRvQe/Cm22HyQzdbIToq6s
+Rw21DcbFrDZnCKtaPdSx3iV72ZJsu/w9FiURRypoJR1YusNa4pOYEdlf7UXTpokD
+bAQYAQoAIBYhBFd114Aa9q4p+yj3UJfc2l5W67giBQJgiKJgAhsCAcAJEJfc2l5W
+67giwPQgBBkBCgAdFiEEpYfgrh6lrG5A3YT5G8j1dk00j+EFAmCIomAACgkQG8j1
+dk00j+E8IAwAn39q961rFEt/q9P/oI8AajmV/t26VmiEZYj+DXKuXAqsaUb8d8Hq
+P9/PL/IfalPElbBa/6aqmAwMFyM6A/fTRi8LUqCTTjUebWILmrQ51h99hA6p+wkV
+Jo4YLQTQv6eu6YYrOJY7NfkvyTHC+OFmJvVbpfyB3+O2RsBeiGZJKhl4NoHbv31T
+Yl/zG3VD9xMJJ32U7QF6Oj4Ueo92EwU79IXPoP37stLn2moDW25emZ7ShLrYUKm1
+MCy2mUTP4369RHQ+2jFv6ih3oCdTObqEU3kCvQdQfl0CNHYUw7yQ7IHvXDDfMQgB
+ScT0/8f3DeVqhzE7dWpbgR+3J8VJ0BH7wf1tW5yTpoHNsfWqtvzpmX0uJd3z1i60
+Bp2xCHx6Kd7T+cZ/tTtnw7oCwcsPonSA4Tuc5tP+XuNVr7FbXlBPHQ6Nx27djAwU
+VF1g+tdSLZZcKJgGIp7F+o7iFFzDtB6jddXi/6Z0sYuetEek/PYfZuyYULfuTziT
+2ZUhmFrtbloovWwL/0toYKSK2wDPvYEuuZy2GBPwEaOr1rodx7tcwli4hA4i/8id
+etyZuHt1IV+hCLsbvohBz55Yd107Ft/XX3V6Fp0yrvjfm22CZ7l1p9JQIBfkzhJa
+dEz1R4dFeYBoLesOIDSmHb3+7slzSFhIjTOXQ2y+op9++YAxO7rH9t1zi+hIiAha
+B+lvK9QHKxWw1DVgeC5oq1EavfnWvpyVeJEEVr13ZGC6L3wCnwCSDH/LYeccQhwT
+gI4RiZHrxzqYmylEWl0805loQGqXRJiQ3qJV49d6DwbpxqG5h348q3X9/MBrf33U
+lE6VcGlnA+jJWrTDLFVfvq43tkK5ijwCEARxy2gZbSe2yCJISjUbj91dKZodZSke
+/Yp5b7796hF3BgYfc4/nHwZ3/y1T9QiP9aNqjewdgu5lV0vCWeSuxClDhsN6j26H
+mAcXGskaWqSJQMkFE8XiHMpUCe4XdqRRXD5whKs6p3K/++3TY3iFDg2Rkazqv1lW
+oioVw7mVvoNfjRdeEZ0FWARgiKJvAQwAns+yngkGgkrAWOqNMPQtpSbchsiKQoGi
+eaYNfx5PdXW221DLUHTYDQKgN0w6mPCnYqH0oFpozfL8368ZE+sIumpRM1nEyhSq
+x2fiTv0TX/cM1YkKpSkSfk/sBN8VNiChG4UessVnrxze1waNgikp91bbSSbbjpK/
+bBvn3v1rifFi17K+5Hg8YVLFrm6p7CbGdjVZbMzDrkQlzell0bicrwZu3L5YPEMN
+ozVYFIu2EMB6jVh/Digq2EGosw0W1p08b9ZSEcAuHM5WEkArZ1ejOpgKN8JhS1bm
+sNtaPMdQvxa7vnaSBCI8mQmG6/8G/1gzerjV00DVsvsViQeCLZdWsvLVHl00lzet
+nZGIkd+EukjnQD4jQTt96ma2teIIKba3EXvDaK5plsI/zHfyvVqoxf/y9Jn90kPB
+ZfxBRK3x4Oiu0Q3bHcDzoCKji5HlMBsWzD2/ayo8CLyJqkL5z/B3Hk4fwUSiU8hj
+JrKmwoeFQUbDbY3xuxX1bpYkGbL0/H4pABEBAAEAC/wKMkQh1OsD0QhV/SM5Dihj
+FuJoTfZgjEGyBUkPDRNlc3wcyyxumz3m4fEG8+A8QxFAKi1SYVOiy3PUacHWr0u1
+ak+R2DTkE50eZetYDnQgwHQkvqJ+FavAC+IXsvoB6mjloy+kIzwDuHsPO7a4sWtm
+G7/DC9ljZ0UejBEgVk2CAwtJVYrfkN+xkParuyOyS5AI9WZrL59tsCbsOEzHAQ8g
+Rq22AwuXvOdif/GKiijTnQQRUKoBru8HSPnrmw7JEzm9AK3RrVQPMgKgzttjPDqU
+7k6eS7PHmeWJScRqt9Viq4L8zpHOn1KM+NHN4LgP123MFPSYCXys3Ph6B1RjiePT
+R0klrFWf7T4y3SEPOBgC9QthJjDl4IhvP+dHJBeFOnz4qbaQNLSSzn86tBmnfljO
+hTu9bkV+aHnfN+Fz00RrZDr37s+Z5459vhL+drvrvTHDzRp3Y5Ywhn5uk5t7oK/c
+srRvfwPoOz7Pns4a9xTjLo7ykPEtq6rHwtW8y8BZxOEGAMcfYszVoZd4LGHh+8zG
+qTQAQRypRmBZ7bNt2MOOWUBIXo1w70hmc3VuuAWBfNI683EDJA49gA/EwRuhmGHn
+buUzVRi1AYmc8fGk542c94+3ak3KQZP3Pvx9gpzjTeB3aqFtNCtfXQIlcPKF//Iw
+KZURduNXjuw35o17czPfTJiU/Uda3OXko2r4Mjg+oqJhKqFLh93yWvlLxJgHThFt
+3BrVGwJdxXOFOPp6vrdEYjvWtCIYx+VwWl6UvKkHe6DE8QYAzCyYVmIxG2zGk4Pw
+x96yIikF7Kw6J/RXFUn+OiGc8ydk2mmienG3sML905U5tiRYRFBW3BTra3lLSUFx
+07N5EH+EGdOfA2QuqwmUDT1hliFW2XFuC8kxY/NctKmSdkS3ZpHegP4GBjZCgux3
+W8hrZU/+2TW44m2bnvqJHwuvDYvXnSapyhhuAEqwUrfZ1Y5kJQ18zajyGpvU/ej8
+TCK43NXOikcxnH4OLeJpjmBlCC3IInkt36JSxgwcrMPsQOy5Bf98SjLxzBN7WSvD
+fIF8D49+pPqhTuetUrgro6nQtcDDgG5FaJPo7KSHBfWFuBingFe7EvrBMjfsJX+R
+bW7Dxa/waujiHyfeQaLLdM9tRD5E29cvIHPpit0CJgZlNlrr2hlSNMX7Ymo4S+c0
+JzWrkZUxZBVTwOGUAhZPpSKesq9aISOaTMrpSMmRqoCYYYc5Su+0RP/ah0fZaCmg
+8kZBJUmXPgWT/wv/YjOV8Qq5I/LX8xqf1p+Pg9ej75P+PYfQB//jPYkBtgQYAQoA
+IBYhBFd114Aa9q4p+yj3UJfc2l5W67giBQJgiKJvAhsMAAoJEJfc2l5W67gin9gM
+AMbF890cE1CaNghv2nN5SscmZzAZSc8qTjqWLYMmxftuMNaoL8rjuowp++k/oRXG
+wW31MyqwKrAP3ezv88o7cNDFjriGHKN5ZZxNJRqsiF7VcyMbi/7OoHONAJXF4NcY
+6+2K/F8moxsG50BfX6B1gV1sezlI0Uli275karr7DNHBbpDMBMPBOESBJbF1vwxC
+gHrRCdgNHcQqe43s0goLMsB+8fM+Ge9XrkfJuVS48Bl4QUecddYzzgjZQqB8VcnB
+QTQfbZ+h+TgBIhayKP8EFtDshbiWSTQe8bSjGXHwWwF7BdJQsjEz8nq87oCS5WyC
+R38D1gfTOWhsnZqPFv5n4qZuyT3GSEGPVPa2AH+5ddQ3R58o60eovi0oNScrxk4z
+XjkGaipJhhajGXrzutAyLoesRLnCnhoLYYWAw5wC+Ioa2M3scYa3AG1ZejrE5KZ+
+tDB2iuB6Sp9Aho91nhoGO1ktcqfaUlDX2HmhdGkMdY6ppU+YaR+fAGsRti5YsTIy
+MQ==
+=lHHo
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/secret-for-preferred-sign-subkey-is-missing--a-without-second-sub--sec.asc b/comm/mail/test/browser/openpgp/data/keys/secret-for-preferred-sign-subkey-is-missing--a-without-second-sub--sec.asc
new file mode 100644
index 0000000000..bcf9db3064
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/secret-for-preferred-sign-subkey-is-missing--a-without-second-sub--sec.asc
@@ -0,0 +1,129 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQVYBFLtfH4BDAC9ad3c2rD5jOZ0ynonf90JzXHvPeoksmj7fMT2KX3FTg+lzdKG
+40Pxk3/53/otqvYOvw8A6Pb18fOLlTvkTMQ2CFjRqd4oydFKHZjM+gWBoyFWMq2X
+PRIrYw6n3LJb6/wfula4rgnCVE4gOSkTBtto0yJCZtHQMh2blBtq2W/jNJs+OEpf
++LLDoOhO7B31HdWiVHuQTMG7sZwrH6MwuZKU0tYuwoA/Eeznuy9X42IKc7WEA3fj
+0A7Eme8Bw8lnZ1kiLe6jgAA1DxKBbu2dB3ParC+d97FZ6pWwnd0JiWZ7ws5F+KBF
+r4RSnJ552xUjrmiFehbAIZ1I9EY9m7eQ62lhOZAa/WfE7WA7hyufgpf0e8CR01Tj
+60ckFrfoTXnumWOASDJLUaGaGo9J2yjbBjcut8Nn2OfXysofzVevkrCi/zLFHWPa
+mjoa+M2R7vBl5alk6XoLSN0SBzDLeXpMdVfNBqLrlC4QbqI2oakkqEpX2VEV3f9Z
+ozlizzNMYSWp50UAEQEAAQAL/RwJ/29RqlAOxRC15nZRbcGlOX/+bNpI1NShqDB+
+fOFHyHY1bTxNiUHKIXA2cTzwaWNOciSi1+gZjIF1snt9x6/t9WP4Huxvz70Ge5eg
+TU9e/DDb6KmSP03P6Jv9xiNoYBa8SVkmXkh3nWcUvxlTcwhl9NTajqWgvZRJzPM4
+w+Dg6ThBMfVaBfCCsdD5EAg4heb1VaNLUYR86s7RbKFqXyILwewDG+P6PdUh9wSu
+ItXEQjMMJmPBaWY+GUzsFDTr7dsvFCRD2rJhaTlFaJhfN2F5dSrBwGQvNlDueity
+DbGBvYU6KzOuHwo37/dpRQ7NdQk9OGN0foLE7t5MxiCVpnzdtTXO0ypbPU9xSpkP
+Zo7c2ufPGx2HFVUW/9GKCruxR/aLfXCzkYC1ZbO26tjjp4YA0lQMOtCormjMFiw4
+OR3rISCvAuQ106nvjyfBs6UmtYJExaFdzERZqw8+Z/cT5lNpptHxtRQIN1eLktMv
+d8tWiDAHCurRqbthAJ/FQAs5gwYA1L32mx0YpfRA6S9DNAElR+s+ffkcr3I7dj5A
+RsAlsT0OXqFFfA007kB1+13Aww2UFnDI2gVkEWsFa75YUY9iUKOjllFyT6HFJiyq
+NzTv1V96OJOzrgfIzBdttZhb1YdlG7qnHQt7u8jeBCjVaB+7wzRCk0dknG/i9Qqz
+y4huWbu1KOlRE4FsrqguYR6e/O2KVBdvAkqlp9KcIWUoXyA7p3bYnQgqV6qGUHgM
+YZh010rMXpmtqKSIyjr4PekuPhI7BgDj7ZDLjLeAOCH3fdNKYV02oBlEpio4P5RR
+EoShoyiocczBXUfRolgJyO+8+4eKjs+xJNYiQDeZr8MrMkodXWXZT75uiBKBmDEN
+jaTMFp9C8nubmZ0ai7f4R8d49+XHy77h2yMTiwcNs2iLfSPWXl0VS8pFS4XfobMG
+ixhOG6p5Y2Rjc/t4uEDV+n11em3BGD5+d4/8WvBw+b0UVIrHnWbKCWDmE9FvB/fc
+HG9lDExvwJn++COsyK+lG2NlZJby1H8F/21hYNQGeFIBNSeDrPxRC4tgHHOA1LH0
+9qgEek6Zq9Ag3zblY4r3c5AnV4dMu7lnzseFUnBpb73SxsRD7e+6rg59wpmDeMs4
+ajpM9YLmVdPgYmn6OjpxdfmKgYpMZTjisGGFm7XbKKQ0GEFuo3oQggZYMuQ+d3eK
+cC+89gMU0eyP0gRg75feJpxlM158B6uVYB8pfap2d2yfeadJPcBSSdoVuwl2zQTK
+ECP46uW++mhN2gBVU4iyMphyrclOPbe8ItgBtDdzZWNyZXQtZm9yLXByZWZlcnJl
+ZC1zaWduLXN1YmtleS1pcy1taXNzaW5nQGV4YW1wbGUuY29tiQHOBBMBCgA4FiEE
+cagS43SiqCdNHP5mW1w0QlGl2n4FAlLtfH4CGwMFCwkIBwIGFQoJCAsCBBYCAwEC
+HgECF4AACgkQW1w0QlGl2n7aywwAgWr9bhgNwzesG7pJo2G0v5E/XlznEInkSxCl
+xTuRLg6uzQnLnFB4dCkN4TNhfrVkPl+PZS7BM9D84VwIjO7KbSnDILXt/eOi1uYk
+EdDLKrTxc7OAQMmnktvw3t8xsul+HvjY5EEYMz73+TWS5DHPuOX8B3gtzOnTDMfn
+PrslkmakypoQe+CAsjh6JZoHt19RIlvVg8xr8Gx0SeXlp0ZO+SA52Wb/Tp1sTpXV
+H8QBf5iOj++9wxaf6kJvRvdZqtxgqBSTaq3/w1z4Achav5/fofiQgQaJ2A02r4Uj
+RleUfMjF0RiFbu7XS7XXBvQ8Qnt00J0I91v0WnaagheDEyRezOoKYXh7ArU9X2b6
+uxumXDB1eSoR8HUMDxsEtmBEVxoXH8DhqDkiYXzrfm9GKdrIAzz/Miwxqb9G2XGq
+oX2VRBkpnGoaNG6K6a2v3kvfSZFKXCjfiRDVNeK6XL7f/VOqcO/gX+6UJHlqytNf
+ONloSdzOzWmgP9MH0vZxbeqEppVOnQVYBFLtfH4BDAC2k4vdz3B1pZfwC0jzWeZC
+uFLxZEBHKBJlt6q19PGovRZz3IXZE/pHNAsfSYZn82eJa9OJ9+BIUSjkxzA4Drnf
+Vg5mLk+bmy/LcH2IBVxZTBSiSjWkZijBqLjPDL+DKOp04LUYEoRFdmvC8pzDYdy3
+Fei32TBlie/VLIOHJY0YHjziYscIu6OhTnRsx2AcAeCxPr8BWxWWpLzoK1Bg3Ka3
+TnI9AqzqEFs0QChVuti7gzz2ItGhE6GUP6LyHVQJYRZgCyGzCxw2ObM3lsRBIpLU
+i0S0bcwfnaI8ZEJtRJp0ogdJvgzdnPa4AS93otmAsy0EeypKtW32yDH+bz9ddk4S
+if7Mat20m5dNNxFGecCGKUnk8JooiFg/5NmBOkCOuKV+foNSkbf+Ue++Yg3KVW5J
+ewpTJzgBENHKJKDYhUCnTJ4R38Yb/8Ub7caXibWJ4t/cQ06mymnm61rthQ57adz3
+ChC0+r+q1zj/eKI+rPyMWcRRUFWpZBBVds1Woje23jEAEQEAAQAL+wSYboJ/73gl
+ma89AHpAFLawlCVfaSsFVh+1bZSAgKHnNwDF7+BGs4Ot6psALzPv/WbZBgE8sgpt
+8Qgfn54EmF73x/tgTmiHsZ52s5EsNd6pRqJuWN7tQ5i6xAKRowMkkTCtjH9ZbXcA
+X95ffAzCqoMY+ANjIgfHPtg0M3AQFNbi4XRPCUyV6PylGHL5unh//1lkv3rjsZWZ
+6keTegWjHibiYIeauPKYBGyqcf1061I+b5cQKau6zlQIfUPn8zLcfMBd0d09QOAM
+JU5nPEMtWZKbow67eaCN66k23vI9bNbKMV+1et85gYa6dlgPYqcVTCNrNrcrRnlB
+eXbOTWVqVPkuDZWpYrC9dYVxF7CQvSOHOCVwZNojirMCJTYhCwsvL7GOp7UPIfiP
+2OOWclBBCpNsYDRxqTqp8KarlZPxl/bZkkcYDX6zWLN84ArAfUsGJsYHUiOQInUi
+UKG7R+GLsmmzLux1yBpkaQI8vJU9TBUZUrbu5cE227UzwkLlg5Ul4QYA0/iGHn6x
+fQh3u/fhdHkr1Noc7WbbPQopkdfAtOTZF16b6PG/JGRjgp01/bjCm7pc27JwjOuW
+e09SgNJ1zXfFvRa0XgOC7ZX22CAxT0nT/OUYtMa4cg2HndvDikOTOjDwKGjMzvuQ
+Yd5O4/NxkYVwdEEBy/MDsT2hUMgHXa22JGRGveTWZS2Pk1eWsoJPTcxDttE8a/Na
+jtqzNpGh6YZe0FyvcQmi7UIIeTm/1Rv4qc15DgaLms51fEzUtLJEIT5hBgDcf/uW
+PMQRVCxkexQgINUzqx2w2VEUnkgyMSaiTuKhJT90VhKy+9Yb1YGRKGA/SsdWhV7Y
+gxmoT73vbKB68KGIB4RS157jNH5DMlfMXXhuF0QRZm32jU7a4ShP/Do9KsHXCNva
+ZKc/DczrLfpnBjMWwS0w93pNQeZI2M1tHp07BMyW2JiNtcGXbz0iWq6tsq2J6JLY
+KbnW83KGXCvfLBS0N6qhP6i3fUktWZ17P3g8xLdQlCBii6VVdYRUkeD+kdEGAI8w
+K86sWfq+RCKwcNtfEXQZZ8ATOzYDDW0WPodU8suUqXuxEZUw9sG3EquIUSrmCGd9
+fWvAgxfHcE/RPjNn1xT+8soO+CeT84jhCpGcYAiWcOkFuOjYMsBQNSQmuz0GPnJf
+OOF8EWkJNXsp2JVZgCkMhBBnFWXM6CVydqaFQ6jspGt/LKsVBTtAxUisrjFB/f1k
+DhRd0zknWVZqrK3n0f3WiKgZpaEt8eclCypEsUywPvjah+HEO6LbziLftEBVkNXX
+iQG2BBgBCgAgFiEEcagS43SiqCdNHP5mW1w0QlGl2n4FAlLtfH4CGwwACgkQW1w0
+QlGl2n5MeQv9G8ZjWnszJAZa7iF0KnJrRInsrpiduzG2iONb45z1G3arXKkD2sKt
+2KL+AgMtLx+/DXHKsoJBNgjXmkM2EYiMUuKTpzsqE9TPNtnu5maZFc+Q0tMK9Ycf
+Ot/zLCja0Owxf0975Zg0Cck9JZSDGUqYTtYWaIQ+gID3bvtb9/5yEQ+5KmtdLYUH
+/yCeXraZ9y8rodHW91cVLCY0v3r3pgaV5zCB5iw1SwVnAWMqcA1s4oHffwOJTVQb
+9OoC+Yq2z4Nk86+wubEqnC+5x76Xqep7jIARRXWeuVufsUcci6YPNWmnHpd8/BPu
++nbvOVVD/X9fhOdxbL0DNymHmbh5zAsNz2l1rm58u8ACxx8UuugukgkKRmIcpxZE
+WRLGn7czwjUfluttSDCDDLMC9cP/R9sJyM+QTD+hK2sNCvx4nrHAf8jiDtk1Y+9h
+NXIvVXzuRp2W9nPJJopuAAGBvPizZF3Ej3nITJddb423z8+Jb1kAUKzH2wXSzgSh
+iPbtdboNhtsinQVYBFT064MBDADPZIdxXmDJJbnSfzgOI8c43YSteEFtJjSmTUo4
+fAMz/4RNk24FmFnnDNTqboE5qFVK7Mf6Zxbmgjo0cQQzPFPJXk9YyuN9Ge5lLeaQ
+0fXyfpIYTPbcunqBrbVjMcnI/v+x6O9ma+5c3AzX1CkRm0YrSu9t55Fm/y3bP/2u
+L1PVSmAr+AZMhMZf3xP7TZQXwqx1aROdUARAk6zR44JyiopOApCA5vwZsMgg/2rV
+t+B9VH430wqkQV53IGINXCum5oMpeBxm+LrzpYnZiUrMLv4uZkmmj/5Mx3+aDfIS
+u7+cmvbNzHoRsIBwphUtGD+SNdFr95wXSjxSfA6NNTJWGD1Gi9l2bO2grVq1j7sz
+h2QconAPrXv4hB72RxPAZP6gV0jfApb22ihEKi5FuOYY0i4halWBGWOXpseQue26
+fM+XsoS83TwMyk7lAA5a4hVQZDUr/JJjdIJkCtqoxixF+AU7W7w/1vnVgAGai8I1
+QI41KiGkm9F9nU/dCtQmnjuLu3kAEQEAAQAL/RSHN9zh4aSnZlBOpWbI5dRcIODm
+0VsTeAyqA9m5dLu15AultzM4lFWJcJ3P2Fyzq9WhwF2pzJt+cnJ0aV0E8Koy+pmo
+Y4IjifRb6cGV9slM+/sJyzmn/65MWnL6H6YUj4y1qNSzhEGOynqmlnYWr4hjf3Wa
+gUr3oTtdhyexqZOoLALOJxl13wjoVNsAH9OGQnnQr89Xd0RJGccgxO2/htcX6+PG
+eVe1pumVPqbu73qYXXH7IseFbOtPukTmRa/cixtv6Dda6FX63k2pfA+xbGTEijx1
+NXsU5Ol7wW1ZrhmJxIPRtTJrRAaPHDJzzx6p6WTEz0d2efe30UCS6aYuWy3yTe56
+TDyMlspHcsyw2Elr+1wthkjrZ1zXgGAP7kQtBmpEwyeSP7Fu49Fw8fSm0D027nbH
+SaqN2R/nkuqclFvUfg2WcskBc/Q51/qr2MTNlYWK+/uP7XKhywrzSlzLHnaxrlL3
+ESY1BZkZ9t9Va5sXzWBR2J8ogCMMv1ta9OSMMQYA3muIQo79ZZAnqLUECrHP0cst
+3PgfCGxED1aAZ68rlO93+pTA0kHlHURXGLVCLM5jP2UpTfN3oymJbjmr9WvZqcQY
+mRvJ9R2SuCH8tb6QZt0pT2nyhHuEwT/TNbtE04wMH2unHhmuavp20MqcFmXudI3b
+YEMa9zd51JPMtjB/UpkfXyXeIjjXnkZvbIuu+Ah93Z9xiKjkczEljX+ynFOLGOf5
+KTqDDDXepzO7YMZhIfhYSVLLX4/d5o6q0YLVllR9BgDutDGrnDn87bYl8hUQb7h6
+AkQeimrgqBuZ2B+Ubqq2f6Y52oKV9RS49KKrc+/DNSbME1+2/39W+NIPOuUB69UO
+48b9bmSiGOdUR02y9LYaE4qLCDR4gVmERsekf+GnaRFx/00gFgXCWnw1e5emcUPR
+W7hfrDzLO4At2vU+kPEbbnvlh64gs+tPz0c5AmdsqwZi000IQsvkzfXMI0LsZVNF
+NsAulu9B9lz1BMJ3pb8Hxf47oHAdxX3R0/OocRBXn60F+wcfplWrStRARMGK+/CM
+voYZKEWRZ6pk44a/vq1L9/VALUiGRB4XvXdiQK+rOH5vVJRhH9LFTgoM00X+sGZf
+r8WBbFl+hBHe5z+SUi916+qLq4+qhz/VwLTRZCZrBgEqxYsu0L4h7jKRL3ifZCV+
+nBf+4ObHXw8gk0+PT+ZuNQ28gslupvayWWcloMxxIT7KPVMvk+D4f4bYzhI0DCGZ
+Jbf4F7KOPMlN/ZYiz8jrFeIWeZjA1y0Sy0e6AKaKj4RJA/JriQNsBBgBCgAgFiEE
+cagS43SiqCdNHP5mW1w0QlGl2n4FAlT064MCGwIBwAkQW1w0QlGl2n7A9CAEGQEK
+AB0WIQRhIYHmBsx+W+KdrGhiXUgZ8C7nJwUCVPTrgwAKCRBiXUgZ8C7nJ0OrC/9S
++9c+9dlCz7ls/Ez8+3fnttL72Ui2VPEgB+KYj71K6Jx/29JFHec2Bl35Si/6NSWx
+25rrEbbYtcXQPqu0W7PqAfzytx+GxCdMDUHqMdcxx8mZn4WCYVYwxOhsNMQIZHmz
+0noJnHholQ9XsGVWkV9TPyW38zGVC0yHAakrJ5ZFuJN4f7UNydYgwyVtPbVfOryd
+7dDPaBNJOPjpgQ2Xuj1p4Ygz0WQZ+FYl8EcV4jzYi085lhuDCjX9gbF5IkIkjLZm
+RKpAX3tR8L+d5x8gTuVTZoLrpSDZR0WsSPFhgKKiY/hVo0oPKXoSz1DOygwk1PMS
+cEiJ5jbGYvQ+yBJbJO02O4fXTeiKYHONBIhu7fl+49JMXqOscHoKsP7PlCZ87YKq
+u6S1h/nHY+vVpbnhV2XDINWpn7d1qsBfewy/plXf5BPB4334ruqclz12DqreHXtO
+IzFr6LSTm4pIWia1Nxu03qXwCl9dhhBQCWZ00bnYzL19sWnN6HLdfvTdYxNPzn5T
+Ugv8C0Boxbf8lhkiy0+P8Z9JKP0sSRhjKlsoi7hk2artsfcPGOcid7WNP3Ruc+rh
+IQADbgjSHnUpJ892XPkJ58yUEPCr0B1+GkV93Lv41Q6UHqDHJOWeWqhOoD/qRT+6
+ZRGGA3BQ2xmxNKphsUx7uBcndQsig0JNCMawH/OklvlBqDMthlOFMeSfhkVvLymy
+DtikFrdkYQmo/IrFHe0leeiQ671ZF6MRxtnx6NmtJo29LOquReTU2LZyUJ6q6XEk
+daH1Av2c1u0BmIJoHSUgHaxM1wJ7oSE9GfGS9dDyWADAKv1obVb3Zpi5Dzr/oBN+
+ipstAEUYvBlLvaitt3Wvx9/8QUTpqxCQJk2owXE8omY2xH/yBJCiF/ht8+cUKw/a
+lbiFAUz9S2ncUC2j1j/myF17obcnrDWKwMbW0BPIkZmIqKyKp4GTL+rEkGiZzqcf
+s5zFIgnbiE4E7r3nKR3uc8RbeJ631che4mZjFi43lt7KVOAC+OD2j+tCnPz04f8H
+iUE5
+=f5S8
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/secret-for-preferred-sign-subkey-is-missing--b-with-second-sub--pub.asc b/comm/mail/test/browser/openpgp/data/keys/secret-for-preferred-sign-subkey-is-missing--b-with-second-sub--pub.asc
new file mode 100644
index 0000000000..a4eec6f4c1
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/secret-for-preferred-sign-subkey-is-missing--b-with-second-sub--pub.asc
@@ -0,0 +1,95 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQGNBFLtfH4BDAC9ad3c2rD5jOZ0ynonf90JzXHvPeoksmj7fMT2KX3FTg+lzdKG
+40Pxk3/53/otqvYOvw8A6Pb18fOLlTvkTMQ2CFjRqd4oydFKHZjM+gWBoyFWMq2X
+PRIrYw6n3LJb6/wfula4rgnCVE4gOSkTBtto0yJCZtHQMh2blBtq2W/jNJs+OEpf
++LLDoOhO7B31HdWiVHuQTMG7sZwrH6MwuZKU0tYuwoA/Eeznuy9X42IKc7WEA3fj
+0A7Eme8Bw8lnZ1kiLe6jgAA1DxKBbu2dB3ParC+d97FZ6pWwnd0JiWZ7ws5F+KBF
+r4RSnJ552xUjrmiFehbAIZ1I9EY9m7eQ62lhOZAa/WfE7WA7hyufgpf0e8CR01Tj
+60ckFrfoTXnumWOASDJLUaGaGo9J2yjbBjcut8Nn2OfXysofzVevkrCi/zLFHWPa
+mjoa+M2R7vBl5alk6XoLSN0SBzDLeXpMdVfNBqLrlC4QbqI2oakkqEpX2VEV3f9Z
+ozlizzNMYSWp50UAEQEAAbQ3c2VjcmV0LWZvci1wcmVmZXJyZWQtc2lnbi1zdWJr
+ZXktaXMtbWlzc2luZ0BleGFtcGxlLmNvbYkBzgQTAQoAOBYhBHGoEuN0oqgnTRz+
+ZltcNEJRpdp+BQJS7Xx+AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEFtc
+NEJRpdp+2ssMAIFq/W4YDcM3rBu6SaNhtL+RP15c5xCJ5EsQpcU7kS4Ors0Jy5xQ
+eHQpDeEzYX61ZD5fj2UuwTPQ/OFcCIzuym0pwyC17f3jotbmJBHQyyq08XOzgEDJ
+p5Lb8N7fMbLpfh742ORBGDM+9/k1kuQxz7jl/Ad4Lczp0wzH5z67JZJmpMqaEHvg
+gLI4eiWaB7dfUSJb1YPMa/BsdEnl5adGTvkgOdlm/06dbE6V1R/EAX+Yjo/vvcMW
+n+pCb0b3WarcYKgUk2qt/8Nc+AHIWr+f36H4kIEGidgNNq+FI0ZXlHzIxdEYhW7u
+10u11wb0PEJ7dNCdCPdb9Fp2moIXgxMkXszqCmF4ewK1PV9m+rsbplwwdXkqEfB1
+DA8bBLZgRFcaFx/A4ag5ImF8635vRinayAM8/zIsMam/RtlxqqF9lUQZKZxqGjRu
+iumtr95L30mRSlwo34kQ1TXiuly+3/1TqnDv4F/ulCR5asrTXzjZaEnczs1poD/T
+B9L2cW3qhKaVTrkBjQRS7Xx+AQwAtpOL3c9wdaWX8AtI81nmQrhS8WRARygSZbeq
+tfTxqL0Wc9yF2RP6RzQLH0mGZ/NniWvTiffgSFEo5McwOA6531YOZi5Pm5svy3B9
+iAVcWUwUoko1pGYowai4zwy/gyjqdOC1GBKERXZrwvKcw2HctxXot9kwZYnv1SyD
+hyWNGB484mLHCLujoU50bMdgHAHgsT6/AVsVlqS86CtQYNymt05yPQKs6hBbNEAo
+VbrYu4M89iLRoROhlD+i8h1UCWEWYAshswscNjmzN5bEQSKS1ItEtG3MH52iPGRC
+bUSadKIHSb4M3Zz2uAEvd6LZgLMtBHsqSrVt9sgx/m8/XXZOEon+zGrdtJuXTTcR
+RnnAhilJ5PCaKIhYP+TZgTpAjrilfn6DUpG3/lHvvmINylVuSXsKUyc4ARDRyiSg
+2IVAp0yeEd/GG//FG+3Gl4m1ieLf3ENOpspp5uta7YUOe2nc9woQtPq/qtc4/3ii
+Pqz8jFnEUVBVqWQQVXbNVqI3tt4xABEBAAGJAbYEGAEKACAWIQRxqBLjdKKoJ00c
+/mZbXDRCUaXafgUCUu18fgIbDAAKCRBbXDRCUaXafkx5C/0bxmNaezMkBlruIXQq
+cmtEieyumJ27MbaI41vjnPUbdqtcqQPawq3Yov4CAy0vH78NccqygkE2CNeaQzYR
+iIxS4pOnOyoT1M822e7mZpkVz5DS0wr1hx863/MsKNrQ7DF/T3vlmDQJyT0llIMZ
+SphO1hZohD6AgPdu+1v3/nIRD7kqa10thQf/IJ5etpn3Lyuh0db3VxUsJjS/evem
+BpXnMIHmLDVLBWcBYypwDWzigd9/A4lNVBv06gL5irbPg2Tzr7C5sSqcL7nHvpep
+6nuMgBFFdZ65W5+xRxyLpg81aacel3z8E+76du85VUP9f1+E53FsvQM3KYeZuHnM
+Cw3PaXWubny7wALHHxS66C6SCQpGYhynFkRZEsaftzPCNR+W621IMIMMswL1w/9H
+2wnIz5BMP6Eraw0K/HiescB/yOIO2TVj72E1ci9VfO5GnZb2c8kmim4AAYG8+LNk
+XcSPechMl11vjbfPz4lvWQBQrMfbBdLOBKGI9u11ug2G2yK5AY0EVPTrgwEMAM9k
+h3FeYMkludJ/OA4jxzjdhK14QW0mNKZNSjh8AzP/hE2TbgWYWecM1OpugTmoVUrs
+x/pnFuaCOjRxBDM8U8leT1jK430Z7mUt5pDR9fJ+khhM9ty6eoGttWMxycj+/7Ho
+72Zr7lzcDNfUKRGbRitK723nkWb/Lds//a4vU9VKYCv4BkyExl/fE/tNlBfCrHVp
+E51QBECTrNHjgnKKik4CkIDm/BmwyCD/atW34H1UfjfTCqRBXncgYg1cK6bmgyl4
+HGb4uvOlidmJSswu/i5mSaaP/kzHf5oN8hK7v5ya9s3MehGwgHCmFS0YP5I10Wv3
+nBdKPFJ8Do01MlYYPUaL2XZs7aCtWrWPuzOHZByicA+te/iEHvZHE8Bk/qBXSN8C
+lvbaKEQqLkW45hjSLiFqVYEZY5emx5C57bp8z5eyhLzdPAzKTuUADlriFVBkNSv8
+kmN0gmQK2qjGLEX4BTtbvD/W+dWAAZqLwjVAjjUqIaSb0X2dT90K1CaeO4u7eQAR
+AQABiQNsBBgBCgAgFiEEcagS43SiqCdNHP5mW1w0QlGl2n4FAlT064MCGwIBwAkQ
+W1w0QlGl2n7A9CAEGQEKAB0WIQRhIYHmBsx+W+KdrGhiXUgZ8C7nJwUCVPTrgwAK
+CRBiXUgZ8C7nJ0OrC/9S+9c+9dlCz7ls/Ez8+3fnttL72Ui2VPEgB+KYj71K6Jx/
+29JFHec2Bl35Si/6NSWx25rrEbbYtcXQPqu0W7PqAfzytx+GxCdMDUHqMdcxx8mZ
+n4WCYVYwxOhsNMQIZHmz0noJnHholQ9XsGVWkV9TPyW38zGVC0yHAakrJ5ZFuJN4
+f7UNydYgwyVtPbVfOryd7dDPaBNJOPjpgQ2Xuj1p4Ygz0WQZ+FYl8EcV4jzYi085
+lhuDCjX9gbF5IkIkjLZmRKpAX3tR8L+d5x8gTuVTZoLrpSDZR0WsSPFhgKKiY/hV
+o0oPKXoSz1DOygwk1PMScEiJ5jbGYvQ+yBJbJO02O4fXTeiKYHONBIhu7fl+49JM
+XqOscHoKsP7PlCZ87YKqu6S1h/nHY+vVpbnhV2XDINWpn7d1qsBfewy/plXf5BPB
+4334ruqclz12DqreHXtOIzFr6LSTm4pIWia1Nxu03qXwCl9dhhBQCWZ00bnYzL19
+sWnN6HLdfvTdYxNPzn5TUgv8C0Boxbf8lhkiy0+P8Z9JKP0sSRhjKlsoi7hk2art
+sfcPGOcid7WNP3Ruc+rhIQADbgjSHnUpJ892XPkJ58yUEPCr0B1+GkV93Lv41Q6U
+HqDHJOWeWqhOoD/qRT+6ZRGGA3BQ2xmxNKphsUx7uBcndQsig0JNCMawH/OklvlB
+qDMthlOFMeSfhkVvLymyDtikFrdkYQmo/IrFHe0leeiQ671ZF6MRxtnx6NmtJo29
+LOquReTU2LZyUJ6q6XEkdaH1Av2c1u0BmIJoHSUgHaxM1wJ7oSE9GfGS9dDyWADA
+Kv1obVb3Zpi5Dzr/oBN+ipstAEUYvBlLvaitt3Wvx9/8QUTpqxCQJk2owXE8omY2
+xH/yBJCiF/ht8+cUKw/albiFAUz9S2ncUC2j1j/myF17obcnrDWKwMbW0BPIkZmI
+qKyKp4GTL+rEkGiZzqcfs5zFIgnbiE4E7r3nKR3uc8RbeJ631che4mZjFi43lt7K
+VOAC+OD2j+tCnPz04f8HiUE5uQGNBFcBkmMBDADxUSvSq2B7ctdtq3EyR/msyv0w
+zqjE/r/2DAZyodM5Rkz4vSpKL4GWs9dfXFz2g+0snmH95uHTbZ1sV7R0jdD9y0nW
++cz6/eAvZmeDD8fkpaz13T1LiptC0Y1jqpmPUN0tMBCx2ILA/gbogV1N4CG8VB2q
+fbbovIF1xUBbw5iT76E77JqrNZ2YxgReaxSfZHjcih0V3k2tshv3CBkFjbwx2WtD
+jJ1ZuRMDxMdJzstwKwq2m67Z8eYzvZuJFeBSg6cyXm1lEqLsZg6qSvSa8NWJb2b8
+0/OTQj+Nc+wDptuvgUVpdCTnuhejahzSNUR6JZsndvSnUsKviypOTSHh/4L678WI
+sNEFuAv0iYEk+AhpMUIXAll/p0G435bOxkOtWFSbkIkLlvpN0pRF6umn0z1D0Bg6
+XUKeqZmuQ6aof5cP7AT9gj5kTD4Z7BLdXlqvtpJAyMJrWIGSRaXy9QVhkqHU6oXm
+UKxosEO8zlcVH8jM9omAG8XOd9+KAqlR/nFQMX8AEQEAAYkDbAQYAQoAIBYhBHGo
+EuN0oqgnTRz+ZltcNEJRpdp+BQJXAZJjAhsCAcAJEFtcNEJRpdp+wPQgBBkBCgAd
+FiEE4gkjKPkt/ksh70dUtjZz25idcjMFAlcBkmMACgkQtjZz25idcjN7cwv+Kjou
+3GApBRTteGLBwJjf5KlrJYo/YRSWqZG6HtR9E++AkOh2/Dl0wL77mwWExzsA0+WI
+nsWiq4QxWWmlEZLuJ3shXKzjouRpazW1bXOE+jKXPtcuIX8gWb9ms3V2czQHCX2c
+cEN/zo8A5K5LdCB/76weIROkZHb0AO7nME/sz1Ja8QzKOaMDM8xlBt7tMifbqB3j
+dy0UmJUeNFttWzPfe1Nv2vTHHhE8WhccoT50bXYs5bPTrvIrtr4ZsfYnUcFxe1cg
+a8n5zb2I60YB9akvc4/9Io37QXeHBNFkEMj2ivwpo4j7l9cklno/O5D6FtbIjJFe
+PVzxqdcHHKxvb1nF5/BpugHCOO3SxMzukaewFRlBxmsg3tEvoaNviLGsy87skhoK
+I0WL3GGASzkkCoMmuG6s3jv23zxYQ55bHLtEiv3e/VWgntWk1jePm97nlLcbkEww
+k2tH0skoFQV73/5m3Ys2AaDaUctk+h18o3z0qQRRoEiGlEMkEpCHjBrUGAAL2NwL
+/1RDeMNIzMeINdRgTtN1ckzv6rJ3Y9uAV8bcmdY6/9gl5sH6k+Kce0ViF4mBNDW2
+sIlXhtn0ULGpxc3r7XGf6n8VDEtL+pI/vVCoopvKPEWLbYZAF9xHVRiYYUX6ikRX
+dXObqJAemErpnEMZq4aXsjQs8n9fnzOI2uut+gum2xu2LRvbiepAbt4kjWkEtZ8U
+nj8YNraeB9Dhr5uZ6JqeXc5fOpLiDI7O6250hEWvTX3+rh8055B6b4nI/g8kYCiM
+WueITj/0UKe3OeZO9BB/G7UhM83HoMgUHMH+RPcuyWmn/XSR95WYNUgAs1nxi1jq
+17cqU9TMSCULhgtds4zJXsbmGUX8+iJAy52gD//BZswDJRJe9YDS5LaMgQgM2XiI
+pVyan8lQMzHvxXx638lh7gbzkqM4Zc7TQ8G/KruBvh4Vvt4m0ppZooiF8b9QiqS3
+BD7QblDB8QAGLcLwzAM0uJv33ylHf70U/dLIg7IcBp5NAg6WuvaCfYefn+vNQqD6
+tA==
+=nDSP
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/two-enc-subkeys-one-deleted.sec.asc b/comm/mail/test/browser/openpgp/data/keys/two-enc-subkeys-one-deleted.sec.asc
new file mode 100644
index 0000000000..99a9c96578
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/two-enc-subkeys-one-deleted.sec.asc
@@ -0,0 +1,35 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lFgEY01cZxYJKwYBBAHaRw8BAQdA0/vtsQqotv2/BlbtBvnC9J8oCEADfA0ohd1M
+rckzcFQAAQCRArAmnlHwP/ujI1JPmOfBsng0EREJhcLEvRJV3sO56xAntC90aGly
+ZC11aWQgPG11bHRpLXN1YmtleS10ZXN0LXRoaXJkLXVpZEBwZ3AuaWN1PoiTBBMW
+CgA7FiEEX/tXmslvesUot/ERAG28BiQHOm8FAmNNXkQCGwMFCwkIBwICIgIGFQoJ
+CAsCBBYCAwECHgcCF4AACgkQAG28BiQHOm+YgwD/Uk+D77bi6oQ9Z0CF99GoJM52
+ynn14hSzrZOXNuMVxEsA/juz6AEHkBC3jV/ZeQuBJQliBz7uXLxcj/bl/xYifscC
+tC5zZWNvbmQtdWlkIDxtdWx0aS1zdWJrZXktdGVzdC0ybmQtdWlkQHBncC5pY3U+
+iJMEExYKADsWIQRf+1eayW96xSi38REAbbwGJAc6bwUCY01cpQIbAwULCQgHAgIi
+AgYVCgkICwIEFgIDAQIeBwIXgAAKCRAAbbwGJAc6b2p4AQC3d/O+vFcnNe/GHOzu
+QmsP1ZBz5lAXtrWyauY6Adtp1AEAyj7BRAasgfEXLELb8N2TVX8NIetLU9+FH27Q
+P9AaZAu0LW11bHRpLXN1YmtleS10ZXN0IDxtdWx0aS1zdWJrZXktdGVzdEBwZ3Au
+aWN1PoiTBBMWCgA7FiEEX/tXmslvesUot/ERAG28BiQHOm8FAmNNXGcCGwMFCwkI
+BwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQAG28BiQHOm/OnwEA9k/CHig05CxJ
+pjwoCIrilHF62AO0r2kDd3wil4hF1nABAOiUCiSPVTq4AmAd3QW440WQab5dHZd9
+jOuc0WsDRwADnFgEY01ctxYJKwYBBAHaRw8BAQdAcdi+7ywtSvkYlpmmy3Ai3Hgt
+EMTnUnNtNDtTkIfFAugAAP93nRlqvPBPTqaReAq91JYIadNOT3iq3MsyR08RTWcJ
+Ug9RiO8EGBYKACAWIQRf+1eayW96xSi38REAbbwGJAc6bwUCY01ctwIbAgCBCRAA
+bbwGJAc6b3YgBBkWCgAdFiEED5X+QxlFxF1e8EjtPc3XaDh2NHsFAmNNXLcACgkQ
+Pc3XaDh2NHvhpQD9FA/tv7SU23f1xJ8WcvRVCRIh1wzQkgDrKdrBFfdXhPkA/iXd
++ZpJs7xkLYu0N75QYJJWF796WA/Z4DZVxxv6lBgBhnMA/RkmlJT+QUoVnqMfWJSq
+Hu4hFG07+doLPvhMUMKju5P2APwNolvvuIEJtswtcphofdgQnd8/JYjY3HOwPVzz
+ELfBB5xdBGNNXYcSCisGAQQBl1UBBQEBB0AkqcqaLksLBWGlLymck89q/RMsWc1C
+pnI1sYae0H8tOgMBCAcAAP90/BSxTFRDm1gtPsj/rICaHwnpzuxRq6Dx3GnhASE2
+6BHAiHgEGBYKACAWIQRf+1eayW96xSi38REAbbwGJAc6bwUCY01dhwIbDAAKCRAA
+bbwGJAc6b13yAQDuqCmCOKho7sNL6ZCBTWc3oKcUFCt+tj41sXPYwMqSowD+JbpN
+aR0TCo0p7K4fVzZ80/qoTaYubwcI+Cp57AqK1gCcWARjTV2oFgkrBgEEAdpHDwEB
+B0AVh/XLcWJIxjr1lawAyOmXAjSzLtnp4GcvbEPTiuhmwAABAJq1jRaMBch76ML4
+DJTZFq296InZhzdvMe5x3Hp0bm6eEa2IeAQYFgoAIBYhBF/7V5rJb3rFKLfxEQBt
+vAYkBzpvBQJjTV2oAhsgAAoJEABtvAYkBzpvb/kBANsloi0xUnnxT7Nc/j8X10mH
+WXOgGJ6g1RrBiky9l942AP0YrDC6GWfioUsCBb0fSKFHqx6tlnw25JpFH1IhZ1wC
+Aw==
+=gBXR
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/two-enc-subkeys-still-both.pub.asc b/comm/mail/test/browser/openpgp/data/keys/two-enc-subkeys-still-both.pub.asc
new file mode 100644
index 0000000000..b66dfd8b90
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/two-enc-subkeys-still-both.pub.asc
@@ -0,0 +1,31 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEY01cZxYJKwYBBAHaRw8BAQdA0/vtsQqotv2/BlbtBvnC9J8oCEADfA0ohd1M
+rckzcFS0LnNlY29uZC11aWQgPG11bHRpLXN1YmtleS10ZXN0LTJuZC11aWRAcGdw
+LmljdT6IkwQTFgoAOxYhBF/7V5rJb3rFKLfxEQBtvAYkBzpvBQJjTVylAhsDBQsJ
+CAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEABtvAYkBzpvangBALd38768Vyc1
+78Yc7O5Caw/VkHPmUBe2tbJq5joB22nUAQDKPsFEBqyB8RcsQtvw3ZNVfw0h60tT
+34UfbtA/0BpkC7QtbXVsdGktc3Via2V5LXRlc3QgPG11bHRpLXN1YmtleS10ZXN0
+QHBncC5pY3U+iJMEExYKADsWIQRf+1eayW96xSi38REAbbwGJAc6bwUCY01cZwIb
+AwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRAAbbwGJAc6b86fAQD2T8Ie
+KDTkLEmmPCgIiuKUcXrYA7SvaQN3fCKXiEXWcAEA6JQKJI9VOrgCYB3dBbjjRZBp
+vl0dl32M65zRawNHAAO4OARjTVxnEgorBgEEAZdVAQUBAQdAtEVlipyXCm2fdZO+
+ki8PztQwm8M5l3gfnnmgWZijcHADAQgHiHgEGBYKACAWIQRf+1eayW96xSi38REA
+bbwGJAc6bwUCY01cZwIbDAAKCRAAbbwGJAc6b+McAP0YBPv9FosKyFgs3U6s0qMo
+N3g20mHcnzK8Tn0d2CVXNgD/f0UuMeTNQNaobmKgncX3WxOTRCdU8gq7fTmADuYT
+iAO4MwRjTVy3FgkrBgEEAdpHDwEBB0Bx2L7vLC1K+RiWmabLcCLceC0QxOdSc200
+O1OQh8UC6IjvBBgWCgAgFiEEX/tXmslvesUot/ERAG28BiQHOm8FAmNNXLcCGwIA
+gQkQAG28BiQHOm92IAQZFgoAHRYhBA+V/kMZRcRdXvBI7T3N12g4djR7BQJjTVy3
+AAoJED3N12g4djR74aUA/RQP7b+0lNt39cSfFnL0VQkSIdcM0JIA6ynawRX3V4T5
+AP4l3fmaSbO8ZC2LtDe+UGCSVhe/elgP2eA2Vccb+pQYAYZzAP0ZJpSU/kFKFZ6j
+H1iUqh7uIRRtO/naCz74TFDCo7uT9gD8DaJb77iBCbbMLXKYaH3YEJ3fPyWI2Nxz
+sD1c8xC3wQe4OARjTV2HEgorBgEEAZdVAQUBAQdAJKnKmi5LCwVhpS8pnJPPav0T
+LFnNQqZyNbGGntB/LToDAQgHiHgEGBYKACAWIQRf+1eayW96xSi38REAbbwGJAc6
+bwUCY01dhwIbDAAKCRAAbbwGJAc6b13yAQDuqCmCOKho7sNL6ZCBTWc3oKcUFCt+
+tj41sXPYwMqSowD+JbpNaR0TCo0p7K4fVzZ80/qoTaYubwcI+Cp57AqK1gC4MwRj
+TV2oFgkrBgEEAdpHDwEBB0AVh/XLcWJIxjr1lawAyOmXAjSzLtnp4GcvbEPTiuhm
+wIh4BBgWCgAgFiEEX/tXmslvesUot/ERAG28BiQHOm8FAmNNXagCGyAACgkQAG28
+BiQHOm9v+QEA2yWiLTFSefFPs1z+PxfXSYdZc6AYnqDVGsGKTL2X3jYA/RisMLoZ
+Z+KhSwIFvR9IoUerHq2WfDbkmkUfUiFnXAID
+=217r
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/keys/untweaked-secret.asc b/comm/mail/test/browser/openpgp/data/keys/untweaked-secret.asc
new file mode 100644
index 0000000000..0be1a35ff8
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/keys/untweaked-secret.asc
@@ -0,0 +1,15 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+xYYEZXNJrhYJKwYBBAHaRw8BAQdAwMIRYaiBLlSzWaTOvX4YpQoJ/I8TSQUDjC0zw7frs5H+CQMI
+Na6GCdd3/p7/KfGebtqCVkavU4e+IYcKBLBGC/G+dyeVphQDH0P2IYtyVbKZdtKEEtpjAP7qSc6t
+M1n1Mpb42m7oLW4RaNENERnWh4drXM0pVW50d2Vha2VkIEVDQyA8dW50d2Vha2VkLWVjY0BleGFt
+cGxlLmNvbT7CiwQTFggAMxYhBEkpZab1ba0kI7NQboSfKbACBwf3BQJlc0muAhsDBQsJCAcCBhUI
+CQoLAgUWAgMBAAAKCRCEnymwAgcH9+Q8AQDqLxLTGc/3UrK23CtVc96WAV7tltj7KKRkUugkBp4H
+MgEAogcAHKOL0BVfZvP6dNsivYnzwQ+ag+9zIHA+sFABzAXHiwRlc0mvEgorBgEEAZdVAQUBAQdA
+ePxMSOnzYXwXII1LGdjTMqx3tCAHbtnLlkV/ZJ1xkTUDAQgH/gkDCHDqhB15/mtK/4WDct+GhyRo
+j7o5YZcyVQ6tybRH5Eh+iGaM3bfYqy4ZRYXPkSFzkdS7NRZ1XljmdljZ6YTZV2/k9Hjhfg0VxI8N
+dC32ysjCeAQYFggAIBYhBEkpZab1ba0kI7NQboSfKbACBwf3BQJlc0mvAhsMAAoJEISfKbACBwf3
+OAAA/3y4RqOyBuQ+ikiPs0QLNNK7ViDZFmgHPPKIAqTa3lRSAP9NTe08wQK6XhT8St0IXxBxKcLf
+2K4znVqxoGrOEFZjDA==
+=0rDW
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/comm/mail/test/browser/openpgp/data/smime/Bob.p12 b/comm/mail/test/browser/openpgp/data/smime/Bob.p12
new file mode 100644
index 0000000000..b651346cb1
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/smime/Bob.p12
Binary files differ
diff --git a/comm/mail/test/browser/openpgp/data/smime/alice.env.eml b/comm/mail/test/browser/openpgp/data/smime/alice.env.eml
new file mode 100644
index 0000000000..0f11bd2835
--- /dev/null
+++ b/comm/mail/test/browser/openpgp/data/smime/alice.env.eml
@@ -0,0 +1,25 @@
+MIME-Version: 1.0
+From: Alice@example.com
+To: Bob@example.com
+Subject: enveloped
+Content-Type: application/pkcs7-mime; name=smime.p7m;
+ smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename=smime.p7m
+Content-Description: S/MIME Encrypted Message
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggGFMIIBgQIBADBpMGQxCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIw
+EAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEoMA0GCSqG
+SIb3DQEBAQUABIIBAEs7IhROhu/wFSuypxWqfseBLKs1lhJJk9cX7o7tJ2mcVSjJ
+qiBn8DVyjXp0HARnkWTdYtNaRvdBNnDDdLIrTl0Furmw85jaamqhX7do+mIgvRPy
+PvEFhbel9zNS9oFxddWYBbMVl6Ib3ADXqjV+m3wZ463iB4SSxvIlxOMUdpluEhuT
+buZH6AyS4THOEMfJbuM6HOH902tK+PrwuJkk1CWR9lzt6tlf1rPuUna0Eq6n0+u2
+c0PpovqELnSUUbF0SpTS4pJU4WhIVpZPouzOSrvYgU4NId7kfJW/bdQQltsBsrcN
+wVGe/SQT+bwgZiJQaocuFylI4iyK7DNMucaWlkMwgAYJKoZIhvcNAQcBMB0GCWCG
+SAFlAwQBAgQQCTtMRKcvkPn97BUXVdftp6CABIGgp2FLzMSQFopatI6MEYm0LSb2
+ihSTtccIH2AROEBD0i+MX8YTyp+3SAZPEAIawavVimqmxfHSHmKXRjO3Ywjp3+yO
+hTvF5SjaSgxpPk8L0Pyh5n2RK+DEoUk1vUu5xufOigVhI9X6xVhcgpZBPJkCmUye
+coWbXmAgvZrsXbfkSB6ZXqxfVVllAFKsVcpbKKvQTL9i/iOIAbu7z1tfynbGyAQQ
+09g7by06cAm7iMe22ldyeAAAAAAAAAAAAAA=
+
diff --git a/comm/mail/test/browser/override-main-menu-collapse/browser.ini b/comm/mail/test/browser/override-main-menu-collapse/browser.ini
new file mode 100644
index 0000000000..ec463c34be
--- /dev/null
+++ b/comm/mail/test/browser/override-main-menu-collapse/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+prefs =
+ mail.main_menu.collapse_by_default=false
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_overrideMainmenuCollapse.js]
+skip-if = os == "mac"
diff --git a/comm/mail/test/browser/override-main-menu-collapse/browser_overrideMainmenuCollapse.js b/comm/mail/test/browser/override-main-menu-collapse/browser_overrideMainmenuCollapse.js
new file mode 100644
index 0000000000..5a7c9393f4
--- /dev/null
+++ b/comm/mail/test/browser/override-main-menu-collapse/browser_overrideMainmenuCollapse.js
@@ -0,0 +1,19 @@
+/* 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/. */
+
+/**
+ * Tests that the main menu will NOT be collapsed by default if Thunderbird
+ * starts with no accounts created, and mail.main_menu.collapse_by_default set
+ * to false.
+ */
+
+"use strict";
+
+add_task(function test_main_menu_not_collapsed() {
+ let mainMenu = document.getElementById("toolbar-menubar");
+ Assert.ok(
+ !mainMenu.hasAttribute("autohide"),
+ "The main menu should not have the autohide attribute."
+ );
+});
diff --git a/comm/mail/test/browser/pref-window/browser.ini b/comm/mail/test/browser/pref-window/browser.ini
new file mode 100644
index 0000000000..6137f68dbb
--- /dev/null
+++ b/comm/mail/test/browser/pref-window/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+skip-if = debug
+subsuite = thunderbird
+
+[browser_fontChooser.js]
diff --git a/comm/mail/test/browser/pref-window/browser_fontChooser.js b/comm/mail/test/browser/pref-window/browser_fontChooser.js
new file mode 100644
index 0000000000..bf75433ec3
--- /dev/null
+++ b/comm/mail/test/browser/pref-window/browser_fontChooser.js
@@ -0,0 +1,375 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 various things about the font chooser window, including
+ * - whether if the font defined in font.name.<style>.<language> is not present
+ * on the computer, we fall back to displaying what's in
+ * font.name-list.<style>.<language>.
+ */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { content_tab_e } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+var { mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_pref_tab, open_pref_tab } = ChromeUtils.import(
+ "resource://testing-common/mozmill/PrefTabHelpers.jsm"
+);
+var { wait_for_frame_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+var gFontEnumerator;
+var gTodayPane;
+
+// We'll test with Western. Unicode has issues on Windows (bug 550443).
+const kLanguage = "x-western";
+
+// A list of fonts present on the computer for each font type.
+var gRealFontLists = {};
+
+// A list of font types to consider
+const kFontTypes = ["serif", "sans-serif", "monospace"];
+
+add_setup(function () {
+ if (AppConstants.platform == "win") {
+ Services.prefs.setStringPref(
+ "font.name-list.serif.x-western",
+ "bc7e8c62-0634-467f-a029-fe6abcdf1582, Times New Roman"
+ );
+ Services.prefs.setStringPref(
+ "font.name-list.sans-serif.x-western",
+ "419129aa-43b7-40c4-b554-83d99b504b89, Arial"
+ );
+ Services.prefs.setStringPref(
+ "font.name-list.monospace.x-western",
+ "348df6e5-e874-4d21-ad4b-359b530a33b7, Courier New"
+ );
+ } else if (AppConstants.platform == "macosx") {
+ Services.prefs.setStringPref(
+ "font.name-list.serif.x-western",
+ "bc7e8c62-0634-467f-a029-fe6abcdf1582, Times"
+ );
+ Services.prefs.setStringPref(
+ "font.name-list.sans-serif.x-western",
+ "419129aa-43b7-40c4-b554-83d99b504b89, Helvetica"
+ );
+ Services.prefs.setStringPref(
+ "font.name-list.monospace.x-western",
+ "348df6e5-e874-4d21-ad4b-359b530a33b7, Courier"
+ );
+ } else {
+ Services.prefs.setStringPref(
+ "font.name-list.serif.x-western",
+ "bc7e8c62-0634-467f-a029-fe6abcdf1582, serif"
+ );
+ Services.prefs.setStringPref(
+ "font.name-list.sans-serif.x-western",
+ "419129aa-43b7-40c4-b554-83d99b504b89, sans-serif"
+ );
+ Services.prefs.setStringPref(
+ "font.name-list.monospace.x-western",
+ "348df6e5-e874-4d21-ad4b-359b530a33b7, monospace"
+ );
+ }
+
+ let finished = false;
+ buildFontList().then(() => (finished = true), console.error);
+ utils.waitFor(
+ () => finished,
+ "Timeout waiting for font enumeration to complete."
+ );
+
+ // Hide Lightning's Today pane as it obscures buttons in preferences in the
+ // small TB window our tests run in.
+ gTodayPane = mc.window.document.getElementById("today-pane-panel");
+ if (gTodayPane) {
+ if (!gTodayPane.collapsed) {
+ EventUtils.synthesizeKey("VK_F11", {});
+ } else {
+ gTodayPane = null;
+ }
+ }
+});
+
+async function buildFontList() {
+ gFontEnumerator = Cc["@mozilla.org/gfx/fontenumerator;1"].createInstance(
+ Ci.nsIFontEnumerator
+ );
+ for (let fontType of kFontTypes) {
+ gRealFontLists[fontType] = await gFontEnumerator.EnumerateFontsAsync(
+ kLanguage,
+ fontType
+ );
+ if (gRealFontLists[fontType].length == 0) {
+ throw new Error(
+ "No fonts found for language " +
+ kLanguage +
+ " and font type " +
+ fontType +
+ "."
+ );
+ }
+ }
+}
+
+function assert_fonts_equal(aDescription, aExpected, aActual, aPrefix = false) {
+ if (
+ !(
+ (!aPrefix && aExpected == aActual) ||
+ (aPrefix && aActual.startsWith(aExpected))
+ )
+ ) {
+ throw new Error(
+ "The " +
+ aDescription +
+ " font should be '" +
+ aExpected +
+ "', but " +
+ (aActual.length == 0
+ ? "nothing is actually selected."
+ : "is actually: " + aActual + ".")
+ );
+ }
+}
+
+/**
+ * Verify that the given fonts are displayed in the font chooser. This opens the
+ * pref window to the display pane and checks that, then opens the font chooser
+ * and checks that too.
+ */
+async function _verify_fonts_displayed(
+ aDefaults,
+ aSerif,
+ aSansSerif,
+ aMonospace
+) {
+ // Bring up the preferences window.
+ let prefTab = open_pref_tab("paneGeneral");
+ let contentDoc = prefTab.browser.contentDocument;
+ let prefsWindow = contentDoc.ownerGlobal;
+ prefsWindow.resizeTo(screen.availWidth, screen.availHeight);
+
+ let isSansDefault =
+ Services.prefs.getCharPref("font.default." + kLanguage) == "sans-serif";
+ let displayPaneExpected = isSansDefault ? aSansSerif : aSerif;
+ let displayPaneActual = content_tab_e(prefTab, "defaultFont");
+ utils.waitFor(
+ () => displayPaneActual.itemCount > 0,
+ "No font names were populated in the font picker."
+ );
+ assert_fonts_equal(
+ "display pane",
+ displayPaneExpected,
+ displayPaneActual.value
+ );
+
+ let advancedFonts = contentDoc.getElementById("advancedFonts");
+ advancedFonts.scrollIntoView(false);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ // Now open the advanced dialog.
+ EventUtils.synthesizeMouseAtCenter(advancedFonts, {}, prefsWindow);
+ let fontc = wait_for_frame_load(
+ prefsWindow.gSubDialog._topDialog._frame,
+ "chrome://messenger/content/preferences/fonts.xhtml"
+ );
+
+ // The font pickers are populated async so we need to wait for it.
+ for (let fontElemId of ["serif", "sans-serif", "monospace"]) {
+ utils.waitFor(
+ () => fontc.window.document.getElementById(fontElemId).label != "",
+ "Timeout waiting for font picker '" + fontElemId + "' to populate."
+ );
+ }
+
+ if (!aDefaults) {
+ assert_fonts_equal(
+ "serif",
+ aSerif,
+ fontc.window.document.getElementById("serif").value
+ );
+ assert_fonts_equal(
+ "sans-serif",
+ aSansSerif,
+ fontc.window.document.getElementById("sans-serif").value
+ );
+ assert_fonts_equal(
+ "monospace",
+ aMonospace,
+ fontc.window.document.getElementById("monospace").value
+ );
+ } else if (AppConstants.platform == "linux") {
+ // When default fonts are displayed in the menulist, there is no value set,
+ // only the label, in the form "Default (font name)".
+
+ // On Linux the prefs we set contained only the generic font names,
+ // like 'serif', but here a specific font name will be shown, but it is
+ // system-dependent what it will be. So we just check for the 'Default'
+ // prefix.
+ assert_fonts_equal(
+ "serif",
+ `Default (`,
+ fontc.window.document.getElementById("serif").label,
+ true
+ );
+ assert_fonts_equal(
+ "sans-serif",
+ `Default (`,
+ fontc.window.document.getElementById("sans-serif").label,
+ true
+ );
+ assert_fonts_equal(
+ "monospace",
+ `Default (`,
+ fontc.window.document.getElementById("monospace").label,
+ true
+ );
+ } else {
+ assert_fonts_equal(
+ "serif",
+ `Default (${aSerif})`,
+ fontc.window.document.getElementById("serif").label
+ );
+ assert_fonts_equal(
+ "sans-serif",
+ `Default (${aSansSerif})`,
+ fontc.window.document.getElementById("sans-serif").label
+ );
+ assert_fonts_equal(
+ "monospace",
+ `Default (${aMonospace})`,
+ fontc.window.document.getElementById("monospace").label
+ );
+ }
+
+ close_pref_tab(prefTab);
+}
+
+/**
+ * Test that for a particular language, whatever's in
+ * font.name.<type>.<language> is displayed in the font chooser (if it is
+ * present on the computer).
+ */
+add_task(async function test_font_name_displayed() {
+ Services.prefs.setCharPref("font.language.group", kLanguage);
+
+ // Pick the first font for each font type and set it.
+ let expected = {};
+ for (let [fontType, fontList] of Object.entries(gRealFontLists)) {
+ // Work around bug 698238 (on Windows, Courier is returned by the enumerator but
+ // substituted with Courier New) by getting the standard (substituted) family
+ // name for each font.
+ let standardFamily = gFontEnumerator.getStandardFamilyName(fontList[0]);
+ Services.prefs.setCharPref(
+ "font.name." + fontType + "." + kLanguage,
+ standardFamily
+ );
+ expected[fontType] = standardFamily;
+ }
+
+ let fontTypes = kFontTypes.map(fontType => expected[fontType]);
+ await _verify_fonts_displayed(false, ...fontTypes);
+ teardownTest();
+});
+
+// Fonts definitely not present on a computer -- we simply use UUIDs. These
+// should be kept in sync with the ones in *-prefs.js.
+const kFakeFonts = {
+ serif: "bc7e8c62-0634-467f-a029-fe6abcdf1582",
+ "sans-serif": "419129aa-43b7-40c4-b554-83d99b504b89",
+ monospace: "348df6e5-e874-4d21-ad4b-359b530a33b7",
+};
+
+/**
+ * Test that for a particular language, if font.name.<type>.<language> is not
+ * present on the computer, we fall back to displaying what's in
+ * font.name-list.<type>.<language>.
+ */
+add_task(async function test_font_name_not_present() {
+ Services.prefs.setCharPref("font.language.group", kLanguage);
+
+ // The fonts we're expecting to see selected in the font chooser for
+ // test_font_name_not_present.
+ let expected = {};
+ for (let [fontType, fakeFont] of Object.entries(kFakeFonts)) {
+ // Look at the font.name-list. We need to verify that the first font is the
+ // fake one, and that the second one is present on the user's computer.
+ let listPref = "font.name-list." + fontType + "." + kLanguage;
+ let fontList = Services.prefs.getCharPref(listPref);
+ let fonts = fontList.split(",").map(font => font.trim());
+ if (fonts.length != 2) {
+ throw new Error(
+ listPref +
+ " should have exactly two fonts, but it is '" +
+ fontList +
+ "'."
+ );
+ }
+
+ if (fonts[0] != fakeFont) {
+ throw new Error(
+ "The first font in " +
+ listPref +
+ " should be '" +
+ fakeFont +
+ "', but is actually: " +
+ fonts[0] +
+ "."
+ );
+ }
+
+ if (!gRealFontLists[fontType].includes(fonts[1])) {
+ throw new Error(
+ "The second font in " +
+ listPref +
+ " (" +
+ fonts[1] +
+ ") should be present on this computer, but isn't."
+ );
+ }
+ expected[fontType] = fonts[1];
+
+ // Set font.name to be a nonsense name that shouldn't exist.
+ // font.name-list is handled by wrapper.py.
+ Services.prefs.setCharPref(
+ "font.name." + fontType + "." + kLanguage,
+ fakeFont
+ );
+ }
+
+ let fontTypes = kFontTypes.map(fontType => expected[fontType]);
+ await _verify_fonts_displayed(true, ...fontTypes);
+ teardownTest();
+});
+
+function teardownTest() {
+ // nsIPrefBranch.resetBranch() is not implemented in M-C, so we can't use
+ // Services.prefs.resetBranch().
+ Preferences.resetBranch("font.name.");
+}
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("font.language.group");
+ if (gTodayPane && gTodayPane.collapsed) {
+ EventUtils.synthesizeKey("VK_F11", {});
+ }
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/quick-filter-bar/browser.ini b/comm/mail/test/browser/quick-filter-bar/browser.ini
new file mode 100644
index 0000000000..e1d90f25f0
--- /dev/null
+++ b/comm/mail/test/browser/quick-filter-bar/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_filterLogic.js]
+[browser_keyboardInterface.js]
+[browser_stickyFilterLogic.js]
+[browser_toggleBar.js]
diff --git a/comm/mail/test/browser/quick-filter-bar/browser_filterLogic.js b/comm/mail/test/browser/quick-filter-bar/browser_filterLogic.js
new file mode 100644
index 0000000000..fb863ea154
--- /dev/null
+++ b/comm/mail/test/browser/quick-filter-bar/browser_filterLogic.js
@@ -0,0 +1,462 @@
+/* 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/. */
+
+/**
+ * Verify that we are constructing the filters that we expect and that they
+ * are hooked up to the right buttons.
+ */
+
+"use strict";
+
+var {
+ assert_messages_in_view,
+ assert_messages_not_in_view,
+ be_in_folder,
+ create_folder,
+ delete_messages,
+ get_about_3pane,
+ make_message_sets_in_folders,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_quick_filter_bar_visible,
+ assert_results_label_count,
+ assert_text_constraints_checked,
+ clear_constraints,
+ set_filter_text,
+ toggle_boolean_constraints,
+ toggle_quick_filter_bar,
+ toggle_tag_constraints,
+ toggle_tag_mode,
+ toggle_text_constraints,
+ cleanup_qfb_button,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/QuickFilterBarHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(async function () {
+ // Quick filter bar is hidden by default, need to toggle it on. To toggle
+ // quick filter bar, need to be inside folder
+ const folder = await create_folder("QuickFilterBarFilterFilterLogicSetup");
+ await be_in_folder(folder);
+ await ensure_table_view();
+ await toggle_quick_filter_bar();
+
+ registerCleanupFunction(async function () {
+ await ensure_cards_view();
+ await cleanup_qfb_button();
+ // Quick filter bar is hidden by default, need to toggle it off.
+ await toggle_quick_filter_bar();
+ });
+});
+
+add_task(async function test_filter_unread() {
+ let folder = await create_folder("QuickFilterBarFilterUnread");
+ let [unread, read] = await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1 }, { count: 1 }]
+ );
+ read.setRead(true);
+
+ await be_in_folder(folder);
+ toggle_boolean_constraints("unread");
+ assert_messages_in_view(unread);
+ teardownTest();
+});
+
+add_task(async function test_filter_starred() {
+ let folder = await create_folder("QuickFilterBarFilterStarred");
+ let [, starred] = await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1 }, { count: 1 }]
+ );
+ starred.setStarred(true);
+
+ await be_in_folder(folder);
+ toggle_boolean_constraints("starred");
+ assert_messages_in_view(starred);
+ teardownTest();
+});
+
+add_task(async function test_filter_simple_intersection_unread_and_starred() {
+ let folder = await create_folder("QuickFilterBarFilterUnreadAndStarred");
+ let [, readUnstarred, unreadStarred, readStarred] =
+ await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1 }, { count: 1 }, { count: 1 }, { count: 1 }]
+ );
+ readUnstarred.setRead(true);
+ unreadStarred.setStarred(true);
+ readStarred.setRead(true);
+ readStarred.setStarred(true);
+
+ await be_in_folder(folder);
+ toggle_boolean_constraints("unread", "starred");
+
+ assert_messages_in_view(unreadStarred);
+ teardownTest();
+});
+
+add_task(async function test_filter_attachments() {
+ let attachSetDef = {
+ count: 1,
+ attachments: [
+ {
+ filename: "foo.png",
+ contentType: "image/png",
+ encoding: "base64",
+ charset: null,
+ body: "YWJj\n",
+ format: null,
+ },
+ ],
+ };
+ let noAttachSetDef = {
+ count: 1,
+ };
+
+ let folder = await create_folder("QuickFilterBarFilterAttachments");
+ let [, setAttach] = await make_message_sets_in_folders(
+ [folder],
+ [noAttachSetDef, attachSetDef]
+ );
+
+ await be_in_folder(folder);
+ toggle_boolean_constraints("attachments");
+
+ assert_messages_in_view(setAttach);
+ teardownTest();
+});
+
+/**
+ * Create a card for the given e-mail address, adding it to the first address
+ * book we can find.
+ */
+function add_email_to_address_book(aEmailAddr) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.primaryEmail = aEmailAddr;
+
+ for (let addrbook of MailServices.ab.directories) {
+ addrbook.addCard(card);
+ return;
+ }
+
+ throw new Error("Unable to find any suitable address book.");
+}
+
+add_task(async function test_filter_in_address_book() {
+ let bookSetDef = {
+ from: ["Qbert Q Qbington", "q@q.invalid"],
+ count: 1,
+ };
+ add_email_to_address_book(bookSetDef.from[1]);
+ let folder = await create_folder("MesssageFilterBarInAddressBook");
+ let [setBook] = await make_message_sets_in_folders(
+ [folder],
+ [bookSetDef, { count: 1 }]
+ );
+ await be_in_folder(folder);
+ toggle_boolean_constraints("addrbook");
+ assert_messages_in_view(setBook);
+ teardownTest();
+});
+
+add_task(async function test_filter_tags() {
+ let folder = await create_folder("QuickFilterBarTags");
+ const tagA = "$label1",
+ tagB = "$label2",
+ tagC = "$label3";
+ let [setNoTag, setTagA, setTagB, setTagAB, setTagC] =
+ await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1 }, { count: 1 }, { count: 1 }, { count: 1 }, { count: 1 }]
+ );
+ setTagA.addTag(tagA);
+ setTagB.addTag(tagB);
+ setTagAB.addTag(tagA);
+ setTagAB.addTag(tagB);
+ setTagC.addTag(tagC);
+
+ await be_in_folder(folder);
+ toggle_boolean_constraints("tags"); // must have a tag
+ assert_messages_in_view([setTagA, setTagB, setTagAB, setTagC]);
+
+ toggle_tag_constraints(tagA); // must have tag A
+ assert_messages_in_view([setTagA, setTagAB]);
+
+ toggle_tag_constraints(tagB);
+ // mode is OR by default -> must have tag A or tag B
+ assert_messages_in_view([setTagA, setTagB, setTagAB]);
+
+ toggle_tag_mode();
+ // mode is now AND -> must have tag A and tag B
+ assert_messages_in_view([setTagAB]);
+
+ toggle_tag_constraints(tagA); // must have tag B
+ assert_messages_in_view([setTagB, setTagAB]);
+
+ toggle_tag_constraints(tagB); // have have a tag
+ assert_messages_in_view([setTagA, setTagB, setTagAB, setTagC]);
+
+ toggle_boolean_constraints("tags"); // no constraints
+ assert_messages_in_view([setNoTag, setTagA, setTagB, setTagAB, setTagC]);
+
+ // If we have filtered to a specific tag and we disable the tag filter
+ // entirely, make sure that when we turn it back on we are just back to "any
+ // tag".
+ toggle_boolean_constraints("tags");
+ toggle_tag_constraints(tagC);
+ assert_messages_in_view(setTagC);
+
+ toggle_boolean_constraints("tags"); // no constraints
+ toggle_boolean_constraints("tags"); // should be any tag (not tagC!)
+ assert_messages_in_view([setTagA, setTagB, setTagAB, setTagC]);
+ teardownTest();
+});
+
+add_task(async function test_filter_text_single_word_and_predicates() {
+ let folder = await create_folder("QuickFilterBarTextSingleWord");
+ let whoFoo = ["zabba", "foo@madeup.invalid"];
+ let [, setSenderFoo, setRecipientsFoo, setSubjectFoo, setBodyFoo] =
+ await make_message_sets_in_folders(
+ [folder],
+ [
+ { count: 1 },
+ { count: 1, from: whoFoo },
+ { count: 1, to: [whoFoo] },
+ { count: 1, subject: "foo" },
+ { count: 1, body: { body: "foo" } },
+ ]
+ );
+ await be_in_folder(folder);
+
+ // by default, sender/recipients/subject are selected
+ assert_text_constraints_checked("sender", "recipients", "subject");
+
+ // con defaults, por favor
+ set_filter_text("foo");
+ assert_messages_in_view([setSenderFoo, setRecipientsFoo, setSubjectFoo]);
+ // note: we sequence the changes in the list so there is always at least one
+ // dude selected. selecting down to nothing has potential UI implications
+ // we don't want this test to get affected by.
+ // sender only
+ toggle_text_constraints("recipients", "subject");
+ assert_messages_in_view(setSenderFoo);
+ // recipients only
+ toggle_text_constraints("recipients", "sender");
+ assert_messages_in_view(setRecipientsFoo);
+ // subject only
+ toggle_text_constraints("subject", "recipients");
+ assert_messages_in_view(setSubjectFoo);
+ // body only
+ toggle_text_constraints("body", "subject");
+ assert_messages_in_view(setBodyFoo);
+ // everybody
+ toggle_text_constraints("sender", "recipients", "subject");
+ assert_messages_in_view([
+ setSenderFoo,
+ setRecipientsFoo,
+ setSubjectFoo,
+ setBodyFoo,
+ ]);
+
+ // sanity check non-matching
+ set_filter_text("notgonnamatchevercauseisayso");
+ assert_messages_in_view([]);
+ // disable body, still should get nothing
+ toggle_text_constraints("body");
+ assert_messages_in_view([]);
+
+ // (we are leaving with the defaults once again active)
+ assert_text_constraints_checked("sender", "recipients", "subject");
+ teardownTest();
+});
+
+/**
+ * Verify that the multi-word logic is actually splitting the words into
+ * different terms and that the terms can match in different predicates.
+ * This means that given "foo bar" we should be able to match "bar foo" in
+ * a subject and "foo" in the sender and "bar" in the recipient. And that
+ * constitutes sufficient positive coverage, although we also want to make
+ * sure that just a single term match is insufficient.
+ */
+add_task(async function test_filter_text_multi_word() {
+ let folder = await create_folder("QuickFilterBarTextMultiWord");
+
+ let whoFoo = ["foo", "zabba@madeup.invalid"];
+ let whoBar = ["zabba", "bar@madeup.invalid"];
+ let [, setPeepMatch, setSubjReverse] = await make_message_sets_in_folders(
+ [folder],
+ [
+ { count: 1 },
+ { count: 1, from: whoFoo, to: [whoBar] },
+ { count: 1, subject: "bar foo" },
+ { count: 1, from: whoFoo },
+ ]
+ );
+ await be_in_folder(folder);
+
+ // (precondition)
+ assert_text_constraints_checked("sender", "recipients", "subject");
+
+ set_filter_text("foo bar");
+ assert_messages_in_view([setPeepMatch, setSubjReverse]);
+ teardownTest();
+});
+
+/**
+ * Verify that the quickfilter bar has OR functionality using
+ * | (Pipe character) - Bug 586131
+ */
+add_task(async function test_filter_or_operator() {
+ let folder = await create_folder("QuickFilterBarOrOperator");
+
+ let whoFoo = ["foo", "zabba@madeup.invalid"];
+ let whoBar = ["zabba", "bar@madeup.invalid"];
+ let whoTest = ["test", "test@madeup.invalid"];
+ let [setInert, setSenderFoo, setToBar, , , setSubject3, setMail1] =
+ await make_message_sets_in_folders(
+ [folder],
+ [
+ { count: 1 },
+ { count: 1, from: whoFoo },
+ { count: 1, to: [whoBar] },
+ { count: 1, subject: "foo bar" },
+ { count: 1, subject: "bar test" },
+ { count: 1, subject: "test" },
+ { count: 1, to: [whoTest], subject: "logic" },
+ { count: 1, from: whoFoo, to: [whoBar], subject: "test" },
+ ]
+ );
+ await be_in_folder(folder);
+
+ assert_text_constraints_checked("sender", "recipients", "subject");
+ set_filter_text("foo | bar");
+ assert_messages_not_in_view([setInert, setSubject3, setMail1]);
+
+ set_filter_text("test | bar");
+ assert_messages_not_in_view([setInert, setSenderFoo]);
+
+ set_filter_text("foo | test");
+ assert_messages_not_in_view([setInert, setToBar]);
+
+ // consists of leading and trailing spaces and tab character.
+ set_filter_text("test | foo bar");
+ assert_messages_not_in_view([
+ setInert,
+ setSenderFoo,
+ setToBar,
+ setSubject3,
+ setMail1,
+ ]);
+
+ set_filter_text("test | foo bar |logic");
+ assert_messages_not_in_view([setInert, setSenderFoo, setToBar, setSubject3]);
+ teardownTest();
+});
+
+/**
+ * Make sure that when dropping all constraints on toggle off or changing
+ * folders that we persist/propagate the state of the
+ * sender/recipients/subject/body toggle buttons.
+ */
+add_task(async function test_filter_text_constraints_propagate() {
+ let whoFoo = ["foo", "zabba@madeup.invalid"];
+ let whoBar = ["zabba", "bar@madeup.invalid"];
+
+ let folderOne = await create_folder("QuickFilterBarTextPropagate1");
+ let [setSubjFoo, setWhoFoo] = await make_message_sets_in_folders(
+ [folderOne],
+ [
+ { count: 1, subject: "foo" },
+ { count: 1, from: whoFoo },
+ ]
+ );
+ let folderTwo = await create_folder("QuickFilterBarTextPropagate2");
+ let [, setWhoBar] = await make_message_sets_in_folders(
+ [folderTwo],
+ [
+ { count: 1, subject: "bar" },
+ { count: 1, from: whoBar },
+ ]
+ );
+
+ await be_in_folder(folderOne);
+ set_filter_text("foo");
+ // (precondition)
+ assert_text_constraints_checked("sender", "recipients", "subject");
+ assert_messages_in_view([setSubjFoo, setWhoFoo]);
+
+ // -- drop subject, close bar to reset, make sure it sticks
+ toggle_text_constraints("subject");
+ assert_messages_in_view([setWhoFoo]);
+
+ await toggle_quick_filter_bar();
+ await toggle_quick_filter_bar();
+
+ set_filter_text("foo");
+ assert_messages_in_view([setWhoFoo]);
+ assert_text_constraints_checked("sender", "recipients");
+
+ // -- now change folders and make sure the settings stick
+ await be_in_folder(folderTwo);
+ set_filter_text("bar");
+ assert_messages_in_view([setWhoBar]);
+ assert_text_constraints_checked("sender", "recipients");
+ teardownTest();
+});
+
+/**
+ * Here is what the results label does:
+ * - No filter active: results label is not visible.
+ * - Filter active, messages: it says the number of messages.
+ * - Filter active, no messages: it says there are no messages.
+ *
+ * Additional nuances:
+ * - The count needs to update as the user deletes messages or what not.
+ */
+add_task(async function test_results_label() {
+ let folder = await create_folder("QuickFilterBarResultsLabel");
+ let [setImmortal, setMortal, setGoldfish] =
+ await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1 }, { count: 1 }, { count: 1 }]
+ );
+
+ await be_in_folder(folder);
+
+ // no filter, the label should not be visible
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ get_about_3pane().document.getElementById("qfb-results-label")
+ ),
+ "results label should not be visible"
+ );
+
+ toggle_boolean_constraints("unread");
+ assert_messages_in_view([setImmortal, setMortal, setGoldfish]);
+ assert_results_label_count(3);
+
+ await delete_messages(setGoldfish);
+ assert_results_label_count(2);
+
+ await delete_messages(setMortal);
+ assert_results_label_count(1);
+
+ await delete_messages(setImmortal);
+ assert_results_label_count(0);
+ teardownTest();
+});
+
+function teardownTest() {
+ clear_constraints();
+}
diff --git a/comm/mail/test/browser/quick-filter-bar/browser_keyboardInterface.js b/comm/mail/test/browser/quick-filter-bar/browser_keyboardInterface.js
new file mode 100644
index 0000000000..7deced3391
--- /dev/null
+++ b/comm/mail/test/browser/quick-filter-bar/browser_keyboardInterface.js
@@ -0,0 +1,185 @@
+/* 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/. */
+
+/**
+ * Tests keyboard stuff that doesn't fall under some other test's heading.
+ * Namely, control-shift-k toggling the bar into existence happens in
+ * test-toggle-bar.js, but we test that repeatedly hitting control-shift-k
+ * selects the text entered in the quick filter bar.
+ */
+
+"use strict";
+
+var {
+ be_in_folder,
+ create_folder,
+ get_about_3pane,
+ make_message_sets_in_folders,
+ mc,
+ select_click_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_constraints_expressed,
+ assert_filter_text,
+ assert_quick_filter_bar_visible,
+ clear_constraints,
+ set_filter_text,
+ toggle_boolean_constraints,
+ toggle_quick_filter_bar,
+ cleanup_qfb_button,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/QuickFilterBarHelpers.jsm"
+);
+
+var folder;
+
+add_setup(async function () {
+ folder = await create_folder("QuickFilterBarKeyboardInterface");
+ // We need a message so we can select it so we can find in message.
+ await make_message_sets_in_folders([folder], [{ count: 1 }]);
+ await be_in_folder(folder);
+ await ensure_table_view();
+
+ // Quick filter bar is hidden by default, need to toggle it on.
+ await toggle_quick_filter_bar();
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ await cleanup_qfb_button();
+ // Quick filter bar is hidden by default, need to toggle it off.
+ await toggle_quick_filter_bar();
+ });
+});
+
+/**
+ * The rules for pressing escape:
+ * - If there are any applied constraints:
+ * - If there is a 'most recent' constraint, it is relaxed and the 'most
+ * recent' field gets cleared, so that if escape gets hit again...
+ * - If there is no 'most recent' constraint, all constraints are cleared.
+ * - If there are no applied constraints, we close the filter bar.
+ *
+ * We test these rules two ways:
+ * 1) With the focus in the thread pane.
+ * 2) With our focus in our text-box.
+ */
+add_task(async function test_escape_rules() {
+ assert_quick_filter_bar_visible(true); // (precondition)
+
+ // the common logic for each bit...
+ async function legwork() {
+ // apply two...
+ toggle_boolean_constraints("unread", "starred", "addrbook");
+ assert_constraints_expressed({
+ unread: true,
+ starred: true,
+ addrbook: true,
+ });
+ assert_quick_filter_bar_visible(true);
+
+ // hit escape, should clear addrbook
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ assert_quick_filter_bar_visible(true);
+ assert_constraints_expressed({ unread: true, starred: true });
+
+ // hit escape, should clear both remaining ones
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ assert_quick_filter_bar_visible(true);
+ assert_constraints_expressed({});
+
+ // hit escape, bar should disappear
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ assert_quick_filter_bar_visible(false);
+
+ // bring the bar back for the next dude
+ await toggle_quick_filter_bar();
+ }
+
+ let about3Pane = get_about_3pane();
+
+ // 1) focus in the thread pane
+ about3Pane.document.getElementById("threadTree").focus();
+ await legwork();
+
+ // 2) focus in the text box
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ await legwork();
+
+ // 3) focus in the text box and pretend to type stuff...
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ set_filter_text("qxqxqxqx");
+
+ // Escape should clear the text constraint but the bar should still be
+ // visible. The trick here is that escape is clearing the text widget
+ // and is not falling through to the cmd_popQuickFilterBarStack case so we
+ // end up with a situation where the _lastFilterAttr is the textbox but the
+ // textbox does not actually have any active filter.
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ assert_quick_filter_bar_visible(true);
+ assert_constraints_expressed({});
+ assert_filter_text("");
+
+ // Next escape should close the box
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ assert_quick_filter_bar_visible(false);
+ teardownTest();
+});
+
+/**
+ * Control-shift-k expands the quick filter bar when it's collapsed. When
+ * already expanded, it focuses the text box and selects its text.
+ */
+add_task(function test_control_shift_k_shows_quick_filter_bar() {
+ let about3Pane = get_about_3pane();
+
+ let dispatcha = mc.window.document.commandDispatcher;
+ let qfbTextbox = about3Pane.document.getElementById("qfb-qs-textbox");
+
+ // focus explicitly on the thread pane so we know where the focus is.
+ about3Pane.document.getElementById("threadTree").focus();
+ // select a message so we can find in message
+ select_click_row(0);
+
+ // hit control-shift-k to get in the quick filter box
+ EventUtils.synthesizeKey("k", { accelKey: true, shiftKey: true });
+ if (dispatcha.focusedElement != qfbTextbox.inputField) {
+ throw new Error("control-shift-k did not focus quick filter textbox");
+ }
+
+ set_filter_text("search string");
+
+ // hit control-shift-k to select the text in the quick filter box
+ EventUtils.synthesizeKey("k", { accelKey: true, shiftKey: true });
+ if (dispatcha.focusedElement != qfbTextbox.inputField) {
+ throw new Error(
+ "second control-shift-k did not keep focus on filter textbox"
+ );
+ }
+ if (
+ qfbTextbox.inputField.selectionStart != 0 ||
+ qfbTextbox.inputField.selectionEnd != qfbTextbox.inputField.textLength
+ ) {
+ throw new Error(
+ "second control-shift-k did not select text in filter textbox"
+ );
+ }
+
+ // hit escape and make sure the text is cleared, but the quick filter bar is
+ // still open.
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ assert_quick_filter_bar_visible(true);
+ assert_filter_text("");
+
+ // hit escape one more time and make sure we finally collapsed the quick
+ // filter bar.
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ assert_quick_filter_bar_visible(false);
+ teardownTest();
+});
+
+function teardownTest() {
+ clear_constraints();
+}
diff --git a/comm/mail/test/browser/quick-filter-bar/browser_stickyFilterLogic.js b/comm/mail/test/browser/quick-filter-bar/browser_stickyFilterLogic.js
new file mode 100644
index 0000000000..2c373889c3
--- /dev/null
+++ b/comm/mail/test/browser/quick-filter-bar/browser_stickyFilterLogic.js
@@ -0,0 +1,167 @@
+/* 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/. */
+
+/*
+ * Sticky logic only needs to test the general sticky logic plus any filters
+ * with custom propagateState implementations (currently: tags, text filter.)
+ */
+
+"use strict";
+
+var {
+ assert_messages_in_view,
+ be_in_folder,
+ close_tab,
+ create_folder,
+ get_about_3pane,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_tab,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_constraints_expressed,
+ assert_filter_text,
+ assert_tag_constraints_visible,
+ clear_constraints,
+ set_filter_text,
+ toggle_boolean_constraints,
+ toggle_tag_constraints,
+ toggle_quick_filter_bar,
+ cleanup_qfb_button,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/QuickFilterBarHelpers.jsm"
+);
+
+add_setup(async function () {
+ // Quick filter bar is hidden by default, need to toggle it on. To toggle
+ // quick filter bar, we need to be inside folder
+ const folder = await create_folder("QuickFilterBarFilterStickySetup");
+ await be_in_folder(folder);
+ await ensure_table_view();
+ await toggle_quick_filter_bar();
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ await cleanup_qfb_button();
+ // Quick filter bar is hidden by default, need to toggle it off.
+ await toggle_quick_filter_bar();
+ });
+});
+
+/**
+ * Persist the current settings through folder change and inherit into a new tab.
+ */
+add_task(async function test_sticky_basics() {
+ let folderOne = await create_folder("QuickFilterBarStickyBasics1");
+ let [unreadOne, readOne] = await make_message_sets_in_folders(
+ [folderOne],
+ [{ count: 1 }, { count: 1 }]
+ );
+ readOne.setRead(true);
+
+ let folderTwo = await create_folder("QuickFilterBarStickyBasics2");
+ let [unreadTwo, readTwo] = await make_message_sets_in_folders(
+ [folderTwo],
+ [{ count: 1 }, { count: 1 }]
+ );
+ readTwo.setRead(true);
+
+ // -- setup
+ await be_in_folder(folderOne);
+ toggle_boolean_constraints("sticky", "unread");
+ assert_messages_in_view(unreadOne);
+
+ // -- change folders
+ await be_in_folder(folderTwo);
+ assert_constraints_expressed({ sticky: true, unread: true });
+ assert_messages_in_view(unreadTwo);
+
+ // -- inherit into a new folder
+ // TODO: Reimplement this behaviour.
+ // let tabB = await open_folder_in_new_tab(folderOne);
+ // assert_constraints_expressed({ sticky: true, unread: true });
+ // assert_messages_in_view(unreadOne);
+
+ // close_tab(tabB);
+ teardownTest();
+});
+
+/**
+ * The semantics of sticky tags are not obvious; there were decisions involved:
+ * - If the user has the tag facet enabled but not explicitly filtered on
+ * specific tags then we propagate just "true" to cause the faceting to
+ * run in the new folder. In other words, the list of displayed tags should
+ * change.
+ * - If the user has filtered on specific tags, then we do and must propagate
+ * the list of tags.
+ *
+ * We only need to do folder changes from here on out since the logic is
+ * identical (and tested to be identical in |test_sticky_basics|).
+ */
+add_task(async function test_sticky_tags() {
+ let folderOne = await create_folder("QuickFilterBarStickyTags1");
+ let folderTwo = await create_folder("QuickFilterBarStickyTags2");
+ const tagA = "$label1",
+ tagB = "$label2",
+ tagC = "$label3";
+ let [, setTagA1, setTagB1] = await make_message_sets_in_folders(
+ [folderOne],
+ [{ count: 1 }, { count: 1 }, { count: 1 }]
+ );
+ let [, setTagA2, setTagC2] = await make_message_sets_in_folders(
+ [folderTwo],
+ [{ count: 1 }, { count: 1 }, { count: 1 }]
+ );
+ setTagA1.addTag(tagA);
+ setTagB1.addTag(tagB);
+ setTagA2.addTag(tagA);
+ setTagC2.addTag(tagC);
+
+ await be_in_folder(folderOne);
+ toggle_boolean_constraints("sticky", "tags");
+ assert_tag_constraints_visible(tagA, tagB);
+ assert_messages_in_view([setTagA1, setTagB1]);
+
+ // -- re-facet when we change folders since constraint was just true
+ await be_in_folder(folderTwo);
+ assert_tag_constraints_visible(tagA, tagC);
+ assert_messages_in_view([setTagA2, setTagC2]);
+
+ // -- do not re-facet since tag A was selected
+ toggle_tag_constraints(tagA);
+ await be_in_folder(folderOne);
+ assert_tag_constraints_visible(tagA, tagC);
+ assert_messages_in_view([setTagA1]);
+
+ // -- if we turn off sticky, make sure that things clear when we change
+ // folders. (we had a bug with this before.)
+ toggle_boolean_constraints("sticky");
+ await be_in_folder(folderTwo);
+ assert_constraints_expressed({});
+ teardownTest();
+});
+
+/**
+ * All we are testing propagating is the text value; the text states are always
+ * propagated and that is tested in test-filter-logic.js by
+ * |test_filter_text_constraints_propagate|.
+ */
+add_task(async function test_sticky_text() {
+ let folderOne = await create_folder("QuickFilterBarStickyText1");
+ let folderTwo = await create_folder("QuickFilterBarStickyText2");
+
+ await be_in_folder(folderOne);
+ toggle_boolean_constraints("sticky");
+ set_filter_text("foo");
+
+ await be_in_folder(folderTwo);
+ assert_filter_text("foo");
+ teardownTest();
+});
+
+function teardownTest() {
+ clear_constraints();
+}
diff --git a/comm/mail/test/browser/quick-filter-bar/browser_toggleBar.js b/comm/mail/test/browser/quick-filter-bar/browser_toggleBar.js
new file mode 100644
index 0000000000..779df5e467
--- /dev/null
+++ b/comm/mail/test/browser/quick-filter-bar/browser_toggleBar.js
@@ -0,0 +1,117 @@
+/* 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 that the message filter bar toggles into and out of existence and
+ * states are updated as appropriate.
+ */
+
+"use strict";
+
+var {
+ assert_messages_in_view,
+ be_in_folder,
+ create_folder,
+ focus_thread_tree,
+ get_about_3pane,
+ make_message_sets_in_folders,
+ mc,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_constraints_expressed,
+ assert_quick_filter_bar_visible,
+ assert_quick_filter_button_enabled,
+ clear_constraints,
+ toggle_boolean_constraints,
+ toggle_quick_filter_bar,
+ cleanup_qfb_button,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/QuickFilterBarHelpers.jsm"
+);
+
+var folder;
+var setUnstarred, setStarred;
+
+add_setup(async function () {
+ folder = await create_folder("QuickFilterBarToggleBar");
+ [setUnstarred, setStarred] = await make_message_sets_in_folders(
+ [folder],
+ [{ count: 1 }, { count: 1 }]
+ );
+ setStarred.setStarred(true);
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ });
+});
+
+add_task(async function test_hidden_on_account_central() {
+ await be_in_folder(folder.rootFolder);
+ await assert_quick_filter_button_enabled(false);
+ assert_quick_filter_bar_visible(false);
+ teardownTest();
+});
+
+add_task(async function test_visible_by_default() {
+ await be_in_folder(folder);
+ await ensure_table_view();
+ await assert_quick_filter_button_enabled(true);
+ assert_quick_filter_bar_visible(true);
+ teardownTest();
+});
+
+add_task(async function test_direct_toggle() {
+ assert_quick_filter_bar_visible(true);
+ await toggle_quick_filter_bar();
+ assert_quick_filter_bar_visible(false);
+ await toggle_quick_filter_bar();
+ assert_quick_filter_bar_visible(true);
+ teardownTest();
+});
+
+add_task(async function test_control_shift_k_triggers_display() {
+ // hide it
+ await toggle_quick_filter_bar();
+ assert_quick_filter_bar_visible(false);
+
+ // focus explicitly on the thread pane so we know where the focus is.
+ focus_thread_tree();
+
+ // hit control-shift-k
+ EventUtils.synthesizeKey("k", { accelKey: true, shiftKey: true });
+
+ // now we should be visible again!
+ assert_quick_filter_bar_visible(true);
+ teardownTest();
+});
+
+add_task(async function test_constraints_disappear_when_collapsed() {
+ // set some constraints
+ toggle_boolean_constraints("starred");
+ assert_constraints_expressed({ starred: true });
+ assert_messages_in_view(setStarred);
+
+ // collapse, now we should see them all again!
+ await toggle_quick_filter_bar();
+ assert_messages_in_view([setUnstarred, setStarred]);
+
+ // uncollapse, we should still see them all!
+ await toggle_quick_filter_bar();
+ assert_messages_in_view([setUnstarred, setStarred]);
+
+ // there better be no constraints left!
+ assert_constraints_expressed({});
+ teardownTest();
+});
+
+registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ await cleanup_qfb_button();
+});
+
+function teardownTest() {
+ clear_constraints();
+}
diff --git a/comm/mail/test/browser/quick-filter-bar/head.js b/comm/mail/test/browser/quick-filter-bar/head.js
new file mode 100644
index 0000000000..2a8b342cc0
--- /dev/null
+++ b/comm/mail/test/browser/quick-filter-bar/head.js
@@ -0,0 +1,56 @@
+/* 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 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"
+ );
+}
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("mail.pane_config.dynamic");
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view"
+ );
+});
diff --git a/comm/mail/test/browser/search-window/browser.ini b/comm/mail/test/browser/search-window/browser.ini
new file mode 100644
index 0000000000..d2a69cdb55
--- /dev/null
+++ b/comm/mail/test/browser/search-window/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_multipleSearchWindows.js]
+[browser_rightClickToOpenSearchWindow.js]
+[browser_searchWindow.js]
+[browser_searchFromSyntheticView.js]
+skip-if = true # TODO
diff --git a/comm/mail/test/browser/search-window/browser_multipleSearchWindows.js b/comm/mail/test/browser/search-window/browser_multipleSearchWindows.js
new file mode 100644
index 0000000000..482656357d
--- /dev/null
+++ b/comm/mail/test/browser/search-window/browser_multipleSearchWindows.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/. */
+
+/*
+ * Test that we open multiple search windows when shortcuts are invoked multiple
+ * times.
+ */
+
+"use strict";
+
+var { be_in_folder, create_folder, mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_search_window_folder_displayed,
+ close_search_window,
+ open_search_window,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/SearchWindowHelpers.jsm"
+);
+
+var folderA, folderB;
+add_setup(async function () {
+ folderA = await create_folder("MultipleSearchWindowsA");
+ folderB = await create_folder("MultipleSearchWindowsB");
+});
+
+/**
+ * Test bringing up multiple search windows for multiple folders.
+ */
+add_task(
+ async function test_show_multiple_search_windows_for_multiple_folders() {
+ await be_in_folder(folderA);
+
+ let swcA = open_search_window();
+ // Check whether the window's displaying the right folder
+ assert_search_window_folder_displayed(swcA, folderA);
+
+ mc.window.focus();
+ await be_in_folder(folderB);
+ // This should time out if a second search window isn't opened
+ let swcB = open_search_window();
+
+ // Now check whether both windows are displaying the right folders
+ assert_search_window_folder_displayed(swcA, folderA);
+ assert_search_window_folder_displayed(swcB, folderB);
+
+ // Clean up, close both windows
+ close_search_window(swcA);
+ close_search_window(swcB);
+ }
+);
+
+/**
+ * Test bringing up multiple search windows for the same folder.
+ */
+add_task(
+ async function test_show_multiple_search_windows_for_the_same_folder() {
+ await be_in_folder(folderA);
+ let swc1 = open_search_window();
+ // Check whether the window's displaying the right folder
+ assert_search_window_folder_displayed(swc1, folderA);
+
+ mc.window.focus();
+ // This should time out if a second search window isn't opened
+ let swc2 = open_search_window();
+
+ // Now check whether both windows are displaying the right folders
+ assert_search_window_folder_displayed(swc1, folderA);
+ assert_search_window_folder_displayed(swc2, folderA);
+
+ // Clean up, close both windows
+ close_search_window(swc1);
+ close_search_window(swc2);
+ }
+);
diff --git a/comm/mail/test/browser/search-window/browser_rightClickToOpenSearchWindow.js b/comm/mail/test/browser/search-window/browser_rightClickToOpenSearchWindow.js
new file mode 100644
index 0000000000..580379e209
--- /dev/null
+++ b/comm/mail/test/browser/search-window/browser_rightClickToOpenSearchWindow.js
@@ -0,0 +1,63 @@
+/* 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 {
+ assert_folders_selected_and_displayed,
+ create_folder,
+ enter_folder,
+ select_click_folder,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_search_window_folder_displayed,
+ close_search_window,
+ open_search_window,
+ open_search_window_from_context_menu,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/SearchWindowHelpers.jsm"
+);
+
+var folderA, folderB;
+add_setup(async function () {
+ folderA = await create_folder("RightClickToOpenSearchWindowA");
+ folderB = await create_folder("RightClickToOpenSearchWindowB");
+});
+
+/**
+ * Test opening a search window while the same folder is selected.
+ */
+add_task(
+ async function test_open_search_window_with_existing_single_selection() {
+ select_click_folder(folderA);
+ assert_folders_selected_and_displayed(folderA);
+
+ let swc = await open_search_window_from_context_menu(folderA);
+ assert_search_window_folder_displayed(swc, folderA);
+
+ close_search_window(swc);
+ }
+);
+
+/**
+ * Test opening a search window while a different folder is selected.
+ */
+add_task(async function test_open_search_window_with_one_thing_selected() {
+ select_click_folder(folderA);
+ assert_folders_selected_and_displayed(folderA);
+
+ let swc = await open_search_window_from_context_menu(folderB);
+ assert_search_window_folder_displayed(swc, folderB);
+
+ close_search_window(swc);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/search-window/browser_searchFromSyntheticView.js b/comm/mail/test/browser/search-window/browser_searchFromSyntheticView.js
new file mode 100644
index 0000000000..254ede5c17
--- /dev/null
+++ b/comm/mail/test/browser/search-window/browser_searchFromSyntheticView.js
@@ -0,0 +1,110 @@
+/* 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 {
+ add_message_sets_to_folders,
+ be_in_folder,
+ create_folder,
+ create_thread,
+ delete_messages,
+ get_about_3pane,
+ inboxFolder,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+const { SyntheticPartLeaf } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { GlodaMsgIndexer } = ChromeUtils.import(
+ "resource:///modules/gloda/IndexMsg.jsm"
+);
+
+/**
+ * Tests the SearchDialog displays a folder when opened from a synthetic view.
+ * See bug 1664761 and bug 1248522.
+ */
+add_task(async function testSearchDialogFolderSelectedFromSyntheticView() {
+ // Make sure the whole test runs with an unthreaded view in all folders.
+ Services.prefs.setIntPref("mailnews.default_view_flags", 0);
+
+ let folderName = "Test Folder Name";
+ let folder = await create_folder(folderName);
+ let thread = create_thread(3);
+ let term = "atermtosearchfor";
+
+ registerCleanupFunction(async () => {
+ await be_in_folder(inboxFolder);
+ await delete_messages(thread);
+
+ let trash = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ folder.deleteSelf(null);
+ trash.emptyTrash(null);
+
+ let tabmail = document.querySelector("tabmail");
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(1);
+ }
+ Services.prefs.clearUserPref("mailnews.default_view_flags");
+ });
+
+ for (let msg of thread.synMessages) {
+ msg.bodyPart = new SyntheticPartLeaf(term);
+ }
+
+ await be_in_folder(folder);
+ await add_message_sets_to_folders([folder], [thread]);
+
+ await new Promise(callback => {
+ GlodaMsgIndexer.indexFolder(folder, { callback, force: true });
+ });
+
+ let dbView = get_about_3pane().gDBView;
+ await TestUtils.waitForCondition(
+ () =>
+ thread.synMessages.every((_, i) =>
+ window.Gloda.isMessageIndexed(dbView.getMsgHdrAt(i))
+ ),
+ "messages were not indexed in time"
+ );
+
+ let searchInput = window.document.querySelector("#searchInput");
+ searchInput.value = term;
+ EventUtils.synthesizeMouseAtCenter(searchInput, {}, window);
+ EventUtils.synthesizeKey("VK_RETURN", {}, window);
+
+ let tab = document.querySelector(
+ "tabmail>tabbox>tabpanels>vbox[selected=true]"
+ );
+
+ let iframe = tab.querySelector("iframe");
+ await BrowserTestUtils.waitForEvent(iframe.contentWindow, "load");
+
+ let browser = iframe.contentDocument.querySelector("browser");
+ await TestUtils.waitForCondition(
+ () =>
+ browser.contentWindow.FacetContext &&
+ browser.contentWindow.FacetContext.rootWin != null,
+ "reachOutAndTouchFrame() did not run in time"
+ );
+
+ browser.contentDocument.querySelector(".message-subject").click();
+
+ let dialogPromise = BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ return (
+ win.document.documentURI ===
+ "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ });
+ document.querySelector("#searchMailCmd").click();
+
+ let dialogWindow = await dialogPromise;
+ let selectedFolder =
+ dialogWindow.document.querySelector("#searchableFolders").label;
+
+ Assert.ok(selectedFolder.includes(folderName), "a folder is selected");
+ dialogWindow.close();
+});
diff --git a/comm/mail/test/browser/search-window/browser_searchWindow.js b/comm/mail/test/browser/search-window/browser_searchWindow.js
new file mode 100644
index 0000000000..bf414bdf6f
--- /dev/null
+++ b/comm/mail/test/browser/search-window/browser_searchWindow.js
@@ -0,0 +1,358 @@
+/* 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/. */
+
+/*
+ * Tests:
+ * - https://bugzilla.mozilla.org/show_bug.cgi?id=474701#c96 first para
+ */
+
+"use strict";
+
+var {
+ assert_messages_in_view,
+ assert_number_of_tabs_open,
+ assert_selected_and_displayed,
+ assert_tab_mode_name,
+ assert_tab_titled_from,
+ be_in_folder,
+ close_message_window,
+ close_tab,
+ create_folder,
+ make_message_sets_in_folders,
+ mc,
+ open_selected_message,
+ open_selected_messages,
+ plan_for_message_display,
+ reset_open_message_behavior,
+ set_open_message_behavior,
+ switch_tab,
+ wait_for_all_messages_to_load,
+ wait_for_message_display_completion,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ assert_search_window_folder_displayed,
+ open_search_window,
+ assert_messages_in_search_view,
+ select_click_search_row,
+ select_shift_click_search_row,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/SearchWindowHelpers.jsm"
+);
+var {
+ plan_for_modal_dialog,
+ async_plan_for_new_window,
+ plan_for_window_close,
+ wait_for_modal_dialog,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var folder, setFooBar;
+
+// Number of messages to open for multi-message tests
+var NUM_MESSAGES_TO_OPEN = 5;
+
+/**
+ * Create some messages that our constraint below will satisfy
+ */
+add_task(async function test_create_messages() {
+ folder = await create_folder("SearchWindowA");
+ [, , setFooBar] = await make_message_sets_in_folders(
+ [folder],
+ [{ subject: "foo" }, { subject: "bar" }, { subject: "foo bar" }]
+ );
+});
+
+/**
+ * The search window controller.
+ */
+var swc = null;
+
+/**
+ * Bring up the search window.
+ */
+add_task(async function test_show_search_window() {
+ // put us in the folder we care about so it defaults to that
+ await be_in_folder(folder);
+
+ swc = open_search_window();
+ assert_search_window_folder_displayed(swc, folder);
+});
+
+/**
+ * Set up the search.
+ */
+add_task(function test_enter_some_stuff() {
+ // - turn off search subfolders
+ // (we're not testing the UI, direct access is fine)
+ swc.window.document
+ .getElementById("checkSearchSubFolders")
+ .removeAttribute("checked");
+
+ // - put "foo" in the subject contains box
+ // Each filter criterion is a listitem in the listbox with id=searchTermList.
+ // Each filter criterion has id "searchRowN", and the textbox has id
+ // "searchValN" exposing the value on attribute "value".
+ // XXX I am having real difficulty getting the click/type pair to actually
+ // get the text in there reliably. I am just going to poke things directly
+ // into the text widget. (We used to use .aid instead of .a with swc.click
+ // and swc.type.)
+ let searchVal0 = swc.window.document.getElementById("searchVal0");
+ let index = 0;
+
+ if (searchVal0.hasAttribute("selectedIndex")) {
+ index = parseInt(searchVal0.getAttribute("selectedIndex"));
+ }
+
+ searchVal0 = searchVal0.children[index];
+ searchVal0.value = "foo";
+
+ // - add another subject box
+ let plusButton = swc.window.document.querySelector(
+ "#searchRow0 button[label='+']"
+ );
+ EventUtils.synthesizeMouseAtCenter(plusButton, {}, plusButton.ownerGlobal);
+
+ // - put "bar" in it
+ let searchVal1 = swc.window.document.getElementById("searchVal1");
+ index = 0;
+
+ if (searchVal1.hasAttribute("selectedIndex")) {
+ index = parseInt(searchVal1.getAttribute("selectedIndex"));
+ }
+
+ searchVal1 = searchVal1.children[index];
+ searchVal1.value = "bar";
+});
+
+/**
+ * Trigger the search, make sure the right results show up.
+ */
+add_task(function test_go_search() {
+ // - Trigger the search
+ // The "Search" button has id "search-button"
+ EventUtils.synthesizeMouseAtCenter(
+ swc.window.document.getElementById("search-button"),
+ {},
+ swc.window.document.getElementById("search-button").ownerGlobal
+ );
+ wait_for_all_messages_to_load(swc);
+
+ // - Verify we got the right messages
+ assert_messages_in_search_view(setFooBar, swc);
+
+ // - Click the "Save as Search Folder" button, id "saveAsVFButton"
+ // This will create a virtual folder properties dialog...
+ // (label: "New Saved Search Folder", source: virtualFolderProperties.xhtml
+ // no windowtype, id: "virtualFolderPropertiesDialog")
+ plan_for_modal_dialog(
+ "mailnews:virtualFolderProperties",
+ subtest_save_search
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ swc.window.document.getElementById("saveAsVFButton"),
+ {},
+ swc.window.document.getElementById("saveAsVFButton").ownerGlobal
+ );
+ wait_for_modal_dialog("mailnews:virtualFolderProperties");
+});
+
+/**
+ * Test opening a single search result in a new tab.
+ */
+add_task(async function test_open_single_search_result_in_tab() {
+ swc.window.focus();
+ set_open_message_behavior("NEW_TAB");
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ // Select one message
+ swc.window.document.getElementById("threadTree").focus();
+ let msgHdr = select_click_search_row(1, swc);
+ // Open the selected message
+ open_selected_message(swc);
+ // This is going to trigger a message display in the main 3pane window
+ wait_for_message_display_completion(mc);
+ // Check that the tab count has increased by 1
+ assert_number_of_tabs_open(preCount + 1);
+ // Check that the currently displayed tab is a message tab (i.e. our newly
+ // opened tab is in the foreground)
+ assert_tab_mode_name(null, "mailMessageTab");
+ // Check that the message header displayed is the right one
+ assert_selected_and_displayed(msgHdr);
+ // Clean up, close the tab
+ close_tab(mc.window.document.getElementById("tabmail").currentTabInfo);
+ await switch_tab(folderTab);
+ reset_open_message_behavior();
+});
+
+/**
+ * Test opening multiple search results in new tabs.
+ */
+add_task(async function test_open_multiple_search_results_in_new_tabs() {
+ swc.window.focus();
+ set_open_message_behavior("NEW_TAB");
+ let folderTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ // Select a bunch of messages
+ swc.window.document.getElementById("threadTree").focus();
+ select_click_search_row(1, swc);
+ let selectedMessages = select_shift_click_search_row(
+ NUM_MESSAGES_TO_OPEN,
+ swc
+ );
+ // Open them
+ open_selected_messages(swc);
+ // This is going to trigger a message display in the main 3pane window
+ wait_for_message_display_completion(mc, true);
+ // Check that the tab count has increased by the correct number
+ assert_number_of_tabs_open(preCount + NUM_MESSAGES_TO_OPEN);
+ // Check that the currently displayed tab is a message tab (i.e. one of our
+ // newly opened tabs is in the foreground)
+ assert_tab_mode_name(null, "mailMessageTab");
+
+ // Now check whether each of the NUM_MESSAGES_TO_OPEN tabs has the correct
+ // title
+ for (let i = 0; i < NUM_MESSAGES_TO_OPEN; i++) {
+ assert_tab_titled_from(
+ mc.window.document.getElementById("tabmail").tabInfo[preCount + i],
+ selectedMessages[i]
+ );
+ }
+
+ // Check whether each tab has the correct message, then close it to load the
+ // previous tab.
+ for (let i = 0; i < NUM_MESSAGES_TO_OPEN; i++) {
+ assert_selected_and_displayed(selectedMessages.pop());
+ close_tab(mc.window.document.getElementById("tabmail").currentTabInfo);
+ }
+ await switch_tab(folderTab);
+ reset_open_message_behavior();
+});
+
+/**
+ * Test opening a search result in a new window.
+ */
+add_task(async function test_open_search_result_in_new_window() {
+ swc.window.focus();
+ set_open_message_behavior("NEW_WINDOW");
+
+ // Select a message
+ swc.window.document.getElementById("threadTree").focus();
+ let msgHdr = select_click_search_row(1, swc);
+
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ // Open it
+ open_selected_message(swc);
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ assert_selected_and_displayed(msgc, msgHdr);
+ // Clean up, close the window
+ close_message_window(msgc);
+ reset_open_message_behavior();
+});
+
+/**
+ * Test reusing an existing window to open another search result.
+ */
+add_task(async function test_open_search_result_in_existing_window() {
+ swc.window.focus();
+ set_open_message_behavior("EXISTING_WINDOW");
+
+ // Open up a window
+ swc.window.document.getElementById("threadTree").focus();
+ select_click_search_row(1, swc);
+ let newWindowPromise = async_plan_for_new_window("mail:messageWindow");
+ open_selected_message(swc);
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+
+ // Select another message and open it
+ let msgHdr = select_click_search_row(2, swc);
+ plan_for_message_display(msgc);
+ open_selected_message(swc);
+ wait_for_message_display_completion(msgc, true);
+
+ // Check if our old window displays the message
+ assert_selected_and_displayed(msgc, msgHdr);
+ // Clean up, close the window
+ close_message_window(msgc);
+ reset_open_message_behavior();
+});
+
+/**
+ * Save the search, making sure the constraints propagated.
+ */
+function subtest_save_search(savc) {
+ // - make sure our constraint propagated
+ // The query constraints are displayed using the same widgets (and code) that
+ // we used to enter them, so it's very similar to check.
+ let searchVal0 = swc.window.document.getElementById("searchVal0");
+ let index = 0;
+
+ if (searchVal0.hasAttribute("selectedIndex")) {
+ index = parseInt(searchVal0.getAttribute("selectedIndex"));
+ }
+
+ searchVal0 = searchVal0.children[index];
+
+ Assert.ok(searchVal0);
+ Assert.equal(searchVal0.value, "foo");
+
+ let searchVal1 = swc.window.document.getElementById("searchVal1");
+ index = 0;
+
+ if (searchVal1.hasAttribute("selectedIndex")) {
+ index = parseInt(searchVal1.getAttribute("selectedIndex"));
+ }
+
+ searchVal1 = searchVal1.children[index];
+
+ Assert.ok(searchVal1);
+ Assert.equal(searchVal1.value, "bar");
+
+ // - name the search
+ savc.window.document.getElementById("name").focus();
+ EventUtils.sendString("SearchSaved", savc.window);
+
+ // - save it!
+ // this will close the dialog, which wait_for_modal_dialog is making sure
+ // happens.
+ savc.window.document.querySelector("dialog").acceptDialog();
+}
+
+add_task(function test_close_search_window() {
+ swc.window.focus();
+ // now close the search window
+ plan_for_window_close(swc);
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, swc.window);
+ wait_for_window_close(swc);
+ swc = null;
+});
+
+/**
+ * Make sure the folder showed up with the right name, and that displaying it
+ * has the right contents.
+ */
+add_task(async function test_verify_saved_search() {
+ let savedFolder = folder.getChildNamed("SearchSaved");
+ if (savedFolder == null) {
+ throw new Error("Saved folder did not show up.");
+ }
+
+ await be_in_folder(savedFolder);
+ assert_messages_in_view(setFooBar);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
diff --git a/comm/mail/test/browser/session-store/browser.ini b/comm/mail/test/browser/session-store/browser.ini
new file mode 100644
index 0000000000..e4fcbec796
--- /dev/null
+++ b/comm/mail/test/browser/session-store/browser.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_sessionStore.js]
diff --git a/comm/mail/test/browser/session-store/browser_sessionStore.js b/comm/mail/test/browser/session-store/browser_sessionStore.js
new file mode 100644
index 0000000000..92a14a51fa
--- /dev/null
+++ b/comm/mail/test/browser/session-store/browser_sessionStore.js
@@ -0,0 +1,680 @@
+/* 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/. */
+
+/**
+ * Session Storage Tests. Session Restoration Tests are currently implemented in
+ * folder-display/browser_messagePaneVisibility.js.
+ */
+
+"use strict";
+
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var {
+ assert_message_pane_hidden,
+ assert_message_pane_visible,
+ assert_pane_layout,
+ be_in_folder,
+ create_folder,
+ kClassicMailLayout,
+ kVerticalMailLayout,
+ make_message_sets_in_folders,
+ mc,
+ set_mc,
+ set_pane_layout,
+ toggle_message_pane,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ close_window,
+ plan_for_new_window,
+ plan_for_window_close,
+ wait_for_new_window,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { SessionStoreManager } = ChromeUtils.import(
+ "resource:///modules/SessionStoreManager.jsm"
+);
+
+var folderA, folderB;
+
+// Default JSONFile save delay with saveSoon().
+var kSaveDelayMs = 1500;
+
+// With async file writes, use a delay larger than the session autosave timer.
+var asyncFileWriteDelayMS = 3000;
+
+/* ........ Helper Functions ................*/
+
+/**
+ * Reads the contents of the session file into a JSON object.
+ */
+async function readFile2() {
+ try {
+ return await IOUtils.readJSON(SessionStoreManager.sessionFile.path);
+ } catch (ex) {
+ if (!["NotFoundError"].includes(ex.name)) {
+ console.error(ex);
+ }
+ // fall through and return null if the session file cannot be read
+ // or is bad
+ dump(ex + "\n");
+ }
+ return null;
+}
+
+/**
+ * Reads the contents of the session file into a JSON object.
+ * FIXME: readFile2 should really be used instead. For some weird reason using
+ * that, OR making this function async (+ await the results) will
+ * not work - seem like the file reading just dies (???)
+ * So use the sync file reading for now...
+ */
+function readFile() {
+ let data = mailTestUtils.loadFileToString(SessionStoreManager.sessionFile);
+ return JSON.parse(data);
+}
+
+async function waitForFileRefresh() {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, kSaveDelayMs));
+ TestUtils.waitForCondition(
+ () => SessionStoreManager.sessionFile.exists(),
+ "session file should exist"
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, asyncFileWriteDelayMS));
+}
+
+function open3PaneWindow() {
+ plan_for_new_window("mail:3pane");
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messenger.xhtml",
+ "",
+ "all,chrome,dialog=no,status,toolbar",
+ null
+ );
+ return wait_for_new_window("mail:3pane");
+}
+
+function openActivityManager() {
+ plan_for_new_window("Activity:Manager");
+ window.openActivityMgr();
+ return wait_for_new_window("Activity:Manager");
+}
+
+/* :::::::: The Tests ::::::::::::::: */
+
+add_setup(async function () {
+ folderA = await create_folder("SessionStoreA");
+ await make_message_sets_in_folders([folderA], [{ count: 3 }]);
+
+ folderB = await create_folder("SessionStoreB");
+ await make_message_sets_in_folders([folderB], [{ count: 3 }]);
+
+ SessionStoreManager.stopPeriodicSave();
+
+ // Opt out of calendar promotion so we don't show the "ligthing now
+ // integrated" notification bar (which gives us unexpected heights).
+ Services.prefs.setBoolPref("calendar.integration.notify", false);
+});
+
+registerCleanupFunction(function () {
+ folderA.server.rootFolder.propagateDelete(folderA, true);
+ folderB.server.rootFolder.propagateDelete(folderB, true);
+
+ // 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();
+});
+
+add_task(async function test_periodic_session_persistence_simple() {
+ // delete the session file if it exists
+ let sessionFile = SessionStoreManager.sessionFile;
+ if (sessionFile.exists()) {
+ sessionFile.remove(false);
+ }
+
+ utils.waitFor(() => !sessionFile.exists(), "session file should not exist");
+
+ // change some state to guarantee the file will be recreated
+ // if periodic session persistence works
+ await be_in_folder(folderA);
+
+ // if periodic session persistence is working, the file should be
+ // re-created
+ SessionStoreManager._saveState();
+ await waitForFileRefresh();
+});
+
+add_task(async function test_periodic_nondirty_session_persistence() {
+ // This changes state.
+ await be_in_folder(folderB);
+
+ SessionStoreManager._saveState();
+ await waitForFileRefresh();
+
+ // delete the session file
+ let sessionFile = SessionStoreManager.sessionFile;
+ sessionFile.remove(false);
+
+ // Since the state of the session hasn't changed since last _saveState(),
+ // the session file should not be re-created.
+ SessionStoreManager._saveState();
+
+ await new Promise(resolve =>
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, kSaveDelayMs + asyncFileWriteDelayMS)
+ );
+
+ utils.waitFor(() => !sessionFile.exists(), "session file should not exist");
+});
+
+add_task(async function test_single_3pane_periodic_session_persistence() {
+ await be_in_folder(folderA);
+
+ // get the state object. this assumes there is one and only one
+ // 3pane window.
+ let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ let state = mail3PaneWindow.getWindowStateForSessionPersistence();
+
+ SessionStoreManager._saveState();
+ await waitForFileRefresh();
+
+ // load the saved state from disk
+ let loadedState = readFile();
+ Assert.ok(loadedState, "previously saved state should be non-null");
+
+ // get the state object for the one and only one 3pane window
+ let windowState = loadedState.windows[0];
+ Assert.ok(
+ JSON.stringify(windowState) == JSON.stringify(state),
+ "saved state and loaded state should be equal"
+ );
+});
+
+async function test_restore_single_3pane_persistence() {
+ await be_in_folder(folderA);
+ toggle_message_pane();
+ assert_message_pane_hidden();
+
+ // get the state object. this assumes there is one and only one
+ // 3pane window.
+ let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ // make sure we have a different window open, so that we don't start shutting
+ // down just because the last window was closed
+ let amController = openActivityManager();
+
+ // close the 3pane window
+ mail3PaneWindow.close();
+ // Wait for window close async session write to finish.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, asyncFileWriteDelayMS));
+
+ mc = open3PaneWindow();
+ set_mc(mc);
+ await be_in_folder(folderA);
+ assert_message_pane_hidden();
+ // restore message pane.
+ toggle_message_pane();
+
+ // We don't need the address book window any more.
+ plan_for_window_close(amController);
+ amController.window.close();
+ wait_for_window_close();
+}
+add_task(test_restore_single_3pane_persistence).skip(); // Bug 1753963.
+
+add_task(async function test_restore_single_3pane_persistence_again() {
+ // test that repeating the save w/o changing the state restores
+ // correctly.
+ await test_restore_single_3pane_persistence();
+}).skip(); // Bug 1753963.
+
+add_task(async function test_message_pane_height_persistence() {
+ await be_in_folder(folderA);
+ assert_message_pane_visible();
+ assert_pane_layout(kClassicMailLayout);
+
+ // Get the state object. This assumes there is one and only one
+ // 3pane window.
+ let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ let oldHeight = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientHeight;
+ let minHeight = Math.floor(
+ mc.window.document
+ .getElementById("messagepaneboxwrapper")
+ .getAttribute("minheight")
+ );
+ let newHeight = Math.floor((minHeight + oldHeight) / 2);
+ let diffHeight = oldHeight - newHeight;
+
+ Assert.notEqual(
+ oldHeight,
+ newHeight,
+ "To really perform a test the new message pane height should be " +
+ "should be different from the old one but they are the same: " +
+ newHeight
+ );
+
+ _move_splitter(
+ mc.window.document.getElementById("threadpane-splitter"),
+ 0,
+ diffHeight
+ );
+
+ // Check that the moving of the threadpane-splitter resulted in the correct height.
+ let actualHeight = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientHeight;
+
+ Assert.equal(
+ newHeight,
+ actualHeight,
+ "The message pane height should be " +
+ newHeight +
+ ", but is actually " +
+ actualHeight +
+ ". The oldHeight was: " +
+ oldHeight
+ );
+
+ // Make sure we have a different window open, so that we don't start shutting
+ // down just because the last window was closed.
+ let amController = openActivityManager();
+
+ // The 3pane window is closed.
+ mail3PaneWindow.close();
+ // Wait for window close async session write to finish.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, asyncFileWriteDelayMS));
+
+ mc = open3PaneWindow();
+ set_mc(mc);
+ await be_in_folder(folderA);
+ assert_message_pane_visible();
+
+ actualHeight = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientHeight;
+
+ Assert.equal(
+ newHeight,
+ actualHeight,
+ "The message pane height should be " +
+ newHeight +
+ ", but is actually " +
+ actualHeight +
+ ". The oldHeight was: " +
+ oldHeight
+ );
+
+ // The old height is restored.
+ _move_splitter(
+ mc.window.document.getElementById("threadpane-splitter"),
+ 0,
+ -diffHeight
+ );
+
+ // The 3pane window is closed.
+ close_window(mc);
+ // Wait for window close async session write to finish.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, asyncFileWriteDelayMS));
+
+ mc = open3PaneWindow();
+ set_mc(mc);
+ await be_in_folder(folderA);
+ assert_message_pane_visible();
+
+ actualHeight = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientHeight;
+ Assert.equal(
+ oldHeight,
+ actualHeight,
+ "The message pane height should be " +
+ oldHeight +
+ ", but is actually " +
+ actualHeight
+ );
+
+ // We don't need the address book window any more.
+ plan_for_window_close(amController);
+ amController.window.close();
+ wait_for_window_close();
+}).skip(); // Bug 1753963.
+
+add_task(async function test_message_pane_width_persistence() {
+ await be_in_folder(folderA);
+ assert_message_pane_visible();
+
+ // At the beginning we are in classic layout. We will switch to
+ // vertical layout to test the width, and then back to classic layout.
+ assert_pane_layout(kClassicMailLayout);
+ set_pane_layout(kVerticalMailLayout);
+ assert_pane_layout(kVerticalMailLayout);
+
+ // Get the state object. This assumes there is one and only one
+ // 3pane window.
+ let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ let oldWidth = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientWidth;
+ let minWidth = Math.floor(
+ mc.window.document
+ .getElementById("messagepaneboxwrapper")
+ .getAttribute("minwidth")
+ );
+ let newWidth = Math.floor((minWidth + oldWidth) / 2);
+ let diffWidth = oldWidth - newWidth;
+
+ Assert.notEqual(
+ newWidth,
+ oldWidth,
+ "To really perform a test the new message pane width should be " +
+ "should be different from the old one but they are the same: " +
+ newWidth
+ );
+
+ // We move the threadpane-splitter and not the folderpane_splitter because
+ // we are in vertical layout.
+ _move_splitter(
+ mc.window.document.getElementById("threadpane-splitter"),
+ diffWidth,
+ 0
+ );
+ // Check that the moving of the folderpane_splitter resulted in the correct width.
+ let actualWidth = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientWidth;
+
+ // FIXME: For whatever reasons the new width is off by one pixel on Mac OSX
+ // But this test case is not for testing moving around a splitter but for
+ // persistency. Therefore it is enough if the actual width is equal to the
+ // the requested width plus/minus one pixel.
+ assert_equals_fuzzy(
+ newWidth,
+ actualWidth,
+ 1,
+ "The message pane width should be " +
+ newWidth +
+ ", but is actually " +
+ actualWidth +
+ ". The oldWidth was: " +
+ oldWidth
+ );
+ newWidth = actualWidth;
+
+ // Make sure we have a different window open, so that we don't start shutting
+ // down just because the last window was closed
+ let amController = openActivityManager();
+
+ // The 3pane window is closed.
+ mail3PaneWindow.close();
+ // Wait for window close async session write to finish.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, asyncFileWriteDelayMS));
+
+ mc = open3PaneWindow();
+ set_mc(mc);
+ await be_in_folder(folderA);
+ assert_message_pane_visible();
+ assert_pane_layout(kVerticalMailLayout);
+
+ actualWidth = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientWidth;
+ Assert.equal(
+ newWidth,
+ actualWidth,
+ "The message pane width should be " +
+ newWidth +
+ ", but is actually " +
+ actualWidth
+ );
+
+ // The old width is restored.
+ _move_splitter(
+ mc.window.document.getElementById("threadpane-splitter"),
+ -diffWidth,
+ 0
+ );
+ actualWidth = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientWidth;
+
+ // FIXME: For whatever reasons the new width is off by two pixels on Mac OSX
+ // But this test case is not for testing moving around a splitter but for
+ // persistency. Therefore it is enough if the actual width is equal to the
+ // the requested width plus/minus two pixels.
+ assert_equals_fuzzy(
+ oldWidth,
+ actualWidth,
+ 2,
+ "The message pane width should be " +
+ oldWidth +
+ ", but is actually " +
+ actualWidth
+ );
+ oldWidth = actualWidth;
+
+ // The 3pane window is closed.
+ close_window(mc);
+ // Wait for window close async session write to finish.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, asyncFileWriteDelayMS));
+
+ mc = open3PaneWindow();
+ set_mc(mc);
+ await be_in_folder(folderA);
+ assert_message_pane_visible();
+ assert_pane_layout(kVerticalMailLayout);
+
+ actualWidth = mc.window.document.getElementById(
+ "messagepaneboxwrapper"
+ ).clientWidth;
+ Assert.equal(
+ oldWidth,
+ actualWidth,
+ "The message pane width should be " +
+ oldWidth +
+ ", but is actually " +
+ actualWidth
+ );
+
+ // The layout is reset to classical mail layout.
+ set_pane_layout(kClassicMailLayout);
+ assert_pane_layout(kClassicMailLayout);
+
+ // We don't need the address book window any more.
+ plan_for_window_close(amController);
+ amController.window.close();
+ wait_for_window_close();
+}).skip(); // Bug 1753963.
+
+add_task(async function test_multiple_3pane_periodic_session_persistence() {
+ // open a few more 3pane windows
+ for (var i = 0; i < 3; ++i) {
+ open3PaneWindow();
+ }
+
+ // then get the state objects for each window
+ let state = [];
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ state.push(window.getWindowStateForSessionPersistence());
+ }
+
+ SessionStoreManager._saveState();
+ await waitForFileRefresh();
+
+ // load the saved state from disk
+ let loadedState = readFile();
+
+ Assert.ok(loadedState, "previously saved state should be non-null");
+
+ Assert.equal(
+ loadedState.windows.length,
+ state.length,
+ "number of windows in saved state and loaded state should be equal"
+ );
+
+ for (let i = 0; i < state.length; ++i) {
+ Assert.ok(
+ JSON.stringify(loadedState.windows[i]) == JSON.stringify(state[i]),
+ "saved state and loaded state should be equal"
+ );
+ }
+
+ // close all but one 3pane window
+ let windows = Services.wm.getEnumerator("mail:3pane");
+ for (let win of windows) {
+ win.close();
+ }
+}).skip(); // Bug 1753963.
+
+async function test_bad_session_file_simple() {
+ // forcefully write a bad session file
+ let data = "BAD SESSION FILE";
+ let fos = FileUtils.openSafeFileOutputStream(SessionStoreManager.sessionFile);
+ fos.write(data, data.length);
+ FileUtils.closeSafeFileOutputStream(fos);
+
+ // tell the session store manager to try loading the bad session file.
+ // NOTE: periodic session persistence is not enabled in this test
+ SessionStoreManager._store = null;
+ await SessionStoreManager._loadSessionFile();
+
+ // since the session file is bad, the session store manager's state field
+ // should be null
+ Assert.ok(
+ !SessionStoreManager._initialState,
+ "saved state is bad so state object should be null"
+ );
+
+ // The bad session file should now not exist.
+ utils.waitFor(
+ () => !SessionStoreManager.sessionFile.exists(),
+ "session file should now not exist"
+ );
+}
+
+add_task(async function test_clean_shutdown_session_persistence_simple() {
+ // open a few more 3pane windows
+ for (var i = 0; i < 3; ++i) {
+ open3PaneWindow();
+ }
+
+ // make sure we have a different window open, so that we don't start shutting
+ // down just because the last window was closed
+ let amController = openActivityManager();
+
+ // close all the 3pane windows
+ let lastWindowState = null;
+ let enumerator = Services.wm.getEnumerator("mail:3pane");
+ for (let window of enumerator) {
+ if (!enumerator.hasMoreElements()) {
+ lastWindowState = window.getWindowStateForSessionPersistence();
+ }
+ window.close();
+ }
+
+ // Wait for session file to be created (removed in prior test) after
+ // all 3pane windows close and for session write to finish.
+ await waitForFileRefresh();
+
+ // load the saved state from disk
+ let loadedState = readFile();
+ Assert.ok(loadedState, "previously saved state should be non-null");
+
+ Assert.equal(
+ loadedState.windows.length,
+ 1,
+ "only the state of the last 3pane window should have been saved"
+ );
+
+ // get the state object for the one and only one 3pane window
+ let windowState = loadedState.windows[0];
+ Assert.ok(
+ JSON.stringify(windowState) == JSON.stringify(lastWindowState),
+ "saved state and loaded state should be equal"
+ );
+
+ open3PaneWindow();
+
+ // We don't need the address book window any more.
+ plan_for_window_close(amController);
+ amController.window.close();
+ wait_for_window_close();
+}).skip(); // Bug 1753963.
+
+/*
+ * A set of private helper functions for drag'n'drop
+ * These functions are inspired by tabmail/test-tabmail-dragndrop.js
+ */
+
+function _move_splitter(aSplitter, aDiffX, aDiffY) {
+ // catch the splitter in the middle
+ let rect = aSplitter.getBoundingClientRect();
+ let middleX = Math.round(rect.width / 2);
+ let middleY = Math.round(rect.height / 2);
+ EventUtils.synthesizeMouse(
+ aSplitter,
+ middleX,
+ middleY,
+ { type: "mousedown" },
+ mc.window
+ );
+ EventUtils.synthesizeMouse(
+ aSplitter,
+ aDiffX + middleX,
+ aDiffY + middleY,
+ { type: "mousemove" },
+ mc.window
+ );
+ // release the splitter
+ EventUtils.synthesizeMouse(aSplitter, 0, 0, { type: "mouseup" }, mc.window);
+}
+
+/**
+ * Helper function that checks the fuzzy equivalence of two numeric
+ * values against some given tolerance.
+ *
+ * @param aLeft one value to check equivalence with
+ * @param aRight the other value to check equivalence with
+ * @param aTolerance how fuzzy can our equivalence be?
+ * @param aMessage the message to give off if we're outside of tolerance.
+ */
+function assert_equals_fuzzy(aLeft, aRight, aTolerance, aMessage) {
+ Assert.ok(Math.abs(aLeft - aRight) <= aTolerance, aMessage);
+}
+
+// XXX todo
+// - crash test: not sure if this test should be here. restoring a crashed
+// session depends on periodically saved session data (there is
+// already a test for this). session restoration tests do not
+// belong here. see test-message-pane-visibility.
+// when testing restoration in test-message-pane-visibility, also
+// include test of bad session file.
+// ..............maybe we should move all session restoration related tests
+// ..............here.
diff --git a/comm/mail/test/browser/shared-modules/.eslintrc.js b/comm/mail/test/browser/shared-modules/.eslintrc.js
new file mode 100644
index 0000000000..e57058ecb1
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+};
diff --git a/comm/mail/test/browser/shared-modules/AccountManagerHelpers.jsm b/comm/mail/test/browser/shared-modules/AccountManagerHelpers.jsm
new file mode 100644
index 0000000000..5cd8d6d6bc
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/AccountManagerHelpers.jsm
@@ -0,0 +1,204 @@
+/* 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 = [
+ "openAccountProvisioner",
+ "openAccountSetup",
+ "openAccountSettings",
+ "open_advanced_settings",
+ "click_account_tree_row",
+ "get_account_tree_row",
+ "remove_account",
+ "wait_for_account_tree_load",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var fdh = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var wh = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { content_tab_e, open_content_tab_with_url, wait_for_content_tab_load } =
+ ChromeUtils.import("resource://testing-common/mozmill/ContentTabHelpers.jsm");
+
+var mc = fdh.mc;
+
+/**
+ * Waits until the Account Manager tree fully loads after first open.
+ */
+function wait_for_account_tree_load(tab) {
+ utils.waitFor(
+ () => tab.browser.contentWindow.currentAccount != null,
+ "Timeout waiting for currentAccount to become non-null"
+ );
+}
+
+async function openAccountSettings() {
+ return new Promise(resolve => {
+ let tab = open_content_tab_with_url("about:accountsettings");
+ wait_for_account_tree_load(tab);
+ resolve(tab);
+ });
+}
+
+/**
+ * Opens the Account Manager.
+ *
+ * @callback tabCallback
+ *
+ * @param {tabCallback} callback - The callback for the account manager tab that is opened.
+ */
+async function open_advanced_settings(callback) {
+ let tab = open_content_tab_with_url("about:accountsettings");
+ wait_for_account_tree_load(tab);
+ await callback(tab);
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+}
+
+async function openAccountSetup() {
+ return new Promise(resolve => {
+ let tab = open_content_tab_with_url("about:accountsetup");
+ wait_for_content_tab_load(tab, "about:accountsetup", 10000);
+ resolve(tab);
+ });
+}
+
+async function openAccountProvisioner() {
+ return new Promise(resolve => {
+ let tab = open_content_tab_with_url("about:accountprovisioner");
+ wait_for_content_tab_load(tab, "about:accountprovisioner", 10000);
+ resolve(tab);
+ });
+}
+
+/**
+ * Click a row in the account settings tree.
+ *
+ * @param {object} tab - The account manager tab controller that opened.
+ * @param {number} rowIndex - The row to click.
+ */
+function click_account_tree_row(tab, rowIndex) {
+ utils.waitFor(
+ () => tab.browser.contentWindow.currentAccount != null,
+ "Timeout waiting for currentAccount to become non-null"
+ );
+
+ let tree = content_tab_e(tab, "accounttree");
+ tree.selectedIndex = rowIndex;
+
+ utils.waitFor(
+ () => tab.browser.contentWindow.pendingAccount == null,
+ "Timeout waiting for pendingAccount to become null"
+ );
+
+ // Ensure the page is fully loaded (e.g. onInit functions).
+ wh.wait_for_frame_load(
+ content_tab_e(tab, "contentFrame"),
+ tab.browser.contentWindow.pageURL(
+ tree.rows[rowIndex].getAttribute("PageTag")
+ )
+ );
+}
+
+/**
+ * Returns the index of the row in account tree corresponding to the wanted
+ * account and its settings pane.
+ *
+ * @param {number} accountKey - The key of the account to return.
+ * If 'null', the SMTP pane is returned.
+ * @param {number} paneId - The ID of the account settings pane to select.
+ *
+ *
+ * @returns {number} The row index of the account and pane. If it was not found return -1.
+ * Do not throw as callers may intentionally just check if a row exists.
+ * Just dump into the log so that a subsequent throw in
+ * click_account_tree_row has a useful context.
+ */
+function get_account_tree_row(accountKey, paneId, tab) {
+ let accountTree = content_tab_e(tab, "accounttree");
+ let row;
+ if (accountKey && paneId) {
+ row = accountTree.querySelector(`#${accountKey} [PageTag="${paneId}"]`);
+ } else if (accountKey) {
+ row = accountTree.querySelector(`#${accountKey}`);
+ }
+ return accountTree.rows.indexOf(row);
+}
+
+/**
+ * Remove an account via the account manager UI.
+ *
+ * @param {object} account - The account to remove.
+ * @param {object} tab - The account manager tab that opened.
+ * @param {boolean} removeAccount - Remove the account itself.
+ * @param {boolean} removeData - Remove the message data of the account.
+ */
+function remove_account(
+ account,
+ tab,
+ removeAccount = true,
+ removeData = false
+) {
+ let accountRow = get_account_tree_row(account.key, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ account = null;
+ // Use the Remove item in the Account actions menu.
+ let actionsButton = content_tab_e(tab, "accountActionsButton");
+ EventUtils.synthesizeMouseAtCenter(
+ actionsButton,
+ { clickCount: 1 },
+ actionsButton.ownerGlobal
+ );
+ let actionsDd = content_tab_e(tab, "accountActionsDropdown");
+ utils.waitFor(
+ () => actionsDd.state == "open" || actionsDd.state == "showing"
+ );
+ let remove = content_tab_e(tab, "accountActionsDropdownRemove");
+ EventUtils.synthesizeMouseAtCenter(
+ remove,
+ { clickCount: 1 },
+ remove.ownerGlobal
+ );
+ utils.waitFor(() => actionsDd.state == "closed");
+
+ let cdc = wh.wait_for_frame_load(
+ tab.browser.contentWindow.gSubDialog._topDialog._frame,
+ "chrome://messenger/content/removeAccount.xhtml"
+ );
+
+ // Account removal confirmation dialog. Select what to remove.
+ if (removeAccount) {
+ EventUtils.synthesizeMouseAtCenter(
+ cdc.window.document.getElementById("removeAccount"),
+ {},
+ cdc.window.document.getElementById("removeAccount").ownerGlobal
+ );
+ }
+ if (removeData) {
+ EventUtils.synthesizeMouseAtCenter(
+ cdc.window.document.getElementById("removeData"),
+ {},
+ cdc.window.document.getElementById("removeData").ownerGlobal
+ );
+ }
+
+ cdc.window.document.documentElement.querySelector("dialog").acceptDialog();
+ utils.waitFor(
+ () =>
+ !cdc.window.document.querySelector("dialog").getButton("accept").disabled,
+ "Timeout waiting for finish of account removal",
+ 5000,
+ 100
+ );
+ cdc.window.document.documentElement.querySelector("dialog").acceptDialog();
+}
diff --git a/comm/mail/test/browser/shared-modules/AddressBookHelpers.jsm b/comm/mail/test/browser/shared-modules/AddressBookHelpers.jsm
new file mode 100644
index 0000000000..cce1fd81a1
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/AddressBookHelpers.jsm
@@ -0,0 +1,182 @@
+/* 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 = [
+ "create_address_book",
+ "create_contact",
+ "create_ldap_address_book",
+ "create_mailing_list",
+ "delete_address_book",
+ "ensure_card_exists",
+ "ensure_no_card_exists",
+ "get_cards_in_all_address_books_for_email",
+ "get_mailing_list_from_address_book",
+ "load_contacts_into_address_book",
+];
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var ABJS_PREFIX = "jsaddrbook://";
+var ABLDAP_PREFIX = "moz-abldapdirectory://";
+
+var collectedAddresses;
+
+// Ensure all the directories are initialised.
+MailServices.ab.directories;
+collectedAddresses = MailServices.ab.getDirectory(
+ "jsaddrbook://history.sqlite"
+);
+
+/**
+ * Make sure that there is a card for this email address
+ *
+ * @param emailAddress the address that should have a card
+ * @param displayName the display name the card should have
+ * @param preferDisplayName |true| if the card display name should override the
+ * header display name
+ */
+function ensure_card_exists(emailAddress, displayName, preferDisplayName) {
+ ensure_no_card_exists(emailAddress);
+ let card = create_contact(emailAddress, displayName, preferDisplayName);
+ collectedAddresses.addCard(card);
+}
+
+/**
+ * Make sure that there is no card for this email address
+ *
+ * @param emailAddress the address that should have no cards
+ */
+function ensure_no_card_exists(emailAddress) {
+ for (let ab of MailServices.ab.directories) {
+ try {
+ var card = ab.cardForEmailAddress(emailAddress);
+ if (card) {
+ ab.deleteCards([card]);
+ }
+ } catch (ex) {}
+ }
+}
+
+/**
+ * Return all address book cards for a particular email address
+ *
+ * @param aEmailAddress the address to search for
+ */
+function get_cards_in_all_address_books_for_email(aEmailAddress) {
+ var result = [];
+
+ for (let ab of MailServices.ab.directories) {
+ var card = ab.cardForEmailAddress(aEmailAddress);
+ if (card) {
+ result.push(card);
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Creates and returns a SQLite-backed address book.
+ *
+ * @param aName the name for the address book
+ * @returns the nsIAbDirectory address book
+ */
+function create_address_book(aName) {
+ let abPrefString = MailServices.ab.newAddressBook(aName, "", 101);
+ let abURI = Services.prefs.getCharPref(abPrefString + ".filename");
+ return MailServices.ab.getDirectory(ABJS_PREFIX + abURI);
+}
+
+/**
+ * Creates and returns an LDAP-backed address book.
+ * This function will automatically fill in a dummy
+ * LDAP URI if no URI is supplied.
+ *
+ * @param aName the name for the address book
+ * @param aURI an optional URI for the address book
+ * @returns the nsIAbDirectory address book
+ */
+function create_ldap_address_book(aName, aURI) {
+ if (!aURI) {
+ aURI = "ldap://dummyldap/??sub?(objectclass=*)";
+ }
+ let abPrefString = MailServices.ab.newAddressBook(aName, aURI, 0);
+ return MailServices.ab.getDirectory(ABLDAP_PREFIX + abPrefString);
+}
+
+/**
+ * Creates and returns an address book contact
+ *
+ * @param aEmailAddress the e-mail address for this contact
+ * @param aDisplayName the display name for the contact
+ * @param aPreferDisplayName set to true if the card display name should
+ * override the header display name
+ */
+function create_contact(aEmailAddress, aDisplayName, aPreferDisplayName) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.primaryEmail = aEmailAddress;
+ card.displayName = aDisplayName;
+ card.setProperty("PreferDisplayName", !!aPreferDisplayName);
+ return card;
+}
+
+/* Creates and returns a mailing list
+ * @param aMailingListName the display name for the new mailing list
+ */
+function create_mailing_list(aMailingListName) {
+ var mailList = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ mailList.isMailList = true;
+ mailList.dirName = aMailingListName;
+ return mailList;
+}
+
+/* Finds and returns a mailing list with a given dirName within a
+ * given address book.
+ * @param aAddressBook the address book to search
+ * @param aDirName the dirName of the mailing list
+ */
+function get_mailing_list_from_address_book(aAddressBook, aDirName) {
+ for (let list of aAddressBook.childNodes) {
+ if (list.dirName == aDirName) {
+ return list;
+ }
+ }
+ throw Error("Could not find a mailing list with dirName " + aDirName);
+}
+
+/* Given some address book, adds a collection of contacts to that
+ * address book.
+ * @param aAddressBook an address book to add the contacts to
+ * @param aContacts a collection of nsIAbCards, or contacts,
+ * where each contact has members "email"
+ * and "displayName"
+ *
+ * Example:
+ * [{email: 'test@example.com', displayName: 'Sammy Jenkis'}]
+ */
+function load_contacts_into_address_book(aAddressBook, aContacts) {
+ for (let i = 0; i < aContacts.length; i++) {
+ let contact = aContacts[i];
+ if (!(contact instanceof Ci.nsIAbCard)) {
+ contact = create_contact(contact.email, contact.displayName, true);
+ }
+
+ aContacts[i] = aAddressBook.addCard(contact);
+ }
+}
+
+/**
+ * Deletes an address book.
+ */
+function delete_address_book(aAddrBook) {
+ MailServices.ab.deleteAddressBook(aAddrBook.URI);
+}
diff --git a/comm/mail/test/browser/shared-modules/AttachmentHelpers.jsm b/comm/mail/test/browser/shared-modules/AttachmentHelpers.jsm
new file mode 100644
index 0000000000..971b25fa77
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/AttachmentHelpers.jsm
@@ -0,0 +1,240 @@
+/* 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 = [
+ "create_body_part",
+ "create_deleted_attachment",
+ "create_detached_attachment",
+ "create_enclosure_attachment",
+ "gMockFilePicker",
+ "gMockFilePickReg",
+ "select_attachments",
+];
+
+var { MockObjectReplacer } = ChromeUtils.import(
+ "resource://testing-common/mozmill/MockObjectHelpers.jsm"
+);
+
+var gMockFilePickReg = new MockObjectReplacer(
+ "@mozilla.org/filepicker;1",
+ MockFilePickerConstructor
+);
+
+function MockFilePickerConstructor() {
+ return gMockFilePicker;
+}
+
+var gMockFilePicker = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFilePicker"]),
+ defaultExtension: "",
+ filterIndex: null,
+ displayDirectory: null,
+ returnFiles: [],
+ addToRecentDocs: false,
+
+ get defaultString() {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ },
+
+ get fileURL() {
+ return null;
+ },
+
+ get file() {
+ if (this.returnFiles.length >= 1) {
+ return this.returnFiles[0];
+ }
+ return null;
+ },
+
+ get files() {
+ let self = this;
+ return {
+ index: 0,
+ QueryInterface: ChromeUtils.generateQI(["nsISimpleEnumerator"]),
+ hasMoreElements() {
+ return this.index < self.returnFiles.length;
+ },
+ getNext() {
+ return self.returnFiles[this.index++];
+ },
+ [Symbol.iterator]() {
+ return self.returnFiles.values();
+ },
+ };
+ },
+
+ init(aParent, aTitle, aMode) {},
+
+ appendFilters(aFilterMask) {},
+
+ appendFilter(aTitle, aFilter) {},
+
+ open(aFilePickerShownCallback) {
+ aFilePickerShownCallback.done(Ci.nsIFilePicker.returnOK);
+ },
+
+ set defaultString(aVal) {},
+};
+
+/**
+ * Create a body part with attachments for the message generator
+ *
+ * @param body the text of the main body of the message
+ * @param attachments an array of attachment objects (as strings)
+ * @param boundary an optional string defining the boundary of the parts
+ * @returns an object suitable for passing as the |bodyPart| for create_message
+ */
+function create_body_part(body, attachments, boundary) {
+ if (!boundary) {
+ boundary = "------------CHOPCHOP";
+ }
+
+ return {
+ contentTypeHeaderValue: 'multipart/mixed;\r\n boundary="' + boundary + '"',
+ toMessageString() {
+ let str =
+ "This is a multi-part message in MIME format.\r\n" +
+ "--" +
+ boundary +
+ "\r\n" +
+ "Content-Type: text/plain; charset=ISO-8859-1; " +
+ "format=flowed\r\n" +
+ "Content-Transfer-Encoding: 7bit\r\n\r\n" +
+ body +
+ "\r\n\r\n";
+
+ for (let i = 0; i < attachments.length; i++) {
+ str += "--" + boundary + "\r\n" + attachments[i] + "\r\n";
+ }
+
+ str += "--" + boundary + "--";
+ return str;
+ },
+ };
+}
+
+function help_create_detached_deleted_attachment(filename, type) {
+ return (
+ "You deleted an attachment from this message. The original MIME " +
+ "headers for the attachment were:\r\n" +
+ "Content-Type: " +
+ type +
+ ";\r\n" +
+ ' name="' +
+ filename +
+ '"\r\n' +
+ "Content-Transfer-Encoding: 7bit\r\n" +
+ "Content-Disposition: attachment;\r\n" +
+ ' filename="' +
+ filename +
+ '"\r\n\r\n'
+ );
+}
+
+/**
+ * Create the raw data for a detached attachment
+ *
+ * @param file an nsIFile for the external file for the attachment
+ * @param type the content type
+ * @returns a string representing the attachment
+ */
+function create_detached_attachment(file, type) {
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let url = fileHandler.getURLSpecFromActualFile(file);
+ let filename = file.leafName;
+
+ let str =
+ 'Content-Type: text/plain;\r\n name="' +
+ filename +
+ '"\r\n' +
+ 'Content-Disposition: attachment; filename="' +
+ filename +
+ '"\r\n' +
+ "X-Mozilla-External-Attachment-URL: " +
+ url +
+ "\r\n" +
+ 'X-Mozilla-Altered: AttachmentDetached; date="' +
+ 'Wed Oct 06 17:28:24 2010"\r\n\r\n';
+
+ str += help_create_detached_deleted_attachment(filename, type);
+ return str;
+}
+
+/**
+ * Create the raw data for a deleted attachment
+ *
+ * @param filename the "original" filename
+ * @param type the content type
+ * @returns a string representing the attachment
+ */
+function create_deleted_attachment(filename, type) {
+ let str =
+ 'Content-Type: text/x-moz-deleted; name="Deleted: ' +
+ filename +
+ '"\r\n' +
+ "Content-Transfer-Encoding: 8bit\r\n" +
+ 'Content-Disposition: inline; filename="Deleted: ' +
+ filename +
+ '"\r\n' +
+ 'X-Mozilla-Altered: AttachmentDeleted; date="' +
+ 'Wed Oct 06 17:28:24 2010"\r\n\r\n';
+ str += help_create_detached_deleted_attachment(filename, type);
+ return str;
+}
+
+/**
+ * Create the raw data for a feed enclosure attachment.
+ *
+ * @param filename the filename
+ * @param type the content type
+ * @param url the remote link url
+ * @param size the optional size (use > 1 for real size)
+ * @returns a string representing the attachment
+ */
+function create_enclosure_attachment(filename, type, url, size) {
+ return (
+ "Content-Type: " +
+ type +
+ '; name="' +
+ filename +
+ (size ? '"; size=' + size : '"') +
+ "\r\n" +
+ "X-Mozilla-External-Attachment-URL: " +
+ url +
+ "\r\n" +
+ 'Content-Disposition: attachment; filename="' +
+ filename +
+ '"\r\n\r\n' +
+ "This MIME attachment is stored separately from the message."
+ );
+}
+
+/**
+ * A helper function that selects either one, or a continuous range
+ * of items in the attachment list.
+ *
+ * @param aController a composer window controller
+ * @param aIndexStart the index of the first item to select
+ * @param aIndexEnd (optional) the index of the last item to select
+ */
+function select_attachments(aController, aIndexStart, aIndexEnd) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ bucket.clearSelection();
+
+ if (aIndexEnd !== undefined) {
+ let startItem = bucket.getItemAtIndex(aIndexStart);
+ let endItem = bucket.getItemAtIndex(aIndexEnd);
+ bucket.selectItemRange(startItem, endItem);
+ } else {
+ bucket.selectedIndex = aIndexStart;
+ }
+
+ bucket.focus();
+ return [...bucket.selectedItems];
+}
diff --git a/comm/mail/test/browser/shared-modules/CloudfileHelpers.jsm b/comm/mail/test/browser/shared-modules/CloudfileHelpers.jsm
new file mode 100644
index 0000000000..a8c937e770
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/CloudfileHelpers.jsm
@@ -0,0 +1,278 @@
+/* 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 = [
+ "gMockCloudfileManager",
+ "MockCloudfileAccount",
+ "getFile",
+ "collectFiles",
+ "CloudFileTestProvider",
+];
+
+var fdh = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+var kDefaults = {
+ type: "default",
+ displayName: "default",
+ iconURL: "chrome://messenger/content/extension.svg",
+ accountKey: null,
+ managementURL: "",
+ reuseUploads: true,
+ authErr: cloudFileAccounts.constants.authErr,
+ offlineErr: cloudFileAccounts.constants.offlineErr,
+ uploadErr: cloudFileAccounts.constants.uploadErr,
+ uploadWouldExceedQuota: cloudFileAccounts.constants.uploadWouldExceedQuota,
+ uploadExceedsFileLimit: cloudFileAccounts.constants.uploadExceedsFileLimit,
+ uploadCancelled: cloudFileAccounts.constants.uploadCancelled,
+};
+
+function getFile(aFilename, aRoot) {
+ var file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(aRoot);
+ file.append(aFilename);
+ Assert.ok(file.exists, "File " + aFilename + " does not exist.");
+ return file;
+}
+
+/**
+ * Helper function for getting the nsIFile's for some files located
+ * in a subdirectory of the test directory.
+ *
+ * @param aFiles an array of filename strings for files underneath the test
+ * file directory.
+ * @param aFileRoot the file who's parent directory we should start looking
+ * for aFiles in.
+ *
+ * Example:
+ * let files = collectFiles(['./data/testFile1', './data/testFile2'],
+ * __file__);
+ */
+function collectFiles(aFiles, aFileRoot) {
+ return aFiles.map(filename => getFile(filename, aFileRoot));
+}
+
+function MockCloudfileAccount() {
+ for (let someDefault in kDefaults) {
+ this[someDefault] = kDefaults[someDefault];
+ }
+}
+
+MockCloudfileAccount.prototype = {
+ _nextId: 1,
+ _uploads: new Map(),
+
+ init(aAccountKey, aOverrides = {}) {
+ for (let override in aOverrides) {
+ this[override] = aOverrides[override];
+ }
+ this.accountKey = aAccountKey;
+
+ Services.prefs.setCharPref(
+ "mail.cloud_files.accounts." + aAccountKey + ".displayName",
+ aAccountKey
+ );
+ Services.prefs.setCharPref(
+ "mail.cloud_files.accounts." + aAccountKey + ".type",
+ aAccountKey
+ );
+ cloudFileAccounts._accounts.set(aAccountKey, this);
+ },
+
+ renameFile(window, uploadId, newName) {
+ if (this.renameError) {
+ throw Components.Exception(
+ this.renameError.message,
+ this.renameError.result
+ );
+ }
+
+ let upload = this._uploads.get(uploadId);
+ upload.url = `https://www.example.com/${this.accountKey}/${newName}`;
+ upload.name = newName;
+ return upload;
+ },
+
+ isReusedUpload() {
+ return false;
+ },
+
+ uploadFile(window, aFile) {
+ if (this.uploadError) {
+ return Promise.reject(
+ Components.Exception(this.uploadError.message, this.uploadError.result)
+ );
+ }
+
+ return new Promise((resolve, reject) => {
+ let upload = {
+ // Values used in the WebExtension CloudFile type.
+ id: this._nextId++,
+ url: this.urlForFile(aFile),
+ name: aFile.leafName,
+ // Properties of the local file.
+ path: aFile.path,
+ size: aFile.exists() ? aFile.fileSize : 0,
+ // Use aOverrides to set these.
+ serviceIcon: this.serviceIcon || this.iconURL,
+ serviceName: this.serviceName || this.displayName,
+ serviceUrl: this.serviceUrl || "",
+ downloadPasswordProtected: this.downloadPasswordProtected || false,
+ downloadLimit: this.downloadLimit || 0,
+ downloadExpiryDate: this.downloadExpiryDate || null,
+ // Usage tracking.
+ immutable: false,
+ };
+ this._uploads.set(upload.id, upload);
+ gMockCloudfileManager.inProgressUploads.add({
+ resolve,
+ reject,
+ resolveData: upload,
+ });
+ });
+ },
+
+ urlForFile(aFile) {
+ return `https://www.example.com/${this.accountKey}/${aFile.leafName}`;
+ },
+
+ cancelFileUpload(window, aUploadId) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ deleteFile(window, aUploadId) {
+ return new Promise(resolve => fdh.mc.window.setTimeout(resolve));
+ },
+};
+
+var gMockCloudfileManager = {
+ _mock_map: {},
+
+ register(aID, aOverrides) {
+ if (!aID) {
+ aID = "default";
+ }
+
+ if (!aOverrides) {
+ aOverrides = {};
+ }
+
+ cloudFileAccounts.registerProvider(aID, {
+ type: aID,
+ displayName: aID,
+ iconURL: "chrome://messenger/content/extension.svg",
+ initAccount(accountKey, aAccountOverrides = {}) {
+ let account = new MockCloudfileAccount();
+ for (let override in aOverrides) {
+ if (!aAccountOverrides.hasOwnProperty(override)) {
+ aAccountOverrides[override] = aOverrides[override];
+ }
+ }
+ account.init(accountKey, aAccountOverrides);
+ return account;
+ },
+ });
+ },
+
+ unregister(aID) {
+ if (!aID) {
+ aID = "default";
+ }
+
+ cloudFileAccounts.unregisterProvider(aID);
+ },
+
+ inProgressUploads: new Set(),
+ resolveUploads() {
+ let uploads = [];
+ for (let upload of this.inProgressUploads.values()) {
+ uploads.push(upload.resolveData);
+ upload.resolve(upload.resolveData);
+ }
+ this.inProgressUploads.clear();
+ return uploads;
+ },
+ rejectUploads() {
+ for (let upload of this.inProgressUploads.values()) {
+ upload.reject(
+ Components.Exception(
+ "Upload error.",
+ cloudFileAccounts.constants.uploadErr
+ )
+ );
+ }
+ this.inProgressUploads.clear();
+ },
+};
+
+class CloudFileTestProvider {
+ constructor(name = "CloudFileTestProvider") {
+ this.extension = null;
+ this.name = name;
+ }
+
+ get providerType() {
+ return `ext-${this.extension.id}`;
+ }
+
+ /**
+ * Register an extension based cloudFile provider.
+ *
+ * @param testScope - scope of the test, mostly "this"
+ * @param [background] - optional background script, overriding the default
+ */
+ async register(testScope, background) {
+ if (!testScope) {
+ throw new Error("Missing testScope for CloudFileTestProvider.init().");
+ }
+
+ async function default_background() {
+ function fileListener(account, { id, name, data }, tab, relatedFileInfo) {
+ return { url: "https://example.com/" + name };
+ }
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ }
+
+ this.extension = testScope.ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background || default_background,
+ },
+ manifest: {
+ cloud_file: {
+ name: this.name,
+ management_url: "/content/management.html",
+ },
+ applications: { gecko: { id: `${this.name}@mochi.test` } },
+ background: { scripts: ["background.js"] },
+ },
+ });
+
+ await this.extension.startup();
+ }
+
+ async unregister() {
+ cloudFileAccounts.unregisterProvider(this.providerType);
+ await this.extension.unload();
+ }
+
+ async createAccount(displayName) {
+ let account = await cloudFileAccounts.createAccount(this.providerType);
+ cloudFileAccounts.setDisplayName(account, displayName);
+ return account;
+ }
+
+ removeAccount(aKeyOrAccount) {
+ return cloudFileAccounts.removeAccount(aKeyOrAccount);
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/ComposeHelpers.jsm b/comm/mail/test/browser/shared-modules/ComposeHelpers.jsm
new file mode 100644
index 0000000000..0d32769760
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/ComposeHelpers.jsm
@@ -0,0 +1,2430 @@
+/* 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 = [
+ "add_attachments",
+ "add_cloud_attachments",
+ "rename_selected_cloud_attachment",
+ "convert_selected_to_cloud_attachment",
+ "assert_previous_text",
+ "async_wait_for_compose_window",
+ "clear_recipients",
+ "close_compose_window",
+ "create_msg_attachment",
+ "delete_attachment",
+ "get_compose_body",
+ "get_first_pill",
+ "get_msg_source",
+ "open_compose_from_draft",
+ "open_compose_new_mail",
+ "open_compose_with_edit_as_new",
+ "open_compose_with_forward",
+ "open_compose_with_forward_as_attachments",
+ "open_compose_with_reply",
+ "open_compose_with_reply_to_all",
+ "open_compose_with_reply_to_list",
+ "save_compose_message",
+ "setup_msg_contents",
+ "type_in_composer",
+ "wait_for_compose_window",
+ "FormatHelper",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { get_about_message, mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { gMockCloudfileManager } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CloudfileHelpers.jsm"
+);
+var windowHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var { get_notification } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var kTextNodeType = 3;
+
+/**
+ * Opens the compose window by starting a new message
+ *
+ * @param aController the controller for the mail:3pane from which to spawn
+ * the compose window. If left blank, defaults to mc.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ *
+ */
+function open_compose_new_mail(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "n",
+ { shiftKey: false, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by replying to a selected message and waits for it
+ * to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_reply(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "r",
+ { shiftKey: false, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by replying to all for a selected message and waits
+ * for it to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_reply_to_all(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "R",
+ { shiftKey: true, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by replying to list for a selected message and waits for it
+ * to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_reply_to_list(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "l",
+ { shiftKey: true, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by forwarding the selected messages as attachments
+ * and waits for it to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_forward_as_attachments(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ aController.window.goDoCommand("cmd_forwardAttachment");
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by editing the selected message as new
+ * and waits for it to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_edit_as_new(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ aController.window.goDoCommand("cmd_editAsNew");
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by forwarding the selected message and waits for it
+ * to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_forward(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "l",
+ { shiftKey: false, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Open draft editing by clicking the "Edit" on the draft notification bar
+ * of the selected message.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_from_draft(win = get_about_message()) {
+ windowHelper.plan_for_new_window("msgcompose");
+ let box = get_notification(win, "mail-notification-top", "draftMsgContent");
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ win
+ );
+ return wait_for_compose_window();
+}
+
+/**
+ * Saves the message being composed and waits for the save to complete.
+ *
+ * @param {Window} win - A messengercompose.xhtml window.
+ */
+async function save_compose_message(win) {
+ let savePromise = BrowserTestUtils.waitForEvent(win, "aftersave");
+ win.document.querySelector("#button-save").click();
+ await savePromise;
+}
+
+/**
+ * Closes the requested compose window.
+ *
+ * @param aController the controller whose window is to be closed.
+ * @param aShouldPrompt (optional) true: check that the prompt to save appears
+ * false: check there's no prompt to save
+ */
+function close_compose_window(aController, aShouldPrompt) {
+ if (aShouldPrompt === undefined) {
+ // caller doesn't care if we get a prompt
+ windowHelper.close_window(aController);
+ return;
+ }
+
+ windowHelper.plan_for_window_close(aController);
+ if (aShouldPrompt) {
+ windowHelper.plan_for_modal_dialog(
+ "commonDialogWindow",
+ function (controller) {
+ controller.window.document
+ .querySelector("dialog")
+ .getButton("extra1")
+ .doCommand();
+ }
+ );
+ // Try to close, we should get a prompt to save.
+ aController.window.goDoCommand("cmd_close");
+ windowHelper.wait_for_modal_dialog();
+ } else {
+ aController.window.goDoCommand("cmd_close");
+ }
+ windowHelper.wait_for_window_close();
+}
+
+/**
+ * Waits for a new compose window to open. This assumes you have already called
+ * "windowHelper.plan_for_new_window("msgcompose");" and the command to open
+ * the compose window itself.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+async function async_wait_for_compose_window(aController, aPromise) {
+ let replyWindow = await aPromise;
+ return _wait_for_compose_window(aController, replyWindow);
+}
+
+function wait_for_compose_window(aController) {
+ let replyWindow = windowHelper.wait_for_new_window("msgcompose");
+ return _wait_for_compose_window(aController, replyWindow);
+}
+
+function _wait_for_compose_window(aController, replyWindow) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ utils.waitFor(
+ () => Services.focus.activeWindow == replyWindow.window,
+ "waiting for the compose window to have focus"
+ );
+ utils.waitFor(
+ () => replyWindow.window.composeEditorReady,
+ "waiting for the compose editor to be ready"
+ );
+ utils.sleep(0);
+
+ return replyWindow;
+}
+
+/**
+ * Fills in the given message recipient/subject/body into the right widgets.
+ *
+ * @param aCwc Compose window controller.
+ * @param aAddr Recipient to fill in.
+ * @param aSubj Subject to fill in.
+ * @param aBody Message body to fill in.
+ * @param inputID The input field to fill in.
+ */
+function setup_msg_contents(
+ aCwc,
+ aAddr,
+ aSubj,
+ aBody,
+ inputID = "toAddrInput"
+) {
+ let pillcount = function () {
+ return aCwc.window.document.querySelectorAll("mail-address-pill").length;
+ };
+ let targetCount = pillcount();
+ if (aAddr.trim()) {
+ targetCount += aAddr.split(",").filter(s => s.trim()).length;
+ }
+
+ let input = aCwc.window.document.getElementById(inputID);
+ utils.sleep(1000);
+ input.focus();
+ EventUtils.sendString(aAddr, aCwc.window);
+ input.focus();
+
+ EventUtils.synthesizeKey("VK_RETURN", {}, aCwc.window);
+ aCwc.window.document.getElementById("msgSubject").focus();
+ EventUtils.sendString(aSubj, aCwc.window);
+ aCwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString(aBody, aCwc.window);
+
+ // Wait for the pill(s) to be created.
+ utils.waitFor(
+ () => pillcount() == targetCount,
+ `Creating pill for: ${aAddr}`
+ );
+}
+
+/**
+ * Remove all recipients.
+ *
+ * @param aController Compose window controller.
+ */
+function clear_recipients(aController) {
+ for (let pill of aController.window.document.querySelectorAll(
+ "mail-address-pill"
+ )) {
+ pill.toggleAttribute("selected", true);
+ }
+ aController.window.document
+ .getElementById("recipientsContainer")
+ .removeSelectedPills();
+}
+
+/**
+ * Return the first available recipient pill.
+ *
+ * @param aController - Compose window controller.
+ */
+function get_first_pill(aController) {
+ return aController.window.document.querySelector("mail-address-pill");
+}
+
+/**
+ * Create and return an nsIMsgAttachment for the passed URL.
+ *
+ * @param aUrl the URL for this attachment (either a file URL or a web URL)
+ * @param aSize (optional) the file size of this attachment, in bytes
+ */
+function create_msg_attachment(aUrl, aSize) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+
+ attachment.url = aUrl;
+ if (aSize) {
+ attachment.size = aSize;
+ }
+
+ return attachment;
+}
+
+/**
+ * Add an attachment to the compose window.
+ *
+ * @param aController the controller of the composition window in question
+ * @param aUrl the URL for this attachment (either a file URL or a web URL)
+ * @param aSize (optional) - the file size of this attachment, in bytes
+ * @param aWaitAdded (optional) - True to wait for the attachments to be fully added, false otherwise.
+ */
+function add_attachments(aController, aUrls, aSizes, aWaitAdded = true) {
+ if (!Array.isArray(aUrls)) {
+ aUrls = [aUrls];
+ }
+
+ if (!Array.isArray(aSizes)) {
+ aSizes = [aSizes];
+ }
+
+ let attachments = [];
+
+ for (let [i, url] of aUrls.entries()) {
+ attachments.push(create_msg_attachment(url, aSizes[i]));
+ }
+
+ let attachmentsDone = false;
+ function collectAddedAttachments(event) {
+ Assert.equal(event.detail.length, attachments.length);
+ attachmentsDone = true;
+ }
+
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ if (aWaitAdded) {
+ bucket.addEventListener("attachments-added", collectAddedAttachments, {
+ once: true,
+ });
+ }
+ aController.window.AddAttachments(attachments);
+ if (aWaitAdded) {
+ utils.waitFor(() => attachmentsDone, "Attachments adding didn't finish");
+ }
+ utils.sleep(0);
+}
+
+/**
+ * Rename the selected cloud (filelink) attachment
+ *
+ * @param aController The controller of the composition window in question.
+ * @param aName The requested new name for the attachment.
+ *
+ */
+function rename_selected_cloud_attachment(aController, aName) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ let attachmentRenamed = false;
+ let upload = null;
+ let seenAlert = null;
+
+ function getRenamedUpload(event) {
+ upload = event.target.cloudFileUpload;
+ attachmentRenamed = true;
+ }
+
+ /** @implements {nsIPromptService} */
+ let mockPromptService = {
+ value: "",
+ prompt(window, title, message, rv) {
+ rv.value = this.value;
+ return true;
+ },
+ alert(window, title, message) {
+ seenAlert = { title, message };
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ };
+
+ bucket.addEventListener("attachment-renamed", getRenamedUpload, {
+ once: true,
+ });
+
+ let originalPromptService = Services.prompt;
+ Services.prompt = mockPromptService;
+ Services.prompt.value = aName;
+ aController.window.RenameSelectedAttachment();
+
+ utils.waitFor(
+ () => attachmentRenamed || seenAlert,
+ "Couldn't rename attachment"
+ );
+ Services.prompt = originalPromptService;
+
+ utils.sleep(0);
+ if (seenAlert) {
+ return seenAlert;
+ }
+
+ return upload;
+}
+
+/**
+ * Convert the selected attachment to a cloud (filelink) attachment
+ *
+ * @param aController The controller of the composition window in question.
+ * @param aProvider The provider account to upload the selected attachment to.
+ * @param aWaitUploaded (optional) - True to wait for the attachments to be uploaded, false otherwise.
+ */
+function convert_selected_to_cloud_attachment(
+ aController,
+ aProvider,
+ aWaitUploaded = true
+) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ let uploads = [];
+ let attachmentsSelected =
+ aController.window.gAttachmentBucket.selectedItems.length;
+ let attachmentsSubmitted = 0;
+ let attachmentsConverted = 0;
+
+ Assert.equal(
+ attachmentsSelected,
+ 1,
+ "Exactly one attachment should be scheduled for conversion."
+ );
+
+ function collectConvertingAttachments(event) {
+ let item = event.target;
+ let img = item.querySelector("img.attachmentcell-icon");
+ Assert.equal(
+ img.src,
+ "chrome://global/skin/icons/loading.png",
+ "Icon should be the spinner during conversion."
+ );
+
+ attachmentsSubmitted++;
+ if (attachmentsSubmitted == attachmentsSelected) {
+ bucket.removeEventListener(
+ "attachment-uploading",
+ collectConvertingAttachments
+ );
+ bucket.removeEventListener(
+ "attachment-moving",
+ collectConvertingAttachments
+ );
+ }
+ }
+
+ function collectConvertedAttachment(event) {
+ let item = event.target;
+ let img = item.querySelector("img.attachmentcell-icon");
+ Assert.equal(
+ img.src,
+ item.cloudIcon,
+ "Cloud icon should be used after conversion has finished."
+ );
+
+ attachmentsConverted++;
+ if (attachmentsConverted == attachmentsSelected) {
+ item.removeEventListener(
+ "attachment-uploaded",
+ collectConvertedAttachment
+ );
+ item.removeEventListener("attachment-moved", collectConvertedAttachment);
+ }
+ }
+
+ bucket.addEventListener("attachment-uploading", collectConvertingAttachments);
+ bucket.addEventListener("attachment-moving", collectConvertingAttachments);
+ aController.window.convertSelectedToCloudAttachment(aProvider);
+ utils.waitFor(
+ () => attachmentsSubmitted == attachmentsSelected,
+ "Couldn't start converting all attachments"
+ );
+
+ if (aWaitUploaded) {
+ bucket.addEventListener("attachment-uploaded", collectConvertedAttachment);
+ bucket.addEventListener("attachment-moved", collectConvertedAttachment);
+
+ uploads = gMockCloudfileManager.resolveUploads();
+ utils.waitFor(
+ () => attachmentsConverted == attachmentsSelected,
+ "Attachments uploading didn't finish"
+ );
+ }
+
+ utils.sleep(0);
+ return uploads;
+}
+
+/**
+ * Add a cloud (filelink) attachment to the compose window.
+ *
+ * @param aController - The controller of the composition window in question.
+ * @param aProvider - The provider account to upload to, with files to be uploaded.
+ * @param [aWaitUploaded] - True to wait for the attachments to be uploaded,
+ * false otherwise.
+ * @param [aExpectedAlerts] - The number of expected alert prompts.
+ */
+function add_cloud_attachments(
+ aController,
+ aProvider,
+ aWaitUploaded = true,
+ aExpectedAlerts = 0
+) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ let uploads = [];
+ let seenAlerts = [];
+
+ let attachmentsAdded = 0;
+ let attachmentsSubmitted = 0;
+ let attachmentsUploaded = 0;
+
+ function collectAddedAttachments(event) {
+ attachmentsAdded = event.detail.length;
+ if (!aExpectedAlerts) {
+ bucket.addEventListener(
+ "attachment-uploading",
+ collectUploadingAttachments
+ );
+ }
+ }
+
+ function collectUploadingAttachments(event) {
+ let item = event.target;
+ let img = item.querySelector("img.attachmentcell-icon");
+ Assert.equal(
+ img.src,
+ "chrome://global/skin/icons/loading.png",
+ "Icon should be the spinner during upload."
+ );
+
+ attachmentsSubmitted++;
+ if (attachmentsSubmitted == attachmentsAdded) {
+ bucket.removeEventListener(
+ "attachment-uploading",
+ collectUploadingAttachments
+ );
+ }
+ }
+
+ function collectUploadedAttachments(event) {
+ let item = event.target;
+ let img = item.querySelector("img.attachmentcell-icon");
+ Assert.equal(
+ img.src,
+ item.cloudIcon,
+ "Cloud icon should be used after upload has finished."
+ );
+
+ attachmentsUploaded++;
+ if (attachmentsUploaded == attachmentsAdded) {
+ bucket.removeEventListener(
+ "attachment-uploaded",
+ collectUploadedAttachments
+ );
+ }
+ }
+
+ /** @implements {nsIPromptService} */
+ let mockPromptService = {
+ alert(window, title, message) {
+ seenAlerts.push({ title, message });
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ };
+
+ bucket.addEventListener("attachments-added", collectAddedAttachments, {
+ once: true,
+ });
+
+ let originalPromptService = Services.prompt;
+ Services.prompt = mockPromptService;
+ aController.window.attachToCloudNew(aProvider);
+ utils.waitFor(
+ () =>
+ (!aExpectedAlerts &&
+ attachmentsAdded > 0 &&
+ attachmentsAdded == attachmentsSubmitted) ||
+ (aExpectedAlerts && seenAlerts.length == aExpectedAlerts),
+ "Couldn't attach attachments for upload"
+ );
+
+ Services.prompt = originalPromptService;
+ if (seenAlerts.length > 0) {
+ return seenAlerts;
+ }
+
+ if (aWaitUploaded) {
+ bucket.addEventListener("attachment-uploaded", collectUploadedAttachments);
+ uploads = gMockCloudfileManager.resolveUploads();
+ utils.waitFor(
+ () => attachmentsAdded == attachmentsUploaded,
+ "Attachments uploading didn't finish"
+ );
+ }
+ utils.sleep(0);
+ return uploads;
+}
+
+/**
+ * Delete an attachment from the compose window
+ *
+ * @param aComposeWindow the composition window in question
+ * @param aIndex the index of the attachment in the attachment pane
+ */
+function delete_attachment(aComposeWindow, aIndex) {
+ let bucket =
+ aComposeWindow.window.document.getElementById("attachmentBucket");
+ let node = bucket.querySelectorAll("richlistitem.attachmentItem")[aIndex];
+
+ EventUtils.synthesizeMouseAtCenter(node, {}, node.ownerGlobal);
+ aComposeWindow.window.RemoveSelectedAttachment();
+}
+
+/**
+ * Helper function returns the message body element of a composer window.
+ *
+ * @param aController the controller for a compose window.
+ */
+function get_compose_body(aController) {
+ let mailBody = aController.window.document
+ .getElementById("messageEditor")
+ .contentDocument.querySelector("body");
+ if (!mailBody) {
+ throw new Error("Compose body not found!");
+ }
+ return mailBody;
+}
+
+/**
+ * Given some compose window controller, type some text into that composer,
+ * pressing enter after each line except for the last.
+ *
+ * @param aController a compose window controller.
+ * @param aText an array of strings to type.
+ */
+function type_in_composer(aController, aText) {
+ // If we have any typing to do, let's do it.
+ let frame = aController.window.document.getElementById("messageEditor");
+ for (let [i, aLine] of aText.entries()) {
+ frame.focus();
+ EventUtils.sendString(aLine, aController.window);
+ if (i < aText.length - 1) {
+ frame.focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, aController.window);
+ }
+ }
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart is a text node which
+ * has a value matching the last value of the aText string array, and has
+ * a br node immediately preceding it. Repeated for each subsequent string
+ * of the aText array (working from end to start).
+ *
+ * @param aStart the first node to check
+ * @param aText an array of strings that should be checked for in reverse
+ * order (so the last element of the array should be the first
+ * text node encountered, the second last element of the array
+ * should be the next text node encountered, etc).
+ */
+function assert_previous_text(aStart, aText) {
+ let textNode = aStart;
+ for (let i = aText.length - 1; i >= 0; --i) {
+ if (textNode.nodeType != kTextNodeType) {
+ throw new Error(
+ "Expected a text node! Node type was: " + textNode.nodeType
+ );
+ }
+
+ if (textNode.nodeValue != aText[i]) {
+ throw new Error(
+ "Unexpected inequality - " + textNode.nodeValue + " != " + aText[i]
+ );
+ }
+
+ // We expect a BR preceding each text node automatically, except
+ // for the last one that we reach.
+ if (i > 0) {
+ let br = textNode.previousSibling;
+
+ if (br.localName != "br") {
+ throw new Error(
+ "Expected a BR node - got a " + br.localName + "instead."
+ );
+ }
+
+ textNode = br.previousSibling;
+ }
+ }
+ return textNode;
+}
+
+/**
+ * Helper to get the raw contents of a message. It only reads the first 64KiB.
+ *
+ * @param aMsgHdr nsIMsgDBHdr addressing a message which will be returned as text.
+ * @param aCharset Charset to use to decode the message.
+ *
+ * @returns String with the message source.
+ */
+async function get_msg_source(aMsgHdr, aCharset = "") {
+ let msgUri = aMsgHdr.folder.getUriForMsg(aMsgHdr);
+
+ let content = await new Promise((resolve, reject) => {
+ let streamListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+ sis: Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ ),
+ content: "",
+ onDataAvailable(request, inputStream, offset, count) {
+ this.sis.init(inputStream);
+ this.content += this.sis.read(count);
+ },
+ onStartRequest(request) {},
+ onStopRequest(request, statusCode) {
+ this.sis.close();
+ if (Components.isSuccessCode(statusCode)) {
+ resolve(this.content);
+ } else {
+ reject(new Error(statusCode));
+ }
+ },
+ };
+ MailServices.messageServiceFromURI(msgUri).streamMessage(
+ msgUri,
+ streamListener,
+ null,
+ null,
+ false,
+ "",
+ false
+ );
+ });
+
+ if (!aCharset) {
+ return content;
+ }
+
+ let buffer = Uint8Array.from(content, c => c.charCodeAt(0));
+ return new TextDecoder(aCharset).decode(buffer);
+}
+
+/**
+ * Helper class for performing formatted editing on the composition message.
+ */
+class FormatHelper {
+ /**
+ * Create the helper for the given composition window.
+ *
+ * @param {Window} win - The composition window.
+ */
+ constructor(win) {
+ this.window = win;
+ /** The Format menu. */
+ this.formatMenu = this._getById("formatMenuPopup");
+
+ /** The Font sub menu of {@link FormatHelper#formatMenu}. */
+ this.fontMenu = this._getById("fontFaceMenuPopup");
+ /** The menu items below the Font menu. */
+ this.fontMenuItems = Array.from(this.fontMenu.querySelectorAll("menuitem"));
+ /** The (font) Size sub menu of {@link FormatHelper#formatMenu}. */
+ this.sizeMenu = this._getById("fontSizeMenuPopup");
+ /** The menu items below the Size menu. */
+ this.sizeMenuItems = Array.from(
+ // Items without a value are the increase/decrease items.
+ this.sizeMenu.querySelectorAll("menuitem[value]")
+ );
+ /** The Text Style sub menu of {@link FormatHelper#formatMenu}. */
+ this.styleMenu = this._getById("fontStyleMenuPopup");
+ /** The menu items below the Text Style menu. */
+ this.styleMenuItems = Array.from(
+ this.styleMenu.querySelectorAll("menuitem")
+ );
+ /** The Paragraph (state) sub menu of {@link FormatHelper#formatMenu}. */
+ this.paragraphStateMenu = this._getById("paragraphMenuPopup");
+ /** The menu items below the Paragraph menu. */
+ this.paragraphStateMenuItems = Array.from(
+ this.paragraphStateMenu.querySelectorAll("menuitem")
+ );
+
+ /** The toolbar paragraph state selector button. */
+ this.paragraphStateSelector = this._getById("ParagraphSelect");
+ /** The toolbar paragraph state selector menu. */
+ this.paragraphStateSelectorMenu = this._getById("ParagraphPopup");
+ /** The toolbar font face selector button. */
+ this.fontSelector = this._getById("FontFaceSelect");
+ /** The toolbar font face selector menu. */
+ this.fontSelectorMenu = this._getById("FontFacePopup");
+ /** The toolbar font size selector button. */
+ this.sizeSelector = this._getById("AbsoluteFontSizeButton");
+ /** The toolbar font size selector menu. */
+ this.sizeSelectorMenu = this._getById("AbsoluteFontSizeButtonPopup");
+ /** The menu items below the toolbar font size selector. */
+ this.sizeSelectorMenuItems = Array.from(
+ this.sizeSelectorMenu.querySelectorAll("menuitem")
+ );
+
+ /** The toolbar foreground color selector. */
+ this.colorSelector = this._getById("TextColorButton");
+ /** The Format foreground color item. */
+ this.colorMenuItem = this._getById("fontColor");
+
+ /** The toolbar increase font size button. */
+ this.increaseSizeButton = this._getById("IncreaseFontSizeButton");
+ /** The toolbar decrease font size button. */
+ this.decreaseSizeButton = this._getById("DecreaseFontSizeButton");
+ /** The increase font size menu item. */
+ this.increaseSizeMenuItem = this._getById("menu_increaseFontSize");
+ /** The decrease font size menu item. */
+ this.decreaseSizeMenuItem = this._getById("menu_decreaseFontSize");
+
+ /** The toolbar bold button. */
+ this.boldButton = this._getById("boldButton");
+ /** The toolbar italic button. */
+ this.italicButton = this._getById("italicButton");
+ /** The toolbar underline button. */
+ this.underlineButton = this._getById("underlineButton");
+
+ /** The toolbar remove text styling button. */
+ this.removeStylingButton = this._getById("removeStylingButton");
+ /** The remove text styling menu item. */
+ this.removeStylingMenuItem = this._getById("removeStylesMenuitem");
+
+ this.messageEditor = this._getById("messageEditor");
+ /** The Window of the message content. */
+ this.messageWindow = this.messageEditor.contentWindow;
+ /** The Document of the message content. */
+ this.messageDocument = this.messageEditor.contentDocument;
+ /** The Body of the message content. */
+ this.messageBody = this.messageDocument.body;
+
+ let styleDataMap = new Map([
+ ["bold", { tag: "B" }],
+ ["italic", { tag: "I" }],
+ ["underline", { tag: "U" }],
+ ["strikethrough", { tag: "STRIKE" }],
+ ["superscript", { tag: "SUP" }],
+ ["subscript", { tag: "SUB" }],
+ ["tt", { tag: "TT" }],
+ // ["nobreak", { tag: "NOBR" }], // Broken after bug 1806330. Why?
+ ["em", { tag: "EM", linked: "italic" }],
+ ["strong", { tag: "STRONG", linked: "bold" }],
+ ["cite", { tag: "CITE", implies: "italic" }],
+ ["abbr", { tag: "ABBR" }],
+ ["acronym", { tag: "ACRONYM" }],
+ ["code", { tag: "CODE", implies: "tt" }],
+ ["samp", { tag: "SAMP", implies: "tt" }],
+ ["var", { tag: "VAR", implies: "italic" }],
+ ]);
+ styleDataMap.forEach((data, name) => {
+ data.item = this.getStyleMenuItem(name);
+ data.name = name;
+ });
+ styleDataMap.forEach((data, name, map) => {
+ // Reference the object rather than the name.
+ if (data.linked) {
+ data.linked = map.get(data.linked);
+ Assert.ok(data.linked, `Found linked for ${name}`);
+ }
+ if (data.implies) {
+ data.implies = map.get(data.implies);
+ Assert.ok(data.implies, `Found implies for ${name}`);
+ }
+ });
+ /**
+ * @typedef StyleData
+ * @property {string} name - The style name.
+ * @property {string} tag - The tagName for the corresponding HTML element.
+ * @property {MozMenuItem} item - The corresponding menu item in the
+ * styleMenu.
+ * @property {StyleData} [linked] - The style that is linked to this style.
+ * If this style is set, the linked style is shown as also set. If the
+ * linked style is unset, so is this style.
+ * @property {StyleData} [implies] - The style that is implied by this
+ * style. If this style is set, the implied style is shown as also set.
+ */
+ /**
+ * Data for the various text styles. Maps from the style name to its data.
+ *
+ * @type {Map<string, StyleData>}
+ */
+ this.styleDataMap = styleDataMap;
+
+ /**
+ * A list of common font families available in Thunderbird. Excludes the
+ * Variable Width ("") and Fixed Width ("monospace") fonts.
+ *
+ * @type {[string]}
+ */
+ this.commonFonts = [
+ "Helvetica, Arial, sans-serif",
+ "Times New Roman, Times, serif",
+ "Courier New, Courier, monospace",
+ ];
+
+ /** The default font size that corresponds to no <font> being applied. */
+ this.NO_SIZE = 3;
+ /** The maximum font size. */
+ this.MAX_SIZE = 6;
+ /** The minimum font size. */
+ this.MIN_SIZE = 1;
+ }
+
+ _getById(id) {
+ return this.window.document.getElementById(id);
+ }
+
+ /**
+ * Move focus to the message area. The message needs to be focused for most
+ * of the interactive methods to work.
+ */
+ focusMessage() {
+ EventUtils.synthesizeMouseAtCenter(this.messageEditor, {}, this.window);
+ }
+
+ /**
+ * Type some text into the message area.
+ *
+ * @param {string} text - A string of printable characters to type.
+ */
+ async typeInMessage(text) {
+ EventUtils.sendString(text, this.messageWindow);
+ // Wait one loop to be similar to a user.
+ await TestUtils.waitForTick();
+ }
+
+ /**
+ * Simulate pressing enter/return in the message area.
+ *
+ * @param {boolean} [shift = false] - Whether to hold shift at the same time.
+ */
+ async typeEnterInMessage(shift = false) {
+ EventUtils.synthesizeKey(
+ "VK_RETURN",
+ { shiftKey: shift },
+ this.messageWindow
+ );
+ await TestUtils.waitForTick();
+ }
+
+ /**
+ * Delete the current selection in the message window (using backspace).
+ */
+ async deleteSelection() {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, this.messageWindow);
+ await TestUtils.waitForTick();
+ }
+
+ /**
+ * Select the entire message.
+ */
+ async selectAll() {
+ let selection = this.messageWindow.getSelection();
+ selection.removeAllRanges();
+
+ let changePromise = BrowserTestUtils.waitForEvent(
+ this.messageDocument,
+ "selectionchange"
+ );
+
+ selection.selectAllChildren(this.messageDocument.body);
+
+ await changePromise;
+ }
+
+ /**
+ * Select the first paragraph in the message.
+ */
+ async selectFirstParagraph() {
+ let selection = this.messageWindow.getSelection();
+ selection.removeAllRanges();
+
+ let changePromise = BrowserTestUtils.waitForEvent(
+ this.messageDocument,
+ "selectionchange"
+ );
+
+ let paragraph = this.messageDocument.body.querySelector("p");
+ Assert.ok(paragraph, "Have at least one paragraph");
+ selection.selectAllChildren(paragraph);
+
+ await changePromise;
+ }
+
+ /**
+ * Delete the entire message.
+ *
+ * Note, this currently deletes the paragraph state (see Bug 1715076).
+ */
+ async deleteAll() {
+ await this.selectAll();
+ await this.deleteSelection();
+ }
+
+ /**
+ * Empty the message paragraph.
+ */
+ async emptyParagraph() {
+ await this.selectFirstParagraph();
+ await this.deleteSelection();
+ let p = this.messageDocument.body.querySelector("p");
+ Assert.equal(p.textContent, "", "should have emptied p");
+ }
+
+ /**
+ * Tags that correspond to inline styling (in upper case).
+ *
+ * @type {[string]}
+ */
+ static inlineStyleTags = [
+ "B",
+ "I",
+ "U",
+ "STRIKE",
+ "SUP",
+ "SUB",
+ "TT",
+ "NOBR",
+ "EM",
+ "STRONG",
+ "CITE",
+ "ABBR",
+ "ACRONYM",
+ "CODE",
+ "SAMP",
+ "VAR",
+ ];
+ /**
+ * Tags that correspond to block scopes (in upper case).
+ *
+ * @type {[string]}
+ */
+ static blockTags = [
+ "P",
+ "PRE",
+ "ADDRESS",
+ "H1",
+ "H2",
+ "H3",
+ "H4",
+ "H5",
+ "H6",
+ ];
+
+ /**
+ * @param {Node} node - The node to test.
+ *
+ * @returns {boolean} Whether the node is considered a block.
+ */
+ static isBlock(node) {
+ return this.blockTags.includes(node.tagName);
+ }
+
+ /**
+ * @param {Node} node - The node to test.
+ *
+ * @returns {boolean} Whether the node is considered inline styling.
+ */
+ static isInlineStyle(node) {
+ return this.inlineStyleTags.includes(node.tagName);
+ }
+
+ /**
+ * @param {Node} node - The node to test.
+ *
+ * @returns {boolean} Whether the node is considered a font node.
+ */
+ static isFont(node) {
+ return node.tagName === "FONT";
+ }
+
+ /**
+ * @param {Node} node - The node to test.
+ *
+ * @returns {boolean} Whether the node is considered a break.
+ */
+ static isBreak(node) {
+ return node.tagName === "BR";
+ }
+
+ /**
+ * A leaf of the message body. Actual leaves of the HTMLBodyElement will have
+ * a corresponding Leaf (corresponding to the "break", "text" and "empty"
+ * types), with the exception of empty block elements. These leaves are
+ * ordered with respect to the corresponding childNode ordering. In addition,
+ * every block element will have two corresponding leaves: one for the start
+ * of the block ("block-start") that is ordered just before its children; and
+ * one for the end of the block ("block-end") that is ordered just after its
+ * children. Essentially, you can think of the opening and closing tags of the
+ * block as leaves of the message body.
+ *
+ * @typedef Leaf
+ * @property {"break"|"block-start"|"block-end"|"text"|"empty"} type -
+ * The leaf type.
+ * @property {Node} node - The associated node in the document.
+ */
+
+ /**
+ * Get the first leaf below the given node with respect to Leaf ordering.
+ *
+ * @param {Node} node - The node to fetch the first leaf of.
+ *
+ * @returns {Leaf} - The first leaf below the node.
+ */
+ static firstLeaf(node) {
+ while (true) {
+ // Starting the block scope.
+ if (this.isBlock(node)) {
+ return { type: "block-start", node };
+ }
+ let child = node.firstChild;
+ if (child) {
+ node = child;
+ } else {
+ break;
+ }
+ }
+ if (Text.isInstance(node)) {
+ return { type: "text", node };
+ } else if (this.isBreak(node)) {
+ return { type: "break", node };
+ }
+ return { type: "empty", node };
+ }
+
+ /**
+ * Get the next Leaf that follows the given Leaf in the ordering.
+ *
+ * @param {Node} root - The root of the tree to find leaves from.
+ * @param {Leaf} leaf - The leaf to search from.
+ *
+ * @returns {Leaf|null} - The next Leaf under the root that follows the given
+ * Leaf, or null if the given leaf was the last one.
+ */
+ static nextLeaf(root, leaf) {
+ if (leaf.type === "block-start") {
+ // Enter within the block scope.
+ let child = leaf.node.firstChild;
+ if (!child) {
+ return { type: "block-end", node };
+ }
+ return this.firstLeaf(child);
+ }
+ // Find the next branch of the tree.
+ let node = leaf.node;
+ let sibling;
+ while (true) {
+ if (node === root) {
+ return null;
+ }
+ // Move to the next branch, if there is one.
+ sibling = node.nextSibling;
+ if (sibling) {
+ break;
+ }
+ // Otherwise, move back up the current branch.
+ node = node.parentNode;
+ // Leaving the block scope.
+ if (this.isBlock(node)) {
+ return { type: "block-end", node };
+ }
+ }
+ // Travel to the first leaf of the branch.
+ return this.firstLeaf(sibling);
+ }
+
+ /**
+ * Select some text in the message body.
+ *
+ * Note, the start and end values refer to offsets from the start of the
+ * message, and they count the spaces *between* string characters in the
+ * message.
+ *
+ * A single newline will also count 1 towards the offset. This can refer to
+ * either the start or end of a block (such as a <p>), or an explicit line
+ * break (<br>). Note, as an exception, line breaks that do not produce a new
+ * line visually (breaks at the end of a block, or breaks in the body scope
+ * between a text node and the start of a block) do not count.
+ *
+ * You can either choose to select in a forward direction or a backward
+ * direction. When no end parameter is given, this corresponds to if a user
+ * approaches a position in the message by moving the text cursor forward or
+ * backward (using the arrow keys). Otherwise, this refers to the direction in
+ * which the selection was formed (using shift + arrow keys or dragging).
+ *
+ * @param {number} start - The position to start selecting from.
+ * @param {number|null} [end = null] - The position to end selecting from,
+ * after start, or null to select the same position as the start.
+ * @param {boolean} [forward = true] - Whether to select in the forward or
+ * backward direction.
+ */
+ async selectTextRange(start, end = null, forward = true) {
+ let selectionTargets = [{ position: start }];
+ if (end !== null) {
+ Assert.ok(
+ end >= start,
+ `End of selection (${end}) should be after the start (${start})`
+ );
+ selectionTargets.push({ position: end });
+ }
+
+ let cls = this.constructor;
+ let root = this.messageBody;
+ let prevLeaf = null;
+ let leaf = cls.firstLeaf(root);
+ let total = 0;
+ // NOTE: Only the leaves of the root will contribute to the total, which is
+ // why we only need to traverse them.
+ // Search the tree until we find the target nodes, or run out of leaves.
+ while (leaf && selectionTargets.some(target => !target.node)) {
+ // Look ahead at the next leaf.
+ let nextLeaf = cls.nextLeaf(root, leaf);
+ switch (leaf.type) {
+ case "text":
+ // Each character in the text content counts towards the total.
+ let textLength = leaf.node.textContent.length;
+ total += textLength;
+
+ for (let target of selectionTargets) {
+ if (target.node) {
+ continue;
+ }
+ if (total === target.position) {
+ // If the next leaf is a text node, then the start of the
+ // selection is between the end of this node and the start of
+ // the next node. If selecting forward, we prefer the end of the
+ // first node. Otherwise, we prefer the start of the next node.
+ // If the next node is not a text node (such as a break or the end
+ // of a block), we end at the current node.
+ if (forward || nextLeaf?.type !== "text") {
+ target.node = leaf.node;
+ target.offset = textLength;
+ }
+ // Else, let the next (text) leaf set the node and offset.
+ } else if (total > target.position) {
+ target.node = leaf.node;
+ // Difference between the selection start and the start of the
+ // node.
+ target.offset = target.position - total + textLength;
+ }
+ }
+ break;
+ case "block-start":
+ // Block start is a newline if the previous leaf was a text node in
+ // the body scope.
+ // Note that it is sufficient to test if the previous leaf was a text
+ // node, because if such a text node was not in the body scope we
+ // would have visited "block-end" in-between.
+ // If the body scope ended in a break we would have already have a
+ // newline, so there is no need to double count it.
+ if (prevLeaf?.type === "text") {
+ // If the total was already equal to a target.position, then the
+ // previous text node would have handled it in the
+ // (total === target.position)
+ // case above.
+ // So we can safely increase the total and let the next leaf handle
+ // it.
+ total += 1;
+ }
+ break;
+ case "block-end":
+ // Only create a newline if non-empty.
+ if (prevLeaf?.type !== "block-start") {
+ for (let target of selectionTargets) {
+ if (!target.node && total === target.position) {
+ // This should only happen for blocks that contain no text, such
+ // as a block that only contains a break.
+ target.node = leaf.node;
+ target.offset = leaf.node.childNodes.length - 1;
+ }
+ }
+ // Let the next leaf handle it.
+ total += 1;
+ }
+ break;
+ case "break":
+ // Only counts as a newline if it is not trailing in the body or block
+ // scope.
+ if (nextLeaf && nextLeaf.type !== "block-end") {
+ for (let target of selectionTargets) {
+ if (!target.node && total === target.position) {
+ // This should only happen for breaks that are at the start of a
+ // block.
+ // The break has no content, so the parent is used as the
+ // target.
+ let parentNode = leaf.node.parentNode;
+ target.node = parentNode;
+ let index = 0;
+ while (parentNode[index] !== leaf.node) {
+ index += 1;
+ }
+ target.offset = index;
+ }
+ }
+ total += 1;
+ }
+ break;
+ // Ignore type === "empty"
+ }
+ prevLeaf = leaf;
+ leaf = nextLeaf;
+ }
+
+ Assert.ok(
+ selectionTargets.every(target => target.node),
+ `Found selection from ${start} to ${end === null ? start : end}`
+ );
+
+ // Clear the current selection.
+ let selection = this.messageWindow.getSelection();
+ selection.removeAllRanges();
+
+ // Create the new one.
+ let range = this.messageDocument.createRange();
+ range.setStart(selectionTargets[0].node, selectionTargets[0].offset);
+ if (end !== null) {
+ range.setEnd(selectionTargets[1].node, selectionTargets[1].offset);
+ } else {
+ range.setEnd(selectionTargets[0].node, selectionTargets[0].offset);
+ }
+
+ let changePromise = BrowserTestUtils.waitForEvent(
+ this.messageDocument,
+ "selectionchange"
+ );
+ selection.addRange(range);
+
+ await changePromise;
+ }
+
+ /**
+ * Select the given text and delete it. See selectTextRange to know how to set
+ * the parameters.
+ *
+ * @param {number} start - The position to start selecting from.
+ * @param {number} end - The position to end selecting from, after start.
+ */
+ async deleteTextRange(start, end) {
+ await this.selectTextRange(start, end);
+ await this.deleteSelection();
+ }
+
+ /**
+ * @typedef BlockSummary
+ * @property {string} block - The tag name of the node.
+ * @property {(StyledTextSummary|string)[]} content - The regions of styled
+ * text content, ordered the same as in the document structure. String
+ * entries are equivalent to StyledTextSummary object with no set styling
+ * properties.
+ */
+
+ /**
+ * @typedef StyledTextSummary
+ * @property {string} text - The text for this region.
+ * @property {Set<string>} [tags] - The tags applied to this region, if any.
+ * When passing in an object, you can use an Array of strings instead, which
+ * will be converted into a Set when needed.
+ * @property {string} [font] - The font family applied to this region, if any.
+ * @property {number} [size] - The font size applied to this region, if any.
+ * @property {string} [color] - The font color applied to this region, if any.
+ */
+
+ /**
+ * Test if the two sets of tags are equal. undefined tags count as an empty
+ * set.
+ *
+ * @param {Set<string>|undefined} tags - A set of tags.
+ * @param {Set<string>|undefined} cmp - A set to compare against.
+ *
+ * @returns {boolean} - Whether the two sets are equal.
+ */
+ static equalTags(tags, cmp) {
+ if (!tags || tags.size === 0) {
+ return !cmp || cmp.size === 0;
+ }
+ if (!cmp) {
+ return false;
+ }
+ if (tags.size !== cmp.size) {
+ return false;
+ }
+ for (let t of tags) {
+ if (!cmp.has(t)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get a summary of the message body content.
+ *
+ * Note that the summary will exclude break nodes that do not produce a
+ * newline. That is break nodes between a text node and either:
+ * + the end of the body,
+ * + the start of a block, or
+ * + the end of a block.
+ *
+ * @returns {(BlockSummary|StyledTextSummary)[]} - A summary of the body
+ * content.
+ */
+ getMessageBodyContent() {
+ let cls = this.constructor;
+ let bodyNode = this.messageBody;
+ let bodyContent = [];
+ let blockNode = null;
+ let blockContent = null;
+ let prevLeaf = null;
+ let leaf = cls.firstLeaf(bodyNode);
+ // NOTE: Only the leaves of the body will contribute to the content, which
+ // is why we only need to traverse them.
+ while (leaf) {
+ // Look ahead at the next leaf.
+ let nextLeaf = cls.nextLeaf(bodyNode, leaf);
+ let isText = leaf.type === "text";
+ let isBreak = leaf.type === "break";
+ let isEmpty = leaf.type === "empty";
+ // Ignore a break node between a text node and either:
+ // + the end of the body,
+ // + the start of a block, or
+ // + the end of a block.
+ let ignoreBreak =
+ prevLeaf?.type === "text" &&
+ (!nextLeaf ||
+ nextLeaf.type === "block-start" ||
+ nextLeaf.type === "block-end");
+ if (leaf.type === "block-start") {
+ if (blockNode) {
+ throw new Error(
+ `Unexpected ${leaf.node.tagName} within a ${blockNode.tagName}`
+ );
+ }
+ // Set the block to add content to.
+ let block = { block: leaf.node.tagName, content: [] };
+ blockNode = leaf.node;
+ blockContent = block.content;
+ // Add to the content of the body.
+ bodyContent.push(block);
+ } else if (leaf.type === "block-end") {
+ if (!blockNode) {
+ throw new Error(`Unexpected block end for ${leaf.node.tagName}`);
+ }
+ // Remove the block to add content to.
+ blockNode = null;
+ blockContent = null;
+ } else if (isText || isEmpty || (isBreak && !ignoreBreak)) {
+ let tags;
+ let font;
+ let size;
+ let color;
+ let ancestorBlock = blockNode || bodyNode;
+ for (
+ // If empty, then we include the styling of the empty element.
+ let ancestor = isEmpty ? leaf.node : leaf.node.parentNode;
+ ancestor !== ancestorBlock;
+ ancestor = ancestor.parentNode
+ ) {
+ if (cls.isInlineStyle(ancestor)) {
+ if (!tags) {
+ tags = new Set();
+ }
+ tags.add(ancestor.tagName);
+ } else if (cls.isFont(ancestor)) {
+ // Prefer attributes from closest <font> ancestor.
+ if (font === undefined && ancestor.hasAttribute("face")) {
+ font = ancestor.getAttribute("face");
+ }
+ if (size === undefined && ancestor.hasAttribute("size")) {
+ size = Number(ancestor.getAttribute("size"));
+ }
+ if (color === undefined && ancestor.hasAttribute("color")) {
+ color = ancestor.getAttribute("color");
+ }
+ } else {
+ throw new Error(`Unknown format element ${ancestor.tagName}`);
+ }
+ }
+ let text;
+ if (isBreak) {
+ text = "<BR>";
+ } else if (isText) {
+ text = leaf.node.textContent;
+ } else {
+ // Empty styling elements.
+ text = "";
+ }
+
+ let content = blockContent || bodyContent;
+ let merged = false;
+ if (content.length) {
+ let prevSummary = content[content.length - 1];
+ // NOTE: prevSummary may be a block if this leaf lives in the body
+ // scope. We don't merge in that case.
+ if (
+ !prevSummary.block &&
+ cls.equalTags(prevSummary.tags, tags) &&
+ prevSummary.font === font &&
+ prevSummary.size === size &&
+ prevSummary.color === color
+ ) {
+ // Merge into the previous text if this region has the same text
+ // tags applied to it.
+ prevSummary.text += text;
+ merged = true;
+ }
+ }
+ if (!merged) {
+ let summary = { text };
+ summary.tags = tags;
+ summary.font = font;
+ summary.size = size;
+ summary.color = color;
+ content.push(summary);
+ }
+ }
+ prevLeaf = leaf;
+ leaf = nextLeaf;
+ }
+
+ if (blockNode) {
+ throw new Error(`Unexpected end of body within a ${blockNode.tagName}`);
+ }
+
+ return bodyContent;
+ }
+
+ /**
+ * Test that the current message body matches the given content.
+ *
+ * Note that the test is performed against a simplified version of the message
+ * body, where adjacent equivalent styling tags are merged together, and <BR>
+ * elements that do not produce a newline are ignored (see
+ * {@link FormatHelper#getMessageBodyContent}). This is to capture what the
+ * message would appear as to a user, rather than the exact details of the
+ * document structure.
+ *
+ * To represent breaks between text regions, simply include a "<BR>" in the
+ * expected text string. As such, the test cannot distinguish between a "<BR>"
+ * textContent and a break element, so do not use "<BR>" within the typed text
+ * of the message.
+ *
+ * @param {(BlockSummary|StyledTextSummary|string)[]} content - The expected
+ * content, ordered the same as in the document structure. BlockSummary
+ * objects represent blocks, and will have their own content.
+ * StyledTextSummary objects represent styled text directly in the body
+ * scope, and string objects represent un-styled text directly in the body
+ * scope.
+ * @param {string} assertMessage - A description of the test.
+ */
+ assertMessageBodyContent(content, assertMessage) {
+ let cls = this.constructor;
+
+ function message(message, below, index) {
+ return `${message} (at index ${index} below ${below})`;
+ }
+
+ function getDifference(node, expect, below, index) {
+ if (typeof expect === "string") {
+ expect = { text: expect };
+ }
+ if (expect.text !== undefined) {
+ // StyledTextSummary
+ if (node.text === undefined) {
+ return message("Is not a (styled) text region", below, index);
+ }
+ if (node.text !== expect.text) {
+ return message(
+ `Different text "${node.text}" vs "${expect.text}"`,
+ below,
+ index
+ );
+ }
+ if (Array.isArray(expect.tags)) {
+ expect.tags = new Set(expect.tags);
+ }
+ if (!cls.equalTags(node.tags, expect.tags)) {
+ function tagsToString(tags) {
+ if (!tags) {
+ return "NONE";
+ }
+ return Array.from(tags).join(",");
+ }
+ let have = tagsToString(node.tags);
+ let wanted = tagsToString(expect.tags);
+ return message(`Different tags ${have} vs ${wanted}`, below, index);
+ }
+ if (node.font !== expect.font) {
+ return message(
+ `Different font "${node.font}" vs "${expect.font}"`,
+ below,
+ index
+ );
+ }
+ if (node.size !== expect.size) {
+ return message(
+ `Different size ${node.size} vs ${expect.size}`,
+ below,
+ index
+ );
+ }
+ if (node.color !== expect.color) {
+ return message(
+ `Different color ${node.color} vs ${expect.color}`,
+ below,
+ index
+ );
+ }
+ return null;
+ } else if (expect.block !== undefined) {
+ if (node.block === undefined) {
+ return message("Is not a block", below, index);
+ }
+ if (node.block !== expect.block) {
+ return message(
+ `Different block names ${node.block} vs ${expect.block}`,
+ below,
+ index
+ );
+ }
+ let i;
+ for (i = 0; i < expect.content.length; i++) {
+ if (i >= node.content.length) {
+ return message("Missing child", node.block, i);
+ }
+ let childDiff = getDifference(
+ node.content[i],
+ expect.content[i],
+ node.block,
+ i
+ );
+ if (childDiff !== null) {
+ return childDiff;
+ }
+ }
+ if (i !== node.content.length) {
+ let extra = "";
+ for (; i < node.content.length; i++) {
+ let child = node.content[i];
+ if (child.text !== undefined) {
+ extra += child.text;
+ } else {
+ extra += `<${child.block}/>`;
+ }
+ }
+ return message(`Has extra children: ${extra}`, node.block, i);
+ }
+ return null;
+ }
+ throw new Error(message("Unrecognised object", below, index));
+ }
+
+ let expectBlock = { block: "BODY", content };
+ let bodyBlock = { block: "BODY", content: this.getMessageBodyContent() };
+
+ // We use a single Assert so that we can bail early if there is a
+ // difference. Only show the first difference found.
+ Assert.equal(
+ getDifference(bodyBlock, expectBlock, "HTML", 0),
+ null,
+ `${assertMessage}: Should be no difference in body content`
+ );
+ }
+
+ /**
+ * For debugging, print the message body content, as produced by
+ * {@link FormatHelper#getMessageBodyContent}.
+ */
+ dumpMessageBodyContent() {
+ function printTextSummary(textSummary, indent = "") {
+ let str = `${indent}<text`;
+ for (let prop in textSummary) {
+ let value = textSummary[prop];
+ switch (prop) {
+ case "text":
+ continue;
+ case "tags":
+ value = value ? Array.from(value).join(",") : undefined;
+ break;
+ }
+ if (value !== undefined) {
+ str += ` ${prop}="${value}"`;
+ }
+ }
+ str += `>${textSummary.text}</text>`;
+ console.log(str);
+ }
+
+ function printBlockSummary(blockSummary) {
+ console.log(`<${blockSummary.block}>`);
+ for (let textSummary of blockSummary.content) {
+ printTextSummary(textSummary, " ");
+ }
+ console.log(`</${blockSummary.block}>`);
+ }
+
+ for (let summary of this.getMessageBodyContent()) {
+ if (summary.block !== undefined) {
+ printBlockSummary(summary);
+ } else {
+ printTextSummary(summary);
+ }
+ }
+ }
+
+ /**
+ * Test that the message body contains a single paragraph block with the
+ * given content. See {@link FormatHelper#assertMessageBodyContent}.
+ *
+ * @param {(StyledTextSummary|string)[]} content - The expected content of the
+ * paragraph.
+ * @param {string} assertMessage - A description of the test.
+ */
+ assertMessageParagraph(content, assertMessage) {
+ this.assertMessageBodyContent([{ block: "P", content }], assertMessage);
+ }
+
+ /**
+ * Attempt to show a menu. The menu must be closed when calling.
+ *
+ * NOTE: this fails to open a native application menu on mac/osx because it is
+ * handled and restricted by the OS.
+ *
+ * @param {MozMenuPopup} menu - The menu to show.
+ *
+ * @returns {boolean} Whether the menu was opened. Otherwise, the menu is still
+ * closed.
+ */
+ async _openMenuOnce(menu) {
+ menu = menu.parentNode;
+ // NOTE: Calling openMenu(true) on a closed menu will put the menu in the
+ // "showing" state. But this can be cancelled (for some unknown reason) and
+ // the menu will be put back in the "hidden" state. Therefore we listen to
+ // both popupshown and popuphidden. See bug 1720174.
+ // NOTE: This only seems to happen for some platforms, specifically this
+ // sometimes occurs for the linux64 build on the try server.
+ // FIXME: Use only BrowserEventUtils.waitForEvent(menu, "popupshown")
+ let eventPromise = new Promise(resolve => {
+ let listener = event => {
+ menu.removeEventListener("popupshown", listener);
+ menu.removeEventListener("popuphidden", listener);
+ resolve(event.type);
+ };
+ menu.addEventListener("popupshown", listener);
+ menu.addEventListener("popuphidden", listener);
+ });
+ menu.openMenu(true);
+ let eventType = await eventPromise;
+ return eventType == "popupshown";
+ }
+
+ /**
+ * Show a menu. The menu must be closed when calling.
+ *
+ * @param {MozMenuPopup} menu - The menu to show.
+ */
+ async _openMenu(menu) {
+ if (!(await this._openMenuOnce(menu))) {
+ // If opening failed, try one more time. See bug 1720174.
+ Assert.ok(
+ await this._openMenuOnce(menu),
+ `Opening ${menu.id} should succeed on a second attempt`
+ );
+ }
+ }
+
+ /**
+ * Hide a menu. The menu must be open when calling.
+ *
+ * @param {MozMenuPopup} menu - The menu to hide.
+ */
+ async _closeMenu(menu) {
+ menu = menu.parentNode;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.openMenu(false);
+ await hiddenPromise;
+ }
+
+ /**
+ * Select a menu item from an open menu. This will also close the menu.
+ *
+ * @param {MozMenuItem} item - The item to select.
+ * @param {MozMenuPopup} menu - The open menu that the item belongs to.
+ */
+ async _selectFromOpenMenu(item, menu) {
+ menu = menu.parentNode;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.menupopup.activateItem(item);
+ await hiddenPromise;
+ }
+
+ /**
+ * Open a menu, select one of its items and close the menu.
+ *
+ * @param {MozMenuItem} item - The item to select.
+ * @param {MozMenuPopup} menu - The menu to open, that item belongs to.
+ */
+ async _selectFromClosedMenu(item, menu) {
+ if (item.disabled) {
+ await TestUtils.waitForCondition(
+ () => !item.disabled,
+ `Waiting for "${item.label}" to be enabled`
+ );
+ }
+ await this._openMenu(menu);
+ await this._selectFromOpenMenu(item, menu);
+ }
+
+ /**
+ * Close the [Format menu]{@link FormatHelper#formatMenu}, without selecting
+ * anything.
+ *
+ * Note, any open sub menus are also closed.
+ *
+ * Note, this method does not currently work on mac/osx because the Format
+ * menu is part of the native application menu, which cannot be activated
+ * through mochitests.
+ */
+ async closeFormatMenu() {
+ // Closing format menu closes the sub menu.
+ await this._closeMenu(this.formatMenu);
+ }
+
+ /**
+ * Select an item directly below the
+ * [Format menu]{@link FormatHelper#formatMenu}.
+ *
+ * Note, the Format menu must be closed before calling.
+ *
+ * Note, this method does not currently work on mac/osx because the Format
+ * menu is part of the native application menu, which cannot be activated
+ * through mochitests.
+ *
+ * @param {MozMenuItem} item - The item to select.
+ */
+ async selectFromFormatMenu(item) {
+ await this._openMenu(this.formatMenu);
+ await this._selectFromOpenMenu(item, this.formatMenu);
+ }
+
+ /**
+ * Open the [Format menu]{@link FormatHelper#formatMenu} and open one of its
+ * sub-menus, without selecting anything.
+ *
+ * Note, the Format menu must be closed before calling.
+ *
+ * Note, this method does not currently work on mac/osx because the Format
+ * menu is part of the native application menu, which cannot be activated
+ * through mochitests.
+ *
+ * @param {MozMenuPopup} menu - A closed menu below the Format menu to open.
+ */
+ async openFormatSubMenu(menu) {
+ if (
+ !(await this._openMenuOnce(this.formatMenu)) ||
+ !(await this._openMenuOnce(menu))
+ ) {
+ // If opening failed, try one more time. See bug 1720174.
+ // NOTE: failing to open the sub-menu can cause the format menu to also
+ // close. But we still make sure the format menu is closed before trying
+ // again.
+ if (this.formatMenu.state == "open") {
+ await this._closeMenu(this.formatMenu);
+ }
+ Assert.ok(
+ await this._openMenuOnce(this.formatMenu),
+ "Opening format menu should succeed on a second attempt"
+ );
+ Assert.ok(
+ await this._openMenuOnce(menu),
+ `Opening format sub-menu ${menu.id} should succeed on a second attempt`
+ );
+ }
+ }
+
+ /**
+ * Select an item from a sub-menu of the
+ * [Format menu]{@link FormatHelper#formatMenu}. The menu is opened before
+ * selecting.
+ *
+ * Note, the Format menu must be closed before calling.
+ *
+ * Note, this method does not currently work on mac/osx because the Format
+ * menu is part of the native application menu, which cannot be activated
+ * through mochitests.
+ *
+ * @param {MozMenuItem} item - The item to select.
+ * @param {MozMenuPopup} menu - The Format sub-menu that the item belongs to.
+ */
+ async selectFromFormatSubMenu(item, menu) {
+ if (item.disabled) {
+ await TestUtils.waitForCondition(
+ () => !item.disabled,
+ `Waiting for "${item.label}" to be enabled`
+ );
+ }
+ await this.openFormatSubMenu(menu);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ this.formatMenu,
+ "popuphidden"
+ );
+ // Selecting from the submenu also closes the parent menu.
+ await this._selectFromOpenMenu(item, menu);
+ await hiddenPromise;
+ }
+
+ /**
+ * Run a test with the format sub menu open. Before each test attempt, the
+ * [Format menu]{@link FormatHelper#formatMenu} is opened and so is the given
+ * sub-menu. After each attempt, the menu is closed.
+ *
+ * Note, the Format menu must be closed before calling.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {MozMenuPopup} menu - A closed menu below the Format menu to open.
+ * @param {Function} test - A test to run, without arguments, when the menu is
+ * open. Should return a truthy value on success.
+ * @param {string} message - The message to use when asserting the success of
+ * the test.
+ * @param {boolean} [wait] - Whether to retry until the test passes.
+ */
+ async assertWithFormatSubMenu(menu, test, message, wait = false) {
+ let performTest = async () => {
+ await this.openFormatSubMenu(menu);
+ let pass = test();
+ await this.closeFormatMenu();
+ return pass;
+ };
+ if (wait) {
+ await TestUtils.waitForCondition(performTest, message);
+ } else {
+ Assert.ok(await performTest(), message);
+ }
+ }
+
+ /**
+ * Select a paragraph state for the editor, using toolbar selector.
+ *
+ * @param {string} state - The state to select.
+ */
+ async selectParagraphState(state) {
+ await this._selectFromClosedMenu(
+ this.paragraphStateSelectorMenu.querySelector(
+ `menuitem[value="${state}"]`
+ ),
+ this.paragraphStateSelectorMenu
+ );
+ }
+
+ /**
+ * Get the menu item corresponding to the given state, that lives in the
+ * [Paragraph sub-menu]{@link FormatHelper#paragraphStateMenu} below the
+ * Format menu.
+ *
+ * @param {string} state - A state.
+ *
+ * @returns {MozMenuItem} - The menu item used for selecting the given state.
+ */
+ getParagraphStateMenuItem(state) {
+ return this.paragraphStateMenu.querySelector(`menuitem[value="${state}"]`);
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given paragraph state.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {string|null} state - The expected paragraph state, or null if the
+ * state should be shown as mixed.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownParagraphState(state, message) {
+ if (state === null) {
+ // In mixed state.
+ // getAttribute("value") currently returns "", rather than null, so test
+ // for hasAttribute instead.
+ await TestUtils.waitForCondition(
+ () => !this.paragraphStateSelector.hasAttribute("value"),
+ `${message}: Selector has no value`
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => this.paragraphStateSelector.value === state,
+ `${message}: Selector has the value "${state}"`
+ );
+ }
+
+ await this.assertWithFormatSubMenu(
+ this.paragraphStateMenu,
+ () =>
+ this.paragraphStateMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") === (item.value === state)
+ ),
+ `${message}: Only state="${state}" menu item should be checked`
+ );
+ }
+
+ /**
+ * Select a font family for the editor, using the toolbar selector.
+ *
+ * @param {string} font - The font family to select.
+ */
+ async selectFont(font) {
+ await this._selectFromClosedMenu(
+ this.fontSelectorMenu.querySelector(`menuitem[value="${font}"]`),
+ this.fontSelectorMenu
+ );
+ }
+
+ /**
+ * Get the menu item corresponding to the given font family, that lives in
+ * the [Font sub-menu]{@link FormatHelper#fontMenu} below the Format menu.
+ *
+ * @param {string} font - A font family.
+ *
+ * @returns {MozMenuItem} - The menu item used for selecting the given font
+ * family.
+ */
+ getFontMenuItem(font) {
+ return this.fontMenu.querySelector(`menuitem[value="${font}"]`);
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given font family.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {string|null} font - The expected font family, or null if the state
+ * should be shown as mixed.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownFont(font, message) {
+ if (font === null) {
+ // In mixed state.
+ // getAttribute("value") currently returns "", rather than null, so test
+ // for hasAttribute instead.
+ await TestUtils.waitForCondition(
+ () => !this.fontSelector.hasAttribute("value"),
+ `${message}: Selector has no value`
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => this.fontSelector.value === font,
+ `${message}: Selector value is "${font}"`
+ );
+ }
+
+ await this.assertWithFormatSubMenu(
+ this.fontMenu,
+ () =>
+ this.fontMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") === (item.value === font)
+ ),
+ `${message}: Only font="${font}" menu item should be checked`
+ );
+ }
+
+ /**
+ * Select a font size for the editor, using the toolbar selector.
+ *
+ * @param {number} size - The font size to select.
+ */
+ async selectSize(size) {
+ await this._selectFromClosedMenu(
+ this.sizeSelectorMenu.querySelector(`menuitem[value="${size}"]`),
+ this.sizeSelectorMenu
+ );
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given font size.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {number|null} size - The expected font size, or null if the state
+ * should be shown as mixed.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownSize(size, message) {
+ size = size?.toString();
+ // Test in Format Menu.
+ await this.assertWithFormatSubMenu(
+ this.sizeMenu,
+ () =>
+ this.sizeMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") === (item.value === size)
+ ),
+ `${message}: Only size=${size} Format menu item should be checked`
+ // Don't have to wait for size menu.
+ );
+ // Test the same in the Toolbar selector.
+ await this._openMenu(this.sizeSelectorMenu);
+ Assert.ok(
+ this.sizeSelectorMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") === (item.value === size)
+ ),
+ `${message}: Only size=${size} Toolbar menu item should be checked`
+ );
+ await this._closeMenu(this.sizeSelectorMenu);
+ }
+
+ /**
+ * Get the menu item corresponding to the given font size, that lives in
+ * the [Size sub-menu]{@link FormatHelper#sizeMenu} below the Format menu.
+ *
+ * @param {number} size - A font size.
+ *
+ * @returns {MozMenuItem} - The menu item used for selecting the given font
+ * size.
+ */
+ getSizeMenuItem(size) {
+ return this.sizeMenu.querySelector(`menuitem[value="${size}"]`);
+ }
+
+ /**
+ * Select the given color when the color picker dialog is opened.
+ *
+ * Note, the dialog will have to be opened separately to this method. Normally
+ * after this method, but before awaiting on the promise.
+ *
+ * @property {string|null} - The color to choose, or null to choose the default.
+ *
+ * @returns {Promise} - The promise to await on once the dialog is triggered.
+ */
+ async selectColorInDialog(color) {
+ return BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://messenger/content/messengercompose/EdColorPicker.xhtml",
+ {
+ callback: async win => {
+ if (color === null) {
+ win.document.getElementById("DefaultColorButton").click();
+ } else {
+ win.document.getElementById("ColorInput").value = color;
+ }
+ win.document.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+ }
+
+ /**
+ * Select a font color for the editor, using the toolbar selector.
+ *
+ * @param {string} font - The font color to select.
+ */
+ async selectColor(color) {
+ let selector = this.selectColorInDialog(color);
+ this.colorSelector.click();
+ await selector;
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given font color.
+ *
+ * @param {{value: string, rgb: [number]}|""|null} color - The expected font
+ * color. You should supply both the value, as set in the test, and its
+ * corresponding RGB numbers. Alternatively, give "" to assert the default
+ * color, or null to assert that the font color is shown as mixed.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownColor(color, message) {
+ if (color === "") {
+ color = { value: "", rgb: [0, 0, 0] };
+ }
+
+ let rgbRegex = /^rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)$/;
+ let testOnce = foundColor => {
+ if (color === null) {
+ return foundColor === "mixed";
+ }
+ // color can either be the value or an rgb.
+ let foundRgb = rgbRegex.exec(foundColor);
+ if (foundRgb) {
+ foundRgb = foundRgb.slice(1).map(s => Number(s));
+ return (
+ foundRgb[0] === color.rgb[0] &&
+ foundRgb[1] === color.rgb[1] &&
+ foundRgb[2] === color.rgb[2]
+ );
+ }
+ return foundColor === color.value;
+ };
+
+ let name = color === null ? '"mixed"' : `"${color.value}"`;
+ let foundColor = this.colorSelector.getAttribute("color");
+ if (testOnce(foundColor)) {
+ Assert.ok(
+ true,
+ `${message}: Found color "${foundColor}" should match ${name}`
+ );
+ return;
+ }
+ await TestUtils.waitForCondition(() => {
+ let colorNow = this.colorSelector.getAttribute("color");
+ if (colorNow !== foundColor) {
+ foundColor = colorNow;
+ return true;
+ }
+ return false;
+ }, `${message}: Waiting for the color to change from ${foundColor}`);
+ Assert.ok(
+ testOnce(foundColor),
+ `${message}: Changed color "${foundColor}" should match ${name}`
+ );
+ }
+
+ /**
+ * Get the menu item corresponding to the given style, that lives in the
+ * [Text Style sub-menu]{@link FormatHelper#styleMenu} below the Format menu.
+ *
+ * @param {string} style - A style.
+ *
+ * @returns {MozMenuItem} - The menu item used for selecting the given style.
+ */
+ getStyleMenuItem(style) {
+ return this.styleMenu.querySelector(`menuitem[observes="cmd_${style}"]`);
+ }
+
+ /**
+ * Select the given style from the [Style menu]{@link FormatHelper#styleMenu}.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {StyleData} style - The style data for the style to select.
+ */
+ async selectStyle(styleData) {
+ await this.selectFromFormatSubMenu(styleData.item, this.styleMenu);
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given text styles.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * Implied styles (see {@link StyleData#linked} and {@linj StyleData#implies})
+ * will be automatically checked for from the given styles.
+ *
+ * @param {[(StyleData|string)]|StyleData|string|null} styleSet - The styles
+ * to assert as shown. If none should be shown, given null. Otherwise,
+ * styles can either be specified by their style name (as used in
+ * {@link FormatHelper#styleDataMap}) or by the style data directly. Either
+ * an array of styles can be passed, or a single style.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownStyles(styleSet, message) {
+ let expectItems = [];
+ let expectString;
+ let isBold = false;
+ let isItalic = false;
+ let isUnderline = false;
+ if (styleSet) {
+ expectString = "Only ";
+ let first = true;
+ let addSingleStyle = data => {
+ if (!data) {
+ return;
+ }
+ isBold = isBold || data.name === "bold";
+ isItalic = isItalic || data.name === "italic";
+ isUnderline = isUnderline || data.name === "underline";
+ expectItems.push(data.item);
+ if (first) {
+ first = false;
+ } else {
+ expectString += ", ";
+ }
+ expectString += data.name;
+ };
+ let addStyle = style => {
+ if (typeof style === "string") {
+ style = this.styleDataMap.get(style);
+ }
+ addSingleStyle(style);
+ addSingleStyle(style.linked);
+ addSingleStyle(style.implies);
+ };
+
+ if (Array.isArray(styleSet)) {
+ styleSet.forEach(style => addStyle(style));
+ } else {
+ addStyle(styleSet);
+ }
+ } else {
+ expectString = "None";
+ }
+ await this.assertWithFormatSubMenu(
+ this.styleMenu,
+ () => {
+ let checkedIds = this.styleMenuItems
+ .filter(i => i.getAttribute("checked") === "true")
+ .map(m => m.id);
+ if (expectItems.length != checkedIds.length) {
+ dump(
+ `Expected: ${expectItems.map(i => i.id)}, Actual: ${checkedIds}\n`
+ );
+ }
+ return this.styleMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") ===
+ expectItems.includes(item)
+ );
+ },
+ `${message}: ${expectString} should be checked`,
+ true
+ );
+
+ // Check the toolbar buttons.
+ Assert.equal(
+ this.boldButton.checked,
+ isBold,
+ `${message}: Bold button should be ${isBold ? "" : "un"}checked`
+ );
+ Assert.equal(
+ this.italicButton.checked,
+ isItalic,
+ `${message}: Italic button should be ${isItalic ? "" : "un"}checked`
+ );
+ Assert.equal(
+ this.underlineButton.checked,
+ isUnderline,
+ `${message}: Underline button should be ${isUnderline ? "" : "un"}checked`
+ );
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/ContentTabHelpers.jsm b/comm/mail/test/browser/shared-modules/ContentTabHelpers.jsm
new file mode 100644
index 0000000000..3d7ba3ee2c
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/ContentTabHelpers.jsm
@@ -0,0 +1,423 @@
+/* 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 = [
+ "open_content_tab_with_url",
+ "open_content_tab_with_click",
+ "plan_for_content_tab_load",
+ "wait_for_content_tab_load",
+ "assert_content_tab_has_favicon",
+ "content_tab_e",
+ "get_content_tab_element_display",
+ "assert_content_tab_element_hidden",
+ "assert_content_tab_element_visible",
+ "wait_for_content_tab_element_display",
+ "get_element_by_text",
+ "assert_content_tab_text_present",
+ "assert_content_tab_text_absent",
+ "NotificationWatcher",
+ "get_notification_bar_for_tab",
+ "updateBlocklist",
+ "setAndUpdateBlocklist",
+ "resetBlocklist",
+ "gMockExtProtSvcReg",
+ "gMockExtProtSvc",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var folderDisplayHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { MockObjectReplacer } = ChromeUtils.import(
+ "resource://testing-common/mozmill/MockObjectHelpers.jsm"
+);
+var wh = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+
+var FAST_TIMEOUT = 1000;
+var FAST_INTERVAL = 100;
+var EXT_PROTOCOL_SVC_CID = "@mozilla.org/uriloader/external-protocol-service;1";
+
+var mc = folderDisplayHelper.mc;
+
+var _originalBlocklistURL = null;
+
+var gMockExtProtSvcReg = new MockObjectReplacer(
+ EXT_PROTOCOL_SVC_CID,
+ MockExtProtConstructor
+);
+
+/**
+ * gMockExtProtocolSvc allows us to capture (most if not all) attempts to
+ * open links in the default browser.
+ */
+var gMockExtProtSvc = {
+ _loadedURLs: [],
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+
+ externalProtocolHandlerExists(aProtocolScheme) {},
+
+ getApplicationDescription(aScheme) {},
+
+ getProtocolHandlerInfo(aProtocolScheme) {},
+
+ getProtocolHandlerInfoFromOS(aProtocolScheme, aFound) {},
+
+ isExposedProtocol(aProtocolScheme) {},
+
+ loadURI(aURI, aWindowContext) {
+ this._loadedURLs.push(aURI.spec);
+ },
+
+ setProtocolHandlerDefaults(aHandlerInfo, aOSHandlerExists) {},
+
+ urlLoaded(aURL) {
+ return this._loadedURLs.includes(aURL);
+ },
+};
+
+function MockExtProtConstructor() {
+ return gMockExtProtSvc;
+}
+
+/* Allows for planning / capture of notification events within
+ * content tabs, for example: plugin crash notifications, theme
+ * install notifications.
+ */
+var ALERT_TIMEOUT = 10000;
+
+var NotificationWatcher = {
+ planForNotification(aController) {
+ this.alerted = false;
+ aController.window.document.addEventListener(
+ "AlertActive",
+ this.alertActive
+ );
+ },
+ waitForNotification(aController) {
+ if (!this.alerted) {
+ utils.waitFor(
+ () => this.alerted,
+ "Timeout waiting for alert",
+ ALERT_TIMEOUT,
+ 100
+ );
+ }
+ // Double check the notification box has finished animating.
+ let notificationBox = mc.window.document
+ .getElementById("tabmail")
+ .selectedTab.panel.querySelector("notificationbox");
+ if (notificationBox && notificationBox._animating) {
+ utils.waitFor(
+ () => !notificationBox._animating,
+ "Timeout waiting for notification box animation to finish",
+ ALERT_TIMEOUT,
+ 100
+ );
+ }
+
+ aController.window.document.removeEventListener(
+ "AlertActive",
+ this.alertActive
+ );
+ },
+ alerted: false,
+ alertActive() {
+ NotificationWatcher.alerted = true;
+ },
+};
+
+/**
+ * Opens a content tab with the given URL.
+ *
+ * @param {string} aURL - The URL to load.
+ * @param {string} [aLinkHandler=null] - See specialTabs.contentTabType.openTab.
+ * @param {boolean} [aBackground=false] Whether the tab is opened in the background.
+ *
+ * @returns {object} The newly-opened tab.
+ */
+function open_content_tab_with_url(
+ aURL,
+ aLinkHandler = null,
+ aBackground = false
+) {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ let preCount = tabmail.tabContainer.allTabs.length;
+ tabmail.openTab("contentTab", {
+ url: aURL,
+ background: aBackground,
+ linkHandler: aLinkHandler,
+ });
+ utils.waitFor(
+ () => tabmail.tabContainer.allTabs.length == preCount + 1,
+ "Timeout waiting for the content tab to open with URL: " + aURL,
+ FAST_TIMEOUT,
+ FAST_INTERVAL
+ );
+
+ // We append new tabs at the end, so check the last one.
+ let expectedNewTab = tabmail.tabInfo[preCount];
+ folderDisplayHelper.assert_selected_tab(expectedNewTab);
+ wait_for_content_tab_load(expectedNewTab, aURL);
+ return expectedNewTab;
+}
+
+/**
+ * Opens a content tab with a click on the given element. The tab is expected to
+ * be opened in the foreground. The element is expected to be associated with
+ * the given controller.
+ *
+ * @param aElem The element to click or a function that causes the tab to open.
+ * @param aExpectedURL The URL that is expected to be opened (string).
+ * @param [aController] The controller the element is associated with. Defaults
+ * to |mc|.
+ * @param [aTabType] Optional tab type to expect (string).
+ * @returns The newly-opened tab.
+ */
+function open_content_tab_with_click(
+ aElem,
+ aExpectedURL,
+ aController,
+ aTabType = "contentTab"
+) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ let preCount =
+ aController.window.document.getElementById("tabmail").tabContainer.allTabs
+ .length;
+ if (typeof aElem != "function") {
+ EventUtils.synthesizeMouseAtCenter(aElem, {}, aElem.ownerGlobal);
+ } else {
+ aElem();
+ }
+
+ utils.waitFor(
+ () =>
+ aController.window.document.getElementById("tabmail").tabContainer.allTabs
+ .length ==
+ preCount + 1,
+ "Timeout waiting for the content tab to open",
+ FAST_TIMEOUT,
+ FAST_INTERVAL
+ );
+
+ // We append new tabs at the end, so check the last one.
+ let expectedNewTab =
+ aController.window.document.getElementById("tabmail").tabInfo[preCount];
+ folderDisplayHelper.assert_selected_tab(expectedNewTab);
+ folderDisplayHelper.assert_tab_mode_name(expectedNewTab, aTabType);
+ wait_for_content_tab_load(expectedNewTab, aExpectedURL);
+ return expectedNewTab;
+}
+
+/**
+ * Call this before triggering a page load that you are going to wait for using
+ * |wait_for_content_tab_load|. This ensures that if a page is already displayed
+ * in the given tab that state is sufficiently cleaned up so it doesn't trick us
+ * into thinking that there is no need to wait.
+ *
+ * @param [aTab] optional tab, defaulting to the current tab.
+ */
+function plan_for_content_tab_load(aTab) {
+ if (aTab === undefined) {
+ aTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ }
+ aTab.pageLoaded = false;
+}
+
+/**
+ * Waits for the given content tab to load completely with the given URL. This
+ * is expected to be accompanied by a |plan_for_content_tab_load| right before
+ * the action triggering the page load takes place.
+ *
+ * Note that you cannot call |plan_for_content_tab_load| if you're opening a new
+ * tab. That is fine, because pageLoaded is initially false.
+ *
+ * @param [aTab] Optional tab, defaulting to the current tab.
+ * @param aURL The URL being loaded in the tab.
+ * @param [aTimeout] Optional time to wait for the load.
+ */
+function wait_for_content_tab_load(aTab, aURL, aTimeout) {
+ if (aTab === undefined) {
+ aTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ }
+
+ function isLoadedChecker() {
+ // Require that the progress listener think that the page is loaded.
+ if (!aTab.pageLoaded) {
+ return false;
+ }
+ // Also require that our tab infrastructure thinks that the page is loaded.
+ return !aTab.busy;
+ }
+
+ utils.waitFor(
+ isLoadedChecker,
+ "Timeout waiting for the content tab page to load.",
+ aTimeout
+ );
+ // The above may return immediately, meaning the event queue might not get a
+ // chance. Give it a chance now.
+ utils.sleep(0);
+ // Finally, require that the tab's browser thinks that no page is being loaded.
+ wh.wait_for_browser_load(aTab.browser, aURL);
+}
+
+/**
+ * Gets the element with the given ID from the content tab's displayed page.
+ */
+function content_tab_e(aTab, aId) {
+ return aTab.browser.contentDocument.getElementById(aId);
+}
+
+/**
+ * Assert that the given content tab has the given URL loaded as a favicon.
+ */
+function assert_content_tab_has_favicon(aTab, aURL) {
+ Assert.equal(aTab.favIconUrl, aURL, "Checking tab favicon");
+}
+
+/**
+ * Returns the current "display" style property of an element.
+ */
+function get_content_tab_element_display(aTab, aElem) {
+ let style = aTab.browser.contentWindow.getComputedStyle(aElem);
+ return style.getPropertyValue("display");
+}
+
+/**
+ * Asserts that the given element is hidden from view on the page.
+ */
+function assert_content_tab_element_hidden(aTab, aElem) {
+ let display = get_content_tab_element_display(aTab, aElem);
+ Assert.equal(display, "none", "Element should be hidden");
+}
+
+/**
+ * Asserts that the given element is visible on the page.
+ */
+function assert_content_tab_element_visible(aTab, aElem) {
+ let display = get_content_tab_element_display(aTab, aElem);
+ Assert.notEqual(display, "none", "Element should be visible");
+}
+
+/**
+ * Waits for the element's display property indicate it is visible.
+ */
+function wait_for_content_tab_element_display(aTab, aElem) {
+ function isValue() {
+ return get_content_tab_element_display(aTab, aElem) != "none";
+ }
+ try {
+ utils.waitFor(isValue);
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "Timeout waiting for element to become visible"
+ );
+ } else {
+ throw e;
+ }
+ }
+}
+
+/**
+ * Finds element in document fragment, containing only the specified text
+ * as its textContent value.
+ *
+ * @param aRootNode Root node of the node tree where search should start.
+ * @param aText The string to search.
+ */
+function get_element_by_text(aRootNode, aText) {
+ // Check every node existing.
+ let nodes = aRootNode.querySelectorAll("*");
+ for (let node of nodes) {
+ // We ignore surrounding whitespace.
+ if (node.textContent.trim() == aText) {
+ return node;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Finds element containing only the specified text in the content tab's page.
+ */
+function get_content_tab_element_by_text(aTab, aText) {
+ let doc = aTab.browser.contentDocument.documentElement;
+ return get_element_by_text(doc, aText);
+}
+
+/**
+ * Asserts that the given text is present on the content tab's page.
+ */
+function assert_content_tab_text_present(aTab, aText) {
+ Assert.ok(
+ get_content_tab_element_by_text(aTab, aText),
+ `String "${aText}" should be on the content tab's page`
+ );
+}
+
+/**
+ * Asserts that the given text is absent on the content tab's page.
+ */
+function assert_content_tab_text_absent(aTab, aText) {
+ Assert.ok(
+ !get_content_tab_element_by_text(aTab, aText),
+ `String "${aText}" should not be on the content tab's page`
+ );
+}
+
+/**
+ * Returns the notification bar for a tab if one is currently visible,
+ * null if otherwise.
+ */
+function get_notification_bar_for_tab(aTab) {
+ let notificationBoxEls = mc.window.document
+ .getElementById("tabmail")
+ .selectedTab.panel.querySelector("notificationbox");
+ if (!notificationBoxEls) {
+ return null;
+ }
+
+ return notificationBoxEls;
+}
+
+function updateBlocklist(aController, aCallback) {
+ let observer = function () {
+ Services.obs.removeObserver(observer, "blocklist-updated");
+ aController.window.setTimeout(aCallback, 0);
+ };
+ Services.obs.addObserver(observer, "blocklist-updated");
+ Services.blocklist.QueryInterface(Ci.nsITimerCallback).notify(null);
+}
+
+function setAndUpdateBlocklist(aController, aURL, aCallback) {
+ if (!_originalBlocklistURL) {
+ _originalBlocklistURL = Services.prefs.getCharPref(
+ "extensions.blocklist.url"
+ );
+ }
+ Services.prefs.setCharPref("extensions.blocklist.url", aURL);
+ updateBlocklist(aController, aCallback);
+}
+
+function resetBlocklist(aController, aCallback) {
+ Services.prefs.setCharPref("extensions.blocklist.url", _originalBlocklistURL);
+ updateBlocklist(aController, aCallback);
+}
diff --git a/comm/mail/test/browser/shared-modules/CustomizationHelpers.jsm b/comm/mail/test/browser/shared-modules/CustomizationHelpers.jsm
new file mode 100644
index 0000000000..01adbb0cda
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/CustomizationHelpers.jsm
@@ -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";
+
+const EXPORTED_SYMBOLS = ["CustomizeDialogHelper"];
+
+var wh = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var USE_SHEET_PREF = "toolbar.customization.usesheet";
+
+/**
+ * Initialize the help for a customization dialog
+ *
+ * @param {} aToolbarId
+ * the ID of the toolbar to be customized
+ * @param {} aOpenElementId
+ * the ID of the element to be clicked on to open the dialog
+ * @param {} aWindowType
+ * the windowType of the window containing the dialog to be opened
+ */
+function CustomizeDialogHelper(aToolbarId, aOpenElementId, aWindowType) {
+ this._toolbarId = aToolbarId;
+ this._openElementId = aOpenElementId;
+ this._windowType = aWindowType;
+ this._openInWindow = !Services.prefs.getBoolPref(USE_SHEET_PREF);
+}
+
+CustomizeDialogHelper.prototype = {
+ /**
+ * Open a customization dialog by clicking on a given element.
+ *
+ * @param {} aController
+ * the controller object of the window for which the customization
+ * dialog should be opened
+ * @returns a controller for the customization dialog
+ */
+ async open(aController) {
+ aController.window.document.getElementById(this._openElementId).click();
+
+ let ctc;
+ // Depending on preferences the customization dialog is
+ // either a normal window or embedded into a sheet.
+ if (!this._openInWindow) {
+ ctc = wh.wait_for_frame_load(
+ aController.window.document.getElementById(
+ "customizeToolbarSheetIFrame"
+ ),
+ "chrome://messenger/content/customizeToolbar.xhtml"
+ );
+ } else {
+ ctc = wh.wait_for_existing_window(this._windowType);
+ }
+ utils.sleep(500);
+ return ctc;
+ },
+
+ /**
+ * Close the customization dialog.
+ *
+ * @param {} aCtc
+ * the controller object of the customization dialog which should be closed
+ */
+ close(aCtc) {
+ if (this._openInWindow) {
+ wh.plan_for_window_close(aCtc);
+ }
+
+ let doneButton = aCtc.window.document.getElementById("donebutton");
+ EventUtils.synthesizeMouseAtCenter(doneButton, {}, doneButton.ownerGlobal);
+ utils.sleep(0);
+ // XXX There should be an equivalent for testing the closure of
+ // XXX the dialog embedded in a sheet, but I do not know how.
+ if (this._openInWindow) {
+ wh.wait_for_window_close();
+ Assert.ok(aCtc.window.closed, "The customization dialog is not closed.");
+ }
+ },
+
+ /**
+ * Restore the default buttons in the header pane toolbar
+ * by clicking the corresponding button in the palette dialog
+ * and check if it worked.
+ *
+ * @param {} aController
+ * the controller object of the window for which the customization
+ * dialog should be opened
+ */
+ async restoreDefaultButtons(aController) {
+ let ctc = await this.open(aController);
+ let restoreButton = ctc.window.document
+ .getElementById("main-box")
+ .querySelector("[oncommand*='overlayRestoreDefaultSet();']");
+
+ EventUtils.synthesizeMouseAtCenter(
+ restoreButton,
+ {},
+ restoreButton.ownerGlobal
+ );
+ utils.sleep(0);
+
+ this.close(ctc);
+
+ let toolbar = aController.window.document.getElementById(this._toolbarId);
+ let defaultSet = toolbar.getAttribute("defaultset");
+
+ Assert.equal(toolbar.currentSet, defaultSet);
+ },
+};
diff --git a/comm/mail/test/browser/shared-modules/DOMHelpers.jsm b/comm/mail/test/browser/shared-modules/DOMHelpers.jsm
new file mode 100644
index 0000000000..719de9c381
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/DOMHelpers.jsm
@@ -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";
+
+const EXPORTED_SYMBOLS = [
+ "assert_element_visible",
+ "element_visible_recursive",
+ "assert_element_not_visible",
+ "wait_for_element",
+ "assert_next_nodes",
+ "assert_previous_nodes",
+ "wait_for_element_enabled",
+ "check_element_visible",
+ "wait_for_element_visible",
+ "wait_for_element_invisible",
+ "collapse_panes",
+];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "mc",
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+/**
+ * This function takes either a string or an elementlibs.Elem, and returns
+ * whether it is hidden or not (simply by poking at its hidden property). It
+ * doesn't try to do anything smart, like is it not into view, or whatever.
+ *
+ * @param aElt The element to query.
+ * @returns Whether the element is visible or not.
+ */
+function element_visible(aElt) {
+ let e;
+ if (typeof aElt == "string") {
+ e = lazy.mc.window.document.getElementById(aElt);
+ } else {
+ e = aElt;
+ }
+ return !e.hidden;
+}
+
+/**
+ * Assert that en element's visible.
+ *
+ * @param aElt The element, an ID or an elementlibs.Elem
+ * @param aWhy The error message in case of failure
+ */
+function assert_element_visible(aElt, aWhy) {
+ Assert.ok(element_visible(aElt), aWhy);
+}
+
+/**
+ * Returns if a element is visible by traversing all parent elements and check
+ * that all are visible.
+ *
+ * @param aElem The element to be checked
+ */
+function element_visible_recursive(aElem) {
+ if (aElem.hidden || aElem.collapsed) {
+ return false;
+ }
+ let parent = aElem.parentNode;
+ if (parent == null) {
+ return true;
+ }
+
+ // #tabpanelcontainer and its parent #tabmail-tabbox have the same selectedPanel.
+ // Don't ask me why, it's just the way it is.
+ if (
+ "selectedPanel" in parent &&
+ parent.selectedPanel != aElem &&
+ aElem.id != "tabpanelcontainer"
+ ) {
+ return false;
+ }
+ return element_visible_recursive(parent);
+}
+
+/**
+ * Assert that en element's not visible.
+ *
+ * @param aElt The element, an ID or an elementlibs.Elem
+ * @param aWhy The error message in case of failure
+ */
+function assert_element_not_visible(aElt, aWhy) {
+ Assert.ok(!element_visible(aElt), aWhy);
+}
+
+/**
+ * Wait for and return an element matching a particular CSS selector.
+ *
+ * @param aParent the node to begin searching from
+ * @param aSelector the CSS selector to search with
+ */
+function wait_for_element(aParent, aSelector) {
+ let target = null;
+ utils.waitFor(function () {
+ target = aParent.querySelector(aSelector);
+ return target != null;
+ }, "Timed out waiting for a target for selector: " + aSelector);
+
+ return target;
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart and the aNum next
+ * siblings of aStart are nodes of type aNodeType.
+ *
+ * @param aNodeType the type of node to look for, example: "br".
+ * @param aStart the first node to check.
+ * @param aNum the number of sibling br nodes to check for.
+ */
+function assert_next_nodes(aNodeType, aStart, aNum) {
+ let node = aStart;
+ for (let i = 0; i < aNum; ++i) {
+ node = node.nextSibling;
+ if (node.localName != aNodeType) {
+ throw new Error(
+ "The node should be followed by " +
+ aNum +
+ " nodes of " +
+ "type " +
+ aNodeType
+ );
+ }
+ }
+ return node;
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart and the aNum previous
+ * siblings of aStart are nodes of type aNodeType.
+ *
+ * @param aNodeType the type of node to look for, example: "br".
+ * @param aStart the first node to check.
+ * @param aNum the number of sibling br nodes to check for.
+ */
+function assert_previous_nodes(aNodeType, aStart, aNum) {
+ let node = aStart;
+ for (let i = 0; i < aNum; ++i) {
+ node = node.previousSibling;
+ if (node.localName != aNodeType) {
+ throw new Error(
+ "The node should be preceded by " +
+ aNum +
+ " nodes of " +
+ "type " +
+ aNodeType
+ );
+ }
+ }
+ return node;
+}
+
+/**
+ * Given some element, wait for that element to be enabled or disabled,
+ * depending on the value of aEnabled.
+ *
+ * @param aController the controller parent of the element
+ * @param aNode the element to check.
+ * @param aEnabled whether or not the node should be enabled, or disabled.
+ */
+function wait_for_element_enabled(aController, aElement, aEnabled) {
+ if (!("disabled" in aElement)) {
+ throw new Error(
+ "Element does not appear to have disabled property; id=" + aElement.id
+ );
+ }
+
+ utils.waitFor(
+ () => aElement.disabled != aEnabled,
+ "Element should have eventually been " +
+ (aEnabled ? "enabled" : "disabled") +
+ "; id=" +
+ aElement.id
+ );
+}
+
+function check_element_visible(aController, aId) {
+ let element = aController.window.document.getElementById(aId);
+ if (!element) {
+ return false;
+ }
+
+ while (element) {
+ if (
+ element.hidden ||
+ element.collapsed ||
+ element.clientWidth == 0 ||
+ element.clientHeight == 0 ||
+ aController.window.getComputedStyle(element).display == "none"
+ ) {
+ return false;
+ }
+ element = element.parentElement;
+ }
+ return true;
+}
+
+/**
+ * Wait for a particular element to become fully visible.
+ *
+ * @param aController the controller parent of the element
+ * @param aId id of the element to wait for
+ */
+function wait_for_element_visible(aController, aId) {
+ utils.waitFor(function () {
+ return check_element_visible(aController, aId);
+ }, "Timed out waiting for element with ID=" + aId + " to become visible");
+}
+
+/**
+ * Wait for a particular element to become fully invisible.
+ *
+ * @param aController the controller parent of the element
+ * @param aId id of the element to wait for
+ */
+function wait_for_element_invisible(aController, aId) {
+ utils.waitFor(function () {
+ return !check_element_visible(aController, aId);
+ }, "Timed out waiting for element with ID=" + aId + " to become invisible");
+}
+
+/**
+ * Helper to collapse panes separated by splitters. If aElement is a splitter
+ * itself, then this splitter is collapsed, otherwise all splitters that are
+ * direct children of aElement are collapsed.
+ *
+ * @param aElement The splitter or container
+ * @param aShouldBeCollapsed If true, collapse the pane
+ */
+function collapse_panes(aElement, aShouldBeCollapsed) {
+ let state = aShouldBeCollapsed ? "collapsed" : "open";
+ if (aElement.localName == "splitter") {
+ aElement.setAttribute("state", state);
+ } else {
+ for (let n of aElement.childNodes) {
+ if (n.localName == "splitter") {
+ n.setAttribute("state", state);
+ }
+ }
+ }
+ // Spin the event loop once to let other window elements redraw.
+ utils.sleep(50);
+}
diff --git a/comm/mail/test/browser/shared-modules/EventUtils.jsm b/comm/mail/test/browser/shared-modules/EventUtils.jsm
new file mode 100644
index 0000000000..ad21f1b670
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/EventUtils.jsm
@@ -0,0 +1,876 @@
+// Export all available functions for Mozmill
+var EXPORTED_SYMBOLS = [
+ "sendChar",
+ "sendString",
+ "synthesizeMouse",
+ "synthesizeMouseAtCenter",
+ "synthesizeMouseScroll",
+ "synthesizeKey",
+ "synthesizeMouseExpectEvent",
+];
+
+function computeButton(aEvent) {
+ if (typeof aEvent.button != "undefined") {
+ return aEvent.button;
+ }
+ return aEvent.type == "contextmenu" ? 2 : 0;
+}
+
+function computeButtons(aEvent, utils) {
+ if (typeof aEvent.buttons != "undefined") {
+ return aEvent.buttons;
+ }
+
+ if (typeof aEvent.button != "undefined") {
+ return utils.MOUSE_BUTTONS_NOT_SPECIFIED;
+ }
+
+ if (typeof aEvent.type != "undefined" && aEvent.type != "mousedown") {
+ return utils.MOUSE_BUTTONS_NO_BUTTON;
+ }
+
+ return utils.MOUSE_BUTTONS_NOT_SPECIFIED;
+}
+
+/**
+ * Parse the key modifier flags from aEvent. Used to share code between
+ * synthesizeMouse and synthesizeKey.
+ */
+function _parseModifiers(aEvent) {
+ var hwindow = Services.appShell.hiddenDOMWindow;
+
+ var mval = 0;
+ if (aEvent.shiftKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_SHIFT;
+ }
+ if (aEvent.ctrlKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_CONTROL;
+ }
+ if (aEvent.altKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_ALT;
+ }
+ if (aEvent.metaKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_META;
+ }
+ if (aEvent.accelKey) {
+ mval |= hwindow.navigator.platform.includes("Mac")
+ ? Ci.nsIDOMWindowUtils.MODIFIER_META
+ : Ci.nsIDOMWindowUtils.MODIFIER_CONTROL;
+ }
+
+ return mval;
+}
+
+/**
+ * Send the char aChar to the focused element. This method handles casing of
+ * chars (sends the right charcode, and sends a shift key for uppercase chars).
+ * No other modifiers are handled at this point.
+ *
+ * For now this method only works for ASCII characters and emulates the shift
+ * key state on US keyboard layout.
+ */
+function sendChar(aChar, aWindow) {
+ var hasShift;
+ // Emulate US keyboard layout for the shiftKey state.
+ switch (aChar) {
+ case "!":
+ case "@":
+ case "#":
+ case "$":
+ case "%":
+ case "^":
+ case "&":
+ case "*":
+ case "(":
+ case ")":
+ case "_":
+ case "+":
+ case "{":
+ case "}":
+ case ":":
+ case '"':
+ case "|":
+ case "<":
+ case ">":
+ case "?":
+ hasShift = true;
+ break;
+ default:
+ hasShift =
+ aChar.toLowerCase() != aChar.toUpperCase() &&
+ aChar == aChar.toUpperCase();
+ break;
+ }
+ synthesizeKey(aChar, { shiftKey: hasShift }, aWindow);
+}
+
+/**
+ * Send the string aStr to the focused element.
+ *
+ * For now this method only works for ASCII characters and emulates the shift
+ * key state on US keyboard layout.
+ */
+function sendString(aStr, aWindow) {
+ for (var i = 0; i < aStr.length; ++i) {
+ sendChar(aStr.charAt(i), aWindow);
+ }
+}
+
+/**
+ * Synthesize a mouse event on a target. The actual client point is determined
+ * by taking the aTarget's client box and offseting it by aOffsetX and
+ * aOffsetY. This allows mouse clicks to be simulated by calling this method.
+ *
+ * aEvent is an object which may contain the properties:
+ * `shiftKey`, `ctrlKey`, `altKey`, `metaKey`, `accessKey`, `clickCount`,
+ * `button`, `type`.
+ * For valid `type`s see nsIDOMWindowUtils' `sendMouseEvent`.
+ *
+ * If the type is specified, an mouse event of that type is fired. Otherwise,
+ * a mousedown followed by a mouseup is performed.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ *
+ * Returns whether the event had preventDefault() called on it.
+ */
+function synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) {
+ var rect = aTarget.getBoundingClientRect();
+ return synthesizeMouseAtPoint(
+ rect.left + aOffsetX,
+ rect.top + aOffsetY,
+ aEvent,
+ aWindow
+ );
+}
+
+/*
+ * Synthesize a mouse event at a particular point in aWindow.
+ *
+ * aEvent is an object which may contain the properties:
+ * `shiftKey`, `ctrlKey`, `altKey`, `metaKey`, `accessKey`, `clickCount`,
+ * `button`, `type`.
+ * For valid `type`s see nsIDOMWindowUtils' `sendMouseEvent`.
+ *
+ * If the type is specified, an mouse event of that type is fired. Otherwise,
+ * a mousedown followed by a mouseup is performed.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeMouseAtPoint(left, top, aEvent, aWindow) {
+ var utils = aWindow.windowUtils;
+ var defaultPrevented = false;
+
+ if (utils) {
+ var button = computeButton(aEvent);
+ var clickCount = aEvent.clickCount || 1;
+ var modifiers = _parseModifiers(aEvent, aWindow);
+ var pressure = "pressure" in aEvent ? aEvent.pressure : 0;
+
+ // aWindow might be cross-origin from us.
+ var MouseEvent = aWindow.MouseEvent;
+
+ // Default source to mouse.
+ var inputSource =
+ "inputSource" in aEvent
+ ? aEvent.inputSource
+ : MouseEvent.MOZ_SOURCE_MOUSE;
+ // Compute a pointerId if needed.
+ var id;
+ if ("id" in aEvent) {
+ id = aEvent.id;
+ } else {
+ var isFromPen = inputSource === MouseEvent.MOZ_SOURCE_PEN;
+ id = isFromPen
+ ? utils.DEFAULT_PEN_POINTER_ID
+ : utils.DEFAULT_MOUSE_POINTER_ID;
+ }
+
+ var isDOMEventSynthesized =
+ "isSynthesized" in aEvent ? aEvent.isSynthesized : true;
+ var isWidgetEventSynthesized =
+ "isWidgetEventSynthesized" in aEvent
+ ? aEvent.isWidgetEventSynthesized
+ : false;
+ if ("type" in aEvent && aEvent.type) {
+ defaultPrevented = utils.sendMouseEvent(
+ aEvent.type,
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ computeButtons(aEvent, utils),
+ id
+ );
+ } else {
+ utils.sendMouseEvent(
+ "mousedown",
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ computeButtons(Object.assign({ type: "mousedown" }, aEvent), utils),
+ id
+ );
+ utils.sendMouseEvent(
+ "mouseup",
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ computeButtons(Object.assign({ type: "mouseup" }, aEvent), utils),
+ id
+ );
+ }
+ }
+
+ return defaultPrevented;
+}
+
+// Call synthesizeMouse with coordinates at the center of aTarget.
+function synthesizeMouseAtCenter(aTarget, aEvent, aWindow) {
+ var rect = aTarget.getBoundingClientRect();
+ return synthesizeMouse(
+ aTarget,
+ rect.width / 2,
+ rect.height / 2,
+ aEvent,
+ aWindow
+ );
+}
+
+/**
+ * Synthesize a mouse scroll event on a target. The actual client point is determined
+ * by taking the aTarget's client box and offsetting it by aOffsetX and
+ * aOffsetY.
+ *
+ * aEvent is an object which may contain the properties:
+ * shiftKey, ctrlKey, altKey, metaKey, accessKey, button, type, axis, delta, hasPixels
+ *
+ * If the type is specified, a mouse scroll event of that type is fired. Otherwise,
+ * "DOMMouseScroll" is used.
+ *
+ * If the axis is specified, it must be one of "horizontal" or "vertical". If not specified,
+ * "vertical" is used.
+ *
+ * 'delta' is the amount to scroll by (can be positive or negative). It must
+ * be specified.
+ *
+ * 'hasPixels' specifies whether kHasPixels should be set in the scrollFlags.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeMouseScroll(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) {
+ var utils = aWindow.windowUtils;
+ if (utils) {
+ // See nsMouseScrollFlags in nsGUIEvent.h
+ const kIsVertical = 0x02;
+ const kIsHorizontal = 0x04;
+ const kHasPixels = 0x08;
+
+ var button = aEvent.button || 0;
+ var modifiers = _parseModifiers(aEvent);
+
+ var rect = aTarget.getBoundingClientRect();
+
+ var left = rect.left;
+ var top = rect.top;
+
+ var type = ("type" in aEvent && aEvent.type) || "DOMMouseScroll";
+ var axis = aEvent.axis || "vertical";
+ var scrollFlags = axis == "horizontal" ? kIsHorizontal : kIsVertical;
+ if (aEvent.hasPixels) {
+ scrollFlags |= kHasPixels;
+ }
+ utils.sendMouseScrollEvent(
+ type,
+ left + aOffsetX,
+ top + aOffsetY,
+ button,
+ scrollFlags,
+ aEvent.delta,
+ modifiers
+ );
+ }
+}
+
+/**
+ * Synthesize a key event. It is targeted at whatever would be targeted by an
+ * actual keypress by the user, typically the focused element.
+ *
+ * aKey should be:
+ * - key value (recommended). If you specify a non-printable key name,
+ * append "KEY_" prefix. Otherwise, specifying a printable key, the
+ * key value should be specified.
+ * - keyCode name starting with "VK_" (e.g., VK_RETURN). This is available
+ * only for compatibility with legacy API. Don't use this with new tests.
+ *
+ * aEvent is an object which may contain the properties:
+ * - code: If you emulates a physical keyboard's key event, this should be
+ * specified.
+ * - repeat: If you emulates auto-repeat, you should set the count of repeat.
+ * This method will automatically synthesize keydown (and keypress).
+ * - location: If you want to specify this, you can specify this explicitly.
+ * However, if you don't specify this value, it will be computed
+ * from code value.
+ * - type: Basically, you shouldn't specify this. Then, this function will
+ * synthesize keydown (, keypress) and keyup.
+ * If keydown is specified, this only fires keydown (and keypress if
+ * it should be fired).
+ * If keyup is specified, this only fires keyup.
+ * - altKey, altGraphKey, ctrlKey, capsLockKey, fnKey, fnLockKey, numLockKey,
+ * metaKey, osKey, scrollLockKey, shiftKey, symbolKey, symbolLockKey:
+ * Basically, you shouldn't use these attributes. nsITextInputProcessor
+ * manages modifier key state when you synthesize modifier key events.
+ * However, if some of these attributes are true, this function activates
+ * the modifiers only during dispatching the key events.
+ * Note that if some of these values are false, they are ignored (i.e.,
+ * not inactivated with this function).
+ * - keyCode: Must be 0 - 255 (0xFF). If this is specified explicitly,
+ * .keyCode value is initialized with this value.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ * aCallback is optional, use the callback for receiving notifications of TIP.
+ */
+function synthesizeKey(aKey, aEvent, aWindow, aCallback) {
+ var TIP = _getTIP(aWindow, aCallback);
+ if (!TIP) {
+ return;
+ }
+ var KeyboardEvent = _getKeyboardEvent(aWindow);
+ var modifiers = _emulateToActivateModifiers(TIP, aEvent, aWindow);
+ var keyEventDict = _createKeyboardEventDictionary(aKey, aEvent, aWindow);
+ var keyEvent = new KeyboardEvent("", keyEventDict.dictionary);
+ var dispatchKeydown =
+ !("type" in aEvent) || aEvent.type === "keydown" || !aEvent.type;
+ var dispatchKeyup =
+ !("type" in aEvent) || aEvent.type === "keyup" || !aEvent.type;
+
+ try {
+ if (dispatchKeydown) {
+ TIP.keydown(keyEvent, keyEventDict.flags);
+ if ("repeat" in aEvent && aEvent.repeat > 1) {
+ keyEventDict.dictionary.repeat = true;
+ var repeatedKeyEvent = new KeyboardEvent("", keyEventDict.dictionary);
+ for (var i = 1; i < aEvent.repeat; i++) {
+ TIP.keydown(repeatedKeyEvent, keyEventDict.flags);
+ }
+ }
+ }
+ if (dispatchKeyup) {
+ TIP.keyup(keyEvent, keyEventDict.flags);
+ }
+ } finally {
+ _emulateToInactivateModifiers(TIP, modifiers, aWindow);
+ }
+}
+
+var _gSeenEvent = false;
+
+/**
+ * Indicate that an event with an original target of aExpectedTarget and
+ * a type of aExpectedEvent is expected to be fired, or not expected to
+ * be fired.
+ */
+function _expectEvent(aExpectedTarget, aExpectedEvent, aTestName) {
+ if (!aExpectedTarget || !aExpectedEvent) {
+ return null;
+ }
+
+ _gSeenEvent = false;
+
+ var type =
+ aExpectedEvent.charAt(0) == "!"
+ ? aExpectedEvent.substring(1)
+ : aExpectedEvent;
+ var eventHandler = function (event) {
+ var epassed =
+ !_gSeenEvent && event.target == aExpectedTarget && event.type == type;
+ if (!epassed) {
+ throw new Error(
+ aTestName + " " + type + " event target " + (_gSeenEvent ? "twice" : "")
+ );
+ }
+ _gSeenEvent = true;
+ };
+
+ aExpectedTarget.addEventListener(type, eventHandler);
+ return eventHandler;
+}
+
+/**
+ * Check if the event was fired or not. The event handler aEventHandler
+ * will be removed.
+ */
+function _checkExpectedEvent(
+ aExpectedTarget,
+ aExpectedEvent,
+ aEventHandler,
+ aTestName
+) {
+ if (aEventHandler) {
+ var expectEvent = aExpectedEvent.charAt(0) != "!";
+ var type = expectEvent ? aExpectedEvent : aExpectedEvent.substring(1);
+ aExpectedTarget.removeEventListener(type, aEventHandler);
+ var desc = type + " event";
+ if (expectEvent) {
+ desc += " not";
+ }
+ if (_gSeenEvent != expectEvent) {
+ throw new Error(aTestName + ": " + desc + " fired.");
+ }
+ }
+
+ _gSeenEvent = false;
+}
+
+/**
+ * Similar to synthesizeMouse except that a test is performed to see if an
+ * event is fired at the right target as a result.
+ *
+ * aExpectedTarget - the expected originalTarget of the event.
+ * aExpectedEvent - the expected type of the event, such as 'select'.
+ * aTestName - the test name when outputting results
+ *
+ * To test that an event is not fired, use an expected type preceded by an
+ * exclamation mark, such as '!select'. This might be used to test that a
+ * click on a disabled element doesn't fire certain events for instance.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeMouseExpectEvent(
+ aTarget,
+ aOffsetX,
+ aOffsetY,
+ aEvent,
+ aExpectedTarget,
+ aExpectedEvent,
+ aTestName,
+ aWindow
+) {
+ var eventHandler = _expectEvent(aExpectedTarget, aExpectedEvent, aTestName);
+ synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow);
+ _checkExpectedEvent(aExpectedTarget, aExpectedEvent, eventHandler, aTestName);
+}
+
+/**
+ * The functions that follow were copied from
+ * mozilla-central/testing/mochitest/tests/SimpleTest/EventUtils.js
+ */
+
+var TIPMap = new WeakMap();
+
+function _getTIP(aWindow, aCallback) {
+ var tip;
+ if (TIPMap.has(aWindow)) {
+ tip = TIPMap.get(aWindow);
+ } else {
+ tip = Cc["@mozilla.org/text-input-processor;1"].createInstance(
+ Ci.nsITextInputProcessor
+ );
+ TIPMap.set(aWindow, tip);
+ }
+ if (!tip.beginInputTransactionForTests(aWindow, aCallback)) {
+ tip = null;
+ TIPMap.delete(aWindow);
+ }
+ return tip;
+}
+
+function _getKeyboardEvent(aWindow) {
+ if (typeof KeyboardEvent != "undefined") {
+ try {
+ // See if the object can be instantiated; sometimes this yields
+ // 'TypeError: can't access dead object' or 'KeyboardEvent is not a constructor'.
+ new KeyboardEvent("", {});
+ return KeyboardEvent;
+ } catch (ex) {}
+ }
+ return aWindow.KeyboardEvent;
+}
+
+/* eslint-disable complexity */
+function _guessKeyNameFromKeyCode(aKeyCode, aWindow) {
+ var KeyboardEvent = _getKeyboardEvent(aWindow);
+ switch (aKeyCode) {
+ case KeyboardEvent.DOM_VK_CANCEL:
+ return "Cancel";
+ case KeyboardEvent.DOM_VK_HELP:
+ return "Help";
+ case KeyboardEvent.DOM_VK_BACK_SPACE:
+ return "Backspace";
+ case KeyboardEvent.DOM_VK_TAB:
+ return "Tab";
+ case KeyboardEvent.DOM_VK_CLEAR:
+ return "Clear";
+ case KeyboardEvent.DOM_VK_RETURN:
+ return "Enter";
+ case KeyboardEvent.DOM_VK_SHIFT:
+ return "Shift";
+ case KeyboardEvent.DOM_VK_CONTROL:
+ return "Control";
+ case KeyboardEvent.DOM_VK_ALT:
+ return "Alt";
+ case KeyboardEvent.DOM_VK_PAUSE:
+ return "Pause";
+ case KeyboardEvent.DOM_VK_EISU:
+ return "Eisu";
+ case KeyboardEvent.DOM_VK_ESCAPE:
+ return "Escape";
+ case KeyboardEvent.DOM_VK_CONVERT:
+ return "Convert";
+ case KeyboardEvent.DOM_VK_NONCONVERT:
+ return "NonConvert";
+ case KeyboardEvent.DOM_VK_ACCEPT:
+ return "Accept";
+ case KeyboardEvent.DOM_VK_MODECHANGE:
+ return "ModeChange";
+ case KeyboardEvent.DOM_VK_PAGE_UP:
+ return "PageUp";
+ case KeyboardEvent.DOM_VK_PAGE_DOWN:
+ return "PageDown";
+ case KeyboardEvent.DOM_VK_END:
+ return "End";
+ case KeyboardEvent.DOM_VK_HOME:
+ return "Home";
+ case KeyboardEvent.DOM_VK_LEFT:
+ return "ArrowLeft";
+ case KeyboardEvent.DOM_VK_UP:
+ return "ArrowUp";
+ case KeyboardEvent.DOM_VK_RIGHT:
+ return "ArrowRight";
+ case KeyboardEvent.DOM_VK_DOWN:
+ return "ArrowDown";
+ case KeyboardEvent.DOM_VK_SELECT:
+ return "Select";
+ case KeyboardEvent.DOM_VK_PRINT:
+ return "Print";
+ case KeyboardEvent.DOM_VK_EXECUTE:
+ return "Execute";
+ case KeyboardEvent.DOM_VK_PRINTSCREEN:
+ return "PrintScreen";
+ case KeyboardEvent.DOM_VK_INSERT:
+ return "Insert";
+ case KeyboardEvent.DOM_VK_DELETE:
+ return "Delete";
+ case KeyboardEvent.DOM_VK_WIN:
+ return "OS";
+ case KeyboardEvent.DOM_VK_CONTEXT_MENU:
+ return "ContextMenu";
+ case KeyboardEvent.DOM_VK_SLEEP:
+ return "Standby";
+ case KeyboardEvent.DOM_VK_F1:
+ return "F1";
+ case KeyboardEvent.DOM_VK_F2:
+ return "F2";
+ case KeyboardEvent.DOM_VK_F3:
+ return "F3";
+ case KeyboardEvent.DOM_VK_F4:
+ return "F4";
+ case KeyboardEvent.DOM_VK_F5:
+ return "F5";
+ case KeyboardEvent.DOM_VK_F6:
+ return "F6";
+ case KeyboardEvent.DOM_VK_F7:
+ return "F7";
+ case KeyboardEvent.DOM_VK_F8:
+ return "F8";
+ case KeyboardEvent.DOM_VK_F9:
+ return "F9";
+ case KeyboardEvent.DOM_VK_F10:
+ return "F10";
+ case KeyboardEvent.DOM_VK_F11:
+ return "F11";
+ case KeyboardEvent.DOM_VK_F12:
+ return "F12";
+ case KeyboardEvent.DOM_VK_F13:
+ return "F13";
+ case KeyboardEvent.DOM_VK_F14:
+ return "F14";
+ case KeyboardEvent.DOM_VK_F15:
+ return "F15";
+ case KeyboardEvent.DOM_VK_F16:
+ return "F16";
+ case KeyboardEvent.DOM_VK_F17:
+ return "F17";
+ case KeyboardEvent.DOM_VK_F18:
+ return "F18";
+ case KeyboardEvent.DOM_VK_F19:
+ return "F19";
+ case KeyboardEvent.DOM_VK_F20:
+ return "F20";
+ case KeyboardEvent.DOM_VK_F21:
+ return "F21";
+ case KeyboardEvent.DOM_VK_F22:
+ return "F22";
+ case KeyboardEvent.DOM_VK_F23:
+ return "F23";
+ case KeyboardEvent.DOM_VK_F24:
+ return "F24";
+ case KeyboardEvent.DOM_VK_NUM_LOCK:
+ return "NumLock";
+ case KeyboardEvent.DOM_VK_SCROLL_LOCK:
+ return "ScrollLock";
+ case KeyboardEvent.DOM_VK_VOLUME_MUTE:
+ return "AudioVolumeMute";
+ case KeyboardEvent.DOM_VK_VOLUME_DOWN:
+ return "AudioVolumeDown";
+ case KeyboardEvent.DOM_VK_VOLUME_UP:
+ return "AudioVolumeUp";
+ case KeyboardEvent.DOM_VK_META:
+ return "Meta";
+ case KeyboardEvent.DOM_VK_ALTGR:
+ return "AltGraph";
+ case KeyboardEvent.DOM_VK_ATTN:
+ return "Attn";
+ case KeyboardEvent.DOM_VK_CRSEL:
+ return "CrSel";
+ case KeyboardEvent.DOM_VK_EXSEL:
+ return "ExSel";
+ case KeyboardEvent.DOM_VK_EREOF:
+ return "EraseEof";
+ case KeyboardEvent.DOM_VK_PLAY:
+ return "Play";
+ default:
+ return "Unidentified";
+ }
+}
+/* eslint-enable complexity */
+
+function _createKeyboardEventDictionary(aKey, aKeyEvent, aWindow) {
+ var result = { dictionary: null, flags: 0 };
+ var keyCodeIsDefined = "keyCode" in aKeyEvent;
+ var keyCode =
+ keyCodeIsDefined && aKeyEvent.keyCode >= 0 && aKeyEvent.keyCode <= 255
+ ? aKeyEvent.keyCode
+ : 0;
+ var keyName = "Unidentified";
+ if (aKey.indexOf("KEY_") == 0) {
+ keyName = aKey.substr("KEY_".length);
+ result.flags |= Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY;
+ } else if (aKey.indexOf("VK_") == 0) {
+ keyCode = _getKeyboardEvent(aWindow)["DOM_" + aKey];
+ if (!keyCode) {
+ throw new Error("Unknown key: " + aKey);
+ }
+ keyName = _guessKeyNameFromKeyCode(keyCode, aWindow);
+ result.flags |= Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY;
+ } else if (aKey != "") {
+ keyName = aKey;
+ if (!keyCodeIsDefined) {
+ keyCode = _computeKeyCodeFromChar(aKey.charAt(0), aWindow);
+ }
+ if (!keyCode) {
+ result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEYCODE_ZERO;
+ }
+ result.flags |= Ci.nsITextInputProcessor.KEY_FORCE_PRINTABLE_KEY;
+ }
+ var locationIsDefined = "location" in aKeyEvent;
+ if (locationIsDefined && aKeyEvent.location === 0) {
+ result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEY_LOCATION_STANDARD;
+ }
+ result.dictionary = {
+ key: keyName,
+ code: "code" in aKeyEvent ? aKeyEvent.code : "",
+ location: locationIsDefined ? aKeyEvent.location : 0,
+ repeat: "repeat" in aKeyEvent ? aKeyEvent.repeat === true : false,
+ keyCode,
+ };
+ return result;
+}
+
+function _emulateToActivateModifiers(aTIP, aKeyEvent, aWindow) {
+ if (!aKeyEvent) {
+ return null;
+ }
+ var KeyboardEvent = _getKeyboardEvent(aWindow);
+
+ var modifiers = {
+ normal: [
+ { key: "Alt", attr: "altKey" },
+ { key: "AltGraph", attr: "altGraphKey" },
+ { key: "Control", attr: "ctrlKey" },
+ { key: "Fn", attr: "fnKey" },
+ { key: "Meta", attr: "metaKey" },
+ { key: "OS", attr: "osKey" },
+ { key: "Shift", attr: "shiftKey" },
+ { key: "Symbol", attr: "symbolKey" },
+ {
+ key: aWindow.navigator.platform.includes("Mac") ? "Meta" : "Control",
+ attr: "accelKey",
+ },
+ ],
+ lockable: [
+ { key: "CapsLock", attr: "capsLockKey" },
+ { key: "FnLock", attr: "fnLockKey" },
+ { key: "NumLock", attr: "numLockKey" },
+ { key: "ScrollLock", attr: "scrollLockKey" },
+ { key: "SymbolLock", attr: "symbolLockKey" },
+ ],
+ };
+
+ for (let i = 0; i < modifiers.normal.length; i++) {
+ if (!aKeyEvent[modifiers.normal[i].attr]) {
+ continue;
+ }
+ if (aTIP.getModifierState(modifiers.normal[i].key)) {
+ continue; // already activated.
+ }
+ let event = new KeyboardEvent("", { key: modifiers.normal[i].key });
+ aTIP.keydown(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ modifiers.normal[i].activated = true;
+ }
+ for (let i = 0; i < modifiers.lockable.length; i++) {
+ if (!aKeyEvent[modifiers.lockable[i].attr]) {
+ continue;
+ }
+ if (aTIP.getModifierState(modifiers.lockable[i].key)) {
+ continue; // already activated.
+ }
+ let event = new KeyboardEvent("", { key: modifiers.lockable[i].key });
+ aTIP.keydown(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ aTIP.keyup(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ modifiers.lockable[i].activated = true;
+ }
+ return modifiers;
+}
+
+function _emulateToInactivateModifiers(aTIP, aModifiers, aWindow) {
+ if (!aModifiers) {
+ return;
+ }
+ var KeyboardEvent = _getKeyboardEvent(aWindow);
+ for (let i = 0; i < aModifiers.normal.length; i++) {
+ if (!aModifiers.normal[i].activated) {
+ continue;
+ }
+ let event = new KeyboardEvent("", { key: aModifiers.normal[i].key });
+ aTIP.keyup(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ }
+ for (let i = 0; i < aModifiers.lockable.length; i++) {
+ if (!aModifiers.lockable[i].activated) {
+ continue;
+ }
+ if (!aTIP.getModifierState(aModifiers.lockable[i].key)) {
+ continue; // who already inactivated this?
+ }
+ let event = new KeyboardEvent("", { key: aModifiers.lockable[i].key });
+ aTIP.keydown(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ aTIP.keyup(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ }
+}
+
+/* eslint-disable complexity */
+function _computeKeyCodeFromChar(aChar, aWindow) {
+ if (aChar.length != 1) {
+ return 0;
+ }
+ var KeyEvent = _getKeyboardEvent(aWindow);
+ if (aChar >= "a" && aChar <= "z") {
+ return KeyEvent.DOM_VK_A + aChar.charCodeAt(0) - "a".charCodeAt(0);
+ }
+ if (aChar >= "A" && aChar <= "Z") {
+ return KeyEvent.DOM_VK_A + aChar.charCodeAt(0) - "A".charCodeAt(0);
+ }
+ if (aChar >= "0" && aChar <= "9") {
+ return KeyEvent.DOM_VK_0 + aChar.charCodeAt(0) - "0".charCodeAt(0);
+ }
+ // returns US keyboard layout's keycode
+ switch (aChar) {
+ case "~":
+ case "`":
+ return KeyEvent.DOM_VK_BACK_QUOTE;
+ case "!":
+ return KeyEvent.DOM_VK_1;
+ case "@":
+ return KeyEvent.DOM_VK_2;
+ case "#":
+ return KeyEvent.DOM_VK_3;
+ case "$":
+ return KeyEvent.DOM_VK_4;
+ case "%":
+ return KeyEvent.DOM_VK_5;
+ case "^":
+ return KeyEvent.DOM_VK_6;
+ case "&":
+ return KeyEvent.DOM_VK_7;
+ case "*":
+ return KeyEvent.DOM_VK_8;
+ case "(":
+ return KeyEvent.DOM_VK_9;
+ case ")":
+ return KeyEvent.DOM_VK_0;
+ case "-":
+ case "_":
+ return KeyEvent.DOM_VK_SUBTRACT;
+ case "+":
+ case "=":
+ return KeyEvent.DOM_VK_EQUALS;
+ case "{":
+ case "[":
+ return KeyEvent.DOM_VK_OPEN_BRACKET;
+ case "}":
+ case "]":
+ return KeyEvent.DOM_VK_CLOSE_BRACKET;
+ case "|":
+ case "\\":
+ return KeyEvent.DOM_VK_BACK_SLASH;
+ case ":":
+ case ";":
+ return KeyEvent.DOM_VK_SEMICOLON;
+ case "'":
+ case '"':
+ return KeyEvent.DOM_VK_QUOTE;
+ case "<":
+ case ",":
+ return KeyEvent.DOM_VK_COMMA;
+ case ">":
+ case ".":
+ return KeyEvent.DOM_VK_PERIOD;
+ case "?":
+ case "/":
+ return KeyEvent.DOM_VK_SLASH;
+ case "\n":
+ return KeyEvent.DOM_VK_RETURN;
+ case " ":
+ return KeyEvent.DOM_VK_SPACE;
+ default:
+ return 0;
+ }
+}
+/* eslint-enable complexity */
diff --git a/comm/mail/test/browser/shared-modules/FolderDisplayHelpers.jsm b/comm/mail/test/browser/shared-modules/FolderDisplayHelpers.jsm
new file mode 100644
index 0000000000..a90cbcf457
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/FolderDisplayHelpers.jsm
@@ -0,0 +1,3243 @@
+/* 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 = [
+ "add_message_to_folder",
+ "add_message_sets_to_folders",
+ "add_to_toolbar",
+ "archive_messages",
+ "archive_selected_messages",
+ "assert_attachment_list_focused",
+ "assert_collapsed",
+ "assert_default_window_size",
+ "assert_displayed",
+ "assert_expanded",
+ "assert_folder_at_index_as",
+ "assert_folder_child_in_view",
+ "assert_folder_collapsed",
+ "assert_folder_displayed",
+ "assert_folder_expanded",
+ "assert_folder_mode",
+ "assert_folder_not_visible",
+ "assert_folder_selected",
+ "assert_folder_selected_and_displayed",
+ "assert_folder_tree_focused",
+ "assert_folder_tree_view_row_count",
+ "assert_folder_visible",
+ "assert_folders_selected",
+ "assert_folders_selected_and_displayed",
+ "assert_mail_view",
+ "assert_message_not_in_view",
+ "assert_message_pane_focused",
+ "assert_message_pane_hidden",
+ "assert_message_pane_visible",
+ "assert_messages_in_view",
+ "assert_messages_not_in_view",
+ "assert_messages_summarized",
+ "assert_multimessage_pane_focused",
+ "assert_not_selected_tab",
+ "assert_not_showing_unread_only",
+ "assert_not_shown",
+ "assert_nothing_selected",
+ "assert_number_of_tabs_open",
+ "assert_pane_layout",
+ "assert_selected",
+ "assert_selected_and_displayed",
+ "assert_selected_tab",
+ "assert_showing_unread_only",
+ "assert_summary_contains_N_elts",
+ "assert_tab_has_title",
+ "assert_tab_mode_name",
+ "assert_tab_titled_from",
+ "assert_thread_tree_focused",
+ "assert_visible",
+ "be_in_folder",
+ "click_tree_row",
+ "close_message_window",
+ "close_popup",
+ "close_tab",
+ "collapse_all_threads",
+ "collapse_folder",
+ "create_encrypted_smime_message",
+ "create_encrypted_openpgp_message",
+ "create_folder",
+ "create_message",
+ "create_thread",
+ "create_virtual_folder",
+ "delete_messages",
+ "delete_via_popup",
+ "display_message_in_folder_tab",
+ "empty_folder",
+ "enter_folder",
+ "expand_all_threads",
+ "expand_folder",
+ "FAKE_SERVER_HOSTNAME",
+ "focus_folder_tree",
+ "focus_message_pane",
+ "focus_multimessage_pane",
+ "focus_thread_tree",
+ "gDefaultWindowHeight",
+ "gDefaultWindowWidth",
+ "get_about_3pane",
+ "get_about_message",
+ "get_smart_folder_named",
+ "get_special_folder",
+ "inboxFolder",
+ "kClassicMailLayout",
+ "kVerticalMailLayout",
+ "kWideMailLayout",
+ "make_display_grouped",
+ "make_display_threaded",
+ "make_display_unthreaded",
+ "make_message_sets_in_folders",
+ "mc",
+ "middle_click_on_folder",
+ "middle_click_on_row",
+ "msgGen",
+ "normalize_for_json",
+ "open_folder_in_new_tab",
+ "open_folder_in_new_window",
+ "open_message_from_file",
+ "open_selected_message",
+ "open_selected_message_in_new_tab",
+ "open_selected_message_in_new_window",
+ "open_selected_messages",
+ "plan_for_message_display",
+ "plan_to_wait_for_folder_events",
+ "press_delete",
+ "press_enter",
+ "remove_from_toolbar",
+ "reset_close_message_on_delete",
+ "reset_context_menu_background_tabs",
+ "reset_open_message_behavior",
+ "restore_default_window_size",
+ "right_click_on_folder",
+ "right_click_on_row",
+ "select_click_folder",
+ "select_click_row",
+ "select_column_click_row",
+ "select_control_click_row",
+ "select_none",
+ "select_shift_click_folder",
+ "select_shift_click_row",
+ "set_close_message_on_delete",
+ "set_context_menu_background_tabs",
+ "set_mail_view",
+ "set_mc",
+ "set_open_message_behavior",
+ "set_pane_layout",
+ "set_show_unread_only",
+ "show_folder_pane",
+ "smimeUtils_ensureNSS",
+ "smimeUtils_loadCertificateAndKey",
+ "smimeUtils_loadPEMCertificate",
+ "switch_tab",
+ "throw_and_dump_view_state",
+ "toggle_main_menu",
+ "toggle_message_pane",
+ "toggle_thread_row",
+ "wait_for_all_messages_to_load",
+ "wait_for_blank_content_pane",
+ "wait_for_folder_events",
+ "wait_for_message_display_completion",
+ "wait_for_popup_to_open",
+];
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+// the windowHelper module
+var windowHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+var nsMsgViewIndex_None = 0xffffffff;
+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, MessageScenarioFactory, SyntheticMessageSet } =
+ ChromeUtils.import("resource://testing-common/mailnews/MessageGenerator.jsm");
+var { MessageInjection } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageInjection.jsm"
+);
+var { SmimeUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/smimeUtils.jsm"
+);
+var { dump_view_state } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ViewHelpers.jsm"
+);
+
+/**
+ * Server hostname as set in runtest.py
+ */
+var FAKE_SERVER_HOSTNAME = "tinderbox123";
+
+/** The controller for the main 3-pane window. */
+var mc;
+function set_mc(value) {
+ mc = value;
+}
+
+/** the index of the current 'other' tab */
+var otherTab;
+
+var testHelperModule;
+
+var msgGen;
+
+var messageInjection;
+
+msgGen = new MessageGenerator();
+var msgGenFactory = new MessageScenarioFactory(msgGen);
+
+var inboxFolder = null;
+
+// logHelper exports
+var normalize_for_json;
+
+// Default size of the main Thunderbird window in which the tests will run.
+var gDefaultWindowWidth = 1024;
+var gDefaultWindowHeight = 768;
+
+var initialized = false;
+function setupModule() {
+ if (initialized) {
+ return;
+ }
+ initialized = true;
+
+ testHelperModule = {
+ Cc,
+ Ci,
+ Cu,
+ // fake some xpcshell stuff
+ _TEST_FILE: ["mozmill"],
+ _do_not_wrap_xpcshell: true,
+ do_throw(aMsg) {
+ throw new Error(aMsg);
+ },
+ do_check_eq() {},
+ do_check_neq() {},
+ gDEPTH: "../../",
+ };
+
+ // -- logging
+
+ // The xpcshell test resources assume they are loaded into a single global
+ // namespace, so we need to help them out to maintain their delusion.
+ load_via_src_path(
+ "../../../testing/mochitest/resources/logHelper.js",
+ testHelperModule
+ );
+ // - Hook-up logHelper to the mozmill event system...
+ normalize_for_json = testHelperModule._normalize_for_json;
+
+ mc = windowHelper.wait_for_existing_window("mail:3pane");
+
+ setupAccountStuff();
+}
+setupModule();
+
+function get_about_3pane(win = mc.window) {
+ let tabmail = win.document.getElementById("tabmail");
+ if (tabmail?.currentTabInfo.mode.name == "mail3PaneTab") {
+ return tabmail.currentAbout3Pane;
+ }
+ throw new Error("The current tab is not a mail3PaneTab.");
+}
+
+function get_about_message(win = mc.window) {
+ let doc = win.document;
+ let tabmail = doc.getElementById("tabmail");
+ if (tabmail?.currentTabInfo.mode.name == "mailMessageTab") {
+ return tabmail.currentAboutMessage;
+ } else if (tabmail?.currentTabInfo.mode.name == "mail3PaneTab") {
+ // Not `currentAboutMessage`, we'll return a value even if it's hidden.
+ return get_about_3pane(win).messageBrowser.contentWindow;
+ } else if (
+ doc.documentElement.getAttribute("windowtype") == "mail:messageWindow"
+ ) {
+ return doc.getElementById("messageBrowser").contentWindow;
+ }
+ throw new Error("The current tab is not a mail3PaneTab or mailMessageTab.");
+}
+
+function ready_about_win(win) {
+ if (win.document.readyState == "complete") {
+ return;
+ }
+ utils.waitFor(
+ () => win.document.readyState == "complete",
+ `About win should complete loading`
+ );
+}
+
+function get_about_3pane_or_about_message(win = mc.window) {
+ let doc = win.document;
+ let tabmail = doc.getElementById("tabmail");
+ if (
+ tabmail &&
+ ["mail3PaneTab", "mailMessageTab"].includes(
+ tabmail.currentTabInfo.mode.name
+ )
+ ) {
+ return tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ } else if (
+ doc.documentElement.getAttribute("windowtype") == "mail:messageWindow"
+ ) {
+ return doc.getElementById("messageBrowser").contentWindow;
+ }
+ throw new Error("The current tab is not a mail3PaneTab or mailMessageTab.");
+}
+
+function get_db_view(win = mc.window) {
+ let aboutMessageWin = get_about_3pane_or_about_message(win);
+ ready_about_win(aboutMessageWin);
+ return aboutMessageWin.gDBView;
+}
+
+function smimeUtils_ensureNSS() {
+ SmimeUtils.ensureNSS();
+}
+
+function smimeUtils_loadPEMCertificate(file, certType, loadKey = false) {
+ SmimeUtils.loadPEMCertificate(file, certType, loadKey);
+}
+
+function smimeUtils_loadCertificateAndKey(file, pw) {
+ SmimeUtils.loadCertificateAndKey(file, pw);
+}
+
+function setupAccountStuff() {
+ messageInjection = new MessageInjection(
+ {
+ mode: "local",
+ },
+ msgGen
+ );
+ inboxFolder = messageInjection.getInboxFolder();
+}
+
+/*
+ * Although we all agree that the use of generators when dealing with async
+ * operations is awesome, the mozmill idiom is for calls to be synchronous and
+ * just spin event loops when they need to wait for things to happen. This
+ * does make the test code significantly less confusing, so we do it too.
+ * All of our operations are synchronous and just spin until they are happy.
+ */
+
+/**
+ * Create a folder and rebuild the folder tree view.
+ *
+ * @param {string} aFolderName - A folder name with no support for hierarchy at this time.
+ * @param {nsMsgFolderFlags} [aSpecialFlags] An optional list of nsMsgFolderFlags bits to set.
+ * @returns {nsIMsgFolder}
+ */
+async function create_folder(aFolderName, aSpecialFlags) {
+ wait_for_message_display_completion();
+
+ let folder = await messageInjection.makeEmptyFolder(
+ aFolderName,
+ aSpecialFlags
+ );
+ return folder;
+}
+
+/**
+ * Create a virtual folder by deferring to |MessageInjection.makeVirtualFolder| and making
+ * sure to rebuild the folder tree afterwards.
+ *
+ * @see MessageInjection.makeVirtualFolder
+ * @returns {nsIMsgFolder}
+ */
+function create_virtual_folder(...aArgs) {
+ let folder = messageInjection.makeVirtualFolder(...aArgs);
+ return folder;
+}
+
+/**
+ * Get special folder having a folder flag under Local Folders.
+ * This function clears the contents of the folder by default.
+ *
+ * @param aFolderFlag Folder flag of the required folder.
+ * @param aCreate Create the folder if it does not exist yet.
+ * @param aEmpty Set to false if messages from the folder must not be emptied.
+ */
+async function get_special_folder(
+ aFolderFlag,
+ aCreate = false,
+ aServer,
+ aEmpty = true
+) {
+ let folderNames = new Map([
+ [Ci.nsMsgFolderFlags.Drafts, "Drafts"],
+ [Ci.nsMsgFolderFlags.Templates, "Templates"],
+ [Ci.nsMsgFolderFlags.Queue, "Outbox"],
+ [Ci.nsMsgFolderFlags.Inbox, "Inbox"],
+ ]);
+
+ let folder = (
+ aServer ? aServer : MailServices.accounts.localFoldersServer
+ ).rootFolder.getFolderWithFlags(aFolderFlag);
+
+ if (!folder && aCreate) {
+ folder = await create_folder(folderNames.get(aFolderFlag), [aFolderFlag]);
+ }
+ if (!folder) {
+ throw new Error("Special folder not found");
+ }
+
+ // Ensure the folder is empty so that each test file can puts its new messages in it
+ // and they are always at reliable positions (starting from 0).
+ if (aEmpty) {
+ await empty_folder(folder);
+ }
+
+ return folder;
+}
+
+/**
+ * Create a thread with the specified number of messages in it.
+ *
+ * @param {number} aCount
+ * @returns {SyntheticMessageSet}
+ */
+function create_thread(aCount) {
+ return new SyntheticMessageSet(msgGenFactory.directReply(aCount));
+}
+
+/**
+ * Create and return a SyntheticMessage object.
+ *
+ * @param {MakeMessageOptions} aArgs An arguments object to be passed to
+ * MessageGenerator.makeMessage()
+ * @returns {SyntheticMessage}
+ */
+function create_message(aArgs) {
+ return msgGen.makeMessage(aArgs);
+}
+
+/**
+ * Create and return an SMIME SyntheticMessage object.
+ *
+ * @param {MakeMessageOptions} aArgs An arguments object to be passed to
+ * MessageGenerator.makeEncryptedSMimeMessage()
+ */
+function create_encrypted_smime_message(aArgs) {
+ return msgGen.makeEncryptedSMimeMessage(aArgs);
+}
+
+/**
+ * Create and return an OpenPGP SyntheticMessage object.
+ *
+ * @param {MakeMessageOptions} aArgs An arguments object to be passed to
+ * MessageGenerator.makeEncryptedOpenPGPMessage()
+ */
+function create_encrypted_openpgp_message(aArgs) {
+ return msgGen.makeEncryptedOpenPGPMessage(aArgs);
+}
+
+/**
+ * Adds a SyntheticMessage as a SyntheticMessageSet to a folder or folders.
+ *
+ * @see MessageInjection.addSetsToFolders
+ * @param {SyntheticMessage} aMsg
+ * @param {nsIMsgFolder[]} aFolder
+ */
+async function add_message_to_folder(aFolder, aMsg) {
+ await messageInjection.addSetsToFolders(aFolder, [
+ new SyntheticMessageSet([aMsg]),
+ ]);
+}
+
+/**
+ * Adds SyntheticMessageSets to a folder or folders.
+ *
+ * @see MessageInjection.addSetsToFolders
+ * @param {nsIMsgLocalMailFolder[]} aFolders
+ * @param {SyntheticMessageSet[]} aMsg
+ */
+async function add_message_sets_to_folders(aFolders, aMsg) {
+ await messageInjection.addSetsToFolders(aFolders, aMsg);
+}
+/**
+ * Makes SyntheticMessageSets in aFolders
+ *
+ * @param {nsIMsgFolder[]} aFolders
+ * @param {MakeMessageOptions[]} aOptions
+ * @returns {SyntheticMessageSet[]}
+ */
+async function make_message_sets_in_folders(aFolders, aOptions) {
+ return messageInjection.makeNewSetsInFolders(aFolders, aOptions);
+}
+
+/**
+ * @param {SyntheticMessageSet} aSynMessageSet The set of messages
+ * to delete. The messages do not all
+ * have to be in the same folder, but we have to delete them folder by
+ * folder if they are not.
+ */
+async function delete_messages(aSynMessageSet) {
+ await MessageInjection.deleteMessages(aSynMessageSet);
+}
+
+/**
+ * Make sure we are entering the folder from not having been in the folder. We
+ * will leave the folder and come back if we have to.
+ */
+async function enter_folder(aFolder) {
+ let win = get_about_3pane();
+
+ // If we're already selected, go back to the root...
+ if (win.gFolder == aFolder) {
+ await enter_folder(aFolder.rootFolder);
+ }
+
+ let displayPromise = BrowserTestUtils.waitForEvent(win, "folderURIChanged");
+ win.displayFolder(aFolder.URI);
+ await displayPromise;
+
+ // Drain the event queue.
+ utils.sleep(0);
+}
+
+/**
+ * Make sure we are in the given folder, entering it if we were not.
+ *
+ * @returns The tab info of the current tab (a more persistent identifier for
+ * tabs than the index, which will change as tabs open/close).
+ */
+async function be_in_folder(aFolder) {
+ let win = get_about_3pane();
+ if (win.gFolder != aFolder) {
+ await enter_folder(aFolder);
+ }
+ return mc.window.document.getElementById("tabmail").currentTabInfo;
+}
+
+/**
+ * Create a new tab displaying a folder, making that tab the current tab. This
+ * does not wait for message completion, because it doesn't know whether a
+ * message display will be triggered. If you know that a message display will be
+ * triggered, you should follow this up with
+ * |wait_for_message_display_completion(mc, true)|. If you know that a blank
+ * pane should be displayed, you should follow this up with
+ * |wait_for_blank_content_pane()| instead.
+ *
+ * @returns The tab info of the current tab (a more persistent identifier for
+ * tabs than the index, which will change as tabs open/close).
+ */
+async function open_folder_in_new_tab(aFolder) {
+ otherTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ let tab = mc.window.openTab(
+ "mail3PaneTab",
+ { folderURI: aFolder.URI },
+ "tab"
+ );
+ if (
+ tab.chromeBrowser.docShell.isLoadingDocument ||
+ tab.chromeBrowser.currentURI.spec != "about:3pane"
+ ) {
+ await BrowserTestUtils.browserLoaded(tab.chromeBrowser);
+ }
+ await TestUtils.waitForCondition(() => tab.folder == aFolder);
+
+ return tab;
+}
+
+/**
+ * Open a new mail:3pane window displaying a folder.
+ *
+ * @param aFolder the folder to be displayed in the new window
+ * @returns the augmented controller for the new window
+ */
+function open_folder_in_new_window(aFolder) {
+ windowHelper.plan_for_new_window("mail:3pane");
+ mc.window.MsgOpenNewWindowForFolder(aFolder.URI);
+ let mail3pane = windowHelper.wait_for_new_window("mail:3pane");
+ return mail3pane;
+}
+
+/**
+ * Open the selected message(s) by pressing Enter. The mail.openMessageBehavior
+ * pref is supposed to determine how the messages are opened.
+ *
+ * Since we don't know where this is going to trigger a message load, you're
+ * going to have to wait for message display completion yourself.
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function open_selected_messages(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ // Focus the thread tree
+ focus_thread_tree();
+ // Open whatever's selected
+ press_enter(aController);
+}
+
+var open_selected_message = open_selected_messages;
+
+/**
+ * Create a new tab displaying the currently selected message, making that tab
+ * the current tab. We block until the message finishes loading.
+ *
+ * @param aBackground [optional] If true, then the tab is opened in the
+ * background. If false or not given, then the tab is opened
+ * in the foreground.
+ *
+ * @returns The tab info of the new tab (a more persistent identifier for tabs
+ * than the index, which will change as tabs open/close).
+ */
+async function open_selected_message_in_new_tab(aBackground) {
+ // get the current tab count so we can make sure the tab actually opened.
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ // save the current tab as the 'other' tab
+ otherTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ let win = get_about_3pane();
+ let message = win.gDBView.hdrForFirstSelectedMessage;
+ let tab = mc.window.document
+ .getElementById("tabmail")
+ .openTab("mailMessageTab", {
+ messageURI: message.folder.getUriForMsg(message),
+ viewWrapper: win.gViewWrapper,
+ background: aBackground,
+ });
+
+ if (
+ tab.chromeBrowser.docShell.isLoadingDocument ||
+ tab.chromeBrowser.currentURI.spec != "about:message"
+ ) {
+ await BrowserTestUtils.browserLoaded(tab.chromeBrowser);
+ }
+
+ if (!aBackground) {
+ wait_for_message_display_completion(undefined, true);
+ }
+
+ // check that the tab count increased
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length !=
+ preCount + 1
+ ) {
+ throw new Error("The tab never actually got opened!");
+ }
+
+ return tab;
+}
+
+/**
+ * Create a new window displaying the currently selected message. We do not
+ * return until the message has finished loading.
+ *
+ * @returns The MozmillController-wrapped new window.
+ */
+async function open_selected_message_in_new_window() {
+ let win = get_about_3pane();
+ let newWindowPromise =
+ windowHelper.async_plan_for_new_window("mail:messageWindow");
+ mc.window.MsgOpenNewWindowForMessage(
+ win.gDBView.hdrForFirstSelectedMessage,
+ win.gViewWrapper
+ );
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+ return msgc;
+}
+
+/**
+ * Display the given message in a folder tab. This doesn't make any assumptions
+ * about whether a new tab is opened, since that is dependent on a user
+ * preference. However, we do check that the tab we're returning is a folder
+ * tab.
+ *
+ * @param aMsgHdr The message header to display.
+ * @param [aExpectNew3Pane] This should be set to true if it is expected that a
+ * new 3-pane window will be opened as a result of
+ * the API call.
+ *
+ * @returns The currently selected tab, guaranteed to be a folder tab.
+ */
+function display_message_in_folder_tab(aMsgHdr, aExpectNew3Pane) {
+ if (aExpectNew3Pane) {
+ windowHelper.plan_for_new_window("mail:3pane");
+ }
+ MailUtils.displayMessageInFolderTab(aMsgHdr);
+ if (aExpectNew3Pane) {
+ mc = windowHelper.wait_for_new_window("mail:3pane");
+ }
+
+ // Make sure that the tab we're returning is a folder tab
+ let currentTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ assert_tab_mode_name(currentTab, "mail3PaneTab");
+
+ return currentTab;
+}
+
+/**
+ * Create a new window displaying a message loaded from a file. We do not
+ * return until the message has finished loading.
+ *
+ * @param file An nsIFile to load the message from.
+ * @returns The MozmillController-wrapped new window.
+ */
+async function open_message_from_file(file) {
+ if (!file.isFile() || !file.isReadable()) {
+ throw new Error(
+ "The requested message file " +
+ file.leafName +
+ " was not found or is not accessible."
+ );
+ }
+
+ let fileURL = Services.io.newFileURI(file).QueryInterface(Ci.nsIFileURL);
+ fileURL = fileURL
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ let newWindowPromise =
+ windowHelper.async_plan_for_new_window("mail:messageWindow");
+ let win = mc.window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ );
+ await BrowserTestUtils.waitForEvent(win, "load");
+ let aboutMessage = get_about_message(win);
+ await BrowserTestUtils.waitForEvent(aboutMessage, "MsgLoaded");
+
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+ windowHelper.wait_for_window_focused(msgc.window);
+ utils.sleep(0);
+
+ return msgc;
+}
+
+/**
+ * Switch to another folder or message tab. If no tab is specified, we switch
+ * to the 'other' tab. That is the last tab we used, most likely the tab that
+ * was current when we created this tab.
+ *
+ * @param aNewTab Optional, index of the other tab to switch to.
+ */
+async function switch_tab(aNewTab) {
+ if (typeof aNewTab == "number") {
+ aNewTab = mc.window.document.getElementById("tabmail").tabInfo[aNewTab];
+ }
+
+ // If the new tab is the same as the current tab, none of the below applies.
+ // Get out now.
+ if (aNewTab == mc.window.document.getElementById("tabmail").currentTabInfo) {
+ return;
+ }
+
+ let targetTab = aNewTab != null ? aNewTab : otherTab;
+ // now the current tab will be the 'other' tab after we switch
+ otherTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let selectPromise = BrowserTestUtils.waitForEvent(
+ mc.window.document.getElementById("tabmail").tabContainer,
+ "select"
+ );
+ mc.window.document.getElementById("tabmail").switchToTab(targetTab);
+ await selectPromise;
+}
+
+/**
+ * Assert that the currently selected tab is the given one.
+ *
+ * @param aTab The tab that should currently be selected.
+ */
+function assert_selected_tab(aTab) {
+ Assert.equal(
+ mc.window.document.getElementById("tabmail").currentTabInfo,
+ aTab
+ );
+}
+
+/**
+ * Assert that the currently selected tab is _not_ the given one.
+ *
+ * @param aTab The tab that should currently not be selected.
+ */
+function assert_not_selected_tab(aTab) {
+ Assert.notEqual(
+ mc.window.document.getElementById("tabmail").currentTabInfo,
+ aTab
+ );
+}
+
+/**
+ * Assert that the given tab has the given mode name. Valid mode names include
+ * "message" and "folder".
+ *
+ * @param aTab A Tab. The currently selected tab if null.
+ * @param aModeName A string that should match the mode name of the tab.
+ */
+function assert_tab_mode_name(aTab, aModeName) {
+ if (!aTab) {
+ aTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ }
+
+ Assert.equal(aTab.mode.name, aModeName, `Tab should be of type ${aModeName}`);
+}
+
+/**
+ * Assert that the number of tabs open matches the value given.
+ *
+ * @param aNumber The number of tabs that should be open.
+ */
+function assert_number_of_tabs_open(aNumber) {
+ let actualNumber =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+ Assert.equal(actualNumber, aNumber, `There should be ${aNumber} tabs open`);
+}
+
+/**
+ * Assert that the given tab's title is based on the provided folder or
+ * message.
+ *
+ * @param aTab A Tab.
+ * @param aWhat Either an nsIMsgFolder or an nsIMsgDBHdr
+ */
+function assert_tab_titled_from(aTab, aWhat) {
+ let text;
+ if (aWhat instanceof Ci.nsIMsgFolder) {
+ text = aWhat.prettyName;
+ } else if (aWhat instanceof Ci.nsIMsgDBHdr) {
+ text = aWhat.mime2DecodedSubject;
+ }
+
+ utils.waitFor(
+ () => aTab.title.includes(text),
+ `Tab title should include '${text}' but does not. (Current title: '${aTab.title}')`
+ );
+}
+
+/**
+ * Assert that the given tab's title is what is given.
+ *
+ * @param aTab The tab to check.
+ * @param aTitle The title to check.
+ */
+function assert_tab_has_title(aTab, aTitle) {
+ Assert.equal(aTab.title, aTitle);
+}
+
+/**
+ * Close a tab. If no tab is specified, it is assumed you want to close the
+ * current tab.
+ */
+function close_tab(aTabToClose) {
+ if (typeof aTabToClose == "number") {
+ aTabToClose =
+ mc.window.document.getElementById("tabmail").tabInfo[aTabToClose];
+ }
+
+ // Get the current tab count so we can make sure the tab actually closed.
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ mc.window.document.getElementById("tabmail").closeTab(aTabToClose);
+
+ // Check that the tab count decreased.
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length !=
+ preCount - 1
+ ) {
+ throw new Error("The tab never actually got closed!");
+ }
+}
+
+/**
+ * Close a message window by calling window.close() on the controller.
+ */
+function close_message_window(aController) {
+ windowHelper.close_window(aController);
+}
+
+/**
+ * Clear the selection. I'm not sure how we're pretending we did that, but
+ * we explicitly focus the thread tree as a side-effect.
+ */
+function select_none(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ wait_for_message_display_completion();
+ focus_thread_tree();
+ get_db_view(aController.window).selection.clearSelection();
+ get_about_3pane().threadTree.dispatchEvent(new CustomEvent("select"));
+ // Because the selection event may not be generated immediately, we need to
+ // spin until the message display thinks it is not displaying a message,
+ // which is the sign that the event actually happened.
+ let win2 = get_about_message();
+ function noMessageChecker() {
+ return win2.gMessage == null;
+ }
+ try {
+ utils.waitFor(noMessageChecker);
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "Timeout waiting for displayedMessage to become null."
+ );
+ } else {
+ throw e;
+ }
+ }
+ wait_for_blank_content_pane(aController);
+}
+
+/**
+ * Normalize a view index to be an absolute index, handling slice-style negative
+ * references as well as piercing complex things like message headers and
+ * synthetic message sets.
+ *
+ * @param aViewIndex An absolute index (integer >= 0), slice-style index (< 0),
+ * or a SyntheticMessageSet (we only care about the first message in it).
+ */
+function _normalize_view_index(aViewIndex) {
+ let dbView = get_db_view();
+
+ // SyntheticMessageSet special-case
+ if (typeof aViewIndex != "number") {
+ let msgHdrIter = aViewIndex.msgHdrs();
+ let msgHdr = msgHdrIter.next().value;
+ msgHdrIter.return();
+ // do not expand
+ aViewIndex = dbView.findIndexOfMsgHdr(msgHdr, false);
+ }
+
+ if (aViewIndex < 0) {
+ return dbView.rowCount + aViewIndex;
+ }
+ return aViewIndex;
+}
+
+/**
+ * Generic method to simulate a left click on a row in a <tree> element.
+ *
+ * @param {XULTreeElement} aTree - The tree element.
+ * @param {number} aRowIndex - Index of a row in the tree to click on.
+ * @param {MozMillController} aController - Controller object.
+ * @see mailTestUtils.treeClick for another way.
+ */
+function click_tree_row(aTree, aRowIndex, aController) {
+ if (aRowIndex < 0 || aRowIndex >= aTree.view.rowCount) {
+ throw new Error(
+ "Row " + aRowIndex + " does not exist in the tree " + aTree.id + "!"
+ );
+ }
+
+ let selection = aTree.view.selection;
+ selection.select(aRowIndex);
+ aTree.ensureRowIsVisible(aRowIndex);
+
+ // get cell coordinates
+ let column = aTree.columns[0];
+ let coords = aTree.getCoordsForCellItem(aRowIndex, column, "text");
+
+ utils.sleep(0);
+ EventUtils.synthesizeMouse(
+ aTree.body,
+ coords.x + 4,
+ coords.y + 4,
+ {},
+ aTree.ownerGlobal
+ );
+ utils.sleep(0);
+}
+
+function _get_row_at_index(aViewIndex) {
+ let win = get_about_3pane();
+ let tree = win.document.getElementById("threadTree");
+ Assert.greater(
+ tree.view.rowCount,
+ aViewIndex,
+ `index ${aViewIndex} must exist to be clicked on`
+ );
+ tree.scrollToIndex(aViewIndex, true);
+ utils.waitFor(() => tree.getRowAtIndex(aViewIndex));
+ return tree.getRowAtIndex(aViewIndex);
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse.
+ *
+ * @param aViewIndex If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ *
+ * @returns The message header selected.
+ */
+function select_click_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let row = _get_row_at_index(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, {}, row.ownerGlobal);
+ utils.sleep(0);
+
+ wait_for_message_display_completion(undefined, true);
+
+ return get_about_3pane().gDBView.getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Pretend we are clicking on a row in the select column with our mouse.
+ *
+ * @param aViewIndex - If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ * @param aController - The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ *
+ * @returns The message header selected.
+ */
+function select_column_click_row(aViewIndex, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ let dbView = get_db_view(aController.window);
+
+ let hasMessageDisplay = "messageDisplay" in aController;
+ if (hasMessageDisplay) {
+ wait_for_message_display_completion(aController);
+ }
+ aViewIndex = _normalize_view_index(aViewIndex, aController);
+
+ // A click in the select column will always change the message display. If
+ // clicking on a single selection (deselect), don't wait for a message load.
+ var willDisplayMessage =
+ hasMessageDisplay &&
+ aController.messageDisplay.visible &&
+ !(dbView.selection.count == 1 && dbView.selection.isSelected(aViewIndex)) &&
+ dbView.selection.currentIndex !== aViewIndex;
+
+ if (willDisplayMessage) {
+ plan_for_message_display(aController);
+ }
+ _row_click_helper(
+ aController,
+ aController.window.document.getElementById("threadTree"),
+ aViewIndex,
+ 0,
+ null,
+ "selectCol"
+ );
+ if (hasMessageDisplay) {
+ wait_for_message_display_completion(aController, willDisplayMessage);
+ }
+ return dbView.getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Pretend we are toggling the thread specified by a row.
+ *
+ * @param aViewIndex If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ *
+ */
+function toggle_thread_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let win = get_about_3pane();
+ let row = win.document.getElementById("threadTree").getRowAtIndex(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row.querySelector(".twisty"), {}, win);
+
+ wait_for_message_display_completion();
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse with the control key pressed,
+ * resulting in the addition/removal of just that row to/from the selection.
+ *
+ * @param aViewIndex If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ *
+ * @returns The message header of the affected message.
+ */
+function select_control_click_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let win = get_about_3pane();
+ let row = win.document.getElementById("threadTree").getRowAtIndex(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, { accelKey: true }, win);
+
+ wait_for_message_display_completion();
+
+ return win.gDBView.getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse with the shift key pressed,
+ * adding all the messages between the shift pivot and the shift selected row.
+ *
+ * @param aViewIndex If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ *
+ * @returns The message headers for all messages that are now selected.
+ */
+function select_shift_click_row(aViewIndex, aController, aDoNotRequireLoad) {
+ aViewIndex = _normalize_view_index(aViewIndex, aController);
+
+ let win = get_about_3pane();
+ let row = win.document.getElementById("threadTree").getRowAtIndex(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, { shiftKey: true }, win);
+
+ wait_for_message_display_completion();
+
+ return win.gDBView.getSelectedMsgHdrs();
+}
+
+/**
+ * Helper function to click on a row with a given button.
+ */
+function _row_click_helper(
+ aController,
+ aTree,
+ aViewIndex,
+ aButton,
+ aExtra,
+ aColumnId
+) {
+ // Force-focus the tree
+ aTree.focus();
+ // coordinates of the upper left of the entire tree widget (headers included)
+ let treeRect = aTree.getBoundingClientRect();
+ let tx = treeRect.x,
+ ty = treeRect.y;
+ // coordinates of the row display region of the tree (below the headers)
+ let children = aController.window.document.getElementById(aTree.id, {
+ tagName: "treechildren",
+ });
+ let childrenRect = children.getBoundingClientRect();
+ let x = childrenRect.x,
+ y = childrenRect.y;
+ // Click in the middle of the row by default
+ let rowX = childrenRect.width / 2;
+ // For the thread tree, Position our click on the subject column (which cannot
+ // be hidden), and far enough in that we are in no danger of clicking the
+ // expand toggler unless that is explicitly requested.
+ if (aTree.id == "threadTree") {
+ let columnId = aColumnId || "subjectCol";
+ let col = aController.window.document.getElementById(columnId);
+ rowX = col.getBoundingClientRect().x - tx + 8;
+ // click on the toggle if so requested (for subjectCol)
+ if (columnId == "subjectCol" && aExtra !== "toggle") {
+ rowX += 32;
+ }
+ }
+ // Very important, gotta be able to see the row.
+ aTree.ensureRowIsVisible(aViewIndex);
+ let rowY =
+ aTree.rowHeight * (aViewIndex - aTree.getFirstVisibleRow()) +
+ aTree.rowHeight / 2;
+ if (aTree.getRowAt(x + rowX, y + rowY) != aViewIndex) {
+ throw new Error(
+ "Thought we would find row " +
+ aViewIndex +
+ " at " +
+ rowX +
+ "," +
+ rowY +
+ " but we found " +
+ aTree.getRowAt(rowX, rowY)
+ );
+ }
+ // Generate a mouse-down for all click types; the transient selection
+ // logic happens on mousedown which our tests assume is happening. (If you
+ // are using a keybinding to trigger the event, that will not happen, but
+ // we don't test that.)
+ EventUtils.synthesizeMouse(
+ aTree,
+ x + rowX - tx,
+ y + rowY - ty,
+ {
+ type: "mousedown",
+ button: aButton,
+ shiftKey: aExtra === "shift",
+ accelKey: aExtra === "accel",
+ },
+ aController.window
+ );
+
+ // For right-clicks, the platform code generates a "contextmenu" event
+ // when it sees the mouse press/down event. We are not synthesizing a platform
+ // level event (though it is in our power; we just historically have not),
+ // so we need to be the people to create the context menu.
+ if (aButton == 2) {
+ EventUtils.synthesizeMouse(
+ aTree,
+ x + rowX - tx,
+ y + rowY - ty,
+ { type: "contextmenu", button: aButton },
+ aController.window
+ );
+ }
+
+ EventUtils.synthesizeMouse(
+ aTree,
+ x + rowX - tx,
+ y + rowY - ty,
+ {
+ type: "mouseup",
+ button: aButton,
+ shiftKey: aExtra == "shift",
+ accelKey: aExtra === "accel",
+ },
+ aController.window
+ );
+}
+
+/**
+ * Right-click on the tree-view in question. With any luck, this will have
+ * the side-effect of opening up a pop-up which it is then on _your_ head
+ * to do something with or close. However, we have helpful popup function
+ * helpers because I'm so nice.
+ *
+ * @returns The message header that you clicked on.
+ */
+async function right_click_on_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let win = get_about_3pane();
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ win.document.getElementById("mailContext"),
+ "popupshown"
+ );
+ let row = win.document.getElementById("threadTree").getRowAtIndex(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, { type: "contextmenu" }, win);
+ await shownPromise;
+
+ return get_db_view().getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Middle-click on the tree-view in question, presumably opening a new message
+ * tab.
+ *
+ * @returns [The new tab, the message that you clicked on.]
+ */
+function middle_click_on_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let win = get_about_3pane();
+ let row = _get_row_at_index(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, { button: 1 }, win);
+
+ return [
+ mc.window.document.getElementById("tabmail").tabInfo[
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length -
+ 1
+ ],
+ win.gDBView.getMsgHdrAt(aViewIndex),
+ ];
+}
+
+/**
+ * Assert that the given folder mode is the current one.
+ *
+ * @param aMode The expected folder mode.
+ * @param [aController] The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function assert_folder_mode(aMode, aController) {
+ let about3Pane = get_about_3pane(aController?.window);
+ if (!about3Pane.folderPane.activeModes.includes(aMode)) {
+ throw new Error(`The folder mode "${aMode}" is not visible`);
+ }
+}
+
+/**
+ * Assert that the given folder is the child of the given parent in the folder
+ * tree view. aParent == null is equivalent to saying that the given folder
+ * should be a top-level folder.
+ */
+function assert_folder_child_in_view(aChild, aParent) {
+ let about3Pane = get_about_3pane();
+ let childRow = about3Pane.folderPane.getRowForFolder(aChild);
+ let parentRow = childRow.parentNode.closest("li");
+
+ if (parentRow?.uri != aParent.URI) {
+ throw new Error(
+ "Folder " +
+ aChild.URI +
+ " should be the child of " +
+ (aParent && aParent.URI) +
+ ", but is actually the child of " +
+ parentRow?.uri
+ );
+ }
+}
+
+/**
+ * Assert that the given folder is in the current folder mode and is visible.
+ *
+ * @param aFolder The folder to assert as visible
+ * @param [aController] The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ * @returns The index of the folder, if it is visible.
+ */
+function assert_folder_visible(aFolder, aController) {
+ let about3Pane = get_about_3pane(aController?.window);
+ let folderIndex = about3Pane.folderTree.rows.findIndex(
+ row => row.uri == aFolder.URI
+ );
+ if (folderIndex == -1) {
+ throw new Error("Folder: " + aFolder.URI + " should be visible, but isn't");
+ }
+
+ return folderIndex;
+}
+
+/**
+ * Assert that the given folder is either not in the current folder mode at all,
+ * or is not currently visible.
+ */
+function assert_folder_not_visible(aFolder) {
+ let about3Pane = get_about_3pane();
+ let folderIndex = about3Pane.folderTree.rows.findIndex(
+ row => row.uri == aFolder.URI
+ );
+ if (folderIndex != -1) {
+ throw new Error(
+ "Folder: " + aFolder.URI + " should not be visible, but is"
+ );
+ }
+}
+
+/**
+ * Collapse a folder if it has children. This will throw if the folder itself is
+ * not visible in the folder view.
+ */
+function collapse_folder(aFolder) {
+ let folderIndex = assert_folder_visible(aFolder);
+ let about3Pane = get_about_3pane();
+ let folderRow = about3Pane.folderTree.getRowAtIndex(folderIndex);
+ if (!folderRow.classList.contains("collapsed")) {
+ EventUtils.synthesizeMouseAtCenter(
+ folderRow.querySelector(".twisty"),
+ {},
+ about3Pane
+ );
+ }
+}
+
+/**
+ * Expand a folder if it has children. This will throw if the folder itself is
+ * not visible in the folder view.
+ */
+function expand_folder(aFolder) {
+ let folderIndex = assert_folder_visible(aFolder);
+ let about3Pane = get_about_3pane();
+ let folderRow = about3Pane.folderTree.getRowAtIndex(folderIndex);
+ if (folderRow.classList.contains("collapsed")) {
+ EventUtils.synthesizeMouseAtCenter(
+ folderRow.querySelector(".twisty"),
+ {},
+ about3Pane
+ );
+ }
+}
+
+/**
+ * Assert that a folder is currently visible and collapsed. This will throw if
+ * either of the two is untrue.
+ */
+function assert_folder_collapsed(aFolder) {
+ let folderIndex = assert_folder_visible(aFolder);
+ let row = get_about_3pane().folderTree.getRowAtIndex(folderIndex);
+ Assert.ok(row.classList.contains("collapsed"));
+}
+
+/**
+ * Assert that a folder is currently visible and expanded. This will throw if
+ * either of the two is untrue.
+ */
+function assert_folder_expanded(aFolder) {
+ let folderIndex = assert_folder_visible(aFolder);
+ let row = get_about_3pane().folderTree.getRowAtIndex(folderIndex);
+ Assert.ok(!row.classList.contains("collapsed"));
+}
+
+/**
+ * Pretend we are clicking on a folder with our mouse.
+ *
+ * @param aFolder The folder to click on. This needs to be present in the
+ * current folder tree view, of course.
+ *
+ * @returns the view index that you clicked on.
+ */
+function select_click_folder(aFolder) {
+ let win = get_about_3pane();
+ let folderTree = win.window.document.getElementById("folderTree");
+ let row = folderTree.rows.find(row => row.uri == aFolder.URI);
+ row.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(row.querySelector(".container"), {}, win);
+}
+
+/**
+ * Pretend we are clicking on a folder with our mouse with the shift key pressed.
+ *
+ * @param aFolder The folder to shift-click on. This needs to be present in the
+ * current folder tree view, of course.
+ *
+ * @returns An array containing all the folders that are now selected.
+ */
+function select_shift_click_folder(aFolder) {
+ wait_for_all_messages_to_load();
+
+ let viewIndex = mc.folderTreeView.getIndexOfFolder(aFolder);
+ // Passing -1 as the start range checks the shift-pivot, which should be -1,
+ // so it should fall over to the current index, which is what we want. It
+ // will then set the shift-pivot to the previously-current-index and update
+ // the current index to be what we shift-clicked on. All matches user
+ // interaction.
+ mc.folderTreeView.selection.rangedSelect(-1, viewIndex, false);
+ wait_for_all_messages_to_load();
+ // give the event queue a chance to drain...
+ utils.sleep(0);
+
+ return mc.folderTreeView.getSelectedFolders();
+}
+
+/**
+ * Right click on the folder tree view. With any luck, this will have the
+ * side-effect of opening up a pop-up which it is then on _your_ head to do
+ * something with or close. However, we have helpful popup function helpers
+ * helpers because asuth's so nice.
+ *
+ * @note The argument is a folder here, unlike in the message case, so beware.
+ *
+ * @returns The view index that you clicked on.
+ */
+async function right_click_on_folder(aFolder) {
+ let win = get_about_3pane();
+ let folderTree = win.window.document.getElementById("folderTree");
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ win.document.getElementById("folderPaneContext"),
+ "popupshown"
+ );
+ let row = folderTree.rows.find(row => row.uri == aFolder.URI);
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".container"),
+ { type: "contextmenu" },
+ win
+ );
+ await shownPromise;
+}
+
+/**
+ * Middle-click on the folder tree view, presumably opening a new folder tab.
+ *
+ * @note The argument is a folder here, unlike in the message case, so beware.
+ *
+ * @returns [The new tab, the view index that you clicked on.]
+ */
+function middle_click_on_folder(aFolder, shiftPressed) {
+ let win = get_about_3pane();
+ let folderTree = win.window.document.getElementById("folderTree");
+ let row = folderTree.rows.find(row => row.uri == aFolder.URI);
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".container"),
+ { button: 1, shiftKey: shiftPressed },
+ win
+ );
+
+ return [
+ mc.window.document.getElementById("tabmail").tabInfo[
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length -
+ 1
+ ],
+ ];
+}
+
+/**
+ * Get a reference to the smart folder with the given name.
+ *
+ * @param aFolderName The name of the smart folder (e.g. "Inbox").
+ * @returns An nsIMsgFolder representing the smart folder with the given name.
+ */
+function get_smart_folder_named(aFolderName) {
+ let smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ return smartServer.rootFolder.getChildNamed(aFolderName);
+}
+
+/**
+ * Assuming the context popup is popped-up (via right_click_on_row), select
+ * the deletion option. If the popup is not popped up, you are out of luck.
+ */
+async function delete_via_popup() {
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+ let win = get_about_3pane();
+ let ctxDelete = win.document.getElementById("mailContext-delete");
+ if (AppConstants.platform == "macosx") {
+ // We need to use click() since the synthesizeMouseAtCenter doesn't work for
+ // context menu items on macos.
+ ctxDelete.click();
+ } else {
+ EventUtils.synthesizeMouseAtCenter(ctxDelete, {}, ctxDelete.ownerGlobal);
+ }
+
+ // for reasons unknown, the pop-up does not close itself?
+ await close_popup(mc, win.document.getElementById("mailContext"));
+ wait_for_folder_events();
+}
+
+async function wait_for_popup_to_open(popupElem) {
+ if (popupElem.state != "open") {
+ await BrowserTestUtils.waitForEvent(popupElem, "popupshown");
+ }
+}
+
+/**
+ * Close the open pop-up.
+ */
+async function close_popup(aController, elem) {
+ // if it was already closing, just leave
+ if (elem.state == "closed") {
+ return;
+ }
+
+ if (elem.state != "hiding") {
+ // Actually close the popup because it's not closing/closed.
+ let hiddenPromise = BrowserTestUtils.waitForEvent(elem, "popuphidden");
+ elem.hidePopup();
+ await hiddenPromise;
+ await new Promise(resolve =>
+ aController.window.requestAnimationFrame(resolve)
+ );
+ }
+}
+
+/**
+ * Pretend we are pressing the delete key, triggering message deletion of the
+ * selected messages.
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ * @param aModifiers (optional) Modifiers to pass to the keypress method.
+ */
+function press_delete(aController, aModifiers) {
+ if (aController == null) {
+ aController = mc;
+ }
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+
+ EventUtils.synthesizeKey("VK_DELETE", aModifiers || {}, aController.window);
+ wait_for_folder_events();
+}
+
+/**
+ * Delete all messages in the given folder.
+ * (called empty_folder similarly to emptyTrash method on root folder)
+ *
+ * @param aFolder Folder to empty.
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+async function empty_folder(aFolder, aController = mc) {
+ if (!aFolder) {
+ throw new Error("No folder for emptying given");
+ }
+
+ await be_in_folder(aFolder);
+ let msgCount;
+ while ((msgCount = aFolder.getTotalMessages(false)) > 0) {
+ select_click_row(0, aController);
+ press_delete(aController);
+ utils.waitFor(() => aFolder.getTotalMessages(false) < msgCount);
+ }
+}
+
+/**
+ * Archive the selected messages, and wait for it to complete. Archiving
+ * plans and waits for message display if the display is visible because
+ * successful archiving will by definition change the currently displayed
+ * set of messages (unless you are looking at a virtual folder that includes
+ * the archive folder.)
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function archive_selected_messages(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ let dbView = get_db_view(aController.window);
+
+ // How many messages do we expect to remain after the archival?
+ let expectedCount = dbView.rowCount - dbView.numSelected;
+
+ // if (expectedCount && aController.messageDisplay.visible) {
+ // plan_for_message_display(aController);
+ // }
+ EventUtils.synthesizeKey("a", {}, aController.window);
+
+ // Wait for the view rowCount to decrease by the number of selected messages.
+ let messagesDeletedFromView = function () {
+ return dbView.rowCount == expectedCount;
+ };
+ utils.waitFor(
+ messagesDeletedFromView,
+ "Timeout waiting for messages to be archived"
+ );
+ // wait_for_message_display_completion(
+ // aController,
+ // expectedCount && aController.messageDisplay.visible
+ // );
+ // The above may return immediately, meaning the event queue might not get a
+ // chance. give it a chance now.
+ utils.sleep(0);
+}
+
+/**
+ * Pretend we are pressing the Enter key, triggering opening selected messages.
+ * Note that since we don't know where this is going to trigger a message load,
+ * you're going to have to wait for message display completion yourself.
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function press_enter(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ // if something is loading, make sure it finishes loading...
+ if ("messageDisplay" in aController) {
+ wait_for_message_display_completion(aController);
+ }
+ EventUtils.synthesizeKey("VK_RETURN", {}, aController.window);
+ // The caller's going to have to wait for message display completion
+}
+
+/**
+ * Wait for the |folderDisplay| on aController (defaults to mc if omitted) to
+ * finish loading. This generally only matters for folders that have an active
+ * search.
+ * This method is generally called automatically most of the time, and you
+ * should not need to call it yourself unless you are operating outside the
+ * helper methods in this file.
+ */
+function wait_for_all_messages_to_load(aController = mc) {
+ // utils.waitFor(
+ // () => aController.window.gFolderDisplay.allMessagesLoaded,
+ // "Messages never finished loading. Timed Out."
+ // );
+ // the above may return immediately, meaning the event queue might not get a
+ // chance. give it a chance now.
+ utils.sleep(0);
+}
+
+/**
+ * Call this before triggering a message display that you are going to wait for
+ * using |wait_for_message_display_completion| where you are passing true for
+ * the aLoadDemanded argument. This ensures that if a message is already
+ * displayed for the given controller that state is sufficiently cleaned up
+ * so it doesn't trick us into thinking that there is no need to wait.
+ *
+ * @param [aControllerOrTab] optional controller or tab, defaulting to |mc|. If
+ * the message display is going to be caused by a tab switch, a reference to
+ * the tab to switch to should be passed in.
+ */
+function plan_for_message_display(aControllerOrTab) {}
+
+/**
+ * If a message or summary is in the process of loading, let it finish;
+ * optionally, be sure to wait for a load to happen (assuming
+ * |plan_for_message_display| is used, modulo the conditions below.)
+ *
+ * This method is used defensively by a lot of other code in this file that is
+ * really not sure whether there might be a load in progress or not. So by
+ * default we only do something if there is obviously a message display in
+ * progress. Since some events may end up getting deferred due to script
+ * blockers or the like, it is possible the event that triggers the display
+ * may not have happened by the time you call this. In that case, you should
+ *
+ * 1) pass true for aLoadDemanded, and
+ * 2) invoke |plan_for_message_display|
+ *
+ * before triggering the event that will induce a message display. Note that:
+ * - You cannot do #2 if you are opening a new message window and can assume
+ * that this will be the first message ever displayed in the window. This is
+ * fine, because messageLoaded is initially false.
+ * - You should not do #2 if you are opening a new folder or message tab. That
+ * is because you'll affect the old tab's message display instead of the new
+ * tab's display. Again, this is fine, because a new message display will be
+ * created for the new tab, and messageLoaded will initially be false for it.
+ *
+ * If we didn't use this method defensively, we would get horrible assertions
+ * like so:
+ * ###!!! ASSERTION: Overwriting an existing document channel!
+ *
+ *
+ * @param [aController] optional controller, defaulting to |mc|.
+ * @param [aLoadDemanded=false] Should we require that we wait for a message to
+ * be loaded? You should use this in conjunction with
+ * |plan_for_message_display| as per the documentation above. If you do
+ * not pass true and there is no message load in process, this method will
+ * return immediately.
+ */
+function wait_for_message_display_completion(aController, aLoadDemanded) {
+ let win;
+ if (
+ aController == null ||
+ aController.window.document.getElementById("tabmail")
+ ) {
+ win = get_about_message(aController?.window);
+ } else {
+ win =
+ aController.window.document.getElementById(
+ "messageBrowser"
+ ).contentWindow;
+ }
+
+ let tabmail = mc.window.document.getElementById("tabmail");
+ if (tabmail.currentTabInfo.mode.name == "mail3PaneTab") {
+ let about3Pane = tabmail.currentAbout3Pane;
+ if (about3Pane?.gDBView?.getSelectedMsgHdrs().length > 1) {
+ // Displaying multiple messages.
+ return;
+ }
+ if (about3Pane?.messagePaneSplitter.isCollapsed) {
+ // Message pane hidden.
+ return;
+ }
+ }
+
+ utils.waitFor(() => win.document.readyState == "complete");
+
+ let browser = win.getMessagePaneBrowser();
+
+ try {
+ utils.waitFor(
+ () =>
+ !browser.docShell?.isLoadingDocument &&
+ (!aLoadDemanded || browser.currentURI?.spec != "about:blank")
+ );
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Timeout waiting for a message. Current location: ${browser.currentURI?.spec}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ utils.sleep();
+}
+
+/**
+ * Wait for the content pane to be blank because no message is to be displayed.
+ *
+ * @param aController optional controller, defaulting to |mc|.
+ */
+function wait_for_blank_content_pane(aController) {
+ let win;
+ if (aController == null || aController == mc) {
+ win = get_about_message();
+ } else {
+ win = aController.window;
+ }
+
+ utils.waitFor(() => win.document.readyState == "complete");
+
+ let browser = win.getMessagePaneBrowser();
+ if (BrowserTestUtils.is_hidden(browser)) {
+ return;
+ }
+
+ try {
+ utils.waitFor(
+ () =>
+ !browser.docShell?.isLoadingDocument &&
+ browser.currentURI?.spec == "about:blank"
+ );
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Timeout waiting for blank content pane. Current location: ${browser.currentURI?.spec}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ // the above may return immediately, meaning the event queue might not get a
+ // chance. give it a chance now.
+ utils.sleep();
+}
+
+var FolderListener = {
+ _inited: false,
+ ensureInited() {
+ if (this._inited) {
+ return;
+ }
+
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.event
+ );
+
+ this._inited = true;
+ },
+
+ sawEvents: false,
+ watchingFor: null,
+ planToWaitFor(...aArgs) {
+ this.sawEvents = false;
+ this.watchingFor = aArgs;
+ },
+
+ waitForEvents() {
+ if (this.sawEvents) {
+ return;
+ }
+ let self = this;
+ try {
+ utils.waitFor(() => self.sawEvents);
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Timeout waiting for events: ${this.watchingFor}`
+ );
+ } else {
+ throw e;
+ }
+ }
+ },
+
+ onFolderEvent(aFolder, aEvent) {
+ if (!this.watchingFor) {
+ return;
+ }
+ if (this.watchingFor.includes(aEvent)) {
+ this.watchingFor = null;
+ this.sawEvents = true;
+ }
+ },
+};
+
+/**
+ * Plan to wait for an nsIFolderListener.onFolderEvent matching one of the
+ * provided strings. Call this before you do the thing that triggers the
+ * event, then call |wait_for_folder_events| after the event. This ensures
+ * that we see the event, because it might be too late after you initiate
+ * the thing that would generate the event.
+ * For example, plan_to_wait_for_folder_events("DeleteOrMoveMsgCompleted",
+ * "DeleteOrMoveMsgFailed") waits for a deletion completion notification
+ * when you call |wait_for_folder_events|.
+ * The waiting is currently un-scoped, so the event happening on any folder
+ * triggers us. It is expected that you won't try and have multiple events
+ * in-flight or will augment us when the time comes to have to deal with that.
+ */
+function plan_to_wait_for_folder_events(...aArgs) {
+ FolderListener.ensureInited();
+ FolderListener.planToWaitFor(...aArgs);
+}
+function wait_for_folder_events() {
+ FolderListener.waitForEvents();
+}
+
+/**
+ * Assert that the given synthetic message sets are present in the folder
+ * display.
+ *
+ * Verify that the messages in the provided SyntheticMessageSets are the only
+ * visible messages in the provided DBViewWrapper. If dummy headers are present
+ * in the view for group-by-sort, the code will ensure that the dummy header's
+ * underlying header corresponds to a message in the synthetic sets. However,
+ * you should generally not rely on this code to test for anything involving
+ * dummy headers.
+ *
+ * In the event the view does not contain all of the messages from the provided
+ * sets or contains messages not in the provided sets, throw_and_dump_view_state
+ * will be invoked with a human readable explanation of the problem.
+ *
+ * @param aSynSets Either a single SyntheticMessageSet or a list of them.
+ * @param aController Optional controller, which we get the folderDisplay
+ * property from. If omitted, we use mc.
+ */
+function assert_messages_in_view(aSynSets, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ if (!("length" in aSynSets)) {
+ aSynSets = [aSynSets];
+ }
+
+ // - Iterate over all the message sets, retrieving the message header. Use
+ // this to construct a URI to populate a dictionary mapping.
+ let synMessageURIs = {}; // map URI to message header
+ for (let messageSet of aSynSets) {
+ for (let msgHdr of messageSet.msgHdrs()) {
+ synMessageURIs[msgHdr.folder.getUriForMsg(msgHdr)] = msgHdr;
+ }
+ }
+
+ // - Iterate over the contents of the view, nulling out values in
+ // synMessageURIs for found messages, and exploding for missing ones.
+ let dbView = get_db_view(aController.window);
+ let treeView = dbView.QueryInterface(Ci.nsITreeView);
+ let rowCount = treeView.rowCount;
+
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let msgHdr = dbView.getMsgHdrAt(iViewIndex);
+ let uri = msgHdr.folder.getUriForMsg(msgHdr);
+ // expected hit, null it out. (in the dummy case, we will just null out
+ // twice, which is also why we do an 'in' test and not a value test.
+ if (uri in synMessageURIs) {
+ synMessageURIs[uri] = null;
+ } else {
+ // the view is showing a message that should not be shown, explode.
+ throw_and_dump_view_state(
+ "The view should show the message header" + msgHdr.messageKey
+ );
+ }
+ }
+
+ // - Iterate over our URI set and make sure every message got nulled out.
+ for (let uri in synMessageURIs) {
+ let msgHdr = synMessageURIs[uri];
+ if (msgHdr != null) {
+ throw_and_dump_view_state(
+ "The view should include the message header" + msgHdr.messageKey
+ );
+ }
+ }
+}
+
+/**
+ * Assert the the given message/messages are not present in the view.
+ *
+ * @param aMessages Either a single nsIMsgDBHdr or a list of them.
+ */
+function assert_messages_not_in_view(aMessages) {
+ if (aMessages instanceof Ci.nsIMsgDBHdr) {
+ aMessages = [aMessages];
+ }
+
+ let dbView = get_db_view();
+ for (let msgHdr of aMessages) {
+ Assert.equal(
+ dbView.findIndexOfMsgHdr(msgHdr, true),
+ nsMsgViewIndex_None,
+ `Message header is present in view but should not be`
+ );
+ }
+}
+var assert_message_not_in_view = assert_messages_not_in_view;
+
+/**
+ * When displaying a folder, assert that the message pane is visible and all the
+ * menus, splitters, etc. are set up right.
+ */
+function assert_message_pane_visible() {
+ let win = get_about_3pane();
+ let messagePane = win.document.getElementById("messagePane");
+
+ Assert.equal(
+ win.paneLayout.messagePaneVisible,
+ true,
+ "The tab does not think that the message pane is visible, but it should!"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(messagePane),
+ "The message pane should not be collapsed!"
+ );
+ Assert.equal(
+ win.messagePaneSplitter.isCollapsed,
+ false,
+ "The message pane splitter should not be collapsed!"
+ );
+
+ mc.window.view_init(); // Force the view menu to update.
+ let paneMenuItem = mc.window.document.getElementById("menu_showMessage");
+ Assert.equal(
+ paneMenuItem.getAttribute("checked"),
+ "true",
+ "The Message Pane menu item should be checked."
+ );
+}
+
+/**
+ * When displaying a folder, assert that the message pane is hidden and all the
+ * menus, splitters, etc. are set up right.
+ */
+function assert_message_pane_hidden() {
+ let win = get_about_3pane();
+ let messagePane = win.document.getElementById("messagePane");
+
+ Assert.equal(
+ win.paneLayout.messagePaneVisible,
+ false,
+ "The tab thinks that the message pane is visible, but it shouldn't!"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(messagePane),
+ "The message pane should be collapsed!"
+ );
+ Assert.equal(
+ win.messagePaneSplitter.isCollapsed,
+ true,
+ "The message pane splitter should be collapsed!"
+ );
+
+ mc.window.view_init(); // Force the view menu to update.
+ let paneMenuItem = mc.window.document.getElementById("menu_showMessage");
+ Assert.notEqual(
+ paneMenuItem.getAttribute("checked"),
+ "true",
+ "The Message Pane menu item should not be checked."
+ );
+}
+
+/**
+ * Toggle the visibility of the message pane.
+ */
+function toggle_message_pane() {
+ EventUtils.synthesizeKey("VK_F8", {}, get_about_3pane());
+}
+
+/**
+ * Make the folder pane visible in order to run tests.
+ * This is necessary as the FolderPane is collapsed if no account is available.
+ */
+function show_folder_pane() {
+ mc.window.document.getElementById("folderPaneBox").collapsed = false;
+}
+
+/**
+ * Helper function for use by assert_selected / assert_selected_and_displayed /
+ * assert_displayed.
+ *
+ * @returns A list of two elements: [MozmillController, [list of view indices]].
+ */
+function _process_row_message_arguments(...aArgs) {
+ let troller = mc;
+ // - normalize into desired selected view indices
+ let desiredIndices = [];
+ for (let arg of aArgs) {
+ // An integer identifying a view index
+ if (typeof arg == "number") {
+ desiredIndices.push(_normalize_view_index(arg));
+ } else if (arg instanceof Ci.nsIMsgDBHdr) {
+ // A message header
+ // do not expand; the thing should already be selected, eg expanded!
+ let viewIndex = get_db_view(troller.window).findIndexOfMsgHdr(arg, false);
+ if (viewIndex == nsMsgViewIndex_None) {
+ throw_and_dump_view_state(
+ "Message not present in view that should be there. " +
+ "(" +
+ arg.messageKey +
+ ": " +
+ arg.mime2DecodedSubject +
+ ")"
+ );
+ }
+ desiredIndices.push(viewIndex);
+ } else if (arg.length == 2 && typeof arg[0] == "number") {
+ // A list containing two integers, indicating a range of view indices.
+ let lowIndex = _normalize_view_index(arg[0]);
+ let highIndex = _normalize_view_index(arg[1]);
+ for (let viewIndex = lowIndex; viewIndex <= highIndex; viewIndex++) {
+ desiredIndices.push(viewIndex);
+ }
+ } else if (arg.length !== undefined) {
+ // a List of message headers
+ for (let iMsg = 0; iMsg < arg.length; iMsg++) {
+ let msgHdr = arg[iMsg].QueryInterface(Ci.nsIMsgDBHdr);
+ if (!msgHdr) {
+ throw new Error(arg[iMsg] + " is not a message header!");
+ }
+ // false means do not expand, it should already be selected
+ let viewIndex = get_db_view(troller.window).findIndexOfMsgHdr(
+ msgHdr,
+ false
+ );
+ if (viewIndex == nsMsgViewIndex_None) {
+ throw_and_dump_view_state(
+ "Message not present in view that should be there. " +
+ "(" +
+ msgHdr.messageKey +
+ ": " +
+ msgHdr.mime2DecodedSubject +
+ ")"
+ );
+ }
+ desiredIndices.push(viewIndex);
+ }
+ } else if (arg.synMessages) {
+ // SyntheticMessageSet
+ for (let msgHdr of arg.msgHdrs()) {
+ let viewIndex = get_db_view(troller.window).findIndexOfMsgHdr(
+ msgHdr,
+ false
+ );
+ if (viewIndex == nsMsgViewIndex_None) {
+ throw_and_dump_view_state(
+ "Message not present in view that should be there. " +
+ "(" +
+ msgHdr.messageKey +
+ ": " +
+ msgHdr.mime2DecodedSubject +
+ ")"
+ );
+ }
+ desiredIndices.push(viewIndex);
+ }
+ } else if (arg.window) {
+ // it's a MozmillController
+ troller = arg;
+ } else {
+ throw new Error("Illegal argument: " + arg);
+ }
+ }
+ // sort by integer value
+ desiredIndices.sort(function (a, b) {
+ return a - b;
+ });
+
+ return [troller, desiredIndices];
+}
+
+/**
+ * Asserts that the given set of messages are selected. Unless you are dealing
+ * with transient selections resulting from right-clicks, you want to be using
+ * assert_selected_and_displayed because it makes sure that the display is
+ * correct too.
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - A message header.
+ * - A list of message headers.
+ * - A synthetic message set.
+ */
+function assert_selected(...aArgs) {
+ let [troller, desiredIndices] = _process_row_message_arguments(...aArgs);
+
+ // - get the actual selection (already sorted by integer value)
+ let selectedIndices = get_db_view(troller.window).getIndicesForSelection();
+
+ // - test selection equivalence
+ // which is the same as string equivalence in this case. muah hah hah.
+ Assert.equal(
+ selectedIndices.toString(),
+ desiredIndices.toString(),
+ "should have the right selected indices"
+ );
+ return [troller, desiredIndices];
+}
+
+/**
+ * Assert that the given set of messages is displayed, but not necessarily
+ * selected. Unless you are dealing with transient selection issues or some
+ * other situation where the FolderDisplay should not be correlated with the
+ * MessageDisplay, you really should be using assert_selected_and_displayed.
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - A message header.
+ * - A list of message headers.
+ */
+function assert_displayed(...aArgs) {
+ let [troller, desiredIndices] = _process_row_message_arguments(...aArgs);
+ _internal_assert_displayed(false, troller, desiredIndices);
+}
+
+/**
+ * Assert-that-the-display-is-right logic. We need an internal version so that
+ * we can know whether we can trust/assert that folderDisplay.selectedMessage
+ * agrees with messageDisplay, and also so that we don't have to re-compute
+ * troller and desiredIndices.
+ */
+function _internal_assert_displayed(trustSelection, troller, desiredIndices) {
+ // - verify that the right thing is being displayed.
+ // no selection means folder summary.
+ if (desiredIndices.length == 0) {
+ wait_for_blank_content_pane(troller);
+
+ let messageWindow = get_about_message();
+
+ // folder summary is not landed yet, just verify there is no message.
+ if (messageWindow.gMessage) {
+ throw new Error(
+ "Message display should not think it is displaying a message."
+ );
+ }
+ // make sure the content pane is pointed at about:blank
+ let location = messageWindow.getMessagePaneBrowser()?.location;
+ if (location && location.href != "about:blank") {
+ throw new Error(
+ `the content pane should be blank, but is showing: '${location.href}'`
+ );
+ }
+ } else if (desiredIndices.length == 1) {
+ /*
+ // 1 means the message should be displayed
+ // make sure message display thinks we are in single message display mode
+ if (!troller.messageDisplay.singleMessageDisplay) {
+ throw new Error("Message display is not in single message display mode.");
+ }
+ // now make sure that we actually are in single message display mode
+ let singleMessagePane = troller.window.document.getElementById("singleMessage");
+ let multiMessagePane = troller.window.document.getElementById("multimessage");
+ if (singleMessagePane && singleMessagePane.hidden) {
+ throw new Error("Single message pane is hidden but it should not be.");
+ }
+ if (multiMessagePane && !multiMessagePane.hidden) {
+ throw new Error("Multiple message pane is visible but it should not be.");
+ }
+
+ if (trustSelection) {
+ if (
+ troller.window.gFolderDisplay.selectedMessage !=
+ troller.messageDisplay.displayedMessage
+ ) {
+ throw new Error(
+ "folderDisplay.selectedMessage != " +
+ "messageDisplay.displayedMessage! (fd: " +
+ troller.window.gFolderDisplay.selectedMessage +
+ " vs md: " +
+ troller.messageDisplay.displayedMessage +
+ ")"
+ );
+ }
+ }
+
+ let msgHdr = troller.messageDisplay.displayedMessage;
+ let msgUri = msgHdr.folder.getUriForMsg(msgHdr);
+ // wait for the document to load so that we don't try and replace it later
+ // and get that stupid assertion
+ wait_for_message_display_completion();
+ utils.sleep(500)
+ // make sure the content pane is pointed at the right thing
+
+ let msgService = troller.window.gFolderDisplay.messenger.messageServiceFromURI(
+ msgUri
+ );
+ let msgUrl = msgService.getUrlForUri(
+ msgUri,
+ troller.window.gFolderDisplay.msgWindow
+ );
+ if (troller.window.content?.location.href != msgUrl.spec) {
+ throw new Error(
+ "The content pane is not displaying the right message! " +
+ "Should be: " +
+ msgUrl.spec +
+ " but it's: " +
+ troller.window.content.location.href
+ );
+ }
+ */
+ } else {
+ /*
+ // multiple means some form of multi-message summary
+ // XXX deal with the summarization threshold bail case.
+
+ // make sure the message display thinks we are in multi-message mode
+ if (troller.messageDisplay.singleMessageDisplay) {
+ throw new Error(
+ "Message display should not be in single message display" +
+ "mode! Desired indices: " +
+ desiredIndices
+ );
+ }
+
+ // verify that the message pane browser is displaying about:blank
+ if (mc.window.content && mc.window.content.location.href != "about:blank") {
+ throw new Error(
+ "the content pane should be blank, but is showing: '" +
+ mc.window.content.location.href +
+ "'"
+ );
+ }
+
+ // now make sure that we actually are in nultiple message display mode
+ let singleMessagePane = troller.window.document.getElementById("singleMessage");
+ let multiMessagePane = troller.window.document.getElementById("multimessage");
+ if (singleMessagePane && !singleMessagePane.hidden) {
+ throw new Error("Single message pane is visible but it should not be.");
+ }
+ if (multiMessagePane && multiMessagePane.hidden) {
+ throw new Error("Multiple message pane is hidden but it should not be.");
+ }
+
+ // and _now_ make sure that we actually summarized what we wanted to
+ // summarize.
+ let desiredMessages = desiredIndices.map(vi => mc.window.gFolderDisplay.view.dbView.getMsgHdrAt(vi));
+ assert_messages_summarized(troller, desiredMessages);
+ */
+ }
+}
+
+/**
+ * Assert that the messages corresponding to the one or more message spec
+ * arguments are selected and displayed. If you specify multiple messages,
+ * we verify that the multi-message selection mode is in effect and that they
+ * are doing the desired thing. (Verifying the summarization may seem
+ * overkill, but it helps make the tests simpler and allows you to be more
+ * confident if you're just running one test that everything in the test is
+ * performing in a sane fashion. Refactoring could be in order, of course.)
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - A message header.
+ * - A list of message headers.
+ */
+function assert_selected_and_displayed(...aArgs) {
+ // make sure the selection is right first.
+ let [troller, desiredIndices] = assert_selected(...aArgs);
+ // now make sure the display is right
+ _internal_assert_displayed(true, troller, desiredIndices);
+}
+
+/**
+ * Use the internal archiving code for archiving any given set of messages
+ *
+ * @param aMsgHdrs a list of message headers
+ */
+function archive_messages(aMsgHdrs) {
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+
+ let { MessageArchiver } = ChromeUtils.import(
+ "resource:///modules/MessageArchiver.jsm"
+ );
+ let batchMover = new MessageArchiver();
+ batchMover.archiveMessages(aMsgHdrs);
+ wait_for_folder_events();
+}
+
+/**
+ * Check if the selected messages match the summarized messages.
+ *
+ * @param aSummarizedKeys An array of keys (messageKey + folder.URI) for the
+ * summarized messages.
+ * @param aSelectedMessages An array of nsIMsgDBHdrs for the selected messages.
+ * @returns true is aSelectedMessages and aSummarizedKeys refer to the same set
+ * of messages.
+ */
+function _verify_summarized_message_set(aSummarizedKeys, aSelectedMessages) {
+ let summarizedKeys = aSummarizedKeys.slice();
+ summarizedKeys.sort();
+ // We use the same key-generation as in multimessageview.js.
+ let selectedKeys = aSelectedMessages.map(
+ msgHdr => msgHdr.messageKey + msgHdr.folder.URI
+ );
+ selectedKeys.sort();
+
+ // Stringified versions should now be equal...
+ return selectedKeys.toString() == summarizedKeys.toString();
+}
+
+/**
+ * Asserts that the messages the controller's folder display widget thinks are
+ * summarized are in fact summarized. This is automatically called by
+ * assert_selected_and_displayed, so you do not need to call this directly
+ * unless you are testing the summarization logic.
+ *
+ * @param aController The controller who has the summarized display going on.
+ * @param [aMessages] Optional set of messages to verify. If not provided, this
+ * is extracted via the folderDisplay. If a SyntheticMessageSet is provided
+ * we will automatically retrieve what we need from it.
+ */
+function assert_messages_summarized(aController, aSelectedMessages) {
+ // - Compensate for selection stabilization code.
+ // Although WindowHelpers sets the stabilization interval to 0, we
+ // still need to make sure we have drained the event queue so that it has
+ // actually gotten a chance to run.
+ utils.sleep(0);
+
+ // - Verify summary object knows about right messages
+ if (aSelectedMessages == null) {
+ aSelectedMessages = aController.window.gFolderDisplay.selectedMessages;
+ }
+ // if it's a synthetic message set, we want the headers...
+ if (aSelectedMessages.synMessages) {
+ aSelectedMessages = Array.from(aSelectedMessages.msgHdrs());
+ }
+
+ let summaryFrame = aController.window.gSummaryFrameManager.iframe;
+ let summary = summaryFrame.contentWindow.gMessageSummary;
+ let summarizedKeys = Object.keys(summary._msgNodes);
+ if (aSelectedMessages.length != summarizedKeys.length) {
+ let elaboration =
+ "Summary contains " +
+ summarizedKeys.length +
+ " messages, expected " +
+ aSelectedMessages.length +
+ ".";
+ throw new Error(
+ "Summary does not contain the right set of messages. " + elaboration
+ );
+ }
+ if (!_verify_summarized_message_set(summarizedKeys, aSelectedMessages)) {
+ let elaboration =
+ "Summary: " + summarizedKeys + " Selected: " + aSelectedMessages + ".";
+ throw new Error(
+ "Summary does not contain the right set of messages. " + elaboration
+ );
+ }
+}
+
+/**
+ * Assert that there is nothing selected and, assuming we are in a folder, that
+ * the folder summary is displayed.
+ */
+var assert_nothing_selected = assert_selected_and_displayed;
+
+/**
+ * Assert that the given view index or message is visible in the thread pane.
+ */
+function assert_visible(aViewIndexOrMessage) {
+ let win = get_about_3pane();
+ let viewIndex;
+ if (typeof aViewIndexOrMessage == "number") {
+ viewIndex = _normalize_view_index(aViewIndexOrMessage);
+ } else {
+ viewIndex = win.gDBView.findIndexOfMsgHdr(aViewIndexOrMessage, false);
+ }
+ let tree = win.threadTree;
+ let firstVisibleIndex = tree.getFirstVisibleIndex();
+ let lastVisibleIndex = tree.getLastVisibleIndex();
+
+ if (viewIndex < firstVisibleIndex || viewIndex > lastVisibleIndex) {
+ throw new Error(
+ "View index " +
+ viewIndex +
+ " is not visible! (" +
+ firstVisibleIndex +
+ "-" +
+ lastVisibleIndex +
+ " are visible)"
+ );
+ }
+}
+
+/**
+ * Assert that the given message is now shown in the current view.
+ */
+function assert_not_shown(aMessages) {
+ let win = get_about_3pane();
+ aMessages.forEach(function (msg) {
+ let viewIndex = win.gDBView.findIndexOfMsgHdr(msg, false);
+ if (viewIndex !== nsMsgViewIndex_None) {
+ throw new Error(
+ "Message shows; " + msg.messageKey + ": " + msg.mime2DecodedSubject
+ );
+ }
+ });
+}
+
+/**
+ * @param aShouldBeElided Should the messages at the view indices be elided?
+ * @param aArgs Arguments of the form processed by
+ * |_process_row_message_arguments|.
+ */
+function _assert_elided_helper(aShouldBeElided, ...aArgs) {
+ let [troller, viewIndices] = _process_row_message_arguments(...aArgs);
+
+ let dbView = get_db_view(troller.window);
+ for (let viewIndex of viewIndices) {
+ let flags = dbView.getFlagsAt(viewIndex);
+ if (Boolean(flags & Ci.nsMsgMessageFlags.Elided) != aShouldBeElided) {
+ throw new Error(
+ "Message at view index " +
+ viewIndex +
+ (aShouldBeElided
+ ? " should be elided but is not!"
+ : " should not be elided but is!")
+ );
+ }
+ }
+}
+
+/**
+ * Assert that all of the messages at the given view indices are collapsed.
+ * Arguments should be of the type accepted by |assert_selected_and_displayed|.
+ */
+function assert_collapsed(...aArgs) {
+ _assert_elided_helper(true, ...aArgs);
+}
+
+/**
+ * Assert that all of the messages at the given view indices are expanded.
+ * Arguments should be of the type accepted by |assert_selected_and_displayed|.
+ */
+function assert_expanded(...aArgs) {
+ _assert_elided_helper(false, ...aArgs);
+}
+
+/**
+ * Add the widget with the given id to the toolbar if it is not already present.
+ * It gets added to the front if we add it. Use |remove_from_toolbar| to
+ * remove the widget from the toolbar when you are done.
+ *
+ * @param aToolbarElement The DOM element that is the toolbar, like you would
+ * get from getElementById.
+ * @param aElementId The id attribute of the toolbaritem item you want added to
+ * the toolbar (not the id of the thing inside the toolbaritem tag!).
+ * We take the id name rather than element itself because if not already
+ * present the element is off floating in DOM limbo. (The toolbar widget
+ * calls removeChild on the palette.)
+ */
+function add_to_toolbar(aToolbarElement, aElementId) {
+ let currentSet = aToolbarElement.currentSet.split(",");
+ if (!currentSet.includes(aElementId)) {
+ currentSet.unshift(aElementId);
+ aToolbarElement.currentSet = currentSet.join(",");
+ }
+}
+
+/**
+ * Remove the widget with the given id from the toolbar if it is present. Use
+ * |add_to_toolbar| to add the item in the first place.
+ *
+ * @param aToolbarElement The DOM element that is the toolbar, like you would
+ * get from getElementById.
+ * @param aElementId The id attribute of the item you want removed to the
+ * toolbar.
+ */
+function remove_from_toolbar(aToolbarElement, aElementId) {
+ let currentSet = aToolbarElement.currentSet.split(",");
+ if (currentSet.includes(aElementId)) {
+ currentSet.splice(currentSet.indexOf(aElementId), 1);
+ aToolbarElement.currentSet = currentSet.join(",");
+ }
+}
+
+var RECOGNIZED_WINDOWS = ["messagepane", "multimessage"];
+var RECOGNIZED_ELEMENTS = ["folderTree", "threadTree", "attachmentList"];
+
+/**
+ * Focus the folder tree.
+ */
+function focus_folder_tree() {
+ let folderTree = get_about_3pane().document.getElementById("folderTree");
+ Assert.ok(BrowserTestUtils.is_visible(folderTree), "folder tree is visible");
+ folderTree.focus();
+}
+
+/**
+ * Focus the thread tree.
+ */
+function focus_thread_tree() {
+ let threadTree = get_about_3pane().document.getElementById("threadTree");
+ threadTree.table.body.focus();
+}
+
+/**
+ * Focus the (single) message pane.
+ */
+function focus_message_pane() {
+ let messageBrowser =
+ get_about_3pane().document.getElementById("messageBrowser");
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser is visible"
+ );
+ messageBrowser.focus();
+}
+
+/**
+ * Focus the multimessage pane.
+ */
+function focus_multimessage_pane() {
+ let multiMessageBrowser = get_about_3pane().document.getElementById(
+ "multiMessageBrowser"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(multiMessageBrowser),
+ "multi message browser is visible"
+ );
+ multiMessageBrowser.focus();
+}
+
+/**
+ * Returns a string indicating whatever's currently focused. This will return
+ * either one of the strings in RECOGNIZED_WINDOWS/RECOGNIZED_ELEMENTS or null.
+ */
+function _get_currently_focused_thing() {
+ // If the message pane or multimessage is focused, return that
+ let focusedWindow = mc.window.document.commandDispatcher.focusedWindow;
+ if (focusedWindow) {
+ for (let windowId of RECOGNIZED_WINDOWS) {
+ let elem = mc.window.document.getElementById(windowId);
+ if (elem && focusedWindow == elem.contentWindow) {
+ return windowId;
+ }
+ }
+ }
+
+ // Focused window not recognized, let's try the focused element.
+ // If an element is focused, it is necessary for the main window to be
+ // focused.
+ if (focusedWindow != mc.window) {
+ return null;
+ }
+
+ let focusedElement = mc.window.document.commandDispatcher.focusedElement;
+ let elementsToMatch = RECOGNIZED_ELEMENTS.map(elem =>
+ mc.window.document.getElementById(elem)
+ );
+ while (focusedElement && !elementsToMatch.includes(focusedElement)) {
+ focusedElement = focusedElement.parentNode;
+ }
+
+ return focusedElement ? focusedElement.id : null;
+}
+
+function _assert_thing_focused(aThing) {
+ let focusedThing = _get_currently_focused_thing();
+ if (focusedThing != aThing) {
+ throw new Error(
+ "The currently focused thing should be " +
+ aThing +
+ ", but is actually " +
+ focusedThing
+ );
+ }
+}
+
+/**
+ * Assert that the folder tree is focused.
+ */
+function assert_folder_tree_focused() {
+ Assert.equal(get_about_3pane().document.activeElement.id, "folderTree");
+}
+
+/**
+ * Assert that the thread tree is focused.
+ */
+function assert_thread_tree_focused() {
+ let about3Pane = get_about_3pane();
+ Assert.equal(
+ about3Pane.document.activeElement,
+ about3Pane.threadTree.table.body
+ );
+}
+
+/**
+ * Assert that the (single) message pane is focused.
+ */
+function assert_message_pane_focused() {
+ // TODO: this doesn't work.
+ // let aboutMessageWin = get_about_3pane_or_about_message();
+ // ready_about_win(aboutMessageWin);
+ // Assert.equal(
+ // aboutMessageWin.document.activeElement.id,
+ // "messageBrowser"
+ // );
+}
+
+/**
+ * Assert that the multimessage pane is focused.
+ */
+function assert_multimessage_pane_focused() {
+ _assert_thing_focused("multimessage");
+}
+
+/**
+ * Assert that the attachment list is focused.
+ */
+function assert_attachment_list_focused() {
+ _assert_thing_focused("attachmentList");
+}
+
+function _normalize_folder_view_index(aViewIndex, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ if (aViewIndex < 0) {
+ return (
+ aController.folderTreeView.QueryInterface(Ci.nsITreeView).rowCount +
+ aViewIndex
+ );
+ }
+ return aViewIndex;
+}
+
+/**
+ * Helper function for use by assert_folders_selected /
+ * assert_folders_selected_and_displayed / assert_folder_displayed.
+ */
+function _process_row_folder_arguments(...aArgs) {
+ let troller = mc;
+ // - normalize into desired selected view indices
+ let desiredFolders = [];
+ for (let arg of aArgs) {
+ // An integer identifying a view index
+ if (typeof arg == "number") {
+ let folder = troller.folderTreeView.getFolderForIndex(
+ _normalize_folder_view_index(arg)
+ );
+ if (!folder) {
+ throw new Error("Folder index not present in folder view: " + arg);
+ }
+ desiredFolders.push(folder);
+ } else if (arg instanceof Ci.nsIMsgFolder) {
+ // A folder
+ desiredFolders.push(arg);
+ } else if (arg.length == 2 && typeof arg[0] == "number") {
+ // A list containing two integers, indicating a range of view indices.
+ let lowIndex = _normalize_folder_view_index(arg[0]);
+ let highIndex = _normalize_folder_view_index(arg[1]);
+ for (let viewIndex = lowIndex; viewIndex <= highIndex; viewIndex++) {
+ desiredFolders.push(
+ troller.folderTreeView.getFolderForIndex(viewIndex)
+ );
+ }
+ } else if (arg.length !== undefined) {
+ // a List of folders
+ for (let iFolder = 0; iFolder < arg.length; iFolder++) {
+ let folder = arg[iFolder].QueryInterface(Ci.nsIMsgFolder);
+ if (!folder) {
+ throw new Error(arg[iFolder] + " is not a folder!");
+ }
+ desiredFolders.push(folder);
+ }
+ } else if (arg.window) {
+ // it's a MozmillController
+ troller = arg;
+ } else {
+ throw new Error("Illegal argument: " + arg);
+ }
+ }
+ // we can't really sort, so you'll have to grin and bear it
+ return [troller, desiredFolders];
+}
+
+/**
+ * Asserts that the given set of folders is selected. Unless you are dealing
+ * with transient selections resulting from right-clicks, you want to be using
+ * assert_folders_selected_and_displayed because it makes sure that the
+ * display is correct too.
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - An nsIMsgFolder.
+ * - A list of nsIMsgFolders.
+ */
+function assert_folders_selected(...aArgs) {
+ let [troller, desiredFolders] = _process_row_folder_arguments(...aArgs);
+
+ let win = get_about_3pane();
+ let folderTree = win.window.document.getElementById("folderTree");
+ // - get the actual selection (already sorted by integer value)
+ let uri = folderTree.rows[folderTree.selectedIndex]?.uri;
+ let selectedFolders = [MailServices.folderLookup.getFolderForURL(uri)];
+
+ // - test selection equivalence
+ // no shortcuts here. check if each folder in either array is present in the
+ // other array
+ if (
+ desiredFolders.some(
+ folder => _non_strict_index_of(selectedFolders, folder) == -1
+ ) ||
+ selectedFolders.some(
+ folder => _non_strict_index_of(desiredFolders, folder) == -1
+ )
+ ) {
+ throw new Error(
+ "Desired selection is: " +
+ _prettify_folder_array(desiredFolders) +
+ " but actual " +
+ "selection is: " +
+ _prettify_folder_array(selectedFolders)
+ );
+ }
+
+ return [troller, desiredFolders];
+}
+
+var assert_folder_selected = assert_folders_selected;
+
+/**
+ * Assert that the given folder is displayed, but not necessarily selected.
+ * Unless you are dealing with transient selection issues, you really should
+ * be using assert_folders_selected_and_displayed.
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - An nsIMsgFolder.
+ * - A list of nsIMsgFolders.
+ *
+ * In each case, since we can only have one folder displayed, we only look at
+ * the first folder you pass in.
+ */
+function assert_folder_displayed(...aArgs) {
+ let [troller, desiredFolders] = _process_row_folder_arguments(...aArgs);
+ Assert.equal(
+ troller.window.gFolderDisplay.displayedFolder,
+ desiredFolders[0]
+ );
+}
+
+/**
+ * Asserts that the folders corresponding to the one or more folder spec
+ * arguments are selected and displayed. If you specify multiple folders,
+ * we verify that all of them are selected and that the first folder you pass
+ * in is the one displayed. (If you don't pass in any folders, we can't assume
+ * anything, so we don't test that case.)
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - An nsIMsgFolder.
+ * - A list of nsIMsgFolders.
+ */
+function assert_folders_selected_and_displayed(...aArgs) {
+ let [, desiredFolders] = assert_folders_selected(...aArgs);
+ if (desiredFolders.length > 0) {
+ let win = get_about_3pane();
+ Assert.equal(win.gFolder, desiredFolders[0]);
+ }
+}
+
+var assert_folder_selected_and_displayed =
+ assert_folders_selected_and_displayed;
+
+/**
+ * Assert that there are the given number of rows (not including children of
+ * collapsed parents) in the folder tree view.
+ */
+function assert_folder_tree_view_row_count(aCount) {
+ let about3Pane = get_about_3pane();
+ if (about3Pane.folderTree.rowCount != aCount) {
+ throw new Error(
+ "The folder tree view's row count should be " +
+ aCount +
+ ", but is actually " +
+ about3Pane.folderTree.rowCount
+ );
+ }
+}
+
+/**
+ * Assert that the displayed text of the folder at index n equals to str.
+ */
+function assert_folder_at_index_as(n, str) {
+ let folderN = mc.window.gFolderTreeView.getFTVItemForIndex(n);
+ Assert.equal(folderN.text, str);
+}
+
+/**
+ * Since indexOf does strict equality checking, we need this.
+ */
+function _non_strict_index_of(aArray, aSearchElement) {
+ for (let [i, item] of aArray.entries()) {
+ if (item == aSearchElement) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+function _prettify_folder_array(aArray) {
+ return aArray.map(folder => folder.prettyName).join(", ");
+}
+
+/**
+ * Put the view in unthreaded mode.
+ */
+function make_display_unthreaded() {
+ wait_for_message_display_completion();
+ get_about_3pane().gViewWrapper.showUnthreaded = true;
+ // drain event queue
+ utils.sleep(0);
+ wait_for_message_display_completion();
+}
+
+/**
+ * Put the view in threaded mode.
+ */
+function make_display_threaded() {
+ wait_for_message_display_completion();
+ get_about_3pane().gViewWrapper.showThreaded = true;
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Put the view in group-by-sort mode.
+ */
+function make_display_grouped() {
+ wait_for_message_display_completion();
+ get_about_3pane().gViewWrapper.showGroupedBySort = true;
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Collapse all threads in the current view.
+ */
+function collapse_all_threads() {
+ wait_for_message_display_completion();
+ get_about_3pane().commandController.doCommand("cmd_collapseAllThreads");
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Set whether to show unread messages only in the current view.
+ */
+function set_show_unread_only(aShowUnreadOnly) {
+ wait_for_message_display_completion();
+ mc.window.gFolderDisplay.view.showUnreadOnly = aShowUnreadOnly;
+ wait_for_all_messages_to_load();
+ wait_for_message_display_completion();
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Assert that we are showing unread messages only in this view.
+ */
+function assert_showing_unread_only() {
+ wait_for_message_display_completion();
+ if (!mc.window.gFolderDisplay.view.showUnreadOnly) {
+ throw new Error(
+ "The view should be showing unread messages only, but it isn't."
+ );
+ }
+}
+
+/**
+ * Assert that we are _not_ showing unread messages only in this view.
+ */
+function assert_not_showing_unread_only() {
+ wait_for_message_display_completion();
+ if (mc.window.gFolderDisplay.view.showUnreadOnly) {
+ throw new Error(
+ "The view should not be showing unread messages only, but it is."
+ );
+ }
+}
+
+/**
+ * Set the mail view filter for the current view. The aData parameter is for
+ * tags (e.g. you can specify "$label1" for the first tag).
+ */
+function set_mail_view(aMailViewIndex, aData) {
+ wait_for_message_display_completion();
+ get_about_3pane().gViewWrapper.setMailView(aMailViewIndex, aData);
+ wait_for_all_messages_to_load();
+ wait_for_message_display_completion();
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Assert that the current mail view is as given. See the documentation for
+ * |set_mail_view| for information about aData.
+ */
+function assert_mail_view(aMailViewIndex, aData) {
+ let actualMailViewIndex = mc.window.gFolderDisplay.view.mailViewIndex;
+ if (actualMailViewIndex != aMailViewIndex) {
+ throw new Error(
+ "The mail view index should be " +
+ aMailViewIndex +
+ ", but is actually " +
+ actualMailViewIndex
+ );
+ }
+
+ let actualMailViewData = mc.window.gFolderDisplay.view.mailViewData;
+ if (actualMailViewData != aData) {
+ throw new Error(
+ "The mail view data should be " +
+ aData +
+ ", but is actually " +
+ actualMailViewData
+ );
+ }
+}
+
+/**
+ * Expand all threads in the current view.
+ */
+function expand_all_threads() {
+ wait_for_message_display_completion();
+ get_about_3pane().commandController.doCommand("cmd_expandAllThreads");
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Set the mail.openMessageBehavior pref.
+ *
+ * @param aPref One of "NEW_WINDOW", "EXISTING_WINDOW" or "NEW_TAB"
+ */
+function set_open_message_behavior(aPref) {
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior[aPref]
+ );
+}
+
+/**
+ * Reset the mail.openMessageBehavior pref.
+ */
+function reset_open_message_behavior() {
+ if (Services.prefs.prefHasUserValue("mail.openMessageBehavior")) {
+ Services.prefs.clearUserPref("mail.openMessageBehavior");
+ }
+}
+
+/**
+ * Set the mail.tabs.loadInBackground pref.
+ *
+ * @param aPref true/false.
+ */
+function set_context_menu_background_tabs(aPref) {
+ Services.prefs.setBoolPref("mail.tabs.loadInBackground", aPref);
+}
+
+/**
+ * Reset the mail.tabs.loadInBackground pref.
+ */
+function reset_context_menu_background_tabs() {
+ if (Services.prefs.prefHasUserValue("mail.tabs.loadInBackground")) {
+ Services.prefs.clearUserPref("mail.tabs.loadInBackground");
+ }
+}
+
+/**
+ * Set the mail.close_message_window.on_delete pref.
+ *
+ * @param aPref true/false.
+ */
+function set_close_message_on_delete(aPref) {
+ Services.prefs.setBoolPref("mail.close_message_window.on_delete", aPref);
+}
+
+/**
+ * Reset the mail.close_message_window.on_delete pref.
+ */
+function reset_close_message_on_delete() {
+ if (Services.prefs.prefHasUserValue("mail.close_message_window.on_delete")) {
+ Services.prefs.clearUserPref("mail.close_message_window.on_delete");
+ }
+}
+
+/**
+ * assert that the multimessage/thread summary view contains
+ * the specified number of elements of the specified selector.
+ *
+ * @param aSelector: the CSS selector to use to select
+ * @param aNumElts: the number of expected elements that have that class
+ */
+
+function assert_summary_contains_N_elts(aSelector, aNumElts) {
+ let htmlframe = mc.window.document.getElementById("multimessage");
+ let matches = htmlframe.contentDocument.querySelectorAll(aSelector);
+ if (matches.length != aNumElts) {
+ throw new Error(
+ "Expected to find " +
+ aNumElts +
+ " elements with selector '" +
+ aSelector +
+ "', found: " +
+ matches.length
+ );
+ }
+}
+
+function throw_and_dump_view_state(aMessage, aController) {
+ dump("******** " + aMessage + "\n");
+ dump_view_state(get_db_view(aController?.window));
+ throw new Error(aMessage);
+}
+
+/**
+ * Copy constants from mailWindowOverlay.js
+ */
+
+var kClassicMailLayout = 0;
+var kWideMailLayout = 1;
+var kVerticalMailLayout = 2;
+
+/**
+ * Assert that the expected mail pane layout is shown.
+ *
+ * @param aLayout layout code
+ */
+function assert_pane_layout(aLayout) {
+ let actualPaneLayout = Services.prefs.getIntPref("mail.pane_config.dynamic");
+ if (actualPaneLayout != aLayout) {
+ throw new Error(
+ "The mail pane layout should be " +
+ aLayout +
+ ", but is actually " +
+ actualPaneLayout
+ );
+ }
+}
+
+/**
+ * Change the current mail pane layout.
+ *
+ * @param aLayout layout code
+ */
+function set_pane_layout(aLayout) {
+ Services.prefs.setIntPref("mail.pane_config.dynamic", aLayout);
+}
+
+/*
+ * Check window sizes of the main Tb window whether they are at the default values.
+ * Some tests change the window size so need to be sure what size they start with.
+ */
+function assert_default_window_size() {
+ Assert.equal(
+ mc.window.outerWidth,
+ gDefaultWindowWidth,
+ "Main window didn't meet the expected width"
+ );
+ Assert.equal(
+ mc.window.outerHeight,
+ gDefaultWindowHeight,
+ "Main window didn't meet the expected height"
+ );
+}
+
+/**
+ * Restore window to nominal dimensions; saving the size was not working out.
+ */
+function restore_default_window_size() {
+ windowHelper.resize_to(mc, gDefaultWindowWidth, gDefaultWindowHeight);
+}
+
+/**
+ * Toggle visibility of the Main menu bar.
+ *
+ * @param {boolean} aEnabled - Whether the menu should be shown or not.
+ */
+function toggle_main_menu(aEnabled = true) {
+ let menubar = mc.window.document.getElementById("toolbar-menubar");
+ let state = menubar.getAttribute("autohide") != "true";
+ menubar.setAttribute("autohide", !aEnabled);
+ utils.sleep(0);
+ return state;
+}
+
+/**
+ * Load a file in its own 'module' (scope really), based on the effective
+ * location of the staged FolderDisplayHelpers.jsm module.
+ *
+ * @param {string} aPath - A path relative to the module (can be just a file name)
+ * @param {object} aScope - Scope to load the file into.
+ *
+ * @returns An object that serves as the global scope for the loaded file.
+ */
+function load_via_src_path(aPath, aScope) {
+ let thisFileURL = Cc["@mozilla.org/network/protocol;1?name=resource"]
+ .getService(Ci.nsIResProtocolHandler)
+ .resolveURI(
+ Services.io.newURI(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ )
+ );
+ let thisFile = Services.io
+ .newURI(thisFileURL)
+ .QueryInterface(Ci.nsIFileURL).file;
+
+ thisFile.setRelativePath;
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.setRelativePath(thisFile, aPath);
+ // The files are at different paths when tests are run locally vs. CI.
+ // Plain js files shouldn't really be loaded from a module, but while we
+ // work on resolving that, try both locations...
+ if (!file.exists()) {
+ file.setRelativePath(thisFile, aPath.replace("/testing", ""));
+ }
+ if (!file.exists()) {
+ throw new Error(
+ `Could not resolve file ${file.path} for path ${aPath} relative to ${thisFile.path}`
+ );
+ }
+ let uri = Services.io.newFileURI(file).spec;
+ Services.scriptloader.loadSubScript(uri, aScope);
+}
diff --git a/comm/mail/test/browser/shared-modules/JunkHelpers.jsm b/comm/mail/test/browser/shared-modules/JunkHelpers.jsm
new file mode 100644
index 0000000000..6d53271ed5
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/JunkHelpers.jsm
@@ -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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "mark_selected_messages_as_junk",
+ "delete_mail_marked_as_junk",
+];
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+var {
+ mc,
+ get_about_3pane,
+ plan_to_wait_for_folder_events,
+ wait_for_message_display_completion,
+ wait_for_folder_events,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+/**
+ * Mark the selected messages as junk. This is done by pressing the J key.
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function mark_selected_messages_as_junk(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+ let win = get_about_3pane(aController.window);
+ if (aController == mc) {
+ win.document.getElementById("threadTree").focus();
+ }
+ EventUtils.synthesizeKey("j", {}, win);
+}
+
+/**
+ * Delete all mail marked as junk in the selected folder. This is done by
+ * activating the menu option from the Tools menu.
+ *
+ * @param aNumDeletesExpected The number of deletes expected.
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+async function delete_mail_marked_as_junk(aNumDeletesExpected, aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+ let win = get_about_3pane(aController.window);
+
+ // Monkey patch and wrap around the deleteJunkInFolder function, mainly for
+ // the case where deletes aren't expected.
+ let realDeleteJunkInFolder = win.deleteJunkInFolder;
+ let numMessagesDeleted = null;
+ let fakeDeleteJunkInFolder = function () {
+ numMessagesDeleted = realDeleteJunkInFolder();
+ return numMessagesDeleted;
+ };
+ try {
+ win.deleteJunkInFolder = fakeDeleteJunkInFolder;
+
+ // If something is loading, make sure it finishes loading...
+ wait_for_message_display_completion(aController);
+ if (aNumDeletesExpected != 0) {
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+ }
+
+ win.goDoCommand("cmd_deleteJunk");
+
+ if (aNumDeletesExpected != 0) {
+ wait_for_folder_events();
+ }
+
+ // If timeout waiting for numMessagesDeleted to turn non-null,
+ // this either means that deleteJunkInFolder didn't get called or that it
+ // didn't return a value."
+
+ await TestUtils.waitForCondition(
+ () => numMessagesDeleted === aNumDeletesExpected,
+ `Should have got ${aNumDeletesExpected} deletes, not ${numMessagesDeleted}`
+ );
+ } finally {
+ win.deleteJunkInFolder = realDeleteJunkInFolder;
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/KeyboardHelpers.jsm b/comm/mail/test/browser/shared-modules/KeyboardHelpers.jsm
new file mode 100644
index 0000000000..e99c5bd484
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/KeyboardHelpers.jsm
@@ -0,0 +1,58 @@
+/* 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 = [
+ "input_value",
+ "delete_existing",
+ "delete_all_existing",
+];
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+/**
+ * Emulates manual input
+ *
+ * @param aController The window controller to input keypresses into
+ * @param aStr The string to input into the control element
+ * @param aElement (optional) Element on which to perform the input
+ */
+function input_value(aController, aStr, aElement) {
+ if (aElement) {
+ aElement.focus();
+ }
+ for (let i = 0; i < aStr.length; i++) {
+ EventUtils.synthesizeKey(aStr.charAt(i), {}, aController.window);
+ }
+}
+
+/**
+ * Emulates deleting strings via the keyboard
+ *
+ * @param aController The window controller to input keypresses into
+ * @param aElement The element in which to delete characters
+ * @param aNumber The number of times to press the delete key.
+ */
+function delete_existing(aController, aElement, aNumber) {
+ for (let i = 0; i < aNumber; ++i) {
+ aElement.focus();
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, aController.window);
+ }
+}
+
+/**
+ * Emulates deleting the entire string by pressing Ctrl-A and DEL
+ *
+ * @param aController The window controller to input keypresses into
+ * @param aElement The element in which to delete characters
+ */
+function delete_all_existing(aController, aElement) {
+ aElement.focus();
+ EventUtils.synthesizeKey("a", { accelKey: true }, aController.window);
+ aElement.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, aController.window);
+}
diff --git a/comm/mail/test/browser/shared-modules/MockObjectHelpers.jsm b/comm/mail/test/browser/shared-modules/MockObjectHelpers.jsm
new file mode 100644
index 0000000000..3b4ed91e53
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/MockObjectHelpers.jsm
@@ -0,0 +1,161 @@
+/* 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 = ["MockObjectReplacer", "MockObjectRegisterer"];
+
+var Cm = Components.manager;
+
+function MockObjectRegisterer(aContractID, aCID, aComponent) {
+ this._contractID = aContractID;
+ this._cid = Components.ID("{" + aCID + "}");
+ this._component = aComponent;
+}
+
+MockObjectRegisterer.prototype = {
+ register() {
+ let providedConstructor = this._component;
+ this._mockFactory = {
+ createInstance(aIid) {
+ return new providedConstructor().QueryInterface(aIid);
+ },
+ };
+
+ let componentRegistrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+
+ componentRegistrar.registerFactory(
+ this._cid,
+ "",
+ this._contractID,
+ this._mockFactory
+ );
+ },
+
+ unregister() {
+ let componentRegistrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+
+ componentRegistrar.unregisterFactory(this._cid, this._mockFactory);
+ },
+};
+
+/**
+ * Allows registering a mock XPCOM component, that temporarily replaces the
+ * original one when an object implementing a given ContractID is requested
+ * using createInstance.
+ *
+ * @param aContractID
+ * The ContractID of the component to replace, for example
+ * "@mozilla.org/filepicker;1".
+ *
+ * @param aReplacementCtor
+ * The constructor function for the JavaScript object that will be
+ * created every time createInstance is called. This object must
+ * implement QueryInterface and provide the XPCOM interfaces required by
+ * the specified ContractID (for example
+ * Ci.nsIFilePicker).
+ */
+
+function MockObjectReplacer(aContractID, aReplacementCtor) {
+ this._contractID = aContractID;
+ this._replacementCtor = aReplacementCtor;
+ this._cid = null;
+}
+
+MockObjectReplacer.prototype = {
+ /**
+ * Replaces the current factory with one that returns a new mock object.
+ *
+ * After register() has been called, it is mandatory to call unregister() to
+ * restore the original component. Usually, you should use a try-catch block
+ * to ensure that unregister() is called.
+ */
+ register() {
+ if (this._cid) {
+ throw Error("Invalid object state when calling register()");
+ }
+
+ // Define a factory that creates a new object using the given constructor.
+ var providedConstructor = this._replacementCtor;
+ this._mockFactory = {
+ createInstance(aIid) {
+ return new providedConstructor().QueryInterface(aIid);
+ },
+ };
+
+ var retVal = swapFactoryRegistration(
+ this._cid,
+ this._originalCID,
+ this._contractID,
+ this._mockFactory
+ );
+ if ("error" in retVal) {
+ throw new Error("ERROR: " + retVal.error);
+ } else {
+ this._cid = retVal.cid;
+ this._originalCID = retVal.originalCID;
+ }
+ },
+
+ /**
+ * Restores the original factory.
+ */
+ unregister() {
+ if (!this._cid) {
+ throw Error("Invalid object state when calling unregister()");
+ }
+
+ // Free references to the mock factory.
+ swapFactoryRegistration(
+ this._cid,
+ this._originalCID,
+ this._contractID,
+ this._mockFactory
+ );
+
+ // Allow registering a mock factory again later.
+ this._cid = null;
+ this._originalCID = null;
+ this._mockFactory = null;
+ },
+
+ // --- Private methods and properties ---
+
+ /**
+ * The CID under which the mock contractID was registered.
+ */
+ _cid: null,
+
+ /**
+ * The nsIFactory that was automatically generated by this object.
+ */
+ _mockFactory: null,
+};
+
+/**
+ * Swiped from mozilla/testing/mochitest/tests/SimpleTest/specialpowersAPI.js
+ */
+function swapFactoryRegistration(CID, originalCID, contractID, newFactory) {
+ let componentRegistrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+
+ if (originalCID == null) {
+ if (contractID != null) {
+ originalCID = componentRegistrar.contractIDToCID(contractID);
+ void Cm.getClassObject(Cc[contractID], Ci.nsIFactory);
+ } else {
+ return {
+ error: "trying to register a new contract ID: Missing contractID",
+ };
+ }
+ CID = Services.uuid.generateUUID();
+
+ componentRegistrar.registerFactory(CID, "", contractID, newFactory);
+ } else {
+ componentRegistrar.unregisterFactory(CID, newFactory);
+ // Restore the original factory.
+ componentRegistrar.registerFactory(originalCID, "", contractID, null);
+ }
+
+ return { cid: CID, originalCID };
+}
diff --git a/comm/mail/test/browser/shared-modules/MouseEventHelpers.jsm b/comm/mail/test/browser/shared-modules/MouseEventHelpers.jsm
new file mode 100644
index 0000000000..cd0f9a09d4
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/MouseEventHelpers.jsm
@@ -0,0 +1,226 @@
+/* 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 = [
+ "drag_n_drop_element",
+ "synthesize_drag_start",
+ "synthesize_drag_over",
+ "synthesize_drag_end",
+ "synthesize_drop",
+];
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+
+/**
+ * Execute a drag and drop session.
+ *
+ * @param {XULElement} aDragObject
+ * the element from which the drag session should be started.
+ * @param {} aDragWindow
+ * the window the aDragObject is in
+ * @param {XULElement} aDropObject
+ * the element at which the drag session should be ended.
+ * @param {} aDropWindow
+ * the window the aDropObject is in
+ * @param {} aRelDropX
+ * the relative x-position the element is dropped over the aDropObject
+ * in percent of the aDropObject width
+ * @param {} aRelDropY
+ * the relative y-position the element is dropped over the aDropObject
+ * in percent of the aDropObject height
+ * @param {XULElement} aListener
+ * the element who's drop target should be captured and returned.
+ */
+function drag_n_drop_element(
+ aDragObject,
+ aDragWindow,
+ aDropObject,
+ aDropWindow,
+ aRelDropX,
+ aRelDropY,
+ aListener
+) {
+ let dt = synthesize_drag_start(aDragWindow, aDragObject, aListener);
+ Assert.ok(dt, "Drag data transfer was undefined");
+
+ synthesize_drag_over(aDropWindow, aDropObject, dt);
+
+ let dropRect = aDropObject.getBoundingClientRect();
+ synthesize_drop(aDropWindow, aDropObject, dt, {
+ screenX: aDropObject.screenX + dropRect.width * aRelDropX,
+ screenY: aDropObject.screenY + dropRect.height * aRelDropY,
+ });
+}
+
+/**
+ * Starts a drag new session.
+ *
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {XULElement} aListener
+ * the element who's drop target should be captured and returned.
+ * @returns {nsIDataTransfer}
+ * returns the DataTransfer Object of captured by aListener.
+ */
+function synthesize_drag_start(aWindow, aDispatcher, aListener) {
+ let dt;
+
+ let trapDrag = function (event) {
+ if (!event.dataTransfer) {
+ throw new Error("no DataTransfer");
+ }
+
+ dt = event.dataTransfer;
+
+ event.preventDefault();
+ };
+
+ aListener.addEventListener("dragstart", trapDrag, true);
+
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mousedown" }, aWindow);
+ EventUtils.synthesizeMouse(
+ aDispatcher,
+ 5,
+ 10,
+ { type: "mousemove" },
+ aWindow
+ );
+ EventUtils.synthesizeMouse(
+ aDispatcher,
+ 5,
+ 15,
+ { type: "mousemove" },
+ aWindow
+ );
+
+ aListener.removeEventListener("dragstart", trapDrag, true);
+
+ return dt;
+}
+
+/**
+ * Synthesizes a drag over event.
+ *
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {nsIDataTransfer} aDt
+ * the DataTransfer Object of captured by listener.
+ * @param {} aArgs
+ * arguments passed to the mouse event.
+ */
+function synthesize_drag_over(aWindow, aDispatcher, aDt, aArgs) {
+ _synthesizeDragEvent("dragover", aWindow, aDispatcher, aDt, aArgs);
+}
+
+/**
+ * Synthesizes a drag end event.
+ *
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {nsIDataTransfer} aDt
+ * the DataTransfer Object of captured by listener.
+ * @param {} aArgs
+ * arguments passed to the mouse event.
+ */
+function synthesize_drag_end(aWindow, aDispatcher, aListener, aDt, aArgs) {
+ _synthesizeDragEvent("dragend", aWindow, aListener, aDt, aArgs);
+
+ // Ensure drag has ended.
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mousemove" }, aWindow);
+ EventUtils.synthesizeMouse(
+ aDispatcher,
+ 5,
+ 10,
+ { type: "mousemove" },
+ aWindow
+ );
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mouseup" }, aWindow);
+}
+
+/**
+ * Synthesizes a drop event.
+ *
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {nsIDataTransfer} aDt
+ * the DataTransfer Object of captured by listener.
+ * @param {} aArgs
+ * arguments passed to the mouse event.
+ */
+function synthesize_drop(aWindow, aDispatcher, aDt, aArgs) {
+ _synthesizeDragEvent("drop", aWindow, aDispatcher, aDt, aArgs);
+
+ // Ensure drag has ended.
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mousemove" }, aWindow);
+ EventUtils.synthesizeMouse(
+ aDispatcher,
+ 5,
+ 10,
+ { type: "mousemove" },
+ aWindow
+ );
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mouseup" }, aWindow);
+}
+
+/**
+ * Private function: Synthesizes a specified drag event.
+ *
+ * @param {} aType
+ * the type of the drag event to be synthesiyzed.
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {nsIDataTransfer} aDt
+ * the DataTransfer Object of captured by listener.
+ * @param {} aArgs
+ * arguments passed to the mouse event.
+ */
+function _synthesizeDragEvent(aType, aWindow, aDispatcher, aDt, aArgs) {
+ let screenX;
+ if (aArgs && "screenX" in aArgs) {
+ screenX = aArgs.screenX;
+ } else {
+ screenX = aDispatcher.screenX;
+ }
+
+ let screenY;
+ if (aArgs && "screenY" in aArgs) {
+ screenY = aArgs.screenY;
+ } else {
+ screenY = aDispatcher.screenY;
+ }
+
+ let event = aWindow.document.createEvent("DragEvent");
+ event.initDragEvent(
+ aType,
+ true,
+ true,
+ aWindow,
+ 0,
+ screenX,
+ screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ null,
+ aDt
+ );
+ aDispatcher.dispatchEvent(event);
+}
diff --git a/comm/mail/test/browser/shared-modules/NNTPHelpers.jsm b/comm/mail/test/browser/shared-modules/NNTPHelpers.jsm
new file mode 100644
index 0000000000..71c785c02c
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/NNTPHelpers.jsm
@@ -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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "setupNNTPDaemon",
+ "NNTP_PORT",
+ "setupLocalServer",
+ "startupNNTPServer",
+ "shutdownNNTPServer",
+];
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { NewsArticle, NNTP_RFC977_handler, NntpDaemon } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Nntpd.jsm"
+);
+var { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+);
+
+var kSimpleNewsArticle =
+ "From: John Doe <john.doe@example.com>\n" +
+ "Date: Sat, 24 Mar 1990 10:59:24 -0500\n" +
+ "Newsgroups: test.subscribe.simple\n" +
+ "Subject: H2G2 -- What does it mean?\n" +
+ "Message-ID: <TSS1@nntp.invalid>\n" +
+ "\n" +
+ "What does the acronym H2G2 stand for? I've seen it before...\n";
+
+// The groups to set up on the fake server.
+// It is an array of tuples, where the first element is the group name and the
+// second element is whether or not we should subscribe to it.
+var groups = [
+ ["test.empty", false],
+ ["test.subscribe.empty", true],
+ ["test.subscribe.simple", true],
+ ["test.filter", true],
+];
+
+// Sets up the NNTP daemon object for use in fake server
+function setupNNTPDaemon() {
+ var daemon = new NntpDaemon();
+
+ groups.forEach(function (element) {
+ daemon.addGroup(element[0]);
+ });
+
+ var article = new NewsArticle(kSimpleNewsArticle);
+ daemon.addArticleToGroup(article, "test.subscribe.simple", 1);
+
+ return daemon;
+}
+
+// Startup server
+function startupNNTPServer(daemon, port) {
+ var handler = NNTP_RFC977_handler;
+
+ function createHandler(daemon) {
+ return new handler(daemon);
+ }
+
+ var server = new nsMailServer(createHandler, daemon);
+ server.start(port);
+ return server;
+}
+
+// Shutdown server
+function shutdownNNTPServer(server) {
+ server.stop();
+}
+
+// Enable strict threading
+Services.prefs.setBoolPref("mail.strict_threading", true);
+
+// Make sure we don't try to use a protected port. I like adding 1024 to the
+// default port when doing so...
+var NNTP_PORT = 1024 + 119;
+
+var _server = null;
+
+function subscribeServer(incomingServer) {
+ // Subscribe to newsgroups
+ incomingServer.QueryInterface(Ci.nsINntpIncomingServer);
+ groups.forEach(function (element) {
+ if (element[1]) {
+ incomingServer.subscribeToNewsgroup(element[0]);
+ }
+ });
+ // Only allow one connection
+ incomingServer.maximumConnectionsNumber = 1;
+}
+
+// Sets up the client-side portion of fakeserver
+function setupLocalServer(port) {
+ if (_server != null) {
+ return _server;
+ }
+
+ var server = MailServices.accounts.createIncomingServer(
+ null,
+ "localhost",
+ "nntp"
+ );
+ server.port = port;
+ server.valid = false;
+
+ var account = MailServices.accounts.createAccount();
+ account.incomingServer = server;
+ server.valid = true;
+ // hack to cause an account loaded notification now the server is valid
+ // (see also Bug 903804)
+ account.incomingServer = account.incomingServer; // eslint-disable-line no-self-assign
+
+ subscribeServer(server);
+
+ _server = server;
+
+ return server;
+}
diff --git a/comm/mail/test/browser/shared-modules/NewMailAccountHelpers.jsm b/comm/mail/test/browser/shared-modules/NewMailAccountHelpers.jsm
new file mode 100644
index 0000000000..e9992fa344
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/NewMailAccountHelpers.jsm
@@ -0,0 +1,25 @@
+/* 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 = ["remove_email_account"];
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Remove an account with the address from the current profile.
+ *
+ * @param {string} address - The email address to try to remove.
+ */
+function remove_email_account(address) {
+ for (let account of MailServices.accounts.accounts) {
+ if (account.defaultIdentity && account.defaultIdentity.email == address) {
+ MailServices.accounts.removeAccount(account);
+ break;
+ }
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/NotificationBoxHelpers.jsm b/comm/mail/test/browser/shared-modules/NotificationBoxHelpers.jsm
new file mode 100644
index 0000000000..14a853e001
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/NotificationBoxHelpers.jsm
@@ -0,0 +1,219 @@
+/* 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 = [
+ "check_notification_displayed",
+ "assert_notification_displayed",
+ "close_notification",
+ "wait_for_notification_to_stop",
+ "wait_for_notification_to_show",
+ "get_notification_button",
+ "get_notification",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+/**
+ * A helper function for determining whether or not a notification with
+ * a particular value is being displayed.
+ *
+ * @param aWindow the window to check
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to look for
+ * @param aNotification an optional out parameter: object that will pass the
+ * notification element out of this function in its
+ * 'notification' property
+ *
+ * @returns True/false depending on the state of the notification.
+ */
+function check_notification_displayed(aWindow, aBoxId, aValue, aNotification) {
+ let nb = aWindow.document.getElementById(aBoxId);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + aBoxId);
+ }
+
+ if (nb.querySelector(".notificationbox-stack")) {
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ let notification = box.getNotificationWithValue(aValue);
+ if (aNotification) {
+ aNotification.notification = notification;
+ }
+ return notification != null;
+ }
+
+ return false;
+}
+
+/**
+ * A helper function ensuring whether or not a notification with
+ * a particular value is being displayed. Throws if the state is
+ * not the expected one.
+ *
+ * @param aWindow the window to check
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to look for
+ * @param aDisplayed true if the notification should be displayed, false
+ * otherwise
+ * @returns the notification if we're asserting that the notification is
+ * displayed, and it actually shows up. Throws otherwise.
+ */
+function assert_notification_displayed(aWindow, aBoxId, aValue, aDisplayed) {
+ let notification = {};
+ let hasNotification = check_notification_displayed(
+ aWindow,
+ aBoxId,
+ aValue,
+ notification
+ );
+ if (hasNotification != aDisplayed) {
+ throw new Error(
+ "Expected the notification with value " +
+ aValue +
+ " to " +
+ (aDisplayed ? "be shown" : "not be shown")
+ );
+ }
+
+ return notification.notification;
+}
+
+/**
+ * A helper function for closing a notification if one is currently displayed
+ * in the window.
+ *
+ * @param aWindow the window with the notification
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to close
+ */
+function close_notification(aWindow, aBoxId, aValue) {
+ let nb = aWindow.document.getElementById(aBoxId);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + aBoxId);
+ }
+
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ let notification = box.getNotificationWithValue(aValue);
+ if (notification) {
+ notification.close();
+ }
+}
+
+/**
+ * A helper function that waits for a notification with value aValue
+ * to stop displaying in the window.
+ *
+ * @param aWindow the window with the notification
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to wait to stop
+ */
+function wait_for_notification_to_stop(aWindow, aBoxId, aValue) {
+ let nb = aWindow.document.getElementById(aBoxId);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + aBoxId);
+ }
+
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ utils.waitFor(
+ () => !box.getNotificationWithValue(aValue),
+ "Timed out waiting for notification with value " + aValue + " to stop."
+ );
+}
+
+/**
+ * A helper function that waits for a notification with value aValue
+ * to show in the window.
+ *
+ * @param aWindow the window that we want the notification to appear in
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to wait for
+ */
+function wait_for_notification_to_show(aWindow, aBoxId, aValue) {
+ let nb = aWindow.document.getElementById(aBoxId);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + aBoxId);
+ }
+
+ function nbReady() {
+ if (nb.querySelector(".notificationbox-stack")) {
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ return box.getNotificationWithValue(aValue) != null && !box._animating;
+ }
+ return false;
+ }
+ utils.waitFor(
+ nbReady,
+ "Timed out waiting for notification with value " + aValue + " to show."
+ );
+}
+
+/**
+ * Return the notification element based on the container ID and the Value type.
+ *
+ * @param {Window} win - The window that we want the notification to appear in.
+ * @param {string} id - The id of the notification box.
+ * @param {string} val - The value of the notification to fetch.
+ * @returns {?Element} - The notification element if found.
+ */
+function get_notification(win, id, val) {
+ let nb = win.document.getElementById(id);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + id);
+ }
+
+ if (nb.querySelector(".notificationbox-stack")) {
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ return box.getNotificationWithValue(val);
+ }
+
+ return null;
+}
+
+/**
+ * Gets a button in a notification, as those do not have IDs.
+ *
+ * @param aWindow The window that has the notification.
+ * @param aBoxId The id of the notification box.
+ * @param aValue The value of the notification to find.
+ * @param aMatch Attributes of the button to find. An object with key:value
+ * pairs, similar to click_menus_in_sequence().
+ */
+function get_notification_button(aWindow, aBoxId, aValue, aMatch) {
+ let notification = get_notification(aWindow, aBoxId, aValue);
+ let buttons = notification.buttonContainer.querySelectorAll(
+ "button, toolbarbutton"
+ );
+ for (let button of buttons) {
+ let matchedAll = true;
+ for (let name in aMatch) {
+ let value = aMatch[name];
+ let matched = false;
+ if (name == "popup") {
+ if (button.getAttribute("type") == "menu") {
+ // The button contains a menupopup as the first child.
+ matched = button.querySelector("menupopup#" + value);
+ } else {
+ // The "popup" attribute is not on the button itself but in its
+ // buttonInfo member.
+ matched = "buttonInfo" in button && button.buttonInfo.popup == value;
+ }
+ } else if (
+ button.hasAttribute(name) &&
+ button.getAttribute(name) == value
+ ) {
+ matched = true;
+ }
+ if (!matched) {
+ matchedAll = false;
+ break;
+ }
+ }
+ if (matchedAll) {
+ return button;
+ }
+ }
+
+ throw new Error("Couldn't find the requested button on a notification");
+}
diff --git a/comm/mail/test/browser/shared-modules/OpenPGPTestUtils.jsm b/comm/mail/test/browser/shared-modules/OpenPGPTestUtils.jsm
new file mode 100644
index 0000000000..5913778590
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/OpenPGPTestUtils.jsm
@@ -0,0 +1,329 @@
+/* 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 = ["OpenPGPTestUtils"];
+
+const { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+const EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ OpenPGPAlias: "chrome://openpgp/content/modules/OpenPGPAlias.jsm",
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+ RNP: "chrome://openpgp/content/modules/RNP.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+});
+
+const OpenPGPTestUtils = {
+ ACCEPTANCE_PERSONAL: "personal",
+ ACCEPTANCE_REJECTED: "rejected",
+ ACCEPTANCE_UNVERIFIED: "unverified",
+ ACCEPTANCE_VERIFIED: "verified",
+ ACCEPTANCE_UNDECIDED: "undecided",
+ ALICE_KEY_ID: "F231550C4F47E38E",
+ BOB_KEY_ID: "FBFCC82A015E7330",
+ CAROL_KEY_ID: "3099FF1238852B9F",
+
+ /**
+ * Given a compose message window, clicks on the "Digitally Sign This Message"
+ * menu item.
+ */
+ async toggleMessageSigning(win) {
+ return clickToolbarButtonMenuItem(win, "#button-encryption-options", [
+ "#menu_securitySign_Toolbar",
+ ]);
+ },
+
+ /**
+ * Given a compose message window, clicks on the "Encrypt Subject"
+ * menu item.
+ */
+ async toggleMessageEncryptSubject(win) {
+ return clickToolbarButtonMenuItem(win, "#button-encryption-options", [
+ "#menu_securityEncryptSubject_Toolbar",
+ ]);
+ },
+
+ /**
+ * Given a compose message window, clicks on the "Attach My Public Key"
+ * menu item.
+ */
+ async toggleMessageKeyAttachment(win) {
+ return clickToolbarButtonMenuItem(win, "#button-attach", [
+ "#button-attachPopup_attachPublicKey",
+ ]);
+ },
+
+ /**
+ * Given a compose message window, clicks on the "Require Encryption"
+ * menu item.
+ */
+ async toggleMessageEncryption(win) {
+ // Note: doing it through #menu_securityEncryptRequire_Menubar won't work on
+ // mac since the native menu bar can't be clicked.
+ // Use the toolbar button to click Require encryption.
+ await clickToolbarButtonMenuItem(win, "#button-encryption-options", [
+ "#menu_securityEncrypt_Toolbar",
+ ]);
+ },
+
+ /**
+ * For xpcshell-tests OpenPGP is not initialized automatically. This method
+ * should be called at the start of testing.
+ */
+ async initOpenPGP() {
+ Assert.ok(await lazy.RNP.init(), "librnp did load");
+ Assert.ok(await lazy.EnigmailCore.getService({}), "EnigmailCore did load");
+ lazy.EnigmailKeyRing.init();
+ await lazy.OpenPGPAlias.load();
+ },
+
+ /**
+ * Tests whether the signed icon's "src" attribute matches the provided state.
+ *
+ * @param {HTMLDocument} doc - The document of the message window.
+ * @param {"ok"|"unknown"|"verified"|"unverified"|"mismatch"} state - The
+ * state to test for.
+ * @returns {boolean}
+ */
+ hasSignedIconState(doc, state) {
+ return !!doc.querySelector(`#signedHdrIcon[src*=message-signed-${state}]`);
+ },
+
+ /**
+ * Checks that the signed icon is hidden.
+ *
+ * @param {HTMLDocument} doc - The document of the message window.
+ * @returns {boolean}
+ */
+ hasNoSignedIconState(doc) {
+ return !!doc.querySelector(`#signedHdrIcon[hidden]`);
+ },
+
+ /**
+ * Checks that the encrypted icon is hidden.
+ *
+ * @param {HTMLDocument} doc - The document of the message window.
+ * @returns {boolean}
+ */
+ hasNoEncryptedIconState(doc) {
+ return !!doc.querySelector(`#encryptedHdrIcon[hidden]`);
+ },
+
+ /**
+ * Tests whether the encrypted icon's "src" attribute matches the provided
+ * state value.
+ *
+ * @param {HTMLDocument} doc - The document of the message window.
+ * @param {"ok"|"notok"} state - The state to test for.
+ * @returns {boolean}
+ */
+ hasEncryptedIconState(doc, state) {
+ return !!doc.querySelector(
+ `#encryptedHdrIcon[src*=message-encrypted-${state}]`
+ );
+ },
+
+ /**
+ * Imports a public key into the keyring while also updating its acceptance.
+ *
+ * @param {nsIWindow} parent - The parent window.
+ * @param {nsIFile} file - A valid file containing a public OpenPGP key.
+ * @param {string} [acceptance] - The acceptance setting for the key.
+ * @returns {string[]} - List of imported key ids.
+ */
+ async importPublicKey(
+ parent,
+ file,
+ acceptance = OpenPGPTestUtils.ACCEPTANCE_VERIFIED
+ ) {
+ let ids = await OpenPGPTestUtils.importKey(parent, file, false);
+ if (!ids.length) {
+ throw new Error(`No public key imported from ${file.leafName}`);
+ }
+ return OpenPGPTestUtils.updateKeyIdAcceptance(ids, acceptance);
+ },
+
+ /**
+ * Imports a private key into the keyring while also updating its acceptance.
+ *
+ * @param {nsIWindow} parent - The parent window.
+ * @param {nsIFile} file - A valid file containing a private OpenPGP key.
+ * @param {string} [acceptance] - The acceptance setting for the key.
+ * @param {string} [passphrase] - The passphrase string that is required
+ * for unlocking the imported private key, or null, if no passphrase
+ * is necessary. The existing passphrase protection is kept.
+ * @param {boolean} [keepPassphrase] - true for keeping the existing
+ * passphrase. False for removing the existing passphrase and to
+ * set the automatic protection. If parameter passphrase is null
+ * then parameter keepPassphrase is ignored.
+ * @returns {string[]} - List of imported key ids.
+ */
+ async importPrivateKey(
+ parent,
+ file,
+ acceptance = OpenPGPTestUtils.ACCEPTANCE_PERSONAL,
+ passphrase = null,
+ keepPassphrase = false
+ ) {
+ let data = await IOUtils.read(file.path);
+ let pgpBlock = lazy.MailStringUtils.uint8ArrayToByteString(data);
+
+ function localPassphraseProvider(win, promptString, resultFlags) {
+ resultFlags.canceled = false;
+ return passphrase;
+ }
+
+ if (passphrase != null && keepPassphrase == undefined) {
+ throw new Error(
+ "must provide true of false for parameter keepPassphrase"
+ );
+ }
+
+ let result = await lazy.RNP.importSecKeyBlockImpl(
+ parent,
+ localPassphraseProvider,
+ passphrase != null && keepPassphrase,
+ pgpBlock,
+ false,
+ []
+ );
+
+ if (!result || result.exitCode !== 0) {
+ throw new Error(
+ `EnigmailKeyRing.importKey failed with result "${result.errorMsg}"!`
+ );
+ }
+ if (!result.importedKeys || !result.importedKeys.length) {
+ throw new Error(`No private key imported from ${file.leafName}`);
+ }
+
+ lazy.EnigmailKeyRing.updateKeys(result.importedKeys);
+ lazy.EnigmailKeyRing.clearCache();
+ return OpenPGPTestUtils.updateKeyIdAcceptance(
+ result.importedKeys.slice(),
+ acceptance
+ );
+ },
+
+ /**
+ * Imports a key into the keyring.
+ *
+ * @param {nsIWindow} parent - The parent window.
+ * @param {nsIFile} file - A valid file containing an OpenPGP key.
+ * @param {boolean} [isBinary] - false for ASCII armored files
+ * @returns {Promise<string[]>} - A list of ids for the key(s) imported.
+ */
+ async importKey(parent, file, isBinary) {
+ let data = await IOUtils.read(file.path);
+ let txt = lazy.MailStringUtils.uint8ArrayToByteString(data);
+ let errorObj = {};
+ let fingerPrintObj = {};
+
+ let result = lazy.EnigmailKeyRing.importKey(
+ parent,
+ false,
+ txt,
+ isBinary,
+ null,
+ errorObj,
+ fingerPrintObj,
+ false,
+ [],
+ false
+ );
+
+ if (result !== 0) {
+ console.debug(
+ `EnigmailKeyRing.importKey failed with result "${result}"!`
+ );
+ return [];
+ }
+ return fingerPrintObj.value.slice();
+ },
+
+ /**
+ * Updates the acceptance value of the provided key(s) in the database.
+ *
+ * @param {string|string[]} id - The id or list of ids to update.
+ * @param {string} acceptance - The new acceptance level for the key id.
+ * @returns {string[]} - A list of the key ids processed.
+ */
+ async updateKeyIdAcceptance(id, acceptance) {
+ let ids = Array.isArray(id) ? id : [id];
+ for (let id of ids) {
+ let key = lazy.EnigmailKeyRing.getKeyById(id);
+ let email = lazy.EnigmailFuncs.getEmailFromUserID(key.userId);
+ await lazy.PgpSqliteDb2.updateAcceptance(key.fpr, [email], acceptance);
+ }
+ lazy.EnigmailKeyRing.clearCache();
+ return ids.slice();
+ },
+
+ getProtectedKeysCount() {
+ return lazy.RNP.getProtectedKeysCount();
+ },
+
+ /**
+ * Removes a key by its id, clearing its acceptance and refreshing the
+ * cache.
+ *
+ * @param {string|string[]} id - The id or list of ids to remove.
+ * @param {boolean} [deleteSecret=false] - If true, secret keys will be removed too.
+ */
+ async removeKeyById(id, deleteSecret = false) {
+ let ids = Array.isArray(id) ? id : [id];
+ for (let id of ids) {
+ let key = lazy.EnigmailKeyRing.getKeyById(id);
+ await lazy.RNP.deleteKey(key.fpr, deleteSecret);
+ await lazy.PgpSqliteDb2.deleteAcceptance(key.fpr);
+ }
+ lazy.EnigmailKeyRing.clearCache();
+ },
+};
+
+/**
+ * Click a toolbar button to make it show the dropdown. Then click one of
+ * the menuitems in that popup.
+ */
+async function clickToolbarButtonMenuItem(
+ win,
+ buttonSelector,
+ menuitemSelectors
+) {
+ let menupopup = win.document.querySelector(`${buttonSelector} > menupopup`);
+ let popupshown = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ win.document.querySelector(`${buttonSelector} > dropmarker`),
+ {},
+ win
+ );
+ await popupshown;
+
+ if (menuitemSelectors.length > 1) {
+ let submenuSelector = menuitemSelectors.shift();
+ menupopup.querySelector(submenuSelector).openMenu(true);
+ }
+
+ let popuphidden = BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ menupopup.activateItem(win.document.querySelector(menuitemSelectors[0]));
+ await popuphidden;
+}
diff --git a/comm/mail/test/browser/shared-modules/PrefTabHelpers.jsm b/comm/mail/test/browser/shared-modules/PrefTabHelpers.jsm
new file mode 100644
index 0000000000..9f56fd2c25
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/PrefTabHelpers.jsm
@@ -0,0 +1,53 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/*
+ * Helpers to deal with the preferences tab.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["close_pref_tab", "open_pref_tab"];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var fdh = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var cth = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+
+/**
+ * Open the preferences tab with the given pane displayed. The pane needs to
+ * be one of the prefpane ids in mail/components/preferences/preferences.xhtml.
+ *
+ * @param aPaneID The ID of the pref pane to display (see
+ * mail/components/preferences/preferences.xhtml for valid IDs.)
+ */
+function open_pref_tab(aPaneID, aScrollTo) {
+ let tab = cth.open_content_tab_with_click(
+ function () {
+ fdh.mc.window.openOptionsDialog(aPaneID, aScrollTo);
+ },
+ "about:preferences",
+ fdh.mc,
+ "preferencesTab"
+ );
+ utils.waitFor(
+ () => tab.browser.contentWindow.gLastCategory.category == aPaneID,
+ "Timed out waiting for prefpane " + aPaneID + " to load."
+ );
+ return tab;
+}
+
+/**
+ * Close the preferences tab.
+ *
+ * @param aTab The content tab to close.
+ */
+function close_pref_tab(aTab) {
+ fdh.mc.window.document.getElementById("tabmail").closeTab(aTab);
+}
diff --git a/comm/mail/test/browser/shared-modules/PromptHelpers.jsm b/comm/mail/test/browser/shared-modules/PromptHelpers.jsm
new file mode 100644
index 0000000000..ca3eaac3b0
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/PromptHelpers.jsm
@@ -0,0 +1,271 @@
+/* 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 = [
+ "gMockPromptService",
+ "gMockAuthPromptReg",
+ "gMockAuthPrompt",
+];
+
+var { MockObjectReplacer } = ChromeUtils.import(
+ "resource://testing-common/mozmill/MockObjectHelpers.jsm"
+);
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var kMockPromptServiceName = "Mock Prompt Service";
+var kPromptServiceContractID = "@mozilla.org/prompter;1";
+var kPromptServiceName = "Prompt Service";
+
+var gMockAuthPromptReg = new MockObjectReplacer(
+ "@mozilla.org/prompter;1",
+ MockAuthPromptFactoryConstructor
+);
+
+function MockAuthPromptFactoryConstructor() {
+ return gMockAuthPromptFactory;
+}
+
+var gMockAuthPromptFactory = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptFactory"]),
+ getPrompt(aParent, aIID, aResult) {
+ return gMockAuthPrompt.QueryInterface(aIID);
+ },
+};
+
+var gMockAuthPrompt = {
+ password: "",
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAuthPrompt"]),
+
+ prompt(aTitle, aText, aRealm, aSave, aDefaultText) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ promptUsernameAndPassword(aTitle, aText, aRealm, aSave, aUser, aPwd) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ promptPassword(aTitle, aText, aRealm, aSave, aPwd) {
+ aPwd.value = this.password;
+ return true;
+ },
+};
+
+var gMockPromptService = {
+ _registered: false,
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ _will_return: null,
+ _inout_value: null,
+ _promptState: null,
+ _origFactory: null,
+ _promptCb: null,
+
+ alert(aParent, aDialogTitle, aText) {
+ this._promptState = {
+ method: "alert",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ };
+ },
+
+ confirm(aParent, aDialogTitle, aText) {
+ this._promptState = {
+ method: "confirm",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ };
+
+ this.fireCb();
+
+ return this._will_return;
+ },
+
+ confirmCheck(aParent, aDialogTitle, aText) {
+ this._promptState = {
+ method: "confirmCheck",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ };
+
+ this.fireCb();
+
+ return this._will_return;
+ },
+
+ confirmEx(
+ aParent,
+ aDialogTitle,
+ aText,
+ aButtonFlags,
+ aButton0Title,
+ aButton1Title,
+ aButton2Title,
+ aCheckMsg,
+ aCheckState
+ ) {
+ this._promptState = {
+ method: "confirmEx",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ buttonFlags: aButtonFlags,
+ button0Title: aButton0Title,
+ button1Title: aButton1Title,
+ button2Title: aButton2Title,
+ checkMsg: aCheckMsg,
+ checkState: aCheckState,
+ };
+
+ this.fireCb();
+
+ return this._will_return;
+ },
+
+ prompt(aParent, aDialogTitle, aText, aValue, aCheckMsg, aCheckState) {
+ this._promptState = {
+ method: "prompt",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ value: aValue,
+ checkMsg: aCheckMsg,
+ checkState: aCheckState,
+ };
+
+ this.fireCb();
+
+ if (this._inout_value != null) {
+ aValue.value = this._inout_value;
+ }
+
+ return this._will_return;
+ },
+
+ // Other dialogs should probably be mocked here, including alert,
+ // alertCheck, etc.
+ // See: http://mxr.mozilla.org/mozilla-central/source/embedding/components/
+ // windowwatcher/public/nsIPromptService.idl
+
+ /* Sets the value that the alert, confirm, etc dialog will return to
+ * the caller.
+ */
+ set returnValue(aReturn) {
+ this._will_return = aReturn;
+ },
+
+ set inoutValue(aValue) {
+ this._inout_value = aValue;
+ },
+
+ set onPromptCallback(aCb) {
+ this._promptCb = aCb;
+ },
+
+ promisePrompt() {
+ return new Promise(resolve => {
+ this.onPromptCallback = resolve;
+ });
+ },
+
+ fireCb() {
+ if (typeof this._promptCb == "function") {
+ this._promptCb.call();
+ }
+ },
+
+ /* Wipes out the prompt state and any return values.
+ */
+ reset() {
+ this._will_return = null;
+ this._promptState = null;
+ this._promptCb = null;
+ this._inout_value = null;
+ },
+
+ /* Returns the prompt state if one was observed since registering
+ * the Mock Prompt Service.
+ */
+ get promptState() {
+ return this._promptState;
+ },
+
+ CID: Components.ID("{404ebfa2-d8f4-4c94-8416-e65a55f9df5b}"),
+
+ get registrar() {
+ delete this.registrar;
+ return (this.registrar = Components.manager.QueryInterface(
+ Ci.nsIComponentRegistrar
+ ));
+ },
+
+ /* Registers the Mock Prompt Service, and stores the original Prompt Service.
+ */
+ register() {
+ if (!this.originalCID) {
+ void Components.manager.getClassObject(
+ Cc[kPromptServiceContractID],
+ Ci.nsIFactory
+ );
+
+ this.originalCID = this.registrar.contractIDToCID(
+ kPromptServiceContractID
+ );
+ this.registrar.registerFactory(
+ this.CID,
+ kMockPromptServiceName,
+ kPromptServiceContractID,
+ gMockPromptServiceFactory
+ );
+ this._resetServicesPrompt();
+ }
+ },
+
+ /* Unregisters the Mock Prompt Service, and re-registers the original
+ * Prompt Service.
+ */
+ unregister() {
+ if (this.originalCID) {
+ // Unregister the mock.
+ this.registrar.unregisterFactory(this.CID, gMockPromptServiceFactory);
+
+ this.registrar.registerFactory(
+ this.originalCID,
+ kPromptServiceName,
+ kPromptServiceContractID,
+ null
+ );
+
+ delete this.originalCID;
+ this._resetServicesPrompt();
+ }
+ },
+
+ _resetServicesPrompt() {
+ // eslint-disable-next-line mozilla/use-services
+ XPCOMUtils.defineLazyServiceGetter(
+ Services,
+ "prompt",
+ kPromptServiceContractID,
+ "nsIPromptService"
+ );
+ },
+};
+
+var gMockPromptServiceFactory = {
+ createInstance(aIID) {
+ if (!aIID.equals(Ci.nsIPromptService)) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ return gMockPromptService;
+ },
+};
diff --git a/comm/mail/test/browser/shared-modules/QuickFilterBarHelpers.jsm b/comm/mail/test/browser/shared-modules/QuickFilterBarHelpers.jsm
new file mode 100644
index 0000000000..04dee48e09
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/QuickFilterBarHelpers.jsm
@@ -0,0 +1,391 @@
+/* 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 = [
+ "assert_quick_filter_button_enabled",
+ "assert_quick_filter_bar_visible",
+ "toggle_quick_filter_bar",
+ "assert_constraints_expressed",
+ "toggle_boolean_constraints",
+ "toggle_tag_constraints",
+ "toggle_tag_mode",
+ "assert_tag_constraints_visible",
+ "assert_tag_constraints_checked",
+ "toggle_text_constraints",
+ "assert_text_constraints_checked",
+ "set_filter_text",
+ "assert_filter_text",
+ "assert_results_label_count",
+ "clear_constraints",
+ "cleanup_qfb_button",
+];
+
+var { get_about_3pane, mc, wait_for_all_messages_to_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+
+const { getState, storeState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+
+const { getDefaultItemIdsForSpace } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+
+let about3Pane = get_about_3pane();
+about3Pane.quickFilterBar.deferredUpdateSearch =
+ about3Pane.quickFilterBar.updateSearch;
+
+/**
+ * Maps names to bar DOM ids to simplify checking.
+ */
+var nameToBarDomId = {
+ sticky: "qfb-sticky",
+ unread: "qfb-unread",
+ starred: "qfb-starred",
+ addrbook: "qfb-inaddrbook",
+ tags: "qfb-tags",
+ attachments: "qfb-attachment",
+};
+
+async function ensure_qfb_unified_toolbar_button() {
+ const document = mc.window.document;
+
+ const state = getState();
+ if (state.mail?.includes("quick-filter-bar")) {
+ return;
+ }
+ if (!state.mail) {
+ state.mail = getDefaultItemIdsForSpace("mail");
+ if (state.mail.includes("quick-filter-bar")) {
+ return;
+ }
+ }
+ state.mail.push("quick-filter-bar");
+ storeState(state);
+ await BrowserTestUtils.waitForMutationCondition(
+ document.getElementById("unifiedToolbarContent"),
+ {
+ subtree: true,
+ childList: true,
+ },
+ () =>
+ document.querySelector("#unifiedToolbarContent .quick-filter-bar button")
+ );
+}
+
+async function cleanup_qfb_button() {
+ const document = mc.window.document;
+ const state = getState();
+ if (!state.mail?.includes("quick-filter-bar")) {
+ return;
+ }
+ state.mail = getDefaultItemIdsForSpace("mail");
+ storeState(state);
+ await BrowserTestUtils.waitForMutationCondition(
+ document.getElementById("unifiedToolbarContent"),
+ {
+ subtree: true,
+ childList: true,
+ },
+ () => !document.querySelector("#unifiedToolbarContent .quick-filter-bar")
+ );
+}
+
+async function assert_quick_filter_button_enabled(aEnabled) {
+ await ensure_qfb_unified_toolbar_button();
+ if (
+ mc.window.document.querySelector(
+ "#unifiedToolbarContent .quick-filter-bar button"
+ ).disabled == aEnabled
+ ) {
+ throw new Error(
+ "Quick filter bar button should be " + (aEnabled ? "enabled" : "disabled")
+ );
+ }
+}
+
+function assert_quick_filter_bar_visible(aVisible) {
+ let bar = about3Pane.document.getElementById("quick-filter-bar");
+ if (aVisible) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(bar),
+ "Quick filter bar should be visible"
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(bar),
+ "Quick filter bar should be hidden"
+ );
+ }
+}
+
+/**
+ * Toggle the state of the message filter bar as if by a mouse click.
+ */
+async function toggle_quick_filter_bar() {
+ await ensure_qfb_unified_toolbar_button();
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.querySelector(
+ "#unifiedToolbarContent .quick-filter-bar"
+ ),
+ { clickCount: 1 },
+ mc.window
+ );
+ wait_for_all_messages_to_load();
+}
+
+/**
+ * Assert that the state of the constraints visually expressed by the bar is
+ * consistent with the passed-in constraints. This method does not verify
+ * that the search constraints are in effect. Check that elsewhere.
+ */
+function assert_constraints_expressed(aConstraints) {
+ for (let name in nameToBarDomId) {
+ let domId = nameToBarDomId[name];
+ let expectedValue = name in aConstraints ? aConstraints[name] : false;
+ let domNode = about3Pane.document.getElementById(domId);
+ Assert.equal(
+ domNode.pressed,
+ expectedValue,
+ name + "'s pressed state should be " + expectedValue
+ );
+ }
+}
+
+/**
+ * Toggle the given filter buttons by name (from nameToBarDomId); variable
+ * argument magic enabled.
+ */
+function toggle_boolean_constraints(...aArgs) {
+ aArgs.forEach(arg =>
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.document.getElementById(nameToBarDomId[arg]),
+ { clickCount: 1 },
+ about3Pane
+ )
+ );
+ wait_for_all_messages_to_load(mc);
+}
+
+/**
+ * Toggle the tag faceting buttons by tag key. Wait for messages after.
+ */
+function toggle_tag_constraints(...aArgs) {
+ aArgs.forEach(function (arg) {
+ let tagId = "qfb-tag-" + arg;
+ let button = about3Pane.document.getElementById(tagId);
+ button.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, about3Pane);
+ });
+ wait_for_all_messages_to_load(mc);
+}
+
+/**
+ * Set the tag filtering mode. Wait for messages after.
+ */
+function toggle_tag_mode() {
+ let qbm = about3Pane.document.getElementById("qfb-boolean-mode");
+ if (qbm.value === "AND") {
+ qbm.selectedIndex--; // = move to "OR";
+ Assert.equal(qbm.value, "OR", "qfb-boolean-mode has wrong state");
+ } else if (qbm.value === "OR") {
+ qbm.selectedIndex++; // = move to "AND";
+ Assert.equal(qbm.value, "AND", "qfb-boolean-mode has wrong state");
+ } else {
+ throw new Error("qfb-boolean-mode value=" + qbm.value);
+ }
+ wait_for_all_messages_to_load(mc);
+}
+
+/**
+ * Verify that tag buttons exist for exactly the given set of tag keys in the
+ * provided variable argument list. Ordering is significant.
+ */
+function assert_tag_constraints_visible(...aArgs) {
+ // the stupid bar should be visible if any arguments are specified
+ let tagBar = get_about_3pane().document.getElementById(
+ "quickFilterBarTagsContainer"
+ );
+ if (aArgs.length > 0) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(tagBar),
+ "The tag bar should not be collapsed!"
+ );
+ }
+
+ let kids = tagBar.children;
+ let tagLength = kids.length - 1; // -1 for the qfb-boolean-mode widget
+ // this is bad error reporting in here for now.
+ if (tagLength != aArgs.length) {
+ throw new Error(
+ "Mismatch in expected tag count and actual. " +
+ "Expected " +
+ aArgs.length +
+ " actual " +
+ tagLength
+ );
+ }
+ for (let iArg = 0; iArg < aArgs.length; iArg++) {
+ let nodeId = "qfb-tag-" + aArgs[iArg];
+ if (nodeId != kids[iArg + 1].id) {
+ throw new Error(
+ "Mismatch at tag " +
+ iArg +
+ " expected " +
+ nodeId +
+ " but got " +
+ kids[iArg + 1].id
+ );
+ }
+ }
+}
+
+/**
+ * Verify that only the buttons corresponding to the provided tag keys are
+ * checked.
+ */
+function assert_tag_constraints_checked(...aArgs) {
+ let expected = {};
+ for (let arg of aArgs) {
+ let nodeId = "qfb-tag-" + arg;
+ expected[nodeId] = true;
+ }
+
+ let kids = mc.window.document.getElementById(
+ "quickFilterBarTagsContainer"
+ ).children;
+ for (let iNode = 0; iNode < kids.length; iNode++) {
+ let node = kids[iNode];
+ if (node.pressed != node.id in expected) {
+ throw new Error(
+ "node " +
+ node.id +
+ " should " +
+ (node.id in expected ? "be " : "not be ") +
+ "checked."
+ );
+ }
+ }
+}
+
+var nameToTextDomId = {
+ sender: "qfb-qs-sender",
+ recipients: "qfb-qs-recipients",
+ subject: "qfb-qs-subject",
+ body: "qfb-qs-body",
+};
+
+function toggle_text_constraints(...aArgs) {
+ aArgs.forEach(arg =>
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.document.getElementById(nameToTextDomId[arg]),
+ { clickCount: 1 },
+ about3Pane
+ )
+ );
+ wait_for_all_messages_to_load(mc);
+}
+
+/**
+ * Assert that the text constraint buttons are checked. Variable-argument
+ * support where the arguments are one of sender/recipients/subject/body.
+ */
+function assert_text_constraints_checked(...aArgs) {
+ let expected = {};
+ for (let arg of aArgs) {
+ let nodeId = nameToTextDomId[arg];
+ expected[nodeId] = true;
+ }
+
+ let kids = about3Pane.document.querySelectorAll(
+ "#quick-filter-bar-filter-text-bar button"
+ );
+ for (let iNode = 0; iNode < kids.length; iNode++) {
+ let node = kids[iNode];
+ if (node.tagName == "label") {
+ continue;
+ }
+ if (node.pressed != node.id in expected) {
+ throw new Error(
+ "node " +
+ node.id +
+ " should " +
+ (node.id in expected ? "be " : "not be ") +
+ "checked."
+ );
+ }
+ }
+}
+
+/**
+ * Set the text in the text filter box, trigger it like enter was pressed, then
+ * wait for all messages to load.
+ */
+function set_filter_text(aText) {
+ // We're not testing the reliability of the textbox widget; just poke our text
+ // in and trigger the command logic.
+ let textbox = about3Pane.document.getElementById("qfb-qs-textbox");
+ textbox.value = aText;
+ textbox.doCommand();
+ wait_for_all_messages_to_load(mc);
+}
+
+function assert_filter_text(aText) {
+ let textbox = get_about_3pane().document.getElementById("qfb-qs-textbox");
+ if (textbox.value != aText) {
+ throw new Error(
+ "Expected text filter value of '" +
+ aText +
+ "' but got '" +
+ textbox.value +
+ "'"
+ );
+ }
+}
+
+/**
+ * Assert that the results label is telling us there are aCount messages
+ * using the appropriate string.
+ */
+function assert_results_label_count(aCount) {
+ let resultsLabel = about3Pane.document.getElementById("qfb-results-label");
+ let attributes = about3Pane.document.l10n.getAttributes(resultsLabel);
+ if (aCount == 0) {
+ Assert.deepEqual(
+ attributes,
+ { id: "quick-filter-bar-no-results", args: null },
+ "results label should be displaying the no messages case"
+ );
+ } else {
+ Assert.deepEqual(
+ attributes,
+ { id: "quick-filter-bar-results", args: { count: aCount } },
+ `result count should show ${aCount}`
+ );
+ }
+}
+
+/**
+ * Clear active constraints via any means necessary; state cleanup for testing,
+ * not to be used as part of a test. Unlike normal clearing, this will kill
+ * the sticky bit.
+ *
+ * This is automatically called by the test teardown helper.
+ */
+function clear_constraints() {
+ about3Pane.quickFilterBar._testHelperResetFilterState();
+}
diff --git a/comm/mail/test/browser/shared-modules/SearchWindowHelpers.jsm b/comm/mail/test/browser/shared-modules/SearchWindowHelpers.jsm
new file mode 100644
index 0000000000..2eea0e17e7
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/SearchWindowHelpers.jsm
@@ -0,0 +1,206 @@
+/* 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 = [
+ "assert_messages_in_search_view",
+ "assert_search_window_folder_displayed",
+ "close_search_window",
+ "open_search_window",
+ "open_search_window_from_context_menu",
+ "select_click_search_row",
+ "select_shift_click_search_row",
+];
+
+var { get_about_3pane, mc, right_click_on_folder } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var windowHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+/**
+ * Open a search window using the accel-shift-f shortcut.
+ *
+ * @returns the controller for the search window
+ */
+function open_search_window() {
+ windowHelper.plan_for_new_window("mailnews:search");
+ EventUtils.synthesizeKey("f", { shiftKey: true, accelKey: true }, mc.window);
+ return windowHelper.wait_for_new_window("mailnews:search");
+}
+
+/**
+ * Open a search window as if from the context menu. This needs the context menu
+ * to be already open.
+ *
+ * @param aFolder the folder to open the search window for
+ * @returns the controller for the search window
+ */
+async function open_search_window_from_context_menu(aFolder) {
+ let win = get_about_3pane();
+ let context = win.document.getElementById("folderPaneContext");
+ let item = win.document.getElementById("folderPaneContext-searchMessages");
+ await right_click_on_folder(aFolder);
+
+ windowHelper.plan_for_new_window("mailnews:search");
+ context.activateItem(item);
+ return windowHelper.wait_for_new_window("mailnews:search");
+}
+
+/**
+ * Close a search window by calling window.close() on the controller.
+ */
+function close_search_window(aController) {
+ windowHelper.close_window(aController);
+}
+
+/**
+ * Assert that the given folder is selected in the search window corresponding
+ * to the given controller.
+ */
+function assert_search_window_folder_displayed(aController, aFolder) {
+ let currentFolder = aController.window.gCurrentFolder;
+ Assert.equal(
+ currentFolder,
+ aFolder,
+ "The search window's selected folder should have been: " +
+ aFolder.prettyName +
+ ", but is actually: " +
+ currentFolder?.prettyName
+ );
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse.
+ *
+ * @param {number} aViewIndex - The view index to click.
+ * @param {MozMillController} aController - The controller in whose context to
+ * do this.
+ * @returns {nsIMsgDBHdr} The message header selected.
+ */
+function select_click_search_row(aViewIndex, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ let tree = aController.window.document.getElementById("threadTree");
+ tree.scrollToRow(aViewIndex);
+ let coords = tree.getCoordsForCellItem(
+ aViewIndex,
+ tree.columns.subjectCol,
+ "cell"
+ );
+ let treeChildren = tree.lastElementChild;
+ EventUtils.synthesizeMouse(
+ treeChildren,
+ coords.x + coords.width / 2,
+ coords.y + coords.height / 2,
+ {},
+ aController.window
+ );
+
+ return aController.window.gFolderDisplay.view.dbView.getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse with the shift key pressed,
+ * adding all the messages between the shift pivot and the shift selected row.
+ *
+ * @param {number} aViewIndex - The view index to click.
+ * @param {MozMillController} aController The controller in whose context to
+ * do this.
+ * @returns {nsIMsgDBHdr[]} The message headers for all messages that are now
+ * selected.
+ */
+function select_shift_click_search_row(aViewIndex, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ let tree = aController.window.document.getElementById("threadTree");
+ tree.scrollToRow(aViewIndex);
+ let coords = tree.getCoordsForCellItem(
+ aViewIndex,
+ tree.columns.subjectCol,
+ "cell"
+ );
+ let treeChildren = tree.lastElementChild;
+ EventUtils.synthesizeMouse(
+ treeChildren,
+ coords.x + coords.width / 2,
+ coords.y + coords.height / 2,
+ { shiftKey: true },
+ aController.window
+ );
+
+ utils.sleep(0);
+ return aController.window.gFolderDisplay.selectedMessages;
+}
+
+/**
+ * Assert that the given synthetic message sets are present in the folder
+ * display.
+ *
+ * Verify that the messages in the provided SyntheticMessageSets are the only
+ * visible messages in the provided DBViewWrapper.
+ *
+ * @param {SyntheticMessageSet} aSynSets - Either a single SyntheticMessageSet
+ * or a list of them.
+ * @param {MozMillController} aController - The controller which we get the
+ * folderDisplay property from.
+ */
+function assert_messages_in_search_view(aSynSets, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ if (!Array.isArray(aSynSets)) {
+ aSynSets = [aSynSets];
+ }
+
+ // Iterate over all the message sets, retrieving the message header. Use
+ // this to construct a URI to populate a dictionary mapping.
+ let synMessageURIs = {}; // map URI to message header
+ for (let messageSet of aSynSets) {
+ for (let msgHdr of messageSet.msgHdrs()) {
+ synMessageURIs[msgHdr.folder.getUriForMsg(msgHdr)] = msgHdr;
+ }
+ }
+
+ // Iterate over the contents of the view, nulling out values in
+ // synMessageURIs for found messages, and exploding for missing ones.
+ let dbView = aController.window.gFolderDisplay.view.dbView;
+ let treeView = aController.window.gFolderDisplay.view.dbView.QueryInterface(
+ Ci.nsITreeView
+ );
+ let rowCount = treeView.rowCount;
+
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let msgHdr = dbView.getMsgHdrAt(iViewIndex);
+ let uri = msgHdr.folder.getUriForMsg(msgHdr);
+ Assert.ok(
+ uri in synMessageURIs,
+ "The view should show the message header" + msgHdr.messageKey
+ );
+ delete synMessageURIs[uri];
+ }
+
+ // Iterate over our URI set and make sure every message was shown.
+ for (let uri in synMessageURIs) {
+ let msgHdr = synMessageURIs[uri];
+ Assert.ok(
+ false,
+ "The view is should include the message header" + msgHdr.messageKey
+ );
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/SubscribeWindowHelpers.jsm b/comm/mail/test/browser/shared-modules/SubscribeWindowHelpers.jsm
new file mode 100644
index 0000000000..2f096479f0
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/SubscribeWindowHelpers.jsm
@@ -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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "open_subscribe_window_from_context_menu",
+ "enter_text_in_search_box",
+ "check_newsgroup_displayed",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { get_about_3pane, right_click_on_folder } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { input_value, delete_all_existing } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+var { click_menus_in_sequence, plan_for_modal_dialog, wait_for_modal_dialog } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+/**
+ * Open a subscribe dialog from the context menu.
+ *
+ * @param aFolder the folder to open the subscribe dialog for
+ * @param aFunction Callback that will be invoked with a controller
+ * for the subscribe dialogue as parameter
+ */
+async function open_subscribe_window_from_context_menu(aFolder, aFunction) {
+ let win = get_about_3pane();
+
+ await right_click_on_folder(aFolder);
+ let callback = function (controller) {
+ // When the "stop button" is disabled, the panel is populated.
+ utils.waitFor(
+ () => controller.window.document.getElementById("stopButton").disabled
+ );
+ aFunction(controller);
+ };
+ plan_for_modal_dialog("mailnews:subscribe", callback);
+ await click_menus_in_sequence(
+ win.document.getElementById("folderPaneContext"),
+ [{ id: "folderPaneContext-subscribe" }]
+ );
+ wait_for_modal_dialog("mailnews:subscribe");
+}
+
+/**
+ * Enter a string in the text box for the search value.
+ *
+ * @param swc A controller for a subscribe dialog
+ * @param text The text to enter
+ */
+function enter_text_in_search_box(swc, text) {
+ let textbox = swc.window.document.getElementById("namefield");
+ delete_all_existing(swc, textbox);
+ input_value(swc, text, textbox);
+}
+
+/**
+ * Check whether the given newsgroup is in the searchview.
+ *
+ * @param swc A controller for the subscribe window
+ * @param name Name of the newsgroup
+ * @returns {boolean} Result of the check
+ */
+function check_newsgroup_displayed(swc, name) {
+ let tree = swc.window.document.getElementById("searchTree");
+ if (!tree.columns) {
+ // Maybe not yet available.
+ return false;
+ }
+ let treeview = tree.view;
+ let nameCol = tree.columns.getNamedColumn("nameColumn2");
+ for (let i = 0; i < treeview.rowCount; i++) {
+ if (treeview.getCellText(i, nameCol) == name) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/comm/mail/test/browser/shared-modules/ViewHelpers.jsm b/comm/mail/test/browser/shared-modules/ViewHelpers.jsm
new file mode 100644
index 0000000000..33d39a8e07
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/ViewHelpers.jsm
@@ -0,0 +1,85 @@
+/* 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/. */
+
+/** Module to help debugging view wrapper issues. */
+
+const EXPORTED_SYMBOLS = ["dump_view_contents", "dump_view_state"];
+
+function dump_view_state(aViewWrapper, aDoNotDumpContents) {
+ if (aViewWrapper.dbView == null) {
+ dump("no nsIMsgDBView instance!\n");
+ return;
+ }
+ if (!aDoNotDumpContents) {
+ dump_view_contents(aViewWrapper);
+ }
+ dump("View: " + aViewWrapper.dbView + "\n");
+ dump(
+ " View Type: " +
+ _lookupValueNameInInterface(
+ aViewWrapper.dbView.viewType,
+ Ci.nsMsgViewType
+ ) +
+ " " +
+ "View Flags: " +
+ aViewWrapper.dbView.viewFlags +
+ "\n"
+ );
+ dump(
+ " Sort Type: " +
+ _lookupValueNameInInterface(
+ aViewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType
+ ) +
+ " " +
+ "Sort Order: " +
+ _lookupValueNameInInterface(
+ aViewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder
+ ) +
+ "\n"
+ );
+ dump(aViewWrapper.search.prettyString());
+}
+
+var WHITESPACE = " ";
+var MSG_VIEW_FLAG_DUMMY = 0x20000000;
+function dump_view_contents(aViewWrapper) {
+ let dbView = aViewWrapper.dbView;
+ let treeView = aViewWrapper.dbView.QueryInterface(Ci.nsITreeView);
+ let rowCount = treeView.rowCount;
+
+ dump("********* Current View Contents\n");
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let level = treeView.getLevel(iViewIndex);
+ let flags = dbView.getFlagsAt(iViewIndex);
+ let msgHdr = dbView.getMsgHdrAt(iViewIndex);
+
+ let s = WHITESPACE.substr(0, level * 2);
+ if (treeView.isContainer(iViewIndex)) {
+ s += treeView.isContainerOpen(iViewIndex) ? "- " : "+ ";
+ } else {
+ s += ". ";
+ }
+ // s += treeView.getCellText(iViewIndex, )
+ if (flags & MSG_VIEW_FLAG_DUMMY) {
+ s += "dummy: ";
+ }
+ s += dbView.cellTextForColumn(iViewIndex, "subject");
+ s += " [" + msgHdr.folder.prettyName + "," + msgHdr.messageKey + "]";
+
+ dump(s + "\n");
+ }
+ dump("********* end view contents\n");
+}
+
+function _lookupValueNameInInterface(aValue, aInterface) {
+ for (let key in aInterface) {
+ let value = aInterface[key];
+ if (value == aValue) {
+ return key;
+ }
+ }
+ return "unknown: " + aValue;
+}
diff --git a/comm/mail/test/browser/shared-modules/WindowHelpers.jsm b/comm/mail/test/browser/shared-modules/WindowHelpers.jsm
new file mode 100644
index 0000000000..9a8d16fbae
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/WindowHelpers.jsm
@@ -0,0 +1,1018 @@
+/* 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 = [
+ "click_menus_in_sequence",
+ "close_popup_sequence",
+ "click_through_appmenu",
+ "plan_for_new_window",
+ "wait_for_new_window",
+ "async_plan_for_new_window",
+ "plan_for_modal_dialog",
+ "wait_for_modal_dialog",
+ "plan_for_window_close",
+ "wait_for_window_close",
+ "close_window",
+ "wait_for_existing_window",
+ "wait_for_window_focused",
+ "wait_for_browser_load",
+ "wait_for_frame_load",
+ "resize_to",
+];
+
+var controller = ChromeUtils.import(
+ "resource://testing-common/mozmill/controller.jsm"
+);
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+/**
+ * Timeout to use when waiting for the first window ever to load. This is
+ * long because we are basically waiting for the entire app startup process.
+ */
+var FIRST_WINDOW_EVER_TIMEOUT_MS = 30000;
+/**
+ * Interval to check if the window has shown up for the first window ever to
+ * load. The check interval is longer because it's less likely the window
+ * is going to show up quickly and there is a cost to the check.
+ */
+var FIRST_WINDOW_CHECK_INTERVAL_MS = 300;
+
+/**
+ * Timeout for opening a window.
+ */
+var WINDOW_OPEN_TIMEOUT_MS = 10000;
+/**
+ * Check interval for opening a window.
+ */
+var WINDOW_OPEN_CHECK_INTERVAL_MS = 100;
+
+/**
+ * Timeout for closing a window.
+ */
+var WINDOW_CLOSE_TIMEOUT_MS = 10000;
+/**
+ * Check interval for closing a window.
+ */
+var WINDOW_CLOSE_CHECK_INTERVAL_MS = 100;
+
+/**
+ * Timeout for focusing a window. Only really an issue on linux.
+ */
+var WINDOW_FOCUS_TIMEOUT_MS = 10000;
+
+function getWindowTypeOrId(aWindowElem) {
+ let windowType = aWindowElem.getAttribute("windowtype");
+ // Ignore types that start with "prompt:". This prefix gets added in
+ // toolkit/components/prompts/src/CommonDialog.jsm since bug 1388238.
+ if (windowType && !windowType.startsWith("prompt:")) {
+ return windowType;
+ }
+
+ return aWindowElem.getAttribute("id");
+}
+
+/**
+ * Return the "windowtype" or "id" for the given app window if it is available.
+ * If not, return null.
+ */
+function getWindowTypeForAppWindow(aAppWindow, aBusyOk) {
+ // Sometimes we are given HTML windows, for which the logic below will
+ // bail. So we use a fast-path here that should work for HTML and should
+ // maybe also work with XUL. I'm not going to go into it...
+ if (
+ aAppWindow.document &&
+ aAppWindow.document.documentElement &&
+ aAppWindow.document.documentElement.hasAttribute("windowtype")
+ ) {
+ return getWindowTypeOrId(aAppWindow.document.documentElement);
+ }
+
+ let docshell = aAppWindow.docShell;
+ // we need the docshell to exist...
+ if (!docshell) {
+ return null;
+ }
+
+ // we can't know if it's the right document until it's not busy
+ if (!aBusyOk && docshell.busyFlags) {
+ return null;
+ }
+
+ // it also needs to have content loaded (it starts out not busy with no
+ // content viewer.)
+ if (docshell.contentViewer == null) {
+ return null;
+ }
+
+ // now we're cooking! let's get the document...
+ let outerDoc = docshell.contentViewer.DOMDocument;
+ // and make sure it's not blank. that's also an intermediate state.
+ if (outerDoc.location.href == "about:blank") {
+ return null;
+ }
+
+ // finally, we can now have a windowtype!
+ let windowType = getWindowTypeOrId(outerDoc.documentElement);
+
+ if (windowType) {
+ return windowType;
+ }
+
+ // As a last resort, use the name given to the DOM window.
+ let domWindow = aAppWindow.docShell.domWindow;
+
+ return domWindow.name;
+}
+
+var WindowWatcher = {
+ _inited: false,
+ _firstWindowOpened: false,
+ ensureInited() {
+ if (this._inited) {
+ return;
+ }
+
+ // Add ourselves as an nsIWindowMediatorListener so we can here about when
+ // windows get registered with the window mediator. Because this
+ // generally happens
+ // Another possible means of getting this info would be to observe
+ // "xul-window-visible", but it provides no context and may still require
+ // polling anyways.
+ Services.wm.addListener(this);
+
+ // Clean up any references to windows at the end of each test, and clean
+ // up the listeners/observers as the end of the session.
+ let observer = {
+ observe(subject, topic) {
+ WindowWatcher.monitoringList.length = 0;
+ WindowWatcher.waitingList.clear();
+ if (topic == "quit-application-granted") {
+ Services.wm.removeListener(this);
+ Services.obs.removeObserver(this, "test-complete");
+ Services.obs.removeObserver(this, "quit-application-granted");
+ }
+ },
+ };
+ Services.obs.addObserver(observer, "test-complete");
+ Services.obs.addObserver(observer, "quit-application-granted");
+
+ this._inited = true;
+ },
+
+ /**
+ * Track the windowtypes we are waiting on. Keys are windowtypes. When
+ * watching for new windows, values are initially null, and are set to an
+ * nsIAppWindow when we actually find the window. When watching for closing
+ * windows, values are nsIAppWindows. This symmetry lets us have windows
+ * that appear and dis-appear do so without dangerously confusing us (as
+ * long as another one comes along...)
+ */
+ waitingList: new Map(),
+ /**
+ * Note that we will be looking for a window with the given window type
+ * (ex: "mailnews:search"). This allows us to be ready if an event shows
+ * up before waitForWindow is called.
+ */
+ planForWindowOpen(aWindowType) {
+ this.waitingList.set(aWindowType, null);
+ },
+
+ /**
+ * Like planForWindowOpen but we check for already-existing windows.
+ */
+ planForAlreadyOpenWindow(aWindowType) {
+ this.waitingList.set(aWindowType, null);
+ // We need to iterate over all the app windows and consider them all.
+ // We can't pass the window type because the window might not have a
+ // window type yet.
+ // because this iterates from old to new, this does the right thing in that
+ // side-effects of consider will pick the most recent window.
+ for (let appWindow of Services.wm.getAppWindowEnumerator(null)) {
+ if (!this.consider(appWindow)) {
+ this.monitoringList.push(appWindow);
+ }
+ }
+ },
+
+ /**
+ * The current windowType we are waiting to open. This is mainly a means of
+ * communicating the desired window type to monitorize without having to
+ * put the argument in the eval string.
+ */
+ waitingForOpen: null,
+ /**
+ * Wait for the given windowType to open and finish loading.
+ *
+ * @returns The window wrapped in a MozMillController.
+ */
+ waitForWindowOpen(aWindowType) {
+ this.waitingForOpen = aWindowType;
+ utils.waitFor(
+ () => this.monitorizeOpen(),
+ "Timed out waiting for window open!",
+ this._firstWindowOpened
+ ? WINDOW_OPEN_TIMEOUT_MS
+ : FIRST_WINDOW_EVER_TIMEOUT_MS,
+ this._firstWindowOpened
+ ? WINDOW_OPEN_CHECK_INTERVAL_MS
+ : FIRST_WINDOW_CHECK_INTERVAL_MS
+ );
+
+ this.waitingForOpen = null;
+ let appWindow = this.waitingList.get(aWindowType);
+ let domWindow = appWindow.docShell.domWindow;
+ this.waitingList.delete(aWindowType);
+ // spin the event loop to make sure any setTimeout 0 calls have gotten their
+ // time in the sun.
+ utils.sleep(0);
+ this._firstWindowOpened = true;
+ return new controller.MozMillController(domWindow);
+ },
+
+ /**
+ * Because the modal dialog spins its own event loop, the mozmill idiom of
+ * spinning your own event-loop as performed by waitFor is no good. We use
+ * this timer to generate our events so that we can have a waitFor
+ * equivalent.
+ *
+ * We only have one timer right now because modal dialogs that spawn modal
+ * dialogs are not tremendously likely.
+ */
+ _timer: null,
+ _timerRuntimeSoFar: 0,
+ /**
+ * The test function to run when the modal dialog opens.
+ */
+ subTestFunc: null,
+ planForModalDialog(aWindowType, aSubTestFunc) {
+ if (this._timer == null) {
+ this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ }
+ this.waitingForOpen = aWindowType;
+ this.subTestFunc = aSubTestFunc;
+ this.waitingList.set(aWindowType, null);
+
+ this._timerRuntimeSoFar = 0;
+ this._timer.initWithCallback(
+ this,
+ WINDOW_OPEN_CHECK_INTERVAL_MS,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ },
+
+ /**
+ * This is the nsITimer notification we receive...
+ */
+ notify() {
+ if (this.monitorizeOpen()) {
+ // okay, the window is opened, and we should be in its event loop now.
+ let appWindow = this.waitingList.get(this.waitingForOpen);
+ let domWindow = appWindow.docShell.domWindow;
+ let troller = new controller.MozMillController(domWindow);
+
+ this._timer.cancel();
+
+ let self = this;
+ async function startTest() {
+ self.planForWindowClose(troller.window);
+ try {
+ await self.subTestFunc(troller);
+ } finally {
+ self.subTestFunc = null;
+ }
+
+ // if the test failed, make sure we force the window closed...
+ // except I'm not sure how to easily figure that out...
+ // so just close it no matter what.
+ troller.window.close();
+ self.waitForWindowClose();
+
+ self.waitingList.delete(self.waitingForOpen);
+ // now we are waiting for it to close...
+ self.waitingForClose = self.waitingForOpen;
+ self.waitingForOpen = null;
+ }
+
+ let targetFocusedWindow = {};
+ Services.focus.getFocusedElementForWindow(
+ domWindow,
+ true,
+ targetFocusedWindow
+ );
+ targetFocusedWindow = targetFocusedWindow.value;
+
+ let focusedWindow = {};
+ if (Services.focus.activeWindow) {
+ Services.focus.getFocusedElementForWindow(
+ Services.focus.activeWindow,
+ true,
+ focusedWindow
+ );
+
+ focusedWindow = focusedWindow.value;
+ }
+
+ if (focusedWindow == targetFocusedWindow) {
+ startTest();
+ } else {
+ function onFocus(event) {
+ targetFocusedWindow.setTimeout(startTest, 0);
+ }
+ targetFocusedWindow.addEventListener("focus", onFocus, {
+ capture: true,
+ once: true,
+ });
+ targetFocusedWindow.focus();
+ }
+ }
+ // notify is only used for modal dialogs, which are never the first window,
+ // so we can always just use this set of timeouts/intervals.
+ this._timerRuntimeSoFar += WINDOW_OPEN_CHECK_INTERVAL_MS;
+ if (this._timerRuntimeSoFar >= WINDOW_OPEN_TIMEOUT_MS) {
+ this._timer.cancel();
+ throw new Error("Timeout while waiting for modal dialog.\n");
+ }
+ },
+
+ /**
+ * Symmetry for planForModalDialog; conceptually provides the waiting. In
+ * reality, all we do is potentially soak up the event loop a little to
+ */
+ waitForModalDialog(aWindowType, aTimeout) {
+ // did the window already come and go?
+ if (this.subTestFunc == null) {
+ return;
+ }
+ // spin the event loop until we the window has come and gone.
+ utils.waitFor(
+ () => {
+ return this.waitingForOpen == null && this.monitorizeClose();
+ },
+ "Timeout waiting for modal dialog to open.",
+ aTimeout || WINDOW_OPEN_TIMEOUT_MS,
+ WINDOW_OPEN_CHECK_INTERVAL_MS
+ );
+ this.waitingForClose = null;
+ },
+
+ planForWindowClose(aAppWindow) {
+ let windowType = getWindowTypeOrId(aAppWindow.document.documentElement);
+ this.waitingList.set(windowType, aAppWindow);
+ this.waitingForClose = windowType;
+ },
+
+ /**
+ * The current windowType we are waiting to close. Same deal as
+ * waitingForOpen, this makes the eval less crazy.
+ */
+ waitingForClose: null,
+ waitForWindowClose() {
+ utils.waitFor(
+ () => this.monitorizeClose(),
+ "Timeout waiting for window to close!",
+ WINDOW_CLOSE_TIMEOUT_MS,
+ WINDOW_CLOSE_CHECK_INTERVAL_MS
+ );
+ let didDisappear = this.waitingList.get(this.waitingForClose) == null;
+ let windowType = this.waitingForClose;
+ this.waitingList.delete(windowType);
+ this.waitingForClose = null;
+ if (!didDisappear) {
+ throw new Error(windowType + " window did not disappear!");
+ }
+ },
+
+ /**
+ * Used by waitForWindowOpen to check all of the windows we are monitoring and
+ * then check if we have any results.
+ *
+ * @returns true if we found what we were |waitingForOpen|, false otherwise.
+ */
+ monitorizeOpen() {
+ for (let iWin = this.monitoringList.length - 1; iWin >= 0; iWin--) {
+ let appWindow = this.monitoringList[iWin];
+ if (this.consider(appWindow)) {
+ this.monitoringList.splice(iWin, 1);
+ }
+ }
+
+ return (
+ this.waitingList.has(this.waitingForOpen) &&
+ this.waitingList.get(this.waitingForOpen) != null
+ );
+ },
+
+ /**
+ * Used by waitForWindowClose to check if the window we are waiting to close
+ * actually closed yet.
+ *
+ * @returns true if it closed.
+ */
+ monitorizeClose() {
+ return this.waitingList.get(this.waitingForClose) == null;
+ },
+
+ /**
+ * A list of app windows to monitor because they are loading and it's not yet
+ * possible to tell whether they are something we are looking for.
+ */
+ monitoringList: [],
+ /**
+ * Monitor the given window's loading process until we can determine whether
+ * it is what we are looking for.
+ */
+ monitorWindowLoad(aAppWindow) {
+ this.monitoringList.push(aAppWindow);
+ },
+
+ /**
+ * nsIWindowMediatorListener notification that a app window was opened. We
+ * check out the window, and if we were not able to fully consider it, we
+ * add it to our monitoring list.
+ */
+ onOpenWindow(aAppWindow) {
+ // note: we would love to add our window activation/deactivation listeners
+ // and poke our unique id, but there is no contentViewer at this point
+ // and so there's no place to poke our unique id. (aAppWindow does not
+ // let us put expandos on; it's an XPCWrappedNative and explodes.)
+ // There may be nuances about outer window/inner window that make it
+ // feasible, but I have forgotten any such nuances I once knew.
+ if (!this.consider(aAppWindow)) {
+ this.monitorWindowLoad(aAppWindow);
+ }
+ },
+
+ /**
+ * Consider if the given window is something in our |waitingList|.
+ *
+ * @returns true if we were able to fully consider the object, false if we were
+ * not and need to be called again on the window later. This has no
+ * relation to whether the window was one in our waitingList or not.
+ * Check the waitingList structure for that.
+ */
+ consider(aAppWindow) {
+ let windowType = getWindowTypeForAppWindow(aAppWindow);
+ if (windowType == null) {
+ return false;
+ }
+
+ // stash the window if we were watching for it
+ if (this.waitingList.has(windowType)) {
+ this.waitingList.set(windowType, aAppWindow);
+ }
+
+ return true;
+ },
+
+ /**
+ * Closing windows have the advantage of having to already have been loaded,
+ * so things like their windowtype are immediately available.
+ */
+ onCloseWindow(aAppWindow) {
+ let domWindow = aAppWindow.docShell.domWindow;
+ let windowType = getWindowTypeOrId(domWindow.document.documentElement);
+ if (this.waitingList.has(windowType)) {
+ this.waitingList.set(windowType, null);
+ }
+ },
+};
+
+/**
+ * Call this if the window you want to get may already be open. What we
+ * provide above just directly grabbing the window yourself is:
+ * - We wait for it to finish loading.
+ *
+ * @param aWindowType the window type that will be created. This is literally
+ * the value of the "windowtype" attribute on the window. The values tend
+ * to look like "app:windowname", for example "mailnews:search".
+ *
+ * @returns {MozmillController}
+ */
+function wait_for_existing_window(aWindowType) {
+ WindowWatcher.ensureInited();
+ WindowWatcher.planForAlreadyOpenWindow(aWindowType);
+ return WindowWatcher.waitForWindowOpen(aWindowType);
+}
+
+/**
+ * Call this just before you trigger the event that will cause a window to be
+ * displayed.
+ * In theory, we don't need this and could just do a sweep of existing windows
+ * when you call wait_for_new_window, or we could always just keep track of
+ * the most recently seen window of each type, but this is arguably more
+ * resilient in the face of multiple windows of the same type as long as you
+ * don't try and open them all at the same time.
+ *
+ * @param aWindowType the window type that will be created. This is literally
+ * the value of the "windowtype" attribute on the window. The values tend
+ * to look like "app:windowname", for example "mailnews:search".
+ */
+function plan_for_new_window(aWindowType) {
+ WindowWatcher.ensureInited();
+ WindowWatcher.planForWindowOpen(aWindowType);
+}
+
+/**
+ * Wait for the loading of the given window type to complete (that you
+ * previously told us about via |plan_for_new_window|), returning it wrapped
+ * in a MozmillController.
+ *
+ * @returns {MozmillController}
+ */
+function wait_for_new_window(aWindowType) {
+ let c = WindowWatcher.waitForWindowOpen(aWindowType);
+ // A nested event loop can get spun inside the Controller's constructor
+ // (which is arguably not a great idea), so it's important that we denote
+ // when we're actually leaving this function in case something crazy
+ // happens.
+ return c;
+}
+
+async function async_plan_for_new_window(aWindowType) {
+ let domWindow = await BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ return (
+ win.document.documentElement.getAttribute("windowtype") == aWindowType
+ );
+ });
+
+ await new Promise(r => domWindow.setTimeout(r));
+ await new Promise(r => domWindow.setTimeout(r));
+
+ let domWindowController = new controller.MozMillController(domWindow);
+ return domWindowController;
+}
+
+/**
+ * Plan for the imminent display of a modal dialog. Modal dialogs spin their
+ * own event loop which means that either that control flow will not return
+ * to the caller until the modal dialog finishes running. This means that
+ * you need to provide a sub-test function to be run inside the modal dialog
+ * (and it should not start with "test" or mozmill will also try and run it.)
+ *
+ * @param aWindowType The window type that you expect the modal dialog to have
+ * or the id of the window if there is no window type
+ * available.
+ * @param aSubTestFunction The sub-test function that will be run once the modal
+ * dialog appears and is loaded. This function should take one argument,
+ * a MozmillController against the modal dialog.
+ */
+function plan_for_modal_dialog(aWindowType, aSubTestFunction) {
+ WindowWatcher.ensureInited();
+ WindowWatcher.planForModalDialog(aWindowType, aSubTestFunction);
+}
+/**
+ * In case the dialog might be stuck for a long time, you can pass an optional
+ * timeout.
+ *
+ * @param aTimeout Your custom timeout (default is WINDOW_OPEN_TIMEOUT_MS)
+ */
+function wait_for_modal_dialog(aWindowType, aTimeout) {
+ WindowWatcher.waitForModalDialog(aWindowType, aTimeout);
+}
+
+/**
+ * Call this just before you trigger the event that will cause the provided
+ * controller's window to disappear. You then follow this with a call to
+ * |wait_for_window_close| when you want to block on verifying the close.
+ *
+ * @param aController The MozmillController, potentially returned from a call to
+ * wait_for_new_window, whose window should be disappearing.
+ */
+function plan_for_window_close(aController) {
+ WindowWatcher.ensureInited();
+ WindowWatcher.planForWindowClose(aController.window);
+}
+
+/**
+ * Wait for the closure of the window you noted you would listen for its close
+ * in plan_for_window_close.
+ */
+function wait_for_window_close() {
+ WindowWatcher.waitForWindowClose();
+}
+
+/**
+ * Close a window by calling window.close() on the controller.
+ *
+ * @param aController the controller whose window is to be closed.
+ */
+function close_window(aController) {
+ plan_for_window_close(aController);
+ aController.window.close();
+ wait_for_window_close();
+}
+
+/**
+ * Wait for the window to be focused.
+ *
+ * @param aWindow the window to be focused.
+ */
+function wait_for_window_focused(aWindow) {
+ let targetWindow = {};
+
+ Services.focus.getFocusedElementForWindow(aWindow, true, targetWindow);
+ targetWindow = targetWindow.value;
+
+ let focusedWindow = {};
+ if (Services.focus.activeWindow) {
+ Services.focus.getFocusedElementForWindow(
+ Services.focus.activeWindow,
+ true,
+ focusedWindow
+ );
+ focusedWindow = focusedWindow.value;
+ }
+
+ let focused = false;
+ if (focusedWindow == targetWindow) {
+ focused = true;
+ } else {
+ targetWindow.addEventListener("focus", () => (focused = true), {
+ capture: true,
+ once: true,
+ });
+ targetWindow.focus();
+ }
+
+ utils.waitFor(
+ () => focused,
+ "Timeout waiting for window to be focused.",
+ WINDOW_FOCUS_TIMEOUT_MS,
+ 100,
+ this
+ );
+}
+
+/**
+ * Given a <browser>, waits for it to completely load.
+ *
+ * @param aBrowser The <browser> element to wait for.
+ * @param aURLOrPredicate The URL that should be loaded (string) or a predicate
+ * for the URL (function).
+ * @returns The browser's content window wrapped in a MozMillController.
+ */
+function wait_for_browser_load(aBrowser, aURLOrPredicate) {
+ // aBrowser has all the fields we need already.
+ return _wait_for_generic_load(aBrowser, aURLOrPredicate);
+}
+
+/**
+ * Given an HTML <frame> or <iframe>, waits for it to completely load.
+ *
+ * @param aFrame The element to wait for.
+ * @param aURLOrPredicate The URL that should be loaded (string) or a predicate
+ * for the URL (function).
+ * @returns The frame wrapped in a MozMillController.
+ */
+function wait_for_frame_load(aFrame, aURLOrPredicate) {
+ return _wait_for_generic_load(aFrame, aURLOrPredicate);
+}
+
+/**
+ * Generic function to wait for some sort of document to load. We expect
+ * aDetails to have three fields:
+ * - webProgress: an nsIWebProgress associated with the contentWindow.
+ * - currentURI: the currently loaded page (nsIURI).
+ */
+function _wait_for_generic_load(aDetails, aURLOrPredicate) {
+ let predicate;
+ if (typeof aURLOrPredicate == "string") {
+ let expectedURL = NetUtil.newURI(aURLOrPredicate);
+ predicate = url => expectedURL.equals(url);
+ } else {
+ predicate = aURLOrPredicate;
+ }
+
+ function isLoadedChecker() {
+ if (aDetails.webProgress?.isLoadingDocument) {
+ return false;
+ }
+ if (
+ aDetails.contentDocument &&
+ aDetails.contentDocument.readyState != "complete"
+ ) {
+ return false;
+ }
+
+ return predicate(
+ aDetails.currentURI ||
+ NetUtil.newURI(aDetails.contentWindow.location.href)
+ );
+ }
+
+ try {
+ utils.waitFor(isLoadedChecker);
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Timeout waiting for content page to load. Current URL is: ${aDetails.currentURI.spec}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ // Lie to mozmill to convince it to not explode because these frames never
+ // get a mozmillDocumentLoaded attribute (bug 666438).
+ let contentWindow = aDetails.contentWindow;
+ if (contentWindow) {
+ return new controller.MozMillController(contentWindow);
+ }
+ return null;
+}
+
+/**
+ * Resize given window to new dimensions.
+ *
+ * @param aController window controller
+ * @param aWidth the requested window width
+ * @param aHeight the requested window height
+ */
+function resize_to(aController, aWidth, aHeight) {
+ aController.window.resizeTo(aWidth, aHeight);
+ // Give the event loop a spin in order to let the reality of an asynchronously
+ // interacting window manager have its impact. This still may not be
+ // sufficient.
+ utils.sleep(0);
+ utils.waitFor(
+ () =>
+ aController.window.outerWidth == aWidth &&
+ aController.window.outerHeight == aHeight,
+ "Timeout waiting for resize (current screen size: " +
+ aController.window.screen.availWidth +
+ "X" +
+ aController.window.screen.availHeight +
+ "), Requested width " +
+ aWidth +
+ " but got " +
+ aController.window.outerWidth +
+ ", Request height " +
+ aHeight +
+ " but got " +
+ aController.window.outerHeight,
+ 10000,
+ 50
+ );
+}
+
+/**
+ * Dynamically-built/XBL-defined menus can be hard to work with, this makes it
+ * easier.
+ *
+ * @param aRootPopup The base popup. The caller is expected to activate it
+ * (by clicking/rightclicking the right widget). We will only wait for it
+ * to open if it is in the process.
+ * @param aActions An array of objects where each object has attributes
+ * with a value defined. We pick the menu item whose DOM node matches
+ * all the attributes with the specified names and value. We click whatever
+ * we find. We throw if the element being asked for is not found.
+ * @param aKeepOpen If set to true the popups are not closed after last click.
+ *
+ * @returns An array of popup elements that were left open. It will be
+ * an empty array if aKeepOpen was set to false.
+ */
+async function click_menus_in_sequence(aRootPopup, aActions, aKeepOpen) {
+ if (aRootPopup.state != "open") {
+ await BrowserTestUtils.waitForEvent(aRootPopup, "popupshown");
+ }
+
+ /**
+ * Check if a node's attributes match all those given in actionObj.
+ * Nodes that are obvious containers are skipped, and their children
+ * will be used to recursively find a match instead.
+ *
+ * @param {Element} node - The node to check.
+ * @param {object} actionObj - Contains attribute-value pairs to match.
+ * @returns {Element|null} The matched node or null if no match.
+ */
+ let findMatch = function (node, actionObj) {
+ // Ignore some elements and just use their children instead.
+ if (node.localName == "hbox" || node.localName == "vbox") {
+ for (let i = 0; i < node.children.length; i++) {
+ let childMatch = findMatch(node.children[i]);
+ if (childMatch) {
+ return childMatch;
+ }
+ }
+ return null;
+ }
+
+ let matchedAll = true;
+ for (let name in actionObj) {
+ let value = actionObj[name];
+ if (!node.hasAttribute(name) || node.getAttribute(name) != value) {
+ matchedAll = false;
+ break;
+ }
+ }
+ return matchedAll ? node : null;
+ };
+
+ // These popups sadly do not close themselves, so we need to keep track
+ // of them so we can make sure they end up closed.
+ let closeStack = [aRootPopup];
+
+ let curPopup = aRootPopup;
+ for (let [iAction, actionObj] of aActions.entries()) {
+ let matchingNode = null;
+ let kids = curPopup.children;
+ for (let iKid = 0; iKid < kids.length; iKid++) {
+ let node = kids[iKid];
+ matchingNode = findMatch(node, actionObj);
+ if (matchingNode) {
+ break;
+ }
+ }
+
+ if (!matchingNode) {
+ throw new Error(
+ "Did not find matching menu item for action index " +
+ iAction +
+ ": " +
+ JSON.stringify(actionObj)
+ );
+ }
+
+ if (matchingNode.localName == "menu") {
+ matchingNode.openMenu(true);
+ } else {
+ curPopup.activateItem(matchingNode);
+ }
+ await new Promise(r => matchingNode.ownerGlobal.setTimeout(r, 500));
+
+ let newPopup = null;
+ if ("menupopup" in matchingNode) {
+ newPopup = matchingNode.menupopup;
+ }
+ if (newPopup) {
+ curPopup = newPopup;
+ closeStack.push(curPopup);
+ if (curPopup.state != "open") {
+ await BrowserTestUtils.waitForEvent(curPopup, "popupshown");
+ }
+ }
+ }
+
+ if (!aKeepOpen) {
+ close_popup_sequence(closeStack);
+ return [];
+ }
+ return closeStack;
+}
+
+/**
+ * Close given menupopups.
+ *
+ * @param aCloseStack An array of menupopup elements that are to be closed.
+ * The elements are processed from the end of the array
+ * to the front (a stack).
+ */
+function close_popup_sequence(aCloseStack) {
+ while (aCloseStack.length) {
+ let curPopup = aCloseStack.pop();
+ if (curPopup.state == "open") {
+ curPopup.focus();
+ curPopup.hidePopup();
+ }
+ }
+}
+
+/**
+ * Click through the appmenu. Callers are expected to open the initial
+ * appmenu panelview (e.g. by clicking the appmenu button). We wait for it
+ * to open if it is not open yet. Then we use a recursive style approach
+ * with a sequence of event listeners handling "ViewShown" events. The
+ * `navTargets` parameter specifies items to click to navigate through the
+ * menu. The optional `nonNavTarget` parameter specifies a final item to
+ * click to perform a command after navigating through the menu. If this
+ * argument is omitted, callers can interact with the last view panel that
+ * is returned. Callers will then need to close the appmenu when they are
+ * done with it.
+ *
+ * @param {object[]} navTargets - Array of objects that contain
+ * attribute->value pairs. We pick the menu item whose DOM node matches
+ * all the attribute->value pairs. We click whatever we find. We throw
+ * if the element being asked for is not found.
+ * @param {object} [nonNavTarget] - Contains attribute->value pairs used
+ * to identify a final menu item to click.
+ * @param {Window} win - The window we're using.
+ * @returns {Element} The <vbox class="panel-subview-body"> element inside
+ * the last shown <panelview>.
+ */
+function _click_appmenu_in_sequence(navTargets, nonNavTarget, win) {
+ const rootPopup = win.document.getElementById("appMenu-popup");
+
+ function viewShownListener(navTargets, nonNavTarget, allDone, event) {
+ // Set up the next listener if there are more navigation targets.
+ if (navTargets.length > 0) {
+ rootPopup.addEventListener(
+ "ViewShown",
+ viewShownListener.bind(
+ null,
+ navTargets.slice(1),
+ nonNavTarget,
+ allDone
+ ),
+ { once: true }
+ );
+ }
+
+ const subview = event.target.querySelector(".panel-subview-body");
+
+ // Click a target if there is a target left to click.
+ const clickTarget = navTargets[0] || nonNavTarget;
+
+ if (clickTarget) {
+ const kids = Array.from(subview.children);
+ const findFunction = node => {
+ let selectors = [];
+ for (let name in clickTarget) {
+ let value = clickTarget[name];
+ selectors.push(`[${name}="${value}"]`);
+ }
+ let s = selectors.join(",");
+ return node.matches(s) || node.querySelector(s);
+ };
+
+ // Some views are dynamically populated after ViewShown, so we wait.
+ utils.waitFor(
+ () => kids.find(findFunction),
+ () =>
+ "Waited but did not find matching menu item for target: " +
+ JSON.stringify(clickTarget)
+ );
+
+ const foundNode = kids.find(findFunction);
+
+ EventUtils.synthesizeMouseAtCenter(foundNode, {}, foundNode.ownerGlobal);
+ }
+
+ // We are all done when there are no more navigation targets.
+ if (navTargets.length == 0) {
+ allDone(subview);
+ }
+ }
+
+ let done = false;
+ let subviewToReturn;
+ const allDone = subview => {
+ subviewToReturn = subview;
+ done = true;
+ };
+
+ utils.waitFor(
+ () => rootPopup.getAttribute("panelopen") == "true",
+ "Waited for the appmenu to open, but it never opened."
+ );
+
+ // Because the appmenu button has already been clicked in the calling
+ // code (to match click_menus_in_sequence), we have to call the first
+ // viewShownListener manually, using a fake event argument, to start the
+ // series of event listener calls.
+ const fakeEvent = {
+ target: win.document.getElementById("appMenu-mainView"),
+ };
+ viewShownListener(navTargets, nonNavTarget, allDone, fakeEvent);
+
+ utils.waitFor(() => done, "Timed out in _click_appmenu_in_sequence.");
+ return subviewToReturn;
+}
+
+/**
+ * Utility wrapper function that clicks the main appmenu button to open the
+ * appmenu before calling `click_appmenu_in_sequence`. Makes things simple
+ * and concise for the most common case while still allowing for tests that
+ * open the appmenu via keyboard before calling `_click_appmenu_in_sequence`.
+ *
+ * @param {object[]} navTargets - Array of objects that contain
+ * attribute->value pairs to be used to identify menu items to click.
+ * @param {?object} nonNavTarget - Contains attribute->value pairs used
+ * to identify a final menu item to click.
+ * @param {Window} win - The window we're using.
+ * @returns {Element} The <vbox class="panel-subview-body"> element inside
+ * the last shown <panelview>.
+ */
+function click_through_appmenu(navTargets, nonNavTarget, win) {
+ let appmenu = win.document.getElementById("button-appmenu");
+ EventUtils.synthesizeMouseAtCenter(appmenu, {}, appmenu.ownerGlobal);
+ return _click_appmenu_in_sequence(navTargets, nonNavTarget, win);
+}
diff --git a/comm/mail/test/browser/shared-modules/controller.jsm b/comm/mail/test/browser/shared-modules/controller.jsm
new file mode 100644
index 0000000000..9c0bd084d9
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/controller.jsm
@@ -0,0 +1,60 @@
+// ***** BEGIN LICENSE BLOCK *****
+// Version: MPL 1.1/GPL 2.0/LGPL 2.1
+//
+// The contents of this file are subject to the Mozilla Public License Version
+// 1.1 (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+// http://www.mozilla.org/MPL/
+//
+// Software distributed under the License is distributed on an "AS IS" basis,
+// WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+// for the specific language governing rights and limitations under the
+// License.
+//
+// The Original Code is Mozilla Corporation Code.
+//
+// The Initial Developer of the Original Code is
+// Adam Christian.
+// Portions created by the Initial Developer are Copyright (C) 2008
+// the Initial Developer. All Rights Reserved.
+//
+// Contributor(s):
+// Adam Christian <adam.christian@gmail.com>
+// Mikeal Rogers <mikeal.rogers@gmail.com>
+// Henrik Skupin <hskupin@mozilla.com>
+// Aaron Train <atrain@mozilla.com>
+//
+// Alternatively, the contents of this file may be used under the terms of
+// either the GNU General Public License Version 2 or later (the "GPL"), or
+// the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+// in which case the provisions of the GPL or the LGPL are applicable instead
+// of those above. If you wish to allow use of your version of this file only
+// under the terms of either the GPL or the LGPL, and not to allow others to
+// use your version of this file under the terms of the MPL, indicate your
+// decision by deleting the provisions above and replace them with the notice
+// and other provisions required by the GPL or the LGPL. If you do not delete
+// the provisions above, a recipient may use your version of this file under
+// the terms of any one of the MPL, the GPL or the LGPL.
+//
+// ***** END LICENSE BLOCK *****
+
+var EXPORTED_SYMBOLS = ["MozMillController"];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var MozMillController = function (win) {
+ this.window = win;
+
+ utils.waitFor(
+ function () {
+ return (
+ win != null &&
+ win.document.readyState == "complete" &&
+ win.location.href != "about:blank"
+ );
+ },
+ "controller(): Window could not be initialized.",
+ undefined,
+ undefined,
+ this
+ );
+};
diff --git a/comm/mail/test/browser/shared-modules/moz.build b/comm/mail/test/browser/shared-modules/moz.build
new file mode 100644
index 0000000000..d61395fac5
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/moz.build
@@ -0,0 +1,34 @@
+# vim: set filetype=python:
+# 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/.
+
+TESTING_JS_MODULES.mozmill += [
+ "AccountManagerHelpers.jsm",
+ "AddressBookHelpers.jsm",
+ "AttachmentHelpers.jsm",
+ "CloudfileHelpers.jsm",
+ "ComposeHelpers.jsm",
+ "ContentTabHelpers.jsm",
+ "controller.jsm",
+ "CustomizationHelpers.jsm",
+ "DOMHelpers.jsm",
+ "EventUtils.jsm",
+ "FolderDisplayHelpers.jsm",
+ "JunkHelpers.jsm",
+ "KeyboardHelpers.jsm",
+ "MockObjectHelpers.jsm",
+ "MouseEventHelpers.jsm",
+ "NewMailAccountHelpers.jsm",
+ "NNTPHelpers.jsm",
+ "NotificationBoxHelpers.jsm",
+ "OpenPGPTestUtils.jsm",
+ "PrefTabHelpers.jsm",
+ "PromptHelpers.jsm",
+ "QuickFilterBarHelpers.jsm",
+ "SearchWindowHelpers.jsm",
+ "SubscribeWindowHelpers.jsm",
+ "utils.jsm",
+ "ViewHelpers.jsm",
+ "WindowHelpers.jsm",
+]
diff --git a/comm/mail/test/browser/shared-modules/utils.jsm b/comm/mail/test/browser/shared-modules/utils.jsm
new file mode 100644
index 0000000000..9605adfdca
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/utils.jsm
@@ -0,0 +1,130 @@
+// ***** BEGIN LICENSE BLOCK *****
+// Version: MPL 1.1/GPL 2.0/LGPL 2.1
+//
+// The contents of this file are subject to the Mozilla Public License Version
+// 1.1 (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+// http://www.mozilla.org/MPL/
+//
+// Software distributed under the License is distributed on an "AS IS" basis,
+// WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+// for the specific language governing rights and limitations under the
+// License.
+//
+// The Original Code is Mozilla Corporation Code.
+//
+// The Initial Developer of the Original Code is
+// Adam Christian.
+// Portions created by the Initial Developer are Copyright (C) 2008
+// the Initial Developer. All Rights Reserved.
+//
+// Contributor(s):
+// Adam Christian <adam.christian@gmail.com>
+// Mikeal Rogers <mikeal.rogers@gmail.com>
+// Henrik Skupin <hskupin@mozilla.com>
+//
+// Alternatively, the contents of this file may be used under the terms of
+// either the GNU General Public License Version 2 or later (the "GPL"), or
+// the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+// in which case the provisions of the GPL or the LGPL are applicable instead
+// of those above. If you wish to allow use of your version of this file only
+// under the terms of either the GPL or the LGPL, and not to allow others to
+// use your version of this file under the terms of the MPL, indicate your
+// decision by deleting the provisions above and replace them with the notice
+// and other provisions required by the GPL or the LGPL. If you do not delete
+// the provisions above, a recipient may use your version of this file under
+// the terms of any one of the MPL, the GPL or the LGPL.
+//
+// ***** END LICENSE BLOCK *****
+
+var EXPORTED_SYMBOLS = ["sleep", "TimeoutError", "waitFor"];
+
+var hwindow = Services.appShell.hiddenDOMWindow;
+
+/**
+ * Sleep for the given amount of milliseconds
+ *
+ * @param {number} milliseconds
+ * Sleeps the given number of milliseconds
+ */
+function sleep(milliseconds) {
+ // We basically just call this once after the specified number of milliseconds
+ var timeup = false;
+ function wait() {
+ timeup = true;
+ }
+ hwindow.setTimeout(wait, milliseconds);
+
+ var thread = Services.tm.currentThread;
+ while (!timeup) {
+ thread.processNextEvent(true);
+ }
+}
+
+/**
+ * TimeoutError
+ *
+ * Error object used for timeouts
+ */
+function TimeoutError(message, fileName, lineNumber) {
+ var err = new Error();
+ if (err.stack) {
+ this.stack = err.stack;
+ }
+ this.message = message === undefined ? err.message : message;
+ this.fileName = fileName === undefined ? err.fileName : fileName;
+ this.lineNumber = lineNumber === undefined ? err.lineNumber : lineNumber;
+}
+TimeoutError.prototype = new Error();
+TimeoutError.prototype.constructor = TimeoutError;
+TimeoutError.prototype.name = "TimeoutError";
+
+/**
+ * Waits for the callback evaluates to true
+ *
+ * @param callback Function that returns true when the waiting thould end.
+ * @param message {string or function} A message to throw if the callback didn't
+ * succeed until the timeout. Use a function
+ * if the message is to show some object state
+ * after the end of the wait (not before wait).
+ * @param timeout Milliseconds to wait until callback succeeds.
+ * @param interval Milliseconds to 'sleep' between checks of callback.
+ * @param thisObject (optional) 'this' to be passed into the callback.
+ */
+function waitFor(callback, message, timeout, interval, thisObject) {
+ timeout = timeout || 5000;
+ interval = interval || 100;
+
+ var self = { counter: 0, result: callback.call(thisObject) };
+
+ function wait() {
+ self.counter += interval;
+ self.result = callback.call(thisObject);
+ }
+
+ var timeoutInterval = hwindow.setInterval(wait, interval);
+ var thread = Services.tm.currentThread;
+
+ while (!self.result && self.counter < timeout) {
+ thread.processNextEvent(true);
+ }
+
+ hwindow.clearInterval(timeoutInterval);
+
+ if (self.counter >= timeout) {
+ let messageText;
+ if (message) {
+ if (typeof message === "function") {
+ messageText = message();
+ } else {
+ messageText = message;
+ }
+ } else {
+ messageText = "waitFor: Timeout exceeded for '" + callback + "'";
+ }
+
+ throw new TimeoutError(messageText);
+ }
+
+ return true;
+}
diff --git a/comm/mail/test/browser/smime/browser.ini b/comm/mail/test/browser/smime/browser.ini
new file mode 100644
index 0000000000..ba509e2496
--- /dev/null
+++ b/comm/mail/test/browser/smime/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+support-files = data/**
+
+[browser_multipartAlternative.js]
+[browser_nestedSMimeSigs.js]
diff --git a/comm/mail/test/browser/smime/browser_multipartAlternative.js b/comm/mail/test/browser/smime/browser_multipartAlternative.js
new file mode 100644
index 0000000000..cd55bba265
--- /dev/null
+++ b/comm/mail/test/browser/smime/browser_multipartAlternative.js
@@ -0,0 +1,100 @@
+/* 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 that a reply to a multipart/alternative message with two
+ * encrypted parts doesn't leak the secret plaintext from the second
+ * part.
+ */
+
+"use strict";
+
+var { close_compose_window, get_msg_source, open_compose_with_reply } =
+ ChromeUtils.import("resource://testing-common/mozmill/ComposeHelpers.jsm");
+var {
+ be_in_folder,
+ get_special_folder,
+ open_message_from_file,
+ press_delete,
+ select_click_row,
+ smimeUtils_ensureNSS,
+ smimeUtils_loadCertificateAndKey,
+ smimeUtils_loadPEMCertificate,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gDrafts;
+
+add_setup(async function () {
+ gDrafts = await get_special_folder(Ci.nsMsgFolderFlags.Drafts, true);
+
+ Services.prefs.setBoolPref("mail.identity.id1.compose_html", true);
+});
+
+add_task(async function test_multipart_alternative() {
+ smimeUtils_ensureNSS();
+ smimeUtils_loadPEMCertificate(
+ new FileUtils.File(getTestFilePath("data/TestCA.pem")),
+ Ci.nsIX509Cert.CA_CERT
+ );
+ smimeUtils_loadCertificateAndKey(
+ new FileUtils.File(getTestFilePath("data/Bob.p12"), "nss")
+ );
+
+ let msgc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/multipart-alternative.eml"))
+ );
+
+ let cwc = open_compose_with_reply(msgc);
+
+ close_window(msgc);
+
+ // Now save the message as a draft.
+ EventUtils.synthesizeKey(
+ "s",
+ { shiftKey: false, accelKey: true },
+ cwc.window
+ );
+ await TestUtils.waitForCondition(
+ () => !cwc.window.gSaveOperationInProgress && !cwc.window.gWindowLock,
+ "Saving of draft did not finish"
+ );
+ close_compose_window(cwc);
+
+ // Now check the message content in the drafts folder.
+ await be_in_folder(gDrafts);
+ let message = select_click_row(0);
+ let messageContent = await get_msg_source(message);
+
+ // Check for a single line that contains text and make sure there is a
+ // space at the end for a flowed reply.
+ Assert.ok(
+ !messageContent.includes("SECRET-TEXT"),
+ "Secret text was found, but shouldn't be there."
+ );
+
+ // Delete the outgoing message.
+ press_delete();
+});
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("mail.identity.id1.compose_html");
+
+ // 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();
+});
diff --git a/comm/mail/test/browser/smime/browser_nestedSMimeSigs.js b/comm/mail/test/browser/smime/browser_nestedSMimeSigs.js
new file mode 100644
index 0000000000..996c4e804a
--- /dev/null
+++ b/comm/mail/test/browser/smime/browser_nestedSMimeSigs.js
@@ -0,0 +1,49 @@
+/* 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 that a message containing two nested S/MIME signatures shows
+ * the contents of the inner signed message.
+ */
+
+"use strict";
+
+var { open_message_from_file, get_about_message, smimeUtils_ensureNSS } =
+ ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ );
+var { close_window } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+function getMsgBodyTxt(mc) {
+ let msgPane = get_about_message(mc.window).getMessagePaneBrowser();
+ return msgPane.contentDocument.documentElement.textContent;
+}
+
+add_task(async function test_nested_sigs() {
+ smimeUtils_ensureNSS();
+
+ let msgc = await open_message_from_file(
+ new FileUtils.File(getTestFilePath("data/nested-sigs.eml"))
+ );
+
+ Assert.ok(
+ getMsgBodyTxt(msgc).includes("level 2"),
+ "level 2 text is shown in body"
+ );
+
+ close_window(msgc);
+});
+
+registerCleanupFunction(() => {
+ // 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();
+});
diff --git a/comm/mail/test/browser/smime/data/Bob.p12 b/comm/mail/test/browser/smime/data/Bob.p12
new file mode 100644
index 0000000000..b5c8504a73
--- /dev/null
+++ b/comm/mail/test/browser/smime/data/Bob.p12
Binary files differ
diff --git a/comm/mail/test/browser/smime/data/README.md b/comm/mail/test/browser/smime/data/README.md
new file mode 100644
index 0000000000..43c4ce63fa
--- /dev/null
+++ b/comm/mail/test/browser/smime/data/README.md
@@ -0,0 +1,5 @@
+S/MIME Mozmill test certificates and messages
+=============================================
+
+The test data in this directory can be refreshed by executing script
+local-gen.sh found in comm/mailnews/test/data/smime
diff --git a/comm/mail/test/browser/smime/data/TestCA.pem b/comm/mail/test/browser/smime/data/TestCA.pem
new file mode 100644
index 0000000000..0e9f065d6e
--- /dev/null
+++ b/comm/mail/test/browser/smime/data/TestCA.pem
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDdTCCAl2gAwIBAgIBATANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJVUzET
+MBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzESMBAG
+A1UEChMJQk9HVVMgTlNTMRQwEgYDVQQDEwtOU1MgVGVzdCBDQTAgFw0yMzExMjEy
+MDUwMDdaGA8yMDczMTEyMTIwNTAwN1owZDELMAkGA1UEBhMCVVMxEzARBgNVBAgT
+CkNhbGlmb3JuaWExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEjAQBgNVBAoTCUJP
+R1VTIE5TUzEUMBIGA1UEAxMLTlNTIFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUA
+A4IBDwAwggEKAoIBAQDPL+VBVHBnK32PfKsFz5mwpeOdSOsfqymx2DN1qo42mQ8s
+SRmrK7lbi0iiGuU+3jFBh/29wWitHH+1qb/DDSiIYk+RS3mVKdTxDOztwyRW7mf3
+oqK3OR4cQhLxXhIDuhDW4P4CQTwu6CSqwyTkrJeEi77foQ/C1rX1zQWMpJW7n4Iv
+9PNMedHpNtoXP7zS8GxV9WwiHFEcRYzwdrlHQJm9+l0OWp8Tl5DFniJjjOuQYr4n
+DGJ1R4F74yU9NPrOzAy9Cvm1eO4novQEcUyQ5Mdnw7bHI31ChWf/KyZzDLA+jTMI
+30fLNDr512k0Z1429p1n77nzGhSDSbSNKFtyyNy1AgMBAAGjMDAuMBEGCWCGSAGG
++EIBAQQEAwIABzAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0B
+AQsFAAOCAQEAt8reMMu83pDQiZqgq1bI7P1sGznDykMefFITZF3veDas86T9seDZ
+pPAT45uo8t/+yuQWciDfBDcB5NnedkTjmXUaG1ZKekTamv6uNaLqr5aZrotnNUwK
+CYk4ci1K5MuprqE6kBKKP8cGYL5ZqA4PPIHxlCgU2JR9G8NVn7Nw3Xb6ZTqRWTSn
+g2tM3LWBDCu2p6qIZPNu35OYBUmLzsfSSlroj8iwirnOa+LYAmUMTQlnBQpln1WI
+q+q0haYFPt9MTGpvwA9JBoElS9I4XxJHbthlsCizas9UJfue37RIR5LRXA8zudRU
+Rwo8QS6MTgppRvorz2kvABRmkXiYk3Vn/g==
+-----END CERTIFICATE-----
diff --git a/comm/mail/test/browser/smime/data/multipart-alternative.eml b/comm/mail/test/browser/smime/data/multipart-alternative.eml
new file mode 100644
index 0000000000..0ff896f048
--- /dev/null
+++ b/comm/mail/test/browser/smime/data/multipart-alternative.eml
@@ -0,0 +1,42 @@
+MIME-Version: 1.0
+Date: Tue, 21 Nov 2023 21:12:39 +0000
+From: Alice <alice@example.com>
+To: Bob <bob@example.com>
+Subject: a message
+Content-Type: multipart/alternative; boundary="--------BOUNDARY"
+
+----------BOUNDARY
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggGFMIIBgQIBADBpMGQxCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIw
+EAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEoMA0GCSqG
+SIb3DQEBAQUABIIBAJFkVEzBMOiW8RY8h8xH7ZujIgZ/HjTP0NziHV+asAP1ANm1
+EqPqiy8C5/jtrW/rYF+2JYZIe8xW9kOpd4AlKyAvESn/jM+wRwRai3YHJ8DalFzl
+4+CsqIzSwmf9KZa0yK72Cy+Lp8/0ycWJstDU/ZXFd6lUsHWiYdPgF1KsceWepyYS
+Ggg4n9dopCN7+Xthlj8uW2yG5u+WtJkWJrXqrM4z7pzaW9GDPeOV5jp9CUauZOdV
+ItlwPlGH/mMeiJ7KKQVclnN8bx2q/QHOvnSWS6fID1WTBYV1ZhAlxZHWBRSn643J
+3ZwLxIS+F1cRc/EClsWv8zgKLfNXrcVSja1vD4UwgAYJKoZIhvcNAQcBMB0GCWCG
+SAFlAwQBAgQQ57mGV7ei08u956g0xkC4caCABIGQt8h3XciNdcCwh4xWGWviytVC
+Gbt5ZAI/iAsrBbkm2Tx/PxNrcn7+5FBhsTld1Inm8lge2WTNClOxoOecoeZxCe+y
+MGqrAsxPIDYpO7HP/n1+RBWy8azv+j2o2+LuguA+xo2wnTbt8BJz6DN8k61PL8Xg
+yY8hP+DQsA/6OxB/KA8TJRFxR8N26DseKmuXoo/xBBAJyufRO/Z/JkWOTbTpOIjC
+AAAAAAAAAAAAAA==
+----------BOUNDARY
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggGFMIIBgQIBADBpMGQxCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRIw
+EAYDVQQKEwlCT0dVUyBOU1MxFDASBgNVBAMTC05TUyBUZXN0IENBAgEoMA0GCSqG
+SIb3DQEBAQUABIIBAAZXwFocAUc633rlij5f7h34x/PYispef+6ufoNaQKq1/c9b
+SbKRZR4hqBYy1pBxO6tK61gWnFeHdnrq7Kec/i+LOkUG4g8Ki4ELG3MGICaqo7Fu
+CAQeSbSD+E8LuO/rRsnNwUwNE2oy7Zie9iqqcJQuCR48WMeVhuGn+RwEDAlZod73
+/us2Xvobl3Q40vcbidegMmP9UXBa45BhIfeq8GrtPOUzVW9zv7fQoNc+1x1JSK7L
+Y+o5VPUoJUMsr2jcgpGDb0uPN1xN8qEThEb8YeFLQbynEoEihJLtKfs33E0U2BVe
+GhSCHB0w6SvSkhwSYI7OY6vj18JBcjnFTIY2VkEwgAYJKoZIhvcNAQcBMB0GCWCG
+SAFlAwQBAgQQECWIfBfTzibznP1cQzICh6CABEBc9eyhZr/eEGCVEcyXi/X5XfUr
+YAbZTYjcjislKjI+2R922HPdAtQabWkWY9S8O81XlRNmTr0qhl+IArc3IWUwBBC+
+3oryP+raeGBWDEEj7ZTYAAAAAAAAAAAAAA==
+----------BOUNDARY
diff --git a/comm/mail/test/browser/smime/data/nested-sigs.eml b/comm/mail/test/browser/smime/data/nested-sigs.eml
new file mode 100644
index 0000000000..3a84ae1f98
--- /dev/null
+++ b/comm/mail/test/browser/smime/data/nested-sigs.eml
@@ -0,0 +1,219 @@
+Date: Wed, 5 Jan 2022 08:54:26 +0100
+From: nobody1@example.com
+To: nobody2@example.com
+Subject: subject 1
+MIME-Version: 1.0
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-1;
+ boundary="----=_Part_159674795_1647108947.1641369266420"
+
+------=_Part_159674795_1647108947.1641369266420
+Content-Type: multipart/mixed;
+ boundary="----=_Part_159674792_1533625984.1641369266416"
+
+------=_Part_159674792_1533625984.1641369266416
+Content-Type: text/plain; charset="ISO-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+level 1
+
+------=_Part_159674792_1533625984.1641369266416
+Content-Type: message/rfc822; name=nested2.eml
+Content-Disposition: attachment; filename="nested2.eml"
+
+Date: Wed, 5 Jan 2022 08:54:23 +0100
+From: nobody3@example.com
+To: nobody1@example.com
+Subject: subject 2
+MIME-Version: 1.0
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-1;
+ boundary="----=_Part_71865141_195979751.1641369263077"
+
+------=_Part_71865141_195979751.1641369263077
+Content-Type: text/plain; charset="ISO-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+level 2
+
+------=_Part_71865141_195979751.1641369263077
+Content-Type: application/pkcs7-signature; name=smime.p7s; smime-type=signed-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7s"
+Content-Description: S/MIME Cryptographic Signature
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExDzANBglghkgBZQMEAgMFADCABgkqhkiG9w0BBwEAAKCC
+DMgwggY9MIIEJaADAgECAgMU4igwDQYJKoZIhvcNAQENBQAweTEQMA4GA1UEChMHUm9vdCBD
+QTEeMBwGA1UECxMVaHR0cDovL3d3dy5jYWNlcnQub3JnMSIwIAYDVQQDExlDQSBDZXJ0IFNp
+Z25pbmcgQXV0aG9yaXR5MSEwHwYJKoZIhvcNAQkBFhJzdXBwb3J0QGNhY2VydC5vcmcwHhcN
+MjEwNDE5MTIxODMwWhcNMzEwNDE3MTIxODMwWjBUMRQwEgYDVQQKEwtDQWNlcnQgSW5jLjEe
+MBwGA1UECxMVaHR0cDovL3d3dy5DQWNlcnQub3JnMRwwGgYDVQQDExNDQWNlcnQgQ2xhc3Mg
+MyBSb290MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAq0k1EUh80iZ+U5TPQ6nd
+KNdCKovzh3gZWHwPntqJfeH763KQDXShlmSrn6AkmXPa4lV2xxd79QSsRrjDvn9kjRBsJPNh
+nMDykPpR5vVpAWPDD1biSkLP4kSMJSioxXkJfUa5ivPp8zQpCEXkHJ/LlAQcgagUs5hlxEPs
+ToKNCdG9qluNktDs3pDFfwrC4+vmMVpedD6XM1nowwM9YDO/99FvR8TN7mKDUm4uCJqk2RUY
+kaaFkkewrkjrbbch7IUaaHI1q//wEF3A9JSnatU7kn5MkAV+k8Esi6SOYnQVcW4LcQPqrxU4
+mtTSBXJvjPkr61pyJfk5RuNyGz4Ew2QnIhAqik9YpwOtvrQuE+1dqkjX1X3UKntc+kYEUOTM
+DkJbjO3b8s/8lpPg2xE2VGI0OI8MYJs7l1Y4rfPSW4ugW+pOlrh819WghnBA05Ept6I8rfWM
+u88akorkNHvA2Gxf6QrCw6cgmlrfLF1SXLpH1ZvvJChwOCAv1X8pwLJBA2iSzOCczJdLRe86
+EAqrcDqYlXCtNbHqhSukHIAhMamuYHqAJkgAuAHAk2NVIpE8Vuev2zol848xVOomi4FZ+aHR
+UxHFe50D9nQR4G2xLD8shpGZcZqmd4s0YNEUtCysna+MENOfxGr4bxP8c1n3ZkJ0Horj+NzS
+b5icy0eYlUAF++kCAwEAAaOB8jCB7zAPBgNVHRMBAf8EBTADAQH/MGEGCCsGAQUFBwEBBFUw
+UzAjBggrBgEFBQcwAYYXaHR0cDovL29jc3AuQ0FjZXJ0Lm9yZy8wLAYIKwYBBQUHMAKGIGh0
+dHA6Ly93d3cuQ0FjZXJ0Lm9yZy9jbGFzczMuY3J0MEUGA1UdIAQ+MDwwOgYLKwYBBAGBkEoC
+AwEwKzApBggrBgEFBQcCARYdaHR0cDovL3d3dy5DQWNlcnQub3JnL2Nwcy5waHAwMgYDVR0f
+BCswKTAnoCWgI4YhaHR0cHM6Ly93d3cuY2FjZXJ0Lm9yZy9jbGFzczMuY3JsMA0GCSqGSIb3
+DQEBDQUAA4ICAQDGHq13XLQom9HIjUQSwL12dgSDIQf4EYJ/a8GVQsA4EbUlcI2LDMHVbP0c
+GgN8i/gGMaWd3kEp1IubhNc9wTeGcaMfW2EpHl13fbvwrbkVGRMU5jWA/6YZtDeFlEHoiMNf
+4LIGpLv4QKkdOazt6j+YBE35jPlHeXNS9ezfNJf7Pnfg3NGDiLqIc0dapqQVxA1wDQ+eSxMH
+fu8YPvmlAap5KbHnUvpTOsimf7bviaGxoU0vzmOFf6Uq6TvUwaPPChOFu5nXnGaQhOdm1FCz
+oeEtIiolaMMgsivEupgd6ErvXFjCtE2EVvdOuxZoQmySuG94zQ6z+++gs2SH8veIRDn8ueYs
+wJgk1EAsXsjuCx24Ak0muAoYxi8eS3Vujy4hc7zCA1XuqhTgmhoHUwvfRBSoZwWvRMjToUV2
+ArZ/DLmG6U/GbrC7FbS/6IC1djH+ZGTBClhtxVC2sgO/HUJPWTnRxDGL6MgqORwVYfDeQGgO
+cKizT+6R6A9PtpCeTYBsvhzucKS4BwQrDUECVIROR+qLlu12WGHnwyF7Bm/UtwvnNDKDzDWm
+5yVPfBdC/LxXA8afQn+YYPiAstn2sZwcNQQKiTEWhaT67kwJxWqYZuzIbirmy5LcI2yWwdRF
+8zxtArigu8dHwsIcQExFx0UGfztxK84rp4HWR0YosDzKZfFmnzCCBoMwggRroAMCAQICAwL0
+FjANBgkqhkiG9w0BAQ0FADBUMRQwEgYDVQQKEwtDQWNlcnQgSW5jLjEeMBwGA1UECxMVaHR0
+cDovL3d3dy5DQWNlcnQub3JnMRwwGgYDVQQDExNDQWNlcnQgQ2xhc3MgMyBSb290MB4XDTIx
+MTAxMzE1MDA0NloXDTIzMTAxMzE1MDA0NlowVzETMBEGA1UEAxMKS2FpIEVuZ2VydDEbMBkG
+CSqGSIb3DQEJARYMa2FpZUBrdWl4LmRlMSMwIQYJKoZIhvcNAQkBFhRrYWllQHRodW5kZXJi
+aXJkLm5ldDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKlAMvhWj/JTL3TYeWHi
+ElUgH7QcaKr57viBf5Hz6GliZuC7IffSD7o9FPL5Her+Y6ny/7emyzY444619SgxA6GtT2cx
+bl7usPrV8JX+ZxFQLrODQ3xmlhO1PiQGVhLxnjh/hBQYLqyuibjy77vexAAmHYyvjcvwtOeV
+JxYQn0wRWf9fXOqa5jKot9U4/gT3UnSxLtFfU9d8wjAE3EV8Q6wto0e3xKy4GkP6DWBK/LJI
+tE+wEtvP6egFJgxByuNrCIwtL6qCi15JAFUpAegnxEl6Ejn+vBKSz3GtiSPA5TuCtJx6nYy4
+C8JSIMUxXsLxXeabtRgJyb/4AP14Vjfa/bdryUTgzoNjmQ4kJJv0ltG2weyhomsL4oZIoY/V
+x9cXnBrlr+C1CU3r4d7JeB3OlJFGxC0C5uq3+OfTozdrn26fVamlAo3CnjQ/qYqPZfmjtSD7
+bVl4gYxFOlAxN6a1QpDf3dNdmXGRalj04osQ+QcaNVeLhpyqFN4dslFWMboGDkDQhRysA2la
+BxS1f99syysaiI7xNIOs6QYExf/yCo1BKEXbpTkygFJLicIv/SC/o6/9OZhwoG2tM3eSKp8V
+0P9NcIh6KZN5wZ4KYebW1y2aA+Hh+qHAgVUhb8FZyRuKKpqfrpavaQB1SAHJK0xq4Xjd7KI8
+gUTR8Z0zIg/ZJ5kVAgMBAAGjggFZMIIBVTAMBgNVHRMBAf8EAjAAMFYGCWCGSAGG+EIBDQRJ
+FkdUbyBnZXQgeW91ciBvd24gY2VydGlmaWNhdGUgZm9yIEZSRUUgaGVhZCBvdmVyIHRvIGh0
+dHA6Ly93d3cuQ0FjZXJ0Lm9yZzAOBgNVHQ8BAf8EBAMCA6gwQAYDVR0lBDkwNwYIKwYBBQUH
+AwQGCCsGAQUFBwMCBgorBgEEAYI3CgMEBgorBgEEAYI3CgMDBglghkgBhvhCBAEwMgYIKwYB
+BQUHAQEEJjAkMCIGCCsGAQUFBzABhhZodHRwOi8vb2NzcC5jYWNlcnQub3JnMDgGA1UdHwQx
+MC8wLaAroCmGJ2h0dHA6Ly9jcmwuY2FjZXJ0Lm9yZy9jbGFzczMtcmV2b2tlLmNybDAtBgNV
+HREEJjAkgQxrYWllQGt1aXguZGWBFGthaWVAdGh1bmRlcmJpcmQubmV0MA0GCSqGSIb3DQEB
+DQUAA4ICAQBEyGOkkF49VDUeSiqRn7J2QVpNTJszE5yofqw3YjUEx1GQZ3pf2dWwSUnCR3bi
+Am2/OomaBvqWVpS0nDvbFrTCF8ewoRcvQRt9AwIZDYA0mPnl9wR66L4o2/1AkvmXUAXbOilr
+9m2rAZQ8Xu6iZtCUmXR/0QoXbiJYk7ljFVd3/cjOzXAo5K1GAWwQ8jh8s06RVkSXuWORX3Pa
+mkHfRcdokp9oi2nRHrLWUWwJUj8f2zURovXDVyUZYephYfMpac9y/ySnviuTmMYsndIZm9qP
+E7IiC22mTLMNaTGjfI13n+X7jC0Oh0K7WqqmmpCb+xYFfa0G3gmnQaPuS8CcecEvL06dNOBC
+aeMrWPbR7n0IwQSWxCueg8TvGryqhK0nX0X9DyX90uxtK+Zqb+mZGJ5ovv54FsWpYfZDMWIk
+UMQs8jiFxHQi+uFtDB1UYxaN5ML15VnFH2wRzq0aHzHevn8cq5SXo5tbbpOucEqMyASiYAdj
+51TumBPhTLGV81lLHQxSY8bBTrMfaljIieps1pP/pL5LgbrmB+ZuHpXxOeHQADzNuTipdYLr
+kDwjMfjIVQEiiNPxjmOH/oEIjYZMlvZdnZyz0RYTJfAMEHhpo/VkC0m6CWxUnpy4YE4/179s
+tsSviSN77rLqdPVhtgucPn7FP1vdWi+xkNxpG/KmaCnTVjGCBFswggRXAgEBMFswVDEUMBIG
+A1UEChMLQ0FjZXJ0IEluYy4xHjAcBgNVBAsTFWh0dHA6Ly93d3cuQ0FjZXJ0Lm9yZzEcMBoG
+A1UEAxMTQ0FjZXJ0IENsYXNzIDMgUm9vdAIDAvQWMA0GCWCGSAFlAwQCAwUAoIIB0TAYBgkq
+hkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yMTExMDIwOTU2MDNaME8G
+CSqGSIb3DQEJBDFCBED769ZTTw8PcLnVMFuaVHnDUorGj9QTWjUfRdTnwvdskdCvXa4df9U0
+U5IcPrdX9QQy4COFNAX4X5070FSXRTx4MGoGCSsGAQQBgjcQBDFdMFswVDEUMBIGA1UEChML
+Q0FjZXJ0IEluYy4xHjAcBgNVBAsTFWh0dHA6Ly93d3cuQ0FjZXJ0Lm9yZzEcMBoGA1UEAxMT
+Q0FjZXJ0IENsYXNzIDMgUm9vdAIDAvQWMGwGCSqGSIb3DQEJDzFfMF0wCwYJYIZIAWUDBAEq
+MAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwIC
+AUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwbAYLKoZIhvcNAQkQAgsxXaBbMFQxFDASBgNV
+BAoTC0NBY2VydCBJbmMuMR4wHAYDVQQLExVodHRwOi8vd3d3LkNBY2VydC5vcmcxHDAaBgNV
+BAMTE0NBY2VydCBDbGFzcyAzIFJvb3QCAwL0FjANBgkqhkiG9w0BAQEFAASCAgCNhxk8PfAe
+qCu70oJfAvQ09c1SYERIaRXqYLpHT1gC0L0M/8mn+nlhvHh+suNI6yvsqafvyBRwZKp5e2K3
+dQT+irZrn0CWiwfJarGsqNoilmNbXRieRgXbSPCU6O+BTDXJmwAc9EvPO0PKeJnQH0VhVSw2
+s8IpqdHnmlf8XZ8BBVEZLUstuzzVBXtVDlvzwneKULrn07yEfLuHkVbG1addtssOhOabjy2t
+/RK+jAi00bO9KyhBedXvtbd1ugWowRSFZHqnKHccvIpsHF1P1PCPnnDuQQftlPzxoWK11gCz
+XVpJyJyuI/C007QuJFBPVaJL3kTR2tR/dnyOq8iJ8iAOQ227/9i0Xt2IYF5fBlwrWsm7VIg7
+XJb/EHAqaXNSfbo66gWBRWi9YF/mx59CGQLbS6FIlEca1T25HlXipoN8nH7SaHgHpM/rziuY
+5kkNIWB8UXZYe9XpPKTBat3dUVpKlyHVTKYq1UqyoUiFSMigicFvuz3uEBJHUMMuEuaJSUB9
+rraWm+++XlHMVfgwyDw77jws6+cI0CfOrsEtGPzLCiStnlSlKfywg5dzBSn26NXxN26Z3P/e
+PiB8aayx0hANyVd+J4TWcbd5WckAOj2kVZk9bNUFumrxMCOpoai/JOFdXU5P1yaxaTrJJICf
+nynNuW256mKMxVwYWImgCmQjngAAAAAAAA==
+------=_Part_71865141_195979751.1641369263077--
+
+------=_Part_159674792_1533625984.1641369266416--
+
+------=_Part_159674795_1647108947.1641369266420
+Content-Type: application/pkcs7-signature; name=smime.p7s; smime-type=signed-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7s"
+Content-Description: S/MIME Cryptographic Signature
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExDzANBglghkgBZQMEAgMFADCABgkqhkiG9w0BBwEAAKCC
+DMgwggY9MIIEJaADAgECAgMU4igwDQYJKoZIhvcNAQENBQAweTEQMA4GA1UEChMHUm9vdCBD
+QTEeMBwGA1UECxMVaHR0cDovL3d3dy5jYWNlcnQub3JnMSIwIAYDVQQDExlDQSBDZXJ0IFNp
+Z25pbmcgQXV0aG9yaXR5MSEwHwYJKoZIhvcNAQkBFhJzdXBwb3J0QGNhY2VydC5vcmcwHhcN
+MjEwNDE5MTIxODMwWhcNMzEwNDE3MTIxODMwWjBUMRQwEgYDVQQKEwtDQWNlcnQgSW5jLjEe
+MBwGA1UECxMVaHR0cDovL3d3dy5DQWNlcnQub3JnMRwwGgYDVQQDExNDQWNlcnQgQ2xhc3Mg
+MyBSb290MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAq0k1EUh80iZ+U5TPQ6nd
+KNdCKovzh3gZWHwPntqJfeH763KQDXShlmSrn6AkmXPa4lV2xxd79QSsRrjDvn9kjRBsJPNh
+nMDykPpR5vVpAWPDD1biSkLP4kSMJSioxXkJfUa5ivPp8zQpCEXkHJ/LlAQcgagUs5hlxEPs
+ToKNCdG9qluNktDs3pDFfwrC4+vmMVpedD6XM1nowwM9YDO/99FvR8TN7mKDUm4uCJqk2RUY
+kaaFkkewrkjrbbch7IUaaHI1q//wEF3A9JSnatU7kn5MkAV+k8Esi6SOYnQVcW4LcQPqrxU4
+mtTSBXJvjPkr61pyJfk5RuNyGz4Ew2QnIhAqik9YpwOtvrQuE+1dqkjX1X3UKntc+kYEUOTM
+DkJbjO3b8s/8lpPg2xE2VGI0OI8MYJs7l1Y4rfPSW4ugW+pOlrh819WghnBA05Ept6I8rfWM
+u88akorkNHvA2Gxf6QrCw6cgmlrfLF1SXLpH1ZvvJChwOCAv1X8pwLJBA2iSzOCczJdLRe86
+EAqrcDqYlXCtNbHqhSukHIAhMamuYHqAJkgAuAHAk2NVIpE8Vuev2zol848xVOomi4FZ+aHR
+UxHFe50D9nQR4G2xLD8shpGZcZqmd4s0YNEUtCysna+MENOfxGr4bxP8c1n3ZkJ0Horj+NzS
+b5icy0eYlUAF++kCAwEAAaOB8jCB7zAPBgNVHRMBAf8EBTADAQH/MGEGCCsGAQUFBwEBBFUw
+UzAjBggrBgEFBQcwAYYXaHR0cDovL29jc3AuQ0FjZXJ0Lm9yZy8wLAYIKwYBBQUHMAKGIGh0
+dHA6Ly93d3cuQ0FjZXJ0Lm9yZy9jbGFzczMuY3J0MEUGA1UdIAQ+MDwwOgYLKwYBBAGBkEoC
+AwEwKzApBggrBgEFBQcCARYdaHR0cDovL3d3dy5DQWNlcnQub3JnL2Nwcy5waHAwMgYDVR0f
+BCswKTAnoCWgI4YhaHR0cHM6Ly93d3cuY2FjZXJ0Lm9yZy9jbGFzczMuY3JsMA0GCSqGSIb3
+DQEBDQUAA4ICAQDGHq13XLQom9HIjUQSwL12dgSDIQf4EYJ/a8GVQsA4EbUlcI2LDMHVbP0c
+GgN8i/gGMaWd3kEp1IubhNc9wTeGcaMfW2EpHl13fbvwrbkVGRMU5jWA/6YZtDeFlEHoiMNf
+4LIGpLv4QKkdOazt6j+YBE35jPlHeXNS9ezfNJf7Pnfg3NGDiLqIc0dapqQVxA1wDQ+eSxMH
+fu8YPvmlAap5KbHnUvpTOsimf7bviaGxoU0vzmOFf6Uq6TvUwaPPChOFu5nXnGaQhOdm1FCz
+oeEtIiolaMMgsivEupgd6ErvXFjCtE2EVvdOuxZoQmySuG94zQ6z+++gs2SH8veIRDn8ueYs
+wJgk1EAsXsjuCx24Ak0muAoYxi8eS3Vujy4hc7zCA1XuqhTgmhoHUwvfRBSoZwWvRMjToUV2
+ArZ/DLmG6U/GbrC7FbS/6IC1djH+ZGTBClhtxVC2sgO/HUJPWTnRxDGL6MgqORwVYfDeQGgO
+cKizT+6R6A9PtpCeTYBsvhzucKS4BwQrDUECVIROR+qLlu12WGHnwyF7Bm/UtwvnNDKDzDWm
+5yVPfBdC/LxXA8afQn+YYPiAstn2sZwcNQQKiTEWhaT67kwJxWqYZuzIbirmy5LcI2yWwdRF
+8zxtArigu8dHwsIcQExFx0UGfztxK84rp4HWR0YosDzKZfFmnzCCBoMwggRroAMCAQICAwL0
+FjANBgkqhkiG9w0BAQ0FADBUMRQwEgYDVQQKEwtDQWNlcnQgSW5jLjEeMBwGA1UECxMVaHR0
+cDovL3d3dy5DQWNlcnQub3JnMRwwGgYDVQQDExNDQWNlcnQgQ2xhc3MgMyBSb290MB4XDTIx
+MTAxMzE1MDA0NloXDTIzMTAxMzE1MDA0NlowVzETMBEGA1UEAxMKS2FpIEVuZ2VydDEbMBkG
+CSqGSIb3DQEJARYMa2FpZUBrdWl4LmRlMSMwIQYJKoZIhvcNAQkBFhRrYWllQHRodW5kZXJi
+aXJkLm5ldDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKlAMvhWj/JTL3TYeWHi
+ElUgH7QcaKr57viBf5Hz6GliZuC7IffSD7o9FPL5Her+Y6ny/7emyzY444619SgxA6GtT2cx
+bl7usPrV8JX+ZxFQLrODQ3xmlhO1PiQGVhLxnjh/hBQYLqyuibjy77vexAAmHYyvjcvwtOeV
+JxYQn0wRWf9fXOqa5jKot9U4/gT3UnSxLtFfU9d8wjAE3EV8Q6wto0e3xKy4GkP6DWBK/LJI
+tE+wEtvP6egFJgxByuNrCIwtL6qCi15JAFUpAegnxEl6Ejn+vBKSz3GtiSPA5TuCtJx6nYy4
+C8JSIMUxXsLxXeabtRgJyb/4AP14Vjfa/bdryUTgzoNjmQ4kJJv0ltG2weyhomsL4oZIoY/V
+x9cXnBrlr+C1CU3r4d7JeB3OlJFGxC0C5uq3+OfTozdrn26fVamlAo3CnjQ/qYqPZfmjtSD7
+bVl4gYxFOlAxN6a1QpDf3dNdmXGRalj04osQ+QcaNVeLhpyqFN4dslFWMboGDkDQhRysA2la
+BxS1f99syysaiI7xNIOs6QYExf/yCo1BKEXbpTkygFJLicIv/SC/o6/9OZhwoG2tM3eSKp8V
+0P9NcIh6KZN5wZ4KYebW1y2aA+Hh+qHAgVUhb8FZyRuKKpqfrpavaQB1SAHJK0xq4Xjd7KI8
+gUTR8Z0zIg/ZJ5kVAgMBAAGjggFZMIIBVTAMBgNVHRMBAf8EAjAAMFYGCWCGSAGG+EIBDQRJ
+FkdUbyBnZXQgeW91ciBvd24gY2VydGlmaWNhdGUgZm9yIEZSRUUgaGVhZCBvdmVyIHRvIGh0
+dHA6Ly93d3cuQ0FjZXJ0Lm9yZzAOBgNVHQ8BAf8EBAMCA6gwQAYDVR0lBDkwNwYIKwYBBQUH
+AwQGCCsGAQUFBwMCBgorBgEEAYI3CgMEBgorBgEEAYI3CgMDBglghkgBhvhCBAEwMgYIKwYB
+BQUHAQEEJjAkMCIGCCsGAQUFBzABhhZodHRwOi8vb2NzcC5jYWNlcnQub3JnMDgGA1UdHwQx
+MC8wLaAroCmGJ2h0dHA6Ly9jcmwuY2FjZXJ0Lm9yZy9jbGFzczMtcmV2b2tlLmNybDAtBgNV
+HREEJjAkgQxrYWllQGt1aXguZGWBFGthaWVAdGh1bmRlcmJpcmQubmV0MA0GCSqGSIb3DQEB
+DQUAA4ICAQBEyGOkkF49VDUeSiqRn7J2QVpNTJszE5yofqw3YjUEx1GQZ3pf2dWwSUnCR3bi
+Am2/OomaBvqWVpS0nDvbFrTCF8ewoRcvQRt9AwIZDYA0mPnl9wR66L4o2/1AkvmXUAXbOilr
+9m2rAZQ8Xu6iZtCUmXR/0QoXbiJYk7ljFVd3/cjOzXAo5K1GAWwQ8jh8s06RVkSXuWORX3Pa
+mkHfRcdokp9oi2nRHrLWUWwJUj8f2zURovXDVyUZYephYfMpac9y/ySnviuTmMYsndIZm9qP
+E7IiC22mTLMNaTGjfI13n+X7jC0Oh0K7WqqmmpCb+xYFfa0G3gmnQaPuS8CcecEvL06dNOBC
+aeMrWPbR7n0IwQSWxCueg8TvGryqhK0nX0X9DyX90uxtK+Zqb+mZGJ5ovv54FsWpYfZDMWIk
+UMQs8jiFxHQi+uFtDB1UYxaN5ML15VnFH2wRzq0aHzHevn8cq5SXo5tbbpOucEqMyASiYAdj
+51TumBPhTLGV81lLHQxSY8bBTrMfaljIieps1pP/pL5LgbrmB+ZuHpXxOeHQADzNuTipdYLr
+kDwjMfjIVQEiiNPxjmOH/oEIjYZMlvZdnZyz0RYTJfAMEHhpo/VkC0m6CWxUnpy4YE4/179s
+tsSviSN77rLqdPVhtgucPn7FP1vdWi+xkNxpG/KmaCnTVjGCBFswggRXAgEBMFswVDEUMBIG
+A1UEChMLQ0FjZXJ0IEluYy4xHjAcBgNVBAsTFWh0dHA6Ly93d3cuQ0FjZXJ0Lm9yZzEcMBoG
+A1UEAxMTQ0FjZXJ0IENsYXNzIDMgUm9vdAIDAvQWMA0GCWCGSAFlAwQCAwUAoIIB0TAYBgkq
+hkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yMTExMDIwOTU2MDNaME8G
+CSqGSIb3DQEJBDFCBED769ZTTw8PcLnVMFuaVHnDUorGj9QTWjUfRdTnwvdskdCvXa4df9U0
+U5IcPrdX9QQy4COFNAX4X5070FSXRTx4MGoGCSsGAQQBgjcQBDFdMFswVDEUMBIGA1UEChML
+Q0FjZXJ0IEluYy4xHjAcBgNVBAsTFWh0dHA6Ly93d3cuQ0FjZXJ0Lm9yZzEcMBoGA1UEAxMT
+Q0FjZXJ0IENsYXNzIDMgUm9vdAIDAvQWMGwGCSqGSIb3DQEJDzFfMF0wCwYJYIZIAWUDBAEq
+MAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwIC
+AUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwbAYLKoZIhvcNAQkQAgsxXaBbMFQxFDASBgNV
+BAoTC0NBY2VydCBJbmMuMR4wHAYDVQQLExVodHRwOi8vd3d3LkNBY2VydC5vcmcxHDAaBgNV
+BAMTE0NBY2VydCBDbGFzcyAzIFJvb3QCAwL0FjANBgkqhkiG9w0BAQEFAASCAgCNhxk8PfAe
+qCu70oJfAvQ09c1SYERIaRXqYLpHT1gC0L0M/8mn+nlhvHh+suNI6yvsqafvyBRwZKp5e2K3
+dQT+irZrn0CWiwfJarGsqNoilmNbXRieRgXbSPCU6O+BTDXJmwAc9EvPO0PKeJnQH0VhVSw2
+s8IpqdHnmlf8XZ8BBVEZLUstuzzVBXtVDlvzwneKULrn07yEfLuHkVbG1addtssOhOabjy2t
+/RK+jAi00bO9KyhBedXvtbd1ugWowRSFZHqnKHccvIpsHF1P1PCPnnDuQQftlPzxoWK11gCz
+XVpJyJyuI/C007QuJFBPVaJL3kTR2tR/dnyOq8iJ8iAOQ227/9i0Xt2IYF5fBlwrWsm7VIg7
+XJb/EHAqaXNSfbo66gWBRWi9YF/mx59CGQLbS6FIlEca1T25HlXipoN8nH7SaHgHpM/rziuY
+5kkNIWB8UXZYe9XpPKTBat3dUVpKlyHVTKYq1UqyoUiFSMigicFvuz3uEBJHUMMuEuaJSUB9
+rraWm+++XlHMVfgwyDw77jws6+cI0CfOrsEtGPzLCiStnlSlKfywg5dzBSn26NXxN26Z3P/e
+PiB8aayx0hANyVd+J4TWcbd5WckAOj2kVZk9bNUFumrxMCOpoai/JOFdXU5P1yaxaTrJJICf
+nynNuW256mKMxVwYWImgCmQjngAAAAAAAA==
+------=_Part_159674795_1647108947.1641369266420--
diff --git a/comm/mail/test/browser/startup-firstrun/browser.ini b/comm/mail/test/browser/startup-firstrun/browser.ini
new file mode 100644
index 0000000000..a2b9d58688
--- /dev/null
+++ b/comm/mail/test/browser/startup-firstrun/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+prefs =
+ mail.provider.enabled=false
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_menubarCollapsed.js]
+skip-if = os == "mac"
diff --git a/comm/mail/test/browser/startup-firstrun/browser_menubarCollapsed.js b/comm/mail/test/browser/startup-firstrun/browser_menubarCollapsed.js
new file mode 100644
index 0000000000..83c321188e
--- /dev/null
+++ b/comm/mail/test/browser/startup-firstrun/browser_menubarCollapsed.js
@@ -0,0 +1,19 @@
+/* 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/. */
+
+/**
+ * Tests that the main menu will be collapsed by default if Thunderbird starts
+ * with no accounts created.
+ */
+
+"use strict";
+
+add_task(function test_main_menu_collapsed() {
+ let mainMenu = document.getElementById("toolbar-menubar");
+ Assert.equal(
+ mainMenu.getAttribute("autohide"),
+ "true",
+ "The main menu should have the autohide attribute set to true."
+ );
+});
diff --git a/comm/mail/test/browser/subscribe/browser.ini b/comm/mail/test/browser/subscribe/browser.ini
new file mode 100644
index 0000000000..58ac0c93be
--- /dev/null
+++ b/comm/mail/test/browser/subscribe/browser.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_newsFilter.js]
diff --git a/comm/mail/test/browser/subscribe/browser_newsFilter.js b/comm/mail/test/browser/subscribe/browser_newsFilter.js
new file mode 100644
index 0000000000..66ec1a0349
--- /dev/null
+++ b/comm/mail/test/browser/subscribe/browser_newsFilter.js
@@ -0,0 +1,67 @@
+/* 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 that the subscribe window for news servers has working autocomplete. */
+
+"use strict";
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var {
+ NNTP_PORT,
+ setupLocalServer,
+ setupNNTPDaemon,
+ shutdownNNTPServer,
+ startupNNTPServer,
+} = ChromeUtils.import("resource://testing-common/mozmill/NNTPHelpers.jsm");
+var {
+ check_newsgroup_displayed,
+ enter_text_in_search_box,
+ open_subscribe_window_from_context_menu,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/SubscribeWindowHelpers.jsm"
+);
+
+/**
+ * Checks that the filter in the subscribe window works correctly
+ * (shows only newsgroups matching all of several search strings
+ * separated by whitespace)
+ */
+add_task(async function test_subscribe_newsgroup_filter() {
+ var daemon = setupNNTPDaemon();
+ var remoteServer = startupNNTPServer(daemon, NNTP_PORT);
+ let server = setupLocalServer(NNTP_PORT);
+ let rootFolder = server.rootFolder;
+ await new Promise(r => setTimeout(r));
+ await open_subscribe_window_from_context_menu(rootFolder, filter_test_helper);
+ shutdownNNTPServer(remoteServer);
+
+ Assert.report(
+ false,
+ undefined,
+ undefined,
+ "Test ran to completion successfully"
+ );
+});
+
+/**
+ * Helper function (callback), needed because the subscribe window is modal.
+ *
+ * @param swc Controller for the subscribe window
+ */
+function filter_test_helper(swc) {
+ enter_text_in_search_box(swc, "subscribe empty");
+ utils.waitFor(
+ () => check_newsgroup_displayed(swc, "test.subscribe.empty"),
+ "test.subscribe.empty not in the list"
+ );
+ utils.waitFor(
+ () => !check_newsgroup_displayed(swc, "test.empty"),
+ "test.empty is in the list, but should not be"
+ );
+ utils.waitFor(
+ () => !check_newsgroup_displayed(swc, "test.subscribe.simple"),
+ "test.subscribe.simple is in the list, but should not be"
+ );
+}
diff --git a/comm/mail/test/browser/tabmail/browser.ini b/comm/mail/test/browser/tabmail/browser.ini
new file mode 100644
index 0000000000..55ed4e25b5
--- /dev/null
+++ b/comm/mail/test/browser/tabmail/browser.ini
@@ -0,0 +1,14 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_closing.js]
+[browser_customize.js]
+[browser_dragndrop.js]
+[browser_tabSwitch.js]
diff --git a/comm/mail/test/browser/tabmail/browser_closing.js b/comm/mail/test/browser/tabmail/browser_closing.js
new file mode 100644
index 0000000000..b0caa65bc0
--- /dev/null
+++ b/comm/mail/test/browser/tabmail/browser_closing.js
@@ -0,0 +1,407 @@
+/* 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 tabmail behaviour when tabs close.
+ */
+
+"use strict";
+
+var {
+ assert_selected_tab,
+ be_in_folder,
+ collapse_all_threads,
+ create_folder,
+ make_display_threaded,
+ make_message_sets_in_folders,
+ mc,
+ open_selected_message_in_new_tab,
+ open_selected_messages,
+ select_click_row,
+ switch_tab,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var gFolder;
+
+var MSGS_PER_THREAD = 3;
+
+add_setup(async function () {
+ gFolder = await create_folder("test-tabmail-closing folder");
+ await make_message_sets_in_folders(
+ [gFolder],
+ [{ msgsPerThread: MSGS_PER_THREAD }]
+ );
+});
+
+/**
+ * Test that if we open up a message in a tab from the inbox tab, that
+ * if we immediately close that tab, we switch back to the inbox tab.
+ */
+add_task(async function test_closed_single_message_tab_returns_to_inbox() {
+ await be_in_folder(gFolder);
+ make_display_threaded();
+ let inboxTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ select_click_row(0);
+ // Open a message in a new tab...
+ await open_selected_message_in_new_tab(false);
+
+ // Open a second message in a new tab...
+ await switch_tab(0);
+ select_click_row(1);
+ await open_selected_message_in_new_tab(false);
+
+ // Close the second tab
+ mc.window.document.getElementById("tabmail").closeTab(2);
+
+ // We should have gone back to the inbox tab
+ assert_selected_tab(inboxTab);
+
+ // Close the first tab
+ mc.window.document.getElementById("tabmail").closeTab(1);
+});
+
+/**
+ * Test that if we open up some message tabs from the inbox tab, and then
+ * switch around in those tabs, closing the tabs doesn't immediately jump
+ * you back to the inbox tab.
+ */
+add_task(async function test_does_not_go_to_opener_if_switched() {
+ await be_in_folder(gFolder);
+ make_display_threaded();
+
+ select_click_row(0);
+ // Open a message in a new tab...
+ await open_selected_message_in_new_tab(false);
+
+ // Open a second message in a new tab...
+ await switch_tab(0);
+ select_click_row(1);
+ await open_selected_message_in_new_tab(false);
+
+ // Switch to the first tab
+ await switch_tab(1);
+ let firstTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ // Switch back to the second tab
+ await switch_tab(2);
+
+ // Close the second tab
+ mc.window.document.getElementById("tabmail").closeTab(2);
+
+ // We should have gone back to the second tab
+ assert_selected_tab(firstTab);
+
+ // Close the first tab
+ mc.window.document.getElementById("tabmail").closeTab(1);
+});
+
+/**
+ * Test that if we open a whole thread up in message tabs, closing
+ * the last message tab takes us to the second last message tab as opposed
+ * to the inbox tab.
+ */
+add_task(async function test_opening_thread_in_tabs_closing_behaviour() {
+ await be_in_folder(gFolder);
+ make_display_threaded();
+ collapse_all_threads();
+
+ // Open a thread as a series of message tabs.
+ select_click_row(0);
+ open_selected_messages(mc);
+
+ // At this point, the last message tab should be selected already. We
+ // close that tab, and the second last message tab should be selected.
+ // We should close that tab, and the third last tab should be selected,
+ // etc.
+ for (let i = MSGS_PER_THREAD; i > 0; --i) {
+ let previousTab = mc.window.document
+ .getElementById("tabmail")
+ .tabContainer.getItemAtIndex(i - 1);
+ mc.window.document.getElementById("tabmail").closeTab(i);
+ Assert.equal(
+ previousTab,
+ mc.window.document.getElementById("tabmail").tabContainer.selectedItem,
+ "Expected tab at index " + (i - 1) + " to be selected."
+ );
+ }
+}).skip();
+
+/**
+ * @typedef {object} TestTab
+ * @property {Element} node - The tab's DOM node.
+ * @property {number} index - The tab's index.
+ * @property {object} info - The tabInfo for this tab, as used in #tabmail.
+ */
+
+/**
+ * Open some message tabs in the background from the folder tab.
+ *
+ * @param {number} numAdd - The number of tabs to add.
+ *
+ * @param {TestTab[]} An array of tab objects corresponding to all the open
+ * tabs.
+ */
+async function openTabs(numAdd) {
+ await be_in_folder(gFolder);
+ select_click_row(0);
+ for (let i = 0; i < numAdd; i++) {
+ await open_selected_message_in_new_tab(true);
+ }
+ let tabs = mc.window.document
+ .getElementById("tabmail")
+ .tabInfo.map((info, index) => {
+ return {
+ info,
+ index,
+ node: info.tabNode,
+ };
+ });
+ Assert.equal(tabs.length, numAdd + 1, "Have expected number of tabs");
+ return tabs;
+}
+
+/**
+ * Assert that a tab is closed.
+ *
+ * @param {TestTab} fromTab - The tab to close from.
+ * @param {Function} closeMethod - The (async) method to call on fromTab.node in
+ * order to perform the tab close.
+ * @param {TestTab} switchToTab - The tab we expect to switch to after closing
+ * tab.
+ * @param {TestTab[]} [closingTabs] - The tabs we expect to close after calling
+ * the closeMethod. This is just fromTab by default.
+ */
+async function assertClose(fromTab, closeMethod, switchToTab, closingTabs) {
+ let desc;
+ if (closingTabs) {
+ let closingIndices = closingTabs.map(t => t.index).join(",");
+ desc = `closing tab #${closingIndices} using tab #${fromTab.index}`;
+ } else {
+ closingTabs = [fromTab];
+ desc = `closing tab #${fromTab.index}`;
+ }
+ let numTabsBefore =
+ mc.window.document.getElementById("tabmail").tabInfo.length;
+ for (let tab of closingTabs) {
+ Assert.ok(
+ tab.node.parentNode,
+ `tab #${tab.index} should be in the DOM tree before ${desc}`
+ );
+ }
+ fromTab.node.scrollIntoView();
+ await closeMethod(fromTab.node);
+ for (let tab of closingTabs) {
+ Assert.ok(
+ !tab.node.parentNode,
+ `tab #${tab.index} should be removed from the DOM tree after ${desc}`
+ );
+ }
+ Assert.equal(
+ mc.window.document.getElementById("tabmail").tabInfo.length,
+ numTabsBefore - closingTabs.length,
+ `Number of tabs after ${desc}`
+ );
+ assert_selected_tab(
+ switchToTab.info,
+ `tab #${switchToTab.index} is selected after ${desc}`
+ );
+}
+
+/**
+ * Close a tab using its close button.
+ *
+ * @param {Element} tab - The tab to close.
+ */
+function closeWithButton(tab) {
+ EventUtils.synthesizeMouseAtCenter(
+ tab.querySelector(".tab-close-button"),
+ {},
+ tab.ownerGlobal
+ );
+}
+
+/**
+ * Close a tab using a middle mouse click.
+ *
+ * @param {Element} tab - The tab to close.
+ */
+function closeWithMiddleClick(tab) {
+ EventUtils.synthesizeMouseAtCenter(tab, { button: 1 }, tab.ownerGlobal);
+}
+
+/**
+ * Close the currently selected tab.
+ *
+ * @param {Element} tab - The tab to close.
+ */
+function closeWithKeyboard(tab) {
+ if (AppConstants.platform == "macosx") {
+ EventUtils.synthesizeKey("w", { accelKey: true }, tab.ownerGlobal);
+ } else {
+ EventUtils.synthesizeKey("w", { ctrlKey: true }, tab.ownerGlobal);
+ }
+}
+
+/**
+ * Open the context menu of a tab.
+ *
+ * @param {Element} tab - The tab to open the context menu of.
+ */
+async function openContextMenu(tab) {
+ let win = tab.ownerGlobal;
+ let contextMenu = win.document.getElementById("tabContextMenu");
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ tab,
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await shownPromise;
+}
+
+/**
+ * Close the context menu, without selecting anything.
+ *
+ * @param {Element} tab - The tab to close the context menu of.
+ */
+async function closeContextMenu(tab) {
+ let win = tab.ownerGlobal;
+ let contextMenu = win.document.getElementById("tabContextMenu");
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.hidePopup();
+ await hiddenPromise;
+}
+
+/**
+ * Open a tab's context menu and select an item.
+ *
+ * @param {Element} tab - The tab to open the context menu on.
+ * @param {string} itemId - The id of the menu item to select.
+ */
+async function selectFromContextMenu(tab, itemId) {
+ let doc = tab.ownerDocument;
+ let contextMenu = doc.getElementById("tabContextMenu");
+ let item = doc.getElementById(itemId);
+ await openContextMenu(tab);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.activateItem(item);
+ await hiddenPromise;
+}
+
+/**
+ * Close a tab using its context menu.
+ *
+ * @param {Element} tab - The tab to close.
+ */
+async function closeWithContextMenu(tab) {
+ await selectFromContextMenu(tab, "tabContextMenuClose");
+}
+
+/**
+ * Close all other tabs using a tab's context menu.
+ *
+ * @param {Element} tab - The tab to not close.
+ */
+async function closeOtherTabsWithContextMenu(tab) {
+ await selectFromContextMenu(tab, "tabContextMenuCloseOtherTabs");
+}
+
+/**
+ * Test closing unselected tabs with the mouse or keyboard.
+ */
+add_task(async function test_close_unselected_tab_methods() {
+ let tabs = await openTabs(3);
+
+ // Can't close the first tab.
+ Assert.ok(
+ BrowserTestUtils.is_hidden(tabs[0].node.querySelector(".tab-close-button")),
+ "Close button should be hidden for the first tab"
+ );
+ // Middle click does nothing.
+ closeWithMiddleClick(tabs[0].node);
+ assert_selected_tab(tabs[0].info);
+ // Keyboard shortcut does nothing.
+ closeWithKeyboard(tabs[0].node);
+ assert_selected_tab(tabs[0].info);
+ // Context close item is disabled.
+ await openContextMenu(tabs[0].node);
+ Assert.ok(
+ mc.window.document.getElementById("tabContextMenuClose").disabled,
+ "Close context menu item should be disabled for the first tab"
+ );
+ await closeContextMenu(tabs[0].node);
+
+ // Close unselected tabs. The selected tab should stay the same.
+ await assertClose(tabs[3], closeWithButton, tabs[0]);
+ await assertClose(tabs[1], closeWithMiddleClick, tabs[0]);
+ await assertClose(tabs[2], closeWithContextMenu, tabs[0]);
+ // Keyboard shortcut cannot be used to close an unselected tab.
+});
+
+/**
+ * Test closing selected tabs with the mouse or keyboard.
+ */
+add_task(async function test_close_selected_tab_methods() {
+ let tabs = await openTabs(4);
+
+ // Select tab by clicking it.
+ EventUtils.synthesizeMouseAtCenter(tabs[4].node, {}, mc.window);
+ assert_selected_tab(tabs[4].info);
+ await assertClose(tabs[4], closeWithButton, tabs[3]);
+
+ // Select tab #2 by clicking tab #3 and using the shortcut to go back.
+ EventUtils.synthesizeMouseAtCenter(tabs[3].node, {}, mc.window);
+ assert_selected_tab(tabs[3].info);
+ EventUtils.synthesizeKey(
+ "VK_TAB",
+ { ctrlKey: true, shiftKey: true },
+ mc.window
+ );
+ assert_selected_tab(tabs[2].info);
+ await assertClose(tabs[2], closeWithKeyboard, tabs[3]);
+
+ // Note: Current open tabs is: #0, #1, #2, #3.
+
+ // Select tab #1 by using the shortcut to go forward from tab #0.
+ EventUtils.synthesizeMouseAtCenter(tabs[0].node, {}, mc.window);
+ assert_selected_tab(tabs[0].info);
+ EventUtils.synthesizeKey("VK_TAB", { ctrlKey: true }, mc.window);
+ assert_selected_tab(tabs[1].info);
+ await assertClose(tabs[1], closeWithMiddleClick, tabs[3]);
+
+ // Note: Current open tabs is: #0, #3.
+ // Close tabs #3 using the context menu.
+ await assertClose(tabs[3], closeWithContextMenu, tabs[0]);
+});
+
+/**
+ * Test closing other tabs with the context menu.
+ */
+add_task(async function test_close_other_tabs() {
+ let tabs = await openTabs(3);
+
+ EventUtils.synthesizeMouseAtCenter(tabs[3].node, {}, mc.window);
+ assert_selected_tab(tabs[3].info);
+ // Close tabs #1 and #2 using the context menu of #3.
+ await assertClose(tabs[3], closeOtherTabsWithContextMenu, tabs[3], [
+ tabs[1],
+ tabs[2],
+ ]);
+
+ // Note: Current open tabs is: #0 #3.
+ // The tab #3 closeOtherItem is now disabled since only tab #0 is left, which
+ // cannot be closed.
+ await openContextMenu(tabs[3].node);
+ Assert.ok(
+ mc.window.document.getElementById("tabContextMenuCloseOtherTabs").disabled,
+ "Close context menu item should be disabled for the first tab"
+ );
+ await closeContextMenu(tabs[3].node);
+
+ // But we can close tab #3 using tab #0 context menu.
+ await assertClose(tabs[0], closeOtherTabsWithContextMenu, tabs[0], [tabs[3]]);
+});
diff --git a/comm/mail/test/browser/tabmail/browser_customize.js b/comm/mail/test/browser/tabmail/browser_customize.js
new file mode 100644
index 0000000000..d9a0fc777d
--- /dev/null
+++ b/comm/mail/test/browser/tabmail/browser_customize.js
@@ -0,0 +1,145 @@
+/* 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/. */
+
+/*
+ * Tests customization features of the tabs toolbar.
+ */
+
+"use strict";
+
+const { close_popup, mc, wait_for_popup_to_open } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+const { drag_n_drop_element } = ChromeUtils.import(
+ "resource://testing-common/mozmill/MouseEventHelpers.jsm"
+);
+
+const { click_through_appmenu } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+const { wait_for_element_visible, wait_for_element_invisible } =
+ ChromeUtils.import("resource://testing-common/mozmill/DOMHelpers.jsm");
+
+add_setup(function () {
+ Services.prefs.setBoolPref("mail.tabs.autoHide", false);
+});
+
+registerCleanupFunction(function () {
+ // Let's reset any and all of our changes to the toolbar
+ Services.prefs.clearUserPref("mail.tabs.autoHide");
+});
+
+/**
+ * Test that we can access the unified toolbar by clicking
+ * customize on the toolbar context menu
+ */
+add_task(async function test_open_unified_by_context() {
+ // First, ensure that the context menu is closed.
+ let contextPopup = mc.window.document.getElementById("toolbar-context-menu");
+ Assert.notEqual(
+ contextPopup.state,
+ "open",
+ "Context menu is currently open!"
+ );
+
+ // Right click on the tab bar.
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.getElementById("tabmail-tabs"),
+ { type: "contextmenu" },
+ window
+ );
+
+ // Ensure that the popup opened.
+ await wait_for_popup_to_open(contextPopup);
+ Assert.equal(contextPopup.state, "open", "Context menu was not opened!");
+
+ const customizeButton = document.getElementById("CustomizeMailToolbar");
+ // Click customize.
+ contextPopup.activateItem(customizeButton);
+
+ // Wait for hidden css attribute on unified toolbar
+ // customization to be removed.
+ await wait_for_element_visible(
+ window,
+ "unifiedToolbarCustomizationContainer"
+ );
+
+ // Ensure messengerWindow (HTML element) has customizingUnifiedToolbar class,
+ // which means unified toolbar customization should be open.
+ Assert.ok(
+ document
+ .getElementById("messengerWindow")
+ .classList.contains("customizingUnifiedToolbar"),
+ "customizingUnifiedToolbar class not found on messengerWindow element"
+ );
+
+ // Click cancel.
+ const cancelButton = document.getElementById(
+ "unifiedToolbarCustomizationCancel"
+ );
+ cancelButton.click();
+
+ // Wait for hidden css attribute on Unified Toolbar
+ // customization to be added.
+ await wait_for_element_invisible(
+ window,
+ "unifiedToolbarCustomizationContainer"
+ );
+
+ await close_popup(mc, contextPopup);
+});
+
+/**
+ * Test that we can access the unified toolbar customization by clicking
+ * the toolbar layout menu option
+ */
+add_task(async function test_open_unified_by_menu() {
+ // First, ensure that the menu is closed.
+ let appMenu = mc.window.document.getElementById("appMenu-popup");
+ Assert.notEqual(
+ appMenu.getAttribute("panelopen"),
+ "true",
+ "appMenu-popup is currently open!"
+ );
+
+ // Click through app menu to view unified toolbar.
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_Toolbars" }],
+ { id: "appmenu_toolbarLayout" },
+ window
+ );
+
+ // Wait for hidden css attribute on unified toolbar
+ // customization to be removed.
+ await wait_for_element_visible(
+ window,
+ "unifiedToolbarCustomizationContainer"
+ );
+
+ // Ensure messengerWindow (HTML element) has customizingUnifiedToolbar class,
+ // which means unified toolbar customization should be open.
+ Assert.ok(
+ document
+ .getElementById("messengerWindow")
+ .classList.contains("customizingUnifiedToolbar"),
+ "customizingUnifiedToolbar class not found on messengerWindow element"
+ );
+
+ // Click cancel.
+ const cancelButton = document.getElementById(
+ "unifiedToolbarCustomizationCancel"
+ );
+ cancelButton.click();
+
+ // Wait for hidden css attribute on unified toolbar
+ // customization to be added.
+ await wait_for_element_invisible(
+ window,
+ "unifiedToolbarCustomizationContainer"
+ );
+
+ await close_popup(mc, appMenu);
+});
diff --git a/comm/mail/test/browser/tabmail/browser_dragndrop.js b/comm/mail/test/browser/tabmail/browser_dragndrop.js
new file mode 100644
index 0000000000..2c09c6f621
--- /dev/null
+++ b/comm/mail/test/browser/tabmail/browser_dragndrop.js
@@ -0,0 +1,475 @@
+/* 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";
+
+// Double timeout for code coverage runs.
+if (AppConstants.MOZ_CODE_COVERAGE) {
+ requestLongerTimeout(2);
+}
+
+/*
+ * Test rearanging tabs via drag'n'drop.
+ */
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var {
+ assert_folder_selected_and_displayed,
+ assert_number_of_tabs_open,
+ assert_selected_and_displayed,
+ be_in_folder,
+ close_popup,
+ create_folder,
+ display_message_in_folder_tab,
+ get_about_3pane,
+ make_message_sets_in_folders,
+ mc,
+ open_folder_in_new_window,
+ open_selected_message_in_new_tab,
+ select_click_row,
+ switch_tab,
+ wait_for_message_display_completion,
+ wait_for_popup_to_open,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var {
+ drag_n_drop_element,
+ synthesize_drag_end,
+ synthesize_drag_start,
+ synthesize_drag_over,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/MouseEventHelpers.jsm"
+);
+var { async_plan_for_new_window, close_window, wait_for_new_window } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var folder;
+var msgHdrsInFolder = [];
+
+// The number of messages in folder.
+var NUM_MESSAGES_IN_FOLDER = 15;
+
+add_setup(async function () {
+ folder = await create_folder("MessageFolder");
+ await make_message_sets_in_folders(
+ [folder],
+ [{ count: NUM_MESSAGES_IN_FOLDER }]
+ );
+ msgHdrsInFolder = [...folder.messages];
+ folder.markAllMessagesRead(null);
+
+ await be_in_folder(folder);
+});
+
+registerCleanupFunction(async function () {
+ folder.deleteSelf(null);
+});
+
+/**
+ * Tests reordering tabs by drag'n'drop within the tabbar
+ *
+ * It opens additional movable and closable tabs. The picks the first
+ * movable tab and drops it onto the third movable tab.
+ */
+add_task(async function test_tab_reorder_tabbar() {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ // Ensure only one tab is open, otherwise our test most likey fail anyway.
+ tabmail.closeOtherTabs(0);
+ assert_number_of_tabs_open(1);
+
+ await be_in_folder(folder);
+
+ // Open four tabs
+ for (let idx = 0; idx < 4; idx++) {
+ select_click_row(idx);
+ await open_selected_message_in_new_tab(true);
+ }
+
+ // Check if every thing is correctly initialized
+ assert_number_of_tabs_open(5);
+
+ Assert.ok(
+ tabmail.tabModes.mailMessageTab.tabs[0] == tabmail.tabInfo[1],
+ " tabMode.tabs and tabInfo out of sync"
+ );
+
+ Assert.ok(
+ tabmail.tabModes.mailMessageTab.tabs[1] ==
+ mc.window.document.getElementById("tabmail").tabInfo[2],
+ " tabMode.tabs and tabInfo out of sync"
+ );
+
+ Assert.ok(
+ tabmail.tabModes.mailMessageTab.tabs[2] == tabmail.tabInfo[3],
+ " tabMode.tabs and tabInfo out of sync"
+ );
+
+ // Start dragging the first tab
+ await switch_tab(1);
+ assert_selected_and_displayed(msgHdrsInFolder[0]);
+
+ let tab1 = tabmail.tabContainer.allTabs[1];
+ let tab3 = tabmail.tabContainer.allTabs[3];
+
+ drag_n_drop_element(
+ tab1,
+ mc.window,
+ tab3,
+ mc.window,
+ 0.75,
+ 0.0,
+ tabmail.tabContainer
+ );
+
+ wait_for_message_display_completion(mc);
+
+ // if every thing went well...
+ assert_number_of_tabs_open(5);
+
+ // ... we should find tab1 at the third position...
+ Assert.equal(tab1, tabmail.tabContainer.allTabs[3], "Moving tab1 failed");
+ await switch_tab(3);
+ assert_selected_and_displayed(msgHdrsInFolder[0]);
+
+ // ... while tab3 moves one up and gets second.
+ Assert.ok(tab3 == tabmail.tabContainer.allTabs[2], "Moving tab3 failed");
+ await switch_tab(2);
+ assert_selected_and_displayed(msgHdrsInFolder[2]);
+
+ // we have one "message" tab and three "folder" tabs, thus tabInfo[1-3] and
+ // tabMode["message"].tabs[0-2] have to be same, otherwise something went
+ // wrong while moving tabs around
+ Assert.ok(
+ tabmail.tabModes.mailMessageTab.tabs[0] == tabmail.tabInfo[1],
+ " tabMode.tabs and tabInfo out of sync"
+ );
+
+ Assert.ok(
+ tabmail.tabModes.mailMessageTab.tabs[1] == tabmail.tabInfo[2],
+ " tabMode.tabs and tabInfo out of sync"
+ );
+
+ Assert.ok(
+ tabmail.tabModes.mailMessageTab.tabs[2] == tabmail.tabInfo[3],
+ " tabMode.tabs and tabInfo out of sync"
+ );
+ teardownTest();
+});
+
+/**
+ * Tests drag'n'drop tab reordering between windows
+ */
+add_task(async function test_tab_reorder_window() {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ // Ensure only one tab is open, otherwise our test most likey fail anyway.
+ tabmail.closeOtherTabs(0);
+ assert_number_of_tabs_open(1);
+
+ let mc2 = null;
+
+ await be_in_folder(folder);
+
+ // Open a new tab...
+ select_click_row(1);
+ await open_selected_message_in_new_tab(false);
+
+ assert_number_of_tabs_open(2);
+
+ await switch_tab(1);
+ assert_selected_and_displayed(msgHdrsInFolder[1]);
+
+ // ...and then a new 3 pane as our drop target.
+ mc2 = open_folder_in_new_window(folder);
+
+ // Start dragging the first tab ...
+ let tabA = tabmail.tabContainer.allTabs[1];
+ Assert.ok(tabA, "No movable Tab");
+
+ // We drop onto the Folder Tab, it is guaranteed to exist.
+ let tabmail2 = mc2.window.document.getElementById("tabmail");
+ let tabB = tabmail2.tabContainer.allTabs[0];
+ Assert.ok(tabB, "No movable Tab");
+
+ drag_n_drop_element(
+ tabA,
+ mc.window,
+ tabB,
+ mc2.window,
+ 0.75,
+ 0.0,
+ tabmail.tabContainer
+ );
+
+ wait_for_message_display_completion(mc2);
+
+ Assert.ok(
+ tabmail.tabContainer.allTabs.length == 1,
+ "Moving tab to new window failed, tab still in old window"
+ );
+
+ Assert.ok(
+ tabmail2.tabContainer.allTabs.length == 2,
+ "Moving tab to new window failed, no new tab in new window"
+ );
+
+ assert_selected_and_displayed(mc2, msgHdrsInFolder[1]);
+ teardownTest();
+});
+
+/**
+ * Tests detaching tabs into windows via drag'n'drop
+ */
+add_task(async function test_tab_reorder_detach() {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ // Ensure only one tab is open, otherwise our test most likey fail anyway.
+ tabmail.closeOtherTabs(0);
+ assert_number_of_tabs_open(1);
+
+ let mc2 = null;
+
+ await be_in_folder(folder);
+
+ // Open a new tab...
+ select_click_row(2);
+ await open_selected_message_in_new_tab(false);
+
+ assert_number_of_tabs_open(2);
+
+ // ... if every thing works we should expect a new window...
+ let newWindowPromise = async_plan_for_new_window("mail:3pane");
+
+ // ... now start dragging
+ tabmail.switchToTab(1);
+
+ let tab1 = tabmail.tabContainer.allTabs[1];
+ let dropContent = mc.window.document.getElementById("tabpanelcontainer");
+
+ let dt = synthesize_drag_start(mc.window, tab1, tabmail.tabContainer);
+
+ synthesize_drag_over(mc.window, dropContent, dt);
+
+ // notify tab1 drag has ended
+ let dropRect = dropContent.getBoundingClientRect();
+ synthesize_drag_end(mc.window, dropContent, tab1, dt, {
+ screenX: dropContent.screenX + dropRect.width / 2,
+ screenY: dropContent.screenY + dropRect.height / 2,
+ });
+
+ // ... and wait for the new window
+ mc2 = await newWindowPromise;
+ let tabmail2 = mc2.window.document.getElementById("tabmail");
+ await TestUtils.waitForCondition(
+ () => tabmail2.tabInfo[1]?.chromeBrowser,
+ "waiting for a second tab to open in the new window"
+ );
+ wait_for_message_display_completion(mc2, true);
+
+ Assert.ok(
+ tabmail.tabContainer.allTabs.length == 1,
+ "Moving tab to new window failed, tab still in old window"
+ );
+
+ Assert.ok(
+ tabmail2.tabContainer.allTabs.length == 2,
+ "Moving tab to new window failed, no new tab in new window"
+ );
+
+ assert_selected_and_displayed(mc2, msgHdrsInFolder[2]);
+ teardownTest();
+});
+
+/**
+ * Test undo of recently closed tabs.
+ */
+add_task(async function test_tab_undo() {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ // Ensure only one tab is open, otherwise our test most likey fail anyway.
+ tabmail.closeOtherTabs(0);
+ assert_number_of_tabs_open(1);
+
+ await be_in_folder(folder);
+
+ // Open five tabs...
+ for (let idx = 0; idx < 5; idx++) {
+ select_click_row(idx);
+ await open_selected_message_in_new_tab(true);
+ }
+
+ assert_number_of_tabs_open(6);
+
+ await switch_tab(2);
+ assert_selected_and_displayed(msgHdrsInFolder[1]);
+
+ tabmail.closeTab(2);
+ // This tab should not be added to recently closed tabs...
+ // ... thus it can't be restored
+ tabmail.closeTab(2, true);
+ tabmail.closeTab(2);
+
+ assert_number_of_tabs_open(3);
+ assert_selected_and_displayed(mc, msgHdrsInFolder[4]);
+
+ tabmail.undoCloseTab();
+ wait_for_message_display_completion();
+ assert_number_of_tabs_open(4);
+ assert_selected_and_displayed(mc, msgHdrsInFolder[3]);
+
+ // msgHdrsInFolder[2] won't be restored, it was closed with disabled undo.
+
+ tabmail.undoCloseTab();
+ wait_for_message_display_completion();
+ assert_number_of_tabs_open(5);
+ assert_selected_and_displayed(mc, msgHdrsInFolder[1]);
+ teardownTest();
+});
+
+async function _synthesizeRecentlyClosedMenu() {
+ let tab =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs[1];
+ EventUtils.synthesizeMouseAtCenter(
+ tab,
+ { type: "contextmenu", button: 2 },
+ tab.ownerGlobal
+ );
+
+ let tabContextMenu = mc.window.document.getElementById("tabContextMenu");
+ await wait_for_popup_to_open(tabContextMenu);
+
+ let recentlyClosedTabs = mc.window.document.getElementById(
+ "tabContextMenuRecentlyClosed"
+ );
+
+ recentlyClosedTabs.openMenu(true);
+ await wait_for_popup_to_open(recentlyClosedTabs.menupopup);
+
+ return recentlyClosedTabs;
+}
+
+async function _teardownRecentlyClosedMenu() {
+ let menu = mc.window.document.getElementById("tabContextMenu");
+ await close_popup(mc, menu);
+}
+
+/**
+ * Tests the recently closed tabs menu.
+ */
+add_task(async function test_tab_recentlyClosed() {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ // Ensure only one tab is open, otherwise our test most likey fail anyway.
+ tabmail.closeOtherTabs(0, true);
+ assert_number_of_tabs_open(1);
+
+ // We start with a clean tab history.
+ tabmail.clearRecentlyClosedTabs();
+ Assert.equal(tabmail.recentlyClosedTabs.length, 0);
+
+ // The history is cleaned so let's open 15 tabs...
+ await be_in_folder(folder);
+
+ for (let idx = 0; idx < 15; idx++) {
+ select_click_row(idx);
+ await open_selected_message_in_new_tab(true);
+ }
+
+ assert_number_of_tabs_open(16);
+
+ await switch_tab(2);
+ assert_selected_and_displayed(msgHdrsInFolder[1]);
+
+ // ... and store the tab titles, to ensure they match with the menu items.
+ let tabTitles = [];
+ for (let idx = 0; idx < 16; idx++) {
+ tabTitles.unshift(tabmail.tabInfo[idx].title);
+ }
+
+ // Start the test by closing all tabs except the first two tabs...
+ for (let idx = 0; idx < 14; idx++) {
+ tabmail.closeTab(2);
+ }
+
+ assert_number_of_tabs_open(2);
+
+ // ...then open the context menu.
+ let menu = await _synthesizeRecentlyClosedMenu();
+
+ // Check if the context menu was populated correctly...
+ Assert.ok(menu.itemCount == 12, "Failed to populate context menu");
+ for (let idx = 0; idx < 10; idx++) {
+ Assert.ok(
+ tabTitles[idx] == menu.getItemAtIndex(idx).label,
+ "Tab Title does not match Menu item"
+ );
+ }
+
+ // Restore the most recently closed tab
+ menu.menupopup.activateItem(menu.getItemAtIndex(0));
+ await _teardownRecentlyClosedMenu();
+ await new Promise(resolve => setTimeout(resolve));
+
+ wait_for_message_display_completion(mc);
+ assert_number_of_tabs_open(3);
+ assert_selected_and_displayed(msgHdrsInFolder[14]);
+
+ // The context menu should now contain one item less.
+ await _synthesizeRecentlyClosedMenu();
+
+ Assert.ok(menu.itemCount == 11, "Failed to populate context menu");
+ for (let idx = 0; idx < 9; idx++) {
+ Assert.ok(
+ tabTitles[idx + 1] == menu.getItemAtIndex(idx).label,
+ "Tab Title does not match Menu item"
+ );
+ }
+
+ // Now we restore an "random" tab.
+ menu.menupopup.activateItem(menu.getItemAtIndex(5));
+ await _teardownRecentlyClosedMenu();
+ await new Promise(resolve => setTimeout(resolve));
+
+ wait_for_message_display_completion(mc);
+ assert_number_of_tabs_open(4);
+ assert_selected_and_displayed(msgHdrsInFolder[8]);
+
+ // finally restore all tabs
+ await _synthesizeRecentlyClosedMenu();
+
+ Assert.ok(menu.itemCount == 10, "Failed to populate context menu");
+ Assert.ok(
+ tabTitles[1] == menu.getItemAtIndex(0).label,
+ "Tab Title does not match Menu item"
+ );
+ Assert.ok(
+ tabTitles[7] == menu.getItemAtIndex(5).label,
+ "Tab Title does not match Menu item"
+ );
+
+ menu.menupopup.activateItem(menu.getItemAtIndex(menu.itemCount - 1));
+ await _teardownRecentlyClosedMenu();
+ await new Promise(resolve => setTimeout(resolve));
+
+ wait_for_message_display_completion(mc);
+
+ // out of the 16 tab, we closed all except two. As the history can store
+ // only 10 items we have to endup with exactly 10 + 2 tabs.
+ assert_number_of_tabs_open(12);
+ teardownTest();
+});
+
+function teardownTest(test) {
+ // Some test cases open new windows, thus we need to ensure all
+ // opened windows get closed.
+ for (let win of Services.wm.getEnumerator("mail:3pane")) {
+ if (win != mc.window) {
+ win.close();
+ }
+ }
+
+ // clean up the tabbbar
+ mc.window.document.getElementById("tabmail").closeOtherTabs(0);
+ assert_number_of_tabs_open(1);
+}
diff --git a/comm/mail/test/browser/tabmail/browser_tabSwitch.js b/comm/mail/test/browser/tabmail/browser_tabSwitch.js
new file mode 100644
index 0000000000..38a158a66d
--- /dev/null
+++ b/comm/mail/test/browser/tabmail/browser_tabSwitch.js
@@ -0,0 +1,344 @@
+/* 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 () {
+ // Helper functions.
+
+ function assertSelected(element, name) {
+ Assert.ok(
+ element.hasAttribute("selected"),
+ `${name} has selected attribute`
+ );
+ }
+ function assertNotSelected(element, name) {
+ Assert.ok(
+ !element.hasAttribute("selected"),
+ `${name} does NOT have selected attribute`
+ );
+ }
+ function getTabElements() {
+ return [...tabmail.tabContainer.querySelectorAll("tab")];
+ }
+ function checkTabElements(expectedCount, expectedSelection) {
+ let tabElements = getTabElements();
+ Assert.equal(
+ tabElements.length,
+ expectedCount,
+ `${expectedCount} tab elements exist`
+ );
+
+ for (let i = 0; i < expectedCount; i++) {
+ if (i == expectedSelection) {
+ assertSelected(tabElements[i], `tab element ${i}`);
+ } else {
+ assertNotSelected(tabElements[i], `tab element ${i}`);
+ }
+ }
+ }
+ async function switchTab(index) {
+ let tabElement = getTabElements()[index];
+ eventPromise = BrowserTestUtils.waitForEvent(
+ tabmail.tabContainer,
+ "TabSelect"
+ );
+ EventUtils.synthesizeMouseAtCenter(tabElement, {});
+ event = await eventPromise;
+ Assert.equal(
+ event.target,
+ tabElement,
+ `TabSelect event fired from tab ${index}`
+ );
+ }
+ async function closeTab(index) {
+ let tabElement = getTabElements()[index];
+ eventPromise = BrowserTestUtils.waitForEvent(
+ tabmail.tabContainer,
+ "TabClose"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ tabElement.querySelector(".tab-close-button"),
+ {}
+ );
+ event = await eventPromise;
+ Assert.equal(
+ event.target,
+ tabElement,
+ `TabClose event fired from tab ${index}`
+ );
+ }
+
+ // Collect some elements.
+
+ let tabmail = document.getElementById("tabmail");
+ let calendarTabButton = document.getElementById("calendarButton");
+
+ let mailTabPanel = document.getElementById("mail3PaneTab1");
+ let mailTabBrowser = document.getElementById("mail3PaneTabBrowser1");
+ let folderTree = mailTabBrowser.contentDocument.getElementById("folderTree");
+ let calendarTabPanel = document.getElementById("calendarTabPanel");
+ let contentTab;
+ let contentTabPanel;
+
+ let calendarList = document.getElementById("calendar-list");
+
+ let eventPromise;
+ let event;
+
+ // Check we're in a good state to start with.
+
+ Assert.equal(tabmail.tabInfo.length, 1, "only one tab is open");
+ checkTabElements(1, 0);
+ assertSelected(mailTabPanel, "mail tab's panel");
+
+ // Set the focus on the mail tab.
+
+ folderTree.focus();
+ Assert.equal(
+ document.activeElement,
+ mailTabBrowser,
+ "mail tab's browser has focus"
+ );
+ Assert.equal(
+ mailTabBrowser.contentDocument.activeElement,
+ folderTree,
+ "folder tree has focus"
+ );
+
+ // Switch to the calendar tab.
+
+ eventPromise = BrowserTestUtils.waitForEvent(tabmail.tabContainer, "TabOpen");
+ EventUtils.synthesizeMouseAtCenter(calendarTabButton, {});
+ event = await eventPromise;
+ Assert.equal(
+ event.target,
+ getTabElements()[1],
+ "TabOpen event fired from tab 1"
+ );
+
+ checkTabElements(2, 1);
+ assertNotSelected(mailTabPanel, "mail tab's panel");
+ assertSelected(calendarTabPanel, "calendar tab's panel");
+ Assert.equal(
+ document.activeElement,
+ document.body,
+ "mail tab's browser does NOT have focus"
+ );
+ Assert.equal(
+ tabmail.tabInfo[0].lastActiveElement,
+ folderTree,
+ "mail tab's last active element should be stored"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[1].lastActiveElement,
+ "calendar tab's last active element should not be stored yet"
+ );
+
+ // Set the focus on the calendar list.
+
+ EventUtils.synthesizeMouseAtCenter(calendarList, {});
+ Assert.equal(document.activeElement, calendarList, "calendar list has focus");
+
+ // Switch to the mail tab.
+
+ await switchTab(0);
+
+ checkTabElements(2, 0);
+ assertSelected(mailTabPanel, "mail tab's panel");
+ assertNotSelected(calendarTabPanel, "calendar tab's panel");
+ Assert.equal(
+ document.activeElement,
+ mailTabBrowser,
+ "mail tab's browser has focus"
+ );
+ Assert.equal(
+ mailTabBrowser.contentDocument.activeElement,
+ folderTree,
+ "folder tree has focus"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[0].lastActiveElement,
+ "mail tab's last active element should have been cleaned up"
+ );
+ Assert.equal(
+ tabmail.tabInfo[1].lastActiveElement,
+ calendarList,
+ "calendar tab's last active element should be stored"
+ );
+
+ // Switch to the calendar tab.
+
+ await switchTab(1);
+
+ checkTabElements(2, 1);
+ assertNotSelected(mailTabPanel, "mail tab's panel");
+ assertSelected(calendarTabPanel, "calendar tab's panel");
+ Assert.equal(document.activeElement, calendarList, "calendar list has focus");
+ Assert.equal(
+ tabmail.tabInfo[0].lastActiveElement,
+ folderTree,
+ "mail tab's last active element should be stored"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[1].lastActiveElement,
+ "calendar tab's last active element should have been cleaned up"
+ );
+
+ // Open a content tab.
+
+ eventPromise = BrowserTestUtils.waitForEvent(tabmail.tabContainer, "TabOpen");
+ contentTab = window.openContentTab("https://example.org/");
+ contentTabPanel = contentTab.browser.closest(
+ ".contentTabInstance"
+ ).parentNode;
+ event = await eventPromise;
+ Assert.equal(
+ event.target,
+ getTabElements()[2],
+ "TabOpen event fired from tab 2"
+ );
+
+ checkTabElements(3, 2);
+ assertNotSelected(mailTabPanel, "mail tab's panel");
+ assertNotSelected(calendarTabPanel, "calendar tab's panel");
+ assertSelected(contentTabPanel, "content tab's panel");
+ Assert.equal(
+ document.activeElement,
+ document.body,
+ "folder tree and calendar list do NOT have focus"
+ );
+ Assert.equal(
+ tabmail.tabInfo[0].lastActiveElement,
+ folderTree,
+ "mail tab's last active element should be stored"
+ );
+ Assert.equal(
+ tabmail.tabInfo[1].lastActiveElement,
+ calendarList,
+ "calendar tab's last active element should be stored"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[2].lastActiveElement,
+ "content tab should have no last active element"
+ );
+
+ // Switch to the mail tab.
+
+ await switchTab(0);
+
+ checkTabElements(3, 0);
+ assertSelected(mailTabPanel, "mail tab's panel");
+ assertNotSelected(calendarTabPanel, "calendar tab's panel");
+ Assert.equal(
+ document.activeElement,
+ mailTabBrowser,
+ "mail tab's browser has focus"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[0].lastActiveElement,
+ "mail tab's last active element should be cleaned up"
+ );
+ Assert.equal(
+ tabmail.tabInfo[1].lastActiveElement,
+ calendarList,
+ "calendar tab's last active element should be stored"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[2].lastActiveElement,
+ "content tab should have no last active element"
+ );
+
+ // Switch to the calendar tab.
+
+ await switchTab(1);
+
+ checkTabElements(3, 1);
+ assertNotSelected(mailTabPanel, "mail tab's panel");
+ assertSelected(calendarTabPanel, "calendar tab's panel");
+ Assert.equal(document.activeElement, calendarList, "calendar list has focus");
+ Assert.equal(
+ tabmail.tabInfo[0].lastActiveElement,
+ folderTree,
+ "mail tab's last active element should be stored"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[1].lastActiveElement,
+ "calendar tab's last active element should be cleaned up"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[2].lastActiveElement,
+ "content tab should have no last active element"
+ );
+
+ // Switch to the content tab.
+
+ await switchTab(2);
+
+ checkTabElements(3, 2);
+ assertNotSelected(mailTabPanel, "mail tab's panel");
+ assertNotSelected(calendarTabPanel, "calendar tab's panel");
+ assertSelected(contentTabPanel, "content tab's panel");
+ Assert.equal(
+ document.activeElement,
+ document.body,
+ "folder tree and calendar list do NOT have focus"
+ );
+ Assert.equal(
+ tabmail.tabInfo[0].lastActiveElement,
+ folderTree,
+ "mail tab's last active element should be stored"
+ );
+ Assert.equal(
+ tabmail.tabInfo[1].lastActiveElement,
+ calendarList,
+ "calendar tab's last active element should be stored"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[2].lastActiveElement,
+ "content tab should have no last active element"
+ );
+
+ // Close the content tab.
+
+ await closeTab(2);
+
+ checkTabElements(2, 1);
+ assertNotSelected(mailTabPanel, "mail tab's panel");
+ assertSelected(calendarTabPanel, "calendar tab's panel");
+ // At this point contentTabPanel is still part of the DOM, it is removed
+ // after the TabClose event.
+ assertNotSelected(contentTabPanel, "content tab's panel");
+ Assert.equal(document.activeElement, calendarList, "calendar list has focus");
+ Assert.equal(
+ tabmail.tabInfo[0].lastActiveElement,
+ folderTree,
+ "mail tab's last active element should be stored"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[1].lastActiveElement,
+ "calendar tab's last active element should have been cleaned up"
+ );
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.ok(
+ !contentTabPanel.parentNode,
+ "content tab's panel is removed from the DOM"
+ );
+
+ // Close the calendar tab.
+
+ await closeTab(1);
+
+ checkTabElements(1, 0);
+ assertSelected(mailTabPanel, "mail tab's panel");
+ assertNotSelected(calendarTabPanel, "calendar tab's panel");
+ Assert.equal(
+ document.activeElement,
+ mailTabBrowser,
+ "mail tab's browser has focus"
+ );
+ Assert.ok(
+ !tabmail.tabInfo[0].lastActiveElement,
+ "mail tab's last active element should have been cleaned up"
+ );
+});
diff --git a/comm/mail/test/browser/utils/browser.ini b/comm/mail/test/browser/utils/browser.ini
new file mode 100644
index 0000000000..a3393b0c79
--- /dev/null
+++ b/comm/mail/test/browser/utils/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+prefs =
+ mail.addr_book.useNewAddressBook=false
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+subsuite = thunderbird
+
+[browser_extensionSupport.js]
+support-files = html/collections.html
diff --git a/comm/mail/test/browser/utils/browser_extensionSupport.js b/comm/mail/test/browser/utils/browser_extensionSupport.js
new file mode 100644
index 0000000000..2a64fedc27
--- /dev/null
+++ b/comm/mail/test/browser/utils/browser_extensionSupport.js
@@ -0,0 +1,171 @@
+/* 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/. */
+
+/**
+ * Tests ExtensionSupport.jsm functions.
+ */
+
+var { close_compose_window, open_compose_new_mail } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ComposeHelpers.jsm"
+);
+var {
+ plan_for_new_window,
+ plan_for_window_close,
+ wait_for_new_window,
+ wait_for_window_close,
+} = ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+
+/**
+ * Bug 1450288
+ * Test ExtensionSupport.registerWindowListener and ExtensionSupport.unregisterWindowListener.
+ */
+add_task(function test_windowListeners() {
+ // There may be some pre-existing listeners already set up, e.g. mozmill ones.
+ let originalListenerCount = ExtensionSupport.registeredWindowListenerCount;
+
+ let addonRunCount = [];
+ addonRunCount.load = new Map();
+ addonRunCount.unload = new Map();
+
+ function addonListener(aAddon, aEvent) {
+ if (!addonRunCount[aEvent].has(aAddon)) {
+ addonRunCount[aEvent].set(aAddon, 0);
+ }
+ addonRunCount[aEvent].set(aAddon, addonRunCount[aEvent].get(aAddon) + 1);
+ }
+
+ function addonCount(aAddon, aEvent) {
+ if (!addonRunCount[aEvent].has(aAddon)) {
+ return 0;
+ }
+
+ return addonRunCount[aEvent].get(aAddon);
+ }
+
+ // Extension listening to all windows and all events.
+ Assert.ok(
+ ExtensionSupport.registerWindowListener("test-addon1", {
+ onLoadWindow() {
+ addonListener("test-addon1", "load");
+ },
+ onUnloadWindow() {
+ addonListener("test-addon1", "unload");
+ },
+ })
+ );
+
+ Assert.equal(addonCount("test-addon1", "load"), 2);
+
+ // Extension listening to compose window only.
+ Assert.ok(
+ ExtensionSupport.registerWindowListener("test-addon2", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow() {
+ addonListener("test-addon2", "load");
+ },
+ onUnloadWindow() {
+ addonListener("test-addon2", "unload");
+ },
+ })
+ );
+
+ let cwc = open_compose_new_mail();
+
+ Assert.equal(addonCount("test-addon1", "load"), 3);
+ Assert.equal(addonCount("test-addon2", "load"), 1);
+
+ // Extension listening to compose window once while it is already open.
+ Assert.ok(
+ ExtensionSupport.registerWindowListener("test-addon3", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow() {
+ addonListener("test-addon3", "load");
+ ExtensionSupport.unregisterWindowListener("test-addon3");
+ },
+ })
+ );
+
+ Assert.equal(addonCount("test-addon3", "load"), 1);
+
+ // Extension listening to compose window while it is already open.
+ Assert.ok(
+ ExtensionSupport.registerWindowListener("test-addon4", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow() {
+ addonListener("test-addon4", "load");
+ },
+ onUnloadWindow() {
+ addonListener("test-addon4", "unload");
+ ExtensionSupport.unregisterWindowListener("test-addon4");
+ },
+ })
+ );
+
+ Assert.equal(addonCount("test-addon4", "load"), 1);
+
+ close_compose_window(cwc);
+
+ Assert.equal(addonCount("test-addon1", "unload"), 1);
+ Assert.equal(addonCount("test-addon2", "unload"), 1);
+ Assert.equal(addonCount("test-addon3", "unload"), 0);
+ Assert.equal(addonCount("test-addon4", "unload"), 1);
+
+ cwc = open_compose_new_mail();
+
+ Assert.equal(addonCount("test-addon1", "load"), 4);
+ // Addon3 didn't listen to the new compose window, addon2 did.
+ Assert.equal(addonCount("test-addon2", "load"), 2);
+ Assert.equal(addonCount("test-addon3", "load"), 1);
+
+ close_compose_window(cwc);
+
+ Assert.equal(addonCount("test-addon1", "unload"), 2);
+ Assert.equal(addonCount("test-addon2", "unload"), 2);
+ Assert.equal(addonCount("test-addon3", "unload"), 0);
+
+ plan_for_new_window("Activity:Manager");
+ window.openActivityMgr();
+ let amController = wait_for_new_window("Activity:Manager");
+
+ // Only Addon1 listens to any window.
+ Assert.equal(addonCount("test-addon1", "load"), 5);
+ Assert.equal(addonCount("test-addon2", "load"), 2);
+ Assert.equal(addonCount("test-addon3", "load"), 1);
+ Assert.equal(addonCount("test-addon4", "load"), 1);
+
+ plan_for_window_close(amController);
+ amController.window.close();
+ wait_for_window_close(amController);
+
+ Assert.equal(addonCount("test-addon1", "unload"), 3);
+ Assert.equal(addonCount("test-addon2", "unload"), 2);
+ Assert.equal(addonCount("test-addon3", "unload"), 0);
+ Assert.equal(addonCount("test-addon4", "unload"), 1);
+
+ // Registering with some invalid data should fail.
+ Assert.ok(!ExtensionSupport.registerWindowListener("", {}));
+ Assert.ok(!ExtensionSupport.registerWindowListener("test-addon1", {}));
+ Assert.ok(!ExtensionSupport.registerWindowListener("test-addon5", {}));
+ Assert.ok(!ExtensionSupport.unregisterWindowListener(""));
+ Assert.ok(!ExtensionSupport.unregisterWindowListener("test-addon5"));
+
+ // Clean up addon registrations. addon3 unregistered itself already.
+ Assert.ok(ExtensionSupport.unregisterWindowListener("test-addon1"));
+ Assert.ok(ExtensionSupport.unregisterWindowListener("test-addon2"));
+ Assert.ok(!ExtensionSupport.unregisterWindowListener("test-addon3"));
+ Assert.equal(
+ ExtensionSupport.registeredWindowListenerCount,
+ originalListenerCount
+ );
+});
diff --git a/comm/mail/test/browser/utils/html/collections.html b/comm/mail/test/browser/utils/html/collections.html
new file mode 100644
index 0000000000..e51ee98216
--- /dev/null
+++ b/comm/mail/test/browser/utils/html/collections.html
@@ -0,0 +1,26 @@
+<html>
+ <head>
+ <title>Collections</title>
+ <script>
+ const JS_HAS_SYMBOLS = typeof Symbol === "function";
+ const ITERATOR_SYMBOL = JS_HAS_SYMBOLS ? Symbol.iterator : "@@iterator";
+ var gIterator = (function* () {
+ yield 1; yield 2; yield 3; yield 4; yield 5;
+ })();
+
+ var gCustomIterator = {
+ _array: [6, 7, 8, 9],
+ *[ITERATOR_SYMBOL]() {
+ for (var i = 0; i < this._array.length; ++i) {
+ yield this._array[i];
+ }
+ },
+ };
+ </script>
+ </head>
+ <body>
+ I have two collections defined - gIterator is an iterator, and contains
+ [1, 2, 3, 4, 5], and gCustomIterator is an object with an @@iterator
+ method that contains [6, 7, 8, 9].
+ </body>
+</html>
diff --git a/comm/mail/test/marionette/manifest.ini b/comm/mail/test/marionette/manifest.ini
new file mode 100644
index 0000000000..20a610032a
--- /dev/null
+++ b/comm/mail/test/marionette/manifest.ini
@@ -0,0 +1 @@
+[test_empty.py]
diff --git a/comm/mail/test/marionette/moz.build b/comm/mail/test/marionette/moz.build
new file mode 100644
index 0000000000..e4758a591b
--- /dev/null
+++ b/comm/mail/test/marionette/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# 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/.
+
+MARIONETTE_UNIT_MANIFESTS += ["manifest.ini"]
diff --git a/comm/mail/test/marionette/test_empty.py b/comm/mail/test/marionette/test_empty.py
new file mode 100644
index 0000000000..00ab55de30
--- /dev/null
+++ b/comm/mail/test/marionette/test_empty.py
@@ -0,0 +1,10 @@
+# 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/.
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestMain(MarionetteTestCase):
+ def test_empty(self):
+ return
diff --git a/comm/mail/test/static/.eslintrc.js b/comm/mail/test/static/.eslintrc.js
new file mode 100644
index 0000000000..2faf85ea4e
--- /dev/null
+++ b/comm/mail/test/static/.eslintrc.js
@@ -0,0 +1,11 @@
+"use strict";
+
+const browserTestConfig = require("eslint-plugin-mozilla/lib/configs/browser-test.js");
+
+module.exports = {
+ ...browserTestConfig,
+ rules: {
+ ...browserTestConfig.rules,
+ "func-names": "off",
+ },
+};
diff --git a/comm/mail/test/static/browser.ini b/comm/mail/test/static/browser.ini
new file mode 100644
index 0000000000..d1a6656170
--- /dev/null
+++ b/comm/mail/test/static/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+skip-if = debug
+subsuite = thunderbird
+
+[browser_parsable_css.js]
+support-files =
+ dummy_page.html
+[browser_parsable_script.js]
diff --git a/comm/mail/test/static/browser_parsable_css.js b/comm/mail/test/static/browser_parsable_css.js
new file mode 100644
index 0000000000..6e2269e9b9
--- /dev/null
+++ b/comm/mail/test/static/browser_parsable_css.js
@@ -0,0 +1,573 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+SimpleTest.requestCompleteLog();
+
+/* This list allows pre-existing or 'unfixable' CSS issues to remain, while we
+ * detect newly occurring issues in shipping CSS. It is a list of objects
+ * specifying conditions under which an error should be ignored.
+ *
+ * Every property of the objects in it needs to consist of a regular expression
+ * matching the offending error. If an object has multiple regex criteria, they
+ * ALL need to match an error in order for that error not to cause a test
+ * failure. */
+let whitelist = [
+ // CodeMirror is imported as-is, see bug 1004423.
+ { sourceName: /codemirror\.css$/i, isFromDevTools: true },
+ {
+ sourceName: /devtools\/content\/debugger\/src\/components\/([A-z\/]+).css/i,
+ isFromDevTools: true,
+ },
+ // Highlighter CSS uses a UA-only pseudo-class, see bug 985597.
+ {
+ sourceName: /highlighters\.css$/i,
+ errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i,
+ isFromDevTools: true,
+ },
+ // UA-only media features.
+ {
+ sourceName: /\b(autocomplete-item)\.css$/,
+ errorMessage: /Expected media feature name but found \u2018-moz.*/i,
+ isFromDevTools: false,
+ platforms: ["windows"],
+ },
+ {
+ sourceName:
+ /\b(contenteditable|EditorOverride|svg|forms|html|mathml|ua)\.css$/i,
+ errorMessage: /Unknown pseudo-class.*-moz-/i,
+ isFromDevTools: false,
+ },
+ {
+ sourceName:
+ /\b(scrollbars|xul|html|mathml|ua|forms|svg|manageDialog|autocomplete-item-shared|formautofill)\.css$/i,
+ errorMessage: /Unknown property.*-moz-/i,
+ isFromDevTools: false,
+ },
+ {
+ sourceName: /(scrollbars|xul)\.css$/i,
+ errorMessage: /Unknown pseudo-class.*-moz-/i,
+ isFromDevTools: false,
+ },
+ // Reserved to UA sheets unless layout.css.overflow-clip-box.enabled flipped to true.
+ {
+ sourceName: /(?:res|gre-resources)\/forms\.css$/i,
+ errorMessage: /Unknown property.*overflow-clip-box/i,
+ isFromDevTools: false,
+ },
+ // These variables are declared somewhere else, and error when we load the
+ // files directly. They're all marked intermittent because their appearance
+ // in the error console seems to not be consistent.
+ {
+ sourceName: /jsonview\/css\/general\.css$/i,
+ intermittent: true,
+ errorMessage: /Property contained reference to invalid variable.*color/i,
+ isFromDevTools: true,
+ },
+ // PDF.js uses a property that is currently only supported in chrome.
+ {
+ sourceName: /web\/viewer\.css$/i,
+ errorMessage:
+ /Unknown property ‘text-size-adjust’\. {2}Declaration dropped\./i,
+ isFromDevTools: false,
+ },
+ // PDF.js uses a property that is currently only supported in chrome.
+ {
+ sourceName: /web\/viewer\.css$/i,
+ errorMessage:
+ /Unknown property ‘forced-color-adjust’\. {2}Declaration dropped\./i,
+ isFromDevTools: false,
+ },
+ {
+ sourceName: /overlay\.css$/i,
+ errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i,
+ isFromDevTools: false,
+ },
+];
+
+if (!Services.prefs.getBoolPref("layout.css.color-mix.enabled")) {
+ // Reserved to UA sheets unless layout.css.color-mix.enabled flipped to true.
+ whitelist.push({
+ sourceName: /\b(autocomplete-item)\.css$/,
+ errorMessage: /Expected color but found \u2018color-mix\u2019./i,
+ isFromDevTools: false,
+ platforms: ["windows"],
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.math-depth.enabled")) {
+ // mathml.css UA sheet rule for math-depth.
+ whitelist.push({
+ sourceName: /\b(scrollbars|mathml)\.css$/i,
+ errorMessage: /Unknown property .*\bmath-depth\b/i,
+ isFromDevTools: false,
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.math-style.enabled")) {
+ // mathml.css UA sheet rule for math-style.
+ whitelist.push({
+ sourceName: /(?:res|gre-resources)\/mathml\.css$/i,
+ errorMessage: /Unknown property .*\bmath-style\b/i,
+ isFromDevTools: false,
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.scroll-anchoring.enabled")) {
+ whitelist.push({
+ sourceName: /webconsole\.css$/i,
+ errorMessage: /Unknown property .*\boverflow-anchor\b/i,
+ isFromDevTools: true,
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.forced-colors.enabled")) {
+ whitelist.push({
+ sourceName: /pdf\.js\/web\/viewer\.css$/,
+ errorMessage: /Expected media feature name but found ‘forced-colors’*/i,
+ isFromDevTools: false,
+ });
+}
+
+let propNameWhitelist = [
+ // These custom properties are retrieved directly from CSSOM
+ // in videocontrols.xml to get pre-defined style instead of computed
+ // dimensions, which is why they are not referenced by CSS.
+ { propName: "--clickToPlay-width", isFromDevTools: false },
+ { propName: "--playButton-width", isFromDevTools: false },
+ { propName: "--muteButton-width", isFromDevTools: false },
+ { propName: "--castingButton-width", isFromDevTools: false },
+ { propName: "--closedCaptionButton-width", isFromDevTools: false },
+ { propName: "--fullscreenButton-width", isFromDevTools: false },
+ { propName: "--durationSpan-width", isFromDevTools: false },
+ { propName: "--durationSpan-width-long", isFromDevTools: false },
+ { propName: "--positionDurationBox-width", isFromDevTools: false },
+ { propName: "--positionDurationBox-width-long", isFromDevTools: false },
+
+ // These variables are used in a shorthand, but the CSS parser deletes the values
+ // when expanding the shorthands. See https://github.com/w3c/csswg-drafts/issues/2515
+ { propName: "--bezier-diagonal-color", isFromDevTools: true },
+ { propName: "--bezier-grid-color", isFromDevTools: true },
+];
+
+let thunderbirdWhitelist = [];
+
+// Add suffix to stylesheets' URI so that we always load them here and
+// have them parsed. Add a random number so that even if we run this
+// test multiple times, it would be unlikely to affect each other.
+const kPathSuffix = "?always-parse-css-" + Math.random();
+
+function dumpWhitelistItem(item) {
+ return JSON.stringify(item, (key, value) => {
+ return value instanceof RegExp ? value.toString() : value;
+ });
+}
+
+/**
+ * Check if an error should be ignored due to matching one of the whitelist
+ * objects defined in whitelist
+ *
+ * @param aErrorObject the error to check
+ * @returns true if the error should be ignored, false otherwise.
+ */
+function ignoredError(aErrorObject) {
+ for (let list of [whitelist, thunderbirdWhitelist]) {
+ for (let whitelistItem of list) {
+ let matches = true;
+ let catchAll = true;
+ for (let prop of ["sourceName", "errorMessage"]) {
+ if (whitelistItem.hasOwnProperty(prop)) {
+ catchAll = false;
+ if (!whitelistItem[prop].test(aErrorObject[prop] || "")) {
+ matches = false;
+ break;
+ }
+ }
+ }
+ if (catchAll) {
+ ok(
+ false,
+ "A whitelist item is catching all errors. " +
+ dumpWhitelistItem(whitelistItem)
+ );
+ continue;
+ }
+ if (matches) {
+ whitelistItem.used = true;
+ let { sourceName, errorMessage } = aErrorObject;
+ info(
+ `Ignored error "${errorMessage}" on ${sourceName} ` +
+ "because of whitelist item " +
+ dumpWhitelistItem(whitelistItem)
+ );
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+var gChromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+);
+var gChromeMap = new Map();
+
+var resHandler = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+var gResourceMap = [];
+function trackResourcePrefix(prefix) {
+ let uri = Services.io.newURI("resource://" + prefix + "/");
+ gResourceMap.unshift([prefix, resHandler.resolveURI(uri)]);
+}
+trackResourcePrefix("gre");
+trackResourcePrefix("app");
+
+function getBaseUriForChromeUri(chromeUri) {
+ let chromeFile = chromeUri + "gobbledygooknonexistentfile.reallynothere";
+ let uri = Services.io.newURI(chromeFile);
+ let fileUri = gChromeReg.convertChromeURL(uri);
+ return fileUri.resolve(".");
+}
+
+function parseManifest(manifestUri) {
+ return fetchFile(manifestUri.spec).then(data => {
+ for (let line of data.split("\n")) {
+ let [type, ...argv] = line.split(/\s+/);
+ if (type == "content" || type == "skin") {
+ let chromeUri = `chrome://${argv[0]}/${type}/`;
+ gChromeMap.set(getBaseUriForChromeUri(chromeUri), chromeUri);
+ } else if (type == "resource") {
+ trackResourcePrefix(argv[0]);
+ }
+ }
+ });
+}
+
+function convertToCodeURI(fileUri) {
+ let baseUri = fileUri;
+ let path = "";
+ while (true) {
+ let slashPos = baseUri.lastIndexOf("/", baseUri.length - 2);
+ if (slashPos <= 0) {
+ // File not accessible from chrome protocol, try resource://
+ for (let res of gResourceMap) {
+ if (fileUri.startsWith(res[1])) {
+ return fileUri.replace(res[1], "resource://" + res[0] + "/");
+ }
+ }
+ // Give up and return the original URL.
+ return fileUri;
+ }
+ path = baseUri.slice(slashPos + 1) + path;
+ baseUri = baseUri.slice(0, slashPos + 1);
+ if (gChromeMap.has(baseUri)) {
+ return gChromeMap.get(baseUri) + path;
+ }
+ }
+}
+
+function messageIsCSSError(msg) {
+ // Only care about CSS errors generated by our iframe:
+ if (
+ msg instanceof Ci.nsIScriptError &&
+ msg.category.includes("CSS") &&
+ msg.sourceName.endsWith(kPathSuffix)
+ ) {
+ let sourceName = msg.sourceName.slice(0, -kPathSuffix.length);
+ let msgInfo = { sourceName, errorMessage: msg.errorMessage };
+ // Check if this error is whitelisted in whitelist
+ if (!ignoredError(msgInfo)) {
+ ok(false, `Got error message for ${sourceName}: ${msg.errorMessage}`);
+ return true;
+ }
+ }
+ return false;
+}
+
+let imageURIsToReferencesMap = new Map();
+let customPropsToReferencesMap = new Map();
+
+function neverMatches(mediaList) {
+ const perPlatformMediaQueryMap = {
+ macosx: ["(-moz-platform: macos)"],
+ win: [
+ "(-moz-platform: windows)",
+ "(-moz-platform: windows-win7)",
+ "(-moz-platform: windows-win8)",
+ "(-moz-platform: windows-win10)",
+ ],
+ linux: ["(-moz-platform: linux)"],
+ android: ["(-moz-platform: android)"],
+ };
+ for (let platform in perPlatformMediaQueryMap) {
+ if (platform === AppConstants.platform) {
+ continue;
+ }
+ if (perPlatformMediaQueryMap[platform].includes(mediaList.mediaText)) {
+ // This query only matches on another platform that isn't ours.
+ return true;
+ }
+ }
+ return false;
+}
+
+function processCSSRules(sheet) {
+ for (let rule of sheet.cssRules) {
+ if (rule.media && neverMatches(rule.media)) {
+ continue;
+ }
+ if (
+ CSSConditionRule.isInstance(rule) ||
+ CSSKeyframesRule.isInstance(rule)
+ ) {
+ processCSSRules(rule);
+ continue;
+ }
+ if (!CSSStyleRule.isInstance(rule) && !CSSKeyframeRule.isInstance(rule)) {
+ continue;
+ }
+
+ // Extract urls from the css text.
+ // Note: CSSRule.cssText always has double quotes around URLs even
+ // when the original CSS file didn't.
+ let urls = rule.cssText.match(/url\("[^"]*"\)/g);
+ // Extract props by searching all "--" preceded by "var(" or a non-word
+ // character.
+ let props = rule.cssText.match(/(var\(|\W)(--[\w\-]+)/g);
+ if (!urls && !props) {
+ continue;
+ }
+
+ for (let url of urls || []) {
+ // Remove the url(" prefix and the ") suffix.
+ url = url.replace(/url\("(.*)"\)/, "$1");
+ if (url.startsWith("data:")) {
+ continue;
+ }
+
+ // Make the url absolute and remove the ref.
+ let baseURI = Services.io.newURI(rule.parentStyleSheet.href);
+ url = Services.io.newURI(url, null, baseURI).specIgnoringRef;
+
+ // Store the image url along with the css file referencing it.
+ let baseUrl = baseURI.spec.split("?always-parse-css")[0];
+ if (!imageURIsToReferencesMap.has(url)) {
+ imageURIsToReferencesMap.set(url, new Set([baseUrl]));
+ } else {
+ imageURIsToReferencesMap.get(url).add(baseUrl);
+ }
+ }
+
+ for (let prop of props || []) {
+ if (prop.startsWith("var(")) {
+ prop = prop.substring(4);
+ let prevValue = customPropsToReferencesMap.get(prop) || 0;
+ customPropsToReferencesMap.set(prop, prevValue + 1);
+ } else {
+ // Remove the extra non-word character captured by the regular
+ // expression.
+ prop = prop.substring(1);
+ if (!customPropsToReferencesMap.has(prop)) {
+ customPropsToReferencesMap.set(prop, undefined);
+ }
+ }
+ }
+ }
+}
+
+function chromeFileExists(aURI) {
+ let available = 0;
+ try {
+ let channel = NetUtil.newChannel({
+ uri: aURI,
+ loadUsingSystemPrincipal: true,
+ });
+ let stream = channel.open();
+ let sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ sstream.init(stream);
+ available = sstream.available();
+ sstream.close();
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) {
+ dump("Checking " + aURI + ": " + e + "\n");
+ console.error(e);
+ }
+ }
+ return available > 0;
+}
+
+add_task(async function checkAllTheCSS() {
+ // Since we later in this test use Services.console.getMessageArray(),
+ // better to not have some messages from previous tests in the array.
+ Services.console.reset();
+
+ let appDir = Services.dirsvc.get("GreD", Ci.nsIFile);
+ // This asynchronously produces a list of URLs (sadly, mostly sync on our
+ // test infrastructure because it runs against jarfiles there, and
+ // our zipreader APIs are all sync)
+ let uris = await generateURIsFromDirTree(appDir, [".css", ".manifest"]);
+
+ // Create a clean iframe to load all the files into. This needs to live at a
+ // chrome URI so that it's allowed to load and parse any styles.
+ let testFile = getRootDirectory(gTestPath) + "dummy_page.html";
+ let { HiddenFrame } = ChromeUtils.importESModule(
+ "resource://gre/modules/HiddenFrame.sys.mjs"
+ );
+ let hiddenFrame = new HiddenFrame();
+ let win = await hiddenFrame.get();
+ let iframe = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "html:iframe"
+ );
+ win.document.documentElement.appendChild(iframe);
+ let iframeLoaded = BrowserTestUtils.waitForEvent(iframe, "load", true);
+ iframe.contentWindow.location = testFile;
+ await iframeLoaded;
+ let doc = iframe.contentWindow.document;
+ iframe.contentWindow.docShell.cssErrorReportingEnabled = true;
+
+ // Parse and remove all manifests from the list.
+ // NOTE that this must be done before filtering out devtools paths
+ // so that all chrome paths can be recorded.
+ let manifestURIs = [];
+ uris = uris.filter(uri => {
+ if (uri.pathQueryRef.endsWith(".manifest")) {
+ manifestURIs.push(uri);
+ return false;
+ }
+ return true;
+ });
+ // Wait for all manifest to be parsed
+ await throttledMapPromises(manifestURIs, parseManifest);
+
+ // filter out either the devtools paths or the non-devtools paths:
+ let isDevtools = SimpleTest.harnessParameters.subsuite == "devtools";
+ let devtoolsPathBits = ["devtools"];
+ uris = uris.filter(
+ uri => isDevtools == devtoolsPathBits.some(path => uri.spec.includes(path))
+ );
+
+ let loadCSS = chromeUri =>
+ new Promise(resolve => {
+ let linkEl, onLoad, onError;
+ onLoad = e => {
+ processCSSRules(linkEl.sheet);
+ resolve();
+ linkEl.removeEventListener("load", onLoad);
+ linkEl.removeEventListener("error", onError);
+ };
+ onError = e => {
+ ok(
+ false,
+ "Loading " + linkEl.getAttribute("href") + " threw an error!"
+ );
+ resolve();
+ linkEl.removeEventListener("load", onLoad);
+ linkEl.removeEventListener("error", onError);
+ };
+ linkEl = doc.createElement("link");
+ linkEl.setAttribute("rel", "stylesheet");
+ linkEl.setAttribute("type", "text/css");
+ linkEl.addEventListener("load", onLoad);
+ linkEl.addEventListener("error", onError);
+ linkEl.setAttribute("href", chromeUri + kPathSuffix);
+ doc.head.appendChild(linkEl);
+ });
+
+ // We build a list of promises that get resolved when their respective
+ // files have loaded and produced no errors.
+ const kInContentCommonCSS = "chrome://global/skin/in-content/common.css";
+ let allPromises = uris
+ .map(uri => convertToCodeURI(uri.spec))
+ .filter(uri => uri !== kInContentCommonCSS);
+
+ // Make sure chrome://global/skin/in-content/common.css is loaded before other
+ // stylesheets in order to guarantee the --in-content variables can be
+ // correctly referenced.
+ if (allPromises.length !== uris.length) {
+ await loadCSS(kInContentCommonCSS);
+ }
+
+ // Wait for all the files to have actually loaded:
+ await throttledMapPromises(allPromises, loadCSS);
+
+ // Check if all the files referenced from CSS actually exist.
+ for (let [image, references] of imageURIsToReferencesMap) {
+ if (!chromeFileExists(image)) {
+ for (let ref of references) {
+ let whitelisted = false;
+ for (let whitelistItem of thunderbirdWhitelist) {
+ if (whitelistItem.sourceName.test(ref)) {
+ whitelistItem.used = true;
+ whitelisted = true;
+ info("missing " + image + " referenced from " + ref);
+ break;
+ }
+ }
+ if (!whitelisted) {
+ ok(false, "missing " + image + " referenced from " + ref);
+ }
+ }
+ }
+ }
+
+ // Check if all the properties that are defined are referenced.
+ for (let [prop, refCount] of customPropsToReferencesMap) {
+ if (!refCount) {
+ let ignored = false;
+ for (let item of propNameWhitelist) {
+ if (item.propName == prop && isDevtools == item.isFromDevTools) {
+ item.used = true;
+ if (
+ !item.platforms ||
+ item.platforms.includes(AppConstants.platform)
+ ) {
+ ignored = true;
+ }
+ break;
+ }
+ }
+ if (!ignored) {
+ info("custom property `" + prop + "` is not referenced");
+ }
+ }
+ }
+
+ let messages = Services.console.getMessageArray();
+ // Count errors (the test output will list actual issues for us, as well
+ // as the ok(false) in messageIsCSSError.
+ let errors = messages.filter(messageIsCSSError);
+ is(
+ errors.length,
+ 0,
+ "All the styles (" + allPromises.length + ") loaded without errors."
+ );
+
+ // Confirm that all whitelist rules have been used.
+ function checkWhitelist(list) {
+ for (let item of list) {
+ if (
+ !item.used &&
+ isDevtools == item.isFromDevTools &&
+ (!item.platforms || item.platforms.includes(AppConstants.platform)) &&
+ !item.intermittent
+ ) {
+ ok(false, "Unused whitelist item: " + dumpWhitelistItem(item));
+ }
+ }
+ }
+ checkWhitelist(thunderbirdWhitelist);
+
+ // Clean up to avoid leaks:
+ doc.head.innerHTML = "";
+ doc = null;
+ iframe.remove();
+ iframe = null;
+ win = null;
+ hiddenFrame.destroy();
+ hiddenFrame = null;
+ imageURIsToReferencesMap = null;
+ customPropsToReferencesMap = null;
+});
diff --git a/comm/mail/test/static/browser_parsable_script.js b/comm/mail/test/static/browser_parsable_script.js
new file mode 100644
index 0000000000..bc46465b3d
--- /dev/null
+++ b/comm/mail/test/static/browser_parsable_script.js
@@ -0,0 +1,169 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This list allows pre-existing or 'unfixable' JS issues to remain, while we
+ * detect newly occurring issues in shipping JS. It is a list of regexes
+ * matching files which have errors:
+ */
+
+requestLongerTimeout(2);
+SimpleTest.requestCompleteLog();
+
+const kWhitelist = new Set([
+ /browser\/content\/browser\/places\/controller.js$/,
+]);
+
+const kESModuleList = new Set([
+ /browser\/res\/payments\/(components|containers|mixins)\/.*\.js$/,
+ /browser\/res\/payments\/paymentRequest\.js$/,
+ /browser\/res\/payments\/PaymentsStore\.js$/,
+ /browser\/aboutlogins\/components\/.*\.js$/,
+ /browser\/aboutlogins\/.*\.js$/,
+ /browser\/protections.js$/,
+ /browser\/lockwise-card.js$/,
+ /browser\/monitor-card.js$/,
+ /browser\/proxy-card.js$/,
+ /toolkit\/content\/global\/certviewer\/components\/.*\.js$/,
+ /toolkit\/content\/global\/certviewer\/.*\.js$/,
+ /chrome\/pdfjs\/content\/web\/.*\.js$/,
+]);
+
+// Normally we would use reflect.jsm to get Reflect.parse. However, if
+// we do that, then all the AST data is allocated in reflect.jsm's
+// zone. That exposes a bug in our GC. The GC collects reflect.jsm's
+// zone but not the zone in which our test code lives (since no new
+// data is being allocated in it). The cross-compartment wrappers in
+// our zone that point to the AST data never get collected, and so the
+// AST data itself is never collected. We need to GC both zones at
+// once to fix the problem.
+const init = Cc["@mozilla.org/jsreflect;1"].createInstance();
+init();
+
+/**
+ * Check if an error should be ignored due to matching one of the whitelist
+ * objects defined in kWhitelist
+ *
+ * @param uri the uri to check against the whitelist
+ * @returns true if the uri should be skipped, false otherwise.
+ */
+function uriIsWhiteListed(uri) {
+ for (let whitelistItem of kWhitelist) {
+ if (whitelistItem.test(uri.spec)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Check if a URI should be parsed as an ES module.
+ *
+ * @param uri the uri to check against the ES module list
+ * @returns true if the uri should be parsed as a module, otherwise parse it as a script.
+ */
+function uriIsESModule(uri) {
+ for (let whitelistItem of kESModuleList) {
+ if (whitelistItem.test(uri.spec)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function parsePromise(uri, parseTarget) {
+ let promise = new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", uri, true);
+ xhr.onreadystatechange = function () {
+ if (this.readyState == this.DONE) {
+ let scriptText = this.responseText;
+ try {
+ info(`Checking ${parseTarget} ${uri}`);
+ let parseOpts = {
+ source: uri,
+ target: parseTarget,
+ };
+ Reflect.parse(scriptText, parseOpts);
+ resolve(true);
+ } catch (ex) {
+ let errorMsg = "Script error reading " + uri + ": " + ex;
+ ok(false, errorMsg);
+ resolve(false);
+ }
+ }
+ };
+ xhr.onerror = error => {
+ ok(false, "XHR error reading " + uri + ": " + error);
+ resolve(false);
+ };
+ xhr.overrideMimeType("application/javascript");
+ xhr.send(null);
+ });
+ return promise;
+}
+
+add_task(async function checkAllTheJS() {
+ // In debug builds, even on a fast machine, collecting the file list may take
+ // more than 30 seconds, and parsing all files may take four more minutes.
+ // For this reason, this test must be explicitly requested in debug builds by
+ // using the "--setpref parse=<filter>" argument to mach. You can specify:
+ // - A case-sensitive substring of the file name to test (slow).
+ // - A single absolute URI printed out by a previous run (fast).
+ // - An empty string to run the test on all files (slowest).
+ let parseRequested = Services.prefs.prefHasUserValue("parse");
+ let parseValue = parseRequested && Services.prefs.getCharPref("parse");
+ if (SpecialPowers.isDebugBuild) {
+ if (!parseRequested) {
+ ok(
+ true,
+ "Test disabled on debug build. To run, execute: ./mach" +
+ " mochitest-browser --setpref parse=<case_sensitive_filter>" +
+ " browser/base/content/test/general/browser_parsable_script.js"
+ );
+ return;
+ }
+ // Request a 15 minutes timeout (30 seconds * 30) for debug builds.
+ requestLongerTimeout(30);
+ }
+
+ let uris;
+ // If an absolute URI is specified on the command line, use it immediately.
+ if (parseValue && parseValue.includes(":")) {
+ uris = [NetUtil.newURI(parseValue)];
+ } else {
+ let appDir = Services.dirsvc.get("GreD", Ci.nsIFile);
+ // This asynchronously produces a list of URLs (sadly, mostly sync on our
+ // test infrastructure because it runs against jarfiles there, and
+ // our zipreader APIs are all sync)
+ let startTimeMs = Date.now();
+ info("Collecting URIs");
+ uris = await generateURIsFromDirTree(appDir, [".js", ".jsm"]);
+ info("Collected URIs in " + (Date.now() - startTimeMs) + "ms");
+
+ // Apply the filter specified on the command line, if any.
+ if (parseValue) {
+ uris = uris.filter(uri => {
+ if (uri.spec.includes(parseValue)) {
+ return true;
+ }
+ info("Not checking filtered out " + uri.spec);
+ return false;
+ });
+ }
+ }
+
+ // We create an array of promises so we can parallelize all our parsing
+ // and file loading activity:
+ await throttledMapPromises(uris, uri => {
+ if (uriIsWhiteListed(uri)) {
+ info("Not checking whitelisted " + uri.spec);
+ return undefined;
+ }
+ let target = "script";
+ if (uriIsESModule(uri)) {
+ target = "module";
+ }
+ return parsePromise(uri.spec, target);
+ });
+ ok(true, "All files parsed");
+});
diff --git a/comm/mail/test/static/dummy_page.html b/comm/mail/test/static/dummy_page.html
new file mode 100644
index 0000000000..1a87e28408
--- /dev/null
+++ b/comm/mail/test/static/dummy_page.html
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>Dummy test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<p>Dummy test page</p>
+</body>
+</html>
diff --git a/comm/mail/test/static/head.js b/comm/mail/test/static/head.js
new file mode 100644
index 0000000000..a1cce8735a
--- /dev/null
+++ b/comm/mail/test/static/head.js
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* Shorthand constructors to construct an nsI(Local)File and zip reader: */
+const LocalFile = new Components.Constructor(
+ "@mozilla.org/file/local;1",
+ Ci.nsIFile,
+ "initWithPath"
+);
+const ZipReader = new Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+);
+
+/**
+ * Returns a promise that is resolved with a list of files that have one of the
+ * extensions passed, represented by their nsIURI objects, which exist inside
+ * the directory passed.
+ *
+ * @param dir the directory which to scan for files (nsIFile)
+ * @param extensions the extensions of files we're interested in (Array).
+ */
+function generateURIsFromDirTree(dir, extensions) {
+ if (!Array.isArray(extensions)) {
+ extensions = [extensions];
+ }
+ let dirQueue = [dir.path];
+ return (async function () {
+ let rv = [];
+ while (dirQueue.length) {
+ let nextDir = dirQueue.shift();
+ let { subdirs, files } = await iterateOverPath(nextDir, extensions);
+ dirQueue.push(...subdirs);
+ rv.push(...files);
+ }
+ return rv;
+ })();
+}
+
+/**
+ * Iterates over all entries of a directory.
+ * It returns a promise that is resolved with an object with two properties:
+ * - files: an array of nsIURIs corresponding to files that match the extensions passed
+ * - subdirs: an array of paths for subdirectories we need to recurse into
+ * (handled by generateURIsFromDirTree above)
+ *
+ * @param path the path to check (string)
+ * @param extensions the file extensions we're interested in.
+ */
+async function iterateOverPath(path, extensions) {
+ let parentDir = new LocalFile(path);
+ let subdirs = [];
+ let files = [];
+
+ // Iterate through the directory
+ for (let childPath of await IOUtils.getChildren(path)) {
+ let stat = await IOUtils.stat(childPath);
+ if (stat.type === "directory") {
+ subdirs.push(childPath);
+ } else if (extensions.some(extension => childPath.endsWith(extension))) {
+ let file = parentDir.clone();
+ file.append(PathUtils.filename(childPath));
+ // the build system might leave dead symlinks hanging around, which are
+ // returned as part of the directory iterator, but don't actually exist:
+ if (file.exists()) {
+ let uriSpec = getURLForFile(file);
+ files.push(Services.io.newURI(uriSpec));
+ }
+ } else if (
+ childPath.endsWith(".ja") ||
+ childPath.endsWith(".jar") ||
+ childPath.endsWith(".zip") ||
+ childPath.endsWith(".xpi")
+ ) {
+ let file = parentDir.clone();
+ file.append(PathUtils.filename(childPath));
+ for (let extension of extensions) {
+ let jarEntryIterator = generateEntriesFromJarFile(file, extension);
+ files.push(...jarEntryIterator);
+ }
+ }
+ }
+ return { files, subdirs };
+}
+
+/* Helper function to generate a URI spec (NB: not an nsIURI yet!)
+ * given an nsIFile object */
+function getURLForFile(file) {
+ let fileHandler = Services.io.getProtocolHandler("file");
+ fileHandler = fileHandler.QueryInterface(Ci.nsIFileProtocolHandler);
+ return fileHandler.getURLSpecFromActualFile(file);
+}
+
+/**
+ * A generator that generates nsIURIs for particular files found in jar files
+ * like omni.ja.
+ *
+ * @param jarFile an nsIFile object for the jar file that needs checking.
+ * @param extension the extension we're interested in.
+ */
+function* generateEntriesFromJarFile(jarFile, extension) {
+ let zr = new ZipReader(jarFile);
+ const kURIStart = getURLForFile(jarFile);
+
+ for (let entry of zr.findEntries("*" + extension + "$")) {
+ // Ignore the JS cache which is stored in omni.ja
+ if (entry.startsWith("jsloader") || entry.startsWith("jssubloader")) {
+ continue;
+ }
+ let entryURISpec = "jar:" + kURIStart + "!/" + entry;
+ yield Services.io.newURI(entryURISpec);
+ }
+ zr.close();
+}
+
+function fetchFile(uri) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.responseType = "text";
+ xhr.open("GET", uri, true);
+ xhr.onreadystatechange = function () {
+ if (this.readyState != this.DONE) {
+ return;
+ }
+ try {
+ resolve(this.responseText);
+ } catch (ex) {
+ ok(false, `Script error reading ${uri}: ${ex}`);
+ resolve("");
+ }
+ };
+ xhr.onerror = error => {
+ ok(false, `XHR error reading ${uri}: ${error}`);
+ resolve("");
+ };
+ xhr.send(null);
+ });
+}
+
+async function throttledMapPromises(iterable, task, limit = 64) {
+ let promises = new Set();
+ for (let data of iterable) {
+ while (promises.size >= limit) {
+ await Promise.race(promises);
+ }
+
+ let promise = task(data);
+ if (promise) {
+ promise.finally(() => promises.delete(promise));
+ promises.add(promise);
+ }
+ }
+
+ await Promise.all(promises);
+}
diff --git a/comm/mail/test/static/moz.build b/comm/mail/test/static/moz.build
new file mode 100644
index 0000000000..168aad77c5
--- /dev/null
+++ b/comm/mail/test/static/moz.build
@@ -0,0 +1,8 @@
+# vim: set filetype=python:
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser.ini",
+]
diff --git a/comm/mail/testsuite-targets.mk b/comm/mail/testsuite-targets.mk
new file mode 100644
index 0000000000..bcf0c7cf25
--- /dev/null
+++ b/comm/mail/testsuite-targets.mk
@@ -0,0 +1,20 @@
+# 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/.
+
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+# Mac options
+APP_NAME = $(MOZ_APP_DISPLAYNAME)
+ifdef MOZ_DEBUG
+APP_NAME := $(APP_NAME)Debug
+endif
+BINARY = $(DIST)/$(APP_NAME).app/
+ABS_BINARY = $(abspath $(DIST))/$(APP_NAME).app/
+else
+# Non-mac options
+BINARY = $(DIST)/bin/thunderbird$(BIN_SUFFIX)
+ABS_BINARY = $(abspath $(BINARY))
+endif
+
+check-no-solo = $(foreach solo,SOLO_TEST SOLO_FILE,$(if $($(solo)),$(error $(subst SOLOVAR,$(solo),$(1)))))
+find-solo-test = $(if $(and $(SOLO_TEST),$(SOLO_FILE)),$(error Both SOLO_TEST and SOLO_FILE are specified. You may only specify one.),$(if $(SOLO_TEST),$(SOLO_TEST),$(if $(SOLO_FILE),$(SOLO_FILE),$(error SOLO_TEST or SOLO_FILE needs to be specified.))))
diff --git a/comm/mail/themes/BuiltInThemes.sys.mjs b/comm/mail/themes/BuiltInThemes.sys.mjs
new file mode 100644
index 0000000000..1409f49f52
--- /dev/null
+++ b/comm/mail/themes/BuiltInThemes.sys.mjs
@@ -0,0 +1,109 @@
+/* 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 lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+// List of themes built in to the browser. The themes are represented by objects
+// containing their id, current version, and path relative to
+// resource://builtin-themes/.
+const STANDARD_THEMES = new Map([
+ [
+ "thunderbird-compact-light@mozilla.org",
+ {
+ version: "1.2",
+ path: "light/",
+ },
+ ],
+ [
+ "thunderbird-compact-dark@mozilla.org",
+ {
+ version: "1.2",
+ path: "dark/",
+ },
+ ],
+]);
+
+class _BuiltInThemes {
+ constructor() {}
+
+ /**
+ * @param {string} id An addon's id string.
+ * @returns {string}
+ * If `id` refers to a built-in theme, returns a path pointing to the
+ * theme's preview image. Null otherwise.
+ */
+ previewForBuiltInThemeId(id) {
+ if (STANDARD_THEMES.has(id)) {
+ return `resource://builtin-themes/${
+ STANDARD_THEMES.get(id).path
+ }preview.svg`;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param {string} id An addon's id string.
+ * @returns {boolean}
+ * True if the theme with id `id` is a monochromatic theme.
+ */
+ isMonochromaticTheme(id) {
+ return id.endsWith("-colorway@mozilla.org");
+ }
+
+ /**
+ * @param {string} id
+ * The theme's id.
+ * @returns {boolean}
+ * True if the theme with id `id` is both expired and retained. That is,
+ * the user has the ability to use it after its expiry date.
+ * Or it would - this is just a shim not to break assumptions...
+ */
+ isRetainedExpiredTheme(id) {
+ return false;
+ }
+
+ /**
+ * If the active theme is built-in, this function calls
+ * AddonManager.maybeInstallBuiltinAddon for that theme.
+ */
+ maybeInstallActiveBuiltInTheme() {
+ let activeThemeID = Services.prefs.getStringPref(
+ "extensions.activeThemeID",
+ "default-theme@mozilla.org"
+ );
+ let activeBuiltInTheme = STANDARD_THEMES.get(activeThemeID);
+ if (activeBuiltInTheme) {
+ lazy.AddonManager.maybeInstallBuiltinAddon(
+ activeThemeID,
+ activeBuiltInTheme.version,
+ `resource://builtin-themes/${activeBuiltInTheme.path}`
+ );
+ }
+ }
+
+ /**
+ * Ensures that all built-in themes are installed.
+ */
+ async ensureBuiltInThemes() {
+ let installPromises = [];
+ for (let [id, { version, path }] of STANDARD_THEMES.entries()) {
+ installPromises.push(
+ lazy.AddonManager.maybeInstallBuiltinAddon(
+ id,
+ version,
+ `resource://builtin-themes/${path}`
+ )
+ );
+ }
+
+ await Promise.all(installPromises);
+ }
+}
+
+export var BuiltInThemes = new _BuiltInThemes();
diff --git a/comm/mail/themes/ThemeVariableMap.sys.mjs b/comm/mail/themes/ThemeVariableMap.sys.mjs
new file mode 100644
index 0000000000..fb7ccb048c
--- /dev/null
+++ b/comm/mail/themes/ThemeVariableMap.sys.mjs
@@ -0,0 +1,194 @@
+/* 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/. */
+
+export const ThemeVariableMap = [
+ [
+ "--lwt-accent-color-inactive",
+ {
+ lwtProperty: "accentcolorInactive",
+ },
+ ],
+ [
+ "--lwt-background-alignment",
+ {
+ isColor: false,
+ lwtProperty: "backgroundsAlignment",
+ },
+ ],
+ [
+ "--lwt-background-tiling",
+ {
+ isColor: false,
+ lwtProperty: "backgroundsTiling",
+ },
+ ],
+ [
+ "--tab-loading-fill",
+ {
+ lwtProperty: "tab_loading",
+ },
+ ],
+ [
+ "--lwt-tab-text",
+ {
+ lwtProperty: "tab_text",
+ },
+ ],
+ [
+ "--lwt-tab-line-color",
+ {
+ lwtProperty: "tab_line",
+ },
+ ],
+ [
+ "--lwt-background-tab-separator-color",
+ {
+ lwtProperty: "tab_background_separator",
+ },
+ ],
+ [
+ "--toolbar-bgcolor",
+ {
+ lwtProperty: "toolbarColor",
+ },
+ ],
+ [
+ "--toolbar-color",
+ {
+ lwtProperty: "toolbar_text",
+ },
+ ],
+ [
+ "--lwt-tabs-border-color",
+ {
+ lwtProperty: "toolbar_top_separator",
+ },
+ ],
+ [
+ "--toolbarseparator-color",
+ {
+ lwtProperty: "toolbar_vertical_separator",
+ },
+ ],
+ [
+ "--chrome-content-separator-color",
+ {
+ lwtProperty: "toolbar_bottom_separator",
+ },
+ ],
+ [
+ "--toolbarbutton-icon-fill",
+ {
+ lwtProperty: "icon_color",
+ },
+ ],
+ [
+ "--lwt-toolbarbutton-icon-fill-attention",
+ {
+ lwtProperty: "icon_attention_color",
+ },
+ ],
+ [
+ "--lwt-toolbarbutton-hover-background",
+ {
+ lwtProperty: "button_background_hover",
+ },
+ ],
+ [
+ "--lwt-toolbarbutton-active-background",
+ {
+ lwtProperty: "button_background_active",
+ },
+ ],
+ [
+ "--lwt-selected-tab-background-color",
+ {
+ lwtProperty: "tab_selected",
+ },
+ ],
+ [
+ "--arrowpanel-border-color",
+ {
+ lwtProperty: "popup_border",
+ },
+ ],
+ [
+ "--autocomplete-popup-highlight-background",
+ {
+ lwtProperty: "popup_highlight",
+ },
+ ],
+ [
+ "--autocomplete-popup-highlight-color",
+ {
+ lwtProperty: "popup_highlight_text",
+ },
+ ],
+ [
+ "--sidebar-background-color",
+ {
+ lwtProperty: "sidebar",
+ },
+ ],
+ [
+ "--sidebar-text-color",
+ {
+ lwtProperty: "sidebar_text",
+ processColor(rgbaChannels, element) {
+ if (!rgbaChannels) {
+ element.removeAttribute("lwt-tree");
+ element.removeAttribute("lwt-tree-brighttext");
+ return null;
+ }
+
+ const { r, g, b } = rgbaChannels;
+ let luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b;
+ let brighttext = luminance > 110;
+
+ element.setAttribute("lwt-tree", "true");
+ if (!brighttext) {
+ element.removeAttribute("lwt-tree-brighttext");
+ } else {
+ element.setAttribute("lwt-tree-brighttext", "true");
+ }
+
+ // Drop alpha channel.
+ return `rgb(${r}, ${g}, ${b})`;
+ },
+ },
+ ],
+ [
+ "--sidebar-highlight-background-color",
+ {
+ lwtProperty: "sidebar_highlight",
+ },
+ ],
+ [
+ "--sidebar-highlight-text-color",
+ {
+ lwtProperty: "sidebar_highlight_text",
+ },
+ ],
+ [
+ "--sidebar-border-color",
+ {
+ lwtProperty: "sidebar_border",
+ },
+ ],
+ [
+ "--sidebar-highlight-border-color",
+ {
+ lwtProperty: "sidebar_highlight_border",
+ },
+ ],
+];
+
+export const ThemeContentPropertyList = [
+ "ntp_background",
+ "ntp_text",
+ "sidebar",
+ "sidebar_highlight",
+ "sidebar_highlight_text",
+ "sidebar_text",
+];
diff --git a/comm/mail/themes/Windows8WindowFrameColor.sys.mjs b/comm/mail/themes/Windows8WindowFrameColor.sys.mjs
new file mode 100644
index 0000000000..f676e37978
--- /dev/null
+++ b/comm/mail/themes/Windows8WindowFrameColor.sys.mjs
@@ -0,0 +1,55 @@
+/* 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 { WindowsRegistry } from "resource://gre/modules/WindowsRegistry.sys.mjs";
+
+export var Windows8WindowFrameColor = {
+ _windowFrameColor: null,
+
+ get() {
+ if (this._windowFrameColor) {
+ return this._windowFrameColor;
+ }
+
+ const HKCU = Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER;
+ const dwmKey = "Software\\Microsoft\\Windows\\DWM";
+ let customizationColor = WindowsRegistry.readRegKey(
+ HKCU,
+ dwmKey,
+ "ColorizationColor"
+ );
+ if (customizationColor == undefined) {
+ // Seems to be the default color (hardcoded because of bug 1065998)
+ return [158, 158, 158];
+ }
+
+ // The color returned from the Registry is in decimal form.
+ let customizationColorHex = customizationColor.toString(16);
+
+ // Zero-pad the number just to make sure that it is 8 digits.
+ customizationColorHex = ("00000000" + customizationColorHex).substr(-8);
+ let customizationColorArray = customizationColorHex.match(/../g);
+ let [, fgR, fgG, fgB] = customizationColorArray.map(val =>
+ parseInt(val, 16)
+ );
+ let colorizationColorBalance = WindowsRegistry.readRegKey(
+ HKCU,
+ dwmKey,
+ "ColorizationColorBalance"
+ );
+ if (colorizationColorBalance == undefined) {
+ colorizationColorBalance = 78;
+ }
+
+ // Window frame base color when Color Intensity is at 0, see bug 1004576.
+ let frameBaseColor = 217;
+ let alpha = colorizationColorBalance / 100;
+
+ // Alpha-blend the foreground color with the frame base color.
+ let r = Math.round(fgR * alpha + frameBaseColor * (1 - alpha));
+ let g = Math.round(fgG * alpha + frameBaseColor * (1 - alpha));
+ let b = Math.round(fgB * alpha + frameBaseColor * (1 - alpha));
+ return (this._windowFrameColor = [r, g, b]);
+ },
+};
diff --git a/comm/mail/themes/addons/dark/experiment.css b/comm/mail/themes/addons/dark/experiment.css
new file mode 100644
index 0000000000..e9a68dfe62
--- /dev/null
+++ b/comm/mail/themes/addons/dark/experiment.css
@@ -0,0 +1,6 @@
+/* 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/. */
+
+/* Dark theme */
+@import url("chrome://messenger/skin/compacttheme.css");
diff --git a/comm/mail/themes/addons/dark/icon.svg b/comm/mail/themes/addons/dark/icon.svg
new file mode 100644
index 0000000000..c43145632b
--- /dev/null
+++ b/comm/mail/themes/addons/dark/icon.svg
@@ -0,0 +1,13 @@
+<!-- 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" width="32" height="32" viewBox="0 0 32 32">
+ <path fill="#0c0c0d" d="M2 2h14v13H2z"/>
+ <path fill="#323234" d="M16 2v13H2v15h28V2H16z"/>
+ <rect x="1" y="1" width="30" height="30" rx="2" ry="2" fill="none" stroke="#08091a" stroke-opacity=".35" stroke-width="2"/>
+ <circle cx="9.5" cy="22.5" r="6" fill="#474749" stroke="#08091a"/>
+ <path d="M12.5 22H7.7l2.15-2.15a.5.5 0 00-.7-.7l-3 3a.5.5 0 000 .7l3 3a.5.5 0 10.7-.7L7.71 23h4.79a.5.5 0 000-1zM20.5 20h4a.5.5 0 000-1h-4a.5.5 0 000 1zm4 2h-4a.5.5 0 000 1h4a.5.5 0 000-1zm0 3h-4a.5.5 0 000 1h4a.5.5 0 000-1z" fill="#f9f9fa" fill-opacity=".8"/>
+ <path fill="#0a84ff" d="M16 2h14v1H16z"/>
+ <path d="M26.35 8.65l-3.5-3.5a.5.5 0 00-.7 0l-3.5 3.5a.5.5 0 00.7.7l.65-.64v2.79a.5.5 0 00.5.5h4a.5.5 0 00.5-.5V8.7l.65.65a.5.5 0 10.7-.7zM24 11h-1V9h-1v2h-1V7.7l1.5-1.5L24 7.7z" fill="#f9f9fa" fill-opacity=".8"/>
+ <path fill="#08091a" d="M15 2v12H2v1h14V2h-1z"/>
+</svg>
diff --git a/comm/mail/themes/addons/dark/manifest.json b/comm/mail/themes/addons/dark/manifest.json
new file mode 100644
index 0000000000..d7d175ba93
--- /dev/null
+++ b/comm/mail/themes/addons/dark/manifest.json
@@ -0,0 +1,73 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "thunderbird-compact-dark@mozilla.org"
+ }
+ },
+
+ "name": "Dark",
+ "description": "A theme with a dark color scheme.",
+ "author": "Mozilla",
+ "version": "1.3",
+
+ "icons": { "32": "icon.svg" },
+
+ "theme": {
+ "colors": {
+ "tab_background_text": "#fafafa",
+ "icons": "#fafafa",
+ "frame": "#18181b",
+ "popup": "#27272a",
+ "popup_text": "#f4f4f5",
+ "popup_border": "#71717a",
+ "popup_highlight": "#2493ef",
+ "tab_line": "#2493ef",
+ "toolbar": "#3f3f46",
+ "toolbar_bottom_separator": "#18181b",
+ "toolbar_field": "#3f3f46",
+ "toolbar_field_border": "#71717a",
+ "toolbar_field_border_focus": "#2493ef",
+ "toolbar_field_text": "#f4f4f5",
+ "toolbar_field_focus": "#52525b",
+ "sidebar": "#27272a",
+ "sidebar_text": "#d4d4d8",
+ "sidebar_highlight": "#2493ef",
+ "sidebar_highlight_text": "#fff",
+ "sidebar_highlight_border": "#4cb1f9",
+ "sidebar_border": "#3f3f46",
+ "button": "rgb(63, 62, 71)",
+ "button_hover": "rgb(82, 82, 94)",
+ "button_active": "rgb(91, 91, 102)",
+ "error_text_color": "#fca5a5",
+ "input_background": "#3f3f46",
+ "input_color": "#f4f4f5",
+ "input_border": "#71717a"
+ },
+ "properties": {
+ "color_scheme": "dark",
+ "panel_hover": "color-mix(in srgb, currentColor 10%, transparent)",
+ "panel_active": "color-mix(in srgb, currentColor 14%, transparent)",
+ "panel_active_darker": "color-mix(in srgb, currentColor 25%, transparent)"
+ }
+ },
+
+ "theme_experiment": {
+ "stylesheet": "experiment.css",
+ "colors": {
+ "button": "--button-background-color",
+ "button_hover": "--button-hover-background-color",
+ "button_active": "--button-active-background-color",
+ "error_text_color": "--error-text-color",
+ "input_background": "--input-bgcolor",
+ "input_color": "--input-color",
+ "input_border": "--input-border-color"
+ },
+ "properties": {
+ "panel_hover": "--arrowpanel-dimmed",
+ "panel_active": "--arrowpanel-dimmed-further",
+ "panel_active_darker": "--arrowpanel-dimmed-even-further"
+ }
+ }
+}
diff --git a/comm/mail/themes/addons/dark/preview.svg b/comm/mail/themes/addons/dark/preview.svg
new file mode 100644
index 0000000000..7fd9cbcfa7
--- /dev/null
+++ b/comm/mail/themes/addons/dark/preview.svg
@@ -0,0 +1,18 @@
+<!-- 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 width="680" height="92" viewBox="0 0 680 92" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <rect width="680" height="92" fill="#1C1B22" />
+ <rect x="28" y="5" width="166" height="34" rx="4" fill="#42414D" />
+ <rect x="51" y="20" width="121" height="4" rx="2" fill="#FBFBFE" />
+ <rect x="221" y="20" width="121" height="4" rx="2" fill="#B8B7BB" />
+ <rect y="44" width="680" height="48" fill="#2B2A33" />
+ <circle cx="24" cy="68" r="6.25" stroke="#FBFBFE" stroke-width="1.5" />
+ <circle cx="60" cy="68" r="6.25" stroke="#FBFBFE" stroke-width="1.5" />
+ <line x1="663" y1="73.75" x2="649" y2="73.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <line x1="663" y1="67.75" x2="649" y2="67.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <line x1="663" y1="61.75" x2="649" y2="61.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <rect x="114" y="52" width="488" height="32" rx="4" fill="#1C1B22" />
+ <circle cx="130" cy="68" r="6.25" stroke="white" stroke-width="1.5" />
+ <rect x="146" y="66" width="308" height="4" rx="2" fill="white" />
+</svg>
diff --git a/comm/mail/themes/addons/jar.mn b/comm/mail/themes/addons/jar.mn
new file mode 100644
index 0000000000..ee6fda7ab8
--- /dev/null
+++ b/comm/mail/themes/addons/jar.mn
@@ -0,0 +1,14 @@
+# 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:
+% resource builtin-themes %content/builtin-themes/
+
+ content/builtin-themes/dark (dark/*.svg)
+ content/builtin-themes/dark (dark/*.css)
+ content/builtin-themes/dark/manifest.json (dark/manifest.json)
+
+ content/builtin-themes/light (light/*.svg)
+ content/builtin-themes/light (light/*.css)
+ content/builtin-themes/light/manifest.json (light/manifest.json)
diff --git a/comm/mail/themes/addons/light/experiment.css b/comm/mail/themes/addons/light/experiment.css
new file mode 100644
index 0000000000..c978c40ac9
--- /dev/null
+++ b/comm/mail/themes/addons/light/experiment.css
@@ -0,0 +1,6 @@
+/* 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/. */
+
+/* Light theme */
+@import url("chrome://messenger/skin/compacttheme.css");
diff --git a/comm/mail/themes/addons/light/icon.svg b/comm/mail/themes/addons/light/icon.svg
new file mode 100644
index 0000000000..bd45f8528f
--- /dev/null
+++ b/comm/mail/themes/addons/light/icon.svg
@@ -0,0 +1,13 @@
+<!-- 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" width="32" height="32" viewBox="0 0 32 32">
+ <path fill="#e1e1e5" d="M2 2h14v13H2z"/>
+ <path fill="#f9f9fa" d="M16 2v13H2v15h28V2H16z"/>
+ <rect x="1" y="1" width="30" height="30" rx="2" ry="2" fill="none" stroke="#08091a" stroke-opacity=".35" stroke-width="2"/>
+ <circle cx="9.5" cy="22.5" r="6" fill="#fff" stroke="#adadb3"/>
+ <path d="M12.5 22H7.7l2.15-2.15a.5.5 0 00-.7-.7l-3 3a.5.5 0 000 .7l3 3a.5.5 0 10.7-.7L7.71 23h4.79a.5.5 0 000-1zM20.5 20h4a.5.5 0 000-1h-4a.5.5 0 000 1zm4 2h-4a.5.5 0 000 1h4a.5.5 0 000-1zm0 3h-4a.5.5 0 000 1h4a.5.5 0 000-1z" fill="#0c0c0d" fill-opacity=".8"/>
+ <path fill="#0a84ff" d="M16 2h14v1H16z"/>
+ <path d="M26.35 8.65l-3.5-3.5a.5.5 0 00-.7 0l-3.5 3.5a.5.5 0 00.7.7l.65-.64v2.79a.5.5 0 00.5.5h4a.5.5 0 00.5-.5V8.7l.65.65a.5.5 0 10.7-.7zM24 11h-1V9h-1v2h-1V7.7l1.5-1.5L24 7.7z" fill="#0c0c0d" fill-opacity=".8"/>
+ <path fill="#08091a" fill-opacity=".25" d="M15 2v12H2v1h14V2h-1z"/>
+</svg>
diff --git a/comm/mail/themes/addons/light/manifest.json b/comm/mail/themes/addons/light/manifest.json
new file mode 100644
index 0000000000..90ea486cba
--- /dev/null
+++ b/comm/mail/themes/addons/light/manifest.json
@@ -0,0 +1,68 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "thunderbird-compact-light@mozilla.org"
+ }
+ },
+
+ "name": "Light",
+ "description": "A theme with a light color scheme.",
+ "author": "Mozilla",
+ "version": "1.3",
+
+ "icons": { "32": "icon.svg" },
+
+ "theme": {
+ "colors": {
+ "tab_background_text": "#18181b",
+ "icons": "#52525b",
+ "frame": "#e4e4e7",
+ "popup": "#fff",
+ "popup_text": "#18181b",
+ "popup_border": "#a1a1aa",
+ "popup_highlight": "#1373d9",
+ "popup_highlight_text": "#fff",
+ "tab_line": "#1373d9",
+ "toolbar": "#f4f4f5",
+ "toolbar_top_separator": "#ccc",
+ "toolbar_bottom_separator": "#ccc",
+ "toolbar_field": "#f4f4f5",
+ "toolbar_field_text": "#18181b",
+ "toolbar_field_border": "#a1a1aa",
+ "toolbar_field_border_focus": "#1373d9",
+ "toolbar_field_focus": "white",
+ "toolbar_text": "#52525b",
+ "sidebar": "#fafafa",
+ "sidebar_text": "#3f3f46",
+ "sidebar_border": "#d4d4d8",
+ "sidebar_highlight": "#2493ef",
+ "sidebar_highlight_text": "#fff",
+ "sidebar_highlight_border": "#105bbc",
+ "button": "rgba(207, 207, 216, 0.33)",
+ "button_hover": "rgba(207, 207, 216, 0.66)",
+ "button_active": "rgb(207, 207, 216)"
+ },
+ "properties": {
+ "color_scheme": "light",
+ "panel_hover": "color-mix(in srgb, currentColor 12%, transparent)",
+ "panel_active": "color-mix(in srgb, currentColor 20%, transparent)",
+ "panel_active_darker": "color-mix(in srgb, currentColor 27%, transparent)"
+ }
+ },
+
+ "theme_experiment": {
+ "stylesheet": "experiment.css",
+ "colors": {
+ "button": "--button-background-color",
+ "button_hover": "--button-hover-background-color",
+ "button_active": "--button-active-background-color"
+ },
+ "properties": {
+ "panel_hover": "--arrowpanel-dimmed",
+ "panel_active": "-arrowpanel-dimmed-further",
+ "panel_active_darker": "--arrowpanel-dimmed-even-further"
+ }
+ }
+}
diff --git a/comm/mail/themes/addons/light/preview.svg b/comm/mail/themes/addons/light/preview.svg
new file mode 100644
index 0000000000..19e9643169
--- /dev/null
+++ b/comm/mail/themes/addons/light/preview.svg
@@ -0,0 +1,36 @@
+<!-- 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 width="680" height="92" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <rect width="680" height="92" fill="#F0F0F4" />
+ <g filter="url(#filter0_dd)">
+ <rect x="28" y="5" width="166" height="34" rx="4" fill="white" />
+ </g>
+ <rect x="51" y="20" width="121" height="4" rx="2" fill="#15141A" />
+ <rect x="221" y="20" width="121" height="4" rx="2" fill="#15141A" />
+ <rect y="44" width="680" height="48" fill="#F9F9FB" />
+ <circle cx="24" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <circle cx="60" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <line x1="663" y1="73.75" x2="649" y2="73.75" stroke="#5B5B66" stroke-width="1.5" />
+ <line x1="663" y1="67.75" x2="649" y2="67.75" stroke="#5B5B66" stroke-width="1.5" />
+ <line x1="663" y1="61.75" x2="649" y2="61.75" stroke="#5B5B66" stroke-width="1.5" />
+ <rect x="114" y="52" width="488" height="32" rx="4" fill="#F0F0F4" />
+ <circle cx="130" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <rect x="146" y="66" width="308" height="4" rx="2" fill="#5B5B66" />
+ <defs>
+ <filter id="filter0_dd" x="24" y="1" width="174" height="42" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+ <feFlood flood-opacity="0" result="BackgroundImageFix" />
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
+ <feOffset />
+ <feGaussianBlur stdDeviation="2" />
+ <feColorMatrix type="matrix" values="0 0 0 0 0.501961 0 0 0 0 0.501961 0 0 0 0 0.556863 0 0 0 0.5 0" />
+ <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
+ <feOffset />
+ <feGaussianBlur stdDeviation="0.5" />
+ <feColorMatrix type="matrix" values="0 0 0 0 0.501961 0 0 0 0 0.501961 0 0 0 0 0.556863 0 0 0 0.9 0" />
+ <feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow" />
+ <feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape" />
+ </filter>
+ </defs>
+</svg>
diff --git a/comm/mail/themes/addons/moz.build b/comm/mail/themes/addons/moz.build
new file mode 100644
index 0000000000..d988c0ff9b
--- /dev/null
+++ b/comm/mail/themes/addons/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/themes/icon.png b/comm/mail/themes/icon.png
new file mode 100644
index 0000000000..dd0b1691d2
--- /dev/null
+++ b/comm/mail/themes/icon.png
Binary files differ
diff --git a/comm/mail/themes/icon64.png b/comm/mail/themes/icon64.png
new file mode 100644
index 0000000000..533aa1329e
--- /dev/null
+++ b/comm/mail/themes/icon64.png
Binary files differ
diff --git a/comm/mail/themes/linux/editor/EditorDialog.css b/comm/mail/themes/linux/editor/EditorDialog.css
new file mode 100644
index 0000000000..14d013adaa
--- /dev/null
+++ b/comm/mail/themes/linux/editor/EditorDialog.css
@@ -0,0 +1,14 @@
+/* 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 url("chrome://messenger/skin/shared/EditorDialog.css");
+
+groupbox {
+ margin: 5px;
+ padding: 5px;
+}
+
+.spell-check {
+ min-width: 8.5em;
+}
diff --git a/comm/mail/themes/linux/editor/img-align-bottom.gif b/comm/mail/themes/linux/editor/img-align-bottom.gif
new file mode 100644
index 0000000000..a8ba157970
--- /dev/null
+++ b/comm/mail/themes/linux/editor/img-align-bottom.gif
Binary files differ
diff --git a/comm/mail/themes/linux/editor/img-align-left.gif b/comm/mail/themes/linux/editor/img-align-left.gif
new file mode 100644
index 0000000000..117b93fcaf
--- /dev/null
+++ b/comm/mail/themes/linux/editor/img-align-left.gif
Binary files differ
diff --git a/comm/mail/themes/linux/editor/img-align-middle.gif b/comm/mail/themes/linux/editor/img-align-middle.gif
new file mode 100644
index 0000000000..4189f7ac33
--- /dev/null
+++ b/comm/mail/themes/linux/editor/img-align-middle.gif
Binary files differ
diff --git a/comm/mail/themes/linux/editor/img-align-right.gif b/comm/mail/themes/linux/editor/img-align-right.gif
new file mode 100644
index 0000000000..8e679f50a4
--- /dev/null
+++ b/comm/mail/themes/linux/editor/img-align-right.gif
Binary files differ
diff --git a/comm/mail/themes/linux/editor/img-align-top.gif b/comm/mail/themes/linux/editor/img-align-top.gif
new file mode 100644
index 0000000000..d871945929
--- /dev/null
+++ b/comm/mail/themes/linux/editor/img-align-top.gif
Binary files differ
diff --git a/comm/mail/themes/linux/jar.mn b/comm/mail/themes/linux/jar.mn
new file mode 100644
index 0000000000..b4c4d6a3aa
--- /dev/null
+++ b/comm/mail/themes/linux/jar.mn
@@ -0,0 +1,85 @@
+# 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/.
+
+classic.jar:
+% skin messenger classic/1.0 %skin/classic/messenger/
+#include ../shared/jar.inc.mn
+ skin/classic/messenger/primaryToolbar.css (mail/primaryToolbar.css)
+ skin/classic/messenger/accountCentral.css (mail/accountCentral.css)
+ skin/classic/messenger/accountManage.css (mail/accountManage.css)
+ skin/classic/messenger/chat.css (mail/chat.css)
+ skin/classic/messenger/common.css (mail/common.css)
+ skin/classic/messenger/contextMenu.css (mail/contextMenu.css)
+ skin/classic/messenger/imAccounts.css (mail/imAccounts.css)
+ skin/classic/messenger/compacttheme.css (mail/compacttheme.css)
+ skin/classic/messenger/customizeToolbar.css (mail/customizeToolbar.css)
+ skin/classic/messenger/menulist.css (mail/menulist.css)
+ skin/classic/messenger/messageBody.css (mail/messageBody.css)
+ skin/classic/messenger/message-bar.css (mail/message-bar.css)
+ skin/classic/messenger/messageHeader.css (mail/messageHeader.css)
+ skin/classic/messenger/messageIcons.css (mail/messageIcons.css)
+ skin/classic/messenger/messenger.css (mail/messenger.css)
+ skin/classic/messenger/attachmentList.css (mail/attachmentList.css)
+ skin/classic/messenger/mailWindow1.css (mail/mailWindow1.css)
+ skin/classic/messenger/messageWindow.css (mail/messageWindow.css)
+ skin/classic/messenger/searchBox.css (mail/searchBox.css)
+ skin/classic/messenger/junkMail.css (mail/junkMail.css)
+ skin/classic/messenger/input-fields.css (mail/input-fields.css)
+ skin/classic/messenger/folderMenus.css (mail/folderMenus.css)
+ skin/classic/messenger/folderPane.css (mail/folderPane.css)
+ skin/classic/messenger/searchDialog.css (mail/searchDialog.css)
+ skin/classic/messenger/spacesToolbar.css (mail/spacesToolbar.css)
+ skin/classic/messenger/filterDialog.css (mail/filterDialog.css)
+ skin/classic/messenger/multimessageview.css (mail/multimessageview.css)
+ skin/classic/messenger/glodaFacetView.css (mail/glodaFacetView.css)
+ skin/classic/messenger/panelUI.css (mail/panelUI.css)
+ skin/classic/messenger/tabmail.css (mail/tabmail.css)
+ skin/classic/messenger/themeableDialog.css (mail/themeableDialog.css)
+ skin/classic/messenger/popupPanel.css (mail/popupPanel.css)
+ skin/classic/messenger/variables.css (mail/variables.css)
+ skin/classic/messenger/activity/activity.css (mail/activity/activity.css)
+ skin/classic/messenger/activity/defaultProcessIcon.png (mail/activity/defaultProcessIcon.png)
+ skin/classic/messenger/activity/defaultEventIcon.png (mail/activity/defaultEventIcon.png)
+ skin/classic/messenger/activity/warning.png (mail/activity/warning.png)
+ skin/classic/messenger/activity/undoIcon.png (mail/activity/undoIcon.png)
+ skin/classic/messenger/activity/syncMailIcon.png (mail/activity/syncMailIcon.png)
+ skin/classic/messenger/activity/sendMailIcon.png (mail/activity/sendMailIcon.png)
+ skin/classic/messenger/activity/removeItemIcon.png (mail/activity/removeItemIcon.png)
+ skin/classic/messenger/activity/addItemIcon.png (mail/activity/addItemIcon.png)
+ skin/classic/messenger/activity/moveMailIcon.png (mail/activity/moveMailIcon.png)
+ skin/classic/messenger/activity/copyMailIcon.png (mail/activity/copyMailIcon.png)
+ skin/classic/messenger/activity/deleteMailIcon.png (mail/activity/deleteMailIcon.png)
+ skin/classic/messenger/activity/compactMailIcon.png (mail/activity/compactMailIcon.png)
+ skin/classic/messenger/activity/indexMailIcon.png (mail/activity/indexMailIcon.png)
+ skin/classic/messenger/addressbook/abContactsPanel.css (mail/addrbook/abContactsPanel.css)
+ skin/classic/messenger/addressbook/cardDialog.css (mail/addrbook/cardDialog.css)
+ skin/classic/messenger/messengercompose/messengercompose.css (mail/compose/messengercompose.css)
+ skin/classic/messenger/downloads/aboutDownloads.css (mail/downloads/aboutDownloads.css)
+% skin messenger-newsblog classic/1.0 %skin/classic/messenger-newsblog/
+ skin/classic/messenger-newsblog/feed-subscriptions.css (mail/newsblog/feed-subscriptions.css)
+ skin/classic/messenger/preferences/alwaysAsk.png (mail/preferences/alwaysAsk.png)
+ skin/classic/messenger/preferences/saveFile.png (mail/preferences/saveFile.png)
+ skin/classic/messenger/preferences/preferences.css (mail/preferences/preferences.css)
+ skin/classic/messenger/preferences/applications.css (mail/preferences/applications.css)
+ skin/classic/messenger/icons/identity.png (mail/icons/identity.png)
+ skin/classic/messenger/icons/error.png (mail/icons/error.png)
+ skin/classic/messenger/icons/multicolor.png (mail/icons/multicolor.png)
+ skin/classic/messenger/icons/arrow/arrow-left.png (mail/icons/arrow/arrow-left.png)
+ skin/classic/messenger/icons/arrow/arrow-right.png (mail/icons/arrow/arrow-right.png)
+ skin/classic/messenger/icons/arrow/arrow-up.png (mail/icons/arrow/arrow-up.png)
+ skin/classic/messenger/icons/arrow/arrow-down.png (mail/icons/arrow/arrow-down.png)
+ skin/classic/messenger/icons/arrow/arrow-right-dim.png (mail/icons/arrow/arrow-right-dim.png)
+ skin/classic/messenger/icons/arrow/arrow-down-dim.png (mail/icons/arrow/arrow-down-dim.png)
+ skin/classic/messenger/icons/connecting.png (mail/icons/connecting.png)
+ skin/classic/messenger/window-controls/close.svg (../windows/mail/window-controls/close.svg)
+ skin/classic/messenger/window-controls/maximize.svg (../windows/mail/window-controls/maximize.svg)
+ skin/classic/messenger/window-controls/minimize.svg (../windows/mail/window-controls/minimize.svg)
+ skin/classic/messenger/window-controls/restore.svg (../windows/mail/window-controls/restore.svg)
+% skin editor classic/1.0 %skin/classic/editor/
+ skin/classic/editor/EditorDialog.css (editor/EditorDialog.css)
+ skin/classic/editor/icons/img-align-bottom.gif (editor/img-align-bottom.gif)
+ skin/classic/editor/icons/img-align-left.gif (editor/img-align-left.gif)
+ skin/classic/editor/icons/img-align-middle.gif (editor/img-align-middle.gif)
+ skin/classic/editor/icons/img-align-right.gif (editor/img-align-right.gif)
+ skin/classic/editor/icons/img-align-top.gif (editor/img-align-top.gif)
diff --git a/comm/mail/themes/linux/mail/accountCentral.css b/comm/mail/themes/linux/mail/accountCentral.css
new file mode 100644
index 0000000000..e70d790323
--- /dev/null
+++ b/comm/mail/themes/linux/mail/accountCentral.css
@@ -0,0 +1,9 @@
+/* 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/. */
+
+/* ===== accountCentral.css ==========================================
+ == Styles for the Messenger Account Central panel.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/accountCentral.css");
diff --git a/comm/mail/themes/linux/mail/accountManage.css b/comm/mail/themes/linux/mail/accountManage.css
new file mode 100644
index 0000000000..1e90c9dd0f
--- /dev/null
+++ b/comm/mail/themes/linux/mail/accountManage.css
@@ -0,0 +1,5 @@
+/* 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 url("chrome://messenger/skin/shared/accountManage.css");
diff --git a/comm/mail/themes/linux/mail/activity/activity.css b/comm/mail/themes/linux/mail/activity/activity.css
new file mode 100644
index 0000000000..bf348bdf7c
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/activity.css
@@ -0,0 +1,5 @@
+/* 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 url("chrome://messenger/skin/shared/activity/activity.css");
diff --git a/comm/mail/themes/linux/mail/activity/addItemIcon.png b/comm/mail/themes/linux/mail/activity/addItemIcon.png
new file mode 100644
index 0000000000..2acdd8f514
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/addItemIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/compactMailIcon.png b/comm/mail/themes/linux/mail/activity/compactMailIcon.png
new file mode 100644
index 0000000000..e62682ac1b
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/compactMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/copyMailIcon.png b/comm/mail/themes/linux/mail/activity/copyMailIcon.png
new file mode 100644
index 0000000000..92cd9e584b
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/copyMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/defaultEventIcon.png b/comm/mail/themes/linux/mail/activity/defaultEventIcon.png
new file mode 100644
index 0000000000..033e7ec1b3
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/defaultEventIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/defaultProcessIcon.png b/comm/mail/themes/linux/mail/activity/defaultProcessIcon.png
new file mode 100644
index 0000000000..addf20dcf7
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/defaultProcessIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/deleteMailIcon.png b/comm/mail/themes/linux/mail/activity/deleteMailIcon.png
new file mode 100644
index 0000000000..c04529dc9e
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/deleteMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/indexMailIcon.png b/comm/mail/themes/linux/mail/activity/indexMailIcon.png
new file mode 100644
index 0000000000..4babd39aa6
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/indexMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/moveMailIcon.png b/comm/mail/themes/linux/mail/activity/moveMailIcon.png
new file mode 100644
index 0000000000..3a8217c5f2
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/moveMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/removeItemIcon.png b/comm/mail/themes/linux/mail/activity/removeItemIcon.png
new file mode 100644
index 0000000000..c5524f7284
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/removeItemIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/sendMailIcon.png b/comm/mail/themes/linux/mail/activity/sendMailIcon.png
new file mode 100644
index 0000000000..3488a6e7b2
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/sendMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/syncMailIcon.png b/comm/mail/themes/linux/mail/activity/syncMailIcon.png
new file mode 100644
index 0000000000..12b2004769
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/syncMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/undoIcon.png b/comm/mail/themes/linux/mail/activity/undoIcon.png
new file mode 100644
index 0000000000..18664b3f73
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/undoIcon.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/activity/warning.png b/comm/mail/themes/linux/mail/activity/warning.png
new file mode 100644
index 0000000000..42d053a9c4
--- /dev/null
+++ b/comm/mail/themes/linux/mail/activity/warning.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/addrbook/abContactsPanel.css b/comm/mail/themes/linux/mail/addrbook/abContactsPanel.css
new file mode 100644
index 0000000000..db220ae467
--- /dev/null
+++ b/comm/mail/themes/linux/mail/addrbook/abContactsPanel.css
@@ -0,0 +1,31 @@
+/* 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/. */
+
+/* ===== sidebarPanel.css ===============================================
+ == Styles for the Address Book sidebar panel.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/abContactsPanel.css");
+
+#AbPickerHeader > label {
+ margin-block: 3px 0;
+}
+
+#abContextMenuButton {
+ margin: -4px 4px 0;
+ padding-inline: 4px 2px;
+}
+
+#abContextMenuButton > .toolbarbutton-icon {
+ margin-inline-end: 0;
+}
+
+:root:not([lwt-tree]) #abResultsTree {
+ border-inline-start: 1px solid ThreeDShadow;
+ border-bottom: 1px solid ThreeDShadow;
+}
+
+#GeneratedName {
+ padding-inline-start: 24px;
+}
diff --git a/comm/mail/themes/linux/mail/addrbook/cardDialog.css b/comm/mail/themes/linux/mail/addrbook/cardDialog.css
new file mode 100644
index 0000000000..e960578770
--- /dev/null
+++ b/comm/mail/themes/linux/mail/addrbook/cardDialog.css
@@ -0,0 +1,14 @@
+/* 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/. */
+
+/* ===== cardViewOverlay.css ============================================
+ == Styles for Address Book dialogs.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/cardDialog.css");
+
+panel[type="autocomplete-richlistbox"],
+panel[type="autocomplete-richlistbox"]::part(content) {
+ margin: 0;
+}
diff --git a/comm/mail/themes/linux/mail/attachmentList.css b/comm/mail/themes/linux/mail/attachmentList.css
new file mode 100644
index 0000000000..bfb960281b
--- /dev/null
+++ b/comm/mail/themes/linux/mail/attachmentList.css
@@ -0,0 +1,7 @@
+/* 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 url("chrome://messenger/skin/shared/attachmentList.css");
+
+/* styles for the attachment list */
diff --git a/comm/mail/themes/linux/mail/chat.css b/comm/mail/themes/linux/mail/chat.css
new file mode 100644
index 0000000000..0b93ded3f5
--- /dev/null
+++ b/comm/mail/themes/linux/mail/chat.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/chat.css");
+
+.convUnreadTargetedCount {
+ padding: 2px 7px;
+}
+
+.convUnreadCount,
+.contactDisplayName,
+.convDisplayName,
+.contactStatusText,
+.convStatusText {
+ margin-top: 2px;
+}
+
+#listSplitter,
+#contextSplitter,
+.splitter[orient="vertical"],
+ #logsSplitter {
+ appearance: none;
+}
+
+.im-placeholder-button-box > button {
+ padding-inline: 8px;
+}
+
+/* Adaptation from #folderTree */
+:root:not([lwt-tree]) #listPaneBox {
+ background-color: -moz-OddTreeRow;
+}
+
+#listPaneBox > * {
+ background: transparent !important;
+ appearance: none !important;
+ border: none;
+}
+
+.conv-textbox > .textbox-input-box {
+ background: inherit;
+}
+
+.conv-counter {
+ padding-bottom: 0;
+ padding-inline-end: 5px;
+ margin-inline-end: 2px;
+ margin-bottom: 2px !important; /* override 4px from description */
+}
+
+:root:not([lwt-tree]) #conv-top-info {
+ appearance: none;
+ background-color: -moz-Dialog;
+ color: -moz-DialogText;
+}
+
+.conv-nicklist-header,
+.conv-logs-header-label {
+ color: -moz-DialogText;
+ background-color: -moz-Dialog;
+ padding-inline-start: 3px;
+}
+
+.conv-nicklist-header-label {
+ font-weight: bold;
+ margin-inline: 0 2px !important;
+}
+
+#nicklist > richlistitem[inactive][selected] > label {
+ color: -moz-cellhighlighttext !important;
+}
+
+richlistitem[is="chat-group-richlistitem"] .twisty {
+ margin-inline-end: 2px;
+}
+
+.startChatBubble > .button-box > .button-icon,
+.closeConversationButton > .button-box > .button-icon {
+ margin-inline-end: 0;
+}
+
+.conv-hbox {
+ align-items: center;
+}
+
+#setStatusTypeMenupopup .menu-iconic-icon,
+#imAccountsStatus .menu-iconic-icon {
+ visibility: visible;
+}
+
+.encryption-button:hover {
+ color: inherit;
+}
diff --git a/comm/mail/themes/linux/mail/common.css b/comm/mail/themes/linux/mail/common.css
new file mode 100644
index 0000000000..ac25ade240
--- /dev/null
+++ b/comm/mail/themes/linux/mail/common.css
@@ -0,0 +1,56 @@
+/* - 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 url("chrome://messenger/skin/shared/common.css");
+
+@namespace html "http://www.w3.org/1999/xhtml";
+@namespace xul "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+@media not (prefers-contrast) {
+ @media (prefers-color-scheme: dark) {
+ :host,
+ :root {
+ /* Don't apply scrollbar-color since it removes the native scrollbar style on Linux */
+ scrollbar-color: initial;
+ }
+ }
+}
+
+xul|tab[visuallyselected] {
+ /* Override styles for tab[selected] from
+ toolkit/themes/linux/global/tabbox.css */
+ margin-bottom: 0;
+}
+
+/* Overriding appearance also avoids incorrect selection background color with
+ light text. */
+xul|menulist::part(label-box),
+xul|*.radio-label-box,
+xul|*.checkbox-label-box {
+ appearance: none;
+}
+
+xul|button[type="menu"] > xul|*.button-box > xul|*.button-menu-dropmarker {
+ appearance: none !important;
+}
+
+xul|menulist {
+ font-size: inherit;
+}
+
+html|button {
+ /* XUL button min-width */
+ min-width: 6.3em;
+}
+
+xul|tab {
+ min-height: 2.5em;
+}
+
+:host(dialog[subdialog]) .dialog-button-box > button {
+ min-height: var(--in-content-button-height);
+ padding-block: initial;
+ padding-inline: 15px;
+ border-color: transparent;
+ border-radius: var(--in-content-button-border-radius);
+}
diff --git a/comm/mail/themes/linux/mail/compacttheme.css b/comm/mail/themes/linux/mail/compacttheme.css
new file mode 100644
index 0000000000..a958e0bc8f
--- /dev/null
+++ b/comm/mail/themes/linux/mail/compacttheme.css
@@ -0,0 +1,5 @@
+/* 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 url("chrome://messenger/skin/shared/compacttheme.css");
diff --git a/comm/mail/themes/linux/mail/compose/messengercompose.css b/comm/mail/themes/linux/mail/compose/messengercompose.css
new file mode 100644
index 0000000000..58a9fedfda
--- /dev/null
+++ b/comm/mail/themes/linux/mail/compose/messengercompose.css
@@ -0,0 +1,157 @@
+/* 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/. */
+
+/* ===== messengercompose.css ===========================================
+ == Styles for the main Messenger Compose window.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/shared/messengercompose.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+#mail-menubar:-moz-lwtheme {
+ color: inherit;
+}
+
+#compose-toolbox {
+ border-bottom: 1px solid var(--chrome-content-separator-color);
+}
+
+/* ::::: special toolbar colors ::::: */
+
+#subjectLabel {
+ margin-bottom: 0;
+ margin-inline-end: 6px;
+}
+
+/* ::::: autocomplete icons ::::: */
+
+.autocomplete-richlistitem[type$="-abook"] > .ac-site-icon {
+ display: flex;
+ margin: 1px 5px;
+}
+
+#composeContentBox {
+ background-color: -moz-dialog;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2) inset;
+}
+
+#composeContentBox:-moz-window-inactive {
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1) inset;
+}
+
+#MsgHeadersToolbar {
+ appearance: none !important;
+ color: WindowText;
+ text-shadow: none;
+ padding-block-start: 7px;
+}
+
+.address-container {
+ padding: 1px 4px;
+}
+
+.address-label-container {
+ padding-top: 6px;
+}
+
+#msgIdentity,
+.address-container {
+ min-height: 30px;
+}
+
+#msgSubject {
+ min-height: 28px;
+}
+
+.address-pill {
+ padding-block: 2px;
+}
+
+.address-pill label {
+ margin-block: 0;
+}
+
+.pill-indicator {
+ margin-top: -2px;
+}
+
+#identityLabel-box {
+ margin-top: 1px;
+}
+
+#msgIdentity {
+ margin-block: 2px 0;
+ padding-block: 4px;
+ padding-inline: 2px 20px;
+}
+
+#msgIdentity::part(label-box) {
+ background: none;
+ padding-inline-end: initial;
+}
+
+#msgIdentity::part(text-input) {
+ appearance: none;
+ padding-block: 1px 2px;
+ padding-inline: 3px 12px;
+ background-color: transparent;
+ color: inherit;
+}
+
+#msgIdentity[editable="true"]::part(dropmarker) {
+ margin-inline-end: 0;
+ width: 12px;
+}
+
+#msgIdentity[open="true"] {
+ color: FieldText;
+}
+
+:root[lwt-tree] #msgIdentity[open="true"] {
+ color: var(--lwt-toolbar-field-color);
+}
+
+/* ::::: format toolbar ::::: */
+
+#FormatToolbar {
+ appearance: none;
+ color: WindowText;
+ margin-inline: 3px;
+ padding-block: 4px;
+}
+
+toolbarbutton.formatting-button {
+ margin: 1px;
+}
+
+.formatting-button > .toolbarbutton-menu-dropmarker {
+ appearance: none !important;
+ margin-inline-start: 3px;
+}
+
+/* ::::: address book sidebar ::::: */
+
+#contactsBrowser {
+ background-color: Window;
+}
+
+menu[command="cmd_attachCloud"] .menu-iconic-left,
+menu[command="cmd_convertCloud"] .menu-iconic-left {
+ /* Ensure that the provider icons are visible even if the Gnome theme says
+ menus shouldn't have icons. */
+ visibility: visible;
+}
+
+/* Styles for the default system dark theme */
+
+:root[lwt-tree] #MsgHeadersToolbar {
+ background-image: none;
+}
+
+:root[lwt-tree] #FormatToolbar {
+ color: inherit;
+ background-image: none;
+}
diff --git a/comm/mail/themes/linux/mail/contextMenu.css b/comm/mail/themes/linux/mail/contextMenu.css
new file mode 100644
index 0000000000..621241722d
--- /dev/null
+++ b/comm/mail/themes/linux/mail/contextMenu.css
@@ -0,0 +1,19 @@
+/* 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 url("chrome://messenger/skin/shared/contextMenu.css");
+
+menupopup > menuitem:is([type="checkbox"],[type="radio"],[checked="true"]) {
+ appearance: none !important;
+}
+
+menupopup > menuitem:is([type="checkbox"],[type="radio"]) .menu-iconic-icon {
+ appearance: none;
+}
+
+menupopup > menu > .menu-text,
+menuitem:not(:is([type="checkbox"],[type="radio"])) > .menu-text {
+ /* Align with the checkbox and radio menuitems. */
+ padding-inline-start: 21px;
+}
diff --git a/comm/mail/themes/linux/mail/customizeToolbar.css b/comm/mail/themes/linux/mail/customizeToolbar.css
new file mode 100644
index 0000000000..a3825bda32
--- /dev/null
+++ b/comm/mail/themes/linux/mail/customizeToolbar.css
@@ -0,0 +1,13 @@
+/* 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 url("chrome://messenger/skin/shared/customizeToolbar.css");
+
+#palette-box {
+ margin: 0 4px 10px;
+}
+
+#titlebarSettings {
+ margin-inline-start: 4px;
+}
diff --git a/comm/mail/themes/linux/mail/downloads/aboutDownloads.css b/comm/mail/themes/linux/mail/downloads/aboutDownloads.css
new file mode 100644
index 0000000000..bd2a29934b
--- /dev/null
+++ b/comm/mail/themes/linux/mail/downloads/aboutDownloads.css
@@ -0,0 +1,9 @@
+/* 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 url("chrome://messenger/skin/shared/aboutDownloads.css");
+
+.downloadButton > .button-box > .button-icon {
+ list-style-image: url("chrome://global/skin/icons/folder.svg");
+}
diff --git a/comm/mail/themes/linux/mail/filterDialog.css b/comm/mail/themes/linux/mail/filterDialog.css
new file mode 100644
index 0000000000..3df96b5a06
--- /dev/null
+++ b/comm/mail/themes/linux/mail/filterDialog.css
@@ -0,0 +1,47 @@
+/* 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/. */
+
+/* ===== filterDialog.css ===============================================
+ == Styles for the Mail Filters dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/filterDialog.css");
+
+/* ::::: columns :::::: */
+
+#gray_horizontal_splitter {
+ margin-inline: -6px;
+}
+
+#filterHeader,
+#filterListGrid {
+ margin: 8px 6px 0;
+}
+
+#FilterEditor {
+ padding-inline: 4px;
+}
+
+.search-menulist,
+.search-value-menulist {
+ width: 16em;
+}
+
+.small-button {
+ min-width: 3em;
+ padding: 0;
+ margin: 2px;
+}
+
+.small-button + .small-button {
+ margin-inline: 0 4px;
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"] {
+ padding-inline-end: 8px !important;
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"] > dropmarker {
+ padding-inline-start: 6px;
+}
diff --git a/comm/mail/themes/linux/mail/folderMenus.css b/comm/mail/themes/linux/mail/folderMenus.css
new file mode 100644
index 0000000000..d3b1d61974
--- /dev/null
+++ b/comm/mail/themes/linux/mail/folderMenus.css
@@ -0,0 +1,22 @@
+/* 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/. */
+
+/* ===== folderMenus.css ================================================
+ == Icons for menus which represent mail folder.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/folderMenus.css");
+
+.folderMenuItem .menu-iconic-left {
+ display: flex;
+}
+
+menulist.folderMenuItem::part(icon) {
+ width: 16px;
+ height: 16px;
+}
+
+.menulist-menupopup[is="folder-menupopup"] {
+ list-style-image: none;
+}
diff --git a/comm/mail/themes/linux/mail/folderPane.css b/comm/mail/themes/linux/mail/folderPane.css
new file mode 100644
index 0000000000..ef1de33e42
--- /dev/null
+++ b/comm/mail/themes/linux/mail/folderPane.css
@@ -0,0 +1,19 @@
+/* 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 url("chrome://messenger/skin/shared/folderPane.css");
+
+/* UI Density customization */
+
+#folderTree > treechildren::-moz-tree-row {
+ min-height: 1.8rem;
+}
+
+:root[uidensity="compact"] #folderTree > treechildren::-moz-tree-row {
+ min-height: 1.6rem;
+}
+
+:root[uidensity="touch"] #folderTree > treechildren::-moz-tree-row {
+ min-height: 2.4rem;
+}
diff --git a/comm/mail/themes/linux/mail/glodaFacetView.css b/comm/mail/themes/linux/mail/glodaFacetView.css
new file mode 100644
index 0000000000..3ad1c777c2
--- /dev/null
+++ b/comm/mail/themes/linux/mail/glodaFacetView.css
@@ -0,0 +1,9 @@
+/* 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 url("chrome://messenger/skin/shared/glodaFacetView.css");
+
+.facets-sidebar {
+ font-size: 0.95rem;
+}
diff --git a/comm/mail/themes/linux/mail/icons/arrow/arrow-down-dim.png b/comm/mail/themes/linux/mail/icons/arrow/arrow-down-dim.png
new file mode 100644
index 0000000000..4f7fcd5784
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/arrow/arrow-down-dim.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/arrow/arrow-down.png b/comm/mail/themes/linux/mail/icons/arrow/arrow-down.png
new file mode 100644
index 0000000000..d2df341a58
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/arrow/arrow-down.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/arrow/arrow-left.png b/comm/mail/themes/linux/mail/icons/arrow/arrow-left.png
new file mode 100644
index 0000000000..6607869ad0
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/arrow/arrow-left.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/arrow/arrow-right-dim.png b/comm/mail/themes/linux/mail/icons/arrow/arrow-right-dim.png
new file mode 100644
index 0000000000..49dc2d55e4
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/arrow/arrow-right-dim.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/arrow/arrow-right.png b/comm/mail/themes/linux/mail/icons/arrow/arrow-right.png
new file mode 100644
index 0000000000..f9e33978e7
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/arrow/arrow-right.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/arrow/arrow-up.png b/comm/mail/themes/linux/mail/icons/arrow/arrow-up.png
new file mode 100644
index 0000000000..1eb4d4ceb2
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/arrow/arrow-up.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/connecting.png b/comm/mail/themes/linux/mail/icons/connecting.png
new file mode 100644
index 0000000000..3c8e71f5db
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/connecting.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/error.png b/comm/mail/themes/linux/mail/icons/error.png
new file mode 100644
index 0000000000..628cf2dae3
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/error.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/identity.png b/comm/mail/themes/linux/mail/icons/identity.png
new file mode 100644
index 0000000000..8d4f3bc327
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/identity.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/icons/multicolor.png b/comm/mail/themes/linux/mail/icons/multicolor.png
new file mode 100644
index 0000000000..b96853cf37
--- /dev/null
+++ b/comm/mail/themes/linux/mail/icons/multicolor.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/imAccounts.css b/comm/mail/themes/linux/mail/imAccounts.css
new file mode 100644
index 0000000000..37b68cef40
--- /dev/null
+++ b/comm/mail/themes/linux/mail/imAccounts.css
@@ -0,0 +1,13 @@
+/* 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 url("chrome://messenger/skin/shared/imAccounts.css");
+
+richlistitem .account-buttons {
+ margin-inline-start: 32px;
+}
+
+#statusTypeIcon .button-box {
+ padding: 0;
+}
diff --git a/comm/mail/themes/linux/mail/input-fields.css b/comm/mail/themes/linux/mail/input-fields.css
new file mode 100644
index 0000000000..f999d553c4
--- /dev/null
+++ b/comm/mail/themes/linux/mail/input-fields.css
@@ -0,0 +1,19 @@
+/* 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 url("chrome://messenger/skin/shared/input-fields.css");
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+html|input.input-inline {
+ padding-inline-start: 4px;
+}
+
+html|input.input-filefield {
+ padding-inline-start: 25px !important;
+ background: center left 6px / 16px no-repeat;
+}
+
+html|input.input-filefield:-moz-locale-dir(rtl) {
+ background-position-x: right 6px;
+}
diff --git a/comm/mail/themes/linux/mail/junkMail.css b/comm/mail/themes/linux/mail/junkMail.css
new file mode 100644
index 0000000000..5a4b3b3336
--- /dev/null
+++ b/comm/mail/themes/linux/mail/junkMail.css
@@ -0,0 +1,18 @@
+/*
+#
+# 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/.
+*/
+
+/*===== junkMail=======.css ==============================================
+ == Styles for the junk mail dialog
+ ======================================================================== */
+
+@import url("chrome://messenger/skin/messenger.css");
+
+/* ::::: account manager :::::: */
+
+.specialFolderPickerGrid {
+ margin-inline-start: 20px;
+}
diff --git a/comm/mail/themes/linux/mail/mailWindow1.css b/comm/mail/themes/linux/mail/mailWindow1.css
new file mode 100644
index 0000000000..c78e870e13
--- /dev/null
+++ b/comm/mail/themes/linux/mail/mailWindow1.css
@@ -0,0 +1,67 @@
+/*
+# 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/.
+*/
+
+/* ===== mailWindow1.css ================================================
+ == Styles for the main Mail window in the default layout scheme.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/primaryToolbar.css");
+@import url("chrome://messenger/skin/folderPane.css");
+@import url("chrome://messenger/skin/messageIcons.css");
+@import url("chrome://messenger/skin/shared/mailWindow1.css");
+
+#messengerBox {
+ background-color: -moz-Dialog;
+}
+
+:root:not([lwt-tree]) #folderTree {
+ background-color: -moz-OddTreeRow;
+}
+
+#mailContent {
+ appearance: auto;
+ -moz-default-appearance: toolbox;
+ background-color: -moz-Dialog;
+}
+
+/* ..... status bar adjustments ..... */
+
+#threadTree > treechildren::-moz-tree-row(odd) {
+ background-color: transparent;
+}
+
+#threadTree > treechildren::-moz-tree-row(dummy, odd) {
+ background-color: var(--row-grouped-header-bg-color);
+}
+
+#threadTree > treechildren::-moz-tree-row(odd, hover),
+#threadTree > treechildren::-moz-tree-row(dummy, odd, hover) {
+ background-color: hsla(0, 0%, 50%, 0.15);
+}
+
+#threadTree > treechildren::-moz-tree-row(selected) {
+ color: -moz-cellhighlighttext;
+ background-color: -moz-cellhighlight;
+}
+
+#threadTree > treechildren::-moz-tree-row(selected, focus) {
+ color: var(--selected-item-text-color);
+ background-color: var(--selected-item-color);
+}
+
+/* ..... tabs ..... */
+
+#tabpanelcontainer {
+ appearance: none;
+}
+
+/* Global notification popup */
+
+#notification-popup {
+ background-color: transparent;
+ border: none;
+}
diff --git a/comm/mail/themes/linux/mail/menulist.css b/comm/mail/themes/linux/mail/menulist.css
new file mode 100644
index 0000000000..3a12b2ed12
--- /dev/null
+++ b/comm/mail/themes/linux/mail/menulist.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/menulist.css");
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+menulist::part(text-input) {
+ padding: 2px 2px 3px;
+}
+
+menulist[is="menulist-editable"][editable="true"] {
+ padding-inline-end: 2.4em;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(dropmarker) {
+ width: 2em;
+ margin-top: -2px;
+ margin-bottom: -2px;
+ margin-inline-end: -2.4em;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(text-input) {
+ margin-inline-end: 6px;
+}
+
+menulist[open="true"]::part(text-input) {
+ color: FieldText;
+}
diff --git a/comm/mail/themes/linux/mail/message-bar.css b/comm/mail/themes/linux/mail/message-bar.css
new file mode 100644
index 0000000000..669a7ea81a
--- /dev/null
+++ b/comm/mail/themes/linux/mail/message-bar.css
@@ -0,0 +1,13 @@
+/* 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 url("chrome://messenger/skin/shared/message-bar.css");
+
+.notification-button.small-button {
+ padding-block: 2px;
+}
+
+#reminderBarPopup {
+ margin-top: -4px;
+}
diff --git a/comm/mail/themes/linux/mail/messageBody.css b/comm/mail/themes/linux/mail/messageBody.css
new file mode 100644
index 0000000000..a8c696fd37
--- /dev/null
+++ b/comm/mail/themes/linux/mail/messageBody.css
@@ -0,0 +1,10 @@
+/* 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 url("chrome://messenger/skin/shared/messageBody.css");
+
+.moz-txt-formfeed {
+ display: block;
+ height: 100%;
+}
diff --git a/comm/mail/themes/linux/mail/messageHeader.css b/comm/mail/themes/linux/mail/messageHeader.css
new file mode 100644
index 0000000000..70b5fdf0d0
--- /dev/null
+++ b/comm/mail/themes/linux/mail/messageHeader.css
@@ -0,0 +1,16 @@
+/* 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/. */
+
+/* ===== messageHeader.css ==============================================
+ == Styles for the header toolbars of a mail message.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/messageHeader.css");
+
+/* ::::: expanded header pane ::::: */
+
+.inline-toolbox {
+ appearance: none;
+ padding-inline-end: 2px;
+}
diff --git a/comm/mail/themes/linux/mail/messageIcons.css b/comm/mail/themes/linux/mail/messageIcons.css
new file mode 100644
index 0000000000..4741ce1725
--- /dev/null
+++ b/comm/mail/themes/linux/mail/messageIcons.css
@@ -0,0 +1,57 @@
+/* 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 url("chrome://messenger/skin/shared/messageIcons.css");
+
+/* ..... read column ..... */
+
+.readColumnHeader {
+ padding-inline-start: 3px;
+}
+
+/* ..... attachment column ..... */
+
+.attachmentColumnHeader {
+ padding-inline: 2px 5px;
+}
+
+treechildren::-moz-tree-image(attachmentCol) {
+ margin-inline-start: 3px;
+}
+
+/* ..... junkStatus column ..... */
+
+treechildren::-moz-tree-image(junkStatusCol) {
+ margin-inline-start: 1px;
+}
+
+/* ..... junkStatus column ..... */
+
+.junkStatusHeader {
+ padding-inline-end: 3px;
+}
+
+/* ..... correspondent column ..... */
+
+#correspondentCol {
+ padding-inline-start: 17px;
+}
+
+/* ..... subject column ..... */
+
+#subjectCol {
+ padding-inline-start: 15px;
+}
+
+#subjectCol[primary="true"] {
+ padding-inline-start: 35px;
+}
+
+treechildren::-moz-tree-image(subjectCol) {
+ margin-inline-end: 2px;
+}
+
+:root[uidensity="compact"] treechildren::-moz-tree-image {
+ margin-block-start: -1px;
+}
diff --git a/comm/mail/themes/linux/mail/messageWindow.css b/comm/mail/themes/linux/mail/messageWindow.css
new file mode 100644
index 0000000000..2f66037dec
--- /dev/null
+++ b/comm/mail/themes/linux/mail/messageWindow.css
@@ -0,0 +1,25 @@
+/* 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/. */
+
+/* ===== messageWindow.css ==============================================
+ == Styles for the message window.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/primaryToolbar.css");
+
+.mail-toolbox {
+ margin-top: 0;
+ padding-top: 0;
+}
+
+#messagepaneboxwrapper {
+ overflow: hidden;
+ min-height: 0;
+}
+
+#messagepanebox {
+ flex: 3 3;
+ text-shadow: none;
+}
diff --git a/comm/mail/themes/linux/mail/messenger.css b/comm/mail/themes/linux/mail/messenger.css
new file mode 100644
index 0000000000..fdff68aacf
--- /dev/null
+++ b/comm/mail/themes/linux/mail/messenger.css
@@ -0,0 +1,421 @@
+/* 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.css ==================================================
+ == Styles shared throughout the Messenger application.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/messenger.css");
+
+/* Special rules for dark system theme and default TB theme */
+
+:root[lwt-tree-brighttext]:not([lwt-tree]) {
+ --sidebar-background-color: var(--color-gray-80);
+ --sidebar-text-color: var(--color-gray-10);
+}
+
+:root[lwt-tree-brighttext]:not([lwt-tree]) treechildren::-moz-tree-row(current, focus) {
+ --sidebar-highlight-background-color: var(--selected-item-color);
+ --sidebar-highlight-text-color: var(--selected-item-text-color);
+}
+
+#tabs-toolbar {
+ --toolbarbutton-hover-background: var(--lwt-toolbarbutton-hover-background,
+ hsla(240, 5%, 5%, .1));
+ --toolbarbutton-hover-bordercolor: var(--lwt-toolbarbutton-hover-background,
+ hsla(240, 5%, 5%, .1));
+ --toolbarbutton-active-background: var(--lwt-toolbarbutton-active-background,
+ hsla(240, 5%, 5%, .15));
+ --toolbarbutton-active-bordercolor: var(--lwt-toolbarbutton-active-background,
+ hsla(240, 5%, 5%, .15));
+}
+
+#tabs-toolbar[brighttext] {
+ --toolbarbutton-hover-background: var(--lwt-toolbarbutton-hover-background,
+ hsla(0, 0%, 70%, .4));
+ --toolbarbutton-hover-bordercolor: var(--lwt-toolbarbutton-hover-background,
+ hsla(0, 0%, 70%, .4));
+ --toolbarbutton-active-background: var(--lwt-toolbarbutton-active-background,
+ hsla(0, 0%, 70%, .6));
+ --toolbarbutton-active-bordercolor: var(--lwt-toolbarbutton-active-background,
+ hsla(0, 0%, 70%, .6));
+}
+
+#navigation-toolbox {
+ appearance: none;
+ background-color: transparent;
+ border-top: none;
+}
+
+.titlebar-buttonbox-container {
+ margin-inline-end: 6px;
+}
+
+/**
+ * Titlebar drawing:
+ *
+ * We draw to titlebar when Gkt+ CSD is available. This is mostly
+ * straight-forward, but getting the window corners to look perfect is a bit
+ * tricky, as there are different variables to take into account.
+ *
+ * GTK windows have both a window radius (exposed via the
+ * `-moz-gtk-csd-titlebar-radius`) environment variable, and a window shadow
+ * (which we can't read back from GTK). Note that the native drawing does draw
+ * the shadow already.
+ *
+ * So there are multiple configurations to consider:
+ *
+ * * Whether we're using Wayland vs. X11
+ * * Whether we're using a lightweight theme or not.
+ *
+ * Consider the simple case (default system theme). We render the titlebar
+ * using `-moz-default-appearance: -moz-window-titlebar;`, then don't draw
+ * anything else. Success!
+ *
+ * Now consider lightweight themes: We need to render the native titlebar
+ * behind the "theme" titlebar in order to render the native shadow on X11. But
+ * we can't just use the #navigation-toolbox as that's where the lightweight
+ * theme background goes, so we need to use the #navigation-toolbox-background.
+ * We still have to apply the corner radii to #navigation-toolbox of course, so
+ * the lightweight theme background doesn't overflow the titlebar radius.
+ *
+ * In a Wayland-only world, the setup could be much simpler: We could apply the
+ * titlebar appearance to #navigation-toolbox, and just apply the border radius
+ * on the <body> or #navigation-toolbox-background to clip the extra shadow when
+ * using the system theme. For the lightweight theme, we could use
+ * appearance: none and the titlebar radius on the toolbox. In X11 however, we
+ * do need the native titlebar behind at all times.
+ */
+@media (-moz-gtk-csd-available) {
+ :root[tabsintitlebar][sizemode="normal"] {
+ background-color: transparent;
+ }
+
+ :root[tabsintitlebar] #titlebar {
+ color: CaptionText;
+ }
+
+ :root[tabsintitlebar] #titlebar:-moz-window-inactive {
+ color: InactiveCaptionText;
+ }
+
+ :root[tabsintitlebar] #titlebar:-moz-lwtheme {
+ color: inherit;
+ }
+
+ :root[tabsintitlebar] #navigation-toolbox-background {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-titlebar-maximized;
+ }
+
+ :root[tabsintitlebar][sizemode="normal"]:not([gtktiledwindow="true"]) #navigation-toolbox-background {
+ -moz-default-appearance: -moz-window-titlebar;
+ }
+
+ :root[tabsintitlebar][sizemode="normal"]:not([gtktiledwindow="true"]) #navigation-toolbox:-moz-lwtheme,
+ :root[tabsintitlebar][sizemode="normal"]:not([gtktiledwindow="true"]) ::backdrop {
+ border-top-left-radius: env(-moz-gtk-csd-titlebar-radius);
+ border-top-right-radius: env(-moz-gtk-csd-titlebar-radius);
+ }
+
+ /* Make #tabs-toolbar transparent as we style underlying #titlebar with
+ * -moz-window-titlebar (Gtk+ theme). */
+ :root[tabsintitlebar] #tabs-toolbar,
+ :root[tabsintitlebar] #toolbar-menubar {
+ appearance: none;
+ color: inherit;
+ }
+
+ :root[tabsintitlebar] #mail-menubar {
+ color: inherit;
+ }
+
+ /* The button box must appear on top of the navigation-toolbox in order for
+ * click and hover mouse events to work properly for the button in the restored
+ * window state. Otherwise, elements in the navigation-toolbox, like the menubar,
+ * can swallow those events. */
+ .titlebar-buttonbox {
+ position: relative;
+ z-index: 1;
+ align-items: center;
+ }
+
+ /* Render titlebar command buttons according to system config.
+ * Use full scale icons here as the Gtk+ does. */
+ .titlebar-min {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-minimize;
+ order: env(-moz-gtk-csd-minimize-button-position);
+ }
+ .titlebar-max {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-maximize;
+ order: env(-moz-gtk-csd-maximize-button-position);
+ }
+ .titlebar-restore {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-restore;
+ order: env(-moz-gtk-csd-maximize-button-position);
+ }
+ .titlebar-close {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-close;
+ order: env(-moz-gtk-csd-close-button-position);
+ }
+
+ /* When using lightweight themes, use our own buttons since native ones might
+ * assume a native background in order to be visible. */
+ .titlebar-button:-moz-lwtheme {
+ appearance: none !important;
+ border-radius: 100%;
+ margin-inline: 5px;
+ padding: 1px;
+ }
+ .titlebar-button > .toolbarbutton-icon:-moz-lwtheme {
+ display: inline-flex;
+ padding: 3px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ }
+ .titlebar-min:-moz-lwtheme {
+ list-style-image: url(chrome://messenger/skin/window-controls/minimize.svg);
+ }
+ .titlebar-max:-moz-lwtheme {
+ list-style-image: url(chrome://messenger/skin/window-controls/maximize.svg);
+ }
+ .titlebar-restore:-moz-lwtheme {
+ list-style-image: url(chrome://messenger/skin/window-controls/restore.svg);
+ }
+ .titlebar-close:-moz-lwtheme {
+ list-style-image: url(chrome://messenger/skin/window-controls/close.svg);
+ }
+ .titlebar-button:-moz-lwtheme:hover {
+ background-color: color-mix(in srgb, currentColor 20%, transparent);
+ }
+ .titlebar-button:-moz-lwtheme:hover:active {
+ background-color: color-mix(in srgb, currentColor 28%, transparent);
+ }
+ .titlebar-close:-moz-lwtheme:hover {
+ background-color: #d70022;
+ color: white;
+ }
+ .titlebar-close:-moz-lwtheme:hover:active {
+ background-color: #ff0039;
+ }
+
+ @media not (-moz-gtk-csd-minimize-button) {
+ .titlebar-min {
+ display: none;
+ }
+ }
+ @media not (-moz-gtk-csd-maximize-button) {
+ .titlebar-restore,
+ .titlebar-max {
+ display: none;
+ }
+ }
+ @media not (-moz-gtk-csd-close-button) {
+ .titlebar-close {
+ display: none;
+ }
+ }
+
+ @media (-moz-gtk-csd-reversed-placement) {
+ .titlebar-buttonbox-container {
+ margin-inline-start: 6px;
+ }
+ }
+}
+
+toolbar:not(.inline-toolbar,.contentTabToolbar,:-moz-lwtheme) {
+ appearance: auto;
+ -moz-default-appearance: menubar;
+}
+
+.inline-toolbar,
+.contentTabToolbar {
+ appearance: none;
+ min-height: 15px;
+ padding: 0;
+}
+
+.inline-toolbar > toolbarseparator {
+ height: 28px;
+}
+
+menulist {
+ padding: 1px 6px !important;
+}
+
+/*
+ * Override the menulist icon forbidding in menu.css so that we can show
+ * check-marks. radio-marks and folder icons. bug 443516
+ */
+.menulist-menupopup > menuitem > .menu-iconic-left,
+menulist > menupopup >
+ menuitem:is(.menuitem-iconic,[type="radio"],[type="checkbox"]) >
+ .menu-iconic-left,
+.menulist-menupopup > menu > .menu-iconic-left,
+menulist > menupopup >
+ menu:is(.menuitem-iconic,[type="radio"],[type="checkbox"]) >
+ .menu-iconic-left {
+ display: flex;
+}
+
+/* ::::: Toolbar customization ::::: */
+
+toolbarpaletteitem[place="toolbar"] > toolbarspacer {
+ width: 11px;
+}
+
+/* ::::: toolbarbutton menu-button ::::: */
+
+toolbarbutton[is="toolbarbutton-menu-button"] {
+ align-items: stretch;
+ appearance: auto;
+ -moz-default-appearance: dualbutton;
+ flex-direction: row !important;
+ padding: 0 !important;
+}
+
+/* .......... dropmarker .......... */
+
+.toolbarbutton-menubutton-dropmarker {
+ appearance: auto;
+ -moz-default-appearance: toolbarbutton-dropdown !important;
+ list-style-image: none;
+}
+
+/* ::::: toolbarbutton ::::: */
+
+.toolbarbutton-1,
+.toolbarbutton-menubutton-button,
+.toolbarbutton-1[is="toolbarbutton-menu-button"],
+.toolbarbutton-1 .toolbarbutton-menu-dropmarker,
+.toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker {
+ appearance: none;
+}
+
+.toolbarbutton-1 .toolbarbutton-menu-dropmarker {
+ margin-inline-start: 3px;
+}
+
+toolbar[mode="text"] .toolbarbutton-text {
+ margin: 0 !important;
+ padding-inline: 2px !important;
+}
+
+.toolbarbutton-1[disabled=true] .toolbarbutton-icon,
+.toolbarbutton-1[disabled=true] .toolbarbutton-text,
+.toolbarbutton-1[disabled=true] .toolbarbutton-menu-dropmarker,
+.toolbarbutton-1[disabled=true] > .toolbarbutton-menubutton-dropmarker {
+ opacity: .4;
+}
+
+.sidebar-header .toolbarbutton-text:not([value]) {
+ display: none;
+}
+
+button[is="toolbarbutton-menu-button"] > .button-box > button {
+ margin-block: -5px;
+}
+
+/* message column icons */
+
+.treecol-sortdirection {
+ appearance: none;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+treecol[sortDirection="ascending"]:not([hideheader="true"]) > .treecol-sortdirection {
+ list-style-image: url("chrome://global/skin/icons/arrow-down-12.svg");
+}
+
+treecol[sortDirection="descending"]:not([hideheader="true"]) > .treecol-sortdirection {
+ list-style-image: url("chrome://global/skin/icons/arrow-up-12.svg");
+}
+
+:root[lwt-tree] treecol:not([hideheader="true"]),
+:root[lwt-tree] .tree-columnpicker-button:not([hideheader="true"]) {
+ padding-inline-start: 7px;
+ padding-inline-end: 6px;
+}
+
+:root[lwt-tree] treechildren::-moz-tree-row(odd) {
+ background-color: transparent;
+}
+
+:root[lwt-tree] treechildren::-moz-tree-row(hover) {
+ background-color: hsla(0, 0%, 50%, 0.15);
+}
+
+treechildren::-moz-tree-cell-text {
+ padding-inline-start: 5px;
+}
+
+/* Status panel */
+
+.statuspanel-label {
+ margin: 0;
+ padding: 2px 4px;
+ background-color: -moz-dialog;
+ border: 1px none ThreeDShadow;
+ border-top-style: solid;
+ color: -moz-dialogText;
+ text-shadow: none;
+}
+
+.statuspanel-label:-moz-locale-dir(ltr):not([mirror]),
+.statuspanel-label:-moz-locale-dir(rtl)[mirror] {
+ border-right-style: solid;
+ border-top-right-radius: .3em;
+ margin-right: 1em;
+}
+
+.statuspanel-label:-moz-locale-dir(rtl):not([mirror]),
+.statuspanel-label:-moz-locale-dir(ltr)[mirror] {
+ border-left-style: solid;
+ border-top-left-radius: .3em;
+ margin-left: 1em;
+}
+
+.contentTabInstance {
+ background-color: -moz-dialog;
+}
+
+.contentTabInstance:-moz-lwtheme {
+ background-color: transparent;
+ background-image: linear-gradient(transparent 40px, -moz-dialog 40px);
+}
+
+fieldset {
+ margin: 5px;
+ padding: 5px;
+ border: none;
+}
+
+legend {
+ font-weight: bold;
+}
+
+fieldset > hbox,
+fieldset > vbox,
+fieldset > radiogroup {
+ width: -moz-available;
+}
+
+/* UI Density customization */
+
+treechildren::-moz-tree-row {
+ min-height: 1.6rem;
+}
+
+:root[uidensity="compact"] treechildren::-moz-tree-row {
+ min-height: 1.3rem;
+}
+
+:root[uidensity="touch"] treechildren::-moz-tree-row {
+ min-height: 2.4rem;
+}
diff --git a/comm/mail/themes/linux/mail/multimessageview.css b/comm/mail/themes/linux/mail/multimessageview.css
new file mode 100644
index 0000000000..aaea75ae57
--- /dev/null
+++ b/comm/mail/themes/linux/mail/multimessageview.css
@@ -0,0 +1,11 @@
+/* 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 url("chrome://messenger/skin/shared/multimessageview.css");
+
+/* linux-specific overrides for multimessageview.css*/
+
+.star {
+ top: 0.5em;
+}
diff --git a/comm/mail/themes/linux/mail/newsblog/feed-subscriptions.css b/comm/mail/themes/linux/mail/newsblog/feed-subscriptions.css
new file mode 100644
index 0000000000..482b11b8da
--- /dev/null
+++ b/comm/mail/themes/linux/mail/newsblog/feed-subscriptions.css
@@ -0,0 +1,7 @@
+/* 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/. */
+
+/* ::::: Feed Subscription styling :::::: */
+
+@import url("chrome://messenger/skin/shared/feedSubscribe.css");
diff --git a/comm/mail/themes/linux/mail/panelUI.css b/comm/mail/themes/linux/mail/panelUI.css
new file mode 100644
index 0000000000..51517dee4a
--- /dev/null
+++ b/comm/mail/themes/linux/mail/panelUI.css
@@ -0,0 +1,21 @@
+/* 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 url("chrome://messenger/skin/shared/panelUI.css");
+
+#appMenu-popup {
+ margin-top: -1px;
+}
+
+#appMenu-popup {
+ margin-inline: 0 -13px;
+}
+
+.subviewradio > .radio-label-box {
+ appearance: none;
+}
+
+.subviewbutton-iconic > .menu-text {
+ margin-inline-start: 21px !important;
+}
diff --git a/comm/mail/themes/linux/mail/popupPanel.css b/comm/mail/themes/linux/mail/popupPanel.css
new file mode 100644
index 0000000000..433df17ab7
--- /dev/null
+++ b/comm/mail/themes/linux/mail/popupPanel.css
@@ -0,0 +1,20 @@
+/* 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 url("chrome://messenger/skin/shared/popupPanel.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+#contactMoveDisabledText {
+ margin-top: 5px;
+}
+
+html|input.editContactTextbox {
+ padding: 3px 6px;
+}
+
+#messageHeaderCustomizationPanel {
+ margin-top: -10px;
+ margin-inline-end: 1px;
+}
diff --git a/comm/mail/themes/linux/mail/preferences/alwaysAsk.png b/comm/mail/themes/linux/mail/preferences/alwaysAsk.png
new file mode 100644
index 0000000000..45256d4e76
--- /dev/null
+++ b/comm/mail/themes/linux/mail/preferences/alwaysAsk.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/preferences/applications.css b/comm/mail/themes/linux/mail/preferences/applications.css
new file mode 100644
index 0000000000..c58ae9fdf5
--- /dev/null
+++ b/comm/mail/themes/linux/mail/preferences/applications.css
@@ -0,0 +1,25 @@
+/* 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 url("chrome://messenger/skin/shared/preferences/applications.css");
+
+/**
+ * Used by the cloudFile manager
+ */
+
+.cloudfileAccount > label {
+ margin-inline-start: 1px;
+ margin-block: initial;
+}
+
+.cloudfileAccount > input {
+ min-height: unset !important;
+ margin: 0 !important;
+ padding-block: 2px 3px !important;
+ padding-inline: 4px 3px !important;
+}
+
+.actionsMenu > menupopup > menuitem {
+ padding-inline-start: 12px;
+}
diff --git a/comm/mail/themes/linux/mail/preferences/preferences.css b/comm/mail/themes/linux/mail/preferences/preferences.css
new file mode 100644
index 0000000000..1797a762a7
--- /dev/null
+++ b/comm/mail/themes/linux/mail/preferences/preferences.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/preferences/preferences.css");
+@namespace html "http://www.w3.org/1999/xhtml";
+
+.treecol-sortdirection {
+ /* override the Linux only toolkit rule */
+ appearance: none;
+}
+
+tab + tab {
+ margin-inline-start: 0;
+}
+
+/**
+ * Dialog
+ */
+
+#defaultWebSearchPopup > menuitem > .menu-iconic-left {
+ margin-inline: 5px !important;
+}
diff --git a/comm/mail/themes/linux/mail/preferences/saveFile.png b/comm/mail/themes/linux/mail/preferences/saveFile.png
new file mode 100644
index 0000000000..c210e8473f
--- /dev/null
+++ b/comm/mail/themes/linux/mail/preferences/saveFile.png
Binary files differ
diff --git a/comm/mail/themes/linux/mail/primaryToolbar.css b/comm/mail/themes/linux/mail/primaryToolbar.css
new file mode 100644
index 0000000000..6e0a76b4d1
--- /dev/null
+++ b/comm/mail/themes/linux/mail/primaryToolbar.css
@@ -0,0 +1,15 @@
+/* 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 url("chrome://messenger/skin/shared/primaryToolbar.css");
+
+/* ::::: Mail Toolbars and Menubars ::::: */
+
+.mail-toolbox::after,
+.contentTabToolbox::after {
+ content: "";
+ display: flex;
+ height: 1px;
+ border-bottom: 1px solid var(--chrome-content-separator-color);
+}
diff --git a/comm/mail/themes/linux/mail/searchBox.css b/comm/mail/themes/linux/mail/searchBox.css
new file mode 100644
index 0000000000..d638743e12
--- /dev/null
+++ b/comm/mail/themes/linux/mail/searchBox.css
@@ -0,0 +1,24 @@
+/* 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 url("chrome://messenger/skin/shared/searchBox.css");
+
+.search-icon {
+ margin-inline-start: 7px;
+}
+
+.remote-gloda-search-container {
+ padding-block: 3px 2px;
+}
+
+.searchBox,
+.themeableSearchBox {
+ padding-block: 3px;
+ padding-inline: 4px 2px;
+ margin: 0 3px;
+}
+
+.autocomplete-richlistitem[type^="gloda-"] {
+ padding-inline-start: 10px;
+}
diff --git a/comm/mail/themes/linux/mail/searchDialog.css b/comm/mail/themes/linux/mail/searchDialog.css
new file mode 100644
index 0000000000..7504aa73a0
--- /dev/null
+++ b/comm/mail/themes/linux/mail/searchDialog.css
@@ -0,0 +1,41 @@
+/* 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/. */
+
+/* ===== searchDialog.css ===============================================
+ == Styles for the Mail Search dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/searchDialog.css");
+
+.search-menulist,
+.search-value-menulist {
+ width: 16em;
+}
+
+.small-button {
+ min-width: 3em;
+ margin: 2px;
+}
+
+.small-button + .small-button {
+ margin-inline-start: 0;
+ margin-inline-end: 4px;
+}
+
+treechildren::-moz-tree-cell-text(subjectCol) {
+ padding-inline-start: 0;
+}
+
+#checkSearchSubFolders,
+#checkSearchOnline {
+ margin-inline-start: 6px;
+}
+
+#booleanAndGroup {
+ margin-inline-start: 2px;
+}
+
+#gray_horizontal_splitter {
+ appearance: none;
+}
diff --git a/comm/mail/themes/linux/mail/spacesToolbar.css b/comm/mail/themes/linux/mail/spacesToolbar.css
new file mode 100644
index 0000000000..00e9471e10
--- /dev/null
+++ b/comm/mail/themes/linux/mail/spacesToolbar.css
@@ -0,0 +1,11 @@
+/* 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 url("chrome://messenger/skin/shared/spacesToolbar.css");
+
+@media (-moz-gtk-csd-available) {
+ :root[tabsintitlebar]:not([gtktiledwindow="true"]) .spaces-toolbar:not([hidden]) {
+ border-start-start-radius: env(-moz-gtk-csd-titlebar-radius);
+ }
+}
diff --git a/comm/mail/themes/linux/mail/tabmail.css b/comm/mail/themes/linux/mail/tabmail.css
new file mode 100644
index 0000000000..9e72bbf66a
--- /dev/null
+++ b/comm/mail/themes/linux/mail/tabmail.css
@@ -0,0 +1,91 @@
+/* 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 url("chrome://messenger/skin/shared/tabmail.css");
+
+/**
+ * Tabmail Tabs
+ */
+
+:root:not([tabsintitlebar]) #navigation-toolbox:not(:-moz-lwtheme) {
+ appearance: auto;
+ -moz-default-appearance: menubar;
+}
+
+#tabs-toolbar {
+ -moz-window-dragging: drag;
+ padding-block-end: 0;
+}
+
+#tabs-toolbar:not(:-moz-lwtheme) {
+ color: -moz-menubartext;
+}
+
+#tabpanelcontainer:-moz-lwtheme {
+ color: inherit;
+}
+
+tabpanels {
+ appearance: none;
+ background-color: transparent;
+}
+
+/**
+ * Tab
+ */
+
+.tabmail-tab .tab-label-container {
+ /* tabmail-tab focus ring */
+ border: 1px dotted transparent;
+ margin: -1px !important; /* let the border not consume any space, like outline */
+}
+
+.tabmail-tab[selected]:focus .tab-label-container {
+ border-color: -moz-DialogText;
+}
+
+/**
+ * Tab Scrollbox Arrow Buttons
+ */
+
+#tabmail-arrowscrollbox::part(scrollbutton-up),
+#tabmail-arrowscrollbox::part(scrollbutton-down) {
+ padding: 3px !important;
+ border-style: none !important;
+}
+
+#tabmail-arrowscrollbox:not([scrolledtostart=true])::part(scrollbutton-up):hover,
+#tabmail-arrowscrollbox:not([scrolledtoend=true])::part(scrollbutton-down):hover {
+ background: var(--toolbarbutton-active-background);
+}
+
+#tabmail-arrowscrollbox[scrolledtostart=true]::part(scrollbutton-up),
+#tabmail-arrowscrollbox[scrolledtoend=true]::part(scrollbutton-down) {
+ --toolbarbutton-icon-fill-opacity: .4;
+}
+
+/**
+ * All Tabs Menupopup
+ */
+
+.tabs-alltabs-button {
+ min-width: 24px;
+ padding-right: 1px;
+ padding-left: 1px;
+}
+
+.tabs-alltabs-button > .toolbarbutton-icon {
+ margin-inline-end: 0;
+}
+
+.alltabs-item > .menu-iconic-left {
+ visibility: visible !important;
+}
+
+/* Content Tabs */
+.contentTabAddress {
+ height: 32px;
+ padding-left: 10px;
+ padding-right: 10px;
+}
diff --git a/comm/mail/themes/linux/mail/themeableDialog.css b/comm/mail/themes/linux/mail/themeableDialog.css
new file mode 100644
index 0000000000..73537a5701
--- /dev/null
+++ b/comm/mail/themes/linux/mail/themeableDialog.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/themeableDialog.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:root:-moz-lwtheme {
+ appearance: none;
+}
+
+button > .button-box {
+ appearance: none;
+ padding-block: 4px;
+}
+
+#resetColor > .button-box {
+ padding-block: 0;
+}
+
+.button-menu-dropmarker {
+ appearance: none;
+ padding: 0;
+ border: none;
+ background-color: transparent;
+ list-style-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+menulist::part(dropmarker) {
+ display: flex;
+}
+
+html|input {
+ padding: 4px;
+}
+
+html|input[type="number"]::-moz-number-spin-up,
+html|input[type="number"]::-moz-number-spin-down {
+ min-height: 0.7em;
+}
+
+menulist[is="menulist-editable"][editable="true"] {
+ appearance: none;
+ margin-inline-end: 4px;
+ padding: 0;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(text-input) {
+ padding: 5px 4px;
+ margin-block: -2px;
+ margin-inline: -7px 7px;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(dropmarker) {
+ width: initial;
+ min-width: 0;
+ margin-block: 0;
+ margin-inline-end: 3px;
+}
+
+menulist menupopup menu,
+menulist menupopup menuitem,
+button menupopup menu,
+button menupopup menuitem {
+ appearance: none;
+ padding: 5px 8px;
+}
+
+.menu-right {
+ height: 1em;
+ margin-inline-end: 3px;
+ transform: scale(0.7);
+}
+
+tab + tab {
+ margin-inline-start: 0;
+}
diff --git a/comm/mail/themes/linux/mail/variables.css b/comm/mail/themes/linux/mail/variables.css
new file mode 100644
index 0000000000..f8bb95f640
--- /dev/null
+++ b/comm/mail/themes/linux/mail/variables.css
@@ -0,0 +1,18 @@
+/* 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 url("chrome://messenger/skin/shared/variables.css");
+
+:root {
+ --tabline-color: var(--selected-item-color);
+ --toolbar-non-lwt-bgcolor: color-mix(in srgb, -moz-dialog 85%, white);
+ --toolbar-non-lwt-textcolor: -moz-dialogtext;
+ --toolbar-non-lwt-bgimage: linear-gradient(rgba(255, 255, 255, 0.15),
+ rgba(255, 255, 255, 0.15));
+ --chrome-content-separator-color: ThreeDShadow;
+ --row-grouped-header-bg-color: -moz-dialog;
+ --row-grouped-header-bg-color-selected: var(--selected-item-color);
+ --panel-separator-color: ThreeDShadow;
+ --autocomplete-popup-url-color: -moz-nativehyperlinktext;
+}
diff --git a/comm/mail/themes/linux/moz.build b/comm/mail/themes/linux/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/mail/themes/linux/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/themes/moz.build b/comm/mail/themes/moz.build
new file mode 100644
index 0000000000..1fee74dca7
--- /dev/null
+++ b/comm/mail/themes/moz.build
@@ -0,0 +1,25 @@
+# vim: set filetype=python:
+# 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_JS_MODULES += [
+ "BuiltInThemes.sys.mjs",
+ "ThemeVariableMap.sys.mjs",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ EXTRA_JS_MODULES += [
+ "Windows8WindowFrameColor.sys.mjs",
+ ]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ DIRS += ["linux"]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ DIRS += ["osx"]
+else:
+ DIRS += ["windows"]
+
+DIRS += [
+ "addons",
+]
diff --git a/comm/mail/themes/osx/editor/EditorDialog.css b/comm/mail/themes/osx/editor/EditorDialog.css
new file mode 100644
index 0000000000..41b2e53542
--- /dev/null
+++ b/comm/mail/themes/osx/editor/EditorDialog.css
@@ -0,0 +1,18 @@
+/* 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 url("chrome://messenger/skin/shared/EditorDialog.css");
+
+groupbox {
+ padding: 0 8px 5px;
+ margin: 6px;
+ margin-top: 1.5em;
+}
+
+groupbox > .groupbox-title > .header {
+ font-size: 1.18em;
+ margin-top: -1.3em;
+ margin-bottom: 5px;
+ margin-inline-start: -5px;
+}
diff --git a/comm/mail/themes/osx/editor/img-align-bottom.gif b/comm/mail/themes/osx/editor/img-align-bottom.gif
new file mode 100644
index 0000000000..a8ba157970
--- /dev/null
+++ b/comm/mail/themes/osx/editor/img-align-bottom.gif
Binary files differ
diff --git a/comm/mail/themes/osx/editor/img-align-left.gif b/comm/mail/themes/osx/editor/img-align-left.gif
new file mode 100644
index 0000000000..117b93fcaf
--- /dev/null
+++ b/comm/mail/themes/osx/editor/img-align-left.gif
Binary files differ
diff --git a/comm/mail/themes/osx/editor/img-align-middle.gif b/comm/mail/themes/osx/editor/img-align-middle.gif
new file mode 100644
index 0000000000..4189f7ac33
--- /dev/null
+++ b/comm/mail/themes/osx/editor/img-align-middle.gif
Binary files differ
diff --git a/comm/mail/themes/osx/editor/img-align-right.gif b/comm/mail/themes/osx/editor/img-align-right.gif
new file mode 100644
index 0000000000..8e679f50a4
--- /dev/null
+++ b/comm/mail/themes/osx/editor/img-align-right.gif
Binary files differ
diff --git a/comm/mail/themes/osx/editor/img-align-top.gif b/comm/mail/themes/osx/editor/img-align-top.gif
new file mode 100644
index 0000000000..d871945929
--- /dev/null
+++ b/comm/mail/themes/osx/editor/img-align-top.gif
Binary files differ
diff --git a/comm/mail/themes/osx/jar.mn b/comm/mail/themes/osx/jar.mn
new file mode 100644
index 0000000000..a9641e5eb5
--- /dev/null
+++ b/comm/mail/themes/osx/jar.mn
@@ -0,0 +1,84 @@
+# 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/.
+
+classic.jar:
+% skin messenger classic/1.0 %skin/classic/messenger/
+#include ../shared/jar.inc.mn
+ skin/classic/messenger/glodaFacetView.css (mail/glodaFacetView.css)
+ skin/classic/messenger/multimessageview.css (mail/multimessageview.css)
+ skin/classic/messenger/messenger.css (mail/messenger.css)
+ skin/classic/messenger/primaryToolbar.css (mail/primaryToolbar.css)
+ skin/classic/messenger/accountCentral.css (mail/accountCentral.css)
+ skin/classic/messenger/accountManage.css (mail/accountManage.css)
+ skin/classic/messenger/chat.css (mail/chat.css)
+ skin/classic/messenger/common.css (mail/common.css)
+ skin/classic/messenger/contextMenu.css (mail/contextMenu.css)
+ skin/classic/messenger/imAccounts.css (mail/imAccounts.css)
+ skin/classic/messenger/compacttheme.css (mail/compacttheme.css)
+ skin/classic/messenger/customizeToolbar.css (mail/customizeToolbar.css)
+ skin/classic/messenger/menulist.css (mail/menulist.css)
+ skin/classic/messenger/messageBody.css (mail/messageBody.css)
+ skin/classic/messenger/message-bar.css (mail/message-bar.css)
+ skin/classic/messenger/messageHeader.css (mail/messageHeader.css)
+ skin/classic/messenger/messageIcons.css (mail/messageIcons.css)
+ skin/classic/messenger/messageWindow.css (mail/messageWindow.css)
+ skin/classic/messenger/attachmentList.css (mail/attachmentList.css)
+ skin/classic/messenger/mailWindow1.css (mail/mailWindow1.css)
+ skin/classic/messenger/searchBox.css (mail/searchBox.css)
+ skin/classic/messenger/junkMail.css (mail/junkMail.css)
+ skin/classic/messenger/input-fields.css (mail/input-fields.css)
+ skin/classic/messenger/folderMenus.css (mail/folderMenus.css)
+ skin/classic/messenger/folderPane.css (mail/folderPane.css)
+ skin/classic/messenger/searchDialog.css (mail/searchDialog.css)
+ skin/classic/messenger/spacesToolbar.css (mail/spacesToolbar.css)
+ skin/classic/messenger/filterDialog.css (mail/filterDialog.css)
+ skin/classic/messenger/panelUI.css (mail/panelUI.css)
+ skin/classic/messenger/tabmail.css (mail/tabmail.css)
+ skin/classic/messenger/themeableDialog.css (mail/themeableDialog.css)
+ skin/classic/messenger/popupPanel.css (mail/popupPanel.css)
+ skin/classic/messenger/variables.css (mail/variables.css)
+ skin/classic/messenger/activity/activity.css (mail/activity/activity.css)
+ skin/classic/messenger/activity/defaultProcessIcon.png (mail/activity/defaultProcessIcon.png)
+ skin/classic/messenger/activity/defaultEventIcon.png (mail/activity/defaultEventIcon.png)
+ skin/classic/messenger/activity/error.png (mail/activity/error.png)
+ skin/classic/messenger/activity/undoIcon.png (mail/activity/undoIcon.png)
+ skin/classic/messenger/activity/syncMailIcon.png (mail/activity/syncMailIcon.png)
+ skin/classic/messenger/activity/sendMailIcon.png (mail/activity/sendMailIcon.png)
+ skin/classic/messenger/activity/removeItemIcon.png (mail/activity/removeItemIcon.png)
+ skin/classic/messenger/activity/addItemIcon.png (mail/activity/addItemIcon.png)
+ skin/classic/messenger/activity/moveMailIcon.png (mail/activity/moveMailIcon.png)
+ skin/classic/messenger/activity/copyMailIcon.png (mail/activity/copyMailIcon.png)
+ skin/classic/messenger/activity/deleteMailIcon.png (mail/activity/deleteMailIcon.png)
+ skin/classic/messenger/activity/compactMailIcon.png (mail/activity/compactMailIcon.png)
+ skin/classic/messenger/activity/indexMailIcon.png (mail/activity/indexMailIcon.png)
+ skin/classic/messenger/activity/warning.png (mail/activity/warning.png)
+ skin/classic/messenger/addressbook/abContactsPanel.css (mail/addrbook/abContactsPanel.css)
+ skin/classic/messenger/addressbook/cardDialog.css (mail/addrbook/cardDialog.css)
+ skin/classic/messenger/messengercompose/messengercompose.css (mail/compose/messengercompose.css)
+ skin/classic/messenger/downloads/aboutDownloads.css (mail/downloads/aboutDownloads.css)
+% skin messenger-newsblog classic/1.0 %skin/classic/messenger-newsblog/
+ skin/classic/messenger-newsblog/feed-subscriptions.css (mail/newsblog/feed-subscriptions.css)
+ skin/classic/messenger/preferences/alwaysAsk.png (mail/preferences/alwaysAsk.png)
+ skin/classic/messenger/preferences/application.png (mail/preferences/application.png)
+ skin/classic/messenger/preferences/saveFile.png (mail/preferences/saveFile.png)
+ skin/classic/messenger/preferences/preferences.css (mail/preferences/preferences.css)
+ skin/classic/messenger/preferences/applications.css (mail/preferences/applications.css)
+ skin/classic/messenger/icons/multicolor.png (mail/icons/multicolor.png)
+ skin/classic/messenger/icons/identity.png (mail/icons/identity.png)
+ skin/classic/messenger/icons/identity@2x.png (mail/icons/identity@2x.png)
+ skin/classic/messenger/icons/error.png (mail/icons/error.png)
+ skin/classic/messenger/icons/connecting.png (mail/icons/connecting.png)
+ skin/classic/messenger/icons/arrow/arrow-left.png (mail/icons/arrow/arrow-left.png)
+ skin/classic/messenger/icons/arrow/arrow-right.png (mail/icons/arrow/arrow-right.png)
+ skin/classic/messenger/icons/arrow/arrow-up.png (mail/icons/arrow/arrow-up.png)
+ skin/classic/messenger/icons/arrow/arrow-down.png (mail/icons/arrow/arrow-down.png)
+ skin/classic/messenger/icons/arrow/arrow-right-dim.png (mail/icons/arrow/arrow-right-dim.png)
+ skin/classic/messenger/icons/arrow/arrow-down-dim.png (mail/icons/arrow/arrow-down-dim.png)
+% skin editor classic/1.0 %skin/classic/editor/
+ skin/classic/editor/EditorDialog.css (editor/EditorDialog.css)
+ skin/classic/editor/icons/img-align-bottom.gif (editor/img-align-bottom.gif)
+ skin/classic/editor/icons/img-align-left.gif (editor/img-align-left.gif)
+ skin/classic/editor/icons/img-align-middle.gif (editor/img-align-middle.gif)
+ skin/classic/editor/icons/img-align-right.gif (editor/img-align-right.gif)
+ skin/classic/editor/icons/img-align-top.gif (editor/img-align-top.gif)
diff --git a/comm/mail/themes/osx/mail/accountCentral.css b/comm/mail/themes/osx/mail/accountCentral.css
new file mode 100644
index 0000000000..cd6332a4cb
--- /dev/null
+++ b/comm/mail/themes/osx/mail/accountCentral.css
@@ -0,0 +1,14 @@
+/* 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/. */
+
+/* ===== accountCentral.css ==========================================
+ == Styles for the Messenger Account Central panel.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/accountCentral.css");
+
+#accountName {
+ /* Prevents UI jumping when dynamically changing the content. */
+ min-height: 17px;
+}
diff --git a/comm/mail/themes/osx/mail/accountManage.css b/comm/mail/themes/osx/mail/accountManage.css
new file mode 100644
index 0000000000..468950217b
--- /dev/null
+++ b/comm/mail/themes/osx/mail/accountManage.css
@@ -0,0 +1,13 @@
+/* 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 url("chrome://messenger/skin/shared/accountManage.css");
+
+description {
+ margin: 4px 4px 8px;
+}
+
+menulist > menupopup .menu-right {
+ margin-inline: 0;
+}
diff --git a/comm/mail/themes/osx/mail/activity/activity.css b/comm/mail/themes/osx/mail/activity/activity.css
new file mode 100644
index 0000000000..bf348bdf7c
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/activity.css
@@ -0,0 +1,5 @@
+/* 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 url("chrome://messenger/skin/shared/activity/activity.css");
diff --git a/comm/mail/themes/osx/mail/activity/addItemIcon.png b/comm/mail/themes/osx/mail/activity/addItemIcon.png
new file mode 100644
index 0000000000..2505f5fa79
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/addItemIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/compactMailIcon.png b/comm/mail/themes/osx/mail/activity/compactMailIcon.png
new file mode 100644
index 0000000000..501ee0d89c
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/compactMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/copyMailIcon.png b/comm/mail/themes/osx/mail/activity/copyMailIcon.png
new file mode 100644
index 0000000000..7fa1cfebc1
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/copyMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/defaultEventIcon.png b/comm/mail/themes/osx/mail/activity/defaultEventIcon.png
new file mode 100644
index 0000000000..033e7ec1b3
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/defaultEventIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/defaultProcessIcon.png b/comm/mail/themes/osx/mail/activity/defaultProcessIcon.png
new file mode 100644
index 0000000000..bcc073e95a
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/defaultProcessIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/deleteMailIcon.png b/comm/mail/themes/osx/mail/activity/deleteMailIcon.png
new file mode 100644
index 0000000000..ae7b9e23b7
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/deleteMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/error.png b/comm/mail/themes/osx/mail/activity/error.png
new file mode 100644
index 0000000000..de466bbfa5
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/error.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/indexMailIcon.png b/comm/mail/themes/osx/mail/activity/indexMailIcon.png
new file mode 100644
index 0000000000..0ba1a64d53
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/indexMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/moveMailIcon.png b/comm/mail/themes/osx/mail/activity/moveMailIcon.png
new file mode 100644
index 0000000000..ab263d45e7
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/moveMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/removeItemIcon.png b/comm/mail/themes/osx/mail/activity/removeItemIcon.png
new file mode 100644
index 0000000000..4ca7df7e20
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/removeItemIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/sendMailIcon.png b/comm/mail/themes/osx/mail/activity/sendMailIcon.png
new file mode 100644
index 0000000000..1f2c042e4a
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/sendMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/syncMailIcon.png b/comm/mail/themes/osx/mail/activity/syncMailIcon.png
new file mode 100644
index 0000000000..f06a254930
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/syncMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/undoIcon.png b/comm/mail/themes/osx/mail/activity/undoIcon.png
new file mode 100644
index 0000000000..ed6f67fa7d
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/undoIcon.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/activity/warning.png b/comm/mail/themes/osx/mail/activity/warning.png
new file mode 100644
index 0000000000..7471e1e45f
--- /dev/null
+++ b/comm/mail/themes/osx/mail/activity/warning.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/addrbook/abContactsPanel.css b/comm/mail/themes/osx/mail/addrbook/abContactsPanel.css
new file mode 100644
index 0000000000..6839fe3afd
--- /dev/null
+++ b/comm/mail/themes/osx/mail/addrbook/abContactsPanel.css
@@ -0,0 +1,22 @@
+/* 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/. */
+
+/* ===== sidebarPanel.css ===============================================
+ == Styles for the Address Book sidebar panel.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/abContactsPanel.css");
+
+#AbPickerHeader > label {
+ margin-block: 6px 0;
+}
+
+#abContextMenuButton {
+ margin-block: -3px -1px;
+ padding-inline: 4px 0;
+}
+
+#GeneratedName {
+ padding-inline-start: 26px;
+}
diff --git a/comm/mail/themes/osx/mail/addrbook/cardDialog.css b/comm/mail/themes/osx/mail/addrbook/cardDialog.css
new file mode 100644
index 0000000000..9b4e96cc4b
--- /dev/null
+++ b/comm/mail/themes/osx/mail/addrbook/cardDialog.css
@@ -0,0 +1,13 @@
+/* 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/. */
+
+/* ===== cardViewOverlay.css ============================================
+ == Styles for the Address Book Card view.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/cardDialog.css");
+
+menulist::part(icon) {
+ margin-inline-end: 2px;
+}
diff --git a/comm/mail/themes/osx/mail/attachmentList.css b/comm/mail/themes/osx/mail/attachmentList.css
new file mode 100644
index 0000000000..bfb960281b
--- /dev/null
+++ b/comm/mail/themes/osx/mail/attachmentList.css
@@ -0,0 +1,7 @@
+/* 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 url("chrome://messenger/skin/shared/attachmentList.css");
+
+/* styles for the attachment list */
diff --git a/comm/mail/themes/osx/mail/chat.css b/comm/mail/themes/osx/mail/chat.css
new file mode 100644
index 0000000000..608d2c5854
--- /dev/null
+++ b/comm/mail/themes/osx/mail/chat.css
@@ -0,0 +1,117 @@
+/* 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 url("chrome://messenger/skin/shared/chat.css");
+
+.convUnreadTargetedCount {
+ padding: 2px 7px;
+}
+
+.conv-counter[value^="-"] {
+ margin-bottom: 1px;
+ /* The 6px padding-end from .conv-counter is split into a 1px margin-end
+ (to avoid the border) and 5px padding-end (as regular padding). */
+ margin-inline-end: 1px;
+ padding-top: 1px;
+ padding-bottom: 0;
+ padding-inline-end: 5px;
+}
+
+.conv-nicklist > richlistitem > label {
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
+}
+
+.conv-nicklist-header {
+ appearance: auto;
+ -moz-default-appearance: treeheadercell;
+ margin-right: -1px;
+}
+
+.conv-nicklist-header-label {
+ margin-inline: 3px 2px !important;
+ margin-block: 1px 0 !important;
+}
+
+.conv-nicklist, #logTree {
+ appearance: none;
+ width: 250px;
+ border: 0;
+}
+
+#nicklist > richlistitem[inactive][selected] > label {
+ color: -moz-DialogText !important;
+}
+
+richlistitem[is="chat-group-richlistitem"] .twisty {
+ margin-inline-end: 3px;
+}
+
+#chatPanel:-moz-lwtheme {
+ color: -moz-DialogText;
+ text-shadow: none;
+}
+
+/* Adaptation from #folderTree */
+:root:not([lwt-tree]) #chatPanel {
+ background-color: -moz-OddTreeRow;
+}
+
+@media (prefers-contrast) {
+ :root:not([lwt-tree]) #chatPanel {
+ background-color: Field;
+ }
+}
+
+#contactlistbox {
+ background: transparent;
+ appearance: none;
+}
+
+.convUnreadCount,
+.contactDisplayName,
+.convDisplayName,
+.contactStatusText,
+.convStatusText {
+ margin-top: 3px;
+}
+
+#statusTypeIcon:-moz-locale-dir(ltr) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+#statusTypeIcon:-moz-locale-dir(rtl) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+#statusTypeIcon > .toolbarbutton-text {
+ display: none;
+}
+
+.statusMessageToolbarItem {
+ margin-inline-start: -3px;
+ margin-bottom: 2px;
+ padding-bottom: 3px;
+}
+
+/* Adaptation of #folderpane_splitter */
+#listSplitter,
+#contextSplitter {
+ min-width: 5px;
+}
+
+.conv-status-container {
+ border-bottom-color: #8B8B8B;
+}
+
+.startChatBubble > .button-box > .button-icon,
+.closeConversationButton > .button-box > .button-icon {
+ margin-inline-start: 0;
+}
+
+/* Set a background color to avoid lightweight theme backgrounds */
+#contextPane {
+ background-color: Field;
+}
diff --git a/comm/mail/themes/osx/mail/common.css b/comm/mail/themes/osx/mail/common.css
new file mode 100644
index 0000000000..8075f055d4
--- /dev/null
+++ b/comm/mail/themes/osx/mail/common.css
@@ -0,0 +1,76 @@
+/* - 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 url("chrome://messenger/skin/shared/common.css");
+
+@namespace html "http://www.w3.org/1999/xhtml";
+@namespace xul "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+xul|tabs {
+ padding-inline: 0;
+ position: static;
+}
+
+xul|button[dlgtype="help"] {
+ appearance: none;
+ width: auto;
+}
+
+xul|menulist > xul|menupopup > xul|menuitem[checked="true"]::before,
+xul|menulist > xul|menupopup > xul|menuitem[selected="true"]::before {
+ display: none;
+}
+
+xul|menulist > xul|menupopup xul|menu,
+xul|menulist > xul|menupopup xul|menuitem {
+ padding-inline-end: 34px;
+}
+
+xul|menulist > xul|menupopup xul|menuitem::after,
+xul|menulist > xul|menupopup xul|menuitem::before {
+ display: none;
+}
+
+xul|*.checkbox-icon,
+xul|*.radio-icon {
+ margin-inline-end: 0;
+}
+
+xul|*.text-link:-moz-focusring {
+ box-shadow: none;
+}
+
+xul|search-textbox::part(search-sign) {
+ -moz-context-properties: stroke, fill-opacity;
+ stroke: currentColor;
+ fill-opacity: 0.8;
+ list-style-image: url("chrome://messenger/skin/icons/new/compact/search.svg");
+ margin-inline-end: 5px;
+}
+
+html|button {
+ /* XUL button min-width */
+ min-width: 79px;
+}
+
+html|input[type="email"],
+html|input[type="tel"],
+html|input[type="text"],
+html|input[type="password"],
+html|input[type="number"],
+html|textarea {
+ margin: 4px;
+}
+
+xul|tab {
+ min-height: 2.5em;
+}
+
+:host(dialog[subdialog]) .dialog-button-box > button {
+ min-height: var(--in-content-button-height);
+ padding-block: initial;
+ padding-inline: 15px;
+ border-color: transparent;
+ border-radius: var(--in-content-button-border-radius);
+}
diff --git a/comm/mail/themes/osx/mail/compacttheme.css b/comm/mail/themes/osx/mail/compacttheme.css
new file mode 100644
index 0000000000..146ee15f46
--- /dev/null
+++ b/comm/mail/themes/osx/mail/compacttheme.css
@@ -0,0 +1,9 @@
+/* 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 url("chrome://messenger/skin/shared/compacttheme.css");
+
+.tabmail-tab[selected="true"] {
+ -moz-font-smoothing-background-color: var(--toolbar-bgcolor);
+}
diff --git a/comm/mail/themes/osx/mail/compose/messengercompose.css b/comm/mail/themes/osx/mail/compose/messengercompose.css
new file mode 100644
index 0000000000..22af742cf5
--- /dev/null
+++ b/comm/mail/themes/osx/mail/compose/messengercompose.css
@@ -0,0 +1,217 @@
+/* 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/. */
+
+/* ===== messengercompose.css ===========================================
+ == Styles for the main Messenger Compose window.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/shared/messengercompose.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+#compose-toolbox:-moz-lwtheme::after {
+ top: 100%;
+ margin-top: -1px;
+ border-bottom: 1px solid var(--chrome-content-separator-color);
+ z-index: 1;
+}
+
+#compose-toolbox toolbarbutton[checked="true"] {
+ background-color: transparent;
+}
+
+/* Inactive window state */
+#compose-toolbox > toolbar:-moz-window-inactive {
+ border-top-color: rgba(255,255,255,0.45);
+ border-bottom-color: rgba(0,0,0,0.35);
+}
+
+#compose-toolbox > toolbar:not(:-moz-lwtheme) {
+ background-color: #cfcfcf;
+}
+
+#composeToolbar2 {
+ /* Cover the titlebar with the toolbox background */
+ margin-top: -22px;
+ padding: 22px 4px 0;
+}
+
+toolbar[nowindowdrag="true"] {
+ appearance: none;
+}
+
+/* Findbar */
+
+#findbar-replaceButton {
+ height: 18px;
+ margin-inline-start: 5px;
+ padding-block: 2px;
+}
+
+/* ::::: special toolbar colors ::::: */
+
+#composeContentBox {
+ color: -moz-DialogText;
+ text-shadow: none;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2) inset;
+}
+
+#composeContentBox:-moz-window-inactive {
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1) inset;
+}
+
+#MsgHeadersToolbar {
+ padding-block-start: 5px;
+}
+
+#identityLabel-box {
+ margin-top: 3px;
+}
+
+#msgIdentity {
+ margin-block: 2px 0;
+ padding-block: 3px;
+ color: inherit;
+}
+
+#msgIdentity::part(text-input) {
+ color: inherit;
+ padding-inline-start: 3px;
+}
+
+#msgIdentity[is="menulist-editable"][editable="true"] > menupopup {
+ appearance: auto;
+ -moz-default-appearance: menupopup;
+ margin-inline-start: 0;
+}
+
+#msgIdentity[is="menulist-editable"][editable="true"] > menupopup > menuitem {
+ appearance: auto;
+ -moz-default-appearance: menuitem;
+}
+
+#msgIdentityPopup {
+ margin-inline-start: initial;
+}
+
+/* ::::: attachment reminder ::::: */
+
+.notification-button[is="toolbarbutton-menu-button"] {
+ padding-top: 0;
+}
+
+.notification-button[is="toolbarbutton-menu-button"] > button {
+ appearance: none;
+ margin-bottom: -1px;
+ margin-inline-start: -3px;
+ margin-inline-end: 3px;
+ padding-inline-end: 5px;
+ border-inline-end: 1px solid #9b9b9b;
+}
+
+#subjectLabel {
+ margin-top: 3px;
+ margin-inline-end: 6px;
+}
+
+.address-label-container {
+ padding-top: 7px;
+}
+
+.address-container {
+ padding: 1px 4px;
+}
+
+.address-container > .address-input {
+ padding-block: 5px;
+ min-height: 14px;
+}
+
+#msgIdentity,
+.address-container,
+#msgSubject {
+ min-height: 26px;
+}
+
+.address-pill {
+ padding-block: 2px;
+}
+
+.address-pill label {
+ margin-block: 0;
+}
+
+.pill-indicator {
+ margin-top: -2px;
+}
+
+/* ::::: autocomplete icons ::::: */
+
+.ac-site-icon {
+ display: flex;
+ margin: 2px 5px;
+}
+
+/* ::::: format toolbar ::::: */
+
+#FormatToolbar {
+ padding-block: 4px;
+ margin-inline: 3px;
+ margin-block-end: 3px;
+}
+
+#FormatToolbar toolbarseparator {
+ background-image: none;
+}
+
+toolbarbutton.formatting-button {
+ margin-inline: 1px;
+ padding-inline: 4px;
+}
+
+.formatting-button > .toolbarbutton-menu-dropmarker {
+ margin-inline-start: 3px;
+}
+
+#FontFaceSelect {
+ max-width: 15em;
+}
+
+/* ::::: address book sidebar ::::: */
+
+#contactsBrowserTitle {
+ font: icon;
+}
+
+#titlebar {
+ z-index: 1;
+ height: 22px;
+}
+
+.titlebar-buttonbox-container {
+ margin-top: 3px;
+ margin-inline-start: 7px;
+}
+
+.titlebar-buttonbox {
+ margin-inline: 0;
+}
+
+#titlebar-title {
+ overflow: hidden;
+ /* Equalize the titlebar-buttonbox width */
+ padding-inline-end: 60px;
+}
+
+#titlebar-title-label {
+ justify-content: center;
+}
+
+/* Styles for the default system dark theme */
+
+:root[lwt-tree] #FormatToolbar {
+ background-image: none;
+ background-color: transparent !important;
+}
diff --git a/comm/mail/themes/osx/mail/contextMenu.css b/comm/mail/themes/osx/mail/contextMenu.css
new file mode 100644
index 0000000000..017001f801
--- /dev/null
+++ b/comm/mail/themes/osx/mail/contextMenu.css
@@ -0,0 +1,54 @@
+/* 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 url("chrome://messenger/skin/shared/contextMenu.css");
+
+menupopup {
+ appearance: none;
+ background-color: transparent;
+}
+
+menupopup::part(content) {
+ margin: 1px;
+}
+
+menupopup .menu-iconic-icon {
+ width: 16px;
+ margin-inline-start: 0;
+}
+
+menupopup > menu::after {
+ line-height: 0;
+}
+
+menupopup > menu > .menu-text,
+menupopup > menuitem > .menu-text {
+ margin-inline-start: 21px !important;
+}
+
+menupopup > menuitem > .menu-right {
+ margin-inline-end: -4px;
+ appearance: none;
+}
+
+/* Only show the system checkmark on main window menu */
+#windowPopup > menuitem[checked="true"] {
+ list-style-image: none;
+}
+
+menupopup > menuseparator#customizeMailToolbarMenuSeparator {
+ display: none;
+}
+
+menulist > menupopup > menuitem::before {
+ margin-inline: 0 -10px;
+}
+
+menulist > menupopup > menuitem:is([checked="true"], [selected="true"])::before {
+ visibility: hidden;
+}
+
+menulist > menupopup > menuitem.menuitem-iconic::before {
+ display: none;
+}
diff --git a/comm/mail/themes/osx/mail/customizeToolbar.css b/comm/mail/themes/osx/mail/customizeToolbar.css
new file mode 100644
index 0000000000..6c0eb3847d
--- /dev/null
+++ b/comm/mail/themes/osx/mail/customizeToolbar.css
@@ -0,0 +1,25 @@
+/* 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 url("chrome://messenger/skin/shared/customizeToolbar.css");
+
+#palette-box {
+ margin: 2px 6px 10px;
+}
+
+#titlebarSettings {
+ margin-inline-start: 6px;
+}
+
+hbox button {
+ font: menu;
+}
+
+#main-box > box > button {
+ font: message-box;
+}
+
+:root[toolboxId="mail-toolbox"] #titlebarSettings {
+ display: flex;
+}
diff --git a/comm/mail/themes/osx/mail/downloads/aboutDownloads.css b/comm/mail/themes/osx/mail/downloads/aboutDownloads.css
new file mode 100644
index 0000000000..413b63e318
--- /dev/null
+++ b/comm/mail/themes/osx/mail/downloads/aboutDownloads.css
@@ -0,0 +1,9 @@
+/* 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 url("chrome://messenger/skin/shared/aboutDownloads.css");
+
+.downloadButton > .button-box > .button-icon {
+ list-style-image: url("chrome://global/skin/icons/search-glass.svg");
+}
diff --git a/comm/mail/themes/osx/mail/filterDialog.css b/comm/mail/themes/osx/mail/filterDialog.css
new file mode 100644
index 0000000000..d1c770a556
--- /dev/null
+++ b/comm/mail/themes/osx/mail/filterDialog.css
@@ -0,0 +1,58 @@
+/* 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/. */
+
+/* ===== filterDialog.css ===============================================
+ == Styles for the Mail Filters dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/filterDialog.css");
+
+#filterListDialog {
+ padding: 0;
+}
+
+#filterHeader {
+ margin: 8px;
+ font: menu;
+}
+
+#filterListGrid {
+ margin: 8px;
+}
+
+/* ::::: columns :::::: */
+
+.search-menulist,
+.search-value-menulist {
+ width: 14.5em;
+}
+
+.small-button {
+ min-width: 22px;
+ height: 20px;
+ padding-block: 0 1px;
+ padding-inline: 0 1px;
+ margin: 4px 0;
+}
+
+.small-button:first-child {
+ margin-inline-start: 2px;
+}
+
+listcell > hbox {
+ justify-content: flex-end;
+}
+
+/* No '.filler' here, so add margin to make more room. */
+hbox > .small-button + .small-button {
+ margin-inline-end: 2px;
+}
+
+#searchTermList > listitem[selected="true"] {
+ background-color: inherit;
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"] > toolbarbutton {
+ min-height: 0;
+}
diff --git a/comm/mail/themes/osx/mail/folderMenus.css b/comm/mail/themes/osx/mail/folderMenus.css
new file mode 100644
index 0000000000..82446df1c3
--- /dev/null
+++ b/comm/mail/themes/osx/mail/folderMenus.css
@@ -0,0 +1,21 @@
+/* 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/. */
+
+/* ===== folderMenus.css ================================================
+ == Icons for menus which represent mail folder.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/folderMenus.css");
+
+@media (min-resolution: 2dppx) {
+ .folderMenuItem > .menu-iconic-left > .menu-iconic,
+ .folderMenuItem::part(icon) {
+ width: 16px;
+ height: 16px;
+ }
+}
+
+.menulist-menupopup[is="folder-menupopup"] {
+ list-style-image: none;
+}
diff --git a/comm/mail/themes/osx/mail/folderPane.css b/comm/mail/themes/osx/mail/folderPane.css
new file mode 100644
index 0000000000..3bce538d55
--- /dev/null
+++ b/comm/mail/themes/osx/mail/folderPane.css
@@ -0,0 +1,19 @@
+/* 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 url("chrome://messenger/skin/shared/folderPane.css");
+
+/* UI Density customization */
+
+#folderTree > treechildren::-moz-tree-row {
+ min-height: 2rem;
+}
+
+:root[uidensity="compact"] #folderTree > treechildren::-moz-tree-row {
+ min-height: 1.7rem;
+}
+
+:root[uidensity="touch"] #folderTree > treechildren::-moz-tree-row {
+ min-height: 2.4rem;
+}
diff --git a/comm/mail/themes/osx/mail/glodaFacetView.css b/comm/mail/themes/osx/mail/glodaFacetView.css
new file mode 100644
index 0000000000..dd4ddfaa01
--- /dev/null
+++ b/comm/mail/themes/osx/mail/glodaFacetView.css
@@ -0,0 +1,5 @@
+/* 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 url("chrome://messenger/skin/shared/glodaFacetView.css");
diff --git a/comm/mail/themes/osx/mail/icons/arrow/arrow-down-dim.png b/comm/mail/themes/osx/mail/icons/arrow/arrow-down-dim.png
new file mode 100644
index 0000000000..4f7fcd5784
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/arrow/arrow-down-dim.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/arrow/arrow-down.png b/comm/mail/themes/osx/mail/icons/arrow/arrow-down.png
new file mode 100644
index 0000000000..d2df341a58
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/arrow/arrow-down.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/arrow/arrow-left.png b/comm/mail/themes/osx/mail/icons/arrow/arrow-left.png
new file mode 100644
index 0000000000..6607869ad0
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/arrow/arrow-left.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/arrow/arrow-right-dim.png b/comm/mail/themes/osx/mail/icons/arrow/arrow-right-dim.png
new file mode 100644
index 0000000000..49dc2d55e4
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/arrow/arrow-right-dim.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/arrow/arrow-right.png b/comm/mail/themes/osx/mail/icons/arrow/arrow-right.png
new file mode 100644
index 0000000000..f9e33978e7
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/arrow/arrow-right.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/arrow/arrow-up.png b/comm/mail/themes/osx/mail/icons/arrow/arrow-up.png
new file mode 100644
index 0000000000..1eb4d4ceb2
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/arrow/arrow-up.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/connecting.png b/comm/mail/themes/osx/mail/icons/connecting.png
new file mode 100644
index 0000000000..3c8e71f5db
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/connecting.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/error.png b/comm/mail/themes/osx/mail/icons/error.png
new file mode 100644
index 0000000000..628cf2dae3
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/error.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/identity.png b/comm/mail/themes/osx/mail/icons/identity.png
new file mode 100644
index 0000000000..8d4f3bc327
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/identity.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/identity@2x.png b/comm/mail/themes/osx/mail/icons/identity@2x.png
new file mode 100644
index 0000000000..8fedc7953f
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/identity@2x.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/icons/multicolor.png b/comm/mail/themes/osx/mail/icons/multicolor.png
new file mode 100644
index 0000000000..b96853cf37
--- /dev/null
+++ b/comm/mail/themes/osx/mail/icons/multicolor.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/imAccounts.css b/comm/mail/themes/osx/mail/imAccounts.css
new file mode 100644
index 0000000000..cf32eeb56d
--- /dev/null
+++ b/comm/mail/themes/osx/mail/imAccounts.css
@@ -0,0 +1,13 @@
+/* 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 url("chrome://messenger/skin/shared/imAccounts.css");
+
+richlistitem .account-buttons {
+ margin-inline-start: 35px;
+}
+
+#statusTypeIcon .button-box {
+ padding: 0;
+}
diff --git a/comm/mail/themes/osx/mail/input-fields.css b/comm/mail/themes/osx/mail/input-fields.css
new file mode 100644
index 0000000000..2b6b4b981d
--- /dev/null
+++ b/comm/mail/themes/osx/mail/input-fields.css
@@ -0,0 +1,18 @@
+/* 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 url("chrome://messenger/skin/shared/input-fields.css");
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+html|input.input-inline {
+ padding-inline-start: 4px;
+}
+
+html|input.input-filefield {
+ background: center left 2px / 16px no-repeat;
+}
+
+html|input[type="number"] {
+ padding-inline-end: 0;
+}
diff --git a/comm/mail/themes/osx/mail/junkMail.css b/comm/mail/themes/osx/mail/junkMail.css
new file mode 100644
index 0000000000..5a4b3b3336
--- /dev/null
+++ b/comm/mail/themes/osx/mail/junkMail.css
@@ -0,0 +1,18 @@
+/*
+#
+# 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/.
+*/
+
+/*===== junkMail=======.css ==============================================
+ == Styles for the junk mail dialog
+ ======================================================================== */
+
+@import url("chrome://messenger/skin/messenger.css");
+
+/* ::::: account manager :::::: */
+
+.specialFolderPickerGrid {
+ margin-inline-start: 20px;
+}
diff --git a/comm/mail/themes/osx/mail/mailWindow1.css b/comm/mail/themes/osx/mail/mailWindow1.css
new file mode 100644
index 0000000000..4e606af6bc
--- /dev/null
+++ b/comm/mail/themes/osx/mail/mailWindow1.css
@@ -0,0 +1,76 @@
+/*
+# 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/.
+*/
+
+/* ===== mailWindow1.css ================================================
+ == Styles for the main Mail window in the default layout scheme.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/primaryToolbar.css");
+@import url("chrome://messenger/skin/folderPane.css");
+@import url("chrome://messenger/skin/messageIcons.css");
+@import url("chrome://messenger/skin/shared/mailWindow1.css");
+
+:root:not([lwt-tree]) #folderTree {
+ background-color: -moz-OddTreeRow;
+}
+
+@media (prefers-contrast) {
+ :root:not([lwt-tree]) #folderTree {
+ background-color: Field;
+ }
+}
+
+#folderTree treechildren::-moz-tree-indentation {
+ background-color: black !important;
+}
+
+#folderTree treechildren::-moz-tree-row {
+ padding-inline-start: 2px !important;
+ padding-bottom: 2px;
+ background: transparent;
+}
+
+#folderTree treechildren::-moz-tree-row(hover) {
+ background-color: hsla(0, 0%, 50%, 0.15);
+}
+
+#folderTree treechildren::-moz-tree-row(selected) {
+ color: -moz-cellhighlighttext;
+ background-color: -moz-cellhighlight;
+}
+
+#folderTree treechildren::-moz-tree-row(selected, focus) {
+ background-color: var(--selected-item-color);
+}
+
+#folderTree treechildren::-moz-tree-cell-text {
+ font-family: -apple-system, sans-serif;
+ font-weight: 400;
+}
+
+/* ::::: thread decoration ::::: */
+
+/* ::::: group rows ::::: */
+treechildren::-moz-tree-row(dummy) {
+ padding-inline-start: 0;
+}
+
+.telemetry-text-link {
+ color: #fff;
+}
+
+/* Global notification popup */
+
+#notification-popup {
+ appearance: none;
+ background: transparent;
+ margin-top: 4px;
+}
+
+#notification-popup::part(content) {
+ margin: 1px;
+}
diff --git a/comm/mail/themes/osx/mail/menulist.css b/comm/mail/themes/osx/mail/menulist.css
new file mode 100644
index 0000000000..9323ced470
--- /dev/null
+++ b/comm/mail/themes/osx/mail/menulist.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/menulist.css");
+
+menulist[is="menulist-editable"][editable="true"] {
+ margin: 4px 2px;
+}
+
+menulist[is="menulist-editable"][editable="true"]:focus-within {
+ outline: var(--focus-outline);
+}
+
+menulist[is="menulist-editable"][editable="true"] > menupopup,
+menulist[is="menulist-editable"][editable="true"] > menupopup > menuitem,
+menulist[is="menulist-editable"][editable="true"] > menupopup > menucaption {
+ appearance: none;
+}
+
+menulist[is="menulist-editable"][editable="true"] > menupopup > :is(menuitem,menucaption) > .menu-iconic-left {
+ display: none;
+}
+
+menulist[is="menulist-editable"][editable="true"] > menupopup > menuitem[checked="true"]::before,
+menulist[is="menulist-editable"][editable="true"] > menupopup > menuitem[selected="true"]::before {
+ display: none;
+ margin-inline-start: 0;
+}
diff --git a/comm/mail/themes/osx/mail/message-bar.css b/comm/mail/themes/osx/mail/message-bar.css
new file mode 100644
index 0000000000..514b4dad3f
--- /dev/null
+++ b/comm/mail/themes/osx/mail/message-bar.css
@@ -0,0 +1,9 @@
+/* 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 url("chrome://messenger/skin/shared/message-bar.css");
+
+.notification-button.small-button {
+ padding-block: 4px;
+}
diff --git a/comm/mail/themes/osx/mail/messageBody.css b/comm/mail/themes/osx/mail/messageBody.css
new file mode 100644
index 0000000000..3641de574b
--- /dev/null
+++ b/comm/mail/themes/osx/mail/messageBody.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/messageBody.css");
+
+mailattachcount {
+ border: blue;
+}
+
+/* ::::: message text, incl. quotes ::::: */
+
+.moz-text-plain pre {
+ margin: inherit;
+ font-family: inherit;
+}
+
+.moz-text-plain[graphical-quote="false"] blockquote {
+ margin: inherit;
+ border-left: inherit;
+ padding-inline-start: inherit;
+}
+
+.moz-text-plain[graphical-quote="true"] blockquote {
+ margin: inherit;
+ border-width: 2px;
+ border-color: gray;
+}
diff --git a/comm/mail/themes/osx/mail/messageHeader.css b/comm/mail/themes/osx/mail/messageHeader.css
new file mode 100644
index 0000000000..0f7bb251c5
--- /dev/null
+++ b/comm/mail/themes/osx/mail/messageHeader.css
@@ -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/. */
+
+/* ===== messageHeader.css ==============================================
+ == Styles for the header toolbars of a mail message.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/messageHeader.css");
+
+/* ::::: msg header toolbars ::::: */
+
+.inline-toolbox {
+ padding-inline-end: 3px;
+}
+
+/* ::::: collapsed view styles ::::: */
+
+.message-security-label {
+ /* Necessary to not cut the background icon */
+ min-height: 16px;
+}
+
+.button-focusable:focus-visible:not(:hover) {
+ outline-offset: -2px;
+}
+
+button.button-focusable:focus-visible:not(:hover) {
+ outline: none;
+ box-shadow: 0 0 0 3px -moz-mac-focusring;
+}
+
+
+/* Customization options */
+
+.message-header-buttons-only-icons .toolbarbutton-menu-dropmarker {
+ padding-inline-start: 0;
+}
diff --git a/comm/mail/themes/osx/mail/messageIcons.css b/comm/mail/themes/osx/mail/messageIcons.css
new file mode 100644
index 0000000000..1e44f588fe
--- /dev/null
+++ b/comm/mail/themes/osx/mail/messageIcons.css
@@ -0,0 +1,70 @@
+/* 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 url("chrome://messenger/skin/shared/messageIcons.css");
+
+/* ..... select column ..... */
+
+treechildren::-moz-tree-image(selectCol) {
+ margin-inline-start: -1px;
+}
+
+/* ..... delete column ..... */
+
+treechildren::-moz-tree-image(deleteCol) {
+ margin-inline-start: -1px;
+}
+
+/* ..... thread column ..... */
+
+.threadColumnHeader {
+ padding-inline: 3px 2px;
+}
+
+/* ..... read column ..... */
+
+.readColumnHeader {
+ padding-inline: 2px 3px;
+}
+
+treechildren::-moz-tree-image(unreadButtonColHeader) {
+ margin-inline-start: -2px;
+}
+
+/* ..... attachment column ..... */
+
+.attachmentColumnHeader {
+ padding-inline: 2px 3px;
+}
+
+treechildren::-moz-tree-image(attachmentCol) {
+ margin-inline-start: -2px;
+}
+
+/* ..... flag column ..... */
+
+.flagColumnHeader {
+ padding-inline: 2px 3px;
+}
+
+/* ..... junkStatus column ..... */
+treechildren::-moz-tree-image(junkStatusCol) {
+ margin-inline-start: -1px;
+}
+
+/* ..... correspondent column ..... */
+
+#correspondentCol {
+ padding-inline-start: 20px;
+}
+
+/* ..... subject column ..... */
+
+#subjectCol {
+ padding-inline-start: 20px;
+}
+
+#subjectCol[primary="true"] {
+ padding-inline-start: 40px;
+}
diff --git a/comm/mail/themes/osx/mail/messageWindow.css b/comm/mail/themes/osx/mail/messageWindow.css
new file mode 100644
index 0000000000..fbd88547cb
--- /dev/null
+++ b/comm/mail/themes/osx/mail/messageWindow.css
@@ -0,0 +1,53 @@
+/* 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/. */
+
+/* ===== messageWindow.css ==============================================
+ == Styles for the message window.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/primaryToolbar.css");
+
+/* ::::: Mail Toolbars and Menubars ::::: */
+
+#titlebar {
+ -moz-window-dragging: drag;
+ height: 22px;
+}
+
+.titlebar-buttonbox-container {
+ margin-top: 3px;
+ margin-inline-start: 7px;
+}
+
+.titlebar-buttonbox {
+ margin-inline: 0;
+}
+
+#titlebar-title {
+ /* Equalize the titlebar-buttonbox width */
+ padding-inline-end: 60px;
+}
+
+#titlebar-title-label {
+ justify-content: center;
+}
+
+.mail-toolbox {
+ border-bottom: 0;
+}
+
+#mail-toolbox:-moz-lwtheme {
+ box-shadow: none;
+}
+
+#messagepaneboxwrapper {
+ overflow: hidden;
+ min-height: 0;
+}
+
+#messagepanebox {
+ flex: 3 3;
+ text-shadow: none;
+}
diff --git a/comm/mail/themes/osx/mail/messenger.css b/comm/mail/themes/osx/mail/messenger.css
new file mode 100644
index 0000000000..431ad5068e
--- /dev/null
+++ b/comm/mail/themes/osx/mail/messenger.css
@@ -0,0 +1,460 @@
+/* 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.css ==================================================
+ == Styles shared throughout the Messenger application.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/messenger.css");
+
+#mail-menubar,
+#mailContext {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.sidebar-header {
+ background-color: -moz-Dialog;
+ color: -moz-dialogText;
+ text-shadow: none;
+}
+
+.inline-toolbar,
+.contentTabToolbar {
+ appearance: none;
+ min-width: 50px;
+ min-height: 14px;
+}
+
+.inline-toolbar > toolbarseparator {
+ height: 20px;
+ margin-top: 2px;
+}
+
+#navigation-toolbox {
+ appearance: none;
+}
+
+/*
+ This is a workaround for Bug 1482157
+ -moz-default-appearance: toolbox; makes the macOS sheets attached to the
+ element's bottom border. We cannot put this property on the toolbox itself as
+ it cancels all backgrounds that are there, so we set it on the toolbox bottom
+ border.
+*/
+#navigation-toolbox::after {
+ content: "";
+ display: flex;
+ appearance: auto;
+ -moz-default-appearance: toolbox;
+ height: 1px;
+ margin-top: -1px;
+ opacity: 0.001;
+}
+
+#titlebar {
+ /* Centrally align content items vertically */
+ justify-content: center;
+}
+
+.titlebar-button {
+ display: none;
+}
+
+#titlebar:-moz-lwtheme {
+ appearance: none;
+}
+
+#toolbar-menubar {
+ visibility: collapse;
+}
+
+/* ::::: toolbarbutton menu-button ::::: */
+
+toolbarbutton[is="toolbarbutton-menu-button"] {
+ align-items: stretch;
+ flex-direction: row !important;
+ background-color: transparent;
+}
+
+.toolbarbutton-menubutton-button {
+ text-shadow: inherit;
+}
+
+/* .......... dropmarker .......... */
+
+.toolbarbutton-menubutton-dropmarker {
+ appearance: none;
+ border: none;
+ padding: 0 5px;
+}
+
+/* ::::: primary toolbar buttons ::::: */
+
+.toolbarbutton-1[open],
+.toolbarbutton-1[open] > .toolbarbutton-menubutton-button {
+ text-shadow: none;
+}
+
+.toolbarbutton-1[checked="true"]:-moz-window-inactive {
+ box-shadow: var(--toolbarbutton-inactive-boxshadow);
+ border-color: var(--toolbarbutton-inactive-bordercolor);
+ background: transparent !important;
+}
+
+.toolbarbutton-1[is="toolbarbutton-menu-button"] {
+ padding: 0;
+}
+
+.toolbarbutton-1[disabled="true"] .toolbarbutton-text,
+.toolbarbutton-1[is="toolbarbutton-menu-button"] > .toolbarbutton-menubutton-button[disabled="true"]
+ > .toolbarbutton-icon {
+ opacity: .4;
+}
+
+.toolbarbutton-1[disabled="true"] .toolbarbutton-icon,
+.toolbarbutton-1[is="toolbarbutton-menu-button"]
+ > .toolbarbutton-menubutton-button[disabled="true"] > .toolbarbutton-icon,
+.toolbarbutton-1[disabled="true"] > .toolbarbutton-menu-dropmarker,
+.toolbarbutton-1[disabled="true"] > .toolbarbutton-menubutton-dropmarker,
+.toolbarbutton-1:not(:hover):-moz-window-inactive .toolbarbutton-icon,
+.toolbarbutton-1:not(:hover):-moz-window-inactive
+ > .toolbarbutton-menu-dropmarker,
+.toolbarbutton-1:not(:hover):-moz-window-inactive
+ > .toolbarbutton-menubutton-dropmarker {
+ opacity: .5;
+}
+
+.toolbarbutton-1:-moz-window-inactive[disabled="true"] > .toolbarbutton-icon,
+.toolbarbutton-1:-moz-window-inactive[is="toolbarbutton-menu-button"]
+ > .toolbarbutton-menubutton-button[disabled="true"] > .toolbarbutton-icon {
+ opacity: .25;
+}
+
+.toolbarbutton-1 > .toolbarbutton-menu-dropmarker {
+ margin-inline-end: 1px;
+ padding-inline-start: 4px;
+}
+
+toolbar[mode="icons"] .toolbarbutton-1 > menupopup {
+ margin-top: 1px;
+}
+
+menulist {
+ padding: 1px 6px;
+}
+
+menulist > menupopup:not([position]) {
+ margin-inline-start: 0;
+}
+
+menulist > menupopup menu,
+menulist > menupopup menuitem,
+toolbarbutton > menupopup menu,
+toolbarbutton > menupopup menuitem {
+ appearance: none !important;
+ padding-block: 4px !important;
+}
+
+@media (-moz-mac-big-sur-theme) {
+ menulist > menupopup menu,
+ menulist > menupopup menuitem,
+ toolbarbutton > menupopup menu,
+ toolbarbutton > menupopup menuitem {
+ margin-inline:5px;
+ border-radius: 4px;
+ }
+}
+
+menupopup menu[disabled="true"][_moz-menuactive="true"],
+menupopup menuitem[disabled="true"][_moz-menuactive="true"] {
+ background-color: transparent !important;
+}
+
+menulist > menupopup menu .menu-right,
+toolbarbutton > menupopup menu .menu-right {
+ appearance: none;
+ list-style-image: url(chrome://global/skin/icons/arrow-right-12.svg);
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+menulist > menupopup menu .menu-right:-moz-locale-dir(rtl),
+toolbarbutton > menupopup menu .menu-right:-moz-locale-dir(rtl) {
+ list-style-image: url(chrome://global/skin/icons/arrow-left-12.svg);
+}
+
+/* :::::: throbber :::::::::: */
+
+#throbber-box {
+ margin: 0 4px;
+}
+
+/* ::::: online/offline icons ::::: */
+
+#offline-status {
+ padding-inline-start: 3px;
+}
+
+/* ::::: directional button icons ::::: */
+
+.up,
+.down {
+ min-width: 0;
+ -moz-context-properties: stroke, fill-opacity;
+ stroke: currentColor;
+ fill-opacity: 1;
+}
+
+.up {
+ list-style-image: url("chrome://global/skin/icons/arrow-up-12.svg");
+}
+
+.down {
+ list-style-image: url("chrome://global/skin/icons/arrow-down-12.svg");
+}
+
+.up[disabled="true"],
+.down[disabled="true"] {
+ fill-opacity: .5;
+}
+
+/* ::::: Tabs ::::: */
+
+tabmail > tabbox {
+ margin: 0;
+}
+
+/* ::::: Trees ::::: */
+
+treechildren::-moz-tree-cell-text {
+ padding-inline-start: 2px;
+}
+
+treechildren::-moz-tree-line {
+ border-color: inherit;
+}
+
+treechildren::-moz-tree-line(selected, focus) {
+ border-color: var(--selected-item-text-color);
+}
+
+/* message column icons */
+
+:root:not(:-moz-lwtheme) treecol:not([hideheader="true"]),
+:root:not(:-moz-lwtheme) .tree-columnpicker-button:not([hideheader="true"]) {
+ appearance: none;
+ color: inherit;
+ background-color: transparent;
+ padding-block: 2px;
+ border-bottom: 1px solid ThreeDShadow;
+}
+
+:root:not(:-moz-lwtheme) treecol {
+ border-inline-end: 1px solid ThreeDShadow;
+}
+
+:root:not(:-moz-lwtheme) treecol:hover:active,
+:root:not(:-moz-lwtheme) .tree-columnpicker-button:hover:active {
+ background-color: ThreeDFace;
+}
+
+.treecol-sortdirection {
+ appearance: none;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+treecol[sortDirection="ascending"]:not([hideheader="true"]) > .treecol-sortdirection {
+ list-style-image: url("chrome://global/skin/icons/arrow-up-12.svg");
+}
+
+treecol[sortDirection="descending"]:not([hideheader="true"]) > .treecol-sortdirection {
+ list-style-image: url("chrome://global/skin/icons/arrow-down-12.svg");
+}
+
+:root[lwt-tree] treechildren::-moz-tree-row(odd) {
+ background-color: transparent;
+}
+
+:root[lwt-tree] treechildren::-moz-tree-row(odd, hover) {
+ background-color: hsla(0, 0%, 50%, 0.15);
+}
+
+/* ::::: Tabs in Titlebar :::::: */
+
+#messengerWindow[tabsintitlebar="true"]:not(:-moz-lwtheme) #titlebar {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-titlebar;
+}
+
+.titlebar-buttonbox-container {
+ align-items: center;
+}
+
+@media not (-moz-mac-rtl) {
+ .titlebar-buttonbox-container:-moz-locale-dir(ltr) {
+ order: -1;
+ }
+}
+
+@media (-moz-mac-rtl) {
+ #unifiedToolbarContainer:-moz-locale-dir(rtl) {
+ flex-direction: row-reverse;
+ }
+
+ .titlebar-buttonbox-container:-moz-locale-dir(rtl) {
+ order: -1;
+ }
+}
+
+/* NB: these would be margin-inline-start/end if it wasn't for the fact that OS X
+ * doesn't reverse the order of the items in the titlebar in RTL mode. */
+.titlebar-buttonbox {
+ margin-inline: 12px;
+}
+
+.titlebar-buttonbox {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-box;
+}
+
+#messengerWindow[sizemode="fullscreen"] .titlebar-buttonbox-container {
+ display: none;
+}
+
+button.notification-button[is="toolbarbutton-menu-button"] {
+ margin: 0 6px;
+}
+
+button[is="toolbarbutton-menu-button"] > .button-box > button {
+ margin-block: -4px;
+ margin-inline: -7px 2px;
+}
+
+.button-menubutton-dropmarker {
+ appearance: none;
+}
+
+button dropmarker::part(icon) {
+ margin-inline-start: 3px;
+}
+
+toolbarbutton.notification-button[is="toolbarbutton-menu-button"] > .button-box > dropmarker {
+ margin-inline-end: -5px;
+}
+
+/* Status bar */
+
+#status-bar:-moz-lwtheme {
+ padding-top: 1px;
+}
+
+.statusbar {
+ min-width: 1px; /* DON'T DELETE!
+ Prevents hiding of scrollbars in browser when window is made smaller.*/
+ min-height: 15px !important;
+ margin: 0 !important;
+ /* need to use padding-inline-end when/if bug 631729 gets fixed: */
+ padding: 0 16px 1px 1px;
+ text-shadow: rgba(255, 255, 255, 0.4) 0 1px;
+}
+
+.statusbar:-moz-lwtheme {
+ background: none;
+ border-style: none;
+ text-shadow: inherit;
+}
+
+/* Status panel */
+
+.statuspanel-label {
+ margin: 0;
+ padding: 2px 4px;
+ background-color: #f9f9fa;
+ border: 1px none #ddd;
+ border-top-style: solid;
+ color: #444;
+ text-shadow: none;
+}
+
+.statuspanel-label:-moz-locale-dir(ltr):not([mirror]),
+.statuspanel-label:-moz-locale-dir(rtl)[mirror] {
+ border-right-style: solid;
+ border-top-right-radius: .3em;
+ margin-right: 1em;
+}
+
+.statuspanel-label:-moz-locale-dir(rtl):not([mirror]),
+.statuspanel-label:-moz-locale-dir(ltr)[mirror] {
+ border-left-style: solid;
+ border-top-left-radius: .3em;
+ margin-left: 1em;
+}
+
+.contentTabInstance {
+ background-color: -moz-dialog;
+}
+
+.contentTabInstance:-moz-lwtheme {
+ background-color: transparent;
+ background-image: linear-gradient(transparent 40px, -moz-dialog 40px);
+}
+
+.contentTabAddress * {
+ text-shadow: none;
+}
+
+fieldset {
+ padding: 0 8px 5px;
+ margin: 1.5em 6px 6px 6px;
+ border: none;
+}
+
+legend {
+ font-size: 1.18em;
+ margin-top: -1.3em;
+ margin-bottom: 5px;
+ margin-inline-start: -5px;
+ font-weight: bold;
+}
+
+fieldset > hbox,
+fieldset > vbox,
+fieldset > radiogroup {
+ width: -moz-available;
+}
+
+.menu-right,
+.menu-accel-container {
+ margin-inline-end: 0;
+}
+
+menupopup[type="arrow"] .menu-accel-container {
+ margin-inline-end: 0;
+}
+
+.menu-accel-container {
+ opacity: 0.5;
+}
+
+menuitem:not([disabled="true"]):hover .menu-accel-container,
+menuitem:not([disabled="true"]):focus .menu-accel-container {
+ opacity: 1;
+}
+
+/* UI Density customization */
+
+treechildren::-moz-tree-row {
+ min-height: 1.8rem;
+}
+
+:root[uidensity="compact"] treechildren::-moz-tree-row {
+ min-height: 1.6rem;
+}
+
+:root[uidensity="touch"] treechildren::-moz-tree-row {
+ min-height: 2.4rem;
+}
diff --git a/comm/mail/themes/osx/mail/multimessageview.css b/comm/mail/themes/osx/mail/multimessageview.css
new file mode 100644
index 0000000000..f3feac4f06
--- /dev/null
+++ b/comm/mail/themes/osx/mail/multimessageview.css
@@ -0,0 +1,11 @@
+/* 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 url("chrome://messenger/skin/shared/multimessageview.css");
+
+/* osx-specific overrides for multimessageview.css*/
+
+.star {
+ top: 0.6em;
+}
diff --git a/comm/mail/themes/osx/mail/newsblog/feed-subscriptions.css b/comm/mail/themes/osx/mail/newsblog/feed-subscriptions.css
new file mode 100644
index 0000000000..162bb26989
--- /dev/null
+++ b/comm/mail/themes/osx/mail/newsblog/feed-subscriptions.css
@@ -0,0 +1,11 @@
+/* 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/. */
+
+/* ::::: Feed Subscription styling :::::: */
+
+@import url("chrome://messenger/skin/shared/feedSubscribe.css");
+
+#subscriptionsDialog {
+ padding: 4px;
+}
diff --git a/comm/mail/themes/osx/mail/panelUI.css b/comm/mail/themes/osx/mail/panelUI.css
new file mode 100644
index 0000000000..808c135edc
--- /dev/null
+++ b/comm/mail/themes/osx/mail/panelUI.css
@@ -0,0 +1,58 @@
+/* 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 url("chrome://messenger/skin/shared/panelUI.css");
+
+#appMenu-popup {
+ margin-top: 4px;
+}
+
+#appMenu-popup {
+ margin-inline: 0 -8px;
+}
+
+.panel-subview-body {
+ scrollbar-color: color-mix(in srgb, currentColor 26%, transparent) transparent;
+}
+
+.restoreallitem > .toolbarbutton-icon {
+ display: none;
+}
+
+panelmultiview .toolbaritem-combined-buttons > spacer.before-label {
+ /* 8px + 18px toolbarbutton margin start/padding + 16px icon + 4px label padding start */
+ width: 46px;
+}
+
+.subviewbutton[shortcut]::after {
+ opacity: 0.5;
+}
+
+.subviewbutton[shortcut]:not([disabled="true"]):hover::after,
+.subviewbutton[shortcut]:not([disabled="true"]):focus::after {
+ opacity: 1;
+}
+
+menupopup[type="arrow"] {
+ appearance: none;
+ background-color: transparent;
+}
+
+menupopup[type="arrow"]::part(content) {
+ margin: 1px;
+}
+
+menuitem.subviewbutton-iconic > .menu-iconic-left > .menu-iconic-icon {
+ width: 16px;
+}
+
+menu.subviewbutton > .menu-text,
+menuitem.subviewbutton > .menu-text {
+ margin-inline-start: 16px !important;
+}
+
+menuitem.subviewbutton > .menu-right {
+ margin-inline-end: -4px;
+ appearance: none;
+}
diff --git a/comm/mail/themes/osx/mail/popupPanel.css b/comm/mail/themes/osx/mail/popupPanel.css
new file mode 100644
index 0000000000..77379487f5
--- /dev/null
+++ b/comm/mail/themes/osx/mail/popupPanel.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/popupPanel.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+#editContactAddressBookList {
+ margin-inline: 6px;
+ padding-inline-start: 8px;
+}
+
+#editContactAddressBookList:focus {
+ outline: var(--focus-outline);
+}
+
+#editContactAddressBookList[disabled="true"] {
+ opacity: .5;
+}
+
+html|input.editContactTextbox {
+ padding: 3px 8px;
+}
+
+#messageHeaderCustomizationPanel {
+ margin-top: -6px;
+ margin-inline-end: 6px;
+}
diff --git a/comm/mail/themes/osx/mail/preferences/alwaysAsk.png b/comm/mail/themes/osx/mail/preferences/alwaysAsk.png
new file mode 100644
index 0000000000..c792d14887
--- /dev/null
+++ b/comm/mail/themes/osx/mail/preferences/alwaysAsk.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/preferences/application.png b/comm/mail/themes/osx/mail/preferences/application.png
new file mode 100644
index 0000000000..b4c1ca7d02
--- /dev/null
+++ b/comm/mail/themes/osx/mail/preferences/application.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/preferences/applications.css b/comm/mail/themes/osx/mail/preferences/applications.css
new file mode 100644
index 0000000000..a02b123213
--- /dev/null
+++ b/comm/mail/themes/osx/mail/preferences/applications.css
@@ -0,0 +1,32 @@
+/* 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 url("chrome://messenger/skin/shared/preferences/applications.css");
+
+/**
+ * Used by the cloudFile manager
+ */
+
+.cloudfileAccount description{
+ padding-inline-start: 3px;
+}
+
+.cloudfileAccount .typeIcon {
+ margin-inline-end: 5px;
+}
+
+.cloudfileAccount > input {
+ min-height: unset !important;
+ margin: 0 !important;
+ padding-block: 2px 3px !important;
+ padding-inline: 4px 3px !important;
+}
+
+.actionsMenu > menupopup {
+ margin: initial;
+}
+
+.actionsMenu > menupopup > menuitem {
+ padding-inline-start: 12px;
+}
diff --git a/comm/mail/themes/osx/mail/preferences/preferences.css b/comm/mail/themes/osx/mail/preferences/preferences.css
new file mode 100644
index 0000000000..7b0103a0b8
--- /dev/null
+++ b/comm/mail/themes/osx/mail/preferences/preferences.css
@@ -0,0 +1,33 @@
+/* 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 url("chrome://messenger/skin/shared/preferences/preferences.css");
+@namespace html "http://www.w3.org/1999/xhtml";
+
+html|h2 {
+ border-bottom-width: 0;
+ padding-bottom: 2px !important;
+ padding-inline-start: 0;
+}
+
+html|legend {
+ margin: 0;
+}
+
+tab:-moz-focusring > .tab-middle > .tab-text {
+ outline: none;
+}
+
+/**
+ * Dialog
+ */
+
+/* Add to Cookies dialog a bottom padding */
+#CookiesDialog > hbox > .actionButtons {
+ padding-bottom: 10px;
+}
+
+#defaultWebSearchPopup > menuitem > .menu-iconic-left {
+ margin-inline: 5px 3px;
+}
diff --git a/comm/mail/themes/osx/mail/preferences/saveFile.png b/comm/mail/themes/osx/mail/preferences/saveFile.png
new file mode 100644
index 0000000000..7177f8df30
--- /dev/null
+++ b/comm/mail/themes/osx/mail/preferences/saveFile.png
Binary files differ
diff --git a/comm/mail/themes/osx/mail/primaryToolbar.css b/comm/mail/themes/osx/mail/primaryToolbar.css
new file mode 100644
index 0000000000..f339f0182c
--- /dev/null
+++ b/comm/mail/themes/osx/mail/primaryToolbar.css
@@ -0,0 +1,44 @@
+/* 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 url("chrome://messenger/skin/shared/primaryToolbar.css");
+
+/* ::::: Mail Toolbars and Menubars ::::: */
+
+/*
+ This is a workaround for Bug 1482157
+ -moz-default-appearance: toolbox; makes the macOS sheets attached to the
+ element's bottom border. We cannot put this property on the toolbox itself as
+ it cancels all backgrounds that are there, so we set it on the toolbox bottom
+ border.
+*/
+.mail-toolbox::after,
+.contentTabToolbox::after {
+ appearance: auto;
+ -moz-default-appearance: toolbox;
+ content: "";
+ display: flex;
+ margin-top: -1px;
+ height: 1px;
+ /* use inset box-shadow instead of border because -moz-default-appearance hides the border */
+ box-shadow: inset 0 -1px var(--chrome-content-separator-color);
+}
+
+.mail-toolbox > toolbar
+.contentTabToolbox > toolbar {
+ appearance: none;
+}
+
+#button-chat[unreadMessages="true"] {
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
+}
+
+.button-appmenu {
+ margin-inline: 7px;
+}
+
+.msgNotificationBarText {
+ font: icon;
+ padding: 0;
+}
diff --git a/comm/mail/themes/osx/mail/searchBox.css b/comm/mail/themes/osx/mail/searchBox.css
new file mode 100644
index 0000000000..1e12d2f37d
--- /dev/null
+++ b/comm/mail/themes/osx/mail/searchBox.css
@@ -0,0 +1,44 @@
+/* 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 url("chrome://messenger/skin/shared/searchBox.css");
+
+.search-icon {
+ margin-inline-start: 8px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.8;
+}
+
+.searchBox,
+.themeableSearchBox {
+ margin-block: 3px 2px;
+ font: icon;
+ font-size: inherit;
+ padding: 1px 4px;
+}
+
+.searchBox:not(.gloda-search),
+.themeableSearchBox:not(.contentTabUrlInput) {
+ background-image: url("chrome://global/skin/icons/search-textbox.svg");
+ background-repeat: no-repeat;
+ background-position: 5px center;
+ padding-inline-start: 21px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.8;
+}
+
+.gloda-search,
+#peopleSearchInput {
+ margin-block: 2px 3px;
+}
+
+.remote-gloda-search-container {
+ padding-block: 1px;
+}
+
+.autocomplete-richlistitem[type^="gloda-"] {
+ padding-inline-start: 13px;
+}
diff --git a/comm/mail/themes/osx/mail/searchDialog.css b/comm/mail/themes/osx/mail/searchDialog.css
new file mode 100644
index 0000000000..3b61a474fa
--- /dev/null
+++ b/comm/mail/themes/osx/mail/searchDialog.css
@@ -0,0 +1,56 @@
+/* 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/. */
+
+/* ===== searchDialog.css ===============================================
+ == Styles for the Mail Search dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/searchDialog.css");
+
+#searchTerms > vbox {
+ font: menu;
+}
+
+#checkSearchSubFolders,
+#checkSearchOnline {
+ margin-inline-start: 6px;
+}
+
+#booleanAndGroup {
+ margin-inline-start: 4px;
+}
+
+:root:not([lwt-tree]) #searchResultListBox {
+ appearance: auto;
+ -moz-default-appearance: listbox;
+}
+
+.search-menulist,
+.search-value-menulist {
+ width: 14.5em;
+}
+
+.small-button {
+ min-width: 22px;
+ height: 20px;
+ padding: 0;
+ padding-inline-end: 1px;
+ padding-bottom: 1px;
+ margin: 2px 0;
+}
+
+.small-button:first-child {
+ margin-inline-start: 2px;
+}
+
+#sizeCol,
+#unreadCol,
+#totalCol {
+ text-align: right;
+}
+
+#status-bar {
+ font: message-box;
+ margin-top: 8px;
+}
diff --git a/comm/mail/themes/osx/mail/spacesToolbar.css b/comm/mail/themes/osx/mail/spacesToolbar.css
new file mode 100644
index 0000000000..09a3a74d19
--- /dev/null
+++ b/comm/mail/themes/osx/mail/spacesToolbar.css
@@ -0,0 +1,9 @@
+/* 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 url("chrome://messenger/skin/shared/spacesToolbar.css");
+
+:root:not([sizemode="fullscreen"]) .spaces-toolbar:not([hidden]) {
+ margin-top: 0;
+}
diff --git a/comm/mail/themes/osx/mail/tabmail.css b/comm/mail/themes/osx/mail/tabmail.css
new file mode 100644
index 0000000000..7470cafe67
--- /dev/null
+++ b/comm/mail/themes/osx/mail/tabmail.css
@@ -0,0 +1,147 @@
+/* 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 url("chrome://messenger/skin/shared/tabmail.css");
+
+/**
+ * Tabmail Tabs
+ */
+
+#tabs-toolbar:not(:-moz-lwtheme) {
+ color: #333;
+ text-shadow: 0 1px rgba(255, 255, 255, 0.4);
+}
+
+/**
+ * Tab
+ */
+
+.tab-label {
+ margin-top: 1px;
+ margin-bottom: 0;
+}
+
+.tabmail-tab[type="glodaSearch"] .tab-icon-image {
+ transform: scaleX(-1);
+}
+
+.tabmail-tab:not(:hover) .tab-icon-image:not([selected="true"]) {
+ opacity: .9;
+}
+
+.tab-label-container:not([selected="true"]) {
+ opacity: .7;
+}
+
+.tabmail-tab,
+.tabs-newtab-button {
+ font: message-box;
+ font-size: inherit;
+ border: none;
+}
+
+/* override the selected tab toolkit color/text-shadow */
+.tabmail-tab[selected="true"]:not(:-moz-lwtheme) {
+ color: #333;
+ text-shadow: 0 1px rgba(255, 255, 255, 0.4);
+}
+
+.tabmail-tab[selected="true"]:not(:-moz-lwtheme) {
+ -moz-font-smoothing-background-color: var(--toolbar-bgcolor);
+}
+
+.tabmail-tab:focus .tab-label-container {
+ outline: var(--focus-outline);
+}
+
+#tabmail-tabs {
+ align-items: stretch;
+ font-size: inherit;
+ padding-left: 0;
+ padding-right: 0;
+ margin-bottom: 0;
+}
+
+:root[tabsintitlebar]:not([sizemode="fullscreen"]) #tabmail-tabs {
+ position: unset;
+}
+
+tabmail > tabbox > tabpanels {
+ appearance: none !important;
+}
+
+/**
+ * Tab Scrollbox Arrow Buttons
+ */
+
+#tabmail-arrowscrollbox::part(scrollbutton-up),
+#tabmail-arrowscrollbox::part(scrollbutton-down) {
+ padding: 0 4px !important;
+ margin: 0 0 var(--tabs-tabbar-border-size) !important;
+}
+
+#tabmail-arrowscrollbox:not([scrolledtostart=true])::part(scrollbutton-up):hover,
+#tabmail-arrowscrollbox:not([scrolledtoend=true])::part(scrollbutton-down):hover {
+ background-image: linear-gradient(transparent, rgba(0,0,0,0.15));
+}
+
+#tabmail-arrowscrollbox:not([scrolledtostart=true])::part(scrollbutton-up):hover:active,
+#tabmail-arrowscrollbox:not([scrolledtoend=true])::part(scrollbutton-down):hover:active {
+ background-image: linear-gradient(transparent, rgba(0,0,0,0.3));
+}
+
+#tabs-toolbar[brighttext]
+ #tabmail-arrowscrollbox:not([scrolledtostart=true])::part(scrollbutton-up):hover,
+#tabs-toolbar[brighttext]
+ #tabmail-arrowscrollbox:not([scrolledtoend=true])::part(scrollbutton-down):hover {
+ background-image: linear-gradient(rgba(255,255,255,0.25), rgba(255,255,255,0.25));
+}
+
+#tabs-toolbar[brighttext]
+ #tabmail-arrowscrollbox:not([scrolledtostart=true])::part(scrollbutton-up):hover:active,
+#tabs-toolbar[brighttext]
+ #tabmail-arrowscrollbox:not([scrolledtoend=true])::part(scrollbutton-down):hover:active {
+ background-image: linear-gradient(rgba(255,255,255,0.35), rgba(255,255,255,0.35));
+}
+
+#tabmail-arrowscrollbox[scrolledtostart=true]::part(scrollbutton-up),
+#tabmail-arrowscrollbox[scrolledtoend=true]::part(scrollbutton-down) {
+ --toolbarbutton-icon-fill-opacity: .5;
+ background-image: none;
+}
+
+/* Tab Overflow */
+#tabmail-arrowscrollbox:not([scrolledtostart])::part(arrowscrollbox-overflow-start-indicator),
+#tabmail-arrowscrollbox:not([scrolledtoend])::part(arrowscrollbox-overflow-end-indicator) {
+ margin-bottom: 0;
+}
+
+/**
+ * All Tabs Buttons
+ */
+
+.tabs-alltabs-box {
+ margin: 0;
+}
+
+/**
+ * All Tabs Menupopup
+ */
+
+@media (min-resolution: 1.1dppx) {
+ alltabs-item[busy] {
+ list-style-image: url("chrome://global/skin/icons/loading@2x.png") !important;
+ }
+}
+
+/* Content Tabs */
+.contentTabAddress {
+ height: 31px;
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
+.contentTabUrlbarInput {
+ background-image: none;
+}
diff --git a/comm/mail/themes/osx/mail/themeableDialog.css b/comm/mail/themes/osx/mail/themeableDialog.css
new file mode 100644
index 0000000000..5bd2432aca
--- /dev/null
+++ b/comm/mail/themes/osx/mail/themeableDialog.css
@@ -0,0 +1,91 @@
+/* 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 url("chrome://messenger/skin/shared/themeableDialog.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+@media (prefers-color-scheme: dark) {
+ :host,
+ :root:-moz-lwtheme {
+ --arrowpanel-color: #f9f9fa;
+ --arrowpanel-background: #282829;
+ --richlist-button-background: #1e1e1e;
+ }
+}
+
+:host,
+:root {
+ appearance: none;
+}
+
+button > .button-box {
+ padding-block: 3px;
+}
+
+#resetColor > .button-box {
+ padding-block: 0;
+}
+
+.button-menu-dropmarker {
+ display: flex;
+}
+
+menulist {
+ padding-inline-end: 0;
+}
+
+menulist::part(dropmarker) {
+ display: flex;
+ width: 20px;
+}
+
+html|input {
+ padding: 4px;
+}
+
+html|input[type="number"]::-moz-number-spin-up,
+html|input[type="number"]::-moz-number-spin-down {
+ min-height: 0.7em;
+}
+
+menulist[is="menulist-editable"][editable="true"] {
+ padding: 0;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(text-input) {
+ padding: 4px;
+ margin-block: -1px;
+ margin-inline: -1px 2px;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(dropmarker) {
+ display: flex;
+ width: initial;
+ min-width: 0;
+ margin-block: 0;
+ margin-inline-end: 3px;
+}
+
+menulist > menupopup:not([position]) {
+ margin-inline-start: 0;
+ margin-top: 0;
+}
+
+.menu-right {
+ margin-top: 3px;
+}
+
+tabbox {
+ margin-inline: 0;
+}
+
+.tab-middle {
+ padding: 0;
+}
+
+#commonDialog:not([subdialog]) {
+ color: var(--arrowpanel-color);
+ background-color: var(--arrowpanel-background);
+}
diff --git a/comm/mail/themes/osx/mail/toolbar.css b/comm/mail/themes/osx/mail/toolbar.css
new file mode 100644
index 0000000000..0f796a6a96
--- /dev/null
+++ b/comm/mail/themes/osx/mail/toolbar.css
@@ -0,0 +1,47 @@
+/* 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/. */
+
+/* ===== toolbar.css ====================================================
+ == Styles used by XUL toolbar-related elements.
+ ======================================================================= */
+
+/* ::::: toolbox ::::: */
+
+toolbox {
+ appearance: auto;
+ -moz-default-appearance: toolbox;
+ background-color: -moz-Dialog;
+ border-left: 1px solid ThreeDShadow;
+ border-top: 1px solid ThreeDShadow;
+ border-right: none;
+ border-bottom: 1px solid #000000;
+}
+
+/* ::::: toolbar & menubar ::::: */
+
+toolbar,
+menubar {
+ appearance: auto;
+ -moz-default-appearance: toolbar;
+ min-width: 1px; /* DON'T DELETE!
+ Prevents hiding of scrollbars in browser when window is made smaller.*/
+}
+
+.toolbar-holder {
+ border-left: 1px solid ThreeDHighlight;
+ border-top: 1px solid ThreeDHighlight;
+ border-right: 1px solid ThreeDShadow;
+ border-bottom: 1px solid ThreeDShadow;
+}
+
+/* ::::: toolbarseparator ::::: */
+
+toolbarseparator {
+ appearance: auto;
+ -moz-default-appearance: separator;
+ margin: 2px 0.2em;
+ border-right: 1px solid ThreeDHighlight;
+ border-left: 1px solid ThreeDShadow;
+ width: 2px;
+}
diff --git a/comm/mail/themes/osx/mail/variables.css b/comm/mail/themes/osx/mail/variables.css
new file mode 100644
index 0000000000..e008c12f02
--- /dev/null
+++ b/comm/mail/themes/osx/mail/variables.css
@@ -0,0 +1,26 @@
+/* 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 url("chrome://messenger/skin/shared/variables.css");
+
+:root {
+ --tabline-color: AccentColor;
+ --toolbar-non-lwt-bgcolor: #f9f9fa;
+ --toolbar-non-lwt-textcolor: #0c0c0d;
+ --toolbar-non-lwt-bgimage: none;
+ --chrome-content-separator-color: hsl(0, 0%, 60%);
+ --row-grouped-header-bg-color: #d5d5d5;
+ --row-grouped-header-bg-color-selected: #3874d1;
+ --panel-separator-color: hsla(210, 4%, 10%, 0.14);
+ --autocomplete-popup-url-color: hsl(210, 77%, 47%);
+}
+
+:root:not(:-moz-lwtheme) {
+ --chrome-content-separator-color: hsl(0, 0%, 68%);
+}
+
+:root:not(:-moz-lwtheme):-moz-window-inactive {
+ --toolbar-bgcolor: -moz-mac-chrome-inactive;
+ --chrome-content-separator-color: hsl(0, 0%, 85%);
+}
diff --git a/comm/mail/themes/osx/moz.build b/comm/mail/themes/osx/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/mail/themes/osx/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/themes/shared/jar.inc.mn b/comm/mail/themes/shared/jar.inc.mn
new file mode 100644
index 0000000000..4f4657b668
--- /dev/null
+++ b/comm/mail/themes/shared/jar.inc.mn
@@ -0,0 +1,731 @@
+# 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 is not a complete / proper jar manifest. It is included by the
+# actual theme-specific manifests, so that shared resources need only
+# be specified once. As a result, the source file paths are relative
+# to the location of the actual manifest.
+
+% override chrome://global/skin/wizard.css chrome://messenger/skin/wizard.css
+% override chrome://global/skin/in-content/common.css chrome://messenger/skin/common.css
+% override chrome://global/content/elements/message-bar.css chrome://messenger/skin/message-bar.css
+% override chrome://browser/skin/developer.svg chrome://messenger/skin/icons/developer.svg
+
+# Devtools overrides
+% override chrome://devtools/skin/images/aboutdebugging-firefox-nightly.svg chrome://branding/content/about-logo.svg
+% override chrome://devtools/skin/images/aboutdebugging-firefox-release.svg chrome://branding/content/about-logo.svg
+% override chrome://devtools/skin/images/aboutdebugging-firefox-beta.svg chrome://branding/content/about-logo.svg
+% override chrome://devtools/skin/images/aboutdebugging-firefox-logo.svg chrome://messenger/skin/icons/aboutdebugging-logo.svg
+
+# Proton icon overrides
+% override chrome://global/skin/icons/add.svg chrome://messenger/skin/overrides/add.svg
+% override chrome://global/skin/icons/blocked.svg chrome://messenger/skin/overrides/blocked.svg
+% override chrome://global/skin/icons/chevron.svg chrome://messenger/skin/overrides/chevron.svg
+% override chrome://global/skin/icons/close.svg chrome://messenger/skin/overrides/close.svg
+% override chrome://global/skin/icons/defaultFavicon.svg chrome://messenger/skin/overrides/defaultFavicon.svg
+% override chrome://global/skin/icons/delete.svg chrome://messenger/skin/overrides/delete.svg
+% override chrome://global/skin/icons/error.svg chrome://messenger/skin/overrides/error.svg
+% override chrome://global/skin/icons/find-next-arrow.svg chrome://messenger/skin/overrides/find-next-arrow.svg
+% override chrome://global/skin/icons/find-previous-arrow.svg chrome://messenger/skin/overrides/find-previous-arrow.svg
+% override chrome://global/skin/icons/folder.svg chrome://messenger/skin/overrides/folder.svg
+% override chrome://global/skin/icons/help.svg chrome://messenger/skin/overrides/help.svg
+% override chrome://global/skin/icons/info.svg chrome://messenger/skin/overrides/info.svg
+% override chrome://global/skin/icons/info-filled.svg chrome://messenger/skin/overrides/info-filled.svg
+% override chrome://global/skin/icons/menu-check.svg chrome://messenger/skin/overrides/check.svg
+% override chrome://global/skin/icons/more.svg chrome://messenger/skin/overrides/more.svg
+% override chrome://global/skin/icons/open-in-new.svg chrome://messenger/skin/overrides/open-in-new.svg
+% override chrome://global/skin/icons/plugin.svg chrome://messenger/skin/overrides/plugin.svg
+% override chrome://global/skin/icons/plugin-blocked.svg chrome://messenger/skin/overrides/plugin-blocked.svg
+% override chrome://global/skin/icons/print.svg chrome://messenger/skin/overrides/print.svg
+% override chrome://global/skin/icons/security.svg chrome://messenger/skin/overrides/security.svg
+% override chrome://global/skin/icons/security-broken.svg chrome://messenger/skin/overrides/security-broken.svg
+% override chrome://global/skin/icons/security-warning.svg chrome://messenger/skin/overrides/security-warning.svg
+% override chrome://global/skin/icons/update-icon.svg chrome://messenger/skin/overrides/update-icon.svg
+% override chrome://global/skin/icons/warning.svg chrome://messenger/skin/overrides/warning.svg
+% override chrome://mozapps/skin/extensions/category-available.svg chrome://messenger/skin/overrides/category-available.svg
+% override chrome://mozapps/skin/extensions/category-discover.svg chrome://messenger/skin/overrides/category-discover.svg
+% override chrome://mozapps/skin/extensions/category-extensions.svg chrome://messenger/skin/overrides/category-extensions.svg
+% override chrome://mozapps/skin/extensions/category-recent.svg chrome://messenger/skin/overrides/category-recent.svg
+% override chrome://mozapps/skin/extensions/category-themes.svg chrome://messenger/skin/overrides/category-themes.svg
+% override chrome://mozapps/skin/extensions/extensionGeneric.svg chrome://messenger/skin/overrides/category-extensions.svg
+
+ skin/classic/messenger/overrides/add.svg (../shared/mail/overrides/add.svg)
+ skin/classic/messenger/overrides/arrow-down.svg (../shared/mail/overrides/arrow-down.svg)
+ skin/classic/messenger/overrides/arrow-down-12.svg (../shared/mail/overrides/arrow-down-12.svg)
+ skin/classic/messenger/overrides/arrow-left.svg (../shared/mail/overrides/arrow-left.svg)
+ skin/classic/messenger/overrides/arrow-left-12.svg (../shared/mail/overrides/arrow-left-12.svg)
+ skin/classic/messenger/overrides/arrow-right.svg (../shared/mail/overrides/arrow-right.svg)
+ skin/classic/messenger/overrides/arrow-right-12.svg (../shared/mail/overrides/arrow-right-12.svg)
+ skin/classic/messenger/overrides/arrow-up.svg (../shared/mail/overrides/arrow-up.svg)
+ skin/classic/messenger/overrides/arrow-up-12.svg (../shared/mail/overrides/arrow-up-12.svg)
+ skin/classic/messenger/overrides/blocked.svg (../shared/mail/overrides/blocked.svg)
+ skin/classic/messenger/overrides/category-available.svg (../shared/mail/overrides/category-available.svg)
+ skin/classic/messenger/overrides/category-discover.svg (../shared/mail/overrides/category-discover.svg)
+ skin/classic/messenger/overrides/category-extensions.svg (../shared/mail/overrides/category-extensions.svg)
+ skin/classic/messenger/overrides/category-recent.svg (../shared/mail/overrides/category-recent.svg)
+ skin/classic/messenger/overrides/category-themes.svg (../shared/mail/overrides/category-themes.svg)
+ skin/classic/messenger/overrides/check.svg (../shared/mail/overrides/check.svg)
+ skin/classic/messenger/overrides/chevron.svg (../shared/mail/overrides/chevron.svg)
+ skin/classic/messenger/overrides/close.svg (../shared/mail/overrides/close.svg)
+ skin/classic/messenger/overrides/defaultFavicon.svg (../shared/mail/overrides/defaultFavicon.svg)
+ skin/classic/messenger/overrides/delete.svg (../shared/mail/overrides/delete.svg)
+ skin/classic/messenger/overrides/error.svg (../shared/mail/overrides/error.svg)
+ skin/classic/messenger/overrides/extension.svg (../shared/mail/overrides/extension.svg)
+ skin/classic/messenger/overrides/find-next-arrow.svg (../shared/mail/overrides/find-next-arrow.svg)
+ skin/classic/messenger/overrides/find-previous-arrow.svg (../shared/mail/overrides/find-previous-arrow.svg)
+ skin/classic/messenger/overrides/folder.svg (../shared/mail/overrides/folder.svg)
+ skin/classic/messenger/overrides/help.svg (../shared/mail/overrides/help.svg)
+ skin/classic/messenger/overrides/info.svg (../shared/mail/overrides/info.svg)
+ skin/classic/messenger/overrides/info-filled.svg (../shared/mail/overrides/info-filled.svg)
+ skin/classic/messenger/overrides/more.svg (../shared/mail/overrides/more.svg)
+ skin/classic/messenger/overrides/open-in-new.svg (../shared/mail/overrides/open-in-new.svg)
+ skin/classic/messenger/overrides/plugin.svg (../shared/mail/overrides/plugin.svg)
+ skin/classic/messenger/overrides/plugin-blocked.svg (../shared/mail/overrides/plugin-blocked.svg)
+ skin/classic/messenger/overrides/print.svg (../shared/mail/overrides/print.svg)
+ skin/classic/messenger/overrides/search-glass.svg (../shared/mail/overrides/search-glass.svg)
+ skin/classic/messenger/overrides/security.svg (../shared/mail/overrides/security.svg)
+ skin/classic/messenger/overrides/security-broken.svg (../shared/mail/overrides/security-broken.svg)
+ skin/classic/messenger/overrides/security-warning.svg (../shared/mail/overrides/security-warning.svg)
+ skin/classic/messenger/overrides/settings.svg (../shared/mail/overrides/settings.svg)
+ skin/classic/messenger/overrides/update-icon.svg (../shared/mail/overrides/update-icon.svg)
+ skin/classic/messenger/overrides/warning.svg (../shared/mail/overrides/warning.svg)
+# End Proton icon overrides
+
+# Supernova icon overrides
+% override chrome://global/skin/icons/settings.svg chrome://messenger/skin/icons/new/compact/settings.svg
+% override chrome://mozapps/skin/extensions/extension.svg chrome://messenger/skin/icons/new/compact/extension.svg
+% override chrome://global/skin/icons/search-glass.svg chrome://messenger/skin/icons/new/compact/search.svg
+% override chrome://global/skin/icons/arrow-down.svg chrome://messenger/skin/icons/new/compact/nav-down.svg
+% override chrome://global/skin/icons/arrow-down-12.svg chrome://messenger/skin/icons/new/nav-down-sm.svg
+% override chrome://global/skin/icons/arrow-left.svg chrome://messenger/skin/icons/new/compact/nav-left.svg
+% override chrome://global/skin/icons/arrow-left-12.svg chrome://messenger/skin/icons/new/nav-left-sm.svg
+% override chrome://global/skin/icons/arrow-right.svg chrome://messenger/skin/icons/new/compact/nav-right.svg
+% override chrome://global/skin/icons/arrow-right-12.svg chrome://messenger/skin/icons/new/nav-right-sm.svg
+% override chrome://global/skin/icons/arrow-up.svg chrome://messenger/skin/icons/new/compact/nav-up.svg
+% override chrome://global/skin/icons/arrow-up-12.svg chrome://messenger/skin/icons/new/nav-up-sm.svg
+% override chrome://global/skin/icons/check.svg chrome://messenger/skin/icons/new/compact/check.svg
+# End Supernova icon overrides
+
+ skin/classic/messenger/abFormFields.css (../shared/mail/abFormFields.css)
+ skin/classic/messenger/about3Pane.css (../shared/mail/about3Pane.css)
+ skin/classic/messenger/aboutAddonsExtra.css (../shared/mail/aboutAddonsExtra.css)
+ skin/classic/messenger/aboutAddressBook.css (../shared/mail/aboutAddressBook.css)
+ skin/classic/messenger/aboutImport.css (../shared/mail/aboutImport.css)
+ skin/classic/messenger/aboutPolicies.css (../shared/mail/aboutPolicies.css)
+ skin/classic/messenger/aboutSupport.css (../shared/mail/aboutSupport.css)
+
+ skin/classic/messenger/abSearchDialog.css (../shared/mail/abSearchDialog.css)
+ skin/classic/messenger/accountHub.css (../shared/mail/accountHub.css)
+ skin/classic/messenger/accountHubForms.css (../shared/mail/accountHubForms.css)
+ skin/classic/messenger/accountSetup.css (../shared/mail/accountSetup.css)
+ skin/classic/messenger/accountWizard.css (../shared/mail/accountWizard.css)
+ skin/classic/messenger/autocomplete.css (../shared/mail/autocomplete.css)
+ skin/classic/messenger/cloudfileSelectDialog.css (../shared/mail/cloudfileSelectDialog.css)
+ skin/classic/messenger/extensionPopup.css (../shared/mail/extensionPopup.css)
+ skin/classic/messenger/folderProps.css (../shared/mail/folderProps.css)
+ skin/classic/messenger/imMenulist.css (../shared/mail/imMenulist.css)
+ skin/classic/messenger/imRichlistbox.css (../shared/mail/imRichlistbox.css)
+ skin/classic/messenger/inContentDialog.css (../shared/mail/inContentDialog.css)
+ skin/classic/messenger/joinchat.css (../shared/mail/joinchat.css)
+ skin/classic/messenger/messageQuotes.css (../shared/mail/messageQuotes.css)
+ skin/classic/messenger/migrationProgress.css (../shared/mail/migrationProgress.css)
+ skin/classic/messenger/msgSelectOffline.css (../shared/mail/msgSelectOffline.css)
+#ifndef XP_MACOSX
+ skin/classic/messenger/newmailalert.css (../shared/mail/newmailalert.css)
+#endif
+ skin/classic/messenger/profileDowngrade.css (../shared/mail/profileDowngrade.css)
+ skin/classic/messenger/subscribe.css (../shared/mail/subscribe.css)
+ skin/classic/messenger/tagColors.css (../shared/mail/tagColors.css)
+ skin/classic/messenger/vcard.css (../shared/mail/vcard.css)
+ skin/classic/messenger/wizard.css (../shared/mail/wizard.css)
+ skin/classic/messenger/verifychat.css (../shared/mail/verifychat.css)
+ skin/classic/messenger/addressbook/abResultsPane.css (../shared/mail/abResultsPane.css)
+ skin/classic/messenger/addressbook/icons/contact-generic.svg (../shared/mail/icons/contact-generic.svg)
+ skin/classic/messenger/addressbook/icons/menu.svg (../shared/mail/icons/menu.svg)
+ skin/classic/messenger/browserRequest.css (../shared/mail/browserRequest.css)
+ skin/classic/messenger/icons/ablist.svg (../shared/mail/icons/ablist.svg)
+ skin/classic/messenger/icons/aboutdebugging-logo.svg (../shared/mail/icons/aboutdebugging-logo.svg)
+ skin/classic/messenger/icons/account-sync.svg (../shared/mail/icons/account-sync.svg)
+ skin/classic/messenger/icons/address.svg (../shared/mail/icons/address.svg)
+ skin/classic/messenger/icons/addcontact.svg (../shared/mail/icons/addcontact.svg)
+ skin/classic/messenger/icons/addlist.svg (../shared/mail/icons/addlist.svg)
+ skin/classic/messenger/icons/accounts.svg (../shared/mail/icons/accounts.svg)
+ skin/classic/messenger/icons/add-circle-fill.svg (../shared/mail/icons/add-circle-fill.svg)
+ skin/classic/messenger/icons/addon-install-blocked.svg (../shared/mail/icons/addon-install-blocked.svg)
+ skin/classic/messenger/icons/addon-install-confirm.svg (../shared/mail/icons/addon-install-confirm.svg)
+ skin/classic/messenger/icons/addon-install-downloading.svg (../shared/mail/icons/addon-install-downloading.svg)
+ skin/classic/messenger/icons/addon-install-error.svg (../shared/mail/icons/addon-install-error.svg)
+ skin/classic/messenger/icons/addon-install-installed.svg (../shared/mail/icons/addon-install-installed.svg)
+ skin/classic/messenger/icons/addon-install-warning.svg (../shared/mail/icons/addon-install-warning.svg)
+ skin/classic/messenger/icons/anchor.svg (../shared/mail/icons/anchor.svg)
+#ifdef MOZ_UPDATER
+ skin/classic/messenger/icons/app-update.svg (../shared/mail/icons/app-update.svg)
+ skin/classic/messenger/icons/app-update-badge.svg (../shared/mail/icons/app-update-badge.svg)
+#endif
+ skin/classic/messenger/icons/appbutton.svg (../shared/mail/icons/appbutton.svg)
+ skin/classic/messenger/icons/appbutton-badged.svg (../shared/mail/icons/appbutton-badged.svg)
+ skin/classic/messenger/icons/archive.svg (../shared/mail/icons/archive.svg)
+ skin/classic/messenger/icons/arrow-dropdown.svg (../shared/mail/icons/arrow-dropdown.svg)
+ skin/classic/messenger/icons/attach.svg (../shared/mail/icons/attach.svg)
+ skin/classic/messenger/icons/attachment-col.svg (../shared/mail/icons/attachment-col.svg)
+ skin/classic/messenger/icons/attachment-deleted.svg (../shared/mail/icons/attachment-deleted.svg)
+ skin/classic/messenger/icons/attachment-deleted-large.svg (../shared/mail/icons/attachment-deleted-large.svg)
+ skin/classic/messenger/icons/bold.svg (../shared/mail/icons/bold.svg)
+ skin/classic/messenger/icons/browser-back.svg (../shared/mail/icons/browser-back.svg)
+ skin/classic/messenger/icons/browser-forward.svg (../shared/mail/icons/browser-forward.svg)
+ skin/classic/messenger/icons/bullet-list.svg (../shared/mail/icons/bullet-list.svg)
+ skin/classic/messenger/icons/cancel.svg (../shared/mail/icons/cancel.svg)
+ skin/classic/messenger/icons/cert-error.svg (../shared/mail/icons/cert-error.svg)
+ skin/classic/messenger/icons/chat.svg (../shared/mail/icons/chat.svg)
+ skin/classic/messenger/icons/checkbox.svg (../shared/mail/icons/checkbox.svg)
+ skin/classic/messenger/icons/collapse.svg (../shared/mail/icons/collapse.svg)
+ skin/classic/messenger/icons/center-align.svg (../shared/mail/icons/center-align.svg)
+ skin/classic/messenger/icons/cut.svg (../shared/mail/icons/cut.svg)
+ skin/classic/messenger/icons/correspondents.svg (../shared/mail/icons/correspondents.svg)
+ skin/classic/messenger/icons/correspondents-rtl.svg (../shared/mail/icons/correspondents-rtl.svg)
+ skin/classic/messenger/icons/compact.svg (../shared/mail/icons/compact.svg)
+ skin/classic/messenger/icons/connection-insecure.svg (../shared/mail/icons/connection-insecure.svg)
+ skin/classic/messenger/icons/connection-mixed.svg (../shared/mail/icons/connection-mixed.svg)
+ skin/classic/messenger/icons/connection-secure.svg (../shared/mail/icons/connection-secure.svg)
+ skin/classic/messenger/icons/folder-new-indicator.svg (../shared/mail/icons/folder-new-indicator.svg)
+ skin/classic/messenger/icons/message-encrypted-ok.svg (../shared/mail/icons/message-encrypted-ok.svg)
+ skin/classic/messenger/icons/message-encrypted-notok.svg (../shared/mail/icons/message-encrypted-notok.svg)
+ skin/classic/messenger/icons/contact.svg (../shared/mail/icons/contact.svg)
+ skin/classic/messenger/icons/conversation.svg (../shared/mail/icons/conversation.svg)
+ skin/classic/messenger/icons/decrease.svg (../shared/mail/icons/decrease.svg)
+ skin/classic/messenger/icons/delete.svg (../shared/mail/icons/delete.svg)
+ skin/classic/messenger/icons/delete-col.svg (../shared/mail/icons/delete-col.svg)
+ skin/classic/messenger/icons/developer.svg (../shared/mail/icons/developer.svg)
+ skin/classic/messenger/icons/shield.svg (../shared/mail/icons/shield.svg)
+ skin/classic/messenger/icons/message-signed-ok.svg (../shared/mail/icons/message-signed-ok.svg)
+ skin/classic/messenger/icons/message-signed-mismatch.svg (../shared/mail/icons/message-signed-mismatch.svg)
+ skin/classic/messenger/icons/message-signed-unknown.svg (../shared/mail/icons/message-signed-unknown.svg)
+ skin/classic/messenger/icons/message-signed-unverified.svg (../shared/mail/icons/message-signed-unverified.svg)
+ skin/classic/messenger/icons/message-signed-verified.svg (../shared/mail/icons/message-signed-verified.svg)
+ skin/classic/messenger/icons/exclude.svg (../shared/mail/icons/exclude.svg)
+ skin/classic/messenger/icons/empty-search-results.svg (../shared/mail/icons/empty-search-results.svg)
+ skin/classic/messenger/icons/encryption-key.svg (../shared/mail/icons/encryption-key.svg)
+ skin/classic/messenger/icons/feeds.svg (../shared/mail/icons/feeds.svg)
+ skin/classic/messenger/icons/feeds-folder.svg (../shared/mail/icons/feeds-folder.svg)
+ skin/classic/messenger/icons/file.svg (../shared/mail/icons/file.svg)
+ skin/classic/messenger/icons/file-item.svg (../shared/mail/icons/file-item.svg)
+ skin/classic/messenger/icons/filter.svg (../shared/mail/icons/filter.svg)
+ skin/classic/messenger/icons/fingerprint.svg (../shared/mail/icons/fingerprint.svg)
+ skin/classic/messenger/icons/flag-col.svg (../shared/mail/icons/flag-col.svg)
+ skin/classic/messenger/icons/flagged.svg (../shared/mail/icons/flagged.svg)
+ skin/classic/messenger/icons/folder.svg (../shared/mail/icons/folder.svg)
+ skin/classic/messenger/icons/folder-local.svg (../shared/mail/icons/folder-local.svg)
+ skin/classic/messenger/icons/forget.svg (../shared/mail/icons/forget.svg)
+ skin/classic/messenger/icons/forward.svg (../shared/mail/icons/forward.svg)
+ skin/classic/messenger/icons/forward-redirect.svg (../shared/mail/icons/forward-redirect.svg)
+ skin/classic/messenger/icons/founder.png (../shared/mail/icons/founder.png)
+ skin/classic/messenger/icons/get-all.svg (../shared/mail/icons/get-all.svg)
+ skin/classic/messenger/icons/getmsg.svg (../shared/mail/icons/getmsg.svg)
+ skin/classic/messenger/icons/goback.svg (../shared/mail/icons/goback.svg)
+ skin/classic/messenger/icons/goforward.svg (../shared/mail/icons/goforward.svg)
+ skin/classic/messenger/icons/globe.svg (../shared/mail/icons/globe.svg)
+ skin/classic/messenger/icons/globe-secure.svg (../shared/mail/icons/globe-secure.svg)
+ skin/classic/messenger/icons/half-operator.png (../shared/mail/icons/half-operator.png)
+ skin/classic/messenger/icons/help.svg (../shared/mail/icons/help.svg)
+ skin/classic/messenger/icons/hidden.svg (../shared/mail/icons/hidden.svg)
+ skin/classic/messenger/icons/highlights.svg (../shared/mail/icons/highlights.svg)
+ skin/classic/messenger/icons/hline.svg (../shared/mail/icons/hline.svg)
+ skin/classic/messenger/icons/hourglass.svg (../shared/mail/icons/hourglass.svg)
+ skin/classic/messenger/icons/image.svg (../shared/mail/icons/image.svg)
+ skin/classic/messenger/icons/import.svg (../shared/mail/icons/import.svg)
+ skin/classic/messenger/icons/inbox.svg (../shared/mail/icons/inbox.svg)
+ skin/classic/messenger/icons/increase.svg (../shared/mail/icons/increase.svg)
+ skin/classic/messenger/icons/indent.svg (../shared/mail/icons/indent.svg)
+ skin/classic/messenger/icons/info.svg (../shared/mail/icons/info.svg)
+ skin/classic/messenger/icons/information.svg (../shared/mail/icons/information.svg)
+ skin/classic/messenger/icons/italics.svg (../shared/mail/icons/italics.svg)
+ skin/classic/messenger/icons/join.svg (../shared/mail/icons/join.svg)
+ skin/classic/messenger/icons/junk.svg (../shared/mail/icons/junk.svg)
+ skin/classic/messenger/icons/junk-col.svg (../shared/mail/icons/junk-col.svg)
+ skin/classic/messenger/icons/justify.svg (../shared/mail/icons/justify.svg)
+ skin/classic/messenger/icons/left-align.svg (../shared/mail/icons/left-align.svg)
+ skin/classic/messenger/icons/loading.svg (../shared/mail/icons/loading.svg)
+ skin/classic/messenger/icons/login.svg (../shared/mail/icons/login.svg)
+ skin/classic/messenger/icons/mark.svg (../shared/mail/icons/mark.svg)
+ skin/classic/messenger/icons/message.svg (../shared/mail/icons/message.svg)
+ skin/classic/messenger/icons/message-secure.svg (../shared/mail/icons/message-secure.svg)
+ skin/classic/messenger/icons/more.svg (../shared/mail/icons/more.svg)
+ skin/classic/messenger/icons/move-first.svg (../shared/mail/icons/move-first.svg)
+ skin/classic/messenger/icons/move-last.svg (../shared/mail/icons/move-last.svg)
+ skin/classic/messenger/icons/move-left.svg (../shared/mail/icons/move-left.svg)
+ skin/classic/messenger/icons/move-right.svg (../shared/mail/icons/move-right.svg)
+ skin/classic/messenger/icons/move-together.svg (../shared/mail/icons/move-together.svg)
+ skin/classic/messenger/icons/navigation.svg (../shared/mail/icons/navigation.svg)
+ skin/classic/messenger/icons/new-addressbook.svg (../shared/mail/icons/new-addressbook.svg)
+ skin/classic/messenger/icons/new-calendar.svg (../shared/mail/icons/new-calendar.svg)
+ skin/classic/messenger/icons/new-key.svg (../shared/mail/icons/new-key.svg)
+ skin/classic/messenger/icons/newmail.svg (../shared/mail/icons/newmail.svg)
+ skin/classic/messenger/icons/newmsg.svg (../shared/mail/icons/newmsg.svg)
+ skin/classic/messenger/icons/newsgroup.svg (../shared/mail/icons/newsgroup.svg)
+ skin/classic/messenger/icons/new-window.svg (../shared/mail/icons/new-window.svg)
+ skin/classic/messenger/icons/nextmsg.svg (../shared/mail/icons/nextmsg.svg)
+ skin/classic/messenger/icons/nextunread.svg (../shared/mail/icons/nextunread.svg)
+ skin/classic/messenger/icons/notification-fill-12.svg (../shared/mail/icons/notification-fill-12.svg)
+ skin/classic/messenger/icons/notloading.png (../shared/mail/icons/notloading.png)
+ skin/classic/messenger/icons/notloading@2x.png (../shared/mail/icons/notloading@2x.png)
+ skin/classic/messenger/icons/number-list.svg (../shared/mail/icons/number-list.svg)
+ skin/classic/messenger/icons/offline.svg (../shared/mail/icons/offline.svg)
+ skin/classic/messenger/icons/online.svg (../shared/mail/icons/online.svg)
+ skin/classic/messenger/icons/operator.png (../shared/mail/icons/operator.png)
+ skin/classic/messenger/icons/outbox.svg (../shared/mail/icons/outbox.svg)
+ skin/classic/messenger/icons/outdent.svg (../shared/mail/icons/outdent.svg)
+ skin/classic/messenger/icons/overflow-indicator.png (../shared/mail/icons/overflow-indicator.png)
+ skin/classic/messenger/icons/overflow.svg (../shared/mail/icons/overflow.svg)
+ skin/classic/messenger/icons/paragraph.svg (../shared/mail/icons/paragraph.svg)
+ skin/classic/messenger/icons/paste.svg (../shared/mail/icons/paste.svg)
+ skin/classic/messenger/icons/phishing.svg (../shared/mail/icons/phishing.svg)
+ skin/classic/messenger/icons/pill-indicator.svg (../shared/mail/icons/pill-indicator.svg)
+ skin/classic/messenger/icons/pluginBlocked.svg (../shared/mail/icons/pluginBlocked.svg)
+ skin/classic/messenger/icons/previousmsg.svg (../shared/mail/icons/previousmsg.svg)
+ skin/classic/messenger/icons/previousunread.svg (../shared/mail/icons/previousunread.svg)
+ skin/classic/messenger/icons/print.svg (../shared/mail/icons/print.svg)
+ skin/classic/messenger/icons/privacy-security.svg (../shared/mail/icons/privacy-security.svg)
+ skin/classic/messenger/icons/popular.svg (../shared/mail/icons/popular.svg)
+ skin/classic/messenger/icons/quit.svg (../shared/mail/icons/quit.svg)
+ skin/classic/messenger/icons/quote.svg (../shared/mail/icons/quote.svg)
+ skin/classic/messenger/icons/read.svg (../shared/mail/icons/read.svg)
+ skin/classic/messenger/icons/reader-mode.svg (../shared/mail/icons/reader-mode.svg)
+ skin/classic/messenger/icons/readcol.svg (../shared/mail/icons/readcol.svg)
+ skin/classic/messenger/icons/redirect.svg (../shared/mail/icons/redirect.svg)
+ skin/classic/messenger/icons/remote-blocked.svg (../shared/mail/icons/remote-blocked.svg)
+ skin/classic/messenger/icons/remove-text-styling.svg (../shared/mail/icons/remove-text-styling.svg)
+ skin/classic/messenger/icons/reply.svg (../shared/mail/icons/reply.svg)
+ skin/classic/messenger/icons/replyall.svg (../shared/mail/icons/replyall.svg)
+ skin/classic/messenger/icons/replylist.svg (../shared/mail/icons/replylist.svg)
+ skin/classic/messenger/icons/reply-forward.svg (../shared/mail/icons/reply-forward.svg)
+ skin/classic/messenger/icons/reply-redirect.svg (../shared/mail/icons/reply-redirect.svg)
+ skin/classic/messenger/icons/reply-forward-redirect.svg (../shared/mail/icons/reply-forward-redirect.svg)
+ skin/classic/messenger/icons/restore.svg (../shared/mail/icons/restore.svg)
+ skin/classic/messenger/icons/return-receipt.svg (../shared/mail/icons/return-receipt.svg)
+ skin/classic/messenger/icons/right-align.svg (../shared/mail/icons/right-align.svg)
+ skin/classic/messenger/icons/search-folder.svg (../shared/mail/icons/search-folder.svg)
+ skin/classic/messenger/icons/save.svg (../shared/mail/icons/save.svg)
+ skin/classic/messenger/icons/save-as.svg (../shared/mail/icons/save-as.svg)
+ skin/classic/messenger/icons/search-not-found.svg (../shared/mail/icons/search-not-found.svg)
+ skin/classic/messenger/icons/search-spinner.svg (../shared/mail/icons/search-spinner.svg)
+ skin/classic/messenger/icons/send.svg (../shared/mail/icons/send.svg)
+ skin/classic/messenger/icons/sent.svg (../shared/mail/icons/sent.svg)
+ skin/classic/messenger/icons/sidebar-left.svg (../shared/mail/icons/sidebar-left.svg)
+ skin/classic/messenger/icons/size.svg (../shared/mail/icons/size.svg)
+ skin/classic/messenger/icons/smiley.svg (../shared/mail/icons/smiley.svg)
+ skin/classic/messenger/icons/sort.svg (../shared/mail/icons/sort.svg)
+ skin/classic/messenger/icons/spaces.svg (../shared/mail/icons/spaces.svg)
+ skin/classic/messenger/icons/spelling.svg (../shared/mail/icons/spelling.svg)
+ skin/classic/messenger/icons/spring.svg (../shared/mail/icons/spring.svg)
+ skin/classic/messenger/icons/star.svg (../shared/mail/icons/star.svg)
+ skin/classic/messenger/icons/starred.svg (../shared/mail/icons/starred.svg)
+ skin/classic/messenger/icons/status-away.svg (../shared/mail/icons/status-away.svg)
+ skin/classic/messenger/icons/status-idle.svg (../shared/mail/icons/status-idle.svg)
+ skin/classic/messenger/icons/status-offline.svg (../shared/mail/icons/status-offline.svg)
+ skin/classic/messenger/icons/status-online.svg (../shared/mail/icons/status-online.svg)
+ skin/classic/messenger/icons/sticky.svg (../shared/mail/icons/sticky.svg)
+ skin/classic/messenger/icons/stop.svg (../shared/mail/icons/stop.svg)
+ skin/classic/messenger/icons/subthread-ignored.svg (../shared/mail/icons/subthread-ignored.svg)
+ skin/classic/messenger/icons/subtract-circle-fill.svg (../shared/mail/icons/subtract-circle-fill.svg)
+ skin/classic/messenger/icons/tab-drag-indicator.svg (../shared/mail/icons/tab-drag-indicator.svg)
+ skin/classic/messenger/icons/table.svg (../shared/mail/icons/table.svg)
+ skin/classic/messenger/icons/tag.svg (../shared/mail/icons/tag.svg)
+ skin/classic/messenger/icons/template.svg (../shared/mail/icons/template.svg)
+ skin/classic/messenger/icons/thread.svg (../shared/mail/icons/thread.svg)
+ skin/classic/messenger/icons/thread-col.svg (../shared/mail/icons/thread-col.svg)
+ skin/classic/messenger/icons/thread-ignored.svg (../shared/mail/icons/thread-ignored.svg)
+ skin/classic/messenger/icons/timeline.svg (../shared/mail/icons/timeline.svg)
+ skin/classic/messenger/icons/toolbarbutton-arrow.svg (../shared/mail/icons/toolbarbutton-arrow.svg)
+ skin/classic/messenger/icons/underline.svg (../shared/mail/icons/underline.svg)
+ skin/classic/messenger/icons/userIcon.svg (../shared/mail/icons/userIcon.svg)
+ skin/classic/messenger/icons/visible.svg (../shared/mail/icons/visible.svg)
+ skin/classic/messenger/icons/voice.png (../shared/mail/icons/voice.png)
+ skin/classic/messenger/icons/waiting.svg (../shared/mail/icons/waiting.svg)
+ skin/classic/messenger/icons/warning-12.svg (../shared/mail/icons/warning-12.svg)
+ skin/classic/messenger/icons/zoomout.svg (../shared/mail/icons/zoomout.svg)
+ skin/classic/messenger/preferences/dialog.css (../shared/mail/preferences/dialog.css)
+ skin/classic/messenger/shared/aboutDownloads.css (../shared/mail/aboutDownloads.css)
+ skin/classic/messenger/shared/abContactsPanel.css (../shared/mail/abContactsPanel.css)
+ skin/classic/messenger/shared/abPrint.css (../shared/mail/abPrint.css)
+ skin/classic/messenger/shared/accountCentral.css (../shared/mail/accountCentral.css)
+ skin/classic/messenger/shared/accountManage.css (../shared/mail/accountManage.css)
+ skin/classic/messenger/shared/accountManager.css (../shared/mail/accountManager.css)
+ skin/classic/messenger/shared/attachmentList.css (../shared/mail/attachmentList.css)
+ skin/classic/messenger/shared/cardDAV.css (../shared/mail/cardDAV.css)
+ skin/classic/messenger/shared/cardDialog.css (../shared/mail/cardDialog.css)
+ skin/classic/messenger/shared/chat.css (../shared/mail/chat.css)
+ skin/classic/messenger/shared/common.css (../shared/mail/common.css)
+ skin/classic/messenger/shared/compacttheme.css (../shared/mail/compacttheme.css)
+ skin/classic/messenger/shared/composerOverlay.css (../shared/mail/composerOverlay.css)
+ skin/classic/messenger/shared/contextMenu.css (../shared/mail/contextMenu.css)
+ skin/classic/messenger/shared/converterDialog.css (../shared/mail/converterDialog.css)
+ skin/classic/messenger/shared/customizeToolbar.css (../shared/mail/customizeToolbar.css)
+ skin/classic/messenger/shared/popupPanel.css (../shared/mail/popupPanel.css)
+ skin/classic/messenger/shared/EditorDialog.css (../shared/mail/EditorDialog.css)
+ skin/classic/messenger/shared/editorContent.css (../shared/mail/editorContent.css)
+ skin/classic/messenger/shared/EdInsertChars.css (../shared/mail/EdInsertChars.css)
+ skin/classic/messenger/shared/feedSubscribe.css (../shared/mail/feedSubscribe.css)
+ skin/classic/messenger/shared/fieldMapImport.css (../shared/mail/fieldMapImport.css)
+ skin/classic/messenger/shared/filterDialog.css (../shared/mail/filterDialog.css)
+ skin/classic/messenger/filterEditor.css (../shared/mail/filterEditor.css)
+ skin/classic/messenger/shared/folderMenus.css (../shared/mail/folderMenus.css)
+ skin/classic/messenger/shared/folderPane.css (../shared/mail/folderPane.css)
+ skin/classic/messenger/shared/glodacomplete.css (../shared/mail/glodacomplete.css)
+ skin/classic/messenger/shared/glodaFacetView.css (../shared/mail/glodaFacetView.css)
+ skin/classic/messenger/shared/imAccounts.css (../shared/mail/imAccounts.css)
+ skin/classic/messenger/shared/preferences/applications.css (../shared/mail/preferences/applications.css)
+ skin/classic/messenger/shared/preferences/passwordmgr.css (../shared/mail/preferences/passwordmgr.css)
+ skin/classic/messenger/shared/preferences/preferences.css (../shared/mail/preferences/preferences.css)
+ skin/classic/messenger/shared/preferences/subdialog.css (../shared/mail/preferences/subdialog.css)
+ skin/classic/messenger/shared/preferences/calendar.svg (../shared/mail/preferences/calendar.svg)
+ skin/classic/messenger/shared/preferences/chat.svg (../shared/mail/preferences/chat.svg)
+ skin/classic/messenger/shared/preferences/general.svg (../shared/mail/preferences/general.svg)
+ skin/classic/messenger/shared/preferences/privacy-security.svg (../shared/mail/preferences/privacy-security.svg)
+ skin/classic/messenger/shared/mailWindow1.css (../shared/mail/mailWindow1.css)
+ skin/classic/messenger/shared/menulist.css (../shared/mail/menulist.css)
+ skin/classic/messenger/shared/message-bar.css (../shared/mail/message-bar.css)
+ skin/classic/messenger/shared/messageBody.css (../shared/mail/messageBody.css)
+ skin/classic/messenger/shared/messageHeader.css (../shared/mail/messageHeader.css)
+ skin/classic/messenger/shared/messageIcons.css (../shared/mail/messageIcons.css)
+ skin/classic/messenger/shared/messenger.css (../shared/mail/messenger.css)
+ skin/classic/messenger/shared/messengercompose.css (../shared/mail/messengercompose.css)
+ skin/classic/messenger/shared/multimessageview.css (../shared/mail/multimessageview.css)
+ skin/classic/messenger/shared/primaryToolbar.css (../shared/mail/primaryToolbar.css)
+ skin/classic/messenger/shared/quickFilterBar.css (../shared/mail/quickFilterBar.css)
+ skin/classic/messenger/shared/activity/activity.css (../shared/mail/activity/activity.css)
+ skin/classic/messenger/shared/sanitizeDialog.css (../shared/mail/sanitizeDialog.css)
+ skin/classic/messenger/shared/search-bar.css (../shared/mail/search-bar.css)
+ skin/classic/messenger/shared/searchBox.css (../shared/mail/searchBox.css)
+ skin/classic/messenger/shared/searchDialog.css (../shared/mail/searchDialog.css)
+ skin/classic/messenger/shared/spacesToolbar.css (../shared/mail/spacesToolbar.css)
+ skin/classic/messenger/shared/tabmail.css (../shared/mail/tabmail.css)
+ skin/classic/messenger/shared/themeableDialog.css (../shared/mail/themeableDialog.css)
+ skin/classic/messenger/shared/threadPane.css (../shared/mail/threadPane.css)
+ skin/classic/messenger/shared/tree-listbox.css (../shared/mail/tree-listbox.css)
+ skin/classic/messenger/shared/panelUI.css (../shared/mail/panelUI.css)
+ skin/classic/messenger/shared/grid-layout.css (../shared/mail/grid-layout.css)
+ skin/classic/messenger/shared/input-fields.css (../shared/mail/input-fields.css)
+ skin/classic/messenger/shared/unifiedToolbar.css (../shared/mail/unifiedToolbar.css)
+ skin/classic/messenger/shared/unifiedToolbarCustomizableItems.css (../shared/mail/unifiedToolbarCustomizableItems.css)
+ skin/classic/messenger/shared/unifiedToolbarCustomizationPane.css (../shared/mail/unifiedToolbarCustomizationPane.css)
+ skin/classic/messenger/shared/unifiedToolbarShared.css (../shared/mail/unifiedToolbarShared.css)
+ skin/classic/messenger/shared/unifiedToolbarTab.css (../shared/mail/unifiedToolbarTab.css)
+ skin/classic/messenger/shared/variables.css (../shared/mail/variables.css)
+ skin/classic/messenger/messengercompose/format-dropmarker.svg (../shared/mail/icons/format-dropmarker.svg)
+ skin/classic/messenger/images/account-watermark.png (../shared/mail/images/account-watermark.png)
+ skin/classic/messenger/images/account-watermark-light.png (../shared/mail/images/account-watermark-light.png)
+ skin/classic/messenger/images/pendingpaint.png (../shared/mail/images/pendingpaint.png)
+ skin/classic/messenger/openpgp/backupKeyPassword.css (../shared/openpgp/backupKeyPassword.css)
+ skin/classic/messenger/openpgp/changeExpiryDlg.css (../shared/openpgp/changeExpiryDlg.css)
+ skin/classic/messenger/openpgp/confirmPubkeyImport.css (../shared/openpgp/confirmPubkeyImport.css)
+ skin/classic/messenger/openpgp/enigEncActiveConflict.png (../shared/openpgp/enigEncActiveConflict.png)
+ skin/classic/messenger/openpgp/enigEncActiveMinus.png (../shared/openpgp/enigEncActiveMinus.png)
+ skin/classic/messenger/openpgp/enigEncActiveNone.png (../shared/openpgp/enigEncActiveNone.png)
+ skin/classic/messenger/openpgp/enigEncActivePlus.png (../shared/openpgp/enigEncActivePlus.png)
+ skin/classic/messenger/openpgp/enigEncForceNo.png (../shared/openpgp/enigEncForceNo.png)
+ skin/classic/messenger/openpgp/enigEncForceYes.png (../shared/openpgp/enigEncForceYes.png)
+ skin/classic/messenger/openpgp/enigEncInactiveConflict.png (../shared/openpgp/enigEncInactiveConflict.png)
+ skin/classic/messenger/openpgp/enigEncInactiveMinus.png (../shared/openpgp/enigEncInactiveMinus.png)
+ skin/classic/messenger/openpgp/enigEncInactiveNone.png (../shared/openpgp/enigEncInactiveNone.png)
+ skin/classic/messenger/openpgp/enigEncInactivePlus.png (../shared/openpgp/enigEncInactivePlus.png)
+ skin/classic/messenger/openpgp/enigEncInactive.png (../shared/openpgp/enigEncInactive.png)
+ skin/classic/messenger/openpgp/enigEncNotOk.png (../shared/openpgp/enigEncNotOk.png)
+ skin/classic/messenger/openpgp/enigmail.css (../shared/openpgp/enigmail.css)
+ skin/classic/messenger/openpgp/enigmail-html.css (../shared/openpgp/enigmail-html.css)
+ skin/classic/messenger/openpgp/enigSignActiveConflict.png (../shared/openpgp/enigSignActiveConflict.png)
+ skin/classic/messenger/openpgp/enigSignActiveMinus.png (../shared/openpgp/enigSignActiveMinus.png)
+ skin/classic/messenger/openpgp/enigSignActiveNone.png (../shared/openpgp/enigSignActiveNone.png)
+ skin/classic/messenger/openpgp/enigSignActivePlus.png (../shared/openpgp/enigSignActivePlus.png)
+ skin/classic/messenger/openpgp/enigSignForceNo.png (../shared/openpgp/enigSignForceNo.png)
+ skin/classic/messenger/openpgp/enigSignForceYes.png (../shared/openpgp/enigSignForceYes.png)
+ skin/classic/messenger/openpgp/enigSignInactiveConflict.png (../shared/openpgp/enigSignInactiveConflict.png)
+ skin/classic/messenger/openpgp/enigSignInactiveMinus.png (../shared/openpgp/enigSignInactiveMinus.png)
+ skin/classic/messenger/openpgp/enigSignInactiveNone.png (../shared/openpgp/enigSignInactiveNone.png)
+ skin/classic/messenger/openpgp/enigSignInactivePlus.png (../shared/openpgp/enigSignInactivePlus.png)
+ skin/classic/messenger/openpgp/enigSignNotOk.png (../shared/openpgp/enigSignNotOk.png)
+ skin/classic/messenger/openpgp/enigSignUnkown.png (../shared/openpgp/enigSignUnkown.png)
+ skin/classic/messenger/openpgp/keyDetails.css (../shared/openpgp/keyDetails.css)
+ skin/classic/messenger/openpgp/keyWizard.css (../shared/openpgp/keyWizard.css)
+ skin/classic/messenger/openpgp/inlineNotification.css (../shared/openpgp/inlineNotification.css)
+
+ skin/classic/messenger/openpgp/openPgpComposeStatus.css (../shared/openpgp/openPgpComposeStatus.css)
+ skin/classic/messenger/smime/msgCompSecurityInfo.css (../shared/smime/msgCompSecurityInfo.css)
+
+# Illustrations
+ skin/classic/messenger/illustrations/accounts.svg (../shared/mail/illustrations/accounts.svg)
+ skin/classic/messenger/illustrations/connection-error.svg (../shared/mail/illustrations/connection-error.svg)
+ skin/classic/messenger/illustrations/form.svg (../shared/mail/illustrations/form.svg)
+ skin/classic/messenger/illustrations/octopus-setup.svg (../shared/mail/illustrations/octopus-setup.svg)
+ skin/classic/messenger/illustrations/sloth.svg (../shared/mail/illustrations/sloth.svg)
+ skin/classic/messenger/illustrations/sync-devices.svg (../shared/mail/illustrations/sync-devices.svg)
+
+# New design system
+ skin/classic/messenger/avatars.css (../shared/mail/avatars.css)
+ skin/classic/messenger/icons.css (../shared/mail/icons.css)
+ skin/classic/messenger/colors.css (../shared/mail/colors.css)
+ skin/classic/messenger/layout.css (../shared/mail/layout.css)
+ skin/classic/messenger/folderColors.css (../shared/mail/folderColors.css)
+ skin/classic/messenger/splitter.css (../shared/mail/splitter.css)
+ skin/classic/messenger/widgets.css (../shared/mail/widgets.css)
+
+ skin/classic/messenger/icons/new/compact/account-settings.svg (../shared/mail/icons/new/compact/account-settings.svg)
+ skin/classic/messenger/icons/new/compact/add.svg (../shared/mail/icons/new/compact/add.svg)
+ skin/classic/messenger/icons/new/compact/add-circle.svg (../shared/mail/icons/new/compact/add-circle.svg)
+ skin/classic/messenger/icons/new/compact/address-book.svg (../shared/mail/icons/new/compact/address-book.svg)
+ skin/classic/messenger/icons/new/compact/app-menu.svg (../shared/mail/icons/new/compact/app-menu.svg)
+ skin/classic/messenger/icons/new/compact/app-menu-badged.svg (../shared/mail/icons/new/compact/app-menu-badged.svg)
+ skin/classic/messenger/icons/new/compact/archive.svg (../shared/mail/icons/new/compact/archive.svg)
+ skin/classic/messenger/icons/new/compact/attachment.svg (../shared/mail/icons/new/compact/attachment.svg)
+ skin/classic/messenger/icons/new/compact/calendar-invite.svg (../shared/mail/icons/new/compact/calendar-invite.svg)
+ skin/classic/messenger/icons/new/compact/calendar-today.svg (../shared/mail/icons/new/compact/calendar-today.svg)
+ skin/classic/messenger/icons/new/compact/calendar.svg (../shared/mail/icons/new/compact/calendar.svg)
+ skin/classic/messenger/icons/new/compact/chat.svg (../shared/mail/icons/new/compact/chat.svg)
+ skin/classic/messenger/icons/new/compact/check.svg (../shared/mail/icons/new/compact/check.svg)
+ skin/classic/messenger/icons/new/compact/checkbox.svg (../shared/mail/icons/new/compact/checkbox.svg)
+ skin/classic/messenger/icons/new/compact/clock.svg (../shared/mail/icons/new/compact/clock.svg)
+ skin/classic/messenger/icons/new/compact/close.svg (../shared/mail/icons/new/compact/close.svg)
+ skin/classic/messenger/icons/new/compact/cloud-download.svg (../shared/mail/icons/new/compact/cloud-download.svg)
+ skin/classic/messenger/icons/new/compact/collapse.svg (../shared/mail/icons/new/compact/collapse.svg)
+ skin/classic/messenger/icons/new/compact/compress.svg (../shared/mail/icons/new/compact/compress.svg)
+ skin/classic/messenger/icons/new/compact/contact.svg (../shared/mail/icons/new/compact/contact.svg)
+ skin/classic/messenger/icons/new/compact/conversation.svg (../shared/mail/icons/new/compact/conversation.svg)
+ skin/classic/messenger/icons/new/compact/copy.svg (../shared/mail/icons/new/compact/copy.svg)
+ skin/classic/messenger/icons/new/compact/cut.svg (../shared/mail/icons/new/compact/cut.svg)
+ skin/classic/messenger/icons/new/compact/density-compact.svg (../shared/mail/icons/new/compact/density-compact.svg)
+ skin/classic/messenger/icons/new/compact/density-default.svg (../shared/mail/icons/new/compact/density-default.svg)
+ skin/classic/messenger/icons/new/compact/density-relaxed.svg (../shared/mail/icons/new/compact/density-relaxed.svg)
+ skin/classic/messenger/icons/new/compact/display-options.svg (../shared/mail/icons/new/compact/display-options.svg)
+ skin/classic/messenger/icons/new/compact/download.svg (../shared/mail/icons/new/compact/download.svg)
+ skin/classic/messenger/icons/new/compact/draft.svg (../shared/mail/icons/new/compact/draft.svg)
+ skin/classic/messenger/icons/new/compact/error-circle.svg (../shared/mail/icons/new/compact/error-circle.svg)
+ skin/classic/messenger/icons/new/compact/export.svg (../shared/mail/icons/new/compact/export.svg)
+ skin/classic/messenger/icons/new/compact/extension.svg (../shared/mail/icons/new/compact/extension.svg)
+ skin/classic/messenger/icons/new/compact/event-status.svg (../shared/mail/icons/new/compact/event-status.svg)
+ skin/classic/messenger/icons/new/compact/eye.svg (../shared/mail/icons/new/compact/eye.svg)
+ skin/classic/messenger/icons/new/compact/features.svg (../shared/mail/icons/new/compact/features.svg)
+ skin/classic/messenger/icons/new/compact/file.svg (../shared/mail/icons/new/compact/file.svg)
+ skin/classic/messenger/icons/new/compact/filter.svg (../shared/mail/icons/new/compact/filter.svg)
+ skin/classic/messenger/icons/new/compact/fingerprint.svg (../shared/mail/icons/new/compact/fingerprint.svg)
+ skin/classic/messenger/icons/new/compact/flexible-space.svg (../shared/mail/icons/new/compact/flexible-space.svg)
+ skin/classic/messenger/icons/new/compact/folder-filter.svg (../shared/mail/icons/new/compact/folder-filter.svg)
+ skin/classic/messenger/icons/new/compact/folder-rss.svg (../shared/mail/icons/new/compact/folder-rss.svg)
+ skin/classic/messenger/icons/new/compact/folder-save.svg (../shared/mail/icons/new/compact/folder-save.svg)
+ skin/classic/messenger/icons/new/compact/folder.svg (../shared/mail/icons/new/compact/folder.svg)
+ skin/classic/messenger/icons/new/compact/font.svg (../shared/mail/icons/new/compact/font.svg)
+ skin/classic/messenger/icons/new/compact/forward.svg (../shared/mail/icons/new/compact/forward.svg)
+ skin/classic/messenger/icons/new/compact/get-mail.svg (../shared/mail/icons/new/compact/get-mail.svg)
+ skin/classic/messenger/icons/new/compact/globe-secure.svg (../shared/mail/icons/new/compact/globe-secure.svg)
+ skin/classic/messenger/icons/new/compact/globe.svg (../shared/mail/icons/new/compact/globe.svg)
+ skin/classic/messenger/icons/new/compact/handshake.svg (../shared/mail/icons/new/compact/handshake.svg)
+ skin/classic/messenger/icons/new/compact/heart.svg (../shared/mail/icons/new/compact/heart.svg)
+ skin/classic/messenger/icons/new/compact/hidden.svg (../shared/mail/icons/new/compact/hidden.svg)
+ skin/classic/messenger/icons/new/compact/id.svg (../shared/mail/icons/new/compact/id.svg)
+ skin/classic/messenger/icons/new/compact/import.svg (../shared/mail/icons/new/compact/import.svg)
+ skin/classic/messenger/icons/new/compact/inbox.svg (../shared/mail/icons/new/compact/inbox.svg)
+ skin/classic/messenger/icons/new/compact/info.svg (../shared/mail/icons/new/compact/info.svg)
+ skin/classic/messenger/icons/new/compact/kebab.svg (../shared/mail/icons/new/compact/kebab.svg)
+ skin/classic/messenger/icons/new/compact/key.svg (../shared/mail/icons/new/compact/key.svg)
+ skin/classic/messenger/icons/new/compact/layout.svg (../shared/mail/icons/new/compact/layout.svg)
+ skin/classic/messenger/icons/new/compact/link.svg (../shared/mail/icons/new/compact/link.svg)
+ skin/classic/messenger/icons/new/compact/lock.svg (../shared/mail/icons/new/compact/lock.svg)
+ skin/classic/messenger/icons/new/compact/lock-disabled.svg (../shared/mail/icons/new/compact/lock-disabled.svg)
+ skin/classic/messenger/icons/new/compact/mail-secure.svg (../shared/mail/icons/new/compact/mail-secure.svg)
+ skin/classic/messenger/icons/new/compact/mail.svg (../shared/mail/icons/new/compact/mail.svg)
+ skin/classic/messenger/icons/new/compact/more.svg (../shared/mail/icons/new/compact/more.svg)
+ skin/classic/messenger/icons/new/compact/nav-back.svg (../shared/mail/icons/new/compact/nav-back.svg)
+ skin/classic/messenger/icons/new/compact/nav-down-unread.svg (../shared/mail/icons/new/compact/nav-down-unread.svg)
+ skin/classic/messenger/icons/new/compact/nav-down.svg (../shared/mail/icons/new/compact/nav-down.svg)
+ skin/classic/messenger/icons/new/compact/nav-forward.svg (../shared/mail/icons/new/compact/nav-forward.svg)
+ skin/classic/messenger/icons/new/compact/nav-left.svg (../shared/mail/icons/new/compact/nav-left.svg)
+ skin/classic/messenger/icons/new/compact/nav-right.svg (../shared/mail/icons/new/compact/nav-right.svg)
+ skin/classic/messenger/icons/new/compact/nav-up-unread.svg (../shared/mail/icons/new/compact/nav-up-unread.svg)
+ skin/classic/messenger/icons/new/compact/nav-up.svg (../shared/mail/icons/new/compact/nav-up.svg)
+ skin/classic/messenger/icons/new/compact/new-address-book.svg (../shared/mail/icons/new/compact/new-address-book.svg)
+ skin/classic/messenger/icons/new/compact/new-chat.svg (../shared/mail/icons/new/compact/new-chat.svg)
+ skin/classic/messenger/icons/new/compact/new-contact.svg (../shared/mail/icons/new/compact/new-contact.svg)
+ skin/classic/messenger/icons/new/compact/new-event.svg (../shared/mail/icons/new/compact/new-event.svg)
+ skin/classic/messenger/icons/new/compact/new-key.svg (../shared/mail/icons/new/compact/new-key.svg)
+ skin/classic/messenger/icons/new/compact/new-mail.svg (../shared/mail/icons/new/compact/new-mail.svg)
+ skin/classic/messenger/icons/new/compact/new-task.svg (../shared/mail/icons/new/compact/new-task.svg)
+ skin/classic/messenger/icons/new/compact/new-user-list.svg (../shared/mail/icons/new/compact/new-user-list.svg)
+ skin/classic/messenger/icons/new/compact/newsletter.svg (../shared/mail/icons/new/compact/newsletter.svg)
+ skin/classic/messenger/icons/new/compact/offline.svg (../shared/mail/icons/new/compact/offline.svg)
+ skin/classic/messenger/icons/new/compact/online.svg (../shared/mail/icons/new/compact/online.svg)
+ skin/classic/messenger/icons/new/compact/outbox.svg (../shared/mail/icons/new/compact/outbox.svg)
+ skin/classic/messenger/icons/new/compact/overflow.svg (../shared/mail/icons/new/compact/overflow.svg)
+ skin/classic/messenger/icons/new/compact/paint-brush.svg (../shared/mail/icons/new/compact/paint-brush.svg)
+ skin/classic/messenger/icons/new/compact/paste.svg (../shared/mail/icons/new/compact/paste.svg)
+ skin/classic/messenger/icons/new/compact/pencil.svg (../shared/mail/icons/new/compact/pencil.svg)
+ skin/classic/messenger/icons/new/compact/photo-ban.svg (../shared/mail/icons/new/compact/photo-ban.svg)
+ skin/classic/messenger/icons/new/compact/pin.svg (../shared/mail/icons/new/compact/pin.svg)
+ skin/classic/messenger/icons/new/compact/print.svg (../shared/mail/icons/new/compact/print.svg)
+ skin/classic/messenger/icons/new/compact/priority.svg (../shared/mail/icons/new/compact/priority.svg)
+ skin/classic/messenger/icons/new/compact/low-priority.svg (../shared/mail/icons/new/compact/low-priority.svg)
+ skin/classic/messenger/icons/new/compact/question.svg (../shared/mail/icons/new/compact/question.svg)
+ skin/classic/messenger/icons/new/compact/quit.svg (../shared/mail/icons/new/compact/quit.svg)
+ skin/classic/messenger/icons/new/compact/quote.svg (../shared/mail/icons/new/compact/quote.svg)
+ skin/classic/messenger/icons/new/compact/receipt.svg (../shared/mail/icons/new/compact/receipt.svg)
+ skin/classic/messenger/icons/new/compact/redirect.svg (../shared/mail/icons/new/compact/redirect.svg)
+ skin/classic/messenger/icons/new/compact/remove.svg (../shared/mail/icons/new/compact/remove.svg)
+ skin/classic/messenger/icons/new/compact/reply-all.svg (../shared/mail/icons/new/compact/reply-all.svg)
+ skin/classic/messenger/icons/new/compact/reply-list.svg (../shared/mail/icons/new/compact/reply-list.svg)
+ skin/classic/messenger/icons/new/compact/reply.svg (../shared/mail/icons/new/compact/reply.svg)
+ skin/classic/messenger/icons/new/compact/restore.svg (../shared/mail/icons/new/compact/restore.svg)
+ skin/classic/messenger/icons/new/compact/ribbon.svg (../shared/mail/icons/new/compact/ribbon.svg)
+ skin/classic/messenger/icons/new/compact/rss.svg (../shared/mail/icons/new/compact/rss.svg)
+ skin/classic/messenger/icons/new/compact/search.svg (../shared/mail/icons/new/compact/search.svg)
+ skin/classic/messenger/icons/new/compact/sent.svg (../shared/mail/icons/new/compact/sent.svg)
+ skin/classic/messenger/icons/new/compact/settings.svg (../shared/mail/icons/new/compact/settings.svg)
+ skin/classic/messenger/icons/new/compact/shield.svg (../shared/mail/icons/new/compact/shield.svg)
+ skin/classic/messenger/icons/new/compact/shortcut.svg (../shared/mail/icons/new/compact/shortcut.svg)
+ skin/classic/messenger/icons/new/compact/sort.svg (../shared/mail/icons/new/compact/sort.svg)
+ skin/classic/messenger/icons/new/compact/spaces-menu.svg (../shared/mail/icons/new/compact/spaces-menu.svg)
+ skin/classic/messenger/icons/new/compact/spam.svg (../shared/mail/icons/new/compact/spam.svg)
+ skin/classic/messenger/icons/new/compact/spelling.svg (../shared/mail/icons/new/compact/spelling.svg)
+ skin/classic/messenger/icons/new/compact/star.svg (../shared/mail/icons/new/compact/star.svg)
+ skin/classic/messenger/icons/new/compact/subtract-circle.svg (../shared/mail/icons/new/compact/subtract-circle.svg)
+ skin/classic/messenger/icons/new/compact/sync.svg (../shared/mail/icons/new/compact/sync.svg)
+ skin/classic/messenger/icons/new/compact/tag.svg (../shared/mail/icons/new/compact/tag.svg)
+ skin/classic/messenger/icons/new/compact/tasks.svg (../shared/mail/icons/new/compact/tasks.svg)
+ skin/classic/messenger/icons/new/compact/template.svg (../shared/mail/icons/new/compact/template.svg)
+ skin/classic/messenger/icons/new/compact/tentative.svg (../shared/mail/icons/new/compact/tentative.svg)
+ skin/classic/messenger/icons/new/compact/thread.svg (../shared/mail/icons/new/compact/thread.svg)
+ skin/classic/messenger/icons/new/compact/thread-ignored.svg (../shared/mail/icons/new/compact/thread-ignored.svg)
+ skin/classic/messenger/icons/new/compact/subthread-ignored.svg (../shared/mail/icons/new/compact/subthread-ignored.svg)
+ skin/classic/messenger/icons/new/compact/tools.svg (../shared/mail/icons/new/compact/tools.svg)
+ skin/classic/messenger/icons/new/compact/trash.svg (../shared/mail/icons/new/compact/trash.svg)
+ skin/classic/messenger/icons/new/compact/unread.svg (../shared/mail/icons/new/compact/unread.svg)
+ skin/classic/messenger/icons/new/compact/user.svg (../shared/mail/icons/new/compact/user.svg)
+ skin/classic/messenger/icons/new/compact/user-list.svg (../shared/mail/icons/new/compact/user-list.svg)
+ skin/classic/messenger/icons/new/compact/user-list-alt.svg (../shared/mail/icons/new/compact/user-list-alt.svg)
+ skin/classic/messenger/icons/new/compact/warning.svg (../shared/mail/icons/new/compact/warning.svg)
+
+ skin/classic/messenger/icons/new/normal/add.svg (../shared/mail/icons/new/normal/add.svg)
+ skin/classic/messenger/icons/new/normal/address-book.svg (../shared/mail/icons/new/normal/address-book.svg)
+ skin/classic/messenger/icons/new/normal/add-circle.svg (../shared/mail/icons/new/normal/add-circle.svg)
+ skin/classic/messenger/icons/new/normal/archive.svg (../shared/mail/icons/new/normal/archive.svg)
+ skin/classic/messenger/icons/new/normal/calendar.svg (../shared/mail/icons/new/normal/calendar.svg)
+ skin/classic/messenger/icons/new/normal/calendar-invite.svg (../shared/mail/icons/new/normal/calendar-invite.svg)
+ skin/classic/messenger/icons/new/normal/chat.svg (../shared/mail/icons/new/normal/chat.svg)
+ skin/classic/messenger/icons/new/normal/cloud-download.svg (../shared/mail/icons/new/normal/cloud-download.svg)
+ skin/classic/messenger/icons/new/normal/collapse.svg (../shared/mail/icons/new/normal/collapse.svg)
+ skin/classic/messenger/icons/new/normal/download.svg (../shared/mail/icons/new/normal/download.svg)
+ skin/classic/messenger/icons/new/normal/draft.svg (../shared/mail/icons/new/normal/draft.svg)
+ skin/classic/messenger/icons/new/normal/folder-filter.svg (../shared/mail/icons/new/normal/folder-filter.svg)
+ skin/classic/messenger/icons/new/normal/folder-rss.svg (../shared/mail/icons/new/normal/folder-rss.svg)
+ skin/classic/messenger/icons/new/normal/folder.svg (../shared/mail/icons/new/normal/folder.svg)
+ skin/classic/messenger/icons/new/normal/globe-secure.svg (../shared/mail/icons/new/normal/globe-secure.svg)
+ skin/classic/messenger/icons/new/normal/globe.svg (../shared/mail/icons/new/normal/globe.svg)
+ skin/classic/messenger/icons/new/normal/inbox.svg (../shared/mail/icons/new/normal/inbox.svg)
+ skin/classic/messenger/icons/new/normal/link.svg (../shared/mail/icons/new/normal/link.svg)
+ skin/classic/messenger/icons/new/normal/mail-secure.svg (../shared/mail/icons/new/normal/mail-secure.svg)
+ skin/classic/messenger/icons/new/normal/mail.svg (../shared/mail/icons/new/normal/mail.svg)
+ skin/classic/messenger/icons/new/normal/more.svg (../shared/mail/icons/new/normal/more.svg)
+ skin/classic/messenger/icons/new/normal/newsletter.svg (../shared/mail/icons/new/normal/newsletter.svg)
+ skin/classic/messenger/icons/new/normal/outbox.svg (../shared/mail/icons/new/normal/outbox.svg)
+ skin/classic/messenger/icons/new/normal/overflow.svg (../shared/mail/icons/new/normal/overflow.svg)
+ skin/classic/messenger/icons/new/normal/rss.svg (../shared/mail/icons/new/normal/rss.svg)
+ skin/classic/messenger/icons/new/normal/sent.svg (../shared/mail/icons/new/normal/sent.svg)
+ skin/classic/messenger/icons/new/normal/settings.svg (../shared/mail/icons/new/normal/settings.svg)
+ skin/classic/messenger/icons/new/normal/spam.svg (../shared/mail/icons/new/normal/spam.svg)
+ skin/classic/messenger/icons/new/normal/subtract-circle.svg (../shared/mail/icons/new/normal/subtract-circle.svg)
+ skin/classic/messenger/icons/new/normal/tasks.svg (../shared/mail/icons/new/normal/tasks.svg)
+ skin/classic/messenger/icons/new/normal/template.svg (../shared/mail/icons/new/normal/template.svg)
+ skin/classic/messenger/icons/new/normal/trash.svg (../shared/mail/icons/new/normal/trash.svg)
+
+ skin/classic/messenger/icons/new/touch/add-circle.svg (../shared/mail/icons/new/touch/add-circle.svg)
+ skin/classic/messenger/icons/new/touch/address-book.svg (../shared/mail/icons/new/touch/address-book.svg)
+ skin/classic/messenger/icons/new/touch/calendar.svg (../shared/mail/icons/new/touch/calendar.svg)
+ skin/classic/messenger/icons/new/touch/chat.svg (../shared/mail/icons/new/touch/chat.svg)
+ skin/classic/messenger/icons/new/touch/collapse.svg (../shared/mail/icons/new/touch/collapse.svg)
+ skin/classic/messenger/icons/new/touch/dictionary.svg (../shared/mail/icons/new/touch/dictionary.svg)
+ skin/classic/messenger/icons/new/touch/export.svg (../shared/mail/icons/new/touch/export.svg)
+ skin/classic/messenger/icons/new/touch/extension-update-available.svg (../shared/mail/icons/new/touch/extension-update-available.svg)
+ skin/classic/messenger/icons/new/touch/extension-update-recent.svg (../shared/mail/icons/new/touch/extension-update-recent.svg)
+ skin/classic/messenger/icons/new/touch/extension.svg (../shared/mail/icons/new/touch/extension.svg)
+ skin/classic/messenger/icons/new/touch/features.svg (../shared/mail/icons/new/touch/features.svg)
+ skin/classic/messenger/icons/new/touch/globe.svg (../shared/mail/icons/new/touch/globe.svg)
+ skin/classic/messenger/icons/new/touch/import.svg (../shared/mail/icons/new/touch/import.svg)
+ skin/classic/messenger/icons/new/touch/language.svg (../shared/mail/icons/new/touch/language.svg)
+ skin/classic/messenger/icons/new/touch/lock.svg (../shared/mail/icons/new/touch/lock.svg)
+ skin/classic/messenger/icons/new/touch/mail.svg (../shared/mail/icons/new/touch/mail.svg)
+ skin/classic/messenger/icons/new/touch/overflow.svg (../shared/mail/icons/new/touch/overflow.svg)
+ skin/classic/messenger/icons/new/touch/paint-brush.svg (../shared/mail/icons/new/touch/paint-brush.svg)
+ skin/classic/messenger/icons/new/touch/pencil.svg (../shared/mail/icons/new/touch/pencil.svg)
+ skin/classic/messenger/icons/new/touch/settings.svg (../shared/mail/icons/new/touch/settings.svg)
+ skin/classic/messenger/icons/new/touch/subtract-circle.svg (../shared/mail/icons/new/touch/subtract-circle.svg)
+ skin/classic/messenger/icons/new/touch/sync.svg (../shared/mail/icons/new/touch/sync.svg)
+ skin/classic/messenger/icons/new/touch/tasks.svg (../shared/mail/icons/new/touch/tasks.svg)
+
+ skin/classic/messenger/icons/new/address-book-indicator.svg (../shared/mail/icons/new/address-book-indicator.svg)
+ skin/classic/messenger/icons/new/bell.svg (../shared/mail/icons/new/bell.svg)
+ skin/classic/messenger/icons/new/bell-disabled.svg (../shared/mail/icons/new/bell-disabled.svg)
+ skin/classic/messenger/icons/new/bell-ring.svg (../shared/mail/icons/new/bell-ring.svg)
+ skin/classic/messenger/icons/new/loading.svg (../shared/mail/icons/new/loading.svg)
+ skin/classic/messenger/icons/new/calendar-empty.svg (../shared/mail/icons/new/calendar-empty.svg)
+ skin/classic/messenger/icons/new/circle-sm.svg (../shared/mail/icons/new/circle-sm.svg)
+ skin/classic/messenger/icons/new/mail-sm.svg (../shared/mail/icons/new/mail-sm.svg)
+ skin/classic/messenger/icons/new/nav-down-sm.svg (../shared/mail/icons/new/nav-down-sm.svg)
+ skin/classic/messenger/icons/new/nav-left-sm.svg (../shared/mail/icons/new/nav-left-sm.svg)
+ skin/classic/messenger/icons/new/nav-right-sm.svg (../shared/mail/icons/new/nav-right-sm.svg)
+ skin/classic/messenger/icons/new/nav-up-sm.svg (../shared/mail/icons/new/nav-up-sm.svg)
+ skin/classic/messenger/icons/new/nav-today.svg (../shared/mail/icons/new/nav-today.svg)
+ skin/classic/messenger/icons/new/attachment-sm.svg (../shared/mail/icons/new/attachment-sm.svg)
+ skin/classic/messenger/icons/new/recurrence.svg (../shared/mail/icons/new/recurrence.svg)
+ skin/classic/messenger/icons/new/recurrence-exception.svg (../shared/mail/icons/new/recurrence-exception.svg)
+ skin/classic/messenger/icons/new/spam-sm.svg (../shared/mail/icons/new/spam-sm.svg)
+ skin/classic/messenger/icons/new/star-sm.svg (../shared/mail/icons/new/star-sm.svg)
+ skin/classic/messenger/icons/new/tag-sm.svg (../shared/mail/icons/new/tag-sm.svg)
+ skin/classic/messenger/icons/new/thread-sm.svg (../shared/mail/icons/new/thread-sm.svg)
+ skin/classic/messenger/icons/new/trash-sm.svg (../shared/mail/icons/new/trash-sm.svg)
+ skin/classic/messenger/icons/new/unread-sm.svg (../shared/mail/icons/new/unread-sm.svg)
+ skin/classic/messenger/icons/new/unread-dot.svg (../shared/mail/icons/new/unread-dot.svg)
+ skin/classic/messenger/icons/new/column-menu.svg (../shared/mail/icons/new/column-menu.svg)
+ skin/classic/messenger/icons/new/notify.svg (../shared/mail/icons/new/notify.svg)
+ skin/classic/messenger/icons/new/event-start.svg (../shared/mail/icons/new/event-start.svg)
+ skin/classic/messenger/icons/new/event-end.svg (../shared/mail/icons/new/event-end.svg)
+ skin/classic/messenger/icons/new/event-continue.svg (../shared/mail/icons/new/event-continue.svg)
+ skin/classic/messenger/icons/new/subtract-circle-sm.svg (../shared/mail/icons/new/subtract-circle-sm.svg)
+
+ skin/classic/messenger/icons/new/compact/forward-col.svg (../shared/mail/icons/new/compact/forward-col.svg)
+ skin/classic/messenger/icons/new/compact/reply-col.svg (../shared/mail/icons/new/compact/reply-col.svg)
+ skin/classic/messenger/icons/new/compact/redirect-col.svg (../shared/mail/icons/new/compact/redirect-col.svg)
+ skin/classic/messenger/icons/new/compact/forward-redirect-col.svg (../shared/mail/icons/new/compact/forward-redirect-col.svg)
+ skin/classic/messenger/icons/new/compact/reply-forward-col.svg (../shared/mail/icons/new/compact/reply-forward-col.svg)
+ skin/classic/messenger/icons/new/compact/reply-forward-redirect-col.svg (../shared/mail/icons/new/compact/reply-forward-redirect-col.svg)
+ skin/classic/messenger/icons/new/compact/reply-redirect-col.svg (../shared/mail/icons/new/compact/reply-redirect-col.svg)
+
+ skin/classic/messenger/icons/new/status-away.svg (../shared/mail/icons/new/status-away.svg)
+ skin/classic/messenger/icons/new/status-away-sm.svg (../shared/mail/icons/new/status-away-sm.svg)
+ skin/classic/messenger/icons/new/status-idle.svg (../shared/mail/icons/new/status-idle.svg)
+ skin/classic/messenger/icons/new/status-idle-sm.svg (../shared/mail/icons/new/status-idle-sm.svg)
+ skin/classic/messenger/icons/new/status-offline.svg (../shared/mail/icons/new/status-offline.svg)
+ skin/classic/messenger/icons/new/status-offline-sm.svg (../shared/mail/icons/new/status-offline-sm.svg)
+ skin/classic/messenger/icons/new/status-online.svg (../shared/mail/icons/new/status-online.svg)
+ skin/classic/messenger/icons/new/status-online-sm.svg (../shared/mail/icons/new/status-online-sm.svg)
+
+ skin/classic/messenger/icons/new/chat-lock.svg (../shared/mail/icons/new/chat-lock.svg)
+ skin/classic/messenger/icons/new/chat-lock-finished.svg (../shared/mail/icons/new/chat-lock-finished.svg)
+ skin/classic/messenger/icons/new/chat-lock-insecure.svg (../shared/mail/icons/new/chat-lock-insecure.svg)
+ skin/classic/messenger/icons/new/chat-lock-private.svg (../shared/mail/icons/new/chat-lock-private.svg)
+ skin/classic/messenger/icons/new/chat-lock-unverified.svg (../shared/mail/icons/new/chat-lock-unverified.svg)
+
+ skin/classic/messenger/icons/new/activity/addItemIcon.svg (../shared/mail/icons/new/activity/addItemIcon.svg)
+ skin/classic/messenger/icons/new/activity/compactMailIcon.svg (../shared/mail/icons/new/activity/compactMailIcon.svg)
+ skin/classic/messenger/icons/new/activity/copyMailIcon.svg (../shared/mail/icons/new/activity/copyMailIcon.svg)
+ skin/classic/messenger/icons/new/activity/defaultEventIcon.svg (../shared/mail/icons/new/activity/defaultEventIcon.svg)
+ skin/classic/messenger/icons/new/activity/defaultProcessIcon.svg (../shared/mail/icons/new/activity/defaultProcessIcon.svg)
+ skin/classic/messenger/icons/new/activity/deleteMailIcon.svg (../shared/mail/icons/new/activity/deleteMailIcon.svg)
+ skin/classic/messenger/icons/new/activity/indexMailIcon.svg (../shared/mail/icons/new/activity/indexMailIcon.svg)
+ skin/classic/messenger/icons/new/activity/moveMailIcon.svg (../shared/mail/icons/new/activity/moveMailIcon.svg)
+ skin/classic/messenger/icons/new/activity/removeItemIcon.svg (../shared/mail/icons/new/activity/removeItemIcon.svg)
+ skin/classic/messenger/icons/new/activity/sendMailIcon.svg (../shared/mail/icons/new/activity/sendMailIcon.svg)
+ skin/classic/messenger/icons/new/activity/syncMailIcon.svg (../shared/mail/icons/new/activity/syncMailIcon.svg)
+ skin/classic/messenger/icons/new/activity/undoIcon.svg (../shared/mail/icons/new/activity/undoIcon.svg)
+ skin/classic/messenger/icons/new/activity/warning.svg (../shared/mail/icons/new/activity/warning.svg)
+ skin/classic/messenger/icons/new/activity/question.svg (../shared/mail/icons/new/activity/question.svg)
+ skin/classic/messenger/icons/new/activity/info.svg (../shared/mail/icons/new/activity/info.svg)
+ skin/classic/messenger/icons/new/activity/error.svg (../shared/mail/icons/new/activity/error.svg)
+
+ skin/classic/messenger/icons/new/supernova-logo.webp (../shared/mail/icons/new/supernova-logo.webp)
diff --git a/comm/mail/themes/shared/mail/EdInsertChars.css b/comm/mail/themes/shared/mail/EdInsertChars.css
new file mode 100644
index 0000000000..1fb5430642
--- /dev/null
+++ b/comm/mail/themes/shared/mail/EdInsertChars.css
@@ -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/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+/* Keep the reading order in the document for accessibility, but re-order for
+ * visual display. */
+#LatinL_Label {
+ grid-row: 1;
+ grid-column: 1;
+}
+
+#LatinM_Label {
+ grid-row: 1;
+ grid-column: 2;
+}
+
+#LatinL {
+ grid-row: 2;
+ grid-column: 1;
+}
+
+#LatinM {
+ grid-row: 2;
+ grid-column: 2;
+}
diff --git a/comm/mail/themes/shared/mail/EditorDialog.css b/comm/mail/themes/shared/mail/EditorDialog.css
new file mode 100644
index 0000000000..4bd7b4b86f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/EditorDialog.css
@@ -0,0 +1,347 @@
+/* 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 html url("http://www.w3.org/1999/xhtml");
+
+:root:-moz-lwtheme {
+ background-color: var(--lwt-accent-color);
+ color: var(--lwt-text-color);
+}
+
+#imageDlg {
+ overflow: auto;
+ max-width: inherit;
+}
+
+.MinWidth5em {
+ min-width: 5em;
+}
+
+.MinWidth10em {
+ min-width: 10em;
+}
+
+.MinWidth15em {
+ min-width: 15em;
+}
+
+.MinWidth20em {
+ min-width: 20em;
+}
+
+.bold {
+ font-weight: bold;
+}
+
+.italic {
+ font-style: italic;
+}
+
+.larger {
+ font-size: 120%;
+}
+
+*|*.narrow {
+ width: 4em;
+}
+
+.menulist-narrow {
+ width: 10em;
+}
+
+.wrap {
+ width: 1em;
+}
+
+.menuitem-highlight-1 {
+ font-weight : bold;
+}
+
+.color-well {
+ width: 30px;
+ height: 12px;
+ border: 1px inset #CCCCCC;
+ margin: auto;
+}
+
+.color-well[default="true"] {
+ border: 1px solid transparent;
+ background-color: inherit;
+}
+
+.color-well + .button-box {
+ display: none;
+}
+
+.color-button {
+ min-width: 0;
+ margin: 2px;
+}
+
+button.color-button .button-text {
+ margin-inline: 0;
+}
+
+#spacingLabel,
+#alignLabel,
+#imagemapLabel {
+ margin-top: 0;
+}
+
+#ColorPickerSwatch {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ margin-inline: -21px 10px;
+ border: 1px solid var(--field-border-color);
+}
+
+#ColorInput {
+ padding-inline-end: 21px;
+}
+
+#LastPickedButton {
+ align-items: center;
+}
+
+#LastPickedColor {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ margin-inline-start: 2px;
+ border: 1px solid var(--field-border-color);
+ order: 2;
+}
+
+/* temporary -- we need a simple box-based list defined in XBL */
+tree.list {
+ border: 1px inset #ccc;
+ /* same as in menulist.css */
+ margin: 1px 5px 2px;
+ /* use rows="#" in XUL to define height */
+}
+
+#ColorPreview {
+ border: 1px inset #ccc;
+ margin-inline-start: 10px;
+ padding: 0 5px;
+ min-width: 100px;
+ min-height: 50px;
+}
+
+#alignTypeSelect {
+ margin-inline-start: 5px;
+}
+
+/* ::::: table properties dialog ::::: */
+
+#NextButton,
+#PreviousButton {
+ list-style-image: url("chrome://global/skin/icons/arrow-left-12.svg");
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+#PreviousButton[type="row"] {
+ list-style-image: url("chrome://global/skin/icons/arrow-up-12.svg");
+}
+
+#NextButton[type="row"] {
+ list-style-image: url("chrome://global/skin/icons/arrow-down-12.svg");
+}
+
+#NextButton > .button-box > .button-icon,
+#PreviousButton > .button-box > .button-icon {
+ margin-inline-end: 2px;
+}
+
+#NextButton:not([type="row"]) > .button-box > .button-icon {
+ transform: scaleX(-1);
+}
+
+/* ::::: spelling dialog ::::: */
+
+#MisspelledWord {
+ font-weight: bold;
+ max-width: 16em;
+ text-overflow: ellipsis;
+}
+
+#ReplaceWordInput {
+ min-width: 16em;
+ width: 16em;
+}
+
+.spell-check {
+ min-width: 8em;
+}
+
+/* ::::: color picker ::::: */
+
+/* use outset shape for a button look */
+.colorpicker {
+ border: 1px outset #CCCCCC;
+ /* This should be the same as for textbox */
+ margin-inline-start: 4px;
+ /* For a little extra space between buttons */
+ margin-bottom: 2px;
+}
+
+.colorpicker:active {
+ border: 1px inset #CCCCCC;
+}
+
+.smallspacer {
+ width: 3px;
+ height: 3px;
+ min-width: 3px;
+ min-height: 3px;
+}
+
+.spacer {
+ width: 5px;
+ height: 5px;
+ min-width: 5px;
+ min-height: 5px;
+}
+
+.bigspacer {
+ width: 10px;
+ height: 10px;
+ min-width: 10px;
+ min-height: 10px;
+}
+
+/* These should be the width of the checkbox and radio button images + margin + padding
+ Used to indent below those to the level of the text label next to image
+*/
+.checkbox-spacer {
+ width: 2em;
+ min-width: 2em;
+}
+
+.radio-spacer {
+ width: 2em;
+ min-width: 2em;
+}
+
+.align-menu[value="top"] {
+ list-style-image:url("chrome://editor/skin/icons/img-align-top.gif");
+}
+
+.align-menu[value="middle"] {
+ list-style-image:url("chrome://editor/skin/icons/img-align-middle.gif");
+}
+
+.align-menu[value="bottom"] {
+ list-style-image:url("chrome://editor/skin/icons/img-align-bottom.gif");
+}
+
+.align-menu[value="right"] {
+ list-style-image:url("chrome://editor/skin/icons/img-align-right.gif");
+}
+
+.align-menu[value="left"] {
+ list-style-image:url("chrome://editor/skin/icons/img-align-left.gif");
+}
+
+/* Don't change width/height of these without changing values in
+ GetOriginalWidth(), EdImageProps.js
+*/
+#preview-image-box {
+ border: 1px inset #CCCCCC;
+ width: 82px;
+ max-width: 82px;
+ min-width: 82px;
+ height: 52px;
+ max-height: 52px;
+ min-height: 52px;
+ margin: 6px 5px;
+ overflow: clip;
+}
+
+#preview-image-holder {
+ padding: 0;
+ margin: 0;
+}
+
+#tagLabel {
+ font-weight: bold;
+}
+
+.AttributesTree {
+ min-width: 200px;
+ min-height: 200px;
+}
+
+#AddJSEAttributeNameList > menupopup > menuseparator,
+#AddHTMLAttributeNameInput > menupopup > menuseparator {
+ appearance: none;
+ margin-block: 3px;
+ padding-block: 0;
+ border-top-color: #d7d7d7;
+}
+
+html|fieldset {
+ margin: 1em 3px 3px 3px;
+ padding: 3px 0 6px;
+ border: none;
+}
+html|legend {
+ font-weight: bold;
+ margin-top: -1em;
+ margin-inline-start: 3px;
+ padding-inline: 3px;
+}
+
+html|fieldset > hbox,
+html|fieldset > vbox,
+html|fieldset > radiogroup,
+html|fieldset > menulist {
+ width: -moz-available;
+}
+
+html|table html|th {
+ font-weight: normal;
+ text-align: start;
+}
+
+*|*.display-flex {
+ display: flex;
+}
+
+*|*.flex-1 {
+ flex: 1;
+}
+
+#SuggestedList {
+ flex-direction: column;
+ max-height: 10em;
+}
+
+#dictionary-list {
+ list-style-type: none;
+ padding: 6px 3px;
+ margin: 0;
+ max-height: 5em;
+ overflow-y: auto;
+}
+
+html|input[type="checkbox"] {
+ -moz-appearance: checkbox;
+}
+
+/* Advanced Edit dialog */
+
+#HTMLAttrCol,
+#CSSPropCol,
+#AttrCol {
+ flex: 35 35;
+}
+
+#HTMLValCol,
+#CSSValCol,
+#HeaderCol {
+ flex: 65 65;
+}
diff --git a/comm/mail/themes/shared/mail/abContactsPanel.css b/comm/mail/themes/shared/mail/abContactsPanel.css
new file mode 100644
index 0000000000..269ee3d110
--- /dev/null
+++ b/comm/mail/themes/shared/mail/abContactsPanel.css
@@ -0,0 +1,93 @@
+/* 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/. */
+
+/* ===== sidebarPanel.css ===============================================
+ == Styles for the Address Book sidebar panel.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+
+#abContactsPanel:not([lwt-tree]) {
+ --button-background: rgba(128, 128, 128, .15);
+ --button-background-hover: rgba(128, 128, 128, .25);
+ --button-background-active: rgba(128, 128, 128, .35);
+ --box-text-color: MenuText;
+ --box-background-color: Menu;
+ --box-border-color: ThreeDShadow;
+ --field-border-color: rgba(128, 128, 128, .6);
+}
+
+#abContactsPanel {
+ appearance: none;
+ background-color: -moz-Dialog;
+ background-image: none !important;
+ color: -moz-dialogText;
+ text-shadow: none;
+}
+
+#abContactsPanel[lwt-tree] {
+ background-color: var(--toolbar-bgcolor);
+ color: var(--lwt-text-color);
+}
+
+#abContextMenuButton {
+ min-width: 11px;
+ list-style-image: url("chrome://messenger/skin/addressbook/icons/menu.svg");
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#abContextMenuButton:not(:hover) {
+ background: transparent;
+ border-color: transparent;
+}
+
+#abContextMenuButton:hover:focus-visible {
+ outline-style: none;
+}
+
+#abContextMenuButton > .toolbarbutton-icon {
+ width: 11px;
+ height: 11px;
+}
+
+#abContextMenuButton > .toolbarbutton-text {
+ display: none;
+}
+
+#sidebarAbContextMenu {
+ /* Compensate the arrow-scrollbox padding. */
+ margin: -4px;
+}
+
+#addressbookList:not(:-moz-focusring) {
+ border: 1px solid var(--field-border-color);
+}
+
+#addressbookList:not(:hover,[open="true"]) {
+ background: transparent;
+}
+
+#abResultsTree {
+ border-inline-end: none !important;
+}
+
+/* Hide the twisty gap. */
+treechildren::-moz-tree-twisty {
+ width: 0;
+ padding-inline: 2px;
+}
+
+treechildren::-moz-tree-image(GeneratedName) {
+ margin-inline-end: 2px;
+ list-style-image: var(--icon-contact);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ opacity: 0.85;
+}
+
+treechildren::-moz-tree-image(GeneratedName, MailList) {
+ list-style-image: var(--icon-user-list);
+}
diff --git a/comm/mail/themes/shared/mail/abFormFields.css b/comm/mail/themes/shared/mail/abFormFields.css
new file mode 100644
index 0000000000..ac0d2cb9a0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/abFormFields.css
@@ -0,0 +1,89 @@
+/* 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/. */
+
+:root {
+ --in-content-button-border-radius: 3px;
+}
+
+@media not (prefers-contrast) {
+ :root {
+ --in-content-button-background: var(--grey-90-a10);
+ --in-content-button-background-hover: var(--grey-90-a20);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --in-content-button-background: rgba(249, 249, 250, 0.1);
+ --in-content-button-background-hover: rgba(249, 249, 250, 0.15);
+ }
+ }
+}
+
+button {
+ margin: 0;
+ padding: 0 12px;
+ border-radius: var(--in-content-button-border-radius);
+ min-height: var(--in-content-button-height);
+}
+
+button + button {
+ margin-inline-start: 6px;
+}
+
+input[type="number"] {
+ min-height: calc(var(--in-content-button-height) - 4px);
+ padding: 1px;
+ border-radius: var(--in-content-button-border-radius);
+}
+
+/* sizes: chars + 8px padding + 1px borders + spin buttons 25+2+10px */
+input[type="number"].size3 {
+ width: calc(3ch + 55px);
+}
+input[type="number"].size5 {
+ width: calc(5ch + 55px);
+}
+
+input[type="number"]::-moz-number-spin-box {
+ padding-inline-start: 10px;
+}
+
+input[type="number"]::-moz-number-spin-up,
+input[type="number"]::-moz-number-spin-down {
+ appearance: none;
+ min-width: 25px;
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 0;
+ background-color: var(--in-content-button-background);
+ background-position: center;
+ background-repeat: no-repeat;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+input[type="number"]::-moz-number-spin-up:hover,
+input[type="number"]::-moz-number-spin-down:hover {
+ background-color: var(--in-content-button-background-hover);
+}
+
+input[type="number"]::-moz-number-spin-up {
+ min-height: calc(var(--in-content-button-height) * 0.5 - 3px);
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-start-end-radius: var(--in-content-button-border-radius);
+ background-image: var(--icon-nav-up-sm);
+}
+
+input[type="number"]::-moz-number-spin-down {
+ min-height: calc(var(--in-content-button-height) * 0.5 - 4px);
+ border-end-end-radius: var(--in-content-button-border-radius);
+ background-image: var(--icon-nav-down-sm);
+}
+
+input:is([type="email"], [type="tel"], [type="text"], [type="password"], [type="url"]) {
+ border-radius: var(--in-content-button-border-radius);
+ padding-block: initial;
+ /* it should be --in-content-button-height but input doesn't include the border */
+ min-height: calc(var(--in-content-button-height) - 2px);
+}
diff --git a/comm/mail/themes/shared/mail/abPrint.css b/comm/mail/themes/shared/mail/abPrint.css
new file mode 100644
index 0000000000..ce5aaa9e75
--- /dev/null
+++ b/comm/mail/themes/shared/mail/abPrint.css
@@ -0,0 +1,88 @@
+/* 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/. */
+
+:root {
+ font-family: sans-serif;
+}
+
+body > div {
+ padding: 0.5em;
+ break-inside: avoid-page;
+ border: 1px solid black;
+ margin: 0.5em;
+}
+
+.contact-header {
+ display: flex;
+ align-items: center;
+}
+
+.contact-photo {
+ margin-inline-end: 0.5em;
+ width: 5em;
+ height: 5em;
+ border-radius: 100%;
+ object-fit: cover;
+ object-position: center;
+}
+
+.contact-headings {
+ flex: 1;
+}
+
+.contact-heading-name {
+ margin-block: 0;
+ font-size: 1.4rem;
+ font-weight: 400;
+}
+
+.contact-heading-nickname {
+ margin-block: 0;
+ font-size: 1.1rem;
+}
+
+.contact-heading-email {
+ /* Don't print this, it's redundant information. */
+ display: none;
+}
+
+.contact-body {
+ column-width: 25em;
+}
+
+section {
+ padding-block: 0.5em;
+ line-height: 1.2;
+ box-sizing: border-box;
+ break-inside: avoid-column;
+}
+
+h2 {
+ margin-block: 0 0.5em;
+ font-size: 1.1rem;
+ line-height: 1.2;
+ font-weight: 500;
+}
+
+.entry-list {
+ display: grid;
+ grid-template-columns: min-content auto;
+ gap: 0.5em;
+ align-items: baseline;
+ margin-block: 0;
+ margin-inline-start: 0.5em;
+ padding: 0;
+ list-style: none inside;
+}
+
+.entry-item {
+ display: contents;
+}
+
+.entry-type {
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ opacity: .85;
+}
diff --git a/comm/mail/themes/shared/mail/abResultsPane.css b/comm/mail/themes/shared/mail/abResultsPane.css
new file mode 100644
index 0000000000..9aa7cf9085
--- /dev/null
+++ b/comm/mail/themes/shared/mail/abResultsPane.css
@@ -0,0 +1,22 @@
+/* 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/. */
+
+/* Hide the twisty gap. */
+treechildren::-moz-tree-twisty {
+ width: 0;
+ padding-inline: 2px;
+}
+
+treechildren::-moz-tree-image(GeneratedName) {
+ margin-inline-end: 2px;
+ list-style-image: var(--icon-contact);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ opacity: 0.85;
+}
+
+treechildren::-moz-tree-image(GeneratedName, MailList) {
+ list-style-image: var(--icon-user-list);
+}
diff --git a/comm/mail/themes/shared/mail/abSearchDialog.css b/comm/mail/themes/shared/mail/abSearchDialog.css
new file mode 100644
index 0000000000..75ca6c8ff8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/abSearchDialog.css
@@ -0,0 +1,18 @@
+/* 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/. */
+
+#searchResults {
+ flex: 1 1 0;
+ min-height: 150px;
+}
+
+#searchResultListBox {
+ flex: 1 1 0;
+ min-height: 0;
+ overflow: hidden;
+}
+
+#abResultsTree {
+ min-height: 0;
+}
diff --git a/comm/mail/themes/shared/mail/about3Pane.css b/comm/mail/themes/shared/mail/about3Pane.css
new file mode 100644
index 0000000000..75a4f9b668
--- /dev/null
+++ b/comm/mail/themes/shared/mail/about3Pane.css
@@ -0,0 +1,631 @@
+/* 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 url("chrome://messenger/skin/shared/threadPane.css");
+
+:root {
+ --foldertree-background: -moz-Dialog;
+ --treeitem-border-radius: 3px;
+ --folder-mode-name-padding: 6px;
+ --folder-tree-header-gap: 9px;
+ --folder-tree-header-padding: 9px;
+ --folder-pane-icon-size: 16px;
+ --folder-pane-icon-new: var(--icon-add);
+ --folder-pane-icon-more: var(--icon-more);
+ --folder-pane-icon-download: var(--icon-cloud-download);
+ --folder-pane-icon-kebab: var(--icon-kebab);
+}
+
+@media not (prefers-contrast) {
+ :root {
+ --foldertree-background: var(--color-gray-05);
+ --folderpane-unread-count-background: var(--color-gray-50);
+ --folderpane-unread-count-text: var(--color-white);
+ --folderpane-total-count-background: var(--color-gray-20);
+ --folderpane-total-count-text: var(--color-gray-90);
+ --folderpane-unread-new-count-background: var(--color-blue-60);
+ --treeitem-background-selected: var(--color-gray-20);
+ --treeitem-background-hover: var(--color-gray-10);
+ --treeitem-text-active: var(--color-white);
+ --treeitem-background-active: var(--color-blue-50);
+ --in-content-item-selected-unfocused: var(--color-gray-20);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --foldertree-background: var(--color-gray-80);
+ --folderpane-unread-new-count-background: var(--color-blue-50);
+ --folderpane-total-count-background: var(--color-gray-60);
+ --folderpane-total-count-text: var(--color-white);
+ --treeitem-background-selected: var(--color-gray-70);
+ --treeitem-background-hover: var(--color-gray-60);
+ --treeitem-background-active: var(--color-blue-60);
+ --in-content-item-selected-unfocused: rgba(249, 249, 250, 0.05);
+ --in-content-primary-button-background: #45a1ff;
+ --in-content-primary-button-background-hover: #65c1ff;
+ --in-content-primary-button-background-active: #85e1ff;
+ --in-content-focus-outline-color: #45a1ff;
+ }
+ }
+}
+
+@media (prefers-contrast) {
+ :root:not(:-moz-lwtheme) {
+ --folderpane-unread-count-background: var(--selected-item-color);
+ --folderpane-unread-count-text: var(--selected-item-text-color);
+ --folderpane-total-count-background: var(--selected-item-color);
+ --folderpane-total-count-text: var(--selected-item-text-color);
+ --folderpane-unread-new-count-background: ButtonShadow;
+ --treeitem-background-selected: transparent;
+ --treeitem-background-hover: transparent;
+ --treeitem-outline-hover: 2px solid var(--selected-item-color);
+ --treeitem-text-active: var(--selected-item-text-color);
+ --treeitem-background-active: var(--selected-item-color);
+ }
+}
+
+:root[lwt-tree] {
+ --foldertree-background: var(--sidebar-background-color);
+ --treeitem-background-selected: color-mix(in srgb, transparent 70%, var(--sidebar-highlight-background-color));
+ --treeitem-background-hover: color-mix(in srgb, transparent 80%, var(--sidebar-highlight-background-color));
+ --treeitem-text-active: var(--sidebar-highlight-text-color);
+ --treeitem-background-active: var(--sidebar-highlight-background-color);
+}
+
+:root[uidensity="compact"] {
+ --folder-mode-name-padding: 3px;
+ --folder-tree-header-gap: 6px;
+ --folder-tree-header-padding: 4px 6px 3px;
+}
+
+:root[uidensity="touch"] {
+ --folder-mode-name-padding: 9px;
+ --folder-tree-header-gap: 12px;
+ --folder-pane-icon-size: 20px;
+ --folder-pane-icon-new: var(--icon-add-md);
+ --folder-pane-icon-more: var(--icon-more-md);
+ --folder-pane-icon-download: var(--icon-cloud-download-md);
+}
+
+
+html {
+ height: 100%;
+}
+
+body {
+ height: 100%;
+ margin: 0;
+ text-shadow: none;
+
+ display: grid;
+ --folderPaneSplitter-width: 18em;
+ --messagePaneSplitter-width: 54em;
+ --messagePaneSplitter-height: 36em;
+}
+
+/* Different layouts */
+
+#folderPane {
+ grid-area: folders;
+ box-sizing: border-box;
+ /* Matches the collapse-width on the splitter. */
+ min-width: 100px;
+ overflow: hidden auto;
+ background-color: var(--sidebar-background-color, var(--foldertree-background));
+ color: var(--sidebar-text-color, inherit);
+ user-select: none;
+}
+
+#folderPaneSplitter {
+ grid-area: folderPaneSplitter;
+}
+
+#threadPane {
+ container-name: threadPane;
+ container-type: inline-size;
+ grid-area: threads;
+ box-sizing: border-box;
+ min-height: 200px;
+ min-width: 300px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+#messagePaneSplitter {
+ grid-area: messagePaneSplitter;
+}
+
+#messagePane {
+ grid-area: message;
+ box-sizing: border-box;
+ /* Matches the collapse-height and collapse-width on the splitter. */
+ min-height: 100px;
+ min-width: 300px;
+ overflow: auto;
+ display: flex;
+}
+
+#accountCentralBrowser {
+ grid-area: account-central;
+ box-sizing: border-box;
+ min-width: 400px;
+}
+
+:is(
+ #messagePane,
+ #folderPane
+).collapsed-by-splitter {
+ /* NOTE: We use "display: none" rather than "visibility: collapse". In the
+ * latter case, the min-width of min-height of the element would still
+ * influence the "auto" track sizing in the grid layout. In the former case
+ * the grid is completely missing a grid item for this track, so the "auto"
+ * size properly reduces to 0, as desired. Note that the track is indeed
+ * missing a grid item because each element in the body is explicitly placed
+ * in a grid-area, which means that there is no auto-placement of the grid
+ * items that would fill the track. */
+ display: none;
+}
+
+/* Classic layout: folder tree on the left, thread tree and message browser on the right. */
+body.layout-classic {
+ grid-template: "folders folderPaneSplitter threads" minmax(auto, 1fr)
+ "folders folderPaneSplitter messagePaneSplitter" min-content
+ "folders folderPaneSplitter message" minmax(auto, var(--messagePaneSplitter-height))
+ / minmax(auto, var(--folderPaneSplitter-width)) min-content minmax(auto, 1fr);
+}
+
+/* Vertical layout: three columns. */
+body.layout-vertical {
+ grid-template: "folders folderPaneSplitter threads messagePaneSplitter message" auto
+ / minmax(auto, var(--folderPaneSplitter-width)) min-content minmax(auto, 1fr) min-content minmax(auto, var(--messagePaneSplitter-width));
+}
+
+/* Wide layout: folder tree and thread tree on the top, and message browser on the bottom. */
+body.layout-wide {
+ grid-template: "folders folderPaneSplitter threads" minmax(auto, 1fr)
+ "messagePaneSplitter messagePaneSplitter messagePaneSplitter" min-content
+ "message message message" minmax(auto, var(--messagePaneSplitter-height))
+ / minmax(auto, var(--folderPaneSplitter-width)) min-content minmax(auto, 1fr);
+}
+
+/* If Account Central is shown, it overrides the layout setting. */
+body.account-central {
+ grid-template: "folders folderPaneSplitter account-central" auto
+ / minmax(auto, var(--folderPaneSplitter-width)) min-content minmax(auto, 1fr);
+}
+
+body.account-central :is(
+ #threadPane,
+ #messagePaneSplitter,
+ #messagePane,
+) {
+ display: none;
+}
+
+body:not(.account-central) #accountCentralBrowser {
+ display: none;
+}
+
+/* Folder tree pane. */
+
+#folderPaneHeaderBar:not([hidden]) {
+ --button-margin: 0;
+ display: flex;
+ flex-direction: row-reverse;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--folder-tree-header-gap);
+ position: sticky;
+ top: 0;
+ background-color: var(--sidebar-background-color, var(--foldertree-background));
+ color: var(--layout-color-2);
+ padding: var(--folder-tree-header-padding);
+ z-index: 1;
+}
+
+#folderPaneGetMessages,
+#folderPaneMoreButton,
+#folderPaneWriteMessage {
+ --icon-size: var(--folder-pane-icon-size);
+}
+
+#folderPaneGetMessages {
+ background-image: var(--folder-pane-icon-download);
+}
+
+#folderPaneGetMessagesContext {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#itemGetAllNewMessages {
+ list-style-image: var(--folder-pane-icon-download);
+}
+
+#folderPaneMoreButton {
+ background-image: var(--folder-pane-icon-more);
+}
+
+#folderPaneWriteMessage {
+ background-image: var(--folder-pane-icon-new);
+}
+
+#folderTree,
+#folderTree ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ --depth: 0;
+ --indent: calc(16px * var(--depth));
+}
+
+#folderTree ul {
+ overflow: hidden;
+ height: auto;
+}
+
+#folderTree:focus-visible {
+ outline: none;
+}
+
+#folderTree li[data-mode] {
+ margin-bottom: 3px;
+}
+
+#folderTree > li:not(:first-of-type) > .mode-container {
+ justify-content: space-between;
+ border-top: 1px solid var(--sidebar-border-color, var(--splitter-color));
+}
+
+#folderTree > li.unselectable > .mode-container {
+ margin: 0;
+ border-radius: 0;
+}
+
+#folderTree > li.unselectable > .mode-container:hover {
+ background-color: transparent;
+}
+
+#folderTree > li > .mode-container > .mode-name {
+ font-weight: 600;
+ font-size: 1.1rem;
+ min-height: initial;
+ padding-block: var(--folder-mode-name-padding);
+ padding-inline-start: 12px;
+ flex: 1;
+}
+
+#folderTree > li > .mode-container > .mode-button {
+ background-image: var(--icon-kebab);
+}
+
+#folderPaneHeaderBar[hidden] + #folderTree > li.unselectable:first-of-type > .mode-container {
+ border-top-style: none;
+}
+
+.mode-container:not([hidden]) {
+ display: flex;
+ align-items: center;
+}
+
+.container {
+ display: flex;
+ align-items: center;
+ -moz-context-properties: fill;
+ margin-inline: 6px;
+ border-radius: var(--treeitem-border-radius);
+ fill: currentColor;
+ cursor: default;
+ padding-inline-start: var(--indent);
+}
+
+li.selected > .container {
+ background-color: var(--treeitem-background-selected);
+}
+
+li:not(.selected) > .container:hover {
+ background-color: var(--treeitem-background-hover);
+}
+
+@media (prefers-contrast) {
+ li:not(:focus-visible) > .container:hover {
+ outline: var(--treeitem-outline-hover);
+ outline-offset: -2px;
+ }
+}
+
+li.context-menu-target:not(.selected) > .container,
+li.context-menu-target:not(.selected) > .container:hover {
+ background-color: color-mix(in srgb, var(--treeitem-background-active) 10%, transparent);
+ outline: 1px var(--listbox-border-type) var(--listbox-focused-selected-bg);
+ outline-offset: -1px;
+}
+
+#folderTree:focus-within li.selected > .container,
+#folderTree li.drop-target > .container {
+ background-color: var(--treeitem-background-active);
+ color: var(--treeitem-text-active);
+}
+
+#folderTree li .twisty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--list-item-buttons-size);
+ height: var(--list-item-buttons-size);
+}
+
+#folderTree li:not(.children) .twisty-icon {
+ display: none;
+}
+
+#folderTree li.children.collapsed > .container .twisty-icon {
+ transform: rotate(-90deg);
+}
+
+#folderTree li.children.collapsed:dir(rtl) > .container .twisty-icon {
+ transform: rotate(90deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ #folderTree .twisty-icon {
+ transition: transform var(--transition-duration) var(--transition-timing);
+ }
+}
+
+#folderTree .icon {
+ position: relative;
+ width: 16px;
+ height: 16px;
+ background-image: var(--icon-folder);
+ background-repeat: no-repeat;
+ --icon-color: var(--folder-color-folder);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, var(--icon-color, var(--folder-color-folder)) 20%, transparent);
+ stroke: var(--icon-color, var(--folder-color-folder));
+}
+
+.new-messages:not([data-server-type]) > .container > .icon::after {
+ position: absolute;
+ content: var(--icon-new-indicator);
+ inset-inline-start: -2px;
+ inset-block-start: -2px;
+}
+
+#folderTree:focus-within li.selected > .container > .icon {
+ --icon-color: currentColor !important; /* Override the user customized folder color. */
+}
+
+#folderTree li[data-server-type] > .container > .icon {
+ --icon-color: var(--primary);
+}
+
+#folderTree li:is([data-server-type="imap"], [data-server-type="pop3"]) > .container > .icon {
+ background-image: var(--icon-mail);
+}
+
+menuitem.server:is([data-server-type="imap"], [data-server-type="pop3"]) {
+ list-style-image: var(--icon-mail);
+}
+
+#folderTree li:is([data-server-type="imap"], [data-server-type="pop3"])[data-server-secure="true"]
+ > .container > .icon {
+ background-image: var(--icon-mail-secure);
+}
+
+menuitem.server:is([data-server-type="imap"], [data-server-type="pop3"])[data-server-secure="true"] {
+ list-style-image: var(--icon-mail-secure);
+}
+
+#folderTree li[data-server-type="none"] > .container > .icon {
+ background-image: var(--icon-folder);
+}
+
+#folderTree li[data-server-type="nntp"] > .container > .icon {
+ background-image: var(--icon-globe);
+}
+
+menuitem.server[data-server-type="nntp"] {
+ list-style-image: var(--icon-globe);
+}
+
+#folderTree li[data-server-type="nntp"][data-server-secure="true"] > .container > .icon {
+ background-image: var(--icon-globe-secure);
+}
+
+menuitem.server[data-server-type="nntp"][data-server-secure="true"] {
+ list-style-image: var(--icon-globe-secure);
+}
+
+#folderTree li[data-server-type="rss"] > .container > .icon {
+ background-image: var(--icon-rss);
+}
+
+#folderTree li[data-server-type="rss"] > ul > li:not([data-folder-type], [data-is-busy="true"]) .icon {
+ --icon-color: currentColor;
+ fill: var(--icon-color);
+}
+
+menuitem.server[data-server-type="rss"] {
+ list-style-image: var(--icon-rss);
+}
+
+#folderTree li[data-folder-type="archive"] > .container > .icon {
+ background-image: var(--icon-archive);
+ --icon-color: var(--folder-color-archive);
+}
+
+#folderTree li[data-folder-type="drafts"] > .container > .icon {
+ background-image: var(--icon-draft);
+ --icon-color: var(--folder-color-draft);
+}
+
+#folderTree li[data-folder-type="inbox"] > .container > .icon {
+ background-image: var(--icon-inbox);
+ --icon-color: var(--folder-color-inbox);
+}
+
+#folderTree li[data-folder-type="junk"] > .container > .icon {
+ background-image: var(--icon-spam);
+ --icon-color: var(--folder-color-spam);
+}
+
+#folderTree li[data-folder-type="outbox"] > .container > .icon {
+ background-image: var(--icon-outbox);
+ --icon-color: var(--folder-color-outbox);
+}
+
+#folderTree li[data-folder-type="sent"] > .container > .icon {
+ background-image: var(--icon-sent);
+ --icon-color: var(--folder-color-sent);
+}
+
+#folderTree li[data-folder-type="templates"] > .container > .icon {
+ background-image: var(--icon-template);
+ --icon-color: var(--folder-color-template);
+}
+
+#folderTree li[data-folder-type="trash"] > .container > .icon {
+ background-image: var(--icon-trash);
+ --icon-color: var(--folder-color-trash);
+}
+
+#folderTree li[data-folder-type="virtual"] > .container > .icon {
+ background-image: var(--icon-folder-filter);
+ --icon-color: var(--folder-color-folder-filter);
+}
+
+#folderTree li[data-server-type="nntp"] ul .icon {
+ background-image: var(--icon-newsletter);
+ --icon-color: var(--folder-color-newsletter);
+}
+
+#folderTree li[data-is-paused="true"] > .container > .icon,
+#folderTree li[data-is-paused="true"] > .container > .name,
+#folderTree li[data-is-paused="true"] ul > li > .container > .icon,
+#folderTree li[data-is-paused="true"] ul > li > .container > .name {
+ opacity: 0.6;
+}
+
+#folderTree li[data-is-busy="true"] > .container > .icon {
+ content: var(--icon-clock) !important;
+ background-image: none;
+ --icon-color: var(--button-primary-background-color);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ #folderTree li[data-is-busy="true"] > .container > .icon {
+ content: var(--icon-loading) !important;
+ animation: activity-indicator-3pane 1.05s steps(30) infinite;
+ object-fit: cover;
+ object-position: 0px 0;
+ }
+ @keyframes activity-indicator-3pane {
+ 100% { object-position: -480px 0; }
+ }
+}
+
+#folderTree li[data-has-error="true"] > .container > .icon {
+ content: var(--icon-warning) !important;
+ background-image: none;
+ fill: var(--color-amber-30);
+ stroke: var(--color-amber-60);
+}
+
+#folderTree li[data-tag-key] > .container > .icon {
+ background-image: var(--icon-tag);
+}
+
+.name {
+ flex: 1;
+ margin-inline: 7px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+}
+
+#folderTree li[data-server-type] > .container > .name {
+ font-weight: bold;
+}
+
+.noselect-folder > .container > .name {
+ font-style: italic;
+ opacity: 0.6;
+}
+
+#folderTree:focus-within li.noselect-folder.selected > .container > .name {
+ opacity: 0.8;
+}
+
+.unread > .container > .name,
+.new-messages > .container > .name {
+ font-weight: bold;
+}
+
+.new-messages > .container > .name {
+ color: var(--new-folder-color);
+}
+
+#folderTree:focus-within li.selected.new-messages > .container > .name {
+ color: currentColor;
+}
+
+.folder-count-badge {
+ display: none;
+ padding: 1px 4px;
+ border-radius: 1000px;
+ color: var(--folderpane-unread-count-text);
+ font-size: 0.85em;
+ font-weight: bold;
+ min-width: 16px;
+ text-align: center;
+ line-height: initial;
+ box-sizing: border-box;
+ margin-inline-end: 6px;
+}
+
+.total-count {
+ font-weight: normal;
+ color: var(--folderpane-total-count-text);
+ background-color: var(--folderpane-total-count-background);
+}
+
+.unread > .container > .unread-count {
+ display: unset;
+ background-color: var(--folderpane-unread-count-background);
+}
+
+.total > .container > .total-count:not([hidden]) {
+ display: unset;
+}
+
+.new-messages > .container > .unread-count {
+ background-color: var(--folderpane-unread-new-count-background);
+}
+
+#folderTree:focus-within li.selected > .container > .unread-count {
+ background-color: var(--folderpane-unread-count-text);
+ color: var(--treeitem-background-active);
+}
+
+.folder-size {
+ font-size: .8rem;
+ font-weight: bold;
+ color: var(--layout-color-3);
+ margin-inline-end: 6px;
+}
+
+#folderTree:focus-within li.selected > .container > .folder-size {
+ color: currentColor;
+}
+
+/* Message browser pane. */
+
+#webBrowser,
+#messageBrowser,
+#multiMessageBrowser {
+ flex: 1;
+}
diff --git a/comm/mail/themes/shared/mail/aboutAddonsExtra.css b/comm/mail/themes/shared/mail/aboutAddonsExtra.css
new file mode 100644
index 0000000000..9fc38f6c5e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/aboutAddonsExtra.css
@@ -0,0 +1,174 @@
+/* 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 url("chrome://messenger/skin/icons.css");
+@import url("chrome://messenger/skin/colors.css");
+
+body {
+ /* Override the absolute (px) font-size value in common-shared.css. */
+ font-size: 1.1rem;
+}
+
+#full {
+ grid-template-columns: 18em 1fr;
+}
+
+#sidebar {
+ background-color: var(--in-content-categories-background);
+}
+
+#sidebar > #categories {
+ width: inherit;
+ padding-inline-end: 0;
+}
+
+#categories > .category {
+ border-radius: var(--in-content-button-border-radius);
+ -moz-context-properties: fill, fill-opacity, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/* Hide Plugins category */
+button[name="plugin"] {
+ display: none;
+}
+
+.sidebar-footer-list > li {
+ margin-inline: 6px;
+ border-radius: var(--in-content-button-border-radius);
+}
+
+@media (max-width: 830px) {
+ #full {
+ grid-template-columns: 60px 1fr;
+ }
+
+ #categories > .category {
+ padding-inline: 12px;
+ }
+
+ .sidebar-footer-list > li > a {
+ margin-inline: auto;
+ }
+}
+
+.category[name="discover"] {
+ background-image: var(--addons-manager-recommendations);
+}
+
+.category[name="extension"] {
+ background-image: var(--addons-manager-extensions);
+}
+
+.category[name="theme"] {
+ background-image: var(--addons-manager-themes);
+}
+
+.category[name="dictionary"] {
+ background-image: var(--addons-manager-dictionaries);
+}
+
+.category[name="locale"] {
+ background-image: var(--addons-manager-languages);
+}
+
+.category[name="sitepermission"] {
+ background-image: var(--addons-manager-site-permissions);
+}
+
+.category[name="available-updates"] {
+ background-image: var(--addons-manager-available-updates);
+}
+
+.category[name="recent-updates"] {
+ background-image: var(--addons-manager-recent-updates);
+}
+
+/* Temporary styles for the supernova icons */
+#preferencesButton .sidebar-footer-icon,
+.page-options-menu > .more-options-button {
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/* Settings icon override */
+#preferencesButton .sidebar-footer-icon {
+ content: var(--icon-settings);
+}
+
+.page-options-menu > .more-options-button {
+ background-image: url("chrome://messenger/skin/icons/new/touch/settings.svg");
+ width: 24px;
+ height: 24px;
+}
+
+.more-options-button {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ background-image: var(--icon-more);
+}
+
+/* Hide in extension details the private browsing section */
+section > .addon-detail-row-private-browsing,
+.addon-detail-row-private-browsing + .addon-detail-row.addon-detail-help-row {
+ display: none;
+}
+
+/* Hide the options entry in the options menu, as we have a dedicated button */
+addon-options panel-item[action="preferences"] {
+ display:none;
+}
+
+.extension-options-button {
+ min-width: auto;
+ min-height: auto;
+ width: 24px;
+ height: 24px;
+ margin: 0;
+ margin-inline-start: 8px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-image: url("chrome://messenger/skin/icons/developer.svg");
+ background-repeat: no-repeat;
+ background-position: center center;
+ /* Get the -badged ::after element in the right spot. */
+ padding: 1px;
+ display: flex;
+ justify-content: flex-end;
+}
+
+recommended-addon-card .addon.card:hover {
+ box-shadow: var(--card-shadow-hover);
+ cursor: pointer;
+}
+
+panel-item[action="remove"] {
+ --icon: var(--icon-trash);
+}
+panel-item {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/* Override the absolute (px) font-size values in aboutaddons.css. */
+
+.addon-name,
+.disco-addon-name {
+ font-size: 1.25rem;
+ font-weight: 600;
+}
+
+.theme-enable-button,
+.addon-description,
+.disco-addon-author,
+.disco-cta-button {
+ font-size: 1.1rem;
+}
+
+button.tab-button {
+ font-size: 1.1rem;
+}
diff --git a/comm/mail/themes/shared/mail/aboutAddressBook.css b/comm/mail/themes/shared/mail/aboutAddressBook.css
new file mode 100644
index 0000000000..d155fe3d1c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/aboutAddressBook.css
@@ -0,0 +1,1023 @@
+/* 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 url("chrome://messenger/skin/shared/preferences/subdialog.css");
+@import url("chrome://messenger/skin/abFormFields.css");
+@import url("chrome://messenger/skin/icons.css");
+
+:root {
+ appearance: none;
+ font: message-box;
+ background-color: var(--layout-background-0);
+
+ --address-book-card-min-height: 140px;
+
+ --ab-card-line-height: 1.3em;
+ --ab-card-table-row-height: 22px;
+}
+
+:root[uidensity="compact"] {
+ --ab-card-line-height: 1em;
+ --ab-card-table-row-height: 18px;
+}
+
+:root[uidensity="touch"] {
+ --ab-card-line-height: 1.4em;
+ --ab-card-table-row-height: 32px;
+}
+
+@media (prefers-contrast) {
+ :root {
+ background-color: transparent;
+ color: currentColor;
+
+ --address-book-cards-list-bg: transparent;
+ --address-book-icons-color: currentColor;
+ }
+}
+
+@media not (prefers-contrast) {
+ :root {
+ background: var(--layout-background-0);
+ color: var(--layout-color-0);
+
+ --address-book-cards-list-bg: var(--layout-background-1);
+ --address-book-icons-color: var(--layout-color-0);
+ }
+}
+
+/* Globals */
+
+.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+/* Page layout */
+
+body {
+ --address-book-pane-min-width: 240px;
+ --booksSplitter-width: var(--address-book-pane-min-width);
+ --sharedSplitter-height: 50%;
+ --address-book-cards-pane-min-width: 340px;
+ --sharedSplitter-width: var(--address-book-cards-pane-min-width);
+ --address-book-details-pane-min-width: 500px;
+
+ display: grid;
+ grid-template: "toolbox toolbox toolbox toolbox toolbox" min-content
+ "books booksSplitter cards sharedSplitter details" minmax(auto, 1fr)
+ / minmax(var(--address-book-pane-min-width), var(--booksSplitter-width)) min-content
+ minmax(var(--address-book-cards-pane-min-width), var(--sharedSplitter-width)) min-content
+ minmax(var(--address-book-details-pane-min-width), 1fr);
+ text-shadow: none;
+ font-size: 1.1rem;
+}
+
+body.layout-table {
+ grid-template: "toolbox toolbox toolbox" min-content
+ "books booksSplitter cards" minmax(var(--address-book-card-min-height), 1fr)
+ "books booksSplitter sharedSplitter" min-content
+ "books booksSplitter details" minmax(auto, var(--sharedSplitter-height))
+ / minmax(var(--address-book-pane-min-width), var(--booksSplitter-width)) min-content minmax(var(--address-book-details-pane-min-width), 1fr);
+}
+
+#dialogStack {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+/* Toolbar */
+
+#toolbox {
+ grid-area: toolbox;
+ font: message-box;
+ font-size: 1rem;
+}
+
+.toolbarbutton-1:focus-visible {
+ outline: var(--in-content-focus-outline);
+}
+
+#toolbarCreateBook {
+ list-style-image: var(--icon-new-address-book);
+}
+
+#toolbarCreateContact {
+ list-style-image: var(--icon-new-contact);
+}
+
+#toolbarCreateList {
+ list-style-image: var(--icon-new-user-list);
+}
+
+#toolbarImport {
+ list-style-image: var(--icon-import);
+}
+
+/* Books pane */
+
+#booksPane {
+ grid-area: books;
+ padding-block-start: 30px;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ background-color: var(--layout-background-2);
+}
+
+#booksSplitter {
+ grid-area: booksSplitter;
+}
+
+#books {
+ flex: 1;
+ font-size: 1.1rem;
+ scroll-behavior: smooth;
+ -moz-user-select: none;
+}
+
+#books:focus-visible {
+ outline: none;
+}
+
+#books,
+#books ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+#allAddressBooks {
+ margin-block-end: 10px;
+}
+
+.bookRow-container,
+.listRow-container {
+ display: flex;
+ align-items: center;
+ margin-inline: 6px;
+ border-radius: var(--in-content-button-border-radius);
+ color: var(--listbox-color);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, var(--address-book-icons-color) 20%, transparent);
+ stroke: var(--address-book-icons-color);
+ cursor: default;
+}
+
+#books:not(:focus) :where(.bookRow, .listRow).selected > :is(.bookRow-container, .listRow-container) {
+ background-color: var(--listbox-selected-bg);
+ outline: var(--listbox-selected-outline);
+ outline-offset: -2px;
+}
+
+.bookRow > .bookRow-container:hover,
+.listRow > .listRow-container:hover {
+ background-color: var(--listbox-hover);
+}
+
+#books:focus :where(.bookRow, .listRow).selected > :is(.bookRow-container, .listRow-container) {
+ background-color: var(--listbox-focused-selected-bg);
+ color: var(--listbox-selected-color);
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#books:focus .selected .twisty {
+ stroke: currentColor;
+}
+
+.bookRow.drop-target > .bookRow-container,
+.listRow.drop-target > .listRow-container {
+ background-color: var(--in-content-item-selected);
+ color: var(--in-content-item-selected-text);
+}
+
+.bookRow .twisty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--list-item-buttons-size);
+ height: var(--list-item-buttons-size);
+ -moz-context-properties: stroke;
+ stroke: var(--address-book-icons-color);
+}
+
+.bookRow:not(.children) .twisty-icon {
+ display: none;
+}
+
+.bookRow.children.collapsed .twisty-icon {
+ transform: rotate(-90deg);
+}
+
+.bookRow.children.collapsed:dir(rtl) .twisty-icon {
+ transform: rotate(90deg);
+}
+
+.bookRow-icon,
+.listRow-icon {
+ width: 16px;
+ height: 16px;
+ background-image: var(--addressbook-tree-ab);
+ background-position: center center;
+ background-repeat: no-repeat;
+}
+
+.bookRow.remote .bookRow-icon {
+ background-image: var(--addressbook-tree-remote);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .bookRow.requesting .bookRow-icon {
+ position: relative;
+ overflow: hidden;
+ background: none;
+ }
+
+ .bookRow.requesting .bookRow-icon::before {
+ content: "";
+ position: absolute;
+ background-image: var(--icon-loading);
+ width: 480px;
+ height: 100%;
+ animation: tab-throbber-animation 1.05s steps(30) infinite;
+ }
+
+ .bookRow.requesting .bookRow-icon:dir(rtl)::before {
+ animation-name: tab-throbber-animation-rtl;
+ }
+
+ @keyframes tab-throbber-animation {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-100%); }
+ }
+
+ @keyframes tab-throbber-animation-rtl {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(100%); }
+ }
+}
+
+.bookRow-name,
+.listRow-name {
+ flex: 1;
+ margin-inline: 7px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+}
+
+.bookRow > ul {
+ overflow: hidden;
+ height: auto;
+}
+
+.bookRow > ul:empty,
+.bookRow.collapsed > ul {
+ height: 0;
+}
+
+.bookRow-menu,
+.listRow-menu {
+ background-image: var(--icon-more);
+ background-position: center center;
+ background-repeat: no-repeat;
+ width: var(--list-item-buttons-size);
+ height: var(--list-item-buttons-size);
+ display: none;
+}
+
+.bookRow-container:hover .bookRow-menu,
+.listRow-container:hover .listRow-menu,
+#books:focus-visible .bookRow.selected .bookRow-menu,
+#books:focus-visible .listRow.selected .listRow-menu {
+ display: unset;
+}
+
+.listRow-container {
+ padding-inline-start: 51px;
+}
+
+.listRow-icon {
+ background-image: var(--addressbook-tree-list);
+}
+
+/* Cards pane */
+
+#cardsPane {
+ position: relative;
+ grid-area: cards;
+ padding-block-start: 32px;
+ display: flex;
+ flex-direction: column;
+ overflow-y: hidden;
+ background-color: var(--address-book-cards-list-bg);
+}
+
+#cardsPaneHeader {
+ display: flex;
+ margin-block-end: 8px;
+ margin-inline: 6px;
+ align-items: center;
+}
+
+#searchInput {
+ height: var(--in-content-button-height);
+ flex: 1;
+ margin-block: 0;
+ margin-inline: 0 6px;
+ padding-inline: 8px;
+ box-sizing: border-box;
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: var(--in-content-button-border-radius);
+ color: inherit;
+ background-color: var(--in-content-box-background);
+}
+
+#searchInput:focus {
+ border-color: transparent;
+ outline: 2px solid var(--color-blue-60);
+ outline-offset: -1px;
+}
+
+#displayButton {
+ --button-margin: 0;
+ background-image: var(--icon-display-options);
+}
+
+/* Hide list items for sorting in table view. */
+body.layout-table ~ menupopup#sortContext >
+ :where(menuitem:is([name="sort"], [value="addrbook"]), menuseparator:last-of-type) {
+ display: none;
+}
+
+/* Hide address book toggle in the list view if All Address Book is not selected. */
+body:not(.layout-table):not(.all-ab-selected) ~ menupopup#sortContext >
+ menuitem[value="addrbook"] {
+ display: none;
+}
+
+.all-ab-selected .address-book-name {
+ margin-block-start: auto;
+ opacity: 0.8;
+ flex-shrink: 0;
+ font-weight: initial;
+}
+
+#cards {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#cards[rows="ab-card-row"] {
+ border-top: 1px solid var(--splitter-color);
+}
+
+#placeholderEmptyBook::before,
+#placeholderSearchOnly::before {
+ background-image: var(--addressbook-tree-list);
+}
+
+#placeholderCreateContact {
+ color: var(--in-content-primary-button-background);
+}
+
+#placeholderCreateContact::before {
+ background-image: var(--icon-new-contact);
+}
+
+#placeholderSearching::before {
+ background-image: var(--icon-search);
+}
+
+#placeholderNoSearchResults::before {
+ /* TODO: Replace this with a "no results" search icon, like search-not-found.svg but nice. */
+ background-image: var(--icon-search);
+}
+
+tr[is="ab-card-row"] td > .card-container {
+ display: flex;
+ align-items: center;
+ max-height: inherit;
+ box-sizing: border-box;
+}
+
+.selected-card {
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+}
+
+:is(tr[is="ab-card-row"], .selected-card) .ab-card-row-data {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ line-height: var(--ab-card-line-height);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1 1 auto;
+}
+
+:is(tr[is="ab-card-row"], .selected-card) :is(.ab-card-first-line, .ab-card-second-line) {
+ display: flex;
+ justify-content: space-between;
+ margin-block: 0;
+ font-size: 1rem;
+ position: relative;
+}
+
+:is(tr[is="ab-card-row"], .selected-card) span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+:is(tr[is="ab-card-row"], .selected-card) .name {
+ font-size: 1.1rem;
+ font-weight: 500;
+}
+
+:is(tr[is="ab-card-row"], .selected-card) :is(.name, .address) {
+ flex: 1 1 auto;
+}
+
+tr[is="ab-card-row"] :is(.ab-card-first-line, .ab-card-second-line) {
+ line-height: 1.3;
+}
+
+:root[uidensity="compact"] tr[is="ab-card-row"] :is(.ab-card-first-line, .ab-card-second-line) {
+ line-height: 1.2;
+}
+
+:root[uidensity="touch"] tr[is="ab-card-row"] :is(.ab-card-first-line, .ab-card-second-line) {
+ line-height: 1.4;
+}
+
+@media (-moz-platform: windows) {
+ :root[uidensity="compact"] tr[is="ab-card-row"] :is(.ab-card-first-line, .ab-card-second-line) {
+ line-height: 1.4;
+ }
+}
+
+tr[is="ab-card-row"]:not(.selected) .ab-card-second-line {
+ color: var(--in-content-deemphasized-text);
+}
+
+tr[is="ab-card-row"].selected .recipient-avatar.is-mail-list {
+ color: currentColor;
+}
+
+/* Details pane */
+
+#sharedSplitter {
+ grid-area: sharedSplitter;
+}
+
+body.is-editing #sharedSplitter {
+ z-index: 2;
+}
+
+body.layout-table #sharedSplitter {
+ width: auto !important;
+}
+
+body:not(.layout-table) #sharedSplitter {
+ height: auto !important;
+}
+
+#detailsPane.collapsed-by-splitter,
+#sharedSplitter.splitter-collapsed {
+ display: none;
+}
+
+#detailsPane {
+ grid-area: details;
+ min-height: var(--address-book-card-min-height);
+ user-select: text;
+}
+
+#detailsPane:not([hidden]) {
+ display: grid;
+ grid-template: "scroll-content" 1fr
+ "footer" auto / 1fr;
+}
+
+#editContactForm {
+ display: contents;
+}
+
+.contact-details-scroll {
+ grid-area: scroll-content;
+ overflow: auto;
+ padding: 21px;
+}
+
+#detailsFooter {
+ grid-area: footer;
+ padding: 21px;
+ background-color: var(--in-content-page-background);
+}
+
+.contact-details-scroll > *,
+#detailsFooter {
+ /* Fits two #detailsBody and vcard-edit columns. */
+ max-width: 64em;
+}
+
+body.is-editing #detailsPane {
+ z-index: 2;
+ background-color: var(--in-content-page-background);
+ color: var(--in-content-page-color);
+ box-shadow: 0 2px 6px -5px #000;
+}
+
+#detailsPaneBackdrop {
+ grid-column: 1 / -1;
+ grid-row: 2 / -1;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 1;
+}
+
+body:not(.is-editing) #detailsPaneBackdrop {
+ display: none;
+}
+
+body.is-editing #viewContact {
+ display: none;
+}
+
+body:not(.is-editing) #editContactForm {
+ display: none;
+}
+
+.contact-header:not([hidden]) {
+ display: grid;
+ grid-template: "photo headings" auto / auto 1fr;
+ align-items: center;
+ gap: 1em;
+ margin-bottom: 15px;
+}
+
+#photoInput {
+ display: contents;
+}
+
+#photoButton,
+#viewContactPhoto {
+ grid-area: photo;
+}
+
+.contact-photo {
+ width: 100px;
+ height: 100px;
+ border-radius: 100%;
+ object-fit: cover;
+ object-position: center;
+ background-color: var(--in-content-button-background);
+ -moz-context-properties: stroke;
+ stroke: color-mix(in srgb, transparent 50%, var(--recipient-avatar-color));
+}
+
+#photoButton {
+ position: relative;
+ border-radius: 100%;
+ padding: 0;
+ margin: 0;
+}
+
+#photoButton:hover {
+ background: none;
+}
+
+#photoOverlay {
+ position: absolute;
+ inset: 0;
+ border-radius: 100%;
+}
+
+#photoButton:focus-visible {
+ outline: 2px solid var(--in-content-focus-outline-color);
+ outline-offset: 2px;
+}
+
+#photoButton:is(:focus-visible, :hover) #photoOverlay {
+ background-color: #0003;
+ background-image: var(--icon-more);
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: 48px;
+ -moz-context-properties: stroke;
+ stroke: #fff;
+ cursor: pointer;
+}
+
+.contact-headings:not([hidden]) {
+ grid-area: headings;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ word-break: break-word;
+}
+
+.contact-heading-name,
+.contact-headings p {
+ margin-block: 0;
+}
+
+.contact-heading-name {
+ font-size: 1.6rem;
+ font-weight: 400;
+}
+
+.contact-heading-nickname {
+ font-size: 1.2rem;
+ color: var(--in-content-deemphasized-text);
+}
+
+.contact-heading-email {
+ margin-block: 0;
+ font-size: 1.1rem;
+ font-weight: 400;
+ color: var(--in-content-deemphasized-text);
+}
+
+.list-header:not([hidden]),
+.selection-header:not([hidden]) {
+ display: flex;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.list-header .recipient-avatar {
+ width: 50px;
+ height: 50px;
+}
+
+#detailsBody {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(31.5em, 1fr));
+ grid-template-rows: masonry;
+ gap: 1em;
+}
+
+#detailsActions {
+ grid-column: 1 / -1;
+}
+
+#detailsBody section {
+ padding: 15px;
+ border-radius: var(--in-content-button-border-radius);
+ border: 1px solid var(--in-content-box-info-border);
+ background-color: var(--in-content-box-info-background);
+ font-size: 1.1rem;
+ line-height: 1.2;
+}
+
+.button-block:not([hidden]) {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ row-gap: 6px;
+}
+
+.button-block div {
+ display: flex;
+ align-items: center;
+}
+
+.edit-block {
+ flex: 1;
+ justify-content: end;
+}
+
+.icon-button:not([hidden]) {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ background-color: transparent;
+ --in-content-button-border-color: transparent;
+}
+
+.icon-button:hover {
+ background-color: var(--in-content-button-background);
+}
+
+.icon-button:hover:active {
+ background-color: var(--in-content-button-background-active);
+}
+
+.icon-button::before {
+ content: "";
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, stroke, fill-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+#detailsWriteButton::before {
+ background-image: var(--icon-pencil);
+}
+
+#detailsEventButton::before {
+ background-image: var(--icon-new-event);
+}
+
+#detailsSearchButton::before {
+ background-image: var(--icon-search);
+}
+
+#detailsNewListButton::before {
+ background-image: var(--icon-new-user-list);
+}
+
+#detailsBody h2 {
+ margin-block: 0 15px;
+ font-size: 1.1rem;
+ line-height: 1.2;
+ font-weight: 500;
+}
+
+.entry-list {
+ display: grid;
+ grid-template-columns: min-content auto;
+ gap: 6px;
+ align-items: baseline;
+ margin-block: 0;
+ margin-inline-start: 9px;
+ padding: 0;
+ list-style: none inside;
+}
+
+.entry-item {
+ display: contents;
+}
+
+.entry-type {
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ opacity: .85;
+}
+
+.entry-value {
+ word-break: break-all;
+}
+
+section#notes div {
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+}
+
+section#selectedCards {
+ width: 31.5em;
+ grid-column: 1 / -1;
+ margin-inline: auto;
+}
+
+section#selectedCards ul {
+ margin: 0;
+ padding: 0;
+ list-style: none inside;
+ display: flex;
+ flex-direction: column;
+ row-gap: 12px;
+}
+
+#detailsDeleteButton {
+ color: var(--vcard-delete-button-color);
+}
+
+#detailsDeleteButton:hover {
+ background-color: var(--vcard-delete-button-color);
+ color: var(--color-white);
+}
+
+#detailsDeleteButton:hover:active {
+ background-color: var(--red-70);
+}
+
+#detailsDeleteButton::before,
+.icon-button-delete {
+ background-image: var(--icon-trash);
+}
+
+#detailsFooter label {
+ margin: 0 4px;
+ white-space: nowrap;
+}
+
+#detailsFooter menulist:not([hidden]) {
+ margin-inline: 4px;
+ min-height: var(--in-content-button-height);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#detailsFooter menulist > menupopup {
+ --panel-padding: 4px 0;
+ --panel-border-color: var(--arrowpanel-border-color);
+ --panel-border-radius: var(--arrowpanel-border-radius);
+}
+
+input[type="number"] {
+ min-height: 28px;
+ padding: 1px;
+ border-radius: var(--in-content-button-border-radius);
+}
+
+/* sizes: chars + 8px padding + 1px borders + spin buttons 25+2+10px */
+input[type="number"].size3 {
+ width: calc(3ch + 55px);
+}
+input[type="number"].size5 {
+ width: calc(5ch + 55px);
+}
+
+input[type="number"]::-moz-number-spin-box {
+ padding-inline-start: 10px;
+}
+
+input[type="number"]::-moz-number-spin-up,
+input[type="number"]::-moz-number-spin-down {
+ appearance: none;
+ min-width: 25px;
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 0;
+ background-color: var(--in-content-button-background);
+ background-position: center;
+ background-repeat: no-repeat;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+input[type="number"]::-moz-number-spin-up:hover,
+input[type="number"]::-moz-number-spin-down:hover {
+ background-color: var(--in-content-button-background-hover);
+}
+
+input[type="number"]::-moz-number-spin-up {
+ min-height: calc(var(--in-content-button-height) * 0.5 - 2px);
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-start-end-radius: var(--in-content-button-border-radius);
+ background-image: url("chrome://global/skin/icons/arrow-up-12.svg");
+}
+
+input[type="number"]::-moz-number-spin-down {
+ min-height: calc(var(--in-content-button-height) * 0.5 - 3px);
+ border-end-end-radius: var(--in-content-button-border-radius);
+ background-image: url("chrome://global/skin/icons/arrow-down-12.svg");
+}
+
+/* Photo dialog */
+
+#photoDialogInner {
+ width: 500px;
+ text-align: center;
+}
+
+#photoDropTarget {
+ height: 100px;
+ background-color: var(--in-content-button-background);
+ background-image: var(--icon-user);
+ background-size: 80px;
+ background-position: center;
+ -moz-context-properties: stroke;
+ stroke: var(--in-content-box-background);
+ background-repeat: no-repeat;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+#photoDropTarget[hidden] {
+ display: none;
+}
+
+#photoDropTarget .icon {
+ display: none;
+ margin-inline-end: 8px;
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .bookRow-container,
+ .listRow-container,
+ .twisty {
+ transition:
+ color var(--transition-duration) var(--transition-timing),
+ background-color var(--transition-duration) var(--transition-timing),
+ fill var(--transition-duration) var(--transition-timing),
+ stroke var(--transition-duration) var(--transition-timing);
+ }
+
+ .bookRow .twisty-icon {
+ transition: transform var(--transition-duration) var(--transition-timing);
+ }
+
+ #photoDropTarget.drop-loading .icon {
+ display: unset;
+ position: relative;
+ overflow: hidden;
+ text-align: start;
+ }
+
+ #photoDropTarget.drop-loading .icon::before {
+ content: "";
+ position: absolute;
+ background-image: url("chrome://messenger/skin/icons/loading.svg");
+ width: 480px;
+ height: 100%;
+ animation: tab-throbber-animation 1.05s steps(30) infinite;
+ }
+
+ #photoDropTarget.drop-loading .icon:dir(rtl)::before {
+ animation-name: tab-throbber-animation-rtl;
+ }
+
+ @keyframes tab-throbber-animation {
+ 0% {
+ transform: translateX(0);
+ }
+ 100% {
+ transform: translateX(-100%);
+ }
+ }
+
+ @keyframes tab-throbber-animation-rtl {
+ 0% {
+ transform: translateX(0);
+ }
+ 100% {
+ transform: translateX(100%);
+ }
+ }
+}
+
+#photoDropTarget.drop-error .icon {
+ display: unset;
+ background-image: url("chrome://global/skin/icons/warning.svg");
+ background-size: contain;
+}
+
+#photoDialog svg {
+ margin: -10px;
+}
+
+#photoDialog svg[hidden] {
+ display: none;
+}
+
+#photoDialog rect {
+ fill: transparent;
+}
+
+#photoDialog rect:not(.corner) {
+ shape-rendering: crispEdges;
+ stroke: #fff;
+ stroke-width: 1px;
+}
+
+#photoDialog .corner.nw {
+ cursor: nw-resize;
+}
+
+#photoDialog .corner.ne {
+ cursor: ne-resize;
+}
+
+#photoDialog .corner.se {
+ cursor: se-resize;
+}
+
+#photoDialog .corner.sw {
+ cursor: sw-resize;
+}
+
+#photoDialog .extra1 {
+ margin-inline-end: auto;
+}
+
+#cardCount {
+ position: sticky;
+ bottom: 0;
+ background-color: var(--in-content-categories-background);
+ border-top: 1px solid var(--splitter-color);
+ color: color-mix(in srgb, currentColor 75%, transparent);
+ padding: 9px;
+ margin-block-start: 6px;
+ font-weight: 500;
+ font-size: 1rem;
+}
diff --git a/comm/mail/themes/shared/mail/aboutDownloads.css b/comm/mail/themes/shared/mail/aboutDownloads.css
new file mode 100644
index 0000000000..5fd6b119c6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/aboutDownloads.css
@@ -0,0 +1,131 @@
+/* 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 html url("http://www.w3.org/1999/xhtml");
+
+@media not (prefers-contrast) {
+ :root {
+ --in-content-button-background: var(--grey-90-a10);
+ --in-content-button-background-hover: var(--grey-90-a20);
+ --in-content-button-background-active: var(--grey-90-a30);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --in-content-button-background: rgba(249, 249, 250, 0.1);
+ --in-content-button-background-hover: rgba(249, 249, 250, 0.15);
+ --in-content-button-background-active: rgba(249, 249, 250, 0.2);
+ --in-content-primary-button-background: #45a1ff;
+ --in-content-focus-outline-color: #45a1ff;
+ }
+ }
+}
+
+body {
+ color: var(--in-content-page-color);
+ background: var(--in-content-page-background);
+ text-shadow: none;
+}
+
+#downloadTopBox {
+ background-color: var(--toolbar-bgcolor);
+ padding: 10px 18px;
+ border-bottom: 1px solid var(--chrome-content-separator-color);
+}
+
+#downloadBottomBox {
+ padding: 15px 18px;
+}
+
+#downloadBottomBox:-moz-lwtheme {
+ text-shadow: none;
+}
+
+#msgDownloadsRichListBox {
+ margin-block: 0;
+}
+
+#msgDownloadsRichListBox > .download {
+ min-height: 5em;
+ border-bottom: 1px solid hsla(0, 0%, 50%, .3);
+ border-radius: 3px;
+}
+
+#msgDownloadsRichListBox > .download > vbox {
+ display: flex;
+ flex-direction: column;
+}
+
+#clearDownloads {
+ margin-inline-start: 0;
+ padding: 0 12px;
+ border-radius: 3px;
+ font-weight: 400;
+}
+
+#searchBox {
+ width: 22em;
+ margin-inline-end: 0;
+}
+
+.fileTypeIcon {
+ margin-inline: 8px;
+ /* explicitly size the icon, so size doesn't vary on hidpi systems */
+ height: 32px;
+ width: 32px;
+}
+
+.sender,
+.fileName {
+ margin-block: 3px;
+ font-weight: 600;
+}
+
+.sender[value=""] {
+ display: none;
+}
+
+.size,
+.startDate {
+ opacity: 0.7;
+ margin-block: 3px;
+}
+
+.downloadButton {
+ align-items: center;
+ background: transparent !important;
+ min-width: 0;
+ height: unset;
+ margin: 0;
+ border: none !important;
+ outline: none !important;
+ color: inherit;
+ padding: 0 12px;
+}
+
+.downloadButton > .button-box {
+ appearance: none;
+ padding: 8px;
+}
+
+.downloadButton > .button-box > .button-icon {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.downloadButton > .button-box > .button-text {
+ display: none;
+}
+
+.downloadButton:hover > .button-box {
+ background-color: var(--in-content-button-background-hover);
+ border-radius: 50%;
+}
+
+.downloadButton:hover:active > .button-box {
+ background-color: var(--in-content-button-background-active);
+}
diff --git a/comm/mail/themes/shared/mail/aboutImport.css b/comm/mail/themes/shared/mail/aboutImport.css
new file mode 100644
index 0000000000..9d81f4a029
--- /dev/null
+++ b/comm/mail/themes/shared/mail/aboutImport.css
@@ -0,0 +1,431 @@
+/* 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 url("chrome://messenger/skin/colors.css");
+
+:root {
+ --main-title-size: 1.8rem;
+ --title-icon-margin: 8px;
+}
+
+*[hidden] {
+ display: none !important;
+}
+
+body {
+ text-shadow: none;
+ background-color: rgba(0, 0, 0, 0.03);
+}
+
+h1 {
+ padding-top: 0;
+ margin: 0 0 8px;
+ font-size: var(--main-title-size);
+ font-weight: 300;
+ line-height: 1.3;
+}
+
+h2 {
+ margin: 0 0 4px;
+ font-size: 1.4rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.light-heading {
+ font-weight: 300;
+}
+
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+p {
+ line-height: 1.8em;
+ margin: 0;
+}
+
+progress {
+ width: 100%;
+}
+
+dl {
+ display: grid;
+ grid-template-columns: [label] max-content [value] minmax(min-content, auto);
+ column-gap: 1ch;
+}
+
+dl div {
+ display: grid;
+ grid-column: 1 / 3;
+ grid-template-columns: subgrid;
+ grid-template-rows: subgrid;
+}
+
+dt {
+ font-weight: 600;
+ grid-area: label;
+}
+
+dd {
+ grid-area: value;
+ margin: 0;
+}
+
+#csvFieldMap table {
+ margin-top: 1rem;
+ table-layout: fixed;
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 1rem;
+}
+
+#csvFieldMap th {
+ font-weight: 600;
+}
+
+#csvFieldMap th,
+#csvFieldMap td {
+ border-bottom: 1px solid var(--in-content-page-color);
+}
+
+#csvFieldMap th:last-child {
+ width: 2rem;
+}
+
+#csvFieldMap td:last-child {
+ text-align: end;
+}
+
+#csvFieldMap {
+ margin-top: 2rem;
+ display: block;
+}
+
+#csvFieldMap select {
+ line-height: 1.2;
+ background-position-x: right 10px;
+ padding-inline-start: 8px;
+ margin-inline-start: 0;
+ width: 100%;
+}
+
+#csvFieldMap select:dir(rtl) {
+ background-position-x: left 10px;
+}
+
+#csvFieldMap input {
+ margin-top: 4px;
+}
+
+#main {
+ margin: 4em;
+ min-width: 400px;
+ max-width: 600px;
+}
+
+#main .buttons-container {
+ margin-top: 1.5em;
+ display: flex;
+ justify-content: space-between;
+}
+
+#main .buttons-container button {
+ margin: 0;
+}
+
+#main .buttons-container .continue {
+ margin-inline-start: auto;
+}
+
+.tabPane > section {
+ background-color: var(--in-content-box-background);
+ border-radius: var(--in-content-button-border-radius);
+ padding: 2em calc(var(--main-title-size) + var(--title-icon-margin) + 1em);
+}
+
+.tabPane h1 {
+ position: relative;
+}
+
+.tabPane h1::before {
+ content: "";
+ background-repeat: no-repeat;
+ background-position: bottom center;
+ display: inline-block;
+ width: 24px;
+ height: 24px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ color: var(--primary);
+ margin-inline-end: var(--title-icon-margin);
+ position: absolute;
+ inset-inline-start: calc(-1 * (var(--main-title-size) + var(--title-icon-margin)));
+}
+
+#tabPane-start.tabPane h1::before {
+ background-image: var(--icon-import-lg);
+}
+
+#tabPane-export.tabPane h1::before {
+ background-image: var(--icon-export-lg);
+}
+
+.source-list {
+ padding: 1em 0;
+}
+
+.profile-list {
+ padding-top: 0.5em;
+}
+
+.option-list {
+ padding: 1em 0;
+ list-style: none;
+}
+
+#calendarItemsTools {
+ margin-top: 1rem;
+ display: flex;
+ align-items: center;
+}
+
+#calendarItemsTools input {
+ flex: 1;
+ padding-inline: 0.5rem;
+ height: 32px;
+}
+
+#calendar-item-list {
+ height: 60vh;
+ overflow: auto;
+ background-color: rgba(215, 215, 219, 0.2);
+ margin-top: 1rem;
+ margin-inline: 2px;
+}
+
+.calendar-item-wrapper {
+ margin: 1rem;
+ padding: 1rem;
+ display: flex;
+ background: var(--in-content-page-background);
+}
+
+.notificationbox-stack notification-message:last-child {
+ margin-block-end: 12px;
+}
+
+/* Override common.css */
+.toggle-container-with-text {
+ grid-template-columns: min-content 1fr;
+ display: grid;
+ grid-template-areas:
+ "i text"
+ ". desc";
+ margin-block: 1em;
+ line-height: 1.5;
+}
+
+.toggle-container-with-text:last-child {
+ margin-block-end: 0;
+}
+
+.toggle-container-with-text input {
+ grid-area: i;
+}
+
+.toggle-container-with-text p:last-of-type {
+ grid-area: desc;
+}
+
+.toggle-container-with-text p:first-of-type {
+ grid-area: text;
+}
+
+.toggle-container-with-text .tip-caption dt {
+ color: var(--in-content-page-color);
+}
+
+#tabPane-export p {
+ margin-bottom: 1rem;
+}
+
+/* Override calendar-item-summary.css */
+calendar-item-summary {
+ font-size: 1rem;
+ width: 100%;
+}
+
+.calendar-caption {
+ display: none;
+}
+
+.item-description {
+ border: 1px solid var(--in-content-page-color);
+ margin: 2px 4px 0;
+ min-height: 10em;
+}
+
+.summary-items {
+ list-style-image: var(--icon-check);
+ list-style-position: inside;
+ -moz-context-properties: stroke;
+ stroke: var(--color-green-60);
+ line-height: 1.8;
+}
+
+.tabPane > section > .center-button,
+.center-button {
+ margin-inline: auto;
+}
+
+.center-button {
+ display: block;
+ margin-block: 1em;
+}
+
+a.center-button {
+ max-width: max-content;
+}
+
+.progressPane,
+.center {
+ text-align: center;
+}
+
+/* Conditionally visible elements */
+.restart-only,
+.progressFinish,
+.progressPane {
+ display: none;
+}
+
+.restart-required .restart-only {
+ display: inherit;
+}
+
+.progress .before-progress {
+ display: none;
+}
+
+.progress .progressPane,
+.complete .progressFinish {
+ display: inherit;
+}
+
+.final-step .next-button,
+.restart-required .no-restart {
+ display: none;
+}
+
+.complete .progressFinish.center-button {
+ display: block;
+}
+
+/* Icons */
+.icon {
+ object-fit: contain;
+ vertical-align: middle;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.icon.info {
+ color: var(--primary);
+}
+
+.icon.warn {
+ color: var(--color-orange-40);
+}
+
+.back {
+ background-image: var(--icon-nav-left);
+ background-repeat: no-repeat;
+ background-position: 0.5em center;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ padding-inline-start: 1em;
+}
+
+.back:dir(rtl) {
+ background-image: var(--icon-nav-right);
+ background-position-x: right 0.5em;
+}
+
+#importFooter {
+ margin-block-start: 4em;
+ text-align: center;
+}
+
+/* Navigation steps */
+
+#stepNav {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+ margin-block-end: 2em;
+ margin-inline: 1em;
+ --step-size: var(--in-content-button-height);
+}
+
+#stepNav li {
+ flex-grow: 1;
+ font-size: 1.4rem;
+ display: flex;
+ align-items: center;
+}
+
+#stepNav li:first-child {
+ flex-grow: 0;
+}
+
+#stepNav button {
+ border-radius: var(--step-size);
+ height: var(--step-size);
+ width: var(--step-size);
+ min-width: var(--step-size);
+ padding: 0;
+ margin: 0;
+ opacity: 1;
+ box-sizing: border-box;
+ font-weight: 600;
+}
+
+#stepNav li:not(:first-child)::before {
+ content: "";
+ border-block-start: 2px dashed var(--in-content-button-background);
+ display: block;
+ margin-block-start: 1px;
+ flex-grow: 1;
+}
+
+#stepNav li.current button {
+ border: 3px solid var(--primary);
+ background: var(--in-content-box-background);
+ color: var(--primary);
+}
+
+#stepNav li.completed button {
+ background: var(--primary);
+ color: var(--in-content-box-background);
+}
+
+#stepNav li.completed:not(:first-child)::before,
+#stepNav li.current:not(:first-child)::before {
+ border-block-start: 2px solid var(--primary);
+}
+
+#navConfirm button {
+ border-radius: var(--in-content-button-border-radius);
+ width: auto;
+ font-size: 1rem;
+ padding-inline: 0.5em;
+}
diff --git a/comm/mail/themes/shared/mail/aboutPolicies.css b/comm/mail/themes/shared/mail/aboutPolicies.css
new file mode 100644
index 0000000000..2217e30a6a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/aboutPolicies.css
@@ -0,0 +1,159 @@
+/* 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 url("chrome://global/skin/in-content/common.css");
+
+html {
+ height: 100%;
+}
+
+body {
+ display: flex;
+ align-items: stretch;
+ height: 100%;
+}
+
+#sectionTitle {
+ float: inline-start;
+ padding-inline-start: 1rem;
+}
+
+/** Categories **/
+
+.category {
+ cursor: pointer;
+ /* Center category names */
+ display: flex;
+ align-items: center;
+}
+
+.category .category-name {
+ pointer-events: none;
+}
+
+#categories hr {
+ border-top-color: rgba(255,255,255,0.15);
+}
+
+/** Content area **/
+
+.main-content {
+ flex: 1;
+}
+
+.tab {
+ padding: 0.5em 0;
+}
+
+.tab table {
+ width: 100%;
+}
+
+tbody tr {
+ transition: background cubic-bezier(.07, .95, 0, 1) 250ms;
+}
+
+tbody tr:hover {
+ background-color: var(--in-content-item-hover);
+}
+
+th, td, table {
+ border-collapse: collapse;
+ border: none;
+ text-align: start;
+}
+
+th {
+ padding: 1rem;
+ font-size: larger;
+}
+
+td {
+ padding: 1rem;
+}
+
+/*
+ * In Documentation Tab, this property sets the policies row in an
+ * alternate color scheme of white and grey as each policy comprises
+ * of two tbody tags, one for the description and the other for the
+ * collapsible information block.
+ */
+
+.active-policies tr.odd:not(:hover),
+.errors tr:nth-child(odd):not(:hover),
+tbody:nth-child(4n + 1) {
+ background-color: var(--in-content-box-background-odd);
+}
+
+.arr_sep.odd:not(:last-child) td:not(:first-child) {
+ border-bottom: 2px solid #f9f9fa;
+}
+
+.arr_sep.even:not(:last-child) td:not(:first-child) {
+ border-bottom: 2px solid #ededf0;
+}
+
+.last_row:not(:last-child) td {
+ border-bottom: 2px solid #d7d7db !important;
+}
+
+.icon {
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ -moz-context-properties: fill;
+ display: inline-block;
+ fill: var(--newtab-icon-primary-color);
+ height: 14px;
+ vertical-align: middle;
+ width: 14px;
+ margin-top: -.125rem;
+ margin-left: .5rem;
+}
+
+.collapsible {
+ cursor: pointer;
+ border: none;
+ outline: none;
+}
+
+.content {
+ display: none;
+}
+
+.content-style {
+ background-color: var(--in-content-box-background);
+ color: var(--blue-50);
+}
+
+tbody.collapsible td {
+ padding-bottom: 1rem;
+}
+
+.schema {
+ font-family: monospace;
+ white-space: pre;
+ direction: ltr;
+}
+
+/*
+ * The Active tab has two messages: one for when the policy service
+ * is inactive and another for when the there are no specified
+ * policies. The three classes below control which message to display
+ * or to show the policy table.
+ */
+.no-specified-policies > table,
+.inactive-service > table {
+ display: none;
+}
+
+:not(.no-specified-policies) > .no-specified-policies-message,
+:not(.inactive-service) > .inactive-service-message {
+ display: none;
+}
+
+.no-specified-policies-message,
+.inactive-service-message {
+ padding: 1rem;
+}
diff --git a/comm/mail/themes/shared/mail/aboutSupport.css b/comm/mail/themes/shared/mail/aboutSupport.css
new file mode 100644
index 0000000000..288950dc79
--- /dev/null
+++ b/comm/mail/themes/shared/mail/aboutSupport.css
@@ -0,0 +1,67 @@
+/* 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/. */
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --in-content-button-background: rgba(249, 249, 250, 0.1);
+ --in-content-button-background-hover: rgba(249, 249, 250, 0.15);
+ --in-content-button-background-active: rgba(249, 249, 250, 0.2);
+ --in-content-primary-button-background: #45a1ff;
+ --in-content-primary-button-background-hover: #65c1ff;
+ --in-content-primary-button-background-active: #85e1ff;
+ --in-content-focus-outline-color: #45a1ff;
+ }
+}
+
+#support-buttons {
+ display: flex;
+}
+
+#support-buttons > div {
+ display: inherit;
+ align-items: center;
+}
+
+#contents.show-private-data .data-public {
+ display: none;
+}
+
+#contents:not(.show-private-data) .data-private {
+ display: none;
+}
+
+#safe-mode-box > h3 {
+ margin-top: 0;
+}
+
+#check-show-private-data {
+ margin-inline-end: 3px;
+ vertical-align: text-bottom;
+}
+
+.gray-text {
+ color: #888;
+}
+
+.thead-level2 > th {
+ background-color: hsl(0, 0%, 60%);
+}
+
+td.data-private {
+ background-color: #ff9;
+ color: #333;
+}
+
+.calendar-table > thead > th:nth-of-type(1) {
+ width: 25%;
+}
+
+.calendar-table + .calendar-table {
+ margin-top: 50px;
+}
+
+.calendar-table caption {
+ margin-bottom: 5px;
+ font-weight: bold;
+}
diff --git a/comm/mail/themes/shared/mail/accountCentral.css b/comm/mail/themes/shared/mail/accountCentral.css
new file mode 100644
index 0000000000..343901fbb7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/accountCentral.css
@@ -0,0 +1,527 @@
+/* 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 url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/icons.css");
+
+html {
+ height: 100%;
+}
+
+:root {
+ --bg-color: #ffffff;
+ --bg-watermark: url("chrome://messenger/skin/images/account-watermark.png");
+ --header-bg-color: rgba(0, 0, 0, 0.05);
+ --accounts-bg-color: rgba(0, 0, 0, 0.03);
+ --text-color: #36385A;
+ --title-color: #002275;
+ --primary-color: #0a84ff;
+ --primary-color-hover: #0060df;
+ --btn-color: #36385A;
+ --btn-color-hover: #FFFFFF;
+ --btn-bg: #FFFFFF;
+ --btn-bg-hover: #0060df;
+ --btn-shadow-hover: rgba(0, 0, 0, 0.3);
+ --popup-bg: #EDEDF0;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg-color: #2f2f33;
+ --bg-watermark: url("chrome://messenger/skin/images/account-watermark-light.png");
+ --header-bg-color: rgba(255, 255, 255, 0.05);
+ --accounts-bg-color: rgba(255, 255, 255, 0.03);
+ --text-color: #f9f9fa;
+ --title-color: #fefefe;
+ --primary-color: #0a84ff;
+ --primary-color-hover: #0a84ff;
+ --btn-color: #FFFFFF;
+ --btn-color-hover: #FFFFFF;
+ --btn-bg: #38383d;
+ --btn-bg-hover: #0060df;
+ --btn-shadow-hover: rgba(0, 0, 0, 0.3);
+ --popup-bg: #474749;
+ }
+}
+
+:root[lwt-tree-brighttext] {
+ --bg-color: #2f2f33;
+ --bg-watermark: url("chrome://messenger/skin/images/account-watermark-light.png");
+ --header-bg-color: rgba(255, 255, 255, 0.05);
+ --accounts-bg-color: rgba(255, 255, 255, 0.03);
+ --text-color: #f9f9fa;
+ --title-color: #fefefe;
+ --primary-color: #0a84ff;
+ --primary-color-hover: #0a84ff;
+ --btn-color: #FFFFFF;
+ --btn-color-hover: #FFFFFF;
+ --btn-bg: #38383d;
+ --btn-bg-hover: #0060df;
+ --btn-shadow-hover: rgba(0, 0, 0, 0.3);
+ --popup-bg: #474749;
+}
+
+body {
+ /* Overwrite rules in messenger.css. */
+ display: block;
+ overflow: auto;
+ margin: 0;
+ height: 100vh;
+ background-color: var(--body-background-color);
+ text-shadow: none;
+}
+
+#accountCentral {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--bg-color);
+ background-image: var(--bg-watermark);
+ background-position: bottom right;
+ background-repeat: no-repeat;
+ color: var(--text-color);
+ height: 100%;
+}
+
+#brandLogo {
+ width: 64px;
+ height: 64px;
+ margin-inline-end: 20px;
+ flex-shrink: 0;
+}
+
+#accountLogo {
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ color: var(--primary-color);
+ width: 20px;
+ height: 20px;
+ margin-inline-end: 10px;
+ flex-shrink: 0;
+ background: var(--account-central-mail) center no-repeat;
+ background-size: contain;
+ display: block;
+}
+
+#accountLogo[type="none"] {
+ background-image: var(--account-central-folder);
+}
+
+#accountLogo[type="rss"] {
+ background-image: var(--account-central-rss);
+}
+
+#accountLogo[type="nntp"] {
+ background-image: var(--account-central-globe);
+ margin-block-start: 3px;
+}
+
+.account-central-header {
+ display: flex;
+ align-items: center;
+ background-color: var(--header-bg-color);
+ padding: 20px 30px;
+}
+
+.account-central-header.summary-header {
+ padding-block: 16px;
+}
+
+[hidden] {
+ display: none !important; /* Ensure flex items obey hidden="hidden". */
+}
+
+.account-central-header > aside {
+ display: flex;
+ align-items: center;
+ width: 160px;
+ flex: 1 1 auto;
+}
+
+aside.settings-btn-container {
+ justify-content: end;
+}
+
+.account-central-title {
+ font-size: x-large;
+ margin-inline-end: 6px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ margin-block: 0;
+}
+
+#accountName {
+ font-size: 1.2em;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ margin-block: 0;
+ /* Prevents UI jumping when dynamically changing the content. */
+ min-height: 21px;
+}
+
+.account-central-version {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ font-size: small;
+ margin-top: 10px;
+ line-height: 1em;
+}
+
+.account-central-version > label {
+ margin-inline: 0 1px;
+}
+
+#releasenotes {
+ cursor: pointer;
+}
+
+#releasenotes img {
+ color: var(--primary-color);
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: 1;
+ width: 16px;
+ height: 16px;
+ margin-inline-start: 3px;
+}
+
+#releasenotes img:hover,
+#releasenotes img:focus {
+ color: var(--primary-color-hover);
+}
+
+.account-central-section {
+ display: flex;
+ flex-direction: column;
+ padding-inline: 30px;
+ padding-block: 10px;
+ position: relative;
+}
+
+.account-central-section.setup-section {
+ padding-block-end: 30px;
+}
+
+.account-central-section.zebra {
+ background-color: var(--accounts-bg-color);
+}
+
+#accountFeaturesSection {
+ margin-block-start: 20px;
+}
+
+.section-title {
+ font-size: larger;
+ font-weight: 600;
+ color: var(--title-color);
+ margin-block: 10px 20px;
+ /* Prevents UI jumping when dynamically changing the content. */
+ min-height: 21px;
+}
+
+.row-container {
+ margin-inline: -10px;
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.row-container > p {
+ flex: 1;
+ display: block;
+ min-width: 300px;
+ max-width: 550px;
+ margin-inline: 10px;
+ margin-block: 0 20px;
+}
+
+.row-container.account-options > .btn-link {
+ margin-inline: 5px 15px;
+ padding-inline: 5px;
+}
+
+/* Custom buttons style */
+.btn-hub {
+ transition: background-color 280ms ease,
+ color 280ms ease,
+ box-shadow 280ms ease;
+ appearance: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.1em;
+ color: var(--btn-color);
+ background-color: var(--btn-bg);
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ min-width: 110px;
+ height: 90px;
+ padding: 10px;
+ border: none;
+ border-radius: 4px;
+ margin-inline: 10px;
+ margin-block-end: 10px;
+ cursor: pointer;
+ box-shadow: 0 5px 20px -5px var(--btn-shadow-hover);
+}
+
+.btn-hub:not([disabled="true"]):hover {
+ color: var(--btn-color-hover) !important;
+ background-color: var(--btn-bg-hover);
+ box-shadow: 0 14px 16px -12px var(--btn-shadow-hover),
+ inset 20px 20px 50px -30px rgba(255, 255, 255, .35);
+}
+
+.btn-hub.btn-inline {
+ flex-direction: row;
+ width: auto;
+ height: auto;
+ padding: 8px 12px;
+ justify-content: flex-start;
+ min-width: 140px;
+}
+
+.btn-hub:focus:not(:hover) {
+ color: var(--primary-color) !important;
+}
+
+.btn-hub::before {
+ position: relative;
+ display: block;
+ content: '';
+ margin-block-end: 10px;
+ margin-inline-end: 0;
+ width: 20px;
+ height: 20px;
+ color: var(--primary-color);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: contain;
+ transition: color 280ms ease;
+}
+
+.btn-hub.btn-inline::before {
+ margin-block-end: 0;
+ margin-inline-end: 10px;
+ width: 16px;
+ height: 16px;
+}
+
+.btn-hub:hover::before {
+ color: var(--btn-color-hover) !important;
+}
+
+#setupEmail::before {
+ background-image: var(--account-central-mail);
+}
+
+#setupCalendar::before {
+ background-image: var(--account-central-calendar);
+}
+
+#setupAddressBook::before {
+ background-image: var(--account-central-address-book);
+}
+
+#setupChat::before {
+ background-image: var(--account-central-chat);
+}
+
+#setupFilelink::before {
+ background-image: var(--account-central-link);
+}
+
+#setupFeeds::before,
+#rssSubscriptionButton::before {
+ background-image: var(--account-central-rss);
+}
+
+#nntpSubscriptionButton::before {
+ background-image: var(--account-central-globe);
+}
+
+#setupNewsgroups::before {
+ background-image: var(--account-central-newsletter);
+}
+
+#importButton::before {
+ background-image: var(--icon-import);
+}
+
+#setupEmail.btn-inline::before {
+ background-image: var(--icon-mail);
+}
+
+#setupCalendar.btn-inline::before {
+ background-image: var(--icon-calendar);
+}
+
+#setupAddressBook.btn-inline::before {
+ background-image: var(--icon-address-book);
+}
+
+#setupChat.btn-inline::before {
+ background-image: var(--icon-chat);
+}
+
+#setupFilelink.btn-inline::before {
+ background-image: var(--icon-link);
+}
+
+#setupFeeds.btn-inline::before,
+#rssSubscriptionButton.btn-inline::before {
+ background-image: var(--icon-rss);
+}
+
+#nntpSubscriptionButton.btn-inline::before {
+ background-image: var(--icon-newsletter);
+}
+
+#setupNewsgroups.btn-inline::before {
+ background-image: var(--icon-newsletter);
+}
+
+
+.account-description {
+ position: absolute;
+ opacity: 0;
+ top: 100%;
+ margin-top: -30px;
+ width: 90vw;
+ left: 30px;
+ text-align: left;
+ transition: opacity 280ms ease;
+ z-index: 1;
+}
+
+.account-description > p {
+ display: inline-block;
+ background-color: var(--popup-bg);
+ padding: 4px 8px;
+ border-radius: 4px;
+ box-shadow: 0 2px 5px -4px rgba(0, 0, 0, 0.8);
+}
+
+.btn-hub:hover + .account-description {
+ opacity: 1;
+}
+
+/* Custom link style */
+.donation-link {
+ color: var(--primary-color);
+ font-style: italic;
+ font-weight: 600;
+ transition: color .2s;
+ cursor: pointer;
+}
+
+.donation-link:hover {
+ color: var(--primary-color-hover);
+}
+
+.donation-link:focus:not(:hover) {
+ outline: 1px dotted var(--selected-item-color);
+}
+
+.btn-link {
+ appearance: none;
+ display: flex;
+ background-color: transparent;
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ line-height: 1em;
+ align-items: center;
+ transition: color .2s;
+}
+
+.btn-link.btn-inline {
+ margin-block-end: 20px;
+}
+
+.resource-link {
+ color: var(--text-color);
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ margin-block-end: 10px;
+ margin-inline: 10px 30px;
+ transition: color .2s;
+ cursor: pointer;
+}
+
+.btn-link::before,
+.resource-link::before {
+ position: relative;
+ display: inline-block;
+ content: '';
+ background-repeat: no-repeat;
+ width: 16px;
+ height: 16px;
+ margin-inline-end: 5px;
+ vertical-align: sub;
+}
+
+.btn-link:hover,
+.resource-link:hover {
+ color: var(--primary-color-hover) !important;
+ background-color: transparent;
+}
+
+.resource-link:focus:not(:hover) {
+ outline: 1px dotted var(--selected-item-color);
+}
+
+#featuresLink::before {
+ background-image: var(--icon-features);
+}
+
+#supportLink::before {
+ background-image: var(--icon-question);
+}
+
+#involvedLink::before {
+ background-image: var(--icon-handshake);
+}
+
+#developerLink::before {
+ background-image: var(--icon-tools);
+}
+
+#settingsButton {
+ padding-inline: 3px;
+}
+
+#settingsButton::before {
+ background-image: var(--icon-account-settings);
+}
+
+#readButton::before {
+ background-image: var(--icon-inbox);
+}
+
+#composeButton::before {
+ background-image: var(--icon-pencil);
+}
+
+#searchButton::before {
+ background-image: var(--icon-search);
+}
+
+#filterButton::before {
+ background-image: var(--icon-filter);
+}
+
+#e2eButton::before {
+ background-image: var(--icon-key);
+}
diff --git a/comm/mail/themes/shared/mail/accountHub.css b/comm/mail/themes/shared/mail/accountHub.css
new file mode 100644
index 0000000000..e12b5024b8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/accountHub.css
@@ -0,0 +1,349 @@
+/* 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 url("chrome://messenger/skin/colors.css");
+@import url("chrome://messenger/skin/widgets.css");
+@import url("chrome://global/skin/in-content/common.css");
+@import url("chrome://messenger/skin/inContentDialog.css");
+@import url("chrome://messenger/skin/accountHubForms.css");
+
+dialog {
+ --hub-button-color: var(--color-gray-90);
+ --hub-button-background: var(--color-white);
+ --hub-account-button-background: transparent;
+ --hub-account-button-background-hover: var(--color-ink-10);
+ --hub-account-button-background-hover-active: var(--color-gray-10);
+ --hub-account-button-border-color: var(--color-gray-30);
+
+ --hub-input-height: 33px;
+ --hub-input-border-radius: 3px;
+}
+
+@media not (prefers-contrast) {
+ @media (prefers-color-scheme: dark) {
+ dialog {
+ --hub-button-color: var(--color-gray-05);
+ --hub-button-background: var(--color-gray-90);
+ --hub-account-button-background: var(--color-gray-70);
+ --hub-account-button-background-hover: var(--color-gray-80);
+ --hub-account-button-background-hover-active: var(--color-gray-90);
+ --hub-account-button-border-color: var(--color-gray-60);
+ }
+ }
+}
+
+@media (prefers-contrast) {
+ dialog {
+ --hub-button-color: currentColor;
+ --hub-button-background: transparent;
+ --hub-account-button-background: transparent;
+ --hub-account-button-background-hover: transparent;
+ --hub-account-button-background-hover-active: transparent;
+ --hub-account-button-border-color: AccentColor;
+ }
+}
+
+/* Dialog container */
+
+.account-hub-dialog {
+ display: grid;
+ min-width: 660px;
+ min-height: 360px;
+ max-width: 680px;
+ max-height: 70vh;
+}
+
+.account-hub-view:not([hidden]),
+.account-hub-form {
+ display: grid;
+ grid-template: "header" min-content
+ "body" minmax(auto, 1fr)
+ "footer" min-content;
+ gap: 21px;
+ text-align: center;
+}
+
+/* Typography */
+
+dialog h1 {
+ font-weight: 300;
+ font-size: 1.8rem;
+ line-height: 1em;
+ margin-block: 0;
+}
+
+dialog h1.sub-view-title {
+ font-size: 1.7rem;
+ font-weight: 400;
+ margin-block-start: 9px;
+}
+
+/* Header */
+
+.start-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 15px;
+}
+
+.start-header h1 {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+#closeButton:not([hidden]) {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ background-color: transparent;
+ --in-content-button-border-color: transparent;
+}
+
+#closeButton:hover {
+ background-color: var(--in-content-button-background);
+}
+
+#closeButton:hover:active {
+ background-color: var(--in-content-button-background-active);
+}
+
+#closeButton {
+ position: absolute;
+ inset-inline-end: 15px;
+ inset-block-start: 15px;
+ appearance: none;
+ border: none;
+ border-radius: 50%;
+ width: 21px;
+ height: 21px;
+}
+
+#closeButton:hover {
+ background-color: var(--button-hover-background-color);
+}
+#closeButton:hover:active {
+ background-color: var(--button-active-background-color);
+}
+
+#closeButton img {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ content: var(--icon-close);
+}
+
+#welcomeHeader > img {
+ width: 90px;
+ height: 90px;
+}
+
+#defaultHeader > img {
+ width: 60px;
+ height: 60px;
+}
+
+#defaultHeader .start-header-brand-name {
+ font-size: 1.3rem;
+}
+
+#defaultHeader .start-header-title {
+ font-size: 2rem;
+}
+
+#welcomeHeader h1 {
+ font-size: 1.4rem;
+ gap: 12px;
+}
+
+#welcomeHeader h1 > span {
+ font-size: 2.5rem;
+}
+
+#welcomeHeader h1 > span > small {
+ font-size: 1rem;
+ font-weight: 400;
+}
+
+.hub-header {
+ grid-area: header;
+}
+
+/* Body */
+
+.hub-body {
+ grid-area: body;
+ display: flex;
+ gap: 30px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ margin-inline: 30px;
+}
+
+.hub-body-grid {
+ display: flex;
+ gap: 21px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.button-account {
+ width: 170px;
+ min-height: 93px;
+ font-size: 1.2rem;
+ font-weight: normal;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ margin: 0;
+ padding: 12px;
+ color: var(--hub-button-color);
+ background-color: var(--hub-account-button-background);
+ border: 1px solid var(--hub-account-button-border-color);
+ box-shadow: 0 4px 6px -6px rgba(0, 0, 0, 0.3), inset 0 0 0 transparent;
+}
+
+.button-account:hover {
+ color: var(--hub-button-color) !important;
+ background-color: var(--hub-account-button-background-hover) !important;
+ box-shadow: 0 8px 12px -8px rgba(0, 0, 0, 0.3), inset 0 0 0 transparent;
+}
+
+.button-account:hover:active {
+ color: var(--hub-button-color) !important;
+ background-color: var(--hub-account-button-background-hover-active) !important;
+ box-shadow: 0 0 0 transparent, inset 0 4px 6px -2px rgba(0, 0, 0, 0.4);
+}
+
+.button-account::before {
+ display: block;
+ content: '';
+ width: 24px;
+ height: 24px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, var(--primary) 10%, transparent);
+ stroke: var(--primary);
+ background-size: 24px;
+ background-repeat: no-repeat;
+ background-position: center;
+}
+
+#emailButton::before {
+ background-image: var(--icon-mail-lg);
+}
+
+#newEmailButton::before {
+ background-image: var(--icon-new-mail);
+}
+
+#calendarButton::before {
+ background-image: var(--icon-calendar-lg);
+}
+
+#addressBookButton::before {
+ background-image: var(--icon-address-book-lg);
+}
+
+#chatButton::before {
+ background-image: var(--icon-chat-lg);
+}
+
+#feedButton::before {
+ background-image: var(--icon-rss);
+}
+
+#newsgroupButton::before {
+ background-image: var(--icon-newsletter);
+}
+
+#importButton::before {
+ background-image: var(--icon-import-lg);
+}
+
+#hubSyncButton {
+ align-self: center;
+ position: relative;
+ padding: 9px 12px;
+ font-weight: normal;
+ font-size: 1.2rem;
+ line-height: 1;
+ border: none;
+ color: var(--hub-button-color);
+ background-color: var(--hub-button-background);
+}
+
+#hubSyncButton:hover {
+ color: var(--hub-button-color);
+ background-color: var(--hub-account-button-background-hover);
+}
+
+#hubSyncButton::before {
+ content: var(--icon-account-sync);
+ display: inline-block;
+ margin-inline-end: 12px;
+ vertical-align: middle;
+}
+
+#hubSyncButton::after {
+ content: '';
+ position: absolute;
+ background: var(--color-blue-50);
+ background-image: linear-gradient(127deg, var(--color-teal-50), var(--color-magenta-50));
+ inset: -2px;
+ border-radius: 8px;
+ filter: blur(10px);
+ opacity: 0.6;
+ z-index: -1;
+ transform: scale(0.95) translateY(5px);
+}
+
+#hubSyncButton:hover::after {
+ transform: scale(0.85) translateY(8px);
+ filter: blur(15px);
+}
+
+#hubSyncButton:hover:active::after {
+ transform: scale(1) translateY(0px);
+ filter: blur(3px);
+}
+
+/* Footer */
+
+.hub-footer {
+ grid-area: footer;
+}
+
+.footer-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 3px 6px;
+ justify-content: center;
+}
+
+.footer-links li:not([hidden]) ~ li:not([hidden])::before {
+ content: "·";
+ margin-inline-end: 6px;
+}
+
+/* Animations */
+
+@media (prefers-reduced-motion: no-preference) {
+ .button-account {
+ transition: background 220ms ease, box-shadow 200ms ease;
+ }
+
+ #hubSyncButton {
+ transition: background 220ms ease;
+ }
+
+ #hubSyncButton::after {
+ transition: transform 200ms ease, filter 200ms ease;
+ }
+}
diff --git a/comm/mail/themes/shared/mail/accountHubForms.css b/comm/mail/themes/shared/mail/accountHubForms.css
new file mode 100644
index 0000000000..30887a24be
--- /dev/null
+++ b/comm/mail/themes/shared/mail/accountHubForms.css
@@ -0,0 +1,114 @@
+/* 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/. */
+
+form {
+ grid-row: header / footer;
+}
+
+form .hub-body {
+ align-items: stretch;
+ justify-content: flex-start;
+ place-self: center;
+ gap: 0;
+ width: 100%;
+ max-width: 400px;
+ text-align: start;
+ margin-inline: 0;
+}
+
+form label {
+ font-size: 1.1rem;
+ line-height: 1;
+ margin-block-end: 3px;
+}
+
+.input-control {
+ display: flex;
+ align-items: center;
+ margin-block-end: 21px;
+}
+
+.input-control.vertical {
+ flex-direction: column;
+ align-items: stretch;
+}
+
+form .input-field {
+ flex: 1;
+ margin-inline: 0;
+ padding-block: 0;
+ padding-inline-end: 33px;
+ min-height: var(--hub-input-height);
+ border-radius: var(--hub-input-border-radius);
+}
+
+.form-icon {
+ cursor: pointer;
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, fill-opacity, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: 0.7;
+ margin-inline: -26px 10px;
+}
+
+.form-toggle-button {
+ cursor: pointer;
+ appearance: none;
+ background: transparent;
+ border: none;
+ padding: 0 4px;
+ margin-inline: -30px 6px;
+ min-width: auto;
+ min-height: auto;
+ margin-block: 0;
+ line-height: 0;
+}
+
+.form-toggle-button:hover,
+.form-toggle-button:active {
+ background-color: transparent !important;
+}
+
+.form-toggle-button .form-icon {
+ pointer-events: none;
+ margin-inline: 0;
+}
+
+#password:placeholder-shown + .form-toggle-button {
+ display: none;
+}
+
+#password[type="password"] + .form-toggle-button .form-icon {
+ content: var(--icon-hidden);
+}
+
+#password[type="text"] + .form-toggle-button .form-icon {
+ content: var(--icon-eye);
+}
+
+#password[type="text"] + .form-toggle-button {
+ color: var(--in-content-primary-button-background);
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.icon-warning {
+ display: none;
+ color: var(--orange-50);
+ fill-opacity: 1;
+}
+
+input:user-invalid ~ .form-icon {
+ display: none;
+}
+
+input:user-invalid ~ .icon-warning {
+ display: inline-block;
+}
+
+.remember-button-container {
+ margin-block-start: -18px;
+}
diff --git a/comm/mail/themes/shared/mail/accountManage.css b/comm/mail/themes/shared/mail/accountManage.css
new file mode 100644
index 0000000000..40d156815f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/accountManage.css
@@ -0,0 +1,606 @@
+/* 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/. */
+
+/* ===== accountManage.css ==============================================
+ == Styles for the Mail Account Manager.
+ ======================================================================= */
+
+@import url("chrome://global/skin/global.css");
+@import url("chrome://global/skin/in-content/common.css");
+@import url("chrome://messenger/skin/preferences/preferences.css");
+@import url("chrome://messenger/skin/colors.css");
+
+@media (prefers-contrast) {
+ :root {
+ --in-content-accent-color: var(--selected-item-color);
+ }
+}
+
+#containerBox {
+ width: 100%;
+ max-width: 800px;
+ padding-block: 40px;
+ padding-inline: 24px 28px;
+}
+
+fieldset {
+ margin: 0 0 32px;
+ padding: initial;
+ border-style: none;
+}
+
+fieldset:last-of-type {
+ margin-bottom: 0;
+}
+
+.openpgp-more-btn > menupopup {
+ appearance: none;
+ font-size: 1em;
+ --panel-border-color: var(--in-content-box-border-color);
+ --panel-border-radius: 2px;
+ --panel-background: var(--in-content-box-background);
+ --panel-color: var(--in-content-text-color);
+ --panel-padding: 0;
+}
+
+.openpgp-more-btn > menupopup > menuitem {
+ appearance: none;
+ color: var(--in-content-text-color);
+ padding-block: 0.2em;
+ padding-inline: 10px 30px;
+}
+
+.openpgp-more-btn > menupopup > menuitem[_moz-menuactive="true"] {
+ color: var(--in-content-item-hover-text);
+ background-color: var(--in-content-item-hover);
+}
+
+.openpgp-more-btn > menupopup > menuitem[selected="true"] {
+ color: var(--in-content-item-selected-text);
+ background-color: var(--in-content-item-selected);
+}
+
+.openpgp-more-btn > menupopup > menuseparator {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--in-content-box-border-color);
+ border-bottom: none;
+}
+
+#archiveHierarchyButton,
+#globalJunkPrefsLink {
+ margin-inline-end: 8px;
+}
+
+#archiveTree > treechildren::-moz-tree-image {
+ -moz-context-properties: fill, fill-opacity, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/* ::::: account manager :::::: */
+
+.header {
+ font-size: 1.1em;
+ font-weight: 600;
+ line-height: 1.4em;
+ margin-block: 16px 4px;
+ padding-bottom: 0;
+}
+
+.input-container:not([hidden="true"]) {
+ display: flex;
+ align-items: center;
+}
+
+.input-container.container-column:not([hidden="true"]) {
+ flex-direction: column;
+}
+
+.input-container:not([hidden="true"]) > .input-inline {
+ flex: 1;
+}
+
+menulist.input-inline {
+ margin: 2px 4px;
+}
+
+.identity-table {
+ margin-inline-end: 14px;
+}
+
+menupopup[is="folder-menupopup"] {
+ appearance: none;
+}
+
+menulist > menupopup menu,
+menulist > menupopup menuitem {
+ padding-inline-end: 5px;
+}
+
+/* Needed for additional menupopup levels */
+menulist > menupopup menupopup {
+ font: inherit;
+}
+
+menulist > menupopup menupopup > menu,
+menulist > menupopup menupopup > menuitem {
+ border: 1px solid transparent;
+}
+
+menulist > menupopup menupopup > menu:not([disabled="true"])[_moz-menuactive="true"],
+menulist > menupopup menupopup > menuitem:not([disabled="true"])[_moz-menuactive="true"] {
+ color: var(--in-content-text-color);
+ background-color: var(--in-content-item-hover);
+}
+
+menulist > menupopup menupopup > menu[disabled="true"],
+menulist > menupopup menupopup > menuitem[disabled="true"] {
+ color: #999;
+ /* override the [_moz-menuactive="true"] background color from
+ global/menu.css */
+ background-color: transparent;
+}
+
+menulist > menupopup .menu-right {
+ appearance: none;
+ -moz-context-properties: fill;
+ list-style-image: url("chrome://global/skin/icons/arrow-left-12.svg");
+ fill: currentColor;
+}
+
+menulist > menupopup .menu-right:-moz-locale-dir(ltr) {
+ transform: scaleX(-1);
+}
+
+menulist > menupopup menupopup menuseparator {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--in-content-box-border-color);
+ border-bottom: none;
+}
+
+.specialFolderPickerGrid {
+ margin-inline-start: 20px;
+}
+
+.fccReplyFollowsParent {
+ margin-inline-start: 20px;
+}
+
+.signatureBox {
+ font-family: -moz-fixed;
+}
+
+richlistitem[default="true"],
+#identitiesList > richlistitem:first-child {
+ font-weight: bold;
+}
+
+.label-inline {
+ margin-block: auto;
+ min-width: 200px;
+}
+
+.option-description {
+ margin-inline-start: 30px;
+ margin-block-start: 3px;
+}
+
+#defaultPort,
+#servertypeVerbose,
+#identity\.htmlSigFormat {
+ margin-inline-start: 4px;
+}
+
+#identity\.signature {
+ margin-block: 4px;
+}
+
+#autosyncNotDownload {
+ margin-inline-end: 12px;
+}
+
+#whiteListAbURI richlistitem {
+ padding-inline-start: 10px;
+}
+
+#whiteListAbURI checkbox {
+ -moz-user-focus: none;
+}
+
+#identitiesList richlistitem {
+ align-items: center;
+ padding-inline-start: 10px;
+ height: 34px;
+}
+
+#identityDialog.doScroll {
+ margin-inline: 0;
+}
+
+#identityDialog.doScroll::part(content-box) {
+ contain: initial;
+}
+
+#identityTabsPanels > vbox {
+ overflow-y: auto;
+ height: calc(100vh - 7em); /* Don't include the tabbar and buttons. */
+}
+
+/* ::::: Server Settings :::::: */
+
+#amServerSetting {
+ display: inline-grid;
+ grid-template-columns: max-content 1fr auto;
+}
+
+#amServerSetting div:not([hidden]) {
+ /* Do not override display: none when hidden. */
+ display: flex;
+ align-items: center;
+}
+
+#amServerSetting .input-flex {
+ flex-grow: 1;
+ width: 14ch;
+}
+
+/* ::::: SMTP Server Panel :::::: */
+
+.smtpServerListItem {
+ align-items: center;
+ padding-inline-start: 10px;
+ height: 34px;
+}
+
+#smtpServerInfoBox {
+ padding: 9px;
+ border: 1px solid var(--in-content-border-color);
+ border-radius: 4px;
+ border-spacing: 0;
+ background-color: rgba(215, 215, 219, 0.2);
+}
+
+#smtpServerInfoBox th {
+ height: 1.7em;
+ padding: 0;
+ text-align: end;
+ width: 10%;
+ white-space: nowrap;
+}
+
+#smtpServerInfoBox td {
+ padding: 0;
+ padding-inline-start: 8px;
+}
+
+/* ::::: dialog header ::::: */
+
+.dialogheader-title {
+ margin-block: 0 8px;
+ margin-inline-start: 0;
+ font-size: 1.46em;
+ font-weight: 300;
+ line-height: 1.3em;
+ color: var(--in-content-text-color);
+}
+
+.identity-table th {
+ font-weight: normal;
+ text-align: left;
+}
+
+.identity-table td {
+ padding-inline-end: 10px;
+}
+
+.identity-table td input {
+ width: 100%;
+}
+
+/* ::::: e2e encryption ::::: */
+
+#openPgpKey {
+ -moz-context-properties: fill, stroke, fill-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: 0.5;
+ width: 48px;
+ height: 48px;
+ margin-inline-end: 10px;
+}
+
+/* Add a bit of space to the end of descriptions to
+ * leave margin with e.g. additional buttons on the side. */
+.description-with-side-element {
+ margin-inline-end: 10px !important;
+}
+
+.openpgp-description p {
+ margin-block: 0 6px;
+}
+
+.openpgp-status {
+ vertical-align: text-top;
+ -moz-context-properties: fill, stroke;
+ margin-inline-end: 2px;
+ width: 16px;
+}
+
+.openpgp-status.status-success {
+ fill: color-mix(in srgb, var(--color-green-50) 20%, transparent);
+ stroke: var(--color-green-50);
+}
+
+.openpgp-status.status-error {
+ fill: color-mix(in srgb, var(--color-red-50) 20%, transparent);
+ stroke: var(--color-red-50);
+}
+
+/* ::::: OpenPGP key selection ::::: */
+
+.openpgp-container {
+ margin-top: 10px;
+}
+
+.opengpg-intro-section {
+ margin-bottom: 10px;
+}
+
+#openPgpKeyList {
+ margin-top: 16px;
+}
+
+.content-blocking-category .checkbox-label-box,
+.extra-information-label > img {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.content-blocking-category {
+ border-radius: 4px;
+ margin: 3px 0;
+ padding: 9px;
+ border: 1px solid var(--in-content-border-color);
+ background-color: rgba(215, 215, 219, 0.2);
+}
+
+.content-blocking-category.disabled {
+ opacity: 0.5;
+}
+
+.content-blocking-category.disabled .radio-check {
+ opacity: 1;
+}
+
+.content-blocking-warning > .indent,
+.content-blocking-category > .indent {
+ margin-inline-end: 28px;
+ margin-inline-start: 30px;
+}
+
+.arrowhead {
+ appearance: none;
+ border: none;
+ border-radius: 2px;
+ min-height: 20px;
+ min-width: 20px;
+ max-height: 20px;
+ max-width: 20px;
+ list-style-image: url("chrome://messenger/skin/icons/new/nav-down-sm.svg");
+ background-color: transparent;
+ padding: 3px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.arrowhead:not([disabled]):hover {
+ cursor: pointer;
+ background-color: var(--grey-90-a10);
+}
+
+.arrowhead:not([disabled]):hover:active {
+ background-color: var(--grey-90-a20);
+}
+
+.arrowhead.up {
+ list-style-image: url("chrome://messenger/skin/icons/new/nav-up-sm.svg");
+}
+
+.content-blocking-category.expanded:not(.selected) .content-blocking-warning {
+ background-color: var(--grey-90-a10);
+}
+
+.content-blocking-category.selected {
+ border: 1px solid #45A1FF;
+ background-color: rgba(69, 161, 255, 0.2);
+}
+
+.content-blocking-warning-title,
+.content-blocking-category .radio-label-box {
+ font-weight: bold;
+}
+
+.content-blocking-extra-information {
+ visibility: collapse;
+}
+
+.extra-information-label {
+ display: grid;
+ grid-template-columns: auto max-content 1fr;
+ row-gap: 10px;
+ align-items: center;
+ margin-block: 18px;
+}
+
+/* Apply display: block to the containers of all the category information, as
+ * without this the flex-wrapped blocks inside don't stretch vertically to
+ * enclose their content. */
+.content-blocking-category > .indent {
+ display: block;
+}
+
+.content-blocking-category.expanded .content-blocking-extra-information {
+ visibility: visible;
+ display: flex;
+ flex-direction: column;
+ align-content: stretch;
+ margin-bottom: 10px;
+}
+
+.content-blocking-extra-information > .custom-option {
+ margin: 10px 0;
+}
+
+.content-blocking-warning {
+ background-color: rgba(69, 161, 255, 0.2);
+ border-radius: 4px;
+ padding: 10px 0;
+ margin: 10px 0;
+}
+
+.content-blocking-warning:not([hidden]) + .content-blocking-warning {
+ margin-top: 0;
+}
+
+.content-blocking-category-description {
+ font-size: 90%;
+ opacity: 0.6;
+}
+
+.expiration-date-icon {
+ vertical-align: text-top;
+ -moz-context-properties: fill, stroke;
+ margin-inline-end: 4px;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ width: 16px;
+}
+
+.expiration-date-icon:not([src]) {
+ display: none;
+}
+
+.expiration-date-container.key-expired description {
+ color: var(--color-red-50);
+ font-weight: 600;
+}
+
+.expiration-date-container.key-expired .expiration-date-icon {
+ fill: color-mix(in srgb, var(--color-amber-50) 20%, transparent);
+ stroke: var(--color-amber-50);
+}
+
+.first-element {
+ margin-inline-start: 0;
+}
+
+.last-element {
+ margin-inline-end: 0;
+}
+
+.button-small {
+ margin-inline-start: 8px;
+ font-size: 0.9em;
+ min-height: 28px;
+ margin-block: 0;
+}
+
+.extra-information-label-type {
+ font-weight: 600;
+ margin-inline-end: 4px;
+}
+
+/* Key info icons */
+.extra-information-label > img {
+ margin-inline-end: 5px;
+}
+
+.openpgp-key-details {
+ margin-bottom: 18px;
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 4px;
+ overflow: hidden;
+ background-color: var(--in-content-page-background);
+}
+
+.openpgp-key-details tabs {
+ border-top: none;
+}
+
+.openpgp-key-details tabpanels {
+ padding: 0 10px 18px;
+}
+
+.openpgp-image-btn .button-icon {
+ margin-inline-end: 4px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.openpgp-add-key-button {
+ list-style-image: var(--icon-new-key);
+}
+
+.openpgp-props-btn {
+ list-style-image: var(--icon-tools);
+}
+
+.openpgp-more-btn .button-menu-dropmarker {
+ list-style-image: url("chrome://messenger/skin/icons/new/nav-down-sm.svg");
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.intro-paragraph {
+ margin-block: 0 6px;
+}
+
+.external-pill {
+ background-color: #4576B6;
+ color: #fff;
+ font-weight: 600;
+ font-size: 0.9em;
+ padding: 1px 6px;
+ border-radius: 12px;
+}
+
+.input-container > .plain {
+ flex: 1;
+ background-color: transparent;
+ border-style: none;
+ box-shadow: none !important;
+ outline: none;
+ padding-block: 0;
+}
+
+.chat-encryption-status {
+ margin: 0;
+ padding: 0;
+}
+
+#protocolIcon {
+ margin-inline-end: 6px;
+}
+
+.chat-encryption-sessions {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+
+.chat-encryption-sessions li {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.chat-current-session > span {
+ font-weight: bold;
+}
diff --git a/comm/mail/themes/shared/mail/accountManager.css b/comm/mail/themes/shared/mail/accountManager.css
new file mode 100644
index 0000000000..ead8c232aa
--- /dev/null
+++ b/comm/mail/themes/shared/mail/accountManager.css
@@ -0,0 +1,305 @@
+/* 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/. */
+
+/* ===== accountManage.css ==============================================
+ == Styles for the Mail Account Manager.
+ ======================================================================= */
+
+@import url("chrome://global/skin/global.css");
+@import url("chrome://global/skin/in-content/common.css");
+@import url("chrome://messenger/skin/preferences/preferences.css");
+
+@media (prefers-contrast) {
+ :root {
+ --in-content-accent-color: var(--selected-item-color);
+ }
+}
+
+html {
+ height: 100%;
+}
+
+body {
+ height: 100%;
+ display: grid;
+ grid-template-columns: min-content auto;
+}
+
+#accountTreeBox {
+ display: flex;
+ flex-direction: column;
+ max-width: 25em;
+ min-width: 18em;
+ height: 100vh;
+ box-sizing: border-box;
+ padding-top: 40px;
+ background-color: var(--in-content-categories-background);
+ border-inline-end: 1px solid var(--in-content-categories-border);
+}
+
+#accountTreeBox:-moz-locale-dir(rtl) {
+ background-image: linear-gradient(to right, transparent, transparent 3px,
+ var(--in-content-categories-background) 3px);
+}
+
+/* Account list */
+
+#accounttree {
+ flex-grow: 1;
+ overflow-y: auto;
+}
+
+#accounttree:focus-visible {
+ outline: none;
+}
+
+#accounttree, #accounttree ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+#accounttree li {
+ transition: opacity 250ms;
+}
+
+#accounttree li > div {
+ display: flex;
+ align-items: center;
+ -moz-context-properties: fill;
+ margin-inline: 6px;
+ border-radius: var(--in-content-button-border-radius);
+ fill: currentColor;
+ cursor: default;
+}
+
+#accounttree li.selected > div {
+ background-color: var(--in-content-button-background);
+}
+
+#accounttree li > div:hover {
+ background-color: var(--in-content-item-hover);
+ color: var(--in-content-item-hover-text);
+}
+
+#accounttree:focus li.selected > div {
+ background-color: var(--in-content-item-selected);
+ color: var(--in-content-item-selected-text);
+}
+
+#accounttree li .twisty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--list-item-buttons-size);
+ height: var(--list-item-buttons-size);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+#accounttree li:not(.children) .twisty-icon {
+ display: none;
+}
+
+#accounttree li.children.collapsed .twisty-icon {
+ transform: rotate(-90deg);
+}
+
+#accounttree li.children.collapsed:dir(rtl) .twisty-icon {
+ transform: rotate(90deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ li .twisty-icon {
+ transition: transform 200ms ease;
+ }
+}
+
+#accounttree li div.icon {
+ width: 16px;
+ height: 16px;
+ background-image: var(--icon-mail);
+ background-position: center center;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#accounttree li.serverType-imap.isSecure div.icon,
+#accounttree li.serverType-pop3.isSecure div.icon {
+ background-image: var(--icon-mail-secure);
+}
+
+#accounttree li.serverType-feeds div.icon,
+#accounttree li.serverType-rss div.icon {
+ list-style-image: var(--icon-rss);
+}
+
+#accounttree li.serverType-im div.icon {
+ background-image: var(--spaces-icon-chat);
+}
+
+#accounttree li.serverType-news div.icon {
+ background-image: var(--icon-newsletter);
+}
+
+#accounttree li.serverType-nntp div.icon {
+ background-image: var(--icon-globe);
+}
+
+#accounttree li.serverType-nntp.isSecure div.icon {
+ background-image: var(--icon-globe-secure);
+}
+
+#accounttree li.serverType-none div.icon {
+ background-image: var(--icon-folder);
+}
+
+#accounttree li.serverType-smtp div.icon {
+ background-image: var(--icon-outbox);
+}
+
+#accounttree li .name {
+ flex: 1;
+ margin-inline: 7px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+}
+
+#accounttree > li > div > .name {
+ font-weight: 500;
+}
+
+#accounttree > li.isDefaultServer > div > .name {
+ text-decoration: underline;
+}
+
+#accounttree ul > li > div {
+ padding-inline-start: 42px;
+}
+
+#accounttree ul {
+ overflow: hidden;
+ height: auto;
+}
+
+#accounttree li ul:empty,
+#accounttree li.collapsed ul {
+ height: 0;
+}
+
+#accounttree li.dragging {
+ opacity: 0.75;
+}
+
+/* Styles for the Account Actions button */
+
+#accountActionsButton {
+ margin: 6px;
+}
+
+#accountActionsDropdown {
+ appearance: none;
+ font-size: 1em;
+ --panel-border-color: var(--in-content-box-border-color);
+ --panel-border-radius: 2px;
+ --panel-background: var(--in-content-box-background);
+ --panel-color: var(--in-content-text-color);
+ --panel-padding: 0;
+}
+
+#accountActionsDropdown > menuitem {
+ appearance: none;
+ color: var(--in-content-text-color);
+ padding-block: 0.2em;
+ padding-inline: 10px 30px;
+}
+
+#accountActionsDropdown > menuitem:not([disabled="true"])[_moz-menuactive="true"] {
+ color: var(--in-content-item-hover-text);
+ background-color: var(--in-content-item-hover);
+}
+
+#accountActionsDropdown > menuitem:not([disabled="true"])[selected="true"] {
+ color: var(--in-content-item-selected-text);
+ background-color: var(--in-content-item-selected);
+}
+
+#accountActionsDropdown > menuitem[disabled="true"] {
+ color: #999;
+ /* override the [_moz-menuactive="true"] background color from
+ global/menu.css */
+ background-color: transparent;
+}
+
+#accountActionsDropdown > menuseparator {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--in-content-box-border-color);
+ border-bottom: none;
+}
+
+#accountActionsButton > .button-box > .button-menu-dropmarker {
+ appearance: none;
+ display: flex;
+ list-style-image: url("chrome://global/skin/icons/arrow-down-12.svg");
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 12px;
+ height: 12px;
+}
+
+.sidebar-footer-list {
+ margin-top: 24px;
+ margin-inline: 0;
+}
+
+.sidebar-footer-link {
+ margin-inline: 6px;
+ border-radius: var(--in-content-button-border-radius);
+}
+
+#contentFrame {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+#dialogStack {
+ position: absolute;
+ inset: 0;
+}
+
+#editVCardDialog {
+ /* Two <vcard-edit> columns. */
+ width: 64em;
+}
+
+#editVCardDialog form {
+ display: flex;
+ flex-direction: column;
+}
+
+#editVCardDialog #vCardDisplayNameCheckbox {
+ display: none;
+}
+
+@media (max-width: 830px) {
+ .sidebar-footer-list {
+ align-items: unset;
+ margin-inline-start: unset;
+ }
+
+ .sidebar-footer-link {
+ width: unset;
+ height: unset;
+ }
+
+ .sidebar-footer-label {
+ display: inline-block;
+ }
+}
diff --git a/comm/mail/themes/shared/mail/accountSetup.css b/comm/mail/themes/shared/mail/accountSetup.css
new file mode 100644
index 0000000000..a91c73f4a2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/accountSetup.css
@@ -0,0 +1,1021 @@
+/* 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 url("chrome://global/skin/in-content/common.css");
+@import url("chrome://messenger/skin/preferences/preferences.css");
+
+:root {
+ --addon-bg: #f8f8f8;
+ --addon-border: #ccc;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --addon-bg: #333;
+ --addon-border: #111;
+ }
+}
+
+:root,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ width: 100%;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: auto;
+ background-color: rgba(0, 0, 0, 0.03);
+}
+
+header {
+ margin: 3rem 4rem;
+}
+
+.title {
+ font-size: 2.2rem;
+}
+
+.title.success {
+ padding-inline-start: 24px;
+ background: var(--icon-check) 0 center no-repeat;
+ background-size: 22px;
+ -moz-context-properties: stroke;
+ stroke: var(--color-green-50);
+}
+
+.title.success:dir(rtl) {
+ background-position-x: right;
+}
+
+.description {
+ line-height: 1.45em;
+ margin-block-end: 0;
+}
+
+.description + .description {
+ margin-block-start: 0;
+ margin-block-end: 1em;
+}
+
+.main-container {
+ max-width: 80rem;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ margin: 0 4rem 3rem;
+}
+
+.column {
+ flex: 1;
+ min-width: 400px;
+}
+
+.first-column {
+ max-width: 425px;
+}
+
+.column-wide {
+ max-width: 525px;
+}
+
+.second-column {
+ display: flex;
+ justify-content: center;
+ text-align: center;
+}
+
+@media (max-width: 57rem) {
+ .second-column {
+ max-width: 425px;
+ margin-top: 2rem;
+ }
+
+ .second-column img {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .second-column article {
+ transition: opacity .3s ease, transform .3s ease;
+ }
+
+ .second-column article.hide {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+}
+
+.second-column article p {
+ max-width: 40rem;
+ padding-inline: 4rem;
+}
+
+#form {
+ display: flex;
+ flex-direction: column;
+}
+
+#form label,
+#calendarDialog label {
+ font-size: 0.9em;
+ line-height: 1em;
+ margin-block-end: 3px;
+}
+
+.input-control {
+ display: flex;
+ align-items: center;
+ margin-block-end: 21px;
+}
+
+.input-control.vertical {
+ flex-direction: column;
+ align-items: stretch;
+}
+
+#form .input-field {
+ flex: 1;
+ font-size: 0.9em;
+ margin-inline: 0;
+ padding-block: 0;
+ padding-inline-end: 33px;
+ min-height: var(--in-content-button-height);
+ border-radius: var(--in-content-button-border-radius);
+}
+
+.form-icon {
+ cursor: pointer;
+ -moz-context-properties: fill, fill-opacity, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: 0.7;
+ margin-inline: -26px 10px;
+}
+
+.form-toggle-button {
+ appearance: none;
+ background: transparent;
+ border: none;
+ padding: 0 4px;
+ margin-inline: -30px 6px;
+ min-width: auto;
+ min-height: auto;
+ margin-block: 0;
+ line-height: 0;
+}
+
+.form-toggle-button:hover,
+.form-toggle-button:active {
+ background-color: transparent !important;
+}
+
+.form-toggle-button .form-icon {
+ margin-inline: 0;
+}
+
+.icon-warning {
+ display: none;
+ color: var(--orange-50);
+ fill-opacity: 1;
+}
+
+input:user-invalid ~ .form-icon {
+ display: none;
+}
+
+input:user-invalid ~ .icon-warning {
+ display: inline-block;
+}
+
+.provisioner-button-container {
+ display: flex;
+ justify-content: end;
+}
+
+.btn-link {
+ appearance: none;
+ background-color: transparent !important;
+ color: var(--in-content-link-color) !important;
+ border-style: none;
+ padding: 0 3px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ min-height: auto;
+ border-radius: var(--in-content-button-border-radius);
+}
+
+.btn-link-new-email {
+ margin: -18px 0 2px;
+}
+
+.btn-link:hover {
+ color: var(--in-content-link-color-hover) !important;
+ text-decoration: underline;
+}
+
+.btn-link:focus-visible {
+ outline-offset: 1px;
+}
+
+.btn-link[hidden] {
+ margin: 0;
+}
+
+.password-toggled {
+ color: var(--in-content-primary-button-background);
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.remember-button-container {
+ display: flex;
+ justify-content: start;
+ margin-block: -15px 24px;
+}
+
+.action-buttons-container {
+ display: flex;
+ justify-content: space-between;
+ margin-inline: -6px;
+}
+
+.action-buttons-container aside {
+ display: flex;
+ align-items: center;
+}
+
+.action-buttons-container button {
+ border-radius: var(--in-content-button-border-radius);
+}
+
+.account-setup-notifications {
+ display: flex;
+ flex-direction: column;
+ margin-inline: -4px;
+}
+
+.account-setup-notifications .notificationbox-stack {
+ margin-block-end: 15px;
+ background-color: transparent;
+}
+
+/* Results area */
+
+#resultsArea:not([hidden]) {
+ display: flex;
+ flex-direction: column;
+ margin-block-end: 15px;
+}
+
+.section-title {
+ margin-block: 0 12px;
+}
+
+.autoconfig-note {
+ margin-block: 10px 0;
+ font-size: 1rem;
+}
+
+.content-blocking-category {
+ border-radius: 4px;
+ margin: 3px 0;
+ padding: 9px;
+ border: 1px solid var(--in-content-border-color);
+ background-color: rgba(215, 215, 219, 0.2);
+}
+
+.content-blocking-category.selected {
+ border: 1px solid #45A1FF;
+ background-color: rgba(69, 161, 255, 0.2);
+}
+
+.content-blocking-category.selected .result-details {
+ display: flex;
+}
+
+.results-option:not([hidden]) {
+ display: grid;
+ grid-template-columns: auto auto 1fr;
+ column-gap: 3px;
+}
+
+.results-option .toggle-container-with-text,
+.result-details,
+.result-details-row {
+ display: contents;
+}
+
+.results-option .toggle-container-with-text span,
+.result-indent,
+.result-details {
+ grid-column: 2 / 4;
+}
+
+.result-details {
+ display: none;
+ flex-direction: column;
+ font-size: 0.9em;
+ row-gap: 9px;
+ margin-block: 9px;
+}
+
+.result-details-row {
+ display: grid;
+ grid-template: "icon heading" auto
+ ". info" auto / auto 1fr;
+ gap: 3px 6px;
+}
+
+.result-details-row img {
+ grid-area: icon;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ color: inherit;
+ width: 16px;
+ height: 16px;
+}
+
+.result-details-title {
+ grid-area: heading;
+ display: flex;
+ align-items: baseline;
+ gap: 3px;
+}
+
+.result-details-title h4 {
+ margin-block: 0;
+}
+
+.result-details-row .result-host-info {
+ grid-area: info;
+}
+
+.results-option .toggle-container-with-text span {
+ line-height: 1.4em;
+}
+
+.strong {
+ font-weight: 600;
+}
+
+.result-indent {
+ margin-block: 0;
+}
+
+/* Confirmation dialog */
+
+.account-setup-dialog {
+ max-width: 500px;
+}
+
+/* Insecure dialog */
+
+.account-setup-dialog.dialog-critical {
+ max-width: 700px;
+}
+
+/* Manual config area */
+
+#manualConfigArea {
+ margin-block-end: 15px;
+}
+
+#manualConfigArea select,
+#manualConfigArea input:not([type="radio"],[type="checkbox"]) {
+ margin: 0;
+ width: 4em;
+ flex: 1;
+}
+
+#manualConfigArea select {
+ padding-inline-start: 6px;
+ padding-block: 0;
+}
+
+#manualConfigArea select:not([size], [multiple]) {
+ background-position-x: right 10px;
+}
+
+#manualConfigArea select:not([size], [multiple]):dir(rtl) {
+ background-position-x: left 10px;
+}
+
+#manualConfigArea select > option {
+ padding-inline-start: 11px;
+}
+
+#manualConfigArea legend {
+ margin-top: 0;
+ background-color: var(--in-content-primary-button-background);
+ border-radius: var(--in-content-button-border-radius);
+ padding: 2px 6px;
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ color: var(--in-content-primary-button-text-color);
+}
+
+#manualConfigArea input[type="number"] {
+ width: calc(2ch + 60px);
+ text-align: end;
+}
+
+.manual-config-grid {
+ display: grid;
+ row-gap: 12px;
+}
+
+.manual-config-grid ~ .manual-config-grid {
+ margin-top: 15px;
+}
+
+.manual-config-grid aside {
+ display: grid;
+ grid-template-columns: 40% 1fr;
+ column-gap: 6px;
+ align-items: center;
+}
+
+.manual-config-grid .input-control {
+ align-items: initial;
+ margin-block-end: 0;
+}
+
+.manual-config-two-columns {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ column-gap: 12px;
+}
+
+.option-label {
+ font-size: 1.05rem;
+ line-height: 1em;
+ font-weight: 500;
+}
+
+.link-row {
+ display: flex;
+ justify-content: end;
+}
+
+#outgoingProtocol {
+ display: flex;
+ height: 100%;
+ align-items: center;
+ font-weight: 500;
+ margin-inline: 4px;
+}
+
+.foot-note {
+ line-height: 1.5em;
+ font-size: 1rem;
+ margin-block-start: 21px;
+}
+
+/* Result area */
+
+.result-host-info {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.protocol-type {
+ display: inline-block;
+ text-transform: uppercase;
+ padding: 1px 4px;
+ font-size: 0.8rem;
+ font-weight: bold;
+ border-radius: 2px;
+ background-color: var(--in-content-primary-button-background);
+ color: var(--in-content-primary-button-text-color);
+}
+
+.protocol-type.insecure {
+ background-color: var(--red-70);
+ color: var(--in-content-primary-button-text-color);
+}
+
+.result-host-info > span {
+ margin-inline: 0;
+}
+
+.result-host-info > .domain {
+ font-weight: bold;
+}
+
+.cert-status.insecure {
+ margin: 0 0 5px 0;
+ color: var(--red-70);
+}
+
+#installAddonInfo {
+ background-color: var(--addon-bg);
+ padding: 3px 6px;
+ border-radius: var(--in-content-button-border-radius);
+ border: 1px solid var(--addon-border);
+}
+
+#resultAddonIntro {
+ margin-block: 3px 12px;
+}
+
+#resultAddonInstallRows .icon {
+ width: 32px;
+ height: 32px;
+ margin-inline-end: 6px;
+}
+
+.addon-container {
+ display: flex;
+ align-items: center;
+}
+
+.link {
+ flex: 1;
+ line-height: 1.2em;
+}
+
+input[disabled],
+select[disabled] {
+ opacity: 0.5;
+}
+
+/* Success view */
+
+.success-column:not([hidden]) {
+ display: flex;
+ flex-direction: column;
+}
+
+.account-success-block {
+ display: grid;
+ grid-template-columns: min-content 1fr min-content;
+ align-items: center;
+ color: inherit;
+ background-color: var(--in-content-box-background);
+ box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);
+ column-gap: 6px;
+ padding: 3px 9px;
+ border-radius: var(--in-content-button-border-radius);
+ min-height: 39px;
+ line-height: 1em;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.account-type-image {
+ color: var(--in-content-link-color);
+}
+
+.linked-services-button {
+ display: flex;
+ align-items: center;
+ background-color: transparent;
+ border-width: 0;
+ appearance: none;
+ margin: 0;
+ padding-inline: 0;
+ width: 100%;
+ height: auto;
+}
+
+button.linked-services-button:hover {
+ background-color: transparent;
+ color: var(--in-content-accent-color);
+ cursor: pointer;
+}
+
+button.linked-services-button:hover:active {
+ background-color: transparent;
+ border-color: transparent;
+}
+
+.linked-services-button > aside {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ flex: 1;
+ padding: 3px;
+}
+
+.linked-services-description {
+ font-size: 0.9em;
+ margin-block: 0;
+ text-align: start;
+ color: var(--in-content-deemphasized-text);
+}
+
+.account-name {
+ margin-block-end: 3px;
+ font-weight: 500;
+}
+
+.account-email {
+ font-size: 0.9em;
+ color: var(--in-content-link-color);
+ font-weight: 500;
+}
+
+.quick-links {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-around;
+ margin: 12px 21px 27px;
+}
+
+.quick-link {
+ appearance: none;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ border: none;
+ padding: 6px;
+ padding-inline-start: 27px;
+ margin: 6px 3px;
+ line-height: 1em;
+ background-position: 3px center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ background-color: transparent;
+ min-height: auto;
+ border-radius: 0;
+ cursor: pointer;
+ text-align: start;
+}
+
+.quick-link:dir(rtl) {
+ background-position-x: right 3px;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .quick-link {
+ transition: color .2s ease;
+ }
+}
+
+button.quick-link:hover,
+button.quick-link:hover:active {
+ background-color: transparent;
+ color: var(--in-content-link-color);
+}
+
+#settingsButton {
+ background-image: var(--icon-settings);
+}
+
+#signatureButton {
+ background-image: var(--icon-pencil);
+}
+
+#encryptionButton {
+ background-image: var(--icon-key);
+}
+
+#dictionariesButton {
+ background-image: var(--icon-download);
+}
+
+#addressBookCardDAVButton,
+#addressBookLDAPButton {
+ background-image: var(--icon-new-address-book);
+ align-self: start;
+}
+
+#createCalendarButton {
+ background-image: var(--icon-new-event);
+ align-self: start;
+}
+
+#linkedServices h3 {
+ font-size: 1.6rem;
+ font-weight: 300;
+ margin-bottom: 0;
+}
+
+#linkedServices p.tip-caption {
+ margin-top: 9px;
+ margin-bottom: 0;
+}
+
+.services-buttons-container {
+ display: flex;
+ flex-direction: column;
+}
+
+.linked-services-container:not([hidden]) {
+ display: flex;
+ flex-direction: column;
+ margin-block-start: 6px;
+}
+
+.linked-services-section {
+ margin-top: 18px;
+}
+
+.linked-services-list {
+ list-style: none;
+ padding-inline: 0 3px;
+ margin-block: 0;
+ max-height: 15em;
+ overflow: auto;
+}
+
+.linked-services-list li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-inline-start: 4px;
+ border-radius: var(--in-content-button-border-radius);
+}
+
+.linked-services-list button.small-button {
+ line-height: 0.9em;
+ padding-block: 0;
+}
+
+.list-item-name {
+ flex: 1;
+ padding-inline: 6px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+.self-center {
+ align-self: center;
+}
+
+.final-buttons-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ margin-block: 18px 12px;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .linked-service-dropdown {
+ transition: transform .2s ease;
+ }
+}
+
+.opened .linked-service-dropdown {
+ transform: rotate(90deg);
+}
+
+.linked-service-dropdown:dir(rtl) {
+ transform: rotate(180deg);
+}
+
+.linked-service-dropdown img {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+button.existing {
+ background: var(--icon-check) 0 center no-repeat;
+ background-size: 16px;
+ -moz-context-properties: stroke;
+ stroke: var(--color-green-50);
+ opacity: 1;
+ padding-inline-start: 18px;
+}
+
+button.existing:dir(rtl) {
+ background-position-x: right;
+}
+
+#calendarDialog {
+ width: 100%;
+}
+
+.calendar-dialog-form {
+ display: grid;
+ grid-template-columns: max-content auto;
+ align-items: center;
+ row-gap: 3px;
+ column-gap: 3px;
+ margin-block-end: 21px;
+}
+
+.input-grow {
+ flex: 1;
+}
+
+#calendarDialog .calendar-dialog-form label,
+.calendar-dialog-form .input-control {
+ margin-block-end: 0;
+}
+
+.calendar-dialog-form select,
+.calendar-dialog-form input[type="color"] {
+ margin-inline: 4px;
+}
+
+.calendar-dialog-form select {
+ padding-inline-start: 9px;
+ padding-block: 0;
+ line-height: var(--in-content-button-height);
+}
+
+.calendar-dialog-form select:not([size], [multiple]) {
+ background-position-x: right 10px;
+}
+
+.calendar-dialog-form select:not([size], [multiple]):dir(rtl) {
+ background-position-x: left 10px;
+}
+
+.calendar-dialog-form select > option {
+ padding-inline-start: 11px;
+}
+
+
+/* Account Provisioner variations */
+
+#backButton {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ background-image: var(--icon-nav-back);
+ background-repeat: no-repeat;
+ background-position-x: 6px;
+ background-position-y: center;
+ padding-inline-start: 25px;
+ background-size: 16px;
+}
+
+#backButton:dir(rtl) {
+ background-image: var(--icon-nav-forward);
+ background-position-x: right 6px;
+}
+
+.service-title {
+ margin-top: 0;
+ font-size: 1.1em;
+ font-weight: 600;
+}
+
+.service-description {
+ font-size: 0.9em;
+ line-height: 1.3em;
+ color: var(--in-content-deemphasized-text);
+}
+
+.service-form {
+ margin-bottom: 30px;
+}
+
+.service-form-container {
+ display: flex;
+ align-items: center;
+}
+
+.service-form-container input {
+ flex: 1;
+ margin-inline: 0 10px;
+}
+
+.service-form-container button {
+ margin-inline: 0;
+}
+
+.providers-list {
+ margin: 10px 0 40px;
+ padding: 0;
+ list-style: none;
+ display: flex;
+}
+
+.providers-list li {
+ margin-inline: 6px;
+ display: flex;
+ align-items: center;
+}
+
+.providers-list img {
+ width: 26px;
+ margin-inline-end: 6px;
+}
+
+.providers-list span {
+ font-weight: bold;
+}
+
+.provisioner-buttons {
+ margin-inline: -3px;
+}
+
+.provisioner-results-area {
+ margin-block-end: 21px;
+}
+
+.results-title {
+ font-weight: 500;
+ margin-block: 0 9px;
+}
+
+.result-list-header {
+ margin-block: 0 10px;
+ text-transform: uppercase;
+ background-color: var(--button-background-color);
+ color: var(--default);
+ padding: 9px 6px;
+ border-radius: var(--in-content-button-border-radius);
+ text-align: center;
+ margin: -9px -9px 10px;
+}
+.results-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.results-list ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.results-list ul + .result-list-header {
+ margin-block-start: 10px;
+}
+
+.result-item {
+ margin-block-end: 12px;
+}
+
+.result-item > button {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-radius: var(--in-content-button-border-radius);
+ background-color: rgba(215, 215, 219, 0.2);
+ border: 1px solid var(--in-content-border-color);
+ padding: 6px 9px;
+ width: 100%;
+ text-align: start;
+ margin: 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .result-item > button {
+ transition: background-color 180ms ease, color 180ms ease, border 180ms ease;
+ }
+}
+
+.result-item > button > img {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 12px;
+ opacity: 0.7;
+}
+
+.result-data {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ word-wrap: break-word;
+}
+
+.result-name {
+ font-size: 1.05em;
+ font-weight: 500;
+}
+
+.result-price {
+ color: var(--in-content-primary-button-background);
+}
+
+.result-item > button:hover {
+ background-color: var(--in-content-box-background);
+ box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);
+}
+
+.result-item > button:hover > img {
+ color: var(--in-content-primary-button-background);
+}
+
+@media (-moz-platform: linux),
+ (-moz-platform: windows) {
+ #manualConfigArea select {
+ line-height: 1.7;
+ }
+}
+
+@media (-moz-platform: macos) {
+ #manualConfigArea select {
+ line-height: 2;
+ }
+}
diff --git a/comm/mail/themes/shared/mail/accountWizard.css b/comm/mail/themes/shared/mail/accountWizard.css
new file mode 100644
index 0000000000..e1933104e3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/accountWizard.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/messenger.css");
+
+description {
+ margin-inline: 4px;
+}
+
+.awIdentityLabel {
+ width: 8em;
+ margin-inline-start: 5px;
+}
+
+#accountprotocol,
+#accountsummary {
+ overflow: visible;
+}
+
+#summarygrid {
+ overflow: auto;
+}
+
+#value-column {
+ min-width: 15em;
+}
diff --git a/comm/mail/themes/shared/mail/activity/activity.css b/comm/mail/themes/shared/mail/activity/activity.css
new file mode 100644
index 0000000000..209ae5547a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/activity/activity.css
@@ -0,0 +1,117 @@
+/* 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/. */
+
+#activityManager[lwt-tree] {
+ background-color: var(--toolbar-bgcolor);
+ color: var(--lwt-text-color);
+}
+
+body {
+ margin: 0;
+}
+
+#activityContainer {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+#activityContainer > #activityView {
+ min-height: 0;
+ flex: 1 1 auto;
+}
+
+#activityContainer > #clearListButton {
+ align-self: start;
+ flex: 0 0 auto;
+}
+
+ul.activityview {
+ display: block;
+ margin: 0;
+ padding-inline-start: 0;
+ overflow: auto;
+ border-bottom: 1px solid var(--field-border-color);
+ background-color: var(--field-background-color);
+}
+
+li.activitygroup {
+ display: block;
+}
+
+li.activityitem {
+ display: grid;
+ grid-template:
+ "icon display time" auto
+ "icon progress progress" auto
+ "icon status status" 1fr / auto 1fr auto;
+ align-items: baseline;
+}
+
+.activityitem {
+ padding: 7px 4px;
+ border-bottom: 1px solid var(--field-border-color);
+}
+
+.activitygroup-list .activityitem {
+ padding-inline-start: 12px;
+}
+
+.activityitem > img {
+ width: 48px;
+ height: 48px;
+ grid-area: icon;
+ align-self: start;
+}
+
+.activityitem > .displayText {
+ grid-area: display;
+}
+
+.activityitem > .dateTime {
+ grid-area: time;
+}
+
+.activityitem > .progressmeter {
+ grid-area: progress;
+}
+
+.activityitem > .statusText {
+ grid-area: status;
+}
+
+.contextDisplayText,
+.progressmeter,
+.dateTime,
+.displayText,
+.statusText {
+ margin: 2px 6px;
+}
+
+:is(
+ .contextDisplayText,
+ .dateTime,
+ .displayText,
+ .statusText
+):empty {
+ display: none;
+}
+
+.contextDisplayText,
+.displayText,
+.statusText {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+h2.contextDisplayText {
+ font-size: inherit;
+}
+
+.dateTime,
+.statusText {
+ font-size: smaller;
+ opacity: .8;
+}
diff --git a/comm/mail/themes/shared/mail/attachmentList.css b/comm/mail/themes/shared/mail/attachmentList.css
new file mode 100644
index 0000000000..a0142d2b00
--- /dev/null
+++ b/comm/mail/themes/shared/mail/attachmentList.css
@@ -0,0 +1,168 @@
+/* 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/. */
+
+.attachmentList {
+ appearance: none;
+ -moz-user-focus: normal;
+ margin: 0;
+ padding: 3px;
+ background-color: var(--layout-background-2);
+ color: var(--layout-color-2);
+ border: none;
+ display: flex;
+ overflow-x: hidden;
+ align-items: start;
+ align-content: start;
+ flex: 1;
+ flex-direction: row;
+ flex-wrap: wrap;
+}
+
+.attachmentList[collapsed] {
+ display: none;
+ height: 0;
+}
+
+.attachmentItem {
+ border: 1px solid transparent;
+ min-width: 10em;
+ padding: 1px 3px;
+ border-radius: 2px;
+ display: flex;
+ align-items: baseline;
+}
+
+.attachmentItem > * {
+ /* We treat the entire attachment item as a single object for click events.
+ * This ensures that dragging will drag the entire widget by default, and
+ * click event targets will point to the attachmentItem, rather than a
+ * descendant. */
+ pointer-events: none;
+}
+
+.attachmentList:focus > .attachmentItem[selected="true"] > * {
+ color: inherit;
+}
+
+.attachmentItem > .attachmentcell-icon {
+ flex: 0 0 auto;
+ align-self: center;
+}
+
+.attachmentItem > .attachmentcell-name {
+ flex: 0 1 auto;
+ white-space: nowrap;
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+}
+
+.attachmentItem > .attachmentcell-extension {
+ /* The extension part grows to fill the available space after the attachment
+ * name, but the name part shrinks when we do not have enough space. */
+ flex: 1 0 auto;
+}
+
+.attachmentItem > .attachmentcell-size {
+ flex: 0 0 auto;
+}
+
+.attachmentList[seltype="multiple"]:focus .attachmentItem[current="true"] {
+ border-color: var(--selected-item-color);
+ outline: none;
+}
+
+/* Hide the drop indicator for the message pane attachment list. */
+#attachmentList .attachmentItem .attach-drop-indicator {
+ display: none;
+}
+
+#attachmentBucket .attachmentItem .attach-drop-indicator {
+ position: absolute;
+ z-index: 3;
+ display: none;
+ margin: -6px -6px -4px;
+ transform: scale(0.8);
+}
+
+#attachmentBucket .attachmentItem[dropOn="before"] .attach-drop-indicator.before {
+ display: inline;
+}
+
+#attachmentBucket .attachmentItem[dropOn="after"] .attach-drop-indicator.after {
+ display: inline;
+}
+
+.attachmentList[seltype="multiple"]:focus
+ .attachmentItem[current="true"][selected="true"] {
+ border-color: var(--sidebar-highlight-border-color, var(--item-focus-selected-border-color));
+}
+
+:root[lwt-tree] .attachmentList {
+ background-color: var(--sidebar-background-color);
+ color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree] .attachmentList .attachmentItem {
+ color: var(--sidebar-text-color) !important;
+}
+
+:root[lwt-tree] .attachmentList .attachmentItem:hover {
+ background-color: hsla(0, 0%, 50%, .15);
+ border-color: transparent;
+}
+
+:root[lwt-tree] .attachmentList .attachmentItem[selected="true"] {
+ border-color: hsla(0, 0%, 50%, 0.2);
+ background: hsla(0, 0%, 50%, 0.2);
+ color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree] .attachmentList:focus .attachmentItem[selected="true"] {
+ background: var(--sidebar-highlight-background-color, hsla(0, 0%, 80%, .3));
+ color: var(--sidebar-highlight-text-color, var(--sidebar-text-color)) !important;
+}
+
+:root[lwt-tree-brighttext] .attachmentList:focus .attachmentItem[selected="true"] {
+ background: var(--sidebar-highlight-background-color, rgba(249, 249, 250, .1));
+}
+
+#attachmentName.notfound,
+.attachmentItem.notfound {
+ text-decoration-line: line-through;
+}
+
+.attachmentcell-icon {
+ margin: 1px;
+ width: 16px;
+ height: 16px;
+}
+
+.attachmentcell-name,
+.attachmentcell-extension,
+.attachmentcell-size {
+ margin-block: 2px;
+}
+
+/* NOTE: We do not create a margin between the name and extension. */
+.attachmentcell-name,
+.attachmentcell-size {
+ margin-inline-start: 6px;
+}
+
+.attachmentcell-extension,
+.attachmentcell-size {
+ margin-inline-end: 5px;
+}
+
+.attachmentcell-size {
+ opacity: 0.6;
+}
+
+.attachmentList:focus .attachmentItem[selected="true"] .attachmentcell-size {
+ opacity: 0.8;
+}
+
+.attachmentItem:not(.notfound):hover .text-link {
+ text-decoration: underline;
+}
diff --git a/comm/mail/themes/shared/mail/autocomplete.css b/comm/mail/themes/shared/mail/autocomplete.css
new file mode 100644
index 0000000000..02b3f563ef
--- /dev/null
+++ b/comm/mail/themes/shared/mail/autocomplete.css
@@ -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/. */
+
+#PopupAutoComplete > richlistbox > richlistitem {
+ min-height: 20px;
+ border: 0;
+ border-radius: 0;
+ padding: 0 1px;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem > .ac-site-icon {
+ margin-inline: 4px 0;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem > .ac-title {
+ font: icon;
+ margin-inline-start: 4px;
+}
+
+#PopupAutoComplete > richlistbox {
+ padding: 0;
+}
diff --git a/comm/mail/themes/shared/mail/avatars.css b/comm/mail/themes/shared/mail/avatars.css
new file mode 100644
index 0000000000..b649b13758
--- /dev/null
+++ b/comm/mail/themes/shared/mail/avatars.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/colors.css");
+
+:root {
+ --recipient-avatar-size: 28px;
+ --recipient-avatar-placeholder-size: 16px;
+
+ --recipient-avatar-color: var(--color-gray-50);
+ --recipient-avatar-background-color: var(--color-gray-30);
+}
+
+:root[uidensity="compact"] {
+ --recipient-avatar-size: 24px;
+ --recipient-avatar-placeholder-size: contain;
+ --recipient-multi-line-gap: 0;
+}
+
+:root[uidensity="touch"] {
+ --recipient-avatar-size: 32px;
+ --recipient-avatar-placeholder-size: 16px;
+}
+
+@media not (prefers-contrast) {
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --recipient-avatar-color: var(--color-gray-30);
+ --recipient-avatar-background-color: var(--color-gray-60);
+ }
+ }
+}
+
+@media (prefers-contrast) {
+ :root {
+ --recipient-avatar-color: currentColor;
+ --recipient-avatar-background-color: color-mix(in srgb, currentColor 30%, transparent);
+ }
+}
+
+.recipient-avatar {
+ display: inline-flex;
+ height: var(--recipient-avatar-size);
+ width: var(--recipient-avatar-size);
+ border-radius: 50%;
+ margin-inline-end: 6px;
+ text-align: center;
+ overflow: hidden;
+ color: var(--recipient-avatar-color);
+ background-color: var(--recipient-avatar-background-color);
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+}
+
+.recipient-avatar.is-mail-list {
+ background: none;
+}
+
+.recipient-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.recipient-avatar.is-mail-list img {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
diff --git a/comm/mail/themes/shared/mail/browserRequest.css b/comm/mail/themes/shared/mail/browserRequest.css
new file mode 100644
index 0000000000..ecd7298494
--- /dev/null
+++ b/comm/mail/themes/shared/mail/browserRequest.css
@@ -0,0 +1,14 @@
+/* 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/. */
+
+#header.contentTabAddress {
+ min-width: 0;
+ padding: 2px;
+ margin: 0;
+ border-block-end: 1px solid ThreeDShadow;
+}
+
+#headerMessage.contentTabUrlInput {
+ overflow: hidden;
+}
diff --git a/comm/mail/themes/shared/mail/cardDAV.css b/comm/mail/themes/shared/mail/cardDAV.css
new file mode 100644
index 0000000000..a93dd63b0e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/cardDAV.css
@@ -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/. */
+
+#carddav-statusArea {
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 2px;
+ padding: 3px 4px;
+ color: var(--text-color);
+}
+
+#carddav-statusArea[status=error] {
+ background-color: #FFE900;
+ border-color: #F2D00F;
+ color: -moz-DialogText;
+}
+
+#carddav-statusArea[status=loading] {
+ background-color: rgba(0, 0, 0, 0.05);
+ border-color: rgba(0, 0, 0, 0.1);
+}
+
+#carddav-statusContainer {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ /* center align */
+ max-width: max-content;
+ margin-inline: auto;
+}
+
+#carddav-statusContainer > #carddav-statusImage {
+ flex: 0 0 auto;
+}
+
+#carddav-statusContainer > #carddav-statusMessage {
+ flex: 1 1 auto;
+}
+
+#carddav-statusImage {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+}
+
+#carddav-statusImage:not([src]) {
+ display: none;
+}
+
+#carddav-resultsArea {
+ margin-block-start: 6px;
+ margin-block-end: 6px;
+}
+
+#carddav-availableBooksHeader {
+ font-weight: 600;
+}
+
+#carddav-availableBooks {
+ overflow: auto;
+ flex: 1 1 0;
+}
+
+#carddav-properties-table {
+ display: grid;
+ grid-template-columns: min-content auto;
+ align-items: baseline;
+}
+
+.input-container {
+ display: flex;
+}
+
+.input-container > * {
+ flex: 1;
+}
+
+#carddav-refreshActive-cell {
+ /* This shouldn't be necessary, but there's no good combination of checkbox
+ * and label that play nicely with the align-items: baseline above. */
+ align-self: center;
+}
+
+#carddav-refreshInterval-cell {
+ display: flex;
+ align-items: baseline;
+}
+
+#carddav-refreshInterval {
+ flex: 1;
+ margin: 2px 4px;
+}
diff --git a/comm/mail/themes/shared/mail/cardDialog.css b/comm/mail/themes/shared/mail/cardDialog.css
new file mode 100644
index 0000000000..3706fb6ddb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/cardDialog.css
@@ -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/. */
+
+/* ===== cardViewOverlay.css ============================================
+ == Styles for Address Book dialogs.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/icons.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+window {
+ --lwt-header-image: none !important;
+}
+
+.input-inline {
+ margin-inline-end: 0 !important;
+}
+
+/* ::::: Card Edit dialog ::::: */
+
+#contactGrid {
+ display: grid;
+ grid-auto-flow: column;
+}
+
+.inputGrid {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-content: start;
+ align-items: center;
+}
+
+.align-end {
+ text-align: end;
+}
+
+.showingPhonetic .input-inline {
+ max-width: 16ch;
+}
+
+#BirthMonth[value="-1"],
+#BirthDay[value="-1"],
+.placeholderOption {
+ color: #888;
+}
+
+.YearWidth {
+ width: 8ch;
+}
+
+.ZipWidth {
+ max-width: 14ch !important;
+}
+
+.DepartmentWidth {
+ max-width: 20ch !important;
+}
+
+/* ::::: List dialogs ::::: */
+
+.menu-iconic-left,
+menulist::part(icon) {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#addressingWidget {
+ -moz-user-focus: none;
+ height: 15em;
+ margin-bottom: 12px;
+}
+
+.dummy-row,
+.addressingWidgetItem {
+ border: none !important;
+ background-color: inherit !important;
+ color: inherit !important;
+ min-height: 30px;
+}
+
+.addressingWidgetItem:hover,
+.autocomplete-richlistitem:hover {
+ background-color: var(--in-content-item-hover, hsla(0, 0%, 50%, 0.3)) !important;
+}
+
+.textbox-addressingWidget {
+ min-height: 30px;
+ padding-inline: 5px;
+ outline-offset: -2px;
+}
+
+.dummy-row-cell,
+.addressingWidgetCell {
+ border-bottom: 1px solid hsla(0, 0%, 50%, 0.3);
+}
+
+panel[type="autocomplete-richlistbox"] {
+ border: 1px solid var(--in-content-box-border-color, ThreeDShadow);
+}
+
+panel[type="autocomplete-richlistbox"]::part(content) {
+ border-width: 0;
+}
+
+.autocomplete-richlistbox {
+ border-width: 0;
+ border-radius: 0;
+}
+
+.autocomplete-richlistitem {
+ padding-inline-start: 3px;
+}
+
+.person-icon {
+ margin: 0 3px;
+ content: var(--icon-contact);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#photo {
+ list-style-image: var(--icon-user);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ box-shadow: 0 0 5px rgba(127, 127, 127, 0.7);
+}
+
+#PhotoContainer {
+ margin: 5px;
+}
+
+#PhotoDropTarget {
+ margin-top: 5px;
+}
+
+#PhotoDropTarget:hover {
+ border: 1px dashed hsla(0, 0%, 50%, 0.5);
+}
+
+#PhotoFile {
+ background-position: 2px center;
+ background-repeat: no-repeat;
+ padding-inline-start: 20px;
+}
+
+#ProgressContainer {
+ max-height: 0;
+ transition: all .5s ease-out;
+ overflow: hidden;
+}
+#ProgressContainer.expanded {
+ margin-top: 10px;
+ max-height: 40px; /* something higher than the actual height, but not too large */
+}
diff --git a/comm/mail/themes/shared/mail/chat.css b/comm/mail/themes/shared/mail/chat.css
new file mode 100644
index 0000000000..e7a65f137f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/chat.css
@@ -0,0 +1,1006 @@
+/* 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 html url("http://www.w3.org/1999/xhtml");
+
+#chatTabPanel {
+ --imbox-selected-text-color: FieldText;
+ --imbox-selected-background-color: Field;
+ --imgroup-selected-background-color: ThreeDLightShadow;
+ overflow: hidden;
+}
+
+:root[lwt-tree-brighttext] #chatTabPanel {
+ --imbox-selected-text-color: #f9f9fa;
+ --imbox-selected-background-color: #18181a;
+}
+
+.im-placeholder-screen {
+ color: FieldText;
+ background-color: Field;
+ overflow: auto;
+ font-size: 15px;
+ font-weight: normal;
+}
+
+:root[lwt-tree-brighttext] .im-placeholder-screen {
+ color: var(--sidebar-text-color);
+ background-color: var(--sidebar-background-color);
+}
+
+.im-placeholder-box {
+ max-width: calc(500px + 9em);
+ min-height: 3em;
+ background: url("chrome://global/skin/icons/info.svg") left 0 no-repeat;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-size: 3em;
+ margin-right: .5em;
+ margin-left: .5em;
+ padding-right: 4.5em;
+ padding-left: 4.5em;
+}
+
+.im-placeholder-innerbox {
+ opacity: .8;
+}
+
+.im-placeholder-title {
+ font-size: 2em;
+ font-weight: lighter;
+ line-height: 1.2;
+ margin: 0;
+ margin-bottom: .5em;
+ padding-bottom: .4em;
+ border-bottom: 1px solid var(--splitter-color);
+}
+
+.im-placeholder-desc {
+ margin-right: 0;
+ margin-left: 0;
+}
+
+#noPreviousConvDesc {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.im-placeholder-button-box > button {
+ appearance: none;
+ /* override forms.css */
+ font: inherit;
+ min-height: 30px;
+ color: inherit;
+ border: 1px solid var(--button-border-color);
+ border-radius: var(--button-border-radius);
+ background-color: var(--button-background-color);
+}
+
+.im-placeholder-button-box > button:hover {
+ background-color: var(--button-hover-background-color);
+}
+
+.im-placeholder-button-box > button:hover:active {
+ background-color: var(--button-active-background-color);
+}
+
+.im-placeholder-button-box > button:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+.im-placeholder-button-box > button > .button-box {
+ padding-inline: 10px;
+}
+
+#listPaneBox {
+ border-inline-end: 1px solid var(--splitter-color);
+}
+
+:root[lwt-tree] #listPaneBox {
+ appearance: none;
+ background-color: var(--sidebar-background-color);
+ border-inline-end-color: var(--sidebar-border-color);
+ color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree-brighttext] #listPaneBox {
+ border-inline-end-color: var(--sidebar-border-color);
+}
+
+#contactlistbox {
+ color: inherit;
+ margin: 0;
+ /* make it possible to let the children overwrite the end border.
+ margin-inline-start because of the inverted direction */
+ margin-inline-start: -1px;
+ contain: size;
+}
+
+/* move the scrollbar to the left */
+#contactlistbox:-moz-locale-dir(ltr),
+#contactlistbox:-moz-locale-dir(rtl) > richlistitem:is([is="chat-imconv-richlistitem"],[is="chat-contact-richlistitem"],[is="chat-group-richlistitem"]) {
+ direction: rtl;
+}
+
+#contactlistbox:-moz-locale-dir(rtl),
+#contactlistbox:-moz-locale-dir(ltr) > richlistitem:is([is="chat-imconv-richlistitem"],[is="chat-contact-richlistitem"],[is="chat-group-richlistitem"]) {
+ direction: ltr;
+}
+
+richlistitem[is="chat-group-richlistitem"] {
+ align-items: center;
+ padding-inline-start: 4px;
+ margin-inline-end: 1px;
+}
+
+richlistitem[is="chat-group-richlistitem"] > label {
+ margin-inline-start: 4px;
+}
+
+richlistitem[is="chat-group-richlistitem"],
+richlistitem[is="chat-imconv-richlistitem"][unread] {
+ font-weight: bold;
+}
+
+richlistitem[is="chat-imconv-richlistitem"][attention] {
+ color: blue;
+}
+
+richlistitem[is="chat-group-richlistitem"][selected] {
+ background-color: var(--imgroup-selected-background-color);
+ color: var(--imbox-selected-text-color);
+}
+
+richlistbox:focus > richlistitem[is="chat-group-richlistitem"][selected="true"] {
+ background-color: var(--imgroup-selected-background-color);
+ color: var(--imbox-selected-text-color);
+}
+
+richlistitem[is="chat-imconv-richlistitem"],
+richlistitem[is="chat-contact-richlistitem"] {
+ border-top: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ align-items: center;
+}
+
+.contact-hbox {
+ align-items: center;
+ overflow: hidden;
+}
+
+.conv-hbox {
+ overflow: hidden;
+}
+
+.box-line {
+ width: 2px;
+ height: 100%;
+}
+
+richlistitem[is="chat-contact-richlistitem"] > .box-line {
+ /* equalize the space, the .closeConversationButton uses */
+ margin-inline-end: 22px;
+}
+
+.box-line[selected=true] {
+ background-color: var(--tabline-color);
+}
+
+richlistitem:is([is="chat-imconv-richlistitem"],[is="chat-contact-richlistitem"],[is="chat-group-richlistitem"]) {
+ pointer-events: auto;
+}
+
+richlistitem:is([is="chat-imconv-richlistitem"],[is="chat-contact-richlistitem"],[is="chat-group-richlistitem"]):not([selected=true]):hover {
+ background-color: rgba(0,0,0,.1);
+}
+
+:root[lwt-tree] richlistitem[is="chat-group-richlistitem"],
+:root[lwt-tree] richlistitem[is="chat-imconv-richlistitem"]:not([selected]),
+:root[lwt-tree] richlistitem[is="chat-contact-richlistitem"]:not([selected]) {
+ color: var(--sidebar-text-color);
+}
+
+richlistitem[is="chat-imconv-richlistitem"][selected=true],
+richlistitem[is="chat-contact-richlistitem"][selected=true] {
+ background-color: var(--imbox-selected-background-color) !important;
+ border-color: var(--splitter-color) !important;
+ color: var(--imbox-selected-text-color) !important;
+}
+
+:root[lwt-tree] richlistitem[is="chat-group-richlistitem"][selected],
+:root[lwt-tree] richlistitem:is([is="chat-imconv-richlistitem"],[is="chat-contact-richlistitem"],[is="chat-group-richlistitem"]):not([selected=true]):hover {
+ background-color: var(--sidebar-highlight-background-color, hsla(0,0%,80%,.3));
+ color: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+}
+
+:root[lwt-tree-brighttext] richlistitem[is="chat-group-richlistitem"][selected],
+:root[lwt-tree-brighttext] richlistitem:is([is="chat-imconv-richlistitem"],[is="chat-contact-richlistitem"],[is="chat-group-richlistitem"]):not([selected=true]):hover {
+ background-color: var(--sidebar-highlight-background-color, rgba(249,249,250,.1));
+}
+
+richlistitem[is="chat-imconv-richlistitem"]:not(:hover) > .closeConversationButton {
+ visibility: hidden;
+}
+
+/* From instantbird/themes/blist.css */
+.contactStatusText,
+.convStatusText {
+ color: GrayText;
+}
+
+.convDisplayName,
+.blistDisplayName,
+.contactDisplayName,
+richlistitem[is="chat-group-richlistitem"] > label {
+ display: inline-block;
+}
+
+.blistDisplayName {
+ flex: 1 auto;
+}
+
+.convUnreadCount,
+.contactDisplayName,
+.convDisplayName,
+.contactDisplayNameInput {
+ margin-inline-end: 0;
+}
+
+.contactDisplayNameInput {
+ margin: 0;
+}
+
+.convUnreadCount {
+ margin-inline-start: 0.5ch;
+}
+
+.convUnreadTargetedCount {
+ color: hsl(0, 100%, 27%);
+ background-color: hsl(0, 100%, 87%);
+ border-radius: 50px;
+ margin: 1px 3px;
+ margin-inline-start: 1ch;
+}
+
+.convUnreadCount[value="0"],
+.convUnreadTargetedCount[value="0"] {
+ display: none;
+}
+
+.convUnreadTargetedCountLabel {
+ margin: 0;
+}
+
+.contactStatusText,
+.convStatusText {
+ margin-inline-start: 0;
+}
+
+/* Avoid a strange jumping bug when hovering and the startChatBubble appears */
+.contact-vbox {
+ min-height: 40px;
+}
+
+.startChatBubble,
+.closeConversationButton {
+ margin: 0 3px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ min-height: 16px;
+ min-width: 16px;
+ cursor: pointer;
+ -moz-user-focus: ignore;
+}
+
+.startChatBubble {
+ display: none;
+ list-style-image: var(--icon-new-chat);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+richlistitem[is="chat-contact-richlistitem"][cansend]:hover .startChatBubble {
+ display: flex;
+}
+
+.closeConversationButton > .button-box > .button-icon {
+ width: 16px;
+ height: 16px;
+}
+
+/* From im/themes/conversation.css */
+.browser {
+ margin: 0 0;
+}
+
+.conv-bottom, .conv-nicklist {
+ margin: 0 0;
+}
+
+.convBox {
+ min-height: 135px;
+ min-width: 200px;
+}
+
+.conv-top {
+ min-height: 60px;
+}
+
+.conv-top-info {
+ margin: 0;
+ border-style: none;
+ appearance: none;
+ -moz-window-dragging: no-drag;
+ border-bottom: 1px solid var(--splitter-color);
+}
+
+.userIcon {
+ border: 2px solid rgba(0,0,0,0.15);
+ border-radius: 5px;
+ object-fit: contain;
+ width: 48px;
+ height: 48px;
+}
+
+:root[lwt-tree-brighttext] .userIcon {
+ border-color: rgba(255,255,255,0.15);
+}
+
+.userIcon:not([src]) {
+ display: none;
+}
+
+.fillUserIcon {
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.3;
+}
+
+.statusTypeIcon, .smallStatusIcon {
+ /* Place in opposite corner. */
+ /* NOTE: unlike position: absolute, the image will still take up space if its
+ * sibling (such as .userIcon) is hidden. */
+ margin-block: auto 0;
+ margin-inline: auto 0;
+}
+
+.statusTypeIcon {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, var(--color-ink-50) 20%, transparent);
+ stroke: var(--color-ink-50);
+}
+
+#statusTypeIcon:not([disabled]) {
+ cursor: pointer;
+}
+
+.userIcon:not([src]) + .statusTypeIcon {
+ /* If userIcon is invisible (for chat) we still keep the status icon in the
+ * same position. */
+ padding-block-start: 32px;
+}
+
+/* Used with protoIcon. */
+.smallStatusIcon {
+ width: 10px;
+ height: 10px;
+}
+
+#protolist richlistitem {
+ align-items: center;
+}
+
+.protoIcon {
+ width: 16px;
+ height: 16px;
+ object-fit: contain;
+}
+
+.protoIconDimmed {
+ opacity: 0.7;
+}
+
+richlistitem:not([selected]) .protoIconDimmed {
+ opacity: 0.3;
+}
+
+:root[lwt-tree-brighttext] .protoIconDimmed {
+ opacity: 0.8;
+}
+
+:root[lwt-tree-brighttext] richlistitem:not([selected]) .protoIconDimmed {
+ opacity: 0.5;
+}
+
+#statusTypeIcon[status="available"],
+#statusTypeAvailable,
+#imStatusAvailable {
+ list-style-image: var(--icon-status-online);
+}
+
+#statusTypeIcon[status="idle"] {
+ list-style-image: var(--icon-status-idle);
+}
+
+#statusTypeIcon[status="offline"],
+#statusTypeIcon[status="invisible"],
+#statusTypeOffline,
+#imStatusOffline {
+ list-style-image: var(--icon-status-offline);
+}
+
+#statusTypeIcon[status="unavailable"],
+#statusTypeIcon[status="away"],
+#statusTypeUnavailable,
+#imStatusUnavailable {
+ list-style-image: var(--icon-status-away);
+}
+
+/* corresponds to im/themes/conversation.css @media all and (min-height: 251px) */
+.displayUserAccount {
+ padding: 6px;
+ display: flex;
+ gap: 6px;
+}
+
+/* User image and status stack. */
+.displayUserAccount > stack {
+ flex: 0 0 auto;
+ align-self: start;
+}
+
+.nameAndStatusGrid {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 4px;
+ flex: 1 1 auto;
+}
+
+.nameAndStatusGrid > * {
+ margin: 0;
+}
+
+.nameAndStatusGrid > :is(hr, .statusMessage, .statusMessageInput) {
+ grid-column: 1 / 3;
+}
+
+.nameAndStatusGrid > hr {
+ height: 0;
+ border-block-start: 1px solid hsla(0, 0%, 50%, 0.5);
+ border-block-end: none;
+ border-inline: none;
+}
+
+.displayName {
+ font-size: larger;
+ display: block;
+}
+
+.nameAndStatusGrid > .displayName:empty + hr {
+ display: none;
+}
+
+.statusMessage {
+ display: inline-block;
+}
+
+.statusMessage[noTopic] {
+ font-style: italic;
+}
+
+.statusMessageInput[editing] {
+ color: -moz-dialogtext;
+}
+
+.conv-messages {
+ min-width: 150px;
+}
+
+/* Animation copied from the find bar in Firefox - http://dxr.mozilla.org/mozilla-central/source/toolkit/themes/linux/global/findBar.css#7 */
+.conv-status-container {
+ display: block;
+ height: 20px;
+ padding: 3px;
+ border-bottom: 1px solid ThreeDShadow;
+ transition-property: margin-top, opacity, visibility;
+ transition-duration: 150ms, 150ms, 0s;
+ transition-timing-function: ease-in-out, ease-in-out, linear;
+}
+
+.conv-status-container[hidden] {
+ /* Override display:none to make the transition work. */
+ display: flex;
+ visibility: collapse;
+ margin-top: -1em;
+ opacity: 0;
+ transition-delay: 0s, 0s, 150ms;
+}
+
+.conv-textbox {
+ appearance: none;
+ margin: 0;
+ /* margin-inline-end so the borders show up on all sides. */
+ margin-inline-end: 1px;
+ padding: 2px;
+ box-sizing: content-box;
+ border: 2px solid transparent;
+}
+
+:root[lwt-tree] .conv-textbox {
+ background-color: var(--toolbar-field-background-color);
+ color: var(--toolbar-field-color);
+}
+
+.conv-textbox:focus-visible {
+ border-color: var(--toolbar-field-focus-border-color);
+ outline-style: none;
+}
+
+.conv-textbox[invalidInput="true"] {
+ border-color: red;
+}
+
+.conv-counter {
+ justify-self: end;
+ align-self: end;
+ color: #000;
+ background-color: rgba(246, 246, 246, 0.7);
+ border-inline-start: 1px solid rgb(200, 200, 200);
+ border-top: 1px solid rgb(200, 200, 200);
+ /* Padding that gets flipped to margins in .conv-counter[value^="0"] to avoid the red border. */
+ font-size: 130%;
+ padding-top: 0;
+ padding-bottom: 1px;
+ padding-inline-start: 5px;
+ padding-inline-end: 6px;
+ margin: 0;
+}
+
+:root[lwt-tree-brighttext] .conv-counter {
+ color: var(--sidebar-text-color);
+ background-color: rgba(22, 22, 22, 0.2);
+ border-inline-start: 1px solid var(--splitter-color);
+ border-top: 1px solid var(--splitter-color);
+}
+
+.conv-counter:-moz-locale-dir(ltr) {
+ border-top-left-radius: 3px;
+}
+
+.conv-counter:-moz-locale-dir(rtl) {
+ border-top-right-radius: 3px;
+}
+
+.conv-counter[value=""] {
+ display: none;
+}
+
+/* Negative counter values (user went over the character limit). */
+.conv-counter[value^="-"] {
+ color: red;
+}
+
+.splitter[orient="vertical"],
+#logsSplitter {
+ border-style: none;
+ min-height: 0;
+ /* splitter grip area */
+ height: 5px;
+ background-color: transparent;
+ /* make only the splitter border visible */
+ margin-top: -5px;
+ margin-inline-start: 0;
+ /* because of the negative margin needed to make the splitter visible */
+ position: relative;
+ z-index: 10;
+ border-bottom: 1px solid var(--splitter-color);
+}
+
+/* Adaptation of #folderpane_splitter */
+#listSplitter,
+#contextSplitter {
+ margin-top: 0;
+ /* splitter grip area */
+ width: 5px;
+ /* because of the negative margin needed to make the splitter visible */
+ position: relative;
+ z-index: 10;
+ transition: border-width .3s ease-in;
+}
+
+#listSplitter {
+ border-inline-start: 1px solid transparent;
+ /* make only the splitter border visible */
+ margin-inline-end: -5px;
+}
+
+#listSplitter[state="collapsed"]:hover {
+ border-inline-start: 4px solid var(--selected-item-color);
+}
+
+#contextSplitter {
+ border-inline-end: 1px solid var(--splitter-color);
+ /* make only the splitter border visible */
+ margin-inline-start: -5px;
+}
+
+#contextSplitter[state="collapsed"]:hover {
+ border-inline-end: 4px solid var(--selected-item-color);
+}
+
+#conv-toolbar {
+ border-style: none;
+}
+
+#logTree {
+ margin: 0 0;
+}
+
+.conv-nicklist-label {
+ pointer-events: none;
+ font-weight: bold;
+ padding-inline-start: 1px;
+ display: inline-block;
+}
+
+.conv-nicklist-image {
+ pointer-events: none;
+ width: 16px;
+ margin: 0 2px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.conv-nicklist-image:not([src]) {
+ visibility: hidden;
+}
+
+.conv-logs-header-label {
+ appearance: auto;
+ -moz-default-appearance: treeheadercell;
+ margin: 0 -1px 0 0;
+ padding-left: 3px;
+}
+
+#nicklist > richlistitem[inactive] > .conv-nicklist-image {
+ opacity: 0.45;
+}
+
+#nicklist > richlistitem[inactive][selected] > .conv-nicklist-image {
+ opacity: 0.7;
+}
+
+#nicklist > richlistitem[inactive] > label {
+ color: GrayText !important;
+ font-weight: normal;
+}
+
+.conv-nicklist:focus > richlistitem[inactive][selected] > label {
+ color: var(--selected-item-text-color) !important;
+}
+
+/* from instantbird/themes/blist.css */
+richlistitem[is="chat-group-richlistitem"] .twisty {
+ padding-top: 1px;
+ width: 10px;
+ height: 10px;
+ margin-inline-start: 5px;
+ background: var(--icon-nav-down-sm) no-repeat center;
+ background-size: contain;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+richlistitem[is="chat-group-richlistitem"].closed .twisty {
+ transform: rotate(-90deg);
+}
+
+richlistitem[is="chat-group-richlistitem"].closed:-moz-locale-dir(rtl) .twisty {
+ transform: rotate(90deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ richlistitem[is="chat-group-richlistitem"] .twisty {
+ transition: transform 200ms ease;
+ }
+}
+
+.prplBuddyIcon {
+ margin: 2px 0;
+}
+
+.searchProtoIcon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ opacity: 0.54;
+}
+
+#statusTypeIcon {
+ min-width: 40px;
+}
+
+/* show the status icon also in text mode */
+toolbar[mode="text"] #statusTypeIcon > .toolbarbutton-icon {
+ display: flex;
+}
+
+.status-container {
+ width: 15em;
+}
+
+#statusMessageLabel:not([statusType="offline"],[disabled]) {
+ cursor: text;
+}
+
+#statusMessageInput.status-message-input {
+ margin: 0;
+}
+
+.statusMessageToolbarItem {
+ margin: 2px 4px;
+ margin-inline-start: -1px;
+ padding: 3px 5px 4px;
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+ background-clip: padding-box;
+}
+
+#chat-status-selector:hover > vbox > .statusMessageToolbarItem,
+.statusMessageToolbarItem:focus {
+ color: var(--toolbar-field-color);
+ background-color: var(--toolbar-field-background-color);
+ border-color: var(--toolbar-field-border-color);
+}
+
+#chat-status-selector > vbox > .statusMessageToolbarItem[editing],
+.statusMessageToolbarItem:-moz-lwtheme:focus {
+ appearance: none;
+ padding-inline-start: 4px;
+ padding-inline-end: 4px;
+ color: var(--toolbar-field-color);
+ background-color: var(--toolbar-field-focus-background-color);
+ border-color: var(--toolbar-field-focus-border-color);
+ outline: 1px solid var(--toolbar-field-focus-border-color);
+}
+
+.statusMessageToolbarItem[statusType="offline"],
+.statusMessageToolbarItem[disabled] {
+ border: none;
+}
+
+.alltabs-item[style*="chat.svg"] {
+ -moz-context-properties: fill, stroke-opacity;
+ stroke-opacity: 0;
+ fill: currentColor;
+}
+
+#button-add-buddy {
+ list-style-image: var(--icon-new-contact);
+}
+
+#button-join-chat {
+ list-style-image: var(--icon-new-chat);
+}
+
+#button-chat-accounts {
+ list-style-image: var(--icon-id);
+}
+
+:root[lwt-tree] #contextPane {
+ background-color: var(--sidebar-background-color);
+ border-bottom: 1px solid var(--sidebar-border-color);
+ color: var(--sidebar-text-color);
+}
+
+#contextPaneFlexibleBox {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+#contextPaneFlexibleBox vbox.conv-chat {
+ flex: 1 1 0;
+ min-height: 0;
+}
+
+#previousConversations {
+ flex: 1 1 0;
+}
+
+:root[lwt-tree] #nicklist,
+:root[lwt-tree] .conv-nicklist-header,
+:root[lwt-tree] .conv-logs-header-label {
+ appearance: none;
+ background-color: var(--sidebar-background-color);
+ border-bottom: 1px solid var(--sidebar-border-color);
+ color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree-brighttext] #nicklist,
+:root[lwt-tree-brighttext] .conv-nicklist-header,
+:root[lwt-tree-brighttext] .conv-logs-header-label {
+ border-bottom-color: var(--sidebar-border-color, rgba(249,249,250,.2));
+}
+
+.conv-header-label {
+ display: inline-block;
+}
+
+#participantCount {
+ background: transparent;
+ width: 0;
+}
+
+:root[lwt-tree] #participantCount {
+ color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree] .conv-nicklist > richlistitem {
+ color: var(--sidebar-text-color);
+ box-shadow: none;
+}
+
+:root[lwt-tree] .conv-nicklist > richlistitem[selected="true"],
+:root[lwt-tree] .conv-nicklist:focus > richlistitem[current="true"],
+:root[lwt-tree] .conv-nicklist:focus > richlistitem[selected="true"] {
+ background-color: var(--sidebar-highlight-background-color, hsla(0,0%,80%,.3));
+ background-image: none;
+ border-color: var(--sidebar-border-color);
+ color: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+}
+
+:root[lwt-tree-brighttext] .conv-nicklist > richlistitem[selected="true"],
+:root[lwt-tree-brighttext] .conv-nicklist:focus > richlistitem[current="true"],
+:root[lwt-tree-brighttext] .conv-nicklist:focus > richlistitem[selected="true"] {
+ background-color: var(--sidebar-highlight-background-color, rgba(249,249,250,.1));
+ border-color: var(--sidebar-highlight-background-color, rgba(249,249,250,.1));
+}
+
+:root[lwt-tree] #nicklist > richlistitem[inactive]:not([selected]) > label {
+ color: var(--sidebar-text-color) !important;
+ opacity: .55;
+}
+
+:root[lwt-tree] #nicklist > richlistitem[inactive][selected] > label,
+:root[lwt-tree] #nicklist:focus > richlistitem[inactive][selected] > label {
+ color: var(--sidebar-highlight-text-color, var(--sidebar-text-color)) !important;
+}
+
+:root[lwt-tree] #logTree {
+ appearance: none;
+}
+
+#goToConversation {
+ appearance: none !important;
+ margin: 4px;
+ padding: 1px !important;
+ color: inherit !important;
+ border: 1px solid hsla(0, 0%, 50%, 0.5);
+ border-radius: var(--button-border-radius);
+ background-color: hsla(0, 0%, 50%, 0.2) !important;
+ box-shadow: none;
+ transition-property: background-color, border-color, box-shadow;
+ transition-duration: 150ms;
+}
+
+#goToConversation:hover {
+ background-color: hsla(0, 0%, 50%, 0.3) !important;
+}
+
+#goToConversation:hover:active {
+ background-color: hsla(0, 0%, 50%, 0.4) !important;
+ box-shadow: 0 0 1px hsla(0, 0%, 50%, 0.15) inset;
+}
+
+
+/* Chat Toolbar button. */
+toolbar[mode="text"] #button-chat {
+ flex-direction: row;
+}
+
+.badgeButton-badge {
+ background-color: red;
+ border: 1px solid white;
+ border-radius: 1em;
+ box-shadow: 1px 1px 1px black;
+ color: white;
+ font: xx-small Helvetica, Verdana, Tahoma, sans-serif;
+ height: -moz-fit-content;
+ min-width: 1em;
+ text-align: center;
+ margin-top: -4px;
+ margin-inline-start: 5px;
+ margin-inline-end: 0;
+ padding-inline: 1px;
+ padding-block: 1px 0;
+}
+
+toolbar[mode="text"] .badgeButton-badge {
+ margin: 0;
+}
+
+.badgeButton-badgeLabel {
+ margin: 0;
+}
+
+/* encryption status selector */
+
+.encryption-container {
+ border-top: 1px solid var(--splitter-color);
+ min-height: 32px;
+ padding: 4px;
+}
+
+.encryption-label {
+ font-weight: 600;
+ text-overflow: ellipsis;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.encryption-not-private > image {
+ list-style-image: url("chrome://messenger/skin/icons/new/chat-lock-insecure.svg");
+}
+
+.encryption-unverified > image {
+ list-style-image: url("chrome://messenger/skin/icons/new/chat-lock-unverified.svg");
+}
+
+.encryption-finished > image {
+ list-style-image: url("chrome://messenger/skin/icons/new/chat-lock-finished.svg");
+}
+
+.encryption-private > image {
+ list-style-image: url("chrome://messenger/skin/icons/new/chat-lock-private.svg");
+}
+
+.encryption-button {
+ appearance: none !important;
+ padding: 1px !important;
+ border: 1px solid var(--toolbarbutton-hover-bordercolor);
+ border-radius: var(--button-border-radius);
+ background: var(--toolbarbutton-hover-background) !important;
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+ transition-property: background-color, border-color, box-shadow;
+ transition-duration: 150ms;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.encryption-button:not([disabled="true"]):not([open="true"]):hover {
+ background: var(--toolbarbutton-active-background) !important;
+ border-color: var(--toolbarbutton-active-bordercolor);
+}
+
+.encryption-button[open="true"] {
+ background: var(--toolbarbutton-active-background) !important;
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+}
+
+.encryption-button > image {
+ margin-inline-end: 3px !important;
+ width: 14px;
+}
+
+.encryption-button .toolbarbutton-menu-dropmarker {
+ appearance: none !important;
+ list-style-image: var(--icon-nav-down-sm);
+ margin-inline: 3px 0;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
diff --git a/comm/mail/themes/shared/mail/cloudfileSelectDialog.css b/comm/mail/themes/shared/mail/cloudfileSelectDialog.css
new file mode 100644
index 0000000000..3bb5ac03e0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/cloudfileSelectDialog.css
@@ -0,0 +1,20 @@
+/* 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/. */
+
+richlistitem {
+ display: flex;
+ align-items: center;
+ padding: 4px 8px;
+}
+
+richlistitem > label {
+ flex: 1 1 auto;
+}
+
+richlistitem > img {
+ flex: 0 0 auto;
+ margin-inline-end: 8px;
+ width: 32px;
+ height: 32px;
+}
diff --git a/comm/mail/themes/shared/mail/colors.css b/comm/mail/themes/shared/mail/colors.css
new file mode 100644
index 0000000000..c393401bb0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/colors.css
@@ -0,0 +1,130 @@
+/* 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/. */
+
+:host,
+:root {
+ --color-red-10: #FEE2E2;
+ --color-red-20: #FECACA;
+ --color-red-30: #FCA5A5;
+ --color-red-40: #f87171;
+ --color-red-50: #ef4444;
+ --color-red-60: #dc2626;
+ --color-red-70: #b91c1c;
+ --color-red-80: #991b1b;
+ --color-red-90: #7f1d1d;
+
+ --color-orange-10: #ffedd5;
+ --color-orange-20: #fed7aa;
+ --color-orange-30: #fdba74;
+ --color-orange-40: #fb923c;
+ --color-orange-50: #f97316;
+ --color-orange-60: #ea580c;
+ --color-orange-70: #c2410c;
+ --color-orange-80: #9a3412;
+ --color-orange-90: #7c2d12;
+
+ --color-amber-10: #fef3c7;
+ --color-amber-20: #fde68a;
+ --color-amber-30: #fcd34d;
+ --color-amber-40: #fbbf24;
+ --color-amber-50: #f59e0b;
+ --color-amber-60: #d97706;
+ --color-amber-70: #b45309;
+ --color-amber-80: #92400e;
+ --color-amber-90: #78350f;
+
+ --color-yellow-10: #fef9c3;
+ --color-yellow-20: #fef08a;
+ --color-yellow-30: #fde047;
+ --color-yellow-40: #facc15;
+ --color-yellow-50: #eab308;
+ --color-yellow-60: #ca8a04;
+ --color-yellow-70: #a16207;
+ --color-yellow-80: #854d0e;
+ --color-yellow-90: #713f12;
+
+ --color-green-10: #dcfce7;
+ --color-green-20: #bbf7d0;
+ --color-green-30: #86efac;
+ --color-green-40: #4ade80;
+ --color-green-50: #22c55e;
+ --color-green-60: #16a34a;
+ --color-green-70: #15803d;
+ --color-green-80: #166534;
+ --color-green-90: #14532d;
+
+ --color-teal-10: #cdfaf7;
+ --color-teal-20: #9ff4f0;
+ --color-teal-30: #62e9e6;
+ --color-teal-40: #27d3d6;
+ --color-teal-50: #0db7bd;
+ --color-teal-60: #0a929d;
+ --color-teal-70: #0e757f;
+ --color-teal-80: #135e67;
+ --color-teal-90: #144e56;
+
+ --color-blue-10: #ddeefe;
+ --color-blue-20: #bce0fd;
+ --color-blue-30: #88ccfc;
+ --color-blue-40: #4cb1f9;
+ --color-blue-50: #2493ef;
+ --color-blue-60: #1373d9;
+ --color-blue-70: #105bbc;
+ --color-blue-80: #124c9a;
+ --color-blue-90: #15427c;
+
+ --color-purple-10: #f3e8ff;
+ --color-purple-20: #e9d5ff;
+ --color-purple-30: #d8b4fe;
+ --color-purple-40: #c084fc;
+ --color-purple-50: #a855f7;
+ --color-purple-60: #9333ea;
+ --color-purple-70: #7e22ce;
+ --color-purple-80: #6b21a8;
+ --color-purple-90: #581c87;
+
+ --color-magenta-10: #fbe7f9;
+ --color-magenta-20: #f8cff3;
+ --color-magenta-30: #f4a9e8;
+ --color-magenta-40: #ee75d7;
+ --color-magenta-50: #e247c4;
+ --color-magenta-60: #cd26a5;
+ --color-magenta-70: #b01a86;
+ --color-magenta-80: #91186e;
+ --color-magenta-90: #79195c;
+
+ --color-brown-10: #f4e9d7;
+ --color-brown-20: #efdfc4;
+ --color-brown-30: #e4cdab;
+ --color-brown-40: #d7bc96;
+ --color-brown-50: #b6986c;
+ --color-brown-60: #96764b;
+ --color-brown-70: #755b38;
+ --color-brown-80: #51412c;
+ --color-brown-90: #47341f;
+
+ --color-gray-05: #fafafa;
+ --color-gray-10: #f4f4f5;
+ --color-gray-20: #e4e4e7;
+ --color-gray-30: #d4d4d8;
+ --color-gray-40: #a1a1aa;
+ --color-gray-50: #71717a;
+ --color-gray-60: #52525b;
+ --color-gray-70: #3f3f46;
+ --color-gray-80: #27272a;
+ --color-gray-90: #18181b;
+
+ --color-ink-10: #f1f3fa;
+ --color-ink-20: #e3e5f2;
+ --color-ink-30: #cdd0e5;
+ --color-ink-40: #9b9ec2;
+ --color-ink-50: #6e6f9b;
+ --color-ink-60: #52507c;
+ --color-ink-70: #3e3c67;
+ --color-ink-80: #2a284b;
+ --color-ink-90: #1a1838;
+
+ --color-white: #ffffff;
+ --color-black: #000000;
+}
diff --git a/comm/mail/themes/shared/mail/common.css b/comm/mail/themes/shared/mail/common.css
new file mode 100644
index 0000000000..460fcbefc7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/common.css
@@ -0,0 +1,131 @@
+/* - 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 url("chrome://global/skin/in-content/common-shared.css");
+@import url("chrome://messenger/skin/layout.css");
+
+:host,
+:root {
+ --in-content-button-height: 2.2em;
+ --in-content-button-border-radius: 3px;
+ --in-content-button-border-color: color-mix(in srgb, currentColor 9%, transparent);
+ --in-content-button-border-color-hover: color-mix(in srgb, currentColor 17%, transparent);
+ --in-content-sidebar-width: auto;
+ --menu-item-margin: 0 3px;
+}
+
+:root:not(.system-font-size) {
+ font: message-box;
+}
+
+@media not (prefers-contrast) {
+ :host,
+ :root {
+ --in-content-box-info-background: var(--layout-background-1);
+ --in-content-box-info-border: var(--layout-border-0);
+ --in-content-button-background: var(--grey-90-a10);
+ --in-content-button-background-hover: var(--grey-90-a20);
+ --in-content-button-background-active: var(--grey-90-a30);
+ --in-content-categories-background: var(--layout-background-2);
+ --in-content-categories-border: var(--in-content-categories-background);
+ --in-content-item-selected-unfocused: var(--color-gray-20);
+ --in-content-item-hover: color-mix(in srgb, currentColor 12%, transparent);
+ --in-content-item-selected: color-mix(in srgb, currentColor 20%, transparent);
+ --in-content-item-selected-text: var(--in-content-page-color);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :host,
+ :root {
+ --in-content-box-info-background: var(--layout-background-2);
+ --in-content-box-info-border: transparent;
+ --in-content-categories-background: var(--layout-background-2);
+ --in-content-item-selected-unfocused: rgba(249, 249, 250, 0.05);
+ --in-content-button-background: rgba(249, 249, 250, 0.1);
+ --in-content-button-background-hover: rgba(249, 249, 250, 0.15);
+ --in-content-button-background-active: rgba(249, 249, 250, 0.2);
+ --in-content-primary-button-background: #45a1ff;
+ --in-content-primary-button-background-hover: #65c1ff;
+ --in-content-primary-button-background-active: #85e1ff;
+ --in-content-focus-outline-color: #45a1ff;
+ }
+ }
+}
+
+@media (prefers-contrast) {
+ :root {
+ --in-content-box-info-background: transparent;
+ --in-content-box-info-border: currentColor;
+ --in-content-categories-background: transparent;
+ --in-content-categories-border: currentColor;
+ }
+}
+
+.sidebar-footer-link,
+#categories > .category {
+ border-color: transparent !important;
+}
+
+#categories > .category {
+ margin-inline: 6px;
+}
+
+#categories > .category[selected] {
+ font-weight: 500;
+}
+
+@media not (prefers-contrast) {
+ #categories > .category[selected] {
+ background-color: var(--in-content-button-background) !important;
+ color: unset;
+ }
+
+ #categories[keyboard-navigation="true"]:focus-visible > .category[current],
+ #categories > .category:focus-visible {
+ background-color: var(--in-content-item-selected) !important;
+ color: var(--in-content-item-selected-text);
+ outline: none;
+ }
+}
+
+.category-name {
+ font-size: 1.1rem;
+}
+
+.sidebar-footer-list {
+ margin-inline: 0;
+}
+
+.sidebar-footer-icon {
+ margin: 10px;
+}
+
+.sidebar-footer-label {
+ margin: 0;
+}
+
+menupopup {
+ --panel-border-color: var(--in-content-box-border-color);
+ --panel-background: var(--in-content-box-background);
+ --panel-color: var(--in-content-text-color);
+}
+
+menupopup::part(content) {
+ border-radius: var(--arrowpanel-border-radius);
+}
+
+menulist > menupopup {
+ --panel-padding: 3px 0;
+}
+
+menupopup > :is(menu, menuitem) {
+ margin: var(--menu-item-margin);
+ min-height: 24px;
+ padding-block: var(--menu-item-padding);
+ border-radius: 3px;
+}
+
+menulist > menupopup menuseparator {
+ margin: 4px 8px;
+}
diff --git a/comm/mail/themes/shared/mail/compacttheme.css b/comm/mail/themes/shared/mail/compacttheme.css
new file mode 100644
index 0000000000..3ead999372
--- /dev/null
+++ b/comm/mail/themes/shared/mail/compacttheme.css
@@ -0,0 +1,39 @@
+/* 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/. */
+
+/* compacttheme.css is loaded in messenger.xhtml after messenger.css when it
+ is preffed on. The bulk of the styling is here in the shared file, but
+ there are overrides for each platform in their compacttheme.css files. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:root:-moz-lwtheme {
+ --toolbar-non-lwt-bgcolor: var(--toolbar-bgcolor);
+ --toolbar-non-lwt-textcolor: var(--lwt-text-color);
+ --toolbar-non-lwt-bgimage: none;
+ --new-focused-folder-color: var(--sidebar-highlight-text-color);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:-moz-lwtheme {
+ /* Toolbar buttons */
+ --lwt-toolbarbutton-hover-background: rgba(179, 179, 179, 0.4);
+ --lwt-toolbarbutton-active-background: rgba(179, 179, 179, 0.6);
+ --autocomplete-popup-highlight-background: #0060DF;
+ }
+
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-image(folderNameCol, newMessages-true),
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-image(folderNameCol, isServer-true, biffState-NewMail) {
+ --new-folder-color: var(--color-blue-40);
+ }
+
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-cell-text(hasUnreadMessages-true, selected, focus),
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-cell-text(closed, subfoldersHaveUnreadMessages-true, selected, focus) {
+ color: var(--sidebar-highlight-text-color) !important;
+ }
+}
diff --git a/comm/mail/themes/shared/mail/composerOverlay.css b/comm/mail/themes/shared/mail/composerOverlay.css
new file mode 100644
index 0000000000..89f0fcaa7e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/composerOverlay.css
@@ -0,0 +1,16 @@
+/* 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/. */
+
+/* Because this sheet is loaded synchronously while the user is waiting for the
+ compose window to appear, it must not @import a ton of other things, and
+ especially must not trigger network access. */
+
+table.moz-email-headers-table:hover,
+table.moz-email-headers-table:hover > tbody > tr > th,
+table.moz-email-headers-table:hover > tbody > tr > td,
+blockquote[type=cite] table:hover,
+blockquote[type=cite] table:hover > tbody > tr > th,
+blockquote[type=cite] table:hover > tbody > tr > td {
+ border: 1px solid lightgrey !important;
+}
diff --git a/comm/mail/themes/shared/mail/contextMenu.css b/comm/mail/themes/shared/mail/contextMenu.css
new file mode 100644
index 0000000000..7a29a5ec0d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/contextMenu.css
@@ -0,0 +1,210 @@
+/* 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/. */
+
+:root {
+ --menu-item-margin: 0 4px;
+ --menu-item-padding: 4px;
+}
+
+/* UI Density customization */
+
+:root[uidensity="compact"] {
+ --menu-item-margin: 0 3px;
+ --menu-item-padding: 3px;
+}
+
+:root[uidensity="compact"] menupopup {
+ --arrowpanel-padding: 3px 0;
+}
+
+:root[uidensity="touch"] {
+ --menu-item-padding: 9px;
+}
+
+menupopup {
+ /* Complement the :is(menu, menuitem) margin. */
+ --arrowpanel-padding: 4px 0;
+ --panel-background: var(--arrowpanel-background);
+ --panel-color: var(--arrowpanel-color);
+ --panel-border-color: var(--arrowpanel-border-color);
+ --panel-border-radius: var(--arrowpanel-border-radius);
+ --panel-padding: var(--arrowpanel-padding);
+ --menu-color: var(--arrowpanel-color);
+ --menu-border-color: var(--arrowpanel-border-color);
+ --menu-background-color: var(--arrowpanel-background);
+ font: menu;
+ font-size: inherit;
+ text-align: start;
+}
+
+menubar:not(:-moz-lwtheme) > menu menupopup {
+ --panel-background: Menu;
+ --panel-color: MenuText;
+}
+
+menupopup > menuitem {
+ appearance: none !important;
+}
+
+menupopup > :is(menu, menuitem) > .menu-text {
+ appearance: none;
+ margin-inline-start: 0 !important;
+}
+
+menupopup > :is(menu, menuitem) {
+ appearance: none;
+ margin: var(--menu-item-margin);
+ min-height: 24px;
+ padding-inline: 8px;
+ padding-block: var(--menu-item-padding);
+ border-radius: 3px;
+ background-color: transparent;
+}
+
+menupopup > :is(menu, menuitem):focus {
+ outline: 0;
+}
+
+menupopup > :is(menu, menuitem)[disabled="true"],
+menupopup > :is(menu, menuitem)[disabled="true"]:hover,
+menupopup > :is(menu, menuitem)[checked="true"][disabled="true"],
+menupopup > :is(menu, menuitem)[checked="true"][disabled="true"]:hover {
+ color: var(--text-color-deemphasized);
+ background-color: transparent;
+}
+
+menu > .menu-right {
+ display: none;
+}
+
+menupopup > menu::after {
+ -moz-context-properties: stroke, fill-opacity;
+ content: var(--icon-nav-right-sm);
+ stroke: currentColor;
+ float: inline-end;
+}
+
+menupopup > menu:-moz-locale-dir(rtl)::after {
+ content: var(--icon-nav-left-sm);
+}
+
+menupopup > menu::after {
+ margin-inline-start: 10px;
+}
+
+menupopup > :is(menu, menuitem)[checked="true"] {
+ list-style-image: var(--icon-check);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ color: inherit;
+}
+
+menupopup > :is(menu, menuitem):not([disabled="true"],:active)[_moz-menuactive] {
+ color: inherit;
+ background-color: var(--arrowpanel-dimmed);
+}
+
+menupopup > :is(menu, menuitem):not([disabled="true"])[_moz-menuactive]:active {
+ color: inherit;
+ background-color: var(--arrowpanel-dimmed-further);
+ box-shadow: 0 1px 0 hsla(210, 4%, 10%, 0.03) inset;
+}
+
+menubar:not(:-moz-lwtheme) > menu menupopup >
+ :is(menu, menuitem):not([disabled="true"])[_moz-menuactive] {
+ color: -moz-MenuHoverText;
+ background-color: -moz-MenuHover;
+}
+
+@media (-moz-platform: windows-win10) and (-moz-windows-default-theme){
+ menubar:not(:-moz-lwtheme) > menu menupopup >
+ :is(menu, menuitem):not([disabled="true"])[_moz-menuactive] {
+ color: inherit;
+ background-color: var(--arrowpanel-dimmed-further);
+ }
+}
+
+menupopup > menuseparator {
+ appearance: none !important;
+ min-height: 0;
+ border-top: 1px solid var(--panel-separator-color);
+ border-bottom: none;
+ margin: 4px 8px;
+ padding: 0;
+}
+
+#ContentSelectDropdown > menupopup {
+ /* !important to override inline styles */
+ --content-select-background-image: none !important;
+ --panel-color: var(--arrowpanel-color) !important;
+}
+
+#ContentSelectDropdown > menupopup[customoptionstyling="true"]::part(arrowscrollbox) {
+ /* !important to override inline styles */
+ background-image: none !important;
+ background-color: var(--arrowpanel-background) !important;
+}
+
+#ContentSelectDropdown .ContentSelectDropdown-item-0:not([_moz-menuactive="true"]) {
+ background-color: var(--arrowpanel-background) !important;
+ color: var(--arrowpanel-color) !important;
+}
+
+.menu-iconic-accel {
+ margin-inline-end: 3px !important;
+}
+
+/* browser navigation context menu in content pages */
+#context-navigation > .menuitem-iconic {
+ -moz-box-flex: 1;
+ -moz-box-pack: center;
+ -moz-box-align: center;
+ flex: 1 0 auto;
+ margin: var(--menu-item-margin);
+ border-radius: 3px;
+}
+
+#context-navigation > .menuitem-iconic > .menu-iconic-left {
+ appearance: none;
+}
+
+#context-navigation > .menuitem-iconic > .menu-iconic-text,
+#context-navigation > .menuitem-iconic > .menu-accel-container {
+ display: none;
+}
+
+#context-navigation > .menuitem-iconic > .menu-iconic-left {
+ margin: 0;
+ padding: 0;
+}
+
+#context-navigation > .menuitem-iconic > .menu-iconic-left > .menu-iconic-icon {
+ width: 1.25em;
+ height: auto;
+ margin: 7px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#browserContext-back {
+ list-style-image: url("chrome://messenger/skin/icons/browser-back.svg");
+}
+
+#browserContext-forward {
+ list-style-image: url("chrome://messenger/skin/icons/browser-forward.svg");
+}
+
+#browserContext-reload {
+ list-style-image: url("chrome://global/skin/icons/reload.svg");
+}
+
+#browserContext-stop {
+ list-style-image: url("chrome://global/skin/icons/close.svg");
+}
+
+#browserContext-back:-moz-locale-dir(rtl),
+#browserContext-forward:-moz-locale-dir(rtl),
+#browserContext-reload:-moz-locale-dir(rtl) {
+ transform: scaleX(-1);
+}
diff --git a/comm/mail/themes/shared/mail/converterDialog.css b/comm/mail/themes/shared/mail/converterDialog.css
new file mode 100644
index 0000000000..381ccf808d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/converterDialog.css
@@ -0,0 +1,57 @@
+/* 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/. */
+
+html {
+ background-color: -moz-Dialog;
+}
+
+body {
+ color: -moz-DialogText;
+ font: message-box;
+ position: relative;
+ min-width: 330px;
+ max-width: 50em;
+}
+
+.convert-area {
+ display: grid;
+ grid-template: "icon text text" auto
+ "icon progress-bar progress-percent" auto
+ "buttons buttons buttons" 1fr / auto 70% 1fr;
+ gap: 10px 15px;
+}
+
+.convert-area[hidden] {
+ display: none;
+}
+
+.infoIcon {
+ width: 48px;
+ height: 48px;
+ grid-area: icon;
+ align-self: start;
+}
+
+p {
+ margin: 0;
+ /* Only one paragraph should be displayed at any given time. */
+ grid-area: text;
+ align-self: start;
+}
+
+.controls {
+ grid-area: buttons;
+ align-self: end;
+ justify-self: end;
+}
+
+#progress {
+ grid-area: progress-bar;
+ align-self: center;
+}
+
+#progressPercent {
+ grid-area: progress-percent;
+ align-self: center;
+}
diff --git a/comm/mail/themes/shared/mail/customizeToolbar.css b/comm/mail/themes/shared/mail/customizeToolbar.css
new file mode 100644
index 0000000000..01f68490ec
--- /dev/null
+++ b/comm/mail/themes/shared/mail/customizeToolbar.css
@@ -0,0 +1,110 @@
+/* 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/. */
+
+#CustomizeToolbarWindow:-moz-lwtheme[lwtheme-image] {
+ background-image: none !important;
+ text-shadow: none;
+}
+
+#main-box {
+ padding: 8px;
+ height: 100%;
+}
+
+#instructions {
+ font-weight: 600;
+ font-size: 1.2em;
+ margin-block: 5px 10px;
+}
+
+#palette-box {
+ overflow: auto;
+ display: block;
+ min-height: 3em;
+ background-color: hsla(0, 0%, 100%, .3);
+ border: 1px solid hsla(0, 0%, 50%, .4);
+}
+
+:root[lwt-tree] #palette-box {
+ scrollbar-color: rgba(204, 204, 204, .5) rgba(230, 230, 235, .5);
+}
+
+:root[lwt-tree-brighttext] #palette-box {
+ scrollbar-color: rgba(249, 249, 250, .4) rgba(20, 20, 25, .3);
+}
+
+#palette-box > toolbarpaletteitem {
+ padding: 8px 2px;
+ margin: 0 8px;
+}
+
+toolbarpaletteitem {
+ -moz-window-dragging: no-drag;
+ justify-content: flex-start;
+}
+
+toolbarpaletteitem[place="palette"] {
+ flex-direction: column;
+ width: 10em;
+ max-width: 10em;
+ /* icon (16) + margin (9 + 12) + 4 lines of text: */
+ height: calc(39px + 4em);
+ margin-bottom: 5px;
+ margin-inline-end: 24px;
+ overflow: visible;
+ display: inline-flex;
+ vertical-align: top;
+}
+
+toolbarpaletteitem[place=palette]::after {
+ content: attr(title);
+ display: block;
+ text-align: center;
+}
+
+toolbarpaletteitem > toolbarbutton,
+toolbarpaletteitem > toolbarseparator,
+toolbarpaletteitem > toolbaritem {
+ /* Prevent children from getting events */
+ pointer-events: none;
+ justify-content: center;
+ flex: 1;
+}
+
+toolbarpaletteitem[type="separator"][place="palette"] {
+ align-items: center;
+}
+
+toolbarpaletteitem[type="separator"][place="palette"] toolbarseparator {
+ background-color: currentColor;
+}
+
+toolbarpaletteitem[type="spacer"][place="palette"] toolbarspacer {
+ flex: 1000 1000;
+}
+
+#main-box > box {
+ overflow: hidden;
+}
+
+/* Hide the toolbarbutton label because we replicate it on the wrapper */
+.toolbarbutton-text {
+ display: none;
+}
+
+toolbarbutton > .toolbarbutton-menubutton-dropmarker {
+ display: none;
+}
+
+#buttonBox {
+ margin-block: 5px;
+}
+
+#titlebarSettings > checkbox {
+ margin-inline: 0 15px;
+}
+
+#modelistLabel {
+ margin-top: 2px;
+}
diff --git a/comm/mail/themes/shared/mail/editorContent.css b/comm/mail/themes/shared/mail/editorContent.css
new file mode 100644
index 0000000000..9b2d6c913f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/editorContent.css
@@ -0,0 +1,108 @@
+/* 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/. */
+
+/* Styles to alter look of things in the Editor content window
+ * for the "Normal Edit Mode" These settings will be removed
+ * when we display in completely WYSIWYG "Edit Preview" mode
+ * Anything that should never change, like cursors, should be
+ * place in EditorOverride.css, instead of here.
+*/
+
+a[name] {
+ min-height: 17px; margin-left: 2px; margin-top: 2px;
+ padding-left: 20px;
+ background-image: url(chrome://messenger/content/messengercompose/images/tag-anchor.gif);
+ background-repeat: no-repeat;
+ background-position: top left;
+}
+
+/* Force border display for empty cells
+ and tables with 0 border
+*/
+table {
+ empty-cells: show;
+}
+
+/* give a red dotted border to tables and cells with no border
+ otherwise they are invisible
+*/
+table[empty-cells],
+ table[border="0"],
+ /* next two selectors on line below for the case where tbody is omitted */
+ table[border="0"] > tr > td, table[border="0"] > tr > th,
+ table[border="0"] > thead > tr > td, table[border="0"] > tbody > tr > td, table[border="0"] > tfoot > tr > td,
+ table[border="0"] > thead > tr > th, table[border="0"] > tbody > tr > th, table[border="0"] > tfoot > tr > th,
+ table:not([border]),
+ /* next two selectors on line below for the case where tbody is omitted */
+ table:not([border]) > tr > td, table:not([border]) > tr > th,
+ table:not([border]) > thead > tr > td, table:not([border]) > tbody > tr > td, table:not([border]) > tfoot > tr > td,
+ table:not([border]) > thead > tr > th, table:not([border]) > tbody > tr > th, table:not([border]) > tfoot > tr > th
+{
+ border: 1px dotted red;
+}
+
+/* give a green dashed border to forms otherwise they are invisible
+*/
+form
+{
+ border: 2px dashed green;
+}
+/* give a green dotted border to labels otherwise they are invisible
+*/
+label
+{
+ border: 1px dotted green;
+}
+
+img {
+ -moz-force-broken-image-icon: 1;
+}
+
+.moz-card.loading {
+ opacity: 0;
+}
+.moz-card {
+ position: relative;
+ opacity: 1;
+ transition: opacity 1.5s;
+}
+
+.moz-card .remove-card {
+ position: absolute;
+ inset-inline-end: 15px;
+ top: 15px;
+ width: 20px;
+ height: 20px;
+ opacity: 0.3;
+ cursor: pointer;
+
+ font-size: 1.4em;
+ line-height: 18px;
+ border: 1px solid #f9f9fa;
+ border-radius: 15px;
+ float: inline-end;
+ font-weight: 600;
+ display: inline-block;
+ transform: rotate(45deg);
+ margin-block: -0.2em 0.2em;
+ margin-inline: 0.2em -0.2em;
+
+ color: #2a2a2e;
+ background-color: white;
+}
+.moz-card .remove-card:hover {
+ opacity: 1;
+}
+
+/* Can be removed when it is in messageQuotes.css enabled again */
+@media (prefers-color-scheme: dark) {
+ body {
+ color: #f9f9fa;
+ background-color: #2a2a2e;
+ }
+
+ span[_moz_quote="true"] {
+ color: #009fff;
+ }
+}
diff --git a/comm/mail/themes/shared/mail/extensionPopup.css b/comm/mail/themes/shared/mail/extensionPopup.css
new file mode 100644
index 0000000000..bf4e615f08
--- /dev/null
+++ b/comm/mail/themes/shared/mail/extensionPopup.css
@@ -0,0 +1,14 @@
+/* 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/. */
+
+.popup-anchor {
+ /* should occupy space but not be visible */
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+}
+
+:root[chromehidden~="location"] #header {
+ display: none
+}
diff --git a/comm/mail/themes/shared/mail/feedSubscribe.css b/comm/mail/themes/shared/mail/feedSubscribe.css
new file mode 100644
index 0000000000..a97709be68
--- /dev/null
+++ b/comm/mail/themes/shared/mail/feedSubscribe.css
@@ -0,0 +1,43 @@
+/* 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/. */
+
+#feedSubscriptions {
+ min-width: 60em;
+ min-height: 40em;
+}
+
+#rssSubscriptionsList > treechildren::-moz-tree-image {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, fill-opacity, stroke;
+ fill: color-mix(in srgb, var(--default) 20%, transparent);
+ stroke: var(--default);
+}
+
+#folderNameCol {
+ flex: 2 2;
+}
+
+#rssFeedInfoBox {
+ border: 1px solid ThreeDShadow;
+ margin: 4px;
+ padding-top: 4px;
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+:root[lwt-tree] #rssFeedInfoBox {
+ border-color: var(--sidebar-border-color);
+}
+
+:root[lwt-tree-brighttext] #rssFeedInfoBox {
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+#autotagPrefix {
+ max-width: 15em;
+}
+
+#statusContainerBox {
+ height: 2.4em;
+}
diff --git a/comm/mail/themes/shared/mail/fieldMapImport.css b/comm/mail/themes/shared/mail/fieldMapImport.css
new file mode 100644
index 0000000000..f5653905ef
--- /dev/null
+++ b/comm/mail/themes/shared/mail/fieldMapImport.css
@@ -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/. */
+
+treecolpicker {
+ display: none;
+}
+
+#checkedHeader {
+ flex: 3 3;
+}
+
+#fieldNameHeader {
+ flex: 7 7;
+}
+
+#sampleDataHeader {
+ flex: 13 13;
+}
+
+richlistitem > hbox > checkbox {
+ margin: 0;
+}
diff --git a/comm/mail/themes/shared/mail/filterDialog.css b/comm/mail/themes/shared/mail/filterDialog.css
new file mode 100644
index 0000000000..e52ace4073
--- /dev/null
+++ b/comm/mail/themes/shared/mail/filterDialog.css
@@ -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/. */
+
+/* ===== filterDialog.css ===============================================
+ == Styles for the Mail Filters dialog.
+ ======================================================================= */
+
+#filterListDialog:not([lwt-tree]):-moz-lwtheme {
+ background: -moz-Dialog !important;
+ color: -moz-DialogText;
+ text-shadow: none;
+
+ --button-background: rgba(128, 128, 128, .15);
+ --button-background-hover: rgba(128, 128, 128, .25);
+ --button-background-active: rgba(128, 128, 128, .35);
+ --button-border-color: rgba(128, 128, 128, .4);
+ --box-text-color: MenuText;
+ --box-background-color: Menu;
+ --box-border-color: ThreeDShadow;
+ --field-text-color: FieldText;
+ --field-background-color: Field;
+ --field-border-color: rgba(128, 128, 128, .6);
+ --field-border-hover-color: rgba(128, 128, 128, .8);
+ --popup-item-hover: rgba(128, 128, 128, .2);
+ --popup-item-hover-text: MenuText;
+ --popup-item-selected: var(--selected-item-color);
+ --popup-selected-text: var(--selected-item-text-color);
+ --richlist-button-background: -moz-Dialog;
+}
+
+#filterListGrid {
+ min-height: 0;
+}
+
+#filterListBox {
+ min-width: 0;
+ min-height: 0;
+}
+
+#filterList {
+ height: 340px;
+}
+
+#searchBox {
+ max-width: 30ch;
+}
+
+/* ::::: columns :::::: */
+
+treecolpicker {
+ display: none;
+}
+
+richlistitem {
+ padding-block: 1px;
+}
+
+richlistitem > checkbox {
+ width: 100px;
+ min-width: 100px;
+ margin: 0;
+ -moz-user-focus: none;
+}
+
+checkbox:not([label]) .checkbox-label-box {
+ display: none;
+}
+
+.search-value-menulist {
+ flex: 1;
+}
+
+.search-value-input {
+ width: -moz-available;
+}
+
+.search-menulist[unavailable="true"] {
+ opacity: 0.6;
+}
+
+.ruleactionitem {
+ min-width: 20em;
+}
+
+.ruleaction-type {
+ min-width: 15em;
+}
+
+#statusbar {
+ height: 1.8em;
+ padding: 2px 4px;
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"] {
+ appearance: none;
+ min-height: 24px;
+ margin: 4px;
+ color: inherit !important;
+ background-color: var(--button-background-color);
+ border: 1px solid var(--button-border-color);
+ border-radius: var(--button-border-radius);
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"] > toolbarbutton {
+ appearance: none;
+ margin-block: 0;
+ padding-block: 0 !important;
+ font-weight: inherit;
+ background-color: transparent;
+ border-width: 0;
+ border-inline-end: 1px solid var(--button-border-color);
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"]:hover,
+toolbarbutton[is="toolbarbutton-menu-button"] > toolbarbutton:hover {
+ background-color: var(--button-hover-background-color);
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"]:hover:active,
+toolbarbutton[is="toolbarbutton-menu-button"] > toolbarbutton:hover:active {
+ background-color: var(--button-active-background-color);
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"] > dropmarker {
+ appearance: none;
+ list-style-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ display: inline-flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+}
+
+#filterListGrid toolbarbutton *:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
diff --git a/comm/mail/themes/shared/mail/filterEditor.css b/comm/mail/themes/shared/mail/filterEditor.css
new file mode 100644
index 0000000000..b65f8aff96
--- /dev/null
+++ b/comm/mail/themes/shared/mail/filterEditor.css
@@ -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/. */
+
+#filterNameBox {
+ min-height: 30px;
+}
+
+#applyFiltersSettings {
+ min-height: 150px;
+}
+
+#searchTermListBox {
+ flex: 1 1 auto;
+ min-height: 100px;
+ height: 150px;
+}
+
+#searchTermBox {
+ min-height: 0;
+}
+
+#filterActionsBox {
+ flex: 1 1 auto;
+ min-height: 150px;
+ height: 150px;
+}
diff --git a/comm/mail/themes/shared/mail/folderColors.css b/comm/mail/themes/shared/mail/folderColors.css
new file mode 100644
index 0000000000..8e3a5906ee
--- /dev/null
+++ b/comm/mail/themes/shared/mail/folderColors.css
@@ -0,0 +1,37 @@
+/* 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/. */
+
+:root {
+ --folder-color-inbox: var(--color-blue-50);
+ --folder-color-draft: var(--color-purple-50);
+ --folder-color-sent: var(--color-green-60);
+ --folder-color-archive: var(--color-brown-60);
+ --folder-color-spam: var(--color-red-60);
+ --folder-color-trash: var(--color-ink-60);
+ --folder-color-template: var(--color-gray-60);
+ --folder-color-newsletter: var(--color-gray-60);
+ --folder-color-rss: var(--color-orange-60);
+ --folder-color-outbox: var(--color-teal-60);
+ --folder-color-folder: var(--color-yellow-50);
+ --folder-color-folder-filter: var(--color-magenta-60);
+ --folder-color-folder-rss: var(--color-orange-60);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --folder-color-inbox: var(--color-blue-30);
+ --folder-color-draft: var(--color-purple-30);
+ --folder-color-sent: var(--color-green-30);
+ --folder-color-archive: var(--color-brown-30);
+ --folder-color-spam: var(--color-red-30);
+ --folder-color-trash: var(--color-ink-30);
+ --folder-color-template: var(--color-gray-10);
+ --folder-color-newsletter: var(--color-gray-10);
+ --folder-color-rss: var(--color-orange-30);
+ --folder-color-outbox: var(--color-teal-30);
+ --folder-color-folder: var(--color-yellow-10);
+ --folder-color-folder-filter: var(--color-magenta-30);
+ --folder-color-folder-rss: var(--color-orange-30);
+ }
+}
diff --git a/comm/mail/themes/shared/mail/folderMenus.css b/comm/mail/themes/shared/mail/folderMenus.css
new file mode 100644
index 0000000000..3dcce5510a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/folderMenus.css
@@ -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/. */
+
+/* ===== folderMenus.css ================================================
+ == Icons for menus which represent mail folder.
+ ======================================================================= */
+
+.folderMenuItem::part(icon),
+.folderMenuItem > .menu-iconic-left > .menu-iconic-icon {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/* ::::: Folder icons for menus ::::: */
+
+/* Folders */
+.folderMenuItem {
+ list-style-image: var(--icon-folder);
+}
+
+/* Newsgroup */
+.folderMenuItem[ServerType="nntp"] {
+ list-style-image: var(--icon-newsletter);
+}
+
+/* Feed folder */
+.folderMenuItem[IsFeedFolder="true"] {
+ list-style-image: var(--icon-folder-rss);
+}
+
+/* Special folders */
+
+.folderMenuItem[SpecialFolder="Inbox"] {
+ list-style-image: var(--icon-inbox);
+}
+
+.folderMenuItem[SpecialFolder="Sent"] {
+ list-style-image: var(--icon-sent);
+}
+
+.folderMenuItem[SpecialFolder="Outbox"] {
+ list-style-image: var(--icon-outbox);
+}
+
+.folderMenuItem[SpecialFolder="Drafts"] {
+ list-style-image: var(--icon-draft);
+}
+
+.folderMenuItem[SpecialFolder="Templates"] {
+ list-style-image: var(--icon-template);
+}
+
+.folderMenuItem[SpecialFolder="Junk"] {
+ list-style-image: var(--icon-spam);
+}
+
+.folderMenuItem[SpecialFolder="Trash"] {
+ list-style-image: var(--icon-trash);
+}
+
+.folderMenuItem[SpecialFolder="Archive"] {
+ list-style-image: var(--icon-archive);
+}
+
+.folderMenuItem[SpecialFolder="Virtual"] {
+ list-style-image: var(--icon-folder-filter);
+
+}
+
+/* IMAP/POP server */
+.folderMenuItem[IsServer="true"] {
+ list-style-image: var(--icon-mail);
+}
+
+/* IMAP/POP secure server */
+.folderMenuItem[IsServer="true"][ServerType="imap"][IsSecure="true"],
+.folderMenuItem[IsServer="true"][ServerType="pop3"][IsSecure="true"] {
+ list-style-image: var(--icon-mail-secure);
+}
+
+/* Local server */
+.folderMenuItem[IsServer="true"][ServerType="none"] {
+ list-style-image: var(--icon-folder);
+}
+
+/* News server */
+.folderMenuItem[IsServer="true"][ServerType="nntp"] {
+ list-style-image: var(--icon-globe);
+}
+
+/** Secure news server */
+.folderMenuItem[IsServer="true"][ServerType="nntp"][IsSecure="true"] {
+ list-style-image: var(--icon-globe-secure);
+}
+
+/* Feed server */
+.folderMenuItem[IsServer="true"][ServerType="rss"] {
+ list-style-image: var(--icon-rss);
+}
diff --git a/comm/mail/themes/shared/mail/folderPane.css b/comm/mail/themes/shared/mail/folderPane.css
new file mode 100644
index 0000000000..ba48da9aee
--- /dev/null
+++ b/comm/mail/themes/shared/mail/folderPane.css
@@ -0,0 +1,312 @@
+/* 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 url("chrome://messenger/skin/folderColors.css");
+
+:root {
+ --new-focused-folder-color: var(--selected-item-text-color);
+
+ --primary-fill: color-mix(in srgb, var(--primary) 20%, transparent);
+ --primary-stroke: var(--primary);
+}
+
+/* ::::: Tabmail ::::: */
+#folderTree > treechildren::-moz-tree-image {
+ -moz-context-properties: fill, fill-opacity, stroke;
+}
+
+/* ::::: Folder Pane ::::: */
+
+#folderTree > treechildren::-moz-tree-image,
+#accounttree > treechildren::-moz-tree-image {
+ width: 16px;
+ height: 16px;
+}
+
+/* reduce the padding set from messenger.css */
+#folderTree > treechildren::-moz-tree-cell-text {
+ padding-inline-start: 3px;
+}
+
+treechildren::-moz-tree-image(folderNameCol) {
+ list-style-image: var(--folder-pane-folder);
+ margin-inline-end: 2px;
+ fill: color-mix(in srgb, var(--folder-color-folder) 20%, transparent);
+ stroke: var(--folder-color-folder);
+}
+
+treechildren::-moz-tree-twisty {
+ list-style-image: var(--icon-nav-right-sm);
+}
+
+treechildren:-moz-locale-dir(rtl)::-moz-tree-twisty {
+ list-style-image: var(--icon-nav-left-sm);
+}
+
+treechildren::-moz-tree-twisty(open) {
+ list-style-image: var(--icon-nav-down-sm);
+}
+
+/* ..... Inbox ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Inbox) {
+ list-style-image: var(--folder-pane-inbox);
+ fill: color-mix(in srgb, var(--folder-color-inbox) 20%, transparent);
+ stroke: var(--folder-color-inbox);
+}
+
+/* ..... Sent ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Sent) {
+ list-style-image: var(--folder-pane-sent);
+ fill: color-mix(in srgb, var(--folder-color-sent) 20%, transparent);
+ stroke: var(--folder-color-sent);
+}
+
+/* ..... Outbox ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Outbox) {
+ list-style-image: var(--folder-pane-outbox);
+ fill: color-mix(in srgb, var(--folder-color-outbox) 20%, transparent);
+ stroke: var(--folder-color-outbox);
+}
+
+/* ..... Drafts ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Drafts) {
+ list-style-image: var(--folder-pane-draft);
+ fill: color-mix(in srgb, var(--folder-color-draft) 20%, transparent);
+ stroke: var(--folder-color-draft);
+}
+
+/* ..... Trash ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Trash) {
+ list-style-image: var(--folder-pane-trash);
+ fill: color-mix(in srgb, var(--folder-color-trash) 20%, transparent);
+ stroke: var(--folder-color-trash);
+}
+
+/* ..... Archives ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Archive) {
+ list-style-image: var(--folder-pane-archive);
+ fill: color-mix(in srgb, var(--folder-color-archive) 20%, transparent);
+ stroke: var(--folder-color-archive);
+}
+
+/* ..... Templates ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Templates) {
+ list-style-image: var(--folder-pane-template);
+ fill: color-mix(in srgb, var(--folder-color-template) 20%, transparent);
+ stroke: var(--folder-color-template);
+}
+
+/* ..... Junk ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Junk) {
+ list-style-image: var(--folder-pane-spam);
+ fill: color-mix(in srgb, var(--folder-color-spam) 20%, transparent);
+ stroke: var(--folder-color-spam);
+}
+
+/* ..... Saved Search Folder ..... */
+treechildren::-moz-tree-image(folderNameCol, specialFolder-Virtual) {
+ list-style-image: var(--folder-pane-folder-filter);
+ fill: color-mix(in srgb, var(--folder-color-folder-filter) 20%, transparent);
+ stroke: var(--folder-color-folder-filter);
+}
+
+/* ..... Newsgroup ..... */
+treechildren::-moz-tree-image(folderNameCol, serverType-nntp) {
+ list-style-image: var(--folder-pane-newsletter);
+ fill: color-mix(in srgb, var(--folder-color-newsletter) 20%, transparent);
+ stroke: var(--folder-color-newsletter);
+}
+
+/* ..... Mail server ..... */
+treechildren::-moz-tree-image(folderNameCol, isServer-true) {
+ list-style-image: var(--folder-pane-mail);
+ fill: var(--primary-fill);
+ stroke: var(--primary-stroke);
+}
+
+/* ..... Feed server/account ..... */
+treechildren::-moz-tree-image(folderNameCol, isServer-true, serverType-rss) {
+ list-style-image: var(--folder-pane-rss);
+ fill: var(--primary-fill);
+ stroke: var(--primary-stroke);
+}
+/* ..... Feed folder ..... */
+treechildren::-moz-tree-image(folderNameCol, isFeedFolder-true) {
+ list-style-image: var(--folder-pane-folder-rss);
+ fill: color-mix(in srgb, var(--folder-color-folder-rss) 20%, transparent);
+ stroke: var(--folder-color-folder-rss);
+}
+/* ..... Feed message or subscription item ..... */
+treechildren::-moz-tree-image(folderNameCol, isFeed-true) {
+ list-style-image: var(--folder-pane-rss);
+ fill: color-mix(in srgb, var(--folder-color-rss) 20%, transparent);
+ stroke: var(--folder-color-rss);
+}
+
+treechildren::-moz-tree-image(folderNameCol, serverIsPaused),
+treechildren::-moz-tree-cell-text(folderNameCol, serverIsPaused),
+treechildren::-moz-tree-image(folderNameCol, isPaused),
+treechildren::-moz-tree-cell-text(folderNameCol, isPaused) {
+ opacity: 0.6;
+}
+
+treechildren::-moz-tree-image(folderNameCol, isBusy) {
+ list-style-image: url("chrome://messenger/skin/icons/waiting.svg");
+}
+
+treechildren::-moz-tree-image(folderNameCol, hasError) {
+ list-style-image: url("chrome://global/skin/icons/warning.svg");
+ fill: #e62117;
+}
+
+/* ..... Local folders ..... */
+treechildren::-moz-tree-image(folderNameCol, isServer-true, serverType-none) {
+ list-style-image: var(--folder-pane-folder);
+ fill: var(--primary-fill);
+ stroke: var(--primary-stroke);
+}
+
+/* ..... Secure mail server ..... */
+treechildren::-moz-tree-image(folderNameCol, isServer-true, serverType-pop3, isSecure-true),
+treechildren::-moz-tree-image(folderNameCol, isServer-true, serverType-imap, isSecure-true) {
+ list-style-image: var(--folder-pane-mail-secure);
+ fill: var(--primary-fill);
+ stroke: var(--primary-stroke);
+}
+
+/* ..... News server ..... */
+treechildren::-moz-tree-image(folderNameCol, isServer-true, serverType-nntp) {
+ list-style-image: var(--folder-pane-globe);
+ fill: var(--primary-fill);
+ stroke: var(--primary-stroke);
+}
+
+treechildren::-moz-tree-image(folderNameCol, isServer-true, serverType-nntp, isSecure-true) {
+ list-style-image: var(--folder-pane-globe-secure);
+ fill: var(--primary-fill);
+ stroke: var(--primary-stroke);
+}
+
+/* ::::: All Servers ::::: */
+
+treechildren::-moz-tree-cell-text(hasUnreadMessages-true),
+treechildren::-moz-tree-cell-text(folderNameCol, isServer-true),
+treechildren::-moz-tree-cell-text(closed, subfoldersHaveUnreadMessages-true),
+treechildren::-moz-tree-cell-text(folderNameCol, newMessages-true),
+treechildren::-moz-tree-cell-text(folderNameCol, specialFolder-Inbox, newMessages-true) {
+ font-weight: bold !important;
+}
+
+treechildren::-moz-tree-image(folderNameCol, newMessages-true),
+treechildren::-moz-tree-image(folderNameCol, isServer-true, biffState-NewMail),
+treechildren::-moz-tree-cell-text(folderNameCol, isServer-true, biffState-NewMail),
+treechildren::-moz-tree-cell-text(folderNameCol, newMessages-true),
+treechildren::-moz-tree-cell-text(folderNameCol, specialFolder-Inbox, newMessages-true) {
+ color: var(--new-folder-color) !important;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/* Make the new mail indicator better visible on dark themes */
+@media (prefers-color-scheme: dark) {
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-cell-text(hasUnreadMessages-true),
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-cell-text(closed, subfoldersHaveUnreadMessages-true) {
+ --sidebar-text-color: var(--color-white);
+ }
+
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-cell-text(folderNameCol, isServer-true, biffState-NewMail),
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-cell-text(folderNameCol, newMessages-true),
+ #folderTree:-moz-lwtheme
+ treechildren::-moz-tree-cell-text(folderNameCol, specialFolder-Inbox, newMessages-true) {
+ --new-folder-color: var(--color-blue-40);
+ }
+}
+
+treechildren::-moz-tree-image(folderNameCol, newMessages-true),
+treechildren::-moz-tree-image(folderNameCol, isServer-true, biffState-NewMail) {
+ fill: color-mix(in srgb, var(--new-folder-color) 20%, transparent) !important;
+ stroke: var(--new-folder-color) !important;
+}
+
+treechildren::-moz-tree-cell-text(folderNameCol, isServer-true, biffState-NewMail, selected, focus),
+treechildren::-moz-tree-cell-text(folderNameCol, newMessages-true, selected, focus),
+treechildren::-moz-tree-cell-text(folderNameCol, specialFolder-Inbox, newMessages-true, selected, focus) {
+ color: var(--new-focused-folder-color) !important;
+}
+
+treechildren::-moz-tree-image(folderNameCol, selected, focus),
+treechildren::-moz-tree-image(folderNameCol, newMessages-true, selected, focus),
+treechildren::-moz-tree-image(folderNameCol, isServer-true, biffState-NewMail, selected, focus) {
+ opacity: 1 !important;
+ fill: color-mix(in srgb, var(--new-focused-folder-color) 20%, transparent) !important;
+ stroke: var(--new-focused-folder-color) !important;
+}
+
+treechildren::-moz-tree-cell-text(folderNameCol, noSelect-true) {
+ opacity: 0.6;
+ font-style: italic;
+}
+
+treechildren::-moz-tree-cell-text(imapdeleted) {
+ text-decoration: line-through;
+}
+
+@media not (prefers-contrast) {
+ treechildren::-moz-tree-cell-text(imapdeleted) {
+ opacity: 0.6;
+ }
+}
+
+.tree-folder-checkbox {
+ list-style-image: none;
+}
+
+/* ::::: Folder Summary Popup ::::: */
+
+.folderSummary-message-row {
+ /* This max width ends up dictating the overall width of the popup
+ because it controls how large the preview, subject and sender text can be
+ before cropping kicks in */
+ max-width: 450px;
+}
+
+.folderSummary-subject {
+ font-weight: bold;
+}
+
+.folderSummary-previewText {
+ opacity: 0.6;
+}
+
+#folderTree treechildren::-moz-tree-cell-text(folderNameCol, newMessages-true) {
+ margin-inline-start: -20px;
+ padding-inline-start: 23px;
+ background-image: var(--icon-new-indicator);
+ background-repeat: no-repeat;
+ background-position: left;
+}
+
+#folderTree:-moz-locale-dir(rtl)
+ treechildren::-moz-tree-cell-text(folderNameCol, newMessages-true) {
+ background-position: right;
+}
+
+/* UI Density customization */
+
+:root[uidensity="touch"] #folderTree
+ treechildren::-moz-tree-cell-text(folderNameCol, newMessages-true) {
+ margin-inline-start: -24px;
+ padding-inline-start: 29px;
+ margin-block-start: -6px;
+ padding-block-start: 6px;
+}
+
+:root[uidensity="touch"] #folderTree > treechildren::-moz-tree-image {
+ width: 20px;
+ height: 20px;
+}
diff --git a/comm/mail/themes/shared/mail/folderProps.css b/comm/mail/themes/shared/mail/folderProps.css
new file mode 100644
index 0000000000..d6d03d8e3c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/folderProps.css
@@ -0,0 +1,41 @@
+/* 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/. */
+
+html {
+ min-height: 370px;
+ min-width: 750px;
+}
+
+body {
+ height: 100vh;
+ margin: 0;
+}
+
+description {
+ max-width: 80vw;
+}
+
+#nameBox {
+ display: grid;
+ grid-template-columns: auto 1fr;
+}
+
+#quotaDetails {
+ padding-inline-start: 6px;
+ list-style: none;
+}
+
+#quotaDetails > li {
+ margin-block-end: 4px;
+}
+
+progress.quota-percentage {
+ appearance: none;
+ height: 10px;
+ background-color: hsla(0, 0%, 60%, 0.2);
+ border: 1px solid var(--chrome-content-separator-color);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-inline: 1em;
+}
diff --git a/comm/mail/themes/shared/mail/glodaFacetView.css b/comm/mail/themes/shared/mail/glodaFacetView.css
new file mode 100644
index 0000000000..2707545185
--- /dev/null
+++ b/comm/mail/themes/shared/mail/glodaFacetView.css
@@ -0,0 +1,723 @@
+/* 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 url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/colors.css");
+@import url("chrome://messenger/skin/layout.css");
+@import url("chrome://messenger/skin/widgets.css");
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ height: 100%;
+ overflow-y: auto;
+}
+
+#body {
+ --dateColor: var(--color-gray-20);
+ --dateTextColor: var(--color-gray-90);
+ --barColor: var(--selected-item-color);
+ --barHlColor: var(--linkColor);
+ --dateHLColor: var(--selected-item-color);
+ --panelHoverColor: inherit;
+ --linkColor: var(--color-blue-70);
+}
+
+@media (prefers-color-scheme: dark) {
+ #body {
+ --dateColor: var(--color-gray-60);
+ --dateTextColor: var(--color-white);
+ --linkColor: var(--color-blue-30);
+ }
+}
+
+@media (prefers-contrast) {
+ #body {
+ --panelHoverColor: SelectedItemText;
+ --linkColor: -moz-NativehyperlinkText;
+ }
+}
+
+#gloda-facet-view {
+ display: flex;
+ background-color: var(--layout-background-0);
+ color: var(--layout-color-0);
+ align-items: stretch;
+}
+
+.facets-sidebar {
+ width: 20em;
+ max-width: 20em;
+ padding: 4px;
+ padding-inline-start: 1em;
+ background-color: var(--layout-background-1);
+ color: var(--layout-color-1);
+}
+
+#main-column {
+ flex: 1;
+ padding-inline-start: 1em;
+ min-height: 100vh;
+}
+
+#header {
+ max-width: 60em;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 2em;
+}
+
+#data-column {
+ margin-top: 1em;
+ margin-inline-end: 1em;
+ max-width: 60em;
+ display: flex;
+ flex-direction: column;
+}
+
+.popup-menu {
+ position: absolute;
+ display: block;
+ z-index: 100;
+ overflow: hidden;
+ padding: 3px;
+ border: 1px solid var(--arrowpanel-border-color);
+ border-radius: 8px;
+ background-color: var(--arrowpanel-background);
+ box-shadow: 0 0 4px hsla(210, 4%, 10%, .2);
+}
+
+.popup-menuitem {
+ font: menu;
+ padding: 4px 10px;
+ border-radius: 3px;
+}
+
+.popup-menuitem:hover,
+.popup-menuitem:focus {
+ background-color: var(--arrowpanel-dimmed);
+ color: var(--panelHoverColor);
+}
+
+.popup-menu[variety="remainder"] .undo {
+ display: none;
+}
+.popup-menu[variety="include"] .top {
+ display: none;
+}
+.popup-menu[variety="include"] .bottom {
+ display: none;
+}
+.popup-menu[variety="exclude"] .top {
+ display: none;
+}
+.popup-menu[variety="exclude"] .bottom {
+ display: none;
+}
+
+.popup-menuitem:focus {
+ cursor: pointer;
+}
+
+.popup-menu[variety="invisible"] {
+ display: none;
+}
+
+/* ===== Query Explanation ===== */
+
+#query-explanation {
+ padding-inline-start: 0;
+ font-size: small;
+}
+
+.explanation-fulltext-label,
+.explanation-query-label {
+ font-size: medium;
+ font-weight: bold;
+}
+
+.explanation-fulltext-label,
+.explanation-fulltext-term {
+ margin: 0 0.1em;
+}
+
+.explanation-fulltext-criteria {
+ color: var(--layout-color-3);
+ margin: 0 0.1em;
+}
+
+.explanation-query-label,
+.explanation-query-involves,
+.explanation-query-tagged {
+ margin-inline-end: 0.5ex;
+}
+
+/* ===== Facets ===== */
+
+h1, h2, h3 {
+ cursor: default;
+}
+
+.facetious[uninitialized] {
+ display: none !important;
+}
+
+.facetious {
+ display: list-item; /* take the whole column width */
+ list-style: none;
+ padding: 2px;
+}
+
+.facet-included-header[state="empty"],
+.facet-excluded-header[state="empty"],
+.facet-remaindered-header[needed="false"] {
+ display: none;
+}
+
+.facet-included-header[state="empty"] + .facet-included,
+.facet-excluded-header[state="empty"] + .facet-excluded,
+.facet-remaindered:empty {
+ display: none;
+}
+
+.facet-excluded > .bar > .bar-link {
+ text-decoration: line-through; /* strike the names of excluded facets */
+}
+
+.date-wrapper {
+ position: relative;
+ height: 80px;
+ display: block;
+ padding: 0;
+ padding-top: 0.5em;
+ margin-inline-end: 1em;
+ padding-inline-start: 2em;
+ padding-bottom: 1em;
+}
+
+.gloda-timeline-button {
+ margin-inline-start: 8px;
+}
+
+.gloda-timeline-button > img {
+ /* Icon is squashed. */
+ width: 14px;
+ height: 10px;
+ margin-inline-end: 2px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.gloda-timeline-button[checked="true"] {
+ background: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+}
+
+.facet-date-zoom-out {
+ position: absolute;
+ top: -18px;
+ left: 0;
+ width: 24px !important;
+ height: 24px !important;
+ background-position: center center;
+ background-repeat: no-repeat;
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+}
+
+html[dir="rtl"] .facet-date-zoom-out {
+ left: auto;
+ right: 0;
+}
+
+.facetious[type="date"][zoomedout="false"] .facet-date-zoom-out {
+ background-image: url("chrome://messenger/skin/icons/zoomout.svg");
+ cursor: pointer;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.facetious[type="date"][zoomedout="false"] .facet-date-zoom-out:hover {
+ fill: var(--selected-item-text-color);
+ background-color: var(--selected-item-color);
+ border-color: Field;
+}
+
+.date-vis-frame {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+html[dir="rtl"] .date-vis-frame {
+ left: auto;
+ right: 0;
+}
+
+/* === Boolean Facet === */
+
+#facet-fromMe, #facet-toMe, #facet-star, #facet-attachmentTypes {
+ display: inline-block; /* override the general .facetious list-item style */
+}
+
+/* special case: hide these two facets when they don't match */
+#facet-star[disabled], #facet-attachmentTypes[disabled] {
+ display: none;
+}
+
+.facet-checkbox-bubble {
+ display: inline-flex;
+ padding: 2px;
+ padding-inline-end: 6px;
+ border-radius: var(--button-border-radius);
+ cursor: pointer;
+}
+
+.facet-checkbox-bubble > input {
+ display: none;
+}
+
+facet-boolean[disabled] {
+ opacity: 0.6;
+}
+
+facet-boolean[disabled] > .facet-checkbox-bubble,
+facet-boolean-filtered[disabled] > .facet-checkbox-bubble {
+ cursor: default;
+}
+
+facet-boolean:not([disabled]):hover > .facet-checkbox-bubble,
+facet-boolean-filtered:not([disabled]):hover > .facet-checkbox-bubble,
+facet-boolean[checked="true"]:not([disabled]) > .facet-checkbox-bubble,
+facet-boolean-filtered[checked="true"]:not([disabled]) > .facet-checkbox-bubble {
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+.facet-checkbox-label,
+.facet-checkbox-count {
+ margin: 3px;
+}
+
+.facetious:not(:hover) > .facet-checkbox-count {
+ color: var(--layout-color-3);
+}
+.facet-checkbox-count:empty {
+ display: none;
+}
+.facet-checkbox-count::before {
+ content: "(";
+}
+.facet-checkbox-count::after {
+ content: ")";
+}
+
+/* === Boolean Filtered === */
+
+facet-boolean-filtered:not([checked]) > .facet-filter-list {
+ display: none
+}
+
+.facet-filter-list {
+ display: block;
+}
+
+/* === Discrete Facet === */
+
+.facet-content {
+ max-height: 32em;
+ overflow: auto;
+}
+
+.facet-more {
+ display: none;
+ margin: 1px;
+ margin-top: 0.5em;
+ cursor: pointer;
+}
+
+.facet-more[needed="true"] {
+ display: inline-block;
+}
+
+html[dir="rtl"] .bar-count {
+ right: auto;
+ left: 3px;
+}
+
+.barry {
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--layout-color-3);
+}
+
+.bar {
+ position: relative;
+ cursor: pointer;
+ border-bottom: 1px solid var(--layout-color-3);
+}
+
+.bar[selected="true"] {
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+.bar:hover,
+.bar:focus {
+ background-color: var(--selected-item-color);
+ outline: none;
+}
+
+.bar-link {
+ text-decoration: none;
+ display: block;
+ color: var(--linkColor);
+ padding-top: 0.3em;
+ padding-bottom: 0.3em;
+ padding-inline-start: 0.5em;
+ padding-inline-end: 4em;
+ position: relative;
+ z-index: 2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.bar-count {
+ position: absolute;
+ display: block;
+ right: 0;
+ margin-inline-end: 8px;
+ line-height: 1.6em;
+ color: var(--layout-color-3);
+}
+
+html[dir="rtl"] .bar-link {
+ padding: 0.3em 0.5em 0.3em 2em;
+}
+
+.bar:hover > .bar-link,
+.bar:hover > .bar-count,
+.bar:focus > .bar-link,
+.bar:focus > .bar-count {
+ color: var(--selected-item-text-color);
+}
+
+.bar[selected="true"]> .bar-link {
+ color: var(--selected-item-text-color);
+}
+.bar[selected="true"] > .bar-count {
+ color: var(--selected-item-text-color);
+}
+
+/* ===== Results ===== */
+
+.results-message-header {
+ display: none; /* $('.results-message-header').show() is run if there are results */
+ border-bottom: 2px solid hsla(0, 0%, 60%, 0.25);
+ padding: 4px 2px;
+ padding-inline-end: 0;
+ margin-bottom: 0.5em;
+}
+
+#results[state="some"] .results-message-header {
+ display: flex;
+ align-items: center;
+}
+
+.results-message-count {
+ font-weight: 600;
+ font-size: 1rem;
+ margin: 0;
+}
+
+.results-message-showall-button {
+ appearance: none !important;
+ color: var(--linkColor);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: var(--button-border-radius);
+ padding-inline-end: 20px;
+ margin-inline-end: 2px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-image: url("chrome://messenger/skin/icons/new-window.svg");
+ background-position: right 4px center;
+ background-size: 11px;
+ background-repeat: no-repeat;
+}
+
+.results-message-showall-button:hover,
+.results-message-showall-button:focus {
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+.results-message-sort-bar {
+ flex: 1;
+ display: flex;
+ justify-content: end;
+}
+
+#sortby {
+ appearance: none;
+ min-height: 24px;
+ color: inherit;
+ margin: 1px 5px;
+ padding-block: 0;
+ padding-inline: 4px 20px;
+ border: 1px solid var(--button-border-color);
+ border-radius: var(--button-border-radius);
+ background-color: var(--button-background-color);
+ background-image: url("chrome://global/skin/icons/arrow-down-12.svg");
+ background-position: right 4px center;
+ background-repeat: no-repeat;
+ background-size: auto 12px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#sortby:dir(rtl) {
+ background-position-x: left 4px;
+}
+
+#sortby:hover,
+#sortby:focus {
+ background-color: var(--button-hover-background-color);
+ cursor: pointer;
+}
+
+/* ===== Messages ===== */
+
+.message {
+ display: block;
+ font-family: sans-serif;
+ font-size: small;
+ padding: 3px 5px;
+ border: 1px solid transparent;
+ border-bottom-color: lightgrey;
+ border-radius: var(--button-border-radius);
+ color: var(--layout-color-1);
+ overflow: hidden;
+}
+.message:not(:first-child) {
+ border-top-style: none;
+}
+.message:last-child {
+ border-bottom-color: transparent;
+}
+
+.message:hover {
+ border-color: lightgrey;
+ box-shadow: 0 0 1px lightgrey inset, 0 0 1px lightgrey inset, 0 0 1px lightgrey inset;
+}
+
+.message:hover .message-subject {
+ color: var(--selected-item-color);
+}
+
+.message .message-subject:hover,
+.message .message-subject:focus {
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+.message:focus,
+.message[unread="true"]:focus {
+ border: 1px dotted lightgrey;
+ padding: 1em 0;
+}
+
+.message-header {
+ margin-bottom: 0.5em;
+}
+.message-meta {
+ float: inline-end;
+ padding-inline-start: 2em;
+ text-align: end;
+ max-width: 20em;
+ max-height: 10em;
+ overflow: hidden;
+ color: var(--layout-color-3);
+}
+
+.message-attachments {
+ text-align: end;
+ overflow: hidden;
+}
+
+.message-attachment {
+ max-width: 20em;
+ text-align: start;
+ display: inline-block;
+ white-space: nowrap;
+ padding-inline-start: 1ex;
+}
+
+.message-attachment::after {
+ content: ", ";
+}
+.message-attachment:last-child::after {
+ content: "";
+}
+
+.message-attachment-icon {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ background: url("chrome://messenger/skin/icons/attach.svg") transparent no-repeat center right;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+html[dir="rtl"] .message-attachment-icon {
+ background-position: center left;
+}
+
+.message-line {
+ position: relative;
+}
+
+.message-addresses-group {
+ text-align: end;
+}
+
+.message-star[starred="true"] {
+ display: inline-block;
+ width: 12px !important;
+ height: 12px;
+ background-image: url("chrome://messenger/skin/icons/flagged.svg");
+ background-size: contain;
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ fill: #f4bc44;
+ fill-opacity: 1;
+ stroke: #f4bc44;
+ stroke-opacity: 1;
+}
+
+.message-addresses-group {
+ padding-inline-start: 1em;
+}
+
+.message-subject-group {
+ padding-inline-start: 2px; /* to line up the subjects with the "Top N messages out of M" */
+}
+
+.message-author, .message-recipients {
+ text-align: end;
+ display: inline;
+ color: var(--layout-color-1);
+}
+
+.message-subject {
+ color: var(--linkColor);
+ font-size: medium;
+}
+
+.message-subject:hover {
+ cursor: pointer;
+}
+
+.message-body {
+ color: var(--layout-color-1);
+ margin-inline-start: 1em;
+ font-family: monospace;
+ font-size: medium;
+ white-space: pre-wrap;
+}
+
+.message-body-fulltext-match {
+ color: var(--selected-item-text-color);
+ background-color: var(--selected-item-color);
+ border-radius: var(--button-border-radius);
+}
+
+.message-recipients-group {
+ margin-inline-start: 0.5em;
+ font-size: small;
+}
+
+.message-tag {
+ display: inline-block; /* to avoid splitting 'To' and 'Do' e.g. */
+ margin-inline-start: 3px;
+ padding: 0 0.5ex;
+ border-radius: 3px;
+ border: 1px solid color-mix(in srgb, currentColor 50%, transparent);
+}
+
+.show-more {
+ display: none; /* $('.show-more').show() is run if there are results */
+ float: inline-end;
+ margin-block: 5px 2em;
+ margin-inline-end: 1em;
+ cursor: pointer;
+ align-self: flex-end;
+}
+
+div.loading,
+div.empty {
+ margin: 0 auto;
+ text-align: center;
+}
+
+span.loading,
+span.empty {
+ color: var(--layout-color-2);
+ background-color: var(--layout-background-2);
+ border: 1px outset var(--layout-border-2);
+ border-radius: var(--button-border-radius);
+}
+
+img.loading,
+img.empty {
+ margin: 0 1ex;
+ padding: 0;
+ border: none;
+ vertical-align: middle;
+}
+
+html[dir="rtl"] img.empty {
+ transform: scaleX(-1);
+}
+
+div.empty {
+ display: none;
+}
+
+span.empty {
+ background-color: inherit;
+ border: none;
+ font-size: large;
+ color: var(--color-blue-70);
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#facet-date {
+ max-height: 104px;
+ padding-top: 2px;
+ padding-bottom: 2px;
+ overflow: visible;
+ /* Put facet-date at the top */
+ order: -1;
+}
+
+#facet-date[hide="true"] {
+ max-height: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ #facet-date,
+ .results-message-showall-button {
+ transition: all 200ms ease;
+ }
+}
diff --git a/comm/mail/themes/shared/mail/glodacomplete.css b/comm/mail/themes/shared/mail/glodacomplete.css
new file mode 100644
index 0000000000..bfbe7fb610
--- /dev/null
+++ b/comm/mail/themes/shared/mail/glodacomplete.css
@@ -0,0 +1,51 @@
+/* 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/. */
+
+#PopupGlodaAutocomplete .explanation, .gloda-single-identity {
+ margin-inline-start: 1em;
+ margin-top: 2px;
+ margin-bottom: 2px;
+}
+
+#PopupGlodaAutocomplete .autocomplete-richlistbox {
+ display: flex;
+ justify-content: flex-start;
+ flex-direction: column;
+}
+
+#PopupGlodaAutocomplete .ac-comment {
+ font-size: 1.1em;
+ margin-inline-start: 0;
+}
+
+#PopupGlodaAutocomplete .ac-url-text {
+ color: -moz-nativehyperlinktext;
+ font-size: 0.95em;
+}
+
+#PopupGlodaAutocomplete span.ac-emphasize-text {
+ font-weight: bold;
+}
+
+#PopupGlodaAutocomplete .ac-url-text[selected="true"] {
+ color: inherit !important;
+}
+
+#PopupGlodaAutocomplete .gloda-single-identity[selected="true"] .ac-url{
+ color: white;
+}
+
+#PopupGlodaAutocomplete .parameters {
+ font-style: italic;
+ margin-inline-start: 1em;
+}
+
+/**
+ * Match type gloda-single-tag-richlistitem, gloda-single-identity-richlistitem,
+ * gloda-fulltext-single-richlistitem, gloda-fulltext-all, gloda-contact-chunk-richlistitem
+ * and gloda-multi.
+ */
+#PopupGlodaAutocomplete .autocomplete-richlistitem[type^="gloda"] {
+ overflow: clip;
+}
diff --git a/comm/mail/themes/shared/mail/grid-layout.css b/comm/mail/themes/shared/mail/grid-layout.css
new file mode 100644
index 0000000000..a83e831d34
--- /dev/null
+++ b/comm/mail/themes/shared/mail/grid-layout.css
@@ -0,0 +1,84 @@
+/* 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/. */
+
+.grid-two-column {
+ display: inline-grid;
+ grid-template-columns: auto auto;
+}
+
+.grid-three-column {
+ display: inline-grid;
+ grid-template-columns: auto auto auto;
+}
+
+.grid-two-column-fr {
+ display: inline-grid;
+}
+
+.grid-block-two-column-fr {
+ display: block grid;
+}
+
+.grid-two-column-fr, .grid-block-two-column-fr {
+ grid-template-columns: auto 1fr;
+}
+
+.grid-two-column-equalsize {
+ display: inline-grid;
+ min-width: max-content;
+ grid-template-columns: 1fr 1fr;
+}
+
+.grid-two-column-auto-min {
+ display: inline-grid;
+ grid-template-columns: auto min-content;
+}
+
+.grid-three-column-auto-x-auto {
+ display: inline-grid;
+ grid-template-columns: auto 1fr auto;
+}
+
+.grid-two-column-x-auto {
+ display: inline-grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: 1fr auto auto;
+}
+
+.grid-two-column menulist,
+.grid-two-column textarea,
+.grid-three-column menulist,
+.grid-three-column textarea {
+ width: 100%;
+}
+
+.grid-items-baseline {
+ align-items: baseline;
+}
+
+.grid-items-center {
+ align-items: center;
+}
+
+.flex-items-center {
+ display: flex;
+ align-items: center;
+}
+
+.flex-content-center {
+ justify-content: center;
+}
+
+.flex-content-column {
+ display: flex;
+ flex-direction: column;
+}
+
+.grid-item-span-row {
+ grid-column: 1 / -1;
+}
+
+.grid-item-col2 {
+ grid-column: 2 / 2;
+}
diff --git a/comm/mail/themes/shared/mail/icons.css b/comm/mail/themes/shared/mail/icons.css
new file mode 100644
index 0000000000..5af3ea1388
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons.css
@@ -0,0 +1,304 @@
+/* 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/. */
+
+/* Density variations */
+
+:root {
+ --icon-account-settings: url("chrome://messenger/skin/icons/new/compact/account-settings.svg");
+ --icon-add: url("chrome://messenger/skin/icons/new/compact/add.svg");
+ --icon-address-book: url("chrome://messenger/skin/icons/new/compact/address-book.svg");
+ --icon-app-menu: url("chrome://messenger/skin/icons/new/compact/app-menu.svg");
+ --icon-app-menu-badged: url("chrome://messenger/skin/icons/new/compact/app-menu-badged.svg");
+ --icon-archive: url("chrome://messenger/skin/icons/new/compact/archive.svg");
+ --icon-attachment: url("chrome://messenger/skin/icons/new/compact/attachment.svg");
+ --icon-calendar-invite: url("chrome://messenger/skin/icons/new/compact/calendar-invite.svg");
+ --icon-calendar-today: url("chrome://messenger/skin/icons/new/compact/calendar-today.svg");
+ --icon-calendar: url("chrome://messenger/skin/icons/new/compact/calendar.svg");
+ --icon-chat: url("chrome://messenger/skin/icons/new/compact/chat.svg");
+ --icon-check: url("chrome://messenger/skin/icons/new/compact/check.svg");
+ --icon-checkbox: url("chrome://messenger/skin/icons/new/compact/checkbox.svg");
+ --icon-clock: url("chrome://messenger/skin/icons/new/compact/clock.svg");
+ --icon-close: url("chrome://messenger/skin/icons/new/compact/close.svg");
+ --icon-cloud-download: url("chrome://messenger/skin/icons/new/compact/cloud-download.svg");
+ --icon-collapse: url("chrome://messenger/skin/icons/new/compact/collapse.svg");
+ --icon-compress: url("chrome://messenger/skin/icons/new/compact/compress.svg");
+ --icon-contact: url("chrome://messenger/skin/icons/new/compact/contact.svg");
+ --icon-conversation: url("chrome://messenger/skin/icons/new/compact/conversation.svg");
+ --icon-copy: url("chrome://messenger/skin/icons/new/compact/copy.svg");
+ --icon-cut: url("chrome://messenger/skin/icons/new/compact/cut.svg");
+ --icon-density-compact: url("chrome://messenger/skin/icons/new/compact/density-compact.svg");
+ --icon-density-default: url("chrome://messenger/skin/icons/new/compact/density-default.svg");
+ --icon-density-relaxed: url("chrome://messenger/skin/icons/new/compact/density-relaxed.svg");
+ --icon-display-options: url("chrome://messenger/skin/icons/new/compact/display-options.svg");
+ --icon-download: url("chrome://messenger/skin/icons/new/compact/download.svg");
+ --icon-draft: url("chrome://messenger/skin/icons/new/compact/draft.svg");
+ --icon-error-circle: url("chrome://messenger/skin/icons/new/compact/error-circle.svg");
+ --icon-export: url("chrome://messenger/skin/icons/new/compact/export.svg");
+ --icon-extension: url("chrome://messenger/skin/icons/new/compact/extension.svg");
+ --icon-event-status: url("chrome://messenger/skin/icons/new/compact/event-status.svg");
+ --icon-eye: url("chrome://messenger/skin/icons/new/compact/eye.svg");
+ --icon-features: url("chrome://messenger/skin/icons/new/compact/features.svg");
+ --icon-file: url("chrome://messenger/skin/icons/new/compact/file.svg");
+ --icon-filter: url("chrome://messenger/skin/icons/new/compact/filter.svg");
+ --icon-fingerprint: url("chrome://messenger/skin/icons/new/compact/fingerprint.svg");
+ --icon-flexible-space: url("chrome://messenger/skin/icons/new/compact/flexible-space.svg");
+ --icon-folder-filter: url("chrome://messenger/skin/icons/new/compact/folder-filter.svg");
+ --icon-folder-rss: url("chrome://messenger/skin/icons/new/compact/folder-rss.svg");
+ --icon-folder-save: url("chrome://messenger/skin/icons/new/compact/folder-save.svg");
+ --icon-folder: url("chrome://messenger/skin/icons/new/compact/folder.svg");
+ --icon-font: url("chrome://messenger/skin/icons/new/compact/font.svg");
+ --icon-forward: url("chrome://messenger/skin/icons/new/compact/forward.svg");
+ --icon-get-mail: url("chrome://messenger/skin/icons/new/compact/get-mail.svg");
+ --icon-globe-secure: url("chrome://messenger/skin/icons/new/compact/globe-secure.svg");
+ --icon-globe: url("chrome://messenger/skin/icons/new/compact/globe.svg");
+ --icon-handshake: url("chrome://messenger/skin/icons/new/compact/handshake.svg");
+ --icon-heart: url("chrome://messenger/skin/icons/new/compact/heart.svg");
+ --icon-hidden: url("chrome://messenger/skin/icons/new/compact/hidden.svg");
+ --icon-id: url("chrome://messenger/skin/icons/new/compact/id.svg");
+ --icon-import: url("chrome://messenger/skin/icons/new/compact/import.svg");
+ --icon-inbox: url("chrome://messenger/skin/icons/new/compact/inbox.svg");
+ --icon-info: url("chrome://messenger/skin/icons/new/compact/info.svg");
+ --icon-kebab: url("chrome://messenger/skin/icons/new/compact/kebab.svg");
+ --icon-key: url("chrome://messenger/skin/icons/new/compact/key.svg");
+ --icon-layout: url("chrome://messenger/skin/icons/new/compact/layout.svg");
+ --icon-link: url("chrome://messenger/skin/icons/new/compact/link.svg");
+ --icon-lock: url("chrome://messenger/skin/icons/new/compact/lock.svg");
+ --icon-lock-disabled: url("chrome://messenger/skin/icons/new/compact/lock-disabled.svg");
+ --icon-mail-secure: url("chrome://messenger/skin/icons/new/compact/mail-secure.svg");
+ --icon-mail: url("chrome://messenger/skin/icons/new/compact/mail.svg");
+ --icon-more: url("chrome://messenger/skin/icons/new/compact/more.svg");
+ --icon-nav-back: url("chrome://messenger/skin/icons/new/compact/nav-back.svg");
+ --icon-nav-down-unread: url("chrome://messenger/skin/icons/new/compact/nav-down-unread.svg");
+ --icon-nav-down: url("chrome://messenger/skin/icons/new/compact/nav-down.svg");
+ --icon-nav-forward: url("chrome://messenger/skin/icons/new/compact/nav-forward.svg");
+ --icon-nav-left: url("chrome://messenger/skin/icons/new/compact/nav-left.svg");
+ --icon-nav-right: url("chrome://messenger/skin/icons/new/compact/nav-right.svg");
+ --icon-nav-up-unread: url("chrome://messenger/skin/icons/new/compact/nav-up-unread.svg");
+ --icon-nav-up: url("chrome://messenger/skin/icons/new/compact/nav-up.svg");
+ --icon-new-address-book: url("chrome://messenger/skin/icons/new/compact/new-address-book.svg");
+ --icon-new-chat: url("chrome://messenger/skin/icons/new/compact/new-chat.svg");
+ --icon-new-contact: url("chrome://messenger/skin/icons/new/compact/new-contact.svg");
+ --icon-new-event: url("chrome://messenger/skin/icons/new/compact/new-event.svg");
+ --icon-new-key: url("chrome://messenger/skin/icons/new/compact/new-key.svg");
+ --icon-new-mail: url("chrome://messenger/skin/icons/new/compact/new-mail.svg");
+ --icon-new-task: url("chrome://messenger/skin/icons/new/compact/new-task.svg");
+ --icon-new-user-list: url("chrome://messenger/skin/icons/new/compact/new-user-list.svg");
+ --icon-newsletter: url("chrome://messenger/skin/icons/new/compact/newsletter.svg");
+ --icon-notification-sm: url("chrome://messenger/skin/icons/notification-fill-12.svg");
+ --icon-offline: url("chrome://messenger/skin/icons/new/compact/offline.svg");
+ --icon-online: url("chrome://messenger/skin/icons/new/compact/online.svg");
+ --icon-outbox: url("chrome://messenger/skin/icons/new/compact/outbox.svg");
+ --icon-overflow: url("chrome://messenger/skin/icons/new/compact/overflow.svg");
+ --icon-paint-brush: url("chrome://messenger/skin/icons/new/compact/paint-brush.svg");
+ --icon-paste: url("chrome://messenger/skin/icons/new/compact/paste.svg");
+ --icon-pencil: url("chrome://messenger/skin/icons/new/compact/pencil.svg");
+ --icon-photo-ban: url("chrome://messenger/skin/icons/new/compact/photo-ban.svg");
+ --icon-pin: url("chrome://messenger/skin/icons/new/compact/pin.svg");
+ --icon-print: url("chrome://messenger/skin/icons/new/compact/print.svg");
+ --icon-priority: url("chrome://messenger/skin/icons/new/compact/priority.svg");
+ --icon-priority-low: url("chrome://messenger/skin/icons/new/compact/low-priority.svg");
+ --icon-question: url("chrome://messenger/skin/icons/new/compact/question.svg");
+ --icon-quit: url("chrome://messenger/skin/icons/new/compact/quit.svg");
+ --icon-quote: url("chrome://messenger/skin/icons/new/compact/quote.svg");
+ --icon-receipt: url("chrome://messenger/skin/icons/new/compact/receipt.svg");
+ --icon-redirect: url("chrome://messenger/skin/icons/new/compact/redirect.svg");
+ --icon-remove: url("chrome://messenger/skin/icons/new/compact/remove.svg");
+ --icon-reply-all: url("chrome://messenger/skin/icons/new/compact/reply-all.svg");
+ --icon-reply-list: url("chrome://messenger/skin/icons/new/compact/reply-list.svg");
+ --icon-reply: url("chrome://messenger/skin/icons/new/compact/reply.svg");
+ --icon-restore: url("chrome://messenger/skin/icons/new/compact/restore.svg");
+ --icon-ribbon: url("chrome://messenger/skin/icons/new/compact/ribbon.svg");
+ --icon-rss: url("chrome://messenger/skin/icons/new/compact/rss.svg");
+ --icon-search: url("chrome://messenger/skin/icons/new/compact/search.svg");
+ --icon-sent: url("chrome://messenger/skin/icons/new/compact/sent.svg");
+ --icon-settings: url("chrome://messenger/skin/icons/new/compact/settings.svg");
+ --icon-shield: url("chrome://messenger/skin/icons/new/compact/shield.svg");
+ --icon-shortcut: url("chrome://messenger/skin/icons/new/compact/shortcut.svg");
+ --icon-sort: url("chrome://messenger/skin/icons/new/compact/sort.svg");
+ --icon-spaces-menu: url("chrome://messenger/skin/icons/new/compact/spaces-menu.svg");
+ --icon-spam: url("chrome://messenger/skin/icons/new/compact/spam.svg");
+ --icon-spelling: url("chrome://messenger/skin/icons/new/compact/spelling.svg");
+ --icon-star: url("chrome://messenger/skin/icons/new/compact/star.svg");
+ --icon-subtract-circle-sm: url("chrome://messenger/skin/icons/new/subtract-circle-sm.svg");
+ --icon-sync: url("chrome://messenger/skin/icons/new/compact/sync.svg");
+ --icon-tag: url("chrome://messenger/skin/icons/new/compact/tag.svg");
+ --icon-tasks: url("chrome://messenger/skin/icons/new/compact/tasks.svg");
+ --icon-template: url("chrome://messenger/skin/icons/new/compact/template.svg");
+ --icon-tentative: url("chrome://messenger/skin/icons/new/compact/tentative.svg");
+ --icon-thread: url("chrome://messenger/skin/icons/new/compact/thread.svg");
+ --icon-thread-ignored: url("chrome://messenger/skin/icons/new/compact/thread-ignored.svg");
+ --icon-subthread-ignored: url("chrome://messenger/skin/icons/new/compact/subthread-ignored.svg");
+ --icon-tools: url("chrome://messenger/skin/icons/new/compact/tools.svg");
+ --icon-trash: url("chrome://messenger/skin/icons/new/compact/trash.svg");
+ --icon-unread: url("chrome://messenger/skin/icons/new/compact/unread.svg");
+ --icon-user: url("chrome://messenger/skin/icons/new/compact/user.svg");
+ --icon-user-list: url("chrome://messenger/skin/icons/new/compact/user-list.svg");
+ --icon-warning: url("chrome://messenger/skin/icons/new/compact/warning.svg");
+ --icon-warning-sm: url("chrome://messenger/skin/icons/warning-12.svg");
+
+ /* Medium variations, touch, 20px */
+ --icon-add-md: url("chrome://messenger/skin/icons/new/normal/add.svg");
+ --icon-cloud-download-md: url("chrome://messenger/skin/icons/new/normal/cloud-download.svg");
+ --icon-download-md: url("chrome://messenger/skin/icons/new/normal/download.svg");
+ --icon-more-md: url("chrome://messenger/skin/icons/new/normal/more.svg");
+
+ /* Large variations, touch, 24px */
+ --icon-address-book-lg: url("chrome://messenger/skin/icons/new/touch/address-book.svg");
+ --icon-calendar-lg: url("chrome://messenger/skin/icons/new/touch/calendar.svg");
+ --icon-chat-lg: url("chrome://messenger/skin/icons/new/touch/chat.svg");
+ --icon-export-lg: url("chrome://messenger/skin/icons/new/touch/export.svg");
+ --icon-import-lg: url("chrome://messenger/skin/icons/new/touch/import.svg");
+ --icon-mail-lg: url("chrome://messenger/skin/icons/new/touch/mail.svg");
+
+ --icon-calendar-empty: url("chrome://messenger/skin/icons/new/calendar-empty.svg");
+ --icon-circle-small: url("chrome://messenger/skin/icons/new/circle-sm.svg");
+ --icon-loading: url("chrome://messenger/skin/icons/new/loading.svg");
+ --icon-nav-down-sm: url("chrome://messenger/skin/icons/new/nav-down-sm.svg");
+ --icon-nav-left-sm: url("chrome://messenger/skin/icons/new/nav-left-sm.svg");
+ --icon-nav-right-sm: url("chrome://messenger/skin/icons/new/nav-right-sm.svg");
+ --icon-nav-up-sm: url("chrome://messenger/skin/icons/new/nav-up-sm.svg");
+ --icon-nav-today: url("chrome://messenger/skin/icons/new/nav-today.svg");
+ --icon-column-menu: url("chrome://messenger/skin/icons/new/column-menu.svg");
+ --icon-attachment-sm: url("chrome://messenger/skin/icons/new/attachment-sm.svg");
+ --icon-spam-sm: url("chrome://messenger/skin/icons/new/spam-sm.svg");
+ --icon-star-sm: url("chrome://messenger/skin/icons/new/star-sm.svg");
+ --icon-tag-sm: url("chrome://messenger/skin/icons/new/tag-sm.svg");
+ --icon-thread-sm: url("chrome://messenger/skin/icons/new/thread-sm.svg");
+ --icon-trash-sm: url("chrome://messenger/skin/icons/new/trash-sm.svg");
+ --icon-unread-dot: url("chrome://messenger/skin/icons/new/unread-dot.svg");
+ --icon-unread-sm: url("chrome://messenger/skin/icons/new/unread-sm.svg");
+ --icon-new-indicator: url("chrome://messenger/skin/icons/folder-new-indicator.svg");
+ --icon-notify: url("chrome://messenger/skin/icons/new/notify.svg");
+ --icon-calendar-imip: url("chrome://messenger/skin/icons/new/normal/calendar-invite.svg");
+ --icon-event-start: url("chrome://messenger/skin/icons/new/event-start.svg");
+ --icon-event-end: url("chrome://messenger/skin/icons/new/event-end.svg");
+ --icon-event-continue: url("chrome://messenger/skin/icons/new/event-continue.svg");
+ --icon-mail-sm: url("chrome://messenger/skin/icons/new/mail-sm.svg");
+ --icon-bell: url("chrome://messenger/skin/icons/new/bell.svg");
+ --icon-bell-disabled: url("chrome://messenger/skin/icons/new/bell-disabled.svg");
+ --icon-bell-ring: url("chrome://messenger/skin/icons/new/bell-ring.svg");
+ --icon-recurrence: url("chrome://messenger/skin/icons/new/recurrence.svg");
+ --icon-recurrence-exception: url("chrome://messenger/skin/icons/new/recurrence-exception.svg");
+
+ --icon-forward-col: url("chrome://messenger/skin/icons/new/compact/forward-col.svg");
+ --icon-reply-col: url("chrome://messenger/skin/icons/new/compact/reply-col.svg");
+ --icon-redirect-col: url("chrome://messenger/skin/icons/new/compact/redirect-col.svg");
+ --icon-forward-redirect-col: url("chrome://messenger/skin/icons/new/compact/forward-redirect-col.svg");
+ --icon-reply-forward-col: url("chrome://messenger/skin/icons/new/compact/reply-forward-col.svg");
+ --icon-reply-forward-redirect-col: url("chrome://messenger/skin/icons/new/compact/reply-forward-redirect-col.svg");
+ --icon-reply-redirect-col: url("chrome://messenger/skin/icons/new/compact/reply-redirect-col.svg");
+
+ --icon-status-away: url("chrome://messenger/skin/icons/new/status-away.svg");
+ --icon-status-away-sm: url("chrome://messenger/skin/icons/new/status-away-sm.svg");
+ --icon-status-idle: url("chrome://messenger/skin/icons/new/status-idle.svg");
+ --icon-status-idle-sm: url("chrome://messenger/skin/icons/new/status-idle-sm.svg");
+ --icon-status-offline: url("chrome://messenger/skin/icons/new/status-offline.svg");
+ --icon-status-offline-sm: url("chrome://messenger/skin/icons/new/status-offline-sm.svg");
+ --icon-status-online: url("chrome://messenger/skin/icons/new/status-online.svg");
+ --icon-status-online-sm: url("chrome://messenger/skin/icons/new/status-online-sm.svg");
+
+ --icon-warning-dialog: url("chrome://messenger/skin/icons/new/activity/warning.svg");
+ --icon-question-dialog: url("chrome://messenger/skin/icons/new/activity/question.svg");
+
+ --spaces-icon-mail: url("chrome://messenger/skin/icons/new/normal/mail.svg");
+ --spaces-icon-address-book: url("chrome://messenger/skin/icons/new/normal/address-book.svg");
+ --spaces-icon-calendar: url("chrome://messenger/skin/icons/new/normal/calendar.svg");
+ --spaces-icon-tasks: url("chrome://messenger/skin/icons/new/normal/tasks.svg");
+ --spaces-icon-chat: url("chrome://messenger/skin/icons/new/normal/chat.svg");
+ --spaces-icon-overflow: url("chrome://messenger/skin/icons/new/normal/overflow.svg");
+ --spaces-icon-settings: url("chrome://messenger/skin/icons/new/normal/settings.svg");
+ --spaces-icon-collapse: url("chrome://messenger/skin/icons/new/normal/collapse.svg");
+
+ --folder-pane-mail: var(--icon-mail);
+ --folder-pane-mail-secure: var(--icon-mail-secure);
+ --folder-pane-inbox: var(--icon-inbox);
+ --folder-pane-draft: var(--icon-draft);
+ --folder-pane-sent: var(--icon-sent);
+ --folder-pane-archive: var(--icon-archive);
+ --folder-pane-spam: var(--icon-spam);
+ --folder-pane-trash: var(--icon-trash);
+ --folder-pane-template: var(--icon-template);
+ --folder-pane-rss: var(--icon-rss);
+ --folder-pane-globe: var(--icon-globe);
+ --folder-pane-globe-secure: var(--icon-globe-secure);
+ --folder-pane-newsletter: var(--icon-newsletter);
+ --folder-pane-outbox: var(--icon-outbox);
+ --folder-pane-folder: var(--icon-folder);
+ --folder-pane-folder-filter: var(--icon-folder-filter);
+ --folder-pane-folder-rss: var(--icon-folder-rss);
+
+ --addressbook-tree-ab: var(--icon-address-book);
+ --addressbook-tree-list: var(--icon-user-list);
+ --addressbook-tree-remote: var(--icon-globe);
+
+ --account-central-address-book: url("chrome://messenger/skin/icons/new/normal/address-book.svg");
+ --account-central-calendar: url("chrome://messenger/skin/icons/new/normal/calendar.svg");
+ --account-central-chat: url("chrome://messenger/skin/icons/new/normal/chat.svg");
+ --account-central-folder: url("chrome://messenger/skin/icons/new/normal/folder.svg");
+ --account-central-globe: url("chrome://messenger/skin/icons/new/normal/globe.svg");
+ --account-central-link: url("chrome://messenger/skin/icons/new/normal/link.svg");
+ --account-central-mail: url("chrome://messenger/skin/icons/new/normal/mail.svg");
+ --account-central-newsletter: url("chrome://messenger/skin/icons/new/normal/newsletter.svg");
+ --account-central-rss: url("chrome://messenger/skin/icons/new/normal/rss.svg");
+
+ --font-size-increase: url("chrome://messenger/skin/icons/new/normal/add-circle.svg");
+ --font-size-decrease: url("chrome://messenger/skin/icons/new/normal/subtract-circle.svg");
+
+ --addons-manager-recommendations: url("chrome://messenger/skin/icons/new/touch/features.svg");
+ --addons-manager-extensions: url("chrome://messenger/skin/icons/new/touch/extension.svg");
+ --addons-manager-themes: url("chrome://messenger/skin/icons/new/touch/paint-brush.svg");
+ --addons-manager-dictionaries: url("chrome://messenger/skin/icons/new/touch/dictionary.svg");
+ --addons-manager-languages: url("chrome://messenger/skin/icons/new/touch/language.svg");
+ --addons-manager-site-permissions: url("chrome://messenger/skin/icons/new/touch/globe.svg");
+ --addons-manager-available-updates: url("chrome://messenger/skin/icons/new/touch/extension-update-available.svg");
+ --addons-manager-recent-updates: url("chrome://messenger/skin/icons/new/touch/extension-update-recent.svg");
+
+ --icon-account-sync: url("chrome://messenger/skin/icons/account-sync.svg");
+}
+
+:root[uidensity="compact"] {
+ --spaces-icon-mail: var(--icon-mail);
+ --spaces-icon-address-book: var(--icon-address-book);
+ --spaces-icon-calendar: var(--icon-calendar);
+ --spaces-icon-tasks: var(--icon-tasks);
+ --spaces-icon-chat: var(--icon-chat);
+ --spaces-icon-overflow: var(--icon-overflow);
+ --spaces-icon-settings: var(--icon-settings);
+ --spaces-icon-collapse: var(--icon-collapse);
+
+ --font-size-increase: url("chrome://messenger/skin/icons/new/compact/add-circle.svg");
+ --font-size-decrease: url("chrome://messenger/skin/icons/new/compact/subtract-circle.svg");
+}
+
+:root[uidensity="touch"] {
+ --spaces-icon-mail: url("chrome://messenger/skin/icons/new/touch/mail.svg");
+ --spaces-icon-address-book: url("chrome://messenger/skin/icons/new/touch/address-book.svg");
+ --spaces-icon-calendar: url("chrome://messenger/skin/icons/new/touch/calendar.svg");
+ --spaces-icon-tasks: url("chrome://messenger/skin/icons/new/touch/tasks.svg");
+ --spaces-icon-chat: url("chrome://messenger/skin/icons/new/touch/chat.svg");
+ --spaces-icon-overflow: url("chrome://messenger/skin/icons/new/touch/overflow.svg");
+ --spaces-icon-settings: url("chrome://messenger/skin/icons/new/touch/settings.svg");
+ --spaces-icon-collapse: url("chrome://messenger/skin/icons/new/touch/collapse.svg");
+
+ --folder-pane-mail: url("chrome://messenger/skin/icons/new/normal/mail.svg");
+ --folder-pane-mail-secure: url("chrome://messenger/skin/icons/new/normal/mail-secure.svg");
+ --folder-pane-inbox: url("chrome://messenger/skin/icons/new/normal/inbox.svg");
+ --folder-pane-draft: url("chrome://messenger/skin/icons/new/normal/draft.svg");
+ --folder-pane-sent: url("chrome://messenger/skin/icons/new/normal/sent.svg");
+ --folder-pane-archive: url("chrome://messenger/skin/icons/new/normal/archive.svg");
+ --folder-pane-spam: url("chrome://messenger/skin/icons/new/normal/spam.svg");
+ --folder-pane-trash: url("chrome://messenger/skin/icons/new/normal/trash.svg");
+ --folder-pane-template: url("chrome://messenger/skin/icons/new/normal/template.svg");
+ --folder-pane-rss: url("chrome://messenger/skin/icons/new/normal/rss.svg");
+ --folder-pane-globe: url("chrome://messenger/skin/icons/new/normal/globe.svg");
+ --folder-pane-globe-secure: url("chrome://messenger/skin/icons/new/normal/globe-secure.svg");
+ --folder-pane-newsletter: url("chrome://messenger/skin/icons/new/normal/newsletter.svg");
+ --folder-pane-outbox: url("chrome://messenger/skin/icons/new/normal/outbox.svg");
+ --folder-pane-folder: url("chrome://messenger/skin/icons/new/normal/folder.svg");
+ --folder-pane-folder-filter: url("chrome://messenger/skin/icons/new/normal/folder-filter.svg");
+ --folder-pane-folder-rss: url("chrome://messenger/skin/icons/new/normal/folder-rss.svg");
+
+ --font-size-increase: url("chrome://messenger/skin/icons/new/touch/add-circle.svg");
+ --font-size-decrease: url("chrome://messenger/skin/icons/new/touch/subtract-circle.svg");
+}
diff --git a/comm/mail/themes/shared/mail/icons/ablist.svg b/comm/mail/themes/shared/mail/icons/ablist.svg
new file mode 100644
index 0000000000..eb06c7192f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/ablist.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M6.812 9.349a4 4 0 1 1 4.377 0A7.003 7.003 0 0 1 16 16H2a7.003 7.003 0 0 1 4.812-6.651zM9 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 3a5.001 5.001 0 0 0-4.584 3h9.168A5.001 5.001 0 0 0 9 11z"/>
+ <path d="M4.049 6.7A4 4 0 0 1 9.7 1.049 5 5 0 0 0 4.049 6.7zM1.252 14H0c0-2.89 1.75-5.37 4.249-6.439.178.543.447 1.045.788 1.488A8.016 8.016 0 0 0 1.252 14z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/aboutdebugging-logo.svg b/comm/mail/themes/shared/mail/icons/aboutdebugging-logo.svg
new file mode 100644
index 0000000000..a238bd3902
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/aboutdebugging-logo.svg
@@ -0,0 +1,6 @@
+<!-- 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" version="1.1" viewBox="0 0 24 24" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M22.75 10.2a9.42 9.42 0 00-1.22-3.13c-.05-.09-.1-.2-.17-.28-.12-.19-.26-.38-.4-.56a8 8 0 00-2.4-2.43 8.99 8.99 0 00-.52-.34c-1.6-1-2.62-1.13-2.62-1.13a8.99 8.99 0 00-4.95-.57c.13-.24.25-.33.29-.34h.02-.02c-.41.03-.91.3-1.36.58.31-.6.96-.95 1.05-1h.02c-.52-.03-1.98.3-3.23 1.69A9.86 9.86 0 001 11.79a9.45 9.45 0 002.39 6.46l.02.04a8 8 0 003.1 2.16c.1.04.18.05.25.05l-.04-.01c.02-.05-.79-1.34-1.15-2.89.72.74 1.58 1.3 2.54 1.4.1.03-.34-.52-.82-1.25l.65.23 7.17 2.39c-.7.72-1.65 1.42-2.94 2.04 0 0 2.88-.24 4.31-1.84-.58 1.34-2.18 2.06-2.18 2.06a10.63 10.63 0 006.22-3.54c2.23-2.78 2.9-5.39 2.23-8.9zm-5.7 6.5a6.14 6.14 0 01-1.5 3.3L2.7 15.6a9.2 9.2 0 01-.48-3.72c.22.41.9.75 1.3.77-.96-2.15-.68-3.41.04-4.4.26.39.62.86.98 1.39l.07-.3c.02-.14.03-.24.07-.35-.33-.48-.6-.9-.81-1.18.4-.45.84-.78 1.24-1 .01.19.05.36.1.57.07.29.09.72.05 1.06v-.01l-.03.2c-.02.07-.2.26-.26.76-.05.53.14.82.33 1.05.22-.21.63-.73 1.39-1.02.75-.3 1.25-.75 2.21-1.24.57-.29 1.17-.25 1.93-.22 1.36.24 3.14.72 4.81.8.38.9.53 2.2.53 2.23l.02.32a112.47 112.47 0 01-7.89 2.77c-.12.03-1.39-1.8-2.57-3.56-.02.04-.06.05-.07.07a.46.46 0 00-.09.1l-.25.15c1.27 1.87 2.78 3.95 2.92 3.95 1.29-.46 5.17-1.83 7.97-2.79-.02 2.11-.57 2.66-.57 2.66s.7-.27 1.43-1c0 .59-.16 2.1-1.38 3.56 0 0 .64-.17 1.36-.51z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/account-sync.svg b/comm/mail/themes/shared/mail/icons/account-sync.svg
new file mode 100644
index 0000000000..48315145b1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/account-sync.svg
@@ -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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28">
+ <path fill="url(#a)" d="M25.179 5.495A13.814 13.814 0 0 0 14.554.003C11.304-.062 9.058.915 7.787 1.7c1.7-.986 4.16-1.545 6.314-1.517 5.54.07 11.492 3.843 12.375 10.644 1.015 7.807-4.423 14.323-12.072 14.343-8.416.022-13.535-7.43-12.197-14.123.025-.328.073-.654.145-.975a13.235 13.235 0 0 1 1.467-4.907c-.969.502-2.203 2.088-2.812 3.557a14.474 14.474 0 0 0-.91 7.063c.022.181.04.362.065.542A14.022 14.022 0 1 0 25.179 5.495Z"/>
+ <path fill="url(#b)" d="M25.179 5.495A13.814 13.814 0 0 0 14.554.003C11.304-.062 9.058.915 7.787 1.7c1.7-.986 4.16-1.545 6.314-1.517 5.54.07 11.492 3.843 12.375 10.644 1.015 7.807-4.423 14.323-12.072 14.343-8.416.022-13.535-7.43-12.197-14.123.025-.328.073-.654.145-.975a13.235 13.235 0 0 1 1.467-4.907c-.969.502-2.203 2.088-2.812 3.557a14.474 14.474 0 0 0-.91 7.063c.022.181.04.362.065.542A14.022 14.022 0 1 0 25.179 5.495Z" opacity=".67"/>
+ <path fill="url(#c)" d="M25.179 5.495A13.814 13.814 0 0 0 14.554.003C11.304-.062 9.058.915 7.787 1.7c1.7-.986 4.16-1.545 6.314-1.517 5.54.07 11.492 3.843 12.375 10.644 1.015 7.807-4.423 14.323-12.072 14.343-8.416.022-13.535-7.43-12.197-14.123.025-.328.073-.654.145-.975a13.235 13.235 0 0 1 1.467-4.907c-.969.502-2.203 2.088-2.812 3.557a14.474 14.474 0 0 0-.91 7.063c.022.181.04.362.065.542A14.022 14.022 0 1 0 25.179 5.495Z"/>
+ <path fill="url(#d)" d="M25.179 5.495A13.814 13.814 0 0 0 14.554.003C11.304-.062 9.058.915 7.787 1.7c1.7-.986 4.16-1.545 6.314-1.517 5.54.07 11.492 3.843 12.375 10.644 1.015 7.807-4.423 14.323-12.072 14.343-8.416.022-13.535-7.43-12.197-14.123.025-.328.073-.654.145-.975a13.235 13.235 0 0 1 1.467-4.907c-.969.502-2.203 2.088-2.812 3.557a14.474 14.474 0 0 0-.91 7.063c.022.181.04.362.065.542A14.022 14.022 0 1 0 25.179 5.495Z"/>
+ <path fill="url(#e)" d="M25.179 5.495A13.814 13.814 0 0 0 14.554.003C11.304-.062 9.058.915 7.787 1.7c1.7-.986 4.16-1.545 6.314-1.517 5.54.07 11.492 3.843 12.375 10.644 1.015 7.807-4.423 14.323-12.072 14.343-8.416.022-13.535-7.43-12.197-14.123.025-.328.073-.654.145-.975a13.235 13.235 0 0 1 1.467-4.907c-.969.502-2.203 2.088-2.812 3.557a14.474 14.474 0 0 0-.91 7.063c.022.181.04.362.065.542A14.022 14.022 0 1 0 25.179 5.495Z"/>
+ <path fill="url(#f)" d="M26.478 10.827c.11.841.145 1.69.105 2.537.464-.07.93-.132 1.395-.186a14.004 14.004 0 0 0-2.798-7.683A13.814 13.814 0 0 0 14.556.003C11.305-.062 9.059.915 7.789 1.7c1.7-.986 4.16-1.545 6.314-1.517 5.54.07 11.491 3.842 12.375 10.644z"/>
+ <path fill="url(#g)" d="M26.819 10.44C26.03 3.33 19.692.136 14.103.182 11.949.2 9.489.714 7.789 1.7c-.45.267-.868.584-1.246.945.045-.037.18-.148.403-.301l.022-.015.02-.014a9.33 9.33 0 0 1 2.692-1.25 15.225 15.225 0 0 1 4.614-.54 11.639 11.639 0 0 1 10.938 11.198c.128 4.622-3.655 8.307-8.016 8.521-3.172.155-6.16-1.38-7.62-4.45a7.587 7.587 0 0 1-.687-2.216c-.692-4.673 2.446-8.657 5.323-9.644C12.68 2.578 8.791 2.67 5.896 4.8 3.81 6.334 2.459 8.667 2.01 11.45a11.53 11.53 0 0 0 .819 6.3 12.354 12.354 0 0 0 10.53 7.505c.348.027.697.04 1.045.04 9.262 0 13.28-7.03 12.414-14.856z"/>
+ <path fill="url(#h)" d="M26.819 10.44C26.03 3.33 19.692.136 14.103.182 11.949.2 9.489.714 7.789 1.7c-.45.267-.868.584-1.246.945.045-.037.18-.148.403-.301l.022-.015.02-.014a9.33 9.33 0 0 1 2.692-1.25 15.225 15.225 0 0 1 4.614-.54 11.639 11.639 0 0 1 10.938 11.198c.128 4.622-3.655 8.307-8.016 8.521-3.172.155-6.16-1.38-7.62-4.45a7.587 7.587 0 0 1-.687-2.216c-.692-4.673 2.446-8.657 5.323-9.644C12.68 2.578 8.791 2.67 5.896 4.8 3.81 6.334 2.459 8.667 2.01 11.45a11.53 11.53 0 0 0 .819 6.3 12.354 12.354 0 0 0 10.53 7.505c.348.027.697.04 1.045.04 9.262 0 13.28-7.03 12.414-14.856z"/>
+ <path fill="url(#i)" d="M26.819 10.44C26.03 3.33 19.692.136 14.103.182 11.949.2 9.489.714 7.789 1.7c-.45.267-.868.584-1.246.945.045-.037.18-.148.403-.301l.022-.015.02-.014a9.33 9.33 0 0 1 2.692-1.25 15.225 15.225 0 0 1 4.614-.54 11.639 11.639 0 0 1 10.938 11.198c.128 4.622-3.655 8.307-8.016 8.521-3.172.155-6.16-1.38-7.62-4.45a7.587 7.587 0 0 1-.687-2.216c-.692-4.673 2.446-8.657 5.323-9.644C12.68 2.578 8.791 2.67 5.896 4.8 3.81 6.334 2.459 8.667 2.01 11.45a11.53 11.53 0 0 0 .819 6.3 12.354 12.354 0 0 0 10.53 7.505c.348.027.697.04 1.045.04 9.262 0 13.28-7.03 12.414-14.856z" opacity=".53"/>
+ <path fill="url(#j)" d="M26.819 10.44C26.03 3.33 19.692.136 14.103.182 11.949.2 9.489.714 7.789 1.7c-.45.267-.868.584-1.246.945.045-.037.18-.148.403-.301l.022-.015.02-.014a9.33 9.33 0 0 1 2.692-1.25 15.225 15.225 0 0 1 4.614-.54 11.639 11.639 0 0 1 10.938 11.198c.128 4.622-3.655 8.307-8.016 8.521-3.172.155-6.16-1.38-7.62-4.45a7.587 7.587 0 0 1-.687-2.216c-.692-4.673 2.446-8.657 5.323-9.644C12.68 2.578 8.791 2.67 5.896 4.8 3.81 6.334 2.459 8.667 2.01 11.45a11.53 11.53 0 0 0 .819 6.3 12.354 12.354 0 0 0 10.53 7.505c.348.027.697.04 1.045.04 9.262 0 13.28-7.03 12.414-14.856z" opacity=".53"/>
+ <path fill="url(#k)" d="M17.216 20.244c5.985-.364 8.547-5.32 8.707-8.836C26.174 5.915 22.915-.007 14.294.525a15.225 15.225 0 0 0-4.614.541 10.1 10.1 0 0 0-2.692 1.25l-.02.014-.022.015a7.092 7.092 0 0 0-.393.294 11.719 11.719 0 0 1 7.342-1.377c4.95.65 9.475 4.5 9.475 9.58 0 3.91-3.02 6.895-6.558 6.68-5.255-.314-6.58-5.704-3.846-8.033-.737-.159-2.123.152-3.087 1.594-.866 1.295-.817 3.294-.283 4.711a7.742 7.742 0 0 0 7.62 4.45z"/>
+ <path fill="url(#l)" d="M25.18 5.495a13.98 13.98 0 0 0-1.218-1.394 10.97 10.97 0 0 0-1.112-1.024c.226.197.442.407.647.627a7.864 7.864 0 0 1 1.709 2.85c.73 2.215.683 4.988-.713 7.165a8.239 8.239 0 0 1-7.3 3.813c-.126 0-.253 0-.381-.01-5.255-.314-6.58-5.704-3.846-8.033-.737-.159-2.123.152-3.087 1.594-.866 1.295-.817 3.294-.283 4.711a7.587 7.587 0 0 1-.687-2.216c-.692-4.673 2.446-8.657 5.323-9.644C12.68 2.578 8.791 2.67 5.896 4.8a9.746 9.746 0 0 0-3.53 5.11A13.475 13.475 0 0 1 3.82 5.166c-.97.502-2.203 2.088-2.812 3.557a14.474 14.474 0 0 0-.91 7.062c.021.181.04.362.064.542A14.022 14.022 0 1 0 25.18 5.495Z"/>
+ <path fill="url(#m)" d="M25.206 6.554a7.86 7.86 0 0 0-1.71-2.85 10.564 10.564 0 0 0-3.182-2.273A14.182 14.182 0 0 0 17.185.322a13.942 13.942 0 0 0-5.798-.035c-1.989.42-3.738 1.28-4.844 2.357a11.235 11.235 0 0 1 2.81-1.12 11.748 11.748 0 0 1 10.911 2.835 9.453 9.453 0 0 1 1.515 1.855c1.714 2.785 1.552 6.287.216 8.353-.992 1.534-3.118 2.975-5.1 2.958a8.27 8.27 0 0 0 7.598-3.805c1.396-2.178 1.444-4.95.713-7.166z"/>
+ <path fill="url(#n)" d="M25.206 6.554a7.86 7.86 0 0 0-1.71-2.85 10.564 10.564 0 0 0-3.182-2.273A14.182 14.182 0 0 0 17.185.322a13.942 13.942 0 0 0-5.798-.035c-1.989.42-3.738 1.28-4.844 2.357a11.235 11.235 0 0 1 2.81-1.12 11.748 11.748 0 0 1 10.911 2.835 9.453 9.453 0 0 1 1.515 1.855c1.714 2.785 1.552 6.287.216 8.353-.992 1.534-3.118 2.975-5.1 2.958a8.27 8.27 0 0 0 7.598-3.805c1.396-2.178 1.444-4.95.713-7.166z"/>
+ <defs>
+ <radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="translate(25.034 5.735) scale(31.773)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#FFF36E"/>
+ <stop offset=".5" stop-color="#FC4055"/>
+ <stop offset="1" stop-color="#E31587"/>
+ </radialGradient>
+ <radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="translate(2.319 7.049) scale(18.8041)" gradientUnits="userSpaceOnUse">
+ <stop offset=".001" stop-color="#C60084"/>
+ <stop offset="1" stop-color="#FC4055" stop-opacity="0"/>
+ </radialGradient>
+ <radialGradient id="c" cx="0" cy="0" r="1" gradientTransform="matrix(37.3096 0 0 37.3097 27.75 3.927)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#FFDE67" stop-opacity=".6"/>
+ <stop offset=".093" stop-color="#FFD966" stop-opacity=".581"/>
+ <stop offset=".203" stop-color="#FFCA65" stop-opacity=".525"/>
+ <stop offset=".321" stop-color="#FEB262" stop-opacity=".432"/>
+ <stop offset=".446" stop-color="#FE8F5E" stop-opacity=".302"/>
+ <stop offset=".573" stop-color="#FD6459" stop-opacity=".137"/>
+ <stop offset=".664" stop-color="#FC4055" stop-opacity="0"/>
+ </radialGradient>
+ <radialGradient id="d" cx="0" cy="0" r="1" gradientTransform="matrix(48.1323 0 0 48.1324 14.798 15.541)" gradientUnits="userSpaceOnUse">
+ <stop offset=".153" stop-color="#810220"/>
+ <stop offset=".167" stop-color="#920B27" stop-opacity=".861"/>
+ <stop offset=".216" stop-color="#CB2740" stop-opacity=".398"/>
+ <stop offset=".253" stop-color="#EF394F" stop-opacity=".11"/>
+ <stop offset=".272" stop-color="#FC4055" stop-opacity="0"/>
+ </radialGradient>
+ <radialGradient id="e" cx="0" cy="0" r="1" gradientTransform="matrix(48.1323 0 0 48.1324 11.156 14.936)" gradientUnits="userSpaceOnUse">
+ <stop offset=".113" stop-color="#810220"/>
+ <stop offset=".133" stop-color="#920B27" stop-opacity=".861"/>
+ <stop offset=".204" stop-color="#CB2740" stop-opacity=".398"/>
+ <stop offset=".257" stop-color="#EF394F" stop-opacity=".11"/>
+ <stop offset=".284" stop-color="#FC4055" stop-opacity="0"/>
+ </radialGradient>
+ <radialGradient id="g" cx="0" cy="0" r="1" gradientTransform="matrix(29.8269 0 0 29.8891 24.31 4.232)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#FF9640"/>
+ <stop offset=".8" stop-color="#FC4055"/>
+ </radialGradient>
+ <radialGradient id="h" cx="0" cy="0" r="1" gradientTransform="matrix(29.8269 0 0 29.8891 24.31 4.232)" gradientUnits="userSpaceOnUse">
+ <stop offset=".084" stop-color="#FFDE67"/>
+ <stop offset=".147" stop-color="#FFDC66" stop-opacity=".968"/>
+ <stop offset=".246" stop-color="#FFD562" stop-opacity=".879"/>
+ <stop offset=".369" stop-color="#FFCB5D" stop-opacity=".734"/>
+ <stop offset=".511" stop-color="#FFBC55" stop-opacity=".533"/>
+ <stop offset=".667" stop-color="#FFAA4B" stop-opacity=".28"/>
+ <stop offset=".822" stop-color="#FF9640" stop-opacity="0"/>
+ </radialGradient>
+ <radialGradient id="i" cx="0" cy="0" r="1" gradientTransform="matrix(3.61273 14.2023 -14.79377 3.76319 17.478 13.529)" gradientUnits="userSpaceOnUse">
+ <stop offset=".363" stop-color="#FC4055"/>
+ <stop offset=".443" stop-color="#FD604D" stop-opacity=".633"/>
+ <stop offset=".545" stop-color="#FE8644" stop-opacity=".181"/>
+ <stop offset=".59" stop-color="#FF9640" stop-opacity="0"/>
+ </radialGradient>
+ <radialGradient id="j" cx="0" cy="0" r="1" gradientTransform="translate(14.958 14.734) scale(14.6265)" gradientUnits="userSpaceOnUse">
+ <stop offset=".216" stop-color="#FC4055" stop-opacity=".8"/>
+ <stop offset=".267" stop-color="#FD5251" stop-opacity=".633"/>
+ <stop offset=".41" stop-color="#FE8345" stop-opacity=".181"/>
+ <stop offset=".474" stop-color="#FF9640" stop-opacity="0"/>
+ </radialGradient>
+ <radialGradient id="k" cx="0" cy="0" r="1" gradientTransform="matrix(50.413 0 0 50.5181 30.262 .856)" gradientUnits="userSpaceOnUse">
+ <stop offset=".054" stop-color="#FFF36E"/>
+ <stop offset=".457" stop-color="#FF9640"/>
+ <stop offset=".639" stop-color="#FF9640"/>
+ </radialGradient>
+ <linearGradient id="f" x1="16.041" x2="24.286" y1="2.725" y2="17.007" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#FFBD4F"/>
+ <stop offset=".508" stop-color="#FF9640" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient id="l" x1="20.668" x2="6.354" y1="2.479" y2="27.272" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#FFF36E" stop-opacity=".8"/>
+ <stop offset=".094" stop-color="#FFF36E" stop-opacity=".699"/>
+ <stop offset=".752" stop-color="#FFF36E" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient id="m" x1="14.205" x2="21.805" y1="-.234" y2="21.771" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#B833E1"/>
+ <stop offset=".371" stop-color="#9059FF"/>
+ <stop offset=".614" stop-color="#5B6DF8"/>
+ <stop offset="1" stop-color="#0090ED"/>
+ </linearGradient>
+ <linearGradient id="n" x1="9.699" x2="23.825" y1=".113" y2="14.24" gradientUnits="userSpaceOnUse">
+ <stop offset=".805" stop-color="#722291" stop-opacity="0"/>
+ <stop offset="1" stop-color="#592ACB" stop-opacity=".5"/>
+ </linearGradient>
+ </defs>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/accounts.svg b/comm/mail/themes/shared/mail/icons/accounts.svg
new file mode 100644
index 0000000000..3274cb80d0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/accounts.svg
@@ -0,0 +1,9 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M7.281 5H8.75A.25.25 0 0 0 9 4.75V.984a.984.984 0 0 0-1.969 0V4.75a.25.25 0 0 0 .25.25z"/>
+ <path d="M13.5 2H11a1 1 0 0 0 0 2h2.5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5H5a1 1 0 0 0 0-2H2.5A2.5 2.5 0 0 0 0 4.5v7A2.5 2.5 0 0 0 2.5 14h11a2.5 2.5 0 0 0 2.5-2.5v-7A2.5 2.5 0 0 0 13.5 2z"/>
+ <rect x="3" y="6" width="4" height="4" rx=".577" ry=".577"/>
+ <path d="M9.5 7h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zm0 1a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/add-circle-fill.svg b/comm/mail/themes/shared/mail/icons/add-circle-fill.svg
new file mode 100644
index 0000000000..4732d4f63c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/add-circle-fill.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 16 16" width="16" height="16" fill-opacity="context-fill-opacity">
+ <circle fill="context-stroke" cx="7.6" cy="7.6" r="7.5"/>
+ <path fill="context-fill" d="M11 8.2H8.6l-.4.4V11c0 .3-.3.6-.6.6S7 11.3 7 11V8.6l-.3-.4H4.2c-.3 0-.6-.2-.6-.6s.3-.6.6-.6h2.4l.4-.3V4.2c0-.3.3-.6.6-.6s.6.3.6.6v2.4l.4.4H11c.3 0 .6.3.6.6s-.3.6-.6.6z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/addcontact.svg b/comm/mail/themes/shared/mail/icons/addcontact.svg
new file mode 100644
index 0000000000..746df96777
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/addcontact.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm.027 6c.082.734.34 1.416.73 2H1a7.003 7.003 0 0 1 4.812-6.651 4 4 0 1 1 4.377 0c.107.035.214.074.32.114a4.52 4.52 0 0 0-1.785 1.589A5.001 5.001 0 0 0 3.417 13h4.61z"/>
+ <path d="M12.5 16a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm.5-4v-1.5a.5.5 0 1 0-1 0V12h-1.5a.5.5 0 1 0 0 1H12v1.5a.5.5 0 1 0 1 0V13h1.5a.5.5 0 1 0 0-1H13z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/addlist.svg b/comm/mail/themes/shared/mail/icons/addlist.svg
new file mode 100644
index 0000000000..fb60e383a0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/addlist.svg
@@ -0,0 +1,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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M16 16h-.671H16zm-3.535-8a4.502 4.502 0 0 0-4.229 3.058A5.006 5.006 0 0 0 4.416 14h3.84a4.51 4.51 0 0 0 1.415 2H2a7.003 7.003 0 0 1 4.812-6.651A4 4 0 1 1 12.465 8zM9 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>
+ <path d="M4.049 6.7A4 4 0 0 1 9.7 1.049 5 5 0 0 0 4.049 6.7zM1.252 14H0a7 7 0 0 1 4.249-6.439c.178.543.447 1.045.788 1.488A8.016 8.016 0 0 0 1.252 14z"/>
+ <path d="M12.5 16a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm.5-4v-1.5a.5.5 0 1 0-1 0V12h-1.5a.5.5 0 1 0 0 1H12v1.5a.5.5 0 1 0 1 0V13h1.5a.5.5 0 1 0 0-1H13z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/addon-install-blocked.svg b/comm/mail/themes/shared/mail/icons/addon-install-blocked.svg
new file mode 100644
index 0000000000..caaaa466b6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/addon-install-blocked.svg
@@ -0,0 +1,38 @@
+<?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-badge-shadow {
+ fill: #0d131a;
+ fill-opacity: .15;
+ }
+ .style-badge-background {
+ fill: #fff;
+ }
+ .style-badge-inside {
+ fill: #e62117;
+ }
+ .style-badge-icon {
+ fill: #fff;
+ }
+ </style>
+ <linearGradient id="gradient-linear-puzzle-piece" x1="0%" y1="0%" x2="0%" y2="100%">
+ <stop offset="0%" stop-color="#999999" stop-opacity="1"/>
+ <stop offset="100%" stop-color="#8c8c8c" 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 width="32" height="32" x="32" y="0">
+ <ellipse class="style-badge-shadow" rx="14" ry="15" cx="16" cy="17" />
+ <circle class="style-badge-background" r="15" cy="15" cx="16" />
+ <circle class="style-badge-inside" r="12" cy="15" cx="16" />
+ <rect class="style-badge-icon" x="9" y="13" width="14" height="4" rx="1" ry="1" />
+ </svg>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/addon-install-confirm.svg b/comm/mail/themes/shared/mail/icons/addon-install-confirm.svg
new file mode 100644
index 0000000000..a164552538
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/addon-install-confirm.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/themes/shared/mail/icons/addon-install-downloading.svg b/comm/mail/themes/shared/mail/icons/addon-install-downloading.svg
new file mode 100644
index 0000000000..9dcc8069cd
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/addon-install-downloading.svg
@@ -0,0 +1,38 @@
+<?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-badge-shadow {
+ fill: #0d131a;
+ fill-opacity: .15;
+ }
+ .style-badge-background {
+ fill: #fff;
+ }
+ .style-badge-inside {
+ fill: #55cc3d;
+ }
+ .style-badge-icon {
+ fill: #fff;
+ }
+ </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 width="32" height="32" x="32" y="0">
+ <ellipse class="style-badge-shadow" rx="14" ry="15" cx="16" cy="17" />
+ <circle class="style-badge-background" r="15" cy="15" cx="16" />
+ <circle class="style-badge-inside" r="12" cy="15" cx="16" />
+ <path class="style-badge-icon" d="M22.7,16.1l-5.6,5.5C16.8,21.9,16.4,22,16,22c-0.4,0-0.7-0.1-1-0.4 l-5.6-5.5C8.8,15.5,8.9,15,9.8,15l3.2,0V9c0-0.6,0.5-1,1.1-1h4c0.6,0,1,0.4,1,1v6h3.2C23.1,15,23.3,15.5,22.7,16.1z"/>
+ </svg>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/addon-install-error.svg b/comm/mail/themes/shared/mail/icons/addon-install-error.svg
new file mode 100644
index 0000000000..e25950f258
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/addon-install-error.svg
@@ -0,0 +1,38 @@
+<?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-badge-shadow {
+ fill: #0d131a;
+ fill-opacity: .15;
+ }
+ .style-badge-background {
+ fill: #fff;
+ }
+ .style-badge-inside {
+ fill: #e62117;
+ }
+ .style-badge-icon {
+ fill: #fff;
+ }
+ </style>
+ <linearGradient id="gradient-linear-puzzle-piece" x1="0%" y1="0%" x2="0%" y2="100%">
+ <stop offset="0%" stop-color="#999999" stop-opacity="1"/>
+ <stop offset="100%" stop-color="#8c8c8c" 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 width="32" height="32" x="32" y="0">
+ <ellipse class="style-badge-shadow" rx="14" ry="15" cx="16" cy="17" />
+ <circle class="style-badge-background" r="15" cy="15" cx="16" />
+ <circle class="style-badge-inside" r="12" cy="15" cx="16" />
+ <path class="style-badge-icon" d="M14.9,16.2c0,0,0.1,0.8,1.1,0.8c1,0,1.1-0.8,1.1-0.8 s0.7-3.5,0.8-5.2C18,9.3,18.4,7,16,7s-2,2.4-1.9,4C14.2,12.7,14.9,16.2,14.9,16.2z M16,19c-1.1,0-2,0.9-2,2c0,1.1,0.9,2,2,2 c1.1,0,2-0.9,2-2C18,19.9,17.1,19,16,19z" />
+ </svg>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/addon-install-installed.svg b/comm/mail/themes/shared/mail/icons/addon-install-installed.svg
new file mode 100644
index 0000000000..3b352c21d3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/addon-install-installed.svg
@@ -0,0 +1,38 @@
+<?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-badge-shadow {
+ fill: #0d131a;
+ fill-opacity: .15;
+ }
+ .style-badge-background {
+ fill: #fff;
+ }
+ .style-badge-inside {
+ fill: #55cc3d;
+ }
+ .style-badge-icon {
+ fill: #fff;
+ }
+ </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 width="32" height="32" x="32" y="0">
+ <ellipse class="style-badge-shadow" rx="14" ry="15" cx="16" cy="17" />
+ <circle class="style-badge-background" r="15" cy="15" cx="16" />
+ <circle class="style-badge-inside" r="12" cy="15" cx="16" />
+ <path class="style-badge-icon" d="M22.8,12.3c0,0-6.7,8.1-6.9,8.3c-0.4,0.5-1.5,0.3-1.7,0 c-0.2-0.3-5-5.8-5-5.8c-0.3-0.3-0.3-0.7,0-1l1-1c0.4-0.4,0.9,0,1.2,0.3c0.3,0.4,3.4,3.8,3.4,3.8s5.2-6.1,5.4-6.4 c0.5-0.8,1.6-0.8,1.9-0.5l0.7,0.6C23.1,11.1,23.1,12,22.8,12.3z" />
+ </svg>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/addon-install-warning.svg b/comm/mail/themes/shared/mail/icons/addon-install-warning.svg
new file mode 100644
index 0000000000..bac1903c61
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/addon-install-warning.svg
@@ -0,0 +1,38 @@
+<?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-badge-shadow {
+ fill: #0d131a;
+ fill-opacity: .15;
+ }
+ .style-badge-background {
+ fill: #fff;
+ }
+ .style-badge-inside {
+ fill: #ffcd02;
+ }
+ .style-badge-icon {
+ fill: #fff;
+ }
+ </style>
+ <linearGradient id="gradient-linear-puzzle-piece" x1="0%" y1="0%" x2="0%" y2="100%">
+ <stop offset="0%" stop-color="#999999" stop-opacity="1"/>
+ <stop offset="100%" stop-color="#8c8c8c" 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 width="32" height="32" x="32" y="0">
+ <path class="style-badge-shadow" d="M29.5,25.8L18.7,4c-0.6-1.2-1.6-2-2.7-2c-1.1,0-2.1,0.7-2.7,2L2.5,25.8 c-0.6,1.2-0.6,2.5-0.1,3.6C2.9,30.4,4,31,5.2,31h21.6c1.2,0,2.3-0.6,2.8-1.6C30.2,28.4,30.1,27.1,29.5,25.8z" />
+ <path class="style-badge-background" d="M16,0c-1.7,0-3.2,1-4.1,2.7L1.7,21.9c-0.9,1.7-0.9,3.4,0,4.8C2.5,28.2,4.1,29,5.9,29H26 c1.9,0,3.4-0.8,4.3-2.2c0.9-1.4,0.8-3.2,0-4.8L20.1,2.7C19.2,1,17.7,0,16,0L16,0z" />
+ <path class="style-badge-inside" d="M5.9,26c-1.7,0-2.4-1.2-1.6-2.7L14.6,4.1c0.8-1.5,2.1-1.5,2.8,0l10.3,19.3 c0.8,1.5,0.1,2.7-1.6,2.7H5.9z" />
+ <path class="style-badge-icon" d="M14.9,17.6c0,0,0.1,0.7,1.1,0.7c1,0,1.1-0.7,1.1-0.7 s0.7-2.9,0.8-4.2c0.1-1.3,0.5-3.2-1.9-3.2c-2.4,0-2,1.9-1.9,3.2C14.2,14.8,14.9,17.6,14.9,17.6z M16,20c-1.1,0-2,0.9-2,2 c0,1.1,0.9,2,2,2c1.1,0,2-0.9,2-2C18,20.9,17.1,20,16,20z" />
+ </svg>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/address.svg b/comm/mail/themes/shared/mail/icons/address.svg
new file mode 100644
index 0000000000..71a48830b7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/address.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M3 0a2 2 0 00-2 2v1.02c-1.32.11-1.32 1.84 0 1.96v2.04c-1.32.12-1.32 1.85 0 1.96v2.04c-1.32.12-1.32 1.84 0 1.96V14c0 1.1.9 2 2 2h11a2 2 0 002-2V2a2 2 0 00-2-2zm1 2h9a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1c1.5 0 1.5-2 0-2V9c1.5 0 1.5-2 0-2V5c1.5 0 1.5-2 0-2a1 1 0 011-1zm9 10c0-3.1-.68-3.36-2.74-3.84C12.51 6.89 11.58 3.46 9 3.5c-2.53.02-3.44 3.36-1.26 4.65C5.68 8.67 5 9.02 5 12z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/anchor.svg b/comm/mail/themes/shared/mail/icons/anchor.svg
new file mode 100644
index 0000000000..f4f8f9f149
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/anchor.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 1C.667 1 .667 3 2 3h12c1.33 0 1.33-2 0-2zm1.732 9.14l.618.86H2c-1.241 0-1.258 2 0 2h2.35l-.618.86c-.809 1.02.679 2.26 1.538 1.28L7.801 12 5.27 8.859c-.874-.874-2.281.356-1.538 1.281zM2 5C.667 5 .667 7 2 7h12c1.33 0 1.33-2 0-2zm8 4c-1.333 0-1.333 2 0 2h4c1.33 0 1.33-2 0-2zm0 4c-1.333 0-1.333 2 0 2h4c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/app-update-badge.svg b/comm/mail/themes/shared/mail/icons/app-update-badge.svg
new file mode 100644
index 0000000000..3aff78f4a4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/app-update-badge.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" width="16" height="16" fill="context-fill">
+ <path d="M7.63.5a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm3.31 7.38a.62.62 0 0 1-.88 0l-1.81-1.8v5.3a.63.63 0 0 1-1.25 0v-5.3l-1.8 1.8a.63.63 0 0 1-.9-.89L7.3 4h.65l3 3a.63.63 0 0 1 0 .88z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/app-update.svg b/comm/mail/themes/shared/mail/icons/app-update.svg
new file mode 100644
index 0000000000..df68f58851
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/app-update.svg
@@ -0,0 +1,7 @@
+<!-- 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 fill="context-fill" fill-opacity="context-fill-opacity" xmlns="http://www.w3.org/2000/svg"
+ width="32" height="32" viewBox="0 0 32 32">
+ <path stroke="#fff" stroke-width="3px" stroke-linecap="round" d="M 16,9 L 16,24 M 16,9 L 11,14 M 16,9 L 21,14"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/appbutton-badged.svg b/comm/mail/themes/shared/mail/icons/appbutton-badged.svg
new file mode 100644
index 0000000000..b4a7a722ee
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/appbutton-badged.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M3 4h4c1.33 0 1.33-2 0-2H3C1.67 2 1.67 4 3 4zm10 3H3C1.67 7 1.67 9 3 9h10c1.33 0 1.33-2 0-2zm0 5H3c-1.33 0-1.33 2 0 2h10c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/appbutton.svg b/comm/mail/themes/shared/mail/icons/appbutton.svg
new file mode 100644
index 0000000000..86f38be5e4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/appbutton.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M3,4H13a1,1,0,0,0,0-2H3A1,1,0,0,0,3,4ZM13,7H3A1,1,0,0,0,3,9H13a1,1,0,0,0,0-2Zm0,5H3a1,1,0,0,0,0,2H13a1,1,0,0,0,0-2Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/archive.svg b/comm/mail/themes/shared/mail/icons/archive.svg
new file mode 100644
index 0000000000..abe6b8f598
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/archive.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M3.5 5a.5.5 0 0 1 0-1h9a.5.5 0 1 1 0 1h-9zm1-2a.5.5 0 0 1 0-1h7a.5.5 0 1 1 0 1h-7zM3 6h3a2 2 0 1 0 4 0h3a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2zm1.535 2H3v5h10V8h-1.535A3.998 3.998 0 0 1 8 10a3.998 3.998 0 0 1-3.465-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/arrow-dropdown.svg b/comm/mail/themes/shared/mail/icons/arrow-dropdown.svg
new file mode 100644
index 0000000000..de2ba56361
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/arrow-dropdown.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M8 11.7c-.26 0-.5-.12-.7-.3l-5-5c-.68-.9.5-2.38 1.4-1.78L8 8.9l4.3-4.28c.9-.68 2.1.88 1.4 1.78l-5 5c-.2.2-.46.3-.7.3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/attach.svg b/comm/mail/themes/shared/mail/icons/attach.svg
new file mode 100644
index 0000000000..c5efb3bc9c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/attach.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M5 3v9a3 3 0 0 0 6 0V3.5a.5.5 0 1 1 1 0V12a4 4 0 1 1-8 0V3a3 3 0 1 1 6 0v8a2 2 0 1 1-4 0V5.5a.5.5 0 0 1 1 0V11a1 1 0 0 0 2 0V3a2 2 0 1 0-4 0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/attachment-col.svg b/comm/mail/themes/shared/mail/icons/attachment-col.svg
new file mode 100644
index 0000000000..077f91eaca
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/attachment-col.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="14" height="14" viewBox="0 0 14 14">
+ <path fill="context-fill" d="M11 10V4c0-.28-.22-.5-.5-.5s-.5.22-.5.5v6zM6 5v4H5V5c0-.28.22-.5.5-.5s.5.22.5.5zM3 3c0-1.66 1.34-3 3-3s3 1.34 3 3v6H8V4H4v6H3zm3 6c0 .55.45 1 1 1s1-.45 1-1h1c0 1.1-.9 2-2 2s-2-.9-2-2zm-2 1c0 1.66 1.34 3 3 3s3-1.34 3-3h1c0 2.2-1.8 4-4 4s-4-1.8-4-4zm0-7v1h4V3zm4 0c0-1.1-.9-2-2-2s-2 .9-2 2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/attachment-deleted-large.svg b/comm/mail/themes/shared/mail/icons/attachment-deleted-large.svg
new file mode 100644
index 0000000000..683e94bec6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/attachment-deleted-large.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="32" height="32" viewBox="0 0 32 32">
+ <path fill="#f60e0e" d="M13.18 16.02l-6.6 6.58c-1.8 1.9.94 4.66 2.84 2.84l6.58-6.6 6.58 6.6c1.9 1.82 4.66-.94 2.84-2.84l-6.6-6.58 6.6-6.6c1.94-1.88-.94-4.76-2.84-2.8L16 13.18l-6.6-6.6c-1.88-1.8-4.64.94-2.8 2.84z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/attachment-deleted.svg b/comm/mail/themes/shared/mail/icons/attachment-deleted.svg
new file mode 100644
index 0000000000..531e8fc435
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/attachment-deleted.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="#f60e0e" d="M6.59 8l-3.3 3.29c-.9.95.47 2.33 1.42 1.42L8 9.41l3.29 3.3c.95.91 2.33-.47 1.42-1.42L9.41 8l3.3-3.3c.97-.94-.47-2.38-1.42-1.4L8 6.58l-3.3-3.3c-.94-.9-2.32.47-1.4 1.42z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/bold.svg b/comm/mail/themes/shared/mail/icons/bold.svg
new file mode 100644
index 0000000000..bd9361600f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/bold.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M3 1a1 1 0 00-1 1v12a1 1 0 001 1h6.5a4.5 4.5 0 002.91-7.93A4 4 0 0013 5a4 4 0 00-4-4H4zm2 3h4a1 1 0 011 1 1 1 0 01-1 1H5zm0 5h4.5a1.5 1.5 0 110 3H5z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/browser-back.svg b/comm/mail/themes/shared/mail/icons/browser-back.svg
new file mode 100644
index 0000000000..b52c06b776
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/browser-back.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M14.375 8 3.048 8l4.308-4.308a.626.626 0 0 0-.885-.885L1 8.281l0 .689 5.472 5.473a.623.623 0 0 0 .884 0 .628.628 0 0 0 0-.885L3.048 9.25l11.327 0a.625.625 0 0 0 0-1.25z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/browser-forward.svg b/comm/mail/themes/shared/mail/icons/browser-forward.svg
new file mode 100644
index 0000000000..2eac6f3ed7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/browser-forward.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="m1.625 8 11.327 0-4.308-4.308a.626.626 0 0 1 .885-.885L15 8.281l0 .689-5.472 5.473a.623.623 0 0 1-.884 0 .628.628 0 0 1 0-.885l4.308-4.308-11.327 0a.625.625 0 0 1 0-1.25z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/bullet-list.svg b/comm/mail/themes/shared/mail/icons/bullet-list.svg
new file mode 100644
index 0000000000..c8fcd69b9b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/bullet-list.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2.5 1.5A1.5 1.5 0 0 0 1 3a1.5 1.5 0 0 0 1.5 1.5A1.5 1.5 0 0 0 4 3a1.5 1.5 0 0 0-1.5-1.5zM7 2C5.667 2 5.667 4 7 4h6c1.33 0 1.33-2 0-2H7zM2.5 6.5A1.5 1.5 0 0 0 1 8a1.5 1.5 0 0 0 1.5 1.5A1.5 1.5 0 0 0 4 8a1.5 1.5 0 0 0-1.5-1.5zM7 7C5.667 7 5.667 9 7 9h6c1.33 0 1.33-2 0-2H7zm-4.5 4.5A1.5 1.5 0 0 0 1 13a1.5 1.5 0 0 0 1.5 1.5A1.5 1.5 0 0 0 4 13a1.5 1.5 0 0 0-1.5-1.5zM7 12c-1.333 0-1.333 2 0 2h6c1.33 0 1.33-2 0-2H7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/cancel.svg b/comm/mail/themes/shared/mail/icons/cancel.svg
new file mode 100644
index 0000000000..f47acb22de
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/cancel.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M6.586 8l-2.293 2.293a1 1 0 0 0 1.414 1.414L8 9.414l2.293 2.293a1 1 0 0 0 1.414-1.414L9.414 8l2.293-2.293a1 1 0 1 0-1.414-1.414L8 6.586 5.707 4.293a1 1 0 0 0-1.414 1.414L6.586 8zM8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/center-align.svg b/comm/mail/themes/shared/mail/icons/center-align.svg
new file mode 100644
index 0000000000..0f29d9937a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/center-align.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 1C.667 1 .667 3 2 3h12c1.33 0 1.33-2 0-2zm2 4C2.667 5 2.667 7 4 7h8c1.33 0 1.33-2 0-2zM2 9C.667 9 .667 11 2 11h12c1.33 0 1.33-2 0-2zm1 4c-1.333 0-1.333 2 0 2h10c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/cert-error.svg b/comm/mail/themes/shared/mail/icons/cert-error.svg
new file mode 100644
index 0000000000..134944525f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/cert-error.svg
@@ -0,0 +1,31 @@
+<!-- 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 version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="45" height="45" viewBox="0 0 45 45">
+ <style>
+ .icon-default {
+ fill: #999;
+ }
+ </style>
+ <defs>
+ <rect id="shape-lock-clasp-outer" x="8" y="2" width="28" height="40" rx="14" ry="14" />
+ <rect id="shape-lock-clasp-inner" x="14" y="8" width="16" height="28" rx="8" ry="8" />
+ <rect id="shape-lock-base" x="4" y="18" width="36" height="24" rx="3" ry="3" />
+ <mask id="mask-clasp-cutout">
+ <rect width="48" height="48" fill="#000" />
+ <use xlink:href="#shape-lock-clasp-outer" fill="#fff" />
+ <use xlink:href="#shape-lock-clasp-inner" fill="#000" />
+ <line x1="4" y1="38" x2="41" y2="3" stroke="#000" stroke-width="5.5" />
+ <line x1="4" y1="46" x2="41" y2="11" stroke="#000" stroke-width="5.5" />
+ <rect x="4" y="18" width="36" height="26" rx="6" ry="6" />
+ </mask>
+ <mask id="mask-base-cutout">
+ <rect width="45" height="45" fill="#000" />
+ <use xlink:href="#shape-lock-base" fill="#fff" />
+ <line x1="2.5" y1="41.5" x2="41" y2="5" stroke="#000" stroke-width="8.5" />
+ </mask>
+ </defs>
+ <use xlink:href="#shape-lock-clasp-outer" mask="url(#mask-clasp-cutout)" fill="#999" />
+ <use xlink:href="#shape-lock-base" mask="url(#mask-base-cutout)" fill="#999" />
+ <line x1="2.5" y1="41.5" x2="41" y2="5" stroke="#d92d21" stroke-width="5.5" />
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/chat.svg b/comm/mail/themes/shared/mail/icons/chat.svg
new file mode 100644
index 0000000000..84fa9763b9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/chat.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M12 16a1 1 0 0 1-.77-.37L8.26 12H2.42A2.43 2.43 0 0 1 0 9.58V3.42A2.43 2.43 0 0 1 2.42 1h11.16A2.43 2.43 0 0 1 16 3.42v6.16A2.43 2.43 0 0 1 13.58 12H13v3a1 1 0 0 1-1 1zM2.42 3a.42.42 0 0 0-.42.42v6.16c0 .232.188.42.42.42h6.31a1 1 0 0 1 .77.37l1.5 1.82V11a1 1 0 0 1 1-1h1.58a.42.42 0 0 0 .42-.42V3.42a.42.42 0 0 0-.42-.42H2.42z"/>
+ <path fill="context-fill" fill-opacity="context-stroke-opacity" d="M2.42 3a.42.42 0 0 0-.42.42v6.16c0 .232.188.42.42.42h6.31a1 1 0 0 1 .77.37l1.5 1.82V11c0-.55.45-1 1-1h1.58c.23 0 .42-.188.42-.42V3.42c0-.232-.19-.42-.42-.42z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/checkbox.svg b/comm/mail/themes/shared/mail/icons/checkbox.svg
new file mode 100644
index 0000000000..0d94782d98
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/checkbox.svg
@@ -0,0 +1,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" width="16" height="16" viewBox="0 0 16 16">
+ <rect fill="none" stroke="context-stroke" stroke-opacity="1" width="15" height="15" x="0.5" y="0.5" rx="2" ry="2" />
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M6 14a1 1 0 0 1-.7-.3l-3-3a1 1 0 0 1 1.4-1.4l2.16 2.15 6.32-9.02a1 1 0 0 1 1.64 1.14l-7 10a1 1 0 0 1-.73.43.86.86 0 0 1-.09 0z"/>
+ <path fill="context-fill" fill-opacity="context-stroke-opacity" d="M 2,7 H 14 V 9 H 2 Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/collapse.svg b/comm/mail/themes/shared/mail/icons/collapse.svg
new file mode 100644
index 0000000000..b721d38203
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/collapse.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="m3.293 7.293a1 1 0 0 0 0 1.414l5 5a1 1 0 0 0 1.414-1.414l-3.293-3.293h8.586a1 1 0 1 0 0-2h-8.586l3.293-3.293a1 1 0 0 0-1.414-1.414zm-1.293 5.707v-10a1 1 0 0 0-2 0v10a1 1 0 0 0 2 0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/compact.svg b/comm/mail/themes/shared/mail/icons/compact.svg
new file mode 100644
index 0000000000..20fb907707
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/compact.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M9 15v-2.5a.5.5 0 1 1 1 0V15h1.82l-.667-4H4.847l-.667 4H6v-2.5a.5.5 0 1 1 1 0V15h2zm3.004 1h-8a1 1 0 0 1-.98-1.2L4 10c0-1.864 2.5-3 3-3V1a1 1 0 1 1 2 0v6c.5 0 3 1.136 3 3l.983 4.8a1 1 0 0 1-.98 1.2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/connection-insecure.svg b/comm/mail/themes/shared/mail/icons/connection-insecure.svg
new file mode 100644
index 0000000000..1d558a2dd9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/connection-insecure.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="M12.5 6.984h-.484L4 15h8.5a1.5 1.5 0 0 0 1.5-1.5V8.484a1.5 1.5 0 0 0-1.5-1.5zm-6.5 0V5a2 2 0 0 1 4 0v1l1.892-1.892A4 4 0 0 0 4 5v1.984h-.5a1.5 1.5 0 0 0-1.5 1.5V13.5a1.483 1.483 0 0 0 .07.43l6.946-6.946z" fill="context-fill"/>
+ <path d="M2 15a1 1 0 0 1-.707-1.707l12-12a1 1 0 0 1 1.414 1.414l-12 12A1 1 0 0 1 2 15z" fill="#ff0039"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/connection-mixed.svg b/comm/mail/themes/shared/mail/icons/connection-mixed.svg
new file mode 100644
index 0000000000..9238cdc18b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/connection-mixed.svg
@@ -0,0 +1,9 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="M8 1a4 4 0 0 0-4 4v1.984h-.5a1.5 1.5 0 0 0-1.5 1.5V13.5A1.5 1.5 0 0 0 3.5 15h2.535a2.274 2.274 0 0 1 .207-1.318L9.43 7.27a2.266 2.266 0 0 1 .2-.286H6V5a2 2 0 0 1 4 0v1.568A2.255 2.255 0 0 1 11.478 6a2.283 2.283 0 0 1 .522.073V5a4 4 0 0 0-4-4z" fill="context-fill"/>
+ <path d="M15.818 14.127l-3.189-6.411a1.285 1.285 0 0 0-2.3 0l-3.192 6.411A1.294 1.294 0 0 0 8.289 16h6.377a1.294 1.294 0 0 0 1.152-1.873z" fill="#ffbf00"/>
+ <path d="M11.478 8a.272.272 0 0 1 .256.161l3.188 6.412a.291.291 0 0 1-.013.291.275.275 0 0 1-.243.137H8.289a.275.275 0 0 1-.243-.137.29.29 0 0 1-.013-.291l3.188-6.412A.272.272 0 0 1 11.478 8m0-1a1.272 1.272 0 0 0-1.152.716l-3.189 6.411A1.294 1.294 0 0 0 8.289 16h6.377a1.294 1.294 0 0 0 1.152-1.873l-3.189-6.411A1.272 1.272 0 0 0 11.478 7z" fill="#d76e00" opacity=".35"/>
+ <path d="M11.5 12a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-1 0v2a.5.5 0 0 0 .5.5zm0 .809a.691.691 0 1 0 .691.691.691.691 0 0 0-.691-.691z" fill="#fff"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/connection-secure.svg b/comm/mail/themes/shared/mail/icons/connection-secure.svg
new file mode 100644
index 0000000000..7636448b3a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/connection-secure.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M12,7 L13,7 C13.5522847,7 14,7.44771525 14,8 L14,14 C14,14.5522847 13.5522847,15 13,15 L3,15 C2.44771525,15 2,14.5522847 2,14 L2,8 C2,7.44771525 2.44771525,7 3,7 L4,7 L4,5.00032973 C4,2.79202307 5.79321704,1 8,1 C10.2075938,1 12,2.79481161 12,5.00032973 L12,7 Z M10,7 L10,5.00032973 C10,3.89878113 9.10242341,3 8,3 C6.89748845,3 6,3.89689088 6,5.00032973 L6,7 L10,7 Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/contact-generic.svg b/comm/mail/themes/shared/mail/icons/contact-generic.svg
new file mode 100644
index 0000000000..1600b8fa2a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/contact-generic.svg
@@ -0,0 +1,15 @@
+<!-- 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="150" height="150" viewBox="0 0 150 150">
+ <defs>
+ <linearGradient gradientUnits="userSpaceOnUse" y2="33" x2="1" y1="53" x1="62" id="lg1" xlink:href="#lg2"/>
+ <linearGradient id="lg2">
+ <stop offset="0" stop-color="#a7a7a7" stop-opacity="1"/>
+ <stop offset="1" stop-color="#a7a7a7" stop-opacity="0.15"/>
+ </linearGradient>
+ </defs>
+ <rect fill="#ffffff" y="0" x="0" height="150" width="150"/>
+ <rect fill="url(#lg1)" y="8" x="8" height="134" width="134"/>
+ <path fill="#000000" d="M8 142v-11L53 111.8c1.7-4.8 2.2-9.7-.4-16.3C49 92.1 47.3 86 46.3 80a40.3 40.3 0 01-6.9-22.5c.2-1.9 3.1-3.3 4.6 0-1-8.5-.6-17 1.2-25.2l2.7 2.3 13.9-17.7-.6 6.6 17.3-4.3L95 23.9l-2.5 2.8 17.8 6.4-8.5 2.6 6.7 9.7-5 .3c1 4.2.8 8 .3 12 1.3-1.3 3.7-2.3 3.9 1.2-.1 8-2.8 20.1-6 20.3a25 25 0 01-7.2 16.2 40.5 40.5 0 00-.5 16.8l48 20v10z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/contact.svg b/comm/mail/themes/shared/mail/icons/contact.svg
new file mode 100644
index 0000000000..1b4b6f7d35
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/contact.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M5.81 8.35a4 4 0 114.38 0A7 7 0 0115 15H1a7 7 0 014.81-6.65zM8 7a2 2 0 100-4 2 2 0 000 4zm0 3a5 5 0 00-4.58 3h9.16A5 5 0 008 10z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/conversation.svg b/comm/mail/themes/shared/mail/icons/conversation.svg
new file mode 100644
index 0000000000..f73088ec83
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/conversation.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M16 9.671a4.509 4.509 0 0 0-2-1.415V5c0-.1 0-.2-.1-.3 0 .1 0 .2-.1.2l-5.5 4c-.1.1-.2.1-.3.1-.1 0-.2 0-.3-.1l-5.5-4c-.1 0-.2 0-.2-.1V11c0 .6.4 1 1 1h5.027a4.55 4.55 0 0 0 .23 2H3c-1.7 0-3-1.3-3-3V5c0-1.7 1.3-3 3-3h10c1.7 0 3 1.3 3 3v4.671zM13 4H3c-.1 0-.2 0-.3.1h.1L8 7.9l5.2-3.8h.1c-.1-.1-.2-.1-.3-.1z"/>
+ <path d="M12.5 16a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm2.354-2.146A.498.498 0 0 0 14.5 13h-4a.5.5 0 1 0 0 1h2.793l-.147.146a.5.5 0 0 0 .708.708l1-1zm-4.708-2.708A.498.498 0 0 0 10.5 12h4a.5.5 0 1 0 0-1h-2.793l.147-.146a.5.5 0 0 0-.708-.708l-1 1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/correspondents-rtl.svg b/comm/mail/themes/shared/mail/icons/correspondents-rtl.svg
new file mode 100644
index 0000000000..15ecf13250
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/correspondents-rtl.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M13 7H5.41l2.3-2.3c.9-.94-.47-2.32-1.42-1.4l-4 4a1 1 0 000 1.4l4 4.01c.95.91 2.33-.47 1.42-1.42L5.4 9H13c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/correspondents.svg b/comm/mail/themes/shared/mail/icons/correspondents.svg
new file mode 100644
index 0000000000..b4e6299af0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/correspondents.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M13.71 7.3l-4-4c-.95-.92-2.33.47-1.42 1.4L10.6 7H3C1.67 7 1.67 9 3 9h7.59l-2.3 2.29c-.97.95.48 2.39 1.42 1.42l4-4a1 1 0 000-1.42z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/cut.svg b/comm/mail/themes/shared/mail/icons/cut.svg
new file mode 100644
index 0000000000..e0e685c4c1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/cut.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M11.5 10a2.481 2.481 0 0 0-.379.038L3.977 1.214a2.5 2.5 0 0 0-.371 3.515l2.789 3.444-1.51 1.866A2.486 2.486 0 0 0 4.5 10a2.522 2.522 0 1 0 2.329 1.609L8 10.159 9.172 11.6A2.5 2.5 0 1 0 11.5 10zm-7 3.75a1.25 1.25 0 1 1 1.25-1.25 1.251 1.251 0 0 1-1.25 1.25zm7 0a1.25 1.25 0 1 1 1.25-1.25 1.251 1.251 0 0 1-1.25 1.25zm.9-9.021a2.5 2.5 0 0 0-.371-3.515L8.5 5.569l1.608 1.986z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/decrease.svg b/comm/mail/themes/shared/mail/icons/decrease.svg
new file mode 100644
index 0000000000..3e76de2fbe
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/decrease.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M10 10l3 4.5 3-4.5zM1 4a1 1 0 100 2h3v8c0 .55.45 1 1 1s.93-.45 1-1V6h3a1 1 0 100-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/delete-col.svg b/comm/mail/themes/shared/mail/icons/delete-col.svg
new file mode 100644
index 0000000000..de9c226249
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/delete-col.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M6.5 2.01c-.67 0-1.34.31-1.5.99H3C1.67 3 1.67 5 3 5v5c0 1 1 2 2 2h3c1 0 2-1 2-2V5c1.33 0 1.33-2 0-2H8c-.16-.64-.83-.98-1.5-.99zM5.5 5c.25 0 .5.17.5.5v4a.5.5 0 01-1 0v-4c0-.33.25-.5.5-.5zm1.98 0c.25 0 .5.17.5.5v4a.5.5 0 01-1 0v-4c0-.33.25-.5.5-.5z"/>
+</svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/delete.svg b/comm/mail/themes/shared/mail/icons/delete.svg
new file mode 100644
index 0000000000..1a5dc2c023
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/delete.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M6.5 12a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5zm2 0a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5zm2 0a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5z"/>
+ <path d="M14 2h-3.05a2.5 2.5 0 0 0-4.9 0H3a1 1 0 0 0 0 2v9a3 3 0 0 0 3 3h5a3 3 0 0 0 3-3V4a1 1 0 0 0 0-2zM8.5 1a1.489 1.489 0 0 1 1.391 1H7.109A1.489 1.489 0 0 1 8.5 1zM12 13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4h7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/developer.svg b/comm/mail/themes/shared/mail/icons/developer.svg
new file mode 100644
index 0000000000..c2496a1f44
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/developer.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M14.555 3.2l-2.434 2.436a1.243 1.243 0 1 1-1.757-1.757L12.8 1.445A3.956 3.956 0 0 0 11 1a3.976 3.976 0 0 0-3.434 6.02l-6.273 6.273a1 1 0 1 0 1.414 1.414L8.98 8.434A3.96 3.96 0 0 0 11 9a4 4 0 0 0 4-4 3.956 3.956 0 0 0-.445-1.8z"></path>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/download.svg b/comm/mail/themes/shared/mail/icons/download.svg
new file mode 100644
index 0000000000..f61d718648
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/download.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M7.3 13.92a1 1 0 0 0 1.4 0l5-5a1 1 0 0 0-1.4-1.4L9 10.8V2.2a1 1 0 0 0-2 0v8.6L3.7 7.5a1 1 0 0 0-1.4 1.4l5 5z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/empty-search-results.svg b/comm/mail/themes/shared/mail/icons/empty-search-results.svg
new file mode 100644
index 0000000000..29286aeb50
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/empty-search-results.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 185 185" width="185px" height="185px">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M74.8 1.08a71.6 70.98 0 0 0-61 30.12A76.91 76.91 0 0 0 1 73.33a72.34 71.72 0 0 0 72.2 71.54c14.4 0 28.8-4.78 41.6-12.73l49.8 49.29c4.8 4.76 12 4.76 16.8 0a11.64 11.54 0 0 0 0-16.69l-49.8-50.09A72.16 71.54 0 0 0 114.8 14.5 73.44 72.8 0 0 0 74.8 1Zm-1.6 24.57c26.4 0 48 21.45 48 47.68a48.2 47.78 0 0 1-48 47.69A48.24 47.82 0 0 1 25 73.33a48.24 47.82 0 0 1 48.2-47.68ZM50.8 58.38 66 73.73 50.8 88.97c-5 4.78 2.4 11.94 7.2 7.16l15.2-15.32 14.4 15.24c4.8 4.7 12-2.46 7.2-7.15L80.4 73.65l15.2-15.27c4-4.68-2.4-11.04-7.2-7.08L73.2 66.57 58 51.3c-5.6-3.73-10.4 2.16-7.2 7.08z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/encryption-key.svg b/comm/mail/themes/shared/mail/icons/encryption-key.svg
new file mode 100644
index 0000000000..ab5432bb57
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/encryption-key.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="context-fill" fill-opacity="context-fill-opacity" d="M12 4a4 4 0 0 0-3.86 3H1a1 1 0 0 0 0 2v1a1 1 0 0 0 2 0V9h1v1a1 1 0 0 0 2 0V9h2.14A4 4 0 1 0 12 4zm0 6a2 2 0 1 1 2-2 2 2 0 0 1-2 2z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/exclude.svg b/comm/mail/themes/shared/mail/icons/exclude.svg
new file mode 100644
index 0000000000..dcd05894c3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/exclude.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" width="16px" height="16px">
+ <path d="M 10.508 3.674 C 9.77 3.246 8.914 3 8 3 C 5.24 3 3 5.24 3 8 L 3 8 C 3 8.914 3.246 9.77 3.674 10.508 L 10.508 3.674 Z M 12.326 5.492 C 12.754 6.23 13 7.086 13 8 C 13 10.76 10.76 13 8 13 C 7.086 13 6.23 12.754 5.492 12.326 L 12.326 5.492 Z M 1 8 C 1 4.137 4.137 1 8 1 C 11.863 1 15 4.137 15 8 C 15 11.863 11.863 15 8 15 C 4.137 15 1 11.863 1 8 L 1 8 Z" fill-rule="evenodd" fill="context-fill"/>
+</svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/feeds-folder.svg b/comm/mail/themes/shared/mail/icons/feeds-folder.svg
new file mode 100644
index 0000000000..dd0179007a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/feeds-folder.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M2 1a2 2 0 00-2 2v10c0 1.1.9 2 2 2h12a2 2 0 002-2V5a2 2 0 00-2-2H8.15L6.58 1.54A2 2 0 005.22 1H2zm0 2h3.22L6.3 4H2V3zm6 1.99l.15.01H14v8H2V5h6v-.01zM5 5.5a.5.5 0 000 1 5.44 5.44 0 015.5 5.5.5.5 0 001 0A6.43 6.43 0 005 5.5zm0 2a.5.5 0 000 1A3.46 3.46 0 018.5 12a.5.5 0 001 0A4.45 4.45 0 005 7.5zm.75 2.5A1.25 1.25 0 107 11.25 1.25 1.25 0 005.75 10z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/feeds.svg b/comm/mail/themes/shared/mail/icons/feeds.svg
new file mode 100644
index 0000000000..b07091fa92
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/feeds.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity"><path d="M3.5 10A2.5 2.5 0 1 0 6 12.5 2.5 2.5 0 0 0 3.5 10zM2 1a1 1 0 0 0 0 2 10.883 10.883 0 0 1 11 11 1 1 0 0 0 2 0A12.862 12.862 0 0 0 2 1zm0 4a1 1 0 0 0 0 2 6.926 6.926 0 0 1 7 7 1 1 0 0 0 2 0 8.9 8.9 0 0 0-9-9z"></path></svg>
diff --git a/comm/mail/themes/shared/mail/icons/file-item.svg b/comm/mail/themes/shared/mail/icons/file-item.svg
new file mode 100644
index 0000000000..c01f14d1be
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/file-item.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M13 0H3a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1zm-2.5-9h-5a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zm0 2h-5a.5.5 0 0 0 0 1h5a.5.5 0 1 0 0-1zm0 2h-5a.5.5 0 0 0 0 1h5a.5.5 0 1 0 0-1zm-3 2h-2a.5.5 0 0 0 0 1h2a.5.5 0 1 0 0-1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/file.svg b/comm/mail/themes/shared/mail/icons/file.svg
new file mode 100644
index 0000000000..b7268c328d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/file.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M7.293 12.707a1 1 0 0 0 1.414 0l5-5a1 1 0 0 0-1.414-1.414L9 9.586V1a1 1 0 1 0-2 0v8.586L3.707 6.293a1 1 0 0 0-1.414 1.414zM13 14H3a1 1 0 0 0 0 2h10a1 1 0 0 0 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/filter.svg b/comm/mail/themes/shared/mail/icons/filter.svg
new file mode 100644
index 0000000000..ad403b2a96
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/filter.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M3 3l4 5v5h2V8l4-5H3zm8 5.702V13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V8.702L1.438 4.249C.391 2.94 1.323 1 3 1h10c1.677 0 2.61 1.94 1.562 3.25L11 8.701z"/>
+ <path fill-opacity=".3" d="M4.5 5h7L9 8v5H7V8z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/fingerprint.svg b/comm/mail/themes/shared/mail/icons/fingerprint.svg
new file mode 100644
index 0000000000..f28ff18706
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/fingerprint.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity"><path d="M8 0a7 7 0 0 0-7 7 1 1 0 0 0 2 0 5 5 0 0 1 10 0 8.24 8.24 0 0 1-1.14 4.28A5.75 5.75 0 0 0 11 14a1 1 0 0 0 2 0 3.91 3.91 0 0 1 .63-1.79A10.18 10.18 0 0 0 15 7a7 7 0 0 0-7-7z"></path><path d="M8 3a4 4 0 0 0-4 4 2 2 0 0 1-2 2 1 1 0 0 0 0 2 4 4 0 0 0 4-4 2 2 0 0 1 4 0 6.88 6.88 0 0 1-1.74 4.8 11 11 0 0 0-1.15 1.75 1 1 0 0 0 .44 1.34A.93.93 0 0 0 8 15a1 1 0 0 0 .89-.55 9.74 9.74 0 0 1 1-1.44A8.84 8.84 0 0 0 12 7a4 4 0 0 0-4-4z"></path><path d="M8 6a1 1 0 0 0-1 1c0 4.21-5.26 6-5.32 6.05a1 1 0 0 0-.63 1.27A1 1 0 0 0 2 15a1.25 1.25 0 0 0 .32 0C2.59 14.86 9 12.66 9 7a1 1 0 0 0-1-1z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/flag-col.svg b/comm/mail/themes/shared/mail/icons/flag-col.svg
new file mode 100644
index 0000000000..ddc3614c7c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/flag-col.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill">
+ <path d="M9.3 11L6 9.34l-3.26 1.63.77-3.61L.87 4.98l3.57-.47L6.06.89l1.45 3.59 3.62.5-2.46 2.45z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/flagged.svg b/comm/mail/themes/shared/mail/icons/flagged.svg
new file mode 100644
index 0000000000..e0f1e15412
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/flagged.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-stroke" fill-opacity="context-stroke-opacity" d="M8 2l2 4 4 .7-3 2.58L12 14l-4-2-4 2 1-4.72L2 6.7 6 6z"/>
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M14.95 6.3a1.06 1.06 0 00-.84-.71l-3.58-.52-1.6-3.26a1.08 1.08 0 00-1.86 0l-1.6 3.26-3.58.52a1.05 1.05 0 00-.57 1.8L3.9 9.91l-.6 3.58c-.16.85.74 1.51 1.5 1.1L8 12.91l3.2 1.7c.15.12.31.12.49.12.22 0 .43-.12.6-.21.33-.24.48-.62.42-1.02l-.6-3.59 2.59-2.53c.28-.28.38-.7.25-1.08zm-5.1 2.88l.45 2.57L8 10.54l-2.3 1.21.44-2.57L4.3 7.36 6.86 7 8 4.65l1.15 2.34 2.56.37z"/>
+</svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/folder-local.svg b/comm/mail/themes/shared/mail/icons/folder-local.svg
new file mode 100644
index 0000000000..4533451d60
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/folder-local.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill="context-fill" fill-opacity="context-fill-opacity" width="16" height="16" viewBox="0 0 16 16">
+ <path d="M14 3H8.151L6.584 1.538A2 2 0 0 0 5.219 1H2a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM5.219 3l1.072 1H2V3zM14 13H2V5h6v-.014c.05 0 .1.014.151.014H14z"/>
+ <path fill-opacity=".3" d="M 14,13 H 2 V 5 h 12 z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/folder-new-indicator.svg b/comm/mail/themes/shared/mail/icons/folder-new-indicator.svg
new file mode 100644
index 0000000000..3de1d7faca
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/folder-new-indicator.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="8" height="16" viewBox="0 0 8 16">
+ <circle r="3" cy="4" cx="4" fill="#ffe900" stroke="#ff9400" stroke-width="1.5"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/folder.svg b/comm/mail/themes/shared/mail/icons/folder.svg
new file mode 100644
index 0000000000..6738ad6749
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/folder.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M13 4H7.85L6.38 2.54A1.93 1.93 0 005.02 2H2a2 2 0 00-2 2v9c0 1.1.9 2 2 2h11a2 2 0 002-2V6a2 2 0 00-2-2zM5 4l1 1H2V4zm8 9H2V6h5.8v-.01c0-.05.1.01.15.01H13z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/forget.svg b/comm/mail/themes/shared/mail/icons/forget.svg
new file mode 100644
index 0000000000..56b9c0b057
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/forget.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M6.854 10.854l2-2A.5.5 0 0 0 9 8.5v-4a.5.5 0 0 0-1 0v3.793l-1.854 1.853a.5.5 0 1 0 .707.707zM8 0a8.011 8.011 0 0 0-7 4.184V1.5a.5.5 0 0 0-1 0v5a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 0-1H2.344a.938.938 0 0 0 .056-.085 6 6 0 1 1 0 4.184 1 1 0 0 0-1.873.7A7.991 7.991 0 1 0 8 0z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/format-dropmarker.svg b/comm/mail/themes/shared/mail/icons/format-dropmarker.svg
new file mode 100644
index 0000000000..d1f1e2a01b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/format-dropmarker.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="8" height="6" viewBox="0 0 8 6">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M4 5.8c-.26 0-.5-.12-.7-.3l-3-3C-.38 1.6.8.12 1.7.72L4 3 6.3.72c.9-.68 2.1.88 1.4 1.78l-3 3a1 1 0 0 1-.7.3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/forward-redirect.svg b/comm/mail/themes/shared/mail/icons/forward-redirect.svg
new file mode 100644
index 0000000000..07806bd2df
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/forward-redirect.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M12.21 5h-2.15c-1.1 0-1.92-.11-2.66.48-.32.25-.34.24-.63.53-.95.96-2.38-.48-1.41-1.42.3-.33.49-.4.79-.67 1.07-.86 2.34-.94 3.93-.92h2.16l-.94-1.16c-1-1.03.69-2.44 1.53-1.28L15.66 4l-2.83 3.44c-.86.9-2.26-.27-1.53-1.28z"/>
+ <path fill="context-stroke" fill-opacity="context-fill-opacity" d="M12.44 8.16c-.76-.03-1.48.91-.81 1.68L13.36 12l-1.73 2.16c-.8 1.02.68 2.26 1.53 1.28L16 12l-2.84-3.44a.97.97 0 00-.72-.4zm-4.19.04c-.74-.01-1.42.88-.82 1.64L8.35 11H6.19c-.96.03-1.9-.3-2.65-.92-.32-.25-.54-.5-.83-.8-.95-.96-2.4.48-1.42 1.43.3.33.7.66 1 .93 1.1.9 2.49 1.4 3.91 1.36h2.17l-.95 1.16c-.9 1.03.69 2.35 1.54 1.28L11.8 12 8.97 8.56a.97.97 0 00-.72-.36z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/forward.svg b/comm/mail/themes/shared/mail/icons/forward.svg
new file mode 100644
index 0000000000..09772f53e2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/forward.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M10.35 9.017c-1.263.006-2.315.004-3.156-.003-1.092-.01-1.922.314-2.657.905a6.041 6.041 0 0 0-.45.404c-.09.088-.362.367-.38.384a1 1 0 1 1-1.414-1.414c.004-.004.286-.292.393-.397.199-.196.39-.368.598-.535 1.076-.866 2.344-1.362 3.929-1.347.839.008 1.894.009 3.166.003L9.232 5.64a1 1 0 1 1 1.536-1.28L13.802 8l-3.034 3.64a1 1 0 0 1-1.536-1.28l1.119-1.343z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/founder.png b/comm/mail/themes/shared/mail/icons/founder.png
new file mode 100644
index 0000000000..aca3a925c6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/founder.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/icons/get-all.svg b/comm/mail/themes/shared/mail/icons/get-all.svg
new file mode 100644
index 0000000000..8427692084
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/get-all.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 0c-.5 0-1 .33-1 1v8.59l-3.3-3.3c-.94-.9-2.32.47-1.4 1.42l5 5a1 1 0 001.4 0l5.01-5c.91-.95-.47-2.33-1.42-1.42L9 9.6V1c0-.67-.5-1-1-1z"/><path d="M14 11a1 1 0 00-1 1v1a1 1 0 01-1 1H4a1 1 0 01-1-1v-1c0-1.33-2-1.33-2 0v1a3 3 0 003 3h8a3 3 0 003-3v-1a1 1 0 00-1-1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/getmsg.svg b/comm/mail/themes/shared/mail/icons/getmsg.svg
new file mode 100644
index 0000000000..5161709ff3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/getmsg.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M10.3 11.3L9 12.58V8a1 1 0 00-2 0v4.59l-1.3-1.3a1 1 0 00-1.4 1.42l3 3a1 1 0 001.4 0l3-3a1 1 0 00-1.4-1.42zM2.5 10H5c1.33 0 1.33-2 0-2H2.5a.5.5 0 01-.5-.5v-5c0-.28.23-.5.5-.5h11c.28 0 .5.22.5.5v5a.5.5 0 01-.5.5H11c-1.33 0-1.33 2 0 2h2.5A2.5 2.5 0 0016 7.5v-5A2.5 2.5 0 0013.5 0h-11A2.5 2.5 0 000 2.5v5A2.5 2.5 0 002.5 10z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/globe-secure.svg b/comm/mail/themes/shared/mail/icons/globe-secure.svg
new file mode 100644
index 0000000000..8f6c73dc0f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/globe-secure.svg
@@ -0,0 +1,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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 0A8 8 0 00-.03 8.86a8.06 8.06 0 008.96 7.11A5.1 5.1 0 017.68 14c-1.48-.75-1.88-1.81-2.24-2.96h2.41c.07-.37.18-.74.32-1.08H5.17c-.23-1.3-.23-2.62 0-3.92h5.65c.1.49.14.88.16 1.37.32-.12.65-.2.98-.22 0-.42-.1-.74-.12-1.15h1.82c.19.55.29.9.32 1.49.77.4 1.42 1.3 1.84 2.15A8 8 0 008 0zm0 2c1.07 0 2.04 1.2 2.57 2.96H5.43C5.96 3.2 6.93 2 8 2zm-2.56.58a7.7 7.7 0 00-1.05 2.38H2.84a6.03 6.03 0 012.6-2.38zm5.12 0a6.05 6.05 0 012.6 2.38h-1.55a7.7 7.7 0 00-1.05-2.38zM2.34 6.04h1.82c-.2 1.3-.2 2.62 0 3.92H2.34a5.98 5.98 0 010-3.92zm.5 5h1.55c.21.85.57 1.65 1.05 2.38a6.08 6.08 0 01-2.6-2.38z"/>
+ <path d="M14 11h.56c.24 0 .44.2.44.44v3.12c0 .24-.2.44-.44.44H9.44a.44.44 0 01-.44-.44v-3.12c0-.24.2-.44.44-.44H10v-.84c0-2.6 4-2.63 4 0zm-1 0v-.84c0-1.17-2-1.17-2 0V11z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/globe.svg b/comm/mail/themes/shared/mail/icons/globe.svg
new file mode 100644
index 0000000000..1a51482293
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/globe.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M8 0a8 8 0 1 0 8 8 8.009 8.009 0 0 0-8-8zm5.163 4.958h-1.552a7.7 7.7 0 0 0-1.051-2.376 6.03 6.03 0 0 1 2.603 2.376zM14 8a5.963 5.963 0 0 1-.335 1.958h-1.821A12.327 12.327 0 0 0 12 8a12.327 12.327 0 0 0-.156-1.958h1.821A5.963 5.963 0 0 1 14 8zm-6 6c-1.075 0-2.037-1.2-2.567-2.958h5.135C10.037 12.8 9.075 14 8 14zM5.174 9.958a11.084 11.084 0 0 1 0-3.916h5.651A11.114 11.114 0 0 1 11 8a11.114 11.114 0 0 1-.174 1.958zM2 8a5.963 5.963 0 0 1 .335-1.958h1.821a12.361 12.361 0 0 0 0 3.916H2.335A5.963 5.963 0 0 1 2 8zm6-6c1.075 0 2.037 1.2 2.567 2.958H5.433C5.963 3.2 6.925 2 8 2zm-2.56.582a7.7 7.7 0 0 0-1.051 2.376H2.837A6.03 6.03 0 0 1 5.44 2.582zm-2.6 8.46h1.549a7.7 7.7 0 0 0 1.051 2.376 6.03 6.03 0 0 1-2.603-2.376zm7.723 2.376a7.7 7.7 0 0 0 1.051-2.376h1.552a6.03 6.03 0 0 1-2.606 2.376z"></path>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/goback.svg b/comm/mail/themes/shared/mail/icons/goback.svg
new file mode 100644
index 0000000000..81f28f533a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/goback.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M15 7H3.414l4.293-4.293a1 1 0 0 0-1.414-1.414l-6 6a1 1 0 0 0 0 1.414l6 6a1 1 0 0 0 1.414-1.414L3.414 9H15a1 1 0 0 0 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/goforward.svg b/comm/mail/themes/shared/mail/icons/goforward.svg
new file mode 100644
index 0000000000..9075cb58f1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/goforward.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M15.707 7.293l-6-6a1 1 0 0 0-1.414 1.414L12.586 7H1a1 1 0 0 0 0 2h11.586l-4.293 4.293a1 1 0 1 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/half-operator.png b/comm/mail/themes/shared/mail/icons/half-operator.png
new file mode 100644
index 0000000000..2e89f37b31
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/half-operator.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/icons/help.svg b/comm/mail/themes/shared/mail/icons/help.svg
new file mode 100644
index 0000000000..737218ce17
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/help.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M8 1a7 7 0 1 0 7 7 7.008 7.008 0 0 0-7-7zm0 13a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6zM8 3.125A2.7 2.7 0 0 0 5.125 6a.875.875 0 0 0 1.75 0c0-1 .6-1.125 1.125-1.125a1.105 1.105 0 0 1 1.13.744.894.894 0 0 1-.53 1.016A2.738 2.738 0 0 0 7.125 9v.337a.875.875 0 0 0 1.75 0v-.37a1.041 1.041 0 0 1 .609-.824A2.637 2.637 0 0 0 10.82 5.16 2.838 2.838 0 0 0 8 3.125zm0 7.625A1.25 1.25 0 1 0 9.25 12 1.25 1.25 0 0 0 8 10.75z"></path>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/hidden.svg b/comm/mail/themes/shared/mail/icons/hidden.svg
new file mode 100644
index 0000000000..1752f9f45e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/hidden.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M12 7l-4 4a4 4 0 0 0 4-4zm3.955.7a1 1 0 0 1 0 .6A8.325 8.325 0 0 1 8 14a8.478 8.478 0 0 1-2.59-.409l1.66-1.661c.308.046.619.069.93.07a6.331 6.331 0 0 0 5.943-4 5.781 5.781 0 0 0-1.118-1.828l1.41-1.41a7.817 7.817 0 0 1 1.72 2.938zm-1.248-6.407a1 1 0 0 1 0 1.414l-12 12a1 1 0 1 1-1.414-1.414l1.284-1.287A7.874 7.874 0 0 1 .045 8.294a1 1 0 0 1 0-.594A8.355 8.355 0 0 1 11.7 2.882l1.593-1.589a1 1 0 0 1 1.414 0zM8.5 5A1.5 1.5 0 0 0 7 6.5c.003.295.094.581.263.823l2.06-2.06A1.46 1.46 0 0 0 8.5 5zM2.057 8a5.928 5.928 0 0 0 1.936 2.595l.986-.986A3.933 3.933 0 0 1 4.557 5a6.061 6.061 0 0 0-2.5 3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/highlights.svg b/comm/mail/themes/shared/mail/icons/highlights.svg
new file mode 100644
index 0000000000..91c7acd1fa
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/highlights.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="M9.5 3s.428 2.43 1.249 3.251S14 7.5 14 7.5s-2.43.394-3.251 1.215S9.5 12 9.5 12s-.394-2.464-1.215-3.285S5 7.5 5 7.5s2.464-.428 3.285-1.249S9.5 3 9.5 3m0-2h-.014a2 2 0 0 0-1.96 1.68 7.536 7.536 0 0 1-.659 2.154 7.9 7.9 0 0 1-2.212.7 2 2 0 0 0 .029 3.945 7.733 7.733 0 0 1 2.183.658 7.74 7.74 0 0 1 .658 2.185A2 2 0 0 0 9.489 14H9.5a2 2 0 0 0 1.971-1.657 7.891 7.891 0 0 1 .7-2.209 7.566 7.566 0 0 1 2.154-.659 2 2 0 0 0 .027-3.944 7.694 7.694 0 0 1-2.181-.7 7.731 7.731 0 0 1-.7-2.181A2 2 0 0 0 9.5 1zM3 15.5a.5.5 0 0 1-.49-.421 3.047 3.047 0 0 0-.4-1.186 3.047 3.047 0 0 0-1.186-.4.5.5 0 0 1-.007-.986 3.147 3.147 0 0 0 1.192-.417 3.051 3.051 0 0 0 .4-1.171A.5.5 0 0 1 3 10.5a.5.5 0 0 1 .492.413 3.094 3.094 0 0 0 .417 1.179 3.142 3.142 0 0 0 1.178.416.5.5 0 0 1-.007.985 3.007 3.007 0 0 0-1.172.4 3.166 3.166 0 0 0-.416 1.192A.5.5 0 0 1 3 15.5zm-.5-11a.5.5 0 0 1-.49-.42 2.344 2.344 0 0 0-.265-.82 2.344 2.344 0 0 0-.82-.265.5.5 0 0 1-.007-.986 2.41 2.41 0 0 0 .827-.277A2.306 2.306 0 0 0 2.007.92.5.5 0 0 1 2.5.5a.5.5 0 0 1 .492.412 2.353 2.353 0 0 0 .278.818 2.372 2.372 0 0 0 .816.276.5.5 0 0 1-.007.985 2.306 2.306 0 0 0-.811.266 2.41 2.41 0 0 0-.277.827.5.5 0 0 1-.491.416z" fill="context-fill"></path>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/hline.svg b/comm/mail/themes/shared/mail/icons/hline.svg
new file mode 100644
index 0000000000..85f6ddb31a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/hline.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2.5 6h11c2 0 2 3 0 3h-11C.5 9 .5 6 2.5 6z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/hourglass.svg b/comm/mail/themes/shared/mail/icons/hourglass.svg
new file mode 100644
index 0000000000..ebb0409f74
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/hourglass.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill">
+ <path d="M13 1a1 1 0 010 2h-.01l.01.5c0 1.75-2.01 2.51-2.01 4.5 0 2 2.01 2.75 2.01 4.5l-.01.5H13a1 1 0 010 2H3a1 1 0 010-2h.01a8.56 8.56 0 01-.01-.5C3 10.75 4.99 10 5 8c.01-1.99-2-2.75-2-4.5l.01-.5H3a1 1 0 110-2h10zm-2.01 2H5v.25L5 3.5C5 5.36 7 6.02 7 8c0 1.99-2 2.64-2 4.5v.25l.01.25h.6l.43-.7C7.02 10.77 7.68 10 8 10c.37 0 1.17 1 2.4 3h.59l.01-.5c0-1.86-2.01-2.49-2.01-4.5S11 5.36 11 3.5v-.25L10.99 3z"/>
+ <path d="M6 4h4C8.95 6 8.28 7 8 7s-.95-1-2-3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/image.svg b/comm/mail/themes/shared/mail/icons/image.svg
new file mode 100644
index 0000000000..3034ec15ee
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/image.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M3 1a3 3 0 0 0-3 3v8c0 1.66 1.343 3 3 3h10c1.66 0 3-1.34 3-3V4c0-1.691-1.31-3-3-3zm0 2h10c.55 0 1 .448 1 1v6l-1.5-1-1.5 1-4.5-3-3 2H2V4a1 1 0 0 1 1-1zm8.51 1c-2.052-.05-2.053 3.051 0 2.999 1.95-.05 1.95-2.948 0-2.999zM6.5 8l4.5 3 1.5-1 1.5 1v1c0 .55-.45 1-1 1H3c-.552 0-1-.45-1-1v-2h1.5z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/import.svg b/comm/mail/themes/shared/mail/icons/import.svg
new file mode 100644
index 0000000000..ba6c0bc709
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/import.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M13.374 1H4.623A2.83 2.83 0 0 0 2 4v4h2V4a.928.928 0 0 1 .833-1h8.333A.928.928 0 0 1 14 4v8a.928.928 0 0 1-.833 1H4.833A.928.928 0 0 1 4 12v-1H2v1a2.833 2.833 0 0 0 2.627 3h9.623A1.888 1.888 0 0 0 16 13V4a2.833 2.833 0 0 0-2.626-3z"></path><path fill="context-fill" d="M7.146 11.146a.5.5 0 1 0 .707.707l2-2a.5.5 0 0 0 0-.707l-2-2a.5.5 0 0 0-.707.707L8.293 9H1.5a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 0-1 0v2A1.5 1.5 0 0 0 1.5 10h6.793z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/inbox.svg b/comm/mail/themes/shared/mail/icons/inbox.svg
new file mode 100644
index 0000000000..2431953f58
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/inbox.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M13 1H3a3.007 3.007 0 0 0-3 3v8a3.009 3.009 0 0 0 3 3h10a3.005 3.005 0 0 0 3-3V4a3.012 3.012 0 0 0-3-3zM3 3h10a1 1 0 0 1 1 1v3h-3.5a.5.5 0 0 0-.5.5c0 .751-.146 2.5-1.5 2.5h-1C6.146 10 6 8.251 6 7.5a.5.5 0 0 0-.5-.5H2V4a1 1 0 0 1 1-1zm10 10H3a1 1 0 0 1-1-1V8h3.017c.134 1.889 1.041 3 2.483 3h1c1.442 0 2.349-1.111 2.483-3H14v4a1 1 0 0 1-1 1z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/increase.svg b/comm/mail/themes/shared/mail/icons/increase.svg
new file mode 100644
index 0000000000..64deccf52d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/increase.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M1 1a1 1 0 100 2h4v11a1 1 0 102 0V3h4a1 1 0 100-2H1zm12 3.5L10 9h6l-3-4.5z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/indent.svg b/comm/mail/themes/shared/mail/icons/indent.svg
new file mode 100644
index 0000000000..fb32427c9f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/indent.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 1C.667 1 .667 3 2 3h12c1.33 0 1.33-2 0-2H2zm2.045 2.965c-.763-.032-1.487.905-.813 1.676L4.35 7H2C.759 7 .742 9 2 9h2.35l-1.118 1.36c-.809 1.02.679 2.26 1.538 1.28L7.801 8 4.77 4.359c-.213-.267-.471-.384-.725-.394zM10 5c-1.333 0-1.333 2 0 2h4c1.33 0 1.33-2 0-2h-4zm0 4c-1.333 0-1.333 2 0 2h4c1.33 0 1.33-2 0-2h-4zm-8 4c-1.333 0-1.333 2 0 2h12c1.33 0 1.33-2 0-2H2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/info.svg b/comm/mail/themes/shared/mail/icons/info.svg
new file mode 100644
index 0000000000..d55bf6ec5b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/info.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M8 15a7 7 0 1 1 7-7 7 7 0 0 1-7 7zM8 2a6 6 0 1 0 6 6 6 6 0 0 0-6-6zm0 10a1 1 0 0 1-1-1V8a1 1 0 0 1 2 0v3a1 1 0 0 1-1 1zm0-6a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/information.svg b/comm/mail/themes/shared/mail/icons/information.svg
new file mode 100644
index 0000000000..2c1a63bd8e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/information.svg
@@ -0,0 +1,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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 16a8 8 0 1 1 8-8 8.009 8.009 0 0 1-8 8zM8 2a6 6 0 1 0 6 6 6.006 6.006 0 0 0-6-6z"/>
+ <path d="M8 7a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1z"/>
+ <circle cx="8" cy="5" r="1.188"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/italics.svg b/comm/mail/themes/shared/mail/icons/italics.svg
new file mode 100644
index 0000000000..3ecd12f5bd
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/italics.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M7 1a1 1 0 100 2h1.48L5.42 13H4a1 1 0 100 2h5a1 1 0 100-2H7.52l3.05-10H12a1 1 0 100-2H7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/join.svg b/comm/mail/themes/shared/mail/icons/join.svg
new file mode 100644
index 0000000000..7ec1ab0608
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/join.svg
@@ -0,0 +1,9 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M12 16a1 1 0 0 1-.77-.37L8.26 12H2.42A2.43 2.43 0 0 1 0 9.58V3.42A2.43 2.43 0 0 1 2.42 1h11.16A2.43 2.43 0 0 1 16 3.42v6.16A2.43 2.43 0 0 1 13.58 12H13v3a1 1 0 0 1-1 1zM2.42 3a.42.42 0 0 0-.42.42v6.16a.42.42 0 0 0 .42.42h6.31a1 1 0 0 1 .77.37l1.5 1.82V11a1 1 0 0 1 1-1h1.58a.42.42 0 0 0 .42-.42V3.42a.42.42 0 0 0-.42-.42z"/>
+ <circle cx="5" cy="7" r="1"/>
+ <circle cx="8" cy="7" r="1"/>
+ <circle cx="11" cy="7" r="1"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/junk-col.svg b/comm/mail/themes/shared/mail/icons/junk-col.svg
new file mode 100644
index 0000000000..88f4f5ed5b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/junk-col.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill">
+ <path d="M6.67 1.12c2.7 1.45 4.2 3.33 4.26 6.08v.74a4.03 4.03 0 01-3.92 4.04 4 4 0 01-3.93-4.04l-.02-1c0-1.57 1.22-2.93 2.14-3.58-.66 1.54-.64 2.77.63 3.8l.4.26.44-.24c1.55-2.01 1-4.04 0-6.06z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/junk.svg b/comm/mail/themes/shared/mail/icons/junk.svg
new file mode 100644
index 0000000000..ede045c211
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/junk.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-stroke" fill-opacity="context-stroke-opacity" d="M8.25 3.84c2.48 1.42 4.64 6.11 3.7 8.05-1.16 2.02-2.75 2.44-5.07 1.92-1.76-1.1-2.3-3.48-1.87-5.54.73.3 1.47.88 1.97 1.56 1.9-1.53 1.76-4.01 1.27-6z"/>
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M13.94 11.73C13.45 14.32 11.12 16 8.38 16a4.9 4.9 0 01-4.93-3.5c-.62-1.8-.46-4.35-.2-5.54.6-1.72 2.38-.7 3.21 0 .34-1.25.1-2.84-.71-3.92C4.96 2 6.03.6 7.23 1.09c4.39 1.73 7.46 6.7 6.71 10.64zM7 10S6 8.55 5.07 8.4c0 0-.33 2.2.3 3.6s1.5 2 3.01 2c1.83 0 3.3-1.06 3.6-2.64.45-2.4-1.11-5.52-3.6-7.35C8.86 5.8 8.76 8.34 7 10z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/justify.svg b/comm/mail/themes/shared/mail/icons/justify.svg
new file mode 100644
index 0000000000..bf9c2208f4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/justify.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 1c-1.333 0-1.333 2 0 2h12c1.33 0 1.33-2 0-2zm0 4c-1.333 0-1.333 2 0 2h12c1.33 0 1.33-2 0-2zm0 4C.667 9 .667 11 2 11h12c1.33 0 1.33-2 0-2zM2 13c-1.333 0-1.333 2 0 2h12c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/left-align.svg b/comm/mail/themes/shared/mail/icons/left-align.svg
new file mode 100644
index 0000000000..10c58bab9e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/left-align.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 1C.667 1 .667 3 2 3h12c1.33 0 1.33-2 0-2zm0 4C.667 5 .667 7 2 7h10c1.33 0 1.33-2 0-2zm0 4C.667 9 .667 11 2 11h12c1.33 0 1.33-2 0-2zm0 4c-1.333 0-1.333 2 0 2h11c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/loading.svg b/comm/mail/themes/shared/mail/icons/loading.svg
new file mode 100644
index 0000000000..1bc5843781
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/loading.svg
@@ -0,0 +1,98 @@
+<!-- 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" width="480" height="16" fill="context-fill">
+ <svg>
+ <path d="M2.062 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="16">
+ <path d="M3.613 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="32">
+ <path d="M6.352 6.057c1.214 0 2.2 0.87 2.2 1.943 0 1.072 -0.986 1.943 -2.2 1.943 -1.214 0 -2.2 -0.87 -2.2 -1.943 0 -1.072 0.986 -1.943 2.2 -1.943z"/>
+ </svg>
+ <svg x="48">
+ <path d="M8.338 6.171c1.435 0 2.6 0.82 2.6 1.829 0 1.01 -1.165 1.829 -2.6 1.829s-2.6 -0.82 -2.6 -1.829c0 -1.01 1.165 -1.829 2.6 -1.829z"/>
+ </svg>
+ <svg x="64">
+ <path d="M9.762 6.286c1.655 0 3 0.768 3 1.714s-1.345 1.714 -3 1.714c-1.656 0 -3 -0.768 -3 -1.714s1.344 -1.714 3 -1.714z"/>
+ </svg>
+ <svg x="80">
+ <path d="M10.828 6.4c1.877 0 3.4 0.717 3.4 1.6 0 0.883 -1.523 1.6 -3.4 1.6 -1.876 0 -3.4 -0.717 -3.4 -1.6 0 -0.883 1.524 -1.6 3.4 -1.6z"/>
+ </svg>
+ <svg x="96">
+ <path d="M11.648 6.3c1.683 0 3.05 0.762 3.05 1.7s-1.367 1.7 -3.05 1.7c-1.683 0 -3.05 -0.762 -3.05 -1.7s1.367 -1.7 3.05 -1.7z"/>
+ </svg>
+ <svg x="112">
+ <path d="M12.287 6.2c1.49 0 2.7 0.807 2.7 1.8s-1.21 1.8 -2.7 1.8c-1.49 0 -2.7 -0.807 -2.7 -1.8s1.21 -1.8 2.7 -1.8z"/>
+ </svg>
+ <svg x="128">
+ <path d="M12.785 6.1c1.297 0 2.35 0.851 2.35 1.9s-1.053 1.9 -2.35 1.9c-1.297 0 -2.35 -0.851 -2.35 -1.9s1.053 -1.9 2.35 -1.9z"/>
+ </svg>
+ <svg x="144">
+ <path d="M13.17 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2c0 -1.104 0.897 -2 2 -2z"/>
+ </svg>
+ <svg x="160">
+ <path d="M13.463 6c1.103 0 2 0.896 2 2s-0.897 2 -2 2a2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="176">
+ <path d="M13.677 6c1.103 0 2 0.896 2 2s-0.897 2 -2 2a2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="192">
+ <path d="M13.823 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="208">
+ <path d="M13.911 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="224">
+ <path d="M13.947 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="240">
+ <path d="M13.937 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="256">
+ <path d="M13.27 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2c0 -1.104 0.897 -2 2 -2z"/>
+ </svg>
+ <svg x="272">
+ <path d="M10.65 6.057c1.182 0 2.142 0.87 2.142 1.943 0 1.072 -0.96 1.943 -2.143 1.943 -1.182 0 -2.142 -0.87 -2.142 -1.943 0 -1.072 0.96 -1.943 2.142 -1.943z"/>
+ </svg>
+ <svg x="288">
+ <path d="M7.911 6.171c1.34 0 2.429 0.82 2.429 1.829 0 1.01 -1.088 1.829 -2.429 1.829 -1.34 0 -2.428 -0.82 -2.428 -1.829 0 -1.01 1.088 -1.829 2.428 -1.829z"/>
+ </svg>
+ <svg x="304">
+ <path d="M6.18 6.286c1.498 0 2.715 0.768 2.715 1.714s-1.217 1.714 -2.715 1.714c-1.498 0 -2.714 -0.768 -2.714 -1.714s1.216 -1.714 2.714 -1.714z"/>
+ </svg>
+ <svg x="320">
+ <path d="M5.01 6.4c1.655 0 3 0.717 3 1.6 0 0.883 -1.345 1.6 -3 1.6 -1.656 0 -3 -0.717 -3 -1.6 0 -0.883 1.344 -1.6 3 -1.6z"/>
+ </svg>
+ <svg x="336">
+ <path d="M4.167 6.3c1.518 0 2.75 0.762 2.75 1.7s-1.232 1.7 -2.75 1.7 -2.75 -0.762 -2.75 -1.7 1.232 -1.7 2.75 -1.7z"/>
+ </svg>
+ <svg x="352">
+ <path d="M3.54 6.2c1.38 0 2.5 0.807 2.5 1.8s-1.12 1.8 -2.5 1.8 -2.5 -0.807 -2.5 -1.8 1.12 -1.8 2.5 -1.8z"/>
+ </svg>
+ <svg x="368">
+ <path d="M3.069 6.1c1.241 0 2.25 0.851 2.25 1.9s-1.009 1.9 -2.25 1.9c-1.242 0 -2.25 -0.851 -2.25 -1.9s1.008 -1.9 2.25 -1.9z"/>
+ </svg>
+ <svg x="384">
+ <path d="M2.714 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="400">
+ <path d="M2.452 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="416">
+ <path d="M2.266 6c1.103 0 2 0.896 2 2s-0.897 2 -2 2a2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="432">
+ <path d="M2.142 6c1.103 0 2 0.896 2 2s-0.897 2 -2 2a2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="448">
+ <path d="M2.071 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="464">
+ <path d="M2.046 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2c0 -1.104 0.897 -2 2 -2z"/>
+ </svg>
+ <svg x="480">
+ <path d="M2.062 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/login.svg b/comm/mail/themes/shared/mail/icons/login.svg
new file mode 100644
index 0000000000..bd39abee73
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/login.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" fill-opacity="context-fill-opacity" d="M10.992 1a4.009 4.009 0 0 0-4.009 4.008c0 .1.022.187.028.282-.059.05-.119.087-.178.143L5.667 6.6a.366.366 0 0 0 0 .467A1.878 1.878 0 0 0 6 7.5.353.353 0 0 1 6 8l-5 5v1.767a.229.229 0 0 0 .233.233H3.77a.229.229 0 0 0 .23-.233v-.778h.75a.227.227 0 0 0 .233-.228v-.768H5.2s.28 0 .28-.235V12.5h.779s.233-.1.233-.244v-1.271h.855l1.12-1.118H8.7l.467.467c.233.233.233.233.365.233a.437.437 0 0 0 .275-.127l.993-1.273c.034-.053.054-.107.084-.161.036 0 .07.011.107.011a4.008 4.008 0 1 0 0-8.017zM12.5 4.489a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"></path></svg>
diff --git a/comm/mail/themes/shared/mail/icons/mark.svg b/comm/mail/themes/shared/mail/icons/mark.svg
new file mode 100644
index 0000000000..ddf0bd9f5a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/mark.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M3 3a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v12l-5-3-5 3V3zm8 0H5v8.468l3-1.8 3 1.8V3zM8 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/menu.svg b/comm/mail/themes/shared/mail/icons/menu.svg
new file mode 100644
index 0000000000..fe7fbda549
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/menu.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="11" height="11" viewBox="0 0 11 11">
+ <path stroke="context-fill" d="M2 2.5h7-7zm0 3h7-7zm0 3h7-7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/message-encrypted-notok.svg b/comm/mail/themes/shared/mail/icons/message-encrypted-notok.svg
new file mode 100644
index 0000000000..1d558a2dd9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message-encrypted-notok.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="M12.5 6.984h-.484L4 15h8.5a1.5 1.5 0 0 0 1.5-1.5V8.484a1.5 1.5 0 0 0-1.5-1.5zm-6.5 0V5a2 2 0 0 1 4 0v1l1.892-1.892A4 4 0 0 0 4 5v1.984h-.5a1.5 1.5 0 0 0-1.5 1.5V13.5a1.483 1.483 0 0 0 .07.43l6.946-6.946z" fill="context-fill"/>
+ <path d="M2 15a1 1 0 0 1-.707-1.707l12-12a1 1 0 0 1 1.414 1.414l-12 12A1 1 0 0 1 2 15z" fill="#ff0039"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/message-encrypted-ok.svg b/comm/mail/themes/shared/mail/icons/message-encrypted-ok.svg
new file mode 100644
index 0000000000..516b3811a1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message-encrypted-ok.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="#12bc00" d="M9.33 16a.83.83 0 01-.6-.24l-2.5-2.5a.83.83 0 011.18-1.18l1.8 1.8 5.28-7.53a.83.83 0 011.36.96l-5.84 8.34a.83.83 0 01-.68.36z"/>
+ <path fill="context-fill" d="M5.54 15l-.02-1c-1.76-1.76.76-4.3 2.6-2.6l.88.87 3-4.3c0-.56-.5-.97-1-.97h-1V5C10-.33 2-.33 2 5v2H1a1 1 0 00-1 1v6a1 1 0 001 1zM8 5v2H4V5c0-2.67 4-2.67 4 0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/message-secure.svg b/comm/mail/themes/shared/mail/icons/message-secure.svg
new file mode 100644
index 0000000000..f443e8ec22
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message-secure.svg
@@ -0,0 +1,4 @@
+<!-- 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" fill="context-fill" fill-opacity="context-fill-opacity" width="16" height="16"><path d="M3 2C1.35 2 0 3.35 0 5v6c0 1.65 1.35 3 3 3h5v-2H3a1 1 0 01-1-1V5a1 1 0 011-1h10a1 1 0 011 1v2.5c.53.37.96.98 1 2.04.01.37.53.43 1 .46V5c0-1.65-1.35-3-3-3z"/><path d="M8 9a.5.5 0 01-.3-.1l-5.5-4a.5.5 0 11.6-.8L8 7.88l5.2-3.78a.5.5 0 01.6.8l-5.5 4A.5.5 0 018 9zm6 2h.5c.28 0 .5.22.5.5v3a.5.5 0 01-.5.5h-5a.5.5 0 01-.5-.5v-3c0-.28.22-.5.5-.5h.5v-1a2 2 0 014 0zm-1 0v-1a1 1 0 00-2 0v1z"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/message-signed-mismatch.svg b/comm/mail/themes/shared/mail/icons/message-signed-mismatch.svg
new file mode 100644
index 0000000000..bf35b03352
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message-signed-mismatch.svg
@@ -0,0 +1,9 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M7.86 0c-.4 0-1.23 1.06-1.62 1.19-.38.12-1.7-.2-2.03.03-.32.24-.35 1.55-.58 1.88-.23.32-1.5.8-1.62 1.17-.12.38.67 1.46.68 1.85 0 .39-.73 1.5-.6 1.86.14.37 1.44.8 1.68 1.11.25.31.34 1.62.67 1.84.33.23 1.64-.16 2.03-.05.21.07.57.39.91.67l1.33-2.64A3 3 0 018 9a3 3 0 01-3-3 3 3 0 012.95-3A3 3 0 018 3a3 3 0 013 2.83 1.9 1.9 0 012.2 1.04L13.76 8c.11-.09.2-.18.23-.26.12-.38-.67-1.46-.68-1.85 0-.39.73-1.5.6-1.86-.14-.37-1.44-.8-1.68-1.11-.25-.31-.34-1.62-.67-1.84-.33-.23-1.64.16-2.03.05C9.14 1 8.27-.01 7.86 0zM6.29 12.15c-.08-.04-.17-.1-.22-.11-.4-.08-1.64.41-2 .22-.35-.2-.6-1.51-.88-1.8a1.46 1.46 0 00-.4-.24L.53 14l2.23.13L4 16z"/>
+ <path fill="#f00" d="M15.82 14.13l-3.2-6.41a1.28 1.28 0 00-2.3 0l-3.18 6.4A1.3 1.3 0 008.29 16h6.38a1.3 1.3 0 001.15-1.87z"/>
+ <path fill="#690000" opacity=".35" d="M11.48 8a.27.27 0 01.25.16l3.2 6.41a.3.3 0 01-.02.3.28.28 0 01-.24.13H8.29a.28.28 0 01-.24-.14.29.29 0 01-.02-.29l3.2-6.4a.27.27 0 01.25-.17m0-1a1.27 1.27 0 00-1.15.72l-3.2 6.4A1.3 1.3 0 008.3 16h6.38a1.3 1.3 0 001.15-1.87l-3.2-6.41A1.27 1.27 0 0011.49 7z"/>
+ <path fill="#fff" d="M11.5 12a.5.5 0 00.5-.5v-2a.5.5 0 00-1 0v2a.5.5 0 00.5.5zm0 .8a.7.7 0 10.7.7.7.7 0 00-.7-.7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/message-signed-ok.svg b/comm/mail/themes/shared/mail/icons/message-signed-ok.svg
new file mode 100644
index 0000000000..35625c02c5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message-signed-ok.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill">
+ <path d="M7.86 0c-.4 0-1.23 1.06-1.62 1.19-.38.12-1.7-.2-2.03.03-.32.24-.35 1.55-.58 1.88-.23.32-1.5.8-1.62 1.17-.12.38.67 1.46.68 1.85 0 .39-.73 1.5-.6 1.86.14.37 1.44.8 1.68 1.11.25.31.34 1.62.67 1.84.33.23 1.64-.16 2.03-.05.39.12 1.26 1.13 1.67 1.12.4 0 1.23-1.06 1.62-1.19.38-.12 1.7.2 2.03-.03.32-.24.35-1.55.58-1.88.23-.32 1.5-.8 1.62-1.17.11-.38-.67-1.46-.68-1.85 0-.39.73-1.5.6-1.86-.14-.37-1.44-.8-1.68-1.11-.25-.31-.34-1.62-.67-1.84-.33-.23-1.64.16-2.03.05C9.14 1 8.27-.01 7.86 0zm.09 3A3 3 0 018 3a3 3 0 013 3 3 3 0 01-3 3 3 3 0 01-3-3 3 3 0 012.95-3zM6.29 12.15c-.08-.04-.17-.1-.22-.11-.4-.08-1.64.41-2 .22-.35-.2-.6-1.51-.88-1.8a1.46 1.46 0 00-.4-.24L.53 14l2.23.13L4 16zM13.27 10.1c-.07.04-.16.08-.2.13-.27.3-.46 1.62-.8 1.83s-1.62-.23-2.01-.13c-.1.03-.25.12-.41.23L12 16l1.23-1.87 2.23-.13z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/message-signed-unknown.svg b/comm/mail/themes/shared/mail/icons/message-signed-unknown.svg
new file mode 100644
index 0000000000..6037d4caf4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message-signed-unknown.svg
@@ -0,0 +1,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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M7.86 0c-.4 0-1.23 1.06-1.62 1.19-.38.12-1.7-.2-2.03.03-.32.24-.35 1.55-.58 1.88-.23.32-1.5.8-1.62 1.17-.12.38.67 1.46.68 1.85 0 .39-.73 1.5-.6 1.86.14.37 1.44.8 1.68 1.11.25.31.34 1.62.67 1.84.25.17 1.05 0 1.6-.06a5.5 5.5 0 01.72-2.15A3 3 0 015 6a3 3 0 012.95-3A3 3 0 018 3a3 3 0 013 3 3 3 0 010 .02 5.5 5.5 0 01.5-.02 5.5 5.5 0 011.98.37c-.1-.2-.17-.37-.17-.49 0-.39.73-1.5.6-1.86-.14-.37-1.44-.8-1.68-1.11-.25-.31-.34-1.62-.67-1.84-.33-.23-1.64.16-2.03.05C9.13 1 8.27-.01 7.86 0zM2.79 10.22L.53 14l2.23.13L4 16l2.09-3.52a5.5 5.5 0 01-.06-.44c-.43-.05-1.6.4-1.96.22-.35-.2-.6-1.5-.88-1.8a1.46 1.46 0 00-.4-.24z"/>
+ <circle fill="#fff" r="3.5" cy="11.5" cx="11.5"/>
+ <path fill="#0a84ff" d="M11.5 16c-6 0-6-9 0-9s6 9 0 9zm.52-2.25c0-.69-1.04-.69-1.04 0s1.04.69 1.04 0zm-1.2-3.38c0-.33.29-.67.68-.67s.68.34.68.68a.8.8 0 01-.18.43c-.08.1-.17.2-.26.27l-.12.1-.33.37a1.46 1.46 0 00-.24.85c0 .6.9.6.9 0 0-.2.05-.3.09-.34a.69.69 0 01.09-.11l.06-.06.08-.08.04-.03c.14-.12.27-.27.4-.41.17-.23.37-.57.37-1a1.58 1.57 0 00-3.16 0c0 .6.9.6.9 0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/message-signed-unverified.svg b/comm/mail/themes/shared/mail/icons/message-signed-unverified.svg
new file mode 100644
index 0000000000..62705fdb18
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message-signed-unverified.svg
@@ -0,0 +1,9 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M7.86 0c-.4 0-1.23 1.06-1.62 1.19-.38.12-1.7-.2-2.03.03-.32.24-.35 1.55-.58 1.88-.23.32-1.5.8-1.62 1.17-.12.38.67 1.46.68 1.85 0 .39-.73 1.5-.6 1.86.14.37 1.44.8 1.68 1.11.25.31.34 1.62.67 1.84.33.23 1.64-.16 2.03-.05.21.07.57.39.91.67l1.33-2.64A3 3 0 018 9a3 3 0 01-3-3 3 3 0 012.95-3A3 3 0 018 3a3 3 0 013 2.83 1.9 1.9 0 012.2 1.04L13.76 8c.11-.09.2-.18.23-.26.12-.38-.67-1.46-.68-1.85 0-.39.73-1.5.6-1.86-.14-.37-1.44-.8-1.68-1.11-.25-.31-.34-1.62-.67-1.84-.33-.23-1.64.16-2.03.05C9.14 1 8.27-.01 7.86 0zM6.29 12.15c-.08-.04-.17-.1-.22-.11-.4-.08-1.64.41-2 .22-.35-.2-.6-1.51-.88-1.8a1.46 1.46 0 00-.4-.24L.53 14l2.23.13L4 16z"/>
+ <path fill="#ffbf00" d="M15.82 14.13l-3.2-6.41a1.28 1.28 0 00-2.3 0l-3.18 6.4A1.3 1.3 0 008.29 16h6.38a1.3 1.3 0 001.15-1.87z"/>
+ <path fill="#d76e00" opacity=".35" d="M11.48 8a.27.27 0 01.25.16l3.2 6.41a.3.3 0 01-.02.3.28.28 0 01-.24.13H8.29a.28.28 0 01-.24-.14.29.29 0 01-.02-.29l3.2-6.4a.27.27 0 01.25-.17m0-1a1.27 1.27 0 00-1.15.72l-3.2 6.4A1.3 1.3 0 008.3 16h6.38a1.3 1.3 0 001.15-1.87l-3.2-6.41A1.27 1.27 0 0011.49 7z"/>
+ <path fill="#fff" d="M11.5 12a.5.5 0 00.5-.5v-2a.5.5 0 00-1 0v2a.5.5 0 00.5.5zm0 .8a.7.7 0 10.7.7.7.7 0 00-.7-.7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/message-signed-verified.svg b/comm/mail/themes/shared/mail/icons/message-signed-verified.svg
new file mode 100644
index 0000000000..5e1b083417
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message-signed-verified.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M7.86 0c-.4 0-1.23 1.06-1.62 1.19-.38.12-1.7-.2-2.03.03-.32.24-.35 1.55-.58 1.88-.23.32-1.5.8-1.62 1.17-.12.38.67 1.46.68 1.85 0 .39-.73 1.5-.6 1.86.14.37 1.44.8 1.68 1.11.25.31.34 1.62.67 1.84.12.09.4.08.7.05a1.83 1.83 0 012.97-.62l.97.96 4.59-6.55.1-.11c.1-.26.18-.5.13-.64-.13-.37-1.43-.8-1.67-1.11-.25-.31-.34-1.62-.67-1.84-.33-.23-1.64.16-2.03.05C9.14 1 8.27-.01 7.86 0zm.09 3A3 3 0 018 3a3 3 0 013 3 3 3 0 01-3 3 3 3 0 01-3-3 3 3 0 012.95-3zM2.78 10.22L.54 14l2.23.13L4 16l1.7-2.86-.18-.18v-.01c-.21-.22-.36-.48-.44-.76-.41.09-.82.17-1 .07-.36-.2-.6-1.51-.9-1.8a1.47 1.47 0 00-.4-.24zM13.78 11l-2.54 3.64L12 16l1.23-1.87 2.23-.13-1.68-3z"/>
+ <path fill="#12bc00" d="M9.33 15a.83.83 0 01-.6-.25l-2.5-2.5a.83.83 0 011.18-1.18l1.8 1.8 5.28-7.52a.83.83 0 011.36.96l-5.84 8.33a.83.83 0 01-.68.36z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/message.svg b/comm/mail/themes/shared/mail/icons/message.svg
new file mode 100644
index 0000000000..62fd0e0875
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/message.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M13 2H3a3.013 3.013 0 0 0-3 3v6a3.013 3.013 0 0 0 3 3h10a3.013 3.013 0 0 0 3-3V5a3.013 3.013 0 0 0-3-3zm1 9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1z"/>
+ <path d="M8 9a.5.5 0 0 1-.294-.1l-5.5-4a.5.5 0 1 1 .588-.8L8 7.882 13.207 4.1a.5.5 0 0 1 .588.809l-5.5 4A.5.5 0 0 1 8 9z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/more.svg b/comm/mail/themes/shared/mail/icons/more.svg
new file mode 100644
index 0000000000..3457654c25
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/more.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M2 6a2 2 0 1 0 2 2 2 2 0 0 0-2-2zm6 0a2 2 0 1 0 2 2 2 2 0 0 0-2-2zm6 0a2 2 0 1 0 2 2 2 2 0 0 0-2-2z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/move-first.svg b/comm/mail/themes/shared/mail/icons/move-first.svg
new file mode 100644
index 0000000000..b579880fdb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/move-first.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="m12.703 12.297c0.9 0.94-0.46 2.3-1.4 1.4l-5-5c-0.4-0.38-0.4-1.02 0-1.4l5-5c0.94-0.9 2.3 0.47 1.4 1.4l-4.27 4.3zm-7.7-9.3a1 1 0 1 0-2 0v10a1 1 0 1 0 2 0z" fill="context-fill" fill-opacity="context-fill-opacity"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/move-last.svg b/comm/mail/themes/shared/mail/icons/move-last.svg
new file mode 100644
index 0000000000..cc1aba7a8b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/move-last.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="m3.2928 12.307c-0.88 0.94 0.47 2.3 1.4 1.4l5.02-5c0.4-0.38 0.4-1.02 0-1.4l-5-5c-0.95-0.9-2.3 0.47-1.42 1.4l4.3 4.3zm7.7-9.3a1 1 0 1 1 2 0v10a1 1 0 1 1-2 0z" fill="context-fill" fill-opacity="context-fill-opacity"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/move-left.svg b/comm/mail/themes/shared/mail/icons/move-left.svg
new file mode 100644
index 0000000000..db1e7bb13f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/move-left.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="m11.217 12.382c0.88 0.94-0.47 2.3-1.4 1.4l-5.02-5c-0.4-0.38-0.4-1.02 0-1.4l5-5c0.95-0.9 2.3 0.47 1.42 1.4l-4.3 4.3z" fill="context-fill" fill-opacity="context-fill-opacity"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/move-right.svg b/comm/mail/themes/shared/mail/icons/move-right.svg
new file mode 100644
index 0000000000..622dc59858
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/move-right.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="m4.7822 12.217c-0.88 0.94 0.47 2.3 1.4 1.4l5.02-5c0.4-0.38 0.4-1.02 0-1.4l-5-5c-0.95-0.9-2.3 0.47-1.42 1.4l4.3 4.3z" fill="context-fill" fill-opacity="context-fill-opacity"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/move-together.svg b/comm/mail/themes/shared/mail/icons/move-together.svg
new file mode 100644
index 0000000000..cedbcb8909
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/move-together.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M2 4h4c1.3-.04 1.3-1.96 0-2H2C.7 2.04.7 3.96 2 4zm4 3H2C.67 7 .67 9 2 9h4c1.33 0 1.33-2 0-2zm0 5H2c-1.33 0-1.33 2 0 2h4c1.33 0 1.33-2 0-2zM10 6h4c1.3-.04 1.3-1.96 0-2h-4c-1.3.04-1.3 1.96 0 2zm4 1h-4c-1.34 0-1.34 2 0 2h4c1.33 0 1.33-2 0-2zm0 3h-4c-1.33 0-1.33 2 0 2h4c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/navigation.svg b/comm/mail/themes/shared/mail/icons/navigation.svg
new file mode 100644
index 0000000000..ce876d06d4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/navigation.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill-opacity="context-fill-opacity" fill="context-fill" d="M14 7H4.4l4.3-4.3c.92-.94-.46-2.32-1.4-1.4l-6 6c-.4.38-.4 1 0 1.4l6 6c.94.92 2.32-.46 1.4-1.4L4.4 9H14c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new-addressbook.svg b/comm/mail/themes/shared/mail/icons/new-addressbook.svg
new file mode 100644
index 0000000000..0e26f7ede4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new-addressbook.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M3 0a2 2 0 00-2 2v1.02c-1.32.1-1.32 1.84 0 1.96v2.04c-1.32.12-1.32 1.85 0 1.96v2.04c-1.32.12-1.32 1.84 0 1.96V14c0 1.1.9 2 2 2h6.69a4.5 4.5 0 01-1.42-2H4a1 1 0 01-1-1c1.5 0 1.5-2 0-2V9c1.5 0 1.5-2 0-2V5c1.5 0 1.5-2 0-2a1 1 0 011-1h9a1 1 0 011 1v5.26a4.5 4.5 0 012 1.41V2a2 2 0 00-2-2H3zm6 3.5c-2.53.02-3.44 3.36-1.26 4.65C5.68 8.67 5 9.02 5 12h3.04a4.5 4.5 0 012.84-3.69l-.62-.15C12.51 6.89 11.58 3.46 9 3.5zM12.5 9a3.5 3.5 0 000 7 3.5 3.5 0 000-7zm0 1a.5.5 0 01.5.5V12h1.5a.5.5 0 010 1H13v1.5a.5.5 0 01-1 0V13h-1.5a.5.5 0 010-1H12v-1.5a.5.5 0 01.5-.5z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new-calendar.svg b/comm/mail/themes/shared/mail/icons/new-calendar.svg
new file mode 100644
index 0000000000..e6ad0dfb79
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new-calendar.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M4.5 0a.5.5 0 00-.5.5v3a.5.5 0 001 0v-3a.5.5 0 00-.5-.5zm7 0a.5.5 0 00-.5.5v3a.5.5 0 001 0v-3a.5.5 0 00-.5-.5zM6 2v2h4V2H6zm-3 .17A3 3 0 001 5v7a3 3 0 003 3h4.76a4.5 4.5 0 01-.72-2H4a1 1 0 01-1-1V6h10v2.03a4.5 4.5 0 012 .73V5a3 3 0 00-2-2.83V5H3V2.17zM4 7v2h2V7H4zm3 0v2h2V7H7zm3 0v1.76a4.5 4.5 0 012-.72V7h-2zm2.5 2a3.5 3.5 0 000 7 3.5 3.5 0 000-7zM4 10v2h2v-2H4zm3 0v2h1.04a4.5 4.5 0 01.72-2H7zm5.5 0a.5.5 0 01.5.5V12h1.5a.5.5 0 010 1H13v1.5a.5.5 0 01-1 0V13h-1.5a.5.5 0 010-1H12v-1.5a.5.5 0 01.5-.5z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new-key.svg b/comm/mail/themes/shared/mail/icons/new-key.svg
new file mode 100644
index 0000000000..8dab38df32
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new-key.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M8.7.98a4.01 4.01 0 00-3.36 6.06L.3 12.1c-.9.95.46 2.27 1.4 1.4l.72.72c.95.76 2.2-.53 1.36-1.44l-.64-.68.64-.75.72.75c.95.76 2.24-.49 1.4-1.43l-.68-.68 1.52-1.52c.38.23.8.38 1.25.46 0-.46.38-.93.98-.93H11V6.5c0-.46 0-1.43 1.75-1.43 0-2.35-1.55-4.05-3.9-4.09zm.08 2.04c.52 0 1.06.2 1.43.57.76.76.76 2.05 0 2.84a2 2 0 11-1.43-3.4zm3.74 2.96a.49.49 0 00-.52.53V9H9.5c-.68 0-.68.99 0 .99H12v2.52c0 .64.99.64 1 0V10h2.51c.68 0 .68-1 0-1H13V6.5c0-.28-.19-.53-.48-.52z"/>
+</svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/new-window.svg b/comm/mail/themes/shared/mail/icons/new-window.svg
new file mode 100644
index 0000000000..a349f80106
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new-window.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M15.5 12H13V9.5a.5.5 0 0 0-1 0V12H9.5a.5.5 0 0 0 0 1H12v2.5a.5.5 0 0 0 1 0V13h2.5a.5.5 0 0 0 0-1z"></path><path fill="context-fill" d="M16 4a3 3 0 0 0-3-3H3a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h4.03v-.006a.994.994 0 0 0 0-1.987V13H3a1 1 0 0 1-1-1V6h12v1.952h.01c0 .017-.01.031-.01.048a1 1 0 0 0 2 0c0-.017-.009-.031-.01-.048H16zM2 5V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/addItemIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/addItemIcon.svg
new file mode 100644
index 0000000000..fbf9a040ec
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/addItemIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop style="stop-color:#0a929d;stop-opacity:1" offset="0"/><stop style="stop-color:#27d3d6;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="b" x1="24.025" y1="654.52" x2="24.025" y2="612.546" gradientUnits="userSpaceOnUse"/></defs><path style="opacity:1;fill:url(#b);stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M46.026 634.546a22 22 0 0 1-22 22 22 22 0 0 1-22-22 22 22 0 0 1 22-22 22 22 0 0 1 22 22z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#135e67;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 616.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm0 5c-1.108 0-2 .892-2 2v7h-7c-1.108 0-2 .892-2 2s.892 2 2 2h7v7c0 1.108.892 2 2 2s2-.892 2-2v-7h7c1.108 0 2-.892 2-2s-.892-2-2-2h-7v-7c0-1.108-.892-2-2-2zm21.979 9.658c-.356 11.874-10.074 21.322-21.954 21.342-11.738-.026-21.388-9.263-21.927-20.989-.032.338-.056.676-.073 1.014 0 12.15 9.85 22 22 22s22-9.85 22-22a21.973 21.973 0 0 0-.046-1.367z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#f1f3fa;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 615.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm0 5c-1.108 0-2 .892-2 2v7h-7c-1.108 0-2 .892-2 2s.892 2 2 2h7v7c0 1.108.892 2 2 2s2-.892 2-2v-7h7c1.108 0 2-.892 2-2s-.892-2-2-2h-7v-7c0-1.108-.892-2-2-2z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/compactMailIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/compactMailIcon.svg
new file mode 100644
index 0000000000..2d16c60571
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/compactMailIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="b"><stop style="stop-color:#a1a1aa;stop-opacity:1" offset="0"/><stop style="stop-color:#d4d4d8;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="a"><stop style="stop-color:#d7bc96;stop-opacity:1" offset="0"/><stop style="stop-color:#efdfc4;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="c" x1="25" y1="652.52" x2="25" y2="624.52" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="d" x1="24" y1="652.52" x2="24" y2="631.52" gradientUnits="userSpaceOnUse"/></defs><g transform="translate(0 -610.52)"><rect style="fill:#d7bc96;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" width="40" height="40" x="4" y="614.52" rx="9" ry="9"/><rect style="fill:#96764b;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" width="36" height="34" x="6" y="616.52" rx="7" ry="7"/><rect style="fill:#51412c;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000;opacity:.3" width="34" height="32" x="7" y="617.52" rx="6" ry="6"/><rect style="fill:#f1f3fa;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" width="32" height="30" x="8" y="618.52" rx="5" ry="5"/><path style="fill:#51412c;fill-opacity:1;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000;opacity:.3" d="M4 623.52v22c0 4.986 4.014 9 9 9h22c4.986 0 9-4.014 9-9v-22H32a1 1 0 0 0-1 1c-.483 3.399-3.456 6-6.986 6h-.028c-3.53 0-6.503-2.601-6.986-6a1 1 0 0 0-1-1z"/><path style="fill:url(#c);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000;fill-opacity:1" d="M4 624.52v21c0 4.986 4.014 9 9 9h22c4.986 0 9-4.014 9-9v-21H32a1 1 0 0 0-1 1c-.483 3.399-3.456 6-6.986 6h-.028c-3.53 0-6.503-2.601-6.986-6a1 1 0 0 0-1-1H4z"/><path style="fill:#96764b;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M4 643.52v2c0 4.986 4.014 9 9 9h22c4.986 0 9-4.014 9-9v-2c0 4.986-4.014 9-9 9H13a8.98 8.98 0 0 1-9-9z"/><path style="fill:#f4e9d7;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000;fill-opacity:1" d="M4 624.52v1h12a1 1 0 0 1 1 1c.483 3.399 3.456 6 6.986 6h.028c3.53 0 6.503-2.601 6.986-6a1 1 0 0 1 1-1h12v-1H32a1 1 0 0 0-1 1c-.483 3.399-3.456 6-6.986 6h-.028c-3.53 0-6.503-2.601-6.986-6a1 1 0 0 0-1-1H4z"/><path style="opacity:.3;fill:#51412c;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M25 632.52c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1zm-3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1zm3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1zm-3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1zm3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1zm-3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1zm3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1z"/><rect style="opacity:.3;fill:#51412c;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" width="2" height="21" x="23" y="631.52" rx=".5" ry=".5"/><path style="opacity:1;fill:url(#d);fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M25 631.52c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1h-1zm-3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1h-1zm3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1h-1zm-3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1h-1zm3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1h-1zm-3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1h-1zm3 3c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1c0-.554-.446-1-1-1h-1z"/></g></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/copyMailIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/copyMailIcon.svg
new file mode 100644
index 0000000000..946135b038
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/copyMailIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="c"><stop offset="0" style="stop-color:#f1f3fa;stop-opacity:1"/><stop offset="1" style="stop-color:#e3e5f2;stop-opacity:1"/></linearGradient><linearGradient xlink:href="#a" id="g" x1="8.5" x2="8.5" y1="623.52" y2="613.52" gradientUnits="userSpaceOnUse"/><linearGradient id="a"><stop offset="0" style="stop-color:#f1f3fa;stop-opacity:1"/><stop offset="1" style="stop-color:#cdd0e5;stop-opacity:1"/></linearGradient><linearGradient xlink:href="#b" id="h" x1="8" x2="8" y1="617.52" y2="624.52" gradientUnits="userSpaceOnUse"/><linearGradient id="b"><stop offset="0" style="stop-color:#f1f3fa;stop-opacity:1"/><stop offset="1" style="stop-color:#e3e5f2;stop-opacity:1"/></linearGradient><linearGradient xlink:href="#c" id="i" x1="8" x2="8" y1="3" y2="10" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#a" id="d" gradientUnits="userSpaceOnUse" x1="8.5" y1="623.52" x2="8.5" y2="613.52"/><linearGradient xlink:href="#b" id="e" gradientUnits="userSpaceOnUse" x1="8" y1="617.52" x2="8" y2="624.52"/><linearGradient xlink:href="#c" id="f" gradientUnits="userSpaceOnUse" x1="8" y1="3" x2="8" y2="10"/></defs><g transform="matrix(2 0 0 2 2 -1223.038)" style="opacity:1"><rect width="14" height="11" x="1" y="613.52" rx="1.5" ry="1.5" style="opacity:1;fill:url(#d);fill-opacity:1;stroke:none;stroke-linecap:round;stroke-linejoin:round"/><path style="color:#000;fill:#cdd0e5;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round" d="M8 617.019a.498.498 0 0 0-.342.135l-6.638 6.098c.005.035.01.069.018.102.038.09.07.192.102.298.237.513.755.868 1.36.868h11c.752 0 1.369-.548 1.48-1.268l-6.638-6.098a.498.498 0 0 0-.342-.135Z"/><path d="M2.5 624.52a1.496 1.496 0 0 1-1.332-.809l6.49-6.057a.5.5 0 0 1 .684 0l6.49 6.057c-.25.481-.75.809-1.332.809z" style="color:#000;fill:url(#e);fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"/><path style="color:#000;fill:#cdd0e5;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round" d="M8 621.02a.498.498 0 0 1-.342-.135l-6.638-6.098c.005-.035.01-.069.018-.102.038-.09.07-.192.102-.298a1.496 1.496 0 0 1 1.36-.868h11c.752 0 1.369.548 1.48 1.268l-6.638 6.098a.498.498 0 0 1-.342.135Z"/><path d="M2.5 3c-.581 0-1.083.328-1.332.809l6.49 6.056a.5.5 0 0 0 .684 0l6.49-6.056A1.496 1.496 0 0 0 13.5 3Z" style="color:#000;fill:url(#f);fill-opacity:1;stroke-linecap:round;stroke-linejoin:round" transform="translate(0 610.52)"/><path style="opacity:1;fill:#cdd0e5;fill-opacity:1;stroke:none;stroke-linecap:round;stroke-linejoin:round" d="M1 622.025v.995c0 .831.669 1.5 1.5 1.5h11c.831 0 1.5-.669 1.5-1.5v-.995c0 .83-.669 1.5-1.5 1.5h-11c-.831 0-1.5-.67-1.5-1.5z"/><path style="opacity:1;fill:#cdd0e5;fill-opacity:1;stroke:none;stroke-linecap:round;stroke-linejoin:round" d="M8.5 622.019c-1.108 0-2 .892-2 2v.501h7c.831 0 1.5-.669 1.5-1.5v-1.001H8.5z"/></g><g transform="matrix(2 0 0 2 14 -1205.04)"><rect width="14" height="11" x="1" y="613.52" rx="1.5" ry="1.5" style="opacity:1;fill:url(#g);fill-opacity:1;stroke:none;stroke-linecap:round;stroke-linejoin:round"/><path style="color:#000;fill:#cdd0e5;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round" d="M8 617.019a.498.498 0 0 0-.342.135l-6.638 6.098c.005.035.01.069.018.102.038.09.07.192.102.298.237.513.755.868 1.36.868h11c.752 0 1.369-.548 1.48-1.268l-6.638-6.098a.498.498 0 0 0-.342-.135z"/><path d="M2.5 624.52a1.496 1.496 0 0 1-1.332-.809l6.49-6.057a.5.5 0 0 1 .684 0l6.49 6.057c-.25.481-.75.809-1.332.809z" style="color:#000;fill:url(#h);fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"/><path style="color:#000;fill:#cdd0e5;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round" d="M8 621.02a.498.498 0 0 1-.342-.135l-6.638-6.098c.005-.035.01-.069.018-.102.038-.09.07-.192.102-.298a1.496 1.496 0 0 1 1.36-.868h11c.752 0 1.369.548 1.48 1.268l-6.638 6.098a.498.498 0 0 1-.342.135Z"/><path d="M2.5 3c-.581 0-1.083.328-1.332.809l6.49 6.056a.5.5 0 0 0 .684 0l6.49-6.056A1.496 1.496 0 0 0 13.5 3Z" style="color:#000;fill:url(#i);fill-opacity:1;stroke-linecap:round;stroke-linejoin:round" transform="translate(0 610.52)"/><path style="opacity:1;fill:#cdd0e5;fill-opacity:1;stroke:none;stroke-linecap:round;stroke-linejoin:round" d="M1 622.025v.995c0 .831.669 1.5 1.5 1.5h11c.831 0 1.5-.669 1.5-1.5v-.995c0 .83-.669 1.5-1.5 1.5h-11c-.831 0-1.5-.67-1.5-1.5z"/></g></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/defaultEventIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/defaultEventIcon.svg
new file mode 100644
index 0000000000..841fe1bc10
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/defaultEventIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="c"><stop style="stop-color:#a1a1aa;stop-opacity:1" offset="0"/><stop style="stop-color:#f4f4f5;stop-opacity:1" offset=".5"/><stop style="stop-color:#a1a1aa;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="b"><stop style="stop-color:#e4e4e7;stop-opacity:1" offset="0"/><stop style="stop-color:#fafafa;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="a"><stop style="stop-color:#b91c1c;stop-opacity:1" offset="0"/><stop style="stop-color:#f87171;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="24" y1="625.52" x2="24" y2="614.52" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="d" x1="24" y1="652.52" x2="24" y2="614.52" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#c" id="f" x1="13.5" y1="620.52" x2="13.5" y2="629.52" gradientUnits="userSpaceOnUse"/></defs><g transform="translate(0 -610.52)"><rect style="fill:url(#d);stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000;fill-opacity:1" width="40" height="40" x="4" y="614.52" rx="9" ry="9"/><path style="fill:#d4d4d8;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M4 643.52v2c0 4.986 4.014 9 9 9h22c4.986 0 9-4.014 9-9v-2c0 4.986-4.014 9-9 9H13a8.98 8.98 0 0 1-9-9z"/><path style="fill:url(#e);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000;fill-opacity:1" d="M13 614.52h22c4.986 0 9 4.014 9 9v2H4v-2c0-4.986 4.014-9 9-9z"/><rect style="opacity:1;fill:#991b1b;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" width="40" height="1" x="4" y="624.52" rx="0" ry="0"/><path style="opacity:1;fill:#991b1b;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M13.5 619.52c-.831 0-1.5.669-1.5 1.5v6c0 .83.669 1.5 1.5 1.5s1.5-.67 1.5-1.5v-6c0-.831-.669-1.5-1.5-1.5zm21 0c-.831 0-1.5.669-1.5 1.5v6c0 .83.669 1.5 1.5 1.5s1.5-.67 1.5-1.5v-6c0-.831-.669-1.5-1.5-1.5z"/><path style="opacity:1;fill:#e4e4e7;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M13.5 625.52a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5-2.5 2.5 2.5 0 0 0-2.5-2.5zm21 0a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5-2.5 2.5 2.5 0 0 0-2.5-2.5z"/><path style="opacity:1;fill:url(#f);fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M13.5 620.52c-.831 0-1.5.669-1.5 1.5v6c0 .83.669 1.5 1.5 1.5s1.5-.67 1.5-1.5v-6c0-.831-.669-1.5-1.5-1.5zm21 0c-.831 0-1.5.669-1.5 1.5v6c0 .83.669 1.5 1.5 1.5s1.5-.67 1.5-1.5v-6c0-.831-.669-1.5-1.5-1.5z"/><path style="opacity:.1;fill:#000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M9 631.52c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h7c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1H9zm23 0c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h7c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1h-7zm-12 6c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h8c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1h-8zm-11 6c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h7c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1H9zm23 0c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h7c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1h-7z"/><path style="opacity:.05;fill:#000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M20 631.52c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h8c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1h-8zm-11 6c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h7c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1H9zm23 0c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h7c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1h-7zm-12 6c-.554 0-1 .446-1 1v3c0 .554.446 1 1 1h8c.554 0 1-.446 1-1v-3c0-.554-.446-1-1-1h-8z"/></g></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/defaultProcessIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/defaultProcessIcon.svg
new file mode 100644
index 0000000000..028d65cead
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/defaultProcessIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop style="stop-color:#6e6f9b;stop-opacity:1" offset="0"/><stop style="stop-color:#9b9ec2;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="b" x1="24.025" y1="658.923" x2="24.025" y2="607.664" gradientUnits="userSpaceOnUse" gradientTransform="translate(4.34 114.617) scale(.81937)"/></defs><path style="opacity:1;fill:url(#b);stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M22 612.52c-1.108 0-2 .892-2 2v2.502a18.026 18.026 0 0 0-4.785 1.85l-1.676-1.677a1.995 1.995 0 0 0-2.828 0l-3.535 3.536a1.995 1.995 0 0 0 0 2.828l1.53 1.529a18.026 18.026 0 0 0-2.097 4.932H4.5c-1.108 0-2 .892-2 2v5c0 1.108.892 2 2 2h2.1a18.026 18.026 0 0 0 2.086 4.95l-1.51 1.51a1.995 1.995 0 0 0 0 2.829l3.535 3.535a1.995 1.995 0 0 0 2.828 0l1.64-1.64A18.026 18.026 0 0 0 20 652.07v2.45c0 1.108.892 2 2 2h5c1.108 0 2-.892 2-2v-2.655a18.026 18.026 0 0 0 4.484-1.998l1.977 1.977a1.995 1.995 0 0 0 2.828 0l3.535-3.535a1.995 1.995 0 0 0 0-2.828l-2.123-2.123a18.026 18.026 0 0 0 1.752-4.338H44.5c1.108 0 2-.892 2-2v-5c0-1.108-.892-2-2-2h-3.03a18.026 18.026 0 0 0-1.76-4.348l2.114-2.113a1.995 1.995 0 0 0 0-2.828l-3.535-3.536a1.995 1.995 0 0 0-2.828 0l-1.996 1.997a18.026 18.026 0 0 0-4.465-1.97v-2.702c0-1.108-.892-2-2-2h-5zm2 13a8 8 0 0 1 8 8 8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#52507c;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M6.865 621.145a1.99 1.99 0 0 0 .31 2.414l1.53 1.529c.21-.449.437-.888.682-1.318l-2.211-2.211a1.998 1.998 0 0 1-.31-.414zm35.27 0a1.998 1.998 0 0 1-.31.414l-2.764 2.763c.235.441.452.891.65 1.35l2.113-2.113a1.99 1.99 0 0 0 .31-2.414zM24 624.52a8 8 0 0 0-8 8 8 8 0 0 0 .035.5 8 8 0 0 1 7.965-7.5 8 8 0 0 1 7.973 7.623 8 8 0 0 0 .027-.623 8 8 0 0 0-8-8zm-21.5 10.5v2c0 1.108.892 2 2 2h2.1c.207 1.227 1.166 3.504 2.086 4.95l.677-.677c-.938-1.537-2.32-4.528-2.763-6.273H4.5c-1.108 0-2-.892-2-2zm44 0c0 1.108-.892 2-2 2h-3.047c-.393 1.516-1.632 4.323-2.402 5.687l.65.65c.818-1.513 1.329-2.732 1.752-4.337H44.5c1.108 0 2-.892 2-2v-2zM6.865 645.895a1.99 1.99 0 0 0 .31 2.414l3.536 3.535a1.995 1.995 0 0 0 2.828 0l1.64-1.64c1.51.85 3.133 1.479 4.821 1.866v-2a18.026 18.026 0 0 1-4.82-1.867l-1.64 1.64a1.995 1.995 0 0 1-2.83 0l-3.534-3.534a1.998 1.998 0 0 1-.31-.414zm35.27 0a1.998 1.998 0 0 1-.31.414l-3.536 3.535a1.995 1.995 0 0 1-2.828 0l-1.977-1.977A18.026 18.026 0 0 1 29 649.865v2a18.026 18.026 0 0 0 4.484-1.998l1.977 1.977a1.995 1.995 0 0 0 2.828 0l3.535-3.535a1.99 1.99 0 0 0 .31-2.414zM20 652.52v2c0 1.108.892 2 2 2h5c1.108 0 2-.892 2-2v-2c0 1.108-.892 2-2 2h-5c-1.108 0-2-.892-2-2z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/deleteMailIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/deleteMailIcon.svg
new file mode 100644
index 0000000000..9d4d061a19
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/deleteMailIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="c"><stop style="stop-color:#6e6f9b;stop-opacity:1" offset="0"/><stop style="stop-color:#52507c;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="b"><stop style="stop-color:#cdd0e5;stop-opacity:1" offset="0"/><stop style="stop-color:#e3e5f2;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="a"><stop style="stop-color:#9b9ec2;stop-opacity:1" offset="0"/><stop style="stop-color:#cdd0e5;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="24" y1="656.52" x2="24" y2="631.52" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="e" x1="24" y1="629.52" x2="24" y2="615.52" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#c" id="f" x1="24" y1="648.02" x2="24" y2="632.52" gradientUnits="userSpaceOnUse"/></defs><g transform="translate(0 -612.52)"><rect style="fill:url(#d);stroke-linecap:round;stroke-linejoin:round;stop-color:#000;fill-opacity:1" width="34" height="41.5" x="7" y="617.02" rx="11.5" ry="11.5"/><rect style="fill:#9b9ec2;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" width="36" height="16" x="6" y="615.52" rx="11" ry="6.682"/><rect style="fill:url(#e);stroke-linecap:round;stroke-linejoin:round;stop-color:#000;fill-opacity:1" width="36" height="14" x="6" y="615.52" rx="11" ry="6.682"/><path style="fill:#6e6f9b;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M7 645.02v2c0 6.37 5.129 11.5 11.5 11.5h11c6.371 0 11.5-5.13 11.5-11.5v-2c0 6.37-5.129 11.5-11.5 11.5h-11c-6.371 0-11.5-5.13-11.5-11.5z"/></g><path style="color:#000;fill:#9b9ec2;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none" d="M22 612.52c-2.735 0-5 2.264-5 5v1a2 2 0 0 0 2 2 2 2 0 0 0 2-2v-1c0-.59.411-1 1-1h4c.589 0 1 .41 1 1v1a2 2 0 0 0 2 2 2 2 0 0 0 2-2v-1c0-2.736-2.265-5-5-5z" transform="translate(0 -610.52)"/><path style="opacity:.5;fill:url(#f);fill-opacity:1;stroke:none;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M15 632.52c-1.108 0-2 .892-2 2v11c0 1.108.892 2 2 2s2-.892 2-2v-11c0-1.108-.892-2-2-2zm9 1c-1.108 0-2 .892-2 2v11c0 1.108.892 2 2 2s2-.892 2-2v-11c0-1.108-.892-2-2-2zm9-1c-1.108 0-2 .892-2 2v11c0 1.108.892 2 2 2s2-.892 2-2v-11c0-1.108-.892-2-2-2z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/error.svg b/comm/mail/themes/shared/mail/icons/new/activity/error.svg
new file mode 100644
index 0000000000..3bfc4e1170
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/error.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop style="stop-color:#dc2626;stop-opacity:1" offset="0"/><stop style="stop-color:#f87171;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="b" x1="24.025" y1="654.52" x2="24.025" y2="612.546" gradientUnits="userSpaceOnUse"/></defs><path style="opacity:1;fill:url(#b);stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M46.026 634.546a22 22 0 0 1-22 22 22 22 0 0 1-22-22 22 22 0 0 1 22-22 22 22 0 0 1 22 22z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#991b1b;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 616.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm0 6c-1.108 0-2 .892-2 2v9c0 1.108.892 2 2 2s2-.892 2-2v-9c0-1.108-.892-2-2-2zm21.979 8.658c-.356 11.874-10.074 21.322-21.954 21.342-11.738-.026-21.388-9.263-21.927-20.989-.032.338-.056.676-.073 1.014 0 12.15 9.85 22 22 22s22-9.85 22-22a21.965 21.965 0 0 0-.046-1.367zM24 639.52c-1.108 0-2 .892-2 2s.892 2 2 2 2-.892 2-2-.892-2-2-2z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#f1f3fa;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 651.52c-9.941 0-18-8.06-18-18 0-9.941 8.059-18 18-18s18 8.059 18 18c0 9.94-8.059 18-18 18zm0-2c8.837 0 16-7.164 16-16 0-8.837-7.163-16-16-16s-16 7.163-16 16c0 8.836 7.163 16 16 16zm0-7c-1.108 0-2-.892-2-2s.892-2 2-2 2 .892 2 2-.892 2-2 2zm0-6c-1.108 0-2-.892-2-2v-9c0-1.108.892-2 2-2s2 .892 2 2v9c0 1.108-.892 2-2 2z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/indexMailIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/indexMailIcon.svg
new file mode 100644
index 0000000000..cc5113d4e7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/indexMailIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="c"><stop style="stop-color:#fff;stop-opacity:1" offset="0"/><stop style="stop-color:#fff;stop-opacity:0" offset="1"/></linearGradient><linearGradient id="b"><stop style="stop-color:#cdd0e5;stop-opacity:1" offset="0"/><stop style="stop-color:#e3e5f2;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="a"><stop style="stop-color:#96764b;stop-opacity:1" offset="0"/><stop style="stop-color:#b6986c;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="42" y1="655.52" x2="42" y2="638.52" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="d" x1="18" y1="642.52" x2="18" y2="612.52" gradientUnits="userSpaceOnUse"/><radialGradient xlink:href="#c" id="f" cx="14" cy="622.52" fx="14" fy="622.52" r="4" gradientUnits="userSpaceOnUse"/></defs><g transform="translate(0 -610.52)"><circle style="opacity:.5;fill:#ddeefe;stroke:none;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" cx="18" cy="628.52" r="14"/><path style="color:#000;fill:url(#d);stroke-linecap:round;stroke-linejoin:round;fill-opacity:1" d="M18 612.52c-8.813 0-16 7.187-16 16 0 8.813 7.187 16 16 16 3.688 0 7.088-1.264 9.799-3.373l2.787 2.787a2 2 0 0 0 2.828 0 2 2 0 0 0 0-2.828l-2.787-2.788A15.907 15.907 0 0 0 34 628.52c0-8.813-7.187-16-16-16zm0 4c6.651 0 12 5.349 12 12 0 6.65-5.349 12-12 12s-12-5.35-12-12c0-6.651 5.349-12 12-12z"/><path style="color:#000;fill:#cdd0e5;stroke-linecap:round;stroke-linejoin:round" d="M18 614.52a11.97 11.97 0 0 0-11.955 13c.505-6.179 5.641-11 11.955-11s11.45 4.821 11.955 11a11.97 11.97 0 0 0-11.955-13z"/><path style="color:#000;fill:#9b9ec2;stroke-linecap:round;stroke-linejoin:round;opacity:.3" d="M33.965 627.516c-.206 3.302-2.248 7.51-4.166 9.974l.828.828C32.25 636.295 34 633.051 34 628.52c0-.338-.014-.672-.035-1.004zm-31.93.004c-.02.33-.035.663-.035 1 0 8.812 7.187 16 16 16 3.688 0 7.088-1.264 9.799-3.373l2.787 2.787a2 2 0 0 0 3.146-2.414 2 2 0 0 1-3.146.414l-2.787-2.787A15.907 15.907 0 0 1 18 642.52c-8.477 0-15.445-6.651-15.965-15z"/><path style="color:#000;fill:url(#e);stroke-linecap:round;stroke-linejoin:round;fill-opacity:1" d="M32 638.52a4 4 0 0 0-2.828 1.171 4 4 0 0 0 0 5.657l11 11a4 4 0 0 0 5.656 0 4 4 0 0 0 0-5.657l-11-11A4 4 0 0 0 32 638.52Z"/><path style="color:#000;fill:#755b38;stroke-linecap:round;stroke-linejoin:round" d="M28.152 641.52a4 4 0 0 0 1.02 3.828l11 11a4 4 0 0 0 5.656 0 4 4 0 0 0 1.043-3.828 4 4 0 0 1-1.043 1.828 4 4 0 0 1-5.656 0l-11-11a4 4 0 0 1-1.02-1.828z"/><circle style="opacity:.5;fill:url(#f);stroke:none;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stop-color:#000;fill-opacity:1" cx="14" cy="622.52" r="4"/></g></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/info.svg b/comm/mail/themes/shared/mail/icons/new/activity/info.svg
new file mode 100644
index 0000000000..aabfb6406a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/info.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop style="stop-color:#2493ef;stop-opacity:1" offset="0"/><stop style="stop-color:#4cb1f9;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="b" x1="24.025" y1="654.52" x2="24.025" y2="612.546" gradientUnits="userSpaceOnUse"/></defs><path style="opacity:1;fill:url(#b);stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M46.026 634.546a22 22 0 0 1-22 22 22 22 0 0 1-22-22 22 22 0 0 1 22-22 22 22 0 0 1 22 22z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#105bbc;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 616.52a18 18 0 0 0-18 18 18 18 0 0 0 18 18 18 18 0 0 0 18-18 18 18 0 0 0-18-18zm0 2a16 16 0 0 1 16 16 16 16 0 0 1-16 16 16 16 0 0 1-16-16 16 16 0 0 1 16-16zm0 6c-1.108 0-2 .892-2 2s.892 2 2 2 2-.892 2-2-.892-2-2-2zm0 6c-1.108 0-2 .892-2 2v9c0 1.108.892 2 2 2s2-.892 2-2v-9c0-1.108-.892-2-2-2zm21.979 2.658a22 22 0 0 1-21.954 21.342A22 22 0 0 1 2.098 633.53a22 22 0 0 0-.073 1.014 22 22 0 0 0 22 22 22 22 0 0 0 22-22 22 22 0 0 0-.046-1.367z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#f1f3fa;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 615.52a18 18 0 0 0-18 18 18 18 0 0 0 18 18 18 18 0 0 0 18-18 18 18 0 0 0-18-18zm0 2a16 16 0 0 1 16 16 16 16 0 0 1-16 16 16 16 0 0 1-16-16 16 16 0 0 1 16-16zm0 6c-1.108 0-2 .892-2 2s.892 2 2 2 2-.892 2-2-.892-2-2-2zm0 6c-1.108 0-2 .892-2 2v9c0 1.108.892 2 2 2s2-.892 2-2v-9c0-1.108-.892-2-2-2z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/moveMailIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/moveMailIcon.svg
new file mode 100644
index 0000000000..178941b263
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/moveMailIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="b"><stop style="stop-color:#e3e5f2;stop-opacity:1" offset="0"/><stop style="stop-color:#f1f3fa;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="a"><stop style="stop-color:#1373d9;stop-opacity:1" offset="0"/><stop style="stop-color:#4cb1f9;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="28.08" y1="646.515" x2="28.08" y2="619.52" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="28.163" y1="649.498" x2="27.742" y2="617.525" gradientUnits="userSpaceOnUse"/></defs><path style="color:#000;fill:url(#c);stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none;fill-opacity:1" d="M27.742 617.525a6.004 6.004 0 0 0-4.164 1.938 3 3 0 0 0 0 .002c-2.215 2.416-2.048 6.261.367 8.476l.631.579H8c-3.278 0-6 2.721-6 6 0 3.278 2.722 6 6 6h16.576l-.63.578c-2.416 2.215-2.583 6.06-.368 8.476 2.216 2.416 6.06 2.582 8.477.367l12-11c2.558-2.346 2.558-6.497 0-8.843l-12-11a3 3 0 0 0 0-.002 6.004 6.004 0 0 0-4.313-1.57z" transform="translate(0 -610.52)"/><path style="color:#000;fill:url(#d);stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none;fill-opacity:1" d="M27.871 619.522a3 3 0 0 0-2.082.97 3 3 0 0 0 .184 4.237l6.314 5.79H8a3 3 0 0 0-3 3 3 3 0 0 0 3 3h24.287l-6.314 5.789a3 3 0 0 0-.184 4.238 3 3 0 0 0 4.238.183l12-11a3 3 0 0 0 0-4.421l-12-11a3 3 0 0 0-2.156-.786z" transform="translate(0 -610.52)"/><path style="color:#000;fill:#124c9a;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none" d="M25.057 622.042a3 3 0 0 0 .916 2.687l6.314 5.79h1.094l-7.408-6.791a3 3 0 0 1-.916-1.686zM5.078 633.019a3 3 0 0 0-.078.5 3 3 0 0 0 3 3h24.287l1.094-1.002H8a3 3 0 0 1-2.922-2.498zm37.875 0a3 3 0 0 1-.926 1.709l-12 11a3 3 0 0 1-4.238-.184 3 3 0 0 1-.732-1.55 3 3 0 0 0 .732 2.552 3 3 0 0 0 4.238.183l12-11a3 3 0 0 0 .926-2.71z" transform="translate(0 -610.52)"/><path style="color:#000;fill:#cdd0e5;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none" d="M22.107 622.508c-.227 1.365-.195 3.836 2.47 6.012h1.763l-.631-.579c-2.397-2.099-3.391-4.153-3.602-5.433zM2.092 633.52c-.057.326-.092.659-.092 1 0 3.278 2.722 6 6 6h16.576c.296-.519.673-1 1.133-1.422l.63-.578H8c-2.937 0-5.422-2.188-5.908-5zm43.793 0a5.918 5.918 0 0 1-1.83 3.422l-12 11c-2.416 2.215-6.261 2.048-8.477-.368a5.92 5.92 0 0 1-1.47-3.043c-.29 1.765.187 3.643 1.47 5.043 2.216 2.416 6.06 2.583 8.477.368l12-11c1.543-1.416 2.148-3.486 1.83-5.422z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/question.svg b/comm/mail/themes/shared/mail/icons/new/activity/question.svg
new file mode 100644
index 0000000000..2da7f369e8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/question.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop style="stop-color:#2493ef;stop-opacity:1" offset="0"/><stop style="stop-color:#4cb1f9;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="b" x1="24.025" y1="654.52" x2="24.025" y2="612.546" gradientUnits="userSpaceOnUse"/></defs><path style="opacity:1;fill:url(#b);stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M46.026 634.546a22 22 0 0 1-22 22 22 22 0 0 1-22-22 22 22 0 0 1 22-22 22 22 0 0 1 22 22z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#105bbc;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 616.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm0 6c-1.746 0-3.418.463-4.748 1.476-1.33 1.014-2.252 2.68-2.252 4.524a2 2 0 0 0 2 2 2 2 0 0 0 2-2c0-.642.196-.977.676-1.342.48-.365 1.309-.658 2.324-.658 1.789 0 3 1.215 3 2.5 0 1.284-1.211 2.5-3 2.5h-.082c-1.063 0-1.918.892-1.918 2v2c0 1.108.855 2 1.918 2h.164c1.063 0 1.918-.892 1.918-2v-.276c2.827-.792 5-3.218 5-6.224 0-3.687-3.266-6.5-7-6.5zm21.979 8.658c-.356 11.874-10.074 21.322-21.954 21.342-11.738-.026-21.388-9.263-21.927-20.989-.032.338-.056.676-.073 1.014 0 12.15 9.85 22 22 22s22-9.85 22-22a21.965 21.965 0 0 0-.046-1.367zM24 641.52a2 2 0 0 0-2 2 2 2 0 0 0 2 2 2 2 0 0 0 2-2 2 2 0 0 0-2-2z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#f1f3fa;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 615.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm0 6c-1.746 0-3.418.463-4.748 1.476-1.33 1.014-2.252 2.68-2.252 4.524a2 2 0 0 0 2 2 2 2 0 0 0 2-2c0-.642.196-.977.676-1.342.48-.365 1.309-.658 2.324-.658 1.789 0 3 1.215 3 2.5 0 1.284-1.211 2.5-3 2.5h-.082c-1.063 0-1.918.892-1.918 2v2c0 1.108.855 2 1.918 2h.164c1.063 0 1.918-.892 1.918-2v-.276c2.827-.792 5-3.218 5-6.224 0-3.687-3.266-6.5-7-6.5zm0 17a2 2 0 0 0-2 2 2 2 0 0 0 2 2 2 2 0 0 0 2-2 2 2 0 0 0-2-2z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/removeItemIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/removeItemIcon.svg
new file mode 100644
index 0000000000..1e10304869
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/removeItemIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop style="stop-color:#0a929d;stop-opacity:1" offset="0"/><stop style="stop-color:#27d3d6;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="b" x1="24.025" y1="654.52" x2="24.025" y2="612.546" gradientUnits="userSpaceOnUse"/></defs><path style="opacity:1;fill:url(#b);stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M46.026 634.546a22 22 0 0 1-22 22 22 22 0 0 1-22-22 22 22 0 0 1 22-22 22 22 0 0 1 22 22z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#135e67;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 616.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm-9 14c-1.108 0-2 .892-2 2s.892 2 2 2h18c1.108 0 2-.892 2-2s-.892-2-2-2zm30.979.658c-.356 11.874-10.074 21.322-21.954 21.342-11.738-.026-21.388-9.263-21.927-20.989-.032.338-.056.676-.073 1.014 0 12.15 9.85 22 22 22s22-9.85 22-22a21.973 21.973 0 0 0-.046-1.367z" transform="translate(0 -610.52)"/><path style="opacity:1;fill:#f1f3fa;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 615.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm-9 14c-1.108 0-2 .892-2 2s.892 2 2 2c6.668.017 11.333 0 18 0 1.108 0 2-.892 2-2s-.892-2-2-2z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/sendMailIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/sendMailIcon.svg
new file mode 100644
index 0000000000..c900edd0db
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/sendMailIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="c"><stop style="stop-color:#e3e5f2;stop-opacity:1" offset="0"/><stop style="stop-color:#f1f3fa;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="b"><stop style="stop-color:#cdd0e5;stop-opacity:1" offset="0"/><stop style="stop-color:#e3e5f2;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="a"><stop offset="0" style="stop-color:#ffe900;stop-opacity:1"/><stop offset="1" style="stop-color:#fff376;stop-opacity:1"/></linearGradient><linearGradient xlink:href="#b" id="e" x1="18.915" y1="30.545" x2="18.915" y2="13.093" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#c" id="d" x1="18.915" y1="32" x2="18.915" y2="0" gradientUnits="userSpaceOnUse"/></defs><g style="stroke-width:.72716"><path d="M29 0c-1.306 0-2.304.412-3.58.926L2.654 9.71l.102-.03c-1.51.404-2.744 1.694-2.744 3.332 0 1.7 1.33 2.882 2.734 3.148l-.172-.05 9.798 3.526 3.47 9.774-.038-.124c.16.686.532 1.318 1.068 1.824A3.12 3.12 0 0 0 19 32c1.626 0 2.718-1.26 3.27-2.48a.967.967 0 0 0 .022-.056L31.12 6.176a.998.998 0 0 0 .014-.048c.304-.932.868-1.74.868-3.128 0-1.646-1.354-3-3-3z" style="color:#000;fill:url(#d);fill-rule:evenodd;stroke-width:1.45431;stroke-linejoin:round;fill-opacity:1" transform="matrix(1.37543 0 0 1.375 1.983 2)"/><path d="m15.842 29.41-.038-.124c.16.686.532 1.318 1.068 1.824A3.12 3.12 0 0 0 19 32c1.626 0 2.718-1.26 3.27-2.48a.967.967 0 0 0 .022-.056L31.12 6.176a.998.998 0 0 0 .014-.048c.304-.932.868-1.74.868-3.128 0-.822-.326-1.56-.87-2.104-.22.195-18.76 18.74-18.76 18.74Z" style="color:#000;fill:url(#e);fill-rule:evenodd;stroke-width:1.45431;stroke-linejoin:round;fill-opacity:1" transform="matrix(1.37543 0 0 1.375 1.983 2)"/><path d="m10.122 17.705 2.977 1.204 1.139 3.76L31.132.896Z" style="opacity:.5;fill:#9b9ec2;stroke:none;stroke-width:1.45431px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" transform="matrix(1.37543 0 0 1.375 1.983 2)"/><path style="color:#000;fill:#9b9ec2;fill-rule:evenodd;stroke-width:1.45431;stroke-linejoin:round" d="M31.93 2.357c-.16.922-.556 1.583-.796 2.316a1.01 1.01 0 0 1-.014.049L22.292 28.01a.933.933 0 0 1-.021.055c-.552 1.22-1.645 2.48-3.27 2.48a3.12 3.12 0 0 1-2.13-.89 3.67 3.67 0 0 1-1.044-1.74l-2.728-9.006-10.45-4.226C1.49 14.428.405 13.553.1 12.281a3.165 3.165 0 0 0-.088.73c0 1.661 1.27 2.826 2.637 3.127l-.075-.029.172.052-.097-.023 9.723 3.498 3.455 9.733c-.008-.028-.017-.055-.023-.083l.038.125-.015-.042a3.67 3.67 0 0 0 1.045 1.74A3.12 3.12 0 0 0 19 32c1.626 0 2.719-1.26 3.27-2.48a.93.93 0 0 0 .022-.056L31.12 6.176a.906.906 0 0 0 .014-.048c.304-.932.868-1.74.868-3.128 0-.221-.026-.436-.072-.643zM2.649 14.683c.032.007.064.017.097.023l-.172-.051Zm13.178 13.232.015.041-.038-.125.023.084z" transform="matrix(1.37543 0 0 1.375 1.983 2)"/></g></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/syncMailIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/syncMailIcon.svg
new file mode 100644
index 0000000000..50fceb098c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/syncMailIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop style="stop-color:#16a34a;stop-opacity:1" offset="0"/><stop style="stop-color:#22c55e;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="b" x1="24.025" y1="654.52" x2="24.025" y2="612.546" gradientUnits="userSpaceOnUse"/></defs><path style="opacity:1;fill:url(#b);stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M46.026 634.546a22 22 0 0 1-22 22 22 22 0 0 1-22-22 22 22 0 0 1 22-22 22 22 0 0 1 22 22z" transform="translate(0 -610.52)"/><path style="fill:#166534;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 616.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm0 5c-2.989 0-5.71 1.198-7.693 3.138a1 1 0 0 0-.016 1.414 1 1 0 0 0 1.414.016A8.958 8.958 0 0 1 24 625.52c4.84 0 8.77 3.791 8.99 8.576l-1.283-1.283a1 1 0 0 0-.707-.293 1 1 0 0 0-.707.293 1 1 0 0 0 0 1.414l3 3a1 1 0 0 0 1.414 0l3-3a1 1 0 0 0 0-1.414 1 1 0 0 0-1.414 0l-1.303 1.302c-.214-5.876-5.062-10.595-10.99-10.595zm-9.85 8.996a1 1 0 0 0-.103.01 1 1 0 0 0-.754.287l-3 3a1 1 0 0 0 0 1.414 1 1 0 0 0 1.414 0l1.377-1.377c.66 5.438 5.303 9.67 10.916 9.67 3.035 0 5.795-1.235 7.785-3.229a1 1 0 0 0-.002-1.414 1 1 0 0 0-1.414 0A8.958 8.958 0 0 1 24 643.52a8.983 8.983 0 0 1-8.865-7.452l1.158 1.159a1 1 0 0 0 1.414 0 1 1 0 0 0 0-1.414l-2.78-2.78a1 1 0 0 0-.777-.517zm31.829.662c-.356 11.874-10.074 21.322-21.954 21.342-11.738-.026-21.388-9.263-21.927-20.989-.032.338-.056.676-.073 1.014 0 12.15 9.85 22 22 22s22-9.85 22-22a21.973 21.973 0 0 0-.046-1.367z" transform="translate(0 -610.52)"/><path style="fill:#f1f3fa;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 615.52c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18zm0 2c8.837 0 16 7.163 16 16 0 8.836-7.163 16-16 16s-16-7.164-16-16c0-8.837 7.163-16 16-16zm0 5c-2.989 0-5.71 1.198-7.693 3.138a1 1 0 0 0-.016 1.414 1 1 0 0 0 1.414.016A8.958 8.958 0 0 1 24 624.52c4.84 0 8.77 3.791 8.99 8.576l-1.283-1.283a1 1 0 0 0-.707-.293 1 1 0 0 0-.707.293 1 1 0 0 0 0 1.414l3 3a1 1 0 0 0 1.414 0l3-3a1 1 0 0 0 0-1.414 1 1 0 0 0-1.414 0l-1.303 1.302c-.214-5.876-5.062-10.595-10.99-10.595zm-9.85 8.996a1 1 0 0 0-.103.01 1 1 0 0 0-.754.287l-3 3a1 1 0 0 0 0 1.414 1 1 0 0 0 1.414 0l1.377-1.377c.66 5.438 5.303 9.67 10.916 9.67 3.035 0 5.795-1.235 7.785-3.229a1 1 0 0 0-.002-1.414 1 1 0 0 0-1.414 0A8.958 8.958 0 0 1 24 642.52a8.983 8.983 0 0 1-8.865-7.452l1.158 1.159a1 1 0 0 0 1.414 0 1 1 0 0 0 0-1.414l-2.78-2.78a1 1 0 0 0-.777-.517z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/undoIcon.svg b/comm/mail/themes/shared/mail/icons/new/activity/undoIcon.svg
new file mode 100644
index 0000000000..3e4ab55c73
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/undoIcon.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="b"><stop style="stop-color:#e3e5f2;stop-opacity:1" offset="0"/><stop style="stop-color:#f1f3fa;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="a"><stop style="stop-color:#f59e0b;stop-opacity:1" offset="0"/><stop style="stop-color:#fbbf24;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="20.147" y1="646.514" x2="20.147" y2="619.524" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="19.248" y1="649.521" x2="19.248" y2="617.514" gradientUnits="userSpaceOnUse"/></defs><path style="color:#000;fill:url(#c);stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none;fill-opacity:1" d="M20.262 617.52a6.004 6.004 0 0 0-4.317 1.57l-12 11c-2.558 2.347-2.558 6.497 0 8.844l12 11c2.416 2.215 6.261 2.048 8.477-.367 2.215-2.417 2.048-6.262-.367-8.477l-.631-.578H27.512c.09 0 .182-.003.273-.008 4.123-.196 6.294 1.238 6.3 1.246.216.249.155.079.15.328-.006.25-.167 1.073-.965 2.492-1.608 2.858-.57 6.565 2.289 8.172 2.857 1.608 6.565.57 8.171-2.289 3.075-5.466 3.333-12.047-.566-16.555-3.709-4.288-9.549-5.653-15.793-5.382l.129-.004h-4.076l.63-.578c2.416-2.216 2.583-6.06.368-8.477a6.004 6.004 0 0 0-4.16-1.937z" transform="translate(0 -610.52)"/><path style="color:#000;fill:url(#d);stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none;fill-opacity:1" d="M20.13 619.524a3 3 0 0 0-2.157.785l-12 11a3 3 0 0 0 0 4.422l12 11a3 3 0 0 0 4.238-.184 3 3 0 0 0-.184-4.238l-6.314-5.79H27.5a3 3 0 0 0 .143-.003c4.705-.224 7.617 1.012 8.712 2.279 1.096 1.267 1.403 2.924-.47 6.254a3 3 0 0 0 1.144 4.086 3 3 0 0 0 4.086-1.145c2.627-4.67 2.684-9.763-.22-13.12-2.88-3.331-7.664-4.599-13.395-4.35H15.713l6.314-5.79a3 3 0 0 0 .184-4.238 3 3 0 0 0-2.08-.968z" transform="translate(0 -610.52)"/><path style="color:#000;fill:#cdd0e5;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none" d="M25.895 622.504c-.21 1.282-1.58 3.91-3.6 5.43l-.63.578h1.759c2.669-2.165 2.697-4.641 2.47-6.008zm-23.78 11.012c-.317 1.935.288 4.003 1.83 5.418l12 11c2.416 2.215 6.261 2.048 8.477-.367 1.282-1.4 1.761-3.276 1.473-5.04a5.922 5.922 0 0 1-1.473 3.047c-2.216 2.416-6.06 2.583-8.477.368l-12-11a5.917 5.917 0 0 1-1.83-3.426zm26.42 4.976c-.242 0-.492.007-.75.02a6.234 6.234 0 0 1-.285.008l-5.972.019.63.578c.458.42.874.93 1.266 1.395H27.512c.09 0 .182-.003.273-.008 3.34-.159 6.06.87 6.459 1.411 1.039-1.881-2.214-3.365-5.709-3.423zm17.488 2.569c-.176 2.504-.973 5.054-2.293 7.4-1.606 2.858-5.314 3.897-8.171 2.29-1.631-.918-2.667-2.518-2.96-4.255-.401 2.425.714 4.984 2.96 6.246 2.857 1.608 6.565.57 8.171-2.289 1.673-2.972 2.503-6.273 2.293-9.392z" transform="translate(0 -610.52)"/><path style="color:#000;fill:#d97706;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none" d="M22.955 622.016a3 3 0 0 1-.928 1.713l-7.408 6.79h1.094l6.314-5.788a3 3 0 0 0 .928-2.715zM5.043 633.02a3 3 0 0 0 .93 2.71l12 11a3 3 0 0 0 4.238-.183 3 3 0 0 0 .744-2.525 3 3 0 0 1-4.982 1.707l-12-11a3 3 0 0 1-.93-1.71Zm23.46 2.47c-.28.003-.566.01-.86.024a3.39 3.39 0 0 1-.143.004H14.62l1.093 1.002H27.5c.048 0 .095-.001.143-.004 4.705-.224 7.617 1.012 8.712 2.279.44.508.748 1.082.844 1.799.15-1.22-.217-2.076-.844-2.801-1.027-1.188-3.65-2.347-7.851-2.303zm14.558 5.077c-.096 2.093-.753 4.3-1.946 6.421a3 3 0 0 1-4.086 1.145 3 3 0 0 1-1.472-2.139 3 3 0 0 0 1.472 3.14 3 3 0 0 0 4.086-1.144c1.386-2.463 2.047-5.042 1.946-7.423z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/activity/warning.svg b/comm/mail/themes/shared/mail/icons/new/activity/warning.svg
new file mode 100644
index 0000000000..2553a85097
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/activity/warning.svg
@@ -0,0 +1,4 @@
+<!-- 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 width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="b"><stop style="stop-color:#fbbf24;stop-opacity:1" offset="0"/><stop style="stop-color:#fcd34d;stop-opacity:1" offset="1"/></linearGradient><linearGradient id="a"><stop offset="0" style="stop-color:#ffe900;stop-opacity:1"/><stop offset="1" style="stop-color:#fff376;stop-opacity:1"/></linearGradient><linearGradient xlink:href="#b" id="c" x1="24" y1="652.52" x2="24" y2="614.52" gradientUnits="userSpaceOnUse"/></defs><path style="color:#000;fill:url(#c);stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none;fill-opacity:1" d="M24 614.52a4 4 0 0 0-3.535 2.129l-18 30c-1.41 2.663.521 5.87 3.535 5.87h36c3.014 0 4.944-3.207 3.535-5.87l-18-30a4 4 0 0 0-3.535-2.13Z" transform="translate(0 -610.52)"/><path style="color:#000;fill:#a16207;stroke-linecap:round;stroke-linejoin:round" d="M24 627.52c-1.108 0-2 .892-2 2v9c0 1.108.892 2 2 2s2-.892 2-2v-9c0-1.108-.892-2-2-2zm0 15c-1.108 0-2 .892-2 2s.892 2 2 2 2-.892 2-2-.892-2-2-2zm-21.535 4.129zm-.461 2.095c-.006.255-.004.513-.004.776 0 2.5 1.308 4.999 4 5h36c2.692-.001 4-2.5 4-5.002 0-.263.002-.52-.004-.774-.117 2.003-1.756 3.775-3.996 3.776H6c-2.24 0-3.879-1.773-3.996-3.776z" transform="translate(0 -610.52)"/><path style="fill:#2a284b;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stop-color:#000" d="M24 616.52a2 2 0 0 0-1.766 1.062l-18 30c-.706 1.332.259 2.937 1.766 2.938h36c1.507-.001 2.472-1.606 1.766-2.938l-18-30A2 2 0 0 0 24 616.52zm0 3a1 1 0 0 1 .889.54l15.877 27a1 1 0 0 1-.889 1.46H8.123a1 1 0 0 1-.889-1.46l15.877-27a1 1 0 0 1 .889-.54zm0 7c-1.108 0-2 .892-2 2v9c0 1.108.892 2 2 2s2-.892 2-2v-9c0-1.108-.892-2-2-2zm0 15c-1.108 0-2 .892-2 2s.892 2 2 2 2-.892 2-2-.892-2-2-2z" transform="translate(0 -610.52)"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/address-book-indicator.svg b/comm/mail/themes/shared/mail/icons/new/address-book-indicator.svg
new file mode 100644
index 0000000000..edb3e6f56e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/address-book-indicator.svg
@@ -0,0 +1,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 width="12" height="12" xmlns="http://www.w3.org/2000/svg" fill-opacity="context-fill-opacity" viewBox="0 0 12 12">
+ <path d="M6 .5A5.5 5.5 0 0 0 .5 6a5.5 5.5 0 0 0 2 4.232V8.5c0-.554.446-1 1-1h1v-.508A2.5 2.5 0 0 1 3.5 5 2.5 2.5 0 0 1 6 2.5 2.5 2.5 0 0 1 8.5 5a2.5 2.5 0 0 1-1 1.992V7.5h1c.554 0 1 .446 1 1v1.732A5.5 5.5 0 0 0 11.5 6 5.5 5.5 0 0 0 6 .5Z" fill="context-fill"/>
+ <path d="M6 0C2.692 0 0 2.692 0 6s2.692 6 6 6 6-2.692 6-6-2.692-6-6-6Zm0 1c2.767 0 5 2.233 5 5a4.983 4.983 0 0 1-1 3.004V8.5C10 7.678 9.322 7 8.5 7h-.373c.516-.549.87-1.233.873-1.998A.5.5 0 0 0 9 5v-.006a.5.5 0 0 0 0-.002A3.006 3.006 0 0 0 5.996 2 3.008 3.008 0 0 0 3 5.002a.5.5 0 0 0 0 .002c.004.765.358 1.448.873 1.996H3.5C2.678 7 2 7.678 2 8.5v.504A4.983 4.983 0 0 1 1 6c0-2.767 2.233-5 5-5Zm-.004 2A1.994 1.994 0 0 1 8 4.996v.002a1.999 1.999 0 0 1-.8 1.592.5.5 0 0 0-.2.4v.51a.5.5 0 0 0 .5.5h1c.286 0 .5.214.5.5V10a.5.5 0 0 0 0 .002A4.982 4.982 0 0 1 6 11a4.982 4.982 0 0 1-3-.998A.5.5 0 0 0 3 10V8.5c0-.286.214-.5.5-.5h1a.5.5 0 0 0 .5-.5v-.51a.5.5 0 0 0-.2-.4A1.998 1.998 0 0 1 4 5.002V5c0-1.109.887-1.998 1.996-2Z" fill="context-stroke"/>
+</svg>
+
diff --git a/comm/mail/themes/shared/mail/icons/new/attachment-sm.svg b/comm/mail/themes/shared/mail/icons/new/attachment-sm.svg
new file mode 100644
index 0000000000..ac86975ef8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/attachment-sm.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M7 2.501A1.5 1.5 0 0 0 4 2.5v5.975c0 1.38 1.12 2.55 2.5 2.55S9 9.855 9 8.475V4.5a.5.5 0 0 1 1 0v4.054c-.044 1.896-1.594 3.47-3.5 3.47S3.044 10.451 3 8.555V2.502a2.5 2.5 0 0 1 5 0v5.027a1.5 1.5 0 0 1-3-.003V3.501a.5.5 0 0 1 1 0v4.024a.5.5 0 0 0 1 0z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/bell-disabled.svg b/comm/mail/themes/shared/mail/icons/new/bell-disabled.svg
new file mode 100644
index 0000000000..05e9884c17
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/bell-disabled.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M6 1.504c-1.662 0-3.5 1.338-3.5 3v2.88l5.355-5.357C7.285 1.7 6.63 1.504 6 1.504Zm3.5 3.122L4.62 9.504h5.598a.344.346 0 0 0 .346-.346v-.615c0-.575-.461-1.038-1.034-1.038H9.5ZM2.364 7.519a1.029 1.035 0 0 0-.85.85l.85-.849Z" fill="context-fill"/>
+ <path d="M6 1.002c-.964 0-1.932.375-2.687.989C2.558 2.604 2 3.487 2 4.504v2.715c-.555.232-1 .685-1 1.324v.34l1.998-1.998V4.504c0-.645.362-1.26.942-1.732.579-.471 1.363-.766 2.06-.766.487 0 1.013.147 1.485.393l.736-.736c-.668-.416-1.445-.66-2.221-.66Zm4.5.002a.5.5 0 0 0-.354.147l-9 9a.5.5 0 0 0 0 .707.5.5 0 0 0 .707 0l9-9a.5.5 0 0 0 0-.707.5.5 0 0 0-.353-.147Zm-.525 3.147-.973.972v2.883h.529a.523.526 0 0 1 .535.537v.461H5.121l-1.117 1.117c.02.537.175 1.02.517 1.362.369.367.895.521 1.479.521.584 0 1.11-.154 1.478-.521.368-.367.521-.895.522-1.477h2.219a.854.854 0 0 0 .843-.847v-.616c0-.661-.476-1.13-1.064-1.345V4.504c0-.12-.009-.238-.023-.353zM5 10.006h2c0 .416-.096.638-.229.77-.132.132-.354.228-.77.228-.418 0-.64-.096-.773-.228-.131-.132-.228-.354-.228-.77Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/bell-ring.svg b/comm/mail/themes/shared/mail/icons/new/bell-ring.svg
new file mode 100644
index 0000000000..d1f32b8a59
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/bell-ring.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M6 1.504c-1.662 0-3.5 1.338-3.5 3v3.002a1.03 1.036 0 0 0-1.002 1.037v.616c0 .192.153.345.344.345h8.377a.344.346 0 0 0 .346-.346v-.615c0-.575-.461-1.038-1.034-1.038H9.5V4.504c0-1.662-1.838-3-3.5-3z" fill="context-fill"/>
+ <path d="M2.498.007a.5.5 0 0 0-.293.095C.364 1.452.11 3.538 0 5.477a.5.5 0 0 0 .473.527A.5.5 0 0 0 1 5.532c.106-1.896.31-3.536 1.795-4.625A.5.5 0 0 0 2.902.21a.5.5 0 0 0-.404-.202Zm7.004 0a.5.5 0 0 0-.404.202.5.5 0 0 0 .107.698C10.69 1.997 10.894 3.636 11 5.53a.5.5 0 0 0 .527.473.5.5 0 0 0 .473-.527c-.108-1.939-.364-4.024-2.205-5.375a.5.5 0 0 0-.293-.095ZM6 1.002c-.964 0-1.932.375-2.687.989C2.558 2.604 2 3.487 2 4.504v2.715c-.555.232-1 .685-1 1.324v.616c0 .46.384.847.842.847H4c0 .582.154 1.11.521 1.477.369.367.895.521 1.479.521.584 0 1.11-.154 1.478-.521.368-.367.521-.895.522-1.477h2.219a.854.854 0 0 0 .843-.847v-.616c0-.661-.476-1.13-1.064-1.345V4.504c0-1.017-.557-1.9-1.312-2.513C7.93 1.377 6.964 1.002 6 1.002Zm0 1.004c.698 0 1.481.295 2.06.766.58.47.942 1.087.942 1.732v3.502h.529a.523.526 0 0 1 .535.537v.461h-8.07v-.46a.523.526 0 0 1 .518-.538l.484-.014V4.504c0-.645.362-1.26.942-1.732.579-.471 1.363-.766 2.06-.766Zm-1 8h2c0 .416-.096.638-.229.77-.132.132-.354.228-.77.228-.418 0-.64-.096-.773-.228-.131-.132-.228-.354-.228-.77Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/bell.svg b/comm/mail/themes/shared/mail/icons/new/bell.svg
new file mode 100644
index 0000000000..e44e0058d8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/bell.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M6 1.504c-1.662 0-3.5 1.338-3.5 3v3.002a1.03 1.036 0 0 0-1.002 1.037v.616c0 .192.153.345.344.345h8.377a.344.346 0 0 0 .346-.346v-.615c0-.575-.461-1.038-1.034-1.038H9.5V4.504c0-1.662-1.838-3-3.5-3z" fill="context-fill"/>
+ <path d="M6 1.002c-.964 0-1.932.375-2.687.989C2.558 2.604 2 3.487 2 4.504v2.715c-.555.232-1 .685-1 1.324v.616c0 .46.384.847.842.847H4c0 .582.154 1.11.521 1.477.369.367.895.521 1.479.521.584 0 1.11-.154 1.478-.521.368-.367.521-.895.522-1.477h2.219a.854.854 0 0 0 .843-.847v-.616c0-.661-.476-1.13-1.064-1.345V4.504c0-1.017-.557-1.9-1.312-2.513C7.93 1.377 6.964 1.002 6 1.002Zm0 1.004c.698 0 1.481.295 2.06.766.58.47.942 1.087.942 1.732v3.502h.529a.523.526 0 0 1 .535.537v.461h-8.07v-.46a.523.526 0 0 1 .518-.538l.484-.014V4.504c0-.645.362-1.26.942-1.732.579-.471 1.363-.766 2.06-.766Zm-1 8h2c0 .416-.096.638-.229.77-.132.132-.354.228-.77.228-.418 0-.64-.096-.773-.228-.131-.132-.228-.354-.228-.77Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/calendar-empty.svg b/comm/mail/themes/shared/mail/icons/new/calendar-empty.svg
new file mode 100644
index 0000000000..6bd489de69
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/calendar-empty.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M2.5.5c-1.108 0-2 2.892-2 4h15c0-1.108-.892-4-2-4z" fill="context-fill"/>
+ <path d="M3 0C1.347 0 0 1.346 0 3v10c0 1.653 1.347 3 3 3h10c1.653 0 3-1.347 3-3V3c0-1.654-1.347-3-3-3Zm0 1h10c1.117 0 2 .883 2 2v1h-2v-.5c0-.667-1-.667-1 0V4H4v-.5c0-.667-1-.667-1 0V4H1V3c0-1.117.883-2 2-2ZM1 5h2v.5c0 .667 1 .667 1 0V5h8v.5c0 .667 1 .667 1 0V5h2v8c0 1.116-.883 2-2 2H3c-1.117 0-2-.884-2-2Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/chat-lock-finished.svg b/comm/mail/themes/shared/mail/icons/new/chat-lock-finished.svg
new file mode 100644
index 0000000000..80002b2c23
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/chat-lock-finished.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M3 4.5c-.831 0-1.5.669-1.5 1.5v3c0 .83.669 1.5 1.5 1.5h3.148A3.5 3.5 0 0 1 6 9.5 3.5 3.5 0 0 1 9.5 6c0-.831-.669-1.5-1.5-1.5Z" fill="context-fill"/>
+ <path d="M5.5 0C3.57 0 2 1.57 2 3.5v.771C1.404 4.619 1 5.264 1 6v3c0 1.099.9 2 2 2h3.342a3.5 3.5 0 0 1-.307-1H3c-.563 0-1-.438-1-1V6c0-.563.437-1 1-1h5c.563 0 1 .437 1 1v.035A3.5 3.5 0 0 1 9.5 6a3.5 3.5 0 0 1 .5.035V6c0-.736-.404-1.381-1-1.729V3.5C9 1.57 7.43 0 5.5 0Zm0 1C6.894 1 8 2.106 8 3.5V4H3v-.5C3 2.106 4.106 1 5.5 1Z" fill="context-stroke"/>
+ <path fill="#f97316" d="M12 9.5A2.5 2.5 0 0 1 9.5 12 2.5 2.5 0 0 1 7 9.5 2.5 2.5 0 0 1 9.5 7 2.5 2.5 0 0 1 12 9.5Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/chat-lock-insecure.svg b/comm/mail/themes/shared/mail/icons/new/chat-lock-insecure.svg
new file mode 100644
index 0000000000..42f368957f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/chat-lock-insecure.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M3 4.5c-.831 0-1.5.669-1.5 1.5v3c0 .83.669 1.5 1.5 1.5h3.385c.018-.02.035-.042.054-.06l.94-.94-.94-.94a1.515 1.515 0 0 1 0-2.12 1.501 1.501 0 0 1 2.122 0l.939.939v-1.38c0-.83-.669-1.5-1.5-1.5H3Z" fill="context-fill"/>
+ <path d="M5.5 0C3.57 0 2 1.57 2 3.5v.771C1.404 4.619 1 5.264 1 6v3c0 1.099.9 2 2 2h3.092a1.45 1.45 0 0 1 .347-.56l.44-.44H3c-.563 0-1-.438-1-1V6c0-.563.437-1 1-1h5c.563 0 1 .437 1 1v.879l.5.5.5-.5v-.88c0-.735-.404-1.38-1-1.728V3.5C9 1.57 7.43 0 5.5 0Zm0 1C6.894 1 8 2.106 8 3.5V4H3v-.5C3 2.106 4.106 1 5.5 1Z" fill="context-stroke"/>
+ <path fill="#ef4444" d="M7.5 7a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707L8.793 9.5l-1.647 1.646a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0L9.5 10.207l1.646 1.646a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L10.207 9.5l1.647-1.647a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0L9.5 8.793 7.854 7.146A.5.5 0 0 0 7.5 7Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/chat-lock-private.svg b/comm/mail/themes/shared/mail/icons/new/chat-lock-private.svg
new file mode 100644
index 0000000000..cbffebaf75
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/chat-lock-private.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M3 4.5c-.831 0-1.5.669-1.5 1.5v3c0 .83.669 1.5 1.5 1.5h3.379l-.94-.94a1.515 1.515 0 0 1 0-2.12 1.515 1.515 0 0 1 2.122 0l.775.774L9.5 6.664V6c0-.831-.669-1.5-1.5-1.5Z" fill="context-fill"/>
+ <path d="M5.5 0C3.57 0 2 1.57 2 3.5v.771C1.404 4.619 1 5.264 1 6v3c0 1.099.9 2 2 2h3.877l-.998-1H3c-.563 0-1-.438-1-1V6c0-.563.437-1 1-1h5c.563 0 1 .437 1 1v1.33L10 6v-.002c0-.735-.404-1.38-1-1.727V3.5C9 1.57 7.43 0 5.5 0Zm0 1C6.894 1 8 2.106 8 3.5V4H3v-.5C3 2.106 4.106 1 5.5 1Z" fill="context-stroke"/>
+ <path fill="#4ade80" d="M11.43 6.006a.5.5 0 0 0-.33.193L8.445 9.74 6.854 8.145a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .708l2 2A.5.5 0 0 0 8.9 10.8l3-4a.5.5 0 0 0-.1-.701.5.5 0 0 0-.37-.094z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/chat-lock-unverified.svg b/comm/mail/themes/shared/mail/icons/new/chat-lock-unverified.svg
new file mode 100644
index 0000000000..f2348401c7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/chat-lock-unverified.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M3 4.5c-.831 0-1.5.669-1.5 1.5v3c0 .83.669 1.5 1.5 1.5h2.3l2.42-4.031c.264-.397.67-.47.67-.47.17 0 .392-.002.61 0h.5c0-.83-.669-1.5-1.5-1.5z" fill="context-fill"/>
+ <path d="M5.5 0C3.57 0 2 1.57 2 3.5v.771C1.404 4.619 1 5.264 1 6v3c0 1.099.9 2 2 2h2l.6-1H3c-.563 0-1-.438-1-1V6c0-.563.437-1 1-1h5c.563 0 1 .437 1 1h1c0-.736-.404-1.381-1-1.729V3.5C9 1.57 7.43 0 5.5 0Zm0 1C6.894 1 8 2.106 8 3.5V4H3v-.5C3 2.106 4.106 1 5.5 1Z" fill="context-stroke"/>
+ <path fill="#facc15" d="M9 7a.5.5 0 0 0-.424.234l-2.5 4A.5.5 0 0 0 6.5 12h5a.5.5 0 0 0 .424-.766l-2.5-4A.5.5 0 0 0 9 7Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/chat-lock.svg b/comm/mail/themes/shared/mail/icons/new/chat-lock.svg
new file mode 100644
index 0000000000..e09622329c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/chat-lock.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M3 4.5h5c.831 0 1.5.669 1.5 1.5v3c0 .83-.669 1.5-1.5 1.5H3c-.831 0-1.5-.67-1.5-1.5V6c0-.831.669-1.5 1.5-1.5Z" fill="context-fill"/>
+ <path d="M5.5 0C3.57 0 2 1.57 2 3.5v.771C1.404 4.619 1 5.264 1 6v3c0 1.099.9 2 2 2h5c1.1 0 2-.901 2-2V6c0-.736-.404-1.381-1-1.729V3.5C9 1.57 7.43 0 5.5 0Zm0 1C6.894 1 8 2.106 8 3.5V4H3v-.5C3 2.106 4.106 1 5.5 1ZM3 5h5c.563 0 1 .437 1 1v3c0 .562-.437 1-1 1H3c-.563 0-1-.438-1-1V6c0-.563.437-1 1-1Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/circle-sm.svg b/comm/mail/themes/shared/mail/icons/new/circle-sm.svg
new file mode 100644
index 0000000000..8497dcfac1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/circle-sm.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="10" height="10" xmlns="http://www.w3.org/2000/svg" fill="context-fill">
+ <circle cx="5" cy="5" r="5" />
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/column-menu.svg b/comm/mail/themes/shared/mail/icons/new/column-menu.svg
new file mode 100644
index 0000000000..336cb16281
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/column-menu.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M3 2c-.554 0-1 .446-1 1v1h8V3c0-.554-.446-1-1-1Z" fill="context-fill"/>
+ <path d="M3 1c-1.1 0-2 .9-2 2v6c0 1.099.9 2 2 2h4.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H5V5h2v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V5h2v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V3c0-1.1-.9-2-2-2Zm0 1h1v2H2V3c0-.563.437-1 1-1Zm2 0h2v2H5Zm3 0h1c.563 0 1 .437 1 1v1H8ZM2 5h2v5H3c-.563 0-1-.438-1-1Zm5.5 2a.5.5 0 0 0-.416.777l2 3a.5.5 0 0 0 .832 0l2-3A.5.5 0 0 0 11.5 7Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/account-settings.svg b/comm/mail/themes/shared/mail/icons/new/compact/account-settings.svg
new file mode 100644
index 0000000000..7f7d3f47f0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/account-settings.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8 .498a1 1 0 0 0-.48.123l-5.5 3c-.32.176-.52.514-.52.879v7c0 .365.2.703.52.879l1.48.807V12c0-.831.669-1.5 1.5-1.5h1.5c-.741-.295-1.999-1.953-2-3.5a3.5 3.5 0 0 1 7 0c-.001 1.547-1.259 3.205-2 3.5H11c.831 0 1.5.669 1.5 1.5v1.186l1.48-.807c.32-.176.52-.514.52-.879v-7c0-.365-.2-.703-.52-.879l-5.5-3A1 1 0 0 0 8 .498ZM7.969 15.5h.062z" fill="context-fill"/>
+ <path d="M8-.002c-.248 0-.496.06-.72.184l-5.5 3A1.5 1.5 0 0 0 1 4.5v7a1.502 1.502 0 0 0 .78 1.318l5.5 3c.448.246.992.246 1.44 0l5.5-3A1.5 1.5 0 0 0 15 11.5v-7a1.502 1.502 0 0 0-.78-1.318l-5.5-3A1.499 1.499 0 0 0 8-.002ZM8 1a.5.5 0 0 1 .24.06l5.5 3c.16.088.26.258.26.44v7c0 .182-.1.352-.26.44l-.74.404V12c0-1.1-.9-2-2-2h-.326c.167-.2.372-.269.527-.514C11.646 8.782 12 7.91 12 7c0-2.203-1.797-4-4-4-2.203 0-4 1.797-4 4 0 .909.354 1.782.799 2.486.155.245.36.314.527.514H5c-1.1 0-2 .9-2 2v.344l-.74-.405A.503.503 0 0 1 2 11.5v-7c0-.182.1-.352.26-.44l5.5-3A.5.5 0 0 1 8 1Zm0 3c1.663 0 3 1.337 3 3 0 .638-.275 1.368-.645 1.953-.369.585-.879 1.018-1.04 1.082A.5.5 0 0 0 9.5 11H11c.563 0 1 .437 1 1v.889l-3.76 2.05a.502.502 0 0 1-.48 0L4 12.89V12c0-.563.437-1 1-1h1.5a.5.5 0 0 0 .186-.965c-.162-.064-.672-.497-1.041-1.082C5.275 8.368 5 7.638 5 7c0-1.663 1.337-3 3-3Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/add-circle.svg b/comm/mail/themes/shared/mail/icons/new/compact/add-circle.svg
new file mode 100644
index 0000000000..cef48e8643
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/add-circle.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5.47A7.03 7.03 0 0 0 .47 7.5a7.03 7.03 0 0 0 7.03 7.03 7.03 7.03 0 0 0 7.03-7.03A7.03 7.03 0 0 0 7.5.47Z" stroke-opacity="0.2" stroke="context-stroke" fill="context-fill"/>
+ <path d="M7.5 4a.5.5 0 0 0-.5.5V7H4.5a.5.5 0 0 0 0 1H7v2.5a.5.5 0 0 0 1 0V8h2.5a.5.5 0 0 0 0-1H8V4.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/add.svg b/comm/mail/themes/shared/mail/icons/new/compact/add.svg
new file mode 100644
index 0000000000..a15b3f2b61
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/add.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5 3a.5.5 0 0 0-.5.5V7H3.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H7v3.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V8h3.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H8V3.5a.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/address-book.svg b/comm/mail/themes/shared/mail/icons/new/compact/address-book.svg
new file mode 100644
index 0000000000..05c49694d7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/address-book.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 1c-1.1 0-2 .9-2 2h-.5a.5.5 0 0 0 0 1H2v2h-.5a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5H2v2h-.5a.5.5 0 0 0 0 1H2v2h-.5a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5H2c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2Zm0 1h8c.563 0 1 .437 1 1v10c0 .563-.437 1-1 1H4c-.563 0-1-.437-1-1h.5a.5.5 0 0 0 .428-.748A.5.5 0 0 0 3.5 12H3v-2h.5a.5.5 0 0 0 .428-.252A.5.5 0 0 0 3.5 9H3V7h.5a.5.5 0 0 0 .428-.252.5.5 0 0 0 0-.496A.5.5 0 0 0 3.5 6H3V4h.5a.5.5 0 0 0 .428-.252A.5.5 0 0 0 3.5 3H3c0-.563.437-1 1-1Zm4.5 2c-.861 0-1.537.312-1.947.805C6.143 5.297 6 5.917 6 6.5c0 .861.298 1.486.61 1.875.181.227.258.243.39.334V9h-.5C5.678 9 5 9.678 5 10.5v1a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-1c0-.286.214-.5.5-.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.277-.447s-.143-.067-.332-.303C7.2 7.514 7 7.139 7 6.5c0-.417.108-.797.322-1.055C7.537 5.188 7.862 5 8.5 5c.639 0 .963.188 1.178.445.214.258.322.638.322 1.055 0 .639-.202 1.014-.39 1.25-.19.236-.333.303-.333.303A.5.5 0 0 0 9 8.5v1a.5.5 0 0 0 .5.5h1c.286 0 .5.214.5.5v1a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-1c0-.822-.678-1.5-1.5-1.5H10v-.291c.132-.091.209-.107.39-.334.312-.389.61-1.014.61-1.875 0-.583-.142-1.203-.553-1.695C10.037 4.312 9.361 4 8.5 4Z" fill="context-stroke"/>
+ <path d="M4 2c-.554 0-1 .446-1 1v10c0 .554.446 1 1 1h8c.554 0 1-.446 1-1V3c0-.554-.446-1-1-1Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/app-menu-badged.svg b/comm/mail/themes/shared/mail/icons/new/compact/app-menu-badged.svg
new file mode 100644
index 0000000000..4b51fb0a8c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/app-menu-badged.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M2.5 3c-.667 0-.667 1 0 1h5c.667 0 .667-1 0-1zm0 4c-.667 0-.667 1 0 1h11c.667 0 .667-1 0-1zm0 4c-.667 0-.667 1 0 1h11c.667 0 .667-1 0-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/app-menu.svg b/comm/mail/themes/shared/mail/icons/new/compact/app-menu.svg
new file mode 100644
index 0000000000..c0a65b1818
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/app-menu.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M2.5 3a.5.5 0 1 0 0 1h11a.5.5 0 0 0 0-1Zm0 4a.5.5 0 1 0 0 1h11a.5.5 0 0 0 0-1Zm0 4a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/archive.svg b/comm/mail/themes/shared/mail/icons/new/compact/archive.svg
new file mode 100644
index 0000000000..0d29b3243d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/archive.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 2c-1.108 0-2 .892-2 2v8c0 1.108.892 2 2 2h8c1.108 0 2-.892 2-2V4c0-1.108-.892-2-2-2Zm7 1H5ZM4 4h8v1.5V5v1h-1.17C10.42 7.167 9.31 8 8 8s-2.42-.833-2.83-2H4V5v.5Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.347 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.653-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2v1h-1V4c0-.545-.455-1-1-1H4c-.545 0-1 .455-1 1v1.002c-.342 0-.673 0-1-.002V4c0-1.117.883-2 2-2Zm0 2h8v1h-1.5a.5.5 0 0 0-.5.5C10 6.167 9.278 7 8 7s-2-.833-2-1.5a.5.5 0 0 0-.5-.5c-.399 0-.951 0-1.5.002Zm1.244 2C5.543 7.084 6.524 8 8 8c1.476 0 2.457-.916 2.756-2H14v6c0 1.117-.883 2-2 2H9v-1H8v1H4c-1.117 0-2-.883-2-2V6.002c1.123.004 2.362-.001 3.244-.002ZM8 13v-1H7v1zm0-1h1v-1H8Zm0-1v-1H7v1zm0-1h1V9H8Zm0-1V8H7v1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/attachment.svg b/comm/mail/themes/shared/mail/icons/new/compact/attachment.svg
new file mode 100644
index 0000000000..e7b8f9717f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/attachment.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M9 3.5a1.5 1.5 0 0 0-3-.001v7.95C6 12.83 7.12 14 8.5 14s2.5-1.17 2.5-2.55V5.5a.5.5 0 0 1 1 0v6.03C11.955 13.427 10.405 15 8.5 15S5.044 13.426 5 11.53V3.5a2.5 2.5 0 0 1 5 0v7.003a1.5 1.5 0 0 1-3-.003v-5a.5.5 0 0 1 1 0v5a.5.5 0 0 0 1 0Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/calendar-invite.svg b/comm/mail/themes/shared/mail/icons/new/compact/calendar-invite.svg
new file mode 100644
index 0000000000..ae2b4b0f0a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/calendar-invite.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 2c-1.108 0-2 .892-2 2h12c0-1.108-.892-2-2-2Zm1 5.5c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h1c.277 0 .5-.223.5-.5V8c0-.277-.223-.5-.5-.5Zm5 3c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h1c.277 0 .5-.223.5-.5v-1c0-.277-.223-.5-.5-.5z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.654-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2h-2v-.5a.5.5 0 1 0-1 0V4H5v-.5c0-.277-.208-.5-.467-.5h-.066C4.208 3 4 3.223 4 3.5V4H2c0-1.117.883-2 2-2ZM2 5h2v.5c0 .277.208.5.467.5h.066C4.792 6 5 5.777 5 5.5V5h6v.5a.5.5 0 1 0 1 0V5h2v7c0 1.116-.883 2-2 2H4c-1.117 0-2-.884-2-2Zm2.5 2a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5ZM5 8h1v1H5Zm3.5 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm1 2a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5Zm-5 1a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm5.5 0h1v1h-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/calendar-today.svg b/comm/mail/themes/shared/mail/icons/new/compact/calendar-today.svg
new file mode 100644
index 0000000000..facf5a9871
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/calendar-today.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 2c-1.108 0-2 .892-2 2h12c0-1.108-.892-2-2-2Zm1 5.5c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h1c.277 0 .5-.223.5-.5V8c0-.277-.223-.5-.5-.5Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.654-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2h-2v-.5a.5.5 0 0 0-.5-.493.5.5 0 0 0-.5.493V4H5v-.5a.5.5 0 0 0-.5-.493.5.5 0 0 0-.5.493V4H2c0-1.117.883-2 2-2ZM2 5h2v.5a.5.5 0 0 0 1 0V5h6v.5a.5.5 0 0 0 1 0V5h2v7c0 1.116-.883 2-2 2H4c-1.117 0-2-.884-2-2Zm2.5 2a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5Zm3.975 0A.5.5 0 1 0 8.5 7ZM10.5 7a.5.5 0 0 0 0 1h1a.5.5 0 0 0 .428-.252A.5.5 0 0 0 11.5 7ZM5 8h1v1H5Zm3.475 1A.5.5 0 1 0 8.5 9ZM10.5 9a.5.5 0 0 0 0 1h1a.5.5 0 0 0 .428-.252A.5.5 0 0 0 11.5 9Zm-6 2a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h1a.5.5 0 0 0 .428-.748A.5.5 0 0 0 5.5 11Zm3.016 0a.5.5 0 0 0-.493.5.5.5 0 0 0 .493.5h1a.5.5 0 0 0 .427-.748.5.5 0 0 0-.427-.252Zm2.984 0a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h1a.5.5 0 0 0 .428-.748A.5.5 0 0 0 11.5 11Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/calendar.svg b/comm/mail/themes/shared/mail/icons/new/compact/calendar.svg
new file mode 100644
index 0000000000..9620681019
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/calendar.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 2c-1.108 0-2 .892-2 2h12c0-1.108-.892-2-2-2Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.347 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.653-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2h-2v-.5a.5.5 0 0 0-.5-.492.5.5 0 0 0-.5.492V4H5v-.5a.5.5 0 0 0-.5-.492.5.5 0 0 0-.5.492V4H2c0-1.117.883-2 2-2ZM2 5h2v.5a.5.5 0 0 0 1 0V5h6v.5a.5.5 0 0 0 1 0V5h2v7c0 1.117-.883 2-2 2H4c-1.117 0-2-.883-2-2Zm2.475 2A.5.5 0 0 0 4.5 8h1a.5.5 0 1 0 0-1zm3 0A.5.5 0 0 0 7.5 8h1a.5.5 0 1 0 0-1zm3 0a.5.5 0 0 0 .025 1h1a.5.5 0 1 0 0-1zm-6 2a.5.5 0 0 0 .025 1h1a.5.5 0 1 0 0-1zm3 0a.5.5 0 0 0 .025 1h1a.5.5 0 1 0 0-1zm3 0a.5.5 0 0 0 .025 1h1a.5.5 0 1 0 0-1zm-6 2a.5.5 0 0 0 .025 1h1a.5.5 0 1 0 0-1zm3 0a.5.5 0 0 0 .025 1h1a.5.5 0 1 0 0-1zm3 0a.5.5 0 0 0 .025 1h1a.5.5 0 1 0 0-1z" fill="context-stroke"/>
+</svg>
+
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/chat.svg b/comm/mail/themes/shared/mail/icons/new/compact/chat.svg
new file mode 100644
index 0000000000..984941fd89
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/chat.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 1C2.347 1 1 2.347 1 4v3c0 1.653 1.347 3 3 3v2.5a.5.5 0 0 0 .854.354L5.707 12h2.586l2.853 2.854A.5.5 0 0 0 12 14.5V12c1.653 0 3-1.347 3-3V6c0-1.35-.899-2.497-2.129-2.871A3.007 3.007 0 0 0 10 1Zm0 1h6c1.117 0 2 .883 2 2v3c0 1.117-.883 2-2 2H7.5a.5.5 0 0 0-.354.146L5 11.293V9.5a.5.5 0 0 0-.5-.5H4c-1.117 0-2-.883-2-2V4c0-1.117.883-2 2-2Zm9 2.264c.6.342 1 .986 1 1.736v3c0 1.117-.883 2-2 2h-.5a.5.5 0 0 0-.5.5v1.793l-2.146-2.147A.5.5 0 0 0 8.5 11H6.707l1-1H10c1.653 0 3-1.347 3-3zM4.5 5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm1.975 0A.5.5 0 0 0 6.5 6h1a.499.499 0 1 0 0-1H6.475ZM9.5 5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+ <path d="M13 4.264c.6.342 1 .986 1 1.736v3c0 1.117-.883 2-2 2h-.5a.5.5 0 0 0-.5.5v1.793l-2.146-2.147A.5.5 0 0 0 8.5 11H6.707l1-1H10c1.653 0 3-1.347 3-3z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/check.svg b/comm/mail/themes/shared/mail/icons/new/compact/check.svg
new file mode 100644
index 0000000000..0e6e03b7eb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/check.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M13.43 3.006a.5.5 0 0 0-.33.195l-5.655 7.537-3.591-3.592a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .708l4 4A.5.5 0 0 0 7.9 11.8l6-8a.5.5 0 0 0-.1-.701.5.5 0 0 0-.37-.094z" fill="context-fill transparent"/>
+ <path d="M13.43 3.006a.5.5 0 0 0-.33.195l-5.655 7.537-3.591-3.592a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .708l4 4A.5.5 0 0 0 7.9 11.8l6-8a.5.5 0 0 0-.1-.701.5.5 0 0 0-.37-.094z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/checkbox.svg b/comm/mail/themes/shared/mail/icons/new/compact/checkbox.svg
new file mode 100644
index 0000000000..dccc7e9a5b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/checkbox.svg
@@ -0,0 +1,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" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.654-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2v8c0 1.116-.883 2-2 2H4c-1.117 0-2-.884-2-2V4c0-1.117.883-2 2-2Z" fill="context-stroke"/>
+ <path d="M11.402 4.01a.5.5 0 0 0-.318.212L7.422 9.714 4.854 7.146a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .707l3 3a.5.5 0 0 0 .77-.076l4-6a.5.5 0 0 0-.139-.693.5.5 0 0 0-.375-.075Z" fill-opacity="context-fill-opacity" fill="context-fill"/>
+ <path d="M4.5 7a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill-opacity="context-stroke-opacity" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/clock.svg b/comm/mail/themes/shared/mail/icons/new/compact/clock.svg
new file mode 100644
index 0000000000..563d5ba2b9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/clock.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M14.5 7.5a7 7 0 0 1-7 7 7 7 0 0 1-7-7 7 7 0 0 1 7-7 7 7 0 0 1 7 7z" fill="context-fill"/>
+ <path d="M7.5 0C3.364 0 0 3.363 0 7.5 0 11.636 3.364 15 7.5 15S15 11.636 15 7.5C15 3.363 11.636 0 7.5 0Zm0 1C11.096 1 14 3.904 14 7.5c0 3.595-2.904 6.5-6.5 6.5A6.492 6.492 0 0 1 1 7.5C1 3.904 3.904 1 7.5 1Zm0 1a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H8V2.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/close.svg b/comm/mail/themes/shared/mail/icons/new/compact/close.svg
new file mode 100644
index 0000000000..1317c42410
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/close.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4.5 4a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707L7.293 8l-3.147 3.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0L8 8.707l3.146 3.146a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L8.707 8l3.147-3.147a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0L8 7.293 4.854 4.146A.5.5 0 0 0 4.5 4z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/cloud-download.svg b/comm/mail/themes/shared/mail/icons/new/compact/cloud-download.svg
new file mode 100644
index 0000000000..60d86587da
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/cloud-download.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M10.5 1.5a2.993 2.993 0 0 0-2.62 1.533A2.74 2.74 0 0 0 6.25 2.5c-1.276 0-2.343.86-2.656 2.035A2.492 2.492 0 0 0 1.5 7c0 1.385 1.115 2.5 2.5 2.5h3v-1C7 7.669 7.669 7 8.5 7s1.5.669 1.5 1.5v1h2c1.385 0 2.5-1.115 2.5-2.5 0-.82-.393-1.543-1-1.998V4.5c0-1.662-1.338-3-3-3z" fill="context-fill"/>
+ <path d="M10.5 1c-1.158 0-2.125.618-2.762 1.486C7.28 2.24 6.805 2 6.25 2c-1.405 0-2.54.932-2.992 2.186C1.982 4.53 1 5.618 1 7c0 1.653 1.347 3 3 3h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H4c-1.117 0-2-.883-2-2 0-1.005.72-1.816 1.674-1.97a.5.5 0 0 0 .402-.366A2.238 2.238 0 0 1 6.25 3c.504 0 .96.161 1.334.436a.5.5 0 0 0 .732-.159A2.489 2.489 0 0 1 10.5 2C11.894 2 13 3.106 13 4.5v.502a.5.5 0 0 0 .2.4c.486.366.8.937.8 1.598 0 1.117-.883 2-2 2h-1.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H12c1.653 0 3-1.347 3-3 0-.868-.41-1.615-1-2.162V4.5C14 2.57 12.43 1 10.5 1zm-2 7.006a.5.5 0 0 0-.5.5v4.793l-2.146-2.147a.5.5 0 1 0-.708.707l3 3a.482.482 0 0 0 .036.026.495.495 0 0 0 .318.121.468.468 0 0 0 .035-.006.504.504 0 0 0 .15-.035.495.495 0 0 0 .133-.08.482.482 0 0 0 .036-.026l3-3a.5.5 0 1 0-.708-.707L9 13.3V8.506a.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/collapse.svg b/comm/mail/themes/shared/mail/icons/new/compact/collapse.svg
new file mode 100644
index 0000000000..d0dae80d20
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/collapse.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M2.5 2a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5Zm6 1a.5.5 0 0 0-.354.146l-4 4A.5.5 0 0 0 4 7.5a.5.5 0 0 0 .146.354l4 4a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.708L5.707 8H13.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H5.707l3.147-3.146a.5.5 0 0 0 0-.708A.5.5 0 0 0 8.5 3Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/compress.svg b/comm/mail/themes/shared/mail/icons/new/compact/compress.svg
new file mode 100644
index 0000000000..05a4391e7a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/compress.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M6.5 5.5h3c.554 0 1 .446 1 1v3c0 .554-.446 1-1 1h-3c-.554 0-1-.446-1-1v-3c0-.554.446-1 1-1z" fill="context-fill"/>
+ <path d="M1.5 1a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707L3.293 4H1.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .354-.147.5.5 0 0 0 .076-.1.5.5 0 0 0 .004-.003.5.5 0 0 0 .002-.004.5.5 0 0 0 .046-.117.5.5 0 0 0 .018-.13v-3a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5v1.794L1.854 1.146A.5.5 0 0 0 1.5 1Zm10 0a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .146.353.5.5 0 0 0 .1.076.5.5 0 0 0 .004.004.5.5 0 0 0 .004.002.5.5 0 0 0 .117.047A.5.5 0 0 0 11.5 5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-1.793l2.147-2.147A.5.5 0 0 0 15 1.5a.5.5 0 0 0-.146-.354.5.5 0 0 0-.708 0L12 3.293V1.5a.5.5 0 0 0-.5-.5Zm-5 4C5.678 5 5 5.677 5 6.5v3c0 .822.678 1.5 1.5 1.5h3c.822 0 1.5-.678 1.5-1.5v-3c0-.823-.678-1.5-1.5-1.5Zm0 1h3c.286 0 .5.214.5.5v3c0 .285-.214.5-.5.5h-3a.488.488 0 0 1-.5-.5v-3c0-.286.214-.5.5-.5Zm-5 5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h1.793l-2.147 2.146A.5.5 0 0 0 1 14.5a.5.5 0 0 0 .146.353.5.5 0 0 0 .708 0L4 12.707V14.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.146-.354.5.5 0 0 0-.1-.076.5.5 0 0 0-.004-.004.5.5 0 0 0-.004-.002.5.5 0 0 0-.117-.047A.5.5 0 0 0 4.5 11Zm10 0a.5.5 0 0 0-.354.146.5.5 0 0 0-.076.1.5.5 0 0 0-.004.004.5.5 0 0 0-.002.004.5.5 0 0 0-.046.117.5.5 0 0 0-.018.129v3a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-1.793l2.146 2.146A.5.5 0 0 0 14.5 15a.5.5 0 0 0 .354-.147.5.5 0 0 0 0-.707L12.707 12H14.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/contact.svg b/comm/mail/themes/shared/mail/icons/new/compact/contact.svg
new file mode 100644
index 0000000000..5851d57e1f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/contact.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5.5a7 7 0 0 0-7 7 7 7 0 0 0 3 5.728V11.75c0-.693.558-1.25 1.25-1.25H6.5v-.446A2.75 2.75 0 0 1 4.75 7.5 2.75 2.75 0 0 1 7.5 4.75a2.75 2.75 0 0 1 2.75 2.75 2.75 2.75 0 0 1-1.75 2.554v.446h1.75c.692 0 1.25.557 1.25 1.25v1.478a7 7 0 0 0 3-5.728 7 7 0 0 0-7-7z" fill="context-fill"/>
+ <path d="M7.5 0C3.364 0 0 3.363 0 7.5a7.493 7.493 0 0 0 3.143 6.1.5.5 0 0 0 .064.044A7.458 7.458 0 0 0 7.5 15a7.458 7.458 0 0 0 4.293-1.356.5.5 0 0 0 .064-.045A7.493 7.493 0 0 0 15 7.499c0-4.136-3.364-7.5-7.5-7.5zm0 1C11.096 1 14 3.904 14 7.5a6.479 6.479 0 0 1-2 4.691V12c0-1.1-.9-2-2-2h-.102c.083-.096.193-.101.272-.207.46-.616.829-1.417.83-2.293C11 5.573 9.427 4 7.5 4A3.508 3.508 0 0 0 4 7.5c.001.876.37 1.677.83 2.293.08.106.19.11.272.207H5c-1.1 0-2 .9-2 2v.191A6.477 6.477 0 0 1 1 7.5C1 3.904 3.904 1 7.5 1Zm0 4C8.887 5 10 6.113 10 7.5c-.001.585-.27 1.212-.63 1.695-.36.483-.857.792-.97.814a.5.5 0 0 0 .1.99H10c.563 0 1 .438 1 1v.98a6.475 6.475 0 0 1-3.5 1.02 6.48 6.48 0 0 1-3.5-1.02v-.98c0-.562.437-1 1-1h1.5a.5.5 0 0 0 .1-.99c-.113-.022-.61-.331-.97-.814-.36-.483-.629-1.11-.63-1.695C5 6.113 6.113 5 7.5 5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/conversation.svg b/comm/mail/themes/shared/mail/icons/new/compact/conversation.svg
new file mode 100644
index 0000000000..37f6919c74
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/conversation.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3 3.5c-.831 0-1.5.669-1.5 1.5v7c0 .83.669 1.5 1.5 1.5h5c0-.401.171-.766.436-1.037-.116-.127-.258-.237-.32-.39a1.537 1.537 0 0 1 .323-1.634l2-2a1.5 1.5 0 0 1 2.122 0c.424.425.428 1.025.228 1.56H14.5v-5c0-.83-.669-1.5-1.5-1.5H3Z" fill="context-fill"/>
+ <path d="M3 3c-1.1 0-2 .9-2 2v7c0 1.099.9 2 2 2h5.09a1.416 1.416 0 0 1 .004-1H3c-.563 0-1-.438-1-1V5c0-.563.437-1 1-1h10c.563 0 1 .437 1 1v5h1V5c0-1.1-.9-2-2-2Zm.47 2.002a.5.5 0 0 0-.343.166.5.5 0 0 0 .041.705l2.781 2.47-2.803 2.803a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.843-2.844.971.864a.5.5 0 0 0 .664 0l.97-.864.284.284.707-.707-.242-.243 2.781-2.47a.5.5 0 0 0 .041-.705.5.5 0 0 0-.705-.041L8 8.832 3.832 5.127a.5.5 0 0 0-.361-.125Zm7.952 4.004a.5.5 0 0 0-.276.14l-2 2A.5.5 0 0 0 9.5 12h6a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-4.793l1.147-1.147a.5.5 0 0 0 0-.707.5.5 0 0 0-.432-.14zM9.5 13a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h4.793l-1.147 1.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2-2A.5.5 0 0 0 15.5 13Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/copy.svg b/comm/mail/themes/shared/mail/icons/new/compact/copy.svg
new file mode 100644
index 0000000000..9d2c4afd8f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/copy.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M5 1c-1.1 0-2 .901-2 2-1.1 0-2 .901-2 2v8c0 1.1.9 2 2 2h7c1.1 0 2-.9 2-2 1.1 0 2-.9 2-2V3c0-1.099-.9-2-2-2Zm0 1h7c.563 0 1 .438 1 1v8c0 .563-.437 1-1 1H5c-.563 0-1-.437-1-1V3c0-.562.437-1 1-1ZM3 4v7c0 1.1.9 2 2 2h6c0 .563-.437 1-1 1H3c-.563 0-1-.437-1-1V5c0-.562.437-1 1-1Zm3.5 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm0 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm0 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+ <path d="M3.268 4C3.098 4.294 3 4.634 3 5v6c0 1.108.892 2 2 2h5a1.99 1.99 0 0 0 1-.268V13c0 .554-.446 1-1 1H3c-.554 0-1-.446-1-1V5c0-.554.446-1 1-1Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/cut.svg b/comm/mail/themes/shared/mail/icons/new/compact/cut.svg
new file mode 100644
index 0000000000..a8f1159a01
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/cut.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4.418 2.006a.5.5 0 0 0-.184.07.5.5 0 0 0-.158.69L7.41 8.099 6.34 9.812A2.492 2.492 0 0 0 4.5 9 2.508 2.508 0 0 0 2 11.5C2 12.874 3.125 14 4.5 14S7 12.874 7 11.5c0-.246-.037-.483-.104-.707a.5.5 0 0 0 .028-.028L8 9.043l1.076 1.722a.5.5 0 0 0 .028.028A2.482 2.482 0 0 0 9 11.5c0 1.374 1.125 2.5 2.5 2.5s2.5-1.126 2.5-2.5c0-1.375-1.125-2.5-2.5-2.5-.726 0-1.382.313-1.84.812L8.59 8.099l3.334-5.334a.5.5 0 0 0-.158-.69.5.5 0 0 0-.69.159L8 7.156l-3.076-4.92a.5.5 0 0 0-.506-.23ZM4.5 10c.834 0 1.5.665 1.5 1.5 0 .834-.666 1.5-1.5 1.5S3 12.334 3 11.5c0-.835.666-1.5 1.5-1.5zm7 0c.834 0 1.5.665 1.5 1.5 0 .834-.666 1.5-1.5 1.5s-1.5-.666-1.5-1.5c0-.835.666-1.5 1.5-1.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/density-compact.svg b/comm/mail/themes/shared/mail/icons/new/compact/density-compact.svg
new file mode 100644
index 0000000000..05a4391e7a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/density-compact.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M6.5 5.5h3c.554 0 1 .446 1 1v3c0 .554-.446 1-1 1h-3c-.554 0-1-.446-1-1v-3c0-.554.446-1 1-1z" fill="context-fill"/>
+ <path d="M1.5 1a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707L3.293 4H1.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .354-.147.5.5 0 0 0 .076-.1.5.5 0 0 0 .004-.003.5.5 0 0 0 .002-.004.5.5 0 0 0 .046-.117.5.5 0 0 0 .018-.13v-3a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5v1.794L1.854 1.146A.5.5 0 0 0 1.5 1Zm10 0a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .146.353.5.5 0 0 0 .1.076.5.5 0 0 0 .004.004.5.5 0 0 0 .004.002.5.5 0 0 0 .117.047A.5.5 0 0 0 11.5 5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-1.793l2.147-2.147A.5.5 0 0 0 15 1.5a.5.5 0 0 0-.146-.354.5.5 0 0 0-.708 0L12 3.293V1.5a.5.5 0 0 0-.5-.5Zm-5 4C5.678 5 5 5.677 5 6.5v3c0 .822.678 1.5 1.5 1.5h3c.822 0 1.5-.678 1.5-1.5v-3c0-.823-.678-1.5-1.5-1.5Zm0 1h3c.286 0 .5.214.5.5v3c0 .285-.214.5-.5.5h-3a.488.488 0 0 1-.5-.5v-3c0-.286.214-.5.5-.5Zm-5 5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h1.793l-2.147 2.146A.5.5 0 0 0 1 14.5a.5.5 0 0 0 .146.353.5.5 0 0 0 .708 0L4 12.707V14.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.146-.354.5.5 0 0 0-.1-.076.5.5 0 0 0-.004-.004.5.5 0 0 0-.004-.002.5.5 0 0 0-.117-.047A.5.5 0 0 0 4.5 11Zm10 0a.5.5 0 0 0-.354.146.5.5 0 0 0-.076.1.5.5 0 0 0-.004.004.5.5 0 0 0-.002.004.5.5 0 0 0-.046.117.5.5 0 0 0-.018.129v3a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-1.793l2.146 2.146A.5.5 0 0 0 14.5 15a.5.5 0 0 0 .354-.147.5.5 0 0 0 0-.707L12.707 12H14.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/density-default.svg b/comm/mail/themes/shared/mail/icons/new/compact/density-default.svg
new file mode 100644
index 0000000000..7de83e9f4c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/density-default.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M6.5 5.5h3c.554 0 1 .446 1 1v3c0 .554-.446 1-1 1h-3c-.554 0-1-.446-1-1v-3c0-.554.446-1 1-1z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v1.488a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V4c0-1.117.883-2 2-2h1.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm6.5 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H12c1.117 0 2 .883 2 2v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V4c0-1.654-1.347-3-3-3Zm-4 4C5.678 5 5 5.677 5 6.5v3c0 .822.678 1.5 1.5 1.5h3c.822 0 1.5-.678 1.5-1.5v-3c0-.823-.678-1.5-1.5-1.5Zm0 1h3c.286 0 .5.214.5.5v3c0 .285-.214.5-.5.5h-3a.488.488 0 0 1-.5-.5v-3c0-.286.214-.5.5-.5Zm-5 4a.5.5 0 0 0-.5.5V12c0 1.653 1.347 3 3 3h1.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H4c-1.117 0-2-.884-2-2v-1.5a.5.5 0 0 0-.5-.5Zm13 0a.5.5 0 0 0-.5.5V12c0 1.116-.883 2-2 2h-1.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H12c1.653 0 3-1.347 3-3v-1.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/density-relaxed.svg b/comm/mail/themes/shared/mail/icons/new/compact/density-relaxed.svg
new file mode 100644
index 0000000000..652f77c5a7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/density-relaxed.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M6.5 5.5h3c.554 0 1 .446 1 1v3c0 .554-.446 1-1 1h-3c-.554 0-1-.446-1-1v-3c0-.554.446-1 1-1z" fill="context-fill"/>
+ <path d="M1.5 1a.5.5 0 0 0-.129.017.5.5 0 0 0-.225.13.5.5 0 0 0-.076.099.5.5 0 0 0-.004.004.5.5 0 0 0-.002.004.5.5 0 0 0-.046.117A.5.5 0 0 0 1 1.5v3a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V2.707l2.146 2.146A.5.5 0 0 0 4.5 5a.5.5 0 0 0 .354-.147.5.5 0 0 0 0-.707L2.707 2H4.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm10 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h1.793l-2.147 2.146A.5.5 0 0 0 11 4.5a.5.5 0 0 0 .146.353.5.5 0 0 0 .708 0L14 2.707V4.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.018-.13.5.5 0 0 0-.128-.224.5.5 0 0 0-.1-.076.5.5 0 0 0-.004-.004.5.5 0 0 0-.004-.002.5.5 0 0 0-.117-.047A.5.5 0 0 0 14.5 1Zm-5 4C5.678 5 5 5.677 5 6.5v3c0 .822.678 1.5 1.5 1.5h3c.822 0 1.5-.678 1.5-1.5v-3c0-.823-.678-1.5-1.5-1.5Zm0 1h3c.286 0 .5.214.5.5v3c0 .285-.214.5-.5.5h-3a.488.488 0 0 1-.5-.5v-3c0-.286.214-.5.5-.5Zm-5 5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .018.129.5.5 0 0 0 .128.224.5.5 0 0 0 .1.076.5.5 0 0 0 .004.004.5.5 0 0 0 .004.002.5.5 0 0 0 .117.047A.5.5 0 0 0 1.5 15h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H2.707l2.147-2.147A.5.5 0 0 0 5 11.5a.5.5 0 0 0-.146-.354.5.5 0 0 0-.708 0L2 13.293V11.5a.5.5 0 0 0-.5-.5Zm10 0a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707L13.293 14H11.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .129-.018.5.5 0 0 0 .225-.129.5.5 0 0 0 .076-.1.5.5 0 0 0 .004-.003.5.5 0 0 0 .002-.004.5.5 0 0 0 .046-.117.5.5 0 0 0 .018-.13v-3a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5v1.794l-2.146-2.147A.5.5 0 0 0 11.5 11Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/display-options.svg b/comm/mail/themes/shared/mail/icons/new/compact/display-options.svg
new file mode 100644
index 0000000000..2efb8fc123
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/display-options.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M2.5 2a.5.5 0 0 0-.5.5V4c0 .545.455 1 1 1h6c.545 0 1-.455 1-1V2.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5V4H3V2.5a.5.5 0 0 0-.5-.5zm10 0a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 1 0v-3a.5.5 0 0 0-.5-.5zM3 6c-.545 0-1 .455-1 1v2c0 .545.455 1 1 1h6c.545 0 1-.455 1-1V7c0-.545-.455-1-1-1H3zm0 1h6v2H3V7zm9.506.006c-.82 0-1.494.675-1.494 1.494A1.5 1.5 0 0 0 12 9.902V13.5a.5.5 0 0 0 1 0V9.906c.579-.206 1-.76 1-1.406 0-.82-.675-1.494-1.494-1.494zm0 1c.279 0 .494.215.494.494a.487.487 0 0 1-.494.494.487.487 0 0 1-.494-.494c0-.279.215-.494.494-.494zM3 11c-.545 0-1 .455-1 1v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12h6v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12c0-.545-.455-1-1-1H3z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/download.svg b/comm/mail/themes/shared/mail/icons/new/compact/download.svg
new file mode 100644
index 0000000000..b1f6aa5513
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/download.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5 3a.5.5 0 0 0-.5.5v4.793L4.854 6.146a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .707l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0L8 8.293V3.5a.5.5 0 0 0-.5-.5zm-4 7a.5.5 0 0 0-.5.5v1c0 .822.678 1.5 1.5 1.5h6c.822 0 1.5-.678 1.5-1.5v-1a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5v1c0 .285-.214.5-.5.5h-6a.488.488 0 0 1-.5-.5v-1a.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/draft.svg b/comm/mail/themes/shared/mail/icons/new/compact/draft.svg
new file mode 100644
index 0000000000..404d190c31
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/draft.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 2h8c.554 0 1 .446 1 1v10c0 .554-.446 1-1 1H4c-.554 0-1-.446-1-1V3c0-.554.446-1 1-1Z" fill="context-fill"/>
+ <path d="M4 1c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2Zm0 1h8c.563 0 1 .437 1 1v10c0 .563-.437 1-1 1H4c-.563 0-1-.437-1-1V3c0-.563.437-1 1-1Zm1.5 2a.5.5 0 0 0 0 1h5a.5.5 0 0 0 .428-.252A.5.5 0 0 0 10.5 4Zm0 2a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h5a.5.5 0 0 0 .428-.252.5.5 0 0 0 0-.496A.5.5 0 0 0 10.5 6Zm0 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 .428-.252A.5.5 0 0 0 8.5 8Zm2 3a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h3a.5.5 0 0 0 .428-.748A.5.5 0 0 0 10.5 11Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/error-circle.svg b/comm/mail/themes/shared/mail/icons/new/compact/error-circle.svg
new file mode 100644
index 0000000000..c45136b411
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/error-circle.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 16 16" fill-opacity="context-fill-opacity" height="16" width="16">
+ <path d="M7.5 14.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" fill="context-fill"/>
+ <path d="M7.5 0a7.5 7.5 0 1 0 .02 15.02A7.5 7.5 0 0 0 7.5 0zm0 1a6.5 6.5 0 1 1 0 13A6.5 6.5 0 0 1 1 7.5C1 3.9 3.9 1 7.5 1zm0 3a.5.5 0 0 0-.5.5v4a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-4a.5.5 0 0 0-.5-.5zm0 6a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/event-status.svg b/comm/mail/themes/shared/mail/icons/new/compact/event-status.svg
new file mode 100644
index 0000000000..271ff999e8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/event-status.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5h8c1.385 0 2.5 1.115 2.5 2.5v.5h-13V4c0-1.385 1.115-2.5 2.5-2.5Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.654-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2h-2v-.5a.5.5 0 1 0-1 0V4H5v-.5a.5.5 0 1 0-1 0V4H2c0-1.117.883-2 2-2ZM2 5h2v.5a.5.5 0 1 0 1 0V5h6v.5a.5.5 0 1 0 1 0V5h2v7c0 1.116-.883 2-2 2H4c-1.117 0-2-.884-2-2Zm8.56 2.004a.5.5 0 0 0-.13.002.5.5 0 0 0-.33.193l-2.655 3.539-1.591-1.592a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .707l2 2A.5.5 0 0 0 7.9 11.8l3-4a.5.5 0 0 0-.1-.7.5.5 0 0 0-.24-.096Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/export.svg b/comm/mail/themes/shared/mail/icons/new/compact/export.svg
new file mode 100644
index 0000000000..a985cdd80a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/export.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M11.732 4c.17.294.268.635.268 1v6c0 1.108-.892 2-2 2H5a1.99 1.99 0 0 1-1-.268V13c0 .554.446 1 1 1h7c.554 0 1-.446 1-1V5c0-.554-.446-1-1-1Z" fill="context-fill"/>
+ <path d="M3 1c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2 0 1.1.9 2 2 2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2 0-1.1-.9-2-2-2Zm0 1h7c.563 0 1 .437 1 1v8c0 .563-.437 1-1 1H3c-.563 0-1-.437-1-1V3c0-.563.437-1 1-1Zm3.5 1c-.14 0-.24.043-.354.146l-2 2a.5.5 0 0 0 .708.708L6 4.707V7.5a.5.5 0 1 0 1 0V4.707l1.146 1.147a.5.5 0 0 0 .708-.708l-2-2C6.763 3.033 6.65 3 6.5 3ZM12 4c.563 0 1 .437 1 1v8c0 .563-.437 1-1 1H5c-.563 0-1-.437-1-1h6c1.1 0 2-.9 2-2zM4.5 8a.5.5 0 0 0-.5.5v1c0 .822.678 1.5 1.5 1.5h2c.822 0 1.5-.678 1.5-1.5v-1a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5v1c0 .286-.214.5-.5.5h-2a.488.488 0 0 1-.5-.5v-1a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/extension.svg b/comm/mail/themes/shared/mail/icons/new/compact/extension.svg
new file mode 100644
index 0000000000..23ddf6b3ee
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/extension.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M14 6.5c-.97 0-1 1-1.75 1a.77.77 0 0 1-.75-.75V5.5a1 1 0 0 0-1-1H9.25a.77.77 0 0 1-.75-.75c0-.75 1-.78 1-1.75C9.5 1.14 8.1.5 7 .5S4.5 1.14 4.5 2c0 .97 1 1 1 1.75a.77.77 0 0 1-.75.75H1.5a1 1 0 0 0-1 1v1.25c.01.41.34.74.75.75.75 0 .78-1 1.75-1 .87 0 1.5 1.4 1.5 2.5s-.64 2.5-1.5 2.5c-.97 0-1-1-1.75-1a.77.77 0 0 0-.75.75v3.25a1 1 0 0 0 1 1h3.25a.77.77 0 0 0 .75-.75c0-.75-1-.78-1-1.75 0-.86 1.4-1.5 2.5-1.5s2.5.64 2.5 1.5c0 .97-1 1-1 1.75.01.41.34.74.75.75h1.25a1 1 0 0 0 1-1v-3.25a.77.77 0 0 1 .75-.75c.75 0 .78 1 1.75 1 .86 0 1.5-1.4 1.5-2.5s-.64-2.5-1.5-2.5z" fill="context-fill"/>
+ <path d="M7 0c-.647 0-1.33.175-1.902.488C4.526.802 4 1.298 4 2c0 .657.403 1.094.668 1.33.133.118.23.209.277.27.044.057.05.076.051.14a.263.263 0 0 1-.26.26H1.5C.677 4 0 4.677 0 5.5v1.262A1.277 1.277 0 0 0 1.238 8h.012c.299 0 .57-.117.756-.258.186-.14.306-.293.41-.41C2.625 7.097 2.686 7 3 7c.167 0 .417.176.637.578C3.857 7.981 4 8.545 4 9c0 .453-.144 1.019-.365 1.422S3.159 11 3 11c-.313 0-.375-.097-.584-.332-.104-.117-.224-.27-.41-.41A1.279 1.279 0 0 0 1.25 10h-.014c-.67.017-1.219.565-1.236 1.236V14.5c0 .823.677 1.5 1.5 1.5h3.264A1.276 1.276 0 0 0 6 14.764v-.014c0-.299-.117-.57-.258-.756-.14-.186-.293-.306-.41-.41C5.097 13.375 5 13.314 5 13c0-.159.175-.414.578-.635A3.211 3.211 0 0 1 7 12c.453 0 1.019.144 1.422.365S9 12.841 9 13c0 .313-.097.375-.332.584-.117.104-.27.224-.41.41A1.279 1.279 0 0 0 8 14.75v.012A1.277 1.277 0 0 0 9.238 16H10.5c.823 0 1.5-.677 1.5-1.5v-3.236a.263.263 0 0 1 .26-.26c.064.001.083.007.14.05.061.047.152.145.27.278.236.265.673.668 1.33.668.701 0 1.198-.526 1.512-1.098A4.046 4.046 0 0 0 16 9c0-.647-.175-1.33-.488-1.902C15.198 6.526 14.702 6 14 6c-.657 0-1.094.403-1.33.668a2.493 2.493 0 0 1-.27.277c-.057.044-.076.05-.14.051a.263.263 0 0 1-.26-.26V5.5c0-.823-.677-1.5-1.5-1.5H9.264a.263.263 0 0 1-.26-.26c.001-.064.007-.083.05-.14.047-.061.145-.152.278-.27C9.597 3.094 10 2.657 10 2 10 1.299 9.474.802 8.902.488A4.046 4.046 0 0 0 7 0Zm0 1c.453 0 1.019.144 1.422.365S9 1.841 9 2c0 .313-.097.375-.332.584-.117.104-.27.224-.41.41A1.279 1.279 0 0 0 8 3.75v.014c.017.67.565 1.219 1.236 1.236H10.5c.282 0 .5.218.5.5v1.264c.017.67.565 1.219 1.236 1.236h.014c.299 0 .57-.117.756-.258.186-.14.306-.293.41-.41.209-.235.27-.332.584-.332.159 0 .414.175.635.578.221.403.365.969.365 1.422 0 .453-.144 1.019-.365 1.422S14.159 11 14 11c-.313 0-.375-.097-.584-.332-.104-.117-.224-.27-.41-.41A1.279 1.279 0 0 0 12.25 10h-.014c-.67.017-1.219.565-1.236 1.236V14.5c0 .282-.218.5-.5.5H9.262a.26.26 0 0 1-.258-.258c0-.066.007-.085.05-.142.047-.061.145-.152.278-.27.265-.236.668-.673.668-1.33 0-.701-.526-1.198-1.098-1.512A4.046 4.046 0 0 0 7 11c-.647 0-1.33.175-1.902.488C4.526 11.802 4 12.298 4 13c0 .657.403 1.094.668 1.33.133.118.23.209.277.27.044.057.05.076.051.14a.263.263 0 0 1-.26.26H1.5a.493.493 0 0 1-.5-.5v-3.236a.263.263 0 0 1 .26-.26c.064.001.083.007.14.05.061.047.152.145.27.278.236.265.673.668 1.33.668.701 0 1.198-.526 1.512-1.098A4.046 4.046 0 0 0 5 9c0-.645-.172-1.33-.484-1.902C4.203 6.525 3.703 6 3 6c-.657 0-1.094.403-1.33.668a2.493 2.493 0 0 1-.27.277c-.057.044-.076.05-.142.051A.26.26 0 0 1 1 6.738V5.5c0-.282.218-.5.5-.5h3.264A1.276 1.276 0 0 0 6 3.764V3.75c0-.299-.117-.57-.258-.756-.14-.186-.293-.306-.41-.41C5.097 2.375 5 2.314 5 2c0-.159.175-.414.578-.635A3.211 3.211 0 0 1 7 1Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/eye.svg b/comm/mail/themes/shared/mail/icons/new/compact/eye.svg
new file mode 100644
index 0000000000..feb2d5b43c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/eye.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M11.5 7A3.5 3.5 0 0 1 8 10.5 3.5 3.5 0 0 1 4.5 7 3.5 3.5 0 0 1 8 3.5 3.5 3.5 0 0 1 11.5 7Z" fill="context-fill"/>
+ <path d="M8 3c-.069 0-.137.002-.205.006-3.657.114-5.73 3.244-6.711 4.716a.5.5 0 0 0 0 .555C2.084 9.777 4.214 13 8 13c3.786 0 5.916-3.223 6.916-4.723a.5.5 0 0 0 0-.555c-.982-1.472-3.054-4.602-6.71-4.716A4.018 4.018 0 0 0 8 3Zm0 1c.052 0 .103.002.154.004h.008A2.993 2.993 0 0 1 11 7c0 1.662-1.337 3-3 3S5 8.662 5 7a2.993 2.993 0 0 1 2.838-2.996h.008A3.06 3.06 0 0 1 8 4Zm0 1a2 2 0 0 0-2 2 2 2 0 0 0 2 2 2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm-3.672.414A3.972 3.972 0 0 0 4 7c0 2.203 1.797 4 4 4 2.203 0 4-1.797 4-4 0-.563-.117-1.1-.328-1.586.96.81 1.666 1.814 2.185 2.586-1.034 1.537-2.808 4-5.857 4-3.05 0-4.823-2.463-5.857-4 .519-.772 1.225-1.777 2.185-2.586Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/features.svg b/comm/mail/themes/shared/mail/icons/new/compact/features.svg
new file mode 100644
index 0000000000..0c10ff7a14
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/features.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5c-.337 0-.572 1.45-.81 1.69-.24.238-1.69.473-1.69.81 0 .337 1.45.572 1.69.81.238.24.473 1.69.81 1.69.337 0 .572-1.45.81-1.69.24-.238 1.69-.473 1.69-.81 0-.337-1.45-.572-1.69-.81C4.573 2.95 4.338 1.5 4 1.5Zm6 .977c-.76 0-1.096 3.353-1.633 3.89-.537.537-3.89.874-3.89 1.633 0 .76 3.353 1.096 3.89 1.633.537.537.874 3.89 1.633 3.89.76 0 1.096-3.353 1.633-3.89.537-.537 3.89-.874 3.89-1.633 0-.76-3.353-1.096-3.89-1.633-.537-.537-.874-3.89-1.633-3.89ZM4 9.5c-.337 0-.572 1.45-.81 1.69-.24.238-1.69.473-1.69.81 0 .337 1.45.572 1.69.81.238.24.473 1.69.81 1.69.337 0 .572-1.45.81-1.69.24-.238 1.69-.473 1.69-.81 0-.337-1.45-.572-1.69-.81-.238-.24-.473-1.69-.81-1.69Z" fill="context-fill"/>
+ <path d="M4 1a.678.678 0 0 0-.48.213c-.094.096-.148.19-.198.285-.1.19-.17.395-.238.6-.068.204-.131.406-.186.558-.054.152-.153.27-.062.18.09-.09-.028.008-.18.062-.152.055-.354.118-.558.186a4.05 4.05 0 0 0-.6.238c-.095.05-.189.104-.285.198A.678.678 0 0 0 1 4c0 .221.117.387.213.48.096.094.19.148.285.198.19.1.395.17.6.238.204.068.406.131.558.186.152.054.27.153.18.062-.09-.09.008.028.062.18.055.152.118.354.186.558.068.205.139.41.238.6.05.095.104.189.198.285A.677.677 0 0 0 4 7a.678.678 0 0 0 .48-.213c.094-.096.148-.19.198-.285.1-.19.17-.395.238-.6.068-.204.131-.406.186-.558.054-.152.153-.27.062-.18-.09.09.028-.008.18-.062.152-.055.354-.118.558-.186a4.05 4.05 0 0 0 .6-.238c.095-.05.189-.104.285-.198A.678.678 0 0 0 7 4a.678.678 0 0 0-.213-.48 1.127 1.127 0 0 0-.285-.198c-.19-.1-.395-.17-.6-.238-.204-.068-.406-.131-.558-.186-.152-.054-.27-.153-.18-.062.09.09-.008-.028-.062-.18-.055-.152-.118-.354-.186-.558a4.021 4.021 0 0 0-.238-.6 1.127 1.127 0 0 0-.198-.285A.678.678 0 0 0 4 1Zm6 .977c-.279 0-.5.158-.639.312a1.956 1.956 0 0 0-.316.514c-.17.376-.303.826-.428 1.287-.125.46-.24.927-.353 1.297-.113.37-.282.658-.25.627.031-.032-.258.137-.627.25-.37.112-.837.228-1.297.353-.46.125-.911.257-1.287.428a1.94 1.94 0 0 0-.514.316c-.154.138-.312.36-.312.639s.158.5.312.639c.155.138.326.23.514.316.376.17.826.303 1.287.428.46.125.927.24 1.297.353.37.113.658.282.627.25-.032-.031.137.258.25.627.112.37.228.837.353 1.297.125.46.257.911.428 1.287.085.188.178.36.316.514.138.154.36.312.639.312s.5-.158.639-.312c.138-.155.23-.326.316-.514.17-.376.303-.826.428-1.287.125-.46.24-.927.353-1.297.113-.37.282-.658.25-.627-.031.032.258-.137.627-.25.37-.112.837-.228 1.297-.353.46-.125.911-.257 1.287-.428a1.94 1.94 0 0 0 .514-.316c.154-.138.312-.36.312-.639s-.158-.5-.312-.639a1.956 1.956 0 0 0-.514-.316c-.376-.17-.826-.303-1.287-.428-.46-.125-.927-.24-1.297-.353-.37-.113-.658-.282-.627-.25.032.031-.137-.258-.25-.627-.112-.37-.228-.837-.353-1.297-.125-.46-.257-.911-.428-1.287a1.956 1.956 0 0 0-.316-.514c-.138-.154-.36-.312-.639-.312Zm-6 .53c.053.162.104.33.16.487.067.187.087.34.297.549.21.21.362.23.549.297.157.056.325.107.486.16-.161.053-.33.104-.486.16-.187.067-.34.087-.549.297-.21.21-.23.362-.297.549-.056.157-.107.325-.16.486-.053-.161-.104-.33-.16-.486-.067-.187-.087-.34-.297-.549-.21-.21-.362-.23-.549-.297-.157-.056-.325-.107-.486-.16.161-.053.33-.104.486-.16.187-.067.34-.087.549-.297.21-.21.23-.362.297-.549.056-.157.107-.325.16-.486Zm6 .632c.017.032.027.038.045.078.123.27.252.69.373 1.135.121.445.236.916.361 1.326.125.41.2.743.5 1.043.3.3.633.375 1.043.5.41.125.881.24 1.326.361.446.121.865.25 1.135.373.04.018.046.028.078.045-.032.017-.038.027-.078.045-.27.123-.69.252-1.135.373-.445.121-.916.236-1.326.361-.41.125-.743.2-1.043.5-.3.3-.375.633-.5 1.043-.125.41-.24.881-.361 1.326-.121.446-.25.866-.373 1.135-.018.04-.028.046-.045.078-.017-.032-.027-.038-.045-.078-.123-.27-.252-.69-.373-1.135-.121-.445-.236-.916-.361-1.326-.125-.41-.2-.743-.5-1.043-.3-.3-.633-.375-1.043-.5-.41-.125-.881-.24-1.326-.361-.446-.121-.866-.25-1.135-.373-.04-.018-.046-.028-.078-.045.032-.017.038-.027.078-.045.27-.123.69-.252 1.135-.373.445-.121.916-.236 1.326-.361.41-.125.743-.2 1.043-.5.3-.3.375-.633.5-1.043.125-.41.24-.881.361-1.326.121-.446.25-.866.373-1.135.018-.04.028-.046.045-.078ZM4 9a.678.678 0 0 0-.48.213c-.094.096-.148.19-.198.285-.1.19-.17.395-.238.6-.068.204-.131.406-.186.558-.054.152-.153.27-.062.18.09-.09-.028.008-.18.062-.152.055-.354.118-.558.186a4.02 4.02 0 0 0-.6.238c-.095.05-.189.104-.285.198A.678.678 0 0 0 1 12c0 .221.117.387.213.48.096.094.19.148.285.198.19.1.395.17.6.238.204.068.406.131.558.186.152.054.27.153.18.062-.09-.09.008.028.062.18.055.152.118.354.186.558.068.205.139.41.238.6.05.095.104.189.198.285A.677.677 0 0 0 4 15a.678.678 0 0 0 .48-.213c.094-.096.148-.19.198-.285.1-.19.17-.395.238-.6.068-.204.131-.406.186-.558.054-.152.153-.27.062-.18-.09.09.028-.008.18-.062.152-.055.354-.118.558-.186a4.02 4.02 0 0 0 .6-.238c.095-.05.189-.104.285-.198A.678.678 0 0 0 7 12a.678.678 0 0 0-.213-.48 1.127 1.127 0 0 0-.285-.198c-.19-.1-.395-.17-.6-.238-.204-.068-.406-.131-.558-.186-.152-.054-.27-.153-.18-.062.09.09-.008-.028-.062-.18-.055-.152-.118-.354-.186-.558a4.021 4.021 0 0 0-.238-.6 1.127 1.127 0 0 0-.198-.285A.678.678 0 0 0 4 9Zm0 1.508c.053.161.104.33.16.486.067.187.087.34.297.549.21.21.362.23.549.297.157.056.325.107.486.16-.161.053-.33.104-.486.16-.187.067-.34.087-.549.297-.21.21-.23.362-.297.549-.056.157-.107.325-.16.486-.053-.161-.104-.33-.16-.486-.067-.187-.087-.34-.297-.549-.21-.21-.362-.23-.549-.297-.157-.056-.325-.107-.486-.16.161-.053.33-.104.486-.16.187-.067.34-.087.549-.297.21-.21.23-.362.297-.549.056-.157.107-.325.16-.486Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/file.svg b/comm/mail/themes/shared/mail/icons/new/compact/file.svg
new file mode 100644
index 0000000000..31b266c291
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/file.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5h8c.831 0 1.5.669 1.5 1.5v10c0 .83-.669 1.5-1.5 1.5H4c-.831 0-1.5-.67-1.5-1.5V3c0-.831.669-1.5 1.5-1.5Z" fill="context-fill"/>
+ <path d="M4 1c-1.1 0-2 .9-2 2v10c0 1.099.9 2 2 2h8c1.1 0 2-.901 2-2V3c0-1.1-.9-2-2-2Zm0 1h8c.563 0 1 .437 1 1v10c0 .562-.437 1-1 1H4c-.563 0-1-.438-1-1V3c0-.563.437-1 1-1Zm1.5 2a.5.5 0 0 0 0 1h5a.5.5 0 0 0 .428-.252A.5.5 0 0 0 10.5 4Zm0 2a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h5a.5.5 0 0 0 .428-.252.5.5 0 0 0 0-.496A.5.5 0 0 0 10.5 6Zm0 2a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h5a.5.5 0 0 0 .428-.252.5.5 0 0 0 0-.496A.5.5 0 0 0 10.5 8Zm0 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 .428-.748A.5.5 0 0 0 8.5 10Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/filter.svg b/comm/mail/themes/shared/mail/icons/new/compact/filter.svg
new file mode 100644
index 0000000000..ad90c8dfb2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/filter.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3.5 3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5Zm4 0a.5.5 0 0 0-.5.5v6.59a1.51 1.51 0 0 0-1 1.416 1.507 1.507 0 0 0 1.5 1.496c.823 0 1.5-.678 1.5-1.5V11.5a1.512 1.512 0 0 0-1-1.41V3.5a.5.5 0 0 0-.5-.5Zm4 0a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5ZM3.496 6.002A1.508 1.508 0 0 0 2 7.506a1.506 1.506 0 0 0 1 1.406V12.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V8.912c.58-.208 1-.763 1-1.41V7.5a1.51 1.51 0 0 0-1.504-1.498Zm.002 1a.494.494 0 0 1 .502.5.491.491 0 0 1-.488.498.5.5 0 0 0-.012 0 .5.5 0 0 0-.006.002A.492.492 0 0 1 3 7.504c0-.283.216-.502.498-.502Zm7.998 0A1.508 1.508 0 0 0 10 8.506a1.506 1.506 0 0 0 1 1.406V12.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V9.912c.58-.208 1-.763 1-1.41V8.5a1.51 1.51 0 0 0-1.504-1.498Zm.002 1c.282-.001.5.216.502.498v.002a.491.491 0 0 1-.488.498.5.5 0 0 0-.012 0 .5.5 0 0 0-.006.002.492.492 0 0 1-.494-.498c0-.283.216-.502.498-.502zm-4 3a.494.494 0 0 1 .502.5c0 .282-.218.5-.5.5a.492.492 0 0 1-.5-.498c0-.283.216-.502.498-.502z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/fingerprint.svg b/comm/mail/themes/shared/mail/icons/new/compact/fingerprint.svg
new file mode 100644
index 0000000000..786681a146
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/fingerprint.svg
@@ -0,0 +1,6 @@
+<!-- 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" xml:space="preserve" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8 1c-1.958 0-3.606.883-4.855 2.148a.5.5 0 0 0 .003.707.5.5 0 0 0 .707-.003C4.963 2.73 6.34 2 8 2c1.19 0 2.296.345 3.229.941a.5.5 0 0 0 .69-.152.5.5 0 0 0-.151-.69A6.976 6.976 0 0 0 8 1Zm0 2C5.245 3 3 5.245 3 8a.5.5 0 0 0 .5.5A.5.5 0 0 0 4 8c0-2.215 1.785-4 4-4s3.982 1.784 4 4.004c.011 1.366-.064 3.941-.916 5.219a.5.5 0 0 0 .139.693.5.5 0 0 0 .693-.139c1.148-1.722 1.095-4.399 1.084-5.78C12.978 5.245 10.756 3 8 3Zm5.393 1.012a.5.5 0 0 0-.182.07.5.5 0 0 0-.152.691 5.962 5.962 0 0 1 .921 3.717.5.5 0 0 0 .457.54.5.5 0 0 0 .54-.46 6.972 6.972 0 0 0-1.074-4.336.5.5 0 0 0-.315-.218.5.5 0 0 0-.195-.004Zm-11.391.98a.5.5 0 0 0-.463.309 7.022 7.022 0 0 0-.354 4.303.5.5 0 0 0 .6.373.5.5 0 0 0 .373-.602 6.017 6.017 0 0 1 .305-3.69.5.5 0 0 0-.27-.654.5.5 0 0 0-.191-.039ZM8 5C6.35 5 5 6.35 5 8c0 .508.122 1.314.064 2.145-.057.83-.285 1.568-.841 1.939a.5.5 0 0 0-.139.693.5.5 0 0 0 .693.139c.944-.629 1.216-1.736 1.284-2.703C6.128 9.246 6 8.302 6 8c0-1.11.89-2 2-2 .63 0 1.158.124 1.484.35.327.225.516.524.516 1.15a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5c0-.874-.369-1.575-.947-1.975C9.474 5.125 8.75 5 8 5Zm0 2.06a.624.624 0 0 0-.121.018c-.315.079-.515.294-.656.541A1.768 1.768 0 0 0 7 8.5v3.338c-.459.84-1.006 1.665-1.8 2.262a.5.5 0 0 0-.1.7.5.5 0 0 0 .7.1c.914-.685 1.524-1.605 2.044-2.543a.5.5 0 0 0 .015-.02C8.555 11.076 9 9.74 9 8.5c0-.43-.059-.763-.234-1.041A.908.908 0 0 0 8 7.061ZM3.5 9a.5.5 0 0 0-.5.5c0 .833-.354 1.146-.854 1.646a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0C3.354 11.354 4 10.667 4 9.5a.5.5 0 0 0-.5-.5Zm7.006 0a.5.5 0 0 0-.506.494c-.017 1.495-.547 3.032-1.889 4.692a.5.5 0 0 0 .075.703.5.5 0 0 0 .703-.075c1.452-1.797 2.092-3.58 2.111-5.308A.5.5 0 0 0 10.506 9Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/flexible-space.svg b/comm/mail/themes/shared/mail/icons/new/compact/flexible-space.svg
new file mode 100644
index 0000000000..ab614511f9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/flexible-space.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" width="16" height="16" fill-opacity="context-fill-opacity">
+ <path d="M4.5 4a.5.5 0 0 0-.354.146l-3 3a.5.5 0 0 0 0 .708l3 3a.5.5 0 1 0 .708-.708L2.207 7.5l2.647-2.646A.5.5 0 0 0 4.5 4zm7.04.002a.498.498 0 0 0-.394.852L13.793 7.5l-2.647 2.646a.5.5 0 1 0 .708.708l3-3a.5.5 0 0 0 0-.708l-3-3a.498.498 0 0 0-.315-.144zM4.5 7a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1zm3 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-1zm3 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/folder-filter.svg b/comm/mail/themes/shared/mail/icons/new/compact/folder-filter.svg
new file mode 100644
index 0000000000..420ad3d575
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/folder-filter.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M2 3.5c-.277 0-.5.223-.5.5v8c0 .831.669 1.5 1.5 1.5h5.635a3.5 3.5 0 0 1-.635-2A3.5 3.5 0 0 1 11.5 8c1.229 0 2.4.5 3 1.5V8c0-1-.82-1.5-1.5-1.5H8.5l-2-3H3Z" fill="context-fill"/>
+ <path d="M2 3c-.545 0-1 .455-1 1v8c0 1.1.9 2 2 2h5.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H3c-.563 0-1-.437-1-1V6h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H2V4h4.232l1.852 2.777A.5.5 0 0 0 8.5 7H13c.218 0 .487.09.678.254.19.165.322.383.322.746v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V6c0-1.1-.9-2-2-2H7.5a.5.5 0 0 0-.06.01l-.524-.787A.5.5 0 0 0 6.5 3Zm6.102 2H13c.563 0 1 .437 1 1v.264A2.045 2.045 0 0 0 13 6H8.768ZM11.5 9A2.508 2.508 0 0 0 9 11.5c0 1.375 1.125 2.5 2.5 2.5a2.48 2.48 0 0 0 1.377-.416l1.27 1.27a.5.5 0 0 0 .707 0 .5.5 0 0 0 0-.708l-1.27-1.269A2.48 2.48 0 0 0 14 11.5c0-1.375-1.125-2.5-2.5-2.5Zm0 1c.834 0 1.5.666 1.5 1.5s-.666 1.5-1.5 1.5-1.5-.666-1.5-1.5.666-1.5 1.5-1.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/folder-rss.svg b/comm/mail/themes/shared/mail/icons/new/compact/folder-rss.svg
new file mode 100644
index 0000000000..3af238d044
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/folder-rss.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M2 3.5c-.277 0-.5.223-.5.5v8c0 .831.669 1.5 1.5 1.5h5v-4C8 8.669 8.669 8 9.5 8h2.611c.725 0 1.383.046 1.889.5h.5V8c0-1-.82-1.5-1.5-1.5H8.5l-2-3H3Z" fill="context-fill"/>
+ <path d="M2 3c-.545 0-1 .455-1 1v8c0 1.1.9 2 2 2h4.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H3c-.563 0-1-.437-1-1V6h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H2V4h4.232l1.852 2.777A.5.5 0 0 0 8.5 7H13c.218 0 .487.09.678.254.19.165.322.383.322.746v.5a.5.5 0 0 0 .059.219.5.5 0 0 0 .023.047.5.5 0 0 0 .152.152.5.5 0 0 0 .047.023A.5.5 0 0 0 14.5 9a.5.5 0 0 0 .219-.059.5.5 0 0 0 .047-.023.5.5 0 0 0 .152-.152.5.5 0 0 0 .023-.047A.5.5 0 0 0 15 8.5V6c0-1.1-.9-2-2-2H7.5a.5.5 0 0 0-.06.01l-.524-.787A.5.5 0 0 0 6.5 3Zm6.102 2H13c.563 0 1 .437 1 1v.264A2.045 2.045 0 0 0 13 6H8.768ZM9.5 9a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h.5a4 4 0 0 1 4 4v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.001.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002V14a5 5 0 0 0-5-5h-.475A.5.5 0 0 0 9.5 9Zm0 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h.5a2 2 0 0 1 2 2v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002.5.5 0 0 0 0-.001.5.5 0 0 0 0-.002.5.5 0 0 0 0-.002V14a3 3 0 0 0-3-3h-.475a.5.5 0 0 0-.025 0Zm.5 2a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/folder-save.svg b/comm/mail/themes/shared/mail/icons/new/compact/folder-save.svg
new file mode 100644
index 0000000000..a0a3412e91
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/folder-save.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M2 3.5c-.277 0-.5.223-.5.5v8c0 .831.669 1.5 1.5 1.5h6.379l-.94-.94a1.515 1.515 0 0 1 0-2.12 1.515 1.515 0 0 1 2.122 0l.439.439V8.5c0-.817.683-1.5 1.5-1.5s1.5.683 1.5 1.5v2.379l.44-.44c.019-.019.04-.036.06-.054V8c0-1-.82-1.5-1.5-1.5H8.5l-2-3z" fill="context-fill"/>
+ <path d="M2 3c-.545 0-1 .455-1 1v8c0 1.1.9 2 2 2h6.879l-1-1H3c-.563 0-1-.437-1-1V6h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H2V4h4.232l1.852 2.777A.5.5 0 0 0 8.5 7H13c.218 0 .487.09.678.254.19.165.322.383.322.746v2.879l.44-.44c.161-.161.354-.278.56-.35V6c0-1.1-.9-2-2-2H7.5a.5.5 0 0 0-.059.012l-.525-.79A.5.5 0 0 0 6.5 3Zm6.102 2H13c.563 0 1 .437 1 1v.264A2.045 2.045 0 0 0 13 6H8.768ZM12.5 8a.5.5 0 0 0-.5.5v4.793l-2.146-2.147a.5.5 0 0 0-.708.708l3 3a.507.507 0 0 0 .036.025.5.5 0 0 0 .132.08.448.448 0 0 0 .15.035L12.5 15a.213.213 0 0 0 .035-.006.53.53 0 0 0 .15-.035.496.496 0 0 0 .133-.08.507.507 0 0 0 .036-.025l3-3a.5.5 0 0 0-.708-.708L13 13.293V8.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/folder.svg b/comm/mail/themes/shared/mail/icons/new/compact/folder.svg
new file mode 100644
index 0000000000..26f1f73397
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/folder.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M2 3.5c-.277 0-.5.223-.5.5v8c0 .831.669 1.5 1.5 1.5h10c.831 0 1.5-.669 1.5-1.5V8c0-1-.82-1.5-1.5-1.5H8.5l-2-3H3Z" fill="context-fill"/>
+ <path d="M2 3c-.545 0-1 .455-1 1v8c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H7.5a.5.5 0 0 0-.059.012l-.525-.79A.5.5 0 0 0 6.5 3Zm0 1h4.232l1.852 2.777A.5.5 0 0 0 8.5 7H13c.218 0 .487.09.678.254.19.165.322.383.322.746v4c0 .563-.437 1-1 1H3c-.563 0-1-.437-1-1V6h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H2Zm6.102 1H13c.563 0 1 .437 1 1v.264A2.045 2.045 0 0 0 13 6H8.768Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/font.svg b/comm/mail/themes/shared/mail/icons/new/compact/font.svg
new file mode 100644
index 0000000000..bd19aa2154
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/font.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 16 16" fill-opacity="context-fill-opacity" height="16" width="16">
+ <path d="M4 1.5A2.495 2.495 0 0 0 1.5 4v8c0 1.385 1.115 2.5 2.5 2.5h8c1.385 0 2.5-1.115 2.5-2.5V4c0-1.385-1.115-2.5-2.5-2.5H4zm5.5 3h2v7h-2v-7z" fill="context-fill"/>
+ <path d="M4 1a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3V4a3 3 0 0 0-3-3H4zm0 1h8a2.042 2.042 0 0 1 .402.041h.002a2.002 2.002 0 0 1 1.555 1.555v.002A2.013 2.013 0 0 1 14 4v8a1.997 1.997 0 0 1-2 2H4c-.138 0-.272-.015-.402-.041h-.002A2.002 2.002 0 0 1 2 12V4a2.036 2.036 0 0 1 .041-.402v-.002A2.002 2.002 0 0 1 4 2zm3.5 2a.5.5 0 0 0-.459.303L4.17 11H3.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-.242l.857-2H9v2h-.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H12V4.5a.5.5 0 0 0-.5-.5h-4zm.33 1H9v3H6.545L7.83 5zM10 5h1v6h-1V5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/forward-col.svg b/comm/mail/themes/shared/mail/icons/new/compact/forward-col.svg
new file mode 100644
index 0000000000..b506626163
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/forward-col.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="context-fill" d="M9.5 4a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707L11.293 7H6c-1.1 0-2 .9-2 2v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V9c0-.563.437-1 1-1h5.293l-2.147 2.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 .105-.156.5.5 0 0 0 .004-.006.5.5 0 0 0 .002-.006A.5.5 0 0 0 13 7.5a.5.5 0 0 0-.004-.065.5.5 0 0 0-.004-.015.5.5 0 0 0-.01-.05.5.5 0 0 0-.017-.052.5.5 0 0 0-.002-.01.5.5 0 0 0-.012-.025.5.5 0 0 0-.017-.033.5.5 0 0 0-.01-.016.5.5 0 0 0-.028-.039.5.5 0 0 0-.005-.008.5.5 0 0 0-.037-.04l-3-3A.5.5 0 0 0 9.5 4Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/forward-redirect-col.svg b/comm/mail/themes/shared/mail/icons/new/compact/forward-redirect-col.svg
new file mode 100644
index 0000000000..695d04a4b0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/forward-redirect-col.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="context-stroke" d="M2.5 9a.5.5 0 0 0-.5.5V11c0 1.099.9 2 2 2h4.293l-2.147 2.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 .05-.06.5.5 0 0 0 .07-.139A.5.5 0 0 0 10 12.5a.5.5 0 0 0-.004-.06.5.5 0 0 0-.008-.052.5.5 0 0 0-.002-.007.5.5 0 0 0-.002-.006.5.5 0 0 0-.017-.053.5.5 0 0 0-.016-.04.5.5 0 0 0-.008-.015.5.5 0 0 0-.02-.033.5.5 0 0 0-.01-.018.5.5 0 0 0-.022-.029.5.5 0 0 0-.004-.006.5.5 0 0 0-.033-.035l-.022-.021-2.978-2.979A.5.5 0 0 0 6.5 9a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707L8.293 12H4c-.563 0-1-.438-1-1V9.5a.5.5 0 0 0-.5-.5Zm6.922.006a.5.5 0 0 0-.276.14.5.5 0 0 0 0 .707l2.647 2.647-2.647 2.646a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 0-.707l-3-3a.5.5 0 0 0-.432-.14Z"/>
+ <path fill="context-fill" d="M9.422 1.006a.5.5 0 0 0-.276.14.5.5 0 0 0 0 .707L11.293 4H6c-1.1 0-2 .9-2 2v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V6c0-.563.437-1 1-1h5.293L9.146 7.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 .105-.156.5.5 0 0 0 .004-.006.5.5 0 0 0 .002-.006A.5.5 0 0 0 13 4.5a.5.5 0 0 0-.004-.065.5.5 0 0 0-.004-.015.5.5 0 0 0-.01-.05.5.5 0 0 0-.017-.052.5.5 0 0 0-.002-.01.5.5 0 0 0-.012-.025.5.5 0 0 0-.017-.033.5.5 0 0 0-.01-.016.5.5 0 0 0-.028-.039.5.5 0 0 0-.005-.008.5.5 0 0 0-.037-.04l-3-3a.5.5 0 0 0-.432-.141Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/forward.svg b/comm/mail/themes/shared/mail/icons/new/compact/forward.svg
new file mode 100644
index 0000000000..3525ee6534
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/forward.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8.5 3.5a1 1 0 0 1 .707.293l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L9.086 8.5H5c-.294 0-.5.205-.5.5v1.5a1 1 0 0 1-2 0V9c0-1.368 1.132-2.5 2.5-2.5h4.086L7.793 5.207A1 1 0 0 1 8.5 3.5Z" fill="context-fill"/>
+ <path d="M8.5 3a1.5 1.5 0 0 0-1.06.439 1.508 1.508 0 0 0 0 2.121l.439.44H5C3.356 6 2 7.356 2 9v1.5c0 .822.677 1.5 1.5 1.5S5 11.322 5 10.5V9h2.879l-.44.439a1.508 1.508 0 0 0 0 2.121c.582.582 1.54.582 2.122 0l3-3c.092-.093.173-.197.238-.31h-.002a1.494 1.494 0 0 0 0-1.5h.002a1.504 1.504 0 0 0-.238-.311l-3-3a1.5 1.5 0 0 0-1.061-.44Zm0 1a.5.5 0 0 1 .354.146l3 3c.03.03.056.066.078.104a.5.5 0 0 0 .002 0 .499.499 0 0 1 0 .5.5.5 0 0 0-.002 0 .498.498 0 0 1-.078.103l-3 3c-.2.2-.508.2-.708 0a.493.493 0 0 1 0-.707l1.293-1.293A.5.5 0 0 0 9.086 8H5c-.527 0-1 .472-1 1v1.5c0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5V9c0-1.092.908-2 2-2h4.086a.5.5 0 0 0 .353-.854L8.146 4.853a.493.493 0 0 1 0-.707A.5.5 0 0 1 8.5 4Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/get-mail.svg b/comm/mail/themes/shared/mail/icons/new/compact/get-mail.svg
new file mode 100644
index 0000000000..872135a289
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/get-mail.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3 4c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1h5.879l-.44-.44a1.515 1.515 0 0 1 0-2.12 1.515 1.515 0 0 1 2.122 0l.439.439V8.5c0-.817.683-1.5 1.5-1.5s1.5.683 1.5 1.5V5c0-.554-.446-1-1-1Z" fill="context-fill"/>
+ <path d="M3 3c-1.1 0-2 .9-2 2v7c0 1.1.9 2 2 2h6.879l-1-1H3c-.563 0-1-.437-1-1V5c0-.563.437-1 1-1h10c.563 0 1 .437 1 1v5.879l.44-.44c.161-.161.354-.276.56-.347V5c0-1.1-.9-2-2-2Zm.47 2.002a.5.5 0 0 0-.343.166.5.5 0 0 0 .041.705l2.781 2.47-2.803 2.803a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0L6.697 9.01l.971.863a.5.5 0 0 0 .664 0l.97-.863L11 10.707V9.293l-.95-.95 2.782-2.47a.5.5 0 0 0 .041-.705.5.5 0 0 0-.705-.041L8 8.832 3.832 5.127a.5.5 0 0 0-.361-.125ZM12.5 8a.5.5 0 0 0-.5.5v4.793l-2.146-2.147a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .036.025.5.5 0 0 0 .058.04.5.5 0 0 0 .074.04.5.5 0 0 0 .065.021.5.5 0 0 0 .086.014.5.5 0 0 0 .035.006.5.5 0 0 0 .035-.006.5.5 0 0 0 .086-.014.5.5 0 0 0 .065-.021.5.5 0 0 0 .074-.04.5.5 0 0 0 .058-.04.5.5 0 0 0 .036-.025l3-3a.5.5 0 0 0 0-.708.5.5 0 0 0-.708 0L13 13.293V8.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/globe-secure.svg b/comm/mail/themes/shared/mail/icons/new/compact/globe-secure.svg
new file mode 100644
index 0000000000..0201c7362a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/globe-secure.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M7.5.5a7 7 0 0 0-7 7 7 7 0 0 0 7 7 7 7 0 0 0 .695-.035A2.466 2.466 0 0 1 8 13.5v-2c0-.78.434-1.393 1-1.852V9.5C9 7.58 10.58 6 12.5 6c.714 0 1.38.22 1.936.592A7 7 0 0 0 7.5.5Zm3 10c-.554 0-1 .446-1 1v2c0 .554.446 1 1 1h4c.554 0 1-.446 1-1v-2c0-.554-.446-1-1-1z" fill="context-fill"/>
+ <path d="M7.5 0C3.818 0 .749 2.667.12 6.17l-.022.129a7.517 7.517 0 0 0 0 2.404l.021.127C.75 12.333 3.818 15 7.5 15c.326 0 .647-.022.963-.063-.3-.42-.462-.922-.463-1.437v-.139a5.56 5.56 0 0 1-.5.502 6.391 6.391 0 0 1-1.084-1.265 7.947 7.947 0 0 1-.844-1.674c.582.048 1.22.076 1.928.076.197 0 .378-.008.564-.012.102-.388.317-.725.59-1.015-1.13.055-2.264.027-3.39-.084A10.487 10.487 0 0 1 5 7.5c0-.9.101-1.695.264-2.39C5.913 5.04 6.652 5 7.5 5c.848 0 1.587.04 2.236.11.136.582.226 1.238.252 1.962.276-.285.601-.523.96-.703-.037-.389-.09-.76-.159-1.11 1.024.185 1.744.441 2.238.688.1.05.186.099.268.147.65.153 1.233.488 1.69.949a7.638 7.638 0 0 0-.083-.744l-.021-.129C14.25 2.667 11.182 0 7.5 0Zm0 1.137c.257.225.661.631 1.084 1.265.298.447.597 1.002.844 1.674A23.127 23.127 0 0 0 7.5 4c-.708 0-1.346.028-1.928.076.214-.59.498-1.151.844-1.674.305-.467.67-.892 1.084-1.265Zm-1.393.013a7.97 7.97 0 0 0-.523.698 9.031 9.031 0 0 0-1.107 2.353c-1.335.198-2.282.518-2.95.852-.022.01-.042.022-.064.033A6.494 6.494 0 0 1 6.107 1.15Zm2.786 0a6.494 6.494 0 0 1 4.644 3.936l-.064-.033c-.668-.334-1.615-.654-2.95-.852a9.066 9.066 0 0 0-1.107-2.353 7.853 7.853 0 0 0-.523-.698ZM4.21 5.26C4.079 5.934 4 6.68 4 7.5c0 .82.079 1.566.21 2.24-1.023-.184-1.743-.44-2.237-.687-.456-.228-.721-.446-.87-.594-.021-.022-.022-.026-.039-.043a6.573 6.573 0 0 1 0-1.834l.04-.041c.148-.148.413-.366.869-.594.494-.247 1.214-.503 2.238-.687ZM12.5 7A2.506 2.506 0 0 0 10 9.5v.59c-.58.208-1 .763-1 1.41v2c0 .822.678 1.5 1.5 1.5h4c.822 0 1.5-.678 1.5-1.5v-2c0-.647-.42-1.202-1-1.41V9.5C15 8.124 13.876 7 12.5 7Zm0 1c.84 0 1.5.66 1.5 1.5v.5h-3v-.5c0-.84.66-1.5 1.5-1.5ZM1.463 9.912l.064.035c.668.334 1.615.654 2.95.852a9.066 9.066 0 0 0 1.107 2.353c.177.265.354.494.525.698a6.496 6.496 0 0 1-4.646-3.938ZM10.5 11h4c.286 0 .5.214.5.5v2c0 .286-.214.5-.5.5h-4a.488.488 0 0 1-.5-.5v-2c0-.286.214-.5.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/globe.svg b/comm/mail/themes/shared/mail/icons/new/compact/globe.svg
new file mode 100644
index 0000000000..e6649bdef5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/globe.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M14.5 7.5a7 7 0 1 1-14 0 7 7 0 0 1 14 0z" fill="context-fill"/>
+ <path d="M7.5 0C3.818 0 .749 2.667.12 6.17l-.022.129a7.517 7.517 0 0 0 0 2.404l.021.127C.75 12.333 3.818 15 7.5 15c3.682 0 6.751-2.667 7.38-6.17l.022-.127a7.518 7.518 0 0 0 0-2.404l-.021-.129C14.25 2.667 11.182 0 7.5 0Zm0 1.137c.257.225.661.631 1.084 1.265.298.447.597 1.002.844 1.674A23.127 23.127 0 0 0 7.5 4c-.708 0-1.346.028-1.928.076.214-.59.498-1.151.844-1.674.305-.467.67-.892 1.084-1.265Zm-1.393.013a7.97 7.97 0 0 0-.523.698 9.031 9.031 0 0 0-1.107 2.353c-1.335.198-2.282.518-2.95.852-.022.01-.042.022-.064.033A6.494 6.494 0 0 1 6.107 1.15Zm2.786 0a6.494 6.494 0 0 1 4.644 3.936l-.064-.033c-.668-.334-1.615-.654-2.95-.852a9.066 9.066 0 0 0-1.107-2.353 7.853 7.853 0 0 0-.523-.698ZM7.5 5c.848 0 1.587.04 2.236.11C9.9 5.804 10 6.6 10 7.5c0 .9-.101 1.693-.264 2.389A21.13 21.13 0 0 1 7.5 10a21.13 21.13 0 0 1-2.236-.111A10.47 10.47 0 0 1 5 7.5c0-.9.101-1.695.264-2.39C5.913 5.04 6.652 5 7.5 5Zm-3.29.26C4.08 5.934 4 6.68 4 7.5c0 .82.079 1.566.21 2.24-1.023-.184-1.743-.44-2.237-.687-.456-.228-.721-.446-.87-.594-.021-.022-.022-.026-.039-.043a6.569 6.569 0 0 1 0-1.834l.04-.041c.148-.148.413-.366.869-.594.494-.247 1.214-.503 2.238-.687Zm6.58 0c1.023.184 1.743.44 2.237.687.456.228.721.446.87.594l.039.041a6.59 6.59 0 0 1 0 1.834c-.017.017-.018.021-.04.043-.148.148-.413.366-.869.594-.494.247-1.214.503-2.238.687.132-.674.211-1.42.211-2.24 0-.82-.079-1.566-.21-2.24ZM1.462 9.914l.064.033c.668.334 1.615.654 2.95.852a9.077 9.077 0 0 0 1.107 2.353c.177.265.354.494.525.698a6.495 6.495 0 0 1-4.646-3.936Zm12.074 0A6.495 6.495 0 0 1 8.89 13.85c.171-.204.348-.433.525-.698.48-.727.853-1.52 1.107-2.353 1.335-.198 2.282-.518 2.95-.852.022-.01.042-.022.064-.033zm-7.965 1.01c.582.048 1.22.076 1.928.076s1.346-.028 1.928-.076a7.947 7.947 0 0 1-.844 1.674A6.391 6.391 0 0 1 7.5 13.863a6.391 6.391 0 0 1-1.084-1.265 7.947 7.947 0 0 1-.844-1.674Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/handshake.svg b/comm/mail/themes/shared/mail/icons/new/compact/handshake.svg
new file mode 100644
index 0000000000..5fd165bed8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/handshake.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M11.148 2.5c-.854 0-1.708.558-2.363 1.213l-.805.708 1.475 1.284c.47.41.44 1.15 0 1.59-.44.44-1.151.44-1.592 0L6.561 5.992C5.16 7.457 4.665 8.02 3.5 9.25c1.621 1.58 2.1 2.149 3.63 3.816.217.217.541.434.866.434.325 0 .65-.217.867-.434 1.5-1.695 3.05-3.139 4.65-4.738.656-.654.987-1.412.987-2.328 0-.916-.331-1.863-.986-2.518a3.337 3.337 0 0 0-2.366-.982Z" fill="context-fill"/>
+ <path d="M4.844 2c-.983 0-1.97.38-2.719 1.129C1.358 3.896 1 4.966 1 6s.392 1.95 1.125 2.682a.48.48 0 0 0 .004.004c1.619 1.578 3.111 3.061 4.633 4.718l.008.008.007.008c.035.034.071.069.11.103.038.035.077.07.119.104h.002c.042.033.085.067.13.098a1.81 1.81 0 0 0 .475.222 1.406 1.406 0 0 0 .766 0c.356-.098.631-.32.838-.527l.012-.01.01-.012a46.757 46.757 0 0 1 1.69-1.804c.955-.97 1.933-1.908 2.938-2.912a4.253 4.253 0 0 0 .68-.897A3.633 3.633 0 0 0 15 6a4.39 4.39 0 0 0-.156-1.162 3.852 3.852 0 0 0-.977-1.71 3.871 3.871 0 0 0-2.35-1.11h-.001A3.844 3.844 0 0 0 11.148 2c-.067 0-.133.003-.199.008a3.178 3.178 0 0 0-1.295.406 5.166 5.166 0 0 0-.65.434 6.51 6.51 0 0 0-.572.511l-.463.407-.407-.407C6.854 2.651 5.917 2 4.845 2Zm0 1c.637 0 1.41.465 2.011 1.066.337.314.687.643.999.909l1.273 1.107c.237.206.225.609-.025.86a.614.614 0 0 1-.885 0L6.553 5.276l-.354.37c-1.252 1.31-1.827 1.944-2.754 2.93-.204-.201-.405-.402-.613-.604C2.255 7.396 2 6.798 2 6s.29-1.622.832-2.164A2.83 2.83 0 0 1 4.844 3Zm6.304 0a2.83 2.83 0 0 1 2.204 1.053c.12.152.224.318.312.496v.002c.219.444.336.952.336 1.449 0 .696-.201 1.241-.639 1.754v.002a3.98 3.98 0 0 1-.2.217c-.793.792-1.577 1.55-2.352 2.324a60.45 60.45 0 0 0-1.73 1.787c-.191.206-.38.415-.57.629-.117.118-.29.22-.411.264a.33.33 0 0 1-.102.023.33.33 0 0 1-.1-.023h-.001a1.198 1.198 0 0 1-.276-.153h-.002a1.114 1.114 0 0 1-.133-.111l-.234-.256 2.604-2.603a.5.5 0 0 0-.708-.708l-2.574 2.575a96.12 96.12 0 0 0-.754-.81l2.057-2.057a.5.5 0 0 0-.707-.708l-2.045 2.045a71.88 71.88 0 0 0-.936-.943c.814-.865 1.442-1.543 2.385-2.537l.938.937c.63.631 1.668.631 2.299 0 .63-.63.677-1.708-.026-2.32l-1.02-.887.364-.363.012-.012c.45-.45.998-.825 1.511-.984A1.9 1.9 0 0 1 11.148 3Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/heart.svg b/comm/mail/themes/shared/mail/icons/new/compact/heart.svg
new file mode 100644
index 0000000000..bc89914c4d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/heart.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M13.514 8.327c.655-.654.986-1.41.986-2.327 0-.916-.331-1.864-.986-2.518a3.335 3.335 0 0 0-2.365-.982c-.854 0-1.71.558-2.364 1.212l-.789.788-.788-.788C6.553 3.058 5.698 2.5 4.844 2.5c-.855 0-1.71.327-2.365.982C1.824 4.136 1.5 5.084 1.5 6c0 .916.324 1.673.979 2.327C4.1 9.908 5.599 11.4 7.13 13.067c.217.216.542.433.866.433.325 0 .65-.216.867-.433 1.5-1.696 3.05-3.14 4.65-4.74Z" fill="context-fill"/>
+ <path d="M4.844 2c-.983 0-1.97.38-2.719 1.129C1.358 3.896 1 4.966 1 6s.392 1.95 1.125 2.682a.5.5 0 0 0 .004.004c1.619 1.578 3.111 3.061 4.633 4.718a.5.5 0 0 0 .015.016c.276.275.672.58 1.22.58.546 0 .944-.305 1.22-.58a.5.5 0 0 0 .02-.022c1.48-1.673 3.022-3.11 4.63-4.716C14.6 7.95 15 7.037 15 6s-.367-2.106-1.133-2.871A3.843 3.843 0 0 0 11.148 2c-1.072 0-2.008.651-2.716 1.36l-.436.433-.434-.434C6.854 2.651 5.917 2 4.845 2Zm0 1c.637 0 1.41.465 2.011 1.066l.788.788a.5.5 0 0 0 .707 0l.789-.788C9.74 3.466 10.51 3 11.149 3a2.83 2.83 0 0 1 2.011.836C13.704 4.38 14 5.204 14 6s-.262 1.395-.84 1.973c-1.585 1.584-3.137 3.032-4.65 4.74-.158.157-.411.287-.514.287-.103 0-.354-.13-.512-.287-1.534-1.67-3.035-3.164-4.652-4.74C2.255 7.396 2 6.798 2 6s.29-1.622.832-2.164A2.83 2.83 0 0 1 4.844 3Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/hidden.svg b/comm/mail/themes/shared/mail/icons/new/compact/hidden.svg
new file mode 100644
index 0000000000..bc82ddca41
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/hidden.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8 3.5A3.5 3.5 0 0 0 4.5 7a3.5 3.5 0 0 0 .316 1.453l3.903-4.88A3.5 3.5 0 0 0 8 3.5Zm3.107 1.892-3.99 4.989A3.5 3.5 0 0 0 8 10.5 3.5 3.5 0 0 0 11.5 7a3.5 3.5 0 0 0-.393-1.608Z" fill="context-fill"/>
+ <path d="M11.445 2.002a.5.5 0 0 0-.336.185l-8 10a.5.5 0 0 0 .079.703.5.5 0 0 0 .703-.078l8-10a.5.5 0 0 0-.079-.703.5.5 0 0 0-.367-.107ZM8 3c-.069 0-.137.002-.205.006-3.657.114-5.73 3.244-6.711 4.716a.5.5 0 0 0 0 .555c.422.633 1.05 1.572 1.914 2.447l.637-.795c-.619-.654-1.107-1.356-1.492-1.93.519-.771 1.225-1.776 2.185-2.585A3.972 3.972 0 0 0 4 7c0 .68.171 1.32.473 1.882l.7-.878a2.993 2.993 0 0 1 2.664-4h.009A3.06 3.06 0 0 1 8 4c.052 0 .103.002.154.004h.008c.067.003.132.011.197.02l.74-.927a6.343 6.343 0 0 0-.894-.091A4.018 4.018 0 0 0 8 3Zm3.943 1.35-1.181 1.476c.152.36.238.756.238 1.174a2.992 2.992 0 0 1-3.537 2.949l-.688.857c.387.125.798.194 1.225.194 2.203 0 4-1.797 4-4 0-.563-.117-1.1-.328-1.586.96.81 1.666 1.814 2.185 2.586-1.034 1.537-2.808 4-5.857 4a5.29 5.29 0 0 1-1.9-.348l-.65.814A6.29 6.29 0 0 0 8 13c3.786 0 5.916-3.223 6.916-4.723a.5.5 0 0 0 0-.555c-.578-.867-1.536-2.31-2.973-3.373Zm-4.416.712a2 2 0 0 0-1.523 1.904Zm2.461 1.73-1.74 2.174A2 2 0 0 0 10 7a2 2 0 0 0-.012-.207Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/id.svg b/comm/mail/themes/shared/mail/icons/new/compact/id.svg
new file mode 100644
index 0000000000..05f1c1040c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/id.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7 2.5c-.277 0-.5.223-.5.5v2c0 .277.223.5.5.5h2c.277 0 .5-.223.5-.5V3c0-.277-.223-.5-.5-.5Zm-3 5c-.277 0-.5.223-.5.5v3c0 .277.223.5.5.5h3c.277 0 .5-.223.5-.5V8c0-.277-.223-.5-.5-.5Z" fill="context-fill"/>
+ <path d="M7 2c-.545 0-1 .454-1 1H3c-1.1 0-2 .9-2 2v7c0 1.099.9 2 2 2h10c1.1 0 2-.901 2-2V5c0-1.1-.9-2-2-2h-3c0-.546-.455-1-1-1Zm0 1h2v2H7ZM3 4h3v1c0 .545.455 1 1 1h2c.545 0 1-.455 1-1V4h3c.563 0 1 .437 1 1v7c0 .562-.437 1-1 1H3c-.563 0-1-.438-1-1V5c0-.563.437-1 1-1Zm1 3c-.545 0-1 .454-1 1v3c0 .545.455 1 1 1h3c.545 0 1-.455 1-1V8c0-.546-.455-1-1-1Zm5.5 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5ZM4 8h3v3H4Zm5.5 1a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm0 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/import.svg b/comm/mail/themes/shared/mail/icons/new/compact/import.svg
new file mode 100644
index 0000000000..e80bc6b44e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/import.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M11.732 4c.17.294.268.635.268 1v6c0 1.108-.892 2-2 2H5a1.99 1.99 0 0 1-1-.268V13c0 .554.446 1 1 1h7c.554 0 1-.446 1-1V5c0-.554-.446-1-1-1Z" fill="context-fill"/>
+ <path d="M3 1c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2 0 1.1.9 2 2 2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2 0-1.1-.9-2-2-2Zm0 1h7c.563 0 1 .437 1 1v8c0 .563-.437 1-1 1H3c-.563 0-1-.437-1-1V3c0-.563.437-1 1-1Zm3.5 1a.5.5 0 0 0-.5.5v2.793L4.854 5.146a.5.5 0 0 0-.708.708l2 2C6.322 8.029 6.5 8 6.5 8s.182.025.354-.146l2-2a.5.5 0 0 0-.708-.708L7 6.293V3.5a.5.5 0 0 0-.5-.5ZM12 4c.563 0 1 .437 1 1v8c0 .563-.437 1-1 1H5c-.563 0-1-.437-1-1h6c1.1 0 2-.9 2-2zM4.5 8a.5.5 0 0 0-.5.5v1c0 .822.678 1.5 1.5 1.5h2c.822 0 1.5-.678 1.5-1.5v-1a.5.5 0 1 0-1 0v1c0 .286-.214.5-.5.5h-2a.488.488 0 0 1-.5-.5v-1a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/inbox.svg b/comm/mail/themes/shared/mail/icons/new/compact/inbox.svg
new file mode 100644
index 0000000000..551edad095
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/inbox.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 2c-1.108 0-2 .892-2 2v8c0 1.108.892 2 2 2h8c1.108 0 2-.892 2-2V4c0-1.108-.892-2-2-2Zm1 3h6v1H5ZM4 7h8v3h-1.17c-.41 1.167-1.52 2-2.83 2s-2.42-.833-2.83-2H4Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.347 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.653-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2v5h-1V7c0-.545-.455-1-1-1V5c0-.545-.455-1-1-1H5c-.545 0-1 .455-1 1v1c-.545 0-1 .455-1 1v2.002c-.342 0-.673 0-1-.002V4c0-1.117.883-2 2-2Zm1 3h6v1H5ZM4 7h8v2h-1.5a.5.5 0 0 0-.5.5c0 .667-.722 1.5-2 1.5s-2-.833-2-1.5a.5.5 0 0 0-.5-.5c-.399 0-.951 0-1.5.002Zm1.244 3c.299 1.084 1.28 2 2.756 2 1.476 0 2.457-.916 2.756-2H14v2c0 1.117-.883 2-2 2H4c-1.117 0-2-.883-2-2v-1.998c1.123.004 2.362-.001 3.244-.002Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/info.svg b/comm/mail/themes/shared/mail/icons/new/compact/info.svg
new file mode 100644
index 0000000000..ebf600cc09
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/info.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M14.5 7.5a7 7 0 0 1-7 7 7 7 0 0 1-7-7 7 7 0 0 1 7-7 7 7 0 0 1 7 7z" fill="context-fill"/>
+ <path d="M7.5 0C3.364 0 0 3.363 0 7.5 0 11.636 3.364 15 7.5 15S15 11.636 15 7.5C15 3.363 11.636 0 7.5 0Zm0 1C11.096 1 14 3.904 14 7.5c0 3.595-2.904 6.5-6.5 6.5A6.492 6.492 0 0 1 1 7.5C1 3.904 3.904 1 7.5 1Zm0 3a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm0 2a.5.5 0 0 0-.5.5v4a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-4a.5.5 0 0 0-.5-.5Zm0-1.5zm0 0z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/kebab.svg b/comm/mail/themes/shared/mail/icons/new/compact/kebab.svg
new file mode 100644
index 0000000000..33552abf5a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/kebab.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M9 4a1 1 0 1 0-1 1h.002a.5.5 0 0 0 .002 0A1.002 1.002 0 0 0 9 4Zm0 4a1 1 0 1 0-1 1h.002a.5.5 0 0 0 .002 0A1.002 1.002 0 0 0 9 8Zm0 4a1 1 0 1 0-1 1h.002a.5.5 0 0 0 .002 0A1.002 1.002 0 0 0 9 12Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/key.svg b/comm/mail/themes/shared/mail/icons/new/compact/key.svg
new file mode 100644
index 0000000000..ad970e8a1d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/key.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M11 1.5A3.5 3.5 0 0 0 7.5 5a3.5 3.5 0 0 0 .246 1.264l-.24.242L7.5 6.5l-5 5v2h2l1-1v-1h1l1-1v-1h1l1-1-.006-.006.242-.24A3.5 3.5 0 0 0 11 8.5 3.5 3.5 0 0 0 14.5 5 3.5 3.5 0 0 0 11 1.5Zm.5 2a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="context-fill"/>
+ <path d="M11 1C8.797 1 7 2.797 7 5a.5.5 0 0 0 0 .004c.003.373.108.731.213 1.088l-.055.054a.5.5 0 0 0-.012 0l-5 5A.5.5 0 0 0 2 11.5v2a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .354-.146l1-1A.5.5 0 0 0 6 12.5V12h.5a.5.5 0 0 0 .354-.146l1-1A.5.5 0 0 0 8 10.5V10h.5a.5.5 0 0 0 .354-.146l1-1a.5.5 0 0 0 .023-.036l.031-.03c.357.104.715.21 1.088.212A.5.5 0 0 0 11 9c2.203 0 4-1.797 4-4 0-2.203-1.797-4-4-4Zm0 1c1.663 0 3 1.337 3 3a2.992 2.992 0 0 1-2.998 3 3.004 3.004 0 0 1-1.082-.21.5.5 0 0 0-.535.108l-.242.24a.5.5 0 0 0-.008.02L8.293 9H7.5a.5.5 0 0 0-.5.5v.793L6.293 11H5.5a.5.5 0 0 0-.5.5v.793L4.293 13H3v-1.293l4.842-4.842a.5.5 0 0 0 .02-.008l.24-.242a.5.5 0 0 0 .109-.535A3.004 3.004 0 0 1 8 5v-.002A2.992 2.992 0 0 1 11 2Zm.5 1c-.823 0-1.5.677-1.5 1.5S10.677 6 11.5 6 13 5.323 13 4.5 12.323 3 11.5 3Zm0 1c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/layout.svg b/comm/mail/themes/shared/mail/icons/new/compact/layout.svg
new file mode 100644
index 0000000000..31e9587719
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/layout.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3 3.5c-.831 0-1.5.669-1.5 1.5v7c0 .83.669 1.5 1.5 1.5h4.5v-10z" fill="context-fill"/>
+ <path d="M3 3c-1.1 0-2 .9-2 2v7c0 1.099.9 2 2 2h10c1.1 0 2-.901 2-2V5c0-1.1-.9-2-2-2Zm0 1h4v9H3c-.563 0-1-.438-1-1V5c0-.563.437-1 1-1Zm5 0h5c.563 0 1 .437 1 1v7c0 .562-.437 1-1 1H8ZM3.5 6a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm0 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm0 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/link.svg b/comm/mail/themes/shared/mail/icons/new/compact/link.svg
new file mode 100644
index 0000000000..5161058abc
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/link.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M11.588 1a1.992 1.992 0 0 0-1.414.582L8.052 3.703a2.006 2.006 0 0 0 0 2.829l.354.355-1.52 1.52-.354-.354a2.007 2.007 0 0 0-2.828 0l-2.122 2.122a2.006 2.006 0 0 0 0 2.827l1.414 1.415a2.008 2.008 0 0 0 2.83 0l2.12-2.122a2.006 2.006 0 0 0 0-2.828l-.353-.354 1.52-1.52.354.353c.777.778 2.05.778 2.828 0l2.121-2.121a2.006 2.006 0 0 0 0-2.829l-1.414-1.414A1.989 1.989 0 0 0 11.588 1zm0 .99c.254 0 .508.1.707.299l1.414 1.414a.988.988 0 0 1 0 1.414L11.588 7.24a.987.987 0 0 1-1.414 0l-.354-.352 1.032-1.033a.5.5 0 0 0 0-.707.5.5 0 0 0-.707 0L9.113 6.18l-.354-.355a.987.987 0 0 1 0-1.415l2.122-2.121a1 1 0 0 1 .707-.298zm-6.47 6.471c.255 0 .509.1.708.299l.353.353-1.033 1.033a.5.5 0 0 0 0 .708.5.5 0 0 0 .707 0L6.886 9.82l.354.354a.987.987 0 0 1 0 1.414L5.119 13.71a.99.99 0 0 1-1.415-.001l-1.415-1.414a.986.986 0 0 1 0-1.415L4.41 8.76a.998.998 0 0 1 .709-.299z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/lock-disabled.svg b/comm/mail/themes/shared/mail/icons/new/compact/lock-disabled.svg
new file mode 100644
index 0000000000..f822e07c76
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/lock-disabled.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 5.5c-.831 0-1.5.669-1.5 1.5v4.379l5.879-5.88H4Zm8.525.095L3.66 14.461c.11.025.223.039.34.039h8c.831 0 1.5-.67 1.5-1.5V7c0-.646-.405-1.192-.975-1.405z" fill="context-fill"/>
+ <path d="M8 0C5.793 0 4 1.792 4 4v1c-1.1 0-2 .9-2 2v4.879l1-1v-3.88c0-.562.437-1 1-1h3.879l1-1H5v-1c0-1.67 1.33-3 3-3 .836 0 1.583.335 2.125.878a.5.5 0 0 0 .707.002.5.5 0 0 0 0-.707A3.995 3.995 0 0 0 8 0Zm4.902 5.218-.789.79A.984.984 0 0 1 13 7v6c0 .562-.437 1-1 1H4.121l-.857.857c.228.091.476.143.736.143h8c1.1 0 2-.901 2-2V7c0-.775-.449-1.45-1.098-1.782ZM8.99 9.131l-1.926 1.925-.084.315a.5.5 0 0 0 .485.629H8.5a.5.5 0 0 0 .484-.621L8.59 9.802c.214-.158.363-.398.4-.671Z" fill="context-stroke"/>
+ <path d="m14.146 1.146-13 13a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l13-13a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0z" fill="#ff0039"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/lock.svg b/comm/mail/themes/shared/mail/icons/new/compact/lock.svg
new file mode 100644
index 0000000000..9c1239d294
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/lock.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 5.5h8c.831 0 1.5.669 1.5 1.5v6c0 .83-.669 1.5-1.5 1.5H4c-.831 0-1.5-.67-1.5-1.5V7c0-.831.669-1.5 1.5-1.5Z" fill="context-fill"/>
+ <path d="M8 0C5.793 0 4 1.792 4 4v1c-1.1 0-2 .9-2 2v6c0 1.099.9 2 2 2h8c1.1 0 2-.901 2-2V7c0-1.1-.9-2-2-2V4c0-2.208-1.793-4-4-4Zm0 1c1.67 0 3 1.329 3 3v1H5V4c0-1.671 1.33-3 3-3ZM4 6h8c.563 0 1 .437 1 1v6c0 .562-.437 1-1 1H4c-.563 0-1-.438-1-1V7c0-.563.437-1 1-1Zm4 2c-.546 0-1 .453-1 1 0 .323.16.613.402.797l-.422 1.574a.5.5 0 0 0 .485.629H8.5a.5.5 0 0 0 .484-.621L8.59 9.802A1 1 0 0 0 9 9c0-.547-.454-1-1-1Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/low-priority.svg b/comm/mail/themes/shared/mail/icons/new/compact/low-priority.svg
new file mode 100644
index 0000000000..59653d7eb3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/low-priority.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" fill-opacity="context-fill-opacity" height="16" width="16">
+ <path d="M7.5 3a.5.5 0 0 0-.5.5v7.793l-1.146-1.147A.5.5 0 0 0 5.5 10a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708l2 2a.5.5 0 0 0 .141.095.5.5 0 0 0 .049.02A.5.5 0 0 0 7.5 13a.5.5 0 0 0 .164-.031.5.5 0 0 0 .049-.02.5.5 0 0 0 .14-.095l2-2a.5.5 0 0 0 0-.708.5.5 0 0 0-.707 0L8 11.293V3.5a.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/mail-secure.svg b/comm/mail/themes/shared/mail/icons/new/compact/mail-secure.svg
new file mode 100644
index 0000000000..479dab310b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/mail-secure.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M3 4c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1h5v-1.5c0-.78.434-1.393 1-1.852V9.5C9 7.58 10.58 6 12.5 6c.536 0 1.044.123 1.5.342V5c0-.554-.446-1-1-1Zm7.5 6.5c-.554 0-1 .446-1 1v2c0 .554.446 1 1 1h4c.554 0 1-.446 1-1v-2c0-.554-.446-1-1-1z" fill="context-fill"/>
+ <path d="M3 3c-1.1 0-2 .9-2 2v7c0 1.1.9 2 2 2h5.05a2.48 2.48 0 0 1-.05-.5V13H3c-.563 0-1-.437-1-1V5c0-.563.437-1 1-1h10c.563 0 1 .437 1 1v1.342c.374.18.713.424 1 .719V5c0-1.1-.9-2-2-2Zm.47 2.002a.5.5 0 0 0-.343.166.5.5 0 0 0 .041.705l2.781 2.47-2.803 2.803a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0L6.697 9.01l.971.863a.5.5 0 0 0 .664 0l.676-.6a3.496 3.496 0 0 1 1.021-2.244L8 8.832 3.832 5.127a.5.5 0 0 0-.361-.125Zm8.928.01a.5.5 0 0 0-.23.115l-1.86 1.654a3.474 3.474 0 0 1 2.376-.777l.148-.131a.5.5 0 0 0 .041-.705.5.5 0 0 0-.475-.156ZM12.5 7A2.506 2.506 0 0 0 10 9.5v.59c-.58.208-1 .763-1 1.41v2c0 .822.678 1.5 1.5 1.5h4c.822 0 1.5-.678 1.5-1.5v-2c0-.647-.42-1.202-1-1.41V9.5C15 8.124 13.876 7 12.5 7Zm0 1c.84 0 1.5.66 1.5 1.5v.5h-3v-.5c0-.84.66-1.5 1.5-1.5Zm-2 3h4c.286 0 .5.214.5.5v2c0 .286-.214.5-.5.5h-4a.488.488 0 0 1-.5-.5v-2c0-.286.214-.5.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/mail.svg b/comm/mail/themes/shared/mail/icons/new/compact/mail.svg
new file mode 100644
index 0000000000..161ced2898
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/mail.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M3 4h10c.554 0 1 .446 1 1v7c0 .554-.446 1-1 1H3c-.554 0-1-.446-1-1V5c0-.554.446-1 1-1Z" fill="context-fill"/>
+ <path d="M3 3c-1.1 0-2 .9-2 2v7c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2Zm0 1h10c.563 0 1 .437 1 1v7c0 .563-.437 1-1 1H3c-.563 0-1-.437-1-1V5c0-.563.437-1 1-1Zm.47 1.002a.5.5 0 0 0-.343.166.5.5 0 0 0 .041.705l2.781 2.47-2.803 2.803a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0L6.697 9.01l.971.863a.5.5 0 0 0 .664 0l.97-.863 2.844 2.844a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.708L10.05 8.344l2.781-2.471a.5.5 0 0 0 .041-.705.5.5 0 0 0-.705-.041L8 8.832 3.832 5.127a.5.5 0 0 0-.361-.125Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/more.svg b/comm/mail/themes/shared/mail/icons/new/compact/more.svg
new file mode 100644
index 0000000000..5ab35ef587
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/more.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 7c-.546 0-1 .453-1 1 0 .546.454 1 1 1s1-.454 1-1v-.002a.5.5 0 0 0 0-.002A1.008 1.008 0 0 0 4 7Zm4 0c-.546 0-1 .453-1 1 0 .546.454 1 1 1s1-.454 1-1v-.002a.5.5 0 0 0 0-.002A1.008 1.008 0 0 0 8 7Zm4 0c-.546 0-1 .453-1 1 0 .546.454 1 1 1s1-.454 1-1v-.002a.5.5 0 0 0 0-.002A1.008 1.008 0 0 0 12 7Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/nav-back.svg b/comm/mail/themes/shared/mail/icons/new/compact/nav-back.svg
new file mode 100644
index 0000000000..450a3b1ba0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/nav-back.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5 3a.5.5 0 0 0-.354.146l-4 4A.5.5 0 0 0 3 7.5a.5.5 0 0 0 .146.353l4 4a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L4.707 8H12.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H4.707l3.147-3.147a.5.5 0 0 0 0-.707A.5.5 0 0 0 7.5 3Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/nav-down-unread.svg b/comm/mail/themes/shared/mail/icons/new/compact/nav-down-unread.svg
new file mode 100644
index 0000000000..dcb514cc68
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/nav-down-unread.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3.525 5.002a.5.5 0 0 0-.359.127.5.5 0 0 0-.037.705l4.5 5a.5.5 0 0 0 .742 0l4.5-5a.5.5 0 0 0-.037-.705.5.5 0 0 0-.705.037L8 9.754 3.871 5.166a.5.5 0 0 0-.346-.164zM13 9c-1.099 0-2 .901-2 2s.901 2 2 2 2-.901 2-2-.901-2-2-2zm0 1c.558 0 1 .442 1 1s-.442 1-1 1-1-.442-1-1 .442-1 1-1z" fill="context-stroke"/>
+ <path d="M14.5 11a1.5 1.5 0 0 1-1.5 1.5 1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 13 9.5a1.5 1.5 0 0 1 1.5 1.5Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/nav-down.svg b/comm/mail/themes/shared/mail/icons/new/compact/nav-down.svg
new file mode 100644
index 0000000000..68adb2ac47
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/nav-down.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M3.166 5.128a.5.5 0 0 0-.037.706l4.5 5a.5.5 0 0 0 .742 0l4.5-5a.5.5 0 0 0-.037-.706.5.5 0 0 0-.705.038L8 9.753 3.871 5.166a.5.5 0 0 0-.705-.038z" fill="context-fill transparent"/>
+ <path d="M3.166 5.128a.5.5 0 0 0-.037.706l4.5 5a.5.5 0 0 0 .742 0l4.5-5a.5.5 0 0 0-.037-.706.5.5 0 0 0-.705.038L8 9.753 3.871 5.166a.5.5 0 0 0-.705-.038z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/nav-forward.svg b/comm/mail/themes/shared/mail/icons/new/compact/nav-forward.svg
new file mode 100644
index 0000000000..fd2d750d05
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/nav-forward.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8.5 3a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707L11.293 7H3.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h7.793l-3.147 3.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l4-4a.5.5 0 0 0 .103-.152.5.5 0 0 0 .008-.024A.5.5 0 0 0 13 7.5a.5.5 0 0 0-.018-.13.5.5 0 0 0-.048-.12.5.5 0 0 0-.08-.104l-4-4A.5.5 0 0 0 8.5 3Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/nav-left.svg b/comm/mail/themes/shared/mail/icons/new/compact/nav-left.svg
new file mode 100644
index 0000000000..b88b57605c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/nav-left.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M10.872 3.166a.5.5 0 0 0-.706-.037l-5 4.5a.5.5 0 0 0 0 .742l5 4.5a.5.5 0 0 0 .706-.037.5.5 0 0 0-.038-.705L6.247 8l4.587-4.129a.5.5 0 0 0 .038-.705z" fill="context-fill transparent"/>
+ <path d="M10.872 3.166a.5.5 0 0 0-.706-.037l-5 4.5a.5.5 0 0 0 0 .742l5 4.5a.5.5 0 0 0 .706-.037.5.5 0 0 0-.038-.705L6.247 8l4.587-4.129a.5.5 0 0 0 .038-.705z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/nav-right.svg b/comm/mail/themes/shared/mail/icons/new/compact/nav-right.svg
new file mode 100644
index 0000000000..43a78bcbef
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/nav-right.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M5.128 3.166a.5.5 0 0 1 .706-.037l5 4.5a.5.5 0 0 1 0 .742l-5 4.5a.5.5 0 0 1-.706-.037.5.5 0 0 1 .038-.705L9.753 8 5.166 3.871a.5.5 0 0 1-.038-.705z" fill="context-fill transparent"/>
+ <path d="M5.128 3.166a.5.5 0 0 1 .706-.037l5 4.5a.5.5 0 0 1 0 .742l-5 4.5a.5.5 0 0 1-.706-.037.5.5 0 0 1 .038-.705L9.753 8 5.166 3.871a.5.5 0 0 1-.038-.705z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/nav-up-unread.svg b/comm/mail/themes/shared/mail/icons/new/compact/nav-up-unread.svg
new file mode 100644
index 0000000000..68dd8e6933
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/nav-up-unread.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M13 3c-1.099 0-2 .901-2 2s.901 2 2 2 2-.901 2-2-.901-2-2-2zm0 1c.558 0 1 .442 1 1s-.442 1-1 1-1-.442-1-1 .442-1 1-1zM8 5a.5.5 0 0 0-.371.166l-4.5 5a.5.5 0 0 0 .037.705.5.5 0 0 0 .705-.037L8 6.246l4.129 4.588a.5.5 0 0 0 .705.037.5.5 0 0 0 .037-.705l-4.5-5A.5.5 0 0 0 8 5z" fill="context-stroke"/>
+ <path d="M14.5 5A1.5 1.5 0 0 1 13 6.5 1.5 1.5 0 0 1 11.5 5 1.5 1.5 0 0 1 13 3.5 1.5 1.5 0 0 1 14.5 5Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/nav-up.svg b/comm/mail/themes/shared/mail/icons/new/compact/nav-up.svg
new file mode 100644
index 0000000000..5072e04f28
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/nav-up.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M3.166 10.872a.5.5 0 0 1-.037-.706l4.5-5a.5.5 0 0 1 .742 0l4.5 5a.5.5 0 0 1-.037.706.5.5 0 0 1-.705-.038L8 6.247l-4.129 4.587a.5.5 0 0 1-.705.038z" fill="context-fill transparent"/>
+ <path d="M3.166 10.872a.5.5 0 0 1-.037-.706l4.5-5a.5.5 0 0 1 .742 0l4.5 5a.5.5 0 0 1-.037.706.5.5 0 0 1-.705-.038L8 6.247l-4.129 4.587a.5.5 0 0 1-.705.038z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/new-address-book.svg b/comm/mail/themes/shared/mail/icons/new/compact/new-address-book.svg
new file mode 100644
index 0000000000..7156f194ac
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/new-address-book.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5c-.831 0-1.5.669-1.5 1.5v10c0 .83.669 1.5 1.5 1.5h6V13H8.5c-.817 0-1.5-.684-1.5-1.5 0-.817.683-1.5 1.5-1.5H10V8.5c0-.817.683-1.5 1.5-1.5s1.5.683 1.5 1.5V10h.5V3c0-.831-.669-1.5-1.5-1.5Z" fill="context-fill"/>
+ <path d="M4 1c-1.1 0-2 .9-2 2h-.5a.5.5 0 1 0 0 1H2v2h-.5a.5.5 0 1 0 0 1H2v2h-.5a.5.5 0 1 0 0 1H2v2h-.5a.5.5 0 1 0 0 1H2c0 1.099.9 2 2 2h6.09a1.456 1.456 0 0 1-.09-.5V14H4c-.563 0-1-.438-1-1h.5a.5.5 0 1 0 0-1H3v-2h.5a.5.5 0 1 0 0-1H3V7h.5a.5.5 0 1 0 0-1H3V4h.5a.5.5 0 1 0 0-1H3c0-.563.437-1 1-1h8c.563 0 1 .437 1 1v7h1V3c0-1.1-.9-2-2-2Zm4.5 3c-.861 0-1.537.312-1.947.804C6.143 5.297 6 5.916 6 6.5c0 .86.298 1.486.61 1.875.181.227.258.242.39.334v.29h-.5c-.822 0-1.5.678-1.5 1.5v1a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-1c0-.285.214-.5.5-.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.277-.447s-.143-.066-.332-.302C7.201 7.513 7 7.139 7 6.5c0-.417.108-.798.322-1.055C7.537 5.188 7.862 5 8.5 5c.639 0 .963.188 1.178.445.214.257.322.638.322 1.055 0 .639-.202 1.013-.39 1.25-.19.236-.333.302-.333.302A.5.5 0 0 0 9 8.5v1a.5.5 0 0 0 .5.5h.5V8.499c0-.621.397-1.166.947-1.39.034-.188.053-.39.053-.61 0-.583-.142-1.202-.553-1.695C10.037 4.312 9.361 4 8.5 4Zm3 4a.5.5 0 0 0-.5.5V11H8.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H11v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H12V8.5a.5.5 0 0 0-.5-.5Zm1.5 5v1.5c0 .082-.008.163-.021.242A2.006 2.006 0 0 0 14 13Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/new-chat.svg b/comm/mail/themes/shared/mail/icons/new/compact/new-chat.svg
new file mode 100644
index 0000000000..dbec416515
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/new-chat.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1C2.347 1 1 2.346 1 4v3c0 1.653 1.347 3 3 3v2.5a.5.5 0 0 0 .854.353L5.707 12H8.09a1.456 1.456 0 0 1 0-1H6.707l1-1H10c.35 0 .687-.06 1-.172V8.736A2.008 2.008 0 0 1 10 9H7.5a.5.5 0 0 0-.354.146L5 11.293V9.5a.5.5 0 0 0-.5-.5H4c-1.117 0-2-.884-2-2V4c0-1.117.883-2 2-2h6c1.117 0 2 .883 2 2v3c0 .03-.003.06-.004.091.159-.058.328-.091.504-.091.174 0 .341.033.498.09L13 7V4.263c.6.343 1 .986 1 1.737v4h.828c.111-.314.172-.65.172-1V6c0-1.35-.899-2.497-2.129-2.871A3.007 3.007 0 0 0 10 .999H4Zm.5 4a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm1.975 0A.5.5 0 0 0 6.5 6h1a.5.5 0 1 0 0-1zM9.5 5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm3 3a.5.5 0 0 0-.5.5V11H9.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H12v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H13V8.5a.5.5 0 0 0-.5-.5Zm-3.227 4.98 1.747 1.746A1.444 1.444 0 0 1 11 14.5v-1.207L10.707 13H9.5a1.43 1.43 0 0 1-.227-.02Z" fill="context-stroke"/>
+ <path d="M13 4.264V7l-.002.09C13.576 7.3 14 7.858 14 8.5V6c0-.75-.4-1.394-1-1.736Zm-2 5.564c-.313.111-.65.172-1 .172h1zM7.707 10l-1 1H8.09c.21-.577.768-1 1.41-1Zm3 3 .293.293V13Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/new-contact.svg b/comm/mail/themes/shared/mail/icons/new/compact/new-contact.svg
new file mode 100644
index 0000000000..cf80f4d863
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/new-contact.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5.5a7 7 0 0 0-7 7 7 7 0 0 0 3 5.728V11.75c0-.693.558-1.25 1.25-1.25H6.5v-.446A2.75 2.75 0 0 1 4.75 7.5 2.75 2.75 0 0 1 7.5 4.75a2.75 2.75 0 0 1 2.75 2.75 2.75 2.75 0 0 1-1.75 2.554v.338a1.49 1.49 0 0 1 1-.392H11V8.5c0-.817.683-1.5 1.5-1.5s1.5.683 1.5 1.5V10h.03a7 7 0 0 0 .47-2.5 7 7 0 0 0-7-7z" fill="context-fill"/>
+ <path d="M7.5 0C3.364 0 0 3.363 0 7.5a7.493 7.493 0 0 0 3.143 6.1.5.5 0 0 0 .064.044 7.458 7.458 0 0 0 7.793.48V13h-.037A6.473 6.473 0 0 1 7.5 14c-1.29 0-2.49-.376-3.5-1.022V12c0-.563.437-1 1-1h1.5a.5.5 0 0 0 .1-.99c-.113-.023-.61-.332-.97-.815-.36-.483-.629-1.11-.63-1.695C5 6.113 6.113 5 7.5 5S10 6.113 10 7.5c-.001.585-.27 1.212-.63 1.695-.36.483-.857.792-.97.814a.5.5 0 0 0-.244.836A1.516 1.516 0 0 1 9.5 10h.398c.083-.096.193-.101.272-.207.46-.616.829-1.417.83-2.293C11 5.573 9.427 4 7.5 4A3.508 3.508 0 0 0 4 7.5c.001.876.37 1.677.83 2.293.08.106.19.11.272.207H5c-1.1 0-2 .9-2 2v.191A6.477 6.477 0 0 1 1 7.5C1 3.904 3.904 1 7.5 1a6.492 6.492 0 0 1 6.465 7.183v.002c.022.102.035.207.035.315V10h.568A7.46 7.46 0 0 0 15 7.5C15 3.363 11.636 0 7.5 0Zm5 8a.5.5 0 0 0-.5.5V11H9.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H12v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H13V8.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/new-event.svg b/comm/mail/themes/shared/mail/icons/new/compact/new-event.svg
new file mode 100644
index 0000000000..587dd655ce
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/new-event.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3.5 1.5c-1.108 0-2 1.892-2 3h13c0-1.108-.892-3-2-3z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 1.653 1.347 3 3 3h5.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H4c-1.117 0-2-.884-2-2V5h2v.5a.5.5 0 0 0 1 0V5h6v.5a.5.5 0 0 0 1 0V5h2v4.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V4c0-1.654-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2h-2v-.5a.5.5 0 0 0-.5-.493.5.5 0 0 0-.5.493V4H5v-.5a.5.5 0 0 0-.5-.493.5.5 0 0 0-.5.493V4H2c0-1.117.883-2 2-2Zm.5 5a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h1a.5.5 0 0 0 .428-.748A.5.5 0 0 0 5.5 7Zm3.016 0a.5.5 0 0 0-.493.5.5.5 0 0 0 .493.5h1a.5.5 0 0 0 .427-.252.5.5 0 0 0 0-.496A.5.5 0 0 0 8.516 7ZM11.5 8a.5.5 0 0 0-.5.5V11H8.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H11v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H12V8.5a.5.5 0 0 0-.5-.5Zm-7 1a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h1a.5.5 0 0 0 .428-.748A.5.5 0 0 0 5.5 9Zm3.016 0a.5.5 0 0 0-.493.5.5.5 0 0 0 .493.5h1a.5.5 0 0 0 0-1zM4.5 11a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h1a.5.5 0 0 0 .428-.252.5.5 0 0 0 0-.496A.5.5 0 0 0 5.5 11Zm9.371 2.017a.5.5 0 0 0-.225.13l-.5.5a.5.5 0 0 0 0 .706.5.5 0 0 0 .708 0l.5-.5a.5.5 0 0 0 0-.707.5.5 0 0 0-.483-.129z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/new-key.svg b/comm/mail/themes/shared/mail/icons/new/compact/new-key.svg
new file mode 100644
index 0000000000..4a3e3f2620
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/new-key.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M11 1.5A3.5 3.5 0 0 0 7.5 5a3.5 3.5 0 0 0 .246 1.264l-.24.242L7.5 6.5l-5 5v2h2l1-1v-1h1l1-1v-1h1l1-1-.006-.006.242-.24A3.5 3.5 0 0 0 11 8.5c0-.831.669-1.5 1.5-1.5.4 0 .761.155 1.03.408A3.5 3.5 0 0 0 14.5 5 3.5 3.5 0 0 0 11 1.5Zm.5 2a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="context-fill"/>
+ <path d="M11 1C8.797 1 7 2.797 7 5a.5.5 0 0 0 0 .004c.003.373.108.731.213 1.088l-.055.054a.5.5 0 0 0-.012 0l-5 5A.5.5 0 0 0 2 11.5v2a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .354-.146l1-1A.5.5 0 0 0 6 12.5V12h.5a.5.5 0 0 0 .354-.146l1-1A.5.5 0 0 0 8 10.5V10h.5a.5.5 0 0 0 .354-.146l1-1a.5.5 0 0 0 .023-.036l.031-.03c.357.104.715.21 1.088.212A.5.5 0 0 0 11 9v-.5a1.5 1.5 0 0 1 .088-.504c-.029 0-.057.004-.086.004a3.004 3.004 0 0 1-1.082-.21.5.5 0 0 0-.535.108l-.242.24a.5.5 0 0 0-.008.02L8.293 9H7.5a.5.5 0 0 0-.5.5v.793L6.293 11H5.5a.5.5 0 0 0-.5.5v.793L4.293 13H3v-1.293l4.842-4.842a.5.5 0 0 0 .02-.008l.24-.242a.5.5 0 0 0 .109-.535A3.004 3.004 0 0 1 8 5v-.002A2.992 2.992 0 0 1 11 2c1.663 0 3 1.337 3 3a2.99 2.99 0 0 1-.885 2.133 1.5 1.5 0 0 1 .72.683A3.987 3.987 0 0 0 15 5c0-2.203-1.797-4-4-4Zm.5 2c-.823 0-1.5.677-1.5 1.5S10.677 6 11.5 6 13 5.323 13 4.5 12.323 3 11.5 3Zm0 1c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5Zm1 4a.5.5 0 0 0-.5.5V11H9.5a.5.5 0 0 0 0 1H12v2.5a.5.5 0 0 0 1 0V12h2.5a.5.5 0 0 0 0-1H13V8.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/new-mail.svg b/comm/mail/themes/shared/mail/icons/new/compact/new-mail.svg
new file mode 100644
index 0000000000..1b8c93ed36
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/new-mail.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3 4c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1h6.5c-.817 0-1.5-.683-1.5-1.5S8.683 10 9.5 10H11V8.5c0-.817.683-1.5 1.5-1.5s1.5.683 1.5 1.5V5c0-.554-.446-1-1-1Z" fill="context-fill"/>
+ <path d="M3 3c-1.1 0-2 .9-2 2v7c0 1.1.9 2 2 2h8v-1H3c-.563 0-1-.437-1-1V5c0-.563.437-1 1-1h10c.563 0 1 .437 1 1v5h1V5c0-1.1-.9-2-2-2Zm.47 2.002a.5.5 0 0 0-.343.166.5.5 0 0 0 .041.705l2.781 2.47-2.803 2.803a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0L6.697 9.01l.971.863a.5.5 0 0 0 .664 0l.97-.863.991.99H11v-.707l-.95-.95 2.782-2.47a.5.5 0 0 0 .041-.705.5.5 0 0 0-.705-.041L8 8.832 3.832 5.127a.5.5 0 0 0-.361-.125ZM12.5 8a.5.5 0 0 0-.5.5V11H9.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H12v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H13V8.5a.5.5 0 0 0-.5-.5Zm1.5 5v.729c.301-.176.553-.428.729-.729Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/new-task.svg b/comm/mail/themes/shared/mail/icons/new/compact/new-task.svg
new file mode 100644
index 0000000000..c44b6a243b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/new-task.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M6.5 0c-.647 0-1.204.42-1.412 1H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6.086a1.5 1.5 0 0 1-.086-.5V14H4c-.563 0-1-.437-1-1V3c0-.563.437-1 1-1h1.086A1.51 1.51 0 0 0 6.5 3.004h3A1.51 1.51 0 0 0 10.914 2H12c.563 0 1 .437 1 1v7h1V3c0-1.1-.9-2-2-2h-1.088c-.208-.58-.765-1-1.412-1Zm0 1h3c.286 0 .5.214.5.5v.004c0 .286-.214.5-.5.5h-3a.488.488 0 0 1-.5-.5V1.5c0-.286.214-.5.5-.5Zm4.06 5.004a.5.5 0 0 0-.13.002.5.5 0 0 0-.33.193L7.445 9.74 5.854 8.145a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .708l2 2A1.497 1.497 0 0 1 8.5 10l1.756-2.34c.028-.041.058-.08.09-.119l.554-.74a.5.5 0 0 0-.1-.701.5.5 0 0 0-.24-.096ZM11.5 8a.5.5 0 0 0-.5.5V11H8.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H11v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H12V8.5a.5.5 0 0 0-.5-.5Zm1.5 5v1.5c0 .082-.009.162-.021.24A2.004 2.004 0 0 0 14 13Z" fill="context-stroke"/>
+ <path d="M4 1.5c-.554 0-1.5.946-1.5 1.5v10c0 .554.946 1.5 1.5 1.5h6V13H8.5c-.831 0-1.5-.669-1.5-1.5S7.669 10 8.5 10H10V8.5c0-.831.669-1.5 1.5-1.5s1.5.669 1.5 1.5h.5V3c0-.554-.946-1.5-1.5-1.5h-1.5V2c0 .277-.223.5-.5.5H6a.499.499 0 0 1-.5-.5v-.5Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/new-user-list.svg b/comm/mail/themes/shared/mail/icons/new/compact/new-user-list.svg
new file mode 100644
index 0000000000..532ac2c51f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/new-user-list.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5A2.495 2.495 0 0 0 1.5 4v8c0 .82.5 2 2 2.5v-2.998C3.5 10 4 10 4.5 9.5V7.992a2.5 2.5 0 0 1-1-1.993 2.5 2.5 0 0 1 4.844-.869A2.5 2.5 0 0 1 10 4.5 2.5 2.5 0 0 1 12.5 7c.831 0 1.5.668 1.5 1.5V10h.5V4c0-1.386-1.115-2.5-2.5-2.5Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 .953.45 1.803 1.146 2.353l.004.004a.504.504 0 0 0 .096.072c.494.358 1.1.57 1.754.57h7.086a1.5 1.5 0 0 1-.086-.5v-.5H8v-2.5c0-.598.347-1.112.852-1.353A.5.5 0 0 0 8.5 10c-.822 0-1.5.677-1.5 1.5V14H3.992A.5.5 0 0 0 4 13.935V10.5c0-.286.214-.5.5-.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5C3.678 9 3 9.677 3 10.5v3.234a1.97 1.97 0 0 1-1-1.715V4c0-1.117.883-2 2-2h8c1.117 0 2 .883 2 2v6h1V4c0-1.654-1.347-3-3-3Zm2 2C4.35 3 3 4.349 3 6c0 1.65 1.35 3 3 3 .178 0 .352-.017.521-.047-.17-.301-.3-.623-.388-.957A1.992 1.992 0 0 1 4 6c0-1.11.89-2 2-2 .39 0 .752.108 1.059.298a4 4 0 0 1 .779-.664A2.98 2.98 0 0 0 6 3Zm4 1C8.35 4 7 5.349 7 7c0 1.65 1.35 3 3 3 .35 0 .687-.064 1-.176V8.732c-.294.17-.634.268-1 .268-1.11 0-2-.89-2-2 0-1.11.89-2 2-2 1.11 0 2 .89 2 2 0 .03-.003.058-.004.088a1.5 1.5 0 0 1 1-.004c0-.028.004-.056.004-.084 0-1.651-1.35-3-3-3Zm2.5 4a.5.5 0 0 0-.5.5V11H9.5a.5.5 0 0 0 0 1H12v2.5a.5.5 0 0 0 1 0V12h2.5a.5.5 0 0 0 0-1H13V8.5a.5.5 0 0 0-.5-.5Zm1.5 5v1.224A2.99 2.99 0 0 0 14.826 13Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/newsletter.svg b/comm/mail/themes/shared/mail/icons/new/compact/newsletter.svg
new file mode 100644
index 0000000000..0fedaa67d5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/newsletter.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M7 1.996c-1.1 0-2 .9-2 2V6H3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3.996c0-1.1-.9-2-2-2zm0 1h6c.563 0 1 .437 1 1V12c0 .563-.437 1-1 1H5.613c.206-.33.387-.667.387-1V3.996c0-.563.437-1 1-1zM8 4c-.545 0-1 .455-1 1v1c0 .545.455 1 1 1h1c.545 0 1-.455 1-1V5c0-.545-.455-1-1-1Zm3.5 0a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h1a.5.5 0 0 0 0-1zM8 5h1v1H8Zm3.5 1a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h1a.5.5 0 0 0 0-1zM3 7h2v5c0 .083-.087.401-.266.625-.179.224-.4.375-.734.375H3c-.563 0-1-.437-1-1V8c0-.563.437-1 1-1Zm4.5 1a.5.5 0 0 0 0 1h5a.5.5 0 0 0 .428-.748A.5.5 0 0 0 12.5 8Zm0 2a.5.5 0 0 0-.492.5.5.5 0 0 0 .492.5h5a.5.5 0 0 0 0-1z" fill="context-stroke"/>
+ <path d="M3 7c-.554 0-1 .446-1 1v4c0 .554.446 1 1 1h1c.554 0 1-.446 1-1V7Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/offline.svg b/comm/mail/themes/shared/mail/icons/new/compact/offline.svg
new file mode 100644
index 0000000000..bcf757e208
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/offline.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.875 6.004a2.01 2.01 0 0 0-1.871 1.871zm2.121 2.121L8.125 9.996a2.01 2.01 0 0 0 1.871-1.871z" fill="context-stroke"/>
+ <path d="M2.582 2.227a.5.5 0 0 0-.225.128A8.002 8.002 0 0 0 0 7.998a.5.5 0 0 0 0 .004 8.003 8.003 0 0 0 1.396 4.484l.043-.047.678-.677A6.996 6.996 0 0 1 1 8.002v-.004a6.998 6.998 0 0 1 2.064-4.932.5.5 0 0 0 .002-.709.5.5 0 0 0-.484-.13Zm12.03 1.277c-.018.019-.033.039-.051.057l-.668.668A7 7 0 0 1 15 7.998a6.998 6.998 0 0 1-2.064 4.936.5.5 0 0 0-.002.709.5.5 0 0 0 .709.002A8.002 8.002 0 0 0 16 8.002.5.5 0 0 0 16 8a8 8 0 0 0-1.389-4.496Zm-9.794.814a.5.5 0 0 0-.353.147A5 5 0 0 0 3 8c0 .812.2 1.603.568 2.31l.75-.75a4 4 0 0 1 .854-4.389.5.5 0 0 0 0-.706.5.5 0 0 0-.354-.147zm7.614 1.371-.75.75a4 4 0 0 1-.854 4.389.5.5 0 0 0 0 .707.5.5 0 0 0 .707 0A5 5 0 0 0 13 8c0-.812-.2-1.603-.568-2.31Z" fill="context-fill"/>
+ <path d="m13.146 2.146-11 11a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l11-11a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0z" fill="#ff555f"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/online.svg b/comm/mail/themes/shared/mail/icons/new/compact/online.svg
new file mode 100644
index 0000000000..ec7bcb0010
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/online.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M9.5 8A1.5 1.5 0 0 1 8 9.5 1.5 1.5 0 0 1 6.5 8 1.5 1.5 0 0 1 8 6.5 1.5 1.5 0 0 1 9.5 8Z" fill="context-fill"/>
+ <path d="M13.3 2.199a.5.5 0 0 0-.353.146.5.5 0 0 0 0 .707A7 7 0 0 1 15 7.998a6.998 6.998 0 0 1-2.064 4.935.5.5 0 0 0-.002.71.5.5 0 0 0 .709.001A8.002 8.002 0 0 0 16 8.002.5.5 0 0 0 16 8a8 8 0 0 0-2.346-5.655.5.5 0 0 0-.353-.146Zm-10.718.027a.5.5 0 0 0-.225.13A8.002 8.002 0 0 0 0 7.997a.5.5 0 0 0 0 .004 8.002 8.002 0 0 0 2.357 5.642.5.5 0 0 0 .71-.002.5.5 0 0 0-.003-.709A6.998 6.998 0 0 1 1 8.002v-.004a6.998 6.998 0 0 1 2.064-4.932.5.5 0 0 0 .002-.709.5.5 0 0 0-.484-.13Zm2.236 2.092a.5.5 0 0 0-.353.146 5 5 0 0 0 0 7.07.5.5 0 0 0 .707 0 .5.5 0 0 0 0-.706 4 4 0 0 1 0-5.656.5.5 0 0 0 0-.708.5.5 0 0 0-.354-.146zm6.364 0a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708 4 4 0 0 1 0 5.656.5.5 0 0 0 0 .707.5.5 0 0 0 .707 0 5 5 0 0 0 0-7.07.5.5 0 0 0-.353-.147zM8 6c-1.099 0-2 .901-2 2 0 1.098.901 2 2 2s2-.902 2-2c0-1.099-.901-2-2-2Zm0 1c.558 0 1 .441 1 1 0 .558-.442 1-1 1s-1-.442-1-1c0-.559.442-1 1-1Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/outbox.svg b/comm/mail/themes/shared/mail/icons/new/compact/outbox.svg
new file mode 100644
index 0000000000..704ce67e24
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/outbox.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 2c-1.108 0-2 .892-2 2v8c0 1.108.892 2 2 2h5.98l-.162-.607-1.824-.178a1.694 1.694 0 0 1-.133-.02c-.951-.18-1.746-.835-1.85-1.947-.377-.334-.596-.762-.767-1.248H4V7h7.688l1.462-.652.051-.022c.231-.093.497-.201.799-.281V4c0-1.108-.892-2-2-2Zm1 3h6v1H5Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.347 1 4v8c0 1.653 1.347 3 3 3h6.248l-.268-1H4c-1.117 0-2-.883-2-2v-1.998c1.123.004 2.362-.001 3.244-.002.13.475.392.918.77 1.266A2.661 2.661 0 0 1 6 11c0-.33.066-.631.182-.9A1.184 1.184 0 0 1 6 9.5a.5.5 0 0 0-.5-.5c-.399 0-.951 0-1.5.002V7h7.688l1.162-.518A1.004 1.004 0 0 0 12 6V5c0-.545-.455-1-1-1H5c-.545 0-1 .455-1 1v1c-.545 0-1 .455-1 1v2.002c-.342 0-.673 0-1-.002V4c0-1.117.883-2 2-2h8c1.117 0 2 .883 2 2v2.045a3.24 3.24 0 0 1 .834-.113c.056 0 .111.005.166.01V4c0-1.653-1.347-3-3-3Zm1 4h6v1H5Zm9.834 1.932c-.471 0-.834.15-1.26.322a.5.5 0 0 0-.015.008l-5.58 2.484C7.437 9.915 7 10.388 7 11c0 .666.5 1.11 1.047 1.213a.5.5 0 0 0 .045.006l2.513.246.668 2.502c.062.265.2.5.403.691.203.192.489.342.824.342.629 0 1.043-.484 1.242-.924a.5.5 0 0 0 .018-.049l1.941-5.83a.5.5 0 0 0 .002-.004c.097-.296.297-.605.297-1.095 0-.639-.528-1.166-1.166-1.166Zm0 1c.098 0 .166.068.166.166 0 .176-.112.37-.248.787l-1.928 5.785c-.108.235-.219.33-.324.33-.033 0-.08-.015-.137-.068a.472.472 0 0 1-.117-.194.5.5 0 0 0-.004-.013l-.685-2.575 1.297-1.296a.5.5 0 0 0 0-.708.5.5 0 0 0-.708 0l-1.332 1.333-2.584-.25C8.156 11.213 8 11.069 8 11c0-.102.12-.259.297-.307a.5.5 0 0 0 .074-.025l5.576-2.486c.43-.173.622-.25.887-.25z" fill="context-stroke"/>
+</svg>
+
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/overflow.svg b/comm/mail/themes/shared/mail/icons/new/compact/overflow.svg
new file mode 100644
index 0000000000..a044b5689e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/overflow.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M3.53 3.002a.5.5 0 0 0-.362.125.5.5 0 0 0-.041.705L6.83 8l-3.703 4.168a.5.5 0 0 0 .041.705.5.5 0 0 0 .705-.041l4-4.5a.5.5 0 0 0 0-.664l-4-4.5a.5.5 0 0 0-.344-.166Zm5 0a.5.5 0 0 0-.362.125.5.5 0 0 0-.041.705L11.83 8l-3.703 4.168a.5.5 0 0 0 .041.705.5.5 0 0 0 .705-.041l4-4.5a.5.5 0 0 0 0-.664l-4-4.5a.5.5 0 0 0-.344-.166Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/paint-brush.svg b/comm/mail/themes/shared/mail/icons/new/compact/paint-brush.svg
new file mode 100644
index 0000000000..a5144c06a7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/paint-brush.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M14.5 1.5c-.426-.02-.974.305-.974.305C8.372 6.95 6.5 6.5 6.5 8.5a1826.084 1826.084 0 0 0 2 2c2 0 1.81-2.545 6.963-7.692 0 0 .024-.379-.008-.75-.044-.507-.568-.472-.955-.558Zm-10 9c-.628 0-1.176.426-1.275.727L1.5 14.5c2.616 0 4.5 0 4.987-1.804.016-.062.013-.13.013-.195 0-2-1.239-2.007-2-2z" fill="context-fill"/>
+ <path d="M14.523 1c-.35-.017-.637.09-.86.183-.225.093-.392.192-.392.192a.5.5 0 0 0-.1.076c-2.55 2.547-4.256 3.685-5.378 4.473-.561.393-.99.698-1.31 1.097a2.25 2.25 0 0 0-.477 1.266l-1.73 1.73a2.104 2.104 0 0 0-.88.31c-.297.19-.533.4-.646.743l.031-.076-1.724 3.271A.5.5 0 0 0 1.5 15c1.308 0 2.455.01 3.418-.243.963-.252 1.765-.873 2.05-1.931.012-.043.013-.072.018-.108l1.73-1.728a2.245 2.245 0 0 0 1.33-.602c.413-.382.746-.887 1.17-1.529.85-1.284 2.055-3.155 4.6-5.697a.5.5 0 0 0 .147-.323s.027-.403-.01-.824a1.016 1.016 0 0 0-.236-.576 1.05 1.05 0 0 0-.444-.289c-.287-.102-.531-.107-.664-.137A.5.5 0 0 0 14.523 1Zm-.056 1.002c.208.04.402.066.472.091l.018.008c.021.247.01.41.006.52-2.483 2.512-3.76 4.444-4.582 5.687-.422.638-.738 1.092-1.014 1.348-.228.212-.442.258-.719.285l-.795-.795-.804-.804c.023-.283.058-.5.215-.696.192-.24.552-.52 1.103-.906 1.091-.766 2.885-1.975 5.445-4.52.022-.012.095-.056.233-.113.153-.063.342-.107.422-.105ZM6.5 9.207l.646.646.647.647-.94.94c-.162-.524-.448-.896-.804-1.116a1.99 1.99 0 0 0-.457-.21ZM4.5 11a.5.5 0 0 0 .004 0c.377-.003.77.018 1.017.172.248.153.479.43.479 1.328 0 .083-.007.106.004.066-.201.746-.585 1.027-1.34 1.225-.55.144-1.44.138-2.305.152l1.309-2.484a.5.5 0 0 0 .031-.077c-.014.042.075-.109.233-.209.157-.1.367-.173.568-.173Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/paste.svg b/comm/mail/themes/shared/mail/icons/new/compact/paste.svg
new file mode 100644
index 0000000000..29a73020c6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/paste.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5c-.554 0-1.5.446-1.5 1v11c0 .554.946 1 1.5 1h8c.554 0 1.5-.446 1.5-1v-11c0-.554-.946-1-1.5-1h-1.5s-.5 1-1 1h-3c-.5 0-1-1-1-1zm1 3h6c.277 0 .5.223.5.5v7c0 .277-.223.5-.5.5H5a.499.499 0 0 1-.5-.5V5c0-.277.223-.5.5-.5Z" fill="context-fill"/>
+ <path d="M6.5 0c-.647 0-1.204.42-1.412 1H4c-1.1 0-2 .9-2 2v10c0 1.099.9 2 2 2h8c1.1 0 2-.901 2-2V3c0-1.1-.9-2-2-2h-1.088c-.208-.58-.765-1-1.412-1Zm0 1h3c.286 0 .5.214.5.5v.004c0 .285-.214.5-.5.5h-3a.488.488 0 0 1-.5-.5V1.5c0-.286.214-.5.5-.5ZM4 2h1.086c.196.55.708.958 1.312 1h3.204a1.512 1.512 0 0 0 1.312-1H12c.563 0 1 .437 1 1v10c0 .562-.437 1-1 1H4c-.563 0-1-.438-1-1V3c0-.563.437-1 1-1Zm1.5 2C4.678 4 4 4.677 4 5.5v6c0 .822.678 1.5 1.5 1.5h5c.822 0 1.5-.678 1.5-1.5v-6c0-.823-.678-1.5-1.5-1.5Zm0 1h5c.286 0 .5.214.5.5v6c0 .285-.214.5-.5.5h-5a.488.488 0 0 1-.5-.5v-6c0-.286.214-.5.5-.5Zm1 1a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/pencil.svg b/comm/mail/themes/shared/mail/icons/new/compact/pencil.svg
new file mode 100644
index 0000000000..306fcbb2a1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/pencil.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M10 4.707 11.293 6 5.5 11.793 4.207 10.5Z" fill="context-fill"/>
+ <path d="M11.5 2a.5.5 0 0 0-.354.146l-8 8a.5.5 0 0 0-.12.195l-1 3a.5.5 0 0 0 .632.633l3-1a.5.5 0 0 0 .196-.12l8-8A.5.5 0 0 0 14 4.5s0-.298-.072-.66c-.073-.363-.207-.826-.574-1.194-.368-.368-.831-.502-1.194-.574C11.797 1.999 11.5 2 11.5 2Zm.186 1.021c.083.005.121 0 .279.031.262.053.55.169.681.301.133.132.249.42.301.682.032.157.026.196.032.28L12 5.293 10.707 4ZM10 4.707 11.293 6 6 11.293 4.707 10Zm-6 6L5.293 12l-.063.062-1.939.647.647-1.94Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/photo-ban.svg b/comm/mail/themes/shared/mail/icons/new/compact/photo-ban.svg
new file mode 100644
index 0000000000..856555d1d7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/photo-ban.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4.5 3.5c-.554 0-1 .446-1 1v6c0 .554.446 1 1 1h3.623A4.5 4.5 0 0 1 12.5 8V4.5c0-.554-.446-1-1-1z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 1.653 1.347 3 3 3h4.764a4.5 4.5 0 0 1-.496-1H4c-1.117 0-2-.884-2-2V4c0-1.117.883-2 2-2h8c1.117 0 2 .883 2 2v4.258a4.5 4.5 0 0 1 1 .5V4c0-1.654-1.347-3-3-3H4zm.5 1.998V3C3.678 3 3 3.677 3 4.5v6c0 .822.678 1.5 1.5 1.5h3.535a4.5 4.5 0 0 1 .233-1H4.5a.488.488 0 0 1-.459-.293L6.062 9.09l1.715.857a.5.5 0 0 0 .577-.093L9.967 8.24l.303.364a4.5 4.5 0 0 1 .953-.418l-.838-1.006a.5.5 0 0 0-.362-.182.5.5 0 0 0-.377.148L7.9 8.893l-1.677-.84a.5.5 0 0 0-.535.056L4 9.46V4.498c0-.285.214-.5.5-.5h7c.286 0 .5.215.5.5v3.537A4.5 4.5 0 0 1 12.5 8a4.5 4.5 0 0 1 .5.03V4.497c0-.822-.678-1.5-1.5-1.5h-7zM7 5a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm5.5 4A3.508 3.508 0 0 0 9 12.5c0 1.927 1.573 3.5 3.5 3.5s3.5-1.573 3.5-3.5S14.427 9 12.5 9zm0 1c1.387 0 2.5 1.113 2.5 2.5 0 .511-.151.987-.412 1.38l-3.469-3.468c.395-.26.87-.412 1.381-.412zm-2.088 1.12 3.469 3.468c-.395.26-.87.412-1.381.412a2.492 2.492 0 0 1-2.5-2.5c0-.512.151-.986.412-1.38z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/pin.svg b/comm/mail/themes/shared/mail/icons/new/compact/pin.svg
new file mode 100644
index 0000000000..206d63a6e4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/pin.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8.156 12.5a.99.99 0 0 0 .707-.294l.523-2.574L10.5 8.499l1.058-1.04 2.65-.601a.996.996 0 0 0 0-1.414l-3.657-3.658a.996.996 0 0 0-1.414 0l-.523 2.576L7.5 5.499 6.442 6.535l-2.65.6a.996.996 0 0 0 0 1.413l3.657 3.658a.999.999 0 0 0 .707.295z" fill="context-fill"/>
+ <path d="M9.842.996c-.386 0-.77.146-1.06.44a.5.5 0 0 0-.136.251l-.492 2.43-1.008 1.03-.953.933-2.511.566a.5.5 0 0 0-.243.133 1.505 1.505 0 0 0-.002 2.123l1.477 1.477-2.768 2.767a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.767-2.767 1.475 1.474a1.494 1.494 0 0 0 2.123-.002.5.5 0 0 0 .135-.254l.492-2.427 1.008-1.024.953-.937 2.511-.57a.5.5 0 0 0 .243-.132c.586-.58.583-1.543.002-2.125l-3.659-3.656A1.501 1.501 0 0 0 9.842.996Zm.05 1.025a.394.394 0 0 1 .305.12l3.658 3.657c.18.18.141.432.002.627l-2.41.545a.5.5 0 0 0-.24.131L10.15 8.142a.5.5 0 0 0-.007.006L9.029 9.283a.5.5 0 0 0-.133.25l-.48 2.36c-.082.053-.165.109-.26.109a.492.492 0 0 1-.353-.149L4.145 8.195c-.18-.18-.141-.432-.002-.627l2.41-.545a.5.5 0 0 0 .238-.13L7.85 5.857a.5.5 0 0 0 .007-.008l1.114-1.138a.5.5 0 0 0 .133-.25l.472-2.323a.619.619 0 0 1 .317-.117Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/print.svg b/comm/mail/themes/shared/mail/icons/new/compact/print.svg
new file mode 100644
index 0000000000..6db6926930
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/print.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3 5.5c-.831 0-1.5.669-1.5 1.5v5c0 .83.669 1.5 1.5 1.5h.5v-4h9v4h.5c.831 0 1.5-.67 1.5-1.5V7c0-.831-.669-1.5-1.5-1.5h-.5v2h-9v-2z" fill="context-fill"/>
+ <path d="M4.5 1C3.678 1 3 1.677 3 2.5V5c-1.1 0-2 .9-2 2v5c0 1.099.9 2 2 2v.5c0 .822.678 1.5 1.5 1.5h7c.822 0 1.5-.678 1.5-1.5V14c1.1 0 2-.901 2-2V7c0-1.1-.9-2-2-2V2.5c0-.823-.678-1.5-1.5-1.5Zm0 1h7c.286 0 .5.214.5.5V7H4V2.5c0-.286.214-.5.5-.5ZM3 6v1.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V6c.563 0 1 .437 1 1v5c0 .562-.437 1-1 1V9.5a.5.5 0 0 0-.5-.5h-9a.5.5 0 0 0-.5.5V13c-.563 0-1-.438-1-1V7c0-.563.437-1 1-1Zm1 4h8v4.5c0 .285-.214.5-.5.5h-7a.488.488 0 0 1-.5-.5Zm3.5 1a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm-2 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/priority.svg b/comm/mail/themes/shared/mail/icons/new/compact/priority.svg
new file mode 100644
index 0000000000..4b189948b2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/priority.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5 3a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.5-.5zm0 9a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/question.svg b/comm/mail/themes/shared/mail/icons/new/compact/question.svg
new file mode 100644
index 0000000000..6b99c277e5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/question.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M14.5 7.5a7 7 0 0 1-7 7 7 7 0 0 1-7-7 7 7 0 0 1 7-7 7 7 0 0 1 7 7z" fill="context-fill"/>
+ <path d="M7.5 0C3.364 0 0 3.363 0 7.5 0 11.636 3.364 15 7.5 15S15 11.636 15 7.5C15 3.363 11.636 0 7.5 0Zm0 1C11.096 1 14 3.904 14 7.5c0 3.595-2.904 6.5-6.5 6.5A6.492 6.492 0 0 1 1 7.5C1 3.904 3.904 1 7.5 1Zm0 2A2.508 2.508 0 0 0 5 5.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5C6 4.665 6.666 4 7.5 4S9 4.665 9 5.5C9 6.334 8.334 7 7.5 7a.5.5 0 0 0-.137.021.5.5 0 0 0-.037.012.5.5 0 0 0-.107.056.5.5 0 0 0-.016.01.5.5 0 0 0-.101.1.5.5 0 0 0-.016.025.5.5 0 0 0-.055.112.5.5 0 0 0-.008.017A.5.5 0 0 0 7 7.5v2a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V7.949h.002A2.51 2.51 0 0 0 10 5.499c0-1.374-1.125-2.5-2.5-2.5zm0 8a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/quit.svg b/comm/mail/themes/shared/mail/icons/new/compact/quit.svg
new file mode 100644
index 0000000000..8dde690c87
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/quit.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5 0a.5.5 0 0 0-.5.5v4a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-4a.5.5 0 0 0-.5-.5zm2.34 1.472a.5.5 0 0 0-.192.035.5.5 0 0 0-.273.27.5.5 0 0 0 .266.654c.657.278 1.25.682 1.748 1.18A5.476 5.476 0 0 1 13 7.5c0 3.043-2.457 5.5-5.5 5.5A5.492 5.492 0 0 1 2 7.5c0-1.522.615-2.893 1.611-3.889a5.51 5.51 0 0 1 1.748-1.18.5.5 0 0 0 .266-.654.5.5 0 0 0-.654-.266A6.51 6.51 0 0 0 1 7.5C1 11.084 3.916 14 7.5 14S14 11.084 14 7.5a6.51 6.51 0 0 0-3.97-5.988.5.5 0 0 0-.19-.04z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/quote.svg b/comm/mail/themes/shared/mail/icons/new/compact/quote.svg
new file mode 100644
index 0000000000..f3c9e7fcd9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/quote.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 3.5a2.5 2.5 0 0 0 0 5 2.5 2.5 0 0 0 .5-.057V9c0 1-1 1.5-2 1.5.25.5.648.988 1.5 1A2.518 2.518 0 0 0 6.5 9V6.5c-.013-.08-.002-.33 0-.5A2.5 2.5 0 0 0 4 3.5Zm7 0a2.5 2.5 0 0 0 0 5 2.5 2.5 0 0 0 .5-.057V9c0 1-1 1.5-2 1.5.25.5.648.988 1.5 1A2.518 2.518 0 0 0 13.5 9V6.5c-.013-.08-.002-.33 0-.5A2.5 2.5 0 0 0 11 3.5Z" fill="context-fill"/>
+ <path d="M14 9a3.019 3.019 0 0 1-3 3 .5.5 0 0 1-.008 0c-.52-.007-.957-.173-1.277-.424-.32-.25-.519-.567-.662-.853A.5.5 0 0 1 9.5 10c.417 0 .83-.112 1.098-.291C10.866 9.53 11 9.333 11 9 9.35 9 8 7.65 8 6s1.35-3 3-3c1.651 0 3 1.35 3 3a.5.5 0 0 1 0 .006c0 .093-.005.197-.006.285 0 .088.01.191 0 .129A.5.5 0 0 1 14 6.5Zm-.994-2.42c-.023-.142-.013-.203-.011-.303 0-.097.005-.2.006-.277 0-1.11-.89-2-2-2-1.11 0-2 .89-2 2a1.992 1.992 0 0 0 1.998 2h.002c.132-.002.265-.017.394-.045a.5.5 0 0 1 .605.488V9c0 .667-.366 1.22-.847 1.541-.243.162-.55.156-.832.23.005.005.007.014.011.018.154.12.344.203.672.209A2.016 2.016 0 0 0 13 9V6.5ZM7 9a3.019 3.019 0 0 1-3 3 .5.5 0 0 1-.008 0c-.52-.007-.957-.173-1.277-.424-.32-.25-.519-.567-.662-.853A.5.5 0 0 1 2.5 10c.417 0 .83-.112 1.098-.291C3.866 9.53 4 9.333 4 9 2.35 9 1 7.65 1 6s1.35-3 3-3c1.651 0 3 1.35 3 3a.5.5 0 0 1 0 .006c0 .093-.005.197-.006.285 0 .088.01.191 0 .129A.5.5 0 0 1 7 6.5Zm-.994-2.42c-.023-.142-.013-.203-.011-.303 0-.097.005-.2.006-.277 0-1.11-.89-2-2-2-1.11 0-2 .89-2 2a1.992 1.992 0 0 0 1.998 2h.002c.132-.002.265-.017.394-.045A.5.5 0 0 1 5 8.443V9c0 .667-.366 1.22-.847 1.541-.243.162-.55.156-.832.23.005.005.007.014.011.018.154.12.344.203.672.209A2.016 2.016 0 0 0 6 9V6.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/receipt.svg b/comm/mail/themes/shared/mail/icons/new/compact/receipt.svg
new file mode 100644
index 0000000000..b86b340cae
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/receipt.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M13.43 2.006a.5.5 0 0 0-.33.193L7.445 9.738 3.854 6.146a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .707l4 4A.5.5 0 0 0 7.9 10.8l6-8a.5.5 0 0 0-.1-.7.5.5 0 0 0-.37-.094zm0 3a.5.5 0 0 0-.33.193l-5.655 7.539-3.591-3.592a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .707l4 4A.5.5 0 0 0 7.9 13.8l6-8a.5.5 0 0 0-.1-.7.5.5 0 0 0-.37-.094z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/redirect-col.svg b/comm/mail/themes/shared/mail/icons/new/compact/redirect-col.svg
new file mode 100644
index 0000000000..54ef23f80c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/redirect-col.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="context-fill" d="M4.5 1a.5.5 0 0 0-.354.146l-3 3a.5.5 0 0 0-.097.145.5.5 0 0 0-.02.062.5.5 0 0 0-.023.117A.5.5 0 0 0 1 4.5a.5.5 0 0 0 .006.029.5.5 0 0 0 .023.117.5.5 0 0 0 .02.063.5.5 0 0 0 .097.144l3 3a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L2.707 5H8c.563 0 1 .437 1 1v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V6c0-1.1-.9-2-2-2H2.707l2.147-2.147a.5.5 0 0 0 0-.707A.5.5 0 0 0 4.5 1Zm2 7a.5.5 0 0 0-.5.5V10c0 1.099.9 2 2 2h5.293l-2.147 2.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 .109-.162.5.5 0 0 0 .002-.006.5.5 0 0 0 .031-.25.5.5 0 0 0-.004-.015.5.5 0 0 0-.03-.112.5.5 0 0 0-.01-.025.5.5 0 0 0-.018-.033.5.5 0 0 0-.01-.016.5.5 0 0 0-.028-.039.5.5 0 0 0-.001-.002.5.5 0 0 0-.004-.006.5.5 0 0 0-.037-.04l-3-3a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .706L13.293 11H8c-.563 0-1-.438-1-1V8.5a.5.5 0 0 0-.5-.5Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/redirect.svg b/comm/mail/themes/shared/mail/icons/new/compact/redirect.svg
new file mode 100644
index 0000000000..bcbdf515b0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/redirect.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4.5.5a1 1 0 0 0-.707.293l-3 3a1 1 0 0 0 0 1.414l3 3a1 1 0 0 0 1.414-1.414L3.914 5.5H7c1 0 1.5 1 1.5 2 .5-.25.988-.648 1-1.5 0-1.368-1.132-2.5-2.5-2.5H3.914l1.293-1.293A1 1 0 0 0 4.5.5Zm7 6.996a1 1 0 0 0-.707 1.707l1.293 1.293H9c-1.5.003-1.5-1.246-1.5-2.043-.5.047-.988.668-1 1.543 0 1.367 1.132 2.5 2.5 2.5h3.086l-1.293 1.293a1 1 0 0 0 1.414 1.414l3-3a1 1 0 0 0 0-1.414l-3-3a.997.997 0 0 0-.707-.293Z" fill="context-fill"/>
+ <path d="M4.5 0a1.5 1.5 0 0 0-1.06.44l-3 3a1.504 1.504 0 0 0-.239.31h.002A1.501 1.501 0 0 0 0 4.5c0 .263.071.522.203.75H.201c.065.113.146.217.238.31l3 3c.582.582 1.54.582 2.122 0a1.508 1.508 0 0 0 0-2.12L5.121 6H7c.333 0 .53.134.709.402C7.888 6.67 8 7.083 8 7.5a.5.5 0 0 0 .723.447c.286-.143.603-.343.853-.662.25-.32.417-.757.424-1.278a.5.5 0 0 1 0-.002.5.5 0 0 1 0-.002.5.5 0 0 1 0-.002.5.5 0 0 1 0-.002c0-1.643-1.356-3-3-3H5.121l.44-.439a1.508 1.508 0 0 0 0-2.121A1.5 1.5 0 0 0 4.5-.001zm0 1a.5.5 0 0 1 .354.146c.199.2.199.508 0 .707L3.561 3.146A.5.5 0 0 0 3.914 4H7c1.09 0 1.995.906 1.998 1.996-.006.328-.088.518-.209.672-.004.005-.013.007-.018.011-.074-.282-.068-.59-.23-.832C8.22 5.366 7.667 5 7 5H3.914a.5.5 0 0 0-.353.853l1.293 1.293c.199.2.199.508 0 .707-.2.2-.508.2-.708 0l-3-3a.497.497 0 0 1-.078-.103.5.5 0 0 1-.002 0 .5.5 0 0 1 0-.5.5.5 0 0 1 .002 0 .497.497 0 0 1 .078-.104l3-3A.5.5 0 0 1 4.5 1Zm7 6c-.385 0-.77.144-1.06.435a1.508 1.508 0 0 0 0 2.121l.439.44H9a.5.5 0 0 1-.002 0c-.25 0-.483-.2-.687-.56C8.107 9.074 8 8.573 8 8.453a.5.5 0 0 0-.547-.498c-.88.081-1.439.976-1.453 2.033a.5.5 0 0 0 0 .008c0 1.644 1.356 3 3 3h1.879l-.44.44a1.508 1.508 0 0 0 0 2.12 1.5 1.5 0 0 0 2.122 0l3-3c.092-.093.173-.197.238-.31h-.002a1.5 1.5 0 0 0 .203-.75c0-.264-.071-.522-.203-.75h.002a1.506 1.506 0 0 0-.238-.31l-3-3A1.495 1.495 0 0 0 11.5 7Zm0 .994c.127 0 .254.048.354.148l3 3c.03.031.056.066.078.104a.5.5 0 0 1 .002 0 .5.5 0 0 1 0 .5.5.5 0 0 1-.002 0 .497.497 0 0 1-.078.103l-3 3a.5.5 0 0 1-.708 0 .493.493 0 0 1 0-.707l1.293-1.293a.5.5 0 0 0-.353-.853H9A2.016 2.016 0 0 1 7.002 10c.006-.37.16-.389.291-.563.065.17.048.315.146.488.296.524.812 1.072 1.561 1.07h3.086a.5.5 0 0 0 .353-.853l-1.293-1.293a.493.493 0 0 1 0-.707c.1-.1.227-.148.354-.148z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/remove.svg b/comm/mail/themes/shared/mail/icons/new/compact/remove.svg
new file mode 100644
index 0000000000..3337f2873d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/remove.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3.5 7a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/reply-all.svg b/comm/mail/themes/shared/mail/icons/new/compact/reply-all.svg
new file mode 100644
index 0000000000..9431f02eca
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/reply-all.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M11.502 1.5a1 1 0 0 0-.709.293l-3 3a1.002 1.002 0 0 0-.147.191 1.015 1.015 0 0 0-.04.086.996.996 0 0 0-.057.133.998.998 0 0 0-.024.105 1 1 0 0 0-.02.147.996.996 0 0 0-.005.045l.006.045a1 1 0 0 0 .02.148 1 1 0 0 0 .023.1 1 1 0 0 0 .058.14.996.996 0 0 0 .04.082 1 1 0 0 0 .146.192.999.999 0 0 0 .191.146.98.98 0 0 0 .079.037.854.854 0 0 0 .23.082.996.996 0 0 0 .164.022L8.5 6.5h2.25c.75 0 1.75 1.25 1.75 2v1a1 1 0 0 0 2 0v-1c0-1.25-2-4-3-4h-.586l1.293-1.293a1 1 0 0 0-.705-1.707ZM4.5 5.5a1 1 0 0 0-.707.293l-3 3A1 1 0 0 0 .635 9a1 1 0 0 0-.135.5 1 1 0 0 0 .135.5 1 1 0 0 0 .158.207l3 3a1 1 0 0 0 1.414 0 1 1 0 0 0 0-1.414L3.914 10.5H8c.294 0 .5.205.5.5v2.5a1 1 0 0 0 1 1 1 1 0 0 0 1-1V11c0-1.368-1.132-2.5-2.5-2.5H3.914l1.293-1.293a1 1 0 0 0 0-1.414A1 1 0 0 0 4.5 5.5Z" fill="context-fill"/>
+ <path d="M11.504 1a1.51 1.51 0 0 0-.17.01.5.5 0 0 0-.002 0 1.497 1.497 0 0 0-.893.43l-3 3a.5.5 0 0 0-.003.003c-.085.086-.16.183-.221.287a.5.5 0 0 0-.01.018c-.016.03-.03.062-.045.093l-.008.018-.007.018a1.32 1.32 0 0 0-.069.164.5.5 0 0 0-.01.027 1.496 1.496 0 0 0-.03.139v.006a1.37 1.37 0 0 0-.03.212l-.004.022a.5.5 0 0 0 0 .105c.002.023.006.046.01.069a1.5 1.5 0 0 0 .021.162v.004l.002.008c.009.046.02.091.033.136a.5.5 0 0 0 .006.018c.024.072.054.143.088.21.014.032.027.063.043.093a.5.5 0 0 0 .01.017c.061.104.136.2.22.287a.5.5 0 0 0 .008.008c.087.085.183.16.287.22a.5.5 0 0 0 .02.01c.027.015.056.029.084.042l.02.01c.005.001.01.005.015.007.062.03.124.057.19.078a.5.5 0 0 0 .01.002 1.517 1.517 0 0 0 .318.059l.045.006A.5.5 0 0 0 8.5 7h2c.334 0 .725.18 1.021.476.297.296.479.686.479 1.024v1c0 .822.677 1.5 1.5 1.5s1.5-.678 1.5-1.5v-1c0-1.105-.34-2.211-1.027-3.065-.496-.615-1.238-.996-2.075-1.213l.663-.662a1.508 1.508 0 0 0 0-2.12A1.497 1.497 0 0 0 11.504 1Zm-.06 1v.002h.001a.502.502 0 0 1 .409.851l-1.293 1.293a.5.5 0 0 0 .353.854c1.025 0 1.758.418 2.28 1.064.52.647.806 1.54.806 2.436v1c0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5v-1c0-.669-.318-1.278-.771-1.73C11.775 6.316 11.166 6 10.5 6H8.537l-.01-.002a.5.5 0 0 0-.047-.004.454.454 0 0 1-.082-.01.5.5 0 0 0-.007-.002l-.024-.006a.48.48 0 0 1-.076-.031.5.5 0 0 0-.035-.016l-.018-.007-.002-.002a.495.495 0 0 1-.09-.067.495.495 0 0 1-.066-.088l-.002-.004a.35.35 0 0 1-.01-.021.5.5 0 0 0-.015-.033.505.505 0 0 1-.026-.06.516.516 0 0 1-.01-.044.5.5 0 0 0-.001-.012.5.5 0 0 1-.01-.074.5.5 0 0 0-.002-.017.5.5 0 0 0 .002-.016.484.484 0 0 1 .01-.072.5.5 0 0 0 .004-.022.5.5 0 0 1 .01-.043.496.496 0 0 1 .02-.049.5.5 0 0 0 .016-.037l.012-.025.002-.004a.493.493 0 0 1 .066-.088l.004-.004 2.996-2.996A.503.503 0 0 1 11.443 2ZM4.5 5a1.5 1.5 0 0 0-1.06.44l-3 3a1.504 1.504 0 0 0-.239.31h.002A1.501 1.501 0 0 0 0 9.5c0 .263.071.522.203.75H.201c.065.113.146.217.238.31l3 3c.582.582 1.54.582 2.122 0a1.508 1.508 0 0 0 0-2.12l-.44-.44H8v2.5c0 .822.677 1.5 1.5 1.5s1.5-.678 1.5-1.5V11c0-1.644-1.356-3-3-3H5.121l.44-.44a1.508 1.508 0 0 0 0-2.12A1.5 1.5 0 0 0 4.5 5Zm0 1a.5.5 0 0 1 .354.146c.199.2.199.508 0 .707L3.561 8.146A.5.5 0 0 0 3.914 9H8c1.092 0 2 .908 2 2v2.5c0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5V11c0-.528-.473-1-1-1H3.914a.5.5 0 0 0-.353.853l1.293 1.293c.199.2.199.508 0 .707-.2.2-.508.2-.708 0l-3-3a.495.495 0 0 1-.078-.103.5.5 0 0 0-.002 0 .499.499 0 0 1 0-.5.5.5 0 0 0 .002 0 .496.496 0 0 1 .078-.104l3-3A.5.5 0 0 1 4.5 6Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/reply-col.svg b/comm/mail/themes/shared/mail/icons/new/compact/reply-col.svg
new file mode 100644
index 0000000000..da2513d26d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/reply-col.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="context-fill" d="M7.422 4.006a.5.5 0 0 0-.276.14l-3 3a.5.5 0 0 0-.097.145.5.5 0 0 0-.02.062.5.5 0 0 0-.023.117A.5.5 0 0 0 4 7.5a.5.5 0 0 0 .006.029.5.5 0 0 0 .023.117.5.5 0 0 0 .02.063.5.5 0 0 0 .097.144l3 3a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L5.707 8H11c.563 0 1 .437 1 1v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V9c0-1.1-.9-2-2-2H5.707l2.147-2.147a.5.5 0 0 0 0-.707.5.5 0 0 0-.432-.14z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/reply-forward-col.svg b/comm/mail/themes/shared/mail/icons/new/compact/reply-forward-col.svg
new file mode 100644
index 0000000000..6a3faebe25
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/reply-forward-col.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="context-stroke" d="M11.422 1.006a.5.5 0 0 0-.276.14.5.5 0 0 0 0 .707L13.293 4H8c-1.1 0-2 .9-2 2v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V6c0-.563.437-1 1-1h5.293l-2.147 2.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 .105-.156.5.5 0 0 0 .004-.006.5.5 0 0 0 .002-.006A.5.5 0 0 0 15 4.5a.5.5 0 0 0-.004-.065.5.5 0 0 0-.004-.015.5.5 0 0 0-.01-.05.5.5 0 0 0-.017-.052.5.5 0 0 0-.002-.01.5.5 0 0 0-.012-.025.5.5 0 0 0-.017-.033.5.5 0 0 0-.01-.016.5.5 0 0 0-.028-.039.5.5 0 0 0-.005-.008.5.5 0 0 0-.037-.04l-3-3a.5.5 0 0 0-.432-.141Z"/>
+ <path fill="context-fill" d="M4.422 7.006a.5.5 0 0 0-.276.14l-3 3a.5.5 0 0 0-.097.145.5.5 0 0 0-.02.062.5.5 0 0 0-.023.117.5.5 0 0 0-.006.03.5.5 0 0 0 .006.029.5.5 0 0 0 .023.117.5.5 0 0 0 .02.063.5.5 0 0 0 .097.144l3 3a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L2.707 11H8c.563 0 1 .437 1 1v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12c0-1.1-.9-2-2-2H2.707l2.147-2.147a.5.5 0 0 0 0-.707.5.5 0 0 0-.432-.14z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/reply-forward-redirect-col.svg b/comm/mail/themes/shared/mail/icons/new/compact/reply-forward-redirect-col.svg
new file mode 100644
index 0000000000..113ce0d938
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/reply-forward-redirect-col.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="context-fill" d="M3.422.006a.5.5 0 0 0-.276.14l-3 3a.5.5 0 0 0-.021.03.5.5 0 0 0-.049.066.5.5 0 0 0-.035.07.5.5 0 0 0-.021.067.5.5 0 0 0-.014.085A.5.5 0 0 0 0 3.5a.5.5 0 0 0 .006.035.5.5 0 0 0 .014.086.5.5 0 0 0 .021.064.5.5 0 0 0 .04.074.5.5 0 0 0 .04.059.5.5 0 0 0 .025.035l3 3a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L1.707 4H6c.563 0 1 .437 1 1v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V5c0-1.1-.9-2-2-2H1.707L3.854.853a.5.5 0 0 0 0-.707.5.5 0 0 0-.432-.14z"/>
+ <path fill="#ff9400" d="M5.5 9a.5.5 0 0 0-.5.5V11c0 1.099.9 2 2 2h4.293l-2.147 2.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 .05-.06.5.5 0 0 0 .07-.139A.5.5 0 0 0 13 12.5a.5.5 0 0 0-.004-.06.5.5 0 0 0-.008-.052.5.5 0 0 0-.002-.007.5.5 0 0 0-.002-.006.5.5 0 0 0-.017-.053.5.5 0 0 0-.016-.04.5.5 0 0 0-.008-.015.5.5 0 0 0-.02-.033.5.5 0 0 0-.01-.018.5.5 0 0 0-.022-.029.5.5 0 0 0-.004-.006.5.5 0 0 0-.033-.035l-.022-.021-2.978-2.979a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .707L11.293 12H7c-.563 0-1-.438-1-1V9.5a.5.5 0 0 0-.5-.5Zm6.922.006a.5.5 0 0 0-.276.14.5.5 0 0 0 0 .707l2.647 2.647-2.647 2.646a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 0-.707l-3-3a.5.5 0 0 0-.432-.14z"/>
+ <path fill="context-stroke" d="M12.422.006a.5.5 0 0 0-.276.14.5.5 0 0 0 0 .707L14.293 3H11c-1.1 0-2 .9-2 2v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V5c0-.563.437-1 1-1h3.293l-2.147 2.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 .05-.06.5.5 0 0 0 .07-.139A.5.5 0 0 0 16 3.5a.5.5 0 0 0-.146-.354l-3-3a.5.5 0 0 0-.432-.14z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/reply-list.svg b/comm/mail/themes/shared/mail/icons/new/compact/reply-list.svg
new file mode 100644
index 0000000000..aadb673ffb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/reply-list.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3.5 2.5c-.554 0-1 .446-1 1v9c0 .554.446 1 1 1h4.879l-1.94-1.94a1.516 1.516 0 0 1 0-2.12l3-3a1.501 1.501 0 0 1 2.122 0 1.515 1.515 0 0 1 0 2.12l-.44.44H12.5V3.5c0-.554-.446-1-1-1z" fill="context-fill"/>
+ <path d="M4 2c-1.1 0-2 .9-2 2v8c0 1.099.9 2 2 2h5l-1-1H4c-.563 0-1-.438-1-1V4c0-.563.437-1 1-1h7c.563 0 1 .437 1 1v5h1V4c0-1.1-.9-2-2-2Zm1.5 3a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1zm5 0a.5.5 0 0 0-.354.146l-3 3a.5.5 0 0 0 0 .707l3 3a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L8.707 11H13c.563 0 1 .437 1 1v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12c0-1.1-.9-2-2-2H8.707l2.147-2.147a.5.5 0 0 0 0-.707A.5.5 0 0 0 10.5 7Zm-5 2a.5.5 0 1 0 0 1 .5.5 0 0 0 0-1zm6.5 3v1.728c.596-.347 1-.993 1-1.728Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/reply-redirect-col.svg b/comm/mail/themes/shared/mail/icons/new/compact/reply-redirect-col.svg
new file mode 100644
index 0000000000..135009e5f0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/reply-redirect-col.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="context-fill" d="M4.422 7.006a.5.5 0 0 0-.276.14l-3 3a.5.5 0 0 0-.097.145.5.5 0 0 0-.02.062.5.5 0 0 0-.023.117.5.5 0 0 0-.006.03.5.5 0 0 0 .006.029.5.5 0 0 0 .023.117.5.5 0 0 0 .02.063.5.5 0 0 0 .097.144l3 3a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L2.707 11H8c.563 0 1 .437 1 1v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V12c0-1.1-.9-2-2-2H2.707l2.147-2.147a.5.5 0 0 0 0-.707.5.5 0 0 0-.432-.14z"/>
+ <path fill="context-stroke" d="M4.5 1a.5.5 0 0 0-.5.5V3c0 1.099.9 2 2 2h4.293L8.146 7.146a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 .05-.06.5.5 0 0 0 .07-.139A.5.5 0 0 0 12 4.5a.5.5 0 0 0-.004-.06.5.5 0 0 0-.008-.052.5.5 0 0 0-.002-.007.5.5 0 0 0-.002-.006.5.5 0 0 0-.017-.053.5.5 0 0 0-.016-.04.5.5 0 0 0-.008-.015.5.5 0 0 0-.02-.033.5.5 0 0 0-.01-.018.5.5 0 0 0-.022-.029.5.5 0 0 0-.004-.006.5.5 0 0 0-.033-.035l-.022-.021-2.978-2.979a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .707L10.293 4H6c-.563 0-1-.438-1-1V1.5a.5.5 0 0 0-.5-.5Zm6.922.006a.5.5 0 0 0-.276.14.5.5 0 0 0 0 .707L13.793 4.5l-2.647 2.646a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 0-.707l-3-3a.5.5 0 0 0-.432-.14z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/reply.svg b/comm/mail/themes/shared/mail/icons/new/compact/reply.svg
new file mode 100644
index 0000000000..d4f1d0c63a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/reply.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5 3.5a1 1 0 0 0-.707.293l-3 3A1 1 0 0 0 3.635 7a1 1 0 0 0-.135.5 1 1 0 0 0 .135.5 1 1 0 0 0 .158.207l3 3a1 1 0 0 0 1.414 0 1 1 0 0 0 0-1.414L6.914 8.5H11c.294 0 .5.205.5.5v2.5a1 1 0 0 0 1 1 1 1 0 0 0 1-1V9c0-1.368-1.132-2.5-2.5-2.5H6.914l1.293-1.293a1 1 0 0 0 0-1.414A1 1 0 0 0 7.5 3.5Z" fill="context-fill"/>
+ <path d="M7.5 3a1.5 1.5 0 0 0-1.06.439l-3 3a1.504 1.504 0 0 0-.239.31h.002a1.501 1.501 0 0 0-.203.75c0 .264.071.523.203.75h-.002c.065.114.146.218.238.311l3 3c.582.582 1.54.582 2.122 0a1.508 1.508 0 0 0 0-2.121l-.44-.44H11v2.5c0 .823.677 1.5 1.5 1.5s1.5-.677 1.5-1.5v-2.5c0-1.643-1.356-3-3-3H8.121l.44-.439a1.508 1.508 0 0 0 0-2.121 1.5 1.5 0 0 0-1.061-.44Zm0 1a.5.5 0 0 1 .354.146c.199.2.199.508 0 .707L6.561 6.146A.5.5 0 0 0 6.914 7H11c1.092 0 2 .908 2 2v2.5c0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5V9c0-.528-.473-1-1-1H6.914a.5.5 0 0 0-.353.853l1.293 1.293c.199.2.199.508 0 .707-.2.2-.508.2-.708 0l-3-3a.495.495 0 0 1-.078-.103.5.5 0 0 0-.002 0 .499.499 0 0 1 0-.5.5.5 0 0 0 .002 0 .496.496 0 0 1 .078-.104l3-3A.5.5 0 0 1 7.5 4Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/restore.svg b/comm/mail/themes/shared/mail/icons/new/compact/restore.svg
new file mode 100644
index 0000000000..2b170e1b58
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/restore.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.85 2a6.013 6.013 0 0 0-5.623 4.363c-.778 2.742.524 5.39 2.925 6.637H3.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5v1.797c-2.287-.946-3.506-3.209-2.81-5.66a4.992 4.992 0 0 1 6.173-3.448 4.992 4.992 0 0 1 3.448 6.174c-.352 1.241-1.617 2.231-2.534 2.69a.5.5 0 0 0-.224.671.5.5 0 0 0 .67.223c1.083-.542 2.574-1.63 3.05-3.31A6.007 6.007 0 0 0 7.85 2z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/ribbon.svg b/comm/mail/themes/shared/mail/icons/new/compact/ribbon.svg
new file mode 100644
index 0000000000..3f387d8c2f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/ribbon.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="m4.035 8.5-2.5 4.33 2.733-.733L5 14.83l2.5-4.33Zm7.447 0-3.464 2 2.5 4.33.732-2.733 2.732.733z" fill="context-fill"/>
+ <path d="M8 1c-.518 0-.878.313-1.129.554-.25.242-.45.435-.566.485-.118.05-.369.052-.696.052-.327 0-.788.017-1.144.373-.356.357-.373.818-.373 1.145 0 .327-.003.578-.053.695-.05.118-.243.318-.484.569C3.313 5.123 3 5.482 3 6c0 .517.313.878.555 1.129.241.25.434.449.484.566.027.065.04.174.047.31a.5.5 0 0 0-.482.245l-2.5 4.33a.5.5 0 0 0 .562.732l2.248-.601.604 2.248a.5.5 0 0 0 .916.12l2.365-4.097a1.245 1.245 0 0 0 .31.012l2.36 4.086a.5.5 0 0 0 .916-.121l.603-2.248 2.248.601a.5.5 0 0 0 .563-.732l-2.5-4.33a.5.5 0 0 0-.385-.239c.006-.14.02-.25.047-.316.05-.117.243-.316.484-.566.242-.251.555-.612.555-1.13 0-.517-.313-.875-.555-1.126-.241-.251-.434-.451-.484-.569-.05-.117-.053-.368-.053-.695 0-.327-.017-.788-.373-1.145-.356-.355-.817-.372-1.144-.373-.327 0-.578-.003-.696-.052-.117-.05-.315-.243-.566-.485C8.879 1.313 8.518 1 8 1Zm0 1c.103 0 .224.074.434.275.209.202.45.507.87.686.422.178.81.13 1.087.13.277 0 .386.03.437.08.051.052.08.16.08.438s-.047.665.131 1.086c.178.421.484.662.686.871.201.21.275.33.275.434 0 .103-.074.224-.275.433-.202.21-.508.45-.686.871-.178.421-.13.809-.13 1.086 0 .278-.03.387-.08.438-.052.051-.16.08-.438.08s-.665-.047-1.086.13c-.421.179-.662.485-.871.686-.21.202-.33.276-.434.276-.103 0-.224-.074-.434-.276-.209-.201-.45-.507-.87-.685-.422-.178-.81-.131-1.087-.131-.277 0-.386-.029-.437-.08-.051-.051-.08-.16-.08-.438 0-.277.047-.665-.131-1.086-.178-.42-.484-.662-.686-.87C4.074 6.224 4 6.104 4 6c0-.104.074-.225.275-.434.202-.21.508-.45.686-.871.178-.421.13-.808.13-1.086s.03-.386.08-.437c.052-.052.16-.08.438-.08s.665.047 1.086-.131c.421-.179.662-.484.871-.686C7.776 2.074 7.896 2 8 2Zm0 1C6.35 3 5 4.349 5 6c0 1.65 1.35 3 3 3s3-1.35 3-3c0-1.651-1.35-3-3-3Zm0 1c1.11 0 2 .89 2 2 0 1.11-.89 2-2 2-1.11 0-2-.89-2-2 0-1.11.89-2 2-2ZM4.223 9.177c.055.125.13.247.242.358.356.356.817.373 1.144.373.327 0 .578.003.696.053.117.05.315.242.566.484.016.016.037.033.055.049l-1.756 3.04-.42-1.566a.5.5 0 0 0-.611-.353l-1.567.42 1.65-2.858zm7.51.092 1.597 2.766-1.566-.42a.5.5 0 0 0-.614.353l-.42 1.569-1.72-2.985c.04-.036.083-.073.119-.107.25-.242.45-.435.566-.484.118-.05.369-.053.696-.053.327 0 .788-.017 1.144-.373.084-.084.147-.174.197-.266z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/rss.svg b/comm/mail/themes/shared/mail/icons/new/compact/rss.svg
new file mode 100644
index 0000000000..655453e5d1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/rss.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 1.5c-.5 0-1.5 0-1.5 1s1 1 1.5 1c4.5 0 8.5 3 8.5 8.5 0 .5 0 1.5 1 1.5s.992-1 .992-1.5C14.492 4.5 9.5 1.5 4 1.5Zm0 4c-.5 0-1.5 0-1.5 1s1 1 1.5 1c2.5.035 4.5 2 4.5 4.5 0 .5 0 1.5 1 1.5s1.007-1 1-1.5C10.5 8 7 5.5 4 5.5Zm0 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z" fill="context-fill"/>
+ <path d="M4 1c-.25 0-.682-.016-1.123.16C2.437 1.336 2 1.833 2 2.5s.436 1.164.877 1.34C3.317 4.016 3.75 4 4 4c2.139 0 4.14.711 5.6 2.055C11.06 7.398 12 9.37 12 12c0 .25-.016.682.16 1.123.176.44.673.877 1.34.877s1.162-.438 1.336-.879c.174-.441.156-.874.156-1.121 0-3.86-1.306-6.653-3.355-8.44C9.587 1.775 6.84 1 4 1Zm0 1c2.66 0 5.158.726 6.98 2.314 1.823 1.59 3.012 4.047 3.012 7.686 0 .253-.013.57-.086.754-.072.184-.073.246-.406.246s-.336-.064-.41-.248c-.074-.184-.09-.502-.09-.752 0-2.871-1.06-5.148-2.725-6.68C8.61 3.79 6.361 3 4 3c-.25 0-.568-.016-.752-.09C3.064 2.836 3 2.833 3 2.5c0-.333.064-.336.248-.41C3.432 2.016 3.75 2 4 2Zm0 3c-.25 0-.682-.016-1.123.16C2.437 5.336 2 5.833 2 6.5s.436 1.164.877 1.34c.438.175.864.16 1.115.16C6.234 8.031 8 9.767 8 12c0 .25-.016.682.16 1.123.176.44.673.877 1.34.877s1.164-.434 1.342-.875c.177-.44.162-.871.158-1.129C10.998 7.706 7.277 5 4 5Zm0 1c2.722 0 6 2.292 6 6a.5.5 0 0 0 0 .008c.004.242-.012.558-.086.742-.074.184-.08.25-.414.25-.333 0-.336-.064-.41-.248C9.016 12.568 9 12.25 9 12c0-2.767-2.234-4.961-4.992-5A.5.5 0 0 0 4 7c-.25 0-.568-.016-.752-.09C3.064 6.836 3 6.833 3 6.5c0-.333.064-.336.248-.41C3.432 6.016 3.75 6 4 6Zm0 3c-1.65 0-3 1.35-3 3s1.35 3 3 3 3-1.35 3-3-1.35-3-3-3Zm0 1c1.11 0 2 .89 2 2 0 1.11-.89 2-2 2-1.11 0-2-.89-2-2 0-1.11.89-2 2-2z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/search.svg b/comm/mail/themes/shared/mail/icons/new/compact/search.svg
new file mode 100644
index 0000000000..cb1a815f57
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/search.svg
@@ -0,0 +1,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" viewBox="0 0 16 16" fill-opacity="context-fill-opacity" height="16" width="16">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M6.5 2C4.02 2 2 4.02 2 6.5S4.02 11 6.5 11c1.06 0 2.034-.371 2.805-.988l3.841 3.842a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.708l-3.842-3.841C10.629 8.535 11 7.56 11 6.5 11 4.02 8.98 2 6.5 2zm0 1C8.439 3 10 4.561 10 6.5S8.439 10 6.5 10A3.492 3.492 0 0 1 3 6.5C3 4.561 4.561 3 6.5 3z" fill="context-fill transparent"/>
+ <path d="M6.5 2C4.02 2 2 4.02 2 6.5S4.02 11 6.5 11c1.06 0 2.034-.371 2.805-.988l3.841 3.842a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.708l-3.842-3.841C10.629 8.535 11 7.56 11 6.5 11 4.02 8.98 2 6.5 2zm0 1C8.439 3 10 4.561 10 6.5S8.439 10 6.5 10A3.492 3.492 0 0 1 3 6.5C3 4.561 4.561 3 6.5 3z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/sent.svg b/comm/mail/themes/shared/mail/icons/new/compact/sent.svg
new file mode 100644
index 0000000000..8807e5246c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/sent.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M8.39 13.529c.073.544.558.971 1.11.971s.946-.432 1.178-.945L14.09 3.91c.175-.535.41-.91.41-1.41a1 1 0 0 0-1-1c-.552 0-.967.17-1.608.43L2.5 5.321c-.559.15-1 .632-1 1.184 0 .552.422 1.009.96 1.082L6.5 9.5Z" fill="context-fill"/>
+ <path d="M13.5 1c-.654 0-1.153.206-1.791.463l-.004.002L2.33 4.852C1.595 5.066 1 5.702 1 6.506c0 .824.625 1.405 1.303 1.56l-.057-.027.121.041a1.688 1.688 0 0 1-.064-.014l3.82 1.809 1.795 3.822-.016-.054.035.097-.019-.043c.087.322.264.618.518.858A1.56 1.56 0 0 0 9.5 15c.813 0 1.359-.63 1.635-1.24a.5.5 0 0 0 .015-.04l3.412-9.642a.5.5 0 0 0 .004-.014C14.721 3.59 15 3.157 15 2.5c0-.823-.677-1.5-1.5-1.5Zm0 1c.282 0 .5.218.5.5 0 .343-.19.66-.385 1.256l-3.394 9.596c-.188.413-.43.648-.721.648a.556.556 0 0 1-.377-.172.918.918 0 0 1-.246-.414.5.5 0 0 0-.035-.098l-1.74-3.71 2.752-2.752a.5.5 0 0 0 0-.708.5.5 0 0 0-.708 0L6.395 8.898 2.674 7.137a.5.5 0 0 0-.121-.04C2.324 7.055 2 6.76 2 6.507c0-.286.265-.604.629-.701a.5.5 0 0 0 .04-.012L12.063 2.4a.5.5 0 0 0 .016-.007c.643-.26.973-.393 1.422-.393Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/settings.svg b/comm/mail/themes/shared/mail/icons/new/compact/settings.svg
new file mode 100644
index 0000000000..e10ee5a77e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/settings.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M7.5.5c-.554 0-1 .446-1 1v1.213A5.5 5.5 0 0 0 4.182 4.06l-1.057-.612a.999.999 0 0 0-1.367.365l-.5.868a.997.997 0 0 0 .367 1.365l1.053.607A5.5 5.5 0 0 0 2.5 8a5.5 5.5 0 0 0 .178 1.338l-1.053.607a.997.997 0 0 0-.367 1.366l.5.867a.999.999 0 0 0 1.367.365l1.053-.607A5.5 5.5 0 0 0 6.5 13.287v1.205c0 .554.446 1 1 1h1c.554 0 1-.446 1-1v-1.205a5.5 5.5 0 0 0 2.322-1.351l1.053.607a.999.999 0 0 0 1.367-.365l.5-.867a.997.997 0 0 0-.367-1.366l-1.053-.607A5.5 5.5 0 0 0 13.5 8a5.5 5.5 0 0 0-.168-1.352l1.043-.601a.997.997 0 0 0 .367-1.365l-.5-.868a.999.999 0 0 0-1.367-.365l-1.047.604A5.5 5.5 0 0 0 9.5 2.709V1.5c0-.554-.446-1-1-1zm.5 6A1.5 1.5 0 0 1 9.5 8 1.5 1.5 0 0 1 8 9.5 1.5 1.5 0 0 1 6.5 8 1.5 1.5 0 0 1 8 6.5Z" fill="context-fill"/>
+ <path d="M7.5 0C6.678 0 6 .678 6 1.5v.943A5.957 5.957 0 0 0 4.197 3.49l-.822-.474a1.51 1.51 0 0 0-2.05.548l-.5.868a1.508 1.508 0 0 0 .55 2.048l.766.442c-.068.355-.138.712-.141 1.074a.5.5 0 0 0 0 .008c.003.36.073.714.14 1.066l-.765.442a1.508 1.508 0 0 0-.55 2.049l.5.867a1.51 1.51 0 0 0 2.05.549l.816-.471A5.955 5.955 0 0 0 6 13.556v.936c0 .823.678 1.5 1.5 1.5h1c.822 0 1.5-.677 1.5-1.5v-.935a5.955 5.955 0 0 0 1.809-1.051l.816.47a1.51 1.51 0 0 0 2.05-.548l.5-.867a1.508 1.508 0 0 0-.55-2.05l-.766-.44c.068-.353.138-.708.141-1.067A.5.5 0 0 0 14 8c0-.365-.069-.724-.135-1.082l.76-.438c.712-.41.962-1.336.55-2.048l-.5-.868a1.51 1.51 0 0 0-2.05-.548l-.813.468A5.95 5.95 0 0 0 10 2.438V1.5C10 .678 9.322 0 8.5 0Zm0 1h1c.286 0 .5.214.5.5v1.209a.5.5 0 0 0 .363.48 5.005 5.005 0 0 1 2.117 1.223.5.5 0 0 0 .598.074l1.047-.603a.489.489 0 0 1 .684.181l.5.868a.487.487 0 0 1-.184.681l-1.043.602a.5.5 0 0 0-.234.556c.101.402.152.813.152 1.227a4.99 4.99 0 0 1-.162 1.213.5.5 0 0 0 .234.56l1.053.608a.487.487 0 0 1 .184.682l-.5.867a.489.489 0 0 1-.684.181l-1.053-.607a.5.5 0 0 0-.6.076 5 5 0 0 1-2.11 1.229.5.5 0 0 0-.362.48v1.205c0 .286-.214.5-.5.5h-1a.488.488 0 0 1-.5-.5v-1.205a.5.5 0 0 0-.361-.48 5 5 0 0 1-2.112-1.229.5.5 0 0 0-.6-.076l-1.052.607a.489.489 0 0 1-.684-.181l-.5-.867a.487.487 0 0 1 .184-.682l1.053-.608a.5.5 0 0 0 .234-.56A4.989 4.989 0 0 1 3 8.004v-.008c.004-.41.059-.818.162-1.215a.5.5 0 0 0-.234-.56l-1.053-.608a.487.487 0 0 1-.184-.681l.5-.868a.489.489 0 0 1 .684-.181l1.057.611a.5.5 0 0 0 .6-.076 4.998 4.998 0 0 1 2.107-1.225.5.5 0 0 0 .361-.48V1.5c0-.286.214-.5.5-.5ZM8 6c-1.099 0-2 .901-2 2s.901 2 2 2 2-.901 2-2-.901-2-2-2Zm0 1c.558 0 1 .442 1 1s-.442 1-1 1-1-.442-1-1 .442-1 1-1Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/shield.svg b/comm/mail/themes/shared/mail/icons/new/compact/shield.svg
new file mode 100644
index 0000000000..0edf1e1e7a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/shield.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8 .498a1 1 0 0 0-.48.123l-5.5 3c-.32.176-.52.514-.52.879 0 7 1 8.478 6.02 10.879a.998.998 0 0 0 .96 0C13.5 12.979 14.5 11.5 14.5 4.5c0-.365-.2-.703-.52-.879l-5.5-3A1 1 0 0 0 8 .498ZM7.5 3v10c-3.45-1.65-3.969-2.595-3.969-7.406 0-.251.138-.483.358-.604Z" fill="context-fill"/>
+ <path d="M8-.002c-.248 0-.496.06-.72.184l-5.5 3A1.5 1.5 0 0 0 1 4.5c0 3.524.239 5.732 1.193 7.412.951 1.674 2.57 2.7 5.09 3.906a1.5 1.5 0 0 0 1.434 0c2.52-1.206 4.139-2.232 5.09-3.906C14.76 10.232 15 8.024 15 4.5a1.502 1.502 0 0 0-.78-1.318l-5.5-3A1.499 1.499 0 0 0 8-.002ZM8 1a.5.5 0 0 1 .24.06l5.5 3c.16.088.26.258.26.44 0 3.476-.262 5.509-1.063 6.918-.8 1.41-2.186 2.321-4.671 3.51a.5.5 0 0 0-.026.011.502.502 0 0 1-.48 0 .5.5 0 0 0-.026-.011c-2.485-1.189-3.87-2.1-4.671-3.51C2.261 10.008 2 7.976 2 4.5c0-.182.1-.352.26-.44l5.5-3A.5.5 0 0 1 8 1Zm-.545 1.502a.5.5 0 0 0-.197.06l-3.61 1.99a1.19 1.19 0 0 0-.617 1.042c0 2.423.114 3.94.75 5.125.636 1.184 1.754 1.895 3.504 2.732A.5.5 0 0 0 8 13V3a.5.5 0 0 0-.545-.498ZM7 3.846v8.3c-1.16-.613-1.94-1.16-2.338-1.9-.485-.903-.63-2.264-.63-4.652 0-.068.038-.132.097-.164a.5.5 0 0 0 .002-.002Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/shortcut.svg b/comm/mail/themes/shared/mail/icons/new/compact/shortcut.svg
new file mode 100644
index 0000000000..a31a815e2c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/shortcut.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5A2.495 2.495 0 0 0 1.5 4v8c0 1.385 1.115 2.5 2.5 2.5h8c1.385 0 2.5-1.115 2.5-2.5V4c0-1.385-1.115-2.5-2.5-2.5Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.654-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2v8c0 1.116-.883 2-2 2H4c-1.117 0-2-.884-2-2V4c0-1.117.883-2 2-2Zm1 1c-1.099 0-2 .901-2 2 0 1.098.901 2 2 2h1v2H5c-1.099 0-2 .901-2 2 0 1.098.901 2 2 2s2-.902 2-2v-1h2v1c0 1.098.901 2 2 2s2-.902 2-2c0-1.099-.901-2-2-2h-1V7h1c1.099 0 2-.902 2-2 0-1.099-.901-2-2-2s-2 .901-2 2v1H7V5c0-1.099-.901-2-2-2Zm0 1c.558 0 1 .441 1 1v1H5c-.558 0-1-.442-1-1 0-.559.442-1 1-1Zm6 0c.558 0 1 .441 1 1 0 .558-.442 1-1 1h-1V5c0-.559.442-1 1-1ZM7 7h2v2H7Zm-2 3h1v1c0 .558-.442 1-1 1s-1-.442-1-1c0-.559.442-1 1-1Zm5 0h1c.558 0 1 .441 1 1 0 .558-.442 1-1 1s-1-.442-1-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/sort.svg b/comm/mail/themes/shared/mail/icons/new/compact/sort.svg
new file mode 100644
index 0000000000..f735ba446a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/sort.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3.5 3A1.5 1.5 0 0 0 2 4.5 1.5 1.5 0 0 0 3.5 6 1.5 1.5 0 0 0 5 4.5 1.5 1.5 0 0 0 3.5 3Zm0 1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Zm3 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm-3 3A1.5 1.5 0 0 0 2 8.5 1.5 1.5 0 0 0 3.5 10 1.5 1.5 0 0 0 5 8.5 1.5 1.5 0 0 0 3.5 7Zm0 1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Zm3 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm-3 3A1.5 1.5 0 0 0 2 12.5 1.5 1.5 0 0 0 3.5 14 1.5 1.5 0 0 0 5 12.5 1.5 1.5 0 0 0 3.5 11Zm0 1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Zm3 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/spaces-menu.svg b/comm/mail/themes/shared/mail/icons/new/compact/spaces-menu.svg
new file mode 100644
index 0000000000..a4fa781a81
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/spaces-menu.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5A2.495 2.495 0 0 0 1.5 4v8c0 1.385 1.115 2.5 2.5 2.5h1.502v-13z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 1.653 1.347 3 3 3h8c1.653 0 3-1.347 3-3V4c0-1.654-1.347-3-3-3Zm0 1h1v12H4c-1.117 0-2-.884-2-2V4c0-1.117.883-2 2-2Zm2 0h6c1.117 0 2 .883 2 2v8c0 1.116-.883 2-2 2H6ZM3.5 4a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm4 0c-.277 0-.5.196-.5.44v.12c0 .243.223.44.5.44h4c.277 0 .5-.197.5-.44v-.12c0-.244-.223-.44-.5-.44Zm-4 3a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm4 0c-.277 0-.5.205-.5.459v.082c0 .254.223.459.5.459h4c.277 0 .5-.205.5-.46v-.081c0-.254-.223-.46-.5-.46h-4zm-4 3a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm4 0a.5.5 0 1 0 0 1h4a.5.5 0 1 0 0-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/spam.svg b/comm/mail/themes/shared/mail/icons/new/compact/spam.svg
new file mode 100644
index 0000000000..1c7f5d78d7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/spam.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill="none" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7 1.5c.146.5.5 1.912.5 2.5 0 .89-.187 1.374-.5 2-.298.596-.294 1-1 1-.553 0-.707-.082-1-.5-.157-.223-.393-1.21-.5-1.5-1 .5-2 3.109-2 4.5 0 1.749 1 4.5 3 5a3.674 3.674 0 0 1-.003-.16c0-1.547.524-3.34 2.003-3.34 1.48 0 2.003 1.793 2.003 3.34 0 .054 0 .107-.003.16 2-.5 3-3.251 3-5 0-.115-.004-.228-.01-.341a.5.5 0 0 1 .01-.143C12.145 4.868 10 2.5 7 1.5Z" fill="context-fill"/>
+ <path d="M6.432 1.234a.5.5 0 0 1 .51-.227c1.658.289 5.577 2.165 6.056 7.916a6.695 6.695 0 0 1 .002.542c0 2.533-1.444 4.73-3.527 5.602a.5.5 0 0 1-.656-.651c.116-.281.183-.604.183-.952 0-1.214-.77-2-1.5-2s-1.5.786-1.5 2c0 .348.067.67.183.952a.5.5 0 0 1-.656.65C3.444 14.195 2 11.999 2 9.465c0-1.992.891-3.77 2.28-4.865a.5.5 0 0 1 .802.303c.096.526.265.946.458 1.22.197.282.365.342.46.342.123 0 .369-.112.613-.602.23-.46.387-1.13.387-1.898 0-.954-.241-1.742-.55-2.172a.5.5 0 0 1-.018-.558ZM7.775 2.31c.146.5.225 1.067.225 1.654 0 .89-.18 1.72-.492 2.345C7.21 6.905 6.706 7.464 6 7.464c-.553 0-.986-.35-1.279-.767a3.414 3.414 0 0 1-.396-.775C3.511 6.823 3 8.073 3 9.464c0 1.75.805 3.27 2.003 4.16a3.674 3.674 0 0 1-.003-.16c0-1.546 1.02-3 2.5-3s2.5 1.454 2.5 3c0 .054-.001.107-.003.16 1.198-.89 2.003-2.41 2.003-4.16a6.37 6.37 0 0 0-.01-.34.5.5 0 0 1 .01-.144c-.355-4.149-2.653-5.985-4.225-6.671Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/spelling.svg b/comm/mail/themes/shared/mail/icons/new/compact/spelling.svg
new file mode 100644
index 0000000000..393d40b734
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/spelling.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="m6.5 2.5-4 12h3l1-3h.535a5 5 0 0 1 .647-2H6.5l1-4h1l.61 2.434a5 5 0 0 1 1.925-.829L9.5 2.5h-3z" fill="context-fill"/>
+ <path d="M6.5 2a.5.5 0 0 0-.475.342l-4 12A.5.5 0 0 0 2.5 15h3a.5.5 0 0 0 .475-.342L6.86 12H7a5 5 0 0 1 .113-1H6.5a.5.5 0 0 0-.475.342L5.14 14H3.193L6.861 3H9.14l1.406 4.22a5 5 0 0 1 .994-.187L9.975 2.342A.5.5 0 0 0 9.5 2h-3zm1 3a.5.5 0 0 0-.484.379l-1 4A.5.5 0 0 0 6.5 10h.924a5 5 0 0 1 .592-1H7.14l.75-3h.218l.569 2.27a5 5 0 0 1 .875-.616L8.984 5.38A.5.5 0 0 0 8.5 5h-1zM12 8c-2.203 0-4 1.797-4 4 0 2.203 1.797 4 4 4 2.203 0 4-1.797 4-4 0-2-1.58-3.965-4-4zm0 1c.692 0 1.328.232 1.834.623l-1.871 2.992L10.9 11.2a.5.5 0 1 0-.8.602l1.5 2a.5.5 0 0 0 .824-.035l2.11-3.375c.294.464.466 1.016.466 1.609 0 1.663-1.337 3-3 3s-3-1.337-3-3 1.337-3 3-3z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/star.svg b/comm/mail/themes/shared/mail/icons/new/compact/star.svg
new file mode 100644
index 0000000000..895566f4e0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/star.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8 1.5a.869.869 0 0 0-.787.499L5.5 5.499l-3.272.344a.867.867 0 0 0-.486 1.475L4.019 9.49l-.779 3.972a.868.868 0 0 0 1.204.967L8 12.851l3.556 1.578a.868.868 0 0 0 1.204-.967l-.779-3.972 2.277-2.172a.867.867 0 0 0-.486-1.475L10.5 5.5 8.787 1.999a.869.869 0 0 0-.787-.5Z" fill="context-fill"/>
+ <path d="M8 1a1.37 1.37 0 0 0-1.24.787L5.172 5.031l-2.996.314a.5.5 0 0 0-.02.002C1.07 5.505.609 6.903 1.391 7.673a.5.5 0 0 0 .005.006l2.079 1.983-.725 3.699v.002c-.216 1.06.907 1.961 1.896 1.523L8 13.398l3.354 1.488c.989.438 2.112-.464 1.896-1.523l-.725-3.701 2.079-1.983a.5.5 0 0 0 .005-.006c.782-.77.321-2.168-.765-2.326a.5.5 0 0 0-.02-.002l-2.996-.314L9.24 1.786A1.373 1.373 0 0 0 8 1Zm0 1c.145 0 .273.08.334.21a.5.5 0 0 0 .004.008l1.713 3.502a.5.5 0 0 0 .396.278l3.254.34c.334.048.444.39.205.625l-2.27 2.166a.5.5 0 0 0-.146.457l.78 3.972a.5.5 0 0 0 0 .006c.065.32-.21.54-.51.409l-3.557-1.579a.5.5 0 0 0-.406 0l-3.555 1.579c-.302.133-.578-.088-.512-.409a.5.5 0 0 0 0-.006l.78-3.972a.5.5 0 0 0-.147-.458l-2.27-2.166c-.238-.235-.128-.576.206-.625l3.254-.34a.5.5 0 0 0 .396-.277l1.713-3.502a.5.5 0 0 0 .004-.008C7.727 2.08 7.856 2 8 2Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/subthread-ignored.svg b/comm/mail/themes/shared/mail/icons/new/compact/subthread-ignored.svg
new file mode 100644
index 0000000000..a327060d3c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/subthread-ignored.svg
@@ -0,0 +1,9 @@
+<!-- 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" fill="none" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="#ff0039" d="M15.5 12.5a3 3 0 0 1-3 3 3 3 0 0 1-3-3 3 3 0 0 1 3-3 3 3 0 0 1 3 3z" opacity="0.2"/>
+ <path fill="#ff0039" d="M12.5 9A3.508 3.508 0 0 0 9 12.5c0 1.927 1.573 3.5 3.5 3.5s3.5-1.573 3.5-3.5S14.427 9 12.5 9zm0 1c1.387 0 2.5 1.113 2.5 2.5S13.887 15 12.5 15a2.492 2.492 0 0 1-2.5-2.5c0-1.387 1.113-2.5 2.5-2.5zm-1 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-2z"/>
+ <path d="M4.5 1.5a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" fill="context-fill"/>
+ <path d="M4.5 1C3.677 1 3 1.677 3 2.5c0 .648.42 1.204 1 1.412V11c0 1.1.9 2 2 2h3.91a3 3 0 0 1-.363-1H6c-.563 0-1-.437-1-1V6.729c.295.171.636.183 1 .183h3.088c.208.58.764 1 1.412 1 .823 0 1.5-.677 1.5-1.5 0-.822-.677-1.5-1.5-1.5-.648 0-1.204.42-1.412 1H6c-.563 0-1-.35-1-.912V3.912c.58-.208 1-.764 1-1.412C6 1.677 5.323 1 4.5 1Zm0 1c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5Zm6 3.912c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/subtract-circle.svg b/comm/mail/themes/shared/mail/icons/new/compact/subtract-circle.svg
new file mode 100644
index 0000000000..6001409c5e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/subtract-circle.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5.47A7.03 7.03 0 0 0 .47 7.5a7.03 7.03 0 0 0 7.03 7.03 7.03 7.03 0 0 0 7.03-7.03A7.03 7.03 0 0 0 7.5.47Z" stroke-opacity="0.2" stroke="context-stroke" fill="context-fill"/>
+ <path fill-opacity="1" d="M4.5 7a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/sync.svg b/comm/mail/themes/shared/mail/icons/new/compact/sync.svg
new file mode 100644
index 0000000000..706ad9df26
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/sync.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M3.757 3.757a6.008 6.008 0 0 1 8.486 0l.001.001a6.001 6.001 0 0 1 1.733 4.628l1.17-1.24a.5.5 0 1 1 .707.707l-2 2.047a.5.5 0 0 1-.708 0l-2-2.047a.5.5 0 1 1 .708-.707L13 8.5c.22-1.538-.345-2.912-1.464-4.036a4.992 4.992 0 0 0-7.377.335.5.5 0 1 1-.77-.638c.116-.14.24-.275.368-.404zM2.147 6.1a.5.5 0 0 1 .707 0l2 2.046a.5.5 0 1 1-.708.707L3 7.5c-.22 1.537.347 2.912 1.466 4.037a4.993 4.993 0 0 0 7.375-.337.5.5 0 1 1 .77.638 6.008 6.008 0 0 1-8.854.405l-.001-.001a6.004 6.004 0 0 1-1.733-4.63L.853 8.853a.5.5 0 1 1-.707-.707z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/tag.svg b/comm/mail/themes/shared/mail/icons/new/compact/tag.svg
new file mode 100644
index 0000000000..e0dbbfe5e9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/tag.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M6.502 2.5.79 8.139a.996.996 0 0 0 0 1.414l5.658 5.658a.996.996 0 0 0 1.414 0L13.501 9.5v-7zm3.5 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z" fill="context-fill"/>
+ <path d="M6.502 2a.5.5 0 0 0-.352.144L.44 7.783a1.505 1.505 0 0 0-.002 2.123l5.658 5.658a1.505 1.505 0 0 0 2.123-.002l5.638-5.711a.5.5 0 0 0 .145-.352v-7a.5.5 0 0 0-.5-.5zm.205 1h6.295v6.294l-5.494 5.565c-.199.2-.503.2-.705-.002L1.145 9.199a.487.487 0 0 1-.002-.705Zm3.295 1c-1.099 0-2 .9-2 2 0 1.098.901 2 2 2s2-.902 2-2c0-1.1-.901-2-2-2zm0 1c.558 0 1 .441 1 1 0 .558-.442 1-1 1s-1-.442-1-1c0-.559.442-1 1-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/tasks.svg b/comm/mail/themes/shared/mail/icons/new/compact/tasks.svg
new file mode 100644
index 0000000000..9f83908935
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/tasks.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 2a.98.98 0 0 0-.477.121 1.004 1.004 0 0 0-.478.578v.002c-.01.031-.02.062-.025.094v.004c-.007.032-.01.065-.014.097v.002A1.056 1.056 0 0 0 3 3v10a.86.86 0 0 0 .006.102v.002a.781.781 0 0 0 .014.097v.004c.006.031.016.064.025.094v.002a1.004 1.004 0 0 0 .654.654h.002a.77.77 0 0 0 .094.026h.004c.032.006.065.01.097.013h.002c.034.003.067.006.102.006h8a.98.98 0 0 0 .477-.121 1.004 1.004 0 0 0 .478-.578A.997.997 0 0 0 13 13V3a.983.983 0 0 0-.02-.201v-.004c-.006-.032-.016-.063-.025-.094V2.7a1.005 1.005 0 0 0-.654-.654h-.002a1.031 1.031 0 0 0-.094-.025h-.004a1.005 1.005 0 0 0-.098-.014h-.002A.996.996 0 0 0 12 2h-1.086A1.51 1.51 0 0 1 9.5 3.004h-3A1.51 1.51 0 0 1 5.086 2Z" fill="context-fill"/>
+ <path d="M6.5 0c-.647 0-1.204.42-1.412 1H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2h-1.088c-.208-.58-.765-1-1.412-1Zm0 1h3c.286 0 .5.214.5.5v.004c0 .286-.214.5-.5.5h-3a.488.488 0 0 1-.5-.5V1.5c0-.286.214-.5.5-.5ZM4 2h1.086A1.51 1.51 0 0 0 6.5 3.004h3A1.51 1.51 0 0 0 10.914 2H12c.563 0 1 .437 1 1v10c0 .563-.437 1-1 1H4c-.563 0-1-.437-1-1V3c0-.563.437-1 1-1Zm6.56 4.004a.5.5 0 0 0-.13.002.5.5 0 0 0-.33.193L7.445 9.74 5.854 8.145a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .708l2 2A.5.5 0 0 0 7.9 10.8l3-4a.5.5 0 0 0-.1-.701.5.5 0 0 0-.24-.096Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/template.svg b/comm/mail/themes/shared/mail/icons/new/compact/template.svg
new file mode 100644
index 0000000000..3c50a5d335
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/template.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 2h8c.554 0 1 .446 1 1v10c0 .554-.446 1-1 1H4c-.554 0-1-.446-1-1V3c0-.554.446-1 1-1Z" fill="context-fill"/>
+ <path d="M4 1c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2Zm0 1h8c.563 0 1 .437 1 1v10c0 .563-.437 1-1 1H4c-.563 0-1-.437-1-1V3c0-.563.437-1 1-1Zm4 1a.5.5 0 0 0-.5.5v.563A2.011 2.011 0 0 0 6 6c0 .692.358 1.304.896 1.664L5.961 10H5.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h.06l-.525 1.314a.5.5 0 0 0 .28.65.5.5 0 0 0 .65-.278L6.639 11H9.36l.674 1.686a.5.5 0 0 0 .65.279.5.5 0 0 0 .28-.65L10.439 11h.061a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-.46l-.936-2.336C9.642 7.304 10 6.692 10 6c0-.926-.64-1.714-1.5-1.938V3.5A.5.5 0 0 0 8 3Zm0 2c.558 0 1 .442 1 1s-.442 1-1 1-1-.442-1-1 .442-1 1-1Zm-.158 2.994a1.979 1.979 0 0 0 .316 0L8.961 10H7.039Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/tentative.svg b/comm/mail/themes/shared/mail/icons/new/compact/tentative.svg
new file mode 100644
index 0000000000..ee9fff177a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/tentative.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8.5 2c-1.35 0-2.29.435-2.832 1.13C5.127 3.828 5 4.695 5 5.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5c0-.694.123-1.327.457-1.756C6.791 3.315 7.35 3 8.5 3c.892 0 1.466.264 1.865.742.4.478.635 1.229.635 2.258 0 .78-.202 1.204-.494 1.523-.293.32-.718.538-1.201.745-.484.206-1.019.39-1.483.742A2.095 2.095 0 0 0 7 10.705v.795a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-.795c0-.492.142-.68.428-.898.286-.218.751-.4 1.267-.62.517-.22 1.092-.49 1.55-.99C11.701 7.697 12 6.97 12 6c0-1.18-.264-2.179-.865-2.898C10.534 2.382 9.608 2 8.5 2zm-1 11a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/thread-ignored.svg b/comm/mail/themes/shared/mail/icons/new/compact/thread-ignored.svg
new file mode 100644
index 0000000000..3b6a5d308a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/thread-ignored.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="#ff0039" d="M14.5 7.5a7 7 0 0 1-7 7 7 7 0 0 1-7-7 7 7 0 0 1 7-7 7 7 0 0 1 7 7z" opacity="0.2"/>
+ <path fill="#ff0039" d="M7.5 0C3.364 0 0 3.363 0 7.5 0 11.636 3.364 15 7.5 15S15 11.636 15 7.5C15 3.363 11.636 0 7.5 0Zm0 1C11.096 1 14 3.904 14 7.5c0 3.595-2.904 6.5-6.5 6.5A6.492 6.492 0 0 1 1 7.5C1 3.904 3.904 1 7.5 1Zm-4 6a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/thread.svg b/comm/mail/themes/shared/mail/icons/new/compact/thread.svg
new file mode 100644
index 0000000000..731e7af137
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/thread.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4.5 1.5a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm6 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm1 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" fill="context-fill"/>
+ <path d="M4.5 1C3.677 1 3 1.677 3 2.5c0 .648.42 1.204 1 1.412V12c0 1.1.9 2 2 2h4.088c.208.58.764 1 1.412 1 .823 0 1.5-.677 1.5-1.5s-.677-1.5-1.5-1.5c-.648 0-1.204.42-1.412 1H6c-.563 0-1-.437-1-1V7.816c.295.172.636.184 1 .184h3.088c.208.58.764 1 1.412 1 .823 0 1.5-.677 1.5-1.5S11.323 6 10.5 6c-.648 0-1.204.42-1.412 1H6c-.563 0-1-.35-1-.912V3.912c.58-.208 1-.764 1-1.412C6 1.677 5.323 1 4.5 1Zm0 1c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5Zm6 5c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5Zm1 6c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/tools.svg b/comm/mail/themes/shared/mail/icons/new/compact/tools.svg
new file mode 100644
index 0000000000..2f68413f08
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/tools.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M6 2.5A3.5 3.5 0 0 1 9.5 6a3.5 3.5 0 0 1-.246 1.264l.243.239L14 12s1 1 0 2-2 0-2 0c-1.575-1.58-3.18-3.168-4.736-4.746A3.5 3.5 0 0 1 6 9.5 3.5 3.5 0 0 1 2.5 6V4.5L5 7s1 0 1.5-.5C7.5 5.5 7 5 7 5L4.5 2.5Z" fill="context-fill"/>
+ <path d="M4.5 2a.5.5 0 0 0-.354.853l2.47 2.469c.001.004.009.014.009.053 0 .083-.037.33-.479.771-.132.132-.419.248-.681.3-.158.032-.196.027-.28.032L2.854 4.146A.5.5 0 0 0 2 4.5V6c0 2.203 1.797 4 4 4a.5.5 0 0 0 .004 0c.372-.003.73-.107 1.086-.211 1.513 1.53 3.041 3.044 4.556 4.564 0 0 .32.329.82.496.502.167 1.27.122 1.888-.496.617-.618.663-1.385.496-1.887a2.232 2.232 0 0 0-.496-.82c-1.519-1.518-3.033-3.043-4.565-4.555.105-.356.208-.715.211-1.088a.5.5 0 0 0 0-.004c0-2.203-1.797-4-4-4zm1.207 1H6a2.992 2.992 0 0 1 3 2.998V6c-.003.37-.075.736-.21 1.08a.5.5 0 0 0 .112.54c1.59 1.566 3.163 3.152 4.744 4.733 0 0 .172.181.254.43.083.248.129.48-.254.863-.382.382-.614.337-.863.254a1.297 1.297 0 0 1-.43-.254c-1.579-1.585-3.167-3.156-4.734-4.744a.5.5 0 0 0-.539-.113c-.344.136-.71.207-1.08.21h-.002a2.992 2.992 0 0 1-2.998-3v-.292l1.646 1.646A.5.5 0 0 0 5 7.5s.297 0 .66-.073c.363-.072.826-.206 1.194-.574.558-.559.771-1.062.771-1.478 0-.209-.054-.382-.115-.504a1.118 1.118 0 0 0-.156-.225Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/trash.svg b/comm/mail/themes/shared/mail/icons/new/compact/trash.svg
new file mode 100644
index 0000000000..4b8747b30f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/trash.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" width="16" height="16">
+ <path d="M4 3c-.554 0-1 .446-1 1v1h10V4c0-.554-.446-1-1-1Zm0 3v7c0 .554.446 1 1 1h6c.554 0 1-.446 1-1V6Z" fill="context-fill"/>
+ <path d="M7 1c-.545 0-1 .455-1 1H4c-1.1 0-2 .9-2 2v1c0 .545.455 1 1 1v7c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V6c.545 0 1-.455 1-1V4c0-1.1-.9-2-2-2h-2c0-.545-.455-1-1-1ZM4 3h8c.563 0 1 .437 1 1v1H3V4c0-.563.437-1 1-1Zm0 3h8v7c0 .563-.437 1-1 1H5c-.563 0-1-.437-1-1Zm2.5 1a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-5a.5.5 0 0 0-.5-.5Zm3 0a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/unread.svg b/comm/mail/themes/shared/mail/icons/new/compact/unread.svg
new file mode 100644
index 0000000000..6814ac1d68
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/unread.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M5.854 3.5c.094.317.146.653.146 1C6 6.427 4.427 8 2.5 8a3.48 3.48 0 0 1-1-.147V12c0 .83.669 1.5 1.5 1.5h10c.831 0 1.5-.67 1.5-1.5V5c0-.831-.669-1.5-1.5-1.5Z" fill="context-fill"/>
+ <path d="M2.5 2A2.508 2.508 0 0 0 0 4.5C0 5.874 1.125 7 2.5 7S5 5.874 5 4.5C5 3.125 3.875 2 2.5 2Zm0 1C3.334 3 4 3.665 4 4.5 4 5.334 3.334 6 2.5 6S1 5.334 1 4.5C1 3.665 1.666 3 2.5 3Zm3.162 0c.148.31.25.647.3 1H13c.563 0 1 .437 1 1v7c0 .562-.437 1-1 1H3c-.563 0-1-.438-1-1V7.963a3.473 3.473 0 0 1-1-.301V12c0 1.099.9 2 2 2h10c1.1 0 2-.901 2-2V5c0-1.1-.9-2-2-2Zm6.736 2.011a.5.5 0 0 0-.23.116L8 8.832 5.371 6.496c-.191.274-.42.52-.68.73l1.258 1.117-2.803 2.803a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.843-2.844.971.864a.5.5 0 0 0 .664 0l.97-.864 2.844 2.844a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707l-2.803-2.803 2.781-2.47a.5.5 0 0 0 .041-.705.5.5 0 0 0-.475-.157Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/user-list-alt.svg b/comm/mail/themes/shared/mail/icons/new/compact/user-list-alt.svg
new file mode 100644
index 0000000000..7e0990a3b9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/user-list-alt.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M5.996 1.998A3.008 3.008 0 0 0 3 5a.5.5 0 0 0 0 .002c.003.782.368 1.482.904 2.035C2.856 7.095 2 7.935 2 8.997v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2.5c0-.562.437-1 1-1h.5a.5.5 0 0 0 .5-.5V6.99a.5.5 0 0 0-.2-.4A1.999 1.999 0 0 1 4 5v-.002c0-1.11.887-1.999 1.996-2a1.993 1.993 0 0 1 1.992 1.779A2.994 2.994 0 0 0 7 7.002a.5.5 0 0 0 0 .002c.003.782.368 1.482.904 2.035C6.856 9.097 6 9.937 6 10.999v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2.5c0-.562.437-1 1-1h.5a.5.5 0 0 0 .5-.5v-.507a.5.5 0 0 0-.2-.4 1.999 1.999 0 0 1-.8-1.59V7c0-1.11.887-1.999 1.996-2A1.994 1.994 0 0 1 12 6.996v.002a2 2 0 0 1-.8 1.593.5.5 0 0 0-.2.4V9.5a.5.5 0 0 0 .5.5h.5c.563 0 1 .437 1 1v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V11c0-1.063-.856-1.903-1.904-1.961.537-.553.902-1.254.904-2.037A.5.5 0 0 0 13 7v-.006a.5.5 0 0 0 0-.002A3.006 3.006 0 0 0 9.996 4c-.388 0-.759.077-1.1.213a3.01 3.01 0 0 0-2.9-2.215z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/user-list.svg b/comm/mail/themes/shared/mail/icons/new/compact/user-list.svg
new file mode 100644
index 0000000000..1de4ec47aa
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/user-list.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M4 1.5A2.495 2.495 0 0 0 1.5 4v8c0 .82.5 2 2 2.5v-2.998C3.5 10 4 10 4.5 9.5V7.992a2.5 2.5 0 0 1-1-1.993 2.5 2.5 0 0 1 4.844-.869A2.5 2.5 0 0 1 10 4.5 2.5 2.5 0 0 1 12.5 7a2.5 2.5 0 0 1-1 1.992V10.5c.5 0 1 1 1 1.5v2.5c1-.5 2-1.68 2-2.5V4c0-1.385-1.115-2.5-2.5-2.5Z" fill="context-fill"/>
+ <path d="M4 1C2.347 1 1 2.346 1 4v8c0 .953.45 1.803 1.146 2.353l.004.004a.504.504 0 0 0 .096.072c.494.358 1.1.57 1.754.57h8a2.98 2.98 0 0 0 1.754-.57.5.5 0 0 0 .096-.072l.004-.004A2.995 2.995 0 0 0 15 12V4c0-1.654-1.347-3-3-3Zm0 1h8c1.117 0 2 .883 2 2v8.02a1.97 1.97 0 0 1-.988 1.706V11.5c0-.823-.678-1.5-1.5-1.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5c.285 0 .5.214.5.5V14H8v-2.5c0-.286.214-.5.5-.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5c-.822 0-1.5.677-1.5 1.5V14H3.992A.5.5 0 0 0 4 13.935V10.5c0-.286.214-.5.5-.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5C3.678 9 3 9.677 3 10.5v3.234a1.97 1.97 0 0 1-1-1.715V4c0-1.117.883-2 2-2Zm2 1C4.35 3 3 4.349 3 6c0 1.65 1.35 3 3 3 .178 0 .352-.017.521-.047a4 4 0 0 1-.388-.957A1.992 1.992 0 0 1 4 6c0-1.11.89-2 2-2 .39 0 .752.108 1.059.298a4 4 0 0 1 .779-.664A2.98 2.98 0 0 0 6 3Zm4 1C8.35 4 7 5.349 7 7c0 1.65 1.35 3 3 3s3-1.35 3-3c0-1.651-1.35-3-3-3zm0 1c1.11 0 2 .89 2 2 0 1.11-.89 2-2 2-1.11 0-2-.89-2-2 0-1.11.89-2 2-2Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/user.svg b/comm/mail/themes/shared/mail/icons/new/compact/user.svg
new file mode 100644
index 0000000000..af4a1ee718
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/user.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.996 3.007A3.008 3.008 0 0 0 5 6.01a.5.5 0 0 0 0 .002c.003.782.368 1.483.904 2.035C4.856 8.105 4 8.947 4 10.008v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2.5c0-.562.437-1 1-1h.5a.5.5 0 0 0 .5-.5v-.507a.5.5 0 0 0-.2-.4 1.999 1.999 0 0 1-.8-1.59v-.003c0-1.109.887-1.998 1.996-2A1.994 1.994 0 0 1 10 6.003v.002a2 2 0 0 1-.8 1.594.5.5 0 0 0-.2.4v.508a.5.5 0 0 0 .5.5h.5c.563 0 1 .438 1 1v2.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2.5c0-1.062-.856-1.903-1.904-1.96.537-.554.902-1.255.904-2.038a.5.5 0 0 0 0-.002v-.006a.5.5 0 0 0 0-.002 3.006 3.006 0 0 0-3.004-2.992Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/compact/warning.svg b/comm/mail/themes/shared/mail/icons/new/compact/warning.svg
new file mode 100644
index 0000000000..2bc5c04ce6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/compact/warning.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M7.5 1.5c-.653 0-1.222.453-1.5 1L.824 11.96C.5 12.5.502 12.611.5 13A1.5 1.5 0 0 0 2 14.5h11a1.5 1.5 0 0 0 1.5-1.5c0-.394 0-.5-.332-1.053L9 2.5c-.282-.553-.844-1-1.5-1Z" fill="context-fill"/>
+ <path d="M7.5 1c-.883 0-1.593.58-1.945 1.273l.006-.014-5.166 9.444c-.34.566-.393.908-.395 1.295A.5.5 0 0 0 0 13c0 1.098.901 2 2 2h11c1.099 0 2-.902 2-2 0-.394-.058-.734-.404-1.31l.011.017-5.168-9.448.006.014C9.091 1.578 8.383 1 7.5 1Zm0 1c.429 0 .845.316 1.055.726a.5.5 0 0 0 .006.014l5.168 9.447a.5.5 0 0 0 .011.018c.318.528.26.4.26.795 0 .558-.442 1-1 1H2a.993.993 0 0 1-1-.998V13c.001-.387-.055-.27.252-.782a.5.5 0 0 0 .01-.017L6.439 2.74a.5.5 0 0 0 .006-.014C6.649 2.326 7.077 2 7.5 2Zm0 3a.5.5 0 0 0-.5.5v4a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-4a.5.5 0 0 0-.5-.5Zm0 6a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/event-continue.svg b/comm/mail/themes/shared/mail/icons/new/event-continue.svg
new file mode 100644
index 0000000000..d52808a151
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/event-continue.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 11 11" height="11" width="11">
+ <path d="M5.5 1a.5.5 0 0 0-.035.006.5.5 0 0 0-.086.014.5.5 0 0 0-.065.021.5.5 0 0 0-.074.04.5.5 0 0 0-.058.04.5.5 0 0 0-.036.025l-3 3a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0L5 2.707v5.586L2.854 6.146A.5.5 0 0 0 2.5 6a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .036.025.5.5 0 0 0 .058.04.5.5 0 0 0 .074.04.5.5 0 0 0 .065.021.5.5 0 0 0 .086.014A.5.5 0 0 0 5.5 10a.5.5 0 0 0 .035-.006.5.5 0 0 0 .086-.014.5.5 0 0 0 .065-.021.5.5 0 0 0 .074-.04.5.5 0 0 0 .058-.04.5.5 0 0 0 .036-.025l3-3a.5.5 0 0 0 0-.708.5.5 0 0 0-.708 0L6 8.293V2.707l2.146 2.147a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.708L5.875 1.168A.5.5 0 0 0 5.5 1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/event-end.svg b/comm/mail/themes/shared/mail/icons/new/event-end.svg
new file mode 100644
index 0000000000..b016d88934
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/event-end.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 11 11" height="11" width="11">
+ <path d="M5.5 1a.5.5 0 0 0-.5.5v5.793L2.854 5.146A.5.5 0 0 0 2.5 5a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .036.025.5.5 0 0 0 .058.04.5.5 0 0 0 .074.04.5.5 0 0 0 .065.021.5.5 0 0 0 .086.014A.5.5 0 0 0 5.5 9a.5.5 0 0 0 .035-.006.5.5 0 0 0 .086-.014.5.5 0 0 0 .065-.021.5.5 0 0 0 .074-.04.5.5 0 0 0 .058-.04.5.5 0 0 0 .036-.025l3-3a.5.5 0 0 0 0-.708.5.5 0 0 0-.708 0L6 7.293V1.5a.5.5 0 0 0-.5-.5zm0 8h-3a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-3z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/event-start.svg b/comm/mail/themes/shared/mail/icons/new/event-start.svg
new file mode 100644
index 0000000000..9bd0c0b5c9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/event-start.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 11 11" height="11" width="11">
+ <path d="M2.5 1a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H5v6.293L2.854 6.146A.5.5 0 0 0 2.5 6a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .036.025.5.5 0 0 0 .058.04.5.5 0 0 0 .074.04.5.5 0 0 0 .065.021.5.5 0 0 0 .086.014A.5.5 0 0 0 5.5 10a.5.5 0 0 0 .035-.006.5.5 0 0 0 .086-.014.5.5 0 0 0 .065-.021.5.5 0 0 0 .074-.04.5.5 0 0 0 .058-.04.5.5 0 0 0 .036-.025l3-3a.5.5 0 0 0 0-.708.5.5 0 0 0-.708 0L6 8.293V2h2.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-6z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/loading.svg b/comm/mail/themes/shared/mail/icons/new/loading.svg
new file mode 100644
index 0000000000..4020946bb4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/loading.svg
@@ -0,0 +1,98 @@
+<!-- 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" width="480" height="16" fill="context-stroke">
+ <svg>
+ <path d="M2.062 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="16">
+ <path d="M3.613 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="32">
+ <path d="M6.352 6.057c1.214 0 2.2 0.87 2.2 1.943 0 1.072 -0.986 1.943 -2.2 1.943 -1.214 0 -2.2 -0.87 -2.2 -1.943 0 -1.072 0.986 -1.943 2.2 -1.943z"/>
+ </svg>
+ <svg x="48">
+ <path d="M8.338 6.171c1.435 0 2.6 0.82 2.6 1.829 0 1.01 -1.165 1.829 -2.6 1.829s-2.6 -0.82 -2.6 -1.829c0 -1.01 1.165 -1.829 2.6 -1.829z"/>
+ </svg>
+ <svg x="64">
+ <path d="M9.762 6.286c1.655 0 3 0.768 3 1.714s-1.345 1.714 -3 1.714c-1.656 0 -3 -0.768 -3 -1.714s1.344 -1.714 3 -1.714z"/>
+ </svg>
+ <svg x="80">
+ <path d="M10.828 6.4c1.877 0 3.4 0.717 3.4 1.6 0 0.883 -1.523 1.6 -3.4 1.6 -1.876 0 -3.4 -0.717 -3.4 -1.6 0 -0.883 1.524 -1.6 3.4 -1.6z"/>
+ </svg>
+ <svg x="96">
+ <path d="M11.648 6.3c1.683 0 3.05 0.762 3.05 1.7s-1.367 1.7 -3.05 1.7c-1.683 0 -3.05 -0.762 -3.05 -1.7s1.367 -1.7 3.05 -1.7z"/>
+ </svg>
+ <svg x="112">
+ <path d="M12.287 6.2c1.49 0 2.7 0.807 2.7 1.8s-1.21 1.8 -2.7 1.8c-1.49 0 -2.7 -0.807 -2.7 -1.8s1.21 -1.8 2.7 -1.8z"/>
+ </svg>
+ <svg x="128">
+ <path d="M12.785 6.1c1.297 0 2.35 0.851 2.35 1.9s-1.053 1.9 -2.35 1.9c-1.297 0 -2.35 -0.851 -2.35 -1.9s1.053 -1.9 2.35 -1.9z"/>
+ </svg>
+ <svg x="144">
+ <path d="M13.17 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2c0 -1.104 0.897 -2 2 -2z"/>
+ </svg>
+ <svg x="160">
+ <path d="M13.463 6c1.103 0 2 0.896 2 2s-0.897 2 -2 2a2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="176">
+ <path d="M13.677 6c1.103 0 2 0.896 2 2s-0.897 2 -2 2a2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="192">
+ <path d="M13.823 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="208">
+ <path d="M13.911 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="224">
+ <path d="M13.947 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="240">
+ <path d="M13.937 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="256">
+ <path d="M13.27 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2c0 -1.104 0.897 -2 2 -2z"/>
+ </svg>
+ <svg x="272">
+ <path d="M10.65 6.057c1.182 0 2.142 0.87 2.142 1.943 0 1.072 -0.96 1.943 -2.143 1.943 -1.182 0 -2.142 -0.87 -2.142 -1.943 0 -1.072 0.96 -1.943 2.142 -1.943z"/>
+ </svg>
+ <svg x="288">
+ <path d="M7.911 6.171c1.34 0 2.429 0.82 2.429 1.829 0 1.01 -1.088 1.829 -2.429 1.829 -1.34 0 -2.428 -0.82 -2.428 -1.829 0 -1.01 1.088 -1.829 2.428 -1.829z"/>
+ </svg>
+ <svg x="304">
+ <path d="M6.18 6.286c1.498 0 2.715 0.768 2.715 1.714s-1.217 1.714 -2.715 1.714c-1.498 0 -2.714 -0.768 -2.714 -1.714s1.216 -1.714 2.714 -1.714z"/>
+ </svg>
+ <svg x="320">
+ <path d="M5.01 6.4c1.655 0 3 0.717 3 1.6 0 0.883 -1.345 1.6 -3 1.6 -1.656 0 -3 -0.717 -3 -1.6 0 -0.883 1.344 -1.6 3 -1.6z"/>
+ </svg>
+ <svg x="336">
+ <path d="M4.167 6.3c1.518 0 2.75 0.762 2.75 1.7s-1.232 1.7 -2.75 1.7 -2.75 -0.762 -2.75 -1.7 1.232 -1.7 2.75 -1.7z"/>
+ </svg>
+ <svg x="352">
+ <path d="M3.54 6.2c1.38 0 2.5 0.807 2.5 1.8s-1.12 1.8 -2.5 1.8 -2.5 -0.807 -2.5 -1.8 1.12 -1.8 2.5 -1.8z"/>
+ </svg>
+ <svg x="368">
+ <path d="M3.069 6.1c1.241 0 2.25 0.851 2.25 1.9s-1.009 1.9 -2.25 1.9c-1.242 0 -2.25 -0.851 -2.25 -1.9s1.008 -1.9 2.25 -1.9z"/>
+ </svg>
+ <svg x="384">
+ <path d="M2.714 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="400">
+ <path d="M2.452 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="416">
+ <path d="M2.266 6c1.103 0 2 0.896 2 2s-0.897 2 -2 2a2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="432">
+ <path d="M2.142 6c1.103 0 2 0.896 2 2s-0.897 2 -2 2a2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="448">
+ <path d="M2.071 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+ <svg x="464">
+ <path d="M2.046 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2c0 -1.104 0.897 -2 2 -2z"/>
+ </svg>
+ <svg x="480">
+ <path d="M2.062 6a2 2 0 0 1 0 4 2.001 2.001 0 0 1 -2 -2 2 2 0 0 1 2 -2z"/>
+ </svg>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/mail-sm.svg b/comm/mail/themes/shared/mail/icons/new/mail-sm.svg
new file mode 100644
index 0000000000..26a103fc84
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/mail-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M3 2.5h6c.831 0 1.5.669 1.5 1.5v4c0 .83-.669 1.5-1.5 1.5H3c-.831 0-1.5-.67-1.5-1.5V4c0-.831.669-1.5 1.5-1.5Z" fill="context-fill"/>
+ <path d="M3 2c-1.1 0-2 .9-2 2v4c0 1.099.9 2 2 2h6c1.1 0 2-.901 2-2V4c0-1.1-.9-2-2-2Zm0 1h6c.563 0 1 .437 1 1v4c0 .562-.437 1-1 1H3c-.563 0-1-.438-1-1V4c0-.563.437-1 1-1Zm5.477 1a.5.5 0 0 0-.29.109L6 5.859l-2.188-1.75a.5.5 0 0 0-.367-.105.5.5 0 0 0-.336.183.5.5 0 0 0 .079.703l1.23.985-1.272 1.271a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l1.35-1.35.484.387a.5.5 0 0 0 .625 0l.484-.386 1.35 1.35a.5.5 0 0 0 .707 0 .5.5 0 0 0 0-.708L7.582 5.875l1.23-.985a.5.5 0 0 0 .079-.703A.5.5 0 0 0 8.477 4Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/nav-down-sm.svg b/comm/mail/themes/shared/mail/icons/new/nav-down-sm.svg
new file mode 100644
index 0000000000..523d59f17d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/nav-down-sm.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M1.166 3.128a.5.5 0 0 0-.037.706l4.5 5a.5.5 0 0 0 .742 0l4.5-5a.5.5 0 0 0-.037-.706.5.5 0 0 0-.705.038L6 7.753 1.871 3.166a.5.5 0 0 0-.705-.038Z" fill="context-fill transparent"/>
+ <path d="M1.166 3.128a.5.5 0 0 0-.037.706l4.5 5a.5.5 0 0 0 .742 0l4.5-5a.5.5 0 0 0-.037-.706.5.5 0 0 0-.705.038L6 7.753 1.871 3.166a.5.5 0 0 0-.705-.038Z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/nav-left-sm.svg b/comm/mail/themes/shared/mail/icons/new/nav-left-sm.svg
new file mode 100644
index 0000000000..58b55d608b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/nav-left-sm.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="m8.166 1.128-5 4.5a.5.5 0 0 0 0 .743l5 4.5a.5.5 0 0 0 .705-.037.5.5 0 0 0-.037-.706L4.246 6l4.588-4.13a.5.5 0 0 0 .037-.704.5.5 0 0 0-.705-.038Z" fill="context-fill transparent"/>
+ <path d="m8.166 1.128-5 4.5a.5.5 0 0 0 0 .743l5 4.5a.5.5 0 0 0 .705-.037.5.5 0 0 0-.037-.706L4.246 6l4.588-4.13a.5.5 0 0 0 .037-.704.5.5 0 0 0-.705-.038Z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/nav-right-sm.svg b/comm/mail/themes/shared/mail/icons/new/nav-right-sm.svg
new file mode 100644
index 0000000000..6549c5826d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/nav-right-sm.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M3.834 1.128a.5.5 0 0 0-.705.038.5.5 0 0 0 .037.705L7.754 6l-4.588 4.128a.5.5 0 0 0-.037.706.5.5 0 0 0 .705.037l5-4.5a.5.5 0 0 0 0-.743z" fill="context-fill transparent"/>
+ <path d="M3.834 1.128a.5.5 0 0 0-.705.038.5.5 0 0 0 .037.705L7.754 6l-4.588 4.128a.5.5 0 0 0-.037.706.5.5 0 0 0 .705.037l5-4.5a.5.5 0 0 0 0-.743z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/nav-today.svg b/comm/mail/themes/shared/mail/icons/new/nav-today.svg
new file mode 100644
index 0000000000..1604ef4b55
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/nav-today.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M6 1C3.244 1 1 3.244 1 6c0 2.755 2.244 5 5 5s5-2.245 5-5c0-2.756-2.244-5-5-5zm0 1c2.215 0 4 1.784 4 4 0 2.215-1.785 4-4 4S2 8.215 2 6c0-2.216 1.785-4 4-4z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/nav-up-sm.svg b/comm/mail/themes/shared/mail/icons/new/nav-up-sm.svg
new file mode 100644
index 0000000000..a3ac411e84
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/nav-up-sm.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <!-- Use both context-fill and context-stroke path to account for the styling coming from toolkit for those sections we can't override. -->
+ <path d="M6 3a.5.5 0 0 0-.371.166l-4.5 5a.5.5 0 0 0 .037.705.5.5 0 0 0 .705-.037L6 4.246l4.129 4.588a.5.5 0 0 0 .705.037.5.5 0 0 0 .037-.705l-4.5-5A.5.5 0 0 0 6 3Z" fill="context-fill transparent"/>
+ <path d="M6 3a.5.5 0 0 0-.371.166l-4.5 5a.5.5 0 0 0 .037.705.5.5 0 0 0 .705-.037L6 4.246l4.129 4.588a.5.5 0 0 0 .705.037.5.5 0 0 0 .037-.705l-4.5-5A.5.5 0 0 0 6 3Z" fill="context-stroke transparent"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/add-circle.svg b/comm/mail/themes/shared/mail/icons/new/normal/add-circle.svg
new file mode 100644
index 0000000000..ee88865b27
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/add-circle.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M17.5 9.5a8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8Z" stroke-opacity="0.2" stroke="context-stroke" fill="context-fill"/>
+ <path d="M9.5 5a.5.5 0 0 0-.5.5V9H5.5a.5.5 0 0 0 0 1H9v3.5a.5.5 0 0 0 1 0V10h3.5a.5.5 0 0 0 0-1H10V5.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/add.svg b/comm/mail/themes/shared/mail/icons/new/normal/add.svg
new file mode 100644
index 0000000000..c7ff0dcf9e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/add.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewbox="0 0 20 20" width="20" height="20" fill-opacity="context-fill-opacity">
+ <path d="M10.5 4c-.277 0-.5.223-.5.5V9H5.5c-.277 0-.5.219-.5.49v.02c0 .271.223.49.5.49H10v4.5a.499.499 0 1 0 1 0V10h4.5c.277 0 .5-.219.5-.49v-.02a.494.494 0 0 0-.5-.49H11V4.5c0-.277-.223-.5-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/address-book.svg b/comm/mail/themes/shared/mail/icons/new/normal/address-book.svg
new file mode 100644
index 0000000000..f308e614db
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/address-book.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20">
+ <path d="M5.5 2h9c.831 0 1.5.669 1.5 1.5v13c0 .831-.669 1.5-1.5 1.5h-9c-.831 0-1.5-.669-1.5-1.5v-13C4 2.669 4.669 2 5.5 2Z" fill="context-fill"/>
+ <path d="M5.5 1a2.508 2.508 0 0 0-2.45 2H2.5a.499.499 0 1 0 0 1H3v2h-.5a.499.499 0 1 0 0 1H3v2h-.5a.499.499 0 1 0 0 1H3v2h-.5a.499.499 0 1 0 0 1H3v2h-.5a.499.499 0 1 0 0 1H3v.5C3 17.876 4.124 19 5.5 19h9c1.376 0 2.5-1.124 2.5-2.5v-13C17 2.124 15.876 1 14.5 1h-9zm0 1h9c.84 0 1.5.66 1.5 1.5v13c0 .84-.66 1.5-1.5 1.5h-9c-.84 0-1.5-.66-1.5-1.5V16h.5a.499.499 0 1 0 0-1H4v-2h.5a.499.499 0 1 0 0-1H4v-2h.5a.499.499 0 1 0 0-1H4V7h.5a.499.499 0 1 0 0-1H4V4h.5a.499.499 0 1 0 0-1h-.416c.202-.587.752-1 1.416-1zm4.496 3A3.008 3.008 0 0 0 7 8.002a.5.5 0 0 0 0 .002c.004.765.358 1.448.873 1.996H7.5c-.822 0-1.5.678-1.5 1.5v2a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2c0-.286.214-.5.5-.5h1a.5.5 0 0 0 .5-.5v-.51a.5.5 0 0 0-.2-.4A1.998 1.998 0 0 1 8 8.002V8c0-1.109.887-1.998 1.996-2A1.994 1.994 0 0 1 12 7.996v.002a1.999 1.999 0 0 1-.8 1.592.5.5 0 0 0-.2.4v.51a.5.5 0 0 0 .5.5h1c.286 0 .5.214.5.5v2a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2c0-.822-.678-1.5-1.5-1.5h-.373c.516-.549.87-1.233.873-1.998A.5.5 0 0 0 13 8v-.006a.5.5 0 0 0 0-.002A3.006 3.006 0 0 0 9.996 5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/archive.svg b/comm/mail/themes/shared/mail/icons/new/normal/archive.svg
new file mode 100644
index 0000000000..a40b9f18a3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/archive.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M5.5 3A2.495 2.495 0 0 0 3 5.5v9C3 15.885 4.115 17 5.5 17h9c1.385 0 2.5-1.115 2.5-2.5v-9C17 4.115 15.885 3 14.5 3ZM14 5h.5c.277 0 .5.223.5.5V7h-1.473c0 1.657-1.87 3-3.527 3S6.479 8.657 6.479 7H5V5.5c0-.277.223-.5.5-.5H6Z" fill="context-fill"/>
+ <path d="M5.5 2C3.57 2 2 3.57 2 5.5v9C2 16.43 3.57 18 5.5 18h9c1.93 0 3.5-1.57 3.5-3.5v-9C18 3.57 16.43 2 14.5 2Zm0 1h9C15.894 3 17 4.106 17 5.5V7h-1V5.5c0-.822-.678-1.5-1.5-1.5h-9C4.678 4 4 4.678 4 5.5V7H3V5.5C3 4.106 4.106 3 5.5 3Zm0 2h9c.286 0 .5.214.5.5V7h-1.496a.5.5 0 0 0-.5.502c.003 1.15-1.327 2.514-3.004 2.514S6.993 8.652 6.996 7.502a.5.5 0 0 0-.5-.502H5V5.5c0-.286.214-.5.5-.5ZM3 8h3.115C6.41 9.632 8.01 11.016 10 11.016V12h1v-1h-.63c1.816-.158 3.239-1.47 3.515-3H17v6.5c0 1.394-1.106 2.5-2.5 2.5H10v-1H9v1H5.5A2.484 2.484 0 0 1 3 14.5Zm7 8h1v-1h-1zm0-1v-1H9v1zm0-1h1v-1h-1zm0-1v-1H9v1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/calendar-invite.svg b/comm/mail/themes/shared/mail/icons/new/normal/calendar-invite.svg
new file mode 100644
index 0000000000..e178e1d08e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/calendar-invite.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M5.5 2.5c-1.394 0-3 1.606-3 3v1h15v-1c0-1.394-1.606-3-3-3zm.467 7c-.259 0-.467.223-.467.5v2c0 .277.208.5.467.5h2.066c.259 0 .467-.223.467-.5v-2c0-.277-.208-.5-.467-.5Zm6 2c-.259 0-.467.223-.467.5v2c0 .277.208.5.467.5h2.066c.259 0 .467-.223.467-.5v-2c0-.277-.208-.5-.467-.5z" fill="context-fill"/>
+ <path d="M5.5 2C3.57 2 2 3.57 2 5.5v9C2 16.43 3.57 18 5.5 18h9c1.93 0 3.5-1.57 3.5-3.5v-9C18 3.57 16.43 2 14.5 2Zm0 1h9C15.894 3 17 4.106 17 5.5V6h-3v-.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5V6H7v-.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5V6H3v-.5C3 4.106 4.106 3 5.5 3ZM3 7h3v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V7h6v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V7h3v7.5c0 1.393-1.106 2.5-2.5 2.5h-9A2.484 2.484 0 0 1 3 14.5Zm2.967 2C5.421 9 5 9.472 5 10v2c0 .527.42 1 .967 1h2.066c.546 0 .967-.473.967-1v-2c0-.528-.42-1-.967-1ZM10.5 9a.5.5 0 1 0 0 1h4a.5.5 0 1 0 0-1zM6 10h2v2H6Zm5.967 1c-.546 0-.967.472-.967 1v2c0 .527.42 1 .967 1h2.066c.546 0 .967-.473.967-1v-2c0-.528-.42-1-.967-1zM12 12h2v2h-2zm-6.5 2a.5.5 0 1 0 0 1h4a.5.5 0 1 0 0-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/calendar.svg b/comm/mail/themes/shared/mail/icons/new/normal/calendar.svg
new file mode 100644
index 0000000000..39da9f7a90
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/calendar.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20">
+ <path d="M5.5 2C3.57 2 2 3.57 2 5.5v9C2 16.43 3.57 18 5.5 18h9c1.93 0 3.5-1.57 3.5-3.5v-9C18 3.57 16.43 2 14.5 2h-9zm0 1h9C15.894 3 17 4.106 17 5.5V6h-3v-.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5V6H7v-.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5V6H3v-.5C3 4.106 4.106 3 5.5 3zM3 7h3v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V7h6v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V7h3v7.5c0 1.394-1.106 2.5-2.5 2.5h-9A2.484 2.484 0 0 1 3 14.5V7zm2.5 2a.5.5 0 1 0 0 1h1a.5.5 0 1 0 0-1h-1zm4 0a.5.5 0 1 0 0 1h1a.5.5 0 0 0 0-1h-1zm4 0a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1zm-8 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1zm4 0a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1zm4 0a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1zm-8 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1zm4 0a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1zm4 0a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1z" fill="context-stroke"/>
+ <path d="M5.5 3A2.484 2.484 0 0 0 3 5.5V6h14v-.5C17 4.106 15.894 3 14.5 3Z" fill="context-fill"/>
+</svg>
+
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/chat.svg b/comm/mail/themes/shared/mail/icons/new/normal/chat.svg
new file mode 100644
index 0000000000..4966b39fc2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/chat.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20">
+ <path d="M4.5 1C2.57 1 1 2.57 1 4.5v4C1 10.43 2.57 12 4.5 12H5v3.5a.5.5 0 0 0 .854.354L6.707 15h3.586l3.853 3.854A.5.5 0 0 0 15 18.5V15h.5c1.93 0 3.5-1.57 3.5-3.5v-4a3.503 3.503 0 0 0-3.033-3.467A3.503 3.503 0 0 0 12.5 1Zm0 1h8C13.894 2 15 3.106 15 4.5v4c0 1.394-1.106 2.5-2.5 2.5h-3a.5.5 0 0 0-.354.146L6 14.293V11.5a.5.5 0 0 0-.5-.5h-1A2.484 2.484 0 0 1 2 8.5v-4C2 3.106 3.106 2 4.5 2ZM16 5.049A2.48 2.48 0 0 1 18 7.5v4c0 1.394-1.106 2.5-2.5 2.5h-1a.5.5 0 0 0-.5.5v2.793l-3.146-3.147A.5.5 0 0 0 10.5 14H7.707l2-2H12.5c1.93 0 3.5-1.57 3.5-3.5ZM5.5 6a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm2 0a.5.5 0 1 0 0 1h2a.5.5 0 1 0 0-1zm4 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+ <path d="M16 5.049A2.48 2.48 0 0 1 18 7.5v4c0 1.394-1.106 2.5-2.5 2.5h-1a.5.5 0 0 0-.5.5v2.793l-3.146-3.147A.5.5 0 0 0 10.5 14H7.707l2-2H12.5c1.93 0 3.5-1.57 3.5-3.5Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/cloud-download.svg b/comm/mail/themes/shared/mail/icons/new/normal/cloud-download.svg
new file mode 100644
index 0000000000..b02e2252e2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/cloud-download.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" width="20" height="20">
+ <path d="M12.5 2.527A3.987 3.987 0 0 0 9.396 4 3.997 3.997 0 0 0 7.5 3.527a3.992 3.992 0 0 0-3.908 3.137A2.964 2.964 0 0 0 1.5 9.494v.067c0 1.643 1.338 2.966 3 2.966H9V10.5c0-.831.669-1.5 1.5-1.5s1.5.669 1.5 1.5v2.027h3.5c1.662 0 3-1.323 3-2.966v-.067a2.96 2.96 0 0 0-2-2.795v-.172c0-2.216-1.784-4-4-4z" fill="context-fill"/>
+ <path d="M12.5 2.027c-1.284 0-2.404.582-3.223 1.444-.554-.242-1.136-.444-1.777-.444-2.082 0-3.81 1.423-4.322 3.348C1.925 6.902 1 8.062 1 9.495v.066c0 1.915 1.573 3.466 3.5 3.466h4a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-4c-1.397 0-2.5-1.094-2.5-2.466v-.067a2.46 2.46 0 0 1 1.74-2.353.5.5 0 0 0 .34-.37A3.487 3.487 0 0 1 7.5 4.027c.604 0 1.168.15 1.66.414a.5.5 0 0 0 .625-.125A3.48 3.48 0 0 1 12.5 3.027c1.948 0 3.5 1.553 3.5 3.5V6.7a.5.5 0 0 0 .336.473A2.452 2.452 0 0 1 18 9.494v.067c0 1.372-1.103 2.466-2.5 2.466h-3a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h3c1.927 0 3.5-1.551 3.5-3.466v-.067c0-1.364-.855-2.463-2.012-3.027a4.495 4.495 0 0 0-4.488-4.44zM10.5 10a.5.5 0 0 0-.5.5v6.793l-3.146-3.147a.5.5 0 1 0-.708.708l4 4 .03.019c.03.026.064.048.1.066a.5.5 0 0 0 .225.06.5.5 0 0 0 .323-.126.438.438 0 0 0 .03-.02l4-4a.5.5 0 1 0-.708-.707L11 17.293V10.5a.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/collapse.svg b/comm/mail/themes/shared/mail/icons/new/normal/collapse.svg
new file mode 100644
index 0000000000..76055a11c5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/collapse.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20">
+ <path d="M2.5 2a.5.5 0 0 0-.5.5v14a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-14a.5.5 0 0 0-.5-.5zm8 2a.5.5 0 0 0-.354.146l-5 5A.5.5 0 0 0 5 9.5a.5.5 0 0 0 .146.354l5 5a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.708L6.707 10H17.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H6.707l4.147-4.146a.5.5 0 0 0 0-.708A.5.5 0 0 0 10.5 4z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/download.svg b/comm/mail/themes/shared/mail/icons/new/normal/download.svg
new file mode 100644
index 0000000000..20e34d6056
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/download.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewbox="0 0 20 20" width="20" height="20" fill-opacity="context-fill-opacity">
+ <path d="M10.5 3a.5.5 0 0 0-.5.5v7.793L6.854 8.146A.5.5 0 0 0 6.5 8a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708l4 4A.5.5 0 0 0 10.5 13a.5.5 0 0 0 .354-.146l4-4a.5.5 0 0 0 0-.708.5.5 0 0 0-.708 0L11 11.293V3.5a.5.5 0 0 0-.5-.5zm-6 9a.5.5 0 0 0-.5.5V14c0 1.1.9 2 2 2h9c1.1 0 2-.9 2-2v-1.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5V14c0 .563-.437 1-1 1H6c-.563 0-1-.437-1-1v-1.5a.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/draft.svg b/comm/mail/themes/shared/mail/icons/new/normal/draft.svg
new file mode 100644
index 0000000000..0814caa9bf
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/draft.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M5.5 2h9c.831 0 1.5.669 1.5 1.5v13c0 .83-.669 1.5-1.5 1.5h-9c-.831 0-1.5-.67-1.5-1.5v-13C4 2.669 4.669 2 5.5 2Z" fill="context-fill"/>
+ <path d="M5.5 1A2.506 2.506 0 0 0 3 3.5v13C3 17.876 4.124 19 5.5 19h9c1.376 0 2.5-1.124 2.5-2.5v-13C17 2.124 15.876 1 14.5 1Zm0 1h9c.84 0 1.5.66 1.5 1.5v13c0 .84-.66 1.5-1.5 1.5h-9c-.84 0-1.5-.66-1.5-1.5v-13C4 2.66 4.66 2 5.5 2Zm1 3a.5.5 0 1 0 0 1h7a.5.5 0 1 0 0-1zm0 3a.5.5 0 1 0 0 1h7a.5.5 0 1 0 0-1zm0 3a.5.5 0 1 0 0 1h5a.5.5 0 1 0 0-1zm4 3a.5.5 0 1 0 0 1h3a.5.5 0 1 0 0-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/folder-filter.svg b/comm/mail/themes/shared/mail/icons/new/normal/folder-filter.svg
new file mode 100644
index 0000000000..46b46b45e0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/folder-filter.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M3 3.5C2 3.5 1.5 4 1.5 5v9.5c0 1.108.892 2 2 2H11v-.857A4 4 0 0 1 14 9c2.21 0 4 1.5 4 3.5l.5.5V8.5c0-1.108-.892-2-2-2h-6l-2-3z" fill="context-fill"/>
+ <path d="M3 3c-.583 0-1.11.154-1.479.521C1.154 3.89 1 4.417 1 5v9.5C1 15.876 2.124 17 3.5 17h7a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-7c-.84 0-1.5-.66-1.5-1.5V7h3.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H2V5c0-.417.096-.64.229-.771C2.36 4.096 2.583 4 3 4h5.232l1.852 2.777A.5.5 0 0 0 10.5 7h6c.84 0 1.5.66 1.5 1.5v4a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-6C19 5.124 17.876 4 16.5 4h-7a.5.5 0 0 0-.059.012l-.525-.79A.5.5 0 0 0 8.5 3Zm7.102 2H16.5c.84 0 1.5.66 1.5 1.5v.006A2.482 2.482 0 0 0 16.5 6h-5.732ZM14 10c-1.65 0-3 1.35-3 3s1.35 3 3 3a2.98 2.98 0 0 0 1.736-.557l2.41 2.41a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707l-2.41-2.41c.35-.49.556-1.09.556-1.736 0-1.65-1.35-3-3-3Zm0 1c1.11 0 2 .89 2 2 0 1.11-.89 2-2 2-1.11 0-2-.89-2-2 0-1.11.89-2 2-2z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/folder-rss.svg b/comm/mail/themes/shared/mail/icons/new/normal/folder-rss.svg
new file mode 100644
index 0000000000..49e8085d7a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/folder-rss.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M3 3.5C2 3.5 1.5 4 1.5 5v9.5c0 1.108.892 2 2 2H10V11c0-1.108.892-2 2-2h5c.6 0 1.134.263 1.5.678V8.5c0-1.108-.892-2-2-2h-6l-2-3zm10 11a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5Z" fill="context-fill"/>
+ <path d="M3 3c-.583 0-1.11.154-1.479.521C1.154 3.89 1 4.417 1 5v9.5C1 15.876 2.124 17 3.5 17h6a.5.5 0 0 0 0-1h-6c-.84 0-1.5-.66-1.5-1.5V7h3.5a.5.5 0 1 0 0-1H2V5c0-.417.096-.64.229-.771C2.36 4.096 2.583 4 3 4h5.232l1.852 2.777A.5.5 0 0 0 10.5 7h6c.84 0 1.5.66 1.5 1.5v1.004a.5.5 0 0 0 1 0V6.5C19 5.124 17.876 4 16.5 4h-7a.5.5 0 0 0-.059.012l-.525-.79A.5.5 0 0 0 8.5 3Zm7.102 2H16.5c.84 0 1.5.66 1.5 1.5v.006A2.482 2.482 0 0 0 16.5 6h-5.732Zm1.398 5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H13c2.767 0 5 2.233 5 5v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V16c0-3.308-2.692-6-6-6zm0 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5H13c1.674 0 3 1.261 3 3v1.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V16c0-2.261-1.808-4-4-4zm1.5 2c-1.099 0-2 .901-2 2s.901 2 2 2 2-.901 2-2-.901-2-2-2zm0 1c.558 0 1 .442 1 1s-.442 1-1 1-1-.442-1-1 .442-1 1-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/folder.svg b/comm/mail/themes/shared/mail/icons/new/normal/folder.svg
new file mode 100644
index 0000000000..8fbc7571ba
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/folder.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M3 3.5h5.5l2 3h6c1.108 0 2 .892 2 2v6c0 1.108-.892 2-2 2h-13c-1.108 0-2-.892-2-2V5C1.5 4 2 3.5 3 3.5Z" fill="context-fill"/>
+ <path d="M3 3c-.583 0-1.11.153-1.479.521C1.154 3.889 1 4.416 1 5v9.5C1 15.876 2.124 17 3.5 17h13c1.376 0 2.5-1.124 2.5-2.5v-8C19 5.123 17.876 4 16.5 4h-7a.5.5 0 0 0-.059.011l-.525-.789A.5.5 0 0 0 8.5 3Zm0 1h5.232l1.852 2.777A.5.5 0 0 0 10.5 7h6c.84 0 1.5.66 1.5 1.5v6c0 .84-.66 1.5-1.5 1.5h-13c-.84 0-1.5-.66-1.5-1.5V7h3.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H2V5c0-.417.096-.64.229-.772C2.361 4.096 2.583 4 3 4Zm7.102 1H16.5c.84 0 1.5.66 1.5 1.5v.006A2.482 2.482 0 0 0 16.5 6h-5.732z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/globe-secure.svg b/comm/mail/themes/shared/mail/icons/new/normal/globe-secure.svg
new file mode 100644
index 0000000000..b64b797123
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/globe-secure.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M9.5 1.5a8 8 0 0 0-8 8 8 8 0 0 0 8 8 8 8 0 0 0 .5-.035V14.5c0-.78.434-1.393 1-1.852V12.5c0-2.472 2.028-4.5 4.5-4.5.689 0 1.342.157 1.928.438A8 8 0 0 0 9.5 1.5Zm3 12.5c-.277 0-.5.223-.5.5v3c0 .277.223.5.5.5h6c.277 0 .5-.223.5-.5v-3c0-.277-.223-.5-.5-.5Z" fill="context-fill"/>
+ <path d="M9.5 1c-.432 0-.855.033-1.27.096l-.007-.012-.02.014a8.52 8.52 0 0 0-7.105 7.105l-.014.02.012.007a8.535 8.535 0 0 0 0 2.54l-.012.007.014.02a8.52 8.52 0 0 0 7.105 7.105l.02.014.007-.012a8.533 8.533 0 0 0 1.817.076A2.478 2.478 0 0 1 10 17.5v-.518a7.353 7.353 0 0 1-1.4-.037c-.238-.202-.687-.647-1.17-1.453a8.951 8.951 0 0 1-.795-1.744c.843.156 1.793.252 2.865.252.194 0 .38-.008.566-.014.104-.396.325-.739.606-1.033A15.91 15.91 0 0 1 9.5 13c-1.23 0-2.277-.13-3.168-.332A14.277 14.277 0 0 1 6 9.5c0-1.23.13-2.278.332-3.168C7.222 6.13 8.27 6 9.5 6c1.23 0 2.277.13 3.168.332.161.71.274 1.522.314 2.445.3-.204.625-.374.971-.502a15.02 15.02 0 0 0-.203-1.64c.704.238 1.28.518 1.742.795.4.239.707.469.944.67a4.477 4.477 0 0 1 1.525.64 8.728 8.728 0 0 0-.057-.51l.012-.007-.014-.02a8.52 8.52 0 0 0-7.105-7.105 24.73 24.73 0 0 1-.02-.014l-.007.012A8.533 8.533 0 0 0 9.5 1Zm0 1c.305 0 .605.02.9.055.238.202.687.647 1.17 1.453.278.462.557 1.039.795 1.744A15.706 15.706 0 0 0 9.5 5c-1.072 0-2.022.096-2.865.252a8.951 8.951 0 0 1 .795-1.744c.483-.806.932-1.25 1.17-1.453.295-.036.595-.055.9-.055Zm-2.572.451a7.864 7.864 0 0 0-.358.541c-.388.647-.771 1.48-1.062 2.52a10.25 10.25 0 0 0-2.516 1.06c-.198.12-.375.239-.54.358a7.502 7.502 0 0 1 4.475-4.48Zm5.144 0A7.502 7.502 0 0 1 16.55 6.93a7.891 7.891 0 0 0-.541-.358 10.269 10.269 0 0 0-2.516-1.06 10.237 10.237 0 0 0-1.062-2.52 7.865 7.865 0 0 0-.358-.54ZM5.25 6.635A15.71 15.71 0 0 0 5 9.5c0 1.073.094 2.024.25 2.867a8.923 8.923 0 0 1-1.742-.795c-.806-.483-1.25-.933-1.453-1.172a7.588 7.588 0 0 1 0-1.798c.202-.238.645-.688 1.453-1.172a8.947 8.947 0 0 1 1.742-.795ZM15.5 9c-1.93 0-3.5 1.57-3.5 3.5v.59c-.58.208-1 .763-1 1.41v3c0 .822.678 1.5 1.5 1.5h6c.822 0 1.5-.678 1.5-1.5v-3c0-.647-.42-1.202-1-1.41v-.59c0-1.93-1.57-3.5-3.5-3.5Zm0 1c1.394 0 2.5 1.106 2.5 2.5v.5h-5v-.5c0-1.394 1.106-2.5 2.5-2.5ZM2.453 12.074c.165.118.342.237.54.356.646.388 1.478.77 2.517 1.06.29 1.039.672 1.871 1.06 2.518.12.198.239.375.358.54a7.505 7.505 0 0 1-4.475-4.474ZM12.5 14h6c.286 0 .5.214.5.5v3c0 .286-.214.5-.5.5h-6a.488.488 0 0 1-.5-.5v-3c0-.286.214-.5.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/globe.svg b/comm/mail/themes/shared/mail/icons/new/normal/globe.svg
new file mode 100644
index 0000000000..1fc85ec75d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/globe.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M17.5 9.5a8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8z" fill="context-fill"/>
+ <path d="M9.5 1c-.432 0-.855.033-1.27.096l-.007-.012-.02.014a8.52 8.52 0 0 0-7.105 7.105l-.014.02.012.007a8.535 8.535 0 0 0 0 2.54l-.012.007.014.02a8.52 8.52 0 0 0 7.105 7.105l.02.014.007-.012a8.533 8.533 0 0 0 2.54 0l.007.012.02-.014a8.52 8.52 0 0 0 7.105-7.105l.014-.02-.012-.007a8.533 8.533 0 0 0 0-2.54l.012-.007-.014-.02a8.52 8.52 0 0 0-7.105-7.105 24.73 24.73 0 0 1-.02-.014l-.007.012A8.533 8.533 0 0 0 9.5 1Zm0 1c.305 0 .605.02.9.055.238.202.687.647 1.17 1.453.278.462.557 1.039.795 1.744A15.706 15.706 0 0 0 9.5 5c-1.072 0-2.022.096-2.865.252a8.951 8.951 0 0 1 .795-1.744c.483-.806.932-1.25 1.17-1.453.295-.036.595-.055.9-.055Zm-2.572.451a7.864 7.864 0 0 0-.358.541c-.388.647-.771 1.48-1.062 2.52a10.25 10.25 0 0 0-2.516 1.06c-.198.12-.375.239-.54.358a7.502 7.502 0 0 1 4.475-4.48Zm5.144 0A7.502 7.502 0 0 1 16.55 6.93a7.891 7.891 0 0 0-.541-.358 10.269 10.269 0 0 0-2.516-1.06 10.237 10.237 0 0 0-1.062-2.52 7.864 7.864 0 0 0-.358-.54ZM9.5 6c1.23 0 2.277.13 3.168.332.202.89.332 1.938.332 3.168 0 1.23-.13 2.277-.332 3.168-.89.202-1.938.332-3.168.332-1.23 0-2.277-.13-3.168-.332A14.277 14.277 0 0 1 6 9.5c0-1.23.13-2.278.332-3.168C7.222 6.13 8.27 6 9.5 6Zm-4.25.635A15.71 15.71 0 0 0 5 9.5c0 1.073.094 2.024.25 2.867a8.923 8.923 0 0 1-1.742-.795c-.806-.483-1.25-.933-1.453-1.172a7.588 7.588 0 0 1 0-1.798c.202-.238.645-.688 1.453-1.172a8.947 8.947 0 0 1 1.742-.795Zm8.5 0c.704.238 1.28.518 1.742.795.808.484 1.251.934 1.453 1.172.035.294.055.594.055.898 0 .305-.02.605-.055.9-.202.239-.647.689-1.453 1.172a8.923 8.923 0 0 1-1.742.795c.156-.843.25-1.794.25-2.867a15.71 15.71 0 0 0-.25-2.865Zm-11.297 5.44c.165.117.342.236.54.355.646.388 1.478.77 2.517 1.06.29 1.039.672 1.871 1.06 2.518.12.198.239.375.358.54a7.505 7.505 0 0 1-4.475-4.474Zm14.094 0a7.505 7.505 0 0 1-4.475 4.474c.12-.166.239-.343.358-.541.388-.647.77-1.48 1.06-2.518a10.253 10.253 0 0 0 2.518-1.06c.197-.119.374-.238.539-.356zm-9.912 1.673c.843.156 1.793.252 2.865.252 1.072 0 2.022-.096 2.865-.252a8.951 8.951 0 0 1-.795 1.744c-.483.806-.932 1.25-1.17 1.453a7.588 7.588 0 0 1-1.8 0c-.238-.202-.687-.647-1.17-1.453a8.951 8.951 0 0 1-.795-1.744Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/inbox.svg b/comm/mail/themes/shared/mail/icons/new/normal/inbox.svg
new file mode 100644
index 0000000000..8a2153c19a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/inbox.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M5.5 3A2.495 2.495 0 0 0 3 5.5v9C3 15.885 4.115 17 5.5 17h9c1.385 0 2.5-1.115 2.5-2.5v-9C17 4.115 15.885 3 14.5 3Zm1 3h7c.277 0 .5.223.5.5V9h.5c.277 0 .5.223.5.5V11h-1.473c0 1.657-1.87 3-3.527 3s-3.521-1.343-3.521-3H5V9.5c0-.277.223-.5.5-.5H6V6.5c0-.277.223-.5.5-.5Z" fill="context-fill"/>
+ <path d="M5.5 2C3.57 2 2 3.57 2 5.5v9C2 16.43 3.57 18 5.5 18h9c1.93 0 3.5-1.57 3.5-3.5v-9C18 3.57 16.43 2 14.5 2Zm0 1h9C15.894 3 17 4.106 17 5.5V11h-1V9.5c0-.647-.42-1.202-1-1.41V6.5c0-.822-.678-1.5-1.5-1.5h-7C5.678 5 5 5.678 5 6.5v1.59c-.58.208-1 .763-1 1.41V11H3V5.5C3 4.106 4.106 3 5.5 3Zm1 3h7c.286 0 .5.214.5.5V8H6V6.5c0-.286.214-.5.5-.5Zm-1 3h9c.286 0 .5.214.5.5V11h-1.496a.5.5 0 0 0-.5.502c.003 1.15-1.327 2.514-3.004 2.514s-3.007-1.364-3.004-2.514a.5.5 0 0 0-.5-.502H5V9.5c0-.286.214-.5.5-.5ZM3 12h3.115C6.41 13.632 8.01 15.016 10 15.016c1.99 0 3.59-1.384 3.885-3.016H17v2.5c0 1.394-1.106 2.5-2.5 2.5h-9A2.484 2.484 0 0 1 3 14.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/link.svg b/comm/mail/themes/shared/mail/icons/new/normal/link.svg
new file mode 100644
index 0000000000..dd03bdf405
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/link.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M14.488 1.001a2.559 2.559 0 0 0-1.814.751L9.751 4.676c-1 .999-1 2.629-.001 3.628l.62.619-1.448 1.449-.62-.62c-1-1-2.63-1-3.63 0l-2.921 2.922c-1 1-1 2.631-.001 3.63l1.949 1.948c1 .999 2.63.999 3.63 0l2.921-2.922c1-1 1-2.63 0-3.63l-.62-.62 1.449-1.449.62.62c.998.999 2.63 1 3.63 0l2.923-2.921c1-1 1-2.632 0-3.632l-1.949-1.947c-.5-.499-1.156-.75-1.815-.75Zm-.001.993c.399 0 .8.155 1.109.465l1.947 1.948c.62.62.62 1.593 0 2.214L14.62 9.544a1.55 1.55 0 0 1-2.214 0l-.62-.62 2.068-2.068a.5.5 0 0 0 0-.708.5.5 0 0 0-.352-.148.5.5 0 0 0-.355.148l-2.068 2.069-.622-.622a1.552 1.552 0 0 1-.002-2.215l2.924-2.923c.31-.31.709-.464 1.108-.464Zm-8 8c.399 0 .8.155 1.109.465l.618.62-2.068 2.068a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0l2.069-2.068.62.619c.62.62.621 1.597 0 2.218l-2.92 2.921c-.62.62-1.598.621-2.219.001l-1.948-1.948a1.556 1.556 0 0 1 .001-2.218L5.38 10.46a1.56 1.56 0 0 1 1.107-.465Z" fill="context-stroke"/>
+</svg>
+
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/mail-secure.svg b/comm/mail/themes/shared/mail/icons/new/normal/mail-secure.svg
new file mode 100644
index 0000000000..7fe4750e9a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/mail-secure.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path fill-opacity="1" d="M3.5 4C2.669 4 2 4.669 2 5.5v9c0 .831.669 1.5 1.5 1.5H10v-1.5c0-.78.434-1.393 1-1.852V12.5c0-2.472 2.028-4.5 4.5-4.5a4.46 4.46 0 0 1 2.5.766V5.5c0-.831-.669-1.5-1.5-1.5Zm9 10c-.277 0-.5.223-.5.5v3c0 .277.223.5.5.5h6c.277 0 .5-.223.5-.5v-3c0-.277-.223-.5-.5-.5Z" fill="context-fill"/>
+ <path d="M3.5 3A2.506 2.506 0 0 0 1 5.5v9C1 15.876 2.124 17 3.5 17H10v-1H3.5c-.84 0-1.5-.66-1.5-1.5v-9C2 4.66 2.66 4 3.5 4h13c.84 0 1.5.66 1.5 1.5v3.266c.377.254.715.563 1 .916V5.5C19 4.124 17.876 3 16.5 3Zm.977 3.002a.5.5 0 0 0-.346.162.5.5 0 0 0 .033.705l3.28 2.98-3.298 3.297a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0l3.33-3.33 1.48 1.345a.5.5 0 0 0 .672 0l.91-.828a4.536 4.536 0 0 1 3.207-2.916l1.383-1.256a.5.5 0 0 0 .033-.705.5.5 0 0 0-.705-.033L10 10.824 4.836 6.131a.5.5 0 0 0-.36-.129ZM15.5 9c-1.93 0-3.5 1.57-3.5 3.5v.59c-.58.208-1 .763-1 1.41v3c0 .822.678 1.5 1.5 1.5h6c.822 0 1.5-.678 1.5-1.5v-3c0-.647-.42-1.202-1-1.41v-.59c0-1.93-1.57-3.5-3.5-3.5Zm0 1c1.394 0 2.5 1.106 2.5 2.5v.5h-5v-.5c0-1.394 1.106-2.5 2.5-2.5Zm-3 4h6c.286 0 .5.214.5.5v3c0 .286-.214.5-.5.5h-6a.488.488 0 0 1-.5-.5v-3c0-.286.214-.5.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/mail.svg b/comm/mail/themes/shared/mail/icons/new/normal/mail.svg
new file mode 100644
index 0000000000..ddc100a26a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/mail.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20">
+ <path d="M3.5 3.5h13c1.108 0 2 .892 2 2v9c0 1.108-.892 2-2 2h-13c-1.108 0-2-.892-2-2v-9c0-1.108.892-2 2-2z" fill="context-fill"/>
+ <path d="M3.5 3A2.506 2.506 0 0 0 1 5.5v9C1 15.876 2.124 17 3.5 17h13c1.376 0 2.5-1.124 2.5-2.5v-9C19 4.123 17.876 3 16.5 3Zm0 1h13c.84 0 1.5.66 1.5 1.5v9c0 .84-.66 1.5-1.5 1.5h-13c-.84 0-1.5-.66-1.5-1.5v-9C2 4.66 2.66 4 3.5 4Zm.977 2.002a.5.5 0 0 0-.346.162.5.5 0 0 0 .033.705l3.28 2.98-3.298 3.297a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l3.33-3.33 1.48 1.346a.5.5 0 0 0 .672 0l1.48-1.346 3.33 3.33a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707l-3.297-3.297 3.279-2.98a.5.5 0 0 0 .033-.705.5.5 0 0 0-.705-.033L10 10.824 4.836 6.131a.5.5 0 0 0-.36-.13z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/more.svg b/comm/mail/themes/shared/mail/icons/new/normal/more.svg
new file mode 100644
index 0000000000..6dc269cb3d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/more.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewbox="0 0 20 20" width="20" height="20" fill-opacity="context-fill-opacity">
+ <path d="M5 9a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm5 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm5 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/newsletter.svg b/comm/mail/themes/shared/mail/icons/new/normal/newsletter.svg
new file mode 100644
index 0000000000..bbc88dfbf7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/newsletter.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M8 2c-1.1 0-2 .9-2 2v3H3c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h2.445c.062.007.124.013.192.016C6.065 17.033 6.758 17 8 17h9c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2Zm0 1h9c.563 0 1 .437 1 1v11c0 .563-.437 1-1 1H8c-.766 0-1.069.006-1.414.01A.5.5 0 0 0 6.5 16h-.367c.125-.174.207-.25.347-.537.28-.574.52-1.372.52-2.44V4c0-.563.437-1 1-1Zm1 2c-.545 0-1 .455-1 1v3c0 .545.455 1 1 1h3c.545 0 1-.455 1-1V6c0-.545-.455-1-1-1Zm5.5 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5ZM9 6h3v3H9Zm5.5 1a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5ZM3 8h3v5.023c0 .921-.198 1.551-.418 2.002-.22.452-.448.689-.623.95L4.943 16H3c-.563 0-1-.437-1-1V9c0-.563.437-1 1-1Zm11.5 1a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm-6 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm0 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+ <path d="M3 8h3v5.023c0 .921-.198 1.551-.418 2.002-.22.452-.448.689-.623.95L4.943 16H3c-.563 0-1-.437-1-1V9c0-.563.437-1 1-1Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/outbox.svg b/comm/mail/themes/shared/mail/icons/new/normal/outbox.svg
new file mode 100644
index 0000000000..5ef8c6b366
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/outbox.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M5.5 3A2.495 2.495 0 0 0 3 5.5v9C3 15.885 4.115 17 5.5 17h6.59l-1.455-.688C9.72 16.058 9 15.338 9 14.17c0-.11.01-.214.023-.316-1.335-.39-2.544-1.52-2.544-2.854H5V9.5c0-.277.223-.5.5-.5H6V6.5c0-.277.223-.5.5-.5h7c.277 0 .5.223.5.5V9h.5c.277 0 .5.223.5.5v.762l2-.787V5.5C17 4.115 15.885 3 14.5 3Zm5.135 13.313c.074.02.15.038.226.052l-.363-.117.137.064z" fill="context-fill"/>
+ <path d="M5.5 2C3.57 2 2 3.57 2 5.5v9C2 16.43 3.57 18 5.5 18h7.537l-.332-.709L12.09 17H5.5A2.484 2.484 0 0 1 3 14.5V12h3.115c.25 1.38 1.434 2.58 2.998 2.918a2.424 2.424 0 0 1-.09-1.063c-1.187-.391-2.03-1.438-2.027-2.353a.5.5 0 0 0-.5-.502H5V9.5c0-.286.214-.5.5-.5h9c.286 0 .5.214.5.5v.762l1-.395V9.5c0-.647-.42-1.202-1-1.41V6.5c0-.822-.678-1.5-1.5-1.5h-7C5.678 5 5 5.678 5 6.5v1.59c-.58.208-1 .763-1 1.41V11H3V5.5C3 4.106 4.106 3 5.5 3h9C15.894 3 17 4.106 17 5.5v3.975l.201-.08.004-.002c.23-.093.494-.2.795-.28V5.5C18 3.57 16.43 2 14.5 2Zm1 4h7c.286 0 .5.214.5.5V8H6V6.5c0-.286.214-.5.5-.5Zm12.334 4c-.47 0-.832.151-1.256.322l-6.596 2.594c-.542.168-.982.64-.982 1.254 0 .629.447 1.054.957 1.187l-.031-.013.12.039c-.03-.006-.059-.018-.089-.026l2.5 1.184 1.176 2.508c-.008-.028-.019-.054-.026-.082l.034.097-.008-.015c.07.23.196.438.377.61.203.19.489.341.824.341.629 0 1.041-.484 1.24-.924a.5.5 0 0 0 .012-.027l2.607-6.762a.5.5 0 0 0 .008-.025c.097-.297.299-.606.299-1.096 0-.638-.528-1.166-1.166-1.166Zm0 1c.098 0 .166.068.166.166 0 .175-.112.369-.246.78l-.002.005-2.59 6.713c-.11.244-.22.336-.328.336-.033 0-.08-.015-.137-.068a.472.472 0 0 1-.117-.194.5.5 0 0 0-.035-.097l-1.11-2.368 1.752-1.752a.5.5 0 0 0 0-.707.5.5 0 0 0-.353-.148.5.5 0 0 0-.355.148l-1.75 1.75-2.375-1.125a.5.5 0 0 0-.122-.039c-.073-.013-.232-.16-.232-.23 0-.102.117-.257.295-.305a.5.5 0 0 0 .055-.02l6.593-2.593a.5.5 0 0 0 .004-.002c.43-.173.622-.25.887-.25Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/overflow.svg b/comm/mail/themes/shared/mail/icons/new/normal/overflow.svg
new file mode 100644
index 0000000000..b35b9955b1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/overflow.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20">
+ <path d="M4.5 4a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708L8.793 9.5l-4.647 4.646a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0l5-5a.5.5 0 0 0 0-.708l-5-5A.5.5 0 0 0 4.5 4zm6 0a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708L14.793 9.5l-4.647 4.646a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0l5-5a.5.5 0 0 0 0-.708l-5-5A.5.5 0 0 0 10.5 4z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/rss.svg b/comm/mail/themes/shared/mail/icons/new/normal/rss.svg
new file mode 100644
index 0000000000..ab5c7c902f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/rss.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M4 2.5a1.5 1.5 0 1 0 0 3h1a9.5 9.5 0 0 1 9.422 8.5l.078 2a1.5 1.5 0 1 0 2.984-.221v-.002L17.428 14A12.5 12.5 0 0 0 5 2.5Zm0 5a1.5 1.5 0 1 0 0 3h1A4.5 4.5 0 0 1 9.373 14l.127 2a1.5 1.5 0 1 0 2.984-.221v-.002L12.428 14A7.5 7.5 0 0 0 5 7.5Zm1 5A2.5 2.5 0 0 0 2.5 15 2.5 2.5 0 0 0 5 17.5 2.5 2.5 0 0 0 7.5 15 2.5 2.5 0 0 0 5 12.5Z" fill="context-fill"/>
+ <path d="M4 2c-1.099 0-2 .9-2 2 0 1.098.901 2 2 2h1a8.994 8.994 0 0 1 8.924 8.05v.002L14 16c0 1.098.901 2 2 2 1.098 0 1.999-.9 2-1.999v-.002c0-.074-.007-.148-.016-.222a.5.5 0 0 0 0-.016l-.056-1.777a.5.5 0 0 0-.002-.024C17.386 7.223 11.761 2.017 5.002 2A.5.5 0 0 0 5 2Zm0 1h1a11.994 11.994 0 0 1 11.928 11.037l.056 1.748a.5.5 0 0 0 .006.066c.007.049.01.097.01.147A.5.5 0 0 0 17 16c0 .558-.442 1-1 1s-1-.442-1-1a.5.5 0 0 0 0-.02l-.078-2a.5.5 0 0 0-.002-.033A10.006 10.006 0 0 0 5.002 5 .5.5 0 0 0 5 5H4c-.558 0-1-.442-1-1 0-.559.442-1 1-1Zm0 4c-1.099 0-2 .9-2 2 0 1.098.901 2 2 2h1a3.993 3.993 0 0 1 3.879 3.103L9 16c0 1.098.901 2 2 2 1.098 0 1.999-.9 2-1.999v-.002c0-.074-.007-.148-.016-.222a.5.5 0 0 0 0-.016l-.056-1.777a.5.5 0 0 0-.004-.05A8.007 8.007 0 0 0 5 7Zm0 1h1a6.992 6.992 0 0 1 6.93 6.058l.054 1.727a.5.5 0 0 0 .006.066c.007.049.01.097.01.147A.5.5 0 0 0 12 16c0 .558-.442 1-1 1s-1-.442-1-1a.5.5 0 0 0-.002-.032l-.127-2a.5.5 0 0 0-.01-.08A5.005 5.005 0 0 0 5.002 10 .5.5 0 0 0 5 10H4c-.558 0-1-.442-1-1 0-.559.442-1 1-1Zm1 4c-1.65 0-3 1.349-3 3 0 1.65 1.35 3 3 3s3-1.35 3-3c0-1.651-1.35-3-3-3zm0 1c1.11 0 2 .889 2 2 0 1.11-.89 2-2 2-1.11 0-2-.89-2-2 0-1.111.89-2 2-2z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/sent.svg b/comm/mail/themes/shared/mail/icons/new/normal/sent.svg
new file mode 100644
index 0000000000..dd16150659
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/sent.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path fill-rule="evenodd" d="M10.387 16.528c.073.544.558.972 1.11.972s.947-.432 1.178-.946L17.087 4.91c.175-.536.41-.858.41-1.41a1 1 0 0 0-1-1c-.552 0-.967.17-1.608.429L3.503 7.322c-.56.15-1 .631-1 1.184 0 .552.422 1.008.96 1.082L8.497 11.5Z" fill="context-fill"/>
+ <path fill-rule="evenodd" d="M16.496 2c-.655 0-1.155.207-1.795.465L3.322 6.855l.051-.015c-.754.202-1.37.847-1.37 1.666 0 .826.627 1.404 1.306 1.558l-.024-.01.084.026a.665.665 0 0 1-.06-.016l4.8 1.823 1.81 4.818c.088.32.263.612.515.85.267.253.635.445 1.062.445.813 0 1.359-.63 1.635-1.24a.5.5 0 0 0 .012-.028l4.412-11.644a.5.5 0 0 0 .008-.022c.151-.466.433-.872.433-1.566 0-.822-.677-1.5-1.5-1.5Zm0 1c.282 0 .5.218.5.5 0 .41-.187.65-.385 1.256l-4.396 11.598c-.187.41-.43.646-.719.646a.556.556 0 0 1-.377-.172.913.913 0 0 1-.246-.412.5.5 0 0 0-.018-.062l-1.777-4.727 2.774-2.773a.5.5 0 0 0 0-.708.5.5 0 0 0-.71 0l-2.77 2.772L3.64 9.121a.5.5 0 0 0-.086-.023c-.229-.044-.551-.338-.551-.592 0-.286.265-.604.629-.701a.5.5 0 0 0 .05-.016l11.385-4.393a.5.5 0 0 0 .008-.003c.643-.26.97-.393 1.42-.393Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/settings.svg b/comm/mail/themes/shared/mail/icons/new/normal/settings.svg
new file mode 100644
index 0000000000..4801da3d2b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/settings.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20">
+ <path d="M9.5 1.5c-.554 0-1 .446-1 1v1.191a6.5 6.5 0 0 0-3.21 1.852l-1.032-.594a.997.997 0 0 0-1.365.365l-.5.868a.997.997 0 0 0 .365 1.365l1.017.588A6.5 6.5 0 0 0 3.5 10a6.5 6.5 0 0 0 .273 1.86l-1.015.585a.997.997 0 0 0-.365 1.366l.5.865a.997.997 0 0 0 1.365.367l1.025-.592A6.5 6.5 0 0 0 8.5 16.31v1.183c0 .554.446 1 1 1h1c.554 0 1-.446 1-1V16.31a6.5 6.5 0 0 0 3.217-1.858l1.025.592a.997.997 0 0 0 1.365-.367l.5-.865a.997.997 0 0 0-.365-1.366l-1.015-.586A6.5 6.5 0 0 0 16.5 10a6.5 6.5 0 0 0-.273-1.867l1.015-.586a.997.997 0 0 0 .365-1.365l-.5-.868a.997.997 0 0 0-1.365-.365l-1.02.588a6.5 6.5 0 0 0-3.222-1.86V2.5c0-.554-.446-1-1-1zm.5 6a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 7.5 10 2.5 2.5 0 0 1 10 7.5Z" fill="context-fill"/>
+ <path d="M9.5 1C8.678 1 8 1.678 8 2.5v.857a6.95 6.95 0 0 0-2.75 1.586l-.742-.427a1.508 1.508 0 0 0-2.049.548l-.5.868a1.508 1.508 0 0 0 .549 2.048l.728.42C3.112 8.926 3.001 9.458 3 10c0 .54.11 1.068.234 1.592l-.726.42a1.508 1.508 0 0 0-.549 2.049l.5.865c.41.711 1.336.962 2.049.55l.736-.425A6.948 6.948 0 0 0 8 16.643v.85c0 .822.678 1.5 1.5 1.5h1c.822 0 1.5-.678 1.5-1.5v-.85a6.948 6.948 0 0 0 2.756-1.592l.736.426a1.51 1.51 0 0 0 2.049-.551l.5-.865a1.508 1.508 0 0 0-.549-2.05l-.726-.42c.123-.523.233-1.052.234-1.591 0-.542-.11-1.073-.234-1.6l.726-.42c.712-.41.96-1.337.549-2.048l-.5-.868a1.508 1.508 0 0 0-2.049-.548l-.73.421A6.946 6.946 0 0 0 12 3.345V2.5c0-.822-.678-1.5-1.5-1.5Zm0 1h1c.286 0 .5.214.5.5v1.178a.5.5 0 0 0 .385.486 6 6 0 0 1 2.974 1.717.5.5 0 0 0 .614.09l1.02-.588a.486.486 0 0 1 .68.181l.5.868a.486.486 0 0 1-.18.681l-1.016.586a.5.5 0 0 0-.229.576 6.007 6.007 0 0 1 0 3.442.5.5 0 0 0 .229.576l1.015.586a.486.486 0 0 1 .182.682l-.5.865a.487.487 0 0 1-.682.183l-1.025-.591a.5.5 0 0 0-.613.09 5.999 5.999 0 0 1-2.97 1.714.5.5 0 0 0-.384.487v1.183c0 .286-.214.5-.5.5h-1a.488.488 0 0 1-.5-.5V16.31a.5.5 0 0 0-.385-.487 5.999 5.999 0 0 1-2.969-1.715.5.5 0 0 0-.613-.09l-1.025.592a.487.487 0 0 1-.682-.183l-.5-.865a.486.486 0 0 1 .182-.682l1.015-.586a.5.5 0 0 0 .229-.576 5.993 5.993 0 0 1 .002-3.438.5.5 0 0 0-.229-.578l-1.017-.588a.486.486 0 0 1-.182-.681l.5-.868a.486.486 0 0 1 .682-.181l1.031.594a.5.5 0 0 0 .613-.09 5.996 5.996 0 0 1 2.963-1.71A.5.5 0 0 0 9 3.692V2.5c0-.286.214-.5.5-.5Zm.5 5c-1.65 0-3 1.35-3 3s1.35 3 3 3 3-1.35 3-3-1.35-3-3-3zm0 1c1.11 0 2 .89 2 2 0 1.11-.89 2-2 2-1.11 0-2-.89-2-2 0-1.11.89-2 2-2Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/spam.svg b/comm/mail/themes/shared/mail/icons/new/normal/spam.svg
new file mode 100644
index 0000000000..b15e1534ea
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/spam.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M8.5.5c11.91 4.927 8.427 16.652 3 18 1.5-2.598 0-5-2-5s-3.5 2.402-2 5c-4-.5-8-8-2-14C5 8 7 9 8 8.5c2-1 1.906-5.263.5-8z" fill="context-fill"/>
+ <path d="M8.691.037a.5.5 0 0 0-.636.692c.656 1.278 1.02 2.967.972 4.406-.047 1.439-.507 2.546-1.25 2.918-.261.13-.782.098-1.218-.387-.437-.485-.8-1.445-.565-3.096a.5.5 0 0 0-.848-.424C2.034 7.26 1.473 10.85 2.268 13.697c.794 2.848 2.885 5.013 5.17 5.3a.5.5 0 0 0 .496-.747c-.681-1.179-.658-2.242-.295-3.01C8.002 14.472 8.7 14 9.5 14s1.498.472 1.861 1.24.386 1.831-.295 3.01a.5.5 0 0 0 .555.734c6.037-1.5 9.27-13.9-2.93-18.947Zm.717 1.545c9.382 4.699 7.029 13.754 2.91 15.955.27-.993.31-1.955-.054-2.724C11.752 13.73 10.7 13 9.5 13c-1.2 0-2.252.73-2.764 1.813-.36.761-.325 1.712-.064 2.695-1.436-.621-2.858-1.995-3.44-4.08-.621-2.228-.246-4.939 1.803-7.53.074 1.037.307 1.91.781 2.436.689.765 1.668.983 2.407.613 1.257-.628 1.75-2.152 1.804-3.779.04-1.18-.224-2.417-.619-3.586Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/subtract-circle.svg b/comm/mail/themes/shared/mail/icons/new/normal/subtract-circle.svg
new file mode 100644
index 0000000000..fbc0ecd1dc
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/subtract-circle.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M17.5 9.5a8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8Z" stroke-opacity="0.2" stroke="context-stroke" fill="context-fill"/>
+ <path d="M5.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/tasks.svg b/comm/mail/themes/shared/mail/icons/new/normal/tasks.svg
new file mode 100644
index 0000000000..772133faaf
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/tasks.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20">
+ <path d="M5.5 2C4.66 2 4 2.66 4 3.5v13c0 .84.66 1.5 1.5 1.5h9c.84 0 1.5-.66 1.5-1.5v-13c0-.84-.66-1.5-1.5-1.5h-1.588c-.208.58-.765 1-1.412 1h-3c-.647 0-1.204-.42-1.412-1Z" fill="context-fill"/>
+ <path d="M8.5 0c-.647 0-1.204.42-1.412 1H5.5A2.506 2.506 0 0 0 3 3.5v13C3 17.876 4.124 19 5.5 19h9c1.376 0 2.5-1.124 2.5-2.5v-13C17 2.124 15.876 1 14.5 1h-1.588c-.208-.58-.765-1-1.412-1Zm0 1h3c.286 0 .5.214.5.5 0 .286-.214.5-.5.5h-3a.488.488 0 0 1-.5-.5c0-.286.214-.5.5-.5Zm-3 1h1.588c.208.58.765 1 1.412 1h3c.647 0 1.204-.42 1.412-1H14.5c.84 0 1.5.66 1.5 1.5v13c0 .84-.66 1.5-1.5 1.5h-9c-.84 0-1.5-.66-1.5-1.5v-13C4 2.66 4.66 2 5.5 2Zm7.902 5.01a.5.5 0 0 0-.318.213l-3.662 5.492-2.568-2.569a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .77-.077l4-6a.5.5 0 0 0-.139-.693.5.5 0 0 0-.375-.074Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/template.svg b/comm/mail/themes/shared/mail/icons/new/normal/template.svg
new file mode 100644
index 0000000000..0d2b3ccf7a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/template.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M5.5 2h9c.831 0 1.5.669 1.5 1.5v13c0 .83-.669 1.5-1.5 1.5h-9c-.831 0-1.5-.67-1.5-1.5v-13C4 2.669 4.669 2 5.5 2Z" fill="context-fill"/>
+ <path d="M5.5 1A2.506 2.506 0 0 0 3 3.5v13C3 17.876 4.124 19 5.5 19h9c1.376 0 2.5-1.124 2.5-2.5v-13C17 2.124 15.876 1 14.5 1Zm0 1h9c.84 0 1.5.66 1.5 1.5v13c0 .84-.66 1.5-1.5 1.5h-9c-.84 0-1.5-.66-1.5-1.5v-13C4 2.66 4.66 2 5.5 2ZM10 4a.5.5 0 0 0-.5.5v.563A2.011 2.011 0 0 0 8 7c0 .705.372 1.327.928 1.684L7.98 12H6.5a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5h1.195l-.675 2.363a.5.5 0 0 0 .343.617.5.5 0 0 0 .617-.343L8.734 13h2.532l.754 2.637a.5.5 0 0 0 .617.343.5.5 0 0 0 .343-.617L12.305 13H13.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5h-1.48l-.948-3.316C11.628 8.327 12 7.704 12 7c0-.926-.64-1.714-1.5-1.938V4.5A.5.5 0 0 0 10 4Zm0 2c.558 0 1 .442 1 1s-.442 1-1 1-1-.442-1-1 .442-1 1-1Zm-.123 2.996a1.97 1.97 0 0 0 .246 0L10.98 12H9.02Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/normal/trash.svg b/comm/mail/themes/shared/mail/icons/new/normal/trash.svg
new file mode 100644
index 0000000000..a82f19a871
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/normal/trash.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 20 20" height="20" width="20">
+ <path d="M4.5 3C3.669 3 3 3.669 3 4.5V6h14V4.5c0-.831-.669-1.5-1.5-1.5ZM4 7v9.5c0 .831.669 1.5 1.5 1.5h9c.831 0 1.5-.669 1.5-1.5V7Z" fill="context-fill"/>
+ <path d="M8 1c-.545 0-1 .455-1 1H4.5A2.506 2.506 0 0 0 2 4.5V6c0 .545.455 1 1 1v9.5C3 17.876 4.124 19 5.5 19h9c1.376 0 2.5-1.124 2.5-2.5V7c.545 0 1-.455 1-1V4.5C18 3.124 16.876 2 15.5 2H13c0-.545-.455-1-1-1ZM4.5 3h11c.84 0 1.5.66 1.5 1.5V6H3V4.5C3 3.66 3.66 3 4.5 3ZM4 7h12v9.5c0 .84-.66 1.5-1.5 1.5h-9c-.84 0-1.5-.66-1.5-1.5Zm2.5 2c-.277 0-.5.223-.5.5v6a.5.5 0 1 0 1 0v-6c0-.277-.223-.5-.5-.5Zm2 0c-.277 0-.5.223-.5.5v6a.5.5 0 1 0 1 0v-6c0-.277-.223-.5-.5-.5Zm3 0c-.277 0-.5.223-.5.5v6a.5.5 0 1 0 1 0v-6c0-.277-.223-.5-.5-.5Zm2 0c-.277 0-.5.223-.5.5v6a.5.5 0 1 0 1 0v-6c0-.277-.223-.5-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/notify.svg b/comm/mail/themes/shared/mail/icons/new/notify.svg
new file mode 100644
index 0000000000..04b0e3901b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/notify.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path d="M8.023 3.5C7.504 3.5 7.323 4.836 7 6c-1.05-.594-1.5-.5-1.5-.5s-.094.45.5 1.5c-1.163.323-2.5.504-2.5 1.023 0 .52 1.337.654 2.5.977-.594 1.049-.5 1.5-.5 1.5s.45.094 1.5-.5c.323 1.163.504 2.5 1.023 2.5.52 0 .654-1.337.977-2.5 1.05.594 1.5.5 1.5.5s.094-.451-.5-1.5c1.163-.323 2.72-.317 2.72-.836 0-.52-1.557-.841-2.72-1.164.594-1.05.5-1.5.5-1.5s-.45-.094-1.5.5c-.323-1.164-.457-2.5-.977-2.5Z" fill="context-fill"/>
+ <path d="M8.023 3c-.344 0-.607.235-.75.45-.142.216-.23.453-.312.714-.112.355-.206.763-.303 1.16-.238-.098-.534-.253-.699-.287a1.909 1.909 0 0 0-.37-.043c-.092-.001-.19.015-.19.015a.5.5 0 0 0-.39.389s-.016.1-.015.193c0 .094.011.215.043.367.034.166.19.461.287.7-.396.097-.804.19-1.16.302-.26.082-.498.17-.713.313-.215.143-.451.406-.451.75s.243.612.46.75c.219.138.454.218.714.293.352.102.755.19 1.146.281-.096.236-.249.53-.283.694a1.9 1.9 0 0 0-.043.369c0 .093.016.191.016.191a.5.5 0 0 0 .388.389s.099.016.192.015c.093 0 .217-.01.369-.043.165-.034.46-.19.7-.287.096.397.19.804.302 1.16.082.261.17.498.312.713.143.216.406.451.75.451.345 0 .61-.24.748-.458.138-.218.22-.455.295-.715.103-.353.19-.755.282-1.147.235.096.53.25.693.283.152.032.276.043.37.043.092.001.19-.015.19-.015a.5.5 0 0 0 .39-.389s.016-.098.015-.191a1.9 1.9 0 0 0-.043-.37c-.033-.157-.184-.442-.274-.665.404-.078.817-.154 1.204-.229.283-.055.538-.113.779-.227.12-.056.242-.127.351-.25a.799.799 0 0 0 .198-.505c0-.38-.26-.624-.487-.774a3.434 3.434 0 0 0-.773-.353c-.405-.141-.854-.253-1.29-.368.101-.243.257-.543.292-.71.032-.153.042-.274.043-.368 0-.093-.016-.193-.016-.193a.5.5 0 0 0-.388-.389s-.099-.016-.192-.015a1.82 1.82 0 0 0-.369.043c-.164.034-.458.187-.693.283-.092-.391-.18-.792-.282-1.145a2.716 2.716 0 0 0-.295-.714C8.634 3.243 8.368 3 8.023 3Zm-.007 1.222c.03.079.058.12.09.23.126.44.242 1.07.412 1.682a.5.5 0 0 0 .728.301c.267-.151.373-.163.547-.228-.065.173-.077.28-.229.546a.5.5 0 0 0 .303.729c.596.165 1.27.326 1.766.498.126.044.186.082.281.125-.08.02-.114.04-.213.059-.503.097-1.196.176-1.834.353a.5.5 0 0 0-.303.729c.152.267.164.373.229.547-.174-.066-.28-.078-.547-.229a.5.5 0 0 0-.728.303c-.17.612-.286 1.24-.413 1.68-.031.11-.058.151-.09.23-.034-.08-.063-.126-.099-.24-.137-.438-.265-1.064-.434-1.67a.5.5 0 0 0-.728-.303c-.267.151-.373.163-.547.229.065-.174.077-.28.229-.547a.5.5 0 0 0-.303-.729c-.612-.17-1.241-.285-1.68-.412-.11-.032-.151-.059-.23-.09.08-.034.126-.063.24-.1.437-.137 1.064-.265 1.67-.433a.5.5 0 0 0 .303-.728c-.152-.267-.164-.374-.229-.547.174.065.28.077.547.228a.5.5 0 0 0 .728-.3c.169-.607.297-1.235.434-1.673.036-.114.065-.16.1-.24Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/recurrence-exception.svg b/comm/mail/themes/shared/mail/icons/new/recurrence-exception.svg
new file mode 100644
index 0000000000..308a3ad823
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/recurrence-exception.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M10.5 1a.5.5 0 0 0-.354.146l-9 9a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l9-9a.5.5 0 0 0 0-.707A.5.5 0 0 0 10.5 1zM3 2c-1.1 0-2 .9-2 2v4c0 .26.051.508.143.736L2 7.879v-3.88c0-.562.437-1 1-1h3.879l1-1H3zm7.857 1.263L10 4.121V8c0 .562-.437 1-1 1H7.707l1.147-1.147a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0l-2 2a.5.5 0 0 0 0 .707l2 2a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L7.707 10H9c1.1 0 2-.901 2-2V4c0-.26-.051-.508-.143-.737z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/recurrence.svg b/comm/mail/themes/shared/mail/icons/new/recurrence.svg
new file mode 100644
index 0000000000..d1d0d0ff6c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/recurrence.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M3 2c-1.1 0-2 .9-2 2v4c0 1.099.9 2 2 2h.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H3c-.563 0-1-.438-1-1V4c0-.563.437-1 1-1h6c.563 0 1 .437 1 1v4c0 .562-.437 1-1 1H7.707l1.147-1.147a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0l-2 2a.5.5 0 0 0 0 .707l2 2a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707L7.707 10H9c1.1 0 2-.901 2-2V4c0-1.1-.9-2-2-2H3z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/spam-sm.svg b/comm/mail/themes/shared/mail/icons/new/spam-sm.svg
new file mode 100644
index 0000000000..9b444c8eb0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/spam-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M5 5.5c-1.5 0-1.5-2-1.5-2-.592.639-1 2-1 3.5 0 2 1.128 2.868 2 3.5 0-1 .5-2 1.5-2s1.5 1 1.5 2c.872-.632 2-2 2-4.5s-2-4.5-4-4.5c1.34 1.538 1.5 4-.5 4z" fill="context-fill"/>
+ <path d="M5.5 1a.5.5 0 0 0-.377.828c.59.677.914 1.59.88 2.222-.016.316-.11.545-.253.694C5.607 4.892 5.397 5 5 5c-.264 0-.407-.072-.535-.186a1.247 1.247 0 0 1-.309-.49C4.007 3.926 4 3.5 4 3.5a.5.5 0 0 0-.867-.34C2.378 3.974 2 5.409 2 7c0 2.188 1.325 3.265 2.207 3.904A.5.5 0 0 0 5 10.5c0-.417.112-.83.291-1.098C5.47 9.134 5.667 9 6 9c.333 0 .53.134.709.402.179.268.291.68.291 1.098a.5.5 0 0 0 .793.404C8.777 10.19 10 8.65 10 6c0-2.767-2.167-5-4.5-5Zm1.037 1.257C7.845 2.794 9 4.23 9 6c0 1.838-.64 2.829-1.28 3.498-.072-.216-.053-.461-.179-.65C7.22 8.366 6.667 8 6 8c-.667 0-1.22.366-1.541.847-.147.22-.136.502-.211.756C3.594 9.063 3 8.407 3 7c0-.807.154-1.519.357-2.114.111.234.22.475.444.674.293.26.713.44 1.199.44.603 0 1.123-.202 1.47-.563.348-.36.506-.842.532-1.334.032-.607-.168-1.237-.465-1.846Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/star-sm.svg b/comm/mail/themes/shared/mail/icons/new/star-sm.svg
new file mode 100644
index 0000000000..9a7e1bd1a0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/star-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M6 1.5a.602.602 0 0 0-.546.345L4.267 4.269l-2.268.238a.6.6 0 0 0-.336 1.021l1.578 1.504-.733 2.751a.602.602 0 0 0 .834.67L6 9.36l2.633 1.093a.602.602 0 0 0 .834-.67l-.708-2.75 1.579-1.505a.6.6 0 0 0-.337-1.021l-2.268-.238-1.187-2.424A.602.602 0 0 0 6 1.5Z" fill="context-fill"/>
+ <path d="M6 1c-.426 0-.817.247-.998.632L3.939 3.8l-1.992.21a.5.5 0 0 0-.02.001c-.867.126-1.24 1.258-.615 1.873a.5.5 0 0 0 .006.006l1.364 1.3-.657 2.464a.5.5 0 0 0-.007.03c-.175.848.735 1.576 1.527 1.226L6 9.9l2.432 1.01c.79.348 1.7-.38 1.525-1.227a.5.5 0 0 0-.006-.025l-.635-2.467 1.366-1.3a.5.5 0 0 0 .006-.007c.625-.616.253-1.747-.616-1.873a.5.5 0 0 0-.02-.002L8.061 3.8 6.998 1.632A1.105 1.105 0 0 0 6 1Zm0 1c.041 0 .077.022.094.058a.5.5 0 0 0 .002.008L7.283 4.49a.5.5 0 0 0 .399.277l2.248.235c.116.016.139.089.056.17L8.414 6.67a.5.5 0 0 0-.139.488l.702 2.726c.022.109-.037.158-.141.112a.5.5 0 0 0-.012-.004L6.191 8.898a.5.5 0 0 0-.38 0L3.152 9.99a.5.5 0 0 0-.011.006c-.104.046-.165-.003-.143-.111l.727-2.723a.5.5 0 0 0-.14-.492L2.014 5.172c-.083-.081-.06-.153.056-.17l2.248-.235a.5.5 0 0 0 .399-.277l1.187-2.424a.5.5 0 0 0 .002-.008A.102.102 0 0 1 6 2Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/status-away-sm.svg b/comm/mail/themes/shared/mail/icons/new/status-away-sm.svg
new file mode 100644
index 0000000000..127dcaadde
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/status-away-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 10 10" height="10" width="10">
+ <path fill="#FF555F" d="M5 .5A4.5 4.5 0 0 0 .5 5 4.5 4.5 0 0 0 5 9.5 4.5 4.5 0 0 0 9.5 5 4.5 4.5 0 0 0 5 .5ZM5.5 2c.667 0 1.275.318 1.729.771C7.682 3.225 8 3.833 8 4.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5c0-.334-.182-.725-.479-1.022C6.225 3.182 5.833 3 5.5 3a.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Z"/>
+ <path fill="#FF0039" d="M5 0C2.244 0 0 2.244 0 5c0 2.755 2.244 5 5 5s5-2.245 5-5c0-2.756-2.244-5-5-5Zm0 1c2.215 0 4 1.784 4 4 0 2.215-1.785 4-4 4S1 7.215 1 5c0-2.216 1.785-4 4-4Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/status-away.svg b/comm/mail/themes/shared/mail/icons/new/status-away.svg
new file mode 100644
index 0000000000..53d5fe7548
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/status-away.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="#FF555F" d="M8 .5A7.5 7.5 0 0 0 .5 8 7.5 7.5 0 0 0 8 15.5 7.5 7.5 0 0 0 15.5 8 7.5 7.5 0 0 0 8 .5ZM6.5 2a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Zm2 0c1.417 0 2.775.693 3.791 1.709S14 6.083 14 7.499a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5c0-1.083-.557-2.224-1.416-3.083C10.725 3.556 9.584 3 8.5 3a.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Z"/>
+ <path fill="#FF0039" d="M8 0C3.588 0 0 3.587 0 8c0 4.412 3.588 8 8 8s8-3.588 8-8c0-4.413-3.588-8-8-8Zm0 1c3.872 0 7 3.128 7 7 0 3.871-3.128 7-7 7s-7-3.129-7-7c0-3.872 3.128-7 7-7Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/status-idle-sm.svg b/comm/mail/themes/shared/mail/icons/new/status-idle-sm.svg
new file mode 100644
index 0000000000..541935c950
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/status-idle-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 10 10" height="10" width="10">
+ <path fill="#ffe900" d="M5 .5A4.5 4.5 0 0 0 .5 5 4.5 4.5 0 0 0 5 9.5 4.5 4.5 0 0 0 9.5 5 4.5 4.5 0 0 0 5 .5ZM5.5 2c.667 0 1.275.318 1.729.771C7.682 3.225 8 3.833 8 4.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5c0-.334-.182-.725-.479-1.022C6.225 3.182 5.833 3 5.5 3a.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Z"/>
+ <path fill="#d7b600" d="M5 0C2.244 0 0 2.244 0 5c0 2.755 2.244 5 5 5s5-2.245 5-5c0-2.756-2.244-5-5-5Zm0 1c2.215 0 4 1.784 4 4 0 2.215-1.785 4-4 4S1 7.215 1 5c0-2.216 1.785-4 4-4Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/status-idle.svg b/comm/mail/themes/shared/mail/icons/new/status-idle.svg
new file mode 100644
index 0000000000..7bfe8f5788
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/status-idle.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="#ffe900" d="M8 .5A7.5 7.5 0 0 0 .5 8 7.5 7.5 0 0 0 8 15.5 7.5 7.5 0 0 0 15.5 8 7.5 7.5 0 0 0 8 .5ZM6.5 2a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Zm2 0c1.417 0 2.775.693 3.791 1.709S14 6.083 14 7.499a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5c0-1.083-.557-2.224-1.416-3.083C10.725 3.556 9.584 3 8.5 3a.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Z"/>
+ <path fill="#d7b600" d="M8 0C3.588 0 0 3.587 0 8c0 4.412 3.588 8 8 8s8-3.588 8-8c0-4.413-3.588-8-8-8Zm0 1c3.872 0 7 3.128 7 7 0 3.871-3.128 7-7 7s-7-3.129-7-7c0-3.872 3.128-7 7-7Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/status-offline-sm.svg b/comm/mail/themes/shared/mail/icons/new/status-offline-sm.svg
new file mode 100644
index 0000000000..86e5de067a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/status-offline-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 10 10" height="10" width="10">
+ <path fill="#8a8ca0" d="M5 .5A4.5 4.5 0 0 0 .5 5 4.5 4.5 0 0 0 5 9.5 4.5 4.5 0 0 0 9.5 5 4.5 4.5 0 0 0 5 .5ZM5.5 2c.667 0 1.275.318 1.729.771C7.682 3.225 8 3.833 8 4.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5c0-.334-.182-.725-.479-1.022C6.225 3.182 5.833 3 5.5 3a.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Z"/>
+ <path fill="#575a77" d="M5 0C2.244 0 0 2.244 0 5c0 2.755 2.244 5 5 5s5-2.245 5-5c0-2.756-2.244-5-5-5Zm0 1c2.215 0 4 1.784 4 4 0 2.215-1.785 4-4 4S1 7.215 1 5c0-2.216 1.785-4 4-4Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/status-offline.svg b/comm/mail/themes/shared/mail/icons/new/status-offline.svg
new file mode 100644
index 0000000000..0ce53d16d6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/status-offline.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="#8a8ca0" d="M8 .5A7.5 7.5 0 0 0 .5 8 7.5 7.5 0 0 0 8 15.5 7.5 7.5 0 0 0 15.5 8 7.5 7.5 0 0 0 8 .5ZM6.5 2a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Zm2 0c1.417 0 2.775.693 3.791 1.709S14 6.083 14 7.499a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5c0-1.083-.557-2.224-1.416-3.083C10.725 3.556 9.584 3 8.5 3a.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Z"/>
+ <path fill="#575a77" d="M8 0C3.588 0 0 3.587 0 8c0 4.412 3.588 8 8 8s8-3.588 8-8c0-4.413-3.588-8-8-8Zm0 1c3.872 0 7 3.128 7 7 0 3.871-3.128 7-7 7s-7-3.129-7-7c0-3.872 3.128-7 7-7Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/status-online-sm.svg b/comm/mail/themes/shared/mail/icons/new/status-online-sm.svg
new file mode 100644
index 0000000000..95fccfc1a1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/status-online-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 10 10" height="10" width="10">
+ <path fill="#30E60B" d="M5 .5A4.5 4.5 0 0 0 .5 5 4.5 4.5 0 0 0 5 9.5 4.5 4.5 0 0 0 9.5 5 4.5 4.5 0 0 0 5 .5ZM5.5 2c.667 0 1.275.318 1.729.771C7.682 3.225 8 3.833 8 4.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5c0-.334-.182-.725-.479-1.022C6.225 3.182 5.833 3 5.5 3a.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Z"/>
+ <path fill="#12BC00" d="M5 0C2.244 0 0 2.244 0 5c0 2.755 2.244 5 5 5s5-2.245 5-5c0-2.756-2.244-5-5-5Zm0 1c2.215 0 4 1.784 4 4 0 2.215-1.785 4-4 4S1 7.215 1 5c0-2.216 1.785-4 4-4Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/status-online.svg b/comm/mail/themes/shared/mail/icons/new/status-online.svg
new file mode 100644
index 0000000000..25ed227231
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/status-online.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill="#30E60B" d="M8 .5A7.5 7.5 0 0 0 .5 8 7.5 7.5 0 0 0 8 15.5 7.5 7.5 0 0 0 15.5 8 7.5 7.5 0 0 0 8 .5ZM6.5 2a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Zm2 0c1.417 0 2.775.693 3.791 1.709S14 6.083 14 7.499a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5c0-1.083-.557-2.224-1.416-3.083C10.725 3.556 9.584 3 8.5 3a.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5Z"/>
+ <path fill="#12BC00" d="M8 0C3.588 0 0 3.587 0 8c0 4.412 3.588 8 8 8s8-3.588 8-8c0-4.413-3.588-8-8-8Zm0 1c3.872 0 7 3.128 7 7 0 3.871-3.128 7-7 7s-7-3.129-7-7c0-3.872 3.128-7 7-7Z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/subtract-circle-sm.svg b/comm/mail/themes/shared/mail/icons/new/subtract-circle-sm.svg
new file mode 100644
index 0000000000..839f229de5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/subtract-circle-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M10.5 5.5a5 5 0 1 1-10 0 5 5 0 0 1 10 0z" fill="context-fill"/>
+ <path d="M5.5 0A5.508 5.508 0 0 0 0 5.5a5.496 5.496 0 0 0 2.207 4.404A5.473 5.473 0 0 0 5.5 11a5.473 5.473 0 0 0 3.29-1.094l.009-.008A5.496 5.496 0 0 0 11 5.5C11 2.468 8.532 0 5.5 0Zm0 1c.074 0 .147.002.22.006h.012l.21.015.019.002a4.5 4.5 0 0 1 .412.063h.004l.031.006a4.496 4.496 0 0 1 3.506 3.531v.004c.027.135.048.273.063.412a4.545 4.545 0 0 1-.021 1.094v.008l-.003.01a4.477 4.477 0 0 1-.96 2.193l-.003-.015a4.613 4.613 0 0 1-.99.907v.008l-.066.041a4.696 4.696 0 0 1-.553.303l-.031.013a4.453 4.453 0 0 1-.575.215l-.023.006a4.471 4.471 0 0 1-.6.131l-.045.004A4.556 4.556 0 0 1 5.5 10a4.54 4.54 0 0 1-.607-.043c-.015 0-.03-.003-.045-.004a4.477 4.477 0 0 1-1.198-.352l-.03-.013a4.498 4.498 0 0 1-.554-.303L3 9.244v-.008a4.5 4.5 0 0 1-.99-.908l-.002.015a4.477 4.477 0 0 1-.961-2.193l-.002-.018a4.562 4.562 0 0 1 0-1.27v-.005a4.473 4.473 0 0 1 .361-1.227 4.496 4.496 0 0 1 2.15-2.19c.025-.01.05-.023.075-.034a4.472 4.472 0 0 1 1.226-.362h.006c.208-.029.42-.045.637-.045Zm-2 4a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/supernova-logo.webp b/comm/mail/themes/shared/mail/icons/new/supernova-logo.webp
new file mode 100644
index 0000000000..fd539dbf18
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/supernova-logo.webp
Binary files differ
diff --git a/comm/mail/themes/shared/mail/icons/new/tag-sm.svg b/comm/mail/themes/shared/mail/icons/new/tag-sm.svg
new file mode 100644
index 0000000000..0d0e0bee69
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/tag-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" width="12" height="12">
+ <path d="M6 .496A.691.691 0 0 0 5.51.7L.699 5.51a.692.692 0 0 0 0 .981l4.81 4.811c.272.271.71.271.981 0l4.81-4.81c.23-.205.203-.491.2-.682V1.192A.69.69 0 0 0 10.808.5C9.553.501 7.237.501 6 .496Z" fill="context-fill"/>
+ <path d="M6-.004a1.2 1.2 0 0 0-.844.35l-4.81 4.81a1.202 1.202 0 0 0 0 1.688l4.81 4.81a1.202 1.202 0 0 0 1.688 0l4.789-4.79c.416-.372.37-.911.367-1.063V1.19A1.2 1.2 0 0 0 10.808 0 1272.319 1272.319 0 0 1 6-.005zm-.002 1H6c1.238.005 3.553.005 4.808.004.116 0 .192.076.192.192v4.624c.003.231.01.264-.032.301a.53.53 0 0 0-.021.02l-4.81 4.81c-.082.082-.21.097-.274 0l-4.81-4.81a.184.184 0 0 1 0-.274l4.81-4.81a.216.216 0 0 1 .135-.057zM9 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/thread-sm.svg b/comm/mail/themes/shared/mail/icons/new/thread-sm.svg
new file mode 100644
index 0000000000..47963bcf40
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/thread-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M2.5.5a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm5 5a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm0 4a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1z" fill="context-fill"/>
+ <path d="M3.5 0C2.677 0 2 .677 2 1.5c0 .648.42 1.204 1 1.412V9c0 1.1.9 2 2 2h2.088c.208.58.764 1 1.412 1 .823 0 1.5-.677 1.5-1.5S9.323 9 8.5 9c-.648 0-1.204.42-1.412 1H5c-.563 0-1-.437-1-1V6.729C4.295 6.9 4.636 7 5 7h2.088c.208.58.764 1 1.412 1 .823 0 1.5-.677 1.5-1.5S9.323 5 8.5 5c-.648 0-1.204.42-1.412 1H5c-.563 0-1-.437-1-1V2.912c.58-.208 1-.764 1-1.412C5 .677 4.323 0 3.5 0zm0 1c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5zm5 5c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5zm0 4c.282 0 .5.218.5.5 0 .282-.218.5-.5.5a.493.493 0 0 1-.5-.5c0-.282.218-.5.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/add-circle.svg b/comm/mail/themes/shared/mail/icons/new/touch/add-circle.svg
new file mode 100644
index 0000000000..91d1e322c8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/add-circle.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M11.5 1.5a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10 10 10 0 0 0-10-10z" stroke-opacity="0.2" stroke="context-stroke" fill="context-fill"/>
+ <path d="M11.5 5a.5.5 0 0 0-.5.5V11H5.5a.5.5 0 0 0 0 1H11v5.5a.5.5 0 0 0 1 0V12h5.5a.5.5 0 0 0 0-1H12V5.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/address-book.svg b/comm/mail/themes/shared/mail/icons/new/touch/address-book.svg
new file mode 100644
index 0000000000..d80d7c7de4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/address-book.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M6 1C4.347 1 3 2.347 3 4v1h-.5a.5.5 0 1 0 0 1H3v2h-.5a.5.5 0 1 0 0 1H3v2h-.5a.5.5 0 1 0 0 1H3v2h-.5a.5.5 0 1 0 0 1H3v2h-.5a.5.5 0 1 0 0 1H3v2c0 1.653 1.347 3 3 3h12c1.653 0 3-1.347 3-3V4c0-1.653-1.347-3-3-3Zm0 1h12c1.117 0 2 .883 2 2v16c0 1.117-.883 2-2 2H6c-1.117 0-2-.883-2-2v-2h.5a.5.5 0 1 0 0-1H4v-2h.5a.5.5 0 1 0 0-1H4v-2h.5a.5.5 0 1 0 0-1H4V9h.5a.5.5 0 1 0 0-1H4V6h.5a.5.5 0 1 0 0-1H4V4c0-1.117.883-2 2-2Zm6 3C9.797 5 8 6.797 8 9c0 .827.128 1.682.488 2.418.3.613.836 1.088 1.512 1.371V13H8.5c-.822 0-1.5.678-1.5 1.5v3a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-3c0-.286.214-.5.5-.5h2a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.387-.486c-.597-.14-.962-.496-1.226-1.035C9.123 10.439 9 9.719 9 9c0-1.663 1.337-3 3-3s3 1.337 3 3c0 .695-.123 1.416-.389 1.963-.265.547-.631.912-1.224 1.05A.5.5 0 0 0 13 12.5v1a.5.5 0 0 0 .5.5h2c.286 0 .5.214.5.5v3a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-3c0-.822-.678-1.5-1.5-1.5H14v-.213c.678-.287 1.212-.772 1.512-1.389.36-.74.487-1.593.488-2.398 0-2.203-1.797-4-4-4Z" fill="context-stroke"/>
+ <path d="M6 2h12c1.108 0 2 .892 2 2v16c0 1.108-.892 2-2 2H6c-1.108 0-2-.892-2-2V4c0-1.108.892-2 2-2Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/calendar.svg b/comm/mail/themes/shared/mail/icons/new/touch/calendar.svg
new file mode 100644
index 0000000000..1171142e24
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/calendar.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M6 2C3.793 2 2 3.793 2 6v12c0 2.207 1.793 4 4 4h12c2.207 0 4-1.793 4-4V6c0-2.207-1.793-4-4-4Zm0 1h12c1.67 0 3 1.33 3 3v1h-3v-.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5V7H7v-.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5V7H3V6c0-1.67 1.33-3 3-3ZM3 8h3v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V8h10v.5a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5V8h3v10c0 1.67-1.33 3-3 3H6c-1.67 0-3-1.33-3-3Zm2.5 3a.5.5 0 1 0 0 1h2a.5.5 0 1 0 0-1zm5 0a.5.5 0 1 0 0 1h3a.5.5 0 1 0 0-1zm6 0a.5.5 0 1 0 0 1h2a.5.5 0 1 0 0-1zm-11 3a.5.5 0 1 0 0 1h2a.5.5 0 1 0 0-1zm5 0a.5.5 0 1 0 0 1h3a.5.5 0 1 0 0-1zm6 0a.5.5 0 1 0 0 1h2a.5.5 0 1 0 0-1zm-11 3a.5.5 0 1 0 0 1h2a.5.5 0 1 0 0-1zm5 0a.5.5 0 1 0 0 1h3a.5.5 0 1 0 0-1zm6 0a.5.5 0 1 0 0 1h2a.5.5 0 1 0 0-1z" fill="context-stroke"/>
+ <path d="M6 2.5A3.492 3.492 0 0 0 2.5 6v1.5h19V6c0-1.939-1.561-3.5-3.5-3.5Z" fill="context-fill"/>
+</svg>
+
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/chat.svg b/comm/mail/themes/shared/mail/icons/new/touch/chat.svg
new file mode 100644
index 0000000000..785432ee03
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/chat.svg
@@ -0,0 +1,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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M5 2C2.793 2 1 3.793 1 6v6c0 2.207 1.793 4 4 4h1v3.5a.5.5 0 0 0 .854.354L8.707 18h4.586l3.853 3.854A.5.5 0 0 0 18 21.5V18h1c2.207 0 4-1.793 4-4V8c0-2.207-1.793-4-4-4h-.5a.5.5 0 0 0-.033.01A3.998 3.998 0 0 0 15 2Zm0 1h10c1.67 0 3 1.33 3 3v6c0 1.67-1.33 3-3 3h-4.5a.5.5 0 0 0-.354.146L7 18.293V15.5a.5.5 0 0 0-.5-.5H5c-1.67 0-3-1.33-3-3V6c0-1.67 1.33-3 3-3Zm13.87 2H19c1.67 0 3 1.33 3 3v6c0 1.67-1.33 3-3 3h-1.5a.5.5 0 0 0-.5.5v2.793l-3.146-3.147A.5.5 0 0 0 13.5 17H9.707l1-1H15c2.207 0 4-1.793 4-4V6c0-.346-.048-.68-.13-1ZM5.5 8a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Zm3 0a.5.5 0 1 0 0 1h3a.5.5 0 1 0 0-1zm6 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+ <path d="M18.87 5H19c1.67 0 3 1.33 3 3v6c0 1.67-1.33 3-3 3h-1.5a.5.5 0 0 0-.5.5v2.793l-3.146-3.147A.5.5 0 0 0 13.5 17H9.707l1-1H15c2.207 0 4-1.793 4-4V6c0-.346-.048-.68-.13-1Z" fill="context-fill"/>
+</svg>
+
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/collapse.svg b/comm/mail/themes/shared/mail/icons/new/touch/collapse.svg
new file mode 100644
index 0000000000..b87ed62cfe
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/collapse.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M4.5 4a.5.5 0 0 0-.5.5v14a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-14a.5.5 0 0 0-.5-.5zm8 2a.5.5 0 0 0-.354.146l-5 5A.5.5 0 0 0 7 11.5a.5.5 0 0 0 .146.354l5 5a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.708L8.707 12H19.5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5H8.707l4.147-4.146a.5.5 0 0 0 0-.708A.5.5 0 0 0 12.5 6z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/dictionary.svg b/comm/mail/themes/shared/mail/icons/new/touch/dictionary.svg
new file mode 100644
index 0000000000..4ae575d184
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/dictionary.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M6 1.5A2.495 2.495 0 0 0 3.5 4v16c0 1.385 1.115 2.5 2.5 2.5h1.5v-2H7c-.831 0-1.5-.67-1.5-1.5 0-.831.669-1.5 1.5-1.5h.5v-16z" fill="context-fill"/>
+ <path d="M6 1C4.347 1 3 2.346 3 4v16c0 1.653 1.347 3 3 3h12c1.653 0 3-1.347 3-3V4c0-1.654-1.347-3-3-3Zm0 1h1v15c-1.1 0-2 .9-2 2 0 1.099.9 2 2 2v1H6c-1.117 0-2-.884-2-2V4c0-1.117.883-2 2-2Zm2 0h10c1.117 0 2 .883 2 2v13H8Zm6 3a.5.5 0 0 0-.459.298l-3.5 8a.5.5 0 0 0 .258.66.5.5 0 0 0 .66-.257L12.141 11h3.718l1.182 2.7a.5.5 0 0 0 .66.259.5.5 0 0 0 .258-.66l-3.5-8a.5.5 0 0 0-.459-.3Zm0 1.75L15.422 10h-2.844ZM7 18h13v2H7c-.563 0-1-.438-1-1 0-.563.437-1 1-1Zm1 3h11.736c-.342.6-.986 1-1.736 1H8Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/export.svg b/comm/mail/themes/shared/mail/icons/new/touch/export.svg
new file mode 100644
index 0000000000..54d69b8ee5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/export.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M17.5 5.5V16c0 1.385-1.115 2.5-2.5 2.5H6.5v.5c0 1.385 1.115 2.5 2.5 2.5h9c1.385 0 2.5-1.115 2.5-2.5V8c0-1.385-1.115-2.5-2.5-2.5Z" fill="context-fill"/>
+ <path d="M6 2C4.347 2 3 3.346 3 5v11c0 1.653 1.347 3 3 3 0 1.653 1.347 3 3 3h9c1.653 0 3-1.347 3-3V8c0-1.654-1.347-3-3-3 0-1.654-1.347-3-3-3H6zm0 1h9c1.117 0 2 .883 2 2v11c0 1.116-.883 2-2 2H6c-1.117 0-2-.884-2-2V5c0-1.117.883-2 2-2zm4.5 2a.522.522 0 0 0-.035.006.495.495 0 0 0-.283.115.476.476 0 0 0-.036.025l-3 3a.5.5 0 0 0 .708.708L10 6.707V12.5a.5.5 0 0 0 1 0V6.707l2.146 2.147a.499.499 0 1 0 .708-.708l-3-3a.467.467 0 0 0-.036-.025.515.515 0 0 0-.132-.08.494.494 0 0 0-.15-.035A.522.522 0 0 0 10.5 5zM18 6c1.117 0 2 .883 2 2v11c0 1.116-.883 2-2 2H9c-1.117 0-2-.884-2-2h8c1.653 0 3-1.347 3-3V6zM6.5 13a.5.5 0 0 0-.5.5v.5c0 1.099.9 2 2 2h5c1.1 0 2-.901 2-2v-.5a.5.5 0 0 0-1 0v.5c0 .562-.437 1-1 1H8c-.563 0-1-.438-1-1v-.5a.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/extension-update-available.svg b/comm/mail/themes/shared/mail/icons/new/touch/extension-update-available.svg
new file mode 100644
index 0000000000..81e65dbe1e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/extension-update-available.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path fill-opacity="1" d="M11.6.5c-1.54 0-3.1.896-3.1 2.1 0 1.358 1 1.4 1 2.45 0 .952-.476 1.435-1.05 1.45H3.9a1.4 1.4 0 0 0-1.4 1.4V9.45a1.077 1.077 0 0 0 1.05 1.05c1.05 0 .592-.998 1.95-.998 1.218 0 2 1.458 2 2.998 0 1.54-.796 3-2 3-1.358 0-.899-.999-1.949-.999A1.079 1.079 0 0 0 2.5 15.55v4.55a1.4 1.4 0 0 0 1.4 1.4h4.55a1.077 1.077 0 0 0 1.05-1.05c0-1.05-1-1.092-1-2.45 0-1.204 1.56-2.5 3.1-2.5.43 0 .844.103 1.22.275A5 5 0 0 1 17.5 12.5a5 5 0 0 1 4.531 2.89c.9-.378 1.469-1.688 1.469-2.989 0-1.54-.797-2.9-2-2.9-1.359 0-1.899 1-2.949 1-.574-.014-1.036-.676-1.05-1.25V7.9A1.4 1.4 0 0 0 16.1 6.5h-1.35c-.574-.014-1.225-.5-1.25-1.449 0-1.05 1-1.093 1-2.451 0-1.204-1.36-2.1-2.9-2.1zm2.184 20.336a1.075 1.075 0 0 0 .66.608 5 5 0 0 1-.661-.608z" fill="context-fill"/>
+ <path d="M11.6 0c-.868 0-1.722.244-2.398.678-.676.434-1.204 1.103-1.204 1.922 0 .8.35 1.327.613 1.653.263.328.39.432.39.798 0 .392-.099.633-.204.765a.428.428 0 0 1-.361.182H3.9c-1.043 0-1.9.857-1.9 1.901v1.564A1.585 1.585 0 0 0 3.54 11h.011c.342 0 .644-.097.854-.253.213-.159.322-.335.406-.446.083-.112.13-.168.206-.211.077-.043.205-.09.483-.09.409 0 .748.23 1.033.686.286.46.467 1.13.467 1.816 0 .683-.185 1.35-.472 1.809-.289.458-.629.689-1.028.689-.278 0-.406-.046-.483-.09a.657.657 0 0 1-.207-.207c-.084-.112-.192-.291-.403-.448A1.44 1.44 0 0 0 3.55 14h-.014A1.585 1.585 0 0 0 2 15.536V20.1c0 1.043.857 1.9 1.9 1.9h4.564a1.585 1.585 0 0 0 1.537-1.537v-.014c0-.684-.372-1.126-.609-1.421C9.154 18.732 9 18.556 9 18c0-.386.289-.904.788-1.313.502-.406 1.183-.686 1.813-.686.2 0 .397.03.588.08a5.485 5.485 0 0 0 1.176 5.037c.17.341.46.616.814.763A5.472 5.472 0 0 0 17.5 23a5.508 5.508 0 0 0 5.5-5.502c0-.67-.121-1.31-.341-1.904.287-.214.532-.494.721-.81.406-.678.62-1.528.62-2.385 0-.855-.214-1.669-.63-2.303-.417-.633-1.078-1.095-1.867-1.095-.803 0-1.395.308-1.843.564-.445.255-.739.429-1.103.43-.098-.004-.23-.068-.35-.221a1.012 1.012 0 0 1-.204-.539V7.899a1.908 1.908 0 0 0-1.902-1.901h-1.338c-.3-.007-.735-.242-.759-.956.003-.357.123-.465.384-.789a2.548 2.548 0 0 0 .609-1.653c0-.791-.451-1.467-1.082-1.911C13.286.248 12.468 0 11.6 0Zm0 1c.672 0 1.305.2 1.74.505.434.307.66.68.66 1.095 0 .556-.154.734-.391 1.029-.238.295-.609.738-.609 1.421v.014c.03 1.175.89 1.914 1.736 1.936h1.366c.503 0 .898.395.898.899v1.365c.011.416.168.81.424 1.133.255.322.637.59 1.113.603h.014c.683 0 1.182-.322 1.607-.567.426-.244.787-.434 1.344-.434.413 0 .75.216 1.033.647.283.432.465 1.07.465 1.754 0 .685-.185 1.383-.476 1.869a2.034 2.034 0 0 1-.31.4A5.505 5.505 0 0 0 17.5 12a5.508 5.508 0 0 0-4.966 3.142 3.32 3.32 0 0 0-.934-.14c-.91 0-1.778.368-2.444.91-.664.54-1.158 1.271-1.158 2.089 0 .802.35 1.327.613 1.655.26.322.38.43.384.787a.571.571 0 0 1-.56.558H3.9a.893.893 0 0 1-.9-.9v-4.536a.571.571 0 0 1 .56-.56c.164.003.208.027.246.055a1.382 1.382 0 0 1 .203.24c.104.138.262.335.514.478.252.144.577.224.977.224.804 0 1.463-.504 1.876-1.16.41-.656.622-1.483.622-2.339 0-.854-.207-1.687-.616-2.343C6.972 9.5 6.309 9.002 5.5 9.002c-.4 0-.724.077-.977.22-.252.144-.41.342-.514.479a1.382 1.382 0 0 1-.203.24c-.038.029-.08.054-.248.055A.566.566 0 0 1 3 9.44v-1.54C3 7.395 3.397 7 3.9 7h4.564a1.484 1.484 0 0 0 1.12-.564c.276-.351.417-.826.417-1.386 0-.683-.372-1.126-.609-1.421C9.154 3.334 9 3.156 9 2.6c0-.386.256-.766.745-1.08.49-.314 1.183-.519 1.856-.519zm5.9 12a4.493 4.493 0 0 1 4.5 4.5 4.493 4.493 0 0 1-4.5 4.5 4.493 4.493 0 0 1-4.5-4.5 4.493 4.493 0 0 1 4.5-4.5Zm0 2a.5.5 0 0 0-.353.147l-2 1.999a.5.5 0 0 0 0 .707.5.5 0 0 0 .706 0l1.148-1.147V19.5a.5.5 0 0 0 .499.502.5.5 0 0 0 .5-.502v-2.793l1.147 1.148a.5.5 0 0 0 .706 0 .5.5 0 0 0 0-.708l-1.984-1.982a.5.5 0 0 0-.024-.023.5.5 0 0 0-.093-.073.5.5 0 0 0-.007-.004.5.5 0 0 0-.108-.045.5.5 0 0 0-.009-.001A.5.5 0 0 0 17.5 15z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/extension-update-recent.svg b/comm/mail/themes/shared/mail/icons/new/touch/extension-update-recent.svg
new file mode 100644
index 0000000000..3a31a2f6d2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/extension-update-recent.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path fill-opacity="1" d="M11.6.5c-1.54 0-3.1.896-3.1 2.1 0 1.358 1 1.4 1 2.45 0 .952-.476 1.435-1.05 1.45H3.9a1.4 1.4 0 0 0-1.4 1.4V9.45a1.077 1.077 0 0 0 1.05 1.05c1.05 0 .592-.998 1.95-.998 1.218 0 2 1.458 2 2.998 0 1.54-.796 3-2 3-1.358 0-.899-.999-1.949-.999A1.079 1.079 0 0 0 2.5 15.55v4.55a1.4 1.4 0 0 0 1.4 1.4h4.55a1.077 1.077 0 0 0 1.05-1.05c0-1.05-1-1.092-1-2.45 0-1.204 1.56-2.5 3.1-2.5.43 0 .844.103 1.22.275A5 5 0 0 1 17.5 12.5a5 5 0 0 1 4.531 2.89c.9-.378 1.469-1.688 1.469-2.989 0-1.54-.797-2.9-2-2.9-1.359 0-1.899 1-2.949 1-.574-.014-1.036-.676-1.05-1.25V7.9A1.4 1.4 0 0 0 16.1 6.5h-1.35c-.574-.014-1.225-.5-1.25-1.449 0-1.05 1-1.093 1-2.451 0-1.204-1.36-2.1-2.9-2.1zm2.184 20.336a1.075 1.075 0 0 0 .66.608 5 5 0 0 1-.661-.608z" fill="context-fill"/>
+ <path d="M11.6 0c-.868 0-1.722.244-2.398.678-.676.434-1.204 1.103-1.204 1.922 0 .8.35 1.327.613 1.653.263.328.39.432.39.798 0 .392-.099.633-.204.765a.428.428 0 0 1-.361.182H3.9c-1.043 0-1.9.857-1.9 1.901v1.564A1.585 1.585 0 0 0 3.54 11h.011c.342 0 .644-.097.854-.253.213-.159.322-.335.406-.446.083-.112.13-.168.206-.211.077-.043.205-.09.483-.09.409 0 .748.23 1.033.686.286.46.467 1.13.467 1.816 0 .683-.185 1.35-.472 1.809-.289.458-.629.689-1.028.689-.278 0-.406-.046-.483-.09a.657.657 0 0 1-.207-.207c-.084-.112-.192-.291-.403-.448A1.44 1.44 0 0 0 3.55 14h-.014A1.585 1.585 0 0 0 2 15.536V20.1c0 1.043.857 1.9 1.9 1.9h4.564a1.585 1.585 0 0 0 1.537-1.537v-.014c0-.684-.372-1.126-.609-1.421C9.154 18.732 9 18.556 9 18c0-.386.289-.904.788-1.313.502-.406 1.183-.686 1.813-.686.2 0 .397.03.588.08a5.485 5.485 0 0 0 1.176 5.037c.17.341.46.616.814.763A5.472 5.472 0 0 0 17.5 23a5.508 5.508 0 0 0 5.5-5.502c0-.67-.121-1.31-.341-1.904.287-.214.532-.494.721-.81.406-.678.62-1.528.62-2.385 0-.855-.214-1.669-.63-2.303-.417-.633-1.078-1.095-1.867-1.095-.803 0-1.395.308-1.843.564-.445.255-.739.429-1.103.43-.098-.004-.23-.068-.35-.221a1.012 1.012 0 0 1-.204-.539V7.899a1.908 1.908 0 0 0-1.902-1.901h-1.338c-.3-.007-.735-.242-.759-.956.003-.357.123-.465.384-.789a2.548 2.548 0 0 0 .609-1.653c0-.791-.451-1.467-1.082-1.911C13.286.248 12.468 0 11.6 0zm0 1c.672 0 1.305.2 1.74.505.434.307.66.68.66 1.095 0 .556-.154.734-.391 1.029-.238.295-.609.738-.609 1.421v.014c.03 1.175.89 1.914 1.736 1.936h1.366c.503 0 .898.395.898.899v1.365c.011.416.168.81.424 1.133.255.322.637.59 1.113.603h.014c.683 0 1.182-.322 1.607-.567.426-.244.787-.434 1.344-.434.413 0 .75.216 1.033.647.283.432.465 1.07.465 1.754 0 .685-.185 1.383-.476 1.869a2.034 2.034 0 0 1-.31.4A5.505 5.505 0 0 0 17.5 12a5.508 5.508 0 0 0-4.966 3.142 3.32 3.32 0 0 0-.934-.14c-.91 0-1.778.368-2.444.91-.664.54-1.158 1.271-1.158 2.089 0 .802.35 1.327.613 1.655.26.322.38.43.384.787a.571.571 0 0 1-.56.558H3.9a.893.893 0 0 1-.9-.9v-4.536a.571.571 0 0 1 .56-.56c.164.003.208.027.246.055a1.382 1.382 0 0 1 .203.24c.104.138.262.335.514.478.252.144.577.224.977.224.804 0 1.463-.504 1.876-1.16.41-.656.622-1.483.622-2.339 0-.854-.207-1.687-.616-2.343C6.972 9.5 6.309 9.002 5.5 9.002c-.4 0-.724.077-.977.22-.252.144-.41.342-.514.479a1.382 1.382 0 0 1-.203.24c-.038.029-.08.054-.248.055A.566.566 0 0 1 3 9.44v-1.54C3 7.395 3.397 7 3.9 7h4.564a1.484 1.484 0 0 0 1.12-.564c.276-.351.417-.826.417-1.386 0-.683-.372-1.126-.609-1.421C9.154 3.334 9 3.156 9 2.6c0-.386.256-.766.745-1.08.49-.314 1.183-.519 1.856-.519zm5.9 12a4.493 4.493 0 0 1 4.5 4.5 4.493 4.493 0 0 1-4.5 4.5 4.493 4.493 0 0 1-4.5-4.5 4.493 4.493 0 0 1 4.5-4.5zm0 2a.5.5 0 0 0-.5.5v2.793l-1.147-1.147a.5.5 0 0 0-.707 0 .5.5 0 0 0 0 .707l2 2a.5.5 0 0 0 .354.148.5.5 0 0 0 .128-.019.5.5 0 0 0 .009-.001.5.5 0 0 0 .108-.045.5.5 0 0 0 .007-.004.5.5 0 0 0 .093-.073.5.5 0 0 0 .024-.024l1.984-1.982a.5.5 0 0 0 0-.707.5.5 0 0 0-.707 0l-1.147 1.148V15.5a.5.5 0 0 0-.5-.5z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/extension.svg b/comm/mail/themes/shared/mail/icons/new/touch/extension.svg
new file mode 100644
index 0000000000..7525d56ce2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/extension.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path fill-opacity="1" d="M21.5 9.5c-1.358 0-1.9 1-2.95 1-.574-.015-1.035-.676-1.05-1.25V7.9a1.4 1.4 0 0 0-1.4-1.4h-1.35c-.574-.015-1.226-.5-1.25-1.45 0-1.05 1-1.092 1-2.45 0-1.204-1.36-2.1-2.9-2.1-1.54 0-3.1.896-3.1 2.1 0 1.358 1 1.4 1 2.45 0 .95-.476 1.435-1.05 1.45H3.9a1.4 1.4 0 0 0-1.4 1.4v1.55a1.077 1.077 0 0 0 1.05 1.05c1.05 0 .592-1 1.95-1 1.218 0 2 1.46 2 3s-.796 3-2 3c-1.358 0-.9-1-1.95-1a1.078 1.078 0 0 0-1.05 1.05v4.55a1.4 1.4 0 0 0 1.4 1.4h4.55a1.078 1.078 0 0 0 1.05-1.05c0-1.05-1-1.092-1-2.45 0-1.204 1.56-2.5 3.1-2.5s2.9 1.296 2.9 2.5c0 1.358-.8 1.4-.8 2.45a1.077 1.077 0 0 0 1.05 1.05h1.35a1.4 1.4 0 0 0 1.4-1.4v-4.55a1.078 1.078 0 0 1 1.05-1.05c1.05 0 1.592 1 2.95 1 1.204 0 2-1.56 2-3.1s-.796-2.9-2-2.9z" fill="context-fill"/>
+ <path d="M11.6 0c-.867 0-1.723.243-2.4.678C8.525 1.112 8 1.782 8 2.6c0 .801.347 1.327.61 1.654.262.327.39.43.39.797 0 .391-.099.633-.203.765a.427.427 0 0 1-.36.184H3.9C2.857 6 2 6.857 2 7.9v1.563c.02.835.702 1.517 1.537 1.537h.014c.342 0 .642-.098.853-.256.212-.157.323-.333.407-.445.083-.112.13-.166.207-.21.076-.043.204-.089.482-.089.41 0 .747.23 1.033.688.286.458.467 1.127.467 1.812 0 .684-.185 1.354-.473 1.813C6.24 14.77 5.9 15 5.5 15c-.278 0-.406-.046-.482-.09-.077-.043-.124-.097-.207-.209-.084-.112-.195-.288-.407-.445A1.435 1.435 0 0 0 3.551 14h-.014A1.585 1.585 0 0 0 2 15.537V20.1c0 1.043.857 1.9 1.9 1.9h4.563A1.585 1.585 0 0 0 10 20.463v-.014c0-.684-.372-1.126-.61-1.422C9.154 18.732 9 18.557 9 18c0-.386.289-.907.79-1.314.5-.408 1.18-.686 1.81-.686.63 0 1.246.272 1.695.672.449.4.705.914.705 1.328 0 .58-.13.775-.32 1.07-.19.296-.48.727-.48 1.38v.012c.02.835.703 1.517 1.538 1.537H16.1c1.043 0 1.9-.857 1.9-1.9v-4.538a.57.57 0 0 1 .559-.558c.362.003.657.175 1.101.43.448.256 1.038.566 1.84.566.818 0 1.474-.536 1.88-1.215.408-.678.62-1.528.62-2.385 0-.856-.215-1.669-.63-2.302C22.952 9.464 22.29 9 21.5 9c-.802 0-1.392.31-1.84.566-.444.255-.739.43-1.101.432-.098-.004-.23-.07-.352-.223A1.015 1.015 0 0 1 18 9.236V7.9c0-1.043-.857-1.9-1.9-1.9h-1.336c-.3-.008-.74-.238-.762-.955.002-.36.128-.466.389-.791C14.653 3.927 15 3.4 15 2.6c0-.79-.454-1.467-1.084-1.91C13.286.245 12.466 0 11.6 0Zm0 1c.673 0 1.305.202 1.74.508.434.305.66.678.66 1.092 0 .556-.153.731-.39 1.027-.238.296-.61.74-.61 1.424v.012c.03 1.174.89 1.915 1.736 1.937H16.1c.503 0 .9.397.9.9v1.364c.01.416.168.807.424 1.13.255.324.638.594 1.113.606h.014c.684 0 1.182-.323 1.607-.566.426-.244.786-.434 1.342-.434.414 0 .75.215 1.033.646.283.432.467 1.07.467 1.754 0 .684-.185 1.383-.477 1.87-.291.486-.637.73-1.023.73-.556 0-.916-.19-1.342-.434-.425-.243-.923-.566-1.607-.566h-.014A1.585 1.585 0 0 0 17 15.537V20.1c0 .503-.397.9-.9.9h-1.338a.569.569 0 0 1-.559-.559c.002-.388.11-.506.318-.83.21-.327.479-.834.479-1.611 0-.79-.423-1.525-1.04-2.074-.615-.55-1.45-.926-2.36-.926-.91 0-1.78.369-2.444.91C8.492 16.451 8 17.182 8 18c0 .802.347 1.327.61 1.654.26.324.384.43.386.787a.57.57 0 0 1-.559.559H3.9a.893.893 0 0 1-.9-.9v-4.538a.57.57 0 0 1 .56-.56c.166.002.21.026.249.055.04.03.096.104.199.242.103.138.26.334.513.478.253.144.578.223.979.223.804 0 1.464-.5 1.875-1.156.411-.656.625-1.488.625-2.344 0-.855-.21-1.685-.62-2.342C6.972 9.501 6.31 9 5.5 9c-.4 0-.726.079-.979.223a1.68 1.68 0 0 0-.513.478c-.103.138-.16.212-.2.242-.038.03-.082.053-.247.055A.57.57 0 0 1 3 9.438V7.9c0-.503.397-.9.9-.9h4.563c.424-.01.842-.213 1.119-.564.277-.352.418-.827.418-1.385 0-.684-.372-1.128-.61-1.424C9.154 3.331 9 3.156 9 2.6c0-.386.253-.766.742-1.08.49-.315 1.185-.52 1.858-.52Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/features.svg b/comm/mail/themes/shared/mail/icons/new/touch/features.svg
new file mode 100644
index 0000000000..6113ea44c1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/features.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M6.003 2.25c-.506 0-.858 2.176-1.216 2.534-.358.358-2.536.71-2.536 1.216s2.178.858 2.536 1.215c.358.358.71 2.535 1.216 2.535s.858-2.177 1.216-2.535c.358-.357 2.536-.71 2.536-1.215 0-.506-2.178-.858-2.536-1.216-.358-.358-.71-2.534-1.216-2.534Zm9.004 1.464c-1.14 0-1.644 5.031-2.45 5.836-.806.806-5.839 1.31-5.839 2.45 0 1.139 5.033 1.643 5.839 2.449.806.805 1.31 5.836 2.45 5.836 1.14 0 1.645-5.03 2.45-5.836.807-.806 5.84-1.31 5.84-2.45 0-1.138-5.033-1.643-5.84-2.449-.805-.805-1.31-5.836-2.45-5.836zM6.003 14.25c-.506 0-.858 2.176-1.216 2.534-.358.358-2.536.71-2.536 1.216s2.178.858 2.536 1.215c.358.358.71 2.535 1.216 2.535s.858-2.177 1.216-2.535c.358-.357 2.536-.71 2.536-1.215 0-.506-2.178-.858-2.536-1.216-.358-.358-.71-2.534-1.216-2.534Z" fill="context-fill"/>
+ <path d="M6.002 2c-.222 0-.4.106-.518.212-.116.106-.195.22-.265.34-.141.239-.246.51-.348.782-.101.272-.195.544-.28.757-.084.214-.198.378-.157.338.04-.04-.127.076-.34.16-.214.085-.487.176-.76.278a4.93 4.93 0 0 0-.781.35c-.12.07-.234.146-.34.263A.782.782 0 0 0 2 6c0 .222.107.402.213.52.106.116.22.192.34.263.239.14.508.248.781.35.273.1.546.194.76.279.213.084.38.198.34.158-.04-.04.073.124.158.338.084.213.178.485.28.758.1.272.206.542.347.78.07.12.149.235.265.34a.778.778 0 0 0 .518.214.784.784 0 0 0 .52-.213c.116-.106.195-.22.265-.34.14-.239.246-.51.348-.781.101-.273.195-.545.28-.758.083-.214.197-.378.157-.338-.04.04.125-.076.338-.16.213-.085.486-.176.758-.278.272-.101.54-.208.78-.35a1.41 1.41 0 0 0 .34-.263.785.785 0 0 0 .212-.52.785.785 0 0 0-.213-.519 1.375 1.375 0 0 0-.338-.264 4.93 4.93 0 0 0-.781-.35c-.272-.1-.545-.192-.758-.277-.213-.084-.378-.2-.338-.16.04.04-.074-.124-.158-.338-.084-.213-.178-.485-.28-.757a4.922 4.922 0 0 0-.347-.782 1.393 1.393 0 0 0-.266-.34A.784.784 0 0 0 6.002 2Zm9.006 1c-.334 0-.601.194-.78.402a2.842 2.842 0 0 0-.43.73c-.242.557-.438 1.252-.624 1.965-.187.713-.36 1.443-.533 2.028-.173.584-.41 1.044-.438 1.072-.028.028-.489.263-1.074.435-.586.173-1.315.347-2.03.534-.713.186-1.41.382-1.966.625a2.842 2.842 0 0 0-.73.43C6.195 11.398 6 11.665 6 12c0 .334.195.6.402.779.208.178.452.308.73.43.558.242 1.254.438 1.968.625.714.186 1.443.36 2.029.533.585.172 1.046.407 1.074.435.028.028.265.49.438 1.074.172.585.346 1.315.533 2.028.186.713.382 1.406.625 1.963.121.278.251.525.43.732.178.208.445.4.779.4s.601-.192.78-.4c.177-.207.307-.454.429-.732.243-.557.438-1.25.625-1.963.186-.713.36-1.443.533-2.028.173-.584.408-1.046.436-1.074.028-.028.488-.263 1.072-.435.584-.173 1.313-.347 2.025-.533.712-.187 1.406-.383 1.961-.625.278-.122.523-.252.73-.43.208-.178.401-.446.401-.78 0-.333-.193-.6-.4-.779a2.844 2.844 0 0 0-.73-.43c-.556-.242-1.25-.438-1.962-.624-.712-.187-1.441-.361-2.025-.534-.584-.172-1.044-.407-1.072-.435-.028-.028-.263-.488-.436-1.072-.173-.585-.347-1.315-.533-2.028-.187-.713-.382-1.408-.625-1.965a2.842 2.842 0 0 0-.43-.73c-.178-.208-.445-.402-.78-.402Zm-9.004.236c.063.132.125.263.193.445.095.254.19.531.287.78.099.248.162.458.381.677.219.219.43.283.678.381.248.098.522.19.775.285.181.068.312.132.444.196-.132.063-.263.127-.444.195-.253.094-.527.187-.775.285-.248.098-.459.162-.678.38-.219.22-.282.43-.38.679-.099.248-.193.525-.288.779-.068.182-.13.313-.193.445-.063-.132-.128-.263-.195-.445-.095-.254-.19-.531-.288-.78-.098-.248-.161-.458-.38-.677-.22-.22-.431-.283-.68-.381-.249-.098-.523-.19-.777-.285-.182-.068-.314-.132-.446-.195.132-.064.264-.128.446-.196.254-.094.528-.187.777-.285.249-.098.46-.162.68-.38.219-.22.282-.43.38-.679.099-.248.193-.525.288-.779.067-.182.132-.313.195-.445ZM15.008 4c-.05 0-.051-.03.02.052.07.083.172.254.27.48.198.453.392 1.118.575 1.817.183.699.359 1.432.543 2.057.184.625.313 1.123.688 1.498.374.374.871.503 1.496.687.624.185 1.357.36 2.054.543.698.183 1.363.377 1.815.574.226.1.396.201.478.272.083.07.053.069.053.02 0-.05.03-.052-.053.019-.082.07-.252.173-.478.271-.452.198-1.117.392-1.815.575-.697.182-1.43.358-2.054.543-.625.184-1.122.313-1.496.687-.375.375-.504.873-.688 1.498-.184.625-.36 1.358-.543 2.057-.183.699-.377 1.366-.574 1.818a2.08 2.08 0 0 1-.272.479c-.07.082-.068.052-.02.052.05 0 .05.03-.02-.052a2.096 2.096 0 0 1-.272-.479c-.198-.452-.392-1.12-.574-1.818-.183-.699-.357-1.432-.541-2.057-.185-.625-.315-1.123-.69-1.498-.375-.375-.872-.503-1.498-.687-.626-.185-1.359-.36-2.058-.543-.7-.183-1.368-.377-1.82-.574a2.116 2.116 0 0 1-.481-.272c-.083-.07-.053-.07-.053-.02s-.03.052.053-.019c.082-.07.254-.173.48-.271.453-.198 1.121-.392 1.82-.575.7-.182 1.433-.356 2.06-.54.625-.185 1.122-.315 1.497-.69.375-.375.505-.873.69-1.498.184-.625.358-1.358.54-2.057.183-.699.377-1.364.575-1.816.099-.226.2-.398.271-.48.071-.083.071-.053.022-.053ZM6.002 14c-.222 0-.4.106-.518.212-.116.106-.195.22-.265.34-.141.239-.246.51-.348.782-.101.272-.195.544-.28.757-.084.214-.198.378-.157.338.04-.04-.127.076-.34.16-.214.085-.487.176-.76.278a4.93 4.93 0 0 0-.781.35c-.12.07-.234.146-.34.263A.782.782 0 0 0 2 18c0 .222.107.402.213.52.106.116.22.192.34.263.239.14.508.248.781.35.273.1.546.194.76.279.213.084.38.198.34.158-.04-.04.073.124.158.338.084.213.178.485.28.758.1.272.206.542.347.78.07.12.149.235.265.34a.778.778 0 0 0 .518.214.784.784 0 0 0 .52-.213c.116-.106.195-.22.265-.34.14-.239.246-.51.348-.781.101-.273.195-.545.28-.758.083-.214.197-.378.157-.338-.04.04.125-.076.338-.16.213-.085.486-.176.758-.278.272-.101.54-.208.78-.35a1.41 1.41 0 0 0 .34-.263.785.785 0 0 0 .212-.52.785.785 0 0 0-.213-.519 1.375 1.375 0 0 0-.338-.264 4.93 4.93 0 0 0-.781-.35c-.272-.1-.545-.192-.758-.277-.213-.084-.378-.2-.338-.16.04.04-.074-.124-.158-.338-.084-.213-.178-.485-.28-.757a4.922 4.922 0 0 0-.347-.782 1.393 1.393 0 0 0-.266-.34.784.784 0 0 0-.519-.212Zm.002 1.236c.063.132.125.263.193.445.095.254.19.531.287.78.099.248.162.458.381.677.219.219.43.283.678.381.248.098.522.19.775.285.181.068.312.132.444.196-.132.063-.263.127-.444.195-.253.094-.527.187-.775.285-.248.098-.459.162-.678.38-.219.22-.282.43-.38.679-.099.248-.193.525-.288.779-.068.182-.13.313-.193.445-.063-.132-.128-.263-.195-.445-.095-.254-.19-.531-.288-.78-.098-.248-.161-.458-.38-.677-.22-.22-.431-.283-.68-.381-.249-.098-.523-.19-.777-.285-.182-.068-.314-.132-.446-.195.132-.064.264-.128.446-.196.254-.094.528-.187.777-.285.249-.098.46-.162.68-.38.219-.22.282-.43.38-.679.099-.248.193-.525.288-.779.067-.182.132-.313.195-.445z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/globe.svg b/comm/mail/themes/shared/mail/icons/new/touch/globe.svg
new file mode 100644
index 0000000000..9e4a1a18ec
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/globe.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M21.5 11.5a10 10 0 0 1-10 10 10 10 0 0 1-10-10 10 10 0 0 1 10-10 10 10 0 0 1 10 10z" fill="context-fill"/>
+ <path d="M11.5 1C5.707 1 1 5.707 1 11.5S5.707 22 11.5 22 22 17.293 22 11.5 17.293 1 11.5 1zm0 1c.283 0 .563.015.84.04.47.464 1.818 1.937 2.74 4.358a15.348 15.348 0 0 0-3.605-.423c-1.301 0-2.484.158-3.55.412.923-2.414 2.265-3.884 2.735-4.348.277-.024.557-.039.84-.039zm-2.393.303A14.59 14.59 0 0 0 6.76 6.715 14.376 14.376 0 0 0 2.309 9.09a9.5 9.5 0 0 1 6.798-6.787zm4.786 0a9.5 9.5 0 0 1 6.802 6.8 14.588 14.588 0 0 0-4.45-2.373 14.596 14.596 0 0 0-2.352-4.427zm-2.418 4.672c1.496 0 2.822.217 3.978.546A14.44 14.44 0 0 1 16 11.5a14.38 14.38 0 0 1-.533 3.924c-1.16.332-2.49.55-3.992.55-1.482 0-2.794-.212-3.938-.537A14.375 14.375 0 0 1 7 11.5c0-1.502.219-2.833.55-3.992a14.378 14.378 0 0 1 3.925-.533zm-5.047.9A15.354 15.354 0 0 0 6 11.5c0 1.309.16 2.498.416 3.568-2.513-.954-3.978-2.357-4.38-2.779A9.647 9.647 0 0 1 2 11.5c0-.284.015-.566.04-.844.412-.431 1.881-1.832 4.388-2.781zm10.148.02c2.494.949 3.967 2.335 4.385 2.765.024.277.039.557.039.84 0 .265-.014.527-.035.787-.408.42-1.878 1.808-4.377 2.762C16.84 13.984 17 12.8 17 11.5c0-1.322-.164-2.524-.424-3.605zm4.135 5.94a9.5 9.5 0 0 1-6.857 6.872 14.382 14.382 0 0 0 2.406-4.492 14.588 14.588 0 0 0 4.45-2.38zm-18.418.013a14.374 14.374 0 0 0 4.451 2.382 14.377 14.377 0 0 0 2.402 4.477 9.5 9.5 0 0 1-6.853-6.86zM15.1 16.547c-.989 2.611-2.451 4.08-2.813 4.418-.26.021-.522.035-.787.035s-.527-.014-.787-.035c-.36-.337-1.818-1.803-2.807-4.406 1.07.256 2.26.416 3.569.416 1.33 0 2.538-.165 3.625-.428z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/import.svg b/comm/mail/themes/shared/mail/icons/new/touch/import.svg
new file mode 100644
index 0000000000..ac4ed0757b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/import.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M17.5 5.5V16c0 1.385-1.115 2.5-2.5 2.5H6.5v.5c0 1.385 1.115 2.5 2.5 2.5h9c1.385 0 2.5-1.115 2.5-2.5V8c0-1.385-1.115-2.5-2.5-2.5Z" fill="context-fill"/>
+ <path d="M6 2C4.347 2 3 3.346 3 5v11c0 1.653 1.347 3 3 3 0 1.653 1.347 3 3 3h9c1.653 0 3-1.347 3-3V8c0-1.654-1.347-3-3-3 0-1.654-1.347-3-3-3Zm0 1h9c1.117 0 2 .883 2 2v11c0 1.116-.883 2-2 2H6c-1.117 0-2-.884-2-2V5c0-1.117.883-2 2-2Zm4.5 2a.5.5 0 0 0-.5.5v5.793L7.854 9.146A.5.5 0 0 0 7.5 9a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .707l3 3a.5.5 0 0 0 .036.026.5.5 0 0 0 .058.04.5.5 0 0 0 .074.04.5.5 0 0 0 .065.021.5.5 0 0 0 .086.014.5.5 0 0 0 .035.006.5.5 0 0 0 .035-.006.5.5 0 0 0 .086-.014.5.5 0 0 0 .065-.021.5.5 0 0 0 .074-.04.5.5 0 0 0 .058-.04.5.5 0 0 0 .036-.026l3-3a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0L11 11.293V5.5a.5.5 0 0 0-.5-.5ZM18 6c1.117 0 2 .883 2 2v11c0 1.116-.883 2-2 2H9c-1.117 0-2-.884-2-2h8c1.653 0 3-1.347 3-3zM6.5 13a.5.5 0 0 0-.5.5v.5c0 1.099.9 2 2 2h5c1.1 0 2-.901 2-2v-.5a.5.5 0 0 0-.5-.5.5.5 0 0 0-.5.5v.5c0 .562-.437 1-1 1H8c-.563 0-1-.438-1-1v-.5a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/language.svg b/comm/mail/themes/shared/mail/icons/new/touch/language.svg
new file mode 100644
index 0000000000..73a0a48d33
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/language.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M11.5 1.5a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 3.893-.807A.497.497 0 0 0 15 20.5h-.5c-.5 0-2-1.383-2-2.5v-3c0-1.117 1.383-2.5 2.5-2.5h6c.138 0 .28.023.424.063A10 10 0 0 0 21.5 11.5a10 10 0 0 0-10-10z" fill="context-fill"/>
+ <path d="M11.5 1C5.707 1 1 5.707 1 11.5S5.707 22 11.5 22c1.228 0 2.405-.215 3.5-.604V23.5a.5.5 0 0 0 .854.354l.853-.854 1-1 1-1H21c1.653 0 3-1.347 3-3v-2.736c0-.264-.095-.936-.129-1.135a3.017 3.017 0 0 0-1.894-1.965c.014-.22.023-.44.023-.664C22 5.707 17.293 1 11.5 1zm0 1c.283 0 .563.015.84.04.472.466 1.826 1.946 2.748 4.38A15.356 15.356 0 0 0 11.5 6c-1.315 0-2.511.162-3.588.42.922-2.434 2.276-3.914 2.748-4.38.277-.025.557-.04.84-.04zm-2.393.303A14.6 14.6 0 0 0 6.75 6.75a14.6 14.6 0 0 0-4.447 2.357 9.5 9.5 0 0 1 6.804-6.804zm4.786 0a9.5 9.5 0 0 1 6.804 6.804A14.6 14.6 0 0 0 16.25 6.75a14.6 14.6 0 0 0-2.357-4.447zM11.5 7c1.487 0 2.808.214 3.959.541A14.444 14.444 0 0 1 15.99 12H15c-1.653 0-3 1.347-3 3v.99a14.444 14.444 0 0 1-4.459-.531A14.444 14.444 0 0 1 7 11.5c0-1.487.214-2.808.541-3.959A14.444 14.444 0 0 1 11.5 7zm-5.08.912A15.356 15.356 0 0 0 6 11.5c0 1.315.162 2.511.42 3.588-2.434-.922-3.914-2.276-4.38-2.748A9.575 9.575 0 0 1 2 11.5c0-.283.015-.563.04-.84.466-.472 1.946-1.826 4.38-2.748zm10.16 0c2.434.922 3.914 2.276 4.38 2.748a8.295 8.295 0 0 1 .024 1.34H16.99a15.356 15.356 0 0 0-.41-4.088zM15 13h6c1.117 0 2 .883 2 2v3c0 1.117-.883 2-2 2h-2.5a.5.5 0 0 0-.354.146L16 22.293V20.5a.5.5 0 0 0-.5-.5H15c-1.117 0-2-.883-2-2v-3c0-1.117.883-2 2-2zm-12.7.89c.942.74 2.436 1.7 4.45 2.36a14.598 14.598 0 0 0 2.36 4.45 9.511 9.511 0 0 1-6.81-6.81zM15.5 16a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5zm1.975 0a.5.5 0 0 0 .025 1h1a.499.499 0 1 0 0-1h-1.025zm3.025 0a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5zm-12.588.58a15.356 15.356 0 0 0 4.088.41V18c0 .884.388 1.677.998 2.227-.276.339-.512.59-.658.734a9.723 9.723 0 0 1-.84.039 9.32 9.32 0 0 1-.84-.04c-.472-.466-1.826-1.946-2.748-4.38z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/lock.svg b/comm/mail/themes/shared/mail/icons/new/touch/lock.svg
new file mode 100644
index 0000000000..1dd16c6fa7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/lock.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M6 8.5h12c1.385 0 2.5 1.115 2.5 2.5v8c0 1.385-1.115 2.5-2.5 2.5H6A2.495 2.495 0 0 1 3.5 19v-8c0-1.385 1.115-2.5 2.5-2.5Z" fill="context-fill"/>
+ <path d="M12 0C9.828 0 8.054.621 6.838 1.838 5.622 3.054 5 4.828 5 7v1.175A3.004 3.004 0 0 0 3 11v8c0 1.653 1.347 3 3 3h12c1.653 0 3-1.347 3-3v-8a3.004 3.004 0 0 0-2-2.825V7c0-2.172-.622-3.946-1.838-5.162C15.946.621 14.172 0 12 0Zm0 1c1.983 0 3.46.55 4.455 1.545C17.45 3.539 18 5.016 18 7v1H6V7c0-1.984.55-3.46 1.545-4.455C8.54 1.55 10.017 1 12 1ZM6 9h12c1.117 0 2 .883 2 2v8c0 1.116-.883 2-2 2H6c-1.117 0-2-.884-2-2v-8c0-1.117.883-2 2-2Zm6 4a1 1 0 0 0-1 1 1 1 0 0 0 .684.947L11 17h2l-.684-2.053A1 1 0 0 0 13 14a1 1 0 0 0-1-1Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/mail.svg b/comm/mail/themes/shared/mail/icons/new/touch/mail.svg
new file mode 100644
index 0000000000..7c703819dc
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/mail.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M4 3C2.347 3 1 4.347 1 6v12c0 1.653 1.347 3 3 3h16c1.653 0 3-1.347 3-3V6c0-1.653-1.347-3-3-3Zm0 1h16c1.117 0 2 .883 2 2v12c0 1.117-.883 2-2 2H4c-1.117 0-2-.883-2-2V6c0-1.117.883-2 2-2Zm1.48 3a.5.5 0 0 0-.347.16.5.5 0 0 0 .027.707l4.299 3.967-4.313 4.312a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0l4.34-4.34 1.466 1.353a.5.5 0 0 0 .68 0l1.467-1.353 4.34 4.34a.5.5 0 0 0 .707 0 .5.5 0 0 0 0-.708l-4.313-4.312 4.299-3.967a.5.5 0 0 0 .027-.707.5.5 0 0 0-.347-.16.5.5 0 0 0-.36.133L12 12.82 5.84 7.133A.5.5 0 0 0 5.48 7Z" fill="context-stroke"/>
+ <path d="M4 4h16c1.108 0 2 .892 2 2v12c0 1.108-.892 2-2 2H4c-1.108 0-2-.892-2-2V6c0-1.108.892-2 2-2Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/overflow.svg b/comm/mail/themes/shared/mail/icons/new/touch/overflow.svg
new file mode 100644
index 0000000000..5a1bd9c22a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/overflow.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M6.5 6a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708l4.647 4.646-4.647 4.646a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0l5-5a.5.5 0 0 0 0-.708l-5-5A.5.5 0 0 0 6.5 6zm6 0a.5.5 0 0 0-.354.146.5.5 0 0 0 0 .708l4.647 4.646-4.647 4.646a.5.5 0 0 0 0 .708.5.5 0 0 0 .708 0l5-5a.5.5 0 0 0 0-.708l-5-5A.5.5 0 0 0 12.5 6z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/paint-brush.svg b/comm/mail/themes/shared/mail/icons/new/touch/paint-brush.svg
new file mode 100644
index 0000000000..b42a86f835
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/paint-brush.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M21.094 2.556C20.474 2.526 20 3 20 3c-7.5 7.5-10.5 7-10.5 10.5 0 0 1 0 1.5.5s.5 1.5.5 1.5c3.5 0 3-3 10.5-10.5 0 0 1-1 0-2-.313-.313-.624-.43-.906-.444ZM7 16c-.914 0-1.71.621-1.855 1.06L2.5 21.5c3.807 0 6.253-1.36 6.48-3.215A2 2 0 0 0 7 16Z" fill="context-fill"/>
+ <path d="M21.033 1.923a1.73 1.73 0 0 0-.42.08c-.501.168-.82.497-.82.497-3.723 3.722-6.816 5.954-8.398 7.093-.792.57-1.388 1.035-1.801 1.641-.382.56-.564 1.222-.59 2.055l-2.219 2.218a2.84 2.84 0 0 0-1.27.436c-.377.24-.67.515-.818.889L2.07 21.244A.5.5 0 0 0 2.5 22c1.963 0 3.598-.346 4.81-.967 1.213-.622 2.028-1.56 2.167-2.688a3.67 3.67 0 0 0 .013-.129l2.22-2.22c.833-.026 1.496-.209 2.056-.59.606-.413 1.07-1.01 1.64-1.8 1.14-1.583 3.372-4.677 7.094-8.4 0 0 .329-.318.496-.82.167-.501.122-1.268-.496-1.886a1.934 1.934 0 0 0-.986-.543 1.828 1.828 0 0 0-.48-.033Zm.178.973c.162 0 .343.072.582.31.382.383.337.615.254.864-.083.248-.254.43-.254.43a66.178 66.178 0 0 0-7.2 8.52c-.555.77-.965 1.27-1.39 1.56-.317.216-.78.261-1.27.312-.004-.028 0-.023-.005-.052-.073-.363-.207-.826-.575-1.194-.367-.368-.83-.501-1.193-.574-.029-.006-.025 0-.053-.006.051-.49.099-.953.315-1.27.29-.425.787-.835 1.558-1.39a66.18 66.18 0 0 0 8.52-7.2s.181-.17.43-.253a.882.882 0 0 1 .28-.057ZM9.695 14.011c.077.008.166.02.27.041.262.053.55.169.681.301.133.132.249.42.301.682.021.104.034.193.041.27l-1.697 1.697a2.525 2.525 0 0 0-1.293-1.293ZM7 16.5a1.492 1.492 0 0 1 1.484 1.724c-.089.728-.61 1.396-1.629 1.918-.827.424-2.035.672-3.423.77l2.142-3.596a.502.502 0 0 0 .045-.1c.009-.026.17-.261.432-.427.261-.166.605-.289.949-.29Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/pencil.svg b/comm/mail/themes/shared/mail/icons/new/touch/pencil.svg
new file mode 100644
index 0000000000..d85fbce409
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/pencil.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24" height="24" width="24">
+ <path d="m14.5 5.5-11 11s1.5-.5 3 1 1 3 1 3l11-11s.5-1.5-1-3-3-1-3-1" fill="context-fill"/>
+ <path d="M18.018 1.923c-.42.014-.7.102-.7.102a.5.5 0 0 0-.197.121l-13.5 13.5a.5.5 0 0 0-.13.225l-1.473 5.5a.5.5 0 0 0 .613.611l5.5-1.5a.5.5 0 0 0 .223-.129l13.5-13.5a.5.5 0 0 0 .12-.195s.158-.491.085-1.15c-.074-.66-.377-1.533-1.205-2.362-.83-.83-1.71-1.132-2.375-1.205a3.27 3.27 0 0 0-.461-.018Zm.078.997c.077 0 .168.004.273.015.473.052 1.107.248 1.777.918.672.672.869 1.298.92 1.764.047.415-.022.597-.037.646l-2.006 2.006c-.115-.624-.437-1.39-1.17-2.123-.74-.74-1.517-1.062-2.144-1.174l2.002-2.002c.039-.011.153-.05.385-.05Zm-3.024 2.998c.09-.001.194.003.31.015.466.052 1.093.249 1.764.92.672.672.869 1.298.92 1.764.034.298.008.51-.015.625L7.938 19.355c-.102-.596-.37-1.494-1.084-2.209-.724-.723-1.635-.99-2.231-1.088L14.725 5.957c.067-.016.182-.037.347-.04zm-10.853 11.1c.41.046 1.31.218 1.927.835.609.609.784 1.488.834 1.906l-1.865.51a1.603 1.603 0 0 0-.449-.935 1.597 1.597 0 0 0-.861-.438l-.086-.012Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/settings.svg b/comm/mail/themes/shared/mail/icons/new/touch/settings.svg
new file mode 100644
index 0000000000..d682abccca
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/settings.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M11 1c-1.1 0-2 .9-2 2v1.668c-.67.272-1.3.63-1.873 1.072l-1.438-.83a2.007 2.007 0 0 0-2.732.733l-1 1.732c-.55.952-.22 2.183.732 2.732l1.42.82c-.052.357-.105.714-.109 1.075a.5.5 0 0 0 0 .012c.005.366.06.73.115 1.091l-1.426.823a2.007 2.007 0 0 0-.732 2.732l1 1.733a2.007 2.007 0 0 0 2.732.732l1.461-.844A7.975 7.975 0 0 0 9 19.334v1.701c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2v-1.703c.66-.268 1.28-.62 1.848-1.053l1.463.846c.952.55 2.182.22 2.732-.732l1-1.733c.55-.952.22-2.183-.732-2.732l-1.424-.824c.054-.365.109-.73.113-1.098A.5.5 0 0 0 20 12v-.016a.5.5 0 0 0 0-.006c-.005-.352-.058-.702-.11-1.05l1.42-.82a2.007 2.007 0 0 0 .733-2.733l-1-1.732a2.007 2.007 0 0 0-2.732-.733l-1.436.828A7.977 7.977 0 0 0 15 4.666V3c0-1.1-.9-2-2-2Zm0 1h2c.563 0 1 .437 1 1v1.947a.5.5 0 0 0 .336.473 6.992 6.992 0 0 1 2.224 1.27.5.5 0 0 0 .574.053l1.676-.967a.986.986 0 0 1 1.365.366l1 1.732a.986.986 0 0 1-.365 1.365l-1.698.98a.5.5 0 0 0-.24.53c.08.41.121.823.127 1.24v.004a6.996 6.996 0 0 1-.133 1.285.5.5 0 0 0 .24.532l1.704.984a.986.986 0 0 1 .365 1.365l-1 1.733a.986.986 0 0 1-1.365.365l-1.704-.985a.5.5 0 0 0-.572.051 6.994 6.994 0 0 1-2.2 1.254.5.5 0 0 0-.335.473v1.984c0 .563-.437 1-1 1h-2c-.563 0-1-.437-1-1v-1.982a.5.5 0 0 0-.336-.473 6.995 6.995 0 0 1-2.201-1.254.5.5 0 0 0-.572-.05l-1.702.982a.986.986 0 0 1-1.365-.365l-1-1.733a.986.986 0 0 1 .365-1.365l1.706-.984a.5.5 0 0 0 .24-.532A6.994 6.994 0 0 1 5 12.008a7.08 7.08 0 0 1 .127-1.258.5.5 0 0 0-.24-.53l-1.698-.98a.986.986 0 0 1-.365-1.365l1-1.732a.986.986 0 0 1 1.365-.366l1.678.97a.5.5 0 0 0 .574-.054A6.992 6.992 0 0 1 9.664 5.42.5.5 0 0 0 10 4.947V3c0-.563.437-1 1-1zm1 7c-1.65 0-3 1.35-3 3s1.35 3 3 3 3-1.35 3-3-1.35-3-3-3zm0 1c1.11 0 2 .89 2 2 0 1.11-.89 2-2 2-1.11 0-2-.89-2-2 0-1.11.89-2 2-2z" fill="context-stroke"/>
+ <path fill-opacity=".997" d="M11 1.5c-.831 0-1.5.669-1.5 1.5v1.947a7.5 7.5 0 0 0-2.383 1.365L5.44 5.345a1.496 1.496 0 0 0-2.048.549l-1 1.732a1.497 1.497 0 0 0 .548 2.049l1.698.98a7.5 7.5 0 0 0-.137 1.354 7.5 7.5 0 0 0 .145 1.369l-1.706.984a1.496 1.496 0 0 0-.548 2.05l1 1.732c.415.72 1.329.964 2.048.548l1.702-.982A7.5 7.5 0 0 0 9.5 19.053v1.982c0 .831.669 1.5 1.5 1.5h2c.831 0 1.5-.669 1.5-1.5v-1.984a7.5 7.5 0 0 0 2.357-1.344l1.704.984c.72.416 1.633.171 2.048-.548l1-1.733a1.497 1.497 0 0 0-.548-2.049l-1.704-.984A7.5 7.5 0 0 0 19.5 12v-.016a7.502 7.502 0 0 0-.137-1.33l1.698-.98c.72-.416.964-1.33.548-2.049l-1-1.732a1.496 1.496 0 0 0-2.048-.55l-1.676.968A7.5 7.5 0 0 0 14.5 4.947V3c0-.831-.669-1.5-1.5-1.5Zm1 8a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 12 2.5 2.5 0 0 1 12 9.5Z" fill="context-fill"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/subtract-circle.svg b/comm/mail/themes/shared/mail/icons/new/touch/subtract-circle.svg
new file mode 100644
index 0000000000..6c58a4e7c4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/subtract-circle.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 24 24" fill-opacity="context-fill-opacity" height="24" width="24">
+ <path d="M11.5 1.5a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10 10 10 0 0 0-10-10z" stroke-opacity="0.2" stroke="context-stroke" fill="context-fill"/>
+ <path d="M5.5 11a.5.5 0 0 0 0 1h12a.5.5 0 0 0 0-1h-12z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/sync.svg b/comm/mail/themes/shared/mail/icons/new/touch/sync.svg
new file mode 100644
index 0000000000..d7fe781c69
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/sync.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill-opacity="context-fill-opacity" version="1.1" viewBox="0 0 24 24" height="24" width="24">
+ <path d="M12 3a8.972 8.972 0 0 0-5.803 2.12.5.5 0 0 0-.058.704.5.5 0 0 0 .703.06A7.962 7.962 0 0 1 12 4c4.424 0 8 3.575 8 8v.293l-2.146-2.147a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .707l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0 0-.707.5.5 0 0 0-.708 0L21 12.293V12c0-4.965-4.035-9-9-9Zm-8.578 7.006a.5.5 0 0 0-.276.14l-3 3a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0L3 11.707v.3C3.004 16.969 7.038 21 12 21a8.972 8.972 0 0 0 5.816-2.131.5.5 0 0 0 .059-.705.5.5 0 0 0-.705-.059A7.963 7.963 0 0 1 12 20c-4.424 0-8-3.576-8-8v-.293l2.146 2.146a.5.5 0 0 0 .708 0 .5.5 0 0 0 0-.707l-3-3a.5.5 0 0 0-.432-.14z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/touch/tasks.svg b/comm/mail/themes/shared/mail/icons/new/touch/tasks.svg
new file mode 100644
index 0000000000..00945d1ffb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/touch/tasks.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M6 2c-1.108 0-2 .892-2 2v16c0 1.108.892 2 2 2h12c1.108 0 2-.892 2-2V4c0-1.108-.892-2-2-2h-1c0 1.108-.892 2-2 2H9c-1.108 0-2-.892-2-2Z" fill="context-fill"/>
+ <path d="M9 0c-.736 0-1.381.404-1.729 1H6C4.347 1 3 2.347 3 4v16c0 1.653 1.347 3 3 3h12c1.653 0 3-1.347 3-3V4c0-1.653-1.347-3-3-3h-1.271C16.38.404 15.736 0 15 0Zm0 1h6c.563 0 1 .437 1 1s-.437 1-1 1H9c-.563 0-1-.437-1-1s.437-1 1-1ZM6 2h1c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2h1c1.117 0 2 .883 2 2v16c0 1.117-.883 2-2 2H6c-1.117 0-2-.883-2-2V4c0-1.117.883-2 2-2Zm9.563 6.004a.5.5 0 0 0-.497.248l-3.675 6.432-2.537-2.538a.5.5 0 0 0-.708 0 .5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .788-.106l4-7a.5.5 0 0 0-.186-.682.5.5 0 0 0-.185-.062Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/trash-sm.svg b/comm/mail/themes/shared/mail/icons/new/trash-sm.svg
new file mode 100644
index 0000000000..2838bba881
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/trash-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M2.531 2.42c-.572 0-1.033.461-1.033 1.037v.693c0 .192.153.346.344.346h.626l.032 5c.003.575.428 1 1 1h4c.572 0 1-.425 1-1v-5h.718a.344.346 0 0 0 .347-.346v-.693c0-.576-.462-1.037-1.034-1.037z" fill="context-fill"/>
+ <path d="M5.031 1c-.352 0-.703.158-.863.416a.914.914 0 0 0-.139.507H2.531c-.842 0-1.533.695-1.533 1.538v.693c0 .459.383.846.844.846h.13L2 9.504A1.51 1.51 0 0 0 3.5 11h4c.82 0 1.5-.677 1.5-1.5V5h.219c.46 0 .845-.387.845-.846v-.693c0-.843-.69-1.538-1.533-1.538H7.033a.914.914 0 0 0-.138-.507C6.735 1.158 6.384 1 6.031 1Zm-2.5 1.923h6c.302 0 .533.23.533.538V4H1.998v-.54c0-.308.231-.537.533-.537zM2.973 5H8v4.5c0 .327-.176.5-.5.5h-4c-.324 0-.498-.173-.5-.504ZM4.5 6a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5Zm2 0a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/unread-dot.svg b/comm/mail/themes/shared/mail/icons/new/unread-dot.svg
new file mode 100644
index 0000000000..09d4e5d622
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/unread-dot.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 16 16" height="16" width="16">
+ <path fill-opacity="1" d="M8 4.5A3.5 3.5 0 0 0 4.5 8 3.5 3.5 0 0 0 8 11.5 3.5 3.5 0 0 0 11.5 8 3.5 3.5 0 0 0 8 4.5Zm.518 1.552a.5.5 0 0 1 .11.016 2.007 2.007 0 0 1 1.415 2.45.5.5 0 0 1-.611.353.5.5 0 0 1-.354-.612.992.992 0 0 0-.707-1.224.5.5 0 0 1-.353-.614.5.5 0 0 1 .5-.369Z" fill="context-fill"/>
+ <path d="M8 4C5.797 4 4 5.796 4 8c0 2.203 1.797 4 4 4 2.203 0 4-1.797 4-4 0-2.204-1.797-4-4-4Zm0 1c1.663 0 3 1.337 3 3 0 1.662-1.337 3-3 3S5 9.662 5 8c0-1.663 1.337-3 3-3Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/new/unread-sm.svg b/comm/mail/themes/shared/mail/icons/new/unread-sm.svg
new file mode 100644
index 0000000000..0b3b12d3fb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/new/unread-sm.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill-opacity="context-fill-opacity" viewBox="0 0 12 12" height="12" width="12">
+ <path d="M4.957 2.5a3 3 0 0 1 .008.955l-.002.013-.002.006a3 3 0 0 1-.106.442l-.011.033a3 3 0 0 1-.158.383l-.03.058a3 3 0 0 1-.197.323c-.019.026-.037.054-.057.08l-.004.004a3 3 0 0 1-.601.601l-.004.004c-.026.02-.054.039-.08.057-.103.073-.21.138-.322.197l-.059.03a2.998 2.998 0 0 1-.383.158l-.033.011a3.008 3.008 0 0 1-.441.106l-.006.002-.014.002C2.305 5.988 2.152 6 2 6a3.037 3.037 0 0 1-.5-.047V8.5c0 .554.446 1 1 1h7c.554 0 1-.446 1-1v-5c0-.554-.446-1-1-1z" fill="context-fill"/>
+ <path d="M2 1C.901 1 0 1.901 0 3c0 1.098.901 2 2 2s2-.902 2-2c0-1.099-.901-2-2-2Zm0 1c.558 0 1 .441 1 1 0 .558-.442 1-1 1s-1-.442-1-1c0-.559.442-1 1-1Zm2.826 0c.112.313.174.65.174 1h4c.563 0 1 .437 1 1v4c0 .562-.437 1-1 1H3c-.563 0-1-.438-1-1V6a2.99 2.99 0 0 1-1-.174V8c0 1.099.9 2 2 2h6c1.1 0 2-.901 2-2V4c0-1.1-.9-2-2-2Zm3.729 2.002a.5.5 0 0 0-.367.107L6 5.859 4.498 4.656c-.187.28-.42.529-.687.732l.607.487-1.272 1.271a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l1.35-1.35.484.387a.5.5 0 0 0 .625 0l.484-.386 1.35 1.35a.5.5 0 0 0 .707 0 .5.5 0 0 0 0-.708L7.582 5.875l1.23-.985a.5.5 0 0 0 .079-.703.5.5 0 0 0-.336-.185Z" fill="context-stroke"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/newmail.svg b/comm/mail/themes/shared/mail/icons/newmail.svg
new file mode 100644
index 0000000000..76fec41cdb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/newmail.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" stroke="context-stroke" stroke-opacity="context-stroke-opacity" viewBox="0 0 16 16">
+ <path d="M8 1l1 4 4-2-2 4 4 1-4 1 2 4-4-2-1 4-1-4-4 2 2-4-4-1 4-1-2-4 4 2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/newmsg.svg b/comm/mail/themes/shared/mail/icons/newmsg.svg
new file mode 100644
index 0000000000..b77b63d3ca
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/newmsg.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M14.35 2.35l-.7-.7a2 2 0 0 0-2.83 0l-.38.38a.5.5 0 0 0 0 .7l2.83 2.83a.5.5 0 0 0 .7 0l.38-.38a2 2 0 0 0 0-2.83zM9.73 3.44a.5.5 0 0 0-.7 0L3.24 9.22a1.99 1.99 0 0 0-.46.71l-1.75 4.39a.5.5 0 0 0 .46.68.5.5 0 0 0 .19-.04l4.38-1.75a1.97 1.97 0 0 0 .72-.45l5.77-5.78a.5.5 0 0 0 0-.7zM5.16 12.5l-2.55 1.02a.1.1 0 0 1-.13-.13l1.02-2.56a.1.1 0 0 1 .16-.03l1.54 1.53a.1.1 0 0 1-.04.17z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/newsgroup.svg b/comm/mail/themes/shared/mail/icons/newsgroup.svg
new file mode 100644
index 0000000000..be8154de7a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/newsgroup.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"><path d="M3 16h10a3 3 0 003-3V3a3 3 0 00-3-3H7a3 3 0 00-3 3v10c0 1.08-1.03 1-1.03 1M3 16s3 0 3-3V3a1 1 0 011-1h6a1 1 0 011 1v10a1 1 0 01-1 1H3"/><path d="M11.75 4.88h-3.5c-.55 0-.55-1 0-1h3.5c.54 0 .54 1 0 1zm0 2h-3.5c-.55 0-.55-1 0-1h3.5c.54 0 .54 1 0 1zm0 2h-3.5c-.55 0-.55-1 0-1h3.5c.54 0 .54 1 0 1zm-2.02 2H8.24c-.55 0-.55-1 0-1h1.48c.54 0 .54 1 0 1zM5 4H3a3 3 0 00-3 3v6a3 3 0 003 3h2m0-2H3a1 1 0 01-1-1V7a1 1 0 011-1h2"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/nextmsg.svg b/comm/mail/themes/shared/mail/icons/nextmsg.svg
new file mode 100644
index 0000000000..a2e9eb6b19
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/nextmsg.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/nextunread.svg b/comm/mail/themes/shared/mail/icons/nextunread.svg
new file mode 100644
index 0000000000..0da22e4256
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/nextunread.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M13 4a1 1 0 0 0-.707.293L8 8.586 3.707 4.293a1 1 0 0 0-1.414 1.414l5 5a1 1 0 0 0 1.414 0l5-5A1 1 0 0 0 13 4z"/>
+ <circle cx="13" cy="11" r="2"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/notification-fill-12.svg b/comm/mail/themes/shared/mail/icons/notification-fill-12.svg
new file mode 100644
index 0000000000..6af8d91526
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/notification-fill-12.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 12 12" width="12" height="12" fill-opacity="context-fill-opacity">
+ <circle fill="context-fill" cx="6" cy="6" r="3"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/notloading.png b/comm/mail/themes/shared/mail/icons/notloading.png
new file mode 100644
index 0000000000..0a4dc149e3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/notloading.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/icons/notloading@2x.png b/comm/mail/themes/shared/mail/icons/notloading@2x.png
new file mode 100644
index 0000000000..a073eaf8e8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/notloading@2x.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/icons/number-list.svg b/comm/mail/themes/shared/mail/icons/number-list.svg
new file mode 100644
index 0000000000..4b4744f255
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/number-list.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M7 5h7c1.33 0 1.33-2 0-2H7C5.67 3 5.67 5 7 5zm7 6H7c-1.33 0-1.33 2 0 2h7c1.33 0 1.33-2 0-2zM2.25 6.37V2.8c-.38.28-.75.6-1.2.75C.72 3.66.4 3.3.51 3c.08-.23.35-.3.55-.4.54-.24 1-.63 1.33-1.13.12-.2.3-.42.56-.33.3.07.38.4.36.67v4.49c0 .27-.1.62-.41.68-.27.07-.58-.1-.63-.38a.96.96 0 0 1-.03-.24zm-.4 7.61h2.14c.27 0 .56.21.52.51-.01.27-.29.44-.54.41H1.05c-.33 0-.63-.33-.54-.66.08-.32.27-.6.52-.82.5-.49 1-.99 1.57-1.4.4-.32.78-.78.74-1.32-.04-.45-.48-.8-.92-.77-.4.01-.76.28-.86.67-.11.24-.19.58-.49.63-.3.08-.6-.2-.55-.5.02-.67.5-1.26 1.13-1.46a2.77 2.77 0 0 1 1.7 0 1.6 1.6 0 0 1 1.04 1.77c-.07.47-.35.87-.68 1.2-.55.52-1.2.94-1.7 1.51-.06.1-.11.15-.17.23z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/offline.svg b/comm/mail/themes/shared/mail/icons/offline.svg
new file mode 100644
index 0000000000..aa52393be2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/offline.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill">
+ <path d="M3.08 2.04a.93.93 0 00-.74.3 8 8 0 000 11.32c.83.82 2.34-.5 1.42-1.42a6 6 0 010-8.48c.68-.69.06-1.66-.68-1.71zM13.7 6.56c.88 1.86-.1 4.79-1.47 5.68-1.03.8.5 2.34 1.42 1.42 2.4-1.96 3.06-5.79 1.58-8.92M5.2 4.17a.93.93 0 00-.74.3 5 5 0 000 7.07c.93.92 2.34-.5 1.42-1.42a3 3 0 010-4.24c.7-.69.06-1.66-.68-1.71zm4.92 5.95c-.92.92.5 2.34 1.42 1.42 1.4-1.66 1.42-2.43 1.31-4.27M7.95 6A2 2 0 006 8c0 1.1 1.62 1.58 2.4.8l.5-.5C9.68 7.52 9.1 6 8 6"/>
+ <path fill="#f60e0e" d="M13.3 1.278L1.304 13.27c-.976.95.471 2.39 1.415 1.42L14.72 2.692c.92-.921-.51-2.321-1.42-1.414z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/online.svg b/comm/mail/themes/shared/mail/icons/online.svg
new file mode 100644
index 0000000000..18041fbacf
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/online.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M3.08 2.04a.93.93 0 00-.74.3 8 8 0 000 11.32c.83.82 2.34-.5 1.42-1.42a6 6 0 010-8.48c.68-.69.06-1.66-.68-1.71zm9.84 0c-.74.06-1.37 1.03-.68 1.72a5.98 5.98 0 010 8.48c-.92.92.5 2.34 1.42 1.42a8 8 0 000-11.32.93.93 0 00-.74-.3zM5.2 4.18a.93.93 0 00-.74.3 5 5 0 000 7.07c.93.92 2.34-.5 1.42-1.42a3 3 0 010-4.24c.7-.69.06-1.66-.68-1.71zm5.6 0c-.74.05-1.37 1.02-.68 1.7a3.01 3.01 0 010 4.25c-.92.92.5 2.34 1.42 1.42a5 5 0 000-7.07.93.93 0 00-.74-.3zM7.95 6A2 2 0 006 8a2 2 0 002 2 2 2 0 002-2 2 2 0 00-2-2 2 2 0 00-.05 0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/operator.png b/comm/mail/themes/shared/mail/icons/operator.png
new file mode 100644
index 0000000000..1853d3a019
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/operator.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/icons/outbox.svg b/comm/mail/themes/shared/mail/icons/outbox.svg
new file mode 100644
index 0000000000..124a54c2e8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/outbox.svg
@@ -0,0 +1,4 @@
+<!-- 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" fill="context-fill" fill-opacity="context-fill-opacity" width="16" height="16"><path d="M13.5 1H11c-1.33 0-1.33 2 0 2h2.5c.28 0 .5.23.5.5v9.12a.5.5 0 01-.5.5h-11a.5.5 0 01-.5-.5V3.5c0-.27.22-.5.5-.5H5c1.33 0 1.33-2 0-2H2.5A2.5 2.5 0 000 3.5v9.12a2.5 2.5 0 002.5 2.5h11a2.5 2.5 0 002.5-2.5V3.5A2.5 2.5 0 0013.5 1z"/><path d="M14.67 9.01L10.98 9c-.13 1.89-1.04 3-2.48 3h-1c-1.44 0-2.35-1.11-2.48-3l-3.64-.01v-.96L5.5 8c.27 0 .5.22.5.5 0 .75.14 2.5 1.5 2.5h1C9.85 11 10 9.25 10 8.5c0-.28.22-.5.5-.5l4.17.01"/><path d="M12.02 5.74a.97.97 0 00-.4-.72L8 2 4.38 5.02c-.98.85.25 2.33 1.27 1.53L7 5.43v2.64c0 1.25 2 1.23 2 0V5.43l1.35 1.12c.77.67 1.7-.05 1.67-.81z"/></svg>
diff --git a/comm/mail/themes/shared/mail/icons/outdent.svg b/comm/mail/themes/shared/mail/icons/outdent.svg
new file mode 100644
index 0000000000..1049e27100
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/outdent.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 1C.667 1 .667 3 2 3h12c1.33 0 1.33-2 0-2H2zm2.756 2.965c-.254.01-.514.127-.727.394L1 8l3.029 3.64c.859.98 2.348-.26 1.539-1.28L4.449 9h2.352c1.258 0 1.241-2 0-2H4.449l1.119-1.359c.675-.771-.049-1.708-.812-1.676zM10 5c-1.333 0-1.333 2 0 2h4c1.33 0 1.33-2 0-2h-4zm0 4c-1.333 0-1.333 2 0 2h4c1.33 0 1.33-2 0-2h-4zm-8 4c-1.333 0-1.333 2 0 2h12c1.33 0 1.33-2 0-2H2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/overflow-indicator.png b/comm/mail/themes/shared/mail/icons/overflow-indicator.png
new file mode 100644
index 0000000000..f87112df5d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/overflow-indicator.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/icons/overflow.svg b/comm/mail/themes/shared/mail/icons/overflow.svg
new file mode 100644
index 0000000000..478e3ea906
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/overflow.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M8.707 7.293l-5-5a1 1 0 0 0-1.414 1.414L6.586 8l-4.293 4.293a1 1 0 1 0 1.414 1.414l5-5a1 1 0 0 0 0-1.414zm6 0l-5-5a1 1 0 0 0-1.414 1.414L12.586 8l-4.293 4.293a1 1 0 1 0 1.414 1.414l5-5a1 1 0 0 0 0-1.414z"></path></svg>
diff --git a/comm/mail/themes/shared/mail/icons/paragraph.svg b/comm/mail/themes/shared/mail/icons/paragraph.svg
new file mode 100644
index 0000000000..d9cac2c35a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/paragraph.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M5 1C0 1 0 8 5 8h1v6c0 1.5 2 1.5 2 0V3h2v11c0 1.5 2 1.5 2 0V3h2c1.5 0 1.5-2 0-2zm0 2h1v3H5C3 6 3 3 5 3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/paste.svg b/comm/mail/themes/shared/mail/icons/paste.svg
new file mode 100644
index 0000000000..64b77646c8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/paste.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M11 2H9.95a2.5 2.5 0 0 0-4.9 0H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zm0 7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5h7zm0-5H4V3h1.05a1 1 0 0 0 .98-.8 1.5 1.5 0 0 1 2.939 0 1 1 0 0 0 .98.8H11zM7.5 2a.5.5 0 1 0 .5.5.5.5 0 0 0-.5-.5zm-2 5h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zm0 2h2a.5.5 0 0 0 0-1h-2a.5.5 0 0 0 0 1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/phishing.svg b/comm/mail/themes/shared/mail/icons/phishing.svg
new file mode 100644
index 0000000000..33206944f4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/phishing.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 .98l-4.47.77C2.64 1.91 2 2.68 2 3.57c0 1.03 0 2.87.08 3.71.04 1.9.67 3.75 1.8 5.29a6.31 6.31 0 004 2.44H8l.09-.02a6.2 6.2 0 004.01-2.42 8.99 8.99 0 001.8-5.29c.1-.84.1-2.68.1-3.7 0-.9-.64-1.67-1.53-1.83zm0 2.04l4 .68c0 1.62.05 2.76 0 3.3a7.6 7.6 0 01-1.49 4.38A4.2 4.2 0 018 12.99c-1-.21-1.9-.79-2.51-1.61A7.56 7.56 0 014 7c-.05-.54 0-1.68 0-3.3zM8 4a1 1 0 00-1 1v3c0 1.33 2 1.33 2 0V5a1 1 0 00-1-1zm1 7a1 1 0 01-1 1 1 1 0 01-1-1 1 1 0 011-1 1 1 0 011 1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/pill-indicator.svg b/comm/mail/themes/shared/mail/icons/pill-indicator.svg
new file mode 100644
index 0000000000..fefd655d5b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/pill-indicator.svg
@@ -0,0 +1,6 @@
+<!-- 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" fill="context-fill" stroke="context-stroke" width="8" height="6" viewBox="0 0 8 6">
+ <circle stroke-width="2" cx="4" cy="2" r="3"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/pluginBlocked.svg b/comm/mail/themes/shared/mail/icons/pluginBlocked.svg
new file mode 100644
index 0000000000..5aafbcea90
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/pluginBlocked.svg
@@ -0,0 +1,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" width="32" height="32" viewBox="0 0 32 32">
+ <path d="M6 7h6a2 2 0 0 0 0-4H6a2 2 0 0 0 0 4zm14 0h6a2 2 0 0 0 0-4h-6a2 2 0 0 0 0 4zm8 2H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h11.349A9.987 9.987 0 0 1 30 16.014V11a2 2 0 0 0-2-2z" fill="#ff0039"/>
+ <circle cx="24" cy="24" r="8" fill="#a4000f"/>
+ <rect x="18" y="22" width="12" height="4" rx="1" ry="1" fill="#fff"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/popular.svg b/comm/mail/themes/shared/mail/icons/popular.svg
new file mode 100644
index 0000000000..5496a888f1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/popular.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M15.207 2.793a1 1 0 0 0-.932-.268l-6.5 1.5a1 1 0 1 0 .45 1.949l3.1-.716L6.5 10.086 5.207 8.793a1 1 0 0 0-1.414 0l-3 3a1 1 0 1 0 1.414 1.414L4.5 10.914l1.293 1.293a1 1 0 0 0 1.414 0l5.535-5.535-.716 3.1a1 1 0 0 0 .75 1.2A1.025 1.025 0 0 0 13 11a1 1 0 0 0 .974-.775l1.5-6.5a1 1 0 0 0-.267-.932z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/previousmsg.svg b/comm/mail/themes/shared/mail/icons/previousmsg.svg
new file mode 100644
index 0000000000..b5995f3162
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/previousmsg.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/previousunread.svg b/comm/mail/themes/shared/mail/icons/previousunread.svg
new file mode 100644
index 0000000000..ef2fdde046
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/previousunread.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M13 12a1 1 0 0 1-.707-.293L8 7.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 12z"/>
+ <circle cx="12" cy="4" r="2"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/print.svg b/comm/mail/themes/shared/mail/icons/print.svg
new file mode 100644
index 0000000000..16c12fade0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/print.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M14 5h-1V1a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v4H2a2 2 0 0 0-2 2v5h3v3a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-3h3V7a2 2 0 0 0-2-2zM2.5 8a.5.5 0 1 1 .5-.5.5.5 0 0 1-.5.5zm9.5 7H4v-5h8zm0-10H4V1h8zm-6.5 7h4a.5.5 0 0 0 0-1h-4a.5.5 0 1 0 0 1zm0 2h5a.5.5 0 0 0 0-1h-5a.5.5 0 1 0 0 1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/privacy-security.svg b/comm/mail/themes/shared/mail/icons/privacy-security.svg
new file mode 100644
index 0000000000..45e70a78ae
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/privacy-security.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M12 7c1 0 2 1 2 2v4c0 1-1 2-2 2H4c-1 0-2-1-2-2V9c0-1 1-2 2-2V5c0-5.33 8-5.33 8 0zm-2 0V5c0-2.67-4-2.67-4 0v2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/quit.svg b/comm/mail/themes/shared/mail/icons/quit.svg
new file mode 100644
index 0000000000..cdc1c3e321
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/quit.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M8 6a1 1 0 0 0 1-1V1a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1zm3.5-4.032a1 1 0 0 0-1 1.732A4.95 4.95 0 0 1 13 8 5 5 0 0 1 3 8a4.95 4.95 0 0 1 2.5-4.3 1 1 0 0 0-1-1.73 7 7 0 1 0 7 0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/quote.svg b/comm/mail/themes/shared/mail/icons/quote.svg
new file mode 100644
index 0000000000..b0f1d1c056
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/quote.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M7 5.959c.107 1.46-.084 2.775-.58 3.935-.532 1.238-1.529 2.235-2.952 2.99a1 1 0 1 1-.936-1.768c1.041-.552 1.71-1.221 2.049-2.01a4.67 4.67 0 0 0 .072-.177A3 3 0 1 1 7 5.959zm7 0c.107 1.46-.084 2.775-.58 3.935-.532 1.238-1.529 2.235-2.952 2.99a1 1 0 1 1-.936-1.768c1.041-.552 1.71-1.221 2.049-2.01a4.67 4.67 0 0 0 .072-.177A3 3 0 1 1 14 5.959z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/read.svg b/comm/mail/themes/shared/mail/icons/read.svg
new file mode 100644
index 0000000000..3f71111ca8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/read.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" stroke="context-stroke" stroke-opacity="context-stroke-opacity" viewBox="0 0 16 16">
+ <circle stroke-width="2" cx="8" cy="8" r="3"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/readcol.svg b/comm/mail/themes/shared/mail/icons/readcol.svg
new file mode 100644
index 0000000000..52069242fb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/readcol.svg
@@ -0,0 +1,9 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill">
+ <circle cx="3.5" cy="6.3" r="2" fill-opacity=".5" />
+ <circle cx="9" cy="6.3" r="2" fill-opacity=".5" />
+ <path d="M2.94 4.02a2.5 2.5 0 0 0-2.5 2.5 2.48 2.48 0 1 0 4.94-.38c.14-.05.37-.16.53-.16.25 0 .41.1.56.2a2.5 2.5 0 0 0 2.47 2.84 2.5 2.5 0 0 0 2.5-2.5 2.5 2.5 0 0 0-2.5-2.5c-.9 0-1.69.5-2.13 1.21a2.38 2.38 0 0 0-.97-.25c-.29 0-.59.13-.8.22a2.48 2.48 0 0 0-2.1-1.18zm0 1.03c.83 0 1.47.63 1.47 1.47a1.5 1.5 0 0 1-1.47 1.53c-.84 0-1.5-.7-1.5-1.53 0-.84.66-1.47 1.5-1.47zm6 0c.83 0 1.5.63 1.5 1.47 0 .83-.67 1.53-1.5 1.53-.84 0-1.5-.7-1.5-1.53 0-.84.66-1.47 1.5-1.47z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/reader-mode.svg b/comm/mail/themes/shared/mail/icons/reader-mode.svg
new file mode 100644
index 0000000000..462ef7dfbb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/reader-mode.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M12 0H4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3V3a3 3 0 0 0-3-3zm1 13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1z"></path><path fill="context-fill" d="M10.5 5h-5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1zm0 2h-5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1zm0 2h-5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1zm-3 2h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 0 1z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/redirect.svg b/comm/mail/themes/shared/mail/icons/redirect.svg
new file mode 100644
index 0000000000..2cf3630e47
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/redirect.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M12.24 3.96c-.76-.03-1.48.91-.81 1.68L13.36 8l-1.93 2.36c-.8 1.02.68 2.26 1.53 1.28L16 8l-3.04-3.64a.97.97 0 00-.72-.4zM8.05 4c-.74-.01-1.42.88-.82 1.64l1.12 1.34H6.19a3.96 3.96 0 01-2.65-.9 6 6 0 01-.45-.4l-.38-.39c-.95-.97-2.4.47-1.42 1.42l.4.4c.2.19.39.36.6.53A5.94 5.94 0 006.2 8.99h2.17l-1.15 1.37c-.9 1.03.69 2.35 1.54 1.28L11.8 8 8.77 4.36A.97.97 0 008.05 4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/remote-blocked.svg b/comm/mail/themes/shared/mail/icons/remote-blocked.svg
new file mode 100644
index 0000000000..32a41b70a3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/remote-blocked.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill">
+ <path d="M13 5c0 1.33 2 1.33 2 0V4c-.15-1.65-1.34-3-3-3H3C1.34 1 0 2.34 0 4v8c0 1.66 1.34 3 3 3h2c1.28 0 1.3-2 0-2H3c-.55 0-1.47-.73-1-1l3.5-2c.97-.4.36-1.14 0-1L2 11V8.5L5 6l2 1.6 2-2.1 1.6.5V5L9 4.5 7 6.6 5 5 2 7.5V4c0-.55.45-1 1-1h9c.55 0 1 .45 1 1z"/>
+ <path d="M11.5 7a4.5 4.5 0 0 1 4.5 4.5 4.5 4.5 0 0 1-4.5 4.5A4.5 4.5 0 0 1 7 11.5 4.5 4.5 0 0 1 11.5 7zm0 1.28a3.22 3.22 0 0 0-1.74.53l4.4 4.5a3.22 3.22 0 0 0 .57-1.8 3.22 3.22 0 0 0-3.23-3.2zM8.83 9.7a3.22 3.22 0 0 0-.56 1.8 3.22 3.22 0 0 0 3.23 3.23 3.22 3.22 0 0 0 1.74-.53z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/remove-text-styling.svg b/comm/mail/themes/shared/mail/icons/remove-text-styling.svg
new file mode 100644
index 0000000000..49a29a4691
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/remove-text-styling.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+ <path d="M6.04 1a.8.8 0 00-.55.22c-.1.08-.17.2-.22.33l-.06.13L1.07 12c-.42.87 1.12 1.62 1.55.63l1.3-3h1.81L7.36 7.9H4.58l1.46-3.4 1.45 3.24 1.31-1.4-1.94-4.66-.05-.13a.86.86 0 00-.32-.41.8.8 0 00-.1-.06l-.12-.05a.78.78 0 00-.11-.02L6.04 1zM6.4 10.86a1.5 1.5 0 00.07 2.12l2.2 2.04c.6.57 1.55.53 2.11-.06l4.1-4.39c.56-.6.53-1.56-.07-2.12L12.6 6.4a1.5 1.5 0 00-2.11.07l-3.08 3.3zm.73.68l1.03-1.1 2.92 2.73-1.03 1.1c-.2.2-.5.22-.7.02l-2.2-2.04a.49.49 0 01-.02-.71zm1.7-1.83l2.4-2.55c.2-.21.5-.22.7-.03l2.2 2.05c.2.2.21.5.02.7l-2.4 2.56z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/reply-forward-redirect.svg b/comm/mail/themes/shared/mail/icons/reply-forward-redirect.svg
new file mode 100644
index 0000000000..4cab92e0db
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/reply-forward-redirect.svg
@@ -0,0 +1,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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M12.55 5c-1 0-1.38.14-1.81.48-.32.25-.34.24-.63.53-.95.96-2.38-.48-1.41-1.42.3-.33.49-.4.79-.67A4.52 4.52 0 0112.58 3l-.94-1.16c-1-1.03.69-2.44 1.53-1.28L16 4l-2.83 3.44c-.86.9-2.26-.27-1.53-1.28z"/>
+ <path fill="context-stroke" fill-opacity="context-fill-opacity" d="M3.46 5.03l.9 1.13c.9 1.03-.68 2.35-1.53 1.28L0 4 2.83.56c.86-.98 2.35.26 1.54 1.28L3.4 3.03c5.18-.57 5.7 3.62 3.17 5.35-1.11.85-2.28-.68-1.3-1.56 2.38-2.01-1.2-1.79-1.82-1.79z"/>
+ <path fill="#f68a16" fill-opacity="context-fill-opacity" d="M12.44 8.16c-.76-.03-1.48.91-.81 1.68L13.36 12l-1.73 2.16c-.8 1.02.68 2.26 1.53 1.28L16 12l-2.84-3.44a.97.97 0 00-.72-.4zm-4.19.04c-.74-.01-1.42.88-.82 1.64L8.35 11H6.19c-.96.03-1.9-.3-2.65-.92-.32-.25-.54-.5-.83-.8-.95-.96-2.4.48-1.42 1.43.3.33.7.66 1 .93 1.1.9 2.49 1.4 3.91 1.36h2.17l-.95 1.16c-.9 1.03.69 2.35 1.54 1.28L11.8 12 8.97 8.56a.97.97 0 00-.72-.36z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/reply-forward.svg b/comm/mail/themes/shared/mail/icons/reply-forward.svg
new file mode 100644
index 0000000000..19ee508fb0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/reply-forward.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M12.21 6.02h-1.15c-1.1 0-1.92-.13-2.66.46-.32.25-.34.24-.63.53-.95.96-2.38-.48-1.41-1.42.3-.33.49-.4.79-.67 1.07-.86 2.34-.92 3.93-.9h1.16l-.94-1.18c-1-1.03.69-2.44 1.53-1.28L15.66 5l-2.83 3.44c-.86.9-2.26-.27-1.53-1.28z"/>
+ <path fill="context-stroke" fill-opacity="context-fill-opacity" d="M3.66 11.03l.9 1.13c.9 1.03-.68 2.35-1.53 1.28L.2 10l2.83-3.44c.86-.98 2.35.26 1.54 1.28L3.6 9.03c4.07 0 5.8.05 6.6 1.67.67 1.73.3 2.61-2.03 4.18-1.11.85-2.28-.68-1.3-1.56.95-.74 1.78-1.16 1.55-1.73-.3-.56-1.54-.56-4.77-.56z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/reply-redirect.svg b/comm/mail/themes/shared/mail/icons/reply-redirect.svg
new file mode 100644
index 0000000000..cf271db85f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/reply-redirect.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M12.44.16c-.76-.03-1.48.91-.81 1.68L13.36 4l-1.73 2.16c-.8 1.02.68 2.26 1.53 1.28L16 4 13.16.56a.97.97 0 00-.72-.4zM8.25.2c-.74-.01-1.42.88-.82 1.64L8.35 3H6.19c-.96.03-1.9-.3-2.65-.92-.32-.25-.54-.5-.83-.8-.95-.96-2.4.48-1.42 1.43.3.33.7.66 1 .93 1.1.9 2.49 1.4 3.91 1.36h2.17l-.95 1.16c-.9 1.03.69 2.35 1.54 1.28L11.8 4 8.97.56A.97.97 0 008.25.2z"/>
+ <path fill="context-stroke" fill-opacity="context-fill-opacity" d="M3.66 11.03l.9 1.13c.9 1.03-.68 2.35-1.53 1.28L.2 10l2.83-3.44c.86-.98 2.35.26 1.54 1.28L3.6 9.03c4.07 0 5.8.05 6.6 1.67.67 1.73.3 2.61-2.03 4.18-1.11.85-2.28-.68-1.3-1.56.95-.74 1.78-1.16 1.55-1.73-.3-.56-1.54-.56-4.77-.56z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/reply.svg b/comm/mail/themes/shared/mail/icons/reply.svg
new file mode 100644
index 0000000000..e19b2a9b8f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/reply.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M6.663 6.033L7.768 7.36a1 1 0 1 1-1.536 1.28L3.198 5l3.034-3.64a1 1 0 0 1 1.536 1.28L6.612 4.028c4.068.15 6.474.909 7.282 2.525.933 1.864-.237 4.204-3.187 7.154a1 1 0 1 1-1.414-1.414c2.384-2.384 3.214-4.044 2.813-4.846-.366-.73-2.2-1.277-5.443-1.414z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/replyall.svg b/comm/mail/themes/shared/mail/icons/replyall.svg
new file mode 100644
index 0000000000..40166471ad
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/replyall.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M7.963 6.033L9.068 7.36c.899 1.028-.687 2.35-1.536 1.28L4.498 5l3.034-3.64c.857-.98 2.345.26 1.536 1.28L7.912 4.028c4.068.15 5.178.909 5.978 2.525.94 1.864-.23 4.207-3.18 7.157-.946.97-2.393-.47-1.417-1.42 2.387-2.381 3.217-4.041 2.817-4.843-.37-.73-.9-1.277-4.147-1.414zM3.498 5l1.985 2.36c.899 1.028-.687 2.35-1.536 1.28L.913 5l3.034-3.64c.857-.98 2.345.26 1.536 1.28z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/replylist.svg b/comm/mail/themes/shared/mail/icons/replylist.svg
new file mode 100644
index 0000000000..58423a5cb4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/replylist.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M15 8.758a4.474 4.474 0 0 0-2-.73V3a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h4.256a4.51 4.51 0 0 0 1.415 2H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v5.758zM9.671 9H5.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 .447.275A4.494 4.494 0 0 0 9.67 9zm-1.415 2H5.5a.5.5 0 1 1 0-1h3.258a4.484 4.484 0 0 0-.502 1zM10.5 5h-5a.5.5 0 0 1 0-1h5a.5.5 0 1 1 0 1zm0 2h-5a.5.5 0 0 1 0-1h5a.5.5 0 1 1 0 1z"/>
+ <path d="M12.5 16a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm-.793-3H14.5a.5.5 0 1 0 0-1h-2.793l1.147-1.146a.5.5 0 0 0-.708-.708l-2 2a.5.5 0 0 0 0 .708l2 2a.5.5 0 0 0 .708-.708L11.707 13z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/restore.svg b/comm/mail/themes/shared/mail/icons/restore.svg
new file mode 100644
index 0000000000..dce3219ef7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/restore.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path transform="scale(-1,1) translate(-16,0)" fill="context-fill" d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>
diff --git a/comm/mail/themes/shared/mail/icons/return-receipt.svg b/comm/mail/themes/shared/mail/icons/return-receipt.svg
new file mode 100644
index 0000000000..e6d38e097a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/return-receipt.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M6 16a.99.99 0 01-.7-.29l-3-3c-.95-.94.46-2.36 1.4-1.42l2.16 2.16 5.32-7.62c.76-1.1 2.4.05 1.64 1.14l-6 8.6c-.17.24-.44.4-.73.43H6z"/>
+ <path d="M5.6 11.01a.99.99 0 01-.71-.29l-3-3c-.94-.95.46-2.35 1.41-1.43l2.16 2.17L10.77.83c.76-1.1 2.41.05 1.65 1.15l-6 8.6c-.17.24-.44.4-.74.43H5.6z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/right-align.svg b/comm/mail/themes/shared/mail/icons/right-align.svg
new file mode 100644
index 0000000000..36406efefe
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/right-align.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 1C.667 1 .667 3 2 3h12c1.33 0 1.33-2 0-2zm2 4C2.667 5 2.667 7 4 7h10c1.33 0 1.33-2 0-2zM2 9C.667 9 .667 11 2 11h12c1.33 0 1.33-2 0-2zm1 4c-1.333 0-1.333 2 0 2h11c1.33 0 1.33-2 0-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/save-as.svg b/comm/mail/themes/shared/mail/icons/save-as.svg
new file mode 100644
index 0000000000..0207fb6045
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/save-as.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M14 3h-2v2h2v8H2V5h7V3h-.849L6.584 1.538A2 2 0 0 0 5.219 1H2a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM2 3h3.219l1.072 1H2z"/>
+ <path d="M8.146 6.146a.5.5 0 0 0 0 .707l2 2a.5.5 0 0 0 .707 0l2-2a.5.5 0 1 0-.707-.707L11 7.293V.5a.5.5 0 0 0-1 0v6.793L8.854 6.146a.5.5 0 0 0-.708 0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/save.svg b/comm/mail/themes/shared/mail/icons/save.svg
new file mode 100644
index 0000000000..f37a6c703d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/save.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M9 3H3v10h10V5.828L10.172 3H3v3h5a1 1 0 0 0 1-1V3zM3 1h7.172a2 2 0 0 1 1.414.586l2.828 2.828A2 2 0 0 1 15 5.828V13a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2zm5 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/search-folder.svg b/comm/mail/themes/shared/mail/icons/search-folder.svg
new file mode 100644
index 0000000000..71bad4fa05
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/search-folder.svg
@@ -0,0 +1,7 @@
+<!-- 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" fill="context-fill" fill-opacity="context-fill-opacity" width="16" height="16" viewBox="0 0 16 16">
+ <path d="M14 3H8.15L6.58 1.54A2 2 0 005.22 1H2a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2zM5.22 3l1.07 1H2V3zM14 13H2V5h6v-.01l.15.01H14z"/>
+ <path d="M10.9 11.37L9.08 9.56a2.25 2.25 0 10-.53.53l1.8 1.8a.37.37 0 00.53-.52zM7.25 9.76a1.5 1.5 0 111.5-1.5 1.5 1.5 0 01-1.5 1.5z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/search-not-found.svg b/comm/mail/themes/shared/mail/icons/search-not-found.svg
new file mode 100644
index 0000000000..eed3e08712
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/search-not-found.svg
@@ -0,0 +1,11 @@
+<!-- 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/. -->
+<!DOCTYPE svg [
+ <!ENTITY % quickFilterBarDTD SYSTEM "chrome://messenger/locale/quickFilterBar.dtd">
+ %quickFilterBarDTD;
+]>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 120" width="400px" height="120px">
+ <text x="200" y="112" text-anchor="middle" style="font: 14px sans-serif;" fill="GrayText">&quickFilterBar.resultsLabel.none;</text>
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M192.9 3.24a35.8 35.8 0 00-30.5 15.19c-4 6.01-6.4 13.63-6.4 21.25a36.17 36.17 0 0036.1 36.08c7.2 0 14.4-2.41 20.8-6.42l24.9 24.86c2.4 2.4 6 2.4 8.4 0a5.82 5.82 0 000-8.42l-24.9-25.26a36.08 36.08 0 00-8.4-50.51 36.72 36.72 0 00-20-6.81zm-.8 12.39c13.2 0 24 10.82 24 24.05a24.1 24.1 0 01-24 24.05A24.12 24.12 0 01168 39.68a24.12 24.12 0 0124.1-24.05zm-11.2 16.51l7.6 7.74-7.6 7.69c-2.5 2.41 1.2 6.02 3.6 3.61l7.6-7.73 7.2 7.69c2.4 2.37 6-1.24 3.6-3.61l-7.2-7.69 7.6-7.7c2-2.36-1.2-5.57-3.6-3.57l-7.6 7.7-7.6-7.7c-2.8-1.88-5.2 1.09-3.6 3.57z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/search-spinner.svg b/comm/mail/themes/shared/mail/icons/search-spinner.svg
new file mode 100644
index 0000000000..75ce764939
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/search-spinner.svg
@@ -0,0 +1,9 @@
+<!-- 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" viewBox="0 0 100 120" width="100px" height="120px">
+ <circle fill="none" stroke="context-fill" stroke-opacity="context-fill-opacity" stroke-width="10" stroke-dasharray="36" cx="50" cy="70" r="45">
+ <animateTransform attributeName="transform" type="rotate" from="0 50 70" to="360 50 70" dur="6s" repeatCount="indefinite"/>
+ </circle>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/send.svg b/comm/mail/themes/shared/mail/icons/send.svg
new file mode 100644
index 0000000000..6e2e0c86eb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/send.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M9.985 12.511C8.299 14.171 7.304 15 7 15c-.295 0-.59-1.045-.885-3.134l-2.28-.38a1 1 0 0 1-.519-1.716l8-7.5a1 1 0 0 1 1.678.62l1 9a1 1 0 0 1-1.158 1.096l-2.851-.475zM12 3l-5 8 .5 3L9 11l3-8z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/sent.svg b/comm/mail/themes/shared/mail/icons/sent.svg
new file mode 100644
index 0000000000..a78e54ae8c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/sent.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M12 14.12s.14.03.22.03a1 1 0 00.95-.8L14.98 2.2a1 1 0 00-1.23-1.17L1.4 8s-.5.3-.41 1c.09.65.82.9.82.9L5 11.18v3.4s0 .33.3.41c.3.08.48-.1.7-.35.56-.65 2-2.21 2-2.21zM4 8.57l9-5.12-1.61 8.23-2.69-1.11L10 7 6.15 9.48z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/shield.svg b/comm/mail/themes/shared/mail/icons/shield.svg
new file mode 100644
index 0000000000..30b74c905c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/shield.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M8 15.006l-.112-.012a6.244 6.244 0 0 1-4.012-2.427 9.26 9.26 0 0 1-1.8-5.286C2 6.442 2 4.6 2 3.575a1.845 1.845 0 0 1 1.527-1.822L8 .985l4.471.768A1.845 1.845 0 0 1 14 3.576c0 1.023 0 2.866-.08 3.705a9.26 9.26 0 0 1-1.8 5.286 6.244 6.244 0 0 1-4.012 2.427zM4 3.7C4 5.325 3.951 6.46 4 7a7.572 7.572 0 0 0 1.487 4.382A4.223 4.223 0 0 0 8 12.987a4.221 4.221 0 0 0 2.512-1.605A7.572 7.572 0 0 0 12 7c.049-.54 0-1.675 0-3.3l-4-.685z"></path><path fill="context-fill" d="M8 4.537l-2.5.428c.009.942.03 1.655.062 2a5.765 5.765 0 0 0 1.13 3.53 2.685 2.685 0 0 0 1.3.943H8z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/sidebar-left.svg b/comm/mail/themes/shared/mail/icons/sidebar-left.svg
new file mode 100644
index 0000000000..034778d738
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/sidebar-left.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity"><path d="M3 1h10a3.008 3.008 0 0 1 3 3v8a3.009 3.009 0 0 1-3 3H3a3.005 3.005 0 0 1-3-3V4a3.013 3.013 0 0 1 3-3zm11 11V4a1 1 0 0 0-1-1H8v10h5a1 1 0 0 0 1-1zM2 12a1 1 0 0 0 1 1h4V3H3a1 1 0 0 0-1 1v8z"></path><path d="M3.5 5h2a.5.5 0 0 0 0-1h-2a.5.5 0 0 0 0 1zm0 2h2a.5.5 0 0 0 0-1h-2a.5.5 0 0 0 0 1zm1 2h1a.5.5 0 0 0 0-1h-1a.5.5 0 0 0 0 1z"></path></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/size.svg b/comm/mail/themes/shared/mail/icons/size.svg
new file mode 100644
index 0000000000..8f56292f12
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/size.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M5 2a1 1 0 100 2h4v10a1 1 0 102 0V4h4a1 1 0 100-2zM1 7a1 1 0 100 2h2v5a1 1 0 102 0V9h2a1 1 0 100-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/smiley.svg b/comm/mail/themes/shared/mail/icons/smiley.svg
new file mode 100644
index 0000000000..cfc4127446
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/smiley.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 1.33a6.67 6.67 0 1 1 0 13.35A6.67 6.67 0 0 1 8 1.33zM8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.17 8.99c-1.01.8-1.62 1.59-3.17 1.59-1.55 0-2.16-.79-3.17-1.59L4 9.62A4.91 4.91 0 0 0 8 12a4.9 4.9 0 0 0 4-2.38zm-5.5-3.66a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm4.66 0a1 1 0 0 0 0 2c.56 0 1-.44 1-1a1 1 0 0 0-1-1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/sort.svg b/comm/mail/themes/shared/mail/icons/sort.svg
new file mode 100644
index 0000000000..b07e847709
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/sort.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M13 13c1.33 0 1.33-2 0-2H3c-1.33 0-1.33 2 0 2zm-2-4c1.33 0 1.33-2 0-2H5C3.67 7 3.67 9 5 9zM9 5c1.33 0 1.33-2 0-2H7C5.67 3 5.67 5 7 5z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/spaces.svg b/comm/mail/themes/shared/mail/icons/spaces.svg
new file mode 100644
index 0000000000..bcf498fc4f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/spaces.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" d="M5.3 7H1.7A1.7 1.7 0 0 1 0 5.3V2.7A1.7 1.7 0 0 1 1.7 1h3.6A1.7 1.7 0 0 1 7 2.7v2.6A1.7 1.7 0 0 1 5.3 7zM2 5h3V3H2zm12.3 2h-3.6A1.7 1.7 0 0 1 9 5.3V2.7A1.7 1.7 0 0 1 10.7 1h3.6A1.7 1.7 0 0 1 16 2.7v2.6A1.7 1.7 0 0 1 14.3 7zM11 5h3V3h-3zM5.3 15H1.7A1.7 1.7 0 0 1 0 13.3v-2.6A1.7 1.7 0 0 1 1.7 9h3.6A1.7 1.7 0 0 1 7 10.7v2.6A1.7 1.7 0 0 1 5.3 15zM2 13h3v-2H2zm12.3 2h-3.6A1.7 1.7 0 0 1 9 13.3v-2.6A1.7 1.7 0 0 1 10.7 9h3.6a1.7 1.7 0 0 1 1.7 1.7v2.6a1.7 1.7 0 0 1-1.7 1.7zM11 13h3v-2h-3z"></path></svg>
diff --git a/comm/mail/themes/shared/mail/icons/spelling.svg b/comm/mail/themes/shared/mail/icons/spelling.svg
new file mode 100644
index 0000000000..6317920804
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/spelling.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M11.5 8.994C9.75 8.994 8 10.16 8 12.5c0 4.67 7 4.67 7 0 0-2.34-1.75-3.5-3.5-3.5zm2.38 2.826l-2.5 3c-.16.2-.44.24-.66.1l-1.5-1c-.579-.37 0-1.23.558-.84l1.12.75 2.22-2.65c.47-.47 1.14.17.76.64zm-3.42-4L7.93 1.643c-.333-.844-1.527-.844-1.86 0L1 13.64c-.51 1 1.35 1.86 1.871.72l1.58-3.43H7c.177-.77.679-1.421 1.21-2H5.239l1.76-3.91L8.61 8.6a3.711 3.711 0 0 1 1.85-.879z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/spring.svg b/comm/mail/themes/shared/mail/icons/spring.svg
new file mode 100644
index 0000000000..de16af74a7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/spring.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="32" height="32" viewBox="0 0 32 32">
+ <path fill="context-fill" d="M0 9v14h1v-6.17L6 21V11l-5 4.17V9zm31 6.17L26 11v10l5-4.17V23h1V9h-1zM8 14v4h4v-4zm6 0v4h4v-4zm6 0v4h4v-4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/star.svg b/comm/mail/themes/shared/mail/icons/star.svg
new file mode 100644
index 0000000000..66e03e708b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/star.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M3.8 15.922a1.1 1.1 0 0 1-1.09-1.253l.609-4.36L.392 7.163a1.1 1.1 0 0 1 .616-1.833l4.081-.73L7.015.734a1.1 1.1 0 0 1 1.969 0L10.911 4.6l4.084.729a1.1 1.1 0 0 1 .611 1.833L12.68 10.31l.609 4.359a1.1 1.1 0 0 1-1.6 1.127L8 13.873 4.307 15.8a1.093 1.093 0 0 1-.507.122zm-.415-1.9zm9.228 0zM2.981 7.01l2.451 2.635-.5 3.572L8 11.618l3.067 1.6-.5-3.572 2.451-2.636-3.45-.616L8 3.244l-1.569 3.15zm11.659.29zm-13.278 0zm12.78-1.5zm-12.286 0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/starred.svg b/comm/mail/themes/shared/mail/icons/starred.svg
new file mode 100644
index 0000000000..7a33d7461b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/starred.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M15.845 6.064A1.1 1.1 0 0 0 15 5.331L10.911 4.6 8.985.735a1.1 1.1 0 0 0-1.969 0L5.089 4.6l-4.081.729a1.1 1.1 0 0 0-.615 1.834L3.32 10.31l-.609 4.36a1.1 1.1 0 0 0 1.6 1.127L8 13.873l3.69 1.927a1.1 1.1 0 0 0 1.6-1.127l-.61-4.363 2.926-3.146a1.1 1.1 0 0 0 .239-1.1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/status-away.svg b/comm/mail/themes/shared/mail/icons/status-away.svg
new file mode 100644
index 0000000000..5884f84776
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/status-away.svg
@@ -0,0 +1,14 @@
+<!-- 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="16" height="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#ff7878"/>
+ <stop offset="1" stop-color="#be0000"/>
+ </linearGradient>
+ <radialGradient id="b" xlink:href="#a" r="4" fy="7.6" fx="8.5" cy="7.6" cx="8.5" gradientTransform="matrix(3.6 -.02 .02 3.1 -25.4 -19.3)" gradientUnits="userSpaceOnUse"/>
+ </defs>
+ <circle fill="#a60000" r="8" cy="8" cx="8"/>
+ <circle fill="url(#b)" r="7" cy="8" cx="8"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/status-idle.svg b/comm/mail/themes/shared/mail/icons/status-idle.svg
new file mode 100644
index 0000000000..4a7aba7a0c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/status-idle.svg
@@ -0,0 +1,14 @@
+<!-- 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="16" height="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#ffd08e"/>
+ <stop offset="1" stop-color="#fa5100"/>
+ </linearGradient>
+ <radialGradient id="b" xlink:href="#a" r="4" fy="7.6" fx="8.5" cy="7.6" cx="8.5" gradientTransform="matrix(3.6 -.02 .02 3.1 -25.4 -19.3)" gradientUnits="userSpaceOnUse"/>
+ </defs>
+ <circle fill="#ab4a09" r="8" cy="8" cx="8"/>
+ <circle fill="url(#b)" r="7" cy="8" cx="8"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/status-offline.svg b/comm/mail/themes/shared/mail/icons/status-offline.svg
new file mode 100644
index 0000000000..ea3ae8bb84
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/status-offline.svg
@@ -0,0 +1,14 @@
+<!-- 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="16" height="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#f5f5f5"/>
+ <stop offset="1" stop-color="#b2b2b2"/>
+ </linearGradient>
+ <radialGradient id="b" xlink:href="#a" r="4" fy="7.6" fx="8.5" cy="7.6" cx="8.5" gradientTransform="matrix(3.6 -.02 .02 3.1 -25.4 -19.3)" gradientUnits="userSpaceOnUse"/>
+ </defs>
+ <circle fill="#797979" r="8" cy="8" cx="8"/>
+ <circle fill="url(#b)" r="7" cy="8" cx="8"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/status-online.svg b/comm/mail/themes/shared/mail/icons/status-online.svg
new file mode 100644
index 0000000000..6209d78934
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/status-online.svg
@@ -0,0 +1,14 @@
+<!-- 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="16" height="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#3cff13"/>
+ <stop offset="1" stop-color="#058200"/>
+ </linearGradient>
+ <radialGradient id="b" xlink:href="#a" r="4" fy="7.6" fx="8.5" cy="7.6" cx="8.5" gradientTransform="matrix(3.6 -.02 .02 3.1 -25.4 -19.3)" gradientUnits="userSpaceOnUse"/>
+ </defs>
+ <circle fill="#007600" r="8" cy="8" cx="8"/>
+ <circle fill="url(#b)" r="7" cy="8" cx="8"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/sticky.svg b/comm/mail/themes/shared/mail/icons/sticky.svg
new file mode 100644
index 0000000000..e52fa6aca7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/sticky.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M14.707 13.293L11.414 10l2.293-2.293a1 1 0 0 0 0-1.414A4.384 4.384 0 0 0 10.586 5h-.172A2.415 2.415 0 0 1 8 2.586V2a1 1 0 0 0-1.707-.707l-5 5A1 1 0 0 0 2 8h.586A2.415 2.415 0 0 1 5 10.414v.169a4.036 4.036 0 0 0 1.337 3.166 1 1 0 0 0 1.37-.042L10 11.414l3.293 3.293a1 1 0 0 0 1.414-1.414zm-7.578-1.837A2.684 2.684 0 0 1 7 10.583v-.169a4.386 4.386 0 0 0-1.292-3.121 4.414 4.414 0 0 0-1.572-1.015l2.143-2.142a4.4 4.4 0 0 0 1.013 1.571A4.384 4.384 0 0 0 10.414 7h.172a2.4 2.4 0 0 1 .848.152z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/stop.svg b/comm/mail/themes/shared/mail/icons/stop.svg
new file mode 100644
index 0000000000..464a26489f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/stop.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M9.414 8l5.293-5.293a1 1 0 0 0-1.414-1.414L8 6.586 2.707 1.293a1 1 0 0 0-1.414 1.414L6.586 8l-5.293 5.293a1 1 0 1 0 1.414 1.414L8 9.414l5.293 5.293a1 1 0 0 0 1.414-1.414z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/subthread-ignored.svg b/comm/mail/themes/shared/mail/icons/subthread-ignored.svg
new file mode 100644
index 0000000000..d3a497b7a4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/subthread-ignored.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16">
+ <path stroke="context-fill" stroke-opacity="context-fill-opacity" fill="none" d="M2.5 2v3.5h3v7H9m-3.5-4H9"/>
+ <path fill="red" d="M10.5 10A2.5 2.5 0 008 12.5a2.5 2.5 0 002.5 2.5 2.5 2.5 0 002.5-2.5 2.5 2.5 0 00-2.5-2.5zM9 12h3v1H9v-1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/subtract-circle-fill.svg b/comm/mail/themes/shared/mail/icons/subtract-circle-fill.svg
new file mode 100644
index 0000000000..63a30033f8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/subtract-circle-fill.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 16 16" width="16" height="16" fill-opacity="context-fill-opacity">
+ <circle fill="context-stroke" cx="7.6" cy="7.6" r="7.5"/>
+ <path fill="context-fill" d="M11 8.2H4.2c-.3 0-.6-.2-.6-.6s.3-.6.6-.6H11c.3 0 .6.3.6.6s-.3.6-.6.6z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/tab-drag-indicator.svg b/comm/mail/themes/shared/mail/icons/tab-drag-indicator.svg
new file mode 100644
index 0000000000..d195802641
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/tab-drag-indicator.svg
@@ -0,0 +1,4 @@
+<!-- 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" width="12" height="29"><path d="M6 0a5 5 0 015 5 4.85 4.85 0 01-3 4.48V26a1 1 0 01-1 1H5a1 1 0 01-1-1V9.48C2.02 8.81 1.2 6.93 1 5a5 5 0 015-5z" fill="#fff" filter="drop-shadow(0 1px 0.5px rgba(0,0,0,0.496))"/><path d="M6 1a4 4 0 014 4c-.17 2.25-1.05 3.02-3 3.84V26H5V8.84C3.12 8.28 2.19 6.89 2 5a4 4 0 014-4zm0 2a2 2 0 100 4 2 2 0 000-4z" fill="#0a84ff"/></svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/table.svg b/comm/mail/themes/shared/mail/icons/table.svg
new file mode 100644
index 0000000000..0c9643ae01
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/table.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M3 1a3 3 0 0 0-3 3v8c0 1.66 1.343 3 3 3h10c1.66 0 3-1.34 3-3V4c0-1.665-1.34-3-3-3zm0 2h2v3H2V4a1 1 0 0 1 1-1zm3 0h4v3H6zm5 0h2c.55 0 1 .448 1 1v2h-3zM2 7h3v2H2zm4 0h4v2H6zm5 0h3v2h-3zm-9 3h3v3H3c-.552 0-1-.45-1-1zm4 0h4v3H6zm5 0h3v2c0 .55-.45 1-1 1h-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/tag.svg b/comm/mail/themes/shared/mail/icons/tag.svg
new file mode 100644
index 0000000000..c57b73a5d4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/tag.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M7.379 14.207l-5.5-5.5A3 3 0 0 1 1 6.586V1h5.586a3 3 0 0 1 2.121.879l5.5 5.5a3 3 0 0 1 0 4.242l-2.586 2.586a3 3 0 0 1-4.242 0zm1.414-1.414a1 1 0 0 0 1.414 0l2.586-2.586a1 1 0 0 0 0-1.414l-5.5-5.5A1 1 0 0 0 6.586 3H3v3.586a1 1 0 0 0 .293.707l5.5 5.5zM5 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/template.svg b/comm/mail/themes/shared/mail/icons/template.svg
new file mode 100644
index 0000000000..c6f7597626
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/template.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M3 0a2 2 0 00-2 2v12c0 1.1.9 2 2 2h10a2 2 0 002-2V2a2 2 0 00-2-2H3zm1 2h8a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V3a1 1 0 011-1zm1.5 2c-.24 0-.5.26-.5.5v2c0 .24.26.5.5.5h2c.24 0 .5-.26.5-.5v-2c0-.24-.26-.5-.5-.5h-2zm4 0c-.67 0-.67 1 0 1h1c.67 0 .67-1 0-1h-1zM6 5h1v1H6V5zm3.5 1c-.67 0-.67 1 0 1h1c.67 0 .67-1 0-1h-1zm-4 2c-.67 0-.67 1 0 1h5c.67 0 .67-1 0-1h-5zm0 2c-.67 0-.67 1 0 1h2c.67 0 .67-1 0-1h-2zm3 2c-.66 0-.66 1 0 1h2c.67 0 .67-1 0-1h-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/thread-col.svg b/comm/mail/themes/shared/mail/icons/thread-col.svg
new file mode 100644
index 0000000000..0ce88728f8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/thread-col.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill">
+ <path d="M3 1v3h2v6h3V9H6V7h2V6H6V3H4V1H3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/thread-ignored.svg b/comm/mail/themes/shared/mail/icons/thread-ignored.svg
new file mode 100644
index 0000000000..c4f3f297b3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/thread-ignored.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16">
+ <path fill="red" d="M7 2a6 6 0 00-6 6 6 6 0 006 6 6 6 0 006-6 6 6 0 00-6-6zM3 7h8v2H3V7z"/>
+</svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/thread.svg b/comm/mail/themes/shared/mail/icons/thread.svg
new file mode 100644
index 0000000000..b2003cead8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/thread.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16">
+ <path stroke="context-fill" stroke-opacity="context-fill-opacity" fill="none" d="M3.5 2v3.5h3v7H10M6.5 8.5H10"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/timeline.svg b/comm/mail/themes/shared/mail/icons/timeline.svg
new file mode 100644
index 0000000000..dca29dbdef
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/timeline.svg
@@ -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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" height="17" width="43" fill="context-fill">
+ <rect y="11" x="1" height="5" width="5"/>
+ <rect y="6" x="7" height="10" width="5"/>
+ <rect y="2" x="13" height="14" width="5"/>
+ <rect y="1" x="19" height="15" width="5"/>
+ <rect y="7" x="25" height="9" width="5"/>
+ <rect y="4" x="31" height="12" width="5"/>
+ <rect y="7" x="37" height="9" width="5"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/toolbarbutton-arrow.svg b/comm/mail/themes/shared/mail/icons/toolbarbutton-arrow.svg
new file mode 100644
index 0000000000..be51912a6b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/toolbarbutton-arrow.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 9 7" fill="context-fill" fill-opacity="context-stroke-opacity">
+ <path d="M4.5 6.2A.9.9 0 0 1 3.8 6L.2 2.3A.9.9 0 0 1 1.5 1l3 3L7.5 1a.9.9 0 0 1 1.2 1.3L5.1 6a.9.9 0 0 1-.6.2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/underline.svg b/comm/mail/themes/shared/mail/icons/underline.svg
new file mode 100644
index 0000000000..794653d608
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/underline.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M4 1a1 1 0 00-1 1v5a5 5 0 0010 0V2a1 1 0 00-2 0v5a3 3 0 11-6 0V2a1 1 0 00-1-1zM3 13a1 1 0 000 2h10a1 1 0 000-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/userIcon.svg b/comm/mail/themes/shared/mail/icons/userIcon.svg
new file mode 100644
index 0000000000..407c208b84
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/userIcon.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="48" height="48" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M0 43.57c0-7.53 1.87-10.19 8.27-13.29 9.12-3.55 11.08-3.55 7.94-10.19-2.98-3.1-2.1-13.3.33-16.4C17.73 1.92 20.29.6 22.98.15c3.73-.44 5 0 7.56 2.66 2.57 2.21 3.08 3.99 3.08 8.86 0 3.1-.33 6.2-.89 6.2-.54.45-1.32 2.22-1.61 3.55-2.09 4.87 1.76 6.64 8.31 8.86 5.51 1.77 7.35 3.99 8.16 11.52L48 48H0z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/visible.svg b/comm/mail/themes/shared/mail/icons/visible.svg
new file mode 100644
index 0000000000..e774139205
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/visible.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M15.955 7.7A8.325 8.325 0 0 0 8 2 8.325 8.325 0 0 0 .045 7.7a1 1 0 0 0 0 .594A8.325 8.325 0 0 0 8 14a8.325 8.325 0 0 0 7.955-5.7 1 1 0 0 0 0-.6zM8.5 5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zM8 12a6.331 6.331 0 0 1-5.943-4 6.061 6.061 0 0 1 2.5-3A3.955 3.955 0 0 0 4 7a4 4 0 0 0 8 0 3.955 3.955 0 0 0-.555-2 6.061 6.061 0 0 1 2.5 3A6.331 6.331 0 0 1 8 12z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/voice.png b/comm/mail/themes/shared/mail/icons/voice.png
new file mode 100644
index 0000000000..43cc90eea0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/voice.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/icons/waiting.svg b/comm/mail/themes/shared/mail/icons/waiting.svg
new file mode 100644
index 0000000000..284f50aeef
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/waiting.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="#f0cd2f" d="M8 0C3.5 0 0 3.5 0 8s3.5 8 8 8 8-3.25 8-8c0-4.5-3.5-8-8-8zM7 3c0-1.5 2-1.5 2 0v5c0 1.5-2 1.5-2 0zm3.5 4H13c1.5 0 1.5 2 0 2h-2.5C9 9 9 7 10.5 7z"/>
+</svg> \ No newline at end of file
diff --git a/comm/mail/themes/shared/mail/icons/warning-12.svg b/comm/mail/themes/shared/mail/icons/warning-12.svg
new file mode 100644
index 0000000000..c300212835
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/warning-12.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 12 12" width="12" height="12" fill-opacity="context-fill-opacity">
+ <path fill="context-stroke" d="M5.87 0C4.94 0 4.14.57 3.6 1.43L.3 7.86c-.4.85-.4 1.85 0 2.71.4.86 1.32 1.43 2.25 1.43h6.9c.93 0 1.72-.57 2.25-1.43.4-.86.4-1.86 0-2.71L8.12 1.43C7.6.57 6.8 0 5.87 0z"/>
+ <path fill="context-fill" d="M5.95 1.5c-.41 0-.81.21-1.01.64L1.49 8.57c-.4.86.13 1.86 1.06 1.86h6.9c.93 0 1.46-1 1.06-1.86L7.06 2.14c-.27-.43-.7-.64-1.1-.64zM5 3h2v3H5zm1 4c1.33 0 1.33 2 0 2S4.67 7 6 7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/icons/zoomout.svg b/comm/mail/themes/shared/mail/icons/zoomout.svg
new file mode 100644
index 0000000000..d2020ee31d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/icons/zoomout.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M5.96-.04a6.02 6.02 0 103.51 10.92l4.82 4.83a1 1 0 001.42-1.42l-4.83-4.82a6.02 6.02 0 00-4.93-9.5zM6.04 2A4 4 0 0110 6a4 4 0 01-4 4 4 4 0 11.05-8zM3 5v2h6V5H3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/illustrations/accounts.svg b/comm/mail/themes/shared/mail/illustrations/accounts.svg
new file mode 100644
index 0000000000..accbd3b979
--- /dev/null
+++ b/comm/mail/themes/shared/mail/illustrations/accounts.svg
@@ -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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="209" viewBox="0 0 300 209">
+ <defs>
+ <linearGradient id="lockwise-a" x1="70.127%" x2="70.127%" y1="96.836%" y2="-15.705%">
+ <stop offset="0%" stop-color="#CDCDD4" stop-opacity="0"/>
+ <stop offset="58%" stop-color="#CDCDD4" stop-opacity=".02"/>
+ <stop offset="77%" stop-color="#CDCDD4" stop-opacity=".08"/>
+ <stop offset="96%" stop-color="#CDCDD4" stop-opacity=".18"/>
+ <stop offset="100%" stop-color="#CDCDD4" stop-opacity=".2"/>
+ </linearGradient>
+ <radialGradient id="lockwise-b" cx="126.893%" cy="255.606%" r="325.015%" fx="126.893%" fy="255.606%" gradientTransform="matrix(.5786 0 0 1 .535 0)">
+ <stop offset="26%" stop-color="#CDCDD4" stop-opacity="0"/>
+ <stop offset="40%" stop-color="#CDCDD4" stop-opacity=".02"/>
+ <stop offset="55%" stop-color="#CDCDD4" stop-opacity=".08"/>
+ <stop offset="69%" stop-color="#CDCDD4" stop-opacity=".18"/>
+ <stop offset="72%" stop-color="#CDCDD4" stop-opacity=".2"/>
+ </radialGradient>
+ <radialGradient id="lockwise-c" cx="-55.742%" cy="260.74%" r="317.936%" fx="-55.742%" fy="260.74%" gradientTransform="matrix(.57221 0 0 1 -.238 0)">
+ <stop offset="27%" stop-color="#CDCDD4" stop-opacity="0"/>
+ <stop offset="46%" stop-color="#CDCDD4" stop-opacity=".02"/>
+ <stop offset="66%" stop-color="#CDCDD4" stop-opacity=".08"/>
+ <stop offset="86%" stop-color="#CDCDD4" stop-opacity=".18"/>
+ <stop offset="90%" stop-color="#CDCDD4" stop-opacity=".2"/>
+ </radialGradient>
+ <linearGradient id="lockwise-d" x1="7.536%" x2="68.583%" y1="65.726%" y2="43.12%">
+ <stop offset="0%" stop-color="#B833E1"/>
+ <stop offset="91%" stop-color="#FF4F5E"/>
+ </linearGradient>
+ <linearGradient id="lockwise-e" x1="-68.282%" x2="131.387%" y1="139.888%" y2="-11.036%">
+ <stop offset="28%" stop-color="#7542E5"/>
+ <stop offset="42%" stop-color="#824DEB"/>
+ <stop offset="79%" stop-color="#A067FA"/>
+ <stop offset="100%" stop-color="#AB71FF"/>
+ </linearGradient>
+ <linearGradient id="lockwise-f" x1="-43.795%" x2="124.252%" y1="84.765%" y2="22.502%">
+ <stop offset="40%" stop-color="#0090ED"/>
+ <stop offset="56%" stop-color="#2A88F1"/>
+ <stop offset="92%" stop-color="#9275FC"/>
+ <stop offset="100%" stop-color="#AB71FF"/>
+ </linearGradient>
+ <linearGradient id="lockwise-g" x1="-17.372%" x2="109.306%" y1="151.599%" y2="-39.422%">
+ <stop offset="43%" stop-color="#00B3F4"/>
+ <stop offset="61%" stop-color="#00BBF6"/>
+ <stop offset="89%" stop-color="#00D2FC"/>
+ <stop offset="100%" stop-color="#0DF"/>
+ </linearGradient>
+ </defs>
+ <circle cx="154.306" cy="109.889" r="107.444" fill="url(#lockwise-a)"/>
+ <path fill="url(#lockwise-b)" d="M85.5416667,80.8472222 C85.5416667,89.0277438 78.9304323,95.6701066 70.75,95.7083333 L11.2916667,95.7083333 C5.17541993,95.4943857 0.327726784,90.4741542 0.327726784,84.3541667 C0.327726784,78.2341791 5.17541993,73.2139476 11.2916667,73 C12.2285282,72.99817 13.1618599,73.1148364 14.0694444,73.3472222 C13.8641906,72.4823819 13.7570332,71.5971688 13.75,70.7083333 C13.7942051,66.3953789 16.2033401,62.4551064 20.0221021,60.4499661 C23.840864,58.4448259 28.4522835,58.6987739 32.0277778,61.1111111 C34.528326,51.5379538 43.7475818,45.3095965 53.5622385,46.562831 C63.3768953,47.8160655 70.7352578,56.1612205 70.75,66.0555556 C78.9065601,66.0860192 85.511203,72.6906621 85.5416667,80.8472222 L85.5416667,80.8472222 Z"/>
+ <path fill="url(#lockwise-c)" d="M281.944444,40.1527778 C280.56777,40.1478647 279.195927,40.3158455 277.861111,40.6527778 C279.459938,33.5659175 276.55372,26.223224 270.537855,22.1502383 C264.521991,18.0772526 256.625128,18.1058193 250.638889,22.2222222 C246.813608,7.86060669 232.951542,-1.45416361 218.209226,0.430746832 C203.46691,2.31565727 192.394381,14.8184936 192.305556,29.6805556 C184.346472,29.6805554 176.991976,33.9266759 173.012434,40.8194443 C169.032892,47.7122128 169.032892,56.2044538 173.012434,63.0972223 C176.991976,69.9899908 184.346472,74.2361112 192.305556,74.2361111 L281.944444,74.2361111 C288.136326,74.4130762 293.935121,71.2108318 297.083278,65.8760561 C300.231434,60.5412805 300.231434,53.9170528 297.083278,48.5822772 C293.935121,43.2475016 288.136326,40.0452571 281.944444,40.2222222 L281.944444,40.1527778 Z"/>
+ <path fill="url(#lockwise-d)" d="M104.458333,73.4861111 L45.9444444,73.4861111 C40.4279303,73.5013713 35.9597046,77.9695969 35.9444444,83.4861111 L35.9444444,92.5694444 C35.9597046,98.0859586 40.4279303,102.554184 45.9444444,102.569444 L104.458333,102.569444 C109.974848,102.554184 114.443073,98.0859586 114.458333,92.5694444 L114.458333,83.4861111 C114.443073,77.9695969 109.974848,73.5013713 104.458333,73.4861111 L104.458333,73.4861111 Z M62.9166667,89.3333333 C63.0662911,89.4003249 63.1817989,89.5258768 63.2361111,89.6805556 C63.3039323,89.8258065 63.3039323,89.9936379 63.2361111,90.1388889 L62,92.2361111 C61.9188249,92.3739635 61.7821642,92.4701321 61.625,92.5 C61.4654217,92.5408987 61.2958989,92.5049393 61.1666667,92.4027778 L58.5138889,90.3472222 L58.9305556,93.75 C58.955548,93.9074535 58.9039566,94.0673867 58.7916667,94.1805556 C58.6993523,94.3464174 58.5231234,94.4478826 58.3333333,94.4445291 L55.9027778,94.4445291 C55.737875,94.4450288 55.5808164,94.3740991 55.4722222,94.25 C55.3686393,94.1321904 55.3228314,93.9744078 55.3472222,93.8194444 L55.7638889,90.3888889 L52.9861111,92.4722222 C52.8568789,92.5743837 52.6873561,92.6103431 52.5277778,92.5694444 C52.3695719,92.5330645 52.2336813,92.4324047 52.1527778,92.2916667 L50.9861111,90.2083333 C50.9163117,90.0635278 50.9163117,89.8948055 50.9861111,89.75 C51.0357662,89.5970999 51.1463206,89.4714698 51.2916667,89.4027778 L54.4583333,88.0833333 L51.2916667,86.6944444 C51.1420422,86.6274529 51.0265345,86.501901 50.9722222,86.3472222 C50.9096885,86.2008434 50.9096885,86.0352677 50.9722222,85.8888889 L52.1805556,83.8333333 C52.2650018,83.6957958 52.399379,83.5962572 52.5555556,83.5555556 C52.7171068,83.5281479 52.8828231,83.5683215 53.0138889,83.6666667 L55.7916667,85.7361111 L55.375,82.3472222 C55.3537717,82.1881879 55.3989845,82.0274311 55.5,81.9027778 C55.6139657,81.7879807 55.7687969,81.7230515 55.9305556,81.7221814 L58.3333333,81.7221814 C58.4916013,81.7203046 58.6431729,81.7859857 58.75,81.9027778 C58.8608604,82.022453 58.9118807,82.1857182 58.8888889,82.3472222 L58.4722222,85.7361111 L61.25,83.6944444 C61.3809719,83.5960482 61.5486937,83.5604708 61.7083333,83.5972222 C61.870593,83.6244458 62.0100075,83.7277158 62.0833333,83.875 L63.2638889,85.9583333 C63.3336883,86.1031388 63.3336883,86.2718612 63.2638889,86.4166667 C63.2095767,86.5713454 63.0940689,86.6968974 62.9444444,86.7638889 L59.7222222,88.0277778 L62.9166667,89.3333333 Z M80.9722222,89.3333333 C81.1218467,89.4003249 81.2373544,89.5258768 81.2916667,89.6805556 C81.3542004,89.8269344 81.3542004,89.9925101 81.2916667,90.1388889 L80.0555556,92.2361111 C79.9713468,92.3772725 79.8289514,92.473898 79.6666667,92.5 C79.5114611,92.5385087 79.3471742,92.5025709 79.2222222,92.4027778 L76.5694444,90.3472222 L76.9861111,93.75 C77.0262601,93.9261394 76.9804471,94.1108808 76.8626515,94.2478525 C76.7448558,94.3848241 76.5690536,94.4577748 76.3888889,94.4444444 L74.0138889,94.4444444 C73.8489861,94.4450288 73.6919275,94.3740991 73.5833333,94.25 C73.4764505,94.133611 73.4257216,93.9763514 73.4444444,93.8194444 L73.875,90.2777778 L71.0972222,92.3611111 C70.96799,92.4632726 70.7984672,92.499232 70.6388889,92.4583333 C70.480683,92.4219533 70.3447924,92.3212936 70.2638889,92.1805556 L69.0833333,90.0972222 C69.0279356,89.9494663 69.0279356,89.7866448 69.0833333,89.6388889 C69.1376456,89.4842101 69.2531533,89.3586582 69.4027778,89.2916667 L72.5694444,87.9722222 L69.4027778,86.5833333 C69.2531533,86.5163418 69.1376456,86.3907899 69.0833333,86.2361111 C69.0207996,86.0897323 69.0207996,85.9241566 69.0833333,85.7777778 L70.2916667,83.7222222 C70.3761129,83.5846847 70.5104901,83.4851461 70.6666667,83.4444444 C70.8239003,83.4158311 70.9858407,83.4563162 71.1111111,83.5555556 L73.8888889,85.625 L73.4583333,82.2361111 C73.4409303,82.0751066 73.4912407,81.9141134 73.5972222,81.7916667 C73.7111879,81.6768696 73.8660191,81.6119404 74.0277778,81.6111111 L76.3888889,81.6111111 C76.5464642,81.6125465 76.6967556,81.6776728 76.8055556,81.7916667 C76.9164159,81.9113419 76.9674363,82.074607 76.9444444,82.2361111 L76.5277778,85.625 L79.3055556,83.5833333 C79.437033,83.4869248 79.6035278,83.4514952 79.7628672,83.4860188 C79.9222066,83.5205423 80.059106,83.6217073 80.1388889,83.7638889 L81.3194444,85.8472222 C81.3748422,85.9949781 81.3748422,86.1577996 81.3194444,86.3055556 C81.2651322,86.4602343 81.1496244,86.5857862 81,86.6527778 L77.8333333,87.9583333 L80.9722222,89.3333333 Z M99.0277778,89.3333333 C99.1738255,89.405199 99.2877552,89.5290357 99.3472222,89.6805556 C99.4097559,89.8269344 99.4097559,89.9925101 99.3472222,90.1388889 L98.0972222,92.2361111 C98.0223028,92.3795857 97.8825665,92.4779186 97.7222222,92.5 C97.5670167,92.5385087 97.4027298,92.5025709 97.2777778,92.4027778 L94.6111111,90.3472222 L95.0416667,93.75 C95.0603895,93.906907 95.0096606,94.0641666 94.9027778,94.1805556 C94.7973939,94.3010555 94.6461569,94.3716327 94.4861111,94.375 L92.1111111,94.375 C91.9510653,94.3716327 91.7998284,94.3010555 91.6944444,94.1805556 C91.5875616,94.0641666 91.5368327,93.906907 91.5555556,93.75 L91.9861111,90.2777778 L89.2083333,92.3611111 C89.0781988,92.4613349 88.9096163,92.4970948 88.75,92.4583333 C88.5917942,92.4219533 88.4559035,92.3212936 88.375,92.1805556 L87.1944444,90.0972222 C87.1319107,89.9508434 87.1319107,89.7852677 87.1944444,89.6388889 C87.2487567,89.4842101 87.3642644,89.3586582 87.5138889,89.2916667 L90.6666667,87.9722222 L87.5,86.5833333 C87.3574937,86.5109248 87.2481469,86.386667 87.1944444,86.2361111 C87.1266233,86.0908601 87.1266233,85.9230288 87.1944444,85.7777778 L88.4027778,83.7222222 C88.4849309,83.5826024 88.6202775,83.4823456 88.7777778,83.4444444 C88.9349481,83.417627 89.0961625,83.4579306 89.2222222,83.5555556 L92,85.625 L91.5694444,82.2361111 C91.5520414,82.0751066 91.6023518,81.9141134 91.7083333,81.7916667 C91.8180306,81.6789665 91.9677551,81.6140859 92.125,81.6111111 L94.5555556,81.6111111 C94.7128004,81.6140859 94.862525,81.6789665 94.9722222,81.7916667 C95.0782038,81.9141134 95.1285142,82.0751066 95.1111111,82.2361111 L94.6805556,85.625 L97.4583333,83.5833333 C97.5898108,83.4869248 97.7563056,83.4514952 97.915645,83.4860188 C98.0749844,83.5205423 98.2118837,83.6217073 98.2916667,83.7638889 L99.4583333,85.8472222 C99.5281327,85.9920277 99.5281327,86.1607501 99.4583333,86.3055556 C99.4086783,86.4584557 99.2981238,86.5840857 99.1527778,86.6527778 L95.9861111,87.9583333 L99.0277778,89.3333333 Z"/>
+ <path fill="#AB71FF" d="M214.25,43.875 L93.8888889,43.875 C88.9029849,43.875 84.8611111,47.9168738 84.8611111,52.9027778 L84.8611111,139.472222 C84.8611111,144.458126 88.9029849,148.5 93.8888889,148.5 L217.263889,148.5 C220.585268,148.5 223.277778,145.80749 223.277778,142.486111 L223.277778,52.9027778 C223.277778,47.9168738 219.235904,43.875 214.25,43.875 Z"/>
+ <path fill="url(#lockwise-e)" d="M213.777778,43.875 L93.4166667,43.875 C88.4307627,43.875 84.3888889,47.9168738 84.3888889,52.9027778 L84.3888889,139.472222 C84.3888889,141.866538 85.340027,144.162788 87.0330638,145.855825 C88.7261005,147.548862 91.0223511,148.5 93.4166667,148.5 L216.791667,148.5 C220.113046,148.5 222.805556,145.80749 222.805556,142.486111 L222.805556,52.9027778 C222.805556,50.5084622 221.854417,48.2122116 220.161381,46.5191749 C218.468344,44.8261381 216.172093,43.875 213.777778,43.875 Z M216.791667,55.9166667 L90.4027778,55.9166667 L90.4027778,52.9027778 C90.4104235,51.2436794 91.7575507,49.9027778 93.4166667,49.9027778 L213.777778,49.9027778 C215.436894,49.9027778 216.784021,51.2436794 216.791667,52.9027778 L216.791667,55.9166667 Z"/>
+ <rect width="69.153" height="23.403" x="64.889" y="143.014" fill="#F9F9FA" rx="5.4"/>
+ <path fill="url(#lockwise-f)" d="M129.166667,140.541667 L70.7083333,140.541667 C65.1918192,140.556927 60.7235935,145.025152 60.7083333,150.541667 L60.7083333,159.611111 C60.7235935,165.127625 65.1918192,169.595851 70.7083333,169.611111 L129.166667,169.611111 C134.683181,169.595851 139.151407,165.127625 139.166667,159.611111 L139.166667,150.541667 C139.151407,145.025152 134.683181,140.556927 129.166667,140.541667 L129.166667,140.541667 Z M87.6805556,156.388889 C87.8322323,156.452998 87.948731,156.579627 88,156.736111 C88.0625337,156.88249 88.0625337,157.048066 88,157.194444 L86.75,159.291667 C86.6750806,159.435141 86.5353443,159.533474 86.375,159.555556 C86.2178297,159.582373 86.0566153,159.542069 85.9305556,159.444444 L83.2638889,157.402778 L83.6944444,160.791667 C83.7136028,160.952838 83.6630753,161.114526 83.5555556,161.236111 C83.4501716,161.356611 83.2989347,161.427188 83.1388889,161.430556 L80.6527778,161.430556 C80.492732,161.427188 80.341495,161.356611 80.2361111,161.236111 C80.1285914,161.114526 80.0780639,160.952838 80.0972222,160.791667 L80.5277778,157.375 L77.75,159.444444 C77.6213033,159.547708 77.453364,159.58842 77.2916667,159.555556 C77.1354901,159.514854 77.0011129,159.415315 76.9166667,159.277778 L75.7361111,157.180556 C75.6735774,157.034177 75.6735774,156.868601 75.7361111,156.722222 C75.787538,156.569022 75.9046818,156.446785 76.0555556,156.388889 L79.2083333,155.069444 L76.0416667,153.680556 C75.8971578,153.617797 75.7860909,153.496633 75.7361111,153.347222 C75.6677688,153.197233 75.6677688,153.024989 75.7361111,152.875 L76.9444444,150.819444 C77.022707,150.678939 77.1607659,150.581786 77.3194444,150.555556 C77.4746659,150.524804 77.6356838,150.560026 77.7638889,150.652778 L80.5555556,152.777778 L80.125,149.402778 C80.1058416,149.241606 80.1563692,149.079918 80.2638889,148.958333 C80.3692728,148.837833 80.5205097,148.767256 80.6805556,148.763889 L83.1111111,148.763889 C83.2711569,148.767256 83.4223939,148.837833 83.5277778,148.958333 C83.6352975,149.079918 83.685825,149.241606 83.6666667,149.402778 L83.2083333,152.777778 L85.9861111,150.736111 C86.1164398,150.636214 86.2828376,150.595875 86.4444444,150.625 C86.600621,150.665702 86.7349982,150.76524 86.8194444,150.902778 L87.9861111,153 C88.0559105,153.144805 88.0559105,153.313528 87.9861111,153.458333 C87.939427,153.609659 87.8272587,153.732025 87.6805556,153.791667 L84.5138889,155.180556 L87.6805556,156.388889 Z M105.736111,156.388889 C105.893939,156.443955 106.01381,156.574249 106.055556,156.736111 C106.125355,156.880917 106.125355,157.049639 106.055556,157.194444 L104.805556,159.291667 C104.72438,159.429519 104.58772,159.525688 104.430556,159.555556 C104.273385,159.582373 104.112171,159.542069 103.986111,159.444444 L101.388889,157.402778 L101.819444,160.791667 C101.830706,160.952012 101.781113,161.11071 101.680556,161.236111 C101.571961,161.36021 101.414903,161.43114 101.25,161.430559 L98.7638889,161.430559 C98.60245,161.427825 98.4497595,161.35669 98.3438116,161.23485 C98.2378637,161.11301 98.1886198,160.951921 98.2083333,160.791667 L98.625,157.375 L95.8472222,159.444444 C95.7191348,159.546637 95.552467,159.587072 95.3917905,159.554937 C95.2311139,159.522801 95.0928187,159.421373 95.0138889,159.277778 L93.8333333,157.180556 C93.763534,157.03575 93.763534,156.867028 93.8333333,156.722222 C93.8847603,156.569022 94.001904,156.446785 94.1527778,156.388889 L97.3055556,155.069444 L94.1388889,153.680556 C93.9921857,153.620914 93.8800175,153.498548 93.8333333,153.347222 C93.764991,153.197233 93.764991,153.024989 93.8333333,152.875 L95.0416667,150.819444 C95.1258754,150.678283 95.2682709,150.581658 95.4305556,150.555556 C95.585777,150.524804 95.7467949,150.560026 95.875,150.652778 L98.6111111,152.777778 L98.1944444,149.402778 C98.1747309,149.242524 98.2239748,149.081435 98.3299227,148.959595 C98.4358706,148.837755 98.5885611,148.766619 98.75,148.763885 L101.166667,148.763885 C101.331569,148.763305 101.488628,148.834234 101.597222,148.958333 C101.69778,149.083735 101.747373,149.242433 101.736111,149.402778 L101.388889,152.777778 L104.166667,150.736111 C104.292726,150.638486 104.453941,150.598183 104.611111,150.625 C104.770826,150.658057 104.907946,150.759628 104.986111,150.902778 L106.166667,153 C106.236466,153.144805 106.236466,153.313528 106.166667,153.458333 C106.11524,153.611533 105.998096,153.73377 105.847222,153.791667 L102.694444,155.180556 L105.736111,156.388889 Z M123.791667,156.388889 C123.942901,156.450423 124.05941,156.57537 124.110239,156.73053 C124.161067,156.885691 124.141068,157.055355 124.055556,157.194444 L122.819444,159.291667 C122.738269,159.429519 122.601609,159.525688 122.444444,159.555556 C122.282893,159.582963 122.117177,159.54279 121.986111,159.444444 L119.444444,157.402778 L119.861111,160.791667 C119.885571,160.952818 119.838027,161.116581 119.731072,161.239579 C119.624117,161.362578 119.468542,161.432401 119.305556,161.430591 L116.875,161.430591 C116.713561,161.427825 116.560871,161.35669 116.454923,161.23485 C116.348975,161.11301 116.299731,160.951921 116.319444,160.791667 L116.736111,157.375 L113.958333,159.444444 C113.83482,159.547201 113.671227,159.588099 113.513889,159.555556 C113.354174,159.522498 113.217054,159.420928 113.138889,159.277778 L111.958333,157.180556 C111.888534,157.03575 111.888534,156.867028 111.958333,156.722222 C112.008313,156.572812 112.11938,156.451648 112.263889,156.388889 L115.430556,155.069444 L112.263889,153.680556 C112.113412,153.626463 111.999162,153.501826 111.958333,153.347222 C111.889991,153.197233 111.889991,153.024989 111.958333,152.875 L113.166667,150.819444 C113.247842,150.681592 113.384502,150.585423 113.541667,150.555556 C113.701318,150.522488 113.867522,150.557743 114,150.652778 L116.777778,152.722222 L116.361111,149.347222 C116.336651,149.186071 116.384195,149.022308 116.49115,148.89931 C116.598106,148.776311 116.75368,148.706488 116.916667,148.708298 L119.333333,148.708298 C119.49632,148.706488 119.651894,148.776311 119.75885,148.89931 C119.865805,149.022308 119.913349,149.186071 119.888889,149.347222 L119.444444,152.777778 L122.222222,150.736111 C122.353979,150.63924 122.519065,150.599219 122.680556,150.625 C122.838056,150.662901 122.973402,150.763158 123.055556,150.902778 L124.236111,153 C124.30591,153.144805 124.30591,153.313528 124.236111,153.458333 C124.184684,153.611533 124.06754,153.73377 123.916667,153.791667 L120.763889,155.180556 L123.791667,156.388889 Z"/>
+ <path fill="#FFF" d="M51.64,20.7666667 C46.0864858,14.4998219 40.1601781,8.57351416 33.8933333,3.02 C30.082543,-0.123985623 24.577457,-0.123985623 20.7666667,3.02 C14.4996228,8.57566521 8.573311,14.5042033 3.02,20.7733333 C-0.123985623,24.5841237 -0.123985623,30.0892096 3.02,33.9 C8.57566521,40.1670439 14.5042033,46.0933557 20.7733333,51.6466667 C22.6030951,53.1992337 24.9339084,54.0353892 27.3333333,54 C29.7438562,54.0326548 32.0843096,53.1893335 33.92,51.6266667 C37.1866667,48.68 40.1666667,45.8333333 43.04,42.9133333 C44.0581483,41.7108072 43.9586152,39.9221388 42.8133333,38.84 L34,30.62 C36.274261,28.5981416 37.5179816,25.6602189 37.3866667,22.62 C37.1392566,17.3965683 33.0052958,13.1937081 27.7866667,12.86 C24.9872333,12.6967419 22.2453042,13.7008286 20.2133333,15.6333333 C18.1443724,17.5899155 16.9888257,20.3231815 17.026824,23.1705258 C17.0648224,26.01787 18.2928965,28.7193262 20.4133333,30.62 L17.2266667,33.5066667 C16.2045489,34.4642307 16.1359367,36.0633963 17.0722806,37.1049882 C18.0086245,38.1465801 19.6060411,38.2480612 20.6666667,37.3333333 L24.1933333,34.1333333 L24.2866667,34.0466667 C25.2417489,33.0901939 25.7520065,31.7770665 25.6933333,30.4266667 C25.6438002,29.0668006 25.03025,27.7889736 24,26.9 C22.3771375,25.5322057 21.7818989,23.2961006 22.5099768,21.3024992 C23.2380547,19.3088978 25.1342756,17.982692 27.2566667,17.982692 C29.3790578,17.982692 31.2752786,19.3088978 32.0033565,21.3024992 C32.7314345,23.2961006 32.1361959,25.5322057 30.5133333,26.9 C29.4615006,27.7865292 28.8282135,29.0724403 28.7666667,30.4466667 C28.7144664,31.7900781 29.2241686,33.0945293 30.1733333,34.0466667 L30.2333333,34.1066667 L37.6333333,41.04 C35.3666667,43.2866667 33.0066667,45.5133333 30.4933333,47.7933333 C28.6241984,49.1933351 26.0558016,49.1933351 24.1866667,47.7933333 C18.0681361,42.3666642 12.2800024,36.5785306 6.85333333,30.46 C5.45774974,28.5893962 5.45774974,26.0239371 6.85333333,24.1533333 C12.2766966,18.03169 18.0650233,12.2433633 24.1866667,6.82 C26.0544402,5.42332443 28.6188932,5.42332443 30.4866667,6.82 C36.60831,12.2433633 42.3966367,18.03169 47.82,24.1533333 C49.2166756,26.0211068 49.2166756,28.5855598 47.82,30.4533333 C46.9333333,31.4533333 46.0466667,32.4533333 45.1533333,33.3666667 C44.1923579,34.4104848 44.2595152,36.0356912 45.3033333,36.9966667 C46.3471515,37.9576421 47.9723579,37.8904848 48.9333333,36.8466667 C49.8133333,35.8933333 50.72,34.8933333 51.6,33.8666667 C54.7409838,30.0702181 54.7577411,24.5822259 51.64,20.7666667 Z" transform="translate(126.347 68.5)"/>
+ <rect width="63.083" height="95.125" x="186.611" y="79.222" fill="url(#lockwise-g)" rx="5.4"/>
+ <rect width="11.75" height="5.875" x="212.181" y="158.847" fill="#0DF"/>
+ <path fill="#FFF" d="M33.3508333,13.4118056 C29.7641888,9.3644683 25.9367817,5.53706123 21.8894444,1.95041667 C19.428309,-0.0800740485 15.872941,-0.0800740485 13.4118056,1.95041667 C9.36433971,5.53845045 5.53693002,9.36729793 1.95041667,13.4161111 C-0.0800740485,15.8772466 -0.0800740485,19.4326145 1.95041667,21.89375 C5.53845045,25.9412158 9.36729793,29.7686255 13.4161111,33.3551389 C14.5978323,34.3578384 16.1031492,34.8978556 17.6527778,34.875 C19.2095738,34.8960895 20.7211166,34.3514445 21.9066667,33.3422222 C24.0163889,31.4391667 25.9409722,29.6006944 27.7966667,27.7148611 C28.4542208,26.9382297 28.389939,25.783048 27.6502778,25.0841667 L21.9583333,19.7754167 C23.4271269,18.4696331 24.2303631,16.5722247 24.1455556,14.60875 C23.9857699,11.2352837 21.3159202,8.52093647 17.9455556,8.30541667 C16.1375882,8.19997912 14.366759,8.84845178 13.0544444,10.0965278 C11.7182405,11.3601537 10.9719499,13.1253881 10.9964905,14.9642979 C11.0210311,16.8032077 11.8141623,18.5478982 13.1836111,19.7754167 L11.1255556,21.6397222 C10.4654378,22.258149 10.4211258,23.2909435 11.0258479,23.9636382 C11.63057,24.636333 12.6622349,24.7018729 13.3472222,24.1111111 L15.6248611,22.0444444 L15.6851389,21.9884722 C16.3019629,21.3707502 16.6315042,20.5226888 16.5936111,19.6505556 C16.561621,18.7723087 16.1653698,17.9470455 15.5,17.3729167 C14.4519013,16.4895495 14.0674763,15.0453983 14.5376933,13.7578641 C15.0079103,12.4703298 16.232553,11.6138219 17.6032639,11.6138219 C18.9739748,11.6138219 20.1986174,12.4703298 20.6688344,13.7578641 C21.1390514,15.0453983 20.7546265,16.4895495 19.7065278,17.3729167 C19.0272191,17.9454668 18.6182212,18.775951 18.5784722,19.6634722 C18.5447595,20.5310921 18.8739422,21.3735502 19.4869444,21.9884722 L19.5256944,22.0272222 L24.3048611,26.505 C22.8409722,27.9559722 21.3168056,29.3940278 19.6936111,30.8665278 C18.4864615,31.7706956 16.8277052,31.7706956 15.6205556,30.8665278 C11.6690046,27.361804 7.93083491,23.6236343 4.42611111,19.6720833 C3.52479671,18.463985 3.52479671,16.8071261 4.42611111,15.5990278 C7.9286999,11.6454665 11.6669942,7.90717212 15.6205556,4.40458333 C16.8268259,3.5025637 18.4830352,3.5025637 19.6893056,4.40458333 C23.6428669,7.90717212 27.3811612,11.6454665 30.88375,15.5990278 C31.7857696,16.8052982 31.7857696,18.4615074 30.88375,19.6677778 C30.3111111,20.3136111 29.7384722,20.9594444 29.1615278,21.5493056 C28.5408978,22.2234381 28.5842702,23.2730506 29.2584028,23.8936806 C29.9325354,24.5143105 30.9821478,24.4709381 31.6027778,23.7968056 C32.1711111,23.1811111 32.7566667,22.5352778 33.325,21.8722222 C35.3535521,19.4203492 35.3643744,15.8760209 33.3508333,13.4118056 Z" transform="translate(199.472 103.222)"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/illustrations/connection-error.svg b/comm/mail/themes/shared/mail/illustrations/connection-error.svg
new file mode 100644
index 0000000000..99c8d0082b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/illustrations/connection-error.svg
@@ -0,0 +1,51 @@
+<!-- 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 version="1.1" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <linearGradient id="a" x1="-300.02" x2="547.14" y1="-272.74" y2="574.42" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#ccfbff" offset="0"/>
+ <stop stop-color="#c9e4ff" offset="1"/>
+ </linearGradient>
+ <linearGradient id="b" x1="-18.672" x2="279.8" y1="23.78" y2="322.26" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#00c8d7" offset="0"/>
+ <stop stop-color="#0a84ff" offset="1"/>
+ </linearGradient>
+ </defs>
+ <path d="M224.245 144.067h-10.733c.136.343.274.674.41 1h10.323a.5.5 0 0 0 0-1zm2.454-11.821a.5.5 0 0 0-.5-.5h-20.26c.373.357.727.688 1.065 1h19.2a.5.5 0 0 0 .496-.5zm8.546 11.821h-3a.5.5 0 1 0 0 1h3a.5.5 0 0 0 0-1zm5 0h-1a.5.5 0 1 0 0 1h1a.5.5 0 0 0 0-1zm-3.3-6.66h-25.78a12.767 12.767 0 0 1 .862 2h24.918a1 1 0 0 0 0-2zm20.422 6.66h-8.122a.5.5 0 1 0 0 1h8.122a.5.5 0 0 0 0-1z" fill="#eaeaee" fill-opacity=".4"/>
+ <path d="M269.53 87.757h-24.236c-2.108-3.9-7.559-12.718-14.4-14.023-8.952-1.707-10.737 7.217-10.737 7.217s-5.949-15.468-21-13.419c-16.878 2.3-8.928 20.065-8.928 20.065h-25.408l8.181.159h-8.184a1 1 0 0 0 0 2H269.53a1 1 0 0 0 0-2z" fill="#fff" fill-opacity=".70196"/>
+ <path d="M118.373 63.908h-13.69c-1.129-2.112-4.19-7.156-8.057-7.894-4.978-.949-5.971 4.013-5.971 4.013s-3.309-8.6-11.68-7.462c-9.386 1.278-4.965 11.158-4.965 11.158H59.88l9.471.185h-9.212a1 1 0 0 0 0 2h58.233a1 1 0 1 0 0-2z" fill="#fff" fill-opacity=".70196"/>
+ <ellipse cx="143.57" cy="245.47" rx="55.042" ry="8.362" fill="#fefeff" fill-opacity=".47843"/>
+ <path d="M102.31 121.507H60.818a1 1 0 0 0 0 2h41.492a1 1 0 1 0 0-2zM70.336 117.6H82.1a.5.5 0 0 0 0-1H70.336a.5.5 0 0 0 0 1z" fill="#eaeaee" fill-opacity=".4"/>
+ <path d="M111.457 174.8h-78.3a1 1 0 0 0 0 2h78.3a1 1 0 1 0 0-2zm-26.742-3.793h1a.5.5 0 0 0 0-1h-1a.5.5 0 0 0 0 1zm10 0h3.1a.5.5 0 0 0 0-1h-3.1a.5.5 0 0 0 0 1zm-17 0h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zm-20 0h12a.5.5 0 0 0 0-1h-12a.5.5 0 0 0 0 1z" fill="#eaeaee" fill-opacity=".4"/>
+ <path d="M206.885 62.973l.045-.1c-.058.027-.063.059-.045.1z" fill="#fff"/>
+ <g fill="#eaeaee">
+ <path d="M77.937 214.941H39.95a1 1 0 1 1 0-2h37.987a1 1 0 1 1 0 2z" fill-opacity=".4"/>
+ <path d="m258.93 214.94h-61.813a1 1 0 0 1 0-2h61.813a1 1 0 0 1 0 2z" fill-opacity=".4"/>
+ <path d="M265.745 85.333h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1zm-11 0h-8.07a.5.5 0 0 1-.447-.277c-.007-.014-.724-1.425-1.979-3.342a.5.5 0 1 1 .837-.548c.393.6 1.444 2.293 1.888 3.167h7.772a.5.5 0 0 1 0 1zm-66.489-.712h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1zm-11 0h-12a.5.5 0 0 1 0-1h12a.5.5 0 0 1 0 1zM190.1 83.13a.5.5 0 0 1-.474-.339c-.1-.29-.2-.615-.31-.971a.5.5 0 1 1 .958-.287c.1.343.2.657.3.937a.5.5 0 0 1-.474.661zm30.5-5.156a.5.5 0 0 1-.467-.32 23.7 23.7 0 0 0-2.182-4.039.5.5 0 0 1 .834-.552 27.291 27.291 0 0 1 1.719 2.982 10.522 10.522 0 0 1 3.679-5.015.5.5 0 0 1 .571.82 10.181 10.181 0 0 0-3.665 5.721.5.5 0 0 1-.448.4zm18.345-2.964a.5.5 0 0 1-.339-.132q-.361-.333-.735-.651a.5.5 0 0 1 .647-.762q.39.331.765.678a.5.5 0 0 1-.339.868zm-49.923-1.725a.47.47 0 0 1-.09-.008.5.5 0 0 1-.4-.581c.792-4.351 3.544-7.229 8.18-8.556a.5.5 0 0 1 .275.962c-4.24 1.212-6.753 3.828-7.472 7.773a.5.5 0 0 1-.499.411zm45.893-1.218a.5.5 0 0 1-.237-.06 12.545 12.545 0 0 0-2.666-1.081.5.5 0 1 1 .261-.966 13.559 13.559 0 0 1 2.88 1.167.5.5 0 0 1-.238.94zM212.146 67.4a.5.5 0 0 1-.28-.086q-.4-.27-.82-.524a.5.5 0 1 1 .516-.856q.444.267.865.552a.5.5 0 0 1-.281.914zm-4.47-2.2a.5.5 0 0 1-.154-.024 16.724 16.724 0 0 0-2.832-.647.5.5 0 0 1 .137-.99 17.6 17.6 0 0 1 3 .686.5.5 0 0 1-.154.976z" fill-opacity=".43529"/>
+ <path d="M72.315 62.052h-12a.5.5 0 0 1 0-1h12a.5.5 0 0 1 0 1zm45.582-.184h-1.8a.5.5 0 0 1 0-1h1.8a.5.5 0 0 1 0 1zm-10.8 0h-1a.5.5 0 0 1 0-1h1a.5.5 0 0 1 0 1zm-3.491-2.881a.5.5 0 0 1-.39-.186 18.484 18.484 0 0 0-2-2.129.5.5 0 0 1 .668-.744A19.433 19.433 0 0 1 104 58.174a.5.5 0 0 1-.389.814zm-12.521-.631a.5.5 0 0 1-.466-.319 13.8 13.8 0 0 0-3.857-5.165.5.5 0 0 1 .623-.782 14.47 14.47 0 0 1 3.624 4.438A5.414 5.414 0 0 1 94.12 53.5a.5.5 0 1 1 .326.945 4.994 4.994 0 0 0-2.867 3.51.5.5 0 0 1-.49.401zm-17.637-2.037h-.051a.5.5 0 0 1-.447-.548 6.454 6.454 0 0 1 1.057-3.036.5.5 0 1 1 .824.566 5.46 5.46 0 0 0-.886 2.571.5.5 0 0 1-.497.447zm4.193-5.039a.5.5 0 0 1-.151-.977 10.27 10.27 0 0 1 1.017-.264.5.5 0 0 1 .2.979 9.033 9.033 0 0 0-.917.237.5.5 0 0 1-.148.025z" fill-opacity=".43529"/>
+ </g>
+ <path d="M216.326 144.72c-1.079-4.2-3.73-8.616-7.88-13.117a84.471 84.471 0 0 0-8.921-9.988 5.558 5.558 0 0 0-2.261-1.3c-7.177-6.885-21.972-19.819-32.5-20.546a27.625 27.625 0 0 0-1.889-.063 47.155 47.155 0 0 0-15.147 2.913l-5.114-1.4a5 5 0 0 0-6.142 3.5l-.969 3.545a68.668 68.668 0 0 0-7.844 5.3l-2.052-.048h-.116a5 5 0 0 0-5 4.884l-.042 1.829a45.575 45.575 0 0 0-5.648 7.656 5 5 0 0 0-2.515 5.326c-1.882 5.474-2.848 13.743-2.875 24.633a100.416 100.416 0 0 1-1.646 7.791l-4.279 2.594a6.312 6.312 0 0 0-2.125 8.657l1.516 2.5q-.375.778-.761 1.518l-4.037 1.24a6.312 6.312 0 0 0-4.176 7.877l.6 1.953q-.516.587-1.038 1.143l-1.616-.127a5.926 5.926 0 0 0-.5-.02 6.334 6.334 0 0 0-6.277 5.8l-.115 1.456c-2.741 2.043-5.415 4.547-5.415 9.117 0 6.528 10.272 10.568 13.648 11.9a68.955 68.955 0 0 0 24.224 4.781q.566 0 1.127-.012c.607 2.411 1.208 4.468 1.8 6.144a54.956 54.956 0 0 0 4.745 10.264c1.748 2.839 4.22 6.1 7.845 6.1a5.673 5.673 0 0 0 3.572-1.268c1.746-1.421 2.429-3.072 2.482-10.087a86.865 86.865 0 0 0 16.862 1.533 80.987 80.987 0 0 0 14.53-1.463c-.215 6.82.472 8.55 2.275 10.017a5.672 5.672 0 0 0 3.571 1.268c5.645 0 9.953-8.9 12.573-16.361a52.867 52.867 0 0 0 1.069-3.467c1.432-3.724 4.539-13.353 4.892-27.476.161-6.428 0-12.95-.488-19.419a7.56 7.56 0 0 0 1.131-4.949c6.461-1.2 10.808-3.919 15.1-9.34 3.4-4.308 8-14.308 5.826-22.788z" fill="#fff" fill-opacity=".80392"/>
+ <path d="M211.476 145.947c-1.092-4.252-4.261-8.341-6.9-11.168a78.846 78.846 0 0 0-8.482-9.533.649.649 0 0 0-.907.018c-10.173-10.009-23.029-19.987-30.783-20.521-4.534-.313-10.392.808-16.48 3.1l-6.645-1.817-1.521 5.561a63.823 63.823 0 0 0-10.4 7l-3.884-.09-.087 3.744a40.534 40.534 0 0 0-7.123 9.675l-1.083.338.36 1.153c-2.227 5.146-3.144 14.255-3.147 24.842a104.047 104.047 0 0 1-2.33 10.6l-6 3.638a1.452 1.452 0 0 0-.685 1.835l3.021 4.978a54.736 54.736 0 0 1-2.883 5.777l-5.979 1.836a1.305 1.305 0 0 0-.864 1.63l1.394 4.541a51.394 51.394 0 0 1-4.691 5.189l-3.93-.31a1.3 1.3 0 0 0-1.4 1.2l-.3 3.752c-3.258 2.357-5.218 3.541-5.218 6.422 0 4.144 20.8 13.416 37.9 11.39a88.878 88.878 0 0 0 2.615 9.761c2.852 8.125 6.861 14.032 8.287 12.871.675-.549.711-6.554.562-12.73.446.174 8.025 3.054 21.939 3.054a88.332 88.332 0 0 0 19.876-2.718c-.4 5.844-.626 11.816.085 12.394 1.426 1.161 5.418-4.745 8.269-12.871.357-1.018.7-2.159 1.031-3.377 1.276-3.253 4.307-12.338 4.649-26.035a191.885 191.885 0 0 0-.651-21.1 2.592 2.592 0 0 0-.475-4.743 128.961 128.961 0 0 0-.36-2.871 28.465 28.465 0 0 0 4.578-.19c6.147-.82 9.835-2.869 13.728-7.786 2.797-3.536 6.551-12.06 4.914-18.439z" fill="url(#a)"/>
+ <path d="M193.99 124.155c.033.013.067.031.1.045-9.986-9.641-22.2-18.942-29.683-19.457-4.534-.313-10.392.808-16.48 3.1l-6.645-1.817-1.521 5.561a63.823 63.823 0 0 0-10.4 7l-3.884-.09-.087 3.744a40.534 40.534 0 0 0-7.123 9.675l-1.083.338.36 1.153c-2.227 5.146-3.144 14.255-3.147 24.842a109.944 109.944 0 0 1-.921 4.824c.168-.72.327-1.43.475-2.127.061 3.039.177 6.023.332 8.836-1.014 1.2-4.408 5.548-2.546 8.155a10.876 10.876 0 0 0 3.445 2.858c.069.589.138 1.155.209 1.681 1.015 7.544 4.11 29 6.408 38.3.207-.021.417-.036.623-.061a88.878 88.878 0 0 0 2.615 9.761c2.852 8.125 6.861 14.032 8.287 12.871.675-.549.711-6.554.562-12.73.038.015.145.054.283.1V222.3a46 46 0 0 0 27.634 3.451c15.651-3.043 21.467-14.705 22.172-22.607 1.194-13.372-1.415-19.893-1.415-19.893l6.558-2.852c-.011-.135-.022-.284-.034-.417a2.592 2.592 0 0 0-.475-4.743l-11.158 4.316s-.109-1.739-2.5-4.348c-3.174-3.462-10.521-6.355-11.428-6.7a84.091 84.091 0 0 0 24.884 5.065l-.109-.839c-10.706-.609-24.216-5.348-24.216-5.348s23.783 4.316 33.476-.761c6.847-3.587 11.086-13.369 9.456-21.846-1.731-9.023-13.024-20.623-13.024-20.623z" fill="#f9f9fa"/>
+ <path d="M149.523 146.54l-5.537-1.957a1 1 0 0 0-1.219 1.406l1.99 3.81a1 1 0 0 0 1.349.423l3.547-1.853a1 1 0 0 0-.13-1.829z" fill="#fff"/>
+ <g fill="url(#b)">
+ <path d="M268.754 200.7h-.768c-17.152 0-33.29 0-42.937.322-8.333.277-16.774 1.994-16.787 4.736-.008 1.811 4.646 3.3 13.565 5.982 7.387 2.225 17.5 5.271 17.736 7.75a2.9 2.9 0 0 1-1.2 2.046c-2.984 2.772-11.281 5.909-19.089 4.588a25.1 25.1 0 0 1-12.468-6.4c-4.383-3.888-6.191-7.86-7.786-11.364-.619-1.359-1.2-2.643-1.9-3.837a22.145 22.145 0 0 0-5.357-6.126 194.068 194.068 0 0 0-.6-17.308 4.6 4.6 0 0 0-.2-6.523c.75-.036 1.463-.1 2.13-.188 6.749-.9 10.823-3.211 15.032-8.527 3.282-4.146 7.092-13.147 5.287-20.18-1.2-4.662-4.606-9.055-7.29-11.947a80.769 80.769 0 0 0-8.657-9.724 2.62 2.62 0 0 0-1.653-.713 133.306 133.306 0 0 0-15.775-13.264c-6.315-4.414-11.527-6.79-15.492-7.064a24.782 24.782 0 0 0-1.683-.056 45.119 45.119 0 0 0-15.027 3.055l-6.025-1.648a2 2 0 0 0-2.457 1.4l-1.3 4.745a65.633 65.633 0 0 0-9.391 6.332l-3.141-.073h-.047a2 2 0 0 0-2 1.953l-.069 2.965a42.572 42.572 0 0 0-6.522 8.837l-.3.093a2 2 0 0 0-1.312 2.506l.145.465c-1.993 5.12-3.01 13.443-3.023 24.766a102.594 102.594 0 0 1-2.05 9.47L105.03 171a3.309 3.309 0 0 0-1.114 4.538l2.345 3.87a56.333 56.333 0 0 1-2.1 4.209l-5.216 1.6a3.309 3.309 0 0 0-2.189 4.129l1.082 3.524a49.795 49.795 0 0 1-3.217 3.554l-3.023-.238a3.177 3.177 0 0 0-.267-.011 3.318 3.318 0 0 0-3.287 3.043l-.223 2.83c-2.965 2.13-5.3 3.833-5.3 7.5 0 2.65 3.4 5.1 6.95 6.946a89.372 89.372 0 0 0-1.57 3.365c-1.866 4.156-3.1 6.9-7.448 8.232-8.459 2.584-20.437.244-26.315-3.564-1.825-1.182-2.9-2.432-3.041-3.519-.264-2.111 5.188-4.187 9.167-5.7 3.919-1.492 6.152-2.394 6.47-3.56a1.66 1.66 0 0 0-.295-1.493C64.48 207.69 53.977 207.22 34 207.22a.5.5 0 0 0 0 1c12.5 0 29.627 0 31.645 2.642a.661.661 0 0 1 .125.623c-.2.732-3.339 1.928-5.861 2.888-5 1.9-10.165 3.871-9.8 6.761.176 1.405 1.382 2.869 3.489 4.234 4.275 2.77 11.581 4.841 18.68 4.841a29.2 29.2 0 0 0 8.471-1.16c4.784-1.462 6.161-4.531 8.068-8.778a89.007 89.007 0 0 1 1.552-3.326 54.676 54.676 0 0 0 3.9 1.709 65.842 65.842 0 0 0 23.126 4.572c1.177 0 2.336-.042 3.462-.126a80.823 80.823 0 0 0 2.292 8.265c.516 1.469 5.171 14.354 9.759 14.354a2.629 2.629 0 0 0 1.678-.595c.831-.677 1.5-1.223 1.353-11.536a76.69 76.69 0 0 0 19.885 2.308 86.392 86.392 0 0 0 17.71-2.167c-.585 10.114.149 10.711.989 11.395a2.628 2.628 0 0 0 1.677.595c4.572 0 9.226-12.885 9.742-14.354.347-.989.7-2.137 1.046-3.412 1.345-3.458 4.4-12.766 4.747-26.612.014-.552.01-1.108.019-1.662a20.857 20.857 0 0 1 4.507 5.34c.668 1.152 1.242 2.415 1.851 3.751 1.636 3.595 3.491 7.669 8.032 11.7a26.054 26.054 0 0 0 12.965 6.641 24.08 24.08 0 0 0 4.006.325c6.5 0 12.949-2.4 15.93-5.166a3.735 3.735 0 0 0 1.514-2.872c-.274-2.93-7.481-5.313-18.444-8.614-5.427-1.634-12.859-3.872-12.854-5.02.006-1.314 5.643-3.4 15.82-3.742 9.631-.321 25.785-.326 42.9-.321h.768a.5.5 0 0 0 0-1zM204.579 135c2.636 2.827 5.805 6.916 6.9 11.168 1.637 6.379-2.117 14.9-4.918 18.441-3.893 4.918-7.581 6.967-13.728 7.786-.7.093-1.432.142-2.179.172a.988.988 0 0 0-.354-.077 73.652 73.652 0 0 1-26.1-5.54 1 1 0 0 0-.826 1.821 74.46 74.46 0 0 0 25.115 5.664c.039.323.08.659.121 1.021a2.579 2.579 0 0 1 1.39 3.874.484.484 0 0 0-.243.11c-1.838 1.634-8.407 3.132-8.474 3.146a.5.5 0 0 0-.113.041l-2.2 1.1a.5.5 0 0 0-.274.5c.006.061.561 6.134-2.275 8.973a5.1 5.1 0 0 1-3.97 1.438l.82-4.449a.5.5 0 0 0-.383-.579l-2.605-.579c-.139-1-.37-4.182 1.943-5.917.976-.731 8.157-3.579 10.842-4.614a.5.5 0 0 0-.359-.933c-1 .386-9.818 3.8-11.082 4.747-3.262 2.447-2.312 7.043-2.27 7.237a.492.492 0 0 0 .107.2q-1.276-.217-2.6-.406c-.048-1.62-.626-3.782-2.151-4.88a3.922 3.922 0 0 0-3.715-.379.507.507 0 0 0-.086.039 5.413 5.413 0 0 0-2.65 4.454c0 2.46 1.4 5.3 3.473 5.614a5.187 5.187 0 0 0 .772.059 4.147 4.147 0 0 0 2.763-1 4.691 4.691 0 0 0 1.526-2.9q2.748.39 5.267.9l-.694 3.765a.5.5 0 0 0 .444.588q.446.043.87.043a6.04 6.04 0 0 0 4.449-1.735 6.78 6.78 0 0 0 .928-1.172 40.237 40.237 0 0 1 11.732 5.419q-.012 1.546-.051 3.143c-.342 13.7-3.374 22.782-4.649 26.035a48.73 48.73 0 0 1-1.031 3.377c-2.611 7.438-6.177 13.017-7.855 13.017a.639.639 0 0 1-.415-.146c-.669-.545-.51-5.868-.153-11.366a19.327 19.327 0 0 0 5.9-3.617.5.5 0 0 0-.717-.7 18.885 18.885 0 0 1-5.594 3.4 87.371 87.371 0 0 1-19.4 2.607c-11.281 0-18.4-1.892-20.923-2.7l-.26-6.109a.5.5 0 1 0-1 .043l.243 5.714c.149 6.176.113 12.181-.562 12.73a.639.639 0 0 1-.415.146c-1.681 0-5.261-5.579-7.872-13.017a88.882 88.882 0 0 1-2.615-9.761h-.005c-2.551-10.248-4.636-21.971-4.657-22.089a.5.5 0 1 0-.984.175c.021.118 2.091 11.756 4.633 22-1.324.122-2.662.2-4.013.2a63.607 63.607 0 0 1-26.1-6.059 44.608 44.608 0 0 1 6.949-9.4c9.072-9.555 18.343-13.569 25.331-15.21a6.619 6.619 0 0 0 2.526 3.172 5.232 5.232 0 0 0 2.774.9 4.77 4.77 0 0 0 3.579-1.983.5.5 0 0 0 .052-.57l-1.467-2.574q2.151-.118 4.317-.032c1.077 3.689 5.807 4.056 5.858 4.06h.033a.5.5 0 0 0 .464-.314 8.392 8.392 0 0 0 .432-1.968h2.691a.5.5 0 1 0 0-1h-2.606a18.073 18.073 0 0 0-.152-3.239h2.437a.5.5 0 0 0 0-1h-2.593a24.74 24.74 0 0 0-.512-2.243.5.5 0 0 0-.564-.359 11.053 11.053 0 0 0-3 1.03c-1.867.989-2.77 2.376-2.658 4.025a46.151 46.151 0 0 0-4.689.068l-.048-.084 2.933-.3a.5.5 0 0 0 .449-.484c.005-.2.1-4.931-2.952-6.689-1.677-.968-11.4-4.872-11.818-5.037a.5.5 0 0 0-.372.928c.1.041 10.074 4.043 11.69 4.976 2.05 1.183 2.388 4.223 2.442 5.359l-3.237.333a.5.5 0 0 0-.383.745l2.271 3.988c-1.076 1.186-2.506 2.067-4.831.615-3.425-2.138-3.069-8.563-3.019-9.262 0-.022.005-.044 0-.066v-.007a.5.5 0 0 0-.052-.266.985.985 0 0 0-.548-.63c-.037-.016-3.859-1.7-7.521-3.205-3.062-1.261-3.5-3.464-3.524-3.584a1 1 0 0 0-1.975.313c.021.138.568 3.4 4.738 5.121 3.645 1.5 7.452 3.176 7.488 3.191.056.024.2.091.35.158a17.069 17.069 0 0 0 .664 4.966c-7.122 1.693-16.53 5.791-25.719 15.47a45.518 45.518 0 0 0-7.118 9.64c-3.625-1.887-5.88-3.8-5.88-5.166 0-2.882 1.96-4.065 5.218-6.422l.3-3.752a1.306 1.306 0 0 1 1.293-1.2h.11l2.534.2a15.544 15.544 0 0 1-1.922 1.463.5.5 0 0 0 .545.839 15.693 15.693 0 0 0 2.715-2.2h.058a51.394 51.394 0 0 0 4.691-5.189l-1.394-4.541a1.305 1.305 0 0 1 .864-1.63l4.352-1.336-1.57 2.34a.5.5 0 1 0 .83.558l2.256-3.363.111-.034a59.576 59.576 0 0 0 3-6.032l-2.884-4.76a1.305 1.305 0 0 1 .439-1.792l4.593-2.784-.9 2.5a.5.5 0 0 0 .941.338l1.316-3.665.051-.031c.038-.137.069-.27.106-.406l.078-.218a.5.5 0 0 0 .027-.185 105.18 105.18 0 0 0 2.119-9.789c0-9.717.779-18.179 2.627-23.484l.683 1.709a.5.5 0 0 0 .929-.371l-1.144-2.862-.308-.986 1.083-.338a39.766 39.766 0 0 1 6.223-8.725 12.852 12.852 0 0 0 .241 1.72.5.5 0 0 0 .629.315.5.5 0 0 0 .32-.631 24.71 24.71 0 0 1-.287-2.484l.084-3.613 3.884.09a63.833 63.833 0 0 1 10.4-7l1.521-5.561 5.138 1.405-2.527 1.118a.5.5 0 1 0 .409.912l3.7-1.659a44.04 44.04 0 0 1 14.851-3.124q.8 0 1.545.052c7.754.535 20.609 10.512 30.783 20.521a.655.655 0 0 1 .465-.193.639.639 0 0 1 .442.176m0 0a78.848 78.848 0 0 1 8.484 9.535m-14.79 61.935a42.035 42.035 0 0 0-11.248-5.1 15.357 15.357 0 0 0 1.207-6.3.955.955 0 0 0 .1.016.989.989 0 0 0 .292-.044l9.149-2.784c.288 4.005.511 8.839.5 14.208z"/>
+ <path d="m193.22 139.67a1.412 1.412 0 0 0 1.41-1.41v-3.159a1.41 1.41 0 0 0-2.821 0v3.162a1.412 1.412 0 0 0 1.411 1.407z"/>
+ <path d="m193.22 144.48a5.736 5.736 0 0 0 4.644-2.413 1 1 0 1 0-1.679-1.088 3.76 3.76 0 0 1-2.965 1.5 3.71 3.71 0 0 1-2.969-1.506 1 1 0 0 0-1.675 1.094 5.736 5.736 0 0 0 4.644 2.413z"/>
+ <path d="m163.85 142.72a1 1 0 0 0 1.674-1.094 5.674 5.674 0 0 0-9.286 0 1 1 0 0 0 1.674 1.095 3.679 3.679 0 0 1 5.938 0z"/>
+ <path d="M159.473 135.1v3.162c0 .036.008.07.011.106a6.569 6.569 0 0 1 2.8 0c0-.036.011-.07.011-.106V135.1a1.41 1.41 0 1 0-2.821 0z"/>
+ <path d="m160.91 130.09a5.733 5.733 0 0 0 4.643-2.412 1 1 0 0 0-1.674-1.095 3.679 3.679 0 0 1-5.937 0 1 1 0 0 0-1.674 1.094 5.733 5.733 0 0 0 4.642 2.413z"/>
+ <path d="M155.934 155.646a18.515 18.515 0 0 0-4.51-8.323c-5.144-5.145-12.507-4.867-12.817-4.857a1 1 0 0 0 .089 2 16.68 16.68 0 0 1 3.159.279 18.431 18.431 0 0 0 1.1 3.47 6.118 6.118 0 0 0-1.562.513 7.174 7.174 0 0 0-2.41 2.27.5.5 0 1 0 .84.542 6.254 6.254 0 0 1 2.014-1.917 5.319 5.319 0 0 1 1.6-.478 4.113 4.113 0 0 0 .928 1.091 6.669 6.669 0 0 0-.955 2.347 7.171 7.171 0 0 0 .372 3.29.5.5 0 0 0 .469.326.494.494 0 0 0 .174-.031.5.5 0 0 0 .295-.643 6.214 6.214 0 0 1-.326-2.76 5.734 5.734 0 0 1 .894-2.1 2.673 2.673 0 0 0 .474.054 9.686 9.686 0 0 0 4.458-1.746 16.947 16.947 0 0 1 3.75 7.1 1 1 0 0 0 1.956-.42zm-10.821-6.082c-1.027-.642-1.8-3.029-2.178-4.592a15.045 15.045 0 0 1 6.547 3.285c-1.517.943-3.507 1.843-4.37 1.307z"/>
+ <path d="M193.839 116.06a1 1 0 0 0 .71-.3l2.533-2.554a1 1 0 1 0-1.42-1.408l-2.533 2.554a1 1 0 0 0 .71 1.7z"/>
+ <path d="M199.708 117.691a1 1 0 0 0 .409-.088l3.512-1.576a1 1 0 0 0-.818-1.825l-3.512 1.576a1 1 0 0 0 .41 1.913z"/>
+ <path d="M193.882 183.558a.5.5 0 0 0 .106.988.487.487 0 0 0 .105-.011 5.176 5.176 0 0 0 3-7.9.5.5 0 0 0-.807.591 4.178 4.178 0 0 1-2.41 6.327z"/>
+ <path d="M195.435 187.387a.487.487 0 0 0 .105-.011 7.781 7.781 0 0 0 4.512-11.856.5.5 0 1 0-.807.591 6.884 6.884 0 0 1 .676 6.146 6.8 6.8 0 0 1-4.593 4.142.5.5 0 0 0 .105.989z"/>
+ <path d="M127.087 198.745a5.134 5.134 0 0 0 1.54.239 5.254 5.254 0 0 0 3.143-1.063.5.5 0 0 0-.637-.771 4.178 4.178 0 0 1-6.463-2.021.5.5 0 0 0-.962.271 5.206 5.206 0 0 0 3.379 3.345z"/>
+ <path d="M132.43 200.03a6.783 6.783 0 0 1-10.508-3.284.5.5 0 0 0-.963.27 7.773 7.773 0 0 0 12.108 3.785.5.5 0 0 0-.638-.771z"/>
+ </g>
+ <g fill="#f9f9fa">
+ <path d="M160.366 188.859a.5.5 0 0 1-.43-.754l1.345-2.283a.5.5 0 1 1 .861.508l-1.345 2.283a.5.5 0 0 1-.431.246z"/>
+ <path d="M161.086 192.527a.5.5 0 0 1-.43-.754l1.344-2.282a.5.5 0 1 1 .861.508l-1.345 2.282a.5.5 0 0 1-.43.246z"/>
+ </g>
+</svg>
diff --git a/comm/mail/themes/shared/mail/illustrations/form.svg b/comm/mail/themes/shared/mail/illustrations/form.svg
new file mode 100644
index 0000000000..3207b0f0da
--- /dev/null
+++ b/comm/mail/themes/shared/mail/illustrations/form.svg
@@ -0,0 +1,56 @@
+<!-- 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 version="1.1" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <linearGradient id="a" x1="-300.02" x2="547.14" y1="-272.74" y2="574.42" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#ccfbff" offset="0"/>
+ <stop stop-color="#c9e4ff" offset="1"/>
+ </linearGradient>
+ <linearGradient id="b" x1="-18.672" x2="279.8" y1="23.78" y2="322.26" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#00c8d7" offset="0"/>
+ <stop stop-color="#0a84ff" offset="1"/>
+ </linearGradient>
+ </defs>
+ <path d="m267.46 87.073h-27.583c-2.347-4.377-8.617-14.64-16.509-16.145-10.217-1.948-12.254 8.237-12.254 8.237s-6.79-17.654-23.97-15.315c-19.262 2.623-10.189 22.9-10.189 22.9h-29l16.568 0.323h-16.569a1 1 0 0 0 0 2h119.51a1 1 0 0 0 0-2z" fill="#fff" fill-opacity=".70196"/>
+ <path d="M100.384 63.259H84.836c-1.212-2.3-4.758-8.29-9.271-9.15-5.682-1.083-6.814 4.58-6.814 4.58s-3.776-9.817-13.33-8.517c-10.712 1.458-5.666 12.735-5.666 12.735H33.629l18.049.352H33.924a1 1 0 1 0 0 2h66.46a1 1 0 0 0 0-2z" fill="#fff" fill-opacity=".70196"/>
+ <g fill="#eaeaee" fill-opacity=".4">
+ <path d="M122.246 134H26.93a1 1 0 1 1 0-2h95.316a1 1 0 1 1 0 2z"/>
+ <path d="M106.678 127.455H60.912a.5.5 0 1 1 0-1h45.766a.5.5 0 0 1 0 1z"/>
+ <path d="m245.16 186.75h-90.336a1 1 0 0 1 0-2h90.336a1 1 0 0 1 0 2z"/>
+ </g>
+ <g fill="none" stroke="#eaeaee" stroke-dasharray="12 8 3 4 1 9" stroke-linecap="round" stroke-linejoin="round">
+ <path d="m132.52 192.78h135.95" stroke-opacity=".4"/>
+ <path d="m148.46 82.782h29s-9.074-20.277 10.189-22.9c17.18-2.339 23.97 15.315 23.97 15.315s2.037-10.185 12.254-8.237c10.074 1.921 17.511 16.634 17.511 16.634h25.25" stroke-opacity=".43529"/>
+ <path d="M34.125 60.429H50.25s-5.05-11.276 5.667-12.735c9.554-1.3 13.33 8.517 13.33 8.517s1.133-5.664 6.814-4.58c5.6 1.068 9.738 8.589 9.738 8.589h14.042" stroke-opacity=".43529"/>
+ </g>
+ <ellipse cx="149.78" cy="243.18" rx="77.947" ry="6.445" fill="#fefeff" fill-opacity=".47843"/>
+ <path d="m221.88 77.056h-143.87a10.312 10.312 0 0 0-10.3 10.3v134.03a9.824 9.824 0 0 0 9.95 9.669h144.57a9.824 9.824 0 0 0 9.95-9.669v-134.03a10.313 10.313 0 0 0-10.3-10.3z" fill="#fff" fill-opacity=".80392"/>
+ <path d="M221.876 81.89H78.01a5.306 5.306 0 0 0-5.3 5.3v134.027a4.821 4.821 0 0 0 4.95 4.669h144.566a4.821 4.821 0 0 0 4.95-4.669V87.19a5.307 5.307 0 0 0-5.3-5.3z" fill="#f9f9fa"/>
+ <g fill="url(#a)">
+ <path d="M76.368 105.543V219c0 2.28.84 3.13 3.12 3.13H220.4c2.28 0 3.12-.85 3.12-3.13V105.543zM185 200.6a3.693 3.693 0 0 1-3.693 3.693h-60.762a3.693 3.693 0 0 1-3.693-3.693v-78.762a3.693 3.693 0 0 1 3.693-3.693h44.971L185 137.421z"/>
+ <path d="M187.187 193.93h-16.773a2.625 2.625 0 0 1-1.924-.89l-5.326-5.759a2.924 2.924 0 0 1 0-3.894l5.326-5.759a2.625 2.625 0 0 1 1.924-.89h16.773c1.6 0 2.9 1.582 2.9 3.534V190.4c-.001 1.948-1.299 3.53-2.9 3.53z"/>
+ <path d="m187.19 166.02h-16.773a2.625 2.625 0 0 1-1.924-0.89l-5.326-5.759a2.924 2.924 0 0 1 0-3.894l5.326-5.759a2.625 2.625 0 0 1 1.924-0.89h16.773c1.6 0 2.9 1.582 2.9 3.534v10.126c-1e-3 1.949-1.299 3.532-2.9 3.532z"/>
+ </g>
+ <g fill="url(#b)">
+ <path d="M221.876 81.891a5.307 5.307 0 0 1 5.3 5.3v134.026a4.821 4.821 0 0 1-4.95 4.669H77.66a4.821 4.821 0 0 1-4.95-4.669V87.19a5.306 5.306 0 0 1 5.3-5.3h143.866m0-2H78.01a7.308 7.308 0 0 0-7.3 7.3v134.027a6.822 6.822 0 0 0 6.95 6.669h144.566a6.822 6.822 0 0 0 6.95-6.669V87.19a7.309 7.309 0 0 0-7.3-7.3z"/>
+ <circle cx="85.771" cy="93.32" r="3.241"/>
+ <circle cx="96.226" cy="93.32" r="3.241"/>
+ <path d="M186.093 96.986h-72.3a3.509 3.509 0 0 1-3.509-3.509v-.313a3.509 3.509 0 0 1 3.509-3.509h72.3a3.509 3.509 0 0 1 3.509 3.509v.313a3.509 3.509 0 0 1-3.509 3.509z"/>
+ <circle cx="203.66" cy="93.32" r="3.241"/>
+ <circle cx="214.12" cy="93.32" r="3.241"/>
+ <path d="m157.13 131.14h-31.754a1.351 1.351 0 1 1 0-2.7h31.755a1.351 1.351 0 1 1 0 2.7z"/>
+ <path d="m153.61 139.09a1.351 1.351 0 0 0-1.351-1.351h-26.882a1.351 1.351 0 0 0 0 2.7h26.882a1.351 1.351 0 0 0 1.351-1.349z"/>
+ <path d="m164.68 148.39a1.351 1.351 0 0 0-1.351-1.351h-37.949a1.351 1.351 0 0 0 0 2.7h37.949a1.351 1.351 0 0 0 1.351-1.349z"/>
+ <path d="m159.66 157.69a1.351 1.351 0 0 0-1.351-1.351h-32.938a1.351 1.351 0 0 0 0 2.7h32.938a1.351 1.351 0 0 0 1.351-1.349z"/>
+ <path d="m153.61 166.99a1.351 1.351 0 0 0-1.351-1.351h-26.882a1.351 1.351 0 1 0 0 2.7h26.882a1.351 1.351 0 0 0 1.351-1.349z"/>
+ <path d="m164.68 176.29a1.351 1.351 0 0 0-1.351-1.351h-37.949a1.351 1.351 0 1 0 0 2.7h37.949a1.351 1.351 0 0 0 1.351-1.349z"/>
+ <path d="m159.67 185.59a1.351 1.351 0 0 0-1.351-1.351h-32.945a1.351 1.351 0 1 0 0 2.7h32.944a1.351 1.351 0 0 0 1.352-1.349z"/>
+ <path d="m164.68 194.89a1.351 1.351 0 0 0-1.351-1.351h-37.949a1.351 1.351 0 1 0 0 2.7h37.949a1.351 1.351 0 0 0 1.351-1.349z"/>
+ <path d="M165.517 118.145l19.276 19.276h-15.179a4.1 4.1 0 0 1-4.1-4.1v-15.176m0-1a1 1 0 0 0-1 1v15.179a5.1 5.1 0 0 0 5.1 5.1h15.179a1 1 0 0 0 .707-1.707l-19.276-19.276a1 1 0 0 0-.707-.293z"/>
+ <path d="M187.187 178.737c.367 0 .9.6.9 1.534V190.4c0 .936-.532 1.534-.9 1.534h-16.773a.689.689 0 0 1-.455-.248l-5.326-5.759a.932.932 0 0 1 0-1.178l5.326-5.759a.688.688 0 0 1 .455-.248h16.773m0-2h-16.773a2.625 2.625 0 0 0-1.924.89l-5.326 5.759a2.924 2.924 0 0 0 0 3.894l5.326 5.759a2.626 2.626 0 0 0 1.924.89h16.773c1.6 0 2.9-1.582 2.9-3.534v-10.129c0-1.952-1.3-3.534-2.9-3.534z"/>
+ <path d="M187.187 150.827c.367 0 .9.6.9 1.534v10.126c0 .936-.532 1.534-.9 1.534h-16.773a.689.689 0 0 1-.455-.248l-5.326-5.759a.932.932 0 0 1 0-1.178l5.326-5.76a.687.687 0 0 1 .455-.248h16.773m0-2h-16.773a2.624 2.624 0 0 0-1.924.89l-5.326 5.759a2.924 2.924 0 0 0 0 3.894l5.326 5.759a2.626 2.626 0 0 0 1.924.89h16.773c1.6 0 2.9-1.582 2.9-3.534v-10.125c0-1.952-1.3-3.534-2.9-3.534z"/>
+ <path d="M186.408 136l-19.485-19.276a2 2 0 0 0-1.407-.578h-44.971a5.7 5.7 0 0 0-5.693 5.693V200.6a5.7 5.7 0 0 0 5.693 5.693h60.763A5.693 5.693 0 0 0 187 200.6v-8.67h-2v8.67a3.693 3.693 0 0 1-3.693 3.693h-60.762a3.693 3.693 0 0 1-3.693-3.693v-78.762a3.693 3.693 0 0 1 3.693-3.693h44.971L185 137.421v13.406h2v-13.406a2 2 0 0 0-.592-1.421zM185 178.737h2v-14.716h-2z"/>
+ <path d="m180.31 205.81h-58.763a5.7 5.7 0 0 1-5.693-5.693v4.667a5.7 5.7 0 0 0 5.693 5.693h58.763a5.693 5.693 0 0 0 5.692-5.694v-4.667a5.693 5.693 0 0 1-5.693 5.693z"/>
+ </g>
+</svg>
diff --git a/comm/mail/themes/shared/mail/illustrations/octopus-setup.svg b/comm/mail/themes/shared/mail/illustrations/octopus-setup.svg
new file mode 100644
index 0000000000..6a533c2f92
--- /dev/null
+++ b/comm/mail/themes/shared/mail/illustrations/octopus-setup.svg
@@ -0,0 +1,75 @@
+<!-- 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 width="300" height="300" version="1.1" viewBox="0 0 79.375 79.375" xmlns="http://www.w3.org/2000/svg">
+ <ellipse cx="35.963" cy="60.548" rx="15.196" ry="2.3841" fill="#fefeff" fill-opacity=".48001" stroke-width=".29234"/>
+ <path d="m36.047 19.41c-4.4982-1.7e-5 -8.1447 3.4697-8.1447 7.7499 1.51e-4 0.24727 0.01274 0.4944 0.03772 0.74052-0.99194 0.81304-1.558 1.9688-1.558 3.1812 4.7e-5 1.5603 0.93476 2.9976 2.4407 3.7527 0.02085 0.13742 0.30639 2.0565 0.30282 3.5404-0.18418 0.41388-0.89123 1.6677-2.7032 1.5239-2.1625-0.17163-3.3905-2.7554-3.3905-2.7554s-0.60994-1.9455-1.6434-2.0236c-0.96743-0.07306-0.67426 1.7409-0.67426 1.7409s0.18192 4.9179 4.1186 6.2797c0.42378 0.1466 0.83195 0.25504 1.2278 0.3421-0.9343 0.97235-2.1668 2.2657-2.8763 3.0882-1.0793 1.2511-1.3811 1.6822-2.0304 1.7978-0.64927 0.11561-3.5976-1.027-4.2478-1.0811-4.0779-0.33944-1.9858 1.4504-1.9858 1.4504s2.6731 2.4946 4.2048 2.8935c1.5317 0.39887 1.697 0.3256 2.2608 0.26975 0.56382-0.05585 1.7077-0.06807 3.1946-1.0227 1.0458-0.6714 3.1716-2.2181 4.6245-3.1807 0.0816 0.72176 0.02405 1.4594 0.0863 2.7549 0.10088 2.099 0.17194 3.9611-1.1746 5.4131-1.1433 1.2328-1.8636 1.5117-2.0655 1.572-0.52537 0.0845-0.90908 0.50858-0.90899 1.0046 3.26e-4 0.55464 0.47727 1.0072 1.0733 1.0185l5.3e-4 0.0036s2.5185 0.42481 4.1672-0.71107c1.6487-1.1359 3.5453-3.7658 3.9656-5.656 0.4204-1.8901 0.95442-5.2651 1.5756-5.5904 0.46896-0.24551 1.3174 4.6147 1.7012 5.7914 0.38379 1.1766 1.5944 3.8405 3.0634 4.8044 1.4485 0.95039 3.4636 1.3532 4.5837 1.2795 0.01722 6.09e-4 0.03445 9.76e-4 0.05168 0.0011 0.79347 6e-5 1.4367-0.50366 1.4366-1.125-7e-6 -0.36954-0.23181-0.71551-0.62012-0.92552-0.05145-0.0382-0.34598-0.25401-1.1694-0.75344-0.90125-0.54662-2.4485-1.8997-2.5321-4.1858-0.06164-1.6837 0.04596-3.4773 0.15555-5.2503 0.36244-0.04441 0.87024-0.0055 1.5694 0.23978 1.8317 0.64258 2.44 1.6458 3.1414 2.4169 0.7014 0.77113 2.2625 1.8248 4.0013 1.9771 1.7388 0.15239 2.282-0.64379 3.0954-1.064 0.81343-0.42023 0.50544-0.99596 0.82682-2.0479 0.32139-1.052-0.67861-0.26114-2.2252-0.45579-1.5466-0.19464-2.059-0.83252-2.7471-1.3586-0.68809-0.52605-1.2902-1.8953-2.3167-2.975-0.20753-0.21828-0.43582-0.39526-0.66766-0.54105 0.44637-0.02011 0.86425-0.06243 1.1839-0.14263 1.3367-0.33539 2.3263-1.6463 3.2432-2.8122 0.91694-1.1659 1.2291-2.805 1.1152-4.9036-0.11394-2.0986 0.06614-3.4129 0.6258-4.4111 0.55966-0.99821 1.9466-1.9845 2.434-2.4856 0.4874-0.50118-0.16216-2.7965-1.525-2.1983-1.3628 0.59816-2.2172 1.2577-4.1501 3.0019-1.9329 1.7442-1.697 3.678-1.8118 4.6555-0.11475 0.97749 0.48506 2.8561-1.2501 4.2034-1.7351 1.3472-2.8484 0.22178-3.6758-0.852-0.82744-1.0738-0.62366-2.5437-0.62366-2.5437 1.7322-0.63297 2.8537-2.076 2.8536-3.6716-1.5e-5 -1.1369-0.57221-2.22-1.5735-2.9786 0.04878-0.34342 0.07329-0.68957 0.07338-1.0361 1.2e-5 -4.2802-3.6465-7.7499-8.1447-7.7499z" fill="none" stroke="#05a7eb" stroke-linecap="round" stroke-linejoin="bevel" stroke-width="1.0583"/>
+ <path d="m36.047 19.41c-4.4982-1.7e-5 -8.1447 3.4697-8.1447 7.7499 1.51e-4 0.24727 0.01274 0.4944 0.03772 0.74052-0.99194 0.81304-1.558 1.9688-1.558 3.1812 4.7e-5 1.5603 0.93476 2.9976 2.4407 3.7527 0.02085 0.13742 0.30639 2.0565 0.30282 3.5404-0.18418 0.41388-0.89123 1.6677-2.7032 1.5239-2.1625-0.17163-3.3905-2.7554-3.3905-2.7554s-0.51096-1.1628-1.3544-1.2263c-0.84348-0.06347-0.96325 0.94361-0.96325 0.94361s0.18192 4.9179 4.1186 6.2797c0.42378 0.1466 0.83195 0.25504 1.2278 0.3421-0.9343 0.97235-2.1668 2.2657-2.8763 3.0882-1.0793 1.2511-1.3811 1.6822-2.0304 1.7978-0.64927 0.11561-3.6093-0.94673-4.2478-1.0811-0.63847-0.13434-0.28009 3.1037-0.28009 3.1037s0.96735 0.84136 2.4991 1.2402c1.5317 0.39887 1.697 0.3256 2.2608 0.26975 0.56382-0.05585 1.7077-0.06807 3.1946-1.0227 1.0458-0.6714 3.1716-2.2181 4.6245-3.1807-3e-3 0.79829 0.02405 1.4594 0.0863 2.7549 0.10088 2.099 0.17194 3.9611-1.1746 5.4131-1.1433 1.2328-1.8636 1.5117-2.0655 1.572-0.52537 0.0845-0.90908 0.50858-0.90899 1.0046 3.26e-4 0.55464 0.47727 1.0072 1.0733 1.0185l5.3e-4 0.0036s2.5185 0.42481 4.1672-0.71107c1.6487-1.1359 3.5453-3.7658 3.9656-5.656 0.4204-1.8901 0.95442-5.2651 1.5756-5.5904 0.46896-0.24551 1.3174 4.6147 1.7012 5.7914 0.38379 1.1766 1.5944 3.8405 3.0634 4.8044 1.4485 0.95039 3.4636 1.3532 4.5837 1.2795 0.01722 6.09e-4 0.03445 9.76e-4 0.05168 0.0011 0.79347 6e-5 1.4367-0.50366 1.4366-1.125-7e-6 -0.36954-0.23181-0.71551-0.62012-0.92552-0.05145-0.0382-0.34598-0.25401-1.1694-0.75344-0.90125-0.54662-2.4485-1.8997-2.5321-4.1858-0.06164-1.6837 0.04596-3.4773 0.15555-5.2503 0.36244-0.04441 0.87024-0.0055 1.5694 0.23978 1.8317 0.64258 2.44 1.6458 3.1414 2.4169 0.7014 0.77113 2.2625 1.8248 4.0013 1.9771 1.7388 0.15239 2.282-0.64379 3.0954-1.064 0.81343-0.42023 0.50544-0.99596 0.82682-2.0479 0.32139-1.052-0.67861-0.26114-2.2252-0.45579-1.5466-0.19464-2.059-0.83252-2.7471-1.3586-0.68809-0.52605-1.2902-1.8953-2.3167-2.975-0.20753-0.21828-0.43582-0.39526-0.66766-0.54105 0.44637-0.02011 0.86425-0.06243 1.1839-0.14263 1.3367-0.33539 2.3263-1.6463 3.2432-2.8122 0.91694-1.1659 1.2291-2.805 1.1152-4.9036-0.11394-2.0986 0.06614-3.4129 0.6258-4.4111 0.55966-0.99821 1.9466-1.9845 2.434-2.4856 0.4874-0.50118-0.16216-2.7965-1.525-2.1983-1.3628 0.59816-2.2172 1.2577-4.1501 3.0019-1.9329 1.7442-1.697 3.678-1.8118 4.6555-0.11475 0.97749 0.48506 2.8561-1.2501 4.2034-1.7351 1.3472-2.6707 0.3177-3.5832-0.66921s-0.71624-2.7264-0.71624-2.7264c1.7322-0.63297 2.8537-2.076 2.8536-3.6716-1.5e-5 -1.1369-0.57221-2.22-1.5735-2.9786 0.04878-0.34342 0.07329-0.68957 0.07338-1.0361 1.2e-5 -4.2802-3.6465-7.7499-8.1447-7.7499z" fill="#cde7f8"/>
+ <path d="m27.463 31.113c0.10843 2.0072 1.4842 3.2036 2.9901 3.9588 0.02085 0.13742-0.34457 2.0939-1.3445 3.8164-0.18418 0.41388-0.87356 1.1549-2.6855 1.0111-2.1625-0.17163-3.3905-2.7554-3.3905-2.7554s-0.51096-1.1628-1.3544-1.2263c-0.84348-0.06347-0.96325 0.94361-0.96325 0.94361s0.84366 4.3044 4.7769 5.6761c1.1707 0.40829 2.7731 0.64002 4.9686 0.10294 1.4589-0.35689 2.4778-1.6704 2.5176-1.5153 0 0-0.39255 1.9048-4.04 2.4379-1.6072 0.23488-2.3208-0.05807-2.8767-0.07974-0.97615 1.0359-1.8544 1.902-2.564 2.7245-1.0793 1.2511-1.6934 2.0459-2.3427 2.1615-0.64927 0.11561-3.6093-0.94673-4.2478-1.0811-0.63847-0.13434-0.3114 1.4571-0.3114 1.4571s0.7516 0.31849 2.2833 0.71736c1.5317 0.39887 2.0294 0.35416 2.6493 0.27466 0.53243-0.06829 1.7323-0.23885 3.0515-1.1704 0.92331-0.65194 3.6615-3.4164 3.8602-4.8147 0.89061-0.12684 1.4681-0.26323 2.018-0.3301-0.72619 0.84958-1.2278 5.7358-1.1655 7.0313 0.10088 2.099 0.17194 3.9611-1.1746 5.4131-1.1433 1.2328-1.8636 1.5117-2.0655 1.572-0.52537 0.0845-0.90908 0.50858-0.90899 1.0046 0.01078 0.44749 0.8264 0.53037 1.1244 0.53602 0 0 2.1315 0.09148 3.6604-0.91215 1.6737-1.0986 2.829-3.3513 3.2494-5.2414 0.4204-1.8901 0.18272-8.4143 2.7135-8.401 1.9578 0.01032 2.6608 6.7452 3.0446 7.9218 0.38379 1.1766 1.0058 3.8896 2.4748 4.8534 1.4485 0.95039 2.924 1.6184 3.9066 1.5821 0.98262-0.03629 1.4451 0.0986 1.445-0.52274-7e-6 -0.36954-0.23181-0.71551-0.62012-0.92552-0.05145-0.0382-0.34598-0.25401-1.1694-0.75344-0.90125-0.54662-2.4485-1.8997-2.5321-4.1858-0.06164-1.6837 0.11458-5.3871 0.05743-6.8071-0.05715-1.42 0.03417-2.0048-0.15563-2.861-0.18979-0.85626-1.5052-1.0956-1.6393-1.4892-0.10859-0.31884 0.40471-0.26233 0.6135-0.14178 3.0132 1.9435 5.7632 1.0343 6.4306 0.67316s1.5964-0.71236 2.5134-1.8782c0.91694-1.1659 1.7272-2.7075 1.6132-4.8061-0.11394-2.0986 0.11275-3.6508 0.75828-4.5958 0.6556-0.95968 2.2193-1.9506 2.7067-2.4517 0.4874-0.50118 0.37496-2.2527-0.98786-1.6546-1.3628 0.59816-2.2172 1.2577-4.1501 3.0019-1.9329 1.7442-1.697 3.678-1.8118 4.6555-0.11475 0.97749 0.48506 2.8561-1.2501 4.2034-1.7351 1.3472-3.3851 0.47519-4.1637-0.31079-0.77782-0.67288-1.3536-2.8358-1.3536-2.8358 2.2479-0.53373 4.7541-4.5353 1.7042-7.571 0.35888-3.7899-2.7156-7.7632-7.2138-7.7632-4.4982-1e-6 -7.7969 3.4128-7.3356 8.1071-0.97102 0.90875-1.4369 1.9453-1.3133 3.2442z" fill="#fefefe"/>
+ <g fill="#05a7eb">
+ <ellipse cx="32.002" cy="30.363" rx=".44608" ry=".83815"/>
+ <ellipse cx="40.609" cy="30.362" rx=".428" ry=".79574"/>
+ <path d="m31.985 28.01c-0.45481-0.0087-0.76903 0.16497-0.94797 0.3547s-0.23559 0.40761-0.23559 0.40761a0.25329 0.26547 0 0 0 0.18324 0.32334 0.25329 0.26547 0 0 0 0.30851-0.19009s0.01465-0.07304 0.10471-0.16853c0.09006-0.09549 0.24296-0.20239 0.57963-0.19597 0.3364 0.0064 0.4816 0.11898 0.56654 0.21752 0.08494 0.09854 0.09536 0.17049 0.09536 0.17049a0.25329 0.26547 0 0 0 0.29916 0.20772 0.25329 0.26547 0 0 0 0.19819-0.31355s-0.04621-0.22136-0.21689-0.41937-0.4798-0.3852-0.93488-0.39389z" color="#000000" color-rendering="auto" dominant-baseline="auto" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stop-color="#000000" style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-east-asian:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;font-variation-settings:normal;inline-size:0;isolation:auto;mix-blend-mode:normal;shape-margin:0;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"/>
+ <path d="m40.618 26.395c-0.45481-0.0087-0.76903 0.16497-0.94797 0.3547s-0.23559 0.40761-0.23559 0.40761a0.25329 0.26547 0 0 0 0.18324 0.32334 0.25329 0.26547 0 0 0 0.30851-0.19009s0.01465-0.07304 0.10471-0.16853c0.09006-0.09549 0.24296-0.20239 0.57963-0.19597 0.3364 0.0064 0.4816 0.11898 0.56654 0.21752 0.08494 0.09854 0.09536 0.17049 0.09536 0.17049a0.25329 0.26547 0 0 0 0.29916 0.20772 0.25329 0.26547 0 0 0 0.19819-0.31355s-0.04621-0.22136-0.21689-0.41937-0.4798-0.3852-0.93488-0.39389z" color="#000000" color-rendering="auto" dominant-baseline="auto" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stop-color="#000000" style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-east-asian:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;font-variation-settings:normal;inline-size:0;isolation:auto;mix-blend-mode:normal;shape-margin:0;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"/>
+ <ellipse transform="matrix(.72124 -.69268 .8781 .47847 0 0)" cx="-32.027" cy="56.393" rx="1.1306" ry=".77014"/>
+ <ellipse transform="matrix(.92091 -.38978 .58448 .81141 0 0)" cx="-11.852" cy="57.528" rx="1.1243" ry=".74805"/>
+ <ellipse transform="matrix(.97875 .20507 -.074049 .99725 0 0)" cx="22.546" cy="46.529" rx="1.1193" ry=".75073"/>
+ <ellipse transform="matrix(.097116 -.99527 .99187 -.12723 0 0)" cx="-57.41" cy="40.148" rx="1.1354" ry=".69786"/>
+ <ellipse transform="matrix(-.80948 -.58714 .43784 -.89905 0 0)" cx="-64.145" cy="-12.145" rx="1.0832" ry=".68138"/>
+ <ellipse transform="matrix(-.99443 -.10544 -.10859 -.99409 0 0)" cx="-44.986" cy="-46.612" rx="1.1357" ry=".78417"/>
+ <ellipse transform="matrix(-.80167 .59777 -.74752 -.66424 0 0)" cx="2.4445" cy="-74.384" rx=".93657" ry=".66803"/>
+ <ellipse transform="matrix(-.046119 .99894 -.99355 .11338 0 0)" cx="43.498" cy="-54.915" rx=".99775" ry=".62706"/>
+ <ellipse transform="matrix(-.40189 .91569 -.9924 -.12306 0 0)" cx="36.781" cy="-65.363" rx="1.0533" ry=".85796"/>
+ <ellipse transform="matrix(.61614 .78764 -.70808 .70613 0 0)" cx="44.88" cy="7.5031" rx="1.0451" ry=".8136"/>
+ <ellipse transform="matrix(.50005 -.86599 .97794 .20887 0 0)" cx="-51.788" cy="58.762" rx="1.1631" ry=".7855"/>
+ </g>
+ <g fill="none" stroke="#05a7eb" stroke-linecap="round" stroke-linejoin="bevel" stroke-width=".26458">
+ <path d="m42.696 47.621s0.09037-1.2769 0.06182-2.3845"/>
+ <path d="m29.066 48.629s-0.05091-1.5364 0.26637-2.7173"/>
+ <path d="m44.235 34.449c-1.2853 0.65114-2.0591 0.69564-3.0024 0.54304"/>
+ <path d="m27.801 34.453c0.59055 0.49319 1.7085 0.77054 2.6518 0.61794"/>
+ <path d="m25.214 43.425c2.2061 0.59323 6.357 0.24288 7.466-1.5559"/>
+ <path d="m44.428 43.116c1.2929 0.54053 3.7053 0.3924 4.4862 0.07204"/>
+ </g>
+ <path transform="scale(.26458)" d="m66.607 93.844c-3.9581-0.02916-9.1155 5.2526-12.557 12.859-4.1829 9.2465-4.433 18.744-0.55859 21.213 1.0906 0.69539 2.4408 0.7743 3.9434 0.23046 22.916-2.7796 37.433 5.0142 37.433 5.0142l6.2701 1.482s2.8639-1.0364 4.6749-4.6337c1.7916-3.5589 0.55753-7.6261 0.55753-7.6261s-5.4819-3.3939-13.766-7.5508c-7.4173-3.7221-19.747-15.961-22.365-18.598-0.42787-0.79282-0.96596-1.3992-1.5996-1.8027-0.6021-0.38426-1.2855-0.58185-2.0332-0.58789z" fill="#fcfdfe" stroke="#04afe6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
+ <ellipse transform="matrix(.83974 .54299 -.40726 .91331 0 0)" cx="27.223" cy="15.972" rx="1.8106" ry="4.0309" fill="#04aee6"/>
+ <path transform="scale(.26458)" d="m68.852 106.29a3.4758 7.251 22.642 0 0-5.2207 5.2676 3.4758 7.251 22.642 0 0 0.2207 8.2012 3.4758 7.251 22.642 0 0 0.77734 0.17969 6.7418 15.28 21.86 0 0 2.9336-5.2188 6.7418 15.28 21.86 0 0 2.4277-8.0176 3.4758 7.251 22.642 0 0-0.34961-0.23242 3.4758 7.251 22.642 0 0-0.78906-0.17969z" fill="#fff" fill-opacity=".55006"/>
+ <path transform="scale(.26458)" d="m93.077 113.9c-1e-6 0 0.17995 5.9905-2.1042 9.9634-1.1977 2.0832-3.3816 4.2923-4.2522 5.2334 0 0-0.45584 0.87601-1.1199 1.8435l-7.7609 11.344c-1.2546 1.8296-0.79249 4.3118 1.0371 5.5664s4.3137 0.79249 5.5684-1.0371l9.0395-12.793c1.1512 0.25212 2.8681 0.66637 2.8681 0.66637s3.5241-2.32 5.5319-6.4635c1.915-3.9521 1.8197-8.561 1.8197-8.561l-4.8138-2.8256z" fill="#05a3ed"/>
+ <g fill="#cde7f8">
+ <path transform="scale(.26458)" d="m63.938 154.93 0.35352 8.2656c0.036374 0.85103-0.61968 1.5652-1.4707 1.6016l-3.7031 0.1582c-0.85103 0.03638-1.5652-0.61967-1.6016-1.4707l-0.35156-8.2383a10.144 10.24 0 0 0-6.3145 9.4824 10.144 10.24 0 0 0 8.0059 10.01l0.50391 16.9a10.144 10.24 0 0 0-5.0723 8.8672 10.144 10.24 0 0 0 7.8613 9.9785l-0.19531-8.1523c-0.0205-0.85156 0.64844-1.5537 1.5-1.5742l3.7051-0.08984c0.85156-0.0205 1.5537 0.65039 1.5742 1.502l0.17969 7.5215a10.144 10.24 0 0 0 5.6621-9.1855 10.144 10.24 0 0 0-8.2832-10.066l-0.49609-16.689a10.144 10.24 0 0 0 5.3438-9.0215 10.144 10.24 0 0 0-7.2012-9.7988z" stroke="#04afe6" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" style="paint-order:stroke fill markers"/>
+ <path transform="scale(.26458)" d="m63.938 154.93 0.35352 8.2656c0.036374 0.85103-0.61968 1.5652-1.4707 1.6016l-3.7031 0.1582c-0.85103 0.03638-1.5652-0.61967-1.6016-1.4707l-0.35156-8.2383a10.144 10.24 0 0 0-6.3145 9.4824 10.144 10.24 0 0 0 8.0059 10.01l0.50391 16.9a10.144 10.24 0 0 0-5.0723 8.8672 10.144 10.24 0 0 0 7.8613 9.9785l-0.19531-8.1523c-0.0205-0.85156 0.64844-1.5537 1.5-1.5742l3.7051-0.08984c0.85156-0.0205 1.5537 0.65039 1.5742 1.502l0.17969 7.5215a10.144 10.24 0 0 0 5.6621-9.1855 10.144 10.24 0 0 0-8.2832-10.066l-0.49609-16.689a10.144 10.24 0 0 0 5.3438-9.0215 10.144 10.24 0 0 0-7.2012-9.7988z" style="paint-order:stroke fill markers"/>
+ </g>
+ <g>
+ <path d="m24.31 36.014a1.9191 1.4973 0 0 1-1.9191 1.4973 1.9191 1.4973 0 0 1-1.9191-1.4973 1.9191 1.4973 0 0 1 1.9191-1.4973 1.9191 1.4973 0 0 1 1.9191 1.4973z" fill="#cde7f8" style="paint-order:stroke fill markers"/>
+ <path d="m23.378 35.659a1.324 1.0843 0 0 1-1.323 1.0843 1.324 1.0843 0 0 1-1.3251-1.0825 1.324 1.0843 0 0 1 1.3208-1.0861 1.324 1.0843 0 0 1 1.3273 1.0807l-1.324 0.0035z" fill="#fefefe" style="paint-order:stroke fill markers"/>
+ <path d="m18.107 47.617a1.9191 1.4973 0 0 1-1.9191 1.4973 1.9191 1.4973 0 0 1-1.9191-1.4973 1.9191 1.4973 0 0 1 1.9191-1.4973 1.9191 1.4973 0 0 1 1.9191 1.4973z" fill="#cde7f8" style="paint-order:stroke fill markers"/>
+ <path d="m17.24 47.295a1.4041 1.0572 0 0 1-1.403 1.0572 1.4041 1.0572 0 0 1-1.4053-1.0555 1.4041 1.0572 0 0 1 1.4006-1.059 1.4041 1.0572 0 0 1 1.4076 1.0538l-1.4041 0.0034z" fill="#fefefe" style="paint-order:stroke fill markers"/>
+ <path transform="rotate(-4.8837)" d="m56.653 44.587 0.947-0.90064c0.13528-0.12866 0.33286 0.15034 0.33699 0.33699l0.19304 8.7247c0.0041 0.18665-0.15032 0.3339-0.33699 0.33699l-0.88448 0.01464c-0.18667 0.0031-0.33439-0.15032-0.33699-0.33699l-0.1068-7.6597c-0.0026-0.18667 0.05296-0.38737 0.18824-0.51603z" fill="#e5e5ec" style="paint-order:stroke fill markers"/>
+ </g>
+ <g>
+ <path d="m65.46 41.311-0.07279-1.3049c-0.01044-0.1864-0.34547-0.11853-0.47635 0.01462l-6.118 6.2232c-0.13086 0.13315-0.11926 0.34621 0.01462 0.47635l0.63426 0.61664c0.13385 0.13014 0.34656 0.1196 0.47635-0.01462l5.3255-5.5064c0.12978-0.1342 0.22682-0.31846 0.21642-0.50486z" fill="#dedede" style="paint-order:stroke fill markers"/>
+ <path transform="scale(.26458)" d="m235.26 178.56c-0.10056-0.00499-0.20368-6.6e-4 -0.30859 0.01562l-4.0254 0.625c-0.83929 0.13026-1.4543 0.91784-1.3809 1.7656l1.0644 12.311c0.02158 0.2492 0.10679 0.47071 0.22851 0.66211a8.1676 9.1168 0 0 0 8.1289 8.2266 8.1676 9.1168 0 0 0 8.166-9.1172 8.1676 9.1168 0 0 0-8.166-9.1172 8.1676 9.1168 0 0 0-1.9922 0.27734l-0.37695-4.3379c-0.06425-0.74181-0.63399-1.2756-1.3379-1.3105zm3.9102 8.9297a4.5123 5.6293 0 0 1 0.0332 0 4.5123 5.6293 0 0 1 4.5117 5.6289 4.5123 5.6293 0 0 1-4.5117 5.6289 4.5123 5.6293 0 0 1-4.5137-5.6289 4.5123 5.6293 0 0 1 4.4805-5.6289z" fill="#05a7eb" style="paint-order:stroke fill markers"/>
+ <path transform="scale(.26458)" d="m222.1 174.17c-0.38555 0.01465-0.76502 0.18278-1.0488 0.5l-2.5078 2.8027a8.1676 9.1168 51.609 0 0-11.713-0.16797 8.1676 9.1168 51.609 0 0-2.0723 12.064 8.1676 9.1168 51.609 0 0 12.217 0.73829 8.1676 9.1168 51.609 0 0 1.4512-1.4062c0.16943-0.08289 0.3274-0.19817 0.46289-0.34961l7.584-8.4766c0.56761-0.63443 0.53013-1.6195-0.08399-2.209l-3.2051-3.0781c-0.30706-0.29476-0.69843-0.43262-1.084-0.41796zm-8.9844 4.793a4.5123 5.6293 51.609 0 1 3.2715 1.4531 4.5123 5.6293 51.609 0 1 0.01953 0.02539 4.5123 5.6293 51.609 0 1-1.6094 7.0312 4.5123 5.6293 51.609 0 1-7.2129-0.04102 4.5123 5.6293 51.609 0 1 1.6074-7.0332 4.5123 5.6293 51.609 0 1 3.9238-1.4356z" fill="#05a7eb" style="paint-order:stroke fill markers"/>
+ <path d="m61.67 44.8a0.36193 0.38909 0 0 1-0.36193 0.38909 0.36193 0.38909 0 0 1-0.36193-0.38909 0.36193 0.38909 0 0 1 0.36193-0.38909 0.36193 0.38909 0 0 1 0.36193 0.38909z" fill="#a8a8a8" style="paint-order:stroke fill markers"/>
+ </g>
+ <path d="m56.881 48.495a1.5865 1.323 0 0 1-1.5865 1.323 1.5865 1.323 0 0 1-1.5865-1.323 1.5865 1.323 0 0 1 1.5865-1.323 1.5865 1.323 0 0 1 1.5865 1.323z" fill="#cde7f8" style="paint-order:stroke fill markers"/>
+ <g>
+ <path transform="rotate(72.564)" d="m41.039-44.44v2.2149c0 0.31879-0.25664 0.57543-0.57543 0.57543h-1.7258c-0.31879 0-0.57543-0.25664-0.57543-0.57543v-2.1754z" fill="#05a7eb" style="paint-order:stroke fill markers"/>
+ <path transform="rotate(72.564)" d="m36.214-44.333v2.1763c0 0.31879-0.25664 0.57543-0.57543 0.57543h-1.7258c-0.31879 0-0.57543-0.25664-0.57543-0.57543v-2.149z" fill="#05a7eb" style="paint-order:stroke fill markers"/>
+ <path transform="rotate(-17.849)" d="m43.149 33.577h2.1349c0.32407 0 0.58496 0.26089 0.58496 0.58496v11.035c0 0.32407-0.26089 0.58496-0.58496 0.58496h-2.1349c-0.32407 0-0.58496-0.26089-0.58496-0.58496v-11.035c0-0.32407 0.26089-0.58496 0.58496-0.58496z" fill="#cde7f8" stroke="#05a7eb" stroke-linecap="round" stroke-linejoin="round" stroke-width=".52917" style="paint-order:stroke fill markers"/>
+ <g fill="#05a7eb">
+ <path transform="matrix(.65811 -.75292 0 1 0 0)" d="m82.18 90.865 3.8351-0.02109 0.4358 1.251-3.8898-0.12535z" style="paint-order:stroke fill markers"/>
+ <path d="m53.527 27.31 2.5239-2.9086 0.28681 0.92283-2.5599 2.8033z" style="paint-order:stroke fill markers"/>
+ <path d="m52.93 25.495 2.5239-2.9086 0.28681 0.92283-2.5599 2.8033z" style="paint-order:stroke fill markers"/>
+ <path d="m52.295 23.339 2.5239-2.9086 0.28681 0.92283-2.5599 2.8033z" style="paint-order:stroke fill markers"/>
+ <path d="m51.57 21.294 2.5239-2.9086 0.28681 0.92283-2.5599 2.8033z" style="paint-order:stroke fill markers"/>
+ </g>
+ <path transform="rotate(72.564)" d="m38.737-47.529h1.7258c0.31879 0 0.57543 0.25664 0.57543 0.57543v4.1004l-2.8767 0.06131v-4.1617c0-0.31879 0.25664-0.57543 0.57543-0.57543z" fill="#fefefe" stroke="#05a7eb" stroke-linecap="round" stroke-linejoin="round" stroke-width=".52917" style="paint-order:stroke fill markers"/>
+ <path transform="rotate(72.564)" d="m33.913-47.46h1.7258c0.31879 0 0.57543 0.25664 0.57543 0.57543v4.0481l-2.8767 0.04595v-4.094c0-0.31879 0.25664-0.57543 0.57543-0.57543z" fill="#fefefe" stroke="#05a7eb" stroke-linecap="round" stroke-linejoin="round" stroke-width=".52917" style="paint-order:stroke fill markers"/>
+ </g>
+ <g>
+ <path d="m58.887 27.243a1.5865 1.323 0 0 1-1.5865 1.323 1.5865 1.323 0 0 1-1.5865-1.323 1.5865 1.323 0 0 1 1.5865-1.323 1.5865 1.323 0 0 1 1.5865 1.323z" fill="#cde7f8" style="paint-order:stroke fill markers"/>
+ <path d="m58.243 27.035a1.2343 0.96157 0 0 1-1.2343 0.96157 1.2343 0.96157 0 0 1-1.2343-0.96157 1.2343 0.96157 0 0 1 1.2343-0.96157 1.2343 0.96157 0 0 1 1.2343 0.96157z" fill="#fefefe" style="paint-order:stroke fill markers"/>
+ <path d="m56.264 48.179a1.1964 0.93291 0 0 1-1.1964 0.93291 1.1964 0.93291 0 0 1-1.1964-0.93291 1.1964 0.93291 0 0 1 1.1964-0.93291 1.1964 0.93291 0 0 1 1.1964 0.93291z" fill="#fefefe" style="paint-order:stroke fill markers"/>
+ </g>
+</svg>
diff --git a/comm/mail/themes/shared/mail/illustrations/sloth.svg b/comm/mail/themes/shared/mail/illustrations/sloth.svg
new file mode 100644
index 0000000000..c5567d4025
--- /dev/null
+++ b/comm/mail/themes/shared/mail/illustrations/sloth.svg
@@ -0,0 +1,88 @@
+<!-- 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 version="1.1" width="300" height="300" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <linearGradient id="a" x1="-300.02" x2="547.14" y1="-272.74" y2="574.42" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#ccfbff" offset="0"/>
+ <stop stop-color="#c9e4ff" offset="1"/>
+ </linearGradient>
+ <linearGradient id="b" x1="-18.672" x2="279.8" y1="23.78" y2="322.26" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#00c8d7" offset="0"/>
+ <stop stop-color="#0a84ff" offset="1"/>
+ </linearGradient>
+ </defs>
+ <path d="m265.34 100.32h-23.336c-2.043-3.774-7.272-12.2-13.831-13.453-8.612-1.642-10.329 6.943-10.329 6.943s-5.724-14.88-20.206-12.906c-16.237 2.211-8.589 19.3-8.589 19.3h-24.443l5.931 0.115h-5.939a1 1 0 0 0 0 2h100.74a1 1 0 0 0 0-2z" fill="#fff" fill-opacity=".70321"/>
+ <path d="M111.942 71.45h-13.19c-1.1-2.054-4.033-6.851-7.731-7.556-4.789-.913-5.744 3.861-5.744 3.861s-3.183-8.276-11.236-7.179c-9.03 1.229-4.776 10.735-4.776 10.735H55.671l7.159.139h-6.91a1 1 0 0 0 0 2h56.022a1 1 0 0 0 0-2z" fill="#fff" fill-opacity=".56654"/>
+ <g fill="#eaeaee">
+ <g fill-opacity=".40078">
+ <path d="m235.97 169.58h-69.687a1 1 0 0 1 0-2h69.687a1 1 0 0 1 0 2z"/>
+ <path d="M226.512 163.154h-30.853a.5.5 0 0 1 0-1h30.854a.5.5 0 0 1 0 1z"/>
+ <path d="M251.077 175.007h-1a.5.5 0 0 1 0-1h1a.5.5 0 0 1 0 1zm-5 0h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1zm-11 0h-12a.5.5 0 0 1 0-1h12a.5.5 0 0 1 0 1zm-21 0h-1a.5.5 0 0 1 0-1h1a.5.5 0 0 1 0 1zm-5 0h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1zm-11 0h-12a.5.5 0 0 1 0-1h12a.5.5 0 0 1 0 1zm-21 0h-1a.5.5 0 0 1 0-1h1a.5.5 0 0 1 0 1zm-5 0h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1zm-11 0h-12a.5.5 0 0 1 0-1h12a.5.5 0 0 1 0 1z"/>
+ </g>
+ <path d="m240.91 210.47h-148.28a1 1 0 0 1 0-2h148.28a1 1 0 1 1 0 2z" fill-opacity=".35787"/>
+ </g>
+ <ellipse cx="81.399" cy="227.48" rx="15.603" ry="4.632" fill="#dedede" fill-opacity=".58413"/>
+ <g fill="#eaeaee" fill-opacity=".4">
+ <path d="M68.86 210.47H41.206a1 1 0 0 1 0-2H68.86a1 1 0 0 1 0 2z"/>
+ <path d="m95.206 125.27h-49.919a1 1 0 0 1 0-2h49.919a1 1 0 0 1 0 2z"/>
+ <path d="M86.084 119.993h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1zm-11 0h-12a.5.5 0 0 1 0-1h12a.5.5 0 0 1 0 1z"/>
+ </g>
+ <path d="m165.03 96.856h24.444s-7.649-17.093 8.589-19.3c14.482-1.972 20.205 12.91 20.205 12.91s1.717-8.585 10.329-6.943c8.492 1.619 14.761 14.022 14.761 14.022h21.285" fill="none" stroke="#eaeaee" stroke-dasharray="12 8 3 4 1 9" stroke-linecap="round" stroke-linejoin="round" stroke-opacity=".43393"/>
+ <path d="M56.089 69.222h13.593s-4.253-9.505 4.776-10.735c8.053-1.1 11.236 7.179 11.236 7.179s.955-4.774 5.744-3.861c4.722.9 8.209 7.24 8.209 7.24h11.837" fill="none" stroke="#eaeaee" stroke-dasharray="12 8 3 4 1 9" stroke-linecap="round" stroke-linejoin="round" stroke-opacity=".69501"/>
+ <ellipse cx="149.14" cy="242.9" rx="45.72" ry="9.272" fill="#fefeff" fill-opacity=".48001"/>
+ <path d="M209.9 164.086c3.013-9.515 1.374-19.8-4.615-28.958a45.642 45.642 0 0 0-13.924-13.5 6.243 6.243 0 0 0-4.588-4.872 6.223 6.223 0 0 0-4.553-3.595 6.212 6.212 0 0 0-5.383-3.988 6.522 6.522 0 0 0-.436-.016 6.252 6.252 0 0 0-3.955 1.389 6.211 6.211 0 0 0-.867-.061 6.242 6.242 0 0 0-5.513 3.311q-.946-.037-1.882-.037a45.681 45.681 0 0 0-12.117 1.6c-.062-8.393 1.088-19.17 5.862-29.058a12.031 12.031 0 0 0-10.82-17.255 12.046 12.046 0 0 0-10.836 6.8c-9.762 20.224-9.2 41.788-6.872 56.692a31.125 31.125 0 0 0-3.714 14.724 15.666 15.666 0 0 0-.636 1.638l-.814.521c-1.485.29-2.917.638-4.281 1.039a14.748 14.748 0 0 1-2.456-5.59l-.239-4.606a5.728 5.728 0 0 0-3.769-5.1q-.092-.035-.188-.067a5.749 5.749 0 0 0-1.627-6.85 12.379 12.379 0 0 0-2.9-1.732 15.685 15.685 0 0 0-6.68-1.57 12.767 12.767 0 0 0-4.5.8c-5.209 1.954-8.633 6.666-10.178 14.005-1.818 8.637 2.748 27.443 18.278 40.566 5.772 7.177 17.305 12.187 30.22 13.106 1.437.1 2.885.154 4.307.154a53.486 53.486 0 0 0 13.294-1.619 43.673 43.673 0 0 1-.329 11.819 11.877 11.877 0 0 0 .371 5.606 15.988 15.988 0 0 1-3.391 5.385 6.39 6.39 0 0 0-.307 9.009 6.459 6.459 0 0 0 .909.8 6.359 6.359 0 0 0 6.21 4.88 6.416 6.416 0 0 0 2.374-.452 6.376 6.376 0 0 0 7.382-1.615c3.211-3.7 4.993-8.546 5.964-12.321a12.025 12.025 0 0 0 4.18-7.179 68.453 68.453 0 0 0 .4-19.879 43.989 43.989 0 0 0 11.457-2.63c.49.046.981.068 1.469.068q.652 0 1.3-.054a6.244 6.244 0 0 0 8.974-1.6 6.251 6.251 0 0 0 5.171-8.579l-.383-.937a15.7 15.7 0 0 0 .415-1.681 31.279 31.279 0 0 0 4.216-8.511z" fill="#fff" fill-opacity=".80233"/>
+ <g fill="url(#a)">
+ <path d="m201.45 136.98a45.33 45.33 0 0 0-52.633-16.478c-8.95 3.593-15.307 10.133-17.9 18.415-5.317 16.983 7.121 36.049 27.725 42.5a46.43 46.43 0 0 0 13.856 2.148c15.476 0 29.041-8.1 33.157-21.25 2.591-8.282 1.098-17.28-4.205-25.335z"/>
+ <path d="m161.54 205.62a2.514 2.514 0 0 0-0.771-0.121 2.524 2.524 0 0 0-2.41 1.758 47.907 47.907 0 0 1-2.917 7.05 9.367 9.367 0 0 1-2.409 3.035 2.53 2.53 0 0 0 2.66 4.3 13.056 13.056 0 0 0 4.165-4.868 52.016 52.016 0 0 0 3.318-7.97 2.535 2.535 0 0 0-1.636-3.184z"/>
+ <path d="m164.2 209.06a2.528 2.528 0 0 0-3.182 1.638 48.521 48.521 0 0 1-2.917 7.05 9.375 9.375 0 0 1-2.412 3.035 2.53 2.53 0 0 0 2.662 4.3 13.063 13.063 0 0 0 4.165-4.866 52.056 52.056 0 0 0 3.319-7.971 2.535 2.535 0 0 0-1.635-3.186z"/>
+ <path d="M169.179 209.558a2.513 2.513 0 0 0-1.611-1.075 2.542 2.542 0 0 0-.489-.048 2.537 2.537 0 0 0-2.484 2.037 47.923 47.923 0 0 1-2.094 7.336 9.379 9.379 0 0 1-2.048 3.291 2.529 2.529 0 0 0 3.133 3.971 13.016 13.016 0 0 0 3.584-5.312 51.568 51.568 0 0 0 2.389-8.3 2.513 2.513 0 0 0-.38-1.9z"/>
+ <path d="m190.9 181.29a10.39 10.39 0 0 1-6.11-1.989l-6.7-4.847a10.947 10.947 0 0 1-2.518-15.06 10.448 10.448 0 0 1 14.756-2.57l6.7 4.847a10.947 10.947 0 0 1 2.518 15.06 10.507 10.507 0 0 1-8.646 4.559z"/>
+ <path d="M185.675 122.091a1 1 0 0 0-1.058-.317l-3.187.962.318-3.422a1 1 0 0 0-1.683-.819l-1.856 1.754-1.17-5.146a1 1 0 0 0-1.909-.137l-1.625 4.225-1.34-2.95a1 1 0 0 0-1.9.29c-.216 1.727-.876 7.053-.985 8.508l-.848 1.462a1 1 0 0 0 .58 1.46l11.626 3.462a1 1 0 0 0 1.174-.5l3.984-7.732a1 1 0 0 0-.121-1.1z"/>
+ <path d="M135.176 158.7l-2.414-6.577a1.5 1.5 0 0 0-2.216-.745l-4.127 2.642a1.5 1.5 0 0 0 .3 2.673l2.768 1-1.276 1.617a1.5 1.5 0 0 0 1.178 2.429 1.455 1.455 0 0 0 .341-.039l4.381-1.025a1.5 1.5 0 0 0 1.068-1.978z"/>
+ <path d="M200.937 176.924l-1.854-4.537a1.491 1.491 0 0 0-.96-.868 1.511 1.511 0 0 0-1.282.2l-5.761 3.989a1.5 1.5 0 0 0-.267 2.231l2.995 3.361a1.5 1.5 0 0 0 1.12.5 1.5 1.5 0 0 0 1.5-1.583l-.113-2.056 2.84.771a1.5 1.5 0 0 0 1.785-2.014z"/>
+ <path d="M159.611 159.487c-5.6-3.72-13.21-6.09-21.425-6.675a54.688 54.688 0 0 0-3.871-.138c-15.618 0-27.884 6.871-28.532 15.982-.351 4.933 2.636 9.762 8.411 13.6 5.6 3.72 13.21 6.09 21.425 6.675 1.291.092 2.594.138 3.873.138 15.617 0 27.883-6.871 28.532-15.982.349-4.936-2.638-9.765-8.413-13.6z"/>
+ <path d="M124.666 177.47a4.742 4.742 0 0 0-2.313-2.828c-18.9-10.14-22.139-28.077-21.316-31.987.505-2.4 1.184-2.936 1.32-3.021a.589.589 0 0 0 .175-.055 1.012 1.012 0 0 0 .424-.317c.641-.579 2-2.067 5.575-5.963a1.009 1.009 0 0 0-.136-1.488 12.481 12.481 0 0 0-6.611-2.174 8.2 8.2 0 0 0-2.887.51c-3.59 1.346-6.013 4.893-7.2 10.542-1.374 6.526 2.394 29.625 26.144 42.367a4.774 4.774 0 0 0 6.827-5.585z"/>
+ <path d="M155.655 145.652a7.9 7.9 0 0 0-6.32-3.121 7.941 7.941 0 0 0-4.518 1.4 7.444 7.444 0 0 0-3.174 4.94 7.292 7.292 0 0 0 1.363 5.588c.189.255 18.886 25.834 14.472 50.251a7.288 7.288 0 0 0 1.232 5.528 7.762 7.762 0 0 0 5.12 3.188 8.12 8.12 0 0 0 1.335.111 7.706 7.706 0 0 0 7.671-6.235c5.564-30.792-16.25-60.402-17.181-61.65z"/>
+ <path d="M122.878 156.521q-.132 0-.265.007c-.21.014-.4.021-.589.021-7.346 0-9.405-10.133-9.584-11.093l-.261-4.963a1.009 1.009 0 0 0-1.74-.642l-6.672 7.018a1.009 1.009 0 0 0-.264.863c1.427 8.49 7.125 18.4 18.49 18.4.4 0 .809-.013 1.22-.039a4.8 4.8 0 0 0 3.327-1.629 4.744 4.744 0 0 0 1.183-3.461 4.837 4.837 0 0 0-4.845-4.482z"/>
+ <path d="M154.67 78.331a7.593 7.593 0 0 0-4.039-4.394 7.972 7.972 0 0 0-10.422 3.537c-15.809 31.64-3.755 67.22-3.234 68.719a7.8 7.8 0 0 0 7.418 5.159 8.1 8.1 0 0 0 2.466-.387 7.688 7.688 0 0 0 4.584-3.892 7.291 7.291 0 0 0 .372-5.691c-.106-.308-10.519-31.3 2.489-57.333a7.288 7.288 0 0 0 .366-5.718z"/>
+ </g>
+ <g fill="#f9f9fa">
+ <path d="M144.51 178c-8.129-4.344-13.41-12.143-13.455-19.869a1 1 0 0 0-1.107-.988c-10.364 1.124-17.6 6.019-17.6 11.905 0 6.8 9.618 12.135 21.9 12.135a37.451 37.451 0 0 0 10.069-1.34 1 1 0 0 0 .2-1.844z"/>
+ <path d="M178.859 150.443l.9-1.739a1 1 0 0 0-1.178-1.415l-4.394 1.327.438-4.705a1 1 0 0 0-1.683-.82l-2.677 2.529-1.578-6.942a1 1 0 0 0-.9-.776 1.011 1.011 0 0 0-1 .639l-2.274 5.915-1.949-4.292a1 1 0 0 0-1.9.29 964.54 964.54 0 0 0-.505 4.108c-11.4-4.874-19.225-9.7-21.771-1.659-.125.395-.235.8-.337 1.215a9.531 9.531 0 0 1 1.743-.172l7.761-.024h.03a9.492 9.492 0 0 1 5.762 17.04c-4.371-1.722-7.326-2.554-8.124-.037a6.264 6.264 0 0 0-.276 2l-2.441.008a24.511 24.511 0 0 1-1.91-2.542.5.5 0 0 0-.851.525 28.4 28.4 0 0 0 5.095 5.823q.628.63 1.318 1.241c.624.607 1.3 1.221 2.028 1.827 2.317 2.521 6.1 4.97 11.886 6.8 6.258 1.982 10.941 2.077 14.3 1.2a27.867 27.867 0 0 0 7.433-1.248 13.317 13.317 0 0 0 2.518-.96c.122-.06.209-.1.264-.124a.5.5 0 0 0-.316-.948 3.916 3.916 0 0 0-.386.173 12.409 12.409 0 0 1-2.354.9l-2.384-1.689a6.64 6.64 0 0 0 1.011-1.926c.769-2.428-1.889-3.463-6.173-4.527a10 10 0 0 1 15.415-10.781l6.333 4.487a10.027 10.027 0 0 1 1.221 1.035c.079-.224.168-.448.239-.671 2.515-7.943-6.418-8.584-18.334-11.085z"/>
+ </g>
+ <g fill="url(#b)">
+ <path d="M147.105 74.29a6.777 6.777 0 0 1 6.093 9.723c-6.372 13.2-7 27.638-6.034 38.767a35.29 35.29 0 0 1 2.353-1.046 39.658 39.658 0 0 1 14.664-2.727 44.406 44.406 0 0 1 6.055.419c.142-1.165.272-2.213.347-2.817a1 1 0 0 1 1.9-.29l1.34 2.95 1.625-4.225a1 1 0 0 1 .94-.641h.065a1 1 0 0 1 .9.776l1.17 5.146 1.856-1.754a1 1 0 0 1 1.683.819l-.295 3.169c.113.047.223.1.335.144l2.828-.854a.991.991 0 0 1 .288-.043 1 1 0 0 1 .89 1.458l-.553 1.073A40.9 40.9 0 0 1 200.89 138c5.11 7.816 6.531 16.517 4 24.5a26.118 26.118 0 0 1-4.221 8.054 10.53 10.53 0 0 1-1.05 3.772l1.17 2.864a1 1 0 0 1-.926 1.379.966.966 0 0 1-.261-.036l-2.713-.736c-.244.2-.495.394-.755.571l.108 1.959a1 1 0 0 1-1.745.72l-1.178-1.322a10.589 10.589 0 0 1-5.228.258 39.5 39.5 0 0 1-15.265 2.981q-.832 0-1.668-.033a65.709 65.709 0 0 1 .545 24.057 6.753 6.753 0 0 1-3.656 4.88c-.621 3.04-2.1 8.412-5.255 12.047a1.128 1.128 0 1 1-1.7-1.479c2.39-2.752 3.742-6.752 4.482-9.855a6.238 6.238 0 0 1-.544.024 6.756 6.756 0 0 1-.834-.053c-.958 3.015-2.981 8.111-6.453 11.36a1.116 1.116 0 0 1-.766.291 1.135 1.135 0 0 1-.83-.346 1.128 1.128 0 0 1 .055-1.592c3.065-2.867 4.928-7.565 5.827-10.372a6.769 6.769 0 0 1-1.105-.681 25.01 25.01 0 0 1-5.63 9.032 1.128 1.128 0 0 1-1.542-1.647c2.683-2.509 4.445-6.409 5.471-9.322a6.73 6.73 0 0 1-.864-4.6 52.712 52.712 0 0 0-.623-19.714 43.539 43.539 0 0 1-17.515 3.365c-1.3 0-2.611-.047-3.934-.14-11.783-.838-22.043-5.258-26.8-11.541-14.73-12.177-18.3-29.328-16.935-35.812 1.151-5.468 3.467-8.889 6.883-10.17a7.522 7.522 0 0 1 2.655-.471 11.751 11.751 0 0 1 6.272 2.129.5.5 0 0 1 .067.737c-4.039 4.409-5.322 5.786-5.815 6.224a.486.486 0 0 1-.047.058 10.287 10.287 0 0 0-1.689 3.318c-.794 2.193.414 11.566 7.831 21.016a14.5 14.5 0 0 1 2.117-2.5c-3.636-3.525-5.712-9.427-6.395-13.521a.5.5 0 0 1 .129-.424l6.588-7.006a.5.5 0 0 1 .364-.159.485.485 0 0 1 .172.032.5.5 0 0 1 .327.443l.26 5c.054.286 1.512 8.332 6.76 10.759a40.21 40.21 0 0 1 7.643-1.982.993.993 0 0 1 .382-.47l2.582-1.653a10.523 10.523 0 0 1 1.317-3.71 25.958 25.958 0 0 1 3.924-14.945c-2.288-13.3-3.642-35.193 6.117-55.411a6.8 6.8 0 0 1 6.107-3.833m0-2a8.8 8.8 0 0 0-7.909 4.964c-9.6 19.9-8.838 41.235-6.411 55.882a27.923 27.923 0 0 0-3.845 14.857 12.418 12.418 0 0 0-1.1 2.974l-1.862 1.192a2.936 2.936 0 0 0-.409.315 42.5 42.5 0 0 0-6.374 1.636c-3.467-2.2-4.749-7.91-4.933-8.818l-.252-4.861a2.491 2.491 0 0 0-1.638-2.218 2.44 2.44 0 0 0-.859-.153 2.509 2.509 0 0 0-1.821.789l-6.588 7.005a2.475 2.475 0 0 0-.273.348 8.137 8.137 0 0 1-.072-2.866 9.7 9.7 0 0 1 1.295-2.661l.011-.012c.6-.561 1.842-1.893 5.839-6.256a2.5 2.5 0 0 0-.347-3.691 13.627 13.627 0 0 0-7.468-2.525 9.532 9.532 0 0 0-3.357.6c-4.1 1.537-6.837 5.451-8.138 11.631-1.445 6.865 2.2 24.905 17.441 37.62 5.151 6.627 15.889 11.271 28.107 12.14a55.8 55.8 0 0 0 4.076.145 47.332 47.332 0 0 0 16-2.6 49.245 49.245 0 0 1 .167 16.607 8.721 8.721 0 0 0 .655 5.085 21.128 21.128 0 0 1-4.657 7.723 3.112 3.112 0 0 0 1.519 5.338 3.166 3.166 0 0 0-.058.511 3.137 3.137 0 0 0 5.264 2.385q.167-.157.331-.318a3.13 3.13 0 0 0 4.854.185c3.18-3.667 4.781-8.834 5.522-12.08a8.779 8.779 0 0 0 3.859-5.826 66.713 66.713 0 0 0-.114-22.372 41.259 41.259 0 0 0 14.755-2.906 12.588 12.588 0 0 0 4.315-.094l.373.419a3 3 0 0 0 5.236-2.142l.839.228a2.93 2.93 0 0 0 .785.106 3 3 0 0 0 2.778-4.134l-.849-2.077a12.43 12.43 0 0 0 .805-3.043 28.07 28.07 0 0 0 4.2-8.213c2.712-8.567 1.208-17.871-4.237-26.2a42.593 42.593 0 0 0-14.413-13.424 3 3 0 0 0-3.8-3.544l-.337.1.043-.463a3 3 0 0 0-4.324-2.965l-.426-1.875a2.984 2.984 0 0 0-2.709-2.327 3.298 3.298 0 0 0-.208-.007 2.986 2.986 0 0 0-2.809 1.921l-.052.135a3 3 0 0 0-4.934 1.9l-.106.851a45.995 45.995 0 0 0-4.312-.2 41.52 41.52 0 0 0-15.224 2.8c-.551-9.739.237-22.9 6.043-34.923a8.781 8.781 0 0 0-7.894-12.592z"/>
+ <path d="M201.082 171.875h-.028a.5.5 0 0 1-.472-.526 13.519 13.519 0 0 0-1.958-7 .5.5 0 1 1 .841-.541 14.3 14.3 0 0 1 2.116 7.6.5.5 0 0 1-.499.467z"/>
+ <path d="M130.643 148.528a.5.5 0 0 1-.406-.791c1.734-2.425 5.456-3.655 5.613-3.706a.5.5 0 0 1 .309.951c-.036.012-3.568 1.182-5.109 3.337a.5.5 0 0 1-.407.209z"/>
+ <path d="M184.377 126.738a.5.5 0 0 1-.372-.834c.572-.639 2.077-2.676 1.5-3.632-.484-.8-2.621.081-3.58.636a.5.5 0 0 1-.742-.528c.257-1.324.354-3.466-.258-3.857a.264.264 0 0 0-.235-.03c-.555.153-1.336 1.295-1.714 2.058a.5.5 0 0 1-.942-.151c-.392-2.705-1.323-5.791-2.031-5.892-.021 0-.076-.012-.177.065-.738.565-1.315 3.218-1.5 4.757a.5.5 0 0 1-.431.435.515.515 0 0 1-.529-.309c-.7-1.729-1.931-3.588-2.5-3.544-.009 0-.046.028-.093.107-.611 1.019-.2 4.857.018 5.394a.512.512 0 0 1-.252.655.486.486 0 0 1-.647-.219c-.278-.557-.808-4.824-.012-6.282a1.129 1.129 0 0 1 .879-.649c1.124-.127 2.166 1.38 2.848 2.674.28-1.367.793-3.18 1.62-3.812a1.223 1.223 0 0 1 .927-.262c1.425.2 2.259 3.345 2.654 5.4a3.488 3.488 0 0 1 1.625-1.392 1.247 1.247 0 0 1 1.042.152c1.032.661.993 2.647.849 3.909 1.2-.539 3.233-1.185 4.047.167 1.06 1.757-1.336 4.508-1.613 4.815a.5.5 0 0 1-.381.169z"/>
+ <path d="M130.109 161.852a3.206 3.206 0 0 1-1.385-.28 1.435 1.435 0 0 1-.863-1.084 3.969 3.969 0 0 1 1.2-2.733c-1.424-.448-3.732-1.375-3.751-2.663-.022-1.481 3.985-3.6 5.71-4.441a.5.5 0 1 1 .438.9c-2.727 1.332-5.133 2.98-5.148 3.528.008.539 2.012 1.47 3.8 1.94a.5.5 0 0 1 .221.842c-.674.656-1.578 1.856-1.477 2.468.016.1.066.228.308.343 1.108.53 3.093-.221 3.765-.554a.5.5 0 0 1 .447.895 8.44 8.44 0 0 1-3.265.839z"/>
+ <path d="M195.266 182.317c-1.464 0-3.507-2.879-4.366-4.2a.5.5 0 1 1 .838-.545c1.5 2.3 3.13 3.9 3.528 3.745.514-.2.617-1.951.387-3.4a.5.5 0 0 1 .667-.547c1.947.723 3.724.963 3.957.693.2-.487-.784-3.2-2.154-5.923a.5.5 0 0 1 .894-.449c.845 1.678 2.744 5.7 2.142 6.842-.57 1.077-2.9.534-4.423.04.1 1.208.075 3.222-1.111 3.679a1 1 0 0 1-.359.065z"/>
+ <path d="M153.084 218.985a.5.5 0 0 1-.342-.865c4.939-4.621 6.88-13.587 6.9-13.677a.5.5 0 1 1 .979.207c-.081.38-2.027 9.366-7.194 14.2a.5.5 0 0 1-.343.135z"/>
+ <path d="M155.771 221.9a.5.5 0 0 1-.342-.865c4.935-4.616 6.88-13.588 6.9-13.678a.5.5 0 0 1 .979.207c-.081.38-2.027 9.367-7.194 14.2a.5.5 0 0 1-.343.136z"/>
+ <path d="M160.647 222.024a.5.5 0 0 1-.377-.828c4.433-5.107 5.434-14.227 5.443-14.317a.487.487 0 0 1 .549-.445.5.5 0 0 1 .445.549c-.04.387-1.044 9.526-5.683 14.87a.5.5 0 0 1-.377.171z"/>
+ <path d="M110.254 161.469a.5.5 0 0 1-.369-.837 23.915 23.915 0 0 1 9.942-5.786.5.5 0 1 1 .3.953 23.391 23.391 0 0 0-9.507 5.508.5.5 0 0 1-.366.162z"/>
+ <path d="M107.264 169.781a.5.5 0 0 1-.472-.335c-.049-.139-1.155-3.438 1.8-7.218a.5.5 0 0 1 .787.617c-2.624 3.353-1.657 6.241-1.647 6.271a.5.5 0 0 1-.307.637.489.489 0 0 1-.161.028z"/>
+ <path d="M155.876 186.7a.5.5 0 0 1-.2-.96 22.264 22.264 0 0 0 3.824-2.14.5.5 0 1 1 .588.809 22.934 22.934 0 0 1-4.018 2.251.5.5 0 0 1-.194.04z"/>
+ <path d="M143.722 96.658a.494.494 0 0 1-.223-.053.5.5 0 0 1-.224-.671c2.965-5.928 1.7-14.842 1.683-14.931a.5.5 0 0 1 .989-.146c.056.378 1.329 9.313-1.778 15.524a.5.5 0 0 1-.447.277z"/>
+ <path d="M147.293 99.246a.494.494 0 0 1-.223-.053.5.5 0 0 1-.224-.671c2.965-5.928 1.7-14.841 1.684-14.93a.5.5 0 0 1 .989-.146c.056.378 1.329 9.312-1.778 15.523a.5.5 0 0 1-.448.277z"/>
+ <path d="M151.985 97.6a.487.487 0 0 1-.176-.032.5.5 0 0 1-.292-.644c2.334-6.2.148-14.938.126-15.025a.5.5 0 1 1 .969-.248c.095.369 2.287 9.124-.159 15.625a.5.5 0 0 1-.468.324z"/>
+ <path d="M188.641 167.773a1 1 0 0 1-.962-.729 3.532 3.532 0 0 0-2.26-2.218 3.574 3.574 0 0 0-3.12.508 1 1 0 1 1-1.271-1.543 5.5 5.5 0 0 1 8.578 2.717 1 1 0 0 1-.965 1.265z"/>
+ <path d="M149.323 155.323a1 1 0 0 1-.962-.729 3.532 3.532 0 0 0-2.26-2.218 3.575 3.575 0 0 0-3.12.508 1 1 0 1 1-1.271-1.543 5.5 5.5 0 0 1 8.578 2.717 1 1 0 0 1-.965 1.265z"/>
+ <path d="M169.2 174.119c-12.839 0-21.089-9.653-21.187-9.771a1 1 0 0 1 1.534-1.284c.108.13 11.038 12.862 27.056 7.905a1 1 0 1 1 .592 1.91 26.9 26.9 0 0 1-7.995 1.24z"/>
+ <path d="M148.308 88.455a5.777 5.777 0 0 1-1.881-.312c-4.221-1.443-5.695-7.407-5.756-7.66a1 1 0 0 1 1.945-.468c.013.052 1.282 5.153 4.462 6.237 1.662.566 3.658-.077 5.939-1.908a1 1 0 0 1 1.252 1.561 9.8 9.8 0 0 1-5.961 2.55z"/>
+ <path d="M143.018 98.25a.987.987 0 0 1-.416-.092 1 1 0 0 1-.493-1.325 29.886 29.886 0 0 0 2.285-9.877 1.016 1.016 0 0 1 1.044-.956 1 1 0 0 1 .955 1.043 31.384 31.384 0 0 1-2.465 10.624 1 1 0 0 1-.91.583z"/>
+ <path d="M146.893 100.375a1 1 0 0 1-.893-1.457c.031-.062 1.778-3.614 2.015-11.323a.987.987 0 0 1 1.03-.97 1 1 0 0 1 .969 1.03c-.254 8.258-2.155 12.022-2.236 12.179a1 1 0 0 1-.885.541z"/>
+ <path d="M151.643 99.5a1 1 0 0 1-.889-1.455 22.246 22.246 0 0 0 1.522-11.677 1 1 0 0 1 1.986-.236 23.772 23.772 0 0 1-1.729 12.824 1 1 0 0 1-.89.544z"/>
+ <path d="M175.977 184c-16.543 0-27.147-5.563-33.13-10.23-6.5-5.074-9.045-10.086-9.15-10.3a.5.5 0 0 1 .9-.446c.1.2 10.341 19.974 41.386 19.974a.5.5 0 0 1 0 1z"/>
+ <path d="M136.393 131.5a.5.5 0 0 1-.424-.764c.178-.285 4.463-7.025 14.763-10.543a.5.5 0 0 1 .323.947c-9.966 3.4-14.2 10.057-14.237 10.123a.5.5 0 0 1-.425.237z"/>
+ <path d="M146.4 151.423a1.358 1.358 0 0 1 .884 1.7l-.918 2.9a1.358 1.358 0 0 1-1.7.884 1.358 1.358 0 0 1-.884-1.7l.918-2.9a1.351 1.351 0 0 1 1.7-.884z"/>
+ <path d="M185.721 163.873a1.358 1.358 0 0 1 .884 1.7l-.918 2.9a1.358 1.358 0 0 1-1.7.884 1.358 1.358 0 0 1-.884-1.7l.918-2.9a1.355 1.355 0 0 1 1.7-.884z"/>
+ <path d="M171.819 164.988c-2.257-2.156-9.975-5.389-15.3-3.991a2.079 2.079 0 0 0-1.469 2.1c-.055 2.165 2 5.019 6.72 6.512a18.025 18.025 0 0 0 5.313.982 6.476 6.476 0 0 0 3.311-.779 4.566 4.566 0 0 0 2.166-3.1 1.965 1.965 0 0 0-.741-1.724z"/>
+ <path d="M121.965 96.833a1 1 0 0 1-1-1v-12.55a1 1 0 0 1 .421-.815L167.6 49.634a1 1 0 0 1 1.158 1.631L122.965 83.8v12.033a1 1 0 0 1-1 1z"/>
+ <path d="M116.318 124.646a1 1 0 0 1-.562-.174l-9.2-6.273a1 1 0 0 1-.437-.826v-15.058a1 1 0 1 1 2 0v14.529l8.765 5.976a1 1 0 0 1-.564 1.826z"/>
+ <path d="m80.489 227.78a2.5 2.5 0 0 1-2.5-2.5v-74.444a2.5 2.5 0 0 1 5 0v74.444a2.5 2.5 0 0 1-2.5 2.5z"/>
+ <path d="M80.492 161.79a2.5 2.5 0 0 0 3.477 3.594l10.508-10.168a42.319 42.319 0 0 1-1.528-5.479zm29.467-25.034a2.5 2.5 0 0 0-3.535-.059l-5.171 5c-.121.289-.245.6-.374.954-.332.917-.3 3.1.371 6.007l8.65-8.37a2.5 2.5 0 0 0 .059-3.532z"/>
+ <path d="M141.381 80.325c-18.7 13.4-25.152 18.462-25.416 18.99-.478.955-.552 2.013-.412 13.112.062 4.906.154 12.212-.093 13.978-1.161 1.589-7.3 8.162-12.858 13.906a2.5 2.5 0 1 0 3.593 3.477c4.786-4.944 12.946-13.5 13.824-15.258.605-1.211.69-3.779.534-16.166-.051-4.016-.112-8.893-.007-10.7 1.871-1.531 15.38-11.247 23.162-16.83.027-.094.058-.2.089-.3-.3-.4-.621-.848-.964-1.366a17.248 17.248 0 0 1-1.452-2.843zm24.034-13.956a2.5 2.5 0 0 0-3.483-.6c-4.667 3.287-8.85 6.24-12.611 8.9a6.883 6.883 0 0 1 .722.3 6.719 6.719 0 0 1 3.129 3.111c4.122-2.923 8.12-5.747 11.639-8.226a2.5 2.5 0 0 0 .605-3.485z"/>
+ </g>
+</svg>
diff --git a/comm/mail/themes/shared/mail/illustrations/sync-devices.svg b/comm/mail/themes/shared/mail/illustrations/sync-devices.svg
new file mode 100644
index 0000000000..9d4f3fb97d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/illustrations/sync-devices.svg
@@ -0,0 +1,49 @@
+<!-- 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 height="2in" viewBox="0 0 315.96 144" width="4.39in" xmlns="http://www.w3.org/2000/svg">
+ <radialGradient id="a" cx="101.36" cy="70.18" gradientUnits="userSpaceOnUse" r="58.29">
+ <stop offset=".27" stop-color="#cdcdd4" stop-opacity="0"/>
+ <stop offset=".46" stop-color="#cdcdd4" stop-opacity=".02"/>
+ <stop offset=".66" stop-color="#cdcdd4" stop-opacity=".08"/>
+ <stop offset=".86" stop-color="#cdcdd4" stop-opacity=".18"/>
+ <stop offset=".9" stop-color="#cdcdd4" stop-opacity=".2"/>
+ </radialGradient>
+ <radialGradient id="b" cx="225.08" cy="70.18" r="58.29" href="#a"/>
+ <linearGradient id="c" gradientUnits="userSpaceOnUse" x1="28.25" x2="93.99" y1="99.19" y2="33.44">
+ <stop offset="0" stop-color="#c689ff"/>
+ <stop offset="1" stop-color="#d74cf0"/>
+ </linearGradient>
+ <linearGradient id="d" gradientUnits="userSpaceOnUse" x1="-5.18" x2="106.41" y1="139.25" y2="27.66">
+ <stop offset=".16" stop-color="#b833e1"/>
+ <stop offset=".96" stop-color="#ff4f5e"/>
+ </linearGradient>
+ <linearGradient id="e" gradientUnits="userSpaceOnUse" x1="96.11" x2="225.52" y1="139" y2="9.59">
+ <stop offset=".28" stop-color="#7542e5"/>
+ <stop offset=".42" stop-color="#824deb"/>
+ <stop offset=".79" stop-color="#a067fa"/>
+ <stop offset="1" stop-color="#ab71ff"/>
+ </linearGradient>
+ <linearGradient id="f" gradientUnits="userSpaceOnUse" x1="221.49" x2="303.29" y1="111.6" y2="29.8">
+ <stop offset=".43" stop-color="#00b3f4"/>
+ <stop offset=".61" stop-color="#00bbf6"/>
+ <stop offset=".89" stop-color="#00d2fc"/>
+ <stop offset="1" stop-color="#0df"/>
+ </linearGradient>
+ <path d="m101.41 128.46a58.3 58.3 0 0 1 -29.09-7.76 3.75 3.75 0 0 1 3.68-6.49 50.75 50.75 0 0 0 25.36 6.79 3.76 3.76 0 0 1 3.77 3.74 3.72 3.72 0 0 1 -3.72 3.72zm14.11-1.85a3.75 3.75 0 0 1 -1-7.36 50.81 50.81 0 0 0 22.75-13.17 3.74 3.74 0 0 1 5.29 5.29 58.22 58.22 0 0 1 -26.1 15.11 3.6 3.6 0 0 1 -.94.13zm-52.67-14.07a3.73 3.73 0 0 1 -2.65-1.09 58.32 58.32 0 0 1 -15.13-26.09 3.74 3.74 0 0 1 7.23-1.94 50.83 50.83 0 0 0 13.19 22.73 3.74 3.74 0 0 1 -2.64 6.39zm85.76-11.36a3.74 3.74 0 0 1 -3.24-5.62 50.78 50.78 0 0 0 6.79-25.38v-.18a3.74 3.74 0 1 1 7.48 0v.14a58.31 58.31 0 0 1 -7.79 29.2 3.73 3.73 0 0 1 -3.24 1.84zm-101.79-27.18a3.7 3.7 0 0 1 -3.75-3.69v-.1a58.33 58.33 0 0 1 7.74-29 3.75 3.75 0 0 1 6.49 3.74 50.77 50.77 0 0 0 -6.74 25.3 3.78 3.78 0 0 1 -3.74 3.75zm107.18-14.39a3.74 3.74 0 0 1 -3.61-2.77 50.73 50.73 0 0 0 -13.24-22.7 3.75 3.75 0 0 1 5.28-5.32 58.23 58.23 0 0 1 15.18 26.06 3.77 3.77 0 0 1 -3.61 4.73zm-91.3-24.16a3.75 3.75 0 0 1 -2.7-6.39 58.26 58.26 0 0 1 26.11-15.15 3.74 3.74 0 1 1 2 7.22 50.92 50.92 0 0 0 -22.76 13.21 3.71 3.71 0 0 1 -2.65 1.11zm65.78-8.86a3.72 3.72 0 0 1 -1.87-.5 50.77 50.77 0 0 0 -25.25-6.71h-.16a3.75 3.75 0 1 1 0-7.49h.08a58.37 58.37 0 0 1 29.06 7.71 3.74 3.74 0 0 1 -1.86 7z" fill="url(#a)"/>
+ <path d="m225.13 128.46a58.24 58.24 0 0 1 -29.13-7.76 3.75 3.75 0 0 1 3.74-6.49 50.71 50.71 0 0 0 25.34 6.79 3.76 3.76 0 0 1 3.77 3.74 3.72 3.72 0 0 1 -3.72 3.72zm14.11-1.85a3.74 3.74 0 0 1 -1-7.36 50.78 50.78 0 0 0 22.76-13.17 3.74 3.74 0 0 1 5.3 5.29 58.33 58.33 0 0 1 -26.1 15.11 3.72 3.72 0 0 1 -.96.13zm-52.67-14.07a3.71 3.71 0 0 1 -2.64-1.09 58.14 58.14 0 0 1 -15.13-26.09 3.74 3.74 0 1 1 7.2-1.94 50.83 50.83 0 0 0 13.19 22.73 3.74 3.74 0 0 1 -2.64 6.39zm85.76-11.36a3.74 3.74 0 0 1 -3.24-5.62 50.78 50.78 0 0 0 6.79-25.38v-.18a3.75 3.75 0 1 1 7.49 0v.14a58.32 58.32 0 0 1 -7.8 29.2 3.73 3.73 0 0 1 -3.24 1.84zm-101.79-27.18a3.69 3.69 0 0 1 -3.74-3.69v-.1a58.32 58.32 0 0 1 7.73-29 3.75 3.75 0 0 1 6.47 3.67 50.77 50.77 0 0 0 -6.74 25.3 3.78 3.78 0 0 1 -3.72 3.82zm107.18-14.39a3.74 3.74 0 0 1 -3.61-2.77 50.81 50.81 0 0 0 -13.23-22.7 3.74 3.74 0 0 1 5.27-5.32 58.34 58.34 0 0 1 15.19 26.06 3.74 3.74 0 0 1 -2.64 4.59 3.63 3.63 0 0 1 -.98.14zm-91.3-24.16a3.74 3.74 0 0 1 -2.65-6.39 58.2 58.2 0 0 1 26.07-15.15 3.74 3.74 0 1 1 1.95 7.22 51 51 0 0 0 -22.72 13.21 3.71 3.71 0 0 1 -2.65 1.11zm65.78-8.86a3.71 3.71 0 0 1 -1.86-.5 50.8 50.8 0 0 0 -25.26-6.71h-.16a3.75 3.75 0 0 1 0-7.49h.08a58.37 58.37 0 0 1 29.07 7.71 3.74 3.74 0 0 1 -1.87 7z" fill="url(#b)"/>
+ <path d="m44.62 94.89h33v9.57h-33z" fill="#ff848b"/>
+ <path d="m21.52 40.17h79.19v52.3h-79.19z" fill="url(#c)"/>
+ <path d="m109.28 97.75h-2.15v-59.43a4.28 4.28 0 0 0 -4.28-4.32h-83.46a4.28 4.28 0 0 0 -4.28 4.28v59.47h-2.11a2.12 2.12 0 0 0 -2.13 2.12v4.31a2.13 2.13 0 0 0 2.13 2.13h96.32a2.13 2.13 0 0 0 2.13-2.13v-4.31a2.12 2.12 0 0 0 -2.17-2.12zm-39.6 4.25h-17.13v-4.25h17.13z" fill="url(#d)"/>
+ <path d="m82 62a.83.83 0 0 0 -.83.83v1a5.77 5.77 0 0 0 -9.76 2.62.83.83 0 0 0 .6 1h.2a.84.84 0 0 0 .81-.63 4.11 4.11 0 0 1 4-3.14 4.06 4.06 0 0 1 3.28 1.62h-1.63a.83.83 0 0 0 -.08 1.7h3.41a.84.84 0 0 0 .83-.83v-3.37a.83.83 0 0 0 -.83-.8zm0 6.17a.83.83 0 0 0 -1 .6 4.11 4.11 0 0 1 -7.3 1.49h1.63a.83.83 0 0 0 0-1.66h-3.33a.83.83 0 0 0 -.83.83v3.32a.83.83 0 1 0 1.66.08v-1a5.75 5.75 0 0 0 9.75-2.62.83.83 0 0 0 -.58-1.07zm-36.25-6.72a6.33 6.33 0 1 0 6.33 6.33 6.33 6.33 0 0 0 -6.33-6.33zm0 11.07a4.75 4.75 0 1 1 4.74-4.74 4.75 4.75 0 0 1 -4.74 4.74zm2.77-4.74h-2.77v-2.78a.4.4 0 0 0 -.75 0v3.17a.4.4 0 0 0 .39.39h3.17a.4.4 0 0 0 0-.79zm9.62 6.1a.85.85 0 0 1 -.85-.85.49.49 0 0 1 0-.12l.47-3.37-2.26-2.42a.85.85 0 0 1 .5-1.42l3.15-.56 1.49-3a.85.85 0 0 1 1.14-.38.87.87 0 0 1 .38.38l1.49 3 3.15.56a.85.85 0 0 1 .69 1 .78.78 0 0 1 -.22.43l-2.27 2.42.48 3.36a.85.85 0 0 1 -.73 1 .83.83 0 0 1 -.51-.09l-2.86-1.52-2.85 1.49a.89.89 0 0 1 -.39.09zm-.64-6.88 1.9 2-.4 2.79 2.37-1.23 2.37 1.23-.38-2.79 1.9-2-2.67-.48-1.21-2.43-1.21 2.43z" fill="#f9f9fa"/>
+ <rect fill="url(#e)" height="69.38" rx="7.65" width="46" x="141.93" y="35.49"/>
+ <path d="m160.58 93.56h8.57v4.28h-8.57z" fill="#ab71ff"/>
+ <path d="m169.91 74a.83.83 0 0 0 -.83.83v1a5.75 5.75 0 0 0 -9.75 2.62.83.83 0 0 0 .6 1 .65.65 0 0 0 .2 0 .83.83 0 0 0 .8-.62 4.11 4.11 0 0 1 7.3-1.48h-1.63a.83.83 0 1 0 -.08 1.66h3.39a.83.83 0 0 0 .83-.83v-3.37a.83.83 0 0 0 -.83-.83zm0 6.17a.84.84 0 0 0 -1 .6 4.13 4.13 0 0 1 -4 3.14 4.06 4.06 0 0 1 -3.28-1.66h1.63a.83.83 0 0 0 0-1.65h-3.26a.83.83 0 0 0 -.83.82v3.31a.83.83 0 1 0 1.66.08v-1a5.76 5.76 0 0 0 9.76-2.62.83.83 0 0 0 -.59-1.04zm-5-36.65a6.33 6.33 0 1 0 6.32 6.32 6.32 6.32 0 0 0 -6.29-6.34zm0 11.07a4.75 4.75 0 1 1 4.74-4.75 4.75 4.75 0 0 1 -4.71 4.73zm2.76-4.75h-2.76v-2.79a.4.4 0 0 0 -.4-.39.39.39 0 0 0 -.39.39v3.17a.38.38 0 0 0 .39.39h3.16a.39.39 0 0 0 .4-.39.4.4 0 0 0 -.37-.4zm-6 21.08a.85.85 0 0 1 -.85-.85s0-.07 0-.11l.47-3.37-2.26-2.43a.85.85 0 0 1 0-1.2 1 1 0 0 1 .44-.22l3.15-.56 1.49-3a.85.85 0 0 1 1.14-.38 1 1 0 0 1 .38.38l1.49 3 3.15.56a.86.86 0 0 1 .69 1 .89.89 0 0 1 -.22.43l-2.26 2.43.47 3.37a.85.85 0 0 1 -.72 1 .88.88 0 0 1 -.51-.09l-2.85-1.49-2.85 1.49a.93.93 0 0 1 -.33.02zm-.61-6.92 1.89 2-.38 2.75 2.37-1.23 2.37 1.24-.39-2.76 1.89-2-2.66-.47-1.21-2.44-1.21 2.44z" fill="#f9f9fa"/>
+ <rect fill="url(#f)" height="65.56" rx="5.76" width="80.89" x="222.47" y="37.4"/>
+ <path d="m292.6 64.85h5.33v10.66h-5.33z" fill="#0df"/>
+ <g fill="#f9f9fa">
+ <path d="m282.65 64.37a.83.83 0 0 0 -.83.83v1a5.75 5.75 0 0 0 -9.75 2.62.83.83 0 0 0 .6 1 .64.64 0 0 0 .2 0 .82.82 0 0 0 .8-.63 4.13 4.13 0 0 1 4-3.14 4.08 4.08 0 0 1 3.33 1.65h-1.63a.82.82 0 0 0 -.87.79.84.84 0 0 0 .79.87h3.39a.83.83 0 0 0 .83-.83v-3.33a.83.83 0 0 0 -.83-.83zm0 6.17a.84.84 0 0 0 -1 .6 4.11 4.11 0 0 1 -7.29 1.49h1.64a.83.83 0 0 0 0-1.63h-3.3a.83.83 0 0 0 -.82.83v3.32a.83.83 0 0 0 1.65.08v-1a5.76 5.76 0 0 0 9.76-2.61.83.83 0 0 0 -.6-1.08z"/>
+ <path d="m246.41 63.85a6.33 6.33 0 1 0 6.33 6.33 6.32 6.32 0 0 0 -6.33-6.33zm0 11.07a4.75 4.75 0 1 1 4.75-4.74 4.74 4.74 0 0 1 -4.75 4.74zm2.77-4.74h-2.77v-2.77a.38.38 0 0 0 -.41-.41.39.39 0 0 0 -.4.39v3.16a.4.4 0 0 0 .4.4h3.16a.4.4 0 1 0 0-.79z"/>
+ <path d="m258.8 76.28a.85.85 0 0 1 -.85-.85s0-.08 0-.12l.47-3.37-2.26-2.43a.85.85 0 0 1 0-1.2.9.9 0 0 1 .44-.21l3.15-.56 1.49-3a.85.85 0 0 1 1.14-.38.87.87 0 0 1 .38.38l1.48 3 3.16.56a.85.85 0 0 1 .69 1 .89.89 0 0 1 -.22.43l-2.26 2.43.47 3.37a.85.85 0 0 1 -.72 1 .88.88 0 0 1 -.51-.09l-2.85-1.48-2.86 1.48a.76.76 0 0 1 -.34.04zm-.63-6.88 1.89 2-.38 2.76 2.37-1.16 2.37 1.24-.42-2.81 1.89-2-2.66-.48-1.21-2.43-1.22 2.43z"/>
+ </g>
+</svg>
diff --git a/comm/mail/themes/shared/mail/imAccounts.css b/comm/mail/themes/shared/mail/imAccounts.css
new file mode 100644
index 0000000000..03b12954be
--- /dev/null
+++ b/comm/mail/themes/shared/mail/imAccounts.css
@@ -0,0 +1,203 @@
+/* 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/. */
+
+/* large parts copied from the addon manager */
+
+#accountManager {
+ padding: 0;
+ margin: 0;
+}
+
+#accountsNotificationBox {
+ margin: 0;
+ appearance: none;
+}
+
+#noAccountScreen {
+ color: FieldText;
+ background-color: Field;
+ overflow: auto;
+ border-block: 1px solid var(--splitter-color);
+}
+
+:root[lwt-tree] #noAccountScreen {
+ color: var(--sidebar-text-color);
+ background-color: var(--sidebar-background-color);
+}
+
+#noAccountBox {
+ max-width: 30em;
+ background: url("chrome://global/skin/icons/info.svg") left 5px no-repeat;
+ background-size: 2.5em;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ margin-inline: 1.5em;
+ padding-inline: 3.5em;
+}
+
+#noAccountInnerBox {
+ opacity: 0.9;
+}
+
+#noAccountTitle {
+ font-size: 2em;
+ font-weight: lighter;
+ line-height: 1.2;
+ margin: 0 0 .3em;
+ padding-bottom: .2em;
+ border-bottom: 1px solid var(--splitter-color);
+}
+
+#noAccountDesc {
+ font-size: 110%;
+ margin-inline: 0;
+}
+
+#accountlist {
+ margin: 0;
+ appearance: none;
+ border-block: 1px solid var(--splitter-color);
+ border-inline-style: none;
+ text-shadow: none;
+}
+
+#bottombuttons {
+ padding: 4px;
+}
+
+/* List Items */
+richlistitem:not([selected="true"]) .account-buttons {
+ display: none;
+}
+
+richlistitem:not([state="connected"]) .connected,
+richlistitem:not([state="connecting"]) .connecting,
+richlistitem:not([state="disconnected"]) .disconnected,
+richlistitem:not([state="disconnecting"]) .disconnecting,
+richlistitem:not([error="true"]) .error,
+richlistitem:not([state="disconnected"]) .error,
+richlistitem[error="true"] .disconnected,
+richlistitem[selected="true"]:not([state="disconnected"]) .connectButton,
+richlistitem[selected="true"][state="disconnected"] .disconnectButton,
+richlistitem[selected="true"][state="disconnecting"] .disconnectButton,
+richlistitem:not([selected="true"]) .addException,
+richlistitem:not([selected="true"]) .autoSignOn,
+richlistitem:not([reconnectPending="true"]) description[anonid="reconnect"]
+{
+ display: none;
+}
+
+richlistitem[state="disconnected"] .accountIcon {
+ opacity: 0.3;
+}
+richlistitem[state="connecting"] .accountIcon,
+richlistitem[state="disconnected"][selected="true"] .accountIcon {
+ opacity: 0.7;
+}
+richlistitem[state="disconnected"]:not([selected="true"]) {
+ color: #999;
+}
+
+richlistitem[error="true"] .accountName {
+ color: rgb(150, 0, 0);
+}
+
+/* When the error message was too long, the buttons were too small */
+richlistitem .account-buttons button {
+ min-height: 1.8em;
+ color: var(--lwt-text-color) !important;
+}
+
+richlistitem .account-buttons {
+ margin-top: 2px;
+}
+
+richlistitem[dragover="down"] {
+ border-bottom: 3px solid var(--selected-item-color);
+}
+
+richlistitem[dragover="up"] {
+ border-top: 3px solid var(--selected-item-color);
+}
+
+:root:not([lwt-tree]) #bottombuttons button,
+:root:not([lwt-tree]) richlistbox > richlistitem button {
+ border: 1px solid var(--toolbarbutton-hover-bordercolor);
+}
+
+:root:not([lwt-tree]) #bottombuttons button:hover,
+:root:not([lwt-tree]) richlistbox > richlistitem button:hover {
+ border: 1px solid var(--toolbarbutton-active-bordercolor);
+}
+
+:root:not([lwt-tree]) #bottombuttons button:hover:active,
+:root:not([lwt-tree]) richlistbox > richlistitem button:hover:active {
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+}
+
+.error {
+ color: #c80000;
+ margin-inline-start: 6px;
+}
+
+.accountName {
+ font-weight: bold;
+}
+
+.accountIcon {
+ width: 32px;
+ height: 32px;
+}
+
+#displayNameAndstatusMessageGrid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 4px;
+ flex: 1 1 auto;
+}
+
+#displayNameAndstatusMessageGrid > * {
+ margin: 0;
+}
+
+#displayNameAndstatusMessageGrid > hr {
+ height: 0;
+ border-block-start: 1px solid hsla(0, 0%, 50%, 0.5);
+ border-block-end: none;
+ border-inline: none;
+}
+
+#displayName[usingDefault]:not([editing]) {
+ color: #999;
+}
+
+/* Add styling same as #statusMessageLabel in chat.css */
+#displayName,
+#statusMessageLabel:not([statusType="offline"]) {
+ cursor: text;
+}
+
+#userIcon {
+ border-color: hsla(0, 0%, 50%, 0.3);
+}
+
+#userIcon:hover {
+ border-color: hsla(0, 0%, 50%, 0.5);
+ background-color: hsla(0, 0%, 50%, 0.3);
+ opacity: .4;
+}
+
+#statusImageStack > #statusTypeIcon {
+ /* Need min-width since #statusTypeIcon overlaps with rule in chat.css. */
+ min-width: 16px;
+ padding-inline: 0;
+ appearance: none;
+ background: transparent;
+ box-shadow: none;
+ border: none;
+}
+
+#statusTypeIcon dropmarker {
+ display: none;
+}
diff --git a/comm/mail/themes/shared/mail/imMenulist.css b/comm/mail/themes/shared/mail/imMenulist.css
new file mode 100644
index 0000000000..5506c64bf1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/imMenulist.css
@@ -0,0 +1,13 @@
+/* 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/. */
+
+
+/* Fix the icons in the menulist by undoing things from menu.css */
+menulist > menupopup > .menuitem-iconic > .menu-iconic-left {
+ display: flex !important;
+}
+
+menulist > menupopup > .menuitem-iconic > .menu-iconic-text {
+ margin-left: 2px !important;
+}
diff --git a/comm/mail/themes/shared/mail/imRichlistbox.css b/comm/mail/themes/shared/mail/imRichlistbox.css
new file mode 100644
index 0000000000..9d41490b2c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/imRichlistbox.css
@@ -0,0 +1,15 @@
+/* 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/. */
+
+/* Mostly copied from the download manager */
+
+/* List Items */
+richlistitem {
+ padding-top: 6px;
+ padding-bottom: 6px;
+ padding-inline-start: 4px;
+ padding-inline-end: 4px;
+ min-height: 25px;
+ border-bottom: 1px solid var(--splitter-color);
+}
diff --git a/comm/mail/themes/shared/mail/images/account-watermark-light.png b/comm/mail/themes/shared/mail/images/account-watermark-light.png
new file mode 100644
index 0000000000..2d72178a55
--- /dev/null
+++ b/comm/mail/themes/shared/mail/images/account-watermark-light.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/images/account-watermark.png b/comm/mail/themes/shared/mail/images/account-watermark.png
new file mode 100644
index 0000000000..28192849d1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/images/account-watermark.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/images/pendingpaint.png b/comm/mail/themes/shared/mail/images/pendingpaint.png
new file mode 100644
index 0000000000..1a97feeeb3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/images/pendingpaint.png
Binary files differ
diff --git a/comm/mail/themes/shared/mail/inContentDialog.css b/comm/mail/themes/shared/mail/inContentDialog.css
new file mode 100644
index 0000000000..de2dd459aa
--- /dev/null
+++ b/comm/mail/themes/shared/mail/inContentDialog.css
@@ -0,0 +1,335 @@
+/* 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 url("chrome://messenger/skin/colors.css");
+
+dialog {
+ --dialog-text-color: var(--arrowpanel-color);
+ --dialog-background-color: var(--arrowpanel-background);
+ --dialog-box-text-color: #181920;
+ --dialog-box-background-color: #fff;
+ --dialog-box-border-color: rgba(0, 0, 0, 0.3);
+ --dialog-box-border-hover-color: rgba(128, 128, 128, 0.8);
+ --dialog-button-text-color-hover: currentColor;
+ --dialog-button-background-color: color-mix(in srgb, currentColor 13%, transparent);
+ --dialog-button-background-color-hover: color-mix(in srgb, currentColor 17%, transparent);
+ --dialog-button-background-color-active: color-mix(in srgb, currentColor 30%, transparent);
+ --dialog-highlight-color: var(--in-content-primary-button-background, var(--selected-item-color));
+ --dialog-highlight-text-color: var(--in-content-primary-button-text-color, var(--selected-item-text-color));
+ --dialog-primary-background-hover: color-mix(in srgb, var(--dialog-highlight-color) 85%, black);
+ --dialog-primary-background-active: color-mix(in srgb, var(--dialog-highlight-color) 78%, black);
+}
+
+@media (prefers-color-scheme: dark) {
+ dialog {
+ --dialog-text-color: rgb(251, 251, 254);
+ --dialog-background-color: #2a2a2e;
+ --dialog-box-text-color: #f9f9fa;
+ --dialog-box-background-color: #353537;
+ --dialog-box-border-color: hsla(0, 0%, 70%, 0.4);
+ --dialog-box-border-hover-color: hsla(0, 0%, 70%, 0.5);
+ --dialog-highlight-color: #45a1ff;
+ --dialog-highlight-text-color: rgb(43, 42, 51);
+ }
+}
+
+@media (prefers-contrast) {
+ dialog {
+ --dialog-box-text-color: color-mix(in srgb, currentColor 41%, transparent);
+ --dialog-box-background-color: color-mix(in srgb, currentColor 41%, transparent);
+ --dialog-box-border-color: -moz-DialogText;
+ --dialog-box-border-hover-color: SelectedItemText;
+ --dialog-button-text-color-hover: SelectedItemText;
+ --dialog-button-background-color-hover: SelectedItem;
+ --dialog-button-background-color-active: SelectedItem;
+ border-color: WindowText !important;
+ }
+}
+
+/* Global overrides */
+
+dialog *[hidden] {
+ display: none !important;
+}
+
+.reset-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+/* General UI */
+
+dialog {
+ border: 1px solid transparent;
+ border-radius: var(--arrowpanel-border-radius);
+ background-color: var(--dialog-background-color);
+ color: var(--dialog-text-color);
+ padding: 15px;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.5);
+}
+
+dialog::backdrop {
+ background: rgba(0, 0, 0, 0.5);
+}
+
+dialog :focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: 1px;
+}
+
+.dialog-title {
+ display: flex;
+ align-items: center;
+ margin-block: 0 15px;
+ margin-inline: 15px;
+ font-size: 1.4em;
+ font-weight: 500;
+}
+
+.dialog-container {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.dialog-container.vertical {
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.dialog-header-image {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, var(--dialog-highlight-color) 20%, transparent);
+ stroke: var(--dialog-highlight-color);
+ margin-inline-end: 12px;
+ height: 32px;
+}
+
+.dialog-header-image.small {
+ height: 16px;
+}
+
+.dialog-description {
+ margin-block: 0.2em;
+ line-height: 1.4em;
+}
+
+/* Warning dialog */
+
+dialog.dialog-critical {
+ box-shadow: 0 2px 20px -8px var(--red-70);
+}
+
+.warning-title {
+ display: flex;
+ align-items: center;
+ margin-top: 0;
+ background-color: var(--red-60);
+ color: var(--color-white);
+ border-radius: var(--button-border-radius);
+ padding: 6px;
+}
+
+.warning-icon {
+ fill: color-mix(in srgb, var(--color-white) 20%, transparent);
+ stroke: var(--color-white);
+}
+
+.insecure-section h3 {
+ margin-top: 0;
+}
+
+.insecure-section-description {
+ font-size: 1.05rem;
+ line-height: 1.5em;
+}
+
+.dialog-footnote {
+ margin-inline: 6px;
+ font-size: 1.05rem;
+ line-height: 1.4em;
+}
+
+/* Typography */
+
+dialog h1 {
+ margin-block-start: 0;
+ font-size: 1.17em;
+}
+
+dialog p {
+ margin-block-end: 6px;
+ font-size: 1.1em;
+ line-height: 1.4em;
+}
+
+dialog .tip-caption {
+ opacity: 0.8;
+ font-size: 1em;
+}
+
+/* Lists */
+
+dialog .radio-list {
+ margin-block: 12px;
+}
+
+dialog .radio-list li {
+ margin-block-end: 12px;
+}
+
+/* Radio button */
+
+dialog input[type="radio"] {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ border: 1px solid var(--dialog-box-border-color);
+ border-radius: 100%;
+ margin-block: 2px;
+ margin-inline: 0 6px;
+ background-color: var(--dialog-box-background-color);
+ background-position: center;
+}
+
+dialog input[type="radio"]:enabled:hover {
+ background-color: var(--dialog-button-background-color-hover);
+}
+
+dialog input[type="radio"]:enabled:hover:active {
+ background-color: var(--dialog-button-background-color-active);
+}
+
+dialog input[type="radio"]:checked {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ color: var(--dialog-highlight-text-color);
+ background-color: var(--dialog-highlight-color);
+ background-image: url("chrome://global/skin/icons/radio.svg");
+ border-color: var(--dialog-primary-background-active);
+ color-adjust: exact;
+}
+
+dialog input[type="radio"]:enabled:checked:hover {
+ background-color: var(--dialog-primary-background-hover);
+ border-color: var(--dialog-primary-background-active);
+}
+
+/* Buttons area */
+
+.vertical-buttons-container {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 3px;
+ margin-block-end: 9px;
+ padding: 0;
+}
+
+.dialog-menu-container {
+ list-style-type: none;
+ display: flex;
+ align-items: center;
+ margin-block-end: 0;
+ margin-inline: 0;
+ padding: 2px 10px;
+ justify-content: end;
+ background-color: var(--dialog-background-color);
+ position: sticky;
+ bottom: 0;
+}
+
+.dialog-menu-container.two-columns {
+ justify-content: space-between;
+}
+
+.dialog-menu-container.menu-in-body {
+ margin-inline: -10px;
+}
+
+/* Buttons */
+
+dialog .button-link {
+ appearance: none;
+ background-color: transparent !important;
+ color: LinkText;
+ border-style: none;
+ padding: 0 3px;
+ margin: 0;
+ font-weight: 600;
+ cursor: pointer;
+ min-height: auto;
+}
+
+dialog .button-link:hover {
+ text-decoration: underline;
+}
+
+dialog button:not([disabled]):hover {
+ background-color: var(--dialog-button-background-color-hover);
+ color: var(--dialog-button-text-color-hover);
+}
+
+dialog button:not([disabled]):hover:active {
+ background-color: var(--dialog-button-background-color-active);
+}
+
+dialog button.primary {
+ background-color: var(--dialog-highlight-color);
+ color: var(--dialog-highlight-text-color) !important;
+}
+
+dialog button.primary:not([disabled]):hover {
+ background-color: var(--dialog-primary-background-hover);
+}
+
+dialog button.primary:not([disabled]):hover:active {
+ background-color: var(--dialog-primary-background-active);
+}
+
+dialog button[disabled] {
+ opacity: 0.4;
+}
+
+/* Loading states */
+
+@keyframes loading-animation {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-100%); }
+}
+
+@keyframes loading-animation-rtl {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(100%); }
+}
+
+span.loading-inline {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ display: inline-block;
+ position: relative;
+ overflow: hidden;
+ height: 16px;
+ width: 16px;
+ color: var(--selected-item-color);
+ vertical-align: sub;
+}
+
+span.loading-inline::after {
+ position: absolute;
+ content: '';
+ background-image: url("chrome://messenger/skin/icons/loading.svg");
+ background-position: right center;
+ background-repeat: no-repeat;
+ width: 480px;
+ height: 100%;
+ animation: loading-animation 1.05s steps(30) infinite;
+}
+
+span.loading-inline:dir(rtl)::after {
+ background-position-x: left;
+ animation: loading-animation-rtl 1.05s steps(30) infinite;
+}
diff --git a/comm/mail/themes/shared/mail/input-fields.css b/comm/mail/themes/shared/mail/input-fields.css
new file mode 100644
index 0000000000..9a0cb04217
--- /dev/null
+++ b/comm/mail/themes/shared/mail/input-fields.css
@@ -0,0 +1,51 @@
+/* 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 html url("http://www.w3.org/1999/xhtml");
+
+html|input.plain {
+ background-color: transparent;
+}
+
+html|input.input-filefield {
+ padding-inline-start: 20px !important;
+}
+
+html|input.input-filefield:-moz-locale-dir(rtl) {
+ background-position-x: right 2px;
+}
+
+html|input.input-inline-color {
+ margin: 2px 4px;
+}
+
+.input-container {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+}
+
+.input-container.items-stretch {
+ align-items: stretch;
+}
+
+.input-container.wrap-container {
+ flex-wrap: wrap;
+}
+
+.input-container html|input:not([type="number"],[type="color"]),
+.input-container .label-inline,
+.input-container .spacer-inline {
+ flex: 1;
+}
+
+html|input[type="number"].input-number-inline {
+ flex: 1 !important;
+ padding: 2px 2px 3px;
+ margin-inline-start: 2px;
+}
+
+html|input[type="number"]::-moz-number-spin-box {
+ padding-inline-start: 4px;
+}
diff --git a/comm/mail/themes/shared/mail/joinchat.css b/comm/mail/themes/shared/mail/joinchat.css
new file mode 100644
index 0000000000..c2073c4c68
--- /dev/null
+++ b/comm/mail/themes/shared/mail/joinchat.css
@@ -0,0 +1,25 @@
+/* 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/. */
+
+#joinChatGrid {
+ display: inline-grid;
+ grid-template-columns: auto auto auto;
+}
+
+#joinChatGrid textbox, menulist {
+ width: 100%;
+}
+
+#joinChatGrid div {
+ display: flex;
+ align-items: center;
+}
+
+#joinChatGrid input:not([type="number"],[type="color"]) {
+ flex: 1;
+}
+
+.required {
+ visibility: hidden;
+}
diff --git a/comm/mail/themes/shared/mail/layout.css b/comm/mail/themes/shared/mail/layout.css
new file mode 100644
index 0000000000..cac83df115
--- /dev/null
+++ b/comm/mail/themes/shared/mail/layout.css
@@ -0,0 +1,81 @@
+/* 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 url("chrome://messenger/skin/colors.css");
+
+/* This file defines the colors used for the main layout of the application.
+ * Colors that define a containers for content or thier text color should go
+ * here. Colors that define widgets or content should not */
+
+/* Background 0 should be used for the main content or page background */
+/* Background 1 should be used for center panes and secondary sidebars */
+/* Background 2 should be used for primary sidebars */
+/* Background 3 should be used for blocks of content inside the main content */
+/* Background 4 should be used for elements inside the main content */
+
+/* Color 0 should be used when the text needs more emphasis */
+/* Color 1 should be used for the main text color */
+/* Color 2 should be used when the text needs less emphasis */
+/* Color 2 should be used when the text need even less emphasis */
+
+/* Border 0 should be used for separation from main layout sections */
+/* Border 1 should be used when the separation is part of the element */
+
+:host,
+:root {
+ --layout-background-0: var(--color-white);
+ --layout-background-1: var(--color-gray-05);
+ --layout-background-2: var(--color-gray-10);
+ --layout-background-3: var(--color-gray-20);
+ --layout-background-4: var(--color-gray-30);
+
+ --layout-color-0: var(--color-black);
+ --layout-color-1: var(--color-gray-90);
+ --layout-color-2: var(--color-gray-70);
+ --layout-color-3: var(--color-gray-50);
+
+ --layout-border-0: var(--color-gray-30);
+ --layout-border-1: var(--color-gray-40);
+ --layout-border-2: var(--color-gray-50);
+}
+
+@media (prefers-color-scheme: dark) {
+ :host,
+ :root {
+ --layout-background-0: var(--color-gray-90);
+ --layout-background-1: var(--color-gray-80);
+ --layout-background-2: var(--color-gray-70);
+ --layout-background-3: var(--color-gray-60);
+ --layout-background-4: var(--color-gray-50);
+
+ --layout-color-0: var(--color-white);
+ --layout-color-1: var(--color-gray-10);
+ --layout-color-2: var(--color-gray-30);
+ --layout-color-3: var(--color-gray-50);
+
+ --layout-border-0: var(--color-gray-70);
+ --layout-border-1: var(--color-gray-60);
+ --layout-border-2: var(--color-gray-50);
+ }
+}
+
+@media (prefers-contrast) {
+ :host,
+ :root:not(:-moz-lwtheme) {
+ --layout-background-0: Window;
+ --layout-background-1: -moz-Dialog;
+ --layout-background-2: transparent;
+ --layout-background-3: transparent;
+ --layout-background-4: transparent;
+
+ --layout-color-0: WindowText;
+ --layout-color-1: -moz-DialogText;
+ --layout-color-2: currentColor;
+ --layout-color-3: currentColor;
+
+ --layout-border-0: currentColor;
+ --layout-border-1: currentColor;
+ --layout-border-2: currentColor;
+ }
+}
diff --git a/comm/mail/themes/shared/mail/mailWindow1.css b/comm/mail/themes/shared/mail/mailWindow1.css
new file mode 100644
index 0000000000..22306ae1b7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/mailWindow1.css
@@ -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/. */
+
+#messengerBody {
+ flex: 1;
+}
+
+:root:not([tabsintitlebar]) .titlebar-buttonbox-container {
+ visibility: collapse;
+}
+
+#tabmail:-moz-lwtheme {
+ text-shadow: none;
+}
+
+#messengerBox {
+ color: -moz-DialogText;
+}
+
+#messagepaneboxwrapper {
+ overflow: hidden;
+}
+
+#folderUnreadCol,
+#folderTotalCol,
+#folderSizeCol {
+ text-align: end;
+}
+
+#folderNameCol {
+ flex: 5 5;
+}
+
+/* ::::: thread decoration ::::: */
+
+treechildren::-moz-tree-cell-text(read) {
+ font-weight: normal;
+}
+
+treechildren::-moz-tree-cell-text(unread) {
+ font-weight: bold;
+}
+
+:root[lwt-tree-brighttext] treechildren::-moz-tree-cell-text(untagged, unread) {
+ color: #fff !important;
+}
+
+/* on a collapsed thread, if the top level message is read, but the thread has
+ * unread children, underline the text. 4.x mac did this, very slick
+ */
+treechildren::-moz-tree-cell-text(container, closed, hasUnread, read) {
+ text-decoration: underline;
+}
+
+/* ..... grouped by sort header rows ..... */
+
+treechildren::-moz-tree-row(dummy) {
+ background-color: var(--row-grouped-header-bg-color) !important;
+ border-color: transparent !important;
+ padding-inline-start: 2px;
+ margin-bottom: 1px;
+}
+
+treechildren::-moz-tree-row(dummy, selected, focus) {
+ background-color: var(--row-grouped-header-bg-color-selected) !important;
+}
+
+treechildren::-moz-tree-cell-text(dummy) {
+ font-weight: bold;
+}
+
+:root[lwt-tree] treechildren::-moz-tree-cell-text(dummy, selected) {
+ color: var(--sidebar-highlight-text-color);
+}
+
+/* ..... message pane adjustments ..... */
+
+/* We need to hide overflow in messagepanebox, so that resizing doesn't spill
+ header content over the statusbar. */
+
+#messagepanebox {
+ flex: 2 2;
+ overflow: hidden;
+ background-color: var(--layout-background-1);
+}
+
+/* splitter adjustments */
+
+#folderpane_splitter,
+#threadpane-splitter:not([orient="vertical"]) {
+ appearance: none;
+ border-width: 0;
+ min-width: 0;
+ width: 5px;
+ background-color: transparent;
+ margin-top: 0;
+ position: relative;
+ z-index: 10;
+ transition: border-width .3s ease-in;
+}
+
+#folderpane_splitter {
+ border-inline-start: 1px solid var(--splitter-color);
+ /* make only the splitter border visible */
+ margin-inline-end: -4px;
+}
+
+#folderpane_splitter[state="collapsed"]:hover {
+ border-inline-start: 4px solid var(--selected-item-color);
+}
+
+#threadpane-splitter:not([orient="vertical"]) {
+ border-inline-end: 1px solid var(--splitter-color);
+ /* make only the splitter border visible */
+ margin-inline-start: -4px;
+}
+
+#threadpane-splitter[state="collapsed"]:not([orient="vertical"]):hover {
+ border-inline-end: 4px solid var(--selected-item-color);
+}
+
+#threadpane-splitter {
+ appearance: none;
+ border-width: 0;
+ border-bottom: 1px solid var(--splitter-color);
+ min-height: 0;
+ height: 5px;
+ background-color: transparent;
+ margin-top: -5px;
+ position: relative;
+ z-index: 10;
+ transition: border-width .3s ease-in;
+}
+
+#threadpane-splitter[state="collapsed"] {
+ transition: border-color .3s;
+}
+
+#threadpane-splitter[state="collapsed"]:hover {
+ border-bottom: 4px solid var(--selected-item-color);
+}
+
+/* Quick-Filter-Bar */
+:root[lwt-tree] #quick-filter-bar:-moz-lwtheme {
+ background-color: var(--toolbar-bgcolor);
+ color: var(--lwt-text-color);
+}
+
+/* virtual folder list dialog */
+#virtualFolderList:not(:-moz-lwtheme) {
+ background-color: -moz-dialog;
+}
+
+#folderPickerTree > treechildren::-moz-tree-image(folderNameCol) {
+ -moz-context-properties: fill, fill-opacity, stroke;
+ fill: color-mix(in srgb, var(--default) 20%, transparent);
+ stroke: var(--default);
+}
+
+#folderPickerTree > treechildren::-moz-tree-image(folderNameCol, isServer-true) {
+ fill: color-mix(in srgb, var(--primary) 20%, transparent);
+ stroke: var(--primary);
+}
+
+#folderPickerTree > treechildren::-moz-tree-image(selectedColumn) {
+ width: 14px;
+ height: 14px;
+ list-style-image: url("chrome://messenger/skin/icons/checkbox.svg");
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ fill: currentColor;
+ stroke: currentColor;
+ fill-opacity: 0;
+ stroke-opacity: 0;
+}
+
+#folderPickerTree > treechildren::-moz-tree-image(selectedColumn, selected-true) {
+ fill-opacity: 1;
+}
+
+#folderPickerTree > treechildren::-moz-tree-image(selectedColumn, selected, focus) {
+ fill: var(--select-focus-text-color);
+ stroke: var(--select-focus-text-color);
+}
+
+/* Global notification popup */
+
+#notification-popup {
+ --panel-background: var(--arrowpanel-background);
+ --panel-color: var(--arrowpanel-color);
+ --panel-border-color: var(--arrowpanel-border-color);
+ --panel-border-radius: var(--arrowpanel-border-radius);
+ --panel-padding: var(--arrowpanel-padding);
+ margin-block: 0;
+}
+
+#notification-popup::part(content) {
+ padding: 0;
+ overflow: hidden; /* Don't let panel content overflow the border */
+}
diff --git a/comm/mail/themes/shared/mail/menulist.css b/comm/mail/themes/shared/mail/menulist.css
new file mode 100644
index 0000000000..72a4fb1763
--- /dev/null
+++ b/comm/mail/themes/shared/mail/menulist.css
@@ -0,0 +1,24 @@
+/* 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 html url("http://www.w3.org/1999/xhtml");
+
+menulist[is="menulist-editable"]:not([editable="true"])::part(text-input),
+menulist[is="menulist-editable"][editable="true"]::part(label-box) {
+ display: none;
+}
+
+menulist > html|input {
+ margin: 0;
+}
+
+menulist::part(text-input) {
+ appearance: none;
+ background: transparent;
+ flex: 1;
+ margin: 0;
+ border: none;
+ padding: 0;
+ font: inherit;
+}
diff --git a/comm/mail/themes/shared/mail/message-bar.css b/comm/mail/themes/shared/mail/message-bar.css
new file mode 100644
index 0000000000..a4a7a02e10
--- /dev/null
+++ b/comm/mail/themes/shared/mail/message-bar.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/contextMenu.css");
+
+:host {
+ --icon-size: 16px;
+ --close-icon-size: 32px;
+ --in-content-button-color: #0c0c0d;
+ --in-content-button-border: #c2c2c3;
+ --in-content-button-background: #e2e2e3;
+ --in-content-button-text-color-hover: #0c0c0d;
+ --in-content-button-background-hover: #d2d2d3;
+ --in-content-button-border-active: #a2a2a3;
+ --in-content-button-text-color-active: #0c0c0d;
+ --in-content-button-background-active: #c2c2c3;
+ --panel-border-radius: 3px;
+}
+
+@media (prefers-contrast) {
+ :host {
+ --in-content-box-info-background: -moz-Dialog;
+ --in-content-button-color: ButtonText;
+ --in-content-button-border: ThreeDLightShadow;
+ --in-content-button-background: ButtonFace;
+ --in-content-button-text-color-hover: SelectedItemText;
+ --in-content-button-background-hover: SelectedItem;
+ --in-content-button-border-active: ThreeDFace;
+ --in-content-button-text-color-active: SelectedItemText;
+ --in-content-button-background-active: SelectedItem;
+ }
+}
+
+:host([message-bar-type=infobar]) {
+ --close-icon-size: 24px;
+}
+
+/* MessageBar colors by message type */
+/* Colors from: https://design.firefox.com/photon/components/message-bars.html#type-specific-style */
+
+:host {
+ --message-bar-background-color: var(--in-content-page-background);
+ --message-bar-text-color: var(--in-content-text-color);
+ --message-bar-icon-url: var(--icon-info);
+ /* The default values of --in-content-button* are sufficient, even for dark themes */
+}
+
+:host([type=warning]) {
+ --message-bar-background-color: #fff160;
+ --message-bar-text-color: #000;
+ --message-bar-icon-url: var(--icon-warning);
+}
+
+@media (prefers-color-scheme: dark) {
+ :host {
+ --in-content-box-info-background: var(--color-gray-60);
+ }
+
+ :host([type=warning]) {
+ --message-bar-background-color: #ffe900;
+ }
+}
+
+:host([type=success]) {
+ --message-bar-background-color: var(--green-60);
+ --message-bar-text-color: #ffffff;
+ --message-bar-icon-url: var(--icon-check);
+}
+
+:host([type=error]),
+:host([type=critical]) {
+ --message-bar-background-color: var(--red-60);
+ --message-bar-text-color: #fff;
+}
+:host([type=error]),
+:host([type=critical]) {
+ --message-bar-icon-url: var(--icon-error-circle);
+}
+
+:host([value=attachmentReminder]) {
+ --message-bar-icon-url: var(--icon-attachment);
+}
+
+:host([value=draftMsgContent]) {
+ --message-bar-icon-url: var(--icon-pencil);
+}
+
+:host([value=junkContent]) {
+ --message-bar-icon-url: var(--icon-spam);
+}
+
+:host([value=remoteContent]) {
+ --message-bar-icon-url: var(--icon-photo-ban);
+}
+
+:host {
+ border-radius: 4px;
+}
+
+/* Make the host to behave as a block by default, but allow hidden to hide it. */
+:host(:not([hidden])) {
+ display: block;
+}
+
+::slotted(button) {
+ /* Enforce micro-button width. */
+ min-width: -moz-fit-content !important;
+}
+
+/* MessageBar Grid Layout */
+
+.container {
+ background: var(--message-bar-background-color);
+ color: var(--message-bar-text-color);
+ padding: 4px 8px;
+ position: relative;
+ border-radius: 4px;
+ display: flex;
+ /* Ensure that the message bar shadow dom elements are vertically aligned. */
+ align-items: center;
+}
+
+:host([align="center"]) .container {
+ justify-content: center;
+}
+
+.content {
+ margin: 0 4px;
+ display: inline-block;
+ /* Ensure that the message bar content is vertically aligned. */
+ align-items: center;
+ /* Ensure that the message bar content is wrapped. */
+ word-break: break-word;
+}
+
+/* MessageBar icon style */
+
+.icon {
+ padding: 4px;
+ width: var(--icon-size);
+ height: var(--icon-size);
+ flex-shrink: 0;
+}
+
+.icon::after {
+ appearance: none;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb ,currentColor 20%, transparent);
+ stroke: currentColor;
+ content: var(--message-bar-icon-url);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ :host([value=accountSetupLoading]) .icon {
+ position: relative;
+ overflow: hidden;
+ }
+
+ :host([value=accountSetupLoading]) .icon::after {
+ position: absolute;
+ content: '';
+ background-image: url("chrome://messenger/skin/icons/loading.svg");
+ background-position: left center;
+ background-repeat: no-repeat;
+ width: 480px;
+ height: 100%;
+ animation: loading-animation 1.05s steps(30) infinite;
+ }
+
+ :host([value=accountSetupLoading]) .icon:-moz-locale-dir(rtl)::after {
+ background-position-x: right;
+ animation: loading-animation-rtl 1.05s steps(30) infinite;
+ }
+
+ @keyframes loading-animation {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-100%); }
+ }
+
+ @keyframes loading-animation-rtl {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(100%); }
+ }
+}
+
+/* Close icon styles */
+
+:host(:not([dismissable])) .close {
+ display: none;
+}
+
+.close {
+ color: inherit !important; /* Override common.css */
+ background-image: var(--icon-close);
+ background-repeat: no-repeat;
+ background-position: center center;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb ,currentColor 20%, transparent);
+ stroke: currentColor;
+ min-width: auto;
+ min-height: auto;
+ width: var(--close-icon-size);
+ height: var(--close-icon-size);
+ margin: 0;
+ padding: 0;
+ flex-shrink: 0;
+}
+
+button.ghost-button:not(.semi-transparent):enabled:hover {
+ background-color: color-mix(in srgb, currentColor 15%, transparent);
+}
+
+button.ghost-button:not(.semi-transparent):enabled:hover:active {
+ background-color: color-mix(in srgb, currentColor 25%, transparent);
+}
+
+@media not (prefers-contrast) {
+ .container.infobar {
+ box-shadow: 0 1px 2px rgba(58, 57, 68, 0.3);
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :host([type="info"]) .container.infobar {
+ --message-bar-background-color: var(--in-content-box-info-background);
+ }
+}
+
+:host([message-bar-type=infobar]:first-of-type) {
+ margin-top: 4px;
+}
+
+:host([message-bar-type=infobar]) {
+ margin: 0 4px 4px;
+}
+
+.container.infobar {
+ /* Don't let lwthemes set a text-shadow. */
+ text-shadow: none;
+ padding: 0;
+}
+
+.container.infobar {
+ align-items: center;
+}
+
+/* Infobars styling. */
+.notification-content {
+ flex-grow: 1;
+ flex-wrap: wrap;
+ display: flex;
+ margin: 0;
+ margin-inline-start: 8px;
+}
+
+:host([value=accountSetupLoading]) .notification-content {
+ flex-grow: unset;
+}
+
+.notification-message {
+ flex-grow: 1;
+ min-height: 16px;
+ margin-inline-end: 20px;
+ margin-block: 6px;
+}
+
+.notification-button-container,
+.notification-message {
+ display: flex;
+}
+
+:host(:not([dismissable])) .notification-message {
+ margin-inline-end: 6px;
+}
+
+:host([type=success]) .notification-message {
+ font-weight: 500;
+}
+
+.notification-message > hbox {
+ flex: 100 100;
+}
+
+.notification-button {
+ border: 1px solid var(--in-content-button-border);
+ border-radius: 3px;
+ color: var(--in-content-button-color);
+}
+
+.notification-button:hover:active {
+ border-color: var(--in-content-button-border-active);
+}
+
+/* Button variations */
+
+button.button-menu-list {
+ appearance: none;
+ padding-inline-end: 18px;
+ background-repeat: no-repeat;
+ background-position: right 4px center;
+ background-size: 12px;
+ background-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+button:-moz-locale-dir(rtl).button-menu-list {
+ background-position-x: left 4px;
+}
+
+toolbarbutton.notification-button[is="toolbarbutton-menu-button"] {
+ align-items: center;
+ margin: 4px 6px;
+ padding-block: 0;
+ padding-inline: 0 11px;
+ background-color: var(--in-content-button-background);
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"] > toolbarbutton {
+ margin-block: 0;
+ margin-inline: 0 2px;
+ min-height: 22px;
+ border-start-start-radius: 2px;
+ border-start-end-radius: 2px;
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+ padding: 2px 15px;
+ font-weight: inherit;
+ background-color: var(--in-content-button-background);
+ border-width: 0;
+ border-inline-end: 1px solid var(--in-content-button-border);
+}
+
+toolbarbutton.notification-button[is="toolbarbutton-menu-button"]:hover,
+toolbarbutton.notification-button[is="toolbarbutton-menu-button"] > toolbarbutton:hover {
+ background-color: var(--in-content-button-background-hover);
+ color: var(--in-content-button-text-color-hover);
+ border-color: var(--in-content-button-border-color-hover);
+}
+
+toolbarbutton.notification-button[is="toolbarbutton-menu-button"]:hover:active,
+toolbarbutton.notification-button[is="toolbarbutton-menu-button"] > toolbarbutton:hover:active {
+ background-color: var(--in-content-button-background-active);
+ color: var(--in-content-button-text-color-active);
+ border-color: var(--in-content-button-border-color-active);
+}
+
+toolbarbutton.notification-button[is="toolbarbutton-menu-button"] > dropmarker {
+ appearance: none;
+ list-style-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ margin-inline-end: -5px;
+ padding-inline-start: 5px;
+ border-start-start-radius: 0;
+ border-start-end-radius: 0;
+ pointer-events: none;
+ display: inline-flex;
+}
+
+.close {
+ margin: 4px 8px;
+ background-size: 16px;
+}
+
+.notification-button.small-button {
+ font-size: inherit;
+ font-weight: 400;
+}
+
+.notification-button:first-of-type {
+ /* When the buttons wrap to their own line we want to match the 8px on the message. */
+ margin-inline-start: 0;
+}
+
+strong {
+ font-weight: 600;
+}
+
+.text-link:hover {
+ cursor: pointer;
+}
+
+.infobar > .icon {
+ padding: 0;
+ margin: 8px 0;
+}
+
+.infobar > .icon,
+:host([type=system]) .notification-content {
+ margin-inline-start: 12px;
+}
+
+:host([type=system]) .icon {
+ display: none;
+}
+
+:host([type=info]) .icon {
+ color: #0090ed;
+}
+
+@media (prefers-color-scheme: dark) {
+ :host {
+ --in-content-button-color: #f9f9fa;
+ --in-content-button-border: #828283;
+ --in-content-button-background: #636364;
+ --in-content-button-text-color-hover: #f9f9fa;
+ --in-content-button-background-hover: #777778;
+ --in-content-button-text-color-active: #f9f9fa;
+ --in-content-button-border-active: #878788;
+ --in-content-button-background-active: #878788;
+ --in-content-focus-outline-color: #45a1ff;
+ }
+
+ :host([type=info]) .icon {
+ color: #45a1ff;
+ }
+}
+
+:host([value=draftMsgContent]) .icon {
+ color: inherit;
+}
+
+/* Attachment reminder variations */
+
+#attachmentKeywords {
+ font-weight: bold;
+ margin-inline-start: 3px;
+ text-decoration: underline;
+ cursor: pointer;
+}
diff --git a/comm/mail/themes/shared/mail/messageBody.css b/comm/mail/themes/shared/mail/messageBody.css
new file mode 100644
index 0000000000..f38ee53fa9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/messageBody.css
@@ -0,0 +1,201 @@
+/* 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/. */
+
+/* ===== messageBody.css =================================================
+ == Styles for the body of a mail message.
+ ======================================================================= */
+@import url(chrome://messenger/skin/messageQuotes.css);
+
+@namespace url("http://www.w3.org/1999/xhtml");
+
+mailattachcount {
+ display: none;
+}
+
+/* :::: message header ::::: */
+
+@media screen {
+ .moz-main-header,
+ .moz-main-header + br,
+ .moz-print-only {
+ display: none;
+ }
+}
+
+.moz-header-part1 {
+ background-color: #EFEFEF;
+}
+
+.moz-header-part2,
+.moz-header-part3 {
+ background-color: #DEDEDE;
+}
+
+.moz-header-display-name {
+ display: inline;
+ font-weight: bold;
+ white-space: pre;
+}
+
+/* ::::: message text, incl. quotes ::::: */
+
+.moz-text-flowed blockquote,
+.moz-text-plain blockquote {
+ margin: 0;
+}
+
+.moz-text-plain pre {
+ margin: 0;
+ font-family: inherit;
+}
+
+.moz-text-plain[wrap="true"] {
+ white-space: pre-wrap;
+}
+
+.moz-text-plain[wrap="false"] {
+ white-space: pre;
+}
+
+.moz-text-plain[wrap="flow"] .moz-txt-sig {
+ white-space: pre-wrap;
+}
+
+.moz-text-plain[graphical-quote="false"] blockquote {
+ border-style: none;
+ padding: 0;
+}
+
+.moz-text-plain[graphical-quote="true"] .moz-txt-citetags {
+ display: none;
+}
+
+.moz-txt-underscore {
+ text-decoration: underline;
+}
+
+/* Ignore named pages when printing. */
+@media print {
+ * { page: auto !important; }
+}
+
+/* ::::: images ::::: */
+
+img[overflowing]:not([shrinktofit]) {
+ cursor: zoom-out;
+ width: auto !important;
+}
+
+img[overflowing][shrinktofit] {
+ cursor: zoom-in;
+ max-width: 100% !important;
+ height: auto !important;
+}
+
+@media print {
+ img[shrinktofit] {
+ max-width: 100% !important;
+ height: auto !important;
+ }
+}
+
+.moz-attached-image-container {
+ text-align: center;
+}
+
+/* ::::: vcard ::::: */
+
+.moz-vcard-table {
+ margin-top: 10px;
+ border-radius: 3px;
+ box-shadow: 0 1px 3px hsla(0, 0%, 50%, 0.6);
+}
+
+.moz-vcard-property {
+ font-size: 80%;
+ color: gray;
+}
+
+.moz-vcard-badge {
+ margin-top: 2px;
+ height: 32px;
+ width: 32px;
+ background-image: url("chrome://messenger/skin/addressbook/icons/contact-generic.svg");
+ background-size: 100% 100%;
+ background-color: transparent;
+ display: block;
+ opacity: .7;
+ cursor: pointer;
+}
+
+.moz-vcard-badge:hover {
+ opacity: .8;
+}
+
+.moz-vcard-badge:active {
+ opacity: 1;
+}
+
+.moz-vcard-badge:focus {
+ outline: none;
+}
+
+/* Old style feeds, pre Tb3.0 */
+#_mailrssiframe {
+ position: fixed;
+ display: block;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
+/* Attachment display styling (for inline attachments and printing) */
+.moz-mime-attachment-header {
+ border-style: none;
+ border-top: 1px solid GrayText;
+ padding-block-start: 0.625em;
+ padding-block-end: 0.2em;
+}
+
+.moz-mime-attachment-header.moz-print-only {
+ margin-top: 1em;
+}
+
+.moz-mime-attachment-header-name {
+ color: GrayText;
+ font-size: 80%;
+ font-family: Arial, sans-serif;
+}
+
+.moz-mime-attachment-wrap {
+ padding: 0 1em;
+}
+
+.moz-mime-attachment-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+}
+
+.moz-mime-attachment-table tr + tr > td {
+ border-top: 1px solid GrayText;
+ padding-top: 0.25em;
+}
+
+.moz-mime-attachment-file {
+ word-wrap: break-word;
+}
+
+.moz-mime-attachment-size {
+ vertical-align: top;
+ width: 10ch;
+ text-align: right;
+}
+
+.moz-mime-attachment-file,
+.moz-mime-attachment-size {
+ padding: 0 0 0.25em;
+}
diff --git a/comm/mail/themes/shared/mail/messageHeader.css b/comm/mail/themes/shared/mail/messageHeader.css
new file mode 100644
index 0000000000..588c618545
--- /dev/null
+++ b/comm/mail/themes/shared/mail/messageHeader.css
@@ -0,0 +1,1002 @@
+/* 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 html url("http://www.w3.org/1999/xhtml");
+
+:root {
+ /* This variable is used for both inline and block spacing in order to keep
+ a visual consistency for recipients and simple fields alignment. */
+ --message-header-field-offset: 2px;
+ --recipient-avatar-size: 26px;
+ --recipient-avatar-placeholder-size: 16px;
+ --recipient-avatar-margin-block-start: -1px;
+
+ --recipient-avatar-color: var(--color-gray-50);
+ --recipient-avatar-background-color: var(--color-gray-30);
+
+ --recipient-multi-line-gap: 2px;
+ --message-header-label-opacity: 0.7;
+}
+
+:root[uidensity="compact"] {
+ --recipient-avatar-size: 20px;
+ --recipient-avatar-placeholder-size: contain;
+ --recipient-avatar-margin-block-start: -2px;
+ --recipient-multi-line-gap: 0;
+}
+
+:root[uidensity="touch"] {
+ --recipient-avatar-size: 32px;
+ --recipient-avatar-placeholder-size: 16px;
+ --recipient-multi-line-gap: 3px;
+}
+
+:root .message-header-show-big-avatar {
+ --recipient-avatar-size: 40px;
+}
+
+:root[uidensity="compact"] .message-header-show-big-avatar {
+ --recipient-avatar-size: 36px;
+}
+
+:root[uidensity="touch"] .message-header-show-big-avatar {
+ --recipient-avatar-size: 44px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --recipient-avatar-color: var(--color-gray-40);
+ --recipient-avatar-background-color: var(--color-gray-60);
+ }
+}
+
+#messagepanebox {
+ color: var(--layout-color-1);
+ background-color: var(--layout-background-1);
+ min-width: 0;
+}
+
+.main-header-area {
+ border-bottom-style: none;
+ display: block;
+}
+
+.message-header-container,
+.message-header-extra-container {
+ display: grid;
+ row-gap: 6px;
+}
+
+.message-header-container {
+ padding: 3px;
+}
+
+.message-header-row:not([hidden]) {
+ display: flex;
+}
+
+#headerSubjectSecurityContainer {
+ align-items: center; /* Needed for when the encryption button is visible. */
+}
+
+.message-header-wrap {
+ flex-wrap: wrap;
+}
+
+.message-header-row.header-row-reverse {
+ flex-direction: row-reverse;
+}
+
+.message-header-row.items-center {
+ /* Variation for those rows that include buttons. */
+ align-items: center;
+}
+
+.header-buttons-container {
+ display: flex;
+ justify-content: end;
+ flex-wrap: wrap;
+ gap: 3px;
+}
+
+.message-header-buttons-only-icons .header-buttons-container {
+ gap: 5px;
+}
+
+.header-row-grow:not([hidden]) {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+}
+
+#mail-notification-top {
+ border-bottom: 1px solid var(--splitter-color);
+}
+
+#mail-notification-top > .notificationbox-stack[notificationside="top"] {
+ background-color: var(--layout-background-1);
+}
+
+/* ::::: msg header toolbars ::::: */
+
+#messageHeader[show_header_mode="all"],
+#messageHeader.scrollable {
+ overflow-y: auto;
+ overflow-x: hidden;
+ max-height: 14em;
+}
+
+#expandedBoxSpacer {
+ display: block;
+ height: 4px;
+}
+
+mail-tagfield[collapsed="true"] {
+ display: none;
+}
+
+/* ::::: msg header buttons ::::: */
+
+#otherActionsButton > .toolbarbutton-icon {
+ display: none;
+}
+
+.message-header-view-button {
+ flex-direction: row;
+ min-width: 1em;
+ margin: 0;
+ padding-inline: 3px !important;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.message-header-view-button[is="toolbarbutton-menu-button"] {
+ padding: 0 !important;
+}
+
+.message-header-view-button[is="toolbarbutton-menu-button"] > .toolbarbutton-menubutton-button {
+ flex-direction: row;
+ padding-inline: 3px !important;
+}
+
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button:not([is="toolbarbutton-menu-button"]),
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button > .toolbarbutton-menubutton-button,
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button > .toolbarbutton-menubutton-dropmarker,
+#headingWrapper .toolbarbutton-1.message-header-view-button {
+ border-color: var(--toolbarbutton-header-bordercolor);
+}
+
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button:not([disabled=true]):is(:hover,[open]) >
+ .toolbarbutton-menubutton-button,
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button:not([disabled=true]):is(:hover,[open]) >
+ .toolbarbutton-menubutton-dropmarker,
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button:not([is="toolbarbutton-menu-button"],[disabled=true],[checked=true],[open],:active):hover,
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button:not([buttonover],[open],:active):hover >
+ .toolbarbutton-menubutton-dropmarker:not([disabled]),
+#headingWrapper .toolbarbutton-1.message-header-view-button:hover {
+ border-color: var(--toolbarbutton-active-bordercolor);
+}
+
+/* Separator between menu and split type buttons */
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button:not(:hover,:active,[open],[checked]) >
+ .toolbarbutton-menubutton-dropmarker::before,
+#messageHeader:not(.message-header-buttons-only-icons)
+ .toolbarbutton-1.message-header-view-button[disabled="true"] >
+ .toolbarbutton-menubutton-dropmarker::before {
+ background-image: none;
+}
+
+.message-header-view-button .toolbarbutton-text {
+ padding-inline-start: 2px;
+}
+
+#msgHeaderView[shrink] .message-header-view-button .toolbarbutton-text {
+ display: none;
+}
+
+#msgHeaderView[shrink] .toolbarbutton-1 .toolbarbutton-menu-dropmarker {
+ margin-inline: 3px;
+}
+
+.hdrReplyToSenderButton,
+.hdrDummyReplyButton,
+.hdrReplyButton {
+ list-style-image: var(--icon-reply);
+}
+
+.hdrReplyAllButton {
+ list-style-image: var(--icon-reply-all);
+}
+
+.hdrReplyListButton,
+.hdrFollowupButton {
+ list-style-image: var(--icon-reply-list);
+}
+
+.hdrForwardButton {
+ list-style-image: var(--icon-forward);
+}
+
+.hdrArchiveButton {
+ list-style-image: var(--icon-archive);
+}
+
+.hdrJunkButton {
+ list-style-image: var(--icon-spam);
+}
+
+.hdrTrashButton {
+ list-style-image: var(--icon-trash);
+}
+
+.header-buttons-container *:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+#attachmentSaveAllSingle,
+#attachmentSaveAllMultiple {
+ list-style-image: var(--icon-download);
+}
+
+/* ::::: msg header toolbars ::::: */
+
+#expandedHeadersTopBox {
+ /* Use the HTML layout model to allow the message header toolbar to float to
+ the right of the From field. */
+ display: block;
+}
+
+/* ::::: expanded header pane ::::: */
+
+#expandedsubjectBox {
+ font-weight: 700;
+ flex: 1;
+ margin-inline-start: calc(var(--message-header-field-offset) * -1);
+ padding-inline: var(--message-header-field-offset);
+ /* IMPORTANT! Keep these to avoid issues with very long subjects. Bug 77806 */
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ overflow-y: auto;
+}
+
+/* ::::: attachment view ::::: */
+
+#attachmentBar {
+ color: var(--layout-color-1);
+ background-color: var(--layout-background-1);
+ padding: 3px 0;
+ overflow: hidden;
+}
+
+#attachmentToggle {
+ /* Override button appearance */
+ appearance: none;
+ min-width: 20px;
+ margin-block: 0;
+ margin-inline: 1px 0;
+ border: 0;
+ background-color: transparent;
+ color: inherit;
+ -moz-user-focus: normal;
+ list-style-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke, fill-opacity;
+ stroke: currentColor;
+ fill-opacity: 1;
+}
+
+#attachmentToggle:not([checked="true"]) > .button-box > .button-icon {
+ transform: rotate(-90deg);
+}
+
+#attachmentToggle:not([checked="true"]):-moz-locale-dir(rtl) >
+ .button-box > .button-icon {
+ transform: rotate(90deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ #attachmentToggle > .button-box > .button-icon {
+ transition: transform 200ms ease;
+ }
+}
+
+#attachmentToggle > .button-box > .button-text {
+ display: none;
+}
+
+#attachmentToggle:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: -2px;
+}
+
+#attachmentInfo {
+ overflow: hidden;
+ flex-shrink: 1;
+}
+
+#attachmentName:hover,
+#attachmentName[selected="true"] {
+ cursor: pointer;
+ color: var(--selected-item-text-color);
+ background-color: var(--selected-item-color);
+}
+
+#attachmentSize {
+ margin: 0;
+ margin-inline-start: 8px;
+}
+
+#attachmentIcon {
+ margin-inline-start: 5px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+#attachmentCount {
+ margin: 0;
+ padding: 2px 0;
+ margin-inline: 2px 1px;
+}
+
+#attachmentName {
+ -moz-user-focus: normal;
+ margin: 0;
+ margin-inline-end: -3px;
+ padding: 2px 3px;
+ border-radius: 2px;
+}
+
+#attachmentName:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: -1px;
+}
+
+#attachment-splitter {
+ appearance: none;
+ background-color: transparent;
+ border-width: 0;
+ border-bottom: 1px solid var(--color-gray-30);
+ /* splitter grip area */
+ height: 5px;
+ /* make only the splitter border visible */
+ margin-top: -5px;
+ /* because of the negative margin needed to make the splitter visible */
+ position: relative;
+ z-index: 10;
+ transition: border-width .3s ease-in;
+}
+
+#attachment-splitter:not([state="collapsed"]) {
+ border-bottom: 1px solid transparent;
+}
+
+#attachment-splitter {
+ transition: border-color .3s;
+}
+
+#attachment-splitter[state="collapsed"]:hover {
+ border-bottom: 4px solid var(--selected-item-color);
+}
+
+/* ::::: msg header captions ::::: */
+
+.message-header-label {
+ padding: 0;
+ margin-block: 0;
+ margin-inline: 6px 8px;
+ text-align: end;
+ flex-shrink: 0;
+ align-self: baseline;
+}
+
+.message-header-label.header-pill-label {
+ padding-block-start: var(--message-header-field-offset);
+}
+
+.message-header-label,
+#attachmentSize {
+ opacity: var(--message-header-label-opacity);
+}
+
+.headerValue {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ margin: 0;
+}
+
+.header-row:focus-visible,
+.header-recipient:focus-visible,
+.header-newsgroup:focus-visible {
+ outline: var(--focus-outline);
+}
+
+.header-row[is="simple-header-row"],
+.header-row[is="url-header-row"] {
+ /* Match the visual alignment of the rows with clickable elements. */
+ margin-inline-start: calc(var(--message-header-field-offset) * -1);
+ padding-inline: var(--message-header-field-offset);
+}
+
+.tag {
+ padding: 1px 3px;
+ margin-inline-start: 0;
+ border-radius: var(--button-border-radius);
+ border: 1px solid transparent;
+}
+
+.tag:not([style]) {
+ border-color: color-mix(in srgb, currentColor 50%, transparent);
+}
+
+.message-header-datetime {
+ user-select: text;
+ -moz-user-focus: normal;
+ cursor: text;
+ margin: 0 6px;
+ white-space: nowrap;
+}
+
+#expandedtoRow .message-header-datetime {
+ align-self: flex-start;
+ margin-block: 2px;
+}
+
+/* ::::: msg header email addresses ::::: */
+
+button.email-action-button {
+ margin: 0;
+ padding: 0;
+ border-radius: 0;
+}
+
+button.email-action-button:hover,
+button.email-action-button:hover:active {
+ background-color: transparent;
+}
+
+button.email-action-button:focus-visible {
+ outline: var(--focus-outline);
+}
+
+.header-row {
+ -moz-user-focus: normal;
+ user-select: text;
+ word-wrap: anywhere;
+ display: inherit;
+ line-height: 1.3;
+}
+
+.screen-reader-only {
+ position: absolute;
+ clip-path: inset(50%);
+}
+
+#attachmentView {
+ display: flex;
+ flex-direction: column;
+ justify-content: stretch;
+ /* Allow the area to shrink. */
+ min-width: 0;
+}
+
+#attachmentView,
+#attachmentList {
+ border-top: 1px solid var(--splitter-color); /* The same color as the splitters */
+}
+
+:root[lwt-tree] #singleMessage,
+:root[lwt-tree] #attachmentView {
+ background-color: var(--toolbar-bgcolor) !important;
+ background-image: none !important;
+ color: var(--lwt-text-color);
+}
+
+:root[lwt-tree] .headerValue {
+ color: inherit;
+}
+
+:root[lwt-tree] #attachmentBar {
+ background-color: hsla(0, 0%, 50%, .15);
+ color: inherit;
+}
+
+/* OpenPGP and S/MIME encryption and signature status icons */
+
+#cryptoBox:not([hidden]) {
+ display: contents;
+}
+
+.crypto-label {
+ font-weight: 600;
+}
+
+.crypto-button {
+ display: inline-block;
+ margin-block: 0;
+ margin-inline-end: 3px;
+ fill: currentColor;
+ background-color: transparent;
+}
+
+.crypto-button[hidden] {
+ display: none;
+}
+
+.crypto-button > * {
+ vertical-align: middle;
+}
+
+/* Encryption security pane */
+#messageSecurityPanel {
+ --panel-width: 37rem;
+}
+
+#messageSecurityPanel .security-panel-body {
+ overflow-x: hidden;
+ flex: 1;
+ padding-inline: 6px;
+}
+
+html|header.message-security-header {
+ display: flex;
+ flex-wrap: nowrap;
+ text-align: center;
+ align-items: center;
+ margin-bottom: 6px;
+}
+
+html|header.message-security-header html|h3 {
+ flex: 1;
+ margin-block: 0;
+}
+
+.message-security-body {
+ overflow-y: auto;
+ flex: 1;
+}
+
+.message-security-body > description {
+ margin-bottom: 18px;
+}
+
+.message-security-label {
+ font-weight: 600;
+ font-size: 1.1em;
+ padding-inline-start: 21px;
+ background-position: left center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ margin-bottom: 6px;
+ fill: currentColor;
+ -moz-context-properties: fill;
+}
+
+.message-security-label.none,
+#encryptionLabel.none,
+#signatureLabel.none {
+ padding-inline-start: 0;
+}
+
+#signatureLabel.ok {
+ background-image: url("chrome://messenger/skin/icons/message-signed-ok.svg");
+}
+
+#signatureLabel.verified {
+ background-image: url("chrome://messenger/skin/icons/message-signed-verified.svg");
+}
+
+#signatureLabel.unverified {
+ background-image: url("chrome://messenger/skin/icons/message-signed-unverified.svg");
+}
+
+#signatureLabel.unknown {
+ background-image: url("chrome://messenger/skin/icons/message-signed-unknown.svg");
+}
+
+#signatureLabel.mismatch,
+#signatureLabel.notok {
+ background-image: url("chrome://messenger/skin/icons/message-signed-mismatch.svg");
+}
+
+#encryptionLabel.ok {
+ background-image: url("chrome://messenger/skin/icons/message-encrypted-ok.svg");
+}
+
+#encryptionLabel.notok {
+ background-image: url("chrome://messenger/skin/icons/message-encrypted-notok.svg");
+}
+
+#openpgpImportButton {
+ list-style-image: url("chrome://messenger/skin/icons/encryption-key.svg");
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#openpgpImportButton .button-icon {
+ margin-inline-end: 2px;
+}
+
+.message-security-container {
+ margin-bottom: 21px;
+}
+
+#signatureKeyId,
+#encryptionKeyId,
+.cert-label {
+ font-weight: 600;
+}
+
+#signatureKey {
+ flex-wrap: wrap;
+}
+
+#otherEncryptionKeysList {
+ margin: 9px 6px;
+}
+
+.other-key-row {
+ border-radius: 2px;
+ padding: 3px 2px;
+ border: 1px solid var(--button-border-color);
+ background-color: rgba(215, 215, 219, 0.2);
+ margin-bottom: 3px;
+}
+
+.openpgp-key-id {
+ font-weight: bold;
+}
+
+.openpgp-key-name {
+ font-size: 0.9em;
+}
+
+#signatureKeyId,
+#encryptionKeyId,
+.openpgp-key-id,
+.openpgp-key-name {
+ user-select: text;
+ cursor: text;
+}
+
+.button-focusable {
+ -moz-user-focus: normal;
+}
+
+.button-focusable:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+.button-focusable:focus:not(:focus-visible) {
+ outline: none;
+}
+
+button.email-action-flagged {
+ margin-inline: 6px;
+ cursor: pointer;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+ align-self: center;
+ border-radius: var(--button-border-radius);
+}
+
+button.email-action-flagged.flagged {
+ fill: var(--color-orange-30) !important;
+ stroke: var(--color-orange-60);
+}
+
+.email-action-flagged:not(.flagged):hover {
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/* Responsive style */
+@media (max-width: 768px) {
+ .message-header-row.items-center {
+ align-items: baseline;
+ }
+
+ .message-header-container {
+ padding: 6px;
+ }
+
+ .message-header-wrap:not([hidden]) {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .header-buttons-container {
+ align-self: end;
+ }
+
+ .message-header-label {
+ min-width: 0 !important;
+ margin-inline-start: 0;
+ text-align: start;
+ }
+}
+
+
+/* Customization options */
+
+.message-header-hide-label-column {
+ padding-inline-start: 9px;
+}
+
+.message-header-hide-label-column .message-header-label {
+ display: none;
+}
+
+.message-header-hide-label-column .multi-recipient-row:not(#expandedfromBox),
+.message-header-hide-label-column .multi-message-ids-row,
+.message-header-hide-label-column .header-newsgroups-row {
+ display: flex;
+}
+
+.message-header-hide-label-column .multi-recipient-row:not(#expandedfromBox) ol,
+.message-header-hide-label-column .multi-message-ids-row ol,
+.message-header-hide-label-column .header-newsgroups-row ol {
+ flex: 1 1 auto;
+}
+
+.message-header-hide-label-column .row-heading:not(#subjectHeading, #fromHeading, #tagsHeading) {
+ display: flow-root;
+ margin-inline-end: 1ch;
+ font-weight: 600;
+ align-self: baseline;
+ word-break: keep-all;
+ white-space: nowrap;
+ opacity: var(--message-header-label-opacity);
+}
+
+.message-header-hide-label-column .multi-recipient-row
+ .row-heading:not(#fromHeading),
+.message-header-hide-label-column
+ .row-heading:is(#newsgroupsHeading, #followup-toHeading, #message-idHeading, #referencesHeading, #in-reply-toHeading) {
+ padding-block: var(--message-header-field-offset);
+}
+
+.message-header-large-subject #expandedsubjectBox {
+ font-weight: 500;
+ font-size: 1.35em;
+ line-height: 1.35em;
+}
+
+.message-header-large-subject #expandedsubjectLabel {
+ margin-top: 5px;
+}
+
+.message-header-buttons-only-icons .toolbarbutton-text,
+.message-header-buttons-only-text .toolbarbutton-icon {
+ display: none !important;
+}
+
+.message-header-buttons-only-text .toolbarbutton-text {
+ margin: 0 !important;
+ padding-inline: 2px !important;
+}
+
+.message-header-buttons-only-icons .toolbarbutton-menu-dropmarker {
+ margin-inline: 3px;
+}
+
+/* Header row widgets */
+
+.multi-recipient-row {
+ flex: 1 1 auto;
+}
+
+.row-heading {
+ display: none;
+}
+
+.header-recipient:not(:last-child, .last-before-button):after,
+.header-message-id:not(:last-child, .last-before-button):after,
+.header-newsgroup:not(:last-child):after {
+ content: ",";
+}
+
+.header-recipient,
+.header-newsgroup,
+.header-message-id {
+ display: flow-root;
+ padding: var(--message-header-field-offset);
+ border-radius: var(--button-border-radius);
+}
+
+.header-recipient {
+ white-space: nowrap;
+}
+
+.header-recipient:hover,
+.header-newsgroup:hover {
+ color: var(--selected-item-text-color);
+ background-color: var(--selected-item-color);
+ cursor: pointer;
+}
+
+.header-recipient img {
+ pointer-events: none;
+}
+
+.header-recipient span,
+.header-message-id {
+ word-break: break-word;
+ white-space: break-spaces;
+}
+
+.recipients-list,
+.newsgroups-list,
+.tags-list,
+.ids-list {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ row-gap: 2px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ margin-inline-start: calc(var(--message-header-field-offset) * -1);
+ min-width: 100px;
+}
+
+.tags-list {
+ column-gap: 3px;
+ min-width: fit-content;
+}
+
+.recipient-address-book-button {
+ margin: 0;
+ margin-inline-start: 3px;
+ margin-block-start: -2px;
+ padding: 1px;
+ border-radius: var(--button-border-radius);
+ cursor: pointer;
+ vertical-align: middle;
+ background-color: transparent;
+}
+
+.recipient-address-book-button:not([disabled]):hover {
+ background-color: transparent;
+}
+
+.recipient-address-book-button img {
+ display: block;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.recipient-address-book-button.in-address-book img {
+ fill: color-mix(in srgb, var(--toolbarbutton-icon-fill-attention) 20%, transparent);
+ stroke: var(--toolbarbutton-icon-fill-attention);
+}
+
+.header-recipient:hover .recipient-address-book-button.in-address-book img {
+ fill: var(--selected-item-text-color);
+ stroke: var(--selected-item-text-color);
+}
+
+.header-recipient:hover .recipient-address-book-button img {
+ opacity: var(--message-header-label-opacity);
+}
+
+.header-recipient:hover .recipient-address-book-button:not([disabled]):hover img {
+ opacity: 1;
+}
+
+.show-more-recipients,
+.show-more-ids {
+ min-height: auto;
+ min-width: auto;
+ border-radius: 12px;
+ line-height: 1;
+ text-transform: uppercase;
+ font-weight: 600;
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+.show-more-recipients {
+ padding: 3px 9px;
+ margin-inline-start: 6px !important;
+ font-size: 0.9rem;
+}
+
+.show-more-ids {
+ padding: 2px 6px;
+ font-size: 0.8rem;
+}
+
+button.show-more-recipients:hover,
+button.show-more-ids:hover {
+ background-color: var(--selected-item-text-color);
+ color: var(--selected-item-color);
+}
+
+.show-more-recipients:focus-visible,
+.show-more-ids:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+/* Avatar */
+
+.recipient-avatar {
+ display: none;
+ height: var(--recipient-avatar-size);
+ width: var(--recipient-avatar-size);
+ border-radius: 50%;
+ margin-block-start: var(--recipient-avatar-margin-block-start);
+ margin-inline-end: 6px;
+ text-align: center;
+ overflow: hidden;
+ color: var(--recipient-avatar-color);
+ background-color: var(--recipient-avatar-background-color);
+ align-items: center;
+ justify-content: center;
+}
+
+.recipient-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.message-header-show-recipient-avatar #expandedfromBox .recipient-avatar {
+ display: inline-flex;
+}
+
+.message-header-show-recipient-avatar .recipient-avatar.has-avatar,
+.message-header-show-sender-full-address .recipient-avatar {
+ vertical-align: middle;
+}
+
+.message-header-show-sender-full-address #expandedfromLabel,
+.message-header-show-recipient-avatar #expandedfromLabel {
+ padding-block-start: var(--recipient-avatar-margin-block-start);
+ align-self: center;
+}
+
+.message-header-show-recipient-avatar:not(.message-header-show-sender-full-address ) #expandedfromLabel {
+ padding-block-start: 0;
+}
+
+.recipient-multi-line {
+ display: none;
+ flex-direction: column;
+ gap: var(--recipient-multi-line-gap);
+ vertical-align: middle;
+ margin-inline-end: 3px;
+}
+
+.recipient-multi-line-name {
+ font-weight: 500;
+ font-size: 105%;
+}
+
+.recipient-multi-line-address {
+ opacity: 0.9;
+}
+
+
+.message-header-show-sender-full-address #expandedfromBox .recipient-multi-line {
+ display: inline-flex;
+}
+
+.message-header-show-sender-full-address #expandedfromBox .recipient-single-line {
+ display: none;
+}
diff --git a/comm/mail/themes/shared/mail/messageIcons.css b/comm/mail/themes/shared/mail/messageIcons.css
new file mode 100644
index 0000000000..9f15cdf261
--- /dev/null
+++ b/comm/mail/themes/shared/mail/messageIcons.css
@@ -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/. */
+
+#sizeCol,
+#unreadCol,
+#totalCol {
+ text-align: end;
+}
+
+#subjectCol {
+ flex: 7 7;
+}
+
+#senderCol {
+ flex: 4 4;
+}
+
+#recipientCol {
+ flex: 4 4;
+}
+
+#correspondentCol {
+ flex: 4 4;
+}
+
+#receivedCol {
+ flex: 2 2;
+}
+
+#dateCol {
+ flex: 2 2;
+}
+
+/* ..... select column ..... */
+
+.selectColumnHeader {
+ cursor: pointer;
+ min-width: 28px;
+}
+
+.selectColumnHeader > .treecol-icon,
+treechildren::-moz-tree-image(selectCol) {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ fill: currentColor;
+ stroke: currentColor;
+ fill-opacity: 0;
+ stroke-opacity: 0;
+}
+
+treechildren::-moz-tree-image(selectCol) {
+ cursor: pointer;
+ list-style-image: var(--icon-checkbox);
+}
+
+.selectColumnHeader.someselected > .treecol-icon {
+ stroke-opacity: 1;
+}
+
+.selectColumnHeader.allselected > .treecol-icon,
+treechildren::-moz-tree-image(selectCol, selected) {
+ fill-opacity: 1;
+}
+
+treechildren::-moz-tree-image(selectCol, selected, focus) {
+ fill: var(--select-focus-text-color);
+ stroke: var(--select-focus-text-color);
+}
+
+/* ..... delete column ..... */
+
+treechildren::-moz-tree-image(deleteCol) {
+ list-style-image: var(--icon-trash);
+ -moz-context-properties: stroke, fill;
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+treechildren::-moz-tree-image(deleteCol, imapdeleted) {
+ list-style-image: var(--icon-restore);
+}
+
+treechildren::-moz-tree-image(deleteCol, selected, focus) {
+ fill: color-mix(in srgb, var(--select-focus-text-color) 20%, transparent);
+ stroke: var(--select-focus-text-color);
+}
+
+/* ..... thread column ..... */
+
+.threadColumnHeader {
+ padding-inline-end: 1px;
+ width: 20px;
+}
+
+treechildren::-moz-tree-image(threadCol, container) {
+ list-style-image: var(--icon-thread);
+ -moz-context-properties: stroke, fill;
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 50%, transparent);
+}
+
+treechildren::-moz-tree-image(threadCol, watch) {
+ list-style-image: var(--icon-eye);
+}
+
+treechildren::-moz-tree-image(threadCol, ignore) {
+ list-style-image: var(--icon-thread-ignored);
+}
+
+treechildren::-moz-tree-image(threadCol, ignoreSubthread) {
+ list-style-image: var(--icon-subthread-ignored);
+}
+
+treechildren::-moz-tree-image(threadCol, selected, focus) {
+ fill: color-mix(in srgb, var(--select-focus-text-color) 20%, transparent);
+ stroke: var(--select-focus-text-color);
+}
+
+/* ..... read column ..... */
+
+treechildren::-moz-tree-image(unreadButtonColHeader) {
+ list-style-image: var(--icon-unread-dot);
+ -moz-context-properties: stroke, fill, fill-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: .25;
+}
+
+treechildren::-moz-tree-image(unreadButtonColHeader, unread) {
+ fill: color-mix(in srgb, var(--color-green-60) 50%, transparent);
+ stroke: var(--color-green-60);
+ fill-opacity: 1;
+}
+
+treechildren::-moz-tree-image(unreadButtonColHeader, selected, focus) {
+ fill: color-mix(in srgb, var(--select-focus-text-color) 20%, transparent);
+ stroke: var(--select-focus-text-color);
+}
+
+treechildren::-moz-tree-image(unreadButtonColHeader, unread, selected, focus) {
+ fill: var(--select-focus-text-color);
+}
+
+/* ..... attachment column ..... */
+
+treechildren::-moz-tree-image(attachmentCol, attach) {
+ list-style-image: var(--icon-attachment);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+treechildren::-moz-tree-image(attachmentCol, attach, selected, focus) {
+ stroke: var(--select-focus-text-color);
+}
+
+/* ..... flag column ..... */
+
+treechildren::-moz-tree-image(flaggedCol) {
+ margin-inline-start: -2px;
+ list-style-image: var(--icon-star);
+ -moz-context-properties: stroke, fill;
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+treechildren::-moz-tree-image(flaggedCol, flagged) {
+ fill: var(--color-orange-30) !important; /* override the selected, focus rule */
+ stroke: var(--color-orange-60) !important; /* override the selected, focus rule */
+}
+
+treechildren::-moz-tree-image(flaggedCol, selected, focus) {
+ fill: color-mix(in srgb, var(--select-focus-text-color) 20%, transparent);
+ stroke: var(--select-focus-text-color);
+}
+
+/* ..... junkStatus column ..... */
+
+/* "unknown" now looks like "not junk". see bug #182386 */
+treechildren::-moz-tree-image(junkStatusCol) {
+ margin-inline-start: -3px;
+ padding-inline-start: 0;
+ list-style-image: var(--icon-spam);
+ -moz-context-properties: stroke, fill;
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+treechildren::-moz-tree-image(junkStatusCol, junk) {
+ fill: var(--color-red-50) !important; /* override the selected, focus rule */
+ stroke: var(--color-red-70) !important; /* override the selected, focus rule */
+}
+
+treechildren::-moz-tree-image(junkStatusCol, notjunk) {
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+treechildren::-moz-tree-image(junkStatusCol, selected, focus) {
+ fill: color-mix(in srgb, var(--select-focus-text-color) 20%, transparent);
+ stroke: var(--select-focus-text-color);
+}
+
+/* ..... correspondent column ..... */
+
+treechildren::-moz-tree-cell-text(subjectCol) {
+ padding-inline-start: 0;
+}
+
+treechildren::-moz-tree-image(correspondentCol) {
+ list-style-image: var(--icon-nav-forward);
+ -moz-context-properties: stroke, fill-opacity;
+ stroke: currentColor;
+ fill-opacity: 0;
+}
+
+treechildren::-moz-tree-image(correspondentCol, outgoing) {
+ fill-opacity: 0.3;
+}
+
+#threadTree:-moz-locale-dir(rtl) > treechildren::-moz-tree-image(correspondentCol, outgoing) {
+ list-style-image: var(--icon-nav-back);
+}
+
+treechildren::-moz-tree-image(correspondentCol, outgoing, focus, selected) {
+ stroke: var(--select-focus-text-color);
+ fill-opacity: 0.5;
+}
+
+/* ..... subject column ..... */
+
+treechildren::-moz-tree-image(subjectCol) {
+ margin-inline-end: 2px;
+ -moz-context-properties: fill, stroke;
+ width: 16px;
+ height: 16px;
+}
+
+treechildren::-moz-tree-image(subjectCol, replied) {
+ list-style-image: var(--icon-reply-col);
+ fill: var(--color-purple-50);
+}
+
+treechildren::-moz-tree-image(subjectCol, forwarded) {
+ list-style-image: var(--icon-forward-col);
+ fill: var(--color-blue-50);
+}
+
+treechildren::-moz-tree-image(subjectCol, redirected) {
+ list-style-image: var(--icon-redirect-col);
+ fill: var(--color-orange-50);
+}
+
+treechildren::-moz-tree-image(subjectCol, replied, forwarded) {
+ list-style-image: var(--icon-reply-forward-col);
+ fill: var(--color-blue-50);
+ stroke: var(--color-purple-50);
+}
+
+treechildren::-moz-tree-image(subjectCol, replied, redirected) {
+ list-style-image: var(--icon-reply-redirect-col);
+ fill: var(--color-orange-50);
+ stroke: var(--color-purple-50);
+}
+
+treechildren::-moz-tree-image(subjectCol, forwarded, redirected) {
+ list-style-image: var(--icon-forward-redirect-col);
+ fill: var(--color-blue-50);
+ stroke: var(--color-orange-50);
+}
+
+treechildren::-moz-tree-image(subjectCol, replied, forwarded, redirected) {
+ list-style-image: var(--icon-reply-forward-redirect-col);
+ fill: var(--color-blue-50);
+ stroke: var(--color-purple-50);
+}
+
+treechildren::-moz-tree-image(subjectCol, new) {
+ list-style-image: var(--icon-notify);
+ fill: var(--color-yellow-50) !important; /* override the selected, focus rule */
+ stroke: var(--color-orange-50) !important; /* override the selected, focus rule */
+}
+
+treechildren::-moz-tree-image(subjectCol, selected, focus) {
+ fill: var(--select-focus-text-color);
+ stroke: var(--select-focus-text-color);
+}
+
+@media (prefers-color-scheme: dark) {
+ treechildren::-moz-tree-image(unreadButtonColHeader, unread) {
+ fill: color-mix(in srgb, var(--color-green-50) 50%, transparent) !important; /* override the selected, focus rule */
+ stroke: var(--color-green-50) !important; /* override the selected, focus rule */
+ }
+
+ treechildren::-moz-tree-image(flaggedCol, flagged) {
+ fill: var(--color-orange-40) !important; /* override the selected, focus rule */
+ stroke: var(--color-orange-50) !important; /* override the selected, focus rule */
+ }
+
+ treechildren::-moz-tree-image(junkStatusCol, junk) {
+ fill: var(--color-red-40) !important; /* override the selected, focus rule */
+ stroke: var(--color-red-50) !important; /* override the selected, focus rule */
+ }
+
+ /* ..... subject column, dark color scheme ..... */
+
+ treechildren::-moz-tree-image(subjectCol, replied) {
+ fill: var(--color-purple-40);
+ stroke: var(--color-purple-40);
+ }
+
+ treechildren::-moz-tree-image(subjectCol, forwarded) {
+ fill: var(--color-blue-40);
+ }
+
+ treechildren::-moz-tree-image(subjectCol, redirected) {
+ fill: var(--color-orange-40);
+ }
+
+ treechildren::-moz-tree-image(subjectCol, replied, forwarded) {
+ fill: var(--color-blue-40);
+ stroke: var(--color-purple-40);
+ }
+
+ treechildren::-moz-tree-image(subjectCol, replied, redirected) {
+ fill: var(--color-orange-40);
+ stroke: var(--color-purple-40);
+ }
+
+ treechildren::-moz-tree-image(subjectCol, forwarded, redirected) {
+ fill: var(--color-blue-40);
+ stroke: var(--color-orange-40);
+ }
+
+ treechildren::-moz-tree-image(subjectCol, replied, forwarded, redirected) {
+ fill: var(--color-blue-40);
+ stroke: var(--color-purple-40);
+ }
+
+ treechildren::-moz-tree-image(subjectCol, selected, focus) {
+ fill: var(--select-focus-text-color);
+ stroke: var(--select-focus-text-color);
+ }
+
+ treechildren::-moz-tree-image(subjectCol, new) {
+ fill: var(--color-yellow-40) !important; /* override the selected, focus rule */
+ stroke: var(--color-orange-30) !important; /* override the selected, focus rule */
+ }
+}
+
+/* ..... do not show icons ..... */
+
+treechildren::-moz-tree-image(subjectCol, dummy),
+treechildren::-moz-tree-image(flaggedCol, dummy),
+treechildren::-moz-tree-image(junkStatusCol, dummy) ,
+treechildren::-moz-tree-image(correspondentCol, dummy),
+treechildren::-moz-tree-image(unreadButtonColHeader, dummy) {
+ list-style-image: none !important;
+}
diff --git a/comm/mail/themes/shared/mail/messageQuotes.css b/comm/mail/themes/shared/mail/messageQuotes.css
new file mode 100644
index 0000000000..f930c599bd
--- /dev/null
+++ b/comm/mail/themes/shared/mail/messageQuotes.css
@@ -0,0 +1,67 @@
+/* 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/. */
+
+/* Because this sheet is loaded synchronously while the user is waiting for the
+ compose window to appear, it must not @import a ton of other things, and
+ especially must not trigger network access. */
+
+/* ===== messageQuotes.css =================================================
+ == Shared styles such as block quote colors and signature style
+ == between the message body during
+ == message display and the mail editor instance for mail compose.
+ ======================================================================= */
+
+/* workaround for MS Outlook mails where the line-height is set to 0 */
+body {
+ line-height: initial !important;
+}
+
+/* ::::: signature ::::: */
+
+@media not print {
+ div.moz-text-flowed > div.moz-txt-sig,
+ div.moz-text-plain > pre > div.moz-txt-sig,
+ pre.moz-signature {
+ opacity: 0.6;
+ }
+}
+
+/* ::::: Turn on borders and padding for quotes. ::::: */
+/* ::::: Colorize block quote borders. We only go 5 levels deep. ::::: */
+
+body blockquote[type=cite] {
+ margin-block: 1ex;
+ margin-inline: 0;
+ padding: 0.4ex 1ex;
+ border-inline-start: 2px solid rgb(114, 159, 207); /* Sky Blue 1 */
+}
+
+blockquote[type=cite] blockquote[type=cite] {
+ border-inline-start-color: rgb(173, 127, 168); /* Plum 1 */
+}
+
+blockquote[type=cite] blockquote[type=cite] blockquote[type=cite] {
+ border-inline-start-color: rgb(138, 226, 52); /* Chameleon 1 */
+}
+
+blockquote[type=cite] blockquote[type=cite] blockquote[type=cite] blockquote[type=cite] {
+ border-inline-start-color: rgb(252, 175, 62); /* Orange 1 */
+}
+
+blockquote[type=cite] blockquote[type=cite] blockquote[type=cite] blockquote[type=cite] blockquote[type=cite] {
+ border-inline-start-color: rgb(233, 185, 110); /* Chocolate 1 */
+}
+
+/* Styles for the dark mode */
+/* Disabled in bug 1639249. See also editorContent.css.
+@media (prefers-color-scheme: dark) {
+ body {
+ color: #f9f9fa;
+ background-color: #2a2a2e;
+ }
+
+ span[_moz_quote="true"] {
+ color: #009fff;
+ }
+} */
diff --git a/comm/mail/themes/shared/mail/messenger.css b/comm/mail/themes/shared/mail/messenger.css
new file mode 100644
index 0000000000..1f568a4b1c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/messenger.css
@@ -0,0 +1,1506 @@
+/* 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 url("chrome://global/skin/global.css");
+@import url("chrome://messenger/content/webextensions.css");
+@import url("chrome://messenger/skin/autocomplete.css");
+@import url("chrome://messenger/skin/variables.css");
+@import url("chrome://messenger/skin/splitter.css");
+@import url("chrome://messenger/skin/widgets.css");
+
+@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:root {
+ user-select: none;
+}
+
+:root,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ width: 100%;
+ overflow: clip;
+}
+
+#messengerWindow {
+ text-rendering: optimizeLegibility;
+ min-height: 95px;
+ min-width: 95px;
+}
+
+:root:-moz-locale-dir(rtl) {
+ direction: rtl;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+p {
+ margin: 2px 4px;
+}
+
+hr {
+ width: 100%;
+ border-top: 1px solid var(--field-border-color);
+ border-bottom: 0;
+}
+
+:root:not([sizemode=maximized]) .titlebar-restore,
+:root:is([sizemode=maximized]) .titlebar-max {
+ display: none;
+}
+
+#toolbar-menubar[autohide="true"] {
+ overflow: hidden;
+}
+
+#toolbar-menubar[autohide="true"][inactive="true"]:not([customizing="true"]) {
+ min-height: 0 !important;
+ height: 0 !important;
+ padding: 0 !important;
+ appearance: none !important;
+}
+
+#titlebar-spacer {
+ pointer-events: none;
+}
+
+#navigation-toolbox:-moz-lwtheme,
+#compose-toolbox:-moz-lwtheme {
+ background-color: var(--lwt-accent-color);
+ background-image: var(--lwt-additional-images);
+ background-repeat: var(--lwt-background-tiling);
+ background-position: var(--lwt-background-alignment);
+}
+
+:root:not([tabsintitlebar]) #navigation-toolbox:-moz-lwtheme {
+ color: var(--toolbar-color, inherit);
+ background-color: var(--toolbar-bgcolor);
+}
+
+/* When a theme defines both theme_frame and additional_backgrounds, show
+ the latter atop the former. */
+:root[lwtheme-image] #navigation-toolbox,
+:root[lwtheme-image] #compose-toolbox {
+ background-image: var(--lwt-header-image), var(--lwt-additional-images);
+ background-repeat: no-repeat, var(--lwt-background-tiling);
+ background-position: right top, var(--lwt-background-alignment);
+}
+
+:root[tabsintitlebar] #navigation-toolbox:-moz-window-inactive:-moz-lwtheme,
+#compose-toolbox:-moz-window-inactive:-moz-lwtheme {
+ background-color: var(--lwt-accent-color-inactive, var(--lwt-accent-color));
+}
+
+#titlebar > #toolbar-menubar:-moz-window-inactive,
+#titlebar > #tabs-toolbar:-moz-window-inactive {
+ color: color-mix(in srgb, currentColor 70%, transparent);
+}
+
+.tree-columnpicker-button {
+ list-style-image: var(--icon-column-menu);
+ padding-inline: 4px;
+ border-style: none;
+ border-radius: 0;
+ -moz-context-properties: stroke, fill;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.thread-tree-col-picker > image,
+.thread-tree-icon-header img {
+ -moz-context-properties: stroke, fill;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+treechildren::-moz-tree-row(selected, current, focus) {
+ outline: 1px solid color-mix(in srgb, var(--selected-item-color), black 10%);
+ outline-offset: -1px;
+}
+
+tree > treechildren::-moz-tree-row(hover) {
+ background-color: hsla(0, 0%, 50%, 0.15);
+}
+
+tree > treechildren::-moz-tree-row(selected) {
+ background-color: -moz-cellhighlight;
+}
+
+tree > treechildren::-moz-tree-row(selected, focus) {
+ background-color: var(--selected-item-color);
+}
+
+treechildren::-moz-tree-twisty {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ color: #505050;
+}
+
+@media (prefers-color-scheme: dark) {
+ treechildren::-moz-tree-twisty {
+ color: #d4d4d4;
+ }
+}
+
+treechildren::-moz-tree-twisty(selected, focus) {
+ stroke: var(--select-focus-text-color);
+}
+
+/* LW theme tree styling */
+:root[lwt-tree] #threadTree treechildren::-moz-tree-row(untagged, selected),
+:root[lwt-tree]:-moz-window-inactive #threadTree treechildren::-moz-tree-row(selected),
+:root[lwt-tree] tree:not(#threadTree) treechildren::-moz-tree-row(selected) {
+ border-color: var(--sidebar-highlight-background-color, hsla(0, 0%, 80%, .3));
+ background: var(--sidebar-highlight-background-color, hsla(0, 0%, 80%, .3));
+ outline: none;
+}
+
+:root[lwt-tree] treechildren::-moz-tree-row(current, focus) {
+ border-color: var(--sidebar-highlight-background-color, hsla(0,0%,80%,.6));
+}
+
+/* Dark theme adaptions */
+:root[lwt-tree-brighttext] #threadTree treechildren::-moz-tree-row(untagged, selected),
+:root[lwt-tree-brighttext]:-moz-window-inactive #threadTree treechildren::-moz-tree-row(selected),
+:root[lwt-tree-brighttext] tree:not(#threadTree) treechildren::-moz-tree-row(selected) {
+ border-color: var(--sidebar-highlight-background-color, rgba(249, 249, 250, .1));
+ background: var(--sidebar-highlight-background-color, rgba(249, 249, 250, .1));
+}
+
+:root[lwt-tree-brighttext] treechildren::-moz-tree-row(current, focus) {
+ border-color: var(--sidebar-highlight-background-color, rgba(249,249,250,.3));
+}
+
+:root[lwt-tree] #threadTree treechildren::-moz-tree-row(untagged, selected, focus, current),
+:root[lwt-tree] tree:not(#threadTree) treechildren::-moz-tree-row(selected, focus, current) {
+ border-color: var(--sidebar-highlight-border-color, var(--item-focus-selected-border-color));
+}
+
+/* toolbar */
+
+toolbar[type="menubar"][autohide="true"] {
+ overflow: hidden;
+}
+
+toolbar[type="menubar"][autohide="true"][inactive="true"]:not([customizing="true"]) {
+ min-height: 0 !important;
+ height: 0 !important;
+ appearance: none !important;
+}
+
+/* Show hidden toolbars in customize mode */
+toolbar[customizing="true"][collapsed="true"] {
+ visibility: visible;
+}
+
+/* toolbarbutton */
+
+.toolbarbutton-1 {
+ flex-direction: column;
+ margin: 4px 1px;
+ padding: 1px 5px !important;
+ -moz-context-properties: fill, stroke, fill-opacity;
+ fill: color-mix(in srgb, var(--toolbarbutton-icon-fill, currentColor) 20%, transparent);
+ stroke: var(--toolbarbutton-icon-fill, currentColor);
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+.toolbarbutton-1,
+.toolbarbutton-menubutton-button {
+ min-height: 24px;
+ min-width: 32px;
+ margin: 4px 1px;
+ padding: 1px 5px !important;
+}
+
+:root[uidensity="touch"] .toolbarbutton-1:not([is="toolbarbutton-menu-button"]),
+:root[uidensity="touch"] .toolbarbutton-1 > .toolbarbutton-menubutton-button,
+:root[uidensity="touch"] .toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker {
+ padding: 5px !important;
+}
+
+.toolbarbutton-menubutton-button {
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+}
+
+toolbox[labelalign="end"] .toolbarbutton-1,
+toolbox[labelalign="end"] .toolbarbutton-menubutton-button,
+toolbox:not([mode="full"]) .toolbarbutton-1,
+toolbox:not([mode="full"]) .toolbarbutton-menubutton-button {
+ flex-direction: row;
+}
+
+/* Replicate the xul.css rule for when toolbar button icons are html:img instead
+ * of xul:image. */
+toolbar[mode="text"] .toolbarbutton-icon {
+ display: none;
+}
+
+toolbar[mode="full"] .toolbarbutton-1:not([hideWebExtensionLabel="true"]) {
+ min-width: 55px;
+}
+
+toolbar:not([mode="text"]) .toolbarbutton-1[hideWebExtensionLabel="true"] .toolbarbutton-text {
+ display: none;
+}
+
+#header-view-toolbar .toolbarbutton-1[hideWebExtensionLabel="true"] .toolbarbutton-text {
+ display: none;
+}
+
+.toolbarbutton-menubutton-dropmarker {
+ pointer-events: none;
+}
+
+.toolbarbutton-1:not([is="toolbarbutton-menu-button"]),
+.toolbarbutton-1 > .toolbarbutton-menubutton-button,
+.toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker {
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+ transition: background-color .15s, border-color .15s;
+}
+
+.toolbarbutton-1:not([disabled="true"]):is(:hover,[open="true"]) >
+.toolbarbutton-menubutton-button,
+.toolbarbutton-1:not([disabled="true"]):hover >
+.toolbarbutton-menubutton-dropmarker,
+.toolbarbutton-1:not([is="toolbarbutton-menu-button"],[disabled="true"],[checked="true"],[open="true"],:active):hover {
+ background-color: var(--toolbarbutton-hover-background);
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+}
+
+.toolbarbutton-1 > .toolbarbutton-menubutton-button:not([disabled="true"]):hover:active,
+.toolbarbutton-1[open="true"] > .toolbarbutton-menubutton-dropmarker:not([disabled="true"]),
+.toolbarbutton-1:not([is="toolbarbutton-menu-button"],[disabled="true"]):is([open="true"],[checked="true"],:hover:active) {
+ background-color: var(--toolbarbutton-checked-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+ transition-duration: 10ms;
+}
+
+.toolbarbutton-1:not([is="toolbarbutton-menu-button"],[disabled="true"]):hover:active,
+.toolbarbutton-1:not([is="toolbarbutton-menu-button"],[disabled="true"])[open="true"],
+.toolbarbutton-1 > .toolbarbutton-menubutton-button:not([disabled="true"]):hover:active,
+.toolbarbutton-1[open="true"] > .toolbarbutton-menubutton-dropmarker:not([disabled="true"]) {
+ background-color: var(--toolbarbutton-active-background) !important;
+}
+
+.toolbarbutton-1[is="toolbarbutton-menu-button"] > .toolbarbutton-menubutton-button {
+ border-inline-end: none;
+ margin: 0;
+}
+
+.toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker {
+ height: auto;
+ width: auto;
+ padding: 1px 5px;
+}
+
+.toolbarbutton-1 .toolbarbutton-menu-dropmarker,
+.toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker {
+ list-style-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.toolbarbutton-1 .toolbarbutton-menu-dropmarker::part(icon),
+.toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker::part(icon) {
+ width: 12px;
+}
+
+/* Separator between menu and split type buttons */
+.toolbarbutton-1:not(:hover,:active,[open],[checked]) >
+.toolbarbutton-menubutton-dropmarker::before,
+.toolbarbutton-1[disabled="true"] >
+.toolbarbutton-menubutton-dropmarker::before {
+ margin-inline: -6px 5px;
+}
+
+.toolbarbutton-1 > .toolbarbutton-menubutton-button:-moz-locale-dir(ltr),
+.toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker:-moz-locale-dir(rtl) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.toolbarbutton-1 > .toolbarbutton-menubutton-button:-moz-locale-dir(rtl),
+.toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker:-moz-locale-dir(ltr) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+toolbox[labelalign="end"] > toolbar[mode="full"] .toolbarbutton-1 .toolbarbutton-text {
+ padding-inline-start: 2px;
+}
+
+.toolbarbutton-1 .toolbarbutton-icon {
+ /* NOTE: At the moment, these rule can be applied to either xul:image elements
+ * (the plain toolbarbutton elements, defined in mozilla) or html:img
+ * elements (custom extensions in comm).
+ * The icon content is meant to be 16px by 16px, but xul:image sizing is
+ * essentially always border-box, and a content-box value would be ignored. We
+ * include this explicit border-box value for buttons that use a html:img
+ * icon.
+ * If all such icons become html:img elements, then it will be safe to use the
+ * default content-box sizing. */
+ box-sizing: border-box;
+ padding: 1px;
+ width: 18px;
+ height: 18px;
+}
+
+.toolbarbutton-menubutton-button > .toolbarbutton-icon {
+ padding: 1px;
+}
+
+/* Separator between menu and split type buttons */
+.toolbarbutton-1:not(:hover,:active,[open],[checked]) >
+.toolbarbutton-menubutton-dropmarker::before,
+.toolbarbutton-1[disabled="true"] >
+.toolbarbutton-menubutton-dropmarker::before {
+ content: "";
+ display: flex;
+ width: 1px;
+ height: 18px;
+ background-image: linear-gradient(currentColor 0, currentColor 100%);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 1px 18px;
+ opacity: .2;
+}
+
+toolbox:not([labelalign="end"]) > toolbar[mode="full"]
+ .toolbarbutton-1:not(.button-appmenu,[is="toolbarbutton-menu-button"])[type="menu"] {
+ padding-inline-end: 15px !important;
+ background-image: var(--icon-nav-down-sm);
+ background-size: 12px;
+ background-repeat: no-repeat;
+ background-position: calc(100% - 4px) center;
+}
+
+toolbox:not([labelalign="end"]) > toolbar[mode="full"]
+ .toolbarbutton-1:not(.button-appmenu,[is="toolbarbutton-menu-button"])[type="menu"]:-moz-locale-dir(rtl) {
+ background-position: 4px center;
+}
+
+toolbox:not([labelalign="end"]) > toolbar[mode="full"]
+ .toolbarbutton-1:not(.button-appmenu,[is="toolbarbutton-menu-button"])[type="menu"][disabled="true"] {
+ opacity: 0.4;
+}
+
+/* Don't set a reduced opacity because we set it on the whole button. */
+toolbox:not([labelalign="end"]) > toolbar[mode="full"]
+ .toolbarbutton-1:not(.button-appmenu,[is="toolbarbutton-menu-button"])[type="menu"][disabled="true"]
+ .toolbarbutton-icon,
+toolbox:not([labelalign="end"]) > toolbar[mode="full"]
+ .toolbarbutton-1:not(.button-appmenu,[is="toolbarbutton-menu-button"])[type="menu"][disabled="true"]
+ .toolbarbutton-text {
+ opacity: 1;
+}
+
+toolbox:not([labelalign="end"]) > toolbar[mode="full"]
+ .toolbarbutton-1:not(.button-appmenu,[is="toolbarbutton-menu-button"])[type="menu"] >
+ .toolbarbutton-menu-dropmarker {
+ display: none;
+}
+
+toolbar[brighttext] .toolbarbutton-1:not(:hover,:active,[open]) >
+ .toolbarbutton-menubutton-dropmarker::before {
+ opacity: .3;
+}
+
+#alltabs-button {
+ margin-block: 3px;
+}
+
+#palette-box .toolbarbutton-1 {
+ flex-direction: row;
+}
+
+description.error {
+ color: #f00;
+}
+
+.sidebar-header {
+ min-height: 25px;
+ text-shadow: none;
+}
+
+.sidebar-header > xul|label {
+ padding-inline-start: 4px;
+}
+
+menupopup,
+menubar {
+ font-size: inherit;
+}
+
+menulist {
+ min-height: 24px;
+ margin-block: 4px;
+ color: var(--button-text-color);
+ border: 1px solid var(--toolbarbutton-hover-bordercolor);
+ border-radius: var(--button-border-radius);
+ background-color: var(--toolbarbutton-hover-background);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+ transition-property: background-color, border-color, box-shadow;
+ transition-duration: 150ms;
+}
+
+menulist:-moz-lwtheme {
+ color: inherit;
+}
+
+/* Override the not existing rule in menu.css */
+menulist[disabled="true"]:hover,
+menulist[open="true"]:hover {
+ background-color: var(--toolbarbutton-hover-background);
+}
+
+menulist:not([disabled="true"],[open="true"]):hover {
+ background-color: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+}
+
+menulist[open="true"] {
+ background-color: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+}
+
+menulist[disabled="true"] {
+ opacity: 0.6;
+}
+
+menulist::part(label-box) {
+ font-weight: inherit;
+}
+
+menulist::part(dropmarker-icon) {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ width: 16px;
+}
+
+menupopup.no-accel-menupopup > :is(menu, menuitem) > .menu-accel-container {
+ display: none;
+}
+
+/* NOTE: the menuitem children must be constructed with the menuitem-iconic
+ * class so that this rule can hide the icon container. */
+menupopup.no-icon-menupopup > :is(menu, menuitem) > .menu-iconic-left {
+ display: none;
+}
+
+/* Buttons */
+
+button,
+html|input[type="color"] {
+ appearance: none;
+ min-height: 24px;
+ min-width: 5.5em;
+ color: inherit;
+ border: 1px solid var(--button-border-color);
+ border-radius: var(--button-border-radius);
+ background: var(--button-background-color);
+ margin: 1px 5px;
+ padding: 0 4px;
+ box-shadow: none;
+}
+
+xul|button:is(:not([disabled="true"])):hover,
+html|button:is(:not([disabled])):hover {
+ border-color: var(--button-border-color);
+ background-color: var(--button-hover-background-color);
+}
+
+xul|button:is(:not([disabled="true"])):hover:active,
+html|button:is(:not([disabled])):hover:active,
+xul|button:is(:not([disabled="true"]))[open="true"],
+html|input[type="color"]:not([disabled="true"]):hover,
+html|input[type="color"]:not([disabled="true"]):hover:active {
+ border-color: var(--button-border-color);
+ background-color: var(--button-active-background-color);
+}
+
+html|input[type="color"] {
+ padding: 4px;
+}
+
+/* For buttons that wraps its content with no outline or background.
+ * NOTE: the hover background will still appear on hover. */
+button.plain-button {
+ /* grid display ensures the button only takes up the same room as its
+ * content. */
+ display: inline grid;
+ height: -moz-fit-content;
+ min-height: auto;
+ min-width: -moz-fit-content;
+ border: none;
+ background: none;
+}
+
+button.plain-button[hidden] {
+ display: none;
+}
+
+button[is="toolbarbutton-menu-button"] {
+ appearance: none;
+}
+
+button[is="toolbarbutton-menu-button"] > .button-box > button {
+ appearance: none;
+ min-width: 5.5em;
+ background: transparent;
+ border-color: transparent;
+ border-inline-end-color: var(--toolbarbutton-active-bordercolor);
+ margin-inline: -4px 5px;
+ padding: 0 4px;
+}
+
+button > .button-box > dropmarker {
+ appearance: none;
+ list-style-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+img.invisible-on-broken:-moz-broken {
+ visibility: hidden;
+}
+
+.popup-notification-button {
+ height: auto;
+ border-style: none;
+ border-radius: 0;
+}
+
+#notification-popup-box {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+
+#notification-popup-box > .notification-anchor-icon {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, var(--toolbarbutton-icon-fill, currentColor) 20%, transparent);
+ stroke: var(--toolbarbutton-icon-fill, currentColor);
+ opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+/* notification anchors should only be visible when their associated
+ notifications are */
+.notification-anchor-icon {
+ -moz-user-focus: normal;
+}
+
+.notification-anchor-icon:not([showing]) {
+ display: none;
+}
+
+.popup-notification-icon {
+ width: 32px;
+ height: 32px;
+ margin-inline-end: 12px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.popup-notification-icon[popupid="xpinstall-disabled"],
+.popup-notification-icon[popupid="addon-install-blocked"],
+.popup-notification-icon[popupid="addon-install-origin-blocked"] {
+ list-style-image: url(chrome://messenger/skin/icons/addon-install-blocked.svg);
+}
+
+.popup-notification-icon[popupid="addon-progress"] {
+ list-style-image: url(chrome://messenger/skin/icons/addon-install-downloading.svg);
+}
+
+.popup-notification-icon[popupid="addon-install-failed"] {
+ list-style-image: url(chrome://messenger/skin/icons/addon-install-error.svg);
+}
+
+.popup-notification-icon[popupid="addon-install-confirmation"] {
+ list-style-image: url(chrome://messenger/skin/icons/addon-install-confirm.svg);
+}
+
+#addon-install-confirmation-notification[warning]
+ .popup-notification-icon[popupid="addon-install-confirmation"] {
+ list-style-image: url(chrome://messenger/skin/icons/addon-install-warning.svg);
+}
+
+.popup-notification-icon[popupid="addon-install-complete"] {
+ list-style-image: url(chrome://messenger/skin/icons/addon-install-installed.svg);
+}
+
+.popup-notification-body[popupid="addon-progress"],
+.popup-notification-body[popupid="addon-install-confirmation"] {
+ width: 28em;
+ max-width: 28em;
+}
+
+.addon-install-confirmation-name {
+ font-weight: bold;
+}
+
+html|*.addon-webext-perm-list {
+ margin-block-end: 0;
+ padding-inline-start: 10px;
+}
+
+.addon-webext-perm-single-entry {
+ margin-top: 11px;
+}
+
+.addon-webext-perm-text,
+.addon-webext-perm-single-entry {
+ margin-inline-start: 0;
+}
+
+.popup-notification-description[popupid="addon-webext-permissions"],
+.popup-notification-description[popupid="addon-webext-permissions-notification"] {
+ margin-inline-start: -1px;
+}
+
+.addon-webext-perm-notification-content,
+.addon-installed-notification-content {
+ margin-top: 0;
+}
+
+#addon-webext-experiment-warning {
+ margin-top: 11px;
+ margin-inline-start: 0;
+}
+
+#addon-webext-perm-info {
+ margin-inline-start: 0;
+}
+
+#addon-progress-notification-progressmeter {
+ margin: 2px 4px;
+}
+
+.addon-webext-name {
+ display: inline;
+ font-weight: bold;
+ margin: 0;
+}
+
+html|ul.addon-installed-list {
+ margin-top: 0;
+}
+
+.chromeclass-toolbar {
+ overflow-x: hidden;
+}
+
+.chromeclass-toolbar toolbarseparator {
+ appearance: none;
+ min-width: 1px;
+ background-image: linear-gradient(
+ transparent 4px,
+ var(--toolbarseparator-color) 4px,
+ var(--toolbarseparator-color) calc(100% - 4px),
+ transparent calc(100% - 4px));
+ margin-left: 1px;
+ margin-right: 1px;
+}
+
+#tabpanelcontainer {
+ /* While mail tab browsers load, the window goes transparent. Stop that. */
+ background-color: -moz-Dialog;
+}
+
+.button-more {
+ list-style-image: var(--icon-more);
+}
+
+:root[lwt-tree] tree,
+:root[lwt-tree] #folderPaneHeader {
+ background-color: var(--sidebar-background-color);
+ color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree-brighttext] treechildren::-moz-tree-twisty {
+ color: #d4d4d4;
+}
+
+:root[lwt-tree] treechildren::-moz-tree-image,
+:root[lwt-tree] #threadTree treechildren::-moz-tree-cell-text(untagged),
+:root[lwt-tree] tree:not(#threadTree) treechildren::-moz-tree-cell-text {
+ color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree] treechildren::-moz-tree-image(selected),
+:root[lwt-tree] #threadTree treechildren::-moz-tree-cell-text(untagged, selected),
+:root[lwt-tree] tree:not(#threadTree) treechildren::-moz-tree-cell-text(selected) {
+ color: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+}
+
+tree:-moz-lwtheme:not(:focus)
+ treechildren::-moz-tree-row(selected) {
+ --sidebar-highlight-background-color: hsla(0, 0%, 50%, 0.2);
+}
+
+:root[lwt-tree] tree:not(:focus) treechildren::-moz-tree-image(selected),
+:root[lwt-tree] #threadTree:not(:focus) treechildren::-moz-tree-cell-text(untagged, selected),
+:root[lwt-tree] tree:not(#threadTree,:focus) treechildren::-moz-tree-cell-text(selected) {
+ color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree] treechildren::-moz-tree-line {
+ border-color: var(--sidebar-text-color);
+}
+
+:root[lwt-tree] treechildren::-moz-tree-line(selected, focus) {
+ border-color: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+}
+
+tree > treechildren::-moz-tree-row(dropOn) {
+ border-color: var(--selected-item-text-color);
+ background-color: var(--selected-item-color) !important;
+}
+
+tree > treechildren::-moz-tree-cell-text(primary, dropOn) {
+ color: var(--selected-item-text-color);
+}
+
+tree > treechildren::-moz-tree-image(primary, dropOn) {
+ fill: color-mix(in srgb, var(--selected-item-text-color) 20%, transparent);
+ stroke: var(--selected-item-text-color) !important;
+}
+
+:root:-moz-lwtheme treecol:not([hideheader="true"]),
+:root:-moz-lwtheme .tree-columnpicker-button:not([hideheader="true"]) {
+ appearance: none;
+ color: inherit;
+ background-color: transparent;
+ padding-block: 2px;
+ border-bottom: 1px solid color-mix(in srgb, var(--splitter-color,
+ hsla(0, 0%, 60%, 0.4)) 50%, transparent);
+}
+
+:root:-moz-lwtheme treecol {
+ border-inline-end: 1px solid color-mix(in srgb, var(--splitter-color,
+ hsla(0, 0%, 60%, 0.4)) 50%, transparent);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:-moz-lwtheme treecol,
+ :root:-moz-lwtheme .tree-columnpicker-button {
+ border-color: var(--splitter-color, rgba(249, 249, 250, 0.2));
+ }
+
+ :root:-moz-lwtheme treecol {
+ border-inline-end-color: var(--splitter-color, rgba(249, 249, 250, 0.2));
+ }
+}
+
+:root:-moz-lwtheme treecol:hover,
+:root:-moz-lwtheme .tree-columnpicker-button:hover {
+ background-color: hsla(0, 0%, 60%, 0.4);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:-moz-lwtheme treecol:hover,
+ :root:-moz-lwtheme .tree-columnpicker-button:hover {
+ background-color: rgba(249, 249, 250, 0.2);
+ }
+}
+
+.treecol-sortdirection {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.toolbarbutton-1:not(.qfb-tag-button),
+.toolbarbutton-menubutton-button {
+ color: inherit !important;
+}
+
+/* Don't show a menupopup in customize mode */
+toolbarpaletteitem menulist,
+toolbarpaletteitem toolbarbutton {
+ pointer-events: none;
+}
+
+/* throbber */
+
+#throbber-box {
+ margin: 0 3px;
+ /* Allow image to be center aligned vertically (when in the toolbar) or
+ * horizontally (when in the customize toolbar dialog). */
+ display: grid;
+}
+
+#throbber-box img {
+ width: 16px;
+ height: 16px;
+ align-self: center;
+ justify-self: center;
+}
+
+#throbber-box:not([busy="true"]) .animated-throbber-icon {
+ /* Hide the icon, but still occupy space in the toolbar. */
+ visibility: hidden;
+}
+
+/* When the throbber-box is wrapped by the customizeToolbar dialog, we show the
+ * static image. */
+#wrapper-throbber-box > #throbber-box .animated-throbber-icon {
+ display: none;
+}
+/* Else, we hide the static image. */
+:not(#wrapper-throbber-box) > #throbber-box .static-throbber-icon {
+ display: none;
+}
+
+/* Panels. */
+
+panelmultiview[transitioning] {
+ pointer-events: none;
+}
+
+.panel-viewcontainer {
+ overflow: hidden;
+}
+
+.panel-viewcontainer[panelopen] {
+ transition-property: height;
+ transition-timing-function: var(--animation-easing-function);
+ transition-duration: var(--panelui-subview-transition-duration);
+ will-change: height;
+}
+
+.panel-viewcontainer.offscreen {
+ display: block;
+}
+
+.panel-viewstack {
+ overflow: visible;
+ transition: height var(--panelui-subview-transition-duration);
+}
+
+/* Status panel */
+
+statuspanel {
+ position: fixed;
+ display: block;
+ left: 0;
+ bottom: 0;
+ z-index: 20;
+ max-width: 50%;
+ transition: opacity 120ms ease-out, visibility 120ms;
+}
+
+statuspanel:-moz-locale-dir(ltr)[mirror],
+statuspanel:-moz-locale-dir(rtl):not([mirror]) {
+ left: auto;
+ right: 0;
+}
+
+statuspanel[label=""],
+statuspanel:not([label]) {
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
+}
+
+.statuspanel-inner {
+ height: 3em;
+ align-items: flex-end;
+}
+
+.statuspanel-label {
+ display: inline-block;
+}
+
+:root[lwt-tree] .statuspanel-label {
+ background-color: var(--toolbar-field-background-color);
+ color: var(--toolbar-field-color);
+ border-color: var(--toolbar-field-border-color);
+}
+
+/* Status bar */
+
+.statusbar {
+ background-color: var(--layout-background-1);
+ border-top: 1px solid var(--splitter-color);
+ min-height: 22px;
+}
+
+#status-bar:-moz-lwtheme {
+ appearance: none;
+ background-repeat: no-repeat, var(--lwt-background-tiling);
+ background-position: bottom right, var(--lwt-background-alignment);
+ background-color: var(--lwt-accent-color);
+ background-image: var(--lwt-header-image), var(--lwt-additional-images);
+ color: var(--lwt-text-color);
+ border-top-color: var(--lwt-tabs-border-color);
+}
+
+@media (prefers-color-scheme: dark) {
+ .statusbar:-moz-lwtheme {
+ border-top-color: var(--lwt-accent-color);
+ }
+}
+
+#status-bar:-moz-lwtheme toolbarbutton {
+ color: var(--lwt-text-color, inherit);
+}
+
+.statusbarpanel {
+ padding: 0 4px;
+}
+
+label.statusbarpanel {
+ margin-block: 3px;
+ display: inline-block;
+}
+
+.statusbarpanel-progress {
+ align-items: center;
+}
+
+#dialog\.progress,
+#shutdown_progressmeter,
+.progressmeter-statusbar {
+ appearance: none;
+ height: 4px;
+ background-color: hsla(0, 0%, 60%, 0.2);
+ border-style: none;
+ border-radius: 2px;
+}
+
+#dialog\.progress::-moz-progress-bar,
+#shutdown_progressmeter::-moz-progress-bar,
+.progressmeter-statusbar::-moz-progress-bar {
+ appearance: none;
+ background-color: var(--primary);
+ border-radius: 2px;
+}
+
+#dialog\.progress:indeterminate::-moz-progress-bar,
+#shutdown_progressmeter:indeterminate::-moz-progress-bar,
+.progressmeter-statusbar:indeterminate::-moz-progress-bar {
+ /* Make a white reflecting animation.
+ Create a gradient with 2 identical pattern, and enlarge the size to 200%.
+ This allows us to animate background-position with percentage. */
+ background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%,
+ rgba(255, 255, 255, 0.2) 25%,
+ rgba(255, 255, 255, 0.8) 50%,
+ rgba(255, 255, 255, 0.2) 75%,
+ rgba(255, 255, 255, 0.8) 100%);
+ background-size: 200% 100%;
+}
+
+@media (prefers-color-scheme: dark) {
+ #dialog\.progress:indeterminate::-moz-progress-bar,
+ #shutdown_progressmeter:indeterminate::-moz-progress-bar,
+ .progressmeter-statusbar:indeterminate::-moz-progress-bar {
+ background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.5) 0%,
+ rgba(0, 0, 0, 0.1) 25%,
+ rgba(0, 0, 0, 0.5) 50%,
+ rgba(0, 0, 0, 0.1) 75%,
+ rgba(0, 0, 0, 0.5) 100%);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ #dialog\.progress:indeterminate::-moz-progress-bar,
+ #shutdown_progressmeter:indeterminate::-moz-progress-bar,
+ .progressmeter-statusbar:indeterminate::-moz-progress-bar {
+ animation: progressSlideX 1.5s linear infinite;
+ }
+
+ @keyframes progressSlideX {
+ 0% {
+ background-position: 0 0;
+ }
+ 100% {
+ background-position: -100% 0;
+ }
+ }
+}
+
+/* online/offline icons */
+
+#offline-status {
+ padding: 0 2px;
+ list-style-image: var(--icon-online);
+ appearance: none;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#offline-status[offline="true"] {
+ list-style-image: var(--icon-offline);
+}
+
+/* status bar adjustments */
+
+#quotaMeter {
+ min-width: 8em;
+ max-width: 8em;
+ height: 10px;
+ border: 1px solid var(--chrome-content-separator-color);
+}
+
+#quotaPercentageBar {
+ border-color: ThreeDShadow;
+}
+
+#quotaPanel ::-moz-progress-bar {
+ appearance: none;
+ background-color: #45a1ff;
+ opacity: .75;
+ border-radius: 0;
+}
+
+#quotaPanel.alert-warning ::-moz-progress-bar {
+ background-color: orange;
+}
+
+#quotaPanel.alert-critical ::-moz-progress-bar {
+ background-color: red;
+ opacity: .6;
+}
+
+#quotaLabel {
+ text-align: center;
+ font-size: 0.8rem;
+ text-decoration: none;
+ margin-top: -1px;
+}
+
+/* searchTermOverlay */
+@media (prefers-reduced-motion: no-preference) {
+ #searchTermList > richlistitem[highlight = "true"] {
+ animation: highlight .4s ease-in;
+ }
+
+ @keyframes highlight {
+ from { background-color: Highlight; }
+ to { background-color: transparent; }
+ }
+}
+
+#findbar-beforeReplaceSeparator {
+ height: 16px;
+}
+
+findbar {
+ background-color: var(--toolbar-bgcolor);
+ color: var(--toolbar-color);
+ border-top-color: var(--chrome-content-separator-color, ThreeDShadow);
+}
+
+findbar:-moz-lwtheme {
+ background-color: var(--lwt-accent-color);
+ background-image: linear-gradient(var(--toolbar-bgcolor), var(--toolbar-bgcolor)), var(--lwt-additional-images);
+ background-repeat: no-repeat, var(--lwt-background-tiling);
+ background-position: right bottom, var(--lwt-background-alignment);
+ background-position-y: bottom;
+}
+
+:root[lwtheme-image] findbar {
+ background-image: linear-gradient(var(--toolbar-bgcolor), var(--toolbar-bgcolor)),
+ var(--lwt-header-image), var(--lwt-additional-images);
+ background-repeat: no-repeat, no-repeat, var(--lwt-background-tiling);
+ background-position: center, right bottom, var(--lwt-background-alignment);
+}
+
+/* Workaround until bug 1828322 is fixed */
+#messagepanebox > findbar {
+ color: var(--layout-color-1);
+ background-color: var(--layout-background-1);
+ border-top-color: var(--splitter-color);
+}
+
+#messagepanebox > findbar > .findbar-container {
+ min-width: 100px;
+}
+
+/* menupopup/panel */
+:is(panel, menupopup)::part(content) {
+ --panel-color: var(--layout-color-1);
+ --panel-background: var(--layout-background-1);
+ --panel-border-color: var(--layout-border-1);
+}
+
+/* Panel toolbarbuttons */
+
+.panelTitle {
+ margin-top: 8px;
+ margin-inline-start: 7px;
+ margin-bottom: 6px;
+}
+
+.panelButton {
+ appearance: none;
+ min-height: 24px;
+ padding: 4px 6px;
+ background-color: transparent;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+.panelButton:focus {
+ outline: 0;
+}
+
+.panelButton:not([disabled],[open],:active):is(:hover,:focus-visible) {
+ background-color: var(--arrowpanel-dimmed);
+}
+
+.panelButton:not([disabled]):is([open],:hover:active) {
+ background-color: var(--arrowpanel-dimmed-further);
+ box-shadow: 0 1px 0 hsla(210, 4%, 10%, .03) inset;
+}
+
+.panelButton > .toolbarbutton-text {
+ text-align: start;
+ padding-inline-start: 6px;
+ padding-inline-end: 6px;
+}
+
+.panelButton[prettykey]::after {
+ content: attr(prettykey);
+ float: inline-end;
+ color: GrayText;
+}
+
+toolbarpaletteitem toolbarspacer,
+toolbarpaletteitem toolbarspring {
+ -moz-window-dragging: no-drag;
+}
+
+/* MailExtension panels */
+
+.mail-extension-panel {
+ font: menu;
+}
+
+/* Autocomplete labels
+ * These styles match those in chrome://calendar/content/calendar-event-dialog-attendees.css. */
+
+html|span.ac-emphasize-text {
+ font-weight: bold;
+}
+
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-url,
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-separator {
+ display: flex;
+}
+
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-url {
+ order: 1;
+}
+
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-separator {
+ order: 2;
+}
+
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-title {
+ order: 3;
+}
+
+.ac-url-text {
+ max-width: unset !important;
+}
+
+/* Date/time picker anchor */
+.popup-anchor {
+ /* should occupy space but not be visible */
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+}
+
+html|input[type="number"] {
+ margin: 2px 4px;
+}
+/* sizes: chars + 8px padding + 1px borders + spin buttons 14+0+10px */
+html|input[type="number"].size2 {
+ width: calc(2ch + 44px);
+}
+html|input[type="number"].size3 {
+ width: calc(3ch + 44px);
+}
+html|input[type="number"].size4 {
+ width: calc(4ch + 44px);
+}
+html|input[type="number"].size5 {
+ width: calc(5ch + 44px);
+}
+
+/* Fix the height of the print preview toolbar */
+#print-preview-toolbar {
+ max-height: 2.5em;
+}
+
+/* Toolbar customization */
+
+toolbar[customizing="true"] {
+ outline: 2px dashed var(--focus-outline-color);
+ outline-offset: -3px;
+ border-radius: 3px;
+}
+
+toolbarpaletteitem[type="spacer"][place="toolbar"],
+toolbarpaletteitem[type="spring"][place="toolbar"] {
+ align-items: center;
+}
+
+toolbarpaletteitem[type="spring"][place="toolbar"] {
+ flex: 1000 1000;
+}
+
+toolbarpaletteitem[type="spacer"][place="toolbar"] toolbarspacer,
+toolbarpaletteitem[type="spring"][place="toolbar"] toolbarspring {
+ height: 16px;
+ margin-inline: 6px;
+ opacity: 0.5;
+ border-inline: 2px solid currentColor;
+ background-image: linear-gradient(transparent calc(50% - 1px), currentColor calc(50% - 1px),
+ currentColor calc(50% + 1px), transparent calc(50% + 1px));
+}
+
+toolbarpaletteitem[type="separator"][place="palette"] toolbarseparator {
+ appearance: none;
+ width: 1px;
+ height: 50px;
+ background-color: currentColor;
+}
+
+toolbarpaletteitem[type="spacer"][place="palette"] toolbarspacer,
+toolbarpaletteitem[type="spring"][place="palette"] toolbarspring {
+ margin-bottom: 2px;
+ border: 1px solid currentColor;
+ background-color: hsla(0, 0%, 100%, .3) !important;
+}
+
+toolbarpaletteitem[type="spring"][place="palette"] toolbarspring {
+ background: url("chrome://messenger/skin/icons/spring.svg") no-repeat center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+panel[type="arrow"].panel-block-padding::part(content) {
+ padding-block: 6px;
+}
+
+/* Used by the import dialog */
+.wizard-box {
+ padding: 20px 44px 10px;
+}
+
+.image-container > html|img {
+ width: 32px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.text-container description {
+ margin-block-end: 1em;
+}
+
+#viewSource-toolbox {
+ appearance: none;
+}
+
+/* Print UI, mostly from browser.css */
+
+.browserContainer,
+.browserStack {
+ flex: 10000 10000;
+}
+
+.printPreviewStack {
+ position: relative;
+}
+
+:is(browser, hbox, tabbox, vbox)[tabDialogShowing],
+:is(browser, hbox, tabbox, vbox)[tabDialogShowing] * {
+ -moz-user-focus: none !important;
+}
+
+.printSettingsBrowser {
+ width: 250px !important;
+}
+
+.previewStack {
+ background-color: #f9f9fa;
+ color: #0c0c0d;
+}
+
+.previewRendering {
+ background-repeat: no-repeat;
+ background-size: 60px 60px;
+ background-position: center center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ visibility: hidden;
+}
+
+.printPreviewBrowser {
+ visibility: collapse;
+ opacity: 1;
+}
+
+.previewStack[rendering=true] > .previewRendering,
+.previewStack[previewtype="source"] > .printPreviewBrowser[previewtype="source"],
+.previewStack[previewtype="selection"] > .printPreviewBrowser[previewtype="selection"],
+.previewStack[previewtype="simplified"] > .printPreviewBrowser[previewtype="simplified"] {
+ visibility: inherit;
+}
+
+.previewStack[rendering=true] > .printPreviewBrowser {
+ opacity: 0;
+}
+
+.print-pending-label {
+ margin-top: 110px;
+ font-size: large;
+}
+
+printpreview-pagination {
+ opacity: 0;
+}
+printpreview-pagination:focus-within,
+.previewStack:hover printpreview-pagination {
+ opacity: 1;
+}
+.previewStack[rendering=true] printpreview-pagination {
+ opacity: 0;
+}
+
+@media (prefers-color-scheme: dark) {
+ .previewStack {
+ background-color: #2A2A2E;
+ color: rgb(249, 249, 250);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .previewRendering {
+ background-image: url("chrome://messenger/skin/images/pendingpaint.png");
+ }
+
+ .printPreviewBrowser {
+ transition: opacity 60ms;
+ }
+
+ .previewStack[rendering=true] > .printPreviewBrowser {
+ transition: opacity 1ms 250ms;
+ }
+
+ printpreview-pagination {
+ transition: opacity 100ms 500ms;
+ }
+
+ printpreview-pagination:focus-within,
+ .previewStack:hover printpreview-pagination {
+ transition: opacity 100ms;
+ }
+}
+
+.dialogStack {
+ /* Should outrank the z-index values of other UI elements, particularly the devtools
+ splitter element. */
+ z-index: 11;
+ position: absolute;
+ inset: 0 0 0 0;
+}
+
+.dialogStack.temporarilyHidden {
+ /* For some printing use cases we need to visually hide the dialog before
+ * actually closing it / make it disappear from the frame tree. */
+ visibility: hidden;
+}
+
+.dialogOverlay {
+ visibility: hidden;
+}
+
+.dialogOverlay[topmost="true"] {
+ z-index: 1;
+}
+
+.dialogBox {
+ background-clip: content-box;
+ box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 0.2);
+ display: flex;
+ margin: 0;
+ padding: 0;
+ overflow-x: auto;
+ border-radius: 8px;
+}
+
+.dialogBox[resizable="true"] {
+ resize: both;
+ overflow: hidden;
+ min-height: 20em;
+}
+
+.dialogBox[sizeto="available"] {
+ --box-inline-margin: 4px;
+ --box-block-margin: 4px;
+ --box-ideal-width: 1000;
+ --box-ideal-height: 650;
+ --box-max-width-margin: calc(100vw - 2 * var(--box-inline-margin));
+ --box-max-height-margin: calc(100vh - var(--box-top-px) - var(--box-block-margin));
+ --box-max-width-ratio: 70vw;
+ --box-max-height-ratio: calc(var(--box-ideal-height) / var(--box-ideal-width) * var(--box-max-width-ratio));
+ max-width: min(max(var(--box-ideal-width) * 1px, var(--box-max-width-ratio)), var(--box-max-width-margin));
+ max-height: min(max(var(--box-ideal-height) * 1px, var(--box-max-height-ratio)), var(--box-max-height-margin));
+ width: 100vw;
+ height: 85vh; /* This is 100vh in Firefox but we have less space to work with. */
+}
+
+@media (min-width: 550px) {
+ .dialogBox[sizeto="available"] {
+ --box-inline-margin: min(calc(4px + (100vw - 550px) / 4), 16px);
+ }
+}
+
+@media (min-width: 800px) {
+ .dialogBox[sizeto="available"] {
+ --box-inline-margin: min(calc(16px + (100vw - 800px) / 4), 32px);
+ }
+}
+
+@media (min-height: 350px) {
+ .dialogBox[sizeto="available"] {
+ --box-block-margin: min(calc(4px + (100vh - 350px) / 4), 16px);
+ }
+}
+
+@media (min-height: 550px) {
+ .dialogBox[sizeto="available"] {
+ --box-block-margin: min(calc(16px + (100vh - 550px) / 4), 32px);
+ }
+}
+
+:not(.content-prompt-dialog) > .dialogOverlay > .dialogBox {
+ /* Make dialogs overlap with upper chrome UI. */
+ margin-top: 5px;
+}
+
+.dialogOverlay[topmost="true"] {
+ background-color: rgba(28, 27, 34, 0.45);
+}
diff --git a/comm/mail/themes/shared/mail/messengercompose.css b/comm/mail/themes/shared/mail/messengercompose.css
new file mode 100644
index 0000000000..1c599c1d06
--- /dev/null
+++ b/comm/mail/themes/shared/mail/messengercompose.css
@@ -0,0 +1,1485 @@
+/* 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 html url("http://www.w3.org/1999/xhtml");
+
+:root {
+ --lwt-additional-images: none;
+ --lwt-background-alignment: right top;
+ --lwt-background-tiling: no-repeat;
+ --toolbar-bgcolor: var(--toolbar-non-lwt-bgcolor);
+ --toolbar-color: var(--toolbar-non-lwt-textcolor);
+ --autocomplete-box-padding: 3px;
+ --autocomplete-item-padding: 3px;
+ --autocomplete-item-radius: var(--button-border-radius);
+}
+
+:root[uidensity="compact"] {
+ --autocomplete-box-padding: 0;
+ --autocomplete-item-padding: 0;
+ --autocomplete-item-radius: 0;
+}
+
+:root[uidensity="touch"] {
+ --autocomplete-item-padding: 8px 3px;
+}
+
+#contactsSidebar .sidebar-header {
+ appearance: none;
+ height: 30px;
+ text-shadow: none;
+ background-color: -moz-Dialog;
+ background-image: linear-gradient(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05));
+ color: -moz-dialogText;
+ border-bottom: 1px solid var(--lwt-tabs-border-color);
+}
+
+:root[lwt-tree] #contactsSidebar .sidebar-header {
+ background-color: var(--toolbar-bgcolor);
+ color: var(--lwt-text-color);
+ border-bottom: 1px solid var(--lwt-tabs-border-color);
+}
+
+:root[lwt-tree-brighttext] #contactsSidebar .sidebar-header {
+ background-image: linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05));
+}
+
+#contactsSidebar toolbarbutton.close-icon {
+ margin-inline-end: 3px;
+}
+
+/* Styles for the default system dark theme */
+
+:root[lwt-tree] :is(#MsgHeadersToolbar, #FormatToolbox) {
+ background-color: var(--toolbar-bgcolor) !important;
+ color: var(--lwt-text-color);
+}
+
+:root[lwt-tree] panel[type="autocomplete-richlistbox"] {
+ margin-top: -1px;
+ padding: 2px 0;
+ --panel-background: var(--arrowpanel-background);
+ --panel-color: var(--arrowpanel-color);
+ --panel-border-color: var(--arrowpanel-border-color);
+}
+
+:root[lwt-tree] .autocomplete-richlistbox {
+ color: inherit;
+ background-color: inherit;
+}
+
+:root[lwt-tree] .autocomplete-richlistitem[selected] {
+ background-color: var(--autocomplete-popup-highlight-background);
+ color: var(--autocomplete-popup-highlight-color);
+}
+
+.autocomplete-richlistbox {
+ padding: var(--autocomplete-box-padding);
+}
+
+.autocomplete-richlistitem {
+ padding: var(--autocomplete-item-padding);
+ border-radius: var(--autocomplete-item-radius);
+}
+
+#attachmentBucket {
+ grid-area: attachment-list;
+ border-block: 1px solid var(--splitter-color); /* The same color as the splitters */
+ padding: 1px;
+}
+
+:root[lwt-tree] #attachmentArea > summary {
+ background-color: var(--toolbar-bgcolor);
+ color: var(--toolbar-color);
+}
+
+#attachmentArea > summary {
+ grid-area: attachment-header;
+ padding: 6px;
+ /* Position self for the #newAttachmentIndicator. */
+ position: relative;
+ display: flex;
+ gap: 6px;
+ align-items: baseline;
+}
+
+#attachmentArea > summary > * {
+ flex: 0 0 auto;
+}
+
+#attachmentArea > summary:focus-visible {
+ outline-style: auto;
+ outline-offset: -1px;
+}
+
+#newAttachmentIndicator {
+ color: var(--selected-item-text-color);
+ background-color: var(--selected-item-color);
+ font-size: 0.85em;
+ padding: 2px 5px;
+ border-radius: 10px;
+ font-weight: 600;
+ position: absolute;
+ inset-inline-start: 3px;
+ z-index: 9;
+ opacity: 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .is_animating {
+ animation: new-attachment 1s steps(30) 1;
+ }
+
+ @keyframes new-attachment {
+ 0% {
+ opacity: 0;
+ margin-block-start: 0;
+ }
+ 50% {
+ opacity: 1;
+ margin-block-start: -50px;
+ }
+ 100% {
+ opacity: 0;
+ margin-block-start: -100px;
+ }
+ }
+
+ #attachmentToggle {
+ transition: transform 200ms ease;
+ }
+}
+
+#attachmentToggle {
+ align-self: center;
+ color: inherit;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ fill-opacity: 1;
+}
+
+#attachmentArea:not([open]) #attachmentToggle {
+ transform: rotate(-90deg);
+}
+
+#attachmentArea:-moz-locale-dir(rtl):not([open]) #attachmentToggle {
+ transform: rotate(90deg);
+}
+
+#attachmentBucketCount {
+ text-overflow: ellipsis;
+ /* Required for text-overflow to do anything */
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+#attachmentBucketSize {
+ color: var(--selected-item-text-color);
+ background-color: var(--selected-item-color);
+ font-size: 0.85em;
+ line-height: 1em;
+ padding: 3px 5px;
+ border-radius: 10px;
+ font-weight: 500;
+}
+
+.drop-attachment-overlay {
+ --overlay-color: #fff;
+ --overlay-backround: rgba(0, 0, 0, 0.5);
+ --drop-attachment-box-color: #222;
+ --drop-attachment-box-border-color: rgba(255, 255, 255, 0.85);
+ --drop-attachment-box-background-hover: rgba(255, 255, 255, 0.5);
+ --drop-attachment-box-border-color-hover: #fff;
+ --drop-attachment-title-background: rgba(255, 255, 255, 0.85);
+}
+
+:root[lwt-tree-brighttext] .drop-attachment-overlay {
+ --drop-attachment-box-color: #cbcbcb;
+ --drop-attachment-box-border-color: #999;
+ --drop-attachment-box-background-hover: rgba(0, 0, 0, 0.5);
+ --drop-attachment-box-border-color-hover: #fff;
+ --drop-attachment-title-background: rgba(0, 0, 0, 0.85);
+}
+
+.drop-attachment-overlay {
+ pointer-events: none;
+ position: fixed;
+ z-index: 12; /* above the attachment bucket splitter */
+ background-color: var(--overlay-backround);
+ color: var(--overlay-color);
+ inset: 0;
+ padding: 30px;
+ display: none;
+ justify-content: space-around;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ @keyframes hiding-animation {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+ @keyframes showing-animation {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+ }
+
+ .drop-attachment-box {
+ transition: color 120ms ease, background-color 120ms ease, border 120ms ease;
+ }
+
+ .drop-attachment-box span {
+ transition: background-color 120ms ease;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ /*
+ * Redefine these animations but without any visible transition as we still
+ * need the timing for the animationend JavaScript event.
+ */
+ @keyframes hiding-animation {
+ 0% { opacity: 1; }
+ 100% { opacity: 1; }
+ }
+ @keyframes showing-animation {
+ 0% { opacity: 1; }
+ 100% { opacity: 1; }
+ }
+}
+
+.drop-attachment-overlay.hiding {
+ animation: hiding-animation 120ms ease 1;
+}
+
+.drop-attachment-overlay.showing {
+ display: flex;
+ animation: showing-animation 120ms ease 1;
+}
+
+.drop-attachment-overlay.show {
+ display: flex;
+}
+
+.drop-attachment-box {
+ pointer-events: auto;
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: var(--drop-attachment-box-color);
+ border-radius: 15px;
+ border: 4px dashed var(--drop-attachment-box-border-color);
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.drop-attachment-box:not(.hidden) + .drop-attachment-box {
+ margin-inline-start: 30px;
+}
+
+.drop-attachment-box span {
+ pointer-events: none;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ padding-inline: 25px 6px;
+ border-radius: 3px;
+ background-color: var(--drop-attachment-title-background);
+ background-position: 6px center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+}
+
+.drop-attachment-box span:-moz-locale-dir(rtl) {
+ background-position-x: right 6px;
+}
+
+.drop-attachment-box .drop-as-attachment {
+ background-image: url("chrome://messenger/skin/icons/attach.svg");
+}
+
+.drop-attachment-box .drop-inline {
+ background-image: url("chrome://messenger/skin/icons/image.svg");
+}
+
+.drop-attachment-box.hidden {
+ display: none;
+}
+
+.drop-attachment-box.hover {
+ border-color: var(--drop-attachment-box-border-color-hover);
+ border-style: solid;
+ background-color: var(--drop-attachment-box-background-hover);
+}
+
+.drop-attachment-box.hover span {
+ background-color: transparent;
+}
+
+.add-attachment-label {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background: url("chrome://messenger/skin/icons/attach.svg") left center no-repeat;
+ background-size: contain;
+ padding-inline-start: 25px;
+}
+
+#compose-toolbox > toolbar {
+ /* force iconsize="small" on these toolbars */
+ counter-reset: smallicons;
+ background-color: var(--toolbar-bgcolor);
+}
+
+#compose-toolbox:-moz-lwtheme {
+ appearance: none;
+ position: relative;
+ color: var(--toolbar-color, inherit);
+}
+
+#compose-toolbox:-moz-lwtheme::after {
+ content: "";
+ display: block;
+ position: absolute;
+ pointer-events: none;
+ top: -1px;
+ width: -moz-available;
+ height: 1px;
+ border-bottom: 1px solid var(--lwt-tabs-border-color, transparent);
+}
+
+#composeToolbar2 {
+ padding-inline: 3px;
+}
+
+#composeContentBox {
+ /* In order to remove the shadow border on left/right edges elegantly, use
+ * left/right margins of -3px. We make up for this by adding 3px of padding
+ * instead. */
+ margin-inline: -3px;
+ padding-inline: 3px;
+ display: grid;
+ grid-template: "contacts contacts-splitter headers" minmax(auto, var(--headersSplitter-height))
+ "contacts contacts-splitter format-toolbar" min-content
+ "contacts contacts-splitter headers-splitter" min-content
+ "contacts contacts-splitter message" minmax(33%, 1fr)
+ "contacts contacts-splitter attachment-splitter" min-content
+ "contacts contacts-splitter attachment-header" min-content
+ "contacts contacts-splitter attachment-list" var(--attachment-list-track-size)
+ / minmax(auto, var(--contactsSplitter-width)) min-content minmax(auto, 1fr);
+ /* If the splitter is not used, the header and attachment areas will try and
+ * grow to their content size. */
+ --headersSplitter-height: min-content;
+ --attachmentSplitter-height: min-content;
+ --contactsSplitter-width: 200px;
+ /* NOTE: We specify the sizing of the attachment list using a variable because
+ * when the attachment area is hidden or the attachment list is closed, we
+ * want to adjust the sizing so that we can ignore the splitter height. */
+ --attachment-list-track-size: minmax(auto, var(--attachmentSplitter-height));
+ /* The parent body uses the -moz-box display, which does not take into account
+ * the natural minimum height this element can take due to its grid display.
+ * So we need to explicitly set the minimum height so that the body's layout
+ * will properly resize this element to the available space.
+ * TODO: Remove these rules when the body uses a standard CSS display. */
+ min-height: 0;
+ flex: 1;
+ overflow: clip;
+}
+
+#contactsSidebar {
+ grid-area: contacts;
+ box-sizing: border-box;
+ min-width: 150px;
+ max-width: 400px;
+ display: flex;
+ flex-direction: column;
+}
+
+#contactsSidebar .sidebar-header {
+ flex: 0 0 auto;
+}
+
+#contactsBrowser {
+ flex: 1 1 auto;
+}
+
+#contactsSidebar.collapsed-by-splitter {
+ display: none;
+}
+
+#contactsSplitter {
+ grid-area: contacts-splitter;
+}
+
+#contactsSplitter.splitter-collapsed {
+ /* The splitter cannot be un-collapsed using a mouse drag. */
+ display: none;
+}
+
+#MsgHeadersToolbar {
+ grid-area: headers;
+}
+
+#FormatToolbox {
+ appearance: none;
+ grid-area: format-toolbar;
+}
+
+#headersSplitter {
+ grid-area: headers-splitter;
+}
+
+#messageArea {
+ grid-area: message;
+ display: flex;
+ flex-direction: column;
+}
+
+#messageEditor {
+ flex: 1 1 0;
+ min-height: 0;
+}
+
+#FindToolbar {
+ flex: 0 0 auto;
+}
+
+@media (prefers-color-scheme: dark) {
+ #messageArea {
+ background-color: #2a2a2e;
+ }
+}
+
+#attachmentSplitter {
+ grid-area: attachment-splitter;
+}
+
+#attachmentArea {
+ /* Children are grid items. */
+ display: contents;
+}
+
+/* When the attachment area is hidden, or the visibility of the attachmentBucket
+ * is toggled by the summary element. */
+#composeContentBox:is(.attachment-area-hidden, .attachment-bucket-closed) {
+ /* We adjust the track sizing so it no longer takes up any grid space. */
+ --attachment-list-track-size: 0;
+}
+
+#composeContentBox.attachment-bucket-closed #attachmentSplitter {
+ /* NOTE: When the bucket is closed, we do not consider it "collapsed" by the
+ * splitter. It was closed by the attachmentArea's summary, not the splitter.
+ * Moreover, it cannot be un-collapsed by the splitter either.
+ * Instead, we want to simply stop the splitter from resizing by making it
+ * non-interactive. We keep the splitter visible though as it still acts as a
+ * barrier between the message body and the attachment area. */
+ pointer-events: none;
+}
+
+#composeContentBox.attachment-area-hidden #attachmentSplitter {
+ /* We completely hide the splitter when the attachment area is hidden. */
+ display: none;
+}
+
+#composeContentBox.attachment-area-hidden #attachmentArea {
+ display: none;
+}
+
+#composeContentBox.attachment-bucket-closed #attachmentBucket {
+ display: none;
+}
+
+/* :::: primary toolbar buttons :::: */
+
+#button-send {
+ list-style-image: var(--icon-sent);
+}
+
+#button-contacts {
+ list-style-image: var(--icon-address-book);
+}
+
+#spellingButton {
+ list-style-image: var(--icon-spelling);
+}
+
+#button-attach {
+ list-style-image: var(--icon-attachment);
+}
+
+#button-encryption {
+ list-style-image: var(--icon-lock-disabled);
+}
+
+#button-encryption[checked] {
+ list-style-image: var(--icon-lock);
+}
+
+#button-encryption-options {
+ list-style-image: var(--icon-shield);
+}
+
+#button-signing {
+ list-style-image: var(--icon-ribbon);
+}
+
+#button-save {
+ list-style-image: var(--icon-download);
+}
+
+#quoteButton {
+ list-style-image: var(--icon-quote);
+}
+
+#button-returnReceipt {
+ list-style-image: var(--icon-receipt);
+}
+
+#cut-button {
+ list-style-image: var(--icon-cut);
+}
+
+#copy-button {
+ list-style-image: var(--icon-copy);
+}
+
+#paste-button {
+ list-style-image: var(--icon-paste);
+}
+
+#button-print {
+ list-style-image: var(--icon-print);
+}
+
+.menu-description,
+menulist::part(description) {
+ font-style: italic;
+ opacity: 0.55;
+ margin-inline: 1ex !important;
+}
+
+.aw-firstColBox {
+ /* aw-firstColBox inline padding (4px + 4px) + remove-field-button inline
+ * padding (2px + 2px) + img width (16px) */
+ padding: 0 4px;
+ width: 28px;
+}
+
+.aw-firstColBox,
+#identityLabel-box {
+ flex-shrink: 0;
+}
+
+/* :::: Format toolbar :::: */
+
+#FormatToolbar:not([hidden="true"]) {
+ display: flex;
+}
+
+/*
+ * Removed from global.css in bug 1484949. It's needed so the formatting
+ * toolbar is not disabled while a dropdown (paragraph format or font) is active.
+ */
+.toolbar-focustarget {
+ -moz-user-focus: ignore !important;
+}
+
+#ParagraphSelect {
+ flex-shrink: 0.1;
+ min-width: 7em;
+}
+
+#FontFaceSelect {
+ flex-shrink: 2;
+ min-width: 7em;
+}
+
+#FormatToolbar > menulist {
+ margin-block: 1px;
+}
+
+#FormatToolbar > menulist:not(:hover) {
+ background: transparent;
+}
+
+#FormatToolbar > menulist::part(label-box) {
+ text-shadow: none;
+}
+
+#FormatToolbar > menulist:not([disabled="true"],[open="true"]):hover {
+ background: var(--toolbarbutton-hover-background);
+}
+
+#FormatToolbar > menulist[open="true"] {
+ background: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+}
+
+#FormatToolbar > toolbarbutton > .toolbarbutton-text {
+ display: none;
+}
+
+toolbarbutton.formatting-button {
+ appearance: none;
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+ color: inherit;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ transition-property: background-color, border-color, box-shadow;
+ transition-duration: 150ms;
+}
+
+toolbarbutton.formatting-button:not([disabled="true"]):hover {
+ background: var(--toolbarbutton-hover-background);
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+}
+
+toolbarbutton.formatting-button:not([disabled="true"]):is([open="true"],[checked="true"],:hover:active) {
+ background: var(--toolbarbutton-checked-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+}
+
+toolbarbutton.formatting-button:not([disabled="true"]):is([open="true"],:hover:active) {
+ background: var(--toolbarbutton-active-background) !important;
+}
+
+.formatting-button > .toolbarbutton-menu-dropmarker {
+ list-style-image: url("chrome://messenger/skin/messengercompose/format-dropmarker.svg");
+ -moz-context-properties: fill;
+ fill: currentColor;
+ display: inline-flex;
+}
+
+toolbarbutton.formatting-button[disabled="true"] > .toolbarbutton-icon,
+.formatting-button[disabled="true"] > .toolbarbutton-menu-dropmarker {
+ opacity: 0.4;
+}
+
+#FontFaceSelect {
+ max-width: 35ch;
+}
+
+/* ..... fg/bg color picker ..... */
+
+#ColorButtons {
+ margin-inline: 3px 4px;
+}
+
+.color-button {
+ border: 1px solid var(--toolbarbutton-active-bordercolor);
+ padding: 0;
+ width: 18px;
+ height: 15px;
+ margin: 2px;
+}
+
+.color-button[disabled="true"] {
+ opacity: 0.5;
+}
+
+.ColorPickerLabel {
+ border: 1px inset ThreeDFace;
+ margin: 0;
+ padding: 2px;
+}
+
+#TextColorButton {
+ margin-block: 2px 9px;
+ margin-inline: 2px 9px;
+}
+
+#TextColorButton[color="mixed"] {
+ background-image: url("chrome://messenger/skin/icons/multicolor.png");
+ background-size: cover;
+}
+
+#BackgroundColorButton {
+ margin-block: 9px 2px;
+ margin-inline: 9px 2px;
+}
+
+/* :::: Reorder Attachments Panel :::: */
+
+#reorderAttachmentsPanel::part(content) {
+ --panel-padding: 4px;
+}
+
+#btn_moveAttachmentFirst {
+ list-style-image: url("chrome://messenger/skin/icons/move-first.svg");
+}
+
+#btn_moveAttachmentLeft {
+ list-style-image: url("chrome://messenger/skin/icons/move-left.svg");
+}
+
+#btn_moveAttachmentRight {
+ list-style-image: url("chrome://messenger/skin/icons/move-right.svg");
+}
+
+#btn_moveAttachmentLast {
+ list-style-image: url("chrome://messenger/skin/icons/move-last.svg");
+}
+
+#btn_moveAttachmentBundleUp {
+ list-style-image: url("chrome://messenger/skin/icons/move-together.svg");
+}
+
+#btn_sortAttachmentsToggle {
+ list-style-image: url("chrome://messenger/skin/icons/sort.svg");
+}
+
+#btn_sortAttachmentsToggle[sortdirection="descending"] > .toolbarbutton-icon {
+ transform: scaleY(-1);
+}
+
+.autocomplete-richlistitem:hover {
+ background-color: var(--arrowpanel-dimmed);
+}
+
+.autocomplete-richlistitem[selected] {
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+/* :::: autocomplete icons :::: */
+
+.autocomplete-richlistitem > .ac-site-icon {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.autocomplete-richlistitem[type="local-abook"] > .ac-site-icon {
+ list-style-image: var(--icon-address-book);
+}
+
+.autocomplete-richlistitem[type="remote-abook"] > .ac-site-icon {
+ list-style-image: var(--icon-globe);
+}
+
+.autocomplete-richlistitem[type="remote-err"] > .ac-site-icon {
+ list-style-image: var(--icon-error-circle);
+}
+
+.autocomplete-richlistitem[type="subscribed-news-abook"] > .ac-site-icon {
+ list-style-image: var(--icon-newsletter);
+}
+
+/* :::: attachment notification :::: */
+
+#compose-notification-bottom > .notificationbox-stack {
+ background-color: var(--toolbar-field-focus-background-color);
+}
+
+#attachmentReminderText {
+ margin-inline-start: 0;
+ cursor: pointer;
+}
+
+#attachmentKeywords {
+ font-weight: bold;
+ margin-inline-start: 0;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+#identityLabel,
+.address-label-container label {
+ margin-inline-end: 6px;
+ text-align: right;
+}
+
+#top-gradient-box.address-identity-recipient {
+ overflow: hidden;
+}
+
+#msgIdentity {
+ flex: 0 1 auto;
+ overflow: hidden;
+ appearance: none;
+ align-items: center;
+ font: inherit;
+ margin-inline: 4px 10px;
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+ background-color: transparent;
+ transition: border .2s, box-shadow .2s, background-color .2s;
+}
+
+/* XUL element needs the full [disabled="true"] attribute. */
+#msgIdentity[disabled="true"] {
+ opacity: 0.6;
+}
+
+#msgIdentity:-moz-locale-dir(rtl) {
+ background-position: 5px;
+}
+
+#extraAddressRowsArea {
+ /* Contains the main recipient buttons, plus the button to reveal the
+ * overflow. */
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-inline-end: 6px;
+}
+
+#extraAddressRowsArea > * {
+ flex: 0 0 auto;
+}
+
+#extraAddressRowsArea.addressingWidget-separator::before {
+ display: block;
+ content: '';
+ width: 1px;
+ border-inline-start: 1px solid var(--toolbarbutton-hover-bordercolor);
+ height: 14px;
+}
+
+#msgIdentity::part(text-input) {
+ border-style: none;
+ outline-style: none;
+ margin-inline: 1px;
+}
+
+#msgIdentityPopup > menuitem[selected="true"] {
+ background-color: var(--autocomplete-popup-highlight-background);
+ color: var(--autocomplete-popup-highlight-color);
+}
+
+#msgSubjectContainer {
+ position: relative;
+}
+
+#msgEncryptedSubjectIcon {
+ position: absolute;
+ top: 8px;
+ inset-inline-start: 10px;
+}
+
+#msgSubject {
+ appearance: none;
+ margin-top: 0;
+ margin-inline: 4px 8px;
+ background-color: Field;
+ border: 1px solid var(--toolbarbutton-hover-bordercolor);
+ border-radius: var(--button-border-radius);
+ padding-block: 0;
+ padding-inline: 4px 2px;
+ transition: border .2s, box-shadow .2s;
+}
+
+#msgSubject.with-icon {
+ padding-inline-start: 25px;
+}
+
+.recipients-container {
+ display: block;
+ overflow-y: auto;
+}
+
+:root[lwt-tree] #msgSubject,
+:root[lwt-tree] .address-container {
+ background-color: var(--toolbar-field-background-color);
+ color: var(--lwt-text-color);
+}
+
+.address-row {
+ display: flex;
+ flex: 1;
+ margin-block: 6px;
+ margin-inline-end: 8px;
+ align-items: self-start;
+}
+
+.address-row > .aw-firstColBox {
+ transition: opacity .2s ease;
+ opacity: 0;
+ flex: 0 0 auto;
+ align-self: center;
+}
+
+.address-row:hover > .aw-firstColBox,
+.address-row:focus > .aw-firstColBox,
+.address-row:focus-within > .aw-firstColBox {
+ opacity: 1;
+}
+
+.address-row > .address-label-container {
+ flex: 0 0 auto;
+}
+
+.address-row > .address-container {
+ flex: 1 1 auto;
+}
+
+.address-row.hidden {
+ display: none;
+}
+
+.address-container {
+ margin-inline-start: 4px;
+ margin-inline-end: 0;
+ border: solid 1px var(--toolbarbutton-hover-bordercolor);
+ border-radius: var(--button-border-radius);
+ background-color: Field;
+ transition: border .2s, box-shadow .2s;
+ cursor: text;
+}
+
+.address-container.disable-container {
+ opacity: 0.9;
+}
+
+.address-input {
+ color: inherit;
+ outline: none;
+}
+
+.address-container > .address-input {
+ padding-block: 4px;
+}
+
+.address-container > .address-input:focus {
+ outline: none;
+}
+
+.address-pill {
+ display: flex;
+ align-items: center;
+ border-radius: var(--button-border-radius);
+ margin-inline-end: 3px;
+ margin-block: 2px;
+ padding-inline: 7px;
+ background-color: rgba(0,0,0,0.1);
+ transition: color .2s ease, background-color .2s ease, box-shadow .2s ease,
+ text-shadow .2s ease;
+ -moz-user-focus: normal;
+ cursor: default;
+ box-shadow: inset 0 0 0 1px transparent;
+}
+
+.address-pill label {
+ -moz-user-focus: none;
+ cursor: default;
+ margin-inline: 0;
+}
+
+.address-pill label,
+.address-pill hbox {
+ pointer-events: none;
+}
+
+.address-pill hbox:not([hidden="true"]) {
+ display: flex;
+}
+
+.address-pill:hover:not(.editing),
+.address-pill:focus:not(.editing) {
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3),
+ inset 0 0 0 2em rgba(0, 0, 0, 0.1);
+}
+
+.address-pill[selected]:hover:not(.editing),
+.address-pill[selected]:focus:not(.editing) {
+ box-shadow: 0 1px 5px -2px var(--focus-outline-color),
+ inset 0 0 0 1px rgba(0, 0, 0, 0.3),
+ inset 0 0 0 2em rgba(0, 0, 0, 0.15);
+ text-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
+}
+
+.address-pill.editing {
+ flex: 1;
+ background-color: transparent;
+ box-shadow: inset 0 0 0 1px var(--focus-outline-color);
+ min-height: calc(1.25em + 4px); /* needed to not shrink in edit mode */
+}
+
+.pill-indicator {
+ -moz-context-properties: fill, stroke;
+ fill: currentColor;
+ stroke: Field;
+ margin-inline-end: -8px;
+ margin-bottom: 1em;
+ transition: fill .2s ease, stroke .2s ease;
+}
+
+:root[lwt-tree] .pill-indicator {
+ stroke: var(--toolbar-field-background-color);
+}
+
+#MsgHeadersToolbar[brighttext] .address-pill:not(.editing) {
+ background-color: rgba(0,0,0,0.3);
+}
+
+#MsgHeadersToolbar[brighttext] .address-pill:hover:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill:focus:not(.editing) {
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.3),
+ inset 0 0 0 2em rgba(255, 255, 255, 0.1);
+}
+
+#MsgHeadersToolbar[brighttext] .address-pill[selected]:hover:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill[selected]:focus:not(.editing) {
+ box-shadow: 0 1px 5px -2px var(--focus-outline-color),
+ inset 0 0 0 1px rgba(255,255,255,0.3),
+ inset 0 0 0 2em rgba(0, 0, 0, 0.2);
+ text-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
+}
+
+.address-pill.invalid-address:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill.invalid-address:not(.editing) {
+ color: #fff;
+ background-color: #d70022;
+ background-image: url("chrome://global/skin/icons/warning.svg");
+ background-size: 12px;
+ background-repeat: no-repeat;
+ background-position: calc(100% - 5px);
+ padding-inline-end: 21px;
+ fill: currentColor;
+ -moz-context-properties: fill;
+}
+
+.address-pill.key-issue:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill.key-issue:not(.editing) {
+ color: #000;
+ background-color: #ffe900;
+ background-image: url("chrome://global/skin/icons/warning.svg");
+ background-size: 12px;
+ background-repeat: no-repeat;
+ background-position: calc(100% - 5px);
+ padding-inline-end: 21px;
+ fill: currentcolor;
+ -moz-context-properties: fill;
+}
+
+/* RTL variation for background position */
+.address-pill.invalid-address:not(.editing):-moz-locale-dir(rtl),
+.address-pill.key-issue:not(.editing):-moz-locale-dir(rtl),
+#MsgHeadersToolbar[brighttext] .address-pill.invalid-address:not(.editing):-moz-locale-dir(rtl),
+#MsgHeadersToolbar[brighttext] .address-pill.key-issue:not(.editing):-moz-locale-dir(rtl) {
+ background-position: 5px;
+}
+
+.address-pill.invalid-address:hover:not(.editing),
+.address-pill.invalid-address:focus:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill.invalid-address:hover:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill.invalid-address:focus:not(.editing) {
+ background-color: #a4000f;
+}
+
+.address-pill.key-issue:hover:not(.editing),
+.address-pill.key-issue:focus:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill.key-issue:hover:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill.key-issue:focus:not(.editing) {
+ background-color: #d7b600;
+}
+
+.address-pill[selected]:not(.editing),
+.address-pill.invalid-address[selected]:not(.editing),
+.address-pill.key-issue[selected]:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill[selected]:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill.invalid-address[selected]:not(.editing),
+#MsgHeadersToolbar[brighttext] .address-pill.key-issue[selected]:not(.editing) {
+ color: var(--selected-item-text-color);
+ background-color: var(--selected-item-color);
+}
+
+.address-pill[selected]:not(.editing) .pill-indicator {
+ fill: var(--selected-item-color);
+}
+
+#MsgHeadersToolbar {
+ display: grid;
+ grid-template-rows: min-content minmax(0, min-content) min-content;
+ grid-template-columns: auto;
+}
+
+.address-identity-recipient {
+ margin-inline-end: 8px;
+ display: flex;
+}
+
+.recipient-button {
+ white-space: nowrap;
+ text-align: start;
+}
+
+#extraAddressRowsMenu {
+ min-width: 160px;
+}
+
+.overflow-icon {
+ width: 16px;
+ height: 16px;
+ color: inherit;
+ -moz-context-properties: stroke, fill-opacity;
+ stroke: currentColor;
+ fill-opacity: 1;
+}
+
+.overflow-icon:-moz-locale-dir(rtl) {
+ transform: scaleX(-1);
+}
+
+button#extraAddressRowsMenuButton[aria-expanded="true"] {
+ /* Show as toggled on when the overflow is expanded. */
+ background: var(--toolbarbutton-active-background);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+}
+
+button:is(#extraAddressRowsMenuButton, .remove-field-button) {
+ padding: 2px;
+ margin: 0;
+}
+
+button.recipient-button {
+ padding: 2px 4px;
+ margin: 0;
+}
+
+button:is(
+ #extraAddressRowsMenuButton,
+ .remove-field-button,
+ .recipient-button
+):focus-visible {
+ outline: 2px solid var(--focus-outline-color);
+}
+
+.remove-field-button > img {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+}
+
+#msgIdentity:hover {
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ background-color: Field;
+}
+
+:root[lwt-tree] #msgIdentity:hover {
+ background-color: var(--toolbar-field-background-color);
+}
+
+#msgIdentity:focus,
+#msgIdentity:focus-within,
+#msgIdentity[focused="true"],
+#msgSubject:focus,
+.drag-address-container,
+.address-container[focused="true"] {
+ border-color: var(--toolbar-field-focus-border-color);
+ background-color: Field;
+ outline: 1px solid var(--toolbar-field-focus-border-color);
+ outline-offset: 0;
+}
+
+:root[lwt-tree] #msgIdentity:focus-within,
+:root[lwt-tree] #msgIdentity[focused="true"],
+:root[lwt-tree] #msgSubject:focus,
+:root[lwt-tree] .address-container[focused="true"] {
+ color: var(--toolbar-field-focus-color);
+ background-color: var(--toolbar-field-focus-background-color);
+}
+
+:root[lwt-tree-brighttext] #msgIdentity:focus,
+:root[lwt-tree-brighttext] #msgIdentity:focus-within,
+:root[lwt-tree-brighttext] #msgIdentity[focused="true"],
+:root[lwt-tree-brighttext] #msgSubject:focus,
+:root[lwt-tree-brighttext] .address-container[focused="true"] {
+ background-color: var(--toolbar-field-background-color);
+}
+
+.address-pill::before {
+ display: block;
+ content: '';
+ position: relative;
+ width: 3px;
+ background-color: var(--focus-outline-color);
+ height: 15px;
+ border-radius: 2px;
+ margin-inline: -10px 5px;
+ transition: all .2s ease;
+ transform: scaleY(0);
+}
+
+.drop-indicator::before {
+ transform: scaleY(1);
+}
+
+/* ..... format buttons ..... */
+
+#AbsoluteFontSizeButton {
+ list-style-image: url("chrome://messenger/skin/icons/size.svg");
+}
+
+#DecreaseFontSizeButton {
+ list-style-image: url("chrome://messenger/skin/icons/decrease.svg");
+}
+
+#IncreaseFontSizeButton {
+ list-style-image: url("chrome://messenger/skin/icons/increase.svg");
+}
+
+#boldButton {
+ list-style-image: url("chrome://messenger/skin/icons/bold.svg");
+}
+
+#italicButton {
+ list-style-image: url("chrome://messenger/skin/icons/italics.svg");
+}
+
+#underlineButton {
+ list-style-image: url("chrome://messenger/skin/icons/underline.svg");
+}
+
+#ulButton {
+ list-style-image: url("chrome://messenger/skin/icons/bullet-list.svg");
+}
+
+#removeStylingButton {
+ list-style-image: url("chrome://messenger/skin/icons/remove-text-styling.svg");
+}
+
+#olButton {
+ list-style-image: url("chrome://messenger/skin/icons/number-list.svg");
+}
+
+#outdentButton {
+ list-style-image: url("chrome://messenger/skin/icons/outdent.svg");
+}
+
+#indentButton {
+ list-style-image: url("chrome://messenger/skin/icons/indent.svg");
+}
+
+#InsertPopupButton {
+ list-style-image: url("chrome://messenger/skin/icons/image.svg");
+}
+
+#AlignPopupButton {
+ list-style-image: url("chrome://messenger/skin/icons/left-align.svg");
+}
+
+#smileButtonMenu {
+ list-style-image: url("chrome://messenger/skin/icons/smiley.svg");
+}
+
+/* ..... align menu ..... */
+
+#AlignPopup > menuitem {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#AlignLeftItem, #AlignPopupButton[state="left"] {
+ list-style-image: url("chrome://messenger/skin/icons/left-align.svg");
+}
+
+#AlignCenterItem, #AlignPopupButton[state="center"] {
+ list-style-image: url("chrome://messenger/skin/icons/center-align.svg");
+}
+
+#AlignRightItem, #AlignPopupButton[state="right"] {
+ list-style-image: url("chrome://messenger/skin/icons/right-align.svg");
+}
+
+#AlignJustifyItem, #AlignPopupButton[state="justify"] {
+ list-style-image: url("chrome://messenger/skin/icons/justify.svg");
+}
+
+/* ..... insert menu ..... */
+
+#InsertPopup > menuitem {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#InsertLinkItem {
+ list-style-image: url("chrome://global/skin/icons/link.svg");
+}
+
+#InsertAnchorItem {
+ list-style-image: url("chrome://messenger/skin/icons/anchor.svg");
+}
+
+#InsertImageItem {
+ list-style-image: url("chrome://messenger/skin/icons/image.svg");
+}
+
+#InsertHRuleItem {
+ list-style-image: url("chrome://messenger/skin/icons/hline.svg");
+}
+
+#InsertTableItem {
+ list-style-image: url("chrome://messenger/skin/icons/table.svg");
+}
+
+#findbar-replaceButton {
+ margin-block: 0;
+}
+
+#findbar-replaceButton > .toolbarbutton-icon {
+ display: none;
+}
+
+#linkPreviewSettings {
+ border: 1px solid silver;
+ border-radius: 5px;
+ padding: 10px 20px;
+ width: 500px;
+}
+#linkPreviewSettings h2 {
+ color: blue;
+ font-size: 1em;
+}
+#linkPreviewSettings p {
+ margin: 0.5em 0.2em;
+}
+#linkPreviewSettings .bottom {
+ padding: 1em 0;
+}
+#linkPreviewSettings button {
+ background-color: navy;
+ color: white;
+ padding: 0.2em 2em;
+}
+#linkPreviewSettings .close {
+ font-size: 1.4em;
+ float: inline-end;
+ font-weight: 600;
+ display: inline-block;
+ transform: rotate(45deg);
+ margin-block: -0.2em 0.2em;
+ margin-inline: 0.2em -0.2em;
+}
+
+.statusbar:not([hidden="true"]) {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.statusbar > :not(#statusText) {
+ flex: 0 0 auto;
+}
+
+.statusbar > #statusText {
+ flex: 1 1 auto;
+}
+
+#statusText {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin-inline: 4px;
+}
+
+#compose-progressmeter {
+ margin-inline: 4px;
+}
+
+/* Language selector */
+#languageStatusButton {
+ margin-block: 0;
+ margin-inline: 4px 0;
+ padding: 2px 4px;
+ border-radius: 0;
+}
+
+#languageStatusButton:focus-visible {
+ /* Provide some inset for the outline. */
+ outline-offset: -1px;
+}
+
+#toggleRecipientsButton {
+ margin-top: 3px;
+}
+
+.font-bold {
+ font-weight: bold;
+}
+
+.flex-center {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.margin-top-1em {
+ margin-top: 1em;
+}
+
+dialog.modal-dialog[open] {
+ display: flex;
+ flex-direction: column;
+ width: 580px;
+ color: inherit;
+ padding-inline: 0;
+ max-height: 90vh;
+ overflow-x: hidden;
+}
+
+dialog .modal-dialog-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ overflow-y: auto;
+ padding-inline: 15px;
+}
+
+dialog .container-with-link {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: baseline;
+}
+
+dialog .radio-container-with-text {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: baseline;
+ column-gap: 12px;
+}
+
+dialog .display-block {
+ display: block;
+ margin-block: 1px;
+}
+
+dialog .key-list {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ border: 1px solid var(--dialog-box-border-color);
+ border-radius: 3px;
+ margin-block: 12px 6px;
+}
+
+dialog .key-list > li:nth-child(even) {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+dialog .key-row {
+ display: flex;
+ align-items: center;
+ gap: 3px 6px;
+ padding: 3px 6px;
+}
+
+dialog .key-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+dialog .key-info-block {
+ margin-block-start: 6px;
+ margin-inline-start: 28px;
+}
+
+.comma-separated a:not(:last-child):after {
+ content: ", ";
+}
diff --git a/comm/mail/themes/shared/mail/migrationProgress.css b/comm/mail/themes/shared/mail/migrationProgress.css
new file mode 100644
index 0000000000..4ad549ab20
--- /dev/null
+++ b/comm/mail/themes/shared/mail/migrationProgress.css
@@ -0,0 +1,111 @@
+/* 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/. */
+
+:root {
+ /* Override "dialog" as it results in a transparent background om macOS. */
+ -moz-default-appearance: none;
+}
+
+body {
+ margin: 0;
+ padding: 1em;
+ padding-inline-end: calc(256px - 2em);
+
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+
+ font: message-box;
+ font-size: 15px;
+}
+
+img {
+ position: absolute;
+ inset-inline-end: -3em;
+ bottom: -3em;
+ opacity: .66;
+}
+
+h1 {
+ font-weight: normal;
+ font-size: 1.5em;
+ margin-block: 0;
+}
+
+ol {
+ width: 25em;
+ line-height: 24px;
+ list-style: none;
+ padding-inline: 8px;
+}
+
+li {
+ display: flex;
+ align-items: center;
+}
+
+.task-icon {
+ margin-inline-end: 8px;
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.running {
+ font-weight: bold;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .running .task-icon {
+ position: relative;
+ overflow: hidden;
+ }
+
+ .running .task-icon::before {
+ content: "";
+ position: absolute;
+ background-image: url("chrome://messenger/skin/icons/loading.svg");
+ width: 480px;
+ height: 100%;
+ animation: tab-throbber-animation 1.05s steps(30) infinite;
+ }
+
+ .running .task-icon:dir(rtl)::before {
+ animation-name: tab-throbber-animation-rtl;
+ }
+
+ @keyframes tab-throbber-animation {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-100%); }
+ }
+
+ @keyframes tab-throbber-animation-rtl {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(100%); }
+ }
+}
+
+.finished .task-icon {
+ background-image: url("chrome://global/skin/icons/check.svg");
+}
+
+progress {
+ appearance: none;
+ height: 4px;
+ background-color: hsla(0, 0%, 60%, 0.2);
+ border-style: none;
+ border-radius: 2px;
+}
+
+::-moz-progress-bar {
+ background: linear-gradient(90deg, #2094d2, #236ac2, #393c96, #236ac2, #2094d2, #236ac2, #393c96, #236ac2, #2094d2);
+ background-size: 1200px 100%;
+ animation: progress-animation 5s linear infinite;
+}
+
+@keyframes progress-animation {
+ 0% { background-position: 0; }
+ 100% { background-position: 1200px; }
+}
diff --git a/comm/mail/themes/shared/mail/msgSelectOffline.css b/comm/mail/themes/shared/mail/msgSelectOffline.css
new file mode 100644
index 0000000000..1d3558d4e5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/msgSelectOffline.css
@@ -0,0 +1,56 @@
+/* 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/. */
+
+/* ===== msgSelectOffline.css =================================================
+ == Styles for the Offline Use dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/folderPane.css");
+
+treechildren::-moz-tree-image(folderNameCol) {
+ -moz-context-properties: fill, fill-opacity, stroke;
+ fill: color-mix(in srgb, var(--default) 20%, transparent);
+ stroke: var(--default);
+}
+
+treechildren::-moz-tree-image(folderNameCol, isServer-true) {
+ fill: color-mix(in srgb, var(--primary) 20%, transparent);
+ stroke: var(--primary);
+}
+
+treechildren::-moz-tree-cell-text(folderNameCol, noSelect-true) {
+ opacity: 0.6;
+ font-style: italic;
+}
+
+/* ::::: Download Icons ::::: */
+
+treechildren::-moz-tree-image(syncCol) {
+ width: 14px;
+ height: 14px;
+ list-style-image: url("chrome://messenger/skin/icons/checkbox.svg");
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ fill: currentColor;
+ stroke: currentColor;
+ fill-opacity: 0;
+ stroke-opacity: 0;
+}
+
+treechildren::-moz-tree-image(syncCol, synchronize-true) {
+ fill-opacity: 1;
+}
+
+treechildren::-moz-tree-image(selectedColumn, selected, focus) {
+ fill: var(--select-focus-text-color);
+ stroke: var(--select-focus-text-color);
+}
+
+treechildren::-moz-tree-image(syncCol, isServer-true) {
+ list-style-image: none;
+}
+
+#folderNameCol {
+ flex: 5 5;
+}
diff --git a/comm/mail/themes/shared/mail/multimessageview.css b/comm/mail/themes/shared/mail/multimessageview.css
new file mode 100644
index 0000000000..131f13f11c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/multimessageview.css
@@ -0,0 +1,211 @@
+/* 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 url("chrome://messenger/skin/icons.css");
+
+/* Generic (cross-platform) multimessage view CSS. Some bits will be overridden
+ by theme specific files */
+
+:root {
+ --body-background-color: Field;
+ --body-text-color: FieldText;
+ --info-text-color: GrayText;
+ background-color: var(--body-background-color);
+ color: var(--body-text-color);
+ appearance: none;
+ user-select: auto; /* Overrides messenger.css. */
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --body-background-color: #2a2a2e;
+ --body-text-color: rgb(249, 249, 250);
+ --info-text-color: #b1b1b3;
+ }
+
+ #headingWrapper {
+ --toolbarbutton-hover-background: rgba(179, 179, 179, 0.4);
+ --toolbarbutton-hover-bordercolor: rgba(179, 179, 179, 0.4);
+ --toolbarbutton-header-bordercolor: rgba(179, 179, 179, 0.4);
+ --toolbarbutton-active-background: rgba(179, 179, 179, 0.6);
+ --toolbarbutton-active-bordercolor: rgba(179, 179, 179, 0.6);
+ --toolbarbutton-active-boxshadow: none;
+ }
+}
+
+body {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* The heading area */
+
+#headingWrapper {
+ display: flex;
+ flex-direction: row-reverse;
+ gap: 6px;
+ flex-wrap: wrap;
+ padding: 9px 6px;
+ color: var(--layout-color-1);
+ background-color: var(--layout-background-1);
+ border-bottom: 1px solid var(--splitter-color);
+}
+
+#heading {
+ flex: 1;
+ font-size: large;
+ font-weight: normal;
+ margin: 0;
+ padding: 0;
+}
+
+#summary_subtitle {
+ margin-inline-start: 1em;
+ font-size: small;
+ white-space: nowrap;
+}
+
+/* The main content area */
+
+#content {
+ flex: 1 1 0;
+ overflow: auto;
+ padding: 1ex 1em;
+ font-size: initial;
+}
+
+#message_list {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+
+#footer {
+ margin-top: 1em;
+ padding-inline-start: 1em;
+}
+
+.info {
+ font-size: small;
+}
+
+/* The message/thread summary rows */
+
+#message_list > li {
+ position: relative;
+ margin-bottom: 1ex;
+ border-width: 2px;
+ border-radius: 2px;
+ border: 1px solid transparent;
+ border-bottom-color: lightgrey;
+}
+
+#message_list > li.thread {
+ border-color: lightgrey;
+}
+
+.star {
+ width: 16px;
+ height: 16px;
+ position: absolute;
+ background-repeat: no-repeat;
+}
+
+.starred .star {
+ background-image: url("chrome://messenger/skin/icons/flagged.svg");
+ background-size: contain;
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ fill: #f4bc44;
+ fill-opacity: 1;
+ stroke: #f4bc44;
+ stroke-opacity: 1;
+}
+
+.item_summary {
+ margin-inline-start: 19px;
+ padding: 0.3ex 0;
+}
+
+.item_header {
+ padding-inline-end: 18px;
+ padding-top: 6px;
+ padding-bottom: 6px;
+}
+
+.unread .primary_header {
+ font-weight: bold;
+}
+
+.right {
+ float: inline-end;
+}
+
+.count {
+ white-space: nowrap;
+ margin-inline-start: 1ch;
+}
+
+.tags {
+ padding-top: 4px;
+ margin-inline-start: 1ch;
+}
+
+.tag {
+ display: inline-block; /* to avoid splitting 'To' and 'Do' e.g. */
+ margin-inline-start: 0;
+ margin-inline-end: 3px;
+ padding: 0 0.5ex;
+ border-radius: 3px;
+ border: 1px solid color-mix(in srgb, currentColor 50%, transparent);
+}
+
+.snippet {
+ opacity: .65;
+ padding-inline-end: 1.5em;
+ padding-bottom: 1ex;
+}
+
+.count, .info, .date,
+.message > .header > .snippet,
+.message > .header > .senders {
+ color: var(--info-text-color);
+}
+
+a {
+ color: -moz-nativehyperlinktext;
+ font-weight: bold;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+.link:hover {
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+#header-view-toolbox {
+ font: message-box;
+}
+
+#header-view-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ user-select: none;
+}
+
+@media print {
+ #header-view-toolbox {
+ display: none;
+ }
+
+ #headingWrapper {
+ position: static;
+ }
+}
diff --git a/comm/mail/themes/shared/mail/newmailalert.css b/comm/mail/themes/shared/mail/newmailalert.css
new file mode 100644
index 0000000000..3438b48140
--- /dev/null
+++ b/comm/mail/themes/shared/mail/newmailalert.css
@@ -0,0 +1,140 @@
+/* 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/. */
+
+/* ===== alert.css =====================================================
+ == Styles specific to the alerts dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+
+#alertContainer {
+ border: 1px solid threedshadow;
+ background-color: -moz-Dialog;
+ color: -moz-DialogText;
+ opacity: 0;
+}
+
+@media (prefers-color-scheme: dark) {
+ #alertContainer {
+ background-color: #2a2a2e;
+ color: #f9f9fa;
+ }
+}
+
+#alertContainer[noanimation] {
+ opacity: 1;
+}
+
+#alertContainer[fade-in] {
+ animation-timing-function: ease-out;
+ animation-duration: 2s;
+ animation-fill-mode: both;
+ animation-name: fade-in;
+}
+
+@keyframes fade-in {
+ from {opacity: 0;}
+ to {opacity: 1;}
+}
+
+#alertContainer[fade-out] {
+ animation-timing-function: ease-in;
+ animation-duration: 2s;
+ animation-fill-mode: both;
+ animation-name: fade-out;
+}
+
+@keyframes fade-out {
+ from {opacity: 1;}
+ to {opacity: 0;}
+}
+
+#alertImageBox {
+ display: block;
+ padding: 4px;
+ background-image: linear-gradient(rgba(255, 255, 255, .6),
+ rgba(255, 255, 255, .6));
+ border-inline-end: 1px solid rgba(0, 0, 0, .1);
+}
+
+#alertImage {
+ width: 64px;
+ height: 64px;
+}
+
+#alertTextBox {
+ padding: 4px;
+ padding-inline-end: 0;
+}
+
+#alertTitle {
+ font-weight: bold;
+ font-size: 110%;
+ padding-inline: 5px;
+}
+
+#alertSeparator {
+ margin-inline-start: 11px;
+ border-top: 1px solid -moz-DialogText;
+ height: 0;
+ margin-block: 0.4em;
+}
+
+@media (prefers-color-scheme: dark) {
+ #alertSeparator {
+ border-top-color: #f9f9fa;
+ }
+}
+
+folder-summary {
+ flex-direction: column;
+}
+
+.folderSummary-message-row {
+ /* This max width ends up dictating the overall width of the alert window
+ because it controls how large the preview, subject and sender text can be
+ before cropping kicks in */
+ max-width: 450px;
+ padding: 0 5px;
+}
+
+.folderSummary-subject {
+ font-weight: bold;
+}
+
+.folderSummary-sender, .folderSummary-subject {
+ cursor: inherit;
+}
+
+.folderSummary-sender {
+ width: 150px;
+}
+
+.folderSummary-subject {
+ width: 300px;
+ overflow-wrap: anywhere;
+}
+
+.folderSummary-previewText {
+ color: grey;
+ overflow-wrap: anywhere;
+}
+
+.folderSummaryMessage:hover > .folderSummary-message-row {
+ cursor: pointer;
+ color: -moz-hyperlinktext;
+}
+
+.folderSummaryMessage:hover:active > .folderSummary-message-row {
+ color: -moz-activehyperlinktext;
+}
+
+#closeButton {
+ align-items: flex-start;
+ width: auto;
+}
+
+#closeButton > .toolbarbutton-icon {
+ padding: 5px;
+}
diff --git a/comm/mail/themes/shared/mail/overrides/add.svg b/comm/mail/themes/shared/mail/overrides/add.svg
new file mode 100644
index 0000000000..8fac1d7d17
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/add.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M14 7H9V2a1 1 0 00-2 0v5H2a1 1 0 100 2h5v5a1 1 0 002 0V9h5a1 1 0 000-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/arrow-down-12.svg b/comm/mail/themes/shared/mail/overrides/arrow-down-12.svg
new file mode 100644
index 0000000000..c426b93ee5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/arrow-down-12.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill">
+ <path d="M6 6.75l3.22-3.22c.71-.7 1.77.36 1.06 1.07L6.53 8.34c-.3.3-.77.3-1.06 0L1.72 4.59c-.66-.7.35-1.72 1.06-1.06z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/arrow-down.svg b/comm/mail/themes/shared/mail/overrides/arrow-down.svg
new file mode 100644
index 0000000000..86c7116066
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/arrow-down.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 12a1 1 0 01-.7-.3l-5-5a1 1 0 011.4-1.4L8 9.58l4.3-4.3a1 1 0 011.4 1.42l-5 5A1 1 0 018 12z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/arrow-left-12.svg b/comm/mail/themes/shared/mail/overrides/arrow-left-12.svg
new file mode 100644
index 0000000000..d2a966c8c0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/arrow-left-12.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill">
+ <path d="M4.75 6l3.22-3.22A.75.75 0 006.9 1.72L3.16 5.47c-.3.3-.3.77 0 1.06l3.75 3.75a.75.75 0 001.06-1.06z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/arrow-left.svg b/comm/mail/themes/shared/mail/overrides/arrow-left.svg
new file mode 100644
index 0000000000..f69288906b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/arrow-left.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M6.41 8l4.3-4.3a1 1 0 00-1.42-1.4l-5 5a1 1 0 000 1.4l5 5a1 1 0 001.42-1.4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/arrow-right-12.svg b/comm/mail/themes/shared/mail/overrides/arrow-right-12.svg
new file mode 100644
index 0000000000..ae22ec7193
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/arrow-right-12.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill">
+ <path d="M7.25 6L4.03 2.78A.75.75 0 015.1 1.72l3.74 3.75c.3.3.3.77 0 1.06l-3.75 3.75a.75.75 0 01-1.06-1.06z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/arrow-right.svg b/comm/mail/themes/shared/mail/overrides/arrow-right.svg
new file mode 100644
index 0000000000..1e6b979066
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/arrow-right.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M9.59 8l-4.3-4.3a-1 1 0 011.42-1.4l5 5a-1 1 0 010 1.4l-5 5a-1 1 0 01-1.42-1.4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/arrow-up-12.svg b/comm/mail/themes/shared/mail/overrides/arrow-up-12.svg
new file mode 100644
index 0000000000..40e19e9d1c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/arrow-up-12.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="12" height="12" viewBox="0 0 12 12" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M6 5.12l3.22 3.22c.71.7 1.77-.36 1.06-1.07L6.53 3.53a.74.74 0 00-1.06 0L1.72 7.28c-.66.7.35 1.72 1.06 1.06z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/arrow-up.svg b/comm/mail/themes/shared/mail/overrides/arrow-up.svg
new file mode 100644
index 0000000000..e9ab68ce05
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/arrow-up.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 5.04a1-1 0 00-.7.3l-5 5a1-1 0 001.4 1.4L8 7.46l4.3 4.3a1-1 0 001.4-1.42l-5-5a1-1 0 00-.7-.3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/blocked.svg b/comm/mail/themes/shared/mail/overrides/blocked.svg
new file mode 100644
index 0000000000..d0d28c0b57
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/blocked.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-rule="evenodd">
+ <path d="M3 9h10V7H3v2zm5-8a7 7 0 100 14A7 7 0 008 1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/category-available.svg b/comm/mail/themes/shared/mail/overrides/category-available.svg
new file mode 100644
index 0000000000..6fe4b0c002
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/category-available.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity" width="32" height="32">
+ <path d="M12 8a4 4 0 104 4 4 4 0 00-4-4zm-2.2 3.54l1.92-1.92a.38.38 0 01.54 0l1.92 1.92a.38.38 0 01-.54.54l-1.27-1.27v3.3a.38.38 0 01-.77 0v-3.3l-1.27 1.27a.38.38 0 01-.54-.54z"/>
+ <path d="M6 12a2.56 2.56 0 011 .21V12a5 5 0 015-5V5a1 1 0 00-1-1H7.75A.77.77 0 017 3.25c0-.75 1-.78 1-1.75S7.1 0 6 0 4 .64 4 1.5s1 1 1 1.75a.77.77 0 01-.75.75H1a1 1 0 00-1 1v2.25A.77.77 0 00.75 8c.75 0 .78-1 1.75-1S4 7.9 4 9s-.64 2-1.5 2-1-1-1.75-1a.77.77 0 00-.75.75V15a1 1 0 001 1h3.25a.77.77 0 00.75-.75c0-.75-1-.78-1-1.75S4.9 12 6 12z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/category-discover.svg b/comm/mail/themes/shared/mail/overrides/category-discover.svg
new file mode 100644
index 0000000000..88f4fabe50
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/category-discover.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity" width="32" height="32">
+ <path d="M9.5 3s.43 2.43 1.25 3.25S14 7.5 14 7.5s-2.43.39-3.25 1.21S9.5 12 9.5 12s-.39-2.46-1.21-3.29S5 7.5 5 7.5s2.46-.43 3.29-1.25S9.5 3 9.5 3m0-2a2 2 0 00-2 1.68 7.54 7.54 0 01-.66 2.15 7.9 7.9 0 01-2.21.7 2 2 0 000 3.95 7.73 7.73 0 012.18.66 7.74 7.74 0 01.66 2.19 2 2 0 002 1.68 2 2 0 002-1.66 7.89 7.89 0 01.7-2.21 7.57 7.57 0 012.15-.66 2 2 0 000-3.94 7.69 7.69 0 01-2.18-.7 7.73 7.73 0 01-.7-2.18A2 2 0 009.5 1zM5.09 12.51a3.13 3.13 0 01-1.18-.42 3.11 3.11 0 01-.42-1.18.5.5 0 00-.49-.41.5.5 0 00-.49.42 3 3 0 01-.4 1.17 3.14 3.14 0 01-1.19.42.5.5 0 000 1 3 3 0 011.19.4 3 3 0 01.4 1.19.5.5 0 00.49.4.5.5 0 00.49-.41 3.2 3.2 0 01.42-1.19 3 3 0 011.17-.4.5.5 0 000-1zM4.09 2a2.36 2.36 0 01-.82-.28A2.37 2.37 0 013 .91.5.5 0 002.5.5a.5.5 0 00-.5.42 2.28 2.28 0 01-.27.81A2.4 2.4 0 01.91 2a.5.5 0 000 1 2.32 2.32 0 01.82.26 2.34 2.34 0 01.27.82.5.5 0 00.49.42.5.5 0 00.51-.41 2.42 2.42 0 01.28-.83 2.3 2.3 0 01.8-.26.5.5 0 000-1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/category-extensions.svg b/comm/mail/themes/shared/mail/overrides/category-extensions.svg
new file mode 100644
index 0000000000..c2f04d02c2
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/category-extensions.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity" width="32" height="32">
+ <path d="M14.53 8c-1 0-1 1-1.75 1a.77.77 0 01-.78-.75V5a1 1 0 00-1-1H7.78A.77.77 0 017 3.25c0-.75 1-.78 1-1.75S7.13 0 6 0 4 .64 4 1.5s1 1 1 1.75a.77.77 0 01-.72.75H1a1 1 0 00-1 1v2.25A.77.77 0 00.78 8c.75 0 .78-1 1.75-1S4 7.9 4 9s-.64 2-1.5 2-1-1-1.75-1a.77.77 0 00-.75.75V15a1 1 0 001 1h3.28a.77.77 0 00.72-.75c0-.75-1-.78-1-1.75S4.92 12 6 12s2 .64 2 1.5-1 1-1 1.75a.77.77 0 00.75.75H11a1 1 0 001-1v-3.25a.77.77 0 01.75-.75c.75 0 .78 1 1.75 1s1.5-.9 1.5-2-.61-2-1.47-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/category-recent.svg b/comm/mail/themes/shared/mail/overrides/category-recent.svg
new file mode 100644
index 0000000000..eaabb09837
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/category-recent.svg
@@ -0,0 +1,7 @@
+<!-- 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" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity" width="32" height="32">
+ <path d="M12 16a4 4 0 10-4-4 4 4 0 004 4zm2.2-3.54l-1.92 1.92a.38.38 0 01-.54 0L9.8 12.46a.38.38 0 11.54-.54l1.27 1.27V9.88a.38.38 0 01.77 0v3.3l1.27-1.27a.38.38 0 01.54.54z"/>
+ <path d="M6 12a2.56 2.56 0 011 .21V12a5 5 0 015-5V5a1 1 0 00-1-1H7.75A.77.77 0 017 3.25c0-.75 1-.78 1-1.75S7.1 0 6 0 4 .64 4 1.5s1 1 1 1.75a.77.77 0 01-.75.75H1a1 1 0 00-1 1v2.25A.77.77 0 00.75 8c.75 0 .78-1 1.75-1S4 7.9 4 9s-.64 2-1.5 2-1-1-1.75-1a.77.77 0 00-.75.75V15a1 1 0 001 1h3.25a.77.77 0 00.75-.75c0-.75-1-.78-1-1.75S4.9 12 6 12z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/category-themes.svg b/comm/mail/themes/shared/mail/overrides/category-themes.svg
new file mode 100644
index 0000000000..e58a9701fd
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/category-themes.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity" width="32" height="32">
+ <path d="M4 11a2 2 0 00-1.91 1.44C1.71 13.59 1.37 14.56.31 15a.49.49 0 00-.27.43.5.5 0 00.5.5 7 7 0 004.83-1.5A2 2 0 004 11zm11.69-9.71a1 1 0 00-1.34 0l-8 7a1 1 0 000 1.43l1 1A1 1 0 008 11a1 1 0 00.71-.33l7-8a1 1 0 00-.02-1.38z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/check.svg b/comm/mail/themes/shared/mail/overrides/check.svg
new file mode 100644
index 0000000000..a85c33b6c7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/check.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M6.52 12.5a1 1 0 01-.7-.3L2.28 8.7A1 1 0 113.7 7.3l2.82 2.8 5.77-5.8a1 1 0 011.42 1.41l-6.48 6.5a1 1 0 01-.71.29z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/chevron.svg b/comm/mail/themes/shared/mail/overrides/chevron.svg
new file mode 100644
index 0000000000..e4b7b44aa0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/chevron.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8.7 7.3l-5-5a1 1 0 00-1.4 1.4L6.58 8l-4.3 4.3a1 1 0 101.42 1.4l5-5a1 1 0 000-1.4zm6 0l-5-5a1 1 0 00-1.4 1.4L12.58 8l-4.3 4.3a1 1 0 101.42 1.4l5-5a1 1 0 000-1.4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/close.svg b/comm/mail/themes/shared/mail/overrides/close.svg
new file mode 100644
index 0000000000..08e3832568
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/close.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M9.06 8l3.47-3.47a.75.75 0 00-1.06-1.06L8 6.94 4.53 3.47a.75.75 0 10-1.06 1.06L6.94 8l-3.47 3.47a.75.75 0 101.06 1.06L8 9.06l3.47 3.47a.75.75 0 001.06-1.06z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/defaultFavicon.svg b/comm/mail/themes/shared/mail/overrides/defaultFavicon.svg
new file mode 100644
index 0000000000..f087cb3a4b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/defaultFavicon.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 0a8 8 0 108 8 8 8 0 00-8-8zm5.16 4.96h-1.55a7.7 7.7 0 00-1.05-2.38 6.03 6.03 0 012.6 2.38zM14 8a5.96 5.96 0 01-.34 1.96h-1.82A12.33 12.33 0 0012 8a12.33 12.33 0 00-.16-1.96h1.82A5.96 5.96 0 0114 8zm-6 6c-1.07 0-2.04-1.2-2.57-2.96h5.14C10.04 12.8 9.07 14 8 14zM5.17 9.96a11.08 11.08 0 010-3.92h5.66A11.11 11.11 0 0111 8a11.11 11.11 0 01-.17 1.96zM2 8a5.96 5.96 0 01.34-1.96h1.82a12.36 12.36 0 000 3.92H2.33A5.96 5.96 0 012 8zm6-6c1.07 0 2.04 1.2 2.57 2.96H5.43C5.96 3.2 6.93 2 8 2zm-2.56.58a7.7 7.7 0 00-1.05 2.38H2.84a6.03 6.03 0 012.6-2.38zm-2.6 8.46h1.55a7.7 7.7 0 001.05 2.38 6.03 6.03 0 01-2.6-2.38zm7.72 2.38a7.7 7.7 0 001.05-2.38h1.56a6.03 6.03 0 01-2.61 2.38z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/delete.svg b/comm/mail/themes/shared/mail/overrides/delete.svg
new file mode 100644
index 0000000000..09ea81760a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/delete.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M6.5 12a.5.5 0 00.5-.5v-6a.5.5 0 00-1 0v6a.5.5 0 00.5.5zm2 0a.5.5 0 00.5-.5v-6a.5.5 0 00-1 0v6a.5.5 0 00.5.5zm2 0a.5.5 0 00.5-.5v-6a.5.5 0 00-1 0v6a.5.5 0 00.5.5z"/>
+ <path d="M14 2h-3.05a2.5 2.5 0 00-4.9 0H3a1 1 0 000 2v9a3 3 0 003 3h5a3 3 0 003-3V4a1 1 0 000-2zM8.5 1a1.49 1.49 0 011.4 1H7.1a1.49 1.49 0 011.4-1zM12 13a1 1 0 01-1 1H6a1 1 0 01-1-1V4h7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/error.svg b/comm/mail/themes/shared/mail/overrides/error.svg
new file mode 100644
index 0000000000..0bc4f2c06e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/error.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" fill-rule="evenodd">
+ <path d="M8 15a7 7 0 10-7-7 7 7 0 007 7zm0-3a1 1 0 10-1-1 1 1 0 001 1zm0-3a1 1 0 001-1V5a1 1 0 00-2 0v3a1 1 0 001 1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/extension.svg b/comm/mail/themes/shared/mail/overrides/extension.svg
new file mode 100644
index 0000000000..6a7a852247
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/extension.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M14.5 8c-.97 0-1 1-1.75 1a.77.77 0 01-.75-.75V5a1 1 0 00-1-1H7.75A.77.77 0 017 3.25c0-.75 1-.78 1-1.75C8 .64 7.1 0 6 0S4 .64 4 1.5c0 .97 1 1 1 1.75a.77.77 0 01-.75.75H1a1 1 0 00-1 1v2.25A.77.77 0 00.75 8c.75 0 .78-1 1.75-1C3.37 7 4 7.9 4 9s-.64 2-1.5 2c-.97 0-1-1-1.75-1a.77.77 0 00-.75.75V15a1 1 0 001 1h3.25a.77.77 0 00.75-.75c0-.75-1-.78-1-1.75 0-.86.9-1.5 2-1.5s2 .64 2 1.5c0 .97-1 1-1 1.75a.77.77 0 00.75.75H11a1 1 0 001-1v-3.25a.77.77 0 01.75-.75c.75 0 .78 1 1.75 1 .86 0 1.5-.9 1.5-2s-.64-2-1.5-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/find-next-arrow.svg b/comm/mail/themes/shared/mail/overrides/find-next-arrow.svg
new file mode 100644
index 0000000000..64dd9fc701
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/find-next-arrow.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill">
+ <path d="M8 12a1 1 0 01-.7-.3l-5-5a1 1 0 011.4-1.4L8 9.58l4.3-4.3a1 1 0 011.4 1.42l-5 5A1 1 0 018 12z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/find-previous-arrow.svg b/comm/mail/themes/shared/mail/overrides/find-previous-arrow.svg
new file mode 100644
index 0000000000..64001cd41a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/find-previous-arrow.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill">
+ <path d="M13 11a1 1 0 01-.7-.3L8 6.42l-4.3 4.3a1 1 0 01-1.4-1.42l5-5a1 1 0 011.4 0l5 5A1 1 0 0113 11z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/folder.svg b/comm/mail/themes/shared/mail/overrides/folder.svg
new file mode 100644
index 0000000000..f169d4f051
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/folder.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M14 3H8.15L6.58 1.54A2 2 0 005.22 1H2a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2zM5.22 3l1.07 1H2V3zM14 13H2V5h6v-.01l.15.01H14z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/help.svg b/comm/mail/themes/shared/mail/overrides/help.svg
new file mode 100644
index 0000000000..4226bdaca4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/help.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 1a7 7 0 107 7 7 7 0 00-7-7zm0 13a6 6 0 116-6 6 6 0 01-6 6zM8 3.12A2.7 2.7 0 005.12 6a.88.88 0 001.75 0c0-1 .6-1.13 1.13-1.13a1.1 1.1 0 011.13.75.9.9 0 01-.53 1.01A2.74 2.74 0 007.13 9v.34a.88.88 0 001.75 0v-.37a1.04 1.04 0 01.6-.83 2.64 2.64 0 001.34-2.98A2.84 2.84 0 008 3.12zm0 7.63A1.25 1.25 0 109.25 12 1.25 1.25 0 008 10.75z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/info-filled.svg b/comm/mail/themes/shared/mail/overrides/info-filled.svg
new file mode 100644
index 0000000000..95ea208beb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/info-filled.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path fill-rule="evenodd" d="M8 1a7 7 0 11-7 7 7 7 0 017-7zm0 3a1 1 0 11-1 1 1 1 0 011-1zm0 3a1 1 0 011 1v3a1 1 0 01-2 0V8a1 1 0 011-1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/info.svg b/comm/mail/themes/shared/mail/overrides/info.svg
new file mode 100644
index 0000000000..f5ed872b3f
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/info.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 1a7 7 0 107 7 7 7 0 00-7-7zm0 13a6 6 0 116-6 6 6 0 01-6 6zm0-7a1 1 0 00-1 1v3a1 1 0 102 0V8a1 1 0 00-1-1zm0-3.19A1.19 1.19 0 109.19 5 1.19 1.19 0 008 3.81z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/more.svg b/comm/mail/themes/shared/mail/overrides/more.svg
new file mode 100644
index 0000000000..447e5330ad
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/more.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 6a2 2 0 102 2 2 2 0 00-2-2zm6 0a2 2 0 102 2 2 2 0 00-2-2zm6 0a2 2 0 102 2 2 2 0 00-2-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/open-in-new.svg b/comm/mail/themes/shared/mail/overrides/open-in-new.svg
new file mode 100644
index 0000000000..5a1fe65b64
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/open-in-new.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 12 12" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M4 0c.6 0 1 .4 1 1s-.5 1-1 1H3c-.6 0-1 .4-1 1v6c0 .6.4 1 1 1h6c.6 0 1-.4 1-1V8c0-.6.4-1 1-1s1 .4 1 1v1c0 1.7-1.3 3-3 3H3c-1.7 0-3-1.3-3-3V3c0-1.7 1.3-3 3-3zm7 0c.4 0 .8.2.9.6.1.1.1.3.1.4v3c0 .6-.4 1-1 1s-1-.4-1-1v-.6L8.7 4.7c-.2.3-.6.4-1 .3-.3-.1-.6-.4-.7-.7s0-.7.3-1L8.6 2H8c-.5 0-1-.4-1-1s.4-1 1-1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/plugin-blocked.svg b/comm/mail/themes/shared/mail/overrides/plugin-blocked.svg
new file mode 100644
index 0000000000..6e391761df
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/plugin-blocked.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M3 3h3a1 1 0 000-2H3a1 1 0 000 2zm11.7 1.3L5 14h9a1 1 0 001-1V5a1 1 0 00-.3-.7z"/>
+ <path d="M14.7 1.3a.99.99 0 00-1.2-.15A.97.97 0 0013 1h-3a1 1 0 000 2h1.59l-1 1H2a1 1 0 00-1 1v8a.97.97 0 00.15.5.99.99 0 00.14 1.2 1 1 0 001.42 0l12-12a1 1 0 000-1.4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/plugin.svg b/comm/mail/themes/shared/mail/overrides/plugin.svg
new file mode 100644
index 0000000000..f1ef119cba
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/plugin.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <rect x="1" y="4" width="14" height="10" rx="1" ry="1"/>
+ <path d="M6 1H3a1 1 0 000 2h3a1 1 0 000-2zm7 0h-3a1 1 0 000 2h3a1 1 0 000-2z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/print.svg b/comm/mail/themes/shared/mail/overrides/print.svg
new file mode 100644
index 0000000000..c7bc548b14
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/print.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M14 5h-1V1a1 1 0 00-1-1H4a1 1 0 00-1 1v4H2a2 2 0 00-2 2v5h3v3a1 1 0 001 1h8a1 1 0 001-1v-3h3V7a2 2 0 00-2-2zM2.5 8a.5.5 0 11.5-.5.5.5 0 01-.5.5zm9.5 7H4v-5h8zm0-10H4V1h8zm-6.5 7h4a.5.5 0 000-1h-4a.5.5 0 100 1zm0 2h5a.5.5 0 000-1h-5a.5.5 0 100 1z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/search-glass.svg b/comm/mail/themes/shared/mail/overrides/search-glass.svg
new file mode 100644
index 0000000000..6e6dc883f9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/search-glass.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M15.7 14.3L10.9 9.46a6.02 6.02 0 10-1.42 1.41l4.82 4.83a1 1 0 001.42-1.42zM6 10a4 4 0 114-4 4 4 0 01-4 4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/security-broken.svg b/comm/mail/themes/shared/mail/overrides/security-broken.svg
new file mode 100644
index 0000000000..1b7d21974d
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/security-broken.svg
@@ -0,0 +1,7 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="M12.5 6.98h-.48L4 15h8.5a1.5 1.5 0 001.5-1.5V8.48a1.5 1.5 0 00-1.5-1.5zm-6.5 0V5a2 2 0 014 0v1l1.9-1.9A4 4 0 004 5v1.98h-.5A1.5 1.5 0 002 8.48v5.02a1.48 1.48 0 00.07.43l6.95-6.95z" fill="context-fill" fill-opacity="context-fill-opacity"/>
+ <path d="M2 15a1 1 0 01-.7-1.7l12-12a1 1 0 011.4 1.4l-12 12a1 1 0 01-.7.3z" fill="#ff0039"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/security-warning.svg b/comm/mail/themes/shared/mail/overrides/security-warning.svg
new file mode 100644
index 0000000000..f72c5588d7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/security-warning.svg
@@ -0,0 +1,9 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16">
+ <path d="M8 1a4 4 0 00-4 4v1.98h-.5A1.5 1.5 0 002 8.48v5.02A1.5 1.5 0 003.5 15h2.54a2.27 2.27 0 01.2-1.32l3.19-6.41a2.27 2.27 0 01.2-.29H6V5a2 2 0 014 0v1.57A2.25 2.25 0 0111.48 6a2.28 2.28 0 01.52.07V5a4 4 0 00-4-4z" fill="context-fill" fill-opacity="context-fill-opacity"/>
+ <path d="M15.82 14.13l-3.2-6.41a1.28 1.28 0 00-2.3 0l-3.18 6.4A1.3 1.3 0 008.29 16h6.38a1.3 1.3 0 001.15-1.87z" fill="#ffbf00"/>
+ <path d="M11.48 8a.27.27 0 01.25.16l3.2 6.41a.3.3 0 01-.02.3.28.28 0 01-.24.13H8.29a.28.28 0 01-.24-.14.29.29 0 01-.02-.29l3.2-6.4a.27.27 0 01.25-.17m0-1a1.27 1.27 0 00-1.15.72l-3.2 6.4A1.3 1.3 0 008.3 16h6.38a1.3 1.3 0 001.15-1.87l-3.2-6.41A1.27 1.27 0 0011.49 7z" fill="#d76e00" opacity=".35"/>
+ <path d="M11.5 12a.5.5 0 00.5-.5v-2a.5.5 0 00-1 0v2a.5.5 0 00.5.5zm0 .8a.7.7 0 10.7.7.7.7 0 00-.7-.7z" fill="#fff"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/security.svg b/comm/mail/themes/shared/mail/overrides/security.svg
new file mode 100644
index 0000000000..b4e35ba4cc
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/security.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M12 7h1a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1V8a1 1 0 011-1h1V5a4 4 0 018 0v2zm-2 0V5a2 2 0 00-4 0v2h4z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/settings.svg b/comm/mail/themes/shared/mail/overrides/settings.svg
new file mode 100644
index 0000000000..4ccf1a00ea
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/settings.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M15 7h-2.1a4.97 4.97 0 00-.73-1.75l1.49-1.5a1 1 0 00-1.42-1.4l-1.49 1.48A4.97 4.97 0 009 3.1V1a1 1 0 00-2 0v2.1a4.97 4.97 0 00-1.75.73l-1.5-1.49a1 1 0 00-1.4 1.42l1.48 1.49A4.97 4.97 0 003.1 7H1a1 1 0 000 2h2.1a4.97 4.97 0 00.74 1.76l-.05.03-1.45 1.45a1 1 0 101.42 1.42L5.2 12.2c0-.02.01-.03.03-.05A4.97 4.97 0 007 12.9V15a1 1 0 002 0v-2.1a4.97 4.97 0 001.75-.73l1.5 1.49a1 1 0 001.4-1.42l-1.48-1.49A4.97 4.97 0 0012.9 9H15a1 1 0 000-2zM5 8a3 3 0 113 3 3 3 0 01-3-3z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/update-icon.svg b/comm/mail/themes/shared/mail/overrides/update-icon.svg
new file mode 100644
index 0000000000..6bf4fbfaea
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/update-icon.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" fill="context-fill">
+ <path d="M8 0a8 8 0 100 16A8 8 0 008 0zm3.7 6.7a1 1 0 01-1.4 0L9 5.42V13a1 1 0 11-2 0V5.41l-1.3 1.3a1 1 0 01-1.4-1.42l3-3a1 1 0 011.4 0l3 3a1 1 0 010 1.42z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/overrides/warning.svg b/comm/mail/themes/shared/mail/overrides/warning.svg
new file mode 100644
index 0000000000..d86a9ee432
--- /dev/null
+++ b/comm/mail/themes/shared/mail/overrides/warning.svg
@@ -0,0 +1,6 @@
+<!-- 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" width="16" height="16" viewBox="0 0 16 16" fill="context-fill #FFBF00">
+ <path d="M14.74 12.1L9.8 2.2a2 2 0 00-3.58 0l-4.95 9.91A2 2 0 003.05 15h9.9a2 2 0 001.8-2.9zM7 5a1 1 0 012 0v4a1 1 0 01-2 0zm1 8.25A1.25 1.25 0 119.25 12 1.25 1.25 0 018 13.25z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/panelUI.css b/comm/mail/themes/shared/mail/panelUI.css
new file mode 100644
index 0000000000..932c70177b
--- /dev/null
+++ b/comm/mail/themes/shared/mail/panelUI.css
@@ -0,0 +1,1199 @@
+/* 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/. */
+
+:root .cui-widget-panel {
+ --menu-panel-width: 22.35em;
+ --wide-menu-panel-width: 29em;
+ --panel-palette-icon-size: 16px;
+ --panel-separator-margin: 4px 8px;
+
+ /* XXX This is the ugliest bit of code I think I've ever written for Mozilla.
+ Basically, the [extra 0.1px in the 1.1px] is there to avoid CSS rounding errors
+ causing buttons to wrap. For gory details, refer to
+ https://bugzilla.mozilla.org/show_bug.cgi?id=963365#c11 */
+ /* stylelint-disable-next-line */
+ --menu-panel-button-width: calc(--menu-panel-width / 3 - 1.1px);
+ /* Complement the .subviewbutton margin. */
+ --arrowpanel-padding: 8px 0;
+ --arrowpanel-menuitem-border-radius: 3px;
+ --appmenu-button-border-color: hsla(210, 4%, 10%, 0.14);
+ --appmenu-button-margin: 0 8px;
+ --appmenu-button-padding: 6px;
+ --appmenu-combined-radio-button-padding: 4px 8px;
+ --appmenu-fontsize-icon-size: 20px;
+ --appmenu-fontsize-reset-button-width: 47px;
+ --appmenu-warning-background-color: #ffefbf;
+ --appmenu-warning-background-color-hover: #ffe8a2;
+ --appmenu-warning-background-color-active: #ffe38f;
+ --appmenu-warning-color: black;
+ --appmenu-warning-border-color: hsl(45, 100%, 77%);
+ --appmenu-icons-fill: color-mix(in srgb, var(--color-ink-70) 20%, transparent);
+ --appmenu-icons-stroke: var(--color-ink-70);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root .cui-widget-panel {
+ --appmenu-icons-fill: color-mix(in srgb, var(--color-ink-10) 20%, transparent);
+ --appmenu-icons-stroke: var(--color-ink-10);
+ }
+}
+
+@media (prefers-contrast) {
+ :root .cui-widget-panel {
+ --appmenu-icons-fill: color-mix(in srgb, currentColor 20%, transparent);
+ --appmenu-icons-stroke: currentColor;
+ }
+}
+
+/* UI Density customization */
+
+:root[uidensity="compact"] .cui-widget-panel {
+ --arrowpanel-padding: 4px 0;
+ --appmenu-button-margin: 0 4px;
+ --appmenu-button-padding: 3px;
+ --appmenu-combined-radio-button-padding: 2px 6px;
+ --appmenu-fontsize-icon-size: 16px;
+ --appmenu-fontsize-reset-button-width: 41px;
+}
+
+:root[uidensity="touch"] .cui-widget-panel {
+ --appmenu-button-padding: 9px;
+ --appmenu-combined-radio-button-padding: 6px 10px;
+ --appmenu-fontsize-icon-size: 24px;
+ --appmenu-fontsize-reset-button-width: 51px;
+}
+
+:root[lwt-popup-brighttext] .cui-widget-panel {
+ --appmenu-button-border-color: rgba(249, 249, 250, 0.2);
+ --appmenu-warning-background-color: hsla(55, 100%, 50%, .1);
+ --appmenu-warning-background-color-hover: hsla(55, 100%, 50%, .2);
+ --appmenu-warning-background-color-active: hsla(55, 100%, 50%, .3);
+ --appmenu-warning-color: #f9f9fa;
+ --appmenu-warning-border-color: hsla(55, 100%, 50%, .3);
+}
+
+panelview:not([visible]) {
+ visibility: collapse;
+}
+
+.cui-widget-panel,
+#widget-overflow {
+ font: menu;
+ font-size: inherit;
+}
+
+panelview {
+ flex-direction: column;
+ background: var(--arrowpanel-background);
+ padding: 0;
+}
+
+/* Prevent a scrollbar from appearing while the animation for transitioning from
+ one view to another runs, which would otherwise happen if the new view has
+ more height than the old one because that would mean that during the
+ animation the height of the multiview will be too short for the new view. */
+panelmultiview[transitioning] > .panel-viewcontainer > .panel-viewstack > panelview > .panel-subview-body {
+ overflow-y: hidden;
+}
+
+.panel-subview-body {
+ overflow-y: auto;
+ overflow-x: hidden;
+ flex: 1;
+ padding: var(--arrowpanel-padding);
+}
+
+.panel-view-body-unscrollable {
+ overflow: hidden;
+ flex: 1;
+}
+
+.subviewbutton.panel-subview-footer {
+ box-sizing: border-box;
+ min-height: 41px;
+}
+
+.cui-widget-panelview menuitem.subviewbutton.panel-subview-footer {
+ margin: 4px 0 0;
+}
+
+.cui-widget-panelview .subviewbutton.panel-subview-footer > .menu-text {
+ flex: 1;
+}
+
+#wrapper-edit-controls:is([place="palette"],[place="menu-panel"]) > #edit-controls {
+ margin-inline-start: 0;
+}
+
+#appmenu-edit-button {
+ margin-inline: 0 3px;
+ padding-inline: 7px;
+}
+
+#appmenu-edit-button::after {
+ margin-inline-start: 0;
+}
+
+panelview[id^=PanelUI-webext-] {
+ overflow: hidden;
+}
+
+panelview:not([mainview]) .toolbarbutton-text,
+.cui-widget-panel .toolbarbutton-text,
+#overflowMenu-customize-button > .toolbarbutton-text {
+ text-align: start;
+ display: flex;
+}
+
+#appMenu-popup panelview,
+#customizationui-widget-multiview panelview:not([extension]) {
+ min-width: var(--menu-panel-width);
+ max-width: 35em;
+}
+
+#customizationui-widget-multiview #appMenu-libraryView,
+#widget-overflow panelview {
+ min-width: var(--wide-menu-panel-width);
+ max-width: var(--wide-menu-panel-width);
+}
+
+/* Add 2 * 16px extra width for touch mode button padding. */
+#appMenu-popup[touchmode] panelview {
+ min-width: calc(var(--menu-panel-width) + 32px);
+}
+
+.cui-widget-panel.cui-widget-panelWithFooter::part(content) {
+ padding-bottom: 0;
+}
+
+.toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]) > toolbarbutton > .toolbarbutton-icon {
+ min-width: 0;
+ min-height: 0;
+ margin: 0;
+}
+
+.animate-out {
+ animation-name: widget-animate-out;
+ animation-fill-mode: forwards;
+ animation-duration: 500ms;
+ animation-timing-function: var(--animation-easing-function);
+}
+
+@keyframes widget-animate-out {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 100% {
+ opacity: 0 ;
+ transform: scale(.5);
+ }
+}
+
+toolbarpaletteitem[place="menu-panel"] > .toolbarbutton-1 {
+ flex: 1;
+}
+
+/* Help webextension buttons fit in. */
+toolbarpaletteitem[place="palette"] > toolbarbutton[constrain-size="true"] > .toolbarbutton-icon,
+toolbarpaletteitem[place="palette"] > toolbarbutton[constrain-size="true"] > .toolbarbutton-badge-stack > .toolbarbutton-icon,
+toolbarbutton[constrain-size="true"][cui-areatype="menu-panel"] > .toolbarbutton-icon,
+toolbarbutton[constrain-size="true"][cui-areatype="menu-panel"] > .toolbarbutton-badge-stack > .toolbarbutton-icon {
+ height: var(--panel-palette-icon-size);
+ width: var(--panel-palette-icon-size);
+}
+
+#customization-palette .toolbarbutton-1 {
+ appearance: none;
+ flex-direction: column;
+ padding: 12px 0 9px;
+ margin: 0;
+}
+
+/* above we treat the container as the icon for the margins, that is so the
+/* badge itself is positioned correctly. Here we make sure that the icon itself
+/* has the minimum size we want, but no padding/margin. */
+.customization-palette .toolbarbutton-1 > .toolbarbutton-badge-stack > .toolbarbutton-icon {
+ width: var(--panel-palette-icon-size);
+ height: var(--panel-palette-icon-size);
+ min-width: var(--panel-palette-icon-size);
+ min-height: var(--panel-palette-icon-size);
+ margin: 0;
+ padding: 0;
+}
+
+#zoom-in-button > .toolbarbutton-text,
+#zoom-out-button > .toolbarbutton-text,
+#zoom-reset-button > .toolbarbutton-icon {
+ display: none;
+}
+
+#customization-palette .toolbarbutton-text {
+ display: none;
+}
+
+menuitem.subviewbutton {
+ appearance: none !important;
+}
+
+.subviewbutton > .menu-text {
+ appearance: none;
+ margin-inline-start: 0 !important;
+}
+
+.subview-subheader,
+panelview .toolbarbutton-1,
+.subviewbutton,
+menu.subviewbutton,
+.widget-overflow-list .toolbarbutton-1 {
+ appearance: none;
+ margin: var(--appmenu-button-margin);
+ min-height: 24px;
+ padding-inline: 8px;
+ padding-block: var(--appmenu-button-padding);
+ border-radius: var(--arrowpanel-menuitem-border-radius);
+ background-color: transparent;
+}
+
+.subviewbutton:focus {
+ outline: 0;
+}
+
+.subviewbutton[disabled="true"],
+.subviewbutton[disabled="true"]:hover,
+.subviewbutton[checked="true"][disabled="true"],
+.subviewbutton[checked="true"][disabled="true"]:hover {
+ color: var(--text-color-deemphasized);
+ background-color: transparent;
+}
+
+.subviewbutton > .toolbarbutton-text {
+ padding: 0;
+ padding-inline-start: 24px; /* This is 16px for the icon + 8px for the padding as defined below. */
+}
+
+.addon-banner-item > .toolbarbutton-text,
+.subviewbutton-iconic > .toolbarbutton-text,
+.subviewbutton[image] > .toolbarbutton-text,
+.subviewbutton[targetURI] > .toolbarbutton-text,
+.subviewbutton.restoreallitem > .toolbarbutton-text,
+.subviewbutton.bookmark-item > .toolbarbutton-text,
+.subviewbutton[checked="true"] > .toolbarbutton-text {
+ padding-inline-start: 8px; /* See '.subviewbutton-iconic > .toolbarbutton-text' rule above. */
+}
+
+.addon-banner-item > .toolbarbutton-icon,
+.subviewbutton-iconic > .toolbarbutton-icon {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, stroke;
+}
+
+.addon-banner-item > .toolbarbutton-icon {
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.subviewbutton-iconic > .toolbarbutton-icon {
+ fill: var(--appmenu-icons-fill);
+ stroke: var(--appmenu-icons-stroke);
+}
+
+/* We don't always display: none this item, and if it has forced width (like above)
+ * or margin, that impacts the position of the label. Fix:
+ */
+.subviewbutton > .toolbarbutton-icon {
+ margin: 0;
+}
+
+.subviewbutton.panel-subview-footer > .menu-text {
+ appearance: none;
+ padding-inline-start: 0 !important; /* to override menu.css on Windows */
+ padding-inline-end: 6px;
+ flex: 0 0;
+}
+
+.subviewbutton.panel-subview-footer > .toolbarbutton-text {
+ padding-inline-start: 0;
+}
+
+.subviewbutton[shortcut]::after {
+ content: attr(shortcut);
+ float: inline-end;
+}
+
+.PanelUI-subView .subviewbutton-nav::after {
+ -moz-context-properties: stroke, fill-opacity;
+ content: var(--icon-nav-right-sm);
+ stroke: currentColor;
+ fill-opacity: 0.6;
+ float: inline-end;
+}
+
+.PanelUI-subView .subviewbutton-nav:-moz-locale-dir(rtl)::after {
+ content: var(--icon-nav-left-sm);
+}
+
+.subviewbutton[shortcut]::after,
+.subviewbutton[shortcut]::after,
+.PanelUI-subView .subviewbutton-nav::after {
+ margin-inline-start: 10px;
+}
+
+.subviewbutton[checked="true"] {
+ list-style-image: var(--icon-check);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ color: inherit;
+}
+
+#appMenu-popup .toolbaritem-combined-buttons {
+ align-items: center;
+ flex-direction: row;
+ border: 0;
+ border-radius: 0;
+ margin-inline-end: 6px;
+ padding-block: 0;
+}
+
+panelmultiview .toolbaritem-combined-buttons > label {
+ flex: 1;
+ margin: 0;
+ padding-inline-start: 4px;
+}
+
+panelmultiview .toolbaritem-combined-buttons > spacer.before-label {
+ width: 36px; /* 12px toolbarbutton padding + 16px icon + 8px label padding start */
+}
+
+panelmultiview .toolbaritem-combined-buttons > spacer.after-label {
+ flex: 1;
+ width: 20px; /* a little bigger than the width of the scrollbar */
+}
+
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton {
+ flex: 0 0;
+ height: auto;
+ margin-inline-start: 18px;
+ min-width: auto;
+ padding-inline: 5px;
+ padding-block: var(--appmenu-button-padding);
+ --focus-outline-offset: 1px;
+}
+
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton-iconic {
+ padding-inline: var(--appmenu-button-padding);
+}
+
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton-iconic > .toolbarbutton-text,
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton:not(.subviewbutton-iconic) >
+ .toolbarbutton-icon {
+ display: none;
+}
+
+.toolbaritem-combined-buttons > .subviewbutton:not(.subviewbutton-iconic) > .toolbarbutton-text {
+ padding-inline-start: 0;
+}
+
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton[type="radio"] {
+ margin-inline-start: 0;
+ margin-block: 1px;
+ padding: var(--appmenu-combined-radio-button-padding);
+ min-height: 22px;
+ border: 1px solid var(--appmenu-button-border-color);
+ border-radius: 0;
+}
+
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton[type="radio"]:focus {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton[type="radio"]:first-of-type {
+ border-inline-end: none;
+ border-start-start-radius: var(--arrowpanel-menuitem-border-radius);
+ border-end-start-radius: var(--arrowpanel-menuitem-border-radius);
+}
+
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton[type="radio"]:last-of-type {
+ border-inline-start: none;
+ border-start-end-radius: var(--arrowpanel-menuitem-border-radius);
+ border-end-end-radius: var(--arrowpanel-menuitem-border-radius);
+}
+
+.PanelUI-subView .toolbaritem-combined-buttons > .subviewbutton[type="radio"][checked="true"] {
+ --appmenu-icons-fill: color-mix(in srgb, currentColor 20%, transparent);
+ --appmenu-icons-stroke: currentColor;
+ background-color: var(--select-focus-background-color);
+ color: var(--select-focus-text-color);
+}
+
+.subview-subheader {
+ font-size: inherit;
+ font-weight: inherit;
+ color: var(--text-color-deemphasized);
+}
+
+panelview .toolbarbutton-1 {
+ margin-top: 6px;
+}
+
+panelview .toolbarbutton-1:not([disabled],[open],:active):is(:hover,:focus-visible),
+toolbarbutton.subviewbutton:not([disabled],[open],:active):is(:hover,:focus-visible),
+menu.subviewbutton:not([disabled],:active)[_moz-menuactive],
+menuitem.subviewbutton:not([disabled],:active)[_moz-menuactive],
+.widget-overflow-list .toolbarbutton-1:not([disabled],[open],:active):is(:hover,:focus-visible),
+.toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]) >
+ toolbarbutton:not([disabled],[open],:active):is(:hover,:focus-visible) {
+ color: inherit;
+ background-color: var(--arrowpanel-dimmed);
+}
+
+panelview .toolbarbutton-1:not([disabled]):is([open],:hover:active),
+toolbarbutton.subviewbutton:not([disabled]):is([open],:hover:active),
+menu.subviewbutton:not([disabled])[_moz-menuactive]:active,
+menuitem.subviewbutton:not([disabled])[_moz-menuactive]:active,
+.widget-overflow-list .toolbarbutton-1:not([disabled]):is([open],:hover:active),
+.toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]) >
+ toolbarbutton:not([disabled]):is([open],:hover:active) {
+ color: inherit;
+ background-color: var(--arrowpanel-dimmed-further);
+ box-shadow: 0 1px 0 hsla(210,4%,10%,.03) inset;
+}
+
+.subviewbutton.panel-subview-footer {
+ margin: 0;
+ background-color: var(--arrowpanel-dimmed);
+ border-top: 1px solid var(--panel-separator-color);
+ border-radius: 0;
+}
+
+menuitem.panel-subview-footer:not([disabled],:active)[_moz-menuactive],
+.subviewbutton.panel-subview-footer:not([disabled],[open],:active):is(:hover,:focus-visible) {
+ background-color: var(--arrowpanel-dimmed-further);
+}
+
+menuitem.panel-subview-footer:not([disabled])[_moz-menuactive]:active,
+.subviewbutton.panel-subview-footer:not([disabled]):is([open],:hover:active) {
+ background-color: var(--arrowpanel-dimmed-even-further);
+ box-shadow: 0 1px 0 hsla(210,4%,10%,.05) inset;
+}
+
+#widget-overflow-mainView > .panel-subview-body > toolbarseparator,
+.PanelUI-subView menuseparator,
+.PanelUI-subView toolbarseparator,
+.cui-widget-panelview menuseparator,
+.cui-widget-panel toolbarseparator {
+ appearance: none;
+ min-height: 0;
+ border-top: 1px solid var(--panel-separator-color);
+ border-bottom: none;
+ margin: var(--panel-separator-margin);
+ padding: 0;
+}
+
+.PanelUI-subView toolbarseparator[orient="vertical"] {
+ height: 24px;
+ border-inline-start: 1px solid var(--panel-separator-color);
+ border-top: none;
+ margin: 0;
+ margin-inline: 6px 7px;
+}
+
+#search-container[cui-areatype="menu-panel"] {
+ padding-block: 6px;
+}
+
+toolbarpaletteitem[place="palette"] > #search-container {
+ min-width: 7em;
+ width: 7em;
+ min-height: 37px;
+ max-height: 37px;
+}
+
+.toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]) > toolbarbutton {
+ border: 0;
+ margin: 0;
+ flex: 1;
+ padding-block: 4px;
+ flex-direction: row;
+}
+
+/* In customize mode, extend the buttons *only* in the panel, just to make them not look stupid */
+toolbarpaletteitem[place="menu-panel"] > .toolbaritem-combined-buttons > toolbarbutton {
+ min-width: var(--menu-panel-button-width);
+ max-width: var(--menu-panel-button-width);
+}
+
+.toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]) > toolbarbutton:not(.toolbarbutton-1)[disabled] {
+ opacity: 0.4;
+ /* Override toolbarbutton.css which sets the color to GrayText */
+ color: inherit;
+}
+
+.toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]) > separator {
+ appearance: none;
+ align-items: stretch;
+ margin: .5em 0;
+ width: 1px;
+ height: auto;
+ background: var(--panel-separator-color);
+ transition-property: margin;
+ transition-duration: 10ms;
+ transition-timing-function: ease;
+}
+
+.toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]):hover > separator {
+ margin: 0;
+}
+
+#widget-overflow-mainView .panel-subview-body {
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.widget-overflow-list {
+ width: var(--wide-menu-panel-width);
+}
+
+/* In customize mode, the overflow list is constrained by its container,
+ * so we set width: auto to avoid the scrollbar not fitting.
+ */
+#customization-panelHolder > .widget-overflow-list {
+ width: auto;
+}
+
+toolbaritem[overflowedItem=true],
+.widget-overflow-list .toolbarbutton-1 {
+ width: 100%;
+ max-width: var(--wide-menu-panel-width);
+ background-repeat: no-repeat;
+ background-position: 0 center;
+}
+
+.widget-overflow-list .toolbarbutton-1 {
+ align-items: center;
+ flex-direction: row;
+}
+
+.widget-overflow-list .subviewbutton-nav::after {
+ margin-inline-start: 10px;
+ -moz-context-properties: stroke, fill-opacity;
+ content: var(--icon-nav-right-sm);
+ stroke: currentColor;
+ fill-opacity: 0.6;
+ float: inline-end;
+}
+
+.widget-overflow-list .subviewbutton-nav:-moz-locale-dir(rtl)::after {
+ content: var(--icon-nav-left-sm);
+}
+
+toolbarpaletteitem[place="menu-panel"] > .subviewbutton-nav::after {
+ opacity: 0.5;
+}
+
+.widget-overflow-list .toolbarbutton-1:not(.toolbarbutton-combined) > .toolbarbutton-text {
+ text-align: start;
+ padding-inline-start: .5em;
+}
+
+.subviewradio {
+ appearance: none;
+ align-items: center;
+ padding: 1px;
+ margin: 0 0 2px;
+ background-color: transparent;
+ border-radius: 2px;
+ border: 1px solid transparent;
+}
+
+.subviewradio:not([disabled],[open],:active):is(:hover,:focus-visible) {
+ background-color: var(--arrowpanel-dimmed);
+ border-color: var(--panel-separator-color);
+}
+
+.subviewradio[selected],
+.subviewradio[selected]:hover,
+.subviewradio:not([disabled]):is([open],:hover:active) {
+ background-color: var(--arrowpanel-dimmed-further);
+ border-color: var(--panel-separator-color);
+ box-shadow: 0 1px 0 hsla(210,4%,10%,.03) inset;
+}
+
+.subviewradio > .radio-check {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border: 1px solid #e7e7e7;
+ border-radius: 50%;
+ margin: 1px 5px;
+ background-color: #f1f1f1;
+}
+
+.subviewradio > .radio-check[selected] {
+ background-color: #fff;
+ border: 4px solid #177ee6;
+}
+
+.panel-header {
+ align-items: center;
+ border-bottom: 1px solid var(--panel-separator-color);
+ display: flex;
+ height: 40px; /* fixed item height to prevent flex sizing; height + 2*4px padding */
+ padding: 4px;
+}
+
+.panel-header > h1 {
+ display: inline;
+ flex: auto;
+ font-size: inherit;
+ font-weight: 600;
+ margin: 0;
+ text-align: center;
+}
+
+.panel-header > h1 > span {
+ display: inline-block;
+ white-space: pre-wrap;
+}
+
+.panel-header > .subviewbutton-back + h1 {
+ /* Add the size of the back button to center properly. */
+ margin-inline-end: 32px;
+}
+
+.panel-header > .subviewbutton-back {
+ -moz-context-properties: stroke;
+ stroke: var(--arrowpanel-color);
+ list-style-image: var(--icon-nav-left-sm);
+ padding: 10px;
+}
+
+.subviewbutton-back {
+ transform: translateY(1px);
+ margin: 0 4px;
+}
+
+.subviewbutton-back:-moz-locale-dir(rtl) {
+ list-style-image: var(--icon-nav-right-sm);
+}
+
+.panel-header > .subviewbutton-back > .toolbarbutton-icon {
+ width: 12px;
+ height: 12px;
+}
+
+.subviewbutton-back > .toolbarbutton-text {
+ /* !important to override .cui-widget-panel .toolbarbutton-text
+ * selector further down. */
+ display: none !important;
+}
+
+.subviewbutton.download {
+ align-items: flex-start;
+ min-height: 48px;
+ padding-inline-start: 8px;
+}
+
+.subviewbutton.download > .toolbarbutton-icon,
+.subviewbutton.download > .toolbarbutton-text > label {
+ margin: 4px 0 0;
+}
+
+.subviewbutton.download > .toolbarbutton-icon {
+ width: 32px;
+ height: 32px;
+}
+
+.subviewbutton.download > .toolbarbutton-text > .status-text {
+ color: var(--text-color-deemphasized);
+ font-size: .9em;
+}
+
+.button-appmenu[badge-status] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
+ display: flex;
+ background: center no-repeat transparent;
+ background-size: contain;
+ border: none;
+ box-shadow: none;
+}
+
+.button-appmenu[badge-status="update-available"] > .toolbarbutton-badge-stack > .toolbarbutton-badge,
+.button-appmenu[badge-status="update-downloading"] > .toolbarbutton-badge-stack > .toolbarbutton-badge,
+.button-appmenu[badge-status="update-manual"] > .toolbarbutton-badge-stack > .toolbarbutton-badge,
+.button-appmenu[badge-status="update-other-instance"] > .toolbarbutton-badge-stack > .toolbarbutton-badge,
+.button-appmenu[badge-status="update-restart"] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
+ margin-top: -3px !important;
+ margin-inline-end: -2px !important;
+ min-width: 12px;
+ min-height: 12px;
+ -moz-context-properties: fill;
+ fill: var(--color-green-60);
+ background-image: var(--icon-notification-sm);
+ background-size: 12px;
+}
+
+.button-appmenu[badge-status="addon-alert"] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
+ height: 12px;
+ margin-inline-end: -5px !important;
+ background-image: var(--icon-warning-sm);
+ box-shadow: none;
+ border-radius: 0;
+ -moz-context-properties: fill, stroke;
+ fill: var(--color-amber-30);
+ stroke: var(--color-amber-60);
+}
+
+.button-appmenu:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+/* Main menu banner menuitems */
+
+.panel-banner-item {
+ appearance: none;
+ align-items: flex-start;
+ margin: var(--appmenu-button-margin);
+ padding-inline: 8px;
+ padding-block: calc(var(--appmenu-button-padding) + 3px);
+ border-radius: var(--arrowpanel-menuitem-border-radius);
+ color: var(--button-color);
+ background-color: color-mix(in srgb, currentColor 7%, transparent);;
+ margin-bottom: 4px;
+ font-weight: 600;
+}
+
+.panel-banner-item:not([disabled]):hover {
+ background-color: var(--arrowpanel-dimmed);
+}
+
+.panel-banner-item:not([disabled]):hover:active {
+ background-color: var(--arrowpanel-dimmed-further);
+}
+
+.panel-banner-item > .toolbarbutton-text {
+ display: inline-block;
+ padding: 0;
+}
+
+.panel-banner-item > .toolbarbutton-icon {
+ width: 16px;
+ height: 16px;
+ /* Move the icon to appear after the text label. */
+ order: 2;
+}
+
+.panel-banner-item[notificationid="update-available"] > .toolbarbutton-icon,
+.panel-banner-item[notificationid="update-downloading"] > .toolbarbutton-icon,
+.panel-banner-item[notificationid="update-manual"] > .toolbarbutton-icon,
+.panel-banner-item[notificationid="update-other-instance"] > .toolbarbutton-icon,
+.panel-banner-item[notificationid="update-restart"] > .toolbarbutton-icon {
+ background-image: url(chrome://messenger/skin/icons/app-update-badge.svg);
+ background-repeat: no-repeat;
+ background-position: center;
+ margin-inline-start: 6px;
+ -moz-context-properties: fill;
+ fill: var(--color-green-60);
+}
+
+.addon-banner-item {
+ appearance: none;
+ font-weight: 600;
+ margin: 0 8px 4px;
+ padding-inline: 8px;
+ padding-block: var(--appmenu-button-padding);
+ box-sizing: border-box;
+ box-shadow: none;
+ color: var(--appmenu-warning-color);
+ background-color: var(--appmenu-warning-background-color);
+ border-top: 1px solid var(--appmenu-warning-border-color);
+ border-inline: 1px solid var(--appmenu-warning-border-color);
+ border-radius: var(--arrowpanel-menuitem-border-radius);
+ transition: background-color;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, var(--appmenu-warning-color) 20%, transparent);
+ stroke: var(--appmenu-warning-color);
+}
+
+.addon-banner-item::after {
+ content: "";
+ width: 16px;
+ height: 16px;
+ margin-inline: 10px 0;
+ display: flex;
+ background: var(--icon-warning) no-repeat center;
+}
+
+:root[lwt-popup-brighttext] .addon-banner-item::after {
+ -moz-context-properties: fill;
+ fill: var(--color-amber-30);
+}
+
+.addon-banner-item:last-child {
+ border-bottom: 1px solid var(--appmenu-warning-border-color);
+}
+
+.addon-banner-item:focus,
+.addon-banner-item:hover {
+ background-color: var(--appmenu-warning-background-color-hover);
+}
+
+.addon-banner-item:hover:active {
+ background-color: var(--appmenu-warning-background-color-active);
+}
+
+menulist {
+ border: 1px solid var(--button-border-color);
+ background-color: var(--button-background-color);
+}
+
+menulist:not([disabled="true"],[open="true"]):hover,
+menulist[open="true"]:not([disabled="true"]) {
+ background-color: var(--button-hover-background-color);
+ border-color: var(--button-border-color);
+}
+
+/* App Menu items icons */
+
+/* Sync */
+
+#appmenu_signin {
+ list-style-image: var(--icon-sync);
+}
+
+#appmenu-submenu-sync-now{
+ list-style-image: var(--icon-sync);
+}
+
+#appmenu-submenu-sync-settings {
+ list-style-image: var(--icon-settings);
+}
+
+#appmenu-submenu-sync-sign-out{
+ list-style-image: var(--icon-quit);
+}
+
+#syncSeparator {
+ background-color: var(--color-blue-50);
+ background-image: linear-gradient(to right, var(--color-teal-40), var(--color-magenta-60));
+ height: 1px;
+ border: none;
+ border-radius: 2px;
+}
+
+#appmenu-manage-sync-icon {
+ content: var(--icon-contact);
+}
+
+.appmenu-sync-account-email {
+ font-weight: 600;
+}
+
+@keyframes syncRotate {
+ from { transform: rotate(0); }
+ to { transform: rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ #appmenu-submenu-sync-now[syncstatus="active"] > .toolbarbutton-icon {
+ animation: syncRotate 0.8s linear infinite;
+ }
+}
+
+/* New Account and Create */
+
+#appmenu_new {
+ list-style-image: var(--icon-new-contact);
+}
+
+#appmenu_create {
+ list-style-image: var(--icon-add);
+}
+
+#appmenu_newNewMsgCmd,
+#appmenu_newCreateEmailAccountMenuItem {
+ list-style-image: var(--icon-new-mail);
+}
+
+#appmenu_newMailAccountMenuItem {
+ list-style-image: var(--icon-mail);
+}
+
+#appmenu_calendar-new-calendar-menu-item {
+ list-style-image: var(--icon-calendar);
+}
+
+#appmenu_newAB {
+ list-style-image: var(--icon-address-book);
+}
+
+#appmenu_newABMenuItem,
+#appmenu_newCardDAVMenuItem,
+#appmenu_newLdapMenuItem {
+ list-style-image: var(--icon-new-address-book);
+}
+
+#appmenu_newIMAccountMenuItem {
+ list-style-image: var(--icon-chat);
+}
+
+#appmenu_newFeedAccountMenuItem {
+ list-style-image: var(--icon-rss);
+}
+
+#appmenu_newNewsgroupAccountMenuItem {
+ list-style-image: var(--icon-newsletter);
+}
+
+#appmenu_calendar-new-event-menu-item {
+ list-style-image: var(--icon-new-event);
+}
+
+#appmenu_calendar-new-task-menu-item {
+ list-style-image: var(--icon-new-task);
+}
+
+#appmenu_open {
+ list-style-image: var(--icon-draft);
+}
+
+#appmenu_OpenMessageFileMenuitem {
+ list-style-image: var(--icon-mail);
+}
+
+#appmenu_OpenCalendarFileMenuitem {
+ list-style-image: var(--icon-calendar);
+}
+
+#appmenu_View {
+ list-style-image: var(--icon-eye);
+}
+
+#appmenu_Toolbars {
+ list-style-image: var(--icon-spaces-menu);
+}
+
+#appmenu_MessagePaneLayout {
+ list-style-image: var(--icon-layout);
+}
+
+#appmenu_FolderViews {
+ list-style-image: var(--icon-folder);
+}
+
+#appMenu-uiDensity-controls .toolbarbutton-icon {
+ content: var(--icon-spaces-menu);
+}
+
+#appMenu-fontSize-controls .toolbarbutton-icon {
+ content: var(--icon-font);
+}
+
+#appmenu_newCard {
+ list-style-image: var(--icon-new-contact);
+}
+
+/* Settings */
+
+#appmenu_addons {
+ list-style-image: var(--icon-extension);
+}
+
+#appmenu_accountmgr {
+ list-style-image: var(--icon-account-settings);
+}
+
+#appmenu_preferences {
+ list-style-image: var(--icon-settings);
+}
+
+/* Density Settings */
+
+#appmenu_uiDensityCompact {
+ list-style-image: var(--icon-density-compact);
+}
+
+#appmenu_uiDensityNormal {
+ list-style-image: var(--icon-density-default);
+}
+
+#appmenu_uiDensityTouch {
+ list-style-image: var(--icon-density-relaxed);
+}
+
+/* Font scaling */
+
+#appMenu-fontSizeReduce-button {
+ list-style-image: var(--font-size-decrease);
+}
+#appMenu-fontSizeEnlarge-button {
+ list-style-image: var(--font-size-increase);
+}
+
+#appMenu-fontSizeReduce-button:hover,
+#appMenu-fontSizeReduce-button:focus,
+#appMenu-fontSizeReduce-button:active,
+#appMenu-fontSizeReset-button:hover,
+#appMenu-fontSizeReset-button:focus,
+#appMenu-fontSizeReset-button:active,
+#appMenu-fontSizeEnlarge-button:hover,
+#appMenu-fontSizeEnlarge-button:focus,
+#appMenu-fontSizeEnlarge-button:active,
+#appMenu-fullscreen-button:hover,
+#appMenu-fullscreen-button:focus,
+#appMenu-fullscreen-button:active {
+ color: unset;
+ background-color: unset;
+ box-shadow: unset;
+}
+
+#appMenu-fontSizeReduce-button > .toolbarbutton-icon,
+#appMenu-fontSizeEnlarge-button > .toolbarbutton-icon {
+ width: var(--appmenu-fontsize-icon-size);
+ height: var(--appmenu-fontsize-icon-size);
+ -moz-context-properties: fill, stroke;
+ fill: var(--button-background-color, ButtonFace);
+ stroke: var(--button-color);
+ border-radius: 50%;
+ margin-block-start: 1px;
+ padding: 0;
+}
+
+/* Compensate the smaller icon. */
+#appMenu-fontSizeEnlarge-button > .toolbarbutton-icon {
+ margin-inline-end: -2px;
+}
+
+:root[uidensity="compact"] #appMenu-fontSizeEnlarge-button > .toolbarbutton-icon {
+ margin-inline-end: -1px;
+}
+
+#appMenu-fontSizeReset-button > .toolbarbutton-text {
+ display: flex;
+ justify-content: center;
+ border: 1px solid var(--appmenu-button-border-color);
+ border-radius: 2px;
+ min-width: var(--appmenu-fontsize-reset-button-width);
+ text-align: center;
+ padding: 2px;
+}
+
+#appMenu-fontSizeReset-button:hover > .toolbarbutton-text {
+ background-color: var(--button-hover-background-color);
+}
+#appMenu-fontSizeReduce-button:hover > .toolbarbutton-icon,
+#appMenu-fontSizeEnlarge-button:hover > .toolbarbutton-icon {
+ fill: var(--button-hover-background-color);
+}
+
+#appMenu-fontSizeReset-button:active > .toolbarbutton-text {
+ background-color: var(--button-active-background-color);
+}
+#appMenu-fontSizeReduce-button:active > .toolbarbutton-icon,
+#appMenu-fontSizeEnlarge-button:active > .toolbarbutton-icon {
+ fill: var(--button-active-background-color);
+}
+
+#appMenu-fontSizeReset-button:focus > .toolbarbutton-text,
+#appMenu-fontSizeReduce-button:focus > .toolbarbutton-icon,
+#appMenu-fontSizeEnlarge-button:focus > .toolbarbutton-icon {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+#appMenu-fontSize-controls > .subviewbutton {
+ margin-inline-start: 2px;
+ padding: 3px;
+}
+
+#appMenu-fontSize-controls > #appMenu-fontSizeEnlarge-button {
+ padding-inline-end: 0;
+}
+
+:root[uidensity="compact"] #appMenu-fontSizeReduce-button > .toolbarbutton-icon,
+:root[uidensity="compact"] #appMenu-fontSizeEnlarge-button > .toolbarbutton-icon {
+ margin-block-start: 2px;
+}
+
+/* Tools */
+
+#appmenu_import {
+ list-style-image: var(--icon-import);
+}
+
+#appmenu_export {
+ list-style-image: var(--icon-export);
+}
+
+#appmenu_searchCmd {
+ list-style-image: var(--icon-search);
+}
+
+#appmenu_filtersCmd {
+ list-style-image: var(--icon-filter);
+}
+
+#appmenu_openSavedFilesWnd {
+ list-style-image: var(--icon-download);
+}
+
+#appmenu_manageKeysOpenPGP {
+ list-style-image: var(--icon-key);
+}
+
+#appmenu_activityManager {
+ list-style-image: var(--icon-paste);
+}
+
+#appmenu_toolsMenu,
+#appmenu_devtoolsToolbox {
+ list-style-image: var(--icon-tools);
+}
+
+/* Help */
+
+#appmenu_help,
+#appmenu_openHelp {
+ list-style-image: var(--icon-question);
+}
+
+#appmenu_openTour {
+ list-style-image: var(--icon-features);
+}
+
+#appmenu_keyboardShortcuts {
+ list-style-image: var(--icon-shortcut);
+}
+
+#appmenu_getInvolved {
+ list-style-image: var(--icon-handshake);
+}
+
+#appmenu_makeDonation {
+ list-style-image: var(--icon-heart);
+}
+
+#appmenu_submitFeedback {
+ list-style-image: var(--icon-chat);
+}
+
+#appmenu_troubleshootMode {
+ list-style-image: var(--icon-tools);
+}
+
+#appmenu_troubleshootingInfo {
+ list-style-image: var(--icon-draft);
+}
+
+#appmenu_about {
+ list-style-image: var(--icon-info);
+}
+
+/* Quit */
+
+#appmenu-quit {
+ list-style-image: var(--icon-quit);
+}
diff --git a/comm/mail/themes/shared/mail/popupPanel.css b/comm/mail/themes/shared/mail/popupPanel.css
new file mode 100644
index 0000000000..c46d4457e5
--- /dev/null
+++ b/comm/mail/themes/shared/mail/popupPanel.css
@@ -0,0 +1,213 @@
+/* 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 html url("http://www.w3.org/1999/xhtml");
+
+.editContactPanel_rowLabel {
+ text-align: end;
+}
+
+#editContactHeader {
+ display: flex;
+ margin-bottom: 15px;
+}
+
+#editContactPanelIcon {
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, var(--toolbarbutton-icon-fill-attention) 20%, transparent);
+ stroke: var(--toolbarbutton-icon-fill-attention);
+ width: 20px;
+ height: 20px;
+ margin-block: auto;
+ margin-inline: 3px 12px;
+}
+
+#editContactPanelTitle {
+ font-size: 130%;
+ font-weight: bold;
+ margin-block: auto;
+}
+
+#editContactContent {
+ margin-block: 6px 15px;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+}
+
+#editContactEmail {
+ min-width: 20em;
+}
+
+html|input.editContactTextbox {
+ appearance: none;
+ cursor: text;
+ color: var(--toolbar-field-color);
+ background-color: var(--toolbar-field-background-color);
+ border: 1px solid var(--toolbar-field-border-color);
+ border-radius: var(--button-border-radius);
+ padding: 3px 8px;
+ width: 20em;
+}
+
+html|input.editContactTextbox:focus {
+ border-color: var(--focus-outline-color);
+ outline: 1px solid var(--focus-outline-color);
+}
+
+html|input.editContactTextbox[readonly] {
+ border-color: transparent !important;
+ background-color: inherit !important;
+ box-shadow: none;
+ color: inherit;
+ outline: none !important;
+}
+
+#contactMoveDisabledText {
+ width: 20em;
+}
+
+#editContactAddressBookList {
+ appearance: none;
+ background-color: var(--arrowpanel-dimmed);
+ background-image: none;
+ border: 1px solid;
+ border-color: var(--panel-separator-color) !important;
+ box-shadow: none;
+ color: inherit;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#editContactAddressBookList:not([disabled="true"],[open="true"]):hover {
+ background-image: linear-gradient(var(--arrowpanel-dimmed),
+ var(--arrowpanel-dimmed));
+ background-color: var(--arrowpanel-dimmed);
+}
+
+#editContactAddressBookList[open="true"] {
+ background-image: linear-gradient(var(--arrowpanel-dimmed-further),
+ var(--arrowpanel-dimmed-further));
+ box-shadow: 0 1px 0 hsla(210, 4%, 10%, .05) inset;
+}
+
+#spacesToolbarCustomizationPanel {
+ margin-block-end: 0;
+}
+
+.popup-panel {
+ margin-block: 0;
+}
+
+.popup-panel-body {
+ padding: 9px 15px;
+ min-width: 300px;
+}
+
+.popup-panel-body h3 {
+ margin-block: 0 15px;
+ font-size: 1.4em;
+ font-weight: 500;
+}
+
+.popup-panel-options-grid {
+ display: grid;
+ margin-block: 6px 15px;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 6px;
+}
+
+.popup-panel-options-grid input {
+ margin-inline-end: 0;
+}
+
+.popup-panel-column-container {
+ grid-column: 1 / 3;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.popup-panel button:focus-visible,
+.popup-panel input[type="color"]:focus-visible {
+ outline: 2px solid var(--focus-outline-color);
+ outline-offset: -1px;
+}
+
+.popup-panel label,
+.popup-panel checkbox {
+ margin-inline-start: 0;
+}
+
+.popup-panel label.indent,
+.popup-panel checkbox.indent {
+ margin-inline-start: 23px;
+}
+
+.popup-panel-buttons-container {
+ display: flex;
+ align-items: center;
+ margin: 0;
+ justify-content: end;
+}
+
+.popup-panel-buttons-container > button {
+ appearance: none;
+ border: 0;
+ border-radius: 4px;
+ color: var(--button-color, inherit);
+ background-color: var(--button-background-color, color-mix(in srgb, currentColor 13%, transparent));
+ padding: 8px 16px;
+ font-weight: 600;
+ min-width: 0;
+ margin-inline: 8px 0;
+ margin-bottom: 0;
+}
+
+@media (prefers-contrast) {
+ .popup-panel-buttons-container > button {
+ outline: 1px solid var(--button-border-color);
+ }
+}
+
+.popup-panel-buttons-container > button[disabled] {
+ opacity: 0.4;
+}
+
+.popup-panel-buttons-container > button:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+.popup-panel-buttons-container > button:not([disabled]):hover {
+ background-color: var(--button-hover-background-color, color-mix(in srgb, currentColor 17%, transparent));
+ color: var(--button-hover-text-color);
+}
+
+
+.popup-panel-buttons-container > button:not([disabled]):hover:active {
+ background-color: var(--button-active-background-color, color-mix(in srgb, currentColor 30%, transparent));
+}
+
+.popup-panel-buttons-container > button.primary:not([disabled]) {
+ color: var(--button-primary-color);
+ background-color: var(--button-primary-background-color);
+}
+
+.popup-panel-buttons-container > button.primary:not([disabled]):hover {
+ background-color: var(--button-primary-hover-background-color);
+}
+
+.popup-panel-buttons-container > button.primary:not([disabled]):hover:active {
+ background-color: var(--button-primary-active-background-color);
+}
+
+.checkbox-description {
+ margin-inline-start: 23px;
+ font-size: 0.95rem;
+ opacity: 0.8;
+ margin-block: -5px 6px;
+}
diff --git a/comm/mail/themes/shared/mail/preferences/applications.css b/comm/mail/themes/shared/mail/preferences/applications.css
new file mode 100644
index 0000000000..d68855d258
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/applications.css
@@ -0,0 +1,234 @@
+/* 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/. */
+
+/**
+ * Line up the actions menu with action labels above and below it.
+ * Equalize the distance from the left side of the action box to the left side
+ * of the icon for both the menu and the non-menu versions of the action box.
+ * Also make sure the labels are the same distance away from the icons.
+ */
+
+.shortDetails {
+ text-align: end;
+ opacity: 0.5;
+}
+
+#filter {
+ width: 100%;
+}
+
+#handlersSortSelect {
+ padding-block: 0;
+}
+
+#handlersView {
+ height: 210px;
+ overflow-y: auto;
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 4px;
+}
+
+#handlersTable {
+ width: 100%;
+ border-spacing: 0;
+}
+
+#handlersTable thead > tr {
+ position: sticky;
+ top: 0;
+ /* Background color is needed for sticky headers. */
+ background-color: var(--in-content-page-background);
+}
+
+#handlersTable thead > tr > th:not(:first-child) {
+ border-inline-start: 1px solid var(--in-content-box-border-color);
+}
+
+#handlersTable thead > tr > th {
+ border-block-end: 1px solid var(--in-content-box-border-color);
+}
+
+#handlersTable tbody > tr:hover .typeCell {
+ background-color: var(--in-content-item-hover);
+}
+
+#handlersTable tbody > tr:hover .actionCell menulist {
+ /* Get hover effect if anywhere in the row is hovered. */
+ background-color: var(--in-content-button-background-hover);
+}
+
+#handlersTable tbody > tr:focus-within .typeCell {
+ background-color: var(--in-content-item-selected);
+ color: var(--in-content-item-selected-text);
+}
+
+#handlersTable :is(th, td) {
+ padding: 0;
+ width: 50%;
+}
+
+.handlerHeaderButton {
+ /* Align with .typeIcon. */
+ padding-inline: 10px;
+ border: none;
+ border-radius: 0;
+ margin: 0;
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.handlerHeaderButton:focus-visible {
+ /* The focus outline is drawn on the inside of the element (it has width 2px),
+ * rather than on the outside as usual. This is needed because otherwise the
+ * outline is not visible on the sides that are cut off by the scroll
+ * container #handlersView's edges. */
+ outline-offset: -2px;
+}
+
+.handlerSortHeaderIcon {
+ display: inline-block;
+ width: 12px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.handlerSortHeaderIcon[descending] {
+ transform: scaleY(-1);
+}
+
+.handlerSortHeaderIcon:not([src]) {
+ /* Still want to take up space. */
+ visibility: hidden;
+}
+
+.typeLabel {
+ display: flex;
+ align-items: center;
+}
+
+.typeLabel > .typeIcon {
+ flex: 0 0 auto;
+}
+
+.typeLabel > .typeDescription {
+ flex: 1 1 auto;
+}
+
+.typeIcon {
+ width: 16px;
+ height: 16px;
+ margin-inline: 10px 9px;
+}
+
+.typeIcon:not([src]) {
+ visibility: hidden;
+}
+
+.actionsMenu {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ border-radius: 0;
+ margin: 0;
+ padding-block: 0;
+}
+
+#handlersTable tbody > tr:not(:focus-within) .actionsMenu {
+ background-color: transparent;
+ --in-content-button-border-color: transparent;
+}
+
+#handlersTable tbody > tr:not(:is(:hover,:focus-within)) .actionsMenu::part(dropmarker) {
+ display: none;
+}
+
+.actionsMenu:focus-visible {
+ outline-offset: -2px;
+}
+
+.actionsMenu::part(icon) {
+ margin-inline: 5px !important;
+ height: 16px;
+ width: 16px;
+}
+
+.actionsMenu > menupopup > menuitem > .menu-iconic-left {
+ margin-inline-end: 8px !important;
+ /** Make the icons appear.
+ * Note: we display the icon box for every item whether or not it has an icon
+ * so the labels of all the items align vertically. */
+ display: flex;
+ min-width: 16px;
+}
+
+/**
+ * Used by the cloudFile manager
+ */
+
+#provider-listing {
+ width: 200px;
+}
+
+#cloudFileDefaultPanel {
+ text-align: center;
+ padding-top: 150px;
+}
+
+#addCloudFileAccountButtons button,
+#addCloudFileAccount,
+#removeCloudFileAccount,
+#moreProvidersLink {
+ margin: 4px 0 0;
+ text-align: center;
+}
+
+#addCloudFileAccountButtons button .button-icon {
+ width: 16px;
+ height: 16px;
+ margin-inline-end: 8px;
+}
+
+#addCloudFileAccountListItems {
+ text-align: start;
+}
+
+#addCloudFileAccountListItems > menuitem > .menu-iconic-left {
+ display: flex;
+}
+
+#moreProvidersLink {
+ padding: 4px;
+}
+
+#cloudFileView,
+#cloudFileBox {
+ flex: 1 auto;
+}
+
+#cloudFileView > richlistitem {
+ min-height: 35px;
+}
+
+.cloudfileAccount {
+ padding: 4px;
+}
+
+.cloudfileAccount > label {
+ flex: 1;
+}
+
+.cloudfileAccount > input {
+ min-width: 10ch !important;
+ width: 10ch;
+}
+
+.cloudfileAccount:not([selected]) > label {
+ pointer-events: none;
+}
+
+#cloudFileToggleAndThreshold {
+ padding-bottom: 6px;
+}
diff --git a/comm/mail/themes/shared/mail/preferences/calendar.svg b/comm/mail/themes/shared/mail/preferences/calendar.svg
new file mode 100644
index 0000000000..e660539db8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/calendar.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M3 5h10V2.17c1.165.413 2 1.524 2 2.83v7a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V5c0-1.306.835-2.417 2-2.83V5zm0 1v6a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V6H3zm3-4h4v2H6V2zM4.5 0a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 .5-.5zm7 0a.5.5 0 0 1 .5.5v3a.5.5 0 1 1-1 0v-3a.5.5 0 0 1 .5-.5zM4 7h2v2H4V7zm6 0h2v2h-2V7zm-6 3h2v2H4v-2zm3 0h2v2H7v-2zm0-3h2v2H7V7z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/preferences/chat.svg b/comm/mail/themes/shared/mail/preferences/chat.svg
new file mode 100644
index 0000000000..c1c9f5dfed
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/chat.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M12 16a1 1 0 0 1-.77-.37L8.26 12H2.42A2.43 2.43 0 0 1 0 9.58V3.42A2.43 2.43 0 0 1 2.42 1h11.16A2.43 2.43 0 0 1 16 3.42v6.16A2.43 2.43 0 0 1 13.58 12H13v3a1 1 0 0 1-1 1zM2.42 3a.42.42 0 0 0-.42.42v6.16c0 .232.188.42.42.42h6.31a1 1 0 0 1 .77.37l1.5 1.82V11a1 1 0 0 1 1-1h1.58a.42.42 0 0 0 .42-.42V3.42a.42.42 0 0 0-.42-.42H2.42z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/preferences/dialog.css b/comm/mail/themes/shared/mail/preferences/dialog.css
new file mode 100644
index 0000000000..737f3c6f73
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/dialog.css
@@ -0,0 +1,174 @@
+/* - 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 html url("http://www.w3.org/1999/xhtml");
+
+@media not (prefers-contrast) {
+ @media not (prefers-color-scheme: dark) {
+ :root > * {
+ --in-content-button-background: var(--grey-90-a10);
+ --in-content-button-background-hover: var(--grey-90-a20);
+ --in-content-button-background-active: var(--grey-90-a30);
+ }
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root > * {
+ --in-content-page-background: #2a2a2e;
+ --in-content-button-background: rgba(249, 249, 250, 0.1);
+ --in-content-button-background-hover: rgba(249, 249, 250, 0.15);
+ --in-content-button-background-active: rgba(249, 249, 250, 0.2);
+ --in-content-primary-button-background: #45a1ff;
+ --in-content-primary-button-background-hover: #65c1ff;
+ --in-content-primary-button-background-active: #85e1ff;
+ --in-content-focus-outline-color: #45a1ff;
+ }
+ }
+}
+
+dialog,
+window,
+.windowDialog {
+ appearance: none;
+ background-color: var(--in-content-page-background);
+ color: var(--in-content-page-color);
+ margin: 0;
+ padding: 0;
+}
+
+body > dialog {
+ background-color: unset;
+}
+
+.contentPane,
+dialog::part(content-box) {
+ flex: 1;
+}
+
+.contentPane.doScroll,
+dialog.doScroll::part(content-box) {
+ overflow-y: auto;
+ contain: size;
+}
+
+dialog.doScroll {
+ margin-inline: -4px;
+}
+
+dialog.doScroll::part(content-box) {
+ padding-inline: 4px;
+}
+
+tabbox {
+ /* override the rule in certManager.xhtml */
+ margin: 0 0 5px !important;
+}
+
+tabpanels {
+ font-size: 1em;
+}
+
+tabs,
+label,
+description,
+#useDocumentColors {
+ margin-inline: 4px;
+}
+
+/* This element (in passwordManager.xhtml) has no height until Fluent fills
+ it, but we need to calculate the document height before then. The value is
+ the same as the line-height for a label in preferences.css. */
+label#signonsIntro {
+ height: 1.8em;
+}
+
+tree {
+ min-height: 150px;
+}
+
+caption {
+ padding-inline-start: 0;
+}
+
+groupbox {
+ margin-top: 0;
+ margin-inline: 4px;
+ padding-block: 0 5px;
+}
+
+groupbox description {
+ margin-inline: 0;
+}
+
+menulist label {
+ font-weight: unset;
+}
+
+.actionButtons + resizer {
+ display: none;
+}
+
+menulist,
+button,
+html|input[type="number"] {
+ margin-inline-end: 0;
+}
+
+button,
+menulist {
+ padding: 0 8px;
+}
+
+/* Create a separate rule to unset these styles on .tree-input instead of
+ using :not(.tree-input) so the selector specifity doesn't change. */
+textbox.tree-input {
+ font-size: unset;
+}
+
+/* Give some space in front of elements that follows a menulist, button or an
+ input in dialogs */
+.startSpacing {
+ margin-inline-start: 8px;
+}
+
+#siteCol {
+ flex: 3 auto;
+}
+
+#statusCol {
+ flex: 1 auto;
+}
+
+#domainCol {
+ flex: 2 auto;
+}
+
+#nameCol {
+ flex: 1 auto;
+}
+
+/* Adjust the Lightning Edit Category dialog */
+#colorSelectRow {
+ margin-top: 10px;
+ margin-inline-start: 4px;
+}
+
+#totalOpenTime {
+ min-width: calc(3ch + 55px);
+}
+
+#logView {
+ border: 1px solid var(--in-content-box-border-color);
+ margin: 0 4px 5px;
+}
+
+#cookieInfoSettings {
+ margin-top: 8px;
+ margin-inline-end: -4px;
+}
+
+/* Edit SMTP Server dialog */
+#smtpUsername {
+ margin-inline: 8px 0;
+}
diff --git a/comm/mail/themes/shared/mail/preferences/general.svg b/comm/mail/themes/shared/mail/preferences/general.svg
new file mode 100644
index 0000000000..d20eaa97d3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/general.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 24 24">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M7 11.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0M21.48 10h-2.65a7.45 7.45 0 0 0-1.1-2.64L19.6 5.5a1.5 1.5 0 1 0-2.1-2.13l-1.9 1.87c-.8-.52-1.7-.9-2.65-1.1V1.5a1.5 1.5 0 1 0-3 0V4l.03.14c-.96.2-1.86.58-2.65 1.1L5.5 3.37A1.5 1.5 0 1 0 3.38 5.5l1.87 1.86c-.52.8-.9 1.68-1.1 2.63H1.5a1.5 1.5 0 1 0 0 3h2.64c.2.9.58 1.8 1.1 2.6l-1.87 1.9a1.5 1.5 0 1 0 2.12 2.1l1.8-1.9c.8.52 1.7.9 2.62 1.1v2.64a1.5 1.5 0 1 0 3 0V18.8c.95-.2 1.84-.57 2.63-1.1l1.87 1.88a1.5 1.5 0 0 0 2.12 0 1.5 1.5 0 0 0 0-2.1L17.7 15.6c.52-.8.9-1.68 1.1-2.64h2.64a1.5 1.5 0 1 0 0-3"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/preferences/passwordmgr.css b/comm/mail/themes/shared/mail/preferences/passwordmgr.css
new file mode 100644
index 0000000000..7496dc44af
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/passwordmgr.css
@@ -0,0 +1,44 @@
+/* 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/. */
+
+#providerCol {
+ flex: 40 40;
+}
+
+#userCol {
+ flex: 25 25;
+}
+
+#passwordCol {
+ flex: 15 15;
+}
+
+#timeCreatedCol {
+ flex: 10 10;
+}
+
+#timeLastUsedCol {
+ flex: 20 20;
+}
+
+#timePasswordChangedCol {
+ flex: 10 10;
+}
+
+#timesUsedCol {
+ flex: 1;
+}
+
+treechildren::-moz-tree-image(providerCol) {
+ list-style-image: url(chrome://global/skin/icons/defaultFavicon.svg);
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+ margin-inline-end: 5px;
+}
+
+treechildren::-moz-tree-cell-text(passwordCol) {
+ font-family: monospace;
+}
diff --git a/comm/mail/themes/shared/mail/preferences/preferences.css b/comm/mail/themes/shared/mail/preferences/preferences.css
new file mode 100644
index 0000000000..9f19ef5dc1
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/preferences.css
@@ -0,0 +1,1098 @@
+/* - 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 url("chrome://messenger/skin/shared/preferences/subdialog.css");
+@import url("chrome://messenger/skin/colors.css");
+@import url("chrome://messenger/skin/icons.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+body {
+ font-size: 1.1rem;
+}
+
+button,
+menulist,
+html|select,
+html|input[type="color"] {
+ min-height: var(--in-content-button-height);
+}
+
+search-textbox {
+ min-height: var(--in-content-button-height);
+}
+
+button {
+ padding: 0 12px;
+}
+
+menulist {
+ padding: 0 6px;
+}
+
+button[open="true"],
+menulist[open="true"] {
+ color: var(--in-content-button-text-color-hover);
+}
+
+button,
+menulist,
+html|select,
+html|input:is([type="email"],[type="number"],[type="color"],[type="text"],[type="password"],[type="url"]),
+html|textarea,
+search-textbox {
+ border-radius: var(--in-content-button-border-radius);
+}
+
+#MailPreferences {
+ text-rendering: optimizeLegibility;
+}
+
+#prefBox {
+ position: relative;
+}
+
+#pref-category-box {
+ background-color: var(--in-content-categories-background);
+ border-inline-end: 1px solid var(--in-content-categories-border);
+ width: 18em;
+}
+
+/* Temporary styles for the supernova icons */
+.sidebar-footer-icon,
+.category-icon {
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/*
+ The stack has some very tall content but it shouldn't be taller than the
+ viewport (and we know the tall content will still be accessible via scrollbars
+ regardless if the stack has a shorter height). So we use min-height: 0 to allow
+ the stack to be smaller than its content height so it can fit the viewport size.
+*/
+#preferences-stack {
+ min-height: 0;
+}
+
+.main-content {
+ padding: 0;
+ height: 100vh;
+}
+
+/**
+ * The first subcategory title for each category should not have margin-top.
+ */
+
+.subcategory:not([hidden]) ~ .subcategory {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid var(--in-content-border-color);
+ /* Avoid being hidden under the sticky container. This value is the height
+ of that container minus this element's border and padding. */
+ scroll-margin-top: calc(var(--in-content-button-height) + 33px);
+}
+
+fieldset {
+ margin: 0 0 32px;
+ padding: 0;
+ border-style: none !important;
+}
+
+fieldset > hbox,
+fieldset > vbox,
+fieldset > radiogroup {
+ width: -moz-available;
+}
+
+html|h1 {
+ margin: 0 0 8px;
+ font-size: 1.46em;
+ font-weight: 300;
+ line-height: 1.3em;
+}
+
+html|legend {
+ margin: 16px 0 4px;
+ font-size: 1.1em;
+ font-weight: 600;
+ line-height: 1.4em;
+}
+
+html|th {
+ font-weight: normal;
+ text-align: start;
+}
+
+description,
+label {
+ line-height: 1.8em;
+ margin: 0;
+}
+
+button,
+menulist::part(label-box) {
+ font-weight: 400;
+}
+
+menulist::part(icon),
+.abMenuItem > .menu-iconic-left > .menu-iconic-icon {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+menulist::part(label),
+menuitem > label,
+button > hbox > label {
+ line-height: unset;
+}
+
+menulist::part(dropmarker-icon) {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.abMenuItem > .menu-iconic-left {
+ display: block;
+}
+
+#preferencesContainer {
+ padding: 0 28px 40px;
+ overflow: auto;
+}
+
+.paneDeckContainer {
+ display: block;
+ width: min(100%, 800px);
+ min-width: min-content;
+}
+
+.sticky-container {
+ width: 100%;
+ position: sticky;
+ background-color: var(--in-content-page-background);
+ top: 0;
+ z-index: 1;
+ padding-inline: 4px;
+}
+
+#searchInput {
+ /* If these sizes change, update the scroll margin of .subcategory. */
+ min-height: var(--in-content-button-height);
+ margin: 20px 0 30px;
+}
+
+#paneDeck {
+ width: 100%;
+}
+
+.search-tooltip {
+ max-width: 150px;
+ font-size: 1.25rem;
+ position: absolute;
+ padding: 0 10px;
+ background-color: #ffe900;
+ color: #000;
+ border: 1px solid #d7b600;
+ bottom: 36px;
+ opacity: 0.85;
+}
+
+.search-tooltip:hover {
+ opacity: 0.1;
+}
+
+.search-tooltip::before {
+ position: absolute;
+ content: "";
+ border: 7px solid transparent;
+ border-top-color: #d7b600;
+ top: 100%;
+ inset-inline-start: calc(50% - 7px);
+ forced-color-adjust: none;
+}
+
+.search-tooltip::after {
+ position: absolute;
+ content: "";
+ border: 6px solid transparent;
+ border-top-color: #ffe900;
+ top: 100%;
+ inset-inline-start: calc(50% - 6px);
+ forced-color-adjust: none;
+}
+
+@media (forced-colors: active) {
+ .search-tooltip::before {
+ border-top-color: CanvasText;
+ }
+
+ .search-tooltip::after {
+ border-top-color: Canvas;
+ }
+}
+
+.search-tooltip-parent {
+ position: relative;
+}
+
+.search-tooltip > span {
+ user-select: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.tip-caption {
+ font-size: .9em;
+ color: var(--in-content-deemphasized-text);
+}
+
+.visually-hidden {
+ visibility: collapse;
+}
+
+tabpanel > label,
+tabpanel > description,
+tabpanel > hbox > description {
+ margin-inline-start: 0;
+}
+
+/* Web search menulist */
+#defaultWebSearch::part(icon) {
+ margin-inline: 5px 8px;
+ width: 16px;
+ height: 16px;
+}
+
+/* Category List */
+
+#categories {
+ overflow: visible;
+ min-height: auto;
+}
+
+.sidebar-footer-link {
+ margin-inline: 6px;
+ border-radius: var(--in-content-button-border-radius);
+}
+
+@media (max-width: 830px) {
+ #pref-category-box,
+ #categories {
+ width: auto;
+ }
+
+ #categories > .category {
+ padding-inline: 12px;
+ }
+
+ .sidebar-footer-link {
+ margin-inline: 12px;
+ }
+
+ #preferencesContainer {
+ padding-inline: 15px;
+ }
+}
+
+html|input:is([type="email"], [type="tel"], [type="text"], [type="password"], [type="url"]) {
+ padding-block: initial;
+ /* it should be --in-content-button-height but input doesn't include the border */
+ min-height: calc(var(--in-content-button-height) - 2px);
+}
+
+html|input[type="number"] {
+ margin-inline-start: 4px;
+ padding: 1px;
+ min-height: calc(var(--in-content-button-height) - 4px);
+}
+/* sizes: chars + 8px padding + 1px borders + spin buttons 25+2+10px */
+html|input[type="number"].size2 {
+ width: calc(2ch + 55px);
+}
+html|input[type="number"].size3 {
+ width: calc(3ch + 55px);
+}
+html|input[type="number"].size4 {
+ width: calc(4ch + 55px);
+}
+html|input[type="number"].size5 {
+ width: calc(5ch + 55px);
+}
+
+html|input[type="number"]::-moz-number-spin-box {
+ padding-inline-start: 10px;
+}
+
+html|input[type="number"]::-moz-number-spin-up,
+html|input[type="number"]::-moz-number-spin-down {
+ appearance: none;
+ min-width: 25px;
+ border: 1px solid var(--in-content-box-border-color);
+ background-color: var(--in-content-button-background);
+ background-position: center;
+ background-repeat: no-repeat;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+html|input[type="number"]::-moz-number-spin-up:hover,
+html|input[type="number"]::-moz-number-spin-down:hover {
+ background-color: var(--in-content-button-background-hover);
+}
+
+html|input[type="number"]::-moz-number-spin-up {
+ min-height: calc(var(--in-content-button-height) * 0.5 - 3px);
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-radius: 1px 1px 0 0;
+ background-image: url("chrome://messenger/skin/icons/new/nav-up-sm.svg");
+}
+
+html|input[type="number"]::-moz-number-spin-down {
+ min-height: calc(var(--in-content-button-height) * 0.5 - 4px);
+ border-radius: 0 0 1px 1px;
+ background-image: url("chrome://messenger/skin/icons/new/nav-down-sm.svg");
+}
+
+separator.groove:not([orient="vertical"]) {
+ border-top-color: #c1c1c1;
+ border-bottom-width: 0;
+}
+
+tab:not([selected="true"]):hover {
+ border-bottom-color: transparent;
+}
+
+tab:-moz-focusring > .tab-middle {
+ outline: none;
+}
+
+tab:focus-visible {
+ background-color: var(--in-content-button-background);
+}
+
+tab:focus-visible > .tab-middle > .tab-text {
+ outline: none;
+}
+
+#defaultWebSearchPopup > menuitem > .menu-iconic-left {
+ display: flex;
+}
+
+/* Applications Pane Styles */
+
+#filter {
+ margin-inline: 0;
+}
+
+/* XXX This style is for bug 740213 and should be removed once that
+ bug has a solution. */
+description > html|a {
+ cursor: pointer;
+}
+#previewBox {
+ height: 220px;
+}
+
+.indent,
+html|input.indent {
+ /* This should match the checkboxes/radiobuttons' width and inline margin,
+ such that when we have a toggle with a label followed by another element
+ with this class, the other element's text aligns with the toggle label. */
+ margin-inline-start: 22px;
+}
+
+checkbox {
+ margin-inline: 0;
+}
+
+.align-no-label {
+ margin-inline-start: 4px;
+}
+
+.tail-with-learn-more {
+ margin-inline-end: 10px;
+}
+
+.learnMore {
+ margin-inline-start: 0;
+ font-weight: normal;
+ white-space: nowrap;
+}
+
+#tagList {
+ height: 180px;
+}
+
+#keywordList {
+ height: 250px;
+}
+
+#signonsTree {
+ height: 20em;
+}
+
+.update-deck-container {
+ display: flex;
+ align-items: center;
+}
+
+.update-deck-container > * {
+ flex: 0 0 auto;
+}
+
+.update-deck-container.deck-selected {
+ visibility: visible;
+}
+
+.update-deck-container > button {
+ /* Align the button at the end. */
+ margin-inline-start: auto;
+}
+
+.update-throbber {
+ width: 16px;
+ height: 16px;
+ margin-inline: 6px 4px;
+ content: image-set(url("chrome://global/skin/icons/loading.png"),
+ url("chrome://global/skin/icons/loading@2x.png") 2x);
+}
+
+/* Work around bug 560067 - animated images in visibility: hidden
+ * still eat CPU. */
+#updateDeck > *:not(.deck-selected) > .update-throbber {
+ display: none;
+}
+
+.updateSettingCrossUserWarningContainer {
+ background: var(--in-content-box-info-background);
+ border: 1px solid var(--in-content-box-info-border);
+ border-radius: 5px;
+ padding: 2px 8px 8px;
+ margin-block-end: 17px;
+}
+
+#updateSettingCrossUserWarning {
+ padding-inline-start: 30px;
+ margin-block-start: 20px;
+ line-height: 20px;
+ background-image: url("chrome://messenger/skin/icons/info.svg");
+ background-position-x: left 2px;
+ background-position-y: top 2px;
+ background-size: 16px 16px;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#updateSettingCrossUserWarning:-moz-locale-dir(rtl) {
+ background-position-x: right 2px;
+}
+
+#releasenotes {
+ margin-inline-start: 6px !important;
+}
+
+#telemetry-container {
+ border-radius: 4px;
+ background-color: rgba(12, 12, 13, 0.2);
+ font-size: 85%;
+ padding: 3px;
+ margin-block: 4px;
+ width: 100%;
+ display: flex;
+}
+
+#telemetry-container[hidden] {
+ display: none;
+}
+
+#telemetryInfoIcon {
+ flex: 0 0 auto;
+ align-self: start;
+ width: 16px;
+ height: 16px;
+ padding: 2px;
+}
+
+#telemetryDisabledDescription {
+ flex: 1 1 auto;
+ align-self: start;
+ line-height: 1.3;
+ margin-inline-start: 6px;
+}
+
+#telemetryDataDeletionLearnMore {
+ flex: 0 0 auto;
+ align-self: center;
+}
+
+#submitHealthReportBox {
+ display: inline-flex;
+}
+
+/* Remove the start margin to align these elements */
+#addCloudFileAccount,
+#chatStartupAction,
+#defaults-itemtype-menulist,
+#manageCertificatesButton {
+ margin-inline-start: 0;
+}
+
+#dictionaryList {
+ list-style-type: none;
+ padding: 0;
+ margin-block: 0.5em;
+}
+
+#dictionaryList label {
+ display: flex;
+ align-items: center;
+}
+
+/**
+ * Font dialog menulist fixes
+ */
+
+#font-chooser-group {
+ display: grid;
+ grid-template-columns: max-content 1fr max-content max-content;
+}
+
+#fontsTitle {
+ margin-block: 4px;
+}
+
+#defaultFontType,
+#serif,
+#sans-serif,
+#monospace {
+ min-width: 30ch;
+}
+
+/**
+ * toolkit element overrides
+ */
+
+#preferencesContainer richlistbox {
+ appearance: none;
+ background-color: var(--in-content-box-background);
+ color: var(--in-content-text-color);
+}
+
+#preferencesContainer richlistbox richlistitem {
+ padding: 0.3em;
+ margin: 0;
+ border: none;
+ border-radius: 0;
+ background-image: none;
+}
+
+#containerBox richlistbox richlistitem:hover,
+#identitiesList richlistbox richlistitem:hover,
+#preferencesContainer richlistbox richlistitem:hover {
+ background-color: var(--in-content-item-hover);
+}
+
+#containerBox richlistitem[selected="true"],
+#identitiesList richlistitem[selected="true"],
+#preferencesContainer richlistitem[selected="true"] {
+ background-color: var(--in-content-item-selected-unfocused);
+ color: inherit;
+}
+
+#containerBox richlistbox:focus > richlistitem[selected="true"],
+#identitiesList:focus > richlistitem[selected="true"],
+#preferencesContainer richlistbox:focus > richlistitem[selected="true"] {
+ background-color: var(--in-content-item-selected);
+ color: var(--in-content-item-selected-text) !important;
+}
+
+#defaultWebSearch {
+ margin-inline: 0 4px;
+}
+
+#defaultFontSize,
+#directoriesList,
+#localDirectoriesList,
+#defaultStartupDirList {
+ margin-inline-end: 4px;
+}
+
+#messengerLanguagesDialogPane {
+ min-height: 360px;
+}
+
+#primaryMessengerLocale {
+ margin-inline: 0 4px;
+ min-width: 20em;
+}
+
+#availableLocales {
+ margin-inline: 0;
+}
+
+#warning-message {
+ margin-top: 8px;
+}
+
+#warning-message > .message-bar-description {
+ width: 32em;
+}
+
+.action-button {
+ margin-inline-start: 8px;
+}
+
+#tagList,
+#categorieslist {
+ margin-inline-end: 4px;
+}
+
+#new-tag-button,
+#newCButton {
+ margin-top: 0;
+}
+
+/* Menulist styles */
+.label-item {
+ font-size: .8em;
+}
+
+#datePrefsBox {
+ display: grid;
+ grid-template-columns: auto 1fr 1fr;
+}
+
+#PopupAutoComplete > richlistbox {
+ margin: unset;
+ border: unset;
+ border-radius: unset;
+}
+
+/**
+ * Connection dialog
+ */
+
+#proxyExtensionContent:not([hidden]) {
+ display: flex;
+ align-items: center;
+}
+
+#proxyExtensionDescription {
+ margin-block: 0;
+ flex: 1 1 auto;
+}
+
+#proxyExtensionDescription > img {
+ height: 20px;
+ width: 20px;
+ vertical-align: text-bottom;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#proxyExtensionDisable {
+ flex: 0 0 auto;
+}
+
+#proxy-grid,
+#dnsOverHttps-grid {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+}
+
+.proxy-grid-row:not([hidden]),
+.dnsOverHttps-grid-row:not([hidden]) {
+ display: contents;
+}
+
+#proxy-grid > .thin {
+ grid-column: span 2;
+ height: 20px;
+}
+
+calendar-notifications-setting {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 9px;
+}
+
+calendar-notifications-setting .add-button {
+ list-style-image: var(--icon-add);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+calendar-notifications-setting .add-button .button-icon {
+ margin-inline-end: 6px;
+}
+
+calendar-notifications-setting .remove-button {
+ width: 32px;
+ min-width: 0;
+ padding: 0;
+ list-style-image: var(--icon-trash);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ background: none;
+ --in-content-button-horizontal-padding: 0;
+}
+
+/**
+ * End connection dialog
+ */
+
+/**
+ * Sync Settings Page
+ */
+
+#noFxaAccount:not([hidden]) {
+ display: flex;
+ gap: 24px;
+}
+
+#noFxaInfo {
+ display: flex;
+ flex-direction: column;
+ align-self: center;
+ flex: 3;
+}
+
+#noFxaCaption {
+ margin-block: 0 4px;
+}
+
+#noFxaDescription {
+ padding-inline-end: 52px;
+ padding-bottom: 52px;
+}
+
+#noFxaSignIn {
+ margin-inline-start: 8px;
+ max-width: 151px;
+ margin: 4px 0;
+}
+
+#noFxaSyncIllustration {
+ width: 169px;
+ flex-grow: 2;
+}
+
+.sync-account-section:not([hidden]) {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 24px;
+ margin: 24px 0;
+}
+
+#fxaLoginUnverified:not([hidden]),
+#fxaLoginRejected:not([hidden]) {
+ display: flex;
+ align-items: center;
+}
+
+#photoInput {
+ display: contents;
+ place-self: center;
+}
+
+.contact-photo,
+#photoButton {
+ border-radius: 100%;
+ padding: 0;
+ margin: 0;
+}
+
+.contact-photo {
+ width: 78px;
+ height: 78px;
+ object-fit: cover;
+ object-position: center;
+ border: 1px solid var(--in-content-button-background);
+}
+
+.contact-photo[src=""] {
+ background-color: var(--in-content-button-background);
+ background-image: var(--icon-user);
+ background-size: 48px;
+ background-position: calc(50% - 1px) calc(50% - 1px);
+ background-repeat: no-repeat;
+ -moz-context-properties: stroke;
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+#photoButton {
+ position: relative;
+ width: 80px;
+ height: 80px;
+ min-width: 80px;
+ overflow: hidden;
+ background-color: transparent;
+ border: none;
+}
+
+#photoButton:hover {
+ background: none;
+}
+
+#photoOverlay {
+ position: absolute;
+ inset: 0;
+ border-radius: 100%;
+}
+
+#photoButton:focus-visible {
+ outline: 2px solid var(--in-content-focus-outline-color);
+ outline-offset: 2px;
+}
+
+#photoButton:is(:focus-visible, :hover) #photoOverlay {
+ background-color: color-mix(in srgb, var(--color-gray-90) 75%, transparent);
+ background-image: var(--icon-more);
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: 48px;
+ -moz-context-properties: stroke;
+ stroke: var(--color-white);
+ cursor: pointer;
+}
+
+@media (prefers-contrast) {
+ #photoButton:is(:focus-visible, :hover) #photoOverlay {
+ background-color: var(--color-black);
+ }
+}
+
+#fxaAccountInfo {
+ display: flex;
+ flex-direction: column;
+ align-self: center;
+ align-items: flex-start;
+}
+
+#fxaDisplayName {
+ font-weight: bold;
+ font-size: 1.3rem;
+}
+
+#fxaEmailAddress {
+ margin-bottom: 6px
+}
+
+.place-self-start {
+ place-self: start;
+}
+
+.place-self-center {
+ place-self: center;
+}
+
+.place-self-end {
+ place-self: end;
+}
+
+#fxaDeviceInfo {
+ display: flex;
+ gap: 6px;
+ margin: 24px 0;
+}
+
+#fxaDeviceNameInput {
+ flex-grow: 1;
+ align-self: center;
+}
+
+.sync-section {
+ margin-bottom: 24px;
+}
+
+#showSyncedHeader,
+#showSyncedListHeader {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+/*
+* We usually use rem for font size but this header needs to match perfectly
+* the styling of the HTML legend coming from toolkit.
+*/
+.sync-header {
+ margin: 16px 0 4px;
+ font-size: 1.1em;
+ font-weight: 600;
+ line-height: 1.4em;
+}
+
+.sync-panel {
+ display: grid;
+ margin-block-start: 15px;
+ padding: 15px;
+ background-color: var(--in-content-box-info-background);
+ border-radius: 6px;
+}
+
+#syncDisconnected .sync-panel {
+ grid-template-columns: 1fr auto;
+ align-items: center;
+}
+
+#syncDisconnected p {
+ margin: 0;
+}
+
+#showSyncedListHeading {
+ font-size: 1.1rem;
+ font-weight: bold;
+ margin: 0;
+}
+
+.synced-list {
+ display: grid;
+ gap: 24px;
+ padding-inline-start: 12px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.synced-item:not([hidden]) {
+ display: inline-flex;
+ align-items: center;
+}
+
+.synced-item::before,
+.synced-item-label::before {
+ height: 16px;
+ width: 16px;
+ margin-inline-end: 6px;
+}
+
+ #showSyncedList,
+ #configSyncList {
+ grid-template-columns: 1fr 1fr;
+}
+
+/* For when we get per-account controls:
+#showSyncAccount,
+#configAccountsContainer {
+ display: grid;
+ gap: 12px;
+ grid-column: 1 / span 2;
+}
+
+#syncedAccounts,
+#configAccounts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 24px;
+}
+
+#syncedAccounts .synced-list {
+ gap: 9px;
+}
+
+#showSyncAccountLabel::before,*/
+#showSyncAccount::before,
+#configSyncAccountLabel::before {
+ content: var(--icon-mail);
+}
+
+#showSyncAddress::before,
+#configSyncAddressLabel::before {
+ content: var(--icon-address-book);
+}
+
+#showSyncCalendar::before,
+#configSyncCalendarLabel::before {
+ content: var(--icon-calendar);
+}
+
+#showSyncIdentity::before,
+#configSyncIdentityLabel::before {
+ content: var(--icon-id);
+}
+
+#showSyncPasswords::before,
+#configSyncPasswordsLabel::before {
+ content: var(--icon-key);
+}
+
+.synced-account {
+ padding-inline-start: 21px;
+}
+
+.synced-account-name {
+ font-size: 1rem;
+ font-weight: bold;
+ margin: 0;
+ margin-block-end: 9px;
+}
+
+.synced-account-server-config::before {
+ content: var(--icon-globe);
+}
+
+.synced-account-filters::before {
+ content: var(--icon-filter);
+}
+
+.synced-account-keys::before {
+ content: var(--icon-lock);
+}
+
+#configSyncDialogHeading {
+ place-self: center;
+ margin: 0;
+}
+
+#configSyncList {
+ margin-block: 12px 24px;
+ margin-inline: 6px;
+}
+
+#configAccountsContainer {
+ gap: 9px;
+}
+
+.config-list {
+ display: grid;
+ gap: 24px;
+ padding-inline-start: 0;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.config-list li {
+ list-style: none;
+}
+
+.synced-account .config-list {
+ gap: 0;
+ margin-inline-start: 21px;
+}
+
+.config-item {
+ display: inline-flex;
+ align-items: center;
+ line-height: 1;
+}
+
+.config-item label::before {
+ display: inline-block;
+ height: 16px;
+ width: 16px;
+ margin-inline-end: 6px;
+ vertical-align: sub;
+}
diff --git a/comm/mail/themes/shared/mail/preferences/privacy-security.svg b/comm/mail/themes/shared/mail/preferences/privacy-security.svg
new file mode 100644
index 0000000000..e0c2e8e9b9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/privacy-security.svg
@@ -0,0 +1,6 @@
+<!-- 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" viewBox="0 0 24 24">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M8 6a3 3 0 0 1 6 0v4H8V6zm9 4V6A6 6 0 0 0 5 6v4h-.75C3.01 10 2 11 2 12.25v7.52c0 1.24 1 2.25 2.25 2.25h13.5c1.24 0 2.25-1 2.25-2.25v-7.52C20 11 19 10 17.75 10H17z"/>
+</svg>
diff --git a/comm/mail/themes/shared/mail/preferences/subdialog.css b/comm/mail/themes/shared/mail/preferences/subdialog.css
new file mode 100644
index 0000000000..d1e13f24a4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/preferences/subdialog.css
@@ -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/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+
+body > xul|dialog,
+xul|window > :is(xul|dialog, xul|hbox, xul|vbox) {
+ /* This allows the focus ring to display fully when scrolling is enabled. */
+ padding: 0 4px 4px;
+ font-size: 1.1rem;
+ background-color: var(--in-content-page-background) !important;
+}
+
+.dialogOverlay {
+ visibility: hidden;
+}
+
+.dialogOverlay[topmost="true"] {
+ background-color: rgba(0,0,0,0.5);
+}
+
+.dialogBox {
+ appearance: none;
+ background-color: var(--in-content-page-background);
+ color: var(--in-content-page-color);
+ /* `transparent` will use the dialogText color in high-contrast themes and
+ when page colors are disabled */
+ border: 1px solid transparent;
+ border-radius: var(--arrowpanel-border-radius);
+ box-shadow: 0 2px 4px 0 rgba(0,0,0,0.5);
+ display: flex;
+ margin: 0;
+ padding: 0;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root[dialogroot],
+ body > xul|dialog,
+ .dialogBox {
+ --in-content-page-background: #2a2a2e !important;
+ }
+}
+
+.dialogBox[resizable="true"] {
+ resize: both;
+ overflow: hidden;
+ min-height: 20em;
+ min-width: 66ch;
+}
+
+.dialogTitleBar {
+ padding-block: 6px 0;
+}
+
+.dialogTitle {
+ line-height: 26px;
+ font-weight: 600;
+ text-align: center;
+}
+
+.close-icon {
+ display: none;
+}
+
+.dialogFrame {
+ margin: 10px;
+ flex: 1;
+ /* Default dialog dimensions */
+ height: 12em;
+ min-width: 40ch;
+}
+
+.largeDialogContainer.doScroll {
+ overflow-y: auto;
+ flex: 1;
+}
diff --git a/comm/mail/themes/shared/mail/primaryToolbar.css b/comm/mail/themes/shared/mail/primaryToolbar.css
new file mode 100644
index 0000000000..f5a384de2a
--- /dev/null
+++ b/comm/mail/themes/shared/mail/primaryToolbar.css
@@ -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/. */
+
+/* ::::: Mail Toolbars and Menubars ::::: */
+
+.mail-toolbox,
+.contentTabToolbox {
+ appearance: none;
+ background-color: var(--toolbar-bgcolor);
+}
+
+.mail-toolbox:-moz-lwtheme,
+.contentTabToolbox:-moz-lwtheme {
+ color: var(--toolbar-color, inherit);
+ background-color: var(--lwt-accent-color);
+ background-image: linear-gradient(var(--lwt-selected-tab-background-color, transparent),
+ var(--lwt-selected-tab-background-color, transparent)),
+ linear-gradient(var(--toolbar-bgcolor), var(--toolbar-bgcolor)),
+ var(--lwt-header-image, none);
+ background-position: right top, var(--lwt-background-alignment);
+}
+
+.mail-toolbox > toolbar {
+ /* force iconsize="small" on these toolbars */
+ counter-reset: smallicons;
+}
+
+#button-getmsg {
+ list-style-image: var(--icon-cloud-download);
+}
+
+#button-newmsg {
+ list-style-image: var(--icon-pencil);
+}
+
+#button-address {
+ list-style-image: var(--icon-address-book);
+}
+
+#button-reply {
+ list-style-image: var(--icon-reply);
+}
+
+#button-replyall {
+ list-style-image: var(--icon-reply-all);
+}
+
+#button-replylist {
+ list-style-image: var(--icon-reply-list);
+}
+
+#button-forward {
+ list-style-image: var(--icon-forward);
+}
+
+#button-redirect {
+ list-style-image: var(--icon-redirect);
+}
+
+.delete-button {
+ list-style-image: var(--icon-trash);
+}
+
+.junk-button {
+ list-style-image: var(--icon-spam);
+}
+
+#button-print {
+ list-style-image: var(--icon-print);
+}
+
+#button-stop {
+ list-style-image: var(--icon-close);
+}
+
+#button-file {
+ list-style-image: var(--icon-file);
+}
+
+#button-nextUnread {
+ list-style-image: var(--icon-nav-down-unread);
+}
+
+#button-previousUnread {
+ list-style-image: var(--icon-nav-up-unread);
+}
+
+#button-mark {
+ list-style-image: var(--icon-unread);
+}
+
+#button-tag {
+ list-style-image: var(--icon-tag);
+}
+
+#button-goback {
+ list-style-image: var(--icon-nav-back);
+}
+
+#button-goforward {
+ list-style-image: var(--icon-nav-forward);
+}
+
+#button-compact {
+ list-style-image: var(--icon-compress);
+}
+
+#button-archive {
+ list-style-image: var(--icon-archive);
+}
+
+#button-chat {
+ -moz-context-properties: fill, stroke, fill-opacity;
+}
+
+#button-nextMsg {
+ list-style-image: var(--icon-nav-down);
+}
+
+#button-previousMsg {
+ list-style-image: var(--icon-nav-up);
+}
+
+#qfb-show-filter-bar {
+ list-style-image: var(--icon-filter);
+}
+
+#button-showconversation {
+ list-style-image: var(--icon-conversation);
+}
+
+#button-addons {
+ list-style-image: var(--icon-extension);
+}
+
+.button-appmenu {
+ list-style-image: var(--icon-app-menu);
+ min-width: 35px !important;
+ margin-inline: 4px;
+}
+
+.button-appmenu[badge-status] {
+ list-style-image: var(--icon-app-menu-badged);
+}
+
+#button-chat[unreadMessages="true"] {
+ fill: color-mix(in srgb, var(--toolbarbutton-icon-fill-attention) 20%, transparent);
+ stroke: var(--toolbarbutton-icon-fill-attention);
+}
+
+toolbar[mode="text"] > #button-chat[unreadMessages="true"] {
+ color: var(--toolbarbutton-icon-fill-attention);
+}
+
+#button-newMsgPopup .menuitem-iconic {
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+/* remove the small icons checkbox in Customize window */
+#smallicons {
+ display: none;
+}
+
+.button-appmenu .toolbarbutton-text {
+ display: none;
+}
+
+toolbar[mode="text"] .button-appmenu .toolbarbutton-icon {
+ display: flex;
+}
+
+#folder-location-container {
+ flex: 2 2;
+}
+
+/* Force the folder location and mail view items to fit in the available width
+ in the Customize Toolbar dialog. */
+#palette-box #locationFolders,
+#palette-box #folder-location-container,
+#palette-box #folderpane-mode-selector,
+#palette-box #viewPicker {
+ flex: 1;
+}
+
+#palette-box #button-chat {
+ background: var(--icon-chat) no-repeat center;
+}
+
+/* Hide the toolbarbutton-icon when the button is removed from the toolbar */
+#palette-box #button-chat > stack > .toolbarbutton-icon {
+ display: none;
+}
+
+/* ::::: message notification bar style rules ::::: */
+
+.msgNotificationBarText {
+ font-weight: bold;
+ margin-bottom: 0;
+}
+
+.msgNotificaton-smallText {
+ padding-inline-start: 10px;
+ font-size: 90%;
+}
+
+.subviewbutton-iconic {
+ -moz-context-properties: fill, stroke, fill-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+#button-getAllNewMsg,
+#menu_getnewmsgs_all_accounts {
+ list-style-image: var(--icon-download);
+}
+
+#spacesButtonMail {
+ list-style-image: var(--icon-mail);
+}
+
+#spacesButtonAddressBook {
+ list-style-image: var(--icon-address-book);
+}
+
+#spacesButtonCalendar {
+ list-style-image: var(--icon-calendar);
+}
+
+#spacesButtonTasks {
+ list-style-image: var(--icon-tasks);
+}
+
+#spacesButtonChat {
+ list-style-image: var(--icon-chat);
+}
diff --git a/comm/mail/themes/shared/mail/profileDowngrade.css b/comm/mail/themes/shared/mail/profileDowngrade.css
new file mode 100644
index 0000000000..9e47b272b3
--- /dev/null
+++ b/comm/mail/themes/shared/mail/profileDowngrade.css
@@ -0,0 +1,18 @@
+/* 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/. */
+
+body {
+ width: 600px;
+}
+
+#contentWrapper {
+ display: flex;
+}
+
+#contentWrapper img {
+ width: 48px;
+ height: 48px;
+ margin: 1em;
+ margin-inline-start: 0;
+}
diff --git a/comm/mail/themes/shared/mail/quickFilterBar.css b/comm/mail/themes/shared/mail/quickFilterBar.css
new file mode 100644
index 0000000000..ccdbdca2a7
--- /dev/null
+++ b/comm/mail/themes/shared/mail/quickFilterBar.css
@@ -0,0 +1,197 @@
+/* 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/. */
+
+:root {
+ --qfb-padding: 3px;
+}
+
+:root[uidensity="compact"] {
+ --qfb-padding: 0 3px;
+}
+
+#quick-filter-bar {
+ --button-margin: 3px;
+ background-color: var(--layout-background-1);
+ padding: var(--qfb-padding);
+ border-bottom: 1px solid
+ var(--sidebar-border-color, var(--tree-view-header-border-color));
+}
+
+:root[lwt-tree] #quick-filter-bar:-moz-lwtheme {
+ background-color: color-mix(in srgb, var(--toolbar-bgcolor) 50%, transparent);
+ color: var(--lwt-text-color);
+}
+
+#qfb-sticky {
+ background-image: var(--icon-pin);
+ height: auto;
+}
+
+#qfb-unread {
+ background-image: var(--icon-unread);
+}
+
+#qfb-starred {
+ background-image: var(--icon-star);
+}
+
+#qfb-inaddrbook {
+ background-image: var(--icon-address-book);
+}
+
+#qfb-tags {
+ background-image: var(--icon-tag);
+}
+
+#qfb-attachment {
+ background-image: var(--icon-attachment);
+}
+
+#qfd-dropdown {
+ background-image: var(--icon-filter);
+ display: none;
+}
+
+#qfb-results-label {
+ margin: 3px;
+ text-align: end;
+ align-self: center;
+}
+
+#qfb-qs-textbox {
+ flex: 1;
+ height: unset;
+ margin: 3px;
+ padding-block: 3px;
+ max-width: 450px;
+}
+
+@media (-moz-platform: windows-win7) {
+ #qfb-qs-textbox {
+ margin-block: 4px;
+ }
+}
+
+@container threadPane (max-width: 499px) {
+ #qfb-qs-textbox {
+ min-width: 200px;
+ }
+
+ #qfd-dropdown {
+ display: inline-block;
+ min-width: 22px;
+ }
+
+ .button-group.quickFilterButtons{
+ display: none;
+ }
+}
+
+#quickFilterBarTagsContainer:not([hidden]) {
+ display: flex;
+ align-items: center;
+ margin-inline-start: -3px;
+ flex-wrap: wrap;
+}
+
+#qfb-boolean-mode {
+ line-height: unset;
+ min-height: 0;
+ margin: 3px;
+ padding-block: 3px;
+}
+
+.qfb-tag-button {
+ --tag-color: currentColor;
+ --tag-contrast-color: currentColor;
+ --button-padding: 3px;
+ --button-margin: 3px;
+ background-color: transparent;
+ color: var(--tag-color);
+ border-color: var(--tag-color);
+ border-radius: 100px;
+ padding-inline: 9px;
+ min-height: 0;
+ min-width: 0;
+ line-height: 1;
+}
+
+.qfb-tag-button:enabled:hover:not([aria-pressed="true"]) {
+ color: var(--tag-color);
+ background-color: color-mix(in srgb, var(--tag-color) 20%, transparent);
+ border-color: var(--tag-color);
+}
+
+.qfb-tag-button[aria-pressed="true"]:enabled:hover {
+ color: var(--tag-contrast-color);
+ background-color: color-mix(in srgb, var(--tag-color) 70%, white);
+ border-color: color-mix(in srgb, var(--tag-color) 60%, black);
+}
+
+.qfb-tag-button[aria-pressed="true"] {
+ --tag-color: currentColor;
+ color: var(--tag-contrast-color);
+ background-color: var(--tag-color);
+ border-color: color-mix(in srgb, var(--tag-color) 60%, black);
+ border-radius: 100px;
+ box-shadow: none;
+}
+
+.qfb-tag-button:enabled:hover:active {
+ background-color: color-mix(in srgb, var(--tag-color) 80%, black);
+ border-color: color-mix(in srgb, var(--tag-color) 60%, black);
+}
+
+.qfb-tag-button[inverted] {
+ background-color: transparent;
+ color: var(--tag-color);
+ border-color: var(--tag-color);
+ text-decoration: line-through;
+}
+
+.qfb-tag-button[inverted]:enabled:hover {
+ color: var(--tag-color);
+ background-color: color-mix(in srgb, var(--tag-color) 20%, transparent);
+ border-color: var(--tag-color);
+}
+
+#quickFilterBarContainer {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+#quickFilterBarSecondFilters {
+ display: flex;
+ align-items: center;
+ padding-inline-start: var(--button-margin);
+ flex-wrap: wrap;
+ column-gap: 12px;
+}
+
+#quick-filter-bar-filter-text-bar:not([hidden]) {
+ --button-padding: 3px;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+#quick-filter-bar-filter-text-bar > .button-group > .button {
+ min-width: 0;
+}
+
+#qfb-upsell-line-one {
+ font-weight: bold;
+}
+
+/*#threadTree[filterActive] {
+ background-repeat: no-repeat;
+ background-position: center;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: .3;
+}
+
+#threadTree[filterActive="searching"] {
+ background-image: url("chrome://messenger/skin/icons/search-spinner.svg");
+}*/
diff --git a/comm/mail/themes/shared/mail/sanitizeDialog.css b/comm/mail/themes/shared/mail/sanitizeDialog.css
new file mode 100644
index 0000000000..70e9014501
--- /dev/null
+++ b/comm/mail/themes/shared/mail/sanitizeDialog.css
@@ -0,0 +1,49 @@
+/* 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/. */
+
+/* Hide the duration dropdown suffix label if it's empty. Otherwise it
+ takes up a little space, causing the end of the dropdown to not be aligned
+ with the warning box. */
+#sanitizeDurationSuffixLabel[value=""] {
+ display: none;
+}
+
+/* Sanitize everything warnings */
+
+#sanitizeEverythingWarning,
+#sanitizeEverythingUndoWarning {
+ white-space: pre-wrap;
+}
+
+#sanitizeEverythingWarningBox {
+ background-color: hsla(0, 0%, 50%, 0.2);
+ border: 1px solid hsl(0, 0%, 50%);
+ border-radius: 4px;
+ margin-top: 8px;
+ margin-inline: 4px;
+ padding: 16px;
+}
+
+#sanitizeEverythingWarningIcon {
+ list-style-image: var(--icon-warning-dialog);
+ width: 48px;
+ height: 48px;
+}
+
+#sanitizeEverythingWarningDescBox {
+ padding: 0 16px;
+ margin: 0;
+}
+
+#historyGroupLabel {
+ margin-block: 16px 4px;
+ margin-inline-start: 4px;
+ font-size: 1.14em;
+ font-weight: 600;
+}
+
+checkbox {
+ margin-block: 4px;
+ margin-inline-start: 10px;
+}
diff --git a/comm/mail/themes/shared/mail/search-bar.css b/comm/mail/themes/shared/mail/search-bar.css
new file mode 100644
index 0000000000..001be77aa0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/search-bar.css
@@ -0,0 +1,89 @@
+/* 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/. */
+
+/* Primary CSS file inside the search-bar shadowRoot */
+
+@import url("chrome://messenger/skin/widgets.css");
+
+form {
+ --padding-block: 6px;
+ --search-icon-clearance: 30px;
+ position: relative;
+ min-height: max(1.2em, calc(1.2em + 2 * var(--padding-block)));
+ height: 100%;
+ max-height: 2em;
+}
+
+input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ font-size: 1rem;
+ border: 1px solid var(--color-gray-40);
+ border-radius: var(--button-border-radius);
+ padding-inline: var(--padding-block) var(--search-icon-clearance);
+ background-color: var(--layout-background-0);
+}
+
+input:-moz-lwtheme {
+ color: var(--toolbar-field-color);
+ background-color: var(--toolbar-field-background-color);
+ border-color: var(--toolbar-field-border-color);
+}
+
+input:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: -1px;
+}
+
+@media (-moz-windows-accent-color-in-titlebar) {
+ input:not(:-moz-lwtheme):focus-visible {
+ outline-offset: var(--focus-outline-offset);
+ outline-color: var(--windows-accent-outline-color);
+ }
+}
+
+input:-moz-lwtheme:focus-visible {
+ color: var(--toolbar-field-focus-color);
+ background-color: var(--toolbar-field-focus-background-color);
+}
+
+.button {
+ position: absolute;
+ inset-inline-end: 0;
+ inset-block: 0;
+ color: inherit;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-around;
+}
+
+.button.button-flat.icon-button {
+ padding: 4px;
+ margin: 0;
+}
+
+.button.button-flat.icon-button:focus-visible {
+ outline-offset: -1px;
+}
+
+div {
+ display: none;
+ position: absolute;
+ pointer-events: none;
+ color: var(--search-field-placeholder);
+ inset-inline-start: 1ch;
+ inset-inline-end: var(--search-icon-clearance);
+ flex-direction: column;
+ justify-content: space-around;
+ text-overflow: clip;
+ overflow: hidden;
+ white-space: nowrap;
+ inset-block: 0;
+}
+
+input:placeholder-shown + div {
+ display: flex;
+}
diff --git a/comm/mail/themes/shared/mail/searchBox.css b/comm/mail/themes/shared/mail/searchBox.css
new file mode 100644
index 0000000000..1b32694fc8
--- /dev/null
+++ b/comm/mail/themes/shared/mail/searchBox.css
@@ -0,0 +1,129 @@
+/* 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/. */
+
+.gloda-search {
+ padding-inline-start: 21px !important;
+ flex: 1;
+}
+
+.search-icon {
+ margin-inline-end: -21px;
+ height: 16px;
+ width: 16px;
+ z-index: 2;
+}
+
+.search-icon:-moz-locale-dir(rtl) {
+ transform: scaleX(-1);
+}
+
+.searchBox,
+.themeableSearchBox {
+ appearance: none;
+ color: FieldText;
+ background-color: Field;
+ border: 1px solid color-mix(in srgb, currentColor 50%, transparent);
+ border-radius: var(--button-border-radius);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+ outline: none;
+ min-height: 24px;
+ height: 1.2em;
+}
+
+.textbox-search-clear {
+ opacity: 0.8;
+}
+
+.textbox-search-clear:not([disabled]):hover {
+ opacity: 1;
+}
+
+.themeableSearchBox[disabled] {
+ border-color: hsla(240, 5%, 5%, 0.1) !important;
+}
+
+.searchBox:hover,
+.themeableSearchBox:not([disabled]):hover {
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
+}
+
+@media (prefers-color-scheme: dark) {
+ .searchBox:hover {
+ box-shadow: 0 1px 3px rgba(255, 255, 255, 0.25);
+ }
+}
+
+/* special treatment because these boxes are on themable toolbars */
+.gloda-search:-moz-lwtheme,
+.remote-gloda-search:-moz-lwtheme,
+:root[lwt-tree] .themeableSearchBox,
+:root[lwt-tree] #peopleSearchInput:not([focused="true"]) {
+ color: var(--toolbar-field-color);
+ background-color: var(--toolbar-field-background-color);
+ border-color: var(--toolbar-field-border-color);
+}
+
+.gloda-search:focus,
+.searchBox[focused="true"],
+.themeableSearchBox:not([disabled="true"]):focus,
+.themeableSearchBox:not([disabled="true"])[focused="true"] {
+ border-color: var(--toolbar-field-focus-border-color);
+ outline: 1px solid var(--toolbar-field-focus-border-color);
+}
+
+.gloda-search:-moz-lwtheme:focus,
+:root[lwt-tree] .themeableSearchBox:focus,
+:root[lwt-tree] .themeableSearchBox[focused="true"],
+:root[lwt-tree] #peopleSearchInput[focused="true"],
+.remote-gloda-search:-moz-lwtheme[focused="true"] {
+ color: var(--toolbar-field-focus-color);
+ background-color: var(--toolbar-field-focus-background-color);
+}
+
+.gloda-search:-moz-lwtheme::selection,
+.themeableSearchBox:-moz-lwtheme::selection {
+ background-color: var(--lwt-toolbar-field-highlight, Highlight);
+ color: var(--lwt-toolbar-field-highlight-text, HighlightText);
+}
+
+.gloda-search:not(:focus)::selection,
+.themeableSearchBox:not(:focus)::selection,
+.themeableSearchBox:not([focused="true"])::selection {
+ background-color: var(--lwt-toolbar-field-highlight, text-select-disabled-background);
+}
+
+#PopupGlodaAutocomplete > .autocomplete-richlistbox {
+ padding: 0;
+ color: inherit;
+ background-color: inherit;
+}
+
+#PopupGlodaAutocomplete .ac-url {
+ display: flex;
+ margin-bottom: 2px;
+}
+
+.ac-url:not([selected=true]) > .ac-url-text {
+ color: var(--autocomplete-popup-url-color);
+}
+
+.autocomplete-richlistitem[type^="gloda-"] {
+ margin-inline: 2px;
+ padding-inline-start: 12px;
+ border-radius: 2px;
+}
+
+.autocomplete-richlistitem[type^="gloda-"]:hover {
+ background-color: hsla(0, 0%, 80%, 0.3);
+}
+
+.autocomplete-richlistitem[type^="gloda-"][selected] {
+ background: var(--autocomplete-popup-highlight-background);
+ color: var(--autocomplete-popup-highlight-color);
+}
+
+.remote-gloda-search-container {
+ min-width: 10em;
+ align-items: center;
+}
diff --git a/comm/mail/themes/shared/mail/searchDialog.css b/comm/mail/themes/shared/mail/searchDialog.css
new file mode 100644
index 0000000000..25bfa1a12c
--- /dev/null
+++ b/comm/mail/themes/shared/mail/searchDialog.css
@@ -0,0 +1,126 @@
+/* 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 url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/messageIcons.css");
+
+#threadTree {
+ flex: 1;
+}
+
+#searchTerms {
+ flex: 1 auto;
+ min-height: 12em;
+ margin-top: 8px;
+}
+
+#searchTermListBox > #booleanAndGroup,
+#searchTermListBox > #searchTermBox,
+#searchTerms > vbox > hbox {
+ padding-left: 6px;
+ padding-right: 6px;
+}
+
+#searchTermBox {
+ min-height: 0;
+ padding-top: 2px;
+ padding-bottom: 2px;
+}
+
+#searchResults {
+ flex: 1 auto;
+ min-height: 8em;
+ padding: 0 6px 6px;
+ text-shadow: none;
+}
+
+#searchableFolders {
+ flex: 2 2;
+}
+
+#searchResultListBox {
+ margin: 4px;
+ min-height: 50px;
+}
+
+.search-value-menulist {
+ flex: 1;
+}
+
+.search-value-input {
+ width: -moz-available;
+}
+
+.search-menulist[unavailable="true"] {
+ color: GrayText;
+}
+
+radio[value="and"] {
+ margin-inline-end: 18px;
+}
+
+/* ::::: box sizes ::::: */
+
+#virtualFolderSearchTerms {
+ flex: 1 1 0;
+ overflow: hidden;
+}
+
+#virtualFolderSearchTermListBoxWrapper {
+ flex: 1;
+ min-height: 0;
+}
+
+#virtualFolderSearchTermListBox {
+ flex: 1;
+}
+
+#searchTermTree {
+ min-height: 50px;
+}
+
+/* ::::: thread decoration ::::: */
+
+treechildren::-moz-tree-cell-text(read) {
+ font-weight: normal;
+}
+
+treechildren::-moz-tree-cell-text(unread) {
+ font-weight: bold;
+}
+
+/* on a collapsed thread, if the top level message is read, but the thread has
+ * unread children, underline the text. 4.x mac did this, very slick
+ */
+treechildren::-moz-tree-cell-text(container, closed, hasUnread, read) {
+ text-decoration: underline;
+}
+
+#gray_horizontal_splitter {
+ min-height: 4px;
+ background-color: -moz-Dialog;
+ border-top: 1px solid ThreeDHighlight;
+ border-bottom: 1px solid ThreeDShadow;
+}
+
+/* ::::: theme support ::::: */
+
+:root:not([lwt-tree]) #searchTermBox:-moz-lwtheme,
+:root:not([lwt-tree]) #searchResults:-moz-lwtheme {
+ background-color: -moz-Dialog;
+}
+
+:root[lwt-tree] #searchTermBox,
+:root[lwt-tree] #searchResults {
+ background-color: var(--lwt-accent-color-inactive, var(--lwt-accent-color));
+}
+
+:root:not(:-moz-lwtheme) #searchTermList:focus > richlistitem[selected="true"] {
+ color: inherit;
+}
+
+:root:-moz-lwtheme #gray_horizontal_splitter {
+ background-color: var(--toolbar-bgcolor);
+ border-color: hsla(0, 0%, 50%, .3);
+}
diff --git a/comm/mail/themes/shared/mail/spacesToolbar.css b/comm/mail/themes/shared/mail/spacesToolbar.css
new file mode 100644
index 0000000000..f9ad1cadd6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/spacesToolbar.css
@@ -0,0 +1,371 @@
+/* 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/. */
+
+/* Colors variables */
+
+:root {
+ --spaces-bg-color: #e8e8e8;
+ --spaces-bg-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.05) 100%);
+ --spaces-button-text-color: currentColor;
+ --spaces-button-active-text-color: var(--selected-item-text-color);
+ --spaces-button-active-bg-color: var(--selected-item-color);
+ --spaces-button-badge-text-color: var(--color-white);
+ --spaces-button-badge-bg-color: var(--color-red-70);
+ --spaces-button-opacity: 1;
+ --spaces-border-color: color-mix(in srgb, var(--spaces-bg-color) 85%, black);
+}
+
+:root:-moz-window-inactive {
+ --spaces-button-opacity: 0.7;
+ --spaces-bg-image: none;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --spaces-bg-color: #252525;
+ }
+}
+
+@media (prefers-contrast) {
+ :root:not(:-moz-lwtheme) {
+ --spaces-bg-color: -moz-Dialog;
+ --spaces-bg-image: none;
+ --spaces-border-color: var(--splitter-color);
+ }
+}
+
+/* Density variations */
+
+:root {
+ --messenger-body-border: 1px;
+ --spaces-icon-size: 20px;
+ --spaces-padding: 7px;
+ --spaces-gap: 15px;
+ --spaces-container-gap: 15px;
+ --spaces-button-padding: 5px;
+ --spaces-pinned-button-margin: 5px;
+ --spaces-button-badge-padding: 1px 6px;
+ --spaces-button-badge-font: 0.9rem;
+ --spaces-menuitem-current-indicator-size: 3px;
+ --spaces-total-width: calc(2 * var(--spaces-padding) + var(--spaces-icon-size) + 2 * var(--spaces-button-padding) + var(--messenger-body-border));
+}
+
+:root[uidensity="compact"] {
+ --spaces-icon-size: 16px;
+ --spaces-padding: 6px;
+ --spaces-gap: 9px;
+ --spaces-container-gap: 9px;
+ --spaces-button-padding: 4px;
+ --spaces-pinned-button-margin: 4px;
+ --spaces-button-badge-padding: 1px 4px;
+ --spaces-button-badge-font: 0.85rem;
+ --spaces-menuitem-current-indicator-size: 3px;
+}
+
+:root[uidensity="touch"] {
+ --spaces-icon-size: 24px;
+ --spaces-padding: 9px;
+ --spaces-gap: 18px;
+ --spaces-container-gap: 18px;
+ --spaces-button-padding: 6px;
+ --spaces-pinned-button-margin: 6px;
+ --spaces-button-badge-padding: 3px 8px;
+ --spaces-button-badge-font: 1rem;
+ --spaces-menuitem-current-indicator-size: 4px;
+}
+
+/* DPI variations */
+
+@media (min-resolution: 1.45dppx) and (max-resolution: 1.95dppx) {
+ :root {
+ --messenger-body-border: 0px; /* Keep the px unit. */
+ }
+}
+
+/* The spaces toolbar is using fixed positioning instead of being the left most
+ * element in the window, because the toolbox has to touch both sides of the
+ * window to properly draw client-side window decorations on GTK. To account for
+ * the spaces toolbar having fixed positioning, the window contents are moved to
+ * the right by its width. */
+
+.spaces-toolbar:not([hidden]) {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: var(--spaces-gap);
+ background-color: var(--spaces-bg-color);
+ background-image: var(--spaces-bg-image);
+ border-inline-end: 1px solid var(--spaces-border-color);
+ position: fixed;
+ inset-inline-start: 0;
+ inset-block: 0;
+ padding: var(--spaces-padding);
+ z-index: 2;
+}
+
+.spaces-toolbar:not([hidden]) ~ #messengerBody,
+:root:not(.customizingUnifiedToolbar) .spaces-toolbar:not([hidden]) ~ #navigation-toolbox-background #titlebar {
+ /* Move the window content in by the width of the spaces toolbar */
+ margin-inline-start: var(--spaces-total-width);
+}
+
+:root:not(.customizingUnifiedToolbar) .spaces-toolbar:not([hidden]) ~ #navigation-toolbox-background #unifiedToolbarContainer {
+ width: calc(100vw - var(--spaces-total-width));
+}
+
+.spaces-toolbar-container {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ gap: var(--spaces-container-gap);
+}
+
+.spaces-toolbar-button {
+ position: relative;
+ height: auto;
+ min-width: auto;
+ margin: 0;
+ padding: var(--spaces-button-padding);
+ line-height: 0; /* We never show text in these buttons */
+ border: none;
+ background-color: transparent;
+ opacity: var(--spaces-button-opacity);
+ color: var(--spaces-button-text-color);
+ outline: none;
+}
+
+.spaces-toolbar-button:focus-visible:not(:hover) {
+ outline: 2px solid var(--selected-item-color);
+}
+
+.spaces-toolbar-button.current:focus-visible:not(:hover) {
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .spaces-toolbar {
+ transition: background-color 280ms ease;
+ }
+
+ .spaces-toolbar-button {
+ transition: background-color 280ms ease, opacity 280ms ease, color 280ms;
+ }
+
+ .spaces-toolbar-button > img {
+ transition: fill 280ms ease, stroke 280ms ease, opacity 280ms ease;
+ }
+
+ .spaces-toolbar-pinned-button {
+ transition: background-color 280ms ease, box-shadow 280ms ease;
+ }
+}
+
+.spaces-toolbar-button.current,
+.spaces-toolbar-button.current:hover,
+#spacesAccentPlaceholder {
+ background-color: var(--spaces-button-active-bg-color);
+ color: var(--spaces-button-active-text-color);
+}
+
+.spaces-toolbar-button:hover:not(.current) {
+ background-color: color-mix(in srgb, currentColor 10%, transparent);
+}
+
+@media (prefers-contrast) {
+ .spaces-toolbar-button:hover:not(.current) {
+ background-color: var(--spaces-button-active-bg-color);
+ color: var(--spaces-button-active-text-color);
+ }
+}
+
+.spaces-popup-menuitem > .menu-iconic-left {
+ position: relative;
+}
+
+.spaces-popup-menuitem.current > .menu-iconic-left:before {
+ content: '';
+ display: block;
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: calc(var(--spaces-menuitem-current-indicator-size) * -1.75);
+ width: var(--spaces-menuitem-current-indicator-size);
+ border-radius: 2px;
+ background-color: var(--spaces-button-active-bg-color);
+}
+
+.spaces-toolbar-button:is(:not([disabled])):hover:active {
+ background-color: var(--spaces-button-active-bg-color);
+ color: var(--spaces-button-active-text-color);
+}
+
+.spaces-toolbar-button > img,
+.spaces-toolbar-statusbar-button > img,
+.spaces-toolbar-pinned-button > img,
+.spaces-popup-menuitem image {
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.spaces-toolbar-button > img {
+ width: var(--spaces-icon-size);
+ height: var(--spaces-icon-size);
+}
+
+.spaces-toolbar-statusbar-button {
+ min-width: auto;
+ min-height: auto;
+ padding-inline: 2px;
+ margin-inline: 3px 0 !important;
+ line-height: 0;
+ background: none;
+ color: inherit;
+}
+
+.spaces-toolbar-statusbar-button:is(:not([disabled])):hover {
+ background: none;
+ border: none;
+}
+
+#spacesPinnedButton {
+ margin-inline-start: var(--spaces-pinned-button-margin);
+ list-style-image: var(--icon-spaces-menu);
+ min-width: auto;
+}
+
+#spacesPinnedButton .toolbarbutton-text,
+#spacesPinnedButton[type="menu"] > .toolbarbutton-menu-dropmarker {
+ display: none;
+}
+
+#mailButton img {
+ content: var(--spaces-icon-mail);
+}
+#spacesPopupButtonMail {
+ list-style-image: var(--icon-mail);
+}
+
+#addressBookButton img {
+ content: var(--spaces-icon-address-book);
+}
+#spacesPopupButtonAddressBook {
+ list-style-image: var(--icon-address-book);
+}
+
+#calendarButton img {
+ content: var(--spaces-icon-calendar);
+}
+#spacesPopupButtonCalendar {
+ list-style-image: var(--icon-calendar);
+}
+
+#tasksButton img {
+ content: var(--spaces-icon-tasks);
+}
+#spacesPopupButtonTasks {
+ list-style-image: var(--icon-tasks);
+}
+
+#chatButton img {
+ content: var(--spaces-icon-chat);
+}
+#spacesPopupButtonChat {
+ list-style-image: var(--icon-chat);
+}
+
+#settingsButton img {
+ content: var(--spaces-icon-settings);
+}
+#spacesPopupButtonSettings {
+ list-style-image: var(--icon-settings);
+}
+
+#collapseButton img {
+ content: var(--spaces-icon-collapse);
+}
+#spacesPopupButtonReveal {
+ list-style-image: var(--icon-collapse);
+}
+
+#spacesToolbarAddonsOverflowButton img {
+ content: var(--spaces-icon-overflow);
+}
+
+#spacesToolbarReveal img {
+ height: 16px;
+ width: 16px;
+}
+
+#chatButton > img,
+#spacesPopupButtonChat image {
+ stroke-opacity: 0;
+}
+
+#collapseButton:-moz-locale-dir(rtl) img,
+#spacesToolbarReveal img,
+#spacesPopupButtonReveal image {
+ transform: scaleX(-1);
+}
+
+#spacesToolbarReveal:-moz-locale-dir(rtl) img,
+#spacesPopupButtonReveal:-moz-locale-dir(rtl) image {
+ transform: none;
+}
+
+/* Add-ons section */
+
+#spacesToolbarAddonsContainer {
+ flex: 1 1 0;
+ min-height: 0;
+}
+
+.spaces-badge-container {
+ display: none;
+ position: absolute;
+ inset-inline-end: -3px;
+ inset-block-start: -2px;
+ padding: var(--spaces-button-badge-padding);
+ font-weight: 600;
+ font-size: var(--spaces-button-badge-font);
+ border-radius: 12px;
+ background-color: var(--spaces-button-badge-bg-color);
+ color: var(--spaces-button-badge-text-color);
+ line-height: 1em;
+ z-index: 1;
+}
+
+.spaces-badge-container:-moz-window-inactive {
+ background-color: color-mix(in srgb, var(--spaces-button-badge-bg-color) 50%, black);
+}
+
+.has-badge .spaces-badge-container {
+ display: block;
+}
+
+.has-badge:not([open="true"]) > .toolbarbutton-badge-stack > .toolbarbutton-badge {
+ display: block;
+ box-shadow: none;
+ box-sizing: content-box;
+ margin: -2px -3px 0 !important;
+ min-width: 6px;
+ height: 6px;
+ width: 6px;
+ padding: 0;
+ background: var(--spaces-button-badge-bg-color);
+ border: 2px solid var(--lwt-accent-color-inactive, var(--lwt-accent-color));
+ border-radius: 6px;
+}
+
+.has-badge > .menu-iconic-left::after {
+ display: block;
+ content: "";
+ height: 6px;
+ width: 6px;
+ background: var(--spaces-button-badge-bg-color);
+ border: 2px solid var(--arrowpanel-background);
+ border-radius: 6px;
+ position: absolute;
+ inset-block-start: -3px;
+ inset-inline-end: -1px;
+}
diff --git a/comm/mail/themes/shared/mail/splitter.css b/comm/mail/themes/shared/mail/splitter.css
new file mode 100644
index 0000000000..efc1902691
--- /dev/null
+++ b/comm/mail/themes/shared/mail/splitter.css
@@ -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/. */
+
+/* PaneSplitter / pane-splitter elements */
+
+hr[is="pane-splitter"] {
+ margin: 0;
+ --splitter-z-index: 1;
+ z-index: var(--splitter-z-index);
+ /* Make sure border-box's height and width remain the same when we grow or
+ * shrink the border width. */
+ box-sizing: border-box;
+ /* We use a default border-style of none so we can set a border width and
+ * color without it producing a border on all sides. */
+ border-style: none;
+ /* The defining sizes of the splitter:
+ * + content-size: The dimension for the clickable area of the splitter.
+ * + occupy-size: The amount of space the splitter should occupy in the
+ * layout.
+ */
+ --splitter-content-size: 5px;
+ --splitter-occupy-size: 1px;
+ --splitter-margin-size: calc(var(--splitter-occupy-size) - var(--splitter-content-size));
+ border-width: var(--splitter-occupy-size);
+ border-color: var(--splitter-color);
+}
+
+hr[is="pane-splitter"]:not([resize-direction="horizontal"]):not(.splitter-before) {
+ height: var(--splitter-content-size);
+ border-block-start-style: solid;
+ margin-block-end: var(--splitter-margin-size);
+}
+
+hr[is="pane-splitter"]:not([resize-direction="horizontal"]).splitter-before {
+ height: var(--splitter-content-size);
+ border-block-end-style: solid;
+ margin-block-start: var(--splitter-margin-size);
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"]:not(.splitter-before) {
+ width: var(--splitter-content-size);
+ border-inline-start-style: solid;
+ margin-inline-end: var(--splitter-margin-size);
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"].splitter-before {
+ width: var(--splitter-content-size);
+ border-inline-end-style: solid;
+ margin-inline-start: var(--splitter-margin-size);
+}
+
+/* Collapsed splitters. */
+
+hr[is="pane-splitter"].splitter-collapsed {
+ /* Occupy zero space. */
+ --splitter-occupy-size: 0px;
+ /* Make sure we appear above other splitters. */
+ z-index: calc(var(--splitter-z-index) + 1);
+}
+
+hr[is="pane-splitter"]::after {
+ /* We create a pseudo-element that occupies the splitter content. We will only
+ * show this when the splitter is collapsed and being resized or hovered. */
+ background-color: var(--selected-item-color);
+ content: "";
+ display: block;
+ width: 100%;
+ height: 100%;
+ /* We hide this element with scaleY(0) or scaleX(0) so we can smoothly
+ * grow the element between the hidden and full-height or full-width
+ * states. */
+ transform: scaleY(0);
+}
+
+hr[is="pane-splitter"]:not(.splitter-before)::after {
+ /* Grow top-to-bottom. */
+ transform-origin: top;
+}
+
+hr[is="pane-splitter"].splitter-before::after {
+ transform-origin: bottom;
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"]::after {
+ /* Grow left-to-right or right-to-left instead. */
+ transform: scaleX(0);
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"]:is(
+ :not(.splitter-before):dir(ltr),
+ .splitter-before:dir(rtl)
+)::after {
+ transform-origin: left;
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"]:is(
+ .splitter-before:dir(ltr),
+ :not(.splitter-before):dir(rtl)
+)::after {
+ transform-origin: right;
+}
+
+hr[is="pane-splitter"]:not([disabled]).splitter-collapsed:is(
+ :hover,
+ .splitter-resizing
+)::after {
+ /* Grow to full height. */
+ transform: scaleY(1);
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"]:not([disabled]).splitter-collapsed:is(
+ :hover,
+ .splitter-resizing
+)::after {
+ /* Grow to full width. */
+ transform: scaleX(1);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ /* NOTE: We only show a smooth transition from scale 0 to scale 1, or
+ * vice versa, whilst the splitter is collapsed. In particular, we do *not*
+ * want a smooth transition when we switch from collapsed to not collapsed: in
+ * this case we want to immediately shrink back to zero size. In contrast, the
+ * switch from not collapsed to collapsed whilst dragging *is* a smooth
+ * transition. */
+ hr[is="pane-splitter"].splitter-collapsed::after {
+ transition: transform 200ms ease;
+ }
+}
+
+/* Splitter cursors. */
+
+hr[is="pane-splitter"]:not([disabled]) {
+ cursor: ns-resize;
+}
+
+hr[is="pane-splitter"]:not([disabled]).splitter-collapsed:not(.splitter-before) {
+ cursor: s-resize;
+}
+
+hr[is="pane-splitter"]:not([disabled]).splitter-collapsed.splitter-before {
+ cursor: n-resize;
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"]:not([disabled]) {
+ cursor: ew-resize;
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"]:not([disabled]).splitter-collapsed:is(
+ .splitter-before:dir(ltr),
+ :not(.splitter-before):dir(rtl)
+) {
+ cursor: w-resize;
+}
+
+hr[is="pane-splitter"][resize-direction="horizontal"]:not([disabled]).splitter-collapsed:is(
+ :not(.splitter-before):dir(ltr),
+ .splitter-before:dir(rtl)
+) {
+ cursor: e-resize;
+}
diff --git a/comm/mail/themes/shared/mail/subscribe.css b/comm/mail/themes/shared/mail/subscribe.css
new file mode 100644
index 0000000000..349fb552dd
--- /dev/null
+++ b/comm/mail/themes/shared/mail/subscribe.css
@@ -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/. */
+
+/* ===== subscribe.css ==================================================
+ == Styles for the Subscribe dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+
+#subscribeWindow {
+ min-width: 40em;
+ min-height: 30em;
+}
+
+#nameColumn,
+#nameColumn2 {
+ flex: 10 10;
+}
+
+/* ::::: Subscription Icons :::::: */
+
+treechildren::-moz-tree-image(subscribedColumn),
+treechildren::-moz-tree-image(subscribedColumn2) {
+ width: 14px;
+ height: 14px;
+ list-style-image: url("chrome://messenger/skin/icons/checkbox.svg");
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ fill: currentColor;
+ stroke: currentColor;
+ fill-opacity: 0;
+ stroke-opacity: 0;
+}
+
+treechildren::-moz-tree-image(subscribedColumn, subscribed-true),
+treechildren::-moz-tree-image(subscribedColumn2, subscribed-true) {
+ fill-opacity: 1;
+}
+
+treechildren::-moz-tree-image(subscribedColumn, selected, focus),
+treechildren::-moz-tree-image(subscribedColumn2, selected, focus) {
+ fill: var(--select-focus-text-color);
+ stroke: var(--select-focus-text-color);
+}
+
+treechildren::-moz-tree-image(subscribedColumn, subscribable-false) {
+ list-style-image: none;
+}
+
+treechildren::-moz-tree-cell-text(nameColumn, subscribable-false) {
+ opacity: 0.6;
+ font-style: italic;
+}
+
+/* ::::: Folders :::::: */
+
+treechildren::-moz-tree-image(nameColumn) {
+ margin-inline-end: 2px;
+ list-style-image: url("chrome://messenger/skin/icons/folder.svg");
+}
+
+treechildren::-moz-tree-image(nameColumn, serverType-nntp),
+treechildren::-moz-tree-image(nameColumn2, serverType-nntp) {
+ margin-inline-end: 2px;
+ list-style-image: url("chrome://messenger/skin/icons/globe.svg");
+}
+
+/* ::::: Servers :::::: */
+
+.subscribeMenuItem {
+ list-style-image: url("chrome://messenger/skin/icons/message.svg");
+}
+
+.subscribeMenuItem[ServerType="imap"][IsSecure="true"] {
+ list-style-image: url("chrome://messenger/skin/icons/message-secure.svg");
+}
+
+.subscribeMenuItem[ServerType="nntp"] {
+ list-style-image: url("chrome://messenger/skin/icons/globe.svg");
+}
diff --git a/comm/mail/themes/shared/mail/tabmail.css b/comm/mail/themes/shared/mail/tabmail.css
new file mode 100644
index 0000000000..e179394919
--- /dev/null
+++ b/comm/mail/themes/shared/mail/tabmail.css
@@ -0,0 +1,484 @@
+/* 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/. */
+
+:root {
+ --tabs-toolbar-background-color: rgba(0, 0, 0, 0.075);
+ --tabs-toolbar-box-shadow: inset 0 3px 9px -6px rgba(0, 0, 0, 0.5);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --tabs-toolbar-background-color: rgba(0, 0, 0, 0.15);
+ }
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --tabs-toolbar-background-color: rgba(255, 255, 255, 0.15);
+ }
+}
+
+@media (prefers-contrast) {
+ :root {
+ --tabs-toolbar-background-color: transparent;
+ --tabs-toolbar-box-shadow: none;
+ }
+}
+
+#tabmail {
+ overflow: hidden;
+}
+
+#tabmail-tabs .tabmail-tab:first-child .tab-close-button,
+#tabmail-tabs[closebuttons="activetab"] .tabmail-tab:not([selected="true"]) .tab-close-button {
+ display: none;
+}
+
+.tabmail-tab[selected="true"] {
+ box-shadow: 0 2px 8px -5px var(--color-black);
+}
+
+.tab-drop-indicator {
+ position: absolute;
+ top: 0;
+ /* The z-index needs to be big enough to trump other positioned UI pieces
+ that we want to overlay. The selected tab uses 2. */
+ z-index: 3;
+}
+
+#tabs-toolbar {
+ appearance: none;
+ --tabs-top-border-width: 1px;
+ background-image: linear-gradient(to top, var(--chrome-content-separator-color) 0,
+ var(--tabs-toolbar-background-color) 1px);
+ box-shadow: var(--tabs-toolbar-box-shadow);
+ padding-top: 3px;
+ padding-inline: 3px;
+}
+
+#tabs-toolbar:-moz-lwtheme {
+ --tabline-color: var(--lwt-tab-line-color, currentColor);
+}
+
+#tabmail-arrowscrollbox {
+ min-height: var(--tab-min-height);
+}
+
+#tabmail-arrowscrollbox::part(scrollbox-clip) {
+ contain: inline-size;
+}
+
+#tabpanelcontainer {
+ min-height: 0;
+}
+
+.tab-stack {
+ min-height: inherit;
+}
+
+.tabmail-tab {
+ appearance: none;
+ align-items: stretch;
+ flex: 100 100;
+ background-color: transparent;
+ border-radius: 4px 4px 0 0;
+ border-width: 0;
+ margin: 0;
+ margin-inline-end: 1px;
+ padding: 0;
+ max-width: var(--tab-max-width);
+ min-width: var(--tab-min-width);
+ width: 0;
+ overflow: hidden;
+}
+
+/* The selected tab should appear above adjacent tabs and the highlight
+ of #tabs-toolbar */
+.tabmail-tab[selected=true] {
+ position: relative;
+ z-index: 2;
+}
+
+.tab-content {
+ padding-inline: 9px;
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.tab-content > :is(.tab-throbber, .tab-icon-image, .tab-close-button) {
+ flex: 0 0 auto;
+}
+
+.tab-content > .tab-label-container {
+ flex: 1 1 auto;
+}
+
+.tab-label-container {
+ overflow: hidden;
+}
+
+.tab-label-container[textoverflow] {
+ mask-image: linear-gradient(to left, transparent, black 2em);
+}
+
+.tab-label-container[textoverflow]:-moz-locale-dir(rtl) {
+ mask-image: linear-gradient(to right, transparent, black 2em);
+}
+
+.tab-throbber,
+.tab-icon-image,
+button.tab-close-button {
+ margin-block: 1px 0;
+}
+
+.tab-throbber,
+.tab-icon-image {
+ height: 16px;
+ width: 16px;
+ margin-inline-end: 6px;
+ -moz-context-properties: fill, stroke, stroke-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.tab-icon-image:not([src]) {
+ visibility: hidden;
+}
+
+.tab-throbber:not([busy]):not([thinking]),
+.tab-throbber[busy] + .tab-icon-image,
+.tab-throbber[thinking] + .tab-icon-image,
+.tab-throbber[busy][thinking] + .tab-icon-image {
+ display: none;
+}
+
+.tab-label {
+ white-space: nowrap;
+ margin-inline-end: 0;
+ margin-inline-start: 0;
+}
+
+button.tab-close-button {
+ margin-inline: 1px -2px;
+ padding: 2px;
+}
+
+.tab-close-icon {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+.tabmail-tab:not([selected],:-moz-lwtheme) {
+ color: inherit;
+}
+
+.tabmail-tab:-moz-lwtheme {
+ color: inherit;
+}
+
+.tabmail-tab[visuallyselected=true]:-moz-lwtheme {
+ color: var(--lwt-tab-text, var(--toolbar-color, inherit));
+}
+
+.tab-line {
+ height: 2px;
+ margin-top: 3px;
+ margin-inline: 3px;
+ border-radius: 3px;
+}
+
+/* Selected tab */
+
+.tab-background {
+ background-clip: padding-box;
+}
+
+.tab-background[selected=true] {
+ background-color: var(--toolbar-bgcolor);
+ background-repeat: repeat-x;
+}
+
+.tab-line[selected=true] {
+ background-color: var(--tabline-color);
+}
+
+/*
+ * LightweightThemeConsumer will set the current lightweight theme's header
+ * image to the lwt-header-image variable, used in each of the following rulesets.
+ */
+
+/* Lightweight theme on tabs */
+.tabmail-tab .tab-background[selected=true]:-moz-lwtheme {
+ background-attachment: scroll, scroll, fixed;
+ background-color: transparent;
+ background-image: linear-gradient(var(--lwt-selected-tab-background-color, transparent),
+ var(--lwt-selected-tab-background-color, transparent)),
+ linear-gradient(var(--toolbar-bgcolor), var(--toolbar-bgcolor)),
+ var(--lwt-header-image, none);
+ background-position: 0 0, 0 0, right top;
+ background-repeat: repeat-x, repeat-x, no-repeat;
+ background-size: auto 100%, auto 100%, auto auto;
+}
+
+/* Tab hover */
+
+.tabmail-tab:hover .tab-background:not([selected=true]) {
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+/* Adjust button hover color relative to the darker background. */
+#tabs-toolbar:not([brighttext]) button.tab-close-button:hover {
+ background-color: color-mix(in srgb, transparent 80%, CurrentColor);
+}
+
+#tabs-toolbar:not([brighttext]) button.tab-close-button:hover:active {
+ background-color: color-mix(in srgb, transparent 70%, CurrentColor);
+}
+
+#tabs-toolbar[brighttext] .tabmail-tab:hover .tab-background:not([selected=true]) {
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.tab-line:not([selected=true]) {
+ opacity: 0;
+ transform: scaleX(0);
+ transition: transform 250ms var(--animation-easing-function),
+ opacity 250ms var(--animation-easing-function);
+}
+
+.tabmail-tab:hover .tab-line:not([selected=true]) {
+ background-color: rgba(0, 0, 0, 0.2);
+ opacity: 1;
+ transform: none;
+}
+
+#tabs-toolbar[brighttext] .tabmail-tab:hover .tab-line:not([selected=true]) {
+ background-color: rgba(255, 255, 255, 0.2);
+}
+
+.tab-throbber {
+ list-style-image: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .tab-throbber[busy] {
+ background-image: url("chrome://messenger/skin/icons/hourglass.svg");
+ background-position: center;
+ background-repeat: no-repeat;
+ opacity: 0.8;
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .tab-throbber[busy] {
+ position: relative;
+ overflow: hidden;
+ }
+
+ .tab-throbber[busy]::before {
+ content: "";
+ position: absolute;
+ background-image: var(--icon-loading);
+ background-position: left center;
+ background-repeat: no-repeat;
+ width: 480px;
+ height: 100%;
+ animation: tab-throbber-animation 1.05s steps(30) infinite;
+ opacity: 0.7;
+ }
+
+ .tab-throbber[busy]:-moz-locale-dir(rtl)::before {
+ animation-name: tab-throbber-animation-rtl;
+ }
+
+ @keyframes tab-throbber-animation {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-100%); }
+ }
+
+ @keyframes tab-throbber-animation-rtl {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(100%); }
+ }
+}
+
+/**
+ * Tab Scrollbox Arrow Buttons
+ */
+
+#tabmail-arrowscrollbox::part(scrollbutton-up),
+#tabmail-arrowscrollbox::part(scrollbutton-down) {
+ fill: var(--toolbarbutton-icon-fill, currentColor);
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+/* Tab Overflow */
+#tabmail-arrowscrollbox:not([scrolledtostart])::part(overflow-start-indicator),
+#tabmail-arrowscrollbox:not([scrolledtoend])::part(overflow-end-indicator) {
+ width: 18px;
+ background-image: url("chrome://messenger/skin/icons/overflow-indicator.png");
+ background-size: 17px 100%;
+ background-repeat: no-repeat;
+ border-left: 1px solid;
+ border-image: linear-gradient(rgba(255,255,255,.2),
+ rgba(255,255,255,.2) calc(100% - var(--tabs-tabbar-border-size)),
+ transparent calc(100% - var(--tabs-tabbar-border-size)));
+ border-image-slice: 1;
+ margin-bottom: var(--tabs-tabbar-border-size);
+ pointer-events: none;
+ position: relative;
+ z-index: 3; /* the selected tab's z-index + 1 */
+}
+
+#tabmail-arrowscrollbox:-moz-locale-dir(rtl)::part(overflow-start-indicator),
+#tabmail-arrowscrollbox:-moz-locale-dir(ltr)::part(overflow-end-indicator) {
+ transform: scaleX(-1);
+}
+
+#tabmail-arrowscrollbox:not([scrolledtostart])::part(overflow-start-indicator) {
+ margin-inline-start: -1px;
+ margin-inline-end: -17px;
+}
+
+#tabmail-arrowscrollbox:not([scrolledtoend])::part(overflow-end-indicator) {
+ margin-inline-start: -17px;
+ margin-inline-end: -1px;
+}
+
+#tabmail-arrowscrollbox[scrolledtostart]::part(overflow-start-indicator),
+#tabmail-arrowscrollbox[scrolledtoend]::part(overflow-end-indicator) {
+ opacity: 0;
+}
+
+#tabmail-arrowscrollbox::part(overflow-start-indicator),
+#tabmail-arrowscrollbox::part(overflow-end-indicator) {
+ transition: opacity 150ms ease;
+}
+
+/**
+ * All Tabs Button
+ */
+
+#alltabs-button {
+ list-style-image: url("chrome://messenger/skin/icons/arrow-dropdown.svg");
+}
+
+#alltabs-button .toolbarbutton-icon {
+ width: 16px;
+ height: 16px;
+}
+
+#alltabs-button > .toolbarbutton-text,
+#alltabs-button > .toolbarbutton-menu-dropmarker {
+ display: none;
+}
+
+/* All Tabs Menupopup */
+
+.alltabs-item {
+ list-style-image: var(--icon-draft);
+}
+
+.alltabs-item[selected] {
+ font-weight: bold;
+}
+
+.alltabs-item[busy] {
+ list-style-image: url("chrome://global/skin/icons/loading.png") !important;
+}
+
+.alltabs-item > .menu-iconic-left {
+ fill: MenuText;
+}
+
+.alltabs-item[_moz-menuactive="true"] > .menu-iconic-left {
+ fill: -moz-menuhovertext;
+}
+
+/* Content Tabs */
+.chromeTabInstance[collapsed="false"] .contentTabToolbox,
+.contentTabInstance[collapsed="false"] .contentTabToolbox {
+ display: none;
+}
+
+.contentTabAddress {
+ display: flex;
+ align-items: center;
+}
+
+.contentTabAddress > .contentTabSecurity {
+ flex: 0 0 auto;
+}
+
+.contentTabAddress > .contentTabUrlInput {
+ flex: 1 1 auto;
+}
+
+.contentTabSecurity {
+ height: 16px;
+ width: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ /* Position within the input. */
+ position: relative;
+ /* Make sure we take up no width in the flow. */
+ margin-inline-end: -16px;
+ /* Move within the input. Input has a margin of 3px and border of 1px, so this
+ * is 5px within. */
+ inset-inline-start: 9px;
+}
+
+.contentTabSecurity.secure-connection-icon {
+ fill: #12bc00;
+}
+
+.contentTabSecurity:not([src]) {
+ display: none;
+}
+
+.contentTabSecurity[src] + .contentTabUrlInput {
+ /* 5px before the icon + 16px width + 4px after. */
+ padding-inline-start: 25px;
+}
+
+.contentTabSecurity:not([src]) + .contentTabUrlInput {
+ padding-inline-start: 4px;
+}
+
+.nav-button {
+ appearance: none;
+ list-style-image: var(--icon-nav-back);
+ border: 1px solid transparent;
+ border-radius: 2px;
+ margin: 5px 2px;
+ margin-inline-start: 2px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.nav-button[disabled="true"] {
+ color: inherit;
+}
+
+.nav-button[disabled="true"] > .toolbarbutton-icon {
+ opacity: 0.4;
+}
+
+.nav-button:not([disabled="true"]):hover {
+ background-color: var(--toolbarbutton-hover-background);
+ cursor: pointer;
+}
+
+.nav-button > .toolbarbutton-text {
+ display: none;
+}
+
+.back-btn:-moz-locale-dir(rtl),
+.forward-btn:-moz-locale-dir(ltr) {
+ list-style-image: var(--icon-nav-forward);
+}
diff --git a/comm/mail/themes/shared/mail/tagColors.css b/comm/mail/themes/shared/mail/tagColors.css
new file mode 100644
index 0000000000..ad932f3141
--- /dev/null
+++ b/comm/mail/themes/shared/mail/tagColors.css
@@ -0,0 +1,13 @@
+/* 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/. */
+
+/* ::::: thread labels decoration ::::: */
+
+/*
+ * This style sheet is empty and will be dynamically populated.
+ * To populate it, typically TagUtils.loadTagsIntoCSS(document)
+ * is called from the document's onload() handler.
+ * There is also TagUtils.addTagToAllDocumentSheets() which inserts
+ * CSS into this sheet on all open messenger and search windows.
+ */
diff --git a/comm/mail/themes/shared/mail/themeableDialog.css b/comm/mail/themes/shared/mail/themeableDialog.css
new file mode 100644
index 0000000000..9d1c35ffe9
--- /dev/null
+++ b/comm/mail/themes/shared/mail/themeableDialog.css
@@ -0,0 +1,608 @@
+/* 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 url("chrome://messenger/skin/icons.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:host,
+:root {
+ --button-background-color: color-mix(in srgb, currentColor 13%, transparent);
+ --button-hover-background-color: color-mix(in srgb, currentColor 17%, transparent);
+ --button-active-background-color: color-mix(in srgb, currentColor 30%, transparent);
+ --button-border-color: color-mix(in srgb, currentColor 17%, transparent);
+ --button-border-radius: 3px;
+ --box-text-color: MenuText;
+ --box-background-color: Menu;
+ --box-border-color: ThreeDShadow;
+ --checkbox-border-color: var(--field-border-color);
+ --checkbox-unchecked-bgcolor: var(--field-background-color);
+ --checkbox-unchecked-hover-bgcolor: var(--field-background-color);
+ --field-text-color: FieldText;
+ --field-background-color: Field;
+ --field-border-color: rgba(128, 128, 128, .6);
+ --field-border-hover-color: rgba(128, 128, 128, .8);
+ --selected-item-color: var(--color-blue-60);
+ --primary-text-color: var(--selected-item-text-color);
+ --primary-background-hover: color-mix(in srgb, var(--selected-item-color) 85%, black);
+ --primary-background-active: color-mix(in srgb, var(--selected-item-color) 78%, black);
+ --primary-focus-border: -moz-Dialog;
+ --richlist-button-background: -moz-Dialog;
+ --tab-hover-background: hsla(0, 0%, 50%, 0.15);
+ --tab-selected-background: hsla(0, 0%, 50%, 0.25);
+}
+
+:root:-moz-lwtheme {
+ --box-text-color: var(--color-ink-90);
+ --box-background-color: var(--color-white);
+ --box-border-color: var(--color-gray-40);
+ --field-text-color: var(--color-gray-90);
+ --field-background-color: var(--color-white);
+ --field-border-color: rgba(0, 0, 0, 0.3);
+ --field-border-hover-color: rgba(0, 0, 0, 0.4);
+ --primary-focus-border: var(--lwt-accent-color);
+ background-color: var(--lwt-accent-color);
+ color: var(--lwt-text-color);
+}
+
+:root[lwt-tree] {
+ --richlist-button-background: var(--sidebar-background-color);
+}
+
+:root:not([lwt-tree]):-moz-lwtheme[lwtheme-image] {
+ background-image: var(--lwt-header-image) !important;
+ background-repeat: no-repeat;
+ background-position: right top !important;
+}
+
+:root:not([lwt-tree]):-moz-lwtheme:-moz-window-inactive {
+ background-color: var(--lwt-accent-color-inactive, var(--lwt-accent-color));
+}
+
+:root:not([lwt-tree]):-moz-lwtheme dialog,
+#calendar-event-dialog-inner:not([lwt-tree]):-moz-lwtheme,
+#calendar-task-dialog-inner:not([lwt-tree]):-moz-lwtheme {
+ background-color: -moz-Dialog;
+ color: -moz-DialogText;
+ text-shadow: none !important;
+
+ --box-text-color: MenuText;
+ --box-background-color: Menu;
+ --box-border-color: ThreeDShadow;
+ --checkbox-border-color: rgba(128, 128, 128, .6);
+ --checkbox-unchecked-bgcolor: Field;
+ --checkbox-unchecked-hover-bgcolor: Field;
+ --field-text-color: FieldText;
+ --field-background-color: Field;
+ --field-border-color: rgba(128, 128, 128, .6);
+ --field-border-hover-color: rgba(128, 128, 128, .8);
+ --selected-item-color: var(--color-blue-60);
+ --lwt-accent-color: -moz-Dialog;
+ --richlist-button-background: -moz-Dialog;
+ --tab-hover-background: hsla(0, 0%, 50%, 0.15);
+ --tab-selected-background: hsla(0, 0%, 50%, 0.25);
+}
+
+@media (prefers-color-scheme: dark) {
+ :host,
+ :root:-moz-lwtheme,
+ :root:not([lwt-tree]):-moz-lwtheme dialog {
+ --box-text-color: var(--color-ink-10);
+ --box-background-color: var(--color-ink-80);
+ --box-border-color: rgba(249, 249, 250, 0.3);
+ --field-text-color: var(--color-ink-10);
+ --field-background-color: var(--color-gray-70);
+ --field-border-color: hsla(0, 0%, 70%, 0.4);
+ --field-border-hover-color: hsla(0, 0%, 70%, 0.5);
+ --selected-item-color: var(--color-blue-50);
+ --highlight-background: var(--color-white);
+ --primary-text-color: var(--color-ink-10);
+ --tab-hover-background: hsla(0, 0%, 50%, 0.3);
+ --tab-selected-background: hsla(0, 0%, 50%, 0.5);
+ }
+}
+
+@media (prefers-contrast) {
+ :host,
+ :root:not(:-moz-lwtheme) {
+ --button-background-color: ButtonFace;
+ --button-hover-background-color: SelectedItem;
+ --button-active-background-color: SelectedItem;
+ --button-text-active: SelectedItemText;
+ --button-border-color: ThreeDShadow;
+ --field-border-color: ThreeDShadow;
+ --field-border-hover-color: SelectedItemText;
+ --primary-text-color: SelectedItemText;
+ --selected-item-color: SelectedItem;
+ --tab-hover-background: SelectedItem;
+ --tab-selected-background: SelectedItem;
+ }
+
+ button[open],
+ button:not([disabled="true"]):hover,
+ button:not([disabled="true"]):hover:active,
+ menulist:not([disabled="true"],[open="true"]):hover,
+ menulist[open="true"]:not([disabled="true"]),
+ tab:hover,
+ tab[visuallyselected="true"] {
+ color: var(--button-text-active) !important;
+ }
+}
+
+dialog::part(content-box) {
+ flex: 1;
+}
+
+dialog.scrollable {
+ width: 100vw;
+ height: 100vh;
+}
+
+dialog.scrollable::part(content-box) {
+ overflow: scroll;
+}
+
+html|input,
+html|textarea {
+ appearance: none;
+ background-color: var(--field-background-color);
+ border: 1px solid var(--field-border-color);
+ border-radius: var(--button-border-radius);
+ color: var(--field-text-color);
+ margin: 2px 4px;
+}
+
+html|input:not(:focus):hover,
+html|textarea:not(:focus):hover {
+ border-color: var(--field-border-hover-color);
+}
+
+html|input:focus,
+html|textarea:focus {
+ border-color: var(--selected-item-color);
+ outline: 1px solid var(--selected-item-color);
+}
+
+:root[lwt-tree-brighttext] html|input::selection,
+:root[lwt-tree-brighttext] html|textarea::selection,
+:root[lwt-default-theme-in-dark-mode] html|input::selection,
+:root[lwt-default-theme-in-dark-mode] html|textarea::selection {
+ background-color: var(--highlight-background);
+ color: var(--selected-item-color);
+}
+
+html|input:is([type="email"],[type="tel"],[type="text"],[type="password"],
+ [type="url"],[type="number"]):disabled {
+ opacity: 0.4;
+}
+
+html|input[type="number"] {
+ padding-inline-end: 1px;
+}
+
+html|input[type="number"]::-moz-number-spin-up,
+html|input[type="number"]::-moz-number-spin-down {
+ appearance: none;
+ width: 16px;
+ background-position: center;
+ background-color: var(--button-background-color);
+ background-repeat: no-repeat;
+ border: 1px solid var(--field-border-color);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+html|input[type="number"]::-moz-number-spin-up {
+ background-image: var(--icon-nav-up-sm);
+ border-bottom-style: none;
+ border-radius: 2px 2px 0 0;
+}
+html|input[type="number"]::-moz-number-spin-down {
+ background-image: var(--icon-nav-down-sm);
+ border-radius: 0 0 2px 2px;
+}
+
+button,
+menulist,
+html|input[type="color"] {
+ appearance: none;
+ min-height: 24px;
+ /* !important overrides button.css for disabled and default XUL buttons: */
+ color: inherit !important;
+ border: 1px solid var(--button-border-color); /* needed for high-contrast mode, where it'll show up */
+ border-radius: var(--button-border-radius);
+ background-color: var(--button-background-color);
+ padding: 0 8px;
+ text-decoration: none;
+ margin: 4px;
+ /* Ensure font-size isn't overridden by widget styling (e.g. in forms.css) */
+ font-size: 1em;
+}
+
+menulist {
+ padding-inline: 4px;
+}
+
+html|input[type="color"] {
+ padding: 4px;
+}
+
+#folderCompactDialog {
+ width: 50em;
+}
+
+#resetColor {
+ list-style-image: url("chrome://messenger/skin/icons/forget.svg");
+ -moz-context-properties: fill;
+ fill: currentColor;
+ min-width: 16px;
+ min-height: 16px;
+ padding: 2px !important;
+ margin-inline-end: 4px;
+ --toolbarbutton-hover-background: var(--button-hover-background-color);
+ --toolbarbutton-hover-bordercolor: var(--button-border-color);
+ --toolbarbutton-active-background: var(--button-active-background-color);
+ --toolbarbutton-active-bordercolor: var(--button-border-color);
+}
+
+#resetColor:not(:hover) {
+ background-color: transparent;
+}
+
+#resetColor .button-icon {
+ margin-inline-end: 0;
+}
+
+button:-moz-focusring {
+ outline: 2px solid var(--selected-item-color);
+ outline-offset: -1px;
+}
+
+button:not([disabled="true"]):hover,
+menulist:not([disabled="true"],[open="true"]):hover,
+menulist[open="true"]:not([disabled="true"]),
+html|input[type="color"]:not([disabled="true"]):hover {
+ background-color: var(--button-hover-background-color);
+}
+
+button[open],
+button[open]:hover,
+button:not([disabled="true"]):hover:active,
+html|input[type="color"]:not([disabled="true"]):hover:active {
+ background-color: var(--button-active-background-color);
+}
+
+button[default] {
+ background-color: var(--selected-item-color);
+ color: var(--primary-text-color) !important;
+}
+
+button[default]:-moz-focusring {
+ border-color: var(--primary-focus-border);
+ outline-offset: 0;
+}
+
+button[default]:not([disabled="true"]):hover {
+ background-color: var(--primary-background-hover);
+}
+
+button[default]:not([disabled="true"]):hover:active {
+ background-color: var(--primary-background-active);
+}
+
+button[is="toolbarbutton-menu-button"] > .button-box > button {
+ border-inline-end-color: var(--button-border-color);
+}
+
+button > .button-box > dropmarker {
+ padding-inline-start: 3px;
+}
+
+button[disabled="true"],
+menulist[disabled="true"] {
+ opacity: 0.4;
+}
+
+menulist::part(label-box) {
+ font-weight: inherit;
+ text-shadow: none;
+}
+
+menulist:-moz-focusring::part(label-box),
+menulist:-moz-focusring:not([open="true"])::part(label-box) {
+ outline: none;
+}
+
+menulist::part(icon) {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+menulist[is="menulist-addrbooks"]::part(icon) {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+menulist::part(dropmarker) {
+ appearance: none;
+ width: 16px;
+ padding: 0;
+ border: none;
+ background-color: transparent;
+}
+
+menulist::part(dropmarker-icon) {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ width: 16px;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(text-input) {
+ background-color: var(--field-background-color);
+ color: var(--field-text-color);
+ border: 1px solid var(--field-border-color);
+ border-start-start-radius: 3px;
+ border-end-start-radius: 3px;
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(text-input):focus {
+ border-color: var(--selected-item-color);
+ outline: 1px solid var(--selected-item-color);
+}
+
+.menu-iconic-left {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.abMenuItem > .menu-iconic-left {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.menu-right {
+ appearance: none;
+ -moz-context-properties: fill;
+ list-style-image: var(--icon-nav-right-sm);
+ fill: currentColor;
+}
+
+.menu-right:-moz-locale-dir(rtl) {
+ list-style-image: var(--icon-nav-left-sm);
+}
+
+button menupopup menuseparator,
+menulist menupopup menuseparator {
+ appearance: none;
+ margin: 2px 0;
+ padding: 0;
+ border-top: 1px solid var(--box-border-color);
+ border-bottom: none;
+}
+
+:root[lwt-tree] .autocomplete-richlistbox {
+ border-style: none;
+ background-color: var(--box-background-color);
+}
+
+label {
+ margin-inline-start: 4px;
+}
+
+radio,
+checkbox {
+ appearance: none;
+}
+
+checkbox {
+ margin: 2px 4px;
+ padding-inline: 4px 2px;
+}
+
+radio[disabled="true"],
+checkbox[disabled="true"] {
+ color: #999;
+}
+
+.radio-check {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border: 1px solid var(--field-border-color);
+ margin: 0;
+ margin-inline-end: 6px;
+ background-color: var(--field-background-color);
+}
+
+.radio-check {
+ border-radius: 50%;
+}
+
+radio:not([disabled="true"]):hover > .radio-check,
+checkbox:not([disabled="true"]):hover > .checkbox-check {
+ border-color: var(--selected-item-color);
+}
+
+.radio-check[selected] {
+ list-style-image: url("chrome://global/skin/icons/radio.svg");
+ -moz-context-properties: fill;
+ color: var(--checkbox-checked-border-color, currentColor);
+ fill: var(--checkbox-checked-color, AccentColorText);
+ background-color: var(--checkbox-checked-bgcolor, AccentColor);
+}
+
+:root[lwt-tree] richlistbox {
+ appearance: none;
+ background-color: var(--field-background-color);
+ color: var(--field-text-color);
+ border: 1px solid var(--field-border-color);
+}
+
+:root[lwt-tree] richlistitem[selected="true"] {
+ background-color: hsla(0,0%,50%,.15);
+ color: inherit;
+}
+
+:root[lwt-tree] richlistbox:focus > richlistitem[selected="true"] {
+ background-color: var(--sidebar-highlight-background-color, hsla(0, 0%, 50%, 0.35));
+ color: var(--sidebar-highlight-text-color, inherit);
+ outline: 1px solid var(--selected-item-color) !important;
+ outline-offset: -1px;
+}
+
+:root[lwt-tree] richlistbox:focus > richlistitem[selected="true"] button,
+:root[lwt-tree] richlistbox:focus > richlistitem[selected="true"] menulist {
+ color: var(--sidebar-text-color) !important;
+}
+
+richlistitem[selected="true"] {
+ background-color: var(--tab-selected-background);
+ color: inherit;
+}
+
+richlistbox:where(:focus) > richlistitem[selected="true"] {
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+richlistbox[seltype="multiple"]:focus > richlistitem[current="true"] {
+ outline-color: var(--selected-item-color);
+}
+
+richlistbox > richlistitem {
+ padding-block: 1px;
+}
+
+richlistbox > richlistitem menulist {
+ margin-block: 2px;
+}
+
+richlistitem button,
+richlistitem menulist {
+ background-color: var(--richlist-button-background);
+ background-image: linear-gradient(var(--button-background-color),
+ var(--button-background-color));
+ color: var(--field-text-color) !important;
+}
+
+richlistitem button:not([disabled="true"]):hover,
+richlistitem menulist:not([disabled="true"]):hover,
+richlistitem menulist[open="true"]:not([disabled="true"]) {
+ background-color: var(--richlist-button-background);
+ background-image: linear-gradient(var(--button-hover-background-color),
+ var(--button-hover-background-color));
+}
+
+richlistitem button[open],
+richlistitem button[open]:hover,
+richlistitem button:not([disabled="true"]):hover:active {
+ background-color: var(--richlist-button-background);
+ background-image: linear-gradient(var(--button-active-background-color),
+ var(--button-active-background-color));
+}
+
+menulist[open="true"],
+menulist:not([disabled="true"], [open="true"]):hover {
+ border-color: var(--button-border-color);
+}
+
+:root[lwt-tree] tree {
+ appearance: none;
+ border: 1px solid var(--sidebar-border-color);
+}
+
+tabbox {
+ color: inherit;
+ text-shadow: none;
+}
+
+tabs {
+ margin-block: 8px 10px;
+ margin-inline: 4px;
+ border-bottom: 1px solid var(--box-border-color);
+}
+
+tab {
+ appearance: none;
+ margin-top: 0;
+ padding: 6px 10px !important;
+ border-bottom: 2px solid transparent;
+ color: inherit !important;
+}
+
+tab:hover {
+ background-color: var(--tab-hover-background);
+}
+
+tab[visuallyselected="true"] {
+ margin-block: 0;
+ background-color: var(--tab-selected-background);
+ border-bottom-color: var(--lwt-tab-line-color, var(--selected-item-color));
+}
+
+tabpanels {
+ appearance: none;
+ border: none;
+ padding: 0;
+ background-color: transparent;
+ color: inherit;
+}
+
+.dialog-button-box {
+ padding-top: 6px;
+}
+
+fieldset:-moz-lwtheme {
+ border: 1px solid var(--field-border-color);
+}
+
+legend:-moz-lwtheme {
+ background-color: var(--lwt-accent-color);
+}
+
+separator.groove:not([orient="vertical"]) {
+ border-top-color: var(--field-border-color);
+ border-bottom-style: none;
+}
+
+.tip-caption {
+ opacity: 0.7;
+ font-size: .9em;
+}
+
+:root[lwt-tree-brighttext] .text-link {
+ color: #0aa5ff;
+}
+
+.text-link:focus-visible {
+ outline: 2px solid var(--selected-item-color);
+ outline-offset: 1px;
+ border-radius: 1px;
+}
+
+.alert-icon {
+ content: var(--icon-warning-dialog);
+ height: 48px;
+ width: 48px;
+}
+
+.question-icon {
+ content: var(--icon-question-dialog);
+ height: 48px;
+ width: 48px;
+}
+
+p {
+ margin: 2px 4px;
+}
+
+hr {
+ width: 100%;
+ border-top: 1px solid var(--field-border-color);
+ border-bottom: 0;
+}
diff --git a/comm/mail/themes/shared/mail/threadPane.css b/comm/mail/themes/shared/mail/threadPane.css
new file mode 100644
index 0000000000..c93565fa30
--- /dev/null
+++ b/comm/mail/themes/shared/mail/threadPane.css
@@ -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/. */
+
+:root {
+ --thread-pane-container-gap: 3px;
+ --thread-pane-header-padding: 3px;
+}
+
+:root[uidensity="compact"] {
+ --thread-pane-container-gap: 0;
+}
+
+:root[uidensity="touch"] {
+ --thread-pane-header-padding: 6px;
+}
+
+@media not (prefers-contrast) {
+ :root {
+ --thread-pane-flag-fill: var(--color-orange-30);
+ --thread-pane-flag-stroke: var(--color-orange-60);
+ --thread-pane-unread-fill: var(--color-green-60);
+ --thread-pane-unread-stroke: var(--color-green-60);
+ --thread-pane-spam-fill: var(--color-red-50);
+ --thread-pane-spam-stroke: var(--color-red-70);
+ --thread-pane-unread-color: currentColor;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --thread-pane-flag-fill: var(--color-orange-40);
+ --thread-pane-flag-stroke: var(--color-orange-50);
+ --thread-pane-unread-fill: var(--color-green-50);
+ --thread-pane-unread-stroke: var(--color-green-50);
+ --thread-pane-spam-fill: var(--color-red-40);
+ --thread-pane-spam-stroke: var(--color-red-50);
+ --thread-pane-unread-color: var(--color-white);
+ }
+ }
+}
+
+@media (prefers-contrast) {
+ :root {
+ --thread-pane-unread-color: currentColor;
+ }
+}
+
+#threadPane > tree-view {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-color: var(--tree-view-bg);
+ color: var(--tree-view-color);
+ overflow-anchor: none;
+}
+
+#threadTree tbody [data-properties~="dummy"]:not(:hover, .selected) {
+ background-color: var(--layout-background-2);
+}
+
+#threadTree tbody [data-properties~="dummy"] .subject-line {
+ margin-inline-start: 6px;
+}
+
+#threadTree tbody [data-properties~="dummy"] :is(button:not(.twisty), .subject-line > img) {
+ display: none;
+}
+
+#threadTree tbody [data-properties~="unread"] {
+ font-weight: bold;
+}
+
+#threadTree tbody [data-properties~="imapdeleted"] :is(td, .subject-line) {
+ text-decoration: line-through;
+}
+
+.tree-button-delete img {
+ content: var(--icon-trash-sm);
+}
+
+.tree-button-restore img {
+ content: var(--icon-restore);
+}
+
+#threadTree tbody button.tree-button-restore {
+ display: none;
+}
+
+#threadTree tbody [data-properties~="imapdeleted"] .tree-button-delete {
+ display: none;
+}
+
+#threadTree tbody [data-properties~="imapdeleted"] .tree-button-restore {
+ display: block;
+}
+
+#threadTree tbody .children.collapsed[data-properties~="hasUnread"][data-properties~="read"]
+ :where(td, .subject-line) {
+ text-decoration: underline;
+}
+
+#threadTree thead[is="tree-view-table-header"] th::before {
+ display: none;
+}
+
+#threadTree tbody button {
+ min-height: auto;
+ min-width: auto;
+ padding: 0;
+ margin: 0;
+ background-color: transparent;
+ border: none;
+}
+
+#sizeCol,
+#unreadCol,
+#totalCol {
+ min-width: 4ch;
+}
+
+#sizeColButton,
+.sizecol-column,
+#unreadColButton,
+.unreadcol-column,
+#totalColButton,
+.totalcol-column {
+ text-align: end;
+}
+
+/* Add on the end aligned columns a padding when they are at the end of the row. */
+.sizecol-column[colspan="2"],
+.unreadcol-column[colspan="2"],
+.totalcol-column[colspan="2"] {
+ /* 25px is the width of the column picker. */
+ padding-inline-end: 25px;
+}
+
+@media (-moz-overlay-scrollbars) {
+ .sizecol-column[colspan="2"],
+ .unreadcol-column[colspan="2"],
+ .totalcol-column[colspan="2"] {
+ padding-inline-end: calc(25px + env(scrollbar-inline-size));
+ }
+}
+
+[is="tree-view-table-body"]:focus > .selected button,
+[is="tree-view-table-body"]:focus-within > .selected button,
+[is="tree-view-table-body"] > .selected:focus-within button {
+ color: currentColor;
+}
+
+#threadTree button:not(.twisty, .button-column-picker),
+#threadTree button:not(.button-column-picker) img {
+ display: block;
+ margin-inline: auto;
+}
+
+#threadTree[rows="thread-card"] button {
+ min-height: 16px;
+ min-width: 16px;
+ opacity: 0.7;
+}
+
+#threadTree[rows="thread-card"] button:hover {
+ opacity: 1;
+ color: currentColor;
+}
+
+tr[data-properties~="untagged"][data-properties~="unread"] {
+ color: var(--thread-pane-unread-color);
+}
+
+tr[is="thread-row"],
+tr[is="thread-card"] .subject {
+ color: var(--tag-color, currentColor);
+}
+
+.tag-icon {
+ content: var(--icon-tag-sm);
+}
+
+tr[data-properties~="tagged"] .tag-icon {
+ display: unset;
+ color: var(--tag-color, currentColor);
+}
+
+[is="tree-view-table-body"]:focus > .selected[data-properties~="tagged"] :is(.tag-icon, .subject),
+[is="tree-view-table-body"]:focus-within > .selected[data-properties~="tagged"] :is(.tag-icon, .subject),
+[is="tree-view-table-body"] > .selected[data-properties~="tagged"]:focus-within :is(.tag-icon, .subject) {
+ color: currentColor;
+}
+
+[is="tree-view-table-body"]:focus tr[is="thread-row"].selected,
+[is="tree-view-table-body"]:focus-within tr[is="thread-row"].selected,
+[is="tree-view-table-body"] tr[is="thread-row"].selected:focus-within {
+ background-color: var(--tag-color, var(--listbox-focused-selected-bg));
+ color: var(--tag-contrast-color, var(--listbox-selected-color));
+}
+
+[is="tree-view-table-body"] tr.context-menu-target {
+ background-color: color-mix(in srgb, var(--treeitem-background-active) 10%, transparent);
+ outline: 1px var(--listbox-border-type) var(--listbox-focused-selected-bg);
+ outline-offset: -1px;
+}
+
+/* Thread column */
+
+.tree-view-header-thread img {
+ content: var(--icon-thread-sm);
+ pointer-events: none;
+}
+
+.tree-view-row-thread img {
+ content: var(--icon-thread-sm);
+ color: var(--tree-view-color);
+ opacity: 0.7;
+ pointer-events: none;
+}
+
+#threadTree tbody [data-properties~="ignore"] .tree-view-row-thread img {
+ content: var(--icon-thread-ignored);
+}
+
+#threadTree tbody [data-properties~="ignoreSubthread"] .tree-view-row-thread img {
+ content: var(--icon-subthread-ignored);
+}
+
+#threadTree tbody [data-properties~="watch"] .tree-view-row-thread img {
+ content: var(--icon-eye);
+}
+
+[is="tree-view-table-body"]:focus > .selected .tree-view-row-thread img,
+[is="tree-view-table-body"]:focus-within > .selected .tree-view-row-thread img,
+[is="tree-view-table-body"] > .selected:focus-within .tree-view-row-thread img {
+ color: currentColor;
+}
+
+#threadTree tr:not(.children, [data-properties~="ignoreSubthread"]) .tree-view-row-thread > button {
+ display: none;
+}
+
+/* Starred column */
+
+#flaggedColButton img {
+ content: var(--icon-star-sm);
+}
+
+.tree-view-row-flag button {
+ color: var(--tree-view-color);
+ opacity: 0.8;
+}
+
+.tree-view-row-flag img {
+ content: var(--icon-star);
+ pointer-events: none;
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+@media (prefers-color-scheme: dark) {
+ .tree-view-row-flag img {
+ stroke: color-mix(in srgb, currentColor 40%, transparent);
+ }
+}
+
+.button-star {
+ -moz-context-properties: fill, stroke;
+ background-image: var(--icon-star);
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+[is="tree-view-table-body"]:focus > .selected:not([data-properties~="flagged"]) .button-star,
+[is="tree-view-table-body"]:focus-within > .selected:not([data-properties~="flagged"]) .button-star {
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: color-mix(in srgb, currentColor 70%, transparent);
+}
+
+tr[data-properties~="flagged"] .tree-view-row-flag > .tree-button-flag > img,
+tr[data-properties~="flagged"] .button-star {
+ fill: var(--thread-pane-flag-fill);
+ stroke: var(--thread-pane-flag-stroke);
+}
+
+.tree-view-row-flag button:hover {
+ opacity: 1;
+}
+
+/* Attachment column variations */
+
+#attachmentColButton img {
+ content: var(--icon-attachment-sm);
+}
+
+:is(.attachmentcol-column img, .attachment-icon) {
+ content: var(--icon-attachment-sm);
+ color: var(--tree-view-color);
+ margin-top: 1px;
+ opacity: 0.7;
+}
+
+[is="tree-view-table-body"]:focus >
+ .selected :is(.attachmentcol-column img, .attachment-icon),
+[is="tree-view-table-body"]:focus-within >
+ .selected :is(.attachmentcol-column img, .attachment-icon),
+[is="tree-view-table-body"] >
+ .selected:focus-within :is(.attachmentcol-column img, .attachment-icon) {
+ color: currentColor;
+}
+
+tr:not([data-properties~="attach"]) :is(.attachmentcol-column img, .attachment-icon) {
+ display: none;
+}
+
+/* Unread column variations */
+
+#unreadButtonColHeader img {
+ content: var(--icon-unread-sm);
+}
+
+.tree-view-row-unread button {
+ color: var(--tree-view-color);
+ opacity: 0.8;
+}
+
+.tree-view-row-unread img {
+ content: var(--icon-unread-dot);
+ pointer-events: none;
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+tr[data-properties~="unread"] .tree-view-row-unread > .tree-button-unread > img {
+ fill: color-mix(in srgb, var(--thread-pane-unread-fill) 50%, transparent);
+ stroke: var(--thread-pane-unread-stroke);
+}
+
+[is="tree-view-table-body"]:focus > .selected > .tree-view-row-unread > .tree-button-unread > img,
+[is="tree-view-table-body"]:focus-within > .selected > .tree-view-row-unread > .tree-button-unread > img,
+[is="tree-view-table-body"] > .selected:focus-within > .tree-view-row-unread > .tree-button-unread > img {
+ fill: transparent;
+ stroke: currentColor;
+}
+
+[is="tree-view-table-body"]:focus > tr[data-properties~="unread"].selected >
+ .tree-view-row-unread > .tree-button-unread > img,
+[is="tree-view-table-body"]:focus-within > tr[data-properties~="unread"].selected >
+ .tree-view-row-unread > .tree-button-unread > img,
+[is="tree-view-table-body"] > tr[data-properties~="unread"].selected:focus-within >
+ .tree-view-row-unread > .tree-button-unread > img {
+ fill: currentColor;
+}
+
+tr[data-properties~="unread"].selected .tree-view-row-unread button {
+ opacity: 1;
+}
+
+.tree-view-row-unread button:hover {
+ opacity: 1;
+}
+
+/* Subject column variations */
+
+#subjectColButton {
+ /* TODO: make this density aware. */
+ padding-inline-start: 19px;
+ text-indent: 0;
+}
+
+.threaded #subjectColButton {
+ /* TODO: make this density aware. */
+ padding-inline-start: 41px;
+}
+
+tr[is="thread-row"] td > .thread-container {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ max-height: inherit;
+ box-sizing: border-box;
+}
+
+tr[is="thread-row"] .subject-line {
+ margin-inline-start: calc(16px * var(--thread-level));
+ pointer-events: none;
+ /* Line height px exception to avoid vertical cut off of characters. This
+ should follow and match the density variation height of the row. */
+ line-height: 22px;
+}
+
+[is="tree-view-table-body"][role="tree"] tr[is="thread-row"]:not(.children) .subject-line {
+ padding-inline-start: 22px;
+}
+
+tr[is="thread-row"] .subject-line img {
+ visibility: hidden;
+ width: 16px;
+ height: 16px;
+ vertical-align: sub;
+ margin-inline-end: 3px;
+}
+
+tr[is="thread-card"] .state {
+ display: none;
+}
+
+/* Icons variations for message state in subject column */
+
+tr[data-properties~="new"] .subject-line img {
+ visibility: initial;
+ content: var(--icon-notify);
+ fill: var(--color-yellow-40) !important; /* override the selected, focus rule */
+ stroke: var(--color-orange-50) !important; /* override the selected, focus rule */
+}
+
+tr[data-properties~="replied"] :is(.subject-line img, .replied) {
+ visibility: initial;
+ display: initial;
+ content: var(--icon-reply-col);
+ fill: var(--color-purple-50);
+}
+
+tr[data-properties~="redirected"] :is(.subject-line img, .redirected) {
+ visibility: initial;
+ display: initial;
+ content: var(--icon-redirect-col);
+ fill: var(--color-orange-50);
+}
+
+tr[data-properties~="forwarded"] :is(.subject-line img, .forwarded) {
+ visibility: initial;
+ display: initial;
+ content: var(--icon-forward-col);
+ fill: var(--color-blue-50);
+}
+
+tr[data-properties~="replied"][data-properties~="forwarded"] .subject-line img {
+ visibility: initial;
+ content: var(--icon-reply-forward-col);
+ fill: var(--color-blue-50);
+ stroke: var(--color-purple-50);
+}
+
+tr[data-properties~="replied"][data-properties~="redirected"] .subject-line img {
+ visibility: initial;
+ content: var(--icon-reply-redirect-col);
+ fill: var(--color-orange-50);
+ stroke: var(--color-purple-50);
+}
+
+tr[data-properties~="forwarded"][data-properties~="redirected"] .subject-line img {
+ visibility: initial;
+ content: var(--icon-forward-redirect-col);
+ fill: var(--color-blue-50);
+ stroke: var(--color-orange-50);
+}
+
+tr[data-properties~="replied"][data-properties~="forwarded"][data-properties~="redirected"]
+ .subject-line img {
+ visibility: initial;
+ content: var(--icon-reply-forward-redirect-col);
+ fill: var(--color-blue-50);
+ stroke: var(--color-purple-50);
+}
+
+[is="tree-view-table-body"]:focus > .selected :is(.subject-line img, .state),
+[is="tree-view-table-body"]:focus-within > .selected :is(.subject-line img, .state),
+[is="tree-view-table-body"] > .selected:focus-within :is(.subject-line img, .state) {
+ fill: currentColor !important;
+ stroke: currentColor !important;
+}
+
+tr:is([is="thread-row"], [is="thread-card"]) .twisty {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ color: var(--tree-view-color);
+}
+
+#threadTree tr[is="thread-card"] button.twisty {
+ width: 12px;
+ height: 13px;
+ min-width: 12px;
+ min-height: 13px;
+}
+
+tr:is([is="thread-row"], [is="thread-card"]):not(.children) .twisty {
+ display: none;
+}
+
+tr:is([is="thread-row"], [is="thread-card"]) .twisty-icon {
+ width: 12px;
+ height: 12px;
+ content: var(--icon-nav-down-sm);
+ margin: 1px;
+}
+
+tr:is([is="thread-row"], [is="thread-card"]).children.collapsed .twisty-icon {
+ transform: rotate(-90deg);
+}
+
+tr:is([is="thread-row"], [is="thread-card"]).children.collapsed:dir(rtl) .twisty-icon {
+ transform: rotate(90deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ tr:is([is="thread-row"], [is="thread-card"]) .twisty-icon {
+ transition: transform 200ms ease;
+ }
+}
+
+/* Spam column variations */
+
+#junkStatusCol img {
+ content: var(--icon-spam-sm);
+}
+
+:is(.tree-view-row-spam button, .button-spam) {
+ color: var(--tree-view-color);
+ opacity: 0.8;
+}
+
+.tree-view-row-spam img {
+ content: var(--icon-spam);
+ pointer-events: none;
+}
+
+.button-spam {
+ background-image: var(--icon-spam);
+ -moz-context-properties: fill, stroke;
+}
+
+#threadTree tr:is(:not([data-properties~="junk"]), [data-properties~="notjunk"]) .button-spam {
+ display: none;
+}
+
+:is(.tree-view-row-spam img, .button-spam) {
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 30%, transparent);
+}
+
+@media (prefers-color-scheme: dark) {
+ :is(.tree-view-row-spam img, .button-spam) {
+ stroke: color-mix(in srgb, currentColor 40%, transparent);
+ }
+}
+
+tr[data-properties~="junk"] :is(.tree-view-row-spam > .tree-button-spam > img, .button-spam) {
+ fill: var(--thread-pane-spam-fill);
+ stroke: var(--thread-pane-spam-stroke);
+}
+
+:is(.tree-view-row-spam button, .button-spam):hover {
+ opacity: 1;
+}
+
+/* Vertical view variations */
+#threadTree tr[data-properties~="junk"] :is(.state, .attachment-icon, .button-star) {
+ display: none;
+}
+
+#threadTree tr[data-properties~="junk"] :is(.date, .subject) {
+ color: var(--color-red-60);
+ font-weight: 600;
+}
+
+@media (prefers-color-scheme: dark) {
+ #threadTree tr[data-properties~="junk"] :is(.date, .subject) {
+ color: var(--color-red-40);
+ }
+}
+
+#threadTree [is="tree-view-table-body"]:focus >
+ .selected[data-properties~="junk"] :is(.date, .subject),
+#threadTree [is="tree-view-table-body"]:focus-within >
+ .selected[data-properties~="junk"] :is(.date, .subject),
+#threadTree [is="tree-view-table-body"] >
+ .selected[data-properties~="junk"]:focus-within :is(.date, .subject) {
+ color: inherit;
+}
+
+/* Delete column variations */
+/* TODO: Handle delete button color variations for tagged messages */
+
+/* Correspondent column variations */
+
+#correspondentColButton,
+.correspondentcol-column {
+ text-indent: 18px;
+}
+
+.correspondentcol-column {
+ background-repeat: no-repeat;
+ background-position-x: 1px;
+ background-position-y: center;
+ padding-inline-start: var(--tree-header-cell-padding) !important;
+ -moz-context-properties: stroke, fill-opacity;
+ stroke: currentColor;
+ fill-opacity: 0.3;
+}
+
+.correspondentcol-column:dir(rtl) {
+ background-position-x: right 1px;
+}
+
+tr[data-properties~="outgoing"] .correspondentcol-column {
+ background-image: var(--icon-nav-forward);
+}
+
+tr[data-properties~="outgoing"] .correspondentcol-column:dir(rtl) {
+ background-image: var(--icon-nav-back);
+}
+
+tr[data-properties~="outgoing"].selected .correspondentcol-column {
+ fill-opacity: 0.6;
+}
+
+/* Vertical layout cards */
+
+#threadTree[rows="thread-card"] {
+ background-color: var(--layout-background-1);
+}
+
+tr[is="thread-card"] td {
+ padding: 0;
+}
+
+tr[is="thread-card"][data-properties~="unread"]:not(.selected, :hover) {
+ background-color: var(--tree-view-bg);
+}
+
+tr[is="thread-card"][data-properties~="new"] {
+ position: relative;
+ box-shadow: 0 6px 5px -5px rgba(0, 0, 0, 0.1);
+}
+
+.thread-card-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: var(--thread-pane-container-gap);
+ height: 100%;
+ padding-inline: 12px;
+ box-sizing: border-box;
+}
+
+tr[is="thread-card"] + tr[is="thread-card"]:not(.context-menu-target) .thread-card-container::before {
+ display: block;
+ content: "";
+ position: absolute;
+ height: 1px;
+ background-color: var(--layout-background-3);
+ inset: 0;
+}
+
+@media (-moz-overlay-scrollbars) {
+ .thread-card-container {
+ padding-inline-end: env(scrollbar-inline-size);
+ }
+}
+
+tr[data-properties~="new"] .thread-card-container::after {
+ position: absolute;
+ content: var(--icon-new-indicator);
+ inset-inline-start: 3px;
+ inset-block-start: 3px;
+}
+
+tr[data-properties~="thread-children"] .thread-card-container {
+ margin-inline-start: 24px;
+ border-bottom: none;
+}
+
+tr[data-properties~="thread-children"] + tr[data-properties~="thread-children"] .thread-card-container {
+ border-top: 1px solid var(--layout-background-3);
+}
+
+tr[data-properties~="thread-children"] + tr:not([data-properties~="thread-children"]) .thread-card-container {
+ border-top: 1px solid var(--layout-background-3);
+}
+
+.thread-card-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+ padding: 0;
+ margin: 0;
+ line-height: 1.3;
+}
+
+.thread-card-row > :is(.sender, .subject) {
+ flex: 1 1 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.is-outgoing [data-properties~="outgoing"] .thread-card-row > .sender {
+ background-position-x: 0;
+ background-position-y: center;
+ background-repeat: no-repeat;
+ background-image: var(--icon-nav-forward);
+ -moz-context-properties: stroke, fill-opacity;
+ stroke: currentColor;
+ fill-opacity: 0.3;
+ padding-inline-start: 18px;
+}
+
+.is-outgoing [data-properties~="outgoing"] .thread-card-row > .sender:dir(rtl)::before {
+ background-image: var(--icon-nav-back);
+}
+
+[data-properties~="dummy"] > td > .thread-card-container > .thread-card-row:first-child {
+ display: none;
+}
+
+.thread-card-subject-container .sender {
+ font-weight: 400;
+}
+
+.thread-card-subject-container {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ flex: 1 1 0;
+}
+
+.thread-card-subject-container .subject {
+ font-size: 1.1rem;
+ font-weight: 500;
+ flex: 1 1 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+[data-properties~="dummy"] .thread-card-subject-container .subject {
+ padding-inline-start: 9px;
+}
+
+.thread-card-container .date {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ font-size: 0.95rem;
+ opacity: 0.85;
+}
+
+#threadTree [data-properties~="unread"] .thread-card-container :is(.sender, .subject) {
+ font-weight: bold;
+}
+
+#threadTree [data-properties~="new"]:not(.selected) .thread-card-container :is(.subject, .date) {
+ color: var(--new-folder-color);
+}
+
+/* Header bar */
+
+.list-header-bar:not([hidden]) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 3px;
+ color: var(--layout-color-1);
+ padding-inline: var(--thread-pane-header-padding);
+ background-color: var(--layout-background-1);
+ border-bottom: 1px solid var(--layout-border-0);
+}
+
+:root[lwt-tree] .list-header-bar:-moz-lwtheme {
+ background-color: color-mix(in srgb, var(--toolbar-bgcolor) 50%, transparent);
+ color: var(--toolbar-color, inherit);
+}
+
+#threadPaneFolderCountContainer {
+ flex-wrap: wrap;
+}
+
+.list-header-bar-container-start,
+.list-header-bar-container-end {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow: hidden;
+ padding: var(--thread-pane-header-padding) 3px;
+}
+
+.list-header-bar-container-start {
+ flex-shrink: 1;
+}
+
+.list-header-bar-container-end .button:focus-visible {
+ outline-offset: 1px;
+}
+
+.list-header-title {
+ font-size: 1.2rem;
+ font-weight: 600;
+ margin-block: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+}
+
+.thread-pane-count-info {
+ white-space: nowrap;
+ font-size: 1rem;
+ font-weight: normal;
+ padding-inline-start: 9px;
+}
+
+#threadPaneQuickFilterButton {
+ background-image: var(--icon-filter);
+ margin: 0;
+}
+
+#threadPaneDisplayButton {
+ background-image: var(--icon-display-options);
+ margin: 0;
+ flex-shrink: 0;
+}
diff --git a/comm/mail/themes/shared/mail/tree-listbox.css b/comm/mail/themes/shared/mail/tree-listbox.css
new file mode 100644
index 0000000000..73cf0cf4ba
--- /dev/null
+++ b/comm/mail/themes/shared/mail/tree-listbox.css
@@ -0,0 +1,528 @@
+/* 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/. */
+
+:root {
+ --listbox-selected-outline: none;
+ --tree-header-table-height: 30px;
+ --tree-header-cell-padding: 6px;
+ --tree-header-cell-indent: 0;
+ --list-item-min-height: 26px;
+ --list-item-buttons-size: 22px;
+ --table-header-cell-icon-width: 24px;
+ --table-header-cell-icon-min-width: 24px;
+ --table-row-cell-img-margin-inline-start: 6px;
+ --table-row-cell-button-margin-inline-start: 4px;
+}
+
+:root[uidensity="compact"] {
+ --tree-header-table-height: 27px;
+ --tree-header-cell-padding: 3px;
+ --tree-header-cell-indent: 3px;
+ --list-item-min-height: 18px;
+ --list-item-buttons-size: 18px;
+ --table-header-cell-icon-width: 18px;
+ --table-header-cell-icon-min-width: 18px;
+ --table-header-cell-icon-button-padding: 0;
+ --table-row-cell-img-margin-inline-start: 3px;
+ --table-row-cell-button-margin-inline-start: 1px;
+}
+
+:root[uidensity="touch"] {
+ --tree-header-table-height: 36px;
+ --tree-header-cell-padding: 9px;
+ --tree-header-cell-indent: -3px;
+ --list-item-min-height: 32px;
+ --table-header-cell-icon-width: 33px;
+ --table-header-cell-icon-min-width: 33px;
+ --table-row-cell-img-margin-inline-start: 11px;
+ --table-row-cell-button-margin-inline-start: 9px;
+}
+
+@media not (prefers-contrast) {
+ :root {
+ --listbox-color: var(--color-gray-80);
+ --listbox-selected-bg: var(--color-gray-20);
+ --listbox-focused-selected-bg: var(--selected-item-color);
+ --listbox-selected-color: var(--selected-item-text-color);
+ --listbox-focused-selected-color: var(--selected-item-text-color);
+ --listbox-hover: color-mix(in srgb, transparent 80%, var(--listbox-focused-selected-bg));
+ --listbox-border-type: dashed;
+ --tree-view-bg: var(--color-white);
+ --tree-view-color: var(--color-ink-90);
+ --tree-view-header-hover-bg: var(--color-gray-20);
+ --tree-view-header-hover-active-bg: var(--color-gray-30);
+ --tree-view-header-border-color: var(--color-gray-30);
+ --tree-row-delete-button-color: var(--color-red-60);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --listbox-color: var(--color-gray-05);
+ --listbox-selected-bg: var(--color-gray-70);
+ --listbox-focused-selected-bg: var(--color-blue-60);
+ --tree-view-bg: var(--color-gray-90);
+ --tree-view-color: var(--color-gray-30);
+ --tree-view-header-hover-bg: var(--color-gray-70);
+ --tree-view-header-hover-active-bg: var(--color-gray-90);
+ --tree-view-header-border-color: var(--splitter-color);
+ --tree-row-delete-button-color: var(--color-red-40);
+ }
+ }
+}
+
+@media (prefers-contrast) {
+ :root {
+ --listbox-color: currentColor;
+ --listbox-selected-color: SelectedItemText;
+ --listbox-focused-selected-bg: SelectedItem;
+ --listbox-selected-bg: color-mix(in srgb, transparent 80%, var(--listbox-focused-selected-bg));
+ --listbox-selected-outline: 2px solid SelectedItem;
+ --listbox-border-type: solid;
+ --tree-view-bg: Field;
+ --tree-view-color: FieldText;
+ --tree-view-header-hover-bg: color-mix(in srgb, Field 70%, hsl(0, 0%, 50%));
+ --tree-view-header-border-color: ThreeDShadow;
+ }
+}
+
+:root[lwt-tree] {
+ --listbox-selected-bg: color-mix(in srgb, transparent 70%, var(--sidebar-highlight-background-color));
+ --listbox-hover: color-mix(in srgb, transparent 80%, var(--sidebar-highlight-background-color));
+ --listbox-focused-selected-bg: var(--sidebar-highlight-background-color, var(--selected-item-color));
+ --listbox-selected-color: var(--sidebar-highlight-text-color, var(--selected-item-text-color));
+}
+
+.tree-view-scrollable-container {
+ display: flex;
+ align-items: start;
+ overflow-y: scroll;
+ overscroll-behavior-y: none;
+ height: 100%;
+}
+
+[is="tree-view-table"] img {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+[is="tree-view-table-body"]:focus {
+ outline: none;
+}
+
+[is="tree-view-table-body"] > * {
+ cursor: default;
+}
+
+[is="tree-view-table-body"] > *:hover {
+ background-color: var(--listbox-hover);
+}
+
+@media (prefers-contrast) {
+ [is="tree-view-table-body"] > *:hover {
+ outline: var(--listbox-selected-outline);
+ outline-offset: -2px;
+ }
+}
+
+[is="tree-view-table-body"] > .selected {
+ background-color: var(--listbox-selected-bg);
+}
+
+[is="tree-view-table-body"]:focus > .selected,
+[is="tree-view-table-body"]:focus-within > .selected {
+ color: var(--listbox-selected-color);
+}
+
+[is="tree-view-table-body"]:focus > .current:not(.selected),
+[is="tree-view-table-body"]:focus-within > .current:not(.selected) {
+ outline: 1px var(--listbox-border-type) var(--listbox-focused-selected-bg);
+ outline-offset: -1px;
+}
+
+[is="tree-view-table-body"]:focus > .selected,
+[is="tree-view-table-body"]:focus-within > .selected,
+[is="tree-view-table-body"] > .selected:focus-within {
+ color: var(--listbox-selected-color);
+ background-color: var(--listbox-focused-selected-bg);
+}
+
+.multi-selected [is="tree-view-table-body"] > .selected.current,
+.multi-selected [is="tree-view-table-body"]:focus > .selected.current {
+ outline: 1px var(--listbox-border-type) var(--listbox-selected-color);
+ outline-offset: -1px;
+}
+
+[is="tree-view-table-body"]:focus > .selected img,
+[is="tree-view-table-body"]:focus-within > .selected img,
+[is="tree-view-table-body"] > .selected:focus-within img {
+ fill: color-mix(in srgb, currentColor 40%, transparent);
+ stroke: currentColor;
+}
+
+[is="tree-view-table-body"]:focus > .selected .tree-view-row-thread img,
+[is="tree-view-table-body"]:focus-within > .selected .tree-view-row-thread img {
+ stroke: var(--listbox-selected-color);
+}
+
+tr:is([is="thread-row"], [is="thread-card"]).selected .twisty {
+ color: currentColor;
+}
+
+/* Resizable table columns */
+
+table[is="tree-view-table"] {
+ table-layout: fixed;
+ flex: 1 0 100%;
+ border-spacing: 0;
+ line-height: 1;
+ font-size: 1rem;
+ background-color: var(--tree-view-bg);
+ color: var(--tree-view-color);
+}
+
+body:not(.layout-table) thead[is="tree-view-table-header"] {
+ display: none;
+}
+
+thead[is="tree-view-table-header"] {
+ height: var(--tree-header-table-height);
+ position: sticky;
+ inset-inline: 0;
+ inset-block-start: 0;
+ z-index: 1;
+}
+
+thead[is="tree-view-table-header"]::after {
+ content: '';
+ position: absolute;
+ inset-inline: 0;
+ height: 0;
+ z-index: 1;
+ border-bottom: 1px solid var(--sidebar-border-color, var(--tree-view-header-border-color));
+}
+
+thead[is="tree-view-table-header"] hr[is="pane-splitter"] {
+ position: absolute;
+ inset-inline-end: 4px;
+ inset-block: 3px;
+}
+
+thead[is="tree-view-table-header"] hr[is="pane-splitter"]:hover:not(:active,[disabled]) {
+ background-color: var(--selected-item-color);
+}
+
+th[is="tree-view-table-header-cell"] {
+ height: var(--tree-header-table-height);
+ min-width: 7ch;
+ padding: 0;
+ position: relative;
+ overflow: hidden;
+ background-color: var(--tree-view-bg);
+}
+
+th[is="tree-view-table-header-cell"][data-type="icon"] {
+ width: var(--table-header-cell-icon-width);
+ min-width: var(--table-header-cell-icon-min-width);
+}
+
+th[is="tree-view-table-header-cell"][data-type="icon"] button {
+ padding: var(--table-header-cell-icon-button-padding, var(--tree-header-cell-padding));
+}
+
+th[is="tree-view-table-header-cell"][data-type="icon"] img {
+ display: block;
+ margin-inline: auto;
+}
+
+th[is="tree-view-table-header-cell"] hr[is="pane-splitter"] {
+ border-inline-start-style: none !important;
+ border-inline-end-style: solid;
+}
+
+/* Select column */
+
+#selectColButton {
+ padding-inline: 0;
+}
+
+:is(.tree-view-header-select, .tree-view-row-select) img {
+ color: var(--tree-view-color);
+ content: var(--icon-checkbox);
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ fill: currentColor;
+ fill-opacity: 0;
+ stroke-opacity: 0;
+}
+
+[is="tree-view-table-body"]:focus >
+ .selected :is(.tree-view-header-select, .tree-view-row-select) img,
+[is="tree-view-table-body"]:focus-within >
+ .selected :is(.tree-view-header-select, .tree-view-row-select) img,
+[is="tree-view-table-body"] > .selected:focus-within
+ :is(.tree-view-header-select, .tree-view-row-select) img {
+ color: currentColor;
+}
+
+.tree-view-row-select img {
+ display: block;
+ margin-inline: auto;
+}
+
+.some-selected .tree-view-header-select img {
+ stroke-opacity: 1;
+}
+
+:is(.all-selected, .selected) :is(.tree-view-header-select, .tree-view-row-select) img {
+ fill-opacity: 1;
+}
+
+[is="tree-view-table-body"]:focus .selected > .tree-view-row-select img,
+[is="tree-view-table-body"]:focus-within .selected > .tree-view-row-select img,
+[is="tree-view-table-body"] .selected:focus-within > .tree-view-row-select img {
+ fill: currentColor;
+}
+
+/* Delete column */
+
+.tree-table-cell-container button.tree-view-header-delete {
+ text-align: center;
+}
+
+.tree-view-row-delete button {
+ color: var(--tree-view-color);
+ opacity: 0.8;
+}
+
+.tree-view-row-delete button:hover {
+ opacity: 1;
+ color: var(--tree-row-delete-button-color);
+}
+
+[is="tree-view-table-body"]:focus .selected .tree-view-row-delete button,
+[is="tree-view-table-body"]:focus-within .selected .tree-view-row-delete button,
+[is="tree-view-table-body"] .selected:focus-within .tree-view-row-delete button {
+ color: currentColor;
+}
+
+.tree-view-header-delete img,
+.tree-view-row-delete img {
+ content: var(--icon-trash-sm);
+ pointer-events: none;
+}
+
+.tree-table-cell {
+ position: absolute;
+ inset-block: 0;
+ inset-inline: 0 1px;
+}
+
+th[data-resizable="false"] .tree-table-cell {
+ inset-inline: 0;
+}
+
+.tree-table-cell-container button {
+ font-size: 1rem;
+ font-weight: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-indent: var(--tree-header-cell-indent);
+ white-space: nowrap;
+ text-align: start;
+ padding: var(--tree-header-cell-padding);
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ min-width: auto;
+ min-height: auto;
+ color: inherit;
+ border: none;
+ border-radius: 0;
+ background-color: transparent;
+ background-position: right 6px center;
+ background-repeat: no-repeat;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.tree-table-cell-container button:hover {
+ background-color: var(--tree-view-header-hover-bg);
+}
+
+.tree-table-cell-container button:hover:active {
+ background-color: var(--tree-view-header-hover-active-bg);
+}
+
+.tree-table-cell-container button:focus-visible {
+ outline: 2px solid var(--selected-item-color);
+ outline-offset: -2px;
+ border-radius: var(--button-border-radius);
+}
+
+.tree-table-cell-container button.sorting {
+ background-image: var(--icon-nav-up-sm);
+ padding-inline-end: 18px;
+}
+
+.tree-table-cell-container button.sorting.descending {
+ background-image: var(--icon-nav-down-sm);
+}
+
+@media (-moz-platform: linux) {
+ .tree-table-cell-container button.sorting {
+ background-image: var(--icon-nav-down-sm);
+ }
+
+ .tree-table-cell-container button.sorting.descending {
+ background-image: var(--icon-nav-up-sm);
+ }
+}
+
+th[data-type="icon"] .tree-table-cell-container button.sorting {
+ background-image: none;
+ padding-inline-end: var(--tree-header-cell-padding);
+}
+
+th[is="tree-view-table-column-picker"] {
+ position: relative;
+ width: 25px;
+ min-width: 25px;
+ padding: 0;
+ background-color: var(--tree-view-bg);
+}
+
+@media (-moz-overlay-scrollbars) {
+ th[is="tree-view-table-column-picker"] {
+ padding-inline-end: env(scrollbar-inline-size);
+ }
+}
+
+.button-column-picker {
+ position: absolute;
+ inset: 0;
+}
+
+.button-column-picker img {
+ content: var(--icon-column-menu);
+ margin-inline: 0;
+}
+
+/* Table body */
+
+table[is="tree-view-table"] td {
+ max-width: 0;
+ height: inherit;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding: 0 6px;
+ position: relative;
+ user-select: none;
+ box-sizing: border-box;
+}
+
+table[is="tree-view-table"] td:is(.button-column, .tree-view-row-select) {
+ padding: 0;
+ text-align: center;
+}
+
+table[is="tree-view-table"] td.button-column[colspan="2"] {
+ text-align: start;
+}
+
+table[is="tree-view-table"] td.button-column[colspan="2"] img {
+ margin-inline-start: var(--table-row-cell-img-margin-inline-start);
+}
+
+#threadTree table[is="tree-view-table"] td.button-column[colspan="2"] button {
+ margin-inline-start: var(--table-row-cell-button-margin-inline-start);
+}
+
+#threadTree table[is="tree-view-table"] td.button-column[colspan="2"] button.tree-button-delete {
+ margin-inline-start: calc(var(--table-row-cell-button-margin-inline-start) + 2px);
+}
+
+table[is="tree-view-table"] td div:not(.recipient-avatar) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Placeholder */
+
+slot[name="placeholders"] {
+ position: absolute;
+ display: none;
+ box-sizing: border-box;
+ inset: 120px 0 auto;
+ padding: 0 40px;
+ opacity: 0.5;
+ text-align: center;
+}
+
+slot[name="placeholders"].show {
+ display: block;
+}
+
+slot[name="placeholders"] > div {
+ font-size: 1.5rem;
+ line-height: 1.2;
+ font-weight: 600;
+ margin-block-end: 12px;
+ text-shadow: 0 1px 0 var(--sidebar-background-color, var(--tree-view-bg));
+}
+
+slot[name="placeholders"] div::before {
+ content: "";
+ display: block;
+ height: 32px;
+ margin-block-end: 9px;
+ background-position: center top;
+ background-size: contain;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill, stroke, fill-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+/* Transitions and animations */
+
+@media (prefers-reduced-motion: no-preference) {
+ .tree-view-scrollable-container {
+ scroll-behavior: smooth;
+ }
+
+ thead[is="tree-view-table-header"] hr[is="pane-splitter"] {
+ transition: background-color var(--transition-duration) var(--transition-timing);
+ }
+
+ table[is="tree-view-table"] tbody tr {
+ transition:
+ color var(--transition-duration) var(--transition-timing),
+ background-color var(--transition-duration) var(--transition-timing);
+ }
+
+ table[is="tree-view-table"] tbody tr img {
+ transition:
+ color var(--transition-duration) var(--transition-timing),
+ fill var(--transition-duration) var(--transition-timing),
+ stroke var(--transition-duration) var(--transition-timing);
+ }
+}
+
+:is(ul, ol):is([role="tree"],
+[role="group"]) li > div {
+ min-height: var(--list-item-min-height);
+}
+
+.no-overscroll {
+ overscroll-behavior-y: none;
+}
+
+/* Drag and Drop */
+
+th.column-dragging {
+ opacity: 0.7;
+ z-index: 2;
+}
diff --git a/comm/mail/themes/shared/mail/unifiedToolbar.css b/comm/mail/themes/shared/mail/unifiedToolbar.css
new file mode 100644
index 0000000000..4f725e13ed
--- /dev/null
+++ b/comm/mail/themes/shared/mail/unifiedToolbar.css
@@ -0,0 +1,242 @@
+/* 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 url("chrome://messenger/skin/shared/unifiedToolbarShared.css");
+
+:root {
+ --unified-toolbar-padding: 4px;
+ --unified-drag-space: 30px;
+ --unified-customization-padding: 30px;
+ --unified-toolbar-content-min-height: 28px;
+}
+
+:root[uidensity="compact"] {
+ --unified-toolbar-padding: 1px;
+ --unified-drag-space: 15px;
+ --unified-toolbar-content-min-height: 24px;
+}
+
+:root[uidensity="touch"] {
+ --unified-toolbar-padding: 7px;
+}
+
+:root[sizemode="fullscreen"],
+:root:not([tabsintitlebar]) {
+ --unified-drag-space: 3px;
+}
+
+unified-toolbar {
+ display: block;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.15);
+}
+
+#unifiedToolbarContainer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ -moz-window-dragging: drag;
+ width: 100vw;
+}
+
+/* minheight for the macOS titlebar */
+.customizingUnifiedToolbar #unifiedToolbarContainer {
+ min-height: 22px;
+}
+
+#unifiedToolbar {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ padding-block: var(--unified-toolbar-padding);
+ min-width: 0;
+ overflow: hidden;
+}
+
+#unifiedToolbar .button:is(.toolbar-button, .unified-toolbar-button) {
+ margin-block: 0;
+}
+
+#unifiedToolbarContainer .titlebar-buttonbox-container {
+ flex: 0 0 auto;
+}
+
+@media (-moz-platform: windows) {
+ #unifiedToolbarContainer .titlebar-buttonbox-container {
+ align-self: stretch;
+ }
+}
+
+@media (-moz-gtk-csd-reversed-placement) {
+ #unifiedToolbarContainer {
+ flex-direction: row-reverse;
+ }
+}
+
+#unifiedToolbarContent {
+ flex: 1 1 auto;
+ min-height: var(--unified-toolbar-content-min-height);
+ margin: 0;
+ /* Padding needed for the children's focus ring. */
+ padding-block: 3px;
+ padding-inline: var(--unified-drag-space) 3px;
+}
+
+#unifiedToolbarContent li {
+ -moz-window-dragging: no-drag;
+}
+
+#unifiedToolbarContent .unified-toolbar-button[disabled] {
+ opacity: 0.4;
+}
+
+#unifiedToolbarContent .spacer {
+ -moz-window-dragging: drag;
+}
+
+unified-toolbar-customization {
+ display: none;
+}
+
+/* customizing unified toolbar */
+
+/* TODO this approach will have issues with spaces toolbar paddings */
+
+/* we still need the space to be taken up for correct alignment of window decorations */
+.customizingUnifiedToolbar #unifiedToolbar {
+ visibility: hidden;
+}
+
+.customizingUnifiedToolbar #unifiedToolbar > *,
+.customizingUnifiedToolbar #messengerBody,
+.customizingUnifiedToolbar #spacesToolbar,
+.customizingUnifiedToolbar #toolbar-menubar,
+.customizingUnifiedToolbar #tabs-toolbar {
+ display: none;
+}
+
+.customizingUnifiedToolbar unified-toolbar-customization {
+ display: flex;
+ height: 100%;
+ background-color: color-mix(in srgb, var(--layout-background-0) 50%, var(--color-black));
+ color: var(--layout-color-1);
+ flex: 1;
+ overflow: hidden;
+ padding: var(--unified-customization-padding);
+}
+
+#customizationHeading {
+ align-self: center;
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: var(--layout-color-2);
+ margin-block: .5em;
+}
+
+#unifiedToolbarCustomizationContainer {
+ background-color: var(--layout-background-3);
+ border-radius: 6px;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ --customization-footer-padding: 12px;
+ box-shadow:
+ 0 2px 12px -3px rgba(0, 0, 0, 0.5),
+ 0 2px 24px -6px rgba(0, 0, 0, 0.5);
+}
+
+#unifiedToolbarCustomizationContainer div[role="tabpanel"] {
+ flex-grow: 1;
+}
+
+#customizationFooter {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ user-select: none;
+ background: var(--layout-background-1);
+ border-radius: 0 0 6px 6px;
+ /* We need at least 1rem of padding to ensure the
+ * #unifiedToolbarCustomizationUnsavedChanges can be displayed in it */
+ padding-block: max(calc(var(--customization-footer-padding) * 2), 1rem);
+ padding-inline: var(--customization-footer-padding);
+}
+
+#buttonStyleContainer {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+
+#customizationFooter div:last-child {
+ text-align: end;
+}
+
+#unifiedToolbarCustomizationUnsavedChanges {
+ position: fixed;
+ inset-block-end: calc((var(--customization-footer-padding) * 2 - 1em) / 2 + var(--unified-customization-padding));
+ inset-inline-end: calc(var(--customization-footer-padding) + var(--unified-customization-padding));
+}
+
+#customizationTabs {
+ display: flex;
+ flex-wrap: nowrap;
+ max-width: 100vw;
+ background-color: var(--tabs-toolbar-background-color);
+ box-shadow: var(--tabs-toolbar-box-shadow);
+ padding-top: 3px;
+ padding-inline: 6px;
+}
+
+unified-toolbar-customization-pane:not([hidden]) {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--layout-background-1);
+}
+
+unified-toolbar-tab {
+ min-width: 28px;
+ overflow-x: hidden;
+ display: block;
+}
+
+unified-toolbar-tab::part(icon) {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ content: var(--webextension-toolbar-image, var(--icon-extension));
+ max-width: 16px;
+ max-height: 16px;
+}
+
+#unified-toolbar-customization-tab-mail::part(icon) {
+ content: var(--icon-mail);
+}
+
+#unified-toolbar-customization-tab-calendar::part(icon) {
+ content: var(--icon-calendar);
+}
+
+#unified-toolbar-customization-tab-tasks::part(icon) {
+ content: var(--icon-tasks);
+}
+
+#unified-toolbar-customization-tab-addressbook::part(icon) {
+ content: var(--icon-address-book);
+}
+
+#unified-toolbar-customization-tab-chat::part(icon) {
+ content: var(--icon-chat);
+}
+
+#unified-toolbar-customization-tab-settings::part(icon) {
+ content: var(--icon-settings);
+}
+
+:is(.live-content, .button-appmenu, #spacesPinnedButton):-moz-window-inactive {
+ opacity: 0.6;
+}
diff --git a/comm/mail/themes/shared/mail/unifiedToolbarCustomizableItems.css b/comm/mail/themes/shared/mail/unifiedToolbarCustomizableItems.css
new file mode 100644
index 0000000000..5cc472abeb
--- /dev/null
+++ b/comm/mail/themes/shared/mail/unifiedToolbarCustomizableItems.css
@@ -0,0 +1,197 @@
+/* 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/. */
+
+/* Icon definitions for customizable item previews.
+ * CustomizableItemsDetails.mjs contains the definitions for the root class
+ * names. */
+
+[is="customizable-element"] .preview-icon {
+ content: var(--icon-extension);
+}
+
+.spacer .preview-icon {
+ content: var(--icon-flexible-space);
+}
+
+.search-bar .preview-icon {
+ content: var(--icon-search);
+}
+
+.write-message .preview-icon {
+ content: var(--icon-new-mail);
+}
+
+.move-to .preview-icon {
+ content: var(--icon-file);
+}
+
+.unifinder .preview-icon {
+ content: var(--icon-search);
+}
+
+.folder-location .preview-icon {
+ content: var(--icon-folder);
+}
+
+.edit-event .preview-icon {
+ content: var(--icon-pencil);
+}
+
+.get-messages .preview-icon {
+ content: var(--icon-cloud-download);
+}
+
+.reply .preview-icon {
+ content: var(--icon-reply);
+}
+
+.reply-all .preview-icon {
+ content: var(--icon-reply-all);
+}
+
+.reply-to-list .preview-icon {
+ content: var(--icon-reply-list);
+}
+
+.redirect .preview-icon {
+ content: var(--icon-redirect);
+}
+
+.archive .preview-icon {
+ content: var(--icon-archive);
+}
+
+.conversation .preview-icon {
+ content: var(--icon-conversation);
+}
+
+.previous-unread .preview-icon {
+ content: var(--icon-nav-up-unread);
+}
+
+.previous .preview-icon {
+ content: var(--icon-nav-up);
+}
+
+.next-unread .preview-icon {
+ content: var(--icon-nav-down-unread);
+}
+
+.next .preview-icon {
+ content: var(--icon-nav-down);
+}
+
+.junk .preview-icon {
+ content: var(--icon-spam);
+}
+
+.delete .preview-icon {
+ content: var(--icon-trash);
+}
+
+.compact .preview-icon {
+ content: var(--icon-compress);
+}
+
+.add-as-event .preview-icon {
+ content: var(--icon-new-event);
+}
+
+.add-as-task .preview-icon {
+ content: var(--icon-new-task);
+}
+
+.tag-message .preview-icon {
+ content: var(--icon-tag);
+}
+
+.forward-inline .preview-icon {
+ content: var(--icon-forward);
+}
+
+.forward-attachment .preview-icon {
+ /* TODO separate icon for forwarding as attachment */
+ content: var(--icon-forward);
+}
+
+.mark-as .preview-icon {
+ content: var(--icon-unread);
+}
+
+.view-picker .preview-icon {
+ content: var(--icon-eye);
+}
+
+.address-book .preview-icon {
+ content: var(--icon-address-book);
+}
+
+.chat .preview-icon {
+ content: var(--icon-chat)
+}
+
+.add-ons-and-themes .preview-icon {
+ content: var(--icon-extension);
+}
+
+.calendar .preview-icon {
+ content: var(--icon-calendar);
+}
+
+.tasks .preview-icon {
+ content: var(--icon-tasks)
+}
+
+.mail .preview-icon {
+ content: var(--icon-mail);
+}
+
+.print .preview-icon {
+ content: var(--icon-print);
+}
+
+.quick-filter-bar .preview-icon {
+ content: var(--icon-filter);
+}
+
+.synchronize .preview-icon {
+ content: var(--icon-sync);
+}
+
+.new-event .preview-icon {
+ content: var(--icon-new-event);
+}
+
+.new-task .preview-icon {
+ content: var(--icon-new-task);
+}
+
+.delete-event .preview-icon {
+ content: var(--icon-trash);
+}
+
+.print-event .preview-icon {
+ content: var(--icon-print);
+}
+
+.go-to-today .preview-icon {
+ content: var(--icon-calendar-today);
+}
+
+.go-back .preview-icon {
+ content: var(--icon-nav-back);
+}
+
+.go-forward .preview-icon {
+ content: var(--icon-nav-forward);
+}
+
+.stop .preview-icon {
+ content: var(--icon-close);
+}
+
+.throbber .preview-icon {
+ content: var(--icon-loading);
+ object-fit: cover;
+}
diff --git a/comm/mail/themes/shared/mail/unifiedToolbarCustomizationPane.css b/comm/mail/themes/shared/mail/unifiedToolbarCustomizationPane.css
new file mode 100644
index 0000000000..bd8f73795e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/unifiedToolbarCustomizationPane.css
@@ -0,0 +1,196 @@
+/* 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 is the main stylesheet in the unified-toolbar-customization-pane shadowRoot. */
+
+@import url("chrome://messenger/skin/widgets.css");
+@import url("chrome://messenger/skin/shared/unifiedToolbarCustomizableItems.css");
+@import url("chrome://messenger/skin/shared/unifiedToolbarShared.css");
+
+.search-button-icon {
+ content: var(--icon-search);
+}
+
+.palette-search {
+ display: block;
+ margin: 12px 0;
+ max-width: 50ch;
+ height: 2em;
+ width: 100%;
+ align-self: center;
+}
+
+[is="customization-target"],
+[is="customization-palette"] {
+ display: flex;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ flex-shrink: 0;
+}
+
+[is="customization-target"]:focus-visible,
+[is="customization-palette"]:focus-visible {
+ outline: var(--focus-outline);
+ outline-color: transparent;
+}
+
+.toolbar-target {
+ /* TODO this should match actual titlebar dimensions */
+ background: var(--layout-background-3);
+ border-radius: 6px;
+ padding-inline: 9px;
+ padding-block: var(--unified-toolbar-padding);
+ margin: 1rem;
+ max-width: 100vw;
+ border: 1px solid var(--layout-border-0);
+ gap: 6px;
+ min-height: calc(1rem + 3 * var(--button-padding));
+}
+
+.toolbar-target .button.unified-toolbar-button {
+ margin-block: 0;
+}
+
+.toolbar-target .spacer {
+ color: color-mix(in srgb, var(--layout-color-1) 50%, transparent);
+ background-image: linear-gradient(to left, currentColor 0%, currentColor 50%, transparent 50%, transparent 100%);
+ background-size: 9px 1px;
+ background-position: 5px center;
+ background-repeat: repeat-x;
+ position: relative;
+ height: 100%;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.toolbar-target .spacer::before,
+.toolbar-target .spacer::after {
+ content: '';
+ width: 16px;
+ position: absolute;
+ height: 100%;
+ background-repeat: no-repeat;
+ background-position-y: center;
+}
+
+.toolbar-target .spacer::before {
+ background-image: var(--icon-nav-left);
+ left: -5px;
+}
+
+.toolbar-target .spacer::after {
+ background-image: var(--icon-nav-right);
+ right: -5px;
+}
+
+.toolbar-target .throbber .throbber-icon {
+ visibility: visible;
+}
+
+/* buttons are disabled in the preview, but we want them to look active. */
+.toolbar-target .unified-toolbar-button[disabled] {
+ color: inherit;
+ opacity: 1;
+}
+
+[is="customization-target"] [is="customizable-element"] .live-content {
+ pointer-events: none;
+}
+
+[is="customization-target"] [is="customizable-element"] .preview {
+ display: none;
+}
+
+[is="customization-target"] .collapsed {
+ display: none;
+}
+
+.customization-palettes {
+ overflow: auto;
+}
+
+[is="customization-palette"] {
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin: 1rem;
+}
+
+[is="customization-palette"]:not(:last-of-type) {
+ margin-block-end: 2.5rem;
+}
+
+h2 {
+ margin-inline: 1rem;
+ user-select: none;
+}
+
+[is="customizable-element"] {
+ margin: 0;
+ padding: 0;
+}
+
+[is="customization-target"]:focus-within [is="customizable-element"][aria-selected="true"],
+[is="customization-palette"]:focus-within [is="customizable-element"][aria-selected="true"] {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+[is="customization-palette"] [is="customizable-element"] {
+ width: 96px;
+ height: calc(25px + 2.4rem); /* icon: 16px, gap: 9px, 2.4rem = ~2lh */
+ background: var(--layout-background-3);
+ padding: 9px;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+[is="customization-palette"] [is="customizable-element"] .live-content {
+ display: none;
+}
+
+.preview {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ overflow: hidden;
+ gap: 9px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.preview-icon {
+ height: 16px;
+ width: 16px;
+ object-fit: contain;
+ pointer-events: none;
+}
+
+.preview-label {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ overflow-wrap: break-word;
+ text-overflow: ellipsis;
+ user-select: none;
+ padding-inline: 3px;
+ max-width: 100%;
+}
+
+/* Drag and drop state styling */
+
+.drop-before {
+ border-inline-start: 1px solid currentColor;
+}
+
+.drop-after {
+ border-inline-end: 1px solid currentColor;
+}
+
+.dragging {
+ visibility: hidden;
+}
diff --git a/comm/mail/themes/shared/mail/unifiedToolbarShared.css b/comm/mail/themes/shared/mail/unifiedToolbarShared.css
new file mode 100644
index 0000000000..7f304fa944
--- /dev/null
+++ b/comm/mail/themes/shared/mail/unifiedToolbarShared.css
@@ -0,0 +1,408 @@
+/* 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 url("chrome://messenger/content/unifiedtoolbar/unifiedToolbarWebextensions.css");
+
+/* Styles shared between the actual unfied toolbar and the replica in the
+ * customization overlay */
+
+:root {
+ --toolbar-button-badge-text-color: var(--color-white);
+ --toolbar-button-badge-bg-color: var(--color-red-70);
+ --toolbar-button-badge-padding: 1px 3px;
+ --toolbar-button-badge-font: 0.8rem;
+ --badge-inset-block-start: 1px;
+ --badge-inset-inline-nudge: 0px;
+ --badge-inset-inline-end: 2px;
+}
+
+/* UI Density customization */
+:root[uidensity="compact"] {
+ --badge-inset-block-start: 0px;
+ --badge-inset-inline-nudge: 4px;
+ --badge-inset-inline-end: 0px;
+}
+
+:root[uidensity="touch"] {
+ --badge-inset-block-start: 4px;
+ --badge-inset-inline-nudge: -3px;
+ --badge-inset-inline-end: 3px;
+}
+
+@media (-moz-windows-accent-color-in-titlebar) {
+ /* Apply everywhere */
+ :root {
+ --windows-accent-outline-color: var(--focus-outline-color);
+ }
+ /* Apply only in unified toolbar */
+ .search-bar {
+ --windows-accent-outline-color: accentColorText;
+ }
+}
+
+.unified-toolbar {
+ display: flex;
+ justify-content: start;
+ align-items: center;
+ list-style-type: none;
+ overflow: hidden;
+ flex-wrap: nowrap;
+ min-width: 0;
+}
+
+.unified-toolbar li {
+ padding: 0;
+ margin: 0;
+ border-radius: 1px;
+}
+
+.unified-toolbar [is="customizable-element"] .preview {
+ display: none;
+}
+
+.unified-toolbar-button {
+ display: flex;
+ gap: 3px;
+ align-items: center;
+ flex-wrap: nowrap;
+ min-width: initial;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+.icons-above-text .unified-toolbar-button {
+ flex-direction: column;
+ justify-content: space-around;
+}
+
+.icons-only .unified-toolbar-button .button-label {
+ display: none;
+}
+
+.text-only .unified-toolbar-button .button-icon {
+ display: none;
+}
+
+.unified-toolbar-button[popup] {
+ padding-inline-end: 20px;
+ background-image: var(--icon-nav-down-sm);
+ background-position: calc(100% - 4px) center;
+ background-repeat: no-repeat;
+}
+
+.unified-toolbar-button[popup]:dir(rtl) {
+ background-position-x: 4px;
+}
+
+.unified-toolbar-button[badge] {
+ position: relative;
+}
+
+.unified-toolbar-button[badge]::after {
+ content: attr(badge);
+ background-color: var(--toolbar-button-badge-bg-color);
+ border-radius: 12px;
+ padding: var(--toolbar-button-badge-padding);
+ font-weight: 600;
+ font-size: var(--toolbar-button-badge-font);
+ color: var(--toolbar-button-badge-text-color);
+ line-height: 1em;
+ position: absolute;
+ inset-inline-end: var(--badge-inset-inline-end);
+ inset-block-start: var(--badge-inset-block-start);
+ max-width: 3ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-overflow: clip;
+}
+
+.unified-toolbar-button[popup][badge]::after {
+ inset-inline-end: 14px;
+}
+
+.unified-toolbar-button[badge]:-moz-window-inactive::after {
+ background-color: color-mix(in srgb, var(--toolbar-button-badge-bg-color) 50%, black);
+}
+
+/* If the text is shown after the icon, move the badge to be over the icon */
+.icons-beside-text .unified-toolbar-button[badge]::after {
+ inset-inline-end: unset;
+ inset-inline-start: calc(var(--icon-size) - var(--badge-inset-inline-nudge));
+ inset-block-start: var(--badge-inset-block-start);
+}
+
+/* With text only, just show the badge after the label */
+.text-only .unified-toolbar-button[badge]::after {
+ position: static;
+}
+
+/* If we are showing the text below the icon, move the badge in toward the
+ * center of the button. */
+.icons-above-text .unified-toolbar-button[badge]::after {
+ inset-inline-end: unset;
+ inset-inline-start: 51%;
+}
+
+.icons-above-text .unified-toolbar-button[popup][badge]::after {
+ inset-inline-start: unset;
+ inset-inline-end: 38%;
+}
+
+.unified-toolbar-button .button-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex-shrink: 1;
+ max-width: 50ch;
+ pointer-events: none;
+}
+
+global-search-bar:not([hidden]) {
+ display: block;
+ color: var(--layout-color-0);
+}
+
+global-search-bar:not([hidden]):-moz-lwtheme {
+ color: var(--toolbar-field-color);
+ text-shadow: none;
+}
+
+kbd {
+ background-color: var(--layout-background-3);
+ color: var(--layout-color-2);
+ text-transform: uppercase;
+ font-size: 0.8rem;
+ line-height: 1;
+ font-weight: bold;
+ box-shadow: inset 0px -1px 0px var(--layout-border-2);
+ border-radius: 3px;
+ display: inline-block;
+ padding: 2px 4px;
+}
+
+kbd:first-of-type {
+ margin-inline-start: 6px;
+}
+
+span[slot="placeholder"] {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+
+.unified-toolbar .spacer {
+ flex: 1 1 auto;
+}
+
+.unified-toolbar .search-bar {
+ flex: 1 1 33%;
+ min-width: 5em;
+}
+
+.unified-toolbar .button-icon {
+ content: var(--icon-extension);
+}
+
+.unified-toolbar .search-button-icon {
+ content: var(--icon-search);
+}
+
+.unified-toolbar .write-message .button-icon {
+ content: var(--icon-new-mail);
+}
+
+.unified-toolbar .move-to .button-icon {
+ content: var(--icon-file);
+}
+
+.unified-toolbar .unifinder .button-icon {
+ content: var(--icon-search);
+}
+
+.unified-toolbar .folder-location .button-icon {
+ content: var(--icon-folder);
+}
+
+.unified-toolbar .edit-event .button-icon {
+ content: var(--icon-pencil);
+}
+
+.unified-toolbar .get-messages .button-icon {
+ content: var(--icon-cloud-download);
+}
+
+.unified-toolbar .reply .button-icon {
+ content: var(--icon-reply);
+}
+
+.unified-toolbar .reply-all .button-icon {
+ content: var(--icon-reply-all);
+}
+
+.unified-toolbar .reply-to-list .button-icon {
+ content: var(--icon-reply-list);
+}
+
+.unified-toolbar .redirect .button-icon {
+ content: var(--icon-redirect);
+}
+
+.unified-toolbar .archive .button-icon {
+ content: var(--icon-archive);
+}
+
+.unified-toolbar .conversation .button-icon {
+ content: var(--icon-conversation);
+}
+
+.unified-toolbar .previous-unread .button-icon {
+ content: var(--icon-nav-up-unread);
+}
+
+.unified-toolbar .previous .button-icon {
+ content: var(--icon-nav-up);
+}
+
+.unified-toolbar .next-unread .button-icon {
+ content: var(--icon-nav-down-unread);
+}
+
+.unified-toolbar .next .button-icon {
+ content: var(--icon-nav-down);
+}
+
+.unified-toolbar .junk .button-icon {
+ content: var(--icon-spam);
+}
+
+.unified-toolbar .delete .button-icon {
+ content: var(--icon-trash);
+}
+
+.unified-toolbar .compact .button-icon {
+ content: var(--icon-compress);
+}
+
+.unified-toolbar .add-as-event .button-icon {
+ content: var(--icon-new-event);
+}
+
+.unified-toolbar .add-as-task .button-icon {
+ content: var(--icon-new-task);
+}
+
+.unified-toolbar .tag-message .button-icon {
+ content: var(--icon-tag);
+}
+
+.unified-toolbar .forward-inline .button-icon {
+ content: var(--icon-forward);
+}
+
+.unified-toolbar .forward-attachment .button-icon {
+ /* TODO separate icon for forwarding as attachment */
+ content: var(--icon-forward);
+}
+
+.unified-toolbar .mark-as .button-icon {
+ content: var(--icon-unread);
+}
+
+.unified-toolbar .view-picker .button-icon {
+ content: var(--icon-eye);
+}
+
+.unified-toolbar .address-book .button-icon {
+ content: var(--icon-address-book);
+}
+
+.unified-toolbar .chat .button-icon {
+ content: var(--icon-chat)
+}
+
+.unified-toolbar .add-ons-and-themes .button-icon {
+ content: var(--icon-extension);
+}
+
+.unified-toolbar .calendar .button-icon {
+ content: var(--icon-calendar);
+}
+
+.unified-toolbar .tasks .button-icon {
+ content: var(--icon-tasks)
+}
+
+.unified-toolbar .mail .button-icon {
+ content: var(--icon-mail);
+}
+
+.unified-toolbar .print .button-icon {
+ content: var(--icon-print);
+}
+
+.unified-toolbar .quick-filter-bar .button-icon {
+ content: var(--icon-filter);
+}
+
+.unified-toolbar .synchronize .button-icon {
+ content: var(--icon-sync);
+}
+
+.unified-toolbar .new-event .button-icon {
+ content: var(--icon-new-event);
+}
+
+.unified-toolbar .new-task .button-icon {
+ content: var(--icon-new-task);
+}
+
+.unified-toolbar .delete-event .button-icon {
+ content: var(--icon-trash);
+}
+
+.unified-toolbar .print-event .button-icon {
+ content: var(--icon-print);
+}
+
+.unified-toolbar .go-to-today .button-icon {
+ content: var(--icon-calendar-today);
+}
+
+.unified-toolbar .go-back .button-icon {
+ content: var(--icon-nav-back);
+}
+
+.unified-toolbar .go-forward .button-icon {
+ content: var(--icon-nav-forward);
+}
+
+.unified-toolbar .stop .button-icon {
+ content: var(--icon-close);
+}
+
+.unified-toolbar .throbber .throbber-icon {
+ stroke: var(--button-primary-background-color);
+ -moz-context-properties: stroke, fill;
+ width: 16px;
+ height: 16px;
+ visibility: hidden;
+ margin: var(--button-margin);
+ vertical-align: middle;
+ content: var(--icon-loading);
+ object-fit: cover;
+}
+
+.unified-toolbar .throbber.busy .throbber-icon {
+ visibility: visible;
+ object-position: 0px 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .unified-toolbar .throbber.busy .throbber-icon {
+ animation: activity-indicator-throbber 1.05s steps(30) infinite;
+ }
+
+ @keyframes activity-indicator-throbber {
+ 100% { object-position: -480px 0; }
+ }
+}
diff --git a/comm/mail/themes/shared/mail/unifiedToolbarTab.css b/comm/mail/themes/shared/mail/unifiedToolbarTab.css
new file mode 100644
index 0000000000..51004ed49e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/unifiedToolbarTab.css
@@ -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/. */
+
+button {
+ padding-inline: 9px;
+ padding-block: 10px 6px;
+ box-sizing: border-box;
+ border-radius: 4px 4px 0 0;
+ cursor: default;
+ user-select: none;
+ appearance: none;
+ background: none;
+ border: 2px solid transparent;
+ border-bottom: none;
+ display: flex;
+ align-content: center;
+ gap: 6px;
+ width: 100%;
+ font-size: inherit;
+ position: relative;
+}
+
+button[aria-selected="true"] {
+ background-color: var(--layout-background-1);
+ box-shadow: 0 2px 8px -5px var(--color-black);
+}
+
+button::before {
+ content: '';
+ height: 2px;
+ margin-top: 3px;
+ margin-inline: 3px;
+ border-radius: 3px;
+ position: absolute;
+ inset-inline: 2px;
+ top: 0;
+}
+
+button[aria-selected="true"]::before {
+ background-color: var(--primary);
+}
+
+button:not([aria-selected="true"]):hover {
+ background-color: color-mix(in srgb, var(--layout-background-1) 50%, transparent);
+}
+
+button:not([aria-selected="true"]):hover::before {
+ background-color: color-mix(in srgb, var(--layout-color-1) 15%, transparent);
+}
+
+button:focus-visible {
+ outline: 2px solid transparent;
+ border-color: var(--primary);
+}
+
+span {
+ flex-shrink: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/comm/mail/themes/shared/mail/variables.css b/comm/mail/themes/shared/mail/variables.css
new file mode 100644
index 0000000000..f1443d1b3e
--- /dev/null
+++ b/comm/mail/themes/shared/mail/variables.css
@@ -0,0 +1,291 @@
+/* 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 url("chrome://messenger/skin/layout.css");
+
+/* Default variables */
+
+:host,
+:root {
+ --transition-duration: 120ms;
+ --transition-timing: ease;
+ --arrowpanel-background: var(--layout-background-1);
+ --arrowpanel-color: var(--layout-color-1);
+ --arrowpanel-border-color: var(--layout-border-1);
+ --lwt-additional-images: none;
+ --lwt-background-alignment: right top;
+ --lwt-background-tiling: no-repeat;
+ --toolbar-bgcolor: var(--toolbar-non-lwt-bgcolor);
+ --toolbar-color: var(--toolbar-non-lwt-textcolor);
+ --panelui-subview-transition-duration: 150ms;
+ --autocomplete-popup-highlight-background: var(--selected-item-color);
+ --autocomplete-popup-highlight-color: var(--selected-item-text-color);
+ --button-background-color: var(--layout-background-3);
+ --button-hover-text-color: inherit;
+ --button-hover-background-color: var(--layout-background-2);
+ --button-active-background-color: var(--layout-background-4);
+ --button-border-color: var(--layout-border-1);
+ --button-border-radius: 3px;
+ --button-border-size: 1px;
+ --button-text-color: var(--layout-color-1);
+ --button-margin: 6px;
+ --button-padding: 6px;
+ --button-primary-background-color: var(--color-blue-60);
+ --button-primary-hover-background-color: color-mix(in srgb, var(--color-blue-50) 50%, var(--color-blue-60));
+ --button-primary-active-background-color: var(--color-blue-70);
+ --button-primary-text-color: var(--color-white);
+ --button-primary-border-color: var(--color-blue-80);
+ --button-destructive-background-color: var(--color-red-60);
+ --button-destructive-hover-background-color: color-mix(in srgb, var(--color-red-50) 50%, var(--color-red-60));
+ --button-destructive-active-background-color: var(--color-red-70);
+ --button-destructive-text-color: var(--color-white);
+ --button-destructive-border-color: var(--color-red-80);
+ --button-link-text-color: var(--color-blue-60);
+ --button-link-active-text-color: var(--color-blue-70);
+ --button-pressed-shadow: inset 0 1px 3px color-mix(in srgb, var(--color-gray-90) 30%, transparent);
+ --button-pressed-indicator-background-color: var(--color-blue-50);
+ --button-pressed-indicator-border-color: var(--color-blue-60);
+ --button-pressed-indicator-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ --button-pressed-indicator-padding: 6px;
+ --checkbox-border-color: var(--toolbar-field-border-color);
+ --checkbox-unchecked-bgcolor: var(--toolbar-field-background-color);
+ --checkbox-unchecked-hover-bgcolor: color-mix(in srgb, AccentColor 4%, var(--checkbox-unchecked-bgcolor));
+ --checkbox-unchecked-active-bgcolor: var(--button-active-background-color);
+ --checkbox-checked-border-color: transparent;
+ --checkbox-checked-color: var(--button-primary-color);
+ --checkbox-checked-bgcolor: var(--selected-item-color);
+ --checkbox-checked-hover-bgcolor: color-mix(in srgb, black 10%, var(--selected-item-color));
+ --checkbox-checked-active-bgcolor: color-mix(in srgb, black 20%, var(--selected-item-color));
+ --focus-outline-color: var(--toolbar-field-focus-border-color);
+ --focus-outline-offset: 2px;
+ --focus-outline: 2px solid var(--focus-outline-color);
+ --input-border-color: color-mix(in srgb, currentColor 41%, transparent);
+ --new-folder-color: var(--color-blue-60);
+ --lwt-header-image: none;
+ --search-field-placeholder: color-mix(in srgb, currentColor 50%, transparent);
+ --selected-item-color: var(--color-blue-60);
+ --selected-item-text-color: var(--color-white);
+ --splitter-color: var(--sidebar-border-color, var(--layout-border-0));
+ --sidebar-border-color: var(--layout-border-0);
+
+ --tab-min-height: 33px;
+ --lwt-tabs-border-color: var(--sidebar-border-color);
+ --tabs-tabbar-border-size: 1px;
+ --toolbar-button-hover-background-color: color-mix(in srgb, currentColor 10%, transparent);
+ --toolbar-button-hover-border-color: color-mix(in srgb, currentColor 30%, transparent);
+ --toolbar-button-hover-checked-color: color-mix(in srgb, currentColor 20%, transparent);
+ --toolbar-button-active-background-color: color-mix(in srgb, currentColor 20%, transparent);
+ --toolbar-button-active-border-color: color-mix(in srgb, currentColor 40%, transparent);
+ --toolbarbutton-icon-fill-opacity: .85;
+ --toolbarbutton-hover-background: var(--lwt-toolbarbutton-hover-background,
+ rgba(0, 0, 0, .05));
+ --toolbarbutton-hover-bordercolor: var(--lwt-toolbarbutton-hover-background,
+ rgba(0, 0, 0, .25));
+ --toolbarbutton-header-bordercolor: var(--lwt-toolbarbutton-hover-background,
+ rgba(0, 0, 0, .25));
+ --toolbarbutton-hover-boxshadow: none;
+ --toolbarbutton-active-background: var(--lwt-toolbarbutton-active-background,
+ rgba(0, 0, 0, .1));
+ --toolbarbutton-active-bordercolor: var(--lwt-toolbarbutton-active-background,
+ rgba(0, 0, 0, .3));
+ --toolbarbutton-default-active-boxshadow: rgba(0, 0, 0, .1) inset, 0 0 1px rgba(0, 0, 0, .3);
+ --toolbarbutton-active-boxshadow: 0 1px 1px var(--lwt-toolbarbutton-active-background,
+ var(--toolbarbutton-default-active-boxshadow)) inset;
+ --toolbarbutton-checked-background: var(--lwt-toolbarbutton-hover-background,
+ rgba(200, 200, 200, .5));
+ --toolbarbutton-icon-fill-attention: var(--lwt-toolbarbutton-icon-fill-attention, #0a84ff);
+ --toolbarseparator-color: color-mix(in srgb, currentColor 20%, transparent);
+ --toolbar-field-color: var(--layout-color-1);
+ --toolbar-field-background-color: var(--layout-background-1);
+ --toolbar-field-border-color: var(--layout-border-1);
+ --toolbar-field-focus-border-color: AccentColor;
+ --toolbar-field-highlight: var(--selected-item-color);
+ --toolbar-field-highlight-color: var(--selected-item-text-color);
+ --item-focus-selected-border-color: #0060df;
+ --default: #4f526d;
+ --primary: #0a84ff;
+ --select-focus-text-color: var(--selected-item-text-color);
+ --select-focus-background-color: var(--selected-item-color);
+ /* Wrapper for toolkit variables */
+ --button-primary-bgcolor: var(--button-primary-background-color);
+ --button-primary-hover-bgcolor: var(--button-primary-hover-background-color);
+ --button-primary-active-bgcolor: var(--button-primary-active-background-color);
+ --button-primary-color: var(--button-primary-text-color);
+}
+
+/* LW-themes enabled */
+
+:root:-moz-lwtheme {
+ color: var(--lwt-text-color);
+ --button-background: #d2d2d3;
+ --button-background-hover: #c2c2c3;
+ --button-background-active: #b2b2b3;
+ --panel-separator-color: hsla(210, 4%, 10%, 0.14);
+ --toolbar-color: var(--lwt-text-color, inherit);
+ --toolbar-bgcolor: rgba(255, 255, 255, .4);
+ --toolbar-field-border-color: hsla(240, 5%, 5%, 0.25);
+ --toolbarbutton-icon-fill-opacity: 1;
+ --autocomplete-popup-url-color: hsl(210, 77%, 47%);
+}
+
+:root[lwt-tree] {
+ --select-focus-background-color: var(--sidebar-highlight-background-color, var(--selected-item-color));
+ --select-focus-text-color: var(--sidebar-highlight-text-color,
+ var(--sidebar-text-color));
+ --new-focused-folder-color: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+ --row-grouped-header-bg-color: hsla(0, 0%, 50%, 0.15);
+ --row-grouped-header-bg-color-selected: var(--sidebar-highlight-background-color,
+ hsla(0, 0%, 80%, 0.6));
+}
+
+/* Dark themes enabled */
+
+:root[lwt-tree-brighttext] {
+ --default: #dcdcdc;
+ --primary: #45b1ff;
+ --item-focus-selected-border-color: #bebebf;
+ --row-grouped-header-bg-color-selected: var(--sidebar-highlight-background-color,
+ rgba(249, 249, 250, 0.3));
+ --button-sidebar-bottom-hover-color: var(--color-blue-40);
+}
+
+:root[lwt-popup-brighttext] {
+ --autocomplete-popup-url-color: #0a84ff;
+ --panel-separator-color: rgba(249, 249, 250, 0.1);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --button-background-color: var(--layout-background-3);
+ --button-hover-background-color: var(--layout-background-4);
+ --button-border-color: var(--layout-border-2);
+ --button-active-background-color: var(--layout-background-2);
+ --button-primary-border-color: var(--color-blue-50);
+ --button-destructive-border-color: var(--color-red-50);
+ --button-link-text-color: var(--color-blue-40);
+ --button-link-active-text-color: var(--color-blue-50);
+ --button-pressed-shadow: inset 0 1px 3px color-mix(in srgb, var(--color-black) 50%, transparent);
+ --button-pressed-indicator-background-color: var(--color-blue-30);
+ --button-pressed-indicator-border-color: var(--color-blue-40);
+ --button-pressed-indicator-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
+ --lwt-accent-color: rgb(24, 24, 26);
+ --new-folder-color: var(--color-blue-40);
+ --autocomplete-popup-highlight-background: #0060df;
+ --selected-item-color: var(--color-blue-50);
+ --toolbarbutton-hover-background: var(--lwt-toolbarbutton-hover-background,
+ rgba(255, 255, 255, .25));
+ --toolbarbutton-hover-bordercolor: var(--lwt-toolbarbutton-hover-background,
+ rgba(255, 255, 255, .5));
+ --toolbarbutton-header-bordercolor: var(--lwt-toolbarbutton-hover-background,
+ rgba(255, 255, 255, .25));
+ --toolbarbutton-active-background: var(--lwt-toolbarbutton-active-background,
+ rgba(255, 255, 255, .4));
+ --toolbarbutton-active-bordercolor: var(--lwt-toolbarbutton-active-background,
+ rgba(255, 255, 255, .7));
+ --toolbarbutton-active-boxshadow: none;
+ --toolbarbutton-checked-background: rgba(0, 0, 0, .25);
+ --toolbarbutton-icon-fill-attention: var(--lwt-toolbarbutton-icon-fill-attention, #45a1ff);
+ }
+
+ :root:not([lwt-tree]):-moz-lwtheme {
+ --button-background-color: var(--layout-background-3);
+ --button-hover-background-color: var(--layout-background-4);
+ --button-active-background-color: var(--layout-background-2);
+ --button-link-text-color: var(--color-blue-40);
+ --button-link-active-text-color: var(--color-blue-50);
+ --arrowpanel-border-color: #52525e;
+ --chrome-content-separator-color: #52525e;
+ --item-focus-selected-border-color: #bebebf;
+ --default: #dcdcdc;
+ --primary: var(--color-blue-40);
+ }
+
+ /* Overwrite the toolkit default theme. */
+ :root[lwt-default-theme-in-dark-mode] {
+ --lwt-selected-tab-background-color: var(--color-gray-70) !important;
+ --lwt-tab-line-color: var(--color-blue-50) !important;
+ --sidebar-background-color: var(--layout-background-1) !important;
+ --sidebar-highlight-background-color: var(--color-blue-50);
+ --sidebar-border-color: var(--layout-border-0) !important;
+ --toolbar-field-border-color: var(--color-gray-50) !important;
+ --button-link-text-color: var(--color-blue-40);
+ --button-link-active-text-color: var(--color-blue-50);
+ }
+}
+
+/* Special High contrast setting */
+
+@media (prefers-contrast) {
+ :root:not(:-moz-lwtheme) {
+ --button-color: ButtonText;
+ --button-background-color: ButtonFace;
+ --button-border-color: ButtonText;
+ --button-hover-text-color: SelectedItemText;
+ --button-hover-background-color: SelectedItem;
+ --button-active-background-color: SelectedItem;
+ --button-primary-background-color: SelectedItem;
+ --button-primary-hover-background-color: SelectedItem;
+ --button-primary-active-background-color: SelectedItem;
+ --button-primary-text-color: SelectedItemText;
+ --button-primary-border-color: SelectedItem;
+ --button-primary-hover-border-color: -moz-DialogText;
+ --button-link-text-color: LinkText;
+ --button-link-active-text-color: ActiveText;
+ --button-pressed-shadow: none;
+ --button-pressed-indicator-background-color: SelectedItem;
+ --button-pressed-indicator-border-color: SelectedItem;
+ --search-field-placeholder: GrayText;
+ --selected-item-color: SelectedItem;
+ --selected-item-text-color: SelectedItemText;
+ --toolbar-button-hover-background-color: SelectedItem;
+ --toolbar-button-hover-border-color: SelectedItem;
+ --toolbar-button-hover-checked-color: SelectedItem;
+ --toolbar-button-active-background-color: SelectedItem;
+ --toolbar-button-active-border-color: SelectedItemText;
+ --toolbar-field-background-color: Field;
+ --toolbar-field-color: FieldText;
+ --toolbar-field-border-color: ThreeDShadow;
+ }
+}
+
+@media not (prefers-contrast) {
+ :root:not(:-moz-lwtheme) {
+ --buttons-secondary-bgcolor: rgb(240, 240, 244);
+ --buttons-secondary-hover-bgcolor: rgb(224, 224, 230);
+ --buttons-secondary-active-bgcolor: rgb(207, 207, 216);
+ --buttons-secondary-color: rgb(21, 20, 26);
+ --arrowpanel-dimmed: color-mix(in srgb, currentColor 12%, transparent);
+ --arrowpanel-dimmed-further: color-mix(in srgb, currentColor 20%, transparent);
+ --arrowpanel-dimmed-even-further: color-mix(in srgb, currentColor 27%, transparent);
+ --error-text-color: #c50042;
+ }
+}
+
+/* DPI variations */
+
+@media (min-resolution: 1.5dppx) {
+ :root {
+ --tabs-tabbar-border-size: 0.5px;
+ }
+}
+
+@media (min-resolution: 3dppx) {
+ :root {
+ --tabs-tabbar-border-size: 0.33px;
+ }
+}
+
+/* UI Density customization */
+
+:root[uidensity="compact"] {
+ --tab-min-height: 30px;
+ --button-margin: 3px;
+ --button-padding: 3px;
+ --button-pressed-indicator-padding: 9px;
+}
+
+:root[uidensity="touch"] {
+ --tab-min-height: 39px;
+ --button-pressed-indicator-padding: 3px;
+}
diff --git a/comm/mail/themes/shared/mail/vcard.css b/comm/mail/themes/shared/mail/vcard.css
new file mode 100644
index 0000000000..5ce01ac8a0
--- /dev/null
+++ b/comm/mail/themes/shared/mail/vcard.css
@@ -0,0 +1,476 @@
+/* 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/. */
+
+:root {
+ --vcard-delete-button-color: var(--color-red-60);
+ --vcard-remove-color: var(--color-red-60);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --vcard-delete-button-color: var(--color-red-40);
+ --vcard-remove-color: var(--color-red-40);
+ }
+}
+
+vcard-edit {
+ color: var(--sidebar-text-color);
+ --input-border-density: 1px;
+}
+
+vcard-edit,
+.addr-book-edit-display-nickname {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(30em, 1fr));
+ column-gap: 2em;
+}
+
+vcard-edit {
+ grid-template-rows: masonry;
+ row-gap: 30px;
+}
+
+vcard-edit label[for] {
+ margin-block-end: 6px;
+ font-weight: 600;
+}
+
+vcard-edit input[type="text"],
+vcard-edit input[type="email"],
+vcard-edit input[type="url"],
+vcard-edit input[type="tel"],
+vcard-edit input[type="number"],
+vcard-edit input[type="date"],
+vcard-edit .vcard-type-selection,
+vcard-tz select:not([size], [multiple]),
+vcard-special-date select:not([size], [multiple]) {
+ line-height: 1;
+ padding-inline: 6px;
+ margin: 0;
+ border: var(--input-border-density) solid var(--toolbar-field-border-color);
+ min-height: initial;
+ height: var(--in-content-button-height);
+ color: inherit;
+ margin-block-end: 3px;
+ margin-inline-end: 3px;
+ font-weight: normal;
+}
+
+vcard-edit input[type="text"],
+vcard-edit input[type="email"],
+vcard-edit input[type="url"],
+vcard-edit input[type="tel"],
+vcard-edit input[type="date"] {
+ /* it should be 2em but input doesn't include the border */
+ height: calc(var(--in-content-button-height) - 2px);
+}
+
+vcard-edit vcard-impp input[type="text"] {
+ flex: 1;
+}
+
+vcard-edit input[type="email"] {
+ width: -moz-available;
+}
+
+vcard-edit input[type="url"],
+vcard-edit input[type="tel"] {
+ flex: 1;
+}
+
+vcard-edit input[type="number"] {
+ padding-inline-end: 0;
+}
+
+vcard-edit select:not([size]) {
+ border-color: var(--in-content-button-border-color);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ background-position-x: right 6px;
+ padding-inline-end: 15px;
+}
+
+vcard-edit select:not([size]):dir(rtl) {
+ background-position-x: left 6px;
+}
+
+vcard-edit select:not([size], [multiple]) {
+ padding-inline: 6px 22px;
+ padding-block: 0;
+ margin-block-end: 0;
+}
+
+vcard-edit select:user-invalid {
+ outline: 2px solid var(--in-content-border-invalid);
+ outline-offset: -1px;
+}
+
+vcard-edit textarea {
+ /* 3px is for the margin-end. */
+ width: calc(100% - 3px);
+ box-sizing: border-box;
+ border: var(--input-border-density) solid var(--toolbar-field-border-color);
+ margin-block: 0 3px;
+ margin-inline: 0 3px;
+ resize: vertical;
+}
+
+#addr-book-edit-n {
+ grid-column: 1 / -1;
+}
+
+/* N field styles */
+vcard-n {
+ display: flex;
+ align-items: end;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ gap: 6px;
+ --button-dimension: 24px;
+}
+
+.n-list-component {
+ display: inline-flex;
+ flex-direction: column;
+}
+
+.n-list-component button {
+ min-width: auto;
+ min-height: auto;
+ display: inline-flex;
+ margin: 0;
+ padding: 3px;
+ border: none;
+ z-index: 2;
+ margin-block-end: 9px;
+ border-radius: 8px;
+}
+
+.n-list-component button img {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 9px;
+ height: 9px;
+}
+
+.n-list-component button[hidden] {
+ display: none;
+}
+
+#n-list-component-prefix input,
+#n-list-component-suffix input {
+ width: 10ch;
+ box-sizing: border-box;
+}
+
+#n-list-component-prefix,
+#n-list-component-suffix {
+ flex: 0 0 0%;
+}
+
+#n-list-component-firstname,
+#n-list-component-middlename,
+#n-list-component-lastname {
+ flex: 1 0 0%;
+}
+
+#n-list-component-firstname input,
+#n-list-component-middlename input,
+#n-list-component-lastname input {
+ min-width: 20ch;
+ box-sizing: border-box;
+ width: 100%;
+}
+
+#n-list-component-middlename.hasButton {
+ flex: 0 0 auto;
+}
+
+/* Display name / Full name / Nick name styles */
+vcard-fn,
+vcard-nickname {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-block-start: 12px;
+}
+
+vcard-fn label[for],
+vcard-nickname label[for] {
+ margin-block-end: 0;
+}
+
+.vcard-checkbox {
+ display: flex;
+ align-items: center;
+}
+
+/* Email fieldset styles */
+#addr-book-edit-email table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+/**
+ * Shows the "Default" table header for emails.
+ */
+.default-table-header {
+ position: relative;
+}
+
+.default-table-header legend {
+ /* The legend is positioned absolute over the table headers. */
+ position: absolute;
+}
+
+/* Email field styles */
+#addr-book-edit-email :is(th, td) {
+ padding: 0;
+}
+
+#addr-book-edit-email td.email-column {
+ width: 100%;
+}
+
+.default-column {
+ text-align: center;
+}
+
+#vcard-email input[type="checkbox"]:not([hidden]) {
+ border-radius: 50%;
+ width: 16px;
+ height: 16px;
+ margin-inline: 0;
+ margin-block: 0 3px;
+ display: inline-grid;
+}
+
+#vcard-email input[type="checkbox"]:checked {
+ background-image: none;
+ background-color: transparent;
+ border-color: var(--in-content-primary-button-background);
+ position: relative;
+}
+
+#vcard-email input[type="checkbox"]:enabled:checked:hover {
+ background-color: transparent;
+ border-color: var(--in-content-primary-button-background-hover);
+}
+
+#vcard-email input[type="checkbox"]:checked:active {
+ border-color: var(--in-content-primary-button-background-active);
+}
+
+#vcard-email input[type="checkbox"]:checked::before {
+ content: "";
+ display: block;
+ height: 8px;
+ width: 8px;
+ background-color: var(--in-content-primary-button-background);
+ border-radius: 50%;
+ position: absolute;
+ inset: 3px;
+}
+
+#vcard-email input[type="checkbox"]:enabled:checked:hover::before {
+ background-color: var(--in-content-primary-button-background-hover);
+}
+
+#vcard-email input[type="checkbox"]:checked:active::before {
+ background-color: var(--in-content-primary-button-background-active);
+}
+
+#addr-book-edit-address {
+ display: flex;
+ flex-direction: column;
+}
+
+.screen-reader-only {
+ position: absolute;
+ clip-path: inset(50%);
+}
+
+vcard-url,
+vcard-tel,
+vcard-impp,
+vcard-special-date {
+ display: flex;
+}
+
+vcard-special-date {
+ margin-block-end: 6px;
+}
+
+.fieldset-reset {
+ margin: 0;
+ padding: 0;
+ border-style: none;
+}
+
+.fieldset-grid {
+ display: grid;
+ grid-template-columns: min-content auto;
+}
+
+.addr-book-edit-fieldset legend,
+#addr-book-edit-email-default {
+ margin-block: 0 6px;
+ font-size: 1.1rem;
+ line-height: 1.2;
+ background-color: transparent;
+ font-weight: 600;
+ margin-inline-start: 0;
+ padding-inline: 0;
+}
+
+/* Imitates the legend element. */
+.default-table-header #addr-book-edit-email-default {
+ font-weight: 400;
+ /* This is a th so instead of a margin a padding is used. */
+ padding-block: 0 6px;
+}
+
+.default-table-header #addr-book-edit-email-default span {
+ /* The box height of the th has to fully imitate the legend element. */
+ /* That's why the font size get adjusted directly on the span. */
+ font-size: 1rem;
+}
+
+.vcard-year-month-day-container {
+ display: flex;
+ align-items: center;
+}
+
+.vcard-year-month-day-container select:not([size], [multiple]),
+.vcard-year-month-day-container input[type="number"] {
+ margin-block-end: 0;
+ margin-inline: 0;
+ background-color: var(--in-content-box-background);
+ border-color: var(--toolbar-field-border-color);
+}
+
+.vcard-year-month-day-container select.vcard-month-select {
+ border-radius: 0;
+ border-inline-end-width: 0;
+}
+
+.vcard-year-month-day-container select.vcard-day-select {
+ border-start-start-radius: 0;
+ border-end-start-radius: 0;
+}
+
+.vcard-year-month-day-container input[type="number"] {
+ box-sizing: border-box;
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+ border-inline-end-width: 0;
+}
+
+.vcard-year-month-day-container input[type="number"]::-moz-number-spin-box {
+ padding-inline-end: 2px;
+}
+
+vcard-adr select {
+ grid-column: 1 / 2;
+}
+
+.vcard-adr-inputs:not([hidden]) {
+ display: flex;
+ flex-direction: column;
+ grid-column: 2 / 3;
+}
+
+.addr-book-edit-fieldset-button {
+ background-color: transparent;
+ padding: 3px;
+ border: none;
+ font-size: 1rem;
+ font-weight: 500;
+ border-radius: 3px;
+ min-height: auto;
+ width: fit-content;
+}
+
+.addr-book-edit-fieldset-button:enabled:hover,
+.addr-book-edit-fieldset-button:enabled:active,
+.addr-book-edit-fieldset-button:enabled:active:hover {
+ background-color: var(--in-content-button-background);
+}
+
+.add-property-button:enabled:hover,
+.add-property-button:enabled:active,
+.add-property-button:enabled:active:hover {
+ color: var(--in-content-primary-button-background);
+}
+
+.add-property-button::before {
+ position: relative;
+ display: block;
+ content: "";
+ border-radius: 8px;
+ background-color: var(--in-content-primary-button-background);
+ width: 13px;
+ height: 13px;
+ background-image: url("chrome://global/skin/icons/add.svg");
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 9px;
+ fill: unset;
+ stroke: unset;
+ fill-opacity: unset;
+}
+
+.add-property-button {
+ -moz-context-properties: fill;
+ color: var(--in-content-primary-button-background);
+ fill: var(--in-content-primary-button-text-color);
+}
+
+.remove-property-button {
+ color: var(--vcard-remove-color);
+ margin-block-end: 3px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ align-self: center;
+}
+
+.remove-property-button::before {
+ width: 12px;
+ height: 12px;
+ background-image: var(--icon-subtract-circle-sm);
+ background-repeat: no-repeat;
+ background-position: center;
+}
+
+.remove-property-button:enabled:hover,
+.remove-property-button:enabled:active,
+.remove-property-button:enabled:active:hover {
+ color: var(--color-white);
+ background-color: var(--vcard-delete-button-color);
+}
+
+/* Remove button special cases. */
+#vcard-email .remove-property-button {
+ padding: 2px;
+}
+
+vcard-special-date .remove-property-button {
+ margin-inline-start: 3px;
+ margin-block-end: 0;
+}
+
+.add-property-button + .remove-property-button {
+ margin-inline-start: 0;
+}
+
+vcard-tz {
+ display: inline-flex;
+ flex-direction: column;
+}
+
+vcard-tz .remove-property-button {
+ margin-block-start: 3px;
+ align-self: auto;
+}
diff --git a/comm/mail/themes/shared/mail/verifychat.css b/comm/mail/themes/shared/mail/verifychat.css
new file mode 100644
index 0000000000..1354ab2ea6
--- /dev/null
+++ b/comm/mail/themes/shared/mail/verifychat.css
@@ -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/. */
+
+p {
+ margin-block-start: 0;
+}
+
+#challengePresentation {
+ text-align: center;
+ font-weight: bold;
+}
+
+#challengePresentation span:not([hidden]) {
+ /* Make each span start on a newline. */
+ display: block;
+}
diff --git a/comm/mail/themes/shared/mail/widgets.css b/comm/mail/themes/shared/mail/widgets.css
new file mode 100644
index 0000000000..e2e20bec85
--- /dev/null
+++ b/comm/mail/themes/shared/mail/widgets.css
@@ -0,0 +1,370 @@
+/* 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 defines the various widgets used across the application. */
+
+/* Default Button Styles */
+
+.button {
+ --icon-size: 16px;
+ appearance: none;
+ background-color: var(--button-background-color);
+ color: var(--button-text-color);
+ border: var(--button-border-size) solid var(--button-border-color);
+ border-radius: var(--button-border-radius);
+ padding: var( --button-padding);
+ margin: var(--button-margin);
+ min-width: 6em;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: inherit;
+}
+
+.button:enabled:hover,
+.select:enabled:hover {
+ color: var(--button-hover-text-color);
+ background-color: var(--button-hover-background-color);
+ border-color: var(--button-border-color);
+}
+
+.button:focus-visible,
+.select:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: var(--focus-outline-offset);
+}
+
+.button[aria-pressed="true"] {
+ box-shadow: var(--button-pressed-shadow);
+}
+
+.button:enabled:hover:active,
+.select:enabled:hover:active {
+ background-color: var(--button-active-background-color);
+ border-color: var(--button-border-color);
+}
+
+.button:disabled,
+.select:disabled {
+ opacity: 0.4;
+ pointer-events: none;
+}
+
+.button[hidden],
+.select[hidden] {
+ display: none;
+}
+
+.button:dir(rtl),
+.button:-moz-locale-dir(rtl) {
+ background-position-x: right var(--button-padding);
+}
+
+.button > img {
+ pointer-events: none;
+}
+
+.button.icon-button {
+ background-image: none;
+ background-size: var(--icon-size);
+ background-position: var(--button-padding) center;
+ background-repeat: no-repeat;
+ padding-inline-start: calc(var(--button-padding) * 2 + var(--icon-size));
+ padding-inline-end: calc(var(--button-padding) * 2);
+ min-width: 0;
+}
+
+.button.icon-only {
+ background-position: center;
+ /* 2px at the end for the border. */
+ height: calc(var(--button-padding) * 2 + var(--icon-size) + 2px);
+ padding: 0;
+ aspect-ratio: 1;
+}
+
+.button.toolbar-button,
+.button.unified-toolbar-button {
+ background-color: transparent;
+ color: currentColor;
+ border-color: transparent;
+ margin-block: 4px;
+}
+
+.button.toolbar-button[open="true"],
+.button.toolbar-button:not([disabled="true"]):hover,
+.button.unified-toolbar-button:enabled:is([aria-pressed="true"], :hover) {
+ color: var(--button-hover-text-color);
+ background-color: var(--toolbar-button-hover-background-color);
+ border-color: var(--toolbar-button-hover-border-color);
+}
+
+.button.toolbar-button[open="true"] {
+ color: var(--button-hover-text-color);
+}
+
+.button.unified-toolbar-button[aria-pressed="true"]:enabled {
+ color: var(--button-hover-text-color);
+ box-shadow: var(--button-pressed-shadow);
+}
+
+.button.unified-toolbar-button[aria-pressed="true"]:enabled:hover {
+ background-color: var(--toolbar-button-hover-checked-color);
+}
+
+.button.toolbar-button:not([disabled="true"]):hover:active,
+.button.unified-toolbar-button:enabled:hover:active {
+ background-color: var(--toolbar-button-active-background-color);
+ border-color: var(--toolbar-button-active-border-color);
+}
+
+@media (-moz-windows-accent-color-in-titlebar) {
+ #navigation-toolbox :is(.unified-toolbar-button, .toolbar-button):not(:-moz-lwtheme):focus-visible {
+ outline-color: accentColorText;
+ }
+}
+
+/* Primary Button Styles */
+
+.button.button-primary {
+ background-color: var(--button-primary-background-color);
+ color: var(--button-primary-text-color);
+ border-color: var(--button-primary-border-color);
+}
+
+.button.button-primary:hover {
+ background-color: var(--button-primary-hover-background-color);
+ color: var(--button-primary-text-color);
+ border-color: var(--button-primary-border-color);
+}
+
+@media (prefers-contrast) {
+ .button.button-primary:not(:-moz-lwtheme):hover {
+ border-color: var(--button-primary-hover-border-color);
+ }
+}
+
+.button.button-primary:hover:active {
+ background-color: var(--button-primary-active-background-color);
+ border-color: var(--button-primary-border-color);
+}
+
+/* Destructive Button Styles */
+
+.button.button-destructive {
+ background-color: var(--button-destructive-background-color);
+ color: var(--button-destructive-text-color);
+ border-color: var(--button-destructive-border-color);
+}
+
+.button.button-destructive:hover {
+ background-color: var(--button-destructive-hover-background-color);
+ border-color: var(--button-destructive-border-color);
+}
+
+.button.button-destructive:hover:active {
+ background-color: var(--button-destructive-active-background-color);
+ border-color: var(--button-destructive-border-color);
+}
+
+/* Flat Button Styles */
+
+.button.button-flat {
+ background-color: transparent;
+ color: currentColor;
+ border-color: transparent;
+}
+
+.button.button-flat:hover {
+ background-color: color-mix(in srgb, currentColor 10%, transparent);
+ border-color: transparent;
+}
+
+.button.button-flat:focus-visible {
+ color: currentColor;
+}
+
+.button.button-flat:hover:active {
+ background-color: color-mix(in srgb, currentColor 30%, transparent);
+ border-color: transparent;
+}
+
+@media (prefers-contrast) {
+ .button.button-flat:hover,
+ .button.button-flat:hover:active {
+ background-color: SelectedItem;
+ }
+}
+
+/* Link Button Styles */
+
+.button.link-button {
+ background-color: transparent;
+ color: var(--button-link-text-color);
+ border-color: transparent;
+}
+
+.button.link-button:hover {
+ background-color: transparent;
+ color: var(--button-link-text-color);
+ border-color: transparent;
+ text-decoration: underline;
+}
+
+.button.link-button:hover:active {
+ background-color: transparent;
+ color: var(--button-link-active-text-color);
+ border-color: transparent;
+}
+
+/* Check Button Styles */
+
+.button.check-button {
+ position: relative;
+}
+
+.button.check-button:not(.icon-button) {
+ padding-inline-start: calc(var(--button-padding) * 2 + 9px);
+ padding-inline-end: calc(var(--button-padding) * 2);
+}
+
+.button.check-button.icon-button {
+ background-position: calc(var(--button-padding) * 1.2 + var(--button-pressed-indicator-padding)) center;
+ padding-inline-start: calc(var(--button-padding) * 2 + var(--icon-size) + var(--button-pressed-indicator-padding));
+}
+
+.button.check-button.icon-button:dir(rtl),
+.button.check-button.icon-button:-moz-locale-dir(rtl) {
+ background-position-x: right calc(var(--button-padding) * 1.2 + var(--button-pressed-indicator-padding));
+}
+
+.button.check-button.icon-only {
+ background-position: calc(var(--button-padding) + var(--button-pressed-indicator-padding)) center;
+ aspect-ratio: auto;
+}
+
+.button.check-button.icon-only:dir(rtl),
+.button.check-button.icon-only:-moz-locale-dir(rtl) {
+ background-position-x: right calc(var(--button-padding) + var(--button-pressed-indicator-padding));
+}
+
+.button.check-button::before {
+ content: '';
+ box-sizing: border-box;
+ background-color: color-mix(in srgb, currentColor 10%, transparent);
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+ border-radius: 4px;
+ width: 4px;
+ height: 12px;
+ margin: auto 3px;
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: 2px;
+}
+
+.button.check-button[aria-pressed="true"]::before {
+ background-color: var(--button-pressed-indicator-background-color);
+ border-color: var(--button-pressed-indicator-border-color);
+ box-shadow: var(--button-pressed-indicator-shadow);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .button {
+ transition: background-color .15s, border-color .15s;
+ }
+}
+
+/* Button Group Styles */
+
+.button-group {
+ display: inline-flex;
+ color: var(--button-text-color);
+ border: var(--button-border-size) solid var(--button-border-color);
+ border-radius: var(--button-border-radius);
+ margin: var(--button-margin);
+ position: relative;
+ isolation: isolate;
+ z-index: 1;
+}
+
+.button-group .button + .button:not(:last-child) {
+ border-inline-end: var(--button-border-size) solid var(--button-border-color);
+}
+
+.button-group :is(.button, input) {
+ --button-margin: 0;
+ border: none;
+ border-radius: 0;
+ z-index: 2;
+}
+
+.button-group .button:focus-visible {
+ outline-offset: 0;
+ z-index: 3;
+}
+
+.button-group .button:first-child {
+ border-inline-end: var(--button-border-size) solid var(--button-border-color);
+ border-start-start-radius: calc(var(--button-border-radius) - 1px);
+ border-end-start-radius: calc(var(--button-border-radius) - 1px);
+}
+
+.button-group .button:last-child {
+ border-start-end-radius: calc(var(--button-border-radius) - 1px);
+ border-end-end-radius: calc(var(--button-border-radius) - 1px);
+}
+
+@container threadPane (max-width: 999px) {
+ .button.collapsible-button {
+ padding-inline-end: 0;
+ }
+
+ .button.collapsible-button span {
+ display: inline-block;
+ visibility: hidden;
+ width: 0;
+ }
+}
+
+/* Select element */
+
+.select {
+ appearance: none;
+ text-decoration: none;
+ background-color: var(--button-background-color);
+ color: var(--button-text-color);
+ border: var(--button-border-size) solid var(--button-border-color);
+ border-radius: var(--button-border-radius);
+ padding: var(--button-padding);
+ margin: var(--button-margin);
+ font-size: 1em;
+}
+
+.select:not([size], [multiple]) {
+ --logical-padding: 3px;
+ --start-padding: calc(var(--logical-padding) + 3px);
+ --end-padding: calc(var(--logical-padding) + 9px);
+ --background-image-width: 12px;
+ background-image: var(--icon-nav-down-sm);
+ background-position: right calc(var(--end-padding) / 2) center;
+ background-repeat: no-repeat;
+ background-size: auto var(--background-image-width);
+ -moz-context-properties: fill;
+ fill: currentColor;
+ font: inherit;
+ padding-inline-start: var(--start-padding);
+ padding-inline-end: calc(var(--background-image-width) + var(--end-padding));
+ text-overflow: ellipsis;
+}
+
+.select:not([size], [multiple]):dir(rtl) {
+ background-position-x: left calc(var(--end-padding) / 2);
+}
+
+.select:not([size], [multiple]) > option {
+ background-color: var(--in-content-box-background);
+ color: var(--in-content-text-color);
+}
diff --git a/comm/mail/themes/shared/mail/wizard.css b/comm/mail/themes/shared/mail/wizard.css
new file mode 100644
index 0000000000..93372c79f4
--- /dev/null
+++ b/comm/mail/themes/shared/mail/wizard.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/variables.css");
+@import url("chrome://messenger/skin/themeableDialog.css");
+
+@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:host {
+ padding: 14px 24px;
+}
+
+.wizard-header-box-text {
+ padding: 6px 0;
+ font: menu;
+ font-weight: bold;
+}
+
+.wizard-header-label {
+ font-size: 1.2em;
+ font-weight: bold;
+}
+
+.wizard-header-box-icon {
+ margin-block: 3px 0;
+ margin-inline: 3px 20px;
+}
+
+:host([data-branded="true"]) .wizard-header-icon {
+ list-style-image: url("chrome://branding/content/icon128.png");
+ width: 48px;
+ height: 48px;
+}
+
+html|*.wizard-page-box {
+ padding: 10px 0;
+}
+
+.wizard-buttons-btm {
+ padding: 3px 6px 6px;
+}
+
+.wizard-button[dlgtype="finish"],
+.wizard-button[dlgtype="next"] {
+ margin-inline-start: 0 !important;
+}
+
+.wizard-button[dlgtype="back"] {
+ margin-inline-end: 0 !important;
+}
diff --git a/comm/mail/themes/shared/openpgp/backupKeyPassword.css b/comm/mail/themes/shared/openpgp/backupKeyPassword.css
new file mode 100644
index 0000000000..69c82b2501
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/backupKeyPassword.css
@@ -0,0 +1,37 @@
+/* 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/. */
+
+body {
+ width: 700px;
+ max-width: 700px;
+ min-width: 700px;
+}
+
+dialog {
+ margin-block-start: 10px;
+ margin-inline: 5px;
+}
+
+legend {
+ font-size: 1em;
+}
+
+fieldset {
+ display: grid;
+ grid-template-columns: auto 400px;
+ grid-gap: 5px;
+ align-items: center;
+}
+
+label {
+ grid-column: 1;
+}
+
+input,progress {
+ grid-column: 2;
+}
+
+progress {
+ height: 20px;
+}
diff --git a/comm/mail/themes/shared/openpgp/changeExpiryDlg.css b/comm/mail/themes/shared/openpgp/changeExpiryDlg.css
new file mode 100644
index 0000000000..121ad74309
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/changeExpiryDlg.css
@@ -0,0 +1,19 @@
+/* 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/. */
+
+html {
+ width: 700px;
+ min-width: 700px;
+ max-width: 700px;
+}
+
+fieldset {
+ margin-inline: 20px;
+ margin-block: 4px;
+}
+
+fieldset > div {
+ display: flex;
+ align-items: center;
+}
diff --git a/comm/mail/themes/shared/openpgp/confirmPubkeyImport.css b/comm/mail/themes/shared/openpgp/confirmPubkeyImport.css
new file mode 100644
index 0000000000..301ae19ad6
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/confirmPubkeyImport.css
@@ -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 url("chrome://messenger/skin/messenger.css");
+
+#confirmPubkeyImportDialog {
+ width: 55em;
+}
+
+@media not (-moz-platform: macos) {
+ #confirmPubkeyImportDialog::part(content-box) {
+ width: calc(55em - 18px); /* dialog width - padding. */
+ }
+}
+
+@media (-moz-platform: macos) {
+ #confirmPubkeyImportDialog::part(content-box) {
+ width: calc(55em - 28px); /* dialog width - padding. */
+ }
+}
+
+description {
+ padding-inline: 2px;
+}
+
+.grid-size {
+ display: grid;
+ grid-template-columns: auto 1fr 1fr;
+}
+
+.overlay {
+ display: flex;
+ position: fixed;
+ flex-direction: column;
+ justify-content: start;
+ background-color: var(--in-content-page-background, -moz-dialog);
+ inset: 0;
+ z-index: 1;
+}
+
+.self-center {
+ align-self: center;
+}
+
+#importKeyListContainer {
+ height: 25em;
+ overflow-y: scroll;
+}
+
+#importKeyList {
+ display: grid;
+ row-gap: 6px;
+ margin: 18px 6px 9px;
+}
+
+.key-import-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: end;
+ border-radius: 4px;
+ padding: 3px 6px;
+ border: 1px solid var(--in-content-border-color);
+ background-color: rgba(215, 215, 219, 0.2);
+ margin-bottom: 6px;
+}
+
+.openpgp-key-id {
+ font-weight: bold;
+}
+
+.openpgp-key-name {
+ font-size: 1em;
+}
+
+.openpgp-key-fpr {
+ font-size: 0.9em;
+}
diff --git a/comm/mail/themes/shared/openpgp/enigEncActiveConflict.png b/comm/mail/themes/shared/openpgp/enigEncActiveConflict.png
new file mode 100644
index 0000000000..ae175f67fd
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncActiveConflict.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncActiveMinus.png b/comm/mail/themes/shared/openpgp/enigEncActiveMinus.png
new file mode 100644
index 0000000000..265eda8ebf
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncActiveMinus.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncActiveNone.png b/comm/mail/themes/shared/openpgp/enigEncActiveNone.png
new file mode 100644
index 0000000000..b96f683eef
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncActiveNone.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncActivePlus.png b/comm/mail/themes/shared/openpgp/enigEncActivePlus.png
new file mode 100644
index 0000000000..c8e868c2c5
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncActivePlus.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncForceNo.png b/comm/mail/themes/shared/openpgp/enigEncForceNo.png
new file mode 100644
index 0000000000..322698b33f
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncForceNo.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncForceYes.png b/comm/mail/themes/shared/openpgp/enigEncForceYes.png
new file mode 100644
index 0000000000..6485acab9b
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncForceYes.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncInactive.png b/comm/mail/themes/shared/openpgp/enigEncInactive.png
new file mode 100644
index 0000000000..156d9846dd
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncInactive.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncInactiveConflict.png b/comm/mail/themes/shared/openpgp/enigEncInactiveConflict.png
new file mode 100644
index 0000000000..b5e907a99f
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncInactiveConflict.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncInactiveMinus.png b/comm/mail/themes/shared/openpgp/enigEncInactiveMinus.png
new file mode 100644
index 0000000000..1e432f57d1
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncInactiveMinus.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncInactiveNone.png b/comm/mail/themes/shared/openpgp/enigEncInactiveNone.png
new file mode 100644
index 0000000000..8125f28c3e
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncInactiveNone.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncInactivePlus.png b/comm/mail/themes/shared/openpgp/enigEncInactivePlus.png
new file mode 100644
index 0000000000..9c8de22de1
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncInactivePlus.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigEncNotOk.png b/comm/mail/themes/shared/openpgp/enigEncNotOk.png
new file mode 100644
index 0000000000..95df0702ae
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigEncNotOk.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignActiveConflict.png b/comm/mail/themes/shared/openpgp/enigSignActiveConflict.png
new file mode 100644
index 0000000000..bfb4f372c2
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignActiveConflict.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignActiveMinus.png b/comm/mail/themes/shared/openpgp/enigSignActiveMinus.png
new file mode 100644
index 0000000000..7099d0039b
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignActiveMinus.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignActiveNone.png b/comm/mail/themes/shared/openpgp/enigSignActiveNone.png
new file mode 100644
index 0000000000..c325b0fa27
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignActiveNone.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignActivePlus.png b/comm/mail/themes/shared/openpgp/enigSignActivePlus.png
new file mode 100644
index 0000000000..a4084ea806
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignActivePlus.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignForceNo.png b/comm/mail/themes/shared/openpgp/enigSignForceNo.png
new file mode 100644
index 0000000000..682a501758
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignForceNo.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignForceYes.png b/comm/mail/themes/shared/openpgp/enigSignForceYes.png
new file mode 100644
index 0000000000..e6e3966707
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignForceYes.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignInactiveConflict.png b/comm/mail/themes/shared/openpgp/enigSignInactiveConflict.png
new file mode 100644
index 0000000000..cdf915d5e4
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignInactiveConflict.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignInactiveMinus.png b/comm/mail/themes/shared/openpgp/enigSignInactiveMinus.png
new file mode 100644
index 0000000000..2a70ddb6b0
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignInactiveMinus.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignInactiveNone.png b/comm/mail/themes/shared/openpgp/enigSignInactiveNone.png
new file mode 100644
index 0000000000..a1c3a3528f
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignInactiveNone.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignInactivePlus.png b/comm/mail/themes/shared/openpgp/enigSignInactivePlus.png
new file mode 100644
index 0000000000..b7a69a0d68
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignInactivePlus.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignNotOk.png b/comm/mail/themes/shared/openpgp/enigSignNotOk.png
new file mode 100644
index 0000000000..bb7b212615
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignNotOk.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigSignUnkown.png b/comm/mail/themes/shared/openpgp/enigSignUnkown.png
new file mode 100644
index 0000000000..4e51c94d1f
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigSignUnkown.png
Binary files differ
diff --git a/comm/mail/themes/shared/openpgp/enigmail-html.css b/comm/mail/themes/shared/openpgp/enigmail-html.css
new file mode 100644
index 0000000000..690303335b
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigmail-html.css
@@ -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 https://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * Enigmail style for HTML pages, displayed in tabs
+ */
+
+
+
+
+body {
+ color: black;
+ padding: 0;
+ background-color: white;
+ border-style: none;
+ font-family: Arial, Helvetica, sans-serif;
+ font-size: 14px;
+}
+
+h1 {
+ font-size: 2em;
+ color: #0077bb;
+}
+
+h2 {
+ font-size: 1.4em;
+ margin-top: 1.3em;
+}
+
+h3 {
+ font-size: 1.2em;
+ margin-top: 1.3em;
+}
+
+ul {
+ list-style-image: none;
+}
+
+a {
+ text-decoration: underline;
+}
+
+a:link {
+ color: blue;
+}
+
+a:visited {
+ color: #9900cc;
+}
+
+a:hover {
+ color: red;
+}
+
+.header-bar {
+ box-sizing: border-box;
+ font-size: 14px;
+ font-weight: 300;
+ line-height: 16.8px;
+ margin-left: 0px;
+ margin-right: 0px;
+ padding-left: 15px;
+ padding-right: 15px;
+ position: relative;
+ min-height: 80px;
+ background-color: #0077bb;
+}
+
+.header-icon {
+ box-sizing: border-box;
+ float: left;
+ font-size: 14px;
+ line-height: 16.8px;
+ margin-left: 0px;
+ margin-right: 10px;
+}
+
+.spacer {
+ padding-top: 40px;
+}
+
+.body {
+ max-width: 800px;
+}
+
+.logo-img {
+ max-height: 90px;
+}
+
+button {
+ font-size: 1.2em;
+ margin-top: 1.3em;
+}
+
+.hidden {
+ visibility: collapse;
+ max-height: 0px;
+}
diff --git a/comm/mail/themes/shared/openpgp/enigmail.css b/comm/mail/themes/shared/openpgp/enigmail.css
new file mode 100644
index 0000000000..2edf293b0a
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/enigmail.css
@@ -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 https://mozilla.org/MPL/2.0/.
+ */
+
+.enigmailGroupbox {
+ border-radius: 4px;
+ border-style: groove;
+ border-width: thin;
+ padding: 3px;
+ margin: 3px;
+ border-color: lightgrey;
+}
+
+.enigmailGroupboxMargin {
+ margin: 10px 3px;
+}
+
+.enigmailDialogTitle {
+ font-size: 120%;
+ font-weight: bold;
+ padding-bottom: 6px;
+}
+
+.enigmailDialogBody {
+ -moz-user-focus: normal;
+ user-select: text;
+ cursor: text !important;
+ white-space: pre-wrap;
+ unicode-bidi: plaintext;
+}
+
+.enigmailKeyImportHeader {
+ font-weight: bold;
+ color: #888;
+}
+
+.enigmailKeyImportUserId {
+ font-weight: bold;
+}
+
+.enigmailKeyImportDetails {
+ color: blue;
+}
+
+.enigmailKeyImportDetails:hover {
+ text-decoration: underline;
+}
+
+treechildren::-moz-tree-cell-text(enigmailOwnKey) {
+ font-weight: bold;
+}
+
+treechildren::-moz-tree-cell-text(enigKeyInactive) {
+ color: gray;
+ font-style: italic;
+}
+
+/*
+ the following styles are available for the key trust
+ columnm in the key manager:
+ enigmail_keyValid_unknown
+ enigmail_keyValid_revoked
+ enigmail_keyValid_expired
+ enigmail_keyTrust_untrusted
+ enigmail_keyTrust_marginal
+ enigmail_keyTrust_full
+ enigmail_keyTrust_ultimate
+ enigmail_keyTrust_unknown
+
+They can be applied using:
+treechildren::-moz-tree-cell(STYLE) {}
+treechildren::-moz-tree-cell-text(STYLE) {}
+*/
+
+th {
+ font-weight: normal;
+ text-align: start;
+}
diff --git a/comm/mail/themes/shared/openpgp/inlineNotification.css b/comm/mail/themes/shared/openpgp/inlineNotification.css
new file mode 100644
index 0000000000..528711be40
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/inlineNotification.css
@@ -0,0 +1,59 @@
+/* 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/. */
+
+.inline-notification-container:not([collapsed="true"],[hidden]) {
+ display: flex;
+ border-radius: 3px;
+ padding: 6px 8px;
+ margin: 10px 3px;
+}
+
+.inline-notification-container {
+ color: inherit;
+}
+
+.inline-notification-wrapper {
+ display: flex;
+ align-items: flex-start;
+}
+
+.inline-notification-wrapper.align-center {
+ align-items: center;
+}
+
+.inline-notification-wrapper description {
+ flex: 1;
+}
+
+.notification-image {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ margin-inline: 4px 8px;
+ margin-top: 4px;
+ width: 16px;
+}
+
+.inline-notification-wrapper.align-center .notification-image {
+ margin-top: 0;
+}
+
+/* Info variation */
+
+.inline-notification-container.info-container {
+ background-color: rgba(69, 161, 255, 0.2);
+}
+
+/* error variation */
+
+.inline-notification-container.error-container {
+ background-color: #f7b6b6;
+ color: #410303;
+}
+
+/* success variation */
+
+.inline-notification-container.success-container {
+ background-color: #c1f7b6;
+ color: #043a08;
+}
diff --git a/comm/mail/themes/shared/openpgp/keyDetails.css b/comm/mail/themes/shared/openpgp/keyDetails.css
new file mode 100644
index 0000000000..0decc6508f
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/keyDetails.css
@@ -0,0 +1,72 @@
+/* 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/. */
+
+html {
+ width: 840px;
+ height: 650px;
+ min-width: 840px;
+ min-height: 650px;
+}
+
+body {
+ overflow: auto;
+}
+
+body > dialog,
+button {
+ font-size: 1rem; /* accountManage.css pulls in preferences.css that sets font-size ... */
+}
+
+.key-details-container {
+ display: grid;
+ grid-template-columns: auto max-content;
+ align-items: end;
+ padding-inline: 6px;
+}
+
+.key-details-grid {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ grid-row-gap: 3px;
+ align-items: baseline;
+}
+
+.key-detail-label {
+ font-weight: 600;
+ margin-inline-end: 6px;
+ line-height: unset; /* accountManage.css pulls in preferences.css that sets line-height ... */
+}
+
+.backup-container {
+ padding-inline: 9px;
+}
+
+.input-padding {
+ margin: 2px 4px;
+}
+
+#alsoknown {
+ margin-top: 9px;
+ margin-inline: 6px;
+}
+
+.additional-key-identity {
+ border-radius: 0;
+ padding-inline: 8px;
+}
+
+#addressesListContainer {
+ overflow-y: auto;
+}
+
+.tail-with-learn-more {
+ margin-inline-end: 10px;
+ font-weight: bold;
+}
+
+#key-detail-has-insecure {
+ max-width: 50em;
+ margin-inline: 2em;
+ margin-top: 1em;
+}
diff --git a/comm/mail/themes/shared/openpgp/keyWizard.css b/comm/mail/themes/shared/openpgp/keyWizard.css
new file mode 100644
index 0000000000..7bb19ab347
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/keyWizard.css
@@ -0,0 +1,152 @@
+/* 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 url("chrome://messenger/skin/messenger.css");
+
+description {
+ padding-inline: 2px;
+}
+
+.identity-legend {
+ margin-block: 0;
+}
+
+.dialogheader-title {
+ margin-block: 0 8px;
+ margin-inline-start: 0;
+ font-size: 1.46em;
+ font-weight: 300;
+ line-height: 1.3em;
+ color: var(--in-content-text-color);
+}
+
+.wizard-section {
+ transition: transform 230ms ease, opacity 230ms ease;
+}
+
+.wizard-section.hide {
+ transform: translateY(-100%);
+ opacity: 0;
+}
+
+.wizard-section.hide-reverse {
+ transform: translateY(100%);
+ opacity: 0;
+}
+
+.grid-size {
+ display: grid;
+ grid-template-columns: auto 1fr 1fr;
+}
+
+.overlay {
+ display: flex;
+ position: fixed;
+ flex-direction: column;
+ justify-content: start;
+ background-color: var(--in-content-page-background, -moz-dialog);
+ inset: 0;
+ z-index: 1;
+}
+
+#keySize {
+ width: 7em;
+}
+
+#importLoading {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ flex: 1;
+}
+
+.self-center {
+ align-self: center;
+}
+
+.loading-status {
+ width: 16px;
+ height: 16px;
+}
+
+/* Import key list */
+
+#keyListCount {
+ font-weight: bold;
+}
+
+#importKeyList {
+ display: grid;
+ row-gap: 6px;
+ margin: 18px 6px 9px;
+}
+
+.key-import-row {
+ display: grid;
+ grid-template-columns: 1fr max-content;
+ align-items: end;
+ border-radius: 4px;
+ padding: 3px 6px;
+ border: 1px solid var(--in-content-border-color);
+ background-color: rgba(215, 215, 219, 0.2);
+ margin-bottom: 6px;
+}
+
+.key-import-row.selected {
+ border: 1px solid #45A1FF;
+ background-color: rgba(69, 161, 255, 0.2);
+ margin-bottom: 0;
+}
+
+.openpgp-key-id {
+ font-weight: bold;
+}
+
+.openpgp-key-name {
+ font-size: 0.9em;
+}
+
+.openpgp-image-btn .button-icon {
+ margin-inline-end: 4px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.openpgp-props-btn {
+ list-style-image: url("chrome://messenger/skin/icons/developer.svg");
+}
+
+.extra-information-label {
+ display: grid;
+ grid-template-columns: auto max-content;
+ row-gap: 5px;
+ align-items: center;
+ margin-inline-end: 10px;
+}
+
+.extra-information-label-type {
+ font-weight: 600;
+ margin-inline-end: 4px;
+}
+
+.tip-caption {
+ color: var(--in-content-deemphasized-text);
+ font-size: .9em;
+}
+
+.description-centered {
+ text-align: center;
+ margin-inline: 20px;
+}
+
+.input-container {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+}
+
+.input-container input:not([type="number"],[type="color"]) {
+ flex: 1;
+}
diff --git a/comm/mail/themes/shared/openpgp/openPgpComposeStatus.css b/comm/mail/themes/shared/openpgp/openPgpComposeStatus.css
new file mode 100644
index 0000000000..3acb9c1b02
--- /dev/null
+++ b/comm/mail/themes/shared/openpgp/openPgpComposeStatus.css
@@ -0,0 +1,25 @@
+/* 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 url("chrome://messenger/skin/messenger.css");
+
+treecolpicker {
+ display: none;
+}
+
+#recipientKeyIdCol {
+ flex: 3;
+}
+
+#recipientStatusCol {
+ flex: 4;
+}
+
+#recipientComposeKeyCol {
+ flex: 3;
+}
+
+#statusComposeKeyCol {
+ flex: 2;
+}
diff --git a/comm/mail/themes/shared/smime/msgCompSecurityInfo.css b/comm/mail/themes/shared/smime/msgCompSecurityInfo.css
new file mode 100644
index 0000000000..201e11df6e
--- /dev/null
+++ b/comm/mail/themes/shared/smime/msgCompSecurityInfo.css
@@ -0,0 +1,25 @@
+/* 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 url("chrome://messenger/skin/messenger.css");
+
+#recipientHeader {
+ flex: 3;
+}
+
+#statusHeader {
+ flex: 1;
+}
+
+#issuedDateHeader {
+ flex: 2;
+}
+
+#expiresDateHeader {
+ flex: 2;
+}
+
+treecolpicker {
+ display: none;
+}
diff --git a/comm/mail/themes/windows/editor/EditorDialog.css b/comm/mail/themes/windows/editor/EditorDialog.css
new file mode 100644
index 0000000000..549f47e0f3
--- /dev/null
+++ b/comm/mail/themes/windows/editor/EditorDialog.css
@@ -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 url("chrome://messenger/skin/shared/EditorDialog.css");
+
+@media (-moz-windows-non-native-menus) {
+ menulist > menupopup > menuitem,
+ menulist > menupopup > menu {
+ padding-block: 0.2em;
+ }
+}
diff --git a/comm/mail/themes/windows/editor/img-align-bottom.gif b/comm/mail/themes/windows/editor/img-align-bottom.gif
new file mode 100644
index 0000000000..a8ba157970
--- /dev/null
+++ b/comm/mail/themes/windows/editor/img-align-bottom.gif
Binary files differ
diff --git a/comm/mail/themes/windows/editor/img-align-left.gif b/comm/mail/themes/windows/editor/img-align-left.gif
new file mode 100644
index 0000000000..117b93fcaf
--- /dev/null
+++ b/comm/mail/themes/windows/editor/img-align-left.gif
Binary files differ
diff --git a/comm/mail/themes/windows/editor/img-align-middle.gif b/comm/mail/themes/windows/editor/img-align-middle.gif
new file mode 100644
index 0000000000..4189f7ac33
--- /dev/null
+++ b/comm/mail/themes/windows/editor/img-align-middle.gif
Binary files differ
diff --git a/comm/mail/themes/windows/editor/img-align-right.gif b/comm/mail/themes/windows/editor/img-align-right.gif
new file mode 100644
index 0000000000..8e679f50a4
--- /dev/null
+++ b/comm/mail/themes/windows/editor/img-align-right.gif
Binary files differ
diff --git a/comm/mail/themes/windows/editor/img-align-top.gif b/comm/mail/themes/windows/editor/img-align-top.gif
new file mode 100644
index 0000000000..d871945929
--- /dev/null
+++ b/comm/mail/themes/windows/editor/img-align-top.gif
Binary files differ
diff --git a/comm/mail/themes/windows/jar.mn b/comm/mail/themes/windows/jar.mn
new file mode 100644
index 0000000000..fa66268a96
--- /dev/null
+++ b/comm/mail/themes/windows/jar.mn
@@ -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/.
+
+classic.jar:
+% skin messenger classic/1.0 %skin/classic/messenger/
+#include ../shared/jar.inc.mn
+ skin/classic/messenger/primaryToolbar.css (mail/primaryToolbar.css)
+ skin/classic/messenger/accountCentral.css (mail/accountCentral.css)
+ skin/classic/messenger/accountManage.css (mail/accountManage.css)
+ skin/classic/messenger/chat.css (mail/chat.css)
+ skin/classic/messenger/common.css (mail/common.css)
+ skin/classic/messenger/contextMenu.css (mail/contextMenu.css)
+ skin/classic/messenger/imAccounts.css (mail/imAccounts.css)
+ skin/classic/messenger/compacttheme.css (mail/compacttheme.css)
+ skin/classic/messenger/customizeToolbar.css (mail/customizeToolbar.css)
+ skin/classic/messenger/menulist.css (mail/menulist.css)
+ skin/classic/messenger/messageBody.css (mail/messageBody.css)
+ skin/classic/messenger/message-bar.css (mail/message-bar.css)
+ skin/classic/messenger/messageHeader.css (mail/messageHeader.css)
+ skin/classic/messenger/messageIcons.css (mail/messageIcons.css)
+ skin/classic/messenger/messenger.css (mail/messenger.css)
+ skin/classic/messenger/attachmentList.css (mail/attachmentList.css)
+ skin/classic/messenger/mailWindow1.css (mail/mailWindow1.css)
+ skin/classic/messenger/messageWindow.css (mail/messageWindow.css)
+ skin/classic/messenger/searchBox.css (mail/searchBox.css)
+ skin/classic/messenger/junkMail.css (mail/junkMail.css)
+ skin/classic/messenger/input-fields.css (mail/input-fields.css)
+ skin/classic/messenger/folderMenus.css (mail/folderMenus.css)
+ skin/classic/messenger/folderPane.css (mail/folderPane.css)
+ skin/classic/messenger/searchDialog.css (mail/searchDialog.css)
+ skin/classic/messenger/spacesToolbar.css (mail/spacesToolbar.css)
+ skin/classic/messenger/filterDialog.css (mail/filterDialog.css)
+ skin/classic/messenger/multimessageview.css (mail/multimessageview.css)
+ skin/classic/messenger/glodaFacetView.css (mail/glodaFacetView.css)
+ skin/classic/messenger/panelUI.css (mail/panelUI.css)
+ skin/classic/messenger/tabmail.css (mail/tabmail.css)
+ skin/classic/messenger/themeableDialog.css (mail/themeableDialog.css)
+ skin/classic/messenger/popupPanel.css (mail/popupPanel.css)
+ skin/classic/messenger/variables.css (mail/variables.css)
+ skin/classic/messenger/activity/activity.css (mail/activity/activity.css)
+ skin/classic/messenger/activity/addItemIcon.png (mail/activity/addItemIcon.png)
+ skin/classic/messenger/activity/compactMailIcon.png (mail/activity/compactMailIcon.png)
+ skin/classic/messenger/activity/copyMailIcon.png (mail/activity/copyMailIcon.png)
+ skin/classic/messenger/activity/defaultEventIcon.png (mail/activity/defaultEventIcon.png)
+ skin/classic/messenger/activity/defaultProcessIcon.png (mail/activity/defaultProcessIcon.png)
+ skin/classic/messenger/activity/deleteMailIcon.png (mail/activity/deleteMailIcon.png)
+ skin/classic/messenger/activity/indexMailIcon.png (mail/activity/indexMailIcon.png)
+ skin/classic/messenger/activity/moveMailIcon.png (mail/activity/moveMailIcon.png)
+ skin/classic/messenger/activity/removeItemIcon.png (mail/activity/removeItemIcon.png)
+ skin/classic/messenger/activity/sendMailIcon.png (mail/activity/sendMailIcon.png)
+ skin/classic/messenger/activity/syncMailIcon.png (mail/activity/syncMailIcon.png)
+ skin/classic/messenger/activity/undoIcon.png (mail/activity/undoIcon.png)
+ skin/classic/messenger/activity/warning.png (mail/activity/warning.png)
+ skin/classic/messenger/addressbook/abContactsPanel.css (mail/addrbook/abContactsPanel.css)
+ skin/classic/messenger/addressbook/cardDialog.css (mail/addrbook/cardDialog.css)
+ skin/classic/messenger/messengercompose/messengercompose.css (mail/compose/messengercompose.css)
+ skin/classic/messenger/downloads/aboutDownloads.css (mail/downloads/aboutDownloads.css)
+ skin/classic/messenger/preferences/alwaysAsk.png (mail/preferences/alwaysAsk.png)
+ skin/classic/messenger/preferences/application.png (mail/preferences/application.png)
+ skin/classic/messenger/preferences/applications.css (mail/preferences/applications.css)
+ skin/classic/messenger/preferences/preferences.css (mail/preferences/preferences.css)
+ skin/classic/messenger/preferences/saveFile.png (mail/preferences/saveFile.png)
+ skin/classic/messenger/icons/multicolor.png (mail/icons/multicolor.png)
+ skin/classic/messenger/icons/identity.png (mail/icons/identity.png)
+ skin/classic/messenger/icons/error.png (mail/icons/error.png)
+ skin/classic/messenger/window-controls/close.svg (mail/window-controls/close.svg)
+ skin/classic/messenger/window-controls/close-highcontrast.svg (mail/window-controls/close-highcontrast.svg)
+ skin/classic/messenger/window-controls/close-themes.svg (mail/window-controls/close-themes.svg)
+ skin/classic/messenger/window-controls/maximize.svg (mail/window-controls/maximize.svg)
+ skin/classic/messenger/window-controls/maximize-highcontrast.svg (mail/window-controls/maximize-highcontrast.svg)
+ skin/classic/messenger/window-controls/maximize-themes.svg (mail/window-controls/maximize-themes.svg)
+ skin/classic/messenger/window-controls/minimize.svg (mail/window-controls/minimize.svg)
+ skin/classic/messenger/window-controls/minimize-highcontrast.svg (mail/window-controls/minimize-highcontrast.svg)
+ skin/classic/messenger/window-controls/minimize-themes.svg (mail/window-controls/minimize-themes.svg)
+ skin/classic/messenger/window-controls/restore.svg (mail/window-controls/restore.svg)
+ skin/classic/messenger/window-controls/restore-highcontrast.svg (mail/window-controls/restore-highcontrast.svg)
+ skin/classic/messenger/window-controls/restore-themes.svg (mail/window-controls/restore-themes.svg)
+ skin/classic/messenger/icons/connecting.png (mail/icons/connecting.png)
+ skin/classic/messenger/icons/arrow/arrow-left.png (mail/icons/arrow/arrow-left.png)
+ skin/classic/messenger/icons/arrow/arrow-right.png (mail/icons/arrow/arrow-right.png)
+ skin/classic/messenger/icons/arrow/arrow-up.png (mail/icons/arrow/arrow-up.png)
+ skin/classic/messenger/icons/arrow/arrow-down.png (mail/icons/arrow/arrow-down.png)
+ skin/classic/messenger/icons/arrow/arrow-right-dim.png (mail/icons/arrow/arrow-right-dim.png)
+ skin/classic/messenger/icons/arrow/arrow-down-dim.png (mail/icons/arrow/arrow-down-dim.png)
+% skin messenger-newsblog classic/1.0 %skin/classic/messenger-newsblog/
+ skin/classic/messenger-newsblog/feed-subscriptions.css (mail/newsblog/feed-subscriptions.css)
+% skin editor classic/1.0 %skin/classic/editor/
+ skin/classic/editor/EditorDialog.css (editor/EditorDialog.css)
+ skin/classic/editor/icons/img-align-bottom.gif (editor/img-align-bottom.gif)
+ skin/classic/editor/icons/img-align-left.gif (editor/img-align-left.gif)
+ skin/classic/editor/icons/img-align-middle.gif (editor/img-align-middle.gif)
+ skin/classic/editor/icons/img-align-right.gif (editor/img-align-right.gif)
+ skin/classic/editor/icons/img-align-top.gif (editor/img-align-top.gif)
diff --git a/comm/mail/themes/windows/mail/accountCentral.css b/comm/mail/themes/windows/mail/accountCentral.css
new file mode 100644
index 0000000000..3654030241
--- /dev/null
+++ b/comm/mail/themes/windows/mail/accountCentral.css
@@ -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/. */
+
+/* ===== accountCentral.css ==========================================
+ == Styles for the Messenger Account Central panel.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/accountCentral.css");
+
+@media (-moz-windows-default-theme: 0) {
+ :root:not(:-moz-lwtheme) {
+ --bg-color: Window;
+ --text-color: WindowText;
+ --title-color: -moz-DialogText;
+ --primary-color: SelectedItem;
+ --primary-color-hover: SelectedItem;
+ --btn-color: -moz-DialogText;
+ --btn-color-hover: SelectedItemText;
+ --btn-bg: Dialog;
+ --btn-bg-hover: SelectedItem;
+ --btn-shadow-hover: transparent;
+ --popup-bg: Window;
+ }
+
+ :root:not(:-moz-lwtheme) .btn-hub {
+ border: 1px solid -moz-DialogText;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/accountManage.css b/comm/mail/themes/windows/mail/accountManage.css
new file mode 100644
index 0000000000..1e90c9dd0f
--- /dev/null
+++ b/comm/mail/themes/windows/mail/accountManage.css
@@ -0,0 +1,5 @@
+/* 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 url("chrome://messenger/skin/shared/accountManage.css");
diff --git a/comm/mail/themes/windows/mail/activity/activity.css b/comm/mail/themes/windows/mail/activity/activity.css
new file mode 100644
index 0000000000..3c8f2726bf
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/activity.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/activity/activity.css");
+
+@media (-moz-windows-glass) {
+ :root:not(:-moz-lwtheme) .activityview {
+ border: 1px solid hsla(240, 5%, 5%, .3);
+ background-clip: padding-box;
+ }
+
+ :root:not(:-moz-lwtheme) #clearListButton {
+ margin-inline-start: 0;
+ margin-bottom: 0;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/activity/addItemIcon.png b/comm/mail/themes/windows/mail/activity/addItemIcon.png
new file mode 100644
index 0000000000..fc5e505885
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/addItemIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/compactMailIcon.png b/comm/mail/themes/windows/mail/activity/compactMailIcon.png
new file mode 100644
index 0000000000..d3b3ce6ece
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/compactMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/copyMailIcon.png b/comm/mail/themes/windows/mail/activity/copyMailIcon.png
new file mode 100644
index 0000000000..5671b521f7
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/copyMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/defaultEventIcon.png b/comm/mail/themes/windows/mail/activity/defaultEventIcon.png
new file mode 100644
index 0000000000..033e7ec1b3
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/defaultEventIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/defaultProcessIcon.png b/comm/mail/themes/windows/mail/activity/defaultProcessIcon.png
new file mode 100644
index 0000000000..7f823711d2
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/defaultProcessIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/deleteMailIcon.png b/comm/mail/themes/windows/mail/activity/deleteMailIcon.png
new file mode 100644
index 0000000000..191a0fd7d3
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/deleteMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/indexMailIcon.png b/comm/mail/themes/windows/mail/activity/indexMailIcon.png
new file mode 100644
index 0000000000..7dffa4e186
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/indexMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/moveMailIcon.png b/comm/mail/themes/windows/mail/activity/moveMailIcon.png
new file mode 100644
index 0000000000..6da75d0331
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/moveMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/removeItemIcon.png b/comm/mail/themes/windows/mail/activity/removeItemIcon.png
new file mode 100644
index 0000000000..6723d12b98
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/removeItemIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/sendMailIcon.png b/comm/mail/themes/windows/mail/activity/sendMailIcon.png
new file mode 100644
index 0000000000..e9a77d5ee9
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/sendMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/syncMailIcon.png b/comm/mail/themes/windows/mail/activity/syncMailIcon.png
new file mode 100644
index 0000000000..530a95e90b
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/syncMailIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/undoIcon.png b/comm/mail/themes/windows/mail/activity/undoIcon.png
new file mode 100644
index 0000000000..50d36c8772
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/undoIcon.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/activity/warning.png b/comm/mail/themes/windows/mail/activity/warning.png
new file mode 100644
index 0000000000..7daab9c788
--- /dev/null
+++ b/comm/mail/themes/windows/mail/activity/warning.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/addrbook/abContactsPanel.css b/comm/mail/themes/windows/mail/addrbook/abContactsPanel.css
new file mode 100644
index 0000000000..b5b6e3f7b1
--- /dev/null
+++ b/comm/mail/themes/windows/mail/addrbook/abContactsPanel.css
@@ -0,0 +1,35 @@
+/* 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/. */
+
+/* ===== sidebarPanel.css ===============================================
+ == Styles for the Address Book sidebar panel.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/abContactsPanel.css");
+
+#peopleSearchInput {
+ min-height: 24px;
+}
+
+#AbPickerHeader > label {
+ margin-block: 3px 0;
+}
+
+#abContextMenuButton {
+ margin-block: -4px 2px;
+ padding-inline: 5px;
+}
+
+#abContextMenuButton > .toolbarbutton-icon {
+ margin-inline-end: 0;
+}
+
+:root:not([lwt-tree]) #abResultsTree {
+ border-top: 1px solid #a9b1b8;
+ border-bottom: 1px solid #a9b1b8;
+}
+
+#GeneratedName {
+ padding-inline-start: 28px;
+}
diff --git a/comm/mail/themes/windows/mail/addrbook/cardDialog.css b/comm/mail/themes/windows/mail/addrbook/cardDialog.css
new file mode 100644
index 0000000000..07379b327c
--- /dev/null
+++ b/comm/mail/themes/windows/mail/addrbook/cardDialog.css
@@ -0,0 +1,13 @@
+/* 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/. */
+
+/* ===== cardViewOverlay.css ============================================
+ == Styles for Address Book dialogs.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/cardDialog.css");
+
+menulist::part(icon) {
+ margin-inline-end: 4px;
+}
diff --git a/comm/mail/themes/windows/mail/attachmentList.css b/comm/mail/themes/windows/mail/attachmentList.css
new file mode 100644
index 0000000000..45b16ed099
--- /dev/null
+++ b/comm/mail/themes/windows/mail/attachmentList.css
@@ -0,0 +1,22 @@
+/* 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 url("chrome://messenger/skin/shared/attachmentList.css");
+
+/* styles for the attachment list */
+
+.attachmentItem:where(:hover) {
+ background-color: hsla(0, 0%, 50%, 0.15);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:-moz-lwtheme .attachmentList .attachmentItem[selected="true"] {
+ color: var(--lwt-text-color);
+ background: rgba(249, 249, 250, .1);
+ }
+
+ :root:-moz-lwtheme .attachmentList:focus .attachmentItem[selected="true"] {
+ background: var(--dark-lwt-highlight-color);
+ }
+}
diff --git a/comm/mail/themes/windows/mail/chat.css b/comm/mail/themes/windows/mail/chat.css
new file mode 100644
index 0000000000..7ba0dba0ad
--- /dev/null
+++ b/comm/mail/themes/windows/mail/chat.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/chat.css");
+
+.convUnreadTargetedCount {
+ padding: 0 7px;
+}
+
+/* Adaptation from #folderTree */
+:root:not([lwt-tree]) #listPaneBox {
+ appearance: none;
+ background-color: Field;
+ color: FieldText;
+}
+
+#listPaneBox > * {
+ background: transparent !important;
+ appearance: none !important;
+ border: none;
+}
+
+#conversationsBox {
+ background: var(--imbox-selected-background-color);
+}
+
+.conv-textbox > .textbox-input-box {
+ background: inherit;
+}
+
+.conv-counter {
+ padding-bottom: 0;
+ padding-inline-end: 5px;
+ margin-inline-end: 2px;
+ margin-bottom: 2px !important; /* override 4px from description */
+}
+
+.conv-counter[value^="-"] {
+ margin-inline-end: 2px;
+ padding-inline-end: 4px;
+}
+
+.splitter.conv-chat {
+ border-left: 1px solid rgba(0, 0, 0, 0.25);
+}
+
+#contextPane {
+ background-color: Field;
+ color: FieldText;
+}
+
+.userIcon {
+ border-width: 0;
+}
+
+#logTree,
+.conv-nicklist {
+ appearance: none;
+ border-style: none;
+ margin: 0;
+}
+
+.conv-nicklist-header,
+.conv-logs-header-label {
+ appearance: none;
+ margin: 0;
+ padding-block: 2px;
+ border-bottom: 1px solid ThreeDShadow;
+ background-color: -moz-Dialog;
+}
+
+.conv-nicklist-header-label {
+ font-weight: bold;
+ margin-inline: 0 2px !important;
+}
+
+.statusMessageToolbarItem {
+ margin: 0 1px;
+ margin-inline-start: -2px;
+ padding: 3px 3px 4px;
+}
+
+#listSplitter,
+#contextSplitter {
+ background-color: transparent;
+ min-width: 0;
+}
+
+#listSplitter {
+ border-inline-end-width: 0;
+}
+
+#contextSplitter {
+ border-inline-start-width: 0;
+}
+
+#nicklist > richlistitem[inactive][selected] > label {
+ color: -moz-cellhighlighttext !important;
+}
+
+richlistitem[is="chat-group-richlistitem"] .twisty {
+ margin-inline-end: 2px;
+}
+
+@media (-moz-windows-default-theme) {
+ .conv-status-container {
+ border-bottom-color: var(--color-gray-30);
+ }
+
+ #statusTypeIcon > .toolbarbutton-menu-dropmarker {
+ padding: 1px 3px;
+ }
+
+ #chat-status-selector > vbox > .statusMessageToolbarItem[editing] {
+ appearance: none;
+ padding-inline: 2px;
+ }
+}
+
+@media (-moz-platform: windows-win7) and (-moz-windows-default-theme) {
+ :root:not([lwt-tree]) #listPaneBox {
+ background-color: rgb(238, 243, 250);
+ }
+
+ #conversationsBox {
+ background-color: rgb(233, 239, 245);
+ }
+
+ .conv-nicklist-header,
+ .conv-logs-header-label {
+ background-color: rgb(233, 239, 245);
+ }
+}
+
+@media (-moz-platform: windows-win8) and (-moz-windows-default-theme) {
+ :root:not([lwt-tree]) #listPaneBox {
+ background-color: -moz-Dialog;
+ }
+}
+
+@media (-moz-platform: windows-win10) and (-moz-windows-default-theme) {
+ :root:not([lwt-tree],:-moz-lwtheme) #listPaneBox {
+ background-color: #fafafa;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/common.css b/comm/mail/themes/windows/mail/common.css
new file mode 100644
index 0000000000..0811e25822
--- /dev/null
+++ b/comm/mail/themes/windows/mail/common.css
@@ -0,0 +1,35 @@
+/* - 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 url("chrome://messenger/skin/shared/common.css");
+
+@namespace html "http://www.w3.org/1999/xhtml";
+@namespace xul "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+xul|checkbox,
+xul|radio {
+ padding-inline-start: 0;
+}
+
+/* Override menulist.css */
+xul|menulist[disabled="true"] {
+ background-color: var(--in-content-button-background);
+}
+
+html|button {
+ /* XUL button min-width */
+ min-width: 6.3em;
+}
+
+xul|tab {
+ min-height: 2.5em;
+}
+
+:host(dialog[subdialog]) .dialog-button-box > button {
+ min-height: var(--in-content-button-height);
+ padding-block: initial;
+ padding-inline: 15px;
+ border-color: transparent;
+ border-radius: var(--in-content-button-border-radius);
+}
diff --git a/comm/mail/themes/windows/mail/compacttheme.css b/comm/mail/themes/windows/mail/compacttheme.css
new file mode 100644
index 0000000000..0841693499
--- /dev/null
+++ b/comm/mail/themes/windows/mail/compacttheme.css
@@ -0,0 +1,138 @@
+/* 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 url("chrome://messenger/skin/shared/compacttheme.css");
+
+/* The window background is white due to no accentcolor in the lightweight
+ theme. It can't be changed to transparent when there is no compositor
+ (Win 7 in classic / basic theme), or else dragging and focus become
+ broken. So instead just show the normal titlebar in that case, and override
+ the window color as transparent when the compositor is available. */
+@media (-moz-windows-compositor: 0) {
+ /* Prevent accent color overriding the window background for
+ * light and dark theme on Aero Basic. This is copied from browser-aero.css. */
+ @media (-moz-windows-default-theme) {
+ #messengerWindow {
+ background-color: rgb(185,209,234) !important;
+ }
+ #messengerWindow:-moz-window-inactive {
+ background-color: rgb(215,228,242) !important;
+ }
+ }
+}
+
+@media (-moz-platform: windows-win7) {
+ @media (-moz-windows-default-theme) {
+ /* Always show light toolbar elements on aero surface. */
+ #tabs-toolbar {
+ color: hsl(240,9%,98%);
+ }
+
+ /* Keep showing the correct color inside the tabs. */
+ .tabmail-tab {
+ color: var(--lwt-text-color) !important;
+ }
+
+ #messengerWindow[tabsintitlebar] #mail-menubar > menu {
+ color: inherit;
+ }
+
+ :root[tabsintitlebar] #tabs-toolbar .toolbarbutton-1,
+ :root[tabsintitlebar] #tabmail-arrowscrollbox::part(scrollbutton-up),
+ :root[tabsintitlebar] #tabmail-arrowscrollbox::part(scrollbutton-down) {
+ fill: CaptionText;
+ }
+ }
+}
+
+@media (-moz-windows-glass) {
+ /* Use opaque white icons on Aero Glass. */
+ #tabs-toolbar {
+ --toolbarbutton-icon-fill: white;
+ }
+
+ :root[tabsintitlebar] #mail-menubar,
+ :root[tabsintitlebar]:not([inFullscreen]) #mail-menubar:-moz-window-inactive {
+ color: inherit;
+ }
+}
+
+@media (-moz-platform: windows-win7),
+ (-moz-platform: windows-win8) {
+ #messengerWindow .statusbar {
+ background-color: var(--lwt-accent-color);
+ }
+
+ @media (-moz-windows-compositor) {
+ #messengerWindow[windowtype="mail:3pane"] {
+ background: transparent !important;
+ }
+ }
+
+ /* Show border on tabs with background colors and
+ * show the tabs toolbar background color inside tabs. */
+ .tabmail-tab {
+ background-color: var(--lwt-accent-color) !important;
+ border-top: 1px solid var(--lwt-tabs-border-color);
+ background-clip: padding-box;
+ }
+
+ /* The top border on top of the tab background is replaced
+ * by the slightly transparent outside tabs-border-color. */
+ .tab-background {
+ border-top-style: none !important;
+ }
+
+ /* The border at the start of the tab strip is replaced
+ * by the slightly transparent outside tabs-border-color. */
+ .tabmail-tab:first-child {
+ margin-inline-start: 0 !important;
+ border-inline-start: 1px solid var(--lwt-tabs-border-color);
+ }
+
+ /* The border at the end of the tab strip is replaced
+ * by the slightly transparent outside tabs-border-color. */
+ .tabmail-tab:last-child {
+ border-inline-end: 1px solid var(--lwt-tabs-border-color);
+ }
+
+ .tabmail-tab:first-child::before,
+ .tabmail-tab:last-child::after {
+ display: none !important;
+ }
+
+ /* Use proper menu text styling in Win7 classic mode (copied from browser.css) */
+ @media (-moz-windows-default-theme: 0) {
+ :root[tabsintitlebar]:not([inFullscreen]) #mail-menubar,
+ :root[tabsintitlebar]:not([inFullscreen]) unified-toolbar {
+ color: CaptionText;
+ }
+
+ :root[tabsintitlebar]:not([inFullscreen]) #mail-menubar:-moz-window-inactive,
+ :root[tabsintitlebar]:not([inFullscreen]) unified-toolbar:-moz-window-inactive {
+ color: InactiveCaptionText;
+ }
+
+ #messengerWindow[tabsintitlebar] #mail-menubar > menu {
+ color: inherit;
+ }
+
+ #tabs-toolbar .toolbarbutton-1,
+ #tabmail-arrowscrollbox::part(scrollbutton-up),
+ #tabmail-arrowscrollbox::part(scrollbutton-down) {
+ fill: CaptionText;
+ }
+ }
+}
+
+/* Restored windows get an artificial border on windows, because the lwtheme background
+ * overlaps the regular window border. That isn't the case for us, so we avoid painting
+ * over the native border with our custom borders: */
+#navigation-toolbox {
+ /* These are !important to avoid specificity-wars with the selectors that add borders here. */
+ background-image: none !important;
+ border-top: none !important;
+ box-shadow: none !important;
+ padding-top: 0 !important;
+}
diff --git a/comm/mail/themes/windows/mail/compose/messengercompose.css b/comm/mail/themes/windows/mail/compose/messengercompose.css
new file mode 100644
index 0000000000..f4765d53b2
--- /dev/null
+++ b/comm/mail/themes/windows/mail/compose/messengercompose.css
@@ -0,0 +1,210 @@
+/* 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/. */
+
+/* ===== messengercompose.css ===========================================
+ == Styles for the main Messenger Compose window.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/shared/messengercompose.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+/* ::::: special toolbar colors ::::: */
+
+@media (-moz-platform: windows-win10) and (-moz-windows-default-theme) {
+ #menubar-items > #mail-menubar > menu[disabled="true"] {
+ background-color: transparent;
+ }
+
+ #menubar-items > #mail-menubar >
+ menu:not([disabled="true"])[_moz-menuactive="true"] {
+ background-color: hsla(0, 0%, 0%, .12);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ #menubar-items > #mail-menubar >
+ menu:not([disabled="true"])[_moz-menuactive="true"] {
+ background-color: hsla(0, 0%, 100%, .2);
+ }
+ }
+}
+
+#composeContentBox {
+ appearance: none;
+ color: -moz-dialogtext;
+ background-color: -moz-Dialog;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2) inset;
+ border-top-width: 0;
+}
+
+#composeContentBox:-moz-window-inactive {
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1) inset;
+}
+
+#MsgHeadersToolbar {
+ color: -moz-DialogText;
+ text-shadow: none;
+ padding-block: 4px 2px;
+}
+
+#subjectLabel {
+ margin-bottom: 0;
+}
+
+@media (-moz-windows-classic) {
+ :root:not(:-moz-lwtheme) .autocomplete-richlistitem {
+ --arrowpanel-dimmed: Highlight;
+ }
+}
+
+@media (prefers-contrast) {
+ #msgSubject:not(:-moz-lwtheme),
+ #msgIdentity:not(:-moz-lwtheme),
+ .address-container:not(:-moz-lwtheme) {
+ --toolbarbutton-hover-bordercolor: ThreeDShadow;
+ }
+
+ #msgSubject:not(:-moz-lwtheme):hover,
+ #msgIdentity:not(:-moz-lwtheme):hover,
+ .address-container:not(:-moz-lwtheme):hover {
+ --toolbarbutton-hover-bordercolor: ThreeDDarkShadow;
+ }
+
+ #MsgHeadersToolbar:not(:-moz-lwtheme) .address-pill {
+ box-shadow: inset 0 0 0 1px ThreeDShadow;
+ }
+
+ #MsgHeadersToolbar:not(:-moz-lwtheme) .address-pill:hover:not(.editing),
+ #MsgHeadersToolbar:not(:-moz-lwtheme) .address-pill:focus:not(.editing) {
+ box-shadow: inset 0 0 0 1px SelectedItem;
+ }
+
+ #MsgHeadersToolbar:not(:-moz-lwtheme) .address-pill[selected] {
+ color: SelectedItemText;
+ background-color: SelectedItem;
+ box-shadow: inset 0 0 0 1px SelectedItem, inset 0 0 0 2em SelectedItem;
+ }
+
+ #MsgHeadersToolbar:not(:-moz-lwtheme) .address-pill[selected]:hover:not(.editing),
+ #MsgHeadersToolbar:not(:-moz-lwtheme) .address-pill[selected]:focus:not(.editing) {
+ box-shadow: inset 0 0 0 1px SelectedItemText, inset 0 0 0 2em SelectedItem;
+ text-shadow: none;
+ }
+
+ #MsgHeadersToolbar:not(:-moz-lwtheme) .address-pill:not([selected]) .pill-indicator {
+ fill: ThreeDShadow;
+ }
+
+ #MsgHeadersToolbar:not(:-moz-lwtheme)
+ .address-pill:not([selected],.editing):hover .pill-indicator {
+ fill: SelectedItem;
+ }
+}
+
+#identityLabel-box {
+ margin-top: 1px;
+}
+
+#msgIdentity {
+ box-shadow: none;
+}
+
+#msgIdentity:-moz-focusring:not([open="true"])::part(label-box) {
+ outline: none;
+}
+
+@media (prefers-contrast) {
+ #msgIdentity::part(label-box) {
+ background-color: transparent !important;
+ color: inherit !important;
+ }
+}
+
+#msgIdentity::part(text-input) {
+ background-color: inherit;
+ color: inherit;
+ margin-block: 2px;
+}
+
+.address-label-container {
+ padding-top: 5px;
+}
+
+.address-container {
+ padding: 0 4px;
+}
+
+#msgIdentity,
+.address-container {
+ min-height: 28px;
+}
+
+#msgSubject {
+ min-height: 26px;
+ margin-top: 0;
+ padding-inline-start: 5px;
+}
+
+/* ::::: format toolbar ::::: */
+
+#FormatToolbar {
+ margin-block-end: 2px;
+}
+
+#FontFaceSelect {
+ max-width: 35ch;
+}
+
+toolbarbutton.formatting-button {
+ margin: 1px;
+}
+
+/* ::::: address book sidebar ::::: */
+
+#compose-toolbox {
+ appearance: none;
+ border-bottom: 1px solid var(--chrome-content-separator-color);
+}
+
+@media (-moz-windows-default-theme) {
+ @media (-moz-platform: windows-win8),
+ (-moz-platform: windows-win10) {
+ #compose-toolbox:not(:-moz-lwtheme) {
+ --chrome-content-separator-color: #c2c2c2;
+ }
+ }
+}
+
+toolbar:not(:-moz-lwtheme) {
+ appearance: none;
+}
+
+#compose-toolbox > toolbar:not([type="menubar"]) {
+ padding: 2px 1px;
+}
+
+#compose-toolbox > toolbar:not([type="menubar"]):-moz-lwtheme {
+ text-shadow: none;
+}
+
+/* ::::: autocomplete icons ::::: */
+
+.ac-site-icon {
+ display: flex;
+ margin: 1px 5px;
+}
+
+/* ::::: address book sidebar ::::: */
+
+#contactsBrowser {
+ background-color: -moz-Dialog;
+}
+
+@media (-moz-windows-glass) {
+ #compose-toolbox:not(:-moz-lwtheme) {
+ color: black;
+ text-shadow: 0 0 .7em white, 0 0 .7em white, 0 1px 0 rgba(255, 255, 255, .4);
+ }
+}
diff --git a/comm/mail/themes/windows/mail/contextMenu.css b/comm/mail/themes/windows/mail/contextMenu.css
new file mode 100644
index 0000000000..efec4e8ec9
--- /dev/null
+++ b/comm/mail/themes/windows/mail/contextMenu.css
@@ -0,0 +1,74 @@
+/* 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 url("chrome://messenger/skin/shared/contextMenu.css");
+
+/* Disabled empty item looks too small otherwise, because it has no icon. */
+menuitem[disabled="true"]:not(.menuitem-iconic) {
+ /* This is 16px for an icon + 3px for its margins + 1px for its padding +
+ * 2px for its border. */
+ min-height: 22px;
+}
+
+menupopup:not([type="arrow"]) {
+ margin: -4px;
+}
+
+@media (prefers-contrast) {
+ menupopup:not(:-moz-lwtheme) > :is(menu, menuitem):not([disabled="true"])[_moz-menuactive] {
+ color: SelectedItemText;
+ }
+}
+
+@media (-moz-windows-non-native-menus) {
+ menupopup[needsgutter] menu:not([icon], .menu-iconic),
+ menupopup[needsgutter] menuitem:not([checked="true"], [icon], .menuitem-iconic) {
+ padding-inline-start: 32px;
+ }
+
+ menupopup > :is(menu, menuitem):not([needsgutter]) >
+ menuitem:not([icon], .menuitem-iconic) {
+ padding-inline-start: 1em;
+ }
+
+ menuitem[checked="true"] {
+ padding-inline-start: 8px;
+ }
+}
+
+@media (-moz-windows-non-native-menus: 0) {
+ menupopup {
+ appearance: none;
+ background-color: transparent;
+ border: none;
+ /* Somehow the double border radius is needed to look correct. */
+ border-radius: calc(2 * var(--arrowpanel-border-radius));
+ }
+
+ menupopup:not([type="arrow"])::part(content) {
+ --panel-shadow-margin: 4px;
+ --panel-shadow: 0 0 4px hsla(0, 0%, 0%, 0.2);
+ }
+
+ /* Override popup.css */
+ menulist > menupopup {
+ --panel-background: var(--arrowpanel-background);
+ --panel-border-color: var(--arrowpanel-border-color);
+ }
+
+ :is(.menuitem-iconic, .menu-iconic) > .menu-iconic-left,
+ menupopup > menuitem:is([type="checkbox"],[type="radio"]) > .menu-iconic-left,
+ menupopup > menuitem > .menu-text {
+ appearance: none;
+ }
+
+ menuitem[checked="true"] > .menu-iconic-left > .menu-iconic-icon {
+ display: block;
+ }
+
+ .menu-text, .menu-iconic-text,
+ menupopup > :is(menu, menuitem) > .menu-text {
+ margin-inline-start: 8px !important;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/customizeToolbar.css b/comm/mail/themes/windows/mail/customizeToolbar.css
new file mode 100644
index 0000000000..cfe4305016
--- /dev/null
+++ b/comm/mail/themes/windows/mail/customizeToolbar.css
@@ -0,0 +1,16 @@
+/* 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 url("chrome://messenger/skin/shared/customizeToolbar.css");
+
+#palette-box {
+ margin: 0 4px 10px;
+}
+
+@media (-moz-windows-default-theme: 0) {
+ :root:not(:-moz-lwtheme) #palette-box {
+ background-color: transparent;
+ border-color: ThreeDShadow;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/downloads/aboutDownloads.css b/comm/mail/themes/windows/mail/downloads/aboutDownloads.css
new file mode 100644
index 0000000000..8351498da9
--- /dev/null
+++ b/comm/mail/themes/windows/mail/downloads/aboutDownloads.css
@@ -0,0 +1,47 @@
+/* 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 url("chrome://messenger/skin/shared/aboutDownloads.css");
+
+@media (-moz-windows-glass) {
+ #downloadTopBox:not(:-moz-lwtheme) {
+ --chrome-content-separator-color: #aabccf;
+ }
+}
+
+@media (-moz-windows-default-theme) {
+ #downloadTopBox:not(:-moz-lwtheme) {
+ --chrome-content-separator-color: #c2c2c2;
+ }
+
+ /*
+ -moz-default-appearance: menuitem is almost right, but the hover effect is
+ not transparent and is lighter than desired.
+
+ Copied from the autocomplete richlistbox styling in
+ toolkit/themes/windows/global/autocomplete.css
+
+ This styling should be kept in sync with the style from the above file.
+ */
+ :root:not([lwt-tree]) #msgDownloadsRichListBox > .download[selected] {
+ color: inherit;
+ background-color: transparent;
+ /* four gradients for the bevel highlights on each edge, one for blue background */
+ background-image:
+ linear-gradient(to bottom, rgba(255,255,255,0.9) 3px, transparent 3px),
+ linear-gradient(to right, rgba(255,255,255,0.5) 3px, transparent 3px),
+ linear-gradient(to left, rgba(255,255,255,0.5) 3px, transparent 3px),
+ linear-gradient(to top, rgba(255,255,255,0.4) 3px, transparent 3px),
+ linear-gradient(to bottom, rgba(163,196,247,0.3), rgba(122,180,246,0.3));
+ background-clip: content-box;
+ border-radius: 6px;
+ outline: 1px solid rgb(124,163,206);
+ outline-offset: -2px;
+ --in-content-button-text-color-hover: inherit;
+ }
+}
+
+.downloadButton > .button-box > .button-icon {
+ list-style-image: url("chrome://global/skin/icons/folder.svg");
+}
diff --git a/comm/mail/themes/windows/mail/filterDialog.css b/comm/mail/themes/windows/mail/filterDialog.css
new file mode 100644
index 0000000000..fc934ffe23
--- /dev/null
+++ b/comm/mail/themes/windows/mail/filterDialog.css
@@ -0,0 +1,46 @@
+/* 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/. */
+
+/* ===== filterDialog.css ===============================================
+ == Styles for the Mail Filters dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/filterDialog.css");
+
+/* ::::: columns :::::: */
+
+#gray_horizontal_splitter {
+ margin-inline: -6px;
+}
+
+#filterHeader,
+#filterListGrid {
+ margin: 8px 6px 0;
+}
+
+#FilterEditor {
+ padding-inline: 4px;
+}
+
+.search-menulist,
+.search-value-menulist {
+ width: 11em;
+}
+
+.input-inline.search-value-input {
+ padding-block: 3px;
+}
+
+.small-button {
+ min-width: 3em;
+ margin: 1px 2px;
+}
+
+.small-button + .small-button {
+ margin-inline: 0 4px;
+}
+
+toolbarbutton[is="toolbarbutton-menu-button"] > toolbarbutton {
+ min-height: 22px;
+}
diff --git a/comm/mail/themes/windows/mail/folderMenus.css b/comm/mail/themes/windows/mail/folderMenus.css
new file mode 100644
index 0000000000..a347ba2898
--- /dev/null
+++ b/comm/mail/themes/windows/mail/folderMenus.css
@@ -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/. */
+
+/* ===== folderMenus.css ================================================
+ == Icons for menus which represent mail folder.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/folderMenus.css");
+
+.folderMenuItem .menu-iconic-left {
+ display: flex;
+}
+
+.menulist-menupopup[is="folder-menupopup"] {
+ list-style-image: none;
+}
diff --git a/comm/mail/themes/windows/mail/folderPane.css b/comm/mail/themes/windows/mail/folderPane.css
new file mode 100644
index 0000000000..2303ce2b5c
--- /dev/null
+++ b/comm/mail/themes/windows/mail/folderPane.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/folderPane.css");
+
+/* ::::: All Servers ::::: */
+
+treechildren::-moz-tree-image(folderNameCol) {
+ margin-top: 2px;
+}
+
+@media (-moz-windows-default-theme: 0) {
+ #folderTree treechildren::-moz-tree-cell-text(folderNameCol, newMessages-true) {
+ padding-inline-start: 23px !important;
+ }
+
+ treechildren::-moz-tree-image(folderNameCol, newMessages-true) {
+ fill: color-mix(in srgb, SelectedItemText 20%, transparent) !important;
+ stroke: SelectedItemText !important;
+ }
+
+ :root:not(:-moz-lwtheme) treechildren::-moz-tree-image(folderNameCol) {
+ --default: FieldText;
+ --primary: SelectedItem;
+ }
+}
+
+@media (prefers-contrast) {
+ treechildren::-moz-tree-image(folderNameCol, newMessages-true, selected, focus) {
+ fill: color-mix(in srgb, SelectedItemText 20%, transparent) !important;
+ stroke: SelectedItemText !important;
+ }
+}
+
+#folderTree treechildren::-moz-tree-indentation {
+ width: 8px;
+}
+
+/* UI Density customization */
+
+#folderTree > treechildren::-moz-tree-row {
+ min-height: 1.8rem;
+}
+
+:root[uidensity="compact"] #folderTree > treechildren::-moz-tree-row {
+ min-height: 1.6rem;
+}
+
+:root[uidensity="touch"] #folderTree > treechildren::-moz-tree-row {
+ min-height: 2.4rem;
+}
diff --git a/comm/mail/themes/windows/mail/glodaFacetView.css b/comm/mail/themes/windows/mail/glodaFacetView.css
new file mode 100644
index 0000000000..dd4ddfaa01
--- /dev/null
+++ b/comm/mail/themes/windows/mail/glodaFacetView.css
@@ -0,0 +1,5 @@
+/* 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 url("chrome://messenger/skin/shared/glodaFacetView.css");
diff --git a/comm/mail/themes/windows/mail/icons/arrow/arrow-down-dim.png b/comm/mail/themes/windows/mail/icons/arrow/arrow-down-dim.png
new file mode 100644
index 0000000000..4f7fcd5784
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/arrow/arrow-down-dim.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/arrow/arrow-down.png b/comm/mail/themes/windows/mail/icons/arrow/arrow-down.png
new file mode 100644
index 0000000000..d2df341a58
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/arrow/arrow-down.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/arrow/arrow-left.png b/comm/mail/themes/windows/mail/icons/arrow/arrow-left.png
new file mode 100644
index 0000000000..6607869ad0
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/arrow/arrow-left.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/arrow/arrow-right-dim.png b/comm/mail/themes/windows/mail/icons/arrow/arrow-right-dim.png
new file mode 100644
index 0000000000..49dc2d55e4
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/arrow/arrow-right-dim.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/arrow/arrow-right.png b/comm/mail/themes/windows/mail/icons/arrow/arrow-right.png
new file mode 100644
index 0000000000..f9e33978e7
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/arrow/arrow-right.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/arrow/arrow-up.png b/comm/mail/themes/windows/mail/icons/arrow/arrow-up.png
new file mode 100644
index 0000000000..1eb4d4ceb2
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/arrow/arrow-up.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/connecting.png b/comm/mail/themes/windows/mail/icons/connecting.png
new file mode 100644
index 0000000000..3c8e71f5db
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/connecting.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/error.png b/comm/mail/themes/windows/mail/icons/error.png
new file mode 100644
index 0000000000..628cf2dae3
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/error.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/identity.png b/comm/mail/themes/windows/mail/icons/identity.png
new file mode 100644
index 0000000000..8d4f3bc327
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/identity.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/jumplist.png b/comm/mail/themes/windows/mail/icons/jumplist.png
new file mode 100644
index 0000000000..d070cd1671
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/jumplist.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/icons/multicolor.png b/comm/mail/themes/windows/mail/icons/multicolor.png
new file mode 100644
index 0000000000..b96853cf37
--- /dev/null
+++ b/comm/mail/themes/windows/mail/icons/multicolor.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/imAccounts.css b/comm/mail/themes/windows/mail/imAccounts.css
new file mode 100644
index 0000000000..3608daf7dd
--- /dev/null
+++ b/comm/mail/themes/windows/mail/imAccounts.css
@@ -0,0 +1,13 @@
+/* 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 url("chrome://messenger/skin/shared/imAccounts.css");
+
+richlistitem .account-buttons {
+ margin-inline-start: 32px;
+}
+
+#statusTypeIcon .button-box {
+ border-style: none;
+}
diff --git a/comm/mail/themes/windows/mail/input-fields.css b/comm/mail/themes/windows/mail/input-fields.css
new file mode 100644
index 0000000000..42b639a462
--- /dev/null
+++ b/comm/mail/themes/windows/mail/input-fields.css
@@ -0,0 +1,20 @@
+/* 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 url("chrome://messenger/skin/shared/input-fields.css");
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+html|input.input-inline {
+ padding: 1px;
+ padding-inline-start: 4px;
+}
+
+html|input.input-filefield {
+ background: center left 2px / 16px no-repeat;
+}
+
+html|input[type="number"]::-moz-number-spin-up,
+html|input[type="number"]::-moz-number-spin-down {
+ min-height: .7em;
+}
diff --git a/comm/mail/themes/windows/mail/junkMail.css b/comm/mail/themes/windows/mail/junkMail.css
new file mode 100644
index 0000000000..8cd3f7aa48
--- /dev/null
+++ b/comm/mail/themes/windows/mail/junkMail.css
@@ -0,0 +1,15 @@
+/* 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/. */
+
+/*===== junkMail=======.css ==============================================
+ == Styles for the junk mail dialog
+ ======================================================================== */
+
+@import url("chrome://messenger/skin/messenger.css");
+
+/* ::::: account manager :::::: */
+
+.specialFolderPickerGrid {
+ margin-inline-start: 20px;
+}
diff --git a/comm/mail/themes/windows/mail/mailWindow1.css b/comm/mail/themes/windows/mail/mailWindow1.css
new file mode 100644
index 0000000000..903a120078
--- /dev/null
+++ b/comm/mail/themes/windows/mail/mailWindow1.css
@@ -0,0 +1,461 @@
+/* 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/. */
+
+/* ===== mailWindow1.css ================================================
+ == Styles for the main Mail window in the default layout scheme.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/primaryToolbar.css");
+@import url("chrome://messenger/skin/folderPane.css");
+@import url("chrome://messenger/skin/messageIcons.css");
+@import url("chrome://messenger/skin/shared/mailWindow1.css");
+
+#messengerBox {
+ background-color: -moz-Dialog;
+}
+
+/* ::::: thread decoration ::::: */
+
+/* ::::: group rows ::::: */
+treechildren::-moz-tree-row(dummy, hover),
+treechildren::-moz-tree-row(dummy, selected, focus) {
+ background-color: var(--row-grouped-header-bg-color-selected) !important;
+ color: inherit;
+}
+
+/* ..... tabs ..... */
+
+#tabpanelcontainer {
+ appearance: none;
+ color-scheme: light dark;
+}
+
+/* ..... Draw in titlebar ..... */
+
+:root[tabsintitlebar][sizemode="normal"] #titlebar {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-titlebar;
+}
+
+:root[tabsintitlebar][sizemode="maximized"] #titlebar {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-titlebar-maximized;
+}
+
+@media (-moz-platform: windows-win7) and (-moz-windows-default-theme),
+ (-moz-platform: windows-win8) and (-moz-windows-default-theme) {
+ :root[tabsintitlebar][sizemode="normal"] #titlebar {
+ appearance: none;
+ }
+}
+
+@media (-moz-windows-classic) {
+ :root[tabsintitlebar] #navigation-toolbox > #toolbar-menubar {
+ border-bottom: none;
+ }
+
+ :root[tabsintitlebar][sizemode="normal"] #navigation-toolbox > #toolbar-menubar {
+ margin-top: 4px;
+ }
+}
+
+/* The button box must appear on top of the navigation-toolbox in order for
+ * click and hover mouse events to work properly for the button in the restored
+ * window state. Otherwise, elements in the navigation-toolbox, like the menubar,
+ * can swallow those events. It will also place the buttons above the fog on
+ * themes with Aero Glass.
+ */
+.titlebar-buttonbox {
+ z-index: 1;
+}
+
+.titlebar-buttonbox {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-box;
+ position: relative;
+}
+
+@media (-moz-windows-classic) {
+ .titlebar-buttonbox {
+ appearance: none;
+ }
+}
+
+:root[sizemode="maximized"] .titlebar-buttonbox {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-box-maximized;
+}
+
+.titlebar-min {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-minimize;
+}
+
+@media (-moz-windows-classic: 0) {
+ .titlebar-min {
+ margin-inline-end: 2px;
+ }
+}
+
+.titlebar-max {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-maximize;
+}
+
+.titlebar-restore {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-restore;
+}
+
+.titlebar-close {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-close;
+}
+
+@media (-moz-windows-compositor) {
+ @media (-moz-platform: windows-win7),
+ (-moz-platform: windows-win8) {
+ :root {
+ appearance: auto;
+ -moz-default-appearance: -moz-win-borderless-glass;
+ background-color: transparent;
+ }
+
+ /* These should be hidden w/ glass enabled. Windows draws its own buttons. */
+ .titlebar-button {
+ display: none;
+ }
+
+ :root[sizemode="normal"] .titlebar-buttonbox:-moz-lwtheme {
+ margin-top: -2px;
+ }
+
+ :root[sizemode="maximized"] .titlebar-buttonbox {
+ margin-inline-end: 3px;
+ }
+ }
+
+ @media (-moz-platform: windows-win10) {
+ @media (-moz-windows-default-theme) {
+ :root:not(:-moz-lwtheme) {
+ background-color: var(--color-gray-10);
+ }
+
+ :root[tabsintitlebar]:-moz-lwtheme {
+ background-color: var(--lwt-accent-color);
+ }
+
+ :root[tabsintitlebar]:-moz-lwtheme:-moz-window-inactive {
+ background-color: var(--lwt-accent-color-inactive, var(--lwt-accent-color));
+ }
+
+ :root[tabsintitlebar] #navigation-toolbox {
+ margin-top: -1px;
+ }
+
+ :root[tabsintitlebar] #toolbar-menubar:not([inactive="true"]) {
+ margin-top: 1px;
+ }
+
+ @media (-moz-windows-accent-color-in-titlebar: 0) {
+ :root[sizemode=normal][tabsintitlebar] {
+ border-top: 1px solid rgba(0, 0, 0, 0.7);
+ }
+ :root[sizemode=normal][tabsintitlebar][always-use-accent-color-for-window-border]:not(:-moz-window-inactive) {
+ border-top-color: AccentColor;
+ }
+ :root[tabsintitlebar]:not(:-moz-window-inactive,:-moz-lwtheme) {
+ background-color: var(--color-gray-30);
+ }
+ }
+
+ @media (-moz-windows-accent-color-in-titlebar) {
+ :root[sizemode=normal][tabsintitlebar] {
+ border-top: 1px solid AccentColor;
+ }
+ :root[tabsintitlebar]:not(:-moz-window-inactive,:-moz-lwtheme) {
+ background-color: AccentColor;
+ }
+
+ :root[tabsintitlebar] #navigation-toolbox:not(:-moz-window-inactive,:-moz-lwtheme) {
+ color: AccentColorText;
+ }
+ }
+
+ :root[sizemode=normal][tabsintitlebar]:-moz-window-inactive {
+ border-top-color: rgba(0, 0, 0, 0.3);
+ }
+ }
+
+ @media (prefers-contrast) {
+ #tabmail-container {
+ appearance: auto;
+ -moz-default-appearance: -moz-win-exclude-glass;
+ }
+ }
+
+ .titlebar-buttonbox,
+ .titlebar-button {
+ appearance: none !important;
+ }
+
+ .titlebar-button {
+ border: none;
+ margin: 0 !important;
+ padding: 9px 17px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ }
+
+ @media (-moz-windows-default-theme) {
+ @media (-moz-windows-accent-color-in-titlebar) {
+ .titlebar-button:not(:-moz-window-inactive,:-moz-lwtheme) {
+ stroke: AccentColorText;
+ }
+ }
+ }
+
+ .titlebar-buttonbox > .titlebar-button > .toolbarbutton-icon {
+ display: inline-flex;
+ width: 12px;
+ height: 12px;
+ }
+
+ .titlebar-min {
+ list-style-image: url("chrome://messenger/skin/window-controls/minimize.svg");
+ }
+
+ .titlebar-max {
+ list-style-image: url("chrome://messenger/skin/window-controls/maximize.svg");
+ }
+
+ .titlebar-restore {
+ list-style-image: url("chrome://messenger/skin/window-controls/restore.svg");
+ }
+
+ .titlebar-restore:-moz-locale-dir(rtl) {
+ transform: scaleX(-1);
+ }
+
+ .titlebar-close {
+ list-style-image: url("chrome://messenger/skin/window-controls/close.svg");
+ }
+
+ :root[lwtheme-image] .titlebar-button {
+ -moz-context-properties: unset;
+ }
+ :root[lwtheme-image] .titlebar-min {
+ list-style-image: url("chrome://messenger/skin/window-controls/minimize-themes.svg");
+ }
+ :root[lwtheme-image] .titlebar-max {
+ list-style-image: url("chrome://messenger/skin/window-controls/maximize-themes.svg");
+ }
+ :root[lwtheme-image] .titlebar-restore {
+ list-style-image: url("chrome://messenger/skin/window-controls/restore-themes.svg");
+ }
+ :root[lwtheme-image] .titlebar-close {
+ list-style-image: url("chrome://messenger/skin/window-controls/close-themes.svg");
+ }
+
+ /* the 12px image renders a 10px icon, and the 10px upscaled gets rounded to 12.5, which
+ * rounds up to 13px, which makes the icon one pixel too big on 1.25dppx. Fix: */
+ @media (min-resolution: 1.20dppx) and (max-resolution: 1.45dppx) {
+ .titlebar-button > .toolbarbutton-icon {
+ width: 11.5px;
+ height: 11.5px;
+ }
+ }
+
+ /* 175% dpi should result in the same device pixel sizes as 150% dpi. */
+ @media (min-resolution: 1.70dppx) and (max-resolution: 1.95dppx) {
+ .titlebar-button {
+ padding-left: 14.1px;
+ padding-right: 14.1px;
+ }
+
+ .titlebar-button > .toolbarbutton-icon {
+ width: 10.8px;
+ height: 10.8px;
+ }
+ }
+
+ /* 225% dpi should result in the same device pixel sizes as 200% dpi. */
+ @media (min-resolution: 2.20dppx) and (max-resolution: 2.45dppx) {
+ .titlebar-button {
+ padding-left: 15.3333px;
+ padding-right: 15.3333px;
+ }
+
+ .titlebar-button > .toolbarbutton-icon {
+ width: 10.8px;
+ height: 10.8px;
+ }
+ }
+
+ /* 275% dpi should result in the same device pixel sizes as 250% dpi. */
+ @media (min-resolution: 2.70dppx) and (max-resolution: 2.95dppx) {
+ /* NB: todo: this should also change padding on the buttons
+ * themselves, but without a device to test this on, it's
+ * impossible to know by how much. */
+ .titlebar-button > .toolbarbutton-icon {
+ width: 10.8px;
+ height: 10.8px;
+ }
+ }
+
+ @media (-moz-windows-default-theme) {
+ .titlebar-button:hover {
+ background-color: hsla(0, 0%, 0%, .12);
+ }
+
+ .titlebar-button:hover:active {
+ background-color: hsla(0, 0%, 0%, .22);
+ }
+
+ .titlebar-button:not(:hover) > .toolbarbutton-icon:-moz-window-inactive {
+ opacity: 0.5;
+ }
+
+ .titlebar-close:hover {
+ stroke: white;
+ background-color: var(--color-red-60);
+ }
+
+ .titlebar-close:hover:active {
+ background-color: var(--color-red-50);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ .titlebar-button:hover {
+ background-color: hsla(0, 0%, 100%, .12);
+ }
+
+ .titlebar-button:hover:active {
+ background-color: hsla(0, 0%, 100%, .22);
+ }
+
+ .titlebar-close:hover {
+ background-color: var(--color-red-70);
+ }
+
+ .titlebar-close:hover:active {
+ background-color: var(--color-red-60);
+ }
+ }
+ }
+ @media (prefers-contrast) {
+ .titlebar-button {
+ stroke: ButtonText;
+ background-color: Field;
+ }
+ .titlebar-button:hover {
+ stroke: SelectedItemText;
+ background-color: SelectedItem;
+ }
+
+ .titlebar-min {
+ list-style-image: url("chrome://messenger/skin/window-controls/minimize-highcontrast.svg");
+ }
+
+ .titlebar-max {
+ list-style-image: url("chrome://messenger/skin/window-controls/maximize-highcontrast.svg");
+ }
+
+ .titlebar-restore {
+ list-style-image: url("chrome://messenger/skin/window-controls/restore-highcontrast.svg");
+ }
+
+ .titlebar-close {
+ list-style-image: url("chrome://messenger/skin/window-controls/close-highcontrast.svg");
+ }
+ }
+ }
+}
+
+#messagepanebox {
+ border-top-width: 0;
+ border-inline-start: none;
+}
+
+@media (prefers-contrast) {
+ :root:not(:-moz-lwtheme)
+ treechildren::-moz-tree-image(selected) {
+ color: inherit;
+ }
+}
+
+@media (-moz-windows-default-theme) {
+ :root:not([lwt-tree],:-moz-lwtheme) #folderTree {
+ background-color: #fafafa;
+ }
+}
+
+@media (-moz-platform: windows-win7) and (-moz-windows-default-theme) {
+ @media (-moz-windows-glass: 0) {
+ #messengerWindow:not([tabsintitlebar]) #navigation-toolbox:not(:-moz-lwtheme) {
+ background-color: var(--color-gray-30);
+ }
+ }
+}
+
+@media (-moz-platform: windows-win7) and (-moz-windows-default-theme),
+ (-moz-platform: windows-win8) and (-moz-windows-default-theme) {
+ @media (-moz-windows-compositor) {
+ :root:not(:-moz-lwtheme) {
+ --lwt-tabs-border-color: var(--glassShadowColor);
+ }
+
+ #messengerWindow[sizemode=normal] #tabpanelcontainer {
+ border: 1px solid var(--glassShadowColor);
+ border-top: none;
+ background-clip: padding-box;
+ }
+
+ #messengerWindow[sizemode=normal] #toolbar-menubar {
+ border-right: 1px solid var(--glassShadowColor);
+ border-left: 1px solid var(--glassShadowColor);
+ background-clip: padding-box;
+ }
+
+ #messengerWindow[sizemode=normal] .statusbar {
+ margin-top: -1px;
+ border: 1px solid var(--glassShadowColor);
+ border-top-color: threedshadow;
+ border-radius: 1px 1px 0 0;
+ background-clip: padding-box;
+ }
+ }
+}
+
+@media (-moz-platform: windows-win8) and (-moz-windows-default-theme) {
+ #messengerWindow:not([tabsintitlebar]) #navigation-toolbox:not(:-moz-lwtheme) {
+ background-color: var(--color-gray-30);
+ }
+}
+
+@media not (-moz-windows-non-native-menus) {
+ #viewPickerPopup > menu > .menu-text {
+ padding-inline-start: 0 !important;
+ }
+}
+
+@media (-moz-windows-compositor: 0) {
+ @media (-moz-windows-default-theme) {
+ #messengerWindow:not(:-moz-lwtheme) {
+ background-color: rgb(185, 209, 234);
+ }
+ #messengerWindow:not(:-moz-lwtheme):-moz-window-inactive {
+ background-color: rgb(215, 228, 242);
+ }
+ }
+}
+
+/* Global notification popup */
+
+#notification-popup {
+ appearance: none;
+ background: transparent;
+ border: none;
+}
diff --git a/comm/mail/themes/windows/mail/menulist.css b/comm/mail/themes/windows/mail/menulist.css
new file mode 100644
index 0000000000..72bc07956a
--- /dev/null
+++ b/comm/mail/themes/windows/mail/menulist.css
@@ -0,0 +1,57 @@
+/* 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 url("chrome://messenger/skin/shared/menulist.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+menulist[is="menulist-editable"][editable="true"] {
+ padding: 0;
+}
+
+menulist::part(text-input) {
+ appearance: none;
+ border-style: none;
+ margin-top: -2px;
+ margin-bottom: -2px;
+ margin-inline-start: -2px;
+ margin-inline-end: 3px;
+ padding-top: 1px;
+ padding-bottom: 1px;
+ padding-inline-start: 3px;
+ padding-inline-end: 0;
+}
+
+@media (-moz-platform: windows-win7) and (-moz-windows-default-theme: 0) {
+ menulist::part(text-input) {
+ margin-top: 0;
+ margin-bottom: 0;
+ margin-inline-start: 0;
+ }
+}
+
+@media (-moz-platform: windows-win10) and (-moz-windows-default-theme: 0) {
+ menulist::part(text-input) {
+ margin-top: -1px;
+ margin-bottom: -1px;
+ margin-inline-start: 0;
+ }
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(dropmarker) {
+ display: flex;
+}
+
+@media (-moz-windows-default-theme) {
+ menulist[is="menulist-editable"][editable="true"]::part(dropmarker) {
+ margin-inline-end: 0;
+ margin-inline-start: 0;
+ }
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(text-input) {
+ background-color: Field;
+ color: FieldText;
+ padding-inline-end: 3px;
+}
diff --git a/comm/mail/themes/windows/mail/message-bar.css b/comm/mail/themes/windows/mail/message-bar.css
new file mode 100644
index 0000000000..8a494a0d90
--- /dev/null
+++ b/comm/mail/themes/windows/mail/message-bar.css
@@ -0,0 +1,15 @@
+/* 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 url("chrome://messenger/skin/shared/message-bar.css");
+
+.notification-button.small-button {
+ padding-block: 3px;
+}
+
+@media (-moz-windows-non-native-menus) {
+ #reminderBarPopup {
+ margin-top: -4px;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/messageBody.css b/comm/mail/themes/windows/mail/messageBody.css
new file mode 100644
index 0000000000..a8c696fd37
--- /dev/null
+++ b/comm/mail/themes/windows/mail/messageBody.css
@@ -0,0 +1,10 @@
+/* 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 url("chrome://messenger/skin/shared/messageBody.css");
+
+.moz-txt-formfeed {
+ display: block;
+ height: 100%;
+}
diff --git a/comm/mail/themes/windows/mail/messageHeader.css b/comm/mail/themes/windows/mail/messageHeader.css
new file mode 100644
index 0000000000..5bbd87c58e
--- /dev/null
+++ b/comm/mail/themes/windows/mail/messageHeader.css
@@ -0,0 +1,19 @@
+/* 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/. */
+
+/* ===== messageHeader.css ==============================================
+ == Styles for the header toolbars of a mail message.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/messageHeader.css");
+
+.inline-toolbox {
+ padding-inline-end: 2px;
+}
+
+/* Customization options */
+
+.message-header-buttons-only-icons .toolbarbutton-menu-dropmarker {
+ padding-inline-start: 0 !important;
+}
diff --git a/comm/mail/themes/windows/mail/messageIcons.css b/comm/mail/themes/windows/mail/messageIcons.css
new file mode 100644
index 0000000000..d84f36f8b8
--- /dev/null
+++ b/comm/mail/themes/windows/mail/messageIcons.css
@@ -0,0 +1,49 @@
+/* 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 url("chrome://messenger/skin/shared/messageIcons.css");
+
+/* ..... junkStatus column ..... */
+
+.junkStatusHeader {
+ padding-inline-end: 3px;
+}
+
+/* ..... correspondent column ..... */
+
+#correspondentCol {
+ padding-inline-start: 20px;
+}
+
+/* ..... subject column ..... */
+
+#subjectCol {
+ padding-inline-start: 20px;
+}
+
+#subjectCol[primary="true"] {
+ padding-inline-start: 40px;
+}
+
+/* ..... attachment column ..... */
+
+treechildren::-moz-tree-image(attachmentCol) {
+ margin-inline-start: 1px;
+}
+
+/* ..... junkStatus column ..... */
+
+treechildren::-moz-tree-image(junkStatusCol) {
+ margin-inline-start: 1px;
+}
+
+@media (-moz-windows-default-theme: 0) {
+ #subjectCol {
+ padding-inline-start: 23px;
+ }
+
+ #subjectCol[primary="true"] {
+ padding-inline-start: 43px;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/messageWindow.css b/comm/mail/themes/windows/mail/messageWindow.css
new file mode 100644
index 0000000000..83a0cc6ef1
--- /dev/null
+++ b/comm/mail/themes/windows/mail/messageWindow.css
@@ -0,0 +1,37 @@
+/* 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/. */
+
+/* ===== messageWindow.css ==============================================
+ == Styles for the message window.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/messenger.css");
+@import url("chrome://messenger/skin/primaryToolbar.css");
+
+#messengerWindow {
+ appearance: auto;
+ -moz-default-appearance: window;
+ background-color: -moz-Dialog;
+}
+
+#messagepanebox {
+ flex: 3 3;
+ text-shadow: none;
+}
+
+#messagepaneboxwrapper {
+ overflow: hidden;
+ min-height: 0;
+}
+
+#mail-toolbox:-moz-lwtheme {
+ text-shadow: none;
+}
+
+@media (-moz-platform: windows-win10) and (-moz-windows-default-theme) {
+ #menubar-items > #mail-menubar > menu {
+ appearance: auto;
+ -moz-default-appearance: menuitem;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/messenger.css b/comm/mail/themes/windows/mail/messenger.css
new file mode 100644
index 0000000000..520a108fa7
--- /dev/null
+++ b/comm/mail/themes/windows/mail/messenger.css
@@ -0,0 +1,573 @@
+/* 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.css ==================================================
+ == Styles shared throughout the Messenger application.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/messenger.css");
+
+@media not (-moz-windows-non-native-menus) {
+ .menu-iconic > .menu-iconic-left,
+ .menuitem-iconic > .menu-iconic-left {
+ display: flex;
+ padding-top: 0;
+ }
+}
+
+@media (-moz-windows-non-native-menus) {
+ /* UI Density customization */
+ :root {
+ --menuitem-vertical-padding: 3px;
+ }
+ :root[uidensity="compact"] {
+ --menuitem-vertical-padding: 1px;
+ }
+
+ :root[uidensity="touch"] {
+ --menuitem-vertical-padding: 8px;
+ }
+
+ menupopup > menu,
+ menupopup > menuitem {
+ padding-block: var(--menuitem-vertical-padding);
+ }
+
+ menulist > menupopup > menu,
+ menulist > menupopup > menuitem {
+ padding-inline-end: 5px;
+ }
+
+ menulist > menupopup:not([needsgutter]) > menu:not([icon], .menu-iconic),
+ menulist > menupopup:not([needsgutter]) > menuitem:not([icon], .menuitem-iconic) {
+ padding-inline-start: 1em;
+ }
+
+ menupopup:not([needsgutter]) > menu:not([icon], .menu-iconic),
+ menupopup:not([needsgutter]) > menuitem:not([checked="true"], [icon], .menuitem-iconic) {
+ padding-inline-start: 32px;
+ }
+
+ .folderMenuItem > .menu-iconic-left,
+ .menuitem-iconic > .menu-iconic-left {
+ display: flex;
+ }
+}
+
+@media (-moz-windows-default-theme: 0),
+ (-moz-platform: windows-win10) and (-moz-windows-default-theme) {
+ #menubar-items > #mail-menubar > menu {
+ appearance: none;
+ }
+
+ #menubar-items > #mail-menubar > menu:not([disabled="true"]) {
+ color: inherit;
+ }
+}
+
+@media (-moz-windows-default-theme: 0) {
+ #menubar-items > #mail-menubar > menu[_moz-menuactive="true"] {
+ color: -moz-MenuHoverText;
+ }
+}
+
+.titlebar-buttonbox-container {
+ align-items: stretch;
+}
+
+@media (-moz-platform: windows-win7),
+ (-moz-platform: windows-win8) {
+ /* Preserve window control buttons position at the top of the button box. */
+ .titlebar-buttonbox-container {
+ align-items: flex-start;
+ }
+}
+
+@media (-moz-platform: windows-win7) {
+ @media (-moz-windows-default-theme) and (-moz-windows-glass: 0) {
+ #messengerWindow[sizemode="normal"] .titlebar-buttonbox-container {
+ padding-top: 4px;
+ }
+ }
+}
+
+@media (-moz-platform: windows-win8) {
+ #messengerWindow[sizemode="normal"] .titlebar-buttonbox-container {
+ padding-top: 3px;
+ }
+
+ @media (-moz-windows-default-theme: 0) {
+ menu {
+ appearance: none;
+ }
+ }
+}
+
+@media (-moz-windows-classic) {
+ #messengerWindow[sizemode="normal"] .titlebar-buttonbox-container {
+ padding-top: 3px;
+ }
+
+ :root[tabsintitlebar][sizemode="normal"] #toolbar-menubar {
+ margin-top: 4px;
+ }
+}
+
+.inline-toolbar {
+ appearance: none;
+}
+
+.inline-toolbar toolbarpaletteitem toolbarseparator,
+.inline-toolbar > toolbarseparator {
+ height: 24px;
+}
+
+/* ::::: menubar ::::: */
+
+#menubar-items {
+ flex-direction: column; /* for flex hack */
+ margin-bottom: 1px;
+}
+
+#menubar-items > menubar {
+ flex: 1; /* make menu items expand to fill toolbar height */
+}
+
+menubar > menu[disabled="true"]:-moz-lwtheme {
+ color: inherit;
+ opacity: .4;
+}
+
+/* ::::: Toolbar customization ::::: */
+
+toolbarpaletteitem[place="toolbar"] > toolbarspacer {
+ width: 11px;
+}
+
+/* ::::: toolbarbutton menu-button ::::: */
+
+toolbarbutton[is="toolbarbutton-menu-button"] {
+ align-items: stretch;
+ flex-direction: row !important;
+ padding: 0 !important;
+}
+
+/* .......... dropmarker .......... */
+
+.toolbarbutton-menubutton-dropmarker {
+ appearance: none;
+ padding: 3px 7px;
+ width: auto;
+}
+
+.toolbarbutton-icon {
+ margin-inline-end: 0;
+}
+
+/* Has to be !important to overrule toolkit's dropmarker.css for the
+ dropmarker[disabled="true"] case. */
+.toolbarbutton-menu-dropmarker {
+ padding-inline-start: 3px !important;
+}
+
+.sidebar-header .toolbarbutton-text:not([value]) {
+ display: none;
+}
+
+menulist.folderMenuItem menu:not(.folderMenuItem) {
+ padding-top: 3px;
+ padding-bottom: 3px;
+}
+
+treecol[sortDirection="ascending"]:not([hideheader="true"]) > .treecol-sortdirection {
+ list-style-image: url("chrome://global/skin/icons/arrow-up-12.svg");
+}
+
+treecol[sortDirection="descending"]:not([hideheader="true"]) > .treecol-sortdirection {
+ list-style-image: url("chrome://global/skin/icons/arrow-down-12.svg");
+}
+
+.thread-tree-col-picker image,
+.thread-tree-icon-header img {
+ padding-inline-start: 1px;
+ padding-inline-end: 2px;
+ padding-bottom: 2px;
+}
+
+treechildren::-moz-tree-row(multicol, odd) {
+ background-color: transparent;
+}
+
+treechildren::-moz-tree-cell-text {
+ padding-inline-start: 2px;
+ padding-bottom: 2px;
+}
+
+@media (prefers-color-scheme: dark) {
+ /* Override the toolkit color. */
+ :root:-moz-lwtheme tree > treechildren::-moz-tree-row(selected) {
+ background-color: rgba(249, 249, 250, .1);
+ }
+
+ :root:-moz-lwtheme tree > treechildren::-moz-tree-row(selected, focus) {
+ background-color: var(--dark-lwt-highlight-color);
+ }
+
+ :root:-moz-lwtheme treechildren::-moz-tree-twisty(selected),
+ :root:-moz-lwtheme tree:not(:focus) treechildren::-moz-tree-image(selected),
+ :root:-moz-lwtheme #threadTree:not(:focus) treechildren::-moz-tree-cell-text(untagged, selected),
+ :root:-moz-lwtheme tree:not(#threadTree,:focus) treechildren::-moz-tree-cell-text(selected) {
+ color: FieldText;
+ }
+
+ :root:-moz-lwtheme tree:not(#threadTree) treechildren::-moz-tree-image(selected, focus),
+ :root:-moz-lwtheme #threadTree treechildren::-moz-tree-image(untagged, selected, focus) {
+ color: FieldText;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ }
+
+ :root:-moz-lwtheme #threadTree treechildren::-moz-tree-cell-text(untagged, selected),
+ :root:-moz-lwtheme tree:not(#threadTree) treechildren::-moz-tree-cell-text(selected) {
+ color: FieldText;
+ fill: currentColor;
+ stroke: currentColor;
+ }
+
+ :root:-moz-lwtheme tree:not(#threadTree) treechildren::-moz-tree-twisty(selected),
+ :root:-moz-lwtheme #threadTree treechildren::-moz-tree-twisty(untagged, selected) {
+ fill: FieldText !important;
+ }
+
+ :root:-moz-lwtheme treechildren::-moz-tree-image(folderNameCol, selected, focus) {
+ fill: color-mix(in srgb, currentColor 20%, transparent) !important;
+ stroke: currentColor;
+ }
+}
+
+@media (prefers-contrast) {
+ #threadTree > treechildren::-moz-tree-row(tagged, selected),
+ #threadTree > treechildren::-moz-tree-row(untagged, selected),
+ tree:not(#threadTree) > treechildren::-moz-tree-row(selected) {
+ border-color: SelectedItem;
+ background-color: transparent;
+ }
+}
+
+@media (-moz-windows-default-theme: 0) {
+ tree > treechildren::-moz-tree-row(hover) {
+ border-color: SelectedItemText !important;
+ border-style: solid;
+ background-color: transparent;
+ }
+
+ #threadTree > treechildren::-moz-tree-cell-text(untagged, selected),
+ tree:not(#threadTree) > treechildren::-moz-tree-cell-text(selected) {
+ color: WindowText;
+ }
+
+ #threadTree > treechildren::-moz-tree-row(untagged, selected, focus),
+ tree:not(#threadTree) > treechildren::-moz-tree-row(selected, focus) {
+ border-color: SelectedItemText;
+ background-color: SelectedItem;
+ }
+
+ tree > treechildren::-moz-tree-twisty {
+ color: WindowText;
+ }
+
+ #threadTree > treechildren::-moz-tree-twisty(untagged, selected, focus),
+ tree:not(#threadTree) > treechildren::-moz-tree-twisty(selected, focus),
+ #threadTree > treechildren::-moz-tree-image(untagged, selected, focus),
+ tree:not(#threadTree) > treechildren::-moz-tree-image(selected, focus),
+ #threadTree > treechildren::-moz-tree-cell-text(untagged, selected, focus),
+ tree:not(#threadTree) > treechildren::-moz-tree-cell-text(selected, focus) {
+ color: SelectedItemText;
+ fill: currentColor;
+ }
+
+ treechildren::-moz-tree-cell-text {
+ padding-inline-start: 4px !important;
+ }
+
+ .autocomplete-richlistitem:hover {
+ color: SelectedItemText;
+ }
+}
+
+treechildren::-moz-tree-indentation {
+ width: 12px;
+}
+
+@media (-moz-windows-classic) {
+ treecol[hideheader="true"],
+ .tree-columnpicker-button[hideheader="true"] {
+ border-width: 0;
+ }
+
+ :root[lwt-tree] treecol:not([hideheader="true"]),
+ :root[lwt-tree] .tree-columnpicker-button:not([hideheader="true"]) {
+ border-top-width: 0;
+ border-inline-start-width: 0;
+ }
+
+ :root:not(:-moz-lwtheme) treechildren::-moz-tree-row(hover) {
+ border-color: transparent;
+ background-color: transparent;
+ }
+
+ :root:not(:-moz-lwtheme) treechildren::-moz-tree-row(selected) {
+ border-color: transparent;
+ background-color: -moz-cellhighlight;
+ }
+
+ :root:not(:-moz-lwtheme) tree:not(#threadTree) > treechildren::-moz-tree-row(selected, focus),
+ :root:not(:-moz-lwtheme) #threadTree > treechildren::-moz-tree-row(untagged, selected, focus) {
+ border-color: SelectedItemText;
+ background-color: SelectedItem;
+ }
+
+ /* Add a window top border for webextension themes */
+ :root[tabsintitlebar][sizemode="normal"] #navigation-toolbox:-moz-lwtheme {
+ background-image: linear-gradient(to bottom,
+ ThreeDLightShadow 0, ThreeDLightShadow 1px,
+ ThreeDHighlight 1px, ThreeDHighlight 2px,
+ ActiveBorder 2px, ActiveBorder 4px, transparent 4px),
+ var(--lwt-header-image), var(--lwt-additional-images);
+ }
+}
+
+:root[lwt-tree] treechildren::-moz-tree-row(hover) {
+ background-color: hsla(0,0%,50%,.15);
+ border-color: transparent;
+ background-image: none;
+}
+
+menulist {
+ padding: 0 5px 1px !important;
+}
+
+menulist.folderMenuItem::part(label) {
+ margin-inline-start: 2px !important;
+}
+
+button[is="toolbarbutton-menu-button"] > .button-box > button {
+ margin-block: -1px;
+}
+
+button.notification-button[is="toolbarbutton-menu-button"] {
+ padding-inline-end: 1px;
+}
+
+.messageCloseButton > .toolbarbutton-icon {
+ margin-inline-end: 12px;
+}
+
+.toolbarbutton-menu-dropmarker {
+ margin-top: 0;
+}
+
+@media (-moz-windows-default-theme: 0) {
+ #tabmail:not(:-moz-lwtheme) {
+ background-color: ActiveCaption;
+ }
+
+ #tabmail:not(:-moz-lwtheme):-moz-window-inactive {
+ background-color: InactiveCaption;
+ }
+}
+
+@media (-moz-windows-compositor: 0) {
+ #print-preview-toolbar:not(:-moz-lwtheme) {
+ appearance: auto;
+ -moz-default-appearance: -moz-win-browsertabbar-toolbox;
+ }
+}
+
+/* ::::: primary toolbar buttons ::::: */
+
+.toolbarbutton-1[disabled=true] .toolbarbutton-icon,
+.toolbarbutton-1[disabled=true] .toolbarbutton-text,
+.toolbarbutton-1[disabled=true] .toolbarbutton-menu-dropmarker,
+.toolbarbutton-1[disabled=true] > .toolbarbutton-menubutton-dropmarker {
+ opacity: .4;
+}
+
+toolbar[mode="text"] .toolbarbutton-text {
+ margin: 0 !important;
+ padding-inline: 2px !important;
+}
+
+toolbox[labelalign="end"] > toolbar[mode="full"] .toolbarbutton-1
+.toolbarbutton-text {
+ padding-inline-end: 2px;
+}
+
+.toolbarbutton-1,
+.toolbarbutton-1 > .toolbarbutton-menubutton-button,
+.toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker {
+ appearance: none;
+}
+
+@media (-moz-windows-compositor) {
+ #unifinder-searchBox,
+ #task-addition-box {
+ border-top: none;
+ background-color: -moz-dialog;
+ }
+
+ @media (-moz-platform: windows-win10) {
+ /* See bug 1715990 about why we do this ourselves on HCM */
+ @media (prefers-contrast) {
+ :root[tabsintitlebar]:not(:-moz-lwtheme) {
+ background-color: ActiveCaption;
+ }
+
+ :root[tabsintitlebar]:not(:-moz-lwtheme):-moz-window-inactive {
+ background-color: InactiveCaption;
+ }
+ }
+ }
+}
+
+.statusbarpanel {
+ border-inline-end: 1px solid ThreeDLightShadow;
+}
+
+.statusbarpanel:-moz-lwtheme {
+ border-inline-end-color: var(--lwt-tabs-border-color);
+}
+
+@media (-moz-platform: windows-win7) and (-moz-windows-default-theme),
+ (-moz-platform: windows-win8) and (-moz-windows-default-theme) {
+ #status-bar:not(:-moz-lwtheme) {
+ appearance: none;
+ border-style: none;
+ border-top: 1px solid ThreeDShadow;
+ background-color: var(--toolbar-bgcolor);
+ }
+}
+
+@media (-moz-windows-classic) {
+ label.statusbarpanel {
+ margin: 1px;
+ padding-block: 2px;
+ }
+}
+
+/* Status panel */
+
+.statuspanel-label {
+ margin: 0;
+ padding: 2px 4px;
+ background-color: -moz-dialog;
+ border: 1px none ThreeDLightShadow;
+ border-top-style: solid;
+ color: -moz-dialogText;
+ text-shadow: none;
+}
+
+@media (-moz-windows-default-theme) {
+ .statuspanel-label {
+ background-color: #f9f9fa;
+ color: #444;
+ }
+}
+
+.statuspanel-label:-moz-locale-dir(ltr):not([mirror]),
+.statuspanel-label:-moz-locale-dir(rtl)[mirror] {
+ border-right-style: solid;
+ /* disabled for triggering grayscale AA (bug 659213)
+ border-top-right-radius: .3em;
+ */
+ margin-right: 1em;
+}
+
+.statuspanel-label:-moz-locale-dir(rtl):not([mirror]),
+.statuspanel-label:-moz-locale-dir(ltr)[mirror] {
+ border-left-style: solid;
+ /* disabled for triggering grayscale AA (bug 659213)
+ border-top-left-radius: .3em;
+ */
+ margin-left: 1em;
+}
+
+.contentTabInstance {
+ background-color: -moz-dialog;
+}
+
+.contentTabInstance:-moz-lwtheme {
+ background-color: transparent;
+ background-image: linear-gradient(transparent 40px, -moz-dialog 40px);
+}
+
+/* ::::: groupbox ::::: */
+
+fieldset {
+ border: 0.5px groove ThreeDLightShadow;
+ border-radius: 3px;
+ margin: 1em 3px 3px 3px;
+ padding: 3px 0 6px;
+}
+
+legend {
+ margin-top: -1em;
+ margin-inline-start: 3px;
+ padding-inline: 3px;
+ background-color: -moz-dialog;
+ font-weight: bold;
+}
+
+fieldset > hbox,
+fieldset > vbox,
+fieldset > radiogroup {
+ width: -moz-available;
+}
+
+#navigation-toolbox {
+ appearance: none;
+}
+
+@media (-moz-platform: windows-win7) {
+ @media (-moz-windows-default-theme) and (-moz-windows-glass: 0) {
+ /* Add a window top border behind the titlebar */
+ :root[tabsintitlebar][sizemode="normal"] #navigation-toolbox-background {
+ background-image: linear-gradient(to bottom, ThreeDDarkShadow 0,
+ ThreeDDarkShadow 1px, ThreeDHighlight 1px,
+ ThreeDHighlight 2px, transparent 2px);
+ }
+ }
+ @media (-moz-windows-classic) {
+ /* Add a window top border behind the titlebar */
+ :root[tabsintitlebar][sizemode="normal"] #navigation-toolbox-background {
+ background-image: linear-gradient(to bottom, ThreeDLightShadow 0,
+ ThreeDLightShadow 1px, ThreeDHighlight 1px,
+ ThreeDHighlight 2px, ActiveBorder 2px,
+ ActiveBorder 4px, transparent 4px);
+ }
+ }
+
+ @media (prefers-contrast) {
+ /* Add a window top border behind the titlebar */
+ :root[tabsintitlebar][sizemode="normal"] #navigation-toolbox-background {
+ background-image: linear-gradient(to bottom, ThreeDLightShadow 0,
+ ThreeDLightShadow 1px, ThreeDHighlight 1px,
+ ThreeDHighlight 2px, transparent 2px);
+ }
+ }
+}
+
+/* UI Density customization */
+
+treechildren::-moz-tree-row {
+ height: 1.8rem;
+}
+
+:root[uidensity="compact"] treechildren::-moz-tree-row {
+ height: 1.6rem;
+}
+
+:root[uidensity="touch"] treechildren::-moz-tree-row {
+ height: 2.4rem;
+}
diff --git a/comm/mail/themes/windows/mail/multimessageview.css b/comm/mail/themes/windows/mail/multimessageview.css
new file mode 100644
index 0000000000..e019522855
--- /dev/null
+++ b/comm/mail/themes/windows/mail/multimessageview.css
@@ -0,0 +1,25 @@
+/* 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 url("chrome://messenger/skin/shared/multimessageview.css");
+
+/* windows-specific overrides for multimessageview.css*/
+
+@media not (prefers-color-scheme: dark) {
+ @media (-moz-windows-compositor) and (-moz-windows-default-theme) {
+ :root {
+ --header-background-color: #f8f8f8;
+ }
+ }
+
+ @media (-moz-platform: windows-win7) and (-moz-windows-default-theme) {
+ :root {
+ --header-background-color: rgb(233, 239, 245);
+ }
+ }
+}
+
+.star {
+ top: 0.6em;
+}
diff --git a/comm/mail/themes/windows/mail/newsblog/feed-subscriptions.css b/comm/mail/themes/windows/mail/newsblog/feed-subscriptions.css
new file mode 100644
index 0000000000..482b11b8da
--- /dev/null
+++ b/comm/mail/themes/windows/mail/newsblog/feed-subscriptions.css
@@ -0,0 +1,7 @@
+/* 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/. */
+
+/* ::::: Feed Subscription styling :::::: */
+
+@import url("chrome://messenger/skin/shared/feedSubscribe.css");
diff --git a/comm/mail/themes/windows/mail/panelUI.css b/comm/mail/themes/windows/mail/panelUI.css
new file mode 100644
index 0000000000..25eebb949d
--- /dev/null
+++ b/comm/mail/themes/windows/mail/panelUI.css
@@ -0,0 +1,79 @@
+/* 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 url("chrome://messenger/skin/shared/panelUI.css");
+
+#appMenu-popup {
+ margin-top: -1px;
+}
+
+#appMenu-popup {
+ margin-inline: 0 -14px;
+}
+
+/* Disabled empty item looks too small otherwise, because it has no icon. */
+menuitem.subviewbutton[disabled]:not(.menuitem-iconic) {
+ /* This is 16px for an icon + 3px for its margins + 1px for its padding +
+ * 2px for its border, see above */
+ min-height: 22px;
+}
+
+@media (prefers-contrast) {
+ panelview .toolbarbutton-1:not([disabled],[open],:active):is(:hover,:focus-visible),
+ toolbarbutton.subviewbutton:not([disabled],[open],:active):is(:hover,:focus-visible),
+ menu.subviewbutton:not([disabled],:active)[_moz-menuactive],
+ menuitem.subviewbutton:not([disabled],:active)[_moz-menuactive],
+ .widget-overflow-list .toolbarbutton-1:not([disabled],[open],:active):is(:hover,:focus-visible),
+ .toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]) >
+ toolbarbutton:not([disabled],[open],:active):is(:hover,:focus-visible) {
+ color: SelectedItemText;
+ }
+
+ panelview .toolbarbutton-1:not([disabled]):is([open],:hover:active),
+ toolbarbutton.subviewbutton:not([disabled]):is([open],:hover:active),
+ menu.subviewbutton:not([disabled])[_moz-menuactive]:active,
+ menuitem.subviewbutton:not([disabled])[_moz-menuactive]:active,
+ .widget-overflow-list .toolbarbutton-1:not([disabled]):is([open],:hover:active),
+ .toolbaritem-combined-buttons:is(:not([cui-areatype="toolbar"]),[overflowedItem=true]) >
+ toolbarbutton:not([disabled]):is([open],:hover:active) {
+ color: SelectedItemText;
+ }
+}
+
+@media (-moz-windows-non-native-menus) {
+ menu.subviewbutton > .menu-right {
+ margin-inline-end: 0;
+ }
+}
+
+@media (-moz-windows-non-native-menus: 0) {
+ menupopup[type="arrow"] {
+ appearance: none;
+ background-color: transparent;
+ border: none;
+ border-radius: 4px;
+ }
+
+ menuitem.subviewbutton[type="checkbox"] > .menu-iconic-left {
+ appearance: none;
+ }
+
+ menuitem.subviewbutton > .menu-text {
+ appearance: none;
+ }
+
+ menuitem.subviewbutton-iconic > .menu-iconic-left > .menu-iconic-icon {
+ display: block;
+ }
+
+ menupopup[type="arrow"]::part(content) {
+ /* Prevent contained items from drawing over the border-radius. */
+ overflow: clip;
+ padding: 8px 0;
+ color: var(--arrowpanel-color);
+ background: var(--arrowpanel-background);
+ border-radius: var(--arrowpanel-border-radius);
+ border: 1px solid var(--arrowpanel-border-color);
+ }
+}
diff --git a/comm/mail/themes/windows/mail/popupPanel.css b/comm/mail/themes/windows/mail/popupPanel.css
new file mode 100644
index 0000000000..faa4df3872
--- /dev/null
+++ b/comm/mail/themes/windows/mail/popupPanel.css
@@ -0,0 +1,22 @@
+/* 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 url("chrome://messenger/skin/shared/popupPanel.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+html|input.editContactTextbox {
+ padding: 3px 6px;
+}
+
+/* Swap the primary and secondary action, because Windows
+* platform conventions put the primary action on the left. */
+.popup-panel-buttons-container > button.primary {
+ order: -1;
+}
+
+#messageHeaderCustomizationPanel {
+ margin-top: -10px;
+ margin-inline-end: 2px;
+}
diff --git a/comm/mail/themes/windows/mail/preferences/alwaysAsk.png b/comm/mail/themes/windows/mail/preferences/alwaysAsk.png
new file mode 100644
index 0000000000..ddd4cb2130
--- /dev/null
+++ b/comm/mail/themes/windows/mail/preferences/alwaysAsk.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/preferences/application.png b/comm/mail/themes/windows/mail/preferences/application.png
new file mode 100644
index 0000000000..ff2ecc2f35
--- /dev/null
+++ b/comm/mail/themes/windows/mail/preferences/application.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/preferences/applications.css b/comm/mail/themes/windows/mail/preferences/applications.css
new file mode 100644
index 0000000000..f8985b5b5f
--- /dev/null
+++ b/comm/mail/themes/windows/mail/preferences/applications.css
@@ -0,0 +1,24 @@
+/* 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 url("chrome://messenger/skin/shared/preferences/applications.css");
+
+/**
+ * Used by the cloudFile manager
+ */
+
+.cloudfileAccount > label {
+ margin-block: initial;
+}
+
+.cloudfileAccount > input {
+ min-height: unset !important;
+ margin: 0 !important;
+ padding-block: 2px 3px !important;
+ padding-inline: 4px 3px !important;
+}
+
+.actionsMenu > menupopup > menuitem {
+ padding-inline-start: 10px;
+}
diff --git a/comm/mail/themes/windows/mail/preferences/preferences.css b/comm/mail/themes/windows/mail/preferences/preferences.css
new file mode 100644
index 0000000000..a35cae7621
--- /dev/null
+++ b/comm/mail/themes/windows/mail/preferences/preferences.css
@@ -0,0 +1,15 @@
+/* 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 url("chrome://messenger/skin/shared/preferences/preferences.css");
+@namespace html "http://www.w3.org/1999/xhtml";
+
+html|legend {
+ padding-inline: 0;
+ background-color: transparent;
+}
+
+html|h2 {
+ background-color: transparent;
+}
diff --git a/comm/mail/themes/windows/mail/preferences/saveFile.png b/comm/mail/themes/windows/mail/preferences/saveFile.png
new file mode 100644
index 0000000000..c210e8473f
--- /dev/null
+++ b/comm/mail/themes/windows/mail/preferences/saveFile.png
Binary files differ
diff --git a/comm/mail/themes/windows/mail/primaryToolbar.css b/comm/mail/themes/windows/mail/primaryToolbar.css
new file mode 100644
index 0000000000..01bd68aa88
--- /dev/null
+++ b/comm/mail/themes/windows/mail/primaryToolbar.css
@@ -0,0 +1,244 @@
+/* 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 url("chrome://messenger/skin/shared/primaryToolbar.css");
+
+@media (-moz-windows-compositor: 0),
+ (-moz-windows-default-theme: 0) {
+/* We want a 4px gap between the tabs-toolbar and the toolbar-menubar
+ when the toolbar-menu is displayed. 1px is taken care of by the (light)
+ outer shadow of the tab, the remaining 3 is these margins. */
+ #toolbar-menubar:not([autohide="true"]) ~ #tabs-toolbar,
+ #toolbar-menubar[autohide="true"]:not([inactive]) ~ #tabs-toolbar {
+ margin-top: 3px;
+ }
+
+ :root[tabsintitlebar] #navigation-toolbox:not(:-moz-lwtheme) {
+ color: CaptionText;
+ }
+
+ :root[tabsintitlebar] #navigation-toolbox:not(:-moz-lwtheme):-moz-window-inactive {
+ color: InactiveCaptionText;
+ }
+}
+
+#navigation-toolbox,
+#toolbar-menubar {
+ appearance: none;
+}
+
+@media (-moz-platform: windows-win10) and (-moz-windows-default-theme) {
+ #menubar-items > #mail-menubar > menu[disabled="true"] {
+ background-color: transparent;
+ }
+
+ #menubar-items > #mail-menubar >
+ menu:not([disabled="true"])[_moz-menuactive="true"] {
+ background-color: hsla(0, 0%, 0%, .12);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ #menubar-items > #mail-menubar >
+ menu:not([disabled="true"])[_moz-menuactive="true"] {
+ background-color: hsla(0, 0%, 100%, .2);
+ }
+ }
+}
+
+@media (-moz-windows-classic) {
+ /**
+ * In the classic themes, the titlebar has a horizontal gradient, which is
+ * problematic for reading the text of background tabs when they're in the
+ * titlebar. We side-step this issue by layering our own background underneath
+ * the tabs.
+ */
+ :root[tabsintitlebar] #tabs-toolbar:not(:-moz-lwtheme) {
+ background-image: linear-gradient(transparent, ActiveCaption);
+ background-size: auto 200%;
+ }
+
+ :root[tabsintitlebar] #tabs-toolbar:not(:-moz-lwtheme):-moz-window-inactive {
+ background-image: linear-gradient(transparent, InactiveCaption);
+ }
+
+ /**
+ * With the tabmail-tabs element z-index'd above the nav-bar, we now get the
+ * scrollbox button borders leaking over the nav-bar highlight. This transparent bottom
+ * border forces the scrollbox button borders to terminate a pixel early, working
+ * around the issue.
+ */
+ :root[tabsintitlebar] #tabmail-arrowscrollbox:not(:-moz-lwtheme)::part(scrollbutton-up),
+ :root[tabsintitlebar] #tabmail-arrowscrollbox:not(:-moz-lwtheme)::part(scrollbutton-down) {
+ border-bottom: 1px solid transparent;
+ }
+
+ :root[tabsintitlebar] .mail-toolbox:not(:-moz-lwtheme),
+ :root[tabsintitlebar] .contentTabToolbox:not(:-moz-lwtheme) {
+ box-shadow: none;
+ }
+
+ /* End classic titlebar gradient */
+
+ :root[tabsintitlebar] :is(#tabs-toolbar,#toolbar-menubar)
+ toolbarbutton:not(:-moz-lwtheme) {
+ color: inherit;
+ }
+}
+
+.mail-toolbox::after,
+.contentTabToolbox::after {
+ content: "";
+ display: flex;
+ height: 1px;
+ border-bottom: 1px solid var(--chrome-content-separator-color);
+}
+
+.mail-toolbox > toolbar:not([type="menubar"]) {
+ padding: 1px;
+}
+
+/* ::::: toolbar buttons on tabbar toolbar ::::: */
+
+@media (-moz-windows-compositor: 0) {
+ #toolbar-menubar {
+ background-color: transparent !important
+ }
+
+ :root[tabsintitlebar]:not([lwt-tree]) #titlebar:-moz-lwtheme {
+ appearance: none !important;
+ }
+
+ :root[tabsintitlebar][sizemode="maximized"]:not([lwt-tree]) #titlebar:-moz-lwtheme {
+ margin-top: 4px;
+ }
+
+ #print-preview-toolbar:not(:-moz-lwtheme) {
+ appearance: auto;
+ -moz-default-appearance: -moz-win-browsertabbar-toolbox;
+ }
+
+ @media (-moz-windows-default-theme) {
+ #messengerWindow {
+ background-color: rgb(185, 209, 234);
+ }
+ #messengerWindow:-moz-window-inactive {
+ background-color: rgb(215, 228, 242);
+ }
+
+ #toolbar-menubar:not([autohide=true],:-moz-lwtheme),
+ #tabs-toolbar:not(:-moz-lwtheme) {
+ background-color: transparent;
+ }
+ #toolbar-menubar[autohide=true] {
+ background-color: transparent !important;
+ }
+ }
+}
+
+@media (-moz-windows-compositor) and (-moz-windows-default-theme) {
+ #navigation-toolbox:not(:-moz-lwtheme),
+ #tabs-toolbar {
+ background-color: transparent;
+ }
+
+ #mail-toolbox:not(:-moz-lwtheme),
+ .glodaTabToolbar {
+ color: black;
+ }
+
+ #mail-menubar > menu:not(:-moz-lwtheme) {
+ color: inherit;
+ }
+
+ /* Use a different color only on Windows 8 and higher for inactive windows.
+ * On Win 7, the menubar fog disappears for inactive windows, and renders gray
+ * illegible.
+ */
+ @media not all and (-moz-platform: windows-win7) {
+ #toolbar-menubar:not(:-moz-lwtheme):-moz-window-inactive {
+ color: ThreeDShadow;
+ }
+ }
+}
+
+@media (-moz-windows-glass) {
+ .mail-toolbox:not(:-moz-lwtheme)::after,
+ .contentTabToolbox:not(:-moz-lwtheme)::after {
+ --chrome-content-separator-color: #aabccf;
+ }
+
+ #tabs-toolbar {
+ order: 10;
+ }
+
+ #toolbar-menubar {
+ order: 20;
+ -moz-window-dragging: no-drag;
+ box-shadow: 0 1px 0 rgba(253, 253, 253, 0.45) inset;
+ background-color: var(--toolbar-bgcolor);
+ padding-bottom: 1px !important;
+ padding-top: 2px;
+ }
+
+ /* Don't apply the full negative margin for the Spaces Toolbar. */
+ :root[spacestoolbar="true"] #toolbar-menubar {
+ margin-inline-start: -1px !important;
+ }
+
+ #toolbar-menubar:-moz-lwtheme {
+ background-color: var(--toolbar-bgcolor);
+ color: var(--toolbar-color, inherit);
+ box-shadow: none;
+ }
+
+ #navigation-toolbox > #toolbar-menubar:not(:-moz-lwtheme) {
+ appearance: none;
+ border-bottom: 1px solid #aabccf;
+ }
+
+ #tabs-toolbar:not(:-moz-lwtheme) {
+ position: relative;
+ }
+
+ #navigation-toolbox:not(:-moz-lwtheme)::before {
+ box-shadow: 0 30px 30px 30px rgba(174, 189, 204, 0.85);
+ content: "";
+ display: flex;
+ margin: 0 60px; /* (30px + 30px) from box-shadow */
+ pointer-events: none;
+ width: -moz-available;
+ z-index: -1;
+ }
+}
+
+@media (-moz-platform: windows-win8) {
+ @media (-moz-windows-default-theme) {
+ #messengerWindow[darkwindowframe="true"]:not(:-moz-lwtheme,:-moz-window-inactive) #navigation-toolbox {
+ color: white;
+ }
+ }
+ @media (-moz-windows-default-theme: 0) {
+ #messengerWindow #navigation-toolbox:not(:-moz-lwtheme) {
+ color: CaptionText;
+ }
+
+ #messengerWindow #navigation-toolbox:not(:-moz-lwtheme):-moz-window-inactive {
+ color: InactiveCaptionText;
+ }
+ }
+}
+
+@media (-moz-platform: windows-win8) and (-moz-windows-default-theme),
+ (-moz-platform: windows-win10) and (-moz-windows-default-theme) {
+ #navigation-toolbox > #toolbar-menubar {
+ background-color: transparent;
+ padding-top: 0;
+ padding-bottom: 0 !important;
+ }
+
+ .mail-toolbox:not(:-moz-lwtheme)::after,
+ .contentTabToolbox:not(:-moz-lwtheme)::after {
+ --chrome-content-separator-color: #c2c2c2;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/searchBox.css b/comm/mail/themes/windows/mail/searchBox.css
new file mode 100644
index 0000000000..9589acbf4a
--- /dev/null
+++ b/comm/mail/themes/windows/mail/searchBox.css
@@ -0,0 +1,44 @@
+/* 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 url("chrome://messenger/skin/shared/searchBox.css");
+
+/*
+ * The emptytext style would appear to use italics. This is causing
+ * problems for the search box because it has no minwidth and is flexy, so
+ * removing the emptytext causes the size of the box to change and this is silly
+ * and undesirable. This change is being made to maintain the generally
+ * accepted status quo while reducing breakage. This will cause visual
+ * inconsistency with the quick filter bar unless it gets a change like this
+ * too.
+ */
+.gloda-search {
+ font-style: normal !important;
+}
+
+.search-icon {
+ margin-inline-start: 8px;
+}
+
+.remote-gloda-search-container {
+ padding-block: 1px;
+}
+
+.searchBox,
+.themeableSearchBox {
+ padding-block: 0;
+ padding-inline: 4px 2px;
+ margin-block: 2px;
+}
+
+@media (prefers-contrast) {
+ .searchBox,
+ .themeableSearchBox {
+ border-color: ThreeDDarkShadow;
+ }
+}
+
+.autocomplete-richlistitem[type^="gloda-"] {
+ padding-inline-start: 12px;
+}
diff --git a/comm/mail/themes/windows/mail/searchDialog.css b/comm/mail/themes/windows/mail/searchDialog.css
new file mode 100644
index 0000000000..7732f7b59f
--- /dev/null
+++ b/comm/mail/themes/windows/mail/searchDialog.css
@@ -0,0 +1,34 @@
+/* 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/. */
+
+/* ===== searchDialog.css ===============================================
+ == Styles for the Mail Search dialog.
+ ======================================================================= */
+
+@import url("chrome://messenger/skin/shared/searchDialog.css");
+
+:root:not([lwt-tree]) #threadTree,
+:root:not([lwt-tree]) #abResultsTree {
+ appearance: auto;
+ -moz-default-appearance: listbox;
+}
+
+.search-menulist,
+.search-value-menulist {
+ width: 11em;
+}
+
+.input-inline.search-value-input {
+ padding-block: 3px;
+}
+
+.small-button {
+ min-width: 3em;
+ margin: 2px;
+}
+
+.small-button + .small-button {
+ margin-inline-start: 0;
+ margin-inline-end: 4px;
+}
diff --git a/comm/mail/themes/windows/mail/spacesToolbar.css b/comm/mail/themes/windows/mail/spacesToolbar.css
new file mode 100644
index 0000000000..e8a262be74
--- /dev/null
+++ b/comm/mail/themes/windows/mail/spacesToolbar.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/spacesToolbar.css");
+
+@media (-moz-platform: windows-win7) and (-moz-windows-default-theme),
+ (-moz-platform: windows-win8) and (-moz-windows-default-theme) {
+ @media (-moz-windows-compositor) {
+ #messengerWindow[sizemode="normal"][spacestoolbar="true"] #tabpanelcontainer,
+ #messengerWindow[sizemode="normal"][spacestoolbar="true"] .statusbar {
+ border-inline-start-width: 0;
+ }
+
+ #messengerWindow[sizemode="normal"] .spaces-toolbar:not([hidden]) {
+ border-inline-start: 1px solid var(--glassShadowColor);
+ border-bottom: 1px solid var(--glassShadowColor);
+ }
+ }
+}
+
+@media (-moz-platform: windows-win7) {
+ @media (-moz-windows-default-theme) {
+ #messengerWindow[sizemode="maximized"] .spaces-toolbar:not([hidden]) {
+ margin-top: 8px;
+ }
+ }
+
+ @media (-moz-windows-glass) {
+ #messengerWindow[sizemode="normal"] .spaces-toolbar:not([hidden], :-moz-lwtheme) {
+ margin-top: 2px;
+ }
+ }
+
+ @media (-moz-windows-default-theme) and (-moz-windows-glass: 0) {
+ #messengerWindow[sizemode="normal"] .spaces-toolbar:not([hidden], :-moz-lwtheme) {
+ background-image: linear-gradient(to bottom, ThreeDDarkShadow 0,
+ ThreeDDarkShadow 1px, ThreeDHighlight 1px,
+ ThreeDHighlight 2px, transparent 2px);
+ }
+ }
+
+ @media (-moz-windows-classic) {
+ /* Add a window top border behind the titlebar */
+ #messengerWindow[sizemode="normal"] .spaces-toolbar:not([hidden], :-moz-lwtheme) {
+ background-image: linear-gradient(to bottom, ThreeDLightShadow 0,
+ ThreeDLightShadow 1px, ThreeDHighlight 1px,
+ ThreeDHighlight 2px, ActiveBorder 2px,
+ ActiveBorder 4px, transparent 4px);
+ }
+ }
+
+ @media (-moz-windows-default-theme: 0) {
+ #messengerWindow[sizemode="maximized"] .spaces-toolbar:not([hidden]) {
+ margin-top: 8px;
+ }
+ }
+
+ @media (prefers-contrast) {
+ /* Add a window top border behind the titlebar */
+ #messengerWindow[sizemode="normal"] .spaces-toolbar:not([hidden], :-moz-lwtheme) {
+ background-image: linear-gradient(to bottom, ThreeDLightShadow 0,
+ ThreeDLightShadow 1px, ThreeDHighlight 1px,
+ ThreeDHighlight 2px, transparent 2px);
+ }
+ }
+}
+
+@media (-moz-platform: windows-win8) {
+ #messengerWindow[sizemode="normal"] .spaces-toolbar:not([hidden], :-moz-lwtheme) {
+ margin-top: 1px;
+ }
+
+ #messengerWindow[sizemode="maximized"] .spaces-toolbar:not([hidden]) {
+ margin-top: 8px;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/tabmail.css b/comm/mail/themes/windows/mail/tabmail.css
new file mode 100644
index 0000000000..eaec2bd922
--- /dev/null
+++ b/comm/mail/themes/windows/mail/tabmail.css
@@ -0,0 +1,177 @@
+/* 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 url("chrome://messenger/skin/shared/tabmail.css");
+
+/* Make sure the drop indicator stays inside the parent boundaries */
+#tabmail-tabs {
+ position: relative;
+}
+
+/**
+ * Tabmail Tabs
+ */
+
+#tabpanelcontainer:-moz-lwtheme {
+ color: inherit;
+}
+
+/**
+ * Tab
+ */
+
+tabpanels {
+ appearance: none;
+ background-color: transparent;
+}
+
+/* tabmail-tab focus ring */
+.tabmail-tab .tab-label-container {
+ border: 1px dotted transparent;
+}
+
+.tabmail-tab:focus .tab-label-container {
+ border-color: -moz-DialogText;
+}
+
+#tabmail-arrowscrollbox[overflow="true"] > .tabmail-tab:first-child::before {
+ content: '';
+ display: flex;
+ margin-inline-start: 0;
+}
+
+/**
+ * Tab Scrollbox Arrow Buttons
+ */
+
+#tabmail-arrowscrollbox::part(scrollbutton-up),
+#tabmail-arrowscrollbox::part(scrollbutton-down) {
+ appearance: none;
+ border-style: none !important;
+ padding: 0 3px !important;
+ margin: 0 !important;
+ margin-inline-end: 1px !important;
+}
+
+#tabmail-arrowscrollbox[scrolledtostart=true]::part(scrollbutton-up),
+#tabmail-arrowscrollbox[scrolledtoend=true]::part(scrollbutton-down) {
+ --toolbarbutton-icon-fill-opacity: .4;
+}
+
+#tabmail-arrowscrollbox:-moz-locale-dir(rtl)::part(scrollbutton-up),
+#tabmail-arrowscrollbox:-moz-locale-dir(ltr)::part(scrollbutton-down) {
+ margin-inline-start: 1px !important;
+ margin-inline-end: 0 !important;
+}
+
+/**
+ * All Tabs Button
+ */
+
+#tabmail-arrowscrollbox:not([scrolledtostart=true])::part(scrollbutton-up):hover,
+#tabmail-arrowscrollbox:not([scrolledtoend=true])::part(scrollbutton-down):hover {
+ background: var(--toolbarbutton-active-background);
+}
+
+@media (-moz-windows-glass) {
+ /* Set to full fill-opacity to improve visibility of toolbar buttons on aero glass. */
+ :root[tabsintitlebar] #tabs-toolbar {
+ --toolbarbutton-icon-fill-opacity: 1;
+ }
+
+ :root[tabsintitlebar][sizemode=normal] #tabs-toolbar {
+ margin-top: 6px;
+ }
+
+ #alltabs-button:not(:-moz-lwtheme,[disabled]) {
+ border-color: transparent;
+ margin-top: 0;
+ margin-bottom: -1px;
+ }
+
+ #alltabs-button:not(:-moz-lwtheme,[disabled]):hover,
+ #tabmail-arrowscrollbox:not(:-moz-lwtheme,[scrolledtostart=true])::part(scrollbutton-up):hover,
+ #tabmail-arrowscrollbox:not(:-moz-lwtheme,[scrolledtoend=true])::part(scrollbutton-down):hover {
+ background-color: transparent;
+ background-image: linear-gradient(rgba(255, 255, 255, 0),
+ rgba(255, 255, 255, .5)),
+ linear-gradient(transparent, rgba(0, 0, 0, .25) 30%),
+ linear-gradient(transparent, rgba(0, 0, 0, .25) 30%);
+ background-position: 1px -1px, 0 -1px, 100% -1px;
+ background-size: calc(100% - 2px) 100%, 1px 100%, 1px 100%;
+ background-repeat: no-repeat;
+ }
+
+ #tabs-toolbar[brighttext]
+ #tabmail-arrowscrollbox:not(:-moz-lwtheme,[scrolledtostart=true])::part(scrollbutton-up):hover,
+ #tabs-toolbar[brighttext]
+ #tabmail-arrowscrollbox:not(:-moz-lwtheme,[scrolledtoend=true])::part(scrollbutton-down):hover {
+ background-image: linear-gradient(rgba(255, 255, 255, 0),
+ rgba(255, 255, 255, .5)),
+ linear-gradient(transparent, rgba(255, 255, 355, .25) 30%),
+ linear-gradient(transparent, rgba(255, 255, 255, .25) 30%);
+ }
+}
+
+#alltabs-button {
+ padding-right: 3px !important;
+ padding-left: 3px !important;
+}
+
+.tabs-alltabs-button > hbox > .toolbarbutton-menu-dropmarker {
+ display: none;
+}
+
+/* Content Tabs */
+.contentTabAddress {
+ height: 34px;
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
+@media (-moz-platform: windows-win7) and (-moz-windows-default-theme) {
+ @media (-moz-windows-glass: 0) {
+ :root[tabsintitlebar][sizemode=normal] #toolbar-menubar[autohide=true][inactive] ~
+ #tabs-toolbar > * {
+ margin-top: 6px;
+ }
+ }
+}
+
+@media (-moz-windows-glass) {
+ /* draw always a top border with Glass */
+ #tabs-toolbar {
+ --tabs-top-border-width: 1px;
+ }
+
+ .tab-background {
+ border-top-style: solid;
+ }
+}
+
+@media (-moz-windows-glass),
+ (-moz-platform: windows-win8) and (-moz-windows-default-theme) {
+ :root[sizemode=normal]:not([spacestoolbar]) .tabmail-tab[visuallyselected]:first-child::before {
+ content: '';
+ display: flex;
+ --lwt-tabs-border-color: var(--glassShadowColor);
+ }
+
+ :root[sizemode=normal] .tabmail-tab:not([visuallyselected]):first-child {
+ margin-inline-start: 1px;
+ }
+
+ :root[sizemode=normal] .tabmail-tab:first-child::before {
+ margin-inline-start: 0;
+ }
+}
+
+@media (-moz-platform: windows-win8) and (-moz-windows-default-theme) {
+ #messengerWindow[darkwindowframe="true"]
+ #tabs-toolbar:not(:-moz-lwtheme,:-moz-window-inactive),
+ #messengerWindow[darkwindowframe="true"]
+ .tabmail-tab:not([selected="true"],:-moz-lwtheme,:-moz-window-inactive) {
+ color: white;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/themeableDialog.css b/comm/mail/themes/windows/mail/themeableDialog.css
new file mode 100644
index 0000000000..e98a891779
--- /dev/null
+++ b/comm/mail/themes/windows/mail/themeableDialog.css
@@ -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/. */
+
+@import url("chrome://messenger/skin/shared/themeableDialog.css");
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+html|input {
+ padding: 2px 4px;
+}
+
+html|input[type="number"]::-moz-number-spin-up,
+html|input[type="number"]::-moz-number-spin-down {
+ min-height: 0.55em;
+}
+
+menulist[is="menulist-editable"][editable="true"]::part(text-input) {
+ padding: 2px 4px;
+ margin-block: -1px -2px;
+ margin-inline: -6px 4px;
+}
+
+@media not (-moz-windows-non-native-menus) {
+ menulist > menupopup {
+ --panel-background: var(--box-background-color);
+ --panel-border-radius: var(--arrowpanel-border-radius);
+ --panel-padding: var(--arrowpanel-padding);
+ }
+
+ button menupopup menu,
+ button menupopup menuitem,
+ menulist menupopup menu,
+ menulist menupopup menuitem {
+ appearance: none;
+ color: var(--box-text-color);
+ }
+
+ button menupopup > menu:not([disabled="true"])[_moz-menuactive="true"],
+ button menupopup > menuitem:not([disabled="true"])[_moz-menuactive="true"],
+ menulist menupopup > menu:not([disabled="true"])[_moz-menuactive="true"],
+ menulist menupopup > menuitem:not([disabled="true"])[_moz-menuactive="true"] {
+ color: var(--box-text-color);
+ background-color: color-mix(in srgb, currentColor 9%, transparent);
+ }
+
+ button menupopup > menu[disabled="true"],
+ button menupopup > menuitem[disabled="true"],
+ menulist menupopup > menu[disabled="true"],
+ menulist menupopup > menuitem[disabled="true"] {
+ color: #999;
+ /* override the [_moz-menuactive="true"] background color from
+ global/menu.css */
+ background-color: transparent;
+ }
+
+ menulist menupopup menu,
+ menulist menupopup menuitem,
+ button menupopup menu,
+ button menupopup menuitem {
+ padding-block: 2px;
+ }
+
+ menulist > menupopup > menuitem > .menu-iconic-left,
+ menulist > menupopup > menu > .menu-iconic-left {
+ display: flex;
+ }
+}
+
+menu > menupopup > menuitem,
+menu > menupopup > menu {
+ padding-inline: 5px;
+}
+
+.menu-right {
+ height: 12px;
+}
+
+.menu-right:-moz-locale-dir(rtl) {
+ transform: scaleX(-1);
+}
+
+.radio-label-box {
+ margin-inline-start: 0;
+ padding-inline-start: 0;
+}
+
+tabpanels {
+ color-scheme: light dark;
+}
diff --git a/comm/mail/themes/windows/mail/variables.css b/comm/mail/themes/windows/mail/variables.css
new file mode 100644
index 0000000000..aec12c7305
--- /dev/null
+++ b/comm/mail/themes/windows/mail/variables.css
@@ -0,0 +1,91 @@
+/* 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 url("chrome://messenger/skin/shared/variables.css");
+
+:root {
+ --autocomplete-popup-url-color: -moz-nativehyperlinktext;
+ --tabline-color: var(--selected-item-color);
+ --glassShadowColor: hsla(240, 5%, 5%, 0.3);
+ --toolbar-non-lwt-bgcolor: color-mix(in srgb, -moz-dialog 85%, white);
+ --toolbar-non-lwt-textcolor: -moz-dialogText;
+ --toolbar-non-lwt-bgimage: linear-gradient(rgba(255, 255, 255, 0.15),
+ rgba(255, 255, 255, 0.15));
+ --chrome-content-separator-color: ThreeDShadow;
+ --row-grouped-header-bg-color: -moz-dialog;
+ --row-grouped-header-bg-color-selected: var(--selected-item-color);
+ --panel-separator-color: ThreeDLightShadow;
+}
+
+@media (-moz-windows-default-theme) {
+ :root {
+ --tabline-color: #0a84ff;
+ --toolbar-non-lwt-bgcolor: #f9f9fa;
+ --toolbar-non-lwt-textcolor: #0c0c0d;
+ --toolbar-non-lwt-bgimage: none;
+ --panel-separator-color: hsla(210, 4%, 10%, 0.14);
+ --autocomplete-popup-url-color: hsl(210, 77%, 47%);
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:-moz-lwtheme {
+ --autocomplete-popup-highlight-color: var(--lwt-text-color);
+ --dark-lwt-highlight-color: #0a84ff;
+ }
+
+ @media (-moz-windows-non-native-menus) {
+ :root:not([lwt-tree]):-moz-lwtheme menupopup {
+ --panel-background: var(--arrowpanel-background) !important;
+ --panel-color: var(--arrowpanel-color) !important;
+ --panel-border-color: var(--arrowpanel-border-color) !important;
+ --menu-color: var(--arrowpanel-color) !important;
+ --menu-disabled-color: color-mix(in srgb, var(--arrowpanel-color) 35%, transparent) !important;
+ --menuitem-hover-background-color: color-mix(in srgb, var(--arrowpanel-color) 13%, transparent) !important;
+ }
+ }
+}
+
+@media (prefers-contrast) {
+ :root:not(:-moz-lwtheme) {
+ --lwt-tabs-border-color: ThreeDShadow;
+ --tabline-color: SelectedItem;
+ --item-focus-selected-border-color: SelectedItemText;
+ --new-folder-color: Highlight;
+ --menuitem-vertical-padding: 3px;
+ --arrowpanel-dimmed: SelectedItem;
+ --arrowpanel-dimmed-further: SelectedItem;
+ --toolbar-field-focus-border-color: SelectedItem;
+ }
+
+ :root:not(:-moz-lwtheme) .themeable-full,
+ :root:not(:-moz-lwtheme) .themeable-brighttext,
+ :root:not(:-moz-lwtheme) #navigation-toolbox > toolbar,
+ :root:not(:-moz-lwtheme) #todaypane-new-event-button,
+ :root:not(:-moz-lwtheme) #CardViewBox {
+ --toolbarbutton-hover-background: SelectedItem;
+ --toolbarbutton-hover-bordercolor: SelectedItemText !important;
+ --toolbarbutton-active-background: SelectedItem;
+ --toolbarbutton-active-bordercolor: SelectedItemText;
+ --toolbarbutton-checked-background: SelectedItem;
+ --toolbarbutton-icon-fill-attention: SelectedItem;
+ }
+
+ :root:not(:-moz-lwtheme) .toolbarbutton-1.message-header-view-button {
+ --toolbarbutton-header-bordercolor: WindowText;
+ --toolbarbutton-active-bordercolor: WindowText;
+ }
+
+ :root:not(:-moz-lwtheme) .toolbarbutton-1:not(.qfb-tag-button):hover,
+ :root:not(:-moz-lwtheme) #calendar-add-task-button:hover,
+ :root:not(:-moz-lwtheme) #todaypane-new-event-button:hover,
+ :root:not(:-moz-lwtheme) .toolbarbutton-1[checked="true"],
+ :root:not(:-moz-lwtheme) .toolbarbutton-menubutton-button:hover {
+ color: SelectedItemText !important;
+ }
+
+ menulist:not(:-moz-lwtheme) {
+ --toolbarbutton-hover-background: ButtonFace;
+ }
+}
diff --git a/comm/mail/themes/windows/mail/window-controls/close-highcontrast.svg b/comm/mail/themes/windows/mail/window-controls/close-highcontrast.svg
new file mode 100644
index 0000000000..b37b28a28b
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/close-highcontrast.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg">
+ <path stroke="context-stroke" stroke-width="1.9" fill="none" d="M1,1 l 10,10 M1,11 l 10,-10"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/close-themes.svg b/comm/mail/themes/windows/mail/window-controls/close-themes.svg
new file mode 100644
index 0000000000..e6eac2fc55
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/close-themes.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg">
+ <path stroke="black" stroke-width="3.6" stroke-opacity=".75" d="M1,1 l 10,10 M1,11 l 10,-10"/>
+ <path stroke="white" stroke-width="1.9" d="M1.75,1.75 l 8.5,8.5 M1.75,10.25 l 8.5,-8.5"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/close.svg b/comm/mail/themes/windows/mail/window-controls/close.svg
new file mode 100644
index 0000000000..9d0a252357
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/close.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg">
+ <path stroke="context-stroke" stroke-width=".9" fill="none" d="M1,1 l 10,10 M1,11 l 10,-10"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/maximize-highcontrast.svg b/comm/mail/themes/windows/mail/window-controls/maximize-highcontrast.svg
new file mode 100644
index 0000000000..48ea6166f3
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/maximize-highcontrast.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
+ <rect stroke="context-stroke" stroke-width="1.9" fill="none" x="2" y="2" width="8" height="8"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/maximize-themes.svg b/comm/mail/themes/windows/mail/window-controls/maximize-themes.svg
new file mode 100644
index 0000000000..5740a992ae
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/maximize-themes.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges" fill="none">
+ <rect stroke="black" stroke-width="3.6" stroke-opacity=".75" x="2" y="2" width="8" height="8"/>
+ <rect stroke="white" stroke-width="1.9" x="2" y="2" width="8" height="8"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/maximize.svg b/comm/mail/themes/windows/mail/window-controls/maximize.svg
new file mode 100644
index 0000000000..e9cc939af3
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/maximize.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
+ <rect stroke="context-stroke" stroke-width=".9" fill="none" x="1.5" y="1.5" width="9" height="9"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/minimize-highcontrast.svg b/comm/mail/themes/windows/mail/window-controls/minimize-highcontrast.svg
new file mode 100644
index 0000000000..2ba29a839d
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/minimize-highcontrast.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg">
+ <line stroke="context-stroke" stroke-width="1.9" fill="none" shape-rendering="crispEdges" x1="1" y1="6" x2="11" y2="6"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/minimize-themes.svg b/comm/mail/themes/windows/mail/window-controls/minimize-themes.svg
new file mode 100644
index 0000000000..d74f16bdbc
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/minimize-themes.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
+ <line stroke="black" stroke-width="3.6" stroke-opacity=".75" x1="0" y1="6" x2="12" y2="6"/>
+ <line stroke="white" stroke-width="1.9" x1="1" y1="6" x2="11" y2="6"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/minimize.svg b/comm/mail/themes/windows/mail/window-controls/minimize.svg
new file mode 100644
index 0000000000..7ffa1fecbb
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/minimize.svg
@@ -0,0 +1,6 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg">
+ <line stroke="context-stroke" stroke-width=".9" fill="none" shape-rendering="crispEdges" x1="1" y1="5.5" x2="11" y2="5.5"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/restore-highcontrast.svg b/comm/mail/themes/windows/mail/window-controls/restore-highcontrast.svg
new file mode 100644
index 0000000000..f2cdfa8a9c
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/restore-highcontrast.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg" stroke="context-stroke" stroke-width="1.9" fill="none" shape-rendering="crispEdges">
+ <rect x="2" y="4" width="6" height="6"/>
+ <polyline points="3.5,1.5 10.5,1.5 10.5,8.5" stroke-width=".9"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/restore-themes.svg b/comm/mail/themes/windows/mail/window-controls/restore-themes.svg
new file mode 100644
index 0000000000..e3c92f58a2
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/restore-themes.svg
@@ -0,0 +1,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 width="12" height="12" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges" fill="none" stroke="white">
+ <path stroke="black" stroke-width="3.6" stroke-opacity=".75" d="M2,4 l 6,0 l 0,6 l -6,0z M2.5,1.5 l 8,0 l 0,8"/>
+ <rect stroke-width="1.9" x="2" y="4" width="6" height="6"/>
+ <polyline stroke-width=".9" points="3.5,1.5 10.5,1.5 10.5,8.5"/>
+</svg>
diff --git a/comm/mail/themes/windows/mail/window-controls/restore.svg b/comm/mail/themes/windows/mail/window-controls/restore.svg
new file mode 100644
index 0000000000..80b71b178d
--- /dev/null
+++ b/comm/mail/themes/windows/mail/window-controls/restore.svg
@@ -0,0 +1,7 @@
+<!-- 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 width="12" height="12" xmlns="http://www.w3.org/2000/svg" stroke="context-stroke" stroke-width=".9" fill="none" shape-rendering="crispEdges">
+ <rect x="1.5" y="3.5" width="7" height="7"/>
+ <polyline points="3.5,3.5 3.5,1.5 10.5,1.5 10.5,8.5 8.5,8.5"/>
+</svg>
diff --git a/comm/mail/themes/windows/moz.build b/comm/mail/themes/windows/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/mail/themes/windows/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]